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:
Colby Mchenry
2026-10-23 05:20:21 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5b47bc4963
commit dcb985f1d2
9 changed files with 529 additions and 13 deletions
+1
View File
@@ -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.
+41
View File
@@ -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';
+312
View File
@@ -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);
});
+19 -4
View File
@@ -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> {
+11 -7
View File
@@ -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
+16 -1
View File
@@ -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,
+10
View File
@@ -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 }
+1 -1
View File
@@ -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);
+118
View File
@@ -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));
}