Emit calls edges for top-level function calls, with file node as caller - #2016
Emit calls edges for top-level function calls, with file node as caller#2016Rishet11 wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a graph-extraction gap where calls made at a file’s top level (outside any function/method body) previously had no caller node and therefore emitted no calls edges. It does so by adding a second walk_calls pass rooted at the file AST root with the file node as caller_nid, while gating that pass to avoid duplicating non-calls relations handled by other module-scope passes.
Changes:
- Add a file-root
walk_callspass (skipping Java) to emitcallsedges for script/top-level call sites (#1972). - Introduce a
_toplevel_calls_onlyguard to suppress indirect-dispatch and other non-callsrelations during the file-root walk. - Add tests covering top-level Python calls, top-level JS calls, and a regression guard ensuring no duplicate/incorrect caller attribution.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| graphify/extractors/engine.py | Adds a guarded file-root call walk to attribute top-level calls to the file node and avoid duplicating non-calls relations. |
| tests/test_extract.py | Adds regression tests ensuring top-level calls now produce calls edges sourced from the file node (and that in-function calls remain unchanged). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Rishet11
left a comment
There was a problem hiding this comment.
fixed the required changes
|
@Rishet11 Thanks for picking this up, this is exactly the shape we hoped for: file node as caller, with the walk returning at definition boundaries so nothing double-emits. One coverage ask from the corpus that motivated #1972: our worst case is Ruby rake files, where every call lives either at the file root or inside a On scoping: agreed that limiting the extra walk to direct calls is right for this PR. For Ruby, constant reads are missing everywhere, not just at top level, so that half of #1972 is bigger than this change anyway. Fine by us if it stays tracked on the issue after this merges. Offer: we run a recall census against a real Rails corpus (same one behind our earlier graphify reports). Happy to run it against a build of your branch and report before/after edge counts if useful. |
|
Added both tests: test_ruby_toplevel_call_gets_file_caller and test_ruby_rake_task_block_call_gets_file_caller (the task :name do ... end shape), each asserting the file node is the caller, and both are green... |
|
@krishnateja7 gentle bump on this one. The two Ruby tests you asked for are pushed and green (top-level call plus the |
|
@Rishet11 Took another look, and thanks for adding both Ruby tests so quickly. Before signing off I ran the census I offered: your branch (head Census result:
For the rake case that motivated #1972, this corpus has ~195 file-level call sites we track out-of-band today; the PR surfaced 1 of them. That sent me to a minimal repro, and I think the real issue is upstream of the tests: The new edges do not survive the CLI pipeline, even for the exact content of your green test. Clean directory, one file, the same content as # build.rake
def compile
1
end
task :build do
compile()
end
Since your tests are green while the CLI does not produce the edge, the tests and the shipped pipeline are exercising different layers, and something between the extractor walk and the exported Suggested next step: reproduce through the CLI rather than pytest, find where the file-sourced edges get lost, and consider one end-to-end test that runs the real build against a temp directory and asserts on the exported |
… as caller (Graphify-Labs#1972) Calls at a file's top level (or inside a plain DSL block) had no enclosing definition, so they never entered function_bodies and got no caller node or edge. Walk the file root once with the file node as caller after the per-definition walk; walk_calls already returns at every definition boundary, so it cannot double-emit anything the per-body loop covered. During this root walk emit ONLY direct calls: a _toplevel_calls_only flag suppresses indirect_call (already handled by the dedicated module-dispatch pass with correct shadow filtering) and the PHP-family reference emissions. Java is skipped entirely (no top-level statements; field initializers are walked via initializer_nodes with the owning class as caller). Covers Python and JS/TS, which share this extractor. Adds tests for top-level Python/JS calls and an in-context regression guard.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…phify-Labs#1972) The first cut of this fix used the FILE node as the caller. The extractor emitted the edge and `extract()` returned it, but it never reached graph.json, so the fix was inert for real users. The built graph is an undirected nx.Graph (build.py:673; watch.py and cli.py both call build_from_json without directed=True), so a node PAIR holds exactly one edge and the last write wins (build.py:836-838, :968). Edges are inserted sorted by (source, target, relation), and "calls" sorts before both "contains" and "imports". Every top-level callee is a symbol its file already contains (same file) or imports (cross-file), so the file-sourced calls edge was overwritten by the structural edge on the identical pair, every time. Confirmed by observation: `graphify update .` on the reporter's rake repro emits only `contains`, while the same run with --no-cluster -- which skips build_from_json for the relation-aware dedupe_edges -- emits both. Attribute the root walk to a synthetic `<file>__entry` node instead. It carries no contains/imports edge to the callees, so the call survives. This mirrors the bash extractor, which has attributed top-level calls to an entry node since it was written (bash.py:136-139, :443). The node is created lazily, only when the walk produced an edge or a deferred raw_call, so files without a top-level call gain nothing. Its label is the constant "module top-level" rather than the file name: test_cjs_module_extension.py requires .cjs and .js to yield identical node sets modulo the file-node label, and a name-derived label breaks it. The id is salted when needed. normalize_id collapses underscore runs, so `<stem>_py__entry` and a real symbol named `py_entry` share a normalized key, which would let build.py's norm_to_id fallback (build.py:776) conflate them. The salt is "x", not "_": trailing underscores are stripped by normalize_id, so an underscore salt would never terminate.
…ct() (Graphify-Labs#1972) The existing tests asserted on `extract()`'s return value. That is not the surface users consume, and the gap is exactly why the file-node version shipped green while producing nothing: 1 of ~195 expected edges on the reporter's corpus. tests/test_toplevel_calls_e2e.py runs the real CLI against a temp repo and reads the exported graph.json, so a regression anywhere between the extractor and the export fails the suite. All three cases fail against the previous file-node implementation. test_extract.py's Graphify-Labs#1972 tests now assert the entry node is the caller, that the file node is NOT (it would be overwritten), and that the entry node is reachable from its file via contains. Added: a cross-file top-level call, a guard that a file with no top-level call gains no entry node, and a collision corpus containing a symbol named `py_entry` -- which pins both the normalized-id uniqueness and the salt's termination.
0e561e4 to
f293e0e
Compare
|
@krishnateja7 Thank you for running the census — that was decisive, and you were right that the tests and the pipeline were exercising different layers. Reproduced your minimal repro exactly, and the cause is upstream of where I was testing. Root cause. The built graph is an undirected The discriminating check, on your rake repro:
This means file-as-caller cannot work here, and I want to be upfront that it changes what #1972 asked for. A If you or @safishamsi would rather have the file node literally, say so — it needs On your suggestion for an end-to-end test: added Also covered: a cross-file top-level call, a guard that files without a top-level call gain no node, and a corpus containing a symbol literally named Full suite unchanged against base (4 pre-existing env-var failures both before and after), ruff and skillgen clean. The branch is also rebased onto current The census offer would be very welcome on this push — I would expect the rake number to move well off 1. |
…raphify-Labs#1972) Review feedback: the comment said the root walk emits ONLY direct calls edges, but the flag only short-circuits module-scope indirect dispatch. Other relations walk_calls can emit (e.g. a JS/TS dynamic import() producing imports_from) are unaffected.
|
Picking up the one review comment on this PR that was still unaddressed — thanks for flagging it, and sorry it sat. The inline note on Comment-only change, no behavior touched. The other inline note (the "#1744 removes" tense fix) no longer applies — that line is gone from the current branch after the rework. |
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This PR adds handling in engine.py for top-level/script-context calls that have no enclosing definition. It introduces a _toplevel_calls_only flag that gates several emission paths (indirect dispatch, config helpers, container binds, static/constant references) and performs a root-level walk that attributes surviving direct calls to a synthetic per-file __entry node (with salting to avoid normalized-id collisions and skipping Java), materialized only when calls are actually attributed. The test_extract.py changes add helpers and a batch of new tests covering entry-node creation and edge sourcing across Python, JS, and Ruby (including .rake-style task blocks), plus assertions about contains reachability and node-id shape. The touched surface spans the generic extraction engine's call-walking logic and a large set of extractor test cases.
No blocking issues surfaced. 5 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 839 functions depend on the 479 functions this change touches.
Health — grade A; 10 existing hotspot(s) in the area this change touches (pre-existing, not introduced here):
_extract_generic()— 18 callers, 15 callees (high)extract_xaml()— 19 callers, 12 callees (high)extract_js()— 74 callers, 2 callees (high)extract_objc()— 27 callers, 5 callees (high)extract_python()— 40 callers, 2 callees (high)extract_julia()— 16 callers, 4 callees (high)extract_vue()— 10 callers, 6 callees (high)extract_groovy()— 14 callers, 3 callees (high)- …and 2 more
Verification — 839 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 780 function(s) in the blast radius were not formally verified this run
Fixes #1972
Problem
Calls made at a file's top level (outside any def) have no caller node, so they emit no
callsedges. A function invoked only from module scope looks dead in the graph.Fix
In
graphify/extractors/engine.py, after the per-definition walk, walk the file root once more with the file node as caller.walk_callsalready returns at every definition boundary, so nothing already covered gets re-emitted.Two scoping decisions:
_toplevel_calls_onlyflag limits the extra walk to directcalls. Indirect dispatch and PHP-family references have their own passes; emitting them here would duplicate work with empty shadow context.Test
3 new tests: top-level Python call, top-level JS call, and an in-function regression guard. Full suite shows no new failures against the v8 baseline.
One known behavior to flag: class-body statements now get attributed to the file (relevant for Swift field initializers). No test covers that yet, so I left it alone for now.
Still fairly new to this codebase, so if I have the approach or the scoping wrong anywhere, tell me and I will fix it up.