diff --git a/blast_radius.go b/blast_radius.go index d75959b..3491e0a 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -450,13 +450,14 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas // Single ast-grep scan shared by impact analysis, the deps project, and the // file graph below. Previously each of those triggered its own full-repo // ScanForDeps, tripling latency on large repositories. - var analyses []scanner.FileAnalysis + var scanOutcome scanner.ScanOutcome if diffTotal > 0 { - analyses, err = scanForDepsWithHint(absRoot, filters) + scanOutcome, err = scanForDepsOutcomeWithHint(absRoot, filters) if err != nil { return blastRadiusBundle{}, err } } + analyses := scanOutcome.Analyses diffProject := scanner.Project{ Root: absRoot, @@ -494,7 +495,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas depsTotal = len(depsProject.Files) depsCapped = capBlastRadiusDepsProject(depsProject, limits.MaxChangedFiles) - fg, err := scanner.BuildFileGraphFromFilteredAnalyses(absRoot, analyses, filters) + fg, err := scanner.BuildFileGraphFromOutcomeWithFilters(absRoot, scanOutcome, filters) if err != nil { return blastRadiusBundle{}, err } diff --git a/blast_radius_fixes_test.go b/blast_radius_fixes_test.go index f2874b2..40dda5b 100644 --- a/blast_radius_fixes_test.go +++ b/blast_radius_fixes_test.go @@ -300,8 +300,8 @@ func TestBlastRadiusOmitsEmptyImporterSections(t *testing.T) { } } -// Finding #10: BuildFileGraphFromAnalyses / AnalyzeImpactFromAnalyses must match -// the scanning wrappers so the single-scan refactor is behavior-preserving. +// Finding #10: injected scan outcomes must match the scanning wrappers so the +// single-scan refactor preserves both graph results and provenance. func TestBlastRadiusSingleScanParity(t *testing.T) { requireBlastRadiusTools(t) root := makeBlastRadiusHubRepo(t) @@ -314,27 +314,30 @@ func TestBlastRadiusSingleScanParity(t *testing.T) { cfg := config.Load(root) filters := scanner.Filters{Only: cfg.Only, Exclude: cfg.Exclude} - analyses, err := scanner.ScanForDepsWithFilters(root, filters) + outcome, err := scanner.ScanForDepsOutcomeWithFilters(root, filters) if err != nil { - t.Fatalf("ScanForDepsWithFilters: %v", err) + t.Fatalf("ScanForDepsOutcomeWithFilters: %v", err) } fgWrap, err := scanner.BuildFileGraph(root) if err != nil { t.Fatalf("BuildFileGraph: %v", err) } - fgInjected, err := scanner.BuildFileGraphFromFilteredAnalyses(root, analyses, filters) + fgInjected, err := scanner.BuildFileGraphFromOutcomeWithFilters(root, outcome, filters) if err != nil { - t.Fatalf("BuildFileGraphFromFilteredAnalyses: %v", err) + t.Fatalf("BuildFileGraphFromOutcomeWithFilters: %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", fgWrap.Importers["pkg/hub/hub.go"], fgInjected.Importers["pkg/hub/hub.go"]) } + if !reflect.DeepEqual(fgWrap.Coverage.Sources, fgInjected.Coverage.Sources) { + t.Fatalf("file graph provenance mismatch: %#v vs %#v", fgWrap.Coverage.Sources, fgInjected.Coverage.Sources) + } changed := []scanner.FileInfo{{Path: "pkg/hub/hub.go"}} impWrap := scanner.AnalyzeImpact(root, changed) - impInjected := scanner.AnalyzeImpactFromAnalyses(changed, analyses) + impInjected := scanner.AnalyzeImpactFromAnalyses(changed, outcome.Analyses) if len(impWrap) != len(impInjected) { t.Fatalf("impact parity mismatch: %v vs %v", impWrap, impInjected) } diff --git a/main.go b/main.go index c608353..5ae1e23 100644 --- a/main.go +++ b/main.go @@ -503,7 +503,12 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil // scanForDepsWithHint wraps scanner.ScanForDepsWithFilters (extracted for testability). func scanForDepsWithHint(root string, filters scanner.Filters) ([]FileAnalysis, error) { - return scanner.ScanForDepsWithFilters(root, filters) + outcome, err := scanForDepsOutcomeWithHint(root, filters) + return outcome.Analyses, err +} + +func scanForDepsOutcomeWithHint(root string, filters scanner.Filters) (scanner.ScanOutcome, error) { + return scanner.ScanForDepsOutcomeWithFilters(root, filters) } // runDepsFromStdin reads a JSON manifest from stdin, writes files to a temp diff --git a/scanner/astgrep.go b/scanner/astgrep.go index ceb86b8..ae3a5ad 100644 --- a/scanner/astgrep.go +++ b/scanner/astgrep.go @@ -204,10 +204,10 @@ func findNestedGitRepos(root string) []string { return repos } -// ScanDirectory analyzes all files in a directory using sg scan +// ScanDirectory analyzes all files in a directory using sg scan. func (s *AstGrepScanner) ScanDirectory(root string) ([]FileAnalysis, error) { if !s.Available() { - return nil, nil + return nil, newIncompleteScanError("ast-grep", ScanSourceUnavailable, ErrAstGrepNotFound.Error(), ErrAstGrepNotFound) } inlineRules := s.inlineRules @@ -241,33 +241,28 @@ func (s *AstGrepScanner) ScanDirectory(root string) ([]FileAnalysis, error) { out, err := cmd.Output() if err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { - fmt.Fprintf(os.Stderr, "warning: ast-grep timed out after %s in %s; skipping ast-grep results\n", astGrepScanTimeout, root) - return nil, nil + return nil, newIncompleteScanError("ast-grep", ScanSourceTimeout, fmt.Sprintf("ast-grep timed out after %s", astGrepScanTimeout), err) } var exitErr *exec.ExitError if errors.As(err, &exitErr) { if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() { - fmt.Fprintf(os.Stderr, "warning: ast-grep exited with signal %d in %s; skipping ast-grep results\n", status.Signal(), root) - return nil, nil + return nil, newIncompleteScanError("ast-grep", ScanSourceFailed, fmt.Sprintf("ast-grep terminated by signal %d", status.Signal()), err) } } - // sg scan returns non-zero if no matches, check if output contains JSON - if len(out) == 0 { - return nil, nil - } + return nil, newIncompleteScanError("ast-grep", ScanSourceFailed, "ast-grep exited unsuccessfully", err) } // Extract JSON array from output (handles debug output before JSON, e.g. ast-grep 0.40.2 bug) jsonData := extractJSONArray(out) if jsonData == nil { - return nil, nil + return nil, newIncompleteScanError("ast-grep", ScanSourceFailed, "ast-grep produced no JSON results", err) } var matches []ScanMatch - if err := json.Unmarshal(jsonData, &matches); err != nil { - return nil, err + if decodeErr := json.Unmarshal(jsonData, &matches); decodeErr != nil { + return nil, newIncompleteScanError("ast-grep", ScanSourceFailed, "ast-grep produced invalid JSON results", decodeErr) } // Group matches by file @@ -345,6 +340,18 @@ func (s *AstGrepScanner) ScanDirectory(root string) ([]FileAnalysis, error) { return results, nil } +// ScanDirectoryOutcome analyzes a directory and records ast-grep provenance. +func (s *AstGrepScanner) ScanDirectoryOutcome(root string) (ScanOutcome, error) { + analyses, err := s.ScanDirectory(root) + if err != nil { + return ScanOutcome{}, err + } + return ScanOutcome{ + Analyses: analyses, + Sources: []ScanSourceOutcome{{Source: "ast-grep", Status: ScanSourceAuthoritative}}, + }, nil +} + func dedupeImportReferences(refs []ImportReference) []ImportReference { seen := make(map[ImportReference]bool) result := make([]ImportReference, 0, len(refs)) diff --git a/scanner/astgrep_test.go b/scanner/astgrep_test.go index 0fc473e..ff0f32b 100644 --- a/scanner/astgrep_test.go +++ b/scanner/astgrep_test.go @@ -228,7 +228,7 @@ namespace TestApp } } -func TestAstGrepScanDirectoryTimeout(t *testing.T) { +func TestAstGrepScanDirectoryTimeoutIsIncomplete(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("requires shell script execution") } @@ -239,23 +239,79 @@ func TestAstGrepScanDirectoryTimeout(t *testing.T) { t.Fatalf("failed to create fake ast-grep binary: %v", err) } - scanner := &AstGrepScanner{ - rulesDir: tmpDir, - binary: fakeBinary, - } - + scanner := &AstGrepScanner{rulesDir: tmpDir, binary: fakeBinary} prevTimeout := astGrepScanTimeout astGrepScanTimeout = 20 * time.Millisecond - t.Cleanup(func() { - astGrepScanTimeout = prevTimeout - }) + t.Cleanup(func() { astGrepScanTimeout = prevTimeout }) - results, err := scanner.ScanDirectory(tmpDir) - if err != nil { - t.Fatalf("expected graceful timeout handling, got error: %v", err) + _, err := scanner.ScanDirectory(tmpDir) + var incomplete *IncompleteScanError + if !errors.As(err, &incomplete) { + t.Fatalf("error = %v, want IncompleteScanError", err) + } + if got, want := incomplete.Outcome.Status, ScanSourceTimeout; got != want { + t.Fatalf("status = %q, want %q", got, want) + } +} + +func TestAstGrepScanDirectoryOutcomeDistinguishesEmptyFromFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires shell script execution") + } + + for _, tt := range []struct { + name string + script string + wantStatus ScanSourceStatus + wantOK bool + }{ + {name: "valid empty result", script: "#!/bin/sh\nprintf '[]\\n'\n", wantStatus: ScanSourceAuthoritative, wantOK: true}, + {name: "nonzero JSON output", script: "#!/bin/sh\nprintf '[]\\n'\nexit 2\n", wantStatus: ScanSourceFailed}, + {name: "malformed JSON", script: "#!/bin/sh\nprintf '[{'\n", wantStatus: ScanSourceFailed}, + {name: "terminated by signal", script: "#!/bin/sh\nkill -TERM $$\n", wantStatus: ScanSourceFailed}, + } { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + fakeBinary := filepath.Join(tmpDir, "fake-sg.sh") + if err := os.WriteFile(fakeBinary, []byte(tt.script), 0755); err != nil { + t.Fatal(err) + } + scanner := &AstGrepScanner{rulesDir: tmpDir, binary: fakeBinary} + + outcome, err := scanner.ScanDirectoryOutcome(tmpDir) + if tt.wantOK { + if err != nil { + t.Fatalf("ScanDirectoryOutcome() error = %v", err) + } + if len(outcome.Analyses) != 0 { + t.Fatalf("analyses = %#v, want empty", outcome.Analyses) + } + if got := outcome.Sources[0].Status; got != tt.wantStatus { + t.Fatalf("status = %q, want %q", got, tt.wantStatus) + } + return + } + + var incomplete *IncompleteScanError + if !errors.As(err, &incomplete) { + t.Fatalf("error = %v, want IncompleteScanError", err) + } + if got := incomplete.Outcome.Status; got != tt.wantStatus { + t.Fatalf("status = %q, want %q", got, tt.wantStatus) + } + }) + } +} + +func TestAstGrepScanDirectoryUnavailableIsIncomplete(t *testing.T) { + scanner := &AstGrepScanner{} + _, err := scanner.ScanDirectoryOutcome(t.TempDir()) + var incomplete *IncompleteScanError + if !errors.As(err, &incomplete) { + t.Fatalf("error = %v, want IncompleteScanError", err) } - if results != nil { - t.Fatalf("expected nil results on timeout, got: %v", results) + if got, want := incomplete.Outcome.Status, ScanSourceUnavailable; got != want { + t.Fatalf("status = %q, want %q", got, want) } } diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 2514ff5..17fbbfb 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -39,11 +39,25 @@ func BuildFileGraph(root string) (*FileGraph, error) { // BuildFileGraphWithFilters analyzes a project with explicit filters. func BuildFileGraphWithFilters(root string, filters Filters) (*FileGraph, error) { - analyses, err := ScanForDepsWithFilters(root, filters) + outcome, err := ScanForDepsOutcomeWithFilters(root, filters) if err != nil { return nil, err } - return BuildFileGraphFromFilteredAnalyses(root, analyses, filters) + return BuildFileGraphFromOutcomeWithFilters(root, outcome, filters) +} + +// BuildFileGraphFromOutcome builds a graph without dropping scanner provenance. +func BuildFileGraphFromOutcome(root string, outcome ScanOutcome) (*FileGraph, error) { + cfg := config.Load(root) + filters := Filters{Only: cfg.Only, Exclude: cfg.Exclude} + outcome.Analyses = filterAnalyses(outcome.Analyses, filters) + return BuildFileGraphFromOutcomeWithFilters(root, outcome, filters) +} + +// BuildFileGraphFromOutcomeWithFilters builds a graph from an outcome that +// already matches the supplied filters. +func BuildFileGraphFromOutcomeWithFilters(root string, outcome ScanOutcome, filters Filters) (*FileGraph, error) { + return buildFileGraphFromAnalysesWithCargoMetadataAndFilters(context.Background(), root, outcome.Analyses, filters, loadCargoMetadata, outcome.Sources...) } // BuildFileGraphFromAnalyses builds a file graph from pre-computed analyses @@ -51,24 +65,22 @@ func BuildFileGraphWithFilters(root string, filters Filters) (*FileGraph, error) func BuildFileGraphFromAnalyses(root string, analyses []FileAnalysis) (*FileGraph, error) { cfg := config.Load(root) filters := Filters{Only: cfg.Only, Exclude: cfg.Exclude} - return buildFileGraphFromFilteredAnalysesWithCargoMetadata(context.Background(), root, filterAnalyses(analyses, filters), filters, loadCargoMetadata) + 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) { - return buildFileGraphFromFilteredAnalysesWithCargoMetadata(context.Background(), root, analyses, filters, loadCargoMetadata) + return buildFileGraphFromAnalysesWithCargoMetadataAndFilters(context.Background(), root, analyses, filters, loadCargoMetadata) } -// buildFileGraphFromAnalysesWithCargoMetadata is the testable configuration -// aware variant of BuildFileGraphFromAnalyses. func buildFileGraphFromAnalysesWithCargoMetadata(ctx context.Context, root string, analyses []FileAnalysis, loader cargoMetadataLoader) (*FileGraph, error) { cfg := config.Load(root) filters := Filters{Only: cfg.Only, Exclude: cfg.Exclude} - return buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx, root, filterAnalyses(analyses, filters), filters, loader) + return buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx, root, filterAnalyses(analyses, filters), filters, loader) } -func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, root string, analyses []FileAnalysis, filters Filters, loader cargoMetadataLoader) (*FileGraph, error) { +func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, root string, analyses []FileAnalysis, filters Filters, loader cargoMetadataLoader, sources ...ScanSourceOutcome) (*FileGraph, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -84,6 +96,9 @@ func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, ro Packages: make(map[string][]string), PathAliases: make(map[string][]string), } + for _, source := range sources { + fg.Coverage.AddSource(source) + } // Detect module name from go.mod (for Go import resolution) fg.Module = detectModule(absRoot) @@ -97,7 +112,10 @@ func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, ro if err != nil { return nil, err } - rustWorkspace := buildRustWorkspaceIndex(ctx, absRoot, analyses, files, loader) + rustWorkspace, cargoOutcome := buildRustWorkspaceIndex(ctx, absRoot, analyses, files, loader) + if cargoOutcome != nil { + fg.Coverage.AddSource(*cargoOutcome) + } if err := ctx.Err(); err != nil { return nil, err } @@ -107,7 +125,8 @@ func buildFileGraphFromFilteredAnalysesWithCargoMetadata(ctx context.Context, ro fg.Packages = idx.goPkgs for _, file := range files { if strings.EqualFold(filepath.Ext(file.Path), ".rs") { - fg.Coverage = GraphCoverage{Status: rustCoverageStatus, Notes: []string{rustCoverageNote}} + fg.Coverage.Status = rustCoverageStatus + fg.Coverage.Notes = append(fg.Coverage.Notes, rustCoverageNote) break } } @@ -438,8 +457,6 @@ const HubThreshold = 3 // IsTestFile reports whether a path names a test file across the supported // languages (Go _test files, JS/TS .test/.spec files, Python test_ modules). -// Test importers are real graph edges but shouldn't confer hub status: a file -// imported only by its own tests doesn't have blast radius. func IsTestFile(path string) bool { base := strings.ToLower(filepath.Base(filepath.FromSlash(path))) if strings.HasSuffix(base, "_test.go") { diff --git a/scanner/outcome.go b/scanner/outcome.go new file mode 100644 index 0000000..98998dd --- /dev/null +++ b/scanner/outcome.go @@ -0,0 +1,80 @@ +package scanner + +import "fmt" + +// ScanSourceStatus describes the trust level of one dependency-analysis source. +type ScanSourceStatus string + +const ( + ScanSourceAuthoritative ScanSourceStatus = "authoritative" + ScanSourceMixed ScanSourceStatus = "mixed" + ScanSourceFallback ScanSourceStatus = "fallback" + ScanSourceTimeout ScanSourceStatus = "timeout" + ScanSourceUnavailable ScanSourceStatus = "unavailable" + ScanSourceFailed ScanSourceStatus = "failed" +) + +// ScanSourceOutcome records how one scanner contributed to an analysis. +type ScanSourceOutcome struct { + Source string `json:"source"` + Status ScanSourceStatus `json:"status"` + Detail string `json:"detail,omitempty"` +} + +// ScanOutcome contains dependency analyses and their provenance. +type ScanOutcome struct { + Analyses []FileAnalysis `json:"analyses"` + Sources []ScanSourceOutcome `json:"sources,omitempty"` +} + +// GraphCoverage describes graph blind spots and scanner provenance. +type GraphCoverage struct { + Status string `json:"status,omitempty"` + Notes []string `json:"notes,omitempty"` + Sources []ScanSourceOutcome `json:"sources,omitempty"` +} + +// AddSource records one scanner outcome and marks degraded results partial. +func (c *GraphCoverage) AddSource(outcome ScanSourceOutcome) { + if c == nil || outcome.Source == "" { + return + } + c.Sources = append(c.Sources, outcome) + if outcome.Status == ScanSourceAuthoritative { + return + } + c.Status = "partial" + if outcome.Detail != "" { + c.Notes = append(c.Notes, outcome.Detail) + } +} + +// IncompleteScanError prevents incomplete scanner output from looking authoritative. +type IncompleteScanError struct { + Outcome ScanSourceOutcome + Err error +} + +func newIncompleteScanError(source string, status ScanSourceStatus, detail string, err error) error { + return &IncompleteScanError{ + Outcome: ScanSourceOutcome{Source: source, Status: status, Detail: detail}, + Err: err, + } +} + +func (e *IncompleteScanError) Error() string { + if e == nil { + return "incomplete dependency scan" + } + if e.Outcome.Detail != "" { + return e.Outcome.Detail + } + return fmt.Sprintf("%s dependency scan is %s", e.Outcome.Source, e.Outcome.Status) +} + +func (e *IncompleteScanError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} diff --git a/scanner/outcome_test.go b/scanner/outcome_test.go new file mode 100644 index 0000000..f7ccdeb --- /dev/null +++ b/scanner/outcome_test.go @@ -0,0 +1,162 @@ +package scanner + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func requireSourceOutcome(t *testing.T, coverage GraphCoverage, source string) ScanSourceOutcome { + t.Helper() + for _, outcome := range coverage.Sources { + if outcome.Source == source { + return outcome + } + } + t.Fatalf("source %q missing from %#v", source, coverage.Sources) + return ScanSourceOutcome{} +} + +func TestGraphCoverageSourceOutcomesPreserveSemanticCoverage(t *testing.T) { + coverage := GraphCoverage{Status: "partial", Notes: []string{"dynamic routes unresolved"}} + coverage.AddSource(ScanSourceOutcome{Source: "ast-grep", Status: ScanSourceAuthoritative}) + coverage.AddSource(ScanSourceOutcome{Source: "cargo-metadata", Status: ScanSourceFallback, Detail: "1 of 1 Cargo manifests used fallback topology"}) + + if got, want := coverage.Status, "partial"; got != want { + t.Fatalf("status = %q, want %q", got, want) + } + if got := requireSourceOutcome(t, coverage, "ast-grep").Status; got != ScanSourceAuthoritative { + t.Fatalf("ast-grep status = %q", got) + } + if got := requireSourceOutcome(t, coverage, "cargo-metadata").Status; got != ScanSourceFallback { + t.Fatalf("cargo status = %q", got) + } + if got, want := coverage.Notes[len(coverage.Notes)-1], "1 of 1 Cargo manifests used fallback topology"; got != want { + t.Fatalf("last note = %q, want %q", got, want) + } +} + +func TestScannerOutcomeGuardPaths(t *testing.T) { + var nilCoverage *GraphCoverage + nilCoverage.AddSource(ScanSourceOutcome{Source: "ignored", Status: ScanSourceFailed}) + + coverage := GraphCoverage{} + coverage.AddSource(ScanSourceOutcome{}) + if len(coverage.Sources) != 0 { + t.Fatalf("empty source was recorded: %#v", coverage.Sources) + } + + cause := errors.New("scanner failed") + withDetail := &IncompleteScanError{ + Outcome: ScanSourceOutcome{Source: "scanner", Status: ScanSourceFailed, Detail: "stable detail"}, + Err: cause, + } + if got, want := withDetail.Error(), "stable detail"; got != want { + t.Fatalf("detail error = %q, want %q", got, want) + } + if !errors.Is(withDetail, cause) { + t.Fatal("wrapped scanner error is not discoverable") + } + + withoutDetail := &IncompleteScanError{Outcome: ScanSourceOutcome{Source: "scanner", Status: ScanSourceFailed}} + if got, want := withoutDetail.Error(), "scanner dependency scan is failed"; got != want { + t.Fatalf("fallback error = %q, want %q", got, want) + } + + var nilError *IncompleteScanError + if got, want := nilError.Error(), "incomplete dependency scan"; got != want { + t.Fatalf("nil error = %q, want %q", got, want) + } + if nilError.Unwrap() != nil { + t.Fatal("nil error unwrap must be nil") + } +} + +func TestCargoMetadataOutcomesAndRetry(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "pub fn call() {}\n", + }) + metadata := cargoMetadataJSON(t, root, []map[string]any{cargoPackage(root, ".", "app", "app", nil)}) + analyses := []FileAnalysis{{Path: "src/lib.rs", Language: "rust"}} + attempts := 0 + loader := func(context.Context, string) ([]byte, error) { + attempts++ + if attempts == 1 { + return []byte("{\"packages\":[]}"), nil + } + return metadata, nil + } + + first, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, loader) + if err != nil { + t.Fatal(err) + } + if got := requireSourceOutcome(t, first.Coverage, "cargo-metadata").Status; got != ScanSourceFallback { + t.Fatalf("first status = %q, want %q", got, ScanSourceFallback) + } + + second, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, analyses, loader) + if err != nil { + t.Fatal(err) + } + if got := requireSourceOutcome(t, second.Coverage, "cargo-metadata").Status; got != ScanSourceAuthoritative { + t.Fatalf("second status = %q, want %q", got, ScanSourceAuthoritative) + } + if attempts != 2 { + t.Fatalf("metadata attempts = %d, want 2", attempts) + } +} + +func TestCargoMetadataMixedOutcome(t *testing.T) { + root := t.TempDir() + writeRustCargoFixture(t, root, map[string]string{ + "Cargo.toml": "[package]\nname = \"fallback\"\nversion = \"0.1.0\"\n", + "src/lib.rs": "pub fn fallback() {}\n", + "nested/Cargo.toml": "[package]\nname = \"nested\"\nversion = \"0.1.0\"\n", + "nested/src/lib.rs": "pub fn nested() {}\n", + }) + nestedRoot := filepath.Join(root, "nested") + metadata := cargoMetadataJSON(t, nestedRoot, []map[string]any{cargoPackage(root, "nested", "nested", "nested", nil)}) + loader := func(_ context.Context, manifest string) ([]byte, error) { + if filepath.Clean(manifest) == filepath.Join(nestedRoot, "Cargo.toml") { + return metadata, nil + } + return nil, errors.New("cargo unavailable") + } + graph, err := buildFileGraphFromAnalysesWithCargoMetadata(context.Background(), root, []FileAnalysis{ + {Path: "src/lib.rs", Language: "rust"}, + {Path: "nested/src/lib.rs", Language: "rust"}, + }, loader) + if err != nil { + t.Fatal(err) + } + if got := requireSourceOutcome(t, graph.Coverage, "cargo-metadata").Status; got != ScanSourceMixed { + t.Fatalf("status = %q, want %q", got, ScanSourceMixed) + } +} + +func TestNonCargoGraphHasNoCargoOutcome(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0644); err != nil { + t.Fatal(err) + } + graph, err := BuildFileGraphFromOutcome(root, ScanOutcome{ + Analyses: []FileAnalysis{{Path: "main.go", Language: "go"}}, + Sources: []ScanSourceOutcome{{Source: "ast-grep", Status: ScanSourceAuthoritative}}, + }) + if err != nil { + t.Fatal(err) + } + if got := requireSourceOutcome(t, graph.Coverage, "ast-grep").Status; got != ScanSourceAuthoritative { + t.Fatalf("ast-grep status = %q", got) + } + for _, outcome := range graph.Coverage.Sources { + if outcome.Source == "cargo-metadata" { + t.Fatalf("unexpected Cargo outcome: %#v", outcome) + } + } +} diff --git a/scanner/rustcargo.go b/scanner/rustcargo.go index 081bd97..f60f368 100644 --- a/scanner/rustcargo.go +++ b/scanner/rustcargo.go @@ -3,6 +3,7 @@ package scanner import ( "context" "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -74,10 +75,15 @@ func parseCargoMetadata(data []byte) (cargoMetadata, error) { return metadata, err } -func buildRustWorkspaceIndex(ctx context.Context, root string, analyses []FileAnalysis, files []FileInfo, loader cargoMetadataLoader) *rustWorkspaceIndex { +func buildRustWorkspaceIndex(ctx context.Context, root string, analyses []FileAnalysis, files []FileInfo, loader cargoMetadataLoader) (*rustWorkspaceIndex, *ScanSourceOutcome) { index := buildRustFallbackWorkspaceIndex(root, analyses) + manifestPaths := discoverCargoManifests(root, files) + if len(manifestPaths) == 0 { + return index, nil + } if loader == nil { - return index + outcome := cargoMetadataOutcome(0, len(manifestPaths)) + return index, &outcome } ctx, cancel := context.WithTimeout(ctx, cargoMetadataTimeout) defer cancel() @@ -89,7 +95,7 @@ func buildRustWorkspaceIndex(ctx context.Context, root string, analyses []FileAn pendingByRoot := make(map[string][]pendingRustDependency) handledManifests := make(map[string]bool) - for _, manifestPath := range discoverCargoManifests(root, files) { + for _, manifestPath := range manifestPaths { manifestPath = filepath.Clean(manifestPath) if handledManifests[manifestPath] { continue @@ -103,16 +109,20 @@ func buildRustWorkspaceIndex(ctx context.Context, root string, analyses []FileAn continue } - handledManifests[manifestPath] = true + covered := false for _, metadataPackage := range metadata.Packages { pkg, pending, ok := rustPackageFromCargoMetadata(root, metadataPackage) - if !ok { + if !ok || (pkg.lib == nil && len(pkg.targets) == 0) { continue } + covered = true packagesByRoot[pkg.root] = pkg pendingByRoot[pkg.root] = pending handledManifests[filepath.Clean(metadataPackage.ManifestPath)] = true } + if covered { + handledManifests[manifestPath] = true + } } for rootPath, pending := range pendingByRoot { @@ -152,7 +162,29 @@ func buildRustWorkspaceIndex(ctx context.Context, root string, analyses []FileAn } return len(index.packages[i].root) > len(index.packages[j].root) }) - return index + + handled := 0 + for _, manifestPath := range manifestPaths { + if handledManifests[filepath.Clean(manifestPath)] { + handled++ + } + } + outcome := cargoMetadataOutcome(handled, len(manifestPaths)) + return index, &outcome +} + +func cargoMetadataOutcome(handled, total int) ScanSourceOutcome { + outcome := ScanSourceOutcome{Source: "cargo-metadata", Status: ScanSourceAuthoritative} + fallbacks := total - handled + if fallbacks <= 0 { + return outcome + } + outcome.Status = ScanSourceMixed + if handled == 0 { + outcome.Status = ScanSourceFallback + } + outcome.Detail = fmt.Sprintf("%d of %d Cargo manifests used fallback topology", fallbacks, total) + return outcome } func discoverCargoManifests(root string, files []FileInfo) []string { diff --git a/scanner/rustgraph.go b/scanner/rustgraph.go index 39d18ba..eb0b6d8 100644 --- a/scanner/rustgraph.go +++ b/scanner/rustgraph.go @@ -21,12 +21,6 @@ const ( rustTargetCustomBuild = "custom-build" ) -// GraphCoverage describes known dependency-graph blind spots. -type GraphCoverage struct { - Status string `json:"status,omitempty"` - Notes []string `json:"notes,omitempty"` -} - type rustTarget struct { rootFile string sourceDir string diff --git a/scanner/walker.go b/scanner/walker.go index 9d4e28a..73d85d0 100644 --- a/scanner/walker.go +++ b/scanner/walker.go @@ -326,7 +326,7 @@ func filterConfiguredAnalyses(root string, analyses []FileAnalysis) []FileAnalys return filterAnalyses(analyses, Filters{Only: cfg.Only, Exclude: cfg.Exclude}) } -// ScanForDeps uses the configured project filters for batched dependency analysis. +// ScanForDeps uses ast-grep for batched dependency analysis with configured filters. func ScanForDeps(root string) ([]FileAnalysis, error) { cfg := config.Load(root) return ScanForDepsWithFilters(root, Filters{Only: cfg.Only, Exclude: cfg.Exclude}) @@ -334,19 +334,29 @@ func ScanForDeps(root string) ([]FileAnalysis, error) { // ScanForDepsWithFilters uses ast-grep for batched dependency analysis with explicit filters. func ScanForDepsWithFilters(root string, filters Filters) ([]FileAnalysis, error) { + outcome, err := ScanForDepsOutcomeWithFilters(root, filters) + return outcome.Analyses, err +} + +// ScanForDepsOutcome returns dependency analyses with configured filters and provenance. +func ScanForDepsOutcome(root string) (ScanOutcome, error) { + cfg := config.Load(root) + return ScanForDepsOutcomeWithFilters(root, Filters{Only: cfg.Only, Exclude: cfg.Exclude}) +} + +// ScanForDepsOutcomeWithFilters returns dependency analyses with explicit filters +// and scanner provenance. +func ScanForDepsOutcomeWithFilters(root string, filters Filters) (ScanOutcome, error) { astScanner, err := NewAstGrepScanner() if err != nil { - return nil, err + return ScanOutcome{}, err } defer astScanner.Close() - if !astScanner.Available() { - return nil, ErrAstGrepNotFound - } - - analyses, err := astScanner.ScanDirectory(root) + outcome, err := astScanner.ScanDirectoryOutcome(root) if err != nil { - return nil, err + return ScanOutcome{}, err } - return filterAnalyses(analyses, filters), nil + outcome.Analyses = filterAnalyses(outcome.Analyses, filters) + return outcome, nil }