Skip to content
Draft
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 @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
17 changes: 10 additions & 7 deletions blast_radius_fixes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
7 changes: 6 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 20 additions & 13 deletions scanner/astgrep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
84 changes: 70 additions & 14 deletions scanner/astgrep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
}
}

Expand Down
41 changes: 29 additions & 12 deletions scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,36 +39,48 @@ 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
// 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 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
}
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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") {
Expand Down
Loading
Loading