gitnexus 1.6.10-rc.2 → 1.6.10-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 (219) hide show
  1. package/README.md +56 -12
  2. package/dist/cli/analyze.js +166 -5
  3. package/dist/cli/clean.js +17 -3
  4. package/dist/cli/cli-message.d.ts +1 -1
  5. package/dist/cli/doctor.d.ts +18 -0
  6. package/dist/cli/doctor.js +76 -3
  7. package/dist/cli/embeddings.d.ts +12 -0
  8. package/dist/cli/embeddings.js +49 -0
  9. package/dist/cli/help-i18n.js +4 -0
  10. package/dist/cli/i18n/en.d.ts +10 -5
  11. package/dist/cli/i18n/en.js +10 -5
  12. package/dist/cli/i18n/resources.d.ts +15 -5
  13. package/dist/cli/i18n/zh-CN.d.ts +5 -0
  14. package/dist/cli/i18n/zh-CN.js +10 -5
  15. package/dist/cli/index.js +12 -1
  16. package/dist/core/augmentation/engine.js +4 -3
  17. package/dist/core/embeddings/embedder.js +16 -2
  18. package/dist/core/embeddings/embedding-pipeline.d.ts +20 -0
  19. package/dist/core/embeddings/embedding-pipeline.js +10 -2
  20. package/dist/core/embeddings/http-client.d.ts +28 -1
  21. package/dist/core/embeddings/http-client.js +107 -15
  22. package/dist/core/embeddings/node-module-compat.d.ts +23 -0
  23. package/dist/core/embeddings/node-module-compat.js +23 -0
  24. package/dist/core/embeddings/onnxruntime-common-resolver.js +6 -3
  25. package/dist/core/embeddings/onnxruntime-node-resolver.js +31 -5
  26. package/dist/core/embeddings/runtime-install.d.ts +119 -0
  27. package/dist/core/embeddings/runtime-install.js +372 -0
  28. package/dist/core/embeddings/runtime-support.d.ts +48 -17
  29. package/dist/core/embeddings/runtime-support.js +96 -0
  30. package/dist/core/group/extractors/http-patterns/python.js +246 -108
  31. package/dist/core/group/extractors/manifest-extractor.js +4 -0
  32. package/dist/core/incremental/escalation-gate.d.ts +38 -0
  33. package/dist/core/incremental/escalation-gate.js +48 -0
  34. package/dist/core/incremental/shadow-candidates.d.ts +3 -3
  35. package/dist/core/incremental/shadow-candidates.js +3 -3
  36. package/dist/core/incremental/subgraph-extract.d.ts +2 -2
  37. package/dist/core/incremental/subgraph-extract.js +2 -2
  38. package/dist/core/ingestion/cobol/jcl-processor.js +9 -8
  39. package/dist/core/ingestion/cobol-processor.js +27 -26
  40. package/dist/core/ingestion/community-processor.d.ts +51 -1
  41. package/dist/core/ingestion/community-processor.js +316 -63
  42. package/dist/core/ingestion/emit-references.js +3 -2
  43. package/dist/core/ingestion/languages/cpp/captures.js +157 -74
  44. package/dist/core/ingestion/markdown-processor.js +3 -2
  45. package/dist/core/ingestion/parsing-processor.d.ts +3 -1
  46. package/dist/core/ingestion/parsing-processor.js +4 -0
  47. package/dist/core/ingestion/pipeline-phases/parse-impl.js +42 -0
  48. package/dist/core/ingestion/route-extractors/constant-resolver.d.ts +83 -0
  49. package/dist/core/ingestion/route-extractors/constant-resolver.js +131 -0
  50. package/dist/core/ingestion/route-extractors/python-const-resolver.d.ts +75 -0
  51. package/dist/core/ingestion/route-extractors/python-const-resolver.js +299 -0
  52. package/dist/core/ingestion/tree-sitter-queries.d.ts +1 -1
  53. package/dist/core/ingestion/tree-sitter-queries.js +12 -1
  54. package/dist/core/ingestion/utils/line-base.d.ts +20 -0
  55. package/dist/core/ingestion/utils/line-base.js +20 -0
  56. package/dist/core/ingestion/utils/symbol-labels.d.ts +21 -0
  57. package/dist/core/ingestion/utils/symbol-labels.js +45 -0
  58. package/dist/core/ingestion/workers/parse-worker.d.ts +34 -0
  59. package/dist/core/ingestion/workers/parse-worker.js +40 -5
  60. package/dist/core/ingestion/workers/result-merge.js +4 -0
  61. package/dist/core/ingestion/workers/worker-pool.d.ts +14 -0
  62. package/dist/core/ingestion/workers/worker-pool.js +85 -5
  63. package/dist/core/lbug/csv-generator.js +8 -2
  64. package/dist/core/lbug/cypher-escape.d.ts +20 -0
  65. package/dist/core/lbug/cypher-escape.js +20 -0
  66. package/dist/core/lbug/extension-load-error.d.ts +67 -0
  67. package/dist/core/lbug/extension-load-error.js +320 -0
  68. package/dist/core/lbug/extension-loader.d.ts +17 -2
  69. package/dist/core/lbug/extension-loader.js +38 -13
  70. package/dist/core/lbug/lbug-adapter.d.ts +147 -1
  71. package/dist/core/lbug/lbug-adapter.js +390 -70
  72. package/dist/core/lbug/lbug-config.d.ts +28 -0
  73. package/dist/core/lbug/lbug-config.js +114 -4
  74. package/dist/core/lbug/native-check.d.ts +22 -0
  75. package/dist/core/lbug/native-check.js +66 -0
  76. package/dist/core/lbug/pool-adapter.js +10 -1
  77. package/dist/core/lbug/sidecar-recovery.d.ts +172 -0
  78. package/dist/core/lbug/sidecar-recovery.js +418 -16
  79. package/dist/core/platform/capabilities.js +27 -1
  80. package/dist/core/run-analyze.js +470 -108
  81. package/dist/core/search/fts-indexes.d.ts +7 -0
  82. package/dist/core/search/fts-indexes.js +37 -0
  83. package/dist/core/tree-sitter/safe-parse.d.ts +8 -3
  84. package/dist/core/tree-sitter/safe-parse.js +21 -7
  85. package/dist/core/wiki/graph-queries.js +6 -5
  86. package/dist/mcp/core/embedder.js +16 -2
  87. package/dist/mcp/local/line-display.d.ts +22 -0
  88. package/dist/mcp/local/line-display.js +3 -0
  89. package/dist/mcp/local/local-backend.d.ts +14 -0
  90. package/dist/mcp/local/local-backend.js +63 -19
  91. package/dist/mcp/local/pdg-impact.d.ts +5 -3
  92. package/dist/mcp/local/pdg-impact.js +5 -2
  93. package/dist/mcp/resources.js +1 -0
  94. package/dist/mcp/tools.js +1 -1
  95. package/dist/server/analyze-launch.d.ts +6 -0
  96. package/dist/server/analyze-launch.js +82 -6
  97. package/dist/server/api.d.ts +30 -0
  98. package/dist/server/api.js +63 -21
  99. package/dist/storage/parse-cache.js +1 -1
  100. package/dist/storage/repo-manager.d.ts +65 -1
  101. package/dist/storage/repo-manager.js +18 -1
  102. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +50 -3
  103. package/hooks/claude/gitnexus-hook.cjs +59 -9
  104. package/package.json +5 -3
  105. package/scripts/cross-platform-tests.ts +43 -0
  106. package/scripts/ensure-fts.ts +32 -0
  107. package/scripts/install-duckdb-extension.mjs +85 -24
  108. package/scripts/run-cross-platform.ts +46 -6
  109. package/scripts/shard-arg.ts +30 -0
  110. package/web/assets/ProcessFlowModal-Dna7GkwO.js +22 -0
  111. package/web/assets/{agent-Dli_x7bU.js → agent-DNYDy-KA.js} +49 -49
  112. package/web/assets/architecture-7EHR7CIX-6QZW5X65-BVWFvYz2.js +1 -0
  113. package/web/assets/architectureDiagram-UL44E2DR-BU5ZRITg.js +36 -0
  114. package/web/assets/{blockDiagram-7IZFK4PR-BkWhpzWB.js → blockDiagram-7IZFK4PR-Dut1BpTV.js} +2 -2
  115. package/web/assets/{c4Diagram-Y2BXMSZH-DtBvpzWD.js → c4Diagram-Y2BXMSZH-DOXVs7-C.js} +2 -2
  116. package/web/assets/chunk-2T2R6R2M-pkw2oXHH.js +4 -0
  117. package/web/assets/chunk-2UTLFMKG-CXlhF3Pt.js +1 -0
  118. package/web/assets/chunk-3SSMPTDK-rPsRM1x0.js +321 -0
  119. package/web/assets/chunk-4R4BOZG6-Be_F0JoX.js +159 -0
  120. package/web/assets/{chunk-6764PJDD-DpIS7hxD.js → chunk-6764PJDD-C7xxk063.js} +1 -1
  121. package/web/assets/{chunk-67TQ5CYL-4sNPJU1q.js → chunk-67TQ5CYL-zxhDWgyF.js} +3 -3
  122. package/web/assets/chunk-7FYTHRHK-CsmYIswB.js +32 -0
  123. package/web/assets/chunk-7J6CGLKN-AmkN8jJN.js +10 -0
  124. package/web/assets/chunk-7W6UQGC5-pyKGssV3.js +1 -0
  125. package/web/assets/chunk-C62D2QBJ-3gdL8EKc.js +1 -0
  126. package/web/assets/chunk-CEXFNPSA-CjwtMDkc.js +1 -0
  127. package/web/assets/{chunk-INKRHTLW-Nu4ri9P-.js → chunk-INKRHTLW-D9BM9HWZ.js} +1 -1
  128. package/web/assets/chunk-J5EP6P6S-L19oz55R.js +1 -0
  129. package/web/assets/{chunk-KGFNY3KK-JGMJvKCK.js → chunk-KGFNY3KK-tD2F6wxK.js} +1 -1
  130. package/web/assets/chunk-KGYTTC2M-zm6n242I.js +1 -0
  131. package/web/assets/{chunk-KRXBNO2N-BW5o0KUN.js → chunk-KRXBNO2N-C-UNuKKf.js} +1 -1
  132. package/web/assets/{chunk-LCXTWHL2-CfLLCywY.js → chunk-LCXTWHL2-Ba9JK-6F.js} +2 -2
  133. package/web/assets/{chunk-LII3EMHJ-D-jm-dLa.js → chunk-LII3EMHJ-EGx-eBIu.js} +1 -1
  134. package/web/assets/chunk-LRIF4GLE-C3_PqUBq.js +1 -0
  135. package/web/assets/{chunk-QA3QBVWF-BgRW3SXd.js → chunk-QA3QBVWF-D4cSJBf6.js} +1 -1
  136. package/web/assets/chunk-RERM46MO-B5dAdWpP.js +1 -0
  137. package/web/assets/{chunk-RG4AUYOV-CqWzWsV3.js → chunk-RG4AUYOV-OqBBMwuQ.js} +4 -4
  138. package/web/assets/chunk-RKZBBQEN-MyIQbGgD.js +1 -0
  139. package/web/assets/chunk-RLI5ZMPA-CkXZ0y0U.js +1 -0
  140. package/web/assets/{chunk-T2UQINTJ-DSBqkzH7.js → chunk-T2UQINTJ-BJusEiGe.js} +1 -1
  141. package/web/assets/chunk-UP6H54XL-BG-n7qDp.js +1 -0
  142. package/web/assets/chunk-UXSXWOXI-CW1_r5cS.js +1 -0
  143. package/web/assets/{chunk-UY5QBCOK-DyF0vyGd.js → chunk-UY5QBCOK-BCpUlARQ.js} +1 -1
  144. package/web/assets/chunk-VU6ZFW4Y-BeXVk45_.js +2 -0
  145. package/web/assets/{chunk-W44A43WB-JqMLIpR0.js → chunk-W44A43WB-BN8EQc_u.js} +1 -1
  146. package/web/assets/{chunk-ZXARS5L4-CxN8oiwI.js → chunk-ZXARS5L4-BI_qR2Nm.js} +1 -1
  147. package/web/assets/classDiagram-KGZ6W3CR-D9zSIg0x.js +1 -0
  148. package/web/assets/classDiagram-v2-72OJOZXJ-D9zSIg0x.js +1 -0
  149. package/web/assets/cose-bilkent-UX7MHV2Q-BmnZ7Mvj.js +1 -0
  150. package/web/assets/dagre-ND4H6XIP-CpcV5kbl.js +4 -0
  151. package/web/assets/diagram-3NCE3AQN-6SMxBRpj.js +43 -0
  152. package/web/assets/diagram-GF46GFSD-BZEdNcRZ.js +24 -0
  153. package/web/assets/{diagram-HNR7UZ2L-DFCfd5LI.js → diagram-HNR7UZ2L-DxKW2a2Y.js} +2 -2
  154. package/web/assets/diagram-QXG6HAR7-HfTHcXX_.js +24 -0
  155. package/web/assets/diagram-WEQXMOUZ-CyijUS8r.js +10 -0
  156. package/web/assets/{erDiagram-L5TCEMPS-8X1y6R4Y.js → erDiagram-L5TCEMPS-D0fvpWEX.js} +3 -3
  157. package/web/assets/eventmodeling-FCH6USID-MREXMVOE-CgFBM7e-.js +1 -0
  158. package/web/assets/flowDiagram-H6V6AXG4-Bvr9bbhk.js +162 -0
  159. package/web/assets/{ganttDiagram-JCBTUEKG-D3iL2Aet.js → ganttDiagram-JCBTUEKG-TzODrLzv.js} +2 -2
  160. package/web/assets/gitGraph-WXDBUCRP-R675I2BI-DCJv7SII.js +1 -0
  161. package/web/assets/gitGraphDiagram-S2ZK5IYY-ONQSwJIY.js +106 -0
  162. package/web/assets/index-B4eB4dNZ.js +575 -0
  163. package/web/assets/index-CX_fADmQ.css +2 -0
  164. package/web/assets/info-J43DQDTF-KCYPFFUO-BfNy-9Nd.js +1 -0
  165. package/web/assets/infoDiagram-3YFTVSEB-DIPVUIQ6.js +2 -0
  166. package/web/assets/{ishikawaDiagram-BNXS4ZKH-DfYpxnE5.js → ishikawaDiagram-BNXS4ZKH-BLjivjXL.js} +3 -3
  167. package/web/assets/{journeyDiagram-M6C3CM3L-CvArszLo.js → journeyDiagram-M6C3CM3L-Cetgs3NH.js} +3 -3
  168. package/web/assets/{kanban-definition-75IXJCU3-CpfVLLf9.js → kanban-definition-75IXJCU3-DMlCaIpf.js} +5 -5
  169. package/web/assets/{katex-K3KEBU37-CTc5BslQ.js → katex-K3KEBU37-qRQBy-EZ.js} +2 -2
  170. package/web/assets/{mindmap-definition-2TDM6QVE-DypvbOMQ.js → mindmap-definition-2TDM6QVE-B4GbXQBc.js} +3 -3
  171. package/web/assets/packet-YPE3B663-LP52Z2RK-D4qkeG9k.js +1 -0
  172. package/web/assets/pie-LRSECV5Y-TCRJHUBD-B_yMKyQa.js +1 -0
  173. package/web/assets/{pieDiagram-CU6KROY3-s27EFWgP.js → pieDiagram-CU6KROY3-D3YC_xyg.js} +2 -2
  174. package/web/assets/{quadrantDiagram-VICAPDV7-yXSZ2lLz.js → quadrantDiagram-VICAPDV7-B1TQnlm0.js} +2 -2
  175. package/web/assets/radar-GUYGQ44K-RDLRG3WG-DeD6yN_0.js +1 -0
  176. package/web/assets/{requirementDiagram-JXO7QTGE-CWhqInuf.js → requirementDiagram-JXO7QTGE-f8wczLQf.js} +3 -3
  177. package/web/assets/rolldown-runtime-QTnfLwEv.js +1 -0
  178. package/web/assets/{sankeyDiagram-URQDO5SZ-1qEjlFIB.js → sankeyDiagram-URQDO5SZ-BTxzG5j0.js} +2 -2
  179. package/web/assets/{sequenceDiagram-VS2MUI6T-RyTlZ5cR.js → sequenceDiagram-VS2MUI6T-DuamM-IR.js} +5 -5
  180. package/web/assets/src-BNCXICdA.js +1 -0
  181. package/web/assets/stateDiagram-7D4R322I-Ch2pEUcw.js +1 -0
  182. package/web/assets/stateDiagram-v2-36443NZ5-7ANuoZLL.js +1 -0
  183. package/web/assets/{timeline-definition-O6YCAMPW-DrrqLYAf.js → timeline-definition-O6YCAMPW-07BR8R8G.js} +3 -3
  184. package/web/assets/treeView-BLDUP644-QA4HXRO3-CDuSYnd4.js +1 -0
  185. package/web/assets/treemap-LRROVOQU-LLAWBHMP-imazucyJ.js +1 -0
  186. package/web/assets/{vennDiagram-MWXL3ELB-B0RkWAim.js → vennDiagram-MWXL3ELB-BQX2ossk.js} +4 -4
  187. package/web/assets/wardley-L42UT6IY-5TKZOOLJ-DqrT9tB8.js +1 -0
  188. package/web/assets/{wardleyDiagram-CUQ6CDDI-boUxiQ5F.js → wardleyDiagram-CUQ6CDDI-CGIJBBiK.js} +3 -3
  189. package/web/assets/{xychartDiagram-N2JHSOCM-BOLm0eNm.js → xychartDiagram-N2JHSOCM-B3HNMuWp.js} +4 -4
  190. package/web/index.html +17 -10
  191. package/web/assets/architecture-7EHR7CIX-6QZW5X65-DxJw65fT.js +0 -1
  192. package/web/assets/architectureDiagram-UL44E2DR-dEkqUZN9.js +0 -36
  193. package/web/assets/chunk-3SSMPTDK-B4_etUhr.js +0 -321
  194. package/web/assets/chunk-7W6UQGC5-KyEG0HQg.js +0 -1
  195. package/web/assets/chunk-KGYTTC2M-CWC_c3H9.js +0 -161
  196. package/web/assets/classDiagram-KGZ6W3CR-CMne4tG9.js +0 -1
  197. package/web/assets/classDiagram-v2-72OJOZXJ-CMne4tG9.js +0 -1
  198. package/web/assets/cose-bilkent-UX7MHV2Q-D71wNYRJ.js +0 -1
  199. package/web/assets/dagre-ND4H6XIP-Co5rRx9X.js +0 -4
  200. package/web/assets/diagram-3NCE3AQN-DBp4O00j.js +0 -43
  201. package/web/assets/diagram-GF46GFSD-6N_8yS0i.js +0 -24
  202. package/web/assets/diagram-QXG6HAR7-Di093rFg.js +0 -24
  203. package/web/assets/diagram-WEQXMOUZ-DZWi4SUN.js +0 -10
  204. package/web/assets/eventmodeling-FCH6USID-MREXMVOE-BQm9QzEa.js +0 -1
  205. package/web/assets/flowDiagram-H6V6AXG4-CMpY8Ufn.js +0 -162
  206. package/web/assets/gitGraph-WXDBUCRP-R675I2BI-Cr_Bm2Nb.js +0 -1
  207. package/web/assets/gitGraphDiagram-S2ZK5IYY-CPIDgxGm.js +0 -106
  208. package/web/assets/index-KctnI3Vq.js +0 -635
  209. package/web/assets/index-_lgn7hs5.css +0 -2
  210. package/web/assets/info-J43DQDTF-KCYPFFUO-Cs4p2oyk.js +0 -1
  211. package/web/assets/infoDiagram-3YFTVSEB-BaLMZkUg.js +0 -2
  212. package/web/assets/packet-YPE3B663-LP52Z2RK-CKCrztD2.js +0 -1
  213. package/web/assets/pie-LRSECV5Y-TCRJHUBD-BB7pHqoD.js +0 -1
  214. package/web/assets/radar-GUYGQ44K-RDLRG3WG-DhoTOcuK.js +0 -1
  215. package/web/assets/stateDiagram-7D4R322I-DTjVfX4A.js +0 -1
  216. package/web/assets/stateDiagram-v2-36443NZ5-xY9_k9_Q.js +0 -1
  217. package/web/assets/treeView-BLDUP644-QA4HXRO3-ODehyGKL.js +0 -1
  218. package/web/assets/treemap-LRROVOQU-LLAWBHMP-BfpgC-7o.js +0 -1
  219. package/web/assets/wardley-L42UT6IY-5TKZOOLJ-CNeL6VHE.js +0 -1
