pi-lens 3.8.73 → 4.0.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 (346) hide show
  1. package/CHANGELOG.md +2614 -12
  2. package/README.md +50 -21
  3. package/dist/clients/actionable-warnings.js +73 -18
  4. package/dist/clients/agent-behavior-client.js +21 -4
  5. package/dist/clients/ast-grep-client.js +68 -16
  6. package/dist/clients/ast-grep-types.js +0 -1
  7. package/dist/clients/ast-grep-yaml-synth.js +37 -10
  8. package/dist/clients/atomic-write-staging.js +63 -0
  9. package/dist/clients/atomic-write.js +116 -16
  10. package/dist/clients/bash-file-access.js +193 -23
  11. package/dist/clients/biome-client.js +29 -25
  12. package/dist/clients/bootstrap.js +11 -2
  13. package/dist/clients/bounded-cache.js +34 -0
  14. package/dist/clients/bounded-pid-file-lock.js +192 -0
  15. package/dist/clients/bus-events-logger.js +5 -1
  16. package/dist/clients/bus-publish.js +10 -3
  17. package/dist/clients/cache/rule-cache.js +89 -6
  18. package/dist/clients/cache-manager.js +97 -9
  19. package/dist/clients/cache-observability.js +498 -0
  20. package/dist/clients/call-graph.js +460 -108
  21. package/dist/clients/cascade-logger.js +5 -1
  22. package/dist/clients/child-unref.js +85 -0
  23. package/dist/clients/codebase-model.js +52 -23
  24. package/dist/clients/collateral-test-role.js +40 -0
  25. package/dist/clients/complexity-client.js +6 -8
  26. package/dist/clients/console-guard-install.js +14 -0
  27. package/dist/clients/cooperative-budget.js +42 -0
  28. package/dist/clients/dead-code-client.js +2 -1
  29. package/dist/clients/debug-handles.js +206 -0
  30. package/dist/clients/debug-heap.js +167 -0
  31. package/dist/clients/degradation-ledger.js +153 -0
  32. package/dist/clients/dependency-checker.js +466 -63
  33. package/dist/clients/diagnostic-dispositions.js +83 -13
  34. package/dist/clients/diagnostics-publish.js +10 -3
  35. package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
  36. package/dist/clients/dispatch/dispatcher.js +71 -13
  37. package/dist/clients/dispatch/fact-store.js +11 -0
  38. package/dist/clients/dispatch/facts/function-facts.js +37 -7
  39. package/dist/clients/dispatch/facts/import-facts.js +108 -119
  40. package/dist/clients/dispatch/facts/tree-sitter-facts.js +12 -12
  41. package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
  42. package/dist/clients/dispatch/indent-detect.js +35 -0
  43. package/dist/clients/dispatch/inline-suppressions.js +17 -8
  44. package/dist/clients/dispatch/integration.js +1094 -611
  45. package/dist/clients/dispatch/lazy.js +14 -0
  46. package/dist/clients/dispatch/plan.js +11 -1
  47. package/dist/clients/dispatch/rule-id-normalize.js +50 -0
  48. package/dist/clients/dispatch/rule-policy.js +135 -0
  49. package/dist/clients/dispatch/rules/async-noise.js +1 -0
  50. package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
  51. package/dist/clients/dispatch/runners/ast-grep-napi.js +47 -6
  52. package/dist/clients/dispatch/runners/biome-check.js +5 -9
  53. package/dist/clients/dispatch/runners/detekt.js +3 -2
  54. package/dist/clients/dispatch/runners/hadolint.js +2 -1
  55. package/dist/clients/dispatch/runners/helm-lint.js +154 -0
  56. package/dist/clients/dispatch/runners/htmlhint.js +3 -2
  57. package/dist/clients/dispatch/runners/index.js +5 -1
  58. package/dist/clients/dispatch/runners/lsp.js +14 -6
  59. package/dist/clients/dispatch/runners/markdownlint.js +4 -9
  60. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
  61. package/dist/clients/dispatch/runners/pyright.js +9 -5
  62. package/dist/clients/dispatch/runners/ruff.js +4 -8
  63. package/dist/clients/dispatch/runners/shellcheck.js +2 -3
  64. package/dist/clients/dispatch/runners/shfmt.js +2 -3
  65. package/dist/clients/dispatch/runners/spotbugs.js +2 -1
  66. package/dist/clients/dispatch/runners/taplo.js +2 -1
  67. package/dist/clients/dispatch/runners/terragrunt.js +163 -0
  68. package/dist/clients/dispatch/runners/tflint.js +37 -5
  69. package/dist/clients/dispatch/runners/tree-sitter.js +155 -166
  70. package/dist/clients/dispatch/runners/trivy-config.js +13 -7
  71. package/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  72. package/dist/clients/dispatch/runners/utils/runner-helpers.js +284 -34
  73. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  74. package/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  75. package/dist/clients/disposition-publish.js +10 -3
  76. package/dist/clients/durable-store.js +97 -0
  77. package/dist/clients/event-loop-monitor.js +94 -3
  78. package/dist/clients/extension-log.js +139 -0
  79. package/dist/clients/extension-mode.js +73 -0
  80. package/dist/clients/feature-hints.js +53 -7
  81. package/dist/clients/file-kinds.js +143 -31
  82. package/dist/clients/file-role.js +18 -3
  83. package/dist/clients/file-utils.js +56 -34
  84. package/dist/clients/fix-worklog.js +14 -2
  85. package/dist/clients/format-events-publish.js +16 -3
  86. package/dist/clients/format-service.js +24 -8
  87. package/dist/clients/formatters-lazy.js +8 -0
  88. package/dist/clients/formatters.js +358 -61
  89. package/dist/clients/generated-artifacts.js +169 -15
  90. package/dist/clients/git-guard.js +681 -11
  91. package/dist/clients/git-tracked-ignore.js +4 -0
  92. package/dist/clients/go-client.js +2 -1
  93. package/dist/clients/govulncheck-client.js +8 -0
  94. package/dist/clients/grammar-source.js +12 -4
  95. package/dist/clients/gzip-stage-write.js +102 -0
  96. package/dist/clients/host-ports.js +31 -0
  97. package/dist/clients/installer/index.js +733 -213
  98. package/dist/clients/instance-reaper.js +142 -172
  99. package/dist/clients/jscpd-client.js +31 -54
  100. package/dist/clients/knip-client.js +78 -36
  101. package/dist/clients/language-policy.js +14 -2
  102. package/dist/clients/language-profile.js +72 -77
  103. package/dist/clients/latency-logger.js +28 -16
  104. package/dist/clients/lens-config.js +140 -166
  105. package/dist/clients/lens-engine.js +124 -21
  106. package/dist/clients/lens-events.js +27 -4
  107. package/dist/clients/lens-flag-registry.js +293 -0
  108. package/dist/clients/lens-map.js +96 -26
  109. package/dist/clients/live-bus-emitter.js +30 -0
  110. package/dist/clients/log-cleanup.js +8 -18
  111. package/dist/clients/lsp/cascade-tier.js +23 -3
  112. package/dist/clients/lsp/client.js +566 -57
  113. package/dist/clients/lsp/config.js +74 -10
  114. package/dist/clients/lsp/diagnostic-binding.js +86 -0
  115. package/dist/clients/lsp/edits.js +1012 -121
  116. package/dist/clients/lsp/index.js +1840 -803
  117. package/dist/clients/lsp/language.js +4 -1
  118. package/dist/clients/lsp/launch.js +40 -32
  119. package/dist/clients/lsp/path-utils.js +1 -1
  120. package/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  121. package/dist/clients/lsp/server.js +279 -60
  122. package/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  123. package/dist/clients/lsp-document-symbols.js +112 -0
  124. package/dist/clients/lsp-lazy.js +8 -0
  125. package/dist/clients/lsp-mutation.js +248 -0
  126. package/dist/clients/mcp/analyze.js +128 -36
  127. package/dist/clients/mcp/ipc.js +157 -24
  128. package/dist/clients/mcp/review.js +36 -3
  129. package/dist/clients/mcp/session.js +216 -8
  130. package/dist/clients/memory-sampler.js +115 -0
  131. package/dist/clients/metrics-client.js +2 -1
  132. package/dist/clients/metrics-history.js +2 -1
  133. package/dist/clients/module-report-lsp.js +5 -0
  134. package/dist/clients/module-report.js +267 -82
  135. package/dist/clients/ndjson-logger.js +334 -103
  136. package/dist/clients/opengrep-client.js +1 -1
  137. package/dist/clients/partial-edit-apply.js +120 -5
  138. package/dist/clients/path-keyed-map.js +76 -0
  139. package/dist/clients/path-utils.js +141 -5
  140. package/dist/clients/performance-report.js +327 -0
  141. package/dist/clients/pipeline.js +54 -13
  142. package/dist/clients/print-mode.js +21 -0
  143. package/dist/clients/project-changes.js +117 -4
  144. package/dist/clients/project-diagnostics/cache.js +3 -2
  145. package/dist/clients/project-diagnostics/extractors.js +26 -115
  146. package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
  147. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +117 -0
  148. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
  149. package/dist/clients/project-diagnostics/scanner.js +268 -128
  150. package/dist/clients/project-lens-config.js +138 -34
  151. package/dist/clients/project-report.js +64 -25
  152. package/dist/clients/project-scale.js +8 -10
  153. package/dist/clients/project-snapshot-persist-worker.js +6 -0
  154. package/dist/clients/project-snapshot.js +767 -8
  155. package/dist/clients/project-trust.js +181 -0
  156. package/dist/clients/quiet-window.js +27 -5
  157. package/dist/clients/read-bridge.js +147 -0
  158. package/dist/clients/read-expansion.js +22 -12
  159. package/dist/clients/read-guard-logger.js +159 -1
  160. package/dist/clients/read-guard-tool-lines.js +150 -29
  161. package/dist/clients/read-guard.js +262 -3
  162. package/dist/clients/redact/secrets.js +449 -0
  163. package/dist/clients/resource-sampler.js +144 -39
  164. package/dist/clients/reverse-deps.js +48 -0
  165. package/dist/clients/review-graph/builder.js +2570 -326
  166. package/dist/clients/review-graph/git-identity.js +2 -1
  167. package/dist/clients/review-graph/import-resolvers.js +21 -10
  168. package/dist/clients/review-graph/persist-worker.js +10 -0
  169. package/dist/clients/review-graph/query.js +5 -0
  170. package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
  171. package/dist/clients/review-graph/symbol-id.js +46 -0
  172. package/dist/clients/review-graph/tsconfig-paths.js +57 -6
  173. package/dist/clients/review-graph/workspace-modules.js +51 -3
  174. package/dist/clients/review-graph-logger.js +53 -0
  175. package/dist/clients/ruff-client.js +27 -44
  176. package/dist/clients/runtime-agent-end.js +111 -75
  177. package/dist/clients/runtime-context.js +36 -12
  178. package/dist/clients/runtime-coordinator.js +76 -8
  179. package/dist/clients/runtime-session.js +799 -137
  180. package/dist/clients/runtime-tool-call.js +117 -62
  181. package/dist/clients/runtime-tool-result.js +218 -23
  182. package/dist/clients/runtime-turn.js +284 -40
  183. package/dist/clients/rust-client.js +2 -1
  184. package/dist/clients/safe-spawn.js +873 -71
  185. package/dist/clients/security-scan-client.js +2 -1
  186. package/dist/clients/session-start-observability.js +15 -0
  187. package/dist/clients/session-state-store.js +5 -3
  188. package/dist/clients/sessionstart-logger.js +18 -0
  189. package/dist/clients/sg-runner.js +284 -159
  190. package/dist/clients/slow-fs.js +2 -1
  191. package/dist/clients/smells-rollup.js +224 -0
  192. package/dist/clients/source-filter.js +231 -43
  193. package/dist/clients/source-walker.js +66 -10
  194. package/dist/clients/startup-scan.js +15 -16
  195. package/dist/clients/startup-timing.js +9 -0
  196. package/dist/clients/test-runner-client.js +38 -8
  197. package/dist/clients/todo-scanner.js +30 -3
  198. package/dist/clients/tool-event.js +35 -0
  199. package/dist/clients/tool-policy.js +388 -39
  200. package/dist/clients/tool-render.js +155 -0
  201. package/dist/clients/tree-sitter-cache.js +153 -140
  202. package/dist/clients/tree-sitter-client.js +1722 -80
  203. package/dist/clients/tree-sitter-logger.js +64 -1
  204. package/dist/clients/tree-sitter-query-loader.js +117 -12
  205. package/dist/clients/tree-sitter-shared.js +48 -19
  206. package/dist/clients/tree-sitter-symbol-extractor.js +115 -11
  207. package/dist/clients/user-notify.js +51 -0
  208. package/dist/clients/vanished-instance-marker.js +57 -0
  209. package/dist/clients/warm-attach.js +33 -13
  210. package/dist/clients/widget-state.js +342 -49
  211. package/dist/clients/word-index-logger.js +44 -0
  212. package/dist/clients/word-index.js +612 -68
  213. package/dist/clients/workspace-topology.js +100 -7
  214. package/dist/index.js +65923 -47161
  215. package/dist/mcp/analyze-cli.js +185 -29
  216. package/dist/mcp/cli.js +110 -0
  217. package/dist/mcp/server.js +272 -50
  218. package/dist/tools/ast-grep-search.js +93 -27
  219. package/dist/tools/lens-diagnostics.js +247 -41
  220. package/dist/tools/lsp-diagnostics.js +153 -40
  221. package/dist/tools/lsp-navigation.js +51 -31
  222. package/dist/tools/module-report.js +9 -0
  223. package/dist/tools/symbol-search.js +21 -2
  224. package/docs/agent-guide.md +247 -0
  225. package/docs/agent-tools.md +21 -10
  226. package/docs/analysisall.md +192 -0
  227. package/docs/api-ports-inventory.md +165 -0
  228. package/docs/ast-grep_rules_catalog.md +5 -2
  229. package/docs/astplayground.md +3 -4
  230. package/docs/audit1.md +5 -5
  231. package/docs/durable-store-audit-1202.md +32 -0
  232. package/docs/environment-variables.md +197 -11
  233. package/docs/fable.md +27 -0
  234. package/docs/features.md +35 -5
  235. package/docs/globalconfig.md +101 -5
  236. package/docs/language-coverage.md +6 -5
  237. package/docs/lsp-capability-matrix.md +2 -2
  238. package/docs/mcp.md +54 -20
  239. package/docs/servercapabilities.md +7 -4
  240. package/docs/settings.md +192 -0
  241. package/docs/tools.md +18 -1
  242. package/docs/tree-sitter_rules_catalog.md +11 -31
  243. package/docs/usage.md +31 -0
  244. package/package.json +139 -138
  245. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  246. package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
  247. package/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  248. package/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  249. package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
  250. package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
  251. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
  252. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
  253. package/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  254. package/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  255. package/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  256. package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
  257. package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
  258. package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
  259. package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
  260. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
  261. package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
  262. package/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  263. package/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  264. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
  265. package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
  266. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
  267. package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
  268. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
  269. package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
  270. package/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  271. package/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  272. package/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  273. package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
  274. package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
  275. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
  276. package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
  277. package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
  278. package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
  279. package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
  280. package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
  281. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
  282. package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
  283. package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
  284. package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
  285. package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
  286. package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
  287. package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
  288. package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
  289. package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
  290. package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
  291. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
  292. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
  293. package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
  294. package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
  295. package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
  296. package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
  297. package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
  298. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
  299. package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
  300. package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
  301. package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
  302. package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
  303. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
  304. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
  305. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
  306. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
  307. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
  308. package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
  309. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
  310. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
  311. package/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  312. package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
  313. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
  314. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
  315. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
  316. package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
  317. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
  318. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
  319. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
  320. package/rules/typos/_typos.toml +17 -0
  321. package/scripts/analyze-pi-lens-logs.mjs +55 -2
  322. package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
  323. package/dist/clients/ast-grep-parser.js +0 -86
  324. package/dist/clients/config-enabled-shape.js +0 -39
  325. package/dist/clients/lsp/interactive-install.js +0 -367
  326. package/dist/clients/lsp/lsp-index.js +0 -10
  327. package/dist/clients/lsp/server-strategies.js +0 -5
  328. package/dist/clients/project-metadata.js +0 -690
  329. package/dist/clients/source-groups.js +0 -96
  330. package/dist/clients/types.js +0 -11
  331. package/docs/env_variables.md +0 -10
  332. package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
  333. package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
  334. package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
  335. package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
  336. package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
  337. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
  338. package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
  339. package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
  340. package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
  341. package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
  342. package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
  343. package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
  344. package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
  345. package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
  346. package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
