Skip to content

fix(ruby): capture paren-less self-send calls - #3960

Closed
rajatnagda45 wants to merge 1 commit into
Graphify-Labs:v8from
rajatnagda45:fix/ruby-bare-self-send-calls
Closed

rajatnagda45 wants to merge 1 commit into
Graphify-Labs:v8from
rajatnagda45:fix/ruby-bare-self-send-calls

Conversation

@rajatnagda45

Copy link
Copy Markdown
Contributor

Problem

A receiver-less Ruby method call written without parentheses — the most common call form in idiomatic Ruby — is dropped from the call graph:

class Widget
  def run
    build        # a method call on self: NO edge was emitted
  end
  def build; end
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 each produced a calls edge while the bare build produced nothing — not even a raw_call. A module of mutually-calling helpers could end up with no internal edges at all, and the existing Ruby bare-call promotion path in ruby_resolution.py had nothing to promote.

Implementation

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, assignment targets, block parameters, for variables and rescue captures.
  • In the call-walk, a bare identifier whose name is not one of those (and is not the method/receiver field of a call) is routed through the normal callee path as a non-member call. It then resolves in-file (EXTRACTED) or becomes a raw_call the 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.info where logger is 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-module and 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.build and bare build do not double an edge. The positive specs fail on v8, pass with the fix.
  • uv run pytest tests/ — full suite green (6175 passed, 15 skipped; the DNS-bound test_security.py and the built-wheel test need network/a wheel).
  • uv run ruff check . clean; uv run pyright adds zero new errors.

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).
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

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.

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

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 — impact
  • tests/test_build.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_php_closures.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_indirect_call_block_scoped_shadow.py — impact
  • tests/test_indirect_dispatch.py — impact
  • tests/test_indirect_dispatch_assign_return.py — impact
  • tests/test_indirect_dispatch_getattr.py — impact
  • tests/test_js_exported_scalar_bindings.py — impact
  • tests/test_languages.py — impact
  • tests/test_multilang.py — impact
  • tests/test_python_underscore_resolution.py — impact
  • tests/test_rationale.py — impact
  • tests/test_ruby_bare_self_send_calls.py — impact, changed-test
  • tests/test_ruby_resolution.py — impact
  • tests/test_scala_self_type.py — impact
  • tests/test_swift_computed_properties.py — impact
  • tests/test_swift_protocol_requirements.py — impact
  • tests/test_trailing_newline_not_a_syntax_error.py — impact
  • tests/test_ts_new_expression_calls.py — impact
  • tests/test_typescript_module_extensions.py — impact
  • tests/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)

…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).

@safishamsi

Copy link
Copy Markdown
Member

Shipped in v0.9.74 (live on PyPI as graphifyy==0.9.74). Landed on v8 as 7340b82 via an authorship-preserving cherry-pick, so your original commit authorship is kept intact. Thanks @rajatnagda45 for Ruby paren-less self-send calls 🙏

Closing as shipped.

@safishamsi safishamsi closed this Oct 2, 2026
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.

2 participants