mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 21:13:11 +08:00
Two kinds of Ruby call linked nothing. A call to a method whose name ends
in `!` or `?` on a receiver (`writer.flush!`, `writer.ready?` after
`writer = CacheWriter.new`, `CacheWriter.create!(1)`) failed the method-call
pattern, whose method part took no `!` or `?`. A method called straight on
a new object (`CacheWriter.new(key).write_cache`, `Billing::Ledger.new.charge!`)
was recorded by both extractors with the receiver's raw text, arguments and
blocks included, which no strategy matched.
Both extractors now record a call on `Const.new(...)`, `A::B.new` or
`::Foo.new` as `new Const().m`, the constructed-receiver shape TS/JS, Java
and C# already use; the wasm extractor and the kernel's ruby.rs emit the
same refs. ruby-constant-paths resolves that shape before the name
pre-filter: the class by Ruby's constant lookup from the namespaces around
the call, the method as the class's own instance method or the first its
ancestry declares, and nothing when either is missing, so rack-protection's
`Request.new(env)` (rack's Rack::Request) links nothing. The Ruby dot pattern
in matchMethodCall takes a trailing `!` or `?`; the receiver-word scoring
among several project methods of the name stays off for such names on a
value receiver, where it was wrong on 55 of 74 new edges (`options.key?`
on a Hash -> InheritableOptions#key?). rubyAncestry no longer reads a
module a call returns (`include RouteSet.new.tap {...}.url_helpers`) as a
mixin of the constant the call starts at.
Validation, kernel engine, main 37052b18 vs this change:
- sinatra cb22afd +12, jekyll 97c3bdc +53, activesupport +398,
actionpack +97 (2 removed: `ActionController::Base.with` had reached
RoutesHelpers.with through a misread mixin), activerecord +1012
(rails 793d744 subtrees). About 21 of 1,572 added edges are wrong, all
from the class-name and single-method guesses that plain names get too.
- Controls requests (Python) and cobra (Go): dump-graph byte-identical.
- kernel-parity.mjs: fixtures 10/10, sinatra 147/147, jekyll + rails
1044/1044 files byte-parity, none deferred.
- New tests: 10 fail with main's kernel and TS, all pass with the fix, in
both engines. Dropping the scoring gate or the mixin fix fails its guard
test.
- Index time, median of 3 interleaved: activesupport 5.35 vs 4.63 s,
jekyll 2.66 vs 2.73 s (noise).
Reimplements PR #684 by @ricobl, who found both gaps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
/**
|
|
* Kernel↔wasm Ruby extraction parity (R7b of the kernel migration).
|
|
*
|
|
* Asserts the native walker (codegraph-kernel/src/ruby.rs) produces the SAME
|
|
* ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
|
|
* unresolved refs compared as canonicalized multisets — over the checked-in
|
|
* torture fixture (torture.rb: the importTypes:['call'] funnel and its
|
|
* class-body DSL blindness, mixin implements refs WITH filePath (the v2
|
|
* ref-flag wire path), module nesting + the hook multiply-capture, the
|
|
* sibling-scan visibility trio, require/require_relative path refs incl. the
|
|
* Kernel.require and interpolated-path quirks, the ruby call branch
|
|
* (`.new` instantiates, constant-receiver references, `&.` joins, raw chain
|
|
* text), bare-call statements (do…end vs brace-block blindness), heredocs,
|
|
* `=begin` docstring marker survival, value-ref targets + shadow prune,
|
|
* `__END__` trailer) and its CRLF variant (derived in-memory — #1329).
|
|
*
|
|
* The full-repo sweep lives in scripts/kernel-parity.mjs (sinatra/jekyll/
|
|
* rails for the §5 gate); this suite keeps the invariant alive in `npm test`.
|
|
* Skips when no kernel binary is staged; CODEGRAPH_KERNEL_EXPECT=1 turns that
|
|
* into a failure (kernel-scaffold.test.ts).
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { extractFromSource } from '../src/extraction';
|
|
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
|
|
import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
|
|
import type { ExtractionResult } from '../src/types';
|
|
|
|
const KERNEL_PATH = path.join(
|
|
__dirname,
|
|
'..',
|
|
'codegraph-kernel',
|
|
'prebuilds',
|
|
`${process.platform}-${process.arch}`,
|
|
'codegraph-kernel.node'
|
|
);
|
|
const kernelBuilt = fs.existsSync(KERNEL_PATH);
|
|
|
|
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'kernel-parity');
|
|
|
|
function canon(result: ExtractionResult): { nodes: string[]; edges: string[]; refs: string[] } {
|
|
return {
|
|
nodes: result.nodes
|
|
.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort()))
|
|
.sort(),
|
|
edges: result.edges.map((e) => JSON.stringify(e, Object.keys(e).sort())).sort(),
|
|
refs: result.unresolvedReferences
|
|
.map((r) => JSON.stringify(r, Object.keys(r).sort()))
|
|
.sort(),
|
|
};
|
|
}
|
|
|
|
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
|
|
let savedEnv: Record<string, string | undefined>;
|
|
|
|
describe.skipIf(!kernelBuilt)('kernel Ruby extraction parity', () => {
|
|
beforeAll(async () => {
|
|
await initGrammars();
|
|
await loadGrammarsForLanguages(['ruby']);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
resetKernelForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const k of ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = savedEnv[k];
|
|
}
|
|
resetKernelForTests();
|
|
});
|
|
|
|
function assertParity(filePath: string, source: string, minNodes = 3): void {
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
const viaKernel = tryKernelExtract(filePath, source, 'ruby');
|
|
expect(viaKernel, `kernel extraction failed for ${filePath}`).not.toBeNull();
|
|
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
const viaWasm = extractFromSource(filePath, source, 'ruby');
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
|
|
const k = canon(viaKernel!);
|
|
const w = canon(viaWasm);
|
|
expect(k.nodes, `${filePath}: nodes`).toEqual(w.nodes);
|
|
expect(k.edges, `${filePath}: edges`).toEqual(w.edges);
|
|
expect(k.refs, `${filePath}: refs`).toEqual(w.refs);
|
|
expect(viaWasm.nodes.length).toBeGreaterThanOrEqual(minNodes);
|
|
}
|
|
|
|
it('torture fixture: modules/mixins, visibility scan, requires, call zoo, value refs', () => {
|
|
const file = path.join(FIXTURE_DIR, 'torture.rb');
|
|
assertParity('fixtures/torture.rb', fs.readFileSync(file, 'utf8'), 30);
|
|
});
|
|
|
|
// CRLF variant — the shape every Windows autocrlf checkout has. Derived in
|
|
// memory so no platform or editor can silently normalize it away; pins the
|
|
// JS-multiline-^ docstring semantics for `#` runs and `=begin` bodies
|
|
// (#1329), plus heredoc/`%`-literal CRLF parsing.
|
|
it('torture fixture CRLF parity', () => {
|
|
const file = path.join(FIXTURE_DIR, 'torture.rb');
|
|
const crlf = fs.readFileSync(file, 'utf8').replace(/(?<!\r)\n/g, '\r\n');
|
|
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',
|
|
]);
|
|
});
|
|
|
|
// A call on what `.new` made (`CacheWriter.new(key).write_cache`, #684) is
|
|
// recorded as `new CacheWriter().write_cache` on both backends: the class as
|
|
// written, without the arguments the receiver's text carried. A `new` on a
|
|
// value (`klass.new.run`, `self.class.new`) or a later link of a chain keeps
|
|
// the receiver's text, as before.
|
|
it('calls on a constructed receiver', () => {
|
|
const src = [
|
|
'class Callers',
|
|
' def a(key, klass)',
|
|
' CacheWriter.new.write_cache(key)',
|
|
' CacheWriter.new(key, ttl: 5).flush!',
|
|
' Billing::Ledger.new { |l| l }.post',
|
|
' ::CacheWriter.new.ready?',
|
|
' CacheWriter.new&.write_cache(key)',
|
|
' klass.new.run',
|
|
' self.class.new(key).run',
|
|
' Widget.create(key).save',
|
|
' CacheWriter.new.first.second',
|
|
' end',
|
|
'end',
|
|
'',
|
|
].join('\n');
|
|
assertParity('src/callers.rb', src);
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
const calls = extractFromSource('src/callers.rb', src, 'ruby').unresolvedReferences
|
|
.filter((r) => r.referenceKind === 'calls' && /[.(]/.test(r.referenceName))
|
|
.map((r) => `${r.referenceName} ${r.line}`);
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(calls.sort()).toEqual([
|
|
'CacheWriter.new.first.second 11',
|
|
'Widget.create 10',
|
|
'Widget.create(key).save 10',
|
|
'klass.new 8',
|
|
'klass.new.run 8',
|
|
'new ::CacheWriter().ready? 6',
|
|
'new Billing::Ledger().post 5',
|
|
'new CacheWriter().first 11',
|
|
'new CacheWriter().flush! 4',
|
|
'new CacheWriter().write_cache 3',
|
|
'new CacheWriter().write_cache 7',
|
|
'self.class.new 9',
|
|
'self.class.new(key).run 9',
|
|
]);
|
|
});
|
|
|
|
// A class method (`def self.x`, `def Widget.x`, a `def` in `class << self`)
|
|
// is static on both backends, every other def is not: they share a
|
|
// qualified name with any instance method of the name.
|
|
it('class methods are static', () => {
|
|
const src = [
|
|
'class Widget',
|
|
' def build = self',
|
|
' def self.build(opts) = new',
|
|
' def Widget.named = 1',
|
|
' class << self',
|
|
' def make = new',
|
|
' def other',
|
|
' def nested = 2',
|
|
' end',
|
|
' end',
|
|
' def run',
|
|
' def inner = 3',
|
|
' end',
|
|
'end',
|
|
'def self.top = 4',
|
|
'def plain = 5',
|
|
'',
|
|
].join('\n');
|
|
assertParity('src/widget.rb', src);
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
const viaKernel = tryKernelExtract('src/widget.rb', src, 'ruby');
|
|
const statics = viaKernel!.nodes
|
|
.filter((n) => n.kind === 'method' || n.kind === 'function')
|
|
.map((n) => `${n.kind} ${n.name}:${n.startLine} ${n.isStatic === true ? 'static' : n.isStatic === false ? 'instance' : 'unset'}`)
|
|
.sort();
|
|
expect(statics).toEqual([
|
|
'function inner:12 instance',
|
|
'function nested:8 instance',
|
|
'function plain:16 instance',
|
|
'function top:15 static',
|
|
'method build:2 instance',
|
|
'method build:3 static',
|
|
'method make:6 static',
|
|
'method named:4 static',
|
|
'method other:7 static',
|
|
'method run:11 instance',
|
|
]);
|
|
});
|
|
|
|
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';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
const viaKernel = tryKernelExtract('src/widget.rb', src, 'ruby');
|
|
expect(viaKernel).not.toBeNull();
|
|
const impl = viaKernel!.unresolvedReferences.find((r) => r.referenceKind === 'implements');
|
|
expect(impl?.referenceName).toBe('Comparable');
|
|
expect(impl?.filePath).toBe('src/widget.rb');
|
|
// Ordinary refs stay un-denormalized.
|
|
const other = viaKernel!.unresolvedReferences.find((r) => r.referenceKind !== 'implements');
|
|
if (other) expect(other.filePath).toBeUndefined();
|
|
});
|
|
|
|
it('files with parse errors defer to the wasm extractor (recovery is encoding-dependent)', () => {
|
|
const broken = 'def broken(\n x = [1,\nend\n';
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(tryKernelExtract('src/broken.rb', broken, 'ruby')).toBeNull();
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
const viaWasm = extractFromSource('src/broken.rb', broken, 'ruby');
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(viaWasm.nodes.some((n) => n.kind === 'file')).toBe(true);
|
|
});
|
|
});
|