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
@@ -13,11 +13,14 @@ import fs from 'fs/promises';
13
13
  import { execFileSync } from 'child_process';
14
14
  import { runPipelineFromRepo } from './ingestion/pipeline.js';
15
15
  import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
16
- import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, closeLbugBeforeExit, loadCachedEmbeddings, deleteNodesForFile, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, queryImporters, loadFTSExtension, } from './lbug/lbug-adapter.js';
16
+ import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, closeLbugBeforeExit, loadCachedEmbeddings, deleteNodesForFiles, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, deleteAllCallSummaries, deleteAllInjects, queryImportersBatch, loadFTSExtension, wipeLbugDbFiles, LbugWipeError, DELETE_FILES_CHUNK_SIZE, } from './lbug/lbug-adapter.js';
17
+ import { escapeCypherString } from './lbug/cypher-escape.js';
17
18
  import { createSearchFTSIndexes, initialiseSearchFTSStemmer, verifySearchFTSIndexes, } from './search/fts-indexes.js';
18
19
  import { cjkSegmentationModeMismatch, getSearchFTSCjkSegmentation, initialiseSearchFTSCjkSegmentation, } from './search/cjk-segmentation.js';
19
- import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
20
- import { startWalCheckpointDriver, } from './lbug/wal-checkpoint-driver.js';
20
+ import { getExtensionCapabilities, resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
21
+ import { diagnoseExtensionLoad } from './lbug/extension-load-error.js';
22
+ import { startWalCheckpointDriver, checkpointOnce, } from './lbug/wal-checkpoint-driver.js';
23
+ import { quarantineSidecarsForDirtyRecovery } from './lbug/sidecar-recovery.js';
21
24
  import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, adoptFlatBranchLabel, isReadOnlyFilesystemError, isRepoRegistered, cleanupOldKuzuFiles, reconcileMetadataFiles, isMissingFilesystemError, INDEX_METADATA_FILE, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
22
25
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
23
26
  import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_CDG_EDGES_PER_FUNCTION, } from './ingestion/cfg/emit.js';
@@ -29,6 +32,7 @@ import { parseTruthyEnv, parsePositiveIntEnv } from './ingestion/utils/env.js';
29
32
  import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
30
33
  import { extractChangedSubgraph, computeEffectiveWriteSet, } from './incremental/subgraph-extract.js';
31
34
  import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
35
+ import { shouldEscalateIncrementalWrite } from './incremental/escalation-gate.js';
32
36
  import { loadParseCache, saveParseCache, pruneCache, PARSE_CACHE_VERSION, } from '../storage/parse-cache.js';
33
37
  import { getDurableParsedFileDir, pruneAndSaveDurableParsedFileStore, } from '../storage/parsedfile-store.js';
34
38
  import { getCurrentCommit, getCurrentBranch, getRemoteUrl, hasGitDir, getInferredRepoName, resolveRepoIdentityRoot, } from '../storage/git.js';
@@ -41,7 +45,11 @@ import { STALE_HASH_SENTINEL } from './lbug/schema.js';
41
45
  * a full analyze. Kept as a named constant so the env-var/command guidance
42
46
  * stays in one place (mirrors the VECTOR message in embedding-pipeline.ts).
43
47
  */
44
- const FTS_UNAVAILABLE_MESSAGE = 'FTS extension unavailable; skipping search-index creation. ' +
48
+ // Class-neutral lead, reused for the missing-dependency degrade path (#2383 F2):
49
+ // its remedy already explains that reinstalling will NOT help, so appending the
50
+ // generic "install with network access" tail below would contradict it.
51
+ const FTS_UNAVAILABLE_LEAD = 'FTS extension unavailable; skipping search-index creation.';
52
+ const FTS_UNAVAILABLE_MESSAGE = `${FTS_UNAVAILABLE_LEAD} ` +
45
53
  'Full-text/BM25 search will be disabled until the LadybugDB FTS extension is ' +
46
54
  'installed once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) or ' +
47
55
  'pre-installed for offline use. Run `gitnexus doctor` for details.';
@@ -328,6 +336,18 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
328
336
  throw new Error('Cannot repair FTS indexes because this repository has not been analyzed yet. ' +
329
337
  'Run `gitnexus analyze` first to create the initial index, then retry `--repair-fts`.');
330
338
  }
339
+ if (existingMeta.incrementalInProgress) {
340
+ // #2409 / tri-review 4669518496 (R6): a dirty flag means the previous
341
+ // run died mid-writeback — the graph may be half-written and its WAL
342
+ // possibly poisoned. This branch returns early, so the dirty-recovery
343
+ // sidecar quarantine below would never run: repairing FTS now would
344
+ // open the DB and replay that WAL pre-quarantine, and even a
345
+ // survivable open would certify FTS over a half-written graph.
346
+ throw new Error('Cannot repair FTS indexes: the index is mid-incremental-recovery ' +
347
+ '(a previous analyze run did not complete cleanly). ' +
348
+ 'Run `gitnexus analyze` first — it recovers the index automatically — ' +
349
+ 'then retry `--repair-fts`.');
350
+ }
331
351
  let lbugStat;
332
352
  try {
333
353
  lbugStat = await fs.lstat(lbugPath);
@@ -365,9 +385,23 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
365
385
  policy: resolveAnalyzeInstallPolicy(),
366
386
  });
367
387
  if (!repairFtsAvailable) {
368
- throw new Error('Cannot repair FTS indexes: the LadybugDB FTS extension is unavailable ' +
369
- '(not pre-installed and could not be installed on this machine). ' +
370
- 'Run `gitnexus doctor` to install it, then retry `--repair-fts`.');
388
+ // Surface the load-side reason (#2374): "not pre-installed" was wrong
389
+ // and doctor never installed anything, so the old message trapped
390
+ // users in a query repair-fts → doctor loop with no way out.
391
+ const rawFtsReason = getExtensionCapabilities().find((c) => c.name === 'fts')?.reason;
392
+ const ftsReason = rawFtsReason?.replace(/\.$/, '');
393
+ // A missing runtime dependency (Windows error 126, #2374) is not healed
394
+ // by re-installing — the file is already present. Route that class to the
395
+ // classified remedy (install VC++ redist / OpenSSL) instead of the old
396
+ // "retry the network install" text that trapped the user in a loop.
397
+ const { kind, remedy } = diagnoseExtensionLoad(rawFtsReason);
398
+ const remedyTail = kind === 'missing_dependency'
399
+ ? ` ${remedy}`
400
+ : '. Retry with network access and GITNEXUS_LBUG_EXTENSION_INSTALL=auto to install it, ' +
401
+ 'or pre-install the extension file; run `gitnexus doctor` for live FTS status.';
402
+ throw new Error('Cannot repair FTS indexes: the LadybugDB FTS extension failed to load' +
403
+ (ftsReason ? ` — ${ftsReason}` : '') +
404
+ remedyTail);
371
405
  }
372
406
  progress('fts', 85, 'Repairing search indexes...');
