mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 19:30:10 +08:00
fix(ruby): a call on a namespaced class links its caller to the class and its method (#2827)
A Ruby call on a constant written with its namespace, like Billing::ChargeCard.call(...) or ::Foo.call, recorded no edge. Both extractors (the kernel's ruby.rs and the wasm one) emitted the receiver's references ref only for a bare `constant` receiver, so a scope_resolution receiver got none. The call ref itself, Billing::ChargeCard.call, matched no resolver strategy either: the method-call patterns reject a `::` in the receiver. A class used only through such calls showed no callers and no dependents. Fix: - Both engines treat a constant path (a constant, or a scope_resolution of constants, a leading `::` included) as a constant receiver and emit the references ref under the receiver as written. A path through a value (obj::Thing, self.class::LIMIT) still emits none, and .new stays an instantiation. - A new resolver step, src/resolution/ruby-constant-paths.ts, resolves a Ruby references ref written as such a path, and a calls ref on one, the way Ruby looks the constant up: the class or module around the reference, then each namespace outside it, then the top level; a `::`-rooted path at the top level only. A call reaches the class's or module's own method, else the first one its ancestry declares (rubyAncestry, now exported). The step runs before the pre-filter, which dropped `::`-rooted names, and it is exclusive: a path the project doesn't declare (ActiveRecord::Base) or a class without the method links nothing, never a namesake by its last name, and production code never reaches a class only a test declares. Validation: - New __tests__/ruby-namespaced-receiver.test.ts (8 cases, each on the kernel and on wasm, plus a sync case) and a parity case in kernel-ruby-parity.test.ts. On main 16 of the 22 tests in the two files fail (the rest are guards and the old parity cases); all pass with the fix. Reverting only the resolver step, or only the wasm extractor, turns 13 and 10 of them red. - A/B against main (kernel path), every edge triaged: sinatra +23 edges, rubocop +168, mastodon's Ruby tree (4,363 files) +676; 0 removed, nodes identical. 866 of 867 are right: references by the path as written or under an enclosing namespace (each such case checked against the call site's own nesting), calls to a class method, a module_function method, or an inherited class method. 1 is wrong: Paperclip::Transcoder.make reaches the instance method `make`, since a singleton method and an instance method share one qualified name today; a bare Transcoder.make does the same on main. Controls express and flask are byte-identical. - kernel-parity.mjs --lang ruby: sinatra 147/147, rubocop 1799/1801 (the same 2 deferred on main), 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: +0.4% to +1.7%, the same as the controls (+0.8%, +1.3%). Fixes #2503 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5b47bc4963
commit
dcb985f1d2
@@ -151,6 +151,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In C++, a call on a variable, parameter or member declared with template arguments nested inside its template arguments, like `autovector<std::pair<int, FileMetaData*>>` or `Striped<CacheAlignedWrapper<port::Mutex>>`, now links to the method of the class it is declared as. Before, the nested arguments hid that class: in RocksDB, `files_marked_for_compaction_.clear()` on such an `autovector` linked to an unrelated `CompactionInputFiles::clear` that only shares words with the variable's name, and `mutex_.Get(key)` on a `Striped` linked to nothing. Re-index C++ projects after upgrading.
|
||||
- In C++, a base class like `Message` in `class DynamicMessage final : public Message` now links to the class C++ itself would pick: the one declared in the namespaces around the class, or brought in by the file's `using` declarations and namespace aliases, and the class a `typedef` or `using` alias names. Before, a base class was matched by its name alone, so it could land on a class of the same name in another namespace or another language: in protocolbuffers/protobuf, `DynamicMessage` derived from the JSON parser's internal `Message` class, and in google/leveldb and RocksDB, iterators derived from an unrelated nested `Iterator` class such as the skip list's. The wrong base showed up in `codegraph_explore`'s type hierarchy and in impact, and tied the base's methods to the wrong overrides. A template parameter used as a base no longer links to a class that shares its name, and a base written from the global scope, like `public ::testing::Test`, now links to its class. Re-index C++ projects after upgrading.
|
||||
- In C++ and Rust, a call written with a qualifier, like `Status::OK()`, now links to a method of the class it names, never to one of a class whose name only ends the same way. Before, facebook/rocksdb's calls to `Status::OK()`, `Status::InvalidArgument(…)` and `Status::Corruption(…)` linked to `IOStatus`'s methods of those names, a subclass calling its base, like `return FileSystemWrapper::NewWritableFile(…)` or google/leveldb's `return DB::Put(…)`, linked to another subclass or to the calling method itself, and BurntSushi/ripgrep's `Path::new(…)` on the standard library's `Path` linked to its own `PrinterPath::new`. When classes in several languages share the name, the one in the call's own language is taken, so protocolbuffers/protobuf's C++ `DescriptorPool::generated_pool()` calls, which met its Ruby namesake first and linked to nothing, now link to the C++ method. A C++ call through a template parameter, like `CacheShard::ComputeHash(key)` in rocksdb's `template <class CacheShard> class ShardedCache`, no longer links to a class that merely shares words with the parameter's name, unless that class is the parameter's default, like `TcParser` in `template <typename Parser = TcParser>`. A template's value parameter, like `const PrefetchOpts& kOpts`, is no longer mistaken for a type parameter, so references to `PrefetchOpts` inside it now link. Re-index C++ and Rust projects after upgrading.
|
||||
- In Ruby, a call on a class written with its namespace, like `Billing::ChargeCard.call(…)` or `::Billing::ChargeCard.call(…)`, now links the caller to that class and to the class method it calls, as `ChargeCard.call(…)` already linked to the class. Before, a class used only through such calls looked like it had no callers. The name is looked up the way Ruby does it, from the namespaces around the call outward, so `Users::Create.call` reaches `Users::Create` and never a `Billing::Create`, a call inside `module Api; module V2` reaches `Api::V2`'s class before another version's, and a library's class like `ActiveRecord::Base` is never taken for a project class of the same last name. Re-index Ruby projects after upgrading. Thanks @james-jang for the report and @drakeo338 for the pull request. (#2503)
|
||||
- In C++, a call through a class template's specialization written with its arguments, like fmtlib/fmt's `formatter<int>::format(42, ctx)`, no longer links to a member defined for another specialization, like a test's `formatter<incomplete_type>::format`, and when the template declares no such member, as for `formatter<std::tm, Char>::parse(ctx)`, the call is no longer matched to another class's method of that name, like `native_formatter::parse`. A call to the specialization a member is defined for, and to a template's own members, links as before. Re-index C++ projects after upgrading.
|
||||
- In C++, a function is now named by its declarator, never by something written in the clauses after its parameters. nlohmann/json's constructor `basic_json(CompatibleType&& val) noexcept(noexcept(JSONSerializer<U>::to_json(…)))` was missing from the index, replaced by a method `JSONSerializer::to_json` that doesn't exist and that other `JSONSerializer<T>::to_json(…)` calls linked to; a method declared with `throw(std::bad_alloc)` was indexed as `std::bad_alloc`, and a function with a trailing `requires std::integral<T>` as `std::integral<T>`. Re-index C++ projects after upgrading.
|
||||
- In C and C++, a header or source file named like a test, such as protocolbuffers/protobuf's `conformance_test.h` and `test_runner.h`, now counts as part of the code that `#include`s it, directly or through other headers. Before, calls from that code into it were dropped, so protobuf's conformance suites' `suite_.ReportFailure(…)` and `RunValidInputTest(…)` calls, and its unit tests' calls to the shared `TestUtil` helpers, linked to nothing. A test file that only tests include is still kept out of reach of the rest of your code. Re-index C and C++ projects after upgrading.
|
||||
|
||||
@@ -107,6 +107,47 @@ describe.skipIf(!kernelBuilt)('kernel Ruby extraction parity', () => {
|
||||
assertParity('fixtures/torture.rb (crlf)', crlf, 30);
|
||||
});
|
||||
|
||||
// A receiver written with its namespace (`Billing::ChargeCard.call`, `::Foo.call`)
|
||||
// is a constant read, as a bare `ChargeCard.call`'s is (#2503): both backends
|
||||
// emit the same `references` ref for it, under the receiver as written, at
|
||||
// the receiver. A path through a value (`obj::Thing`, `self.class::LIMIT`)
|
||||
// names no constant the index can know, and `.new` stays an instantiation.
|
||||
it('namespaced constant receivers', () => {
|
||||
const src = [
|
||||
'class Callers',
|
||||
' def a',
|
||||
' Billing::ChargeCard.call(amount: 1)',
|
||||
' ::Billing::ChargeCard.call',
|
||||
' ::Foo.call',
|
||||
' Users::Create&.call',
|
||||
' A::B::C.run!',
|
||||
' Config::DEFAULTS.fetch(:x)',
|
||||
' Foo::Bar::baz',
|
||||
' obj::Thing.go',
|
||||
' self.class::LIMIT.times { }',
|
||||
' Billing::ChargeCard.new(amount: 3)',
|
||||
' end',
|
||||
'end',
|
||||
'',
|
||||
].join('\n');
|
||||
assertParity('src/callers.rb', src);
|
||||
process.env.CODEGRAPH_KERNEL = '0';
|
||||
const refs = extractFromSource('src/callers.rb', src, 'ruby').unresolvedReferences
|
||||
.filter((r) => r.referenceKind === 'references' || r.referenceKind === 'instantiates')
|
||||
.map((r) => `${r.referenceKind} ${r.referenceName} ${r.line}:${r.column}`);
|
||||
delete process.env.CODEGRAPH_KERNEL;
|
||||
expect(refs.sort()).toEqual([
|
||||
'instantiates ChargeCard 12:4',
|
||||
'references ::Billing::ChargeCard 4:4',
|
||||
'references ::Foo 5:4',
|
||||
'references A::B::C 7:4',
|
||||
'references Billing::ChargeCard 3:4',
|
||||
'references Config::DEFAULTS 8:4',
|
||||
'references Foo::Bar 9:4',
|
||||
'references Users::Create 6:4',
|
||||
]);
|
||||
});
|
||||
|
||||
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,312 @@
|
||||
/**
|
||||
* A Ruby call on a constant written with its namespace —
|
||||
* `Billing::ChargeCard.call(…)`, `::Foo.call` — depends on the class it names
|
||||
* and calls that class's method, as `ChargeCard.call(…)` already did (#2503).
|
||||
* The constant is the one Ruby's lookup finds from where it is written: the
|
||||
* nearest namespace around the call that declares it, then the top level; a
|
||||
* name written from the top level (`::Foo`) is looked up there only. A
|
||||
* constant the project doesn't declare is a library's (`ActiveRecord::Base`)
|
||||
* and links nothing, never a project class or method sharing its last name.
|
||||
*
|
||||
* 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 issue's three files.
|
||||
'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
|
||||
`,
|
||||
'app/services/checkout/complete.rb': `module Checkout
|
||||
class Complete
|
||||
def call
|
||||
Billing::ChargeCard.call(amount: 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
`,
|
||||
'app/services/billing/refund.rb': `module Billing
|
||||
class Refund
|
||||
def call
|
||||
ChargeCard.call(amount: 2)
|
||||
end
|
||||
end
|
||||
end
|
||||
`,
|
||||
// One class name in two namespaces: the caller names which one it means.
|
||||
'app/billing/create.rb': `module Billing
|
||||
class Create
|
||||
def self.call = 1
|
||||
end
|
||||
end
|
||||
`,
|
||||
'app/users/create.rb': `module Users
|
||||
class Create
|
||||
def self.call = 2
|
||||
end
|
||||
end
|
||||
`,
|
||||
'app/foo.rb': `class Foo
|
||||
def self.call = 3
|
||||
end
|
||||
`,
|
||||
'app/svc/foo.rb': `module Svc
|
||||
class Foo
|
||||
def self.call = 4
|
||||
end
|
||||
end
|
||||
`,
|
||||
'app/svc/runner.rb': `module Svc
|
||||
class Runner
|
||||
def billing = Billing::Create.call
|
||||
def users = Users::Create.call
|
||||
def users_safe = Users::Create&.call
|
||||
def rooted = ::Billing::Create.call
|
||||
def rooted_top = ::Foo.call
|
||||
end
|
||||
end
|
||||
`,
|
||||
// The same relative name under two API versions: the one around the call.
|
||||
'app/api/v1/users/create.rb': `module Api
|
||||
module V1
|
||||
module Users
|
||||
class Create
|
||||
def self.call = 5
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
`,
|
||||
'app/api/v2/users/create.rb': `module Api
|
||||
module V2
|
||||
module Users
|
||||
class Create
|
||||
def self.call = 6
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
`,
|
||||
'app/api/v2/users_controller.rb': `module Api
|
||||
module V2
|
||||
class UsersController
|
||||
def create
|
||||
Users::Create.call
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
`,
|
||||
// A library constant whose last name a project class shares.
|
||||
'app/base.rb': `class Base
|
||||
def self.transaction = 7
|
||||
end
|
||||
`,
|
||||
'app/models/order.rb': `class Order
|
||||
def save!
|
||||
ActiveRecord::Base.transaction { }
|
||||
Stripe::Charge.create(amount: 1)
|
||||
end
|
||||
end
|
||||
`,
|
||||
// A test's stand-in for a library class, which production code never reaches.
|
||||
'spec/support/stripe.rb': `module Stripe
|
||||
class Charge
|
||||
def self.create(**) = 9
|
||||
end
|
||||
end
|
||||
`,
|
||||
'spec/models/order_spec.rb': `class OrderSpec
|
||||
def test_charge
|
||||
Stripe::Charge.create(amount: 1)
|
||||
end
|
||||
end
|
||||
`,
|
||||
// A module's method, a class method a superclass declares, a class declared
|
||||
// with a compact name, a method the class doesn't declare, a constant value.
|
||||
'app/billing/helpers.rb': `module Billing
|
||||
module Helpers
|
||||
def self.fmt(x) = x
|
||||
end
|
||||
class Document
|
||||
def self.build = new
|
||||
end
|
||||
class Invoice < Document
|
||||
end
|
||||
RATES = { usd: 1 }
|
||||
end
|
||||
`,
|
||||
'app/billing/statement.rb': `class Billing::Statement
|
||||
def self.render = 8
|
||||
end
|
||||
`,
|
||||
'app/billing/query.rb': `class Query
|
||||
def where(x) = x
|
||||
end
|
||||
`,
|
||||
'app/billing/report.rb': `class Report
|
||||
def run
|
||||
Billing::Helpers.fmt(1)
|
||||
Billing::Invoice.build
|
||||
Billing::Statement.render
|
||||
Billing::ChargeCard.where(amount: 1)
|
||||
Billing::RATES.fetch(:usd)
|
||||
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;
|
||||
});
|
||||
|
||||
function write(files: Record<string, string>): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function index(backend: string, files: Record<string, string> = FILES): 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-nsrecv-'));
|
||||
write(files);
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
return cg;
|
||||
}
|
||||
|
||||
/** `<kind> <qualified name>:<line>` of every `calls`/`references` edge written on `line` of `file`. */
|
||||
function edgesAt(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.kind === 'references') && e.line === line)
|
||||
.map((e) => {
|
||||
const t = graph.getNode(e.target)!;
|
||||
return `${e.kind} ${t.qualifiedName}:${t.startLine}`;
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** `<file>:<line>` of every `references` edge into the class or module `qualifiedName`. */
|
||||
function referencedFrom(graph: CodeGraph, qualifiedName: string): string[] {
|
||||
const target = graph.getNodesByQualifiedName(qualifiedName).find((n) => n.kind === 'class' || n.kind === 'module')!;
|
||||
return graph
|
||||
.getIncomingEdgesTo([target.id])
|
||||
.filter((e) => e.kind === 'references')
|
||||
.map((e) => `${graph.getNode(e.source)!.filePath}:${e.line}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const backends = [
|
||||
['kernel', getKernel() === null],
|
||||
['wasm', false],
|
||||
] as const;
|
||||
|
||||
describe.each(backends)('Ruby call on a namespaced constant (%s)', (backend, skip) => {
|
||||
it.skipIf(skip)('links the caller to the class, as a bare constant receiver does', async () => {
|
||||
const graph = await index(backend);
|
||||
expect(referencedFrom(graph, 'Billing::ChargeCard')).toEqual([
|
||||
'app/billing/report.rb:6',
|
||||
'app/services/billing/refund.rb:4',
|
||||
'app/services/checkout/complete.rb:4',
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(skip)("calls the class's method", async () => {
|
||||
const graph = await index(backend);
|
||||
expect(edgesAt(graph, 'app/services/checkout/complete.rb', 4)).toEqual([
|
||||
'calls Billing::ChargeCard::call:3',
|
||||
'references Billing::ChargeCard:2',
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(skip)('reaches the class of the namespace the caller names', async () => {
|
||||
const graph = await index(backend);
|
||||
const runner = 'app/svc/runner.rb';
|
||||
expect(edgesAt(graph, runner, 3)).toEqual(['calls Billing::Create::call:3', 'references Billing::Create:2']);
|
||||
expect(edgesAt(graph, runner, 4)).toEqual(['calls Users::Create::call:3', 'references Users::Create:2']);
|
||||
expect(edgesAt(graph, runner, 5)).toEqual(['calls Users::Create::call:3', 'references Users::Create:2']);
|
||||
});
|
||||
|
||||
it.skipIf(skip)('looks a ::-rooted name up at the top level only', async () => {
|
||||
const graph = await index(backend);
|
||||
const runner = 'app/svc/runner.rb';
|
||||
expect(edgesAt(graph, runner, 6)).toEqual(['calls Billing::Create::call:3', 'references Billing::Create:2']);
|
||||
// `::Foo` inside `module Svc` is the top-level Foo, not Svc::Foo.
|
||||
expect(edgesAt(graph, runner, 7)).toEqual(['calls Foo::call:2', 'references Foo:1']);
|
||||
});
|
||||
|
||||
it.skipIf(skip)('looks a relative name up in the namespaces around the call first', async () => {
|
||||
const graph = await index(backend);
|
||||
expect(edgesAt(graph, 'app/api/v2/users_controller.rb', 5)).toEqual([
|
||||
'calls Api::V2::Users::Create::call:5',
|
||||
'references Api::V2::Users::Create:4',
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(skip)('links nothing for a constant the project does not declare', async () => {
|
||||
const graph = await index(backend);
|
||||
expect(edgesAt(graph, 'app/models/order.rb', 3)).toEqual([]);
|
||||
expect(referencedFrom(graph, 'Base')).toEqual([]);
|
||||
});
|
||||
|
||||
it.skipIf(skip)('never links production code to a class only a test declares', async () => {
|
||||
const graph = await index(backend);
|
||||
expect(edgesAt(graph, 'app/models/order.rb', 4)).toEqual([]);
|
||||
expect(edgesAt(graph, 'spec/models/order_spec.rb', 3)).toEqual([
|
||||
'calls Stripe::Charge::create:3',
|
||||
'references Stripe::Charge:2',
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(skip)('calls module methods, inherited class methods and compact-named classes, and guesses no method', async () => {
|
||||
const graph = await index(backend);
|
||||
const report = 'app/billing/report.rb';
|
||||
expect(edgesAt(graph, report, 3)).toEqual(['calls Billing::Helpers::fmt:3', 'references Billing::Helpers:2']);
|
||||
expect(edgesAt(graph, report, 4)).toEqual(['calls Billing::Document::build:6', 'references Billing::Invoice:8']);
|
||||
expect(edgesAt(graph, report, 5)).toEqual(['calls Billing::Statement::render:2', 'references Billing::Statement:1']);
|
||||
// ChargeCard declares no `where`; Query's is not it.
|
||||
expect(edgesAt(graph, report, 6)).toEqual(['references Billing::ChargeCard:2']);
|
||||
// A constant holding a value: the call is the value's.
|
||||
expect(edgesAt(graph, report, 7)).toEqual(['references Billing::RATES:10']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ruby call on a namespaced constant, after a sync', () => {
|
||||
it('links the calls once a sync adds the class they name', async () => {
|
||||
const { 'app/services/billing/charge_card.rb': chargeCard, ...rest } = FILES;
|
||||
const graph = await index('default', rest);
|
||||
expect(edgesAt(graph, 'app/services/checkout/complete.rb', 4)).toEqual([]);
|
||||
|
||||
write({ 'app/services/billing/charge_card.rb': chargeCard! });
|
||||
expect((await graph.sync()).filesAdded).toBe(1);
|
||||
// complete.rb did not change: only the retry of its parked refs links it.
|
||||
expect(edgesAt(graph, 'app/services/checkout/complete.rb', 4)).toEqual([
|
||||
'calls Billing::ChargeCard::call:3',
|
||||
'references Billing::ChargeCard:2',
|
||||
]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -820,10 +820,12 @@ impl<'t> Walker<'t> {
|
||||
};
|
||||
self.push_ref(caller, &callee, calls_kind, line, col);
|
||||
|
||||
// Capitalized constant receiver (`Klass.static_call`, `RETRY_MAX.times`)
|
||||
// → an ADDITIONAL references ref at the RECEIVER's position.
|
||||
// (scope_resolution receivers get none — type ≠ constant.)
|
||||
if !skip && receiver.kind() == "constant" {
|
||||
// Capitalized constant receiver (`Klass.static_call`, `RETRY_MAX.times`),
|
||||
// or one written with its namespace (`Billing::ChargeCard.call`,
|
||||
// `::Foo.call` — #2503) → an ADDITIONAL references ref at the
|
||||
// RECEIVER's position, under the receiver as written. A path through
|
||||
// a value (`obj::Thing`, `self.class::LIMIT`) names no constant.
|
||||
if !skip && is_constant_path(receiver) {
|
||||
self.push_ref_at(
|
||||
caller,
|
||||
&receiver_name.to_string(),
|
||||
@@ -1121,6 +1123,19 @@ impl<'t> Walker<'t> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A constant, or a `scope_resolution` path of constants (`A::B::C`, a
|
||||
/// top-level `::A` / `::A::B`): what Ruby reads as a constant (isRubyConstantPath).
|
||||
fn is_constant_path(node: Node) -> bool {
|
||||
match node.kind() {
|
||||
"constant" => true,
|
||||
"scope_resolution" => {
|
||||
node.child_by_field_name("name").map_or(false, |n| n.kind() == "constant")
|
||||
&& node.child_by_field_name("scope").map_or(true, is_constant_path)
|
||||
}
|
||||
_ => 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> {
|
||||
|
||||
@@ -429,13 +429,17 @@ field_expression machinery, which ruby therefore NEVER runs. Steps:
|
||||
normalized to `()`**, unlike java/php chain encodings); literal receivers
|
||||
are NOT filtered (`"literal".upcase` → `"literal".upcase`, `5.times` →
|
||||
`5.times` — unresolvable noise, PRESERVE).
|
||||
6. Receiver node TYPE exactly `constant` (and not skipped) → ADDITIONAL
|
||||
`references` ref {name: receiverName, line/col: **the RECEIVER's**
|
||||
position} — `Klass.static_call` emits calls `Klass.static_call` +
|
||||
references `Klass`; fires for VALUE constants too (`RETRY_MAX.times {…}`
|
||||
→ calls `RETRY_MAX.times` + references `RETRY_MAX` — pinned). A
|
||||
`scope_resolution` receiver (`Foo::Bar.baz`) gets NO references ref
|
||||
(type ≠ constant). PRESERVE.
|
||||
6. Receiver a CONSTANT PATH (and not skipped) → ADDITIONAL `references`
|
||||
ref {name: receiverName, line/col: **the RECEIVER's** position} —
|
||||
`Klass.static_call` emits calls `Klass.static_call` + references `Klass`;
|
||||
fires for VALUE constants too (`RETRY_MAX.times {…}` → calls
|
||||
`RETRY_MAX.times` + references `RETRY_MAX` — pinned). A constant path is
|
||||
a `constant`, or a `scope_resolution` whose `name` is a `constant` and
|
||||
whose `scope` is absent or itself a constant path (#2503): `Foo::Bar.baz`
|
||||
→ references `Foo::Bar`, `::Foo.baz` → references `::Foo` (the leading
|
||||
`::` kept — the resolver reads it as "from the top level"). A path
|
||||
through a value (`obj::Thing.go`, `self.class::LIMIT.times`) gets NONE.
|
||||
Both engines: `is_constant_path` / `isRubyConstantPath`.
|
||||
7. return — inner receiver calls are ALSO visited afterwards (the body
|
||||
walker recurses children at 5277 — extractCall does not consume its
|
||||
subtree), so `chained.first_call.second_call` emits BOTH
|
||||
|
||||
@@ -305,6 +305,18 @@ function swiftPropertyInfo(
|
||||
return { nameNode: firstSimpleIdentifier(pattern), isLet, isComputed };
|
||||
}
|
||||
|
||||
/**
|
||||
* A Ruby constant, or a `scope_resolution` path of constants (`A::B::C`, a
|
||||
* top-level `::A` / `::A::B`): what Ruby reads as a constant. A path through
|
||||
* a value (`obj::Thing`, `self.class::LIMIT`) is not one.
|
||||
*/
|
||||
function isRubyConstantPath(node: SyntaxNode): boolean {
|
||||
if (node.type === 'constant') return true;
|
||||
if (node.type !== 'scope_resolution') return false;
|
||||
const scope = getChildByField(node, 'scope');
|
||||
return getChildByField(node, 'name')?.type === 'constant' && (!scope || isRubyConstantPath(scope));
|
||||
}
|
||||
|
||||
/** True when `node` is (transitively) inside a C function body — i.e. a local,
|
||||
* not a file/namespace-scope declaration. Walks the parent chain to the root. */
|
||||
function hasFunctionAncestor(node: SyntaxNode): boolean {
|
||||
@@ -5444,7 +5456,10 @@ export class TreeSitterExtractor {
|
||||
// — is itself a dependency on that constant; emit a `references` ref so a
|
||||
// class used only via its class methods still records a dependent (the edge
|
||||
// the old receiver-only callee happened to provide, now made explicit).
|
||||
if (!skip && receiverNode.type === 'constant') {
|
||||
// So is one written with its namespace, `Billing::ChargeCard.call` or
|
||||
// `::Foo.call` (#2503), under the receiver as written; a path through a
|
||||
// value (`obj::Thing`, `self.class::LIMIT`) names no constant.
|
||||
if (!skip && isRubyConstantPath(receiverNode)) {
|
||||
this.unresolvedReferences.push({
|
||||
fromNodeId: callerId,
|
||||
referenceName: receiverName,
|
||||
|
||||
@@ -41,6 +41,7 @@ import { clearAmbientModuleMemos, ambientModuleDeclarations } from './ambient-mo
|
||||
import { matchShopifyThemeFile } from './shopify-themes';
|
||||
import { isJsSuperCallLanguage, isJsSuperCallSite, matchJsSuperCall } from './js-super-calls';
|
||||
import { CLASS_EXPRESSION_REF_LANGUAGES, isCommonJsExportedClass, isJsClassExpression, writtenIn, type ClassExpressionIndex } from './js-class-expressions';
|
||||
import { matchRubyConstantPathRef } from './ruby-constant-paths';
|
||||
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveTsQualifiedTypeRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, reachesAmbientModule, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, ESM_IMPORT_LANGUAGES, jsModuleFile, pythonRootFingerprint, pythonReopenScope, pythonPackageImporters, moduleDefaultExport, isJsFamilyFile, isModuleBindingName } from './import-resolver';
|
||||
import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
|
||||
import { resolveAliasBinding } from './alias-binding';
|
||||
@@ -1574,6 +1575,15 @@ export class ReferenceResolver {
|
||||
const phpQualified = resolvePhpQualifiedClassRef(ref, this.context);
|
||||
if (phpQualified !== undefined) return this.gateLanguage(phpQualified, ref);
|
||||
|
||||
// A Ruby constant written with its namespace (`Billing::ChargeCard`,
|
||||
// `::Foo`) and a call on one (`Billing::ChargeCard.call`) are what Ruby's
|
||||
// constant lookup finds from where they are written, or nothing the
|
||||
// project declares (see ./ruby-constant-paths). The pre-filter below
|
||||
// drops a `::`-rooted name, and the strategies past it would take a
|
||||
// namesake by its last name.
|
||||
const rubyPath = matchRubyConstantPathRef(ref, this.context);
|
||||
if (rubyPath !== undefined) return this.gateLanguage(rubyPath, ref);
|
||||
|
||||
// Fast pre-filter: skip if no symbol with this name exists anywhere
|
||||
// AND the name doesn't match a local import. The import escape is
|
||||
// necessary because re-export rename chains (`import { login }
|
||||
|
||||
@@ -7805,7 +7805,7 @@ function isRubyMethodInScope(method: Node, ref: UnresolvedRef, context: Resoluti
|
||||
}
|
||||
|
||||
/** A Ruby class's qualified name, those of its superclasses, and of every module mixed into any of them. */
|
||||
function rubyAncestry(qn: string, context: ResolutionContext): Set<string> {
|
||||
export function rubyAncestry(qn: string, context: ResolutionContext): Set<string> {
|
||||
let memo = RUBY_ANCESTRY.get(context);
|
||||
if (!memo) RUBY_ANCESTRY.set(context, (memo = new Map()));
|
||||
const hit = memo.get(qn);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* A Ruby constant written with its namespace — `Billing::ChargeCard`, or from
|
||||
* the top level, `::Foo` and `::Billing::ChargeCard` — is the class, module
|
||||
* or constant Ruby's lookup finds from where it is written (#2503): the
|
||||
* nearest namespace around the reference that declares it, then the top
|
||||
* level; one written from the top level is looked up there only. The
|
||||
* extractor records such a call receiver as a `references` ref beside the
|
||||
* call, as it does a bare one (`ChargeCard.call`), and the call itself as
|
||||
* `Billing::ChargeCard.call`: a method of that class or module, its own or
|
||||
* one its ancestry declares.
|
||||
*
|
||||
* Both resolve here alone. A path the project doesn't declare is a library's
|
||||
* (`ActiveRecord::Base.transaction`), where the strategies past this one
|
||||
* would take a project class or method sharing its last name; and a class
|
||||
* the path names without the method (`Billing::ChargeCard.where`) has it from
|
||||
* somewhere the index can't see, never from another class that declares one.
|
||||
* Production code never names a class a test declares. A bare constant
|
||||
* (`ChargeCard`) is left to the name strategies, as before.
|
||||
*/
|
||||
import type { Node } from '../types';
|
||||
import type { ResolutionContext, ResolvedRef, UnresolvedRef } from './types';
|
||||
import { isTestPath } from '../search/query-utils';
|
||||
import { rubyAncestry } 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*)*)$/;
|
||||
/** `A::B.m`, `::A.m`: a call on one. Group 3 is the method. */
|
||||
const CONSTANT_PATH_CALL = /^(::)?([A-Z]\w*(?:::[A-Z]\w*)*)\.([A-Za-z_]\w*[?!=]?)$/;
|
||||
|
||||
/** What a Ruby constant is in the index: a class, a module, or a value (`RATES = {…}` is a `variable`). */
|
||||
const NAMESPACE_KINDS: ReadonlySet<string> = new Set(['class', 'module']);
|
||||
const VALUE_KINDS: ReadonlySet<string> = new Set(['variable', 'constant']);
|
||||
const CONSTANT_KINDS: ReadonlySet<string> = new Set([...NAMESPACE_KINDS, ...VALUE_KINDS]);
|
||||
|
||||
/**
|
||||
* What a Ruby `references` ref written as a constant path names, or the
|
||||
* method a call on one reaches (see the file comment). Undefined for any
|
||||
* other ref, which the other strategies resolve; null when the project
|
||||
* declares no such constant, or the class or module it names has no method
|
||||
* of the name.
|
||||
*/
|
||||
export function matchRubyConstantPathRef(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined {
|
||||
if (ref.language !== 'ruby') return undefined;
|
||||
if (ref.referenceKind === 'references') {
|
||||
const m = CONSTANT_PATH.exec(ref.referenceName);
|
||||
if (!m || !isNamespaced(m)) return undefined;
|
||||
const target = constantsNamed(m[2]!, m[1] !== undefined, ref, context)[0];
|
||||
return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'qualified-name' } : null;
|
||||
}
|
||||
if (ref.referenceKind === 'calls') {
|
||||
const m = CONSTANT_PATH_CALL.exec(ref.referenceName);
|
||||
if (!m || !isNamespaced(m)) return undefined;
|
||||
const owner = constantsNamed(m[2]!, m[1] !== undefined, ref, context)[0];
|
||||
const method = owner && NAMESPACE_KINDS.has(owner.kind) ? methodOf(owner.qualifiedName, m[3]!, ref, context) : null;
|
||||
return method ? { original: ref, targetNodeId: method.node.id, confidence: method.own ? 0.85 : 0.8, resolvedBy: 'qualified-name' } : null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether a matched path is written with a `::`, which a bare `Foo` is not. */
|
||||
function isNamespaced(m: RegExpExecArray): boolean {
|
||||
return m[1] !== undefined || m[2]!.includes('::');
|
||||
}
|
||||
|
||||
/**
|
||||
* The declarations of the constant `path` names where `ref` is written: those
|
||||
* of the first of `<scope>::<path>`, `<outer scope>::<path>`, … `<path>` the
|
||||
* project declares, the scope being the class or module around the
|
||||
* reference — only `<path>` itself when it is written from the top level.
|
||||
* A class or module comes before a value of the name, then the reference's
|
||||
* own file, then production code.
|
||||
*/
|
||||
function constantsNamed(path: string, absolute: boolean, ref: UnresolvedRef, context: ResolutionContext): Node[] {
|
||||
const production = !isTestPath(ref.filePath);
|
||||
let prefix = absolute ? '' : lexicalScope(ref, context);
|
||||
for (;;) {
|
||||
const qn = prefix ? `${prefix}::${path}` : path;
|
||||
const found = context.getNodesByQualifiedName(qn).filter((n) =>
|
||||
n.language === 'ruby' && CONSTANT_KINDS.has(n.kind) && !(production && isTestPath(n.filePath)));
|
||||
if (found.length > 0) return ordered(found, ref);
|
||||
if (!prefix) return [];
|
||||
const cut = prefix.lastIndexOf('::');
|
||||
prefix = cut >= 0 ? prefix.slice(0, cut) : '';
|
||||
}
|
||||
}
|
||||
|
||||
/** The qualified name of the innermost class or module around `ref`, or '' at the top level of its file. */
|
||||
function lexicalScope(ref: UnresolvedRef, context: ResolutionContext): string {
|
||||
let inner: Node | undefined;
|
||||
for (const n of context.getNodesInFile(ref.filePath)) {
|
||||
if (!NAMESPACE_KINDS.has(n.kind) || n.language !== 'ruby' || n.startLine > ref.line || n.endLine < ref.line) continue;
|
||||
// Of the ranges around a point, the innermost starts last.
|
||||
if (!inner || n.startLine > inner.startLine || (n.startLine === inner.startLine && n.startColumn > inner.startColumn)) inner = n;
|
||||
}
|
||||
return inner?.qualifiedName ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Anything before a value, then the reference's own file, then production code; the index's order within each. */
|
||||
function ordered(nodes: Node[], ref: UnresolvedRef): Node[] {
|
||||
const rank = (n: Node): number =>
|
||||
(VALUE_KINDS.has(n.kind) ? 4 : 0) + (n.filePath === ref.filePath ? 0 : 2) + (isTestPath(n.filePath) ? 1 : 0);
|
||||
return nodes.length < 2 ? nodes : [...nodes].sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
Reference in New Issue
Block a user