Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
34 changes: 30 additions & 4 deletions blast_radius_fixes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"codemap/config"
"codemap/scanner"
)

Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
29 changes: 15 additions & 14 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -312,7 +313,7 @@ func main() {

// Importers mode - check file impact
if *importersMode != "" {
runImportersMode(absRoot, *importersMode, *jsonMode)
runImportersMode(absRoot, *importersMode, *jsonMode, filters)
return
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
159 changes: 156 additions & 3 deletions main_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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:"} {
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading