gitnexus 1.6.10-rc.135 → 1.6.10-rc.136

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -563,6 +563,42 @@ export declare const ensureEmbeddingRowDmlSafe: () => Promise<boolean>;
563
563
  * exists or will be created on the next analyze.
564
564
  */
565
565
  export declare const ensureFTSIndex: (tableName: string, indexName: string, properties: string[], stemmer?: string) => Promise<void>;
566
+ export type FtsQueryFailureClass = 'missing-index' | 'missing-table' | 'other';
567
+ /**
568
+ * Classify a `QUERY_FTS_INDEX` failure so a genuinely-missing index (normal —
569
+ * this table's FTS index hasn't been built yet) is distinguished from a real
570
+ * query-time error that would otherwise look identical (#2767), and from the
571
+ * table itself being missing (schema drift / a corrupted or partial DB — a
572
+ * much more serious condition than an unbuilt index).
573
+ *
574
+ * tri-review Residual-1: this used to be a second, independently-maintained
575
+ * classifier living in `core/search/bm25-index.ts` (re-exported from there
576
+ * for backward compatibility), duplicating this function's job for the
577
+ * IDENTICAL `QUERY_FTS_INDEX` cypher call. `queryFTS` below now uses this
578
+ * same classifier for its own catch instead of a bare, unanchored
579
+ * `.includes('does not exist')` check that could not tell "index missing"
580
+ * from "table missing" apart, and silently swallowed both alike.
581
+ *
582
+ * Three real message shapes were confirmed empirically against a live
583
+ * `CALL QUERY_FTS_INDEX(...)`:
584
+ * `"Prepare failed: Binder exception: Table <T> doesn't have an index with
585
+ * name <name>."` — the table exists, only its FTS index is missing (normal,
586
+ * benign — `missing-index`) — `"Prepare failed: Binder exception: Table <T>
587
+ * does not exist."` — the TABLE ITSELF is missing (`missing-table`) — and a
588
+ * `Catalog exception: function QUERY_FTS_INDEX is not defined...` when the
589
+ * FTS extension isn't loaded at all (`other`; mirrors the confirmed
590
+ * `DROP_FTS_INDEX` shape in {@link isBenignDropFtsIndexError}'s doc comment).
591
+ *
592
+ * Anchored to the exception class (after stripping the optional "Prepare
593
+ * failed: " wrapper LadybugDB adds for statement-preparation failures),
594
+ * mirroring `isBenignDropFtsIndexError`'s START-of-message anchor: a bare
595
+ * substring search would misclassify a genuine, differently-classed error
596
+ * (e.g. a `Runtime exception` from the FTS parser that echoes the user's
597
+ * own search text back into its message) as benign whenever that echoed
598
+ * text happened to contain "does not exist" — silently dropping a real
599
+ * error, the exact #2767 failure mode this function exists to prevent.
600
+ */
601
+ export declare const classifyFtsQueryError: (message: string) => FtsQueryFailureClass;
566
602
  /**
567
603
  * Query a full-text search index
568
604
  * @param tableName - The node table name
@@ -2721,6 +2721,54 @@ export const ensureFTSIndex = async (tableName, indexName, properties, stemmer =
2721
2721
  throw e;
2722
2722
  }
2723
2723
  };
2724
+ /**
2725
+ * Classify a `QUERY_FTS_INDEX` failure so a genuinely-missing index (normal —
2726
+ * this table's FTS index hasn't been built yet) is distinguished from a real
2727
+ * query-time error that would otherwise look identical (#2767), and from the
2728
+ * table itself being missing (schema drift / a corrupted or partial DB — a
2729
+ * much more serious condition than an unbuilt index).
2730
+ *
2731
+ * tri-review Residual-1: this used to be a second, independently-maintained
2732
+ * classifier living in `core/search/bm25-index.ts` (re-exported from there
2733
+ * for backward compatibility), duplicating this function's job for the
2734
+ * IDENTICAL `QUERY_FTS_INDEX` cypher call. `queryFTS` below now uses this
2735
+ * same classifier for its own catch instead of a bare, unanchored
2736
+ * `.includes('does not exist')` check that could not tell "index missing"
2737
+ * from "table missing" apart, and silently swallowed both alike.
2738
+ *
2739
+ * Three real message shapes were confirmed empirically against a live
2740
+ * `CALL QUERY_FTS_INDEX(...)`:
2741
+ * `"Prepare failed: Binder exception: Table <T> doesn't have an index with
2742
+ * name <name>."` — the table exists, only its FTS index is missing (normal,
2743
+ * benign — `missing-index`) — `"Prepare failed: Binder exception: Table <T>
2744
+ * does not exist."` — the TABLE ITSELF is missing (`missing-table`) — and a
2745
+ * `Catalog exception: function QUERY_FTS_INDEX is not defined...` when the
2746
+ * FTS extension isn't loaded at all (`other`; mirrors the confirmed
2747
+ * `DROP_FTS_INDEX` shape in {@link isBenignDropFtsIndexError}'s doc comment).
2748
+ *
2749
+ * Anchored to the exception class (after stripping the optional "Prepare
2750
+ * failed: " wrapper LadybugDB adds for statement-preparation failures),
2751
+ * mirroring `isBenignDropFtsIndexError`'s START-of-message anchor: a bare
2752
+ * substring search would misclassify a genuine, differently-classed error
2753
+ * (e.g. a `Runtime exception` from the FTS parser that echoes the user's
2754
+ * own search text back into its message) as benign whenever that echoed
2755
+ * text happened to contain "does not exist" — silently dropping a real
2756
+ * error, the exact #2767 failure mode this function exists to prevent.
2757
+ */
2758
+ export const classifyFtsQueryError = (message) => {
2759
+ const PREPARE_FAILED_PREFIX = 'Prepare failed: ';
2760
+ const body = message.startsWith(PREPARE_FAILED_PREFIX)
2761
+ ? message.slice(PREPARE_FAILED_PREFIX.length)
2762
+ : message;
2763
+ if (!body.startsWith('Binder exception:') && !body.startsWith('Catalog exception:')) {
2764
+ return 'other';
2765
+ }
2766
+ if (body.includes("doesn't have an index"))
2767
+ return 'missing-index';
2768
+ if (body.includes('does not exist'))
2769
+ return 'missing-table';
2770
+ return 'other';
2771
+ };
2724
2772
  /**
2725
2773
  * Query a full-text search index
2726
2774
  * @param tableName - The node table name
@@ -2755,8 +2803,13 @@ export const queryFTS = async (tableName, indexName, query, limit = 20, conjunct
2755
2803
  });
2756
2804
  }
2757
2805
  catch (e) {
2758
- // Return empty if index doesn't exist yet
2759
- if (e.message?.includes('does not exist')) {
2806
+ // Return empty only for a genuinely-missing index — the ordinary,
2807
+ // expected case. A missing TABLE (schema drift) or any other real error
2808
+ // rethrows instead of being silently swallowed (tri-review Residual-1 /
2809
+ // NEW-6 — this used to be a bare `.includes('does not exist')` check
2810
+ // that could not tell the two apart).
2811
+ const message = e instanceof Error ? e.message : String(e);
2812
+ if (classifyFtsQueryError(message) === 'missing-index') {
2760
2813
  return [];
2761
2814
  }
2762
2815
  throw e;
@@ -603,6 +603,44 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
603
603
  'if that also fails, verify FTS extension availability via `gitnexus doctor`.');
604
604
  }
605
605
  await ensureGitNexusIgnored(repoPath);
606
+ // #2767: stamp ONLY capabilities.fts so a long-lived MCP session's
607
+ // ensureInitialized() has an explicit, correctly-scoped signal that FTS
608
+ // changed — indexedAt/lastCommit/runnerIdentity/stats are copied through
609
+ // untouched (see the "must not claim a new analyzer identity" comment
610
+ // below). capabilities is forensic/no-programmatic-readers-until-now, so
611
+ // graph/vectorSearch are backfilled with conservative, honest defaults
612
+ // when a legacy meta.json predates this field entirely — repair-fts
613
+ // never touched them and cannot claim a capability it did not verify.
614
+ // Best-effort: a write failure must not turn an already-successful FTS
615
+ // rebuild into a reported repair failure.
616
+ try {
617
+ // Re-read the on-disk meta immediately before writing, rather than
618
+ // reusing `existingMeta` (captured before the FTS rebuild ran, which
619
+ // can span real wall-clock time). Another writer to this same
620
+ // gitnexus.json in the interim — e.g. the HTTP server's background
621
+ // embedding-checkpoint job — must not have its update silently
622
+ // reverted by this stamp overwriting a stale snapshot. Falls back to
623
+ // `existingMeta` only if the file became unreadable in that window.
624
+ const latestMeta = (await loadMeta(metaDir)) ?? existingMeta;
625
+ await saveMeta(metaDir, {
626
+ ...latestMeta,
627
+ capabilities: {
628
+ graph: latestMeta.capabilities?.graph ?? {
629
+ provider: 'ladybugdb',
630
+ status: 'available',
631
+ },
632
+ fts: { provider: 'ladybugdb-fts', status: 'available' },
633
+ vectorSearch: latestMeta.capabilities?.vectorSearch ?? {
634
+ provider: 'exact-scan',
635
+ status: 'unavailable',
636
+ exactScanLimit: 0,
637
+ },
638
+ },
639
+ });
640
+ }
641
+ catch (err) {
642
+ log(`FTS capability stamp write failed (non-critical, repair itself succeeded${err instanceof Error ? `: ${err.message}` : ''}); continuing.`);
643
+ }
606
644
  progress('fts', 90, 'Search indexes ready');
607
645
  progress('done', 100, 'Done');
608
646
  return {
@@ -14,6 +14,17 @@ export interface FTSSearchResponse {
14
14
  results: BM25SearchResult[];
15
15
  /** True when at least one FTS index query succeeded (index exists). */
