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
50 changes: 43 additions & 7 deletions blast_radius.go
Original file line number Diff line number Diff line change
Expand Up @@ -1377,10 +1377,16 @@ func renderImportersReportCLI(w io.Writer, report scanner.ImportersReport) {
renderCoverage(w, report.CoverageStatus, report.CoverageNotes)
return
}
fmt.Fprintf(w, "No files import %s.\n", report.File)
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.")
if report.Package != "" && report.PackageSiblings > 0 {
// A Go file's importers are the files that import its package, so
// a zero here says nothing about the package's own files: those
// references are not edges in this graph, and the reader has to
// check them by hand. Say so, with the count, instead of printing
// a bare zero that reads as "nothing depends on this".
fmt.Fprintf(w, "No cross-package importers of %s. Same-package references are not modeled (%s in package %s).\n",
report.File, pluralFiles(report.PackageSiblings, "other file"), report.Package)
} else {
fmt.Fprintf(w, "No files import %s.\n", report.File)
}
// Show scan provenance even for an empty answer, so a partial scan
// isn't read as a confident negative.
Expand All @@ -1390,10 +1396,28 @@ func renderImportersReportCLI(w io.Writer, report scanner.ImportersReport) {
renderImportersReport(w, report)
}

// importersGranularity names the unit an importer count was measured at when
// it is not the file: Go imports name packages, so "21 files import
// watch/events.go" means 21 files import package watch, and a reader must not
// take the number as evidence that any of them references events.go itself.
func importersGranularity(report scanner.ImportersReport) string {
if report.Package == "" {
return ""
}
return fmt.Sprintf(" (package %s: Go imports name packages, not files)", report.Package)
}

func pluralFiles(n int, noun string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, noun)
}
return fmt.Sprintf("%d %ss", n, noun)
}

