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
@@ -349,6 +349,49 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
349
349
  });
350
350
  }
351
351
 
352
+ /**
353
+ * Fallback augmentation for the #2396 path: when a GitNexus process holds the
354
+ * lbug DB write lock the CLI `augment` can't run, so point the agent at the MCP
355
+ * `query` tool instead. Phrased conditionally ("if the MCP tools are live") so it
356
+ * stays truthful on every owner path — a confirmed MCP owner, a `serve` owner, or
357
+ * a fail-closed probe where no server is actually confirmed. `pattern` is embedded
358
+ * verbatim; the caller (sendHookResponse) JSON-escapes it structurally.
359
+ */
360
+ function buildMcpQueryHint(pattern) {
361
+ return (
362
+ `[GitNexus] Local augment is unavailable (the graph DB is held by another ` +
363
+ `GitNexus process). If the GitNexus MCP tools are live in this session, call ` +
364
+ `the GitNexus \`query\` MCP tool (e.g. mcp__gitnexus__query) with ` +
365
+ `search_query "${pattern}".`
366
+ );
367
+ }
368
+
369
+ /**
370
+ * #2396 throttle: emit the MCP-query hint at most once per repo per window, so an
371
+ * owner-locked session isn't nudged on every search. Window (ms) via
372
+ * GITNEXUS_MCP_HINT_THROTTLE_MS (default 10min; 0/invalid disables). Best-effort —
373
+ * any fs error falls back to emitting.
374
+ * ponytail: per-repo mtime marker, shared across concurrent sessions on the same
375
+ * repo; add per-session dedup only if that sharing becomes a problem.
376
+ */
377
+ function shouldEmitMcpHint(gitNexusDir) {
378
+ const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS;
379
+ const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw);
380
+ if (!Number.isFinite(windowMs) || windowMs <= 0) return true;
381
+ const marker = path.join(gitNexusDir, '.mcp-hint-shown');
382
+ try {
383
+ if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false;
384
+ } catch {
385
+ /* marker missing/unreadable → emit */
386
+ }
387
+ try {
388
+ fs.writeFileSync(marker, '');
389
+ } catch {
390
+ /* best-effort; still emit */
391
+ }
392
+ return true;
393
+ }
394
+
352
395
  /**
353
396
  * PreToolUse handler — augment searches with graph context.
354
397
  */
