pi-lens 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (650) hide show
  1. package/CHANGELOG.md +1064 -0
  2. package/README.md +115 -25
  3. package/config/dependency-cruiser-eager-allowlist.json +6 -2
  4. package/dist/chunk-22AVIGXS.js +560 -0
  5. package/dist/chunk-27BBHSZC.js +106 -0
  6. package/dist/chunk-2BEMIWEW.js +51 -0
  7. package/dist/chunk-2EECBNC5.js +89 -0
  8. package/dist/chunk-34U7PLGE.js +23 -0
  9. package/dist/chunk-3I5QFT3N.js +181 -0
  10. package/dist/chunk-3MED75HT.js +37 -0
  11. package/dist/chunk-47D2BO4S.js +87 -0
  12. package/dist/chunk-4RERDEXR.js +348 -0
  13. package/dist/chunk-4W2PLC4J.js +27 -0
  14. package/dist/chunk-52H5B5TC.js +202 -0
  15. package/dist/chunk-54B42YRX.js +27 -0
  16. package/dist/chunk-5RXBNSW2.js +266 -0
  17. package/dist/chunk-5THXD6X5.js +201 -0
  18. package/dist/chunk-5W3R6LFT.js +24 -0
  19. package/dist/chunk-5ZEJHV35.js +341 -0
  20. package/dist/chunk-6P62QVEA.js +118 -0
  21. package/dist/chunk-6QIUX4EP.js +459 -0
  22. package/dist/chunk-6VCD4VKZ.js +78 -0
  23. package/dist/chunk-7F72PXY2.js +326 -0
  24. package/dist/chunk-7FBF7NPR.js +998 -0
  25. package/dist/chunk-7U2IDNXN.js +37 -0
  26. package/dist/chunk-ABBOA7UD.js +157 -0
  27. package/dist/chunk-AHDW3GPE.js +50 -0
  28. package/dist/chunk-AJ3QXTRQ.js +756 -0
  29. package/dist/chunk-AJVLWNFN.js +1610 -0
  30. package/dist/chunk-AJXODZY6.js +112 -0
  31. package/dist/chunk-ARFCXG7S.js +84 -0
  32. package/dist/chunk-AS44CFQ4.js +708 -0
  33. package/dist/chunk-AWE5TMPO.js +38 -0
  34. package/dist/chunk-B3I2UUT4.js +4692 -0
  35. package/dist/chunk-BRJKOBZP.js +331 -0
  36. package/dist/chunk-BW5KFIQC.js +59 -0
  37. package/dist/chunk-C2AUYXQH.js +149 -0
  38. package/dist/chunk-CGGAIP7J.js +29 -0
  39. package/dist/chunk-D6QS3U6M.js +30 -0
  40. package/dist/chunk-DFVMIKMQ.js +349 -0
  41. package/dist/chunk-DIULIWKU.js +169 -0
  42. package/dist/chunk-DQ2NZ7C4.js +2317 -0
  43. package/dist/chunk-E2WFBRHM.js +213 -0
  44. package/dist/chunk-E4MAEDPM.js +50 -0
  45. package/dist/chunk-EOKRQ3SA.js +30 -0
  46. package/dist/chunk-F3AU2TMM.js +2626 -0
  47. package/dist/chunk-F3P7VQ7H.js +170 -0
  48. package/dist/chunk-FC4RP4I2.js +12 -0
  49. package/dist/chunk-FJNKANOS.js +32 -0
  50. package/dist/chunk-GUOGVTCQ.js +146 -0
  51. package/dist/chunk-GVXA6CCE.js +18 -0
  52. package/dist/chunk-HHYAELLJ.js +2334 -0
  53. package/dist/chunk-HMFXILPV.js +83 -0
  54. package/dist/chunk-HVUZXSWK.js +421 -0
  55. package/dist/chunk-HWBZIGMJ.js +62 -0
  56. package/dist/chunk-I3R7GKDR.js +17 -0
  57. package/dist/chunk-I7SPGOWI.js +55 -0
  58. package/dist/chunk-IIXOAGHR.js +38 -0
  59. package/dist/chunk-IVP5KAB7.js +2250 -0
  60. package/dist/chunk-IXEBJSUE.js +22 -0
  61. package/dist/chunk-JOLITGCC.js +27 -0
  62. package/dist/chunk-K6VYLB2U.js +145 -0
  63. package/dist/chunk-LJDODBBZ.js +78 -0
  64. package/dist/chunk-LNAKEO63.js +78 -0
  65. package/dist/chunk-LSMJ6KBE.js +4139 -0
  66. package/dist/chunk-LW6XKDZQ.js +5565 -0
  67. package/dist/chunk-N3YQJI6O.js +899 -0
  68. package/dist/chunk-NABLC7W3.js +148 -0
  69. package/dist/chunk-NKOUFPQF.js +659 -0
  70. package/dist/chunk-NL5GLMFI.js +211 -0
  71. package/dist/chunk-NMABWBZN.js +327 -0
  72. package/dist/chunk-NTHH2J74.js +27 -0
  73. package/dist/chunk-NTUJEGDZ.js +59 -0
  74. package/dist/chunk-NXL4FFQQ.js +47 -0
  75. package/dist/chunk-NXPS2L6U.js +466 -0
  76. package/dist/chunk-O5WILGMI.js +42 -0
  77. package/dist/chunk-O6TQT6RI.js +48 -0
  78. package/dist/chunk-OAXNDZJ7.js +323 -0
  79. package/dist/chunk-ODEEOXPH.js +44 -0
  80. package/dist/chunk-OQGFCZJ4.js +4533 -0
  81. package/dist/chunk-ORTMY4Q4.js +282 -0
  82. package/dist/chunk-OS32RNR2.js +442 -0
  83. package/dist/chunk-PAK6IDME.js +189 -0
  84. package/dist/chunk-PWFHBTSF.js +406 -0
  85. package/dist/chunk-Q5U6FMDI.js +2352 -0
  86. package/dist/chunk-QOZXJ4NA.js +3364 -0
  87. package/dist/chunk-QYF5S6P6.js +45 -0
  88. package/dist/chunk-RIISMHKN.js +36 -0
  89. package/dist/chunk-RPD4ZSB7.js +25 -0
  90. package/dist/chunk-RPD5K5NC.js +79 -0
  91. package/dist/chunk-RUOG5D2C.js +262 -0
  92. package/dist/chunk-T4ERT73Y.js +599 -0
  93. package/dist/chunk-T6D4Q4AQ.js +54 -0
  94. package/dist/chunk-TGPXLY2J.js +391 -0
  95. package/dist/chunk-TLGFBM3F.js +12116 -0
  96. package/dist/chunk-TO6WL3GM.js +88 -0
  97. package/dist/chunk-TT22N74J.js +517 -0
  98. package/dist/chunk-TVN5N6GY.js +21216 -0
  99. package/dist/chunk-U3VGR4VU.js +308 -0
  100. package/dist/chunk-UK3CMEAL.js +1250 -0
  101. package/dist/chunk-UOPVJQ4N.js +103 -0
  102. package/dist/chunk-UXVNL3D7.js +23 -0
  103. package/dist/chunk-UY752QSN.js +45 -0
  104. package/dist/chunk-V5PXFSZK.js +188 -0
  105. package/dist/chunk-V7R425XG.js +398 -0
  106. package/dist/chunk-VCSRSYLF.js +81 -0
  107. package/dist/chunk-VD4JQCQC.js +162 -0
  108. package/dist/chunk-VDBPVIKD.js +62 -0
  109. package/dist/chunk-VHQLPIRJ.js +114 -0
  110. package/dist/chunk-VN2AXLEX.js +5414 -0
  111. package/dist/chunk-VNMT7C64.js +478 -0
  112. package/dist/chunk-WONERJTP.js +309 -0
  113. package/dist/chunk-XECLWQZN.js +85 -0
  114. package/dist/chunk-XIMYZ3JA.js +27 -0
  115. package/dist/chunk-XK4CIILI.js +144 -0
  116. package/dist/chunk-Y4T27COV.js +562 -0
  117. package/dist/chunk-YGVYKNSN.js +1860 -0
  118. package/dist/chunk-YLF3PPT5.js +308 -0
  119. package/dist/chunk-YSNFMJKN.js +14 -0
  120. package/dist/chunk-Z3CCVDUB.js +76 -0
  121. package/dist/chunk-Z54ZSOBZ.js +61 -0
  122. package/dist/chunk-ZAWW3AF6.js +97 -0
  123. package/dist/chunk-ZMOL6RQC.js +167 -0
  124. package/dist/index.js +16456 -8335
  125. package/dist/mcp/analyze-cli.js +186 -232
  126. package/dist/mcp/cli.js +137 -98
  127. package/dist/mcp/server.js +22589 -1573
  128. package/dist/mcp/worker.js +97 -38
  129. package/dist/probes/installer.js +135 -0
  130. package/dist/workers/project-snapshot-persist-worker.js +22 -0
  131. package/dist/workers/review-graph-persist-worker.js +15 -0
  132. package/docs/agent-guide.md +13 -4
  133. package/docs/agent-tools.md +42 -0
  134. package/docs/ast-grep_rules_catalog.md +3 -2
  135. package/docs/configuration.md +20 -1
  136. package/docs/environment-variables.md +52 -2
  137. package/docs/features.md +8 -4
  138. package/docs/globalconfig.md +1 -0
  139. package/docs/io-bridge-v2-migration.md +94 -0
  140. package/docs/lsp-capability-matrix.md +36 -4
  141. package/docs/lsp-idle-eviction.md +124 -0
  142. package/docs/mcp.md +21 -5
  143. package/docs/merge-queue-rollout.md +77 -0
  144. package/docs/pi-lens-fixer.md +156 -88
  145. package/docs/pi-lens-investigator.md +74 -26
  146. package/docs/pi-lens-merge-policy.md +694 -0
  147. package/docs/pi-lens-monitor.md +73 -7
  148. package/docs/pi-lens-retro.md +6 -4
  149. package/docs/pi-lens-reviewer.md +188 -48
  150. package/docs/pi-lens-subagent.md +246 -45
  151. package/docs/pi-lens-warden.md +18 -7
  152. package/docs/public-api-stability.md +55 -0
  153. package/docs/release-qa-baseline.md +84 -17
  154. package/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  155. package/docs/servercapabilities.md +53 -49
  156. package/docs/settings.md +1 -0
  157. package/docs/usage.md +5 -1
  158. package/grammars/tree-sitter-bash.wasm +0 -0
  159. package/grammars/tree-sitter-bash.wasm.json +3 -3
  160. package/package.json +19 -7
  161. package/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  162. package/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  163. package/scripts/analyze-pi-lens-logs.mjs +1275 -62
  164. package/scripts/download-grammars.js +5 -0
  165. package/scripts/grammars.lock.json +6 -1
  166. package/scripts/install-selftest.mjs +33 -34
  167. package/scripts/lib/host-provided-deps.mjs +13 -4
  168. package/scripts/lib/web-tree-sitter-dir.mjs +18 -0
  169. package/dist/clients/actionable-warnings-logger.js +0 -35
  170. package/dist/clients/actionable-warnings.js +0 -1610
  171. package/dist/clients/advisory-provenance.js +0 -593
  172. package/dist/clients/agent-behavior-client.js +0 -147
  173. package/dist/clients/agent-nudge.js +0 -325
  174. package/dist/clients/analysed-root.js +0 -1
  175. package/dist/clients/ast-grep-client.js +0 -731
  176. package/dist/clients/ast-grep-rule-manager.js +0 -146
  177. package/dist/clients/ast-grep-tool-logger.js +0 -118
  178. package/dist/clients/ast-grep-types.js +0 -10
  179. package/dist/clients/ast-grep-yaml-synth.js +0 -154
  180. package/dist/clients/atomic-write-staging.js +0 -75
  181. package/dist/clients/atomic-write.js +0 -189
  182. package/dist/clients/availability-probe-flight.js +0 -12
  183. package/dist/clients/bash-file-access.js +0 -1056
  184. package/dist/clients/biome-client.js +0 -357
  185. package/dist/clients/blocker-freshness.js +0 -837
  186. package/dist/clients/blocker-past-eof.js +0 -129
  187. package/dist/clients/bootstrap.js +0 -569
  188. package/dist/clients/bounded-cache.js +0 -190
  189. package/dist/clients/bounded-pid-file-lock.js +0 -202
  190. package/dist/clients/bounded-telemetry.js +0 -295
  191. package/dist/clients/build-identity.js +0 -95
  192. package/dist/clients/bundled-resource-health.js +0 -113
  193. package/dist/clients/bus-events-logger.js +0 -105
  194. package/dist/clients/bus-publish.js +0 -171
  195. package/dist/clients/cache/rule-cache.js +0 -190
  196. package/dist/clients/cache-manager.js +0 -524
  197. package/dist/clients/cache-observability.js +0 -1313
  198. package/dist/clients/call-graph.js +0 -852
  199. package/dist/clients/cargo-manifest.js +0 -422
  200. package/dist/clients/cascade-budget.js +0 -196
  201. package/dist/clients/cascade-format.js +0 -315
  202. package/dist/clients/cascade-logger.js +0 -31
  203. package/dist/clients/cascade-types.js +0 -9
  204. package/dist/clients/child-unref.js +0 -124
  205. package/dist/clients/code-quality-warnings.js +0 -286
  206. package/dist/clients/codebase-model.js +0 -173
  207. package/dist/clients/collateral-test-role.js +0 -40
  208. package/dist/clients/complexity-client.js +0 -462
  209. package/dist/clients/config-core/deny.js +0 -221
  210. package/dist/clients/config-core/index.js +0 -50
  211. package/dist/clients/config-core/merge.js +0 -357
  212. package/dist/clients/config-core/normalize.js +0 -340
  213. package/dist/clients/config-core/process-spec.js +0 -248
  214. package/dist/clients/config-core/provenance.js +0 -164
  215. package/dist/clients/config-core/records.js +0 -218
  216. package/dist/clients/config-core/resolve.js +0 -125
  217. package/dist/clients/config-core/safe-object.js +0 -78
  218. package/dist/clients/config-core/schema.js +0 -165
  219. package/dist/clients/config-diagnostic-codes.js +0 -285
  220. package/dist/clients/config-locations.js +0 -388
  221. package/dist/clients/config-resolve.js +0 -798
  222. package/dist/clients/config-schema.js +0 -251
  223. package/dist/clients/config-warn.js +0 -407
  224. package/dist/clients/console-guard-install.js +0 -26
  225. package/dist/clients/cooperative-budget.js +0 -42
  226. package/dist/clients/dead-code-client.js +0 -510
  227. package/dist/clients/dead-code-logger.js +0 -30
  228. package/dist/clients/deadline-utils.js +0 -280
  229. package/dist/clients/debug-handles.js +0 -211
  230. package/dist/clients/debug-heap.js +0 -174
  231. package/dist/clients/deferred-lsp-work.js +0 -106
  232. package/dist/clients/degradation-ledger.js +0 -426
  233. package/dist/clients/demoted-finding-render.js +0 -169
  234. package/dist/clients/dependency-checker.js +0 -885
  235. package/dist/clients/deps/ast-grep-napi.js +0 -22
  236. package/dist/clients/deps/js-yaml.js +0 -9
  237. package/dist/clients/deps/minimatch.js +0 -4
  238. package/dist/clients/deps/pi-tui.js +0 -14
  239. package/dist/clients/deps/typebox.js +0 -14
  240. package/dist/clients/deps/vscode-jsonrpc.js +0 -1
  241. package/dist/clients/deps/web-tree-sitter.js +0 -24
  242. package/dist/clients/diagnostic-dispositions.js +0 -630
  243. package/dist/clients/diagnostic-line-freshness.js +0 -305
  244. package/dist/clients/diagnostic-logger.js +0 -73
  245. package/dist/clients/diagnostic-tracker.js +0 -105
  246. package/dist/clients/diagnostics-publish.js +0 -230
  247. package/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  248. package/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  249. package/dist/clients/dispatch/collect-later-tier.js +0 -35
  250. package/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  251. package/dist/clients/dispatch/dispatcher.js +0 -1269
  252. package/dist/clients/dispatch/fact-provider-types.js +0 -1
  253. package/dist/clients/dispatch/fact-rule-runner.js +0 -17
  254. package/dist/clients/dispatch/fact-runner.js +0 -30
  255. package/dist/clients/dispatch/fact-scheduler.js +0 -68
  256. package/dist/clients/dispatch/fact-store.js +0 -377
  257. package/dist/clients/dispatch/facts/comment-facts.js +0 -22
  258. package/dist/clients/dispatch/facts/file-content.js +0 -19
  259. package/dist/clients/dispatch/facts/function-facts.js +0 -348
  260. package/dist/clients/dispatch/facts/import-facts.js +0 -204
  261. package/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  262. package/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  263. package/dist/clients/dispatch/finding-policy.js +0 -339
  264. package/dist/clients/dispatch/indent-detect.js +0 -417
  265. package/dist/clients/dispatch/inline-suppressions.js +0 -89
  266. package/dist/clients/dispatch/integration.js +0 -2377
  267. package/dist/clients/dispatch/lazy.js +0 -11
  268. package/dist/clients/dispatch/pending-runner-findings.js +0 -87
  269. package/dist/clients/dispatch/plan.js +0 -278
  270. package/dist/clients/dispatch/priorities.js +0 -20
  271. package/dist/clients/dispatch/rule-id-normalize.js +0 -50
  272. package/dist/clients/dispatch/rule-ignores.js +0 -69
  273. package/dist/clients/dispatch/rule-policy.js +0 -135
  274. package/dist/clients/dispatch/rules/async-noise.js +0 -40
  275. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  276. package/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  277. package/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  278. package/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  279. package/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  280. package/dist/clients/dispatch/rules/high-complexity.js +0 -72
  281. package/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  282. package/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  283. package/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  284. package/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  285. package/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  286. package/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  287. package/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  288. package/dist/clients/dispatch/runner-context.js +0 -52
  289. package/dist/clients/dispatch/runners/actionlint.js +0 -111
  290. package/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  291. package/dist/clients/dispatch/runners/biome-check.js +0 -296
  292. package/dist/clients/dispatch/runners/cpp-check.js +0 -219
  293. package/dist/clients/dispatch/runners/credo.js +0 -82
  294. package/dist/clients/dispatch/runners/cue-vet.js +0 -416
  295. package/dist/clients/dispatch/runners/dart-analyze.js +0 -183
  296. package/dist/clients/dispatch/runners/detekt.js +0 -176
  297. package/dist/clients/dispatch/runners/dotnet-build.js +0 -164
  298. package/dist/clients/dispatch/runners/elixir-check.js +0 -195
  299. package/dist/clients/dispatch/runners/eslint.js +0 -137
  300. package/dist/clients/dispatch/runners/fact-rules.js +0 -29
  301. package/dist/clients/dispatch/runners/fish-indent.js +0 -69
  302. package/dist/clients/dispatch/runners/gleam-check.js +0 -167
  303. package/dist/clients/dispatch/runners/go-vet.js +0 -104
  304. package/dist/clients/dispatch/runners/golangci-lint.js +0 -135
  305. package/dist/clients/dispatch/runners/hadolint.js +0 -91
  306. package/dist/clients/dispatch/runners/helm-lint.js +0 -171
  307. package/dist/clients/dispatch/runners/helm-render.js +0 -940
  308. package/dist/clients/dispatch/runners/htmlhint.js +0 -117
  309. package/dist/clients/dispatch/runners/index.js +0 -108
  310. package/dist/clients/dispatch/runners/javac.js +0 -99
  311. package/dist/clients/dispatch/runners/ktlint.js +0 -110
  312. package/dist/clients/dispatch/runners/lsp.js +0 -379
  313. package/dist/clients/dispatch/runners/markdownlint.js +0 -167
  314. package/dist/clients/dispatch/runners/mypy.js +0 -102
  315. package/dist/clients/dispatch/runners/oxlint.js +0 -414
  316. package/dist/clients/dispatch/runners/php-lint.js +0 -85
  317. package/dist/clients/dispatch/runners/phpstan.js +0 -120
  318. package/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  319. package/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  320. package/dist/clients/dispatch/runners/pyright.js +0 -169
  321. package/dist/clients/dispatch/runners/rubocop.js +0 -99
  322. package/dist/clients/dispatch/runners/ruff.js +0 -88
  323. package/dist/clients/dispatch/runners/rust-clippy.js +0 -256
  324. package/dist/clients/dispatch/runners/shellcheck.js +0 -173
  325. package/dist/clients/dispatch/runners/shfmt.js +0 -102
  326. package/dist/clients/dispatch/runners/spellcheck.js +0 -128
  327. package/dist/clients/dispatch/runners/spotbugs.js +0 -259
  328. package/dist/clients/dispatch/runners/sqlfluff.js +0 -183
  329. package/dist/clients/dispatch/runners/stylelint.js +0 -166
  330. package/dist/clients/dispatch/runners/swiftlint.js +0 -163
  331. package/dist/clients/dispatch/runners/taplo.js +0 -174
  332. package/dist/clients/dispatch/runners/terragrunt.js +0 -168
  333. package/dist/clients/dispatch/runners/tflint.js +0 -125
  334. package/dist/clients/dispatch/runners/tree-sitter.js +0 -643
  335. package/dist/clients/dispatch/runners/trivy-config.js +0 -253
  336. package/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  337. package/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  338. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -95
  339. package/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  340. package/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  341. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  342. package/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  343. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  344. package/dist/clients/dispatch/runners/utils.js +0 -15
  345. package/dist/clients/dispatch/runners/vale.js +0 -128
  346. package/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  347. package/dist/clients/dispatch/runners/yamllint.js +0 -95
  348. package/dist/clients/dispatch/runners/zig-check.js +0 -79
  349. package/dist/clients/dispatch/suppress-writer.js +0 -79
  350. package/dist/clients/dispatch/tool-profile.js +0 -30
  351. package/dist/clients/dispatch/types.js +0 -21
  352. package/dist/clients/dispatch/utils/format-utils.js +0 -45
  353. package/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  354. package/dist/clients/disposition-logger.js +0 -32
  355. package/dist/clients/disposition-publish.js +0 -124
  356. package/dist/clients/durable-store.js +0 -97
  357. package/dist/clients/effective-config.js +0 -415
  358. package/dist/clients/env-utils.js +0 -83
  359. package/dist/clients/error-class.js +0 -23
  360. package/dist/clients/eval-timestamp.js +0 -17
  361. package/dist/clients/event-loop-hold.js +0 -274
  362. package/dist/clients/event-loop-monitor.js +0 -250
  363. package/dist/clients/extension-log.js +0 -504
  364. package/dist/clients/extension-mode.js +0 -73
  365. package/dist/clients/feature-hints.js +0 -103
  366. package/dist/clients/file-kinds.js +0 -412
  367. package/dist/clients/file-role.js +0 -134
  368. package/dist/clients/file-time.js +0 -160
  369. package/dist/clients/file-utils.js +0 -1032
  370. package/dist/clients/finding-delivery-gate.js +0 -494
  371. package/dist/clients/finding-identity.js +0 -88
  372. package/dist/clients/fix-worklog.js +0 -103
  373. package/dist/clients/format-events-publish.js +0 -343
  374. package/dist/clients/format-service.js +0 -222
  375. package/dist/clients/formatters-lazy.js +0 -9
  376. package/dist/clients/formatters.js +0 -2032
  377. package/dist/clients/freshness-cadence.js +0 -17
  378. package/dist/clients/freshness.js +0 -38
  379. package/dist/clients/generated-artifacts.js +0 -394
  380. package/dist/clients/generation-guard.js +0 -215
  381. package/dist/clients/git-guard.js +0 -1136
  382. package/dist/clients/git-tracked-ignore.js +0 -243
  383. package/dist/clients/gitleaks-client.js +0 -509
  384. package/dist/clients/go-client.js +0 -104
  385. package/dist/clients/govulncheck-client.js +0 -605
  386. package/dist/clients/gradle-ktfmt-style.js +0 -348
  387. package/dist/clients/grammar-source.js +0 -465
  388. package/dist/clients/gzip-stage-write.js +0 -135
  389. package/dist/clients/hashline-anchor.js +0 -424
  390. package/dist/clients/hook-budgets.js +0 -76
  391. package/dist/clients/host-edit-normalize.js +0 -140
  392. package/dist/clients/host-ports.js +0 -39
  393. package/dist/clients/indent-retarget.js +0 -154
  394. package/dist/clients/inline-blocker-dispositions.js +0 -186
  395. package/dist/clients/install-diagnostics.js +0 -156
  396. package/dist/clients/installer/index.js +0 -5416
  397. package/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  398. package/dist/clients/installer/managed-tool-refresh.js +0 -783
  399. package/dist/clients/instance-reaper-state.js +0 -7
  400. package/dist/clients/instance-reaper.js +0 -1137
  401. package/dist/clients/instance-registry-lock.js +0 -145
  402. package/dist/clients/instance-registry.js +0 -773
  403. package/dist/clients/jscpd-client.js +0 -378
  404. package/dist/clients/json-cache-read.js +0 -65
  405. package/dist/clients/knip-client.js +0 -788
  406. package/dist/clients/language-policy.js +0 -252
  407. package/dist/clients/language-profile.js +0 -361
  408. package/dist/clients/language-registry.js +0 -603
  409. package/dist/clients/latency-logger.js +0 -727
  410. package/dist/clients/lazy-import.js +0 -21
  411. package/dist/clients/ledger-bounds.js +0 -54
  412. package/dist/clients/lens-config.js +0 -404
  413. package/dist/clients/lens-engine.js +0 -358
  414. package/dist/clients/lens-events.js +0 -125
  415. package/dist/clients/lens-flag-registry.js +0 -467
  416. package/dist/clients/lens-map.js +0 -1156
  417. package/dist/clients/live-bus-emitter.js +0 -74
  418. package/dist/clients/log-cleanup.js +0 -222
  419. package/dist/clients/lsp/aggregation.js +0 -178
  420. package/dist/clients/lsp/cascade-tier.js +0 -421
  421. package/dist/clients/lsp/client.js +0 -4593
  422. package/dist/clients/lsp/config.js +0 -691
  423. package/dist/clients/lsp/content-limits.js +0 -41
  424. package/dist/clients/lsp/diagnostic-binding.js +0 -225
  425. package/dist/clients/lsp/document-drift.js +0 -467
  426. package/dist/clients/lsp/edits.js +0 -1219
  427. package/dist/clients/lsp/index.js +0 -8305
  428. package/dist/clients/lsp/inferred-project.js +0 -216
  429. package/dist/clients/lsp/jvm-runtime.js +0 -298
  430. package/dist/clients/lsp/language.js +0 -63
  431. package/dist/clients/lsp/launch.js +0 -746
  432. package/dist/clients/lsp/lombok.js +0 -158
  433. package/dist/clients/lsp/path-utils.js +0 -5
  434. package/dist/clients/lsp/pending-aux-coverage.js +0 -251
  435. package/dist/clients/lsp/position-encoding.js +0 -61
  436. package/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  437. package/dist/clients/lsp/server.js +0 -3290
  438. package/dist/clients/lsp/session-roots.js +0 -192
  439. package/dist/clients/lsp/spawn-history.js +0 -17
  440. package/dist/clients/lsp/sync-kind.js +0 -56
  441. package/dist/clients/lsp/tsserver-sync.js +0 -332
  442. package/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  443. package/dist/clients/lsp/wait-policy/classification.js +0 -85
  444. package/dist/clients/lsp/wait-policy/index.js +0 -10
  445. package/dist/clients/lsp/wait-policy/strategies.js +0 -392
  446. package/dist/clients/lsp/watch-queue.js +0 -61
  447. package/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  448. package/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  449. package/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  450. package/dist/clients/lsp-budget.js +0 -186
  451. package/dist/clients/lsp-document-symbols.js +0 -110
  452. package/dist/clients/lsp-lazy.js +0 -9
  453. package/dist/clients/lsp-mutation.js +0 -396
  454. package/dist/clients/lsp-status.js +0 -59
  455. package/dist/clients/map-with-concurrency.js +0 -40
  456. package/dist/clients/mcp/analyze.js +0 -420
  457. package/dist/clients/mcp/host-shim.js +0 -31
  458. package/dist/clients/mcp/ipc.js +0 -523
  459. package/dist/clients/mcp/review.js +0 -197
  460. package/dist/clients/mcp/session.js +0 -412
  461. package/dist/clients/memory-sampler.js +0 -208
  462. package/dist/clients/message-end-attribution.js +0 -30
  463. package/dist/clients/metrics-client.js +0 -107
  464. package/dist/clients/metrics-history.js +0 -327
  465. package/dist/clients/middle-man-analysis.js +0 -263
  466. package/dist/clients/model-provider.js +0 -53
  467. package/dist/clients/module-report-lsp.js +0 -189
  468. package/dist/clients/module-report.js +0 -2150
  469. package/dist/clients/mutating-tool.js +0 -651
  470. package/dist/clients/mutation-attribution.js +0 -368
  471. package/dist/clients/mutation-bridge.js +0 -240
  472. package/dist/clients/ndjson-logger.js +0 -734
  473. package/dist/clients/observed-mutation-sources.js +0 -101
  474. package/dist/clients/observed-mutation.js +0 -1253
  475. package/dist/clients/oldtext-autopatch.js +0 -128
  476. package/dist/clients/opaque-mutation-scan.js +0 -496
  477. package/dist/clients/opengrep-client.js +0 -333
  478. package/dist/clients/opengrep-config.js +0 -76
  479. package/dist/clients/package-manager.js +0 -607
  480. package/dist/clients/package-root.js +0 -38
  481. package/dist/clients/partial-edit-apply.js +0 -439
  482. package/dist/clients/path-attribution-telemetry.js +0 -43
  483. package/dist/clients/path-keyed-map.js +0 -108
  484. package/dist/clients/path-utils.js +0 -1244
  485. package/dist/clients/performance-report.js +0 -331
  486. package/dist/clients/persist-debounce.js +0 -70
  487. package/dist/clients/persistent-reverify.js +0 -271
  488. package/dist/clients/php-cs-fixer-config.js +0 -119
  489. package/dist/clients/pipeline.js +0 -1480
  490. package/dist/clients/post-autofix-notice.js +0 -37
  491. package/dist/clients/print-mode.js +0 -21
  492. package/dist/clients/probe-home-state.js +0 -228
  493. package/dist/clients/process-bridge.js +0 -66
  494. package/dist/clients/process-singletons.js +0 -210
  495. package/dist/clients/process-snapshot.js +0 -68
  496. package/dist/clients/project-changes.js +0 -181
  497. package/dist/clients/project-conventions.js +0 -171
  498. package/dist/clients/project-diagnostics/cache.js +0 -137
  499. package/dist/clients/project-diagnostics/extractors.js +0 -109
  500. package/dist/clients/project-diagnostics/fresh-fetch.js +0 -668
  501. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  502. package/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  503. package/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  504. package/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  505. package/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  506. package/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  507. package/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  508. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  509. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  510. package/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  511. package/dist/clients/project-diagnostics/scanner.js +0 -520
  512. package/dist/clients/project-diagnostics/types.js +0 -1
  513. package/dist/clients/project-lens-config.js +0 -807
  514. package/dist/clients/project-report.js +0 -701
  515. package/dist/clients/project-scale.js +0 -237
  516. package/dist/clients/project-scan-policy.js +0 -11
  517. package/dist/clients/project-snapshot-fingerprint.js +0 -48
  518. package/dist/clients/project-snapshot-persist-worker.js +0 -20
  519. package/dist/clients/project-snapshot.js +0 -1653
  520. package/dist/clients/project-trust.js +0 -181
  521. package/dist/clients/python-environment.js +0 -217
  522. package/dist/clients/python-provenance.js +0 -639
  523. package/dist/clients/quiet-window-config.js +0 -47
  524. package/dist/clients/quiet-window.js +0 -236
  525. package/dist/clients/read-bridge.js +0 -139
  526. package/dist/clients/read-expansion.js +0 -266
  527. package/dist/clients/read-guard-logger.js +0 -251
  528. package/dist/clients/read-guard-tool-lines.js +0 -1306
  529. package/dist/clients/read-guard.js +0 -1561
  530. package/dist/clients/recent-touches.js +0 -240
  531. package/dist/clients/redact/secrets.js +0 -449
  532. package/dist/clients/resource-sampler.js +0 -628
  533. package/dist/clients/reverse-deps.js +0 -270
  534. package/dist/clients/review-graph/builder.js +0 -5126
  535. package/dist/clients/review-graph/format.js +0 -33
  536. package/dist/clients/review-graph/git-identity.js +0 -163
  537. package/dist/clients/review-graph/import-resolvers.js +0 -481
  538. package/dist/clients/review-graph/persist-worker.js +0 -10
  539. package/dist/clients/review-graph/query.js +0 -244
  540. package/dist/clients/review-graph/revision-drift.js +0 -21
  541. package/dist/clients/review-graph/service.js +0 -96
  542. package/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  543. package/dist/clients/review-graph/symbol-id.js +0 -103
  544. package/dist/clients/review-graph/tsconfig-paths.js +0 -299
  545. package/dist/clients/review-graph/types.js +0 -1
  546. package/dist/clients/review-graph/workspace-modules.js +0 -449
  547. package/dist/clients/review-graph-logger.js +0 -81
  548. package/dist/clients/ruff-client.js +0 -179
  549. package/dist/clients/rules-scanner.js +0 -131
  550. package/dist/clients/run-duration.js +0 -55
  551. package/dist/clients/runtime-agent-end.js +0 -746
  552. package/dist/clients/runtime-config.js +0 -77
  553. package/dist/clients/runtime-context.js +0 -211
  554. package/dist/clients/runtime-coordinator.js +0 -1324
  555. package/dist/clients/runtime-session.js +0 -2485
  556. package/dist/clients/runtime-tool-call.js +0 -1295
  557. package/dist/clients/runtime-tool-result.js +0 -2049
  558. package/dist/clients/runtime-turn.js +0 -3767
  559. package/dist/clients/rust-client.js +0 -97
  560. package/dist/clients/safe-spawn.js +0 -2357
  561. package/dist/clients/sanitize.js +0 -286
  562. package/dist/clients/scan-utils.js +0 -25
  563. package/dist/clients/scratch-tree-policy.js +0 -165
  564. package/dist/clients/search-read-registration.js +0 -94
  565. package/dist/clients/secret-findings.js +0 -114
  566. package/dist/clients/security-scan-client.js +0 -352
  567. package/dist/clients/session-event-guard.js +0 -297
  568. package/dist/clients/session-lifecycle.js +0 -484
  569. package/dist/clients/session-start-observability.js +0 -94
  570. package/dist/clients/session-state-store.js +0 -101
  571. package/dist/clients/session-summary.js +0 -15
  572. package/dist/clients/sessionstart-logger.js +0 -27
  573. package/dist/clients/sg-runner.js +0 -876
  574. package/dist/clients/sgconfig.js +0 -347
  575. package/dist/clients/shared-checkout-guard.js +0 -338
  576. package/dist/clients/single-flight.js +0 -158
  577. package/dist/clients/situational-tool-telemetry.js +0 -108
  578. package/dist/clients/skills-resolver.js +0 -141
  579. package/dist/clients/slow-fs.js +0 -138
  580. package/dist/clients/smells-rollup.js +0 -231
  581. package/dist/clients/source-filter.js +0 -596
  582. package/dist/clients/source-walker.js +0 -211
  583. package/dist/clients/spawn-output-cap.js +0 -120
  584. package/dist/clients/spawn-timeout-cooldown.js +0 -110
  585. package/dist/clients/stale-marker.js +0 -17
  586. package/dist/clients/startup-scan.js +0 -400
  587. package/dist/clients/startup-timing.js +0 -130
  588. package/dist/clients/string-utils.js +0 -33
  589. package/dist/clients/subagent-mode.js +0 -111
  590. package/dist/clients/symbol-containment.js +0 -24
  591. package/dist/clients/symbol-types.js +0 -5
  592. package/dist/clients/test-runner-client.js +0 -2507
  593. package/dist/clients/test-runner-delivery.js +0 -287
  594. package/dist/clients/todo-scanner.js +0 -229
  595. package/dist/clients/tool-agreement.js +0 -423
  596. package/dist/clients/tool-config.js +0 -249
  597. package/dist/clients/tool-cwd.js +0 -270
  598. package/dist/clients/tool-definition.js +0 -47
  599. package/dist/clients/tool-event.js +0 -105
  600. package/dist/clients/tool-policy.js +0 -2755
  601. package/dist/clients/tool-probe.js +0 -53
  602. package/dist/clients/tool-render.js +0 -156
  603. package/dist/clients/tool-set-policy.js +0 -118
  604. package/dist/clients/tree-sitter-cache.js +0 -364
  605. package/dist/clients/tree-sitter-client.js +0 -4195
  606. package/dist/clients/tree-sitter-logger.js +0 -100
  607. package/dist/clients/tree-sitter-navigator.js +0 -269
  608. package/dist/clients/tree-sitter-query-loader.js +0 -569
  609. package/dist/clients/tree-sitter-shared.js +0 -158
  610. package/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  611. package/dist/clients/trivy-client.js +0 -467
  612. package/dist/clients/tui-fit.js +0 -50
  613. package/dist/clients/turn-context.js +0 -52
  614. package/dist/clients/turn-end/lane.js +0 -51
  615. package/dist/clients/turn-end/lanes/govulncheck.js +0 -112
  616. package/dist/clients/turn-end/lanes/secrets.js +0 -204
  617. package/dist/clients/turn-summary-render.js +0 -72
  618. package/dist/clients/turn-summary.js +0 -132
  619. package/dist/clients/typos-config.js +0 -29
  620. package/dist/clients/user-notify.js +0 -51
  621. package/dist/clients/vanished-instance-marker.js +0 -57
  622. package/dist/clients/warm-attach.js +0 -305
  623. package/dist/clients/widget-state.js +0 -1808
  624. package/dist/clients/word-index-logger.js +0 -49
  625. package/dist/clients/word-index-store.js +0 -459
  626. package/dist/clients/word-index.js +0 -2080
  627. package/dist/clients/workspace-topology.js +0 -443
  628. package/dist/clients/write-ordering-guard.js +0 -65
  629. package/dist/clients/zizmor-config.js +0 -340
  630. package/dist/i18n.js +0 -69
  631. package/dist/mcp/build-staleness.js +0 -124
  632. package/dist/scripts/lib/process-scan.mjs +0 -583
  633. package/dist/scripts/lib/skills-predicate.mjs +0 -129
  634. package/dist/scripts/lib/web-tree-sitter-dir.mjs +0 -123
  635. package/dist/tools/activate-tools.js +0 -87
  636. package/dist/tools/ast-grep-outline.js +0 -137
  637. package/dist/tools/ast-grep-replace.js +0 -244
  638. package/dist/tools/ast-grep-search.js +0 -762
  639. package/dist/tools/effective-config.js +0 -90
  640. package/dist/tools/lens-diagnostic-mark.js +0 -375
  641. package/dist/tools/lens-diagnostics.js +0 -2571
  642. package/dist/tools/lsp-diagnostics.js +0 -1745
  643. package/dist/tools/lsp-navigation.js +0 -1367
  644. package/dist/tools/lsp-structured-output.js +0 -294
  645. package/dist/tools/module-report.js +0 -382
  646. package/dist/tools/project-report.js +0 -86
  647. package/dist/tools/render-compact.js +0 -374
  648. package/dist/tools/scan-progress.js +0 -53
  649. package/dist/tools/shared.js +0 -29
  650. package/dist/tools/symbol-search.js +0 -155
