pi-lens 4.1.2 → 4.1.4

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 (359) hide show
  1. package/CHANGELOG.md +1308 -0
  2. package/README.md +3 -0
  3. package/config/biome/core.jsonc +11 -2
  4. package/config/dependency-cruiser-eager-allowlist.json +91 -0
  5. package/dist/clients/actionable-warnings-logger.js +18 -3
  6. package/dist/clients/actionable-warnings.js +927 -80
  7. package/dist/clients/agent-behavior-client.js +13 -4
  8. package/dist/clients/ast-grep-client.js +136 -9
  9. package/dist/clients/ast-grep-rule-manager.js +60 -5
  10. package/dist/clients/ast-grep-tool-logger.js +2 -2
  11. package/dist/clients/availability-probe-flight.js +12 -0
  12. package/dist/clients/bash-file-access.js +1 -3
  13. package/dist/clients/biome-client.js +16 -2
  14. package/dist/clients/blocker-freshness.js +34 -0
  15. package/dist/clients/bootstrap.js +509 -73
  16. package/dist/clients/bounded-cache.js +152 -12
  17. package/dist/clients/bounded-telemetry.js +115 -23
  18. package/dist/clients/build-identity.js +95 -0
  19. package/dist/clients/bundled-resource-health.js +113 -0
  20. package/dist/clients/bus-events-logger.js +2 -2
  21. package/dist/clients/bus-publish.js +3 -0
  22. package/dist/clients/cache/rule-cache.js +33 -5
  23. package/dist/clients/cache-manager.js +105 -6
  24. package/dist/clients/cache-observability.js +46 -19
  25. package/dist/clients/cargo-manifest.js +422 -0
  26. package/dist/clients/cascade-logger.js +17 -3
  27. package/dist/clients/code-quality-warnings.js +13 -3
  28. package/dist/clients/complexity-client.js +16 -5
  29. package/dist/clients/config-core/deny.js +221 -0
  30. package/dist/clients/config-core/index.js +47 -0
  31. package/dist/clients/config-core/merge.js +357 -0
  32. package/dist/clients/config-core/normalize.js +340 -0
  33. package/dist/clients/config-core/process-spec.js +248 -0
  34. package/dist/clients/config-core/provenance.js +171 -0
  35. package/dist/clients/config-core/records.js +218 -0
  36. package/dist/clients/config-core/resolve.js +125 -0
  37. package/dist/clients/config-core/safe-object.js +78 -0
  38. package/dist/clients/config-core/schema.js +167 -0
  39. package/dist/clients/config-diagnostic-codes.js +281 -0
  40. package/dist/clients/config-locations.js +160 -0
  41. package/dist/clients/config-resolve.js +789 -0
  42. package/dist/clients/config-schema.js +197 -0
  43. package/dist/clients/config-warn.js +407 -0
  44. package/dist/clients/dead-code-client.js +33 -25
  45. package/dist/clients/dead-code-logger.js +2 -2
  46. package/dist/clients/deadline-utils.js +178 -0
  47. package/dist/clients/debug-handles.js +2 -2
  48. package/dist/clients/debug-heap.js +3 -3
  49. package/dist/clients/deferred-lsp-work.js +106 -0
  50. package/dist/clients/degradation-ledger.js +166 -8
  51. package/dist/clients/demoted-finding-render.js +14 -0
  52. package/dist/clients/dependency-checker.js +28 -22
  53. package/dist/clients/deps/minimatch.js +1 -1
  54. package/dist/clients/diagnostic-line-freshness.js +12 -9
  55. package/dist/clients/diagnostic-logger.js +14 -4
  56. package/dist/clients/dispatch/collect-later-tier.js +39 -0
  57. package/dist/clients/dispatch/dispatcher.js +304 -19
  58. package/dist/clients/dispatch/fact-store.js +328 -9
  59. package/dist/clients/dispatch/integration.js +214 -107
  60. package/dist/clients/dispatch/pending-runner-findings.js +87 -0
  61. package/dist/clients/dispatch/plan.js +21 -5
  62. package/dist/clients/dispatch/runners/actionlint.js +0 -1
  63. package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
  64. package/dist/clients/dispatch/runners/biome-check.js +0 -1
  65. package/dist/clients/dispatch/runners/cpp-check.js +0 -1
  66. package/dist/clients/dispatch/runners/credo.js +0 -1
  67. package/dist/clients/dispatch/runners/cue-vet.js +0 -1
  68. package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  69. package/dist/clients/dispatch/runners/detekt.js +0 -1
  70. package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  71. package/dist/clients/dispatch/runners/elixir-check.js +0 -1
  72. package/dist/clients/dispatch/runners/eslint.js +5 -2
  73. package/dist/clients/dispatch/runners/fact-rules.js +0 -1
  74. package/dist/clients/dispatch/runners/fish-indent.js +0 -1
  75. package/dist/clients/dispatch/runners/gleam-check.js +0 -1
  76. package/dist/clients/dispatch/runners/go-vet.js +1 -3
  77. package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  78. package/dist/clients/dispatch/runners/hadolint.js +0 -1
  79. package/dist/clients/dispatch/runners/helm-lint.js +16 -4
  80. package/dist/clients/dispatch/runners/helm-render.js +54 -10
  81. package/dist/clients/dispatch/runners/htmlhint.js +0 -1
  82. package/dist/clients/dispatch/runners/index.js +1 -3
  83. package/dist/clients/dispatch/runners/javac.js +0 -1
  84. package/dist/clients/dispatch/runners/ktlint.js +0 -1
  85. package/dist/clients/dispatch/runners/lsp.js +37 -2
  86. package/dist/clients/dispatch/runners/markdownlint.js +4 -2
  87. package/dist/clients/dispatch/runners/mypy.js +0 -1
  88. package/dist/clients/dispatch/runners/oxlint.js +24 -22
  89. package/dist/clients/dispatch/runners/php-lint.js +0 -1
  90. package/dist/clients/dispatch/runners/phpstan.js +0 -1
  91. package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  92. package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
  93. package/dist/clients/dispatch/runners/pyright.js +7 -4
  94. package/dist/clients/dispatch/runners/rubocop.js +0 -1
  95. package/dist/clients/dispatch/runners/ruff.js +0 -1
  96. package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
  97. package/dist/clients/dispatch/runners/shellcheck.js +0 -1
  98. package/dist/clients/dispatch/runners/shfmt.js +0 -1
  99. package/dist/clients/dispatch/runners/spellcheck.js +0 -1
  100. package/dist/clients/dispatch/runners/spotbugs.js +0 -1
  101. package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  102. package/dist/clients/dispatch/runners/stylelint.js +0 -1
  103. package/dist/clients/dispatch/runners/swiftlint.js +0 -1
  104. package/dist/clients/dispatch/runners/taplo.js +0 -1
  105. package/dist/clients/dispatch/runners/terragrunt.js +0 -1
  106. package/dist/clients/dispatch/runners/tflint.js +0 -1
  107. package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  108. package/dist/clients/dispatch/runners/trivy-config.js +0 -1
  109. package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  110. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  111. package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
  112. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  113. package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  114. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
  115. package/dist/clients/dispatch/runners/vale.js +0 -1
  116. package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  117. package/dist/clients/dispatch/runners/yamllint.js +0 -1
  118. package/dist/clients/dispatch/runners/zig-check.js +0 -1
  119. package/dist/clients/disposition-logger.js +3 -3
  120. package/dist/clients/effective-config.js +403 -0
  121. package/dist/clients/error-class.js +23 -0
  122. package/dist/clients/event-loop-hold.js +274 -0
  123. package/dist/clients/event-loop-monitor.js +66 -1
  124. package/dist/clients/extension-log.js +2 -2
  125. package/dist/clients/feature-hints.js +2 -1
  126. package/dist/clients/file-time.js +6 -1
  127. package/dist/clients/file-utils.js +266 -13
  128. package/dist/clients/finding-delivery-gate.js +55 -13
  129. package/dist/clients/format-service.js +6 -1
  130. package/dist/clients/formatters.js +303 -104
  131. package/dist/clients/freshness-cadence.js +17 -0
  132. package/dist/clients/generated-artifacts.js +114 -19
  133. package/dist/clients/generation-guard.js +4 -9
  134. package/dist/clients/git-guard.js +198 -48
  135. package/dist/clients/git-tracked-ignore.js +32 -1
  136. package/dist/clients/go-client.js +39 -0
  137. package/dist/clients/govulncheck-client.js +5 -1
  138. package/dist/clients/gradle-ktfmt-style.js +252 -0
  139. package/dist/clients/gzip-stage-write.js +7 -0
  140. package/dist/clients/hashline-anchor.js +424 -0
  141. package/dist/clients/hook-budgets.js +76 -0
  142. package/dist/clients/host-edit-normalize.js +5 -2
  143. package/dist/clients/host-ports.js +1 -1
  144. package/dist/clients/installer/index.js +200 -84
  145. package/dist/clients/installer/managed-tool-refresh.js +3 -1
  146. package/dist/clients/instance-reaper.js +108 -243
  147. package/dist/clients/instance-registry-lock.js +145 -0
  148. package/dist/clients/instance-registry.js +435 -122
  149. package/dist/clients/jscpd-client.js +6 -1
  150. package/dist/clients/language-profile.js +4 -2
  151. package/dist/clients/language-registry.js +595 -0
  152. package/dist/clients/latency-logger.js +179 -3
  153. package/dist/clients/ledger-bounds.js +13 -0
  154. package/dist/clients/lens-config.js +141 -30
  155. package/dist/clients/lens-engine.js +30 -37
  156. package/dist/clients/lens-flag-registry.js +67 -6
  157. package/dist/clients/lens-map.js +3 -1
  158. package/dist/clients/log-cleanup.js +2 -2
  159. package/dist/clients/lsp/aggregation.js +3 -1
  160. package/dist/clients/lsp/cascade-tier.js +4 -0
  161. package/dist/clients/lsp/client.js +672 -49
  162. package/dist/clients/lsp/config.js +504 -156
  163. package/dist/clients/lsp/diagnostic-binding.js +10 -5
  164. package/dist/clients/lsp/edits.js +15 -2
  165. package/dist/clients/lsp/index.js +1179 -247
  166. package/dist/clients/lsp/jvm-runtime.js +5 -0
  167. package/dist/clients/lsp/language.js +18 -169
  168. package/dist/clients/lsp/launch.js +38 -9
  169. package/dist/clients/lsp/pending-aux-coverage.js +113 -12
  170. package/dist/clients/lsp/server.js +426 -160
  171. package/dist/clients/lsp/session-roots.js +192 -0
  172. package/dist/clients/lsp/spawn-history.js +2 -7
  173. package/dist/clients/lsp/tsserver-sync.js +9 -2
  174. package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
  175. package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  176. package/dist/clients/lsp-mutation.js +179 -23
  177. package/dist/clients/map-with-concurrency.js +36 -0
  178. package/dist/clients/mcp/analyze.js +45 -10
  179. package/dist/clients/mcp/session.js +19 -17
  180. package/dist/clients/memory-sampler.js +28 -1
  181. package/dist/clients/message-end-attribution.js +30 -0
  182. package/dist/clients/metrics-history.js +21 -4
  183. package/dist/clients/middle-man-analysis.js +2 -4
  184. package/dist/clients/module-report.js +20 -45
  185. package/dist/clients/mutating-tool.js +651 -0
  186. package/dist/clients/mutation-attribution.js +368 -0
  187. package/dist/clients/mutation-bridge.js +240 -0
  188. package/dist/clients/ndjson-logger.js +247 -20
  189. package/dist/clients/observed-mutation-sources.js +101 -0
  190. package/dist/clients/observed-mutation.js +1215 -0
  191. package/dist/clients/opaque-mutation-scan.js +300 -40
  192. package/dist/clients/opengrep-config.js +4 -0
  193. package/dist/clients/package-manager.js +201 -44
  194. package/dist/clients/partial-edit-apply.js +359 -80
  195. package/dist/clients/path-attribution-telemetry.js +23 -7
  196. package/dist/clients/path-keyed-map.js +35 -3
  197. package/dist/clients/path-utils.js +460 -13
  198. package/dist/clients/persist-debounce.js +8 -1
  199. package/dist/clients/php-cs-fixer-config.js +114 -0
  200. package/dist/clients/pipeline.js +77 -24
  201. package/dist/clients/probe-home-state.js +228 -0
  202. package/dist/clients/process-bridge.js +66 -0
  203. package/dist/clients/process-singletons.js +210 -0
  204. package/dist/clients/process-snapshot.js +68 -0
  205. package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  206. package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  207. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  208. package/dist/clients/project-diagnostics/scanner.js +61 -24
  209. package/dist/clients/project-lens-config.js +393 -122
  210. package/dist/clients/project-snapshot.js +85 -35
  211. package/dist/clients/python-environment.js +217 -0
  212. package/dist/clients/python-provenance.js +639 -0
  213. package/dist/clients/quiet-window.js +6 -1
  214. package/dist/clients/read-bridge.js +13 -21
  215. package/dist/clients/read-expansion.js +9 -46
  216. package/dist/clients/read-guard-logger.js +39 -6
  217. package/dist/clients/read-guard-tool-lines.js +364 -212
  218. package/dist/clients/read-guard.js +95 -7
  219. package/dist/clients/recent-touches.js +17 -5
  220. package/dist/clients/resource-sampler.js +195 -96
  221. package/dist/clients/review-graph/builder.js +752 -132
  222. package/dist/clients/review-graph/service.js +55 -3
  223. package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  224. package/dist/clients/review-graph/tsconfig-paths.js +5 -1
  225. package/dist/clients/review-graph/workspace-modules.js +10 -74
  226. package/dist/clients/review-graph-logger.js +30 -4
  227. package/dist/clients/runtime-agent-end.js +18 -2
  228. package/dist/clients/runtime-context.js +49 -16
  229. package/dist/clients/runtime-coordinator.js +93 -6
  230. package/dist/clients/runtime-session.js +169 -34
  231. package/dist/clients/runtime-tool-call.js +183 -35
  232. package/dist/clients/runtime-tool-result.js +894 -241
  233. package/dist/clients/runtime-turn.js +1264 -74
  234. package/dist/clients/rust-client.js +31 -0
  235. package/dist/clients/safe-spawn.js +191 -28
  236. package/dist/clients/sanitize.js +7 -1
  237. package/dist/clients/scratch-tree-policy.js +1 -3
  238. package/dist/clients/security-scan-client.js +51 -17
  239. package/dist/clients/session-lifecycle.js +238 -42
  240. package/dist/clients/session-start-observability.js +79 -0
  241. package/dist/clients/sessionstart-logger.js +12 -3
  242. package/dist/clients/sg-runner.js +27 -15
  243. package/dist/clients/sgconfig.js +6 -1
  244. package/dist/clients/shared-checkout-guard.js +338 -0
  245. package/dist/clients/skills-resolver.js +105 -0
  246. package/dist/clients/smells-rollup.js +2 -2
  247. package/dist/clients/spawn-output-cap.js +37 -0
  248. package/dist/clients/spawn-timeout-cooldown.js +15 -1
  249. package/dist/clients/startup-scan.js +6 -7
  250. package/dist/clients/startup-timing.js +69 -4
  251. package/dist/clients/string-utils.js +33 -0
  252. package/dist/clients/subagent-mode.js +17 -4
  253. package/dist/clients/test-runner-client.js +586 -118
  254. package/dist/clients/test-runner-delivery.js +239 -0
  255. package/dist/clients/tool-definition.js +41 -1
  256. package/dist/clients/tool-policy.js +92 -22
  257. package/dist/clients/tree-sitter-cache.js +40 -40
  258. package/dist/clients/tree-sitter-client.js +97 -53
  259. package/dist/clients/tree-sitter-logger.js +19 -3
  260. package/dist/clients/tree-sitter-query-loader.js +81 -1
  261. package/dist/clients/tree-sitter-shared.js +51 -46
  262. package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  263. package/dist/clients/typos-config.js +5 -0
  264. package/dist/clients/user-notify.js +6 -2
  265. package/dist/clients/warm-attach.js +8 -10
  266. package/dist/clients/widget-state.js +191 -26
  267. package/dist/clients/word-index-logger.js +15 -3
  268. package/dist/clients/word-index-store.js +463 -0
  269. package/dist/clients/word-index.js +815 -125
  270. package/dist/clients/workspace-topology.js +25 -1
  271. package/dist/clients/zizmor-config.js +29 -6
  272. package/dist/index.js +68577 -53950
  273. package/dist/mcp/analyze-cli.js +3 -1
  274. package/dist/mcp/build-staleness.js +8 -7
  275. package/dist/mcp/cli.js +3 -1
  276. package/dist/mcp/server.js +133 -6
  277. package/dist/scripts/lib/process-scan.mjs +583 -0
  278. package/dist/scripts/lib/skills-predicate.mjs +129 -0
  279. package/dist/tools/effective-config.js +89 -0
  280. package/dist/tools/lens-diagnostics.js +99 -24
  281. package/dist/tools/lsp-diagnostics.js +81 -85
  282. package/dist/tools/lsp-navigation.js +40 -17
  283. package/dist/tools/shared.js +0 -1
  284. package/docs/agent-guide.md +68 -3
  285. package/docs/ast-grep_rules_catalog.md +41 -10
  286. package/docs/astplayground.md +26 -17
  287. package/docs/configuration.md +222 -0
  288. package/docs/dependencies.md +3 -3
  289. package/docs/features.md +44 -5
  290. package/docs/language-coverage.md +2 -2
  291. package/docs/pi-lens-fixer.md +25 -0
  292. package/docs/pi-lens-investigator.md +25 -0
  293. package/docs/pi-lens-reviewer.md +27 -0
  294. package/docs/pi-lens-subagent.md +38 -0
  295. package/docs/pi-lens-warden.md +55 -0
  296. package/docs/public-api-stability.md +359 -0
  297. package/docs/release-qa-baseline.md +182 -0
  298. package/docs/subagent-compat.md +110 -30
  299. package/docs/tree-sitter_rules_catalog.md +2 -2
  300. package/package.json +17 -21
  301. package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  302. package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  303. package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  304. package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  305. package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  306. package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  307. package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  308. package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  309. package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  310. package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  311. package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  312. package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  313. package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  314. package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  315. package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  316. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  317. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  318. package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  319. package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  320. package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  321. package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  322. package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  323. package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  324. package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  325. package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  326. package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  327. package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  328. package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  329. package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  330. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  331. package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  332. package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  333. package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  334. package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  335. package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  336. package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  337. package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  338. package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  339. package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  340. package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  341. package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  342. package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  343. package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  344. package/rules/ast-grep-rules/rules/no-important.yml +21 -0
  345. package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  346. package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  347. package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  348. package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  349. package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  350. package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  351. package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  352. package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  353. package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  354. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  355. package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  356. package/scripts/analyze-pi-lens-logs.mjs +192 -1
  357. package/scripts/install-selftest.mjs +58 -2
  358. package/scripts/lib/skills-predicate.mjs +129 -0
  359. package/scripts/rpc-load-check.mjs +3 -1
