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:
Colby Mchenry
2026-10-23 08:22:15 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f7820b584f
commit 16a8e77c46
8 changed files with 396 additions and 27 deletions
+1
View File
@@ -206,6 +206,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading.
- In JavaScript and TypeScript, a subclass's `static` method no longer counts as overriding an instance method of the same name in its base class, or the reverse, since calling one never runs the other. In webpack, `codegraph_explore`'s call path and impact went on from `Module`'s `deserialize(context)` into the `static deserialize(context)` of `NormalModule` and other modules. A static method still overrides its base's static method and an instance method its base's instance method, and JavaScript class members are now marked static, as TypeScript ones already were. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and @yang0228 for the pull request. (#2761)
- In TypeScript and JavaScript, a class in a `.tsx` or `.jsx` file now links its base class's and interfaces' methods to its overrides, as a class in a `.ts` or `.js` file already did. `codegraph_explore`'s call path, callers and impact now follow a call through the base into the override, like outline's `BaseEmail.send()` reaching each email's `render()`. This also works across file types, such as an interface in a `.ts` file implemented in a `.tsx` one. Before, these classes had no override links at all. Re-index projects with `.tsx` or `.jsx` files after upgrading.
- In Ruby, a class method, `def self.build` or a `def` inside `class << self`, is now told apart from an instance method of the same name. A call on the class, like `Widget.build`, links to the class method; a call on an instance, like `widget.build` after `widget = Widget.new`, links to the instance method; and a call without a receiver links to the one that matches the method it is written in. Before, both were stored as instance methods under one name, so a call linked to whichever the class declared first: in a service object with `def self.call` and `def call`, `card.call` on an instance linked to the class method. Re-index Ruby projects after upgrading.
- In Java, Kotlin, C# and Scala, each overload of an interface or base-class method now links only to the overrides with the same parameters. Before, every overload linked to every method of that name in the implementing class, so `ExampleService.putTairValue(String)` also led to `putTairValue(List<String>, double)`, and `codegraph_explore`'s call path, callers and impact went into overloads that call never runs. Where a parameter is a type parameter, like `save(T entity)` implemented as `save(String entity)`, or the class is written in another language than its interface, the number of parameters decides; a method declared once on each side links as before. Re-index Java, Kotlin, C# and Scala projects after upgrading. Thanks @hxj1225 for the report and @danusha2345 for the pull request. (#2663)
- In Kotlin, a call on what one of your functions returns, like `repository().pending(1)` or `val repo = repository(); repo.pending(1)` with a `private fun repository(): RealRepo` in a test class, now links to the method of the type that function declares, including one the type inherits or an extension on it. Before, the chained call went to whichever `pending` the name matched first, like a test double's in another package, and the local was guessed from its name, which reached the interface. The function is found in the calling class, a class it inherits from or one around it, or at the top level, and a nested type like `Request.Builder` is that type, not another class's `Builder`. When the declared type has no such method, the call now links to nothing rather than a method of the same name elsewhere; a type from outside your project is matched by name as before. Re-index Kotlin projects after upgrading. Thanks @mindcipherrr for the report. (#2805)
- In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497)
+45
View File
@@ -148,6 +148,51 @@ describe.skipIf(!kernelBuilt)('kernel Ruby extraction parity', () => {
]);
});
// A class method (`def self.x`, `def Widget.x`, a `def` in `class << self`)
// is static on both backends, every other def is not: they share a
// qualified name with any instance method of the name.
it('class methods are static', () => {
const src = [
'class Widget',
' def build = self',
' def self.build(opts) = new',
' def Widget.named = 1',
' class << self',
' def make = new',
' def other',
' def nested = 2',
' end',
' end',
' def run',
' def inner = 3',
' end',
'end',
'def self.top = 4',
'def plain = 5',
'',
].join('\n');
assertParity('src/widget.rb', src);
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
delete process.env.CODEGRAPH_KERNEL;
const viaKernel = tryKernelExtract('src/widget.rb', src, 'ruby');
const statics = viaKernel!.nodes
.filter((n) => n.kind === 'method' || n.kind === 'function')
.map((n) => `${n.kind} ${n.name}:${n.startLine} ${n.isStatic === true ? 'static' : n.isStatic === false ? 'instance' : 'unset'}`)
.sort();
expect(statics).toEqual([
'function inner:12 instance',
'function nested:8 instance',
'function plain:16 instance',
'function top:15 static',
'method build:2 instance',
'method build:3 static',
'method make:6 static',
'method named:4 static',
'method other:7 static',
'method run:11 instance',
]);
});
it('mixin implements refs carry filePath through the v2 ref-flag wire path', () => {
const src = 'class Widget\n include Comparable\nend\n';
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
+212
View File
@@ -0,0 +1,212 @@
/**
* A Ruby class method — `def self.build`, `def Widget.build`, or a `def`
* inside `class << self` — is the class's own, not one its instances answer
* to. They were indexed as instance methods under the same qualified name as
* an instance method of that name (`Widget::build` twice), so a call picked
* whichever the class declared first: `Widget.build` could land on
* `def build`, and `widget.build` on `def self.build`. Class methods are now
* marked static, and a call reaches the one of the right kind: a call on the
* class (`Widget.build`, `Billing::Payout.call`) its class method, a call on
* an instance (`w = Widget.new; w.build`) its instance method, and a bare
* call the one of the calling method's kind. Only such pairs are split: a
* block may run on another object, so a bare call with no same-named method
* of the other kind keeps the target it had. A receiver known by its name
* alone (`widget.build`) may hold the class itself, so it splits nothing.
*
* Runs on the native kernel (skipped when none is built) and the wasm
* extractor.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getKernel } from '../src/extraction/kernel/loader';
const FILES: Record<string, string> = {
// The instance method first, so declaration order alone picks it.
'app/widget.rb': `class Widget
def build
self
end
def self.build(opts)
new
end
class << self
def make
new
end
def build_all
build(1)
end
end
def run
build
Widget.build(1)
end
def self.go
build(2)
end
end
`,
'app/user.rb': `class User
def x
w = Widget.new
w.build
Widget.build(3)
Widget.make
end
end
`,
// #2503's control: an instance of a class whose class method comes first.
'app/services/billing/charge_card.rb': `module Billing
class ChargeCard
def self.call(**kw) = new(**kw).call
def initialize(amount:) = @amount = amount
def call = @amount
end
end
`,
// A class method's block (a test DSL's) calls what an instance method and a
// top-level function are both named: no pair of one class, so no split.
'app/spec_like.rb': `class SpecLike
def self.parses
run_case do
mock_app
end
end
def mock_app = 1
end
`,
'app/helpers.rb': `def mock_app = 2
`,
// A receiver named after a class that holds classes, as rubocop's
// `cop.cop_name` over its registry's cop classes does.
'app/cop.rb': `class Cop
def self.cop_name = 'Style/Foo'
def cop_name = self.class.cop_name
end
`,
'app/registry.rb': `class Registry
def names(cops)
cops.map { |cop| cop.cop_name }
end
end
`,
// A namespaced class whose instance method comes first.
'app/services/billing/payout.rb': `module Billing
class Payout
def call = 1
def self.call(**kw) = new.call
end
end
`,
'app/services/checkout/start.rb': `module Checkout
class Start
def call
card = Billing::ChargeCard.new(amount: 3)
card.call
Billing::Payout.call(amount: 4)
payout = Billing::Payout.new
payout.call
end
end
end
`,
};
let root = '';
let cg: CodeGraph | undefined;
let savedKernel: string | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = '';
if (savedKernel === undefined) delete process.env.CODEGRAPH_KERNEL;
else process.env.CODEGRAPH_KERNEL = savedKernel;
});
async function index(backend: string): Promise<CodeGraph> {
savedKernel = process.env.CODEGRAPH_KERNEL;
if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
else delete process.env.CODEGRAPH_KERNEL;
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ruby-singleton-'));
for (const [rel, content] of Object.entries(FILES)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
return cg;
}
/** `<qualified name>:<line>` of the method each `calls` edge written on `line` of `file` reaches. */
function callsAt(graph: CodeGraph, file: string, line: number): string[] {
const ids = graph.getNodesInFile(file).map((n) => n.id);
return graph.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls' && e.line === line)
.map((e) => { const t = graph.getNode(e.target)!; return `${t.qualifiedName}:${t.startLine}`; })
.sort();
}
const backends = [
['kernel', getKernel() === null],
['wasm', false],
] as const;
describe.each(backends)('Ruby class methods (%s)', (backend, skip) => {
it.skipIf(skip)('are static, and instance methods are not', async () => {
const graph = await index(backend);
const statics = graph.getNodesInFile('app/widget.rb')
.filter((n) => n.kind === 'method')
.map((n) => `${n.name}:${n.startLine}:${n.isStatic === true ? 'static' : 'instance'}`)
.sort();
expect(statics).toEqual([
'build:2:instance',
'build:6:static',
'build_all:15:static',
'go:25:static',
'make:11:static',
'run:20:instance',
]);
});
it.skipIf(skip)('a call on the class reaches its class method', async () => {
const graph = await index(backend);
expect(callsAt(graph, 'app/user.rb', 5)).toEqual(['Widget::build:6']);
expect(callsAt(graph, 'app/user.rb', 6)).toEqual(['Widget::make:11']);
expect(callsAt(graph, 'app/widget.rb', 22)).toEqual(['Widget::build:6']);
expect(callsAt(graph, 'app/services/checkout/start.rb', 6)).toEqual(['Billing::Payout::call:4']);
});
it.skipIf(skip)('a call on an instance reaches its instance method', async () => {
const graph = await index(backend);
expect(callsAt(graph, 'app/user.rb', 4)).toEqual(['Widget::build:2']);
expect(callsAt(graph, 'app/services/checkout/start.rb', 5)).toEqual(['Billing::ChargeCard::call:5']);
expect(callsAt(graph, 'app/services/checkout/start.rb', 8)).toEqual(['Billing::Payout::call:3']);
});
it.skipIf(skip)('a bare call reaches the method of the calling method kind', async () => {
const graph = await index(backend);
expect(callsAt(graph, 'app/widget.rb', 21)).toEqual(['Widget::build:2']);
expect(callsAt(graph, 'app/widget.rb', 26)).toEqual(['Widget::build:6']);
expect(callsAt(graph, 'app/widget.rb', 16)).toEqual(['Widget::build:6']);
});
it.skipIf(skip)('a receiver known by its name alone splits nothing: it may hold the class', async () => {
const graph = await index(backend);
expect(callsAt(graph, 'app/registry.rb', 3)).toEqual(['Cop::cop_name:2']);
});
it.skipIf(skip)('a bare call with no method of the other kind by its name keeps its target', async () => {
const graph = await index(backend);
expect(callsAt(graph, 'app/spec_like.rb', 4)).toEqual(['SpecLike::mock_app:8']);
});
});
+30 -3
View File
@@ -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> {
+10 -6
View File
@@ -253,14 +253,18 @@ Hooks PRESENT (port each exactly):
(see extractImport below).
Hooks ABSENT (the walker must NOT do these): `preParse`, `resolveName`,
`recoverMangledName`, `isMisparsedFunction`, `isConst`, `isStatic`,
`recoverMangledName`, `isMisparsedFunction`, `isConst`,
`isExported`, `isAsync`, `getSignature`, `getReturnType`, `getReceiverType`,
`resolveBody`, `classifyClassNode`, `classifyMethodNode`,
`extractPropertyName`, `interfaceKind`, `extraClassNodeTypes`,
`packageTypes`/`extractPackage`, `extractModifiers`, `synthesizeMembers`,
`skipBodilessClass`, `methodsAreTopLevel`. Consequences: every ruby
function/method node has signature/isAsync/isStatic/returnType/isExported
**undefined** (file node isExported:false); **no isConst means every
function/method node has signature/isAsync/returnType/isExported
**undefined** (file node isExported:false). `isStatic` IS a hook
(isRubyClassMethod / `is_class_method`): every function/method node carries
it — true for a `singleton_method` (`def self.x`, `def Foo.x`) and for a
`method` whose parent, past one `body_statement`, is a `singleton_class`
(`class << self`), false for every other def (a nested def included). **no isConst means every
`assignment` extracts kind `'variable'`, never `'constant'` — including
`MAX = 3`** (value-ref targeting still works: kind variable is a target);
`attr_accessor`/`attr_reader`/`attr_writer` synthesize NOTHING (no
@@ -279,9 +283,9 @@ non-decorator sibling — always a no-op).
| every node | visitNode hook first (943) | mixin calls + modules handled there (above); handled → scanFnRefSubtree + STOP |
| every node | maybeCaptureFnRefs (990) | fires for `argument_list` / `pair` (the RUBY_SPEC dispatch keys) in visitNode context too — this is how class-body hook-DSL symbols are captured |
| `method` | functionTypes:994 | inside class-like (class/module/…, isInsideClassLikeNode:1486 — **`module` counts**, 1498) AND in methodTypes → extractMethod:1737; else extractFunction:1517. skipChildren |
| `singleton_method` (`def self.x` / `def Foo.x` / `def obj.x`) | NOT functionTypes → methodTypes:1027 | no classifyMethodNode → extractMethod. At top level: not class-like, no methodsAreTopLevel, no receiver hook → gate 1747 sends it to extractFunction → a top-level `def self.x` is a plain **function** node named x. `object` field ignored everywhere — `def self.x` vs `def x` are indistinguishable in the graph (isStatic undefined) |
| `singleton_method` (`def self.x` / `def Foo.x` / `def obj.x`) | NOT functionTypes → methodTypes:1027 | no classifyMethodNode → extractMethod. At top level: not class-like, no methodsAreTopLevel, no receiver hook → gate 1747 sends it to extractFunction → a top-level `def self.x` is a plain **function** node named x. `object` field ignored everywhere; `def self.x` and `def x` share a qualified name, and only isStatic (true for the singleton_method) tells them apart |
| `class` | classTypes:1005 | no classifyClassNode → extractClass:1679 (kind 'class') |
| `singleton_class` (`class << self`) | NO branch | recursed → its `body` body_statement's defs extract as methods of the OUTER class, indistinguishable from instance methods (probed) |
| `singleton_class` (`class << self`) | NO branch | recursed → its `body` body_statement's defs extract as methods of the OUTER class, under the same qualified names as instance methods, with isStatic true (probed) |
| `assignment` (top level / class / module) | variableTypes:1098 | gate: `!isInsideClassLikeNode() \|\| isClassScopeConstantAssignment` (1508: type==='assignment' AND (`left` field ?? namedChild(0)).type === `'constant'`). File scope: identifier AND constant LHS both extract. Class/module scope: ONLY constant-LHS. Then extractVariable:2538 + scanFnRefSubtree (1110) + skipChildren — **the RHS is never walked: top-level/class-level `X = Foo.new` emits NO instantiates/calls** |
| `operator_assignment` (`+=`, `\|\|=`) | no branch | not `assignment` → nothing extracted at any scope; recursed (children emit nothing either at non-body scope) |
| `call` (top level / class / module body) | **importTypes:1209 — NEVER callTypes** | extractImport:3170 (below). require/require_relative → import node + refs; **every other call → NOTHING** (hook null → 3350 `if (this.extractor.extractImport) return;`). skipChildren stays false → children ARE visited: nested calls also land here (nothing), argument_lists get fn-ref capture. This kills `attr_accessor`, `has_many`, `define_method` (+ its do_block body!), `get '/x' do…end` route blocks, bare DSL calls — **all invisible to extraction at non-body scope** except fn-ref candidates + the rails regex extractor |
@@ -330,7 +334,7 @@ extractFilePackage:1397 (no packageTypes → no namespace node);
`value=` — keep the suffix in the name). `<anonymous>` path (1549) can't
trigger (grammar requires a name). No misparse hook. Node extras: docstring
(getPrecedingDocstring), signature undefined, visibility (sibling scan!),
isExported/isAsync/isStatic/returnType undefined. extractTypeAnnotations →
isExported/isAsync/returnType undefined, isStatic from the hook (above). extractTypeAnnotations →
no-op (ruby ∉ TYPE_ANNOTATION_LANGUAGES:5752). extractDecoratorsFor →
no-op. Push, walk `body` field (`body_statement`) via visitFunctionBody,
pop. **`parameters` (method_parameters) are NEVER walked — a call in a
+15
View File
@@ -2,6 +2,20 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
/**
* A class method: `def self.x` or `def Widget.x` (a singleton_method), or a
* `def` inside `class << self`. It shares its qualified name with an instance
* method of the same name (`Widget::build` for both), so only this tells the
* two apart.
*/
function isRubyClassMethod(node: SyntaxNode): boolean {
if (node.type === 'singleton_method') return true;
if (node.type !== 'method') return false;
let parent = node.parent;
if (parent?.type === 'body_statement') parent = parent.parent;
return parent?.type === 'singleton_class';
}
export const rubyExtractor: LanguageExtractor = {
functionTypes: ['method'],
classTypes: ['class'],
@@ -103,6 +117,7 @@ export const rubyExtractor: LanguageExtractor = {
return name;
},
isStatic: isRubyClassMethod,
getVisibility: (node) => {
// Ruby visibility is based on preceding visibility modifiers
let sibling = node.previousNamedSibling;
+78 -15
View File
@@ -7830,6 +7830,33 @@ function isRubyMethodInScope(method: Node, ref: UnresolvedRef, context: Resoluti
return rubyAncestry(here.qualifiedName, context).has(method.qualifiedName.slice(0, cut));
}
/**
* Of a Ruby class's two methods of one name, `def build` and `def self.build`
* (both `Widget::build`), the one a bare call means: a bare call is a call on
* `self`, so in an instance method it is the instance method, and in a class
* method (`def self.x`, `class << self`) the class method. Widget's `run`
* calling `build` reaches `def build`, and its `def self.go` calling `build`
* reaches `def self.build`. Only such pairs are split: inside a block that
* runs on another object (`Module.new do … end`, a test's `it do … end`) the
* caller's kind says nothing of `self`, and any other candidate stays as it
* was. A call written anywhere but in a method splits nothing.
*/
function rubyMethodsOfCallerKind(candidates: Node[], ref: UnresolvedRef, context: ResolutionContext): Node[] {
if (candidates.length < 2) return candidates;
const caller = context.getNodeById?.(ref.fromNodeId);
if (!caller || caller.kind !== 'method' || caller.language !== 'ruby') return candidates;
const wantClass = caller.isStatic === true;
const isRubyMethod = (n: Node): boolean => n.kind === 'method' && n.language === 'ruby';
// Per qualified name, which kinds the candidates declare: 1 class, 2 instance.
const kinds = new Map<string, number>();
for (const n of candidates) {
if (isRubyMethod(n)) kinds.set(n.qualifiedName, (kinds.get(n.qualifiedName) ?? 0) | (n.isStatic === true ? 1 : 2));
}
const twinOfOtherKind = (n: Node): boolean =>
isRubyMethod(n) && kinds.get(n.qualifiedName) === 3 && (n.isStatic === true) !== wantClass;
return candidates.some(twinOfOtherKind) ? candidates.filter((n) => !twinOfOtherKind(n)) : candidates;
}
/** A Ruby class's qualified name, those of its superclasses, and of every module mixed into any of them. */
export function rubyAncestry(qn: string, context: ResolutionContext): Set<string> {
let memo = RUBY_ANCESTRY.get(context);
@@ -11950,7 +11977,8 @@ export function matchByExactName(
const ranked = dartBare ? nearestDartMembers(preferOwnDartLibrary(filtered, ref, context), ref, context)
: dartTypeRef && dartPrefix === null ? preferOwnDartLibrary(filtered, ref, context)
: swiftShape && swiftShape.shape !== 'chained' ? nearestSwiftMembers(filtered, ref, context)
: kotlinBare ? lexicalKotlinMembers(filtered, ref, context) : cppSpecializationPool(filtered, ref, context);
: kotlinBare ? lexicalKotlinMembers(filtered, ref, context)
: rubyBare ? rubyMethodsOfCallerKind(filtered, ref, context) : cppSpecializationPool(filtered, ref, context);
// (Above the ceiling below nothing is picked, so no translation unit is read.)
const candidates = ranked.length <= AMBIGUOUS_NAME_CEILING ? withinCppTranslationUnit(ranked, ref, context, true) : ranked;
@@ -13125,7 +13153,11 @@ export function resolveMethodOnType(
// intentionally in ANOTHER file (#314) — is unaffected: those blocks return
// early whenever an import pins the class.
if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null;
const ordered = preferCallSiteFile(withinCppTranslationUnit(cppVisibleFirst(matches, ref, context), ref, context), ref.filePath);
// A Ruby receiver is typed only by `x = Widget.new`, so it is an instance:
// `x.build` reaches `def build`, not `def self.build`, wherever each is.
const ordered = rubyMethodsOfKindFirst(
preferCallSiteFile(withinCppTranslationUnit(cppVisibleFirst(matches, ref, context), ref, context), ref.filePath),
ref.language === 'ruby' ? 'instance' : undefined);
return {
original: ref,
targetNodeId: ordered[0]!.id,
@@ -18609,18 +18641,18 @@ function tsClassHead(lines: readonly string[], cls: Node): string {
}
/** A method named `name` on a supertype of the given classes, nearest first. */
function inheritedClassMethod(classes: Node[], name: string, context: ResolutionContext): Node | null {
function inheritedClassMethod(classes: Node[], name: string, context: ResolutionContext, kind?: RubyMethodKind): Node | null {
if (classes.length === 0) return null;
let memo = INHERITED_METHODS.get(context);
if (!memo) INHERITED_METHODS.set(context, (memo = new Map()));
const key = `${classes.map((c) => c.id).join(',')}|${name}`;
const key = `${classes.map((c) => c.id).join(',')}|${name}|${kind ?? ''}`;
if (memo.has(key)) return memo.get(key)!;
const found = findInheritedClassMethod(classes, name, context);
const found = findInheritedClassMethod(classes, name, context, kind);
memo.set(key, found);
return found;
}
function findInheritedClassMethod(classes: Node[], name: string, context: ResolutionContext): Node | null {
function findInheritedClassMethod(classes: Node[], name: string, context: ResolutionContext, kind?: RubyMethodKind): Node | null {
const seen = new Set<string>(classes.map((c) => c.id));
let frontier = classes;
for (let depth = 0; depth < 5 && frontier.length > 0; depth++) {
@@ -18630,8 +18662,8 @@ function findInheritedClassMethod(classes: Node[], name: string, context: Resolu
for (const decl of context.getNodesByName(sup)) {
if (decl.language !== cls.language || !isMethodOwnerKind(decl) || seen.has(decl.id)) continue;
seen.add(decl.id);
const method = context.getNodesInFile(decl.filePath).find((n) => n.kind === 'method' && n.name === name &&
n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./).pop() === decl.name);
const method = rubyMethodsOfKindFirst(context.getNodesInFile(decl.filePath).filter((n) => n.kind === 'method' && n.name === name &&
n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./).pop() === decl.name), kind)[0];
if (method) return method;
next.push(decl);
}
@@ -20242,7 +20274,9 @@ export function matchMethodCall(
return { original: ref, targetNodeId: method.id, confidence: own ? 0.85 : 0.8, resolvedBy: 'qualified-name' };
}
const methodNode = classStrategyMethod(classNode, methodName!, context);
// A Ruby call on a class is a call of its class method: `Widget.build`
// reaches `def self.build`, not `def build`, whichever comes first.
const methodNode = classStrategyMethod(classNode, methodName!, context, ref.language === 'ruby' ? 'class' : undefined);
if (methodNode) {
return {
@@ -20255,7 +20289,8 @@ export function matchMethodCall(
}
// A class method the named class inherits — Horse's `THorse.Get(…)` is
// THorseCore's, three `class(…)` heads up — before any guess by name.
const inherited = inheritedClassMethod(classCandidates.filter((c) => c.language === ref.language), methodName!, context);
const inherited = inheritedClassMethod(classCandidates.filter((c) => c.language === ref.language), methodName!, context,
ref.language === 'ruby' ? 'class' : undefined);
if (inherited) return { original: ref, targetNodeId: inherited.id, confidence: 0.8, resolvedBy: 'qualified-name' };
return null;
});
@@ -20344,6 +20379,10 @@ export function matchMethodCall(
if (!isLexicallyReachable(classNode, ref, context)) continue;
if (productionCaller && isTestPath(classNode.filePath)) continue;
// A Ruby receiver named after a class may hold the class as well as an
// instance of it (rubocop's `cop.cop_name` runs over its registry's
// cop classes, so it is `def self.cop_name`), so neither of a class's
// two methods of the name is preferred: the first declared is taken.
const methodNode = classStrategyMethod(classNode, methodName!, context);
if (methodNode) {
@@ -25688,9 +25727,12 @@ function tsFieldCall(
* the caller's loop, so a nested `Outer::Logger` can take a top-level
* `Logger.log()` (declared first, or the only one with the method). Choosing
* among them needs each language's scope rules.
*
* `kind` picks between a Ruby class's two methods of the name, `def self.m`
* and `def m`, which share one qualified name (rubyMethodsOfKindFirst).
*/
function findOwnMethod(nodesInFile: Node[], classNode: Node, methodName: string): Node | undefined {
const methods = nodesInFile.filter((n) => n.kind === 'method' && n.name === methodName);
function findOwnMethod(nodesInFile: Node[], classNode: Node, methodName: string, kind?: RubyMethodKind): Node | undefined {
const methods = rubyMethodsOfKindFirst(nodesInFile.filter((n) => n.kind === 'method' && n.name === methodName), kind);
const exact = `${classNode.qualifiedName}::${methodName}`;
const byName = `${classNode.name}::${methodName}`;
return (
@@ -25706,9 +25748,30 @@ function findOwnMethod(nodesInFile: Node[], classNode: Node, methodName: string)
* `Form` reaches `BaseForm::is_valid`, which the substring test this replaced
* only found because `BaseForm` contains `Form`.
*/
function classStrategyMethod(classNode: Node, methodName: string, context: ResolutionContext): Node | undefined {
return findOwnMethod(context.getNodesInFile(classNode.filePath), classNode, methodName) ??
inheritedClassMethod([classNode], methodName, context) ?? undefined;
function classStrategyMethod(classNode: Node, methodName: string, context: ResolutionContext, kind?: RubyMethodKind): Node | undefined {
return findOwnMethod(context.getNodesInFile(classNode.filePath), classNode, methodName, kind) ??
inheritedClassMethod([classNode], methodName, context, kind) ?? undefined;
}
/**
* Which of a Ruby class's two methods of one name a call reaches: a call on
* the class (`Widget.build`) its class method, `def self.build` or one in
* `class << self`; a call on an instance (`widget.build`) its instance
* method, `def build`. Both share one qualified name, `Widget::build`, and
* only `isStatic` tells them apart.
*/
export type RubyMethodKind = 'class' | 'instance';
/**
* `methods` with the ones of `kind` first, in their own order: a pick of the
* first then takes one of the kind wherever there is one, and still the
* first otherwise. Methods of other languages are never static-split.
*/
export function rubyMethodsOfKindFirst<T extends Node>(methods: T[], kind: RubyMethodKind | undefined): T[] {
if (!kind || methods.length < 2) return methods;
const fits = (m: T): boolean => m.language === 'ruby' && (m.isStatic === true) === (kind === 'class');
const first = methods.filter(fits);
return first.length === 0 || first.length === methods.length ? methods : [...first, ...methods.filter((m) => !fits(m))];
}
/**
+5 -3
View File
@@ -20,7 +20,7 @@
import type { Node } from '../types';
import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types';
import { isTestPath } from '../search/query-utils';
import { rubyAncestry } from './name-matcher';
import { rubyAncestry, rubyMethodsOfKindFirst } from './name-matcher';
/** `A::B`, `::A`, `::A::B`: a constant path. Group 1 is a leading `::`. */
const CONSTANT_PATH = /^(::)?([A-Z]\w*(?:::[A-Z]\w*)*)$/;
@@ -98,14 +98,16 @@ function lexicalScope(ref: UnresolvedRef, context: ResolutionContext): string {
/**
* The method `name` of the class or module `owner`: its own, else the first
* of its superclasses and mixed-in modules that declares one (rubyAncestry).
* Production code never reaches one a test declares.
* Of a class's `def self.m` and `def m`, which share one qualified name, the
* call on the class reaches the class method. Production code never reaches
* one a test declares.
*/
function methodOf(owner: string, name: string, ref: UnresolvedRef, context: ResolutionContext): { node: Node; own: boolean } | null {
const production = !isTestPath(ref.filePath);
for (const qn of rubyAncestry(owner, context)) {
const methods = context.getNodesByQualifiedName(`${qn}::${name}`).filter((n) =>
n.kind === 'method' && n.language === 'ruby' && !(production && isTestPath(n.filePath)));
if (methods.length > 0) return { node: ordered(methods, ref)[0]!, own: qn === owner };
if (methods.length > 0) return { node: rubyMethodsOfKindFirst(ordered(methods, ref), 'class')[0]!, own: qn === owner };
}
return null;
}