16
16
  ftsAvailable: boolean;
17
+ /**
18
+ * Redacted (via {@link redactPaths}) message(s) from per-table
19
+ * `QUERY_FTS_INDEX` calls that failed for a reason OTHER than "index
20
+ * doesn't exist" (#2767) — a real query/connection error was previously
21
+ * indistinguishable from a genuinely-missing index. Populated whenever ANY
22
+ * table hit a non-benign error, regardless of whether other tables
23
+ * succeeded, so a caller can always log it; whether to also surface it in
24
+ * a client-facing warning is a caller decision (see `LocalBackend.query()`,
25
+ * which only does so when every table failed).
26
+ */
27
+ nonBenignErrors?: string[];
17
28
  }
18
29
  /**
19
30
  * Search using LadybugDB's built-in FTS (always fresh, reads from disk)
@@ -4,14 +4,23 @@
4
4
  * Uses LadybugDB's built-in full-text search indexes for keyword-based search.
5
5
  * Always reads from the database (no cached state to drift).
6
6
  */
7
- import { queryFTS } from '../lbug/lbug-adapter.js';
7
+ // tri-review Residual-1: `classifyFtsQueryError` now lives in lbug-adapter.ts
8
+ // (see its doc comment) so `queryFTS`'s own catch can share the SAME
9
+ // classifier instead of maintaining a second, independently-drifting copy
10
+ // for the identical `QUERY_FTS_INDEX` cypher call.
11
+ import { queryFTS, classifyFtsQueryError } from '../lbug/lbug-adapter.js';
8
12
  import { normalizeFtsText } from '../lbug/csv-generator.js';
