Skip to content

Emit calls edges for top-level function calls, with file node as caller - #2016

Open
Rishet11 wants to merge 6 commits into
Graphify-Labs:v8from
Rishet11:feat/1972-file-level-caller-for-toplevel-calls
Open

Emit calls edges for top-level function calls, with file node as caller#2016
Rishet11 wants to merge 6 commits into
Graphify-Labs:v8from
Rishet11:feat/1972-file-level-caller-for-toplevel-calls

Conversation

@Rishet11

Copy link
Copy Markdown
Contributor

Fixes #1972

Problem

Calls made at a file's top level (outside any def) have no caller node, so they emit no calls edges. 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_calls already returns at every definition boundary, so nothing already covered gets re-emitted.

Two scoping decisions:

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.

Copilot AI review requested due to automatic review settings July 19, 2026 00:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_calls pass (skipping Java) to emit calls edges for script/top-level call sites (#1972).
  • Introduce a _toplevel_calls_only guard to suppress indirect-dispatch and other non-calls relations 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.

Comment thread graphify/extractors/engine.py Outdated
Comment thread graphify/extractors/engine.py Outdated

@Rishet11 Rishet11 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed the required changes

@krishnateja7

Copy link
Copy Markdown
Contributor

@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 task :name do ... end block. Those blocks are not definitions, so the root walk should attribute their inner calls to the file node, but there is no Ruby test in the PR to lock that in. Could you add two: a plain top-level Ruby call, and a call inside a method-with-block (the rake-task shape)? .rake extraction landed in 0.9.13 and this fix is its main consumer.

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.

@Rishet11

Copy link
Copy Markdown
Contributor Author

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...
They lock in the enclosing-definition-context property from #1972 for Ruby top-level and DSL-block call sites.

@Rishet11

Copy link
Copy Markdown
Contributor Author

@krishnateja7 gentle bump on this one. The two Ruby tests you asked for are pushed and green (top-level call plus the task :name do ... end block shape), so it should be ready for another look whenever you have time. No rush.

@krishnateja7

Copy link
Copy Markdown
Contributor

@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 0e561e4) against its merge-base (edec9ea), both installed from git, each run with graphify update . on the same real Rails corpus (~1,800 tracked files, PYTHONHASHSEED=0).

Census result:

build edges calls edges calls with a file-node caller
merge-base 28,178 6,898 0
PR branch 28,211 6,916 18 (15 .rb, 2 .ts, 1 .rake)

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 test_ruby_rake_task_block_call_gets_file_caller:

# build.rake
def compile
  1
end

task :build do
  compile()
end

graphify update . on the PR build produces only the contains edge; there is no calls edge sourced from the file node. Same result for a cross-file constant call inside a task block (task :t do B.y end) and for a bare top-level B.y in a .rb file. A control in the same project (the identical B.y call moved inside a method body) emits its calls edge fine, so calls extraction itself is healthy; it is specifically the file-caller edges that go missing. Padding the project past 60 files changes nothing, so it is not a small-project fast path.

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 graph.json drops or never produces the file-sourced calls edges (same genus as #15, where edges existed in storage but disappeared from export). I did not chase the exact divergence point, but the repro is deterministic: any of the shapes above, graphify update ., then grep graph.json for a calls edge whose source is the file node.

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 graph.json, since that is the surface users consume. Happy to re-run the corpus census on a new push; once the pipeline gap closes I would expect the rake number to jump from 1 into the hundreds here, which is the outcome #1972 is really after.

Rishet11 and others added 5 commits August 1, 2026 00:12
… 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.
@Rishet11
Rishet11 force-pushed the feat/1972-file-level-caller-for-toplevel-calls branch from 0e561e4 to f293e0e Compare July 31, 2026 20:08
@Rishet11

Copy link
Copy Markdown
Contributor Author

@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 nx.Graph (build.py:673; both watch.py and cli.py call build_from_json without directed=True). A node pair therefore holds exactly one edge, and the last write wins — build.py:836-838 says so in a comment. 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 written first and then overwritten by the structural edge on the identical pair.

The discriminating check, on your rake repro:

$ graphify update .
contains | build -> build_compile

$ graphify update . --no-cluster
contains | build -> build_compile
calls    | build -> build_compile      <-- survives here

--no-cluster skips build_from_json and uses the relation-aware dedupe_edges, so the edge lives. That is why your control (the call moved into a method) worked: its caller is a function node, so there is no pair collision. It also explains the 18 survivors — those are pairs that happened to carry no contains/imports edge.

This means file-as-caller cannot work here, and I want to be upfront that it changes what #1972 asked for. A file → symbol calls edge is unrepresentable in this graph model whenever the file also contains or imports that symbol, which is essentially always. Rather than change the graph model, I followed the existing precedent: the bash extractor has always attributed top-level calls to a synthetic <file>__entry node (bash.py:136-139, :443) for exactly this reason. Your repro now gives:

contains | build -> build_rake__entry
calls    | build_rake__entry -> build_compile

If you or @safishamsi would rather have the file node literally, say so — it needs build_from_json to become relation-aware per pair, which touches clustering, path queries, explain and export, so I did not bundle it here.

On your suggestion for an end-to-end test: added tests/test_toplevel_calls_e2e.py, which runs the real graphify extract --code-only against a temp repo and asserts on the exported graph.json. All three cases fail against the previous implementation. That layer is the reason this shipped broken, so it seemed like the right place to lock it down.

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 py_entrynormalize_id collapses underscore runs, so that would otherwise share a normalized id with <stem>_py__entry.

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 v8, so the commits are a clean cherry-pick.

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.
@Rishet11

Rishet11 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

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 _toplevel_calls_only was right: the comment claimed the root walk emits "ONLY direct calls edges", but the flag only short-circuits module-scope indirect dispatch. Other relations walk_calls can emit by different routes — a JS/TS dynamic import() producing imports_from being the example given — are not affected by it. Reworded in 5c28462 to say what the flag actually does, and to state that narrower scope explicitly.

Comment-only change, no behavior touched. ruff clean.

The other inline note (the "#1744 removes" tense fix) no longer applies — that line is gone from the current branch after the rework.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.

Calls and constant reads in top-level / script context never get a caller node, so they emit no edges (uniform across languages)

3 participants