gitnexus 1.6.8-rc.2 → 1.6.8-rc.21

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 (193) hide show
  1. package/README.md +20 -0
  2. package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
  3. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
  4. package/dist/_shared/scope-resolution/symbol-definition.d.ts +4 -0
  5. package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -1
  6. package/dist/cli/ai-context.js +1 -0
  7. package/dist/cli/analyze-config.js +40 -0
  8. package/dist/cli/analyze.d.ts +22 -0
  9. package/dist/cli/analyze.js +36 -6
  10. package/dist/cli/clean.d.ts +1 -0
  11. package/dist/cli/clean.js +43 -1
  12. package/dist/cli/eval-server.js +43 -0
  13. package/dist/cli/help-i18n.js +11 -0
  14. package/dist/cli/i18n/en.d.ts +13 -0
  15. package/dist/cli/i18n/en.js +13 -0
  16. package/dist/cli/i18n/resources.d.ts +26 -0
  17. package/dist/cli/i18n/zh-CN.d.ts +13 -0
  18. package/dist/cli/i18n/zh-CN.js +13 -0
  19. package/dist/cli/index.js +19 -0
  20. package/dist/cli/list.js +12 -0
  21. package/dist/cli/optional-grammars.d.ts +6 -8
  22. package/dist/cli/optional-grammars.js +8 -11
  23. package/dist/cli/skill-gen.js +1 -0
  24. package/dist/cli/status.js +26 -5
  25. package/dist/cli/tool.d.ts +12 -1
  26. package/dist/cli/tool.js +41 -1
  27. package/dist/core/embeddings/embedder.js +4 -0
  28. package/dist/core/embeddings/embedding-pipeline.js +27 -16
  29. package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
  30. package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
  31. package/dist/core/graph/import-cycles.d.ts +10 -0
  32. package/dist/core/graph/import-cycles.js +103 -0
  33. package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
  34. package/dist/core/group/extractors/http-patterns/java.js +3 -50
  35. package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
  36. package/dist/core/group/extractors/include-extractor.js +7 -7
  37. package/dist/core/ingestion/cfg/cfg-builder.d.ts +64 -0
  38. package/dist/core/ingestion/cfg/cfg-builder.js +129 -0
  39. package/dist/core/ingestion/cfg/collect.d.ts +30 -0
  40. package/dist/core/ingestion/cfg/collect.js +34 -0
  41. package/dist/core/ingestion/cfg/control-flow-context.d.ts +97 -0
  42. package/dist/core/ingestion/cfg/control-flow-context.js +113 -0
  43. package/dist/core/ingestion/cfg/emit.d.ts +144 -0
  44. package/dist/core/ingestion/cfg/emit.js +315 -0
  45. package/dist/core/ingestion/cfg/reaching-defs.d.ts +90 -0
  46. package/dist/core/ingestion/cfg/reaching-defs.js +364 -0
  47. package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
  48. package/dist/core/ingestion/cfg/traversal-result.js +2 -0
  49. package/dist/core/ingestion/cfg/types.d.ts +231 -0
  50. package/dist/core/ingestion/cfg/types.js +13 -0
  51. package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +144 -0
  52. package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +1032 -0
  53. package/dist/core/ingestion/cfg/visitors/typescript.d.ts +66 -0
  54. package/dist/core/ingestion/cfg/visitors/typescript.js +584 -0
  55. package/dist/core/ingestion/language-provider.d.ts +36 -2
  56. package/dist/core/ingestion/languages/c-cpp.js +11 -4
  57. package/dist/core/ingestion/languages/cpp/arity-metadata.js +6 -2
  58. package/dist/core/ingestion/languages/cpp/captures.js +24 -1
  59. package/dist/core/ingestion/languages/cpp/query.js +23 -0
  60. package/dist/core/ingestion/languages/java.js +3 -0
  61. package/dist/core/ingestion/languages/kotlin/query.js +3 -2
  62. package/dist/core/ingestion/languages/kotlin.js +5 -1
  63. package/dist/core/ingestion/languages/typescript.js +5 -0
  64. package/dist/core/ingestion/method-extractors/configs/c-cpp.js +6 -13
  65. package/dist/core/ingestion/method-extractors/generic.js +1 -0
  66. package/dist/core/ingestion/method-types.d.ts +2 -0
  67. package/dist/core/ingestion/model/symbol-table.d.ts +1 -0
  68. package/dist/core/ingestion/model/symbol-table.js +1 -0
  69. package/dist/core/ingestion/parsing-processor.js +22 -0
  70. package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
  71. package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
  72. package/dist/core/ingestion/pipeline.d.ts +57 -0
  73. package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
  74. package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
  75. package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
  76. package/dist/core/ingestion/route-extractors/spring.js +136 -0
  77. package/dist/core/ingestion/scope-extractor.js +3 -0
  78. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +12 -0
  79. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +145 -24
  80. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +6 -0
  81. package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +43 -3
  82. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +21 -0
  83. package/dist/core/ingestion/scope-resolution/pipeline/run.js +207 -0
  84. package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
  85. package/dist/core/ingestion/taint/emit.d.ts +124 -0
  86. package/dist/core/ingestion/taint/emit.js +204 -0
  87. package/dist/core/ingestion/taint/match.d.ts +153 -0
  88. package/dist/core/ingestion/taint/match.js +278 -0
  89. package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
  90. package/dist/core/ingestion/taint/path-codec.js +190 -0
  91. package/dist/core/ingestion/taint/propagate.d.ts +216 -0
  92. package/dist/core/ingestion/taint/propagate.js +664 -0
  93. package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
  94. package/dist/core/ingestion/taint/site-safety.js +98 -0
  95. package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
  96. package/dist/core/ingestion/taint/source-sink-config.js +11 -11
  97. package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
  98. package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
  99. package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
  100. package/dist/core/ingestion/taint/typescript-model.js +102 -0
  101. package/dist/core/ingestion/utils/method-props.js +1 -0
  102. package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
  103. package/dist/core/ingestion/workers/clone-safety.js +465 -0
  104. package/dist/core/ingestion/workers/parse-worker.d.ts +11 -0
  105. package/dist/core/ingestion/workers/parse-worker.js +70 -53
  106. package/dist/core/ingestion/workers/post-result.d.ts +22 -0
  107. package/dist/core/ingestion/workers/post-result.js +87 -0
  108. package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
  109. package/dist/core/ingestion/workers/result-merge.js +43 -0
  110. package/dist/core/ingestion/workers/worker-pool.d.ts +15 -0
  111. package/dist/core/ingestion/workers/worker-pool.js +33 -16
  112. package/dist/core/lbug/lbug-adapter.d.ts +19 -0
  113. package/dist/core/lbug/lbug-adapter.js +56 -1
  114. package/dist/core/run-analyze.d.ts +87 -0
  115. package/dist/core/run-analyze.js +280 -25
  116. package/dist/core/tree-sitter/parser-loader.js +5 -4
  117. package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
  118. package/dist/core/tree-sitter/vendored-grammars.js +57 -0
  119. package/dist/mcp/core/embedder.js +4 -0
  120. package/dist/mcp/local/local-backend.d.ts +86 -2
  121. package/dist/mcp/local/local-backend.js +657 -27
  122. package/dist/mcp/resources.js +1 -0
  123. package/dist/mcp/tools.d.ts +9 -0
  124. package/dist/mcp/tools.js +109 -0
  125. package/dist/server/analyze-launch.d.ts +29 -0
  126. package/dist/server/analyze-launch.js +137 -0
  127. package/dist/server/analyze-upload.d.ts +33 -0
  128. package/dist/server/analyze-upload.js +123 -0
  129. package/dist/server/analyze-worker-ipc.d.ts +58 -0
  130. package/dist/server/analyze-worker-ipc.js +16 -0
  131. package/dist/server/analyze-worker.d.ts +17 -1
  132. package/dist/server/analyze-worker.js +7 -1
  133. package/dist/server/api.js +46 -140
  134. package/dist/server/git-clone.d.ts +1 -0
  135. package/dist/server/git-clone.js +1 -1
  136. package/dist/server/middleware.d.ts +11 -0
  137. package/dist/server/middleware.js +27 -0
  138. package/dist/server/upload-ingest.d.ts +56 -0
  139. package/dist/server/upload-ingest.js +276 -0
  140. package/dist/server/upload-paths.d.ts +31 -0
  141. package/dist/server/upload-paths.js +51 -0
  142. package/dist/server/upload-sweep.d.ts +20 -0
  143. package/dist/server/upload-sweep.js +57 -0
  144. package/dist/storage/branch-index.d.ts +52 -0
  145. package/dist/storage/branch-index.js +65 -0
  146. package/dist/storage/git.d.ts +11 -0
  147. package/dist/storage/git.js +28 -0
  148. package/dist/storage/parse-cache.d.ts +22 -1
  149. package/dist/storage/parse-cache.js +32 -10
  150. package/dist/storage/repo-manager.d.ts +122 -10
  151. package/dist/storage/repo-manager.js +162 -21
  152. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +42 -8
  153. package/hooks/claude/gitnexus-hook.cjs +36 -8
  154. package/hooks/claude/hook-db-lock-probe.cjs +123 -2
  155. package/package.json +4 -2
  156. package/scripts/assert-publish-grammar-coverage.cjs +31 -0
  157. package/scripts/build-tree-sitter-grammars.cjs +16 -10
  158. package/skills/gitnexus-guide.md +11 -0
  159. package/vendor/tree-sitter-c/package.json +1 -1
  160. package/vendor/tree-sitter-dart/package.json +1 -1
  161. package/vendor/tree-sitter-kotlin/package.json +1 -1
  162. package/vendor/tree-sitter-proto/package.json +1 -1
  163. package/vendor/tree-sitter-swift/package.json +1 -1
  164. package/web/assets/{agent-CKPMqImC.js → agent-ay4LD70X.js} +1 -1
  165. package/web/assets/{architectureDiagram-UL44E2DR-D1EXI0zA.js → architectureDiagram-UL44E2DR-Dc-viYhd.js} +1 -1
  166. package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-4rpojOyj.js} +1 -1
  167. package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-BnOy944n.js} +1 -1
  168. package/web/assets/{classDiagram-KGZ6W3CR-Bv93af_b.js → classDiagram-KGZ6W3CR-Bsgpy98Q.js} +1 -1
  169. package/web/assets/{classDiagram-v2-72OJOZXJ-CLfEqHUa.js → classDiagram-v2-72OJOZXJ-D5atDGjc.js} +1 -1
  170. package/web/assets/{diagram-3NCE3AQN-BqAtKUpW.js → diagram-3NCE3AQN-Cz1OEMVi.js} +1 -1
  171. package/web/assets/{diagram-GF46GFSD-CaBG6n6o.js → diagram-GF46GFSD-CWYwzfP8.js} +1 -1
  172. package/web/assets/{diagram-QXG6HAR7-CZ-O3rcV.js → diagram-QXG6HAR7-DtBObr8L.js} +1 -1
  173. package/web/assets/{diagram-WEQXMOUZ-P4lSL4GH.js → diagram-WEQXMOUZ-BUV44Ov_.js} +1 -1
  174. package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-Ds9s-sRF.js} +1 -1
  175. package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-CupStHQb.js} +1 -1
  176. package/web/assets/index-COMMmbxW.css +2 -0
  177. package/web/assets/{index-CG6q8eTs.js → index-Do8AE5yF.js} +85 -85
  178. package/web/assets/{infoDiagram-3YFTVSEB-DmLICZx1.js → infoDiagram-3YFTVSEB-DPTXSbgu.js} +1 -1
  179. package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CeK-Qhet.js} +1 -1
  180. package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-D4tx6QQ-.js} +1 -1
  181. package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-Bcto_kGa.js} +1 -1
  182. package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-DPIeBTG6.js} +1 -1
  183. package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-DDGXjVKO.js} +1 -1
  184. package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-BzDqpcVW.js} +1 -1
  185. package/web/assets/{stateDiagram-7D4R322I-CB2nABwH.js → stateDiagram-7D4R322I-Bx__als3.js} +1 -1
  186. package/web/assets/{stateDiagram-v2-36443NZ5-COBGd2RL.js → stateDiagram-v2-36443NZ5-QqOh2yO-.js} +1 -1
  187. package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-C1eogTOG.js} +1 -1
  188. package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-D20F4rSW.js} +1 -1
  189. package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-BeBHg7ST.js} +1 -1
  190. package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-CdOWeoNI.js} +1 -1
  191. package/web/index.html +2 -2
  192. package/scripts/materialize-vendor-grammars.cjs +0 -97
  193. package/web/assets/index-BKWA-m7o.css +0 -2