@@ -16,11 +16,14 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
  import { createDeadline, forEachCooperatively, yieldIfOverBudget, } from "./cooperative-budget.js";
19
+ import { incrementDegradationCount } from "./degradation-ledger.js";
19
20
  import { KIND_EXTENSIONS } from "./file-kinds.js";
20
21
  import { PathKeyedMap } from "./path-keyed-map.js";
21
22
  import { isAtOrAboveHomeDir, normalizeEphemeralMapKey } from "./path-utils.js";
23
+ import { createSingleFlight } from "./single-flight.js";
22
24
  import { createDebounceScheduler, } from "./persist-debounce.js";
23
25
  import { getWordIndexMaxFilesDerived } from "./project-scale.js";
26
+ import { compactPostingsIntoArena, compactPostingsIntoArenaCooperatively, countPostingBackingStores, countPostingEntries, estimateWordIndexStoreBytes, WordForwardEntry, WordIndexFileTable, WordPostingList, } from "./word-index-store.js";
24
27
  import { logWordIndex } from "./word-index-logger.js";
25
28
  /**
26
29
  * The single normalizer every word-index path key folds through (#1025). The
@@ -160,6 +163,7 @@ const WORD_INDEX_LONG_LINE_YIELD_CHARS = 4096;
160
163
  function createEmptyWordIndex(truncated) {
161
164
  return {
162
165
  postings: new Map(),
166
+ fileTable: new WordIndexFileTable(),
163
167
  docLengths: new PathKeyedMap(wordIndexKey),
164
168
  totalTokens: 0,
165
169
  docCount: 0,
@@ -167,38 +171,274 @@ function createEmptyWordIndex(truncated) {
167
171
  forward: new PathKeyedMap(wordIndexKey),
168
172
  fileMtimes: new PathKeyedMap(wordIndexKey),
169
173
  fileSizes: new PathKeyedMap(wordIndexKey),
174
+ replacementStats: { count: 0, totalMs: 0, maxMs: 0 },
175
+ dirtyFiles: new Set(),
176
+ postingStoreCount: 0,
177
+ recompactFlight: createSingleFlight(),
170
178
  };
171
179
  }
172
- function indexWordLine(index, filePath, line, lineNumber, tokenLineCounts) {
180
+ export function countWordIndexPostingEntries(index) {
181
+ return countPostingEntries(index);
182
+ }
183
+ /**
184
+ * Estimated resident bytes of the index's two packed stores (#2069).
185
+ * Reported on the `full_rebuild`, `incremental_refresh`, and `cold_build`
186
+ * word-index log records and on the `memory_sample` word-index subsystem, so
187
+ * a heap census can be reconciled against the log without taking a snapshot —
188
+ * the gap that let #1999 under-count this subsystem by a factor of sixty.
189
+ */
190
+ export function estimateWordIndexResidentBytes(index) {
191
+ return estimateWordIndexStoreBytes(index);
192
+ }
193
+ /**
194
+ * Record a posting whose file id the file table cannot resolve (#2069).
195
+ *
196
+ * Unreachable by construction: {@link WordIndexFileTable.release} only runs
197
+ * after the forward index has enumerated and removed every posting naming that
198
+ * id. If it fires, that invariant broke, and the visible symptom is a query
199
+ * returning FEWER results with nothing to distinguish it from a smaller match
200
+ * set — the clean-versus-errored ambiguity AGENTS.md catalogs as shape 10.
201
+ *
202
+ * `incrementDegradationCount` rather than `recordDegradationOnce` because
203
+ * ranking is a hot loop: the tally stays exact for every occurrence while only
204
+ * the first per (kind, orphaned id) writes a durable row. Subject is the id
205
+ * itself, so aggregation keeps the discriminating identity.
206
+ */
207
+ function recordOrphanWordIndexFileId(fileId, token, seam) {
208
+ incrementDegradationCount({
209
+ kind: "word-index-orphan-file-id",
210
+ subject: `fileId:${fileId}`,
211
+ reason: `${seam} dropped a posting for token "${token}": the file table has no path for this id`,
212
+ });
213
+ }
214
+ /**
215
+ * Decode one token’s postings into `{ file, line }` objects. Allocates, so it
216
+ * is for callers that genuinely need the display form (tests, diagnostics) —
217
+ * never for the hot ranking loop, which reads the packed lanes directly.
218
+ */
219
+ export function wordIndexPostingHits(index, token) {
220
+ const list = index.postings.get(token);
221
+ if (!list)
222
+ return [];
223
+ const hits = [];
224
+ for (let i = 0; i < list.length; i += 1) {
225
+ const fileId = list.fileIdAt(i);
226
+ const file = index.fileTable.pathFor(fileId);
227
+ if (file === undefined) {
228
+ recordOrphanWordIndexFileId(fileId, token, "decode");
229
+ continue;
230
+ }
231
+ hits.push({ file, line: list.lineAt(i) });
232
+ }
233
+ return hits;
234
+ }
235
+ /**
236
+ * Settle a freshly built index's posting store into its compact resident form
237
+ * (#2069): re-home every list into one shared arena, sized from the live entry
238
+ * counts. That both releases the doubling growth slack a build leaves behind
239
+ * and stops the long tail of rare tokens paying for an `ArrayBuffer` header
240
+ * each.
241
+ */
242
+ function compactWordIndexPostings(index) {
243
+ compactPostingsIntoArena(index.postings);
244
+ index.postingStoreCount = countPostingBackingStores(index.postings);
245
+ }
246
+ /** Bump the running distinct-backing-store tally by `count` (#2117). */
247
+ function notePostingStoresAllocated(index, count) {
248
+ index.postingStoreCount = (index.postingStoreCount ?? 0) + count;
249
+ }
250
+ /**
251
+ * Floor for the recompaction gate: below this many backing stores the arena is
252
+ * never repacked, so a small index never pays an O(vocab) walk it cannot amortize
253
+ * (#2117). This is also the whole gate for indexes whose vocabulary is under
254
+ * `WORD_INDEX_RECOMPACT_STORE_FLOOR / WORD_INDEX_RECOMPACT_STORE_FRACTION`
255
+ * tokens, keeping the pre-#2246 behaviour for small corpora bit-identical.
256
+ */
257
+ const WORD_INDEX_RECOMPACT_STORE_FLOOR = 64;
258
+ /**
259
+ * Recompaction gate as a share of the live vocabulary (#2246). The flat 64-store
260
+ * threshold #2117 shipped was crossed by every single edit: one document
261
+ * replacement raises `postingStoreCount` by roughly the edited document's
262
+ * distinct-token count (hundreds), so the whole O(vocab) arena was rebuilt after
263
+ * every edit. Gating on a fraction of `postings.size` instead lets fragmentation
264
+ * accumulate proportionally to the index, so recompaction fires once per many
265
+ * edits rather than once per edit.
266
+ *
267
+ * Memory ceiling: the store count can reach a tenth of the vocabulary in private
268
+ * stores before a repack, each carrying a fixed header plus growth slack. #2117
269
+ * measured the fully-churned ceiling (every token private, store count =
270
+ * vocabulary) at +22% resident. The cost does not scale with the store count
271
+ * alone, because the tokens that churn most are also the ones carrying the
272
+ * longest posting lists. PR #2246's adversarial hot-token probe measured this
273
+ * gate at +13.7% resident at rest and +19.9% peak over base — inside the +22%
274
+ * #2117 accepted, but not by the wide margin a naive store-count ratio would
275
+ * predict. Raising the fraction further would cross that bound.
276
+ */
277
+ const WORD_INDEX_RECOMPACT_STORE_FRACTION = 0.1;
278
+ /**
279
+ * Backing-store count that triggers a repack for `index`: a fixed floor, or a
280
+ * share of the live vocabulary once the index is large enough for the share to
281
+ * exceed the floor. O(1) — reads `postings.size`, never walks the vocabulary.
282
+ */
283
+ function wordIndexRecompactThreshold(index) {
284
+ return Math.max(WORD_INDEX_RECOMPACT_STORE_FLOOR, Math.floor(index.postings.size * WORD_INDEX_RECOMPACT_STORE_FRACTION));
285
+ }
286
+ /**
287
+ * Pack the arena if churn has spread the postings across more than the
288
+ * threshold of backing stores (#2117, #2246). Runs the exact O(vocab) store
289
+ * count as the authoritative gate — the per-edit hot path uses the O(1)
290
+ * `postingStoreCount` estimate, so this expensive walk only happens inside the
291
+ * bounded, off-hot-path recompaction it guards. Resets the estimate to the
292
+ * true post-compaction count.
293
+ */
294
+ async function recompactWordIndexPostingsIfNeeded(index, root) {
295
+ const threshold = wordIndexRecompactThreshold(index);
296
+ const beforeStores = countPostingBackingStores(index.postings);
297
+ if (beforeStores <= threshold) {
298
+ index.postingStoreCount = beforeStores;
299
+ return;
300
+ }
301
+ const beforeBytes = estimateWordIndexResidentBytes(index);
302
+ await compactPostingsIntoArenaCooperatively(index.postings);
303
+ const afterStores = countPostingBackingStores(index.postings);
304
+ index.postingStoreCount = afterStores;
305
+ const afterBytes = estimateWordIndexResidentBytes(index);
306
+ const firstForRoot = incrementDegradationCount({
307
+ kind: "word-index-arena-recompact",
308
+ subject: path.resolve(root),
309
+ reason: `arena store threshold ${threshold} exceeded`,
310
+ metadata: { beforeBytes, afterBytes, beforeStores, afterStores },
311
+ });
312
+ // Keep the detailed record bounded per root. The ledger still counts every
313
+ // repeated recompaction and emits its own power-of-two summaries.
314
+ if (firstForRoot) {
315
+ logWordIndex({
316
+ phase: "incremental_refresh",
317
+ cwd: path.resolve(root),
318
+ trigger: "incremental_refresh",
319
+ reason: `arena_recompact beforeBytes=${beforeBytes} afterBytes=${afterBytes} beforeStores=${beforeStores} afterStores=${afterStores}`,
320
+ });
321
+ }
322
+ }
323
+ /**
324
+ * Serialize a recompaction through the per-index async operation queue so no
325
+ * async edit or refresh interleaves it (#2117). The queue plus the corruption-
326
+ * proof cooperative compactor together close the review-F2 data race: the queue
327
+ * excludes async operations, and the compactor's snapshot-and-skip publish
328
+ * excludes the synchronous per-edit path the queue cannot see. The per-index
329
+ * `recompactFlight` single-flight (#1753) keeps a burst of edits from stacking
330
+ * duplicates: a caller that arrives while one recompaction runs joins it rather
331
+ * than starting a second. The single slot uses a constant key.
332
+ */
333
+ function enqueueWordIndexRecompact(index, root) {
334
+ if ((index.postingStoreCount ?? 0) <= wordIndexRecompactThreshold(index)) {
335
+ return Promise.resolve();
336
+ }
337
+ const flight = (index.recompactFlight ??= createSingleFlight());
338
+ return flight.run("recompact", () => enqueueAsyncWordIndexOperation(index, () => recompactWordIndexPostingsIfNeeded(index, root)));
339
+ }
340
+ /**
341
+ * O(1) hot-path gate for the synchronous per-edit seam. Reads the running
342
+ * store-count estimate, never a fresh `Set` over the vocabulary, then defers
343
+ * the actual work onto the async queue so it cannot stall the edit (#2117).
344
+ */
345
+ function scheduleWordIndexRecompact(index, filePath) {
346
+ void enqueueWordIndexRecompact(index, path.dirname(filePath));
347
+ }
348
+ /**
349
+ * Test hook: settle any queued arena recompaction for `index`. Draining the
350
+ * index's own async operation queue is enough because the recompaction runs
351
+ * through it; there is no module-level scheduler to flush.
352
+ */
353
+ export async function flushWordIndexRecompactionsForTests(index) {
354
+ await (asyncWordIndexOperations.get(index) ?? Promise.resolve());
355
+ }
356
+ function recordWordIndexReplacement(index, startedAt) {
357
+ const stats = index.replacementStats ?? { count: 0, totalMs: 0, maxMs: 0 };
358
+ const durationMs = Math.max(0, Date.now() - startedAt);
359
+ stats.count += 1;
360
+ stats.totalMs += durationMs;
361
+ stats.maxMs = Math.max(stats.maxMs, durationMs);
362
+ index.replacementStats = stats;
363
+ }
364
+ /** Intern a document path once per index; posting removal compares this id. */
365
+ function internWordIndexFile(index, filePath) {
366
+ return index.fileTable.intern(wordIndexKey(filePath), filePath);
367
+ }
368
+ function appendWordIndexPostings(index, filePath, perTokenHits) {
369
+ const fileId = internWordIndexFile(index, filePath);
370
+ const tokenLineCounts = new Map();
371
+ for (const [token, lineNumbers] of perTokenHits) {
372
+ let list = index.postings.get(token);
373
+ if (list) {
374
+ // A grow allocates a fresh private store; count it so the O(1) gate
375
+ // tracks fragmentation without re-walking the vocabulary (#2117).
376
+ if (list.reserve(list.length + lineNumbers.length)) {
377
+ notePostingStoresAllocated(index, 1);
378
+ }
379
+ }
380
+ else {
381
+ list = new WordPostingList(token, lineNumbers.length);
382
+ index.postings.set(token, list);
383
+ notePostingStoresAllocated(index, 1);
384
+ }
385
+ // `list.token` is the canonical instance, so the forward entry points at
386
+ // the same string the postings map is keyed by instead of retaining this
387
+ // document’s own tokenizer allocation (#2069).
388
+ tokenLineCounts.set(list.token, lineNumbers.length);
389
+ for (const line of lineNumbers)
390
+ list.push(fileId, line);
391
+ }
392
+ return tokenLineCounts;
393
+ }
394
+ function commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt) {
395
+ const tokenLineCounts = appendWordIndexPostings(index, doc.path, perTokenHits);
396
+ index.docLengths.set(doc.path, docLength);
397
+ index.forward.set(doc.path, WordForwardEntry.fromTally(tokenLineCounts));
398
+ index.fileMtimes.set(doc.path, -1);
399
+ index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
400
+ index.dirtyFiles?.add(wordIndexKey(doc.path));
401
+ index.totalTokens += docLength;
402
+ index.docCount += 1;
403
+ recordWordIndexReplacement(index, startedAt);
404
+ }
405
+ function indexWordLine(index, fileId, line, lineNumber, tokenLineCounts) {
173
406
  const lineTokens = tokenizeLine(line);
174
407
  const seenOnLine = new Set();
175
408
  for (const token of lineTokens) {
176
409
  if (seenOnLine.has(token))
177
410
  continue;
178
411
  seenOnLine.add(token);
179
- const arr = index.postings.get(token);
180
- if (arr)
181
- arr.push({ file: filePath, line: lineNumber });
182
- else
183
- index.postings.set(token, [{ file: filePath, line: lineNumber }]);
184
- tokenLineCounts.set(token, (tokenLineCounts.get(token) ?? 0) + 1);
412
+ let list = index.postings.get(token);
413
+ if (!list) {
414
+ list = new WordPostingList(token, 1);
415
+ index.postings.set(token, list);
416
+ }
417
+ list.push(fileId, lineNumber);
418
+ // Canonical instance, not this line’s fresh tokenizer allocation (#2069).
419
+ const canonical = list.token;
420
+ tokenLineCounts.set(canonical, (tokenLineCounts.get(canonical) ?? 0) + 1);
185
421
  }
186
422
  return lineTokens.length;
187
423
  }
