gitnexus 1.6.9 → 1.6.10-rc.10

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.
Files changed (91) hide show
  1. package/README.md +126 -41
  2. package/dist/cli/ai-context.d.ts +1 -1
  3. package/dist/cli/ai-context.js +1 -1
  4. package/dist/cli/analyze.js +106 -3
  5. package/dist/cli/cli-message.d.ts +1 -1
  6. package/dist/cli/doctor.d.ts +5 -0
  7. package/dist/cli/doctor.js +41 -3
  8. package/dist/cli/editor-targets.d.ts +17 -2
  9. package/dist/cli/editor-targets.js +44 -0
  10. package/dist/cli/embeddings.d.ts +12 -0
  11. package/dist/cli/embeddings.js +49 -0
  12. package/dist/cli/help-i18n.js +4 -0
  13. package/dist/cli/i18n/en.d.ts +5 -1
  14. package/dist/cli/i18n/en.js +5 -1
  15. package/dist/cli/i18n/resources.d.ts +9 -1
  16. package/dist/cli/i18n/zh-CN.d.ts +4 -0
  17. package/dist/cli/i18n/zh-CN.js +5 -1
  18. package/dist/cli/index.js +12 -1
  19. package/dist/cli/setup.js +204 -28
  20. package/dist/cli/uninstall.js +70 -16
  21. package/dist/core/embeddings/embedder.js +16 -2
  22. package/dist/core/embeddings/http-client.d.ts +28 -1
  23. package/dist/core/embeddings/http-client.js +107 -15
  24. package/dist/core/embeddings/node-module-compat.d.ts +23 -0
  25. package/dist/core/embeddings/node-module-compat.js +23 -0
  26. package/dist/core/embeddings/onnxruntime-common-resolver.js +6 -3
  27. package/dist/core/embeddings/onnxruntime-node-resolver.js +31 -5
  28. package/dist/core/embeddings/runtime-install.d.ts +119 -0
  29. package/dist/core/embeddings/runtime-install.js +372 -0
  30. package/dist/core/embeddings/runtime-support.d.ts +48 -17
  31. package/dist/core/embeddings/runtime-support.js +96 -0
  32. package/dist/core/group/extractors/http-patterns/python.js +246 -108
  33. package/dist/core/group/extractors/manifest-extractor.js +4 -0
  34. package/dist/core/ingestion/cobol/jcl-processor.js +9 -8
  35. package/dist/core/ingestion/cobol-processor.js +27 -26
  36. package/dist/core/ingestion/community-processor.d.ts +51 -1
  37. package/dist/core/ingestion/community-processor.js +316 -63
  38. package/dist/core/ingestion/emit-references.js +3 -2
  39. package/dist/core/ingestion/markdown-processor.js +3 -2
  40. package/dist/core/ingestion/parsing-processor.d.ts +3 -1
  41. package/dist/core/ingestion/parsing-processor.js +4 -0
  42. package/dist/core/ingestion/pipeline-phases/parse-impl.js +42 -0
  43. package/dist/core/ingestion/route-extractors/constant-resolver.d.ts +83 -0
  44. package/dist/core/ingestion/route-extractors/constant-resolver.js +131 -0
  45. package/dist/core/ingestion/route-extractors/python-const-resolver.d.ts +75 -0
  46. package/dist/core/ingestion/route-extractors/python-const-resolver.js +299 -0
  47. package/dist/core/ingestion/tree-sitter-queries.d.ts +1 -1
  48. package/dist/core/ingestion/tree-sitter-queries.js +12 -1
  49. package/dist/core/ingestion/utils/line-base.d.ts +20 -0
  50. package/dist/core/ingestion/utils/line-base.js +20 -0
  51. package/dist/core/ingestion/utils/symbol-labels.d.ts +21 -0
  52. package/dist/core/ingestion/utils/symbol-labels.js +45 -0
  53. package/dist/core/ingestion/workers/parse-worker.d.ts +34 -0
  54. package/dist/core/ingestion/workers/parse-worker.js +39 -4
  55. package/dist/core/ingestion/workers/result-merge.js +4 -0
  56. package/dist/core/lbug/csv-generator.js +8 -2
  57. package/dist/core/lbug/extension-load-error.d.ts +67 -0
  58. package/dist/core/lbug/extension-load-error.js +320 -0
  59. package/dist/core/lbug/extension-loader.d.ts +17 -2
  60. package/dist/core/lbug/extension-loader.js +38 -13
  61. package/dist/core/lbug/lbug-adapter.js +7 -16
  62. package/dist/core/lbug/native-check.d.ts +22 -0
  63. package/dist/core/lbug/native-check.js +66 -0
  64. package/dist/core/lbug/pool-adapter.js +10 -1
  65. package/dist/core/lbug/sidecar-recovery.d.ts +32 -0
  66. package/dist/core/lbug/sidecar-recovery.js +91 -7
  67. package/dist/core/platform/capabilities.js +27 -1
  68. package/dist/core/run-analyze.js +33 -6
  69. package/dist/core/search/fts-indexes.d.ts +7 -0
  70. package/dist/core/search/fts-indexes.js +37 -0
  71. package/dist/mcp/core/embedder.js +16 -2
  72. package/dist/mcp/local/line-display.d.ts +22 -0
  73. package/dist/mcp/local/line-display.js +3 -0
  74. package/dist/mcp/local/local-backend.d.ts +14 -0
  75. package/dist/mcp/local/local-backend.js +63 -19
  76. package/dist/mcp/local/pdg-impact.d.ts +5 -3
  77. package/dist/mcp/local/pdg-impact.js +5 -2
  78. package/dist/mcp/resources.js +1 -0
  79. package/dist/mcp/tools.js +1 -1
  80. package/dist/server/api.js +2 -2
  81. package/dist/storage/parse-cache.js +1 -1
  82. package/dist/storage/repo-manager.d.ts +6 -1
  83. package/dist/storage/repo-manager.js +6 -1
  84. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +50 -3
  85. package/hooks/claude/gitnexus-hook.cjs +59 -9
  86. package/package.json +5 -3
  87. package/scripts/cross-platform-tests.ts +26 -0
  88. package/scripts/ensure-fts.ts +32 -0
  89. package/scripts/install-duckdb-extension.mjs +85 -24
  90. package/scripts/run-cross-platform.ts +46 -6
  91. package/scripts/shard-arg.ts +30 -0
