Files
codegraph/scripts/kernel-parity.mjs
T
Colby McHenryandClaude Fable 5 c8cca9a601 feat(kernel): R3 — TS/JS equivalence gate passed, kernel default-on
Gate evidence (docs/design/rust-kernel-migration-plan.md §4b):

- Graph parity, byte-identical (stronger than the §5 ≤0.5% bar): full
  codegraph-init dump-diffs kernel-vs-wasm on express (13,712 rows),
  excalidraw (89,898), and vscode (2,378,238 rows) — identical bytes.
  Python control repo (flask) identical + timing unchanged. The parity
  harness is now ORDER-sensitive (emission order drives rowids, which
  drive resolution order) and dumps come from the new
  scripts/dump-graph.mjs (natural keys, no rowids/timestamps).

- The one real find, caught by the vscode tier: tree-sitter error
  RECOVERY is encoding-dependent — byte-identical grammar sources and
  the same core (0.25.10) recover erroring files differently under
  UTF-8 (native) vs UTF-16 (web-tree-sitter) parsing; proven by
  reproducing the wasm tree with a native UTF-16 parse. Policy: the
  kernel defers any file whose tree has_error() to the wasm extractor
  (silent 'defer:' signal, per file) — parity by construction on
  erroring files (incidence 0-0.42% across the gate repos), and the
  harness fails if deferrals exceed 10% so a broken kernel can't hide
  behind the fallback.

