Skip to content

Usage accuracy: compiler-verified results across Go, Python, and TypeScript#216

Merged
zzet merged 11 commits into
mainfrom
fix/usages-accuracy-and-cli-trust
Jul 2, 2026
Merged

Usage accuracy: compiler-verified results across Go, Python, and TypeScript#216
zzet merged 11 commits into
mainfrom
fix/usages-accuracy-and-cli-trust

Conversation

@zzet

@zzet zzet commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Eleven commits, each fixing a defect found by benchmarking find_usages against language-server ground truth (gopls / pyright / tsserver) on real repos. Two of them fix behavior that is silently broken in production today.

Production-impacting

  • Fresh single-repo installs return zero search results (f6550041): single-repo daemons index unprefixed nodes, but the scope layer's RepoAllow keys are registry prefixes — search_symbols / search_text / find_files returned 0 rows with ok:true on exactly the first-install shape, since v0.58.0. Multi-repo dev daemons never see it.
  • The daemon's graph-wide resolve pass was a no-op on the sqlite backend (8a5d9d5f): the apply-loop's liveness check compared edge pointers; sqlite returns fresh copies per read, so every computed resolution was dropped (reindex_batch: 0 on every run). Masked by the in-memory shadow resolve during cold indexing.

Both argue for an expedited release after merge.

Accuracy (benchmark: precision / recall vs compiler ground truth, strict tier)

corpus language before after
gin Go 0.206 / 0.112 (iface), silent-zero 10% 1.000 / 1.000, 0%
httpx Python 0.955 / 0.900, 27 wrong-declaration FPs 1.000 / 0.946, 0 FPs
zustand TypeScript 0.018, silent-zero 66.7% 1.000 / 0.946, 0%

Root causes fixed along the way:

  • LSP call-hierarchy edges stamped at caller declaration lines instead of call sites; hierarchy items landing on zero-height #param: nodes (~5k junk edges on a mid-size repo).
  • LSP requests targeting the wrong identifier whenever a method name is a substring of its receiver type (Bind inside formBinding) — the whole dispatch fan-out silently never ran for such methods.
  • text_matched fan-out polluting default usage results; now adaptively suppressed when resolver-verified evidence exists (text_matched_suppressed reports the count; min_tier:"text_matched" restores).
  • TS calls at module top-level or inside anonymous callbacks silently dropped by the extractor; as-cast arrow exports demoted to variables; tsconfig paths broken three ways; barrel-blind cross-package guard.
  • Same-named-candidate ambiguity now resolved by the caller file's import closure (JS/TS-gated; a same-file candidate wins outright).
  • Decorated Python methods (@property, @contextmanager) lost class membership and collided into bare same-name nodes; LSP promotion is now identity-anchored (confirm at the recorded site, rebind via textDocument/definition, or leave the heuristic tier).
  • Import / re-export statements now count as usages (matching LSP reference semantics), classified with a filterable "import" context. Go results unaffected — its import edges target package nodes.

Enrichment lifecycle

Semantic enrichment previously buffered a whole repo's pass and discarded it on a fixed 600s deadline — a medium repo paid ~20 minutes of gopls and got zero edges, while index_health stayed green. Enrichment now lands incrementally (per item / per file, highest-value stages first), is cancelled cooperatively with everything landed so far kept and counted, scales its deadline with repo size, and reports per-repo/per-provider status (semantic_enrichment in index_health). In-place edge promotions now persist on copying backends, and counters count only durable work.

Trust surfaces

  • A repo-narrowed empty Locate result now says so in the response body (with an honest workspace-wide recheck), instead of only in _meta that CLI users never see.
  • Zero-edge classification gains coverage_incomplete: when import/re-export consumers exist but reference-level resolution is incomplete, the caveat says "unverified" instead of advising that live API is safe to remove.
  • LSP position construction skips synthetic StartLine-0 nodes (was ~1.2k guaranteed-error requests per medium repo).

Verification

Full go test -race ./... green (all shipped packages), golangci-lint clean, cmd/gortex wire-contract golden intact. The benchmark harness, corpora pins, ground truth, per-symbol scoring details, and every table above are reproducible; regression tests accompany each fix, including a copying-store shim that makes the in-memory test store behave like the sqlite backend.

