fix(ruby): capture paren-less self-send calls - #3960
rajatnagda45 wants to merge 1 commit into
Conversation
A receiver-less Ruby method call written without parentheses — the most
common call form in idiomatic Ruby — was dropped from the call graph:
def run
build # a method call on self
end
Tree-sitter parses that `build` as a bare `identifier`, structurally
identical to a local-variable read (`x`). The call-walk only inspected
`call` nodes, so `build(1)`, `self.build` and `build arg` all produced a
`calls` edge while the bare `build` produced nothing. A whole module of
mutually-calling helpers could end up with no internal edges at all.
The Ruby resolver already had a dedicated bare-call promotion path, but it
had nothing to promote because extraction never produced the call.
Fix: mirror Ruby's own disambiguation rule — a bare identifier is a send on
`self` unless a local or parameter of that name is in scope. `_ruby_local_names`
collects the bound names of a method body (parameters, assignments, block
params, `for` variables, `rescue` captures); a bare identifier not among them
is routed through the normal callee path as a non-member call, resolving
in-file (EXTRACTED) or becoming a raw_call the resolver can prove.
The collector is deliberately over-inclusive, so a missed binding only
suppresses a candidate call (fail-closed) rather than inventing a wrong edge.
A receiver-position send (`logger.info` where `logger` is itself a send) is
left for a follow-up and stays unresolved rather than guessed.
Added tests/test_ruby_bare_self_send_calls.py covering the self-send, the
cross-module and mixed-in cases, and the negatives (a local, a parameter and
a block parameter that shadow a method name must NOT become calls).
|
Thanks for the pull request, @rajatnagda45. A maintainer will review it soon. Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions. A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic. |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.
Formal verification. PR-changed functions: 0/1 verified (0 proven, 0 may-equivalent, 0 distinguished) · 1 not verified (1 unsupported).
Not verified on this run: \_extract\_generic (unsupported).
Graphify review — findings
Fixes dropped calls edges for paren-less Ruby self-sends like build: a bare identifier is now treated as a call on self unless a local or parameter of that name is in scope (Ruby's own disambiguation rule). It then resolves in-file or becomes a raw call for the Ruby resolver. _ruby_local_names collects each method's parameters, assignment targets, block params, for variables and rescue captures without descending into nested defs. It errs toward over-inclusion, so a missed binding only suppresses a candidate call and never invents a wrong edge.
Worth a look
- Multiple-assignment target walk treats attribute-call identifiers as locals —
graphify/extractors/engine.py:3314· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 737 functions depend on the 257 functions this change touches.
Health — this change adds coupling hotspots:
- new:
_extract_generic()— 18 callers, 30 callees - new:
extract_js()— 87 callers, 4 callees - new:
extract_xaml()— 19 callers, 17 callees - new:
extract_objc()— 27 callers, 9 callees - new:
extract_julia()— 19 callers, 7 callees - new:
extract_cpp()— 31 callers, 3 callees - new:
extract_vue()— 10 callers, 7 callees - new:
walk()— 1 callers, 66 callees - …and 9 more — each is listed as a finding
Verification — 737 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: 674 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
26 of 313 test file(s) selected (8%) via static blast radius.
tests/test_astro_extraction.py— impacttests/test_build.py— impacttests/test_cjs_module_extension.py— impacttests/test_cpp_nested_and_cli.py— impacttests/test_dotnet.py— impacttests/test_extract.py— impacttests/test_extract_php_closures.py— impacttests/test_import_extension_resolution.py— impacttests/test_indirect_call_block_scoped_shadow.py— impacttests/test_indirect_dispatch.py— impacttests/test_indirect_dispatch_assign_return.py— impacttests/test_indirect_dispatch_getattr.py— impacttests/test_js_exported_scalar_bindings.py— impacttests/test_languages.py— impacttests/test_multilang.py— impacttests/test_python_underscore_resolution.py— impacttests/test_rationale.py— impacttests/test_ruby_bare_self_send_calls.py— impact, changed-testtests/test_ruby_resolution.py— impacttests/test_scala_self_type.py— impacttests/test_swift_computed_properties.py— impacttests/test_swift_protocol_requirements.py— impacttests/test_trailing_newline_not_a_syntax_error.py— impacttests/test_ts_new_expression_calls.py— impacttests/test_typescript_module_extensions.py— impacttests/test_vue_extraction.py— impact
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Docs that may be stale (advisory)
.github/PULL_REQUEST_TEMPLATE.md§ Graphify-specific checklist (lines 33-39): references changed symbolsrunBENCHMARKS.md§ Reproducing (lines 174-187): references changed symbolsrunCHANGELOG.md§ 0.9.68 (2026-09-25) (lines 69-81): references changed symbolsrunCHANGELOG.md§ 0.9.59 (2026-09-12) (lines 173-180): references changed symbolsrunCHANGELOG.md§ 0.9.53 (2026-08-30) (lines 249-260): references changed symbolsrunCHANGELOG.md§ 0.9.47 (2026-08-19) (lines 337-353): references changed symbolsrunCHANGELOG.md§ 0.9.45 (2026-08-16) (lines 371-377): references changed symbolsrunCHANGELOG.md§ 0.9.39 (2026-08-10) (lines 450-457): references changed symbolsrunCHANGELOG.md§ 0.9.35 (2026-08-06) (lines 482-490): references changed symbolsrunCHANGELOG.md§ 0.9.24 (2026-07-22) (lines 598-612): references changed symbolsrun
…and 10 more.
Formal verification
Could not verify: Could not verify \_extract\_generic.
The verifier did not have enough to check \_extract\_generic, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `config` is annotated `LanguageConfig` — outside the synthesizable primitive/collection set
· 17 more finding(s) on lines outside this diff (see the check run).
|
Shipped in v0.9.74 (live on PyPI as Closing as shipped. |
Problem
A receiver-less Ruby method call written without parentheses — the most common call form in idiomatic Ruby — is dropped from the call graph:
Tree-sitter parses that
buildas a bareidentifier, structurally identical to a local-variable read (x). The call-walk only inspectedcallnodes, sobuild(1),self.buildandbuild argeach produced acallsedge while the barebuildproduced nothing — not even araw_call. Amoduleof mutually-calling helpers could end up with no internal edges at all, and the existing Ruby bare-call promotion path inruby_resolution.pyhad nothing to promote.Implementation
Mirror Ruby's own disambiguation rule: a bare identifier is a send on
selfunless a local or parameter of that name is in scope._ruby_local_namescollects the bound names of a method body — parameters, assignment targets, block parameters,forvariables andrescuecaptures.identifierwhose name is not one of those (and is not themethod/receiverfield of acall) is routed through the normal callee path as a non-member call. It then resolves in-file (EXTRACTED) or becomes araw_callthe Ruby resolver can prove/promote.The collector is deliberately over-inclusive, so a missed binding only suppresses a candidate call (fail-closed) rather than inventing a wrong edge. All changes are gated on
tree_sitter_ruby, so no other language is affected.Limitations
A receiver-position send (
logger.infowhereloggeris itself a paren-less send) is left for a follow-up and stays unresolved rather than guessed.Verification
tests/test_ruby_bare_self_send_calls.py(new): the self-send, cross-moduleand mixed-in cases are captured; the negatives (a local, a parameter and a block parameter that shadow a method name) must not become calls;self.buildand barebuilddo not double an edge. The positive specs fail onv8, pass with the fix.uv run pytest tests/— full suite green (6175 passed, 15 skipped; the DNS-boundtest_security.pyand the built-wheel test need network/a wheel).uv run ruff check .clean;uv run pyrightadds zero new errors.