fix(mcp): reject corrupt query identities and refresh index metadata

This commit is contained in:
Gergő Magyar 2026-10-13 13:02:01 +03:00
parent 9572e71127
commit 9d79092abc
18 changed files with 1366 additions and 262 deletions

View file

@ -57,6 +57,20 @@ version that writes the completeness receipt. An older index or unreadable compl
`incompleteReasons: ["scope-extraction-unverified"]`; re-analyze it before treating `incompleteReasons: ["scope-extraction-unverified"]`; re-analyze it before treating
empty impact results as exact. empty impact results as exact.
**Invalid symbol identity:** `query`, `context`, or `impact` can return
`The index returned an invalid symbol identity` with recovery guidance instead
of returning malformed results. Rebuild from the affected repository root with
`npx gitnexus analyze --force`. If the error returns, retain the failing command,
GitNexus/Node versions and `gitnexus doctor` output for investigation. This check
detects blank IDs and NUL-containing identity fields; it does not prove that
every otherwise plausible name, path or source range is correct.
**MCP freshness after rebuilding:** a warm session refreshes its index reference
when it opens the replacement graph, including a rebuild at the same commit.
A busy reader keeps its old reference until it can reopen. During an observed
graph/metadata publication gap, freshness is `unknown`; retry after analysis
finishes rather than treating that response as current.
--- ---
## Embeddings ## Embeddings

View file

