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
@@ -33,7 +33,7 @@ import { execFile } from 'child_process';
33
33
  import { promisify } from 'util';
34
34
  import { fileURLToPath } from 'url';
35
35
  import { parseTree, modify, applyEdits, findNodeAtLocation, parse as parseJsonc, } from 'jsonc-parser';
36
- import { getEditorTargets, detectIndentation } from './editor-targets.js';
36
+ import { getEditorTargets, detectIndentation, isEnoent } from './editor-targets.js';
37
37
  const __filename = fileURLToPath(import.meta.url);
38
38
  const __dirname = path.dirname(__filename);
39
39
  const execFileAsync = promisify(execFile);
@@ -50,7 +50,13 @@ async function removeJsoncKey(filePath, keyPath, dryRun) {
50
50
  try {
51
51
  raw = await fs.readFile(filePath, 'utf-8');
52
52
  }
53
- catch {
53
+ catch (err) {
54
+ // ENOENT = genuinely not configured. Any other read failure (EACCES,
55
+ // locks) must not report 'missing' — the file may hold a real gitnexus
56
+ // entry the dry-run would then deny exists. Rethrow into the caller's
57
+ // per-file catch.
58
+ if (!isEnoent(err))
59
+ throw err;
54
60
  return 'missing';
55
61
  }
56
62
  if (raw.trim().length === 0)
@@ -88,7 +94,12 @@ async function removeHookEntries(filePath, eventNames, commandNeedle, dryRun) {
88
94
  try {
89
95
  raw = await fs.readFile(filePath, 'utf-8');
90
96
  }
91
- catch {
97
+ catch (err) {
98
+ // Masking a non-ENOENT read failure as 'missing' would also let the
99
+ // caller delete the hook scriptDir while the unreadable settings file
100
+ // still references it. Rethrow into the hook uninstaller's catch.
101
+ if (!isEnoent(err))
102
+ throw err;
92
103
  return { status: 'missing', count: 0 };
93
104
  }
94
105
  if (raw.trim().length === 0)
@@ -323,8 +334,15 @@ async function uninstallCodex(result, dryRun, configPath, tomlSection) {
323
334
  try {
324
335
  raw = await fs.readFile(configPath, 'utf-8');
325
336
  }
326
- catch {
327
- result.skipped.push('Codex MCP (not configured)');
337
+ catch (err) {
338
+ // Catch locally: this call site has no surrounding try, so a rethrow
339
+ // would abort the hooks/skills cleanup that runs after Codex.
340
+ if (isEnoent(err)) {
341
+ result.skipped.push('Codex MCP (not configured)');
342
+ }
343
+ else {
344
+ result.errors.push(`Codex: ${err instanceof Error ? err.message : String(err)}`);
345
+ }
328
346
  return;
329
347
  }
330
348
  if (!raw.includes(`[${tomlSection}]`)) {
@@ -369,20 +387,50 @@ export const uninstallCommand = async (options) => {
369
387
  console.log(' Dry run — nothing will be changed. Re-run with --force to apply.');
370
388
  console.log('');
371
389
  }
372
- const result = { removed: [], skipped: [], errors: [] };
390
+ const result = { removed: [], skipped: [], errors: [], corruptLegacy: false };
373
391
  // ─── MCP server entries (JSONC editors) ──────────────────────────
392
+ // Sweep legacyFiles too: setup writes into the first existing file of the
393
+ // editor's priority chain, so the gitnexus entry may live in a deprecated
394
+ // location (e.g. CodeBuddy's ~/.codebuddy/mcp.json).
374
395
  for (const target of targets.mcpJsonc) {
375
- try {
376
- const status = await removeJsoncKey(target.file, target.keyPath, dryRun);
377
- if (status === 'removed')
378
- result.removed.push(`${target.label} MCP server — ${target.keyPath.join('.')} in ${target.file}`);
379
- else if (status === 'corrupt')
380
- result.errors.push(`${target.label}: ${path.basename(target.file)} is corrupt left untouched`);
381
- else
382
- result.skipped.push(`${target.label} MCP (not configured)`);
396
+ let removedAny = false;
397
+ let erroredAny = false;
398
+ let corruptLegacyAny = false;
399
+ for (const file of [target.file, ...(target.legacyFiles ?? [])]) {
400
+ try {
401
+ const status = await removeJsoncKey(file, target.keyPath, dryRun);
402
+ if (status === 'removed') {
403
+ removedAny = true;
404
+ result.removed.push(`${target.label} MCP server — ${target.keyPath.join('.')} in ${file}`);
405
+ }
406
+ else if (status === 'corrupt') {
407
+ if (file === target.file) {
408
+ // The primary path is where gitnexus itself writes — corruption
409
+ // there is an error worth failing the command over.
410
+ erroredAny = true;
411
+ result.errors.push(`${target.label}: ${path.basename(file)} is corrupt — left untouched`);
412
+ }
413
+ else {
414
+ // Legacy chain files are vendor locations gitnexus may never have
415
+ // touched (e.g. a corrupt ~/.codebuddy.json from an old install).
416
+ // Report informationally without failing uninstall. Deliberate
417
+ // asymmetry: an UNREADABLE (non-ENOENT) legacy file still errors —
418
+ // that's an environmental problem worth surfacing, while corrupt-
419
+ // but-readable proves there is no removable gitnexus entry.
420
+ corruptLegacyAny = true;
421
+ result.corruptLegacy = true;
422
+ result.skipped.push(`${target.label} MCP (legacy ${path.basename(file)} is corrupt — left untouched)`);
423
+ }
424
+ }
425
+ }
426
+ catch (err) {
427
+ erroredAny = true;
428
+ result.errors.push(`${target.label}: ${err instanceof Error ? err.message : String(err)}`);
429
+ }
383
430
  }
384
- catch (err) {
385
- result.errors.push(`${target.label}: ${err.message}`);
431
+ // A corrupt legacy file makes "not configured" unknowable — suppress it.
432
+ if (!removedAny && !erroredAny && !corruptLegacyAny) {
433
+ result.skipped.push(`${target.label} MCP (not configured)`);
386
434
  }
387
435
  }
388
436
  await uninstallCodex(result, dryRun, targets.codex.configFile, targets.codex.tomlSection);
@@ -425,6 +473,12 @@ export const uninstallCommand = async (options) => {
425
473
  for (const name of result.removed)
426
474
  console.log(` - ${name}`);
427
475
  }
476
+ else if (result.errors.length > 0 || result.corruptLegacy) {
477
+ // Errors (corrupt primary files, unreadable configs) or corrupt legacy
478
+ // configs make "not configured" unknowable — claiming it right above an
479
+ // Errors block would be a contradiction users learn to distrust.
480
+ console.log(' Nothing removed.');
481
+ }
428
482
  else {
429
483
  console.log(' Nothing to remove — GitNexus is not configured in any detected editor.');
430
484
  }
@@ -16,8 +16,9 @@ import { DEFAULT_EMBEDDING_CONFIG } from './types.js';
16
16
  import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
17
17
  import { resolveEmbeddingConfig } from './config.js';
18
18
  import { applyHfEnvOverrides, isHfDownloadFailure, withHfDownloadRetry } from './hf-env.js';
19
- import { getLocalEmbeddingRuntimeBlocker } from './runtime-support.js';
19
+ import { getLocalEmbeddingRuntimeBlocker, getMissingLocalEmbeddingStackMessage, } from './runtime-support.js';
20
20
  import { ensureOnnxRuntimeCommonResolvable } from './onnxruntime-common-resolver.js';
21
+ import { ensureEmbeddingStackResolvable } from './runtime-install.js';
21
22
  import { ensureOnnxRuntimeNodeMatchesSystem, isEffectiveCudaAvailable, } from './onnxruntime-node-resolver.js';
22
23
  import { logger } from '../logger.js';
23
24
  // Module-level state for singleton pattern
@@ -73,6 +74,11 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
73
74
  try {
74
75
  // Lazy-load transformers.js only after the runtime guard has passed, so
75
76
  // unsupported platforms never reach the native ONNX import (#1515).
77
+ // Registered FIRST so it sits last in the hook chain (registerHooks runs
78
+ // the most recent hook first): when the optional stack was pruned at
79
+ // install time (#2370), its bare specifiers fall back to the on-demand
80
+ // runtime prefix.
81
+ ensureEmbeddingStackResolvable();
76
82
  // Under pnpm-strict / `pnpm dlx`, transformers' phantom `onnxruntime-common`
77
83
  // import is unresolvable; register the fallback resolver first (#307).
78
84
  ensureOnnxRuntimeCommonResolvable();
@@ -82,7 +88,15 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
82
88
  // to the CUDA-13 build before transformers imports them. No-op on matching
83
89
  // layouts, non-CUDA, Windows/DirectML, and macOS.
84
90
  ensureOnnxRuntimeNodeMatchesSystem();
85
- const { pipeline, env } = await import('@huggingface/transformers');
91
+ // The stack is an optionalDependency: npm prunes it when onnxruntime-node's
92
+ // postinstall can't reach api.nuget.org (#2370). Rethrow with actionable
93
+ // reinstall guidance instead of a raw ERR_MODULE_NOT_FOUND.
94
+ const { pipeline, env } = await import('@huggingface/transformers').catch((err) => {
95
+ const missing = getMissingLocalEmbeddingStackMessage(err);
96
+ if (missing)
97
+ throw new Error(missing);
98
+ throw err;
99
+ });
86
100
  // Configure transformers.js environment
87
101
  env.allowLocalModels = false;
88
102
  // Bridge user-controlled env vars to transformers.js: HF_HOME →
@@ -11,7 +11,13 @@
11
11
  * via `AbortSignal.timeout` on the underlying fetch.
12
12
  */
13
13
  /**
14
- * Check whether HTTP embedding mode is active (env vars are set).
14
+ * Whether HTTP embedding mode is active i.e. both `GITNEXUS_EMBEDDING_URL` and
15
+ * `GITNEXUS_EMBEDDING_MODEL` are set. A pure presence probe: it deliberately does
16
+ * NOT call {@link readConfig}, so it never throws on a malformed
17
+ * `GITNEXUS_EMBEDDING_DIMS`. This lets its ~13 call sites (analyze, doctor,
18
+ * run-analyze, embedder, mcp) probe the mode without a defensive try/catch; the
19
+ * DIMS value is validated where it is actually used (`readConfig` in
20
+ * `httpEmbed`/`httpEmbedQuery`), surfacing a recognizable config error. See #2385.
15
21
  */
16
22
  export declare const isHttpMode: () => boolean;
17
23
  /**
@@ -26,6 +32,27 @@ export declare const getHttpDimensions: () => number | undefined;
26
32
  * custom-endpoint confirmation can mask the same way.
27
33
  */
28
34
  export declare const safeUrl: (url: string) => string;
35
+ /**
36
+ * Error thrown by this module's HTTP embedding path (`httpEmbedBatch` /
37
+ * `httpEmbed` / `httpEmbedQuery`) for any endpoint failure — a
38
+ * connection/timeout/DNS error, an open circuit, a non-OK status, an
39
+ * unparseable or wrong-shape response body, an empty response, or a dimension
40
+ * mismatch.
41
+ *
42
+ * Carrying a distinct type (rather than a plain `Error`) lets the CLI tell a
43
+ * *custom endpoint* failure apart from a HuggingFace *model download* failure
44
+ * without matching message text: the two share the same underlying network
45
+ * substrings (`fetch failed`, `ECONNREFUSED`, …), which is exactly why
46
+ * `isNetworkFetchError` in `hf-env.ts` cannot tell them apart. Keying on the
47
+ * type instead of the message is also locale-proof and survives message
48
+ * rewording. The human-readable `.message` (built with `safeUrl` and the
49
+ * underlying reason) is what the CLI surfaces to the user. See #2385.
50
+ */
51
+ export declare class HttpEmbeddingError extends Error {
52
+ constructor(message: string, options?: {
53
+ cause?: unknown;
54
+ });
55
+ }
29
56
  /**
30
57
  * Embed texts via the HTTP backend, splitting into batches.
31
58
  * Reads config from env vars on every call.
@@ -17,10 +17,26 @@ const HTTP_RETRY_BACKOFF_MS = 1_000;
17
17
  const HTTP_BATCH_SIZE = 64;
18
18
  const DEFAULT_DIMS = 384;
19
19
  const HTTP_BREAKER_KEY = 'embeddings-http';
20
+ /**
21
+ * Stable lead of the {@link readConfig} malformed-`GITNEXUS_EMBEDDING_DIMS`
22
+ * error. `readConfig` throws a plain `Error` (not an {@link HttpEmbeddingError})
23
+ * because this is a *config* mistake, not an endpoint failure — so the CLI
24
+ * recognizes it by this lead ({@link isHttpEmbeddingDimsError}) and prints a
25
+ * clean config message instead of a raw stack dump. See #2385.
26
+ */
27
+ const EMBEDDING_DIMS_ENV_ERROR_LEAD = 'GITNEXUS_EMBEDDING_DIMS must be a positive integer';
28
+ /**
29
+ * @internal Exported for the CLI analyze error handler. True when `message` is
30
+ * the {@link readConfig} malformed-DIMS config error (a plain `Error`).
31
+ */
32
+ export const isHttpEmbeddingDimsError = (message) => message.includes(EMBEDDING_DIMS_ENV_ERROR_LEAD);
20
33
  /**
21
34
  * Build config from the current process.env snapshot.
22
35
  * Returns null when GITNEXUS_EMBEDDING_URL + GITNEXUS_EMBEDDING_MODEL are unset.
23
36
  * Not cached — env vars are read fresh so late configuration takes effect.
37
+ * Validates GITNEXUS_EMBEDDING_DIMS and throws on a malformed value; callers
38
+ * that only need to know whether HTTP mode is *configured* must use
39
+ * {@link isHttpMode} (a presence probe that never throws), not this.
24
40
  */
25
41
  const readConfig = () => {
26
42
  const baseUrl = process.env.GITNEXUS_EMBEDDING_URL;
@@ -31,11 +47,11 @@ const readConfig = () => {
31
47
  let dimensions;
32
48
  if (rawDims !== undefined) {
33
49
  if (!/^\d+$/.test(rawDims)) {
34
- throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
50
+ throw new Error(`${EMBEDDING_DIMS_ENV_ERROR_LEAD}, got "${rawDims}"`);
35
51
  }
36
52
  const parsed = parseInt(rawDims, 10);
37
53
  if (parsed <= 0) {
38
- throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
54
+ throw new Error(`${EMBEDDING_DIMS_ENV_ERROR_LEAD}, got "${rawDims}"`);
39
55
  }
40
56
  dimensions = parsed;
41
57
  }
@@ -47,9 +63,15 @@ const readConfig = () => {
47
63
  };
48
64
  };
49
65
  /**
50
- * Check whether HTTP embedding mode is active (env vars are set).
66
+ * Whether HTTP embedding mode is active i.e. both `GITNEXUS_EMBEDDING_URL` and
67
+ * `GITNEXUS_EMBEDDING_MODEL` are set. A pure presence probe: it deliberately does
68
+ * NOT call {@link readConfig}, so it never throws on a malformed
69
+ * `GITNEXUS_EMBEDDING_DIMS`. This lets its ~13 call sites (analyze, doctor,
70
+ * run-analyze, embedder, mcp) probe the mode without a defensive try/catch; the
71
+ * DIMS value is validated where it is actually used (`readConfig` in
72
+ * `httpEmbed`/`httpEmbedQuery`), surfacing a recognizable config error. See #2385.
51
73
  */
52
- export const isHttpMode = () => readConfig() !== null;
74
+ export const isHttpMode = () => Boolean(process.env.GITNEXUS_EMBEDDING_URL && process.env.GITNEXUS_EMBEDDING_MODEL);
53
75
  /**
54
76
  * Return the configured embedding dimensions for HTTP mode, or undefined
55
77
  * if HTTP mode is not active or no explicit dimensions are set.
@@ -70,6 +92,63 @@ export const safeUrl = (url) => {
70
92
  return '<invalid-url>';
71
93
  }
72
94
  };
95
+ /**
96
+ * Strip credentials from an underlying transport error message before it is
97
+ * surfaced. A credential-bearing endpoint URL (`https://user:secret@host/v1`)
98
+ * makes undici throw `TypeError: Request cannot be constructed from a URL that
99
+ * includes credentials: <that full URL>`; interpolating `err.message` verbatim
100
+ * would re-leak the secret to stderr + logs even though the URL argument is
101
+ * already masked with {@link safeUrl}. First swap the exact configured `url` for
102
+ * its masked form, then strip any residual `scheme://userinfo@` the transport may
103
+ * have echoed in a normalized (non-exact) form. See #2385.
104
+ */
105
+ const sanitizeReason = (reason, url) => reason
106
+ .split(url)
107
+ .join(safeUrl(url))
108
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1');
109
+ /**
110
+ * Error thrown by this module's HTTP embedding path (`httpEmbedBatch` /
111
+ * `httpEmbed` / `httpEmbedQuery`) for any endpoint failure — a
112
+ * connection/timeout/DNS error, an open circuit, a non-OK status, an
113
+ * unparseable or wrong-shape response body, an empty response, or a dimension
114
+ * mismatch.
115
+ *
116
+ * Carrying a distinct type (rather than a plain `Error`) lets the CLI tell a
117
+ * *custom endpoint* failure apart from a HuggingFace *model download* failure
118
+ * without matching message text: the two share the same underlying network
119
+ * substrings (`fetch failed`, `ECONNREFUSED`, …), which is exactly why
120
+ * `isNetworkFetchError` in `hf-env.ts` cannot tell them apart. Keying on the
121
+ * type instead of the message is also locale-proof and survives message
122
+ * rewording. The human-readable `.message` (built with `safeUrl` and the
123
+ * underlying reason) is what the CLI surfaces to the user. See #2385.
124
+ */
125
+ export class HttpEmbeddingError extends Error {
126
+ constructor(message, options) {
127
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
128
+ this.name = 'HttpEmbeddingError';
129
+ }
130
+ }
131
+ /**
132
+ * @internal Exported for the CLI analyze error handler and unit tests.
133
+ *
134
+ * Type-guard for {@link HttpEmbeddingError}. The `name` fallback keeps the
135
+ * check working across module-realm boundaries where `instanceof` can fail
136
+ * (two loaded copies of the class) — mirroring the codebase's existing
137
+ * `err.name === 'TimeoutError'` idiom. Matches on the stable class
138
+ * discriminator, never on the human-readable (potentially localized) message.
139
+ */
140
+ export const isHttpEmbeddingError = (err) => err instanceof HttpEmbeddingError || (err instanceof Error && err.name === 'HttpEmbeddingError');
141
+ /**
142
+ * Runtime guard for a single response item. The `Array.isArray(data.data)` shape
143
+ * check only validates the outer array — a 200 body like `{"data":[null]}` passes
144
+ * it, then crashes at `new Float32Array(item.embedding)` (`httpEmbed`) or
145
+ * `items[0].embedding` (`httpEmbedQuery`) with a raw `TypeError` that escapes the
146
+ * typed boundary, landing on the CLI's generic stack-dump path — the exact class
147
+ * #2385 closes. Validate each item so every wrong-shape body stays classifiable.
148
+ */
149
+ const isEmbeddingItem = (item) => typeof item === 'object' &&
150
+ item !== null &&
151
+ Array.isArray(item.embedding);
73
152
  /**
74
153
  * Send a single batch of texts to the embedding endpoint with retry.
75
154
  *
@@ -111,23 +190,36 @@ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensi
111
190
  }
112
191
  catch (err) {
113
192
  if (err instanceof CircuitOpenError) {
114
- throw new Error(`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`);
193
+ throw new HttpEmbeddingError(`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`, { cause: err });
115
194
  }
116
195
  if (err instanceof DOMException && err.name === 'TimeoutError') {
117
- throw new Error(`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`);
196
+ throw new HttpEmbeddingError(`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`, { cause: err });
118
197
  }
119
198
  if (err instanceof ResilientFetchExhaustedError) {
120
- throw new Error(`Embedding endpoint returned ${err.response.status} (${safeUrl(url)}, batch ${batchIndex})`);
199
+ throw new HttpEmbeddingError(`Embedding endpoint returned ${err.response.status} (${safeUrl(url)}, batch ${batchIndex})`, { cause: err });
121
200
  }
122
- const reason = err instanceof Error ? err.message : String(err);
123
- throw new Error(`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`);
201
+ const reason = sanitizeReason(err instanceof Error ? err.message : String(err), url);
202
+ throw new HttpEmbeddingError(`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`, { cause: err });
124
203
  }
125
204
  if (!resp.ok) {
126
205
  // resilientFetch already retried 5xx/429; any non-OK response here is
127
206
  // a terminal client error (4xx other than 429).
128
- throw new Error(`Embedding endpoint returned ${resp.status} (${safeUrl(url)}, batch ${batchIndex})`);
207
+ throw new HttpEmbeddingError(`Embedding endpoint returned ${resp.status} (${safeUrl(url)}, batch ${batchIndex})`);
208
+ }
209
+ // A reachable-but-wrong endpoint (e.g. a captive portal or a non-embeddings
210
+ // service) can answer 200 with an HTML/truncated body. Parse inside the
211
+ // typed-error boundary so that lands as an endpoint failure the CLI can
212
+ // classify, not a raw SyntaxError/TypeError on the generic stack-dump path.
213
+ let data;
214
+ try {
215
+ data = (await resp.json());
216
+ }
217
+ catch (err) {
218
+ throw new HttpEmbeddingError(`Embedding endpoint returned an unparseable response (${safeUrl(url)}, batch ${batchIndex})`, { cause: err });
219
+ }
220
+ if (!Array.isArray(data?.data) || !data.data.every(isEmbeddingItem)) {
221
+ throw new HttpEmbeddingError(`Embedding endpoint returned an unexpected response shape (${safeUrl(url)}, batch ${batchIndex})`);
129
222
  }
130
- const data = (await resp.json());
131
223
  return data.data;
132
224
  };
133
225
  /**
@@ -150,7 +242,7 @@ export const httpEmbed = async (texts) => {
150
242
  const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
151
243
  const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex, config.dimensions);
152
244
  if (items.length !== batch.length) {
153
- throw new Error(`Embedding endpoint returned ${items.length} vectors for ${batch.length} texts ` +
245
+ throw new HttpEmbeddingError(`Embedding endpoint returned ${items.length} vectors for ${batch.length} texts ` +
154
246
  `(${safeUrl(url)}, batch ${batchIndex})`);
155
247
  }
156
248
  for (const item of items) {
@@ -162,7 +254,7 @@ export const httpEmbed = async (texts) => {
162
254
  const hint = config.dimensions
163
255
  ? 'Update GITNEXUS_EMBEDDING_DIMS to match your model output.'
164
256
  : `Set GITNEXUS_EMBEDDING_DIMS=${vec.length} to match your model output.`;
165
- throw new Error(`Embedding dimension mismatch: endpoint returned ${vec.length}d vector, ` +
257
+ throw new HttpEmbeddingError(`Embedding dimension mismatch: endpoint returned ${vec.length}d vector, ` +
166
258
  `but expected ${expected}d. ${hint}`);
167
259
  }
168
260
  allVectors.push(vec);
@@ -184,7 +276,7 @@ export const httpEmbedQuery = async (text) => {
184
276
  const url = `${config.baseUrl}/embeddings`;
185
277
  const items = await httpEmbedBatch(url, [text], config.model, config.apiKey, 0, config.dimensions);
186
278
  if (!items.length) {
187
- throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
279
+ throw new HttpEmbeddingError(`Embedding endpoint returned empty response (${safeUrl(url)})`);
188
280
  }
189
281
  const embedding = items[0].embedding;
190
282
  // Same dimension checks as httpEmbed — catch mismatches before they
@@ -194,7 +286,7 @@ export const httpEmbedQuery = async (text) => {
194
286
  const hint = config.dimensions
195
287
  ? 'Update GITNEXUS_EMBEDDING_DIMS to match your model output.'
196
288
  : `Set GITNEXUS_EMBEDDING_DIMS=${embedding.length} to match your model output.`;
197
- throw new Error(`Embedding dimension mismatch: endpoint returned ${embedding.length}d vector, ` +
289
+ throw new HttpEmbeddingError(`Embedding dimension mismatch: endpoint returned ${embedding.length}d vector, ` +
198
290
  `but expected ${expected}d. ${hint}`);
199
291
  }
200
292
  return embedding;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The single access point for `module.registerHooks` (#2372).
3
+ *
4
+ * `module.registerHooks` — the synchronous ESM/CJS resolution-hook API the
5
+ * embedding-stack resolvers rely on — was added in Node 22.15.0 (and 23.5.0 on
6
+ * the 23.x line). The gitnexus engines floor is `>=22.0.0`, which admits Node
7
+ * 22.0–22.14 AND 23.0–23.4, where the export is absent.
8
+ *
9
+ * In this `"type": "module"` package, a *static named* import of a missing
10
+ * builtin export (`import { registerHooks } from 'node:module'`) is a
11
+ * `SyntaxError` at ESM link time — thrown before any `typeof registerHooks`
12
+ * guard in the module body can run, so every module carrying that import fails
13
+ * to load on those Node versions. This module owns the only namespace import of
14
+ * `node:module` and hands callers a value-or-`undefined` they guard at runtime,
15
+ * so the graceful-degradation path is finally reachable.
16
+ *
17
+ * `@types/node` types `registerHooks` as always-present, so `nodeModule.registerHooks`
18
+ * would type as defined while being `undefined` at runtime on older Node. The
19
+ * `Partial` narrow surfaces the real optionality without an `any` cast.
20
+ */
21
+ import * as nodeModule from 'node:module';
22
+ /** `module.registerHooks` if this Node exposes it (>=22.15 / >=23.5), else `undefined`. */
23
+ export declare const getRegisterHooks: () => typeof nodeModule.registerHooks | undefined;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The single access point for `module.registerHooks` (#2372).
3
+ *
4
+ * `module.registerHooks` — the synchronous ESM/CJS resolution-hook API the
5
+ * embedding-stack resolvers rely on — was added in Node 22.15.0 (and 23.5.0 on
6
+ * the 23.x line). The gitnexus engines floor is `>=22.0.0`, which admits Node
7
+ * 22.0–22.14 AND 23.0–23.4, where the export is absent.
8
+ *
9
+ * In this `"type": "module"` package, a *static named* import of a missing
10
+ * builtin export (`import { registerHooks } from 'node:module'`) is a
11
+ * `SyntaxError` at ESM link time — thrown before any `typeof registerHooks`
12
+ * guard in the module body can run, so every module carrying that import fails
13
+ * to load on those Node versions. This module owns the only namespace import of
14
+ * `node:module` and hands callers a value-or-`undefined` they guard at runtime,
15
+ * so the graceful-degradation path is finally reachable.
16
+ *
17
+ * `@types/node` types `registerHooks` as always-present, so `nodeModule.registerHooks`
18
+ * would type as defined while being `undefined` at runtime on older Node. The
19
+ * `Partial` narrow surfaces the real optionality without an `any` cast.
20
+ */
21
+ import * as nodeModule from 'node:module';
22
+ /** `module.registerHooks` if this Node exposes it (>=22.15 / >=23.5), else `undefined`. */
23
+ export const getRegisterHooks = () => nodeModule.registerHooks;
@@ -57,11 +57,12 @@
57
57
  * as before — fine on hoisted layouts. Any failure during installation is
58
58
  * swallowed.
59
59
  */
60
- import { registerHooks, createRequire } from 'node:module';
60
+ import { createRequire } from 'node:module';
61
61
  import { pathToFileURL } from 'node:url';
62
62
  import { join } from 'node:path';
63
63
  import { getEffectiveOnnxRuntimeNodeDir } from './onnxruntime-node-resolver.js';
64
64
  import { logger } from '../logger.js';
65
+ import { getRegisterHooks } from './node-module-compat.js';
65
66
  let attempted = false;
66
67
  /**
67
68
  * Compute the file: URL the hook redirects `onnxruntime-common` to.
@@ -98,8 +99,10 @@ export const ensureOnnxRuntimeCommonResolvable = () => {
98
99
  // initEmbedder() call, and the hook is process-global — once is enough.
99
100
  attempted = true;
100
101
  try {
101
- // Node < 22.15 (the gitnexus engines floor is >= 22.0.0): no synchronous
102
- // hooks API. Degrade gracefully — the import still works on hoisted layouts.
102
+ // Node < 22.15 / < 23.5 (the gitnexus engines floor is >= 22.0.0): no
103
+ // synchronous hooks API. Degrade gracefully — the import still works on
104
+ // hoisted layouts.
105
+ const registerHooks = getRegisterHooks();
103
106
  if (typeof registerHooks !== 'function')
104
107
  return;
105
108
  const redirectUrl = resolveOnnxRuntimeCommonUrl();
@@ -51,12 +51,14 @@
51
51
  * loaded (the probe uses CJS `require.resolve`, which an ESM hook does not
52
52
  * affect) — keeping probe and runtime consistent.
53
53
  */
54
- import { registerHooks, createRequire } from 'node:module';
54
+ import { createRequire } from 'node:module';
55
55
  import { pathToFileURL } from 'node:url';
56
56
  import { existsSync } from 'node:fs';
57
57
  import { join, dirname } from 'node:path';
58
58
  import { execFileSync } from 'node:child_process';
59
59
  import { logger } from '../logger.js';
60
+ import { getEmbeddingRuntimeDir } from './runtime-install.js';
61
+ import { getRegisterHooks } from './node-module-compat.js';
60
62
  const require = createRequire(import.meta.url);
61
63
  /**
62
64
  * Read a shared object's NEEDED entries, tolerating ldd's non-zero exit when a
@@ -152,7 +154,18 @@ const resolveDefaultOrtNodeDir = () => {
152
154
  return dirname(createRequire(transformersMain).resolve('onnxruntime-node/package.json'));
153
155
  }
154
156
  catch {
155
- return null;
157
+ // On-demand runtime prefix (#2370): when the optional stack was pruned at
158
+ // install time and fetched on demand, the copy that actually loads (via
159
+ // ensureEmbeddingStackResolvable's fallback hook) lives in the prefix — so
160
+ // it IS the effective default and must be the one the CUDA probe inspects.
161
+ try {
162
+ const prefixRequire = createRequire(join(getEmbeddingRuntimeDir(), 'noop.js'));
163
+ const transformersMain = prefixRequire.resolve('@huggingface/transformers');
164
+ return dirname(createRequire(transformersMain).resolve('onnxruntime-node/package.json'));
165
+ }
166
+ catch {
167
+ return null;
168
+ }
156
169
  }
157
170
  };
158
171
  /** gitnexus' own direct top-level onnxruntime-node dir. */
@@ -161,7 +174,19 @@ const resolveOurOrtNodeDir = () => {
161
174
  return dirname(require.resolve('onnxruntime-node/package.json'));
162
175
  }
163
176
  catch {
164
- return null;
177
+ // On-demand runtime prefix (#2370): when gitnexus' own onnxruntime-node was
178
+ // pruned at install time and fetched on demand, the prefix copy IS our
179
+ // effective top-level build — so the CUDA-major redirect must be able to
180
+ // target it (mirrors resolveDefaultOrtNodeDir's fallback above). Without
181
+ // this, `embeddings install --cuda` on a pruned install downloads the GPU
182
+ // binaries but the probe still can't see them and embeddings run on CPU.
183
+ try {
184
+ const prefixRequire = createRequire(join(getEmbeddingRuntimeDir(), 'noop.js'));
185
+ return dirname(prefixRequire.resolve('onnxruntime-node/package.json'));
186
+ }
187
+ catch {
188
+ return null;
189
+ }
165
190
  }
166
191
  };
167
192
  let cached = null;
@@ -176,7 +201,7 @@ const decide = () => {
176
201
  // major is still probed: a CUDA-12 host on Node 22.0–22.14 whose default
177
202
  // build already matches must keep auto-selecting the GPU exactly as it did
178
203
  // before this redirect existed.
179
- const canRedirect = typeof registerHooks === 'function';
204
+ const canRedirect = typeof getRegisterHooks() === 'function';
180
205
  const systemMajor = detectSystemCudaMajor();
181
206
  // `defaultDir` resolving is NOT a precondition for checking `ourDir` below —
182
207
  // if transformers' own resolution fails outright (defaultMajor stays null),
@@ -264,8 +289,9 @@ export const ensureOnnxRuntimeNodeMatchesSystem = () => {
264
289
  return;
265
290
  attempted = true;
266
291
  try {
292
+ const registerHooks = getRegisterHooks();
267
293
  if (typeof registerHooks !== 'function')
268
- return; // Node < 22.15: graceful no-op
294
+ return; // Node < 22.15 / < 23.5: graceful no-op
269
295
  const d = decide();
270
296
  if (!d.redirect || !d.effectiveDir)
271
297
  return;