@@ -1,14 +1,47 @@
1
1
  import * as fs from "node:fs";
2
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";
3
7
  import { getProjectDataDir } from "./file-utils.js";
4
8
  import { readJsonCache } from "./json-cache-read.js";
9
+ import { logLatency } from "./latency-logger.js";
5
10
  import { normalizeMapKey } from "./path-utils.js";
6
11
  import { detectProjectConventions, } from "./project-conventions.js";
7
12
  import { deserializeWordIndex, serializeWordIndex, } from "./word-index.js";
8
13
  // v2: added `wordIndex` (identifier inverted index + BM25, #162). Bumping the
9
14
  // version invalidates pre-v2 snapshots so they rebuild with the new field.
10
15
  export const PROJECT_SNAPSHOT_VERSION = 2;
16
+ function parseSequenceIndex(value) {
17
+ if (!value || typeof value !== "object")
18
+ return undefined;
19
+ const index = value;
20
+ if (typeof index.projectSeq !== "number")
21
+ return undefined;
22
+ if (!Array.isArray(index.fileSeqByPath))
23
+ return undefined;
24
+ const fileSeqByPath = index.fileSeqByPath.filter((entry) => Array.isArray(entry) &&
25
+ typeof entry[0] === "string" &&
26
+ typeof entry[1] === "number");
27
+ return { projectSeq: index.projectSeq, fileSeqByPath };
28
+ }
29
+ // #958 item 2: the canonical snapshot body is now streamed gzip
30
+ // (`project-snapshot.json.gz`), written by a worker thread off the save path
31
+ // (see the persist plumbing below). The previous uncompressed
32
+ // `project-snapshot.json` remains readable for ONE compatibility release so an
33
+ // upgrading user doesn't lose their snapshot — see loadSnapshotBody's legacy
34
+ // fallback. gzip measured 5-10x on top of the #957 compaction win (the
35
+ // review-graph's own measurement was 60MB → 1.4MB).
11
36
  export function getProjectSnapshotPath(cwd) {
37
+ return path.join(getProjectDataDir(cwd), "cache", "project-snapshot.json.gz");
38
+ }
39
+ /**
40
+ * Pre-#958 uncompressed body path. Read as a one-release fallback when no
41
+ * `.json.gz` is present; deleted whenever a fresh gz body is promoted so the
42
+ * two never coexist.
43
+ */
44
+ export function getProjectSnapshotLegacyPath(cwd) {
12
45
  return path.join(getProjectDataDir(cwd), "cache", "project-snapshot.json");
13
46
  }
