From cae60c2bd49ed9176544d0d1a0810db6a552f2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 14 Oct 2026 17:06:26 +0300 Subject: [PATCH] fix(mcp): reject corrupt query identities and refresh index metadata (#3571) * fix(mcp): reject corrupt query identities and refresh index metadata * fix(mcp): preserve reader and search integrity Keep replaced readers unknown until matching metadata is published. Defer shared-handle refresh until other consumers release the old graph to avoid rebuilding connections against the same stale generation. Propagate identity corruption after both hybrid search lanes settle, so the sibling cannot query after its database session is released. Select the regressions for the OS matrix and retain Python coverage without optional Swift grammars. Validation: 393 focused tests, nine native generation tests and ten race executions across five fresh processes pass. Build and source/test typechecks pass. The early full run passed 26,096 tests and failed eight: seven reproduce on baseline; the mixed-source publication case passes in clean reruns. Windows/macOS native execution remains for CI. Graph analysis maps all edited production symbols; coverage remains partial for the anonymous FTS test callback, manually reviewed. Refs #3571 * fix(lbug): bump @ladybugdb/core to 0.21.2 to stop doubled rows after delete and re-copy LadybugDB 0.21.0 and 0.21.1 return every row of a table that spans more than one native vector twice once the incremental writeback has DETACH DELETEd it and a COPY into an earlier table has run. NodeGroup::scan let a morsel that ended before the group's first live row wrap an unsigned subtraction and rescan to the end of the group (LadybugDB #1085, fixed in 0.21.2). No primary key error fires. The identity guard from #3442 stops the run with "unexpected or duplicate ID", so a Swift edit that pulls every importer into the write set failed on repos with more than 2048 structs. Only unreleased main was affected: 1.6.12 ships 0.18.3. Pin 0.21.2 in package.json, update the six @ladybugdb/core entries in the lockfile, and move coreVersion in the vendored FTS manifest. The FTS extension stays at 0.21.0 because no v0.21.1 or v0.21.2 artifact is published; the pairing assertion and its unit tests pass. Add a regression to the delete-nodes-for-files suite: 2049 structs with CONTAINS edges are loaded, every file is deleted, the graph is loaded again, and each id and edge must appear exactly once. Verification (Linux, Node 22.18): - lbug-delete-nodes-for-files: 11/11 on 0.21.2. With the 0.21.1 native packages swapped in, the new test fails with 4098 rows instead of 2049. - Reporter recipe end to end on the built CLI: incremental analyze exits 0 with no duplicate ids on a Swift-heavy fixture and on a 70-file Python fixture, with and without FTS. On 0.21.1 the Swift fixture exits 1. - A 0.21.1-written database opens and reads under 0.21.2. - prettier, eslint and typecheck:tests are clean on the changed files. - The full lbug-db project had not finished when this was committed. Co-Authored-By: Claude Sonnet 5.5 * test(fts): say what the Windows warm-session branch covers On Windows the repair step calls poolClose, which removes the pool entry before the in-place writer opens. The step therefore checks the backend's cached capability state, not a live pool connection. The comment now says so instead of implying the pool stays warm. No behavior change. Co-Authored-By: Claude Sonnet 5.5 * revert: take the @ladybugdb/core 0.21.2 bump out of this PR Reverts 0cdc3dfe3. The bump fixes LadybugDB 0.21.0 and 0.21.1 returning every row of a large table twice after a delete and re-copy, but on Windows it crashes the vendored FTS extension, which is still the v0.21.0 build: four of eight Windows shards failed with exit code 3221225477 (0xC0000005) in tests that load libfts and build an FTS index. All eight passed on 9d79092ab with core 0.21.1. A Windows extension is only published for v0.21.0, not for v0.21.1 or v0.21.2. The bump moves to its own draft PR (#3578) until a matching Windows extension exists. This PR returns to its original query-integrity and index-freshness scope. Restores gitnexus/package.json, the six @ladybugdb/core entries in package-lock.json, the coreVersion in vendor/lbug-fts/manifest.json, and removes the regression test that belongs with the bump. All four files are identical to origin/main (1b4e3f0a8) after this commit. Verification: - git diff origin/main for the four files is empty. - fts-artifact-coverage and vendored-extension-path unit tests pass (31), and assert-publish-fts-coverage reports core 0.21.1 paired with extension 0.21.0. - The graph check on the staged revert was partial, because it only deletes test code. The forward change was gated clean: 4 files, 6 symbols, all in that test, no affected processes. - Windows CI has not run on this commit. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Sonnet 5.5 --- RUNBOOK.md | 14 + .../reproduce-search.cjs | 56 +- gitnexus/scripts/cross-platform-tests.ts | 7 + gitnexus/src/core/group/service.ts | 47 +- gitnexus/src/core/lbug/pool-adapter.ts | 20 +- .../src/core/lbug/query-result-integrity.ts | 57 ++ gitnexus/src/core/search/bm25-index.ts | 12 +- gitnexus/src/core/search/hybrid-search.ts | 17 +- gitnexus/src/mcp/local/local-backend.ts | 436 ++++++++++----- .../src/mcp/local/query-result-integrity.ts | 59 +-- .../fts-repair-warm-session.test.ts | 28 +- .../incremental-method-integrity.test.ts | 192 +++++++ .../lbug-incremental-search.test.ts | 10 +- .../integration/lbug-pool-stability.test.ts | 70 +++ .../integration/mcp-index-generation.test.ts | 498 ++++++++++++++++++ .../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 +- gitnexus/test/unit/hybrid-search.test.ts | 68 +++ gitnexus/test/unit/lbug-pool-pinning.test.ts | 51 +- .../repro-3291-staleness-indexed-ref.test.ts | 24 + gitnexus/vitest.config.ts | 8 + 23 files changed, 1739 insertions(+), 267 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/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index f04008b33..578672e15 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -188,6 +188,13 @@ const LBUG_NATIVE = [ 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-interrupted-checkpoint-recovery.test.ts', 'test/integration/lbug-incremental-search.test.ts', + // Retained identities and reader metadata must agree on every native platform. + '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/unit/lbug-pool-pinning.test.ts', + 'test/unit/hybrid-search.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', // Cross-repo trace e2e: builds two real lbug indexes + a real bridge and 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..a0abd9c2e 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 @@ -811,6 +817,14 @@ const initLbugInner = async (repoId: string, dbPath: string): Promise 0) return { status: 'done', reopened: false }; + // Closing only this consumer would leave the shared native handle alive + // and doInitLbug would attach eight new connections to that same old + // generation. Keep this pool intact until the other consumers release it; + // its opened identity stays divergent so the next init can retry then. + const shared = dbCache.get(existing.dbPath); + if (shared && shared.db === existing.db && shared.refCount > 1) { + return { status: 'done', reopened: false }; + } // Awaited: see the rationale on the evictLRU call site in doInitLbug below. await closeOne(repoId); // idle & changed → evict, then fall through to reopen the new file } @@ -946,9 +960,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/core/search/hybrid-search.ts b/gitnexus/src/core/search/hybrid-search.ts index 9b76101ac..e921d4a6d 100644 --- a/gitnexus/src/core/search/hybrid-search.ts +++ b/gitnexus/src/core/search/hybrid-search.ts @@ -11,6 +11,7 @@ import { searchFTSFromLbug, type BM25SearchResult } from './bm25-index.js'; import type { SemanticSearchResult } from '../embeddings/types.js'; import type { FtsDisabledReason } from './fts-policy.js'; +import { rethrowSymbolIdentityError } from '../lbug/query-result-integrity.js'; /** * RRF constant - standard value used in the literature @@ -177,16 +178,24 @@ export const hybridSearch = async ( // index recorded an explicit FTS opt-out (`disabledReason`, #3091). // If FTS fails (e.g. extension not loaded in MCP process), fall back to // semantic-only search instead of crashing with "bm25Results is not iterable". - const [bm25Results, semanticResults] = await Promise.all([ + // Both lanes share the caller's database session. Drain the sibling before + // propagating corruption so it cannot query after that session is released. + const [keyword, semantic] = await Promise.allSettled([ (async (): Promise => { try { const ftsResponse = await searchFTSFromLbug(query, limit, undefined, disabledReason); return ftsResponse?.results ?? []; - } catch { + } catch (error) { + rethrowSymbolIdentityError(error); return []; } })(), - semanticSearch(executeQuery, query, limit).catch(() => []), + semanticSearch(executeQuery, query, limit).catch((error) => { + rethrowSymbolIdentityError(error); + return []; + }), ]); - return mergeWithRRF(bm25Results, semanticResults, limit); + if (keyword.status === 'rejected') throw keyword.reason; + if (semantic.status === 'rejected') throw semantic.reason; + return mergeWithRRF(keyword.value, semantic.value, limit); }; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 4997012f2..7f3563577 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,118 @@ 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); + // The path can change after stat and before initLbug opens it. Track + // replacement against the reader we actually opened, not that snapshot. + // Same-inode auxiliary changes (FTS repair) do not replace the graph; + // in-place graph writes advertise incrementalInProgress below. + const graphReplaced = + !!previous?.dbIdentity && + !!openedIdentity && + previous.dbIdentity.ino !== openedIdentity.ino; + const publicationPending = + !!previous && + !stampChanged && + (previous.awaitingMetadata === true || + (graphReplaced && previous.ref !== undefined && !previous.generationUnknown)); + 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; + } + // A mismatched reader can be older than the disk or have opened a + // replacement after our first stat. Only the latter must wait for a + // stamp newer than the metadata we just observed, even if that stamp + // already differed from the previous reader's. Old pinned readers + // can recover against the existing disk metadata after they reopen. + const currentIdentity = await statDbIdentity(poolKey); + const openedAfterStat = + !currentIdentity || !dbIdentityChanged(openedIdentity, currentIdentity); + this.setObservedState(poolKey, { + indexedAt: meta?.indexedAt ?? previous?.indexedAt ?? repo.indexedAt, + dbIdentity: openedIdentity, + ref: { lastCommit: '', indexedAt: '' }, + generationUnknown: true, + awaitingMetadata: publicationPending || openedAfterStat, + 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, + }; + 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 +2974,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 +3010,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 +3027,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 +3273,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 +3335,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 +3387,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 +3404,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 +3488,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 +3526,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 +3536,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:cluster-info', e); if (!isBenignMissingTableError(e)) enrichmentDegraded = true; } @@ -3417,10 +3554,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 +3673,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 +3695,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:route-definition-bridge', e); if (!isBenignMissingTableError(e)) enrichmentDegraded = true; } @@ -3644,6 +3791,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 +3812,7 @@ export class LocalBackend { } } } catch (e) { + rethrowSymbolIdentityError(e); logQueryError('query:route-lookup', e); if (!isBenignMissingTableError(e)) enrichmentDegraded = true; } @@ -3677,11 +3829,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 +3845,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 +3951,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 +3994,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 +4090,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 +4102,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 +4126,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 +4163,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 +4186,8 @@ export class LocalBackend { bm25Score: bm25Result.score, }); } - } catch { + } catch (err) { + rethrowSymbolIdentityError(err); const fileName = fullPath.split('/').pop() || fullPath; results.push({ name: fileName, @@ -4090,6 +4259,7 @@ export class LocalBackend { try { vectorReady = await ensureVectorExtension(repo.lbugPath); } catch (err) { + rethrowSymbolIdentityError(err); if (!this.warnedVectorLoadFailed) { this.warnedVectorLoadFailed = true; logger.warn( @@ -4122,6 +4292,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 +4302,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 +4330,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 +4359,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 +4375,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 +4389,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 +5154,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 +5987,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 +6305,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 +7757,7 @@ export class LocalBackend { if (mode === 'pdg') { layer = await pdgLayerStatus({ lbugPath: repo.lbugPath, + loadMetaFn: () => this.readToolMetadata(repo), executeParameterized, }); if (isPdgDegradedLayerStatus(layer)) { @@ -7815,10 +7999,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 +8014,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 +8045,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 +9812,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..9122a8bd1 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,13 @@ 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); + // Windows requires releasing the native file lock before an in-place + // writer opens, so poolClose removes the pool entry there. Keep the same + // backend and its observed generation: on Windows this step checks the + // backend's cached capability state, not a live pool connection. The + // live connection is covered where the pool stays open across the repair. + if (process.platform === 'win32') await poolClose(lbugPath); await adapter.initLbug(lbugPath); await createSearchFTSIndexes(); await adapter.flushWAL(); @@ -178,6 +182,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..1143789ab --- /dev/null +++ b/gitnexus/test/integration/incremental-method-integrity.test.ts @@ -0,0 +1,192 @@ +/** + * 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 swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); +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('Python Method identities across selective incremental writes with an optional Swift control (#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'); + if (swiftAvailable) 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); + if (swiftAvailable) + 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/lbug-pool-stability.test.ts b/gitnexus/test/integration/lbug-pool-stability.test.ts index 2762b67c8..2cd8cc61c 100644 --- a/gitnexus/test/integration/lbug-pool-stability.test.ts +++ b/gitnexus/test/integration/lbug-pool-stability.test.ts @@ -16,6 +16,10 @@ * stay well under the limit. */ import { it, expect, afterAll } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import lbug from '@ladybugdb/core'; import { initLbug, executeQuery, @@ -24,6 +28,8 @@ import { isLbugReady, } from '../../src/mcp/core/lbug-adapter.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; +import { createLbugDatabase } from '../../src/core/lbug/lbug-config.js'; +import { closeQueryResults } from '../../src/core/lbug/query-result-utils.js'; const SEED_DATA = [ `CREATE (f:File {id: 'file:index.ts', name: 'index.ts', filePath: 'src/index.ts', content: ''})`, @@ -178,3 +184,67 @@ withTestLbugDB( poolAdapter: true, }, ); + +// Windows cannot atomically replace a file held by a native reader. The same +// shared-consumer refresh logic has portable coverage in lbug-pool-pinning. +it.skipIf(process.platform === 'win32')( + 'keeps shared readers on one native generation until the other consumer releases it', + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-shared-refresh-')); + const dbPath = path.join(root, 'lbug'); + const nextPath = path.join(root, 'next-lbug'); + const reader = 'generation-reader'; + const holder = 'generation-holder'; + + async function seed(dbFile: string, name: string): Promise { + const db = createLbugDatabase(lbug, dbFile); + let conn: lbug.Connection | undefined; + try { + await db.init(); + conn = new lbug.Connection(db); + for (const query of [ + 'CREATE NODE TABLE Entry(name STRING, PRIMARY KEY(name))', + `CREATE (:Entry {name: '${name}'})`, + 'CHECKPOINT', + ]) { + const result = await conn.query(query); + try { + for (const item of Array.isArray(result) ? result : [result]) { + await item.getAll(); + } + } finally { + await closeQueryResults(result); + } + } + } finally { + await conn?.close(); + await db.close(); + } + } + + const read = (repoId: string) => executeQuery(repoId, 'MATCH (n:Entry) RETURN n.name AS name'); + try { + await seed(dbPath, 'before'); + await seed(nextPath, 'after'); + await initLbug(reader, dbPath); + await initLbug(holder, dbPath); + expect(await read(reader)).toEqual([{ name: 'before' }]); + await fs.rename(nextPath, dbPath); + + for (let i = 0; i < 3; i++) { + expect(await initLbug(reader, dbPath)).toBe(false); + expect(await read(reader)).toEqual([{ name: 'before' }]); + expect(await read(holder)).toEqual([{ name: 'before' }]); + } + await closeLbug(holder); + expect(await initLbug(reader, dbPath)).toBe(true); + expect(await read(reader)).toEqual([{ name: 'after' }]); + expect(await initLbug(reader, dbPath)).toBe(false); + } finally { + await closeLbug(holder); + await closeLbug(reader); + await fs.rm(root, { recursive: true, force: true }); + } + }, + 120_000, +); 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..643da2f56 --- /dev/null +++ b/gitnexus/test/integration/mcp-index-generation.test.ts @@ -0,0 +1,498 @@ +/** 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, + initLbug as initReader, +} 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: process.platform !== 'win32', +}; + +describe('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 analyze(); + 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 analyze(target = repo, overrides: Parameters[1] = {}) { + // Release Windows file locks for in-place writes while retaining the + // backend's observed generation and metadata caches between requests. + if (process.platform === 'win32') await backend.dispose(); + return runFullAnalysis(target, { ...options, ...overrides }, { onProgress: () => {} }); + } + + 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 analyze(); + 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 analyze(repo, { force: true }); + 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.skipIf(process.platform === 'win32')( + '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 analyze(); + 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.skipIf(process.platform === 'win32')( + '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 analyze(); + 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.skipIf(process.platform === 'win32').each([false, true])( + 'keeps a replacement opened after stat unknown until publication (intermediate stamp: %s)', + async (intermediateStamp) => { + const { storagePath, lbugPath } = getStoragePaths(repo); + const previous = (await loadMeta(storagePath))!; + await read(); + // Idle eviction closes the pool but leaves the backend's observed generation. + await closeReader(lbugPath); + if (intermediateStamp) { + // Generation B is published without this backend ever opening it. + await adapter.initLbug(lbugPath); + try { + await adapter.executeQuery( + "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' SET n.name = 'intermediate_generation'", + ); + } finally { + await adapter.closeLbug(); + } + await saveMeta(storagePath, { ...previous, indexedAt: new Date().toISOString() }); + } + const replacement = path.join(temp.dbPath, 'replacement.lbug'); + await fs.copyFile(lbugPath, replacement); + await adapter.initLbug(replacement); + try { + await adapter.executeQuery( + "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' SET n.name = 'reopened_after_stat'", + ); + } finally { + await adapter.closeLbug(); + } + const published = { ...previous, indexedAt: new Date().toISOString() }; + const originalStat = fs.stat; + let swapped = false; + const statSpy = vi.spyOn(fs, 'stat').mockImplementation(async (...args) => { + const snapshot = await originalStat(...args); + if (!swapped && args[0] === lbugPath) { + swapped = true; + // The stat describes the published graph; open sees its replacement. + await fs.rename(replacement, lbugPath); + } + return snapshot; + }); + try { + const first = await read(); + expect(swapped).toBe(true); + expect(JSON.stringify(first)).toContain('reopened_after_stat'); + expect(first.staleness).toMatchObject({ status: 'unknown', lastCommit: '', indexedAt: '' }); + statSpy.mockRestore(); + // Repeated requests do not establish publication by themselves. + for (let attempt = 0; attempt < 2; attempt++) { + const pending = await read(); + expect(JSON.stringify(pending)).toContain('reopened_after_stat'); + expect(pending.staleness).toMatchObject({ + status: 'unknown', + lastCommit: '', + indexedAt: '', + }); + } + } finally { + statSpy.mockRestore(); + await saveMeta(storagePath, published); + } + expect((await read()).staleness).toMatchObject({ + status: 'current', + lastCommit: published.lastCommit, + indexedAt: published.indexedAt, + }); + }, + 120_000, + ); + + it.skipIf(process.platform === 'win32')( + 'recovers an unknown old shared reader using already-published metadata', + async () => { + const { storagePath, lbugPath } = getStoragePaths(repo); + const previous = (await loadMeta(storagePath))!; + await read(); + await closeReader(lbugPath); + await adapter.initLbug(lbugPath); + try { + await adapter.executeQuery( + "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' SET n.name = 'held_generation'", + ); + } finally { + await adapter.closeLbug(); + } + const holder = 'old-shared-generation'; + const replacement = path.join(temp.dbPath, 'shared-replacement.lbug'); + await initReader(holder, lbugPath); + try { + await fs.copyFile(lbugPath, replacement); + await adapter.initLbug(replacement); + try { + await adapter.executeQuery( + "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' SET n.name = 'published_generation'", + ); + } finally { + await adapter.closeLbug(); + } + await fs.rename(replacement, lbugPath); + const published = { ...previous, indexedAt: new Date().toISOString() }; + await saveMeta(storagePath, published); + const held = await read(); + expect(JSON.stringify(held)).toContain('held_generation'); + expect(held.staleness).toMatchObject({ status: 'unknown', lastCommit: '', indexedAt: '' }); + await closeReader(holder); + const recovered = await read(); + expect(JSON.stringify(recovered)).toContain('published_generation'); + expect(recovered.staleness).toMatchObject({ + status: 'current', + lastCommit: published.lastCommit, + indexedAt: published.indexedAt, + }); + } finally { + await closeReader(holder); + } + }, + 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 analyze(repo, { branch: 'generation-feature' }); + 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 analyze(); + 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 analyze(sharedRepo); + const storagePath = getStoragePaths(sharedRepo).storagePath; + const previous = (await loadMeta(storagePath))!; + expect(previous.graphPath).toBeTruthy(); + await analyze(sibling); + 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 analyze(sharedRepo); + 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/hybrid-search.test.ts b/gitnexus/test/unit/hybrid-search.test.ts index 771296bc1..53e64694a 100644 --- a/gitnexus/test/unit/hybrid-search.test.ts +++ b/gitnexus/test/unit/hybrid-search.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi } from 'vitest'; import { mergeWithRRF, hybridSearch } from '../../src/core/search/hybrid-search.js'; import type { BM25SearchResult } from '../../src/core/search/bm25-index.js'; import type { SemanticSearchResult } from '../../src/core/embeddings/types.js'; +import { SymbolIdentityError } from '../../src/core/lbug/query-result-integrity.js'; vi.mock('../../src/core/search/bm25-index.js', async (importOriginal) => { const actual = (await importOriginal()) as any; @@ -158,6 +159,73 @@ describe('mergeWithRRF', () => { // Regression: #1489 — hybridSearch must not crash when FTS is unavailable describe('hybridSearch — FTS failure fallback (#1489)', () => { + it.each(['keyword', 'semantic'] as const)( + 'waits for the sibling lane before rejecting %s identity corruption', + async (failingLane) => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + const error = new SymbolIdentityError(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const semantic = vi.fn(async () => { + await gate; + return []; + }); + if (failingLane === 'keyword') { + vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(error); + } else { + vi.mocked(searchFTSFromLbug).mockImplementationOnce(async () => { + await gate; + return { results: [], ftsAvailable: true }; + }); + semantic.mockRejectedValueOnce(error); + } + let settled = false; + const outcome = hybridSearch('test query', 10, vi.fn().mockResolvedValue([]), semantic).then( + () => { + settled = true; + return null; + }, + (failure) => { + settled = true; + return failure; + }, + ); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + } finally { + release(); + } + expect(await outcome).toBe(error); + }, + ); + it.each(['keyword', 'semantic'] as const)( + 'rejects corrupt %s identities even when the other search lane succeeds', + async (lane) => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + const error = new SymbolIdentityError(); + const mockExecuteQuery = vi.fn().mockResolvedValue([]); + const mockSemanticSearch = vi.fn(); + + if (lane === 'keyword') { + vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(error); + mockSemanticSearch.mockResolvedValue([makeSemantic('src/semantic-hit.ts', 0.15)]); + } else { + vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ + results: [{ filePath: 'src/fts-hit.ts', score: 5, rank: 1 }], + ftsAvailable: true, + }); + mockSemanticSearch.mockRejectedValue(error); + } + + await expect( + hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch), + ).rejects.toBe(error); + }, + ); + it('falls back to semantic-only when searchFTSFromLbug throws', async () => { const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable')); diff --git a/gitnexus/test/unit/lbug-pool-pinning.test.ts b/gitnexus/test/unit/lbug-pool-pinning.test.ts index 8276b136a..388a69d3b 100644 --- a/gitnexus/test/unit/lbug-pool-pinning.test.ts +++ b/gitnexus/test/unit/lbug-pool-pinning.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import lbug from '@ladybugdb/core'; // Drive the REAL initLbug path (which calls evictLRU) rather than // initLbugWithDb (which bypasses eviction entirely). The native LadybugDB @@ -81,7 +82,7 @@ vi.mock('../../src/core/lbug/sidecar-recovery.js', () => ({ }, })); -const { initLbug, initLbugWithDb, closeLbug, isLbugReady, pinRepo, unpinRepo } = +const { initLbug, initLbugWithDb, closeLbug, getLbugDbIdentity, isLbugReady, pinRepo, unpinRepo } = await import('../../src/core/lbug/pool-adapter.js'); const { createLbugDatabase } = await import('../../src/core/lbug/lbug-config.js'); const { initWikiDb, closeWikiDb, pinWikiDb } = await import('../../src/core/wiki/graph-queries.js'); @@ -311,4 +312,52 @@ describe('pool-adapter repo pinning (issue #2189)', () => { await injecting; expect(isLbugReady('injected-overlap')).toBe(true); }); + + it('defers shared-handle refresh without connection churn until the other consumer releases it', async () => { + const dbPath = dbPathFor('shared-refresh'); + await initLbug('refresh-reader', dbPath); + await initLbug('refresh-holder', dbPath); + const openedIdentity = getLbugDbIdentity('refresh-reader'); + const database = vi.mocked(createLbugDatabase).mock.results.at(-1)?.value; + expect(database).toBeDefined(); + const connectionCount = vi.mocked(lbug.Connection).mock.calls.length; + const connections = vi.mocked(lbug.Connection).mock.results.map(({ value }) => value); + const closeCounts = connections.map((conn) => conn.close.mock.calls.length); + + // Real file stat changes on every OS; the mocked native handle remains on + // the opened generation without relying on POSIX replacement semantics. + writeFileSync(dbPath, 'new generation'); + for (let i = 0; i < 3; i++) { + expect(await initLbug('refresh-reader', dbPath)).toBe(false); + expect(getLbugDbIdentity('refresh-reader')).toEqual(openedIdentity); + expect(isLbugReady('refresh-holder')).toBe(true); + } + expect(vi.mocked(lbug.Connection).mock.calls).toHaveLength(connectionCount); + expect(connections.map((conn) => conn.close.mock.calls.length)).toEqual(closeCounts); + expect(database.close).not.toHaveBeenCalled(); + + await closeLbug('refresh-holder'); + expect(database.close).not.toHaveBeenCalled(); + expect(await initLbug('refresh-reader', dbPath)).toBe(true); + expect(database.close).toHaveBeenCalledTimes(1); + expect(getLbugDbIdentity('refresh-reader')).not.toEqual(openedIdentity); + expect(await initLbug('refresh-reader', dbPath)).toBe(false); + }); + + it('keeps externally owned databases untracked and open across file changes', async () => { + const dbPath = dbPathFor('external-refresh'); + const externalDb = { + init: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }; + await initLbugWithDb('external-reader', externalDb as never, dbPath); + await initLbug('external-holder', dbPath); + writeFileSync(dbPath, 'new generation'); + expect(await initLbug('external-reader', dbPath)).toBe(false); + expect(getLbugDbIdentity('external-reader')).toBeNull(); + expect(getLbugDbIdentity('external-holder')).toBeNull(); + await closeLbug('external-holder'); + await closeLbug('external-reader'); + expect(externalDb.close).not.toHaveBeenCalled(); + }); }); 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',