@@ -1,3 +1,4 @@
1
+ import { type ExtensionLoadDiagnosis } from './extension-load-error.js';
1
2
  /**
2
3
  * Lifecycle policy for an optional DuckDB extension.
3
4
  *
@@ -20,6 +21,12 @@ export interface ExtensionCapability {
20
21
  loaded: boolean;
21
22
  /** Human-readable reason when `loaded` is false. */
22
23
  reason?: string;
24
+ /**
25
+ * Classified diagnosis of `reason`, computed ONCE at mark-unavailable time so
26
+ * per-request surfaces (ftsDegradedWarning on /api/search + MCP query) read the
27
+ * cached remedy instead of re-inspecting the extension file on every call (#2383 F3).
28
+ */
29
+ diagnosis?: ExtensionLoadDiagnosis;
23
30
  }
24
31
  /** Per-call overrides applied on top of `ExtensionManager` defaults. */
25
32
  export interface ExtensionEnsureOptions {
@@ -29,7 +36,7 @@ export interface ExtensionEnsureOptions {
29
36
  export interface ExtensionManagerOptions {
30
37
  policy?: ExtensionInstallPolicy;
31
38
  installTimeoutMs?: number;
32
- installExtension?: (extensionName: string, timeoutMs: number) => Promise<ExtensionInstallResult>;
39
+ installExtension?: (extensionName: string, timeoutMs: number, loadError?: string) => Promise<ExtensionInstallResult>;
33
40
  warn?: (message: string) => void;
34
41
  }
35
42
  export declare const getExtensionInstallPolicy: () => ExtensionInstallPolicy;
@@ -58,7 +65,7 @@ export declare const getExtensionInstallChildProcessArgs: (extensionName: string
58
65
  * If the child exceeds `timeoutMs` the parent kills it with SIGKILL and
59
66
  * resolves with `timedOut: true`.
60
67
  */
61
- export declare const installDuckDbExtensionOutOfProcess: (extensionName: string, timeoutMs?: number) => Promise<ExtensionInstallResult>;
68
+ export declare const installDuckDbExtensionOutOfProcess: (extensionName: string, timeoutMs?: number, loadError?: string) => Promise<ExtensionInstallResult>;
62
69
  /**
63
70
  * Centralized lifecycle manager for optional LadybugDB extensions.
64
71
  *
@@ -90,6 +97,14 @@ export declare class ExtensionManager {
90
97
  * paths are expected to degrade gracefully.
91
98
  */
92
99
  ensure(query: (sql: string) => Promise<unknown>, name: string, label: string, opts?: ExtensionEnsureOptions): Promise<boolean>;
100
+ /**
101
+ * Attempt `LOAD EXTENSION <name>`; returns `null` on success and the
102
+ * collapsed error message on failure. The message is the load-side ground
103
+ * truth — LadybugDB distinguishes a missing extension file from a present
104
+ * but unloadable one (wrong platform, truncated download, version mismatch),
105
+ * and discarding it left users staring at "not pre-installed" when the file
106
+ * existed all along (#2374).
107
+ */
93
108
  private tryLoad;
94
109
  private markLoaded;
95
110
  private markUnavailable;
@@ -1,12 +1,15 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { LBUG_MAX_DB_SIZE } from './lbug-config.js';
4
+ import { diagnoseExtensionLoad } from './extension-load-error.js';
4
5
  import { logger } from '../logger.js';
5
6
  const DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS = 15_000;
6
7
  const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
7
8
  const alreadyAvailable = (message) => message.includes('already loaded') ||
8
9
  message.includes('already installed') ||
9
10
  message.includes('already exists');
11
+ /** LadybugDB errors are multi-line; collapse for single-line warn/reason strings. */
12
+ const oneLine = (value) => value.replace(/\s+/g, ' ').trim();
10
13
  const resolvePolicyFromEnv = () => {
11
14
  const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
12
15
  if (raw === 'load-only' || raw === 'never' || raw === 'auto')
@@ -51,7 +54,7 @@ export const getExtensionInstallChildProcessArgs = (extensionName, maxDbSize = L
51
54
  * If the child exceeds `timeoutMs` the parent kills it with SIGKILL and
52
55
  * resolves with `timedOut: true`.
53
56
  */
54
- export const installDuckDbExtensionOutOfProcess = async (extensionName, timeoutMs = getExtensionInstallTimeoutMs()) => {
57
+ export const installDuckDbExtensionOutOfProcess = async (extensionName, timeoutMs = getExtensionInstallTimeoutMs(), loadError) => {
55
58
  if (!EXTENSION_NAME_PATTERN.test(extensionName)) {
56
59
  throw new Error(`Invalid DuckDB extension name: ${extensionName}`);
57
60
  }
@@ -60,6 +63,9 @@ export const installDuckDbExtensionOutOfProcess = async (extensionName, timeoutM
60
63
  env: {
61
64
  ...process.env,
62
65
  GITNEXUS_LBUG_EXTENSION_NAME: extensionName,
66
+ // The child picks INSTALL vs FORCE INSTALL from this LOAD error so it
67
+ // only re-downloads when the on-disk extension file is actually broken.
68
+ ...(loadError ? { GITNEXUS_LBUG_EXTENSION_LOAD_ERROR: loadError } : {}),
63
69
  },
64
70
  stdio: ['ignore', 'ignore', 'pipe'],
65
71
  windowsHide: true,
@@ -78,7 +84,7 @@ export const installDuckDbExtensionOutOfProcess = async (extensionName, timeoutM
78
84
  resolve({
79
85
  success: false,
80
86
  timedOut: true,
81
- message: `INSTALL ${extensionName} timed out after ${timeoutMs}ms`,
87
+ message: `extension install for ${extensionName} timed out after ${timeoutMs}ms`,
82
88
  });
83
89
  }, timeoutMs);
84
90
  child.on('error', (err) => {
@@ -97,8 +103,8 @@ export const installDuckDbExtensionOutOfProcess = async (extensionName, timeoutM
97
103
  success: code === 0,
98
104
  timedOut: false,
99
105
  message: code === 0
100
- ? `INSTALL ${extensionName} completed`
101
- : `INSTALL ${extensionName} failed with ${signal ?? `exit code ${code}`}${stderr ? `: ${stderr.trim()}` : ''}`,
106
+ ? `extension install for ${extensionName} completed`
107
+ : `extension install for ${extensionName} failed with ${signal ?? `exit code ${code}`}${stderr ? `: ${stderr.trim()}` : ''}`,
102
108
  });
103
109
  });
104
110
  });
@@ -152,46 +158,65 @@ export class ExtensionManager {
152
158
  this.markUnavailable(name, label, 'extension install policy is "never"', warn);
153
159
  return false;
154
160
  }
155
- if (await this.tryLoad(query, name)) {
161
+ const loadError = await this.tryLoad(query, name);
162
+ if (loadError === null) {
156
163
  this.markLoaded(name);
157
164
  return true;
158
165
  }
159
166
  if (policy === 'load-only') {
160
- this.markUnavailable(name, label, 'load-only policy: extension not pre-installed', warn);
167
+ this.markUnavailable(name, label, `load-only policy (no install attempted); LOAD ${name} failed: ${loadError}`, warn);
161
168
  return false;
162
169
  }
163
170
  let install = this.installAttempted.get(name);
164
171
  if (!install) {
165
172
  const installFn = this.options.installExtension ?? installDuckDbExtensionOutOfProcess;
166
- install = await installFn(name, timeoutMs);
173
+ // Hand the child the LOAD error so it re-downloads (FORCE) only when the
174
+ // present extension file is provably broken, not on every LOAD failure.
175
+ install = await installFn(name, timeoutMs, loadError);
167
176
  this.installAttempted.set(name, install);
168
177
  }
169
178
  if (!install.success) {
170
- this.markUnavailable(name, label, install.message, warn);
179
+ this.markUnavailable(name, label, `${install.message}; LOAD ${name} had failed: ${loadError}`, warn);
171
180
  return false;
172
181
  }
173
- if (await this.tryLoad(query, name)) {
182
+ const retryError = await this.tryLoad(query, name);
183
+ if (retryError === null) {
174
184
  this.markLoaded(name);
175
185
  return true;
176
186
  }
177
- this.markUnavailable(name, label, `LOAD ${name} failed after successful INSTALL`, warn);
187
+ this.markUnavailable(name, label, `LOAD ${name} failed after successful INSTALL: ${retryError}`, warn);
178
188
  return false;
179
189
  }
190
+ /**
191
+ * Attempt `LOAD EXTENSION <name>`; returns `null` on success and the
192
+ * collapsed error message on failure. The message is the load-side ground
193
+ * truth — LadybugDB distinguishes a missing extension file from a present
194
+ * but unloadable one (wrong platform, truncated download, version mismatch),
195
+ * and discarding it left users staring at "not pre-installed" when the file
196
+ * existed all along (#2374).
197
+ */
180
198
  async tryLoad(query, name) {
181
199
  try {
182
200
  await query(`LOAD EXTENSION ${name}`);
183
- return true;
201
+ return null;
184
202
  }
185
203
  catch (err) {
186
204
  const msg = err instanceof Error ? err.message : String(err);
187
- return alreadyAvailable(msg);
205
+ return alreadyAvailable(msg) ? null : oneLine(msg);
188
206
  }
189
207
  }
190
208
  markLoaded(name) {
191
209
  this.capabilities.set(name, { name, loaded: true });
192
210
  }
193
211
  markUnavailable(name, label, reason, warn) {
194
- this.capabilities.set(name, { name, loaded: false, reason });
212
+ // Classify once here (the single load-failure sink, run per Database not per
213
+ // request) so the hot per-request warning path does no file I/O (#2383 F3).
214
+ this.capabilities.set(name, {
215
+ name,
216
+ loaded: false,
217
+ reason,
218
+ diagnosis: diagnoseExtensionLoad(reason),
219
+ });
195
220
  const key = `${name}:${reason}`;
196
221
  if (this.warnedKeys.has(key))
197
222
  return;
@@ -13,7 +13,7 @@ import { streamAllCSVsToDisk } from './csv-generator.js';
13
13
  import { getNodeLabel as deriveNodeLabel } from './rel-pair-routing.js';
14
14
  import { extensionManager } from './extension-loader.js';
15
15
  import { classifyDeleteAllError, closeLbugConnection, isDbBusyError, isOpenRetryExhausted, isWalCorruptionError, openLbugConnection, toNativeSafePath, resolveNativeSafeStorageDir, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, } from './lbug-config.js';
16
- import { finalizeLbugSidecarsAfterClose, inspectLbugSidecars, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, shadowSidecarRecoveryMessage, } from './sidecar-recovery.js';
16
+ import { finalizeLbugSidecarsAfterClose, guardWalQuarantine, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, shadowSidecarRecoveryMessage, } from './sidecar-recovery.js';
17
17
  import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
18
18
  import { logger } from '../logger.js';
19
19
  /**
@@ -409,23 +409,14 @@ const queryAndDrain = async (targetConn, cypher) => {
409
409
  };
410
410
  const READ_ONLY_SHADOW_REPLAY_PROBE = 'MATCH (n) RETURN n LIMIT 1';
411
411
  /**
412
- * Reject the quarantine path when the orphan WAL is too large to safely
413
- * discard (>TINY_ORPHAN_WAL_BYTES). Mirrors the preflight policy at
414
- * sidecar-recovery.ts:153-160 ("warn, do not quarantine"). Symmetric across
415
- * read-only and writable recovery paths (PR #1747 review D2).
416
- *
417
- * Throws shadowSidecarRecoveryMessage immediately when the WAL is large,
418
- * preserving the uncheckpointed pages for explicit operator recovery.
419
- * Returns silently when the WAL is absent, tiny, or in any other state
420
- * where the existing recovery path is safe to proceed.
412
+ * Serve-side entry to the shared WAL-quarantine safety gate. Refuses (throws)
413
+ * when the `.shadow` is present on disk or the orphan WAL is too large to
414
+ * safely discard; returns silently otherwise. The policy itself lives in
415
+ * `guardWalQuarantine` (sidecar-recovery.ts) so serve and the MCP pool share
416
+ * one source of truth (PR #1747 review D2; issue #2382 review, Finding B).
421
417
  */
422
418
  const refuseLargeWalQuarantine = async (dbPath, mode, triggeringErr) => {
423
- const state = await inspectLbugSidecars(dbPath);
424
- if (state.kind === 'orphan-wal') {
425
- logger.warn(`GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` +
426
- 'manual recovery required — run `gitnexus analyze --force <repo-path> --index-only`.');
427
- throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
428
- }
419
+ await guardWalQuarantine(dbPath, mode, triggeringErr, logger);
429
420
  };
430
421
  const reopenReadOnlyAfterMissingShadow = async (dbPath, err) => {
431
422
  await refuseLargeWalQuarantine(dbPath, 'read-only', err);
@@ -4,3 +4,25 @@ export interface NativeCheckResult {
4
4
  message?: string;
5
5
  }
6
6
  export declare function checkLbugNative(overridePkgDir?: string): NativeCheckResult;
7
+ export interface FtsProbeResult {
8
+ loaded: boolean;
9
+ /** Collapsed LadybugDB error when `loaded` is false. */
10
+ reason?: string;
11
+ }
12
+ /**
13
+ * Live-probe `LOAD EXTENSION fts` on a throwaway in-memory database.
14
+ *
15
+ * `doctor` used to print the static platform capability, which contradicted
16
+ * analyze whenever the extension file was missing or unloadable (#2374).
17
+ * LOAD never touches the network, so the probe is safe offline, and it
18
+ * surfaces LadybugDB's real error — which distinguishes a missing extension
19
+ * file from a present-but-broken one (wrong platform, truncated download).
20
+ * Dynamic import so doctor still runs when the native module itself is broken.
21
+ *
22
+ * Bounded by `timeoutMs`: an unresponsive extension file (e.g. on a hung
23
+ * network home dir) must never freeze `doctor` — the tool the degradation
24
+ * warnings send users to. `Promise.race` lets doctor report and move on; it
25
+ * cannot cancel an in-flight native call, so a future thread-blocking case
26
+ * would need an out-of-process probe.
27
+ */
28
+ export declare function probeFtsExtensionLoad(timeoutMs?: number): Promise<FtsProbeResult>;
@@ -81,3 +81,69 @@ export function checkLbugNative(overridePkgDir) {
81
81
  }
82
82
  return { ok: true, binaryPath };
83
83
  }
84
+ const DEFAULT_FTS_PROBE_TIMEOUT_MS = 10_000;
85
+ /** Close each result, swallowing close-time errors so a successful LOAD is not
86
+ * misreported as a failure (native-check keeps no static lbug dependency, so it
87
+ * cannot reuse the adapter's closeQueryResults — that would eagerly load the
88
+ * module and defeat the dynamic import below). */
89
+ const closeProbeResults = (result) => {
90
+ for (const r of Array.isArray(result) ? result : [result]) {
91
+ try {
92
+ r?.close?.();
93
+ }
94
+ catch {
95
+ // ignore — a close failure must not flip a successful LOAD to failed
96
+ }
97
+ }
98
+ };
99
+ /**
100
+ * Live-probe `LOAD EXTENSION fts` on a throwaway in-memory database.
101
+ *
102
+ * `doctor` used to print the static platform capability, which contradicted
103
+ * analyze whenever the extension file was missing or unloadable (#2374).
104
+ * LOAD never touches the network, so the probe is safe offline, and it
105
+ * surfaces LadybugDB's real error — which distinguishes a missing extension
106
+ * file from a present-but-broken one (wrong platform, truncated download).
107
+ * Dynamic import so doctor still runs when the native module itself is broken.
108
+ *
109
+ * Bounded by `timeoutMs`: an unresponsive extension file (e.g. on a hung
110
+ * network home dir) must never freeze `doctor` — the tool the degradation
111
+ * warnings send users to. `Promise.race` lets doctor report and move on; it
112
+ * cannot cancel an in-flight native call, so a future thread-blocking case
113
+ * would need an out-of-process probe.
114
+ */
115
+ export async function probeFtsExtensionLoad(timeoutMs = DEFAULT_FTS_PROBE_TIMEOUT_MS) {
116
+ let timer;
117
+ const timeout = new Promise((resolve) => {
118
+ timer = setTimeout(() => resolve({
119
+ loaded: false,
120
+ reason: 'probe timed out — extension file or filesystem unresponsive',
121
+ }), timeoutMs);
122
+ });
123
+ const probe = (async () => {
124
+ try {
125
+ const { default: lbug } = await import('@ladybugdb/core');
126
+ const db = new lbug.Database(':memory:');
127
+ // Nested finallys so `db` is closed even if the Connection ctor throws.
128
+ try {
129
+ const conn = new lbug.Connection(db);
130
+ try {
131
+ const result = await conn.query('LOAD EXTENSION fts');
132
+ closeProbeResults(result);
133
+ return { loaded: true };
134
+ }
135
+ finally {
136
+ await conn.close().catch(() => { });
137
+ }
138
+ }
139
+ finally {
140
+ await db.close().catch(() => { });
141
+ }
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ return { loaded: false, reason: message.replace(/\s+/g, ' ').trim() };
146
+ }
147
+ })();
148
+ return await Promise.race([probe, timeout]).finally(() => clearTimeout(timer));
149
+ }
@@ -19,7 +19,7 @@ import lbug from '@ladybugdb/core';
19
19
  import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
20
20
  import { closeQueryResults } from './query-result-utils.js';
21
21
  import { createLbugDatabase, isWalCorruptionError, toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
22
- import { isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
22
+ import { guardWalQuarantine, isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
23
23
  const pool = new Map();
24
24
  const poolCloseListeners = new Set();
25
25
  /**
@@ -349,6 +349,12 @@ const poolSidecarLogger = {
349
349
  * See plan: docs/plans/2026-05-21-001-fix-pr-1747-quarantine-enoent-and-large-wal-plan.md (U2)
350
350
  */
351
351
  async function tryQuarantineForMissingShadow(dbPath, opts) {
352
+ // Refuse (throw) before renaming a live WAL when the shadow is present on
353
+ // disk or the orphan WAL is too large — parity with the serve path's
354
+ // refuseLargeWalQuarantine (issue #2382 review, Finding B). Kept OUTSIDE the
355
+ // try so the actionable recovery message propagates to the MCP caller rather
356
+ // than being re-wrapped as a rename failure.
357
+ await guardWalQuarantine(dbPath, opts.reason, opts.err, poolSidecarLogger);
352
358
  try {
353
359
  const quarantinePath = await quarantineWalForMissingShadow(dbPath, {
354
360
  logger: poolSidecarLogger,
@@ -396,6 +402,7 @@ async function replayShadowPagesWithWritableOpen(dbPath) {
396
402
  if (isMissingShadowSidecarError(err)) {
397
403
  await tryQuarantineForMissingShadow(dbPath, {
398
404
  reason: 'pool writable replay recovery',
405
+ err,
399
406
  });
400
407
  return;
401
408
  }
@@ -429,6 +436,7 @@ async function openReadOnlyDatabase(dbPath) {
429
436
  db = undefined;
430
437
  await tryQuarantineForMissingShadow(dbPath, {
431
438
  reason: 'pool read-only recovery',
439
+ err,
432
440
  });
433
441
  await preflightLbugSidecars(dbPath, {
434
442
  mode: 'read-only',
@@ -559,6 +567,7 @@ async function doInitLbug(repoId, dbPath) {
559
567
  }
560
568
  }
561
569
  if (lastError.message.startsWith('LadybugDB checkpoint sidecar is missing') ||
570
+ lastError.message.startsWith('LadybugDB checkpoint sidecar is present but unreachable') ||
562
571
  lastError.message.startsWith('GitNexus could not move the LadybugDB WAL sidecar') ||
563
572
  isMissingShadowSidecarError(lastError)) {
564
573
  throw lastError;
@@ -32,6 +32,16 @@ export declare const statIfExists: (filePath: string) => Promise<{
32
32
  export declare const isMissingShadowSidecarError: (err: unknown) => boolean;
33
33
  export declare const isReadOnlyShadowReplayError: (err: unknown) => boolean;
34
34
  export declare const shadowSidecarRecoveryMessage: (dbPath: string, err: unknown) => string;
35
+ /**
36
+ * Actionable message for the case where LadybugDB reports a "missing shadow"
37
+ * but `inspectLbugSidecars` finds the `.shadow` PRESENT on disk — the open
38
+ * failed on path reachability or a lock, not a genuinely-missing sidecar (issue
39
+ * #2382 review, S2). Unlike `shadowSidecarRecoveryMessage` it does NOT tell the
40
+ * operator to rebuild the index (the remedy is fixing the lock/path). Keeps the
41
+ * `Original error:` tail so downstream `isMissingShadowSidecarError` recognition
42
+ * still matches the wrapped error.
43
+ */
44
+ export declare const presentShadowUnreachableMessage: (dbPath: string, err: unknown) => string;
35
45
  export declare const isPermissionRenameError: (err: unknown) => boolean;
36
46
  /**
37
47
  * Classify a failure surfaced by quarantine rename into an actionable user-facing
@@ -50,6 +60,28 @@ export declare const isPermissionRenameError: (err: unknown) => boolean;
50
60
  */
51
61
  export declare const renameFailureMessage: (dbPath: string, err: unknown) => string;
52
62
  export declare function inspectLbugSidecars(dbPath: string): Promise<LbugSidecarState>;
63
+ /**
64
+ * Reject the WAL-quarantine path when discarding the WAL would be unsafe or
65
+ * wrong. Shared by every reactive missing-shadow recovery consumer — serve (via
66
+ * lbug-adapter's `refuseLargeWalQuarantine`) and the MCP/wiki/augmentation pool
67
+ * (via pool-adapter's `tryQuarantineForMissingShadow`) — so the quarantine
68
+ * safety policy has a single source of truth (issue #2382 review, Finding B).
69
+ *
70
+ * 1. `wal-with-shadow` — the `.shadow` sidecar is PRESENT on disk. A
71
+ * "missing shadow" error alongside a present shadow means the open failed
72
+ * on path reachability or a lock (the #1811 non-ASCII path-garble on
73
+ * Windows), not a genuinely-missing shadow; quarantining would move a live
74
+ * WAL sitting next to its shadow — data loss.
75
+ * 2. `orphan-wal` — the orphan WAL is too large to safely discard
76
+ * (>TINY_ORPHAN_WAL_BYTES); preserve the uncheckpointed pages for explicit
77
+ * operator recovery.
78
+ *
79
+ * Throws `shadowSidecarRecoveryMessage` in either case. Returns silently only
80
+ * when the shadow is absent AND the WAL is absent or tiny — the states where
81
+ * the existing recovery path is safe to proceed. `mode` is a label used only in
82
+ * the warning text (e.g. 'read-only', 'writable', 'pool read-only recovery').
83
+ */
84
+ export declare const guardWalQuarantine: (dbPath: string, mode: string, triggeringErr: unknown, logger: SidecarRecoveryLogger) => Promise<void>;
53
85
  export declare function quarantineWalForMissingShadow(dbPath: string, options: {
54
86
  logger: SidecarRecoveryLogger;
55
87
  level?: 'debug' | 'info' | 'warn';
@@ -87,16 +87,47 @@ const warnOnce = (logger, key, message) => {
87
87
  logger.warn(`${message} (${ordinal(next)} occurrence of this condition)`);
88
88
  };
89
89
  // LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.18.0 native error text.
90
- // When bumping LadybugDB, re-validate this regex against the new error format
90
+ // When bumping LadybugDB, re-validate this against the new error format
91
91
  // — `git grep "LADYBUGDB-CONTRACT"` enumerates every version-coupled spot.
92
- // Verified by upstream source/changelog diff only — forcing a genuine
93
- // `.shadow`-missing state via a live crash to trigger this error is not
94
- // reliably reproducible (a SIGKILL at the exact moment `.shadow` exists on
95
- // disk still recovers via `.wal.checkpoint` alone), so this matcher does not
96
- // have live-trigger test coverage.
92
+ //
93
+ // Two native formats reach here for a genuinely-missing shadow sidecar:
94
+ // POSIX: `Cannot open file <path>.shadow: No such file or directory`
95
+ // Windows: `Cannot open file. path: <path>.shadow - Error 2: <system text>`
96
+ // Windows OS text is localized on non-English installs (issue #2382 was filed
97
+ // from a non-English Windows), so we key on the locale-invariant Win32 code
98
+ // (2 = ERROR_FILE_NOT_FOUND), NOT the English phrase. The code is matched only
99
+ // in the reason AFTER the LAST `.shadow` token (the real failing sidecar; the
100
+ // reason text never contains `.shadow`), so a repo *path* containing e.g.
101
+ // `\error 2\` — even under a `.shadow`-suffixed parent directory — cannot trip
102
+ // it. Deliberate exclusions:
103
+ // - `Error 3` (ERROR_PATH_NOT_FOUND): the #1811 non-ASCII path-garble
104
+ // artifact (see lbug-config.ts) where the shadow is PRESENT on disk;
105
+ // treating it as missing would quarantine a live WAL — data loss.
106
+ // - `Error 5` / `Error 32` / POSIX `Permission denied`: present-but-locked;
107
+ // handled as permission/lock classes, must not quarantine.
108
+ // The quarantine path adds a present-shadow disk check as a belt (see
109
+ // refuseLargeWalQuarantine in lbug-adapter.ts).
110
+ //
111
+ // The Windows branch is derived from the issue #2382 reported string, not a
112
+ // self-produced live crash; unit/consumer tests inject that same string, so
113
+ // GREEN TESTS DO NOT PROVE the byte-exact 0.18.0 Windows format — confirm
114
+ // against a real Windows run before closing #2382.
97
115
  export const isMissingShadowSidecarError = (err) => {
98
116
  const msg = err instanceof Error ? err.message : String(err);
99
- return /Cannot open file .*\.shadow: No such file or directory/i.test(msg);
117
+ if (!/cannot open file/i.test(msg))
118
+ return false;
119
+ // Anchor on the LAST `.shadow`, not the first: LadybugDB names the failing
120
+ // sidecar as the final `.shadow` token and its reason text (POSIX
121
+ // `: No such file or directory` / Windows ` - Error N: ...`) never contains
122
+ // `.shadow`. Slicing from the last match isolates the true reason, so an
123
+ // earlier `.shadow`-suffixed path segment (e.g. a `branch=subdir` directory
124
+ // like `snap.shadow\`) can't shift the anchor and let a path-embedded
125
+ // `error 2` be read as the Win32 code (issue #2382 review, Finding A).
126
+ const lastShadow = [...msg.matchAll(/\.shadow\b/gi)].at(-1);
127
+ if (lastShadow?.index === undefined)
128
+ return false;
129
+ const reason = msg.slice(lastShadow.index);
130
+ return /no such file or directory/i.test(reason) || /\berror\s+2\b/i.test(reason);
100
131
  };
101
132
  // LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.18.0 native error text.
102
133
  // When bumping LadybugDB, re-validate this regex against the new error format
@@ -114,6 +145,24 @@ export const shadowSidecarRecoveryMessage = (dbPath, err) => {
114
145
  'Rebuild the index with `gitnexus analyze --force <repo-path> --index-only` and restart `gitnexus serve`.' +
115
146
  `\n Original error: ${msg.slice(0, 200)}`);
116
147
  };
148
+ /**
149
+ * Actionable message for the case where LadybugDB reports a "missing shadow"
150
+ * but `inspectLbugSidecars` finds the `.shadow` PRESENT on disk — the open
151
+ * failed on path reachability or a lock, not a genuinely-missing sidecar (issue
152
+ * #2382 review, S2). Unlike `shadowSidecarRecoveryMessage` it does NOT tell the
153
+ * operator to rebuild the index (the remedy is fixing the lock/path). Keeps the
154
+ * `Original error:` tail so downstream `isMissingShadowSidecarError` recognition
155
+ * still matches the wrapped error.
156
+ */
157
+ export const presentShadowUnreachableMessage = (dbPath, err) => {
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ return (`LadybugDB checkpoint sidecar is present but unreachable for ${dbPath}. ` +
160
+ 'The .shadow file is on disk, so the open likely failed on path reachability or a file lock ' +
161
+ '(antivirus, another process holding a handle, or a non-ASCII path) rather than a missing sidecar. ' +
162
+ 'Check filesystem access and locks; only run `gitnexus analyze --force <repo-path> --index-only` ' +
163
+ 'if the index is genuinely broken.' +
164
+ `\n Original error: ${msg.slice(0, 200)}`);
165
+ };
117
166
  const PERMISSION_RENAME_CODES = new Set(['EACCES', 'EPERM', 'EBUSY']);
118
167
  export const isPermissionRenameError = (err) => {
119
168
  const code = err?.code;
@@ -165,6 +214,41 @@ export async function inspectLbugSidecars(dbPath) {
165
214
  }
166
215
  return { kind: 'clean', dbPath };
167
216
  }
217
+ /**
218
+ * Reject the WAL-quarantine path when discarding the WAL would be unsafe or
219
+ * wrong. Shared by every reactive missing-shadow recovery consumer — serve (via
220
+ * lbug-adapter's `refuseLargeWalQuarantine`) and the MCP/wiki/augmentation pool
221
+ * (via pool-adapter's `tryQuarantineForMissingShadow`) — so the quarantine
222
+ * safety policy has a single source of truth (issue #2382 review, Finding B).
223
+ *
224
+ * 1. `wal-with-shadow` — the `.shadow` sidecar is PRESENT on disk. A
225
+ * "missing shadow" error alongside a present shadow means the open failed
226
+ * on path reachability or a lock (the #1811 non-ASCII path-garble on
227
+ * Windows), not a genuinely-missing shadow; quarantining would move a live
228
+ * WAL sitting next to its shadow — data loss.
229
+ * 2. `orphan-wal` — the orphan WAL is too large to safely discard
230
+ * (>TINY_ORPHAN_WAL_BYTES); preserve the uncheckpointed pages for explicit
231
+ * operator recovery.
232
+ *
233
+ * Throws `shadowSidecarRecoveryMessage` in either case. Returns silently only
234
+ * when the shadow is absent AND the WAL is absent or tiny — the states where
235
+ * the existing recovery path is safe to proceed. `mode` is a label used only in
236
+ * the warning text (e.g. 'read-only', 'writable', 'pool read-only recovery').
237
+ */
238
+ export const guardWalQuarantine = async (dbPath, mode, triggeringErr, logger) => {
239
+ const state = await inspectLbugSidecars(dbPath);
240
+ if (state.kind === 'wal-with-shadow') {
241
+ warnOnce(logger, `${dbPath}:present-shadow-refuse:${mode}`, `GitNexus: refusing to quarantine WAL at ${dbPath}.wal during ${mode} recovery — ` +
242
+ 'the .shadow sidecar is present on disk, so the open likely failed on path reachability or a lock ' +
243
+ 'rather than a missing shadow. Run `gitnexus analyze --force <repo-path> --index-only` if the index is genuinely broken.');
244
+ throw new Error(presentShadowUnreachableMessage(dbPath, triggeringErr));
245
+ }
246
+ if (state.kind === 'orphan-wal') {
247
+ warnOnce(logger, `${dbPath}:large-wal-refuse:${mode}`, `GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` +
248
+ 'manual recovery required — run `gitnexus analyze --force <repo-path> --index-only`.');
249
+ throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
250
+ }
251
+ };
168
252
  export async function quarantineWalForMissingShadow(dbPath, options) {
169
253
  const walPath = `${dbPath}.wal`;
170
254
  const quarantinePath = `${walPath}.missing-shadow.${Date.now()}-${Math.random()
@@ -1,4 +1,6 @@
1
+ import fs from 'fs';
1
2
  import os from 'os';
3
+ import path from 'path';
2
4
  import { createRequire } from 'module';
3
5
  const require = createRequire(import.meta.url);
4
6
  const packageVersion = (name) => {
@@ -6,7 +8,31 @@ const packageVersion = (name) => {
6
8
  return require(`${name}/package.json`).version;
7
9
  }
8
10
  catch {
9
- return undefined;
11
+ // Packages whose `exports` map omits ./package.json (e.g. @ladybugdb/core)
12
+ // reject the direct require with ERR_PACKAGE_PATH_NOT_EXPORTED, which made
13
+ // doctor print "LadybugDB: unknown" on every platform (#2374). Resolve the
14
+ // entry point instead and walk up to the package's own package.json.
15
+ try {
16
+ let dir = path.dirname(require.resolve(name));
17
+ // Entry points sit at the package root or a shallow dist/ dir; a few
18
+ // hops always reach the package's own package.json.
19
+ for (let hops = 0; hops < 5; hops++) {
20
+ const candidate = path.join(dir, 'package.json');
21
+ if (fs.existsSync(candidate)) {
22
+ const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
23
+ if (pkg.name === name)
24
+ return pkg.version;
25
+ }
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir)
28
+ break;
29
+ dir = parent;
30
+ }
31
+ return undefined;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
10
36
  }
11
37
  };
12
38
  const gitnexusVersion = () => {
@@ -16,7 +16,8 @@ import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
16
16
  import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, closeLbugBeforeExit, loadCachedEmbeddings, deleteNodesForFile, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, queryImporters, loadFTSExtension, } from './lbug/lbug-adapter.js';
17
17
  import { createSearchFTSIndexes, initialiseSearchFTSStemmer, verifySearchFTSIndexes, } from './search/fts-indexes.js';
18
18
  import { cjkSegmentationModeMismatch, getSearchFTSCjkSegmentation, initialiseSearchFTSCjkSegmentation, } from './search/cjk-segmentation.js';
19
- import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
19
+ import { getExtensionCapabilities, resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
20
+ import { diagnoseExtensionLoad } from './lbug/extension-load-error.js';
20
21
  import { startWalCheckpointDriver, } from './lbug/wal-checkpoint-driver.js';
21
22
  import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, adoptFlatBranchLabel, isReadOnlyFilesystemError, isRepoRegistered, cleanupOldKuzuFiles, reconcileMetadataFiles, isMissingFilesystemError, INDEX_METADATA_FILE, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
22
23
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
@@ -41,7 +42,11 @@ import { STALE_HASH_SENTINEL } from './lbug/schema.js';
41
42
  * a full analyze. Kept as a named constant so the env-var/command guidance
42
43
  * stays in one place (mirrors the VECTOR message in embedding-pipeline.ts).
43
44
  */
44
- const FTS_UNAVAILABLE_MESSAGE = 'FTS extension unavailable; skipping search-index creation. ' +
45
+ // Class-neutral lead, reused for the missing-dependency degrade path (#2383 F2):
46
+ // its remedy already explains that reinstalling will NOT help, so appending the
47
+ // generic "install with network access" tail below would contradict it.
48
+ const FTS_UNAVAILABLE_LEAD = 'FTS extension unavailable; skipping search-index creation.';
49
+ const FTS_UNAVAILABLE_MESSAGE = `${FTS_UNAVAILABLE_LEAD} ` +
45
50
  'Full-text/BM25 search will be disabled until the LadybugDB FTS extension is ' +
46
51
  'installed once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) or ' +
47
52
  'pre-installed for offline use. Run `gitnexus doctor` for details.';
@@ -365,9 +370,23 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
365
370
  policy: resolveAnalyzeInstallPolicy(),
366
371
  });
367
372
  if (!repairFtsAvailable) {
368
- throw new Error('Cannot repair FTS indexes: the LadybugDB FTS extension is unavailable ' +
369
- '(not pre-installed and could not be installed on this machine). ' +
370
- 'Run `gitnexus doctor` to install it, then retry `--repair-fts`.');
373
+ // Surface the load-side reason (#2374): "not pre-installed" was wrong
374
+ // and doctor never installed anything, so the old message trapped
375
+ // users in a query repair-fts → doctor loop with no way out.
376
+ const rawFtsReason = getExtensionCapabilities().find((c) => c.name === 'fts')?.reason;
377
+ const ftsReason = rawFtsReason?.replace(/\.$/, '');
378
+ // A missing runtime dependency (Windows error 126, #2374) is not healed
379
+ // by re-installing — the file is already present. Route that class to the
380
+ // classified remedy (install VC++ redist / OpenSSL) instead of the old
381
+ // "retry the network install" text that trapped the user in a loop.
382
+ const { kind, remedy } = diagnoseExtensionLoad(rawFtsReason);
383
+ const remedyTail = kind === 'missing_dependency'
384
+ ? ` ${remedy}`
385
+ : '. Retry with network access and GITNEXUS_LBUG_EXTENSION_INSTALL=auto to install it, ' +
386
+ 'or pre-install the extension file; run `gitnexus doctor` for live FTS status.';
387
+ throw new Error('Cannot repair FTS indexes: the LadybugDB FTS extension failed to load' +
388
+ (ftsReason ? ` — ${ftsReason}` : '') +
389
+ remedyTail);
371
390
  }
372
391
  progress('fts', 85, 'Repairing search indexes...');
373
392
  await createSearchFTSIndexes({
@@ -956,7 +975,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
956
975
  progress('fts', 90, 'Search indexes ready');
957
976
  }
958
977
  else {
959
- log(FTS_UNAVAILABLE_MESSAGE);
978
+ // For a missing runtime dependency (#2374) the file is present, so the
979
+ // generic "install it with network access" tail in FTS_UNAVAILABLE_MESSAGE
980
+ // contradicts the remedy's own "reinstalling will NOT help" (#2383 F2). Lead
981
+ // with the class-neutral sentence and append only the classified remedy.
982
+ const ftsReason = getExtensionCapabilities().find((c) => c.name === 'fts')?.reason;
983
+ const { kind, remedy } = diagnoseExtensionLoad(ftsReason);
984
+ log(kind === 'missing_dependency'
985
+ ? `${FTS_UNAVAILABLE_LEAD} ${remedy}`
986
+ : FTS_UNAVAILABLE_MESSAGE);
960
987
  progress('fts', 90, 'Search indexes skipped (FTS unavailable)');
961
988
  }
962
989
  // ── Phase 3.5: Re-insert cached embeddings ────────────────────────