From 03c2ffec47876825d9574a586bfb4831fb0598e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:40:18 +0000 Subject: [PATCH 1/3] fix(coverage): Stop claiming complete coverage on symbol-level languages Swift, Kotlin, Java, C# and Scala name modules in their imports, not files, and two files in the same package reference each other with no import at all. The file-to-file edge model cannot represent their intra-project structure, so resolving their imports yields framework names belonging to no file and the graph comes back structurally empty. Coverage was derived only from whether the scanner ran, so ast-grep succeeding made the result authoritative and complete. A consumer reading "complete" beside zero dependents concludes a change is isolated, when in fact nothing capable of finding those edges ever ran. Classify languages by whether an import can resolve to a file. When files in a symbol-level language are present, coverage drops to partial and carries one source per language naming it and its file count, so the result says which slice of the project it cannot see instead of emitting a bare partial. Languages whose imports do resolve to files are untouched: an empty graph there is a real finding and stays complete. Relates to #148, #172 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- scanner/filegraph.go | 5 + scanner/importmodel.go | 122 +++++++++++++++ scanner/importmodel_test.go | 143 ++++++++++++++++++ scanner/types.go | 9 ++ testdata/file-imports-go/go.mod | 3 + testdata/file-imports-go/main.go | 7 + testdata/file-imports-go/pkg/user.go | 8 + .../Sources/App/ContentView.swift | 9 ++ .../Sources/App/Models.swift | 6 + .../Sources/App/UserViewModel.swift | 13 ++ 10 files changed, 325 insertions(+) create mode 100644 scanner/importmodel.go create mode 100644 scanner/importmodel_test.go create mode 100644 testdata/file-imports-go/go.mod create mode 100644 testdata/file-imports-go/main.go create mode 100644 testdata/file-imports-go/pkg/user.go create mode 100644 testdata/symbol-imports-swift/Sources/App/ContentView.swift create mode 100644 testdata/symbol-imports-swift/Sources/App/Models.swift create mode 100644 testdata/symbol-imports-swift/Sources/App/UserViewModel.swift diff --git a/scanner/filegraph.go b/scanner/filegraph.go index bb1acf0..2e26db4 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..e1852b9 --- /dev/null +++ b/scanner/importmodel.go @@ -0,0 +1,122 @@ +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 + } +} diff --git a/scanner/importmodel_test.go b/scanner/importmodel_test.go new file mode 100644 index 0000000..203cb4a --- /dev/null +++ b/scanner/importmodel_test.go @@ -0,0 +1,143 @@ +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) + } +} diff --git a/scanner/types.go b/scanner/types.go index 967cfab..4f260c7 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) } 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") + } +} From e0c07b7b394b855798470a8a623b339ae439c079 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:44:16 +0000 Subject: [PATCH 2/3] fix(importers): Always state coverage in the importers answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zero-importer answer carried no honesty signal: coverage_status was omitempty and the graph's complete status is the empty zero value, so the common case emitted no field at all. A consumer could not tell "nothing imports this, and I checked" from "nothing imports this, and I could not check" — the two were byte-identical. Spell the zero value out as complete and always emit the field. Deciding whether a blast-radius importer section is empty now keys on the report's data rather than its rendered text, because every report carries a coverage line and rendering first made files with no importers look like they had content. Relates to #148, #173, #172 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo --- blast_radius.go | 8 +++++++- scanner/importmodel.go | 11 +++++++++++ scanner/importmodel_test.go | 14 ++++++++++++++ scanner/types.go | 2 +- 4 files changed, 33 insertions(+), 2 deletions(-) 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/scanner/importmodel.go b/scanner/importmodel.go index e1852b9..5e2a8a5 100644 --- a/scanner/importmodel.go +++ b/scanner/importmodel.go @@ -120,3 +120,14 @@ func (c *GraphCoverage) AddSymbolLevelImportCoverage(files []FileInfo) { 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 index 203cb4a..9e318e8 100644 --- a/scanner/importmodel_test.go +++ b/scanner/importmodel_test.go @@ -141,3 +141,17 @@ func TestFixtureImporterListsAreExact(t *testing.T) { 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 4f260c7..42b9190 100644 --- a/scanner/types.go +++ b/scanner/types.go @@ -180,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"` } From 9d3c0e4c50ee42f52af59fd3354ee73326a7a145 Mon Sep 17 00:00:00 2001 From: Jordan Coin Jackson Date: Fri, 4 Sep 2026 10:37:09 -0400 Subject: [PATCH 3/3] fix(mcp): spell out complete coverage in get_importers too Independent review of #174 found the MCP importers surface still built coverage_status from the raw zero value, so a fully checked graph emitted "" while the CLI emitted "complete". Use EffectiveStatus() and guard it with a test that fails without the change. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TcyheQmM3HCvxF5wRL3s5t --- mcp/analysis_output.go | 2 +- mcp/structured_contract_test.go | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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{