@@ -11,6 +11,10 @@ import path from 'path';
11
11
  import os from 'os';
12
12
  import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
13
13
  import { logger } from '../core/logger.js';
14
+ import { branchSlug, BRANCHES_DIR, resolveBranchPlacement, } from './branch-index.js';
15
+ // Re-export the #2106 branch primitives (extracted to branch-index.ts, R10) so
16
+ // existing `repo-manager` import sites and tests keep working unchanged.
17
+ export { branchSlug, resolveBranchPlacement };
14
18
  /**
15
19
  * Normalise a repo path for registry comparison across platforms
16
20
  * (#664 review feedback from @evander-wang).
@@ -64,14 +68,21 @@ export const getStoragePath = (repoPath) => {
64
68
  return path.join(path.resolve(repoPath), GITNEXUS_DIR);
65
69
  };
66
70
  /**
67
- * Get paths to key storage files
71
+ * Get paths to key storage files.
72
+ *
73
+ * `storagePath` is ALWAYS the flat `<repo>/.gitnexus` — content-addressed
74
+ * caches (`parse-cache/`, `parsedfile-store/`) live there and are shared
75
+ * across branches (#2106 KTD7). When `branch` is provided, only `lbugPath` and
76
+ * `metaPath` are scoped under `branches/<slug>/`; the flat call (no `branch`)
77
+ * returns byte-identical paths to the pre-multi-branch behavior.
68
78
  */
