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
8 changes: 7 additions & 1 deletion blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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...),
}

Expand Down
2 changes: 1 addition & 1 deletion mcp/analysis_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
20 changes: 20 additions & 0 deletions mcp/structured_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
5 changes: 5 additions & 0 deletions scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
133 changes: 133 additions & 0 deletions scanner/importmodel.go
Original file line number Diff line number Diff line change
@@ -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
}
157 changes: 157 additions & 0 deletions scanner/importmodel_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
11 changes: 10 additions & 1 deletion scanner/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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"`
}

Expand Down
3 changes: 3 additions & 0 deletions testdata/file-imports-go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module fixture

go 1.24
7 changes: 7 additions & 0 deletions testdata/file-imports-go/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package main

import "fixture/pkg"

func main() {
_ = pkg.User{ID: "1", Name: "ada"}
}
Loading
Loading