14
47
  export function getProjectSnapshotMetaPath(cwd) {
@@ -44,6 +77,7 @@ function parseSnapshot(value) {
44
77
  cachedExports: snapshot.cachedExports.filter((entry) => Array.isArray(entry) &&
45
78
  typeof entry[0] === "string" &&
46
79
  typeof entry[1] === "string"),
80
+ sequenceIndex: parseSequenceIndex(snapshot.sequenceIndex),
47
81
  wordIndex: snapshot.wordIndex,
48
82
  projectRulesScan: snapshot.projectRulesScan,
49
83
  startupScan: snapshot.startupScan,
@@ -51,19 +85,733 @@ function parseSnapshot(value) {
51
85
  conventions: snapshot.conventions,
52
86
  };
53
87
  }
88
+ function parseSnapshotMeta(value) {
89
+ if (!value || typeof value !== "object")
90
+ return null;
91
+ const meta = value;
92
+ if (typeof meta.version !== "number")
93
+ return null;
94
+ if (typeof meta.seq !== "number")
95
+ return null;
96
+ return {
97
+ timestamp: typeof meta.timestamp === "string" ? meta.timestamp : "",
98
+ version: meta.version,
99
+ seq: meta.seq,
100
+ sequenceIndex: parseSequenceIndex(meta.sequenceIndex),
101
+ };
102
+ }
103
+ /**
104
+ * Read the tiny meta sidecar (`project-snapshot.meta.json`) WITHOUT parsing
105
+ * the (potentially 40-112MB) snapshot body. Written on every save; absent on
106
+ * legacy installs — callers must treat a `null` return as "no opinion" and
107
+ * fall through to parsing the body. #947.
108
+ */
109
+ export function readProjectSnapshotMeta(cwd) {
110
+ const meta = readJsonCache(getProjectSnapshotMetaPath(cwd), (parsed) => parseSnapshotMeta(parsed) ?? undefined);
111
+ return meta ?? null;
112
+ }
113
+ /**
114
+ * Cheap staleness verdict from the meta sidecar alone. When this returns
115
+ * true, the snapshot body CANNOT be fresh (isProjectSnapshotFresh would
116
+ * reject it on the same two fields), so the expensive body parse can be
117
+ * skipped entirely. #947.
118
+ */
119
+ export function isProjectSnapshotMetaStale(meta, currentProjectSeq) {
120
+ return (meta.version !== PROJECT_SNAPSHOT_VERSION || meta.seq !== currentProjectSeq);
121
+ }
122
+ const SNAPSHOT_PARSE_CACHE_MAX = 4;
123
+ // #957 review: the cache exists to avoid re-parsing NORMAL snapshots within a
124
+ // session. A 112MB-class body parses to hundreds of MB of heap — pinning that
125
+ // for process lifetime inverts the win, so oversized bodies are simply never
126
+ // cached (they re-parse per read, exactly the pre-#947 behavior). Measured
127
+ // against the UNCOMPRESSED body size, never the (much smaller) gz file size.
128
+ const SNAPSHOT_PARSE_CACHE_MAX_BYTES = 24 * 1024 * 1024;
129
+ const snapshotParseCache = new Map();
130
+ function withoutWordIndex(snapshot) {
131
+ if (!snapshot?.wordIndex)
132
+ return snapshot;
133
+ const { wordIndex: _releasedPostings, ...stripped } = snapshot;
134
+ return stripped;
135
+ }
136
+ function cacheParsedSnapshot(snapshotPath, entry) {
137
+ // Refresh recency (Map preserves insertion order).
138
+ snapshotParseCache.delete(snapshotPath);
139
+ snapshotParseCache.set(snapshotPath, entry);
140
+ while (snapshotParseCache.size > SNAPSHOT_PARSE_CACHE_MAX) {
141
+ const oldest = snapshotParseCache.keys().next().value;
142
+ if (oldest === undefined)
143
+ break;
144
+ snapshotParseCache.delete(oldest);
145
+ }
146
+ }
147
+ const authoritativeSnapshots = new Map();
148
+ const PROJECT_SNAPSHOT_MAX_WARM_ROOTS = 8;
149
+ const PROJECT_SNAPSHOT_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
150
+ function projectSnapshotIdleEvictMs() {
151
+ const value = Number.parseInt(process.env.PI_LENS_PROJECT_SNAPSHOT_IDLE_EVICT_MS ?? "", 10);
152
+ return Number.isSafeInteger(value) && value > 0 ? value : PROJECT_SNAPSHOT_IDLE_EVICT_MS_DEFAULT;
153
+ }
154
+ function clearAuthoritativeSnapshotTimer(entry) {
155
+ if (entry.idleTimer)
156
+ clearTimeout(entry.idleTimer);
157
+ entry.idleTimer = undefined;
158
+ }
159
+ function deleteAuthoritativeSnapshot(key) {
160
+ const entry = authoritativeSnapshots.get(key);
161
+ if (entry)
162
+ clearAuthoritativeSnapshotTimer(entry);
163
+ authoritativeSnapshots.delete(key);
164
+ }
165
+ function scheduleAuthoritativeSnapshotEviction(key, entry) {
166
+ clearAuthoritativeSnapshotTimer(entry);
167
+ const generation = entry.lastUsedAt;
168
+ entry.idleTimer = setTimeout(() => {
169
+ entry.idleTimer = undefined;
170
+ if (authoritativeSnapshots.get(key) !== entry || entry.lastUsedAt !== generation)
171
+ return;
172
+ deleteAuthoritativeSnapshot(key);
173
+ }, projectSnapshotIdleEvictMs());
174
+ entry.idleTimer.unref?.();
175
+ }
176
+ function touchAuthoritativeSnapshot(key, entry) {
177
+ entry.lastUsedAt = Date.now();
178
+ scheduleAuthoritativeSnapshotEviction(key, entry);
179
+ }
180
+ function enforceAuthoritativeSnapshotCap() {
181
+ while (authoritativeSnapshots.size > PROJECT_SNAPSHOT_MAX_WARM_ROOTS) {
182
+ const victim = [...authoritativeSnapshots.entries()].sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)[0];
183
+ if (!victim)
184
+ return;
185
+ clearAuthoritativeSnapshotTimer(victim[1]);
186
+ deleteAuthoritativeSnapshot(victim[0]);
187
+ }
188
+ }
189
+ /** Test-only cache keys, in LRU order from oldest to newest. */
190
+ export function _getAuthoritativeSnapshotCacheKeysForTests() {
191
+ return [...authoritativeSnapshots.entries()]
192
+ .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
193
+ .map(([key]) => key);
194
+ }
195
+ /** Test hook: drop all cached parses + authoritative writes (per-worker isolation). */
196
+ export function _resetProjectSnapshotParseCacheForTests() {
197
+ snapshotParseCache.clear();
198
+ for (const entry of authoritativeSnapshots.values())
199
+ clearAuthoritativeSnapshotTimer(entry);
200
+ authoritativeSnapshots.clear();
201
+ }
202
+ /** Test hook: prove the parse-cache tier never owns serialized postings. */
203
+ export function _projectSnapshotParseCacheRetainsWordIndexForTests() {
204
+ return [...snapshotParseCache.values()].some((entry) => entry.snapshot?.wordIndex !== undefined);
205
+ }
206
+ /** Resolve which body file is currently on disk: gz canonical, else legacy. */
207
+ function resolveSnapshotBodyPath(cwd) {
208
+ const gzPath = getProjectSnapshotPath(cwd);
209
+ try {
210
+ const stat = fs.statSync(gzPath);
211
+ return { path: gzPath, gz: true, mtimeMs: stat.mtimeMs, size: stat.size };
212
+ }
213
+ catch {
214
+ /* fall through to the legacy uncompressed body */
215
+ }
216
+ const legacyPath = getProjectSnapshotLegacyPath(cwd);
217
+ try {
218
+ const stat = fs.statSync(legacyPath);
219
+ return {
220
+ path: legacyPath,
221
+ gz: false,
222
+ mtimeMs: stat.mtimeMs,
223
+ size: stat.size,
224
+ };
225
+ }
226
+ catch {
227
+ return null;
228
+ }
229
+ }
230
+ /**
231
+ * Read + parse the body off disk, transparently gunzipping the `.json.gz`
232
+ * canonical form and falling back to the pre-#958 uncompressed `.json` body
233
+ * for one compatibility release. Returns the parsed snapshot (or null on any
234
+ * failure) plus the UNCOMPRESSED byte length, so the caller can apply the
235
+ * heap-bounded cache gate against the real body size rather than the gz size.
236
+ */
237
+ // Test-observable count of ACTUAL disk body reads (both gz and legacy), so the
238
+ // #947 meta-gate tests can assert "body parsed / not parsed" independently of
239
+ // the compression format — the pre-#958 tests keyed this off a readJsonCache
240
+ // spy, which the gz path (gunzip + JSON.parse) legitimately bypasses.
241
+ let _snapshotBodyReadCountForTests = 0;
242
+ export function getSnapshotBodyReadCountForTests() {
243
+ return _snapshotBodyReadCountForTests;
244
+ }
245
+ export function resetSnapshotBodyReadCountForTests() {
246
+ _snapshotBodyReadCountForTests = 0;
247
+ }
248
+ function readSnapshotBody(bodyPath, gz) {
249
+ _snapshotBodyReadCountForTests++;
250
+ if (!gz) {
251
+ const snapshot = readJsonCache(bodyPath, (parsed) => parseSnapshot(parsed) ?? undefined) ?? null;
252
+ let rawBytes = 0;
253
+ try {
254
+ rawBytes = fs.statSync(bodyPath).size;
255
+ }
256
+ catch {
257
+ /* best-effort */
258
+ }
259
+ return { snapshot, rawBytes };
260
+ }
261
+ try {
262
+ const json = gunzipSync(fs.readFileSync(bodyPath)).toString("utf-8");
263
+ const snapshot = parseSnapshot(JSON.parse(json)) ?? null;
264
+ return { snapshot, rawBytes: Buffer.byteLength(json) };
265
+ }
266
+ catch (err) {
267
+ // Corrupt / truncated gz, or a parse failure: fail open exactly like
268
+ // readJsonCache does — a null return rebuilds the snapshot. Log it so a
269
+ // corrupt body is diagnosable rather than indistinguishable from "no
270
+ // snapshot yet" (both return a null snapshot here).
271
+ logLatency({
272
+ type: "phase",
273
+ phase: "project_snapshot_body_corrupt",
274
+ filePath: bodyPath,
275
+ durationMs: 0,
276
+ metadata: { error: err instanceof Error ? err.message : String(err) },
277
+ });
278
+ return { snapshot: null, rawBytes: 0 };
279
+ }
280
+ }
281
+ function loadProjectSnapshotInternal(cwd, requireWordIndex) {
282
+ const key = normalizeMapKey(cwd);
283
+ const body = resolveSnapshotBodyPath(cwd);
284
+ // Authoritative in-process write wins while our own (possibly still
285
+ // in-flight) write has not been superseded on disk by a newer external
286
+ // mtime. `body === null` means nothing is on disk yet — our just-scheduled
287
+ // write is the only truth, so serve it.
288
+ const authoritative = authoritativeSnapshots.get(key);
289
+ if (authoritative) {
290
+ const diskMtime = body ? body.mtimeMs : Number.NEGATIVE_INFINITY;
291
+ if (diskMtime <= authoritative.knownMtime) {
292
+ touchAuthoritativeSnapshot(key, authoritative);
293
+ return authoritative.snapshot;
294
+ }
295
+ // An external writer moved past our write — honor disk and stop
296
+ // serving the now-stale in-memory object.
297
+ deleteAuthoritativeSnapshot(key);
298
+ }
299
+ if (!body) {
300
+ snapshotParseCache.delete(getProjectSnapshotPath(cwd));
301
+ return null;
302
+ }
303
+ const cacheKey = body.path;
304
+ const cached = snapshotParseCache.get(cacheKey);
305
+ // Both mtime AND size must match (see the `size` field doc on
306
+ // SnapshotParseCacheEntry for why: coarse FAT/exFAT mtime resolution can
307
+ // otherwise alias a just-rewritten file onto a stale cache entry).
308
+ if (cached && cached.mtimeMs === body.mtimeMs && cached.size === body.size) {
309
+ if (!requireWordIndex || !cached.snapshot || cached.snapshot.wordIndex) {
310
+ return cached.snapshot;
311
+ }
312
+ }
313
+ const { snapshot, rawBytes } = readSnapshotBody(body.path, body.gz);
314
+ // Serialized postings expand into a much larger object graph. Cache only a
315
+ // shallow postings-stripped body: metadata/report consumers stay warm while
316
+ // the live warm WordIndex remains the sole retained postings graph (#1370).
317
+ const cacheSnapshot = withoutWordIndex(snapshot);
318
+ if (rawBytes > 0 &&
319
+ (rawBytes <= SNAPSHOT_PARSE_CACHE_MAX_BYTES || snapshot?.wordIndex)) {
320
+ cacheParsedSnapshot(cacheKey, {
321
+ mtimeMs: body.mtimeMs,
322
+ size: body.size,
323
+ snapshot: cacheSnapshot,
324
+ });
325
+ }
326
+ else {
327
+ snapshotParseCache.delete(cacheKey);
328
+ }
329
+ return snapshot;
330
+ }
331
+ /** Load the canonical body, including serialized postings when present. */
54
332
  export function loadProjectSnapshot(cwd) {
55
- const snapshot = readJsonCache(getProjectSnapshotPath(cwd), (parsed) => parseSnapshot(parsed) ?? undefined);
56
- return snapshot ?? null;
333
+ return loadProjectSnapshotInternal(cwd, true);
334
+ }
335
+ /**
336
+ * Load snapshot metadata without retaining or re-reading serialized postings.
337
+ * After publication this is served by the postings-stripped parse cache.
338
+ */
339
+ export function loadProjectSnapshotWithoutWordIndex(cwd) {
340
+ return loadProjectSnapshotInternal(cwd, false);
341
+ }
342
+ const _snapshotGenerations = new Map();
343
+ const _snapshotWorkerRequests = new Map();
344
+ let _snapshotPersistWorker;
345
+ let _snapshotWorkerRequestId = 0;
346
+ let _snapshotWorkerDisabled = false;
347
+ let _snapshotGenerationGateEnabledForTests = true;
348
+ let _snapshotPromotionSeamForTests;
349
+ let _lastSnapshotPersistErrorForTests;
350
+ function snapshotWorkerEnabled() {
351
+ // The synchronous fallback writer is a legitimate degraded mode (hosts that
352
+ // can't spawn a worker); tests also force it so a save→load is fully
353
+ // synchronous. Production defaults to the worker.
354
+ const raw = process.env.PI_LENS_SNAPSHOT_PERSIST_SYNC;
355
+ return !(raw === "1" || raw === "true");
356
+ }
357
+ function recordSnapshotPersistFailure(cwd, error) {
358
+ // Honesty (#533): a failed async body write must be surfaced, never left to
359
+ // masquerade as a saved snapshot. The meta gate is already self-healing (an
360
+ // old body under a newer-seq meta is rejected on the body's own embedded
361
+ // seq), and dropping the authoritative entry means the next load reflects
362
+ // what is ACTUALLY on disk rather than the object we failed to persist.
363
+ _lastSnapshotPersistErrorForTests = error;
364
+ deleteAuthoritativeSnapshot(normalizeMapKey(cwd));
365
+ logLatency({
366
+ type: "phase",
367
+ phase: "project_snapshot_persist_failed",
368
+ filePath: getProjectSnapshotPath(cwd),
369
+ durationMs: 0,
370
+ metadata: { error },
371
+ });
372
+ // #1333: the logLatency call above already carries this failure to
373
+ // latency.log — the console.error was a duplicate RAW write into pi's frame.
374
+ }
375
+ function logSnapshotPersistSuccess(pending, stats) {
376
+ logLatency({
377
+ type: "phase",
378
+ phase: "project_snapshot_persist",
379
+ filePath: pending.gzPath,
380
+ durationMs: stats.serializeMs + stats.writeMs,
381
+ metadata: { seq: pending.snapshot.seq, ...stats },
382
+ });
383
+ }
384
+ /**
385
+ * Reconcile the authoritative in-process entry with a body that just landed on
386
+ * disk: update its `knownMtime` so a subsequent load keeps serving our own
387
+ * object without re-parsing, and DROP oversized bodies (their post-promotion
388
+ * disk read is the pre-#947 behavior — we won't pin hundreds of MB of heap).
389
+ */
390
+ function reconcileAuthoritativeAfterWrite(pending, rawBytes) {
391
+ const entry = authoritativeSnapshots.get(pending.key);
392
+ // Only reconcile the entry that still belongs to THIS (latest) generation —
393
+ // a superseding save already replaced it with a newer object.
394
+ if (!entry || entry.snapshot !== pending.snapshot)
395
+ return;
396
+ // The worker needs the serialized snapshot until promotion completes, but
397
+ // retaining it afterward duplicates the mutable warm index's postings. A
398
+ // shared reference is unsafe: ProjectSnapshot stores serialized arrays while
399
+ // WordIndex owns mutable Map/PathKeyedMap state. Drop the authoritative copy
400
+ // after publication; later merge-writers rehydrate the canonical disk body.
401
+ if (pending.snapshot.wordIndex) {
402
+ try {
403
+ const stat = fs.statSync(pending.gzPath);
404
+ cacheParsedSnapshot(pending.gzPath, {
405
+ mtimeMs: stat.mtimeMs,
406
+ size: stat.size,
407
+ snapshot: withoutWordIndex(pending.snapshot),
408
+ });
409
+ }
410
+ catch {
411
+ // A cache miss is safe: the first metadata consumer reconstructs it.
412
+ }
413
+ deleteAuthoritativeSnapshot(pending.key);
414
+ logLatency({
415
+ type: "phase",
416
+ phase: "project_snapshot_word_index_released",
417
+ filePath: pending.gzPath,
418
+ durationMs: 0,
419
+ metadata: { rawBytes },
420
+ });
421
+ return;
422
+ }
423
+ if (rawBytes > SNAPSHOT_PARSE_CACHE_MAX_BYTES) {
424
+ // Benign but invisible otherwise: the next load will re-parse this body
425
+ // from disk instead of serving the in-process object.
426
+ logLatency({
427
+ type: "phase",
428
+ phase: "project_snapshot_authoritative_dropped_oversized",
429
+ filePath: pending.gzPath,
430
+ durationMs: 0,
431
+ metadata: { rawBytes, maxBytes: SNAPSHOT_PARSE_CACHE_MAX_BYTES },
432
+ });
433
+ deleteAuthoritativeSnapshot(pending.key);
434
+ return;
435
+ }
436
+ try {
437
+ entry.knownMtime = fs.statSync(pending.gzPath).mtimeMs;
438
+ }
439
+ catch {
440
+ // If we can't stat our own write, leave knownMtime as-is; the worst case
441
+ // is one extra disk re-parse on the next load.
442
+ }
443
+ }
444
+ function writeSnapshotBodyOnMainThread(pending, reason) {
445
+ if (reason) {
446
+ // We took the synchronous main-thread gzip path (the +656MB-risk path,
447
+ // #950) instead of the worker. Surface it rather than burying it in an
448
+ // `offloaded:false` success line below. `degraded` distinguishes a REAL
449
+ // degradation (worker died/unavailable/promote-failed) from the benign
450
+ // `exit_hook` teardown flush, so an operator triaging worker health isn't
451
+ // misled by normal process-exit flushes.
452
+ logLatency({
453
+ type: "phase",
454
+ phase: "project_snapshot_worker_fallback",
455
+ filePath: pending.gzPath,
456
+ durationMs: 0,
457
+ metadata: {
458
+ reason,
459
+ seq: pending.snapshot.seq,
460
+ degraded: reason !== "exit_hook",
461
+ },
462
+ });
463
+ }
464
+ try {
465
+ const serializeStarted = performance.now();
466
+ const json = JSON.stringify(pending.snapshot);
467
+ const serializeMs = performance.now() - serializeStarted;
468
+ const rawBytes = Buffer.byteLength(json);
469
+ const writeStarted = performance.now();
470
+ const gzip = gzipSync(json);
471
+ fs.mkdirSync(path.dirname(pending.gzPath), { recursive: true });
472
+ writeFileAtomic(pending.gzPath, gzip, { bestEffort: false });
473
+ fs.rmSync(pending.legacyPath, { force: true });
474
+ reconcileAuthoritativeAfterWrite(pending, rawBytes);
475
+ logSnapshotPersistSuccess(pending, {
476
+ rawBytes,
477
+ gzBytes: gzip.byteLength,
478
+ serializeMs,
479
+ writeMs: performance.now() - writeStarted,
480
+ offloaded: false,
481
+ });
482
+ if (reason)
483
+ _lastSnapshotPersistErrorForTests = reason;
484
+ }
485
+ catch (err) {
486
+ recordSnapshotPersistFailure(pending.cwd, err instanceof Error ? err.message : String(err));
487
+ }
488
+ }
489
+ /**
490
+ * The ForTests promotion seam covers worker-message and every main-thread
491
+ * fallback promotion. It stays sync and seam-free in production, identical to
492
+ * calling the writer directly. The process-exit hook is deliberately the sole
493
+ * direct write because an exit handler cannot await this asynchronous seam.
494
+ */
495
+ function dispatchMainThreadWriteThroughSeam(pending, reason) {
496
+ if (_snapshotPromotionSeamForTests) {
497
+ void _snapshotPromotionSeamForTests().then(() => writeSnapshotBodyOnMainThread(pending, reason));
498
+ return;
499
+ }
500
+ writeSnapshotBodyOnMainThread(pending, reason);
501
+ }
502
+ function handleSnapshotWorkerResult(result) {
503
+ const pending = _snapshotWorkerRequests.get(result.id);
504
+ if (!pending) {
505
+ fs.rm(result.stagePath, { force: true }, () => { });
506
+ return;
507
+ }
508
+ _snapshotWorkerRequests.delete(result.id);
509
+ if (result.error ||
510
+ result.rawBytes === undefined ||
511
+ result.gzBytes === undefined ||
512
+ result.serializeMs === undefined ||
513
+ result.writeMs === undefined) {
514
+ fs.rm(result.stagePath, { force: true }, () => { });
515
+ dispatchMainThreadWriteThroughSeam(pending, result.error ?? "invalid worker result");
516
+ return;
517
+ }
518
+ // Generation gate: a newer save already superseded this one — discard the
519
+ // stale stage file rather than promote it over the fresher body.
520
+ if (_snapshotGenerationGateEnabledForTests &&
521
+ _snapshotGenerations.get(pending.key) !== result.generation) {
522
+ // The stale stage is part of the promotion transaction: remove it before
523
+ // returning so a superseded save cannot leave an orphan behind.
524
+ fs.rm(result.stagePath, { force: true }, () => { });
525
+ return;
526
+ }
527
+ try {
528
+ fs.renameSync(result.stagePath, pending.gzPath);
529
+ fs.rmSync(pending.legacyPath, { force: true });
530
+ reconcileAuthoritativeAfterWrite(pending, result.rawBytes);
531
+ logSnapshotPersistSuccess(pending, {
532
+ rawBytes: result.rawBytes,
533
+ gzBytes: result.gzBytes,
534
+ serializeMs: result.serializeMs,
535
+ writeMs: result.writeMs,
536
+ offloaded: true,
537
+ });
538
+ }
539
+ catch (err) {
540
+ fs.rm(result.stagePath, { force: true }, () => { });
541
+ dispatchMainThreadWriteThroughSeam(pending, err instanceof Error ? err.message : String(err));
542
+ }
543
+ }
544
+ function handleSnapshotWorkerDeath(reason) {
545
+ _snapshotPersistWorker = undefined;
546
+ _snapshotWorkerDisabled = true;
547
+ const requests = [..._snapshotWorkerRequests.values()];
548
+ _snapshotWorkerRequests.clear();
549
+ for (const pending of requests)
550
+ dispatchMainThreadWriteThroughSeam(pending, reason);
551
+ }
552
+ function resolveSnapshotPersistWorkerPath() {
553
+ // esbuild does NOT rewrite new URL(...) asset refs, so from the bundled
554
+ // dist/index.js a sibling ./project-snapshot-persist-worker.js resolves
555
+ // beside the BUNDLE where nothing exists. Try the compiled-sibling layout
556
+ // first (source checkout / unbundled dist/clients tree), then the dist-tree
557
+ // path relative to the bundle entry — same shape as the review graph's
558
+ // resolvePersistWorkerPath (#950 review F1).
559
+ const candidates = [
560
+ new URL("./project-snapshot-persist-worker.js", import.meta.url),
561
+ new URL("./clients/project-snapshot-persist-worker.js", import.meta.url),
562
+ ];
563
+ for (const url of candidates) {
564
+ try {
565
+ const resolved = fileURLToPath(url);
566
+ if (fs.existsSync(resolved))
567
+ return resolved;
568
+ }
569
+ catch {
570
+ /* try next layout */
571
+ }
572
+ }
573
+ return undefined;
574
+ }
575
+ function getSnapshotPersistWorker() {
576
+ if (_snapshotWorkerDisabled)
577
+ return undefined;
578
+ if (_snapshotPersistWorker)
579
+ return _snapshotPersistWorker;
580
+ try {
581
+ const workerPath = resolveSnapshotPersistWorkerPath();
582
+ if (workerPath === undefined) {
583
+ handleSnapshotWorkerDeath("persist worker script not found in any layout");
584
+ return undefined;
585
+ }
586
+ const worker = new Worker(workerPath);
587
+ // The ForTests promotion seam wraps ONLY when set — the production path
588
+ // binds the sync handler directly, so scheduling is byte-identical when
589
+ // no test seam is installed (the async-handler variant of this shifted
590
+ // promotion timing under full-suite load and flaked the round-trip test).
591
+ worker.on("message", (result) => {
592
+ if (_snapshotPromotionSeamForTests) {
593
+ void _snapshotPromotionSeamForTests().then(() => handleSnapshotWorkerResult(result));
594
+ return;
595
+ }
596
+ handleSnapshotWorkerResult(result);
597
+ });
598
+ worker.on("error", (err) => handleSnapshotWorkerDeath(err.message));
599
+ worker.on("exit", (code) => {
600
+ if (_snapshotPersistWorker === worker)
601
+ _snapshotPersistWorker = undefined;
602
+ // Any body still queued when the worker exits was abandoned mid-flight
603
+ // (a crash, a `terminate()`, or host recycling) — it will never be
604
+ // promoted by this worker, so fall it back to the sync writer rather
605
+ // than let it vanish (honesty, #533). `terminate()` can report a 0
606
+ // exit code on some platforms, so this cannot key off `code !== 0`.
607
+ const stranded = [..._snapshotWorkerRequests.values()];
608
+ if (stranded.length > 0) {
609
+ _snapshotWorkerRequests.clear();
610
+ for (const pending of stranded) {
611
+ dispatchMainThreadWriteThroughSeam(pending, `persist worker exited with code ${code}`);
612
+ }
613
+ }
614
+ // Only an ABNORMAL exit disables respawning; a clean idle exit (nothing
615
+ // stranded) just drops the reference so the next persist respawns.
616
+ if (code !== 0)
617
+ _snapshotWorkerDisabled = true;
618
+ });
619
+ // #1148: adding a message listener refs the Worker's public MessagePort.
620
+ // Unref only after every listener is installed so it stays background-only.
621
+ worker.unref();
622
+ _snapshotPersistWorker = worker;
623
+ return worker;
624
+ }
625
+ catch (err) {
626
+ handleSnapshotWorkerDeath(err instanceof Error ? err.message : String(err));
627
+ return undefined;
628
+ }
629
+ }
630
+ // #950 review F3: a process that dies between a worker's staged write and its
631
+ // promotion leaves project-snapshot.json.gz.stage-<pid>-<gen> (and the worker's
632
+ // .tmp-<pid>) behind forever. Sweep leftovers from PRIOR processes once per
633
+ // cache dir; our own live stage files carry this pid and are skipped.
634
+ const _sweptSnapshotStageDirs = new Set();
635
+ function sweepStaleSnapshotStageFiles(cacheDir) {
636
+ if (_sweptSnapshotStageDirs.has(cacheDir))
637
+ return;
638
+ _sweptSnapshotStageDirs.add(cacheDir);
639
+ fs.readdir(cacheDir, (err, entries) => {
640
+ if (err)
641
+ return;
642
+ const ownMarker = `.stage-${process.pid}-`;
643
+ for (const entry of entries) {
644
+ if (!entry.startsWith("project-snapshot.json.gz.stage-"))
645
+ continue;
646
+ if (entry.includes(ownMarker))
647
+ continue;
648
+ fs.rm(path.join(cacheDir, entry), { force: true }, () => { });
649
+ }
650
+ });
651
+ }
652
+ // Flush any in-flight worker writes synchronously at process teardown so a body
653
+ // whose worker hasn't promoted yet isn't lost. Sync writes only (no child
654
+ // spawn — the teardown libuv hazard); best-effort.
655
+ let _snapshotExitHookInstalled = false;
656
+ function ensureSnapshotPersistExitHook() {
657
+ if (_snapshotExitHookInstalled)
658
+ return;
659
+ _snapshotExitHookInstalled = true;
660
+ process.once("exit", () => {
661
+ const requests = [..._snapshotWorkerRequests.values()];
662
+ _snapshotWorkerRequests.clear();
663
+ for (const pending of requests) {
664
+ // Only the newest generation per key still matters; older ones are
665
+ // superseded and their stage files are swept on next launch.
666
+ if (_snapshotGenerations.get(pending.key) !== pending.generation)
667
+ continue;
668
+ writeSnapshotBodyOnMainThread(pending, "exit_hook");
669
+ }
670
+ void _snapshotPersistWorker?.terminate();
671
+ });
57
672
  }
