From 16d7e9477bf2dee41fee7515c6998b489974336e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 4 Oct 2026 09:26:02 +0100 Subject: [PATCH 1/3] fix(embeddings): reuse completed vectors after interrupted analyze (#3463) --- docker-server.test.mjs | 45 +- gitnexus/src/cli/embeddings-sync.ts | 18 +- .../staged-embedding-recovery-child.ts | 181 +++++ .../embeddings/staged-embedding-recovery.ts | 244 +++++++ gitnexus/src/core/run-analyze.ts | 162 ++++- gitnexus/src/server/api.ts | 13 +- gitnexus/src/storage/embedding-recovery.ts | 167 +++++ gitnexus/src/storage/index-lock.ts | 9 +- gitnexus/src/storage/repo-meta.ts | 14 + .../staged-embedding-recovery/seed.mjs | 52 ++ .../analyze-staged-embedding-recovery.test.ts | 449 ++++++++++++ .../staged-embedding-recovery.test.ts | 208 ++++++ gitnexus/test/unit/api-fts-mode.test.ts | 172 ++++- .../test/unit/embedding-recovery-race.test.ts | 199 ++++++ gitnexus/test/unit/embedding-recovery.test.ts | 175 +++++ .../test/unit/embeddings-sync-command.test.ts | 116 ++- gitnexus/test/unit/index-lock.test.ts | 104 +++ .../test/unit/run-analyze-fts-repair.test.ts | 659 +++++++++++++++++- .../staged-embedding-recovery-child.test.ts | 300 ++++++++ .../unit/staged-embedding-recovery.test.ts | 95 +++ 20 files changed, 3339 insertions(+), 43 deletions(-) create mode 100644 gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts create mode 100644 gitnexus/src/core/embeddings/staged-embedding-recovery.ts create mode 100644 gitnexus/src/storage/embedding-recovery.ts create mode 100644 gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs create mode 100644 gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts create mode 100644 gitnexus/test/integration/staged-embedding-recovery.test.ts create mode 100644 gitnexus/test/unit/embedding-recovery-race.test.ts create mode 100644 gitnexus/test/unit/embedding-recovery.test.ts create mode 100644 gitnexus/test/unit/staged-embedding-recovery-child.test.ts create mode 100644 gitnexus/test/unit/staged-embedding-recovery.test.ts diff --git a/docker-server.test.mjs b/docker-server.test.mjs index 6d2a9f6c2..309edf3b5 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -331,8 +331,8 @@ const respondOk = (_req, res) => { // // upstream request handler, replaceable mid-test via `ctx.handler`; // null points the proxy at a port nothing ever listens on -// listenAfterMs bind the upstream this late, so the first attempt(s) hit -// ECONNREFUSED (a single-instance restart window) +// listenAfterMs bind the upstream this late after the first refused attempt +// (a single-instance restart window) // schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's // `fromService: { property: hostport }` yields it // env extra environment for docker-server.mjs @@ -366,18 +366,15 @@ async function withProxy( }) : null; - // A late (or never) bind needs its port reserved up front; otherwise let the - // OS assign one at listen time. - const upstreamPort = - server && listenAfterMs === 0 - ? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port))) - : await getFreePort(); - const bindTimer = - server && listenAfterMs > 0 - ? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs) - : null; - + // Keep the upstream port bound until the proxy port is chosen. Releasing it + // sooner lets the OS assign both services the same port and proxy to itself. + const reservation = server ?? createServer(); + const upstreamPort = await new Promise((r) => + reservation.listen(0, '127.0.0.1', () => r(reservation.address().port)), + ); const port = await getFreePort(); + let bindTimer = null; + const target = `127.0.0.1:${upstreamPort}`; const proc = spawnServerWithEnv(dir, port, { GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`, @@ -390,18 +387,25 @@ async function withProxy( ...env, }); proc.stderr.setEncoding('utf8'); - proc.stderr.on('data', (chunk) => { + const collectStderr = (chunk) => { ctx.stderr += chunk; - }); + // Process startup must not consume the restart window or skip the retry. + if (server && listenAfterMs > 0 && !bindTimer && ctx.stderr.includes('ECONNREFUSED; retry')) { + bindTimer = setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs); + } + }; + proc.stderr.on('data', collectStderr); try { await waitForServer(port); + if (!server || listenAfterMs > 0) await new Promise((r) => reservation.close(r)); await fn(port, ctx); } finally { + proc.stderr.off('data', collectStderr); if (bindTimer) clearTimeout(bindTimer); await killAndWait(proc); - if (server?.listening) { - server.closeAllConnections?.(); - await new Promise((r) => server.close(r)); + if (reservation.listening) { + reservation.closeAllConnections?.(); + await new Promise((r) => reservation.close(r)); } await rm(dir, { recursive: true, force: true }); } @@ -661,8 +665,8 @@ it('returns 502 when the upstream is unreachable', async () => { // -- Connection-retry across an upstream restart window --------------------- // -// `listenAfterMs: 400` binds the upstream late, so the first attempt hits -// ECONNREFUSED and must be retried — a single-instance restart. The default 3 +// `listenAfterMs: 400` binds the upstream 400ms after the first ECONNREFUSED, +// so the request must be retried — a single-instance restart. The default 3 // attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind. it('retries a connection-refused POST and succeeds once the upstream is up', async () => { @@ -676,6 +680,7 @@ it('retries a connection-refused POST and succeeds once the upstream is up', asy assert.match(res.body, /"ok":true/); assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)'); assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact'); + assert.match(ctx.stderr, /ECONNREFUSED; retry/, 'the restart gap must exercise a retry'); }); }); diff --git a/gitnexus/src/cli/embeddings-sync.ts b/gitnexus/src/cli/embeddings-sync.ts index 9848ef7c4..59cf9270a 100644 --- a/gitnexus/src/cli/embeddings-sync.ts +++ b/gitnexus/src/cli/embeddings-sync.ts @@ -4,7 +4,11 @@ import { LBUG_DIRECTORY } from '../storage/storage-constants.js'; import path from 'node:path'; import { cliInfo } from './cli-message.js'; import { getGitRoot } from '../storage/git.js'; -import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; +import { + acquireIndexLock, + requireExclusiveIndexLock, + sweepStagingArtifacts, +} from '../storage/index-lock.js'; import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js'; import { closeLbug, @@ -50,12 +54,22 @@ export const embeddingsSyncCommand = async (inputPath?: string): Promise = // Writes go to the slot's own graph. A shared-store checkout that reads an // immutable commit graph (#3352) takes a private copy first. const lbugPath = path.join(metaDir, LBUG_DIRECTORY); - const lock = await acquireIndexLock(metaDir); + const lock = await acquireIndexLock(metaDir, { sweep: false }); try { requireExclusiveIndexLock( lock, `Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`, ); + // Sync writes the published graph and cannot recover a staged generation. + // Reject even malformed receipts before sweeping staging files or writing. + const recoveryCheckpoint = (await loadMeta(metaDir))?.embeddingCheckpoint; + if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) { + throw new Error( + 'Cannot sync embeddings: the index checkpoint references staged embeddings. ' + + 'Run `gitnexus analyze` to recover them first.', + ); + } + sweepStagingArtifacts(metaDir); if (!(await ensurePrivateSharedGraph(metaDir, (m) => console.log(` ${m}`)))) { throw new Error('The shared graph this checkout reads is gone. Run gitnexus analyze first.'); } diff --git a/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts b/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts new file mode 100644 index 000000000..b1fb2cb73 --- /dev/null +++ b/gitnexus/src/core/embeddings/staged-embedding-recovery-child.ts @@ -0,0 +1,181 @@ +/** Isolated strict native reader. Never import this entrypoint into analyze. */ +import fs from 'node:fs'; +import path from 'node:path'; +import lbug from '@ladybugdb/core'; +import { createLbugDatabase, toNativeSafePath } from '../lbug/lbug-config.js'; +import { FAMILY_SUFFIXES } from '../../storage/embedding-recovery.js'; +import { + abortCachedEmbeddingsBuilder, + createCachedEmbeddingsBuilder, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, +} from './embedding-restore-spill.js'; +import type { StagedEmbeddingExport } from './staged-embedding-recovery.js'; + +/** Native replay and close may checkpoint; only give them disposable copies. */ +function copyRecoveryFamily(dbPath: string, exportDir: string): string { + const replayDir = fs.mkdtempSync(path.join(exportDir, 'replay-')); + const replayPath = path.join(replayDir, path.basename(dbPath)); + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + const flags = fs.constants.O_RDONLY | noFollow | (fs.constants.O_NONBLOCK ?? 0); + const buffer = Buffer.allocUnsafe(1024 * 1024); + for (const suffix of FAMILY_SUFFIXES) { + const sourcePath = dbPath + suffix; + let entry: fs.BigIntStats; + try { + entry = fs.lstatSync(sourcePath, { bigint: true }); + } catch (error) { + if (suffix && (error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + if (!entry.isFile()) throw new Error('staged embedding family is not a regular file'); + const source = fs.openSync(sourcePath, flags); + let destination: number | undefined; + try { + const opened = fs.fstatSync(source, { bigint: true }); + const current = fs.lstatSync(sourcePath, { bigint: true }); + if ( + !opened.isFile() || + !current.isFile() || + (noFollow === 0 && opened.ino === 0n) || + opened.dev !== entry.dev || + opened.ino !== entry.ino || + opened.dev !== current.dev || + opened.ino !== current.ino + ) { + throw new Error('staged embedding family changed while opening'); + } + destination = fs.openSync(replayPath + suffix, 'wx', 0o600); + let copied = 0n; + for (;;) { + const bytesRead = fs.readSync(source, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + fs.writeFileSync(destination, buffer.subarray(0, bytesRead)); + copied += BigInt(bytesRead); + } + const after = fs.fstatSync(source, { bigint: true }); + if ( + copied !== opened.size || + after.size !== opened.size || + after.mtimeNs !== opened.mtimeNs || + after.ctimeNs !== opened.ctimeNs + ) { + throw new Error('staged embedding family changed while copying'); + } + } finally { + try { + if (destination !== undefined) fs.closeSync(destination); + } finally { + fs.closeSync(source); + } + } + } + return replayPath; +} + +async function extract(): Promise { + const [dbPath, exportDir, dimensionsArg] = process.argv.slice(2); + const dimensions = Number(dimensionsArg); + if (!dbPath || !exportDir || !Number.isInteger(dimensions) || dimensions <= 0) { + throw new Error('invalid staged embedding extraction arguments'); + } + // The parent owns exportDir and reclaims it even after killing this child. + const replayPath = copyRecoveryFamily(dbPath, exportDir); + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0, spillDir: exportDir }); + const rejectedNodeIds = new Set(); + let db: lbug.Database | undefined; + let conn: lbug.Connection | undefined; + try { + // Avoid openLbugConnection's test-fixture lock sweep: a recovery source must + // never have its WAL removed, even when an external slot resembles a fixture. + db = createLbugDatabase(lbug, toNativeSafePath(replayPath), { throwOnWalReplayFailure: true }); + conn = new lbug.Connection(db); + const queried = await conn.query( + 'MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash', + ); + const results = Array.isArray(queried) ? queried : [queried]; + try { + if (results.length !== 1) throw new Error('unexpected staged embedding query result'); + const result = results[0]; + while (await result.hasNext()) { + const raw = await result.getNext(); + const rec = raw as Record & unknown[]; + const nodeId = rec.nodeId ?? rec[0]; + if (typeof nodeId !== 'string' || !nodeId) + throw new Error('invalid staged embedding node id'); + const chunkIndex = rec.chunkIndex ?? rec[1]; + const startLine = rec.startLine ?? rec[2]; + const endLine = rec.endLine ?? rec[3]; + const embedding = rec.embedding ?? rec[4]; + const contentHash = rec.contentHash ?? rec[5]; + const vector = + Array.isArray(embedding) || + (ArrayBuffer.isView(embedding) && !(embedding instanceof DataView)) + ? Array.from(embedding as ArrayLike) + : undefined; + if ( + !Number.isInteger(chunkIndex) || + Number(chunkIndex) < 0 || + !Number.isInteger(startLine) || + Number(startLine) < 0 || + !Number.isInteger(endLine) || + Number(endLine) < Number(startLine) || + typeof contentHash !== 'string' || + !contentHash || + !vector || + vector.length !== dimensions || + vector.some( + (value) => + typeof value !== 'number' || + !Number.isFinite(value) || + !Number.isFinite(Math.fround(value)), + ) + ) { + rejectedNodeIds.add(nodeId); + continue; + } + ingestCachedEmbeddingRow( + builder, + { nodeId, chunkIndex, startLine, endLine, embedding: vector, contentHash }, + true, + ); + } + } finally { + for (const result of results) await result.close(); + } + // Both closes must succeed. Suppressed native teardown errors are unsafe. + await conn.close(); + await db.close(); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) fs.renameSync(snapshot.spill.path, path.join(exportDir, 'vectors.bin')); + const manifest: StagedEmbeddingExport = { + version: 1, + dimensions, + rows: snapshot.rows, + rejectedNodeIds: [...rejectedNodeIds], + }; + fs.writeFileSync(path.join(exportDir, 'manifest.json'), JSON.stringify(manifest), { + flag: 'wx', + mode: 0o600, + }); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + // Cleanup is best effort on a rejected source, never used to approve output. + try { + await conn?.close(); + } catch { + /* rejected */ + } + try { + await db?.close(); + } catch { + /* rejected */ + } + throw err; + } +} + +extract().catch((err: unknown) => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; +}); diff --git a/gitnexus/src/core/embeddings/staged-embedding-recovery.ts b/gitnexus/src/core/embeddings/staged-embedding-recovery.ts new file mode 100644 index 000000000..b295f4beb --- /dev/null +++ b/gitnexus/src/core/embeddings/staged-embedding-recovery.ts @@ -0,0 +1,244 @@ +/** Recover paid embedding rows without opening an interrupted native DB in analyze. */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + abortCachedEmbeddingsBuilder, + createCachedEmbeddingsBuilder, + EmbeddingSpillReader, + emptyCachedEmbeddingsSnapshot, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + materializeCachedEmbeddings, + type CachedEmbeddingMeta, + type CachedEmbeddingsSnapshot, +} from './embedding-restore-spill.js'; + +export interface StagedEmbeddingRecoveryOptions { + dimensions: number; + /** Active checkpoint window and any incomplete inherited restore groups. */ + excludedNodeIds?: Iterable; + timeoutMs?: number; +} + +export interface StagedEmbeddingExport { + version: 1; + dimensions: number; + rows: CachedEmbeddingMeta[]; + rejectedNodeIds: string[]; +} + +const RESTORE_BATCH_SIZE = 200; +const DEFAULT_EXTRACTION_TIMEOUT_MS = 120_000; + +/** A contiguous prefix is safe only when its node is outside every unsafe window. */ +export function validateRecoveredNodeGroups( + rows: readonly CachedEmbeddingMeta[], + excludedNodeIds: ReadonlySet = new Set(), +): Set { + const groups = new Map; invalid: boolean }>(); + for (const row of rows) { + let group = groups.get(row.nodeId); + if (!group) { + group = { hash: row.contentHash, ordinals: new Set(), invalid: false }; + groups.set(row.nodeId, group); + } + if ( + typeof row.nodeId !== 'string' || + !row.nodeId || + typeof row.contentHash !== 'string' || + !row.contentHash || + row.contentHash !== group.hash || + !Number.isInteger(row.chunkIndex) || + row.chunkIndex < 0 || + group.ordinals.has(row.chunkIndex) || + !Number.isInteger(row.startLine) || + row.startLine < 0 || + !Number.isInteger(row.endLine) || + row.endLine < row.startLine + ) + group.invalid = true; + group.ordinals.add(row.chunkIndex); + } + const accepted = new Set(); + for (const [nodeId, group] of groups) { + if (group.invalid || excludedNodeIds.has(nodeId)) continue; + // Unique ordinals with max n-1 and zero present have no holes. + if (!group.ordinals.has(0)) continue; + if ([...group.ordinals].some((ordinal) => ordinal >= group.ordinals.size)) continue; + accepted.add(nodeId); + } + return accepted; +} + +/** Whole recovered nodes replace whole published groups; vectors stay in bounded batches. */ +export function mergeRecoveredEmbeddings( + live: CachedEmbeddingsSnapshot, + recovered: CachedEmbeddingsSnapshot, +): CachedEmbeddingsSnapshot { + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 }); + try { + appendSnapshotRows( + live, + live.rows.filter((row) => !recovered.embeddingNodeIds.has(row.nodeId)), + builder, + ); + appendSnapshotRows(recovered, recovered.rows, builder); + return finalizeCachedEmbeddingsSnapshot(builder); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; + } +} + +function appendSnapshotRows( + snapshot: CachedEmbeddingsSnapshot, + rows: readonly CachedEmbeddingMeta[], + builder: ReturnType, +): void { + const reader = snapshot.spill ? new EmbeddingSpillReader(snapshot.spill) : undefined; + try { + for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) { + const batch = materializeCachedEmbeddings( + snapshot, + rows.slice(i, i + RESTORE_BATCH_SIZE), + reader, + ); + for (const row of batch) + ingestCachedEmbeddingRow(builder, row as unknown as Record, true); + } + } finally { + reader?.close(); + } +} + +/** + * Caller must validate checkpoint identity, schema, exact generation, and hold the + * index lock. A subprocess contains native WAL replay/query/destructor failures. + * A failed strict open is never retried with WAL removed or validation disabled. + */ +export async function recoverStagedEmbeddings( + dbPath: string, + options: StagedEmbeddingRecoveryOptions, +): Promise { + if (!Number.isInteger(options.dimensions) || options.dimensions <= 0) { + throw new Error('invalid staged embedding dimensions'); + } + const dbStat = fs.lstatSync(dbPath); + if (!dbStat.isFile() || dbStat.isSymbolicLink()) + throw new Error('staged embedding DB is not a regular file'); + const exportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-export-')); + try { + await runExtractionChild(dbPath, exportDir, options); + const manifestPath = path.join(exportDir, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as StagedEmbeddingExport; + if ( + manifest.version !== 1 || + manifest.dimensions !== options.dimensions || + !Array.isArray(manifest.rows) || + !Array.isArray(manifest.rejectedNodeIds) || + manifest.rejectedNodeIds.some((id) => typeof id !== 'string') || + manifest.rows.some((row, i) => !row || row.vectorIndex !== i) + ) + throw new Error('invalid staged embedding export manifest'); + if (manifest.rows.length === 0) return emptyCachedEmbeddingsSnapshot(); + const spill = { + path: path.join(exportDir, 'vectors.bin'), + dims: options.dimensions, + rowCount: manifest.rows.length, + }; + const vectorStat = fs.lstatSync(spill.path); + if ( + !vectorStat.isFile() || + vectorStat.isSymbolicLink() || + vectorStat.size !== 12 + spill.rowCount * spill.dims * 4 + ) { + throw new Error('invalid staged embedding export size'); + } + const excluded = new Set(options.excludedNodeIds ?? []); + for (const nodeId of manifest.rejectedNodeIds) excluded.add(nodeId); + const accepted = validateRecoveredNodeGroups(manifest.rows, excluded); + const exported: CachedEmbeddingsSnapshot = { + rows: manifest.rows, + embeddings: [], + embeddingNodeIds: accepted, + spill, + }; + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0 }); + const reader = new EmbeddingSpillReader(spill); + try { + const rows = manifest.rows.filter((row) => accepted.has(row.nodeId)); + for (let i = 0; i < rows.length; i += RESTORE_BATCH_SIZE) { + for (const row of materializeCachedEmbeddings( + exported, + rows.slice(i, i + RESTORE_BATCH_SIZE), + reader, + )) { + if ( + row.embedding.length !== options.dimensions || + row.embedding.some((value) => !Number.isFinite(value)) + ) { + throw new Error('invalid staged embedding vector'); + } + ingestCachedEmbeddingRow(builder, row as unknown as Record, true); + } + } + return finalizeCachedEmbeddingsSnapshot(builder); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; + } finally { + reader.close(); + } + } finally { + fs.rmSync(exportDir, { recursive: true, force: true }); + } +} + +function runExtractionChild( + dbPath: string, + exportDir: string, + options: StagedEmbeddingRecoveryOptions, +): Promise { + const compiledPath = fileURLToPath( + new URL('./staged-embedding-recovery-child.js', import.meta.url), + ); + const sourcePath = compiledPath.replace(/\.js$/, '.ts'); + const childPath = fs.existsSync(compiledPath) ? compiledPath : sourcePath; + const args = childPath.endsWith('.ts') + ? ['--import', import.meta.resolve('tsx'), childPath] + : [childPath]; + args.push(dbPath, exportDir, String(options.dimensions)); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }); + let stderr = ''; + let timedOut = false; + child.stderr.on('data', (chunk: Buffer) => { + if (stderr.length < 4096) stderr += chunk.toString().slice(0, 4096 - stderr.length); + }); + const timer = setTimeout(() => { + timedOut = true; + // A process boundary is safe to kill even while native code is executing. + child.kill('SIGKILL'); + }, options.timeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS); + child.once('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (code === 0 && !signal && !timedOut) resolve(); + else + reject( + new Error( + `staged embedding extraction failed${timedOut ? ' (timeout)' : ` (${signal ?? code})`}${stderr ? `: ${stderr.trim()}` : ''}`, + ), + ); + }); + }); +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index af51bb6ea..5f43df3e2 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -36,7 +36,12 @@ import fs from 'fs/promises'; import { constants as fsConstants, existsSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { retryRename } from '../storage/fs-atomic.js'; -import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js'; +import { + acquireIndexLock, + requireExclusiveIndexLock, + sweepStagingArtifacts, +} from '../storage/index-lock.js'; +import { resolveEmbeddingRecovery } from '../storage/embedding-recovery.js'; import { invalidateNodeWorkspacePackages } from './ingestion/import-resolvers/node-workspace-packages.js'; import { logNameFallbackSummary, @@ -1287,6 +1292,8 @@ export async function runFullAnalysis( const log = (msg: string) => callbacks.onLog?.(stripControlCharacters(msg)); const acquireOpts = { log, + // Resolve and validate the canonical slot under the lock before cleanup. + sweep: false, onWaitStart: () => callbacks.onProgress('lock', 0, 'Waiting for another analyze to finish on this index…'), }; @@ -1345,6 +1352,7 @@ export async function runFullAnalysis( } const flatShared = writeTarget.placement.branch ? undefined : writeTarget.sharedStore; if (flatShared) await seedSharedSlot(flatShared, repoPath, log); + sweepStagingArtifacts(writeTarget.metaDir, log); const slotToLeave = options.noShare ? await optedInSlotToLeave(repoPath) : undefined; const result = await runFullAnalysisInner( repoPath, @@ -1842,7 +1850,13 @@ async function runFullAnalysisInner( decision = decideEmbeddingResume(checkpoint, embeddingIdentityForRun, resumeOptions); } if (decision.action === 'abort') throw new Error(decision.error); - log(decision.log); + log( + decision.action === 'resume' && checkpoint.recovery + ? `Previous analyze recorded an embedding checkpoint (${checkpoint.nodesProcessed}/` + + `${checkpoint.totalNodes} nodes); validating staged vectors before retrying ` + + `${decision.pendingNodeIds.size} pending node(s).` + : decision.log, + ); if (options.dropEmbeddings) { // --drop-embeddings has always implied a rebuild here; the decision only // covers the marker. @@ -2671,6 +2685,74 @@ async function runFullAnalysisInner( } } + // A checkpoint's pending decision and its paid, complete vectors are + // independent: --force discards the former but can still reuse the latter. + // Select only the explicitly referenced generation, never an orphan by age. + const stagedRecovery = resolveEmbeddingRecovery(metaDir, existingMeta?.embeddingCheckpoint); + const stagedCheckpoint = existingMeta?.embeddingCheckpoint; + const inheritedUnsafeNodeIds = new Set([ + ...pendingEmbeddingNodeIds, + ...(stagedRecovery?.unsafeNodeIds ?? []), + ]); + if (shouldLoadCache && !options.dropEmbeddings && stagedRecovery && stagedCheckpoint) { + if (!embeddingIdentityForRun) { + const { resolveEmbeddingIdentity } = await import('./embeddings/embedding-identity.js'); + embeddingIdentityForRun = resolveEmbeddingIdentity(); + } + const marker = stagedCheckpoint; + const matchesIdentity = + marker.model === embeddingIdentityForRun.model && + marker.dimensions === embeddingIdentityForRun.dimensions && + marker.provider === embeddingIdentityForRun.provider; + if (matchesIdentity && stagedRecovery.schemaFingerprint === SCHEMA_FINGERPRINT) { + // A force-discarded pending decision must not turn a known incomplete + // inherited group into a reusable cache merely because its hash matches. + if (inheritedUnsafeNodeIds.size > 0) { + const rows = cachedSnapshot.rows.filter((row) => !inheritedUnsafeNodeIds.has(row.nodeId)); + cachedSnapshot = { + ...cachedSnapshot, + rows, + embeddingNodeIds: new Set(rows.map((row) => row.nodeId)), + }; + } + let recovered: CachedEmbeddingsSnapshot | undefined; + try { + const { recoverStagedEmbeddings, mergeRecoveredEmbeddings } = + await import('./embeddings/staged-embedding-recovery.js'); + recovered = await recoverStagedEmbeddings(stagedRecovery.dbPath, { + dimensions: embeddingIdentityForRun.dimensions, + excludedNodeIds: stagedRecovery.unsafeNodeIds, + }); + const liveCacheDims = snapshotEmbeddingDims(cachedSnapshot); + if (liveCacheDims !== undefined && liveCacheDims !== embeddingIdentityForRun.dimensions) { + log( + `Embedding dimensions changed (${liveCacheDims}d -> ` + + `${embeddingIdentityForRun.dimensions}d), discarding published cache`, + ); + discardCachedEmbeddings(); + } + if (recovered.rows.length > 0) { + const merged = mergeRecoveredEmbeddings(cachedSnapshot, recovered); + disposeEmbeddingSpill(cachedSnapshot.spill); + adoptCachedEmbeddings(merged); + } + log( + `Recovered ${recovered.rows.length} complete staged embedding chunk(s) ` + + `for ${recovered.embeddingNodeIds.size} node(s); unchanged content can reuse them.`, + ); + } catch (err) { + log( + `Warning: could not recover staged embeddings (${(err as Error).message}); ` + + 'the retry will regenerate missing chunks.', + ); + } finally { + disposeEmbeddingSpill(recovered?.spill); + } + } else { + log('Staged embedding identity or schema changed; its vectors will not be reused.'); + } + } + // ── Load incremental parse cache ────────────────────────────────── // Content-addressed: `--force` reuses parser shards; `useParseCache: false` // stages a new generation under a run-unique parse-rebuild.* dir and publishes @@ -4487,6 +4569,16 @@ async function runFullAnalysisInner( embeddingIdentityForRun = resolveEmbeddingIdentity(); } const embeddingIdentity = embeddingIdentityForRun; + const stagedRecoveryEnabled = useAtomicSwap && isManualCheckpointEnabled(); + if (useAtomicSwap && !stagedRecoveryEnabled) { + log( + 'Manual WAL checkpoints are disabled; new staged work cannot be recovered after interruption. ' + + 'Any previous durable recovery source is retained until publication.', + ); + } + const unsafeRecoveryNodeIds = new Set([...inheritedUnsafeNodeIds, ...restoreFailedNodeIds]); + let activeWindowNodeIds: string[] = []; + let recoveryGenerationDurable = false; // Build a Map from cached embeddings for incremental mode let existingEmbeddings: Map | undefined; if (cachedSnapshot.embeddingNodeIds.size > 0) { @@ -4540,6 +4632,14 @@ async function runFullAnalysisInner( stagedCheckpointEmbeddingCount = embeddings; } const latestMeta = (await loadMeta(metaDir)) ?? existingMeta; + // An in-place write or manual-checkpoint opt-out cannot create a new + // recoverable staged generation. Keep the complete previous receipt: + // updated progress or unsafe nodes would describe different source bytes. + const preservedRecoveryCheckpoint = + !stagedRecoveryEnabled && + resolveEmbeddingRecovery(metaDir, latestMeta?.embeddingCheckpoint) + ? latestMeta?.embeddingCheckpoint + : undefined; // First-ever analyze of this repo: no meta exists on disk yet (the // pre-wipe dirty stamp only fires when one does). Mint the minimum // RepoMeta requires, with `lastCommit: ''` — never `currentCommit` — @@ -4550,6 +4650,11 @@ async function runFullAnalysisInner( lastCommit: '', indexedAt: new Date().toISOString(), }; + const interrupted = mintInterruptedCheckpoint( + embeddingIdentity, + checkpoint, + pendingNodeIds, + ); await saveMeta(metaDir, { ...base, ...(embeddings === undefined || buildPath !== lbugPath @@ -4557,11 +4662,18 @@ async function runFullAnalysisInner( : { stats: { ...base.stats, embeddings } }), // Written by a run that is still IN FLIGHT — see the `kind` doc in // repo-manager.ts. - embeddingCheckpoint: mintInterruptedCheckpoint( - embeddingIdentity, - checkpoint, - pendingNodeIds, - ), + embeddingCheckpoint: preservedRecoveryCheckpoint ?? { + ...interrupted, + ...(stagedRecoveryEnabled + ? { + recovery: { + stagingFile: path.basename(buildPath), + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: [...unsafeRecoveryNodeIds], + }, + } + : {}), + }, }); }; @@ -4584,7 +4696,21 @@ async function runFullAnalysisInner( { forceReembedNodeIds: pendingEmbeddingNodeIds, onCheckpointWindowStart: async ({ nodeIds, ...checkpoint }) => { + const handoff = stagedRecoveryEnabled && !recoveryGenerationDurable; + if (handoff) { + if (!(await checkpointOnce())) { + throw new Error( + 'Could not checkpoint restored embeddings before recovery handoff.', + ); + } + recoveryGenerationDurable = true; + } + activeWindowNodeIds = nodeIds; + for (const id of nodeIds) unsafeRecoveryNodeIds.add(id); await saveEmbeddingCheckpoint(checkpoint, nodeIds); + // Reclaim the old source only after the new durable generation's + // reference is saved. Later windows retain this same generation. + if (handoff) sweepStagingArtifacts(metaDir, log); }, // ── The mid-run count is a DIAGNOSTIC, not a gate (#2790) ────── // This used to run the count query bare. THIS callback's rejection @@ -4598,7 +4724,12 @@ async function runFullAnalysisInner( // touch stats.embeddings" signal — so the checkpoint still lands, // with whatever count is already on disk left alone. onCheckpoint: async (checkpoint) => { - await checkpointOnce(); + const durable = await checkpointOnce(); + if (stagedRecoveryEnabled && !durable) { + throw new Error('Could not checkpoint the completed embedding window for recovery.'); + } + for (const id of activeWindowNodeIds) unsafeRecoveryNodeIds.delete(id); + activeWindowNodeIds = []; const measured = await measurePersistedEmbeddingCount(executeQuery); if (measured.kind === 'unknown') { log( @@ -5085,6 +5216,7 @@ async function runFullAnalysisInner( // is a crash-safety improvement: a failed swap leaves the previous index // live and the next run recovers via the full-rebuild path. await saveMeta(metaDir, meta); + sweepStagingArtifacts(metaDir, log); // Registry freshness is published only after the graph and its metadata. // A failed close, swap, or metadata save must leave the previous registry @@ -5294,7 +5426,13 @@ async function runFullAnalysisInner( // rethrow below is the surface, and the lock's sweep remains the backstop. if (useAtomicSwap && buildPath !== lbugPath) { try { - await wipeLbugDbFiles(buildPath); + const recovery = resolveEmbeddingRecovery( + metaDir, + (await loadMeta(metaDir))?.embeddingCheckpoint, + ); + // Both paths belong to this locked slot. The validated generation + // basename identifies the same file even through a directory alias. + if (recovery?.stagingFile !== path.basename(buildPath)) await wipeLbugDbFiles(buildPath); } catch { /* swallow — orphan reclamation must never mask the real failure */ } @@ -5306,6 +5444,12 @@ async function runFullAnalysisInner( // IndexLockTimeoutError and other domain failures with `instanceof`. recordLiveIndexMutationRisk(err); } + if (/max(?:imum)?(?: database| db)? size|database size limit|maxDBSize/i.test(String(err))) { + log( + 'The database size limit was reached. Set GITNEXUS_LBUG_MAX_DB_SIZE to a larger ' + + 'byte limit before retrying analyze; retained complete embeddings can be reused.', + ); + } throw err; } } diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 86cafb0c1..f4cb39c66 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -12,6 +12,7 @@ import { acquireIndexLock, IndexLockTimeoutError, requireExclusiveIndexLock, + sweepStagingArtifacts, type IndexLockHandle, } from '../storage/index-lock.js'; import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js'; @@ -2152,11 +2153,21 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // for the whole embedding write, released in the finally below. let slotLock: IndexLockHandle | undefined; try { - slotLock = await acquireIndexLock(storagePath); + slotLock = await acquireIndexLock(storagePath, { sweep: false }); requireExclusiveIndexLock( slotLock, `Cannot acquire the index lock at ${storagePath}; refusing an unlocked embedding run.`, ); + // This writer cannot recover staged generations. Preserve their + // receipts, including malformed ones, before sweeping or writing. + const recoveryCheckpoint = (await loadMeta(storagePath))?.embeddingCheckpoint; + if (recoveryCheckpoint && Object.hasOwn(recoveryCheckpoint, 'recovery')) { + throw new Error( + 'Cannot generate embeddings: the index checkpoint references staged embeddings. ' + + 'Run `gitnexus analyze` to recover them first.', + ); + } + sweepStagingArtifacts(storagePath); // Writes go to the slot's own graph; a shared-store checkout // reading an immutable commit graph (#3352) takes a private copy. if (!(await ensurePrivateSharedGraph(storagePath, () => {}))) { diff --git a/gitnexus/src/storage/embedding-recovery.ts b/gitnexus/src/storage/embedding-recovery.ts new file mode 100644 index 000000000..54821bcc7 --- /dev/null +++ b/gitnexus/src/storage/embedding-recovery.ts @@ -0,0 +1,167 @@ +/** + * Filesystem-only staged embedding provenance. Keep this independent of native + * and model imports: every index-lock caller needs the retention decision. + */ +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readFileSync, + realpathSync, +} from 'node:fs'; +import path from 'node:path'; +import type { EmbeddingRecoveryReference } from './repo-meta.js'; +import { INDEX_METADATA_FILE, LEGACY_METADATA_FILE } from './storage-constants.js'; + +const STAGING_FILENAME = + /^lbug\.staging\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +export const FAMILY_SUFFIXES = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', +] as const; + +export interface ResolvedEmbeddingRecovery extends EmbeddingRecoveryReference { + dbPath: string; + /** Exact basenames, never a prefix match that can preserve another generation. */ + familyFiles: string[]; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +const isNonemptyString = (value: unknown): value is string => + typeof value === 'string' && value.length > 0; +const isNodeIds = (value: unknown): value is string[] => + Array.isArray(value) && value.every(isNonemptyString); +const isCount = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + +/** + * Resolve only an explicit interrupted-generation receipt in this canonical + * slot. This validates provenance, not native contents or current identity; + * those checks must pass separately before any rows can be reused. + */ +export const resolveEmbeddingRecovery = ( + lockDir: string, + checkpoint: unknown, +): ResolvedEmbeddingRecovery | undefined => { + if (!isRecord(checkpoint) || !isRecord(checkpoint.recovery)) return undefined; + if (checkpoint.kind !== undefined && checkpoint.kind !== 'interrupted') return undefined; + if ( + !isNonemptyString(checkpoint.at) || + !Number.isFinite(Date.parse(checkpoint.at)) || + !isCount(checkpoint.nodesProcessed) || + !isCount(checkpoint.totalNodes) || + checkpoint.nodesProcessed > checkpoint.totalNodes || + !isCount(checkpoint.chunksProcessed) || + !isNonemptyString(checkpoint.model) || + !isCount(checkpoint.dimensions) || + checkpoint.dimensions === 0 || + !isNonemptyString(checkpoint.provider) || + (checkpoint.pendingNodeIds !== undefined && !isNodeIds(checkpoint.pendingNodeIds)) + ) { + return undefined; + } + const recovery = checkpoint.recovery; + if ( + !isNonemptyString(recovery.stagingFile) || + !STAGING_FILENAME.test(recovery.stagingFile) || + !isNonemptyString(recovery.schemaFingerprint) || + !isNodeIds(recovery.unsafeNodeIds) + ) { + return undefined; + } + const unsafeNodeIds = new Set(recovery.unsafeNodeIds); + if ( + isNodeIds(checkpoint.pendingNodeIds) && + checkpoint.pendingNodeIds.some((nodeId) => !unsafeNodeIds.has(nodeId)) + ) { + return undefined; + } + + try { + const canonicalDir = realpathSync(lockDir); + if (!lstatSync(canonicalDir).isDirectory()) return undefined; + const familyFiles = FAMILY_SUFFIXES.map((suffix) => recovery.stagingFile + suffix); + for (const [index, filename] of familyFiles.entries()) { + try { + // lstat refuses both live and dangling symlinks, without following one + // to a database outside the slot. The base file must exist. + if (!lstatSync(path.join(canonicalDir, filename)).isFile()) return undefined; + } catch (error) { + if (index > 0 && (error as NodeJS.ErrnoException).code === 'ENOENT') continue; + return undefined; + } + } + return { + dbPath: path.join(canonicalDir, recovery.stagingFile), + stagingFile: recovery.stagingFile, + schemaFingerprint: recovery.schemaFingerprint, + unsafeNodeIds: [...unsafeNodeIds], + familyFiles, + }; + } catch { + return undefined; + } +}; + +/** Synchronous mirror of loadMeta's primary-first, absent-only fallback rule. */ +export const readEmbeddingRecovery = (lockDir: string): ResolvedEmbeddingRecovery | undefined => { + let metadataPath = path.join(lockDir, INDEX_METADATA_FILE); + let descriptor: number | undefined; + const noFollow = constants.O_NOFOLLOW ?? 0; + const flags = constants.O_RDONLY | noFollow | (constants.O_NONBLOCK ?? 0); + try { + try { + descriptor = openSync(metadataPath, flags); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') return undefined; + // Windows cannot open with O_NOFOLLOW: an open of a dangling symlink + // reports ENOENT, but that existing primary entry must prevent fallback. + try { + lstatSync(metadataPath); + return undefined; + } catch (statError) { + const statCode = (statError as NodeJS.ErrnoException).code; + if (statCode !== 'ENOENT' && statCode !== 'ENOTDIR') return undefined; + } + metadataPath = path.join(lockDir, LEGACY_METADATA_FILE); + descriptor = openSync(metadataPath, flags); + } + const opened = fstatSync(descriptor, { bigint: true }); + const entry = lstatSync(metadataPath, { bigint: true }); + // Check the opened file itself and match the current non-symlink entry. + // This also refuses replacement on platforms without O_NOFOLLOW. + if ( + !opened.isFile() || + !entry.isFile() || + (noFollow === 0 && opened.ino === 0n) || + opened.dev !== entry.dev || + opened.ino !== entry.ino + ) { + return undefined; + } + const meta: unknown = JSON.parse(readFileSync(descriptor, 'utf8')); + if (!isRecord(meta)) return undefined; + // storagePath describes the flat/cache root, including in branch-slot + // metadata. The current locked directory is the generation boundary. + return resolveEmbeddingRecovery(lockDir, meta.embeddingCheckpoint); + } catch { + return undefined; + } finally { + if (descriptor !== undefined) { + try { + closeSync(descriptor); + } catch { + /* best-effort */ + } + } + } +}; diff --git a/gitnexus/src/storage/index-lock.ts b/gitnexus/src/storage/index-lock.ts index 5588127e6..5d3b6cc47 100644 --- a/gitnexus/src/storage/index-lock.ts +++ b/gitnexus/src/storage/index-lock.ts @@ -62,6 +62,7 @@ import path from 'node:path'; import os from 'node:os'; import { randomBytes, randomUUID, createHash } from 'node:crypto'; import { isProcessAlive } from '../utils/process-identity.js'; +import { readEmbeddingRecovery } from './embedding-recovery.js'; const LOCK_FILENAME = 'analyze.lock'; const LOCK_RECORD_VERSION = 1 as const; @@ -433,8 +434,9 @@ const deniedCreateHandle = ( /** * Delete orphaned build/staging artifacts left in the lock directory by a * crashed prior writer. Safe precisely because we hold the exclusive lock: no - * other writer can be creating these here right now, so anything present is a - * crash orphan. Matches this slot's staging files ONLY — never `lbug` itself, + * other writer can be creating these here right now. A checkpoint-referenced + * generation is retained for embedding recovery; all other stages are orphans. + * Matches this slot's staging files ONLY — never `lbug` itself, * never `lbug.wal`/`lbug.shadow` (the LIVE index's own sidecars), and never a * `branches//` sub-slot (which owns its own lock + sweep). Non-recursive. */ @@ -442,6 +444,7 @@ export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => vo // Matches `lbug.new`, `lbug.new.wal`, `lbug.staging.`, `lbug.staging..wal`, … // Does NOT match `lbug`, `lbug.wal`, `lbug.shadow`. const stagingRe = /^lbug\.(staging\..+|new(\..+)?)$/; + const retained = new Set(readEmbeddingRecovery(lockDir)?.familyFiles ?? []); let removed = 0; let entries: string[]; try { @@ -450,7 +453,7 @@ export const sweepStagingArtifacts = (lockDir: string, log?: (msg: string) => vo return; } for (const name of entries) { - if (!stagingRe.test(name)) continue; + if (!stagingRe.test(name) || retained.has(name)) continue; try { unlinkSync(path.join(lockDir, name)); removed++; diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index 0c412dd7c..1562ee8c7 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -43,6 +43,15 @@ export type ContentRetention = 'full' | 'symbol' | 'none'; export type FtsProfile = 'full' | 'symbol-no-file-content' | 'name-only'; export const CONTENT_RETENTION_SCHEMA_VERSION = 1; +/** Exact staged generation whose completed embedding groups can survive a retry. */ +export interface EmbeddingRecoveryReference { + /** A run-minted basename within this metadata file's index slot. */ + stagingFile: string; + schemaFingerprint: string; + /** Active-window and inherited incomplete groups: never reusable until completed. */ + unsafeNodeIds: string[]; +} + /** * Versioned receipt for the analyzer process that produced an index. * @@ -521,6 +530,11 @@ export interface RepoMeta { * subset of their chunks; for `'partial'` they hold none. */ pendingNodeIds?: string[]; + /** + * Interrupted atomic builds only. This does not publish the staged graph + * or advance live embedding statistics; it identifies a recovery source. + */ + recovery?: EmbeddingRecoveryReference; }; /** * Name of the git branch this index represents (#2106). Absent for the diff --git a/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs b/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs new file mode 100644 index 000000000..68401a4b8 --- /dev/null +++ b/gitnexus/test/fixtures/staged-embedding-recovery/seed.mjs @@ -0,0 +1,52 @@ +import lbug from '@ladybugdb/core'; +import fs from 'node:fs'; +import { createLbugDatabase } from '../../../src/core/lbug/lbug-config.ts'; + +const [dbPath, mode] = process.argv.slice(2); +const db = createLbugDatabase(lbug, dbPath); +const conn = new lbug.Connection(db); +async function query(cypher) { + const queried = await conn.query(cypher); + for (const result of Array.isArray(queried) ? queried : [queried]) { + await result.getAll(); + await result.close(); + } +} +await query('CREATE NODE TABLE CodeEmbedding (id STRING, nodeId STRING, chunkIndex INT32, startLine INT64, endLine INT64, embedding FLOAT[2], contentHash STRING, PRIMARY KEY(id))'); +async function row(id, nodeId, chunkIndex, hash = 'same', startLine = 1, endLine = 3) { + await query(`CREATE (:CodeEmbedding {id: '${id}', nodeId: '${nodeId}', chunkIndex: ${chunkIndex}, startLine: ${startLine}, endLine: ${endLine}, embedding: [1.0, 2.0], contentHash: ${hash === null ? 'NULL' : `'${hash}'`}})`); +} +await row('complete-0', 'complete', 0); +await row('complete-1', 'complete', 1); +await row('other', 'other', 0); +await query('CHECKPOINT'); +if (mode === 'interrupted-checkpoint') { + await row('checkpoint-only', 'checkpoint-only', 0); + const main = fs.readFileSync(dbPath); + const wal = fs.readFileSync(`${dbPath}.wal`); + await conn.close(); + await db.close(); + // Restore the pre-close bytes: writable close has already checkpointed them. + fs.writeFileSync(dbPath, main); + fs.writeFileSync(`${dbPath}.wal.checkpoint`, wal); + fs.writeFileSync(`${dbPath}.wal`, ''); + fs.writeFileSync(`${dbPath}.shadow`, ''); + fs.writeFileSync(`${dbPath}.checkpoint.intent.lock`, ''); + fs.writeFileSync(`${dbPath}.checkpoint.apply.lock`, ''); +} else if (mode === 'hard-kill') { + await row('unsafe', 'unsafe-prefix', 0); + process.kill(process.pid, 'SIGKILL'); +} else { + await row('gap', 'gap', 1); + await row('duplicate-0', 'duplicate', 0); + await row('duplicate-1', 'duplicate', 0); + await row('mixed-0', 'mixed', 0, 'old'); + await row('mixed-1', 'mixed', 1, 'new'); + await row('missing-hash-0', 'missing-hash', 0); + await row('missing-hash-1', 'missing-hash', 1, null); + await row('bad-line', 'bad-line', 0, 'same', -1, 3); + await row('nan-0', 'nan', 0); + await query("CREATE (:CodeEmbedding {id: 'nan-1', nodeId: 'nan', chunkIndex: 1, startLine: 1, endLine: 3, embedding: [CAST('NaN', 'FLOAT'), 2.0], contentHash: 'same'})"); + await conn.close(); + await db.close(); +} diff --git a/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts b/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts new file mode 100644 index 000000000..dbb5b893e --- /dev/null +++ b/gitnexus/test/integration/analyze-staged-embedding-recovery.test.ts @@ -0,0 +1,449 @@ +/** + * Exercise the CLI -> native staged DB -> durable checkpoint -> SIGKILL -> + * isolated recovery -> graph rebuild -> publication chain. The endpoint is + * local and deterministic; request text is the billing/reuse oracle. + */ +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../helpers/cli-entry.js'; + +const DIMS = 8; +const NODE_COUNT = 5_128; +const DEADLINE = process.env.CI ? 180_000 : 120_000; +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const adapterUrl = pathToFileURL(path.join(packageRoot, 'src/core/lbug/lbug-adapter.ts')).href; + +interface CheckpointMeta { + repoPath: string; + stats?: { embeddings?: number }; + embeddingCheckpoint?: { + nodesProcessed: number; + chunksProcessed: number; + pendingNodeIds?: string[]; + recovery?: { stagingFile: string; schemaFingerprint: string; unsafeNodeIds: string[] }; + }; +} + +interface Run { + child: ChildProcess; + output: () => string; + done: Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }>; +} + +let root: string; +let stoppedRepo: string; +let server: http.Server; +let endpoint: string; +let submitted: string[] = []; +let completed: string[] = []; +let durableTexts: string[] = []; +const activeWindowCompleted: string[] = []; +let held: string[] = []; +let stopAfterCheckpoint = false; +let currentRepo: string; +let gateResolve: (() => void) | undefined; +const running = new Set(); + +function readMeta(repo: string): CheckpointMeta { + return JSON.parse(fs.readFileSync(path.join(repo, '.gitnexus', 'gitnexus.json'), 'utf8')); +} + +function cliEnv(repo: string): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith('GITNEXUS_EMBEDDING_') || key.startsWith('GITNEXUS_STORAGE_')) + delete env[key]; + } + return { + ...env, + GITNEXUS_HOME: path.join(root, `home-${path.basename(repo)}`), + GITNEXUS_SHARED_STORE: 'off', + GITNEXUS_LBUG_EXTENSION_INSTALL: 'never', + GITNEXUS_LBUG_BUFFER_POOL_SIZE: String(256 * 1024 * 1024), + GITNEXUS_EMBEDDING_URL: endpoint, + GITNEXUS_EMBEDDING_MODEL: 'staged-recovery-fixture', + GITNEXUS_EMBEDDING_DIMS: String(DIMS), + GITNEXUS_EMBEDDING_BATCH_SIZE: '5000', + GITNEXUS_EMBEDDING_SUB_BATCH_SIZE: '64', + GITNEXUS_EMBEDDING_MAX_ATTEMPTS: '1', + GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT: '0', + GITNEXUS_MEMORY: 'off', + // SIGKILL skips process exit hooks; keep orphaned cache/export spills in + // this suite's owned directory so afterAll can remove them as well. + TMPDIR: path.join(root, 'tmp'), + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=2048`.trim(), + CI: '1', + }; +} + +function runAnalyze(repo: string, flags: string[] = [], onOutput?: (output: string) => void): Run { + let output = ''; + const child = spawn( + process.execPath, + [ + ...CLI_SPAWN_PREFIX, + 'analyze', + repo, + '--no-share', + '--skip-skills', + '--skip-fts', + '--workers', + '1', + ...flags, + ], + { cwd: repo, env: cliEnv(repo), stdio: ['ignore', 'pipe', 'pipe'] }, + ); + running.add(child); + const timeout = setTimeout(() => child.kill('SIGKILL'), DEADLINE); + const collect = (chunk: Buffer) => { + output += chunk.toString(); + onOutput?.(output); + }; + child.stdout?.on('data', collect); + child.stderr?.on('data', collect); + const done = new Promise<{ code: number | null; signal: NodeJS.Signals | null; output: string }>( + (resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => { + clearTimeout(timeout); + running.delete(child); + resolve({ code, signal, output }); + }); + }, + ); + return { child, done, output: () => output }; +} + +async function successfulAnalyze(repo: string, flags: string[] = []): Promise { + submitted = []; + stopAfterCheckpoint = false; + currentRepo = repo; + const result = await runAnalyze(repo, flags).done; + expect(result.output, `CLI exited ${result.code}, signal ${result.signal}`).not.toContain( + 'SIGABRT', + ); + expect(result.code, result.output).toBe(0); + return [...submitted]; +} + +function cloneStoppedRepo(name: string): string { + const repo = path.join(root, name); + fs.cpSync(stoppedRepo, repo, { recursive: true }); + for (const filename of ['gitnexus.json', 'meta.json']) { + const target = path.join(repo, '.gitnexus', filename); + const meta = JSON.parse(fs.readFileSync(target, 'utf8')); + meta.repoPath = repo; + meta.storagePath = path.join(repo, '.gitnexus'); + fs.writeFileSync(target, JSON.stringify(meta)); + } + return repo; +} + +function readPublishedRows( + repo: string, +): Array<{ nodeId: string; chunkIndex: number; embedding: number[] }> { + // Keep native handles out of the vitest fork, and wait for clean teardown + // before accepting the receipt. Every read opens only a published DB. + const receiptPath = path.join(root, `rows-${path.basename(repo)}.json`); + const script = ` + const adapter = await import(${JSON.stringify(adapterUrl)}); + const fs = await import('node:fs'); + await adapter.initLbug(${JSON.stringify(path.join(repo, '.gitnexus', 'lbug'))}); + try { + const rows = await adapter.executeQuery('MATCH (e:CodeEmbedding) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.embedding AS embedding'); + fs.writeFileSync(${JSON.stringify(receiptPath)}, JSON.stringify(rows)); + console.log('ROWS_RECEIPT:' + rows.length); + } finally { await adapter.closeLbug(); } + `; + const result = spawnSync( + process.execPath, + ['--import', tsxLoaderUrl(), '--input-type=module', '-e', script], + { + cwd: packageRoot, + env: cliEnv(repo), + encoding: 'utf8', + timeout: 30_000, + }, + ); + try { + const diagnostic = + `${result.error ?? ''} ${result.signal ?? ''}\n${result.stderr}\n${result.stdout}`.slice( + 0, + 4096, + ); + expect(result.status, diagnostic).toBe(0); + expect(result.stdout).toMatch(/ROWS_RECEIPT:\d+/); + return JSON.parse(fs.readFileSync(receiptPath, 'utf8')); + } finally { + fs.rmSync(receiptPath, { force: true }); + } +} + +function expectPublishedComplete(repo: string, expectedNodes: number): void { + const meta = readMeta(repo); + expect(meta.embeddingCheckpoint).toBeUndefined(); + const rows = readPublishedRows(repo); + expect(new Set(rows.map((row) => row.nodeId)).size).toBe(expectedNodes); + expect(meta.stats?.embeddings).toBe(rows.length); + const byNode = new Map(); + for (const row of rows) { + expect(row.embedding).toHaveLength(DIMS); + expect(row.embedding.every(Number.isFinite)).toBe(true); + const indices = byNode.get(row.nodeId) ?? []; + indices.push(row.chunkIndex); + byNode.set(row.nodeId, indices); + } + for (const indices of byNode.values()) { + expect(indices.sort((a, b) => a - b)).toEqual( + Array.from({ length: indices.length }, (_, index) => index), + ); + } + expect( + fs.readdirSync(path.join(repo, '.gitnexus')).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([]); +} + +beforeAll(async () => { + if (process.platform === 'win32') return; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-staged-recovery-e2e-')); + fs.mkdirSync(path.join(root, 'tmp')); + stoppedRepo = path.join(root, 'interrupted'); + currentRepo = stoppedRepo; + fs.mkdirSync(stoppedRepo); + const shortFunctions = Array.from( + { length: NODE_COUNT - 1 }, + (_, index) => `export function recoverable${index}() { return ${index}; }`, + ); + // Multi-chunk nodes must be reused as a complete group, including their tail. + const longFunction = `export function longRecoverable() {\n${Array.from({ length: 80 }, (_, index) => ` // retained chunk marker ${index} ${'x'.repeat(80)}`).join('\n')}\n return 42;\n}`; + fs.writeFileSync( + path.join(stoppedRepo, 'functions.ts'), + `${longFunction}\n${shortFunctions.join('\n')}\n`, + ); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@test', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@test', + }; + for (const args of [['init'], ['add', 'functions.ts'], ['commit', '-m', 'recovery fixture']]) { + const result = spawnSync('git', args, { cwd: stoppedRepo, env: gitEnv, encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + } + server = http.createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + const { input } = JSON.parse(body) as { input: string[] }; + submitted.push(...input); + if ( + stopAfterCheckpoint && + (readMeta(currentRepo).embeddingCheckpoint?.nodesProcessed ?? 0) >= 5000 + ) { + if (activeWindowCompleted.length > 0) { + held = [...input]; + gateResolve?.(); + // One sub-batch has already inserted rows inside the unsafe window. + // Awaiting the next real request gates a crash before it completes. + return; + } + durableTexts = [...completed]; + activeWindowCompleted.push(...input); + } + completed.push(...input); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + data: input.map((text, index) => ({ + index, + embedding: Array.from( + { length: DIMS }, + (_, dimension) => ((text.length + dimension * 17) % 101) / 101, + ), + })), + }), + ); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address() as { port: number }; + endpoint = `http://127.0.0.1:${address.port}/v1`; + await successfulAnalyze(stoppedRepo, ['--index-only']); + expect(readMeta(stoppedRepo).stats?.embeddings ?? 0).toBe(0); + completed = []; + held = []; + stopAfterCheckpoint = true; + const gate = new Promise((resolve) => { + gateResolve = resolve; + }); + const first = runAnalyze(stoppedRepo, ['--force', '--embeddings']); + await Promise.race([ + gate, + first.done.then((result) => { + throw new Error(`CLI exited before recovery gate: ${result.output}`); + }), + ]); + first.child.kill('SIGKILL'); + expect((await first.done).signal).toBe('SIGKILL'); + const interrupted = readMeta(stoppedRepo); + expect(interrupted.embeddingCheckpoint?.nodesProcessed).toBe(5000); + expect(interrupted.embeddingCheckpoint?.recovery?.stagingFile).toMatch(/^lbug\.staging\./); + expect(interrupted.stats?.embeddings ?? 0).toBe(0); + expect(completed.length).toBeGreaterThanOrEqual(5000); + expect(completed.filter((text) => text.includes('longRecoverable')).length).toBeGreaterThan(1); + expect(activeWindowCompleted).toHaveLength(64); + expect(held.length).toBeGreaterThan(0); + gateResolve = undefined; + stopAfterCheckpoint = false; +}, DEADLINE * 2); + +afterAll(async () => { + for (const child of running) child.kill('SIGKILL'); + await Promise.all( + [...running].map( + (child) => new Promise((resolve) => child.once('close', () => resolve())), + ), + ); + if (server) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +// Atomic publication is POSIX-specific; Windows uses the in-place path. +describe + .skipIf(process.platform === 'win32') + .sequential('interrupted staged embedding recovery (real CLI and native DB)', () => { + it.each([ + ['plain', []], + ['forced', ['--force', '--embeddings']], + ] as const)( + '%s retry bills only unfinished groups and publishes an honest count', + async (name, flags) => { + const repo = cloneStoppedRepo(name); + const texts = await successfulAnalyze(repo, [...flags]); + const durable = new Set(durableTexts); + expect(texts.filter((text) => durable.has(text))).toEqual([]); + for (const text of held) expect(texts).toContain(text); + for (const text of activeWindowCompleted) expect(texts).toContain(text); + expect(texts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE, + ); + + it( + 'manual checkpoint opt-out completes a staged retry without resubmitting durable chunks', + async () => { + const repo = cloneStoppedRepo('manual-checkpoint-opt-out'); + const previous = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = '0'; + try { + const texts = await successfulAnalyze(repo, ['--force', '--embeddings']); + expect(texts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(texts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + } finally { + if (previous === undefined) delete process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT; + else process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT = previous; + } + }, + DEADLINE, + ); + + it( + 'survives another crash after harvesting but before the replacement is durable', + async () => { + const repo = cloneStoppedRepo('crash-again'); + const source = readMeta(repo).embeddingCheckpoint?.recovery?.stagingFile; + expect(source).toBeDefined(); + if (!source) throw new Error('fixture has no retained generation'); + submitted = []; + currentRepo = repo; + let killed = false; + const retry = runAnalyze(repo, [], (output) => { + if (!killed && /Recovered \d+ complete staged embedding chunk/.test(output)) { + killed = true; + retry.child.kill('SIGKILL'); + } + }); + const result = await retry.done; + expect(killed, result.output).toBe(true); + expect(result.signal).toBe('SIGKILL'); + expect(submitted).toEqual([]); + expect(readMeta(repo).embeddingCheckpoint?.recovery?.stagingFile).toBe(source); + expect(fs.existsSync(path.join(repo, '.gitnexus', source))).toBe(true); + const finalTexts = await successfulAnalyze(repo); + expect(finalTexts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(finalTexts.length).toBe(128); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE * 2, + ); + + it( + 'regenerates changed content and removes deleted nodes while reusing the other complete groups', + async () => { + const repo = cloneStoppedRepo('changed'); + const source = path.join(repo, 'functions.ts'); + const content = fs + .readFileSync(source, 'utf8') + .replace( + 'export function recoverable5() { return 5; }', + 'export function recoverable5() { return 999999; }', + ) + .replace( + 'export function recoverable6() { return 6; }', + '// deleted function retains line offsets', + ); + fs.writeFileSync(source, content); + const texts = await successfulAnalyze(repo); + expect(texts.some((text) => text.includes('recoverable5') && text.includes('999999'))).toBe( + true, + ); + expect(texts.some((text) => text.includes('recoverable6'))).toBe(false); + expect(texts.filter((text) => new Set(durableTexts).has(text))).toEqual([]); + expect(texts.length).toBe(129); + expectPublishedComplete(repo, NODE_COUNT - 1); + }, + DEADLINE, + ); + + it( + 'a forced retry with a different model does not import the staged cache', + async () => { + const repo = cloneStoppedRepo('different-model'); + const texts = await successfulAnalyze(repo, [ + '--force', + '--embeddings', + '--embedding-model', + 'different-model', + ]); + for (const text of durableTexts) expect(texts).toContain(text); + expect(texts.length).toBeGreaterThanOrEqual(NODE_COUNT); + expectPublishedComplete(repo, NODE_COUNT); + }, + DEADLINE, + ); + + it( + 'explicit drop abandons staged vectors without contacting the provider', + async () => { + const repo = cloneStoppedRepo('drop'); + expect(await successfulAnalyze(repo, ['--force', '--drop-embeddings'])).toEqual([]); + expect(readMeta(repo).embeddingCheckpoint).toBeUndefined(); + expect(readMeta(repo).stats?.embeddings ?? 0).toBe(0); + expect(readPublishedRows(repo)).toEqual([]); + }, + DEADLINE, + ); + }); diff --git a/gitnexus/test/integration/staged-embedding-recovery.test.ts b/gitnexus/test/integration/staged-embedding-recovery.test.ts new file mode 100644 index 000000000..0a52c67ab --- /dev/null +++ b/gitnexus/test/integration/staged-embedding-recovery.test.ts @@ -0,0 +1,208 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + disposeEmbeddingSpill, + materializeCachedEmbeddings, + type CachedEmbeddingsSnapshot, +} from '../../src/core/embeddings/embedding-restore-spill.js'; +import { recoverStagedEmbeddings } from '../../src/core/embeddings/staged-embedding-recovery.js'; + +describe('isolated native staged embedding recovery', () => { + let tmp: string | undefined; + let recovered: CachedEmbeddingsSnapshot | undefined; + afterEach(() => { + disposeEmbeddingSpill(recovered?.spill); + recovered = undefined; + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; + }); + function stagePath() { + tmp ??= fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-native-')); + return path.join(tmp, `lbug.staging.${randomUUID()}`); + } + function seed(dbPath: string, mode = 'clean') { + const result = spawnSync( + process.execPath, + [ + '--import', + 'tsx', + fileURLToPath(new URL('../fixtures/staged-embedding-recovery/seed.mjs', import.meta.url)), + dbPath, + mode, + ], + { + encoding: 'utf8', + timeout: 20_000, + env: { ...process.env, GITNEXUS_LBUG_BUFFER_POOL_SIZE: String(128 * 1024 * 1024) }, + }, + ); + expect(result.error, result.stderr).toBeUndefined(); + if (mode === 'hard-kill') expect(result.signal, result.stderr).toBe('SIGKILL'); + else expect(result.status, result.stderr).toBe(0); + } + + function snapshotSourceFamily(dbPath: string) { + const basename = path.basename(dbPath); + return Object.fromEntries( + fs + .readdirSync(path.dirname(dbPath)) + .filter((name) => name === basename || name.startsWith(`${basename}.`)) + .sort() + .map((name) => [name, fs.readFileSync(path.join(path.dirname(dbPath), name))]), + ); + } + + it('streams complete same-hash groups and rejects malformed whole nodes', async () => { + const dbPath = stagePath(); + seed(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 2 }); + expect([...recovered.embeddingNodeIds].sort()).toEqual(['complete', 'other']); + expect(recovered.rows).toHaveLength(3); + expect(recovered.embeddings).toEqual([]); + expect(materializeCachedEmbeddings(recovered, recovered.rows)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + nodeId: 'complete', + chunkIndex: 0, + embedding: [1, 2], + contentHash: 'same', + }), + expect.objectContaining({ + nodeId: 'complete', + chunkIndex: 1, + embedding: [1, 2], + contentHash: 'same', + }), + ]), + ); + expect(fs.existsSync(dbPath)).toBe(true); + }, 30_000); + + it('replays a hard-killed native writer strictly and excludes the incomplete active window', async () => { + const dbPath = stagePath(); + seed(dbPath, 'hard-kill'); + const sourceBefore = snapshotSourceFamily(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { + dimensions: 2, + excludedNodeIds: ['unsafe-prefix', 'other'], + }); + expect([...recovered.embeddingNodeIds]).toEqual(['complete']); + expect(recovered.rows).toHaveLength(2); + expect( + materializeCachedEmbeddings(recovered, recovered.rows) + .map((row) => row.chunkIndex) + .sort(), + ).toEqual([0, 1]); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('rejects vectors from a different dimension instead of coercing them', async () => { + const dbPath = stagePath(); + seed(dbPath); + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 3 }); + expect(recovered.rows).toEqual([]); + }, 30_000); + + it('strictly replays an interrupted checkpoint copy with both checkpoint locks retained', async (ctx) => { + const version = JSON.parse( + fs.readFileSync( + new URL('../../node_modules/@ladybugdb/core/package.json', import.meta.url), + 'utf8', + ), + ).version as string; + const [major, minor] = version.split('.').map(Number); + // Older pins do not support the deterministic interrupted-checkpoint plant. + if (Number.isFinite(major) && Number.isFinite(minor) && major === 0 && minor < 19) ctx.skip(); + const dbPath = stagePath(); + seed(dbPath, 'interrupted-checkpoint'); + const sourceBefore = snapshotSourceFamily(dbPath); + expect(fs.statSync(`${dbPath}.wal.checkpoint`).size).toBeGreaterThan(0); + expect(fs.existsSync(`${dbPath}.checkpoint.intent.lock`)).toBe(true); + expect(fs.existsSync(`${dbPath}.checkpoint.apply.lock`)).toBe(true); + + recovered = await recoverStagedEmbeddings(dbPath, { dimensions: 2 }); + + expect([...recovered.embeddingNodeIds].sort()).toEqual([ + 'checkpoint-only', + 'complete', + 'other', + ]); + expect(recovered.rows).toHaveLength(4); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('contains a malformed native source in a subprocess and preserves it', async () => { + const dbPath = stagePath(); + fs.writeFileSync(dbPath, 'not a ladybug database'); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /extraction failed/, + ); + expect(fs.readFileSync(dbPath, 'utf8')).toBe('not a ladybug database'); + }, 30_000); + + it('rejects a malformed WAL without deleting or quarantining it to reopen the source', async () => { + const dbPath = stagePath(); + seed(dbPath, 'hard-kill'); + const walPath = `${dbPath}.wal`; + fs.writeFileSync(walPath, Buffer.alloc(128, 0xff)); + const sourceBefore = snapshotSourceFamily(dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /extraction failed/, + ); + expect(fs.existsSync(walPath)).toBe(true); + expect(fs.readdirSync(path.dirname(dbPath)).some((name) => /bad|quarantine/i.test(name))).toBe( + false, + ); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('does not create a missing source and refuses a symlink', async () => { + const dbPath = stagePath(); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow(/ENOENT/); + expect(fs.existsSync(dbPath)).toBe(false); + const realPath = path.join(path.dirname(dbPath), 'real'); + fs.writeFileSync(realPath, 'fixture'); + fs.symlinkSync(realPath, dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2 })).rejects.toThrow( + /regular file/, + ); + }); + + it('can kill a timed out native subprocess without aborting analyze', async () => { + const dbPath = stagePath(); + seed(dbPath); + const sourceBefore = snapshotSourceFamily(dbPath); + await expect(recoverStagedEmbeddings(dbPath, { dimensions: 2, timeoutMs: 1 })).rejects.toThrow( + /timeout/, + ); + expect(fs.existsSync(dbPath)).toBe(true); + expect(snapshotSourceFamily(dbPath)).toEqual(sourceBefore); + }, 30_000); + + it('loads the source child when analyze runs in another repository directory', () => { + const dbPath = stagePath(); + seed(dbPath); + const result = spawnSync( + process.execPath, + [ + '--import', + import.meta.resolve('tsx'), + '--input-type=module', + '-e', + 'const { recoverStagedEmbeddings } = await import(process.argv[1]); const { disposeEmbeddingSpill } = await import(process.argv[2]); const recovered = await recoverStagedEmbeddings(process.argv[3], {dimensions: 2}); process.stdout.write(String(recovered.rows.length)); disposeEmbeddingSpill(recovered.spill);', + new URL('../../src/core/embeddings/staged-embedding-recovery.ts', import.meta.url).href, + new URL('../../src/core/embeddings/embedding-restore-spill.ts', import.meta.url).href, + dbPath, + ], + { cwd: path.dirname(dbPath), encoding: 'utf8', timeout: 20_000 }, + ); + expect(result.error, result.stderr).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe('3'); + }, 30_000); +}); diff --git a/gitnexus/test/unit/api-fts-mode.test.ts b/gitnexus/test/unit/api-fts-mode.test.ts index 632bd37d3..ab8eaf401 100644 --- a/gitnexus/test/unit/api-fts-mode.test.ts +++ b/gitnexus/test/unit/api-fts-mode.test.ts @@ -7,7 +7,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } const mocks = vi.hoisted(() => ({ loadMeta: vi.fn(), + saveMeta: vi.fn(), listRegisteredRepos: vi.fn(), + acquireIndexLock: vi.fn(), + releaseIndexLock: vi.fn(), + ensurePrivateSharedGraph: vi.fn(), + runEmbeddingPipeline: vi.fn(), withLbugDb: vi.fn(), search: vi.fn(), updateJob: vi.fn(), @@ -16,8 +21,19 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ ...(await importOriginal()), loadMeta: mocks.loadMeta, + saveMeta: mocks.saveMeta, listRegisteredRepos: mocks.listRegisteredRepos, })); +vi.mock('../../src/storage/index-lock.js', async (importOriginal) => ({ + ...(await importOriginal()), + acquireIndexLock: mocks.acquireIndexLock, +})); +vi.mock('../../src/core/shared-store-analyze.js', () => ({ + ensurePrivateSharedGraph: mocks.ensurePrivateSharedGraph, +})); +vi.mock('../../src/core/embeddings/embedding-pipeline.js', () => ({ + runEmbeddingPipeline: mocks.runEmbeddingPipeline, +})); vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({ ...(await importOriginal()), requireRegisteredStoragePath: vi.fn(async (entry: { storagePath: string }) => entry.storagePath), @@ -116,6 +132,19 @@ afterAll(() => { beforeEach(() => { vi.clearAllMocks(); + mocks.acquireIndexLock.mockResolvedValue({ + release: mocks.releaseIndexLock, + record: { + v: 1, + pid: process.pid, + hostname: 'test-host', + startTime: null, + token: 'test-lock', + invocationId: 'test-run', + acquiredAt: '', + }, + }); + mocks.ensurePrivateSharedGraph.mockResolvedValue(true); mocks.listRegisteredRepos.mockResolvedValue([entry]); mocks.withLbugDb.mockImplementation(async (_path, callback) => callback()); mocks.search.mockImplementation(async (_query, _limit, _exec, reason) => ({ @@ -124,6 +153,145 @@ beforeEach(() => { })); }); +describe('POST /api/embed staged recovery preflight', () => { + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(entry.storagePath, { recursive: true, force: true }); + fs.mkdirSync(entry.storagePath, { recursive: true }); + }); + + async function useRealIndexLock() { + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const actual = await vi.importActual( + '../../src/storage/index-lock.js', + ); + mocks.acquireIndexLock.mockImplementation( + async (...args: Parameters) => { + const lock = await actual.acquireIndexLock(...args); + return { + ...lock, + release: () => { + lock.release(); + mocks.releaseIndexLock(); + }, + }; + }, + ); + } + + it.each([ + { + name: 'valid staged receipt', + recovery: { + stagingFile: 'lbug.staging.12345678-1234-4123-8123-123456789abc', + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['n2', 'inherited-window-node'], + }, + }, + { name: 'null receipt', recovery: null }, + { name: 'malformed receipt', recovery: { stagingFile: 'invalid' } }, + { name: 'false receipt', recovery: false }, + ])('preserves a $name and releases the job locks', async ({ recovery }) => { + await useRealIndexLock(); + const lockPath = path.join(entry.storagePath, 'analyze.lock'); + const lbugPath = path.join(entry.storagePath, 'lbug'); + const metaPath = path.join(entry.storagePath, 'gitnexus.json'); + const sourcePath = path.join( + entry.storagePath, + 'lbug.staging.12345678-1234-4123-8123-123456789abc', + ); + fs.writeFileSync(lbugPath, 'published graph'); + fs.writeFileSync(sourcePath, 'completed paid vectors'); + fs.writeFileSync(`${sourcePath}.wal`, 'unfinished window'); + const metadataBytes = JSON.stringify({ + repoPath: entry.path, + lastCommit: 'abc123', + indexedAt: '2026-01-01T00:00:00.000Z', + stats: { embeddings: 7 }, + embeddingCheckpoint: { + at: '2026-01-01T00:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 768, + provider: 'local', + kind: 'interrupted', + pendingNodeIds: ['n2'], + recovery, + }, + }); + fs.writeFileSync(metaPath, metadataBytes); + mocks.loadMeta.mockImplementation(async () => { + expect(fs.existsSync(lockPath)).toBe(true); + return JSON.parse(fs.readFileSync(metaPath, 'utf8')); + }); + mocks.withLbugDb.mockResolvedValue(undefined); + + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(1)); + + expect(mocks.updateJob).toHaveBeenCalledWith( + 'embed-job', + expect.objectContaining({ + status: 'failed', + error: expect.stringMatching( + /staged embeddings.*Run `gitnexus analyze` to recover them first/, + ), + }), + ); + expect(mocks.acquireIndexLock).toHaveBeenCalledWith(entry.storagePath, { sweep: false }); + expect(mocks.loadMeta.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.acquireIndexLock.mock.invocationCallOrder[0]!, + ); + expect(mocks.ensurePrivateSharedGraph).not.toHaveBeenCalled(); + expect(mocks.withLbugDb).not.toHaveBeenCalled(); + expect(mocks.runEmbeddingPipeline).not.toHaveBeenCalled(); + expect(mocks.saveMeta).not.toHaveBeenCalled(); + expect(fs.existsSync(lockPath)).toBe(false); + expect(fs.readFileSync(metaPath, 'utf8')).toBe(metadataBytes); + expect(fs.readFileSync(lbugPath, 'utf8')).toBe('published graph'); + expect(fs.readFileSync(sourcePath, 'utf8')).toBe('completed paid vectors'); + expect(fs.readFileSync(`${sourcePath}.wal`, 'utf8')).toBe('unfinished window'); + + // A second accepted job proves the in-memory repo lock was also released. + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(2)); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('sweeps orphaned staging files before a writable job without a recovery receipt', async () => { + await useRealIndexLock(); + const metaPath = path.join(entry.storagePath, 'gitnexus.json'); + fs.writeFileSync(metaPath, JSON.stringify({ repoPath: entry.path })); + const sourcePath = path.join( + entry.storagePath, + 'lbug.staging.12345678-1234-4123-8123-123456789abc', + ); + fs.writeFileSync(sourcePath, 'orphaned database'); + fs.writeFileSync(`${sourcePath}.wal`, 'orphaned WAL'); + mocks.loadMeta.mockImplementation(async () => JSON.parse(fs.readFileSync(metaPath, 'utf8'))); + mocks.ensurePrivateSharedGraph.mockImplementation(async () => { + expect(fs.existsSync(path.join(entry.storagePath, 'analyze.lock'))).toBe(true); + expect(fs.existsSync(sourcePath)).toBe(false); + expect(fs.existsSync(`${sourcePath}.wal`)).toBe(false); + return true; + }); + mocks.withLbugDb.mockResolvedValue(undefined); + + await invoke('/api/embed'); + await vi.waitFor(() => expect(mocks.releaseIndexLock).toHaveBeenCalledTimes(1)); + + expect(mocks.updateJob).toHaveBeenCalledWith( + 'embed-job', + expect.objectContaining({ status: 'complete' }), + ); + expect(mocks.ensurePrivateSharedGraph).toHaveBeenCalledTimes(1); + expect(mocks.withLbugDb).toHaveBeenCalledTimes(1); + expect(fs.existsSync(path.join(entry.storagePath, 'analyze.lock'))).toBe(false); + }); +}); + async function invoke(route: string, query: Record = {}) { const layer = app.router.stack.find((item: any) => item.route?.path === route); expect(layer, route).toBeDefined(); @@ -257,7 +425,9 @@ describe('serve uses one metadata-derived FTS mode on every DB-open path', () => expect.any(Function), skip ? { skipFts: true } : {}, ); - expect(mocks.loadMeta).toHaveBeenCalledExactlyOnceWith(entry.storagePath); + expect(mocks.loadMeta).toHaveBeenCalledTimes(2); + expect(mocks.loadMeta).toHaveBeenNthCalledWith(1, entry.storagePath); + expect(mocks.loadMeta).toHaveBeenNthCalledWith(2, entry.storagePath); }, ); }); diff --git a/gitnexus/test/unit/embedding-recovery-race.test.ts b/gitnexus/test/unit/embedding-recovery-race.test.ts new file mode 100644 index 000000000..145f860ec --- /dev/null +++ b/gitnexus/test/unit/embedding-recovery-race.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { readEmbeddingRecovery } from '../../src/storage/embedding-recovery.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + constants: { ...actual.constants }, + lstatSync: vi.fn(actual.lstatSync), + openSync: vi.fn(actual.openSync), + fstatSync: vi.fn(actual.fstatSync), + readFileSync: vi.fn(actual.readFileSync), + closeSync: vi.fn(actual.closeSync), + }; +}); + +const actual = await vi.importActual('node:fs'); +const stagingFile = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; +const receipt = { + embeddingCheckpoint: { + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 2, + provider: 'local', + recovery: { stagingFile, schemaFingerprint: 'test-schema', unsafeNodeIds: [] }, + }, +}; + +let dir: string; +let metadataPath: string; +beforeEach(() => { + vi.mocked(fs.lstatSync).mockImplementation(actual.lstatSync); + vi.mocked(fs.openSync).mockImplementation(actual.openSync); + vi.mocked(fs.fstatSync).mockImplementation(actual.fstatSync); + vi.mocked(fs.readFileSync).mockImplementation(actual.readFileSync); + vi.mocked(fs.closeSync).mockImplementation(actual.closeSync); + Object.assign(fs.constants, actual.constants); + vi.clearAllMocks(); + dir = actual.mkdtempSync(path.join(os.tmpdir(), 'gnx-recovery-metadata-race-')); + metadataPath = path.join(dir, 'gitnexus.json'); + actual.writeFileSync(path.join(dir, stagingFile), 'stage'); +}); +afterEach(() => actual.rmSync(dir, { recursive: true, force: true })); + +describe('readEmbeddingRecovery metadata races', () => { + it('does not read replacement metadata after checking the original file', () => { + actual.writeFileSync(metadataPath, JSON.stringify({ embeddingCheckpoint: null })); + let replaced = false; + vi.mocked(fs.lstatSync).mockImplementation((...args) => { + const stat = actual.lstatSync(...args); + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.renameSync(metadataPath, path.join(dir, 'original-metadata.json')); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + } + return stat; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + const descriptor = vi.mocked(fs.openSync).mock.results[0]?.value; + expect(typeof descriptor).toBe('number'); + expect(fs.readFileSync).toHaveBeenCalledWith(descriptor, 'utf8'); + expect(fs.closeSync).toHaveBeenCalledWith(descriptor); + }); + + it('rejects a file replaced between opening and checking its identity', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + const descriptor = actual.openSync(...args); + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.renameSync(metadataPath, path.join(dir, 'original-metadata.json')); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + } + return descriptor; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('reads regular metadata when no-follow opens are unavailable', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('refuses unverifiable file identity when no-follow opens are unavailable', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + vi.mocked(fs.fstatSync).mockImplementation((...args) => { + const stat = actual.fstatSync(...args); + Object.defineProperty(stat, 'ino', { value: 0n }); + return stat; + }); + vi.mocked(fs.lstatSync).mockImplementation((...args) => { + const stat = actual.lstatSync(...args); + Object.defineProperty(stat, 'ino', { value: 0n }); + return stat; + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('rejects a symlink introduced before opening without no-follow support', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.writeFileSync(metadataPath, JSON.stringify({ embeddingCheckpoint: null })); + const target = path.join(dir, 'foreign-metadata.json'); + actual.writeFileSync(target, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.rmSync(metadataPath); + actual.symlinkSync(target, metadataPath); + } + return actual.openSync(...args); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('never falls back from a dangling primary symlink without no-follow support', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + actual.symlinkSync(path.join(dir, 'missing-metadata.json'), metadataPath); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + }); + + it('rejects a symlinked legacy receipt when the primary is absent', () => { + Object.assign(fs.constants, { O_NOFOLLOW: 0, O_NONBLOCK: 0 }); + const target = path.join(dir, 'foreign-metadata.json'); + actual.writeFileSync(target, JSON.stringify(receipt)); + actual.symlinkSync(target, path.join(dir, 'meta.json')); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('closes a descriptor when fstat fails without falling back to legacy metadata', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + vi.mocked(fs.fstatSync).mockImplementationOnce(() => { + throw Object.assign(new Error('stat failed'), { code: 'EIO' }); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it('closes a descriptor when JSON parsing fails without falling back', () => { + actual.writeFileSync(metadataPath, '{'); + actual.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(receipt)); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); + + it.skipIf(process.platform === 'win32')('rejects a substituted FIFO without blocking', () => { + actual.writeFileSync(metadataPath, JSON.stringify(receipt)); + let replaced = false; + vi.mocked(fs.openSync).mockImplementation((...args) => { + if (args[0] === metadataPath && !replaced) { + replaced = true; + actual.rmSync(metadataPath); + execFileSync('mkfifo', [metadataPath]); + } + return actual.openSync(...args); + }); + + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + expect(replaced).toBe(true); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.closeSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/gitnexus/test/unit/embedding-recovery.test.ts b/gitnexus/test/unit/embedding-recovery.test.ts new file mode 100644 index 000000000..a8f1064a0 --- /dev/null +++ b/gitnexus/test/unit/embedding-recovery.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + readEmbeddingRecovery, + resolveEmbeddingRecovery, +} from '../../src/storage/embedding-recovery.js'; + +const stagingFile = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; +const familySuffixes = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', +]; +const checkpoint = () => ({ + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 3, + chunksProcessed: 2, + model: 'test-model', + dimensions: 2, + provider: 'local', + pendingNodeIds: ['active'], + recovery: { + stagingFile, + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['active', 'incomplete-restore'], + }, +}); + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-embedding-recovery-')); + writeFileSync(path.join(dir, stagingFile), 'stage'); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('resolveEmbeddingRecovery', () => { + it('resolves the exact staged generation and carries all unsafe node IDs', () => { + expect(resolveEmbeddingRecovery(dir, checkpoint())).toEqual({ + ...checkpoint().recovery, + dbPath: path.join(dir, stagingFile), + familyFiles: familySuffixes.map((suffix) => stagingFile + suffix), + }); + }); + + it.each([ + '../lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + '/tmp/lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + 'branches/foreign/lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + '..\\lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46', + 'lbug', + 'lbug.new', + 'lbug.staging.orphan', + `${stagingFile}.wal`, + 'lbug.staging.00000000-0000-4000-8000-000000000000', + ])('rejects a foreign, unrecognized or missing generation: %s', (filename) => { + const marker = checkpoint(); + marker.recovery.stagingFile = filename; + expect(resolveEmbeddingRecovery(dir, marker)).toBeUndefined(); + }); + + it.each([ + null, + [], + { recovery: checkpoint().recovery }, + { ...checkpoint(), kind: 'partial' }, + { ...checkpoint(), kind: 'unverified-count' }, + { ...checkpoint(), kind: 'unknown' }, + { ...checkpoint(), at: 'invalid-time' }, + { ...checkpoint(), nodesProcessed: -1 }, + { ...checkpoint(), nodesProcessed: 4 }, + { ...checkpoint(), totalNodes: 1.5 }, + { ...checkpoint(), chunksProcessed: Number.NaN }, + { ...checkpoint(), model: '' }, + { ...checkpoint(), provider: null }, + { ...checkpoint(), dimensions: 0 }, + { ...checkpoint(), pendingNodeIds: [42] }, + { ...checkpoint(), pendingNodeIds: ['unexcluded'] }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, schemaFingerprint: '' } }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, unsafeNodeIds: undefined } }, + { ...checkpoint(), recovery: { ...checkpoint().recovery, unsafeNodeIds: [''] } }, + ])('rejects malformed or incompatible checkpoint shape %#', (marker) => { + expect(resolveEmbeddingRecovery(dir, marker)).toBeUndefined(); + }); + + it('accepts a durable restored stage even before a new embedding window completes', () => { + expect( + resolveEmbeddingRecovery(dir, { + ...checkpoint(), + nodesProcessed: 0, + chunksProcessed: 0, + pendingNodeIds: [], + }), + ).toBeDefined(); + }); + + it('rejects a directory in place of the staged database', () => { + rmSync(path.join(dir, stagingFile)); + mkdirSync(path.join(dir, stagingFile)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes)('rejects a symlink in the staged family: %s', (suffix) => { + const target = path.join(dir, 'external-file'); + writeFileSync(target, 'external'); + const candidate = path.join(dir, stagingFile + suffix); + rmSync(candidate, { force: true }); + symlinkSync(target, candidate); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes.slice(1))('rejects a dangling staged sidecar symlink: %s', (suffix) => { + symlinkSync(path.join(dir, 'missing'), path.join(dir, stagingFile + suffix)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); + + it.each(familySuffixes.slice(1))('rejects a non-file staged sidecar: %s', (suffix) => { + mkdirSync(path.join(dir, stagingFile + suffix)); + expect(resolveEmbeddingRecovery(dir, checkpoint())).toBeUndefined(); + }); +}); + +describe('readEmbeddingRecovery', () => { + const writeMeta = (filename: string, marker: unknown = checkpoint()): void => { + writeFileSync(path.join(dir, filename), JSON.stringify({ embeddingCheckpoint: marker })); + }; + + it('prefers the primary metadata file over a stale legacy reference', () => { + writeMeta('gitnexus.json'); + writeMeta('meta.json', null); + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + }); + + it('loads the legacy mirror only when the primary metadata file is absent', () => { + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)?.stagingFile).toBe(stagingFile); + }); + + it('does not resurrect a legacy reference when the primary file is malformed', () => { + writeFileSync(path.join(dir, 'gitnexus.json'), '{'); + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('does not fall back from valid primary metadata with no recovery reference', () => { + writeMeta('gitnexus.json', null); + writeMeta('meta.json'); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('rejects symlinked primary metadata rather than reading a foreign receipt', () => { + writeMeta('meta.json'); + symlinkSync(path.join(dir, 'meta.json'), path.join(dir, 'gitnexus.json')); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); + + it('resolves branch-slot provenance within that slot despite a flat storagePath', () => { + const branchSlot = path.join(dir, 'branches', 'feature'); + mkdirSync(branchSlot, { recursive: true }); + writeFileSync(path.join(branchSlot, stagingFile), 'branch-stage'); + writeFileSync( + path.join(branchSlot, 'gitnexus.json'), + JSON.stringify({ storagePath: dir, embeddingCheckpoint: checkpoint() }), + ); + expect(readEmbeddingRecovery(branchSlot)?.dbPath).toBe(path.join(branchSlot, stagingFile)); + expect(readEmbeddingRecovery(dir)).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/embeddings-sync-command.test.ts b/gitnexus/test/unit/embeddings-sync-command.test.ts index 01afa40c3..ec33fee6d 100644 --- a/gitnexus/test/unit/embeddings-sync-command.test.ts +++ b/gitnexus/test/unit/embeddings-sync-command.test.ts @@ -3,13 +3,15 @@ * index lock, missing-DB preflight, identity fail-closed, tri-state count, * closeLbug masking, and hash-only cache load. */ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { acquireIndexLockMock, + ensurePrivateSharedGraphMock, releaseMock, getStoragePathsMock, loadMetaMock, @@ -27,6 +29,7 @@ const { reapEmbeddingSidecarMock, } = vi.hoisted(() => ({ acquireIndexLockMock: vi.fn(), + ensurePrivateSharedGraphMock: vi.fn(), releaseMock: vi.fn(), getStoragePathsMock: vi.fn(), loadMetaMock: vi.fn(), @@ -48,6 +51,10 @@ vi.mock('../../src/storage/git.js', () => ({ getGitRoot: () => '/tmp/emb-sync-repo', })); +vi.mock('../../src/core/shared-store-analyze.js', () => ({ + ensurePrivateSharedGraph: (...args: unknown[]) => ensurePrivateSharedGraphMock(...args), +})); + vi.mock('../../src/storage/index-lock.js', async (importOriginal) => ({ ...(await importOriginal()), acquireIndexLock: (...args: unknown[]) => acquireIndexLockMock(...args), @@ -113,6 +120,25 @@ async function run(inputPath = '/tmp/emb-sync-repo') { await embeddingsSyncCommand(inputPath); } +async function useRealIndexLock() { + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const actual = await vi.importActual( + '../../src/storage/index-lock.js', + ); + acquireIndexLockMock.mockImplementation( + async (...args: Parameters) => { + const lock = await actual.acquireIndexLock(...args); + return { + ...lock, + release: () => { + lock.release(); + releaseMock(); + }, + }; + }, + ); +} + describe('embeddingsSyncCommand writer safety (#3065)', () => { const tmpDirs: string[] = []; const originalEmbeddingUrl = process.env.GITNEXUS_EMBEDDING_URL; @@ -132,6 +158,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { beforeEach(() => { vi.resetModules(); acquireIndexLockMock.mockReset().mockResolvedValue(lockHandle()); + ensurePrivateSharedGraphMock.mockReset().mockResolvedValue(true); releaseMock.mockReset(); getStoragePathsMock.mockReset(); loadMetaMock.mockReset().mockResolvedValue({ ...BASE_META }); @@ -156,6 +183,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { }); afterEach(async () => { + vi.unstubAllEnvs(); if (originalEmbeddingUrl === undefined) delete process.env.GITNEXUS_EMBEDDING_URL; else process.env.GITNEXUS_EMBEDDING_URL = originalEmbeddingUrl; if (originalEmbeddingModel === undefined) delete process.env.GITNEXUS_EMBEDDING_MODEL; @@ -183,7 +211,7 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { await run(); - expect(acquireIndexLockMock).toHaveBeenCalledWith(dir); + expect(acquireIndexLockMock).toHaveBeenCalledWith(dir, { sweep: false }); expect(order[0]).toBe('lock'); expect(order.indexOf('loadMeta')).toBeGreaterThan(order.indexOf('lock')); expect(order.indexOf('init')).toBeGreaterThan(order.indexOf('loadMeta')); @@ -299,6 +327,90 @@ describe('embeddingsSyncCommand writer safety (#3065)', () => { expect(releaseMock).toHaveBeenCalled(); }); + it.each([ + { + name: 'valid staged receipt', + recovery: { + stagingFile: 'lbug.staging.12345678-1234-4123-8123-123456789abc', + schemaFingerprint: 'test-schema', + unsafeNodeIds: ['n2', 'inherited-window-node'], + }, + }, + { name: 'null receipt', recovery: null }, + { name: 'malformed receipt', recovery: { stagingFile: 'invalid' } }, + { name: 'false receipt', recovery: false }, + ])('preserves a $name before any writable sync work', async ({ recovery }) => { + const { dir, lbugPath, metaPath } = await store(); + await useRealIndexLock(); + const lockPath = path.join(dir, 'analyze.lock'); + const sourcePath = path.join(dir, 'lbug.staging.12345678-1234-4123-8123-123456789abc'); + await writeFile(sourcePath, 'completed paid vectors'); + await writeFile(`${sourcePath}.wal`, 'unfinished window'); + const metadataBytes = JSON.stringify({ + ...BASE_META, + embeddingCheckpoint: { + ...IDENTITY, + at: '2026-01-01T00:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + kind: 'interrupted', + pendingNodeIds: ['n2'], + recovery, + }, + }); + await writeFile(metaPath, metadataBytes); + loadMetaMock.mockImplementation(async () => { + expect(existsSync(lockPath)).toBe(true); + return JSON.parse(await readFile(metaPath, 'utf8')); + }); + resolveEmbeddingRuntimeMock.mockReturnValue(null); + + await expect(run()).rejects.toThrow( + /staged embeddings.*Run `gitnexus analyze` to recover them first/, + ); + + expect(acquireIndexLockMock).toHaveBeenCalledWith(dir, { sweep: false }); + expect(loadMetaMock.mock.invocationCallOrder[0]).toBeGreaterThan( + acquireIndexLockMock.mock.invocationCallOrder[0]!, + ); + expect(ensurePrivateSharedGraphMock).not.toHaveBeenCalled(); + expect(resolveEmbeddingIdentityMock).not.toHaveBeenCalled(); + expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled(); + expect(initLbugMock).not.toHaveBeenCalled(); + expect(runEmbeddingPipelineMock).not.toHaveBeenCalled(); + expect(saveMetaMock).not.toHaveBeenCalled(); + expect(releaseMock).toHaveBeenCalledTimes(1); + expect(existsSync(lockPath)).toBe(false); + expect(await readFile(metaPath, 'utf8')).toBe(metadataBytes); + expect(await readFile(lbugPath, 'utf8')).toBe('db'); + expect(await readFile(sourcePath, 'utf8')).toBe('completed paid vectors'); + expect(await readFile(`${sourcePath}.wal`, 'utf8')).toBe('unfinished window'); + }); + + it('sweeps orphaned staging files before writable sync work without a recovery receipt', async () => { + const { dir, metaPath } = await store(); + await useRealIndexLock(); + await writeFile(metaPath, JSON.stringify(BASE_META)); + const sourcePath = path.join(dir, 'lbug.staging.12345678-1234-4123-8123-123456789abc'); + await writeFile(sourcePath, 'orphaned database'); + await writeFile(`${sourcePath}.wal`, 'orphaned WAL'); + loadMetaMock.mockImplementation(async () => JSON.parse(await readFile(metaPath, 'utf8'))); + ensurePrivateSharedGraphMock.mockImplementation(async () => { + expect(existsSync(path.join(dir, 'analyze.lock'))).toBe(true); + expect(existsSync(sourcePath)).toBe(false); + expect(existsSync(`${sourcePath}.wal`)).toBe(false); + return true; + }); + + await run(); + + expect(ensurePrivateSharedGraphMock).toHaveBeenCalledTimes(1); + expect(runEmbeddingPipelineMock).toHaveBeenCalledTimes(1); + expect(releaseMock).toHaveBeenCalledTimes(1); + expect(existsSync(path.join(dir, 'analyze.lock'))).toBe(false); + }); + it('persists an interrupted checkpoint from the pipeline checkpoint callbacks', async () => { // The resume contract lives in these callbacks; a mock that never invokes // them leaves the whole save path unexecuted. diff --git a/gitnexus/test/unit/index-lock.test.ts b/gitnexus/test/unit/index-lock.test.ts index 1a6846cc0..120e09727 100644 --- a/gitnexus/test/unit/index-lock.test.ts +++ b/gitnexus/test/unit/index-lock.test.ts @@ -17,6 +17,7 @@ import { existsSync, chmodSync, symlinkSync, + mkdirSync, } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -178,6 +179,109 @@ describe('release', () => { }); describe('sweepStagingArtifacts', () => { + const recoveryStage = 'lbug.staging.6e34c761-bf58-46cc-8b54-78d11607bc46'; + const seedRecovery = (stagingFile = recoveryStage): void => { + writeFileSync( + path.join(dir, 'gitnexus.json'), + JSON.stringify({ + embeddingCheckpoint: { + kind: 'interrupted', + at: '2026-10-03T12:00:00.000Z', + nodesProcessed: 1, + totalNodes: 2, + chunksProcessed: 1, + model: 'test-model', + dimensions: 2, + provider: 'local', + pendingNodeIds: ['node-2'], + recovery: { + stagingFile, + schemaFingerprint: 'schema-v1', + unsafeNodeIds: ['node-2'], + }, + }, + }), + ); + }; + + it('retains the checkpoint-referenced family while reclaiming unrelated orphans', () => { + const family = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', + ].map((suffix) => recoveryStage + suffix); + for (const name of [...family, `${recoveryStage}.unexpected`, 'lbug.staging.orphan']) { + writeFileSync(path.join(dir, name), 'x'); + } + seedRecovery(); + + sweepStagingArtifacts(dir); + + for (const name of family) expect(existsSync(path.join(dir, name))).toBe(true); + expect(existsSync(path.join(dir, `${recoveryStage}.unexpected`))).toBe(false); + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(false); + }); + + it('preserves a referenced generation when a shared caller acquires the lock', async () => { + writeFileSync(path.join(dir, recoveryStage), 'x'); + seedRecovery(); + const lock = await acquireIndexLock(dir); + try { + expect(existsSync(path.join(dir, recoveryStage))).toBe(true); + } finally { + lock.release(); + } + }); + + it('retains only the referenced family in a branch sub-slot', async () => { + const branchDir = path.join(dir, 'branches', 'feature'); + mkdirSync(branchDir, { recursive: true }); + seedRecovery(); + const metadata = JSON.parse(readFileSync(path.join(dir, 'gitnexus.json'), 'utf8')); + writeFileSync( + path.join(branchDir, 'gitnexus.json'), + JSON.stringify({ ...metadata, storagePath: dir }), + ); + writeFileSync(path.join(branchDir, recoveryStage), 'stage'); + writeFileSync(path.join(branchDir, 'lbug.staging.orphan'), 'orphan'); + writeFileSync(path.join(dir, 'lbug.staging.orphan'), 'other-slot'); + + const lock = await acquireIndexLock(branchDir); + try { + expect(existsSync(path.join(branchDir, recoveryStage))).toBe(true); + expect(existsSync(path.join(branchDir, 'lbug.staging.orphan'))).toBe(false); + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(true); + } finally { + lock.release(); + } + }); + + it('rejects a symlinked reference and never follows it while reclaiming the stage', () => { + const external = path.join(dir, 'foreign-database'); + writeFileSync(external, 'external'); + symlinkSync(external, path.join(dir, recoveryStage)); + seedRecovery(); + + sweepStagingArtifacts(dir); + + expect(existsSync(path.join(dir, recoveryStage))).toBe(false); + expect(readFileSync(external, 'utf8')).toBe('external'); + }); + + it('does not sweep any generation when acquisition explicitly defers cleanup', async () => { + writeFileSync(path.join(dir, 'lbug.staging.orphan'), 'x'); + const lock = await acquireIndexLock(dir, { sweep: false }); + try { + expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(true); + } finally { + lock.release(); + } + }); + it('removes only staging files, never the live index or its sidecars', () => { const files = [ 'lbug', diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index b6de84c6b..b60cd60f1 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -1,13 +1,18 @@ import { execSync } from 'child_process'; import fs from 'fs/promises'; -import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { basename } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { getStoragePaths, loadMeta, saveMeta, type RepoMeta, } from '../../src/storage/repo-manager.js'; -import { EMBEDDING_DIMS, STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js'; +import { + EMBEDDING_DIMS, + STALE_HASH_SENTINEL, + SCHEMA_FINGERPRINT, +} from '../../src/core/lbug/schema.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import type { EmbeddingPipelineOptions, @@ -2382,12 +2387,20 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { incrementalInProgress: { phase: 'full-rebuild' }, stats: { embeddings: 7 }, }); + expect(snapshots.postWindow?.embeddingCheckpoint?.recovery).toMatchObject({ + stagingFile: expect.stringMatching(/^lbug\.staging\.[a-f0-9-]+$/), + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: [], + }); // ── Window 2: the published count remains unchanged ──────────────── expect(snapshots.secondWindow).toMatchObject({ lastCommit: STALE_COMMIT, stats: { embeddings: 7 }, - embeddingCheckpoint: { pendingNodeIds: ['node-3', 'node-4'] }, + embeddingCheckpoint: { + pendingNodeIds: ['node-3', 'node-4'], + recovery: { unsafeNodeIds: ['node-3', 'node-4'] }, + }, }); // Only the finalize write — after the index is published — advances @@ -2427,6 +2440,14 @@ describe('runFullAnalysis embedding-checkpoint meta write (#2790)', () => { * NEXT run does with it). */ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => { + const actualPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + + beforeEach(() => { + // These mocked checkpoint tests exercise the atomic rebuild used on POSIX. + // Select that same path on Windows; the in-place case below opts out. + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '1'); + }); + const RESILIENCE_NODE_ID = 'Function:src/app.ts:handler:1'; const stubNode = { id: RESILIENCE_NODE_ID, @@ -2461,7 +2482,7 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => const mockResilienceHarness = ( controls: ResilienceControls, - ): { runEmbeddingPipeline: Mock; loadCachedEmbeddings: Mock } => { + ): { runEmbeddingPipeline: Mock; loadCachedEmbeddings: Mock; batchInsertEmbeddings: Mock } => { const loadCachedEmbeddings = vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [], @@ -2530,11 +2551,13 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => pipelineOptions: EmbeddingPipelineOptions, ): Promise => controls.pipeline(pipelineOptions), ); + const batchInsertEmbeddings = vi.fn(async () => undefined); vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({ runEmbeddingPipeline, + batchInsertEmbeddings, buildVectorIndex: vi.fn(async () => false), })); - return { runEmbeddingPipeline, loadCachedEmbeddings }; + return { runEmbeddingPipeline, loadCachedEmbeddings, batchInsertEmbeddings }; }; /** A checkpoint shaped exactly as `RepoMeta` declares it. */ @@ -2583,12 +2606,17 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => }; afterEach(() => { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } vi.doUnmock('../../src/core/lbug/lbug-adapter.js'); vi.doUnmock('../../src/core/search/fts-indexes.js'); vi.doUnmock('../../src/core/ingestion/pipeline.js'); vi.doUnmock('../../src/storage/repo-manager.js'); vi.doUnmock('../../src/core/embeddings/embedding-identity.js'); vi.doUnmock('../../src/core/embeddings/embedding-pipeline.js'); + vi.doUnmock('../../src/core/embeddings/staged-embedding-recovery.js'); + vi.restoreAllMocks(); vi.resetModules(); vi.clearAllMocks(); vi.unstubAllEnvs(); @@ -3040,4 +3068,625 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () => await tmpRepo.cleanup(); } }); + + const mockStagedFiles = async (): Promise => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + }; + + it.each([ + { mode: 'staged', atomicSwap: '1', checkpointCount: 7 }, + { mode: 'in-place', atomicSwap: '0', checkpointCount: 42 }, + ])( + 'keeps Windows $mode checkpoint counts consistent with the live index', + async ({ mode, atomicSwap, checkpointCount }) => { + const tmpRepo = await createTempDir('gitnexus-2790-checkpoint-meta-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 7 } }); + await fs.writeFile(lbugPath, 'published fixture'); + const snapshots: Array = []; + mockResilienceHarness({ + count: [{ cnt: 42 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: [RESILIENCE_NODE_ID], + }); + snapshots.push(await loadMeta(storagePath)); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 3 }); + snapshots.push(await loadMeta(storagePath)); + return cleanResult(); + }, + }); + await mockStagedFiles(); + // Load modules on the actual host before changing only the platform + // branch exercised by runFullAnalysis; all native DB work is mocked. + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', atomicSwap); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + await runFullAnalysis( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ); + + expect(snapshots[0]?.stats?.embeddings).toBe(7); + expect(snapshots[1]?.stats?.embeddings).toBe(checkpointCount); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const buildPath = vi.mocked(adapter.initLbug).mock.calls.at(-1)?.[0]; + if (mode === 'staged') { + expect(buildPath).toMatch(/\.staging\.[a-f0-9-]+$/); + expect(snapshots[1]?.embeddingCheckpoint?.recovery).toMatchObject({ + stagingFile: expect.stringMatching(/^lbug\.staging\.[a-f0-9-]+$/), + unsafeNodeIds: [], + }); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('staged fixture'); + } else { + expect(buildPath).toBe(lbugPath); + expect(snapshots[0]?.embeddingCheckpoint?.recovery).toBeUndefined(); + expect(snapshots[1]?.embeddingCheckpoint?.recovery).toBeUndefined(); + } + const finalMeta = await loadMeta(storagePath); + expect(finalMeta?.stats?.embeddings).toBe(42); + expect(finalMeta?.embeddingCheckpoint).toBeUndefined(); + } finally { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + await tmpRepo.cleanup(); + } + }, + ); + + it.each(['close', 'rename'] as const)( + 'keeps the published count and database when %s fails before the staged publish', + async (failure) => { + const tmpRepo = await createTempDir('gitnexus-2790-checkpoint-meta-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 7 } }); + await fs.writeFile(lbugPath, 'published fixture'); + const rename = fs.rename.bind(fs); + let checkpointMeta: RepoMeta | null = null; + let failedPublishRename: Mock | undefined; + mockResilienceHarness({ + count: [{ cnt: 42 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: [RESILIENCE_NODE_ID], + }); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 3 }); + checkpointMeta = await loadMeta(storagePath); + if (failure === 'close') { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.closeLbug).mockRejectedValueOnce( + new Error('pre-publish close failed'), + ); + } else { + failedPublishRename = vi.fn(async () => { + throw Object.assign(new Error('staged publish rename failed'), { code: 'EIO' }); + }); + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if ( + String(source).startsWith(`${lbugPath}.staging.`) && + String(destination) === lbugPath + ) { + return failedPublishRename?.(); + } + return rename(source, destination); + }); + } + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect( + await runAnalyze( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + [], + ), + ).toMatchObject({ + message: + failure === 'close' ? 'pre-publish close failed' : 'staged publish rename failed', + }); + expect(checkpointMeta?.stats?.embeddings).toBe(7); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('published fixture'); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected durable stage after failed publish'); + expect(await fs.readFile(`${storagePath}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + if (failure === 'rename') expect(failedPublishRename).toHaveBeenCalledTimes(1); + } finally { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + const seedRecovery = async (storagePath: string, repoPath: string) => { + const stagingFile = 'lbug.staging.11111111-1111-4111-8111-111111111111'; + const checkpoint = checkpointFixture({ + kind: 'interrupted', + pendingNodeIds: [], + recovery: { stagingFile, schemaFingerprint: SCHEMA_FINGERPRINT, unsafeNodeIds: [] }, + }); + await seedMeta(storagePath, repoPath, { + stats: { nodes: 2, embeddings: 7 }, + embeddingCheckpoint: checkpoint, + }); + await fs.writeFile(`${storagePath}/${stagingFile}`, 'previous durable source'); + vi.doMock('../../src/core/embeddings/staged-embedding-recovery.js', () => ({ + recoverStagedEmbeddings: vi.fn(async () => ({ rows: [], embeddingNodeIds: new Set() })), + })); + return { checkpoint, stagingFile }; + }; + + it.each( + ['1', '0'].flatMap((manualCheckpoint) => + ( + ['window-start crash', 'post-window crash', 'final-metadata failure', 'success'] as const + ).map((outcome) => ({ manualCheckpoint, outcome })), + ), + )( + 'preserves the in-place recovery receipt with manual checkpoints=$manualCheckpoint through $outcome', + async ({ manualCheckpoint, outcome }) => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', manualCheckpoint); + vi.stubEnv('GITNEXUS_INDEX_LOCK_BACKEND', 'file'); + const tmpRepo = await createTempDir('gitnexus-3456-opt-out-source-'); + try { + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint, stagingFile } = await seedRecovery(storagePath, tmpRepo.dbPath); + const original = { + ...checkpoint, + pendingNodeIds: ['original-pending'], + recovery: { + stagingFile, + schemaFingerprint: SCHEMA_FINGERPRINT, + unsafeNodeIds: ['original-pending', 'original-unsafe'], + }, + } satisfies NonNullable; + await seedMeta(storagePath, tmpRepo.dbPath, { + stats: { nodes: 2, embeddings: 7 }, + embeddingCheckpoint: original, + }); + const sourceFiles = [ + { filename: stagingFile, contents: 'previous durable source' }, + { filename: `${stagingFile}.wal`, contents: 'previous durable WAL' }, + { filename: `${stagingFile}.shadow`, contents: 'previous durable shadow' }, + ]; + for (const source of sourceFiles) { + await fs.writeFile(`${storagePath}/${source.filename}`, source.contents); + } + await fs.writeFile(lbugPath, 'previous published index'); + const { normalizeCachedEmbeddings } = + await import('../../src/core/embeddings/embedding-restore-spill.js'); + vi.doMock( + '../../src/core/embeddings/staged-embedding-recovery.js', + async (importActual) => ({ + ...(await importActual< + typeof import('../../src/core/embeddings/staged-embedding-recovery.js') + >()), + recoverStagedEmbeddings: vi.fn(async () => + normalizeCachedEmbeddings({ + embeddings: [ + { + nodeId: RESILIENCE_NODE_ID, + chunkIndex: 0, + startLine: 1, + endLine: 2, + contentHash: 'current-hash', + embedding: new Array(EMBEDDING_DIMS).fill(0), + }, + ], + }), + ), + }), + ); + const snapshots: Array = []; + const rename = fs.rename.bind(fs); + const { batchInsertEmbeddings } = mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['current-window'], + }); + snapshots.push(await loadMeta(storagePath)); + if (outcome === 'window-start crash') throw new Error(outcome); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + snapshots.push(await loadMeta(storagePath)); + if (outcome === 'post-window crash') throw new Error(outcome); + if (outcome === 'final-metadata failure') { + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (basename(String(destination)) === 'gitnexus.json') throw new Error(outcome); + return rename(source, destination); + }); + } + return cleanResult(); + }, + }); + await mockStagedFiles(); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.loadGraphToLbug).mockImplementation(async () => { + await fs.writeFile(lbugPath, 'in-place replacement'); + }); + // Import on the host first, then choose the Windows default in-place + // branch. The native adapter is mocked; source files and lock cleanup are real. + await import('../../src/core/run-analyze.js'); + vi.stubEnv('GITNEXUS_ATOMIC_WINDOWS_SWAP', '0'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const error = await runAnalyze( + tmpRepo.dbPath, + { force: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + [], + ); + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + expect(batchInsertEmbeddings).toHaveBeenCalled(); + expect(vi.mocked(adapter.initLbug).mock.calls.at(-1)?.[0]).toBe(lbugPath); + const finalMeta = await loadMeta(storagePath); + // Reacquiring the real lock performs the next retry's orphan sweep. + // A lost receipt would delete every byte of the proven old generation here. + const { acquireIndexLock } = await import('../../src/storage/index-lock.js'); + const lock = await acquireIndexLock(storagePath, { timeoutMs: 1000 }); + try { + if (outcome === 'success') { + expect(error).toBeNull(); + expect(finalMeta?.embeddingCheckpoint).toBeUndefined(); + expect(finalMeta?.stats?.embeddings).toBe(9); + expect(await fs.readFile(lbugPath, 'utf8')).toBe('in-place replacement'); + for (const source of sourceFiles) { + await expect(fs.stat(`${storagePath}/${source.filename}`)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } + } else { + expect(error).toMatchObject({ message: outcome }); + for (const source of sourceFiles) { + expect(await fs.readFile(`${storagePath}/${source.filename}`, 'utf8')).toBe( + source.contents, + ); + } + expect(finalMeta?.embeddingCheckpoint).toEqual(original); + expect(finalMeta?.stats?.embeddings).toBe(outcome === 'window-start crash' ? 7 : 9); + } + expect(snapshots[0]?.embeddingCheckpoint).toEqual(original); + expect(snapshots[0]?.stats?.embeddings).toBe(7); + if (outcome !== 'window-start crash') { + expect(snapshots[1]?.embeddingCheckpoint).toEqual(original); + expect(snapshots[1]?.stats?.embeddings).toBe(9); + } + } finally { + lock.release(); + } + } finally { + if (actualPlatformDescriptor) { + Object.defineProperty(process, 'platform', actualPlatformDescriptor); + } + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + it('finishes a staged embedding run with manual checkpoints disabled', async () => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '0'); + const tmpRepo = await createTempDir('gitnexus-3456-checkpoint-opt-out-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint?.recovery).toBeUndefined(); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + const midRun = await loadMeta(storagePath); + expect(midRun?.embeddingCheckpoint?.recovery).toBeUndefined(); + expect(midRun?.stats?.embeddings).toBe(7); + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toBeNull(); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toBeUndefined(); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(9); + expect(await fs.readFile(getStoragePaths(tmpRepo.dbPath).lbugPath, 'utf8')).toBe( + 'staged fixture', + ); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('keeps the previous durable source when manual checkpoints are disabled', async () => { + vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '0'); + const tmpRepo = await createTempDir('gitnexus-3456-opt-out-source-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint: original, stagingFile } = await seedRecovery(storagePath, tmpRepo.dbPath); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 9 }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + throw new Error('endpoint failure with manual checkpoints disabled'); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toMatchObject( + { message: 'endpoint failure with manual checkpoints disabled' }, + ); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + expect(await fs.readFile(`${storagePath}/${stagingFile}`, 'utf8')).toBe( + 'previous durable source', + ); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([stagingFile]); + } finally { + await tmpRepo.cleanup(); + } + }); + + it.skipIf(process.platform === 'win32')( + 'retains the referenced stage through a symlinked storage directory', + async () => { + const tmpRepo = await createTempDir('gitnexus-3456-storage-alias-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const actualStorage = `${tmpRepo.dbPath}/actual-index`; + await fs.mkdir(actualStorage); + await fs.symlink(actualStorage, storagePath, 'dir'); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 3, chunksProcessed: 9 }); + throw new Error('endpoint failed after durable window'); + }, + }); + await mockStagedFiles(); + expect( + await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, []), + ).toMatchObject({ message: 'endpoint failed after durable window' }); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected retained recovery generation'); + expect(await fs.readFile(`${actualStorage}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + } finally { + await tmpRepo.cleanup(); + } + }, + ); + + it.each(['checkpoint', 'metadata'] as const)( + 'keeps the previous source when %s fails before recovery handoff', + async (failure) => { + const tmpRepo = await createTempDir('gitnexus-3456-handoff-failure-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + const { checkpoint: original, stagingFile } = await seedRecovery( + storagePath, + tmpRepo.dbPath, + ); + const rename = fs.rename.bind(fs); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + if (failure === 'metadata') { + vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (basename(String(destination)) === 'gitnexus.json') + throw new Error('metadata write failed'); + return rename(source, destination); + }); + } else { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.tryFlushWAL).mockResolvedValue(false); + } + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + return cleanResult(); + }, + }); + await mockStagedFiles(); + const error = await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, []); + expect(error).toMatchObject({ + message: + failure === 'metadata' + ? 'metadata write failed' + : 'Could not checkpoint restored embeddings before recovery handoff.', + }); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toEqual(original); + expect((await loadMeta(storagePath))?.stats?.embeddings).toBe(7); + expect(await fs.readFile(`${storagePath}/${stagingFile}`, 'utf8')).toBe( + 'previous durable source', + ); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([stagingFile]); + } finally { + vi.restoreAllMocks(); + await tmpRepo.cleanup(); + } + }, + ); + + it('keeps an active window unsafe when its completion checkpoint fails', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-completion-failure-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { embeddings: 7 } }); + mockResilienceHarness({ + count: [{ cnt: 9 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 3, + chunksProcessed: 0, + nodeIds: ['active-node'], + }); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.tryFlushWAL).mockResolvedValue(false); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 3, chunksProcessed: 9 }); + return cleanResult(); + }, + }); + await mockStagedFiles(); + expect(await runAnalyze(tmpRepo.dbPath, { force: true, embeddings: true }, [])).toMatchObject( + { message: 'Could not checkpoint the completed embedding window for recovery.' }, + ); + const meta = await loadMeta(storagePath); + expect(meta?.stats?.embeddings).toBe(7); + expect(meta?.embeddingCheckpoint?.pendingNodeIds).toEqual(['active-node']); + expect(meta?.embeddingCheckpoint?.recovery?.unsafeNodeIds).toEqual(['active-node']); + if (!meta?.embeddingCheckpoint?.recovery) throw new Error('expected retained active window'); + expect( + await fs.readFile( + `${storagePath}/${meta.embeddingCheckpoint.recovery.stagingFile}`, + 'utf8', + ), + ).toBe('staged fixture'); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('retains a failed stage and keeps future incomplete restore groups unsafe', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-unsafe-restore-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, { stats: { nodes: 2, embeddings: 1 } }); + let completedWindow: RepoMeta | null = null; + const { loadCachedEmbeddings, batchInsertEmbeddings } = mockResilienceHarness({ + count: [{ cnt: 5 }], + pipeline: async (options) => { + await options.onCheckpointWindowStart?.({ + nodesProcessed: 0, + totalNodes: 2, + chunksProcessed: 0, + nodeIds: ['earlier-window-node'], + }); + await options.onCheckpoint?.({ nodesProcessed: 1, totalNodes: 2, chunksProcessed: 1 }); + completedWindow = await loadMeta(storagePath); + throw new Error('Maximum database size exceeded'); + }, + }); + loadCachedEmbeddings.mockResolvedValue({ + embeddingNodeIds: new Set([RESILIENCE_NODE_ID]), + embeddings: [ + { + nodeId: RESILIENCE_NODE_ID, + chunkIndex: 0, + startLine: 1, + endLine: 2, + contentHash: 'current-hash', + embedding: new Array(EMBEDDING_DIMS).fill(0), + }, + ], + }); + batchInsertEmbeddings.mockRejectedValue(new Error('restore batch partially inserted')); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + const logs: string[] = []; + expect( + await runAnalyze( + tmpRepo.dbPath, + { embeddings: true, force: true, skipAgentsMd: true, skipSkills: true }, + logs, + ), + ).toMatchObject({ message: 'Maximum database size exceeded' }); + expect(completedWindow?.embeddingCheckpoint?.recovery?.unsafeNodeIds).toEqual([ + RESILIENCE_NODE_ID, + ]); + expect(completedWindow?.stats?.embeddings).toBe(1); + const recovery = (await loadMeta(storagePath))?.embeddingCheckpoint?.recovery; + if (!recovery) throw new Error('expected retained recovery generation'); + expect(await fs.readFile(`${storagePath}/${recovery.stagingFile}`, 'utf8')).toBe( + 'staged fixture', + ); + expect(logs).toContainEqual(expect.stringContaining('GITNEXUS_LBUG_MAX_DB_SIZE')); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('reclaims a failed current stage before any recovery checkpoint exists', async () => { + const tmpRepo = await createTempDir('gitnexus-3456-no-checkpoint-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await seedMeta(storagePath, tmpRepo.dbPath, {}); + mockResilienceHarness({ + count: [{ cnt: 0 }], + pipeline: async () => { + throw new Error('failed before first window'); + }, + }); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + vi.mocked(adapter.initLbug).mockImplementation(async (dbPath) => { + if (dbPath.includes('.staging.')) await fs.writeFile(dbPath, 'staged fixture'); + }); + vi.mocked(adapter.wipeLbugDbFiles).mockImplementation(async (dbPath) => { + await fs.rm(dbPath, { force: true }); + }); + expect( + await runAnalyze( + tmpRepo.dbPath, + { embeddings: true, force: true, skipAgentsMd: true, skipSkills: true }, + [], + ), + ).toMatchObject({ message: 'failed before first window' }); + expect( + (await fs.readdir(storagePath)).filter((name) => name.startsWith('lbug.staging.')), + ).toEqual([]); + expect((await loadMeta(storagePath))?.embeddingCheckpoint).toBeUndefined(); + } finally { + await tmpRepo.cleanup(); + } + }); }); diff --git a/gitnexus/test/unit/staged-embedding-recovery-child.test.ts b/gitnexus/test/unit/staged-embedding-recovery-child.test.ts new file mode 100644 index 000000000..58711493b --- /dev/null +++ b/gitnexus/test/unit/staged-embedding-recovery-child.test.ts @@ -0,0 +1,300 @@ +import fs from 'node:fs'; +import { EventEmitter } from 'node:events'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + dbCtor: vi.fn(), + connCtor: vi.fn(), + dbClose: vi.fn<() => Promise>(), + connClose: vi.fn<() => Promise>(), + query: vi.fn(), + abortBuilder: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ spawn: h.spawn })); + +vi.mock('@ladybugdb/core', () => { + class Database { + constructor(...args: unknown[]) { + h.dbCtor(...args); + } + close = h.dbClose; + } + class Connection { + constructor(db: unknown) { + h.connCtor(db); + } + query = h.query; + close = h.connClose; + } + return { default: { Database, Connection } }; +}); + +vi.mock('../../src/core/embeddings/embedding-restore-spill.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + abortCachedEmbeddingsBuilder: ( + ...args: Parameters + ) => { + h.abortBuilder(...args); + return actual.abortCachedEmbeddingsBuilder(...args); + }, + }; +}); + +describe('staged embedding recovery child native lifecycle', () => { + const suffixes = [ + '', + '.wal', + '.shadow', + '.wal.checkpoint', + '.lock', + '.checkpoint.intent.lock', + '.checkpoint.apply.lock', + ]; + let tmp: string; + let dbPath: string; + let exportDir: string; + let originalArgv: string[]; + let originalExitCode: typeof process.exitCode; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + h.dbCtor.mockReset(); + h.connCtor.mockReset(); + h.dbClose.mockReset().mockResolvedValue(undefined); + h.connClose.mockReset().mockResolvedValue(undefined); + h.query.mockReset().mockResolvedValue({ + hasNext: vi.fn().mockResolvedValue(false), + getNext: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-recovery-child-')); + dbPath = path.join(tmp, 'lbug.stage-test'); + exportDir = path.join(tmp, 'export'); + fs.writeFileSync(dbPath, 'mock native database'); + fs.writeFileSync(`${dbPath}.wal`, 'retained WAL'); + fs.mkdirSync(exportDir); + originalArgv = process.argv; + originalExitCode = process.exitCode; + process.argv = [process.execPath, 'staged-embedding-recovery-child', dbPath, exportDir, '2']; + process.exitCode = undefined; + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.useRealTimers(); + process.argv = originalArgv; + process.exitCode = originalExitCode; + vi.restoreAllMocks(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + function sourceFamily() { + return Object.fromEntries( + suffixes.map((suffix) => [suffix, fs.readFileSync(dbPath + suffix, 'utf8')]), + ); + } + + function seedCompleteFamily() { + for (const suffix of suffixes) fs.writeFileSync(dbPath + suffix, `retained ${suffix}`); + return sourceFamily(); + } + + async function runRejectedChild(message: string): Promise { + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => { + expect(process.exitCode).toBe(1); + expect(process.stderr.write).toHaveBeenCalledWith(`${message}\n`); + }); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(fs.readFileSync(`${dbPath}.wal`, 'utf8')).toBe('retained WAL'); + } + + it('closes the opened database and aborts the builder when Connection construction fails', async () => { + h.connCtor.mockImplementation(() => { + throw new Error('connection constructor failed'); + }); + + await runRejectedChild('connection constructor failed'); + + expect(h.dbClose).toHaveBeenCalledOnce(); + expect(h.connClose).not.toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + expect(h.query).not.toHaveBeenCalled(); + expect(h.dbCtor.mock.calls[0][7]).toBe(true); + }); + + it('aborts the builder when Database construction fails', async () => { + h.dbCtor.mockImplementation(() => { + throw new Error('database constructor failed'); + }); + + await runRejectedChild('database constructor failed'); + + expect(h.abortBuilder).toHaveBeenCalledOnce(); + expect(h.dbClose).not.toHaveBeenCalled(); + expect(h.connCtor).not.toHaveBeenCalled(); + }); + + it('rejects output and closes the database when Connection close fails', async () => { + h.connClose.mockRejectedValue(new Error('connection close failed')); + + await runRejectedChild('connection close failed'); + + expect(h.dbClose).toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + }); + + it('rejects output when Database close fails', async () => { + h.dbClose.mockRejectedValue(new Error('database close failed')); + + await runRejectedChild('database close failed'); + + expect(h.connClose).toHaveBeenCalled(); + expect(h.abortBuilder).toHaveBeenCalledOnce(); + }); + + it('confines writable replay and failed checkpoint close to a separate copied family', async () => { + const sourceBefore = seedCompleteFamily(); + h.dbCtor.mockImplementation((openedPath: string) => { + for (const suffix of suffixes) { + expect(fs.readFileSync(openedPath + suffix, 'utf8')).toBe(sourceBefore[suffix]); + fs.writeFileSync(openedPath + suffix, `replayed ${suffix}`); + } + }); + h.dbClose.mockImplementation(async () => { + const openedPath = h.dbCtor.mock.calls[0][0] as string; + fs.writeFileSync(openedPath, 'partial checkpoint'); + throw new Error('checkpoint close failed'); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor.mock.calls[0][0]).not.toBe(dbPath); + expect(path.relative(exportDir, h.dbCtor.mock.calls[0][0] as string)).not.toMatch(/^\.\./); + expect(sourceFamily()).toEqual(sourceBefore); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(process.stderr.write).toHaveBeenCalledWith('checkpoint close failed\n'); + }); + + it('reclaims a timed-out writer copy without changing the retained source', async () => { + const sourceBefore = seedCompleteFamily(); + h.query.mockReturnValue(new Promise(() => {})); + h.dbCtor.mockImplementation((openedPath: string) => { + for (const suffix of suffixes) fs.writeFileSync(openedPath + suffix, 'writer opened'); + }); + let childImport: Promise | undefined; + const child = Object.assign(new EventEmitter(), { + stderr: new EventEmitter(), + kill: vi.fn(() => { + queueMicrotask(() => child.emit('close', null, 'SIGKILL')); + return true; + }), + }); + h.spawn.mockImplementation((_command: string, args: string[]) => { + process.argv = [process.execPath, 'staged-embedding-recovery-child', ...args.slice(-3)]; + childImport = import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + return child; + }); + const { recoverStagedEmbeddings } = + await import('../../src/core/embeddings/staged-embedding-recovery.js'); + vi.useFakeTimers(); + const recovering = expect( + recoverStagedEmbeddings(dbPath, { dimensions: 2, timeoutMs: 500 }), + ).rejects.toThrow(/timeout/); + await childImport; + expect(h.dbCtor).toHaveBeenCalledOnce(); + const openedPath = h.dbCtor.mock.calls[0][0] as string; + + await vi.advanceTimersByTimeAsync(500); + await recovering; + + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(openedPath).not.toBe(dbPath); + expect(fs.existsSync(path.dirname(openedPath))).toBe(false); + expect(sourceFamily()).toEqual(sourceBefore); + expect(h.dbClose).not.toHaveBeenCalled(); + }); + + it.each(['.wal', '.checkpoint.intent.lock', '.checkpoint.apply.lock'])( + 'refuses a dangling family symlink before native open: %s', + async (suffix) => { + fs.rmSync(dbPath + suffix, { force: true }); + fs.symlinkSync(path.join(tmp, 'missing-sidecar'), dbPath + suffix); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(process.stderr.write).toHaveBeenCalledWith( + 'staged embedding family is not a regular file\n', + ); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + }, + ); + + it('refuses a family entry replaced with a symlink between lstat and open', async () => { + const foreignPath = path.join(tmp, 'foreign-file'); + fs.writeFileSync(foreignPath, 'foreign'); + const open = fs.openSync; + const read = vi.spyOn(fs, 'readSync'); + vi.spyOn(fs, 'openSync').mockImplementation((...args) => { + if (args[0] === dbPath) { + fs.rmSync(dbPath); + fs.symlinkSync(foreignPath, dbPath); + } + return open(...args); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(read).not.toHaveBeenCalled(); + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(fs.readFileSync(foreignPath, 'utf8')).toBe('foreign'); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + }); + + it('fails closed when copying the complete family runs out of space', async () => { + const sourceBefore = seedCompleteFamily(); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw Object.assign(new Error('copy ran out of space'), { code: 'ENOSPC' }); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(process.exitCode).toBe(1)); + + expect(h.dbCtor).not.toHaveBeenCalled(); + expect(sourceFamily()).toEqual(sourceBefore); + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + expect(process.stderr.write).toHaveBeenCalledWith('copy ran out of space\n'); + }); + + it('writes the manifest only after both native closes succeed', async () => { + const closed: string[] = []; + h.connClose.mockImplementation(async () => { + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + closed.push('connection'); + }); + h.dbClose.mockImplementation(async () => { + expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(false); + closed.push('database'); + }); + + await import('../../src/core/embeddings/staged-embedding-recovery-child.js'); + await vi.waitFor(() => expect(fs.existsSync(path.join(exportDir, 'manifest.json'))).toBe(true)); + + expect(closed).toEqual(['connection', 'database']); + expect(h.abortBuilder).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + expect(fs.readFileSync(`${dbPath}.wal`, 'utf8')).toBe('retained WAL'); + }); +}); diff --git a/gitnexus/test/unit/staged-embedding-recovery.test.ts b/gitnexus/test/unit/staged-embedding-recovery.test.ts new file mode 100644 index 000000000..552e48dc5 --- /dev/null +++ b/gitnexus/test/unit/staged-embedding-recovery.test.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createCachedEmbeddingsBuilder, + disposeEmbeddingSpill, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + materializeCachedEmbeddings, + type CachedEmbeddingsSnapshot, +} from '../../src/core/embeddings/embedding-restore-spill.js'; +import { + mergeRecoveredEmbeddings, + validateRecoveredNodeGroups, +} from '../../src/core/embeddings/staged-embedding-recovery.js'; + +describe('staged embedding recovery', () => { + const snapshots: CachedEmbeddingsSnapshot[] = []; + let tmp: string | undefined; + afterEach(() => { + for (const snapshot of snapshots) disposeEmbeddingSpill(snapshot.spill); + snapshots.length = 0; + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); + tmp = undefined; + }); + + function snapshot( + rows: { nodeId: string; chunkIndex: number; contentHash?: string; embedding?: number[] }[], + ) { + tmp ??= fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-stage-test-')); + const builder = createCachedEmbeddingsBuilder({ inMemoryRowLimit: 0, spillDir: tmp }); + for (const row of rows) { + ingestCachedEmbeddingRow( + builder, + { startLine: 1, endLine: 2, embedding: [1, 2], ...row }, + true, + ); + } + const result = finalizeCachedEmbeddingsSnapshot(builder); + snapshots.push(result); + return result; + } + + it('accepts only complete groups with one content hash and unique contiguous chunk ordinals', () => { + const cached = snapshot([ + { nodeId: 'complete', chunkIndex: 1, contentHash: 'same' }, + { nodeId: 'complete', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'gap', chunkIndex: 1, contentHash: 'same' }, + { nodeId: 'duplicate', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'duplicate', chunkIndex: 0, contentHash: 'same' }, + { nodeId: 'mixed', chunkIndex: 0, contentHash: 'old' }, + { nodeId: 'mixed', chunkIndex: 1, contentHash: 'new' }, + { nodeId: 'no-hash', chunkIndex: 0 }, + { nodeId: 'unsafe', chunkIndex: 0, contentHash: 'same' }, + ]); + expect([...validateRecoveredNodeGroups(cached.rows, new Set(['unsafe']))]).toEqual([ + 'complete', + ]); + }); + + it('replaces an entire published node group and keeps unrelated rows without retaining vector arrays', () => { + const live = snapshot([ + { nodeId: 'changed', chunkIndex: 0, contentHash: 'old' }, + { nodeId: 'changed', chunkIndex: 1, contentHash: 'old' }, + { nodeId: 'other', chunkIndex: 0, contentHash: 'other' }, + ]); + const recovered = snapshot([ + { nodeId: 'changed', chunkIndex: 0, contentHash: 'new', embedding: [7, 8] }, + ]); + const merged = mergeRecoveredEmbeddings(live, recovered); + snapshots.push(merged); + expect(merged.embeddings).toEqual([]); + expect(merged.rows).toHaveLength(2); + expect(materializeCachedEmbeddings(merged, merged.rows)).toEqual([ + expect.objectContaining({ nodeId: 'other', contentHash: 'other' }), + expect.objectContaining({ + nodeId: 'changed', + chunkIndex: 0, + contentHash: 'new', + embedding: [7, 8], + }), + ]); + expect(fs.existsSync(live.spill.path)).toBe(true); + expect(fs.existsSync(recovered.spill.path)).toBe(true); + }); + + it('does not accept missing vector bytes during a merge', () => { + const cached = snapshot([{ nodeId: 'complete', chunkIndex: 0, contentHash: 'same' }]); + fs.truncateSync(cached.spill.path, 12); + expect(() => mergeRecoveredEmbeddings(snapshot([]), cached)).toThrow( + /short embedding spill read/, + ); + }); +}); From 1a5d88391cf459911c2607523ce9786642053748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 4 Oct 2026 10:52:01 +0100 Subject: [PATCH 2/3] fix(mcp): reject corrupt impact and context identities (#3466) --- gitnexus/src/mcp/local/aop-metadata.ts | 27 +- gitnexus/src/mcp/local/local-backend.ts | 222 ++++- gitnexus/src/mcp/local/pdg-impact.ts | 27 +- .../src/mcp/local/query-result-integrity.ts | 57 ++ .../impact-context-integrity.test.ts | 758 ++++++++++++++++ .../unit/calltool-dispatch-id-bridge.test.ts | 17 +- gitnexus/test/unit/calltool-dispatch.test.ts | 104 +-- .../unit/impact-batching-grouping.test.ts | 21 + .../unit/impact-context-integrity.test.ts | 847 ++++++++++++++++++ gitnexus/test/unit/impact-pagination.test.ts | 2 + .../test/unit/impact-route-enrichment.test.ts | 5 +- gitnexus/test/unit/pdg-impact-engine.test.ts | 83 ++ gitnexus/vitest.config.ts | 2 + 13 files changed, 2080 insertions(+), 92 deletions(-) create mode 100644 gitnexus/src/mcp/local/query-result-integrity.ts create mode 100644 gitnexus/test/integration/impact-context-integrity.test.ts create mode 100644 gitnexus/test/unit/impact-context-integrity.test.ts diff --git a/gitnexus/src/mcp/local/aop-metadata.ts b/gitnexus/src/mcp/local/aop-metadata.ts index a3506d9c5..d5d41ab02 100644 --- a/gitnexus/src/mcp/local/aop-metadata.ts +++ b/gitnexus/src/mcp/local/aop-metadata.ts @@ -1,4 +1,9 @@ import { executeParameterized } from '../../core/lbug/pool-adapter.js'; +import { + assertSymbolIdentity, + assertIdentityFields, + rethrowSymbolIdentityError, +} from './query-result-integrity.js'; import { decodeSpringAopReason, type SpringAopReason, @@ -151,6 +156,7 @@ const DETERMINISTIC_RELATIONSHIP_ORDER = 'ORDER BY sourceId, targetId, reason, s * shared decoder. Other DECLARES edges (for example Spring Bean factories) * and malformed/forward-version evidence are ignored. Query failures are * fail-soft because older or partially upgraded indexes must remain readable. + * Corrupt identities propagate to the context/impact integrity error boundary. */ export async function querySpringAopMetadata( lbugPath: string, @@ -204,6 +210,24 @@ export async function querySpringAopMetadata( ), ]); + for (const rows of [ + outgoingAdviceRows, + incomingAdviceRows, + outgoingPointcutRows, + incomingPointcutRows, + ]) { + for (const row of rows) { + assertSymbolIdentity(readRowValue(row, 'sourceId', 0)); + assertSymbolIdentity(readRowValue(row, 'targetId', 3)); + assertIdentityFields( + readRowValue(row, 'sourceName', 1), + readRowValue(row, 'sourceFilePath', 2), + readRowValue(row, 'targetName', 4), + readRowValue(row, 'targetFilePath', 5), + ); + } + } + const behaviors: SpringAopBehaviorMetadata[] = []; const advices: SpringAopAdviceMetadata[] = []; const resolvedPointcuts: SpringAopResolvedPointcutMetadata[] = []; @@ -346,7 +370,8 @@ export async function querySpringAopMetadata( resolvedPointcuts: dedupedResolvedPointcuts, unresolvedPointcuts: dedupedPointcuts, }; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); return undefined; } } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index cb8546543..e5dc607f1 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -28,6 +28,15 @@ import { } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; import { querySpringAopMetadata } from './aop-metadata.js'; +import { + SYMBOL_IDENTITY_RECOVERY_SUGGESTION, + SymbolIdentityError, + assertSymbolIdentity, + queryRowValue, + assertIdentityFields, + assertQueryIdentity, + rethrowSymbolIdentityError, +} from './query-result-integrity.js'; import { queryConvexDispatchMetadata } from './convex-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; @@ -324,25 +333,67 @@ function nonBlankUid(value: unknown): string | undefined { return typeof value === 'string' ? value.trim() || undefined : undefined; } -const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = - 'Run gitnexus analyze --force from the affected repository root to rebuild the index.'; +function assertSymbolRowIdentity(row: unknown): void { + assertQueryIdentity(row, 'id', 0, [ + ['name', 1], + ['type', 2], + ['filePath', 3], + ]); +} -class SymbolIdentityError extends Error { - constructor() { - super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION); - this.name = 'SymbolIdentityError'; +function assertContextRefs(rows: unknown[]): void { + for (const row of rows) { + assertQueryIdentity(row, 'uid', 1, [ + ['name', 2], + ['filePath', 3], + ['kind', 4], + ]); + assertSymbolIdentity(queryRowValue(row, 'relType', 0)); } } -/** Validate database identities before using them as graph traversal anchors. */ -function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string { - if ( - typeof id !== 'string' || - !id.trim() || - id.includes('\0') || - (expectedUid !== undefined && id !== expectedUid) - ) { - throw new SymbolIdentityError(); +const RESPONSE_IDENTITY_FIELDS = new Set([ + 'id', + 'uid', + 'name', + 'filePath', + 'label', + 'kind', + 'type', + 'relationType', + 'processType', + 'url', + 'method', + 'sourceId', + 'targetId', + 'symbolId', + 'symbolName', + 'symbolFilePath', + 'adviceId', + 'adviceName', + 'adviceFilePath', + 'advisedId', + 'advisedName', + 'advisedFilePath', + 'evidenceId', +]); + +/** Cover nested additive identity fields while leaving source/metadata text alone. */ +function assertResponseIdentities(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) assertResponseIdentities(item); + } else if (value !== null && typeof value === 'object') { + for (const [key, field] of Object.entries(value)) { + if (key === 'seedBlocks' || key === 'reachableBlocks' || key === 'intraReachableBlocks') { + if (!Array.isArray(field)) throw new SymbolIdentityError(); + for (const id of field) assertSymbolIdentity(id); + continue; + } + if (RESPONSE_IDENTITY_FIELDS.has(key)) assertIdentityFields(field); + if (key !== 'content' && key !== 'methodMetadata' && key !== 'bean') { + assertResponseIdentities(field); + } + } } } @@ -4393,9 +4444,10 @@ export class LocalBackend { * "unknown kind" and, worse, makes the `kind` disambiguation hint unable to * filter it out (#2687). * - * Failures are swallowed: label enrichment is an optimisation for + * Ordinary query failures are swallowed: label enrichment is an optimisation for * downstream scoring and #480 Class/Interface BFS seeding; if it fails * the symbol still resolves, just without the kind-priority bonus. + * Corrupt identities propagate to the context/impact error envelope. */ private async enrichCandidateLabels( repo: RepoHandle, @@ -4429,6 +4481,7 @@ export class LocalBackend { ); const labelById = new Map(); for (const r of rows as any[]) { + assertQueryIdentity(r, 'id', 0, [['label', 1]]); const id = (r.id ?? r[0]) as string; const label = (r.label ?? r[1]) as string; if (id && label && !labelById.has(id)) labelById.set(id, label); @@ -4436,7 +4489,8 @@ export class LocalBackend { for (const c of candidates) { if (c.type === '' && labelById.has(c.id)) c.type = labelById.get(c.id) as string; } - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); /* best-effort — downstream resolvers still work without the label */ } } @@ -4561,6 +4615,7 @@ export class LocalBackend { { uid }, ); if (rows.length === 0) return { kind: 'not_found' }; + assertSymbolRowIdentity(rows[0]); const r = rows[0] as any; const symbol = { id: (r.id ?? r[0]) as string, @@ -4695,6 +4750,10 @@ export class LocalBackend { if (rows.length === 0) return { kind: 'not_found' }; + // Reject every raw candidate before narrowing/scoring can hide a corrupt row. + for (const row of rows) { + assertSymbolRowIdentity(row); + } // Normalise row shape across object / tuple returns from LadybugDB. let normalized = rows.map((r: any) => ({ id: (r.id ?? r[0]) as string, @@ -4705,10 +4764,6 @@ export class LocalBackend { endLine: (r.endLine ?? r[5]) as number, ...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}), })); - // Reject the whole result before narrowing or scoring: dropping a corrupt - // candidate could make an unrelated surviving symbol look unambiguous. - for (const candidate of normalized) assertSymbolIdentity(candidate.id); - // An exact File path wins over anchored suffix candidates. Without this, // `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an // otherwise unambiguous exact target into `ambiguous` (#3084 review P2). @@ -4858,7 +4913,9 @@ export class LocalBackend { }, ): Promise { try { - return await this._contextImpl(repo, params); + const result = await this._contextImpl(repo, params); + if (!result.error) assertResponseIdentities(result); + return result; } catch (err: any) { const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed'; if (err instanceof SymbolIdentityError) { @@ -4978,6 +5035,8 @@ export class LocalBackend { { symId }, ), ]); + assertContextRefs(incomingRows); + assertContextRefs(incomingAdvisedRows); incomingRows.push(...incomingAdvisedRows); let typedPropertyRows: any[] = []; @@ -5082,6 +5141,16 @@ export class LocalBackend { }, ), ]); + assertContextRefs(ctorIncoming); + assertContextRefs(fileIncoming); + assertContextRefs(typedPropertyIncoming); + for (const row of typedProperties) { + assertQueryIdentity(row, 'uid', 0, [ + ['name', 1], + ['filePath', 2], + ['kind', 3], + ]); + } typedPropertyRows = typedProperties; // Deduplicate by (relType, uid) — a caller can have multiple relation @@ -5098,6 +5167,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:class-incoming-expansion', e); } } @@ -5128,6 +5198,8 @@ export class LocalBackend { { symId }, ), ]); + assertContextRefs(outgoingRows); + assertContextRefs(outgoingAdvisedRows); outgoingRows.push(...outgoingAdvisedRows); // Process participation. @@ -5151,7 +5223,14 @@ export class LocalBackend { `, { symId }, ); + for (const row of processRows) { + assertQueryIdentity(row, 'pid', 0, [ + ['label', 1], + ['entryPointId', 4], + ]); + } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:process-participation', e); } @@ -5188,6 +5267,7 @@ export class LocalBackend { // (GET/POST pair). URL-only dedup would drop the second endpoint. const seenRoutes = new Set(); for (const r of routeRows) { + assertIdentityFields(queryRowValue(r, 'url', 0), queryRowValue(r, 'method', 1)); const url = r.url ?? r[0]; const method = r.method ?? r[1]; const dedupKey = routeEnrichmentKey(method ? String(method) : undefined, url); @@ -5197,7 +5277,8 @@ export class LocalBackend { } } } catch (e) { - // Best-effort enrichment — never fail the context call. + rethrowSymbolIdentityError(e); + // Ordinary query failures leave this best-effort enrichment unavailable. logQueryError('context:route-lookup', e); } @@ -5247,6 +5328,7 @@ export class LocalBackend { ); const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType); const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType); + void aopMetadataPromise.catch(() => undefined); // R3-1. A `Property` whose name the analyzer declined to link — because // every definition of it lives in another language — otherwise returns an @@ -5341,6 +5423,7 @@ export class LocalBackend { try { chain = await this._computeContextChain(repo, symId, requestedDepth); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('context:chain-bfs', e); } } @@ -5381,8 +5464,8 @@ export class LocalBackend { processes: processRows.map((r: any) => ({ id: r.pid || r[0], name: r.label || r[1], - step_index: r.step || r[2], - step_count: r.stepCount || r[3], + step_index: r.step ?? r[2], + step_count: r.stepCount ?? r[3], })), }; } @@ -5463,6 +5546,13 @@ export class LocalBackend { visited: Array.from(visited), }); if (rows.length === 0) return { nextFrontier: [] }; + for (const row of rows) { + assertQueryIdentity(row, 'uid', 0, [ + ['name', 1], + ['filePath', 2], + ['kind', 3], + ]); + } // MATCH is one row per CALLS edge. Cypher `WITH DISTINCT` applies // LIMIT 50 to unique neighbors; this second pass still collapses // twins if a driver/engine ever returns duplicate rows. @@ -5482,6 +5572,7 @@ export class LocalBackend { for (const r of fresh) visited.add(r.uid); return { nodes, nextFrontier: fresh.map((r: any) => r.uid) }; } catch (e) { + rethrowSymbolIdentityError(e); logQueryError(logLabel, e); return { nextFrontier: [] }; } @@ -7163,7 +7254,9 @@ export class LocalBackend { private async impact(repo: RepoHandle, params: ImpactParams): Promise { try { - return await this._impactImpl(repo, params); + const result = await this._impactImpl(repo, params); + if (!result.error) assertResponseIdentities(result); + return result; } catch (err: any) { // Return structured error instead of crashing (#321) const message = @@ -7481,6 +7574,7 @@ export class LocalBackend { } catch (e) { probeFailed = true; candidateProbeFailed = true; + rethrowSymbolIdentityError(e); logQueryError('impact:ambiguous-candidate', e); } return { @@ -7738,6 +7832,7 @@ export class LocalBackend { }); return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-interprocedural-reach', e); return composeUnifiedPdgImpactResult(pdgResult, null, e); } @@ -7775,10 +7870,12 @@ export class LocalBackend { { ids: blockIds }, ); for (const r of rows as any[]) { + assertIdentityFields(r.callees ?? r[0]); const raw = String(r.callees ?? r[0] ?? ''); for (const n of raw.split(' ')) if (n) names.add(n); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-slice-callees', e); } return names; @@ -7814,6 +7911,7 @@ export class LocalBackend { for (const id of splitCalleeIds(r.calleeIds ?? r[0])) ids.add(id); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:pdg-slice-callee-ids', e); } return ids; @@ -7967,7 +8065,10 @@ export class LocalBackend { ORDER BY id LIMIT 25`, { symId, heritage: HERITAGE_TYPES }, - ).catch(() => []); + ).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }); const undecidedSummary = meta?.undecidedInterfaceSatisfaction; const undecidedDrops = undecidedSummary === undefined @@ -8006,6 +8107,10 @@ export class LocalBackend { } const ifaceRows = await interfaceRowsPromise; for (const r of ifaceRows) { + assertQueryIdentity(r, 'id', 0, [ + ['name', 1], + ['label', 2], + ]); const id = (r.id ?? r[0]) as string; if (id && !boundary.has(id)) { boundary.set(id, { @@ -8031,7 +8136,10 @@ export class LocalBackend { WHERE iface.id = $ifaceId AND r.type IN $types RETURN COUNT(DISTINCT other.id) AS cnt`, { ifaceId, types }, - ).catch(() => []); + ).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }); const cnt = rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0; m.set(ifaceId, cnt); @@ -8090,7 +8198,8 @@ export class LocalBackend { callableValueReferences: droppedBoundaries.callableValueReferences, }, }; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); // Never let the heritage probe's failure suppress a drop we already know // about — the whole point is that silence must not read as certainty. return epistemicFrom(droppedBoundaries); @@ -8182,6 +8291,7 @@ export class LocalBackend { `Impact target '${sym.name || sym[1] || '?'}' resolved without a node id; refusing to report a blast radius`, ); } + assertSymbolRowIdentity(sym); // #1858 — kick off the epistemic boundary probe concurrently with the BFS. // It depends only on symId/symType/symName (all known now) and touches no @@ -8217,6 +8327,7 @@ export class LocalBackend { opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) : querySpringAopMetadata(repo.lbugPath, symId, symType); + void aopMetadataPromise.catch(() => undefined); const impacted: any[] = []; const visited = new Set([symId]); const pdgBridgeEvidenceById = new Map(); @@ -8261,6 +8372,7 @@ export class LocalBackend { ]); for (const r of ctorRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8268,6 +8380,7 @@ export class LocalBackend { } } for (const r of fileRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8292,6 +8405,7 @@ export class LocalBackend { ); for (const r of typedPropertyRows) { + assertSymbolRowIdentity(r); const rid = r.id || r[0]; if (rid && !visited.has(rid)) { visited.add(rid); @@ -8299,6 +8413,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:class-node-expansion', e); traversalComplete = false; } @@ -8336,6 +8451,9 @@ export class LocalBackend { `, { symId }, ); + for (const row of memberRows) { + assertSymbolRowIdentity(row); + } memberRows.sort((a, b) => compareCodeUnits(String(a.id ?? a[0]), String(b.id ?? b[0]))); if (memberRows.length > OBJECT_CALLABLE_MEMBER_CAP) traversalComplete = false; for (const row of memberRows.slice(0, OBJECT_CALLABLE_MEMBER_CAP)) { @@ -8355,6 +8473,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:object-callable-expansion', e); traversalComplete = false; } @@ -8411,6 +8530,17 @@ export class LocalBackend { relTypes: relationTypes, ...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}), }); + // Validate before filtering/deduplication; a discarded corrupt edge is + // still evidence that this result's counts cannot be trusted. + for (const row of related) { + assertQueryIdentity(row, 'id', 1, [ + ['sourceId', 0], + ['name', 2], + ['type', 3], + ['filePath', 4], + ]); + assertSymbolIdentity(queryRowValue(row, 'relType', 5)); + } const edges: ImpactFrontierEdge[] = related.map((rel) => ({ id: rel.id || rel[1], @@ -8510,6 +8640,7 @@ export class LocalBackend { }); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:depth-traversal', e); // Break out of depth loop on query failure but return partial results // collected so far, rather than silently swallowing the error (#321) @@ -8635,6 +8766,7 @@ export class LocalBackend { `, { ids }, ).catch((err) => { + rethrowSymbolIdentityError(err); processQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:process-chunk', err); @@ -8642,6 +8774,14 @@ export class LocalBackend { }); for (const row of rows) { + assertQueryIdentity(row, 'pId', 0, [ + ['name', 1], + ['processType', 2], + ['entryPointId', 3], + ['epName', 7], + ['epType', 8], + ['epFilePath', 9], + ]); const pId = row.pId ?? row[0]; const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0]; // Track mapping from process -> entryPoint so we can backfill missing minStep @@ -8690,6 +8830,7 @@ export class LocalBackend { ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity); } } catch (e) { + rethrowSymbolIdentityError(e); processQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:process-chunk', e); @@ -8712,12 +8853,14 @@ export class LocalBackend { `, { pIds, ids: allImpactedIds }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; logQueryError('impact:process-chunk-backfill', err); return []; }); for (const mr of missingRows) { + assertQueryIdentity(mr, 'pid', 0, []); const pid = mr.pid ?? mr[0]; const minStep = mr.minStep ?? mr[1]; const epId = processToEntryPoint.get(String(pid)); @@ -8729,6 +8872,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); enrichmentDegraded = true; logQueryError('impact:process-chunk-backfill', e); } @@ -8791,6 +8935,7 @@ export class LocalBackend { `, { ids: idsChunk }, ).catch((err) => { + rethrowSymbolIdentityError(err); moduleQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:module-chunk', err); @@ -8798,12 +8943,14 @@ export class LocalBackend { }); for (const r of rows) { + assertIdentityFields(queryRowValue(r, 'name', 0)); const name = r.name ?? r[0] ?? null; const hits = (r.hits ?? r[1]) || 0; if (!name) continue; moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits); } } catch (e) { + rethrowSymbolIdentityError(e); moduleQueryFailed = true; enrichmentDegraded = true; logQueryError('impact:module-chunk', e); @@ -8832,16 +8979,19 @@ export class LocalBackend { `, { ids: idsChunk }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; moduleClassificationFailed = true; logQueryError('impact:direct-module-chunk', err); return []; }); for (const r of rows) { + assertIdentityFields(queryRowValue(r, 'name', 0)); const name = r.name ?? r[0] ?? null; if (name) directModuleSet.add(name); } } catch (e) { + rethrowSymbolIdentityError(e); enrichmentDegraded = true; moduleClassificationFailed = true; logQueryError('impact:direct-module-chunk', e); @@ -8903,11 +9053,14 @@ export class LocalBackend { `, { ids: chunkIds }, ).catch((err) => { + rethrowSymbolIdentityError(err); enrichmentDegraded = true; logQueryError('impact:route-chunk', err); return []; }); for (const row of rows) { + assertSymbolIdentity(queryRowValue(row, 'hid', 0)); + assertIdentityFields(queryRowValue(row, 'url', 1), queryRowValue(row, 'method', 2)); const hid = String(row.hid ?? row[0] ?? ''); const url = row.url ?? row[1]; if (!hid || typeof url !== 'string') continue; @@ -9064,8 +9217,16 @@ export class LocalBackend { p.processType AS pType, MIN(r.step) AS step `, { ids: chunkIds }, - ).catch(() => []); + ).catch((err) => { + rethrowSymbolIdentityError(err); + return []; + }); for (const row of rows) { + assertQueryIdentity(row, 'sid', 0, []); + assertQueryIdentity(row, 'pid', 1, [ + ['pName', 2], + ['pType', 3], + ]); const sid = row.sid ?? row[0]; if (!sid) continue; const procEntry = { @@ -9079,6 +9240,7 @@ export class LocalBackend { else perSymbolProcesses.set(String(sid), [procEntry]); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('impact:per-symbol-process-chunk', e); } } diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index ae4e9836f..4b7321925 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -27,6 +27,11 @@ import { toDisplayLine } from './line-display.js'; import { toOneBasedLine } from '../../core/ingestion/utils/line-base.js'; import { decodeCallSummary } from '../../core/ingestion/taint/call-summary-codec.js'; import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js'; +import { + assertSymbolIdentity, + assertIdentityFields, + assertQueryIdentity, +} from './query-result-integrity.js'; /** * Parse the `` segment out of a `BasicBlock` id (1-based function start @@ -90,14 +95,19 @@ const INTERPROC_NODE_BUDGET = 5000; * `classifyPdgBridgeEvidence`); this is the same fact, read at the descent side. */ function parseCalleeIdsCell(raw: unknown): { ids: string[]; truncated: boolean } { + assertIdentityFields(raw); const ids: string[] = []; let truncated = false; + if (!String(raw ?? '').trim()) return { ids, truncated }; // Split on the SHARED CALLEE_ID_SEP (tab) — ids embed file paths / multi-word // C++ type tokens that can contain a space, so a space split would fragment // them. Producer (calleeIdsOfBlock) joins with the same constant. for (const id of String(raw ?? '').split(CALLEE_ID_SEP)) { if (id === CALLEES_TRUNCATED_SENTINEL) truncated = true; - else if (id) ids.push(id); + else { + assertSymbolIdentity(id); + ids.push(id); + } } return { ids, truncated }; } @@ -218,6 +228,7 @@ async function selfReachingDefEdgesByBlock( { ids: blockIds }, ); for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); if (!id) continue; const decoded = decodeReachingDefReason(r['reason']); @@ -297,6 +308,7 @@ async function pdgStatementsForBlocks( // Narrow the awaited rows ONCE at the boundary to a typed record shape; read // the aliased cells via bracket access with String()/Number() coercion. for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); const line = Number(r['line'] ?? 0); if (!id || !Number.isFinite(line) || line <= 0) continue; @@ -501,6 +513,10 @@ async function projectBlocksToSymbols(deps: { // non-aliased row shape) — no per-field `as any`, matching the typed-row // pattern used elsewhere in this file (e.g. lines ~264, ~1309, ~1386). for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [ + ['name', 1], + ['label', 2], + ]); resolved.push({ id: String(r['id'] ?? r['0'] ?? ''), name: String(r['name'] ?? r['1'] ?? ''), @@ -1718,6 +1734,7 @@ async function bfsReachableBlocks(input: { // Narrow the awaited rows ONCE at the boundary (executeParameterized returns // any[]) to a typed record shape, then read the aliased `id` via bracket // access — no `as any` sprayed per field. + for (const row of rawRows) assertQueryIdentity(row, 'id', 0); const rows = rawRows.slice(0, stepLimit) as Array>; depthReached = depth + 1; if (rawRows.length > stepLimit) truncatedByLimit = true; @@ -1796,6 +1813,10 @@ async function calleeIdsByBlock( // Narrow the awaited rows ONCE at the boundary to a typed record shape; read // the aliased cells via bracket access — no per-field `as any`. for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [ + ['calleeIds', 1], + ['callees', 2], + ]); const blockId = String(r['id'] ?? ''); if (!blockId) continue; // ONE pass over the cell classifies BOTH facts — a second full split just to @@ -1877,6 +1898,7 @@ async function calleesWithReturnFlow( { ids: calleeIds }, ); for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0); const id = String(r['id'] ?? ''); if (!id) continue; const decoded = decodeCallSummary(r['reason']); @@ -1931,6 +1953,7 @@ async function resolveCalleeSpans( // the aliased columns via bracket access with Number()/String() coercion — // no per-field `as any` (the same boundary-narrowing the typed helpers use). for (const r of rows as Array>) { + assertQueryIdentity(r, 'id', 0, [['filePath', 1]]); const id = String(r['id'] ?? ''); const filePath = String(r['filePath'] ?? ''); const startLine = Number(r['startLine']); @@ -2168,6 +2191,7 @@ async function interproceduralDescent(input: { seedBlockQuery(anchorClause, probeLimit), queryParams, ); + for (const row of rawSeedRows) assertQueryIdentity(row, 'id', 0); const exceeded = rawSeedRows.length > stepLimit; const seeds = rawSeedRows .slice(0, stepLimit) @@ -2353,6 +2377,7 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise>; let seedBlocks: string[] = seedRows .map((r) => String(r['id'] ?? '')) diff --git a/gitnexus/src/mcp/local/query-result-integrity.ts b/gitnexus/src/mcp/local/query-result-integrity.ts new file mode 100644 index 000000000..da92cca55 --- /dev/null +++ b/gitnexus/src/mcp/local/query-result-integrity.ts @@ -0,0 +1,57 @@ +/** Shared integrity boundary for identities returned by impact/context queries. */ +export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = + 'Run gitnexus analyze --force from the affected repository root to rebuild the index.'; + +export class SymbolIdentityError extends Error { + constructor() { + super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION); + this.name = 'SymbolIdentityError'; + } +} + +/** Validate database identities before using them as graph traversal anchors. */ +export function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string { + if ( + typeof id !== 'string' || + !id.trim() || + id.includes('\0') || + (expectedUid !== undefined && id !== expectedUid) + ) { + throw new SymbolIdentityError(); + } +} + +/** Read either native row shape without turning an absent row into a TypeError. */ +export function queryRowValue(row: unknown, key: string, index: number): unknown { + if (typeof row !== 'object' || row === null) return undefined; + const value = row as Record; + return value[key] ?? value[index]; +} + +/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */ +export function assertIdentityFields(...values: unknown[]): void { + for (const value of values) { + if ( + value !== null && + value !== undefined && + (typeof value !== 'string' || value.includes('\0')) + ) { + throw new SymbolIdentityError(); + } + } +} + +export function assertQueryIdentity( + row: unknown, + idKey: string, + idIndex: number, + fields: ReadonlyArray = [], +): void { + assertSymbolIdentity(queryRowValue(row, idKey, idIndex)); + for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index)); +} + +/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */ +export function rethrowSymbolIdentityError(error: unknown): void { + if (error instanceof SymbolIdentityError) throw error; +} diff --git a/gitnexus/test/integration/impact-context-integrity.test.ts b/gitnexus/test/integration/impact-context-integrity.test.ts new file mode 100644 index 000000000..0e42de484 --- /dev/null +++ b/gitnexus/test/integration/impact-context-integrity.test.ts @@ -0,0 +1,758 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import lbug from '@ladybugdb/core'; +import * as adapter from '../../src/core/lbug/lbug-adapter.js'; +import { executeParameterized } from '../../src/core/lbug/pool-adapter.js'; +import { closeQueryResults } from '../../src/core/lbug/query-result-utils.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { retryRename } from '../../src/storage/fs-atomic.js'; +import { getStoragePaths, registerRepo, saveMeta } from '../../src/storage/repo-manager.js'; +import { createTempDir } from '../helpers/test-db.js'; + +const REPO = 'query-integrity'; +const nodes = { + alpha: { id: 'Function:src/alpha.ts:runSweep', name: 'runSweep', filePath: 'src/alpha.ts' }, + alphaCaller: { + id: 'Function:src/α/caller.ts:appelÉ', + name: 'appelÉ', + filePath: 'src/α/caller.ts', + }, + alphaOtherCaller: { + id: 'Function:src/other.ts:runOther', + name: 'runOther', + filePath: 'src/other.ts', + }, + alphaRoot: { id: 'Function:src/root.ts:startSweep', name: 'startSweep', filePath: 'src/root.ts' }, + alphaReader: { + id: 'Function:src/reader.ts:readSweep', + name: 'readSweep', + filePath: 'src/reader.ts', + }, + beta: { + id: 'Function:src/beta.ts:extractLeadingNumber', + name: 'extractLeadingNumber', + filePath: 'src/beta.ts', + }, + betaCaller: { + id: 'Function:src/number.ts:parseNumber', + name: 'parseNumber', + filePath: 'src/number.ts', + }, + betaReader: { + id: 'Function:src/number-view.ts:readNumber', + name: 'readNumber', + filePath: 'src/number-view.ts', + }, +} as const; + +const processes = { + alpha: { + id: 'process:alpha', + label: 'Sweep flow', + entry: nodes.alphaRoot, + terminal: nodes.alpha, + stepCount: 3, + }, + alphaOther: { + id: 'process:alpha-other', + label: 'Other sweep flow', + entry: nodes.alphaOtherCaller, + terminal: nodes.alpha, + stepCount: 2, + }, + beta: { + id: 'process:beta', + label: 'Number flow', + entry: nodes.betaCaller, + terminal: nodes.beta, + stepCount: 2, + }, +} as const; + +type NodeIdentity = { id: string; name: string; filePath: string }; +type Membership = { id: string; label: string; processType: string; step: number }; +type ImpactRow = NodeIdentity & { + relationType: string; + confidence: number; + processes: Membership[]; +}; +type ContextRef = { uid: string; name: string; filePath: string }; +type Target = 'alpha' | 'beta'; + +const membership = ( + process: (typeof processes)[keyof typeof processes], + step: number, +): Membership => ({ + id: process.id, + label: process.label, + processType: 'intra_community', + step, +}); + +const affectedProcess = (process: (typeof processes)[keyof typeof processes], hits: number) => ({ + name: process.entry.name, + type: 'Function', + filePath: process.entry.filePath, + affected_process_count: 1, + total_hits: hits, + earliest_broken_step: 0, +}); + +const oracle = { + alpha: { + count: 3, + direct: 2, + byDepth: { + 1: [ + { + ...nodes.alphaOtherCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alphaOther, 0)], + }, + { + ...nodes.alphaCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alpha, 1)], + }, + ], + 2: [ + { + ...nodes.alphaRoot, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.alpha, 0)], + }, + ], + }, + callers: [nodes.alphaOtherCaller, nodes.alphaCaller], + accesses: [nodes.alphaReader], + processes: [ + { id: processes.alpha.id, name: processes.alpha.label, step_index: 2, step_count: 3 }, + { + id: processes.alphaOther.id, + name: processes.alphaOther.label, + step_index: 1, + step_count: 2, + }, + ], + affectedProcesses: [ + affectedProcess(processes.alpha, 2), + affectedProcess(processes.alphaOther, 1), + ], + }, + beta: { + count: 1, + direct: 1, + byDepth: { + 1: [ + { + ...nodes.betaCaller, + relationType: 'CALLS', + confidence: 1, + processes: [membership(processes.beta, 0)], + }, + ], + }, + callers: [nodes.betaCaller], + accesses: [nodes.betaReader], + processes: [ + { id: processes.beta.id, name: processes.beta.label, step_index: 1, step_count: 2 }, + ], + affectedProcesses: [affectedProcess(processes.beta, 1)], + }, +} as const; + +// Compare the values returned by the native engine with a hand-written graph +// oracle, rather than accepting a repeated (and potentially wrong) first result. +function expectImpact( + result: Awaited>, + target: Target, + summaryOnly: boolean, +): void { + const expected = oracle[target]; + expect(result).not.toHaveProperty('error'); + expect(result).not.toHaveProperty('partial'); + expect(result.target).toMatchObject(nodes[target]); + expect(result.direction).toBe('upstream'); + expect(result.impactedCount).toBe(expected.count); + expect(result.risk).toBe('LOW'); + expect(result.epistemic).toBe('exact'); + expect(result.summary).toEqual({ + direct: expected.direct, + processes_affected: expected.affectedProcesses.length, + modules_affected: 0, + }); + expect(result.byDepthCounts).toEqual(target === 'alpha' ? { 1: 2, 2: 1 } : { 1: 1 }); + expect(result.affected_processes).toEqual(expected.affectedProcesses); + expect(result.affected_modules).toEqual([]); + expect(result.affected_routes).toEqual([]); + if (summaryOnly) { + expect(result).not.toHaveProperty('byDepth'); + } else { + const byDepth = Object.fromEntries( + Object.entries(result.byDepth).map(([depth, rows]) => [ + depth, + (rows as ImpactRow[]).map( + ({ id, name, filePath, relationType, confidence, processes: memberships }) => ({ + id, + name, + filePath, + relationType, + confidence, + processes: memberships, + }), + ), + ]), + ); + expect(byDepth).toEqual(expected.byDepth); + } +} + +function expectContext( + result: Awaited>, + target: Target, +): void { + const expected = oracle[target]; + expect(result).not.toHaveProperty('error'); + expect(result.status).toBe('found'); + expect(result.symbol).toMatchObject({ + uid: nodes[target].id, + name: nodes[target].name, + filePath: nodes[target].filePath, + }); + expect(result.epistemic).toBe('exact'); + const incoming = Object.fromEntries( + Object.entries(result.incoming).map(([type, refs]) => [ + type, + (refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })), + ]), + ); + expect(incoming).toEqual({ calls: expected.callers, accesses: expected.accesses }); + expect(result.outgoing).toEqual({}); + expect(result.processes).toEqual(expected.processes); +} + +function edge(source: NodeIdentity, target: NodeIdentity, type: 'CALLS' | 'ACCESSES'): string { + return `MATCH (a:Function {id: '${source.id}'}), (b:Function {id: '${target.id}'}) CREATE (a)-[:CodeRelation {type: '${type}', confidence: 1.0, reason: 'direct', step: 0}]->(b)`; +} + +function processStep( + node: NodeIdentity, + process: (typeof processes)[keyof typeof processes], + step: number, +): string { + return `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`; +} + +describe('native impact/context result integrity (#3354)', () => { + let temp: Awaited>; + let backend: LocalBackend; + let lbugPath: string; + + beforeAll(async () => { + temp = await createTempDir(); + vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index')); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + const paths = getStoragePaths(temp.dbPath); + lbugPath = paths.lbugPath; + + // Close the writer before LocalBackend opens its ordinary read pool. No + // mocked registry or injected writable Database bypasses the read path. + await adapter.initLbug(lbugPath); + try { + const seed = [ + ...Object.values(nodes).map( + (node) => + `CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`, + ), + ...Object.values(processes).map( + (process) => + `CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${process.stepCount}, communities: [], entryPointId: '${process.entry.id}', terminalId: '${process.terminal.id}'})`, + ), + edge(nodes.alphaCaller, nodes.alpha, 'CALLS'), + edge(nodes.alphaOtherCaller, nodes.alpha, 'CALLS'), + edge(nodes.alphaRoot, nodes.alphaCaller, 'CALLS'), + edge(nodes.alphaReader, nodes.alpha, 'ACCESSES'), + edge(nodes.betaCaller, nodes.beta, 'CALLS'), + edge(nodes.betaReader, nodes.beta, 'ACCESSES'), + processStep(nodes.alphaRoot, processes.alpha, 0), + processStep(nodes.alphaCaller, processes.alpha, 1), + processStep(nodes.alpha, processes.alpha, 2), + processStep(nodes.alphaOtherCaller, processes.alphaOther, 0), + processStep(nodes.alpha, processes.alphaOther, 1), + processStep(nodes.betaCaller, processes.beta, 0), + processStep(nodes.beta, processes.beta, 1), + ]; + for (const query of seed) await adapter.executeQuery(query); + await adapter.flushWAL(); + } finally { + await adapter.closeLbug(); + } + const meta = { + repoPath: temp.dbPath, + storagePath: paths.storagePath, + lastCommit: 'integrity-fixture', + indexedAt: new Date().toISOString(), + scopeExtractionReceipt: 1 as const, + stats: { files: 8, nodes: 11, processes: 3, communities: 0 }, + }; + await saveMeta(paths.storagePath, meta); + await registerRepo(temp.dbPath, meta, { name: REPO }); + backend = new LocalBackend(); + expect(await backend.init()).toBe(true); + }); + + afterAll(async () => { + try { + await backend?.dispose(); + } finally { + await adapter.closeLbug(); + vi.unstubAllEnvs(); + await temp?.cleanup(); + } + }); + + const impact = (target: Target, summaryOnly = false) => + backend.callTool('impact', { + repo: REPO, + target: nodes[target].name, + direction: 'upstream', + summaryOnly, + }); + const context = (target: Target) => + backend.callTool('context', { repo: REPO, uid: nodes[target].id }); + + it('returns exact values across identical sequential requests', async () => { + for (let repeat = 0; repeat < 6; repeat++) { + expectImpact(await impact('alpha', true), 'alpha', true); + expectContext(await context('alpha'), 'alpha'); + expectImpact(await impact('alpha'), 'alpha', false); + } + }); + + it('keeps unrelated targets isolated across mixed requests', async () => { + for (const target of ['alpha', 'beta', 'beta', 'alpha'] as const) { + expectImpact(await impact(target, true), target, true); + expectContext(await context(target), target); + expectImpact(await impact(target), target, false); + } + }); + + it('keeps mixed concurrent prepared reads symbol-specific', async () => { + for (let repeat = 0; repeat < 3; repeat++) { + const results = await Promise.all([ + impact('alpha', true), + context('beta'), + impact('beta'), + impact('beta', true), + context('alpha'), + impact('alpha'), + ]); + expectImpact(results[0], 'alpha', true); + expectContext(results[1], 'beta'); + expectImpact(results[2], 'beta', false); + expectImpact(results[3], 'beta', true); + expectContext(results[4], 'alpha'); + expectImpact(results[5], 'alpha', false); + } + }); + + it('returns exact relation rows directly from the pooled prepared adapter', async () => { + // Warm the pool through the same backend, then check the native row + // boundary independently of the tool's normalization and aggregation. + expectContext(await context('alpha'), 'alpha'); + const read = (target: Target) => + executeParameterized( + lbugPath, + ` + MATCH (caller:Function)-[r:CodeRelation]->(target:Function {id: $id}) + WHERE r.type IN ['CALLS', 'ACCESSES'] + RETURN caller.id AS id, caller.name AS name, caller.filePath AS filePath, r.type AS relationType + ORDER BY id + `, + { id: nodes[target].id }, + ); + const expectedRows = (target: Target) => + [ + ...oracle[target].callers.map((caller) => ({ ...caller, relationType: 'CALLS' })), + ...oracle[target].accesses.map((reader) => ({ ...reader, relationType: 'ACCESSES' })), + ].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + for (let repeat = 0; repeat < 4; repeat++) { + expect(await read('alpha')).toEqual(expectedRows('alpha')); + const [beta, alpha] = await Promise.all([read('beta'), read('alpha')]); + expect(beta).toEqual(expectedRows('beta')); + expect(alpha).toEqual(expectedRows('alpha')); + } + }); +}); + +describe('native string projections after checkpointed deletion (#3354)', () => { + it('keeps long symbol identities associated with their source rows across segments', async () => { + const temp = await createTempDir(); + const db = new lbug.Database(path.join(temp.dbPath, 'scan.lbug'), 128 * 1024 * 1024); + const conn = new lbug.Connection(db, 4); + const source = Array.from({ length: 10_000 }, (_, startLine) => ({ + id: `Function:src/generated/rené-${String(startLine).padStart(5, '0')}.ts:fn${startLine}`, + name: `generated_function_${startLine}_é`, + filePath: `src/generated/rené-${String(startLine).padStart(5, '0')}.ts`, + startLine, + })); + const projection = + 'RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine'; + const read = async (query: string) => { + const result = await conn.query(query); + try { + const cursor = Array.isArray(result) ? result[0] : result; + return await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + }; + + try { + await read( + 'CREATE NODE TABLE Function(id STRING, name STRING, filePath STRING, startLine INT64, PRIMARY KEY(id))', + ); + // Separate checkpoints create segment boundaries inside scan vectors. + // LadybugDB 0.18.3's filtered STRING scan could retain another row's + // printable identities here (LadybugDB/ladybug#678, fixed by #737). + for (let batch = 0; batch < 4; batch++) { + const csvPath = path.join(temp.dbPath, `rows-${batch}.csv`); + const csv = source + .slice(batch * 2500, (batch + 1) * 2500) + .map((row) => + Object.values(row) + .map((value) => JSON.stringify(value)) + .join(','), + ) + .join('\n'); + await fs.writeFile(csvPath, `${csv}\n`); + await read( + `COPY Function FROM ${JSON.stringify(csvPath.replaceAll('\\', '/'))} (HEADER=false)`, + ); + await read('CHECKPOINT'); + } + expect(await read(`MATCH (n:Function) ${projection} ORDER BY n.startLine`)).toEqual(source); + + await read( + 'MATCH (n:Function) WHERE n.startLine >= 3000 AND n.startLine < 3400 DETACH DELETE n', + ); + await read('CHECKPOINT'); + const surviving = source.filter((row) => row.startLine < 3000 || row.startLine >= 3400); + for (let repeat = 0; repeat < 3; repeat++) { + for (const order of ['', ' ORDER BY n.startLine']) { + const rows = await read(`MATCH (n:Function) ${projection}${order}`); + expect(new Set(rows.map((row) => row.id)).size).toBe(surviving.length); + expect(rows.sort((a, b) => a.startLine - b.startLine)).toEqual(surviving); + } + } + // Point lookups independently verify values in the affected segments; + // a repeatably wrong scan must never become the test's reference answer. + for (const startLine of [1600, 7486]) { + expect( + await read(`MATCH (n:Function {id: '${source[startLine].id}'}) ${projection}`), + ).toEqual([source[startLine]]); + } + expect(await read(`MATCH (n:Function {id: '${source[3000].id}'}) ${projection}`)).toEqual([]); + } finally { + try { + await conn.close(); + } finally { + try { + await db.close(); + } finally { + await temp.cleanup(); + } + } + } + }); +}); + +// Windows graph replacement is opt-in in production. The repeated-read +// characterization above remains enabled there; only this POSIX swap is skipped. +describe.skipIf(process.platform === 'win32')('warm backend index replacement (#3354)', () => { + it('reads changed callers, processes and a new symbol through the real freshness window', async () => { + const temp = await createTempDir(); + let backend: LocalBackend | undefined; + vi.stubEnv('GITNEXUS_HOME', path.join(temp.dbPath, 'home')); + vi.stubEnv('GITNEXUS_STORAGE_PATH', path.join(temp.dbPath, 'index')); + vi.stubEnv('GITNEXUS_SHARED_STORE', 'off'); + const paths = getStoragePaths(temp.dbPath); + const stagedPath = `${paths.lbugPath}.replacement`; + const replacement = { + entry: { + id: 'Function:src/replacement-entry.ts:startReplacement', + name: 'startReplacement', + filePath: 'src/replacement-entry.ts', + }, + caller: { + id: 'Function:src/replacement-caller.ts:callReplacement', + name: 'callReplacement', + filePath: 'src/replacement-caller.ts', + }, + reader: { + id: 'Function:src/replacement-reader.ts:readReplacement', + name: 'readReplacement', + filePath: 'src/replacement-reader.ts', + }, + }; + const nextProcess = { id: 'process:replacement', label: 'Replacement flow' }; + const oldProcess = processes.alphaOther; + + const seed = async (dbPath: string, next: boolean) => { + await adapter.initLbug(dbPath); + try { + const caller = next ? replacement.caller : nodes.alphaOtherCaller; + const reader = next ? replacement.reader : nodes.alphaReader; + const process = next ? nextProcess : oldProcess; + const entry = next ? replacement.entry : caller; + for (const node of [nodes.alpha, caller, reader, ...(next ? [entry] : [])]) { + await adapter.executeQuery( + `CREATE (:Function {id: '${node.id}', name: '${node.name}', filePath: '${node.filePath}', startLine: 1, endLine: 3})`, + ); + } + await adapter.executeQuery( + `CREATE (:Process {id: '${process.id}', label: '${process.label}', heuristicLabel: '${process.label}', processType: 'intra_community', stepCount: ${next ? 3 : 2}, communities: [], entryPointId: '${entry.id}', terminalId: '${nodes.alpha.id}'})`, + ); + await adapter.executeQuery(edge(caller, nodes.alpha, 'CALLS')); + await adapter.executeQuery(edge(reader, nodes.alpha, 'ACCESSES')); + if (next) await adapter.executeQuery(edge(entry, caller, 'CALLS')); + const steps = next ? [entry, caller, nodes.alpha] : [caller, nodes.alpha]; + for (const [step, node] of steps.entries()) { + await adapter.executeQuery( + `MATCH (n:Function {id: '${node.id}'}), (p:Process {id: '${process.id}'}) CREATE (n)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: 'trace-detection', step: ${step}}]->(p)`, + ); + } + await adapter.flushWAL(); + } finally { + await adapter.closeLbug(); + } + }; + const processMembership = (next: boolean, step: number) => ({ + ...(next ? nextProcess : { id: oldProcess.id, label: oldProcess.label }), + processType: 'intra_community', + step, + }); + const expectedCaller = (node: NodeIdentity, next: boolean, step: number) => ({ + ...node, + relationType: 'CALLS', + confidence: 1, + processes: [processMembership(next, step)], + }); + const expectGeneration = ( + impact: Awaited>, + context: Awaited>, + next: boolean, + ) => { + const caller = next ? replacement.caller : nodes.alphaOtherCaller; + const reader = next ? replacement.reader : nodes.alphaReader; + const entry = next ? replacement.entry : caller; + const process = next ? nextProcess : oldProcess; + expect(impact).not.toHaveProperty('error'); + expect(impact).not.toHaveProperty('partial'); + expect(impact.target).toMatchObject(nodes.alpha); + expect(impact.risk).toBe('LOW'); + expect(impact.epistemic).toBe('exact'); + expect(impact.impactedCount).toBe(next ? 2 : 1); + expect(impact.summary).toEqual({ direct: 1, processes_affected: 1, modules_affected: 0 }); + expect(impact.byDepthCounts).toEqual(next ? { 1: 1, 2: 1 } : { 1: 1 }); + const byDepth = Object.fromEntries( + Object.entries(impact.byDepth).map(([depth, rows]) => [ + depth, + (rows as ImpactRow[]).map( + ({ id, name, filePath, relationType, confidence, processes: memberships }) => ({ + id, + name, + filePath, + relationType, + confidence, + processes: memberships, + }), + ), + ]), + ); + expect(byDepth).toEqual({ + 1: [expectedCaller(caller, next, next ? 1 : 0)], + ...(next ? { 2: [expectedCaller(entry, true, 0)] } : {}), + }); + expect(impact.affected_processes).toEqual([ + { + name: entry.name, + type: 'Function', + filePath: entry.filePath, + affected_process_count: 1, + total_hits: next ? 2 : 1, + earliest_broken_step: 0, + }, + ]); + expect(impact.affected_modules).toEqual([]); + expect(impact.affected_routes).toEqual([]); + expect(context).not.toHaveProperty('error'); + expect(context.status).toBe('found'); + expect(context.epistemic).toBe('exact'); + expect(context.symbol).toMatchObject({ + uid: nodes.alpha.id, + name: nodes.alpha.name, + filePath: nodes.alpha.filePath, + }); + expect( + Object.fromEntries( + Object.entries(context.incoming).map(([type, refs]) => [ + type, + (refs as ContextRef[]).map(({ uid, name, filePath }) => ({ id: uid, name, filePath })), + ]), + ), + ).toEqual({ calls: [caller], accesses: [reader] }); + expect(context.outgoing).toEqual({}); + expect(context.processes).toEqual([ + { + id: process.id, + name: process.label, + step_index: next ? 2 : 1, + step_count: next ? 3 : 2, + }, + ]); + }; + + try { + await seed(paths.lbugPath, false); + const meta = { + repoPath: temp.dbPath, + storagePath: paths.storagePath, + lastCommit: 'graph-a', + indexedAt: new Date().toISOString(), + scopeExtractionReceipt: 1 as const, + stats: { files: 3, nodes: 4, processes: 1, communities: 0 }, + }; + await saveMeta(paths.storagePath, meta); + await registerRepo(temp.dbPath, meta, { name: REPO }); + const heldBackend = new LocalBackend(); + backend = heldBackend; + expect(await heldBackend.init()).toBe(true); + const impact = () => + heldBackend.callTool('impact', { + repo: REPO, + target: nodes.alpha.name, + direction: 'upstream', + }); + const context = () => heldBackend.callTool('context', { repo: REPO, uid: nodes.alpha.id }); + expectGeneration(await impact(), await context(), false); + expect( + await heldBackend.callTool('context', { repo: REPO, uid: replacement.entry.id }), + ).toHaveProperty('error'); + + // Keep this backend and its read pool alive. Publish only after the + // separate staged writer has closed, exactly as run-analyze does. + await seed(stagedPath, true); + for (const suffix of ['.wal', '.shadow', '.wal.checkpoint']) { + await expect(fs.stat(`${stagedPath}${suffix}`)).rejects.toMatchObject({ code: 'ENOENT' }); + } + await retryRename(stagedPath, paths.lbugPath); + const nextMeta = { + ...meta, + lastCommit: 'graph-b', + indexedAt: new Date(Date.now() + 1).toISOString(), + stats: { files: 4, nodes: 5, processes: 1, communities: 0 }, + }; + await saveMeta(paths.storagePath, nextMeta); + await registerRepo(temp.dbPath, nextMeta, { name: REPO }); + + // An independent native read-only Database opens the published path. + // It does not share LocalBackend's pool or trigger its reinitialization. + const freshDb = new lbug.Database(paths.lbugPath, 128 * 1024 * 1024, true, true); + const freshConn = new lbug.Connection(freshDb); + try { + const read = async (query: string) => { + const result = await freshConn.query(query); + try { + const cursor = Array.isArray(result) ? result[0] : result; + return await cursor.getAll(); + } finally { + await closeQueryResults(result); + } + }; + expect( + await read(` + MATCH (n:Function) + RETURN n.id AS id, n.name AS name, n.filePath AS filePath + ORDER BY id + `), + ).toEqual( + [nodes.alpha, ...Object.values(replacement)].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0, + ), + ); + expect( + await read(` + MATCH (n:Function)-[r:CodeRelation]->(target:Function {id: '${nodes.alpha.id}'}) + WHERE r.type IN ['CALLS', 'ACCESSES'] + RETURN n.id AS id, n.name AS name, n.filePath AS filePath, r.type AS relationType + ORDER BY id + `), + ).toEqual([ + { ...replacement.caller, relationType: 'CALLS' }, + { ...replacement.reader, relationType: 'ACCESSES' }, + ]); + expect( + await read(` + MATCH (n:Function)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) + RETURN n.id AS id, p.id AS processId, p.heuristicLabel AS label, + r.step AS step, p.stepCount AS stepCount, p.entryPointId AS entryPointId + ORDER BY step + `), + ).toEqual( + [replacement.entry, replacement.caller, nodes.alpha].map((node, step) => ({ + id: node.id, + processId: nextProcess.id, + label: nextProcess.label, + step, + stepCount: 3, + entryPointId: replacement.entry.id, + })), + ); + } finally { + await freshConn.close(); + await freshDb.close(); + } + + // Poll the SAME backend through its unchanged five-second throttle. + // No private watermark override, poolInit, reset or restart is used. + const deadline = Date.now() + 15_000; + let refreshed = await context(); + while (refreshed.processes?.[0]?.id !== nextProcess.id && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 300)); + refreshed = await context(); + } + expectGeneration(await impact(), refreshed, true); + for (let repeat = 0; repeat < 3; repeat++) { + expectGeneration(await impact(), await context(), true); + const introduced = await heldBackend.callTool('context', { + repo: REPO, + name: replacement.entry.name, + }); + expect(introduced).not.toHaveProperty('error'); + expect(introduced.status).toBe('found'); + expect(introduced.symbol).toMatchObject({ + uid: replacement.entry.id, + name: replacement.entry.name, + filePath: replacement.entry.filePath, + }); + expect(introduced.processes).toEqual([ + { id: nextProcess.id, name: nextProcess.label, step_index: 0, step_count: 3 }, + ]); + } + } finally { + try { + await backend?.dispose(); + } finally { + await adapter.closeLbug(); + vi.unstubAllEnvs(); + await temp.cleanup(); + } + } + }); +}); diff --git a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts index 0390caab6..be6d32215 100644 --- a/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts +++ b/gitnexus/test/unit/calltool-dispatch-id-bridge.test.ts @@ -159,9 +159,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - // Target resolution (WHERE n.name = $symName) and any other read. - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -195,8 +194,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -230,8 +229,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee'), frontierRow('func:callee-B', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { @@ -266,8 +265,8 @@ describe('LocalBackend PDG impact — resolved-callee-id bridge (U6)', () => { if (query.includes('r.type IN $relTypes') && !query.includes('STEP_IN_PROCESS')) { return [frontierRow('func:callee-A', 'callee'), frontierRow('*', 'callee')]; } - if (query.includes('COUNT(DISTINCT s.id)') || query.includes('RETURN s.id AS sid')) return []; - return [TARGET_ROW]; + if (query.includes('WHERE n.name = $symName')) return [TARGET_ROW]; + return []; }); const result = await backend.callTool('impact', { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index aeefc94a4..7eaf2e1ff 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -183,6 +183,16 @@ function setupNoRepos() { (listRegisteredRepos as any).mockResolvedValue([]); } +/** Seed resolver rows without inventing relationship/process rows for other projections. */ +function mockSymbolRows(rows: Record[]) { + (executeParameterized as any).mockImplementation( + async (_repo: string, query: string, params: Record) => { + if (query.includes('COUNT(*) AS total')) return [{ total: rows.length }]; + return params?.symName || params?.uid ? rows : []; + }, + ); +} + const duplicateFixtureDirs: string[] = []; function makeDuplicateNameFixture() { @@ -1321,7 +1331,7 @@ describe('LocalBackend.callTool', () => { }); it('dispatches context tool', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:main', name: 'main', @@ -1663,27 +1673,22 @@ describe('LocalBackend.callTool', () => { }); it('exact File path wins over suffixed matches during qualified resolution (#3084 review P2)', async () => { - (executeParameterized as any).mockImplementation(async (_repo: string, query: string) => { - if (query.startsWith('MATCH (n)')) { - return [ - { - id: 'File:src/lib/a.ts', - name: 'a.ts', - filePath: 'src/lib/a.ts', - kind: 'File', - total_hits: 1, - }, - { - id: 'File:lib/a.ts', - name: 'a.ts', - filePath: 'lib/a.ts', - kind: 'File', - total_hits: 1, - }, - ]; - } - return [{ total: 2 }]; - }); + mockSymbolRows([ + { + id: 'File:src/lib/a.ts', + name: 'a.ts', + filePath: 'src/lib/a.ts', + kind: 'File', + total_hits: 1, + }, + { + id: 'File:lib/a.ts', + name: 'a.ts', + filePath: 'lib/a.ts', + kind: 'File', + total_hits: 1, + }, + ]); const result = await backend.callTool('context', { name: 'lib/a.ts' }); expect(result).toMatchObject({ @@ -2005,7 +2010,7 @@ describe('LocalBackend.callTool', () => { }); it('context tool ranks file_path match higher than non-match (#470)', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:handleConnect:1', name: 'handleConnect', @@ -2044,7 +2049,7 @@ describe('LocalBackend.callTool', () => { // review): both candidates satisfy the file_path hint (so DB // pre-filter would return both in production), and promotion is // determined purely by the combined file_path + kind score. - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'fn:App:1', name: 'render', @@ -2135,7 +2140,7 @@ describe('LocalBackend.callTool', () => { it('impact tool returns ambiguous shape with ranked candidates when target has multiple matches (#470)', async () => { // resolveSymbolCandidates issues a single name query; mock it to return // two Function rows in different files with no hints. - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:login:1', name: 'login', @@ -2222,7 +2227,7 @@ describe('LocalBackend.callTool', () => { // Resolver returns target; BFS returns one frontier caller; no STEP_IN_PROCESS rows. (executeParameterized as any).mockImplementation((_repoId: string, cypher: string) => { // BFS frontier query is now parameterized (#1907 U3). - if (cypher.includes('r.type IN') && !cypher.includes('STEP_IN_PROCESS')) { + if (cypher.includes('$frontierIds')) { return Promise.resolve([ { id: 'func:caller', @@ -2234,10 +2239,12 @@ describe('LocalBackend.callTool', () => { }, ]); } - // Symbol resolution. - return Promise.resolve([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); + // Symbol resolution; unseeded enrichment queries return no rows. + return Promise.resolve( + cypher.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : [], + ); }); (executeQuery as any).mockResolvedValue([]); @@ -2974,7 +2981,7 @@ describe('LocalBackend.callTool', () => { }); it('dispatches "explore" as alias for context', async () => { - (executeParameterized as any).mockResolvedValue([ + mockSymbolRows([ { id: 'func:main', name: 'main', @@ -3007,9 +3014,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { // dispatch (callgraph BFS or the PDG traversal). The callgraph BFS then issues // executeQuery for its frontier; the PDG path delegates to runImpactPDG. function resolveSingleTarget() { - (executeParameterized as any).mockResolvedValue([ - { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, - ]); + mockSymbolRows([{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]); (executeQuery as any).mockResolvedValue([]); } @@ -3266,18 +3271,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' + downstream line:8 routes to the PDG traversal and seeds bridge evidence", async () => { resolveSingleTarget(); - // The target-resolution row doubles as the calleesOfBlocks row: `callees` - // ('callee') is the leaf name persisted on the slice's BasicBlock, the - // statement-precise substrate the bridge keys on. - (executeParameterized as any).mockResolvedValue([ - { - id: 'func:main', - name: 'main', - type: 'Function', - filePath: 'src/index.ts', - callees: 'callee', - }, - ]); + // BasicBlock callees and symbol lookup use distinct native projections. + vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { + if (query.includes('RETURN b.callees')) return [{ callees: 'callee' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; + }); // A line-seeded downstream slice with one reachable block → the dispatch // queries that block's callees and seeds the bridge with them. const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ @@ -3402,11 +3402,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { // is not built and the inter-procedural reach falls back to callgraph-equal — // never surfacing the error or producing a partial proven/unproven labeling. resolveSingleTarget(); - // The slice-callees query (RETURN b.callees) throws; every other query (target - // resolution) returns the resolved symbol row. + // The slice-callees query throws; lookup returns the target and unseeded + // relationship/process projections return no rows. vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { if (query.includes('RETURN b.callees')) throw new Error('slice-callees query failed'); - return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; }); // A line-seeded downstream slice so calleesOfBlocks is attempted. vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ @@ -3461,7 +3463,9 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { resolveSingleTarget(); vi.mocked(executeParameterized).mockImplementation(async (_repo, query) => { if (query.includes('RETURN b.callees')) throw new Error('Table BasicBlock does not exist'); - return [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }]; + return query.includes('$symName') + ? [{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }] + : []; }); vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ mode: 'pdg', diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index e068870f9..7f4d18bd8 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -79,6 +79,8 @@ describe('impact: batching and grouping', () => { // Handle parameterized calls (including chunked STEP_IN_PROCESS queries) executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; const params = args[2] || {}; // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), // not the per-symbol enrichment pass added by impact byDepth processes @@ -91,6 +93,7 @@ describe('impact: batching and grouping', () => { const idx = chunkCallIndex++; return [ { + pId: 'proc-' + idx, entryPointId: `ep-${Math.floor(idx)}`, epName: `epName-${idx}`, epType: 'Function', @@ -148,6 +151,8 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; // BFS frontier query (parameterized #1907 U3): return 6 impacted nodes. if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { const res: any[] = []; @@ -166,6 +171,7 @@ describe('impact: batching and grouping', () => { // For STEP_IN_PROCESS in this test, return grouping rows return [ { + pId: 'proc-1a', entryPointId: 'ep-1', epName: 'EP1', epType: 'Function', @@ -174,6 +180,7 @@ describe('impact: batching and grouping', () => { minStep: 1, }, { + pId: 'proc-2', entryPointId: 'ep-2', epName: 'EP2', epType: 'Function', @@ -182,6 +189,7 @@ describe('impact: batching and grouping', () => { minStep: 2, }, { + pId: 'proc-1b', entryPointId: 'ep-1', epName: 'EP1', epType: 'Function', @@ -190,6 +198,7 @@ describe('impact: batching and grouping', () => { minStep: 3, }, { + pId: 'proc-3', entryPointId: 'ep-3', epName: 'EP3', epType: 'Function', @@ -245,6 +254,8 @@ describe('impact: batching and grouping', () => { executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; const params = args[2] || {}; // Match only the aggregation chunk (which uses COUNT(DISTINCT s.id)), // not the per-symbol enrichment pass added by impact byDepth processes @@ -254,6 +265,7 @@ describe('impact: batching and grouping', () => { chunkSizes.push(ids.length); return [ { + pId: 'proc-x-' + chunkSizes.length, entryPointId: 'ep-x', epName: 'EPX', epType: 'Function', @@ -351,6 +363,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { return [ { @@ -394,6 +407,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('STEP_IN_PROCESS')) { throw new Error('process chunk failed'); } @@ -443,6 +457,8 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('MEMBER_OF')) throw new Error('module chunk failed'); if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { return [ @@ -500,6 +516,8 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('MIN(r.step) AS minStep') && !query.includes('COUNT(DISTINCT s.id)')) { throw new Error('minStep backfill failed'); } @@ -559,6 +577,8 @@ describe('impact: batching and grouping', () => { let processChunk = 0; executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; + if (query.includes('RETURN s.id AS sid')) return []; if (query.includes('STEP_IN_PROCESS') && query.includes('COUNT(DISTINCT s.id)')) { processChunk += 1; if (processChunk === 2) throw new Error('later process chunk failed'); @@ -615,6 +635,7 @@ describe('impact: batching and grouping', () => { executeQueryMock.mockImplementation(async () => []); executeParameterizedMock.mockImplementation(async (...args: any[]) => { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('RETURN h.id AS hid')) return []; if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT c.heuristicLabel')) { throw new Error('module classification failed'); } diff --git a/gitnexus/test/unit/impact-context-integrity.test.ts b/gitnexus/test/unit/impact-context-integrity.test.ts new file mode 100644 index 000000000..5ad29172b --- /dev/null +++ b/gitnexus/test/unit/impact-context-integrity.test.ts @@ -0,0 +1,847 @@ +/** Corrupt detail rows must not become usable context/impact answers (#3354). */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { db, aop } = vi.hoisted(() => ({ + db: { + initLbug: vi.fn().mockResolvedValue(undefined), + executeQuery: vi.fn().mockResolvedValue([]), + executeParameterized: vi.fn().mockResolvedValue([]), + closeLbug: vi.fn().mockResolvedValue(undefined), + isLbugReady: vi.fn().mockReturnValue(true), + }, + aop: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...db, +})); +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...db, +})); +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([ + { + name: 'integrity-fixture', + path: '/tmp/integrity-fixture', + storagePath: '/tmp/integrity-fixture/.gitnexus', + indexedAt: '2026-10-03T12:00:00Z', + lastCommit: 'fixture', + stats: { files: 2, nodes: 2, edges: 1, communities: 0, processes: 1 }, + }, + ]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), + loadMeta: vi.fn().mockResolvedValue({ + pdg: { maxCdgEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 }, + }), +})); +vi.mock('../../src/core/git-staleness.js', () => ({ + checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }), + checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }), + checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }), +})); +vi.mock('../../src/storage/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + getGitRoot: vi.fn().mockReturnValue(null), +})); +vi.mock('../../src/mcp/local/aop-metadata.js', () => ({ querySpringAopMetadata: aop })); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { SymbolIdentityError } from '../../src/mcp/local/query-result-integrity.js'; + +const TARGET = { + id: 'func:target', + name: 'target', + type: 'Function', + filePath: 'src/target.ts', + startLine: 1, + endLine: 4, +}; +const REF = { + relType: 'CALLS', + uid: 'func:caller', + name: 'caller', + filePath: 'src/caller.ts', + kind: 'Function', +}; +const EDGE = { + sourceId: TARGET.id, + id: REF.uid, + name: REF.name, + type: REF.kind, + filePath: REF.filePath, + relType: 'CALLS', + confidence: 1, +}; +const PROCESS = { + pId: 'proc:caller', + name: 'Caller flow', + processType: 'intra_community', + entryPointId: REF.uid, + hits: 1, + minStep: 0, + stepCount: 2, + epName: REF.name, + epType: REF.kind, + epFilePath: REF.filePath, +}; +const BAD = 'corrupt\0persisted-value'; + +type Seam = + | 'target' + | 'targetLabels' + | 'aopRows' + | 'pdgSeed' + | 'pdgNeighbor' + | 'pdgOwner' + | 'pdgStatement' + | 'pdgSelf' + | 'pdgCalleeBlocks' + | 'pdgSummary' + | 'pdgSpans' + | 'incoming' + | 'classIncoming' + | 'outgoing' + | 'typedProperties' + | 'contextProcess' + | 'contextRoute' + | 'interfaceBoundary' + | 'interfaceCount' + | 'chain' + | 'seeds' + | 'members' + | 'frontier' + | 'process' + | 'backfill' + | 'membership' + | 'modules' + | 'impactRoute' + | 'metadata'; +let backend: LocalBackend; +let rows: Partial>; +let failedSeam: Seam | undefined; + +function fixture(seam: Seam): unknown[] { + if (failedSeam === seam) throw new Error('ordinary unavailable query'); + return rows[seam] ?? []; +} + +function querySeam(query: string, params: Record | undefined): Seam | undefined { + if (params?.symName || params?.uid) return 'target'; + if (query.includes("RETURN n.id AS id, 'Class' AS label")) return 'targetLabels'; + if (query.includes("r.reason STARTS WITH 'spring-aop:v1:'")) return 'aopRows'; + if (query.includes('RETURN s.id AS id, s.name AS name')) return 'pdgOwner'; + if (query.includes('RETURN s.id AS id, s.filePath AS filePath')) return 'pdgSpans'; + if (query.includes('RETURN c.id AS id, r.reason AS reason')) return 'pdgSummary'; + if (query.includes('RETURN a.id AS id, r.reason AS reason')) return 'pdgSelf'; + if (query.includes('RETURN a.id AS id ORDER BY a.startLine')) return 'pdgSeed'; + if (query.includes('RETURN b.id AS id')) { + if (query.includes('b.calleeIds AS calleeIds')) return 'pdgCalleeBlocks'; + if (query.includes('b.text AS text')) return 'pdgStatement'; + return 'pdgSeed'; + } + if (query.includes('BasicBlock') && query.includes('RETURN DISTINCT')) return 'pdgNeighbor'; + if (query.includes('WITH DISTINCT caller') || query.includes('WITH DISTINCT target')) + return 'chain'; + if (query.includes('caller.id AS uid')) + return query.includes('(ctor:Constructor)') ? 'classIncoming' : 'incoming'; + if (query.includes('target.id AS uid')) return 'outgoing'; + if (query.includes('RETURN p.id AS uid')) return 'typedProperties'; + if (query.includes('RETURN p.id AS pid, p.heuristicLabel AS label')) return 'contextProcess'; + if (query.includes('RETURN route.name AS url')) return 'contextRoute'; + if (query.includes('RETURN DISTINCT iface.id AS id')) return 'interfaceBoundary'; + if (query.includes('RETURN COUNT(DISTINCT other.id) AS cnt')) return 'interfaceCount'; + if ( + query.includes('RETURN c.id AS id') || + query.includes('RETURN f.id AS id') || + query.includes('RETURN p.id AS id') + ) + return 'seeds'; + if (query.includes('RETURN DISTINCT member.id AS id')) return 'members'; + if (query.includes('AS sourceId')) return 'frontier'; + if (query.includes('RETURN p.id AS pId')) return 'process'; + if (query.includes('RETURN p.id AS pid, MIN(r.step) AS minStep')) return 'backfill'; + if (query.includes('RETURN s.id AS sid')) return 'membership'; + if (query.includes('c.heuristicLabel AS name')) return 'modules'; + if (query.includes('RETURN h.id AS hid')) return 'impactRoute'; + if (query.includes('n.visibility AS visibility')) return 'metadata'; + return undefined; +} + +async function context(extra = {}) { + return backend.callTool('context', { name: TARGET.name, ...extra }); +} +async function impact(extra = {}) { + return backend.callTool('impact', { + target: TARGET.name, + direction: 'upstream', + maxDepth: 1, + ...extra, + }); +} +function expectIntegrityError(result: any, isImpact = false) { + expect(result.error).toMatch(/invalid symbol identity/i); + expect(result.recoverySuggestion).toMatch(/analyze.*--force/); + expect(JSON.stringify(result)).not.toContain('persisted-value'); + expect(result).not.toHaveProperty('symbol'); + expect(result).not.toHaveProperty('incoming'); + if (isImpact) { + expect(result.risk).toBe('UNKNOWN'); + expect(result.impactedCount).toBeNull(); + expect(result.epistemic).not.toBe('exact'); + } +} +function tuple(value: Record, keys: string[]): unknown[] { + return keys.map((key) => value[key]); +} + +beforeEach(async () => { + vi.clearAllMocks(); + aop.mockResolvedValue(undefined); + failedSeam = undefined; + rows = { target: [{ ...TARGET }], frontier: [{ ...EDGE }] }; + db.executeParameterized.mockImplementation(async (_db, query, params) => { + const seam = querySeam(query, params); + return seam ? fixture(seam) : []; + }); + backend = new LocalBackend(); + await backend.init(); + vi.spyOn(backend as any, 'ensureInitialized').mockResolvedValue(undefined); + vi.spyOn(backend as any, 'computeEpistemicBoundary').mockResolvedValue({ epistemic: 'exact' }); +}); + +describe('identity corruption before target selection', () => { + for (const corrupt of [true, false]) { + it('distinguishes corrupt and ordinary ambiguous candidate failures: ' + corrupt, async () => { + rows.target = [ + { ...TARGET, id: 'func:one', filePath: 'src/one.ts' }, + { ...TARGET, id: 'func:two', filePath: 'src/two.ts' }, + ]; + vi.spyOn(backend as any, '_runImpactBFS').mockRejectedValue( + corrupt ? new SymbolIdentityError() : new Error('ordinary candidate failure'), + ); + const result = await impact(); + if (corrupt) { + expectIntegrityError(result, true); + } else { + expect(result.error).toBeUndefined(); + expect(result.status).toBe('ambiguous'); + expect(result.partialProbe).toBe(true); + } + }); + } + for (const tool of ['context', 'impact']) { + for (const badRow of [ + { id: BAD, label: 'Class' }, + { id: '', label: 'Class' }, + { id: 42, label: 'Class' }, + { id: TARGET.id, label: BAD }, + ]) { + it('rejects corrupt label enrichment for ' + tool + JSON.stringify(badRow), async () => { + rows.target = [{ ...TARGET, type: '' }]; + rows.targetLabels = [badRow, { id: TARGET.id, label: 'Class' }]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + for (const shape of ['object', 'tuple']) { + for (const field of ['name', 'filePath']) { + for (const tool of ['context', 'impact', 'pdg impact']) { + it('rejects NUL in target ' + field + ' from ' + shape + ' rows for ' + tool, async () => { + const target = { ...TARGET, [field]: BAD }; + rows.target = [ + shape === 'tuple' + ? tuple(target, ['id', 'name', 'type', 'filePath', 'startLine', 'endLine']) + : target, + ]; + expectIntegrityError( + tool === 'context' + ? await context() + : await impact(tool === 'pdg impact' ? { mode: 'pdg' } : {}), + tool !== 'context', + ); + }); + } + } + } + it('rejects corrupt exact-UID metadata before expansion', async () => { + rows.target = [{ ...TARGET, filePath: BAD }]; + expectIntegrityError(await context({ uid: TARGET.id })); + expectIntegrityError(await impact({ target_uid: TARGET.id }), true); + }); + for (const field of ['name', 'filePath']) { + it('rejects non-string target ' + field, async () => { + rows.target = [{ ...TARGET, [field]: 42 }]; + expectIntegrityError(await context()); + }); + } + it('validates every candidate before exact File narrowing', async () => { + rows.target = [ + { ...TARGET, id: 'File:src/target.ts', name: 'target.ts', type: 'File' }, + { ...TARGET, id: 'func:other', filePath: BAD }, + ]; + expectIntegrityError(await context({ name: TARGET.filePath })); + }); +}); + +describe('context detail row integrity', () => { + for (const seam of ['incoming', 'outgoing'] as const) { + for (const shape of ['object', 'tuple']) { + for (const field of ['uid', 'name', 'filePath']) { + it('rejects NUL in ' + seam + ' ' + field + ' from ' + shape + ' rows', async () => { + const ref = { ...REF, [field]: BAD }; + rows[seam] = [ + shape === 'tuple' ? tuple(ref, ['relType', 'uid', 'name', 'filePath', 'kind']) : ref, + ]; + expectIntegrityError(await context()); + }); + } + for (const badRow of [null, {}, [], { ...REF, uid: '' }, { ...REF, relType: '' }]) { + it( + 'rejects incomplete ' + + seam + + ' row ' + + JSON.stringify(badRow) + + ' in ' + + shape + + ' response', + async () => { + rows[seam] = [ + shape === 'tuple' && badRow !== null + ? tuple(badRow, ['relType', 'uid', 'name', 'filePath', 'kind']) + : badRow, + ]; + expectIntegrityError(await context()); + }, + ); + } + } + } + for (const badRow of [ + null, + {}, + [''], + { pid: '' }, + { pid: 'proc:target', label: BAD }, + ['proc:target', BAD, 0, 0], + ]) { + it('rejects corrupt context process ' + JSON.stringify(badRow), async () => { + rows.contextProcess = [badRow]; + expectIntegrityError(await context()); + }); + } + it('does not swallow corrupt class expansion or typed property rows', async () => { + rows.target = [{ ...TARGET, type: 'Class' }]; + rows.typedProperties = [{ uid: 'prop:target', name: 'prop', filePath: BAD, kind: 'Property' }]; + expectIntegrityError(await context()); + }); + it('rejects corrupt class refs before deduplication can discard them', async () => { + rows.target = [{ ...TARGET, type: 'Class' }]; + rows.incoming = [REF]; + rows.classIncoming = [{ ...REF, filePath: BAD }]; + expectIntegrityError(await context()); + }); + for (const badRow of [{}, { ...REF, filePath: BAD }, { ...REF, uid: '' }]) { + it('does not swallow corrupt chain rows ' + JSON.stringify(badRow), async () => { + rows.chain = [badRow]; + expectIntegrityError(await context({ chain_depth: 1 })); + }); + } + it('rejects NUL in route names', async () => { + rows.contextRoute = [{ url: BAD, method: 'GET' }]; + expectIntegrityError(await context()); + }); + it('rejects nested AOP identity fields while keeping the shared error envelope', async () => { + aop.mockResolvedValue({ + framework: 'spring', + advices: [{ adviceId: 'advice:1', adviceName: BAD }], + }); + expectIntegrityError(await context()); + }); +}); + +describe('epistemic boundary row integrity', () => { + const iface = { id: 'iface:target', name: 'Target contract', label: 'Interface' }; + + function prepareBoundary() { + vi.mocked((backend as any).computeEpistemicBoundary).mockRestore(); + rows.interfaceBoundary = [iface]; + rows.interfaceCount = [{ cnt: 2 }]; + } + + for (const tool of ['context', 'impact']) { + for (const shape of ['object', 'tuple']) { + for (const badRow of [ + { ...iface, id: undefined }, + { ...iface, id: '' }, + { ...iface, id: BAD }, + { ...iface, id: 42 }, + { ...iface, name: BAD }, + { ...iface, name: 42 }, + { ...iface, label: BAD }, + { ...iface, label: 42 }, + ]) { + it( + 'rejects corrupt ' + + tool + + ' boundary before deduplication: ' + + shape + + JSON.stringify(badRow), + async () => { + prepareBoundary(); + rows.interfaceBoundary = [iface, badRow].map((row) => + shape === 'tuple' ? tuple(row, ['id', 'name', 'label']) : row, + ); + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }, + ); + } + } + for (const seam of ['interfaceBoundary', 'interfaceCount'] as const) { + for (const corrupt of [true, false]) { + it( + 'distinguishes ' + tool + ' boundary query failure: ' + seam + ' ' + corrupt, + async () => { + prepareBoundary(); + db.executeParameterized.mockImplementation(async (_db, query, params) => { + const currentSeam = querySeam(query, params); + if (currentSeam === seam) { + throw corrupt ? new SymbolIdentityError() : new Error('ordinary boundary failure'); + } + return currentSeam ? fixture(currentSeam) : []; + }); + const result = tool === 'context' ? await context() : await impact(); + if (corrupt) { + expectIntegrityError(result, tool === 'impact'); + } else { + expect(result.error).toBeUndefined(); + expect(result.recoverySuggestion).toBeUndefined(); + expect(result.epistemic).toBe('lower-bound'); + if (tool === 'impact') expect(result.impactedCount).toBe(1); + } + }, + ); + } + } + it('preserves healthy ' + tool + ' boundary descriptions', async () => { + prepareBoundary(); + const result = tool === 'context' ? await context() : await impact(); + expect(result.error).toBeUndefined(); + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries).toContainEqual( + expect.stringContaining('Target contract is an interface'), + ); + expect(result.causes.dispatchBoundary).toBe(4); + }); + } +}); + +describe('impact detail row integrity', () => { + for (const seam of ['frontier', 'process'] as const) { + it( + 'PDG detail integrity rejects corrupt ' + seam + ' rows through the outer envelope', + async () => { + rows[seam] = [ + seam === 'frontier' ? { ...EDGE, filePath: BAD } : { ...PROCESS, epName: BAD }, + ]; + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }, + ); + } + for (const shape of ['object', 'tuple']) { + for (const field of ['id', 'name', 'filePath', 'sourceId']) { + it('rejects NUL in frontier ' + field + ' from ' + shape + ' rows', async () => { + const edge = { ...EDGE, [field]: BAD }; + rows.frontier = [ + shape === 'tuple' + ? tuple(edge, [ + 'sourceId', + 'id', + 'name', + 'type', + 'filePath', + 'relType', + 'confidence', + 'staticGated', + ]) + : edge, + ]; + expectIntegrityError(await impact(), true); + }); + } + } + for (const badRow of [null, {}, [], { ...EDGE, id: '' }]) { + it('rejects incomplete frontier rows ' + JSON.stringify(badRow), async () => { + rows.frontier = [badRow]; + expectIntegrityError(await impact(), true); + }); + } + it('validates corrupt rows before test filtering', async () => { + rows.frontier = [{ ...EDGE, filePath: 'test/corrupt\0.test.ts' }]; + expectIntegrityError(await impact({ includeTests: false }), true); + }); + for (const shape of ['object', 'tuple']) { + for (const field of ['pId', 'name', 'entryPointId', 'epName', 'epFilePath']) { + it('rejects NUL in process ' + field + ' from ' + shape + ' rows', async () => { + const process = { ...PROCESS, [field]: BAD }; + rows.process = [ + shape === 'tuple' + ? tuple(process, [ + 'pId', + 'name', + 'processType', + 'entryPointId', + 'hits', + 'minStep', + 'stepCount', + 'epName', + 'epType', + 'epFilePath', + ]) + : process, + ]; + expectIntegrityError(await impact({ summaryOnly: true }), true); + }); + } + } + for (const badRow of [null, {}, [], { ...PROCESS, pId: '' }]) { + it('does not aggregate incomplete processes ' + JSON.stringify(badRow), async () => { + rows.process = [badRow]; + expectIntegrityError(await impact(), true); + }); + } + for (const badRow of [{}, { pid: BAD, minStep: 1 }]) { + it( + 'does not swallow corrupt backfill process identities ' + JSON.stringify(badRow), + async () => { + rows.process = [{ ...PROCESS, minStep: null }]; + rows.backfill = [badRow]; + expectIntegrityError(await impact(), true); + }, + ); + } + for (const badRow of [ + {}, + { sid: REF.uid, pid: '' }, + { sid: REF.uid, pid: 'proc:caller', pName: BAD }, + ]) { + it( + 'does not swallow corrupt per-symbol process identities ' + JSON.stringify(badRow), + async () => { + rows.process = [PROCESS]; + rows.membership = [badRow]; + expectIntegrityError(await impact(), true); + }, + ); + } + for (const type of ['Class', 'Const']) { + for (const badRow of [{}, { ...TARGET, id: 'seed:1', filePath: BAD }]) { + it('rejects corrupt ' + type + ' seeds ' + JSON.stringify(badRow), async () => { + rows.target = [{ ...TARGET, type }]; + rows[type === 'Class' ? 'seeds' : 'members'] = [badRow]; + expectIntegrityError(await impact({ direction: 'downstream' }), true); + }); + } + } + it('rejects NUL in module names before aggregation', async () => { + rows.modules = [{ name: BAD, hits: 1 }]; + expectIntegrityError(await impact(), true); + }); + it('rejects NUL in route names', async () => { + rows.impactRoute = [{ hid: REF.uid, url: BAD }]; + expectIntegrityError(await impact(), true); + }); + for (const shape of ['object', 'tuple']) { + for (const hid of [undefined, null, '', ' ', BAD, 42, {}]) { + it( + 'rejects corrupt route handler IDs from ' + shape + ' rows: ' + JSON.stringify(hid), + async () => { + const route = { hid, url: '/target', method: 'GET' }; + rows.impactRoute = [shape === 'tuple' ? tuple(route, ['hid', 'url', 'method']) : route]; + expectIntegrityError(await impact(), true); + }, + ); + } + } + it('rejects nested AOP paths', async () => { + aop.mockResolvedValue({ + framework: 'spring', + advices: [{ adviceId: 'advice:1', adviceFilePath: BAD }], + }); + expectIntegrityError(await impact(), true); + }); +}); + +describe('healthy and ordinary-failure compatibility', () => { + it('preserves Unicode, opaque IDs, optional NULL metadata and source NUL', async () => { + const content = 'const value = "actual\0source";'; + rows.target = [{ ...TARGET, name: 'café�', filePath: '源/café�.ts', content }]; + rows.incoming = [{ ...REF, name: '呼び出し�', filePath: null, kind: '' }]; + rows.contextProcess = [ + { pid: 'legacy:proc ', label: '', step: 0, stepCount: 0, entryPointId: null }, + ]; + rows.metadata = [{ annotations: ['@Text("source\0value")'], parameterTypes: null }]; + const result = await context({ include_content: true }); + expect(result.error).toBeUndefined(); + expect(result.symbol).toMatchObject({ + uid: TARGET.id, + name: 'café�', + filePath: '源/café�.ts', + content, + }); + expect(result.symbol.methodMetadata).toEqual({ annotations: ['@Text("source\0value")'] }); + expect(result.incoming.calls[0]).toMatchObject({ uid: REF.uid, name: '呼び出し�' }); + expect(result.processes).toEqual([ + { id: 'legacy:proc ', name: undefined, step_index: 0, step_count: 0 }, + ]); + }); + it('preserves tuple zero steps and empty process labels', async () => { + rows.contextProcess = [['proc:0', '', 0, 0, null]]; + expect((await context()).processes).toEqual([ + { id: 'proc:0', name: '', step_index: 0, step_count: 0 }, + ]); + }); + it('allows absent OPTIONAL MATCH entry-point fields and missing legacy sourceId', async () => { + rows.frontier = [{ ...EDGE, sourceId: undefined }]; + rows.process = [ + { ...PROCESS, name: '', entryPointId: null, epName: null, epType: null, epFilePath: null }, + ]; + const result = await impact(); + expect(result.error).toBeUndefined(); + expect(result.impactedCount).toBe(1); + expect(result.affected_processes).toEqual([ + { + name: 'unknown', + type: 'Function', + filePath: '', + affected_process_count: 1, + total_hits: 1, + earliest_broken_step: 0, + }, + ]); + }); + it('keeps missing optional route fields as ordinary skipped enrichment', async () => { + rows.contextRoute = [{}]; + rows.impactRoute = [{ hid: REF.uid }]; + expect((await context()).error).toBeUndefined(); + expect((await impact()).error).toBeUndefined(); + }); + it('does not misdiagnose an unmatched user-supplied NUL as index corruption', async () => { + rows.target = []; + const contextResult = await context({ name: 'client\0input' }); + const impactResult = await impact({ target: 'client\0input' }); + expect(contextResult.error).toContain('not found'); + expect(impactResult.error).toContain('not found'); + expect(contextResult.recoverySuggestion).toBeUndefined(); + expect(impactResult.recoverySuggestion).toBeUndefined(); + }); + it('keeps ordinary process query failures degraded rather than integrity errors', async () => { + failedSeam = 'process'; + const result = await impact(); + expect(result.error).toBeUndefined(); + expect(result.partial).toBe(true); + expect(result.impactedCount).toBe(1); + expect(result.affected_processes).toEqual([]); + }); + it('keeps ordinary PDG interprocedural failures as degraded results', async () => { + vi.spyOn(backend as any, '_runImpactBFS').mockRejectedValue( + new Error('ordinary bridge failure'), + ); + const result = await impact({ mode: 'pdg' }); + expect(result.error).toBeUndefined(); + expect(result.partial).toBe(true); + expect(result.interproceduralError).toBe('ordinary bridge failure'); + expect(result.recoverySuggestion).toBeUndefined(); + }); + it('keeps ordinary context process query failures as unavailable enrichment', async () => { + failedSeam = 'contextProcess'; + const result = await context(); + expect(result.error).toBeUndefined(); + expect(result.processes).toEqual([]); + }); +}); + +describe('raw PDG identities before coercion, caps, and projection', () => { + const seed = 'BasicBlock:src/target.ts:2:0:0'; + const reached = 'BasicBlock:src/caller.ts:1:0:0'; + + function preparePdg() { + rows.pdgSeed = [{ id: seed }]; + rows.pdgNeighbor = [{ id: reached }]; + rows.pdgOwner = [{ id: REF.uid, name: REF.name, label: 'Function', startLine: 0 }]; + rows.pdgStatement = [{ id: reached, line: 1, endLine: 1, text: 'value = 1;' }]; + } + + for (const seam of ['pdgSeed', 'pdgNeighbor', 'pdgOwner', 'pdgStatement'] as const) { + for (const bad of [BAD, '', null, 42]) { + it('rejects raw ' + seam + ' identity ' + JSON.stringify(bad), async () => { + preparePdg(); + rows[seam] = [{ id: bad, name: 'caller', label: 'Function', line: 1, startLine: 0 }]; + expectIntegrityError( + await impact({ mode: 'pdg', ...(seam === 'pdgStatement' ? { line: 2 } : {}) }), + true, + ); + }); + } + } + for (const seam of ['pdgSeed', 'pdgNeighbor'] as const) { + it('validates ' + seam + ' cap probe rows', async () => { + preparePdg(); + rows[seam] = [{ id: seam === 'pdgSeed' ? seed : reached }, { id: BAD }]; + expectIntegrityError(await impact({ mode: 'pdg', limit: 1 }), true); + }); + } + for (const field of ['name', 'label']) { + it('rejects raw owner ' + field + ' before String coercion', async () => { + preparePdg(); + rows.pdgOwner = [{ id: REF.uid, name: 'caller', label: 'Function', [field]: BAD }]; + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }); + } + for (const field of ['seedBlocks', 'reachableBlocks', 'intraReachableBlocks']) { + for (const bad of [BAD, '', null, 42]) { + it('rejects malformed final ' + field + ' members ' + JSON.stringify(bad), async () => { + const healthy = await impact({ mode: 'pdg' }); + vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValue({ + ...healthy, + [field]: [bad], + }); + expectIntegrityError(await impact({ mode: 'pdg' }), true); + }); + } + } + it('allows a generated unresolved owner marker', async () => { + preparePdg(); + rows.pdgOwner = []; + const result = await impact({ mode: 'pdg' }); + expect(result.error).toBeUndefined(); + expect(result.unresolvedBlockCount).toBe(1); + }); + it('preserves Unicode owner tuples and source NUL', async () => { + preparePdg(); + rows.pdgOwner = [[REF.uid, '呼び出し�', 'Function', 0]]; + expect((await impact({ mode: 'pdg' })).error).toBeUndefined(); + rows.pdgStatement = [{ id: reached, line: 1, endLine: 1, text: 'value = "source\0text";' }]; + const result = await impact({ mode: 'pdg', line: 2 }); + expect(result.error).toBeUndefined(); + expect(result.affectedStatements.some((s: any) => s.text.includes('\0'))).toBe(true); + }); + for (const field of ['callees', 'calleeIds']) { + it('does not swallow a corrupt statement bridge ' + field, async () => { + preparePdg(); + const original = db.executeParameterized.getMockImplementation()!; + db.executeParameterized.mockImplementation(async (...args) => { + if (args[1].includes('RETURN b.' + field + ' AS ' + field)) { + return [{ [field]: BAD }]; + } + return original(...args); + }); + expectIntegrityError(await impact({ mode: 'pdg', direction: 'downstream' }), true); + }); + } +}); + +describe('real AOP helper identities before deduplication', () => { + const reason = + 'spring-aop:v1:' + + JSON.stringify({ + kind: 'advice', + annotation: 'org.aspectj.lang.annotation.Around', + advice: 'around', + pointcut: 'execution(*)', + match: 'static', + activation: 'unknown', + proxy: 'possible', + }); + const advice = { + sourceId: TARGET.id, + sourceName: 'target', + sourceFilePath: TARGET.filePath, + targetId: 'advice:1', + targetName: 'audit', + targetFilePath: 'src/audit.ts', + reason, + }; + async function useRealAop() { + const actual = await vi.importActual( + '../../src/mcp/local/aop-metadata.js', + ); + aop.mockImplementation(actual.querySpringAopMetadata); + rows.target = [{ ...TARGET, type: 'Method' }]; + } + for (const tool of ['context', 'impact']) { + for (const field of ['sourceId', 'targetId']) { + for (const bad of [BAD, '', null, 42]) { + it('rejects raw AOP ' + field + ' for ' + tool + JSON.stringify(bad), async () => { + await useRealAop(); + rows.aopRows = [{ ...advice, [field]: bad }, advice]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + for (const field of ['sourceName', 'sourceFilePath', 'targetName', 'targetFilePath']) { + it('rejects corrupt duplicate AOP ' + field + ' for ' + tool, async () => { + await useRealAop(); + rows.aopRows = [{ ...advice, [field]: BAD }, advice]; + expectIntegrityError( + tool === 'context' ? await context() : await impact(), + tool === 'impact', + ); + }); + } + } + it('validates the AOP cap-probe row', async () => { + await useRealAop(); + rows.aopRows = [...Array.from({ length: 1000 }, () => advice), { ...advice, targetId: BAD }]; + expectIntegrityError(await context()); + }); + it('preserves Unicode and optional NULL metadata', async () => { + await useRealAop(); + rows.aopRows = [ + { ...advice, sourceName: '源�', sourceFilePath: null, targetName: '', targetFilePath: null }, + ]; + const result = await context(); + expect(result.error).toBeUndefined(); + expect(result.symbol.aop.advices[0]).toMatchObject({ + adviceId: advice.targetId, + advisedId: advice.sourceId, + advisedName: '源�', + }); + }); + it('keeps ordinary AOP query failures fail-soft', async () => { + await useRealAop(); + failedSeam = 'aopRows'; + expect((await context()).error).toBeUndefined(); + expect((await impact()).error).toBeUndefined(); + }); + it('handles early AOP rejection while the frontier is pending', async () => { + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + aop.mockRejectedValue(new SymbolIdentityError()); + const original = db.executeParameterized.getMockImplementation()!; + db.executeParameterized.mockImplementation(async (...args) => { + if (querySeam(args[1], args[2]) === 'frontier') { + await new Promise((resolve) => setTimeout(resolve, 30)); + } + return original(...args); + }); + try { + expectIntegrityError(await impact(), true); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); +}); diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts index 9b8aa8e21..0a710a71e 100644 --- a/gitnexus/test/unit/impact-pagination.test.ts +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -56,6 +56,7 @@ function setupMultiDepthHub(d1Count: number, d2Count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('RETURN h.id AS hid')) return []; // The #1858 epistemic-boundary probe (computeEpistemicBoundary) runs // concurrently with the BFS and also matches `r.type IN`, but targets the // `iface` alias. Return empty so it stays `epistemic: 'exact'` and does not @@ -99,6 +100,7 @@ function setupHubSymbol(count: number) { const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); if (query.includes('STEP_IN_PROCESS')) return []; if (query.includes('MEMBER_OF')) return []; + if (query.includes('RETURN h.id AS hid')) return []; // See setupMultiDepthHub — keep the #1858 epistemic probe from matching the // `r.type IN` caller branch below. if (query.includes('iface')) return []; diff --git a/gitnexus/test/unit/impact-route-enrichment.test.ts b/gitnexus/test/unit/impact-route-enrichment.test.ts index 9e9cc8791..d2b53031c 100644 --- a/gitnexus/test/unit/impact-route-enrichment.test.ts +++ b/gitnexus/test/unit/impact-route-enrichment.test.ts @@ -80,7 +80,10 @@ async function runImpact(routeRows: readonly RouteRow[], routeQueryFails = false : []; } if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return []; - return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }]; + if (query.includes('n.id AS id')) { + return [{ id: 'svc', name: 'UnfinalizeRound', filePath: 'svc.go', type: 'Method' }]; + } + return []; }); const backend = new LocalBackend(); diff --git a/gitnexus/test/unit/pdg-impact-engine.test.ts b/gitnexus/test/unit/pdg-impact-engine.test.ts index e163df4be..3ca021e0b 100644 --- a/gitnexus/test/unit/pdg-impact-engine.test.ts +++ b/gitnexus/test/unit/pdg-impact-engine.test.ts @@ -1,12 +1,95 @@ import { describe, expect, it } from 'vitest'; import { IMPACT_MAX_DEPTH } from '../../src/mcp/tools.js'; +import { CALLEES_TRUNCATED_SENTINEL } from '../../src/core/ingestion/cfg/callee-cell-format.js'; import { pdgLayerStatus, runImpactPDG, + splitCalleeIds, type RunPdgImpactDeps, } from '../../src/mcp/local/pdg-impact.js'; +import { SymbolIdentityError } from '../../src/mcp/local/query-result-integrity.js'; + +describe('splitCalleeIds', () => { + const firstId = 'Function:src/my dir/rené.cpp:unsigned char'; + const secondId = 'Function:src/my dir/rené.cpp:long double'; + + it.each([undefined, null, '', ' ', '\t', '\t \t'])( + 'preserves an entirely empty optional cell (%j)', + (cell) => { + expect(splitCalleeIds(cell)).toEqual([]); + }, + ); + + it('preserves spaces and Unicode within healthy callee identities', () => { + expect(splitCalleeIds(`${firstId}\t${secondId}`)).toEqual([firstId, secondId]); + }); + + it('drops the generated truncation sentinel without changing resolved identities', () => { + expect(splitCalleeIds(CALLEES_TRUNCATED_SENTINEL)).toEqual([]); + expect(splitCalleeIds(`${firstId}\t${CALLEES_TRUNCATED_SENTINEL}\t${secondId}`)).toEqual([ + firstId, + secondId, + ]); + }); + + it.each([ + ['leading', `\t${firstId}`], + ['interior', `${firstId}\t\t${secondId}`], + ['trailing', `${firstId}\t`], + ['whitespace-only token', `${firstId}\t \t${secondId}`], + ['before a sentinel', `\t${CALLEES_TRUNCATED_SENTINEL}`], + ['after a sentinel', `${CALLEES_TRUNCATED_SENTINEL}\t`], + ['mixed with a sentinel', `${firstId}\t\t${CALLEES_TRUNCATED_SENTINEL}\t${secondId}`], + ])('rejects a populated cell with an empty identity (%s)', (_case, cell) => { + expect(() => splitCalleeIds(cell)).toThrow(SymbolIdentityError); + }); +}); describe('runImpactPDG', () => { + it.each([ + ['leading', '\tFunction:src/hot.ts:callee'], + ['interior', 'Function:src/hot.ts:a\t\tFunction:src/hot.ts:b'], + ['trailing', 'Function:src/hot.ts:callee\t'], + ['sentinel-adjacent', `Function:src/hot.ts:callee\t${CALLEES_TRUNCATED_SENTINEL}\t`], + ])( + 'rejects an empty callee identity before interprocedural descent (%s)', + async (_case, cell) => { + const seed = 'BasicBlock:src/hot.ts:1:0:0'; + const queries: string[] = []; + const exec: RunPdgImpactDeps['executeParameterized'] = async (_repo, query) => { + queries.push(query); + if (query.includes('MATCH (a:BasicBlock) WHERE')) return [{ id: seed }]; + if (query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')) { + return [{ id: seed, calleeIds: cell, callees: 'callee' }]; + } + return []; + }; + + await expect( + runImpactPDG({ + repo: { lbugPath: 'repo' }, + sym: { + id: 'Function:src/hot.ts:hot', + name: 'hot', + filePath: 'src/hot.ts', + startLine: 0, + endLine: 3, + }, + symType: 'Function', + direction: 'downstream', + maxDepth: 2, + limit: 50, + line: 1, + executeParameterized: exec, + }), + ).rejects.toBeInstanceOf(SymbolIdentityError); + expect( + queries.some((query) => query.includes('RETURN b.id AS id, b.calleeIds AS calleeIds')), + ).toBe(true); + expect(queries.some((query) => query.includes("r.type = 'CALL_SUMMARY'"))).toBe(false); + }, + ); + it('clamps huge maxDepth values to the documented impact traversal cap', async () => { let bfsQueries = 0; const exec = async (_repo: string, query: string) => { diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 8bb6ff502..37c5889f3 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ include: [ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-context-integrity.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', @@ -162,6 +163,7 @@ export default defineConfig({ exclude: [ 'test/integration/skip-fts.test.ts', 'test/integration/impact-callable-value-references.test.ts', + 'test/integration/impact-context-integrity.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts', From 504bff7102e39f45649917396f44ee84b5efb8cc Mon Sep 17 00:00:00 2001 From: rgb-vgx Date: Sun, 4 Oct 2026 18:55:41 +0700 Subject: [PATCH 3/3] fix(group): join gin/echo route-group prefixes and accept method-value handlers in Go HTTP providers (#3458) --- .../core/group/extractors/http-patterns/go.ts | 532 +++++++++- .../group/extractors/http-patterns/types.ts | 8 + .../group/extractors/http-route-extractor.ts | 16 +- .../ingestion/route-extractors/go-gin-echo.ts | 54 +- .../ingestion/route-extractors/go-shared.ts | 75 ++ .../unit/group/go-gin-route-groups.test.ts | 969 ++++++++++++++++++ 6 files changed, 1573 insertions(+), 81 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/go-shared.ts create mode 100644 gitnexus/test/unit/group/go-gin-route-groups.test.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts index ebf87f0e1..6d29de5fe 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/go.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -1,27 +1,38 @@ +import type Parser from 'tree-sitter'; import Go from 'tree-sitter-go'; +import { goImportPackageName } from '../../../ingestion/languages/go/import-package-name.js'; +import { stringLiteral } from '../../../ingestion/route-extractors/go-shared.js'; import { compilePatterns, runCompiledPatterns, - unquoteLiteral, type LanguagePatterns, } from '../tree-sitter-scanner.js'; import type { HttpDetection, HttpLanguagePlugin } from './types.js'; /** * Go HTTP plugin. Handles: - * - gin / echo / chi framework routing — `r.GET("/path", handler)` + * - gin / echo framework routing — `r.GET("/path", handler)`, including + * prefixes from route groups bound in the same function (`r.Group("/api")`) * - net/http stdlib — `http.HandleFunc("/path", handler)` * - net/http consumer — `http.Get(...)`, `http.NewRequest("METHOD", ...)` * - resty consumer — `client.R().Delete("/path")` */ // ─── Provider: framework routing ────────────────────────────────────── -// Matches `\w+\.GET(...)` etc. (gin, echo, chi all share this shape). -// Captures the HTTP method (field name), path literal, and the handler — -// anchored to the LAST argument (`@handler .`) so a variadic middleware -// chain (`r.GET("/x", mw, handler)`, gin/echo/chi style) binds the real -// handler, not a middleware identifier (which would otherwise over-match -// and attach the route to the wrong symbol — see #2276 review). +// Matches `\w+\.GET(...)` etc. (gin and echo share this shape). +// Captures the receiver, the HTTP method (field name), and the path literal +// (either Go string form; stringLiteral decodes both, as ingestion does). The +// query does not anchor the path with `.`: tree-sitter counts comments as +// named children, so `GET(/* c */ "/p", h)` would fail the anchor. scan instead +// requires the path to be the first argument in code (comments skipped) and +// picks the handler out of the remaining code arguments. Which argument that is depends on the framework: +// gin is `GET(path, middleware..., handler)` (last), echo is +// `GET(path, handler, middleware...)` (first) — see readFrameworkImports and +// the per-call choice in scan below. +// The handler must be an identifier, an inline func literal, or a method +// value / package-qualified function (`h.ListUsers`, `handlers.ListUsers`); +// anything else there means the call cannot be attributed to a symbol, so it +// is dropped rather than guessed (variadic-middleware over-match, #2276). const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ name: 'go-framework-route', language: Go, @@ -31,16 +42,431 @@ const FRAMEWORK_ROUTE_PATTERNS = compilePatterns({ query: ` (call_expression function: (selector_expression + operand: (_) @receiver field: (field_identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|PATCH)$")) arguments: (argument_list - (interpreted_string_literal) @path - [(identifier) (func_literal)] @handler - .)) + [(interpreted_string_literal) (raw_string_literal)] @path)) `, }, ], } satisfies LanguagePatterns>); +/** Named children that are code, not comments (tree-sitter names comments). */ +function codeChildren(node: Parser.SyntaxNode | null | undefined): Parser.SyntaxNode[] { + return node ? node.namedChildren.filter((c) => c.type !== 'comment') : []; +} + +/** Argument forms a route handler may take. */ +const HANDLER_ARG_TYPES: ReadonlySet = new Set([ + 'identifier', + 'func_literal', + 'selector_expression', +]); + +/** + * The file's framework import aliases: which local qualifiers resolve to + * echo and to gin. Matched on the import path rather than the local name, so + * an aliased import still counts; an unaliased import is keyed by its + * conventional package name (`goImportPackageName`). `_` and `.` imports bind + * no qualifier this file can route through. An empty set means the file + * proves nothing about that framework. Echo's verb calls take the handler as + * the FIRST argument after the path (`GET(path, handler, middleware...)`), + * gin's as the LAST (`GET(path, middleware..., handler)`) — `scan` picks the + * rule per call from these sets. + */ +function readFrameworkImports(root: Parser.SyntaxNode): { + echo: Set; + gin: Set; +} { + // Imports sit only at file scope; skip bodies. + const specs = root.namedChildren + .filter((node) => node.type === 'import_declaration') + .flatMap((decl) => decl.descendantsOfType('import_spec')); + const echo = new Set(); + const gin = new Set(); + for (const spec of specs) { + const importPath = stringLiteral(spec.childForFieldName('path')); + if (importPath === null) continue; + const local = spec.childForFieldName('name')?.text ?? goImportPackageName(importPath); + if (local === '_' || local === '.') continue; + if (importPath.includes('labstack/echo')) echo.add(local); + else if (importPath.includes('gin-gonic/gin')) gin.add(local); + } + return { echo, gin }; +} + +// ─── Route groups: `v1 := r.Group("/api/v1")` ───────────────────────── +// gin (`*gin.RouterGroup`) and echo (`*echo.Group`) routes registered on a +// group inherit every enclosing `Group(prefix)`. The prefix is recovered by +// walking the route's receiver back through its bindings, lexically, inside +// the enclosing function declaration only: a group handed to another +// function (`registerAdmin(v1)`) arrives as a parameter and contributes no +// prefix there, and a receiver bound to anything but a literal-prefix +// `Group(...)` call contributes none either — the route keeps its literal path. +// Statement-scoped bindings count too: an `if`/`switch` initializer, a `for` +// clause (including `range`), a type-switch guard, and declarations inside a +// switch case all scope over their statement the same way Go scopes them, so +// they shadow an outer group of the same name instead of being skipped. A +// select case's receive binding (`case g := <-ch:`) stops the walk entirely: +// what arrives from the channel is statically unknown, so the route keeps its +// literal path rather than inheriting an outer group. + +const MAX_GROUP_DEPTH = 32; + +function joinRoutePath(prefix: string, relative: string): string { + let joined = relative; + if (prefix && relative) { + joined = `${prefix.replace(/\/+$/, '')}/${relative.replace(/^\/+/, '')}`; + } else if (prefix) { + joined = prefix; + } + // Collapse duplicate slashes on the FINAL result — every return branch, not + // just the join — because ingestion's normalizeExtractedRoutePath collapses + // all "//" while the downstream contract-id normalizer does not: a path + // that keeps "//" would split into two contract ids across the strategies. + const collapsed = joined.replace(/\/+/g, '/'); + // Force a leading "/" for the same reason: ingestion's + // normalizeExtractedRoutePath always adds one, while normalizeHttpPath (the + // shared contract-id normalizer) does not — a literal "x" or a slashless + // Group("api") prefix would emit `...::x` here and `...::/x` there. Gin + // also refuses a registration path that does not start with "/". + return collapsed.startsWith('/') ? collapsed : `/${collapsed}`; +} + +/** `parent.Group("/p", mw...)` → its receiver and literal prefix; null otherwise. */ +function asGroupCall( + node: Parser.SyntaxNode, +): { parent: Parser.SyntaxNode; prefix: string } | null { + if (node.type !== 'call_expression') return null; + const fn = node.childForFieldName('function'); + if (fn?.type !== 'selector_expression' || fn.childForFieldName('field')?.text !== 'Group') { + return null; + } + const parent = fn.childForFieldName('operand'); + const first = codeChildren(node.childForFieldName('arguments'))[0]; + // Only a string literal carries a prefix, and it must decode to the text + // the runtime registers: stringLiteral applies Go unescaping (both `"…"` + // with escapes and raw `` `…` `` strings). A non-literal argument (a + // variable, concatenation) or an undecodable string contributes no prefix. + const prefix = first ? stringLiteral(first) : null; + if (!parent || prefix === null) return null; + return { parent, prefix }; +} + +/** The expression `name` is assigned by `stmt` (`:=`, `=`, or `var`), if any. */ +function boundValue(stmt: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null | undefined { + const pick = ( + names: Parser.SyntaxNode[], + values: Parser.SyntaxNode | null, + ): Parser.SyntaxNode | null | undefined => { + const i = names.findIndex((n) => n.type === 'identifier' && n.text === name); + if (i < 0) return undefined; + return codeChildren(values)[i] ?? null; + }; + switch (stmt.type) { + case 'short_var_declaration': + case 'assignment_statement': + return pick(codeChildren(stmt.childForFieldName('left')), stmt.childForFieldName('right')); + case 'var_spec': + return pick(stmt.childrenForFieldName('name'), stmt.childForFieldName('value')); + case 'var_declaration': { + const specs = stmt.namedChildren.flatMap((c) => + c.type === 'var_spec_list' ? c.namedChildren : [c], + ); + for (const spec of specs) { + if (spec.type !== 'var_spec') continue; + const value = pick(spec.childrenForFieldName('name'), spec.childForFieldName('value')); + if (value !== undefined) return value; + } + return undefined; + } + default: + return undefined; + } +} + +/** + * A binding whose value cannot be established statically: an earlier + * statement writes the name inside a nested scope (`{ g = r.Group("/new") }`, + * a branch or loop body), so which value reaches the use depends on control + * flow. Callers decline the route instead of picking the older binding. + */ +const CONFLICT = Symbol('conflicting-binding'); +/** The name is a parameter (or method receiver): its declaration carries a static type. */ +interface ParamBinding { + param: Parser.SyntaxNode; +} +type Binding = Parser.SyntaxNode | null | undefined | typeof CONFLICT | ParamBinding; + +function isParamBinding(b: Binding): b is ParamBinding { + return typeof b === 'object' && b !== null && 'param' in b; +} + +/** The parameter_declaration of `fn` (parameters or method receiver) declaring `name`. */ +function paramDeclaring(fn: Parser.SyntaxNode, name: string): Parser.SyntaxNode | null { + for (const list of [fn.childForFieldName('parameters'), fn.childForFieldName('receiver')]) { + for (const decl of codeChildren(list)) { + if (decl.type !== 'parameter_declaration' && decl.type !== 'variadic_parameter_declaration') { + continue; + } + if (decl.childrenForFieldName('name').some((n) => n.text === name)) return decl; + } + } + return null; +} + +/** Whether `inner` lies within `outer`'s source span. */ +function within(outer: Parser.SyntaxNode | null, inner: Parser.SyntaxNode): boolean { + return !!outer && outer.startIndex <= inner.startIndex && inner.endIndex <= outer.endIndex; +} + +/** + * Whether `stmt` writes `name` with a plain assignment somewhere inside it + * (`g = …` in a nested block, branch, loop, or closure body). A nested `:=` + * declares a new variable and is not a write to the outer one. + */ +function writesNameInside(stmt: Parser.SyntaxNode, name: string): boolean { + return [stmt, ...stmt.descendantsOfType('assignment_statement')].some( + (a) => a.type === 'assignment_statement' && declaresName(a.childForFieldName('left'), name), + ); +} + +/** Whether an identifier or expression_list (e.g. a range left side) declares `name`. */ +function declaresName(node: Parser.SyntaxNode | null, name: string): boolean { + if (!node) return false; + if (node.type === 'identifier') return node.text === name; + return node.namedChildren.some((n) => n.type === 'identifier' && n.text === name); +} + +/** + * The value last bound to identifier `ident` before its use: the nearest + * binding site in the enclosing scopes, walking outward — preceding statements + * in blocks and switch/select cases (`expression_case`/`type_case`/ + * `communication_case`/`default_case` act as statement containers), then + * statement-scoped bindings (`if`/`switch` initializers, `for` clauses + * including `range`, type-switch guards), up to the enclosing function + * declaration. Returns null when the name is a parameter, is bound without a + * value, is received from a channel by a select case head (the received value + * is statically unknown, so the walk stops instead of escaping to an outer + * group), or is not bound in scope. + */ +function findBinding(ident: Parser.SyntaxNode): Parser.SyntaxNode | null | typeof CONFLICT { + const binding = lookupBinding(ident); + return isParamBinding(binding) ? null : (binding ?? null); +} + +/** + * findBinding's walk, keeping "declared without a traceable value" (null: a + * `func` literal parameter, `var x T`, a select receive) apart from "not + * declared before reaching the enclosing function declaration" (undefined). + * CONFLICT means a preceding statement writes the name in a nested scope, so + * the value at the use is control-flow dependent. A parameter or method + * receiver of the enclosing function returns its declaration (ParamBinding): + * no value, but a static type. + */ +function lookupBinding(ident: Parser.SyntaxNode): Binding { + const name = ident.text; + let child: Parser.SyntaxNode = ident; + for (let node = ident.parent; node; child = node, node = node.parent) { + if ( + node.type === 'function_declaration' || + node.type === 'method_declaration' || + node.type === 'func_literal' + ) { + const param = paramDeclaring(node, name); + if (param) return { param }; + if (node.type === 'func_literal') continue; + return undefined; + } + // Inside a grouped `var ( a = …; b = a.Group(…) )`, the specs before the + // one holding the use are already in scope; the current and later specs + // are not (`var g = g.Group(…)` reads the outer g). + if (node.type === 'var_spec_list') { + const specs = codeChildren(node); + const useIndex = specs.findIndex((s) => s.id === child.id); + for (const spec of specs.slice(0, useIndex).reverse()) { + const value = boundValue(spec, name); + if (value !== undefined) return value; + } + continue; + } + if ( + node.type === 'block' || + node.type === 'expression_case' || + node.type === 'type_case' || + node.type === 'communication_case' || + node.type === 'default_case' + ) { + // A select case head can rebind the name (`case g := <-ch:` or + // `case g = <-ch:`); the received value is statically unknown, so the + // walk must STOP with no traceable value — the same decline the + // ingestion-side route bindings record (value null) — instead of + // escaping to an outer group of the same name. + if (node.type === 'communication_case') { + for (const head of node.children) { + if ( + head.type === 'receive_statement' && + declaresName(head.childForFieldName('left'), name) + ) { + return null; + } + } + } + const stmts = codeChildren(node); + const useIndex = stmts.findIndex((s) => s.id === child.id); + for (const stmt of stmts.slice(0, useIndex).reverse()) { + const value = boundValue(stmt, name); + if (value !== undefined) return value; + // A write nested in an earlier statement (block, branch, loop) may or + // may not run before the use: the reaching value is unprovable. + if (writesNameInside(stmt, name)) return CONFLICT; + } + continue; + } + // Statement-scoped bindings enclose the use the same way Go scopes them. + if (node.type === 'if_statement' || node.type === 'expression_switch_statement') { + // A use inside the initializer itself (`if g := g.Group(…); …`) reads + // the OUTER binding: the new one only scopes over what follows it. + const init = node.childForFieldName('initializer'); + if (init && !within(init, ident)) { + const value = boundValue(init, name); + if (value !== undefined) return value; + } + continue; + } + if (node.type === 'for_statement') { + const clause = codeChildren(node)[0]; + // A write in the loop's post statement, condition, or body runs between + // iterations, so from the second pass on the body sees that value + // instead of the one it entered with (`for ; c; g = r.Group("/post")`): + // the prefix is control-flow dependent, so decline it. + if (within(node.childForFieldName('body'), ident)) { + const loopParts = [ + node.childForFieldName('body'), + clause?.type === 'for_clause' ? clause.childForFieldName('update') : null, + clause?.type === 'for_clause' ? clause.childForFieldName('condition') : null, + ]; + if (loopParts.some((part) => part && writesNameInside(part, name))) return CONFLICT; + } + if (clause?.type === 'for_clause') { + // Only the initializer binds before the body; an absent initializer + // (`for ; c; i++`) binds nothing. + const init = clause.childForFieldName('initializer'); + if (init && !within(init, ident)) { + const value = boundValue(init, name); + if (value !== undefined) return value; + } + } else if (clause?.type === 'range_clause') { + if ( + declaresName(clause.childForFieldName('left'), name) && + !within(clause.childForFieldName('right'), ident) + ) { + return clause.childForFieldName('right') ?? null; + } + } + continue; + } + if (node.type === 'type_switch_statement') { + // `switch g := x.(type)` — the guard list is the `alias` field, which + // only the `:=` form has (bare `switch x.(type)` parses with none), + // matching how the ingestion-side route-bindings read it. The switched + // value is the operand right after the guard list. + const guard = node.childForFieldName('alias'); + const switched = codeChildren(node)[1] ?? null; + if ( + guard?.type === 'expression_list' && + declaresName(guard, name) && + !within(switched, ident) + ) { + return switched; + } + continue; + } + } + return undefined; +} + +/** + * Whether a mixed-import file's route receiver traces back to echo's + * constructor — `e := echo.New()` / `echo.Default()` with `echo` resolving to + * one of the file's verified echo import aliases — either directly or through + * enclosing `Group(...)` calls (`users := api.Group(…)` ← `api := e.Group(…)` + * ← `echo.New()`), the normal shape of grouped routes (review #7, #10). + * A parameter or method receiver counts by its declared type instead: + * `e *echo.Echo` / `g *echo.Group` (with `echo` one of those aliases) proves + * echo; any other type — gin's, or one the file cannot tie to echo — does not. + * Unrelated packages' `New()`, a local that shadows the echo import name, and + * anything else return false so the caller keeps the conservative + * last-argument fallback instead of guessing. Returns null when the Group chain exceeds MAX_GROUP_DEPTH or a + * binding on it is control-flow dependent (CONFLICT): the framework is then + * unprovable either way, so the caller declines the route. + */ +function receiverBindsToEchoConstructor( + receiver: Parser.SyntaxNode, + echoAliases: ReadonlySet, + depth = 0, +): boolean | null { + if (depth > MAX_GROUP_DEPTH) return null; + // An identifier resolves through its binding; a chained `X.Group(…).Group(…)` + // operand is already a call and is inspected as-is. + const value = receiver.type === 'identifier' ? lookupBinding(receiver) : receiver; + if (value === CONFLICT) return null; + if (isParamBinding(value)) + return isEchoRouterType(value.param.childForFieldName('type'), echoAliases); + if (value?.type !== 'call_expression') return false; + const fn = value.childForFieldName('function'); + if (fn?.type !== 'selector_expression') return false; + const field = fn.childForFieldName('field')?.text; + const operand = fn.childForFieldName('operand'); + if (!operand) return false; + if (field === 'New' || field === 'Default') { + return operand.type === 'identifier' && echoAliases.has(operand.text) && !isLocalName(operand); + } + if (field === 'Group') { + return receiverBindsToEchoConstructor(operand, echoAliases, depth + 1); + } + return false; +} + +/** `*echo.Echo` / `echo.Echo` / `*echo.Group` with `echo` a verified echo import alias. */ +function isEchoRouterType( + type: Parser.SyntaxNode | null, + echoAliases: ReadonlySet, +): boolean { + const named = type?.type === 'pointer_type' ? codeChildren(type)[0] : type; + if (named?.type !== 'qualified_type') return false; + const pkg = named.childForFieldName('package')?.text; + const typeName = named.childForFieldName('name')?.text; + return !!pkg && echoAliases.has(pkg) && (typeName === 'Echo' || typeName === 'Group'); +} + +/** + * Whether `ident` names a local value rather than an imported package: a + * declaration in scope (with or without a value — `var echo Factory` shadows + * too) or a parameter/receiver of an enclosing function. Go lets either + * shadow a package qualifier (`func f(echo *Factory) { echo.New() }`). + */ +function isLocalName(ident: Parser.SyntaxNode): boolean { + // lookupBinding covers parameters and receivers too (ParamBinding). + return lookupBinding(ident) !== undefined; +} + +/** + * Joined `Group(...)` prefix of a route receiver; '' when it cannot be traced. + * Returns null when the chain exceeds MAX_GROUP_DEPTH, or when a binding on it + * is control-flow dependent (CONFLICT): a partial or stale prefix would emit + * a wrong path, so the caller declines the route instead. + */ +function groupPrefix(receiver: Parser.SyntaxNode, depth = 0): string | null { + if (depth > MAX_GROUP_DEPTH) return null; + const value = receiver.type === 'identifier' ? findBinding(receiver) : receiver; + if (value === CONFLICT) return null; + const group = value ? asGroupCall(value) : null; + if (!group) return ''; + const outer = groupPrefix(group.parent, depth + 1); + return outer === null ? null : joinRoutePath(outer, group.prefix); +} + // ─── Provider: net/http `http.HandleFunc("/p", handler)` ───────────── const HANDLE_FUNC_PATTERNS = compilePatterns({ name: 'go-handle-func', @@ -135,27 +561,87 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { scan(tree) { const out: HttpDetection[] = []; - // Framework providers: r.GET/POST/... with handler identifier + // Framework providers: r.GET/POST/... on an engine or (nested) route group + const imports = readFrameworkImports(tree.rootNode); + const echoOnly = imports.echo.size > 0 && imports.gin.size === 0; + const mixed = imports.echo.size > 0 && imports.gin.size > 0; + // Handler names declared more than once in this file (`(h *A) List` and + // `(o *B) List`): the emitted name is field-only, so these must resolve + // only when unique in the file instead of taking the first same-named row. + const declaredNames = new Map(); + for (const decl of tree.rootNode.descendantsOfType([ + 'function_declaration', + 'method_declaration', + ])) { + const declName = decl.childForFieldName('name')?.text; + if (declName) declaredNames.set(declName, (declaredNames.get(declName) ?? 0) + 1); + } for (const match of runCompiledPatterns(FRAMEWORK_ROUTE_PATTERNS, tree)) { const methodNode = match.captures.http_method; const pathNode = match.captures.path; - const handlerNode = match.captures.handler; + const receiverNode = match.captures.receiver; if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); - if (path === null) continue; + const literalPath = stringLiteral(pathNode); + if (literalPath === null) continue; + const argList = pathNode.parent; + if (argList?.type !== 'argument_list') continue; + const args = codeChildren(argList); + if (args[0]?.id !== pathNode.id) continue; + // The path is the first code argument, so everything after it is a handler or + // middleware candidate: echo's verb calls take the FIRST of those, gin's + // the LAST (see FRAMEWORK_ROUTE_PATTERNS / readFrameworkImports). The + // rule is chosen per call: an echo-only file is unambiguous; a + // mixed-import file takes the first argument only when the receiver + // provably traces to echo's constructor (directly or through enclosing + // Group() calls) — everything else keeps the last-argument anchor, + // gin's order and the safer default when the file proves nothing. + const rest = args.slice(1); + if (rest.length === 0) continue; + const echoOrder = mixed + ? receiverNode + ? receiverBindsToEchoConstructor(receiverNode, imports.echo) + : false + : echoOnly; + // A Group chain deeper than MAX_GROUP_DEPTH proves neither the full + // prefix nor the framework order: decline rather than emit a guess. + if (echoOrder === null) continue; + const prefix = receiverNode ? groupPrefix(receiverNode) : ''; + if (prefix === null) continue; + const handlerNode = echoOrder ? rest[0] : rest[rest.length - 1]; + if (!HANDLER_ARG_TYPES.has(handlerNode.type)) continue; + const path = receiverNode ? joinRoutePath(prefix, literalPath) : literalPath; // An inline `func(){…}` handler has no name → emit `name: null` and a // `line` so it resolves to its containing/closure symbol by line-span - // containment (like a consumer). A named identifier handler keeps its - // name and resolves by name; `line` is harmless there. + // containment (like a consumer). A named handler keeps its name and + // resolves by name; `line` is harmless there. For a method value or a + // package-qualified function (`h.List`, `pkg.List`) that name is the + // field, and the operand (usually a local variable, not the receiver + // type) does not prove where `List` is declared — so the detection is + // marked qualifiedHandler: resolve only to a repo-wide unique `List`, + // never to a same-named local method that merely shares the name. const isInlineHandler = handlerNode?.type === 'func_literal'; + const isQualified = handlerNode?.type === 'selector_expression'; + const handlerName = isQualified + ? (handlerNode.childForFieldName('field')?.text ?? null) + : (handlerNode?.text ?? null); out.push({ role: 'provider', framework: 'go-framework', method: methodNode.text.toUpperCase(), path, - name: isInlineHandler ? null : (handlerNode?.text ?? null), + name: isInlineHandler ? null : handlerName, line: (handlerNode ?? pathNode).startPosition.row + 1, confidence: 0.8, + ...(isQualified ? { qualifiedHandler: true } : {}), + // A bare name declared more than once in this file (a function and a + // method of the same name) resolves only when the file holds exactly + // one match, rather than binding to whichever row the graph lists first. + ...(!isQualified && + !isInlineHandler && + handlerName && + (declaredNames.get(handlerName) ?? 0) > 1 + ? { strictHandlerResolution: true } + : {}), }); } @@ -164,7 +650,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const pathNode = match.captures.path; const handlerNode = match.captures.handler; if (!pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; // Inline `func(){…}` handler → resolve by containment (see go-framework // note above); a named handler resolves by name. @@ -187,7 +673,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { if (!fnNode || !pathNode) continue; const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text]; if (!httpMethod) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; out.push({ role: 'consumer', @@ -205,8 +691,8 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!methodNode || !pathNode) continue; - const method = unquoteLiteral(methodNode.text); - const path = unquoteLiteral(pathNode.text); + const method = stringLiteral(methodNode); + const path = stringLiteral(pathNode); if (method === null || path === null) continue; out.push({ role: 'consumer', @@ -224,7 +710,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; out.push({ role: 'consumer', diff --git a/gitnexus/src/core/group/extractors/http-patterns/types.ts b/gitnexus/src/core/group/extractors/http-patterns/types.ts index 38c197704..bbd3fe1a2 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/types.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/types.ts @@ -58,6 +58,14 @@ export interface HttpDetection { handlerImport?: { name: string; module: string }; /** Resolve only from the registration file or exact import target; never guess repo-wide. */ strictHandlerResolution?: boolean; + /** + * The handler was designated through a qualifier the plugin cannot tie to a + * declaration (`recv.name`, `pkg.name`): `name` alone does not prove the + * handler lives in the registration file. Skip the file-scoped name lookup + * — a same-named but unrelated local symbol would win it — and accept only + * a repo-wide unique match, else keep the file-level fallback. + */ + qualifiedHandler?: boolean; /** * The plugin saw a provider handler designator but could not prove its owner. * Prevents the orchestrator from treating it as an anonymous inline handler diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts index d116bc046..707ccdff7 100644 --- a/gitnexus/src/core/group/extractors/http-route-extractor.ts +++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts @@ -629,11 +629,17 @@ export class HttpRouteExtractor implements ContractExtractor { if (d.strictHandlerResolution) return null; return resolveSymbolByNameUnique(d.handlerImport.name); } - const byName = d.strictHandlerResolution - ? resolveFileSymbolByNameUnique(syms, d.name) - : resolveSymbolByName(syms, d.name); - if (byName) return byName; - if (d.strictHandlerResolution) return null; + // A qualified designator (`recv.name`) does not prove the handler is + // declared in this file, so the file-first rung could bind an + // unrelated same-named local symbol: go straight to the unique + // repo-wide match. + if (!d.qualifiedHandler) { + const byName = d.strictHandlerResolution + ? resolveFileSymbolByNameUnique(syms, d.name) + : resolveSymbolByName(syms, d.name); + if (byName) return byName; + if (d.strictHandlerResolution) return null; + } const byGlobal = await resolveSymbolByNameUnique(d.name); if (byGlobal) return byGlobal; // A NAMED handler we could not resolve by name (neither file-scoped nor diff --git a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts index f7cdd1c64..e77b1b82b 100644 --- a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts +++ b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts @@ -26,6 +26,7 @@ import type Parser from 'tree-sitter'; import { goImportPackageName } from '../languages/go/import-package-name.js'; import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js'; +import { stringLiteral } from './go-shared.js'; import { normalizeExtractedRoutePath } from './route-path.js'; import type { SyntaxNode } from 'tree-sitter'; import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js'; @@ -58,59 +59,6 @@ interface Framework { const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal']; const FUNCTION_TYPES: ReadonlySet = new Set(FUNCTION_TYPE_LIST); -function stringLiteral(node: SyntaxNode | null | undefined): string | null { - if (!node || node.hasError) return null; - const body = node.text.slice(1, -1); - // Go discards carriage returns in raw strings, including CRLF source files. - if (node.type === 'raw_string_literal') return body.replace(/\r/g, ''); - if (node.type !== 'interpreted_string_literal') return null; - if (!body.includes('\\')) return body; - - const simple: Readonly> = { - a: '\x07', - b: '\b', - f: '\f', - n: '\n', - r: '\r', - t: '\t', - v: '\v', - '\\': '\\', - '"': '"', - }; - const chunks: Buffer[] = []; - const tokens = - /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g; - let consumed = 0; - for (const match of body.matchAll(tokens)) { - if (match.index !== consumed) return null; - const token = match[0]; - consumed += token.length; - if (!token.startsWith('\\')) { - chunks.push(Buffer.from(token)); - } else if (simple[token[1]] !== undefined) { - chunks.push(Buffer.from(simple[token[1]])); - } else { - const octal = /[0-7]/.test(token[1]); - const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16); - if (octal || token[1] === 'x') { - // Octal and hex escapes encode bytes, not Unicode code points. - if (value > 255) return null; - chunks.push(Buffer.from([value])); - } else { - if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null; - chunks.push(Buffer.from(String.fromCodePoint(value))); - } - } - } - if (consumed !== body.length) return null; - try { - // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL. - return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); - } catch { - return null; - } -} - /** The framework this file routes with, when exactly one is imported. */ function readImports(root: SyntaxNode): { readonly framework: Framework | null; diff --git a/gitnexus/src/core/ingestion/route-extractors/go-shared.ts b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts new file mode 100644 index 000000000..a2d13faef --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts @@ -0,0 +1,75 @@ +import type { SyntaxNode } from 'tree-sitter'; + +/** + * Go string-literal decoding shared by the Go route extractors on both + * layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the + * group-mode HTTP plugin (`group/extractors/http-patterns/go.ts`, + * Strategy B). Both sides must produce the SAME text for a route literal, + * or the same route lands under two different contract ids that never + * collide in the merge — a wrong-path duplicate that survives alongside + * the graph's correct entry. + * + * Go's semantics, per `strconv.Unquote`: + * - interpreted (`"…"`) strings decode escapes (`\n`, `\x2f`, `ሴ`, + * `\101`, …); hex and octal escapes encode BYTES, not code points; + * - raw (`` `…` ``) strings have no escapes (a backslash is literal), + * and Go discards carriage returns in them, including CRLF source files; + * - a string that cannot be decoded to valid UTF-8 text (invalid escape, + * non-UTF-8 bytes) returns null — callers decline instead of guessing, + * since a URL cannot carry those bytes losslessly. + * + * Nodes that are not string literals (an identifier prefix, a rune + * literal, an errored subtree) also return null. + */ +export function stringLiteral(node: SyntaxNode | null | undefined): string | null { + if (!node || node.hasError) return null; + const body = node.text.slice(1, -1); + // Go discards carriage returns in raw strings, including CRLF source files. + if (node.type === 'raw_string_literal') return body.replace(/\r/g, ''); + if (node.type !== 'interpreted_string_literal') return null; + if (!body.includes('\\')) return body; + + const simple: Readonly> = { + a: '\x07', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '\\': '\\', + '"': '"', + }; + const chunks: Buffer[] = []; + const tokens = + /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g; + let consumed = 0; + for (const match of body.matchAll(tokens)) { + if (match.index !== consumed) return null; + const token = match[0]; + consumed += token.length; + if (!token.startsWith('\\')) { + chunks.push(Buffer.from(token)); + } else if (simple[token[1]] !== undefined) { + chunks.push(Buffer.from(simple[token[1]])); + } else { + const octal = /[0-7]/.test(token[1]); + const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16); + if (octal || token[1] === 'x') { + // Octal and hex escapes encode bytes, not Unicode code points. + if (value > 255) return null; + chunks.push(Buffer.from([value])); + } else { + if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null; + chunks.push(Buffer.from(String.fromCodePoint(value))); + } + } + } + if (consumed !== body.length) return null; + try { + // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL. + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); + } catch { + return null; + } +} diff --git a/gitnexus/test/unit/group/go-gin-route-groups.test.ts b/gitnexus/test/unit/group/go-gin-route-groups.test.ts new file mode 100644 index 000000000..bdb5bde56 --- /dev/null +++ b/gitnexus/test/unit/group/go-gin-route-groups.test.ts @@ -0,0 +1,969 @@ +/** + * Group HTTP-contract layer: Go gin/echo framework routes registered through + * route groups and method-value handlers. Exercises `GO_HTTP_PLUGIN.scan` + * directly with a real tree-sitter parser, asserting the FULL registered path + * (every enclosing `x := y.Group("/p")` prefix joined in) and the handler name + * the group layer resolves by. The last block runs the real extractor on a Go + * provider repo and a fetch() consumer repo and pairs them with `runExactMatch`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import Parser from 'tree-sitter'; +import Go from 'tree-sitter-go'; +import { GO_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/go.js'; +import { + HttpRouteExtractor, + RESOLVE_BY_NAME_QUERY, +} from '../../../src/core/group/extractors/http-route-extractor.js'; +import { runExactMatch } from '../../../src/core/group/matching.js'; +import type { RepoHandle, StoredContract } from '../../../src/core/group/types.js'; + +const parser = new Parser(); + +interface Provider { + method: string; + path: string; + name: string | null; +} + +function providers(src: string): Provider[] { + parser.setLanguage(Go); + return GO_HTTP_PLUGIN.scan(parser.parse(src)) + .filter((d) => d.role === 'provider') + .map(({ method, path: p, name }) => ({ method, path: p, name })); +} + +// Mirrors the shapes of a real gin `RegisterRoutes`: an engine-level group, +// `{ }` blocks, nested groups three levels deep, empty-prefix groups used only +// to attach middleware, method-value / package-func / identifier / inline +// handlers, and variadic middleware before the handler. +const GIN_ROUTES = `package handlers + +import "github.com/gin-gonic/gin" + +func RegisterRoutes(r *gin.Engine, svc *service.Service) { + playerHandler := NewPlayerHandler(svc.Player) + matchHandler := NewMatchHandler(svc.Match) + r.GET("/ping", pingHandle) + v1 := r.Group("/api/v1") + { + v1.GET("/health", func(c *gin.Context) { c.Status(200) }) + v1.GET("/players", playerHandler.GetPlayersHandle) + v1.POST("/upload/avatar", UploadAvatarHandle) + v1.GET("/exports", exports.ListExportsHandle) + v1.PATCH("/players/:playerId", middleware.AuthRequired(svc.Auth), playerHandler.UpdatePlayerHandle) + admin := v1.Group("/admin") + admin.Use(middleware.AdminRequired(svc.AdminControl)) + { + admin.POST("/seasons/:seasonId/rounds/:roundId/unfinalize", matchHandler.UnfinalizeRoundHandle) + newsAdmin := admin.Group("/news") + { + newsAdmin.DELETE("/:id", newsHandler.DeleteNewsHandle) + } + knockoutAdmin := admin.Group("", middleware.KnockoutGuard()) + knockoutAdmin.POST("/seasons/:seasonId/knockout", knockoutHandler.CreateKnockoutHandle) + adminOnly := admin.Group("") + adminOnly.PUT("/seasons/:seasonId/streak-config", streakHandler.SaveStreakConfigHandle) + } + } +} +`; + +describe('GO_HTTP_PLUGIN — gin route groups', () => { + const got = providers(GIN_ROUTES); + const find = (method: string, p: string) => got.find((d) => d.method === method && d.path === p); + + it('emits exactly one provider per registered route (Use() and Group() are not routes)', () => { + expect(got).toHaveLength(10); + }); + + it('keeps a route on the engine root, outside any group, at its literal path', () => { + expect(find('GET', '/ping')).toEqual({ method: 'GET', path: '/ping', name: 'pingHandle' }); + }); + + it('prefixes an inline func_literal handler and leaves it unnamed', () => { + expect(find('GET', '/api/v1/health')).toEqual({ + method: 'GET', + path: '/api/v1/health', + name: null, + }); + }); + + it('accepts a method-value handler and names it by its field', () => { + expect(find('GET', '/api/v1/players')?.name).toBe('GetPlayersHandle'); + }); + + it('accepts a package-qualified function handler and names it by its field', () => { + expect(find('GET', '/api/v1/exports')?.name).toBe('ListExportsHandle'); + }); + + it('keeps an identifier handler', () => { + expect(find('POST', '/api/v1/upload/avatar')?.name).toBe('UploadAvatarHandle'); + }); + + it('binds the last argument, not the variadic middleware before it', () => { + expect(find('PATCH', '/api/v1/players/:playerId')?.name).toBe('UpdatePlayerHandle'); + }); + + it('joins a nested group inside a { } block', () => { + expect(find('POST', '/api/v1/admin/seasons/:seasonId/rounds/:roundId/unfinalize')?.name).toBe( + 'UnfinalizeRoundHandle', + ); + }); + + it('joins groups nested three levels deep', () => { + expect(find('DELETE', '/api/v1/admin/news/:id')?.name).toBe('DeleteNewsHandle'); + }); + + it('treats an empty-prefix group (with or without middleware) as its parent prefix', () => { + expect(find('POST', '/api/v1/admin/seasons/:seasonId/knockout')?.name).toBe( + 'CreateKnockoutHandle', + ); + expect(find('PUT', '/api/v1/admin/seasons/:seasonId/streak-config')?.name).toBe( + 'SaveStreakConfigHandle', + ); + }); +}); + +describe('GO_HTTP_PLUGIN — group binding edge cases', () => { + it('follows plain `=` assignment and `var x = …` declarations', () => { + const got = providers(`package main +func routes(r *gin.Engine) { + var api *gin.RouterGroup + api = r.Group("/v2") + api.GET("/a", h.A) + var ops = api.Group("/ops") + ops.GET("/b", h.B) +} +`); + expect(got).toEqual([ + { method: 'GET', path: '/v2/a', name: 'A' }, + { method: 'GET', path: '/v2/ops/b', name: 'B' }, + ]); + }); + + it('joins a Group() call chained directly onto the route call', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + r.Group("/inline").GET("/y", h.Y) +} +`), + ).toEqual([{ method: 'GET', path: '/inline/y', name: 'Y' }]); + }); + + it('uses the binding visible at the call site when a name is reused in sibling blocks', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + { + g := r.Group("/a") + g.GET("/x", h.AX) + } + { + g := r.Group("/b") + g.GET("/x", h.BX) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/a/x', name: 'AX' }, + { method: 'GET', path: '/b/x', name: 'BX' }, + ]); + }); + + it('uses the latest assignment that precedes the route, not one after it', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/first") + g.GET("/x", h.X) + g = r.Group("/second") + g.GET("/y", h.Y) +} +`), + ).toEqual([ + { method: 'GET', path: '/first/x', name: 'X' }, + { method: 'GET', path: '/second/y', name: 'Y' }, + ]); + }); + + it('resolves a group captured by a closure registered inside the function', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + v1 := r.Group("/api/v1") + register := func() { + v1.GET("/inner", h.Inner) + } + register() +} +`), + ).toEqual([{ method: 'GET', path: '/api/v1/inner', name: 'Inner' }]); + }); + + it('keeps the literal path when the receiver is a parameter (group passed from another function)', () => { + expect( + providers(`package main +func registerAdmin(g *gin.RouterGroup) { + g.GET("/extra", extraHandle) +} +`), + ).toEqual([{ method: 'GET', path: '/extra', name: 'extraHandle' }]); + }); + + it('keeps the literal path when the receiver is bound to something other than Group()', () => { + expect( + providers(`package main +func routes() { + g := newRouter("/ignored") + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('does not resolve a group bound in a different function', () => { + expect( + providers(`package main +func a(r *gin.Engine) { + g := r.Group("/a") + g.GET("/in-a", h.A) +} +func b(g *gin.RouterGroup) { + g.GET("/in-b", h.B) +} +`), + ).toEqual([ + { method: 'GET', path: '/a/in-a', name: 'A' }, + { method: 'GET', path: '/in-b', name: 'B' }, + ]); + }); + + it('ignores a Group() whose prefix is not a string literal and keeps the route literal', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(prefix) + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('uses the group bound by an if initializer in the body and in the else branch', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if g := r.Group("/inner"); cond { + g.GET("/x", h.X) + } else { + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/inner/x', name: 'X' }, + { method: 'GET', path: '/inner/y', name: 'Y' }, + ]); + }); + + it('keeps the outer group when an if initializer binds a different name', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if x := prepare(); cond { + g.GET("/x", h.X) + } +} +`), + ).toEqual([{ method: 'GET', path: '/outer/x', name: 'X' }]); + }); + + it('stops at an if initializer bound to something other than Group()', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + if g := build(); cond { + g.GET("/x", h.X) + } +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'X' }]); + }); + + it('uses a switch initializer group and a group declared inside a case clause', () => { + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/outer") + switch g := r.Group("/s"); g != nil { + case cond: + g.GET("/x", h.X) + } + switch { + case cond: + g := r.Group("/case") + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/s/x', name: 'X' }, + { method: 'GET', path: '/case/y', name: 'Y' }, + ]); + }); + + it('stops at a type-switch guard binding and resolves groups declared in a type case', () => { + expect( + providers(`package main +func routes(r *gin.Engine, anyVal any) { + g := r.Group("/outer") + switch g := anyVal.(type) { + case *Router: + g.GET("/x", h.X) + } + switch anyVal.(type) { + case interface{}: + g := r.Group("/t") + g.GET("/y", h.Y) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/t/y', name: 'Y' }, + ]); + }); + + it('stops at a select receive binding instead of inheriting an outer group', () => { + // The value received from a channel is statically unknown, so a case that + // rebinds `g` must shadow the outer group with "nothing traceable" (the + // route keeps its literal path) — not leak `/outer` into the id. An + // unrebound case still inherits, and a default clause declaring its own + // group shadows like any other statement list. + expect( + providers(`package main +func routes(r *gin.Engine, ch chan *gin.RouterGroup) { + g := r.Group("/outer") + select { + case g := <-ch: + g.GET("/x", h.X) + } + select { + case <-ch: + g.GET("/y", h.Y) + } + select { + default: + g := r.Group("/d") + g.GET("/z", h.Z) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/outer/y', name: 'Y' }, + { method: 'GET', path: '/d/z', name: 'Z' }, + ]); + }); + + it('keeps the outer group through a plain for init and stops at a range shadow binding', () => { + expect( + providers(`package main +func routes(r *gin.Engine, n int, subs []*gin.RouterGroup) { + g := r.Group("/outer") + for i := 0; i < n; i++ { + g.GET("/i", h.I) + } + for _, g := range subs { + g.GET("/r", h.R) + } + for g := r.Group("/loop"); ; { + g.GET("/l", h.L) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/outer/i', name: 'I' }, + { method: 'GET', path: '/r', name: 'R' }, + { method: 'GET', path: '/loop/l', name: 'L' }, + ]); + }); + + it('declines a route whose group a loop reassigns between iterations', () => { + // The post statement (or the body) runs between iterations, so from the + // second pass on the body sees the reassigned group rather than the one + // it entered with: the prefix is control-flow dependent, so the route is + // declined instead of emitting either value. A loop that never writes the + // name keeps its initializer binding. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool, n int) { + g := r.Group("/old") + for ; cond; g = r.Group("/post") { + g.GET("/a", h.A) + } + for g := r.Group("/init"); ; g = r.Group("/post3") { + g.GET("/c", h.C) + } + for k := r.Group("/k"); cond; { + k.GET("/d", h.D) + } +} +`), + ).toEqual([{ method: 'GET', path: '/k/d', name: 'D' }]); + }); + + it('accepts a raw-string (backtick) group prefix', () => { + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(\`/api\`) + g.GET("/x", h.X) +} +`), + ).toEqual([{ method: 'GET', path: '/api/x', name: 'X' }]); + }); + + it('accepts a raw-string (backtick) route path, as ingestion does', () => { + // Ingestion (Strategy A) decodes both Go string forms for the route path; + // the group layer must emit the same contract for a backtick path, both + // on a bound group and on a chained `Group(...).GET(...)` receiver. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api") + g.GET(\`/health\`, h.Health) + r.Group("/v1").POST(\`/raw\\x2fy\`, h.Raw) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/health', name: 'Health' }, + { method: 'POST', path: '/v1/raw\\x2fy', name: 'Raw' }, + ]); + }); + + it('decodes Go string escapes in group prefixes and route paths', () => { + // "/api\x2fv1" and "/health\x2fcheck" are `/api/v1` and `/health/check` + // once Go processes the escapes — the id must match the registered URL. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api\\x2fv1") + g.GET("/health\\x2fcheck", h.H) +} +`), + ).toEqual([{ method: 'GET', path: '/api/v1/health/check', name: 'H' }]); + }); + + it('leaves escapes literal inside a raw-string (backtick) prefix', () => { + // Go raw strings process no escapes: the prefix is `/raw\x2fy`, backslash included. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(\`/raw\\x2fy\`) + g.GET("/z", h.Z) +} +`), + ).toEqual([{ method: 'GET', path: '/raw\\x2fy/z', name: 'Z' }]); + }); + + it('collapses duplicate slashes so the path matches ingestion normalization', () => { + // normalizeExtractedRoutePath collapses every "//" run; the downstream + // contract-id normalizer does not — a path keeping "//" would get a + // different id than the graph's route node for the same route. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api//v1") + g.GET("/a//b", h.A) + r.GET("//root", h.R) + r.GET("/ok", h.Ok) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/v1/a/b', name: 'A' }, + { method: 'GET', path: '/root', name: 'R' }, + { method: 'GET', path: '/ok', name: 'Ok' }, + ]); + }); + + it('forces a leading slash on slashless literals and group prefixes', () => { + // Ingestion's normalizeExtractedRoutePath always adds a leading "/" but + // normalizeHttpPath (the shared contract-id normalizer) does not — a path + // like "x" would become `http::GET::x` here and `http::GET::/x` there, + // splitting the id across the two strategies. Gin likewise panics when a + // route is registered without one. + expect( + providers(`package main +func routes(r *gin.Engine) { + r.GET("x", h.X) + g := r.Group("api") + g.GET("y", h.Y) + r.GET("", h.Root) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'X' }, + { method: 'GET', path: '/api/y', name: 'Y' }, + { method: 'GET', path: '/', name: 'Root' }, + ]); + }); + + it("binds echo's handler (first argument) when the file imports echo only", () => { + // echo is `GET(path, handler, middleware...)` — the handler is second, + // not last, so a middleware selector must not become the route's name. + expect( + providers(`package main +import "github.com/labstack/echo/v4" + +func routes(e *echo.Echo) { + e.GET("/x", h.Handler, auth.Middleware) + e.POST("/y", handlerID) + e.PUT("/z", func(c echo.Context) error { return nil }) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'Handler' }, + { method: 'POST', path: '/y', name: 'handlerID' }, + { method: 'PUT', path: '/z', name: null }, + ]); + }); + + it('picks the handler order from a typed receiver parameter in a mixed-import file', () => { + // A parameter's static type proves its framework: `*echo.Echo` / + // `*echo.Group` take echo's order (handler FIRST), gin's types and types + // the file cannot tie to echo keep the last-argument fallback. Method + // receivers and func-literal parameters count the same way. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes(e *echo.Echo, g *echo.Group, r *gin.RouterGroup, x *Router) { + e.GET("/e", h.Handler, auth.Middleware) + g.GET("/g", h.Handler, auth.Middleware) + r.GET("/r", auth.Middleware, h.Handler) + x.GET("/x", h.Handler, auth.Middleware) + register := func(sub *echo.Group) { + sub.GET("/f", h.Handler, auth.Middleware) + } + _ = register +} + +type Server struct{} + +func (s *Server) routes(api *echo.Group) { + api.GET("/m", h.Handler, auth.Middleware) +} +`), + ).toEqual([ + { method: 'GET', path: '/e', name: 'Handler' }, + { method: 'GET', path: '/g', name: 'Handler' }, + { method: 'GET', path: '/r', name: 'Handler' }, + { method: 'GET', path: '/x', name: 'Middleware' }, + { method: 'GET', path: '/f', name: 'Handler' }, + { method: 'GET', path: '/m', name: 'Handler' }, + ]); + }); + + it('picks the handler order per receiver constructor in a mixed-import file', () => { + // Mixed imports are ambiguous at file scope, but `e := echo.New()` proves + // this call follows echo's order (handler FIRST after the path) and + // `r := gin.Default()` proves gin's (handler LAST). + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := echo.New() + r := gin.Default() + e.GET("/x", h.Handler, auth.Middleware) + r.POST("/y", auth.Middleware, h.Post) +} +`), + ).toEqual([ + { method: 'GET', path: '/x', name: 'Handler' }, + { method: 'POST', path: '/y', name: 'Post' }, + ]); + }); + + it('does not treat an unrelated New() as a framework constructor', () => { + // `wrapper.New()` is neither echo's nor gin's constructor: no proof → + // conservative last-argument fallback, not a guessed echo order. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := wrapper.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('does not mistake a local shadowing the echo import for its constructor', () => { + // A parameter named `echo` shadows the package qualifier: `echo.New()` + // is then a method on that value, not echo's constructor, so the mixed + // file keeps the conservative last-argument fallback. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes(echo *Factory) { + e := echo.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('does not mistake a value-less local declaration of echo for the import', () => { + // `var echo Factory` declares a local without an initializer; it still + // shadows the package qualifier, so `echo.New()` proves nothing. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + var echo Factory + e := echo.New() + e.GET("/x", h.Handler, auth.Middleware) +} +`), + ).toEqual([{ method: 'GET', path: '/x', name: 'Middleware' }]); + }); + + it('declines a route whose Group chain exceeds the depth cap', () => { + // Past MAX_GROUP_DEPTH (32) the full prefix — and, in a mixed file, the + // framework order — is unprovable: emitting the outer prefixes alone (or + // gin's handler order for an echo chain) would be a silent guess. + const chain = (ctor: string, imports: string) => { + const lines = [`g0 := ${ctor}`]; + for (let i = 1; i <= 34; i++) lines.push(`g${i} := g${i - 1}.Group("/p${i}")`); + return `package main +${imports} + +func routes() { + ${lines.join('\n\t')} + g34.GET("/x", h.Handler, auth.Middleware) + g2.GET("/y", h.Handler, auth.Middleware) +} +`; + }; + expect(providers(chain('gin.Default()', 'import "github.com/gin-gonic/gin"'))).toEqual([ + { method: 'GET', path: '/p1/p2/y', name: 'Middleware' }, + ]); + const mixed = `import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +)`; + expect(providers(chain('echo.New()', mixed))).toEqual([ + { method: 'GET', path: '/p1/p2/y', name: 'Handler' }, + ]); + }); + + it('traces a grouped receiver back to its framework constructor in a mixed file', () => { + // The normal grouped shape: `users := api.Group(…)` ← `api := e.Group(…)` + // ← `echo.New()` proves echo order through the Group chain, while the + // gin chain resolves to gin.Default() and keeps the last-argument rule. + expect( + providers(`package main +import ( + "github.com/gin-gonic/gin" + "github.com/labstack/echo/v4" +) + +func routes() { + e := echo.New() + api := e.Group("/api") + users := api.Group("/users") + users.GET("/:id", h.Handler, auth.Middleware) + r := gin.Default() + v1 := r.Group("/v1") + v1.POST("/x", auth.Middleware, h.Post) +} +`), + ).toEqual([ + { method: 'GET', path: '/api/users/:id', name: 'Handler' }, + { method: 'POST', path: '/v1/x', name: 'Post' }, + ]); + }); + + it('marks handler resolution by how the handler is designated', () => { + // `h.List` / `o.List` emit the field name `List`, but the operand does not + // prove where `List` is declared: they are qualifiedHandler (repo-wide + // unique match only, never a same-named local method). A bare name + // declared more than once in the file (`Show` as function and method) + // resolves only when unique in the file; a bare unique name keeps the + // default resolution. + parser.setLanguage(Go); + const flags = GO_HTTP_PLUGIN.scan( + parser.parse(`package main +import "github.com/gin-gonic/gin" + +type A struct{} + +func (a *A) List(c *gin.Context) {} +func (a *A) Show(c *gin.Context) {} +func Show(c *gin.Context) {} +func Ping(c *gin.Context) {} + +func routes(r *gin.Engine, h *A, o *B) { + r.GET("/a", h.List) + r.GET("/b", o.List) + r.GET("/s", Show) + r.GET("/p", Ping) +} +`), + ) + .filter((d) => d.role === 'provider') + .map((d) => [ + d.path, + d.name, + d.qualifiedHandler ?? false, + d.strictHandlerResolution ?? false, + ]); + expect(flags).toEqual([ + ['/a', 'List', true, false], + ['/b', 'List', true, false], + ['/s', 'Show', false, true], + ['/p', 'Ping', false, false], + ]); + }); + + it('resolves grouped var specs that precede the use', () => { + // Earlier specs in a grouped `var (…)` are in scope for later ones; the + // ingestion side emits /api/admin/x for this, so both strategies agree. + expect( + providers(`package main +func routes(r *gin.Engine) { + var ( + api = r.Group("/api") + admin = api.Group("/admin") + ) + admin.GET("/x", handler) +} +`), + ).toEqual([{ method: 'GET', path: '/api/admin/x', name: 'handler' }]); + }); + + it('declines a route whose group is reassigned in an earlier nested scope', () => { + // `{ g = r.Group("/new") }` (or a branch) writes the outer g: which value + // reaches the use depends on control flow, and ingestion declines it too, + // so emitting the older `/old/x` would invent a route. A nested `:=` + // declares a new variable and leaves the outer binding intact. + expect( + providers(`package main +func routes(r *gin.Engine, cond bool) { + g := r.Group("/old") + { g = r.Group("/new") } + g.GET("/x", handler) + k := r.Group("/k") + if cond { k = r.Group("/other") } + k.GET("/y", handler) + m := r.Group("/m") + { m := r.Group("/inner"); m.GET("/i", handler) } + m.GET("/z", handler) +} +`), + ).toEqual([ + { method: 'GET', path: '/inner/i', name: 'handler' }, + { method: 'GET', path: '/m/z', name: 'handler' }, + ]); + }); + + it('resolves a name used in its own statement initializer to the outer binding', () => { + // `if g := g.Group("/inner"); …` — the right-hand g is the OUTER group; + // the new g only scopes over what follows. Same for a for initializer. + expect( + providers(`package main +func routes(r *gin.Engine, enabled bool) { + g := r.Group("/api") + if g := g.Group("/inner"); enabled { + g.GET("/x", handler) + } + for g := g.Group("/loop"); enabled; { + g.GET("/y", handler) + } +} +`), + ).toEqual([ + { method: 'GET', path: '/api/inner/x', name: 'handler' }, + { method: 'GET', path: '/api/loop/y', name: 'handler' }, + ]); + }); + + it('skips comments when locating the path and the handler', () => { + // tree-sitter names comments, so they must not count as arguments: a + // leading comment must not hide the path, and a comment must not be + // picked as the echo (first) or gin (last) handler. + expect( + providers(`package main +import "github.com/labstack/echo/v4" +func routes(e *echo.Echo) { + e.GET("/users", /* description */ users) + e.POST(/* description */ "/posts", posts /* trailing */) +} +`), + ).toEqual([ + { method: 'GET', path: '/users', name: 'users' }, + { method: 'POST', path: '/posts', name: 'posts' }, + ]); + expect( + providers(`package main +import "github.com/gin-gonic/gin" +func routes(r *gin.Engine) { + r.GET(/* description */ "/users", users /* trailing */) + g := r.Group(/* c */ "/api") + g.GET("/x", h.X) +} +`), + ).toEqual([ + { method: 'GET', path: '/users', name: 'users' }, + { method: 'GET', path: '/api/x', name: 'X' }, + ]); + }); + + it('applies the same group logic to echo', () => { + expect( + providers(`package main +func main() { + e := echo.New() + api := e.Group("/api") + users := api.Group("/users") + users.GET("/:id", userHandler.Get) +} +`), + ).toEqual([{ method: 'GET', path: '/api/users/:id', name: 'Get' }]); + }); +}); + +describe('Go gin provider ↔ fetch() consumer pairing', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-gin-groups-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const repoHandle = (repoPath: string, id: string): RepoHandle => ({ + id, + path: id, + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + it('does not bind a qualified handler to an unrelated same-named local method', async () => { + // routes.go declares A.List; the route's handler is b.List, with B.List in + // b.go. The file-first name lookup would pick A.List, so a qualified + // handler skips it: a repo-wide unique List resolves, an ambiguous one + // keeps the file-level fallback (empty symbolUid) instead of A.List. + const repo = path.join(tmpDir, 'repo'); + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync( + path.join(repo, 'routes.go'), + `package main + +import "github.com/gin-gonic/gin" + +type A struct{} + +func (a *A) List(c *gin.Context) {} + +func routes(r *gin.Engine, b *B) { + r.GET("/bs", b.List) +} +`, + ); + const aList = { + uid: 'Method:routes.go:A.List', + name: 'List', + filePath: 'routes.go', + startLine: 6, + endLine: 6, + labels: ['Method'], + }; + const bList = { uid: 'Method:b.go:B.List', name: 'List', filePath: 'b.go' }; + const run = async (repoWide: Record[]) => { + const db = async (query: string, params?: Record) => { + if (query === RESOLVE_BY_NAME_QUERY) return params?.name === 'List' ? repoWide : []; + if (query.includes('UNION ALL') && params?.filePath === 'routes.go') return [aList]; + return []; + }; + const out = await new HttpRouteExtractor().extract(db, repo, repoHandle(repo, 'repo')); + return out.find((c) => c.contractId === 'http::GET::/bs')?.symbolUid; + }; + expect(await run([aList, bList])).toBe(''); + expect(await run([bList])).toBe(bList.uid); + }); + + it('cross-links a grouped method-value route to a ${API_BASE}-prefixed fetch', async () => { + const backend = path.join(tmpDir, 'backend'); + const web = path.join(tmpDir, 'web'); + fs.mkdirSync(path.join(backend, 'internal/handlers'), { recursive: true }); + fs.mkdirSync(path.join(web, 'src/pages'), { recursive: true }); + fs.writeFileSync(path.join(backend, 'internal/handlers/routes.go'), GIN_ROUTES); + fs.writeFileSync( + path.join(web, 'src/pages/AdminMatchesPage.tsx'), + `const API_BASE = import.meta.env.VITE_API_BASE; + +export async function handleUnfinalize(seasonId: string, roundId: string) { + await fetch(\`\${API_BASE}/api/v1/admin/seasons/\${seasonId}/rounds/\${roundId}/unfinalize\`, { + method: 'POST', + }); +} +`, + ); + + const extractor = new HttpRouteExtractor(); + const contracts: StoredContract[] = [ + ...(await extractor.extract(null, backend, repoHandle(backend, 'backend'))).map((c) => ({ + ...c, + repo: 'backend', + })), + ...(await extractor.extract(null, web, repoHandle(web, 'web'))).map((c) => ({ + ...c, + repo: 'web', + })), + ]; + + const { matched } = runExactMatch(contracts); + const link = matched.find( + (l) => l.contractId === 'http::POST::/api/v1/admin/seasons/{param}/rounds/{param}/unfinalize', + ); + expect(link).toMatchObject({ + from: { repo: 'web', symbolRef: { filePath: 'src/pages/AdminMatchesPage.tsx' } }, + to: { + repo: 'backend', + symbolRef: { filePath: 'internal/handlers/routes.go', name: 'UnfinalizeRoundHandle' }, + }, + matchType: 'exact', + }); + }); +});