From d2dc118e2deff67fc1fe5c7ee39f0289bb9d28f5 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:00:03 +0200 Subject: [PATCH] fix(config): honor resolved dependency filters Thread effective CLI and project filters through dependency, importer, stdin, and blast-radius scans without redundant config loads. Give filtered-match guidance direct JSON-safe config actions. Co-Authored-By: GPT-5.6 Sol --- blast_radius.go | 7 +- blast_radius_fixes_test.go | 34 ++++++- main.go | 29 +++--- main_more_test.go | 159 ++++++++++++++++++++++++++++++- mcp/find_guidance.go | 12 ++- mcp/main_test.go | 43 +++++++-- scanner/filegraph.go | 35 ++++--- scanner/integration_more_test.go | 134 ++++++++++++++++++++++++++ scanner/walker.go | 28 ++++-- 9 files changed, 428 insertions(+), 53 deletions(-) diff --git a/blast_radius.go b/blast_radius.go index 155a6aa..3f8013f 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -431,8 +431,9 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas } cfg := config.Load(absRoot) + filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude} gitCache := scanner.NewGitIgnoreCache(absRoot) - allFiles, err := scanner.ScanFiles(absRoot, gitCache, cfg.Only, cfg.Exclude) + allFiles, err := scanner.ScanFiles(absRoot, gitCache, filters.Only, filters.Exclude) if err != nil { return blastRadiusBundle{}, err } @@ -449,7 +450,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas // ScanForDeps, tripling latency on large repositories. var analyses []scanner.FileAnalysis if diffTotal > 0 { - analyses, err = scanForDepsWithHint(absRoot) + analyses, err = scanForDepsWithHint(absRoot, filters) if err != nil { return blastRadiusBundle{}, err } @@ -491,7 +492,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas depsTotal = len(depsProject.Files) depsCapped = capBlastRadiusDepsProject(depsProject, limits.MaxChangedFiles) - fg, err := scanner.BuildFileGraphFromAnalyses(absRoot, analyses) + fg, err := scanner.BuildFileGraphFromFilteredAnalyses(absRoot, analyses, filters) if err != nil { return blastRadiusBundle{}, err } diff --git a/blast_radius_fixes_test.go b/blast_radius_fixes_test.go index 0616878..ecd2cba 100644 --- a/blast_radius_fixes_test.go +++ b/blast_radius_fixes_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "codemap/config" "codemap/scanner" ) @@ -274,19 +275,27 @@ func TestBlastRadiusOmitsEmptyImporterSections(t *testing.T) { func TestBlastRadiusSingleScanParity(t *testing.T) { requireBlastRadiusTools(t) root := makeBlastRadiusHubRepo(t) + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".codemap", "config.json"), []byte(`{"only":["go"],"exclude":["c"]}`), 0o644); err != nil { + t.Fatal(err) + } + cfg := config.Load(root) + filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude} - analyses, err := scanner.ScanForDeps(root) + analyses, err := scanner.ScanForDepsWithFilters(root, filters) if err != nil { - t.Fatalf("ScanForDeps: %v", err) + t.Fatalf("ScanForDepsWithFilters: %v", err) } fgWrap, err := scanner.BuildFileGraph(root) if err != nil { t.Fatalf("BuildFileGraph: %v", err) } - fgInjected, err := scanner.BuildFileGraphFromAnalyses(root, analyses) + fgInjected, err := scanner.BuildFileGraphFromFilteredAnalyses(root, analyses, filters) if err != nil { - t.Fatalf("BuildFileGraphFromAnalyses: %v", err) + t.Fatalf("BuildFileGraphFromFilteredAnalyses: %v", err) } if len(fgWrap.Importers["pkg/hub/hub.go"]) != len(fgInjected.Importers["pkg/hub/hub.go"]) { t.Fatalf("file graph importer parity mismatch: %v vs %v", @@ -299,6 +308,23 @@ func TestBlastRadiusSingleScanParity(t *testing.T) { if len(impWrap) != len(impInjected) { t.Fatalf("impact parity mismatch: %v vs %v", impWrap, impInjected) } + for _, importer := range fgInjected.Importers["pkg/hub/hub.go"] { + if importer == "c/c.go" { + t.Fatalf("filtered graph retained excluded importer: %v", fgInjected.Importers["pkg/hub/hub.go"]) + } + } + + bundle, err := buildBlastRadiusBundle(root, "HEAD", defaultBlastRadiusLimits()) + if err != nil { + t.Fatalf("buildBlastRadiusBundle: %v", err) + } + for _, report := range bundle.Importers { + for _, importer := range report.Importers { + if importer == "c/c.go" { + t.Fatalf("blast radius retained excluded importer: %+v", bundle.Importers) + } + } + } } // Finding #4 (deterministic unit test): truncating a fenced block mid-content diff --git a/main.go b/main.go index 62749b9..6a83229 100644 --- a/main.go +++ b/main.go @@ -297,6 +297,7 @@ func main() { if *depthLimit == 0 && projCfg.Depth > 0 { *depthLimit = projCfg.Depth } + filters := scanner.Filters{Only: only, Exclude: exclude} if *debugMode { fmt.Fprintf(os.Stderr, "[debug] Root path: %s\n", root) @@ -312,7 +313,7 @@ func main() { // Importers mode - check file impact if *importersMode != "" { - runImportersMode(absRoot, *importersMode, *jsonMode) + runImportersMode(absRoot, *importersMode, *jsonMode, filters) return } @@ -338,7 +339,7 @@ func main() { if diffInfo != nil { changedFiles = diffInfo.Changed } - runDepsMode(absRoot, root, *jsonMode, *diffRef, changedFiles, *stdinMode) + runDepsMode(absRoot, root, *jsonMode, *diffRef, changedFiles, *stdinMode, filters) return } @@ -396,13 +397,13 @@ type stdinManifest struct { } `json:"files"` } -func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFiles map[string]bool, stdinMode bool) { +func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFiles map[string]bool, stdinMode bool, filters scanner.Filters) { var analyses []FileAnalysis var externalDeps map[string][]string var err error if stdinMode { - analyses, externalDeps, err = runDepsFromStdin() + analyses, externalDeps, err = runDepsFromStdin(filters) if err != nil { fmt.Fprintf(os.Stderr, "Error reading stdin manifest: %v\n", err) os.Exit(1) @@ -412,7 +413,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil externalDeps = make(map[string][]string) } } else { - analyses, err = scanForDepsWithHint(root) + analyses, err = scanForDepsWithHint(root, filters) if err != nil { if errors.Is(err, scanner.ErrAstGrepNotFound) { printAstGrepInstallHint(os.Stderr, err) @@ -445,15 +446,15 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil } } -// scanForDepsWithHint wraps scanner.ScanForDeps (extracted for testability). -func scanForDepsWithHint(root string) ([]FileAnalysis, error) { - return scanner.ScanForDeps(root) +// scanForDepsWithHint wraps scanner.ScanForDepsWithFilters (extracted for testability). +func scanForDepsWithHint(root string, filters scanner.Filters) ([]FileAnalysis, error) { + return scanner.ScanForDepsWithFilters(root, filters) } // runDepsFromStdin reads a JSON manifest from stdin, writes files to a temp // directory, runs ast-grep on it, and returns the results with paths matching // the original manifest. -func runDepsFromStdin() ([]FileAnalysis, map[string][]string, error) { +func runDepsFromStdin(filters scanner.Filters) ([]FileAnalysis, map[string][]string, error) { var manifest stdinManifest if err := json.NewDecoder(os.Stdin).Decode(&manifest); err != nil { return nil, nil, fmt.Errorf("invalid JSON: %w", err) @@ -481,7 +482,7 @@ func runDepsFromStdin() ([]FileAnalysis, map[string][]string, error) { } // Run ast-grep on temp directory - analyses, err := scanner.ScanForDeps(tempDir) + analyses, err := scanner.ScanForDepsWithFilters(tempDir, filters) if err != nil { return nil, nil, err } @@ -534,16 +535,16 @@ func runWatchMode(root string, verbose bool) { fmt.Printf(" Events logged: %d\n", len(events)) } -func buildImportersReport(root, file string) (scanner.ImportersReport, error) { - fg, err := scanner.BuildFileGraph(root) +func buildImportersReport(root, file string, filters scanner.Filters) (scanner.ImportersReport, error) { + fg, err := scanner.BuildFileGraphWithFilters(root, filters) if err != nil { return scanner.ImportersReport{}, err } return buildImportersReportFromGraph(root, file, fg), nil } -func runImportersMode(root, file string, jsonMode bool) { - report, err := buildImportersReport(root, file) +func runImportersMode(root, file string, jsonMode bool, filters scanner.Filters) { + report, err := buildImportersReport(root, file, filters) if err != nil { fmt.Fprintf(os.Stderr, "Error building file graph: %v\n", err) os.Exit(1) diff --git a/main_more_test.go b/main_more_test.go index 0e5fd6a..48dd226 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -204,6 +204,51 @@ func writeImportersFixture(t *testing.T, root string) { } } +func writeCLIFilterPrecedenceFixture(t *testing.T, root string) { + t.Helper() + + files := map[string]string{ + "go.mod": "module example.com/precedence\n\ngo 1.22\n", + "pkg/shared/shared.go": "package shared\n\nfunc Value() int { return 1 }\n", + "a/a.go": "package a\n\nimport \"example.com/precedence/pkg/shared\"\n\nfunc Use() int { return shared.Value() }\n", + "c/c.go": "package c\n\nimport \"example.com/precedence/pkg/shared\"\n\nfunc Use() int { return shared.Value() }\n", + "ts/ignored.ts": "import { Value } from '../pkg/shared/shared';\n\nexport const use = Value;\n", + ".codemap/config.json": "{\n \"only\": [\"ts\"],\n \"exclude\": [\"a\"]\n}\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func analysisPaths(analyses []scanner.FileAnalysis) []string { + paths := make([]string, 0, len(analyses)) + for _, analysis := range analyses { + paths = append(paths, filepath.ToSlash(analysis.Path)) + } + return paths +} + +func requirePaths(t *testing.T, paths []string, present, absent []string) { + t.Helper() + joined := "\n" + strings.Join(paths, "\n") + "\n" + for _, path := range present { + if !strings.Contains(joined, "\n"+path+"\n") { + t.Fatalf("expected %q in paths %v", path, paths) + } + } + for _, path := range absent { + if strings.Contains(joined, "\n"+path+"\n") { + t.Fatalf("did not expect %q in paths %v", path, paths) + } + } +} + func makeMainGitRepo(t *testing.T, branch string) string { t.Helper() @@ -290,7 +335,7 @@ func TestRunImportersMode(t *testing.T) { writeImportersFixture(t, root) stdout, _ := captureMainStreams(t, func() { - runImportersMode(root, filepath.Join(root, "pkg", "types", "types.go"), false) + runImportersMode(root, filepath.Join(root, "pkg", "types", "types.go"), false, scanner.Filters{}) }) for _, check := range []string{"HUB FILE: pkg/types/types.go", "Imported by 4 files", "Dependents:"} { @@ -309,7 +354,7 @@ func TestRunDepsModeJSONAndMainDispatchesDepsAndImporters(t *testing.T) { writeImportersFixture(t, root) stdout, _ := captureMainStreams(t, func() { - runDepsMode(root, root, true, "main", map[string]bool{"a/a.go": true}, false) + runDepsMode(root, root, true, "main", map[string]bool{"a/a.go": true}, false, scanner.Filters{}) }) var depsProject scanner.DepsProject @@ -358,6 +403,114 @@ func TestRunDepsModeJSONAndMainDispatchesDepsAndImporters(t *testing.T) { } } +func TestBinaryDependencyModesHonorCLIFiltersOverConfig(t *testing.T) { + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + root := t.TempDir() + writeCLIFilterPrecedenceFixture(t, root) + + tests := []struct { + name string + input string + args []string + check func(t *testing.T, output string) + }{ + { + name: "deps", + args: []string{"--deps", "--json", "--only", "go", "--exclude", "c", root}, + check: func(t *testing.T, output string) { + t.Helper() + var project scanner.DepsProject + if err := json.Unmarshal([]byte(output), &project); err != nil { + t.Fatalf("decode deps JSON: %v\n%s", err, output) + } + requirePaths(t, analysisPaths(project.Files), + []string{"a/a.go", "pkg/shared/shared.go"}, + []string{"c/c.go", "ts/ignored.ts"}) + }, + }, + { + name: "importers", + args: []string{"--importers", "pkg/shared/shared.go", "--json", "--only", "go", "--exclude", "c", root}, + check: func(t *testing.T, output string) { + t.Helper() + var report scanner.ImportersReport + if err := json.Unmarshal([]byte(output), &report); err != nil { + t.Fatalf("decode importers JSON: %v\n%s", err, output) + } + requirePaths(t, report.Importers, []string{"a/a.go"}, []string{"c/c.go", "ts/ignored.ts"}) + }, + }, + { + name: "deps stdin", + input: `{"files":[{"path":"go/main.go","content":"package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(1) }\n"},{"path":"ts/ignored.ts","content":"import { ignored } from \"example\";\n\nexport const value = ignored;\n"}]}`, + args: []string{"--deps", "--stdin", "--json", "--only", "go"}, + check: func(t *testing.T, output string) { + t.Helper() + var project scanner.DepsProject + if err := json.Unmarshal([]byte(output), &project); err != nil { + t.Fatalf("decode stdin deps JSON: %v\n%s", err, output) + } + requirePaths(t, analysisPaths(project.Files), []string{"go/main.go"}, []string{"ts/ignored.ts"}) + if len(project.Files) != 1 || len(project.Files[0].Imports) == 0 { + t.Fatalf("expected parsed Go import, got %+v", project.Files) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + output, stderr, err := runCodemapWithInput(test.input, test.args...) + if err != nil { + t.Fatalf("codemap %s failed: %v\nstderr=%s", test.name, err, stderr) + } + test.check(t, output) + }) + } +} + +func TestRunDepsModeFromStdinHonorsFilters(t *testing.T) { + if !scanner.NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = oldStdin + _ = reader.Close() + }) + manifest := `{"files":[{"path":"go.mod","content":"module example.com/stdin\n\nrequire (\n\texample.com/external v1.0.0\n)\n"},{"path":"go/main.go","content":"package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(1) }\n"},{"path":"ts/ignored.ts","content":"import { ignored } from \"example\";\n\nexport const value = ignored;\n"}]}` + if _, err := writer.WriteString(manifest); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + stdout, _ := captureMainStreams(t, func() { + runDepsMode("stdin-root", "stdin-root", true, "main", nil, true, scanner.Filters{Only: []string{"go"}}) + }) + var project scanner.DepsProject + if err := json.Unmarshal([]byte(stdout), &project); err != nil { + t.Fatalf("decode stdin deps JSON: %v\n%s", err, stdout) + } + requirePaths(t, analysisPaths(project.Files), []string{"go/main.go"}, []string{"ts/ignored.ts"}) + if len(project.Files) != 1 || len(project.Files[0].Imports) == 0 { + t.Fatalf("expected parsed Go import, got %+v", project.Files) + } + if !strings.Contains(strings.Join(project.ExternalDeps["go"], "\n"), "example.com/external") { + t.Fatalf("expected manifest external dependency, got %+v", project.ExternalDeps) + } +} + func TestRunHandoffSubcommandBuildAndDetailJSON(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") @@ -676,7 +829,7 @@ func TestRunDepsModeRenderedOutputAndMainTreeModes(t *testing.T) { writeImportersFixture(t, root) stdout, _ := captureMainStreams(t, func() { - runDepsMode(root, root, false, "main", nil, false) + runDepsMode(root, root, false, "main", nil, false, scanner.Filters{}) }) if !strings.Contains(stdout, "Dependency Flow") { t.Fatalf("expected rendered dependency graph output, got:\n%s", stdout) diff --git a/mcp/find_guidance.go b/mcp/find_guidance.go index 8d6b09f..3447681 100644 --- a/mcp/find_guidance.go +++ b/mcp/find_guidance.go @@ -1,6 +1,7 @@ package codemapmcp import ( + "encoding/json" "fmt" "sort" "strings" @@ -52,10 +53,15 @@ func formatOnlyFilterHint(pattern string, matches []scanner.FileInfo) string { output += fmt.Sprintf("\n... and %d more", remaining) } if len(exts) > 0 { - extList := strings.Join(exts, ", ") - output += fmt.Sprintf("\n\nTell your agent: “include suggestions for %s”, “ignore suggestions for %s”, or “disable suggestions for this repo”.", extList, extList) + quotedExts := make([]string, len(exts)) + for i, ext := range exts { + quoted, _ := json.Marshal(ext) + quotedExts[i] = string(quoted) + } + extList := strings.Join(quotedExts, ", ") + output += fmt.Sprintf("\n\nUpdate `.codemap/config.json`:\n- Add %s to `only` to include these files.\n- Add %s to `guidance.ignored_extensions` to hide this guidance.\n- Set `guidance.missing_extension_hints` to false to disable missing-extension guidance.", extList, extList) } else { - output += "\n\nTell your agent: “disable suggestions for this repo”." + output += "\n\nUpdate `.codemap/config.json`:\n- Set `guidance.missing_extension_hints` to false to disable missing-extension guidance." } return output + "\n\nNo config changed." } diff --git a/mcp/main_test.go b/mcp/main_test.go index 47d1f61..368a148 100644 --- a/mcp/main_test.go +++ b/mcp/main_test.go @@ -188,19 +188,43 @@ func TestHandleFindFileExplainsOnlyFilteredMatches(t *testing.T) { } } -func TestFormatOnlyFilterHintOffersSortedAgentChoices(t *testing.T) { +func TestFormatOnlyFilterHintOffersDirectConfigActionsForExtensions(t *testing.T) { matches := []scanner.FileInfo{ {Path: "schema.sum", Ext: ".sum"}, {Path: "schema.proto", Ext: ".proto"}, } out := formatOnlyFilterHint("schema", matches) - want := "Tell your agent: “include suggestions for proto, sum”, “ignore suggestions for proto, sum”, or “disable suggestions for this repo”." - if !strings.Contains(out, want) { - t.Fatalf("missing concise agent choices:\n%s", out) + for _, want := range []string{ + "Update `.codemap/config.json`:", + "Add \"proto\", \"sum\" to `only`", + "Add \"proto\", \"sum\" to `guidance.ignored_extensions`", + "Set `guidance.missing_extension_hints` to false", + "No config changed.", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing direct config action %q:\n%s", want, out) + } + } + if strings.Contains(out, "Tell your agent") { + t.Fatalf("response retains indirect agent language:\n%s", out) + } +} + +func TestFormatOnlyFilterHintUsesJSONCompatibleExtensionQuoting(t *testing.T) { + out := formatOnlyFilterHint("schema", []scanner.FileInfo{{Path: "schema.proto\a", Ext: ".proto\a"}}) + for _, want := range []string{ + "Add \"proto\\u0007\" to `only`", + "Add \"proto\\u0007\" to `guidance.ignored_extensions`", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing JSON-compatible extension quoting %q:\n%s", want, out) + } } - if strings.Contains(out, ".codemap/config.json") || strings.Contains(out, "guidance.") { - t.Fatalf("response exposes config implementation details:\n%s", out) + for _, unwanted := range []string{"\\a", "\\x"} { + if strings.Contains(out, unwanted) { + t.Fatalf("response contains Go-only escape %q:\n%s", unwanted, out) + } } } @@ -233,7 +257,7 @@ func TestFindConfiguredMatchesSeparatesVisibleAndHintableFiles(t *testing.T) { } } -func TestFormatOnlyFilterHintBoundsExtensionlessMatches(t *testing.T) { +func TestFormatOnlyFilterHintOffersDirectConfigActionForExtensionlessMatches(t *testing.T) { matches := []scanner.FileInfo{ {Path: "schema-1"}, {Path: "schema-2"}, @@ -243,7 +267,7 @@ func TestFormatOnlyFilterHintBoundsExtensionlessMatches(t *testing.T) { {Path: "schema-6"}, } out := formatOnlyFilterHint("schema", matches) - for _, want := range []string{"schema-1", "schema-5", "... and 1 more", "disable suggestions for this repo", "No config changed."} { + for _, want := range []string{"schema-1", "schema-5", "... and 1 more", "Update `.codemap/config.json`:", "Set `guidance.missing_extension_hints` to false", "No config changed."} { if !strings.Contains(out, want) { t.Fatalf("missing %q from bounded hint:\n%s", want, out) } @@ -251,6 +275,9 @@ func TestFormatOnlyFilterHintBoundsExtensionlessMatches(t *testing.T) { if strings.Contains(out, "schema-6") { t.Fatalf("bounded hint exposed sixth path:\n%s", out) } + if strings.Contains(out, "Tell your agent") { + t.Fatalf("response retains indirect agent language:\n%s", out) + } } func TestHandleGetStructureUsesStateHubs(t *testing.T) { diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 4625297..99c4326 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "strings" + + "codemap/config" ) // FileGraph represents internal file-to-file dependencies within a project @@ -27,28 +29,37 @@ type fileIndex struct { goPkgs map[string][]string // Go package path -> files } -// BuildFileGraph analyzes a project and returns file-level dependencies -// Uses ast-grep for multi-language support with universal fuzzy resolution +// BuildFileGraph analyzes a project with its configured filters. func BuildFileGraph(root string) (*FileGraph, error) { - // Use ast-grep to extract imports for all languages. - analyses, err := ScanForDeps(root) + cfg := config.Load(root) + return BuildFileGraphWithFilters(root, Filters{Only: cfg.Only, Exclude: cfg.Exclude}) +} + +// BuildFileGraphWithFilters analyzes a project with explicit filters. +func BuildFileGraphWithFilters(root string, filters Filters) (*FileGraph, error) { + analyses, err := ScanForDepsWithFilters(root, filters) if err != nil { return nil, err } - return BuildFileGraphFromAnalyses(root, analyses) + return BuildFileGraphFromFilteredAnalyses(root, analyses, filters) } -// BuildFileGraphFromAnalyses builds the file graph from a pre-computed ast-grep -// scan, letting callers that already hold the analyses avoid a redundant -// full-repo ScanForDeps. BuildFileGraph is the convenience wrapper that scans. +// BuildFileGraphFromAnalyses builds a file graph from pre-computed analyses +// using the configured project filters. func BuildFileGraphFromAnalyses(root string, analyses []FileAnalysis) (*FileGraph, error) { + cfg := config.Load(root) + filters := Filters{Only: cfg.Only, Exclude: cfg.Exclude} + return BuildFileGraphFromFilteredAnalyses(root, filterAnalyses(analyses, filters), filters) +} + +// BuildFileGraphFromFilteredAnalyses builds a file graph from analyses that +// already match the supplied filters. +func BuildFileGraphFromFilteredAnalyses(root string, analyses []FileAnalysis, filters Filters) (*FileGraph, error) { absRoot, err := filepath.Abs(root) if err != nil { return nil, err } - analyses = filterConfiguredAnalyses(root, analyses) - fg := &FileGraph{ Root: absRoot, Imports: make(map[string][]string), @@ -63,9 +74,9 @@ func BuildFileGraphFromAnalyses(root string, analyses []FileAnalysis) (*FileGrap // Detect path aliases from tsconfig.json (for TS/JS import resolution) fg.PathAliases, fg.BaseURL = detectPathAliases(absRoot) - // Scan all files + // Scan all files with the same filters used for the analyses. gitCache := NewGitIgnoreCache(root) - files, err := ScanConfiguredFiles(root, gitCache) + files, err := ScanFiles(root, gitCache, filters.Only, filters.Exclude) if err != nil { return nil, err } diff --git a/scanner/integration_more_test.go b/scanner/integration_more_test.go index fd14800..6558632 100644 --- a/scanner/integration_more_test.go +++ b/scanner/integration_more_test.go @@ -132,6 +132,132 @@ func TestDependencyAndGraphScansRespectConfiguredFilters(t *testing.T) { if _, ok := fg.Imports["schema.ts"]; ok { t.Fatal("extension-filtered file remains in graph") } + + fromAnalyses, err := BuildFileGraphFromAnalyses(root, analyses) + if err != nil { + t.Fatalf("BuildFileGraphFromAnalyses() error: %v", err) + } + if got := fromAnalyses.Importers["pkg/types/types.go"]; len(got) != 3 { + t.Fatalf("precomputed filtered hub importers = %#v, want exactly 3", got) + } + if _, ok := fromAnalyses.Imports["excluded/d/d.go"]; ok { + t.Fatal("configured precomputed graph retained excluded Go file") + } + if _, ok := fromAnalyses.Imports["schema.ts"]; ok { + t.Fatal("configured precomputed graph retained extension-filtered file") + } +} + +func TestExplicitDependencyFiltersOverrideRepositoryConfig(t *testing.T) { + if !NewAstGrepAnalyzer().Available() { + t.Skip("ast-grep not available") + } + + t.Run("only", func(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + ".codemap/config.json": `{"only":["ts"]}`, + "go.mod": "module example.com/explicit\n\ngo 1.22\n", + "pkg/types/types.go": "package types\n\ntype Item struct{}\n", + "a/a.go": "package a\n\nimport _ \"example.com/explicit/pkg/types\"\n", + "b/b.go": "package b\n\nimport _ \"example.com/explicit/pkg/types\"\n", + "schema.ts": "export const schema = true\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + analyses, err := ScanForDepsWithFilters(root, Filters{Only: []string{"go"}}) + if err != nil { + t.Fatalf("ScanForDepsWithFilters() error: %v", err) + } + for _, analysis := range analyses { + if filepath.Ext(analysis.Path) != ".go" { + t.Fatalf("explicit only filter returned %q", analysis.Path) + } + } + + configured, err := ScanForDeps(root) + if err != nil { + t.Fatalf("ScanForDeps() error: %v", err) + } + for _, analysis := range configured { + if filepath.Ext(analysis.Path) == ".go" { + t.Fatalf("configured wrapper ignored repository only filter: %q", analysis.Path) + } + } + + graph, err := BuildFileGraphWithFilters(root, Filters{Only: []string{"go"}}) + if err != nil { + t.Fatalf("BuildFileGraphWithFilters() error: %v", err) + } + if got := graph.Importers["pkg/types/types.go"]; len(got) != 2 { + t.Fatalf("explicit only graph importers = %#v, want both Go callers", got) + } + if _, ok := graph.Packages["example.com/explicit/pkg/types"]; !ok { + t.Fatal("explicit only graph file index omitted the Go package") + } + }) + + t.Run("exclude", func(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + ".codemap/config.json": `{"exclude":["blocked"]}`, + "go.mod": "module example.com/explicit\n\ngo 1.22\n", + "pkg/types/types.go": "package types\n\ntype Item struct{}\n", + "keep/keep.go": "package keep\n\nimport _ \"example.com/explicit/pkg/types\"\n", + "blocked/blocked.go": "package blocked\n\nimport _ \"example.com/explicit/pkg/types\"\n", + } + for path, content := range files { + full := filepath.Join(root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + filters := Filters{Exclude: []string{"keep"}} + analyses, err := ScanForDepsWithFilters(root, filters) + if err != nil { + t.Fatalf("ScanForDepsWithFilters() error: %v", err) + } + for _, analysis := range analyses { + if strings.HasPrefix(analysis.Path, "keep/") { + t.Fatalf("explicit exclude filter returned %q", analysis.Path) + } + } + if !containsAnalysisPath(analyses, "blocked/blocked.go") { + t.Fatal("explicit exclude filter inherited repository config") + } + + graph, err := BuildFileGraphFromFilteredAnalyses(root, analyses, filters) + if err != nil { + t.Fatalf("BuildFileGraphFromFilteredAnalyses() error: %v", err) + } + if got := graph.Importers["pkg/types/types.go"]; len(got) != 1 || got[0] != "blocked/blocked.go" { + t.Fatalf("explicit exclude graph importers = %#v, want blocked caller only", got) + } + if _, ok := graph.Packages["example.com/explicit/keep"]; ok { + t.Fatal("explicit exclude graph file index retained excluded package") + } + }) +} + +func containsAnalysisPath(analyses []FileAnalysis, want string) bool { + for _, analysis := range analyses { + if analysis.Path == want { + return true + } + } + return false } func TestConfiguredScanHelpers(t *testing.T) { @@ -152,6 +278,10 @@ func TestConfiguredScanHelpers(t *testing.T) { if _, err := ScanConfiguredFiles(filepath.Join(root, "missing"), nil); err == nil { t.Fatal("expected configured scan of missing root to fail") } + + if _, err := BuildFileGraphFromFilteredAnalyses(filepath.Join(root, "missing"), nil, Filters{}); err == nil { + t.Fatal("expected explicit-filter graph of missing root to propagate a file scan error") + } } func TestScanForDepsPropagatesScanError(t *testing.T) { @@ -170,6 +300,10 @@ func TestScanForDepsPropagatesScanError(t *testing.T) { if _, err := ScanForDeps(t.TempDir()); err == nil { t.Fatal("expected malformed ast-grep output to propagate a scan error") } + + if _, err := BuildFileGraphWithFilters(t.TempDir(), Filters{}); err == nil { + t.Fatal("expected explicit-filter graph to propagate a dependency scan error") + } } func TestScanForDepsPropagatesSetupError(t *testing.T) { diff --git a/scanner/walker.go b/scanner/walker.go index fdb84b2..9d4e28a 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -294,30 +294,46 @@ func ScanFiles(root string, cache *GitIgnoreCache, only []string, exclude []stri return files, err } +// Filters controls which files scanner operations include. +type Filters struct { + Only []string + Exclude []string +} + // ScanConfiguredFiles scans using the active setup root's project filters. func ScanConfiguredFiles(root string, cache *GitIgnoreCache) ([]FileInfo, error) { cfg := config.Load(root) return ScanFiles(root, cache, cfg.Only, cfg.Exclude) } -func filterConfiguredAnalyses(root string, analyses []FileAnalysis) []FileAnalysis { - cfg := config.Load(root) - if len(cfg.Only) == 0 && len(cfg.Exclude) == 0 { +func filterAnalyses(analyses []FileAnalysis, filters Filters) []FileAnalysis { + if len(filters.Only) == 0 && len(filters.Exclude) == 0 { return analyses } filtered := make([]FileAnalysis, 0, len(analyses)) for _, analysis := range analyses { path := filepath.ToSlash(analysis.Path) - if MatchesFilters(path, filepath.Ext(path), cfg.Only, cfg.Exclude) { + if MatchesFilters(path, filepath.Ext(path), filters.Only, filters.Exclude) { filtered = append(filtered, analysis) } } return filtered } -// ScanForDeps uses ast-grep for batched dependency analysis. +func filterConfiguredAnalyses(root string, analyses []FileAnalysis) []FileAnalysis { + cfg := config.Load(root) + return filterAnalyses(analyses, Filters{Only: cfg.Only, Exclude: cfg.Exclude}) +} + +// ScanForDeps uses the configured project filters for batched dependency analysis. func ScanForDeps(root string) ([]FileAnalysis, error) { + cfg := config.Load(root) + return ScanForDepsWithFilters(root, Filters{Only: cfg.Only, Exclude: cfg.Exclude}) +} + +// ScanForDepsWithFilters uses ast-grep for batched dependency analysis with explicit filters. +func ScanForDepsWithFilters(root string, filters Filters) ([]FileAnalysis, error) { astScanner, err := NewAstGrepScanner() if err != nil { return nil, err @@ -332,5 +348,5 @@ func ScanForDeps(root string) ([]FileAnalysis, error) { if err != nil { return nil, err } - return filterConfiguredAnalyses(root, analyses), nil + return filterAnalyses(analyses, filters), nil }