188
424
  function finishWordIndexDocument(index, doc, docLength, tokenLineCounts) {
189
425
  index.docLengths.set(doc.path, docLength);
190
- index.forward?.set(doc.path, tokenLineCounts);
426
+ index.forward?.set(doc.path, WordForwardEntry.fromTally(tokenLineCounts));
191
427
  index.fileMtimes.set(doc.path, doc.mtimeMs ?? 0);
192
428
  index.fileSizes.set(doc.path, doc.size ?? Buffer.byteLength(doc.content, "utf-8"));
429
+ // A build has no prior wire cache. Keeping the marker makes a future
430
+ // serializer cache explicit and gives incremental refresh one uniform seam.
431
+ index.dirtyFiles?.add(wordIndexKey(doc.path));
193
432
  index.totalTokens += docLength;
194
433
  index.docCount += 1;
195
434
  }
196
435
  function indexWordDocument(index, doc) {
436
+ const fileId = internWordIndexFile(index, doc.path);
197
437
  const lines = doc.content.split(/\r?\n/);
198
438
  const tokenLineCounts = new Map();
199
439
  let docLength = 0;
200
440
  for (let i = 0; i < lines.length; i += 1) {
201
- docLength += indexWordLine(index, doc.path, lines[i], i + 1, tokenLineCounts);
441
+ docLength += indexWordLine(index, fileId, lines[i], i + 1, tokenLineCounts);
202
442
  }
203
443
  finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
204
444
  }
