ADR 0001 — Scope-aware and import-edge name resolution
- Status: Accepted
- Date: 2026-06-27
- Deciders: Boni Gopalan
- Supersedes: —
- Related: VISION.md §6.3 (
definition“via@local.scope/@local.definitionresolution”), §8 (tool→API mapping), Roadmap Tier 3 (“scope-awarecallers/definitionvia the tagslocalsquery”)
Context
Grove’s definition and callers resolve symbols lexically by name. Today
definition_at (go-to-def from a cursor) finds the identifier under the cursor
and then returns every symbol with that name in the directory:
#![allow(unused)]
fn main() {
// ops.rs — definition_at
let name = engine::identifier_at(...)?; // "parse"
let defs = definition(dir, &name)?; // every "parse" in the dir
}
This has two precision failures that an LLM-driven agent feels directly:
- No scope awareness. A local variable or parameter named
parseresolves to the module-level functionparse. Shadowing is invisible.callerscan even count a local binding as a call site (today mitigated only by a lossy textual fallback). - No cross-file awareness. A reference to an imported top-level symbol resolves to all same-named defs across the directory, not the one the import actually binds — and never to a symbol in a file the agent hasn’t opened.
The strategic question (see the conversation that motivated this ADR) was whether
to adopt stack-graphs (built on tree-sitter-graph) for LSP-grade,
build-tool-free name resolution. That path is real but heavy: per-language .tsg
rule sets are large, the upstream github/stack-graphs is archived
(2025-09-09), and it forces a persisted, invalidated graph index — abandoning
grove’s stateless, parse-on-demand model and its “drop a registry dir, no
recompile” wedge.
This ADR records the decision to first capture the high-value, low-risk subset of
that capability without an index and without per-language .tsg, using
machinery tree-sitter already provides and grove already ships partial support
for. It is explicitly the plan VISION promised, not a vision amendment.
Decision
Add resolution in two independent, separately-shippable steps. Both stay stateless (no persisted index, no watcher) and extend the registry-dir data model rather than the binary, consistent with the WASM-registry spine.
Step 1 — Scope-aware resolution via the locals query
- Add an optional
locals.scmartifact to each registry dir, alongsidetags.scm+manifest.json+grammar.wasm. It uses tree-sitter’s standard locals capture vocabulary:@local.scope,@local.definition,@local.reference. A grammar without one keeps today’s behavior exactly. - The engine compiles
locals.scm(lazily, in the per-processLoadedcache) and gainsresolve_local_at(root, row, col, …): find the identifier under the cursor, then walk enclosing@local.scoperanges innermost→outermost for a@local.definitionof the same name contained in that scope. Innermost match wins (correct shadowing). Returns aSymbolfor the binding, orNone. definition_attries local resolution first. A hit returns the single binding; a miss falls through to today’s directory-wide name lookup. Never worse than current behavior.
Scope: a single file, one parse. No new IO. Fully testable against the dev stub
(rust/python/javascript), each of which gains a minimal locals.scm.
Step 2 — Import-edge cross-file resolution
- Add an optional
importsblock to the manifestprofile(data, not code), describing the import statement node kinds, the fields holding the module path / imported names / alias, and a module-resolution strategy enum (dotted_packagefor Python,relative_pathfor JS/TS,use_pathfor Rust). A grammar without it keeps today’s behavior. definition_atdecision tree becomes:- local scope resolution (Step 1) — 1 file parsed;
- else, if the name is bound by an import in this file: resolve the module path
to a file (string transform + a couple of
exists()probes, no repo scan), parse that one target file, return its matching top-level def — 2 files parsed; - else, today’s directory-wide name lookup.
Resolution cost is bounded by import-chain depth, not repo size. No index, no invalidation. This is the increment that delivers the headline “cross-file go-to-def” for the common case (imported top-level symbols).
Scope boundary — what this deliberately does NOT do
These are the stack-graphs frontier and are out of scope by construction. When hit, resolution degrades to returning the candidate list (today’s behavior), never a confident wrong answer:
- Method/receiver typing —
foo.bar()→ whichbar? Needsfoo’s type. - Multi-hop re-export / barrel chains — Step 2 follows one import hop well; cyclic/transitive re-exports need partial-path stitching.
- Wildcard, dynamic, conditional imports; monkey-patching.
An LLM brain consuming grove is good at picking from a short candidate list with surrounding context, so candidate-list degradation is an acceptable floor.
Alternatives considered
- Adopt stack-graphs / tree-sitter-graph now. Rejected for the near term:
heavy per-language
.tsg, archived upstream, and a persisted index that breaks the stateless model and the broad-language wedge. Kept as a possible later “deep tier on a few languages” bet; Step 2’s measured hit-rate on the testbench becomes the go/no-go evidence for it. (LLM-authored.tsginside a test-driven oracle loop could lower its cost — a separate investigation.) - tree-sitter-graph for richer single-file graphs only. Doesn’t deliver cross-file linkage; rejected as a primary direction.
- Do nothing / keep candidate lists. Rejected: scope and import precision are cheap and high-value, and already promised in VISION.
Consequences
Positive
definitionreturns one correct binding for locals and imported symbols in the common case;callersprecision improves (locals no longer counted as calls).- Stateless model, trust story, and zero-recompile language onboarding preserved.
- Cross-file resolution arrives without an index — a genuinely cheap win.
- Generates the evidence to decide the larger stack-graphs question.
Negative / costs
- Two new optional registry artifacts/fields to author per language (
locals.scm,imports), but they degrade gracefully when absent. - Step 2 re-parses target files per query (bounded by import depth) — acceptable under grove’s existing stateless tradeoff.
- Not LSP-complete (see scope boundary); the tail still needs the agent’s help or a future stack-graphs tier.
Status of implementation
- Step 1: implemented (engine
resolve_local_at,locals.scmfor rust/python/javascript,definition_atwiring, unit + integration tests). - Step 2: implemented (engine
extract_imports+imports.scm, opsresolve_import_at/import_candidate_pathswithdotted_packageandrelative_pathstrategies,import_resolutionmanifest field,definition_atdecision tree local→import→dir-wide, unit + integration tests). Shipped for python/javascript; rust import resolution (use_path) is deferred — rust falls back to directory-wide lookup. Next: validate the cross-file hit-rate on grove-testbench before any stack-graphs decision; widen the per-languagelocals.scm/imports.scm(match arms, destructuring,import *, re-exports).