Skip to content

fix(scanner): resolve Go imports of multi-file packages - #199

Merged
JordanCoin merged 1 commit into
mainfrom
fix/go-package-level-edges
Oct 7, 2026
Merged

JordanCoin merged 1 commit into
mainfrom
fix/go-package-level-edges

Conversation

@JordanCoin

Copy link
Copy Markdown
Owner

What does this PR do?

Fixes #191: in a Go module, an import of a package with two or more non-test .go files produced no edge, so --deps listed the package's files as standalone and --importers answered No files import X with Coverage: complete. Also fixes #138 part 1: a Go file with no cross-package importers now says what the graph does not model instead of printing a bare zero, and the Go note is never printed for non-Go files.

Type of change

  • Bug fix

Problem

scanner/filegraph.go resolves a local Go import to the files of the imported package via fileIndex.goPkgs, then dropped the result whenever it had more than one file:

isGoPkg := sourceLanguage == "go" && isLocalGoImport(imp, fg.Module) && len(resolved) > 1
if !isGoPkg && len(resolved) > 0 { ... }

The comment said this was "to avoid inflating hub counts". The effect was that every importer of a multi-file package vanished from the graph. Single-file packages (config/, analysis/) resolved, which is why hub detection looked plausible on this repository while package watch (21 cross-package importers), package scanner (67) and package cmd had zero.

Root cause

A Go import names a package, and in codemap's model a package is every non-test .go file in a directory. The resolver's package lookup was correct; the len(resolved) > 1 guard after it threw the answer away whenever it was right for more than one file.

Fix

  • scanner/filegraph.go: keep the fan-out. One import of a package becomes one edge per non-test file of that package (_test.go files are never targets). Hub status for a Go file now follows from its package's importers.
  • New FileGraph.GoPackage(path) reports the package a Go file belongs to and its non-test files; new scanner.CollapseGoPackageFiles(paths) collapses the files of one Go package to dir/ (N files) for text output.
  • ImportersReport gains package and package_siblings (JSON, omitempty). --importers text labels the count with its unit and, for an empty answer on a file with package siblings, states what is not modeled.
  • render/depgraph.go: the --deps text view draws Go package imports once at package granularity and ranks hubs per package with distinct importer counts. --json --deps is unchanged in shape and still validates.
  • MCP get_importers text carries the same label and the same empty-answer sentence; cmd/hooks.go collapses the Imports N hub(s) list the same way. A stale comment in collide.go that described the dropped edges is corrected; collide's package weights were already computed from raw imports and are unchanged.