373
407
  await createSearchFTSIndexes({
@@ -405,15 +439,71 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
405
439
  // clear it, the on-disk index may be in a half-state. Cheapest path
406
440
  // back to a known-good index is to wipe + rebuild from scratch.
407
441
  if (existingMeta?.incrementalInProgress) {
442
+ const dirty = existingMeta.incrementalInProgress;
443
+ const dirtyDetails = typeof dirty === 'object'
444
+ ? [
445
+ dirty.phase ? `phase=${dirty.phase}` : undefined,
446
+ `toWrite=${dirty.toWriteCount}`,
447
+ dirty.importerExpansion !== undefined
448
+ ? `importerExpansion=${dirty.importerExpansion}`
449
+ : undefined,
450
+ dirty.effectiveWriteCount !== undefined
451
+ ? `effectiveWrite=${dirty.effectiveWriteCount}`
452
+ : undefined,
453
+ dirty.deleteCount !== undefined ? `deleteCount=${dirty.deleteCount}` : undefined,
454
+ // Only stamped when > 0 (tri-review 4669518496 P2-5): its
455
+ // presence means the crashed run's importer expansion was
456
+ // already degraded — the write set may have been under-expanded
457
+ // before the crash.
458
+ dirty.droppedImporterChunks !== undefined
459
+ ? `droppedImporterChunks=${dirty.droppedImporterChunks}`
460
+ : undefined,
461
+ ]
462
+ .filter(Boolean)
463
+ .join(', ')
464
+ : 'legacy dirty flag';
408
465
  log(
409
466
  // "analyze run", not "incremental run" — since #2099 F1 the flag is a
410
467
  // generic dirty marker written by BOTH writeback branches.
411
468
  'Previous analyze run did not complete cleanly (incrementalInProgress flag set); ' +
469
+ `last dirty state: ${dirtyDetails}; ` +
412
470
  'forcing full rebuild to restore a known-good index.');
413
471
  options = { ...options, force: true };
414
472
  // Reload meta after clearing the flag in-memory; we still want fileHashes
415
473
  // for the post-rebuild meta carry-over, but force=true ensures the
416
474
  // rebuild path executes.
475
+ //
476
+ // #2409 defect 2: the crashed writeback's WAL can be poisoned — replaying
477
+ // it kills the process natively, and the first DB open of this recovery
478
+ // run (the embedding-cache preservation open below) happens BEFORE the
479
+ // rebuild wipe that would discard it. Park the WAL/shadow sidecars aside
480
+ // now, while nothing is open, so every open in this run is replay-free.
481
+ // The rebuild wipes the DB regardless, so no committed data is at stake.
482
+ const { removed, failed } = await quarantineSidecarsForDirtyRecovery(lbugPath, log);
483
+ if (removed.length > 0) {
484
+ log(`Dirty-state recovery discarded ${removed.map((p) => path.basename(p)).join(', ')} ` +
485
+ 'from the interrupted run (the file could not be moved aside, so its bytes were ' +
486
+ 'removed — post-mortem forensics lost). Recovery proceeds with full embedding ' +
487
+ 'preservation.');
488
+ }
489
+ if (failed.length > 0) {
490
+ // FIX 1 (this shipping review, replacing the tri-review 4669518496
491
+ // P2-3 drop-shape design): under a persistent lock the old drop-shape
492
+ // run derived its embedding mode as "drop", ran the WHOLE pipeline,
493
+ // and then died at the rebuild wipe on the very same handle — wasting
494
+ // minutes and zeroing embeddings on the way. A possibly-poisoned
495
+ // sidecar still sits next to the DB (any pre-wipe open would replay it
496
+ // and die), so failing here, in seconds, with the same actionable
497
+ // typed error the wipe would eventually throw is strictly better —
498
+ // and the CLI's LbugWipeError handler already renders it
499
+ // (recoveryHint 'lbug-wipe-failed'). The message is self-contained
500
+ // (headline + paths + lock guidance) because serve forwards only
501
+ // err.message over worker IPC.
502
+ throw new LbugWipeError(failed, {
503
+ headline: "Cannot start dirty-state recovery — the interrupted run's LadybugDB sidecars " +
504
+ 'could neither be moved aside nor removed:',
505
+ });
506
+ }
417
507
  }
418
508
  // ── pdg-mode flip forces full writeback (#2099 F1) ─────────────────
419
509
  // The incremental writeback persists only changed-file nodes, so a pdg
@@ -610,6 +700,12 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
610
700
  // silently dropping embeddings on a mispredicted run. The re-insert
611
701
  // step gates itself on the actual `isIncremental` value to avoid
612
702
  // PK-conflicts when the incremental writeback path keeps the rows.
703
+ //
704
+ // This is the FIRST DB open of the run — the one #2409 defect 2 is about.
705
+ // On a dirty-recovery run it happens only after the sidecar quarantine
706
+ // moved (or removed) the crashed run's WAL/shadow; when neither was
707
+ // possible the dirty block above already threw a LbugWipeError, so this
708
+ // open is replay-free by construction (FIX 1 of this shipping review).
613
709
  if (shouldLoadCache && existingMeta) {
614
710
  try {
615
711
  progress('embeddings', 0, 'Caching embeddings...');
@@ -709,11 +805,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
709
805
  `(skipping wipe + ${allFilePaths.length - hashDiff.toWrite.length} unchanged file rows preserved)`);
710
806
  // Set the dirty flag BEFORE any destructive DB mutation. Cleared on
711
807
  // success at the meta-save step. Scoped to this branch's meta.json.
808
+ const now = Date.now();
712
809
  await saveMeta(metaDir, {
713
810
  ...existingMeta,
714
811
  incrementalInProgress: {
715
- startedAt: Date.now(),
812
+ startedAt: now,
813
+ updatedAt: now,
814
+ phase: 'pre-write',
716
815
  toWriteCount: hashDiff.toWrite.length,
816
+ directWriteCount: hashDiff.toWrite.length,
717
817
  },
718
818
  });
719
819
  }
@@ -727,21 +827,26 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
727
827
  // pdg flip, certify zombie/missing BasicBlock rows indefinitely).
728
828
  // toWriteCount: 0 is the full-path sentinel (no incremental write set).
729
829
  if (existingMeta) {
830
+ const now = Date.now();
730
831
  await saveMeta(metaDir, {
731
832
  ...existingMeta,
732
- incrementalInProgress: { startedAt: Date.now(), toWriteCount: 0 },
833
+ incrementalInProgress: {
834
+ startedAt: now,
835
+ updatedAt: now,
836
+ phase: 'full-rebuild',
837
+ toWriteCount: 0,
838
+ },
733
839
  });
734
840
  }
735
841
  await closeLbug();
736
- const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`];
737
- for (const f of lbugFiles) {
738
- try {
739
- await fs.rm(f, { recursive: true, force: true });
740
- }
741
- catch {
742
- /* swallow */
743
- }
744
- }
842
+ // Shared loud wipe (#2409 + tri-review 4669518496 P2-4). The 4-file
843
+ // family list `.shadow` included, because a checkpoint-in-flight crash
844
+ // leaves a shadow sidecar that is replay poison next to a freshly created
845
+ // DB file lives in wipeLbugDbFiles so this site and the escalation
846
+ // valve below can never drift. Failures now throw a typed LbugWipeError
847
+ // (ENOENT-verified removal) instead of silently letting initLbug reopen
848
+ // a still-populated DB this run believes it wiped.
849
+ await wipeLbugDbFiles(lbugPath);
745
850
  }
746
851
  await initLbug(lbugPath);
747
852
  // Manual WAL checkpoint driver (#1741): periodically drain the WAL
@@ -752,12 +857,31 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
752
857
  // Opt-out via `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` (the driver itself
753
858
  // returns a no-op handle when disabled). Analyze-only: MCP and serve
754
859
  // paths continue to rely on the close-time CHECKPOINT in `safeClose`.
755
- const walCheckpointDriver = startWalCheckpointDriver();
860
+ // `let`: the incremental branch's escalation valve (#2409) stops this driver
861
+ // around its close→wipe→reopen strategy switch and starts a fresh one.
862
+ let walCheckpointDriver = startWalCheckpointDriver();
756
863
  try {
757
864
  // All work after initLbug is wrapped in try/finally to ensure closeLbug()
758
865
  // is called even if an error occurs — the module-level singleton DB handle
759
866
  // must be released to avoid blocking subsequent invocations.
760
867
  let lbugMsgCount = 0;
868
+ // #2409 escalation valve outcome, hoisted above the incremental branch so
869
+ // the vector-index recreation seam in Phase 4 below can tell "surgical
870
+ // incremental" (DB files survived — the HNSW index with them) apart from
871
+ // "escalated full write" (DB wiped, index destroyed) — tri-review
872
+ // 4669518496 P1.
873
+ let escalatedFullWrite = false;
874
+ // Phase 3.5's restore scope (FIX 3 of this shipping review): on the
875
+ // SURGICAL write plan this is the exact file set whose rows
876
+ // deleteNodesForFiles just removed — only THOSE files' cached embedding
877
+ // rows need re-inserting (everything else still sits in the DB, and
878
+ // re-inserting it would PK-conflict). `null` means the DB was wiped
879
+ // (full rebuild or escalated write): the embedding table is fresh and
880
+ // every cached row must come back. Deriving this in memory replaces the
881
+ // old whole-table `RETURN e.id` pre-read, which rescanned data this
882
+ // process already holds and — worse — ran a read against the DB between
883
+ // writeback and finalize for no recovery benefit.
884
+ let deletedFilePathsForRestore = null;
761
885
  if (isIncremental && hashDiff) {
762
886
  // ── Incremental DB writeback ───────────────────────────────────
763
887
  // 0. Expand the writable set with transitive importers of
@@ -782,21 +906,50 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
782
906
  // self-acknowledged as best-effort; `--force` remains the
783
907
  // escape hatch documented in GUARDRAILS.md.
784
908
  //
785
- // `queryImporters` reads `IMPORTS` from the pre-pipeline DB
909
+ // `queryImportersBatch` reads `IMPORTS` from the pre-pipeline DB
786
910
  // state, so the result is "files that USED TO import the
787
911
  // target" — exactly the set whose previously-stored edges may
788
912
  // no longer match what cross-file resolution produces this run.
789
913
  const MAX_IMPORTER_BFS_DEPTH = 4;
914
+ // Escalation thresholds (#2409) live with shouldEscalateIncrementalWrite
915
+ // in incremental/escalation-gate.ts (pure predicate, boundary-tested).
790
916
  const writableFiles = new Set(hashDiff.toWrite);
791
917
  const directlyChangedCount = writableFiles.size;
792
- // Shadow-seed: for ADDED files, queryImporters returns 0 (the new
918
+ const dirtyStartedAt = existingMeta.incrementalInProgress?.startedAt ?? Date.now();
919
+ // Dropped-chunk observability (tri-review 4669518496 P2-5): counts
920
+ // importer-BFS chunks whose IMPORTS query failed across ALL depths
921
+ // (degrade-don't-fail — the expansion shrinks instead of the run
922
+ // dying). Stamped into the #2410 crash diagnostics by
923
+ // saveIncrementalDirtyState ITSELF (FIX 6 of this shipping review),
924
+ // not by per-call-site spreads: the closure rebuilds its object from
925
+ // scratch on every call, so a count riding along at only some sites
926
+ // meant any newly added save site would silently erase it — exactly
927
+ // the phases where #2409-class crashes happen. >0-only semantics
928
+ // unchanged: unconditional zero-stamping would churn every
929
+ // strict-equality consumer of the diagnostics shape.
930
+ let droppedImporterChunks = 0;
931
+ const saveIncrementalDirtyState = async (phase, extra = {}) => {
932
+ await saveMeta(metaDir, {
933
+ ...existingMeta,
934
+ incrementalInProgress: {
935
+ startedAt: dirtyStartedAt,
936
+ updatedAt: Date.now(),
937
+ phase,
938
+ toWriteCount: writableFiles.size,
939
+ directWriteCount: directlyChangedCount,
940
+ ...(droppedImporterChunks > 0 ? { droppedImporterChunks } : {}),
941
+ ...extra,
942
+ },
943
+ });
944
+ };
945
+ // Shadow-seed: for ADDED files, the importer query returns 0 (the new
793
946
  // file has no IMPORTS rows in the pre-pipeline DB yet). But pre-
794
947
  // existing unchanged files may have IMPORTS edges whose module-
795
948
  // resolution claim the newcomer can steal under standard JS/TS
796
949
  // resolution (Bugbot review on PR #1479). For each added file we
797
950
  // derive the shadow candidates and, if the candidate was a known
798
951
  // file in the prior meta, seed it into the BFS frontier so its
799
- // importers — surfaced via queryImporters — get their CALLS edges
952
+ // importers — surfaced via the importer BFS — get their CALLS edges
800
953
  // re-resolved against the new file. See shadow-candidates.ts for
801
954
  // the full pattern catalogue.
802
955
  const priorFileSet = new Set(existingMeta?.fileHashes ? Object.keys(existingMeta.fileHashes) : []);
@@ -809,28 +962,33 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
809
962
  }
810
963
  }
811
964
  {
965
+ // Batched per depth level (#2409): one IN-list query per ~200-path
966
+ // chunk instead of one query per frontier file — a ~700-file frontier
967
+ // used to cost ~700 sequential lock-taking round-trips (~5.6s). The
968
+ // closure is identical: importers already in writableFiles are not
969
+ // re-frontiered, exactly like the per-file loop's membership check.
812
970
  let frontier = [...hashDiff.toWrite, ...hashDiff.deleted, ...shadowSeed];
813
971
  for (let depth = 0; depth < MAX_IMPORTER_BFS_DEPTH && frontier.length > 0; depth++) {
972
+ const importers = await queryImportersBatch(frontier, {
973
+ onChunkFailure: () => {
974
+ droppedImporterChunks += 1;
975
+ },
976
+ });
814
977
  const nextFrontier = [];
815
- for (const f of frontier) {
816
- try {
817
- const importers = await queryImporters(f);
818
- for (const i of importers) {
819
- if (!writableFiles.has(i)) {
820
- writableFiles.add(i);
821
- nextFrontier.push(i);
822
- }
823
- }
824
- }
825
- catch {
826
- /* per-file importer query failure → skip; correctness degrades on
827
- that branch, but DB stays writable. */
978
+ for (const i of importers) {
979
+ if (!writableFiles.has(i)) {
980
+ writableFiles.add(i);
981
+ nextFrontier.push(i);
828
982
  }
829
983
  }
830
984
  frontier = nextFrontier;
831
985
  }
832
986
  }
833
987
  const importerExpansion = writableFiles.size - directlyChangedCount;
988
+ await saveIncrementalDirtyState('importer-bfs', {
989
+ importerExpansion,
990
+ shadowSeedCount: shadowSeed.length,
991
+ });
834
992
  if (importerExpansion > 0) {
835
993
  log(`Incremental: +${importerExpansion} importer(s) added to writable set ` +
836
994
  `(BFS depth ≤ ${MAX_IMPORTER_BFS_DEPTH}` +
@@ -848,69 +1006,143 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
848
1006
  // cross-file CALLS edges that the pre-run DB couldn't
849
1007
  // predict, e.g. a barrel re-export shifting `foo` from
850
1008
  // B to D).
851
- // The composed set is the input to BOTH deleteNodesForFile
1009
+ // The composed set is the input to BOTH deleteNodesForFiles
852
1010
  // and extractChangedSubgraph — asymmetry between the two would
853
1011
  // leave stale rows or PK-conflict at COPY time.
854
1012
  const effectiveWriteSet = computeEffectiveWriteSet(pipelineResult.graph, writableFiles);
855
1013
  // Deduped: deleted entries may already appear via importer-BFS
856
- // expansion (queryImporters can return a now-deleted path), which
857
- // would otherwise call deleteNodesForFile twice for the same file
858
- // (Bugbot LOW finding on PR #1479).
1014
+ // expansion (the importer BFS can return a now-deleted path), which
1015
+ // would otherwise hand deleteNodesForFiles the same path twice in one
1016
+ // batch (Bugbot LOW finding on PR #1479).
859
1017
  const filesToDelete = [...new Set([...effectiveWriteSet, ...hashDiff.deleted])];
860
- for (let i = 0; i < filesToDelete.length; i++) {
861
- const f = filesToDelete[i];
862
- try {
863
- await deleteNodesForFile(f);
864
- }
865
- catch {
866
- /* file may not have rows (e.g. an unparseable file) fine */
867
- }
868
- if (i % 20 === 0) {
869
- progress('lbug', 62, `Removing rows for changed files (${i}/${filesToDelete.length})...`);
870
- }
1018
+ await saveIncrementalDirtyState('effective-write-set', {
1019
+ importerExpansion,
1020
+ shadowSeedCount: shadowSeed.length,
1021
+ effectiveWriteCount: effectiveWriteSet.size,
1022
+ deleteCount: filesToDelete.length,
1023
+ });
1024
+ // Escalation valve (#2409): when the effective write set covers most of
1025
+ // the repo, per-file surgery is strictly worse than the proven
1026
+ // wipe-and-bulk-COPY plan the same data volume lands either way, but
1027
+ // the surgical plan pays per-table deletes plus COPY-into-non-empty
1028
+ // tables, and at this size it measured SLOWER than a full DB load. The
1029
+ // pipeline already produced the FULL graph (it always does), so only the
1030
+ // DB write plan changes here; fileHashes/meta bookkeeping is identical.
1031
+ // Thresholds + the AND-gate live in incremental/escalation-gate.ts.
1032
+ const writeFraction = effectiveWriteSet.size / Math.max(1, allFilePaths.length);
1033
+ if (shouldEscalateIncrementalWrite(filesToDelete.length, effectiveWriteSet.size, allFilePaths.length)) {
1034
+ escalatedFullWrite = true;
1035
+ log(`Incremental: effective write set covers ${effectiveWriteSet.size}/${allFilePaths.length} ` +
1036
+ // Display clamp only (predicate unchanged): BFS-found deleted
1037
+ // importers can push the numerator past the CURRENT file list, so
1038
+ // the raw fraction can exceed 1 — see the population-mismatch note
1039
+ // on shouldEscalateIncrementalWrite (tri-review 4669518496).
1040
+ `files (${Math.min(100, Math.round(writeFraction * 100))}%) — switching to a full DB write ` +
1041
+ `(wipe + bulk COPY) for this run; file-level incremental bookkeeping is unaffected.`);
1042
+ // toWriteCount: 0 is the established full-path dirty-flag sentinel;
1043
+ // the real counters ride along for crash diagnostics.
1044
+ await saveIncrementalDirtyState('escalated-full-write', {
1045
+ toWriteCount: 0,
1046
+ importerExpansion,
1047
+ shadowSeedCount: shadowSeed.length,
1048
+ effectiveWriteCount: effectiveWriteSet.size,
1049
+ deleteCount: filesToDelete.length,
1050
+ });
1051
+ // Strategy switch: stop the checkpoint driver around the close so its
1052
+ // in-flight CHECKPOINT can't race the reopen, drop the DB files
1053
+ // (sidecars included), and bulk-load the full graph into a fresh DB —
1054
+ // byte-for-byte the full-rebuild write plan. The wipe is the shared
1055
+ // ENOENT-verified helper (#2409 + tri-review 4669518496 P2-4): a
1056
+ // surviving family member throws a typed LbugWipeError here instead
1057
+ // of letting the reopen below resurrect the rows this run just chose
1058
+ // to replace wholesale.
1059
+ await walCheckpointDriver.stop();
1060
+ await closeLbug();
1061
+ await wipeLbugDbFiles(lbugPath);
1062
+ await initLbug(lbugPath);
1063
+ walCheckpointDriver = startWalCheckpointDriver();
1064
+ await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
1065
+ lbugMsgCount++;
1066
+ const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
1067
+ progress('lbug', pct, msg);
1068
+ });
871
1069
  }
872
- // 2. Drop graph-wide nodes (Community, Process). They'll be re-inserted
873
- // from the fresh pipeline output below. Required for the
874
- // "Leiden runs on the FULL graph" correctness invariant.
875
- await deleteAllCommunitiesAndProcesses();
876
- // 2a. Drop INJECTS edges (DI collection injection, #2200) their
877
- // validity is a whole-program property (a third-file change to the
878
- // interface or an implementer creates/invalidates edges between two
879
- // untouched files), so endpoint-writability extraction can't refresh
880
- // them; extractChangedSubgraph re-includes all of them from the
881
- // fresh graph (isGraphWideRelType). UNCONDITIONAL, next to the
882
- // Communities delete NOT inside the `options.pdg` block below: the
883
- // di phase runs on every persisting analyze (same !skipGraphPhases
884
- // regime as communities/processes) while the graph-wide re-include
885
- // is unconditional, so a pdg-gated delete would append without
886
- // deleting on every non-pdg incremental run (N runs = N copies of
887
- // every INJECTS row; CodeRelation has no PK and no read-side dedup).
888
- await deleteAllInjects();
889
- // 2b. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
890
- // their validity is a whole-program property (an A→C flow can be
891
- // invalidated by a change to an intermediate function on a third
892
- // file), so endpoint-writability extraction can't refresh them.
893
- // extractChangedSubgraph re-includes all of them from the fresh
894
- // graph (isGraphWideRelType), mirroring Community/Process.
895
- if (options.pdg === true) {
896
- await deleteAllInterprocTaintPaths();
897
- // 2c. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg`
898
- // writeback. They are re-included from the FULL fresh graph
899
- // (isGraphWideRelType) and the callSummaries phase recomputes every
900
- // summary each run, so delete-all-then-rebuild keeps an unchanged
901
- // function's summary from being lost same contract as TAINT_PATH.
902
- await deleteAllCallSummaries();
1070
+ else {
1071
+ // 1a. Remove the write set's existing rows batched (#2409): one
1072
+ // DETACH DELETE per table per 200-file chunk. The former per-file
1073
+ // loop issued a count + delete per table per FILE — ~13k
1074
+ // single-row write transactions on a ~700-file write setwhich
1075
+ // made this phase slower than a full rebuild and is the WAL-append
1076
+ // storm behind the native mid-writeback deaths in #2409. Errors
1077
+ // are NOT swallowed anymore: a zero-match file is a no-op by
1078
+ // construction, so anything thrown is a real engine failure that
1079
+ // must surface instead of silently skipping (that silent skip was
1080
+ // how #2409 hid its root cause).
1081
+ progress('lbug', 62, `Removing rows for changed files (0/${filesToDelete.length})...`);
1082
+ await deleteNodesForFiles(filesToDelete, {
1083
+ onChunk: (done, total) => progress('lbug', 62, `Removing rows for changed files (${done}/${total})...`),
1084
+ });
1085
+ // Surgical path: Phase 3.5 restores exactly these files' embedding
1086
+ // rows (FIX 3). Sound because deleteNodesForFiles propagates errors
1087
+ // reaching this line means every listed file's rows are gone
1088
+ // deterministically and this process holds the exclusive DB lock,
1089
+ // so no concurrent writer can disturb the derivation.
1090
+ deletedFilePathsForRestore = new Set(filesToDelete);
1091
+ // 2. Drop graph-wide nodes (Community, Process). They'll be re-inserted
1092
+ // from the fresh pipeline output below. Required for the
1093
+ // "Leiden runs on the FULL graph" correctness invariant.
1094
+ await deleteAllCommunitiesAndProcesses();
1095
+ // 2a. Drop INJECTS edges (DI collection injection, #2200) their
1096
+ // validity is a whole-program property (a third-file change to the
1097
+ // interface or an implementer creates/invalidates edges between two
1098
+ // untouched files), so endpoint-writability extraction can't refresh
1099
+ // them; extractChangedSubgraph re-includes all of them from the
1100
+ // fresh graph (isGraphWideRelType). UNCONDITIONAL, next to the
1101
+ // Communities delete — NOT inside the `options.pdg` block below: the
1102
+ // di phase runs on every persisting analyze (same !skipGraphPhases
1103
+ // regime as communities/processes) while the graph-wide re-include
1104
+ // is unconditional, so a pdg-gated delete would append without
1105
+ // deleting on every non-pdg incremental run (N runs = N copies of
1106
+ // every INJECTS row; CodeRelation has no PK and no read-side dedup).
1107
+ await deleteAllInjects();
1108
+ // 2b. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
1109
+ // — their validity is a whole-program property (an A→C flow can be
1110
+ // invalidated by a change to an intermediate function on a third
1111
+ // file), so endpoint-writability extraction can't refresh them.
1112
+ // extractChangedSubgraph re-includes all of them from the fresh
1113
+ // graph (isGraphWideRelType), mirroring Community/Process.
1114
+ if (options.pdg === true) {
1115
+ await deleteAllInterprocTaintPaths();
1116
+ // 2c. Drop CALL_SUMMARY edges (PDG FU-C) on an incremental `--pdg`
1117
+ // writeback. They are re-included from the FULL fresh graph
1118
+ // (isGraphWideRelType) and the callSummaries phase recomputes every
1119
+ // summary each run, so delete-all-then-rebuild keeps an unchanged
1120
+ // function's summary from being lost — same contract as TAINT_PATH.
1121
+ await deleteAllCallSummaries();
1122
+ }
1123
+ // 3. Extract the changed subgraph from the FULL ctx.graph and write
1124
+ // only that. Unchanged-file rows in the DB stay untouched. Pass
1125
+ // the SAME effectiveWriteSet so the subgraph and the deletes
1126
+ // cover identical files (asymmetry would silently corrupt).
1127
+ const subgraph = extractChangedSubgraph(pipelineResult.graph, effectiveWriteSet);
1128
+ await saveIncrementalDirtyState('load-graph', {
1129
+ importerExpansion,
1130
+ shadowSeedCount: shadowSeed.length,
1131
+ effectiveWriteCount: effectiveWriteSet.size,
1132
+ deleteCount: filesToDelete.length,
1133
+ });
1134
+ await loadGraphToLbug(subgraph, pipelineResult.repoPath, storagePath, (msg) => {
1135
+ lbugMsgCount++;
1136
+ const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
1137
+ progress('lbug', pct, msg);
1138
+ });
903
1139
  }
904
- // 3. Extract the changed subgraph from the FULL ctx.graph and write
905
- // only that. Unchanged-file rows in the DB stay untouched. Pass
906
- // the SAME effectiveWriteSet so the subgraph and the deletes
907
- // cover identical files (asymmetry would silently corrupt).
908
- const subgraph = extractChangedSubgraph(pipelineResult.graph, effectiveWriteSet);
909
- await loadGraphToLbug(subgraph, pipelineResult.repoPath, storagePath, (msg) => {
910
- lbugMsgCount++;
911
- const pct = Math.min(84, 65 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 19));
912
- progress('lbug', pct, msg);
913
- });
1140
+ // Boundary drain (#2409): checkpoint at the end of the incremental
1141
+ // writeback so the WAL it accumulated never lingers into the FTS and
1142
+ // embedding phases a later crash leaves only post-checkpoint WAL for
1143
+ // the next open to replay. Near-instant when the periodic driver has
1144
+ // kept up; rides the driver's bounded retry via runCheckpointWithRetry.
1145
+ await checkpointOnce();
914
1146
  }
915
1147
  else {
916
1148
  // ── Full rebuild ───────────────────────────────────────────────
@@ -956,23 +1188,50 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
956
1188
  progress('fts', 90, 'Search indexes ready');
957
1189
  }
958
1190
  else {
959
- log(FTS_UNAVAILABLE_MESSAGE);
1191
+ // For a missing runtime dependency (#2374) the file is present, so the
1192
+ // generic "install it with network access" tail in FTS_UNAVAILABLE_MESSAGE
1193
+ // contradicts the remedy's own "reinstalling will NOT help" (#2383 F2). Lead
1194
+ // with the class-neutral sentence and append only the classified remedy.
1195
+ const ftsReason = getExtensionCapabilities().find((c) => c.name === 'fts')?.reason;
1196
+ const { kind, remedy } = diagnoseExtensionLoad(ftsReason);
1197
+ log(kind === 'missing_dependency'
1198
+ ? `${FTS_UNAVAILABLE_LEAD} ${remedy}`
1199
+ : FTS_UNAVAILABLE_MESSAGE);
960
1200
  progress('fts', 90, 'Search indexes skipped (FTS unavailable)');
961
1201
  }
962
1202
  // ── Phase 3.5: Re-insert cached embeddings ────────────────────────
963
1203
  // Runs on BOTH the full-rebuild path and the incremental path:
964
- // - Full rebuild: DB was wiped, every cached row needs to come back.
965
- // - Incremental: changed-file rows were just deleted by
966
- // deleteNodesForFile (which cascades to their
967
- // embedding rows) so their cached vectors need
968
- // to come back too. Unchanged-file rows still
969
- // exist; re-inserting their cached vectors would
970
- // PK-conflict, but the per-batch try/catch below
971
- // silently ignores those (matches the existing
972
- // "some may fail if node was removed, that's
973
- // fine" semantics). Bugbot review on PR #1479
974
- // flagged that gating this on `!isIncremental`
975
- // silently lost changed-file embeddings.
1204
+ // - Full rebuild / escalated write: DB was wiped, every cached row
1205
+ // needs to come back.
1206
+ // - Incremental (surgical): changed/deleted files' rows were just
1207
+ // deleted by deleteNodesForFiles (a REAL delete since tri-review
1208
+ // 4669518496 P2-1 it joins embedding rows through their owning
1209
+ // nodes), so changed-file vectors need to come back; unchanged-file
1210
+ // rows still exist. Bugbot review on PR #1479 flagged that gating
1211
+ // this on `!isIncremental` silently lost changed-file embeddings.
1212
+ //
1213
+ // Restore discipline (tri-review 4669518496 / KTD10, restore scope
1214
+ // derived in memory since FIX 3 of this shipping review) — filtered and
1215
+ // conflict-free, replacing the old insert-everything-and-swallow shape:
1216
+ // 1. Live-graph filter: rows whose nodeId no longer exists in the
1217
+ // freshly-built FULL graph are dropped. The cache was read BEFORE
1218
+ // the pipeline ran, so it still carries deleted files' rows —
1219
+ // re-inserting them resurrected orphans (wholesale onto the wiped
1220
+ // paths' empty table) now that the delete above is real.
1221
+ // 2. Restore-scope filter, derived WITHOUT touching the DB (the old
1222
+ // shape pre-read every surviving embedding id back out of the
1223
+ // table it had just written): on a wiped path
1224
+ // (`deletedFilePathsForRestore === null`) the table is fresh, so
1225
+ // every live row comes back; on the surgical path only rows whose
1226
+ // owning node's filePath is in the just-join-deleted set are
1227
+ // inserted — everything else still sits in the DB and would
1228
+ // PK-conflict. The derivation is sound because deleteNodesForFiles
1229
+ // propagates errors (a completed writeback means a deterministic
1230
+ // delete outcome) and this process holds the exclusive DB lock (no
1231
+ // concurrent writer).
1232
+ // The per-batch try/catch stays as a last-resort guard only — it no
1233
+ // longer fires on the happy path.
1234
+ let restoredEmbeddingCount = 0;
976
1235
  if (cachedEmbeddings.length > 0) {
977
1236
  const cachedDims = cachedEmbeddings[0].embedding.length;
978
1237
  const { EMBEDDING_DIMS } = await import('./lbug/schema.js');
@@ -983,16 +1242,65 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
983
1242
  cachedEmbeddingNodeIds = new Set();
984
1243
  }
985
1244
  else {
986
- progress('embeddings', 88, `Restoring ${cachedEmbeddings.length} cached embeddings...`);
987
1245
  const { batchInsertEmbeddings: batchInsert } = await import('./embeddings/embedding-pipeline.js');
1246
+ // (1) Live-graph filter — the FULL pipeline graph (always produced),
1247
+ // NOT the incremental subgraph, or unchanged files' rows would be
1248
+ // dropped from the restore set.
1249
+ const liveEmbeddings = cachedEmbeddings.filter((e) => pipelineResult.graph.getNode(e.nodeId) !== undefined);
1250
+ // (2) Restore-scope filter (see the discipline note above).
1251
+ const rowsToRestore = deletedFilePathsForRestore === null
1252
+ ? liveEmbeddings
1253
+ : liveEmbeddings.filter((e) => {
1254
+ const filePath = pipelineResult.graph.getNode(e.nodeId)?.properties?.filePath;
1255
+ return typeof filePath === 'string' && deletedFilePathsForRestore.has(filePath);
1256
+ });
1257
+ progress('embeddings', 88, `Restoring ${rowsToRestore.length} cached embeddings...`);
988
1258
  const EMBED_BATCH = 200;
989
- for (let i = 0; i < cachedEmbeddings.length; i += EMBED_BATCH) {
990
- const batch = cachedEmbeddings.slice(i, i + EMBED_BATCH);
1259
+ for (let i = 0; i < rowsToRestore.length; i += EMBED_BATCH) {
1260
+ const batch = rowsToRestore.slice(i, i + EMBED_BATCH);
991
1261
  try {
992
1262
  await batchInsert(executeWithReusedStatement, batch);
1263
+ restoredEmbeddingCount += batch.length;
993
1264
  }
994
1265
  catch {
995
- /* some may fail if node was removed, that's fine */
1266
+ /* last-resort guard conflict-free by construction above */
1267
+ }
1268
+ }
1269
+ // Legacy-orphan sweep (FIX 3, finder B): the live-graph filter's
1270
+ // REJECTS — cached rows whose owning node no longer exists — are the
1271
+ // rows stranded by the era when the embedding delete was a no-op
1272
+ // (tri-review 4669518496 P2-1; schema version stays 6), plus this
1273
+ // run's just-deleted files' rows (already join-deleted above — the
1274
+ // exact-id DELETE matches nothing for those, so including them is a
1275
+ // harmless no-op rather than worth a fragile nodeId parse to
1276
+ // exclude). On the SURGICAL path the true legacy orphans still sit
1277
+ // in the DB and the node join can never reach them again (no owning
1278
+ // node), so delete them by exact row id. On wiped paths the rejects
1279
+ // were simply not restored — nothing to sweep. Legacy-tolerant: a
1280
+ // sweep failure must never fail a completed writeback, so the whole
1281
+ // sweep warns-and-continues.
1282
+ if (deletedFilePathsForRestore !== null) {
1283
+ const orphanRowIds = cachedEmbeddings
1284
+ .filter((e) => pipelineResult.graph.getNode(e.nodeId) === undefined)
1285
+ .map((e) => `${e.nodeId}:${e.chunkIndex}`);
1286
+ if (orphanRowIds.length > 0) {
1287
+ try {
1288
+ for (let i = 0; i < orphanRowIds.length; i += DELETE_FILES_CHUNK_SIZE) {
1289
+ const chunk = orphanRowIds.slice(i, i + DELETE_FILES_CHUNK_SIZE);
1290
+ const listLiteral = `[${chunk
1291
+ .map((id) => `'${escapeCypherString(id)}'`)
1292
+ .join(', ')}]`;
1293
+ await executeQuery(`MATCH (e:${EMBEDDING_TABLE_NAME}) WHERE e.id IN ${listLiteral} DELETE e`);
1294
+ }
1295
+ log(`Swept ${orphanRowIds.length} cached embedding row(s) with no live owning ` +
1296
+ 'node — legacy orphans stranded while the embedding delete was a no-op; ' +
1297
+ 'ids already removed with their files match nothing.');
1298
+ }
1299
+ catch (err) {
1300
+ log(`Warning: could not sweep ${orphanRowIds.length} orphaned embedding ` +
1301
+ `row(s) (${err.message}); they are unreachable by search ` +
1302
+ 'joins and will be retried next run.');
1303
+ }
996
1304
  }
997
1305
  }
998
1306
  }
@@ -1019,6 +1327,40 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
1019
1327
  `\`--embeddings <n>\` to set a custom cap.`);
1020
1328
  }
