Files
colbymchenry--codegraph/scripts/kernel-parity.mjs
Colby Mchenry d1b75a1a27 feat(kernel): R7b Dart walker — dart module, vendored-grammar-C d4d8f3e + wasm byte-copy vendor, dart default-routed (#1386)
R7b batch 4 #4 — the FINAL R7b language (docs/design/dart-kernel-port-checklist.md
is the authoritative quirk list). The fourth vendored-grammar-C language,
with a twist: production dart resolved its wasm from tree-sitter-wasms,
whose dart dependency is an UNPINNED github:UserNobody14/tree-sitter-dart —
a routine dependency update would have silently changed dart's grammar.
This PR byte-copies the shipping 0.1.13 artifact into src/extraction/wasm/
(VENDORED_WASM_LANGS += dart) and compiles the same-commit (d4d8f3e337d8)
parser.c/scanner.c in the kernel — table identity proven by the
kernel-grammar-parity row. crates.io tree-sitter-dart is the nielsenko
fork (different lineage) — rejected.

The center of gravity is THE SIBLING-BODY DOUBLE-WALK, reproduced
bug-for-bug: dart attaches every function/method body as a NEXT SIBLING of
its signature, and the TS walkers consume each body TWICE — once via
resolveBody (attributed to the function/method) and once via the enclosing
generic walk (attributed to the file/class). Duplicate local-function
nodes with the SAME id under different parents, duplicated
calls/instantiates refs, and file/class-attributed fn-ref twins all emit
in the exact observed interleave (a dedicated fixture pins the
duplicate-id rows; the bloc kind-census spot-check pins the counts).

Also preserved (probe-pinned): the extractBareCall selector matrix (the
first callTypes=[] language — cascades completely invisible, `?.` encodes
like `.`, the `ConfigT.load()` calls+references double emission with no
callee-of-call skip, capitalized-chain `Foo.create().run` re-encode,
const-object callee names); the constructor hooks (unnamed ctor skipped,
named ctors/factories renamed to the CTOR name with the class as
returnType, `@override (T) m()` record-misparse rescued by class-name
validation); operator methods minting `method "<anonymous>"`;
static_final_declaration constants via the visitNode hook while instance
fields mint NOTHING; the prefixed-return-type prefix bug (`other.OtherClass
f()` → returnType `other`); enum `with` mixins silent vs `implements`
working; anonymous extensions named after the ON type; deferred imports
invisible; named-argument callbacks NOT fn-ref-captured (the Flutter
`onPressed:` idiom — future accuracy PR, TS-side first); `async*`/`sync*`
NOT async; value-refs with the LIVE dart sibling-body pull and the
`$X`-vs-`${X}` interpolation asymmetry; dartdoc kept in all three comment
forms with the annotation-broken chain.

Gates: parity sweeps first-run 0-diff on shelf/bloc/flutter — 5,815 clean
files byte-parity, deferrals 10/21/1341 ≈ the survey's 10/21/~1340
(both-arm grammar reality: empty object patterns — the sealed-class
idiom — and unnamed `library;` dominate; --max-deferral 0.3); full-init
dumps byte-identical ×3 (shelf 7,959 / bloc 40,026 / flutter 1,855,319
dump lines); bloc per-kind node census identical across arms (the
double-walk duplicate rows survive the store identically);
kernel-dart-parity suite (7 fixtures + in-memory CRLF variants +
double-walk duplicate-id pin + generated-file skip pin + two defer pins);
full suite 2,688 green ×2 with CODEGRAPH_KERNEL_EXPECT=1.
DEFAULT_ROUTED += dart (20 langs — R7b COMPLETE).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:55:48 -05:00