Before / after on the fixture (testdata/go-multi-file-package, the repro from #191)

Before:

$ codemap --deps .
  main ───▶ single/single, single2/impl
  +2 standalone files      # multi
  +2 standalone files      # named
9 files · 10 functions · 2 deps

$ codemap --importers multi/a.go .
No files import multi/a.go.
   Note: files in the same package never import each other (Go resolves
   imports at package level), so only cross-package importers appear here.
Coverage: complete

After:

$ codemap --deps .
  main ───▶ multi/ (2 files), named/ (2 files), single/single, single2/impl
9 files · 10 functions · 4 deps

$ codemap --importers multi/a.go .
📍 File: multi/a.go
   Imported by 1 file(s) (package example.com/demo/multi: Go imports name packages, not files)
   • app/main.go
Coverage: complete

$ codemap --importers multi/a_test.go .
No cross-package importers of multi/a_test.go. Same-package references are not modeled (2 other files in package example.com/demo/multi).
Coverage: complete

$ codemap --importers orphan/orphan.go .      # never imported: a true zero, no caveat
No files import orphan/orphan.go.
Coverage: complete

Before / after on this repository

$ codemap --importers watch/events.go .
# before
No files import watch/events.go.
   Note: files in the same package never import each other ...
Coverage: complete
# after
⚠️  HUB FILE: watch/events.go
   Imported by 21 files (package codemap/watch: Go imports name packages, not files) - changes have wide impact!

   Dependents:
   • cmd/context_evidence.go
   • cmd/context_evidence_test.go
   • cmd/context_test.go
   • cmd/hooks.go
   • cmd/hooks_more_test.go
   ... and 16 more
   Imports 39 hub(s): internal/projectpath/ (2 files), internal/runtimefile/ (5 files), limits/ (2 files), scanner/ (17 files), topology/ (13 files)
Coverage: complete

codemap --deps . text, both binaries run on the same tree:

bytes HUBS line summary
before 7,672 config/config (40←), analysis/contracts (30←), internal/buildinfo/version (6←), find/find (2←) 79 deps
after 15,603 scanner/ (67←, 17 files), internal/projectpath/ (42←, 2 files), config/config (40←), analysis/contracts (30←), watch/ (21←, 11 files), limits/ (20←, 2 files) 271 deps

That is 2.03x. Go edges are already rendered at package granularity in the text view (without collapsing, the same output was 94,894 bytes); the remaining growth is the 192 previously dropped edges, one line per importing file (e.g. blast_radius_test ───▶ scanner/ (17 files)), which is the fix. Unchanged: config/config.go 40 importers, analysis/contracts.go 30, codemap . hub stars (5 before and after). codemap --json --deps . and codemap --json --importers config/config.go validate.

New and changed printed lines (docs/OUTPUT-STANDARD.md)

  1. Imported by 21 files (package codemap/watch: Go imports name packages, not files) - changes have wide impact! (also the non-hub form Imported by 1 file(s) (package …))
    • Sourced: the file under the header and the package import path from go.mod + directory; reproduced by codemap --importers <file>.
    • Precise: the count and the package name; no hedging.
    • Bounded: states the unit of measurement in the same breath, so 21 cannot be read as 21 references to events.go.
    • Useful: the dependents list follows; the label tells the reader the siblings are the part to check by hand.
  2. No cross-package importers of multi/a_test.go. Same-package references are not modeled (2 other files in package example.com/demo/multi).
    • Sourced: file, package and sibling count from the scanned index.
    • Precise: exact count; replaces the vague note.
    • Bounded: names the gap (same-package references) instead of presenting a structural zero as a finding.
    • Useful: tells the reader which N files to look at. Printed only when a package with siblings exists, so a TypeScript or Rust file never gets it; a single-file package with no importers keeps the plain No files import X. because nothing is unmodeled.
  3. main ───▶ multi/ (2 files), single/single and HUBS: scanner/ (67←, 17 files), … in --deps
    • Sourced: the import statements in the left file, resolved against the module's packages.
    • Precise: the number of files the edge reaches and the number of distinct importers.
    • Bounded: one entry per package import, so output grows with imports, not with how a package is split; the file count says what the entry stands for.
    • Useful: names the package to open; --json --deps keeps per-file edges for tools.
  4. Imports 39 hub(s): internal/projectpath/ (2 files), … in --importers and the hooks
    • Sourced/precise: the count is the hub files; the list is the packages they belong to with file counts. Bounded by construction; useful because every non-test file of a hub package is a hub and listing them individually said nothing extra.
  5. MCP get_importers text: 21 files import 'watch/events.go' (package codemap/watch: Go imports name packages, not files): and the same empty-answer sentence as (2). Same tests, same reasons.

Tests

  • testdata/go-multi-file-package/: the Go: imports of multi-file packages are dropped from --deps and --importers #191 repro (single, single2, multi, named, app) plus orphan (never imported) and multi/a_test.go. go build ./... && go vet ./... pass in it.
  • scanner.TestGoMultiFilePackageImportsResolve asserts the exact importer list for every file: [app/main.go] for all six package files, nil for orphan/orphan.go, app/main.go and multi/a_test.go; exact Imports[app/main.go]; GoPackage attribution. Fails on main (Importers["multi/a.go"] = [], want [app/main.go]).
  • scanner.TestGoGraphMatchesGoList: runs go list -json ./... and asserts that for every in-module import edge the compiler reports, every non-test file of the imported package is imported by a file of the importing package in codemap's graph. Runs on the fixture (refuses a vacuous pass: exactly 4 package edges) and on this repository (skipped in -short, or when go is not on PATH). Fails on main with go list: package example.com/demo/app imports example.com/demo/multi, but no file of example.com/demo/app imports multi/a.go in codemap's graph.
  • scanner.TestCollapseGoPackageFiles, render.TestDepgraphRendersGoPackageImportsAtPackageGranularity (exact edge, HUBS and summary lines), TestImportersCLIReportsGoPackageGranularity (exact printed lines on the fixture), TestImportersCommandListsMultiFilePackageImporter (end to end through the binary; fails on main).
  • Updated TestBuildFileGraphGoPackageTargets, TestBuildFileGraphImportResolutionBoundaries and TestRenderImportersReportExplainsEmptyResult, which encoded the old behaviour.
  • gofmt -l ., go vet ./..., staticcheck ./... clean. go test ./... green except the three tests that fail in a root container on main too (TestRunSetupCreatesConfigAndHooks, TestSelectReportsInaccessiblePrimarySetup, TestListProjectsPreservesPerChildScanErrors). watch.TestLineDelta/TestEventDetection are timing tests (fixed sleeps around os.WriteFile) that occasionally read the file between truncate and write under full-suite load; they pass 6/6 in isolation and this PR does not touch watch/.

Checklist

  • I've tested this locally with go build && ./codemap .
  • I've read CONTRIBUTING.md
  • I've updated documentation if needed (no doc changes; codemap collide --help already describes the package granularity this output now matches)

Closes #191
Closes #138

🤖 Generated with Claude Code

https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ


Generated by Claude Code

A Go import names a package, and a package is every non-test .go file
in its directory. The resolver dropped any import that resolved to more
than one file "to avoid inflating hub counts", so an import of a package
with two or more files produced no edge at all: --deps listed the
package's files as standalone and --importers answered "No files import
X" with Coverage: complete (#191). On this repository 21 files import
package watch and --importers watch/events.go reported zero.

Keep the file edges. The text views collapse them back to package
granularity so output stays bounded: --deps draws "main ───▶ watch/ (11
files)" and ranks hubs as "scanner/ (67←, 17 files)", --importers labels
the count "(package codemap/watch: Go imports name packages, not files)"
and lists hub imports per package. JSON keeps the per-file edges.