1021
1329
  }
1330
+ // ── Vector-index recreation after a wipe-and-restore (tri-review
1331
+ // 4669518496 P1 / KTD1) ────────────────────────────────────────────
1332
+ // The full-rebuild and escalated-incremental write plans wipe the DB
1333
+ // files — the HNSW index with them. Phase 3.5 brought the embedding ROWS
1334
+ // back, but on a preserve-only run nothing recreates the index: semantic
1335
+ // search silently loses its vector lane (>10k-embedding repos return
1336
+ // empty under the exact-scan cap) while meta certified 'vector-index'.
1337
+ // Recreate it here, where every gate input is settled:
1338
+ // - restoredEmbeddingCount > 0 — rows actually came back;
1339
+ // - dbWasWiped — surgical incremental runs keep their index (HNSW
1340
+ // self-maintains on insert/delete); only wiped DBs lost it;
1341
+ // - embeddingSkipped — evaluated AFTER the deriveEmbeddingCap decision
1342
+ // above, NOT `!shouldGenerateEmbeddings`: when Phase 4 really runs,
1343
+ // the pipeline builds the index itself after all inserts (firing this
1344
+ // seam first would swap its bulk build for per-row live HNSW
1345
+ // maintenance on the hottest flow), while a capped >50k-node repo has
1346
+ // shouldGenerateEmbeddings=true yet never runs the pipeline — exactly
1347
+ // the case a naive gate would leave index-less again.
1348
+ // buildVectorIndex carries its own extension-policy gate and
1349
+ // warn-on-failure; the boolean feeds semanticMode so the finalize stamp
1350
+ // reflects the DB's ACTUAL state even when recreation fails (win32 /
1351
+ // extension unavailable → 'exact-scan').
1352
+ const dbWasWiped = !isIncremental || escalatedFullWrite;
1353
+ if (restoredEmbeddingCount > 0 && dbWasWiped && embeddingSkipped) {
1354
+ // Re-import at the seam rather than thread a mutable capture from
1355
+ // Phase 3.5 (FIX 3 of this shipping review — the captured function was
1356
+ // a fragile moving part): dynamic imports are memoized, and
1357
+ // `restoredEmbeddingCount > 0` proves Phase 3.5 already loaded the
1358
+ // module, so the lazy-embeddings convention (#2370) holds — no
1359
+ // embeddings module loads unless a restore actually happened.
1360
+ const { buildVectorIndex } = await import('./embeddings/embedding-pipeline.js');
1361
+ const vectorIndexReady = await buildVectorIndex();
1362
+ semanticMode = vectorIndexReady ? 'vector-index' : 'exact-scan';
1363
+ }
1022
1364
  if (!embeddingSkipped) {
1023
1365
  const { isHttpMode } = await import('./embeddings/http-client.js');
1024
1366
  const httpMode = isHttpMode();
@@ -1068,13 +1410,33 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
1068
1410
  }
