mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
Tighten review-evolution scoring, sandbox lock, and gateway cleanup so historical cells score instead of aborting or leaking host state. Co-authored-by: Cursor <cursoragent@cursor.com>
1725 lines
81 KiB
Diff
1725 lines
81 KiB
Diff
diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts
|
|
index 86f763ffa..d107f5e79 100644
|
|
--- a/gitnexus/src/core/lbug/lbug-adapter.ts
|
|
+++ b/gitnexus/src/core/lbug/lbug-adapter.ts
|
|
@@ -3077,6 +3077,55 @@ export const ensureFTSIndex = async (
|
|
}
|
|
};
|
|
|
|
+export type FtsQueryFailureClass = 'missing-index' | 'missing-table' | 'other';
|
|
+
|
|
+/**
|
|
+ * Classify a `QUERY_FTS_INDEX` failure so a genuinely-missing index (normal —
|
|
+ * this table's FTS index hasn't been built yet) is distinguished from a real
|
|
+ * query-time error that would otherwise look identical (#2767), and from the
|
|
+ * table itself being missing (schema drift / a corrupted or partial DB — a
|
|
+ * much more serious condition than an unbuilt index).
|
|
+ *
|
|
+ * tri-review Residual-1: this used to be a second, independently-maintained
|
|
+ * classifier living in `core/search/bm25-index.ts` (re-exported from there
|
|
+ * for backward compatibility), duplicating this function's job for the
|
|
+ * IDENTICAL `QUERY_FTS_INDEX` cypher call. `queryFTS` below now uses this
|
|
+ * same classifier for its own catch instead of a bare, unanchored
|
|
+ * `.includes('does not exist')` check that could not tell "index missing"
|
|
+ * from "table missing" apart, and silently swallowed both alike.
|
|
+ *
|
|
+ * Three real message shapes were confirmed empirically against a live
|
|
+ * `CALL QUERY_FTS_INDEX(...)`:
|
|
+ * `"Prepare failed: Binder exception: Table <T> doesn't have an index with
|
|
+ * name <name>."` — the table exists, only its FTS index is missing (normal,
|
|
+ * benign — `missing-index`) — `"Prepare failed: Binder exception: Table <T>
|
|
+ * does not exist."` — the TABLE ITSELF is missing (`missing-table`) — and a
|
|
+ * `Catalog exception: function QUERY_FTS_INDEX is not defined...` when the
|
|
+ * FTS extension isn't loaded at all (`other`; mirrors the confirmed
|
|
+ * `DROP_FTS_INDEX` shape in {@link isBenignDropFtsIndexError}'s doc comment).
|
|
+ *
|
|
+ * Anchored to the exception class (after stripping the optional "Prepare
|
|
+ * failed: " wrapper LadybugDB adds for statement-preparation failures),
|
|
+ * mirroring `isBenignDropFtsIndexError`'s START-of-message anchor: a bare
|
|
+ * substring search would misclassify a genuine, differently-classed error
|
|
+ * (e.g. a `Runtime exception` from the FTS parser that echoes the user's
|
|
+ * own search text back into its message) as benign whenever that echoed
|
|
+ * text happened to contain "does not exist" — silently dropping a real
|
|
+ * error, the exact #2767 failure mode this function exists to prevent.
|
|
+ */
|
|
+export const classifyFtsQueryError = (message: string): FtsQueryFailureClass => {
|
|
+ const PREPARE_FAILED_PREFIX = 'Prepare failed: ';
|
|
+ const body = message.startsWith(PREPARE_FAILED_PREFIX)
|
|
+ ? message.slice(PREPARE_FAILED_PREFIX.length)
|
|
+ : message;
|
|
+ if (!body.startsWith('Binder exception:') && !body.startsWith('Catalog exception:')) {
|
|
+ return 'other';
|
|
+ }
|
|
+ if (body.includes("doesn't have an index")) return 'missing-index';
|
|
+ if (body.includes('does not exist')) return 'missing-table';
|
|
+ return 'other';
|
|
+};
|
|
+
|
|
/**
|
|
* Query a full-text search index
|
|
* @param tableName - The node table name
|
|
@@ -3121,8 +3170,13 @@ export const queryFTS = async (
|
|
};
|
|
});
|
|
} catch (e: any) {
|
|
- // Return empty if index doesn't exist yet
|
|
- if (e.message?.includes('does not exist')) {
|
|
+ // Return empty only for a genuinely-missing index — the ordinary,
|
|
+ // expected case. A missing TABLE (schema drift) or any other real error
|
|
+ // rethrows instead of being silently swallowed (tri-review Residual-1 /
|
|
+ // NEW-6 — this used to be a bare `.includes('does not exist')` check
|
|
+ // that could not tell the two apart).
|
|
+ const message = e instanceof Error ? e.message : String(e);
|
|
+ if (classifyFtsQueryError(message) === 'missing-index') {
|
|
return [];
|
|
}
|
|
throw e;
|
|
diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts
|
|
index 0908175d8..754c1e6b7 100644
|
|
--- a/gitnexus/src/core/run-analyze.ts
|
|
+++ b/gitnexus/src/core/run-analyze.ts
|
|
@@ -1031,6 +1031,47 @@ async function runFullAnalysisInner(
|
|
);
|
|
}
|
|
await ensureGitNexusIgnored(repoPath);
|
|
+ // #2767: stamp ONLY capabilities.fts so a long-lived MCP session's
|
|
+ // ensureInitialized() has an explicit, correctly-scoped signal that FTS
|
|
+ // changed — indexedAt/lastCommit/runnerIdentity/stats are copied through
|
|
+ // untouched (see the "must not claim a new analyzer identity" comment
|
|
+ // below). capabilities is forensic/no-programmatic-readers-until-now, so
|
|
+ // graph/vectorSearch are backfilled with conservative, honest defaults
|
|
+ // when a legacy meta.json predates this field entirely — repair-fts
|
|
+ // never touched them and cannot claim a capability it did not verify.
|
|
+ // Best-effort: a write failure must not turn an already-successful FTS
|
|
+ // rebuild into a reported repair failure.
|
|
+ try {
|
|
+ // Re-read the on-disk meta immediately before writing, rather than
|
|
+ // reusing `existingMeta` (captured before the FTS rebuild ran, which
|
|
+ // can span real wall-clock time). Another writer to this same
|
|
+ // gitnexus.json in the interim — e.g. the HTTP server's background
|
|
+ // embedding-checkpoint job — must not have its update silently
|
|
+ // reverted by this stamp overwriting a stale snapshot. Falls back to
|
|
+ // `existingMeta` only if the file became unreadable in that window.
|
|
+ const latestMeta = (await loadMeta(metaDir)) ?? existingMeta;
|
|
+ await saveMeta(metaDir, {
|
|
+ ...latestMeta,
|
|
+ capabilities: {
|
|
+ graph: latestMeta.capabilities?.graph ?? {
|
|
+ provider: 'ladybugdb',
|
|
+ status: 'available',
|
|
+ },
|
|
+ fts: { provider: 'ladybugdb-fts', status: 'available' },
|
|
+ vectorSearch: latestMeta.capabilities?.vectorSearch ?? {
|
|
+ provider: 'exact-scan',
|
|
+ status: 'unavailable',
|
|
+ exactScanLimit: 0,
|
|
+ },
|
|
+ },
|
|
+ });
|
|
+ } catch (err) {
|
|
+ log(
|
|
+ `FTS capability stamp write failed (non-critical, repair itself succeeded${
|
|
+ err instanceof Error ? `: ${err.message}` : ''
|
|
+ }); continuing.`,
|
|
+ );
|
|
+ }
|
|
progress('fts', 90, 'Search indexes ready');
|
|
progress('done', 100, 'Done');
|
|
return {
|
|
diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts
|
|
index 19196b11b..44cfe4ce2 100644
|
|
--- a/gitnexus/src/core/search/bm25-index.ts
|
|
+++ b/gitnexus/src/core/search/bm25-index.ts
|
|
@@ -5,8 +5,14 @@
|
|
* Always reads from the database (no cached state to drift).
|
|
*/
|
|
|
|
-import { queryFTS } from '../lbug/lbug-adapter.js';
|
|
+// tri-review Residual-1: `classifyFtsQueryError` now lives in lbug-adapter.ts
|
|
+// (see its doc comment) so `queryFTS`'s own catch can share the SAME
|
|
+// classifier instead of maintaining a second, independently-drifting copy
|
|
+// for the identical `QUERY_FTS_INDEX` cypher call.
|
|
+import { queryFTS, classifyFtsQueryError } from '../lbug/lbug-adapter.js';
|
|
import { normalizeFtsText } from '../lbug/csv-generator.js';
|
|
+import { getExtensionCapabilities } from '../lbug/extension-loader.js';
|
|
+import { redactPaths } from './fts-indexes.js';
|
|
import { FTS_INDEXES } from './fts-schema.js';
|
|
import {
|
|
applyCjkSegmentationIfEnabled,
|
|
@@ -24,12 +30,40 @@ export interface FTSSearchResponse {
|
|
results: BM25SearchResult[];
|
|
/** True when at least one FTS index query succeeded (index exists). */
|
|
ftsAvailable: boolean;
|
|
+ /**
|
|
+ * Redacted (via {@link redactPaths}) message(s) from per-table
|
|
+ * `QUERY_FTS_INDEX` calls that failed for a reason OTHER than "index
|
|
+ * doesn't exist" (#2767) — a real query/connection error was previously
|
|
+ * indistinguishable from a genuinely-missing index. Populated whenever ANY
|
|
+ * table hit a non-benign error, regardless of whether other tables
|
|
+ * succeeded, so a caller can always log it; whether to also surface it in
|
|
+ * a client-facing warning is a caller decision (see `LocalBackend.query()`,
|
|
+ * which only does so when every table failed).
|
|
+ */
|
|
+ nonBenignErrors?: string[];
|
|
+}
|
|
+
|
|
+/**
|
|
+ * Optional-field shape rather than a discriminated union: this project builds
|
|
+ * with `strict: false` (no `strictNullChecks`), under which TypeScript's
|
|
+ * control-flow narrowing across an `if/else` on a boolean discriminant is
|
|
+ * unreliable (verified empirically — narrows correctly under `strict: true`,
|
|
+ * fails under `strict: false`). `rows` present means success; `rows` absent
|
|
+ * means failure, with `benign`/`message` describing why.
|
|
+ */
|
|
+interface FTSQueryOutcome {
|
|
+ rows?: Array<{ filePath: string; score: number; nodeId: string }>;
|
|
+ benign?: boolean;
|
|
+ message?: string;
|
|
}
|
|
|
|
/**
|
|
* Execute a single FTS query via a custom executor (for MCP connection pool).
|
|
- * Returns `null` when the query fails (e.g. FTS index does not exist) so the
|
|
- * caller can distinguish "zero matches" from "index missing".
|
|
+ * Returns a benign failure when the query fails because the index doesn't
|
|
+ * exist (the normal, expected case), and a non-benign failure with the
|
|
+ * captured message for any other error, so the caller can distinguish "zero
|
|
+ * matches", "index missing", and "a real error occurred" instead of
|
|
+ * collapsing the latter two into the same silent `null`.
|
|
*/
|
|
async function queryFTSViaExecutor(
|
|
executor: (cypher: string, params: Record<string, any>) => Promise<any[]>,
|
|
@@ -37,7 +71,7 @@ async function queryFTSViaExecutor(
|
|
indexName: string,
|
|
query: string,
|
|
limit: number,
|
|
-): Promise<Array<{ filePath: string; score: number; nodeId: string }> | null> {
|
|
+): Promise<FTSQueryOutcome> {
|
|
const cypher = `
|
|
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false)
|
|
RETURN node, score
|
|
@@ -46,17 +80,20 @@ async function queryFTSViaExecutor(
|
|
`;
|
|
try {
|
|
const rows = await executor(cypher, { query });
|
|
- return rows.map((row: any) => {
|
|
- const node = row.node || row[0] || {};
|
|
- const score = row.score ?? row[1] ?? 0;
|
|
- return {
|
|
- filePath: node.filePath || '',
|
|
- score: typeof score === 'number' ? score : parseFloat(score) || 0,
|
|
- nodeId: node.nodeId || node.id || '',
|
|
- };
|
|
- });
|
|
- } catch {
|
|
- return null;
|
|
+ return {
|
|
+ rows: rows.map((row: any) => {
|
|
+ const node = row.node || row[0] || {};
|
|
+ const score = row.score ?? row[1] ?? 0;
|
|
+ return {
|
|
+ filePath: node.filePath || '',
|
|
+ score: typeof score === 'number' ? score : parseFloat(score) || 0,
|
|
+ nodeId: node.nodeId || node.id || '',
|
|
+ };
|
|
+ }),
|
|
+ };
|
|
+ } catch (e) {
|
|
+ const message = e instanceof Error ? e.message : String(e);
|
|
+ return { benign: classifyFtsQueryError(message) === 'missing-index', message };
|
|
}
|
|
}
|
|
|
|
@@ -95,8 +132,23 @@ export const searchFTSFromLbug = async (
|
|
);
|
|
const resultsByIndex: any[][] = [];
|
|
let queriesSucceeded = 0;
|
|
+ const nonBenignErrors: string[] = [];
|
|
|
|
- if (repoId) {
|
|
+ const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts');
|
|
+ if (ftsExtension && !ftsExtension.loaded) {
|
|
+ // tri-review NEW-4 (applies to BOTH the MCP pool path and the CLI/pipeline
|
|
+ // path — a /simplify altitude pass caught the original repoId-only guard
|
|
+ // letting the CLI branch surface spurious "non-benign" errors for this
|
|
+ // exact expected state, which the pool branch correctly stayed silent on):
|
|
+ // extension-unavailable is an expected, already-diagnosed degraded-capability
|
|
+ // state (#2374/#2658), not a per-table query error — every configured table
|
|
+ // would throw the identical "function not defined" shape, which
|
|
+ // classifyFtsQueryError correctly refuses to call benign "missing-index"
|
|
+ // (it's a different, more serious condition). Skip the N redundant
|
|
+ // QUERY_FTS_INDEX round-trips and N nonBenignErrors entries; ftsAvailable
|
|
+ // stays false and ftsDegradedWarning() already reports this state
|
|
+ // accurately from the same extension-capabilities registry.
|
|
+ } else if (repoId) {
|
|
// Use MCP connection pool via dynamic import
|
|
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
|
|
// The MCP pool supports multiple connections, but FTS is best run serially.
|
|
@@ -106,21 +158,28 @@ export const searchFTSFromLbug = async (
|
|
executeParameterized(repoId, cypher, params);
|
|
|
|
for (const { table, indexName } of FTS_INDEXES) {
|
|
- const result = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit);
|
|
- if (result !== null) {
|
|
+ const outcome = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit);
|
|
+ if (outcome.rows) {
|
|
queriesSucceeded++;
|
|
- resultsByIndex.push(result);
|
|
+ resultsByIndex.push(outcome.rows);
|
|
+ } else if (!outcome.benign) {
|
|
+ nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error'));
|
|
}
|
|
}
|
|
} else {
|
|
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
|
|
+ // tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing
|
|
+ // index (via the SAME classifyFtsQueryError this module re-exports); a
|
|
+ // missing-table or real query error rethrows here — track it the same way
|
|
+ // the MCP pool path does instead of a bare `catch {}` that dropped it.
|
|
for (const { table, indexName } of FTS_INDEXES) {
|
|
try {
|
|
const result = await queryFTS(table, indexName, searchQuery, limit, false);
|
|
queriesSucceeded++;
|
|
resultsByIndex.push(result);
|
|
- } catch {
|
|
- // FTS index may not exist — count as failed
|
|
+ } catch (e) {
|
|
+ const message = e instanceof Error ? e.message : String(e);
|
|
+ nonBenignErrors.push(redactPaths(message));
|
|
}
|
|
}
|
|
}
|
|
@@ -165,5 +224,6 @@ export const searchFTSFromLbug = async (
|
|
nodeIds: r.nodeIds,
|
|
})),
|
|
ftsAvailable,
|
|
+ ...(nonBenignErrors.length > 0 && { nonBenignErrors }),
|
|
};
|
|
};
|
|
diff --git a/gitnexus/src/core/search/fts-indexes.ts b/gitnexus/src/core/search/fts-indexes.ts
|
|
index ab098a68c..dfc55286c 100644
|
|
--- a/gitnexus/src/core/search/fts-indexes.ts
|
|
+++ b/gitnexus/src/core/search/fts-indexes.ts
|
|
@@ -11,17 +11,62 @@ import { FTS_INDEXES } from './fts-schema.js';
|
|
* ELF header", "has not been installed") have no leading path separator and
|
|
* survive. CLI/doctor/log surfaces keep the full path (they read the reason
|
|
* directly, not through this function).
|
|
+ *
|
|
+ * tri-review Residual-3: every real message shape observed from LadybugDB
|
|
+ * wraps the path in single quotes (`Failed to load library '<path>': ...`),
|
|
+ * so a QUOTED path is redacted first, consuming through its closing quote —
|
|
+ * spaces included (e.g. a Windows username like `alice smith`). The original
|
|
+ * unquoted-stop-at-first-whitespace pattern still runs afterward as a
|
|
+ * fallback for the rare case of a path appearing without quotes; that path's
|
|
+ * own known limitation (partial redaction if it itself contains a space) is
|
|
+ * unchanged, but is no longer the ONLY path this function knows how to redact.
|
|
+ */
|
|
+export const redactPaths = (reason: string): string =>
|
|
+ reason
|
|
+ .replace(/'((?:[A-Za-z]:\\|\/)[^']*)'/g, "'<path>'")
|
|
+ .replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, '<path>');
|
|
+
|
|
+/**
|
|
+ * Resolved-repo/index identity a caller can attach to a degraded-FTS warning
|
|
+ * (#2767) so a reader can tell whether *this* session even resolved the index
|
|
+ * they expect, instead of guessing between a stale connection, a different
|
|
+ * repo/branch, or a genuine build failure. MCP-`query`-only today — never
|
|
+ * forwarded into the HTTP `/api/search` response (see that call site).
|
|
*/
|
|
-const redactPaths = (reason: string): string =>
|
|
- reason.replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, '<path>');
|
|
+export interface FtsWarningContext {
|
|
+ repoName: string;
|
|
+ branch?: string;
|
|
+ indexedAt?: string;
|
|
+ /** Already redacted by the caller (e.g. via {@link redactPaths} on a captured query error). */
|
|
+ lastErrorRedacted?: string;
|
|
+}
|
|
+
|
|
+/** The repo/branch/indexed-at portion shared by both warning-context formatters below. */
|
|
+const formatResolvedSuffix = (context: FtsWarningContext): string => {
|
|
+ const branchSuffix = context.branch ? `/branch:${context.branch}` : '';
|
|
+ const indexedSuffix = context.indexedAt ? `, indexed ${context.indexedAt}` : '';
|
|
+ return `${context.repoName}${branchSuffix}${indexedSuffix}`;
|
|
+};
|
|
+
|
|
+const formatWarningContext = (context: FtsWarningContext): string => {
|
|
+ const errorSuffix = context.lastErrorRedacted ? `; last error: ${context.lastErrorRedacted}` : '';
|
|
+ return ` (resolved: ${formatResolvedSuffix(context)}${errorSuffix})`;
|
|
+};
|
|
|
|
/**
|
|
* Warning attached to search responses when BM25/FTS is degraded. Prefers the
|
|
* live extension-load failure (with LadybugDB's real reason, #2374) over the
|
|
* generic indexes-missing message, so "indexes exist but the extension broke"
|
|
* is not misreported as missing indexes.
|
|
+ *
|
|
+ * `context`, when supplied, appends the resolved repo/branch/indexed-at (and
|
|
+ * redacted query-error detail, if captured) so a CLI/MCP mismatch — or a real
|
|
+ * query error masquerading as "indexes missing" — is visible in the warning
|
|
+ * text itself (#2767). Optional and additive: omitting it reproduces today's
|
|
+ * exact message.
|
|
*/
|
|
-export const ftsDegradedWarning = (): string => {
|
|
+export const ftsDegradedWarning = (context?: FtsWarningContext): string => {
|
|
+ const suffix = context ? formatWarningContext(context) : '';
|
|
const fts = getExtensionCapabilities().find((c) => c.name === 'fts');
|
|
if (fts && !fts.loaded) {
|
|
const reason = fts.reason ? redactPaths(fts.reason).replace(/\.$/, '') : undefined;
|
|
@@ -38,12 +83,33 @@ export const ftsDegradedWarning = (): string => {
|
|
return (
|
|
'FTS extension failed to load — keyword search degraded' +
|
|
(reason ? ` (${reason})` : '') +
|
|
- tail
|
|
+ tail +
|
|
+ suffix
|
|
);
|
|
}
|
|
- return 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.';
|
|
+ return (
|
|
+ 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts ' +
|
|
+ '(or gitnexus analyze --force) to rebuild indexes.' +
|
|
+ suffix
|
|
+ );
|
|
};
|
|
|
|
+/**
|
|
+ * Warning for when the FTS extension is loaded and indexes exist, but every
|
|
+ * configured table's query failed for a real, non-benign reason (timeout,
|
|
+ * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
|
|
+ * missing-index case. `--repair-fts` will not fix a query/connection error,
|
|
+ * so this deliberately does NOT suggest it: reusing the missing-index
|
|
+ * message here would reproduce, for this cause, the exact misleading
|
|
+ * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
|
|
+ */
|
|
+export const ftsQueryFailedWarning = (context: FtsWarningContext): string =>
|
|
+ 'FTS keyword search failed — every configured index query returned an error' +
|
|
+ (context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') +
|
|
+ '; results do not include keyword matches. This is not a missing-index ' +
|
|
+ 'condition — see server logs for details.' +
|
|
+ ` (resolved: ${formatResolvedSuffix(context)})`;
|
|
+
|
|
// Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token
|
|
// set in the extension bundled with @ladybugdb/core 0.18.x (see package.json).
|
|
// Keep in sync on a LadybugDB minor bump — a value here that the installed
|
|
diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts
|
|
index 694483a6d..c4e9d12a9 100644
|
|
--- a/gitnexus/src/mcp/local/local-backend.ts
|
|
+++ b/gitnexus/src/mcp/local/local-backend.ts
|
|
@@ -63,7 +63,7 @@ import {
|
|
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
|
|
import { getExactScanLimit } from '../../core/platform/capabilities.js';
|
|
import { PhaseTimer } from '../../core/search/phase-timer.js';
|
|
-import { ftsDegradedWarning } from '../../core/search/fts-indexes.js';
|
|
+import { ftsDegradedWarning, ftsQueryFailedWarning } from '../../core/search/fts-indexes.js';
|
|
import {
|
|
cjkSegmentationModeMismatch,
|
|
containsSegmentableCjkRun,
|
|
@@ -779,6 +779,13 @@ export function attachToolStaleness(
|
|
};
|
|
}
|
|
|
|
+/** tri-review Residual-2: see `LocalBackend.lastObservedPoolState`'s doc comment. */
|
|
+interface PoolObservedState {
|
|
+ indexedAt?: string;
|
|
+ dbIdentity: Awaited<ReturnType<typeof statDbIdentity>>;
|
|
+ ftsStatus?: string;
|
|
+}
|
|
+
|
|
export class LocalBackend {
|
|
private static readonly TOOL_STALENESS_TTL_MS = 5000;
|
|
private repos: Map<string, RepoHandle> = new Map();
|
|
@@ -796,18 +803,30 @@ export class LocalBackend {
|
|
// the other; lbugPath is unique per flat/branch index.
|
|
private toolStalenessCache: Map<string, { at: number; value: Promise<StalenessInfo> }> =
|
|
new Map();
|
|
- // Last meta.indexedAt observed for an open pool, keyed by lbugPath. Keyed by
|
|
- // pool (not stored on the handle) because branch handles are produced fresh
|
|
- // by applyBranchScope on every resolveRepo call, so mutating the handle would
|
|
- // not persist across calls and the staleness check would reinit forever
|
|
- // (#2106).
|
|
- private lastObservedIndexedAt: Map<string, string> = new Map();
|
|
- // #2614 F1: file identity of the lbug the pool last opened. An atomic swap or
|
|
- // an in-place incremental changes the inode; reiniting on that reinit-covers
|
|
- // the window where meta.indexedAt hasn't caught up (and the incremental case),
|
|
- // so a rebuilt index is never served stale even when the stamp looks current.
|
|
- private lastObservedDbIdentity: Map<string, Awaited<ReturnType<typeof statDbIdentity>>> =
|
|
- 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
|
|
+ // shape. Keyed by lbugPath (not stored on the repo handle) because branch
|
|
+ // handles are produced fresh by applyBranchScope on every resolveRepo call,
|
|
+ // so mutating the handle would not persist across calls and the staleness
|
|
+ // check would reinit forever (#2106).
|
|
+ // - `indexedAt`: last meta.indexedAt observed for an open pool.
|
|
+ // - `dbIdentity`: file identity of the lbug the pool last opened (#2614 F1)
|
|
+ // — an atomic swap or in-place incremental changes the inode; reiniting
|
|
+ // on that covers the window where meta.indexedAt hasn't caught up (and
|
|
+ // the incremental case), so a rebuilt index is never served stale even
|
|
+ // when the stamp looks current.
|
|
+ // - `ftsStatus`: last meta.capabilities.fts.status observed (#2767).
|
|
+ // `--repair-fts` intentionally never restamps `indexedAt` (it doesn't
|
|
+ // regenerate the graph), so this is the dedicated signal a warm session
|
|
+ // uses to notice a repair — independent of the file-identity heuristic,
|
|
+ // which the repair path also triggers but only incidentally.
|
|
+ private lastObservedPoolState: Map<string, PoolObservedState> = new Map();
|
|
+ /** Merge-patch one poolKey's observed state, preserving fields not passed. */
|
|
+ private setObservedState(poolKey: string, patch: Partial<PoolObservedState>): void {
|
|
+ const current = this.lastObservedPoolState.get(poolKey) ?? { dbIdentity: null };
|
|
+ this.lastObservedPoolState.set(poolKey, { ...current, ...patch });
|
|
+ }
|
|
private groupToolSvc: GroupService | null = null;
|
|
/**
|
|
* One-shot stderr warnings for sibling-clone drift, keyed by
|
|
@@ -1143,8 +1162,7 @@ export class LocalBackend {
|
|
this.initializedRepos.delete(key);
|
|
this.lastStalenessCheck.delete(key);
|
|
this.toolStalenessCache.delete(key);
|
|
- this.lastObservedIndexedAt.delete(key);
|
|
- this.lastObservedDbIdentity.delete(key);
|
|
+ this.lastObservedPoolState.delete(key);
|
|
this.reinitPromises.delete(key);
|
|
closeLbug(key).catch(() => {});
|
|
}
|
|
@@ -1548,10 +1566,11 @@ export class LocalBackend {
|
|
// 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 = this.lastObservedIndexedAt.get(poolKey) ?? repo.indexedAt;
|
|
+ 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
|
|
@@ -1559,10 +1578,19 @@ export class LocalBackend {
|
|
// latch on the old inode forever (its stamp already == meta.indexedAt).
|
|
const currentIdentity = await statDbIdentity(repo.lbugPath);
|
|
const identityChanged = dbIdentityChanged(
|
|
- this.lastObservedDbIdentity.get(poolKey) ?? null,
|
|
+ observedState?.dbIdentity ?? null,
|
|
currentIdentity,
|
|
);
|
|
- if (stampChanged || identityChanged) {
|
|
+ // #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
|
|
@@ -1571,17 +1599,22 @@ export class LocalBackend {
|
|
// reinitPromises to serialize concurrent detectors.
|
|
const reinit = (async () => {
|
|
try {
|
|
- // Advance the observed stamp regardless: a stamp change with an
|
|
- // unchanged file must not re-trigger on every check.
|
|
- if (meta?.indexedAt) this.lastObservedIndexedAt.set(poolKey, meta.indexedAt);
|
|
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) {
|
|
- this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath));
|
|
- }
|
|
+ if (reopened) patch.dbIdentity = await statDbIdentity(repo.lbugPath);
|
|
+ this.setObservedState(poolKey, patch);
|
|
} finally {
|
|
this.reinitPromises.delete(poolKey);
|
|
}
|
|
@@ -1599,8 +1632,18 @@ export class LocalBackend {
|
|
try {
|
|
await initLbug(poolKey, repo.lbugPath);
|
|
this.initializedRepos.add(poolKey);
|
|
- this.lastObservedIndexedAt.set(poolKey, repo.indexedAt);
|
|
- this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath));
|
|
+ // #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.
|
|
+ this.setObservedState(poolKey, {
|
|
+ indexedAt: repo.indexedAt,
|
|
+ dbIdentity: await statDbIdentity(repo.lbugPath),
|
|
+ });
|
|
} catch (err: any) {
|
|
// If lock error, mark as not initialized so next call retries
|
|
this.initializedRepos.delete(poolKey);
|
|
@@ -2047,6 +2090,21 @@ export class LocalBackend {
|
|
// unavailable the search helper may return an unexpected shape.
|
|
const bm25Results = bm25SearchResult?.results ?? [];
|
|
const ftsUsed = bm25SearchResult?.ftsUsed ?? false;
|
|
+ // #2767: log every non-benign per-table FTS query error server-side,
|
|
+ // regardless of whether OTHER tables succeeded — previously a real error
|
|
+ // on N-1 of N tables while one succeeded left zero diagnostic trail.
|
|
+ const ftsQueryErrors = bm25SearchResult?.nonBenignErrors;
|
|
+ if (ftsQueryErrors) {
|
|
+ // tri-review NEW-5: these strings are already classified non-benign by
|
|
+ // classifyFtsQueryError — do NOT route them through logQueryError,
|
|
+ // whose own broader, unanchored isBenignMissingTableError regex (any
|
|
+ // "does not exist" substring, anywhere) could disagree and silently
|
|
+ // demote an already-flagged real error to debug, undercutting the
|
|
+ // severity signal this classification exists to preserve.
|
|
+ for (const err of ftsQueryErrors) {
|
|
+ logger.warn({ context: 'query:fts-search', err }, 'GitNexus query failed (degraded)');
|
|
+ }
|
|
+ }
|
|
|
|
// Merge via reciprocal rank fusion
|
|
timer.start('merge');
|
|
@@ -2326,7 +2384,37 @@ export class LocalBackend {
|
|
// path, leaving the success-path response shape byte-identical.
|
|
const warnings: string[] = [];
|
|
if (!ftsUsed) {
|
|
- warnings.push(ftsDegradedWarning());
|
|
+ // #2767: attach what THIS session resolved (repo/branch/indexed-at) so a
|
|
+ // CLI/MCP mismatch is visible in the warning itself rather than requiring
|
|
+ // a separate debugging round-trip. tri-review NEW-3: `indexedAt` reads
|
|
+ // from `lastObservedPoolState` (kept current by ensureInitialized's
|
|
+ // staleness check, including a same-call reinit) rather than the `repo`
|
|
+ // handle resolved before that check ran — a warm backend that just
|
|
+ // reopened against a newer on-disk index must not warn with stale
|
|
+ // metadata. No extra I/O: the map is already maintained per-request.
|
|
+ const warningContext = {
|
|
+ repoName: repo.name,
|
|
+ branch: repo.branch,
|
|
+ indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt,
|
|
+ };
|
|
+ // tri-review NEW-1: every table failing for a REAL error (timeout,
|
|
+ // connection reset) is not a missing-index condition — `ftsDegradedWarning`'s
|
|
+ // "run --repair-fts" headline won't fix it. Route to a dedicated message
|
|
+ // instead of burying the real cause as a trailing suffix on bad advice.
|
|
+ warnings.push(
|
|
+ ftsQueryErrors
|
|
+ ? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] })
|
|
+ : ftsDegradedWarning(warningContext),
|
|
+ );
|
|
+ } else if (ftsQueryErrors) {
|
|
+ // #2767: at least one FTS table succeeded (ftsUsed=true) but another
|
|
+ // hit a real, non-benign error — results may be silently missing
|
|
+ // matches from that table with no signal, the same "partial success"
|
|
+ // shape the enrichmentDegraded branch below already surfaces. Mirror
|
|
+ // that convention instead of only logging server-side.
|
|
+ warnings.push(
|
|
+ `FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`,
|
|
+ );
|
|
}
|
|
// #2331: a CJK query against a server process resolving
|
|
// GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase
|
|
@@ -2403,6 +2491,10 @@ export class LocalBackend {
|
|
'Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).',
|
|
);
|
|
}
|
|
+ // #2767: a partial FTS failure (some tables ok, one or more real errors)
|
|
+ // is as much a "results may be incomplete" signal as enrichmentDegraded —
|
|
+ // flag it the same way rather than only via the warning string.
|
|
+ const ftsPartial = ftsUsed && !!ftsQueryErrors;
|
|
|
|
return {
|
|
processes,
|
|
@@ -2410,7 +2502,7 @@ export class LocalBackend {
|
|
definitions: definitions.slice(0, 20), // cap standalone definitions
|
|
timing,
|
|
...(warnings.length > 0 && { warning: warnings.join(' ') }),
|
|
- ...(enrichmentDegraded && { partial: true }),
|
|
+ ...((enrichmentDegraded || ftsPartial) && { partial: true }),
|
|
};
|
|
}
|
|
|
|
@@ -2421,7 +2513,7 @@ export class LocalBackend {
|
|
repo: RepoHandle,
|
|
query: string,
|
|
limit: number,
|
|
- ): Promise<{ results: any[]; ftsUsed: boolean }> {
|
|
+ ): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> {
|
|
let searchFTSFromLbug;
|
|
try {
|
|
({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js'));
|
|
@@ -2453,6 +2545,7 @@ export class LocalBackend {
|
|
// could be undefined when the FTS extension is unavailable in the MCP process.
|
|
const bm25Results = ftsResponse?.results ?? [];
|
|
const ftsUsed = ftsResponse?.ftsAvailable ?? false;
|
|
+ const nonBenignErrors = ftsResponse?.nonBenignErrors;
|
|
|
|
const results: any[] = [];
|
|
|
|
@@ -2524,7 +2617,7 @@ export class LocalBackend {
|
|
}
|
|
}
|
|
|
|
- return { results, ftsUsed };
|
|
+ return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
|
|
}
|
|
|
|
/**
|
|
diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts
|
|
index 8d3d0aeda..031f148c4 100644
|
|
--- a/gitnexus/src/server/api.ts
|
|
+++ b/gitnexus/src/server/api.ts
|
|
@@ -1833,8 +1833,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|
},
|
|
pendingNodeIds: string[],
|
|
): Promise<void> => {
|
|
+ // tri-review NEW-2: re-read immediately before writing (mirrors
|
|
+ // the pattern in run-analyze.ts's --repair-fts stamp) instead of
|
|
+ // spreading the stale `embeddingMeta` snapshot captured once at
|
|
+ // job start. This job can run up to EMBED_TIMEOUT_MS (30 min);
|
|
+ // without a fresh read, a concurrent writer's update (e.g. a
|
|
+ // --repair-fts capability stamp) would be silently reverted on
|
|
+ // every checkpoint save for the job's whole lifetime.
|
|
+ const latestMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta;
|
|
embeddingMeta = {
|
|
- ...embeddingMeta,
|
|
+ ...latestMeta,
|
|
embeddingCheckpoint: {
|
|
at: new Date().toISOString(),
|
|
...checkpoint,
|
|
@@ -1896,7 +1904,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|
// handles this during process exit, but the server keeps the
|
|
// connection open for other routes — a CHECKPOINT is enough.
|
|
await flushWAL();
|
|
- embeddingMeta = { ...embeddingMeta, embeddingCheckpoint: undefined };
|
|
+ // Same re-read-before-write reasoning as saveEmbeddingCheckpoint above.
|
|
+ const finalMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta;
|
|
+ embeddingMeta = { ...finalMeta, embeddingCheckpoint: undefined };
|
|
await saveMeta(entry.storagePath, embeddingMeta);
|
|
});
|
|
|
|
diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts
|
|
index 6b8b6f19b..7ddc84bb1 100644
|
|
--- a/gitnexus/src/storage/repo-manager.ts
|
|
+++ b/gitnexus/src/storage/repo-manager.ts
|
|
@@ -187,9 +187,12 @@ export interface RepoMeta {
|
|
* the meta literal in run-analyze.ts — typed here so the stamp site is
|
|
* compile-checked; tri-review 4669518496 P1/U3: `vectorSearch.status`
|
|
* must never claim 'vector-index' unless the run verified or recreated
|
|
- * the HNSW index). Forensic today — no programmatic readers (`doctor`
|
|
- * prints platform-derived capabilities, query routing never consults
|
|
- * meta). The status unions mirror `CapabilityStatus` /
|
|
+ * the HNSW index). `fts.status` gained its first programmatic reader in
|
|
+ * #2767: `LocalBackend.ensureInitialized()` compares it against the
|
|
+ * warm connection pool's last-observed value as the dedicated signal
|
|
+ * that `--repair-fts` changed FTS availability (`doctor` still prints
|
|
+ * platform-derived capabilities separately; `graph`/`vectorSearch` remain
|
|
+ * forensic-only). The status unions mirror `CapabilityStatus` /
|
|
* `SemanticSearchMode` in core/platform/capabilities.ts; inlined to keep
|
|
* storage/ free of a core/ type dependency.
|
|
*/
|
|
diff --git a/gitnexus/test/integration/fts-repair-warm-session.test.ts b/gitnexus/test/integration/fts-repair-warm-session.test.ts
|
|
new file mode 100644
|
|
index 000000000..2550ba874
|
|
--- /dev/null
|
|
+++ b/gitnexus/test/integration/fts-repair-warm-session.test.ts
|
|
@@ -0,0 +1,169 @@
|
|
+/**
|
|
+ * Integration test for issue #2767: the MCP `query` tool reported "FTS
|
|
+ * indexes missing" against an index the CLI could search successfully,
|
|
+ * because a long-lived MCP session's pooled read-only connection had no
|
|
+ * reliable signal that `gitnexus analyze --repair-fts` changed FTS
|
|
+ * availability (repair-fts intentionally never restamps `indexedAt`).
|
|
+ *
|
|
+ * Everything real: a real writable LadybugDB session builds the initial
|
|
+ * index WITHOUT FTS (the exact shape implied by the original report — FTS
|
|
+ * built later), a real `LocalBackend` resolves it via the real registry and
|
|
+ * issues a real `query` tool call through the real connection pool, then a
|
|
+ * SEPARATE real writable session performs the repair (real
|
|
+ * `createSearchFTSIndexes`, real `saveMeta` capability stamp — the same
|
|
+ * production functions `--repair-fts` calls), and the SAME still-warm
|
|
+ * `LocalBackend` instance re-queries without any restart.
|
|
+ */
|
|
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
+import path from 'node:path';
|
|
+import { createTempDir } from '../helpers/test-db.js';
|
|
+import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js';
|
|
+import {
|
|
+ getStoragePaths,
|
|
+ registerRepo,
|
|
+ saveMeta,
|
|
+ type RepoMeta,
|
|
+} from '../../src/storage/repo-manager.js';
|
|
+import { closeLbug as poolClose } 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;
|
|
+ warning?: string;
|
|
+ definitions?: Array<{ id: string }>;
|
|
+ process_symbols?: Array<{ id: string }>;
|
|
+};
|
|
+
|
|
+const matchedIds = (r: QueryResult): string[] =>
|
|
+ [...(r.process_symbols ?? []), ...(r.definitions ?? [])].map((s) => s.id);
|
|
+
|
|
+const ftsMissing = (r: QueryResult): boolean =>
|
|
+ typeof r.warning === 'string' && /FTS indexes missing/i.test(r.warning);
|
|
+
|
|
+/**
|
|
+ * 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.
|
|
+ */
|
|
+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).
|
|
+ timeoutMs = 15000,
|
|
+ intervalMs = 300,
|
|
+): Promise<QueryResult> {
|
|
+ const deadline = Date.now() + timeoutMs;
|
|
+ let last: QueryResult;
|
|
+ do {
|
|
+ last = await backend.callTool('query', { query });
|
|
+ if (!ftsMissing(last)) return last;
|
|
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
+ } while (Date.now() < deadline);
|
|
+ return last!;
|
|
+}
|
|
+
|
|
+describe('warm MCP session observes an in-place --repair-fts rebuild (#2767)', () => {
|
|
+ let tmpHandle: Awaited<ReturnType<typeof createTempDir>>;
|
|
+ let repoPath: string;
|
|
+ let storagePath: string;
|
|
+ let lbugPath: string;
|
|
+ let savedHome: string | undefined;
|
|
+
|
|
+ beforeEach(async () => {
|
|
+ tmpHandle = await createTempDir('gnx-fts-repair-warm-');
|
|
+ repoPath = tmpHandle.dbPath;
|
|
+ savedHome = process.env.GITNEXUS_HOME;
|
|
+ process.env.GITNEXUS_HOME = path.join(repoPath, '.gitnexus-home');
|
|
+ ({ storagePath, lbugPath } = getStoragePaths(repoPath));
|
|
+ });
|
|
+
|
|
+ afterEach(async () => {
|
|
+ await poolClose(lbugPath).catch(() => {});
|
|
+ if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
|
+ else process.env.GITNEXUS_HOME = savedHome;
|
|
+ await tmpHandle.cleanup();
|
|
+ });
|
|
+
|
|
+ it(
|
|
+ 'a warm session transitions from FTS-unavailable to FTS-available without restarting, after an out-of-band --repair-fts',
|
|
+ { timeout: 60_000 },
|
|
+ async (ctx) => {
|
|
+ const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
+ const { createSearchFTSIndexes } = await import('../../src/core/search/fts-indexes.js');
|
|
+
|
|
+ // ── Step 1: build the index WITHOUT FTS (analyzed before repair) ────
|
|
+ await adapter.initLbug(lbugPath);
|
|
+
|
|
+ const ftsAvailable = await adapter.loadFTSExtension(undefined, {
|
|
+ policy: resolveAnalyzeInstallPolicy(),
|
|
+ });
|
|
+ if (!ftsAvailable) {
|
|
+ if (REQUIRE_FTS) {
|
|
+ throw new Error(
|
|
+ 'FTS extension is required (GITNEXUS_REQUIRE_FTS=1) but could not be loaded — ' +
|
|
+ 'this FTS-dependent integration test must not be silently skipped in CI.',
|
|
+ );
|
|
+ }
|
|
+ await adapter.closeLbug();
|
|
+ ctx.skip();
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ await adapter.executeQuery(
|
|
+ `CREATE (n:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 3, content: 'function login() { return true; }'})`,
|
|
+ );
|
|
+ await adapter.flushWAL();
|
|
+ await adapter.closeLbug();
|
|
+
|
|
+ const indexedAt = new Date().toISOString();
|
|
+ const baseMeta: RepoMeta = {
|
|
+ repoPath,
|
|
+ lastCommit: 'c1',
|
|
+ indexedAt,
|
|
+ stats: { files: 1, nodes: 1 },
|
|
+ capabilities: {
|
|
+ graph: { provider: 'ladybugdb', status: 'available' },
|
|
+ fts: { provider: 'ladybugdb-fts', status: 'unavailable' },
|
|
+ vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 },
|
|
+ },
|
|
+ };
|
|
+ await saveMeta(storagePath, baseMeta);
|
|
+ await registerRepo(repoPath, baseMeta, { name: 'test-repo' });
|
|
+
|
|
+ // ── Step 2: a real warm LocalBackend observes "FTS unavailable" ─────
|
|
+ const backend = new LocalBackend();
|
|
+ await backend.init();
|
|
+ const before = await backend.callTool('query', { query: 'login' });
|
|
+ expect(before.error).toBeUndefined();
|
|
+ expect(ftsMissing(before)).toBe(true);
|
|
+
|
|
+ // ── Step 3: out-of-band --repair-fts (separate writable session) ────
|
|
+ // 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).
|
|
+ await adapter.initLbug(lbugPath);
|
|
+ await createSearchFTSIndexes();
|
|
+ await adapter.flushWAL();
|
|
+ await adapter.closeLbug();
|
|
+ await saveMeta(storagePath, {
|
|
+ ...baseMeta,
|
|
+ capabilities: {
|
|
+ ...baseMeta.capabilities!,
|
|
+ fts: { provider: 'ladybugdb-fts', status: 'available' },
|
|
+ },
|
|
+ });
|
|
+
|
|
+ // ── Step 4: the SAME still-warm backend re-queries — no restart ─────
|
|
+ const after = await waitForFtsRecognized(backend, 'login');
|
|
+ expect(after.error).toBeUndefined();
|
|
+ expect(ftsMissing(after)).toBe(false);
|
|
+ expect(matchedIds(after)).toContain('func:login');
|
|
+ },
|
|
+ );
|
|
+});
|
|
diff --git a/gitnexus/test/unit/bm25-search.test.ts b/gitnexus/test/unit/bm25-search.test.ts
|
|
index f6105918b..793cf7aae 100644
|
|
--- a/gitnexus/test/unit/bm25-search.test.ts
|
|
+++ b/gitnexus/test/unit/bm25-search.test.ts
|
|
@@ -1,5 +1,7 @@
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { searchFTSFromLbug, type BM25SearchResult } from '../../src/core/search/bm25-index.js';
|
|
+import { classifyFtsQueryError } from '../../src/core/lbug/lbug-adapter.js';
|
|
+import { extensionManager, resetExtensionState } from '../../src/core/lbug/extension-loader.js';
|
|
import { FTS_INDEXES } from '../../src/core/search/fts-schema.js';
|
|
|
|
vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => {
|
|
@@ -315,6 +317,183 @@ describe('BM25 search', () => {
|
|
});
|
|
});
|
|
|
|
+ describe('classifyFtsQueryError (#2767)', () => {
|
|
+ it('classifies the real "doesn\'t have an index" message (confirmed against a live QUERY_FTS_INDEX call) as missing-index', () => {
|
|
+ expect(
|
|
+ classifyFtsQueryError(
|
|
+ "Prepare failed: Binder exception: Table File doesn't have an index with name file_fts.",
|
|
+ ),
|
|
+ ).toBe('missing-index');
|
|
+ });
|
|
+
|
|
+ it('classifies the real "table does not exist" message (confirmed against a live QUERY_FTS_INDEX call on a nonexistent table) as missing-table, distinct from missing-index (tri-review NEW-6)', () => {
|
|
+ // Empirically confirmed: the table-missing message uses "does not
|
|
+ // exist", NOT "doesn't have an index with name" — a genuinely different
|
|
+ // phrasing from missing-index, not the same condition under two names.
|
|
+ // Conflating them was the exact bug: a corrupted/partial DB (table
|
|
+ // itself gone) would have been silently treated as the ordinary
|
|
+ // "index not built yet" case.
|
|
+ expect(
|
|
+ classifyFtsQueryError(
|
|
+ 'Prepare failed: Binder exception: Table TotallyNonexistentTable does not exist.',
|
|
+ ),
|
|
+ ).toBe('missing-table');
|
|
+ });
|
|
+
|
|
+ it('classifies a Catalog-exception "does not exist" message as missing-table too (both exception classes covered)', () => {
|
|
+ expect(classifyFtsQueryError('Catalog exception: Table SomeTable does not exist.')).toBe(
|
|
+ 'missing-table',
|
|
+ );
|
|
+ });
|
|
+
|
|
+ it('classifies the extension-unavailable Catalog exception as other, not benign (mirrors the confirmed DROP_FTS_INDEX shape for QUERY_FTS_INDEX)', () => {
|
|
+ // Message shape confirmed for DROP_FTS_INDEX in
|
|
+ // drop-fts-index-error-classification.test.ts; QUERY_FTS_INDEX would
|
|
+ // fail identically when the extension isn't loaded (same catalog).
|
|
+ expect(
|
|
+ classifyFtsQueryError(
|
|
+ "Catalog exception: function QUERY_FTS_INDEX is not defined. This function exists in the FTS extension. You can install and load the extension by running 'INSTALL FTS; LOAD EXTENSION FTS;'.",
|
|
+ ),
|
|
+ ).toBe('other');
|
|
+ });
|
|
+
|
|
+ it('does not misclassify a real, differently-classed error that echoes the benign phrase in its body', () => {
|
|
+ // Adversarial case: a Runtime exception (not Binder/Catalog) that
|
|
+ // happens to echo the user's own search text — which could itself
|
|
+ // contain "does not exist" — must not be anchored away as benign.
|
|
+ expect(
|
|
+ classifyFtsQueryError(
|
|
+ 'Runtime exception: FTS query syntax error near "the config file does not exist here"',
|
|
+ ),
|
|
+ ).toBe('other');
|
|
+ });
|
|
+
|
|
+ it('does not misclassify a real Binder-class error unrelated to a missing FTS index', () => {
|
|
+ expect(classifyFtsQueryError('Binder exception: column X does not match expected type')).toBe(
|
|
+ 'other',
|
|
+ );
|
|
+ });
|
|
+
|
|
+ it('classifies any other message as other', () => {
|
|
+ expect(classifyFtsQueryError('Query execution timed out after 30000ms')).toBe('other');
|
|
+ expect(classifyFtsQueryError('Connection pool exhausted')).toBe('other');
|
|
+ });
|
|
+ });
|
|
+
|
|
+ describe('MCP pool path — real vs benign FTS query errors (#2767)', () => {
|
|
+ const REPO = 'test-repo-error-classification';
|
|
+
|
|
+ beforeEach(() => {
|
|
+ mockExecuteParameterized.mockReset();
|
|
+ });
|
|
+
|
|
+ it('a benign missing-index error on every table leaves nonBenignErrors unset (unchanged behavior)', async () => {
|
|
+ mockExecuteParameterized.mockRejectedValue(
|
|
+ new Error("Binder exception: Table Function doesn't have an index with name function_fts."),
|
|
+ );
|
|
+
|
|
+ const response = await searchFTSFromLbug('login', 5, REPO);
|
|
+
|
|
+ expect(response.ftsAvailable).toBe(false);
|
|
+ expect(response.nonBenignErrors).toBeUndefined();
|
|
+ });
|
|
+
|
|
+ it('a missing-table error (table itself gone, not just its FTS index) surfaces as non-benign — schema drift is not the ordinary degraded state (tri-review NEW-6)', async () => {
|
|
+ mockExecuteParameterized.mockRejectedValue(
|
|
+ new Error('Binder exception: Table Function does not exist.'),
|
|
+ );
|
|
+
|
|
+ const response = await searchFTSFromLbug('login', 5, REPO);
|
|
+
|
|
+ expect(response.ftsAvailable).toBe(false);
|
|
+ expect(response.nonBenignErrors!.length).toBeGreaterThan(0);
|
|
+ });
|
|
+
|
|
+ it('a real error on every table surfaces it in nonBenignErrors, redacted', async () => {
|
|
+ mockExecuteParameterized.mockRejectedValue(
|
|
+ new Error(
|
|
+ 'Query execution failed: connection reset at /home/alice/.gitnexus/lbug/main.lbug',
|
|
+ ),
|
|
+ );
|
|
+
|
|
+ const response = await searchFTSFromLbug('login', 5, REPO);
|
|
+
|
|
+ expect(response.ftsAvailable).toBe(false);
|
|
+ expect(response.nonBenignErrors).toBeDefined();
|
|
+ expect(response.nonBenignErrors!.length).toBeGreaterThan(0);
|
|
+ expect(response.nonBenignErrors![0]).toContain('connection reset');
|
|
+ expect(response.nonBenignErrors![0]).not.toMatch(/\/home\/alice/);
|
|
+ });
|
|
+
|
|
+ it('a real error on one table while another succeeds is still reported (partial-failure gap closed)', async () => {
|
|
+ let call = 0;
|
|
+ mockExecuteParameterized.mockImplementation(async (_repo: string, cypher: string) => {
|
|
+ call++;
|
|
+ if (cypher.includes("QUERY_FTS_INDEX('Function'")) {
|
|
+ throw new Error('Query execution timed out after 30000ms');
|
|
+ }
|
|
+ if (cypher.includes("QUERY_FTS_INDEX('File'")) {
|
|
+ return [{ node: { filePath: 'src/index.ts', id: 'file:index' }, score: 3 }];
|
|
+ }
|
|
+ return [];
|
|
+ });
|
|
+
|
|
+ const response = await searchFTSFromLbug('login', 5, REPO);
|
|
+
|
|
+ // At least one table succeeded, so the client-visible availability
|
|
+ // signal and result set are unaffected (regression guard).
|
|
+ expect(response.ftsAvailable).toBe(true);
|
|
+ expect(response.results.length).toBeGreaterThan(0);
|
|
+ // But the real error on the OTHER table is not silently dropped.
|
|
+ expect(response.nonBenignErrors).toBeDefined();
|
|
+ expect(response.nonBenignErrors![0]).toContain('timed out');
|
|
+ expect(call).toBe(FTS_INDEXES.length);
|
|
+ });
|
|
+ });
|
|
+
|
|
+ describe('short-circuits when the FTS extension is unavailable (tri-review NEW-4)', () => {
|
|
+ const REPO = 'test-repo-extension-unavailable';
|
|
+
|
|
+ afterEach(() => {
|
|
+ resetExtensionState();
|
|
+ });
|
|
+
|
|
+ it('MCP pool path: skips per-table QUERY_FTS_INDEX calls and reports no nonBenignErrors when the extension failed to load', async () => {
|
|
+ await extensionManager.ensure(
|
|
+ vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
|
|
+ 'fts',
|
|
+ 'FTS',
|
|
+ { policy: 'load-only' },
|
|
+ );
|
|
+ mockExecuteParameterized.mockReset();
|
|
+
|
|
+ const response = await searchFTSFromLbug('login', 5, REPO);
|
|
+
|
|
+ // The expected degraded-capability state — not per-table query errors.
|
|
+ expect(response.ftsAvailable).toBe(false);
|
|
+ expect(response.nonBenignErrors).toBeUndefined();
|
|
+ // No redundant round-trips to a pool that can't have FTS loaded.
|
|
+ expect(mockExecuteParameterized).not.toHaveBeenCalled();
|
|
+ });
|
|
+
|
|
+ it('CLI/pipeline path (no repoId): also skips per-table calls and reports no nonBenignErrors — same expected state, same silence (fixes the pool-only guard a /simplify altitude pass caught)', async () => {
|
|
+ const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
|
|
+ await extensionManager.ensure(
|
|
+ vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
|
|
+ 'fts',
|
|
+ 'FTS',
|
|
+ { policy: 'load-only' },
|
|
+ );
|
|
+ vi.mocked(queryFTS).mockClear();
|
|
+
|
|
+ const response = await searchFTSFromLbug('login', 5); // no repoId → CLI/pipeline branch
|
|
+
|
|
+ expect(response.ftsAvailable).toBe(false);
|
|
+ expect(response.nonBenignErrors).toBeUndefined();
|
|
+ expect(vi.mocked(queryFTS)).not.toHaveBeenCalled();
|
|
+ });
|
|
+ });
|
|
+
|
|
describe('GITNEXUS_FTS_CJK_SEGMENTATION query-side transform (#2331)', () => {
|
|
const CJK_REPO = 'test-repo-cjk-query';
|
|
|
|
diff --git a/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts
|
|
new file mode 100644
|
|
index 000000000..5dc089e56
|
|
--- /dev/null
|
|
+++ b/gitnexus/test/unit/ensure-initialized-reinit-watermark.test.ts
|
|
@@ -0,0 +1,139 @@
|
|
+import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
+
|
|
+// tri-review NEW-7: `lastObservedIndexedAt`/`lastObservedFtsStatus` used to be
|
|
+// advanced BEFORE `initLbug` was awaited, unlike `lastObservedDbIdentity`
|
|
+// (which is only advanced once `initLbug` confirms the pool rolled over). If
|
|
+// `initLbug` threw, the watermark had already been latched to the new value —
|
|
+// permanently hiding a failed reinit from every later staleness check, since
|
|
+// a subsequent comparison against that same watermark would see no change.
|
|
+// This isolates the fix: a poolKey with `identityChanged` always false (a
|
|
+// nonexistent lbugPath — no backstop from the file-identity signal) must
|
|
+// still retry after a transient `initLbug` failure.
|
|
+
|
|
+const initLbugMock = vi.fn();
|
|
+vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
|
|
+ const actual = await importOriginal<typeof import('../../src/core/lbug/pool-adapter.js')>();
|
|
+ return {
|
|
+ ...actual,
|
|
+ initLbug: (...args: any[]) => initLbugMock(...args),
|
|
+ isLbugReady: vi.fn().mockReturnValue(true),
|
|
+ };
|
|
+});
|
|
+
|
|
+const loadMetaMock = vi.fn();
|
|
+vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
|
+ const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
|
|
+ return {
|
|
+ ...actual,
|
|
+ loadMeta: (...args: any[]) => loadMetaMock(...args),
|
|
+ };
|
|
+});
|
|
+
|
|
+import { LocalBackend } from '../../src/mcp/local/local-backend';
|
|
+
|
|
+describe('ensureInitialized reinit watermark (tri-review NEW-7)', () => {
|
|
+ const poolKey = '/tmp/nonexistent-repo/.gitnexus/lbug';
|
|
+ const repoHandle = { id: 'r1', name: 'r1', lbugPath: poolKey, indexedAt: 'v0' } as any;
|
|
+ let backend: any;
|
|
+
|
|
+ beforeEach(() => {
|
|
+ vi.clearAllMocks();
|
|
+ backend = new LocalBackend() as any;
|
|
+ // 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('does not latch the fts-status watermark when initLbug throws, so the next staleness check retries', async () => {
|
|
+ loadMetaMock.mockResolvedValue({
|
|
+ indexedAt: 'v1',
|
|
+ capabilities: { fts: { status: 'available' } },
|
|
+ });
|
|
+ initLbugMock.mockRejectedValueOnce(new Error('lock timeout'));
|
|
+
|
|
+ await expect(backend.ensureInitialized(repoHandle)).rejects.toThrow('lock timeout');
|
|
+
|
|
+ // The failed reinit must NOT have advanced either watermark — a nonexistent
|
|
+ // lbugPath means dbIdentity never changes, so there is no other signal to
|
|
+ // fall back on for a retry.
|
|
+ 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);
|
|
+ initLbugMock.mockResolvedValueOnce(false); // "no real reopen needed" — still a completed call
|
|
+
|
|
+ await expect(backend.ensureInitialized(repoHandle)).resolves.toBeUndefined();
|
|
+
|
|
+ // The retry succeeded and the watermark is now current — proving the
|
|
+ // failed first attempt did not permanently suppress detection.
|
|
+ expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBe('available');
|
|
+ expect(backend.lastObservedPoolState.get(poolKey)?.indexedAt).toBe('v1');
|
|
+ expect(initLbugMock).toHaveBeenCalledTimes(2);
|
|
+ });
|
|
+});
|
|
+
|
|
+describe('ensureInitialized ftsCapsChanged trigger, isolated from identityChanged (tri-review Residual-4)', () => {
|
|
+ // The only existing coverage for this reinit trigger is the integration
|
|
+ // test in fts-repair-warm-session.test.ts, where a REAL --repair-fts run
|
|
+ // also mutates the lbug file — so identityChanged is confounded with
|
|
+ // ftsCapsChanged there, and it's impossible to tell from that test alone
|
|
+ // whether ftsCapsChanged is actually load-bearing. This isolates it: a
|
|
+ // nonexistent lbugPath means statDbIdentity always resolves null, so
|
|
+ // identityChanged is provably false on every check — the ONLY way a reinit
|
|
+ // can fire here is via ftsCapsChanged (indexedAt is held constant too, so
|
|
+ // stampChanged is also false).
|
|
+ const poolKey = '/tmp/nonexistent-repo-caps-only/.gitnexus/lbug';
|
|
+ const repoHandle = { id: 'r2', name: 'r2', lbugPath: poolKey, indexedAt: 'same' } as any;
|
|
+ let backend: any;
|
|
+
|
|
+ beforeEach(() => {
|
|
+ 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 () => {
|
|
+ // Seed a baseline observed state: same indexedAt the next loadMeta will
|
|
+ // report, a DIFFERENT ftsStatus, and dbIdentity null (matches what
|
|
+ // statDbIdentity will keep returning for this nonexistent path).
|
|
+ backend.lastObservedPoolState.set(poolKey, {
|
|
+ indexedAt: 'same',
|
|
+ ftsStatus: 'unavailable',
|
|
+ dbIdentity: null,
|
|
+ });
|
|
+
|
|
+ loadMetaMock.mockResolvedValue({
|
|
+ indexedAt: 'same', // unchanged — stampChanged must be false
|
|
+ capabilities: { fts: { status: 'available' } }, // changed — the only live signal
|
|
+ });
|
|
+ initLbugMock.mockResolvedValueOnce(true);
|
|
+
|
|
+ await backend.ensureInitialized(repoHandle);
|
|
+
|
|
+ // A reinit only happens inside the `if (stampChanged || identityChanged
|
|
+ // || ftsCapsChanged)` branch — initLbug being called at all here proves
|
|
+ // ftsCapsChanged fired, since the other two provably could not have.
|
|
+ expect(initLbugMock).toHaveBeenCalledTimes(1);
|
|
+ expect(backend.lastObservedPoolState.get(poolKey)?.ftsStatus).toBe('available');
|
|
+ });
|
|
+
|
|
+ it('does NOT fire a reinit when nothing observable changed (negative control)', async () => {
|
|
+ backend.lastObservedPoolState.set(poolKey, {
|
|
+ indexedAt: 'same',
|
|
+ ftsStatus: 'available',
|
|
+ dbIdentity: null,
|
|
+ });
|
|
+
|
|
+ loadMetaMock.mockResolvedValue({
|
|
+ indexedAt: 'same',
|
|
+ capabilities: { fts: { status: 'available' } }, // same as observed
|
|
+ });
|
|
+
|
|
+ await backend.ensureInitialized(repoHandle);
|
|
+
|
|
+ expect(initLbugMock).not.toHaveBeenCalled();
|
|
+ });
|
|
+});
|
|
diff --git a/gitnexus/test/unit/fts-degraded-warning.test.ts b/gitnexus/test/unit/fts-degraded-warning.test.ts
|
|
index fe5cc0554..42425be99 100644
|
|
--- a/gitnexus/test/unit/fts-degraded-warning.test.ts
|
|
+++ b/gitnexus/test/unit/fts-degraded-warning.test.ts
|
|
@@ -79,6 +79,28 @@ describe('ftsDegradedWarning (#2374)', () => {
|
|
expect(warning).toContain('not a valid Win32 application');
|
|
});
|
|
|
|
+ it('fully redacts a Windows path containing a space in the username (tri-review Residual-3, was only partially redacted)', async () => {
|
|
+ await extensionManager.ensure(
|
|
+ vi
|
|
+ .fn()
|
|
+ .mockRejectedValue(
|
|
+ new Error(
|
|
+ "Failed to load library 'C:\\Users\\alice smith\\.lbdb\\extension\\0.18.0\\win_amd64\\fts\\libfts.lbug_extension': not a valid Win32 application",
|
|
+ ),
|
|
+ ),
|
|
+ 'fts',
|
|
+ 'FTS',
|
|
+ { policy: 'load-only' },
|
|
+ );
|
|
+
|
|
+ const warning = ftsDegradedWarning();
|
|
+ // Neither the drive-letter prefix NOR the tail after the space may leak.
|
|
+ expect(warning).not.toMatch(/C:\\Users\\/);
|
|
+ expect(warning).not.toContain('smith');
|
|
+ expect(warning).not.toContain('alice');
|
|
+ expect(warning).toContain('not a valid Win32 application');
|
|
+ });
|
|
+
|
|
it('surfaces the runtime-install remedy, not reinstall, for a Windows missing-dependency error', async () => {
|
|
await extensionManager.ensure(
|
|
vi
|
|
@@ -139,3 +161,68 @@ describe('ftsDegradedWarning (#2374)', () => {
|
|
expect(ftsDegradedWarning()).toMatch(/Visual C\+\+/);
|
|
});
|
|
});
|
|
+
|
|
+describe('ftsDegradedWarning resolved-repo context (#2767)', () => {
|
|
+ it('omits the context suffix entirely when no context is passed (unchanged message)', async () => {
|
|
+ await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
+ policy: 'load-only',
|
|
+ });
|
|
+
|
|
+ expect(ftsDegradedWarning()).toBe(
|
|
+ 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.',
|
|
+ );
|
|
+ });
|
|
+
|
|
+ it('appends the resolved repo name and indexed-at on the indexes-missing branch', async () => {
|
|
+ await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
+ policy: 'load-only',
|
|
+ });
|
|
+
|
|
+ const warning = ftsDegradedWarning({
|
|
+ repoName: 'myrepo',
|
|
+ indexedAt: '2026-07-30T12:00:00.000Z',
|
|
+ });
|
|
+ expect(warning).toContain('FTS indexes missing');
|
|
+ expect(warning).toContain('resolved: myrepo');
|
|
+ expect(warning).toContain('indexed 2026-07-30T12:00:00.000Z');
|
|
+ });
|
|
+
|
|
+ it('includes the branch label when the resolved handle is branch-scoped', async () => {
|
|
+ await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
+ policy: 'load-only',
|
|
+ });
|
|
+
|
|
+ const warning = ftsDegradedWarning({ repoName: 'myrepo', branch: 'feature/x' });
|
|
+ expect(warning).toContain('branch:feature/x');
|
|
+ });
|
|
+
|
|
+ it('never leaks an absolute path via the context suffix', async () => {
|
|
+ await extensionManager.ensure(vi.fn().mockResolvedValue({}), 'fts', 'FTS', {
|
|
+ policy: 'load-only',
|
|
+ });
|
|
+
|
|
+ const warning = ftsDegradedWarning({
|
|
+ repoName: 'myrepo',
|
|
+ lastErrorRedacted: 'connection reset',
|
|
+ });
|
|
+ expect(warning).not.toMatch(/\/home\/|\/Users\/|C:\\Users\\/);
|
|
+ expect(warning).toContain('last error: connection reset');
|
|
+ });
|
|
+
|
|
+ it('also renders the context suffix on the extension-failed-to-load branch', async () => {
|
|
+ await extensionManager.ensure(
|
|
+ vi.fn().mockRejectedValue(new Error('invalid ELF header.')),
|
|
+ 'fts',
|
|
+ 'FTS',
|
|
+ { policy: 'load-only' },
|
|
+ );
|
|
+
|
|
+ const warning = ftsDegradedWarning({
|
|
+ repoName: 'myrepo',
|
|
+ indexedAt: '2026-07-30T12:00:00.000Z',
|
|
+ });
|
|
+ expect(warning).toContain('FTS extension failed to load');
|
|
+ expect(warning).toContain('resolved: myrepo');
|
|
+ expect(warning).toContain('indexed 2026-07-30T12:00:00.000Z');
|
|
+ });
|
|
+});
|
|
diff --git a/gitnexus/test/unit/query-degraded-signal.test.ts b/gitnexus/test/unit/query-degraded-signal.test.ts
|
|
index e72b9a7ce..a7ed807e8 100644
|
|
--- a/gitnexus/test/unit/query-degraded-signal.test.ts
|
|
+++ b/gitnexus/test/unit/query-degraded-signal.test.ts
|
|
@@ -46,7 +46,7 @@ import { LocalBackend } from '../../src/mcp/local/local-backend';
|
|
// A backend whose hybrid search yields exactly one matched symbol, so the
|
|
// enrichment chunk loop runs and can be made to fail. `ftsUsed` is parameterized
|
|
// so we can exercise the FTS-missing + enrichment-degraded composition.
|
|
-function makeBackend(ftsUsed = true): LocalBackend {
|
|
+function makeBackend(ftsUsed = true, nonBenignErrors?: string[]): LocalBackend {
|
|
const backend = new LocalBackend();
|
|
const repoHandle = {
|
|
id: 'repo1',
|
|
@@ -68,7 +68,9 @@ function makeBackend(ftsUsed = true): LocalBackend {
|
|
startLine: 1,
|
|
endLine: 2,
|
|
};
|
|
- (backend as any).bm25Search = vi.fn().mockResolvedValue({ results: [sym], ftsUsed });
|
|
+ (backend as any).bm25Search = vi
|
|
+ .fn()
|
|
+ .mockResolvedValue({ results: [sym], ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) });
|
|
(backend as any).semanticSearch = vi.fn().mockResolvedValue([]);
|
|
return { backend, repoHandle } as any;
|
|
}
|
|
@@ -133,6 +135,102 @@ describe('query: degraded-enrichment signal', () => {
|
|
expect(result.warning.toLowerCase()).toContain('enrichment');
|
|
});
|
|
|
|
+ it('a non-benign FTS query error is logged AND surfaced as a partial-result warning even when FTS overall succeeded (#2767)', async () => {
|
|
+ const cap: LoggerCapture = _captureLogger();
|
|
+ try {
|
|
+ const b = makeBackend(true, ['Query execution timed out after 30000ms']);
|
|
+ executeParameterizedMock.mockResolvedValue([]);
|
|
+
|
|
+ const result = await runQuery(b);
|
|
+
|
|
+ // Real error must reach the server log (previously silent)...
|
|
+ expect(result).not.toHaveProperty('error');
|
|
+ const record = cap.records().find((r) => r.context === 'query:fts-search');
|
|
+ expect(record).toBeDefined();
|
|
+ // ...AND the client sees it: mirrors the enrichmentDegraded convention
|
|
+ // for "some succeeded, one genuinely failed" (#2767).
|
|
+ expect(result.partial).toBe(true);
|
|
+ expect(result.warning).toMatch(/FTS keyword search partially failed/);
|
|
+ } finally {
|
|
+ cap.restore();
|
|
+ }
|
|
+ });
|
|
+
|
|
+ it('logs an already-classified non-benign FTS error at warn even when it echoes "does not exist" (tri-review NEW-5)', async () => {
|
|
+ const cap: LoggerCapture = _captureLogger(); // default 'info' level — a debug-level record would be invisible here
|
|
+ try {
|
|
+ // Adversarial shape from classifyFtsQueryError's own doc comment: a real
|
|
+ // error whose body happens to contain the benign phrase. It's already in
|
|
+ // nonBenignErrors (classifyFtsQueryError correctly refused to call it
|
|
+ // benign), so it must not be silently re-demoted to debug by a second,
|
|
+ // broader classifier on the logging path.
|
|
+ const b = makeBackend(true, [
|
|
+ 'Runtime exception: FTS query syntax error near "the config file does not exist here"',
|
|
+ ]);
|
|
+ executeParameterizedMock.mockResolvedValue([]);
|
|
+
|
|
+ await runQuery(b);
|
|
+
|
|
+ const record = cap.records().find((r) => r.context === 'query:fts-search');
|
|
+ expect(record).toBeDefined();
|
|
+ expect(record!.level).toBeGreaterThanOrEqual(40); // pino 'warn', not 'debug' (20)
|
|
+ } finally {
|
|
+ cap.restore();
|
|
+ }
|
|
+ });
|
|
+
|
|
+ it('does not flag partial when FTS fully succeeds with no query errors', async () => {
|
|
+ const b = makeBackend(true);
|
|
+ executeParameterizedMock.mockResolvedValue([]);
|
|
+
|
|
+ const result = await runQuery(b);
|
|
+
|
|
+ expect(result.partial).toBeUndefined();
|
|
+ expect(result.warning).toBeUndefined();
|
|
+ });
|
|
+
|
|
+ it('surfaces a dedicated query-failed warning (not missing-index advice) when every table failed for a real error (tri-review NEW-1)', async () => {
|
|
+ const b = makeBackend(false, ['connection reset']);
|
|
+ executeParameterizedMock.mockResolvedValue([]);
|
|
+
|
|
+ const result = await runQuery(b);
|
|
+
|
|
+ // The indexes are NOT missing here — --repair-fts would not help, so the
|
|
+ // misleading missing-index advice must not be the headline.
|
|
+ expect(result.warning).not.toContain('FTS indexes missing');
|
|
+ expect(result.warning).not.toContain('repair-fts');
|
|
+ expect(result.warning).toContain('FTS keyword search failed');
|
|
+ expect(result.warning).toContain('connection reset');
|
|
+ });
|
|
+
|
|
+ it('the FTS-missing warning uses the freshly-observed indexedAt, not a stale cached repo handle (tri-review NEW-3)', async () => {
|
|
+ const b = makeBackend(false); // FTS unavailable
|
|
+ executeParameterizedMock.mockResolvedValue([]);
|
|
+ // Simulate ensureInitialized having just reinit'd against a newer index —
|
|
+ // it keeps lastObservedPoolState current but never mutates the caller's
|
|
+ // `repo` handle (a fresh spread per call), which still reads the old value.
|
|
+ (b.backend as any).lastObservedPoolState.set('/tmp/repo/.gitnexus/lbug', {
|
|
+ indexedAt: 'fresher-than-repo-handle',
|
|
+ dbIdentity: null,
|
|
+ });
|
|
+
|
|
+ const result = await runQuery(b);
|
|
+
|
|
+ expect(result.warning).toContain('indexed fresher-than-repo-handle');
|
|
+ expect(result.warning).not.toContain('indexed now'); // the stale repo.indexedAt value
|
|
+ });
|
|
+
|
|
+ it('the FTS-missing warning includes the resolved repo name and indexed-at (#2767)', async () => {
|
|
+ const b = makeBackend(false); // FTS unavailable
|
|
+ executeParameterizedMock.mockResolvedValue([]);
|
|
+
|
|
+ const result = await runQuery(b);
|
|
+
|
|
+ expect(result.warning).toContain('FTS indexes missing');
|
|
+ expect(result.warning).toContain('resolved: repo1');
|
|
+ expect(result.warning).toContain('indexed now');
|
|
+ });
|
|
+
|
|
it('warns when a CJK query hits a server resolving segmentation to none (#2331)', async () => {
|
|
const b = makeBackend(true);
|
|
executeParameterizedMock.mockResolvedValue([]);
|
|
diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts
|
|
index 3dc0be797..67b35fcbc 100644
|
|
--- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts
|
|
+++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts
|
|
@@ -259,6 +259,246 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
|
|
}
|
|
});
|
|
|
|
+ const mockRepairSuccessLbugAdapter = (overrides: Record<string, unknown> = {}) => ({
|
|
+ initLbug: vi.fn(async () => undefined),
|
|
+ loadGraphToLbug: vi.fn(async () => undefined),
|
|
+ getLbugStats: vi.fn(async () => ({})),
|
|
+ executeQuery: vi.fn(async () => []),
|
|
+ executeWithReusedStatement: vi.fn(async () => []),
|
|
+ closeLbug: vi.fn(async () => undefined),
|
|
+ wipeLbugDbFiles: vi.fn(async () => undefined),
|
|
+ loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })),
|
|
+ deleteNodesForFile: vi.fn(async () => undefined),
|
|
+ deleteNodesForFiles: vi.fn(async () => undefined),
|
|
+ deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
|
|
+ queryImporters: vi.fn(async () => []),
|
|
+ queryImportersBatch: vi.fn(async () => []),
|
|
+ loadFTSExtension: vi.fn(async () => true),
|
|
+ ...overrides,
|
|
+ });
|
|
+
|
|
+ it('--repair-fts stamps capabilities.fts.status while leaving indexedAt/lastCommit/runnerIdentity/stats byte-identical (#2767)', async () => {
|
|
+ vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter());
|
|
+ vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
|
+ initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
|
|
+ createSearchFTSIndexes: vi.fn(async () => undefined),
|
|
+ verifySearchFTSIndexes: vi.fn(async () => []),
|
|
+ }));
|
|
+ vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
|
|
+ ...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
|
|
+ ensureGitNexusIgnored: vi.fn(async () => undefined),
|
|
+ }));
|
|
+
|
|
+ const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-stamp-');
|
|
+ try {
|
|
+ const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
|
+ await fs.mkdir(storagePath, { recursive: true });
|
|
+ const seededIndexedAt = new Date('2026-01-01T00:00:00.000Z').toISOString();
|
|
+ const seeded: RepoMeta = {
|
|
+ repoPath: tmpRepo.dbPath,
|
|
+ lastCommit: 'abc123',
|
|
+ indexedAt: seededIndexedAt,
|
|
+ stats: { files: 7, nodes: 42, edges: 10 },
|
|
+ runnerIdentity: {
|
|
+ source: { kind: 'source' as const, digest: 'src-digest' },
|
|
+ build: {
|
|
+ kind: 'source' as const,
|
|
+ rootPath: '/x',
|
|
+ canonicalization: 'gitnexus-analyzer-build-v2',
|
|
+ digest: 'build-digest',
|
|
+ },
|
|
+ dependencyRuntime: {
|
|
+ manifestPath: '/x/package.json',
|
|
+ lockfilePath: null,
|
|
+ canonicalization: 'gitnexus-analyzer-dependency-runtime-v4',
|
|
+ packageCount: 1,
|
|
+ artifactCount: 1,
|
|
+ digest: 'dep-digest',
|
|
+ },
|
|
+ },
|
|
+ capabilities: {
|
|
+ graph: { provider: 'ladybugdb', status: 'available' },
|
|
+ fts: { provider: 'ladybugdb-fts', status: 'degraded' },
|
|
+ vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 500 },
|
|
+ },
|
|
+ };
|
|
+ await saveMeta(storagePath, seeded);
|
|
+ await createPlaceholderGraphStore(lbugPath);
|
|
+
|
|
+ const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
+ const result = await runFullAnalysis(
|
|
+ tmpRepo.dbPath,
|
|
+ { repairFts: true },
|
|
+ { onProgress: () => {} },
|
|
+ );
|
|
+ expect(result.ftsRepairedOnly).toBe(true);
|
|
+
|
|
+ const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8'));
|
|
+ expect(meta.capabilities.fts.status).toBe('available');
|
|
+ // Everything repair-fts must NOT touch stays byte-identical (R4).
|
|
+ expect(meta.indexedAt).toBe(seededIndexedAt);
|
|
+ expect(meta.lastCommit).toBe('abc123');
|
|
+ expect(meta.runnerIdentity).toEqual(seeded.runnerIdentity);
|
|
+ expect(meta.stats).toEqual(seeded.stats);
|
|
+ // graph/vectorSearch, which repair-fts also never touches, pass through.
|
|
+ expect(meta.capabilities.graph).toEqual(seeded.capabilities!.graph);
|
|
+ expect(meta.capabilities.vectorSearch).toEqual(seeded.capabilities!.vectorSearch);
|
|
+ } finally {
|
|
+ await tmpRepo.cleanup();
|
|
+ }
|
|
+ });
|
|
+
|
|
+ it('--repair-fts backfills a full capabilities object when the existing meta predates the field entirely (#2767)', async () => {
|
|
+ vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter());
|
|
+ vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
|
+ initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
|
|
+ createSearchFTSIndexes: vi.fn(async () => undefined),
|
|
+ verifySearchFTSIndexes: vi.fn(async () => []),
|
|
+ }));
|
|
+ vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
|
|
+ ...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
|
|
+ ensureGitNexusIgnored: vi.fn(async () => undefined),
|
|
+ }));
|
|
+
|
|
+ const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-legacy-meta-');
|
|
+ try {
|
|
+ const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
|
+ await fs.mkdir(storagePath, { recursive: true });
|
|
+ // Legacy shape: no `capabilities` key at all (pre-#2658 meta.json).
|
|
+ await saveMeta(storagePath, {
|
|
+ repoPath: tmpRepo.dbPath,
|
|
+ lastCommit: '',
|
|
+ indexedAt: new Date().toISOString(),
|
|
+ stats: {},
|
|
+ });
|
|
+ await createPlaceholderGraphStore(lbugPath);
|
|
+
|
|
+ const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
+ // Must not throw — a partial-capabilities spread over `undefined` would
|
|
+ // otherwise violate RepoMeta.capabilities' required sub-fields.
|
|
+ const result = await runFullAnalysis(
|
|
+ tmpRepo.dbPath,
|
|
+ { repairFts: true },
|
|
+ { onProgress: () => {} },
|
|
+ );
|
|
+ expect(result.ftsRepairedOnly).toBe(true);
|
|
+
|
|
+ const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8'));
|
|
+ expect(meta.capabilities.fts.status).toBe('available');
|
|
+ expect(meta.capabilities.graph).toBeDefined();
|
|
+ expect(meta.capabilities.graph.status).toBe('available');
|
|
+ expect(meta.capabilities.vectorSearch).toBeDefined();
|
|
+ expect(meta.capabilities.vectorSearch.status).toBe('unavailable');
|
|
+ expect(typeof meta.capabilities.vectorSearch.exactScanLimit).toBe('number');
|
|
+ } finally {
|
|
+ await tmpRepo.cleanup();
|
|
+ }
|
|
+ });
|
|
+
|
|
+ it('--repair-fts still reports success when the capability-stamp write fails (#2767)', async () => {
|
|
+ vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter());
|
|
+ vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
|
+ initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
|
|
+ createSearchFTSIndexes: vi.fn(async () => undefined),
|
|
+ verifySearchFTSIndexes: vi.fn(async () => []),
|
|
+ }));
|
|
+ vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
|
|
+ ...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
|
|
+ ensureGitNexusIgnored: vi.fn(async () => undefined),
|
|
+ // Repair itself (createSearchFTSIndexes/verify) already succeeded by the
|
|
+ // time this fires — a write failure here must degrade, not fail the run.
|
|
+ saveMeta: vi.fn(async () => {
|
|
+ throw new Error('EACCES: permission denied');
|
|
+ }),
|
|
+ }));
|
|
+
|
|
+ const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-stamp-write-fail-');
|
|
+ try {
|
|
+ const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
|
+ await fs.mkdir(storagePath, { recursive: true });
|
|
+ await saveMeta(storagePath, {
|
|
+ repoPath: tmpRepo.dbPath,
|
|
+ lastCommit: '',
|
|
+ indexedAt: new Date().toISOString(),
|
|
+ stats: {},
|
|
+ });
|
|
+ await createPlaceholderGraphStore(lbugPath);
|
|
+
|
|
+ const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
+ const logs: string[] = [];
|
|
+ const result = await runFullAnalysis(
|
|
+ tmpRepo.dbPath,
|
|
+ { repairFts: true },
|
|
+ { onProgress: () => {}, onLog: (msg: string) => logs.push(msg) },
|
|
+ );
|
|
+
|
|
+ expect(result.ftsRepairedOnly).toBe(true);
|
|
+ expect(logs.join('\n')).toMatch(/capability stamp write failed/i);
|
|
+ } finally {
|
|
+ await tmpRepo.cleanup();
|
|
+ }
|
|
+ });
|
|
+
|
|
+ it('--repair-fts stamps onto the LATEST on-disk meta, not a snapshot from before the rebuild ran (#2767)', async () => {
|
|
+ // A concurrent writer (e.g. the HTTP server's background embedding
|
|
+ // checkpoint job) lands its own saveMeta while the FTS rebuild is in
|
|
+ // flight. The repair-fts stamp must not silently revert that write by
|
|
+ // basing itself on the `existingMeta` captured before the rebuild started.
|
|
+ const CONCURRENT_LAST_COMMIT = 'concurrent-writer-commit';
|
|
+ let storagePathForConcurrentWrite = '';
|
|
+ vi.doMock('../../src/core/lbug/lbug-adapter.js', () => mockRepairSuccessLbugAdapter());
|
|
+ vi.doMock('../../src/core/search/fts-indexes.js', () => ({
|
|
+ initialiseSearchFTSStemmer: vi.fn(() => 'porter'),
|
|
+ createSearchFTSIndexes: vi.fn(async () => {
|
|
+ // Simulate the concurrent writer landing mid-repair, via the test
|
|
+ // file's own top-level `saveMeta` import (bound before any
|
|
+ // vi.doMock call in this file, so it is always the real function).
|
|
+ await saveMeta(storagePathForConcurrentWrite, {
|
|
+ repoPath: '',
|
|
+ lastCommit: CONCURRENT_LAST_COMMIT,
|
|
+ indexedAt: new Date().toISOString(),
|
|
+ stats: { files: 999 },
|
|
+ });
|
|
+ }),
|
|
+ verifySearchFTSIndexes: vi.fn(async () => []),
|
|
+ }));
|
|
+ vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
|
|
+ ...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
|
|
+ ensureGitNexusIgnored: vi.fn(async () => undefined),
|
|
+ }));
|
|
+
|
|
+ const tmpRepo = await createTempDir('gitnexus-run-analyze-repair-race-');
|
|
+ try {
|
|
+ const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath);
|
|
+ storagePathForConcurrentWrite = storagePath;
|
|
+ await fs.mkdir(storagePath, { recursive: true });
|
|
+ await saveMeta(storagePath, {
|
|
+ repoPath: tmpRepo.dbPath,
|
|
+ lastCommit: 'original-commit',
|
|
+ indexedAt: new Date().toISOString(),
|
|
+ stats: { files: 1 },
|
|
+ });
|
|
+ await createPlaceholderGraphStore(lbugPath);
|
|
+
|
|
+ const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
+ const result = await runFullAnalysis(
|
|
+ tmpRepo.dbPath,
|
|
+ { repairFts: true },
|
|
+ { onProgress: () => {} },
|
|
+ );
|
|
+ expect(result.ftsRepairedOnly).toBe(true);
|
|
+
|
|
+ const meta = JSON.parse(await fs.readFile(`${storagePath}/gitnexus.json`, 'utf-8'));
|
|
+ // The concurrent writer's update survives — the stamp did not revert it.
|
|
+ expect(meta.lastCommit).toBe(CONCURRENT_LAST_COMMIT);
|
|
+ expect(meta.stats).toEqual({ files: 999 });
|
|
+ // The FTS stamp still landed on top of that latest state.
|
|
+ expect(meta.capabilities.fts.status).toBe('available');
|
|
+ } finally {
|
|
+ await tmpRepo.cleanup();
|
|
+ }
|
|
+ });
|
|
+
|
|
it('surfaces extension-unavailable errors from FTS index creation in repair mode', async () => {
|
|
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|
initLbug: vi.fn(async () => undefined),
|