feat(index): resolve cross-file references via a project-wide symbol table - #56
Conversation
…table Edge extraction resolved references against name_to_id built from a single file's nodes, so any reference to a symbol defined elsewhere was dropped. Only same-file calls ever linked. Measured against ground truth from parsers independent of tree-sitter (Python's stdlib ast, and javalang for a Java project), the unreachable share was 83% on this repo and 95% on a Spring Boot service -- in layered code essentially every interesting call crosses a file. Indexing is now two-phase: collect nodes from every file and build a project wide name -> id map, then resolve edges against it. extract_edges takes an optional global_symbols map; local definitions are applied last so a file-local symbol always shadows a same-named symbol from another file. Measured on this repo, before -> after: CALLS edges 498 -> 1412 of which cross-file 0 -> 914 total edges 2387 -> 3782 Retrieval quality over the 15 most-called symbols, scored against the stdlib ast oracle across all call relationships (not just same-file): precision 0.99, recall 0.95, F1 0.97 Precision holding at 0.99 is the load-bearing result: name-based global resolution could have produced false positives across same-named symbols, and on this codebase it does not. Suite: 3 failed, 188 passed (baseline: 3 failed, 174 passed) -- same three pre-existing failures, fixed separately in #51. Refs #35
UAT against a live index found 'ast-rag refs' and 'ast-rag symbol-impact' failing with: Neo.ClientError.Statement.ParameterMissing Expected parameter(s): call_kinds Regression from the call-traversal rewrite: two queries were changed to filter on $call_kinds, but their session.run() calls were never given the parameter. Neo4j only reports this at execution time, so nothing caught it -- the unit tests never reach these branches without a populated graph. Binds the parameter at both sites. After the fix, 'refs ParserManager' returns its references and 'symbol-impact ParserManager' reports 34 references and 42 callers. Adds a static checker over the API and repository layers: for every session.run(<var>, **kwargs) it resolves <var> back to its query text and asserts each $parameter is bound. Reverting the fix makes it fail, which is the property the first version of this test lacked -- a runtime test could not reach the broken branch and passed either way.
305614e to
e3557d5
Compare
|
Rebased onto current 1. I resolved a real conflict in 2. Verified locally against
The +7 are this PR's Note on ordering: #60 is stacked on this branch, so this one should go in first. |
Refs #35
This is the step-4 work I proposed in my analysis on #35 — the piece that turns the call graph from "same file only" into something usable.
Stacked on #50. That PR fixes
for n in [], without which noCALLSedge is emitted at all and none of this is observable. Review #50 first; this branch contains it.Problem
EdgeExtractor.extract_edgesresolved references against:nodesis the node list for the file currently being parsed, so a callee defined in another file was never in the map and the edge was dropped.I sized the gap using ground truth from parsers with no shared code with tree-sitter — Python's stdlib
asthere, andjavalangon a Spring Boot service — so the system isn't graded against its own output:In layered Java (controller → service → repository) essentially every interesting call crosses a file, which is why the Java number is so stark.
Approach
Indexing becomes two-phase:
name -> idmapextract_edgestakes an optionalglobal_symbols, and the local map is applied last:so a file-local symbol always shadows a same-named symbol from elsewhere. Omitting
global_symbolspreserves the old behaviour exactly, soindex-folderand any other caller are unaffected.First definition of a name wins in the global map, and files are walked in a stable order, so the choice is deterministic across runs.
Results on this repo
CALLSedgesRetrieval quality over the 15 most-called symbols, scored against the stdlib
astoracle across all call relationships rather than just same-file ones:Precision holding at 0.99 is the load-bearing result. Name-based global resolution could plausibly have produced false positives across same-named symbols in different files; on this codebase it does not.
runandsessionare heavily cross-file and both score 1.00.Recall isn't 1.00 —
ASTEdgeat 0.60 is the weakest. Those are attribute-style call sites thecallsquery doesn't capture, which is a separate gap from resolution and I left it alone.Known limitation
Resolution is by bare name, not qualified name. Two distinct
Config.load()in different modules will collapse to whichever was indexed first. That didn't bite on this repo (precision 0.99), but it will on a codebase with heavy name reuse.The principled fix is qualified-name resolution with a bare-name fallback, plus a lower
confidenceon fallback matches — the edge model already carriesconfidence. I've deliberately not done that here so the change stays reviewable and the measurement stays interpretable. Happy to follow up if you want it before this lands.Tests
4 in
tests/test_global_symbol_resolution.py: the unresolved baseline, cross-file resolution, local-shadows-global, and that an unrelated global table leaves same-file behaviour byte-identical.Verification
Baseline on
mainis3 failed, 174 passed— same three pre-existing failures (fixed in #51), plus the new tests.CI will be red at
Lint with ruffuntil #51 lands; that's the pre-existing breakage, not this change.