69
- export const getStoragePaths = (repoPath) => {
79
+ export const getStoragePaths = (repoPath, branch) => {
70
80
  const storagePath = getStoragePath(repoPath);
81
+ const baseDir = branch ? path.join(storagePath, BRANCHES_DIR, branchSlug(branch)) : storagePath;
71
82
  return {
72
83
  storagePath,
73
- lbugPath: path.join(storagePath, 'lbug'),
74
- metaPath: path.join(storagePath, 'meta.json'),
84
+ lbugPath: path.join(baseDir, 'lbug'),
85
+ metaPath: path.join(baseDir, 'meta.json'),
75
86
  };
76
87
  };
77
88
  /**
@@ -310,7 +321,13 @@ export const readRegistry = async () => {
310
321
  const writeRegistry = async (entries) => {
311
322
  const dir = getGlobalDir();
312
323
  await fs.mkdir(dir, { recursive: true });
313
- await fs.writeFile(getGlobalRegistryPath(), JSON.stringify(entries, null, 2), 'utf-8');
324
+ // Atomic tmp+rename (mirrors saveMeta): a crash mid-write can never leave a
325
+ // truncated/half-written registry.json that the next load would treat as
326
+ // empty and silently drop every registered repo (#2106 R9).
327
+ const target = getGlobalRegistryPath();
328
+ const tmp = `${target}.tmp`;
329
+ await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf-8');
330
+ await fs.rename(tmp, target);
314
331
  };
315
332
  /**
316
333
  * Thrown by {@link registerRepo} when a requested name is already in
@@ -466,22 +483,89 @@ export const registerRepo = async (repoPath, meta, opts) => {
466
483
  throw new RegistryNameCollisionError(name, collidingEntry.path, resolved);
467
484
  }
468
485
  }
469
- const entry = {
470
- name,
471
- path: resolved,
472
- storagePath,
473
- indexedAt: meta.indexedAt,
474
- lastCommit: meta.lastCommit,
475
- remoteUrl: meta.remoteUrl,
476
- stats: meta.stats,
477
- };
478
- if (existingIdx >= 0) {
479
- entries[existingIdx] = entry;
486
+ // This run's branch summary (non-primary runs only); hoisted so the
487
+ // re-read-before-write merge below can re-apply it against a fresh snapshot.
488
+ const summary = opts?.branch
489
+ ? {
490
+ branch: opts.branch,
491
+ indexedAt: meta.indexedAt,
492
+ lastCommit: meta.lastCommit,
493
+ stats: meta.stats,
494
+ }
495
+ : null;
496
+ let entry;
497
+ if (summary) {
498
+ // Non-primary branch run (#2106): keep the primary's top-level fields and
499
+ // upsert this branch into branches[]. One entry per path is preserved.
500
+ // When the registry entry is missing (lost/rebuilt registry.json), rebuild
501
+ // the primary top-level from the FLAT meta.json rather than this branch's
502
+ // meta, so `--branch <primary>` can still resolve (#2106 review).
503
+ const flatMeta = existing ? null : await loadMeta(storagePath);
504
+ const base = existing ?? {
505
+ name,
506
+ path: resolved,
507
+ storagePath,
508
+ indexedAt: flatMeta?.indexedAt ?? meta.indexedAt,
509
+ lastCommit: flatMeta?.lastCommit ?? meta.lastCommit,
510
+ remoteUrl: flatMeta?.remoteUrl ?? meta.remoteUrl,
511
+ stats: flatMeta?.stats ?? meta.stats,
512
+ ...(flatMeta?.branch ? { branch: flatMeta.branch } : {}),
513
+ };
514
+ const branches = (base.branches ?? []).filter((b) => b.branch !== summary.branch);
515
+ branches.push(summary);
516
+ entry = { ...base, name, branches };
480
517
  }
481
518
  else {
482
- entries.push(entry);
519
+ // Primary/flat run: refresh top-level fields, preserve any branch summaries
520
+ // already recorded for this path so a primary re-analyze does not drop them.
521
+ entry = {
522
+ name,
523
+ path: resolved,
524
+ storagePath,
525
+ indexedAt: meta.indexedAt,
526
+ lastCommit: meta.lastCommit,
527
+ remoteUrl: meta.remoteUrl,
528
+ stats: meta.stats,
529
+ ...(meta.branch ? { branch: meta.branch } : {}),
530
+ ...(existing?.branches ? { branches: existing.branches } : {}),
531
+ };
532
+ }
533
+ // Re-read immediately before writing to narrow the lost-update window (#2106
534
+ // R9): re-derive THIS run's delta against the FRESHEST snapshot so a
535
+ // concurrent change to the OTHER axis (a branch upsert vs a primary refresh)
536
+ // survives instead of being clobbered by a stale entry-time view.
537
+ const fresh = await readRegistry();
538
+ const freshIdx = fresh.findIndex((e) => {
539
+ const a = canonicalizePath(e.path);
540
+ return process.platform === 'win32'
541
+ ? a.toLowerCase() === canonicalInput.toLowerCase()
542
+ : a === canonicalInput;
543
+ });
544
+ const freshExisting = freshIdx >= 0 ? fresh[freshIdx] : null;
545
+ let merged;
546
+ if (summary) {
547
+ // Branch run: keep the FRESH top-level + branches, just upsert our summary.
548
+ const base = freshExisting ?? entry;
549
+ const branches = (base.branches ?? []).filter((b) => b.branch !== summary.branch);
550
+ branches.push(summary);
551
+ merged = { ...base, name, branches };
483
552
  }
484
- await writeRegistry(entries);
553
+ else {
554
+ // Primary run: apply our refreshed top-level, but defer to the FRESH
555
+ // branches[] (a concurrent branch upsert or `clean --branch` wins).
556
+ merged = { ...entry };
557
+ if (freshExisting?.branches)
558
+ merged.branches = freshExisting.branches;
559
+ else
560
+ delete merged.branches;
561
+ }
562
+ if (freshIdx >= 0) {
563
+ fresh[freshIdx] = merged;
564
+ }
565
+ else {
566
+ fresh.push(merged);
567
+ }
568
+ await writeRegistry(fresh);
485
569
  return name;
486
570
  };
487
571
  /**
@@ -500,6 +584,36 @@ export const unregisterRepo = async (repoPath) => {
500
584
  const filtered = entries.filter((e) => !matches(canonicalizePath(e.path), resolved));
501
585
  await writeRegistry(filtered);
502
586
  };
587
+ /**
588
+ * Remove a single non-primary branch's summary from a repo's registry entry
589
+ * (#2106 R7). Called by `gitnexus clean --branch`. Returns `true` when a
590
+ * matching `branches[]` summary was found and removed; `false` otherwise (so
591
+ * the CLI can report "no such indexed branch" without crashing). The top-level
592
+ * primary entry is left intact; an empty `branches[]` is dropped to keep the
593
+ * registry shape legacy-clean.
594
+ */
595
+ export const removeBranchIndex = async (repoPath, branch) => {
596
+ const resolved = canonicalizePath(repoPath);
597
+ const matches = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
598
+ const entries = await readRegistry();
599
+ const idx = entries.findIndex((e) => matches(canonicalizePath(e.path), resolved));
600
+ if (idx < 0)
601
+ return false;
602
+ const entry = entries[idx];
603
+ const before = entry.branches?.length ?? 0;
604
+ if (!entry.branches || before === 0)
605
+ return false;
606
+ const remaining = entry.branches.filter((b) => b.branch !== branch);
607
+ if (remaining.length === before)
608
+ return false; // branch not recorded
609
+ if (remaining.length > 0)
610
+ entry.branches = remaining;
611
+ else
612
+ delete entry.branches;
613
+ entries[idx] = entry;
614
+ await writeRegistry(entries);
615
+ return true;
616
+ };
503
617
  /**
504
618
  * Thrown by {@link resolveRegistryEntry} when no registered repo matches
505
619
  * the caller's target string (by alias, basename, remote-inferred name,
@@ -748,7 +862,14 @@ export const resolveRegistryEntry = (entries, target) => {
748
862
  };
749
863
  /**
750
864
  * List all registered repos from the global registry.
751
- * Optionally validates that each entry's .gitnexus/ still exists.
865
+ *
866
+ * With `validate: true`, prunes only entries whose index is *provably* gone
867
+ * (fs.access on .gitnexus/meta.json fails with ENOENT or ENOTDIR) and persists
868
+ * the result. Entries that are merely "not provably absent" — any other
869
+ * fs.access failure (EIO/EAGAIN/EBUSY/EACCES, etc.) — are KEPT, so a transient
870
+ * I/O storm cannot wipe the registry. A kept entry is therefore "not confirmed
871
+ * present," not "confirmed present"; downstream DB opens are independently and
872
+ * lazily guarded.
752
873
  */
753
874
  export const listRegisteredRepos = async (opts) => {
754
875
  const entries = await readRegistry();
@@ -761,8 +882,28 @@ export const listRegisteredRepos = async (opts) => {
761
882
  await fs.access(path.join(entry.storagePath, 'meta.json'));
762
883
  valid.push(entry);
763
884
  }
764
- catch {
765
- // Index no longer exists skip
885
+ catch (err) {
886
+ // Prune ONLY when the index is provably gone: ENOENT (file absent) or
887
+ // ENOTDIR (a path component is no longer a directory). Every other
888
+ // fs.access failure keeps the entry, because the file may well still
889
+ // exist and we must not wipe the registry on a transient I/O storm
890
+ // (EIO/EAGAIN/EBUSY under swap pressure, NFS hiccups, etc.).
891
+ //
892
+ // Note: some kept codes are NOT necessarily transient — EACCES, for
893
+ // example, can be permanent (a chmod'd directory). Keeping is still the
894
+ // correct conservative choice: a stale-but-kept entry is harmless (DB
895
+ // opens are lazily guarded) and removable via `gitnexus remove`, whereas
896
+ // an over-eager prune destroys data. When in doubt, keep.
897
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
898
+ // Index genuinely removed — safe to prune
899
+ }
900
+ else {
901
+ // Not provably absent — keep entry to prevent mass registry wipe.
902
+ // Warn so an I/O storm becomes observable instead of silently
903
+ // keeping (or, pre-fix, silently wiping) entries.
904
+ logger.warn({ name: entry.name, storagePath: entry.storagePath, code: err?.code }, 'Keeping registry entry despite fs.access failure (not provably absent); not pruning to avoid mass registry wipe.');
905
+ valid.push(entry);
906
+ }
766
907
  }
767
908
  }
768
909
  // If we pruned any entries, save the cleaned registry
@@ -91,10 +91,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
91
91
  return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
92
92
  }
