kernel-parity.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. #!/usr/bin/env node
  2. /**
  3. * Kernel↔wasm extraction parity harness (R2/R3 of the kernel migration).
  4. *
  5. * Runs BOTH extraction paths over the given files/directories and diffs the
  6. * per-file ExtractionResults as sets (nodes/edges/refs, canonicalized), so a
  7. * behavioral gap in the native kernel shows up as a categorized diff instead
  8. * of a graph-dump surprise later. This is the fast inner loop; the §5 gate's
  9. * full-repo dump-diff still runs before any default-on.
  10. *
  11. * Usage:
  12. * node scripts/kernel-parity.mjs <file-or-dir>... [--lang typescript,tsx]
  13. * [--max-samples N] [--list-files] [--max-deferral 0.1]
  14. *
  15. * --max-deferral: the broken-kernel backstop (default 0.1). For C/C++ pass
  16. * 0.5: macro-heavy C/C++ trees genuinely parse with errors at 10–40% file
  17. * rates even after the preParse blanking family (git 19%, protobuf 26%, fmt
  18. * 42% — measured 2026-07-17), and every erroring file defers BY POLICY, so
  19. * the 10% bar calibrated on the 0–0.4% incidence of ts/java/py/go would fail
  20. * healthy sweeps. A broken walker still trips 0.5 (it defers ~everything).
  21. *
  22. * Requires: npm run build (dist/) and a staged kernel (npm run build:kernel).
  23. * Exit code: 0 = parity, 1 = diffs found, 2 = setup error.
  24. */
  25. import * as fs from 'node:fs';
  26. import * as path from 'node:path';
  27. import { fileURLToPath } from 'node:url';
  28. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  29. const dist = (p) => path.join(ROOT, 'dist', p);
  30. const args = process.argv.slice(2);
  31. const paths = [];
  32. let langFilter = null;
  33. let maxSamples = 5;
  34. let listFiles = false;
  35. let maxDeferral = 0.1;
  36. for (let i = 0; i < args.length; i++) {
  37. if (args[i] === '--lang') langFilter = new Set(args[++i].split(','));
  38. else if (args[i] === '--max-samples') maxSamples = Number(args[++i]);
  39. else if (args[i] === '--list-files') listFiles = true;
  40. else if (args[i] === '--max-deferral') maxDeferral = Number(args[++i]);
  41. else paths.push(args[i]);
  42. }
  43. if (paths.length === 0) {
  44. console.error('usage: kernel-parity.mjs <file-or-dir>... [--lang ts,tsx] [--max-samples N]');
  45. process.exit(2);
  46. }
  47. 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']);
  48. const EXTS = new Map([
  49. ['.ts', 'typescript'], ['.mts', 'typescript'], ['.cts', 'typescript'],
  50. ['.tsx', 'tsx'], ['.js', 'javascript'], ['.mjs', 'javascript'],
  51. ['.cjs', 'javascript'], ['.jsx', 'jsx'], ['.java', 'java'], ['.py', 'python'], ['.pyw', 'python'], ['.go', 'go'],
  52. // C/C++ (R7a). `.h` needs CONTENT sniffing (C vs C++) — resolved per file
  53. // in the run loop via detectLanguage, matching the real indexer's routing.
  54. ['.c', 'c'], ['.h', 'detect'],
  55. ['.cpp', 'cpp'], ['.cc', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hxx', 'cpp'],
  56. ['.metal', 'cpp'], ['.cu', 'cpp'], ['.cuh', 'cpp'],
  57. ['.rs', 'rust'], // R7b
  58. ['.cs', 'csharp'], // R7b
  59. ['.rb', 'ruby'], ['.rake', 'ruby'], // R7b
  60. ['.php', 'php'], ['.module', 'php'], ['.install', 'php'], ['.theme', 'php'], ['.inc', 'php'], // R7b
  61. ['.swift', 'swift'], // R7b
  62. ['.kt', 'kotlin'], ['.kts', 'kotlin'], // R7b
  63. ['.r', 'r'], // R7b batch 4 — real-world casing is `.R`; extname lowercased below
  64. ['.lua', 'lua'], ['.luau', 'luau'], // R7b batch 4
  65. ['.scala', 'scala'], ['.sc', 'scala'], // R7b batch 4
  66. ['.dart', 'dart'], // R7b batch 4
  67. ]);
  68. /** Collect candidate files. */
  69. function collect(p, out) {
  70. let st;
  71. try {
  72. st = fs.statSync(p); // dangling symlinks (Linux-tree dtc fixtures) throw
  73. } catch {
  74. return;
  75. }
  76. if (st.isDirectory()) {
  77. const base = path.basename(p);
  78. if (base === 'node_modules' || base === '.git' || base === 'dist' || base === '.codegraph') return;
  79. for (const e of fs.readdirSync(p)) collect(path.join(p, e), out);
  80. } else if (EXTS.has(path.extname(p).toLowerCase())) {
  81. // Lowercased to match the real indexer's routing (detectLanguage
  82. // lowercases the extension — `.R` is the dominant real-world casing).
  83. const lang = EXTS.get(path.extname(p).toLowerCase());
  84. // 'detect' (.h) resolves per file in the run loop; under --lang it rides
  85. // along whenever either C-family language is requested.
  86. const passes =
  87. !langFilter ||
  88. (lang === 'detect' ? langFilter.has('c') || langFilter.has('cpp') : langFilter.has(lang));
  89. if (passes) out.push({ file: p, lang });
  90. }
  91. }
  92. const files = [];
  93. for (const p of paths) collect(path.resolve(p), files);
  94. if (files.length === 0) {
  95. console.error('no matching files');
  96. process.exit(2);
  97. }
  98. // --- load the built engine ---------------------------------------------------
  99. const { extractFromSource } = await import(dist('extraction/tree-sitter.js'));
  100. const { initGrammars, loadGrammarsForLanguages, detectLanguage } = await import(dist('extraction/grammars.js'));
  101. const kernel = await import(dist('extraction/kernel/index.js'));
  102. await initGrammars();
  103. await loadGrammarsForLanguages([...KERNEL_LANGS]);
  104. if (!kernel.getKernel()) {
  105. console.error('kernel .node not found — run: npm run build:kernel');
  106. process.exit(2);
  107. }
  108. // --- canonicalization ---------------------------------------------------------
  109. /**
  110. * Node identity for cross-referencing edges/refs: the node id itself (both
  111. * paths compute the same deterministic ids, and id embeds kind+name+line).
  112. */
  113. function canonNode(n) {
  114. const out = {
  115. id: n.id, kind: n.kind, name: n.name, qualifiedName: n.qualifiedName,
  116. filePath: n.filePath, language: n.language,
  117. startLine: n.startLine, endLine: n.endLine,
  118. startColumn: n.startColumn, endColumn: n.endColumn,
  119. };
  120. for (const k of ['docstring', 'signature', 'visibility', 'isExported', 'isAsync', 'isStatic', 'isAbstract', 'returnType']) {
  121. if (n[k] !== undefined) out[k] = n[k];
  122. }
  123. if (n.decorators !== undefined) out.decorators = n.decorators;
  124. if (n.typeParameters !== undefined) out.typeParameters = n.typeParameters;
  125. return JSON.stringify(out);
  126. }
  127. function canonEdge(e) {
  128. const out = { source: e.source, target: e.target, kind: e.kind };
  129. if (e.line !== undefined) out.line = e.line;
  130. if (e.column !== undefined) out.column = e.column;
  131. if (e.provenance !== undefined) out.provenance = e.provenance;
  132. if (e.metadata !== undefined) out.metadata = e.metadata;
  133. return JSON.stringify(out);
  134. }
  135. function canonRef(r) {
  136. // FULL object — a field only one path sets is a parity bug (the vitest
  137. // parity suite caught decode.ts pre-filling filePath/language this way).
  138. const out = {
  139. from: r.fromNodeId, name: r.referenceName, kind: r.referenceKind,
  140. line: r.line, column: r.column,
  141. };
  142. for (const k of ['filePath', 'language', 'candidates', 'rowId']) {
  143. if (r[k] !== undefined) out[k] = r[k];
  144. }
  145. return JSON.stringify(out);
  146. }
  147. function diffSets(aList, bList) {
  148. const a = new Map(); // canon -> count (multiset — duplicates matter)
  149. const b = new Map();
  150. for (const x of aList) a.set(x, (a.get(x) ?? 0) + 1);
  151. for (const x of bList) b.set(x, (b.get(x) ?? 0) + 1);
  152. const onlyA = [];
  153. const onlyB = [];
  154. for (const [k, c] of a) {
  155. const d = c - (b.get(k) ?? 0);
  156. for (let i = 0; i < d; i++) onlyA.push(k);
  157. }
  158. for (const [k, c] of b) {
  159. const d = c - (a.get(k) ?? 0);
  160. for (let i = 0; i < d; i++) onlyB.push(k);
  161. }
  162. return { onlyA, onlyB };
  163. }
  164. // --- run ----------------------------------------------------------------------
  165. const buckets = new Map(); // category -> {count, samples[]}
  166. function report(category, sample) {
  167. let b = buckets.get(category);
  168. if (!b) buckets.set(category, (b = { count: 0, samples: [] }));
  169. b.count++;
  170. if (b.samples.length < maxSamples) b.samples.push(sample);
  171. }
  172. let filesWithDiffs = 0;
  173. let filesOk = 0;
  174. let deferred = 0;
  175. let processed = 0; // collected files minus content-detect skips
  176. let totals = { nodes: 0, edges: 0, refs: 0 };
  177. process.env.CODEGRAPH_KERNEL_LANGS = 'all';
  178. for (const { file, lang: extLang } of files) {
  179. const source = fs.readFileSync(file, 'utf8');
  180. const rel = path.relative(ROOT, file);
  181. // `.h` resolves C vs C++ by content — the same call the indexer makes.
  182. const lang = extLang === 'detect' ? detectLanguage(rel, source) : extLang;
  183. if (!KERNEL_LANGS.has(lang)) continue;
  184. if (langFilter && !langFilter.has(lang)) continue;
  185. processed++;
  186. delete process.env.CODEGRAPH_KERNEL; // kernel path on
  187. const kres = kernel.tryKernelExtract(rel, source, lang);
  188. if (!kres) {
  189. // Expected: files with parse errors defer to wasm (parity by
  190. // construction — both arms run the same extractor). Counted, and
  191. // guarded below so a broken kernel can't silently defer everything.
  192. deferred++;
  193. report('kernel-deferred', rel);
  194. continue;
  195. }
  196. process.env.CODEGRAPH_KERNEL = '0'; // wasm path
  197. const wres = extractFromSource(rel, source, lang);
  198. delete process.env.CODEGRAPH_KERNEL;
  199. totals.nodes += wres.nodes.length;
  200. totals.edges += wres.edges.length;
  201. totals.refs += wres.unresolvedReferences.length;
  202. let fileHasDiff = false;
  203. const tables = [
  204. ['node', wres.nodes.map(canonNode), kres.nodes.map(canonNode)],
  205. ['edge', wres.edges.map(canonEdge), kres.edges.map(canonEdge)],
  206. ['ref', wres.unresolvedReferences.map(canonRef), kres.unresolvedReferences.map(canonRef)],
  207. ];
  208. for (const [table, wasm, kern] of tables) {
  209. const { onlyA, onlyB } = diffSets(wasm, kern);
  210. for (const x of onlyA) {
  211. fileHasDiff = true;
  212. const o = JSON.parse(x);
  213. report(`${table}:missing-in-kernel:${o.kind ?? ''}`, `${rel}: ${x}`);
  214. }
  215. for (const x of onlyB) {
  216. fileHasDiff = true;
  217. const o = JSON.parse(x);
  218. report(`${table}:extra-in-kernel:${o.kind ?? ''}`, `${rel}: ${x}`);
  219. }
  220. // ORDER matters too: identical multisets in a different emission order
  221. // change DB rowids, and resolution iterates refs in rowid order — the
  222. // full-index dump-diff would surface it as a downstream mystery. Catch it
  223. // here instead.
  224. if (onlyA.length === 0 && onlyB.length === 0) {
  225. for (let i = 0; i < wasm.length; i++) {
  226. if (wasm[i] !== kern[i]) {
  227. fileHasDiff = true;
  228. report(`${table}:order-mismatch`, `${rel}: index ${i}: wasm=${wasm[i]} kernel=${kern[i]}`);
  229. break;
  230. }
  231. }
  232. }
  233. }
  234. if (fileHasDiff) {
  235. filesWithDiffs++;
  236. if (listFiles) console.log(`DIFF ${rel}`);
  237. } else {
  238. filesOk++;
  239. }
  240. }
  241. console.log(`\n=== kernel parity: ${filesOk}/${processed} files byte-parity` +
  242. ` (${filesWithDiffs} with diffs, ${deferred} deferred-to-wasm)` +
  243. ` | wasm totals: ${totals.nodes} nodes / ${totals.edges} edges / ${totals.refs} refs ===\n`);
  244. const sorted = [...buckets.entries()].sort((a, b) => b[1].count - a[1].count);
  245. for (const [cat, { count, samples }] of sorted) {
  246. console.log(`--- ${cat}: ${count}`);
  247. for (const s of samples) console.log(` ${s.length > 400 ? s.slice(0, 400) + '…' : s}`);
  248. }
  249. // Deferrals are per-file parse-error routing (expected; rare for most
  250. // languages, routine for macro-heavy C/C++ — see --max-deferral above). A
  251. // rate past the threshold means the kernel is broken and hiding behind the
  252. // fallback — fail loudly.
  253. const deferralRate = deferred / Math.max(processed, 1);
  254. if (deferralRate > maxDeferral) {
  255. console.error(
  256. `deferral rate ${(deferralRate * 100).toFixed(1)}% exceeds ${(maxDeferral * 100).toFixed(0)}% — kernel likely broken`
  257. );
  258. process.exit(1);
  259. }
  260. process.exit(filesWithDiffs > 0 ? 1 : 0);