Usage accuracy: compiler-verified results across Go, Python, and TypeScript#216
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eleven commits, each fixing a defect found by benchmarking
find_usagesagainst language-server ground truth (gopls / pyright / tsserver) on real repos. Two of them fix behavior that is silently broken in production today.Production-impacting
f6550041): single-repo daemons index unprefixed nodes, but the scope layer'sRepoAllowkeys are registry prefixes —search_symbols/search_text/find_filesreturned 0 rows withok:trueon exactly the first-install shape, since v0.58.0. Multi-repo dev daemons never see it.8a5d9d5f): the apply-loop's liveness check compared edge pointers; sqlite returns fresh copies per read, so every computed resolution was dropped (reindex_batch: 0on 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)
Root causes fixed along the way:
#param:nodes (~5k junk edges on a mid-size repo).BindinsideformBinding) — the whole dispatch fan-out silently never ran for such methods.text_matchedfan-out polluting default usage results; now adaptively suppressed when resolver-verified evidence exists (text_matched_suppressedreports the count;min_tier:"text_matched"restores).pathsbroken three ways; barrel-blind cross-package guard.@property,@contextmanager) lost class membership and collided into bare same-name nodes; LSP promotion is now identity-anchored (confirm at the recorded site, rebind viatextDocument/definition, or leave the heuristic tier)."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_healthstayed 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_enrichmentinindex_health). In-place edge promotions now persist on copying backends, and counters count only durable work.Trust surfaces
_metathat CLI users never see.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.Verification
Full
go test -race ./...green (all shipped packages),golangci-lintclean,cmd/gortexwire-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.