93
93
 
94
+ /**
95
+ * Whether opt-in diagnostics should be written to the hook's stderr. Strict
96
+ * hook runners validate hook output, so normal, non-error skip paths must stay
97
+ * silent unless the operator explicitly asks for diagnostics via GITNEXUS_DEBUG.
98
+ * See issue #1913.
99
+ */
100
+ function isDebugEnabled() {
101
+ return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
102
+ }
103
+
94
104
  function extractAugmentContext(stderr) {
95
105
  const output = (stderr || '').trim();
96
106
  const marker = output.indexOf('[GitNexus]');
97
- const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
107
+ const debug = isDebugEnabled();
98
108
  if (debug && output.length > 0) {
99
109
  // Emit the FULL discarded prefix (everything before the marker, or all of
100
110
  // it when no marker is present) so suppressed diagnostics — LadybugDB lock
@@ -258,8 +268,14 @@ function buildAfterToolContext(input) {
258
268
  if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
259
269
  const hint = buildStaleIndexHint(gitNexusDir, cwd);
260
270
  if (hint) {
261
- process.stderr.write(`${hint}\n`);
271
+ // The hint always reaches the agent via additionalContext (parts). Mirror
272
+ // it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
273
+ // hook runners see no unexpected output on this normal path (#1913). The
274
+ // claude hook never mirrored this to stderr — this aligns the two adapters.
262
275
  parts.push(hint);
276
+ if (isDebugEnabled()) {
277
+ process.stderr.write(`${hint}\n`);
278
+ }
263
279
  }
264
280
  }
265
281
  }
@@ -268,14 +284,32 @@ function buildAfterToolContext(input) {
268
284
  }
269
285
 
270
286
  function runAugment(gitNexusDir, cwd, pattern) {
271
- if (hasGitNexusServerOwner(gitNexusDir)) {
272
- process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
287
+ // Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
288
+ // itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
289
+ // as the augment, or concurrent sessions fan out unbounded probe
290
+ // subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in
291
+ // buildAfterToolContext before this — moving the acquire any earlier would
292
+ // churn slot files on tool calls that never probe.
293
+ const release = acquireHookSlot(gitNexusDir);
294
+ if (!release) {
295
+ // Normal skip path: all per-repo hook slots are held by concurrent
296
+ // sessions. Stay silent for strict hook runners (issue #1913); surface
297
+ // the reason only under GITNEXUS_DEBUG.
298
+ if (isDebugEnabled()) {
299
+ process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
300
+ }
273
301
  return '';
274
302
  }
275
- const release = acquireHookSlot(gitNexusDir);
276
- if (!release) return '';
277
- const cliPath = resolveCliPath();
278
303
  try {
304
+ if (hasGitNexusServerOwner(gitNexusDir)) {
305
+ // Normal skip path: the MCP server owns the DB. Stay silent for strict
306
+ // hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
307
+ if (isDebugEnabled()) {
308
+ process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
309
+ }
310
+ return '';
311
+ }
312
+ const cliPath = resolveCliPath();
279
313
  const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
280
314
  if (!child.error && child.status === 0) {
281
315
  return extractAugmentContext(child.stderr || '');
@@ -338,7 +372,7 @@ function main() {
338
372
  const handler = handlers[input.hook_event_name || ''];
339
373
  if (handler) handler(input);
340
374
  } catch (err) {
341
- if (process.env.GITNEXUS_DEBUG) {
375
+ if (isDebugEnabled()) {
342
376
  console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200));
343
377
  }
344
378
  }
@@ -110,10 +110,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
110
110
  return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
111
111
  }
112
112
 
113
+ /**
114
+ * Whether opt-in diagnostics should be written to the hook's stderr. Strict
115
+ * hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
116
+ * non-error skip paths must stay silent unless the operator explicitly asks
117
+ * for diagnostics via GITNEXUS_DEBUG. See issue #1913.
118
+ */
119
+ function isDebugEnabled() {
120
+ return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
121
+ }
122
+
113
123
  function extractAugmentContext(stderr) {
114
124
  const output = (stderr || '').trim();
115
125
  const marker = output.indexOf('[GitNexus]');
116
- const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
126
+ const debug = isDebugEnabled();
117
127
  if (debug && output.length > 0) {
118
128
  // Emit the FULL discarded prefix (everything before the marker, or all of
119
129
  // it when no marker is present) so suppressed diagnostics — KuzuDB lock
@@ -249,17 +259,35 @@ function handlePreToolUse(input) {
249
259
 
250
260
  const pattern = extractPattern(toolName, toolInput);
251
261
  if (!pattern || pattern.length < 3) return;
252
- if (hasGitNexusServerOwner(gitNexusDir)) {
253
- process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
254
- return;
255
- }
256
262
 
263
+ // Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
264
+ // itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
265
+ // as the augment, or concurrent sessions fan out unbounded probe
266
+ // subprocesses. Keep the acquire right after the cheap guards above —
267
+ // moving it earlier would churn slot files on tool calls that never probe.
257
268
  const release = acquireHookSlot(gitNexusDir);
258
- if (!release) return;
269
+ if (!release) {
270
+ // Normal skip path: all per-repo hook slots are held by concurrent
271
+ // sessions. Stay silent for strict hook runners (issue #1913); surface
272
+ // the reason only when diagnostics are explicitly requested.
273
+ if (isDebugEnabled()) {
274
+ process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
275
+ }
276
+ return;
277
+ }
259
278
 
260
- const cliPath = resolveCliPath();
261
279
  let result = '';
262
280
  try {
281
+ if (hasGitNexusServerOwner(gitNexusDir)) {
282
+ // Normal skip path: the MCP server owns the DB, so the CLI augment would
283
+ // contend on the lock. Stay silent for strict hook runners (issue #1913);
284
+ // surface the reason only when diagnostics are explicitly requested.
285
+ if (isDebugEnabled()) {
286
+ process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
287
+ }
288
+ return;
289
+ }
290
+ const cliPath = resolveCliPath();
263
291
  const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
264
292
  if (!child.error && child.status === 0) {
265
293
  result = extractAugmentContext(child.stderr || '');
@@ -361,7 +389,7 @@ function main() {
361
389
  const handler = handlers[input.hook_event_name || ''];
362
390
  if (handler) handler(input);
363
391
  } catch (err) {
364
- if (process.env.GITNEXUS_DEBUG) {
392
+ if (isDebugEnabled()) {
365
393
  console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
366
394
  }
367
395
  }
@@ -11,6 +11,22 @@
11
11
  *
12
12
  * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
13
13
  * PowerShell ETIMEDOUT (Windows), matching the hook contract.
14
+ *
15
+ * Unix subprocess containment contract (#2163):
16
+ * - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
17
+ * wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
18
+ * is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
19
+ * survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
20
+ * it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
21
+ * - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
22
+ * wrapper off deterministically; any other value is adopted only when it
23
+ * exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
24
+ * THROUGH to the built-in candidate list (first self-test pass wins), so
25
+ * no malformed value of any shape can silently disable orphan containment.
26
+ * - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
27
+ * ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
28
+ * A probe before that first query is therefore always false — a known,
29
+ * pre-existing race, not a bug in this probe.
14
30
  */
15
31
 
16
32
  const fs = require('fs');
@@ -46,6 +62,80 @@ function resolveHookBinary(tool) {
46
62
  return tool;
47
63
  }
48
64
 
65
+ // Sentinel:
66
+ // undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
67
+ // string = self-tested coreutils timeout/gtimeout path (use as wrapper)
68
+ // null = no usable wrapper (disabled, none found, or self-test failed)
69
+ let unixGuardTimeoutCache;
70
+
71
+ /**
72
+ * Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
73
+ * (#2163). Dead code on Windows (the win32 dispatch returns earlier).
74
+ *
75
+ * GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
76
+ * wrapper off; any other value is only a CANDIDATE — an existing file path
77
+ * is tried first, but it must pass the `-k` self-test to be adopted. On any
78
+ * failure (non-existent path, directory, non-executable file, wrapper
79
+ * without `-k` support, …) resolution falls through to the built-in
80
+ * candidates below, tried in order, first self-test pass wins. This is
81
+ * strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
82
+ * GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
83
+ * value of ANY shape can silently disable orphan containment.
84
+ *
85
+ * Lazy self-test: candidates are probed only when the lsof/ps fallback is
86
+ * first reached, and the result is memoized. A candidate is adopted only
87
+ * when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
88
+ * not support the coreutils `-k` flag — busybox <1.34, toybox, broken
89
+ * symlinks — which would otherwise exit with a usage error without ever
90
+ * running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
91
+ * into fail-open (#1492 regression). Only when EVERY candidate fails does
92
+ * the probe fall back to the unwrapped status quo (memoized null).
93
+ * busybox ≥1.34 passes the test and is fully usable (capability, not
94
+ * identity, decides).
95
+ */
96
+ function passesGuardSelfTest(guard) {
97
+ try {
98
+ const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
99
+ encoding: 'utf-8',
100
+ timeout: 3000,
101
+ stdio: ['ignore', 'ignore', 'ignore'],
102
+ windowsHide: true,
103
+ });
104
+ return !selfTest.error && selfTest.status === 0;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function resolveUnixGuardTimeout() {
111
+ if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
112
+ unixGuardTimeoutCache = null;
113
+ const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
114
+ const trimmed = fromEnv ? String(fromEnv).trim() : '';
115
+ if (trimmed === 'disabled') return unixGuardTimeoutCache;
116
+ const candidates = [];
117
+ if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
118
+ for (const builtin of [
119
+ '/usr/bin/timeout',
120
+ '/bin/timeout',
121
+ '/opt/homebrew/bin/gtimeout',
122
+ '/usr/local/bin/gtimeout',
123
+ ]) {
124
+ try {
125
+ if (fs.existsSync(builtin)) candidates.push(builtin);
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ }
130
+ for (const candidate of candidates) {
131
+ if (passesGuardSelfTest(candidate)) {
132
+ unixGuardTimeoutCache = candidate;
133
+ break;
134
+ }
135
+ }
136
+ return unixGuardTimeoutCache;
137
+ }
138
+
49
139
  function resolveWindowsPowerShellPath() {
50
140
  const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
51
141
  if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
@@ -188,20 +278,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
188
278
  }
189
279
 
190
280
  function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
281
+ const guard = resolveUnixGuardTimeout();
191
282
  const lsofPath = resolveHookBinary('lsof');
192
- const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
283
+ // The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
284
+ // SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
285
+ // SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
286
+ // fail-closed contract are untouched. The wrapper only matters once this
287
+ // hook process has been SIGKILLed and can no longer deliver that SIGTERM.
288
+ const [lsofCmd, lsofArgs] = guard
289
+ ? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
290
+ : [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
291
+ const lsof = spawnSync(lsofCmd, lsofArgs, {
193
292
  encoding: 'utf-8',
194
293
  timeout: 1000,
195
294
  stdio: ['ignore', 'pipe', 'ignore'],
196
295
  windowsHide: true,
197
296
  });
198
297
  if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
298
+ // Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
299
+ // result shapes, verified against coreutils 9.1:
300
+ // - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
301
+ // SELF-RAISES the signal, so spawnSync reports {status: null, signal}
302
+ // with no .error (spawnSync's own ETIMEDOUT was handled above). The
303
+ // same shape appears when this hook is frozen >2s (SIGSTOP, laptop
304
+ // suspend) and the guard expires while it sleeps. By construction, a
305
+ // guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
306
+ // is a budget/kill outcome.
307
+ // - 124: budget expired and the child exited after the plain SIGTERM.
308
+ // - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
309
+ // or a child SIGKILLed externally (e.g. the OOM killer).
310
+ if (guard && lsof.status === null && lsof.signal) return true;
311
+ if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
199
312
 
200
313
  const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
201
314
  const psPath = resolveHookBinary('ps');
202
315
  for (const pid of pids) {
203
316
  if (Number(pid) === myPid) continue;
204
- const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
317
+ const [psCmd, psArgs] = guard
318
+ ? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
319
+ : [psPath, ['-p', pid, '-o', 'command=']];
320
+ const ps = spawnSync(psCmd, psArgs, {
205
321
  encoding: 'utf-8',
206
322
  timeout: 500,
207
323
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -211,6 +327,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
211
327
  if (ps.error.code === 'ETIMEDOUT') return true;
212
328
  continue;
213
329
  }
330
+ // Same guard-mediated-death mapping as the lsof call above (signal-death
331
+ // from the -k escalation or a frozen hook; 124 budget expiry; 137 only
332
+ // for exit-code-propagating wrappers / external SIGKILL).
333
+ if (guard && ps.status === null && ps.signal) return true;
334
+ if (guard && (ps.status === 124 || ps.status === 137)) return true;
214
335
  if (isGitNexusServerCommand(ps.stdout || '')) return true;
215
336
  }
216
337
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.2",
3
+ "version": "1.6.8-rc.21",
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",
@@ -49,7 +49,7 @@
49
49
  "test:watch": "vitest",
50
50
  "test:coverage": "vitest run --coverage",
51
51
  "test:cross-platform": "tsx scripts/run-cross-platform.ts",
52
- "postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-grammars.cjs",
52
+ "postinstall": "node scripts/build-tree-sitter-grammars.cjs",
53
53
  "assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
54
54
  "prepare": "node scripts/build.js",
55
55
  "prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js"
@@ -59,6 +59,7 @@
59
59
  "@ladybugdb/core": "^0.17.0",
60
60
  "@modelcontextprotocol/sdk": "^1.0.0",
61
61
  "@scarf/scarf": "^1.4.0",
62
+ "busboy": "^1.6.0",
62
63
  "cli-progress": "^3.12.0",
63
64
  "commander": "^14.0.3",
64
65
  "cors": "^2.8.5",
@@ -93,6 +94,7 @@
93
94
  "uuid": "^14.0.0"
94
95
  },
95
96
  "devDependencies": {
97
+ "@types/busboy": "^1.5.4",
96
98
  "@types/cli-progress": "^3.11.6",
97
99
  "@types/cors": "^2.8.17",
98
100
  "@types/express": "^5.0.6",
@@ -112,6 +112,24 @@ function findCoverageProblems({ grammars }) {
112
112
  return problems;
113
113
  }
114
114
 
115
+ /**
116
+ * Stray local source-build outputs under `vendor/<name>/build/`. These would
117
+ * ship in the tarball (`files: ["vendor"]` overrides .gitignore/.npmignore) AND
118
+ * shadow the committed prebuilds — `node-gyp-build` resolves `build/Release`
119
+ * BEFORE `prebuilds/`, so a consumer on the publisher's platform would load the
120
+ * stray (possibly stale/wrong) binding instead of the curated prebuild. The
121
+ * build dir is gitignored and only appears if a maintainer source-built locally
122
+ * (e.g. on a no-prebuild platform); refuse to publish it. (#2144 review.)
123
+ */
124
+ function findStrayBuildArtifacts(vendorDir) {
125
+ if (!fs.existsSync(vendorDir)) return [];
126
+ return fs
127
+ .readdirSync(vendorDir)
128
+ .filter((d) => /^tree-sitter-/.test(d))
129
+ .filter((d) => fs.existsSync(path.join(vendorDir, d, 'build')))
130
+ .map((d) => `vendor/${d}/build`);
131
+ }
132
+
115
133
  function collectGrammars(vendorDir, shipsVendorSource) {
116
134
  if (!fs.existsSync(vendorDir)) return [];
117
135
  return fs
@@ -141,6 +159,18 @@ function main() {
141
159
  process.exit(1);
142
160
  }
143
161
 
162
+ const stray = findStrayBuildArtifacts(vendorDir);
163
+ if (stray.length > 0) {
164
+ console.error(
165
+ '[publish-guard] Refusing to publish — stray source-build output under vendor/ would\n' +
166
+ 'ship and shadow the committed prebuilds (node-gyp-build loads build/Release before\n' +
167
+ 'prebuilds/):',
168
+ );
169
+ for (const s of stray) console.error(` - ${s}`);
170
+ console.error('\nFix: remove it before packing, e.g. `rm -rf gitnexus/vendor/*/build`.');
171
+ process.exit(1);
172
+ }
173
+
144
174
  const problems = findCoverageProblems({ grammars });
145
175
  if (problems.length > 0) {
146
176
  console.error('[publish-guard] Refusing to publish — a vendored grammar would ship unusable:');
@@ -163,6 +193,7 @@ if (require.main === module) main();
163
193
 
164
194
  module.exports = {
165
195
  findCoverageProblems,
196
+ findStrayBuildArtifacts,
166
197
  filesShipsVendorSource,
167
198
  isBuildableFromSource,
168
199
  sourceBuildSet,