1
0

kernel-retry-materialize.test.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. /**
  2. * Kernel results must be DECODED before they are persisted (#1541).
  3. *
  4. * The bulk-index parse workers return kernel extractions as an undecoded
  5. * buffer transport: `nodes`/`edges`/`unresolvedReferences` are EMPTY and the
  6. * real tables ride in `kernelBuffers`. The main loop decodes (or hands the
  7. * buffers to the store worker), but indexAll's retry passes used to store the
  8. * transport as-is — the storage gate passed via `errors.length === 0`, zero
  9. * nodes were inserted, and the file was permanently recorded as
  10. * "(0 symbols)" with the retry counted as a success. Any worker
  11. * crash/timeout whose in-flight file was a kernel-routed language silently
  12. * wiped that file's symbols (issue #1541: v1.5.0 indexes a valid Python file
  13. * as 0 symbols; v1.4.1, pre-kernel, indexed it correctly).
  14. *
  15. * This pins the store boundary: storeExtractionResult must materialize a
  16. * buffer-transport result before persisting, so every caller — including the
  17. * retry passes — stores the real nodes.
  18. *
  19. * Skips when no kernel binary is staged (same gating as the parity suites).
  20. */
  21. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  22. import * as fs from 'node:fs';
  23. import * as path from 'node:path';
  24. import * as os from 'node:os';
  25. import { CodeGraph } from '../src';
  26. import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
  27. import { tryKernelExtractRaw } from '../src/extraction/kernel';
  28. import type { ExtractionResult } from '../src/types';
  29. const KERNEL_PATH = path.join(
  30. __dirname,
  31. '..',
  32. 'codegraph-kernel',
  33. 'prebuilds',
  34. `${process.platform}-${process.arch}`,
  35. 'codegraph-kernel.node'
  36. );
  37. const kernelBuilt = fs.existsSync(KERNEL_PATH);
  38. describe.skipIf(!kernelBuilt)('kernel buffer-transport storage (#1541)', () => {
  39. let dir: string;
  40. let cg: CodeGraph;
  41. beforeEach(async () => {
  42. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kernel-retry-mat-'));
  43. cg = await CodeGraph.init(dir);
  44. await initGrammars();
  45. await loadGrammarsForLanguages(['python']);
  46. });
  47. afterEach(() => {
  48. cg.destroy();
  49. fs.rmSync(dir, { recursive: true, force: true });
  50. });
  51. it('storeExtractionResult persists the decoded nodes of a raw kernel result', async () => {
  52. const source =
  53. 'def target_fn(root, mission_path):\n' +
  54. ' return (root, mission_path)\n' +
  55. '\n' +
  56. 'class Adapter:\n' +
  57. ' def adapt(self):\n' +
  58. ' return target_fn(1, 2)\n';
  59. const filePath = 'adapter.py';
  60. fs.writeFileSync(path.join(dir, filePath), source);
  61. // A genuine undecoded transport, exactly as parse-worker builds it.
  62. const raw = tryKernelExtractRaw(filePath, source, 'python');
  63. expect(raw).not.toBeNull();
  64. expect(raw!.counts.nodes).toBeGreaterThan(0);
  65. const transport: ExtractionResult = {
  66. nodes: [],
  67. edges: [],
  68. unresolvedReferences: [],
  69. errors: raw!.errors,
  70. durationMs: 0,
  71. kernelBuffers: raw!.buffers,
  72. kernelCounts: raw!.counts,
  73. };
  74. const stats = fs.statSync(path.join(dir, filePath));
  75. const orchestrator = (cg as unknown as { orchestrator: { storeExtractionResult(f: string, c: string, l: string, s: fs.Stats, r: ExtractionResult): Promise<void> } }).orchestrator;
  76. await orchestrator.storeExtractionResult(filePath, source, 'python', stats, transport);
  77. // The files row must carry the real symbol count, not the transport's
  78. // empty array — a 0 here is the #1541 "(python, 0 symbols)" wipe.
  79. const file = cg.getFile(filePath);
  80. expect(file).not.toBeNull();
  81. expect(file!.nodeCount).toBe(raw!.counts.nodes);
  82. // And the nodes themselves must be queryable.
  83. const nodes = cg.getNodesInFile(filePath);
  84. expect(nodes.length).toBe(raw!.counts.nodes);
  85. expect(nodes.map((n) => n.name)).toContain('target_fn');
  86. expect(nodes.map((n) => n.name)).toContain('Adapter');
  87. });
  88. });
  89. /**
  90. * Self-heal for rows the released bug already wiped: a files row recorded
  91. * with zero nodes on a symbol-bearing language can only be a #1541 casualty
  92. * (every real extraction stores at least the file node), and its content
  93. * hash matches the on-disk bytes, so hash-based reconciles skip it forever.
  94. * The full-reconcile sync and indexAll now drop such rows so the file
  95. * re-indexes. Kernel-independent — the wipe is simulated at the DB.
  96. */
  97. describe('zero-node row self-heal (#1541)', () => {
  98. let dir: string;
  99. let cg: CodeGraph;
  100. beforeEach(async () => {
  101. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zero-node-heal-'));
  102. fs.writeFileSync(
  103. path.join(dir, 'adapter.py'),
  104. 'def target_fn(root, mission_path):\n' +
  105. ' return (root, mission_path)\n' +
  106. '\n' +
  107. 'class Adapter:\n' +
  108. ' def adapt(self):\n' +
  109. ' return target_fn(1, 2)\n'
  110. );
  111. cg = await CodeGraph.init(dir);
  112. await cg.indexAll();
  113. });
  114. afterEach(() => {
  115. cg.destroy();
  116. fs.rmSync(dir, { recursive: true, force: true });
  117. });
  118. it('sync repairs a wiped row even though the content hash is unchanged', async () => {
  119. const before = cg.getFile('adapter.py');
  120. expect(before).not.toBeNull();
  121. expect(before!.nodeCount).toBeGreaterThan(0);
  122. // Simulate the released-v1.5.0 wipe: nodes gone, row says 0 symbols,
  123. // content hash still matching the file on disk.
  124. const db = (cg as unknown as { db: { getDb(): { prepare(sql: string): { run(...args: unknown[]): unknown } } } }).db.getDb();
  125. db.prepare('DELETE FROM nodes WHERE file_path = ?').run('adapter.py');
  126. db.prepare('UPDATE files SET node_count = 0 WHERE path = ?').run('adapter.py');
  127. expect(cg.getFile('adapter.py')!.nodeCount).toBe(0);
  128. await cg.sync();
  129. const after = cg.getFile('adapter.py');
  130. expect(after).not.toBeNull();
  131. expect(after!.nodeCount).toBe(before!.nodeCount);
  132. expect(cg.getNodesInFile('adapter.py').map((n) => n.name)).toContain('target_fn');
  133. });
  134. });