fix(ruby): match a qualified receiver by its constant path - #3082
fix(ruby): match a qualified receiver by its constant path#3082rohit-jsfreaky wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
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).
|
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 |
Fixes #3078.
Summary
A namespaced Ruby receiver was truncated to its last constant at extraction time, so the namespace never reached the resolver.
engine.pycapturedActiveRecord::Base.transactionas the receiverBase, and the resolver then bound it to whatever single class namedBasethe 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
Basein the corpus nothing is ambiguous, so every framework call became anEXTRACTED/1.0edge into an unrelated class.ActiveRecord::BaseandActiveJob::Basecollapsed onto the same node, because only the final segment survived.Changes
engine.py: keep the whole constant path for ascope_resolutionreceiver, via the existing_ruby_const_full_namehelper.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::Processortherefore still finds anApp::Billing::Processor, whileActiveRecord::Baseno longer matchesThing::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.::Processor.callis still recognised now that the receiver text can start with::.This reuses the
fq_label_map/_segment_pathmachinery already built for the scoped mixin lookup, and only qualified receivers change path — a bareProcessor.callstill goes through_unique_classexactly as before.fq_label_mapis built from source-backed nodes only (the.rbfilter 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
Processorbut referenced asBilling::Processorno 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::Baseand a model making framework calls:Before, on 0.9.49:
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|, andActiveJob::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: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.runstill resolves asEXTRACTED, and a top-level-pinned::Solo.gostill 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
v8checkout 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 redisResponseError. I verified both sets by running them in a detached worktree at282976band diffing the failure lists; they match exactly.