Skip to content

Commit a5e8a48

Browse files
committed
Lint improvements: enhance document file collection with namespace manifest support and add tests for foreign document handling
1 parent baee53e commit a5e8a48

2 files changed

Lines changed: 137 additions & 29 deletions

File tree

internal/docs/lint.go

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package docs
22

33
import (
4+
"encoding/json"
45
"fmt"
56
"os"
67
"path/filepath"
78
"sort"
89
"strings"
910

1011
"github.com/tae2089/code-context-graph/internal/ccgref"
12+
"github.com/tae2089/code-context-graph/internal/ctxns"
1113
"github.com/tae2089/code-context-graph/internal/model"
1214
"github.com/tae2089/code-context-graph/internal/pathutil"
1315
)
@@ -52,35 +54,9 @@ func (g *Generator) Lint() (*LintReport, error) {
5254
report := &LintReport{}
5355

5456
// 1. Collect all .md doc files from the output directory.
55-
docFiles := map[string]os.FileInfo{} // source path → FileInfo of .md
56-
if _, err := os.Stat(g.OutDir); err == nil {
57-
err := filepath.Walk(g.OutDir, func(path string, info os.FileInfo, err error) error {
58-
if err != nil || info.IsDir() {
59-
return err
60-
}
61-
if !strings.HasSuffix(path, ".md") {
62-
return nil
63-
}
64-
65-
rel, _ := filepath.Rel(g.OutDir, path)
66-
rel = filepath.ToSlash(rel)
67-
68-
// Skip index.md — it's not a per-file doc.
69-
if rel == "index.md" {
70-
return nil
71-
}
72-
73-
// Strip .md suffix to get the source path.
74-
srcPath := strings.TrimSuffix(rel, ".md")
75-
if len(g.Exclude) > 0 && pathutil.MatchExcludes(g.Exclude, srcPath) {
76-
return nil
77-
}
78-
docFiles[srcPath] = info
79-
return nil
80-
})
81-
if err != nil {
82-
return nil, fmt.Errorf("walk docs dir: %w", err)
83-
}
57+
docFiles, err := g.lintDocFiles()
58+
if err != nil {
59+
return nil, err
8460
}
8561

8662
// 2. Collect all source file paths from the graph (distinct file_path
@@ -301,6 +277,90 @@ func (g *Generator) Lint() (*LintReport, error) {
301277
return report, nil
302278
}
303279

280+
// @intent collect only the Markdown files that belong to the active docs namespace.
281+
// @domainRule named namespaces trust their scoped manifest; without one, foreign docs in the shared output dir are ignored.
282+
func (g *Generator) lintDocFiles() (map[string]os.FileInfo, error) {
283+
docFiles := map[string]os.FileInfo{} // source path -> FileInfo of .md
284+
if m, ok, err := g.loadLintManifest(); err != nil {
285+
return nil, err
286+
} else if ok {
287+
for _, rel := range m.Files {
288+
rel = filepath.ToSlash(rel)
289+
if rel == "index.md" || !strings.HasSuffix(rel, ".md") {
290+
continue
291+
}
292+
srcPath := strings.TrimSuffix(rel, ".md")
293+
if len(g.Exclude) > 0 && pathutil.MatchExcludes(g.Exclude, srcPath) {
294+
continue
295+
}
296+
full, err := safeDocOutputPath(g.OutDir, rel)
297+
if err != nil {
298+
return nil, err
299+
}
300+
info, err := os.Stat(full)
301+
if err != nil {
302+
if os.IsNotExist(err) {
303+
continue
304+
}
305+
return nil, err
306+
}
307+
if !info.IsDir() {
308+
docFiles[srcPath] = info
309+
}
310+
}
311+
return docFiles, nil
312+
}
313+
if g.Namespace != "" && ctxns.Normalize(g.Namespace) != ctxns.DefaultNamespace {
314+
return docFiles, nil
315+
}
316+
if _, err := os.Stat(g.OutDir); err == nil {
317+
err := filepath.Walk(g.OutDir, func(path string, info os.FileInfo, err error) error {
318+
if err != nil || info.IsDir() {
319+
return err
320+
}
321+
if !strings.HasSuffix(path, ".md") {
322+
return nil
323+
}
324+
325+
rel, _ := filepath.Rel(g.OutDir, path)
326+
rel = filepath.ToSlash(rel)
327+
328+
// Skip index.md — it's not a per-file doc.
329+
if rel == "index.md" {
330+
return nil
331+
}
332+
333+
// Strip .md suffix to get the source path.
334+
srcPath := strings.TrimSuffix(rel, ".md")
335+
if len(g.Exclude) > 0 && pathutil.MatchExcludes(g.Exclude, srcPath) {
336+
return nil
337+
}
338+
docFiles[srcPath] = info
339+
return nil
340+
})
341+
if err != nil {
342+
return nil, fmt.Errorf("walk docs dir: %w", err)
343+
}
344+
}
345+
return docFiles, nil
346+
}
347+
348+
// @intent load the active namespace manifest for lint without hiding whether it exists.
349+
func (g *Generator) loadLintManifest() (*manifest, bool, error) {
350+
data, err := os.ReadFile(g.manifestPath())
351+
if err != nil {
352+
if os.IsNotExist(err) {
353+
return nil, false, nil
354+
}
355+
return nil, false, err
356+
}
357+
m := &manifest{}
358+
if err := json.Unmarshal(data, m); err != nil {
359+
return nil, false, err
360+
}
361+
return m, true, nil
362+
}
363+
304364
// ccgRefExists checks whether a parsed ccg:// @see ref resolves to graph data.
305365
// @intent let docs lint validate cross-namespace refs while keeping local @see lookup semantics unchanged.
306366
func (g *Generator) ccgRefExists(ref ccgref.Ref) (bool, error) {

internal/docs/lint_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,3 +662,51 @@ func TestLint_RespectsNamespace(t *testing.T) {
662662
t.Fatalf("missing = %v, want only alpha file", report.Missing)
663663
}
664664
}
665+
666+
func TestLint_NamedNamespaceIgnoresForeignDocsWithoutManifest(t *testing.T) {
667+
db := newLintTestDB(t)
668+
outDir := t.TempDir()
669+
if err := os.MkdirAll(filepath.Join(outDir, "internal"), 0o755); err != nil {
670+
t.Fatal(err)
671+
}
672+
if err := os.WriteFile(filepath.Join(outDir, "internal", "foreign.go.md"), []byte("# internal/foreign.go\n"), 0o644); err != nil {
673+
t.Fatal(err)
674+
}
675+
db.Create(&model.Node{Namespace: "trace", QualifiedName: "trace.Context", Kind: model.NodeKindFunction, Name: "Context", FilePath: "context.go", StartLine: 1, EndLine: 10, Hash: "h1", Language: "go"})
676+
677+
gen := &Generator{DB: db, OutDir: outDir, Namespace: "trace"}
678+
report, err := gen.Lint()
679+
if err != nil {
680+
t.Fatal(err)
681+
}
682+
if len(report.Orphans) != 0 {
683+
t.Fatalf("orphans = %v, want none from foreign docs", report.Orphans)
684+
}
685+
if len(report.Missing) != 1 || report.Missing[0] != "context.go" {
686+
t.Fatalf("missing = %v, want only trace source", report.Missing)
687+
}
688+
}
689+
690+
func TestLint_NamedNamespaceUsesScopedManifest(t *testing.T) {
691+
db := newLintTestDB(t)
692+
outDir := t.TempDir()
693+
db.Create(&model.Node{Namespace: "trace", QualifiedName: "trace.Context", Kind: model.NodeKindFunction, Name: "Context", FilePath: "context.go", StartLine: 1, EndLine: 10, Hash: "h1", Language: "go"})
694+
if err := os.WriteFile(filepath.Join(outDir, "context.go.md"), []byte("# context.go\n"), 0o644); err != nil {
695+
t.Fatal(err)
696+
}
697+
if err := os.WriteFile(filepath.Join(outDir, "foreign.go.md"), []byte("# foreign.go\n"), 0o644); err != nil {
698+
t.Fatal(err)
699+
}
700+
701+
gen := &Generator{DB: db, OutDir: outDir, Namespace: "trace"}
702+
if err := gen.saveManifest([]string{"context.go.md", "index.md"}); err != nil {
703+
t.Fatal(err)
704+
}
705+
report, err := gen.Lint()
706+
if err != nil {
707+
t.Fatal(err)
708+
}
709+
if len(report.Orphans) != 0 || len(report.Missing) != 0 {
710+
t.Fatalf("report = orphans:%v missing:%v, want clean manifest-scoped docs", report.Orphans, report.Missing)
711+
}
712+
}

0 commit comments

Comments
 (0)