@@ -10,16 +10,83 @@
10
10
  * path is resolved relative to `import.meta.url`.
11
11
  */
12
12
  import path from 'path';
13
+ import { existsSync, statSync } from 'node:fs';
13
14
  import { fork } from 'child_process';
14
15
  import { fileURLToPath, pathToFileURL } from 'url';
15
16
  import { createRequire } from 'node:module';
16
- import { getStoragePath } from '../storage/repo-manager.js';
17
+ import { canonicalizePath, getStoragePath, INDEX_METADATA_FILE, listRegisteredRepos, registryPathEquals, } from '../storage/repo-manager.js';
17
18
  import { logger } from '../core/logger.js';
18
19
  const _require = createRequire(import.meta.url);
19
20
  const MAX_WORKER_RETRIES = 2;
21
+ /**
22
+ * The worker reports `complete` over IPC before its on-disk finalization
23
+ * (LadybugDB checkpoint + native handle release + metadata write) is visible
24
+ * at `getStoragePath(targetPath)` — observed up to ~6.5s behind the IPC
25
+ * message. Opening the database inside that window is what the pre-IPC
26
+ * ordering was meant to prevent and is actively dangerous: reads fail with
27
+ * binder errors or return an empty graph, the open can quarantine the
28
+ * in-flight WAL, and the native layer racing the rewrite has crashed the
29
+ * whole server (SIGSEGV-class exit, no output) on slow CI runners.
30
+ */
31
+ const FINALIZE_SETTLE_TIMEOUT_MS = 60_000;
32
+ const FINALIZE_SETTLE_POLL_MS = 200;
33
+ /**
34
+ * Resolve once the analyzed repo's index is settled at `storagePath`: the
35
+ * LadybugDB file and metadata both exist AND were (re)written by THIS job
36
+ * (mtime >= jobStartMs — bare existence is not enough, a re-analysis leaves
37
+ * the previous index in place while it works), and no transient WAL/shadow/
38
+ * checkpoint sidecars remain (the worker's native close has finished).
39
+ *
40
+ * Never rejects. Timing out logs and proceeds (pre-gate behavior) rather
41
+ * than failing a job whose analysis genuinely succeeded — e.g. a no-op
42
+ * non-force analyze legitimately rewrites nothing.
43
+ */
44
+ /**
45
+ * Look up the analyzed repo's registered storage path. The request's
46
+ * user-provided path is used only as a comparison key; the filesystem probes
47
+ * below run against the registry's own `storagePath` — the server-owned
48
+ * record readers resolve through, and not a user-controlled value
49
+ * (CodeQL js/path-injection).
50
+ */
51
+ const registeredStoragePath = async (targetPath) => {
52
+ const target = canonicalizePath(path.resolve(targetPath));
53
+ const entries = await listRegisteredRepos();
54
+ const entry = entries.find((e) => registryPathEquals(canonicalizePath(e.path), target));
55
+ return entry?.storagePath ?? null;
56
+ };
57
+ const waitForSettledIndex = async (targetPath, jobStartMs) => {
58
+ const settled = (storagePath) => {
59
+ try {
60
+ const lbugStat = statSync(path.join(storagePath, 'lbug'));
61
+ const metaStat = statSync(path.join(storagePath, INDEX_METADATA_FILE));
62
+ return (lbugStat.mtimeMs >= jobStartMs &&
63
+ metaStat.mtimeMs >= jobStartMs &&
64
+ ['lbug.wal', 'lbug.shadow', 'lbug.wal.checkpoint'].every((f) => !existsSync(path.join(storagePath, f))));
65
+ }
66
+ catch {
67
+ return false; // not written yet
68
+ }
69
+ };
70
+ const deadline = Date.now() + FINALIZE_SETTLE_TIMEOUT_MS;
71
+ for (;;) {
72
+ // Re-resolved each round: the worker registers the repo as part of the
73
+ // finalization this gate is waiting out.
74
+ const storagePath = await registeredStoragePath(targetPath);
75
+ if (storagePath && settled(storagePath))
76
+ return;
77
+ if (Date.now() > deadline) {
78
+ logger.warn({ targetPath }, 'analyze finalization not visible after timeout; completing job anyway');
79
+ return;
80
+ }
81
+ await new Promise((resolve) => setTimeout(resolve, FINALIZE_SETTLE_POLL_MS));
82
+ }
83
+ };
20
84
  export function createLaunchAnalysisWorker(deps) {
21
- const { jobManager, backend, acquireRepoLock, releaseRepoLock } = deps;
85
+ const { jobManager, backend, acquireRepoLock, releaseRepoLock, closeDbHandle } = deps;
22
86
  return function launchAnalysisWorker(job, targetPath, opts) {
87
+ // For waitForSettledIndex: files (re)written by this job have mtimes at or
88
+ // after this instant. Taken before the fork so no worker write predates it.
89
+ const jobStartMs = Date.now();
23
90
  // Acquire shared repo lock (keyed on storagePath to match embed handler)
24
91
  const analyzeLockKey = getStoragePath(targetPath);
25
92
  const lockErr = acquireRepoLock(analyzeLockKey);
@@ -67,10 +134,17 @@ export function createLaunchAnalysisWorker(deps) {
67
134
  }
68
135
  else if (msg.type === 'complete') {
69
136
  releaseRepoLock(analyzeLockKey);
70
- // Reinitialize backend BEFORE marking complete ensures the new repo
71
- // is queryable when the client receives the SSE complete event.
72
- backend
73
- .init()
137
+ // Before marking complete: (1) wait for the worker's on-disk
138
+ // finalization to settle (see waitForSettledIndex), (2) evict the
139
+ // cached DB handle — same invalidation DELETE /api/repo performs, a
140
+ // handle opened before the rewrite reads pre-rewrite state — and
141
+ // only then (3) reinitialize the backend. This makes the ordering
142
+ // comment below true in practice: the repo is actually queryable
143
+ // when the client receives the SSE complete event.
144
+ waitForSettledIndex(targetPath, jobStartMs)
145
+ .then(() => closeDbHandle())
146
+ .catch(() => { }) // best-effort: eviction failure must not fail the job
147
+ .then(() => backend.init())
74
148
  .then(() => {
75
149
  jobManager.updateJob(job.id, { status: 'complete', repoName: msg.result.repoName });
76
150
  })
@@ -84,6 +158,8 @@ export function createLaunchAnalysisWorker(deps) {
84
158
  }
85
159
  else if (msg.type === 'error') {
86
160
  releaseRepoLock(analyzeLockKey);
161
+ // A failed (force) analyze may still have rewritten DB files first.
162
+ void closeDbHandle().catch(() => { });
87
163
  jobManager.updateJob(job.id, { status: 'failed', error: msg.message });
88
164
  }
89
165
  });
@@ -8,7 +8,9 @@
8
8
  * CORS is restricted to localhost, private/LAN networks, and the deployed site.
9
9
  */
10
10
  import express from 'express';
11
+ import { type RegistryEntry } from '../storage/repo-manager.js';
11
12
  import { type GraphNode, type GraphRelationship } from '../_shared/index.js';
13
+ import { JobManager } from './analyze-job.js';
12
14
  /**
13
15
  * Determine whether an HTTP Origin header value is allowed by CORS policy.
14
16
  *
@@ -49,6 +51,34 @@ export declare const registerWebUI: (app: express.Express, staticDir: string | n
49
51
  export declare const writeNdjsonRecord: (res: express.Response, record: GraphStreamRecord, signal?: AbortSignal) => Promise<void>;
50
52
  export declare const getNodeQuery: (table: string, includeContent: boolean) => string;
51
53
  export declare const streamGraphNdjson: (res: express.Response, includeContent?: boolean, signal?: AbortSignal) => Promise<void>;
54
+ /**
55
+ * Mount an SSE progress endpoint for a JobManager.
56
+ * Handles: initial state, terminal events, heartbeat, event IDs, client disconnect.
57
+ *
58
+ * Terminal payloads carry `repoPath` (the analyzed path) alongside the display
59
+ * `repoName` so clients can reconnect by path identity — with duplicate
60
+ * basenames, a name-only reconnect resolves to the first same-named sibling.
61
+ * Exported for unit tests that lock the wire payload shape.
62
+ */
63
+ export declare const mountSSEProgress: (app: express.Express, routePath: string, jm: JobManager) => void;
64
+ /**
65
+ * Resolve a `?repo=` request param against the registry in two tiers:
66
+ *
67
+ * 1. Path claim — any input containing a separator ('/' or '\\', which
68
+ * cover path.sep on every platform) is treated as a path claim and
69
+ * resolved by canonical registry path ONLY. A miss fails closed
70
+ * (null, never a basename fallback) so a stale or wrong path can
71
+ * never silently retarget a same-named sibling repo (#2419).
72
+ * Within this tier, only absolute or Windows-shaped ('\\') claims
73
+ * are worth canonicalizing; relative claims like 'org/name' or
74
+ * './repo' are rejected immediately WITHOUT touching the filesystem
75
+ * — canonicalizing them would run an attacker-influenced
76
+ * CWD-relative realpathSync probe on un-rate-limited GET routes,
77
+ * and no legitimate caller sends relative paths.
78
+ * 2. Name fallback — bare names (no separators) keep the legacy
79
+ * basename/name match for older callers.
80
+ */
81
+ export declare const resolveRegisteredRepoEntry: (repos: RegistryEntry[], repoName?: string) => RegistryEntry | null;
52
82
  /**
53
83
  * Handle a GET /api/file request body. Extracted from createServer's route
54
84
  * registration so it can be unit-tested without spinning up an HTTP server
@@ -12,12 +12,13 @@ import cors from 'cors';
12
12
  import path from 'path';
13
13
  import fs from 'fs/promises';
14
14
  import { createRequire } from 'node:module';
15
- import { loadMeta, listRegisteredRepos, getStoragePath } from '../storage/repo-manager.js';
15
+ import { canonicalizePath, cloneDirBelongsToEntry, loadMeta, listRegisteredRepos, getStoragePath, registryPathEquals, } from '../storage/repo-manager.js';
16
16
  import { executeQuery, executePrepared, executeWithReusedStatement, streamQuery, flushWAL, closeLbug, withLbugDb, isReadOnlyDbError, } from '../core/lbug/lbug-adapter.js';
17
17
  import { isValidQueryParams } from '../core/lbug/query-params.js';
18
18
  import { NODE_TABLES } from '../_shared/index.js';
19
19
  import { searchFTSFromLbug } from '../core/search/bm25-index.js';
20
20
  import { hybridSearch } from '../core/search/hybrid-search.js';
21
+ import { ftsDegradedWarning } from '../core/search/fts-indexes.js';
21
22
  import { LocalBackend } from '../mcp/local/local-backend.js';
22
23
  import { mountMCPEndpoints } from './mcp-http.js';
23
24
  import { fileURLToPath } from 'url';
@@ -380,8 +381,13 @@ export const streamGraphNdjson = async (res, includeContent = false, signal) =>
380
381
  /**
381
382
  * Mount an SSE progress endpoint for a JobManager.
382
383
  * Handles: initial state, terminal events, heartbeat, event IDs, client disconnect.
384
+ *
385
+ * Terminal payloads carry `repoPath` (the analyzed path) alongside the display
386
+ * `repoName` so clients can reconnect by path identity — with duplicate
387
+ * basenames, a name-only reconnect resolves to the first same-named sibling.
388
+ * Exported for unit tests that lock the wire payload shape.
383
389
  */
384
- const mountSSEProgress = (app, routePath, jm) => {
390
+ export const mountSSEProgress = (app, routePath, jm) => {
385
391
  app.get(routePath, (req, res) => {
386
392
  let jobId;
387
393
  try {
@@ -411,6 +417,7 @@ const mountSSEProgress = (app, routePath, jm) => {
411
417
  eventId++;
412
418
  res.write(`id: ${eventId}\nevent: ${job.status}\ndata: ${JSON.stringify({
413
419
  repoName: job.repoName,
420
+ repoPath: job.repoPath,
414
421
  error: job.error,
415
422
  })}\n\n`);
416
423
  res.end();
@@ -434,6 +441,7 @@ const mountSSEProgress = (app, routePath, jm) => {
434
441
  const eventJob = jm.getJob(jobId);
435
442
  res.write(`id: ${eventId}\nevent: ${progress.phase}\ndata: ${JSON.stringify({
436
443
  repoName: eventJob?.repoName,
444
+ repoPath: eventJob?.repoPath,
437
445
  error: eventJob?.error,
438
446
  })}\n\n`);
439
447
  clearInterval(heartbeat);
@@ -476,6 +484,43 @@ const requestedRepo = (req) => {
476
484
  }
477
485
  return undefined;
478
486
  };
487
+ const repoParamBasename = (repoName) => repoName.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? repoName;
488
+ /**
489
+ * Resolve a `?repo=` request param against the registry in two tiers:
490
+ *
491
+ * 1. Path claim — any input containing a separator ('/' or '\\', which
492
+ * cover path.sep on every platform) is treated as a path claim and
493
+ * resolved by canonical registry path ONLY. A miss fails closed
494
+ * (null, never a basename fallback) so a stale or wrong path can
495
+ * never silently retarget a same-named sibling repo (#2419).
496
+ * Within this tier, only absolute or Windows-shaped ('\\') claims
497
+ * are worth canonicalizing; relative claims like 'org/name' or
498
+ * './repo' are rejected immediately WITHOUT touching the filesystem
499
+ * — canonicalizing them would run an attacker-influenced
500
+ * CWD-relative realpathSync probe on un-rate-limited GET routes,
501
+ * and no legitimate caller sends relative paths.
502
+ * 2. Name fallback — bare names (no separators) keep the legacy
503
+ * basename/name match for older callers.
504
+ */
505
+ export const resolveRegisteredRepoEntry = (repos, repoName) => {
506
+ if (!repoName)
507
+ return repos[0] ?? null;
508
+ const looksLikePath = path.isAbsolute(repoName) || repoName.includes('/') || repoName.includes('\\');
509
+ if (looksLikePath) {
510
+ // Relative path claims fail closed with zero filesystem probes.
511
+ if (!path.isAbsolute(repoName) && !repoName.includes('\\'))
512
+ return null;
513
+ const requestedPath = canonicalizePath(repoName);
514
+ const pathMatch = repos.find((r) => registryPathEquals(canonicalizePath(r.path), requestedPath));
515
+ if (pathMatch)
516
+ return pathMatch;
517
+ return null;
518
+ }
519
+ const normalizedName = repoParamBasename(repoName);
520
+ return (repos.find((r) => r.name === normalizedName) ||
521
+ repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) ||
522
+ null);
523
+ };
479
524
  /**
480
525
  * Handle a GET /api/file request body. Extracted from createServer's route
481
526
  * registration so it can be unit-tested without spinning up an HTTP server
@@ -702,6 +747,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
702
747
  backend,
703
748
  acquireRepoLock,
704
749
  releaseRepoLock,
750
+ closeDbHandle: closeLbug,
705
751
  });
706
752
  /**
707
753
  * Maximum time the hold-queue will wait for an active analysis job to complete.
@@ -712,19 +758,8 @@ export const createServer = async (port, host = '127.0.0.1') => {
712
758
  // Pass `req` to enable early exit if the client disconnects during the hold-queue wait.
713
759
  const resolveRepo = async (repoName, isRetry = false, req) => {
714
760
  const repos = await listRegisteredRepos();
715
- let found = null;
716
- // Normalize: if a full path is passed, extract just the basename.
717
- // e.g. "C:\Users\LENOVO\.gitnexus\repos\todo.txt-cli" -> "todo.txt-cli"
718
- const normalizedName = repoName ? path.basename(repoName) : undefined;
719
- if (normalizedName) {
720
- found =
721
- repos.find((r) => r.name === normalizedName) ||
722
- repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) ||
723
- null;
724
- }
725
- else if (repos.length > 0) {
726
- found = repos[0]; // default to first repo
727
- }
761
+ const found = resolveRegisteredRepoEntry(repos, repoName);
762
+ const normalizedName = repoName ? repoParamBasename(repoName) : undefined;
728
763
  // If not yet in the registry, check whether a background job is actively cloning or
729
764
  // analyzing this repo. Hold the connection open (up to 5 minutes) until it completes.
730
765
  // We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job
@@ -757,7 +792,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
757
792
  if (currentJob.status === 'complete') {
758
793
  await backend.init();
759
794
  const freshRepos = await listRegisteredRepos();
760
- return freshRepos.find((r) => r.name === normalizedName) || null;
795
+ return resolveRegisteredRepoEntry(freshRepos, repoName);
761
796
  }
762
797
  await new Promise((r) => setTimeout(r, 1000));
763
798
  }
@@ -774,7 +809,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
774
809
  logger.debug({ repoName: String(normalizedName).replace(/[\r\n]/g, ' ') }, '[debug] resolveRepo 404, triggering deep init');
775
810
  }
776
811
  await backend.init();
777
- return await resolveRepo(normalizedName, true, req);
812
+ return await resolveRepo(repoName, true, req);
778
813
  }
779
814
  return found;
780
815
  };
@@ -826,6 +861,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
826
861
  res.json(repos.map((r) => ({
827
862
  name: r.name,
828
863
  path: r.path,
864
+ repoPath: r.path,
829
865
  indexedAt: r.indexedAt,
830
866
  lastCommit: r.lastCommit,
831
867
  stats: r.stats,
@@ -836,7 +872,11 @@ export const createServer = async (port, host = '127.0.0.1') => {
836
872
  }
837
873
  });
838
874
  // Get repo info
839
- app.get('/api/repo', async (req, res) => {
875
+ // Rate-limited (CodeQL js/missing-rate-limiting): resolveRepo canonicalizes
876
+ // the attacker-supplied ?repo= param (realpathSync probe for absolute /
877
+ // Windows-shaped claims). Default 60 rpm/IP — web callers hit this route
878
+ // only on connect/switch, never in a polling loop.
879
+ app.get('/api/repo', createRouteLimiter(), async (req, res) => {
840
880
  try {
841
881
  const entry = await resolveRepo(requestedRepo(req), false, req);
842
882
  if (!entry) {
@@ -906,7 +946,10 @@ export const createServer = async (port, host = '127.0.0.1') => {
906
946
  catch {
907
947
  /* repo name not eligible for a clone dir (local repo) */
908
948
  }
909
- if (cloneDir) {
949
+ // Only remove the clone dir when it is *this* entry's path — a local
950
+ // repo registered under the same name would otherwise take a cloned
951
+ // sibling's checkout down with it (see cloneDirBelongsToEntry).
952
+ if (cloneDir && cloneDirBelongsToEntry(cloneDir, entry.path)) {
910
953
  try {
911
954
  const stat = await fs.stat(cloneDir);
912
955
  if (stat.isDirectory()) {
@@ -1140,8 +1183,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
1140
1183
  }, { readOnly: true });
1141
1184
  const response = { results: results.searchResults ?? results };
1142
1185
  if (results.ftsAvailable === false) {
1143
- response.warning =
1144
- 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.';
1186
+ response.warning = ftsDegradedWarning();
1145
1187
  }
1146
1188
  res.json(response);
1147
1189
  }
@@ -52,7 +52,7 @@ import { fileURLToPath } from 'url';
52
52
  // the main thread (the #1983 OOM). Because the two stores share this version,
53
53
  // any future change to the `ParsedFile` serialization shape MUST bump
54
54
  // SCHEMA_BUMP so both invalidate in lockstep.
55
- const SCHEMA_BUMP = 10; // PR #2200: Property nodes gained `rawDeclaredType` + `annotations` (Spring DI); warm caches must invalidate or the DI phase silently no-ops on replayed pre-upgrade nodes
55
+ const SCHEMA_BUMP = 12; // #2391 follow-up: extractPythonModuleConstants changed what it EMITS for the same source (binding mutual-exclusivity clears stale imports; RHS refs are snapshotted; `$imp$N` aliases). `moduleConstants` is cached verbatim, so a warm shard built pre-fix would replay stale/WRONG folds and the correctness fixes would silently no-op on upgrade — bump to force re-extraction. (11 = #2391: ExtractedDecoratorRoute gained `routePathExpr`/`routePathOperands` + ParseWorkerResult gained per-file `moduleConstants`. 10 = PR #2200: Property nodes gained `rawDeclaredType` + `annotations` for Spring DI)
56
56
  const GITNEXUS_PKG_VERSION = (() => {
57
57
  try {
58
58
  // package.json sits at gitnexus/package.json — two levels up from
@@ -54,6 +54,18 @@ export declare const canonicalizePath: (p: string) => string;
54
54
  * lookups/dedup/finalize checks all share so they answer identically.
55
55
  */
56
56
  export declare const registryPathEquals: (a: string, b: string) => boolean;
57
+ /**
58
+ * Does the clone dir derived from an entry's *name* actually belong to that
59
+ * entry? Registry names are not unique across storage locations: a cloned
60
+ * repo under `~/.gitnexus/repos/<name>` and a local repo registered under the
61
+ * same name share a `getCloneDir(entry.name)` result. The server's delete
62
+ * handler must therefore never remove the clone dir based on the name alone —
63
+ * only when the entry's own `path` resolves to that dir (mirroring its step-2b
64
+ * rule that cleanup is driven off `entry.path`, so a same-named sibling's
65
+ * clone is never removed). Both sides are canonicalised so symlinked or
66
+ * differently-spelled forms of the same dir still match.
67
+ */
68
+ export declare const cloneDirBelongsToEntry: (cloneDir: string, entryPath: string) => boolean;
57
69
  export interface RepoMeta {
58
70
  repoPath: string;
59
71
  lastCommit: string;
@@ -74,6 +86,33 @@ export interface RepoMeta {
74
86
  processes?: number;
75
87
  embeddings?: number;
76
88
  };
89
+ /**
90
+ * Capability stamps for what THIS analyze run actually produced (mirrors
91
+ * the meta literal in run-analyze.ts — typed here so the stamp site is
92
+ * compile-checked; tri-review 4669518496 P1/U3: `vectorSearch.status`
93
+ * must never claim 'vector-index' unless the run verified or recreated
94
+ * the HNSW index). Forensic today — no programmatic readers (`doctor`
95
+ * prints platform-derived capabilities, query routing never consults
96
+ * meta). The status unions mirror `CapabilityStatus` /
97
+ * `SemanticSearchMode` in core/platform/capabilities.ts; inlined to keep
98
+ * storage/ free of a core/ type dependency.
99
+ */
100
+ capabilities?: {
101
+ graph: {
102
+ provider: string;
103
+ status: 'available' | 'degraded' | 'unavailable';
104
+ };
105
+ fts: {
106
+ provider: string;
107
+ status: 'available' | 'degraded' | 'unavailable';
108
+ };
109
+ vectorSearch: {
110
+ provider: string;
111
+ status: 'vector-index' | 'exact-scan' | 'unavailable';
112
+ exactScanLimit: number;
113
+ reason?: string;
114
+ };
115
+ };
77
116
  /**
78
117
  * Bumped whenever incremental-indexing invariants change in an
79
118
  * incompatible way (delete-and-rewrite logic, subgraph extraction,
@@ -110,9 +149,29 @@ export interface RepoMeta {
110
149
  incrementalInProgress?: {
111
150
  /** When the run started (epoch ms). */
112
151
  startedAt: number;
152
+ /** Last dirty-flag refresh (epoch ms). */
153
+ updatedAt?: number;
113
154
  /** Number of files in the writable set, for diagnostic logs.
114
155
  * `0` on the full-rebuild path (no incremental write set exists). */
115
156
  toWriteCount: number;
157
+ /** Last completed writeback phase before the process stopped. */
158
+ phase?: string;
159
+ /** Directly changed/added files before importer expansion. */
160
+ directWriteCount?: number;
161
+ /** Extra files pulled into the writable set by importer BFS. */
162
+ importerExpansion?: number;
163
+ /** Files in the effective write set after graph-boundary expansion. */
164
+ effectiveWriteCount?: number;
165
+ /** Files whose persisted rows were scheduled for deletion. */
166
+ deleteCount?: number;
167
+ /** Added-file shadow seeds included in importer BFS. */
168
+ shadowSeedCount?: number;
169
+ /** Importer-BFS chunks dropped by failed IMPORTS queries (#2410 +
170
+ * tri-review 4669518496 P2-5). Stamped only when > 0: a dropped chunk
171
+ * means the importer expansion silently shrank, so a crash's
172
+ * diagnostics must show whether the write set was already
173
+ * under-expanded when the run died. */
174
+ droppedImporterChunks?: number;
116
175
  };
117
176
  /**
118
177
  * Name of the git branch this index represents (#2106). Absent for the
@@ -242,8 +301,13 @@ export interface RepoMeta {
242
301
  * URL-only id). The incremental writeback preserves unchanged-file rows, so a
243
302
  * top-up against a pre-v5 index would strand old url-keyed Route nodes alongside
244
303
  * new composite-keyed ones — force a full re-analyze instead.
304
+ * v6: line-number storage flipped to uniform 0-based for the last 1-based
305
+ * GraphNode emitters — COBOL/JCL/markdown/scope (#2377/#2379/#2380). Incremental
306
+ * writeback preserves unchanged-file rows, so a top-up against a pre-v6 index
307
+ * would MIX old 1-based rows with new 0-based ones — and the 1-based MCP display
308
+ * would render the stale rows one line too high — so force a full re-analyze.
245
309
  */
246
- export declare const INCREMENTAL_SCHEMA_VERSION = 5;
310
+ export declare const INCREMENTAL_SCHEMA_VERSION = 6;
247
311
  export interface IndexedRepo {
248
312
  repoPath: string;
249
313
  storagePath: string;
@@ -71,6 +71,18 @@ export const canonicalizePath = (p) => {
71
71
  * lookups/dedup/finalize checks all share so they answer identically.
72
72
  */
73
73
  export const registryPathEquals = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
74
+ /**
75
+ * Does the clone dir derived from an entry's *name* actually belong to that
76
+ * entry? Registry names are not unique across storage locations: a cloned
77
+ * repo under `~/.gitnexus/repos/<name>` and a local repo registered under the
78
+ * same name share a `getCloneDir(entry.name)` result. The server's delete
79
+ * handler must therefore never remove the clone dir based on the name alone —
80
+ * only when the entry's own `path` resolves to that dir (mirroring its step-2b
81
+ * rule that cleanup is driven off `entry.path`, so a same-named sibling's
82
+ * clone is never removed). Both sides are canonicalised so symlinked or
83
+ * differently-spelled forms of the same dir still match.
84
+ */
85
+ export const cloneDirBelongsToEntry = (cloneDir, entryPath) => registryPathEquals(canonicalizePath(cloneDir), canonicalizePath(entryPath));
74
86
  /**
75
87
  * Bumped whenever incremental-indexing invariants change incompatibly.
76
88
  * v2: `BasicBlock.callees` column added (statement-precise inter-procedural
@@ -90,8 +102,13 @@ export const registryPathEquals = (a, b) => process.platform === 'win32' ? a.toL
90
102
  * URL-only id). The incremental writeback preserves unchanged-file rows, so a
91
103
  * top-up against a pre-v5 index would strand old url-keyed Route nodes alongside
92
104
  * new composite-keyed ones — force a full re-analyze instead.
105
+ * v6: line-number storage flipped to uniform 0-based for the last 1-based
106
+ * GraphNode emitters — COBOL/JCL/markdown/scope (#2377/#2379/#2380). Incremental
107
+ * writeback preserves unchanged-file rows, so a top-up against a pre-v6 index
108
+ * would MIX old 1-based rows with new 0-based ones — and the 1-based MCP display
109
+ * would render the stale rows one line too high — so force a full re-analyze.
93
110
  */
94
- export const INCREMENTAL_SCHEMA_VERSION = 5;
111
+ export const INCREMENTAL_SCHEMA_VERSION = 6;
95
112
  const GITNEXUS_DIR = '.gitnexus';
96
113
  const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
97
114
  export const INDEX_METADATA_FILE = 'gitnexus.json';
@@ -391,6 +391,49 @@ function buildAfterToolContext(input) {
391
391
  return parts.length > 0 ? parts.join('\n\n') : null;
392
392
  }
393
393
 
394
+ /**
395
+ * Fallback augmentation for the #2396 path: when a GitNexus process holds the
396
+ * lbug DB write lock the CLI `augment` can't run, so point the agent at the MCP
397
+ * `query` tool instead. Phrased conditionally ("if the MCP tools are live") so it
398
+ * stays truthful on every owner path — a confirmed MCP owner, a `serve` owner, or
399
+ * a fail-closed probe where no server is actually confirmed. `pattern` is embedded
400
+ * verbatim; the caller (writeAdditionalContext) JSON-escapes it structurally.
401
+ */
402
+ function buildMcpQueryHint(pattern) {
403
+ return (
404
+ `[GitNexus] Local augment is unavailable (the graph DB is held by another ` +
405
+ `GitNexus process). If the GitNexus MCP tools are live in this session, call ` +
406
+ `the GitNexus \`query\` MCP tool (e.g. mcp__gitnexus__query) with ` +
407
+ `search_query "${pattern}".`
408
+ );
409
+ }
410
+
411
+ /**
412
+ * #2396 throttle: emit the MCP-query hint at most once per repo per window, so an
413
+ * owner-locked session isn't nudged on every search. Window (ms) via
414
+ * GITNEXUS_MCP_HINT_THROTTLE_MS (default 10min; 0/invalid disables). Best-effort —
415
+ * any fs error falls back to emitting.
416
+ * ponytail: per-repo mtime marker, shared across concurrent sessions on the same
417
+ * repo; add per-session dedup only if that sharing becomes a problem.
418
+ */
419
+ function shouldEmitMcpHint(gitNexusDir) {
420
+ const raw = process.env.GITNEXUS_MCP_HINT_THROTTLE_MS;
421
+ const windowMs = raw === undefined || raw === '' ? 600000 : Number(raw);
422
+ if (!Number.isFinite(windowMs) || windowMs <= 0) return true;
423
+ const marker = path.join(gitNexusDir, '.mcp-hint-shown');
424
+ try {
425
+ if (Date.now() - fs.statSync(marker).mtimeMs < windowMs) return false;
426
+ } catch {
427
+ /* marker missing/unreadable → emit */
428
+ }
429
+ try {
430
+ fs.writeFileSync(marker, '');
431
+ } catch {
432
+ /* best-effort; still emit */
433
+ }
434
+ return true;
435
+ }
436
+
394
437
  function runAugment(gitNexusDir, cwd, pattern) {
395
438
  // Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
396
439
  // itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
@@ -410,12 +453,16 @@ function runAugment(gitNexusDir, cwd, pattern) {
410
453
  }
411
454
  try {
412
455
  if (hasGitNexusServerOwner(gitNexusDir)) {
413
- // Normal skip path: the MCP server owns the DB. Stay silent for strict
414
- // hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
456
+ // #2396: the MCP server holds the DB write lock, so a competing CLI
457
+ // `augment` would only contend on it (LadybugDB is single-writer). The
458
+ // session has the GitNexus MCP tools live — route the augmentation to the
459
+ // agent via additionalContext instead of dropping it. Mirror the skip
460
+ // reason to stderr only under GITNEXUS_DEBUG (strict-runner contract,
461
+ // #1913); the hint itself rides the sanctioned additionalContext channel.
415
462
  if (isDebugEnabled()) {
416
463
  process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
417
464
  }
418
- return '';
465
+ return shouldEmitMcpHint(gitNexusDir) ? buildMcpQueryHint(pattern) : '';
419
466
  }
420
467
  const cliPath = resolveCliPath();
421
468
  const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
@@ -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 */