From 9d79092abcea0aa73deb541748cf581dc491efe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 13 Oct 2026 13:02:01 +0300 Subject: [PATCH] fix(mcp): reject corrupt query identities and refresh index metadata --- RUNBOOK.md | 14 + .../reproduce-search.cjs | 56 ++- gitnexus/src/core/group/service.ts | 47 +- gitnexus/src/core/lbug/pool-adapter.ts | 12 +- .../src/core/lbug/query-result-integrity.ts | 57 +++ gitnexus/src/core/search/bm25-index.ts | 12 +- gitnexus/src/mcp/local/local-backend.ts | 424 ++++++++++++------ .../src/mcp/local/query-result-integrity.ts | 59 +-- .../fts-repair-warm-session.test.ts | 22 +- .../incremental-method-integrity.test.ts | 195 ++++++++ .../lbug-incremental-search.test.ts | 10 +- .../integration/mcp-index-generation.test.ts | 356 +++++++++++++++ .../query-symbol-integrity.test.ts | 242 ++++++++++ gitnexus/test/unit/bm25-search.test.ts | 7 +- gitnexus/test/unit/calltool-dispatch.test.ts | 66 +-- ...nsure-initialized-reinit-watermark.test.ts | 17 +- .../repro-3291-staleness-indexed-ref.test.ts | 24 + gitnexus/vitest.config.ts | 8 + 18 files changed, 1366 insertions(+), 262 deletions(-) create mode 100644 gitnexus/src/core/lbug/query-result-integrity.ts create mode 100644 gitnexus/test/integration/incremental-method-integrity.test.ts create mode 100644 gitnexus/test/integration/mcp-index-generation.test.ts create mode 100644 gitnexus/test/integration/query-symbol-integrity.test.ts diff --git a/RUNBOOK.md b/RUNBOOK.md index 202d103a6..ac937284a 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -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 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 diff --git a/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs b/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs index 8e4c2d2e9..3e4fea57f 100644 --- a/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs +++ b/gitnexus/bench/incremental-write-integrity/reproduce-search.cjs @@ -1,5 +1,5 @@ #!/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 fs = require('node:fs/promises'); const os = require('node:os'); @@ -8,8 +8,8 @@ const path = require('node:path'); const lbug = require(process.env.LBUG_MODULE || '@ladybugdb/core'); const scenario = process.argv[2]; assert.ok( - ['context', 'property'].includes(scenario), - 'Usage: reproduce-search.cjs context|property [--keep]', + ['context', 'property', 'method'].includes(scenario), + 'Usage: reproduce-search.cjs context|property|method [--keep]', ); (async () => { @@ -65,29 +65,39 @@ assert.ok( if (errors.length) throw new AggregateError(errors, errors.map(String).join('; ')); return rows; }; - const table = scenario === 'context' ? 'Function' : 'Property'; + const table = { context: 'Function', property: 'Property', method: 'Method' }[scenario]; const columns = ['id', 'name', 'filePath', 'startLine', 'endLine']; if (scenario === 'property') columns.push('content', 'description'); + if (scenario === 'method') columns.push('content'); const indices = Array.from({ length: 8192 }, (_, i) => i); const changed = (i) => i < 32 || i >= 8192 - 32; const tuple = (i) => - scenario === 'context' + scenario === 'method' ? [ - `Function:src/owner${Math.floor(i / 32)}.ts:unchangedSwiftTarget${i}`, - `unchangedSwiftTarget${i}`, // Out-of-line name, unlike the short fn64 fixture. - `src/owner${Math.floor(i / 32)}.ts`, + `Method:checks/test_owner${Math.floor(i / 32)}.py:Checks.test_retained_method_${i}#0`, + `test_retained_method_${i}`, + `checks/test_owner${Math.floor(i / 32)}.py`, (i % 32) * 4, (i % 32) * 4 + 2, + ` def test_retained_method_${i}(self): value = ${i} return value`, ] - : [ - `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)}`, - ]; + : scenario === 'context' + ? [ + `Function:src/owner${Math.floor(i / 32)}.ts:unchangedSwiftTarget${i}`, + `unchangedSwiftTarget${i}`, // Out-of-line name, unlike the short fn64 fixture. + `src/owner${Math.floor(i / 32)}.ts`, + (i % 32) * 4, + (i % 32) * 4 + 2, + ] + : [ + `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) => columns.join(',') + '\n' + @@ -147,9 +157,10 @@ assert.ok( const lookup = async (stage) => { const target = tuple(64); // Retained throughout DELETE/COPY. 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 [ - ['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. ['uid', `MATCH (n {id: $uid}) RETURN ${select} LIMIT 1`, { uid: target[0] }], [ @@ -197,7 +208,7 @@ assert.ok( }; const check = async (stage) => { await scan(stage); - if (scenario === 'context') await lookup(stage); + if (scenario !== 'property') await lookup(stage); else await search(stage); }; try { @@ -208,14 +219,15 @@ assert.ok( await query(`CREATE NODE TABLE ${table}(${schema}, PRIMARY KEY(id))`); // 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 === 'method') + await query(`CREATE NODE TABLE Function(${schema}, PRIMARY KEY(id))`); await copy('full.csv'); if (scenario === 'property') await record('baseline:build', build); await query('CHECKPOINT'); await check('baseline'); if (scenario === 'property') await drop(); - const suffix = scenario === 'context' ? 'ts' : 'swift'; 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( 'after-delete', diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index cdec2652b..1e57b9bab 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -8,6 +8,10 @@ import path from 'node:path'; import { checkStaleness } from '../git-staleness.js'; import { stalenessStatus, type StalenessInfo, type StalenessStatus } from '../staleness-status.js'; import { mapConcurrent } from '../../lib/utils.js'; +import { + SymbolIdentityError, + SYMBOL_IDENTITY_RECOVERY_SUGGESTION, +} from '../lbug/query-result-integrity.js'; import { canonicalizePath, loadMeta, @@ -840,7 +844,20 @@ export class GroupService { })) as { processes?: Array>; process_symbols?: Array>; + 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 ? filterQueryByServicePrefix(queryResult, servicePrefix).processes : queryResult.processes || []; @@ -849,9 +866,19 @@ export class GroupService { _rrf_score: 1 / (idx + 1 + 60), _repo: repoPath, })); - return { repo: repoPath, score: 0, processes: scored as unknown[] }; - } catch { - return { repo: repoPath, score: 0, processes: [] as unknown[] }; + return { + repo: repoPath, + 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 }, @@ -859,9 +886,21 @@ export class GroupService { ).map((result, index) => { if (result) return result; 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>); allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number)); const topN = allProcesses.slice(0, limit); diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index 8f220e227..546520a1f 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -99,6 +99,12 @@ export function dbIdentityChanged(prev: DbIdentity | null, next: DbIdentity | nu const pool = new Map(); +/** 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 * timeout, explicit close). Used by upper layers (e.g. the BM25 search @@ -946,9 +952,9 @@ async function doInitLbug(repoId: string, dbPath: string): Promise; + return value[key] ?? value[index]; +} + +/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */ +export function assertIdentityFields(...values: unknown[]): void { + for (const value of values) { + if ( + value !== null && + value !== undefined && + (typeof value !== 'string' || value.includes('\0')) + ) { + throw new SymbolIdentityError(); + } + } +} + +export function assertQueryIdentity( + row: unknown, + idKey: string, + idIndex: number, + fields: ReadonlyArray = [], +): void { + assertSymbolIdentity(queryRowValue(row, idKey, idIndex)); + for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index)); +} + +/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */ +export function rethrowSymbolIdentityError(error: unknown): void { + if (error instanceof SymbolIdentityError) throw error; +} diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index c105a62a3..0b31c6492 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -15,6 +15,11 @@ // 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. 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 { getExtensionCapabilities } from '../lbug/extension-loader.js'; import { redactPaths } from './fts-indexes.js'; @@ -85,7 +90,9 @@ async function queryFTSViaExecutor( const rows = await executor(cypher, { query }); return { 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; return { filePath: node.filePath || '', @@ -95,6 +102,7 @@ async function queryFTSViaExecutor( }), }; } catch (e) { + rethrowSymbolIdentityError(e); const message = e instanceof Error ? e.message : String(e); return { benign: classifyFtsQueryError(message) === 'missing-index', message }; } @@ -207,6 +215,8 @@ export const searchFTSFromLbug = async ( const addResults = (results: any[]) => { for (const r of results) { + assertSymbolIdentity(r.nodeId); + assertIdentityFields(r.name, r.filePath); if (!fileNodeScores.has(r.filePath)) fileNodeScores.set(r.filePath, []); fileNodeScores.get(r.filePath)!.push({ score: r.score, nodeId: r.nodeId }); } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 4997012f2..d5f76110e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -27,6 +27,7 @@ import { isLbugReady, statDbIdentity, dbIdentityChanged, + getLbugDbIdentity, } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; import { querySpringAopMetadata } from './aop-metadata.js'; @@ -1217,6 +1218,9 @@ interface RepoHandle { branch?: string; /** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */ branches?: BranchSummary[]; + /** Per-tool snapshot; never shared with the registry's cached handle. */ + poolIdentity?: Awaited>; + generationUnknown?: boolean; } /** Resolve symlinks for path comparison; falls back to path.resolve on error. @@ -1677,30 +1681,72 @@ export function attachToolStaleness( 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. */ interface PoolObservedState { indexedAt?: string; dbIdentity: Awaited>; ftsStatus?: string; + ref?: IndexedRef; + generationUnknown?: boolean; + awaitingMetadata?: boolean; + diskIndexedAt?: string; + metadata?: Awaited>; } 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>> + >(); + + private readToolMetadata(repo: RepoHandle): Promise>> { + 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 repos: Map = new Map(); private contextCache: Map = new Map(); private initializedRepos: Set = new Set(); private reinitPromises: Map> = new Map(); - private lastStalenessCheck: Map = new Map(); // #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 // 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. - // 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 // lastCommit values, so a repoPath key would serve one handle's freshness for - // the other; lbugPath is unique per flat/branch index. - private toolStalenessCache: Map }> = - new Map(); + // the other. Shared graphs can serve several checkouts, so the generation + // also identifies the repoPath whose HEAD is measured. + private toolStalenessCache: Map< + string, + { at: number; generation: string; value: Promise } + > = new Map(); // tri-review Residual-2: consolidates what were three parallel per-poolKey // Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus) // 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 // definitive absence (degrade to call-level); `undefined` is unreadable // 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: [] }; // Resolve the anchor symbol (UID is precise; fall back to name/file). @@ -2077,7 +2125,6 @@ export class LocalBackend { for (const key of knownKeys) { if (liveLbugPaths.has(key)) continue; this.initializedRepos.delete(key); - this.lastStalenessCheck.delete(key); this.toolStalenessCache.delete(key); this.lastObservedPoolState.delete(key); this.reinitPromises.delete(key); @@ -2226,7 +2273,24 @@ export class LocalBackend { this.maybeWarnSiblingDrift(result).catch(() => { /* 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 @@ -2539,109 +2603,106 @@ export class LocalBackend { */ private async ensureInitialized(repo: RepoHandle): Promise { const poolKey = repo.lbugPath; - // If a reinit is already in progress for this repo, wait for it - const pending = this.reinitPromises.get(poolKey); - if (pending) return pending; - - // Check if the index was rebuilt since we opened the connection (#297). - // Throttle staleness checks to at most once per 5 seconds per repo to - // avoid an fs.readFile round-trip on every tool invocation. - if (this.initializedRepos.has(poolKey) && isLbugReady(poolKey)) { - 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 `/gitnexus.json`; for a - // branch handle it is `/branches//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 = { 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 + const bindSnapshot = (): void => { + const state = this.lastObservedPoolState.get(poolKey); + if ( + repo.poolIdentity !== undefined && + dbIdentityChanged(repo.poolIdentity, state?.dbIdentity ?? null) + ) { + repo.generationUnknown = true; + return; } + 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 { - await initLbug(poolKey, repo.lbugPath); - this.initializedRepos.add(poolKey); - // #2767: ftsStatus is deliberately left unset (undefined) here rather - // than issuing an extra loadMeta read — every tool call already routes - // through ensureInitialized, so an extra per-cold-init read adds up, - // and the cost of skipping it is negligible: at most one redundant - // initLbug call on the first warm check (initLbug itself no-ops - // cheaply via a single fs.stat when the file identity is actually - // unchanged, per pool-adapter.ts's own "unchanged → reuse" guard), not - // a real reopen. + // Check metadata and file identity before serving a tool; only git freshness + // retains its five-second cache. A publication must not pair new data with an old ref. + const refresh = (async () => { + const warm = this.initializedRepos.has(poolKey) && isLbugReady(poolKey); + const previous = this.lastObservedPoolState.get(poolKey); + const meta = await this.readToolMetadata(repo); + const diskIdentity = await statDbIdentity(poolKey); + if (meta?.indexedAt && meta.indexedAt !== previous?.diskIndexedAt) { + // Even a deferred reader needs its old ref compared to the new HEAD. + // Invalidate once per publication; concurrent checks still deduplicate. + 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, { - indexedAt: repo.indexedAt, - dbIdentity: await statDbIdentity(repo.lbugPath), + indexedAt: meta?.indexedAt ?? previous?.indexedAt ?? repo.indexedAt, + 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.initializedRepos.delete(poolKey); - throw err; + })(); + this.reinitPromises.set(poolKey, refresh); + try { + 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` // undefined, and the builder returns nothing for that even with a ref, so // 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); return attachToolStaleness(result, staleness, { branch: repo.branch, @@ -2922,8 +2998,15 @@ export class LocalBackend { */ private stalenessForTool(repo: RepoHandle): Promise { 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); - 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; } // 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 // non-thenable return can't throw at this boundary — a no-op for the real // async `checkStalenessAsync`, robust defense-in-depth otherwise. - const entry: { at: number; value: Promise } = { + const entry: { at: number; generation: string; value: Promise } = { at: now, + generation, // 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 // rejection lands, and that newer entry must not be dropped. @@ -3177,6 +3261,31 @@ export class LocalBackend { include_content?: boolean; chain_depth?: number; }, + ): Promise { + 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 { // #2175: each consumer resolves the search_query/query alias itself (there is no // 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 // reads this metadata for CJK and embedding-dimension drift diagnostics, // 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 contentAvailability = requestedContentAvailability(requestedContent, meta); const searchQuery = rawQuery.trim(); @@ -3266,6 +3375,10 @@ export class LocalBackend { for (let i = 0; i < bm25Results.length; 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 rrfScore = 1 / (60 + i); const existing = scoreMap.get(key); @@ -3279,6 +3392,8 @@ export class LocalBackend { const safeSemanticResults = semanticResults ?? []; for (let i = 0; i < safeSemanticResults.length; i++) { const result = safeSemanticResults[i]; + assertSymbolIdentity(result.nodeId); + assertIdentityFields(result.name, result.type, result.filePath); const key = result.nodeId || result.filePath; const rrfScore = 1 / (60 + i); const existing = scoreMap.get(key); @@ -3361,12 +3476,20 @@ export class LocalBackend { { nodeIds: ids }, ); 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]; let list = processRowsByNode.get(nid); if (!list) processRowsByNode.set(nid, (list = [])); list.push(row); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:process-lookup', e); if (!isBenignMissingTableError(e)) enrichmentDegraded = true; } @@ -3391,6 +3514,7 @@ export class LocalBackend { { nodeIds: ids }, ); for (const row of rows) { + assertQueryIdentity(row, 'nodeId', 0, [['module', 2]]); const nid = row.nodeId ?? row[0]; if (!cohesionByNode.has(nid)) { cohesionByNode.set(nid, { @@ -3400,6 +3524,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:cluster-info', e); if (!isBenignMissingTableError(e)) enrichmentDegraded = true; } @@ -3417,10 +3542,12 @@ export class LocalBackend { { nodeIds: ids }, ); for (const row of rows) { + assertQueryIdentity(row, 'nodeId', 0); const nid = row.nodeId ?? row[0]; contentByNode.set(nid, row.content ?? row[1]); } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:content-fetch', e); if (!isBenignMissingTableError(e)) enrichmentDegraded = true; } @@ -3534,6 +3661,13 @@ export class LocalBackend { { routeIds: ids }, ); 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 handlerId = String(row.handlerId ?? row[1] ?? ''); if (!routeId || !handlerId) continue; @@ -3549,6 +3683,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:route-definition-bridge', e); if (!isBenignMissingTableError(e)) enrichmentDegraded = true; } @@ -3644,6 +3779,10 @@ export class LocalBackend { }, ); for (const row of routeRows) { + assertQueryIdentity(row, 'pid', 0, [ + ['url', 1], + ['method', 2], + ]); const targetPid = row.pid ?? row[0]; const url = row.url ?? row[1]; const method = row.method ?? row[2]; @@ -3661,6 +3800,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:route-lookup', e); 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 // cap; GroupService.groupQuery also caps member-query fan-out so a group // of N members cannot run 4×N concurrent BFS walks. - // Best-effort — a BFS failure drops that process's chain but never fails - // the query. + // Ordinary BFS failures may drop a chain; corrupt identities must fail + // the query even though mapConcurrent normally swallows worker errors. const chainByProcessId = new Map(); if (requestedChainDepth > 0 && rankedProcesses.length > 0) { timer.start('chain_enrichment'); + let integrityError: SymbolIdentityError | undefined; await mapConcurrent( rankedProcesses, async (p) => { @@ -3692,14 +3833,22 @@ export class LocalBackend { p.entryPointId, requestedChainDepth, ); + assertResponseIdentities(chain); chainByProcessId.set(p.id, chain); } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:chain-bfs', e); 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 } @@ -3790,6 +3939,7 @@ export class LocalBackend { ); } } catch (err) { + rethrowSymbolIdentityError(err); // Best-effort diagnostic only — never fail the query over it. logQueryError('query:cjk-warning', err); } @@ -3832,6 +3982,7 @@ export class LocalBackend { ); } } catch (err) { + rethrowSymbolIdentityError(err); // loadMeta() itself never throws (it returns null on any read/parse // failure) — the actual throw source here is getSearchFTSCjkSegmentation() // on an invalid env value, same root cause as the catch above. This is @@ -3927,6 +4078,7 @@ export class LocalBackend { try { ({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js')); } catch (err: any) { + rethrowSymbolIdentityError(err); // Module import can fail in sandboxed MCP contexts (#1489) logger.warn( { err: err?.message }, @@ -3938,6 +4090,7 @@ export class LocalBackend { try { ftsResponse = await searchFTSFromLbug(query, limit, repo.lbugPath); } catch (err: any) { + rethrowSymbolIdentityError(err); // Swallowed, gracefully-degraded failure: the search falls back to // semantic-only (a valid result), and the most common cause is simply an // un-indexed FTS extension — a normal configuration, not an operation @@ -3961,6 +4114,8 @@ export class LocalBackend { for (const bm25Result of bm25Results) { const fullPath = bm25Result.filePath; + assertIdentityFields(fullPath); + for (const nodeId of bm25Result.nodeIds ?? []) assertSymbolIdentity(nodeId); try { // Prefer direct nodeId lookup (exact FTS-matched nodes) over filePath fallback. // Without this, LIMIT 3 on filePath returns arbitrary symbols rather than @@ -3996,6 +4151,7 @@ export class LocalBackend { if (symbols.length > 0) { for (const sym of symbols) { + assertSymbolRowIdentity(sym); results.push({ nodeId: sym.id || sym[0], name: sym.name || sym[1], @@ -4018,7 +4174,8 @@ export class LocalBackend { bm25Score: bm25Result.score, }); } - } catch { + } catch (err) { + rethrowSymbolIdentityError(err); const fileName = fullPath.split('/').pop() || fullPath; results.push({ name: fileName, @@ -4090,6 +4247,7 @@ export class LocalBackend { try { vectorReady = await ensureVectorExtension(repo.lbugPath); } catch (err) { + rethrowSymbolIdentityError(err); if (!this.warnedVectorLoadFailed) { this.warnedVectorLoadFailed = true; logger.warn( @@ -4122,6 +4280,7 @@ export class LocalBackend { `; const embResults = await executeQuery(repo.lbugPath, vectorQuery); + for (const row of embResults) assertQueryIdentity(row, 'nodeId', 0); return embResults.map((row) => ({ nodeId: row.nodeId ?? row[0], chunkIndex: row.chunkIndex ?? row[1] ?? 0, @@ -4131,6 +4290,7 @@ export class LocalBackend { })); }); } catch (err) { + rethrowSymbolIdentityError(err); bestChunks = new Map(); if (!this.warnedVectorQueryFailed) { // 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 `, ); + for (const row of rows) assertQueryIdentity(row, 'nodeId', 0); const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({ nodeId: row.nodeId ?? row[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` // into a property read on the function. for (const [nodeId, bestChunk] of Array.from(bestChunks.entries()).slice(0, limit)) { + assertSymbolIdentity(nodeId); const labelEndIdx = nodeId.indexOf(':'); const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown'; @@ -4201,6 +4363,10 @@ export class LocalBackend { const nodeRows = await executeParameterized(repo.lbugPath, nodeQuery, { nodeId }); if (nodeRows.length > 0) { const nodeRow = nodeRows[0]; + assertIdentityFields( + queryRowValue(nodeRow, 'name', 0), + queryRowValue(nodeRow, 'filePath', 1), + ); results.push({ nodeId, name: nodeRow.name ?? nodeRow[0] ?? '', @@ -4211,11 +4377,14 @@ export class LocalBackend { endLine: bestChunk.endLine, }); } - } catch {} + } catch (err) { + rethrowSymbolIdentityError(err); + } } return results; } catch (err) { + rethrowSymbolIdentityError(err); // Nothing was embedded on this path unless the throw happened after the // vector existed (a failed lookup downstream of a good embedding, where // 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; // Content retention matters only to the opt-in content response. Avoid a // 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 includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none'; @@ -5806,7 +5975,7 @@ export class LocalBackend { // a seeded test DB) falls through to the row-existence probe below. let pdgStamped: boolean | undefined; try { - const meta = await loadMeta(path.dirname(repo.lbugPath)); + const meta = await this.readToolMetadata(repo); if (meta) pdgStamped = meta.pdg?.taintModelVersion !== undefined; } catch { /* meta unreadable — decide from the DB below */ @@ -6124,7 +6293,9 @@ export class LocalBackend { // contract byte-for-byte: `false` ⇒ definitive no-layer (short-circuit // below), `true` ⇒ proceed, `undefined` ⇒ meta unreadable, defer to the // 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) { return { mode, results: [], total: 0, note: NO_PDG_NOTE }; } @@ -7574,6 +7745,7 @@ export class LocalBackend { if (mode === 'pdg') { layer = await pdgLayerStatus({ lbugPath: repo.lbugPath, + loadMetaFn: () => this.readToolMetadata(repo), executeParameterized, }); if (isPdgDegradedLayerStatus(layer)) { @@ -7815,10 +7987,8 @@ export class LocalBackend { * Fields the analyzer declined to link because every definition of the name * lives in another language (R3-1), keyed by name. * - * Cached per index version. `ensureInitialized` deliberately avoids a - * per-call `loadMeta` because every tool call routes through it; this is one - * small read per (index, indexedAt), which re-reads exactly when a re-analyze - * could have changed the answer and never otherwise. + * Cached per index version. The fresh stamp bound by `ensureInitialized` + * invalidates this derived map exactly when a re-analyze could have changed it. */ private readonly crossLanguagePropertyCache = new Map< string, @@ -7832,7 +8002,7 @@ export class LocalBackend { if (cached !== undefined && cached.indexedAt === repo.indexedAt) return cached.byName; const byName = new Map(); try { - const meta = await loadMeta(path.dirname(repo.lbugPath)); + const meta = await this.readToolMetadata(repo); for (const entry of meta?.crossLanguageProperties ?? []) { 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 // 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. - // ONE read of the index metadata for both probes below. They are the second - // and third consumers of this file on a path whose own comments call out - // avoiding a per-call `loadMeta` (see `ensureInitialized`), and the file is - // dominated by `fileHashes` — megabytes on a large repo. + // Share one metadata read between both probes; fileHashes can make this + // file large, so each probe should not read it separately. // `try`, not `.catch`: `loadMeta` can throw synchronously (a stubbed module // in tests, a mid-read unmount), and a probe failing must never read as // certainty — the whole point of this function. let meta: Awaited> | undefined; try { - meta = await loadMeta(path.dirname(repo.lbugPath)); + meta = await this.readToolMetadata(repo); } catch { meta = undefined; } @@ -9632,7 +9800,7 @@ export class LocalBackend { code: 'source-unavailable'; reason: 'content-retention' | 'checkout-missing'; } | null> { - const meta = await loadMeta(path.dirname(repo.lbugPath)); + const meta = await this.readToolMetadata(repo); const retention = contentRetentionFromMeta(meta); const checkoutIsDir = retention === 'full' ? await checkoutIsDirectory(repo.repoPath) : false; if (isFullSourceAvailable(retention, checkoutIsDir)) return null; diff --git a/gitnexus/src/mcp/local/query-result-integrity.ts b/gitnexus/src/mcp/local/query-result-integrity.ts index da92cca55..b5145b543 100644 --- a/gitnexus/src/mcp/local/query-result-integrity.ts +++ b/gitnexus/src/mcp/local/query-result-integrity.ts @@ -1,57 +1,2 @@ -/** Shared integrity boundary for identities returned by impact/context queries. */ -export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION = - 'Run gitnexus analyze --force from the affected repository root to rebuild the index.'; - -export class SymbolIdentityError extends Error { - constructor() { - super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION); - this.name = 'SymbolIdentityError'; - } -} - -/** Validate database identities before using them as graph traversal anchors. */ -export function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string { - if ( - typeof id !== 'string' || - !id.trim() || - id.includes('\0') || - (expectedUid !== undefined && id !== expectedUid) - ) { - throw new SymbolIdentityError(); - } -} - -/** Read either native row shape without turning an absent row into a TypeError. */ -export function queryRowValue(row: unknown, key: string, index: number): unknown { - if (typeof row !== 'object' || row === null) return undefined; - const value = row as Record; - return value[key] ?? value[index]; -} - -/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */ -export function assertIdentityFields(...values: unknown[]): void { - for (const value of values) { - if ( - value !== null && - value !== undefined && - (typeof value !== 'string' || value.includes('\0')) - ) { - throw new SymbolIdentityError(); - } - } -} - -export function assertQueryIdentity( - row: unknown, - idKey: string, - idIndex: number, - fields: ReadonlyArray = [], -): void { - assertSymbolIdentity(queryRowValue(row, idKey, idIndex)); - for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index)); -} - -/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */ -export function rethrowSymbolIdentityError(error: unknown): void { - if (error instanceof SymbolIdentityError) throw error; -} +/** Compatibility export; search and MCP must share the same error class. */ +export * from '../../core/lbug/query-result-integrity.js'; diff --git a/gitnexus/test/integration/fts-repair-warm-session.test.ts b/gitnexus/test/integration/fts-repair-warm-session.test.ts index d6e5a3ed6..2811601b3 100644 --- a/gitnexus/test/integration/fts-repair-warm-session.test.ts +++ b/gitnexus/test/integration/fts-repair-warm-session.test.ts @@ -24,13 +24,14 @@ import { saveMeta, type RepoMeta, } 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'; const REQUIRE_FTS = process.env.GITNEXUS_REQUIRE_FTS === '1'; type QueryResult = { error?: unknown; + staleness?: { lastCommit?: string; indexedAt?: string }; warning?: string; partial?: boolean; 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 - * the deadline passes. Exercises the real 5s staleness-check throttle - * (`ensureInitialized`) rather than sleeping-and-hoping or reaching into - * backend internals to bypass it — proves the fix holds within the actual - * production timing window. + * the deadline passes. Calls the public tool without bypassing freshness + * checks or mutating backend state. */ async function waitForFtsRecognized( backend: LocalBackend, query: string, - // Production throttle is 5s (`lastStalenessCheck`); this deadline leaves a - // generous margin beyond it for a loaded CI runner, per review feedback - // that the original 7s deadline left only ~2s of slack (#2767). + // Leave headroom for native extension work on loaded CI runners. timeoutMs = 15000, intervalMs = 300, ): Promise { @@ -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 // calls — real FTS build, then the #2767 capability-only meta stamp // (indexedAt/lastCommit deliberately unchanged, R4). + const beforeIdentity = await statDbIdentity(lbugPath); await adapter.initLbug(lbugPath); await createSearchFTSIndexes(); 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(after.partial).toBeUndefined(); 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 }); }, ); }); diff --git a/gitnexus/test/integration/incremental-method-integrity.test.ts b/gitnexus/test/integration/incremental-method-integrity.test.ts new file mode 100644 index 000000000..a8b3697d0 --- /dev/null +++ b/gitnexus/test/integration/incremental-method-integrity.test.ts @@ -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 => { + 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); + }, +); diff --git a/gitnexus/test/integration/lbug-incremental-search.test.ts b/gitnexus/test/integration/lbug-incremental-search.test.ts index ac94170be..40f8584bf 100644 --- a/gitnexus/test/integration/lbug-incremental-search.test.ts +++ b/gitnexus/test/integration/lbug-incremental-search.test.ts @@ -16,8 +16,8 @@ interface Check { indexes?: number; } -describe('native incremental search (#3421, #3423)', () => { - for (const scenario of ['context', 'property'] as const) { +describe('native incremental search (#3421, #3423, #3564)', () => { + for (const scenario of ['context', 'property', 'method'] as const) { it(`preserves retained ${scenario} strings and search through COPY, checkpoint and reopen`, () => { // Own process: isolate native lifetimes and allow old-engine diagnostic runs. const result = spawnSync(process.execPath, [reproducer, scenario], { @@ -36,10 +36,12 @@ describe('native incremental search (#3421, #3423)', () => { if (scenario === 'property') stages.push('repair'); for (const stage of stages) { 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']) { 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 { diff --git a/gitnexus/test/integration/mcp-index-generation.test.ts b/gitnexus/test/integration/mcp-index-generation.test.ts new file mode 100644 index 000000000..1d380a57c --- /dev/null +++ b/gitnexus/test/integration/mcp-index-generation.test.ts @@ -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>; + 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 { + 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): Promise { + 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((resolve) => { + release = resolve; + }); + const ready = new Promise((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); +}); diff --git a/gitnexus/test/integration/query-symbol-integrity.test.ts b/gitnexus/test/integration/query-symbol-integrity.test.ts new file mode 100644 index 000000000..6bc49ac23 --- /dev/null +++ b/gitnexus/test/integration/query-symbol-integrity.test.ts @@ -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>; + 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, legacy = false, target = repo): Promise { + 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); + }); +}); diff --git a/gitnexus/test/unit/bm25-search.test.ts b/gitnexus/test/unit/bm25-search.test.ts index 87b750fb7..af7f3e90b 100644 --- a/gitnexus/test/unit/bm25-search.test.ts +++ b/gitnexus/test/unit/bm25-search.test.ts @@ -192,7 +192,7 @@ describe('BM25 search', () => { 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'); vi.mocked(queryFTS) .mockResolvedValueOnce([]) // File @@ -205,10 +205,7 @@ describe('BM25 search', () => { .mockResolvedValueOnce([]) // Method .mockResolvedValueOnce([]); // Interface - const { results } = await searchFTSFromLbug('util'); - - expect(results).toHaveLength(1); - expect(results[0].nodeIds).toEqual([]); + await expect(searchFTSFromLbug('util')).rejects.toThrow('invalid symbol identity'); }); it('merges hits across multiple index tables for the same file', async () => { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 82e9728fa..0153bf790 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -3019,6 +3019,7 @@ describe('LocalBackend.callTool', () => { // Legacy tool aliases it('dispatches "search" as alias for query', async () => { + (executeQuery as any).mockResolvedValue([]); (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('search', { query: 'auth' }); 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 // rejection from a superseded entry must not drop the newer entry that - // replaced it after the TTL rolled over. - it('a late rejection does not evict the newer cache entry', async () => { - stubResolve(); - vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true }); - const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(1000); - const { checkStalenessAsync } = await import('../../src/core/git-staleness.js'); - let rejectFirst: (e: unknown) => void = () => {}; - const first = new Promise((_res, rej) => { - rejectFirst = rej; - }); - (checkStalenessAsync as any) - .mockReturnValueOnce(first) // entry 1 — held open, will reject late - .mockResolvedValue({ isStale: true, commitsBehind: 7, hint: '7 behind' }); // entry 2+ + // replaced it after TTL expiry or an index generation change. + it.each(['TTL expiry', 'new commit', 'same-commit rebuild'])( + 'a late rejection does not evict the newer cache entry after %s', + async (change) => { + const resolve = stubResolve(); + vi.spyOn(backend as any, 'query').mockResolvedValue({ ok: true }); + const dateSpy = vi.spyOn(Date, 'now').mockReturnValue(1000); + const { checkStalenessAsync } = await import('../../src/core/git-staleness.js'); + let rejectFirst: (e: unknown) => void = () => {}; + const first = new Promise((_res, rej) => { + rejectFirst = rej; + }); + (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 - await new Promise((r) => setTimeout(r, 0)); // entry1 installed, awaiting `first` + const p1 = backend.callTool('query', { search_query: 'x', repo: 'r' }); // installs entry1 @1000 + await new Promise((r) => setTimeout(r, 0)); // entry1 installed, awaiting `first` - dateSpy.mockReturnValue(1000 + 5000 + 1); // past TTL → next call installs entry2 - const r2 = await backend.callTool('query', { search_query: 'x', repo: 'r' }); - expect(r2).toMatchObject({ staleness: { commitsBehind: 7 } }); + if (change === 'TTL expiry') { + dateSpy.mockReturnValue(1000 + 5000 + 1); + } 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 - await p1.catch(() => {}); // p1 degrades to no-staleness + rejectFirst(new Error('late git failure')); // entry1's guarded catch must NOT evict entry2 + await p1.catch(() => {}); // p1 degrades to no-staleness - const callsBefore = (checkStalenessAsync as any).mock.calls.length; - 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(r3).toMatchObject({ staleness: { commitsBehind: 7 } }); - dateSpy.mockRestore(); - }); + const callsBefore = (checkStalenessAsync as any).mock.calls.length; + 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(r3).toMatchObject({ staleness: { commitsBehind: 7 } }); + dateSpy.mockRestore(); + }, + ); }); diff --git a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts index 6ae5835c6..162bb69e3 100644 --- a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts +++ b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts @@ -42,7 +42,18 @@ describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => { // Seed the "warm, already initialized" precondition ensureInitialized // requires to reach the staleness-check branch at all. 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 () => { @@ -60,8 +71,7 @@ describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => { expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBeUndefined(); expect(backend.lastObservedPoolState.get(poolKey)?.indexedAt).toBeUndefined(); - // Second staleness check (throttle reset again) with initLbug now succeeding. - backend.lastStalenessCheck.set(poolKey, 0); + // Second staleness check with initLbug now succeeding. initLbugMock.mockResolvedValueOnce(false); // "no real reopen needed" — still a completed call await expect(backend.ensureInitialized(repoHandle)).resolves.toBeUndefined(); @@ -92,7 +102,6 @@ describe('ensureInitialized ftsCapsChanged trigger, isolated from identityChange vi.clearAllMocks(); backend = new LocalBackend() as any; 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 () => { diff --git a/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts b/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts index a58963d31..c7a8a5937 100644 --- a/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts +++ b/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts @@ -208,6 +208,30 @@ describe('#3291 — tool staleness names the indexed ref', () => { 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 }, + '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`, // real `attachToolStaleness` — must still report a counted gap when the index // is behind its own checkout. Without this, the ref-carrying payload above diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 759afbc6f..9cd972a5e 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -76,6 +76,10 @@ export default defineConfig({ 'test/integration/impact-callable-value-references.test.ts', 'test/integration/impact-context-integrity.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-scope-omission-persistence.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-context-integrity.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-scope-omission-persistence.test.ts', 'test/integration/lbug-core-adapter.test.ts',