58
673
  export function saveProjectSnapshot(cwd, snapshot) {
59
- const snapshotPath = getProjectSnapshotPath(cwd);
60
- fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
61
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
62
- fs.writeFileSync(getProjectSnapshotMetaPath(cwd), JSON.stringify({
674
+ const gzPath = getProjectSnapshotPath(cwd);
675
+ const legacyPath = getProjectSnapshotLegacyPath(cwd);
676
+ const metaPath = getProjectSnapshotMetaPath(cwd);
677
+ const cacheDir = path.dirname(gzPath);
678
+ const key = normalizeMapKey(cwd);
679
+ fs.mkdirSync(cacheDir, { recursive: true });
680
+ // #958: meta is written FIRST, body SECOND — the reverse of the original
681
+ // order. A crash/failure between the two writes can now only produce
682
+ // "meta already claims the new seq, body hasn't caught up yet" (the meta
683
+ // races ahead). The meta-first gate (isProjectSnapshotMetaStale) reads
684
+ // that as *fresh* and falls through to parsing the body, whose own
685
+ // embedded `seq` is still the old one, so `isProjectSnapshotFresh`
686
+ // correctly rejects it as stale on the body's own merits — one wasted
687
+ // parse, self-healing, no data lost. The OLD body-then-meta order could
688
+ // instead leave an old-seq meta sitting over a freshly written body,
689
+ // which the meta-first gate discards WITHOUT ever reading it — throwing
690
+ // away a genuinely fresh snapshot. That direction is not recoverable
691
+ // until the next save, so it's the one this reorder eliminates.
692
+ //
693
+ // The meta write is still SYNCHRONOUS (it is tiny) and uses
694
+ // `bestEffort: false`: if it fails, the body persist below is skipped
695
+ // entirely — the save is simply lost this round (fail-open, caught by
696
+ // `saveRuntimeProjectSnapshot`'s own try/catch) rather than leaving a
697
+ // stale meta in place while the body writer stampedes ahead.
698
+ writeFileAtomic(metaPath, JSON.stringify({
63
699
  timestamp: snapshot.generatedAt,
64
700
  version: snapshot.version,
65
701
  seq: snapshot.seq,
66
- }, null, 2));
702
+ // #1019: mirror the derived sequence index (kept consistent with `seq`
703
+ // because both are stamped from the same runtime moment) so the
704
+ // interactive path can hydrate it from the tiny sidecar. Omitted when
705
+ // absent so a non-runtime side-write (word-index/reverse-deps) that
706
+ // carried no index doesn't stamp an empty one.
707
+ ...(snapshot.sequenceIndex
708
+ ? { sequenceIndex: snapshot.sequenceIndex }
709
+ : {}),
710
+ }), { bestEffort: false });
711
+ // Record the authoritative in-process write BEFORE handing the body off, so
712
+ // a merge-read between now and the worker's promotion sees our own object
713
+ // (the on-disk body still holds the previous generation until promotion). The
714
+ // pre-write mtime is our baseline: while disk stays at it (or below), our
715
+ // object wins; a promotion or an external write moves past it. Baseline off
716
+ // the CURRENTLY-RESOLVED body — which is the legacy uncompressed
717
+ // `project-snapshot.json` in the one-release upgrade window before the first
718
+ // gz promotion — not gz-only: statting only gzPath there would leave the
719
+ // baseline at -Infinity, so the load gate (which resolves the legacy body's
720
+ // real, positive mtime) would reject our own fresh write and serve the stale
721
+ // legacy body to a merge-consumer, silently dropping this snapshot's fields.
722
+ const priorBody = resolveSnapshotBodyPath(cwd);
723
+ const knownMtime = priorBody ? priorBody.mtimeMs : Number.NEGATIVE_INFINITY;
724
+ const authoritativeEntry = {
725
+ snapshot,
726
+ knownMtime,
727
+ lastUsedAt: Date.now(),
728
+ };
729
+ authoritativeSnapshots.set(key, authoritativeEntry);
730
+ scheduleAuthoritativeSnapshotEviction(key, authoritativeEntry);
731
+ enforceAuthoritativeSnapshotCap();
732
+ // A stale disk-parse-cache entry for this path must not out-vote the fresh
733
+ // authoritative write once the latter is dropped (oversized bodies).
734
+ snapshotParseCache.delete(gzPath);
735
+ const generation = (_snapshotGenerations.get(key) ?? 0) + 1;
736
+ _snapshotGenerations.set(key, generation);
737
+ const stagePath = `${gzPath}.stage-${process.pid}-${generation}`;
738
+ const pending = {
739
+ key,
740
+ cwd,
741
+ gzPath,
742
+ legacyPath,
743
+ stagePath,
744
+ snapshot,
745
+ generation,
746
+ };
747
+ sweepStaleSnapshotStageFiles(cacheDir);
748
+ ensureSnapshotPersistExitHook();
749
+ if (!snapshotWorkerEnabled()) {
750
+ dispatchMainThreadWriteThroughSeam(pending, undefined);
751
+ return;
752
+ }
753
+ const worker = getSnapshotPersistWorker();
754
+ if (!worker) {
755
+ dispatchMainThreadWriteThroughSeam(pending, "persist worker unavailable");
756
+ return;
757
+ }
758
+ const id = ++_snapshotWorkerRequestId;
759
+ _snapshotWorkerRequests.set(id, pending);
760
+ const request = {
761
+ id,
762
+ generation,
763
+ stagePath,
764
+ data: snapshot,
765
+ testDelayMs: process.env.NODE_ENV === "test"
766
+ ? Number(process.env.PI_LENS_TEST_SNAPSHOT_PERSIST_WORKER_DELAY_MS) ||
767
+ undefined
768
+ : undefined,
769
+ };
770
+ worker.postMessage(request);
771
+ }
772
+ // --- Test hooks for the worker persist path ---------------------------------
773
+ /** Test-only: wait until worker requests have either landed or degraded. */
774
+ export async function waitForProjectSnapshotPersistsForTests() {
775
+ for (let attempts = 0; attempts < 200 && _snapshotWorkerRequests.size > 0; attempts++) {
776
+ await new Promise((resolve) => setTimeout(resolve, 10));
777
+ }
778
+ }
779
+ /** Test-only: force any in-flight worker body write to the sync main-thread path. */
780
+ export function flushProjectSnapshotPersistsForTests() {
781
+ const requests = [..._snapshotWorkerRequests.values()];
782
+ _snapshotWorkerRequests.clear();
783
+ for (const pending of requests) {
784
+ if (_snapshotGenerations.get(pending.key) !== pending.generation)
785
+ continue;
786
+ dispatchMainThreadWriteThroughSeam(pending, undefined);
787
+ }
788
+ }
789
+ /** Test-only: exercise the degraded worker-death path. */
790
+ export async function terminateProjectSnapshotPersistWorkerForTests() {
791
+ const worker = _snapshotPersistWorker;
792
+ if (worker)
793
+ await worker.terminate();
794
+ }
795
+ /** Test-only: restore worker creation + clear generation state after a deliberate death. */
796
+ export function resetProjectSnapshotPersistWorkerForTests() {
797
+ _snapshotWorkerDisabled = false;
798
+ _snapshotGenerationGateEnabledForTests = true;
799
+ _snapshotPromotionSeamForTests = undefined;
800
+ _snapshotPersistWorker = undefined;
801
+ _snapshotWorkerRequests.clear();
802
+ _snapshotGenerations.clear();
803
+ _lastSnapshotPersistErrorForTests = undefined;
804
+ }
805
+ /** Test-only mutation switch for proving the supersession invariant. */
806
+ export function setProjectSnapshotGenerationGateForTests(enabled) {
807
+ _snapshotGenerationGateEnabledForTests = enabled;
808
+ }
809
+ /** Test-only seam immediately before generation-gated promotion. */
810
+ export function setProjectSnapshotPromotionSeamForTests(seam) {
811
+ _snapshotPromotionSeamForTests = seam;
812
+ }
813
+ export function getProjectSnapshotPersistErrorForTests() {
814
+ return _lastSnapshotPersistErrorForTests;
67
815
  }