@@ -1,2080 +0,0 @@
1
- /**
2
- * Identifier-aware inverted word index + BM25 ranking.
3
- *
4
- * The lexical half of the "codebase mental model + hybrid ranking" ask (#162):
5
- * a deterministic, zero-dep index over source identifiers that answers
6
- * "which files are most relevant to <query>" with BM25 relevance plus a small
7
- * set of priors (demote tests/vendor and doc files) and an optional graph
8
- * centrality boost (importedBy count from the reverse-dependency index). It
9
- * complements LSP/symbol navigation rather than duplicating the host's grep:
10
- * grep finds raw substrings; this ranks files by identifier relevance.
11
- *
12
- * Built from file contents during the session scan, persisted in the project
13
- * snapshot (serialize/deserialize below), and queried via an MCP tool. No
14
- * embeddings, no native deps, no daemon — pure in-process TypeScript.
15
- */
16
- import * as fs from "node:fs";
17
- import * as path from "node:path";
18
- import { createDeadline, forEachCooperatively, yieldIfOverBudget, } from "./cooperative-budget.js";
19
- import { incrementDegradationCount } from "./degradation-ledger.js";
20
- import { KIND_EXTENSIONS } from "./file-kinds.js";
21
- import { isTestFileName } from "./file-role.js";
22
- import { PathKeyedMap } from "./path-keyed-map.js";
23
- import { isAtOrAboveHomeDir, normalizeEphemeralMapKey } from "./path-utils.js";
24
- import { createSingleFlight } from "./single-flight.js";
25
- import { createDebounceScheduler, } from "./persist-debounce.js";
26
- import { getWordIndexMaxFilesDerived } from "./project-scale.js";
27
- import { compactPostingsIntoArena, compactPostingsIntoArenaCooperatively, countPostingBackingStores, countPostingEntries, estimateWordIndexStoreBytes, WordForwardEntry, WordIndexFileTable, WordPostingList, } from "./word-index-store.js";
28
- import { logWordIndex } from "./word-index-logger.js";
29
- /**
30
- * The single normalizer every word-index path key folds through (#1025). The
31
- * CHEAP form — slash-fold + win32-lowercase, NO filesystem I/O — because the
32
- * index is a hot, single-process, in-memory structure whose keys this process
33
- * produces itself (`collectSourceFilesAsync`'s walk, `path.resolve` at the
34
- * per-edit seams). `normalizeMapKey`'s `realpathSync` per call would be wrong on
35
- * the BM25 path. Applied at BOTH the build seam and the per-edit update seam so
36
- * a file's on-disk casing (walk) and its tool-input casing (edit) can no longer
37
- * diverge into a duplicate doc entry with stale postings (the #1025 item #2
38
- * bug). Kept as a named alias so every seam is grep-visible and provably shares
39
- * ONE normalizer with the {@link PathKeyedMap}s below.
40
- */
41
- export const wordIndexKey = normalizeEphemeralMapKey;
42
- // Common language keywords / boilerplate — indexing them adds noise and bloats
43
- // postings without improving relevance. Kept deliberately small and
44
- // language-agnostic.
45
- const STOPWORDS = new Set([
46
- "the",
47
- "and",
48
- "for",
49
- "let",
50
- "var",
51
- "const",
52
- "function",
53
- "return",
54
- "if",
55
- "else",
56
- "import",
57
- "export",
58
- "from",
59
- "class",
60
- "interface",
61
- "type",
62
- "enum",
63
- "new",
64
- "this",
65
- "self",
66
- "void",
67
- "null",
68
- "true",
69
- "false",
70
- "async",
71
- "await",
72
- "public",
73
- "private",
74
- "protected",
75
- "static",
76
- "def",
77
- "fn",
78
- "func",
79
- "struct",
80
- "impl",
81
- "pub",
82
- "use",
83
- "mod",
84
- "in",
85
- "of",
86
- "as",
87
- "is",
88
- "not",
89
- "with",
90
- ]);
91
- // Retain the old directory alternatives and anchors; filename classification
92
- // belongs to file-role (#2928). The relevance multiplier remains 0.3.
93
- const VENDOR_DIR_RE = /(^|[\\/])(?:tests?|__tests__|spec|specs|__mocks__|vendor|node_modules|examples?|fixtures?|\.git|dist|build|coverage)([\\/]|$)/i;
94
- const DOC_FILE_RE = /\.(?:md|mdx|markdown|json|json5|jsonc|txt|rst|lock|ya?ml|toml|csv)$/i;
95
- const TEST_VENDOR_PENALTY = 0.3;
96
- const DOC_FILE_PENALTY = 0.5;
97
- const BM25_K1 = 1.2;
98
- const BM25_B = 0.75;
99
- function isTestOrVendor(file) {
100
- return isTestFileName(file) || VENDOR_DIR_RE.test(file);
101
- }
102
- function isDocFile(file) {
103
- return DOC_FILE_RE.test(file);
104
- }
105
- /**
106
- * Split an identifier into lowercased sub-tokens across camelCase, PascalCase,
107
- * snake_case, kebab-case, dotted, and digit boundaries — and keep the whole
108
- * lowercased identifier too. `getUserByID` → [getuserbyid, get, user, by, id];
109
- * `MAX_RETRY_2` → [max_retry_2, max, retry, 2] (whole kept, plus parts).
110
- */
111
- export function splitIdentifier(identifier) {
112
- const parts = new Set();
113
- const whole = identifier.toLowerCase();
114
- if (whole.length >= 2 && !STOPWORDS.has(whole))
115
- parts.add(whole);
116
- for (const chunk of identifier.split(/[^A-Za-z0-9]+/)) {
117
- if (!chunk)
118
- continue;
119
- const spaced = chunk
120
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // camelCase → camel Case
121
- .replace(/[A-Z](?=[A-Z][a-z])/g, "$& ") // HTTPServer → HTTP Server (linear; lookahead avoids super-linear backtracking, S5852)
122
- .replace(/([A-Za-z])([0-9])/g, "$1 $2") // retry2 → retry 2
123
- .replace(/([0-9])([A-Za-z])/g, "$1 $2"); // 2fa → 2 fa
124
- for (const sub of spaced.split(/\s+/)) {
125
- const token = sub.toLowerCase();
126
- if (token.length >= 2 && !STOPWORDS.has(token))
127
- parts.add(token);
128
- }
129
- }
130
- return [...parts];
131
- }
132
- /** Extract identifier-like tokens from a line and split each into sub-tokens. */
133
- export function tokenizeLine(line) {
134
- const tokens = [];
135
- const matches = line.match(/[A-Za-z_$][A-Za-z0-9_$]*/g);
136
- if (!matches)
137
- return tokens;
138
- for (const match of matches) {
139
- for (const token of splitIdentifier(match))
140
- tokens.push(token);
141
- }
142
- return tokens;
143
- }
144
- const WORD_INDEX_BUILD_YIELD_BUDGET_MS = 8;
145
- /**
146
- * A line at or above this length is treated as a yield checkpoint on its own.
147
- * The per-50-lines cadence is a proxy for work that only holds on hand-written
148
- * source: a minified/bundled single-line file up to {@link WORD_INDEX_MAX_BYTES}
149
- * that the source filter did not exclude would otherwise get ZERO in-document
150
- * yields — one unbroken tokenize+push burst far above the budget (#1197 review
151
- * finding 4). Checking the clock after any long line makes the cooperativeness
152
- * claim true per BYTE, not just per line count.
153
- */
154
- const WORD_INDEX_LONG_LINE_YIELD_CHARS = 4096;
155
- /**
156
- * Shared cooperative time-slicer for every bulk word-index path (#1197).
157
- *
158
- * Time-based, not count-based: "yield every N items" is only a bound when the
159
- * per-item cost is bounded too, and the incremental refresh's per-document cost
160
- * grows with the corpus (each replacement filters shared posting arrays). The
161
- * #1197 outage was exactly that shape — 239 stale documents held pi's event loop
162
- * for seconds between two "every 100 files" checkpoints. Callers combine this
163
- * The shared deadline is checked at every bounded work unit so abort latency
164
- * and occupancy are governed by the same monotonic budget.
165
- */
166
- function createEmptyWordIndex(truncated) {
167
- return {
168
- postings: new Map(),
169
- fileTable: new WordIndexFileTable(),
170
- docLengths: new PathKeyedMap(wordIndexKey),
171
- totalTokens: 0,
172
- docCount: 0,
173
- truncated,
174
- forward: new PathKeyedMap(wordIndexKey),
175
- fileMtimes: new PathKeyedMap(wordIndexKey),
176
- fileSizes: new PathKeyedMap(wordIndexKey),
177
- replacementStats: { count: 0, totalMs: 0, maxMs: 0 },
178
- dirtyFiles: new Set(),
179
- postingStoreCount: 0,
180
- recompactFlight: createSingleFlight(),
181
- };
182
- }
183
- export function countWordIndexPostingEntries(index) {
184
- return countPostingEntries(index);
185
- }
186
- /**
187
- * Estimated resident bytes of the index's two packed stores (#2069).
188
- * Reported on the `full_rebuild`, `incremental_refresh`, and `cold_build`
189
- * word-index log records and on the `memory_sample` word-index subsystem, so
190
- * a heap census can be reconciled against the log without taking a snapshot —
191
- * the gap that let #1999 under-count this subsystem by a factor of sixty.
192
- */
193
- export function estimateWordIndexResidentBytes(index) {
194
- return estimateWordIndexStoreBytes(index);
195
- }
196
- /**
197
- * Record a posting whose file id the file table cannot resolve (#2069).
198
- *
199
- * Unreachable by construction: {@link WordIndexFileTable.release} only runs
200
- * after the forward index has enumerated and removed every posting naming that
201
- * id. If it fires, that invariant broke, and the visible symptom is a query
202
- * returning FEWER results with nothing to distinguish it from a smaller match
203
- * set — the clean-versus-errored ambiguity AGENTS.md catalogs as shape 10.
204
- *
205
- * `incrementDegradationCount` rather than `recordDegradationOnce` because
206
- * ranking is a hot loop: the tally stays exact for every occurrence while only
207
- * the first per (kind, orphaned id) writes a durable row. Subject is the id
208
- * itself, so aggregation keeps the discriminating identity.
209
- */
210
- function recordOrphanWordIndexFileId(fileId, token, seam) {
211
- incrementDegradationCount({
212
- kind: "word-index-orphan-file-id",
213
- subject: `fileId:${fileId}`,
214
- reason: `${seam} dropped a posting for token "${token}": the file table has no path for this id`,
215
- });
216
- }
217
- /**
218
- * Decode one token’s postings into `{ file, line }` objects. Allocates, so it
219
- * is for callers that genuinely need the display form (tests, diagnostics) —
220
- * never for the hot ranking loop, which reads the packed lanes directly.
221
- */
222
- export function wordIndexPostingHits(index, token) {
223
- const list = index.postings.get(token);
224
- if (!list)
225
- return [];
226
- const hits = [];
227
- for (let i = 0; i < list.length; i += 1) {
228
- const fileId = list.fileIdAt(i);
229
- const file = index.fileTable.pathFor(fileId);
230
- if (file === undefined) {
231
- recordOrphanWordIndexFileId(fileId, token, "decode");
232
- continue;
233
- }
234
- hits.push({ file, line: list.lineAt(i) });
235
- }
236
- return hits;
237
- }
238
- /**
239
- * Settle a freshly built index's posting store into its compact resident form
240
- * (#2069): re-home every list into one shared arena, sized from the live entry
241
- * counts. That both releases the doubling growth slack a build leaves behind
242
- * and stops the long tail of rare tokens paying for an `ArrayBuffer` header
243
- * each.
244
- */
245
- function compactWordIndexPostings(index) {
246
- compactPostingsIntoArena(index.postings);
247
- index.postingStoreCount = countPostingBackingStores(index.postings);
248
- }
249
- /** Bump the running distinct-backing-store tally by `count` (#2117). */
250
- function notePostingStoresAllocated(index, count) {
251
- index.postingStoreCount = (index.postingStoreCount ?? 0) + count;
252
- }
253
- /**
254
- * Floor for the recompaction gate: below this many backing stores the arena is
255
- * never repacked, so a small index never pays an O(vocab) walk it cannot amortize
256
- * (#2117). This is also the whole gate for indexes whose vocabulary is under
257
- * `WORD_INDEX_RECOMPACT_STORE_FLOOR / WORD_INDEX_RECOMPACT_STORE_FRACTION`
258
- * tokens, keeping the pre-#2246 behaviour for small corpora bit-identical.
259
- */
260
- const WORD_INDEX_RECOMPACT_STORE_FLOOR = 64;
261
- /**
262
- * Recompaction gate as a share of the live vocabulary (#2246). The flat 64-store
263
- * threshold #2117 shipped was crossed by every single edit: one document
264
- * replacement raises `postingStoreCount` by roughly the edited document's
265
- * distinct-token count (hundreds), so the whole O(vocab) arena was rebuilt after
266
- * every edit. Gating on a fraction of `postings.size` instead lets fragmentation
267
- * accumulate proportionally to the index, so recompaction fires once per many
268
- * edits rather than once per edit.
269
- *
270
- * Memory ceiling: the store count can reach a tenth of the vocabulary in private
271
- * stores before a repack, each carrying a fixed header plus growth slack. #2117
272
- * measured the fully-churned ceiling (every token private, store count =
273
- * vocabulary) at +22% resident. The cost does not scale with the store count
274
- * alone, because the tokens that churn most are also the ones carrying the
275
- * longest posting lists. PR #2246's adversarial hot-token probe measured this
276
- * gate at +13.7% resident at rest and +19.9% peak over base — inside the +22%
277
- * #2117 accepted, but not by the wide margin a naive store-count ratio would
278
- * predict. Raising the fraction further would cross that bound.
279
- */
280
- const WORD_INDEX_RECOMPACT_STORE_FRACTION = 0.1;
281
- /**
282
- * Backing-store count that triggers a repack for `index`: a fixed floor, or a
283
- * share of the live vocabulary once the index is large enough for the share to
284
- * exceed the floor. O(1) — reads `postings.size`, never walks the vocabulary.
285
- */
286
- function wordIndexRecompactThreshold(index) {
287
- return Math.max(WORD_INDEX_RECOMPACT_STORE_FLOOR, Math.floor(index.postings.size * WORD_INDEX_RECOMPACT_STORE_FRACTION));
288
- }
289
- /**
290
- * Pack the arena if churn has spread the postings across more than the
291
- * threshold of backing stores (#2117, #2246). Runs the exact O(vocab) store
292
- * count as the authoritative gate — the per-edit hot path uses the O(1)
293
- * `postingStoreCount` estimate, so this expensive walk only happens inside the
294
- * bounded, off-hot-path recompaction it guards. Resets the estimate to the
295
- * true post-compaction count.
296
- */
297
- async function recompactWordIndexPostingsIfNeeded(index, root) {
298
- const threshold = wordIndexRecompactThreshold(index);
299
- const beforeStores = countPostingBackingStores(index.postings);
300
- if (beforeStores <= threshold) {
301
- index.postingStoreCount = beforeStores;
302
- return;
303
- }
304
- const beforeBytes = estimateWordIndexResidentBytes(index);
305
- await compactPostingsIntoArenaCooperatively(index.postings);
306
- const afterStores = countPostingBackingStores(index.postings);
307
- index.postingStoreCount = afterStores;
308
- const afterBytes = estimateWordIndexResidentBytes(index);
309
- const firstForRoot = incrementDegradationCount({
310
- kind: "word-index-arena-recompact",
311
- subject: path.resolve(root),
312
- reason: `arena store threshold ${threshold} exceeded`,
313
- metadata: { beforeBytes, afterBytes, beforeStores, afterStores },
314
- });
315
- // Keep the detailed record bounded per root. The ledger still counts every
316
- // repeated recompaction and emits its own power-of-two summaries.
317
- if (firstForRoot) {
318
- logWordIndex({
319
- phase: "incremental_refresh",
320
- cwd: path.resolve(root),
321
- trigger: "incremental_refresh",
322
- reason: `arena_recompact beforeBytes=${beforeBytes} afterBytes=${afterBytes} beforeStores=${beforeStores} afterStores=${afterStores}`,
323
- });
324
- }
325
- }
326
- /**
327
- * Serialize a recompaction through the per-index async operation queue so no
328
- * async edit or refresh interleaves it (#2117). The queue plus the corruption-
329
- * proof cooperative compactor together close the review-F2 data race: the queue
330
- * excludes async operations, and the compactor's snapshot-and-skip publish
331
- * excludes the synchronous per-edit path the queue cannot see. The per-index
332
- * `recompactFlight` single-flight (#1753) keeps a burst of edits from stacking
333
- * duplicates: a caller that arrives while one recompaction runs joins it rather
334
- * than starting a second. The single slot uses a constant key.
335
- */
336
- function enqueueWordIndexRecompact(index, root) {
337
- if ((index.postingStoreCount ?? 0) <= wordIndexRecompactThreshold(index)) {
338
- return Promise.resolve();
339
- }
340
- const flight = (index.recompactFlight ??= createSingleFlight());
341
- return flight.run("recompact", () => enqueueAsyncWordIndexOperation(index, () => recompactWordIndexPostingsIfNeeded(index, root)));
342
- }
343
- /**
344
- * O(1) hot-path gate for the synchronous per-edit seam. Reads the running
345
- * store-count estimate, never a fresh `Set` over the vocabulary, then defers
346
- * the actual work onto the async queue so it cannot stall the edit (#2117).
347
- */
348
- function scheduleWordIndexRecompact(index, filePath) {
349
- void enqueueWordIndexRecompact(index, path.dirname(filePath));
350
- }
351
- /**
352
- * Test hook: settle any queued arena recompaction for `index`. Draining the
353
- * index's own async operation queue is enough because the recompaction runs
354
- * through it; there is no module-level scheduler to flush.
355
- */
356
- export async function flushWordIndexRecompactionsForTests(index) {
357
- await (asyncWordIndexOperations.get(index) ?? Promise.resolve());
358
- }
359
- function recordWordIndexReplacement(index, startedAt) {
360
- const stats = index.replacementStats ?? { count: 0, totalMs: 0, maxMs: 0 };
361
- const durationMs = Math.max(0, Date.now() - startedAt);
362
- stats.count += 1;
363
- stats.totalMs += durationMs;
364
- stats.maxMs = Math.max(stats.maxMs, durationMs);
365
- index.replacementStats = stats;
366
- }
367
- /** Intern a document path once per index; posting removal compares this id. */
368
- function internWordIndexFile(index, filePath) {
369
- return index.fileTable.intern(wordIndexKey(filePath), filePath);
370
- }
371
- function appendWordIndexPostings(index, filePath, perTokenHits) {
372
- const fileId = internWordIndexFile(index, filePath);
373
- const tokenLineCounts = new Map();
374
- for (const [token, lineNumbers] of perTokenHits) {
375
- let list = index.postings.get(token);
376
- if (list) {
377
- // A grow allocates a fresh private store; count it so the O(1) gate
378
- // tracks fragmentation without re-walking the vocabulary (#2117).
379
- if (list.reserve(list.length + lineNumbers.length)) {
380
- notePostingStoresAllocated(index, 1);
381
- }
382
- }
383
- else {
384
- list = new WordPostingList(token, lineNumbers.length);
385
- index.postings.set(token, list);
386
- notePostingStoresAllocated(index, 1);
387
- }
388
- // `list.token` is the canonical instance, so the forward entry points at
389
- // the same string the postings map is keyed by instead of retaining this
390
- // document’s own tokenizer allocation (#2069).
391
- tokenLineCounts.set(list.token, lineNumbers.length);
392
- for (const line of lineNumbers)
393
- list.push(fileId, line);
394
- }
395
- return tokenLineCounts;
396
- }
397
- function commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt) {
398
- const tokenLineCounts = appendWordIndexPostings(index, doc.path, perTokenHits);
399
- index.docLengths.set(doc.path, docLength);
400
- index.forward.set(doc.path, WordForwardEntry.fromTally(tokenLineCounts));
401
- index.fileMtimes.set(doc.path, -1);
402
- index.fileSizes.set(doc.path, Buffer.byteLength(doc.content, "utf-8"));
403
- index.dirtyFiles?.add(wordIndexKey(doc.path));
404
- index.totalTokens += docLength;
405
- index.docCount += 1;
406
- recordWordIndexReplacement(index, startedAt);
407
- }
408
- function indexWordLine(index, fileId, line, lineNumber, tokenLineCounts) {
409
- const lineTokens = tokenizeLine(line);
410
- const seenOnLine = new Set();
411
- for (const token of lineTokens) {
412
- if (seenOnLine.has(token))
413
- continue;
414
- seenOnLine.add(token);
415
- let list = index.postings.get(token);
416
- if (!list) {
417
- list = new WordPostingList(token, 1);
418
- index.postings.set(token, list);
419
- }
420
- list.push(fileId, lineNumber);
421
- // Canonical instance, not this line’s fresh tokenizer allocation (#2069).
422
- const canonical = list.token;
423
- tokenLineCounts.set(canonical, (tokenLineCounts.get(canonical) ?? 0) + 1);
424
- }
425
- return lineTokens.length;
426
- }
427
- function finishWordIndexDocument(index, doc, docLength, tokenLineCounts) {
428
- index.docLengths.set(doc.path, docLength);
429
- index.forward?.set(doc.path, WordForwardEntry.fromTally(tokenLineCounts));
430
- index.fileMtimes.set(doc.path, doc.mtimeMs ?? 0);
431
- index.fileSizes.set(doc.path, doc.size ?? Buffer.byteLength(doc.content, "utf-8"));
432
- // A build has no prior wire cache. Keeping the marker makes a future
433
- // serializer cache explicit and gives incremental refresh one uniform seam.
434
- index.dirtyFiles?.add(wordIndexKey(doc.path));
435
- index.totalTokens += docLength;
436
- index.docCount += 1;
437
- }
438
- function indexWordDocument(index, doc) {
439
- const fileId = internWordIndexFile(index, doc.path);
440
- const lines = doc.content.split(/\r?\n/);
441
- const tokenLineCounts = new Map();
442
- let docLength = 0;
443
- for (let i = 0; i < lines.length; i += 1) {
444
- docLength += indexWordLine(index, fileId, lines[i], i + 1, tokenLineCounts);
445
- }
446
- finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
447
- }
448
- export function buildWordIndex(files) {
449
- const index = createEmptyWordIndex(files.truncated ?? false);
450
- for (const doc of files)
451
- indexWordDocument(index, doc);
452
- compactWordIndexPostings(index);
453
- return index;
454
- }
455
- /**
456
- * Cooperative production builder for bulk/cold paths. It produces the exact
457
- * same index as {@link buildWordIndex}, but time-slices both between documents
458
- * and within a large document so startup warmup cannot monopolize pi's TUI
459
- * event loop (#1197). The index is private until this resolves, so a superseded
460
- * build never publishes a partial replacement.
461
- */
462
- export async function buildWordIndexAsync(files, shouldContinue = () => true) {
463
- const index = createEmptyWordIndex(files.truncated ?? false);
464
- const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
465
- for (const doc of files) {
466
- if (!shouldContinue())
467
- throw new Error("word index build superseded");
468
- const fileId = internWordIndexFile(index, doc.path);
469
- const lines = doc.content.split(/\r?\n/);
470
- const tokenLineCounts = new Map();
471
- let docLength = 0;
472
- for (let i = 0; i < lines.length; i += 1) {
473
- const line = lines[i];
474
- docLength += indexWordLine(index, fileId, line, i + 1, tokenLineCounts);
475
- if (line.length >= WORD_INDEX_LONG_LINE_YIELD_CHARS ||
476
- deadline.expired()) {
477
- await yieldIfOverBudget(deadline);
478
- if (!shouldContinue())
479
- throw new Error("word index build superseded");
480
- }
481
- }
482
- finishWordIndexDocument(index, doc, docLength, tokenLineCounts);
483
- if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
484
- if (!shouldContinue())
485
- throw new Error("word index build superseded");
486
- }
487
- }
488
- compactWordIndexPostings(index);
489
- return index;
490
- }
491
- /**
492
- * Remove `filePath`'s postings/docLength/forward entry from `index` in place,
493
- * using the forward index to know exactly which tokens to touch (no scan of
494
- * unrelated postings). No-op (returns false) if the index has no forward
495
- * index yet (pre-phase-2 / deserialized-old-shape) or the file isn't present —
496
- * callers must treat `false` as "fall back to a full rebuild", never as
497
- * silent success.
498
- */
499
- export function removeWordIndexDocument(index, filePath) {
500
- if (!index.forward)
501
- return false;
502
- const tokenLineCounts = index.forward.get(filePath);
503
- if (!tokenLineCounts)
504
- return false;
505
- // `postings` is token-keyed (not a PathKeyedMap), so the file identity it
506
- // carries must be resolved through the SAME normalizer the path maps use —
507
- // otherwise a build-form hit (`SUB/a.ts`) survives an edit-form removal
508
- // (`sub/a.ts`) on a case-insensitive FS and lingers as a stale posting
509
- // (the #1025 item #2 bug this fix closes). Since #2069 that identity is the
510
- // file table’s integer id, so the comparison below is an int compare rather
511
- // than a per-element string compare.
512
- const removedKey = wordIndexKey(filePath);
513
- const removedId = index.fileTable.idFor(removedKey);
514
- if (removedId === undefined)
515
- return false;
516
- for (const token of tokenLineCounts.keys()) {
517
- const list = index.postings.get(token);
518
- if (!list)
519
- continue;
520
- const next = list.withoutFile(removedId);
521
- // `withoutFile` always returns a freshly allocated private store (#2117).
522
- if (next.length > 0) {
523
- index.postings.set(token, next);
524
- notePostingStoresAllocated(index, 1);
525
- }
526
- else
527
- index.postings.delete(token);
528
- }
529
- const docLength = index.docLengths.get(filePath) ?? 0;
530
- index.docLengths.delete(filePath);
531
- index.forward.delete(filePath);
532
- index.fileMtimes.delete(filePath);
533
- index.fileSizes.delete(filePath);
534
- index.dirtyFiles?.add(wordIndexKey(filePath));
535
- index.fileTable.release(removedKey);
536
- index.totalTokens -= docLength;
537
- index.docCount = Math.max(0, index.docCount - 1);
538
- return true;
539
- }
540
- /**
541
- * Add or replace `filePath`'s document in `index` in place: removes the prior
542
- * postings for this file (if any, via {@link removeWordIndexDocument}'s
543
- * forward-index lookup) then re-tokenizes `content` and adds the new
544
- * postings/docLength/forward entry. df/N/totalTokens (avgdl) are updated as
545
- * running stats — no full recompute over other documents.
546
- *
547
- * Returns `false` (no-op on `index`) when the index has no forward index —
548
- * the caller must fall back to a full {@link buildWordIndex} rebuild in that
549
- * case (documented at the `forward` field and enforced by callers, not
550
- * silently patched here: a partially-forward-consistent index would corrupt
551
- * future incremental updates).
552
- */
553
- export function updateWordIndexDocument(index, doc) {
554
- if (!index.forward)
555
- return false;
556
- const startedAt = Date.now();
557
- // Remove the old contribution first (no-op if this is a brand new doc).
558
- if (index.forward.has(doc.path)) {
559
- removeWordIndexDocument(index, doc.path);
560
- }
561
- // Tokenize with line numbers attached (needed for WordHit.line) — this also
562
- // yields the forward-index entry (distinct-line count per token) so the
563
- // tokenization work happens exactly once for this document.
564
- const lines = doc.content.split(/\r?\n/);
565
- const perTokenHits = new Map();
566
- let docLength = 0;
567
- for (let i = 0; i < lines.length; i += 1) {
568
- const lineTokens = tokenizeLine(lines[i]);
569
- docLength += lineTokens.length;
570
- const seenOnLine = new Set();
571
- for (const token of lineTokens) {
572
- if (seenOnLine.has(token))
573
- continue;
574
- seenOnLine.add(token);
575
- const arr = perTokenHits.get(token);
576
- if (arr)
577
- arr.push(i + 1);
578
- else
579
- perTokenHits.set(token, [i + 1]);
580
- }
581
- }
582
- // Per-edit callers generally already have content but not a stat. -1 is an
583
- // impossible real mtime, so it deliberately makes the document stale at the
584
- // next startup refresh (`-1 !== realMtime` always) — unlike 0, which is a
585
- // legal on-disk mtime (SOURCE_DATE_EPOCH=0, archive extraction) and would
586
- // collide, leaving such a file never re-tokenized (#958 review F2).
587
- commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt);
588
- scheduleWordIndexRecompact(index, doc.path);
589
- return true;
590
- }
591
- const asyncWordIndexOperations = new WeakMap();
592
- function enqueueAsyncWordIndexOperation(index, operation) {
593
- const previous = asyncWordIndexOperations.get(index) ?? Promise.resolve();
594
- const run = previous.catch(() => undefined).then(operation);
595
- const settled = run.then(() => undefined, () => undefined);
596
- asyncWordIndexOperations.set(index, settled);
597
- return run.finally(() => {
598
- if (asyncWordIndexOperations.get(index) === settled) {
599
- asyncWordIndexOperations.delete(index);
600
- }
601
- });
602
- }
603
- async function stageWordIndexDocumentRemoval(index, filePath, shouldContinue) {
604
- if (!index.forward)
605
- return undefined;
606
- const tokenLineCounts = index.forward.get(filePath);
607
- if (!tokenLineCounts)
608
- return undefined;
609
- const removedKey = wordIndexKey(filePath);
610
- const removedId = index.fileTable.idFor(removedKey);
611
- if (removedId === undefined)
612
- return undefined;
613
- const postings = new Map();
614
- const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
615
- for (const token of tokenLineCounts.keys()) {
616
- if (!shouldContinue())
617
- throw new Error("word index refresh superseded");
618
- const list = index.postings.get(token);
619
- if (!list)
620
- continue;
621
- // One token's postings, filtered by the SAME packed primitive the
622
- // synchronous path uses, so the staged result is identical rather than
623
- // merely equivalent. The work unit is the token, not the posting element:
624
- // a per-element deadline check cost one `performance.now()` per entry —
625
- // on the order of a million per edit on a 2.2M-posting corpus — which is
626
- // the tax that made this path unusable from the per-edit seam (#2067).
627
- // One list's scan is a bounded, non-yieldable unit; the deadline is
628
- // checked between lists.
629
- const survivors = list.withoutFile(removedId);
630
- postings.set(token, survivors.length > 0 ? survivors : undefined);
631
- if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
632
- if (!shouldContinue())
633
- throw new Error("word index refresh superseded");
634
- }
635
- }
636
- return { postings, docLength: index.docLengths.get(filePath) ?? 0 };
637
- }
638
- function commitWordIndexDocumentRemoval(index, filePath, staged) {
639
- for (const [token, list] of staged.postings) {
640
- // Staged survivors come from `WordPostingList.withoutFile` — a fresh
641
- // private store each, so the O(1) fragmentation gate must count them
642
- // (#2117).
643
- if (list) {
644
- index.postings.set(token, list);
645
- notePostingStoresAllocated(index, 1);
646
- }
647
- else
648
- index.postings.delete(token);
649
- }
650
- index.docLengths.delete(filePath);
651
- index.forward?.delete(filePath);
652
- index.fileMtimes.delete(filePath);
653
- index.fileSizes.delete(filePath);
654
- index.dirtyFiles?.add(wordIndexKey(filePath));
655
- index.fileTable.release(wordIndexKey(filePath));
656
- index.totalTokens -= staged.docLength;
657
- index.docCount = Math.max(0, index.docCount - 1);
658
- }
659
- /** Cooperative, atomically-published removal used only by bulk refresh. */
660
- export async function removeWordIndexDocumentAsync(index, filePath, shouldContinue = () => true) {
661
- return enqueueAsyncWordIndexOperation(index, async () => {
662
- const staged = await stageWordIndexDocumentRemoval(index, filePath, shouldContinue);
663
- if (!staged)
664
- return false;
665
- if (!shouldContinue())
666
- throw new Error("word index refresh superseded");
667
- commitWordIndexDocumentRemoval(index, filePath, staged);
668
- return true;
669
- });
670
- }
671
- /**
672
- * The per-edit replacement primitive the cascade seam calls (#2067).
673
- *
674
- * `updateWordIndexDocumentAsync` plus the arena-recompaction gate the
675
- * synchronous variant carries, which is what makes this the per-edit entry
676
- * point rather than a rename. Bulk refresh must NOT use it: one refreshed
677
- * document raises `postingStoreCount` by roughly its distinct-token count, so a
678
- * per-document schedule would drive an O(vocabulary) recompaction between
679
- * nearly every pair of documents. `refreshWordIndexIncrementally` therefore
680
- * drives `updateWordIndexDocumentAsync` directly and recompacts ONCE after its
681
- * loop; the per-edit seam has no such "after the loop" and schedules here.
682
- */
683
- export async function updateWordIndexDocumentForEdit(index, doc) {
684
- const updated = await updateWordIndexDocumentAsync(index, doc);
685
- if (updated)
686
- scheduleWordIndexRecompact(index, doc.path);
687
- return updated;
688
- }
689
- /** Cooperative replacement whose old/new state is committed without an await. */
690
- export async function updateWordIndexDocumentAsync(index, doc, shouldContinue = () => true) {
691
- return enqueueAsyncWordIndexOperation(index, () => updateWordIndexDocumentAsyncUnsafe(index, doc, shouldContinue));
692
- }
693
- async function updateWordIndexDocumentAsyncUnsafe(index, doc, shouldContinue) {
694
- if (!index.forward)
695
- return false;
696
- const startedAt = Date.now();
697
- const removal = index.forward.has(doc.path)
698
- ? await stageWordIndexDocumentRemoval(index, doc.path, shouldContinue)
699
- : undefined;
700
- if (index.forward.has(doc.path) && !removal)
701
- return false;
702
- const perTokenHits = new Map();
703
- let docLength = 0;
704
- const lines = doc.content.split(/\r?\n/);
705
- await forEachCooperatively(lines, (line, i) => {
706
- const lineTokens = tokenizeLine(line);
707
- docLength += lineTokens.length;
708
- const seenOnLine = new Set();
709
- for (const token of lineTokens) {
710
- if (seenOnLine.has(token))
711
- continue;
712
- seenOnLine.add(token);
713
- const arr = perTokenHits.get(token);
714
- if (arr)
715
- arr.push(i + 1);
716
- else
717
- perTokenHits.set(token, [i + 1]);
718
- }
719
- }, {
720
- budgetMs: WORD_INDEX_BUILD_YIELD_BUDGET_MS,
721
- shouldContinue,
722
- abortMessage: "word index refresh superseded",
723
- });
724
- if (!shouldContinue())
725
- throw new Error("word index refresh superseded");
726
- if (removal)
727
- commitWordIndexDocumentRemoval(index, doc.path, removal);
728
- commitWordIndexDocumentReplacement(index, doc, perTokenHits, docLength, startedAt);
729
- return true;
730
- }
731
- export const WORD_INDEX_MAX_BYTES = 512 * 1024;
732
- /**
733
- * Collect the bounded `{path, content}` doc set `buildWordIndex` consumes —
734
- * the ONE file-walk-and-read implementation shared by every build path
735
- * (session-start task, quick-mode warmup, cold-query background trigger),
736
- * so a bound/skip-rule change lands in one place instead of three copies.
737
- * `shouldContinue` lets a session-scoped caller abort early (session
738
- * superseded) without this module knowing about RuntimeCoordinator.
739
- */
740
- export async function collectWordIndexDocs(root, shouldContinue = () => true, preflightFiles) {
741
- const { collectSourceFilesAsync } = await import("./source-filter.js");
742
- // #747 hardening: pass the cap INTO the walk — without it,
743
- // `collectSourceFilesAsync` defaults to an unbounded traversal and any
744
- // slice below would only trim the result AFTER the whole tree (all of
745
- // $HOME, on a misrooted cwd) has already been enumerated.
746
- // #760: the walk is additionally bounded by source-filter's default
747
- // visited-entry budget (DEFAULT_MAX_SCAN_ENTRIES), so a mixed tree with few
748
- // source files among a huge pile of non-source files can't force a
749
- // full-tree walk either; an index over the truncated list is acceptable.
750
- const maxFiles = preflightFiles?.length ?? getWordIndexMaxFilesDerived(root);
751
- // #894 review: prioritize code kinds within the cap — with broadened
752
- // enumeration, thousands of data/doc files (locale JSON, fixtures, …)
753
- // ahead of the code dirs in walk order could exhaust `maxFiles` and evict
754
- // real source files from the index entirely (DOC_FILE_PENALTY can't
755
- // rescue a file that never made the slice).
756
- const files = preflightFiles
757
- ? preflightFiles.map((file) => file.path)
758
- : await collectSourceFilesAsync(root, {
759
- maxFiles,
760
- prioritizeCodeKinds: true,
761
- });
762
- const truncated = preflightFiles?.truncated ?? files.length === maxFiles;
763
- const docs = Object.assign([], { truncated, skipped: 0 });
764
- if (!shouldContinue())
765
- return docs;
766
- const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
767
- for (const file of files.slice(0, maxFiles)) {
768
- if (!shouldContinue())
769
- return docs;
770
- try {
771
- // A preflight list saves the second full WALK, but its metadata may be
772
- // stale by the time this rebuild reads the file. Re-stat each document at
773
- // content-read time so the stored freshness stamp describes the bytes we
774
- // actually index and the next incremental refresh cannot miss a change in
775
- // the walk-to-read window (#1302).
776
- const stat = fs.statSync(file);
777
- if (stat.size <= WORD_INDEX_MAX_BYTES) {
778
- docs.push({
779
- path: file,
780
- content: fs.readFileSync(file, "utf-8"),
781
- mtimeMs: stat.mtimeMs,
782
- size: stat.size,
783
- });
784
- }
785
- else {
786
- // Over the byte cap — enumerated but deliberately not indexed. Count
787
- // it (L1, #958) so callers can keep coverage honest instead of the
788
- // old silent drop.
789
- docs.skipped += 1;
790
- }
791
- }
792
- catch {
793
- // unreadable / vanished file — skip, but count it (see above).
794
- docs.skipped += 1;
795
- }
796
- if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
797
- if (!shouldContinue())
798
- return docs;
799
- }
800
- }
801
- return docs;
802
- }
803
- // Node's fs.promises.stat runs on libuv's threadpool (default 4 slots), so
804
- // real parallelism tops out there; the surplus workers are queue depth that
805
- // keeps the pool saturated without starving other threadpool consumers.
806
- const WORD_INDEX_STAT_CONCURRENCY = 8;
807
- const WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD = 0.3;
808
- // A ratio alone misclassifies one stale file in a three-file project as dense.
809
- // Below this absolute floor, per-document replacement is bounded and cheaper
810
- // than a second full walk/read even when the percentage is high (#1197).
811
- const WORD_INDEX_DENSE_REFRESH_MIN_DOCUMENTS = 32;
812
- /**
813
- * Cost of one file's stat+read+decode expressed in tokenizer-token units, so
814
- * the work model below can compare a path that re-reads EVERY file (full
815
- * rebuild) against one that re-reads only the stale files (incremental) in a
816
- * single currency. One read ≈ 300 tokens at the ~1 µs/token measured below.
817
- * Order-of-magnitude by design — it only has to keep a tiny-vocabulary corpus
818
- * (where posting scans are nearly free) from being sent to a full rebuild that
819
- * re-reads everything to save a handful of array filters.
820
- */
821
- const WORD_INDEX_FILE_READ_TOKEN_COST = 300;
822
- /**
823
- * Cost of scanning ONE posting-array element (the `wordIndexKey` compare in
824
- * {@link removeWordIndexDocument}'s filter) relative to tokenizing one token,
825
- * so both sides of the model below are denominated in tokens. Measured on this
826
- * repository's own 2,062-document corpus: 213 ns per element scan against 979 ns
827
- * per token — and the resulting predicted crossover (23 stale documents) lands
828
- * on the directly measured one (~21, from 97.8 ms per document replacement
829
- * against a 1,436 ms rebuild + re-read). The synthetic high-df corpus in the
830
- * #1197 probe gives 0.09, so this is the conservative (incremental-favouring)
831
- * end of the observed range.
832
- *
833
- * #2069 made the scan an Int32 compare over packed lanes instead of a string
834
- * compare over boxed objects, so the real per-element cost is now BELOW this
835
- * constant. The constant is left as measured: over-stating incremental cost
836
- * only ever routes work to a full rebuild, which is the bounded side of the
837
- * decision. Re-measuring it is #2067/#2068 territory, not this change.
838
- */
839
- const WORD_INDEX_POSTING_SCAN_TOKEN_COST = 0.2;
840
- /**
841
- * Refresh a serializer-v2 index from the current bounded source-file set.
842
- * The walk/stat pass is cheap; only sparse stale/new documents are read and
843
- * tokenized. Expected dense/legacy states return `full-required` BEFORE any
844
- * mutation; unexpected corruption or supersession still throws.
845
- */
846
- export async function refreshWordIndexIncrementally(index, root, shouldContinue = () => true, options = {}) {
847
- if (!index.forward || !index.fileMtimes || !index.fileSizes) {
848
- return {
849
- mode: "full-required",
850
- reason: "missing-incremental-metadata",
851
- timings: { sourceWalkMs: 0, statWalkMs: 0, refreshReadsMs: 0 },
852
- };
853
- }
854
- const { collectSourceFilesAsync } = await import("./source-filter.js");
855
- const maxFiles = getWordIndexMaxFilesDerived(root);
856
- const sourceWalkStartMs = Date.now();
857
- const walked = await collectSourceFilesAsync(root, {
858
- maxFiles,
859
- prioritizeCodeKinds: true,
860
- });
861
- const sourceWalkMs = Date.now() - sourceWalkStartMs;
862
- if (!shouldContinue())
863
- throw new Error("word index refresh superseded");
864
- // This set-difference must run in the SAME normalized key space the path
865
- // maps use (#1025 review). Otherwise a file whose stored displayPath became
866
- // the edit form (last-writer-wins after a case/separator-divergent per-edit
867
- // update) no longer string-matches its walk form here — even though
868
- // `fileMtimes.get(walkForm)` folds and hits — which would (1) double-count
869
- // churn (the same file scored as both "dropped" and "added"), possibly
870
- // crossing the threshold and forcing a needless full rebuild, and (2) drop
871
- // then re-add an unchanged file, opening a regression window because the drop
872
- // loop runs BEFORE the re-read: a transient read failure (skipped++) would
873
- // strand the file with no postings. Keying `current` and `oldSet` through
874
- // `wordIndexKey` keeps build/edit/refresh convergent; `current`'s value
875
- // retains the raw walk path for the stat/read and for the display key.
876
- const current = new Map();
877
- const statWalkStartMs = Date.now();
878
- const statFile = options.statFile ?? ((file) => fs.promises.stat(file));
879
- const requestedConcurrency = options.statConcurrency ?? WORD_INDEX_STAT_CONCURRENCY;
880
- const statConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0
881
- ? Math.max(1, Math.floor(requestedConcurrency))
882
- : WORD_INDEX_STAT_CONCURRENCY;
883
- const statResults = [];
884
- statResults.length = walked.length;
885
- let cursor = 0;
886
- let superseded = false;
887
- const worker = async () => {
888
- while (true) {
889
- if (!shouldContinue()) {
890
- superseded = true;
891
- return;
892
- }
893
- const slot = cursor++;
894
- if (slot >= walked.length)
895
- return;
896
- const file = walked[slot];
897
- try {
898
- const stat = await statFile(file);
899
- if (stat.size <= WORD_INDEX_MAX_BYTES) {
900
- statResults[slot] = {
901
- path: file,
902
- mtimeMs: stat.mtimeMs,
903
- size: stat.size,
904
- };
905
- }
906
- }
907
- catch {
908
- // A rejection has statSync parity: the file is simply absent.
909
- }
910
- }
911
- };
912
- await Promise.all(Array.from({ length: Math.min(statConcurrency, walked.length) }, () => worker()));
913
- if (superseded || !shouldContinue()) {
914
- throw new Error("word index refresh superseded");
915
- }
916
- const statWalkMs = Date.now() - statWalkStartMs;
917
- for (const result of statResults) {
918
- if (result)
919
- current.set(wordIndexKey(result.path), result);
920
- }
921
- const timings = { sourceWalkMs, statWalkMs, refreshReadsMs: 0 };
922
- const preflightFiles = Object.assign([...current.values()], {
923
- truncated: walked.length === maxFiles,
924
- });
925
- const oldSet = new Set([...index.docLengths.keys()].map(wordIndexKey));
926
- let changedSet = 0;
927
- for (const key of oldSet)
928
- if (!current.has(key))
929
- changedSet++;
930
- for (const key of current.keys())
931
- if (!oldSet.has(key))
932
- changedSet++;
933
- const denominator = Math.max(oldSet.size, current.size, 1);
934
- if (changedSet / denominator > WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD) {
935
- return {
936
- mode: "full-required",
937
- reason: "file-set-churn",
938
- preflightFiles,
939
- timings,
940
- };
941
- }
942
- // Replacing one document filters every shared posting array for that
943
- // document's tokens. That is excellent for a sparse edit, but repeating it
944
- // for most of the corpus becomes effectively quadratic. Decide the dense
945
- // transition BEFORE dropping or editing anything so the old index remains a
946
- // valid fallback until a separately-built full replacement is ready (#1197).
947
- let staleDocuments = 0;
948
- for (const { path: file, mtimeMs, size } of current.values()) {
949
- if (index.fileMtimes.get(file) !== mtimeMs ||
950
- (index.fileSizes.get(file) ?? -1) !== size) {
951
- staleDocuments += 1;
952
- }
953
- }
954
- // Density alone is NOT a bound (#1197 review finding 1): the per-document cost
955
- // GROWS with the corpus, so a stale set comfortably under any fixed ratio (or
956
- // any fixed absolute ceiling) still costs orders of magnitude more than a
957
- // rebuild on a big enough repository. 800 documents with 239 stale — 29.875%,
958
- // just under the ratio — measured 90.6 s with a 39.6 s synchronous block
959
- // against 1.3 s / 10.6 ms for a full build, and at the 6,000-file cap up to
960
- // 1,799 stale documents would have stayed on that path.
961
- //
962
- // So compare estimated WORK, both sides denominated in tokenizer tokens:
963
- // incremental ≈ stale x (docTokens x postingLength x scanCost + readCost)
964
- // full ≈ totalTokens + corpusSize x readCost
965
- // This is self-bounding in a way no constant is: the incremental path is taken
966
- // only while its total estimated cost stays under ONE full rebuild, so the
967
- // worst case is "about as expensive as rebuilding", whatever the corpus size.
968
- //
969
- // Both statistics come from cheap metadata passes — one over `postings`
970
- // (distinct tokens, reading only `.length`) and one over `forward` (documents,
971
- // reading only `.size`). Neither touches a posting ELEMENT, which is the work
972
- // this decision exists to avoid.
973
- let postingEntries = 0;
974
- let weightedPostingEntries = 0;
975
- for (const list of index.postings.values()) {
976
- postingEntries += list.length;
977
- weightedPostingEntries += list.length * list.length;
978
- }
979
- // The mean posting-array length weighted by OCCURRENCES, not the plain mean:
980
- // a document's tokens are drawn from the frequency distribution, so the arrays
981
- // a removal actually filters skew hard toward the high-document-frequency
982
- // tokens. The unweighted mean (dominated by the long tail of df=1 tokens)
983
- // underestimates the real cost by an order of magnitude.
984
- const expectedPostingLength = postingEntries > 0 ? weightedPostingEntries / postingEntries : 0;
985
- let distinctTokenEntries = 0;
986
- for (const tokenLineCounts of index.forward.values()) {
987
- distinctTokenEntries += tokenLineCounts.size;
988
- }
989
- const expectedDocumentTokens = index.docCount > 0 ? distinctTokenEntries / index.docCount : 0;
990
- const estimatedIncrementalWork = staleDocuments *
991
- (expectedDocumentTokens *
992
- expectedPostingLength *
993
- WORD_INDEX_POSTING_SCAN_TOKEN_COST +
994
- WORD_INDEX_FILE_READ_TOKEN_COST);
995
- const estimatedFullRebuildWork = index.totalTokens + current.size * WORD_INDEX_FILE_READ_TOKEN_COST;
996
- // The document-count floor guards the RATIO test only — its documented job is
997
- // that "one stale file in a three-file project" is 33% but not dense. It must
998
- // NOT gate the work test: a floor is another constant, and letting one
999
- // suppress the work comparison would reintroduce exactly the unbounded-on-the
1000
- // -other-axis shape (31 documents at 98 ms each on this repository's own
1001
- // corpus, and worse as the corpus grows). The work test needs no floor — on a
1002
- // small project a full rebuild is cheap, so it never fires there.
1003
- if ((staleDocuments >= WORD_INDEX_DENSE_REFRESH_MIN_DOCUMENTS &&
1004
- staleDocuments / Math.max(current.size, 1) >
1005
- WORD_INDEX_INCREMENTAL_CHURN_THRESHOLD) ||
1006
- estimatedIncrementalWork > estimatedFullRebuildWork) {
1007
- return {
1008
- mode: "full-required",
1009
- reason: "stale-document-churn",
1010
- preflightFiles,
1011
- timings,
1012
- };
1013
- }
1014
- const deadline = createDeadline(WORD_INDEX_BUILD_YIELD_BUDGET_MS);
1015
- let dropped = 0;
1016
- for (const key of oldSet) {
1017
- if (!current.has(key)) {
1018
- // `key` is already folded; removeWordIndexDocument re-folds it
1019
- // idempotently via the PathKeyedMap, so the drop hits the right entry.
1020
- if (!(await removeWordIndexDocumentAsync(index, key, shouldContinue))) {
1021
- throw new Error(`failed to drop word-index document: ${key}`);
1022
- }
1023
- dropped++;
1024
- }
1025
- }
1026
- let refreshed = 0;
1027
- let skipped = 0;
1028
- const refreshReadsStartMs = Date.now();
1029
- for (const { path: file, mtimeMs, size } of current.values()) {
1030
- // #1105: mtime-first, size-second freshness — mtime alone is a stale
1031
- // signal (mtime-preserving content changes serve stale identifiers to
1032
- // symbol_search). Size is free (the stat above already read it) and
1033
- // catches every content change that alters byte length. Both come from the
1034
- // SAME stat, so a mismatch on EITHER axis re-reads. `?? -1` makes a file
1035
- // absent from the (possibly legacy, pre-#1105) size map always count as
1036
- // changed → one-time re-read that repopulates the size, the safe direction.
1037
- if (index.fileMtimes.get(file) !== mtimeMs ||
1038
- (index.fileSizes.get(file) ?? -1) !== size) {
1039
- // A file the walk/stat pass saw can still fail to read here — a
1040
- // transient exclusive lock (antivirus, an editor, a build step) or a
1041
- // file that vanished in the interim. Match collectWordIndexDocs'
1042
- // tolerance: skip this one file and leave its existing posting (and
1043
- // its old mtime, so it is retried next session) rather than aborting
1044
- // the whole incremental pass into a full rebuild (#958 review F1).
1045
- let content;
1046
- try {
1047
- content = fs.readFileSync(file, "utf-8");
1048
- }
1049
- catch {
1050
- skipped++;
1051
- continue;
1052
- }
1053
- if (!(await updateWordIndexDocumentAsync(index, { path: file, content }, shouldContinue))) {
1054
- throw new Error(`failed to refresh word-index document: ${file}`);
1055
- }
1056
- // updateWordIndexDocument stamps mtime=-1 (per-edit convention); the
1057
- // refresh path KNOWS the real on-disk stat, so overwrite both axes with
1058
- // the true values so this document is not needlessly re-read next pass.
1059
- index.fileMtimes.set(file, mtimeMs);
1060
- index.fileSizes.set(file, size);
1061
- refreshed++;
1062
- }
1063
- // Same OR as the stat loop. This is the loop the #1197 outage actually
1064
- // blocked in: replacing one document costs more the larger the corpus is,
1065
- // so a count-only checkpoint let 100 replacements run back to back for
1066
- // seconds. The gate above keeps the stale set sparse; this keeps even a
1067
- // sparse-but-expensive set off the event loop.
1068
- if (deadline.expired() && (await yieldIfOverBudget(deadline))) {
1069
- if (!shouldContinue())
1070
- throw new Error("word index refresh superseded");
1071
- }
1072
- }
1073
- timings.refreshReadsMs = Date.now() - refreshReadsStartMs;
1074
- // Route through the same guarded queue the per-edit seam uses, so a refresh
1075
- // and a concurrent cascade edit cannot both drive a recompaction (#2117).
1076
- await enqueueWordIndexRecompact(index, root);
1077
- index.truncated = walked.length === maxFiles;
1078
- return {
1079
- mode: "incremental",
1080
- refreshed,
1081
- dropped,
1082
- skipped,
1083
- reused: current.size - refreshed - skipped,
1084
- timings,
1085
- };
1086
- }
1087
- const WORD_INDEX_QUERY_FILTER_KEYS = [
1088
- "lang",
1089
- "file",
1090
- "ext",
1091
- ];
1092
- /** Thrown by {@link parseWordIndexQuery}/{@link buildWordIndexQueryFilter} for
1093
- * an unrecognized `key:` prefix or an unrecognized `lang:` value — a query
1094
- * typo fails loudly with the supported list instead of silently degrading
1095
- * into a literal search term (#1450 acceptance criterion). */
1096
- export class WordIndexQueryError extends Error {
1097
- constructor(message) {
1098
- super(message);
1099
- this.name = "WordIndexQueryError";
1100
- }
1101
- }
1102
- const WORD_INDEX_FILTER_TOKEN_RE = /^(-)?([A-Za-z][A-Za-z0-9_-]*):(.+)$/;
1103
- /**
1104
- * Split a word-index query string into plain search terms and `key:value`
1105
- * prefix filters (#1450). Whitespace-delimited, one regex per token — no
1106
- * quoting scheme invented here, matching the pre-existing query path (which
1107
- * never supported quoting either). A `-` immediately before a recognized
1108
- * `key:` negates that filter; a bare `-term` (no colon) is left as an
1109
- * ordinary token, unchanged from prior behavior — `tokenizeLine` already
1110
- * strips the leading `-` when it extracts identifiers.
1111
- *
1112
- * An unrecognized `key:` (e.g. `type:foo`) throws {@link WordIndexQueryError}
1113
- * naming the supported prefixes, rather than falling through as a literal
1114
- * term — a typo must fail loudly, never silently rank as if the filter never
1115
- * existed. An empty value (`lang:`) is not treated as a filter token at all
1116
- * (kept as a term) since there is nothing to filter by.
1117
- */
1118
- export function parseWordIndexQuery(query) {
1119
- const filters = [];
1120
- const termParts = [];
1121
- for (const raw of query.split(/\s+/)) {
1122
- if (!raw)
1123
- continue;
1124
- const match = raw.match(WORD_INDEX_FILTER_TOKEN_RE);
1125
- if (!match) {
1126
- termParts.push(raw);
1127
- continue;
1128
- }
1129
- const [, negation, keyRaw, value] = match;
1130
- const key = keyRaw.toLowerCase();
1131
- if (!value) {
1132
- termParts.push(raw);
1133
- continue;
1134
- }
1135
- if (!WORD_INDEX_QUERY_FILTER_KEYS.includes(key)) {
1136
- // Not a recognized filter key. Ordinary search terms legitimately
1137
- // contain colons (std::vector, error:foo, http://…, C:\ paths, a
1138
- // TODO:tag), and the old tokenizer searched them fine — a hard
1139
- // error here is a usability regression, not typo protection. The
1140
- // token passes through as a plain term; the loud-failure contract
1141
- // survives where it is unambiguous: a KNOWN key with a bad value
1142
- // (lang:notalang) still throws.
1143
- termParts.push(raw);
1144
- continue;
1145
- }
1146
- filters.push({
1147
- key: key,
1148
- value,
1149
- negated: negation === "-",
1150
- });
1151
- }
1152
- return { terms: termParts.join(" "), filters };
1153
- }
1154
- /** `lang:X` → the extension set for FileKind X (case-insensitive), drawn
1155
- * exclusively from `KIND_EXTENSIONS` (clients/file-kinds.ts) — the single
1156
- * source of truth (#894 invariant: no second, hand-maintained language list).
1157
- * An unrecognized kind throws {@link WordIndexQueryError} listing the accepted
1158
- * kinds (KIND_EXTENSIONS' own keys), the same loud-failure contract as an
1159
- * unsupported `key:` prefix. */
1160
- function resolveLangExtensions(value) {
1161
- const kind = value.toLowerCase();
1162
- const extensions = KIND_EXTENSIONS[kind];
1163
- if (!extensions) {
1164
- const known = Object.keys(KIND_EXTENSIONS)
1165
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
1166
- .join(", ");
1167
- throw new WordIndexQueryError(`Unknown lang: "${value}" in word-index query — supported languages: ${known}.`);
1168
- }
1169
- return extensions;
1170
- }
1171
- /** `ext:ts` / `ext:.ts` → normalized to a single leading-dot, lowercased form for extname comparison. */
1172
- function normalizeExtFilterValue(value) {
1173
- return `.${value.replace(/^\.+/, "").toLowerCase()}`;
1174
- }
1175
- function resolveWordIndexFilter(filter) {
1176
- if (filter.key === "lang") {
1177
- const extensions = resolveLangExtensions(filter.value);
1178
- return {
1179
- key: filter.key,
1180
- negated: filter.negated,
1181
- test: (file) => extensions.includes(path.extname(file).toLowerCase()),
1182
- };
1183
- }
1184
- if (filter.key === "ext") {
1185
- const normalized = normalizeExtFilterValue(filter.value);
1186
- return {
1187
- key: filter.key,
1188
- negated: filter.negated,
1189
- test: (file) => path.extname(file).toLowerCase() === normalized,
1190
- };
1191
- }
1192
- // "file": substring match against the index's own normalized display path
1193
- // (#1450 — see the doc comment on {@link buildWordIndexQueryFilter} for the
1194
- // case-behavior choice). Both sides fold through `wordIndexKey` so the
1195
- // comparison matches the SAME normalization the index's own path keys use.
1196
- const needle = wordIndexKey(filter.value);
1197
- return {
1198
- key: filter.key,
1199
- negated: filter.negated,
1200
- test: (_file, displayPath) => displayPath.includes(needle),
1201
- };
1202
- }
1203
- /**
1204
- * Build a pre-ranking predicate from parsed query filters (#1450). Composes
1205
- * (AND) with `searchWordIndex`'s pre-existing `fileFilter` option (#771) —
1206
- * `symbol_search`'s structured `paths`/`lang` params and this query's inline
1207
- * filters both apply when both are present.
1208
- *
1209
- * Semantics: multiple positive filters of the SAME key OR together
1210
- * (`lang:ts lang:go` matches either); filters of DIFFERENT keys AND
1211
- * (`lang:ts file:clients/` requires both); a negated filter always subtracts,
1212
- * regardless of any positive filter sharing its key.
1213
- *
1214
- * All filters are RESOLVED EAGERLY (extension lookups, `WordIndexQueryError`
1215
- * thrown) here at build time — before any candidate file is tested — so a bad
1216
- * `lang:` value fails once, loudly, rather than per-file during the scoring
1217
- * loop.
1218
- *
1219
- * `file:` case behavior: matched via `wordIndexKey` (this module's single
1220
- * path-key normalizer, `normalizeEphemeralMapKey`) on BOTH the candidate path
1221
- * and the filter value — slash-folded everywhere, and case-folded ONLY on
1222
- * win32 (Windows' case-insensitive filesystem), preserved on POSIX. This
1223
- * mirrors the SAME normalization the word index's own path-keyed maps already
1224
- * apply (#1025), rather than inventing a second, divergent case rule for this
1225
- * one filter. The candidate path matched is the RAW index-stored path (not
1226
- * cwd-relativized) — the index already stores paths in the form the caller
1227
- * built it with (relative in tests, absolute in the real project walk), and
1228
- * an absolute path's tail always contains its project-relative suffix, so
1229
- * `file:clients/word-index.ts` still matches naturally either way.
1230
- */
1231
- export function buildWordIndexQueryFilter(filters) {
1232
- if (filters.length === 0)
1233
- return undefined;
1234
- const resolved = filters.map(resolveWordIndexFilter);
1235
- const positivesByKey = new Map();
1236
- const negatives = [];
1237
- for (const r of resolved) {
1238
- if (r.negated) {
1239
- negatives.push(r);
1240
- continue;
1241
- }
1242
- const group = positivesByKey.get(r.key) ?? [];
1243
- group.push(r);
1244
- positivesByKey.set(r.key, group);
1245
- }
1246
- return (file) => {
1247
- const displayPath = wordIndexKey(file);
1248
- for (const negative of negatives) {
1249
- if (negative.test(file, displayPath))
1250
- return false;
1251
- }
1252
- for (const group of positivesByKey.values()) {
1253
- if (!group.some((r) => r.test(file, displayPath)))
1254
- return false;
1255
- }
1256
- return true;
1257
- };
1258
- }
1259
- function combineFileFilters(a, b) {
1260
- if (!a)
1261
- return b;
1262
- if (!b)
1263
- return a;
1264
- return (file) => a(file) && b(file);
1265
- }
1266
- /**
1267
- * Rank files for a query by BM25 over the query's identifier tokens, then apply
1268
- * priors: demote test/vendor and doc/data files, and boost by graph centrality
1269
- * when supplied. Returns the top {@link RankOptions.limit} files, highest first.
1270
- *
1271
- * The query string may mix plain terms with `lang:`/`file:`/`ext:` prefix
1272
- * filters (+ `-` negation, #1450) — parsed by {@link parseWordIndexQuery} and
1273
- * applied as a `fileFilter` (composed, AND, with any caller-supplied one)
1274
- * BEFORE scoring, same as the pre-existing `paths`/`lang` options (#771): a
1275
- * surviving file's score is unaffected by filtering. A query with no filter
1276
- * tokens reproduces prior output byte-for-byte.
1277
- *
1278
- * DF-normalization note: BM25's per-token `idf` is computed from `docFrequency`
1279
- * over the FULL (unfiltered) postings for that token — `fileFilter` (whether
1280
- * from `options.fileFilter` or from this query's inline filters) is applied
1281
- * AFTER `idf` is computed, per file, inside the same loop. This means a
1282
- * filtered query reuses the GLOBAL, corpus-wide document frequency rather than
1283
- * recomputing it over just the filtered subset. That is the pre-existing #771
1284
- * behavior this change does not alter; it is an acceptable approximation
1285
- * (idf reflects true corpus rarity, not an artifact of the filter) and keeps
1286
- * filtered/unfiltered scores for the SAME file directly comparable, which is
1287
- * the property #1450 asks for ("BM25 and centrality stay comparable within
1288
- * the filtered set").
1289
- */
1290
- export function searchWordIndex(index, query, options = {}) {
1291
- const { demoteTestVendor = true, demoteDocs = true, centrality, limit = 20, fileFilter, } = options;
1292
- const parsedQuery = parseWordIndexQuery(query);
1293
- const queryFilter = buildWordIndexQueryFilter(parsedQuery.filters);
1294
- const combinedFilter = combineFileFilters(fileFilter, queryFilter);
1295
- const queryTokens = [...new Set(tokenizeLine(parsedQuery.terms))];
1296
- if (queryTokens.length === 0)
1297
- return [];
1298
- const docCount = index.docCount || 1;
1299
- const avgDocLength = index.totalTokens / docCount || 1;
1300
- const scores = new Map();
1301
- for (const token of queryTokens) {
1302
- const posting = index.postings.get(token);
1303
- if (!posting)
1304
- continue;
1305
- // Grouped by the packed file id, then resolved to the display path once
1306
- // per (token, file) instead of once per posting (#2069). Insertion order,
1307
- // document frequency, and the per-file line lists are identical to the
1308
- // boxed representation, so scores and ordering are unchanged.
1309
- const linesByFileId = new Map();
1310
- for (let i = 0; i < posting.length; i += 1) {
1311
- const fileId = posting.fileIdAt(i);
1312
- const arr = linesByFileId.get(fileId);
1313
- if (arr)
1314
- arr.push(posting.lineAt(i));
1315
- else
1316
- linesByFileId.set(fileId, [posting.lineAt(i)]);
1317
- }
1318
- const docFrequency = linesByFileId.size;
1319
- const idf = Math.log(1 + (docCount - docFrequency + 0.5) / (docFrequency + 0.5));
1320
- for (const [fileId, lines] of linesByFileId) {
1321
- const file = index.fileTable.pathFor(fileId);
1322
- if (file === undefined) {
1323
- // Dropping this silently would shorten the result list with nothing to
1324
- // tell it apart from a smaller match set. Record, then drop.
1325
- recordOrphanWordIndexFileId(fileId, token, "search");
1326
- continue;
1327
- }
1328
- if (combinedFilter && !combinedFilter(file))
1329
- continue;
1330
- const termFrequency = lines.length;
1331
- const docLength = index.docLengths.get(file) ?? avgDocLength;
1332
- const denominator = termFrequency +
1333
- BM25_K1 * (1 - BM25_B + BM25_B * (docLength / avgDocLength));
1334
- const termScore = idf * ((termFrequency * (BM25_K1 + 1)) / denominator);
1335
- const entry = scores.get(file) ?? {
1336
- score: 0,
1337
- hits: 0,
1338
- lines: new Set(),
1339
- };
1340
- entry.score += termScore;
1341
- entry.hits += termFrequency;
1342
- for (const line of lines)
1343
- entry.lines.add(line);
1344
- scores.set(file, entry);
1345
- }
1346
- }
1347
- const results = [];
1348
- for (const [file, entry] of scores) {
1349
- let score = entry.score;
1350
- if (demoteTestVendor && isTestOrVendor(file))
1351
- score *= TEST_VENDOR_PENALTY;
1352
- if (demoteDocs && isDocFile(file))
1353
- score *= DOC_FILE_PENALTY;
1354
- const connections = centrality?.get(file);
1355
- if (connections && connections > 0) {
1356
- score *= 1 + Math.log(1 + connections) / 4;
1357
- }
1358
- results.push({
1359
- file,
1360
- score,
1361
- hits: entry.hits,
1362
- lines: [...entry.lines].sort((a, b) => a - b),
1363
- });
1364
- }
1365
- results.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
1366
- return results.slice(0, Math.max(0, limit));
1367
- }
1368
- /**
1369
- * Build a centrality map (file → importedBy count) keyed by THIS index's file
1370
- * paths, from the project snapshot's `reverseDeps` (importedBy). The snapshot
1371
- * keys are normalized (`normalizeMapKey(resolve(...))`) while the index keys are
1372
- * the raw scanned paths, so the caller injects a `normalizeKey` bridge; it
1373
- * defaults to identity for testing. Pass the result to {@link searchWordIndex}
1374
- * as `centrality` to boost well-connected files. Kept here (not in the engine)
1375
- * so it stays pure + unit-testable without the normalizer dependency.
1376
- */
1377
- export function centralityFromReverseDeps(index, reverseDeps, normalizeKey = (file) => file) {
1378
- const centrality = new Map();
1379
- if (!reverseDeps)
1380
- return centrality;
1381
- for (const file of index.docLengths.keys()) {
1382
- const importers = reverseDeps[normalizeKey(file)];
1383
- if (importers && importers.length > 0) {
1384
- centrality.set(file, importers.length);
1385
- }
1386
- }
1387
- return centrality;
1388
- }
1389
- /** Persisted word-index serialization format version. Bump on breaking format changes. */
1390
- export const WORD_INDEX_FORMAT_VERSION = 2;
1391
- const WORD_INDEX_INT32_MAX = 2_147_483_647;
1392
- // A file id and line fit in one exact Number key through this boundary.
1393
- const WORD_INDEX_PACKED_FILE_ID_MAX = 4_194_303;
1394
- function isCanonicalWordIndexNumber(value) {
1395
- return (typeof value === "number" &&
1396
- Number.isFinite(value) &&
1397
- Number.isSafeInteger(value) &&
1398
- !Object.is(value, -0));
1399
- }
1400
- function isCanonicalWordIndexToken(token) {
1401
- return (typeof token === "string" &&
1402
- token.length >= 2 &&
1403
- token === token.toLowerCase() &&
1404
- /^[a-z0-9_$]+$/.test(token) &&
1405
- !STOPWORDS.has(token));
1406
- }
1407
- const serializedWordIndexCaches = new WeakMap();
1408
- let _lastSerializeWork;
1409
- /** Test-only: work stats for the most recent {@link serializeWordIndex} call. */
1410
- export function getLastWordIndexSerializeWork() {
1411
- return _lastSerializeWork;
1412
- }
1413
- function serializeWordIndexFull(index) {
1414
- const files = [...index.docLengths.keys()];
1415
- // `files` carries whatever spelling `docLengths` last stored, which can
1416
- // differ from the spelling the file table interned. Both fold through
1417
- // `wordIndexKey`, so the slot lookup runs in the folded key space and a
1418
- // separator- or case-divergent spelling still finds its postings (#1025).
1419
- const slotByFileId = new Map();
1420
- files.forEach((file, i) => {
1421
- const fileId = index.fileTable.idFor(wordIndexKey(file));
1422
- if (fileId !== undefined)
1423
- slotByFileId.set(fileId, i);
1424
- });
1425
- const postings = [];
1426
- for (const [token, list] of index.postings) {
1427
- const flat = [];
1428
- for (let i = 0; i < list.length; i += 1) {
1429
- const slot = slotByFileId.get(list.fileIdAt(i));
1430
- if (slot === undefined)
1431
- continue;
1432
- flat.push(slot, list.lineAt(i));
1433
- }
1434
- if (flat.length > 0)
1435
- postings.push([token, flat]);
1436
- }
1437
- const forward = index.forward
1438
- ? files.map((file, i) => [
1439
- i,
1440
- [...(index.forward.get(file)?.entries() ?? [])],
1441
- ])
1442
- : undefined;
1443
- const serialized = {
1444
- version: WORD_INDEX_FORMAT_VERSION,
1445
- files,
1446
- postings,
1447
- docLengths: files.map((file) => index.docLengths.get(file) ?? 0),
1448
- totalTokens: index.totalTokens,
1449
- indexedFileCount: index.docCount,
1450
- truncated: index.truncated,
1451
- fileMtimes: files.map((file) => index.fileMtimes.get(file) ?? 0),
1452
- fileSizes: files.map((file) => index.fileSizes.get(file) ?? 0),
1453
- forward,
1454
- };
1455
- const tokensByFile = new Map();
1456
- if (index.forward) {
1457
- for (const file of files) {
1458
- tokensByFile.set(wordIndexKey(file), new Set(index.forward.get(file)?.keys() ?? []));
1459
- }
1460
- }
1461
- _lastSerializeWork = {
1462
- affectedTokenCount: postings.length,
1463
- tookFullPath: true,
1464
- };
1465
- return { serialized, slotByFileId, tokensByFile };
1466
- }
1467
- /**
1468
- * Refresh the cached wire view without walking untouched posting lanes.
1469
- * Existing files keep their slots, so a document replacement only rebuilds
1470
- * the token lists named by that document's old or new forward entry. A file
1471
- * removal or reordering changes slot identity and deliberately takes the
1472
- * bounded full path.
1473
- */
1474
- function serializeWordIndexIncrementally(index, cache) {
1475
- const files = [...index.docLengths.keys()];
1476
- const priorFiles = cache.serialized.files;
1477
- const currentKeys = new Set(files.map(wordIndexKey));
1478
- const priorKeys = new Set(priorFiles.map(wordIndexKey));
1479
- if ([...priorKeys].some((key) => !currentKeys.has(key))) {
1480
- return serializeWordIndexFull(index);
1481
- }
1482
- // Re-flattening most lanes costs more than rebuilding the compact wire view.
1483
- // The review fixture crosses over between 10% and 100% dirty, so use the
1484
- // midpoint as a conservative bound until a workload-specific model exists.
1485
- const dirty = index.dirtyFiles;
1486
- if (dirty && dirty.size * 2 > files.length) {
1487
- return serializeWordIndexFull(index);
1488
- }
1489
- // A replacement deletes and re-adds a PathKeyedMap entry, which changes Map
1490
- // insertion order. Keep the cached wire slots stable instead of rewriting
1491
- // every posting just because one document changed order.
1492
- const filesInWireOrder = [
1493
- ...priorFiles,
1494
- ...files.filter((file) => !priorKeys.has(wordIndexKey(file))),
1495
- ];
1496
- if (!dirty || dirty.size === 0) {
1497
- _lastSerializeWork = { affectedTokenCount: 0, tookFullPath: false };
1498
- return cache;
1499
- }
1500
- const slotByFileId = new Map(cache.slotByFileId);
1501
- for (let i = priorFiles.length; i < filesInWireOrder.length; i += 1) {
1502
- const fileId = index.fileTable.idFor(wordIndexKey(filesInWireOrder[i]));
1503
- if (fileId !== undefined)
1504
- slotByFileId.set(fileId, i);
1505
- }
1506
- const postings = cache.serialized.postings.slice();
1507
- const postingAt = new Map();
1508
- postings.forEach(([token], i) => postingAt.set(token, i));
1509
- const removedTokens = new Set();
1510
- const tokensByFile = new Map(cache.tokensByFile);
1511
- const fileByKey = new Map(filesInWireOrder.map((file) => [wordIndexKey(file), file]));
1512
- const affectedTokens = new Set();
1513
- for (const fileKey of dirty) {
1514
- const file = fileByKey.get(fileKey);
1515
- const oldTokens = tokensByFile.get(fileKey) ?? new Set();
1516
- const currentEntry = file === undefined ? undefined : index.forward?.get(file);
1517
- const currentTokens = new Set(currentEntry?.keys() ?? []);
1518
- for (const token of oldTokens)
1519
- affectedTokens.add(token);
1520
- for (const token of currentTokens)
1521
- affectedTokens.add(token);
1522
- if (file === undefined)
1523
- tokensByFile.delete(fileKey);
1524
- else
1525
- tokensByFile.set(fileKey, currentTokens);
1526
- }
1527
- for (const token of affectedTokens) {
1528
- const list = index.postings.get(token);
1529
- const flat = [];
1530
- if (list) {
1531
- for (let i = 0; i < list.length; i += 1) {
1532
- const slot = slotByFileId.get(list.fileIdAt(i));
1533
- if (slot !== undefined)
1534
- flat.push(slot, list.lineAt(i));
1535
- }
1536
- }
1537
- const at = postingAt.get(token);
1538
- if (flat.length === 0) {
1539
- if (at !== undefined)
1540
- removedTokens.add(token);
1541
- }
1542
- else if (at === undefined) {
1543
- postings.push([token, flat]);
1544
- postingAt.set(token, postings.length - 1);
1545
- }
1546
- else {
1547
- postings[at] = [postings[at][0], flat];
1548
- }
1549
- }
1550
- const compactedPostings = removedTokens.size
1551
- ? postings.filter(([token]) => !removedTokens.has(token))
1552
- : postings;
1553
- const forward = index.forward
1554
- ? (cache.serialized.forward
1555
- ?.slice()
1556
- .map((entry) => [entry[0], entry[1]]) ?? [])
1557
- : undefined;
1558
- for (const fileKey of dirty) {
1559
- const file = fileByKey.get(fileKey);
1560
- const fileId = file === undefined
1561
- ? undefined
1562
- : index.fileTable.idFor(wordIndexKey(file));
1563
- const slot = fileId === undefined ? undefined : slotByFileId.get(fileId);
1564
- if (slot === undefined || !forward || file === undefined)
1565
- continue;
1566
- forward[slot] = [slot, [...(index.forward?.get(file)?.entries() ?? [])]];
1567
- }
1568
- const serialized = {
1569
- ...cache.serialized,
1570
- files: filesInWireOrder,
1571
- postings: compactedPostings,
1572
- docLengths: filesInWireOrder.map((file) => index.docLengths.get(file) ?? 0),
1573
- fileMtimes: filesInWireOrder.map((file) => index.fileMtimes.get(file) ?? 0),
1574
- fileSizes: filesInWireOrder.map((file) => index.fileSizes.get(file) ?? 0),
1575
- forward,
1576
- totalTokens: index.totalTokens,
1577
- indexedFileCount: index.docCount,
1578
- truncated: index.truncated,
1579
- };
1580
- _lastSerializeWork = {
1581
- affectedTokenCount: affectedTokens.size,
1582
- tookFullPath: false,
1583
- };
1584
- return { serialized, slotByFileId, tokensByFile };
1585
- }
1586
- export function serializeWordIndex(index) {
1587
- const prior = serializedWordIndexCaches.get(index);
1588
- const cache = prior
1589
- ? serializeWordIndexIncrementally(index, prior)
1590
- : serializeWordIndexFull(index);
1591
- serializedWordIndexCaches.set(index, cache);
1592
- index.dirtyFiles?.clear();
1593
- return cache.serialized;
1594
- }
1595
- export function deserializeWordIndex(data) {
1596
- if (!data ||
1597
- data.version !== WORD_INDEX_FORMAT_VERSION ||
1598
- !Array.isArray(data.files) ||
1599
- !Array.isArray(data.postings) ||
1600
- !Array.isArray(data.docLengths) ||
1601
- !Array.isArray(data.fileMtimes)) {
1602
- return null;
1603
- }
1604
- let canonical = data.docLengths.length === data.files.length &&
1605
- data.fileMtimes.length === data.files.length;
1606
- let needsDeepSanitize = !Array.isArray(data.forward);
1607
- if (needsDeepSanitize)
1608
- canonical = false;
1609
- const fileKeys = new Set();
1610
- for (const file of data.files) {
1611
- if (typeof file !== "string")
1612
- return null;
1613
- const key = wordIndexKey(file);
1614
- if (fileKeys.has(key)) {
1615
- canonical = false;
1616
- needsDeepSanitize = true;
1617
- }
1618
- fileKeys.add(key);
1619
- }
1620
- const docLengths = new PathKeyedMap(wordIndexKey);
1621
- // Slot i of the wire `files` array becomes file id `fileIdBySlot[i]`. Two
1622
- // slots whose spellings fold to one key share one id, exactly as a build
1623
- // would intern them, so a divergent snapshot cannot deserialize into two
1624
- // posting identities for one document.
1625
- const fileTable = new WordIndexFileTable();
1626
- const fileIdBySlot = data.files.map((file) => fileTable.intern(wordIndexKey(file), file));
1627
- const slotByFileId = new Map();
1628
- fileIdBySlot.forEach((fileId, slot) => {
1629
- if (!slotByFileId.has(fileId))
1630
- slotByFileId.set(fileId, slot);
1631
- });
1632
- const fileMtimes = new PathKeyedMap(wordIndexKey);
1633
- const fileSizes = new PathKeyedMap(wordIndexKey);
1634
- data.files.forEach((file, i) => {
1635
- const value = data.docLengths[i];
1636
- if (!isCanonicalWordIndexNumber(value) || value < 0)
1637
- canonical = false;
1638
- docLengths.set(file, isCanonicalWordIndexNumber(value) && value >= 0 ? value : 0);
1639
- });
1640
- data.files.forEach((file, i) => {
1641
- const value = data.fileMtimes[i];
1642
- if (typeof value !== "number" || !Number.isFinite(value))
1643
- canonical = false;
1644
- fileMtimes.set(file, typeof value === "number" && Number.isFinite(value) ? value : 0);
1645
- });
1646
- // #1105: `fileSizes` is optional on the wire (pre-#1105 snapshots omit it).
1647
- // Only populate when the array is present AND parallel to `files`; otherwise
1648
- // leave it empty so the refresh gate re-reads every file once to repopulate,
1649
- // rather than trusting a bogus/misaligned size. Never treated as "current".
1650
- if (Array.isArray(data.fileSizes) &&
1651
- data.fileSizes.length === data.files.length) {
1652
- data.files.forEach((file, i) => {
1653
- const value = data.fileSizes?.[i];
1654
- if (!isCanonicalWordIndexNumber(value) || value < 0)
1655
- canonical = false;
1656
- fileSizes.set(file, isCanonicalWordIndexNumber(value) && value >= 0 ? value : 0);
1657
- });
1658
- }
1659
- else {
1660
- canonical = false;
1661
- }
1662
- const postings = new Map();
1663
- const postingTokens = new Set();
1664
- const postingFileCounts = new Map();
1665
- for (const posting of data.postings) {
1666
- if (!Array.isArray(posting) || posting.length !== 2) {
1667
- canonical = false;
1668
- needsDeepSanitize = true;
1669
- continue;
1670
- }
1671
- const [token, flat] = posting;
1672
- if (!isCanonicalWordIndexToken(token) ||
1673
- postingTokens.has(token) ||
1674
- !Array.isArray(flat) ||
1675
- flat.length === 0 ||
1676
- flat.length % 2 !== 0) {
1677
- canonical = false;
1678
- needsDeepSanitize = true;
1679
- continue;
1680
- }
1681
- postingTokens.add(token);
1682
- const lanes = [];
1683
- const seenPairs = new Set();
1684
- const observedCounts = new Map();
1685
- for (let i = 0; i < flat.length; i += 2) {
1686
- const slot = flat[i];
1687
- const line = flat[i + 1];
1688
- if (!isCanonicalWordIndexNumber(slot) ||
1689
- slot < 0 ||
1690
- slot >= fileIdBySlot.length ||
1691
- !isCanonicalWordIndexNumber(line) ||
1692
- line < 1 ||
1693
- line > WORD_INDEX_INT32_MAX) {
1694
- canonical = false;
1695
- needsDeepSanitize = true;
1696
- continue;
1697
- }
1698
- const fileId = fileIdBySlot[slot];
1699
- const pairKey = fileId <= WORD_INDEX_PACKED_FILE_ID_MAX
1700
- ? fileId * (WORD_INDEX_INT32_MAX + 1) + line
1701
- : `${fileId}:${line}`;
1702
- if (seenPairs.has(pairKey)) {
1703
- canonical = false;
1704
- needsDeepSanitize = true;
1705
- }
1706
- seenPairs.add(pairKey);
1707
- lanes.push(fileId, line);
1708
- observedCounts.set(fileId, (observedCounts.get(fileId) ?? 0) + 1);
1709
- }
1710
- if (lanes.length === 0)
1711
- continue;
1712
- const list = WordPostingList.fromLanes(token, lanes);
1713
- postings.set(token, list);
1714
- postingFileCounts.set(token, observedCounts);
1715
- }
1716
- // Every list above was sized exactly from the wire payload, so there is no
1717
- // growth slack to release — but the per-list `ArrayBuffer` headers are the
1718
- // same third-of-the-store cost a fresh build pays, and a deserialized index
1719
- // is the LONG-lived one (a warm session reuses it across turns).
1720
- compactPostingsIntoArena(postings);
1721
- let forward;
1722
- if (Array.isArray(data.forward)) {
1723
- const candidate = data.files.map(() => new Map());
1724
- const forwardTotals = new Map();
1725
- if (data.forward.length !== data.files.length)
1726
- canonical = false;
1727
- if (data.forward.length !== data.files.length)
1728
- needsDeepSanitize = true;
1729
- for (const [entryIndex, entry] of data.forward.entries()) {
1730
- if (!Array.isArray(entry) || entry.length !== 2) {
1731
- canonical = false;
1732
- needsDeepSanitize = true;
1733
- continue;
1734
- }
1735
- const [fileIdx, tokenCounts] = entry;
1736
- if (!isCanonicalWordIndexNumber(fileIdx) ||
1737
- fileIdx !== entryIndex ||
1738
- fileIdx < 0 ||
1739
- fileIdx >= data.files.length ||
1740
- !Array.isArray(tokenCounts)) {
1741
- canonical = false;
1742
- needsDeepSanitize = true;
1743
- continue;
1744
- }
1745
- for (const pair of tokenCounts) {
1746
- if (!Array.isArray(pair) ||
1747
- pair.length !== 2 ||
1748
- !isCanonicalWordIndexToken(pair[0]) ||
1749
- !isCanonicalWordIndexNumber(pair[1]) ||
1750
- pair[1] < 1 ||
1751
- pair[1] > WORD_INDEX_INT32_MAX ||
1752
- candidate[fileIdx].has(pair[0])) {
1753
- canonical = false;
1754
- needsDeepSanitize = true;
1755
- continue;
1756
- }
1757
- candidate[fileIdx].set(pair[0], pair[1]);
1758
- forwardTotals.set(pair[0], (forwardTotals.get(pair[0]) ?? 0) + pair[1]);
1759
- }
1760
- }
1761
- let forwardMatchesPostings = true;
1762
- for (const [token, list] of postings) {
1763
- if (forwardTotals.get(token) !== list.length) {
1764
- forwardMatchesPostings = false;
1765
- }
1766
- const observed = postingFileCounts.get(token);
1767
- if (!observed) {
1768
- forwardMatchesPostings = false;
1769
- continue;
1770
- }
1771
- for (const [fileId, count] of observed) {
1772
- const slot = slotByFileId.get(fileId);
1773
- if (slot === undefined || candidate[slot].get(token) !== count) {
1774
- forwardMatchesPostings = false;
1775
- }
1776
- }
1777
- }
1778
- for (const token of forwardTotals.keys()) {
1779
- if (!postingTokens.has(token))
1780
- forwardMatchesPostings = false;
1781
- }
1782
- if (!forwardMatchesPostings)
1783
- canonical = false;
1784
- if (!forwardMatchesPostings)
1785
- needsDeepSanitize = true;
1786
- forward = new PathKeyedMap(wordIndexKey);
1787
- for (let i = 0; i < data.files.length; i += 1) {
1788
- forward.set(data.files[i], WordForwardEntry.fromTally(candidate[i]));
1789
- }
1790
- }
1791
- else if (Object.prototype.hasOwnProperty.call(data, "forward")) {
1792
- canonical = false;
1793
- needsDeepSanitize = true;
1794
- }
1795
- if (needsDeepSanitize) {
1796
- const actualForwardCounts = data.files.map(() => new Map());
1797
- for (const [token, list] of postings) {
1798
- const seenPairs = new Set();
1799
- const sanitized = new WordPostingList(token, list.length);
1800
- for (let i = 0; i < list.length; i += 1) {
1801
- const fileId = list.fileIdAt(i);
1802
- const line = list.lineAt(i);
1803
- const pairKey = `${fileId}:${line}`;
1804
- if (seenPairs.has(pairKey))
1805
- continue;
1806
- seenPairs.add(pairKey);
1807
- sanitized.push(fileId, line);
1808
- const slot = slotByFileId.get(fileId);
1809
- if (slot !== undefined) {
1810
- const counts = actualForwardCounts[slot];
1811
- counts.set(token, (counts.get(token) ?? 0) + 1);
1812
- }
1813
- }
1814
- postings.set(token, sanitized);
1815
- }
1816
- compactPostingsIntoArena(postings);
1817
- if (Array.isArray(data.forward)) {
1818
- forward = new PathKeyedMap(wordIndexKey);
1819
- for (let i = 0; i < data.files.length; i += 1) {
1820
- const canonicalSlot = slotByFileId.get(fileIdBySlot[i]) ?? i;
1821
- forward.set(data.files[i], WordForwardEntry.fromTally(actualForwardCounts[canonicalSlot]));
1822
- }
1823
- }
1824
- }
1825
- const expectedTotalTokens = [...docLengths.values()].reduce((total, length) => total + length, 0);
1826
- const totalTokens = isCanonicalWordIndexNumber(expectedTotalTokens)
1827
- ? expectedTotalTokens
1828
- : 0;
1829
- if (!isCanonicalWordIndexNumber(data.totalTokens) ||
1830
- data.totalTokens !== totalTokens) {
1831
- canonical = false;
1832
- }
1833
- if (typeof data.indexedFileCount !== "number" ||
1834
- data.indexedFileCount !== docLengths.size) {
1835
- canonical = false;
1836
- }
1837
- if (typeof data.truncated !== "boolean")
1838
- canonical = false;
1839
- const index = {
1840
- postings,
1841
- fileTable,
1842
- docLengths,
1843
- totalTokens,
1844
- docCount: docLengths.size,
1845
- truncated: data.truncated === true,
1846
- forward,
1847
- fileMtimes,
1848
- fileSizes,
1849
- // Postings were just packed into one arena above, so the running gate
1850
- // starts from the exact post-compaction store count (#2117).
1851
- postingStoreCount: countPostingBackingStores(postings),
1852
- recompactFlight: createSingleFlight(),
1853
- // A deserialized index is a fresh object distinct from whichever index
1854
- // produced `data`. Without this, every per-edit dirty mark on a
1855
- // session-start reload (#2068) is a no-op against `undefined`, and
1856
- // serializeWordIndexIncrementally's `!dirty` branch then returns the
1857
- // stale cached wire view forever, silently dropping every later edit
1858
- // from the persisted snapshot.
1859
- dirtyFiles: new Set(),
1860
- };
1861
- // Seed the wire cache from the snapshot just loaded so the FIRST persist
1862
- // of a reloaded session (session-start's `snapshotSaveSyncMs`, #2068) also
1863
- // takes the bounded incremental path instead of paying a full rebuild —
1864
- // mirrors exactly what one priming `serializeWordIndex` call would cache.
1865
- const tokensByFile = new Map();
1866
- if (forward) {
1867
- for (const file of data.files) {
1868
- tokensByFile.set(wordIndexKey(file), new Set(forward.get(file)?.keys() ?? []));
1869
- }
1870
- }
1871
- if (canonical) {
1872
- serializedWordIndexCaches.set(index, {
1873
- serialized: data,
1874
- slotByFileId,
1875
- tokensByFile,
1876
- });
1877
- }
1878
- return index;
1879
- }
1880
- const buildStatuses = new Map();
1881
- export function getWordIndexBuildStatus(cwd) {
1882
- return buildStatuses.get(path.resolve(cwd));
1883
- }
1884
- /** Test-only: reset the in-flight-build guard between test files/cases. */
1885
- export function _resetWordIndexBuildGuardForTests() {
1886
- buildStatuses.clear();
1887
- }
1888
- /**
1889
- * Fire a one-time bounded background build for `cwd` if one isn't already
1890
- * running. Persists into the existing project snapshot (preserving its other
1891
- * fields) so the next query — or the next real session — picks it up. Errors
1892
- * are swallowed (this is best-effort warmth, not a request the caller is
1893
- * waiting on); the guard always clears in a `finally` so a failed build can be
1894
- * retried by a later query.
1895
- */
1896
- export function triggerBackgroundWordIndexBuild(cwd, dbg, options = {}) {
1897
- const key = path.resolve(cwd);
1898
- // #747 hardening: this trigger is the one word-index build path with NO
1899
- // session lifecycle in front of it (cold `symbol_search` queries, in-process
1900
- // and via MCP where cwd can be a raw tool argument) — the session-start and
1901
- // quick-mode-warmup builds sit behind `canWarmCaches`, which already refuses
1902
- // a home-rooted cwd. Apply the same `isAtOrAboveHomeDir` ceiling here so a
1903
- // cold query from $HOME never starts a whole-home walk-and-read.
1904
- if (isAtOrAboveHomeDir(key, options.homeDir)) {
1905
- const reason = `root at/above home directory (${key})`;
1906
- dbg?.(`word-index cold-build: skipped — ${reason}`);
1907
- logWordIndex({
1908
- phase: "cold_build_refused",
1909
- cwd: key,
1910
- trigger: "cold_query",
1911
- reason,
1912
- });
1913
- const status = { state: "refused", reason };
1914
- buildStatuses.set(key, status);
1915
- return status;
1916
- }
1917
- const current = buildStatuses.get(key);
1918
- if (current?.state === "building")
1919
- return current;
1920
- const status = { state: "building" };
1921
- buildStatuses.set(key, status);
1922
- void (async () => {
1923
- const startMs = Date.now();
1924
- try {
1925
- const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
1926
- const docs = await collectWordIndexDocs(key);
1927
- const index = await buildWordIndexAsync(docs);
1928
- const existing = loadProjectSnapshot(key);
1929
- const snapshot = existing ?? {
1930
- version: PROJECT_SNAPSHOT_VERSION,
1931
- projectRoot: key,
1932
- generatedAt: new Date().toISOString(),
1933
- seq: 0,
1934
- files: {},
1935
- symbols: {},
1936
- reverseDeps: {},
1937
- cachedExports: [],
1938
- };
1939
- snapshot.generatedAt = new Date().toISOString();
1940
- snapshot.wordIndex = serializeWordIndex(index);
1941
- saveProjectSnapshot(key, snapshot);
1942
- dbg?.(`word-index cold-build: ${index.docCount} files, ${index.postings.size} tokens (${Date.now() - startMs}ms)`);
1943
- // M2, #958: durable decision/coverage record for the MCP-critical cold
1944
- // path (this is where symbol_search reads the index and dbg is a no-op).
1945
- logWordIndex({
1946
- phase: "cold_build",
1947
- cwd: key,
1948
- trigger: "cold_query",
1949
- durationMs: Date.now() - startMs,
1950
- indexedFileCount: index.docCount,
1951
- tokens: index.postings.size,
1952
- postingEntries: countWordIndexPostingEntries(index),
1953
- residentBytes: estimateWordIndexResidentBytes(index),
1954
- truncated: index.truncated,
1955
- skipped: docs.skipped,
1956
- });
1957
- buildStatuses.delete(key);
1958
- }
1959
- catch (err) {
1960
- const reason = err instanceof Error ? err.message : String(err);
1961
- buildStatuses.set(key, { state: "failed", reason });
1962
- dbg?.(`word-index cold-build: failed: ${reason}`);
1963
- logWordIndex({
1964
- phase: "cold_build_failed",
1965
- cwd: key,
1966
- trigger: "cold_query",
1967
- durationMs: Date.now() - startMs,
1968
- error: reason,
1969
- });
1970
- }
1971
- })();
1972
- return status;
1973
- }
1974
- // --- Debounced per-edit persist (#348 phase 2) --------------------------------
1975
- //
1976
- // The per-edit seam (dispatch/integration.ts) updates `runtime.wordIndex` in
1977
- // memory on every write, same as the review graph's per-edit rebuild. Without
1978
- // coalescing, persisting that in-memory index on every single edit would mean
1979
- // one full-snapshot JSON.stringify+write per keystroke-adjacent edit — the
1980
- // same OOM-risking spike the graph's #260 circuit-breaker exists to prevent.
1981
- // This reuses `createDebounceScheduler` (persist-debounce.ts) rather than
1982
- // growing a second copy of the graph's bespoke pending-map+timer bookkeeping;
1983
- // only the "write" callback differs, because the target differs: the graph
1984
- // owns its own cache file, but the word index must merge into the SHARED
1985
- // project-snapshot file via `saveRuntimeProjectSnapshot`/`saveProjectSnapshot`
1986
- // (preserving unrelated snapshot fields, and honoring the seq-laundering guard
1987
- // in project-snapshot.ts — see saveRuntimeProjectSnapshot's comment).
1988
- const WORD_INDEX_PERSIST_DEBOUNCE_MS_DEFAULT = 1500;
1989
- function wordIndexPersistDebounceMs() {
1990
- const raw = Number(process.env.PI_LENS_WORD_INDEX_PERSIST_DEBOUNCE_MS);
1991
- return Number.isFinite(raw) && raw >= 0
1992
- ? raw
1993
- : WORD_INDEX_PERSIST_DEBOUNCE_MS_DEFAULT;
1994
- }
1995
- let wordIndexPersistScheduler;
1996
- function getWordIndexPersistScheduler() {
1997
- if (wordIndexPersistScheduler)
1998
- return wordIndexPersistScheduler;
1999
- wordIndexPersistScheduler = createDebounceScheduler({
2000
- debounceMs: wordIndexPersistDebounceMs,
2001
- write(_key, pending) {
2002
- void writeWordIndexSnapshot(pending.cwd, pending.index, pending.dbg);
2003
- },
2004
- });
2005
- return wordIndexPersistScheduler;
2006
- }
2007
- async function writeWordIndexSnapshot(cwd, index, dbg) {
2008
- const persistStartedAt = Date.now();
2009
- try {
2010
- const { loadProjectSnapshot, saveProjectSnapshot, PROJECT_SNAPSHOT_VERSION, } = await import("./project-snapshot.js");
2011
- const existing = loadProjectSnapshot(cwd);
2012
- const snapshot = existing ?? {
2013
- version: PROJECT_SNAPSHOT_VERSION,
2014
- projectRoot: path.resolve(cwd),
2015
- generatedAt: new Date().toISOString(),
2016
- seq: 0,
2017
- files: {},
2018
- symbols: {},
2019
- reverseDeps: {},
2020
- cachedExports: [],
2021
- };
2022
- snapshot.generatedAt = new Date().toISOString();
2023
- const serializeStartedAt = performance.now();
2024
- snapshot.wordIndex = serializeWordIndex(index);
2025
- const serializeMs = performance.now() - serializeStartedAt;
2026
- saveProjectSnapshot(cwd, snapshot);
2027
- const writeMs = performance.now() - serializeStartedAt - serializeMs;
2028
- dbg?.(`word-index persist: ${index.docCount} files, ${index.postings.size} tokens`);
2029
- // #958 review F1: durably record persist SUCCESS too, not just failures —
2030
- // in the MCP host (`dbg` is a no-op) this is the only signal that the index
2031
- // is actually being kept fresh across edits. Debounced (~1.5s), so not
2032
- // spammy. Mirrors the review graph's persist_succeeded.
2033
- logWordIndex({
2034
- phase: "persist_succeeded",
2035
- cwd: path.resolve(cwd),
2036
- trigger: "per_edit",
2037
- durationMs: Date.now() - persistStartedAt,
2038
- serializeMs,
2039
- writeMs,
2040
- indexedFileCount: index.docCount,
2041
- tokens: index.postings.size,
2042
- postingEntries: countWordIndexPostingEntries(index),
2043
- residentBytes: estimateWordIndexResidentBytes(index),
2044
- replacementCount: index.replacementStats?.count ?? 0,
2045
- totalReplacementMs: index.replacementStats?.totalMs ?? 0,
2046
- maxReplacementMs: index.replacementStats?.maxMs ?? 0,
2047
- });
2048
- index.replacementStats = { count: 0, totalMs: 0, maxMs: 0 };
2049
- }
2050
- catch (err) {
2051
- dbg?.(`word-index persist: failed: ${err}`);
2052
- // M3, #958: a swallowed persist means every LATER symbol_search reads a
2053
- // stale index with no trace — the exact silent-failure this durable log
2054
- // exists to surface (dbg is a no-op in the MCP host).
2055
- logWordIndex({
2056
- phase: "persist_failed",
2057
- cwd: path.resolve(cwd),
2058
- trigger: "per_edit",
2059
- indexedFileCount: index.docCount,
2060
- error: err instanceof Error ? err.message : String(err),
2061
- });
2062
- }
2063
- }
2064
- /**
2065
- * Schedule a debounced persist of `index` for `cwd`, coalescing a burst of
2066
- * per-edit updates into one write after a quiet window (default 1500ms,
2067
- * override via `PI_LENS_WORD_INDEX_PERSIST_DEBOUNCE_MS`, mirroring the review
2068
- * graph's `PI_LENS_GRAPH_PERSIST_DEBOUNCE_MS`). Merges through the same
2069
- * `saveProjectSnapshot` path phase 1 uses — preserves unrelated snapshot
2070
- * fields and respects the seq-laundering guard (only ever writes wordIndex
2071
- * for the CURRENT in-memory index, never re-stamps a stale one).
2072
- */
2073
- export function scheduleWordIndexPersist(cwd, index, dbg) {
2074
- const key = path.resolve(cwd);
2075
- getWordIndexPersistScheduler().schedule(key, { cwd: key, index, dbg });
2076
- }
2077
- /** Test hook: force any pending debounced word-index persist to write immediately. */
2078
- export function flushWordIndexPersistsForTests() {
2079
- getWordIndexPersistScheduler().flushAll();
2080
- }