/** * Integration test: BasicBlock + taint/PDG edge types round-trip the * bulk-COPY load path (issue #2080, U5 / R4 / AC2). * * Exercises the real csv-generator → loadGraphToLbug → COPY → query path: * - a BasicBlock node (id/filePath/startLine/endLine/text) round-trips * - one edge of each new type (CFG/REACHING_DEF/TAINTED/SANITIZES/TAINT_PATH, * plus CDG/POST_DOMINATE from #2085 M5) between two BasicBlocks round-trips * (asserts the new FROM/TO DDL pair + REL_TYPES load through bulk COPY) * - REACHING_DEF carries its `variable` in the existing `reason` column * (M0/S1 storage decision) and a variable-filtered query returns it * - CDG carries its branch label ('T'|'F') in the same `reason` column * (#2085 M5) and a label-filtered query returns it * - the DDL (BASICBLOCK_SCHEMA wired into NODE_SCHEMA_QUERIES) loads on a * fresh DB — if BASICBLOCK_SCHEMA were not in SCHEMA_QUERIES, initLbug would * never create the table and these COPYs would fail (F1 guard, end-to-end) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { NODE_TABLES } from 'gitnexus-shared'; import { buildTestGraph } from '../helpers/test-graph.js'; import { getNodeQuery } from '../../src/server/api.js'; import { runImpactPDG } from '../../src/mcp/local/pdg-impact.js'; let tmpBase: string; let storagePath: string; let dbPath: string; const BB1 = 'BasicBlock:src/a.ts:1:0:0'; const BB2 = 'BasicBlock:src/a.ts:1:0:1'; const NEW_EDGE_TYPES = [ 'CFG', 'REACHING_DEF', 'TAINTED', 'SANITIZES', 'TAINT_PATH', 'CDG', 'POST_DOMINATE', ] as const; beforeAll(async () => { tmpBase = path.join(os.tmpdir(), `gitnexus-bb-roundtrip-${Date.now()}-${process.pid}`); storagePath = path.join(tmpBase, '.gitnexus'); dbPath = path.join(storagePath, 'lbug'); await fs.mkdir(dbPath, { recursive: true }); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await adapter.initLbug(dbPath); // Two BasicBlock nodes + one edge of each new type between them. The // REACHING_DEF edge stores its variable name ('x') in `reason`. const graph = buildTestGraph( [ { id: 'Module:src/a.ts:program', label: 'Module', name: 'program', filePath: 'src/a.ts', startLine: 0, endLine: 6, }, { id: BB1, label: 'BasicBlock', name: '', // BasicBlock has no name column; ignored by the writer filePath: 'src/a.ts', startLine: 1, endLine: 3, extra: { text: 'const x = req.body;', ownerId: 'Module:src/a.ts:program' }, }, { id: BB2, label: 'BasicBlock', name: '', filePath: 'src/a.ts', startLine: 4, endLine: 6, extra: { text: 'sink(x);' }, }, ], NEW_EDGE_TYPES.map((type) => ({ sourceId: BB1, targetId: BB2, type, reason: type === 'REACHING_DEF' ? 'x' : type === 'CDG' ? 'T' : `${type.toLowerCase()}-edge`, })), ); await adapter.loadGraphToLbug(graph, tmpBase, storagePath); }); afterAll(async () => { try { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await adapter.closeLbug(); } catch { /* may not have opened */ } if (tmpBase) { for (let attempt = 0; attempt < 5; attempt++) { try { await fs.rm(tmpBase, { recursive: true, force: true }); return; } catch { if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1))); } } } }); describe('BasicBlock + taint/PDG edge round-trip (#2080)', () => { it('projects persisted explicit program ownership through the real PDG query path', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const result = await runImpactPDG({ repo: { lbugPath: dbPath }, sym: { id: 'Function:src/a.ts:consumer', name: 'consumer', filePath: 'src/a.ts', startLine: 3, endLine: 6, }, symType: 'Function', direction: 'upstream', maxDepth: 1, limit: 50, line: 4, executeParameterized: (_db, query, params) => adapter.executePrepared(query, params), }); expect(result).toMatchObject({ mode: 'pdg', byDepth: { 1: [expect.objectContaining({ id: 'Module:src/a.ts:program', type: 'Module' })] }, }); }); it('persists a provider-declared executable owner without inventing owners for ordinary blocks', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const rows = await adapter.executeQuery( 'MATCH (n:BasicBlock) RETURN n.id AS id, n.ownerId AS ownerId ORDER BY n.id', ); expect(rows[0].ownerId).toBe('Module:src/a.ts:program'); expect(rows[1].ownerId ?? '').toBe(''); }); it('BasicBlock nodes round-trip with their source span and text', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const rows = await adapter.executeQuery( 'MATCH (n:BasicBlock) RETURN n.id AS id, n.text AS text, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine ORDER BY n.id', ); expect(rows).toHaveLength(2); expect(rows[0].id).toBe(BB1); expect(rows[0].text).toBe('const x = req.body;'); expect(rows[0].filePath).toBe('src/a.ts'); expect(Number(rows[0].startLine)).toBe(1); expect(Number(rows[0].endLine)).toBe(3); expect(rows[1].id).toBe(BB2); expect(rows[1].text).toBe('sink(x);'); expect(rows[1].filePath).toBe('src/a.ts'); expect(Number(rows[1].endLine)).toBe(6); }); // Regression guard: adding a node table whose columns differ from the // default (BasicBlock has no name/content) must not break the server's // graph read path. getNodeQuery is what /api/graph's buildGraph + // streamGraphNdjson run per NODE_TABLE; a default `n.name` projection on // BasicBlock raises a non-ignorable Ladybug binder error → HTTP 500 on // every analyzed repo. Assert every NODE_TABLE's query binds + runs, and // that BasicBlock returns its loaded rows. it('getNodeQuery binds + runs for every NODE_TABLE against the real schema', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); for (const table of NODE_TABLES) { for (const includeContent of [false, true]) { const q = getNodeQuery(table, includeContent); await expect( adapter.executeQuery(q), `getNodeQuery(${table}, includeContent=${includeContent}) should bind`, ).resolves.toBeDefined(); } } const bbRows = await adapter.executeQuery(getNodeQuery('BasicBlock', false)); expect(bbRows).toHaveLength(2); }); it('each new edge type round-trips between the two BasicBlocks', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); // All edges live in the single CodeRelation table, keyed by `type`. for (const type of NEW_EDGE_TYPES) { const rows = await adapter.executeQuery( `MATCH (:BasicBlock)-[r:CodeRelation {type: '${type}'}]->(:BasicBlock) RETURN count(r) AS c`, ); expect(Number(rows[0].c), `${type} edge should round-trip`).toBe(1); } }); it('REACHING_DEF carries its variable in reason and is queryable by it', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const rows = await adapter.executeQuery( "MATCH (a:BasicBlock)-[r:CodeRelation {type: 'REACHING_DEF', reason: 'x'}]->(b:BasicBlock) RETURN a.id AS from, b.id AS to", ); expect(rows).toHaveLength(1); expect(rows[0].from).toBe(BB1); expect(rows[0].to).toBe(BB2); }); it('CDG carries its branch label in reason and is queryable by it (#2085 M5)', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const rows = await adapter.executeQuery( "MATCH (a:BasicBlock)-[r:CodeRelation {type: 'CDG', reason: 'T'}]->(b:BasicBlock) RETURN a.id AS from, b.id AS to", ); expect(rows).toHaveLength(1); expect(rows[0].from).toBe(BB1); expect(rows[0].to).toBe(BB2); }); it('persists explicit and absent owners through CREATE', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); for (const [index, ownerId] of ['Module:src/owner.ts:Before', undefined].entries()) { const id = `BasicBlock:src/owner.ts:1:0:${index}`; expect( await adapter.insertNodeToLbug('BasicBlock', { id, filePath: 'src/owner.ts', startLine: 1, endLine: 1, text: 'run();', ...(ownerId ? { ownerId } : {}), }), ).toBe(true); const rows = await adapter.executePrepared( 'MATCH (b:BasicBlock {id: $id}) RETURN b.ownerId AS ownerId', { id }, ); expect(rows[0]?.ownerId).toBe(ownerId ?? ''); } }); // Matches the native close/reopen guard in lbug-core-adapter and the metadata // roundtrip suite. Query-generation coverage still runs on Windows. (process.platform === 'win32' ? it.skip : it)( 'persists and clears explicit owners through MERGE', async () => { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const id = 'BasicBlock:src/merged-owner.ts:1:0:0'; for (const ownerId of [ 'Module:src/merged-owner.ts:Before', 'Module:src/merged-owner.ts:After', undefined, ]) { await adapter.closeLbug(); try { expect( await adapter.batchInsertNodesToLbug( [ { label: 'BasicBlock', properties: { id, filePath: 'src/merged-owner.ts', startLine: 1, endLine: 1, text: 'run();', ...(ownerId ? { ownerId } : {}), }, }, ], dbPath, ), ).toEqual({ inserted: 1, failed: 0 }); } finally { await adapter.initLbug(dbPath); } const rows = await adapter.executePrepared( 'MATCH (b:BasicBlock {id: $id}) RETURN b.ownerId AS ownerId', { id }, ); expect(rows[0]?.ownerId).toBe(ownerId ?? ''); } }, ); });