@@ -385,18 +428,25 @@ function handlePreToolUse(input) {
385
428
  let result = '';
386
429
  try {
387
430
  if (hasGitNexusServerOwner(gitNexusDir)) {
388
- // Normal skip path: the MCP server owns the DB, so the CLI augment would
389
- // contend on the lock. Stay silent for strict hook runners (issue #1913);
390
- // surface the reason only when diagnostics are explicitly requested.
431
+ // #2396: the MCP server holds the DB write lock, so a competing CLI
432
+ // `augment` would only contend on it (LadybugDB is single-writer). But the
433
+ // session that triggered this hook has the GitNexus MCP tools live route
434
+ // the augmentation to the agent via additionalContext instead of silently
435
+ // doing nothing. Mirror the skip reason to stderr only under GITNEXUS_DEBUG
436
+ // (strict-runner contract, #1913); the hint itself rides the sanctioned
437
+ // additionalContext stdout channel the successful augment already uses.
391
438
  if (isDebugEnabled()) {
392
439
  process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
393
440
  }
394
- return;
395
- }
396
- const cliPath = resolveCliPath();
397
- const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
398
- if (!child.error && child.status === 0) {
399
- result = extractAugmentContext(child.stderr || '');
441
+ if (shouldEmitMcpHint(gitNexusDir)) {
442
+ result = buildMcpQueryHint(pattern);
443
+ }
444
+ } else {
445
+ const cliPath = resolveCliPath();
446
+ const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
447
+ if (!child.error && child.status === 0) {
448
+ result = extractAugmentContext(child.stderr || '');
449
+ }
400
450
  }
401
451
  } catch {
402
452
  /* graceful failure */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.9",
3
+ "version": "1.6.10-rc.10",
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",
@@ -55,7 +55,6 @@
55
55
  "prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js"
56
56
  },
57
57
  "dependencies": {
58
- "@huggingface/transformers": "^4.1.0",
59
58
  "@ladybugdb/core": "^0.18.0",
60
59
  "@modelcontextprotocol/sdk": "^1.0.0",
61
60
  "@scarf/scarf": "^1.4.0",
@@ -76,7 +75,6 @@
76
75
  "node-addon-api": "^8.0.0",
77
76
  "node-gyp-build": "^4.8.0",
78
77
  "onnxruntime-common": "^1.26.0",
79
- "onnxruntime-node": "^1.24.0",
80
78
  "pandemonium": "^2.4.0",
81
79
  "pino": "^10.3.1",
82
80
  "pino-pretty": "^13.1.3",
@@ -93,6 +91,10 @@
93
91
  "tree-sitter-typescript": "^0.23.2",
94
92
  "uuid": "^14.0.0"
95
93
  },
94
+ "optionalDependencies": {
95
+ "@huggingface/transformers": "^4.1.0",
96
+ "onnxruntime-node": "^1.24.0"
97
+ },
96
98
  "devDependencies": {
97
99
  "@babel/generator": "^7.29.7",
98
100
  "@babel/parser": "^7.29.7",
@@ -30,7 +30,11 @@ const PLATFORM_LOGIC = [
30
30
  'test/unit/setup-jsonc.test.ts',
31
31
  'test/unit/setup-codex.test.ts',
32
32
  'test/unit/setup-antigravity.test.ts',
33
+ 'test/integration/setup-uninstall-roundtrip.test.ts',
33
34
  'test/unit/resolve-invocation.test.ts',
35
+ // CLI-spawn entry-point resolution; its path-separator assertion (cli[/\\]index)
36
+ // must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
37
+ 'test/unit/cli-entry.test.ts',
34
38
  'test/unit/platform-capabilities.test.ts',
35
39
  'test/unit/worker-pool-windows-quarantine.test.ts',
36
40
  'test/unit/lbug-pool-fts-load.test.ts',
@@ -42,12 +46,30 @@ const PLATFORM_LOGIC = [
42
46
  'test/unit/cursor-hook.test.ts',
43
47
  'test/unit/sidecar-recovery.test.ts',
44
48
  'test/unit/pool-wal-recovery.test.ts',
49
+ 'test/unit/lbug-adapter-wal-schema.test.ts',
45
50
  'test/unit/detect-changes-worktree.test.ts',
46
51
  'test/unit/eval-server-bind-restriction.test.ts',
47
52
  'test/unit/ignore-service.test.ts',
48
53
  'test/unit/group/bridge-db.test.ts',
49
54
  'test/unit/group/bridge-db-edge.test.ts',
50
55
  'test/unit/onnxruntime-node-resolver.test.ts',
56
+ // Windows cmd.exe arg-quoting + compose-and-spawn for the npm install (#2372):
57
+ // the quoting rules and win32 single-string spawn shape are OS-sensitive, so
58
+ // exercise them on real windows-latest. The spawn-shape/path tests force their
59
+ // platform branch and derive expected paths via the real fns, so they pass on
60
+ // any host (see the platform stubs + resolve() in the test file).
61
+ 'test/unit/embedding-runtime-install.test.ts',
62
+ // Real-spawn arg-delivery round-trip: proves the install spawn delivers args
63
+ // to the child intact on each platform — win32 via the cmd.exe -> .cmd %* ->
64
+ // node chain (real cmd.exe, not just our model), macos/linux via the no-shell
65
+ // array form. Runs on every platform (the ubuntu suite covers Linux; this
66
+ // registration adds windows + macos).
67
+ 'test/unit/embedding-install-arg-delivery.test.ts',
68
+ // Structural FTS-extension classifier against REAL binaries (#2374): on this
69
+ // matrix `process.execPath` / `lbugjs.node` are a real PE (windows) and Mach-O
70
+ // (macos), so the header parsing is proven on genuine binaries, not synthetic
71
+ // buffers (the ubuntu suite covers the ELF path).
72
+ 'test/integration/extension-binary-real.test.ts',
51
73
  ];
52
74
 
53
75
  // Native LadybugDB integration tests — exercise the @ladybugdb/core
@@ -87,6 +109,10 @@ const SPAWN_CLI = [
87
109
  'test/integration/cli-limit-e2e.test.ts',
88
110
  'test/integration/hooks-e2e.test.ts',
89
111
  'test/integration/skills-e2e.test.ts',
112
+ // Spawns the real CLI across hermetic HOME/USERPROFILE homes to exercise the
113
+ // FTS extension lifecycle — the #2374 bug was Windows-reported, so this must
114
+ // run on the Windows/macOS matrix, not just the Ubuntu full suite.
115
+ 'test/integration/fts-extension-e2e.test.ts',
90
116
  'test/integration/server-http-startup.test.ts',
91
117
  'test/integration/mcp/server-startup.test.ts',
92
118
  'test/integration/analyze-heap-oom-e2e.test.ts',
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Install the LadybugDB FTS extension into the shared home (~/.lbdb) up front, so
3
+ * every test in a sharded CI run finds it regardless of which shard it lands in.
4
+ *
5
+ * FTS-dependent tests split two ways: the LOAD-path gate (skipUnlessFtsAvailable)
6
+ * self-installs on miss, but the FILE-path gate (requireFtsResourceOrSkip, e.g.
7
+ * extension-binary-real.test.ts) resolves the extension path at module load and
8
+ * cannot self-install. Sharding (and the balancing sequencer) can drop such a
9
+ * test into a shard with no installer sibling — this step removes that ordering
10
+ * dependency by installing FTS once before vitest starts. `auto` is LOAD-first,
11
+ * so a cache-warmed extension costs no network.
12
+ *
13
+ * Best-effort: exits 0 on failure (offline etc.) — the per-test gates still
14
+ * hard-fail under GITNEXUS_REQUIRE_FTS=1 if FTS is genuinely unavailable, which
15
+ * is where the loud signal belongs.
16
+ */
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { initLbug, loadFTSExtension, closeLbug } from '../src/core/lbug/lbug-adapter.js';
21
+
22
+ const dir = mkdtempSync(join(tmpdir(), 'gn-ensure-fts-'));
23
+ try {
24
+ await initLbug(join(dir, 'ensure-fts.lbug'));
25
+ const ok = await loadFTSExtension(undefined, { policy: 'auto' });
26
+ console.log(ok ? 'FTS extension ready.' : 'FTS extension unavailable (continuing).');
27
+ } catch (err) {
28
+ console.warn(`ensure-fts: skipped (${err instanceof Error ? err.message : String(err)})`);
29
+ } finally {
30
+ await closeLbug();
31
+ rmSync(dir, { recursive: true, force: true });
32
+ }
@@ -3,9 +3,41 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
+ import { pathToFileURL } from 'node:url';
6
7
 
7
8
  const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
8
9
 
10
+ // Positive on-disk-corruption signatures. `FORCE INSTALL` re-downloads even when
11
+ // a file is already present; we only want that when the LOAD error proves the
12
+ // existing file is bad (truncated/wrong-platform, #2374). For everything else —
13
+ // a missing file (plain INSTALL downloads it), or a permanent non-file failure a
14
+ // re-download can never fix (missing runtime dep: "cannot open shared object") —
15
+ // plain INSTALL avoids re-downloading ~2 MB on every analyze run forever.
16
+ // Exported so a parity test keeps this byte-identical to the copy in
17
+ // src/core/lbug/extension-load-error.ts (this `.mjs` cannot import that `.ts`), #2383 F5b.
18
+ export const FILE_CORRUPTION_SIGNATURES = [
19
+ /invalid elf/i,
20
+ /file too short/i,
21
+ /not a valid/i,
22
+ /bad magic/i,
23
+ /wrong architecture/i,
24
+ /mach-o/i,
25
+ /truncat/i,
26
+ ];
27
+
28
+ /**
29
+ * Decide the install verb from the LOAD error that triggered this install.
30
+ * `FORCE INSTALL` only when the error positively indicates file-level breakage;
31
+ * otherwise plain `INSTALL` (missing file, missing-dependency dlopen failure,
32
+ * or unknown/absent error).
33
+ */
34
+ export function chooseInstallVerb(loadError) {
35
+ if (loadError && FILE_CORRUPTION_SIGNATURES.some((re) => re.test(loadError))) {
36
+ return 'FORCE INSTALL';
37
+ }
38
+ return 'INSTALL';
39
+ }
40
+
9
41
  function parseLbugMaxDbSize(raw) {
10
42
  const parsed = raw ? Number(raw) : NaN;
11
43
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -14,28 +46,54 @@ function parseLbugMaxDbSize(raw) {
14
46
  return Math.floor(parsed);
15
47
  }
16
48
 
17
- async function installDuckDbExtension(extensionName, verifyOnly = false) {
18
- if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
19
- throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
20
- }
21
-
22
- const require = createRequire(import.meta.url);
23
- const lbugModule = require('@ladybugdb/core');
24
- const lbug = lbugModule.default ?? lbugModule;
49
+ function resolveMaxDbSize() {
25
50
  // argv[3] is the optional positional size; ignore it when it is actually a
26
51
  // flag token (e.g. `--verify-only`) and fall back to the env default.
27
52
  const sizeArg =
28
53
  process.argv[3] && !process.argv[3].startsWith('--') ? process.argv[3] : undefined;
29
- const lbugMaxDbSize = parseLbugMaxDbSize(sizeArg ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE);
54
+ return parseLbugMaxDbSize(sizeArg ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE);
55
+ }
56
+
57
+ /** Open a scratch LadybugDB and return its connection plus a disposer. */
58
+ async function defaultConnect(lbugMaxDbSize) {
59
+ const require = createRequire(import.meta.url);
60
+ const lbugModule = require('@ladybugdb/core');
61
+ const lbug = lbugModule.default ?? lbugModule;
30
62
 
31
63
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
32
64
  const dbPath = path.join(tmpDir, 'install.lbug');
33
- let db;
34
- let conn;
65
+ const db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
66
+ const conn = new lbug.Connection(db);
67
+ return {
68
+ conn,
69
+ dispose: async () => {
70
+ await conn.close().catch(() => {});
71
+ await db.close().catch(() => {});
72
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Install (or verify) an optional LadybugDB extension in this short-lived process.
79
+ *
80
+ * @param {string} extensionName
81
+ * @param {object} [options]
82
+ * @param {boolean} [options.verifyOnly] LOAD-only Docker build gate — no install.
83
+ * @param {string} [options.loadError] The parent's LOAD failure; selects the verb.
84
+ * @param {(size: number) => Promise<{conn: {query: (sql: string) => Promise<unknown>}, dispose: () => Promise<void>}>} [options.connect]
85
+ * Connection factory; injectable for offline unit tests.
86
+ */
87
+ export async function installDuckDbExtension(extensionName, options = {}) {
88
+ const { verifyOnly = false, loadError, connect } = options;
89
+ if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
90
+ throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
91
+ }
92
+
93
+ const makeConnection = connect ?? (() => defaultConnect(resolveMaxDbSize()));
94
+ const { conn, dispose } = await makeConnection();
35
95
 
36
96
  try {
37
- db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
38
- conn = new lbug.Connection(db);
39
97
  if (verifyOnly) {
40
98
  // Prove a previously-baked extension is resolvable by a FRESH process
41
99
  // under the current HOME (the runtime `LOAD EXTENSION` path) — no INSTALL,
@@ -46,19 +104,22 @@ async function installDuckDbExtension(extensionName, verifyOnly = false) {
46
104
  `[install-ext] LOAD-only verify OK for '${extensionName}' (HOME=${process.env.HOME})`,
47
105
  );
48
106
  } else {
49
- await conn.query(`INSTALL ${extensionName}`);
107
+ // Plain INSTALL is a no-op when the file already exists; escalate to FORCE
108
+ // only when the LOAD error proves the on-disk file is broken (#2374).
109
+ await conn.query(`${chooseInstallVerb(loadError)} ${extensionName}`);
50
110
  }
51
111
  } finally {
52
- if (conn) await conn.close().catch(() => {});
53
- if (db) await db.close().catch(() => {});
54
- await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
112
+ await dispose();
55
113
  }
56
114
  }
57
115
 
58
- installDuckDbExtension(
59
- process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME,
60
- process.argv.includes('--verify-only'),
61
- ).catch((err) => {
62
- console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
63
- process.exitCode = 1;
64
- });
116
+ // Only run when executed directly — imported (e.g. by unit tests) it stays inert.
117
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
118
+ installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME, {
119
+ verifyOnly: process.argv.includes('--verify-only'),
120
+ loadError: process.env.GITNEXUS_LBUG_EXTENSION_LOAD_ERROR,
121
+ }).catch((err) => {
122
+ console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
123
+ process.exitCode = 1;
124
+ });
125
+ }
@@ -14,6 +14,7 @@ import fs from 'fs';
14
14
  import path from 'path';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { ALL_CROSS_PLATFORM } from './cross-platform-tests.js';
17
+ import { parseShardArg } from './shard-arg.js';
17
18
 
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
  const ROOT = path.resolve(__dirname, '..');
@@ -27,18 +28,57 @@ if (missing.length > 0) {
27
28
  process.exit(1);
28
29
  }
29
30
 
30
- console.log(`Running ${ALL_CROSS_PLATFORM.length} platform-sensitive tests...\n`);
31
+ // Optional sharding (CI): `--shard=<i>/<n>` splits the fixed file list across
32
+ // parallel matrix shards so each runner processes ~1/n of it. Passed straight
33
+ // through to vitest, which partitions the *given* files deterministically. The
34
+ // Windows runner is ~5x slower than macOS/Linux on this spawn-heavy suite (~50
35
+ // CLI/worker process spawns), so a single shard was creeping past the watchdog
36
+ // below; sharding keeps each runner well under it (see ci-tests.yml matrix).
37
+ // Fail loud on a malformed --shard arg (mirrors the missing-files check above):
38
+ // a silently-dropped shard flag would run the full unsharded suite and re-trip
39
+ // the watchdog. Kept outside the execFileSync try/catch below so the message
40
+ // isn't swallowed by that catch's watchdog-only branch.
41
+ let shardArg: string | undefined;
42
+ try {
43
+ shardArg = parseShardArg(process.argv.slice(2));
44
+ } catch (err) {
45
+ console.error(err instanceof Error ? err.message : String(err));
46
+ process.exit(1);
47
+ }
48
+
49
+ // Per-shard watchdog, default 15 min. Sharding splits the file list by COUNT, not
50
+ // runtime, so the heaviest spawn suites can cluster on one shard — what this
51
+ // bounds is the *busiest* shard, not an even 1/n of wall-clock. With 3 shards
52
+ // even that shard clears the watchdog, where the whole unsharded Windows run
53
+ // used to trip it. Allow CI/manual runs to add headroom without editing the
54
+ // script again.
55
+ const DEFAULT_TIMEOUT_MIN = 15;
56
+ const timeoutMinutes = Number.parseInt(
57
+ process.env.GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES ?? String(DEFAULT_TIMEOUT_MIN),
58
+ 10,
59
+ );
60
+ const timeoutMs =
61
+ Number.isFinite(timeoutMinutes) && timeoutMinutes > 0
62
+ ? timeoutMinutes * 60 * 1000
63
+ : DEFAULT_TIMEOUT_MIN * 60 * 1000;
64
+
65
+ console.log(
66
+ `Running ${ALL_CROSS_PLATFORM.length} platform-sensitive tests` +
67
+ `${shardArg ? ` (${shardArg.replace('--shard=', 'shard ')})` : ''}...\n`,
68
+ );
31
69
 
32
70
  try {
33
- execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM], {
71
+ execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM, ...(shardArg ? [shardArg] : [])], {
34
72
  cwd: ROOT,
35
73
  stdio: 'inherit',
36
- timeout: 15 * 60 * 1000,
74
+ timeout: timeoutMs,
37
75
  shell: true,
38
76
  });
39
- } catch (err: any) {
40
- if (err.killed || err.signal) {
41
- console.error('vitest timed out after 15 minutes');
77
+ } catch (err) {
78
+ // execFileSync sets `killed`/`signal` when the watchdog above kills vitest.
79
+ const e = err as { killed?: boolean; signal?: NodeJS.Signals | null };
80
+ if (e.killed || e.signal) {
81
+ console.error(`vitest timed out after ${Math.round(timeoutMs / 60_000)} minutes`);
42
82
  }
43
83
  process.exit(1);
44
84
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Resolves the optional `--shard=<index>/<total>` argument for
3
+ * `run-cross-platform.ts`.
4
+ *
5
+ * Extracted as a pure, side-effect-free function so the branch logic is
6
+ * unit-testable without the script's top-level `execFileSync` (see
7
+ * `test/unit/shard-arg.test.ts`). Mirrors the `computeSpawnPrefix` extraction
8
+ * pattern in `test/helpers/cli-entry.ts`.
9
+ */
10
+
11
+ const SHARD_RE = /^--shard=\d+\/\d+$/;
12
+
13
+ /**
14
+ * Returns the matched `--shard=<index>/<total>` token (e.g. `--shard=1/3`) to
15
+ * pass straight through to vitest, or `undefined` when no shard arg is present.
16
+ *
17
+ * Fails loud on a shard-shaped-but-malformed arg (e.g. `--shard=1`, `--shard`,
18
+ * `--shard=abc`): a silently-ignored malformed arg would drop the shard flag and
19
+ * run the full unsharded ~50-spawn suite, re-arming the Windows watchdog timeout
20
+ * with no signal. Only `--shard` / `--shard=…` args are inspected, so unrelated
21
+ * flags (including a hypothetical `--shardx=…`) pass through untouched.
22
+ */
23
+ export function parseShardArg(argv: string[]): string | undefined {
24
+ const shardArgs = argv.filter((a) => a === '--shard' || a.startsWith('--shard='));
25
+ const malformed = shardArgs.find((a) => !SHARD_RE.test(a));
26
+ if (malformed !== undefined) {
27
+ throw new Error(`Malformed --shard arg '${malformed}' — expected --shard=<index>/<total>`);
28
+ }
29
+ return shardArgs[0];
30
+ }