13
+ import { getExtensionCapabilities } from '../lbug/extension-loader.js';
14
+ import { redactPaths } from './fts-indexes.js';
9
15
  import { FTS_INDEXES } from './fts-schema.js';
10
16
  import { applyCjkSegmentationIfEnabled, MAX_CJK_SEGMENTATION_QUERY_LENGTH, } from './cjk-segmentation.js';
11
17
  /**
12
18
  * Execute a single FTS query via a custom executor (for MCP connection pool).
13
- * Returns `null` when the query fails (e.g. FTS index does not exist) so the
14
- * caller can distinguish "zero matches" from "index missing".
19
+ * Returns a benign failure when the query fails because the index doesn't
20
+ * exist (the normal, expected case), and a non-benign failure with the
21
+ * captured message for any other error, so the caller can distinguish "zero
22
+ * matches", "index missing", and "a real error occurred" instead of
23
+ * collapsing the latter two into the same silent `null`.
15
24
  */
16
25
  async function queryFTSViaExecutor(executor, tableName, indexName, query, limit) {
17
26
  const cypher = `
@@ -22,18 +31,21 @@ async function queryFTSViaExecutor(executor, tableName, indexName, query, limit)
22
31
  `;
23
32
  try {
24
33
  const rows = await executor(cypher, { query });
25
- return rows.map((row) => {
26
- const node = row.node || row[0] || {};
27
- const score = row.score ?? row[1] ?? 0;
28
- return {
29
- filePath: node.filePath || '',
30
- score: typeof score === 'number' ? score : parseFloat(score) || 0,
31
- nodeId: node.nodeId || node.id || '',
32
- };
33
- });
34
+ return {
35
+ rows: rows.map((row) => {
36
+ const node = row.node || row[0] || {};
37
+ const score = row.score ?? row[1] ?? 0;
38
+ return {
39
+ filePath: node.filePath || '',
40
+ score: typeof score === 'number' ? score : parseFloat(score) || 0,
41
+ nodeId: node.nodeId || node.id || '',
42
+ };
43
+ }),
44
+ };
34
45
  }
35
- catch {
36
- return null;
46
+ catch (e) {
47
+ const message = e instanceof Error ? e.message : String(e);
48
+ return { benign: classifyFtsQueryError(message) === 'missing-index', message };
37
49
  }
38
50
  }
39
51
  /**
@@ -65,7 +77,23 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
65
77
  : query);
66
78
  const resultsByIndex = [];
67
79
  let queriesSucceeded = 0;
68
- if (repoId) {
80
+ const nonBenignErrors = [];
81
+ const ftsExtension = getExtensionCapabilities().find((c) => c.name === 'fts');
82
+ if (ftsExtension && !ftsExtension.loaded) {
83
+ // tri-review NEW-4 (applies to BOTH the MCP pool path and the CLI/pipeline
84
+ // path — a /simplify altitude pass caught the original repoId-only guard
85
+ // letting the CLI branch surface spurious "non-benign" errors for this
86
+ // exact expected state, which the pool branch correctly stayed silent on):
87
+ // extension-unavailable is an expected, already-diagnosed degraded-capability
88
+ // state (#2374/#2658), not a per-table query error — every configured table
89
+ // would throw the identical "function not defined" shape, which
90
+ // classifyFtsQueryError correctly refuses to call benign "missing-index"
91
+ // (it's a different, more serious condition). Skip the N redundant
92
+ // QUERY_FTS_INDEX round-trips and N nonBenignErrors entries; ftsAvailable
93
+ // stays false and ftsDegradedWarning() already reports this state
94
+ // accurately from the same extension-capabilities registry.
95
+ }
96
+ else if (repoId) {
69
97
  // Use MCP connection pool via dynamic import
70
98
  // IMPORTANT: FTS queries run sequentially to avoid connection contention.
71
99
  // The MCP pool supports multiple connections, but FTS is best run serially.
@@ -73,23 +101,31 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
73
101
  const { executeParameterized } = poolMod;
74
102
  const executor = (cypher, params) => executeParameterized(repoId, cypher, params);
75
103
  for (const { table, indexName } of FTS_INDEXES) {
76
- const result = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit);
77
- if (result !== null) {
104
+ const outcome = await queryFTSViaExecutor(executor, table, indexName, searchQuery, limit);
105
+ if (outcome.rows) {
78
106
  queriesSucceeded++;
79
- resultsByIndex.push(result);
107
+ resultsByIndex.push(outcome.rows);
108
+ }
109
+ else if (!outcome.benign) {
110
+ nonBenignErrors.push(redactPaths(outcome.message ?? 'Unknown FTS query error'));
80
111
  }
81
112
  }
82
113
  }
83
114
  else {
84
115
  // Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
116
+ // tri-review Residual-1: `queryFTS` itself only swallows a genuinely-missing
117
+ // index (via the SAME classifyFtsQueryError this module re-exports); a
118
+ // missing-table or real query error rethrows here — track it the same way
119
+ // the MCP pool path does instead of a bare `catch {}` that dropped it.
85
120
  for (const { table, indexName } of FTS_INDEXES) {
86
121
  try {
87
122
  const result = await queryFTS(table, indexName, searchQuery, limit, false);
88
123
  queriesSucceeded++;
89
124
  resultsByIndex.push(result);
90
125
  }
91
- catch {
92
- // FTS index may not exist — count as failed
126
+ catch (e) {
127
+ const message = e instanceof Error ? e.message : String(e);
128
+ nonBenignErrors.push(redactPaths(message));
93
129
  }
94
130
  }
95
131
  }
@@ -129,5 +165,6 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
129
165
  nodeIds: r.nodeIds,
130
166
  })),
131
167
  ftsAvailable,
168
+ ...(nonBenignErrors.length > 0 && { nonBenignErrors }),
132
169
  };
133
170
  };
@@ -1,10 +1,59 @@
1
+ /**
2
+ * Strip filesystem paths from a LadybugDB error before it reaches the HTTP
3
+ * `/api/search` and MCP query surfaces (#2374, PR #2375): the raw LOAD error
4
+ * embeds the absolute extension path (username, home dir) which must not leak to
5
+ * a network client. The error class words ("Failed to load library", "invalid
6
+ * ELF header", "has not been installed") have no leading path separator and
7
+ * survive. CLI/doctor/log surfaces keep the full path (they read the reason
8
+ * directly, not through this function).
9
+ *
10
+ * tri-review Residual-3: every real message shape observed from LadybugDB
11
+ * wraps the path in single quotes (`Failed to load library '<path>': ...`),
12
+ * so a QUOTED path is redacted first, consuming through its closing quote —
13
+ * spaces included (e.g. a Windows username like `alice smith`). The original
14
+ * unquoted-stop-at-first-whitespace pattern still runs afterward as a
15
+ * fallback for the rare case of a path appearing without quotes; that path's
16
+ * own known limitation (partial redaction if it itself contains a space) is
17
+ * unchanged, but is no longer the ONLY path this function knows how to redact.
18
+ */
19
+ export declare const redactPaths: (reason: string) => string;
20
+ /**
21
+ * Resolved-repo/index identity a caller can attach to a degraded-FTS warning
22
+ * (#2767) so a reader can tell whether *this* session even resolved the index
23
+ * they expect, instead of guessing between a stale connection, a different
24
+ * repo/branch, or a genuine build failure. MCP-`query`-only today — never
25
+ * forwarded into the HTTP `/api/search` response (see that call site).
26
+ */
27
+ export interface FtsWarningContext {
28
+ repoName: string;
29
+ branch?: string;
30
+ indexedAt?: string;
31
+ /** Already redacted by the caller (e.g. via {@link redactPaths} on a captured query error). */
32
+ lastErrorRedacted?: string;
33
+ }
1
34
  /**
2
35
  * Warning attached to search responses when BM25/FTS is degraded. Prefers the
3
36
  * live extension-load failure (with LadybugDB's real reason, #2374) over the
4
37
  * generic indexes-missing message, so "indexes exist but the extension broke"
5
38
  * is not misreported as missing indexes.
39
+ *
40
+ * `context`, when supplied, appends the resolved repo/branch/indexed-at (and
41
+ * redacted query-error detail, if captured) so a CLI/MCP mismatch — or a real
42
+ * query error masquerading as "indexes missing" — is visible in the warning
43
+ * text itself (#2767). Optional and additive: omitting it reproduces today's
44
+ * exact message.
45
+ */
46
+ export declare const ftsDegradedWarning: (context?: FtsWarningContext) => string;
47
+ /**
48
+ * Warning for when the FTS extension is loaded and indexes exist, but every
49
+ * configured table's query failed for a real, non-benign reason (timeout,
50
+ * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
51
+ * missing-index case. `--repair-fts` will not fix a query/connection error,
52
+ * so this deliberately does NOT suggest it: reusing the missing-index
53
+ * message here would reproduce, for this cause, the exact misleading
54
+ * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
6
55
  */
7
- export declare const ftsDegradedWarning: () => string;
56
+ export declare const ftsQueryFailedWarning: (context: FtsWarningContext) => string;
8
57
  export declare const SUPPORTED_FTS_STEMMERS: ReadonlySet<string>;
9
58
  export interface CreateSearchFTSIndexesOptions {
10
59
  onIndexStart?: (table: string, indexName: string) => void;
@@ -10,15 +10,43 @@ import { FTS_INDEXES } from './fts-schema.js';
10
10
  * ELF header", "has not been installed") have no leading path separator and
11
11
  * survive. CLI/doctor/log surfaces keep the full path (they read the reason
12
12
  * directly, not through this function).
13
+ *
14
+ * tri-review Residual-3: every real message shape observed from LadybugDB
15
+ * wraps the path in single quotes (`Failed to load library '<path>': ...`),
16
+ * so a QUOTED path is redacted first, consuming through its closing quote —
17
+ * spaces included (e.g. a Windows username like `alice smith`). The original
18
+ * unquoted-stop-at-first-whitespace pattern still runs afterward as a
19
+ * fallback for the rare case of a path appearing without quotes; that path's
20
+ * own known limitation (partial redaction if it itself contains a space) is
21
+ * unchanged, but is no longer the ONLY path this function knows how to redact.
13
22
  */
14
- const redactPaths = (reason) => reason.replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, '<path>');
23
+ export const redactPaths = (reason) => reason
24
+ .replace(/'((?:[A-Za-z]:\\|\/)[^']*)'/g, "'<path>'")
25
+ .replace(/(?:[A-Za-z]:\\|\/)[^\s'"]+/g, '<path>');
26
+ /** The repo/branch/indexed-at portion shared by both warning-context formatters below. */
27
+ const formatResolvedSuffix = (context) => {
28
+ const branchSuffix = context.branch ? `/branch:${context.branch}` : '';
29
+ const indexedSuffix = context.indexedAt ? `, indexed ${context.indexedAt}` : '';
30
+ return `${context.repoName}${branchSuffix}${indexedSuffix}`;
31
+ };
32
+ const formatWarningContext = (context) => {
33
+ const errorSuffix = context.lastErrorRedacted ? `; last error: ${context.lastErrorRedacted}` : '';
34
+ return ` (resolved: ${formatResolvedSuffix(context)}${errorSuffix})`;
35
+ };
15
36
  /**
16
37
  * Warning attached to search responses when BM25/FTS is degraded. Prefers the
17
38
  * live extension-load failure (with LadybugDB's real reason, #2374) over the
18
39
  * generic indexes-missing message, so "indexes exist but the extension broke"
19
40
  * is not misreported as missing indexes.
41
+ *
42
+ * `context`, when supplied, appends the resolved repo/branch/indexed-at (and
43
+ * redacted query-error detail, if captured) so a CLI/MCP mismatch — or a real
44
+ * query error masquerading as "indexes missing" — is visible in the warning
45
+ * text itself (#2767). Optional and additive: omitting it reproduces today's
46
+ * exact message.
20
47
  */
21
- export const ftsDegradedWarning = () => {
48
+ export const ftsDegradedWarning = (context) => {
49
+ const suffix = context ? formatWarningContext(context) : '';
22
50
  const fts = getExtensionCapabilities().find((c) => c.name === 'fts');
23
51
  if (fts && !fts.loaded) {
24
52
  const reason = fts.reason ? redactPaths(fts.reason).replace(/\.$/, '') : undefined;
@@ -33,10 +61,27 @@ export const ftsDegradedWarning = () => {
33
61
  : '. Run `gitnexus doctor` for details, then `gitnexus analyze --repair-fts` with network access to reinstall.';
34
62
  return ('FTS extension failed to load — keyword search degraded' +
35
63
  (reason ? ` (${reason})` : '') +
36
- tail);
64
+ tail +
65
+ suffix);
37
66
  }
38
- return 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.';
67
+ return ('FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts ' +
68
+ '(or gitnexus analyze --force) to rebuild indexes.' +
69
+ suffix);
39
70
  };
71
+ /**
72
+ * Warning for when the FTS extension is loaded and indexes exist, but every
73
+ * configured table's query failed for a real, non-benign reason (timeout,
74
+ * connection reset, native fault) — as opposed to `ftsDegradedWarning`'s
75
+ * missing-index case. `--repair-fts` will not fix a query/connection error,
76
+ * so this deliberately does NOT suggest it: reusing the missing-index
77
+ * message here would reproduce, for this cause, the exact misleading
78
+ * "run --repair-fts" guidance #2767 itself was about (tri-review NEW-1).
79
+ */
80
+ export const ftsQueryFailedWarning = (context) => 'FTS keyword search failed — every configured index query returned an error' +
81
+ (context.lastErrorRedacted ? ` (${context.lastErrorRedacted})` : '') +
82
+ '; results do not include keyword matches. This is not a missing-index ' +
83
+ 'condition — see server logs for details.' +
84
+ ` (resolved: ${formatResolvedSuffix(context)})`;
40
85
  // Stemmers shipped by the LadybugDB FTS extension. Mirrors the lowercase token
41
86
  // set in the extension bundled with @ladybugdb/core 0.18.x (see package.json).
42
87
  // Keep in sync on a LadybugDB minor bump — a value here that the installed
@@ -156,8 +156,9 @@ export declare class LocalBackend {
156
156
  private reinitPromises;
157
157
  private lastStalenessCheck;
158
158
  private toolStalenessCache;
159
- private lastObservedIndexedAt;
160
- private lastObservedDbIdentity;
159
+ private lastObservedPoolState;
160
+ /** Merge-patch one poolKey's observed state, preserving fields not passed. */
161
+ private setObservedState;
161
162
  private groupToolSvc;
162
163
  /**
163
164
  * One-shot stderr warnings for sibling-clone drift, keyed by
@@ -28,7 +28,7 @@ import { rankExactEmbeddingRows, } from '../../core/embeddings/exact-search.js';
28
28
  import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
29
29
  import { getExactScanLimit } from '../../core/platform/capabilities.js';
30
30
  import { PhaseTimer } from '../../core/search/phase-timer.js';
31
- import { ftsDegradedWarning } from '../../core/search/fts-indexes.js';
31
+ import { ftsDegradedWarning, ftsQueryFailedWarning } from '../../core/search/fts-indexes.js';
32
32
  import { cjkSegmentationModeMismatch, containsSegmentableCjkRun, getSearchFTSCjkSegmentation, isSupportedCjkSegmentationMode, MAX_CJK_SEGMENTATION_QUERY_LENGTH, } from '../../core/search/cjk-segmentation.js';
33
33
  import { checkStalenessAsync, checkCwdMatch, } from '../../core/git-staleness.js';
34
34
  import { logger } from '../../core/logger.js';
@@ -525,17 +525,30 @@ export class LocalBackend {
525
525
  // lastCommit values, so a repoPath key would serve one handle's freshness for
526
526
  // the other; lbugPath is unique per flat/branch index.
527
527
  toolStalenessCache = new Map();
528
- // Last meta.indexedAt observed for an open pool, keyed by lbugPath. Keyed by
529
- // pool (not stored on the handle) because branch handles are produced fresh
530
- // by applyBranchScope on every resolveRepo call, so mutating the handle would
531
- // not persist across calls and the staleness check would reinit forever
532
- // (#2106).
533
- lastObservedIndexedAt = new Map();
534
- // #2614 F1: file identity of the lbug the pool last opened. An atomic swap or
535
- // an in-place incremental changes the inode; reiniting on that reinit-covers
536
- // the window where meta.indexedAt hasn't caught up (and the incremental case),
537
- // so a rebuilt index is never served stale even when the stamp looks current.
538
- lastObservedDbIdentity = new Map();
528
+ // tri-review Residual-2: consolidates what were three parallel per-poolKey
529
+ // Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus)
530
+ // touched in lockstep at every call site below — one Map, one delete, one
531
+ // shape. Keyed by lbugPath (not stored on the repo handle) because branch
532
+ // handles are produced fresh by applyBranchScope on every resolveRepo call,
533
+ // so mutating the handle would not persist across calls and the staleness
534
+ // check would reinit forever (#2106).
535
+ // - `indexedAt`: last meta.indexedAt observed for an open pool.
536
+ // - `dbIdentity`: file identity of the lbug the pool last opened (#2614 F1)
537
+ // — an atomic swap or in-place incremental changes the inode; reiniting
538
+ // on that covers the window where meta.indexedAt hasn't caught up (and
539
+ // the incremental case), so a rebuilt index is never served stale even
540
+ // when the stamp looks current.
541
+ // - `ftsStatus`: last meta.capabilities.fts.status observed (#2767).
542
+ // `--repair-fts` intentionally never restamps `indexedAt` (it doesn't
543
+ // regenerate the graph), so this is the dedicated signal a warm session
544
+ // uses to notice a repair — independent of the file-identity heuristic,
545
+ // which the repair path also triggers but only incidentally.
546
+ lastObservedPoolState = new Map();
547
+ /** Merge-patch one poolKey's observed state, preserving fields not passed. */
548
+ setObservedState(poolKey, patch) {
549
+ const current = this.lastObservedPoolState.get(poolKey) ?? { dbIdentity: null };
550
+ this.lastObservedPoolState.set(poolKey, { ...current, ...patch });
551
+ }
539
552
  groupToolSvc = null;
540
553
  /**
541
554
  * One-shot stderr warnings for sibling-clone drift, keyed by
@@ -821,8 +834,7 @@ export class LocalBackend {
821
834
  this.initializedRepos.delete(key);
822
835
  this.lastStalenessCheck.delete(key);
823
836
  this.toolStalenessCache.delete(key);
824
- this.lastObservedIndexedAt.delete(key);
825
- this.lastObservedDbIdentity.delete(key);
837
+ this.lastObservedPoolState.delete(key);
826
838
  this.reinitPromises.delete(key);
827
839
  closeLbug(key).catch(() => { });
828
840
  }
@@ -1182,18 +1194,28 @@ export class LocalBackend {
1182
1194
  // Reading the flat meta for a branch handle would compare the branch
1183
1195
  // index's indexedAt against the primary's and thrash the pool (#2106).
1184
1196
  const meta = await loadMeta(path.dirname(repo.lbugPath));
1197
+ const observedState = this.lastObservedPoolState.get(poolKey);
1185
1198
  // Compare against the last indexedAt OBSERVED for this pool (keyed by
1186
1199
  // lbugPath), not the handle's — branch handles are fresh spreads so a
1187
1200
  // handle mutation would not persist and would reinit on every check.
1188
- const observed = this.lastObservedIndexedAt.get(poolKey) ?? repo.indexedAt;
1201
+ const observed = observedState?.indexedAt ?? repo.indexedAt;
1189
1202
  const stampChanged = !!meta?.indexedAt && meta.indexedAt !== observed;
1190
1203
  // #2614 F1: also reinit on a file-identity change. An atomic swap (or an
1191
1204
  // in-place incremental) changes the lbug inode; keying only on
1192
1205
  // meta.indexedAt let a reader that reinited inside the pre-swap window
1193
1206
  // latch on the old inode forever (its stamp already == meta.indexedAt).
1194
1207
  const currentIdentity = await statDbIdentity(repo.lbugPath);
1195
- const identityChanged = dbIdentityChanged(this.lastObservedDbIdentity.get(poolKey) ?? null, currentIdentity);
1196
- if (stampChanged || identityChanged) {
1208
+ const identityChanged = dbIdentityChanged(observedState?.dbIdentity ?? null, currentIdentity);
1209
+ // #2767: `--repair-fts` intentionally never restamps `indexedAt` (it
1210
+ // doesn't regenerate the graph), so `stampChanged` alone can't notice
1211
+ // a repair. `capabilities.fts.status` is the field repair-fts DOES
1212
+ // write, so a change there is a third, independent reinit trigger —
1213
+ // sibling to stampChanged/identityChanged, not a replacement for them
1214
+ // (identityChanged still catches an in-place mutation even if the
1215
+ // caps stamp were somehow missed).
1216
+ const ftsStatus = meta?.capabilities?.fts?.status;
1217
+ const ftsCapsChanged = observedState?.ftsStatus !== ftsStatus;
1218
+ if (stampChanged || identityChanged || ftsCapsChanged) {
1197
1219
  // Index was rebuilt/swapped — DELEGATE the close/reopen to the pool's
1198
1220
  // initLbug, which refuses to evict (and close the shared Database)
1199
1221
  // while a query is in flight (its checkedOut>0 guard). Calling
@@ -1202,18 +1224,24 @@ export class LocalBackend {
1202
1224
  // reinitPromises to serialize concurrent detectors.
1203
1225
  const reinit = (async () => {
1204
1226
  try {
1205
- // Advance the observed stamp regardless: a stamp change with an
1206
- // unchanged file must not re-trigger on every check.
1207
- if (meta?.indexedAt)
1208
- this.lastObservedIndexedAt.set(poolKey, meta.indexedAt);
1209
1227
  const reopened = await initLbug(poolKey, repo.lbugPath);
1228
+ // tri-review NEW-7: advance the observed stamp/caps watermarks
1229
+ // only AFTER initLbug completes, not before calling it — still
1230
+ // regardless of `reopened` true/false (a stamp/caps change with
1231
+ // an unchanged file must not re-trigger on every check), but if
1232
+ // initLbug THROWS the watermark must stay at its old value so
1233
+ // the next staleness check retries, instead of a failed reinit
1234
+ // silently latching as "already applied" and never trying again.
1235
+ const patch = { ftsStatus };
1236
+ if (meta?.indexedAt)
1237
+ patch.indexedAt = meta.indexedAt;
1210
1238
  // Advance the observed IDENTITY only when the pool actually rolled
1211
1239
  // over. If a query was in flight, initLbug served the current
1212
1240
  // handle and returned false; leaving the identity divergent
1213
1241
  // re-triggers the reopen on a later idle check instead of latching.
1214
- if (reopened) {
1215
- this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath));
1216
- }
1242
+ if (reopened)
1243
+ patch.dbIdentity = await statDbIdentity(repo.lbugPath);
1244
+ this.setObservedState(poolKey, patch);
1217
1245
  }
1218
1246
  finally {
1219
1247
  this.reinitPromises.delete(poolKey);
@@ -1233,8 +1261,18 @@ export class LocalBackend {
1233
1261
  try {
1234
1262
  await initLbug(poolKey, repo.lbugPath);
1235
1263
  this.initializedRepos.add(poolKey);
1236
- this.lastObservedIndexedAt.set(poolKey, repo.indexedAt);
1237
- this.lastObservedDbIdentity.set(poolKey, await statDbIdentity(repo.lbugPath));
1264
+ // #2767: ftsStatus is deliberately left unset (undefined) here rather
1265
+ // than issuing an extra loadMeta read — every tool call already routes
1266
+ // through ensureInitialized, so an extra per-cold-init read adds up,
1267
+ // and the cost of skipping it is negligible: at most one redundant
1268
+ // initLbug call on the first warm check (initLbug itself no-ops
1269
+ // cheaply via a single fs.stat when the file identity is actually
1270
+ // unchanged, per pool-adapter.ts's own "unchanged → reuse" guard), not
1271
+ // a real reopen.
1272
+ this.setObservedState(poolKey, {
1273
+ indexedAt: repo.indexedAt,
1274
+ dbIdentity: await statDbIdentity(repo.lbugPath),
1275
+ });
1238
1276
  }
1239
1277
  catch (err) {
1240
1278
  // If lock error, mark as not initialized so next call retries
@@ -1628,6 +1666,21 @@ export class LocalBackend {
1628
1666
  // unavailable the search helper may return an unexpected shape.
1629
1667
  const bm25Results = bm25SearchResult?.results ?? [];
1630
1668
  const ftsUsed = bm25SearchResult?.ftsUsed ?? false;
1669
+ // #2767: log every non-benign per-table FTS query error server-side,
1670
+ // regardless of whether OTHER tables succeeded — previously a real error
1671
+ // on N-1 of N tables while one succeeded left zero diagnostic trail.
1672
+ const ftsQueryErrors = bm25SearchResult?.nonBenignErrors;
1673
+ if (ftsQueryErrors) {
1674
+ // tri-review NEW-5: these strings are already classified non-benign by
1675
+ // classifyFtsQueryError — do NOT route them through logQueryError,
1676
+ // whose own broader, unanchored isBenignMissingTableError regex (any
1677
+ // "does not exist" substring, anywhere) could disagree and silently
1678
+ // demote an already-flagged real error to debug, undercutting the
1679
+ // severity signal this classification exists to preserve.
1680
+ for (const err of ftsQueryErrors) {
1681
+ logger.warn({ context: 'query:fts-search', err }, 'GitNexus query failed (degraded)');
1682
+ }
1683
+ }
1631
1684
  // Merge via reciprocal rank fusion
1632
1685
  timer.start('merge');
1633
1686
  const scoreMap = new Map();
@@ -1868,7 +1921,34 @@ export class LocalBackend {
1868
1921
  // path, leaving the success-path response shape byte-identical.
1869
1922
  const warnings = [];
1870
1923
  if (!ftsUsed) {
1871
- warnings.push(ftsDegradedWarning());
1924
+ // #2767: attach what THIS session resolved (repo/branch/indexed-at) so a
1925
+ // CLI/MCP mismatch is visible in the warning itself rather than requiring
1926
+ // a separate debugging round-trip. tri-review NEW-3: `indexedAt` reads
1927
+ // from `lastObservedPoolState` (kept current by ensureInitialized's
1928
+ // staleness check, including a same-call reinit) rather than the `repo`
1929
+ // handle resolved before that check ran — a warm backend that just
1930
+ // reopened against a newer on-disk index must not warn with stale
1931
+ // metadata. No extra I/O: the map is already maintained per-request.
1932
+ const warningContext = {
1933
+ repoName: repo.name,
1934
+ branch: repo.branch,
1935
+ indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt,
1936
+ };
1937
+ // tri-review NEW-1: every table failing for a REAL error (timeout,
1938
+ // connection reset) is not a missing-index condition — `ftsDegradedWarning`'s
1939
+ // "run --repair-fts" headline won't fix it. Route to a dedicated message
1940
+ // instead of burying the real cause as a trailing suffix on bad advice.
1941
+ warnings.push(ftsQueryErrors
1942
+ ? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] })
1943
+ : ftsDegradedWarning(warningContext));
1944
+ }
1945
+ else if (ftsQueryErrors) {
1946
+ // #2767: at least one FTS table succeeded (ftsUsed=true) but another
1947
+ // hit a real, non-benign error — results may be silently missing
1948
+ // matches from that table with no signal, the same "partial success"
1949
+ // shape the enrichmentDegraded branch below already surfaces. Mirror
1950
+ // that convention instead of only logging server-side.
1951
+ 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).`);
1872
1952
  }
1873
1953
  // #2331: a CJK query against a server process resolving
1874
1954
  // GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase
@@ -1935,13 +2015,17 @@ export class LocalBackend {
1935
2015
  if (enrichmentDegraded) {
1936
2016
  warnings.push('Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).');
1937
2017
  }
2018
+ // #2767: a partial FTS failure (some tables ok, one or more real errors)
2019
+ // is as much a "results may be incomplete" signal as enrichmentDegraded —
2020
+ // flag it the same way rather than only via the warning string.
2021
+ const ftsPartial = ftsUsed && !!ftsQueryErrors;
1938
2022
  return {
1939
2023
  processes,
1940
2024
  process_symbols: dedupedSymbols,
1941
2025
  definitions: definitions.slice(0, 20), // cap standalone definitions
1942
2026
  timing,
1943
2027
  ...(warnings.length > 0 && { warning: warnings.join(' ') }),
1944
- ...(enrichmentDegraded && { partial: true }),
2028
+ ...((enrichmentDegraded || ftsPartial) && { partial: true }),
1945
2029
  };
1946
2030
  }
1947
2031
  /**
@@ -1974,6 +2058,7 @@ export class LocalBackend {
1974
2058
  // could be undefined when the FTS extension is unavailable in the MCP process.
1975
2059
  const bm25Results = ftsResponse?.results ?? [];
1976
2060
  const ftsUsed = ftsResponse?.ftsAvailable ?? false;
2061
+ const nonBenignErrors = ftsResponse?.nonBenignErrors;
1977
2062
  const results = [];
1978
2063
  for (const bm25Result of bm25Results) {
1979
2064
  const fullPath = bm25Result.filePath;
@@ -2036,7 +2121,7 @@ export class LocalBackend {
2036
2121
  });
2037
2122
  }
2038
2123
  }
2039
- return { results, ftsUsed };
2124
+ return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
2040
2125
  }
2041
2126
  /**
2042
2127
  * Semantic vector search helper
@@ -1558,8 +1558,16 @@ export const createServer = async (port, host = '127.0.0.1') => {
1558
1558
  }
1559
1559
  const forceReembedNodeIds = new Set(priorCheckpoint?.pendingNodeIds ?? []);
1560
1560
  const saveEmbeddingCheckpoint = async (checkpoint, pendingNodeIds) => {
1561
+ // tri-review NEW-2: re-read immediately before writing (mirrors
1562
+ // the pattern in run-analyze.ts's --repair-fts stamp) instead of
1563
+ // spreading the stale `embeddingMeta` snapshot captured once at
1564
+ // job start. This job can run up to EMBED_TIMEOUT_MS (30 min);
1565
+ // without a fresh read, a concurrent writer's update (e.g. a
1566
+ // --repair-fts capability stamp) would be silently reverted on
1567
+ // every checkpoint save for the job's whole lifetime.
1568
+ const latestMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta;
1561
1569
  embeddingMeta = {
1562
- ...embeddingMeta,
1570
+ ...latestMeta,
1563
1571
  embeddingCheckpoint: {
1564
1572
  at: new Date().toISOString(),
1565
1573
  ...checkpoint,
@@ -1610,7 +1618,9 @@ export const createServer = async (port, host = '127.0.0.1') => {
1610
1618
  // handles this during process exit, but the server keeps the
1611
1619
  // connection open for other routes — a CHECKPOINT is enough.
1612
1620
  await flushWAL();
1613
- embeddingMeta = { ...embeddingMeta, embeddingCheckpoint: undefined };
1621
+ // Same re-read-before-write reasoning as saveEmbeddingCheckpoint above.
1622
+ const finalMeta = (await loadMeta(entry.storagePath)) ?? embeddingMeta;
1623
+ embeddingMeta = { ...finalMeta, embeddingCheckpoint: undefined };
1614
1624
  await saveMeta(entry.storagePath, embeddingMeta);
1615
1625
  });
1616
1626
  // Don't overwrite 'failed' if the job was cancelled while the pipeline was running
@@ -155,9 +155,12 @@ export interface RepoMeta {
155
155
  * the meta literal in run-analyze.ts — typed here so the stamp site is
156
156
  * compile-checked; tri-review 4669518496 P1/U3: `vectorSearch.status`
157
157
  * must never claim 'vector-index' unless the run verified or recreated
158
- * the HNSW index). Forensic today — no programmatic readers (`doctor`
159
- * prints platform-derived capabilities, query routing never consults
160
- * meta). The status unions mirror `CapabilityStatus` /
158
+ * the HNSW index). `fts.status` gained its first programmatic reader in
159
+ * #2767: `LocalBackend.ensureInitialized()` compares it against the
160
+ * warm connection pool's last-observed value as the dedicated signal
161
+ * that `--repair-fts` changed FTS availability (`doctor` still prints
162
+ * platform-derived capabilities separately; `graph`/`vectorSearch` remain
163
+ * forensic-only). The status unions mirror `CapabilityStatus` /
161
164
  * `SemanticSearchMode` in core/platform/capabilities.ts; inlined to keep
162
165
  * storage/ free of a core/ type dependency.
163
166
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.10-rc.135",
3
+ "version": "1.6.10-rc.136",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",