Files
codegraph/__tests__/ruby-namespaced-receiver.test.ts
T
Colby MchenryandClaude Opus 5.5 dcb985f1d2 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>
2026-10-23 05:20:21 +00:00

313 lines
10 KiB
TypeScript

/**
* 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);
});