RFC 0013: Package-Level Import Targets
Status
Proposed.
1. Problem
A Go import names a package, and a package is a directory. The graph model has two target kinds: a file node for a path that was discovered, and an external node for a string that could not be resolved. Neither represents a directory.
The Go resolver therefore answers a package import in one of two shapes.
- Module root (
import "myproject"). The resolver returnsNone(src/language/go.rs:19-24, the same rule in the caching resolver atsrc/language/import_resolver.rs:292-297). The builder treats aNonefor a non-relative specifier as an unresolved external dependency and names the node after the specifier (src/graph/builder.rs:481-489, name fromexternal_nameatsrc/graph/builder.rs:578-586). Measured result: an external node namedmyproject. - Subpackage (
import "myproject/internal/util"). The resolver builds a file path from the module directory and the import remainder, adding the language extension (src/language/go.rs:25-26, mirrored insrc/language/import_resolver.rs:289-291). If no discovered file has exactly that path,add_import(src/graph/builder.rs:198-238) creates an external node whoseraw_pathis that path. Measured result on a module withinternal/util/helper.go: an external node named/tmp/.../internal/util.go, a file that does not exist, while the real member file is a file node with no import edge from the importer and the referenceutil.Helper()fails to resolve (unresolved reference: 'util',unresolved reference: 'Helper').
Four consequences follow.
- The file-level dependency graph misses the package. No import edge reaches the member files, so a cycle that runs through a package is invisible to the SCC pass and to the deployability hints.
- The dependency is not portable. The target name is an absolute path taken from the machine that ran the analysis. Two machines produce two different names for the same import.
- The shard index leaks that path. External nodes get the name
<language> external <escaped raw path>(src/output/shard/name.rs:56-64), and external nodes belong to no file (src/output/shard/name.rs:12-18), so the path is written into the index as the identity of the dependency. - Navigation cannot reach a package. The language server navigates on
symbol-to-symbol reference edges (
src/graph/mod.rs:308-320), and a package target holds no symbol.
Adjacent defect, tracked separately: the persisted import specifier keeps its
quotes (import_specifier == "\"myproject\""), which does not change the
analysis of this RFC.
2. Requirements and constraints
- R1, determinism. The same tree produces the same target identity on every machine and every run, and no absolute path reaches an output document.
- R2, no fabricated paths. The analysis must never name a file that does not exist.
- R3, compatibility. Shard readers refuse a schema version they do not know
(
src/output/shard/file.rs:24,SHARD_SCHEMA_VERSION = 4; reader rules atsrc/output/shard/mod.rs:235and:414), so an identity change needs a version bump and a defined story for indexes written by older builds. - R4, navigation. An editor must reach the package and its members from the import site.
- R5, deploy semantics. Pods partition by language plus Import and Reference edges. A package target must not add an edge kind or inflate a confidence.
- R6, no new dependencies, and every produced order stays deterministic.
3. Options
| Option | Graph shape | Determinism | Shard impact | Navigation | Deploy impact |
|---|---|---|---|---|---|
| A. Package node | New node kind; File -> Package import edges; Package -> File membership edges | High: identity is the project-relative directory | New name shape plus a version bump | Import site reaches the package, members follow membership edges | Membership edges must collapse before partitioning, or pods gain a package member |
| B. No target | None for every package import, consumers group by directory | High | None | Unchanged (no target to navigate to) | No edge, so no package cycle is visible |
| C. Representative file | Keep a file target, but point it at a real member file chosen by rule | Low: depends on directory contents and rule | None | Import site reaches one arbitrary file | The package’s other members stay unreachable |
| D. Package node plus file edges to every member | As A, without a membership edge kind: File -> File for every member | High | Many more edges | Import site reaches each member directly | Duplicates the package identity per member and inflates the import degree of the importer |
Option B rejects the dependency information that the resolver already has. Option C needs a rule that agrees with the language (which file declares the imported identifier?), depends on the file system at resolution time, and is undefined for an empty package or a member outside the extraction set. Option D turns one dependency into N edges and makes the same package appear as N targets in any aggregation.
4. Decision and rationale
Take option A, with these rules.
- Identity: the project-root-relative directory of the package, kept as a portable path. The language comes from the importing file’s language.
- Edges:
File -> Packagefor the import,Package -> Filefor each extracted member file. Membership edges carry full confidence and belong to the sameImportkind as ordinary imports; no new edge kind is introduced. - An empty package (no extracted member) is still a node with no membership edges. Nothing is fabricated.
- A package import never falls back to an external node named after a specifier. Only a workspace without a module file keeps the external fallback, and that fallback stays a string, never a path.
Rationale: the package is the unit the source language names, so it is the right identity; membership edges give the SCC pass and the deployability hints the real reachability without touching confidence rules; and a single new node kind gives the shard layer one nameable target instead of an absolute path.
5. Impact
- Graph model. One additional node kind. The enum is
#[non_exhaustive](src/graph/node.rs:17-22), so consumers that match on it compile unchanged and must handle the new arm when they choose to.graph-model.mdgains the node and both edge directions. - Shard format. External package targets become package names of the shape
<language> package <escaped portable directory>.SHARD_SCHEMA_VERSIONmoves from 4 to 5, and the reader keeps refusing unknown versions with the expected version reported. - Navigation. The import site reaches the package node, and the package node reaches its members, so a symbol lookup can continue on file scope. Symbol to symbol navigation is unchanged.
- Deploy. The partition step must treat a package node as its member files (collapse membership first) so pods keep file granularity.
- Diagnostics.
unresolved referencediagnostics for package members disappear only once membership edges exist; the resolver change alone does not fix the reference pass.
6. Migration and compatibility
Two steps, in this order.
- Stop fabricating paths. A subpackage import becomes
None(or an external node named after the specifier) exactly as the module root case does today. No model change, no schema change. This step is safe to ship alone and it removes the absolute path from every index. - Add the package node and the membership edges, bump the shard schema, and define the reader rule for version 4 indexes (either refuse them, as the reader does today, or map the old external target to the new package node when the name is a directory-shaped path).
Step 1 changes observable behaviour, so the interim contract test must be updated in the same change, never deleted. Step 2 is a model change and needs its own review.
7. Test plan
- Resolver units: module root returns
None; subpackage returns the package identity; a workspace without a module file keeps the specifier fallback; a relative import stays rejected. - Graph: the import edge ends at a package node, membership edges reach each member file, and no edge target is a path without a file.
- Reference resolution: after membership edges exist,
util.Helper()resolves or reports a precise diagnostic. - Shard round trip: export and restore preserve the package node and its edges; a version 4 index is refused with the expected version reported; two exports are byte identical; no shard name contains an absolute path.
- Deploy: a Go-only project keeps one pod for the module, and the package node does not appear as a pod of its own.
8. Open questions
- Should the package node carry the package clause name from the source
(
package util) in addition to the directory? Files in one directory may disagree with the directory name. - Should other languages with directory-shaped imports share the node kind?
Python resolves a directory import to
__init__.py, which exists, so the immediate need is Go only. - Does the deploy pipeline want package granularity in pods, or should it always collapse membership to file granularity?
- Multi-module workspaces:
find_go_modulewalks parents from the project root and returns the firstgo.mod(src/language/import_resolver.rs:136-160), so ago.workworkspace or a nested module resolves against the wrong module. Which module owns a package path?