@@ -206,6 +446,7 @@ export function buildWordIndex(files) {
206
446
  const index = createEmptyWordIndex(files.truncated ?? false);
207
447
  for (const doc of files)
208
448
  indexWordDocument(index, doc);
449
+ compactWordIndexPostings(index);
209
450
  return index;
210
451
  }
211
452
  /**
@@ -221,12 +462,13 @@ export async function buildWordIndexAsync(files, shouldContinue = () => true) {
221
462
  for (const doc of files) {
222
463
  if (!shouldContinue())
223
464
  throw new Error("word index build superseded");
465
+ const fileId = internWordIndexFile(index, doc.path);
224
466
  const lines = doc.content.split(/\r?\n/);
225
467
  const tokenLineCounts = new Map();
226
468
  let docLength = 0;
227
469
  for (let i = 0; i < lines.length; i += 1) {
228
470
  const line = lines[i];
229
- docLength += indexWordLine(index, doc.path, line, i + 1, tokenLineCounts);
471
+ docLength += indexWordLine(index, fileId, line, i + 1, tokenLineCounts);
230
472
  if (line.length >= WORD_INDEX_LONG_LINE_YIELD_CHARS ||
231
473
  deadline.expired()) {
232
474
  await yieldIfOverBudget(deadline);
@@ -240,6 +482,7 @@ export async function buildWordIndexAsync(files, shouldContinue = () => true) {
240
482
  throw new Error("word index build superseded");
241
483
  }
242
484
  }
485
+ compactWordIndexPostings(index);
243
486
  return index;
244
487
  }
245
488
  /**
@@ -256,19 +499,27 @@ export function removeWordIndexDocument(index, filePath) {
256
499
  const tokenLineCounts = index.forward.get(filePath);
257
500
  if (!tokenLineCounts)
258
501
  return false;
259
- // `postings` is token-keyed (not a PathKeyedMap), so its `WordHit.file`
260
- // display strings must be compared through the SAME normalizer the path maps
261
- // use — otherwise a build-form hit (`SUB/a.ts`) survives an edit-form removal
502
+ // `postings` is token-keyed (not a PathKeyedMap), so the file identity it
503
+ // carries must be resolved through the SAME normalizer the path maps use —
504
+ // otherwise a build-form hit (`SUB/a.ts`) survives an edit-form removal
262
505
  // (`sub/a.ts`) on a case-insensitive FS and lingers as a stale posting
263
- // (the #1025 item #2 bug this fix closes).
506
+ // (the #1025 item #2 bug this fix closes). Since #2069 that identity is the
507
+ // file table’s integer id, so the comparison below is an int compare rather
508
+ // than a per-element string compare.
264
509
  const removedKey = wordIndexKey(filePath);
510
+ const removedId = index.fileTable.idFor(removedKey);
511
+ if (removedId === undefined)
512
+ return false;
265
513
  for (const token of tokenLineCounts.keys()) {
266
- const arr = index.postings.get(token);
267
- if (!arr)
514
+ const list = index.postings.get(token);
515
+ if (!list)
268
516
  continue;
269
- const next = arr.filter((hit) => wordIndexKey(hit.file) !== removedKey);
270
- if (next.length > 0)
517
+ const next = list.withoutFile(removedId);
518
+ // `withoutFile` always returns a freshly allocated private store (#2117).
519
+ if (next.length > 0) {
271
520
  index.postings.set(token, next);
521
+ notePostingStoresAllocated(index, 1);
522
+ }
272
523
  else
273
524
  index.postings.delete(token);
274
525
  }
@@ -277,6 +528,8 @@ export function removeWordIndexDocument(index, filePath) {
277
528
  index.forward.delete(filePath);
278
529
  index.fileMtimes.delete(filePath);
279
530
  index.fileSizes.delete(filePath);
531
+ index.dirtyFiles?.add(wordIndexKey(filePath));
532
+ index.fileTable.release(removedKey);
280
533
  index.totalTokens -= docLength;
281
534
  index.docCount = Math.max(0, index.docCount - 1);
282
535
  return true;
@@ -297,6 +550,7 @@ export function removeWordIndexDocument(index, filePath) {
297
550
  export function updateWordIndexDocument(index, doc) {
298
551
  if (!index.forward)
299
552
  return false;
553
+ const startedAt = Date.now();
300
554
  // Remove the old contribution first (no-op if this is a brand new doc).
301
555
  if (index.forward.has(doc.path)) {
302
556
  removeWordIndexDocument(index, doc.path);
@@ -322,34 +576,27 @@ export function updateWordIndexDocument(index, doc) {
322
576
  perTokenHits.set(token, [i + 1]);
323
577
  }
324
578
  }
325
- const tokenLineCounts = new Map();
326
- for (const [token, lineNumbers] of perTokenHits) {
327
- tokenLineCounts.set(token, lineNumbers.length);
328
- const hits = lineNumbers.map((line) => ({ file: doc.path, line }));
329
- const arr = index.postings.get(token);
330
- if (arr)
331
- arr.push(...hits);
332
- else
333
- index.postings.set(token, hits);
334
- }
335
- index.docLengths.set(doc.path, docLength);
336
- index.forward.set(doc.path, tokenLineCounts);
337
579
  // Per-edit callers generally already have content but not a stat. -1 is an
338
580
  // impossible real mtime, so it deliberately makes the document stale at the
339
581
  // next startup refresh (`-1 !== realMtime` always) — unlike 0, which is a
340
582
  // legal on-disk mtime (SOURCE_DATE_EPOCH=0, archive extraction) and would
341
583
  // collide, leaving such a file never re-tokenized (#958 review F2).
342
- index.fileMtimes.set(doc.path, -1);
343
- // Size is likewise recorded for the #1105 mtime+size refresh gate. The -1
344
- // mtime already forces a re-read next session, so this value only keeps the
345
- // map dense (parallel to fileMtimes); store the real byte length so a
346
- // deserialize→reserialize round-trip before any refresh carries a truthful
347
- // size rather than a placeholder.
348
- index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
349
- index.totalTokens += docLength;
350
- index.docCount += 1;
584
+ commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt);
585
+ scheduleWordIndexRecompact(index, doc.path);
351
586
  return true;
352
587
  }
588
+ const asyncWordIndexOperations = new WeakMap();
589
+ function enqueueAsyncWordIndexOperation(index, operation) {
590
+ const previous = asyncWordIndexOperations.get(index) ?? Promise.resolve();
591
+ const run = previous.catch(() => undefined).then(operation);
592
+ const settled = run.then(() => undefined, () => undefined);
593
+ asyncWordIndexOperations.set(index, settled);
594
+ return run.finally(() => {
595
+ if (asyncWordIndexOperations.get(index) === settled) {
596
+ asyncWordIndexOperations.delete(index);
597
+ }
598
+ });
599
+ }
353
600
  async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
354
601
  if (!index.forward)
355
602
  return undefined;
@@ -357,31 +604,43 @@ async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
357
604
  if (!tokenLineCounts)
358
605
  return undefined;
359
606
  const removedKey = wordIndexKey(filePath);
607
+ const removedId = index.fileTable.idFor(removedKey);
608
+ if (removedId === undefined)
609
+ return undefined;
360
610
  const postings = new Map();
361
611
  const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
362
612
  for (const token of tokenLineCounts.keys()) {
363
613
  if (!shouldContinue())
364
614
  throw new Error("word index refresh superseded");
365
- const arr = index.postings.get(token);
366
- if (!arr)
615
+ const list = index.postings.get(token);
616
+ if (!list)
367
617
  continue;
368
- const next = [];
369
- for (const hit of arr) {
370
- if (wordIndexKey(hit.file) !== removedKey)
371
- next.push(hit);
372
- if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
373
- if (!shouldContinue())
374
- throw new Error("word index refresh superseded");
375
- }
618
+ // One token's postings, filtered by the SAME packed primitive the
619
+ // synchronous path uses, so the staged result is identical rather than
620
+ // merely equivalent. The work unit is the token, not the posting element:
621
+ // a per-element deadline check cost one `performance.now()` per entry —
622
+ // on the order of a million per edit on a 2.2M-posting corpus — which is
623
+ // the tax that made this path unusable from the per-edit seam (#2067).
624
+ // One list's scan is a bounded, non-yieldable unit; the deadline is
625
+ // checked between lists.
626
+ const survivors = list.withoutFile(removedId);
627
+ postings.set(token, survivors.length > 0 ? survivors : undefined);
628
+ if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
629
+ if (!shouldContinue())
630
+ throw new Error("word index refresh superseded");
376
631
  }
377
- postings.set(token, next.length > 0 ? next : undefined);
378
632
  }
379
633
  return { postings, docLength: index.docLengths.get(filePath) ?? 0 };
380
634
  }
381
635
  function commitWordIndexDocumentRemoval(index, filePath, staged) {
382
- for (const [token, hits] of staged.postings) {
383
- if (hits)
384
- index.postings.set(token, hits);
636
+ for (const [token, list] of staged.postings) {
637
+ // Staged survivors come from `WordPostingList.withoutFile` — a fresh
638
+ // private store each, so the O(1) fragmentation gate must count them
639
+ // (#2117).
640
+ if (list) {
641
+ index.postings.set(token, list);
642
+ notePostingStoresAllocated(index, 1);
643
+ }
385
644
  else
386
645
  index.postings.delete(token);
387
646
  }
@@ -389,23 +648,49 @@ function commitWordIndexDocumentRemoval(index, filePath, staged) {
389
648
  index.forward?.delete(filePath);
390
649
  index.fileMtimes.delete(filePath);
391
650
  index.fileSizes.delete(filePath);
651
+ index.dirtyFiles?.add(wordIndexKey(filePath));
652
+ index.fileTable.release(wordIndexKey(filePath));
392
653
  index.totalTokens -= staged.docLength;
393
654
  index.docCount = Math.max(0, index.docCount - 1);
394
655
  }
395
656
  /** Cooperative, atomically-published removal used only by bulk refresh. */
