kernel-parity.mjs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  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]
  14. *
  15. * Requires: npm run build (dist/) and a staged kernel (npm run build:kernel).
  16. * Exit code: 0 = parity, 1 = diffs found, 2 = setup error.
  17. */
  18. import * as fs from 'node:fs';
  19. import * as path from 'node:path';
  20. import { fileURLToPath } from 'node:url';
  21. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  22. const dist = (p) => path.join(ROOT, 'dist', p);
  23. const args = process.argv.slice(2);
  24. const paths = [];
  25. let langFilter = null;
  26. let maxSamples = 5;
  27. let listFiles = false;
  28. for (let i = 0; i < args.length; i++) {
  29. if (args[i] === '--lang') langFilter = new Set(args[++i].split(','));
  30. else if (args[i] === '--max-samples') maxSamples = Number(args[++i]);
  31. else if (args[i] === '--list-files') listFiles = true;
  32. else paths.push(args[i]);
  33. }
  34. if (paths.length === 0) {
  35. console.error('usage: kernel-parity.mjs <file-or-dir>... [--lang ts,tsx] [--max-samples N]');
  36. process.exit(2);
  37. }
  38. const KERNEL_LANGS = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
  39. const EXTS = new Map([
  40. ['.ts', 'typescript'], ['.mts', 'typescript'], ['.cts', 'typescript'],
  41. ['.tsx', 'tsx'], ['.js', 'javascript'], ['.mjs', 'javascript'],
  42. ['.cjs', 'javascript'], ['.jsx', 'jsx'],
  43. ]);
  44. /** Collect candidate files. */
  45. function collect(p, out) {
  46. const st = fs.statSync(p);
  47. if (st.isDirectory()) {
  48. const base = path.basename(p);
  49. if (base === 'node_modules' || base === '.git' || base === 'dist' || base === '.codegraph') return;
  50. for (const e of fs.readdirSync(p)) collect(path.join(p, e), out);
  51. } else if (EXTS.has(path.extname(p))) {
  52. const lang = EXTS.get(path.extname(p));
  53. if (!langFilter || langFilter.has(lang)) out.push({ file: p, lang });
  54. }
  55. }
  56. const files = [];
  57. for (const p of paths) collect(path.resolve(p), files);
  58. if (files.length === 0) {
  59. console.error('no matching files');
  60. process.exit(2);
  61. }
  62. // --- load the built engine ---------------------------------------------------
  63. const { extractFromSource } = await import(dist('extraction/tree-sitter.js'));
  64. const { initGrammars, loadGrammarsForLanguages } = await import(dist('extraction/grammars.js'));
  65. const kernel = await import(dist('extraction/kernel/index.js'));
  66. await initGrammars();
  67. await loadGrammarsForLanguages([...KERNEL_LANGS]);
  68. if (!kernel.getKernel()) {
  69. console.error('kernel .node not found — run: npm run build:kernel');
  70. process.exit(2);
  71. }
  72. // --- canonicalization ---------------------------------------------------------
  73. /**
  74. * Node identity for cross-referencing edges/refs: the node id itself (both
  75. * paths compute the same deterministic ids, and id embeds kind+name+line).
  76. */
  77. function canonNode(n) {
  78. const out = {
  79. id: n.id, kind: n.kind, name: n.name, qualifiedName: n.qualifiedName,
  80. filePath: n.filePath, language: n.language,
  81. startLine: n.startLine, endLine: n.endLine,
  82. startColumn: n.startColumn, endColumn: n.endColumn,
  83. };
  84. for (const k of ['docstring', 'signature', 'visibility', 'isExported', 'isAsync', 'isStatic', 'isAbstract', 'returnType']) {
  85. if (n[k] !== undefined) out[k] = n[k];
  86. }
  87. if (n.decorators !== undefined) out.decorators = n.decorators;
  88. if (n.typeParameters !== undefined) out.typeParameters = n.typeParameters;
  89. return JSON.stringify(out);
  90. }
  91. function canonEdge(e) {
  92. const out = { source: e.source, target: e.target, kind: e.kind };
  93. if (e.line !== undefined) out.line = e.line;
  94. if (e.column !== undefined) out.column = e.column;
  95. if (e.provenance !== undefined) out.provenance = e.provenance;
  96. if (e.metadata !== undefined) out.metadata = e.metadata;
  97. return JSON.stringify(out);
  98. }
  99. function canonRef(r) {
  100. // FULL object — a field only one path sets is a parity bug (the vitest
  101. // parity suite caught decode.ts pre-filling filePath/language this way).
  102. const out = {
  103. from: r.fromNodeId, name: r.referenceName, kind: r.referenceKind,
  104. line: r.line, column: r.column,
  105. };
  106. for (const k of ['filePath', 'language', 'candidates', 'rowId']) {
  107. if (r[k] !== undefined) out[k] = r[k];
  108. }
  109. return JSON.stringify(out);
  110. }
  111. function diffSets(aList, bList) {
  112. const a = new Map(); // canon -> count (multiset — duplicates matter)
  113. const b = new Map();
  114. for (const x of aList) a.set(x, (a.get(x) ?? 0) + 1);
  115. for (const x of bList) b.set(x, (b.get(x) ?? 0) + 1);
  116. const onlyA = [];
  117. const onlyB = [];
  118. for (const [k, c] of a) {
  119. const d = c - (b.get(k) ?? 0);
  120. for (let i = 0; i < d; i++) onlyA.push(k);
  121. }
  122. for (const [k, c] of b) {
  123. const d = c - (a.get(k) ?? 0);
  124. for (let i = 0; i < d; i++) onlyB.push(k);
  125. }
  126. return { onlyA, onlyB };
  127. }
  128. // --- run ----------------------------------------------------------------------
  129. const buckets = new Map(); // category -> {count, samples[]}
  130. function report(category, sample) {
  131. let b = buckets.get(category);
  132. if (!b) buckets.set(category, (b = { count: 0, samples: [] }));
  133. b.count++;
  134. if (b.samples.length < maxSamples) b.samples.push(sample);
  135. }
  136. let filesWithDiffs = 0;
  137. let filesOk = 0;
  138. let kernelFailed = 0;
  139. let totals = { nodes: 0, edges: 0, refs: 0 };
  140. process.env.CODEGRAPH_KERNEL_LANGS = 'all';
  141. for (const { file, lang } of files) {
  142. const source = fs.readFileSync(file, 'utf8');
  143. const rel = path.relative(ROOT, file);
  144. delete process.env.CODEGRAPH_KERNEL; // kernel path on
  145. const kres = kernel.tryKernelExtract(rel, source, lang);
  146. if (!kres) {
  147. kernelFailed++;
  148. report('kernel-extract-failed', rel);
  149. continue;
  150. }
  151. process.env.CODEGRAPH_KERNEL = '0'; // wasm path
  152. const wres = extractFromSource(rel, source, lang);
  153. delete process.env.CODEGRAPH_KERNEL;
  154. totals.nodes += wres.nodes.length;
  155. totals.edges += wres.edges.length;
  156. totals.refs += wres.unresolvedReferences.length;
  157. let fileHasDiff = false;
  158. const tables = [
  159. ['node', wres.nodes.map(canonNode), kres.nodes.map(canonNode)],
  160. ['edge', wres.edges.map(canonEdge), kres.edges.map(canonEdge)],
  161. ['ref', wres.unresolvedReferences.map(canonRef), kres.unresolvedReferences.map(canonRef)],
  162. ];
  163. for (const [table, wasm, kern] of tables) {
  164. const { onlyA, onlyB } = diffSets(wasm, kern);
  165. for (const x of onlyA) {
  166. fileHasDiff = true;
  167. const o = JSON.parse(x);
  168. report(`${table}:missing-in-kernel:${o.kind ?? ''}`, `${rel}: ${x}`);
  169. }
  170. for (const x of onlyB) {
  171. fileHasDiff = true;
  172. const o = JSON.parse(x);
  173. report(`${table}:extra-in-kernel:${o.kind ?? ''}`, `${rel}: ${x}`);
  174. }
  175. }
  176. if (fileHasDiff) {
  177. filesWithDiffs++;
  178. if (listFiles) console.log(`DIFF ${rel}`);
  179. } else {
  180. filesOk++;
  181. }
  182. }
  183. console.log(`\n=== kernel parity: ${filesOk}/${files.length} files byte-parity` +
  184. ` (${filesWithDiffs} with diffs, ${kernelFailed} kernel-failed)` +
  185. ` | wasm totals: ${totals.nodes} nodes / ${totals.edges} edges / ${totals.refs} refs ===\n`);
  186. const sorted = [...buckets.entries()].sort((a, b) => b[1].count - a[1].count);
  187. for (const [cat, { count, samples }] of sorted) {
  188. console.log(`--- ${cat}: ${count}`);
  189. for (const s of samples) console.log(` ${s.length > 400 ? s.slice(0, 400) + '…' : s}`);
  190. }
  191. process.exit(filesWithDiffs > 0 || kernelFailed > 0 ? 1 : 0);