mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 19:30:10 +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
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user