396
657
  export async function removeWordIndexDocumentAsync(index, filePath, shouldContinue = () => true) {
397
- const staged = await stageWordIndexDocumentRemoval(index, filePath, shouldContinue);
398
- if (!staged)
399
- return false;
400
- if (!shouldContinue())
401
- throw new Error("word index refresh superseded");
402
- commitWordIndexDocumentRemoval(index, filePath, staged);
403
- return true;
658
+ return enqueueAsyncWordIndexOperation(index, async () => {
659
+ const staged = await stageWordIndexDocumentRemoval(index, filePath, shouldContinue);
660
+ if (!staged)
661
+ return false;
662
+ if (!shouldContinue())
663
+ throw new Error("word index refresh superseded");
664
+ commitWordIndexDocumentRemoval(index, filePath, staged);
665
+ return true;
666
+ });
667
+ }
668
+ /**
669
+ * The per-edit replacement primitive the cascade seam calls (#2067).
670
+ *
671
+ * `updateWordIndexDocumentAsync` plus the arena-recompaction gate the
672
+ * synchronous variant carries, which is what makes this the per-edit entry
673
+ * point rather than a rename. Bulk refresh must NOT use it: one refreshed
674
+ * document raises `postingStoreCount` by roughly its distinct-token count, so a
675
+ * per-document schedule would drive an O(vocabulary) recompaction between
676
+ * nearly every pair of documents. `refreshWordIndexIncrementally` therefore
677
+ * drives `updateWordIndexDocumentAsync` directly and recompacts ONCE after its
678
+ * loop; the per-edit seam has no such "after the loop" and schedules here.
679
+ */
680
+ export async function updateWordIndexDocumentForEdit(index, doc) {
681
+ const updated = await updateWordIndexDocumentAsync(index, doc);
682
+ if (updated)
683
+ scheduleWordIndexRecompact(index, doc.path);
684
+ return updated;
404
685
  }
405
686
  /** Cooperative replacement whose old/new state is committed without an await. */
406
687
  export async function updateWordIndexDocumentAsync(index, doc, shouldContinue = () => true) {
688
+ return enqueueAsyncWordIndexOperation(index, () => updateWordIndexDocumentAsyncUnsafe(index, doc, shouldContinue));
689
+ }
690
+ async function updateWordIndexDocumentAsyncUnsafe(index, doc, shouldContinue) {
407
691
  if (!index.forward)
408
692
  return false;
693
+ const startedAt = Date.now();
409
694
  const removal = index.forward.has(doc.path)
410
695
  ? await stageWordIndexDocumentRemoval(index, doc.path, shouldContinue)
411
696
  : undefined;
@@ -437,22 +722,7 @@ export async function updateWordIndexDocumentAsync(index, doc, shouldContinue =
437
722
  throw new Error("word index refresh superseded");
438
723
  if (removal)
439
724
  commitWordIndexDocumentRemoval(index, doc.path, removal);
440
- const tokenLineCounts = new Map();
441
- for (const [token, lineNumbers] of perTokenHits) {
442
- tokenLineCounts.set(token, lineNumbers.length);
443
- const hits = lineNumbers.map((line) => ({ file: doc.path, line }));
444
- const arr = index.postings.get(token);
445
- if (arr)
446
- arr.push(...hits);
447
- else
448
- index.postings.set(token, hits);
449
- }
450
- index.docLengths.set(doc.path, docLength);
451
- index.forward.set(doc.path, tokenLineCounts);
452
- index.fileMtimes.set(doc.path, -1);
453
- index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
454
- index.totalTokens += docLength;
455
- index.docCount += 1;
725
+ commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt);
456
726
  return true;
457
727
  }
458
728
  /** Bounds shared by every word-index build path — keep the walk off the
@@ -567,6 +837,12 @@ const WORD_INDEX_FILE_READ_TOKEN_COST = 300;
567
837
  * against a 1,436 ms rebuild + re-read). The synthetic high-df corpus in the
568
838
  * #1197 probe gives 0.09, so this is the conservative (incremental-favouring)
569
839
  * end of the observed range.
840
+ *
841
+ * #2069 made the scan an Int32 compare over packed lanes instead of a string
842
+ * compare over boxed objects, so the real per-element cost is now BELOW this
843
+ * constant. The constant is left as measured: over-stating incremental cost
844
+ * only ever routes work to a full rebuild, which is the bounded side of the
845
+ * decision. Re-measuring it is #2067/#2068 territory, not this change.
570
846
  */
571
847
  const WORD_INDEX_POSTING_SCAN_TOKEN_COST = 0.2;