func renderImportersReport(w io.Writer, report scanner.ImportersReport) {
if scanner.CountHubImporters(report.Importers) >= scanner.HubThreshold {
fmt.Fprintf(w, "⚠️ HUB FILE: %s\n", report.File)
fmt.Fprintf(w, " Imported by %d files - changes have wide impact!\n", len(report.Importers))
fmt.Fprintf(w, " Imported by %d files%s - changes have wide impact!\n", len(report.Importers), importersGranularity(report))
fmt.Fprintln(w)
fmt.Fprintln(w, " Dependents:")
for i, imp := range report.Importers {
Expand All @@ -1405,7 +1429,7 @@ func renderImportersReport(w io.Writer, report scanner.ImportersReport) {
}
} else if len(report.Importers) > 0 {
fmt.Fprintf(w, "📍 File: %s\n", report.File)
fmt.Fprintf(w, " Imported by %d file(s)\n", len(report.Importers))
fmt.Fprintf(w, " Imported by %d file(s)%s\n", len(report.Importers), importersGranularity(report))
for _, imp := range report.Importers {
fmt.Fprintf(w, " • %s\n", imp)
}
Expand All @@ -1415,7 +1439,9 @@ func renderImportersReport(w io.Writer, report scanner.ImportersReport) {
if len(report.Importers) == 0 {
fmt.Fprintf(w, "📍 File: %s\n", report.File)
}
fmt.Fprintf(w, " Imports %d hub(s): %s\n", len(report.HubImports), strings.Join(report.HubImports, ", "))
// Every non-test file of a hub Go package is a hub, so list the
// package once with its file count rather than each file.
fmt.Fprintf(w, " Imports %d hub(s): %s\n", len(report.HubImports), strings.Join(scanner.CollapseGoPackageFiles(report.HubImports), ", "))
}

renderCoverage(w, report.CoverageStatus, report.CoverageNotes)
Expand Down Expand Up @@ -1480,6 +1506,16 @@ func buildImportersReportFromGraph(root, file string, fg *scanner.FileGraph) (sc
CoverageNotes: append([]string(nil), fg.Coverage.Notes...),
}

if pkg, pkgFiles, ok := fg.GoPackage(file); ok {
report.Package = pkg
report.PackageSiblings = len(pkgFiles)
if !scanner.IsTestFile(file) {
// pkgFiles holds this file too; a _test.go file is in the package
// but never in the index, so every indexed file is a sibling of it.
report.PackageSiblings--
}
}

for _, imp := range imports {
if fg.IsHub(imp) {
report.HubImports = append(report.HubImports, imp)
Expand Down
2 changes: 1 addition & 1 deletion cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -2153,7 +2153,7 @@ func checkFileImportersWithPhase(root, filePath, phase string) error {
}
}
if len(hubImports) > 0 {
fmt.Printf(" Imports %d hub(s): %s\n", len(hubImports), strings.Join(hubImports, ", "))
fmt.Printf(" Imports %d hub(s): %s\n", len(hubImports), strings.Join(scanner.CollapseGoPackageFiles(hubImports), ", "))
fmt.Println()
}

Expand Down
12 changes: 5 additions & 7 deletions collide.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,13 +364,11 @@ func collidePackageOf(fg *scanner.FileGraph, path string) (string, bool) {

// collidePackageImporterCounts counts, per package, the files that import it.
//
// This cannot be read off FileGraph.Importers. BuildFileGraph deliberately
// drops a Go import that resolves to more than one file rather than fanning one
// import into an edge per file (it would inflate every hub count in the
// repository), so a multi-file package has no file-level edges at all and the
// graph's own answer for "who imports scanner/filegraph.go" is a structural
// zero. The raw import strings do carry it, which is why the scan outcome is
// kept.
// It is read off the raw import strings rather than FileGraph.Importers so
// the count is per package, not per file: BuildFileGraph fans one Go import
// out into an edge per file of the imported package, which is right for
// "who imports scanner/filegraph.go" but would make a package's weight here
// depend on how many files it happens to be split into.
//
// A file importing its own package is not counted: a package is not a second
// party to a collision inside itself. Its siblings are counted separately.
Expand Down
81 changes: 81 additions & 0 deletions importers_go_package_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package main

import (
"context"
"path/filepath"
"strings"
"testing"

"codemap/scanner"
)

// The printed lines for a Go file inside a multi-file package, on the #191
// fixture: the count is labelled with its unit, and an empty answer says what
// the graph does not model instead of a bare zero.
func TestImportersCLIReportsGoPackageGranularity(t *testing.T) {
root, err := filepath.Abs(filepath.Join("testdata", "go-multi-file-package"))
if err != nil {
t.Fatal(err)
}
fg, err := scanner.BuildFileGraph(context.Background(), root, scanner.Filters{})
if err != nil {
t.Fatalf("BuildFileGraph: %v", err)
}

render := func(file string) (scanner.ImportersReport, string) {
t.Helper()
report, err := buildImportersReportFromGraph(root, file, fg)
if err != nil {
t.Fatalf("buildImportersReportFromGraph(%s): %v", file, err)
}
var buf strings.Builder
renderImportersReportCLI(&buf, report)
return report, buf.String()
}

report, out := render("multi/a.go")
if got, want := report.Importers, []string{"app/main.go"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("importers of multi/a.go = %v, want %v", got, want)
}
if report.Package != "example.com/demo/multi" || report.PackageSiblings != 1 {
t.Fatalf("report package = %q siblings = %d, want example.com/demo/multi with 1 sibling", report.Package, report.PackageSiblings)
}
want := " Imported by 1 file(s) (package example.com/demo/multi: Go imports name packages, not files)\n • app/main.go\n"
if !strings.Contains(out, want) {
t.Fatalf("--importers multi/a.go output:\n%q\nwant it to contain:\n%q", out, want)
}

_, out = render("multi/a_test.go")
want = "No cross-package importers of multi/a_test.go. Same-package references are not modeled (2 other files in package example.com/demo/multi).\n"
if !strings.Contains(out, want) {
t.Fatalf("--importers multi/a_test.go output:\n%q\nwant it to contain:\n%q", out, want)
}

// A single-file package with no importers is a true zero, and gets no
// same-package caveat because there is nothing unmodeled.
report, out = render("orphan/orphan.go")
if len(report.Importers) != 0 || report.PackageSiblings != 0 {
t.Fatalf("orphan report = %+v, want zero importers and zero siblings", report)
}
if !strings.Contains(out, "No files import orphan/orphan.go.\n") || strings.Contains(out, "Same-package") {
t.Fatalf("--importers orphan/orphan.go output:\n%q\nwant a plain zero with no same-package caveat", out)
}
}

// End to end through the binary, as the issue reproduced it.
func TestImportersCommandListsMultiFilePackageImporter(t *testing.T) {
root, err := filepath.Abs(filepath.Join("testdata", "go-multi-file-package"))
if err != nil {
t.Fatal(err)
}
out, err := runCodemap("--importers", "multi/a.go", root)
if err != nil {
t.Fatalf("codemap --importers multi/a.go: %v\n%s", err, out)
}
if !strings.Contains(out, "• app/main.go") {
t.Fatalf("--importers multi/a.go must list app/main.go:\n%s", out)
}
if strings.Contains(out, "No files import") {
t.Fatalf("--importers multi/a.go reported a zero:\n%s", out)
}
}
22 changes: 14 additions & 8 deletions main_cli_polish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,25 @@ import (

func TestRenderImportersReportExplainsEmptyResult(t *testing.T) {
report := scanner.ImportersReport{
Root: "/repo",
Mode: "importers",
File: "watch/events.go",
Root: "/repo",
Mode: "importers",
File: "watch/events.go",
Package: "codemap/watch",
PackageSiblings: 10,
}

var buf strings.Builder
renderImportersReportCLI(&buf, report)
out := buf.String()
if !strings.Contains(out, "No files import watch/events.go") {
t.Fatalf("empty importers result must say so instead of printing nothing:\n%q", out)
}
if !strings.Contains(out, "same package") {
t.Fatalf("empty result should explain the same-package limitation for Go:\n%q", out)
// A Go file inside a multi-file package: the zero is a cross-package
// zero, and the line must say what the graph does not model, with the
// count, instead of a bare "No files import" (#138 part 1).
want := "No cross-package importers of watch/events.go. Same-package references are not modeled (10 other files in package codemap/watch).\n"
if !strings.Contains(out, want) {
t.Fatalf("empty Go importers result must name the unmodeled siblings:\n got %q\nwant %q", out, want)
}
if strings.Contains(out, "No files import") {
t.Fatalf("a Go file with package siblings must not print a bare zero:\n%q", out)
}

// The token-budgeted blast-radius renderer must keep omitting empty
Expand Down
24 changes: 22 additions & 2 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -908,8 +908,28 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp
importers := append([]string(nil), fg.Importers[file]...)
sort.Strings(importers)
structured := newImportersOutput(absRoot, file, fg)
// Go imports name packages, so a Go file's importers are its package's
// importers and its own package's files are never among them. Label the
// count with that unit, and turn an empty answer into the statement of
// what is not modeled instead of a bare zero.
granularity := ""
empty := "No files import '" + file + "'"
if pkg, pkgFiles, ok := fg.GoPackage(file); ok {
granularity = fmt.Sprintf(" (package %s: Go imports name packages, not files)", pkg)
siblings := len(pkgFiles)
if !scanner.IsTestFile(file) {
siblings--
}
if siblings > 0 {
noun := "other files"
if siblings == 1 {
noun = "other file"
}
empty = fmt.Sprintf("No cross-package importers of '%s'. Same-package references are not modeled (%d %s in package %s)", file, siblings, noun, pkg)
}
}
if len(importers) == 0 {
return textResult("No files import '" + file + "'" + mcpCoverageText(fg)), structured, nil
return textResult(empty + mcpCoverageText(fg)), structured, nil
}

isHub := scanner.CountHubImporters(importers) >= scanner.HubThreshold
Expand All @@ -918,7 +938,7 @@ func handleGetImporters(ctx context.Context, req *mcp.CallToolRequest, input Imp
hubNote = " ⚠️ HUB FILE"
}

return textResult(fmt.Sprintf("%d files import '%s':%s\n%s%s", len(importers), file, hubNote, strings.Join(importers, "\n"), mcpCoverageText(fg))), structured, nil
return textResult(fmt.Sprintf("%d files import '%s'%s:%s\n%s%s", len(importers), file, granularity, hubNote, strings.Join(importers, "\n"), mcpCoverageText(fg))), structured, nil
}

func handleFind(ctx context.Context, req *mcp.CallToolRequest, input FindQueryInput) (*mcp.CallToolResult, any, error) {
Expand Down
Loading
Loading