zzet added 11 commits July 2, 2026 00:50
When a symbol has resolver-verified usages, the name-only text_matched
fan-out (every same-named token in the repo) buries them: find_usages on
a common method name returned 118 text-matched rows around 24 resolved
ones. Callers filtering by min_tier recovered precision at no recall
cost, but the defaults served the polluted set.

find_usages and get_callers now apply an adaptive default when min_tier
is omitted: text_matched edges are dropped only when at least one
higher-tier edge survives, so symbols whose only evidence is textual
keep today's recall. The response carries text_matched_suppressed (JSON
and GCX meta) with the hidden count, and min_tier:"text_matched"
restores the full set.
…owing

A daemon tracking exactly one repo indexes it unprefixed: node IDs carry
no repo prefix and Node.RepoPrefix is empty. Every producer of RepoAllow
hands out registry prefixes, so ScopeAllows' RepoAllow check rejected
every node of a single-repo graph — search_symbols, search_text and
find_files (the Locate intent narrows them to the session's home repo by
default) returned 0 rows with ok:true on exactly the fresh-install
shape. Multi-repo daemons mint prefixed nodes, which is why dogfooding
never saw it. Explicit repo: args were equally broken.

ScopeAllows now applies the same empty-RepoPrefix carve-out the other
node filters already use; workspace isolation is unaffected (the
WorkspaceID clamp still rejects out-of-workspace nodes, test-pinned).
search_text additionally stamped registry-prefixed match paths that
never joined against unprefixed file nodes: the first-segment drop now
only fires for known repo prefixes, and node attribution retries with
the prefix stripped for unprefixed repos.

Adds a lone-unprefixed-repo server fixture reproducing the fresh-install
shape end-to-end.
The gopls call-hierarchy landing pass had two compounding defects:

Incoming/outgoing call responses carry FromRanges — the actual
call-expression ranges — but the collection loop discarded them and
minted every edge at the caller's declaration line. Interface-dispatch
usages are exactly the edges this pass adds beyond the AST, so the
whole dispatch stratum surfaced at wrong lines.

Hierarchy items were mapped to graph nodes with the generic
innermost-match, and a function's zero-height param nodes sit on the
same line as its name — so params always won the tie. On a mid-size
repo this wired ~5k calls edges from/to #param: nodes, and because the
From was a param the pass never found the existing AST edge and added
a duplicate declaration-line edge on top, inflating false positives on
plainly-resolved symbols too.

Hierarchy endpoints now resolve through a callable-only matcher, and
edges are minted one per distinct call-site line from FromRanges with
line-aware dedup against existing per-site AST edges (no-ranges
responses keep the legacy behavior). Net effect on a mid-size Go repo:
zero calls edges with param endpoints (was ~5k), zero enrichment edges
at declaration lines (was ~5.1k), fewer duplicate edges overall, and
the enrichment pass runs faster because it now confirms instead of
re-adding.
The scope disclosure (scope_applied / scope_widen_hint) rides only in
MCP _meta, which CLI output and most clients never render — so a
scope-narrowed zero was indistinguishable from "not in the graph".
That silence turned a scoping default into an apparent total search
failure and cost a full diagnosis cycle.

search_symbols and search_text now attach a body-visible scope_note
whenever repo narrowing is active and the result is empty. Since the
result is empty anyway, search_symbols pays one extra BM25 fetch at
workspace width (exactly what repo:"*" would unlock — never across
workspaces) and reports whether widening would actually help: "N
candidates match outside the scope" or "a workspace-wide recheck also
found nothing". Both variants teach the repo:"*" escape hatch.
…ified call site

Three recall/trust defects in interface-dispatch handling:

identifierColumn located a symbol's column with a naive substring scan,
so a method whose name is contained in its receiver type — Bind in
formBinding — resolved to a column INSIDE the type identifier. Hover,
prepareCallHierarchy and implementations were silently asked about the
wrong symbol: prepare returned no items and the incoming-call fan-out
never ran for any such method, leaving every implementation of a
same-named interface method with zero dispatch callers. The scan now
accepts only whole-identifier occurrences.