- Retrieval invariants: canonical excalidraw flow (mutateElement →
  renderStaticScene) connects end-to-end on the kernel-indexed graph;
  synthesized-edge families present. Agent A/B is vacuous under
  byte-identical DBs (same justification as #1320-#1322).

- Perf: vscode init 105.4s → 82.1s (1.28×) on an 11-core Mac;
  excalidraw on a 2-CPU/6GB Linux container (the CI-runner envelope)
  6.2-7.1s → 4.3-4.8s (~1.5×). Linux arm64 in-container build: all 22
  kernel tests green under CODEGRAPH_KERNEL_EXPECT=1. Windows VM leg
  deferred (VM stopped; prlctl start needs Parallels Pro) — benign: a
  missing .node falls back to wasm, and the release matrix builds and
  gates the win32 prebuilds.

- Full suite: 2,465 tests pass WITH default-on routing, so the entire
  extraction corpus now exercises the kernel for TS/JS wherever a
  .node is staged.

DEFAULT_ROUTED = {typescript, tsx, javascript, jsx}. Override:
CODEGRAPH_KERNEL_LANGS (replaces the set) / CODEGRAPH_KERNEL=0 (kill).
Changelog entry added under [Unreleased].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:50:02 -05:00

238 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Kernel↔wasm extraction parity harness (R2/R3 of the kernel migration).
*
* Runs BOTH extraction paths over the given files/directories and diffs the
* per-file ExtractionResults as sets (nodes/edges/refs, canonicalized), so a
* behavioral gap in the native kernel shows up as a categorized diff instead
* of a graph-dump surprise later. This is the fast inner loop; the §5 gate's
* full-repo dump-diff still runs before any default-on.
*
* Usage:
* node scripts/kernel-parity.mjs <file-or-dir>... [--lang typescript,tsx]
* [--max-samples N] [--list-files]
*
* Requires: npm run build (dist/) and a staged kernel (npm run build:kernel).
* Exit code: 0 = parity, 1 = diffs found, 2 = setup error.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dist = (p) => path.join(ROOT, 'dist', p);
const args = process.argv.slice(2);
const paths = [];
let langFilter = null;
let maxSamples = 5;
let listFiles = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--lang') langFilter = new Set(args[++i].split(','));
else if (args[i] === '--max-samples') maxSamples = Number(args[++i]);
else if (args[i] === '--list-files') listFiles = true;
else paths.push(args[i]);
}
if (paths.length === 0) {
console.error('usage: kernel-parity.mjs <file-or-dir>... [--lang ts,tsx] [--max-samples N]');
process.exit(2);
}
const KERNEL_LANGS = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
const EXTS = new Map([
['.ts', 'typescript'], ['.mts', 'typescript'], ['.cts', 'typescript'],
['.tsx', 'tsx'], ['.js', 'javascript'], ['.mjs', 'javascript'],
['.cjs', 'javascript'], ['.jsx', 'jsx'],
]);
/** Collect candidate files. */
function collect(p, out) {
const st = fs.statSync(p);
if (st.isDirectory()) {
const base = path.basename(p);
if (base === 'node_modules' || base === '.git' || base === 'dist' || base === '.codegraph') return;
for (const e of fs.readdirSync(p)) collect(path.join(p, e), out);
} else if (EXTS.has(path.extname(p))) {
const lang = EXTS.get(path.extname(p));
if (!langFilter || langFilter.has(lang)) out.push({ file: p, lang });
}
}
const files = [];
for (const p of paths) collect(path.resolve(p), files);
if (files.length === 0) {
console.error('no matching files');
process.exit(2);
}
// --- load the built engine ---------------------------------------------------
const { extractFromSource } = await import(dist('extraction/tree-sitter.js'));
const { initGrammars, loadGrammarsForLanguages } = await import(dist('extraction/grammars.js'));
const kernel = await import(dist('extraction/kernel/index.js'));
await initGrammars();
await loadGrammarsForLanguages([...KERNEL_LANGS]);
if (!kernel.getKernel()) {
console.error('kernel .node not found — run: npm run build:kernel');
process.exit(2);
}
// --- canonicalization ---------------------------------------------------------
/**
* Node identity for cross-referencing edges/refs: the node id itself (both
* paths compute the same deterministic ids, and id embeds kind+name+line).
*/
function canonNode(n) {
const out = {
id: n.id, kind: n.kind, name: n.name, qualifiedName: n.qualifiedName,
filePath: n.filePath, language: n.language,
startLine: n.startLine, endLine: n.endLine,
startColumn: n.startColumn, endColumn: n.endColumn,
};
for (const k of ['docstring', 'signature', 'visibility', 'isExported', 'isAsync', 'isStatic', 'isAbstract', 'returnType']) {
if (n[k] !== undefined) out[k] = n[k];
}
if (n.decorators !== undefined) out.decorators = n.decorators;
if (n.typeParameters !== undefined) out.typeParameters = n.typeParameters;
return JSON.stringify(out);
}
function canonEdge(e) {
const out = { source: e.source, target: e.target, kind: e.kind };
if (e.line !== undefined) out.line = e.line;
if (e.column !== undefined) out.column = e.column;
if (e.provenance !== undefined) out.provenance = e.provenance;
if (e.metadata !== undefined) out.metadata = e.metadata;
return JSON.stringify(out);
}
function canonRef(r) {
// FULL object — a field only one path sets is a parity bug (the vitest
// parity suite caught decode.ts pre-filling filePath/language this way).
const out = {
from: r.fromNodeId, name: r.referenceName, kind: r.referenceKind,
line: r.line, column: r.column,
};
for (const k of ['filePath', 'language', 'candidates', 'rowId']) {
if (r[k] !== undefined) out[k] = r[k];
}
return JSON.stringify(out);
}
function diffSets(aList, bList) {
const a = new Map(); // canon -> count (multiset — duplicates matter)
const b = new Map();
for (const x of aList) a.set(x, (a.get(x) ?? 0) + 1);
for (const x of bList) b.set(x, (b.get(x) ?? 0) + 1);
const onlyA = [];
const onlyB = [];
for (const [k, c] of a) {
const d = c - (b.get(k) ?? 0);
for (let i = 0; i < d; i++) onlyA.push(k);
}
for (const [k, c] of b) {
const d = c - (a.get(k) ?? 0);
for (let i = 0; i < d; i++) onlyB.push(k);
}
return { onlyA, onlyB };
}
// --- run ----------------------------------------------------------------------
const buckets = new Map(); // category -> {count, samples[]}
function report(category, sample) {
let b = buckets.get(category);
if (!b) buckets.set(category, (b = { count: 0, samples: [] }));
b.count++;
if (b.samples.length < maxSamples) b.samples.push(sample);
}
let filesWithDiffs = 0;
let filesOk = 0;
let deferred = 0;
let totals = { nodes: 0, edges: 0, refs: 0 };
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
for (const { file, lang } of files) {
const source = fs.readFileSync(file, 'utf8');
const rel = path.relative(ROOT, file);
delete process.env.CODEGRAPH_KERNEL; // kernel path on
const kres = kernel.tryKernelExtract(rel, source, lang);
if (!kres) {
// Expected: files with parse errors defer to wasm (parity by
// construction — both arms run the same extractor). Counted, and
// guarded below so a broken kernel can't silently defer everything.
deferred++;
report('kernel-deferred', rel);
continue;
}
process.env.CODEGRAPH_KERNEL = '0'; // wasm path
const wres = extractFromSource(rel, source, lang);
delete process.env.CODEGRAPH_KERNEL;
totals.nodes += wres.nodes.length;
totals.edges += wres.edges.length;
totals.refs += wres.unresolvedReferences.length;
let fileHasDiff = false;
const tables = [
['node', wres.nodes.map(canonNode), kres.nodes.map(canonNode)],
['edge', wres.edges.map(canonEdge), kres.edges.map(canonEdge)],
['ref', wres.unresolvedReferences.map(canonRef), kres.unresolvedReferences.map(canonRef)],
];
for (const [table, wasm, kern] of tables) {
const { onlyA, onlyB } = diffSets(wasm, kern);
for (const x of onlyA) {
fileHasDiff = true;
const o = JSON.parse(x);
report(`${table}:missing-in-kernel:${o.kind ?? ''}`, `${rel}: ${x}`);
}
for (const x of onlyB) {
fileHasDiff = true;
const o = JSON.parse(x);
report(`${table}:extra-in-kernel:${o.kind ?? ''}`, `${rel}: ${x}`);
}
// ORDER matters too: identical multisets in a different emission order
// change DB rowids, and resolution iterates refs in rowid order — the
// full-index dump-diff would surface it as a downstream mystery. Catch it
// here instead.
if (onlyA.length === 0 && onlyB.length === 0) {
for (let i = 0; i < wasm.length; i++) {
if (wasm[i] !== kern[i]) {
fileHasDiff = true;
report(`${table}:order-mismatch`, `${rel}: index ${i}: wasm=${wasm[i]} kernel=${kern[i]}`);
break;
}
}
}
}
if (fileHasDiff) {
filesWithDiffs++;
if (listFiles) console.log(`DIFF ${rel}`);
} else {
filesOk++;
}
}
console.log(`\n=== kernel parity: ${filesOk}/${files.length} files byte-parity` +
` (${filesWithDiffs} with diffs, ${deferred} deferred-to-wasm)` +
` | wasm totals: ${totals.nodes} nodes / ${totals.edges} edges / ${totals.refs} refs ===\n`);
const sorted = [...buckets.entries()].sort((a, b) => b[1].count - a[1].count);
for (const [cat, { count, samples }] of sorted) {
console.log(`--- ${cat}: ${count}`);
for (const s of samples) console.log(` ${s.length > 400 ? s.slice(0, 400) + '…' : s}`);
}
// Deferrals are per-file parse-error routing (expected, rare). A high rate
// means the kernel is broken and hiding behind the fallback — fail loudly.
const deferralRate = deferred / files.length;
if (deferralRate > 0.1) {
console.error(`deferral rate ${(deferralRate * 100).toFixed(1)}% exceeds 10% — kernel likely broken`);
process.exit(1);
}
process.exit(filesWithDiffs > 0 ? 1 : 0);