Skip to content

Commit 9268fb4

Browse files
tae2089claude
andcommitted
feat: add unannotated symbol detection to lint command
ccg lint now also reports functions, classes, and types that have no annotations. Test nodes are excluded since they don't need annotations. Example output: Unannotated symbols (3) — no @intent or other annotations: internal/auth/login.go::Login internal/payment/pay.go::Pay pkg/service.go::Config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e7b18fc commit 9268fb4

4 files changed

Lines changed: 167 additions & 7 deletions

File tree

internal/cli/lint.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func newLintCmd(deps *Deps) *cobra.Command {
3939
}
4040

4141
out := stdout(cmd)
42-
total := len(report.Orphans) + len(report.Missing) + len(report.Stale)
42+
total := len(report.Orphans) + len(report.Missing) + len(report.Stale) + len(report.Unannotated)
4343

4444
if len(report.Orphans) > 0 {
4545
fmt.Fprintf(out, "Orphan docs (%d) — no matching source in graph:\n", len(report.Orphans))
@@ -65,11 +65,19 @@ func newLintCmd(deps *Deps) *cobra.Command {
6565
fmt.Fprintln(out)
6666
}
6767

68+
if len(report.Unannotated) > 0 {
69+
fmt.Fprintf(out, "Unannotated symbols (%d) — no @intent or other annotations:\n", len(report.Unannotated))
70+
for _, qn := range report.Unannotated {
71+
fmt.Fprintf(out, " %s\n", qn)
72+
}
73+
fmt.Fprintln(out)
74+
}
75+
6876
if total == 0 {
6977
fmt.Fprintln(out, "All docs are clean — 0 issues found.")
7078
} else {
71-
fmt.Fprintf(out, "Summary: %d orphan, %d missing, %d stale\n",
72-
len(report.Orphans), len(report.Missing), len(report.Stale))
79+
fmt.Fprintf(out, "Summary: %d orphan, %d missing, %d stale, %d unannotated\n",
80+
len(report.Orphans), len(report.Missing), len(report.Stale), len(report.Unannotated))
7381
}
7482

7583
return nil

internal/cli/lint_test.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,18 @@ func TestLintCommand_ReportsStale(t *testing.T) {
110110
func TestLintCommand_CleanReport(t *testing.T) {
111111
deps, stdout, stderr, db := setupLintTest(t)
112112

113-
db.Create(&model.Node{
113+
fn := model.Node{
114114
QualifiedName: "pkg/ok.go::Fn",
115115
Kind: model.NodeKindFunction,
116116
Name: "Fn",
117117
FilePath: "pkg/ok.go",
118118
StartLine: 1, EndLine: 5,
119119
Hash: "h1", Language: "go",
120+
}
121+
db.Create(&fn)
122+
db.Create(&model.Annotation{
123+
NodeID: fn.ID,
124+
Tags: []model.DocTag{{Kind: model.TagIntent, Value: "does something", Ordinal: 0}},
120125
})
121126

122127
// Generate docs so everything is fresh
@@ -135,3 +140,29 @@ func TestLintCommand_CleanReport(t *testing.T) {
135140
t.Errorf("expected clean report, got:\n%s", out)
136141
}
137142
}
143+
144+
func TestLintCommand_ReportsUnannotated(t *testing.T) {
145+
deps, stdout, stderr, db := setupLintTest(t)
146+
147+
db.Create(&model.Node{
148+
QualifiedName: "pkg/svc.go::Handle",
149+
Kind: model.NodeKindFunction,
150+
Name: "Handle",
151+
FilePath: "pkg/svc.go",
152+
StartLine: 1, EndLine: 10,
153+
Hash: "h1", Language: "go",
154+
})
155+
156+
outDir := t.TempDir()
157+
if err := executeCmd(deps, stdout, stderr, "lint", "--out", outDir); err != nil {
158+
t.Fatalf("unexpected error: %v", err)
159+
}
160+
161+
out := stdout.String()
162+
if !strings.Contains(out, "pkg/svc.go::Handle") {
163+
t.Errorf("expected unannotated 'pkg/svc.go::Handle' in output, got:\n%s", out)
164+
}
165+
if !strings.Contains(out, "unannotated") || !strings.Contains(out, "Unannotated") {
166+
t.Errorf("expected 'unannotated' label in output, got:\n%s", out)
167+
}
168+
}

internal/docs/lint.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ import (
1313

1414
// LintReport contains the results of a documentation lint check.
1515
type LintReport struct {
16-
Orphans []string // doc files with no matching source in the graph
17-
Missing []string // source files in the graph with no doc file
18-
Stale []string // doc files older than the source's last update
16+
Orphans []string // doc files with no matching source in the graph
17+
Missing []string // source files in the graph with no doc file
18+
Stale []string // doc files older than the source's last update
19+
Unannotated []string // qualified names of symbols with no annotation
1920
}
2021

2122
// Lint checks the documentation directory against the code graph and
@@ -103,9 +104,47 @@ func (g *Generator) Lint() (*LintReport, error) {
103104
}
104105
}
105106

107+
// 4. Find unannotated symbols (functions, classes, types — skip tests).
108+
var symbolNodes []model.Node
109+
if err := g.DB.Select("id, qualified_name, kind, file_path").
110+
Where("kind IN ?", []string{
111+
string(model.NodeKindFunction),
112+
string(model.NodeKindClass),
113+
string(model.NodeKindType),
114+
}).Find(&symbolNodes).Error; err != nil {
115+
return nil, fmt.Errorf("query symbol nodes: %w", err)
116+
}
117+
118+
// Collect IDs to batch-query annotations.
119+
ids := make([]uint, 0, len(symbolNodes))
120+
nodeByID := map[uint]*model.Node{}
121+
for i := range symbolNodes {
122+
n := &symbolNodes[i]
123+
if len(g.Exclude) > 0 && pathutil.MatchExcludes(g.Exclude, n.FilePath) {
124+
continue
125+
}
126+
ids = append(ids, n.ID)
127+
nodeByID[n.ID] = n
128+
}
129+
130+
if len(ids) > 0 {
131+
var annotations []model.Annotation
132+
g.DB.Select("node_id").Where("node_id IN ?", ids).Find(&annotations)
133+
annotated := map[uint]bool{}
134+
for _, a := range annotations {
135+
annotated[a.NodeID] = true
136+
}
137+
for _, id := range ids {
138+
if !annotated[id] {
139+
report.Unannotated = append(report.Unannotated, nodeByID[id].QualifiedName)
140+
}
141+
}
142+
}
143+
106144
sort.Strings(report.Orphans)
107145
sort.Strings(report.Missing)
108146
sort.Strings(report.Stale)
147+
sort.Strings(report.Unannotated)
109148

110149
return report, nil
111150
}

internal/docs/lint_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,85 @@ func TestLint_EmptyDB_EmptyDir(t *testing.T) {
164164
len(report.Orphans), len(report.Missing), len(report.Stale))
165165
}
166166
}
167+
168+
func TestLint_DetectsUnannotated(t *testing.T) {
169+
db := newLintTestDB(t)
170+
outDir := t.TempDir()
171+
172+
// Function WITH annotation
173+
annotated := model.Node{
174+
QualifiedName: "pkg/a.go::Annotated",
175+
Kind: model.NodeKindFunction,
176+
Name: "Annotated",
177+
FilePath: "pkg/a.go",
178+
StartLine: 1, EndLine: 10,
179+
Hash: "h1", Language: "go",
180+
}
181+
db.Create(&annotated)
182+
db.Create(&model.Annotation{
183+
NodeID: annotated.ID,
184+
Tags: []model.DocTag{{Kind: model.TagIntent, Value: "does something", Ordinal: 0}},
185+
})
186+
187+
// Function WITHOUT annotation
188+
db.Create(&model.Node{
189+
QualifiedName: "pkg/b.go::Bare",
190+
Kind: model.NodeKindFunction,
191+
Name: "Bare",
192+
FilePath: "pkg/b.go",
193+
StartLine: 1, EndLine: 10,
194+
Hash: "h2", Language: "go",
195+
})
196+
197+
// Type WITHOUT annotation
198+
db.Create(&model.Node{
199+
QualifiedName: "pkg/b.go::Config",
200+
Kind: model.NodeKindType,
201+
Name: "Config",
202+
FilePath: "pkg/b.go",
203+
StartLine: 12, EndLine: 20,
204+
Hash: "h3", Language: "go",
205+
})
206+
207+
gen := &Generator{DB: db, OutDir: outDir}
208+
report, err := gen.Lint()
209+
if err != nil {
210+
t.Fatalf("unexpected error: %v", err)
211+
}
212+
213+
if len(report.Unannotated) != 2 {
214+
t.Fatalf("expected 2 unannotated, got %d: %v", len(report.Unannotated), report.Unannotated)
215+
}
216+
217+
// Should NOT include the annotated function
218+
for _, u := range report.Unannotated {
219+
if u == "pkg/a.go::Annotated" {
220+
t.Error("annotated function should not appear in unannotated list")
221+
}
222+
}
223+
}
224+
225+
func TestLint_SkipsTestNodesForUnannotated(t *testing.T) {
226+
db := newLintTestDB(t)
227+
outDir := t.TempDir()
228+
229+
// Test function — should not be reported as unannotated
230+
db.Create(&model.Node{
231+
QualifiedName: "pkg/a_test.go::TestFoo",
232+
Kind: model.NodeKindTest,
233+
Name: "TestFoo",
234+
FilePath: "pkg/a_test.go",
235+
StartLine: 1, EndLine: 10,
236+
Hash: "h1", Language: "go",
237+
})
238+
239+
gen := &Generator{DB: db, OutDir: outDir}
240+
report, err := gen.Lint()
241+
if err != nil {
242+
t.Fatalf("unexpected error: %v", err)
243+
}
244+
245+
if len(report.Unannotated) != 0 {
246+
t.Errorf("test nodes should not be in unannotated list, got: %v", report.Unannotated)
247+
}
248+
}

0 commit comments

Comments
 (0)