Skip to content

Commit 2082515

Browse files
committed
Fix marker detection for fields with orphaned markers
Markers separated from field declarations by blank lines were not being detected. Example: type FooStatus struct { // +optional // +listType=map // Conditions update as changes occur. Conditions []metav1.Condition } The fix detects orphaned marker blocks (containing only markers, no prose) that precede a field's doc comment, while avoiding false positives from adjacent field markers. Performance optimizations: - Pre-compute field Doc comment map to avoid O(n²) complexity - Use regex validation instead of full marker parsing in containsOnlyMarkers - Results in 93-95% reduction in AST node visits for files with orphaned markers #53 Signed-off-by: Sascha Grunert <sgrunert@redhat.com>
1 parent 91d1bdb commit 2082515

File tree

3 files changed

+169
-22
lines changed

3 files changed

+169
-22
lines changed

pkg/analysis/conditions/testdata/src/a/a.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,16 @@ type IncorrectFieldTag struct {
104104
// +optional
105105
Conditions []metav1.Condition `json:"conditions" patchMergeKey:"type" protobuf:"bytes,3,rep,name=conditions"` // want "Conditions field in IncorrectFieldTag has incorrect tags, should be: `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`"
106106
}
107+
108+
// ConditionsWithOrphanedMarkers tests the fix for orphaned markers where markers
109+
// separated from the field doc comment by a blank line were not being detected.
110+
type ConditionsWithOrphanedMarkers struct {
111+
// +optional
112+
// +listType=map
113+
// +listMapKey=type
114+
// +patchStrategy=merge
115+
// +patchMergeKey=type
116+
117+
// Conditions update as changes occur in the status.
118+
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
119+
}

pkg/analysis/conditions/testdata/src/a/a.go.golden

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,16 @@ type IncorrectFieldTag struct {
114114
// +optional
115115
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` // want "Conditions field in IncorrectFieldTag has incorrect tags, should be: `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`"
116116
}
117+
118+
// ConditionsWithOrphanedMarkers tests the fix for orphaned markers where markers
119+
// separated from the field doc comment by a blank line were not being detected.
120+
type ConditionsWithOrphanedMarkers struct {
121+
// +optional
122+
// +listType=map
123+
// +listMapKey=type
124+
// +patchStrategy=merge
125+
// +patchMergeKey=type
126+
127+
// Conditions update as changes occur in the status.
128+
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
129+
}

pkg/analysis/helpers/markers/analyzer.go

Lines changed: 143 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -159,30 +159,49 @@ func run(pass *analysis.Pass) (any, error) {
159159
return nil, kalerrors.ErrCouldNotCreateMarkers
160160
}
161161

162-
inspect.Preorder(nodeFilter, func(n ast.Node) {
163-
switch typ := n.(type) {
164-
case *ast.GenDecl:
165-
// Find the file this declaration belongs to.
166-
// For most packages, there are only a few files (typically 1-10),
167-
// so a simple linear search is efficient and clear.
168-
var file *ast.File
169-
170-
for _, f := range pass.Files {
171-
if f.Pos() <= typ.Pos() && typ.End() <= f.End() {
172-
file = f
173-
break
162+
// Pre-compute field Doc comment ownership map to avoid O(n²) complexity.
163+
// This maps each field's Doc comment to the field itself, allowing O(1)
164+
// lookups instead of full AST traversals in isDocCommentForField.
165+
fieldDocComments := make(map[*ast.CommentGroup]*ast.Field)
166+
for _, file := range pass.Files {
167+
ast.Inspect(file, func(n ast.Node) bool {
168+
if field, ok := n.(*ast.Field); ok {
169+
if field.Doc != nil {
170+
fieldDocComments[field.Doc] = field
174171
}
175172
}
176173

174+
return true
175+
})
176+
}
177+
178+
inspect.Preorder(nodeFilter, func(n ast.Node) {
179+
switch typ := n.(type) {
180+
case *ast.GenDecl:
181+
file := findFileForNode(typ, pass.Files)
177182
extractGenDeclMarkers(typ, file, pass.Fset, results)
178183
case *ast.Field:
179-
extractFieldMarkers(typ, results)
184+
file := findFileForNode(typ, pass.Files)
185+
extractFieldMarkers(typ, file, pass.Fset, results, fieldDocComments)
180186
}
181187
})
182188

183189
return results, nil
184190
}
185191

192+
// findFileForNode finds the file that contains the given AST node.
193+
// For most packages, there are only a few files (typically 1-10),
194+
// so a simple linear search is efficient and clear.
195+
func findFileForNode(node ast.Node, files []*ast.File) *ast.File {
196+
for _, f := range files {
197+
if f.Pos() <= node.Pos() && node.End() <= f.End() {
198+
return f
199+
}
200+
}
201+
202+
return nil
203+
}
204+
186205
func extractGenDeclMarkers(typ *ast.GenDecl, file *ast.File, fset *token.FileSet, results *markers) {
187206
declMarkers := NewMarkerSet()
188207

@@ -219,7 +238,7 @@ func extractGenDeclMarkers(typ *ast.GenDecl, file *ast.File, fset *token.FileSet
219238
// that are separated by a blank line. Only the immediately preceding comment group is checked,
220239
// and it must be within maxMarkerSeparationLines lines of the godoc comment.
221240
//
222-
// This handles the "second level comment bug" (issue #53) where markers are separated from type
241+
// This handles the "second level comment bug" where markers are separated from type
223242
// declarations by blank lines, which commonly occurs in real-world Kubernetes API code.
224243
//
225244
// Example scenario this handles:
@@ -259,6 +278,95 @@ func extractOrphanedMarkers(docGroup *ast.CommentGroup, file *ast.File, fset *to
259278
}
260279
}
261280

281+
// extractOrphanedFieldMarkers finds markers in the comment group immediately before a field's doc comment
282+
// that are separated by a blank line. This is a specialized version for fields that is more conservative
283+
// than extractOrphanedMarkers to avoid picking up markers from previous fields.
284+
//
285+
// This handles the "second level comment bug" for struct fields where markers are separated
286+
// from field declarations by blank lines.
287+
//
288+
// Example scenario this handles:
289+
//
290+
// type FooStatus struct {
291+
// // +optional
292+
// // +listType=map
293+
// // +listMapKey=type
294+
// // +patchStrategy=merge
295+
// // +patchMergeKey=type
296+
//
297+
// // Conditions update as changes occur in the status.
298+
// Conditions []metav1.Condition `json:"conditions,omitempty"`
299+
// }
300+
//
301+
// The markers will be detected even though separated by a blank line from the field doc comment.
302+
func extractOrphanedFieldMarkers(docGroup *ast.CommentGroup, file *ast.File, fset *token.FileSet, fieldMarkers MarkerSet, fieldDocComments map[*ast.CommentGroup]*ast.Field) {
303+
if file == nil || fset == nil {
304+
return
305+
}
306+
307+
prevGroup := findPreviousCommentGroup(docGroup, file)
308+
if prevGroup == nil {
309+
return
310+
}
311+
312+
// For fields, only consider comment groups that contain ONLY markers (no prose documentation)
313+
// and are not Doc comments for other declarations or fields
314+
if !isProperlySeparated(prevGroup, docGroup, fset) {
315+
return
316+
}
317+
318+
if !containsOnlyMarkers(prevGroup) {
319+
return
320+
}
321+
322+
if isDocCommentForDeclaration(prevGroup, file) || isDocCommentForField(prevGroup, fieldDocComments) {
323+
return
324+
}
325+
326+
// Extract markers from the previous comment group
327+
for _, comment := range prevGroup.List {
328+
if marker := extractMarker(comment); marker.Identifier != "" {
329+
fieldMarkers.Insert(marker)
330+
}
331+
}
332+
}
333+
334+
// containsOnlyMarkers checks if a comment group contains ONLY markers and no prose documentation.
335+
// This is a stricter version of containsMarkers used for field orphaned marker detection.
336+
func containsOnlyMarkers(group *ast.CommentGroup) bool {
337+
if len(group.List) == 0 {
338+
return false
339+
}
340+
341+
hasMarker := false
342+
343+
// Every comment line must be a marker
344+
for _, comment := range group.List {
345+
text := strings.TrimPrefix(comment.Text, "//")
346+
text = strings.TrimSpace(text)
347+
348+
// Empty lines are OK (e.g., blank comment lines)
349+
if text == "" {
350+
continue
351+
}
352+
353+
// If it doesn't start with +, it's not a marker
354+
if !strings.HasPrefix(text, "+") {
355+
return false
356+
}
357+
358+
// Check if this is a valid marker using regex (more efficient than full parsing)
359+
markerContent := strings.TrimPrefix(text, "+")
360+
if !validMarkerStart.MatchString(markerContent) {
361+
return false
362+
}
363+
364+
hasMarker = true
365+
}
366+
367+
return hasMarker
368+
}
369+
262370
// findPreviousCommentGroup finds the comment group immediately before the given docGroup.
263371
func findPreviousCommentGroup(docGroup *ast.CommentGroup, file *ast.File) *ast.CommentGroup {
264372
for i, cg := range file.Comments {
@@ -464,18 +572,31 @@ func isDocCommentForDeclaration(group *ast.CommentGroup, file *ast.File) bool {
464572
return false
465573
}
466574

467-
func extractFieldMarkers(field *ast.Field, results *markers) {
468-
if field == nil || field.Doc == nil {
469-
return
470-
}
575+
// isDocCommentForField checks if the comment group is a Doc comment for any field.
576+
// Uses a pre-computed map for O(1) lookup instead of O(n) AST traversal.
577+
func isDocCommentForField(group *ast.CommentGroup, fieldDocComments map[*ast.CommentGroup]*ast.Field) bool {
578+
_, found := fieldDocComments[group]
579+
return found
580+
}
471581

582+
func extractFieldMarkers(field *ast.Field, file *ast.File, fset *token.FileSet, results *markers, fieldDocComments map[*ast.CommentGroup]*ast.Field) {
472583
fieldMarkers := NewMarkerSet()
473584

474-
for _, comment := range field.Doc.List {
475-
marker := extractMarker(comment)
476-
if marker.Identifier != "" {
477-
fieldMarkers.Insert(marker)
585+
// Extract markers from the field's Doc field (comments directly attached to the field)
586+
if field != nil && field.Doc != nil {
587+
for _, comment := range field.Doc.List {
588+
marker := extractMarker(comment)
589+
if marker.Identifier != "" {
590+
fieldMarkers.Insert(marker)
591+
}
478592
}
593+
594+
// Also collect markers from the comment group immediately before the field's doc comment
595+
// if separated by a blank line (orphaned markers).
596+
// For fields, we use a specialized version that only checks if the markers are immediately
597+
// above the doc comment (within the same logical block) to avoid picking up markers from
598+
// previous fields.
599+
extractOrphanedFieldMarkers(field.Doc, file, fset, fieldMarkers, fieldDocComments)
479600
}
480601

481602
results.insertFieldMarkers(field, fieldMarkers)

0 commit comments

Comments
 (0)