mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
fix(ruby): a call reaches the class method or the instance method its receiver calls (#2834)
A Ruby class method (`def self.build`, `def Widget.build`, or a def inside
`class << self`) was indexed exactly like an instance method: is_static 0,
under the same qualified name as an instance method of that name
(Widget::build twice). So every call took whichever of the two the class
declared first. Widget.build could reach `def build`, and widget.build on an
instance could reach `def self.build`. In the repro for issue 2503,
`card = Billing::ChargeCard.new(...); card.call` linked to `def self.call`
rather than `def call`.
Fix:
- Extraction, both engines: the Ruby isStatic hook (isRubyClassMethod in
languages/ruby.ts, is_class_method in codegraph-kernel/src/ruby.rs) marks a
singleton_method, or a method whose parent past one body_statement is a
singleton_class, as static. Every other def is marked not static, the same
way on both, so the graph only changes for class methods.
- Resolution, Ruby only, where the call says which kind it is:
rubyMethodsOfKindFirst orders a call's candidates with the wanted kind
first. A call on a constant (Strategy 1, its inherited walk, and the
namespaced path in ruby-constant-paths.ts) wants the class method. A
receiver typed by `x = Widget.new` (resolveMethodOnType) wants the
instance method. A bare call splits only a real pair, one qualified name
among its candidates as both kinds, keeping the caller's kind: a block
may run on another object, so nothing else moves. A receiver known only
by its name (Strategies 2 and 3) says nothing of its kind and is left as
it was: rubocop's `cop.cop_name` runs over cop classes. With only one
kind present, every path picks what it did before.
Validation:
- New __tests__/ruby-singleton-methods.test.ts (6 cases, each on the
kernel and on wasm) and a parity case in kernel-ruby-parity.test.ts. On
main 9 of the 18 tests in the two files fail, every one that checks the
new behaviour; the guards and the old parity cases pass. All pass with
the fix.
- A/B against main f7820b58 (kernel path): nodes are unchanged except
is_static going 0 to 1 on the class methods, 167 on sinatra, 358 on
rubocop and 266 on mastodon's Ruby tree. Edges: none added or removed.
20 changed their target or confidence: sinatra 10, rubocop 0, mastodon
10. 19 are right: 13 moved from the wrong twin to the right one, such as
InlineRenderer.render to def self.render and sinatra's instance methods'
force_encoding to def force_encoding, and 6 kept their target with
higher confidence. One kept a target wrong in both arms: a call inside
sinatra's define_method block. Controls express and flask are
byte-identical.
- The A/B narrowed two rules. Preferring instance methods for a receiver
known only by its name moved rubocop's 19 right cop.cop_name edges, and
dropping other-kind methods from any bare call moved block-DSL calls on
sinatra. Both are gone: only a real pair is split.
- kernel-parity.mjs --lang ruby is the same on both arms: sinatra 147/147,
rubocop 1799/1801 (2 deferred), mastodon 3333/3333, 0 diffs. The fix
arm's kernel and wasm graphs are byte-identical on sinatra and rubocop.
- Index time, median of 3 interleaved runs: sinatra -3.7%, rubocop +0.5%,
mastodon +0.7%, controls express -2.7% and flask +1.0%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f7820b584f
commit
16a8e77c46
@@ -17,7 +17,7 @@
|
||||
|
||||
use crate::buffers::{
|
||||
build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow,
|
||||
RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FUNCTION_REF_CODE, NONE, NONE_STR,
|
||||
RefRow, StrRef, Tables, FLAG_IS_EXPORTED, FLAG_IS_STATIC, FUNCTION_REF_CODE, NONE, NONE_STR,
|
||||
REF_FLAG_FILE_PATH,
|
||||
};
|
||||
use crate::docstring::preceding_docstring;
|
||||
@@ -97,6 +97,9 @@ struct Extra {
|
||||
docstring: Option<String>,
|
||||
signature: Option<String>,
|
||||
visibility: Option<u8>,
|
||||
/// The isStatic hook (languages/ruby.ts): set on every def, true for a
|
||||
/// class method (is_class_method).
|
||||
is_static: Option<bool>,
|
||||
}
|
||||
|
||||
struct ValueScope<'t> {
|
||||
@@ -283,10 +286,15 @@ impl<'t> Walker<'t> {
|
||||
let id_ref = self.arena.put(&id);
|
||||
let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref());
|
||||
let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref());
|
||||
// No isExported/isAsync hooks; isStatic only for defs.
|
||||
let mut flags = BoolFlags::default();
|
||||
if let Some(v) = extra.is_static {
|
||||
flags.set(FLAG_IS_STATIC, v);
|
||||
}
|
||||
let row = self.tables.push_node(&NodeRow {
|
||||
kind: node_kind_index(kind).unwrap(),
|
||||
visibility: extra.visibility.unwrap_or(0),
|
||||
flags: BoolFlags::default(), // no isExported/isAsync/isStatic hooks
|
||||
flags,
|
||||
start_line,
|
||||
end_line,
|
||||
start_column: self.col_of(node),
|
||||
@@ -615,6 +623,7 @@ impl<'t> Walker<'t> {
|
||||
docstring: preceding_docstring(node, self.src),
|
||||
signature: None, // no getSignature hook
|
||||
visibility: Some(self.visibility_of(node)),
|
||||
is_static: Some(is_class_method(node)),
|
||||
};
|
||||
let Some(row) = self.create_node("function", &name, node, extra) else { return };
|
||||
// (ruby ∉ TYPE_ANNOTATION_LANGUAGES; decorators are a structural no-op)
|
||||
@@ -633,6 +642,7 @@ impl<'t> Walker<'t> {
|
||||
docstring: preceding_docstring(node, self.src),
|
||||
signature: None,
|
||||
visibility: Some(self.visibility_of(node)),
|
||||
is_static: Some(is_class_method(node)),
|
||||
};
|
||||
let Some(row) = self.create_node("method", &name, node, extra) else { return };
|
||||
self.stack.push(Scope { row, kind: "method", name });
|
||||
@@ -649,6 +659,7 @@ impl<'t> Walker<'t> {
|
||||
docstring: preceding_docstring(node, self.src),
|
||||
signature: None,
|
||||
visibility: Some(self.visibility_of(node)),
|
||||
..Extra::default()
|
||||
};
|
||||
let Some(row) = self.create_node("class", &name, node, extra) else { return };
|
||||
|
||||
@@ -692,7 +703,7 @@ impl<'t> Walker<'t> {
|
||||
}
|
||||
let name = self.text(left).to_string();
|
||||
let signature = right.map(|r| util::init_signature(self.text(r)));
|
||||
self.create_node("variable", &name, node, Extra { docstring, signature, visibility: None });
|
||||
self.create_node("variable", &name, node, Extra { docstring, signature, ..Extra::default() });
|
||||
}
|
||||
|
||||
/// extractImport — every non-body `call` lands here (importTypes:['call']).
|
||||
@@ -1136,6 +1147,22 @@ fn is_constant_path(node: Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// isRubyClassMethod (languages/ruby.ts): `def self.x` / `def Widget.x` (a
|
||||
/// singleton_method), or a `def` inside `class << self`.
|
||||
fn is_class_method(node: Node) -> bool {
|
||||
match node.kind() {
|
||||
"singleton_method" => true,
|
||||
"method" => {
|
||||
let mut parent = node.parent();
|
||||
if parent.map_or(false, |p| p.kind() == "body_statement") {
|
||||
parent = parent.and_then(|p| p.parent());
|
||||
}
|
||||
parent.map_or(false, |p| p.kind() == "singleton_class")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Ruby `call` node whose argument_list (or keyword pair) contains `node`
|
||||
/// — nearest `call` ancestor within 4 parent hops (function-ref.ts:837).
|
||||
fn ruby_enclosing_call(node: Node) -> Option<Node> {
|
||||
|
||||
Reference in New Issue
Block a user