pi-lens 4.3.0 → 4.4.0

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 (650) hide show
  1. package/CHANGELOG.md +1064 -0
  2. package/README.md +115 -25
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -2
  4. package/dist/chunk-22AVIGXS.js +560 -0
  5. package/dist/chunk-27BBHSZC.js +106 -0
  6. package/dist/chunk-2BEMIWEW.js +51 -0
  7. package/dist/chunk-2EECBNC5.js +89 -0
  8. package/dist/chunk-34U7PLGE.js +23 -0
  9. package/dist/chunk-3I5QFT3N.js +181 -0
  10. package/dist/chunk-3MED75HT.js +37 -0
  11. package/dist/chunk-47D2BO4S.js +87 -0
  12. package/dist/chunk-4RERDEXR.js +348 -0
  13. package/dist/chunk-4W2PLC4J.js +27 -0
  14. package/dist/chunk-52H5B5TC.js +202 -0
  15. package/dist/chunk-54B42YRX.js +27 -0
  16. package/dist/chunk-5RXBNSW2.js +266 -0
  17. package/dist/chunk-5THXD6X5.js +201 -0
  18. package/dist/chunk-5W3R6LFT.js +24 -0
  19. package/dist/chunk-5ZEJHV35.js +341 -0
  20. package/dist/chunk-6P62QVEA.js +118 -0
  21. package/dist/chunk-6QIUX4EP.js +459 -0
  22. package/dist/chunk-6VCD4VKZ.js +78 -0
  23. package/dist/chunk-7F72PXY2.js +326 -0
  24. package/dist/chunk-7FBF7NPR.js +998 -0
  25. package/dist/chunk-7U2IDNXN.js +37 -0
  26. package/dist/chunk-ABBOA7UD.js +157 -0
  27. package/dist/chunk-AHDW3GPE.js +50 -0
  28. package/dist/chunk-AJ3QXTRQ.js +756 -0
  29. package/dist/chunk-AJVLWNFN.js +1610 -0
  30. package/dist/chunk-AJXODZY6.js +112 -0
  31. package/dist/chunk-ARFCXG7S.js +84 -0
  32. package/dist/chunk-AS44CFQ4.js +708 -0
  33. package/dist/chunk-AWE5TMPO.js +38 -0
  34. package/dist/chunk-B3I2UUT4.js +4692 -0
  35. package/dist/chunk-BRJKOBZP.js +331 -0
  36. package/dist/chunk-BW5KFIQC.js +59 -0
  37. package/dist/chunk-C2AUYXQH.js +149 -0
  38. package/dist/chunk-CGGAIP7J.js +29 -0
  39. package/dist/chunk-D6QS3U6M.js +30 -0
  40. package/dist/chunk-DFVMIKMQ.js +349 -0
  41. package/dist/chunk-DIULIWKU.js +169 -0
  42. package/dist/chunk-DQ2NZ7C4.js +2317 -0
  43. package/dist/chunk-E2WFBRHM.js +213 -0
  44. package/dist/chunk-E4MAEDPM.js +50 -0
  45. package/dist/chunk-EOKRQ3SA.js +30 -0
  46. package/dist/chunk-F3AU2TMM.js +2626 -0
  47. package/dist/chunk-F3P7VQ7H.js +170 -0
  48. package/dist/chunk-FC4RP4I2.js +12 -0
  49. package/dist/chunk-FJNKANOS.js +32 -0
  50. package/dist/chunk-GUOGVTCQ.js +146 -0
  51. package/dist/chunk-GVXA6CCE.js +18 -0
  52. package/dist/chunk-HHYAELLJ.js +2334 -0
  53. package/dist/chunk-HMFXILPV.js +83 -0
  54. package/dist/chunk-HVUZXSWK.js +421 -0
  55. package/dist/chunk-HWBZIGMJ.js +62 -0
  56. package/dist/chunk-I3R7GKDR.js +17 -0
  57. package/dist/chunk-I7SPGOWI.js +55 -0
  58. package/dist/chunk-IIXOAGHR.js +38 -0
  59. package/dist/chunk-IVP5KAB7.js +2250 -0
  60. package/dist/chunk-IXEBJSUE.js +22 -0
  61. package/dist/chunk-JOLITGCC.js +27 -0
  62. package/dist/chunk-K6VYLB2U.js +145 -0
  63. package/dist/chunk-LJDODBBZ.js +78 -0
  64. package/dist/chunk-LNAKEO63.js +78 -0
  65. package/dist/chunk-LSMJ6KBE.js +4139 -0
  66. package/dist/chunk-LW6XKDZQ.js +5565 -0
  67. package/dist/chunk-N3YQJI6O.js +899 -0
  68. package/dist/chunk-NABLC7W3.js +148 -0
  69. package/dist/chunk-NKOUFPQF.js +659 -0
  70. package/dist/chunk-NL5GLMFI.js +211 -0
  71. package/dist/chunk-NMABWBZN.js +327 -0
  72. package/dist/chunk-NTHH2J74.js +27 -0
  73. package/dist/chunk-NTUJEGDZ.js +59 -0
  74. package/dist/chunk-NXL4FFQQ.js +47 -0
  75. package/dist/chunk-NXPS2L6U.js +466 -0
  76. package/dist/chunk-O5WILGMI.js +42 -0
  77. package/dist/chunk-O6TQT6RI.js +48 -0
  78. package/dist/chunk-OAXNDZJ7.js +323 -0
  79. package/dist/chunk-ODEEOXPH.js +44 -0
  80. package/dist/chunk-OQGFCZJ4.js +4533 -0
  81. package/dist/chunk-ORTMY4Q4.js +282 -0
  82. package/dist/chunk-OS32RNR2.js +442 -0
  83. package/dist/chunk-PAK6IDME.js +189 -0
  84. package/dist/chunk-PWFHBTSF.js +406 -0
  85. package/dist/chunk-Q5U6FMDI.js +2352 -0
  86. package/dist/chunk-QOZXJ4NA.js +3364 -0
  87. package/dist/chunk-QYF5S6P6.js +45 -0
  88. package/dist/chunk-RIISMHKN.js +36 -0
  89. package/dist/chunk-RPD4ZSB7.js +25 -0
  90. package/dist/chunk-RPD5K5NC.js +79 -0
  91. package/dist/chunk-RUOG5D2C.js +262 -0
  92. package/dist/chunk-T4ERT73Y.js +599 -0
  93. package/dist/chunk-T6D4Q4AQ.js +54 -0
  94. package/dist/chunk-TGPXLY2J.js +391 -0
  95. package/dist/chunk-TLGFBM3F.js +12116 -0
  96. package/dist/chunk-TO6WL3GM.js +88 -0
  97. package/dist/chunk-TT22N74J.js +517 -0
  98. package/dist/chunk-TVN5N6GY.js +21216 -0
  99. package/dist/chunk-U3VGR4VU.js +308 -0
  100. package/dist/chunk-UK3CMEAL.js +1250 -0
  101. package/dist/chunk-UOPVJQ4N.js +103 -0
  102. package/dist/chunk-UXVNL3D7.js +23 -0
  103. package/dist/chunk-UY752QSN.js +45 -0
  104. package/dist/chunk-V5PXFSZK.js +188 -0
  105. package/dist/chunk-V7R425XG.js +398 -0
  106. package/dist/chunk-VCSRSYLF.js +81 -0
  107. package/dist/chunk-VD4JQCQC.js +162 -0
  108. package/dist/chunk-VDBPVIKD.js +62 -0
  109. package/dist/chunk-VHQLPIRJ.js +114 -0
  110. package/dist/chunk-VN2AXLEX.js +5414 -0
  111. package/dist/chunk-VNMT7C64.js +478 -0
  112. package/dist/chunk-WONERJTP.js +309 -0
  113. package/dist/chunk-XECLWQZN.js +85 -0
  114. package/dist/chunk-XIMYZ3JA.js +27 -0
  115. package/dist/chunk-XK4CIILI.js +144 -0
  116. package/dist/chunk-Y4T27COV.js +562 -0
  117. package/dist/chunk-YGVYKNSN.js +1860 -0
  118. package/dist/chunk-YLF3PPT5.js +308 -0
  119. package/dist/chunk-YSNFMJKN.js +14 -0
  120. package/dist/chunk-Z3CCVDUB.js +76 -0
  121. package/dist/chunk-Z54ZSOBZ.js +61 -0
  122. package/dist/chunk-ZAWW3AF6.js +97 -0
  123. package/dist/chunk-ZMOL6RQC.js +167 -0
  124. package/dist/index.js +16456 -8335
  125. package/dist/mcp/analyze-cli.js +186 -232
  126. package/dist/mcp/cli.js +137 -98
  127. package/dist/mcp/server.js +22589 -1573
  128. package/dist/mcp/worker.js +97 -38
  129. package/dist/probes/installer.js +135 -0
  130. package/dist/workers/project-snapshot-persist-worker.js +22 -0
  131. package/dist/workers/review-graph-persist-worker.js +15 -0
  132. package/docs/agent-guide.md +13 -4
  133. package/docs/agent-tools.md +42 -0
  134. package/docs/ast-grep_rules_catalog.md +3 -2
  135. package/docs/configuration.md +20 -1
  136. package/docs/environment-variables.md +52 -2
  137. package/docs/features.md +8 -4
  138. package/docs/globalconfig.md +1 -0
  139. package/docs/io-bridge-v2-migration.md +94 -0
  140. package/docs/lsp-capability-matrix.md +36 -4
  141. package/docs/lsp-idle-eviction.md +124 -0
  142. package/docs/mcp.md +21 -5
  143. package/docs/merge-queue-rollout.md +77 -0
  144. package/docs/pi-lens-fixer.md +156 -88
  145. package/docs/pi-lens-investigator.md +74 -26
  146. package/docs/pi-lens-merge-policy.md +694 -0
  147. package/docs/pi-lens-monitor.md +73 -7
  148. package/docs/pi-lens-retro.md +6 -4
  149. package/docs/pi-lens-reviewer.md +188 -48
  150. package/docs/pi-lens-subagent.md +246 -45
  151. package/docs/pi-lens-warden.md +18 -7
  152. package/docs/public-api-stability.md +55 -0
  153. package/docs/release-qa-baseline.md +84 -17
  154. package/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  155. package/docs/servercapabilities.md +53 -49
  156. package/docs/settings.md +1 -0
  157. package/docs/usage.md +5 -1
  158. package/grammars/tree-sitter-bash.wasm +0 -0
  159. package/grammars/tree-sitter-bash.wasm.json +3 -3
  160. package/package.json +19 -7
  161. package/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  162. package/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  163. package/scripts/analyze-pi-lens-logs.mjs +1275 -62
  164. package/scripts/download-grammars.js +5 -0
  165. package/scripts/grammars.lock.json +6 -1
  166. package/scripts/install-selftest.mjs +33 -34
  167. package/scripts/lib/host-provided-deps.mjs +13 -4
  168. package/scripts/lib/web-tree-sitter-dir.mjs +18 -0
  169. package/dist/clients/actionable-warnings-logger.js +0 -35
  170. package/dist/clients/actionable-warnings.js +0 -1610
  171. package/dist/clients/advisory-provenance.js +0 -593
  172. package/dist/clients/agent-behavior-client.js +0 -147
  173. package/dist/clients/agent-nudge.js +0 -325
  174. package/dist/clients/analysed-root.js +0 -1
  175. package/dist/clients/ast-grep-client.js +0 -731
  176. package/dist/clients/ast-grep-rule-manager.js +0 -146
  177. package/dist/clients/ast-grep-tool-logger.js +0 -118
  178. package/dist/clients/ast-grep-types.js +0 -10
  179. package/dist/clients/ast-grep-yaml-synth.js +0 -154
  180. package/dist/clients/atomic-write-staging.js +0 -75
  181. package/dist/clients/atomic-write.js +0 -189
  182. package/dist/clients/availability-probe-flight.js +0 -12
  183. package/dist/clients/bash-file-access.js +0 -1056
  184. package/dist/clients/biome-client.js +0 -357
  185. package/dist/clients/blocker-freshness.js +0 -837
  186. package/dist/clients/blocker-past-eof.js +0 -129
  187. package/dist/clients/bootstrap.js +0 -569
  188. package/dist/clients/bounded-cache.js +0 -190
  189. package/dist/clients/bounded-pid-file-lock.js +0 -202
  190. package/dist/clients/bounded-telemetry.js +0 -295
  191. package/dist/clients/build-identity.js +0 -95
  192. package/dist/clients/bundled-resource-health.js +0 -113
  193. package/dist/clients/bus-events-logger.js +0 -105
  194. package/dist/clients/bus-publish.js +0 -171
  195. package/dist/clients/cache/rule-cache.js +0 -190
  196. package/dist/clients/cache-manager.js +0 -524
  197. package/dist/clients/cache-observability.js +0 -1313
  198. package/dist/clients/call-graph.js +0 -852
  199. package/dist/clients/cargo-manifest.js +0 -422
  200. package/dist/clients/cascade-budget.js +0 -196
  201. package/dist/clients/cascade-format.js +0 -315
  202. package/dist/clients/cascade-logger.js +0 -31
  203. package/dist/clients/cascade-types.js +0 -9
  204. package/dist/clients/child-unref.js +0 -124
  205. package/dist/clients/code-quality-warnings.js +0 -286
  206. package/dist/clients/codebase-model.js +0 -173
  207. package/dist/clients/collateral-test-role.js +0 -40
  208. package/dist/clients/complexity-client.js +0 -462
  209. package/dist/clients/config-core/deny.js +0 -221
  210. package/dist/clients/config-core/index.js +0 -50
  211. package/dist/clients/config-core/merge.js +0 -357
  212. package/dist/clients/config-core/normalize.js +0 -340
  213. package/dist/clients/config-core/process-spec.js +0 -248
  214. package/dist/clients/config-core/provenance.js +0 -164
  215. package/dist/clients/config-core/records.js +0 -218
  216. package/dist/clients/config-core/resolve.js +0 -125
  217. package/dist/clients/config-core/safe-object.js +0 -78
  218. package/dist/clients/config-core/schema.js +0 -165
  219. package/dist/clients/config-diagnostic-codes.js +0 -285
  220. package/dist/clients/config-locations.js +0 -388
  221. package/dist/clients/config-resolve.js +0 -798
  222. package/dist/clients/config-schema.js +0 -251
  223. package/dist/clients/config-warn.js +0 -407
  224. package/dist/clients/console-guard-install.js +0 -26
  225. package/dist/clients/cooperative-budget.js +0 -42
  226. package/dist/clients/dead-code-client.js +0 -510
  227. package/dist/clients/dead-code-logger.js +0 -30
  228. package/dist/clients/deadline-utils.js +0 -280
  229. package/dist/clients/debug-handles.js +0 -211
  230. package/dist/clients/debug-heap.js +0 -174
  231. package/dist/clients/deferred-lsp-work.js +0 -106
  232. package/dist/clients/degradation-ledger.js +0 -426
  233. package/dist/clients/demoted-finding-render.js +0 -169
  234. package/dist/clients/dependency-checker.js +0 -885
  235. package/dist/clients/deps/ast-grep-napi.js +0 -22
  236. package/dist/clients/deps/js-yaml.js +0 -9
  237. package/dist/clients/deps/minimatch.js +0 -4
  238. package/dist/clients/deps/pi-tui.js +0 -14
  239. package/dist/clients/deps/typebox.js +0 -14
  240. package/dist/clients/deps/vscode-jsonrpc.js +0 -1
  241. package/dist/clients/deps/web-tree-sitter.js +0 -24
  242. package/dist/clients/diagnostic-dispositions.js +0 -630
  243. package/dist/clients/diagnostic-line-freshness.js +0 -305
  244. package/dist/clients/diagnostic-logger.js +0 -73
  245. package/dist/clients/diagnostic-tracker.js +0 -105
  246. package/dist/clients/diagnostics-publish.js +0 -230
  247. package/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  248. package/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  249. package/dist/clients/dispatch/collect-later-tier.js +0 -35
  250. package/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  251. package/dist/clients/dispatch/dispatcher.js +0 -1269
  252. package/dist/clients/dispatch/fact-provider-types.js +0 -1
  253. package/dist/clients/dispatch/fact-rule-runner.js +0 -17
  254. package/dist/clients/dispatch/fact-runner.js +0 -30
  255. package/dist/clients/dispatch/fact-scheduler.js +0 -68
  256. package/dist/clients/dispatch/fact-store.js +0 -377
  257. package/dist/clients/dispatch/facts/comment-facts.js +0 -22
  258. package/dist/clients/dispatch/facts/file-content.js +0 -19
  259. package/dist/clients/dispatch/facts/function-facts.js +0 -348
  260. package/dist/clients/dispatch/facts/import-facts.js +0 -204
  261. package/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  262. package/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  263. package/dist/clients/dispatch/finding-policy.js +0 -339
  264. package/dist/clients/dispatch/indent-detect.js +0 -417
  265. package/dist/clients/dispatch/inline-suppressions.js +0 -89
  266. package/dist/clients/dispatch/integration.js +0 -2377
  267. package/dist/clients/dispatch/lazy.js +0 -11
  268. package/dist/clients/dispatch/pending-runner-findings.js +0 -87
  269. package/dist/clients/dispatch/plan.js +0 -278
  270. package/dist/clients/dispatch/priorities.js +0 -20
  271. package/dist/clients/dispatch/rule-id-normalize.js +0 -50
  272. package/dist/clients/dispatch/rule-ignores.js +0 -69
  273. package/dist/clients/dispatch/rule-policy.js +0 -135
  274. package/dist/clients/dispatch/rules/async-noise.js +0 -40
  275. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  276. package/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  277. package/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  278. package/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  279. package/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  280. package/dist/clients/dispatch/rules/high-complexity.js +0 -72
  281. package/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  282. package/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  283. package/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  284. package/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  285. package/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  286. package/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  287. package/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  288. package/dist/clients/dispatch/runner-context.js +0 -52
  289. package/dist/clients/dispatch/runners/actionlint.js +0 -111
  290. package/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  291. package/dist/clients/dispatch/runners/biome-check.js +0 -296
  292. package/dist/clients/dispatch/runners/cpp-check.js +0 -219
  293. package/dist/clients/dispatch/runners/credo.js +0 -82
  294. package/dist/clients/dispatch/runners/cue-vet.js +0 -416
  295. package/dist/clients/dispatch/runners/dart-analyze.js +0 -183
  296. package/dist/clients/dispatch/runners/detekt.js +0 -176
  297. package/dist/clients/dispatch/runners/dotnet-build.js +0 -164
  298. package/dist/clients/dispatch/runners/elixir-check.js +0 -195
  299. package/dist/clients/dispatch/runners/eslint.js +0 -137
  300. package/dist/clients/dispatch/runners/fact-rules.js +0 -29
  301. package/dist/clients/dispatch/runners/fish-indent.js +0 -69
  302. package/dist/clients/dispatch/runners/gleam-check.js +0 -167
  303. package/dist/clients/dispatch/runners/go-vet.js +0 -104
  304. package/dist/clients/dispatch/runners/golangci-lint.js +0 -135
  305. package/dist/clients/dispatch/runners/hadolint.js +0 -91
  306. package/dist/clients/dispatch/runners/helm-lint.js +0 -171
  307. package/dist/clients/dispatch/runners/helm-render.js +0 -940
  308. package/dist/clients/dispatch/runners/htmlhint.js +0 -117
  309. package/dist/clients/dispatch/runners/index.js +0 -108
  310. package/dist/clients/dispatch/runners/javac.js +0 -99
  311. package/dist/clients/dispatch/runners/ktlint.js +0 -110
  312. package/dist/clients/dispatch/runners/lsp.js +0 -379
  313. package/dist/clients/dispatch/runners/markdownlint.js +0 -167
  314. package/dist/clients/dispatch/runners/mypy.js +0 -102
  315. package/dist/clients/dispatch/runners/oxlint.js +0 -414
  316. package/dist/clients/dispatch/runners/php-lint.js +0 -85
  317. package/dist/clients/dispatch/runners/phpstan.js +0 -120
  318. package/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  319. package/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  320. package/dist/clients/dispatch/runners/pyright.js +0 -169
  321. package/dist/clients/dispatch/runners/rubocop.js +0 -99
  322. package/dist/clients/dispatch/runners/ruff.js +0 -88
  323. package/dist/clients/dispatch/runners/rust-clippy.js +0 -256
  324. package/dist/clients/dispatch/runners/shellcheck.js +0 -173
  325. package/dist/clients/dispatch/runners/shfmt.js +0 -102
  326. package/dist/clients/dispatch/runners/spellcheck.js +0 -128
  327. package/dist/clients/dispatch/runners/spotbugs.js +0 -259
  328. package/dist/clients/dispatch/runners/sqlfluff.js +0 -183
  329. package/dist/clients/dispatch/runners/stylelint.js +0 -166
  330. package/dist/clients/dispatch/runners/swiftlint.js +0 -163
  331. package/dist/clients/dispatch/runners/taplo.js +0 -174
  332. package/dist/clients/dispatch/runners/terragrunt.js +0 -168
  333. package/dist/clients/dispatch/runners/tflint.js +0 -125
  334. package/dist/clients/dispatch/runners/tree-sitter.js +0 -643
  335. package/dist/clients/dispatch/runners/trivy-config.js +0 -253
  336. package/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  337. package/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  338. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -95
  339. package/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  340. package/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  341. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  342. package/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  343. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  344. package/dist/clients/dispatch/runners/utils.js +0 -15
  345. package/dist/clients/dispatch/runners/vale.js +0 -128
  346. package/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  347. package/dist/clients/dispatch/runners/yamllint.js +0 -95
  348. package/dist/clients/dispatch/runners/zig-check.js +0 -79
  349. package/dist/clients/dispatch/suppress-writer.js +0 -79
  350. package/dist/clients/dispatch/tool-profile.js +0 -30
  351. package/dist/clients/dispatch/types.js +0 -21
  352. package/dist/clients/dispatch/utils/format-utils.js +0 -45
  353. package/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  354. package/dist/clients/disposition-logger.js +0 -32
  355. package/dist/clients/disposition-publish.js +0 -124
  356. package/dist/clients/durable-store.js +0 -97
  357. package/dist/clients/effective-config.js +0 -415
  358. package/dist/clients/env-utils.js +0 -83
  359. package/dist/clients/error-class.js +0 -23
  360. package/dist/clients/eval-timestamp.js +0 -17
  361. package/dist/clients/event-loop-hold.js +0 -274
  362. package/dist/clients/event-loop-monitor.js +0 -250
  363. package/dist/clients/extension-log.js +0 -504
  364. package/dist/clients/extension-mode.js +0 -73
  365. package/dist/clients/feature-hints.js +0 -103
  366. package/dist/clients/file-kinds.js +0 -412
  367. package/dist/clients/file-role.js +0 -134
  368. package/dist/clients/file-time.js +0 -160
  369. package/dist/clients/file-utils.js +0 -1032
  370. package/dist/clients/finding-delivery-gate.js +0 -494
  371. package/dist/clients/finding-identity.js +0 -88
  372. package/dist/clients/fix-worklog.js +0 -103
  373. package/dist/clients/format-events-publish.js +0 -343
  374. package/dist/clients/format-service.js +0 -222
  375. package/dist/clients/formatters-lazy.js +0 -9
  376. package/dist/clients/formatters.js +0 -2032
  377. package/dist/clients/freshness-cadence.js +0 -17
  378. package/dist/clients/freshness.js +0 -38
  379. package/dist/clients/generated-artifacts.js +0 -394
  380. package/dist/clients/generation-guard.js +0 -215
  381. package/dist/clients/git-guard.js +0 -1136
  382. package/dist/clients/git-tracked-ignore.js +0 -243
  383. package/dist/clients/gitleaks-client.js +0 -509
  384. package/dist/clients/go-client.js +0 -104
  385. package/dist/clients/govulncheck-client.js +0 -605
  386. package/dist/clients/gradle-ktfmt-style.js +0 -348
  387. package/dist/clients/grammar-source.js +0 -465
  388. package/dist/clients/gzip-stage-write.js +0 -135
  389. package/dist/clients/hashline-anchor.js +0 -424
  390. package/dist/clients/hook-budgets.js +0 -76
  391. package/dist/clients/host-edit-normalize.js +0 -140
  392. package/dist/clients/host-ports.js +0 -39
  393. package/dist/clients/indent-retarget.js +0 -154
  394. package/dist/clients/inline-blocker-dispositions.js +0 -186
  395. package/dist/clients/install-diagnostics.js +0 -156
  396. package/dist/clients/installer/index.js +0 -5416
  397. package/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  398. package/dist/clients/installer/managed-tool-refresh.js +0 -783
  399. package/dist/clients/instance-reaper-state.js +0 -7
  400. package/dist/clients/instance-reaper.js +0 -1137
  401. package/dist/clients/instance-registry-lock.js +0 -145
  402. package/dist/clients/instance-registry.js +0 -773
  403. package/dist/clients/jscpd-client.js +0 -378
  404. package/dist/clients/json-cache-read.js +0 -65
  405. package/dist/clients/knip-client.js +0 -788
  406. package/dist/clients/language-policy.js +0 -252
  407. package/dist/clients/language-profile.js +0 -361
  408. package/dist/clients/language-registry.js +0 -603
  409. package/dist/clients/latency-logger.js +0 -727
  410. package/dist/clients/lazy-import.js +0 -21
  411. package/dist/clients/ledger-bounds.js +0 -54
  412. package/dist/clients/lens-config.js +0 -404
  413. package/dist/clients/lens-engine.js +0 -358
  414. package/dist/clients/lens-events.js +0 -125
  415. package/dist/clients/lens-flag-registry.js +0 -467
  416. package/dist/clients/lens-map.js +0 -1156
  417. package/dist/clients/live-bus-emitter.js +0 -74
  418. package/dist/clients/log-cleanup.js +0 -222
  419. package/dist/clients/lsp/aggregation.js +0 -178
  420. package/dist/clients/lsp/cascade-tier.js +0 -421
  421. package/dist/clients/lsp/client.js +0 -4593
  422. package/dist/clients/lsp/config.js +0 -691
  423. package/dist/clients/lsp/content-limits.js +0 -41
  424. package/dist/clients/lsp/diagnostic-binding.js +0 -225
  425. package/dist/clients/lsp/document-drift.js +0 -467
  426. package/dist/clients/lsp/edits.js +0 -1219
  427. package/dist/clients/lsp/index.js +0 -8305
  428. package/dist/clients/lsp/inferred-project.js +0 -216
  429. package/dist/clients/lsp/jvm-runtime.js +0 -298
  430. package/dist/clients/lsp/language.js +0 -63
  431. package/dist/clients/lsp/launch.js +0 -746
  432. package/dist/clients/lsp/lombok.js +0 -158
  433. package/dist/clients/lsp/path-utils.js +0 -5
  434. package/dist/clients/lsp/pending-aux-coverage.js +0 -251
  435. package/dist/clients/lsp/position-encoding.js +0 -61
  436. package/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  437. package/dist/clients/lsp/server.js +0 -3290
  438. package/dist/clients/lsp/session-roots.js +0 -192
  439. package/dist/clients/lsp/spawn-history.js +0 -17
  440. package/dist/clients/lsp/sync-kind.js +0 -56
  441. package/dist/clients/lsp/tsserver-sync.js +0 -332
  442. package/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  443. package/dist/clients/lsp/wait-policy/classification.js +0 -85
  444. package/dist/clients/lsp/wait-policy/index.js +0 -10
  445. package/dist/clients/lsp/wait-policy/strategies.js +0 -392
  446. package/dist/clients/lsp/watch-queue.js +0 -61
  447. package/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  448. package/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  449. package/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  450. package/dist/clients/lsp-budget.js +0 -186
  451. package/dist/clients/lsp-document-symbols.js +0 -110
  452. package/dist/clients/lsp-lazy.js +0 -9
  453. package/dist/clients/lsp-mutation.js +0 -396
  454. package/dist/clients/lsp-status.js +0 -59
  455. package/dist/clients/map-with-concurrency.js +0 -40
  456. package/dist/clients/mcp/analyze.js +0 -420
  457. package/dist/clients/mcp/host-shim.js +0 -31
  458. package/dist/clients/mcp/ipc.js +0 -523
  459. package/dist/clients/mcp/review.js +0 -197
  460. package/dist/clients/mcp/session.js +0 -412
  461. package/dist/clients/memory-sampler.js +0 -208
  462. package/dist/clients/message-end-attribution.js +0 -30
  463. package/dist/clients/metrics-client.js +0 -107
  464. package/dist/clients/metrics-history.js +0 -327
  465. package/dist/clients/middle-man-analysis.js +0 -263
  466. package/dist/clients/model-provider.js +0 -53
  467. package/dist/clients/module-report-lsp.js +0 -189
  468. package/dist/clients/module-report.js +0 -2150
  469. package/dist/clients/mutating-tool.js +0 -651
  470. package/dist/clients/mutation-attribution.js +0 -368
  471. package/dist/clients/mutation-bridge.js +0 -240
  472. package/dist/clients/ndjson-logger.js +0 -734
  473. package/dist/clients/observed-mutation-sources.js +0 -101
  474. package/dist/clients/observed-mutation.js +0 -1253
  475. package/dist/clients/oldtext-autopatch.js +0 -128
  476. package/dist/clients/opaque-mutation-scan.js +0 -496
  477. package/dist/clients/opengrep-client.js +0 -333
  478. package/dist/clients/opengrep-config.js +0 -76
  479. package/dist/clients/package-manager.js +0 -607
  480. package/dist/clients/package-root.js +0 -38
  481. package/dist/clients/partial-edit-apply.js +0 -439
  482. package/dist/clients/path-attribution-telemetry.js +0 -43
  483. package/dist/clients/path-keyed-map.js +0 -108
  484. package/dist/clients/path-utils.js +0 -1244
  485. package/dist/clients/performance-report.js +0 -331
  486. package/dist/clients/persist-debounce.js +0 -70
  487. package/dist/clients/persistent-reverify.js +0 -271
  488. package/dist/clients/php-cs-fixer-config.js +0 -119
  489. package/dist/clients/pipeline.js +0 -1480
  490. package/dist/clients/post-autofix-notice.js +0 -37
  491. package/dist/clients/print-mode.js +0 -21
  492. package/dist/clients/probe-home-state.js +0 -228
  493. package/dist/clients/process-bridge.js +0 -66
  494. package/dist/clients/process-singletons.js +0 -210
  495. package/dist/clients/process-snapshot.js +0 -68
  496. package/dist/clients/project-changes.js +0 -181
  497. package/dist/clients/project-conventions.js +0 -171
  498. package/dist/clients/project-diagnostics/cache.js +0 -137
  499. package/dist/clients/project-diagnostics/extractors.js +0 -109
  500. package/dist/clients/project-diagnostics/fresh-fetch.js +0 -668
  501. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  502. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  503. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  504. package/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  505. package/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  506. package/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  507. package/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  508. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  509. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  510. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  511. package/dist/clients/project-diagnostics/scanner.js +0 -520
  512. package/dist/clients/project-diagnostics/types.js +0 -1
  513. package/dist/clients/project-lens-config.js +0 -807
  514. package/dist/clients/project-report.js +0 -701
  515. package/dist/clients/project-scale.js +0 -237
  516. package/dist/clients/project-scan-policy.js +0 -11
  517. package/dist/clients/project-snapshot-fingerprint.js +0 -48
  518. package/dist/clients/project-snapshot-persist-worker.js +0 -20
  519. package/dist/clients/project-snapshot.js +0 -1653
  520. package/dist/clients/project-trust.js +0 -181
  521. package/dist/clients/python-environment.js +0 -217
  522. package/dist/clients/python-provenance.js +0 -639
  523. package/dist/clients/quiet-window-config.js +0 -47
  524. package/dist/clients/quiet-window.js +0 -236
  525. package/dist/clients/read-bridge.js +0 -139
  526. package/dist/clients/read-expansion.js +0 -266
  527. package/dist/clients/read-guard-logger.js +0 -251
  528. package/dist/clients/read-guard-tool-lines.js +0 -1306
  529. package/dist/clients/read-guard.js +0 -1561
  530. package/dist/clients/recent-touches.js +0 -240
  531. package/dist/clients/redact/secrets.js +0 -449
  532. package/dist/clients/resource-sampler.js +0 -628
  533. package/dist/clients/reverse-deps.js +0 -270
  534. package/dist/clients/review-graph/builder.js +0 -5126
  535. package/dist/clients/review-graph/format.js +0 -33
  536. package/dist/clients/review-graph/git-identity.js +0 -163
  537. package/dist/clients/review-graph/import-resolvers.js +0 -481
  538. package/dist/clients/review-graph/persist-worker.js +0 -10
  539. package/dist/clients/review-graph/query.js +0 -244
  540. package/dist/clients/review-graph/revision-drift.js +0 -21
  541. package/dist/clients/review-graph/service.js +0 -96
  542. package/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  543. package/dist/clients/review-graph/symbol-id.js +0 -103
  544. package/dist/clients/review-graph/tsconfig-paths.js +0 -299
  545. package/dist/clients/review-graph/types.js +0 -1
  546. package/dist/clients/review-graph/workspace-modules.js +0 -449
  547. package/dist/clients/review-graph-logger.js +0 -81
  548. package/dist/clients/ruff-client.js +0 -179
  549. package/dist/clients/rules-scanner.js +0 -131
  550. package/dist/clients/run-duration.js +0 -55
  551. package/dist/clients/runtime-agent-end.js +0 -746
  552. package/dist/clients/runtime-config.js +0 -77
  553. package/dist/clients/runtime-context.js +0 -211
  554. package/dist/clients/runtime-coordinator.js +0 -1324
  555. package/dist/clients/runtime-session.js +0 -2485
  556. package/dist/clients/runtime-tool-call.js +0 -1295
  557. package/dist/clients/runtime-tool-result.js +0 -2049
  558. package/dist/clients/runtime-turn.js +0 -3767
  559. package/dist/clients/rust-client.js +0 -97
  560. package/dist/clients/safe-spawn.js +0 -2357
  561. package/dist/clients/sanitize.js +0 -286
  562. package/dist/clients/scan-utils.js +0 -25
  563. package/dist/clients/scratch-tree-policy.js +0 -165
  564. package/dist/clients/search-read-registration.js +0 -94
  565. package/dist/clients/secret-findings.js +0 -114
  566. package/dist/clients/security-scan-client.js +0 -352
  567. package/dist/clients/session-event-guard.js +0 -297
  568. package/dist/clients/session-lifecycle.js +0 -484
  569. package/dist/clients/session-start-observability.js +0 -94
  570. package/dist/clients/session-state-store.js +0 -101
  571. package/dist/clients/session-summary.js +0 -15
  572. package/dist/clients/sessionstart-logger.js +0 -27
  573. package/dist/clients/sg-runner.js +0 -876
  574. package/dist/clients/sgconfig.js +0 -347
  575. package/dist/clients/shared-checkout-guard.js +0 -338
  576. package/dist/clients/single-flight.js +0 -158
  577. package/dist/clients/situational-tool-telemetry.js +0 -108
  578. package/dist/clients/skills-resolver.js +0 -141
  579. package/dist/clients/slow-fs.js +0 -138
  580. package/dist/clients/smells-rollup.js +0 -231
  581. package/dist/clients/source-filter.js +0 -596
  582. package/dist/clients/source-walker.js +0 -211
  583. package/dist/clients/spawn-output-cap.js +0 -120
  584. package/dist/clients/spawn-timeout-cooldown.js +0 -110
  585. package/dist/clients/stale-marker.js +0 -17
  586. package/dist/clients/startup-scan.js +0 -400
  587. package/dist/clients/startup-timing.js +0 -130
  588. package/dist/clients/string-utils.js +0 -33
  589. package/dist/clients/subagent-mode.js +0 -111
  590. package/dist/clients/symbol-containment.js +0 -24
  591. package/dist/clients/symbol-types.js +0 -5
  592. package/dist/clients/test-runner-client.js +0 -2507
  593. package/dist/clients/test-runner-delivery.js +0 -287
  594. package/dist/clients/todo-scanner.js +0 -229
  595. package/dist/clients/tool-agreement.js +0 -423
  596. package/dist/clients/tool-config.js +0 -249
  597. package/dist/clients/tool-cwd.js +0 -270
  598. package/dist/clients/tool-definition.js +0 -47
  599. package/dist/clients/tool-event.js +0 -105
  600. package/dist/clients/tool-policy.js +0 -2755
  601. package/dist/clients/tool-probe.js +0 -53
  602. package/dist/clients/tool-render.js +0 -156
  603. package/dist/clients/tool-set-policy.js +0 -118
  604. package/dist/clients/tree-sitter-cache.js +0 -364
  605. package/dist/clients/tree-sitter-client.js +0 -4195
  606. package/dist/clients/tree-sitter-logger.js +0 -100
  607. package/dist/clients/tree-sitter-navigator.js +0 -269
  608. package/dist/clients/tree-sitter-query-loader.js +0 -569
  609. package/dist/clients/tree-sitter-shared.js +0 -158
  610. package/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  611. package/dist/clients/trivy-client.js +0 -467
  612. package/dist/clients/tui-fit.js +0 -50
  613. package/dist/clients/turn-context.js +0 -52
  614. package/dist/clients/turn-end/lane.js +0 -51
  615. package/dist/clients/turn-end/lanes/govulncheck.js +0 -112
  616. package/dist/clients/turn-end/lanes/secrets.js +0 -204
  617. package/dist/clients/turn-summary-render.js +0 -72
  618. package/dist/clients/turn-summary.js +0 -132
  619. package/dist/clients/typos-config.js +0 -29
  620. package/dist/clients/user-notify.js +0 -51
  621. package/dist/clients/vanished-instance-marker.js +0 -57
  622. package/dist/clients/warm-attach.js +0 -305
  623. package/dist/clients/widget-state.js +0 -1808
  624. package/dist/clients/word-index-logger.js +0 -49
  625. package/dist/clients/word-index-store.js +0 -459
  626. package/dist/clients/word-index.js +0 -2080
  627. package/dist/clients/workspace-topology.js +0 -443
  628. package/dist/clients/write-ordering-guard.js +0 -65
  629. package/dist/clients/zizmor-config.js +0 -340
  630. package/dist/i18n.js +0 -69
  631. package/dist/mcp/build-staleness.js +0 -124
  632. package/dist/scripts/lib/process-scan.mjs +0 -583
  633. package/dist/scripts/lib/skills-predicate.mjs +0 -129
  634. package/dist/scripts/lib/web-tree-sitter-dir.mjs +0 -123
  635. package/dist/tools/activate-tools.js +0 -87
  636. package/dist/tools/ast-grep-outline.js +0 -137
  637. package/dist/tools/ast-grep-replace.js +0 -244
  638. package/dist/tools/ast-grep-search.js +0 -762
  639. package/dist/tools/effective-config.js +0 -90
  640. package/dist/tools/lens-diagnostic-mark.js +0 -375
  641. package/dist/tools/lens-diagnostics.js +0 -2571
  642. package/dist/tools/lsp-diagnostics.js +0 -1745
  643. package/dist/tools/lsp-navigation.js +0 -1367
  644. package/dist/tools/lsp-structured-output.js +0 -294
  645. package/dist/tools/module-report.js +0 -382
  646. package/dist/tools/project-report.js +0 -86
  647. package/dist/tools/render-compact.js +0 -374
  648. package/dist/tools/scan-progress.js +0 -53
  649. package/dist/tools/shared.js +0 -29
  650. package/dist/tools/symbol-search.js +0 -155
