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
9 changes: 7 additions & 2 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -1314,8 +1314,13 @@ func renderImportersReportString(report scanner.ImportersReport) string {
func renderImportersReportCLI(w io.Writer, report scanner.ImportersReport) {
if len(report.Importers) == 0 && len(report.HubImports) == 0 {
fmt.Fprintf(w, "No files import %s.\n", report.File)
fmt.Fprintln(w, " Note: files in the same package never import each other (Go resolves")
fmt.Fprintln(w, " imports at package level), so only cross-package importers appear here.")
if strings.EqualFold(filepath.Ext(report.File), ".go") {
fmt.Fprintln(w, " Note: files in the same package never import each other (Go resolves")
fmt.Fprintln(w, " imports at package level), so only cross-package importers appear here.")
}
// Show scan provenance even for an empty answer, so a partial scan
// isn't read as a confident negative.
renderCoverage(w, report.CoverageStatus, report.CoverageNotes)
return
}
renderImportersReport(w, report)
Expand Down
25 changes: 25 additions & 0 deletions main_cli_polish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,31 @@ func TestRenderImportersReportExplainsEmptyResult(t *testing.T) {
}
}

// A partial scan must not read as a confident negative: the empty importers
// branch shows coverage and skips the Go same-package note for non-Go files.
func TestRenderImportersReportCLIEmitsCoverageInEmptyBranch(t *testing.T) {
report := scanner.ImportersReport{
Root: "/repo",
Mode: "importers",
File: "web/util.ts",
CoverageStatus: "partial",
CoverageNotes: []string{"ast-grep not found (checked bundled tools and PATH)", "Go parser fallback recovered 1 of 1 Go files"},
}

var buf strings.Builder
renderImportersReportCLI(&buf, report)
out := buf.String()
if !strings.Contains(out, "No files import web/util.ts") {
t.Fatalf("empty importers result must say so:\n%q", out)
}
if !strings.Contains(out, "Coverage: partial") {
t.Fatalf("empty result must carry the coverage provenance:\n%q", out)
}
if strings.Contains(out, "same package") {
t.Fatalf("Go same-package note must not be printed about a TypeScript file:\n%q", out)
}
}

func TestNonexistentPathGetsFriendlyError(t *testing.T) {
_, stderr, err := runCodemapWithInput("", "drift")
if err == nil {
Expand Down
91 changes: 84 additions & 7 deletions scanner/cargofallback.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ func buildFileGraphWithFallback(ctx context.Context, root string, scan dependenc
}

func buildFileGraphFromOutcomeWithCargoMetadataAndFilters(ctx context.Context, root string, outcome ScanOutcome, filters Filters, loader cargoMetadataLoader) (*FileGraph, error) {
for _, source := range outcome.Sources {
if source.Name == "cargo-metadata" && source.Status == ScanSourceFallback {
loader = nil
break
}
}
fg, err := buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx, root, outcome.Analyses, filters, loader, outcome.Sources...)
if err != nil {
return nil, err
Expand All @@ -32,7 +38,7 @@ func buildFileGraphFromOutcomeWithCargoMetadataAndFilters(ctx context.Context, r
}

func buildFileGraphWithFallbackWithFilters(ctx context.Context, root string, filters Filters, scan dependencyOutcomeScanner, loader cargoMetadataLoader) (*FileGraph, error) {
outcome, usedFallback, err := scanForGraphOutcomeWithFilters(ctx, root, filters, scan, loader)
outcome, usedFallback, err := scanForGraphOutcomeWithFilters(ctx, root, filters, scan, loader, true)
if err != nil {
return nil, err
}
Expand All @@ -43,10 +49,30 @@ func buildFileGraphWithFallbackWithFilters(ctx context.Context, root string, fil
return buildFileGraphFromOutcomeWithCargoMetadataAndFilters(ctx, root, outcome, filters, graphLoader)
}

func scanForGraphOutcomeWithFilters(ctx context.Context, root string, filters Filters, scan dependencyOutcomeScanner, loader cargoMetadataLoader) (ScanOutcome, bool, error) {
func scanForGraphOutcome(ctx context.Context, root string, scan dependencyOutcomeScanner, loader cargoMetadataLoader, allowCargoOnly ...bool) (ScanOutcome, bool, error) {
allow := true
if len(allowCargoOnly) > 0 {
allow = allowCargoOnly[0]
}
return scanForGraphOutcomeWithFilters(ctx, root, Filters{}, scan, loader, allow)
}

func scanForGraphOutcomeWithFilters(ctx context.Context, root string, filters Filters, scan dependencyOutcomeScanner, loader cargoMetadataLoader, allowCargoOnly bool) (ScanOutcome, bool, error) {
outcome, err := scan(root)
degraded := false
if err == nil {
return outcome, false, nil
// A degraded ast-grep outcome (timeout/failure) carries a nil error;
// treat it like an incomplete scan so the fallback still runs.
if incomplete := degradedAstGrepError(outcome); incomplete != nil {
err = incomplete
degraded = true
} else {
outcome.Analyses, err = filterAnalysesContext(ctx, outcome.Analyses, filters)
if err != nil {
return ScanOutcome{}, false, err
}
return outcome, false, nil
}
}

var incomplete *IncompleteScanError
Expand All @@ -60,14 +86,63 @@ func scanForGraphOutcomeWithFilters(ctx context.Context, root string, filters Fi
}
return ScanOutcome{}, false, err
}
fallback, fallbackErr := buildCargoFallbackOutcome(ctx, root, files, loader)
if fallbackErr != nil {
fallback := ScanOutcome{
Sources: []ScanSourceOutcome{incomplete.Outcome},
}
recovered := false
if goFallback, fallbackErr := buildGoFallbackOutcome(ctx, root, files); fallbackErr == nil {
mergeFallbackOutcome(&fallback, goFallback)
recovered = true
} else if ctx.Err() != nil {
return ScanOutcome{}, false, ctx.Err()
}
if err := ctx.Err(); err != nil {
return ScanOutcome{}, false, err
}
if recovered || allowCargoOnly {
if cargoFallback, fallbackErr := buildCargoFallbackOutcome(ctx, root, files, loader); fallbackErr == nil {
mergeFallbackOutcome(&fallback, cargoFallback)
recovered = true
}
}
if err := ctx.Err(); err != nil {
return ScanOutcome{}, false, err
}
if !recovered {
if degraded {
// Nothing recovered from a degraded scan: fail closed with the
// degraded outcome, not a hard error.
return outcome, false, nil
}
return ScanOutcome{}, false, err
}
fallback.Sources = append([]ScanSourceOutcome{incomplete.Outcome}, fallback.Sources...)
return fallback, true, nil
}

// degradedAstGrepError turns a fail-closed ast-grep outcome into the
// incomplete error the fallback gate recognizes, unless it already recovered
// analyses.
func degradedAstGrepError(outcome ScanOutcome) *IncompleteScanError {
if len(outcome.Analyses) > 0 {
return nil
}
for _, source := range outcome.Sources {
if source.Name != "ast-grep" {
continue
}
if source.Status == ScanSourceTimeout || source.Status == ScanSourceFailed {
return &IncompleteScanError{Outcome: source, Err: errors.New(source.Detail)}
}
}
return nil
}

func mergeFallbackOutcome(dst *ScanOutcome, src ScanOutcome) {
dst.Analyses = append(dst.Analyses, src.Analyses...)
dst.Sources = append(dst.Sources, src.Sources...)
dst.precomputedEdges = append(dst.precomputedEdges, src.precomputedEdges...)
}

func buildCargoFallbackOutcome(ctx context.Context, root string, files []FileInfo, loader cargoMetadataLoader) (ScanOutcome, error) {
manifests, err := discoverCargoManifests(ctx, root, files)
if err != nil {
Expand Down Expand Up @@ -154,7 +229,9 @@ func buildCargoFallbackOutcome(ctx context.Context, root string, files []FileInf
Sources: []ScanSourceOutcome{{
Name: "cargo-metadata",
Status: ScanSourceFallback,
Detail: fmt.Sprintf("Cargo metadata fallback recovered %d dependency edges from %d of %d manifests", len(edges), handled, len(manifests)),
// Recovered edges only reach the file graph, so don't claim them
// in the `--deps` payload.
Detail: fmt.Sprintf("Cargo metadata fallback used for %d of %d manifests", handled, len(manifests)),
}},
precomputedEdges: edges,
}, nil
Expand Down
Loading
Loading