recordHierarchyCall promoted only the first existing (from,to,calls)
edge; a caller with two call sites of the same callee kept its second
site as text_matched, which the read-path precision filter then
suppressed. Every existing edge at a server-verified call-site line is
now promoted.

resolveMethodCall stamped its locality-heuristic interface-dispatch
pick with the lsp_dispatch origin despite no server evidence, letting a
guessed winner-takes-all target masquerade as semantic-provider truth
under min_tier filtering. The pick is now honestly ast_inferred; the
hierarchy pass upgrades the truly verified sites.
… status

Semantic enrichment buffered an entire repo's pass in memory and mutated
the graph only at the end, while the manager raced it against a fixed
600s deadline — on deadline it returned without the result and left the
provider goroutine running detached. A medium repo (20 min of gopls)
therefore paid the full enrichment cost and got NOTHING: zero
lsp_resolved edges, late detached writes racing a finished warmup, and
an all-green index_health with no hint anything was abandoned.

Enrichment now lands in stages, highest accuracy first: the
interface-implementations and ambiguous-reference confirmation passes
commit per item, and the per-file sweep runs call/type hierarchy before
hover and flushes each file's stamps and hops the moment the file
finishes. Providers implementing the new ContextEnricher interface are
cancelled cooperatively — a deadline keeps everything already landed
and returns accurate counters with Partial set; only a provider wedged
past a grace period is abandoned, and that is now recorded instead of
silent. The deadline scales with enrichable-node count (bounded;
GORTEX_LSP_ENRICH_TIMEOUT still wins).

index_health now carries per-repo, per-provider enrichment state
(running / completed / partial / abandoned / failed with counts) and a
recommendation when semantic coverage is incomplete — an un-enriched
graph is no longer indistinguishable from an enriched one.

LSP position construction also skips nodes with StartLine < 1: a
zero-line synthetic node previously produced position.line -1 and a
guaranteed server error per request (~1.2k wasted round trips on a
medium repo).

Measured on a 1,223-file repo: stock behavior yielded 0 lsp-tier edges;
with this change a forced early cut keeps everything landed by that
point, and a full run lands 39k lsp-tier edges with the remaining gap
honestly reported.
The chunked master-resolve pass validates each computed resolution
against the store before applying it, and that liveness check compared
edge POINTERS. In-memory stores return live pointers, but the sqlite
backend materialises a fresh copy per read — so on the default daemon
deployment the check failed for every edge and the apply loop silently
dropped every resolution the master pass computed. The daemon's whole
graph-wide resolve was a no-op (reindex_batch: 0 on every run); nobody
noticed because the CLI cold-index path resolves against the in-memory
shadow graph before draining to disk, and per-file edits resolve
in-process.

Liveness now falls back to value identity (kind, target, line, file)
when pointer identity fails; a copying-store regression test pins it.

On a mid-size TS repo this alone moved daemon resolver metrics from
reindex_batch=0 / candidate_out_of_scope=1236 to 2644 / 12.
…nd cast exports

A modern TS library shape — barrel re-exports, tsconfig paths aliases,
and `export const f = impl as Type` — produced almost no consumer call
edges: a library's public API surface answered find_usages with zero
results and a 'likely unused' caveat despite hundreds of live call
sites. Five stacked defects:

The extractor silently dropped any call whose site sits at module
top-level or inside an anonymous callback (every test-file call) because
the enclosing-function lookup returned empty; those sites now anchor to
the file scope instead of vanishing.

As-cast arrow exports fell out of the arrow-definition query and were
indexed as variables, which every call-resolution path filters out; the
query now unwraps as/satisfies/parenthesized expressions. Alias-cast
re-exports (f = impl as unknown as T) additionally get a conservative
top-level-variable fallback in function-call resolution (same-dir first,
unique-in-repo, JS/TS callers only, ast_inferred).

tsconfig paths never resolved: targets weren't path-cleaned when baseUrl
is absent, the alias map skipped unprefixed single-repo daemons (the
lone-repo bug class again), and the npm-alias rewrite ran before the
paths attempt so a self-install in node_modules hijacked the specifier.
Paths now clean, load for lone repos, and take precedence over
node_modules, matching tsserver.

