snapshot of c000b74a5b09c433f96a69fa102c5c8f6583dff0 Annotations about the code that implements koment.

internal/commentpolicy/comments.go

1 package commentpolicy
2
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"
16
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 )
21
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 }
31
32 // Violation is a prohibited comment and the action that resolves it.
33 type Violation struct {
34 Comment SourceComment
35 Reason string
36 }
37
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 }
44
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 }
55
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 }()
67
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 }
76
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 }
91
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 }
110
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 }
132
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 }
145
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 }
166
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 }
176
177 var detectors = []Detector{goDetector{}, markerDetector{}}
178
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 }
187
188 // Detects reports whether any language koment understands claims this file.
189 func Detects(file string) bool {
190 return detectorFor(file) != nil
191 }
192
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 }
200
201 type goDetector struct{}
202
203 func (goDetector) Handles(file string) bool { return strings.HasSuffix(file, goExtension) }
204
205 func (goDetector) Name() string { return "go/ast" }
206
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 }
228
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 }
273
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 }
289
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 }
298
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 }
319
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 }
330
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 }
340
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 }
354
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 }
388
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 }
399
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 }
413
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 }
442
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 }
452
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 }
470
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 }
498
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 }
508
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 }
513
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 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close