mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-16 04:28:11 +00:00
fix(mcp): reject corrupt query identities and refresh index metadata
This commit is contained in:
parent
9572e71127
commit
9d79092abc
18 changed files with 1366 additions and 262 deletions
14
RUNBOOK.md
14
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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>;
|
||||
process_symbols?: Array<Record<string, unknown>>;
|
||||
error?: string;
|
||||
recoverySuggestion?: string;
|
||||
};
|
||||
if (
|
||||
queryResult.error &&
|
||||
queryResult.recoverySuggestion === SYMBOL_IDENTITY_RECOVERY_SUGGESTION
|
||||
) {
|
||||
return {
|
||||
repo: repoPath,
|
||||
score: 0,
|
||||
processes: [] as unknown[],
|
||||
integrityError: queryResult.error,
|
||||
};
|
||||
}
|
||||
const processes = servicePrefix
|
||||
? 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<Record<string, unknown>>);
|
||||
allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number));
|
||||
const topN = allProcesses.slice(0, limit);
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ export function dbIdentityChanged(prev: DbIdentity | null, next: DbIdentity | nu
|
|||
|
||||
const pool = new Map<string, PoolEntry>();
|
||||
|
||||
/** Identity of the native database currently serving this pool, not the path's latest file. */
|
||||
export function getLbugDbIdentity(repoId: string): DbIdentity | null {
|
||||
const identity = pool.get(repoId)?.dbIdentity;
|
||||
return identity ? { ...identity } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listeners notified when a pool entry is torn down (LRU eviction, idle
|
||||
* timeout, explicit close). Used by upper layers (e.g. the BM25 search
|
||||
|
|
@ -946,9 +952,9 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<InitLbugAttem
|
|||
// Register pool entry only after all connections are pre-warmed and FTS is
|
||||
// loaded. Concurrent executeQuery calls see either "not initialized"
|
||||
// (and throw cleanly) or a fully ready pool — never a half-built one.
|
||||
// Record the on-disk identity so a later initLbug can detect an analyze
|
||||
// rebuild/mutation and re-open onto the new file (pool staleness invalidation).
|
||||
const dbIdentity = await statDbIdentity(dbPath);
|
||||
// Keep the opened SharedDB identity: another consumer may still hold an
|
||||
// older native handle after the file at dbPath has been replaced.
|
||||
const dbIdentity = shared.dbIdentity ?? null;
|
||||
pool.set(repoId, {
|
||||
db,
|
||||
available,
|
||||
|
|
|
|||
57
gitnexus/src/core/lbug/query-result-integrity.ts
Normal file
57
gitnexus/src/core/lbug/query-result-integrity.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/** Shared integrity boundary for identities returned by graph and search queries. */
|
||||
export const SYMBOL_IDENTITY_RECOVERY_SUGGESTION =
|
||||
'Run gitnexus analyze --force from the affected repository root to rebuild the index.';
|
||||
|
||||
export class SymbolIdentityError extends Error {
|
||||
constructor() {
|
||||
super('The index returned an invalid symbol identity. ' + SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
|
||||
this.name = 'SymbolIdentityError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate database identities before using them as graph traversal anchors. */
|
||||
export function assertSymbolIdentity(id: unknown, expectedUid?: string): asserts id is string {
|
||||
if (
|
||||
typeof id !== 'string' ||
|
||||
!id.trim() ||
|
||||
id.includes('\0') ||
|
||||
(expectedUid !== undefined && id !== expectedUid)
|
||||
) {
|
||||
throw new SymbolIdentityError();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read either native row shape without turning an absent row into a TypeError. */
|
||||
export function queryRowValue(row: unknown, key: string, index: number): unknown {
|
||||
if (typeof row !== 'object' || row === null) return undefined;
|
||||
const value = row as Record<string, unknown>;
|
||||
return value[key] ?? value[index];
|
||||
}
|
||||
|
||||
/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */
|
||||
export function assertIdentityFields(...values: unknown[]): void {
|
||||
for (const value of values) {
|
||||
if (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
(typeof value !== 'string' || value.includes('\0'))
|
||||
) {
|
||||
throw new SymbolIdentityError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertQueryIdentity(
|
||||
row: unknown,
|
||||
idKey: string,
|
||||
idIndex: number,
|
||||
fields: ReadonlyArray<readonly [string, number]> = [],
|
||||
): void {
|
||||
assertSymbolIdentity(queryRowValue(row, idKey, idIndex));
|
||||
for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index));
|
||||
}
|
||||
|
||||
/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */
|
||||
export function rethrowSymbolIdentityError(error: unknown): void {
|
||||
if (error instanceof SymbolIdentityError) throw error;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof statDbIdentity>>;
|
||||
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<ReturnType<typeof statDbIdentity>>;
|
||||
ftsStatus?: string;
|
||||
ref?: IndexedRef;
|
||||
generationUnknown?: boolean;
|
||||
awaitingMetadata?: boolean;
|
||||
diskIndexedAt?: string;
|
||||
metadata?: Awaited<ReturnType<typeof loadMeta>>;
|
||||
}
|
||||
|
||||
export class LocalBackend {
|
||||
// A selected handle belongs to one request. Reuse its metadata across
|
||||
// freshness, retention and completeness checks without retaining it forever.
|
||||
private readonly toolMetadata = new WeakMap<
|
||||
RepoHandle,
|
||||
Promise<Awaited<ReturnType<typeof loadMeta>>>
|
||||
>();
|
||||
|
||||
private readToolMetadata(repo: RepoHandle): Promise<Awaited<ReturnType<typeof loadMeta>>> {
|
||||
let pending = this.toolMetadata.get(repo);
|
||||
if (!pending) {
|
||||
pending = loadOptionalMeta(path.dirname(repo.lbugPath)).then((meta) => {
|
||||
if (!meta) return null;
|
||||
// Readers never use ingestion manifests. Do not keep their per-file
|
||||
// hashes/cache keys or pending embedding IDs in an opened snapshot.
|
||||
return {
|
||||
...meta,
|
||||
fileHashes: undefined,
|
||||
cacheKeys: undefined,
|
||||
embeddingCheckpoint: undefined,
|
||||
};
|
||||
});
|
||||
this.toolMetadata.set(repo, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
private static readonly TOOL_STALENESS_TTL_MS = 5000;
|
||||
private repos: Map<string, RepoHandle> = new Map();
|
||||
private contextCache: Map<string, CodebaseContext> = new Map();
|
||||
private initializedRepos: Set<string> = new Set();
|
||||
private reinitPromises: Map<string, Promise<void>> = new Map();
|
||||
private lastStalenessCheck: Map<string, number> = new Map();
|
||||
// #2655: commit-behind freshness for the hot read tools. Stores the IN-FLIGHT
|
||||
// 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<string, { at: number; value: Promise<StalenessInfo> }> =
|
||||
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<StalenessInfo> }
|
||||
> = new Map();
|
||||
// tri-review Residual-2: consolidates what were three parallel per-poolKey
|
||||
// Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus)
|
||||
// touched in lockstep at every call site below — one Map, one delete, one
|
||||
|
|
@ -1903,7 +1949,9 @@ export class LocalBackend {
|
|||
// Meta probe: layer present iff the flows cap is stamped. `false` is a
|
||||
// definitive absence (degrade to call-level); `undefined` is unreadable
|
||||
// meta (fall through and infer presence from rows found).
|
||||
const pdgStamped = await pdgStampForMode(repo.lbugPath, 'flows');
|
||||
const pdgStamped = await pdgStampForMode(repo.lbugPath, 'flows', () =>
|
||||
this.readToolMetadata(repo),
|
||||
);
|
||||
if (pdgStamped === false) return { available: false, hops: [] };
|
||||
|
||||
// Resolve the anchor symbol (UID is precise; fall back to name/file).
|
||||
|
|
@ -2077,7 +2125,6 @@ export class LocalBackend {
|
|||
for (const key of knownKeys) {
|
||||
if (liveLbugPaths.has(key)) continue;
|
||||
this.initializedRepos.delete(key);
|
||||
this.lastStalenessCheck.delete(key);
|
||||
this.toolStalenessCache.delete(key);
|
||||
this.lastObservedPoolState.delete(key);
|
||||
this.reinitPromises.delete(key);
|
||||
|
|
@ -2226,7 +2273,24 @@ export class LocalBackend {
|
|||
this.maybeWarnSiblingDrift(result).catch(() => {
|
||||
/* best-effort; never throw from resolveRepo */
|
||||
});
|
||||
return this.applyBranchScope(this.followSharedGraph(result), branch);
|
||||
const repo = { ...(await this.applyBranchScope(this.followSharedGraph(result), branch)) };
|
||||
// Checkout-only tools also need current metadata, without opening a native DB.
|
||||
const meta = await this.readToolMetadata(repo);
|
||||
if (meta) {
|
||||
repo.indexedAt = meta.indexedAt ?? repo.indexedAt;
|
||||
repo.lastCommit = meta.lastCommit ?? repo.lastCommit;
|
||||
repo.branch = meta.branch ?? repo.branch;
|
||||
// Shared graph metadata is branch-neutral. The checkout slot retains
|
||||
// its branch while the immutable graph path moves between commits.
|
||||
if (
|
||||
!meta.branch &&
|
||||
path.dirname(repo.lbugPath) !== repo.storagePath &&
|
||||
repo.lbugPath === resolveGraphPath(repo.storagePath)
|
||||
) {
|
||||
repo.branch = (await loadOptionalMeta(repo.storagePath))?.branch ?? repo.branch;
|
||||
}
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
// Still no match — throw with helpful message
|
||||
|
|
@ -2539,109 +2603,106 @@ export class LocalBackend {
|
|||
*/
|
||||
private async ensureInitialized(repo: RepoHandle): Promise<void> {
|
||||
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 `<storagePath>/gitnexus.json`; for a
|
||||
// branch handle it is `<storagePath>/branches/<slug>/gitnexus.json`.
|
||||
// loadMeta falls back to legacy meta.json during migration.
|
||||
// Reading the flat meta for a branch handle would compare the branch
|
||||
// index's indexedAt against the primary's and thrash the pool (#2106).
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const observedState = this.lastObservedPoolState.get(poolKey);
|
||||
// Compare against the last indexedAt OBSERVED for this pool (keyed by
|
||||
// lbugPath), not the handle's — branch handles are fresh spreads so a
|
||||
// handle mutation would not persist and would reinit on every check.
|
||||
const observed = observedState?.indexedAt ?? repo.indexedAt;
|
||||
const stampChanged = !!meta?.indexedAt && meta.indexedAt !== observed;
|
||||
// #2614 F1: also reinit on a file-identity change. An atomic swap (or an
|
||||
// in-place incremental) changes the lbug inode; keying only on
|
||||
// meta.indexedAt let a reader that reinited inside the pre-swap window
|
||||
// latch on the old inode forever (its stamp already == meta.indexedAt).
|
||||
const currentIdentity = await statDbIdentity(repo.lbugPath);
|
||||
const identityChanged = dbIdentityChanged(
|
||||
observedState?.dbIdentity ?? null,
|
||||
currentIdentity,
|
||||
);
|
||||
// #2767: `--repair-fts` intentionally never restamps `indexedAt` (it
|
||||
// doesn't regenerate the graph), so `stampChanged` alone can't notice
|
||||
// a repair. `capabilities.fts.status` is the field repair-fts DOES
|
||||
// write, so a change there is a third, independent reinit trigger —
|
||||
// sibling to stampChanged/identityChanged, not a replacement for them
|
||||
// (identityChanged still catches an in-place mutation even if the
|
||||
// caps stamp were somehow missed).
|
||||
const ftsStatus = meta?.capabilities?.fts?.status;
|
||||
const ftsCapsChanged = observedState?.ftsStatus !== ftsStatus;
|
||||
if (stampChanged || identityChanged || ftsCapsChanged) {
|
||||
// Index was rebuilt/swapped — DELEGATE the close/reopen to the pool's
|
||||
// initLbug, which refuses to evict (and close the shared Database)
|
||||
// while a query is in flight (its checkedOut>0 guard). Calling
|
||||
// closeLbug directly here bypassed that guard and could close a
|
||||
// Database mid-query — a native use-after-free (#2614). Wrap in
|
||||
// reinitPromises to serialize concurrent detectors.
|
||||
const reinit = (async () => {
|
||||
try {
|
||||
const reopened = await initLbug(poolKey, repo.lbugPath);
|
||||
// tri-review NEW-7: advance the observed stamp/caps watermarks
|
||||
// only AFTER initLbug completes, not before calling it — still
|
||||
// regardless of `reopened` true/false (a stamp/caps change with
|
||||
// an unchanged file must not re-trigger on every check), but if
|
||||
// initLbug THROWS the watermark must stay at its old value so
|
||||
// the next staleness check retries, instead of a failed reinit
|
||||
// silently latching as "already applied" and never trying again.
|
||||
const patch: Partial<PoolObservedState> = { ftsStatus };
|
||||
if (meta?.indexedAt) patch.indexedAt = meta.indexedAt;
|
||||
// Advance the observed IDENTITY only when the pool actually rolled
|
||||
// over. If a query was in flight, initLbug served the current
|
||||
// handle and returned false; leaving the identity divergent
|
||||
// re-triggers the reopen on a later idle check instead of latching.
|
||||
if (reopened) patch.dbIdentity = await statDbIdentity(repo.lbugPath);
|
||||
this.setObservedState(poolKey, patch);
|
||||
} finally {
|
||||
this.reinitPromises.delete(poolKey);
|
||||
}
|
||||
})();
|
||||
this.reinitPromises.set(poolKey, reinit);
|
||||
return reinit;
|
||||
} else {
|
||||
return; // Pool is current
|
||||
}
|
||||
} catch {
|
||||
return; // Can't read meta — assume pool is fine
|
||||
const bindSnapshot = (): void => {
|
||||
const state = this.lastObservedPoolState.get(poolKey);
|
||||
if (
|
||||
repo.poolIdentity !== undefined &&
|
||||
dbIdentityChanged(repo.poolIdentity, state?.dbIdentity ?? null)
|
||||
) {
|
||||
repo.generationUnknown = true;
|
||||
return;
|
||||
}
|
||||
if (state?.ref) {
|
||||
repo.lastCommit = state.ref.lastCommit;
|
||||
repo.indexedAt = state.ref.indexedAt;
|
||||
// Shared commit graphs have no branch label; preserve this caller's
|
||||
// checkout branch instead of caching another checkout's fallback.
|
||||
repo.branch = state.ref.branch ?? repo.branch;
|
||||
}
|
||||
repo.poolIdentity = state?.dbIdentity;
|
||||
repo.generationUnknown ||= state?.generationUnknown;
|
||||
this.toolMetadata.set(repo, Promise.resolve(state?.metadata ?? null));
|
||||
};
|
||||
const pending = this.reinitPromises.get(poolKey);
|
||||
if (pending) {
|
||||
await pending;
|
||||
bindSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await initLbug(poolKey, repo.lbugPath);
|
||||
this.initializedRepos.add(poolKey);
|
||||
// #2767: ftsStatus is deliberately left unset (undefined) here rather
|
||||
// than issuing an extra loadMeta read — every tool call already routes
|
||||
// through ensureInitialized, so an extra per-cold-init read adds up,
|
||||
// and the cost of skipping it is negligible: at most one redundant
|
||||
// initLbug call on the first warm check (initLbug itself no-ops
|
||||
// cheaply via a single fs.stat when the file identity is actually
|
||||
// unchanged, per pool-adapter.ts's own "unchanged → reuse" guard), not
|
||||
// a real reopen.
|
||||
// Check metadata and file identity before serving a tool; only git freshness
|
||||
// retains its five-second cache. A publication must not pair new data with an old ref.
|
||||
const refresh = (async () => {
|
||||
const warm = this.initializedRepos.has(poolKey) && isLbugReady(poolKey);
|
||||
const previous = this.lastObservedPoolState.get(poolKey);
|
||||
const meta = await this.readToolMetadata(repo);
|
||||
const diskIdentity = await statDbIdentity(poolKey);
|
||||
if (meta?.indexedAt && meta.indexedAt !== previous?.diskIndexedAt) {
|
||||
// Even a deferred reader needs its old ref compared to the new HEAD.
|
||||
// Invalidate once per publication; concurrent checks still deduplicate.
|
||||
this.toolStalenessCache.delete(poolKey);
|
||||
}
|
||||
const stampChanged =
|
||||
!!meta?.indexedAt && meta.indexedAt !== (previous?.indexedAt ?? repo.indexedAt);
|
||||
const identityChanged = dbIdentityChanged(previous?.dbIdentity ?? null, diskIdentity);
|
||||
const ftsStatus = meta?.capabilities?.fts?.status;
|
||||
if (!warm || stampChanged || identityChanged || previous?.ftsStatus !== ftsStatus) {
|
||||
await initLbug(poolKey, poolKey);
|
||||
this.initializedRepos.add(poolKey);
|
||||
}
|
||||
const openedIdentity = getLbugDbIdentity(poolKey);
|
||||
const deferred =
|
||||
!!openedIdentity && (!diskIdentity || dbIdentityChanged(openedIdentity, diskIdentity));
|
||||
if (deferred) {
|
||||
// initLbug keeps a busy reader on the old inode. Never attach the new
|
||||
// metadata to it, including when another backend opened that reader.
|
||||
if (previous && !dbIdentityChanged(previous.dbIdentity, openedIdentity)) {
|
||||
this.setObservedState(poolKey, { diskIndexedAt: meta?.indexedAt });
|
||||
return;
|
||||
}
|
||||
this.setObservedState(poolKey, {
|
||||
dbIdentity: openedIdentity,
|
||||
ref: { lastCommit: '', indexedAt: '' },
|
||||
generationUnknown: true,
|
||||
awaitingMetadata: false,
|
||||
diskIndexedAt: meta?.indexedAt,
|
||||
metadata: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ref: IndexedRef = {
|
||||
lastCommit: meta?.lastCommit ?? previous?.ref?.lastCommit ?? repo.lastCommit,
|
||||
indexedAt: meta?.indexedAt ?? previous?.ref?.indexedAt ?? repo.indexedAt,
|
||||
branch: meta?.branch,
|
||||
};
|
||||
// The graph swap precedes its final metadata stamp. An inode replacement
|
||||
// needs that stamp; same-inode auxiliary changes (e.g. FTS repair) do not.
|
||||
// Actual in-place graph writes advertise incrementalInProgress below.
|
||||
const graphReplaced =
|
||||
!!previous?.dbIdentity && !!diskIdentity && previous.dbIdentity.ino !== diskIdentity.ino;
|
||||
const publicationPending =
|
||||
!!previous &&
|
||||
!stampChanged &&
|
||||
(previous.awaitingMetadata === true ||
|
||||
(graphReplaced && previous.ref !== undefined && !previous.generationUnknown));
|
||||
const generationUnknown = deferred || publicationPending || !!meta?.incrementalInProgress;
|
||||
this.setObservedState(poolKey, {
|
||||
indexedAt: repo.indexedAt,
|
||||
dbIdentity: await statDbIdentity(repo.lbugPath),
|
||||
indexedAt: meta?.indexedAt ?? previous?.indexedAt ?? repo.indexedAt,
|
||||
dbIdentity: openedIdentity,
|
||||
ftsStatus,
|
||||
ref: generationUnknown ? { lastCommit: '', indexedAt: '' } : ref,
|
||||
generationUnknown,
|
||||
awaitingMetadata: publicationPending,
|
||||
diskIndexedAt: meta?.indexedAt,
|
||||
metadata: generationUnknown ? null : meta,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// If lock error, mark as not initialized so next call retries
|
||||
this.initializedRepos.delete(poolKey);
|
||||
throw err;
|
||||
})();
|
||||
this.reinitPromises.set(poolKey, refresh);
|
||||
try {
|
||||
await refresh;
|
||||
bindSnapshot();
|
||||
} finally {
|
||||
this.reinitPromises.delete(poolKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2901,6 +2962,21 @@ export class LocalBackend {
|
|||
// poison the TTL cache entry (#2655 review F1). A rejection leaves `info`
|
||||
// undefined, and the builder returns nothing for that even with a ref, so
|
||||
// the degraded path still attaches no field.
|
||||
// Multiple reads may straddle an idle pool rollover. Do not certify mixed
|
||||
// generations as current. A busy reader that stayed on its inode is known.
|
||||
const changedDuringTool =
|
||||
repo.poolIdentity !== undefined &&
|
||||
dbIdentityChanged(repo.poolIdentity, getLbugDbIdentity(repo.lbugPath));
|
||||
if (repo.generationUnknown || changedDuringTool) {
|
||||
return attachToolStaleness(
|
||||
result,
|
||||
{ status: 'unknown', isStale: false, commitsBehind: 0 },
|
||||
{
|
||||
lastCommit: '',
|
||||
indexedAt: '',
|
||||
},
|
||||
);
|
||||
}
|
||||
const staleness = await this.stalenessForTool(repo).catch(() => undefined);
|
||||
return attachToolStaleness(result, staleness, {
|
||||
branch: repo.branch,
|
||||
|
|
@ -2922,8 +2998,15 @@ export class LocalBackend {
|
|||
*/
|
||||
private stalenessForTool(repo: RepoHandle): Promise<StalenessInfo> {
|
||||
const now = Date.now();
|
||||
const generation = JSON.stringify([
|
||||
repo.repoPath,
|
||||
repo.lastCommit,
|
||||
repo.indexedAt,
|
||||
repo.branch,
|
||||
repo.poolIdentity,
|
||||
]);
|
||||
const cached = this.toolStalenessCache.get(repo.lbugPath);
|
||||
if (cached && now - cached.at < LocalBackend.TOOL_STALENESS_TTL_MS) {
|
||||
if (cached?.generation === generation && now - cached.at < LocalBackend.TOOL_STALENESS_TTL_MS) {
|
||||
return cached.value;
|
||||
}
|
||||
// Evict the entry if the check rejects so a transient failure isn't served
|
||||
|
|
@ -2932,8 +3015,9 @@ export class LocalBackend {
|
|||
// dedup is untouched (#2655 review F1). `Promise.resolve` wraps the call so a
|
||||
// non-thenable return can't throw at this boundary — a no-op for the real
|
||||
// async `checkStalenessAsync`, robust defense-in-depth otherwise.
|
||||
const entry: { at: number; value: Promise<StalenessInfo> } = {
|
||||
const entry: { at: number; generation: string; value: Promise<StalenessInfo> } = {
|
||||
at: now,
|
||||
generation,
|
||||
// Only evict if THIS entry is still current — a later call may have
|
||||
// installed a fresh (resolving) entry for the same key before a slow
|
||||
// rejection lands, and that newer entry must not be dropped.
|
||||
|
|
@ -3177,6 +3261,31 @@ export class LocalBackend {
|
|||
include_content?: boolean;
|
||||
chain_depth?: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
try {
|
||||
const result = await this._queryImpl(repo, params);
|
||||
if (!result.error) assertResponseIdentities(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof SymbolIdentityError) {
|
||||
return { error: err.message, recoverySuggestion: SYMBOL_IDENTITY_RECOVERY_SUGGESTION };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async _queryImpl(
|
||||
repo: RepoHandle,
|
||||
params: {
|
||||
query?: string;
|
||||
search_query?: string;
|
||||
task_context?: string;
|
||||
goal?: string;
|
||||
limit?: number;
|
||||
max_symbols?: number;
|
||||
include_content?: boolean;
|
||||
chain_depth?: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
// #2175: each consumer resolves the search_query/query alias itself (there is no
|
||||
// chokepoint mutation in callTool). This also serves the GroupService port, which
|
||||
|
|
@ -3214,7 +3323,7 @@ export class LocalBackend {
|
|||
// disclosing text merely because a caller asked for it. `query` already
|
||||
// reads this metadata for CJK and embedding-dimension drift diagnostics,
|
||||
// so keep that legacy read unconditional.
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const meta = await this.readToolMetadata(repo);
|
||||
const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none';
|
||||
const contentAvailability = requestedContentAvailability(requestedContent, meta);
|
||||
const searchQuery = rawQuery.trim();
|
||||
|
|
@ -3266,6 +3375,10 @@ export class LocalBackend {
|
|||
|
||||
for (let i = 0; i < bm25Results.length; i++) {
|
||||
const result = bm25Results[i];
|
||||
if (result.nodeId !== undefined && result.nodeId !== null) {
|
||||
assertSymbolIdentity(result.nodeId);
|
||||
}
|
||||
assertIdentityFields(result.name, result.type, result.filePath);
|
||||
const key = result.nodeId || result.filePath;
|
||||
const rrfScore = 1 / (60 + i);
|
||||
const existing = scoreMap.get(key);
|
||||
|
|
@ -3279,6 +3392,8 @@ export class LocalBackend {
|
|||
const safeSemanticResults = semanticResults ?? [];
|
||||
for (let i = 0; i < safeSemanticResults.length; i++) {
|
||||
const result = safeSemanticResults[i];
|
||||
assertSymbolIdentity(result.nodeId);
|
||||
assertIdentityFields(result.name, result.type, result.filePath);
|
||||
const key = result.nodeId || result.filePath;
|
||||
const rrfScore = 1 / (60 + i);
|
||||
const existing = scoreMap.get(key);
|
||||
|
|
@ -3361,12 +3476,20 @@ export class LocalBackend {
|
|||
{ nodeIds: ids },
|
||||
);
|
||||
for (const row of rows) {
|
||||
assertQueryIdentity(row, 'nodeId', 0, [
|
||||
['label', 2],
|
||||
['heuristicLabel', 3],
|
||||
['processType', 4],
|
||||
['entryPointId', 7],
|
||||
]);
|
||||
assertSymbolIdentity(queryRowValue(row, 'pid', 1));
|
||||
const nid = row.nodeId ?? row[0];
|
||||
let list = processRowsByNode.get(nid);
|
||||
if (!list) processRowsByNode.set(nid, (list = []));
|
||||
list.push(row);
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('query:process-lookup', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
|
|
@ -3391,6 +3514,7 @@ export class LocalBackend {
|
|||
{ nodeIds: ids },
|
||||
);
|
||||
for (const row of rows) {
|
||||
assertQueryIdentity(row, 'nodeId', 0, [['module', 2]]);
|
||||
const nid = row.nodeId ?? row[0];
|
||||
if (!cohesionByNode.has(nid)) {
|
||||
cohesionByNode.set(nid, {
|
||||
|
|
@ -3400,6 +3524,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('query:cluster-info', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
|
|
@ -3417,10 +3542,12 @@ export class LocalBackend {
|
|||
{ nodeIds: ids },
|
||||
);
|
||||
for (const row of rows) {
|
||||
assertQueryIdentity(row, 'nodeId', 0);
|
||||
const nid = row.nodeId ?? row[0];
|
||||
contentByNode.set(nid, row.content ?? row[1]);
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('query:content-fetch', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
|
|
@ -3534,6 +3661,13 @@ export class LocalBackend {
|
|||
{ routeIds: ids },
|
||||
);
|
||||
for (const row of rows) {
|
||||
assertQueryIdentity(row, 'routeId', 0, [
|
||||
['handlerName', 2],
|
||||
['handlerFilePath', 3],
|
||||
['url', 4],
|
||||
['method', 5],
|
||||
]);
|
||||
assertSymbolIdentity(queryRowValue(row, 'handlerId', 1));
|
||||
const routeId = String(row.routeId ?? row[0] ?? '');
|
||||
const handlerId = String(row.handlerId ?? row[1] ?? '');
|
||||
if (!routeId || !handlerId) continue;
|
||||
|
|
@ -3549,6 +3683,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('query:route-definition-bridge', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
|
|
@ -3644,6 +3779,10 @@ export class LocalBackend {
|
|||
},
|
||||
);
|
||||
for (const row of routeRows) {
|
||||
assertQueryIdentity(row, 'pid', 0, [
|
||||
['url', 1],
|
||||
['method', 2],
|
||||
]);
|
||||
const targetPid = row.pid ?? row[0];
|
||||
const url = row.url ?? row[1];
|
||||
const method = row.method ?? row[2];
|
||||
|
|
@ -3661,6 +3800,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('query:route-lookup', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
|
|
@ -3677,11 +3817,12 @@ export class LocalBackend {
|
|||
// QUERY_CHAIN_BFS_CONCURRENCY walks in flight. This is the per-query BFS
|
||||
// cap; GroupService.groupQuery also caps member-query fan-out so a group
|
||||
// of N members cannot run 4×N concurrent BFS walks.
|
||||
// Best-effort — a BFS failure drops that process's chain but never fails
|
||||
// the query.
|
||||
// Ordinary BFS failures may drop a chain; corrupt identities must fail
|
||||
// the query even though mapConcurrent normally swallows worker errors.
|
||||
const chainByProcessId = new Map<string, any[]>();
|
||||
if (requestedChainDepth > 0 && rankedProcesses.length > 0) {
|
||||
timer.start('chain_enrichment');
|
||||
let integrityError: SymbolIdentityError | undefined;
|
||||
await mapConcurrent(
|
||||
rankedProcesses,
|
||||
async (p) => {
|
||||
|
|
@ -3692,14 +3833,22 @@ export class LocalBackend {
|
|||
p.entryPointId,
|
||||
requestedChainDepth,
|
||||
);
|
||||
assertResponseIdentities(chain);
|
||||
chainByProcessId.set(p.id, chain);
|
||||
} catch (e) {
|
||||
rethrowSymbolIdentityError(e);
|
||||
logQueryError('query:chain-bfs', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
},
|
||||
{ concurrency: QUERY_CHAIN_BFS_CONCURRENCY },
|
||||
{
|
||||
concurrency: QUERY_CHAIN_BFS_CONCURRENCY,
|
||||
onError: (error) => {
|
||||
if (error instanceof SymbolIdentityError) integrityError = error;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (integrityError) throw integrityError;
|
||||
timer.stop(); // chain_enrichment
|
||||
}
|
||||
|
||||
|
|
@ -3790,6 +3939,7 @@ export class LocalBackend {
|
|||
);
|
||||
}
|
||||
} catch (err) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
// Best-effort diagnostic only — never fail the query over it.
|
||||
logQueryError('query:cjk-warning', err);
|
||||
}
|
||||
|
|
@ -3832,6 +3982,7 @@ export class LocalBackend {
|
|||
);
|
||||
}
|
||||
} catch (err) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
// loadMeta() itself never throws (it returns null on any read/parse
|
||||
// failure) — the actual throw source here is getSearchFTSCjkSegmentation()
|
||||
// on an invalid env value, same root cause as the catch above. This is
|
||||
|
|
@ -3927,6 +4078,7 @@ export class LocalBackend {
|
|||
try {
|
||||
({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js'));
|
||||
} catch (err: any) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
// Module import can fail in sandboxed MCP contexts (#1489)
|
||||
logger.warn(
|
||||
{ err: err?.message },
|
||||
|
|
@ -3938,6 +4090,7 @@ export class LocalBackend {
|
|||
try {
|
||||
ftsResponse = await searchFTSFromLbug(query, limit, repo.lbugPath);
|
||||
} catch (err: any) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
// Swallowed, gracefully-degraded failure: the search falls back to
|
||||
// semantic-only (a valid result), and the most common cause is simply an
|
||||
// un-indexed FTS extension — a normal configuration, not an operation
|
||||
|
|
@ -3961,6 +4114,8 @@ export class LocalBackend {
|
|||
|
||||
for (const bm25Result of bm25Results) {
|
||||
const fullPath = bm25Result.filePath;
|
||||
assertIdentityFields(fullPath);
|
||||
for (const nodeId of bm25Result.nodeIds ?? []) assertSymbolIdentity(nodeId);
|
||||
try {
|
||||
// Prefer direct nodeId lookup (exact FTS-matched nodes) over filePath fallback.
|
||||
// Without this, LIMIT 3 on filePath returns arbitrary symbols rather than
|
||||
|
|
@ -3996,6 +4151,7 @@ export class LocalBackend {
|
|||
|
||||
if (symbols.length > 0) {
|
||||
for (const sym of symbols) {
|
||||
assertSymbolRowIdentity(sym);
|
||||
results.push({
|
||||
nodeId: sym.id || sym[0],
|
||||
name: sym.name || sym[1],
|
||||
|
|
@ -4018,7 +4174,8 @@ export class LocalBackend {
|
|||
bm25Score: bm25Result.score,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
const fileName = fullPath.split('/').pop() || fullPath;
|
||||
results.push({
|
||||
name: fileName,
|
||||
|
|
@ -4090,6 +4247,7 @@ export class LocalBackend {
|
|||
try {
|
||||
vectorReady = await ensureVectorExtension(repo.lbugPath);
|
||||
} catch (err) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
if (!this.warnedVectorLoadFailed) {
|
||||
this.warnedVectorLoadFailed = true;
|
||||
logger.warn(
|
||||
|
|
@ -4122,6 +4280,7 @@ export class LocalBackend {
|
|||
`;
|
||||
|
||||
const embResults = await executeQuery(repo.lbugPath, vectorQuery);
|
||||
for (const row of embResults) assertQueryIdentity(row, 'nodeId', 0);
|
||||
return embResults.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
|
|
@ -4131,6 +4290,7 @@ export class LocalBackend {
|
|||
}));
|
||||
});
|
||||
} catch (err) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
bestChunks = new Map();
|
||||
if (!this.warnedVectorQueryFailed) {
|
||||
// Rare diagnostic: surface why semantic search fell back to the
|
||||
|
|
@ -4158,6 +4318,7 @@ export class LocalBackend {
|
|||
e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding
|
||||
`,
|
||||
);
|
||||
for (const row of rows) assertQueryIdentity(row, 'nodeId', 0);
|
||||
const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({
|
||||
nodeId: row.nodeId ?? row[0],
|
||||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||||
|
|
@ -4186,6 +4347,7 @@ export class LocalBackend {
|
|||
// scope here, and a shadowing local silently turns any later `chunk.x`
|
||||
// into a property read on the function.
|
||||
for (const [nodeId, bestChunk] of Array.from(bestChunks.entries()).slice(0, limit)) {
|
||||
assertSymbolIdentity(nodeId);
|
||||
const labelEndIdx = nodeId.indexOf(':');
|
||||
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
|
||||
|
||||
|
|
@ -4201,6 +4363,10 @@ export class LocalBackend {
|
|||
const nodeRows = await executeParameterized(repo.lbugPath, nodeQuery, { nodeId });
|
||||
if (nodeRows.length > 0) {
|
||||
const nodeRow = nodeRows[0];
|
||||
assertIdentityFields(
|
||||
queryRowValue(nodeRow, 'name', 0),
|
||||
queryRowValue(nodeRow, 'filePath', 1),
|
||||
);
|
||||
results.push({
|
||||
nodeId,
|
||||
name: nodeRow.name ?? nodeRow[0] ?? '',
|
||||
|
|
@ -4211,11 +4377,14 @@ export class LocalBackend {
|
|||
endLine: bestChunk.endLine,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
rethrowSymbolIdentityError(err);
|
||||
// Nothing was embedded on this path unless the throw happened after the
|
||||
// vector existed (a failed lookup downstream of a good embedding, where
|
||||
// the width IS still the live one). Clearing only in the former case
|
||||
|
|
@ -4973,7 +5142,7 @@ export class LocalBackend {
|
|||
const requestedContent = include_content ?? false;
|
||||
// Content retention matters only to the opt-in content response. Avoid a
|
||||
// metadata dependency for the long-standing default context operation.
|
||||
const meta = requestedContent ? await loadMeta(path.dirname(repo.lbugPath)) : null;
|
||||
const meta = requestedContent ? await this.readToolMetadata(repo) : null;
|
||||
const contentAvailability = requestedContentAvailability(requestedContent, meta);
|
||||
const includeContent = requestedContent && contentRetentionFromMeta(meta) !== 'none';
|
||||
|
||||
|
|
@ -5806,7 +5975,7 @@ export class LocalBackend {
|
|||
// a seeded test DB) falls through to the row-existence probe below.
|
||||
let pdgStamped: boolean | undefined;
|
||||
try {
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const meta = await this.readToolMetadata(repo);
|
||||
if (meta) pdgStamped = meta.pdg?.taintModelVersion !== undefined;
|
||||
} catch {
|
||||
/* meta unreadable — decide from the DB below */
|
||||
|
|
@ -6124,7 +6293,9 @@ export class LocalBackend {
|
|||
// contract byte-for-byte: `false` ⇒ definitive no-layer (short-circuit
|
||||
// below), `true` ⇒ proceed, `undefined` ⇒ meta unreadable, defer to the
|
||||
// post-anchored-query probe (Feasibility Issue 4).
|
||||
const pdgStamped = await pdgStampForMode(repo.lbugPath, mode);
|
||||
const pdgStamped = await pdgStampForMode(repo.lbugPath, mode, () =>
|
||||
this.readToolMetadata(repo),
|
||||
);
|
||||
if (pdgStamped === false) {
|
||||
return { mode, results: [], total: 0, note: NO_PDG_NOTE };
|
||||
}
|
||||
|
|
@ -7574,6 +7745,7 @@ export class LocalBackend {
|
|||
if (mode === 'pdg') {
|
||||
layer = await pdgLayerStatus({
|
||||
lbugPath: repo.lbugPath,
|
||||
loadMetaFn: () => this.readToolMetadata(repo),
|
||||
executeParameterized,
|
||||
});
|
||||
if (isPdgDegradedLayerStatus(layer)) {
|
||||
|
|
@ -7815,10 +7987,8 @@ export class LocalBackend {
|
|||
* Fields the analyzer declined to link because every definition of the name
|
||||
* lives in another language (R3-1), keyed by name.
|
||||
*
|
||||
* Cached per index version. `ensureInitialized` deliberately avoids a
|
||||
* per-call `loadMeta` because every tool call routes through it; this is one
|
||||
* small read per (index, indexedAt), which re-reads exactly when a re-analyze
|
||||
* could have changed the answer and never otherwise.
|
||||
* Cached per index version. The fresh stamp bound by `ensureInitialized`
|
||||
* invalidates this derived map exactly when a re-analyze could have changed it.
|
||||
*/
|
||||
private readonly crossLanguagePropertyCache = new Map<
|
||||
string,
|
||||
|
|
@ -7832,7 +8002,7 @@ export class LocalBackend {
|
|||
if (cached !== undefined && cached.indexedAt === repo.indexedAt) return cached.byName;
|
||||
const byName = new Map<string, readonly string[]>();
|
||||
try {
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const meta = await this.readToolMetadata(repo);
|
||||
for (const entry of meta?.crossLanguageProperties ?? []) {
|
||||
byName.set(entry.name, entry.languages);
|
||||
}
|
||||
|
|
@ -7863,16 +8033,14 @@ export class LocalBackend {
|
|||
// reason #2708 was filed. A dropped site's callee is unknown, so the index
|
||||
// records the member NAME invoked at the drop; a match on the queried
|
||||
// symbol's name means at least one call to something of that name was lost.
|
||||
// ONE read of the index metadata for both probes below. They are the second
|
||||
// and third consumers of this file on a path whose own comments call out
|
||||
// avoiding a per-call `loadMeta` (see `ensureInitialized`), and the file is
|
||||
// dominated by `fileHashes` — megabytes on a large repo.
|
||||
// Share one metadata read between both probes; fileHashes can make this
|
||||
// file large, so each probe should not read it separately.
|
||||
// `try`, not `.catch`: `loadMeta` can throw synchronously (a stubbed module
|
||||
// in tests, a mid-read unmount), and a probe failing must never read as
|
||||
// certainty — the whole point of this function.
|
||||
let meta: Awaited<ReturnType<typeof loadMeta>> | undefined;
|
||||
try {
|
||||
meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
meta = await this.readToolMetadata(repo);
|
||||
} catch {
|
||||
meta = undefined;
|
||||
}
|
||||
|
|
@ -9632,7 +9800,7 @@ export class LocalBackend {
|
|||
code: 'source-unavailable';
|
||||
reason: 'content-retention' | 'checkout-missing';
|
||||
} | null> {
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
const meta = await this.readToolMetadata(repo);
|
||||
const retention = contentRetentionFromMeta(meta);
|
||||
const checkoutIsDir = retention === 'full' ? await checkoutIsDirectory(repo.repoPath) : false;
|
||||
if (isFullSourceAvailable(retention, checkoutIsDir)) return null;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
return value[key] ?? value[index];
|
||||
}
|
||||
|
||||
/** Optional labels/paths may be empty or NULL; NUL is never a usable identity. */
|
||||
export function assertIdentityFields(...values: unknown[]): void {
|
||||
for (const value of values) {
|
||||
if (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
(typeof value !== 'string' || value.includes('\0'))
|
||||
) {
|
||||
throw new SymbolIdentityError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertQueryIdentity(
|
||||
row: unknown,
|
||||
idKey: string,
|
||||
idIndex: number,
|
||||
fields: ReadonlyArray<readonly [string, number]> = [],
|
||||
): void {
|
||||
assertSymbolIdentity(queryRowValue(row, idKey, idIndex));
|
||||
for (const [key, index] of fields) assertIdentityFields(queryRowValue(row, key, index));
|
||||
}
|
||||
|
||||
/** Ordinary query failures may degrade; corrupt identities must reach the outer error envelope. */
|
||||
export function rethrowSymbolIdentityError(error: unknown): void {
|
||||
if (error instanceof SymbolIdentityError) throw error;
|
||||
}
|
||||
/** Compatibility export; search and MCP must share the same error class. */
|
||||
export * from '../../core/lbug/query-result-integrity.js';
|
||||
|
|
|
|||
|
|
@ -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<QueryResult> {
|
||||
|
|
@ -160,6 +157,7 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
|
|||
// Same production functions the repair-fts branch of runFullAnalysis
|
||||
// calls — real FTS build, then the #2767 capability-only meta stamp
|
||||
// (indexedAt/lastCommit deliberately unchanged, R4).
|
||||
const beforeIdentity = await statDbIdentity(lbugPath);
|
||||
await adapter.initLbug(lbugPath);
|
||||
await createSearchFTSIndexes();
|
||||
await adapter.flushWAL();
|
||||
|
|
@ -178,6 +176,14 @@ describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', (
|
|||
expect(ftsMissing(after)).toBe(false);
|
||||
expect(after.partial).toBeUndefined();
|
||||
expect(matchedIds(after)).toContain('func:login');
|
||||
const afterIdentity = await statDbIdentity(lbugPath);
|
||||
expect(afterIdentity?.ino).toBe(beforeIdentity?.ino);
|
||||
expect(afterIdentity).not.toEqual(beforeIdentity);
|
||||
// The Function-only repair starts and ends with FTS status available.
|
||||
// Its in-place auxiliary changes must not erase the graph's known ref.
|
||||
expect(after.staleness).toMatchObject({ lastCommit: baseMeta.lastCommit, indexedAt });
|
||||
const again = await backend.callTool('query', { query: 'login' });
|
||||
expect(again.staleness).toMatchObject({ lastCommit: baseMeta.lastCommit, indexedAt });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
195
gitnexus/test/integration/incremental-method-integrity.test.ts
Normal file
195
gitnexus/test/integration/incremental-method-integrity.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Public-workflow regression for #3564/#3527. The reporters' private fixtures
|
||||
* and macOS environments are unavailable; passing this fixture does not
|
||||
* establish the cause of either report.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
|
||||
import { runFullAnalysis } from '../../src/core/run-analyze.js';
|
||||
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { getStoragePaths } from '../../src/storage/repo-manager.js';
|
||||
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
type SymbolTuple = {
|
||||
uid: string;
|
||||
name: string;
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const FILE_COUNT = 70;
|
||||
const METHODS_PER_FILE = 64;
|
||||
const CHANGED_FILE = 'checks/test_batch_0.py';
|
||||
const SWIFT_PATH = 'Sources/Sentinel.swift';
|
||||
const SWIFT_SOURCE = 'struct UnrelatedSwiftSentinel {\n let marker: Int = 701\n}\n';
|
||||
const METHOD_SCAN = `MATCH (n:Method)
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath,
|
||||
n.startLine AS startLine, n.endLine AS endLine, n.content AS content
|
||||
ORDER BY uid`;
|
||||
|
||||
// The expected rows come from the source fixture, never from graph output or a
|
||||
// previous database scan. Native spans are zero-based; context displays +1.
|
||||
function pythonFixture(batch: number, changed = false): { source: string; tuples: SymbolTuple[] } {
|
||||
const filePath = `checks/test_batch_${batch}.py`;
|
||||
const className = `Batch${batch}`;
|
||||
const lines = ['import unittest', '', `class ${className}(unittest.TestCase):`];
|
||||
const tuples: SymbolTuple[] = [];
|
||||
for (let method = 0; method < METHODS_PER_FILE; method++) {
|
||||
const name = `test_sample_${batch}_${method}`;
|
||||
const body = [` def ${name}(self):`, ` return ${batch * METHODS_PER_FILE + method}`];
|
||||
if (changed && method === 0) {
|
||||
body.splice(1, 0, ' updated_value = 9001');
|
||||
body[2] = ' return updated_value';
|
||||
}
|
||||
const startLine = lines.length;
|
||||
tuples.push({
|
||||
uid: `Method:${filePath}:${className}.${name}#0`,
|
||||
name,
|
||||
filePath,
|
||||
startLine,
|
||||
endLine: startLine + body.length - 1,
|
||||
// Stored symbol content preserves indentation and flattens newlines for FTS.
|
||||
content: body.join(' '),
|
||||
});
|
||||
lines.push(...body, '');
|
||||
}
|
||||
return { source: lines.join('\n'), tuples };
|
||||
}
|
||||
|
||||
function expectContext(result: any, expected: SymbolTuple): void {
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.symbol).toMatchObject({
|
||||
...expected,
|
||||
startLine: expected.startLine + 1,
|
||||
endLine: expected.endLine + 1,
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!isLanguageAvailable(SupportedLanguages.Swift))(
|
||||
'Python Method identities across selective incremental writes (#3564, #3527)',
|
||||
() => {
|
||||
it('retains every source tuple across a class-method edit and fresh native/MCP readers', async () => {
|
||||
const temp = await createTempDir();
|
||||
let backend: LocalBackend | undefined;
|
||||
try {
|
||||
const root = await fs.realpath(temp.dbPath);
|
||||
const repo = path.join(root, 'method-integrity');
|
||||
await fs.mkdir(path.join(repo, 'checks'), { recursive: true });
|
||||
await fs.mkdir(path.join(repo, 'Sources'));
|
||||
await fs.mkdir(path.join(root, 'user-home'));
|
||||
vi.stubEnv('HOME', path.join(root, 'user-home'));
|
||||
vi.stubEnv('USERPROFILE', path.join(root, 'user-home'));
|
||||
vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home'));
|
||||
vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined);
|
||||
vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined);
|
||||
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
|
||||
vi.stubEnv('GITNEXUS_CONTENT_RETENTION', 'full');
|
||||
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only');
|
||||
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1');
|
||||
await fs.writeFile(path.join(repo, '.gitignore'), '.gitnexus/\n');
|
||||
await fs.writeFile(path.join(repo, SWIFT_PATH), SWIFT_SOURCE);
|
||||
const fixtures = Array.from({ length: FILE_COUNT }, (_, batch) => pythonFixture(batch));
|
||||
for (const fixture of fixtures) {
|
||||
await fs.writeFile(path.join(repo, fixture.tuples[0].filePath), fixture.source);
|
||||
}
|
||||
initGitRepo(repo);
|
||||
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: repo });
|
||||
commitAll(repo, 'baseline Python methods and unrelated Swift struct');
|
||||
|
||||
// This test isolates selective native writes from extension availability.
|
||||
// FTS corruption and query guards have separate real-native coverage.
|
||||
const options = { skipAgentsMd: true, skipSkills: true, skipFts: true };
|
||||
await runFullAnalysis(repo, options, { onProgress: () => {} });
|
||||
const { lbugPath } = getStoragePaths(repo);
|
||||
backend = new LocalBackend();
|
||||
expect(await backend.init()).toBe(true);
|
||||
|
||||
const readAll = async (expected: SymbolTuple[]): Promise<void> => {
|
||||
const ordered = [...expected].sort((a, b) =>
|
||||
a.uid < b.uid ? -1 : a.uid > b.uid ? 1 : 0,
|
||||
);
|
||||
// The entire Method table exceeds one 2048-row native vector. Reopen
|
||||
// the database for each scan rather than reusing the writer's view.
|
||||
await adapter.initLbug(lbugPath, { skipFts: true });
|
||||
try {
|
||||
expect(await adapter.executeQuery(METHOD_SCAN)).toEqual(ordered);
|
||||
expect(
|
||||
await adapter.executeQuery(
|
||||
`MATCH (n:Struct) RETURN n.id AS uid, n.name AS name, n.filePath AS filePath,
|
||||
n.startLine AS startLine, n.endLine AS endLine, n.content AS content`,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
uid: `Struct:${SWIFT_PATH}:UnrelatedSwiftSentinel`,
|
||||
name: 'UnrelatedSwiftSentinel',
|
||||
filePath: SWIFT_PATH,
|
||||
startLine: 0,
|
||||
endLine: 2,
|
||||
content: SWIFT_SOURCE.trimEnd().replace(/\n/g, ' '),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await adapter.closeLbug();
|
||||
}
|
||||
// Keep the same backend across both analyses, but release its native
|
||||
// reader before the in-place writer opens the DB. No mocked rows.
|
||||
const result = await backend!.callTool('cypher', { repo, statement: METHOD_SCAN });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.row_count).toBe(FILE_COUNT * METHODS_PER_FILE);
|
||||
expect(result.markdown).toBe(
|
||||
[
|
||||
'| uid | name | filePath | startLine | endLine | content |',
|
||||
'| --- | --- | --- | --- | --- | --- |',
|
||||
...ordered.map(
|
||||
(tuple) =>
|
||||
`| ${Object.values(tuple)
|
||||
.map((value) => String(value).replace(/\n/g, ' '))
|
||||
.join(' | ')} |`,
|
||||
),
|
||||
].join('\n'),
|
||||
);
|
||||
for (const index of [0, 63, 2048, expected.length - 1]) {
|
||||
expectContext(
|
||||
await backend!.callTool('context', {
|
||||
repo,
|
||||
uid: expected[index].uid,
|
||||
include_content: true,
|
||||
}),
|
||||
expected[index],
|
||||
);
|
||||
}
|
||||
await backend!.dispose();
|
||||
};
|
||||
|
||||
const before = fixtures.flatMap(({ tuples }) => tuples);
|
||||
expect(before).toHaveLength(4480);
|
||||
await readAll(before);
|
||||
|
||||
const edited = pythonFixture(0, true);
|
||||
await fs.writeFile(path.join(repo, CHANGED_FILE), edited.source);
|
||||
commitAll(repo, 'edit one class method and shift later method spans');
|
||||
const refreshed = await runFullAnalysis(repo, options, { onProgress: () => {} });
|
||||
expect(refreshed.incrementalStats).toMatchObject({
|
||||
changedFiles: 1,
|
||||
deletedFiles: 0,
|
||||
writeMode: 'incremental',
|
||||
});
|
||||
await readAll([...edited.tuples, ...fixtures.slice(1).flatMap(({ tuples }) => tuples)]);
|
||||
} finally {
|
||||
await backend?.dispose();
|
||||
await adapter.closeLbug();
|
||||
vi.unstubAllEnvs();
|
||||
await temp.cleanup();
|
||||
}
|
||||
}, 180_000);
|
||||
},
|
||||
);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
356
gitnexus/test/integration/mcp-index-generation.test.ts
Normal file
356
gitnexus/test/integration/mcp-index-generation.test.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
/** Real indexing, native reads and one MCP session across index publication. */
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
|
||||
import { closeLbug as closeReader } from '../../src/core/lbug/pool-adapter.js';
|
||||
import { runFullAnalysis } from '../../src/core/run-analyze.js';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { createMCPServer, getNextStepHint } from '../../src/mcp/server.js';
|
||||
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
const options = { skipAgentsMd: true, skipSkills: true, skipFts: true, atomicIncremental: true };
|
||||
|
||||
// Atomic incremental publication is POSIX-only; Windows uses in-place writes.
|
||||
describe.skipIf(process.platform === 'win32')('MCP index generation freshness', () => {
|
||||
let temp: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let repo: string;
|
||||
let ftsLoaded = false;
|
||||
const backend = new LocalBackend();
|
||||
const server = createMCPServer(backend);
|
||||
const client = new Client({ name: 'index-generation-regression', version: '1.0.0' });
|
||||
|
||||
beforeAll(async () => {
|
||||
temp = await createTempDir();
|
||||
const root = await fs.realpath(temp.dbPath);
|
||||
vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home'));
|
||||
vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined);
|
||||
vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined);
|
||||
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
|
||||
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1');
|
||||
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only');
|
||||
await adapter.initLbug(path.join(root, 'fts-probe'));
|
||||
ftsLoaded = await adapter.loadFTSExtension(undefined, { policy: 'load-only' });
|
||||
await adapter.closeLbug();
|
||||
if (!ftsLoaded && process.env.GITNEXUS_REQUIRE_FTS === '1')
|
||||
throw new Error('FTS is required for this test');
|
||||
options.skipFts = !ftsLoaded;
|
||||
repo = path.join(root, 'repo');
|
||||
await fs.mkdir(repo);
|
||||
await fs.writeFile(path.join(repo, '.gitignore'), '.gitnexus/\n');
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(repo, `unit_${i}.py`),
|
||||
`def retained_${i}():\n return ${i}\n`,
|
||||
);
|
||||
}
|
||||
initGitRepo(repo);
|
||||
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: repo });
|
||||
commitAll(repo, 'baseline');
|
||||
await runFullAnalysis(repo, options, { onProgress: () => {} });
|
||||
await backend.init();
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await backend.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
await temp?.cleanup();
|
||||
});
|
||||
|
||||
async function read(branch?: string, target = repo): Promise<any> {
|
||||
const args = {
|
||||
repo: target,
|
||||
...(branch ? { branch } : {}),
|
||||
statement: "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' RETURN n.name AS name",
|
||||
};
|
||||
return call('cypher', args);
|
||||
}
|
||||
|
||||
async function call(name: string, args: Record<string, unknown>): Promise<any> {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
expect(result.isError, JSON.stringify(result)).not.toBe(true);
|
||||
const text = (result.content as Array<{ type: string; text: string }>).find(
|
||||
(item) => item.type === 'text',
|
||||
)!.text;
|
||||
const hint = getNextStepHint(name, args);
|
||||
return JSON.parse(hint ? text.slice(0, -hint.length) : text);
|
||||
}
|
||||
|
||||
it('releases ingestion manifests while retaining the opened generation metadata', async () => {
|
||||
const { storagePath, lbugPath } = getStoragePaths(repo);
|
||||
const metadata = (await loadMeta(storagePath))!;
|
||||
expect(Object.keys(metadata.fileHashes ?? {}).length).toBeGreaterThan(0);
|
||||
await read();
|
||||
await closeReader(lbugPath);
|
||||
const state = (backend as any).lastObservedPoolState.get(lbugPath);
|
||||
expect(state.metadata.fileHashes).toBeUndefined();
|
||||
expect(state.metadata.cacheKeys).toBeUndefined();
|
||||
expect(state.metadata.embeddingCheckpoint).toBeUndefined();
|
||||
expect((await read()).staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: metadata.lastCommit,
|
||||
indexedAt: metadata.indexedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes code and metadata immediately after selective publication and same-commit rebuild', async () => {
|
||||
const storagePath = getStoragePaths(repo).storagePath;
|
||||
const beforeMeta = (await loadMeta(storagePath))!;
|
||||
expect((await read()).staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: beforeMeta.lastCommit,
|
||||
indexedAt: beforeMeta.indexedAt,
|
||||
});
|
||||
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def refreshed_zero():\n return 9001\n');
|
||||
commitAll(repo, 'updated function');
|
||||
const update = await runFullAnalysis(repo, options, { onProgress: () => {} });
|
||||
expect(update.incrementalStats).toMatchObject({
|
||||
writeMode: 'incremental',
|
||||
changedFiles: 1,
|
||||
deletedFiles: 0,
|
||||
});
|
||||
const published = (await loadMeta(storagePath))!;
|
||||
const immediate = await read();
|
||||
expect(JSON.stringify(immediate)).toContain('refreshed_zero');
|
||||
expect(immediate.staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: published.lastCommit,
|
||||
indexedAt: published.indexedAt,
|
||||
});
|
||||
expect(published.lastCommit).not.toBe(beforeMeta.lastCommit);
|
||||
const context = await call('context', { repo, name: 'refreshed_zero', include_content: true });
|
||||
expect(context.symbol).toMatchObject({
|
||||
name: 'refreshed_zero',
|
||||
filePath: 'unit_0.py',
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
});
|
||||
expect(context.staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: published.lastCommit,
|
||||
indexedAt: published.indexedAt,
|
||||
});
|
||||
const query = await call('query', {
|
||||
repo,
|
||||
search_query: 'refreshed_zero',
|
||||
include_content: true,
|
||||
});
|
||||
expect(query.staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: published.lastCommit,
|
||||
indexedAt: published.indexedAt,
|
||||
});
|
||||
if (ftsLoaded)
|
||||
expect([...query.process_symbols, ...query.definitions]).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'refreshed_zero',
|
||||
filePath: 'unit_0.py',
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
await runFullAnalysis(repo, { ...options, force: true }, { onProgress: () => {} });
|
||||
const rebuilt = (await loadMeta(storagePath))!;
|
||||
expect(rebuilt.lastCommit).toBe(published.lastCommit);
|
||||
expect(rebuilt.indexedAt).not.toBe(published.indexedAt);
|
||||
expect((await read()).staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: rebuilt.lastCommit,
|
||||
indexedAt: rebuilt.indexedAt,
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
it('keeps a busy native reader on its old ref, then refreshes on the next call', async () => {
|
||||
const storagePath = getStoragePaths(repo).storagePath;
|
||||
const previous = (await loadMeta(storagePath))!;
|
||||
await read();
|
||||
const source = await fs.readFile(path.join(repo, 'unit_0.py'), 'utf8');
|
||||
const baselineName = /^def (\w+)\(/m.exec(source)![1];
|
||||
let release!: () => void;
|
||||
let entered!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const original = lbug.QueryResult.prototype.getAll;
|
||||
let held = false;
|
||||
// Pause after the real native rows are drained but before the adapter closes
|
||||
// that result and returns its connection. No native implementation is mocked.
|
||||
const spy = vi.spyOn(lbug.QueryResult.prototype, 'getAll').mockImplementation(async function (
|
||||
this: lbug.QueryResult,
|
||||
) {
|
||||
const rows = await original.call(this);
|
||||
if (!held && rows.length === 1 && rows[0]?.name === baselineName) {
|
||||
held = true;
|
||||
entered();
|
||||
await gate;
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
const oldRead = read();
|
||||
let releasedResult: any;
|
||||
const other = new LocalBackend();
|
||||
try {
|
||||
await ready;
|
||||
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def newest_zero():\n return 9002\n');
|
||||
commitAll(repo, 'newer function');
|
||||
const update = await runFullAnalysis(repo, options, { onProgress: () => {} });
|
||||
expect(update.incrementalStats?.writeMode).toBe('incremental');
|
||||
const busyRead = await read();
|
||||
expect(JSON.stringify(busyRead)).toContain(baselineName);
|
||||
expect(busyRead.staleness).toMatchObject({
|
||||
status: 'behind',
|
||||
lastCommit: previous.lastCommit,
|
||||
indexedAt: previous.indexedAt,
|
||||
});
|
||||
await other.init();
|
||||
// This backend has no metadata for the native generation already held by
|
||||
// its sibling, so it must not label those old rows with the new disk ref.
|
||||
const unknown = await other.callTool('cypher', {
|
||||
repo,
|
||||
statement: "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' RETURN n.name AS name",
|
||||
});
|
||||
expect(JSON.stringify(unknown)).toContain(baselineName);
|
||||
expect(unknown.staleness).toMatchObject({ status: 'unknown', lastCommit: '', indexedAt: '' });
|
||||
} finally {
|
||||
release();
|
||||
releasedResult = await oldRead;
|
||||
spy.mockRestore();
|
||||
}
|
||||
const published = (await loadMeta(storagePath))!;
|
||||
expect(releasedResult.staleness).toMatchObject({
|
||||
lastCommit: previous.lastCommit,
|
||||
indexedAt: previous.indexedAt,
|
||||
});
|
||||
const recovered = await other.callTool('cypher', {
|
||||
repo,
|
||||
statement: "MATCH (n:Function) WHERE n.filePath = 'unit_0.py' RETURN n.name AS name",
|
||||
});
|
||||
expect(recovered.staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: published.lastCommit,
|
||||
indexedAt: published.indexedAt,
|
||||
});
|
||||
const fresh = await read();
|
||||
expect(JSON.stringify(fresh)).toContain('newest_zero');
|
||||
expect(fresh.staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: published.lastCommit,
|
||||
indexedAt: published.indexedAt,
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
it('does not certify a swapped graph until its final metadata stamp arrives', async () => {
|
||||
const storagePath = getStoragePaths(repo).storagePath;
|
||||
const previous = (await loadMeta(storagePath))!;
|
||||
await read();
|
||||
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def after_gap():\n return 9003\n');
|
||||
commitAll(repo, 'publication gap');
|
||||
await runFullAnalysis(repo, options, { onProgress: () => {} });
|
||||
const published = (await loadMeta(storagePath))!;
|
||||
// Reproduce the actual publication ordering: new native graph, old stamp.
|
||||
await saveMeta(storagePath, previous);
|
||||
try {
|
||||
const gap = await read();
|
||||
expect(JSON.stringify(gap)).toContain('after_gap');
|
||||
expect(gap.staleness).toMatchObject({ status: 'unknown', lastCommit: '', indexedAt: '' });
|
||||
} finally {
|
||||
await saveMeta(storagePath, published);
|
||||
}
|
||||
expect((await read()).staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: published.lastCommit,
|
||||
indexedAt: published.indexedAt,
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
it('keeps a pinned branch isolated when the flat workspace is reindexed', async () => {
|
||||
const oldBranch = execFileSync('git', ['branch', '--show-current'], {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
execFileSync('git', ['checkout', '-qb', 'generation-feature'], { cwd: repo });
|
||||
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def branch_zero():\n return 9004\n');
|
||||
commitAll(repo, 'feature function');
|
||||
await runFullAnalysis(
|
||||
repo,
|
||||
{ ...options, branch: 'generation-feature' },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
await client.callTool({ name: 'list_repos', arguments: {} });
|
||||
const pinned = await read('generation-feature');
|
||||
execFileSync('git', ['checkout', '-q', oldBranch], { cwd: repo });
|
||||
await fs.writeFile(path.join(repo, 'unit_0.py'), 'def flat_latest():\n return 9005\n');
|
||||
commitAll(repo, 'flat update');
|
||||
await runFullAnalysis(repo, options, { onProgress: () => {} });
|
||||
const flat = await read();
|
||||
expect(JSON.stringify(flat)).toContain('flat_latest');
|
||||
expect(flat.staleness.branch).toBe(oldBranch);
|
||||
const stillPinned = await read('generation-feature');
|
||||
expect(JSON.stringify(stillPinned)).toContain('branch_zero');
|
||||
expect(stillPinned.staleness).toMatchObject({
|
||||
lastCommit: pinned.staleness.lastCommit,
|
||||
branch: 'generation-feature',
|
||||
});
|
||||
expect((await read()).staleness.lastCommit).toBe(flat.staleness.lastCommit);
|
||||
}, 120_000);
|
||||
|
||||
it('follows a shared-store graph pointer with matching commit and checkout branch', async () => {
|
||||
vi.stubEnv('GITNEXUS_SHARED_STORE', undefined);
|
||||
const sharedRepo = path.join(temp.dbPath, 'shared-repo');
|
||||
const sibling = path.join(temp.dbPath, 'shared-sibling');
|
||||
await fs.mkdir(sharedRepo);
|
||||
await fs.writeFile(path.join(sharedRepo, '.gitignore'), '.gitnexus/\n');
|
||||
await fs.writeFile(path.join(sharedRepo, 'unit_0.py'), 'def shared_before():\n return 1\n');
|
||||
initGitRepo(sharedRepo);
|
||||
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: sharedRepo });
|
||||
commitAll(sharedRepo, 'shared baseline');
|
||||
execFileSync('git', ['worktree', 'add', '-qb', 'shared-sibling', sibling], { cwd: sharedRepo });
|
||||
await runFullAnalysis(sharedRepo, options, { onProgress: () => {} });
|
||||
const storagePath = getStoragePaths(sharedRepo).storagePath;
|
||||
const previous = (await loadMeta(storagePath))!;
|
||||
expect(previous.graphPath).toBeTruthy();
|
||||
await runFullAnalysis(sibling, options, { onProgress: () => {} });
|
||||
const siblingMeta = (await loadMeta(getStoragePaths(sibling).storagePath))!;
|
||||
expect(siblingMeta.graphPath).toBe(previous.graphPath);
|
||||
const [mainResult, siblingResult] = await Promise.all([
|
||||
read(undefined, sharedRepo),
|
||||
read(undefined, sibling),
|
||||
]);
|
||||
expect(mainResult.staleness).toMatchObject({
|
||||
lastCommit: previous.lastCommit,
|
||||
branch: previous.branch,
|
||||
});
|
||||
expect(siblingResult.staleness).toMatchObject({
|
||||
lastCommit: previous.lastCommit,
|
||||
branch: 'shared-sibling',
|
||||
});
|
||||
execFileSync('git', ['checkout', '-qb', 'shared-feature'], { cwd: sharedRepo });
|
||||
await fs.writeFile(path.join(sharedRepo, 'unit_0.py'), 'def shared_after():\n return 2\n');
|
||||
commitAll(sharedRepo, 'shared update');
|
||||
await runFullAnalysis(sharedRepo, options, { onProgress: () => {} });
|
||||
const published = (await loadMeta(storagePath))!;
|
||||
expect(published.graphPath).not.toBe(previous.graphPath);
|
||||
const moved = await read(undefined, sharedRepo);
|
||||
expect(JSON.stringify(moved)).toContain('shared_after');
|
||||
expect(moved.staleness).toMatchObject({
|
||||
status: 'current',
|
||||
lastCommit: published.lastCommit,
|
||||
indexedAt: published.indexedAt,
|
||||
branch: 'shared-feature',
|
||||
});
|
||||
}, 120_000);
|
||||
});
|
||||
242
gitnexus/test/integration/query-symbol-integrity.test.ts
Normal file
242
gitnexus/test/integration/query-symbol-integrity.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/** Injected corruption proves the query boundary, not the cause of #3527/#3564. */
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
|
||||
import { runFullAnalysis } from '../../src/core/run-analyze.js';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { SYMBOL_IDENTITY_RECOVERY_SUGGESTION } from '../../src/mcp/local/query-result-integrity.js';
|
||||
import { createMCPServer, getNextStepHint } from '../../src/mcp/server.js';
|
||||
import { getStoragePaths } from '../../src/storage/repo-manager.js';
|
||||
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
const SOURCE = [
|
||||
'export function integrityNeedle() {',
|
||||
' return 42;',
|
||||
'}',
|
||||
'',
|
||||
'export function startIntegrityFlow() {',
|
||||
' return middleIntegrityFlow();',
|
||||
'}',
|
||||
'',
|
||||
'function middleIntegrityFlow() {',
|
||||
' return integrityNeedle();',
|
||||
'}',
|
||||
].join('\n');
|
||||
const UID = 'Function:src/fixture.ts:integrityNeedle';
|
||||
|
||||
describe('query identity boundary through the real MCP server', () => {
|
||||
let temp: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let repo: string;
|
||||
let lbugPath: string;
|
||||
let snapshot: string;
|
||||
let ftsLoaded = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
temp = await createTempDir();
|
||||
const root = await fs.realpath(temp.dbPath);
|
||||
const userHome = path.join(root, 'user-home');
|
||||
await fs.mkdir(userHome);
|
||||
vi.stubEnv('HOME', userHome);
|
||||
vi.stubEnv('USERPROFILE', userHome);
|
||||
vi.stubEnv('GITNEXUS_HOME', path.join(root, 'home'));
|
||||
vi.stubEnv('GITNEXUS_STORAGE_PATH', undefined);
|
||||
vi.stubEnv('GITNEXUS_STORAGE_ROOT', undefined);
|
||||
vi.stubEnv('GITNEXUS_SHARED_STORE', 'off');
|
||||
vi.stubEnv('GITNEXUS_CONTENT_RETENTION', 'full');
|
||||
vi.stubEnv('GITNEXUS_LBUG_EXTENSION_INSTALL', 'load-only');
|
||||
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '1');
|
||||
await adapter.initLbug(path.join(root, 'fts-probe'));
|
||||
ftsLoaded = await adapter.loadFTSExtension(undefined, { policy: 'load-only' });
|
||||
await adapter.closeLbug();
|
||||
if (!ftsLoaded) {
|
||||
if (process.env.GITNEXUS_REQUIRE_FTS === '1')
|
||||
throw new Error('FTS is required for this test');
|
||||
return;
|
||||
}
|
||||
repo = path.join(root, 'repo');
|
||||
await fs.mkdir(path.join(repo, 'src'), { recursive: true });
|
||||
await fs.writeFile(path.join(repo, '.gitignore'), '.gitnexus/\n');
|
||||
await fs.writeFile(path.join(repo, 'src/fixture.ts'), SOURCE);
|
||||
initGitRepo(repo);
|
||||
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: repo });
|
||||
commitAll(repo, 'source-derived query fixture');
|
||||
await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} });
|
||||
lbugPath = getStoragePaths(repo).lbugPath;
|
||||
snapshot = path.join(root, 'healthy.lbug');
|
||||
await fs.copyFile(lbugPath, snapshot);
|
||||
const groupDir = path.join(root, 'home', 'groups', 'integrity');
|
||||
await fs.mkdir(groupDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(groupDir, 'group.yaml'),
|
||||
JSON.stringify({ version: 1, name: 'integrity', repos: { service: 'repo' } }),
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
beforeEach(({ skip }) => {
|
||||
if (!ftsLoaded) skip('FTS extension unavailable under the load-only policy');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await adapter.closeLbug();
|
||||
vi.unstubAllEnvs();
|
||||
await temp?.cleanup();
|
||||
});
|
||||
|
||||
async function query(mutate?: () => Promise<void>, legacy = false, target = repo): Promise<any> {
|
||||
await fs.copyFile(snapshot, lbugPath);
|
||||
if (mutate) {
|
||||
await adapter.initLbug(lbugPath);
|
||||
try {
|
||||
await mutate();
|
||||
} finally {
|
||||
await adapter.closeLbug();
|
||||
}
|
||||
}
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
const server = createMCPServer(backend);
|
||||
const client = new Client({ name: 'query-integrity-regression', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const args = {
|
||||
repo: target,
|
||||
[legacy ? 'query' : 'search_query']: 'integrityNeedle',
|
||||
include_content: true,
|
||||
chain_depth: 1,
|
||||
};
|
||||
try {
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
const result = await client.callTool({ name: 'query', arguments: args });
|
||||
expect(result.isError).not.toBe(true);
|
||||
const content = result.content as Array<{ type: string; text: string }>;
|
||||
const text = content.find((item) => item.type === 'text')!.text;
|
||||
const hint = getNextStepHint('query', args);
|
||||
return JSON.parse(hint ? text.slice(0, -hint.length) : text);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await backend.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
it('returns source-derived symbol identity, range and content', async () => {
|
||||
const result = await query();
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result).not.toHaveProperty('partial');
|
||||
expect([...result.process_symbols, ...result.definitions]).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: UID,
|
||||
name: 'integrityNeedle',
|
||||
filePath: 'src/fixture.ts',
|
||||
startLine: 1,
|
||||
endLine: 3,
|
||||
content: SOURCE.split('\n').slice(0, 3).join(' '),
|
||||
}),
|
||||
);
|
||||
expect(result.processes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NUL symbol name', 'MATCH (n:Function {id: $uid}) SET n.name = $value', 'invalid\0name'],
|
||||
['NUL symbol path', 'MATCH (n:Function {id: $uid}) SET n.filePath = $value', 'invalid\0path'],
|
||||
['NUL process label', 'MATCH (p:Process) SET p.label = $value', 'invalid\0process'],
|
||||
['NUL cluster label', 'MATCH (c:Community) SET c.heuristicLabel = $value', 'invalid\0cluster'],
|
||||
])(
|
||||
'rejects injected %s instead of returning success or a fallback',
|
||||
async (_name, statement, value) => {
|
||||
const result = await query(async () => {
|
||||
await adapter.executePrepared(statement, { uid: UID, value });
|
||||
}, true);
|
||||
expect(result.error).toContain('invalid symbol identity');
|
||||
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
|
||||
expect(result).not.toHaveProperty('definitions');
|
||||
expect(JSON.stringify(result)).not.toContain('\\u0000');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a blank UID read from native FTS instead of dropping it', async () => {
|
||||
const result = await query(async () => {
|
||||
await adapter.executeQuery(
|
||||
"CREATE (n:Function {id: '', name: 'integrityNeedle', filePath: 'src/fixture.ts', startLine: 0, endLine: 2})",
|
||||
);
|
||||
await adapter.dropFTSIndex('Function', 'function_fts');
|
||||
await adapter.createFTSIndex('Function', 'function_fts', ['name', 'content', 'description']);
|
||||
});
|
||||
expect(result.error).toContain('invalid symbol identity');
|
||||
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
|
||||
expect(result).not.toHaveProperty('definitions');
|
||||
});
|
||||
|
||||
it('accepts legacy missing process entry points and empty optional labels', async () => {
|
||||
const result = await query(async () => {
|
||||
await adapter.executeQuery(
|
||||
"MATCH (p:Process) SET p.entryPointId = NULL, p.heuristicLabel = ''",
|
||||
);
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.processes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not treat source content as an identity', async () => {
|
||||
const content = 'source text containing \0 is not an identity';
|
||||
const result = await query(async () => {
|
||||
await adapter.executePrepared('MATCH (n:Function {id: $uid}) SET n.content = $content', {
|
||||
uid: UID,
|
||||
content,
|
||||
});
|
||||
});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.process_symbols).toContainEqual(expect.objectContaining({ id: UID, content }));
|
||||
});
|
||||
|
||||
it('rejects corrupt route enrichment rather than degrading to a route-less process', async () => {
|
||||
const result = await query(async () => {
|
||||
await adapter.executePrepared(
|
||||
"CREATE (r:Route {id: 'Route:injected', name: '/example', method: $method})",
|
||||
{ method: 'GE\0T' },
|
||||
);
|
||||
await adapter.executeQuery(
|
||||
"MATCH (p:Process), (r:Route {id: 'Route:injected'}) CREATE (r)-[:CodeRelation {type: 'ENTRY_POINT_OF'}]->(p)",
|
||||
);
|
||||
});
|
||||
expect(result.error).toContain('invalid symbol identity');
|
||||
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
|
||||
});
|
||||
|
||||
it('preserves the integrity error through a group query and its legacy query argument', async () => {
|
||||
const healthy = await query(undefined, true, '@integrity');
|
||||
expect(healthy).not.toHaveProperty('error');
|
||||
expect(healthy.results.length).toBeGreaterThan(0);
|
||||
const result = await query(
|
||||
async () => {
|
||||
await adapter.executePrepared('MATCH (n:Function {id: $uid}) SET n.name = $name', {
|
||||
uid: UID,
|
||||
name: 'invalid\0name',
|
||||
});
|
||||
},
|
||||
true,
|
||||
'@integrity',
|
||||
);
|
||||
expect(result.error).toContain('invalid symbol identity');
|
||||
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
|
||||
expect(result).not.toHaveProperty('results');
|
||||
});
|
||||
|
||||
it('rejects corruption found only by concurrent chain enrichment', async () => {
|
||||
const result = await query(async () => {
|
||||
await adapter.executePrepared(
|
||||
"CREATE (n:Function {id: 'Function:chain-only', name: $name, filePath: 'src/fixture.ts'})",
|
||||
{ name: 'invalid\0chain' },
|
||||
);
|
||||
await adapter.executeQuery(
|
||||
"MATCH (p:Process), (entry:Function), (n:Function {id: 'Function:chain-only'}) WHERE entry.id = p.entryPointId CREATE (entry)-[:CodeRelation {type: 'CALLS'}]->(n)",
|
||||
);
|
||||
});
|
||||
expect(result.error).toContain('invalid symbol identity');
|
||||
expect(result.recoverySuggestion).toBe(SYMBOL_IDENTITY_RECOVERY_SUGGESTION);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<StalenessInfo>((_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<StalenessInfo>((_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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof IMPACT_RESULT> },
|
||||
'impact',
|
||||
).mockResolvedValue(IMPACT_RESULT as never);
|
||||
const current = await backend.callTool('impact', { target: 'doWork', repo: 'current' });
|
||||
const behind = await backend.callTool('impact', { target: 'doWork', repo: 'advanced' });
|
||||
expect(current.staleness).toMatchObject({ status: 'current', lastCommit: indexedCommit });
|
||||
expect(behind.staleness).toMatchObject({
|
||||
status: 'behind',
|
||||
commitsBehind: 1,
|
||||
lastCommit: indexedCommit,
|
||||
});
|
||||
});
|
||||
|
||||
// Negative control. The same wiring — real git, real `checkStalenessAsync`,
|
||||
// real `attachToolStaleness` — must still report a counted gap when the index
|
||||
// is behind its own checkout. Without this, the ref-carrying payload above
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue