internal/commentpolicy/comments.go
1
package commentpolicy
3
import (
4
"bytes"
5
"errors"
6
"fmt"
7
"go/ast"
8
"go/parser"
9
"go/token"
10
"io/fs"
11
"os"
12
"path"
13
"sort"
14
"strings"
15
"unicode"
17
"github.com/koment-dev/koment/internal/anchor"
18
"github.com/koment-dev/koment/internal/policy"
19
"github.com/koment-dev/koment/internal/store"
20
)
22
// SourceComment is an exact syntactic comment group in one source file.
23
type SourceComment struct {
24
File string
25
Raw string
26
Body string
27
Line int
28
Start int
29
End int
30
}
32
// Violation is a prohibited comment and the action that resolves it.
33
type Violation struct {
34
Comment SourceComment
35
Reason string
36
}
38
// Conversion is the source edit and deterministic code anchor for comment intent.
39
type Conversion struct {
40
Content []byte
41
Body string
42
Excerpt string
43
}
45
// IsCommentIntent distinguishes prose from text that parses as Go statements.
46
func IsCommentIntent(comment SourceComment) bool {
47
body := strings.TrimSpace(comment.Body)
48
if body == "" {
49
return false
50
}
51
candidate := "package intent\nfunc inspect() {\n" + body + "\n}\n"
52
_, err := parser.ParseFile(token.NewFileSet(), comment.File, candidate, parser.SkipObjectResolution)
53
return err != nil
54
}
56
// Check scans supported source and returns every prohibited comment.
57
func Check(rootPath string, configured policy.Policy, requested []string) (_ []Violation, returnedError error) {
58
root, err := os.OpenRoot(rootPath)
59
if err != nil {
60
return nil, fmt.Errorf("opening repository root %s: %w", rootPath, err)
61
}
62
defer func() {
63
if closeErr := root.Close(); closeErr != nil {
64
returnedError = errors.Join(returnedError, closeErr)
65
}
66
}()
68
files, err := sourceFiles(root, configured, requested)
69
if err != nil {
70
return nil, err
71
}
72
records, err := store.Open(rootPath).All()
73
if err != nil {
74
return nil, err
75
}
77
var violations []Violation
78
for _, file := range files {
79
content, err := root.ReadFile(file)
80
if err != nil {
81
return nil, fmt.Errorf("reading %s: %w", file, err)
82
}
83
found, err := CheckContent(file, content, configured, records)
84
if err != nil {
85
return nil, err
86
}
87
violations = append(violations, found...)
88
}
89
return violations, nil
90
}
92
// CheckContent applies comment policy to an in-memory source document.
93
func CheckContent(file string, content []byte, configured policy.Policy, records []store.Annotation) ([]Violation, error) {
94
comments, intrinsic, err := scan(file, content, configured)
95
if err != nil {
96
return nil, err
97
}
98
violations := make([]Violation, 0, len(comments))
99
for index, comment := range comments {
100
if intrinsic[index] || acknowledged(comment, content, records) {
101
continue
102
}
103
violations = append(violations, Violation{
104
Comment: comment,
105
Reason: "convert with `koment comments convert` or retain with `koment comments acknowledge --acknowledge-inline-comment`",
106
})
107
}
108
return violations, nil
109
}
111
// Find returns the one comment group matching a verbatim excerpt.
112
func Find(file string, content []byte, excerpt string) (SourceComment, error) {
113
comments, _, err := scan(file, content, policy.Default())
114
if err != nil {
115
return SourceComment{}, err
116
}
117
var matches []SourceComment
118
for _, comment := range comments {
119
if comment.Raw == excerpt || strings.TrimSpace(comment.Raw) == strings.TrimSpace(excerpt) {
120
matches = append(matches, comment)
121
}
122
}
123
switch len(matches) {
124
case 0:
125
return SourceComment{}, fmt.Errorf("comment excerpt does not match a complete syntactic comment group in %s", file)
126
case 1:
127
return matches[0], nil
128
default:
129
return SourceComment{}, fmt.Errorf("comment excerpt matches %d groups in %s; include the complete distinct group", len(matches), file)
130
}
131
}
133
// Convert removes one comment and selects nearby code as its annotation anchor.
134
func Convert(content []byte, comment SourceComment) (Conversion, error) {
135
converted, removedAt := remove(content, comment)
136
if err := stillParses(comment.File, converted); err != nil {
137
return Conversion{}, err
138
}
139
excerpt, err := codeAnchor(comment.File, converted, content, removedAt)
140
if err != nil {
141
return Conversion{}, err
142
}
143
return Conversion{Content: converted, Body: comment.Body, Excerpt: excerpt}, nil
144
}
146
// AcknowledgementExcerpt selects a unique anchor that contains the retained comment.
147
func AcknowledgementExcerpt(content []byte, comment SourceComment) (string, error) {
148
if _, _, err := anchor.Capture(content, comment.Raw); err == nil {
149
return comment.Raw, nil
150
}
151
starts := lineStarts(content)
152
startLine := lineOf(starts, comment.Start)
153
endLine := lineOf(starts, comment.End-1)
154
for radius := 0; radius <= 5; radius++ {
155
first := max(0, startLine-radius)
156
last := min(len(starts)-1, endLine+radius)
157
excerpt := strings.TrimSpace(string(content[starts[first]:lineEnd(content, starts, last)]))
158
if strings.Contains(excerpt, comment.Raw) {
159
if _, _, err := anchor.Capture(content, excerpt); err == nil {
160
return excerpt, nil
161
}
162
}
163
}
164
return "", fmt.Errorf("the retained comment cannot be anchored uniquely; make the comment or surrounding code more specific")
165
}
167
// Detector finds the comment groups one filetype exposes and says which of them
168
// its toolchain requires. koment ships two: a Go parser that recognises godoc
169
// on exported identifiers, and a marker scan over the syntax table that reaches
170
// every other filetype (ADR 0114, ADR 0132).
171
type Detector interface {
172
Handles(file string) bool
173
Name() string
174
Scan(file string, content []byte, configured policy.Policy) ([]SourceComment, []bool, error)
175
}
177
var detectors = []Detector{goDetector{}, markerDetector{}}
179
func detectorFor(file string) Detector {
180
for _, candidate := range detectors {
181
if candidate.Handles(file) {
182
return candidate
183
}
184
}
185
return nil
186
}
188
// Detects reports whether any language koment understands claims this file.
189
func Detects(file string) bool {
190
return detectorFor(file) != nil
191
}
193
func scan(file string, content []byte, configured policy.Policy) ([]SourceComment, []bool, error) {
194
detector := detectorFor(file)
195
if detector == nil {
196
return nil, nil, fmt.Errorf("no comment detector for %s", file)
197
}
198
return detector.Scan(file, content, configured)
199
}
201
type goDetector struct{}
203
func (goDetector) Handles(file string) bool { return strings.HasSuffix(file, goExtension) }
205
func (goDetector) Name() string { return "go/ast" }
207
func (goDetector) Scan(file string, content []byte, configured policy.Policy) ([]SourceComment, []bool, error) {
208
files := token.NewFileSet()
209
parsed, err := parser.ParseFile(files, file, content, parser.ParseComments|parser.SkipObjectResolution)
210
if err != nil {
211
return nil, nil, fmt.Errorf("parsing %s: %w", file, err)
212
}
213
public := publicDocumentation(parsed)
214
comments := make([]SourceComment, 0, len(parsed.Comments))
215
intrinsic := make([]bool, 0, len(parsed.Comments))
216
for _, group := range parsed.Comments {
217
start := files.Position(group.Pos())
218
end := files.Position(group.End())
219
raw := string(content[start.Offset:end.Offset])
220
comments = append(comments, SourceComment{
221
File: file, Raw: raw, Body: commentBody(raw), Line: start.Line,
222
Start: start.Offset, End: end.Offset,
223
})
224
intrinsic = append(intrinsic, isIntrinsic(group, raw, public[group], configured))
225
}
226
return comments, intrinsic, nil
227
}
229
func publicDocumentation(file *ast.File) map[*ast.CommentGroup]bool {
230
public := map[*ast.CommentGroup]bool{}
231
mark := func(group *ast.CommentGroup) {
232
if group != nil {
233
public[group] = true
234
}
235
}
236
mark(file.Doc)
237
for _, declaration := range file.Decls {
238
switch value := declaration.(type) {
239
case *ast.FuncDecl:
240
if ast.IsExported(value.Name.Name) {
241
mark(value.Doc)
242
}
243
case *ast.GenDecl:
244
if exportedSpecs(value.Specs) {
245
mark(value.Doc)
246
}
247
for _, specification := range value.Specs {
248
switch declared := specification.(type) {
249
case *ast.TypeSpec:
250
if ast.IsExported(declared.Name.Name) {
251
mark(declared.Doc)
252
mark(declared.Comment)
253
}
254
case *ast.ValueSpec:
255
if exportedNames(declared.Names) {
256
mark(declared.Doc)
257
mark(declared.Comment)
258
}
259
}
260
}
261
}
262
}
263
ast.Inspect(file, func(node ast.Node) bool {
264
field, ok := node.(*ast.Field)
265
if ok && exportedNames(field.Names) {
266
mark(field.Doc)
267
mark(field.Comment)
268
}
269
return true
270
})
271
return public
272
}
274
func exportedSpecs(specifications []ast.Spec) bool {
275
for _, specification := range specifications {
276
switch value := specification.(type) {
277
case *ast.TypeSpec:
278
if ast.IsExported(value.Name.Name) {
279
return true
280
}
281
case *ast.ValueSpec:
282
if exportedNames(value.Names) {
283
return true
284
}
285
}
286
}
287
return false
288
}
290
func exportedNames(names []*ast.Ident) bool {
291
for _, name := range names {
292
if ast.IsExported(name.Name) {
293
return true
294
}
295
}
296
return false
297
}
299
func isIntrinsic(group *ast.CommentGroup, raw string, public bool, configured policy.Policy) bool {
300
switch {
301
case public && configured.Allows(policy.IntrinsicPublicAPI):
302
return true
303
case configured.Allows(policy.IntrinsicDeprecated) && strings.Contains(group.Text(), "Deprecated:"):
304
return true
305
case configured.Allows(policy.IntrinsicUpstreamLink) &&
306
(strings.Contains(raw, "https://") || strings.Contains(raw, "http://")):
307
return true
308
case configured.Allows(policy.IntrinsicGeneratedMarker) &&
309
strings.Contains(raw, "Code generated") && strings.Contains(raw, "DO NOT EDIT."):
310
return true
311
case configured.Allows(policy.IntrinsicToolchain) && directivesOnly(group):
312
return true
313
case configured.MatchesAllowedAnnotation(commentBody(raw)):
314
return true
315
default:
316
return false
317
}
318
}
320
func directivesOnly(group *ast.CommentGroup) bool {
321
for _, comment := range group.List {
322
text := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(comment.Text, "//"), "/*"))
323
text = strings.TrimSpace(strings.TrimSuffix(text, "*/"))
324
if !hasDirectivePrefix(text) {
325
return false
326
}
327
}
328
return len(group.List) > 0
329
}
331
func hasDirectivePrefix(text string) bool {
332
prefixes := []string{"go:", "+build", "line ", "nolint", "lint:", "revive:", "gosec", "export ", "#cgo"}
333
for _, prefix := range prefixes {
334
if strings.HasPrefix(text, prefix) {
335
return true
336
}
337
}
338
return false
339
}
341
func acknowledged(comment SourceComment, content []byte, records []store.Annotation) bool {
342
for _, record := range records {
343
if record.Spec.Target.File != comment.File || record.Spec.Policy == nil ||
344
record.Spec.Policy.Exception != "inline-comment" || !record.Spec.Policy.Acknowledged ||
345
!strings.Contains(record.Spec.Anchor.Excerpt, comment.Raw) {
346
continue
347
}
348
if !anchor.Resolve(record, content).Status.IsFailure() {
349
return true
350
}
351
}
352
return false
353
}
355
func sourceFiles(root *os.Root, configured policy.Policy, requested []string) ([]string, error) {
356
starts := requested
357
if len(starts) == 0 {
358
starts = []string{"."}
359
}
360
seen := map[string]bool{}
361
for _, requestedPath := range starts {
362
clean, err := sourcePath(requestedPath)
363
if err != nil {
364
return nil, err
365
}
366
if err := fs.WalkDir(root.FS(), clean, func(file string, entry fs.DirEntry, walkErr error) error {
367
if walkErr != nil {
368
return walkErr
369
}
370
if entry.IsDir() && (file == ".git" || file == ".koment" || configured.Excludes(file+"/placeholder")) {
371
return fs.SkipDir
372
}
373
if !entry.IsDir() && Detects(file) && !configured.Excludes(file) {
374
seen[file] = true
375
}
376
return nil
377
}); err != nil {
378
return nil, fmt.Errorf("walking %s: %w", clean, err)
379
}
380
}
381
files := make([]string, 0, len(seen))
382
for file := range seen {
383
files = append(files, file)
384
}
385
sort.Strings(files)
386
return files, nil
387
}
389
func sourcePath(value string) (string, error) {
390
if strings.Contains(value, `\`) || strings.HasPrefix(value, "/") {
391
return "", fmt.Errorf("source path %s must be repository-relative and use forward slashes", value)
392
}
393
clean := path.Clean(value)
394
if clean == ".." || strings.HasPrefix(clean, "../") {
395
return "", fmt.Errorf("source path %s escapes the repository", value)
396
}
397
return clean, nil
398
}
400
func commentBody(raw string) string {
401
lines := strings.Split(raw, "\n")
402
cleaned := make([]string, 0, len(lines))
403
for _, line := range lines {
404
line = strings.TrimSpace(line)
405
line = strings.TrimSpace(strings.TrimPrefix(line, "//"))
406
line = strings.TrimSpace(strings.TrimPrefix(line, "/*"))
407
line = strings.TrimSpace(strings.TrimSuffix(line, "*/"))
408
line = strings.TrimSpace(strings.TrimPrefix(line, "*"))
409
cleaned = append(cleaned, line)
410
}
411
return strings.TrimSpace(strings.Join(cleaned, "\n"))
412
}
414
func remove(content []byte, comment SourceComment) ([]byte, int) {
415
start, end := comment.Start, comment.End
416
lineStart := bytes.LastIndexByte(content[:start], '\n') + 1
417
lineEnd := len(content)
418
if next := bytes.IndexByte(content[end:], '\n'); next >= 0 {
419
lineEnd = end + next + 1
420
}
421
beforeOnlySpace := len(bytes.TrimSpace(content[lineStart:start])) == 0
422
afterEnd := lineEnd
423
if afterEnd > end && content[afterEnd-1] == '\n' {
424
afterEnd--
425
}
426
afterOnlySpace := len(bytes.TrimSpace(content[end:afterEnd])) == 0
427
if beforeOnlySpace && afterOnlySpace {
428
converted := append([]byte{}, content[:lineStart]...)
429
converted = append(converted, content[lineEnd:]...)
430
return converted, lineStart
431
}
432
for start > lineStart && (content[start-1] == ' ' || content[start-1] == '\t') {
433
start--
434
}
435
converted := append([]byte{}, content[:start]...)
436
if start > 0 && end < len(content) && !unicode.IsSpace(rune(content[start-1])) && !unicode.IsSpace(rune(content[end])) {
437
converted = append(converted, ' ')
438
}
439
converted = append(converted, content[end:]...)
440
return converted, start
441
}
443
func stillParses(file string, converted []byte) error {
444
if !strings.HasSuffix(file, ".go") {
445
return nil
446
}
447
if _, err := parser.ParseFile(token.NewFileSet(), file, converted, parser.SkipObjectResolution); err != nil {
448
return fmt.Errorf("removing the comment would leave invalid Go source: %w", err)
449
}
450
return nil
451
}
453
func startsComment(file, excerpt string) bool {
454
syntax, commentable := syntaxFor(file)
455
if !commentable {
456
return false
457
}
458
for _, marker := range append(append([]string{}, syntax.line...), "//", "/*") {
459
if strings.HasPrefix(excerpt, marker) {
460
return true
461
}
462
}
463
for _, block := range syntax.block {
464
if strings.HasPrefix(excerpt, block.open) {
465
return true
466
}
467
}
468
return false
469
}
471
func codeAnchor(file string, content, original []byte, near int) (string, error) {
472
starts := lineStarts(content)
473
line := lineOf(starts, min(near, max(0, len(content)-1)))
474
for distance := 0; distance < len(starts); distance++ {
475
candidates := []int{line + distance}
476
if distance > 0 {
477
candidates = append(candidates, line-distance)
478
}
479
for _, candidate := range candidates {
480
if candidate < 0 || candidate >= len(starts) {
481
continue
482
}
483
for radius := 0; radius <= 5 && candidate+radius < len(starts); radius++ {
484
excerpt := strings.TrimSpace(string(content[starts[candidate]:lineEnd(content, starts, candidate+radius)]))
485
if excerpt == "" || startsComment(file, excerpt) {
486
continue
487
}
488
if _, _, err := anchor.Capture(content, excerpt); err == nil {
489
if _, _, originalErr := anchor.Capture(original, excerpt); originalErr == nil {
490
return excerpt, nil
491
}
492
}
493
}
494
}
495
}
496
return "", fmt.Errorf("cannot select a unique code anchor after removing the comment")
497
}
499
func lineStarts(content []byte) []int {
500
starts := []int{0}
501
for index, character := range content {
502
if character == '\n' && index+1 < len(content) {
503
starts = append(starts, index+1)
504
}
505
}
506
return starts
507
}
509
func lineOf(starts []int, offset int) int {
510
index := sort.Search(len(starts), func(index int) bool { return starts[index] > offset })
511
return max(0, index-1)
512
}
514
func lineEnd(content []byte, starts []int, line int) int {
515
if line+1 < len(starts) {
516
return starts[line+1] - 1
517
}
518
return len(content)
519
}