@@ -1,1653 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { Worker } from "node:worker_threads";
5
- import { gunzipSync, gzipSync } from "node:zlib";
6
- import { writeFileAtomic } from "./atomic-write.js";
7
- import { BoundedFifoMap } from "./bounded-cache.js";
8
- import { getProjectDataDir } from "./file-utils.js";
9
- import { incrementDegradationCount } from "./degradation-ledger.js";
10
- import { readJsonCache } from "./json-cache-read.js";
11
- import { logLatency } from "./latency-logger.js";
12
- import { normalizeMapKey } from "./path-utils.js";
13
- import { fingerprintProjectSnapshotJson } from "./project-snapshot-fingerprint.js";
14
- import { detectProjectConventions, } from "./project-conventions.js";
15
- import { deserializeWordIndex, serializeWordIndex, } from "./word-index.js";
16
- // v2: added `wordIndex` (identifier inverted index + BM25, #162). Bumping the
17
- // version invalidates pre-v2 snapshots so they rebuild with the new field.
18
- export const PROJECT_SNAPSHOT_VERSION = 2;
19
- function parseSequenceIndex(value) {
20
- if (!value || typeof value !== "object")
21
- return undefined;
22
- const index = value;
23
- if (typeof index.projectSeq !== "number")
24
- return undefined;
25
- if (!Array.isArray(index.fileSeqByPath))
26
- return undefined;
27
- const fileSeqByPath = index.fileSeqByPath.filter((entry) => Array.isArray(entry) &&
28
- typeof entry[0] === "string" &&
29
- typeof entry[1] === "number");
30
- return { projectSeq: index.projectSeq, fileSeqByPath };
31
- }
32
- // #958 item 2: the canonical snapshot body is now streamed gzip
33
- // (`project-snapshot.json.gz`), written by a worker thread off the save path
34
- // (see the persist plumbing below). The previous uncompressed
35
- // `project-snapshot.json` remains readable for ONE compatibility release so an
36
- // upgrading user doesn't lose their snapshot — see loadSnapshotBody's legacy
37
- // fallback. gzip measured 5-10x on top of the #957 compaction win (the
38
- // review-graph's own measurement was 60MB → 1.4MB).
39
- export function getProjectSnapshotPath(cwd) {
40
- return path.join(getProjectDataDir(cwd), "cache", "project-snapshot.json.gz");
41
- }
42
- /**
43
- * Pre-#958 uncompressed body path. Read as a one-release fallback when no
44
- * `.json.gz` is present; deleted whenever a fresh gz body is promoted so the
45
- * two never coexist.
46
- */
47
- export function getProjectSnapshotLegacyPath(cwd) {
48
- return path.join(getProjectDataDir(cwd), "cache", "project-snapshot.json");
49
- }
50
- export function getProjectSnapshotMetaPath(cwd) {
51
- return path.join(getProjectDataDir(cwd), "cache", "project-snapshot.meta.json");
52
- }
53
- export function isProjectSnapshotFresh(snapshot, currentProjectSeq) {
54
- return (!!snapshot &&
55
- snapshot.version === PROJECT_SNAPSHOT_VERSION &&
56
- snapshot.seq === currentProjectSeq);
57
- }
58
- function parseSnapshot(value) {
59
- if (!value || typeof value !== "object")
60
- return null;
61
- const snapshot = value;
62
- if (snapshot.version !== PROJECT_SNAPSHOT_VERSION)
63
- return null;
64
- if (typeof snapshot.projectRoot !== "string")
65
- return null;
66
- if (typeof snapshot.generatedAt !== "string")
67
- return null;
68
- if (typeof snapshot.seq !== "number")
69
- return null;
70
- if (!Array.isArray(snapshot.cachedExports))
71
- return null;
72
- return {
73
- version: PROJECT_SNAPSHOT_VERSION,
74
- projectRoot: snapshot.projectRoot,
75
- generatedAt: snapshot.generatedAt,
76
- seq: snapshot.seq,
77
- files: snapshot.files ?? {},
78
- symbols: snapshot.symbols ?? {},
79
- reverseDeps: snapshot.reverseDeps ?? {},
80
- cachedExports: snapshot.cachedExports.filter((entry) => Array.isArray(entry) &&
81
- typeof entry[0] === "string" &&
82
- typeof entry[1] === "string"),
83
- sequenceIndex: parseSequenceIndex(snapshot.sequenceIndex),
84
- wordIndex: snapshot.wordIndex,
85
- projectRulesScan: snapshot.projectRulesScan,
86
- startupScan: snapshot.startupScan,
87
- languageProfile: snapshot.languageProfile,
88
- conventions: snapshot.conventions,
89
- };
90
- }
91
- function parseSnapshotMeta(value) {
92
- if (!value || typeof value !== "object")
93
- return null;
94
- const meta = value;
95
- if (typeof meta.version !== "number")
96
- return null;
97
- if (typeof meta.seq !== "number")
98
- return null;
99
- return {
100
- timestamp: typeof meta.timestamp === "string" ? meta.timestamp : "",
101
- version: meta.version,
102
- seq: meta.seq,
103
- fingerprint: typeof meta.fingerprint === "string" &&
104
- /^[a-f0-9]{64}$/.test(meta.fingerprint)
105
- ? meta.fingerprint
106
- : undefined,
107
- gzBytes: typeof meta.gzBytes === "number" &&
108
- Number.isInteger(meta.gzBytes) &&
109
- meta.gzBytes > 0
110
- ? meta.gzBytes
111
- : undefined,
112
- sequenceIndex: parseSequenceIndex(meta.sequenceIndex),
113
- };
114
- }
115
- /**
116
- * Read the tiny meta sidecar (`project-snapshot.meta.json`) WITHOUT parsing
117
- * the (potentially 40-112MB) snapshot body. Written on every save; absent on
118
- * legacy installs — callers must treat a `null` return as "no opinion" and
119
- * fall through to parsing the body. #947.
120
- */
121
- export function readProjectSnapshotMeta(cwd) {
122
- const meta = readJsonCache(getProjectSnapshotMetaPath(cwd), (parsed) => parseSnapshotMeta(parsed) ?? undefined);
123
- return meta ?? null;
124
- }
125
- /**
126
- * Cheap staleness verdict from the meta sidecar alone. When this returns
127
- * true, the snapshot body CANNOT be fresh (isProjectSnapshotFresh would
128
- * reject it on the same two fields), so the expensive body parse can be
129
- * skipped entirely. #947.
130
- */
131
- export function isProjectSnapshotMetaStale(meta, currentProjectSeq) {
132
- return (meta.version !== PROJECT_SNAPSHOT_VERSION || meta.seq !== currentProjectSeq);
133
- }
134
- export const SNAPSHOT_PARSE_CACHE_MAX = 4;
135
- // #957 review: the cache exists to avoid re-parsing NORMAL snapshots within a
136
- // session. A 112MB-class body parses to hundreds of MB of heap — pinning that
137
- // for process lifetime inverts the win, so oversized bodies are simply never
138
- // cached (they re-parse per read, exactly the pre-#947 behavior). Measured
139
- // against the UNCOMPRESSED body size, never the (much smaller) gz file size.
140
- const SNAPSHOT_PARSE_CACHE_MAX_BYTES = 24 * 1024 * 1024;
141
- const snapshotParseCache = new BoundedFifoMap(SNAPSHOT_PARSE_CACHE_MAX);
142
- function withoutWordIndex(snapshot) {
143
- if (!snapshot?.wordIndex)
144
- return snapshot;
145
- const { wordIndex: _releasedPostings, ...stripped } = snapshot;
146
- return stripped;
147
- }
148
- function cacheParsedSnapshot(snapshotPath, entry) {
149
- // Refresh recency (delete then set moves the key to the newest position;
150
- // BoundedFifoMap itself never reorders an already-present key on set()).
151
- snapshotParseCache.delete(snapshotPath);
152
- snapshotParseCache.set(snapshotPath, entry);
153
- }
154
- const authoritativeSnapshots = new Map();
155
- export const PROJECT_SNAPSHOT_MAX_WARM_ROOTS = 8;
156
- const PROJECT_SNAPSHOT_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
157
- function projectSnapshotIdleEvictMs() {
158
- const value = Number.parseInt(process.env.PI_LENS_PROJECT_SNAPSHOT_IDLE_EVICT_MS ?? "", 10);
159
- return Number.isSafeInteger(value) && value > 0
160
- ? value
161
- : PROJECT_SNAPSHOT_IDLE_EVICT_MS_DEFAULT;
162
- }
163
- function clearAuthoritativeSnapshotTimer(entry) {
164
- if (entry.idleTimer)
165
- clearTimeout(entry.idleTimer);
166
- entry.idleTimer = undefined;
167
- }
168
- function deleteAuthoritativeSnapshot(key) {
169
- const entry = authoritativeSnapshots.get(key);
170
- if (entry)
171
- clearAuthoritativeSnapshotTimer(entry);
172
- authoritativeSnapshots.delete(key);
173
- }
174
- function scheduleAuthoritativeSnapshotEviction(key, entry) {
175
- clearAuthoritativeSnapshotTimer(entry);
176
- const generation = entry.lastUsedAt;
177
- entry.idleTimer = setTimeout(() => {
178
- entry.idleTimer = undefined;
179
- if (authoritativeSnapshots.get(key) !== entry ||
180
- entry.lastUsedAt !== generation)
181
- return;
182
- deleteAuthoritativeSnapshot(key);
183
- }, projectSnapshotIdleEvictMs());
184
- entry.idleTimer.unref?.();
185
- }
186
- function touchAuthoritativeSnapshot(key, entry) {
187
- entry.lastUsedAt = Date.now();
188
- scheduleAuthoritativeSnapshotEviction(key, entry);
189
- }
190
- function enforceAuthoritativeSnapshotCap() {
191
- while (authoritativeSnapshots.size > PROJECT_SNAPSHOT_MAX_WARM_ROOTS) {
192
- const victim = [...authoritativeSnapshots.entries()].sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)[0];
193
- if (!victim)
194
- return;
195
- clearAuthoritativeSnapshotTimer(victim[1]);
196
- deleteAuthoritativeSnapshot(victim[0]);
197
- }
198
- }
199
- /** Test-only cache keys, in LRU order from oldest to newest. */
200
- export function _getAuthoritativeSnapshotCacheKeysForTests() {
201
- return [...authoritativeSnapshots.entries()]
202
- .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
203
- .map(([key]) => key);
204
- }
205
- /** Test hook: drop all cached parses + authoritative writes (per-worker isolation). */
206
- export function _resetProjectSnapshotParseCacheForTests() {
207
- snapshotParseCache.clear();
208
- for (const entry of authoritativeSnapshots.values())
209
- clearAuthoritativeSnapshotTimer(entry);
210
- authoritativeSnapshots.clear();
211
- }
212
- /** Test hook: prove the parse-cache tier never owns serialized postings. */
213
- export function _projectSnapshotParseCacheRetainsWordIndexForTests() {
214
- return [...snapshotParseCache.values()].some((entry) => entry.snapshot?.wordIndex !== undefined);
215
- }
216
- /** Resolve which body file is currently on disk: gz canonical, else legacy. */
217
- function resolveSnapshotBodyPath(cwd) {
218
- const gzPath = getProjectSnapshotPath(cwd);
219
- try {
220
- const stat = fs.statSync(gzPath);
221
- return { path: gzPath, gz: true, mtimeMs: stat.mtimeMs, size: stat.size };
222
- }
223
- catch {
224
- /* fall through to the legacy uncompressed body */
225
- }
226
- const legacyPath = getProjectSnapshotLegacyPath(cwd);
227
- try {
228
- const stat = fs.statSync(legacyPath);
229
- return {
230
- path: legacyPath,
231
- gz: false,
232
- mtimeMs: stat.mtimeMs,
233
- size: stat.size,
234
- };
235
- }
236
- catch {
237
- return null;
238
- }
239
- }
240
- /**
241
- * Read + parse the body off disk, transparently gunzipping the `.json.gz`
242
- * canonical form and falling back to the pre-#958 uncompressed `.json` body
243
- * for one compatibility release. Returns the parsed snapshot (or null on any
244
- * failure) plus the UNCOMPRESSED byte length, so the caller can apply the
245
- * heap-bounded cache gate against the real body size rather than the gz size.
246
- */
247
- // Test-observable count of ACTUAL disk body reads (both gz and legacy), so the
248
- // #947 meta-gate tests can assert "body parsed / not parsed" independently of
249
- // the compression format — the pre-#958 tests keyed this off a readJsonCache
250
- // spy, which the gz path (gunzip + JSON.parse) legitimately bypasses.
251
- let _snapshotBodyReadCountForTests = 0;
252
- export function getSnapshotBodyReadCountForTests() {
253
- return _snapshotBodyReadCountForTests;
254
- }
255
- export function resetSnapshotBodyReadCountForTests() {
256
- _snapshotBodyReadCountForTests = 0;
257
- }
258
- function readSnapshotBody(bodyPath, gz) {
259
- _snapshotBodyReadCountForTests++;
260
- if (!gz) {
261
- const snapshot = readJsonCache(bodyPath, (parsed) => parseSnapshot(parsed) ?? undefined) ?? null;
262
- let rawBytes = 0;
263
- try {
264
- rawBytes = fs.statSync(bodyPath).size;
265
- }
266
- catch {
267
- /* best-effort */
268
- }
269
- return { snapshot, rawBytes };
270
- }
271
- try {
272
- const json = gunzipSync(fs.readFileSync(bodyPath)).toString("utf-8");
273
- const snapshot = parseSnapshot(JSON.parse(json)) ?? null;
274
- return { snapshot, rawBytes: Buffer.byteLength(json) };
275
- }
276
- catch (err) {
277
- // Corrupt / truncated gz, or a parse failure: fail open exactly like
278
- // readJsonCache does — a null return rebuilds the snapshot. Log it so a
279
- // corrupt body is diagnosable rather than indistinguishable from "no
280
- // snapshot yet" (both return a null snapshot here).
281
- logLatency({
282
- type: "phase",
283
- phase: "project_snapshot_body_corrupt",
284
- filePath: bodyPath,
285
- durationMs: 0,
286
- metadata: { error: err instanceof Error ? err.message : String(err) },
287
- });
288
- return { snapshot: null, rawBytes: 0 };
289
- }
290
- }
291
- /**
292
- * Scan a JSON value starting at `start` (skipping any leading whitespace)
293
- * and return the index one past its end. Handles strings, objects, arrays,
294
- * and bare scalars (numbers/`true`/`false`/`null`) — correctly skipping
295
- * nested strings/braces/brackets (respecting `\"` escapes) WITHOUT ever
296
- * constructing a JS value for them. This is the primitive
297
- * `stripTopLevelJsonKeys` (#1785 F5) is built on: `JSON.parse` has no way to
298
- * skip a subtree's construction cost — a reviver only prunes the
299
- * already-built result — so avoiding a field's parse cost requires never
300
- * handing its text to `JSON.parse` in the first place.
301
- */
302
- function skipJsonValue(text, start) {
303
- let i = start;
304
- while (i < text.length && /\s/.test(text[i]))
305
- i++;
306
- const ch = text[i];
307
- if (ch === '"') {
308
- i++;
309
- while (i < text.length) {
310
- if (text[i] === "\\") {
311
- i += 2;
312
- continue;
313
- }
314
- if (text[i] === '"') {
315
- i++;
316
- break;
317
- }
318
- i++;
319
- }
320
- return i;
321
- }
322
- if (ch === "{" || ch === "[") {
323
- const open = ch;
324
- const close = open === "{" ? "}" : "]";
325
- let depth = 0;
326
- let inString = false;
327
- for (; i < text.length; i++) {
328
- const c = text[i];
329
- if (inString) {
330
- if (c === "\\") {
331
- i++;
332
- continue;
333
- }
334
- if (c === '"')
335
- inString = false;
336
- continue;
337
- }
338
- if (c === '"') {
339
- inString = true;
340
- continue;
341
- }
342
- if (c === open)
343
- depth++;
344
- else if (c === close) {
345
- depth--;
346
- if (depth === 0) {
347
- i++;
348
- break;
349
- }
350
- }
351
- }
352
- return i;
353
- }
354
- // A bare scalar (number/true/false/null): scan to the next structural
355
- // character. Every snapshot value is one of the five JSON kinds above, so
356
- // this branch only ever fires for those scalars.
357
- while (i < text.length && !",}]".includes(text[i]))
358
- i++;
359
- return i;
360
- }
361
- /**
362
- * Reconstruct a JSON object literal with the given top-level keys removed,
363
- * without ever materializing their values as JS objects (#1785 F5). Used to
364
- * excise the expensive fields — `wordIndex`'s postings graph above all,
365
- * `files`/`symbols`/`reverseDeps` too — before `JSON.parse` ever sees them,
366
- * so a caller that only needs a few small top-level fields (`seq`,
367
- * `cachedExports`, `projectRulesScan`, …) doesn't pay to construct the
368
- * fields it's about to discard. Correctness relies only on the writer
369
- * (`JSON.stringify(snapshot)`, `clients/gzip-stage-write.ts`) never emitting
370
- * pretty-printed/indented output — compact `JSON.stringify` output is what
371
- * every persist path in this file produces.
372
- */
373
- function stripTopLevelJsonKeys(text, keysToStrip) {
374
- const objStart = text.indexOf("{");
375
- if (objStart === -1)
376
- return text;
377
- let out = text.slice(0, objStart + 1);
378
- let i = objStart + 1;
379
- let wroteField = false;
380
- while (i < text.length) {
381
- while (i < text.length && /\s/.test(text[i]))
382
- i++;
383
- if (text[i] === "}" || i >= text.length) {
384
- out += text.slice(i);
385
- break;
386
- }
387
- if (text[i] === ",") {
388
- i++;
389
- continue;
390
- }
391
- const keyStart = i;
392
- const keyTextEnd = skipJsonValue(text, keyStart);
393
- let key;
394
- try {
395
- key = JSON.parse(text.slice(keyStart, keyTextEnd));
396
- }
397
- catch {
398
- // Malformed key text — bail out to the untouched original rather
399
- // than risk producing invalid JSON; the caller's own JSON.parse
400
- // on the result will then fail loudly instead of silently.
401
- return text;
402
- }
403
- let j = keyTextEnd;
404
- while (j < text.length && /\s/.test(text[j]))
405
- j++;
406
- // text[j] is ':' for a well-formed object.
407
- const valueStart = j + 1;
408
- const valueEnd = skipJsonValue(text, valueStart);
409
- if (!keysToStrip.has(key)) {
410
- out += (wroteField ? "," : "") + text.slice(keyStart, valueEnd);
411
- wroteField = true;
412
- }
413
- i = valueEnd;
414
- }
415
- return out;
416
- }
417
- /**
418
- * Test-only direct access to the raw-text stripper (#1785 F5). Unlike
419
- * asserting on `loadProjectSnapshotExportsAndRules`'s RETURN shape — which
420
- * only proves the output omits the heavy fields, not that their parse cost
421
- * was ever avoided (the function could strip nothing and still hand-pick 4
422
- * fields out of a fully-parsed object) — this lets a test inspect the
423
- * INTERMEDIATE text `JSON.parse` actually receives, which is the only way to
424
- * prove the expensive fields' text never reached `JSON.parse` at all.
425
- */
426
- export function _stripTopLevelJsonKeysForTests(text, keysToStrip) {
427
- return stripTopLevelJsonKeys(text, new Set(keysToStrip));
428
- }
429
- // A fixed, 4-entry, import-time constant — not per-session accumulating
430
- // state, so it needs no session_start reset (counted in
431
- // tests/support/session-state-registry.ts's SESSION_STATE_SYMBOL_COUNTS
432
- // pin for "project-snapshot.ts", alongside the bounded digest hook below).
433
- //
434
- // Adding a key here? Add it to `NARROW_DIGEST_HEAVY_KEY_LITERALS` too (~40
435
- // lines down) — it's a DELIBERATELY separate, independent list (#1785 F7: a
436
- // digest that reads its own containsHeavyKey answer off THIS set can't
437
- // detect this set failing/emptying, so it must not).
438
- const HEAVY_SNAPSHOT_KEYS = new Set([
439
- "wordIndex",
440
- "files",
441
- "symbols",
442
- "reverseDeps",
443
- ]);
444
- // #1785 F7 (review round 5): a FIXED LITERAL list, independent of
445
- // `HEAVY_SNAPSHOT_KEYS` — the digest below exists to detect a broken/emptied
446
- // `HEAVY_SNAPSHOT_KEYS`, so deriving `containsHeavyKey` from that same
447
- // constant makes the check vacuous by construction: empty the set and the
448
- // stripper strips nothing AND `[...HEAVY_SNAPSHOT_KEYS].some(...)` iterates
449
- // zero entries and reports `false` — the exact failure mode this hook exists
450
- // to catch, silently passing. Verified: with the shared-constant version,
451
- // emptying `HEAVY_SNAPSHOT_KEYS` left 38 tests green instead of red.
452
- const NARROW_DIGEST_HEAVY_KEY_LITERALS = [
453
- "wordIndex",
454
- "files",
455
- "symbols",
456
- "reverseDeps",
457
- ];
458
- let _lastNarrowParseDigestForTests;
459
- export function getLastNarrowParseDigestForTests() {
460
- return _lastNarrowParseDigestForTests;
461
- }
462
- export function resetLastNarrowParseDigestForTests() {
463
- _lastNarrowParseDigestForTests = undefined;
464
- }
465
- function parseExportsAndRulesOnly(json) {
466
- const narrowed = stripTopLevelJsonKeys(json, HEAVY_SNAPSHOT_KEYS);
467
- _lastNarrowParseDigestForTests = {
468
- length: narrowed.length,
469
- containsHeavyKey: NARROW_DIGEST_HEAVY_KEY_LITERALS.some((key) => narrowed.includes(`"${key}":`)),
470
- };
471
- const parsed = JSON.parse(narrowed);
472
- if (parsed.version !== PROJECT_SNAPSHOT_VERSION)
473
- return null;
474
- if (typeof parsed.seq !== "number")
475
- return null;
476
- if (!Array.isArray(parsed.cachedExports))
477
- return null;
478
- return {
479
- version: PROJECT_SNAPSHOT_VERSION,
480
- seq: parsed.seq,
481
- cachedExports: parsed.cachedExports.filter((entry) => Array.isArray(entry) &&
482
- typeof entry[0] === "string" &&
483
- typeof entry[1] === "string"),
484
- projectRulesScan: parsed.projectRulesScan,
485
- };
486
- }
487
- function readSnapshotExportsAndRulesBody(bodyPath, gz) {
488
- try {
489
- const json = gz
490
- ? gunzipSync(fs.readFileSync(bodyPath)).toString("utf-8")
491
- : fs.readFileSync(bodyPath, "utf-8");
492
- return parseExportsAndRulesOnly(json);
493
- }
494
- catch (err) {
495
- logLatency({
496
- type: "phase",
497
- phase: "project_snapshot_body_corrupt",
498
- filePath: bodyPath,
499
- durationMs: 0,
500
- metadata: {
501
- error: err instanceof Error ? err.message : String(err),
502
- narrow: true,
503
- },
504
- });
505
- return null;
506
- }
507
- }
508
- /**
509
- * #1785 F5: a narrow counterpart to `loadProjectSnapshot` for callers that
510
- * only need `seq`/`version`/`cachedExports`/`projectRulesScan` — critically,
511
- * NOT `wordIndex`, `files`, `symbols`, or `reverseDeps`. The full
512
- * `loadProjectSnapshot` (even `loadProjectSnapshotWithoutWordIndex`, which
513
- * only strips the RETAINED copy AFTER a full parse) pays `gunzip` +
514
- * `JSON.parse` for the ENTIRE body regardless — dominated by `wordIndex`'s
515
- * postings graph (measured: +200ms warm / +700ms cold on a 29MB body,
516
- * 209-278ms on this repo's own 16.7MB snapshot). This function excises the
517
- * heavy fields from the raw text BEFORE parsing (`stripTopLevelJsonKeys`),
518
- * so their construction cost is never paid at all; the remaining cost is
519
- * dominated by `cachedExports` (typically a small array), not the postings.
520
- *
521
- * Consults the same in-process authoritative-write cache
522
- * `loadProjectSnapshotInternal` does, for the same read-your-own-write
523
- * reason — a save's in-memory object is cheap to narrow (no parse needed at
524
- * all) and must win over a possibly-stale on-disk body exactly like the full
525
- * loader.
526
- */
527
- export function loadProjectSnapshotExportsAndRules(cwd) {
528
- const key = normalizeMapKey(cwd);
529
- const body = resolveSnapshotBodyPath(cwd);
530
- const authoritative = authoritativeSnapshots.get(key);
531
- if (authoritative) {
532
- // `body === null` means nothing is on disk — that is never "the body
533
- // changed size", so it must not be read as superseding our own write.
534
- // See the matching comment in loadProjectSnapshotInternal.
535
- const notSuperseded = body === null ||
536
- (body.mtimeMs <= authoritative.knownMtime &&
537
- body.size === authoritative.knownSize);
538
- if (notSuperseded) {
539
- const { version, seq, cachedExports, projectRulesScan } = authoritative.snapshot;
540
- return { version, seq, cachedExports, projectRulesScan };
541
- }
542
- // On mismatch, unlike the full loader, this narrow loader neither
543
- // deletes the entry nor touches its idle timer (it never calls
544
- // touchAuthoritativeSnapshot even on a hit). The stale entry is safe to
545
- // leave in place: nothing here re-arms its eviction, so it is still
546
- // bounded by whatever idle window the full loader (or the original
547
- // write) last set, not left to live indefinitely.
548
- }
549
- if (!body)
550
- return null;
551
- return readSnapshotExportsAndRulesBody(body.path, body.gz);
552
- }
553
- function loadProjectSnapshotInternal(cwd, requireWordIndex) {
554
- const key = normalizeMapKey(cwd);
555
- const body = resolveSnapshotBodyPath(cwd);
556
- // Authoritative in-process write wins while our own (possibly still
557
- // in-flight) write has not been superseded on disk by a newer external mtime
558
- // or a different-size body in the same/coarser bucket. `body === null` means
559
- // nothing is on disk — either our just-scheduled write hasn't landed yet, or
560
- // something removed the body after we wrote it (e.g. a cleared cache dir).
561
- // Neither case is "the body changed size", so serve the in-process object.
562
- const authoritative = authoritativeSnapshots.get(key);
563
- if (authoritative) {
564
- const notSuperseded = body === null ||
565
- (body.mtimeMs <= authoritative.knownMtime &&
566
- body.size === authoritative.knownSize);
567
- if (notSuperseded) {
568
- touchAuthoritativeSnapshot(key, authoritative);
569
- return authoritative.snapshot;
570
- }
571
- // An external writer changed the body beyond our metadata stamp — honor
572
- // disk and stop serving the now-stale in-memory object.
573
- deleteAuthoritativeSnapshot(key);
574
- }
575
- if (!body) {
576
- snapshotParseCache.delete(getProjectSnapshotPath(cwd));
577
- return null;
578
- }
579
- const cacheKey = body.path;
580
- const cached = snapshotParseCache.get(cacheKey);
581
- // Both mtime AND size must match (see the `size` field doc on
582
- // SnapshotParseCacheEntry for why: coarse FAT/exFAT mtime resolution can
583
- // otherwise alias a just-rewritten file onto a stale cache entry).
584
- if (cached && cached.mtimeMs === body.mtimeMs && cached.size === body.size) {
585
- if (!requireWordIndex || !cached.snapshot || cached.snapshot.wordIndex) {
586
- return cached.snapshot;
587
- }
588
- }
589
- const { snapshot, rawBytes } = readSnapshotBody(body.path, body.gz);
590
- // Serialized postings expand into a much larger object graph. Cache only a
591
- // shallow postings-stripped body: metadata/report consumers stay warm while
592
- // the live warm WordIndex remains the sole retained postings graph (#1370).
593
- const cacheSnapshot = withoutWordIndex(snapshot);
594
- if (rawBytes > 0 &&
595
- (rawBytes <= SNAPSHOT_PARSE_CACHE_MAX_BYTES || snapshot?.wordIndex)) {
596
- cacheParsedSnapshot(cacheKey, {
597
- mtimeMs: body.mtimeMs,
598
- size: body.size,
599
- snapshot: cacheSnapshot,
600
- });
601
- }
602
- else {
603
- snapshotParseCache.delete(cacheKey);
604
- }
605
- return snapshot;
606
- }
607
- /** Load the canonical body, including serialized postings when present. */
608
- export function loadProjectSnapshot(cwd) {
609
- return loadProjectSnapshotInternal(cwd, true);
610
- }
611
- /**
612
- * Load snapshot metadata without retaining or re-reading serialized postings.
613
- * After publication this is served by the postings-stripped parse cache.
614
- */
615
- export function loadProjectSnapshotWithoutWordIndex(cwd) {
616
- return loadProjectSnapshotInternal(cwd, false);
617
- }
618
- const _snapshotGenerationStates = new Map();
619
- const _successfulSnapshotPersists = new BoundedFifoMap(PROJECT_SNAPSHOT_MAX_WARM_ROOTS);
620
- const _failedSnapshotPersists = new BoundedFifoMap(PROJECT_SNAPSHOT_MAX_WARM_ROOTS);
621
- const _activeSnapshotPersists = new Map();
622
- const _queuedSnapshotPersists = new Map();
623
- const _snapshotWorkerRequests = new Map();
624
- let _snapshotPersistWorker;
625
- let _snapshotWorkerRequestId = 0;
626
- let _snapshotWorkerDisabled = false;
627
- let _snapshotGenerationGateEnabledForTests = true;
628
- let _snapshotPromotionSeamForTests;
629
- let _lastSnapshotPersistErrorForTests;
630
- let _snapshotExiting = false;
631
- let _snapshotWorkerBodyWritesForTests = 0;
632
- function snapshotWorkerEnabled() {
633
- // The synchronous fallback writer is a legitimate degraded mode (hosts that
634
- // can't spawn a worker); tests also force it so a save→load is fully
635
- // synchronous. Production defaults to the worker.
636
- const raw = process.env.PI_LENS_SNAPSHOT_PERSIST_SYNC;
637
- return !(raw === "1" || raw === "true");
638
- }
639
- function pendingSnapshotIsCurrent(pending) {
640
- return (!_snapshotGenerationGateEnabledForTests ||
641
- _snapshotGenerationStates.get(pending.key)?.generation ===
642
- pending.generation);
643
- }
644
- function rememberSuccessfulSnapshotPersist(key, record) {
645
- _successfulSnapshotPersists.delete(key);
646
- _successfulSnapshotPersists.set(key, record);
647
- }
648
- function assessSnapshotBodyIntegrity(meta, body) {
649
- if (meta.gzBytes === undefined) {
650
- // Legacy meta (pre-#2008): nothing to compare against, so the evidence is
651
- // untrusted until the next successful persist populates gzBytes.
652
- return { outcome: "legacy-meta", actualBytes: body.size };
653
- }
654
- if (!body.gz || body.size !== meta.gzBytes) {
655
- return {
656
- outcome: "size-mismatch",
657
- expectedGzBytes: meta.gzBytes,
658
- actualBytes: body.size,
659
- };
660
- }
661
- return {
662
- outcome: "ok",
663
- expectedGzBytes: meta.gzBytes,
664
- actualBytes: body.size,
665
- };
666
- }
667
- /**
668
- * Bounded observability for #2008: the degradation ledger keeps one entry per
669
- * corrupted subject with the exact detection count for the session; every
670
- * detection also emits one `project_snapshot_body_integrity` latency row
671
- * naming expected vs actual bytes, so an operator can confirm both the
672
- * detection and the forced republish from logs alone.
673
- */
674
- function noteSnapshotBodyIntegrityWithheld(args) {
675
- incrementDegradationCount({
676
- kind: "snapshot-integrity",
677
- subject: args.gzPath,
678
- reason: args.verdict,
679
- });
680
- logLatency({
681
- type: "phase",
682
- phase: "project_snapshot_body_integrity",
683
- filePath: args.gzPath,
684
- durationMs: 0,
685
- metadata: {
686
- outcome: "dedupe_withheld",
687
- reason: args.verdict,
688
- ...(args.seq !== undefined ? { seq: args.seq } : {}),
689
- ...(args.expectedGzBytes === undefined
690
- ? {}
691
- : { expectedGzBytes: args.expectedGzBytes }),
692
- actualBytes: args.actualBytes,
693
- },
694
- });
695
- }
696
- /** Log one row naming a skip that was refused because integrity regressed. */
697
- function noteSnapshotSkipRefused(args) {
698
- logLatency({
699
- type: "phase",
700
- phase: "project_snapshot_body_integrity",
701
- filePath: args.gzPath,
702
- durationMs: 0,
703
- metadata: {
704
- outcome: "skip_refused_rewrite",
705
- reason: args.verdict,
706
- seq: args.seq,
707
- },
708
- });
709
- }
710
- /**
711
- * PURE READ of the durable meta+body evidence for a snapshot key (#2008
712
- * refactor): it must never mutate {@link _successfulSnapshotPersists}. This
713
- * runs up to three times per persist — admission in saveProjectSnapshot, the
714
- * dispatch-time refresh, and the skip-honor re-read — including on results
715
- * whose fingerprints are only partially used, so the seeding write lives in
716
- * {@link seedSnapshotPersistBaselineFromDurable} and is called only by the
717
- * seam that owns the persist lifecycle (dispatchSnapshotPersist).
718
- */
719
- function snapshotPersistBaselinesFor(cwd, key) {
720
- const local = _successfulSnapshotPersists.get(key);
721
- const meta = readProjectSnapshotMeta(cwd);
722
- const body = resolveSnapshotBodyPath(cwd);
723
- // No body means no dedupe evidence. This keeps same-seq deletion repair live.
724
- if (!meta?.fingerprint || !body) {
725
- if (!body)
726
- _successfulSnapshotPersists.delete(key);
727
- return { fingerprints: [] };
728
- }
729
- // #2008: a meta whose recorded gz size no longer matches the on-disk body
730
- // describes evidence we cannot trust — a truncated/torn gzip under an
731
- // intact meta used to win same-fingerprint dedupe forever, keeping the
732
- // corrupt body canonical until seq advanced. Withhold the fingerprints so
733
- // the pending save republishes (and rewrites) the body.
734
- const integrity = assessSnapshotBodyIntegrity(meta, body);
735
- if (integrity.outcome !== "ok") {
736
- noteSnapshotBodyIntegrityWithheld({
737
- gzPath: getProjectSnapshotPath(cwd),
738
- verdict: integrity.outcome,
739
- seq: meta.seq,
740
- expectedGzBytes: integrity.expectedGzBytes,
741
- actualBytes: integrity.actualBytes,
742
- });
743
- return { fingerprints: [] };
744
- }
745
- const durable = {
746
- seq: meta.seq,
747
- fingerprint: meta.fingerprint,
748
- generatedAt: meta.timestamp,
749
- generation: _snapshotGenerationStates.get(key)?.generation ?? 0,
750
- gzBytes: meta.gzBytes,
751
- };
752
- const fingerprints = [durable.fingerprint];
753
- // A sibling process may have advanced durable state from local A to B. An
754
- // unchanged replay of A is stale work and must not overwrite B. A third
755
- // semantic state C matches neither fingerprint and still publishes.
756
- if (local && local.fingerprint !== durable.fingerprint) {
757
- fingerprints.push(local.fingerprint);
758
- }
759
- return { durable, fingerprints, integrity: "ok" };
760
- }
761
- /**
762
- * Seed the in-process baseline for `key` from freshly-read durable state.
763
- * Deliberately separate from {@link snapshotPersistBaselinesFor} so the read
764
- * stays pure; called only at the dispatch seam where the persist lifecycle
765
- * begins.
766
- */
767
- function seedSnapshotPersistBaselineFromDurable(key, durable) {
768
- if (!durable || _successfulSnapshotPersists.has(key))
769
- return;
770
- rememberSuccessfulSnapshotPersist(key, durable);
771
- }
772
- function writeProjectSnapshotMeta(metaPath, snapshot, bodyRecord) {
773
- writeFileAtomic(metaPath, JSON.stringify({
774
- timestamp: bodyRecord?.generatedAt ?? snapshot.generatedAt,
775
- version: snapshot.version,
776
- seq: snapshot.seq,
777
- ...(bodyRecord
778
- ? {
779
- fingerprint: bodyRecord.fingerprint,
780
- ...(bodyRecord.gzBytes === undefined
781
- ? {}
782
- : { gzBytes: bodyRecord.gzBytes }),
783
- }
784
- : {}),
785
- ...(snapshot.sequenceIndex
786
- ? { sequenceIndex: snapshot.sequenceIndex }
787
- : {}),
788
- }), { bestEffort: false });
789
- }
790
- function recordSnapshotPersistFailure(pending, error, bodyPersisted = false) {
791
- // Honesty (#533): a failed async body write must be surfaced, never left to
792
- // masquerade as a saved snapshot. The meta gate is already self-healing (an
793
- // old body under a newer-seq meta is rejected on the body's own embedded
794
- // seq), and dropping the authoritative entry means the next load reflects
795
- // what is ACTUALLY on disk rather than the object we failed to persist.
796
- _lastSnapshotPersistErrorForTests = error;
797
- _failedSnapshotPersists.set(pending.key, {
798
- seq: pending.snapshot.seq,
799
- generation: pending.generation,
800
- });
801
- deleteAuthoritativeSnapshot(pending.key);
802
- logLatency({
803
- type: "phase",
804
- phase: "project_snapshot_persist_failed",
805
- filePath: pending.gzPath,
806
- durationMs: 0,
807
- metadata: {
808
- error,
809
- seq: pending.snapshot.seq,
810
- outcome: bodyPersisted ? "metadata_repair_required" : "failed",
811
- ...(bodyPersisted ? { bodyPersisted: true } : {}),
812
- },
813
- });
814
- // #1333: the logLatency call above already carries this failure to
815
- // latency.log — the console.error was a duplicate RAW write into pi's frame.
816
- }
817
- function logSnapshotPersistSuccess(pending, fingerprint, stats) {
818
- rememberSuccessfulSnapshotPersist(pending.key, {
819
- seq: pending.snapshot.seq,
820
- fingerprint,
821
- generatedAt: pending.snapshot.generatedAt,
822
- generation: pending.generation,
823
- rawBytes: stats.rawBytes,
824
- gzBytes: stats.gzBytes,
825
- });
826
- _failedSnapshotPersists.delete(pending.key);
827
- logLatency({
828
- type: "phase",
829
- phase: "project_snapshot_persist",
830
- filePath: pending.gzPath,
831
- durationMs: stats.serializeMs + stats.writeMs,
832
- metadata: { seq: pending.snapshot.seq, outcome: "executed", ...stats },
833
- });
834
- }
835
- function logSnapshotPersistDecision(args) {
836
- logLatency({
837
- type: "phase",
838
- phase: "project_snapshot_persist_decision",
839
- filePath: getProjectSnapshotPath(args.cwd),
840
- durationMs: 0,
841
- metadata: {
842
- seq: args.seq,
843
- decision: args.decision,
844
- ...(args.fingerprint
845
- ? { fingerprint: args.fingerprint.slice(0, 12) }
846
- : {}),
847
- ...(args.avoidedRawBytes === undefined
848
- ? {}
849
- : { avoidedRawBytes: args.avoidedRawBytes }),
850
- ...(args.avoidedGzipBytes === undefined
851
- ? {}
852
- : { avoidedGzipBytes: args.avoidedGzipBytes }),
853
- },
854
- });
855
- }
856
- /**
857
- * Reconcile the authoritative in-process entry with a body that just landed on
858
- * disk: update its `knownMtime` so a subsequent load keeps serving our own
859
- * object without re-parsing, and DROP oversized bodies (their post-promotion
860
- * disk read is the pre-#947 behavior — we won't pin hundreds of MB of heap).
861
- */
862
- function reconcileAuthoritativeAfterWrite(pending, rawBytes) {
863
- const entry = authoritativeSnapshots.get(pending.key);
864
- // Only reconcile the entry that still belongs to THIS (latest) generation —
865
- // a superseding save already replaced it with a newer object.
866
- if (!entry || entry.snapshot !== pending.snapshot)
867
- return;
868
- // The worker needs the serialized snapshot until promotion completes, but
869
- // retaining it afterward duplicates the mutable warm index's postings. A
870
- // shared reference is unsafe: ProjectSnapshot stores serialized arrays while
871
- // WordIndex owns mutable Map/PathKeyedMap state. Drop the authoritative copy
872
- // after publication; later merge-writers rehydrate the canonical disk body.
873
- if (pending.snapshot.wordIndex) {
874
- try {
875
- const stat = fs.statSync(pending.gzPath);
876
- cacheParsedSnapshot(pending.gzPath, {
877
- mtimeMs: stat.mtimeMs,
878
- size: stat.size,
879
- snapshot: withoutWordIndex(pending.snapshot),
880
- });
881
- }
882
- catch {
883
- // A cache miss is safe: the first metadata consumer reconstructs it.
884
- }
885
- deleteAuthoritativeSnapshot(pending.key);
886
- logLatency({
887
- type: "phase",
888
- phase: "project_snapshot_word_index_released",
889
- filePath: pending.gzPath,
890
- durationMs: 0,
891
- metadata: { rawBytes },
892
- });
893
- return;
894
- }
895
- if (rawBytes > SNAPSHOT_PARSE_CACHE_MAX_BYTES) {
896
- // Benign but invisible otherwise: the next load will re-parse this body
897
- // from disk instead of serving the in-process object.
898
- logLatency({
899
- type: "phase",
900
- phase: "project_snapshot_authoritative_dropped_oversized",
901
- filePath: pending.gzPath,
902
- durationMs: 0,
903
- metadata: { rawBytes, maxBytes: SNAPSHOT_PARSE_CACHE_MAX_BYTES },
904
- });
905
- deleteAuthoritativeSnapshot(pending.key);
906
- return;
907
- }
908
- try {
909
- const stat = fs.statSync(pending.gzPath);
910
- entry.knownMtime = stat.mtimeMs;
911
- entry.knownSize = stat.size;
912
- }
913
- catch {
914
- // If we can't stat our own write, leave the metadata as-is; the worst case
915
- // is one extra disk re-parse on the next load.
916
- }
917
- }
918
- function finalizeProjectSnapshotMeta(pending, record) {
919
- try {
920
- writeProjectSnapshotMeta(getProjectSnapshotMetaPath(pending.cwd), pending.snapshot, record);
921
- return true;
922
- }
923
- catch (err) {
924
- recordSnapshotPersistFailure(pending, err instanceof Error ? err.message : String(err), true);
925
- return false;
926
- }
927
- }
928
- function completeSnapshotPersist(pending) {
929
- if (_activeSnapshotPersists.get(pending.key) !== pending)
930
- return;
931
- _activeSnapshotPersists.delete(pending.key);
932
- if (_snapshotExiting)
933
- return;
934
- const queued = _queuedSnapshotPersists.get(pending.key);
935
- if (!queued)
936
- return;
937
- _queuedSnapshotPersists.delete(pending.key);
938
- dispatchSnapshotPersist(queued);
939
- }
940
- function writeSnapshotBodyOnMainThread(pending, reason) {
941
- if (!pendingSnapshotIsCurrent(pending)) {
942
- completeSnapshotPersist(pending);
943
- return;
944
- }
945
- if (reason) {
946
- // We took the synchronous main-thread gzip path (the +656MB-risk path,
947
- // #950) instead of the worker. Surface it rather than burying it in an
948
- // `offloaded:false` success line below. `degraded` distinguishes a REAL
949
- // degradation (worker died/unavailable/promote-failed) from the benign
950
- // `exit_hook` teardown flush, so an operator triaging worker health isn't
951
- // misled by normal process-exit flushes.
952
- logLatency({
953
- type: "phase",
954
- phase: "project_snapshot_worker_fallback",
955
- filePath: pending.gzPath,
956
- durationMs: 0,
957
- metadata: {
958
- reason,
959
- seq: pending.snapshot.seq,
960
- degraded: reason !== "exit_hook",
961
- },
962
- });
963
- }
964
- try {
965
- const serializeStarted = performance.now();
966
- const json = JSON.stringify(pending.snapshot);
967
- const serializeMs = performance.now() - serializeStarted;
968
- const rawBytes = Buffer.byteLength(json);
969
- const fingerprint = fingerprintProjectSnapshotJson(json, pending.snapshot.generatedAt);
970
- if (pending.dedupeFingerprints.includes(fingerprint)) {
971
- // Re-read the tiny durable sidecar before honoring a fingerprint
972
- // captured at dispatch time (#2008): a body torn between dispatch and
973
- // execution must fall through to the full write, not skip.
974
- const current = snapshotPersistBaselinesFor(pending.cwd, pending.key);
975
- if (current.integrity === "ok") {
976
- const prior = current.durable ?? pending.durablePersist;
977
- if (authoritativeSnapshots.get(pending.key)?.snapshot === pending.snapshot) {
978
- deleteAuthoritativeSnapshot(pending.key);
979
- }
980
- logSnapshotPersistDecision({
981
- cwd: pending.cwd,
982
- seq: pending.snapshot.seq,
983
- fingerprint,
984
- decision: "skipped_unchanged",
985
- avoidedRawBytes: rawBytes,
986
- avoidedGzipBytes: prior?.gzBytes,
987
- });
988
- return;
989
- }
990
- if (current.integrity !== undefined) {
991
- noteSnapshotSkipRefused({
992
- gzPath: pending.gzPath,
993
- verdict: current.integrity,
994
- seq: pending.snapshot.seq,
995
- });
996
- }
997
- // integrity === undefined: the meta/body evidence vanished entirely
998
- // since dispatch — falling through to the full write repairs it, the
999
- // same contract as deletion-before-dispatch.
1000
- }
1001
- const writeStarted = performance.now();
1002
- const gzip = gzipSync(json);
1003
- fs.mkdirSync(path.dirname(pending.gzPath), { recursive: true });
1004
- writeFileAtomic(pending.gzPath, gzip, { bestEffort: false });
1005
- fs.rmSync(pending.legacyPath, { force: true });
1006
- const metadataFinalized = finalizeProjectSnapshotMeta(pending, {
1007
- fingerprint,
1008
- generatedAt: pending.snapshot.generatedAt,
1009
- gzBytes: gzip.byteLength,
1010
- });
1011
- if (!metadataFinalized)
1012
- return;
1013
- reconcileAuthoritativeAfterWrite(pending, rawBytes);
1014
- logSnapshotPersistSuccess(pending, fingerprint, {
1015
- rawBytes,
1016
- gzBytes: gzip.byteLength,
1017
- serializeMs,
1018
- writeMs: performance.now() - writeStarted,
1019
- offloaded: false,
1020
- });
1021
- if (reason)
1022
- _lastSnapshotPersistErrorForTests = reason;
1023
- }
1024
- catch (err) {
1025
- recordSnapshotPersistFailure(pending, err instanceof Error ? err.message : String(err));
1026
- }
1027
- finally {
1028
- completeSnapshotPersist(pending);
1029
- }
1030
- }
1031
- /**
1032
- * The ForTests promotion seam covers worker-message and every main-thread
1033
- * fallback promotion. It stays sync and seam-free in production, identical to
1034
- * calling the writer directly. The process-exit hook is deliberately the sole
1035
- * direct write because an exit handler cannot await this asynchronous seam.
1036
- */
1037
- function dispatchMainThreadWriteThroughSeam(pending, reason) {
1038
- if (_snapshotPromotionSeamForTests) {
1039
- void _snapshotPromotionSeamForTests().then(() => writeSnapshotBodyOnMainThread(pending, reason));
1040
- return;
1041
- }
1042
- writeSnapshotBodyOnMainThread(pending, reason);
1043
- }
1044
- function handleSnapshotWorkerResult(result) {
1045
- const pending = _snapshotWorkerRequests.get(result.id);
1046
- if (!pending) {
1047
- fs.rmSync(result.stagePath, { force: true });
1048
- return;
1049
- }
1050
- _snapshotWorkerRequests.delete(result.id);
1051
- if (result.error ||
1052
- result.rawBytes === undefined ||
1053
- result.gzBytes === undefined ||
1054
- result.serializeMs === undefined ||
1055
- result.writeMs === undefined ||
1056
- result.semanticFingerprint === undefined) {
1057
- fs.rmSync(result.stagePath, { force: true });
1058
- dispatchMainThreadWriteThroughSeam(pending, result.error ?? "invalid worker result");
1059
- return;
1060
- }
1061
- // Generation gate: a newer save already superseded this one — discard the
1062
- // stale stage file rather than promote it over the fresher body.
1063
- if (!result.skippedUnchanged)
1064
- _snapshotWorkerBodyWritesForTests++;
1065
- if (_snapshotGenerationGateEnabledForTests &&
1066
- _snapshotGenerationStates.get(pending.key)?.generation !== result.generation) {
1067
- // The stale stage is part of the promotion transaction: remove it before
1068
- // returning so a superseded save cannot leave an orphan behind.
1069
- fs.rmSync(result.stagePath, { force: true });
1070
- completeSnapshotPersist(pending);
1071
- return;
1072
- }
1073
- if (result.skippedUnchanged) {
1074
- // Re-read the tiny durable sidecar at the decision seam. A sibling process
1075
- // may have advanced B after this request captured A. A stale replay of A
1076
- // must preserve B's body metadata, not restore the captured A sidecar.
1077
- const baselines = snapshotPersistBaselinesFor(pending.cwd, pending.key);
1078
- if (baselines.integrity !== "ok") {
1079
- // The worker skipped WITHOUT staging a body, trusting the durable
1080
- // fingerprint captured at dispatch. The re-read says that evidence no
1081
- // longer passes the #2008 integrity gate — refuse the skip and rewrite
1082
- // synchronously so a torn/missing body cannot outlive this save. The
1083
- // sync writer recomputes the same baselines, finds them untrusted, and
1084
- // performs the full gzip+stage+promote (its finally completes this
1085
- // pending, so do not complete it twice here).
1086
- if (baselines.integrity !== undefined) {
1087
- noteSnapshotSkipRefused({
1088
- gzPath: pending.gzPath,
1089
- verdict: baselines.integrity,
1090
- seq: pending.snapshot.seq,
1091
- });
1092
- }
1093
- dispatchMainThreadWriteThroughSeam(pending, "snapshot body integrity regressed");
1094
- return;
1095
- }
1096
- const prior = baselines.durable ?? pending.durablePersist;
1097
- if (authoritativeSnapshots.get(pending.key)?.snapshot === pending.snapshot) {
1098
- deleteAuthoritativeSnapshot(pending.key);
1099
- }
1100
- logSnapshotPersistDecision({
1101
- cwd: pending.cwd,
1102
- seq: pending.snapshot.seq,
1103
- fingerprint: result.semanticFingerprint,
1104
- decision: "skipped_unchanged",
1105
- avoidedRawBytes: result.rawBytes,
1106
- avoidedGzipBytes: prior?.gzBytes,
1107
- });
1108
- completeSnapshotPersist(pending);
1109
- return;
1110
- }
1111
- try {
1112
- fs.renameSync(result.stagePath, pending.gzPath);
1113
- fs.rmSync(pending.legacyPath, { force: true });
1114
- const metadataFinalized = finalizeProjectSnapshotMeta(pending, {
1115
- fingerprint: result.semanticFingerprint,
1116
- generatedAt: pending.snapshot.generatedAt,
1117
- gzBytes: result.gzBytes,
1118
- });
1119
- if (!metadataFinalized) {
1120
- completeSnapshotPersist(pending);
1121
- return;
1122
- }
1123
- reconcileAuthoritativeAfterWrite(pending, result.rawBytes);
1124
- logSnapshotPersistSuccess(pending, result.semanticFingerprint, {
1125
- rawBytes: result.rawBytes,
1126
- gzBytes: result.gzBytes,
1127
- serializeMs: result.serializeMs,
1128
- writeMs: result.writeMs,
1129
- offloaded: true,
1130
- });
1131
- completeSnapshotPersist(pending);
1132
- }
1133
- catch (err) {
1134
- fs.rm(result.stagePath, { force: true }, () => { });
1135
- dispatchMainThreadWriteThroughSeam(pending, err instanceof Error ? err.message : String(err));
1136
- }
1137
- }
1138
- function dispatchSnapshotPersist(pending) {
1139
- // A queued request may have waited behind the publication that created its
1140
- // best dedupe evidence. Refresh only the tiny sidecar/body stat here; never
1141
- // retain the stale admission-time baseline through dispatch.
1142
- const baselines = snapshotPersistBaselinesFor(pending.cwd, pending.key);
1143
- pending.durablePersist = baselines.durable;
1144
- pending.dedupeFingerprints = baselines.fingerprints;
1145
- seedSnapshotPersistBaselineFromDurable(pending.key, baselines.durable);
1146
- _activeSnapshotPersists.set(pending.key, pending);
1147
- if (!snapshotWorkerEnabled()) {
1148
- dispatchMainThreadWriteThroughSeam(pending, undefined);
1149
- return;
1150
- }
1151
- const worker = getSnapshotPersistWorker();
1152
- if (!worker) {
1153
- dispatchMainThreadWriteThroughSeam(pending, "persist worker unavailable");
1154
- return;
1155
- }
1156
- const id = ++_snapshotWorkerRequestId;
1157
- _snapshotWorkerRequests.set(id, pending);
1158
- const request = {
1159
- id,
1160
- generation: pending.generation,
1161
- stagePath: pending.stagePath,
1162
- data: pending.snapshot,
1163
- priorFingerprints: pending.dedupeFingerprints,
1164
- testDelayMs: process.env.NODE_ENV === "test"
1165
- ? Number(process.env.PI_LENS_TEST_SNAPSHOT_PERSIST_WORKER_DELAY_MS) ||
1166
- undefined
1167
- : undefined,
1168
- };
1169
- try {
1170
- worker.postMessage(request);
1171
- }
1172
- catch (err) {
1173
- _snapshotWorkerRequests.delete(id);
1174
- dispatchMainThreadWriteThroughSeam(pending, err instanceof Error ? err.message : String(err));
1175
- }
1176
- }
1177
- function handleSnapshotWorkerDeath(reason) {
1178
- _snapshotPersistWorker = undefined;
1179
- _snapshotWorkerDisabled = true;
1180
- const requests = [..._snapshotWorkerRequests.values()];
1181
- _snapshotWorkerRequests.clear();
1182
- for (const pending of requests)
1183
- dispatchMainThreadWriteThroughSeam(pending, reason);
1184
- }
1185
- function resolveSnapshotPersistWorkerPath() {
1186
- // esbuild does NOT rewrite new URL(...) asset refs, so from the bundled
1187
- // dist/index.js a sibling ./project-snapshot-persist-worker.js resolves
1188
- // beside the BUNDLE where nothing exists. Try the compiled-sibling layout
1189
- // first (source checkout / unbundled dist/clients tree), then the dist-tree
1190
- // path relative to the bundle entry — same shape as the review graph's
1191
- // resolvePersistWorkerPath (#950 review F1).
1192
- const candidates = [
1193
- new URL("./project-snapshot-persist-worker.js", import.meta.url),
1194
- new URL("./clients/project-snapshot-persist-worker.js", import.meta.url),
1195
- ];
1196
- for (const url of candidates) {
1197
- try {
1198
- const resolved = fileURLToPath(url);
1199
- if (fs.existsSync(resolved))
1200
- return resolved;
1201
- }
1202
- catch {
1203
- /* try next layout */
1204
- }
1205
- }
1206
- return undefined;
1207
- }
1208
- function getSnapshotPersistWorker() {
1209
- if (_snapshotWorkerDisabled)
1210
- return undefined;
1211
- if (_snapshotPersistWorker)
1212
- return _snapshotPersistWorker;
1213
- try {
1214
- const workerPath = resolveSnapshotPersistWorkerPath();
1215
- if (workerPath === undefined) {
1216
- handleSnapshotWorkerDeath("persist worker script not found in any layout");
1217
- return undefined;
1218
- }
1219
- const worker = new Worker(workerPath);
1220
- // The ForTests promotion seam wraps ONLY when set — the production path
1221
- // binds the sync handler directly, so scheduling is byte-identical when
1222
- // no test seam is installed (the async-handler variant of this shifted
1223
- // promotion timing under full-suite load and flaked the round-trip test).
1224
- worker.on("message", (result) => {
1225
- if (_snapshotPromotionSeamForTests) {
1226
- void _snapshotPromotionSeamForTests().then(() => handleSnapshotWorkerResult(result));
1227
- return;
1228
- }
1229
- handleSnapshotWorkerResult(result);
1230
- });
1231
- worker.on("error", (err) => handleSnapshotWorkerDeath(err.message));
1232
- worker.on("exit", (code) => {
1233
- if (_snapshotPersistWorker === worker)
1234
- _snapshotPersistWorker = undefined;
1235
- // Any body still queued when the worker exits was abandoned mid-flight
1236
- // (a crash, a `terminate()`, or host recycling) — it will never be
1237
- // promoted by this worker, so fall it back to the sync writer rather
1238
- // than let it vanish (honesty, #533). `terminate()` can report a 0
1239
- // exit code on some platforms, so this cannot key off `code !== 0`.
1240
- const stranded = [..._snapshotWorkerRequests.values()];
1241
- if (stranded.length > 0) {
1242
- _snapshotWorkerRequests.clear();
1243
- for (const pending of stranded) {
1244
- dispatchMainThreadWriteThroughSeam(pending, `persist worker exited with code ${code}`);
1245
- }
1246
- }
1247
- // Only an ABNORMAL exit disables respawning; a clean idle exit (nothing
1248
- // stranded) just drops the reference so the next persist respawns.
1249
- if (code !== 0)
1250
- _snapshotWorkerDisabled = true;
1251
- });
1252
- // #1148: adding a message listener refs the Worker's public MessagePort.
1253
- // Unref only after every listener is installed so it stays background-only.
1254
- worker.unref();
1255
- _snapshotPersistWorker = worker;
1256
- return worker;
1257
- }
1258
- catch (err) {
1259
- handleSnapshotWorkerDeath(err instanceof Error ? err.message : String(err));
1260
- return undefined;
1261
- }
1262
- }
1263
- // #950 review F3: a process that dies between a worker's staged write and its
1264
- // promotion leaves project-snapshot.json.gz.stage-<pid>-<gen> (and the worker's
1265
- // .tmp-<pid>) behind forever. Sweep leftovers from PRIOR processes once per
1266
- // cache dir; our own live stage files carry this pid and are skipped.
1267
- const _sweptSnapshotStageDirs = new Set();
1268
- function sweepStaleSnapshotStageFiles(cacheDir) {
1269
- if (_sweptSnapshotStageDirs.has(cacheDir))
1270
- return;
1271
- _sweptSnapshotStageDirs.add(cacheDir);
1272
- fs.readdir(cacheDir, (err, entries) => {
1273
- if (err)
1274
- return;
1275
- const ownMarker = `.stage-${process.pid}-`;
1276
- for (const entry of entries) {
1277
- if (!entry.startsWith("project-snapshot.json.gz.stage-"))
1278
- continue;
1279
- if (entry.includes(ownMarker))
1280
- continue;
1281
- fs.rm(path.join(cacheDir, entry), { force: true }, () => { });
1282
- }
1283
- });
1284
- }
1285
- // Flush any in-flight worker writes synchronously at process teardown so a body
1286
- // whose worker hasn't promoted yet isn't lost. Sync writes only (no child
1287
- // spawn — the teardown libuv hazard); best-effort.
1288
- let _snapshotExitHookInstalled = false;
1289
- function ensureSnapshotPersistExitHook() {
1290
- if (_snapshotExitHookInstalled)
1291
- return;
1292
- _snapshotExitHookInstalled = true;
1293
- process.once("exit", () => {
1294
- _snapshotExiting = true;
1295
- const latestByKey = new Map();
1296
- for (const pending of _snapshotWorkerRequests.values()) {
1297
- latestByKey.set(pending.key, pending);
1298
- }
1299
- for (const pending of _queuedSnapshotPersists.values()) {
1300
- const prior = latestByKey.get(pending.key);
1301
- // Queued work is later admission order. Equal-seq requests deliberately
1302
- // share a gate generation, so a tie must still select the queued payload.
1303
- if (!prior || prior.generation <= pending.generation) {
1304
- latestByKey.set(pending.key, pending);
1305
- }
1306
- }
1307
- _snapshotWorkerRequests.clear();
1308
- _queuedSnapshotPersists.clear();
1309
- _activeSnapshotPersists.clear();
1310
- for (const pending of latestByKey.values()) {
1311
- // Only the newest generation per key still matters; older ones are
1312
- // superseded and their stage files are swept on next launch.
1313
- if (_snapshotGenerationStates.get(pending.key)?.generation !==
1314
- pending.generation)
1315
- continue;
1316
- writeSnapshotBodyOnMainThread(pending, "exit_hook");
1317
- }
1318
- void _snapshotPersistWorker?.terminate();
1319
- });
1320
- }
1321
- export function saveProjectSnapshot(cwd, snapshot) {
1322
- const gzPath = getProjectSnapshotPath(cwd);
1323
- const legacyPath = getProjectSnapshotLegacyPath(cwd);
1324
- const metaPath = getProjectSnapshotMetaPath(cwd);
1325
- const cacheDir = path.dirname(gzPath);
1326
- const key = normalizeMapKey(cwd);
1327
- fs.mkdirSync(cacheDir, { recursive: true });
1328
- const baselines = snapshotPersistBaselinesFor(cwd, key);
1329
- const priorPersist = baselines.durable;
1330
- logSnapshotPersistDecision({
1331
- cwd,
1332
- seq: snapshot.seq,
1333
- decision: "requested",
1334
- });
1335
- if (_failedSnapshotPersists.delete(key)) {
1336
- logSnapshotPersistDecision({ cwd, seq: snapshot.seq, decision: "retry" });
1337
- }
1338
- // #958: for a new seq, meta is written FIRST and body SECOND. A crash or
1339
- // failure between the two writes can now only produce
1340
- // "meta already claims the new seq, body hasn't caught up yet" (the meta
1341
- // races ahead). The meta-first gate (isProjectSnapshotMetaStale) reads
1342
- // that as *fresh* and falls through to parsing the body, whose own
1343
- // embedded `seq` is still the old one, so `isProjectSnapshotFresh`
1344
- // correctly rejects it as stale on the body's own merits — one wasted
1345
- // parse, self-healing, no data lost. The OLD body-then-meta order could
1346
- // instead leave an old-seq meta sitting over a freshly written body,
1347
- // which the meta-first gate discards WITHOUT ever reading it — throwing
1348
- // away a genuinely fresh snapshot. That direction is not recoverable
1349
- // until the next save, so it's the one this reorder eliminates.
1350
- //
1351
- // The new-seq meta write stays synchronous and throwing. Same-seq work does
1352
- // not need to advance the freshness gate, so it leaves the current sidecar
1353
- // untouched until the worker has a semantic verdict. This prevents a stale
1354
- // local request from replacing a
1355
- // newer sibling process's same-seq fingerprint before it can coalesce.
1356
- if (priorPersist?.seq !== snapshot.seq) {
1357
- writeProjectSnapshotMeta(metaPath, snapshot);
1358
- }
1359
- // Record the authoritative in-process write BEFORE handing the body off, so
1360
- // a merge-read between now and the worker's promotion sees our own object
1361
- // (the on-disk body still holds the previous generation until promotion). The
1362
- // pre-write mtime is our baseline: while disk stays at it (or below), our
1363
- // object wins; a promotion or an external write moves past it. Baseline off
1364
- // the CURRENTLY-RESOLVED body — which is the legacy uncompressed
1365
- // `project-snapshot.json` in the one-release upgrade window before the first
1366
- // gz promotion — not gz-only: statting only gzPath there would leave the
1367
- // baseline at -Infinity, so the load gate (which resolves the legacy body's
1368
- // real, positive mtime) would reject our own fresh write and serve the stale
1369
- // legacy body to a merge-consumer, silently dropping this snapshot's fields.
1370
- const priorBody = resolveSnapshotBodyPath(cwd);
1371
- const knownMtime = priorBody ? priorBody.mtimeMs : Number.NEGATIVE_INFINITY;
1372
- const knownSize = priorBody ? priorBody.size : Number.NEGATIVE_INFINITY;
1373
- const authoritativeEntry = {
1374
- snapshot,
1375
- knownMtime,
1376
- knownSize,
1377
- lastUsedAt: Date.now(),
1378
- };
1379
- const previousAuthoritativeEntry = authoritativeSnapshots.get(key);
1380
- if (previousAuthoritativeEntry)
1381
- clearAuthoritativeSnapshotTimer(previousAuthoritativeEntry);
1382
- authoritativeSnapshots.set(key, authoritativeEntry);
1383
- scheduleAuthoritativeSnapshotEviction(key, authoritativeEntry);
1384
- enforceAuthoritativeSnapshotCap();
1385
- // A stale disk-parse-cache entry for this path must not out-vote the fresh
1386
- // authoritative write once the latter is dropped (oversized bodies).
1387
- snapshotParseCache.delete(gzPath);
1388
- const generationState = _snapshotGenerationStates.get(key);
1389
- const generation = generationState?.seq === snapshot.seq
1390
- ? generationState.generation
1391
- : (generationState?.generation ?? 0) + 1;
1392
- _snapshotGenerationStates.set(key, { generation, seq: snapshot.seq });
1393
- const stagePath = `${gzPath}.stage-${process.pid}-${generation}`;
1394
- const pending = {
1395
- key,
1396
- cwd,
1397
- gzPath,
1398
- legacyPath,
1399
- stagePath,
1400
- snapshot,
1401
- generation,
1402
- durablePersist: priorPersist,
1403
- dedupeFingerprints: baselines.fingerprints,
1404
- };
1405
- sweepStaleSnapshotStageFiles(cacheDir);
1406
- ensureSnapshotPersistExitHook();
1407
- if (_activeSnapshotPersists.has(key)) {
1408
- _queuedSnapshotPersists.set(key, pending);
1409
- logSnapshotPersistDecision({
1410
- cwd,
1411
- seq: snapshot.seq,
1412
- decision: "coalesced",
1413
- });
1414
- return;
1415
- }
1416
- dispatchSnapshotPersist(pending);
1417
- }
1418
- // --- Test hooks for the worker persist path ---------------------------------
1419
- /** Test-only: wait until worker requests have either landed or degraded. */
1420
- export async function waitForProjectSnapshotPersistsForTests() {
1421
- for (let attempts = 0; attempts < 200 &&
1422
- (_snapshotWorkerRequests.size > 0 ||
1423
- _activeSnapshotPersists.size > 0 ||
1424
- _queuedSnapshotPersists.size > 0); attempts++) {
1425
- await new Promise((resolve) => setTimeout(resolve, 10));
1426
- }
1427
- }
1428
- /** Test-only: force any in-flight worker body write to the sync main-thread path. */
1429
- export function flushProjectSnapshotPersistsForTests() {
1430
- const requests = [..._snapshotWorkerRequests.values()];
1431
- _snapshotWorkerRequests.clear();
1432
- for (const pending of requests) {
1433
- dispatchMainThreadWriteThroughSeam(pending, undefined);
1434
- }
1435
- }
1436
- /** Test-only: exercise the degraded worker-death path. */
1437
- export async function terminateProjectSnapshotPersistWorkerForTests() {
1438
- const worker = _snapshotPersistWorker;
1439
- if (worker)
1440
- await worker.terminate();
1441
- }
1442
- /** Test-only: restore worker creation + clear generation state after a deliberate death. */
1443
- export function resetProjectSnapshotPersistWorkerForTests() {
1444
- _snapshotWorkerDisabled = false;
1445
- _snapshotGenerationGateEnabledForTests = true;
1446
- _snapshotPromotionSeamForTests = undefined;
1447
- _snapshotPersistWorker = undefined;
1448
- _snapshotWorkerRequests.clear();
1449
- _snapshotGenerationStates.clear();
1450
- _successfulSnapshotPersists.clear();
1451
- _failedSnapshotPersists.clear();
1452
- _activeSnapshotPersists.clear();
1453
- _queuedSnapshotPersists.clear();
1454
- _snapshotExiting = false;
1455
- _snapshotWorkerBodyWritesForTests = 0;
1456
- _lastSnapshotPersistErrorForTests = undefined;
1457
- }
1458
- /** Test-only: expose bounded queue state without retaining snapshot bodies. */
1459
- export function getProjectSnapshotPersistStateForTests(cwd) {
1460
- const key = normalizeMapKey(cwd);
1461
- return {
1462
- active: _activeSnapshotPersists.has(key),
1463
- queued: _queuedSnapshotPersists.has(key),
1464
- successfulFingerprint: _successfulSnapshotPersists.get(key)?.fingerprint,
1465
- workerBodyWrites: _snapshotWorkerBodyWritesForTests,
1466
- };
1467
- }
1468
- /** Test-only mutation switch for proving the supersession invariant. */
1469
- export function setProjectSnapshotGenerationGateForTests(enabled) {
1470
- _snapshotGenerationGateEnabledForTests = enabled;
1471
- }
1472
- /** Test-only seam immediately before generation-gated promotion. */
1473
- export function setProjectSnapshotPromotionSeamForTests(seam) {
1474
- _snapshotPromotionSeamForTests = seam;
1475
- }
1476
- export function getProjectSnapshotPersistErrorForTests() {
1477
- return _lastSnapshotPersistErrorForTests;
1478
- }
1479
- // ── #2442 test-only seams: exercise the three BoundedFifoMap-backed caches ──
1480
- // through the exact same write path production uses (cacheParsedSnapshot /
1481
- // rememberSuccessfulSnapshotPersist's delete+set refresh, and a direct write
1482
- // mirroring recordSnapshotPersistFailure's), without the real gzip/worker
1483
- // persistence pipeline or a full ProjectSnapshot fixture.
1484
- /** Test-only: seed the parse cache exactly as `cacheParsedSnapshot` would. */
1485
- export function _seedSnapshotParseCacheForTests(key, entry) {
1486
- cacheParsedSnapshot(key, entry);
1487
- }
1488
- export function _snapshotParseCacheKeysForTests() {
1489
- return [...snapshotParseCache.keys()];
1490
- }
1491
- /**
1492
- * #2442 test-only: the exact `snapshotParseCache.get(key)` the production
1493
- * read at {@link loadProjectSnapshot}'s parse-cache hit performs. A `.has()`
1494
- * would not do — `.has()` reorders nothing on either bounded class, so a
1495
- * has-based test cannot tell FIFO from LRU, which is the axis these bounded
1496
- * tests exist to pin (#2442 review F4).
1497
- */
1498
- export function _snapshotParseCacheGetForTests(key) {
1499
- return snapshotParseCache.get(key) !== undefined;
1500
- }
1501
- /** Test-only: seed the successful-persist cache exactly as production would. */
1502
- export function _seedSuccessfulSnapshotPersistForTests(key, record) {
1503
- rememberSuccessfulSnapshotPersist(key, record);
1504
- }
1505
- export function _successfulSnapshotPersistKeysForTests() {
1506
- return [..._successfulSnapshotPersists.keys()];
1507
- }
1508
- /** #2442 test-only: the same `_successfulSnapshotPersists.get(key)` the
1509
- * persist-dedupe read and `getProjectSnapshotPersistStateForTests` perform.
1510
- * See {@link _snapshotParseCacheGetForTests} for why `.has()` will not do. */
1511
- export function _successfulSnapshotPersistGetForTests(key) {
1512
- return _successfulSnapshotPersists.get(key) !== undefined;
1513
- }
1514
- /** Test-only: seed the failed-persist cache with the same write
1515
- * `recordSnapshotPersistFailure` performs, without its side effects
1516
- * (deleteAuthoritativeSnapshot / logLatency) or a full PendingSnapshotBody. */
1517
- export function _seedFailedSnapshotPersistForTests(key, record) {
1518
- _failedSnapshotPersists.set(key, record);
1519
- }
1520
- export function _failedSnapshotPersistKeysForTests() {
1521
- return [..._failedSnapshotPersists.keys()];
1522
- }
1523
- export function buildProjectSnapshotFromRuntime(args) {
1524
- return {
1525
- version: PROJECT_SNAPSHOT_VERSION,
1526
- projectRoot: normalizeMapKey(path.resolve(args.cwd)),
1527
- generatedAt: new Date().toISOString(),
1528
- seq: args.runtime.projectSeq,
1529
- files: {},
1530
- symbols: {},
1531
- reverseDeps: {},
1532
- cachedExports: [...args.runtime.cachedExports.entries()].sort((a, b) => a[0].localeCompare(b[0])),
1533
- // #1019: capture the runtime's live sequence index AT this seq. The runtime
1534
- // is seeded from the change log at session start and bumped in lockstep with
1535
- // every append, so `getFileSeqEntries()` IS the fold of the log up to
1536
- // `projectSeq` — and its keys are already `normalizeMapKey(path.resolve())`,
1537
- // the exact form the change-log replay produces.
1538
- sequenceIndex: {
1539
- projectSeq: args.runtime.projectSeq,
1540
- fileSeqByPath: args.runtime.getFileSeqEntries(),
1541
- },
1542
- wordIndex: args.runtime.wordIndex
1543
- ? serializeWordIndex(args.runtime.wordIndex)
1544
- : undefined,
1545
- projectRulesScan: args.runtime.projectRulesScan,
1546
- startupScan: args.startupScan,
1547
- languageProfile: args.languageProfile,
1548
- conventions: args.conventions,
1549
- };
1550
- }
1551
- export function hydrateRuntimeFromProjectSnapshot(runtime, snapshot) {
1552
- runtime.cachedExports.clear();
1553
- for (const [name, filePath] of snapshot.cachedExports) {
1554
- runtime.cachedExports.set(name, filePath);
1555
- }
1556
- if (snapshot.projectRulesScan) {
1557
- runtime.projectRulesScan = snapshot.projectRulesScan;
1558
- }
1559
- runtime.wordIndex = deserializeWordIndex(snapshot.wordIndex);
1560
- }
1561
- /**
1562
- * #1785 F2/F3: additive counterpart to `hydrateRuntimeFromProjectSnapshot`
1563
- * for a LATE/retroactive hydration attempt — one that runs after other work
1564
- * may already have populated the runtime for real (e.g. quick mode's
1565
- * background warmup building a genuine `wordIndex`, docCount > 0). The
1566
- * unconditional version above is only safe for the FIRST hydration attempt,
1567
- * made before anything else has run: clearing `cachedExports` is correct
1568
- * there because there is nothing yet to destroy.
1569
- *
1570
- * A late call cannot make that assumption, and `cachedExports` and
1571
- * `projectRulesScan` are populated by INDEPENDENT tasks from whatever else is
1572
- * running (nothing else in quick mode touches either), so this guards each
1573
- * field on its OWN "nothing computed since" check rather than one
1574
- * all-or-nothing bail-out — the field a concurrent task actually populated is
1575
- * protected without needlessly withholding the other, still-idle field the
1576
- * snapshot could still supply. Returns whether it hydrated ANY field, so the
1577
- * caller can log honestly instead of claiming success when nothing changed.
1578
- *
1579
- * #1785 F5: takes ONLY the narrow `{ cachedExports, projectRulesScan }`
1580
- * shape (see `ProjectSnapshotExportsAndRules`) — deliberately no `wordIndex`
1581
- * parameter at all, not even an optional one. The retroactive path's
1582
- * captured/reloaded snapshot always comes from
1583
- * `loadProjectSnapshotExportsAndRules`, which never parses `wordIndex` in
1584
- * the first place (avoiding its dominant share of a full body parse's cost —
1585
- * see that function's doc comment for the measured numbers). This is also
1586
- * the right behavior, not just a performance shortcut: quick mode's warmup
1587
- * is the only thing that ever builds a `wordIndex` in this window, and F2's
1588
- * hazard was this exact late-hydration path nulling that live index from a
1589
- * disk copy that predated it — removing the parameter removes the hazard by
1590
- * construction instead of merely guarding against it at runtime.
1591
- */
1592
- export function hydrateRuntimeFromProjectSnapshotIfIdle(runtime, snapshot) {
1593
- let hydratedAnything = false;
1594
- if (runtime.cachedExports.size === 0 && snapshot.cachedExports.length > 0) {
1595
- runtime.cachedExports.clear();
1596
- for (const [name, filePath] of snapshot.cachedExports) {
1597
- runtime.cachedExports.set(name, filePath);
1598
- }
1599
- hydratedAnything = true;
1600
- }
1601
- if (!runtime.projectRulesScan.hasCustomRules &&
1602
- runtime.projectRulesScan.rules.length === 0 &&
1603
- snapshot.projectRulesScan) {
1604
- runtime.projectRulesScan = snapshot.projectRulesScan;
1605
- hydratedAnything = true;
1606
- }
1607
- return hydratedAnything;
1608
- }
1609
- export function saveRuntimeProjectSnapshot(args) {
1610
- try {
1611
- if (typeof args.runtime.projectSeq !== "number")
1612
- return;
1613
- const existing = loadProjectSnapshot(args.cwd);
1614
- let conventions = args.conventions ?? existing?.conventions;
1615
- if (!conventions) {
1616
- try {
1617
- conventions = detectProjectConventions(args.cwd);
1618
- }
1619
- catch (err) {
1620
- args.dbg?.(`project_snapshot: convention detection failed: ${err}`);
1621
- }
1622
- }
1623
- const snapshot = buildProjectSnapshotFromRuntime({
1624
- ...args,
1625
- startupScan: args.startupScan ?? existing?.startupScan,
1626
- languageProfile: args.languageProfile ?? existing?.languageProfile,
1627
- conventions,
1628
- });
1629
- if (existing) {
1630
- snapshot.files = existing.files ?? {};
1631
- snapshot.symbols = existing.symbols ?? {};
1632
- snapshot.reverseDeps = existing.reverseDeps ?? {};
1633
- // The word index is built by its own session task, which may not have
1634
- // finished when another task triggers a save — keep the prior index
1635
- // rather than clobbering it with undefined. #348: only carry it forward
1636
- // when `existing` was built AT THIS SAME seq — otherwise a stale
1637
- // snapshot's leftover index (already correctly rejected as stale by
1638
- // isProjectSnapshotFresh on load, seq mismatch) would get silently
1639
- // re-stamped with the CURRENT seq by this save, "laundering" a stale
1640
- // index into looking fresh before the word-index task even runs.
1641
- if (!snapshot.wordIndex &&
1642
- existing.wordIndex &&
1643
- existing.seq === snapshot.seq) {
1644
- snapshot.wordIndex = existing.wordIndex;
1645
- }
1646
- }
1647
- saveProjectSnapshot(args.cwd, snapshot);
1648
- args.dbg?.(`project_snapshot: saved seq=${snapshot.seq} exports=${snapshot.cachedExports.length}`);
1649
- }
1650
- catch (err) {
1651
- args.dbg?.(`project_snapshot: save failed: ${err}`);
1652
- }
1653
- }