Files
codegraph/__tests__/ruby-ivar-receivers.test.ts
T
Colby MchenryandClaude Opus 5.5 cdfa31be54 fix(ruby): a call on an instance variable that initialize types links to its class's method (#3053)
Both extractors record a Ruby call on an instance variable as
`@writer.flush`, and no strategy read the variable's type, so every such
call stayed unresolved: 2,780 in activesupport and 3,239 in actionpack on
main, for example.

ruby-constant-paths now resolves `@name.m` written in an instance method of
a class (not a class method, not a module) by the class's `initialize`.
When every assignment to `@name` there starts a statement and is a lone
`Const.new(...)`, `A::B.new` or `::Foo.new ...` of one constant
(arguments with or without parentheses, a block or a statement modifier
may follow), the class is the one Ruby's constant lookup finds for it from
`initialize`, and the method is that class's own instance method or the
first its ancestry declares. Anything else leaves the variable untyped for
the other strategies, as before: a conditional, two classes, `.tap`,
`writer || Foo.new`, `@a, @b = ...`, a chain continued on the next line. A
class the project doesn't declare (`Logger.new($stdout)`) links nothing.

Validation, kernel engine, main 3a4cd3bf vs this change:
- jekyll 97c3bdc +8, activesupport +19, actionpack +14, activerecord +30
  (rails 793d744 subtrees), sinatra cb22afd 0; nothing removed. 68 of the
  71 are right; 3 in activerecord follow `@pool = NullPool.new` from
  `initialize`, which `pool=` later replaces with the real pool.
- Controls requests (Python) and cobra (Go): dump-graph byte-identical.
- New tests fail with main's resolver (the positive case, in both engines)
  and pass with the fix; the untyped and outside-class cases pass on both.
- Index time, median of 3 interleaved: activesupport 4.72 vs 4.79 s,
  activerecord 11.54 vs 11.54 s.

The gap was found in the triage of PR #684.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:37:36 +00:00

168 lines
4.8 KiB
TypeScript

/**
* A Ruby call on an instance variable, like `@writer.flush`, links to the
* method of the class the class's `initialize` gives the variable:
* `@writer = CacheWriter.new` makes it a `CacheWriter`. Before, no call on an
* instance variable linked at all.
*
* Only `initialize` is read, and only a lone `Const.new(…)` types the
* variable there: one assigned two classes, a conditional, a value passed
* through `.tap`, or anything but `.new` leaves it untyped, and a class the
* project doesn't declare (`Logger.new($stdout)`) links nothing. A class
* method's `@x` is the class's own, and a module's methods run on whatever
* includes it, so neither is typed.
*
* 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> = {
'app/writer.rb': `class CacheWriter
def flush
nil
end
def ready?
true
end
end
class OtherWriter
def flush
nil
end
end
module Billing
class Card
def charge!(amount)
amount
end
end
class Ledger < Card
end
end
`,
'app/holder.rb': `class Holder
def initialize(key)
@writer = CacheWriter.new
@lazy ||= OtherWriter.new(
key
)
@either = key ? CacheWriter.new : OtherWriter.new
@twice = CacheWriter.new
@twice = OtherWriter.new if key
@ledger = Billing::Ledger.new key do |l|
l
end
@tapped = CacheWriter.new.tap { |w| w }
@outside = Logger.new($stdout)
@a, @b = CacheWriter.new, OtherWriter.new
end
def via_ivar
@writer.flush
@writer.ready?
@lazy.flush
@either.flush
@twice.flush
@ledger.charge!(1)
@tapped.flush
@outside.info("x")
@b.flush
@unset.flush
end
def self.klass
@writer.flush
end
end
module Mixin
def mixed
@writer.flush
end
end
`,
};
let root = '';
let cg: CodeGraph | undefined;
let savedKernel: string | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = '';
if (savedKernel === undefined) delete process.env.CODEGRAPH_KERNEL;
else process.env.CODEGRAPH_KERNEL = savedKernel;
});
async function index(backend: string): Promise<CodeGraph> {
savedKernel = process.env.CODEGRAPH_KERNEL;
if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
else delete process.env.CODEGRAPH_KERNEL;
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ruby-ivar-'));
for (const [rel, content] of Object.entries(FILES)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
return cg;
}
/** `<qualified name>:<line>` of every `calls` edge written on `line` of `file`. */
function callsAt(graph: CodeGraph, file: string, line: number): string[] {
const ids = graph.getNodesInFile(file).map((n) => n.id);
return graph
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind === 'calls' && e.line === line)
.map((e) => {
const t = graph.getNode(e.target)!;
return `${t.qualifiedName}:${t.startLine}`;
})
.sort();
}
const backends = [
['kernel', getKernel() === null],
['wasm', false],
] as const;
describe.each(backends)('Ruby calls on an instance variable initialize types (%s)', (backend, skip) => {
it.skipIf(skip)('links the method of the class initialize constructs', async () => {
const graph = await index(backend);
const holder = 'app/holder.rb';
expect(callsAt(graph, holder, 19)).toEqual(['CacheWriter::flush:2']);
expect(callsAt(graph, holder, 20)).toEqual(['CacheWriter::ready?:6']);
// `||=` with its arguments over several lines.
expect(callsAt(graph, holder, 21)).toEqual(['OtherWriter::flush:12']);
// A namespaced class, built with a block: the method its superclass has.
expect(callsAt(graph, holder, 24)).toEqual(['Billing::Card::charge!:19']);
});
it.skipIf(skip)('types nothing initialize leaves ambiguous', async () => {
const graph = await index(backend);
const holder = 'app/holder.rb';
for (const line of [22, 23, 25, 27, 28]) expect(callsAt(graph, holder, line)).toEqual([]);
});
it.skipIf(skip)('links nothing for a class the project does not declare', async () => {
const graph = await index(backend);
expect(callsAt(graph, 'app/holder.rb', 26)).toEqual([]);
});
it.skipIf(skip)("leaves a class method's and a module's instance variables untyped", async () => {
const graph = await index(backend);
expect(callsAt(graph, 'app/holder.rb', 32)).toEqual([]);
expect(callsAt(graph, 'app/holder.rb', 38)).toEqual([]);
});
});