For a Go file whose package has other files and no cross-package
importers, say what is not modeled instead of a bare zero (#138):
"No cross-package importers of X. Same-package references are not
modeled (N other files in package P)." The note is only printed when
there is a package with siblings, so non-Go files never get it.

Hub counts for single-file packages are unchanged (config/config.go 40,
analysis/contracts.go 30). codemap --deps . text: 7,672 → 15,603 bytes;
the growth is the 192 previously dropped edges rendered at package
granularity.

Tests: testdata/go-multi-file-package reproduces the issue with exact
importer lists and a never-imported package as the negative case;
TestGoGraphMatchesGoList checks every in-module edge from `go list
-json ./...` is present in the graph, on the fixture and on this
repository.

Closes #191
Closes #138

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ
Copilot AI balanced review requested due to automatic review settings October 7, 2026 03:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@JordanCoin
JordanCoin merged commit 8ed88ad into main Oct 7, 2026
12 checks passed
JordanCoin added a commit that referenced this pull request Oct 7, 2026
Resolves the one conflict in mcp/main.go handleGetImporters: the #140
not-indexed check runs first inside the empty-importers branch (an
unindexed file has no Go package to be a sibling of), and an indexed
file with zero importers falls through to #199's Go package-granularity
wording.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159tcanCgA3jxi2E7DdRVWJ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants