/** * Kernel results must be DECODED before they are persisted (#1541). * * The bulk-index parse workers return kernel extractions as an undecoded * buffer transport: `nodes`/`edges`/`unresolvedReferences` are EMPTY and the * real tables ride in `kernelBuffers`. The main loop decodes (or hands the * buffers to the store worker), but indexAll's retry passes used to store the * transport as-is — the storage gate passed via `errors.length === 0`, zero * nodes were inserted, and the file was permanently recorded as * "(0 symbols)" with the retry counted as a success. Any worker * crash/timeout whose in-flight file was a kernel-routed language silently * wiped that file's symbols (issue #1541: v1.5.0 indexes a valid Python file * as 0 symbols; v1.4.1, pre-kernel, indexed it correctly). * * This pins the store boundary: storeExtractionResult must materialize a * buffer-transport result before persisting, so every caller — including the * retry passes — stores the real nodes. * * Skips when no kernel binary is staged (same gating as the parity suites). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { CodeGraph } from '../src'; import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; import { tryKernelExtractRaw } from '../src/extraction/kernel'; import type { ExtractionResult } from '../src/types'; const KERNEL_PATH = path.join( __dirname, '..', 'codegraph-kernel', 'prebuilds', `${process.platform}-${process.arch}`, 'codegraph-kernel.node' ); const kernelBuilt = fs.existsSync(KERNEL_PATH); describe.skipIf(!kernelBuilt)('kernel buffer-transport storage (#1541)', () => { let dir: string; let cg: CodeGraph; beforeEach(async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kernel-retry-mat-')); cg = await CodeGraph.init(dir); await initGrammars(); await loadGrammarsForLanguages(['python']); }); afterEach(() => { cg.destroy(); fs.rmSync(dir, { recursive: true, force: true }); }); it('storeExtractionResult persists the decoded nodes of a raw kernel result', async () => { const source = 'def target_fn(root, mission_path):\n' + ' return (root, mission_path)\n' + '\n' + 'class Adapter:\n' + ' def adapt(self):\n' + ' return target_fn(1, 2)\n'; const filePath = 'adapter.py'; fs.writeFileSync(path.join(dir, filePath), source); // A genuine undecoded transport, exactly as parse-worker builds it. const raw = tryKernelExtractRaw(filePath, source, 'python'); expect(raw).not.toBeNull(); expect(raw!.counts.nodes).toBeGreaterThan(0); const transport: ExtractionResult = { nodes: [], edges: [], unresolvedReferences: [], errors: raw!.errors, durationMs: 0, kernelBuffers: raw!.buffers, kernelCounts: raw!.counts, }; const stats = fs.statSync(path.join(dir, filePath)); const orchestrator = (cg as unknown as { orchestrator: { storeExtractionResult(f: string, c: string, l: string, s: fs.Stats, r: ExtractionResult): Promise } }).orchestrator; await orchestrator.storeExtractionResult(filePath, source, 'python', stats, transport); // The files row must carry the real symbol count, not the transport's // empty array — a 0 here is the #1541 "(python, 0 symbols)" wipe. const file = cg.getFile(filePath); expect(file).not.toBeNull(); expect(file!.nodeCount).toBe(raw!.counts.nodes); // And the nodes themselves must be queryable. const nodes = cg.getNodesInFile(filePath); expect(nodes.length).toBe(raw!.counts.nodes); expect(nodes.map((n) => n.name)).toContain('target_fn'); expect(nodes.map((n) => n.name)).toContain('Adapter'); }); }); /** * Self-heal for rows the released bug already wiped: a files row recorded * with zero nodes on a symbol-bearing language can only be a #1541 casualty * (every real extraction stores at least the file node), and its content * hash matches the on-disk bytes, so hash-based reconciles skip it forever. * The full-reconcile sync and indexAll now drop such rows so the file * re-indexes. Kernel-independent — the wipe is simulated at the DB. */ describe('zero-node row self-heal (#1541)', () => { let dir: string; let cg: CodeGraph; beforeEach(async () => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zero-node-heal-')); fs.writeFileSync( path.join(dir, 'adapter.py'), 'def target_fn(root, mission_path):\n' + ' return (root, mission_path)\n' + '\n' + 'class Adapter:\n' + ' def adapt(self):\n' + ' return target_fn(1, 2)\n' ); cg = await CodeGraph.init(dir); await cg.indexAll(); }); afterEach(() => { cg.destroy(); fs.rmSync(dir, { recursive: true, force: true }); }); it('sync repairs a wiped row even though the content hash is unchanged', async () => { const before = cg.getFile('adapter.py'); expect(before).not.toBeNull(); expect(before!.nodeCount).toBeGreaterThan(0); // Simulate the released-v1.5.0 wipe: nodes gone, row says 0 symbols, // content hash still matching the file on disk. const db = (cg as unknown as { db: { getDb(): { prepare(sql: string): { run(...args: unknown[]): unknown } } } }).db.getDb(); db.prepare('DELETE FROM nodes WHERE file_path = ?').run('adapter.py'); db.prepare('UPDATE files SET node_count = 0 WHERE path = ?').run('adapter.py'); expect(cg.getFile('adapter.py')!.nodeCount).toBe(0); await cg.sync(); const after = cg.getFile('adapter.py'); expect(after).not.toBeNull(); expect(after!.nodeCount).toBe(before!.nodeCount); expect(cg.getNodesInFile('adapter.py').map((n) => n.name)).toContain('target_fn'); }); });