572
848
  /**
@@ -612,7 +888,8 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
612
888
  const statConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0
613
889
  ? Math.max(1, Math.floor(requestedConcurrency))
614
890
  : WORD_INDEX_STAT_CONCURRENCY;
615
- const statResults = new Array(walked.length);
891
+ const statResults = [];
892
+ statResults.length = walked.length;
616
893
  let cursor = 0;
617
894
  let superseded = false;
618
895
  const worker = async () => {
@@ -703,9 +980,9 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
703
980
  // this decision exists to avoid.
704
981
  let postingEntries = 0;
705
982
  let weightedPostingEntries = 0;
706
- for (const hits of index.postings.values()) {
707
- postingEntries += hits.length;
708
- weightedPostingEntries += hits.length * hits.length;
983
+ for (const list of index.postings.values()) {
984
+ postingEntries += list.length;
985
+ weightedPostingEntries += list.length * list.length;
709
986
  }
710
987
  // The mean posting-array length weighted by OCCURRENCES, not the plain mean:
711
988
  // a document's tokens are drawn from the frequency distribution, so the arrays
@@ -802,6 +1079,9 @@ export async function refreshWordIndexIncrementally(index, root, shouldContinue
802
1079
  }
803
1080
  }
804
1081
  timings.refreshReadsMs = Date.now() - refreshReadsStartMs;
1082
+ // Route through the same guarded queue the per-edit seam uses, so a refresh
1083
+ // and a concurrent cascade edit cannot both drive a recompaction (#2117).
1084
+ await enqueueWordIndexRecompact(index, root);
805
1085
  index.truncated = walked.length === maxFiles;
806
1086
  return {
807
1087
  mode: "incremental",
@@ -1026,17 +1306,29 @@ export function searchWordIndex(index, query, options = {}) {
1026
1306
  const posting = index.postings.get(token);
1027
1307
  if (!posting)
1028
1308
  continue;
1029
- const linesByFile = new Map();
1030
- for (const hit of posting) {
1031
- const arr = linesByFile.get(hit.file);
1309
+ // Grouped by the packed file id, then resolved to the display path once
1310
+ // per (token, file) instead of once per posting (#2069). Insertion order,
1311
+ // document frequency, and the per-file line lists are identical to the
1312
+ // boxed representation, so scores and ordering are unchanged.
1313
+ const linesByFileId = new Map();
1314
+ for (let i = 0; i < posting.length; i += 1) {
1315
+ const fileId = posting.fileIdAt(i);
1316
+ const arr = linesByFileId.get(fileId);
1032
1317
  if (arr)
1033
- arr.push(hit.line);
1318
+ arr.push(posting.lineAt(i));
1034
1319
  else
1035
- linesByFile.set(hit.file, [hit.line]);
1320
+ linesByFileId.set(fileId, [posting.lineAt(i)]);
1036
1321
  }
1037
- const docFrequency = linesByFile.size;
1322
+ const docFrequency = linesByFileId.size;
1038
1323
  const idf = Math.log(1 + (docCount - docFrequency + 0.5) / (docFrequency + 0.5));
1039
- for (const [file, lines] of linesByFile) {
1324
+ for (const [fileId, lines] of linesByFileId) {
1325
+ const file = index.fileTable.pathFor(fileId);
1326
+ if (file === undefined) {
1327
+ // Dropping this silently would shorten the result list with nothing to
1328
+ // tell it apart from a smaller match set. Record, then drop.
1329
+ recordOrphanWordIndexFileId(fileId, token, "search");
1330
+ continue;
1331
+ }
1040
1332
  if (combinedFilter && !combinedFilter(file))
1041
1333
  continue;
1042
1334
  const termFrequency = lines.length;
@@ -1100,18 +1392,48 @@ export function centralityFromReverseDeps(index, reverseDeps, normalizeKey = (fi
1100
1392
  }
1101
1393
  /** Persisted word-index serialization format version. Bump on breaking format changes. */
1102
1394
  export const WORD_INDEX_FORMAT_VERSION = 2;
