Merge branch 'main' into fix/dart-pubspec-macos

This commit is contained in:
Gergő Magyar 2026-10-04 12:56:51 +01:00 • committed by GitHub
commit e1e5d13c4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 6992 additions and 216 deletions

View file

@ -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');
});
});

View file

@ -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<void> =
// 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.');
}

View file

@ -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<void> {
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<string>();
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<string, unknown> & 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<number>)
: 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;
});

View file

@ -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<string>;
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<string> = new Set(),
): Set<string> {
const groups = new Map<string, { hash: string; ordinals: Set<number>; 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<string>();
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<typeof createCachedEmbeddingsBuilder>,
): 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<string, unknown>, 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<CachedEmbeddingsSnapshot> {
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<string, unknown>, 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<void> {
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()}` : ''}`,
),
);
});
});
}

View file

@ -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<Record<string, never>>);
/** 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<string> = 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<string>;
gin: Set<string>;
} {
// 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<string>();
const gin = new Set<string>();
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<string>,
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<string>,
): 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<string, number>();
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',

View file

@ -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

View file

@ -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

View file

@ -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<string> = 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<Record<string, string>> = {
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;

View file

@ -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<Record<string, string>> = {
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;
}
}

View file

@ -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<nodeId, contentHash> from cached embeddings for incremental mode
let existingEmbeddings: Map<string, string> | 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;
}
}

View file

@ -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;
}
}

View file

@ -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<string, string>();
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<any> {
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<string>();
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<any> {
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<string>([symId]);
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
@ -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);
}
}

View file

@ -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 `<fnLine>` 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<Record<string, unknown>>) {
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<Record<string, unknown>>) {
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<Record<string, unknown>>) {
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<Record<string, unknown>>;
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<Record<string, unknown>>) {
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<Record<string, unknown>>) {
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<Record<string, unknown>>) {
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<PdgImpactRes
seedBlockQuery(anchorClause, probeLimit),
queryParams,
);
for (const row of rawSeedRows) assertQueryIdentity(row, 'id', 0);
const seedRows = rawSeedRows.slice(0, stepLimit) as Array<Record<string, unknown>>;
let seedBlocks: string[] = seedRows
.map((r) => String(r['id'] ?? ''))

View file

@ -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<string, unknown>;
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<readonly [string, number]> = [],
): 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;
}

View file

@ -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, () => {}))) {

View file

@ -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<string, unknown> =>
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 */
}
}
}
};

View file

@ -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/<slug>/` 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.<id>`, `lbug.staging.<id>.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++;

View file

@ -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

View file

@ -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();
}

View file

@ -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<ChildProcess>();
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<string[]> {
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<string, number[]>();
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<void>((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<void>((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<void>((resolve) => child.once('close', () => resolve())),
),
);
if (server) {
server.closeAllConnections();
await new Promise<void>((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,
);
});

View file

@ -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<ReturnType<LocalBackend['callTool']>>,
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<ReturnType<LocalBackend['callTool']>>,
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<ReturnType<typeof createTempDir>>;
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<ReturnType<LocalBackend['callTool']>>,
context: Awaited<ReturnType<LocalBackend['callTool']>>,
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();
}
}
});
});

View file

@ -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);
});

View file

@ -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<typeof import('../../src/storage/repo-manager.js')>()),
loadMeta: mocks.loadMeta,
saveMeta: mocks.saveMeta,
listRegisteredRepos: mocks.listRegisteredRepos,
}));
vi.mock('../../src/storage/index-lock.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/storage/index-lock.js')>()),
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<typeof import('../../src/storage/storage-resolver.js')>()),
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<typeof import('../../src/storage/index-lock.js')>(
'../../src/storage/index-lock.js',
);
mocks.acquireIndexLock.mockImplementation(
async (...args: Parameters<typeof actual.acquireIndexLock>) => {
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<string, unknown> = {}) {
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);
},
);
});

View file

@ -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', {

View file

@ -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<string, unknown>[]) {
(executeParameterized as any).mockImplementation(
async (_repo: string, query: string, params: Record<string, unknown>) => {
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',

View file

@ -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<typeof import('node:fs')>();
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<typeof import('node:fs')>('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();
});
});

View file

@ -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();
});
});

View file

@ -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<typeof import('../../src/storage/index-lock.js')>()),
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<typeof import('../../src/storage/index-lock.js')>(
'../../src/storage/index-lock.js',
);
acquireIndexLockMock.mockImplementation(
async (...args: Parameters<typeof actual.acquireIndexLock>) => {
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.

View file

@ -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<string, unknown>[]) => {
const db = async (query: string, params?: Record<string, unknown>) => {
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',
});
});
});

View file

@ -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');
}

View file

@ -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<typeof import('../../src/storage/repo-manager.js')>()),
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<typeof import('../../src/storage/git.js')>()),
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<Record<Seam, unknown[]>>;
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<string, any> | 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<string, unknown>, 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é<66>', filePath: '源/café<66>.ts', content }];
rows.incoming = [{ ...REF, name: '呼び出し<E587BA>', 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é<66>',
filePath: '源/café<66>.ts',
content,
});
expect(result.symbol.methodMetadata).toEqual({ annotations: ['@Text("source\0value")'] });
expect(result.incoming.calls[0]).toMatchObject({ uid: REF.uid, name: '呼び出し<E587BA>' });
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, '呼び出し<E587BA>', '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<typeof import('../../src/mcp/local/aop-metadata.js')>(
'../../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);
}
});
});

View file

@ -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 [];

View file

@ -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();

View file

@ -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',

View file

@ -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) => {

View file

@ -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<string>(),
embeddings: [],
@ -2530,11 +2551,13 @@ describe('runFullAnalysis embedding-checkpoint resilience (#2790 review)', () =>
pipelineOptions: EmbeddingPipelineOptions,
): Promise<EmbeddingPipelineResult> => 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<void> => {
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<RepoMeta | null> = [];
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<RepoMeta['embeddingCheckpoint']>;
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<RepoMeta | null> = [];
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();
}
});
});

View file

@ -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<void>>(),
connClose: vi.fn<() => Promise<void>>(),
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<typeof import('../../src/core/embeddings/embedding-restore-spill.js')>();
return {
...actual,
abortCachedEmbeddingsBuilder: (
...args: Parameters<typeof actual.abortCachedEmbeddingsBuilder>
) => {
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<void> {
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<unknown> | 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');
});
});

View file

@ -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/,
);
});
});

View file

@ -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',