68
816
  export function buildProjectSnapshotFromRuntime(args) {
69
817
  return {
@@ -75,6 +823,15 @@ export function buildProjectSnapshotFromRuntime(args) {
75
823
  symbols: {},
76
824
  reverseDeps: {},
77
825
  cachedExports: [...args.runtime.cachedExports.entries()].sort((a, b) => a[0].localeCompare(b[0])),
826
+ // #1019: capture the runtime's live sequence index AT this seq. The runtime
827
+ // is seeded from the change log at session start and bumped in lockstep with
828
+ // every append, so `getFileSeqEntries()` IS the fold of the log up to
829
+ // `projectSeq` — and its keys are already `normalizeMapKey(path.resolve())`,
830
+ // the exact form the change-log replay produces.
831
+ sequenceIndex: {
832
+ projectSeq: args.runtime.projectSeq,
833
+ fileSeqByPath: args.runtime.getFileSeqEntries(),
834
+ },
78
835
  wordIndex: args.runtime.wordIndex
79
836
  ? serializeWordIndex(args.runtime.wordIndex)
80
837
  : undefined,
@@ -126,7 +883,9 @@ export function saveRuntimeProjectSnapshot(args) {
126
883
  // isProjectSnapshotFresh on load, seq mismatch) would get silently
127
884
  // re-stamped with the CURRENT seq by this save, "laundering" a stale
128
885
  // index into looking fresh before the word-index task even runs.
129
- if (!snapshot.wordIndex && existing.wordIndex && existing.seq === snapshot.seq) {
886
+ if (!snapshot.wordIndex &&
887
+ existing.wordIndex &&
888
+ existing.seq === snapshot.seq) {
130
889
  snapshot.wordIndex = existing.wordIndex;
131
890
  }
132
891
  }