The cross-package guard's import closure followed only imports edges, so
barrel-mediated calls looked unreachable and were reverted; it now
traverses re_exports transitively.

Zero-edge classification gains coverage_incomplete: when import or
re-export edges point at a symbol (or its file, for public JS/TS
symbols) but no reference-level edges exist, the caveat says coverage is
incomplete and the symbol must be treated as unverified — instead of
advising an agent that live API is safe to remove.

Measured on a TS library with 640 ground-truth references: recall 0.018
to ~0.65-0.70; 10 of 15 public symbols went from zero-result to
near-complete usage sets.
Every LSP reference set includes the `from x import name` /
`export {name} from …` lines, but find_usages excluded imports and
re_exports edge kinds — so a symbol consumed only through a module
façade (a Python package __init__ or a TS barrel) under-reported its
usages, and in the worst case reported none at all.

isUsageEdgeKind now admits both kinds. The graph shape keeps this
language-appropriate for free: extractors bind import edges to symbol
nodes in Python and JS/TS, while Go import edges target package nodes,
so Go results are unchanged. Each such usage is classified with the new
"import" reference context, filterable like the others
(context:"import" isolates them; every returned usage carries its
context so callers can also drop them).
Same-named candidates across directories either stalled resolution
(ambiguous_multi_match) or — worse, after cast exports became callable —
let the same-directory locality pick bind a library's whole API to a
nested test helper that happened to share the tests/ directory: 117
call edges on one mid-size TS repo pointed at an unrelated file's local
createStore.

Two new tiers slot into the function-call cascade between static scope
and the locality picks. A same-file candidate now wins outright (and
any same-file shadow blocks the import pick — a function-scoped helper
may be the true callee, so blocking preserves prior behavior rather
than guessing). Then, for JS/TS callers only, the caller file's import
closure — direct imports plus transitive barrel re-export hops, at file
granularity, expanded through the same specifier machinery resolveImport
uses so it is order-independent mid-pass — elects a candidate when
exactly ONE candidate's file is import-reachable: origin ast_resolved
(an import statement is structural evidence, same rationale as the
renders-child import-binding path), confidence 0.9,
meta.resolution=import_closure. Zero or multiple imported candidates
fall through unchanged.

The JS/TS gate is correctness, not caution: a bare Go call can never
name another package's symbol, and Python imports bind the module, not
the name — both paths are pinned unchanged by the language-gate test.
The closure is memoised per caller file and cleared with the pass
caches.

Measured on the TS corpus (LSP off for determinism): the 117-edge
wrong-file cluster is eliminated, per-file attribution matches ground
truth, and the target symbol's usages go from 21 to 138 of 145.
…ke it stick

Same-named members on different classes scored as compiler-verified
wrong answers through three coupled defects.

The Python extractor lost class membership for every decorated method:
tree-sitter wraps '@Property def encoding' in a decorated_definition
node, so the direct-parent walk missed the class, demoted the member to
a bare free function, and two classes' same-named properties collided
into one node — consumers landed on whichever declaration owned the
bare ID. Decorated methods now hop the wrapper and get their qualified
ID, receiver, and member_of edge.

The ambiguous-references confirm pass promoted an edge to lsp_resolved
when any reference of the target fell anywhere in the caller's span —
a caller touching both same-named declarations rubber-stamped the
misbound one. Promotion is now identity-anchored: the reference must
land at the edge's own site, and on mismatch textDocument/definition
arbitrates — same node confirms, a different known node REBINDS the
edge (rebound_from meta, dup-guarded), no verdict leaves the heuristic
tier alone. The call-hierarchy no-ranges fallback likewise promotes
only an unambiguous pair edge.

And promotions now survive disk backends: in-place edge mutations
evaporate on stores that return detached copies (the sqlite daemon),
so every confirm/promote site persists through the EdgePersister
capability; the enrichment counters now count only durable work.

Measured on the Python corpus (strict tier): 27 wrong-declaration false
positives collapse to zero (P 0.955 -> 1.000), recall 0.900 -> 0.946,
plain-stratum recall preserved exactly.
@zzet zzet merged commit ecdfd10 into main Jul 2, 2026
10 checks passed
@zzet zzet deleted the fix/usages-accuracy-and-cli-trust branch July 3, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant