Files
codegraph/__tests__/kernel-rustlang-parity.test.ts
T
Colby MchenryandClaude Opus 5.5 9c4794d694 fix(rust): a call keeps its whole receiver chain, and links through method calls in it (#2846)
A Rust method call made through more than one field, or on what another
method returns, was recorded by its bare method name in both extractors:
self.services.store.put("a") became `put`, as did self.store().put("m").
Only a single field kept its receiver (self.store.put). The edge's
refName then said nothing of which value the call goes through, and a
call on a method call's value was never typed, so self.store().put("m")
stayed unresolved. PR #2831 had already made the field-chain calls link
by reading the receiver from the source text, but their names stayed
bare.

Both extractors now keep a receiver that is a chain of fields and method
calls on `self` or on a name, a call written `name()` with its arguments
and turbofish dropped, up to 16 links: self.services.store.put,
self.store().put, vis.map.get, self.0.len, x.get().put. The shapes that
already kept a receiver (x.m, self.m, self.<field>.m, Foo::new().m) are
unchanged; a parenthesized receiver, `?`, `.await`, an index and a longer
chain rooted at a path keep the bare name. The kernel's receiver_chain
mirrors the wasm extractor's rustReceiverChain byte for byte.

Resolution reads such a call as the bare-named call was read before
(rustChainedCallByName, at the top of resolveOneIn), so the frameworks,
the strategies and the scope gates see what they saw, and the edge keeps
the call as written. The chain goes along: the written-receiver hook
reads the receiver the chain names where the call is written
(rustChainedReceiver), so a method call's value is typed from that
method's signature, and a later link that calls the same name again is
told apart instead of being left to a guess by the name.

Validation:
- New __tests__/rust-receiver-chains.test.ts (10 tests): the issue's
  workspace verbatim plus self.store().put("m"), chains whose links call
  the same name through self, a tuple field and a parameter, a call on a
  method's std return value, the recorded shapes and the 16-link cap,
  and a sync that adds the missing method. Red on main's sources, 7 of
  10 failing on both the wasm and the kernel path; green with the fix.
  The Rust kernel parity suite gains a chain case. The extraction test
  that pinned the old collapse now pins the chains.
- A/B against main 865ddf4a (same groups against d8338275), both arms
  with the native kernel, every changed call edge read. Per repo: edges
  that only gained their receiver in refName / same target now by type /
  wrong removed / right added / wrong to right:
  ripgrep 3fce3b5b: 159 / 45 / 6 / 6 / 15
  axum 86aa6153: 115 / 25 / 0 / 14 / 7
  tokio 8a2408ed: 1039 / 124 / 36 / 29 / 27, and 1 right link lost
  lemmy 92e077b5: 190 / 24 / 3 / 1 / 3, 1 right lost, 1 wrong added
  The two lost links and the wrong one follow rules the typed-receiver
  reading already applies to typed locals on main: a function-local glob
  `use` does not shadow a file-level named `use` (tokio), a call on a
  dependency's type reaches only impls of project traits, and `x.into()`
  takes the type's explicit `impl Into` (lemmy). Controls gin and
  express: byte-identical dumps. Nodes are identical on every repo.
- Kernel parity (Rust), ripgrep, axum, tokio, lemmy: 1752/1752 files at
  byte parity, 0 deferred.
- Index cost, interleaved median of 3 (wall): ripgrep 3.15 s -> 3.15 s,
  axum 3.39 s -> 3.42 s, tokio 10.48 s -> 10.54 s, lemmy 5.14 s ->
  5.15 s; CPU within noise.

Fixes #2836

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:07:45 +00:00

217 lines
8.7 KiB
TypeScript

/**
* Kernel↔wasm Rust extraction parity (R7b of the kernel migration).
*
* Asserts the native walker (codegraph-kernel/src/rustlang.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.rs: impl/trait quirks incl. generic / lifetime /
* reference / scoped / generic-trait impl receivers (#1588), unit structs
* (a bodiless struct IS a definition — both walkers mint a node), phantom
* const identifiers, use-binding refs incl. nested groups + wildcard-emits-
* nothing, chained-call re-encode, turbofish, Rocket route macros body-only,
* fn-ref shapes, value-ref shadowing, attribute-broken docstrings, dead-code
* isAsync) and its CRLF variant (derived in-memory — #1329 docstring
* semantics).
*
* The full-repo sweep lives in scripts/kernel-parity.mjs (ripgrep/tokio/
* rust-analyzer 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 Rust extraction parity', () => {
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['rust']);
});
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, 'rust');
expect(viaKernel, `kernel extraction failed for ${filePath}`).not.toBeNull();
process.env.CODEGRAPH_KERNEL = '0';
const viaWasm = extractFromSource(filePath, source, 'rust');
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('preserves Tauri command attributes without treating comments or strings as registrations', () => {
const source = `
#[tauri::command]
fn port() -> u16 { 4000 }
#[tauri :: command(rename_all = "snake_case")]
/* between attributes */
#[allow(dead_code)]
pub async fn settings() {}
// #[tauri::command]
fn ordinary() {}
const TEXT: &str = r#"#[tauri::command]"#;
fn after_string() {}
#[other::command]
fn unrelated() {}
#[tauri::command]
fn outer() { fn inner() {} }
fn next() {}
`;
assertParity('commands.rs', source);
assertParity('commands.rs', source.replace(/\n/g, '\r\n'));
});
it('variant paths reference their receiver (#2328)', () => {
const source = `
use crate::mode::{self, Mode};
const DEFAULT: Mode = Mode::A;
impl Mode {
fn flip(&self) -> u8 {
let _s = "ü"; let _x = Self::B;
match self { Self::A => 1, Mode::C(x) => *x, mode::Mode::D { x } => *x, _ => 0 }
}
}
trait Tr { fn t(&self) { let _ = Self::K; } }
fn uses() {
use crate::mode::Mode::A;
let _a = crate::mode::Mode::A; let _b = Mode::C(1); let _c = Mode::D { x: 2 };
let _d = Mode::new(); let _e = util::take(3); let _f = u8::MAX; let _g = Limits::MAX;
let _h = xs.map(Mode::C); let _i = Option::<u8>::None; let _j = Mode::A.flip();
if let Mode::C(x) | Mode::B = m {}
match o { Option::Some(_) => std::cmp::Ordering::Less, _ => cmp::Ordering::Greater };
}
`;
assertParity('variants.rs', source);
assertParity('variants.rs', source.replace(/\n/g, '\r\n'));
// Not vacuous: each path to a capitalized member outside a call's callee,
// a struct literal and a `use` is a `references` ref to its receiver
// (`Self` read as the impl's type; none in a trait). The resolver decides
// which of them name a project enum's variant.
process.env.CODEGRAPH_KERNEL = '0';
const refs = extractFromSource('variants.rs', source, 'rust').unresolvedReferences;
delete process.env.CODEGRAPH_KERNEL;
const lines = source.split('\n');
const paths = refs
.filter((r) => r.referenceKind === 'references' && /^\w+\s*::/.test(lines[r.line - 1]!.slice(r.column)))
.map((r) => `${r.referenceName}:${r.line}`);
expect(paths.sort()).toEqual([
'Limits:14', 'Mode:13', 'Mode:15', 'Mode:15', 'Mode:16', 'Mode:16', 'Mode:3', 'Mode:6',
'Mode:7', 'Mode:7', 'Mode:7', 'Option:17', 'Ordering:17', 'Ordering:17',
]);
});
it('a call keeps its receiver chain of fields and method calls (#2836)', () => {
const links = Array.from({ length: 17 }, (_, i) => `l${i}()`).join('.');
const source = `
pub struct Svc { services: Services, store: Box<dyn Store>, pair: (u8, Config) }
impl Svc {
fn chains(&self, vis: &Vis) {
self.services.store.put("a"); let _ = self.services.store.put("ü");
self
.services // rustfmt
.store
.put("c");
self.store().put("m"); self.store.put("d"); self.put("e"); vis.put(1);
vis.map.get("k").get(0); self.0.len(); self.pair.1.port();
vis.get::<u8>(1).put::<u16>(2); vis.a.b().c.d();
Foo::new().a().b(); (vis).a.b(); self.a?.b(); self.a.b().await.c(); vis[0].a.b();
r#vis.a.b(); vis.${links}.m();
}
}
`;
assertParity('chains.rs', source);
assertParity('chains.rs', source.replace(/\n/g, '\r\n'));
// Not vacuous: the chains are kept, the other receivers keep their old names.
process.env.CODEGRAPH_KERNEL = '0';
const names = new Set(extractFromSource('chains.rs', source, 'rust').unresolvedReferences
.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName));
delete process.env.CODEGRAPH_KERNEL;
for (const name of [
'self.services.store.put', 'self.store().put', 'self.store.put', 'self.put', 'vis.put', 'vis.map.get',
'vis.map.get().get', 'self.0.len', 'self.pair.1.port', 'vis.get', 'vis.get().put', 'vis.a.b().c.d',
'Foo::new().a', 'b', 'c', 'm',
]) {
expect(names, name).toContain(name);
}
});
it('torture fixture: impl/trait quirks, use bindings, chains, fn-refs, value-refs, route macros', () => {
const file = path.join(FIXTURE_DIR, 'torture.rs');
assertParity('fixtures/torture.rs', fs.readFileSync(file, 'utf8'), 20);
});
// 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 (#1329).
it('torture fixture CRLF parity', () => {
const file = path.join(FIXTURE_DIR, 'torture.rs');
const crlf = fs.readFileSync(file, 'utf8').replace(/(?<!\r)\n/g, '\r\n');
assertParity('fixtures/torture.rs (crlf)', crlf, 20);
});
it('files with parse errors defer to the wasm extractor (recovery is encoding-dependent)', () => {
const broken = 'fn f( {\n return }} 12 (\n';
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
delete process.env.CODEGRAPH_KERNEL;
expect(tryKernelExtract('src/broken.rs', broken, 'rust')).toBeNull();
process.env.CODEGRAPH_KERNEL = '0';
const viaWasm = extractFromSource('src/broken.rs', broken, 'rust');
delete process.env.CODEGRAPH_KERNEL;
expect(viaWasm.nodes.some((n) => n.kind === 'file')).toBe(true);
});
});