284 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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] [--max-deferral 0.1]
*
* --max-deferral: the broken-kernel backstop (default 0.1). For C/C++ pass
* 0.5: macro-heavy C/C++ trees genuinely parse with errors at 1040% file
* rates even after the preParse blanking family (git 19%, protobuf 26%, fmt
* 42% — measured 2026-07-17), and every erroring file defers BY POLICY, so
* the 10% bar calibrated on the 00.4% incidence of ts/java/py/go would fail
* healthy sweeps. A broken walker still trips 0.5 (it defers ~everything).
*
* 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;
let maxDeferral = 0.1;
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 if (args[i] === '--max-deferral') maxDeferral = Number(args[++i]);
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', 'java', 'python', 'go', 'c', 'cpp', 'rust', 'csharp', 'ruby', 'php', 'swift', 'kotlin', 'r', 'lua', 'luau', 'scala', 'dart']);
const EXTS = new Map([
['.ts', 'typescript'], ['.mts', 'typescript'], ['.cts', 'typescript'],
['.tsx', 'tsx'], ['.js', 'javascript'], ['.mjs', 'javascript'],
['.cjs', 'javascript'], ['.jsx', 'jsx'], ['.java', 'java'], ['.py', 'python'], ['.pyw', 'python'], ['.go', 'go'],
// C/C++ (R7a). `.h` needs CONTENT sniffing (C vs C++) — resolved per file
// in the run loop via detectLanguage, matching the real indexer's routing.
['.c', 'c'], ['.h', 'detect'],
['.cpp', 'cpp'], ['.cc', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hxx', 'cpp'],
['.metal', 'cpp'], ['.cu', 'cpp'], ['.cuh', 'cpp'],
['.rs', 'rust'], // R7b
['.cs', 'csharp'], // R7b
['.rb', 'ruby'], ['.rake', 'ruby'], // R7b
['.php', 'php'], ['.module', 'php'], ['.install', 'php'], ['.theme', 'php'], ['.inc', 'php'], // R7b
['.swift', 'swift'], // R7b
['.kt', 'kotlin'], ['.kts', 'kotlin'], // R7b
['.r', 'r'], // R7b batch 4 — real-world casing is `.R`; extname lowercased below
['.lua', 'lua'], ['.luau', 'luau'], // R7b batch 4
['.scala', 'scala'], ['.sc', 'scala'], // R7b batch 4
['.dart', 'dart'], // R7b batch 4
]);
/** Collect candidate files. */
function collect(p, out) {
let st;
try {
st = fs.statSync(p); // dangling symlinks (Linux-tree dtc fixtures) throw
} catch {
return;
}
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).toLowerCase())) {
// Lowercased to match the real indexer's routing (detectLanguage
// lowercases the extension — `.R` is the dominant real-world casing).
const lang = EXTS.get(path.extname(p).toLowerCase());
// 'detect' (.h) resolves per file in the run loop; under --lang it rides
// along whenever either C-family language is requested.
const passes =
!langFilter ||
(lang === 'detect' ? langFilter.has('c') || langFilter.has('cpp') : langFilter.has(lang));
if (passes) 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, detectLanguage } = 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 processed = 0; // collected files minus content-detect skips
let totals = { nodes: 0, edges: 0, refs: 0 };
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
for (const { file, lang: extLang } of files) {
const source = fs.readFileSync(file, 'utf8');
const rel = path.relative(ROOT, file);
// `.h` resolves C vs C++ by content — the same call the indexer makes.
const lang = extLang === 'detect' ? detectLanguage(rel, source) : extLang;
if (!KERNEL_LANGS.has(lang)) continue;
if (langFilter && !langFilter.has(lang)) continue;
processed++;
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}/${processed} 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 for most
// languages, routine for macro-heavy C/C++ — see --max-deferral above). A
// rate past the threshold means the kernel is broken and hiding behind the
// fallback — fail loudly.
const deferralRate = deferred / Math.max(processed, 1);
if (deferralRate > maxDeferral) {
console.error(
`deferral rate ${(deferralRate * 100).toFixed(1)}% exceeds ${(maxDeferral * 100).toFixed(0)}% — kernel likely broken`
);
process.exit(1);
}
process.exit(filesWithDiffs > 0 ? 1 : 0);