@@ -159,30 +159,50 @@ 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+
167+ for _ , file := range pass .Files {
168+ ast .Inspect (file , func (n ast.Node ) bool {
169+ if field , ok := n .(* ast.Field ); ok {
170+ if field .Doc != nil {
171+ fieldDocComments [field .Doc ] = field
174172 }
175173 }
176174
175+ return true
176+ })
177+ }
178+
179+ inspect .Preorder (nodeFilter , func (n ast.Node ) {
180+ switch typ := n .(type ) {
181+ case * ast.GenDecl :
182+ file := findFileForNode (typ , pass .Files )
177183 extractGenDeclMarkers (typ , file , pass .Fset , results )
178184 case * ast.Field :
179- extractFieldMarkers (typ , results )
185+ file := findFileForNode (typ , pass .Files )
186+ extractFieldMarkers (typ , file , pass .Fset , results , fieldDocComments )
180187 }
181188 })
182189
183190 return results , nil
184191}
185192
193+ // findFileForNode finds the file that contains the given AST node.
194+ // For most packages, there are only a few files (typically 1-10),
195+ // so a simple linear search is efficient and clear.
196+ func findFileForNode (node ast.Node , files []* ast.File ) * ast.File {
197+ for _ , f := range files {
198+ if f .Pos () <= node .Pos () && node .End () <= f .End () {
199+ return f
200+ }
201+ }
202+
203+ return nil
204+ }
205+
186206func extractGenDeclMarkers (typ * ast.GenDecl , file * ast.File , fset * token.FileSet , results * markers ) {
187207 declMarkers := NewMarkerSet ()
188208
@@ -219,7 +239,7 @@ func extractGenDeclMarkers(typ *ast.GenDecl, file *ast.File, fset *token.FileSet
219239// that are separated by a blank line. Only the immediately preceding comment group is checked,
220240// and it must be within maxMarkerSeparationLines lines of the godoc comment.
221241//
222- // This handles the "second level comment bug" (issue #53) where markers are separated from type
242+ // This handles the "second level comment bug" where markers are separated from type
223243// declarations by blank lines, which commonly occurs in real-world Kubernetes API code.
224244//
225245// Example scenario this handles:
@@ -259,6 +279,95 @@ func extractOrphanedMarkers(docGroup *ast.CommentGroup, file *ast.File, fset *to
259279 }
260280}
261281
282+ // extractOrphanedFieldMarkers finds markers in the comment group immediately before a field's doc comment
283+ // that are separated by a blank line. This is a specialized version for fields that is more conservative
284+ // than extractOrphanedMarkers to avoid picking up markers from previous fields.
285+ //
286+ // This handles the "second level comment bug" for struct fields where markers are separated
287+ // from field declarations by blank lines.
288+ //
289+ // Example scenario this handles:
290+ //
291+ // type FooStatus struct {
292+ // // +optional
293+ // // +listType=map
294+ // // +listMapKey=type
295+ // // +patchStrategy=merge
296+ // // +patchMergeKey=type
297+ //
298+ // // Conditions update as changes occur in the status.
299+ // Conditions []metav1.Condition `json:"conditions,omitempty"`
300+ // }
301+ //
302+ // The markers will be detected even though separated by a blank line from the field doc comment.
303+ func extractOrphanedFieldMarkers (docGroup * ast.CommentGroup , file * ast.File , fset * token.FileSet , fieldMarkers MarkerSet , fieldDocComments map [* ast.CommentGroup ]* ast.Field ) {
304+ if file == nil || fset == nil {
305+ return
306+ }
307+
308+ prevGroup := findPreviousCommentGroup (docGroup , file )
309+ if prevGroup == nil {
310+ return
311+ }
312+
313+ // For fields, only consider comment groups that contain ONLY markers (no prose documentation)
314+ // and are not Doc comments for other declarations or fields
315+ if ! isProperlySeparated (prevGroup , docGroup , fset ) {
316+ return
317+ }
318+
319+ if ! containsOnlyMarkers (prevGroup ) {
320+ return
321+ }
322+
323+ if isDocCommentForDeclaration (prevGroup , file ) || isDocCommentForField (prevGroup , fieldDocComments ) {
324+ return
325+ }
326+
327+ // Extract markers from the previous comment group
328+ for _ , comment := range prevGroup .List {
329+ if marker := extractMarker (comment ); marker .Identifier != "" {
330+ fieldMarkers .Insert (marker )
331+ }
332+ }
333+ }
334+
335+ // containsOnlyMarkers checks if a comment group contains ONLY markers and no prose documentation.
336+ // This is a stricter version of containsMarkers used for field orphaned marker detection.
337+ func containsOnlyMarkers (group * ast.CommentGroup ) bool {
338+ if len (group .List ) == 0 {
339+ return false
340+ }
341+
342+ hasMarker := false
343+
344+ // Every comment line must be a marker
345+ for _ , comment := range group .List {
346+ text := strings .TrimPrefix (comment .Text , "//" )
347+ text = strings .TrimSpace (text )
348+
349+ // Empty lines are OK (e.g., blank comment lines)
350+ if text == "" {
351+ continue
352+ }
353+
354+ // If it doesn't start with +, it's not a marker
355+ if ! strings .HasPrefix (text , "+" ) {
356+ return false
357+ }
358+
359+ // Check if this is a valid marker using regex (more efficient than full parsing)
360+ markerContent := strings .TrimPrefix (text , "+" )
361+ if ! validMarkerStart .MatchString (markerContent ) {
362+ return false
363+ }
364+
365+ hasMarker = true
366+ }
367+
368+ return hasMarker
369+ }
370+
262371// findPreviousCommentGroup finds the comment group immediately before the given docGroup.
263372func findPreviousCommentGroup (docGroup * ast.CommentGroup , file * ast.File ) * ast.CommentGroup {
264373 for i , cg := range file .Comments {
@@ -464,18 +573,31 @@ func isDocCommentForDeclaration(group *ast.CommentGroup, file *ast.File) bool {
464573 return false
465574}
466575
467- func extractFieldMarkers (field * ast.Field , results * markers ) {
468- if field == nil || field .Doc == nil {
469- return
470- }
576+ // isDocCommentForField checks if the comment group is a Doc comment for any field.
577+ // Uses a pre-computed map for O(1) lookup instead of O(n) AST traversal.
578+ func isDocCommentForField (group * ast.CommentGroup , fieldDocComments map [* ast.CommentGroup ]* ast.Field ) bool {
579+ _ , found := fieldDocComments [group ]
580+ return found
581+ }
471582
583+ func extractFieldMarkers (field * ast.Field , file * ast.File , fset * token.FileSet , results * markers , fieldDocComments map [* ast.CommentGroup ]* ast.Field ) {
472584 fieldMarkers := NewMarkerSet ()
473585
474- for _ , comment := range field .Doc .List {
475- marker := extractMarker (comment )
476- if marker .Identifier != "" {
477- fieldMarkers .Insert (marker )
586+ // Extract markers from the field's Doc field (comments directly attached to the field)
587+ if field != nil && field .Doc != nil {
588+ for _ , comment := range field .Doc .List {
589+ marker := extractMarker (comment )
590+ if marker .Identifier != "" {
591+ fieldMarkers .Insert (marker )
592+ }
478593 }
594+
595+ // Also collect markers from the comment group immediately before the field's doc comment
596+ // if separated by a blank line (orphaned markers).
597+ // For fields, we use a specialized version that only checks if the markers are immediately
598+ // above the doc comment (within the same logical block) to avoid picking up markers from
599+ // previous fields.
600+ extractOrphanedFieldMarkers (field .Doc , file , fset , fieldMarkers , fieldDocComments )
479601 }
480602
481603 results .insertFieldMarkers (field , fieldMarkers )
0 commit comments