diff --git a/blast_radius.go b/blast_radius.go index 29ea98c..34f3f3f 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -1034,6 +1034,12 @@ func buildBlastRadiusRendered(diffProject scanner.Project, depsProject scanner.D } var renderable []renderableImporter for _, report := range reports { + // Decide on the data, not on the rendered text: every report now + // carries a coverage line, so rendering first would make a file with + // no importers look like it had content. + if len(report.Importers) == 0 && len(report.HubImports) == 0 { + continue + } text := renderImportersReportString(report) if strings.TrimSpace(text) == "" { continue @@ -1449,7 +1455,7 @@ func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) (sc Imports: imports, ImporterCount: len(importers), IsHub: fg.IsHub(file), - CoverageStatus: string(fg.Coverage.Status), + CoverageStatus: string(fg.Coverage.EffectiveStatus()), CoverageNotes: append([]string(nil), fg.Coverage.Notes...), } diff --git a/mcp/analysis_output.go b/mcp/analysis_output.go index 4d55227..fa9390c 100644 --- a/mcp/analysis_output.go +++ b/mcp/analysis_output.go @@ -94,7 +94,7 @@ func newImportersOutput(root, file string, graph *scanner.FileGraph) ImportersOu Kind: kind, Root: root, Mode: "importers", File: file, Importers: boundedImporters, Imports: boundedImports, HubImports: boundedHubs, ImporterCount: len(importers), IsHub: graph.IsHub(file), - CoverageStatus: string(graph.Coverage.Status), CoverageNotes: nonNilCopy(graph.Coverage.Notes), + CoverageStatus: string(graph.Coverage.EffectiveStatus()), CoverageNotes: nonNilCopy(graph.Coverage.Notes), Truncated: omittedCount > 0, OmittedCount: omittedCount, } } diff --git a/mcp/structured_contract_test.go b/mcp/structured_contract_test.go index 77c5db8..84f5a92 100644 --- a/mcp/structured_contract_test.go +++ b/mcp/structured_contract_test.go @@ -228,6 +228,26 @@ func writeParityRepository(t *testing.T) string { return root } +// A graph built with nothing to report against it has the zero-value status. +// The MCP answer must spell that out as "complete": an empty coverage_status +// makes a zero-importer answer on a fully checked graph indistinguishable from +// one the scanner could not check at all (#148). +func TestNewImportersOutputSpellsOutCompleteCoverage(t *testing.T) { + graph := &scanner.FileGraph{ + Importers: map[string][]string{}, + Imports: map[string][]string{}, + } + out := newImportersOutput("/repo", "pkg/user.go", graph) + if out.CoverageStatus != "complete" { + t.Fatalf("coverage_status = %q, want %q for a zero-value graph coverage", out.CoverageStatus, "complete") + } + graph.Coverage.Status = "partial" + out = newImportersOutput("/repo", "pkg/user.go", graph) + if out.CoverageStatus != "partial" { + t.Fatalf("coverage_status = %q, want %q when the graph reports partial", out.CoverageStatus, "partial") + } +} + func TestNewImportersOutputSortsDeterministically(t *testing.T) { graph := &scanner.FileGraph{ Importers: map[string][]string{ diff --git a/scanner/filegraph.go b/scanner/filegraph.go index 73227c1..bd6c4f5 100644 --- a/scanner/filegraph.go +++ b/scanner/filegraph.go @@ -183,6 +183,11 @@ func buildFileGraphFromAnalysesWithCargoMetadataAndFilters(ctx context.Context, break } } + // Languages whose imports name modules rather than files cannot produce + // intra-project edges at all, so an empty graph over them is a blind spot + // rather than a finding. Recording it here is what keeps --importers and + // blast-radius honest too: both read this graph's provenance. + fg.Coverage.AddSymbolLevelImportCoverage(files) var jsResolver *jsWorkspaceResolver if useJSWorkspace { diff --git a/scanner/importmodel.go b/scanner/importmodel.go new file mode 100644 index 0000000..5e2a8a5 --- /dev/null +++ b/scanner/importmodel.go @@ -0,0 +1,133 @@ +package scanner + +import ( + "fmt" + "sort" + "strings" + + "codemap/analysis" +) + +// symbolLevelImportLanguages are languages whose import statements name a +// module, package or namespace rather than a file, and where two files in the +// same package reference each other with no import statement at all. The +// file-to-file edge model cannot represent intra-package structure for them: +// resolving their imports yields framework names (SwiftUI, System.Text) that +// belong to no file in the project, so the graph comes back structurally empty +// rather than empty-by-accident. +// +// Languages absent from this set express intra-project structure as a path an +// import can be resolved to (Go package paths, Python module paths, relative +// JS/TS specifiers, Rust mod declarations, C/C++ includes), so an empty graph +// there is a real finding and stays complete. +var symbolLevelImportLanguages = map[string]string{ + "csharp": "C#", + "java": "Java", + "kotlin": "Kotlin", + "scala": "Scala", + "swift": "Swift", +} + +// symbolLevelCoverageNote explains why a symbol-level language cannot be +// covered by file-level import resolution, in the terms a consumer needs to +// decide whether to trust a zero-dependent answer. +const symbolLevelCoverageNote = "imports name modules, not files, and same-package files need no import: intra-project edges need symbol-reference resolution and are not represented" + +// ResolvesFileLevelImports reports whether import resolution can produce +// file-to-file edges for a language. +func ResolvesFileLevelImports(language string) bool { + _, symbolLevel := symbolLevelImportLanguages[language] + return !symbolLevel +} + +// symbolLevelInventory counts scanned files per symbol-level language, keyed by +// the language's display name. +func symbolLevelInventory(files []FileInfo) map[string]int { + counts := make(map[string]int) + for _, file := range files { + display, symbolLevel := symbolLevelImportLanguages[DetectLanguage(file.Path)] + if symbolLevel { + counts[display]++ + } + } + return counts +} + +// symbolLevelSources renders one source per symbol-level language present, so +// the source list names which slice of the project the graph cannot see rather +// than emitting a bare "partial" with nothing to point at. +func symbolLevelSources(counts map[string]int) []analysis.Source { + if len(counts) == 0 { + return nil + } + displays := make([]string, 0, len(counts)) + for display := range counts { + displays = append(displays, display) + } + sort.Strings(displays) + + sources := make([]analysis.Source, 0, len(displays)) + for _, display := range displays { + sources = append(sources, analysis.Source{ + Name: "symbol-imports/" + strings.ToLower(display), + Status: analysis.SourceUnavailable, + Detail: fmt.Sprintf("%s (%d files): %s", display, counts[display], symbolLevelCoverageNote), + }) + } + return sources +} + +// ApplySymbolLevelImportCoverage downgrades coverage that would otherwise claim +// complete knowledge of a project whose files are in languages the file-level +// edge model does not apply to. A consumer reading "complete" next to zero +// dependents concludes a change is isolated; for these languages that +// conclusion is unfounded, so the status must not be complete and the reason +// must be attached. +// +// Coverage that is already partial or unavailable keeps its status; this only +// ever removes confidence. +func ApplySymbolLevelImportCoverage(coverage analysis.Coverage, files []FileInfo) analysis.Coverage { + sources := symbolLevelSources(symbolLevelInventory(files)) + if len(sources) == 0 { + return coverage + } + coverage.Sources = append(append([]analysis.Source(nil), coverage.Sources...), sources...) + if coverage.Status == analysis.CoverageComplete { + coverage.Status = analysis.CoveragePartial + } + return analysis.NormalizeCoverage(coverage) +} + +// AddSymbolLevelImportCoverage applies the same rule to a graph's coverage, so +// --importers and blast-radius inherit it alongside --deps. +func (c *GraphCoverage) AddSymbolLevelImportCoverage(files []FileInfo) { + if c == nil { + return + } + counts := symbolLevelInventory(files) + sources := symbolLevelSources(counts) + if len(sources) == 0 { + return + } + c.Sources = append(c.Sources, sources...) + displays := make([]string, 0, len(counts)) + for display := range counts { + displays = append(displays, display) + } + sort.Strings(displays) + c.Notes = append(c.Notes, fmt.Sprintf("%s: %s", strings.Join(displays, ", "), symbolLevelCoverageNote)) + if c.Status == "" || c.Status == analysis.CoverageComplete { + c.Status = analysis.CoveragePartial + } +} + +// EffectiveStatus reports the coverage status a consumer should see. The zero +// value means the graph was built with nothing to report against it, which is +// complete knowledge — but an empty string tells a consumer nothing, so it is +// spelled out. A zero-importer answer has to say how much it checked. +func (c GraphCoverage) EffectiveStatus() analysis.CoverageStatus { + if c.Status == "" { + return analysis.CoverageComplete + } + return c.Status +} diff --git a/scanner/importmodel_test.go b/scanner/importmodel_test.go new file mode 100644 index 0000000..9e318e8 --- /dev/null +++ b/scanner/importmodel_test.go @@ -0,0 +1,157 @@ +package scanner + +import ( + "context" + "strings" + "testing" + + "codemap/analysis" +) + +// A Swift project is the clearest case of a language whose files never import +// each other: the graph is structurally empty, so reporting complete coverage +// tells a consumer a change is isolated when nothing checked that. +func TestSwiftFixtureNeverReportsCompleteCoverage(t *testing.T) { + graph, err := BuildFileGraph(context.Background(), "../testdata/symbol-imports-swift", Filters{}) + if err != nil { + t.Fatalf("build swift fixture graph: %v", err) + } + if graph.Coverage.Status == analysis.CoverageComplete { + t.Fatalf("swift fixture coverage = %q, want anything but complete", graph.Coverage.Status) + } + if edges := len(graph.Imports) + len(graph.Importers); edges != 0 { + t.Fatalf("swift fixture produced %d edges, want 0 (the fixture has no file-level imports)", edges) + } + + var named bool + for _, source := range graph.Coverage.Sources { + if source.Name == "symbol-imports/swift" { + named = true + if !strings.Contains(source.Detail, "Swift (3 files)") { + t.Fatalf("source detail = %q, want it to name the language and file count", source.Detail) + } + } + } + if !named { + t.Fatalf("coverage sources %+v omit a symbol-imports source naming Swift", graph.Coverage.Sources) + } + if len(graph.Coverage.Notes) == 0 { + t.Fatal("coverage carries no note explaining why the graph cannot see intra-project edges") + } +} + +// The counterpart guard: a language whose imports do resolve to files keeps +// complete coverage, so this never becomes a blanket downgrade. +func TestGoFixtureKeepsCompleteCoverage(t *testing.T) { + graph, err := BuildFileGraph(context.Background(), "../testdata/file-imports-go", Filters{}) + if err != nil { + t.Fatalf("build go fixture graph: %v", err) + } + if graph.Coverage.Status != "" && graph.Coverage.Status != analysis.CoverageComplete { + t.Fatalf("go fixture coverage = %q, want complete", graph.Coverage.Status) + } + for _, source := range graph.Coverage.Sources { + if strings.HasPrefix(source.Name, "symbol-imports/") { + t.Fatalf("go fixture gained %q; the file-level edge model applies to Go", source.Name) + } + } +} + +func TestResolvesFileLevelImports(t *testing.T) { + for _, language := range []string{"go", "python", "typescript", "javascript", "rust", "c", "cpp", "dart", "ruby", "php", "lua", "solidity", "bash", "cue"} { + if !ResolvesFileLevelImports(language) { + t.Errorf("ResolvesFileLevelImports(%q) = false, want true", language) + } + } + for _, language := range []string{"swift", "kotlin", "java", "csharp", "scala"} { + if ResolvesFileLevelImports(language) { + t.Errorf("ResolvesFileLevelImports(%q) = true, want false", language) + } + } +} + +// Coverage that already knows less must never be talked back up. +func TestApplySymbolLevelImportCoverageOnlyRemovesConfidence(t *testing.T) { + swift := []FileInfo{{Path: "App/Model.swift"}} + for _, status := range []analysis.CoverageStatus{analysis.CoveragePartial, analysis.CoverageUnavailable} { + got := ApplySymbolLevelImportCoverage(analysis.Coverage{Status: status}, swift) + if got.Status != status { + t.Errorf("status %q became %q, want it preserved", status, got.Status) + } + } + + got := ApplySymbolLevelImportCoverage(analysis.Coverage{Status: analysis.CoverageComplete}, swift) + if got.Status != analysis.CoveragePartial { + t.Errorf("complete coverage over Swift = %q, want partial", got.Status) + } + + unchanged := ApplySymbolLevelImportCoverage( + analysis.Coverage{Status: analysis.CoverageComplete}, + []FileInfo{{Path: "main.go"}, {Path: "app.py"}}, + ) + if unchanged.Status != analysis.CoverageComplete { + t.Errorf("complete coverage over Go and Python = %q, want it left complete", unchanged.Status) + } +} + +// Every symbol-level language present is named, so a mixed project says which +// slice it cannot see rather than emitting a bare partial. +func TestSymbolLevelSourcesNameEveryLanguagePresent(t *testing.T) { + coverage := ApplySymbolLevelImportCoverage( + analysis.Coverage{Status: analysis.CoverageComplete}, + []FileInfo{ + {Path: "main.go"}, + {Path: "App/Model.swift"}, + {Path: "src/Main.kt"}, + {Path: "src/Other.kt"}, + }, + ) + var got []string + for _, source := range coverage.Sources { + if strings.HasPrefix(source.Name, "symbol-imports/") { + got = append(got, source.Name+"|"+source.Detail) + } + } + if len(got) != 2 { + t.Fatalf("got %d symbol-imports sources %v, want one per language present", len(got), got) + } + if !strings.Contains(got[0], "Kotlin (2 files)") || !strings.Contains(got[1], "Swift (1 files)") { + t.Fatalf("sources %v omit per-language file counts in sorted order", got) + } +} + +// The milestone's exit test: the fixture's importer list must be exact, so a +// future resolver change that widens or narrows it is caught here rather than +// in someone's repository. +func TestFixtureImporterListsAreExact(t *testing.T) { + swift, err := BuildFileGraph(context.Background(), "../testdata/symbol-imports-swift", Filters{}) + if err != nil { + t.Fatalf("build swift fixture graph: %v", err) + } + if got := swift.Importers["Sources/App/Models.swift"]; len(got) != 0 { + t.Fatalf("swift Models.swift importers = %v, want none (no file-level import exists to find)", got) + } + + golang, err := BuildFileGraph(context.Background(), "../testdata/file-imports-go", Filters{}) + if err != nil { + t.Fatalf("build go fixture graph: %v", err) + } + got := golang.Importers["pkg/user.go"] + if len(got) != 1 || got[0] != "main.go" { + t.Fatalf("go pkg/user.go importers = %v, want exactly [main.go]", got) + } +} + +// A zero-importer answer has to say how much it checked, so the field is +// always present — the complete case is exactly where an omitted status let a +// silent zero read as a thorough one. +func TestEffectiveStatusSpellsOutComplete(t *testing.T) { + if got := (GraphCoverage{}).EffectiveStatus(); got != analysis.CoverageComplete { + t.Fatalf("zero-value coverage EffectiveStatus() = %q, want %q", got, analysis.CoverageComplete) + } + for _, status := range []analysis.CoverageStatus{analysis.CoveragePartial, analysis.CoverageUnavailable} { + if got := (GraphCoverage{Status: status}).EffectiveStatus(); got != status { + t.Fatalf("EffectiveStatus() = %q, want %q preserved", got, status) + } + } +} diff --git a/scanner/types.go b/scanner/types.go index 967cfab..42b9190 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -100,6 +100,15 @@ func newDepsProject(root string, files []FileAnalysis, externalDeps map[string][ } } } + if len(inventory) > 0 { + coverage = ApplySymbolLevelImportCoverage(coverage, inventory[0]) + } else { + analysed := make([]FileInfo, 0, len(files)) + for _, file := range files { + analysed = append(analysed, FileInfo{Path: file.Path}) + } + coverage = ApplySymbolLevelImportCoverage(coverage, analysed) + } return NewDepsProjectWithCoverage(root, files, externalDeps, diffRef, coverage) } @@ -171,7 +180,7 @@ type ImportersReport struct { HubImports []string `json:"hub_imports,omitempty"` ImporterCount int `json:"importer_count"` IsHub bool `json:"is_hub"` - CoverageStatus string `json:"coverage_status,omitempty"` + CoverageStatus string `json:"coverage_status"` CoverageNotes []string `json:"coverage_notes,omitempty"` } diff --git a/testdata/file-imports-go/go.mod b/testdata/file-imports-go/go.mod new file mode 100644 index 0000000..a4f0126 --- /dev/null +++ b/testdata/file-imports-go/go.mod @@ -0,0 +1,3 @@ +module fixture + +go 1.24 diff --git a/testdata/file-imports-go/main.go b/testdata/file-imports-go/main.go new file mode 100644 index 0000000..44c1080 --- /dev/null +++ b/testdata/file-imports-go/main.go @@ -0,0 +1,7 @@ +package main + +import "fixture/pkg" + +func main() { + _ = pkg.User{ID: "1", Name: "ada"} +} diff --git a/testdata/file-imports-go/pkg/user.go b/testdata/file-imports-go/pkg/user.go new file mode 100644 index 0000000..1b69669 --- /dev/null +++ b/testdata/file-imports-go/pkg/user.go @@ -0,0 +1,8 @@ +package pkg + +// User is referenced by main.go through an import of this package, so a +// file-level edge does exist and an empty graph here would be a real finding. +type User struct { + ID string + Name string +} diff --git a/testdata/symbol-imports-swift/Sources/App/ContentView.swift b/testdata/symbol-imports-swift/Sources/App/ContentView.swift new file mode 100644 index 0000000..dae4b71 --- /dev/null +++ b/testdata/symbol-imports-swift/Sources/App/ContentView.swift @@ -0,0 +1,9 @@ +import SwiftUI + +struct ContentView: View { + @StateObject var model = UserViewModel() + + var body: some View { + Text(model.user?.name ?? "") + } +} diff --git a/testdata/symbol-imports-swift/Sources/App/Models.swift b/testdata/symbol-imports-swift/Sources/App/Models.swift new file mode 100644 index 0000000..5d7600e --- /dev/null +++ b/testdata/symbol-imports-swift/Sources/App/Models.swift @@ -0,0 +1,6 @@ +import Foundation + +struct User { + let id: String + let name: String +} diff --git a/testdata/symbol-imports-swift/Sources/App/UserViewModel.swift b/testdata/symbol-imports-swift/Sources/App/UserViewModel.swift new file mode 100644 index 0000000..d6bcc42 --- /dev/null +++ b/testdata/symbol-imports-swift/Sources/App/UserViewModel.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftUI + +// References User, declared in Models.swift, with no import statement: +// same-module Swift files never import each other, so no file-level edge +// exists for the graph to find. +final class UserViewModel: ObservableObject { + @Published var user: User? + + func load() { + user = User(id: "1", name: "ada") + } +}