Skip to content

fix(ruby): match a qualified receiver by its constant path - #3082

Closed
rohit-jsfreaky wants to merge 1 commit into
Graphify-Labs:v8from
rohit-jsfreaky:fix/ruby-qualified-receiver-scope
Closed

fix(ruby): match a qualified receiver by its constant path#3082
rohit-jsfreaky wants to merge 1 commit into
Graphify-Labs:v8from
rohit-jsfreaky:fix/ruby-qualified-receiver-scope

Conversation

@rohit-jsfreaky

Copy link
Copy Markdown
Contributor

Fixes #3078.

Summary

A namespaced Ruby receiver was truncated to its last constant at extraction time, so the namespace never reached the resolver. engine.py captured ActiveRecord::Base.transaction as the receiver Base, and the resolver then bound it to whatever single class named Base the corpus defined.

The comment there noted the god-node guard bails when ambiguous — but that guard only catches an ambiguous match, not a unique-but-wrong one. With exactly one Base in the corpus nothing is ambiguous, so every framework call became an EXTRACTED / 1.0 edge into an unrelated class. ActiveRecord::Base and ActiveJob::Base collapsed onto the same node, because only the final segment survived.

Changes

  • engine.py: keep the whole constant path for a scope_resolution receiver, via the existing _ruby_const_full_name helper.
  • ruby_resolution.py: add _class_by_const_path, which matches on the constant path rather than its tail — a class qualifies when its own label ends with the referenced segments. Billing::Processor therefore still finds an App::Billing::Processor, while ActiveRecord::Base no longer matches Thing::Base. A leading :: pins the reference to top level and must match the label whole. Ambiguous, or matching nothing in the corpus (the usual case for a framework constant) -> no edge.
  • The constant-receiver check now looks past leading colons, so a top-level-pinned ::Processor.call is still recognised now that the receiver text can start with ::.

This reuses the fq_label_map / _segment_path machinery already built for the scoped mixin lookup, and only qualified receivers change path — a bare Processor.call still goes through _unique_class exactly as before. fq_label_map is built from source-backed nodes only (the .rb filter documented above it), so this cannot bind to one of the file-less external placeholders the issue mentions.

Behaviour change worth flagging: a qualified receiver now requires its namespace to be present in the corpus. A class declared top-level as Processor but referenced as Billing::Processor no longer produces an edge. That is the same class of false edge this fixes, so I believe it is correct, but it is a deliberate tightening rather than a pure bug fix.

Reproduction

Two files — a nested Thing::Base and a model making framework calls:

# app/services/thing.rb
class Thing
  class Base
    def self.call(x) = x
  end
end

# app/models/other.rb
class Other
  def work = ActiveRecord::Base.transaction { save! }
  def more = ActiveJob::Base.default_queue_name
end

Before, on 0.9.49:

.work() -- calls --> Thing::Base | EXTRACTED
.more() -- calls --> Thing::Base | EXTRACTED

After: neither edge is emitted. All five forms listed in the issue were checked — transaction(requires_new:), transaction do…end, .connection.execute, with_connection do |c|, and ActiveJob::Base.default_queue_name — 0 false edges.

Tests

Three appended to tests/test_ruby_resolution.py. The first is the repro and fails without the source change:

FAILED test_framework_qualified_receiver_does_not_bind_same_named_local_class

The other two are guards rather than repros — they pass before and after, and exist so the namespace check cannot be tightened at the cost of a genuine edge: a correctly-namespaced Billing::Processor.run still resolves as EXTRACTED, and a top-level-pinned ::Solo.go still resolves.

uv run --no-sync pytest tests/test_ruby_resolution.py -q — 29 passed (was 26).

uv run --no-sync ruff check graphify/ruby_resolution.py graphify/extractors/engine.py — passed.

Full suite: 14 failed, 4984 passed, 41 skipped. All 14 are pre-existing on a clean v8 checkout of the same commit on this Windows machine — 12 platform tests (fifo / unix-socket / hermes / gemini paths / watch) and 2 FalkorDB integration tests that fail against the local port with a redis ResponseError. I verified both sets by running them in a detached worktree at 282976b and diffing the failure lists; they match exactly.

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

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Fixes Ruby qualified-receiver resolution so a namespaced call like ActiveRecord::Base.transaction no longer binds to an unrelated local class merely named Base: _extract_generic now captures the full constant path instead of its last segment, and resolve_ruby_member_calls routes qualified receivers through a new _class_by_const_path that matches on the trailing namespace segments and emits an edge only on a unique corpus hit (never a guess). The constant-receiver check strips leading :: so a top-level-pinned ::Solo.go still resolves, and genuine in-namespace calls like Billing::Processor.run keep their EXTRACTED edge.

No blocking issues surfaced. 5 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 679 functions depend on the 271 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 24 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_js() — 80 callers, 3 callees
  • new: extract_julia() — 17 callers, 7 callees
  • new: extract_cpp() — 27 callers, 3 callees
  • new: extract_vue() — 10 callers, 6 callees
  • new: walk() — 1 callers, 56 callees
  • …and 9 more — each is listed as a finding

Verification — 679 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: 619 function(s) in the blast radius were not formally verified this run

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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify resolve\_ruby\_member\_calls.

The verifier did not have enough to check resolve\_ruby\_member\_calls, 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: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

· 17 more finding(s) on lines outside this diff (see the check run).

@safishamsi

Copy link
Copy Markdown
Collaborator

Shipped in v0.9.50 via authorship-preserving cherry-pick so you keep contributor-graph credit. Thanks @rohit-jsfreaky! Release: https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.50

@safishamsi safishamsi closed this Aug 25, 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

2 participants