@ -1,5 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
/** Native search contracts for #3421/#3423. See search-root-cause.md. */ /** Native search contracts for #3421/#3423/#3564. See search-root-cause.md. */
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const os = require('node:os'); const os = require('node:os');
@ -8,8 +8,8 @@ const path = require('node:path');
const lbug = require(process.env.LBUG_MODULE || '@ladybugdb/core'); const lbug = require(process.env.LBUG_MODULE || '@ladybugdb/core');
const scenario = process.argv[2]; const scenario = process.argv[2];
assert.ok( assert.ok(
['context', 'property'].includes(scenario), ['context', 'property', 'method'].includes(scenario),
'Usage: reproduce-search.cjs context|property [--keep]', 'Usage: reproduce-search.cjs context|property|method [--keep]',
); );
(async () => { (async () => {
@ -65,29 +65,39 @@ assert.ok(
if (errors.length) throw new AggregateError(errors, errors.map(String).join('; ')); if (errors.length) throw new AggregateError(errors, errors.map(String).join('; '));
return rows; return rows;
}; };
const table = scenario === 'context' ? 'Function' : 'Property'; const table = { context: 'Function', property: 'Property', method: 'Method' }[scenario];
const columns = ['id', 'name', 'filePath', 'startLine', 'endLine']; const columns = ['id', 'name', 'filePath', 'startLine', 'endLine'];
if (scenario === 'property') columns.push('content', 'description'); if (scenario === 'property') columns.push('content', 'description');
if (scenario === 'method') columns.push('content');
const indices = Array.from({ length: 8192 }, (_, i) => i); const indices = Array.from({ length: 8192 }, (_, i) => i);
const changed = (i) => i < 32 || i >= 8192 - 32; const changed = (i) => i < 32 || i >= 8192 - 32;
const tuple = (i) => const tuple = (i) =>
scenario === 'context' scenario === 'method'
? [ ? [
`Function:src/owner${Math.floor(i / 32)}.ts:unchangedSwiftTarget${i}`, `Method:checks/test_owner${Math.floor(i / 32)}.py:Checks.test_retained_method_${i}#0`,
`unchangedSwiftTarget${i}`, // Out-of-line name, unlike the short fn64 fixture. `test_retained_method_${i}`,
`src/owner${Math.floor(i / 32)}.ts`, `checks/test_owner${Math.floor(i / 32)}.py`,
(i % 32) * 4, (i % 32) * 4,
(i % 32) * 4 + 2, (i % 32) * 4 + 2,
` def test_retained_method_${i}(self): value = ${i} return value`,
] ]
: [ : scenario === 'context'
`Property:src/owner${Math.floor(i / 32)}.swift:property${i}`, ? [
`unchangedPropertyValue${i}${'王'.repeat(i % 17)}`, `Function:src/owner${Math.floor(i / 32)}.ts:unchangedSwiftTarget${i}`,
`src/owner${Math.floor(i / 32)}.swift`, `unchangedSwiftTarget${i}`, // Out-of-line name, unlike the short fn64 fixture.
i, `src/owner${Math.floor(i / 32)}.ts`,
i + 1, (i % 32) * 4,
`var unchangedPropertyValue${i} = "Valid UTF8 Å王 café 東京 ${i} ${'Résumé Ελληνικά Привет 🙂 '.repeat(i % 53)}"${i === 64 ? ' retainedsearchsentinel' : ''}`, (i % 32) * 4 + 2,
`Documentation for property ${i} ${'正常な日本語 naïve façade Å王 '.repeat(i % 37)}`, ]
]; : [
`Property:src/owner${Math.floor(i / 32)}.swift:property${i}`,
`unchangedPropertyValue${i}${'王'.repeat(i % 17)}`,
`src/owner${Math.floor(i / 32)}.swift`,
i,
i + 1,
`var unchangedPropertyValue${i} = "Valid UTF8 Å王 café 東京 ${i} ${'Résumé Ελληνικά Привет 🙂 '.repeat(i % 53)}"${i === 64 ? ' retainedsearchsentinel' : ''}`,
`Documentation for property ${i} ${'正常な日本語 naïve façade Å王 '.repeat(i % 37)}`,
];
const csv = (selected) => const csv = (selected) =>
columns.join(',') + columns.join(',') +
'\n' + '\n' +
@ -147,9 +157,10 @@ assert.ok(
const lookup = async (stage) => { const lookup = async (stage) => {
const target = tuple(64); // Retained throughout DELETE/COPY. const target = tuple(64); // Retained throughout DELETE/COPY.
const select = const select =
'n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine'; 'n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine' +
(scenario === 'method' ? ', n.content AS content' : '');
for (const [kind, sql, params] of [ for (const [kind, sql, params] of [
['primary-key', `MATCH (n:Function {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }], ['primary-key', `MATCH (n:${table} {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }],
// Match LocalBackend.resolveContextSymbol's label-free UID/name queries. // Match LocalBackend.resolveContextSymbol's label-free UID/name queries.
['uid', `MATCH (n {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }], ['uid', `MATCH (n {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }],
[ [
@ -197,7 +208,7 @@ assert.ok(
}; };
const check = async (stage) => { const check = async (stage) => {
await scan(stage); await scan(stage);
if (scenario === 'context') await lookup(stage); if (scenario !== 'property') await lookup(stage);
else await search(stage); else await search(stage);
}; };
try { try {
@ -208,14 +219,15 @@ assert.ok(
await query(`CREATE NODE TABLE ${table}(${schema}, PRIMARY KEY(id))`); await query(`CREATE NODE TABLE ${table}(${schema}, PRIMARY KEY(id))`);
// Multiple labels make the UID query use a filtered scan, as in GitNexus. // Multiple labels make the UID query use a filtered scan, as in GitNexus.
if (scenario === 'context') await query(`CREATE NODE TABLE Method(${schema}, PRIMARY KEY(id))`); if (scenario === 'context') await query(`CREATE NODE TABLE Method(${schema}, PRIMARY KEY(id))`);
if (scenario === 'method')
await query(`CREATE NODE TABLE Function(${schema}, PRIMARY KEY(id))`);
await copy('full.csv'); await copy('full.csv');
if (scenario === 'property') await record('baseline:build', build); if (scenario === 'property') await record('baseline:build', build);
await query('CHECKPOINT'); await query('CHECKPOINT');
await check('baseline'); await check('baseline');
if (scenario === 'property') await drop(); if (scenario === 'property') await drop();
const suffix = scenario === 'context' ? 'ts' : 'swift';
await query( await query(
`MATCH (n:${table}) WHERE n.filePath IN ['src/owner0.${suffix}', 'src/owner255.${suffix}'] DETACH DELETE n`, `MATCH (n:${table}) WHERE n.filePath IN [${literal(tuple(0)[2])}, ${literal(tuple(8191)[2])}] DETACH DELETE n`,
); );
await scan( await scan(
'after-delete', 'after-delete',

View file

@ -8,6 +8,10 @@ import path from 'node:path';
import { checkStaleness } from '../git-staleness.js'; import { checkStaleness } from '../git-staleness.js';
import { stalenessStatus, type StalenessInfo, type StalenessStatus } from '../staleness-status.js'; import { stalenessStatus, type StalenessInfo, type StalenessStatus } from '../staleness-status.js';
import { mapConcurrent } from '../../lib/utils.js'; import { mapConcurrent } from '../../lib/utils.js';
import {
SymbolIdentityError,
SYMBOL_IDENTITY_RECOVERY_SUGGESTION,
} from '../lbug/query-result-integrity.js';
import { import {
canonicalizePath, canonicalizePath,
loadMeta, loadMeta,
@ -840,7 +844,20 @@ export class GroupService {
})) as { })) as {
processes?: Array<Record<string, unknown>>; processes?: Array<Record<string, unknown>>;
process_symbols?: Array<Record<string, unknown>>; process_symbols?: Array<Record<string, unknown>>;
error?: string;
recoverySuggestion?: string;
}; };
if (
queryResult.error &&
queryResult.recoverySuggestion === SYMBOL_IDENTITY_RECOVERY_SUGGESTION
) {
return {
repo: repoPath,
score: 0,
processes: [] as unknown[],
integrityError: queryResult.error,
};
}
const processes = servicePrefix const processes = servicePrefix
? filterQueryByServicePrefix(queryResult, servicePrefix).processes ? filterQueryByServicePrefix(queryResult, servicePrefix).processes
: queryResult.processes || []; : queryResult.processes || [];
@ -849,9 +866,19 @@ export class GroupService {
_rrf_score: 1 / (idx + 1 + 60), _rrf_score: 1 / (idx + 1 + 60),
_repo: repoPath, _repo: repoPath,
})); }));
return { repo: repoPath, score: 0, processes: scored as unknown[] }; return {
} catch { repo: repoPath,
return { repo: repoPath, score: 0, processes: [] as unknown[] }; score: 0,
processes: scored as unknown[],
integrityError: undefined,
};
} catch (error) {
return {
repo: repoPath,
score: 0,
processes: [] as unknown[],
integrityError: error instanceof SymbolIdentityError ? error.message : undefined,
};
} }
}, },
{ concurrency: GROUP_MEMBER_CONCURRENCY }, { concurrency: GROUP_MEMBER_CONCURRENCY },
@ -859,9 +886,21 @@ export class GroupService {
).map((result, index) => { ).map((result, index) => {
if (result) return result; if (result) return result;
const [repoPath] = memberEntries[index]!; const [repoPath] = memberEntries[index]!;
return { repo: repoPath, score: 0, processes: [] as unknown[] }; return { repo: repoPath, score: 0, processes: [] as unknown[], integrityError: undefined };
}); });
// A corrupt member is not an empty member. Preserve the same recovery
// contract as single-repo query rather than returning a misleading group.
const invalidMember = perRepo.find((result) => result.integrityError);
if (invalidMember) {
return {
error: invalidMember.integrityError,
recoverySuggestion: SYMBOL_IDENTITY_RECOVERY_SUGGESTION,
group: name,
repo: invalidMember.repo,
};
}
const allProcesses = perRepo.flatMap((r) => r.processes as Array<Record<string, unknown>>); const allProcesses = perRepo.flatMap((r) => r.processes as Array<Record<string, unknown>>);
allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number)); allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number));
const topN = allProcesses.slice(0, limit); const topN = allProcesses.slice(0, limit);

View file

@ -99,6 +99,12 @@ export function dbIdentityChanged(prev: DbIdentity | null, next: DbIdentity | nu
const pool = new Map<string, PoolEntry>(); const pool = new Map<string, PoolEntry>();
/** Identity of the native database currently serving this pool, not the path's latest file. */
export function getLbugDbIdentity(repoId: string): DbIdentity | null {
const identity = pool.get(repoId)?.dbIdentity;
return identity ? { ...identity } : null;
}
/** /**
* Listeners notified when a pool entry is torn down (LRU eviction, idle * Listeners notified when a pool entry is torn down (LRU eviction, idle
* timeout, explicit close). Used by upper layers (e.g. the BM25 search * timeout, explicit close). Used by upper layers (e.g. the BM25 search
@ -946,9 +952,9 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<InitLbugAttem
// Register pool entry only after all connections are pre-warmed and FTS is // Register pool entry only after all connections are pre-warmed and FTS is
// loaded. Concurrent executeQuery calls see either "not initialized" // loaded. Concurrent executeQuery calls see either "not initialized"
// (and throw cleanly) or a fully ready pool — never a half-built one. // (and throw cleanly) or a fully ready pool — never a half-built one.
// Record the on-disk identity so a later initLbug can detect an analyze // Keep the opened SharedDB identity: another consumer may still hold an
// rebuild/mutation and re-open onto the new file (pool staleness invalidation). // older native handle after the file at dbPath has been replaced.
const dbIdentity = await statDbIdentity(dbPath); const dbIdentity = shared.dbIdentity ?? null;
pool.set(repoId, { pool.set(repoId, {
db, db,
available, available,

View file

@ -0,0 +1,57 @@
/** Shared integrity boundary for identities returned by graph and search 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

@ -15,6 +15,11 @@
// that identical call itself — shared for the same reason, so the two paths // that identical call itself — shared for the same reason, so the two paths
// can no longer drift apart the way they had to be edited in lockstep before. // can no longer drift apart the way they had to be edited in lockstep before.
import { queryFTS, classifyFtsQueryError, buildFtsQueryCypher } from '../lbug/lbug-adapter.js'; import { queryFTS, classifyFtsQueryError, buildFtsQueryCypher } from '../lbug/lbug-adapter.js';
import {
assertSymbolIdentity,
assertIdentityFields,
rethrowSymbolIdentityError,
} from '../lbug/query-result-integrity.js';
import { normalizeFtsText } from '../lbug/csv-generator.js'; import { normalizeFtsText } from '../lbug/csv-generator.js';
import { getExtensionCapabilities } from '../lbug/extension-loader.js'; import { getExtensionCapabilities } from '../lbug/extension-loader.js';
import { redactPaths } from './fts-indexes.js'; import { redactPaths } from './fts-indexes.js';
@ -85,7 +90,9 @@ async function queryFTSViaExecutor(
const rows = await executor(cypher, { query }); const rows = await executor(cypher, { query });
return { return {
rows: rows.map((row: any) => { rows: rows.map((row: any) => {
const node = row.node || row[0] || {}; const node = row.node ?? row[0] ?? {};
assertSymbolIdentity(node.nodeId ?? node.id);
assertIdentityFields(node.name, node.filePath);
const score = row.score ?? row[1] ?? 0; const score = row.score ?? row[1] ?? 0;
return { return {
filePath: node.filePath || '', filePath: node.filePath || '',
@ -95,6 +102,7 @@ async function queryFTSViaExecutor(
}), }),
}; };
} catch (e) { } catch (e) {
rethrowSymbolIdentityError(e);
const message = e instanceof Error ? e.message : String(e); const message = e instanceof Error ? e.message : String(e);
return { benign: classifyFtsQueryError(message) === 'missing-index', message }; return { benign: classifyFtsQueryError(message) === 'missing-index', message };
} }
@ -207,6 +215,8 @@ export const searchFTSFromLbug = async (
const addResults = (results: any[]) => { const addResults = (results: any[]) => {
for (const r of results) { for (const r of results) {
assertSymbolIdentity(r.nodeId);
assertIdentityFields(r.name, r.filePath);
if (!fileNodeScores.has(r.filePath)) fileNodeScores.set(r.filePath, []); if (!fileNodeScores.has(r.filePath)) fileNodeScores.set(r.filePath, []);
fileNodeScores.get(r.filePath)!.push({ score: r.score, nodeId: r.nodeId }); fileNodeScores.get(r.filePath)!.push({ score: r.score, nodeId: r.nodeId });
} }

View file

@ -27,6 +27,7 @@ import {
isLbugReady, isLbugReady,
statDbIdentity, statDbIdentity,
dbIdentityChanged, dbIdentityChanged,
getLbugDbIdentity,
} from '../../core/lbug/pool-adapter.js'; } from '../../core/lbug/pool-adapter.js';
import { queryClassBeanMetadata } from './bean-metadata.js'; import { queryClassBeanMetadata } from './bean-metadata.js';
import { querySpringAopMetadata } from './aop-metadata.js'; import { querySpringAopMetadata } from './aop-metadata.js';
@ -1217,6 +1218,9 @@ interface RepoHandle {
branch?: string; branch?: string;
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */ /** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
branches?: BranchSummary[]; branches?: BranchSummary[];
/** Per-tool snapshot; never shared with the registry's cached handle. */
poolIdentity?: Awaited<ReturnType<typeof statDbIdentity>>;
generationUnknown?: boolean;
} }
/** Resolve symlinks for path comparison; falls back to path.resolve on error. /** Resolve symlinks for path comparison; falls back to path.resolve on error.
@ -1677,30 +1681,72 @@ export function attachToolStaleness(
return { ...result, staleness }; return { ...result, staleness };
} }
/** Optional metadata must not prevent a healthy native index from answering. */
async function loadOptionalMeta(storagePath: string) {
try {
return await loadMeta(storagePath);
} catch {
return null;
}
}
/** tri-review Residual-2: see `LocalBackend.lastObservedPoolState`'s doc comment. */ /** tri-review Residual-2: see `LocalBackend.lastObservedPoolState`'s doc comment. */
interface PoolObservedState { interface PoolObservedState {
indexedAt?: string; indexedAt?: string;
dbIdentity: Awaited<ReturnType<typeof statDbIdentity>>; dbIdentity: Awaited<ReturnType<typeof statDbIdentity>>;
ftsStatus?: string; ftsStatus?: string;
ref?: IndexedRef;
generationUnknown?: boolean;
awaitingMetadata?: boolean;
diskIndexedAt?: string;
metadata?: Awaited<ReturnType<typeof loadMeta>>;
} }
export class LocalBackend { export class LocalBackend {
// A selected handle belongs to one request. Reuse its metadata across
// freshness, retention and completeness checks without retaining it forever.
private readonly toolMetadata = new WeakMap<
RepoHandle,
Promise<Awaited<ReturnType<typeof loadMeta>>>
>();
private readToolMetadata(repo: RepoHandle): Promise<Awaited<ReturnType<typeof loadMeta>>> {
let pending = this.toolMetadata.get(repo);
if (!pending) {
pending = loadOptionalMeta(path.dirname(repo.lbugPath)).then((meta) => {
if (!meta) return null;
// Readers never use ingestion manifests. Do not keep their per-file
// hashes/cache keys or pending embedding IDs in an opened snapshot.
return {
...meta,
fileHashes: undefined,
cacheKeys: undefined,
embeddingCheckpoint: undefined,
};
});
this.toolMetadata.set(repo, pending);
}
return pending;
}
private static readonly TOOL_STALENESS_TTL_MS = 5000; private static readonly TOOL_STALENESS_TTL_MS = 5000;
private repos: Map<string, RepoHandle> = new Map(); private repos: Map<string, RepoHandle> = new Map();
private contextCache: Map<string, CodebaseContext> = new Map(); private contextCache: Map<string, CodebaseContext> = new Map();
private initializedRepos: Set<string> = new Set(); private initializedRepos: Set<string> = new Set();
private reinitPromises: Map<string, Promise<void>> = new Map(); private reinitPromises: Map<string, Promise<void>> = new Map();
private lastStalenessCheck: Map<string, number> = new Map();
// #2655: commit-behind freshness for the hot read tools. Stores the IN-FLIGHT // #2655: commit-behind freshness for the hot read tools. Stores the IN-FLIGHT
// promise (not just a timestamp) so N concurrent tool calls arriving before // promise (not just a timestamp) so N concurrent tool calls arriving before
// the first `git rev-list` resolves share one subprocess instead of each // the first `git rev-list` resolves share one subprocess instead of each
// spawning their own; the resolved value is reused for TOOL_STALENESS_TTL_MS. // spawning their own; the resolved value is reused for TOOL_STALENESS_TTL_MS.
// Keyed by lbugPath (like lastStalenessCheck) — NOT repoPath — because flat // Keyed by lbugPath — NOT repoPath — because flat
// and branch handles for one repo share a repoPath but carry different // and branch handles for one repo share a repoPath but carry different
// lastCommit values, so a repoPath key would serve one handle's freshness for // lastCommit values, so a repoPath key would serve one handle's freshness for
// the other; lbugPath is unique per flat/branch index. // the other. Shared graphs can serve several checkouts, so the generation
private toolStalenessCache: Map<string, { at: number; value: Promise<StalenessInfo> }> = // also identifies the repoPath whose HEAD is measured.
new Map(); private toolStalenessCache: Map<
string,
{ at: number; generation: string; value: Promise<StalenessInfo> }
> = new Map();
// tri-review Residual-2: consolidates what were three parallel per-poolKey // tri-review Residual-2: consolidates what were three parallel per-poolKey
// Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus) // Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus)
// touched in lockstep at every call site below — one Map, one delete, one // touched in lockstep at every call site below — one Map, one delete, one
@ -1903,7 +1949,9 @@ export class LocalBackend {
// Meta probe: layer present iff the flows cap is stamped. `false` is a // Meta probe: layer present iff the flows cap is stamped. `false` is a
// definitive absence (degrade to call-level); `undefined` is unreadable // definitive absence (degrade to call-level); `undefined` is unreadable
// meta (fall through and infer presence from rows found). // meta (fall through and infer presence from rows found).
const pdgStamped = await pdgStampForMode(repo.lbugPath, 'flows'); const pdgStamped = await pdgStampForMode(repo.lbugPath, 'flows', () =>
this.readToolMetadata(repo),
);
if (pdgStamped === false) return { available: false, hops: [] }; if (pdgStamped === false) return { available: false, hops: [] };
// Resolve the anchor symbol (UID is precise; fall back to name/file). // Resolve the anchor symbol (UID is precise; fall back to name/file).
@ -2077,7 +2125,6 @@ export class LocalBackend {
for (const key of knownKeys) { for (const key of knownKeys) {
if (liveLbugPaths.has(key)) continue; if (liveLbugPaths.has(key)) continue;
this.initializedRepos.delete(key); this.initializedRepos.delete(key);
this.lastStalenessCheck.delete(key);
this.toolStalenessCache.delete(key); this.toolStalenessCache.delete(key);
this.lastObservedPoolState.delete(key); this.lastObservedPoolState.delete(key);
this.reinitPromises.delete(key); this.reinitPromises.delete(key);
@ -2226,7 +2273,24 @@ export class LocalBackend {
this.maybeWarnSiblingDrift(result).catch(() => { this.maybeWarnSiblingDrift(result).catch(() => {
/* best-effort; never throw from resolveRepo */ /* best-effort; never throw from resolveRepo */
}); });
return this.applyBranchScope(this.followSharedGraph(result), branch); const repo = { ...(await this.applyBranchScope(this.followSharedGraph(result), branch)) };
// Checkout-only tools also need current metadata, without opening a native DB.
const meta = await this.readToolMetadata(repo);
if (meta) {
repo.indexedAt = meta.indexedAt ?? repo.indexedAt;
repo.lastCommit = meta.lastCommit ?? repo.lastCommit;
repo.branch = meta.branch ?? repo.branch;
// Shared graph metadata is branch-neutral. The checkout slot retains
// its branch while the immutable graph path moves between commits.
if (
!meta.branch &&
path.dirname(repo.lbugPath) !== repo.storagePath &&
repo.lbugPath === resolveGraphPath(repo.storagePath)
) {
repo.branch = (await loadOptionalMeta(repo.storagePath))?.branch ?? repo.branch;
}
}
return repo;
} }
// Still no match — throw with helpful message // Still no match — throw with helpful message
@ -2539,109 +2603,106 @@ export class LocalBackend {
*/ */
private async ensureInitialized(repo: RepoHandle): Promise<void> { private async ensureInitialized(repo: RepoHandle): Promise<void> {
const poolKey = repo.lbugPath; const poolKey = repo.lbugPath;
// If a reinit is already in progress for this repo, wait for it const bindSnapshot = (): void => {
const pending = this.reinitPromises.get(poolKey); const state = this.lastObservedPoolState.get(poolKey);
if (pending) return pending; if (
repo.poolIdentity !== undefined &&
// Check if the index was rebuilt since we opened the connection (#297). dbIdentityChanged(repo.poolIdentity, state?.dbIdentity ?? null)
// Throttle staleness checks to at most once per 5 seconds per repo to ) {
// avoid an fs.readFile round-trip on every tool invocation. repo.generationUnknown = true;
if (this.initializedRepos.has(poolKey) && isLbugReady(poolKey)) { return;
const now = Date.now();
const lastCheck = this.lastStalenessCheck.get(poolKey) ?? 0;
if (now - lastCheck < 5000) return; // Checked recently — skip
this.lastStalenessCheck.set(poolKey, now);
try {
// Read the metadata that sits next to THIS handle's lbug. For the
// flat/primary handle this is `<storagePath>/gitnexus.json`; for a
// branch handle it is `<storagePath>/branches/<slug>/gitnexus.json`.
// loadMeta falls back to legacy meta.json during migration.
// Reading the flat meta for a branch handle would compare the branch
// index's indexedAt against the primary's and thrash the pool (#2106).
const meta = await loadMeta(path.dirname(repo.lbugPath));
const observedState = this.lastObservedPoolState.get(poolKey);
// Compare against the last indexedAt OBSERVED for this pool (keyed by
// lbugPath), not the handle's — branch handles are fresh spreads so a
// handle mutation would not persist and would reinit on every check.
const observed = observedState?.indexedAt ?? repo.indexedAt;
const stampChanged = !!meta?.indexedAt && meta.indexedAt !== observed;
// #2614 F1: also reinit on a file-identity change. An atomic swap (or an
// in-place incremental) changes the lbug inode; keying only on
// meta.indexedAt let a reader that reinited inside the pre-swap window
// latch on the old inode forever (its stamp already == meta.indexedAt).
const currentIdentity = await statDbIdentity(repo.lbugPath);
const identityChanged = dbIdentityChanged(
observedState?.dbIdentity ?? null,
currentIdentity,
);
// #2767: `--repair-fts` intentionally never restamps `indexedAt` (it
// doesn't regenerate the graph), so `stampChanged` alone can't notice
// a repair. `capabilities.fts.status` is the field repair-fts DOES
// write, so a change there is a third, independent reinit trigger —
// sibling to stampChanged/identityChanged, not a replacement for them
// (identityChanged still catches an in-place mutation even if the
// caps stamp were somehow missed).
const ftsStatus = meta?.capabilities?.fts?.status;
const ftsCapsChanged = observedState?.ftsStatus !== ftsStatus;
if (stampChanged || identityChanged || ftsCapsChanged) {
// Index was rebuilt/swapped — DELEGATE the close/reopen to the pool's
// initLbug, which refuses to evict (and close the shared Database)
// while a query is in flight (its checkedOut>0 guard). Calling
// closeLbug directly here bypassed that guard and could close a
// Database mid-query — a native use-after-free (#2614). Wrap in
// reinitPromises to serialize concurrent detectors.
const reinit = (async () => {
try {
const reopened = await initLbug(poolKey, repo.lbugPath);
// tri-review NEW-7: advance the observed stamp/caps watermarks
// only AFTER initLbug completes, not before calling it — still
// regardless of `reopened` true/false (a stamp/caps change with
// an unchanged file must not re-trigger on every check), but if
// initLbug THROWS the watermark must stay at its old value so
// the next staleness check retries, instead of a failed reinit
// silently latching as "already applied" and never trying again.
const patch: Partial<PoolObservedState> = { ftsStatus };
if (meta?.indexedAt) patch.indexedAt = meta.indexedAt;
// Advance the observed IDENTITY only when the pool actually rolled
// over. If a query was in flight, initLbug served the current
// handle and returned false; leaving the identity divergent
// re-triggers the reopen on a later idle check instead of latching.
if (reopened) patch.dbIdentity = await statDbIdentity(repo.lbugPath);
this.setObservedState(poolKey, patch);
} finally {
this.reinitPromises.delete(poolKey);
}
})();
this.reinitPromises.set(poolKey, reinit);
return reinit;
} else {
return; // Pool is current
}
} catch {
return; // Can't read meta — assume pool is fine
} }
if (state?.ref) {
repo.lastCommit = state.ref.lastCommit;
repo.indexedAt = state.ref.indexedAt;
// Shared commit graphs have no branch label; preserve this caller's
// checkout branch instead of caching another checkout's fallback.
repo.branch = state.ref.branch ?? repo.branch;
}
repo.poolIdentity = state?.dbIdentity;
repo.generationUnknown ||= state?.generationUnknown;
this.toolMetadata.set(repo, Promise.resolve(state?.metadata ?? null));
};
const pending = this.reinitPromises.get(poolKey);
if (pending) {
await pending;
bindSnapshot();
return;
} }
try { // Check metadata and file identity before serving a tool; only git freshness
await initLbug(poolKey, repo.lbugPath); // retains its five-second cache. A publication must not pair new data with an old ref.
this.initializedRepos.add(poolKey); const refresh = (async () => {
// #2767: ftsStatus is deliberately left unset (undefined) here rather const warm = this.initializedRepos.has(poolKey) && isLbugReady(poolKey);
// than issuing an extra loadMeta read — every tool call already routes const previous = this.lastObservedPoolState.get(poolKey);
// through ensureInitialized, so an extra per-cold-init read adds up, const meta = await this.readToolMetadata(repo);
// and the cost of skipping it is negligible: at most one redundant const diskIdentity = await statDbIdentity(poolKey);
// initLbug call on the first warm check (initLbug itself no-ops if (meta?.indexedAt && meta.indexedAt !== previous?.diskIndexedAt) {
// cheaply via a single fs.stat when the file identity is actually // Even a deferred reader needs its old ref compared to the new HEAD.
// unchanged, per pool-adapter.ts's own "unchanged → reuse" guard), not // Invalidate once per publication; concurrent checks still deduplicate.
// a real reopen. this.toolStalenessCache.delete(poolKey);
}
const stampChanged =
!!meta?.indexedAt && meta.indexedAt !== (previous?.indexedAt ?? repo.indexedAt);
const identityChanged = dbIdentityChanged(previous?.dbIdentity ?? null, diskIdentity);
const ftsStatus = meta?.capabilities?.fts?.status;
if (!warm || stampChanged || identityChanged || previous?.ftsStatus !== ftsStatus) {
await initLbug(poolKey, poolKey);
this.initializedRepos.add(poolKey);
}
const openedIdentity = getLbugDbIdentity(poolKey);
const deferred =
!!openedIdentity && (!diskIdentity || dbIdentityChanged(openedIdentity, diskIdentity));
if (deferred) {
// initLbug keeps a busy reader on the old inode. Never attach the new
// metadata to it, including when another backend opened that reader.
if (previous && !dbIdentityChanged(previous.dbIdentity, openedIdentity)) {
this.setObservedState(poolKey, { diskIndexedAt: meta?.indexedAt });
return;
}
this.setObservedState(poolKey, {
dbIdentity: openedIdentity,
ref: { lastCommit: '', indexedAt: '' },
generationUnknown: true,
awaitingMetadata: false,
diskIndexedAt: meta?.indexedAt,
metadata: null,
});
return;
}
const ref: IndexedRef = {
lastCommit: meta?.lastCommit ?? previous?.ref?.lastCommit ?? repo.lastCommit,
indexedAt: meta?.indexedAt ?? previous?.ref?.indexedAt ?? repo.indexedAt,
branch: meta?.branch,
};
// The graph swap precedes its final metadata stamp. An inode replacement
// needs that stamp; same-inode auxiliary changes (e.g. FTS repair) do not.
// Actual in-place graph writes advertise incrementalInProgress below.
const graphReplaced =
!!previous?.dbIdentity && !!diskIdentity && previous.dbIdentity.ino !== diskIdentity.ino;
const publicationPending =
!!previous &&
!stampChanged &&
(previous.awaitingMetadata === true ||
(graphReplaced && previous.ref !== undefined && !previous.generationUnknown));
const generationUnknown = deferred || publicationPending || !!meta?.incrementalInProgress;
this.setObservedState(poolKey, { this.setObservedState(poolKey, {
indexedAt: repo.indexedAt, indexedAt: meta?.indexedAt ?? previous?.indexedAt ?? repo.indexedAt,
dbIdentity: await statDbIdentity(repo.lbugPath), dbIdentity: openedIdentity,
ftsStatus,
ref: generationUnknown ? { lastCommit: '', indexedAt: '' } : ref,
generationUnknown,
awaitingMetadata: publicationPending,
diskIndexedAt: meta?.indexedAt,
metadata: generationUnknown ? null : meta,
}); });
} catch (err: any) { })();
// If lock error, mark as not initialized so next call retries this.reinitPromises.set(poolKey, refresh);
this.initializedRepos.delete(poolKey); try {
throw err; await refresh;
bindSnapshot();
} finally {
this.reinitPromises.delete(poolKey);
} }
} }
@ -2901,6 +2962,21 @@ export class LocalBackend {
// poison the TTL cache entry (#2655 review F1). A rejection leaves `info` // poison the TTL cache entry (#2655 review F1). A rejection leaves `info`
// undefined, and the builder returns nothing for that even with a ref, so // undefined, and the builder returns nothing for that even with a ref, so
// the degraded path still attaches no field. // the degraded path still attaches no field.
// Multiple reads may straddle an idle pool rollover. Do not certify mixed
// generations as current. A busy reader that stayed on its inode is known.
const changedDuringTool =
repo.poolIdentity !== undefined &&
dbIdentityChanged(repo.poolIdentity, getLbugDbIdentity(repo.lbugPath));
if (repo.generationUnknown || changedDuringTool) {
return attachToolStaleness(
result,
{ status: 'unknown', isStale: false, commitsBehind: 0 },
{
lastCommit: '',
indexedAt: '',
},
);
}
const staleness = await this.stalenessForTool(repo).catch(() => undefined); const staleness = await this.stalenessForTool(repo).catch(() => undefined);
return attachToolStaleness(result, staleness, { return attachToolStaleness(result, staleness, {
branch: repo.branch, branch: repo.branch,
@ -2922,8 +2998,15 @@ export class LocalBackend {
*/ */
private stalenessForTool(repo: RepoHandle): Promise<StalenessInfo> { private stalenessForTool(repo: RepoHandle): Promise<StalenessInfo> {
const now = Date.now(); const now = Date.now();
const generation = JSON.stringify([
repo.repoPath,
repo.lastCommit,
repo.indexedAt,
repo.branch,
repo.poolIdentity,
]);
const cached = this.toolStalenessCache.get(repo.lbugPath); const cached = this.toolStalenessCache.get(repo.lbugPath);
if (cached && now - cached.at < LocalBackend.TOOL_STALENESS_TTL_MS) { if (cached?.generation === generation && now - cached.at < LocalBackend.TOOL_STALENESS_TTL_MS) {
return cached.value; return cached.value;
} }
// Evict the entry if the check rejects so a transient failure isn't served // Evict the entry if the check rejects so a transient failure isn't served
@ -2932,8 +3015,9 @@ export class LocalBackend {
// dedup is untouched (#2655 review F1). `Promise.resolve` wraps the call so a // dedup is untouched (#2655 review F1). `Promise.resolve` wraps the call so a
// non-thenable return can't throw at this boundary — a no-op for the real // non-thenable return can't throw at this boundary — a no-op for the real
// async `checkStalenessAsync`, robust defense-in-depth otherwise. // async `checkStalenessAsync`, robust defense-in-depth otherwise.
const entry: { at: number; value: Promise<StalenessInfo> } = { const entry: { at: number; generation: string; value: Promise<StalenessInfo> } = {
at: now, at: now,
generation,
// Only evict if THIS entry is still current — a later call may have // Only evict if THIS entry is still current — a later call may have
// installed a fresh (resolving) entry for the same key before a slow // installed a fresh (resolving) entry for the same key before a slow
// rejection lands, and that newer entry must not be dropped. // rejection lands, and that newer entry must not be dropped.
@ -3177,6 +3261,31 @@ export class LocalBackend {
include_content?: boolean; include_content?: boolean;
chain_depth?: number; chain_depth?: number;
}, },
): Promise<any> {
try {
const result = await this._queryImpl(repo, params);
if (!result.error) assertResponseIdentities(result);
return result;
} catch (err) {
if (err instanceof SymbolIdentityError) {
return { error: err.message, recoverySuggestion: SYMBOL_IDENTITY_RECOVERY_SUGGESTION };
}
throw err;
}
}
private async _queryImpl(
repo: RepoHandle,
params: {
query?: string;
search_query?: string;
task_context?: string;
goal?: string;
limit?: number;
max_symbols?: number;
include_content?: boolean;
chain_depth?: number;
},
): Promise<any> { ): Promise<any> {
// #2175: each consumer resolves the search_query/query alias itself (there is no // #2175: each consumer resolves the search_query/query alias itself (there is no
// chokepoint mutation in callTool). This also serves the GroupService port, which // chokepoint mutation in callTool). This also serves the GroupService port, which
@ -3214,7 +3323,7 @@ export class LocalBackend {
// disclosing text merely because a caller asked for it. `query` already // disclosing text merely because a caller asked for it. `query` already
// reads this metadata for CJK and embedding-dimension drift diagnostics, // reads this metadata for CJK and embedding-dimension drift diagnostics,
// so keep that legacy read unconditional. // so keep that legacy read unconditional.
const meta = await loadMeta(path.dirname(repo.lbugPath)); const meta = await this.readToolMetadata(repo);
const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none'; const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none';
const contentAvailability = requestedContentAvailability(requestedContent, meta); const contentAvailability = requestedContentAvailability(requestedContent, meta);
const searchQuery = rawQuery.trim(); const searchQuery = rawQuery.trim();
@ -3266,6 +3375,10 @@ export class LocalBackend {
for (let i = 0; i < bm25Results.length; i++) { for (let i = 0; i < bm25Results.length; i++) {
const result = bm25Results[i]; const result = bm25Results[i];
if (result.nodeId !== undefined && result.nodeId !== null) {
assertSymbolIdentity(result.nodeId);
}
assertIdentityFields(result.name, result.type, result.filePath);
const key = result.nodeId || result.filePath; const key = result.nodeId || result.filePath;
const rrfScore = 1 / (60 + i); const rrfScore = 1 / (60 + i);
const existing = scoreMap.get(key); const existing = scoreMap.get(key);
@ -3279,6 +3392,8 @@ export class LocalBackend {
const safeSemanticResults = semanticResults ?? []; const safeSemanticResults = semanticResults ?? [];
for (let i = 0; i < safeSemanticResults.length; i++) { for (let i = 0; i < safeSemanticResults.length; i++) {
const result = safeSemanticResults[i]; const result = safeSemanticResults[i];
assertSymbolIdentity(result.nodeId);
assertIdentityFields(result.name, result.type, result.filePath);
const key = result.nodeId || result.filePath; const key = result.nodeId || result.filePath;
const rrfScore = 1 / (60 + i); const rrfScore = 1 / (60 + i);
const existing = scoreMap.get(key); const existing = scoreMap.get(key);
@ -3361,12 +3476,20 @@ export class LocalBackend {
{ nodeIds: ids }, { nodeIds: ids },
); );
for (const row of rows) { for (const row of rows) {
assertQueryIdentity(row, 'nodeId', 0, [
['label', 2],
['heuristicLabel', 3],
['processType', 4],
['entryPointId', 7],
]);
assertSymbolIdentity(queryRowValue(row, 'pid', 1));
const nid = row.nodeId ?? row[0]; const nid = row.nodeId ?? row[0];
let list = processRowsByNode.get(nid); let list = processRowsByNode.get(nid);
if (!list) processRowsByNode.set(nid, (list = [])); if (!list) processRowsByNode.set(nid, (list = []));
list.push(row); list.push(row);
} }
} catch (e) { } catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('query:process-lookup', e); logQueryError('query:process-lookup', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true; if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
} }
@ -3391,6 +3514,7 @@ export class LocalBackend {
{ nodeIds: ids }, { nodeIds: ids },
); );
for (const row of rows) { for (const row of rows) {
assertQueryIdentity(row, 'nodeId', 0, [['module', 2]]);
const nid = row.nodeId ?? row[0]; const nid = row.nodeId ?? row[0];
if (!cohesionByNode.has(nid)) { if (!cohesionByNode.has(nid)) {
cohesionByNode.set(nid, { cohesionByNode.set(nid, {
@ -3400,6 +3524,7 @@ export class LocalBackend {
} }
} }
} catch (e) { } catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('query:cluster-info', e); logQueryError('query:cluster-info', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true; if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
} }
@ -3417,10 +3542,12 @@ export class LocalBackend {
{ nodeIds: ids }, { nodeIds: ids },
); );
for (const row of rows) { for (const row of rows) {
assertQueryIdentity(row, 'nodeId', 0);
const nid = row.nodeId ?? row[0]; const nid = row.nodeId ?? row[0];
contentByNode.set(nid, row.content ?? row[1]); contentByNode.set(nid, row.content ?? row[1]);
} }
} catch (e) { } catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('query:content-fetch', e); logQueryError('query:content-fetch', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true; if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
} }
@ -3534,6 +3661,13 @@ export class LocalBackend {
{ routeIds: ids }, { routeIds: ids },
); );
for (const row of rows) { for (const row of rows) {
assertQueryIdentity(row, 'routeId', 0, [
['handlerName', 2],
['handlerFilePath', 3],
['url', 4],
['method', 5],
]);
assertSymbolIdentity(queryRowValue(row, 'handlerId', 1));
const routeId = String(row.routeId ?? row[0] ?? ''); const routeId = String(row.routeId ?? row[0] ?? '');
const handlerId = String(row.handlerId ?? row[1] ?? ''); const handlerId = String(row.handlerId ?? row[1] ?? '');
if (!routeId || !handlerId) continue; if (!routeId || !handlerId) continue;
@ -3549,6 +3683,7 @@ export class LocalBackend {
} }
} }
} catch (e) { } catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('query:route-definition-bridge', e); logQueryError('query:route-definition-bridge', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true; if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
} }
@ -3644,6 +3779,10 @@ export class LocalBackend {
}, },
); );
for (const row of routeRows) { for (const row of routeRows) {
assertQueryIdentity(row, 'pid', 0, [
['url', 1],
['method', 2],
]);
const targetPid = row.pid ?? row[0]; const targetPid = row.pid ?? row[0];
const url = row.url ?? row[1]; const url = row.url ?? row[1];
const method = row.method ?? row[2]; const method = row.method ?? row[2];
@ -3661,6 +3800,7 @@ export class LocalBackend {
} }
} }
} catch (e) { } catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('query:route-lookup', e); logQueryError('query:route-lookup', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true; if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
} }
@ -3677,11 +3817,12 @@ export class LocalBackend {
// QUERY_CHAIN_BFS_CONCURRENCY walks in flight. This is the per-query BFS // QUERY_CHAIN_BFS_CONCURRENCY walks in flight. This is the per-query BFS
// cap; GroupService.groupQuery also caps member-query fan-out so a group // cap; GroupService.groupQuery also caps member-query fan-out so a group
// of N members cannot run 4×N concurrent BFS walks. // of N members cannot run 4×N concurrent BFS walks.
// Best-effort — a BFS failure drops that process's chain but never fails // Ordinary BFS failures may drop a chain; corrupt identities must fail
// the query. // the query even though mapConcurrent normally swallows worker errors.
const chainByProcessId = new Map<string, any[]>(); const chainByProcessId = new Map<string, any[]>();
if (requestedChainDepth > 0 && rankedProcesses.length > 0) { if (requestedChainDepth > 0 && rankedProcesses.length > 0) {
timer.start('chain_enrichment'); timer.start('chain_enrichment');
let integrityError: SymbolIdentityError | undefined;
await mapConcurrent( await mapConcurrent(
rankedProcesses, rankedProcesses,
async (p) => { async (p) => {
@ -3692,14 +3833,22 @@ export class LocalBackend {
p.entryPointId, p.entryPointId,
requestedChainDepth, requestedChainDepth,
); );
assertResponseIdentities(chain);
chainByProcessId.set(p.id, chain); chainByProcessId.set(p.id, chain);
} catch (e) { } catch (e) {
rethrowSymbolIdentityError(e);
logQueryError('query:chain-bfs', e); logQueryError('query:chain-bfs', e);
if (!isBenignMissingTableError(e)) enrichmentDegraded = true; if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
} }
}, },
{ concurrency: QUERY_CHAIN_BFS_CONCURRENCY }, {
concurrency: QUERY_CHAIN_BFS_CONCURRENCY,
onError: (error) => {
if (error instanceof SymbolIdentityError) integrityError = error;
},
},
); );
if (integrityError) throw integrityError;
timer.stop(); // chain_enrichment timer.stop(); // chain_enrichment
} }
@ -3790,6 +3939,7 @@ export class LocalBackend {
); );
} }
} catch (err) { } catch (err) {
rethrowSymbolIdentityError(err);
// Best-effort diagnostic only — never fail the query over it. // Best-effort diagnostic only — never fail the query over it.
logQueryError('query:cjk-warning', err); logQueryError('query:cjk-warning', err);
} }
@ -3832,6 +3982,7 @@ export class LocalBackend {
); );
} }
} catch (err) { } catch (err) {
rethrowSymbolIdentityError(err);
// loadMeta() itself never throws (it returns null on any read/parse // loadMeta() itself never throws (it returns null on any read/parse
// failure) — the actual throw source here is getSearchFTSCjkSegmentation() // failure) — the actual throw source here is getSearchFTSCjkSegmentation()
// on an invalid env value, same root cause as the catch above. This is // on an invalid env value, same root cause as the catch above. This is
@ -3927,6 +4078,7 @@ export class LocalBackend {
try { try {
({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js')); ({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js'));
} catch (err: any) { } catch (err: any) {
rethrowSymbolIdentityError(err);
// Module import can fail in sandboxed MCP contexts (#1489) // Module import can fail in sandboxed MCP contexts (#1489)
logger.warn( logger.warn(
{ err: err?.message }, { err: err?.message },
@ -3938,6 +4090,7 @@ export class LocalBackend {
try { try {
ftsResponse = await searchFTSFromLbug(query, limit, repo.lbugPath); ftsResponse = await searchFTSFromLbug(query, limit, repo.lbugPath);
} catch (err: any) { } catch (err: any) {
rethrowSymbolIdentityError(err);
// Swallowed, gracefully-degraded failure: the search falls back to // Swallowed, gracefully-degraded failure: the search falls back to
// semantic-only (a valid result), and the most common cause is simply an // semantic-only (a valid result), and the most common cause is simply an
// un-indexed FTS extension — a normal configuration, not an operation // un-indexed FTS extension — a normal configuration, not an operation
@ -3961,6 +4114,8 @@ export class LocalBackend {
for (const bm25Result of bm25Results) { for (const bm25Result of bm25Results) {
const fullPath = bm25Result.filePath; const fullPath = bm25Result.filePath;
assertIdentityFields(fullPath);
for (const nodeId of bm25Result.nodeIds ?? []) assertSymbolIdentity(nodeId);
try { try {
// Prefer direct nodeId lookup (exact FTS-matched nodes) over filePath fallback. // Prefer direct nodeId lookup (exact FTS-matched nodes) over filePath fallback.
// Without this, LIMIT 3 on filePath returns arbitrary symbols rather than // Without this, LIMIT 3 on filePath returns arbitrary symbols rather than
@ -3996,6 +4151,7 @@ export class LocalBackend {
if (symbols.length > 0) { if (symbols.length > 0) {
for (const sym of symbols) { for (const sym of symbols) {
assertSymbolRowIdentity(sym);
results.push({ results.push({
nodeId: sym.id || sym[0], nodeId: sym.id || sym[0],
name: sym.name || sym[1], name: sym.name || sym[1],
@ -4018,7 +4174,8 @@ export class LocalBackend {
bm25Score: bm25Result.score, bm25Score: bm25Result.score,
}); });
} }
} catch { } catch (err) {
rethrowSymbolIdentityError(err);
const fileName = fullPath.split('/').pop() || fullPath; const fileName = fullPath.split('/').pop() || fullPath;
results.push({ results.push({
name: fileName, name: fileName,
@ -4090,6 +4247,7 @@ export class LocalBackend {
try { try {
vectorReady = await ensureVectorExtension(repo.lbugPath); vectorReady = await ensureVectorExtension(repo.lbugPath);
} catch (err) { } catch (err) {
rethrowSymbolIdentityError(err);
if (!this.warnedVectorLoadFailed) { if (!this.warnedVectorLoadFailed) {
this.warnedVectorLoadFailed = true; this.warnedVectorLoadFailed = true;
logger.warn( logger.warn(
@ -4122,6 +4280,7 @@ export class LocalBackend {
`; `;
const embResults = await executeQuery(repo.lbugPath, vectorQuery); const embResults = await executeQuery(repo.lbugPath, vectorQuery);
for (const row of embResults) assertQueryIdentity(row, 'nodeId', 0);
return embResults.map((row) => ({ return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0], nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0, chunkIndex: row.chunkIndex ?? row[1] ?? 0,
@ -4131,6 +4290,7 @@ export class LocalBackend {
})); }));
}); });
} catch (err) { } catch (err) {
rethrowSymbolIdentityError(err);
bestChunks = new Map(); bestChunks = new Map();
if (!this.warnedVectorQueryFailed) { if (!this.warnedVectorQueryFailed) {
// Rare diagnostic: surface why semantic search fell back to the // Rare diagnostic: surface why semantic search fell back to the
@ -4158,6 +4318,7 @@ export class LocalBackend {
e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding
`, `,
); );
for (const row of rows) assertQueryIdentity(row, 'nodeId', 0);
const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({ const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({
nodeId: row.nodeId ?? row[0], nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0, chunkIndex: row.chunkIndex ?? row[1] ?? 0,
@ -4186,6 +4347,7 @@ export class LocalBackend {
// scope here, and a shadowing local silently turns any later `chunk.x` // scope here, and a shadowing local silently turns any later `chunk.x`
// into a property read on the function. // into a property read on the function.
for (const [nodeId, bestChunk] of Array.from(bestChunks.entries()).slice(0, limit)) { for (const [nodeId, bestChunk] of Array.from(bestChunks.entries()).slice(0, limit)) {
assertSymbolIdentity(nodeId);
const labelEndIdx = nodeId.indexOf(':'); const labelEndIdx = nodeId.indexOf(':');
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown'; const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
@ -4201,6 +4363,10 @@ export class LocalBackend {
const nodeRows = await executeParameterized(repo.lbugPath, nodeQuery, { nodeId }); const nodeRows = await executeParameterized(repo.lbugPath, nodeQuery, { nodeId });
if (nodeRows.length > 0) { if (nodeRows.length > 0) {
const nodeRow = nodeRows[0]; const nodeRow = nodeRows[0];
assertIdentityFields(
queryRowValue(nodeRow, 'name', 0),
queryRowValue(nodeRow, 'filePath', 1),
);
results.push({ results.push({
nodeId, nodeId,
name: nodeRow.name ?? nodeRow[0] ?? '', name: nodeRow.name ?? nodeRow[0] ?? '',
@ -4211,11 +4377,14 @@ export class LocalBackend {
endLine: bestChunk.endLine, endLine: bestChunk.endLine,
}); });
} }
} catch {} } catch (err) {
rethrowSymbolIdentityError(err);
}
} }
return results; return results;
} catch (err) { } catch (err) {
rethrowSymbolIdentityError(err);
// Nothing was embedded on this path unless the throw happened after the // Nothing was embedded on this path unless the throw happened after the
// vector existed (a failed lookup downstream of a good embedding, where // vector existed (a failed lookup downstream of a good embedding, where
// the width IS still the live one). Clearing only in the former case // the width IS still the live one). Clearing only in the former case
@ -4973,7 +5142,7 @@ export class LocalBackend {
const requestedContent = include_content ?? false; const requestedContent = include_content ?? false;
// Content retention matters only to the opt-in content response. Avoid a // Content retention matters only to the opt-in content response. Avoid a
// metadata dependency for the long-standing default context operation. // metadata dependency for the long-standing default context operation.
const meta = requestedContent ? await loadMeta(path.dirname(repo.lbugPath)) : null; const meta = requestedContent ? await this.readToolMetadata(repo) : null;
const contentAvailability = requestedContentAvailability(requestedContent, meta); const contentAvailability = requestedContentAvailability(requestedContent, meta);
const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none'; const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none';
@ -5806,7 +5975,7 @@ export class LocalBackend {
// a seeded test DB) falls through to the row-existence probe below. // a seeded test DB) falls through to the row-existence probe below.
let pdgStamped: boolean | undefined; let pdgStamped: boolean | undefined;
try { try {
const meta = await loadMeta(path.dirname(repo.lbugPath)); const meta = await this.readToolMetadata(repo);
if (meta) pdgStamped = meta.pdg?.taintModelVersion !== undefined; if (meta) pdgStamped = meta.pdg?.taintModelVersion !== undefined;
} catch { } catch {
/* meta unreadable — decide from the DB below */ /* meta unreadable — decide from the DB below */
@ -6124,7 +6293,9 @@ export class LocalBackend {
// contract byte-for-byte: `false` ⇒ definitive no-layer (short-circuit // contract byte-for-byte: `false` ⇒ definitive no-layer (short-circuit
// below), `true` ⇒ proceed, `undefined` ⇒ meta unreadable, defer to the // below), `true` ⇒ proceed, `undefined` ⇒ meta unreadable, defer to the
// post-anchored-query probe (Feasibility Issue 4). // post-anchored-query probe (Feasibility Issue 4).
const pdgStamped = await pdgStampForMode(repo.lbugPath, mode); const pdgStamped = await pdgStampForMode(repo.lbugPath, mode, () =>
this.readToolMetadata(repo),
);
if (pdgStamped === false) { if (pdgStamped === false) {
return { mode, results: [], total: 0, note: NO_PDG_NOTE }; return { mode, results: [], total: 0, note: NO_PDG_NOTE };
} }
@ -7574,6 +7745,7 @@ export class LocalBackend {
if (mode === 'pdg') { if (mode === 'pdg') {
layer = await pdgLayerStatus({ layer = await pdgLayerStatus({
lbugPath: repo.lbugPath, lbugPath: repo.lbugPath,
loadMetaFn: () => this.readToolMetadata(repo),
executeParameterized, executeParameterized,
}); });
if (isPdgDegradedLayerStatus(layer)) { if (isPdgDegradedLayerStatus(layer)) {
@ -7815,10 +7987,8 @@ export class LocalBackend {
* Fields the analyzer declined to link because every definition of the name * Fields the analyzer declined to link because every definition of the name
* lives in another language (R3-1), keyed by name. * lives in another language (R3-1), keyed by name.
* *
* Cached per index version. `ensureInitialized` deliberately avoids a * Cached per index version. The fresh stamp bound by `ensureInitialized`
* per-call `loadMeta` because every tool call routes through it; this is one * invalidates this derived map exactly when a re-analyze could have changed it.
* small read per (index, indexedAt), which re-reads exactly when a re-analyze
* could have changed the answer and never otherwise.
*/ */
private readonly crossLanguagePropertyCache = new Map< private readonly crossLanguagePropertyCache = new Map<
string, string,
@ -7832,7 +8002,7 @@ export class LocalBackend {
if (cached !== undefined && cached.indexedAt === repo.indexedAt) return cached.byName; if (cached !== undefined && cached.indexedAt === repo.indexedAt) return cached.byName;
const byName = new Map<string, readonly string[]>(); const byName = new Map<string, readonly string[]>();
try { try {
const meta = await loadMeta(path.dirname(repo.lbugPath)); const meta = await this.readToolMetadata(repo);
for (const entry of meta?.crossLanguageProperties ?? []) { for (const entry of meta?.crossLanguageProperties ?? []) {
byName.set(entry.name, entry.languages); byName.set(entry.name, entry.languages);
} }
@ -7863,16 +8033,14 @@ export class LocalBackend {
// reason #2708 was filed. A dropped site's callee is unknown, so the index // reason #2708 was filed. A dropped site's callee is unknown, so the index
// records the member NAME invoked at the drop; a match on the queried // records the member NAME invoked at the drop; a match on the queried
// symbol's name means at least one call to something of that name was lost. // symbol's name means at least one call to something of that name was lost.
// ONE read of the index metadata for both probes below. They are the second // Share one metadata read between both probes; fileHashes can make this
// and third consumers of this file on a path whose own comments call out // file large, so each probe should not read it separately.
// avoiding a per-call `loadMeta` (see `ensureInitialized`), and the file is
// dominated by `fileHashes` — megabytes on a large repo.
// `try`, not `.catch`: `loadMeta` can throw synchronously (a stubbed module // `try`, not `.catch`: `loadMeta` can throw synchronously (a stubbed module
// in tests, a mid-read unmount), and a probe failing must never read as // in tests, a mid-read unmount), and a probe failing must never read as
// certainty — the whole point of this function. // certainty — the whole point of this function.
let meta: Awaited<ReturnType<typeof loadMeta>> | undefined; let meta: Awaited<ReturnType<typeof loadMeta>> | undefined;
try { try {
meta = await loadMeta(path.dirname(repo.lbugPath)); meta = await this.readToolMetadata(repo);
} catch { } catch {
meta = undefined; meta = undefined;
} }
@ -9632,7 +9800,7 @@ export class LocalBackend {
code: 'source-unavailable'; code: 'source-unavailable';
reason: 'content-retention' | 'checkout-missing'; reason: 'content-retention' | 'checkout-missing';
} | null> { } | null> {
const meta = await loadMeta(path.dirname(repo.lbugPath)); const meta = await this.readToolMetadata(repo);
const retention = contentRetentionFromMeta(meta); const retention = contentRetentionFromMeta(meta);
const checkoutIsDir = retention === 'full' ? await checkoutIsDirectory(repo.repoPath) : false; const checkoutIsDir = retention === 'full' ? await checkoutIsDirectory(repo.repoPath) : false;
if (isFullSourceAvailable(retention, checkoutIsDir)) return null; if (isFullSourceAvailable(retention, checkoutIsDir)) return null;

View file

@ -1,57 +1,2 @@
/** Shared integrity boundary for identities returned by impact/context queries. */ /** Compatibility export; search and MCP must share the same error class. */
export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = export * from '../../core/lbug/query-result-integrity.js';
'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

@ -24,13 +24,14 @@ import {
saveMeta, saveMeta,
type RepoMeta, type RepoMeta,
} from '../../src/storage/repo-manager.js'; } from '../../src/storage/repo-manager.js';
import { closeLbug as poolClose } from '../../src/core/lbug/pool-adapter.js'; import { closeLbug as poolClose, statDbIdentity } from '../../src/core/lbug/pool-adapter.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js'; import { LocalBackend } from '../../src/mcp/local/local-backend.js';
const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1'; const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1';
type QueryResult = { type QueryResult = {
error?: unknown; error?: unknown;
staleness?: { lastCommit?: string; indexedAt?: string };
warning?: string; warning?: string;
partial?: boolean; partial?: boolean;
definitions?: Array<{ id: string }>; definitions?: Array<{ id: string }>;
@ -46,17 +47,13 @@ const ftsMissing = (r: QueryResult): boolean =>
/** /**
* Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or * Poll the SAME warm `LocalBackend` until it stops reporting FTS-missing, or
* the deadline passes. Exercises the real 5s staleness-check throttle * the deadline passes. Calls the public tool without bypassing freshness
* (`ensureInitialized`) rather than sleeping-and-hoping or reaching into * checks or mutating backend state.
* backend internals to bypass it — proves the fix holds within the actual
* production timing window.
*/ */
async function waitForFtsRecognized( async function waitForFtsRecognized(
backend: LocalBackend, backend: LocalBackend,
query: string, query: string,
// Production throttle is 5s (`lastStalenessCheck`); this deadline leaves a // Leave headroom for native extension work on loaded CI runners.
// generous margin beyond it for a loaded CI runner, per review feedback
// that the original 7s deadline left only ~2s of slack (#2767).
timeoutMs = 15000, timeoutMs = 15000,
intervalMs = 300, intervalMs = 300,
): Promise<QueryResult> { ): Promise<QueryResult> {
@ -160,6 +157,7 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
// Same production functions the repair-fts branch of runFullAnalysis // Same production functions the repair-fts branch of runFullAnalysis
// calls — real FTS build, then the #2767 capability-only meta stamp // calls — real FTS build, then the #2767 capability-only meta stamp
// (indexedAt/lastCommit deliberately unchanged, R4). // (indexedAt/lastCommit deliberately unchanged, R4).
const beforeIdentity = await statDbIdentity(lbugPath);
await adapter.initLbug(lbugPath); await adapter.initLbug(lbugPath);
await createSearchFTSIndexes(); await createSearchFTSIndexes();
await adapter.flushWAL(); await adapter.flushWAL();
@ -178,6 +176,14 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
expect(ftsMissing(after)).toBe(false); expect(ftsMissing(after)).toBe(false);
expect(after.partial).toBeUndefined(); expect(after.partial).toBeUndefined();
expect(matchedIds(after)).toContain('func:login'); expect(matchedIds(after)).toContain('func:login');
const afterIdentity = await statDbIdentity(lbugPath);
expect(afterIdentity?.ino).toBe(beforeIdentity?.ino);
expect(afterIdentity).not.toEqual(beforeIdentity);
// The Function-only repair starts and ends with FTS status available.
// Its in-place auxiliary changes must not erase the graph's known ref.
expect(after.staleness).toMatchObject({ lastCommit: baseMeta.lastCommit, indexedAt });
const again = await backend.callTool('query', { query: 'login' });
expect(again.staleness).toMatchObject({ lastCommit: baseMeta.lastCommit, indexedAt });
}, },
); );
}); });

View file

@ -0,0 +1,195 @@
/**
* Public-workflow regression for #3564/#3527. The reporters' private fixtures
* and macOS environments are unavailable; passing this fixture does not
* establish the cause of either report.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { getStoragePaths } from '../../src/storage/repo-manager.js';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
import { createTempDir } from '../helpers/test-db.js';
type SymbolTuple = {
uid: string;
name: string;
filePath: string;
startLine: number;
endLine: number;
content: string;
};
const FILE_COUNT = 70;
const METHODS_PER_FILE = 64;
const CHANGED_FILE = 'checks/test_batch_0.py';
const SWIFT_PATH = 'Sources/Sentinel.swift';
const SWIFT_SOURCE = 'struct UnrelatedSwiftSentinel {\n let marker: Int = 701\n}\n';
const METHOD_SCAN = `MATCH (n:Method)
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath,
n.startLine AS startLine, n.endLine AS endLine, n.content AS content
ORDER BY uid`;
// The expected rows come from the source fixture, never from graph output or a
// previous database scan. Native spans are zero-based; context displays +1.
function pythonFixture(batch: number, changed = false): { source: string; tuples: SymbolTuple[] } {
const filePath = `checks/test_batch_${batch}.py`;
const className = `Batch${batch}`;
const lines = ['import unittest', '', `class ${className}(unittest.TestCase):`];
const tuples: SymbolTuple[] = [];
for (let method = 0; method < METHODS_PER_FILE; method++) {
const name = `test_sample_${batch}_${method}`;
const body = [` def ${name}(self):`, ` return ${batch * METHODS_PER_FILE + method}`];
if (changed && method === 0) {
body.splice(1, 0, ' updated_value = 9001');
body[2] = ' return updated_value';
}
const startLine = lines.length;
tuples.push({
uid: `Method:${filePath}:${className}.${name}#0`,
name,
filePath,
startLine,
endLine: startLine + body.length - 1,
// Stored symbol content preserves indentation and flattens newlines for FTS.
content: body.join(' '),
});
lines.push(...body, '');
}
return { source: lines.join('\n'), tuples };
}
function expectContext(result: any, expected: SymbolTuple): void {
expect(result).not.toHaveProperty('error');
expect(result.status).toBe('found');
expect(result.symbol).toMatchObject({
...expected,
startLine: expected.startLine + 1,
endLine: expected.endLine + 1,
});
}
describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))(
'Python Method identities across selective incremental writes (#3564, #3527)',
() => {
it('retains every source tuple across a class-method edit and fresh native/MCP readers', async () => {
const temp = await createTempDir();
let backend: LocalBackend | undefined;
try {
const root = await fs.realpath(temp.dbPath);
const repo = path.join(root, 'method-integrity');
await fs.mkdir(path.join(repo, 'checks'), { recursive: true });
await fs.mkdir(path.join(repo, 'Sources'));
await fs.mkdir(path.join(root, 'user-home'));
vi.stubEnv('HOME', path.join(root, 'user-home'));
vi.stubEnv('USERPROFILE', path.join(root, 'user-home'));
vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home'));
vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined);
vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined);
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
vi.stubEnv('GITNEXUS_CONTENT_RETENTION', 'full');
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only');
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1');
await fs.writeFile(path.join(repo, '.gitignore'), '.gitnexus/\n');
await fs.writeFile(path.join(repo, SWIFT_PATH), SWIFT_SOURCE);
const fixtures = Array.from({ length: FILE_COUNT }, (_, batch) => pythonFixture(batch));
for (const fixture of fixtures) {
await fs.writeFile(path.join(repo, fixture.tuples[0].filePath), fixture.source);
}
initGitRepo(repo);
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: repo });
commitAll(repo, 'baseline Python methods and unrelated Swift struct');
// This test isolates selective native writes from extension availability.
// FTS corruption and query guards have separate real-native coverage.
const options = { skipAgentsMd: true, skipSkills: true, skipFts: true };
await runFullAnalysis(repo, options, { onProgress: () => {} });
const { lbugPath } = getStoragePaths(repo);
backend = new LocalBackend();
expect(await backend.init()).toBe(true);
const readAll = async (expected: SymbolTuple[]): Promise<void> => {
const ordered = [...expected].sort((a, b) =>
a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0,
);
// The entire Method table exceeds one 2048-row native vector. Reopen
// the database for each scan rather than reusing the writer's view.
await adapter.initLbug(lbugPath, { skipFts: true });
try {
expect(await adapter.executeQuery(METHOD_SCAN)).toEqual(ordered);
expect(
await adapter.executeQuery(
`MATCH (n:Struct) RETURN n.id AS uid, n.name AS name, n.filePath AS filePath,
n.startLine AS startLine, n.endLine AS endLine, n.content AS content`,
),
).toEqual([
{
uid: `Struct:${SWIFT_PATH}:UnrelatedSwiftSentinel`,
name: 'UnrelatedSwiftSentinel',
filePath: SWIFT_PATH,
startLine: 0,
endLine: 2,
content: SWIFT_SOURCE.trimEnd().replace(/\n/g, ' '),
},
]);
} finally {
await adapter.closeLbug();
}
// Keep the same backend across both analyses, but release its native
// reader before the in-place writer opens the DB. No mocked rows.
const result = await backend!.callTool('cypher', { repo, statement: METHOD_SCAN });
expect(result).not.toHaveProperty('error');
expect(result.row_count).toBe(FILE_COUNT * METHODS_PER_FILE);
expect(result.markdown).toBe(
[
'| uid | name | filePath | startLine | endLine | content |',
'| --- | --- | --- | --- | --- | --- |',
...ordered.map(
(tuple) =>
`| ${Object.values(tuple)
.map((value) => String(value).replace(/\n/g, ' '))
.join(' | ')} |`,
),
].join('\n'),
);
for (const index of [0, 63, 2048, expected.length - 1]) {
expectContext(
await backend!.callTool('context', {
repo,
uid: expected[index].uid,
include_content: true,
}),
expected[index],
);
}
await backend!.dispose();
};
const before = fixtures.flatMap(({ tuples }) => tuples);
expect(before).toHaveLength(4480);
await readAll(before);
const edited = pythonFixture(0, true);
await fs.writeFile(path.join(repo, CHANGED_FILE), edited.source);
commitAll(repo, 'edit one class method and shift later method spans');
const refreshed = await runFullAnalysis(repo, options, { onProgress: () => {} });
expect(refreshed.incrementalStats).toMatchObject({
changedFiles: 1,
deletedFiles: 0,
writeMode: 'incremental',
});
await readAll([...edited.tuples, ...fixtures.slice(1).flatMap(({ tuples }) => tuples)]);
} finally {
await backend?.dispose();
await adapter.closeLbug();
vi.unstubAllEnvs();
await temp.cleanup();
}
}, 180_000);
},
);

View file

@ -16,8 +16,8 @@ interface Check {
indexes?: number; indexes?: number;
} }
describe('native incremental search (#3421, #3423)', () => { describe('native incremental search (#3421, #3423, #3564)', () => {
for (const scenario of ['context', 'property'] as const) { for (const scenario of ['context', 'property', 'method'] as const) {
it(`preserves retained ${scenario} strings and search through COPY, checkpoint and reopen`, () => { it(`preserves retained ${scenario} strings and search through COPY, checkpoint and reopen`, () => {
// Own process: isolate native lifetimes and allow old-engine diagnostic runs. // Own process: isolate native lifetimes and allow old-engine diagnostic runs.
const result = spawnSync(process.execPath, [reproducer, scenario], { const result = spawnSync(process.execPath, [reproducer, scenario], {
@ -36,10 +36,12 @@ describe('native incremental search (#3421, #3423)', () => {
if (scenario === 'property') stages.push('repair'); if (scenario === 'property') stages.push('repair');
for (const stage of stages) { for (const stage of stages) {
expect(check(`${stage}:scan`)).toMatchObject({ rows: 8192, wrong: 0, missing: 0 }); expect(check(`${stage}:scan`)).toMatchObject({ rows: 8192, wrong: 0, missing: 0 });
if (scenario === 'context') { if (scenario !== 'property') {
for (const lookup of ['primary-key', 'uid', 'name']) { for (const lookup of ['primary-key', 'uid', 'name']) {
expect(check(`${stage}:${lookup}`)?.ids).toEqual([ expect(check(`${stage}:${lookup}`)?.ids).toEqual([
'Function:src/owner2.ts:unchangedSwiftTarget64', scenario === 'method'
? 'Method:checks/test_owner2.py:Checks.test_retained_method_64#0'
: 'Function:src/owner2.ts:unchangedSwiftTarget64',
]); ]);
} }
} else { } else {

View file

@ -0,0 +1,356 @@
/** Real indexing, native reads and one MCP session across index publication. */
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import lbug from '@ladybugdb/core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
import { closeLbug as closeReader } from '../../src/core/lbug/pool-adapter.js';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { createMCPServer, getNextStepHint } from '../../src/mcp/server.js';
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
import { createTempDir } from '../helpers/test-db.js';
const options = { skipAgentsMd: true, skipSkills: true, skipFts: true, atomicIncremental: true };
// Atomic incremental publication is POSIX-only; Windows uses in-place writes.
describe.skipIf(process.platform === 'win32')('MCP index generation freshness', () => {
let temp: Awaited<ReturnType<typeof createTempDir>>;
let repo: string;
let ftsLoaded = false;
const backend = new LocalBackend();
const server = createMCPServer(backend);
const client = new Client({ name: 'index-generation-regression', version: '1.0.0' });
beforeAll(async () => {
temp = await createTempDir();
const root = await fs.realpath(temp.dbPath);
vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home'));
vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined);
vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined);
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1');
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only');
await adapter.initLbug(path.join(root, 'fts-probe'));
ftsLoaded = await adapter.loadFTSExtension(undefined, { policy: 'load-only' });
await adapter.closeLbug();
if (!ftsLoaded && process.env.GITNEXUS_REQUIRE_FTS === '1')
throw new Error('FTS is required for this test');
options.skipFts = !ftsLoaded;
repo = path.join(root, 'repo');
await fs.mkdir(repo);
await fs.writeFile(path.join(repo, '.gitignore'), '.gitnexus/\n');
for (let i = 0; i < 60; i++) {
await fs.writeFile(
path.join(repo, `unit_${i}.py`),
`def retained_${i}():\n return ${i}\n`,
);
}
initGitRepo(repo);
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: repo });
commitAll(repo, 'baseline');
await runFullAnalysis(repo, options, { onProgress: () => {} });
await backend.init();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
}, 120_000);
afterAll(async () => {
await client.close();
await server.close();
await backend.dispose();
vi.unstubAllEnvs();
await temp?.cleanup();
});
async function read(branch?: string, target = repo): Promise<any> {
const args = {
repo: target,
...(branch ? { branch } : {}),
statement: "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' RETURN n.name AS name",
};
return call('cypher', args);
}
async function call(name: string, args: Record<string, unknown>): Promise<any> {
const result = await client.callTool({ name, arguments: args });
expect(result.isError, JSON.stringify(result)).not.toBe(true);
const text = (result.content as Array<{ type: string; text: string }>).find(
(item) => item.type === 'text',
)!.text;
const hint = getNextStepHint(name, args);
return JSON.parse(hint ? text.slice(0, -hint.length) : text);
}
it('releases ingestion manifests while retaining the opened generation metadata', async () => {
const { storagePath, lbugPath } = getStoragePaths(repo);
const metadata = (await loadMeta(storagePath))!;
expect(Object.keys(metadata.fileHashes ?? {}).length).toBeGreaterThan(0);
await read();
await closeReader(lbugPath);
const state = (backend as any).lastObservedPoolState.get(lbugPath);
expect(state.metadata.fileHashes).toBeUndefined();
expect(state.metadata.cacheKeys).toBeUndefined();
expect(state.metadata.embeddingCheckpoint).toBeUndefined();
expect((await read()).staleness).toMatchObject({
status: 'current',
lastCommit: metadata.lastCommit,
indexedAt: metadata.indexedAt,
});
});
it('refreshes code and metadata immediately after selective publication and same-commit rebuild', async () => {
const storagePath = getStoragePaths(repo).storagePath;
const beforeMeta = (await loadMeta(storagePath))!;
expect((await read()).staleness).toMatchObject({
status: 'current',
lastCommit: beforeMeta.lastCommit,
indexedAt: beforeMeta.indexedAt,
});
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def refreshed_zero():\n return 9001\n');
commitAll(repo, 'updated function');
const update = await runFullAnalysis(repo, options, { onProgress: () => {} });
expect(update.incrementalStats).toMatchObject({
writeMode: 'incremental',
changedFiles: 1,
deletedFiles: 0,
});
const published = (await loadMeta(storagePath))!;
const immediate = await read();
expect(JSON.stringify(immediate)).toContain('refreshed_zero');
expect(immediate.staleness).toMatchObject({
status: 'current',
lastCommit: published.lastCommit,
indexedAt: published.indexedAt,
});
expect(published.lastCommit).not.toBe(beforeMeta.lastCommit);
const context = await call('context', { repo, name: 'refreshed_zero', include_content: true });
expect(context.symbol).toMatchObject({
name: 'refreshed_zero',
filePath: 'unit_0.py',
startLine: 1,
endLine: 2,
});
expect(context.staleness).toMatchObject({
status: 'current',
lastCommit: published.lastCommit,
indexedAt: published.indexedAt,
});
const query = await call('query', {
repo,
search_query: 'refreshed_zero',
include_content: true,
});
expect(query.staleness).toMatchObject({
status: 'current',
lastCommit: published.lastCommit,
indexedAt: published.indexedAt,
});
if (ftsLoaded)
expect([...query.process_symbols, ...query.definitions]).toContainEqual(
expect.objectContaining({
name: 'refreshed_zero',
filePath: 'unit_0.py',
startLine: 1,
endLine: 2,
}),
);
await runFullAnalysis(repo, { ...options, force: true }, { onProgress: () => {} });
const rebuilt = (await loadMeta(storagePath))!;
expect(rebuilt.lastCommit).toBe(published.lastCommit);
expect(rebuilt.indexedAt).not.toBe(published.indexedAt);
expect((await read()).staleness).toMatchObject({
status: 'current',
lastCommit: rebuilt.lastCommit,
indexedAt: rebuilt.indexedAt,
});
}, 120_000);
it('keeps a busy native reader on its old ref, then refreshes on the next call', async () => {
const storagePath = getStoragePaths(repo).storagePath;
const previous = (await loadMeta(storagePath))!;
await read();
const source = await fs.readFile(path.join(repo, 'unit_0.py'), 'utf8');
const baselineName = /^def (\w+)\(/m.exec(source)![1];
let release!: () => void;
let entered!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const ready = new Promise<void>((resolve) => {
entered = resolve;
});
const original = lbug.QueryResult.prototype.getAll;
let held = false;
// Pause after the real native rows are drained but before the adapter closes
// that result and returns its connection. No native implementation is mocked.
const spy = vi.spyOn(lbug.QueryResult.prototype, 'getAll').mockImplementation(async function (
this: lbug.QueryResult,
) {
const rows = await original.call(this);
if (!held && rows.length === 1 && rows[0]?.name === baselineName) {
held = true;
entered();
await gate;
}
return rows;
});
const oldRead = read();
let releasedResult: any;
const other = new LocalBackend();
try {
await ready;
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def newest_zero():\n return 9002\n');
commitAll(repo, 'newer function');
const update = await runFullAnalysis(repo, options, { onProgress: () => {} });
expect(update.incrementalStats?.writeMode).toBe('incremental');
const busyRead = await read();
expect(JSON.stringify(busyRead)).toContain(baselineName);
expect(busyRead.staleness).toMatchObject({
status: 'behind',
lastCommit: previous.lastCommit,
indexedAt: previous.indexedAt,
});
await other.init();
// This backend has no metadata for the native generation already held by
// its sibling, so it must not label those old rows with the new disk ref.
const unknown = await other.callTool('cypher', {
repo,
statement: "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' RETURN n.name AS name",
});
expect(JSON.stringify(unknown)).toContain(baselineName);
expect(unknown.staleness).toMatchObject({ status: 'unknown', lastCommit: '', indexedAt: '' });
} finally {
release();
releasedResult = await oldRead;
spy.mockRestore();
}
const published = (await loadMeta(storagePath))!;
expect(releasedResult.staleness).toMatchObject({
lastCommit: previous.lastCommit,
indexedAt: previous.indexedAt,
});
const recovered = await other.callTool('cypher', {
repo,
statement: "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' RETURN n.name AS name",
});
expect(recovered.staleness).toMatchObject({
status: 'current',
lastCommit: published.lastCommit,
indexedAt: published.indexedAt,
});
const fresh = await read();
expect(JSON.stringify(fresh)).toContain('newest_zero');
expect(fresh.staleness).toMatchObject({
status: 'current',
lastCommit: published.lastCommit,
indexedAt: published.indexedAt,
});
}, 120_000);
it('does not certify a swapped graph until its final metadata stamp arrives', async () => {
const storagePath = getStoragePaths(repo).storagePath;
const previous = (await loadMeta(storagePath))!;
await read();
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def after_gap():\n return 9003\n');
commitAll(repo, 'publication gap');
await runFullAnalysis(repo, options, { onProgress: () => {} });
const published = (await loadMeta(storagePath))!;
// Reproduce the actual publication ordering: new native graph, old stamp.
await saveMeta(storagePath, previous);
try {
const gap = await read();
expect(JSON.stringify(gap)).toContain('after_gap');
expect(gap.staleness).toMatchObject({ status: 'unknown', lastCommit: '', indexedAt: '' });
} finally {
await saveMeta(storagePath, published);
}
expect((await read()).staleness).toMatchObject({
status: 'current',
lastCommit: published.lastCommit,
indexedAt: published.indexedAt,
});
}, 120_000);
it('keeps a pinned branch isolated when the flat workspace is reindexed', async () => {
const oldBranch = execFileSync('git', ['branch', '--show-current'], {
cwd: repo,
encoding: 'utf8',
}).trim();
execFileSync('git', ['checkout', '-qb', 'generation-feature'], { cwd: repo });
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def branch_zero():\n return 9004\n');
commitAll(repo, 'feature function');
await runFullAnalysis(
repo,
{ ...options, branch: 'generation-feature' },
{ onProgress: () => {} },
);
await client.callTool({ name: 'list_repos', arguments: {} });
const pinned = await read('generation-feature');
execFileSync('git', ['checkout', '-q', oldBranch], { cwd: repo });
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def flat_latest():\n return 9005\n');
commitAll(repo, 'flat update');
await runFullAnalysis(repo, options, { onProgress: () => {} });
const flat = await read();
expect(JSON.stringify(flat)).toContain('flat_latest');
expect(flat.staleness.branch).toBe(oldBranch);
const stillPinned = await read('generation-feature');
expect(JSON.stringify(stillPinned)).toContain('branch_zero');
expect(stillPinned.staleness).toMatchObject({
lastCommit: pinned.staleness.lastCommit,
branch: 'generation-feature',
});
expect((await read()).staleness.lastCommit).toBe(flat.staleness.lastCommit);
}, 120_000);
it('follows a shared-store graph pointer with matching commit and checkout branch', async () => {
vi.stubEnv('GITNEXUS_SHARED_STORE', undefined);
const sharedRepo = path.join(temp.dbPath, 'shared-repo');
const sibling = path.join(temp.dbPath, 'shared-sibling');
await fs.mkdir(sharedRepo);
await fs.writeFile(path.join(sharedRepo, '.gitignore'), '.gitnexus/\n');
await fs.writeFile(path.join(sharedRepo, 'unit_0.py'), 'def shared_before():\n return 1\n');
initGitRepo(sharedRepo);
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: sharedRepo });
commitAll(sharedRepo, 'shared baseline');
execFileSync('git', ['worktree', 'add', '-qb', 'shared-sibling', sibling], { cwd: sharedRepo });
await runFullAnalysis(sharedRepo, options, { onProgress: () => {} });
const storagePath = getStoragePaths(sharedRepo).storagePath;
const previous = (await loadMeta(storagePath))!;
expect(previous.graphPath).toBeTruthy();
await runFullAnalysis(sibling, options, { onProgress: () => {} });
const siblingMeta = (await loadMeta(getStoragePaths(sibling).storagePath))!;
expect(siblingMeta.graphPath).toBe(previous.graphPath);
const [mainResult, siblingResult] = await Promise.all([
read(undefined, sharedRepo),
read(undefined, sibling),
]);
expect(mainResult.staleness).toMatchObject({
lastCommit: previous.lastCommit,
branch: previous.branch,
});
expect(siblingResult.staleness).toMatchObject({
lastCommit: previous.lastCommit,
branch: 'shared-sibling',
});
execFileSync('git', ['checkout', '-qb', 'shared-feature'], { cwd: sharedRepo });
await fs.writeFile(path.join(sharedRepo, 'unit_0.py'), 'def shared_after():\n return 2\n');
commitAll(sharedRepo, 'shared update');
await runFullAnalysis(sharedRepo, options, { onProgress: () => {} });
const published = (await loadMeta(storagePath))!;
expect(published.graphPath).not.toBe(previous.graphPath);
const moved = await read(undefined, sharedRepo);
expect(JSON.stringify(moved)).toContain('shared_after');
expect(moved.staleness).toMatchObject({
status: 'current',
lastCommit: published.lastCommit,
indexedAt: published.indexedAt,
branch: 'shared-feature',
});
}, 120_000);
});

View file

@ -0,0 +1,242 @@
/** Injected corruption proves the query boundary, not the cause of #3527/#3564. */
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { SYMBOL_IDENTITY_RECOVERY_SUGGESTION } from '../../src/mcp/local/query-result-integrity.js';
import { createMCPServer, getNextStepHint } from '../../src/mcp/server.js';
import { getStoragePaths } from '../../src/storage/repo-manager.js';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
import { createTempDir } from '../helpers/test-db.js';
const SOURCE = [
'export function integrityNeedle() {',
' return 42;',
'}',
'',
'export function startIntegrityFlow() {',
' return middleIntegrityFlow();',
'}',
'',
'function middleIntegrityFlow() {',
' return integrityNeedle();',
'}',
].join('\n');
const UID = 'Function:src/fixture.ts:integrityNeedle';
describe('query identity boundary through the real MCP server', () => {
let temp: Awaited<ReturnType<typeof createTempDir>>;
let repo: string;
let lbugPath: string;
let snapshot: string;
let ftsLoaded = false;
beforeAll(async () => {
temp = await createTempDir();
const root = await fs.realpath(temp.dbPath);
const userHome = path.join(root, 'user-home');
await fs.mkdir(userHome);
vi.stubEnv('HOME', userHome);
vi.stubEnv('USERPROFILE', userHome);
vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home'));
vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined);
vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined);
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
vi.stubEnv('GITNEXUS_CONTENT_RETENTION', 'full');
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only');
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1');
await adapter.initLbug(path.join(root, 'fts-probe'));
ftsLoaded = await adapter.loadFTSExtension(undefined, { policy: 'load-only' });
await adapter.closeLbug();
if (!ftsLoaded) {
if (process.env.GITNEXUS_REQUIRE_FTS === '1')
throw new Error('FTS is required for this test');
return;
}
repo = path.join(root, 'repo');
await fs.mkdir(path.join(repo, 'src'), { recursive: true });
await fs.writeFile(path.join(repo, '.gitignore'), '.gitnexus/\n');
await fs.writeFile(path.join(repo, 'src/fixture.ts'), SOURCE);
initGitRepo(repo);
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: repo });
commitAll(repo, 'source-derived query fixture');
await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} });
lbugPath = getStoragePaths(repo).lbugPath;
snapshot = path.join(root, 'healthy.lbug');
await fs.copyFile(lbugPath, snapshot);
const groupDir = path.join(root, 'home', 'groups', 'integrity');
await fs.mkdir(groupDir, { recursive: true });
await fs.writeFile(
path.join(groupDir, 'group.yaml'),
JSON.stringify({ version: 1, name: 'integrity', repos: { service: 'repo' } }),
);
}, 120_000);
beforeEach(({ skip }) => {
if (!ftsLoaded) skip('FTS extension unavailable under the load-only policy');
});
afterAll(async () => {
await adapter.closeLbug();
vi.unstubAllEnvs();
await temp?.cleanup();
});
async function query(mutate?: () => Promise<void>, legacy = false, target = repo): Promise<any> {
await fs.copyFile(snapshot, lbugPath);
if (mutate) {
await adapter.initLbug(lbugPath);
try {
await mutate();
} finally {
await adapter.closeLbug();
}
}
const backend = new LocalBackend();
await backend.init();
const server = createMCPServer(backend);
const client = new Client({ name: 'query-integrity-regression', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const args = {
repo: target,
[legacy ? 'query' : 'search_query']: 'integrityNeedle',
include_content: true,
chain_depth: 1,
};
try {
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
const result = await client.callTool({ name: 'query', arguments: args });
expect(result.isError).not.toBe(true);
const content = result.content as Array<{ type: string; text: string }>;
const text = content.find((item) => item.type === 'text')!.text;
const hint = getNextStepHint('query', args);
return JSON.parse(hint ? text.slice(0, -hint.length) : text);
} finally {
await client.close();
await server.close();
await backend.dispose();
}
}
it('returns source-derived symbol identity, range and content', async () => {
const result = await query();
expect(result).not.toHaveProperty('error');
expect(result).not.toHaveProperty('partial');
expect([...result.process_symbols, ...result.definitions]).toContainEqual(
expect.objectContaining({
id: UID,
name: 'integrityNeedle',
filePath: 'src/fixture.ts',
startLine: 1,
endLine: 3,
content: SOURCE.split('\n').slice(0, 3).join(' '),
}),
);
expect(result.processes.length).toBeGreaterThan(0);
});
it.each([
['NUL symbol name', 'MATCH (n:Function {id: $uid}) SET n.name = $value', 'invalid\0name'],
['NUL symbol path', 'MATCH (n:Function {id: $uid}) SET n.filePath = $value', 'invalid\0path'],
['NUL process label', 'MATCH (p:Process) SET p.label = $value', 'invalid\0process'],
['NUL cluster label', 'MATCH (c:Community) SET c.heuristicLabel = $value', 'invalid\0cluster'],
])(
'rejects injected %s instead of returning success or a fallback',
async (_name, statement, value) => {
const result = await query(async () => {
await adapter.executePrepared(statement, { uid: UID, value });
}, true);
expect(result.error).toContain('invalid symbol identity');
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
expect(result).not.toHaveProperty('definitions');
expect(JSON.stringify(result)).not.toContain('\\u0000');
},
);
it('rejects a blank UID read from native FTS instead of dropping it', async () => {
const result = await query(async () => {
await adapter.executeQuery(
"CREATE (n:Function {id: '', name: 'integrityNeedle', filePath: 'src/fixture.ts', startLine: 0, endLine: 2})",
);
await adapter.dropFTSIndex('Function', 'function_fts');
await adapter.createFTSIndex('Function', 'function_fts', ['name', 'content', 'description']);
});
expect(result.error).toContain('invalid symbol identity');
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
expect(result).not.toHaveProperty('definitions');
});
it('accepts legacy missing process entry points and empty optional labels', async () => {
const result = await query(async () => {
await adapter.executeQuery(
"MATCH (p:Process) SET p.entryPointId = NULL, p.heuristicLabel = ''",
);
});
expect(result).not.toHaveProperty('error');
expect(result.processes.length).toBeGreaterThan(0);
});
it('does not treat source content as an identity', async () => {
const content = 'source text containing \0 is not an identity';
const result = await query(async () => {
await adapter.executePrepared('MATCH (n:Function {id: $uid}) SET n.content = $content', {
uid: UID,
content,
});
});
expect(result).not.toHaveProperty('error');
expect(result.process_symbols).toContainEqual(expect.objectContaining({ id: UID, content }));
});
it('rejects corrupt route enrichment rather than degrading to a route-less process', async () => {
const result = await query(async () => {
await adapter.executePrepared(
"CREATE (r:Route {id: 'Route:injected', name: '/example', method: $method})",
{ method: 'GE\0T' },
);
await adapter.executeQuery(
"MATCH (p:Process), (r:Route {id: 'Route:injected'}) CREATE (r)-[:CodeRelation {type: 'ENTRY_POINT_OF'}]->(p)",
);
});
expect(result.error).toContain('invalid symbol identity');
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
});
it('preserves the integrity error through a group query and its legacy query argument', async () => {
const healthy = await query(undefined, true, '@integrity');
expect(healthy).not.toHaveProperty('error');
expect(healthy.results.length).toBeGreaterThan(0);
const result = await query(
async () => {
await adapter.executePrepared('MATCH (n:Function {id: $uid}) SET n.name = $name', {
uid: UID,
name: 'invalid\0name',
});
},
true,
'@integrity',
);
expect(result.error).toContain('invalid symbol identity');
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
expect(result).not.toHaveProperty('results');
});
it('rejects corruption found only by concurrent chain enrichment', async () => {
const result = await query(async () => {
await adapter.executePrepared(
"CREATE (n:Function {id: 'Function:chain-only', name: $name, filePath: 'src/fixture.ts'})",
{ name: 'invalid\0chain' },
);
await adapter.executeQuery(
"MATCH (p:Process), (entry:Function), (n:Function {id: 'Function:chain-only'}) WHERE entry.id = p.entryPointId CREATE (entry)-[:CodeRelation {type: 'CALLS'}]->(n)",
);
});
expect(result.error).toContain('invalid symbol identity');
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
});
});

View file

@ -192,7 +192,7 @@ describe('BM25 search', () => {
expect(results[0].nodeIds).toEqual(['func:m1', 'func:m2']); expect(results[0].nodeIds).toEqual(['func:m1', 'func:m2']);
}); });
it('filters out empty nodeIds', async () => { it('rejects empty native nodeIds instead of hiding them in file-only results', async () => {
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js'); const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
vi.mocked(queryFTS) vi.mocked(queryFTS)
.mockResolvedValueOnce([]) // File .mockResolvedValueOnce([]) // File
@ -205,10 +205,7 @@ describe('BM25 search', () => {
.mockResolvedValueOnce([]) // Method .mockResolvedValueOnce([]) // Method
.mockResolvedValueOnce([]); // Interface .mockResolvedValueOnce([]); // Interface
const { results } = await searchFTSFromLbug('util'); await expect(searchFTSFromLbug('util')).rejects.toThrow('invalid symbol identity');
expect(results).toHaveLength(1);
expect(results[0].nodeIds).toEqual([]);
}); });
it('merges hits across multiple index tables for the same file', async () => { it('merges hits across multiple index tables for the same file', async () => {

View file

@ -3019,6 +3019,7 @@ describe('LocalBackend.callTool', () => {
// Legacy tool aliases // Legacy tool aliases
it('dispatches "search" as alias for query', async () => { it('dispatches "search" as alias for query', async () => {
(executeQuery as any).mockResolvedValue([]);
(executeParameterized as any).mockResolvedValue([]); (executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('search', { query: 'auth' }); const result = await backend.callTool('search', { query: 'auth' });
expect(result).toHaveProperty('processes'); expect(result).toHaveProperty('processes');
@ -5841,34 +5842,47 @@ describe('LocalBackend tool-staleness signal (#2655 review)', () => {
// The evict-on-reject is guarded by object identity (=== entry), so a LATE // The evict-on-reject is guarded by object identity (=== entry), so a LATE
// rejection from a superseded entry must not drop the newer entry that // rejection from a superseded entry must not drop the newer entry that
// replaced it after the TTL rolled over. // replaced it after TTL expiry or an index generation change.
it('a late rejection does not evict the newer cache entry', async () => { it.each(['TTL expiry', 'new commit', 'same-commit rebuild'])(
stubResolve(); 'a late rejection does not evict the newer cache entry after %s',
vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true }); async (change) => {
const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(1000); const resolve = stubResolve();
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js'); vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true });
let rejectFirst: (e: unknown) => void = () => {}; const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const first = new Promise<StalenessInfo>((_res, rej) => { const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');
rejectFirst = rej; let rejectFirst: (e: unknown) => void = () => {};
}); const first = new Promise<StalenessInfo>((_res, rej) => {
(checkStalenessAsync as any) rejectFirst = rej;
.mockReturnValueOnce(first) // entry 1 — held open, will reject late });
.mockResolvedValue({ isStale: true, commitsBehind: 7, hint: '7 behind' }); // entry 2+ (checkStalenessAsync as any)
.mockReturnValueOnce(first) // entry 1 — held open, will reject late
.mockResolvedValue({ isStale: true, commitsBehind: 7, hint: '7 behind' }); // entry 2+
const p1 = backend.callTool('query', { search_query: 'x', repo: 'r' }); // installs entry1 @1000 const p1 = backend.callTool('query', { search_query: 'x', repo: 'r' }); // installs entry1 @1000
await new Promise((r) => setTimeout(r, 0)); // entry1 installed, awaiting `first` await new Promise((r) => setTimeout(r, 0)); // entry1 installed, awaiting `first`
dateSpy.mockReturnValue(1000 + 5000 + 1); // past TTL → next call installs entry2 if (change === 'TTL expiry') {
const r2 = await backend.callTool('query', { search_query: 'x', repo: 'r' }); dateSpy.mockReturnValue(1000 + 5000 + 1);
expect(r2).toMatchObject({ staleness: { commitsBehind: 7 } }); } else {
// Same path, still inside the TTL: the new generation must get its own
// promise while the old generation's git computation remains in flight.
resolve.mockResolvedValue({
...handle,
indexedAt: 'new-generation',
lastCommit: change === 'new commit' ? 'NEWSHA' : handle.lastCommit,
} as any);
}
const r2 = await backend.callTool('query', { search_query: 'x', repo: 'r' });
expect(r2).toMatchObject({ staleness: { commitsBehind: 7 } });
rejectFirst(new Error('late git failure')); // entry1's guarded catch must NOT evict entry2 rejectFirst(new Error('late git failure')); // entry1's guarded catch must NOT evict entry2
await p1.catch(() => {}); // p1 degrades to no-staleness await p1.catch(() => {}); // p1 degrades to no-staleness
const callsBefore = (checkStalenessAsync as any).mock.calls.length; const callsBefore = (checkStalenessAsync as any).mock.calls.length;
const r3 = await backend.callTool('query', { search_query: 'x', repo: 'r' }); // still within entry2 TTL const r3 = await backend.callTool('query', { search_query: 'x', repo: 'r' }); // still within entry2 TTL
expect((checkStalenessAsync as any).mock.calls.length).toBe(callsBefore); // cache hit → entry2 survived expect((checkStalenessAsync as any).mock.calls.length).toBe(callsBefore); // cache hit → entry2 survived
expect(r3).toMatchObject({ staleness: { commitsBehind: 7 } }); expect(r3).toMatchObject({ staleness: { commitsBehind: 7 } });
dateSpy.mockRestore(); dateSpy.mockRestore();
}); },
);
}); });

View file

@ -42,7 +42,18 @@ describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => {
// Seed the "warm, already initialized" precondition ensureInitialized // Seed the "warm, already initialized" precondition ensureInitialized
// requires to reach the staleness-check branch at all. // requires to reach the staleness-check branch at all.
backend.initializedRepos.add(poolKey); backend.initializedRepos.add(poolKey);
backend.lastStalenessCheck.set(poolKey, 0); // force past the 5s throttle });
it('keeps checkout branches separate for concurrent callers of one branch-neutral pool', async () => {
loadMetaMock.mockResolvedValue({ indexedAt: 'shared-v1', lastCommit: 'shared-commit' });
initLbugMock.mockResolvedValue(true);
const left = { ...repoHandle, branch: 'left', lastCommit: 'shared-commit' };
const right = { ...repoHandle, branch: 'right', lastCommit: 'shared-commit' };
await Promise.all([backend.ensureInitialized(left), backend.ensureInitialized(right)]);
expect(left.branch).toBe('left');
expect(right.branch).toBe('right');
expect(left.indexedAt).toBe('shared-v1');
expect(right.indexedAt).toBe('shared-v1');
}); });
it('does not latch the fts-status watermark when initLbug throws, so the next staleness check retries', async () => { it('does not latch the fts-status watermark when initLbug throws, so the next staleness check retries', async () => {
@ -60,8 +71,7 @@ describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => {
expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBeUndefined(); expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBeUndefined();
expect(backend.lastObservedPoolState.get(poolKey)?.indexedAt).toBeUndefined(); expect(backend.lastObservedPoolState.get(poolKey)?.indexedAt).toBeUndefined();
// Second staleness check (throttle reset again) with initLbug now succeeding. // Second staleness check with initLbug now succeeding.
backend.lastStalenessCheck.set(poolKey, 0);
initLbugMock.mockResolvedValueOnce(false); // "no real reopen needed" — still a completed call initLbugMock.mockResolvedValueOnce(false); // "no real reopen needed" — still a completed call
await expect(backend.ensureInitialized(repoHandle)).resolves.toBeUndefined(); await expect(backend.ensureInitialized(repoHandle)).resolves.toBeUndefined();
@ -92,7 +102,6 @@ describe('ensureInitialized ftsCapsChanged trigger, isolated from identityChange
vi.clearAllMocks(); vi.clearAllMocks();
backend = new LocalBackend() as any; backend = new LocalBackend() as any;
backend.initializedRepos.add(poolKey); backend.initializedRepos.add(poolKey);
backend.lastStalenessCheck.set(poolKey, 0);
}); });
it('fires a reinit on a capabilities.fts.status change alone, with indexedAt and dbIdentity both unchanged', async () => { it('fires a reinit on a capabilities.fts.status change alone, with indexedAt and dbIdentity both unchanged', async () => {

View file

@ -208,6 +208,30 @@ describe('#3291 — tool staleness names the indexed ref', () => {
expect(serialized).toContain(branchHead); expect(serialized).toContain(branchHead);
}); });
it('measures each checkout HEAD separately when both handles share one graph and ref', async () => {
const { mainClone, branchClone } = makeClones();
// Both checkouts call their branch main, but one is one commit behind.
git(branchClone, 'checkout', '-B', 'main', 'origin/main~1');
const indexedCommit = git(branchClone, 'rev-parse', 'HEAD');
expect(git(mainClone, 'rev-list', '--count', `${indexedCommit}..HEAD`)).toBe('1');
const sharedHandle = handleFor(branchClone, indexedCommit, 'main');
vi.spyOn(backend, 'selectToolRepository')
.mockResolvedValueOnce(sharedHandle as never)
.mockResolvedValueOnce({ ...sharedHandle, repoPath: mainClone } as never);
vi.spyOn(
backend as unknown as { impact: () => Promise<typeof IMPACT_RESULT> },
'impact',
).mockResolvedValue(IMPACT_RESULT as never);
const current = await backend.callTool('impact', { target: 'doWork', repo: 'current' });
const behind = await backend.callTool('impact', { target: 'doWork', repo: 'advanced' });
expect(current.staleness).toMatchObject({ status: 'current', lastCommit: indexedCommit });
expect(behind.staleness).toMatchObject({
status: 'behind',
commitsBehind: 1,
lastCommit: indexedCommit,
});
});
// Negative control. The same wiring — real git, real `checkStalenessAsync`, // Negative control. The same wiring — real git, real `checkStalenessAsync`,
// real `attachToolStaleness` — must still report a counted gap when the index // real `attachToolStaleness` — must still report a counted gap when the index
// is behind its own checkout. Without this, the ref-carrying payload above // is behind its own checkout. Without this, the ref-carrying payload above

View file

@ -76,6 +76,10 @@ export default defineConfig({
'test/integration/impact-callable-value-references.test.ts', 'test/integration/impact-callable-value-references.test.ts',
'test/integration/impact-context-integrity.test.ts', 'test/integration/impact-context-integrity.test.ts',
'test/integration/incremental-worktree-context.test.ts', 'test/integration/incremental-worktree-context.test.ts',
'test/integration/incremental-method-integrity.test.ts',
'test/integration/query-symbol-integrity.test.ts',
'test/integration/mcp-index-generation.test.ts',
'test/integration/fts-repair-warm-session.test.ts',
'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts',
'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts',
'test/integration/lbug-core-adapter.test.ts', 'test/integration/lbug-core-adapter.test.ts',
@ -175,6 +179,10 @@ export default defineConfig({
'test/integration/impact-callable-value-references.test.ts', 'test/integration/impact-callable-value-references.test.ts',
'test/integration/impact-context-integrity.test.ts', 'test/integration/impact-context-integrity.test.ts',
'test/integration/incremental-worktree-context.test.ts', 'test/integration/incremental-worktree-context.test.ts',
'test/integration/incremental-method-integrity.test.ts',
'test/integration/query-symbol-integrity.test.ts',
'test/integration/mcp-index-generation.test.ts',
'test/integration/fts-repair-warm-session.test.ts',
'test/integration/impact-epistemic-lower-bound.test.ts', 'test/integration/impact-epistemic-lower-bound.test.ts',
'test/integration/impact-scope-omission-persistence.test.ts', 'test/integration/impact-scope-omission-persistence.test.ts',
'test/integration/lbug-core-adapter.test.ts', 'test/integration/lbug-core-adapter.test.ts',