mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
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>
313 lines
10 KiB
TypeScript
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);
|
|
});
|