1103
- export function serializeWordIndex(index) {
1395
+ const WORD_INDEX_INT32_MAX = 2_147_483_647;
1396
+ // A file id and line fit in one exact Number key through this boundary.
1397
+ const WORD_INDEX_PACKED_FILE_ID_MAX = 4_194_303;
1398
+ function isCanonicalWordIndexNumber(value) {
1399
+ return (typeof value === "number" &&
1400
+ Number.isFinite(value) &&
1401
+ Number.isSafeInteger(value) &&
1402
+ !Object.is(value, -0));
1403
+ }
1404
+ function isCanonicalWordIndexToken(token) {
1405
+ return (typeof token === "string" &&
1406
+ token.length >= 2 &&
1407
+ token === token.toLowerCase() &&
1408
+ /^[a-z0-9_$]+$/.test(token) &&
1409
+ !STOPWORDS.has(token));
1410
+ }
1411
+ const serializedWordIndexCaches = new WeakMap();
1412
+ let _lastSerializeWork;
1413
+ /** Test-only: work stats for the most recent {@link serializeWordIndex} call. */
1414
+ export function getLastWordIndexSerializeWork() {
1415
+ return _lastSerializeWork;
1416
+ }
1417
+ function serializeWordIndexFull(index) {
1104
1418
  const files = [...index.docLengths.keys()];
1105
- const fileIndex = new Map();
1106
- files.forEach((file, i) => fileIndex.set(file, i));
1419
+ // `files` carries whatever spelling `docLengths` last stored, which can
1420
+ // differ from the spelling the file table interned. Both fold through
1421
+ // `wordIndexKey`, so the slot lookup runs in the folded key space and a
1422
+ // separator- or case-divergent spelling still finds its postings (#1025).
1423
+ const slotByFileId = new Map();
1424
+ files.forEach((file, i) => {
1425
+ const fileId = index.fileTable.idFor(wordIndexKey(file));
1426
+ if (fileId !== undefined)
1427
+ slotByFileId.set(fileId, i);
1428
+ });
1107
1429
  const postings = [];
1108
- for (const [token, hits] of index.postings) {
1430
+ for (const [token, list] of index.postings) {
1109
1431
  const flat = [];
1110
- for (const hit of hits) {
1111
- const idx = fileIndex.get(hit.file);
1112
- if (idx === undefined)
1432
+ for (let i = 0; i < list.length; i += 1) {
1433
+ const slot = slotByFileId.get(list.fileIdAt(i));
1434
+ if (slot === undefined)
1113
1435
  continue;
1114
- flat.push(idx, hit.line);
1436
+ flat.push(slot, list.lineAt(i));
1115
1437
  }
1116
1438
  if (flat.length > 0)
1117
1439
  postings.push([token, flat]);
@@ -1119,10 +1441,10 @@ export function serializeWordIndex(index) {
1119
1441
  const forward = index.forward
1120
1442
  ? files.map((file, i) => [
1121
1443
  i,
1122
- [...(index.forward.get(file) ?? new Map()).entries()],
1444
+ [...(index.forward.get(file)?.entries() ?? [])],
1123
1445
  ])
1124
1446
  : undefined;
1125
- return {
1447
+ const serialized = {
1126
1448
  version: WORD_INDEX_FORMAT_VERSION,
1127
1449
  files,
1128
1450
  postings,
@@ -1134,6 +1456,145 @@ export function serializeWordIndex(index) {
1134
1456
  fileSizes: files.map((file) => index.fileSizes.get(file) ?? 0),
1135
1457
  forward,
1136
1458
  };
1459
+ const tokensByFile = new Map();
1460
+ if (index.forward) {
1461
+ for (const file of files) {
1462
+ tokensByFile.set(wordIndexKey(file), new Set(index.forward.get(file)?.keys() ?? []));
1463
+ }
1464
+ }
1465
+ _lastSerializeWork = {
1466
+ affectedTokenCount: postings.length,
1467
+ tookFullPath: true,
1468
+ };
1469
+ return { serialized, slotByFileId, tokensByFile };
1470
+ }
1471
+ /**
1472
+ * Refresh the cached wire view without walking untouched posting lanes.
1473
+ * Existing files keep their slots, so a document replacement only rebuilds
1474
+ * the token lists named by that document's old or new forward entry. A file
1475
+ * removal or reordering changes slot identity and deliberately takes the
1476
+ * bounded full path.
1477
+ */
1478
+ function serializeWordIndexIncrementally(index, cache) {
1479
+ const files = [...index.docLengths.keys()];
1480
+ const priorFiles = cache.serialized.files;
1481
+ const currentKeys = new Set(files.map(wordIndexKey));
1482
+ const priorKeys = new Set(priorFiles.map(wordIndexKey));
1483
+ if ([...priorKeys].some((key) => !currentKeys.has(key))) {
1484
+ return serializeWordIndexFull(index);
1485
+ }
1486
+ // Re-flattening most lanes costs more than rebuilding the compact wire view.
1487
+ // The review fixture crosses over between 10% and 100% dirty, so use the
1488
+ // midpoint as a conservative bound until a workload-specific model exists.
1489
+ const dirty = index.dirtyFiles;
1490
+ if (dirty && dirty.size * 2 > files.length) {
1491
+ return serializeWordIndexFull(index);
1492
+ }
1493
+ // A replacement deletes and re-adds a PathKeyedMap entry, which changes Map
1494
+ // insertion order. Keep the cached wire slots stable instead of rewriting
1495
+ // every posting just because one document changed order.
1496
+ const filesInWireOrder = [
1497
+ ...priorFiles,
1498
+ ...files.filter((file) => !priorKeys.has(wordIndexKey(file))),
1499
+ ];
1500
+ if (!dirty || dirty.size === 0) {
1501
+ _lastSerializeWork = { affectedTokenCount: 0, tookFullPath: false };
1502
+ return cache;
1503
+ }
1504
+ const slotByFileId = new Map(cache.slotByFileId);
1505
+ for (let i = priorFiles.length; i < filesInWireOrder.length; i += 1) {
1506
+ const fileId = index.fileTable.idFor(wordIndexKey(filesInWireOrder[i]));
1507
+ if (fileId !== undefined)
1508
+ slotByFileId.set(fileId, i);
1509
+ }
1510
+ const postings = cache.serialized.postings.slice();
1511
+ const postingAt = new Map();
1512
+ postings.forEach(([token], i) => postingAt.set(token, i));
1513
+ const removedTokens = new Set();
1514
+ const tokensByFile = new Map(cache.tokensByFile);
1515
+ const fileByKey = new Map(filesInWireOrder.map((file) => [wordIndexKey(file), file]));
1516
+ const affectedTokens = new Set();
1517
+ for (const fileKey of dirty) {
1518
+ const file = fileByKey.get(fileKey);
1519
+ const oldTokens = tokensByFile.get(fileKey) ?? new Set();
1520
+ const currentEntry = file === undefined ? undefined : index.forward?.get(file);
1521
+ const currentTokens = new Set(currentEntry?.keys() ?? []);
1522
+ for (const token of oldTokens)
1523
+ affectedTokens.add(token);
1524
+ for (const token of currentTokens)
1525
+ affectedTokens.add(token);
1526
+ if (file === undefined)
1527
+ tokensByFile.delete(fileKey);
1528
+ else
1529
+ tokensByFile.set(fileKey, currentTokens);
1530
+ }
1531
+ for (const token of affectedTokens) {
1532
+ const list = index.postings.get(token);
1533
+ const flat = [];
1534
+ if (list) {
1535
+ for (let i = 0; i < list.length; i += 1) {
1536
+ const slot = slotByFileId.get(list.fileIdAt(i));
1537
+ if (slot !== undefined)
1538
+ flat.push(slot, list.lineAt(i));
1539
+ }
1540
+ }
1541
+ const at = postingAt.get(token);
1542
+ if (flat.length === 0) {
1543
+ if (at !== undefined)
1544
+ removedTokens.add(token);
1545
+ }
1546
+ else if (at === undefined) {
1547
+ postings.push([token, flat]);
1548
+ postingAt.set(token, postings.length - 1);
1549
+ }
1550
+ else {
1551
+ postings[at] = [postings[at][0], flat];
1552
+ }
1553
+ }
1554
+ const compactedPostings = removedTokens.size
1555
+ ? postings.filter(([token]) => !removedTokens.has(token))
1556
+ : postings;
1557
+ const forward = index.forward
1558
+ ? (cache.serialized.forward
1559
+ ?.slice()
1560
+ .map((entry) => [entry[0], entry[1]]) ?? [])
1561
+ : undefined;
1562
+ for (const fileKey of dirty) {
1563
+ const file = fileByKey.get(fileKey);
1564
+ const fileId = file === undefined
1565
+ ? undefined
1566
+ : index.fileTable.idFor(wordIndexKey(file));
1567
+ const slot = fileId === undefined ? undefined : slotByFileId.get(fileId);
1568
+ if (slot === undefined || !forward || file === undefined)
1569
+ continue;
1570
+ forward[slot] = [slot, [...(index.forward?.get(file)?.entries() ?? [])]];
1571
+ }
1572
+ const serialized = {
1573
+ ...cache.serialized,
1574
+ files: filesInWireOrder,
1575
+ postings: compactedPostings,
1576
+ docLengths: filesInWireOrder.map((file) => index.docLengths.get(file) ?? 0),
1577
+ fileMtimes: filesInWireOrder.map((file) => index.fileMtimes.get(file) ?? 0),
1578
+ fileSizes: filesInWireOrder.map((file) => index.fileSizes.get(file) ?? 0),
1579
+ forward,
1580
+ totalTokens: index.totalTokens,
1581
+ indexedFileCount: index.docCount,
1582
+ truncated: index.truncated,
1583
+ };
1584
+ _lastSerializeWork = {
1585
+ affectedTokenCount: affectedTokens.size,
1586
+ tookFullPath: false,
1587
+ };
1588
+ return { serialized, slotByFileId, tokensByFile };
1589
+ }
1590
+ export function serializeWordIndex(index) {
1591
+ const prior = serializedWordIndexCaches.get(index);
1592
+ const cache = prior
1593
+ ? serializeWordIndexIncrementally(index, prior)
1594
+ : serializeWordIndexFull(index);
1595
+ serializedWordIndexCaches.set(index, cache);
1596
+ index.dirtyFiles?.clear();
1597
+ return cache.serialized;
1137
1598
  }
1138
1599
  export function deserializeWordIndex(data) {
1139
1600
  if (!data ||
@@ -1141,70 +1602,284 @@ export function deserializeWordIndex(data) {
1141
1602
  !Array.isArray(data.files) ||
1142
1603
  !Array.isArray(data.postings) ||
1143
1604
  !Array.isArray(data.docLengths) ||
1144
- !Array.isArray(data.fileMtimes) ||
1145
- data.fileMtimes.length !== data.files.length) {
1605
+ !Array.isArray(data.fileMtimes)) {
1146
1606
  return null;
1147
1607
  }
1608
+ let canonical = data.docLengths.length === data.files.length &&
1609
+ data.fileMtimes.length === data.files.length;
1610
+ let needsDeepSanitize = !Array.isArray(data.forward);
1611
+ if (needsDeepSanitize)
1612
+ canonical = false;
1613
+ const fileKeys = new Set();
1614
+ for (const file of data.files) {
1615
+ if (typeof file !== "string")
1616
+ return null;
1617
+ const key = wordIndexKey(file);
1618
+ if (fileKeys.has(key)) {
1619
+ canonical = false;
1620
+ needsDeepSanitize = true;
1621
+ }
1622
+ fileKeys.add(key);
1623
+ }
1148
1624
  const docLengths = new PathKeyedMap(wordIndexKey);
1625
+ // Slot i of the wire `files` array becomes file id `fileIdBySlot[i]`. Two
1626
+ // slots whose spellings fold to one key share one id, exactly as a build
1627
+ // would intern them, so a divergent snapshot cannot deserialize into two
1628
+ // posting identities for one document.
1629
+ const fileTable = new WordIndexFileTable();
1630
+ const fileIdBySlot = data.files.map((file) => fileTable.intern(wordIndexKey(file), file));
1631
+ const slotByFileId = new Map();
1632
+ fileIdBySlot.forEach((fileId, slot) => {
1633
+ if (!slotByFileId.has(fileId))
1634
+ slotByFileId.set(fileId, slot);
1635
+ });
1149
1636
  const fileMtimes = new PathKeyedMap(wordIndexKey);
1150
1637
  const fileSizes = new PathKeyedMap(wordIndexKey);
1151
- data.files.forEach((file, i) => docLengths.set(file, data.docLengths[i] ?? 0));
1152
- data.files.forEach((file, i) => fileMtimes.set(file, data.fileMtimes[i] ?? 0));
1638
+ data.files.forEach((file, i) => {
1639
+ const value = data.docLengths[i];
1640
+ if (!isCanonicalWordIndexNumber(value) || value < 0)
1641
+ canonical = false;
1642
+ docLengths.set(file, isCanonicalWordIndexNumber(value) && value >= 0 ? value : 0);
1643
+ });
1644
+ data.files.forEach((file, i) => {
1645
+ const value = data.fileMtimes[i];
1646
+ if (typeof value !== "number" || !Number.isFinite(value))
1647
+ canonical = false;
1648
+ fileMtimes.set(file, typeof value === "number" && Number.isFinite(value) ? value : 0);
1649
+ });
1153
1650
  // #1105: `fileSizes` is optional on the wire (pre-#1105 snapshots omit it).
1154
1651
  // Only populate when the array is present AND parallel to `files`; otherwise
1155
1652
  // leave it empty so the refresh gate re-reads every file once to repopulate,
1156
1653
  // rather than trusting a bogus/misaligned size. Never treated as "current".
1157
1654
  if (Array.isArray(data.fileSizes) &&
1158
1655
  data.fileSizes.length === data.files.length) {
1159
- data.files.forEach((file, i) => fileSizes.set(file, data.fileSizes?.[i] ?? 0));
1656
+ data.files.forEach((file, i) => {
1657
+ const value = data.fileSizes?.[i];
1658
+ if (!isCanonicalWordIndexNumber(value) || value < 0)
1659
+ canonical = false;
1660
+ fileSizes.set(file, isCanonicalWordIndexNumber(value) && value >= 0 ? value : 0);
1661
+ });
1662
+ }
1663
+ else {
1664
+ canonical = false;
1160
1665
  }
1161
1666
  const postings = new Map();
1162
- for (const [token, flat] of data.postings) {
1163
- if (typeof token !== "string" || !Array.isArray(flat))
1667
+ const postingTokens = new Set();
1668
+ const postingFileCounts = new Map();
1669
+ for (const posting of data.postings) {
1670
+ if (!Array.isArray(posting) || posting.length !== 2) {
1671
+ canonical = false;
1672
+ needsDeepSanitize = true;
1673
+ continue;
1674
+ }
1675
+ const [token, flat] = posting;
1676
+ if (!isCanonicalWordIndexToken(token) ||
1677
+ postingTokens.has(token) ||
1678
+ !Array.isArray(flat) ||
1679
+ flat.length === 0 ||
1680
+ flat.length % 2 !== 0) {
1681
+ canonical = false;
1682
+ needsDeepSanitize = true;
1164
1683
  continue;
1165
- const hits = [];
1166
- for (let i = 0; i + 1 < flat.length; i += 2) {
1167
- const file = data.files[flat[i]];
1684
+ }
1685
+ postingTokens.add(token);
1686
+ const lanes = [];
1687
+ const seenPairs = new Set();
1688
+ const observedCounts = new Map();
1689
+ for (let i = 0; i < flat.length; i += 2) {
1690
+ const slot = flat[i];
1168
1691
  const line = flat[i + 1];
1169
- if (typeof file === "string" && typeof line === "number") {
1170
- hits.push({ file, line });
1692
+ if (!isCanonicalWordIndexNumber(slot) ||
1693
+ slot < 0 ||
1694
+ slot >= fileIdBySlot.length ||
1695
+ !isCanonicalWordIndexNumber(line) ||
1696
+ line < 1 ||
1697
+ line > WORD_INDEX_INT32_MAX) {
1698
+ canonical = false;
1699
+ needsDeepSanitize = true;
1700
+ continue;
1701
+ }
1702
+ const fileId = fileIdBySlot[slot];
1703
+ const pairKey = fileId <= WORD_INDEX_PACKED_FILE_ID_MAX
1704
+ ? fileId * (WORD_INDEX_INT32_MAX + 1) + line
1705
+ : `${fileId}:${line}`;
1706
+ if (seenPairs.has(pairKey)) {
1707
+ canonical = false;
1708
+ needsDeepSanitize = true;
1171
1709
  }
1710
+ seenPairs.add(pairKey);
1711
+ lanes.push(fileId, line);
1712
+ observedCounts.set(fileId, (observedCounts.get(fileId) ?? 0) + 1);
1172
1713
  }
1173
- if (hits.length > 0)
1174
- postings.set(token, hits);
1714
+ if (lanes.length === 0)
1715
+ continue;
1716
+ const list = WordPostingList.fromLanes(token, lanes);
1717
+ postings.set(token, list);
1718
+ postingFileCounts.set(token, observedCounts);
1175
1719
  }
1720
+ // Every list above was sized exactly from the wire payload, so there is no
1721
+ // growth slack to release — but the per-list `ArrayBuffer` headers are the
1722
+ // same third-of-the-store cost a fresh build pays, and a deserialized index
1723
+ // is the LONG-lived one (a warm session reuses it across turns).
1724
+ compactPostingsIntoArena(postings);
1176
1725
  let forward;
1177
1726
  if (Array.isArray(data.forward)) {
1178
- forward = new PathKeyedMap(wordIndexKey);
1179
- for (const entry of data.forward) {
1180
- if (!Array.isArray(entry) || entry.length !== 2)
1727
+ const candidate = data.files.map(() => new Map());
1728
+ const forwardTotals = new Map();
1729
+ if (data.forward.length !== data.files.length)
1730
+ canonical = false;
1731
+ if (data.forward.length !== data.files.length)
1732
+ needsDeepSanitize = true;
1733
+ for (const [entryIndex, entry] of data.forward.entries()) {
1734
+ if (!Array.isArray(entry) || entry.length !== 2) {
1735
+ canonical = false;
1736
+ needsDeepSanitize = true;
1181
1737
  continue;
1738
+ }
1182
1739
  const [fileIdx, tokenCounts] = entry;
1183
- const file = data.files[fileIdx];
1184
- if (typeof file !== "string" || !Array.isArray(tokenCounts))
1740
+ if (!isCanonicalWordIndexNumber(fileIdx) ||
1741
+ fileIdx !== entryIndex ||
1742
+ fileIdx < 0 ||
1743
+ fileIdx >= data.files.length ||
1744
+ !Array.isArray(tokenCounts)) {
1745
+ canonical = false;
1746
+ needsDeepSanitize = true;
1185
1747
  continue;
1186
- const perToken = new Map();
1748
+ }
1187
1749
  for (const pair of tokenCounts) {
1188
- if (!Array.isArray(pair) || pair.length !== 2)
1750
+ if (!Array.isArray(pair) ||
1751
+ pair.length !== 2 ||
1752
+ !isCanonicalWordIndexToken(pair[0]) ||
1753
+ !isCanonicalWordIndexNumber(pair[1]) ||
1754
+ pair[1] < 1 ||
1755
+ pair[1] > WORD_INDEX_INT32_MAX ||
1756
+ candidate[fileIdx].has(pair[0])) {
1757
+ canonical = false;
1758
+ needsDeepSanitize = true;
1189
1759
  continue;
1190
- const [token, count] = pair;
1191
- if (typeof token === "string" && typeof count === "number") {
1192
- perToken.set(token, count);
1193
1760
  }
1761
+ candidate[fileIdx].set(pair[0], pair[1]);
1762
+ forwardTotals.set(pair[0], (forwardTotals.get(pair[0]) ?? 0) + pair[1]);
1194
1763
  }
1195
- forward.set(file, perToken);
1764
+ }
1765
+ let forwardMatchesPostings = true;
1766
+ for (const [token, list] of postings) {
1767
+ if (forwardTotals.get(token) !== list.length) {
1768
+ forwardMatchesPostings = false;
1769
+ }
1770
+ const observed = postingFileCounts.get(token);
1771
+ if (!observed) {
1772
+ forwardMatchesPostings = false;
1773
+ continue;
1774
+ }
1775
+ for (const [fileId, count] of observed) {
1776
+ const slot = slotByFileId.get(fileId);
1777
+ if (slot === undefined || candidate[slot].get(token) !== count) {
1778
+ forwardMatchesPostings = false;
1779
+ }
1780
+ }
1781
+ }
1782
+ for (const token of forwardTotals.keys()) {
1783
+ if (!postingTokens.has(token))
1784
+ forwardMatchesPostings = false;
1785
+ }
1786
+ if (!forwardMatchesPostings)
1787
+ canonical = false;
1788
+ if (!forwardMatchesPostings)
1789
+ needsDeepSanitize = true;
1790
+ forward = new PathKeyedMap(wordIndexKey);
1791
+ for (let i = 0; i < data.files.length; i += 1) {
1792
+ forward.set(data.files[i], WordForwardEntry.fromTally(candidate[i]));
1196
1793
  }
1197
1794
  }
1198
- return {
1795
+ else if (Object.prototype.hasOwnProperty.call(data, "forward")) {
1796
+ canonical = false;
1797
+ needsDeepSanitize = true;
1798
+ }
1799
+ if (needsDeepSanitize) {
1800
+ const actualForwardCounts = data.files.map(() => new Map());
1801
+ for (const [token, list] of postings) {
1802
+ const seenPairs = new Set();
1803
+ const sanitized = new WordPostingList(token, list.length);
1804
+ for (let i = 0; i < list.length; i += 1) {
1805
+ const fileId = list.fileIdAt(i);
1806
+ const line = list.lineAt(i);
1807
+ const pairKey = `${fileId}:${line}`;
1808
+ if (seenPairs.has(pairKey))
1809
+ continue;
1810
+ seenPairs.add(pairKey);
1811
+ sanitized.push(fileId, line);
1812
+ const slot = slotByFileId.get(fileId);
1813
+ if (slot !== undefined) {
1814
+ const counts = actualForwardCounts[slot];
1815
+ counts.set(token, (counts.get(token) ?? 0) + 1);
1816
+ }
1817
+ }
1818
+ postings.set(token, sanitized);
1819
+ }
1820
+ compactPostingsIntoArena(postings);
1821
+ if (Array.isArray(data.forward)) {
1822
+ forward = new PathKeyedMap(wordIndexKey);
1823
+ for (let i = 0; i < data.files.length; i += 1) {
1824
+ const canonicalSlot = slotByFileId.get(fileIdBySlot[i]) ?? i;
1825
+ forward.set(data.files[i], WordForwardEntry.fromTally(actualForwardCounts[canonicalSlot]));
1826
+ }
1827
+ }
1828
+ }
1829
+ const expectedTotalTokens = [...docLengths.values()].reduce((total, length) => total + length, 0);
1830
+ const totalTokens = isCanonicalWordIndexNumber(expectedTotalTokens)
1831
+ ? expectedTotalTokens
1832
+ : 0;
1833
+ if (!isCanonicalWordIndexNumber(data.totalTokens) ||
1834
+ data.totalTokens !== totalTokens) {
1835
+ canonical = false;
1836
+ }
1837
+ if (typeof data.indexedFileCount !== "number" ||
1838
+ data.indexedFileCount !== docLengths.size) {
1839
+ canonical = false;
1840
+ }
1841
+ if (typeof data.truncated !== "boolean")
1842
+ canonical = false;
1843
+ const index = {
1199
1844
  postings,
1845
+ fileTable,
1200
1846
  docLengths,
1201
- totalTokens: typeof data.totalTokens === "number" ? data.totalTokens : 0,
1202
- docCount: data.files.length,
1847
+ totalTokens,
1848
+ docCount: docLengths.size,
1203
1849
  truncated: data.truncated === true,
1204
1850
  forward,
1205
1851
  fileMtimes,
1206
1852
  fileSizes,
1853
+ // Postings were just packed into one arena above, so the running gate
1854
+ // starts from the exact post-compaction store count (#2117).
1855
+ postingStoreCount: countPostingBackingStores(postings),
1856
+ recompactFlight: createSingleFlight(),
1857
+ // A deserialized index is a fresh object distinct from whichever index
1858
+ // produced `data`. Without this, every per-edit dirty mark on a
1859
+ // session-start reload (#2068) is a no-op against `undefined`, and
1860
+ // serializeWordIndexIncrementally's `!dirty` branch then returns the
1861
+ // stale cached wire view forever, silently dropping every later edit
1862
+ // from the persisted snapshot.
1863
+ dirtyFiles: new Set(),
1207
1864
  };
1865
+ // Seed the wire cache from the snapshot just loaded so the FIRST persist
1866
+ // of a reloaded session (session-start's `snapshotSaveSyncMs`, #2068) also
1867
+ // takes the bounded incremental path instead of paying a full rebuild —
1868
+ // mirrors exactly what one priming `serializeWordIndex` call would cache.
1869
+ const tokensByFile = new Map();
1870
+ if (forward) {
1871
+ for (const file of data.files) {
1872
+ tokensByFile.set(wordIndexKey(file), new Set(forward.get(file)?.keys() ?? []));
1873
+ }
1874
+ }
1875
+ if (canonical) {
1876
+ serializedWordIndexCaches.set(index, {
1877
+ serialized: data,
1878
+ slotByFileId,
1879
+ tokensByFile,
1880
+ });
1881
+ }
1882
+ return index;
1208
1883
  }
1209
1884
  const buildStatuses = new Map();
1210
1885
  export function getWordIndexBuildStatus(cwd) {
@@ -1278,6 +1953,8 @@ export function triggerBackgroundWordIndexBuild(cwd, dbg, options = {}) {
1278
1953
  durationMs: Date.now() - startMs,
1279
1954
  indexedFileCount: index.docCount,
1280
1955
  tokens: index.postings.size,
1956
+ postingEntries: countWordIndexPostingEntries(index),
1957
+ residentBytes: estimateWordIndexResidentBytes(index),
1281
1958
  truncated: index.truncated,
1282
1959
  skipped: docs.skipped,
1283
1960
  });
@@ -1332,6 +2009,7 @@ function getWordIndexPersistScheduler() {
1332
2009
  return wordIndexPersistScheduler;
1333
2010
  }
1334
2011
  async function writeWordIndexSnapshot(cwd, index, dbg) {
2012
+ const persistStartedAt = Date.now();
1335
2013
  try {
1336
2014
  const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
1337
2015
  const existing = loadProjectSnapshot(cwd);
@@ -1346,8 +2024,11 @@ async function writeWordIndexSnapshot(cwd, index, dbg) {
1346
2024
  cachedExports: [],
1347
2025
  };
1348
2026
  snapshot.generatedAt = new Date().toISOString();
2027
+ const serializeStartedAt = performance.now();
1349
2028
  snapshot.wordIndex = serializeWordIndex(index);
2029
+ const serializeMs = performance.now() - serializeStartedAt;
1350
2030
  saveProjectSnapshot(cwd, snapshot);
2031
+ const writeMs = performance.now() - serializeStartedAt - serializeMs;
1351
2032
  dbg?.(`word-index persist: ${index.docCount} files, ${index.postings.size} tokens`);
1352
2033
  // #958 review F1: durably record persist SUCCESS too, not just failures —
1353
2034
  // in the MCP host (`dbg` is a no-op) this is the only signal that the index
@@ -1357,9 +2038,18 @@ async function writeWordIndexSnapshot(cwd, index, dbg) {
1357
2038
  phase: "persist_succeeded",
1358
2039
  cwd: path.resolve(cwd),
1359
2040
  trigger: "per_edit",
2041
+ durationMs: Date.now() - persistStartedAt,
2042
+ serializeMs,
2043
+ writeMs,
1360
2044
  indexedFileCount: index.docCount,
1361
2045
  tokens: index.postings.size,
2046
+ postingEntries: countWordIndexPostingEntries(index),
2047
+ residentBytes: estimateWordIndexResidentBytes(index),
2048
+ replacementCount: index.replacementStats?.count ?? 0,
2049
+ totalReplacementMs: index.replacementStats?.totalMs ?? 0,
2050
+ maxReplacementMs: index.replacementStats?.maxMs ?? 0,
1362
2051
  });
2052
+ index.replacementStats = { count: 0, totalMs: 0, maxMs: 0 };
1363
2053
  }
1364
2054
  catch (err) {
1365
2055
  dbg?.(`word-index persist: failed: ${err}`);