1069
1411
  const { getRuntimeCapabilities } = await import('./platform/capabilities.js');
1070
1412
  const runtimeCapabilities = getRuntimeCapabilities();
1413
+ // `semanticMode` is authoritative when set (Phase 4 reported what it
1414
+ // built, or the wipe-and-restore seam above verified/recreated the index
1415
+ // — tri-review 4669518496 P1). When unset, prefer the PREVIOUS run's
1416
+ // persisted stamp over the platform capability (FIX 3, finder A): the
1417
+ // unset case is exactly a run that neither wiped nor generated — e.g. a
1418
+ // surgical incremental whose index survived in place — and such a run
1419
+ // cannot change whether the HNSW index exists, so carrying the persisted
1420
+ // observation forward is strictly more truthful than re-deriving from
1421
+ // what the platform COULD do. Only the two positive observations carry
1422
+ // ('vector-index'/'exact-scan'); 'unavailable'/absent falls through to
1423
+ // the platform default rather than pinning a stale negative.
1424
+ const persistedStatus = existingMeta?.capabilities?.vectorSearch.status;
1425
+ const persistedSemanticMode = persistedStatus === 'vector-index' || persistedStatus === 'exact-scan'
1426
+ ? persistedStatus
1427
+ : undefined;
1071
1428
  const effectiveSemanticMode = semanticMode ??
1429
+ persistedSemanticMode ??
1072
1430
  (runtimeCapabilities.semanticMode === 'vector-index' ? 'vector-index' : 'exact-scan');
1073
1431
  // Convert the post-run file-hash map to the on-disk Record<string,string>
1074
1432
  // shape consumed by RepoMeta.fileHashes.
1075
1433
  const newFileHashesRecord = {};
1076
1434
  for (const [k, v] of newFileHashes)
1077
1435
  newFileHashesRecord[k] = v;
1436
+ // Annotated so the capabilities stamp below is compile-checked against
1437
+ // RepoMeta's status unions (tri-review 4669518496 P1/U3) — an unannotated
1438
+ // literal widens the vectorSearch.status ternary to `string` and the
1439
+ // honesty contract silently decays to "whatever interpolates".
1078
1440
  const meta = {
1079
1441
  repoPath,
1080
1442
  lastCommit: currentCommit,