pi-lens 3.8.73 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/CHANGELOG.md +2614 -12
  2. package/README.md +50 -21
  3. package/dist/clients/actionable-warnings.js +73 -18
  4. package/dist/clients/agent-behavior-client.js +21 -4
  5. package/dist/clients/ast-grep-client.js +68 -16
  6. package/dist/clients/ast-grep-types.js +0 -1
  7. package/dist/clients/ast-grep-yaml-synth.js +37 -10
  8. package/dist/clients/atomic-write-staging.js +63 -0
  9. package/dist/clients/atomic-write.js +116 -16
  10. package/dist/clients/bash-file-access.js +193 -23
  11. package/dist/clients/biome-client.js +29 -25
  12. package/dist/clients/bootstrap.js +11 -2
  13. package/dist/clients/bounded-cache.js +34 -0
  14. package/dist/clients/bounded-pid-file-lock.js +192 -0
  15. package/dist/clients/bus-events-logger.js +5 -1
  16. package/dist/clients/bus-publish.js +10 -3
  17. package/dist/clients/cache/rule-cache.js +89 -6
  18. package/dist/clients/cache-manager.js +97 -9
  19. package/dist/clients/cache-observability.js +498 -0
  20. package/dist/clients/call-graph.js +460 -108
  21. package/dist/clients/cascade-logger.js +5 -1
  22. package/dist/clients/child-unref.js +85 -0
  23. package/dist/clients/codebase-model.js +52 -23
  24. package/dist/clients/collateral-test-role.js +40 -0
  25. package/dist/clients/complexity-client.js +6 -8
  26. package/dist/clients/console-guard-install.js +14 -0
  27. package/dist/clients/cooperative-budget.js +42 -0
  28. package/dist/clients/dead-code-client.js +2 -1
  29. package/dist/clients/debug-handles.js +206 -0
  30. package/dist/clients/debug-heap.js +167 -0
  31. package/dist/clients/degradation-ledger.js +153 -0
  32. package/dist/clients/dependency-checker.js +466 -63
  33. package/dist/clients/diagnostic-dispositions.js +83 -13
  34. package/dist/clients/diagnostics-publish.js +10 -3
  35. package/dist/clients/dispatch/auxiliary-lsp.js +34 -0
  36. package/dist/clients/dispatch/dispatcher.js +71 -13
  37. package/dist/clients/dispatch/fact-store.js +11 -0
  38. package/dist/clients/dispatch/facts/function-facts.js +37 -7
  39. package/dist/clients/dispatch/facts/import-facts.js +108 -119
  40. package/dist/clients/dispatch/facts/tree-sitter-facts.js +12 -12
  41. package/dist/clients/dispatch/facts/try-catch-facts.js +13 -0
  42. package/dist/clients/dispatch/indent-detect.js +35 -0
  43. package/dist/clients/dispatch/inline-suppressions.js +17 -8
  44. package/dist/clients/dispatch/integration.js +1094 -611
  45. package/dist/clients/dispatch/lazy.js +14 -0
  46. package/dist/clients/dispatch/plan.js +11 -1
  47. package/dist/clients/dispatch/rule-id-normalize.js +50 -0
  48. package/dist/clients/dispatch/rule-policy.js +135 -0
  49. package/dist/clients/dispatch/rules/async-noise.js +1 -0
  50. package/dist/clients/dispatch/rules/unsafe-boundary.js +7 -0
  51. package/dist/clients/dispatch/runners/ast-grep-napi.js +47 -6
  52. package/dist/clients/dispatch/runners/biome-check.js +5 -9
  53. package/dist/clients/dispatch/runners/detekt.js +3 -2
  54. package/dist/clients/dispatch/runners/hadolint.js +2 -1
  55. package/dist/clients/dispatch/runners/helm-lint.js +154 -0
  56. package/dist/clients/dispatch/runners/htmlhint.js +3 -2
  57. package/dist/clients/dispatch/runners/index.js +5 -1
  58. package/dist/clients/dispatch/runners/lsp.js +14 -6
  59. package/dist/clients/dispatch/runners/markdownlint.js +4 -9
  60. package/dist/clients/dispatch/runners/psscriptanalyzer.js +10 -1
  61. package/dist/clients/dispatch/runners/pyright.js +9 -5
  62. package/dist/clients/dispatch/runners/ruff.js +4 -8
  63. package/dist/clients/dispatch/runners/shellcheck.js +2 -3
  64. package/dist/clients/dispatch/runners/shfmt.js +2 -3
  65. package/dist/clients/dispatch/runners/spotbugs.js +2 -1
  66. package/dist/clients/dispatch/runners/taplo.js +2 -1
  67. package/dist/clients/dispatch/runners/terragrunt.js +163 -0
  68. package/dist/clients/dispatch/runners/tflint.js +37 -5
  69. package/dist/clients/dispatch/runners/tree-sitter.js +155 -166
  70. package/dist/clients/dispatch/runners/trivy-config.js +13 -7
  71. package/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  72. package/dist/clients/dispatch/runners/utils/runner-helpers.js +284 -34
  73. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  74. package/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  75. package/dist/clients/disposition-publish.js +10 -3
  76. package/dist/clients/durable-store.js +97 -0
  77. package/dist/clients/event-loop-monitor.js +94 -3
  78. package/dist/clients/extension-log.js +139 -0
  79. package/dist/clients/extension-mode.js +73 -0
  80. package/dist/clients/feature-hints.js +53 -7
  81. package/dist/clients/file-kinds.js +143 -31
  82. package/dist/clients/file-role.js +18 -3
  83. package/dist/clients/file-utils.js +56 -34
  84. package/dist/clients/fix-worklog.js +14 -2
  85. package/dist/clients/format-events-publish.js +16 -3
  86. package/dist/clients/format-service.js +24 -8
  87. package/dist/clients/formatters-lazy.js +8 -0
  88. package/dist/clients/formatters.js +358 -61
  89. package/dist/clients/generated-artifacts.js +169 -15
  90. package/dist/clients/git-guard.js +681 -11
  91. package/dist/clients/git-tracked-ignore.js +4 -0
  92. package/dist/clients/go-client.js +2 -1
  93. package/dist/clients/govulncheck-client.js +8 -0
  94. package/dist/clients/grammar-source.js +12 -4
  95. package/dist/clients/gzip-stage-write.js +102 -0
  96. package/dist/clients/host-ports.js +31 -0
  97. package/dist/clients/installer/index.js +733 -213
  98. package/dist/clients/instance-reaper.js +142 -172
  99. package/dist/clients/jscpd-client.js +31 -54
  100. package/dist/clients/knip-client.js +78 -36
  101. package/dist/clients/language-policy.js +14 -2
  102. package/dist/clients/language-profile.js +72 -77
  103. package/dist/clients/latency-logger.js +28 -16
  104. package/dist/clients/lens-config.js +140 -166
  105. package/dist/clients/lens-engine.js +124 -21
  106. package/dist/clients/lens-events.js +27 -4
  107. package/dist/clients/lens-flag-registry.js +293 -0
  108. package/dist/clients/lens-map.js +96 -26
  109. package/dist/clients/live-bus-emitter.js +30 -0
  110. package/dist/clients/log-cleanup.js +8 -18
  111. package/dist/clients/lsp/cascade-tier.js +23 -3
  112. package/dist/clients/lsp/client.js +566 -57
  113. package/dist/clients/lsp/config.js +74 -10
  114. package/dist/clients/lsp/diagnostic-binding.js +86 -0
  115. package/dist/clients/lsp/edits.js +1012 -121
  116. package/dist/clients/lsp/index.js +1840 -803
  117. package/dist/clients/lsp/language.js +4 -1
  118. package/dist/clients/lsp/launch.js +40 -32
  119. package/dist/clients/lsp/path-utils.js +1 -1
  120. package/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  121. package/dist/clients/lsp/server.js +279 -60
  122. package/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  123. package/dist/clients/lsp-document-symbols.js +112 -0
  124. package/dist/clients/lsp-lazy.js +8 -0
  125. package/dist/clients/lsp-mutation.js +248 -0
  126. package/dist/clients/mcp/analyze.js +128 -36
  127. package/dist/clients/mcp/ipc.js +157 -24
  128. package/dist/clients/mcp/review.js +36 -3
  129. package/dist/clients/mcp/session.js +216 -8
  130. package/dist/clients/memory-sampler.js +115 -0
  131. package/dist/clients/metrics-client.js +2 -1
  132. package/dist/clients/metrics-history.js +2 -1
  133. package/dist/clients/module-report-lsp.js +5 -0
  134. package/dist/clients/module-report.js +267 -82
  135. package/dist/clients/ndjson-logger.js +334 -103
  136. package/dist/clients/opengrep-client.js +1 -1
  137. package/dist/clients/partial-edit-apply.js +120 -5
  138. package/dist/clients/path-keyed-map.js +76 -0
  139. package/dist/clients/path-utils.js +141 -5
  140. package/dist/clients/performance-report.js +327 -0
  141. package/dist/clients/pipeline.js +54 -13
  142. package/dist/clients/print-mode.js +21 -0
  143. package/dist/clients/project-changes.js +117 -4
  144. package/dist/clients/project-diagnostics/cache.js +3 -2
  145. package/dist/clients/project-diagnostics/extractors.js +26 -115
  146. package/dist/clients/project-diagnostics/fresh-fetch.js +149 -19
  147. package/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +117 -0
  148. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +37 -5
  149. package/dist/clients/project-diagnostics/scanner.js +268 -128
  150. package/dist/clients/project-lens-config.js +138 -34
  151. package/dist/clients/project-report.js +64 -25
  152. package/dist/clients/project-scale.js +8 -10
  153. package/dist/clients/project-snapshot-persist-worker.js +6 -0
  154. package/dist/clients/project-snapshot.js +767 -8
  155. package/dist/clients/project-trust.js +181 -0
  156. package/dist/clients/quiet-window.js +27 -5
  157. package/dist/clients/read-bridge.js +147 -0
  158. package/dist/clients/read-expansion.js +22 -12
  159. package/dist/clients/read-guard-logger.js +159 -1
  160. package/dist/clients/read-guard-tool-lines.js +150 -29
  161. package/dist/clients/read-guard.js +262 -3
  162. package/dist/clients/redact/secrets.js +449 -0
  163. package/dist/clients/resource-sampler.js +144 -39
  164. package/dist/clients/reverse-deps.js +48 -0
  165. package/dist/clients/review-graph/builder.js +2570 -326
  166. package/dist/clients/review-graph/git-identity.js +2 -1
  167. package/dist/clients/review-graph/import-resolvers.js +21 -10
  168. package/dist/clients/review-graph/persist-worker.js +10 -0
  169. package/dist/clients/review-graph/query.js +5 -0
  170. package/dist/clients/review-graph/shared-extraction-ir.js +90 -0
  171. package/dist/clients/review-graph/symbol-id.js +46 -0
  172. package/dist/clients/review-graph/tsconfig-paths.js +57 -6
  173. package/dist/clients/review-graph/workspace-modules.js +51 -3
  174. package/dist/clients/review-graph-logger.js +53 -0
  175. package/dist/clients/ruff-client.js +27 -44
  176. package/dist/clients/runtime-agent-end.js +111 -75
  177. package/dist/clients/runtime-context.js +36 -12
  178. package/dist/clients/runtime-coordinator.js +76 -8
  179. package/dist/clients/runtime-session.js +799 -137
  180. package/dist/clients/runtime-tool-call.js +117 -62
  181. package/dist/clients/runtime-tool-result.js +218 -23
  182. package/dist/clients/runtime-turn.js +284 -40
  183. package/dist/clients/rust-client.js +2 -1
  184. package/dist/clients/safe-spawn.js +873 -71
  185. package/dist/clients/security-scan-client.js +2 -1
  186. package/dist/clients/session-start-observability.js +15 -0
  187. package/dist/clients/session-state-store.js +5 -3
  188. package/dist/clients/sessionstart-logger.js +18 -0
  189. package/dist/clients/sg-runner.js +284 -159
  190. package/dist/clients/slow-fs.js +2 -1
  191. package/dist/clients/smells-rollup.js +224 -0
  192. package/dist/clients/source-filter.js +231 -43
  193. package/dist/clients/source-walker.js +66 -10
  194. package/dist/clients/startup-scan.js +15 -16
  195. package/dist/clients/startup-timing.js +9 -0
  196. package/dist/clients/test-runner-client.js +38 -8
  197. package/dist/clients/todo-scanner.js +30 -3
  198. package/dist/clients/tool-event.js +35 -0
  199. package/dist/clients/tool-policy.js +388 -39
  200. package/dist/clients/tool-render.js +155 -0
  201. package/dist/clients/tree-sitter-cache.js +153 -140
  202. package/dist/clients/tree-sitter-client.js +1722 -80
  203. package/dist/clients/tree-sitter-logger.js +64 -1
  204. package/dist/clients/tree-sitter-query-loader.js +117 -12
  205. package/dist/clients/tree-sitter-shared.js +48 -19
  206. package/dist/clients/tree-sitter-symbol-extractor.js +115 -11
  207. package/dist/clients/user-notify.js +51 -0
  208. package/dist/clients/vanished-instance-marker.js +57 -0
  209. package/dist/clients/warm-attach.js +33 -13
  210. package/dist/clients/widget-state.js +342 -49
  211. package/dist/clients/word-index-logger.js +44 -0
  212. package/dist/clients/word-index.js +612 -68
  213. package/dist/clients/workspace-topology.js +100 -7
  214. package/dist/index.js +65923 -47161
  215. package/dist/mcp/analyze-cli.js +185 -29
  216. package/dist/mcp/cli.js +110 -0
  217. package/dist/mcp/server.js +272 -50
  218. package/dist/tools/ast-grep-search.js +93 -27
  219. package/dist/tools/lens-diagnostics.js +247 -41
  220. package/dist/tools/lsp-diagnostics.js +153 -40
  221. package/dist/tools/lsp-navigation.js +51 -31
  222. package/dist/tools/module-report.js +9 -0
  223. package/dist/tools/symbol-search.js +21 -2
  224. package/docs/agent-guide.md +247 -0
  225. package/docs/agent-tools.md +21 -10
  226. package/docs/analysisall.md +192 -0
  227. package/docs/api-ports-inventory.md +165 -0
  228. package/docs/ast-grep_rules_catalog.md +5 -2
  229. package/docs/astplayground.md +3 -4
  230. package/docs/audit1.md +5 -5
  231. package/docs/durable-store-audit-1202.md +32 -0
  232. package/docs/environment-variables.md +197 -11
  233. package/docs/fable.md +27 -0
  234. package/docs/features.md +35 -5
  235. package/docs/globalconfig.md +101 -5
  236. package/docs/language-coverage.md +6 -5
  237. package/docs/lsp-capability-matrix.md +2 -2
  238. package/docs/mcp.md +54 -20
  239. package/docs/servercapabilities.md +7 -4
  240. package/docs/settings.md +192 -0
  241. package/docs/tools.md +18 -1
  242. package/docs/tree-sitter_rules_catalog.md +11 -31
  243. package/docs/usage.md +31 -0
  244. package/package.json +139 -138
  245. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  246. package/rules/ast-grep-rules/rule-tests/hardcoded-url-test.yml +2 -0
  247. package/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  248. package/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  249. package/rules/ast-grep-rules/rule-tests/no-case-declarations-js-test.yml +10 -0
  250. package/rules/ast-grep-rules/rule-tests/no-case-declarations-test.yml +10 -0
  251. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +10 -0
  252. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +10 -0
  253. package/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  254. package/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  255. package/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  256. package/rules/ast-grep-rules/rule-tests/strict-equality-js-test.yml +3 -0
  257. package/rules/ast-grep-rules/rule-tests/strict-equality-test.yml +3 -0
  258. package/rules/ast-grep-rules/rule-tests/strict-inequality-js-test.yml +3 -0
  259. package/rules/ast-grep-rules/rule-tests/strict-inequality-test.yml +4 -0
  260. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +22 -4
  261. package/rules/ast-grep-rules/rules/hardcoded-url.yml +22 -4
  262. package/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  263. package/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  264. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +4 -3
  265. package/rules/ast-grep-rules/rules/no-case-declarations.yml +4 -3
  266. package/rules/ast-grep-rules/rules/no-console-except-error-js.yml +10 -0
  267. package/rules/ast-grep-rules/rules/no-console-except-error.yml +10 -0
  268. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +42 -6
  269. package/rules/ast-grep-rules/rules/no-javascript-url.yml +42 -6
  270. package/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  271. package/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  272. package/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  273. package/rules/ast-grep-rules/rules/strict-equality-js.yml +14 -1
  274. package/rules/ast-grep-rules/rules/strict-equality.yml +14 -1
  275. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +14 -1
  276. package/rules/ast-grep-rules/rules/strict-inequality.yml +14 -1
  277. package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +9 -3
  278. package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +6 -6
  279. package/rules/tree-sitter-queries/css/calc-spacing.yml +8 -6
  280. package/rules/tree-sitter-queries/go/go-mutex-copy.yml +21 -18
  281. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +3 -3
  282. package/rules/tree-sitter-queries/java/infinite-loop-java.yml +3 -3
  283. package/rules/tree-sitter-queries/java/junit-call-super.yml +6 -4
  284. package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +10 -8
  285. package/rules/tree-sitter-queries/java/no-field-shadowing.yml +1 -1
  286. package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +2 -3
  287. package/rules/tree-sitter-queries/java/resources-closed.yml +2 -2
  288. package/rules/tree-sitter-queries/java/springboot-default-package.yml +6 -3
  289. package/rules/tree-sitter-queries/java/switch-fall-through.yml +6 -4
  290. package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +4 -3
  291. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +6 -5
  292. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +5 -8
  293. package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +3 -2
  294. package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +9 -5
  295. package/rules/tree-sitter-queries/php/this-in-static-context.yml +5 -5
  296. package/rules/tree-sitter-queries/python/exit-signature-check.yml +2 -9
  297. package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +3 -4
  298. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +4 -8
  299. package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +1 -5
  300. package/rules/tree-sitter-queries/python/python-empty-except.yml +1 -1
  301. package/rules/tree-sitter-queries/python/return-in-init.yml +0 -2
  302. package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +0 -3
  303. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +1 -12
  304. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +1 -1
  305. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +4 -4
  306. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +4 -11
  307. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +1 -1
  308. package/rules/tree-sitter-queries/ruby/ruby-string-eval.yml +2 -3
  309. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +4 -4
  310. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +4 -3
  311. package/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  312. package/rules/tree-sitter-queries/typescript/console-statement.yml +11 -0
  313. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +4 -9
  314. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +13 -9
  315. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +2 -2
  316. package/rules/tree-sitter-queries/typescript/mixed-async-styles.yml +10 -1
  317. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +5 -8
  318. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +7 -10
  319. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +4 -2
  320. package/rules/typos/_typos.toml +17 -0
  321. package/scripts/analyze-pi-lens-logs.mjs +55 -2
  322. package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +5 -0
  323. package/dist/clients/ast-grep-parser.js +0 -86
  324. package/dist/clients/config-enabled-shape.js +0 -39
  325. package/dist/clients/lsp/interactive-install.js +0 -367
  326. package/dist/clients/lsp/lsp-index.js +0 -10
  327. package/dist/clients/lsp/server-strategies.js +0 -5
  328. package/dist/clients/project-metadata.js +0 -690
  329. package/dist/clients/source-groups.js +0 -96
  330. package/dist/clients/types.js +0 -11
  331. package/docs/env_variables.md +0 -10
  332. package/rules/tree-sitter-queries/abap-disabled/delete-where.yml +0 -46
  333. package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +0 -59
  334. package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +0 -58
  335. package/rules/tree-sitter-queries/java/mockito-initialized.yml +0 -66
  336. package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +0 -58
  337. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +0 -57
  338. package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +0 -75
  339. package/rules/tree-sitter-queries/plsql-disabled/delete-update-where.yml +0 -56
  340. package/rules/tree-sitter-queries/plsql-disabled/end-loop-semicolon.yml +0 -51
  341. package/rules/tree-sitter-queries/plsql-disabled/fetch-bulk-collect-limit.yml +0 -47
  342. package/rules/tree-sitter-queries/plsql-disabled/forallsave-exceptions.yml +0 -51
  343. package/rules/tree-sitter-queries/plsql-disabled/nchar-nvarchar2-bytes.yml +0 -54
  344. package/rules/tree-sitter-queries/plsql-disabled/not-null-initialization.yml +0 -59
  345. package/rules/tree-sitter-queries/plsql-disabled/raise-application-error-codes.yml +0 -50
  346. package/rules/tree-sitter-queries/typescript-disabled/ts-path-traversal.yml +0 -71
@@ -2,8 +2,33 @@ import { stat } from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { visibleWidth } from "./deps/pi-tui.js";
5
+ import { normalizeEphemeralMapKey, normalizeMapKey } from "./path-utils.js";
5
6
  import { fitLine } from "./tui-fit.js";
6
7
  import { WriteOrderingGuard } from "./write-ordering-guard.js";
8
+ /**
9
+ * Canonical key for the `files` map (and `diagnosticsWriteGuard`) — #1020.
10
+ *
11
+ * The SAME file reaches this module under DIFFERENT path forms in one session:
12
+ * forward-slash (`C:/…/x.ts`) from the LSP client + cascade fold via
13
+ * `normalizeFilePath`, and backslash (`C:\…\x.ts`) from mode=full's reconcile
14
+ * writing `result.filePath` and from `path.resolve`/event inputs on Windows.
15
+ * Keyed raw, those coexisted as two entries: `mode=full` re-keyed on read and
16
+ * the clean entry hid the stale one, but `mode=all`'s `formatAllMode` reads the
17
+ * summaries verbatim and rendered the stale `blocking:1` as a 🔴 (#1020) — a
18
+ * resolved state that replayed as still-broken on every `mode=all`.
19
+ *
20
+ * `normalizeEphemeralMapKey` (slash-fold + win32-lowercase, NO filesystem I/O)
21
+ * is chosen over `normalizeMapKey`/`normalizeFilePath`, which call
22
+ * `realpathSync.native()` — real disk I/O on EVERY diagnostic/runner/formatter
23
+ * write, far too heavy for this hot path. The key only needs to be a stable
24
+ * syntactic fold that collapses `\`↔`/` and Windows drive-letter case, which
25
+ * this does; on-disk canonical casing is irrelevant for merely deduplicating a
26
+ * process-local footer cache. The human-readable path is preserved separately
27
+ * on the record's `filePath` (see `toDisplayPath`) for rendering/summaries.
28
+ */
29
+ function fileMapKey(filePath) {
30
+ return normalizeEphemeralMapKey(filePath);
31
+ }
7
32
  /**
8
33
  * A diagnostic is "blocking" when pi-lens classifies it as a hard stop
9
34
  * (`semantic === "blocking"`). Falls back to severity for sources that
@@ -32,7 +57,42 @@ let requestRenderFn = null;
32
57
  * `clients/runtime-tool-result.ts:nextWriteIndex`).
33
58
  */
34
59
  const diagnosticsWriteGuard = new WriteOrderingGuard();
60
+ /**
61
+ * Runner completions also mutate the shared file record: they mark the
62
+ * diagnostics snapshot pending. Keep that mutation in the same per-file order
63
+ * as the final diagnostic replacement, or an older pipeline can set a newer
64
+ * confirmed-clean record back to `(pending)` (#1198).
65
+ */
66
+ const runnerWriteGuard = new WriteOrderingGuard();
35
67
  const MAX_STORED_DIAGNOSTICS_PER_FILE = 12;
68
+ const MAX_INACTIVE_FILE_RECORDS = 1024;
69
+ const ACTIVE_FILE_IDLE_MS = 30 * 60_000;
70
+ const MAX_LSP_SERVER_RECORDS = 128;
71
+ // Pruning is a cold-size-boundary operation. Do not walk the whole file map
72
+ // for every record in a large diagnostics reconciliation; the full-scan path
73
+ // can legitimately create thousands of records in one synchronous batch.
74
+ let nextInactivePruneSize = MAX_INACTIVE_FILE_RECORDS + 1;
75
+ function pruneInactiveFileRecords(now = Date.now()) {
76
+ if (files.size <= MAX_INACTIVE_FILE_RECORDS)
77
+ return;
78
+ const victims = [...files.entries()]
79
+ .filter(([, rec]) => now - rec.touchedAt > ACTIVE_FILE_IDLE_MS &&
80
+ !hasLiveDiagnostic(rec))
81
+ .sort(([, a], [, b]) => a.touchedAt - b.touchedAt);
82
+ for (const [key] of victims) {
83
+ if (files.size <= MAX_INACTIVE_FILE_RECORDS)
84
+ break;
85
+ files.delete(key);
86
+ }
87
+ }
88
+ function maybePruneInactiveFileRecords() {
89
+ if (files.size < nextInactivePruneSize)
90
+ return;
91
+ pruneInactiveFileRecords();
92
+ // A live-heavy map may remain above the soft bound. Do not rescan it for
93
+ // every subsequent file; the next lifecycle starts with a fresh state map.
94
+ nextInactivePruneSize = Number.POSITIVE_INFINITY;
95
+ }
36
96
  // ── Public API ────────────────────────────────────────────────────────────────
37
97
  export function setRenderCallback(fn) {
38
98
  requestRenderFn = fn;
@@ -43,8 +103,15 @@ export function clearWidgetState() {
43
103
  sessionLanguages = [];
44
104
  requestRenderFn = null;
45
105
  diagnosticsWriteGuard.clear();
46
- }
47
- const WIDGET_STATE_VERSION = 1;
106
+ runnerWriteGuard.clear();
107
+ nextInactivePruneSize = MAX_INACTIVE_FILE_RECORDS + 1;
108
+ }
109
+ // v1 → v2 (#1186): per-entry `WidgetDiagnostic.observedAt`. v2 is a SUPERSET of
110
+ // v1 (the field is additive/optional), so `importWidgetState` accepts either and
111
+ // migrates a v1 record by inheriting each entry's `observedAt` from the record's
112
+ // `touchedAt`. A v1 file must never be rejected (that would silently drop resume
113
+ // diagnostics) nor crash.
114
+ export const WIDGET_STATE_VERSION = 2;
48
115
  /**
49
116
  * Snapshot the per-file widget diagnostics for persistence (#190). Excludes
50
117
  * `lspServers` — those are process-bound (servers re-spawn fresh on the next
@@ -71,9 +138,29 @@ export function exportWidgetState() {
71
138
  * Replaces the in-memory `files` map; ignores snapshots from a different
72
139
  * version. Triggers a re-render if a callback is registered.
73
140
  */
141
+ /**
142
+ * #1186 v1→v2 migration: stamp each entry that lacks a per-entry `observedAt`
143
+ * with the record's `touchedAt` (the single stamp the whole record shared under
144
+ * v1). Non-mutating; entries that already carry a stamp (a v2 record) pass
145
+ * through untouched.
146
+ */
147
+ function migrateEntryStamps(entries, recordTouchedAt) {
148
+ return (entries ?? []).map((d) => d.observedAt == null ? { ...d, observedAt: recordTouchedAt } : d);
149
+ }
74
150
  export function importWidgetState(state) {
75
- if (!state || state.version !== WIDGET_STATE_VERSION)
151
+ // Accept any known-or-older version and migrate (#1186): reject a missing
152
+ // snapshot, a missing/non-numeric `version` (NaN/undefined/null — the
153
+ // pre-#1186 guard `version !== WIDGET_STATE_VERSION` rejected these, and
154
+ // loosening that would silently admit a malformed/foreign snapshot), or a
155
+ // FUTURE version this build can't understand. Rejecting a v1
156
+ // (pre-per-entry-stamp) file, by contrast, would silently drop all resume
157
+ // diagnostics — so v1..current are accepted and migrated.
158
+ if (!state ||
159
+ typeof state.version !== "number" ||
160
+ state.version < 1 ||
161
+ state.version > WIDGET_STATE_VERSION) {
76
162
  return false;
163
+ }
77
164
  files.clear();
78
165
  // A resumed session's writeIndex counter starts fresh (#190 rehydration is
79
166
  // process-bound like lspServers, see the export above) — any ordering
@@ -81,22 +168,39 @@ export function importWidgetState(state) {
81
168
  // drop them rather than risk a legitimate post-resume write being read as
82
169
  // "superseded" against a stale token.
83
170
  diagnosticsWriteGuard.clear();
171
+ runnerWriteGuard.clear();
84
172
  for (const f of state.files ?? []) {
85
- files.set(f.filePath, {
173
+ // Fold persisted keys through the same normalizer as live writes (#1020),
174
+ // or a persisted forward-slash key stays split from a fresh backslash key
175
+ // across a resumed session — a primary repro condition. Keep a readable
176
+ // display path on the record.
177
+ // #1186 migration: a v1 record's entries have no per-entry `observedAt`.
178
+ // Inherit the record's `touchedAt` (a safe, over-conservative default —
179
+ // the whole record shared that one stamp before), so the per-entry stale
180
+ // gate has a concrete observation time and never treats `undefined` as
181
+ // epoch-0 (which would drop every migrated entry on the first sweep).
182
+ const recordTouchedAt = f.touchedAt ?? Date.now();
183
+ files.set(fileMapKey(f.filePath), {
86
184
  filePath: f.filePath,
87
185
  runners: new Map(f.runners ?? []),
88
- formatters: new Map(f.formatters ?? []),
89
- diagnostics: f.diagnostics ?? [],
90
- allDiagnostics: f.allDiagnostics ?? [],
186
+ // Failure entries do NOT survive a session restore (#1348 review):
187
+ // a fmt-failed marker is live advice about THIS session's last
188
+ // attempt; rehydrating one from a snapshot shows a stale failure the
189
+ // current session never observed (and same-mtime fixes would never
190
+ // clear it). Successes rehydrate as before.
191
+ formatters: new Map((f.formatters ?? []).filter(([, outcome]) => outcome?.success !== false)),
192
+ diagnostics: migrateEntryStamps(f.diagnostics, recordTouchedAt),
193
+ allDiagnostics: migrateEntryStamps(f.allDiagnostics, recordTouchedAt),
91
194
  diagnosticCounts: f.diagnosticCounts ?? {
92
195
  blocking: 0,
93
196
  errors: 0,
94
197
  warnings: 0,
95
198
  },
96
199
  hasFinalDiagnosticsSnapshot: f.hasFinalDiagnosticsSnapshot ?? false,
97
- touchedAt: f.touchedAt ?? Date.now(),
200
+ touchedAt: recordTouchedAt,
98
201
  });
99
202
  }
203
+ pruneInactiveFileRecords();
100
204
  sessionLanguages = state.sessionLanguages ?? [];
101
205
  requestRenderFn?.();
102
206
  return true;
@@ -129,15 +233,23 @@ export function recordFormatter(filePath, formatter, changed, success) {
129
233
  const rec = getOrCreate(filePath);
130
234
  rec.formatters.set(formatter, { changed, success });
131
235
  rec.touchedAt = Date.now();
132
- files.set(filePath, rec);
236
+ files.set(fileMapKey(filePath), rec);
133
237
  requestRender();
134
238
  }
135
- export function recordRunner(filePath, runnerId, status, diagnosticCount, durationMs) {
239
+ export function recordRunner(filePath, runnerId, status, diagnosticCount, durationMs, writeIndex) {
240
+ const key = fileMapKey(filePath);
241
+ if (!runnerWriteGuard.shouldWrite(key, writeIndex))
242
+ return;
243
+ // Advance the final-diagnostics guard too. A runner completion is part of
244
+ // this pipeline's ordered write, even though its immediate effect is only to
245
+ // mark the record pending. This prevents an older final replacement from
246
+ // landing after a newer runner state (and vice versa).
247
+ diagnosticsWriteGuard.shouldWrite(key, writeIndex);
136
248
  const rec = getOrCreate(filePath);
137
249
  rec.runners.set(runnerId, { status, count: diagnosticCount, durationMs });
138
250
  rec.hasFinalDiagnosticsSnapshot = false;
139
251
  rec.touchedAt = Date.now();
140
- files.set(filePath, rec);
252
+ files.set(fileMapKey(filePath), rec);
141
253
  requestRender();
142
254
  }
143
255
  /**
@@ -150,7 +262,16 @@ export function recordRunner(filePath, runnerId, status, diagnosticCount, durati
150
262
  function toSingleLineMessage(message) {
151
263
  return (message ?? "").replace(/\s+/g, " ").trim();
152
264
  }
153
- export function recordDiagnostics(filePath, diagnostics, writeIndex) {
265
+ export function recordDiagnostics(filePath, diagnostics, writeIndex,
266
+ // #1093: when the truth was OBSERVED, not when it's being written. Defaults
267
+ // to `Date.now()` for the per-edit/live path (observed now). A reconcile
268
+ // replaying a CACHED view (e.g. the workspace-diagnostics cache-hit branch in
269
+ // `tools/lsp-diagnostics.ts`) must pass the cache entry's own scan timestamp
270
+ // here — otherwise a repeat "fresh check" that merely re-serves a stale
271
+ // cached view keeps bumping `touchedAt` to now(), permanently disarming
272
+ // `reconcileStaleWidgetFiles`'s `mtimeMs > touchedAt` gate so a resolved
273
+ // finding renders forever (the #1092 touchedAt-re-arming defect).
274
+ observedAt) {
154
275
  // Drop a write that's superseded by a later same-turn edit to this file
155
276
  // whose pipeline finished first (same race class as #555). No cache write,
156
277
  // no count/timestamp update, no render trigger — the recorded state must
@@ -158,11 +279,27 @@ export function recordDiagnostics(filePath, diagnostics, writeIndex) {
158
279
  // the `clients/mcp/analyze.ts` on-demand call site, which has no per-edit
159
280
  // ordering token) always proceeds, same as version-less LSP servers in the
160
281
  // #555 guard.
161
- if (!diagnosticsWriteGuard.shouldWrite(filePath, writeIndex))
282
+ const key = fileMapKey(filePath);
283
+ if (!diagnosticsWriteGuard.shouldWrite(key, writeIndex))
162
284
  return;
163
- const rec = getOrCreate(filePath);
285
+ // Keep runner state ordered with the final diagnostic replacement. The
286
+ // guards are deliberately advanced in both directions because either verb
287
+ // may be the first completion from a pipeline.
288
+ runnerWriteGuard.shouldWrite(key, writeIndex);
289
+ // Resolve the observation time ONCE (#1186): every incoming entry is stamped
290
+ // with it, and it also seeds the record's `touchedAt`. A fresh write (no
291
+ // `observedAt`) is observed now.
292
+ const observedTs = observedAt ?? Date.now();
293
+ const rec = getOrCreate(filePath, key);
294
+ commitDiagnostics(rec, filePath, normalizeDiagnostics(filePath, diagnostics, observedTs), observedTs, key);
295
+ }
296
+ /** Map the raw diagnostic shape callers pass into stored {@link WidgetDiagnostic}s.
297
+ * Every produced entry is stamped with `observedTs` (#1186) — the time THIS batch
298
+ * of diagnostics was observed — so the per-entry stale gate can later drop just
299
+ * the entries older than the file's mtime rather than the whole record. */
300
+ function normalizeDiagnostics(filePath, diagnostics, observedTs) {
164
301
  const base = pathToFileURL(filePath).href;
165
- const normalized = diagnostics.map((d) => {
302
+ return diagnostics.map((d) => {
166
303
  const rule = d.rule ?? d.id;
167
304
  const uri = d.line != null
168
305
  ? `${base}#L${d.line}${d.column != null ? `:${d.column}` : ""}`
@@ -176,12 +313,35 @@ export function recordDiagnostics(filePath, diagnostics, writeIndex) {
176
313
  rule,
177
314
  tool: d.tool,
178
315
  uri,
316
+ observedAt: observedTs,
179
317
  };
180
318
  });
319
+ }
320
+ /** Store `normalized` as the record's complete diagnostic set: recompute counts,
321
+ * cap the display list, stamp `touchedAt` (at `observedAt` when given, else now
322
+ * — #1093), persist, and re-render. The caller decides what `normalized`
323
+ * contains (a full replace, or a merge — see `reconcileCascadeNeighborLspErrors`). */
324
+ function commitDiagnostics(rec, filePath, normalized, observedAt, key = fileMapKey(filePath)) {
325
+ rec.diagnosticCounts = countDiagnostics(normalized);
326
+ rec.diagnostics = capStoredDiagnostics(normalized);
327
+ rec.allDiagnostics = normalized;
328
+ rec.hasFinalDiagnosticsSnapshot = true;
329
+ // Record-level `touchedAt` is the FRESHEST per-entry observation in the merged
330
+ // set (#1186) — drives render recency and the empty-record stale gate. On a
331
+ // merge (`reconcileCascadeNeighborLspErrors`) this is the newest of the
332
+ // preserved + incoming entries, not the (possibly aging) incoming stamp, so a
333
+ // record holding a fresh preserved entry doesn't sort/gate as stale. Empty set
334
+ // falls back to the passed `observedAt` (or now).
335
+ rec.touchedAt = freshestObservation(normalized, observedAt ?? Date.now());
336
+ files.set(key, rec);
337
+ requestRender();
338
+ }
339
+ /** Recompute the {blocking, errors, warnings} tally for a diagnostic set. */
340
+ function countDiagnostics(diags) {
181
341
  let blocking = 0;
182
342
  let errors = 0;
183
343
  let warnings = 0;
184
- for (const diagnostic of normalized) {
344
+ for (const diagnostic of diags) {
185
345
  if (isBlocking(diagnostic))
186
346
  blocking++;
187
347
  if (diagnostic.severity === "error")
@@ -189,13 +349,69 @@ export function recordDiagnostics(filePath, diagnostics, writeIndex) {
189
349
  else if (diagnostic.severity === "warning")
190
350
  warnings++;
191
351
  }
192
- rec.diagnosticCounts = { blocking, errors, warnings };
193
- rec.diagnostics = capStoredDiagnostics(normalized);
194
- rec.allDiagnostics = normalized;
195
- rec.hasFinalDiagnosticsSnapshot = true;
196
- rec.touchedAt = Date.now();
197
- files.set(filePath, rec);
198
- requestRender();
352
+ return { blocking, errors, warnings };
353
+ }
354
+ /** The newest per-entry `observedAt` in `diags`, or `fallback` when empty (or no
355
+ * entry carries a stamp). Used as the record-level `touchedAt` (#1186). */
356
+ function freshestObservation(diags, fallback) {
357
+ let newest;
358
+ for (const d of diags) {
359
+ if (d.observedAt != null && (newest === undefined || d.observedAt > newest)) {
360
+ newest = d.observedAt;
361
+ }
362
+ }
363
+ return newest ?? fallback;
364
+ }
365
+ /**
366
+ * A stored diagnostic that came from a language SERVER (tsserver, pyright, …)
367
+ * AND is an error/blocking finding. `convertLspDiagnostics` tags every
368
+ * language-server diagnostic with `tool: "lsp"` and `retagAuxiliaryDiagnostics`
369
+ * re-tags auxiliary-LSP findings (opengrep/ast-grep/zizmor/typos) to their real
370
+ * tool id, so `tool === "lsp"` uniquely identifies a genuine language-server
371
+ * entry. Used by the cascade merge below to decide which existing entries an
372
+ * errors-only LSP re-check is entitled to replace.
373
+ */
374
+ function isLspErrorEntry(d) {
375
+ return d.tool === "lsp" && (d.semantic === "blocking" || d.severity === "error");
376
+ }
377
+ /**
378
+ * Reconcile a CONFIRMED cascade neighbor re-check (#1093) into the footer,
379
+ * MERGING rather than whole-replacing (unlike `recordDiagnostics`).
380
+ *
381
+ * The cascade only re-checks a neighbor through its LANGUAGE SERVER, and only
382
+ * for ERRORS (`severity === 1`; see `clients/dispatch/integration.ts`). A plain
383
+ * full-replace would therefore erase the neighbor's live findings from OTHER
384
+ * sources that the cascade never re-examined — biome/ruff/ast-grep runner
385
+ * findings, and even the language server's own WARNINGS — turning a
386
+ * cross-file-error re-check into a silent false-clean for everything else
387
+ * (#533). So we replace ONLY the existing LSP-error entries
388
+ * (`isLspErrorEntry`) with the cascade's fresh LSP errors and preserve
389
+ * everything else verbatim. A stale LSP warning or biome finding therefore
390
+ * survives an errors-only cascade — correct, because this check never looked at
391
+ * it; it self-corrects on the next per-edit dispatch or a `lens_diagnostics`
392
+ * scan (which DO re-examine every source).
393
+ *
394
+ * Only ever call this for a CONFIRMED result (a valid passive snapshot or a
395
+ * completed, NON-inconclusive active touch — #571). `writeIndex` and
396
+ * `observedAt` behave exactly as in `recordDiagnostics`.
397
+ */
398
+ export function reconcileCascadeNeighborLspErrors(filePath, lspErrorDiagnostics, writeIndex, observedAt) {
399
+ const key = fileMapKey(filePath);
400
+ if (!diagnosticsWriteGuard.shouldWrite(key, writeIndex))
401
+ return;
402
+ runnerWriteGuard.shouldWrite(key, writeIndex);
403
+ // #1186: the INCOMING LSP errors are stamped at THIS observation time
404
+ // (`observedAt`, e.g. an aging passive snapshot's `entry.ts`, or now for a
405
+ // fresh active touch). The PRESERVED entries keep their OWN prior per-entry
406
+ // `observedAt` — a fresh per-edit finding preserved through this errors-only
407
+ // merge is NOT re-aged to the incoming stamp. That per-entry split is exactly
408
+ // what lets `reconcileStaleWidgetFiles` drop the stale incoming entry while
409
+ // keeping the newer preserved one, instead of dropping the whole record.
410
+ const observedTs = observedAt ?? Date.now();
411
+ const rec = getOrCreate(filePath);
412
+ const incoming = normalizeDiagnostics(filePath, lspErrorDiagnostics, observedTs);
413
+ const preserved = rec.allDiagnostics.filter((d) => !isLspErrorEntry(d));
414
+ commitDiagnostics(rec, filePath, [...incoming, ...preserved], observedTs);
199
415
  }
200
416
  /**
201
417
  * Reconcile a diagnostics result obtained OUTSIDE the per-edit dispatch
@@ -224,11 +440,18 @@ export function recordDiagnostics(filePath, diagnostics, writeIndex) {
224
440
  * write for the same file — an omitted `writeIndex` always proceeds (same
225
441
  * version-less fallback `recordDiagnostics` already documents), which is
226
442
  * only safe for callers with no ordering token to give (e.g. tests).
443
+ *
444
+ * `observedAt` (#1093) is the wall-clock time the diagnostics were actually
445
+ * OBSERVED — pass it whenever the reconciled result is a replay of an older
446
+ * CACHED observation (the workspace-diagnostics cache-hit branch), so
447
+ * `touchedAt` records when the truth was seen, not when it was written. Omit
448
+ * it for genuinely fresh observations (a just-completed touch/scan), which are
449
+ * observed now.
227
450
  */
228
- export function reconcileScanDiagnostics(filePath, diagnostics, confirmed, writeIndex) {
451
+ export function reconcileScanDiagnostics(filePath, diagnostics, confirmed, writeIndex, observedAt) {
229
452
  if (!confirmed)
230
453
  return;
231
- recordDiagnostics(filePath, diagnostics, writeIndex);
454
+ recordDiagnostics(filePath, diagnostics, writeIndex, observedAt);
232
455
  }
233
456
  /**
234
457
  * Drop widget entries whose file changed on disk after pi-lens last recorded
@@ -242,22 +465,60 @@ export function reconcileScanDiagnostics(filePath, diagnostics, confirmed, write
242
465
  */
243
466
  export async function reconcileStaleWidgetFiles() {
244
467
  const entries = [...files.entries()];
245
- const staleKeys = await Promise.all(entries.map(async ([filePath, rec]) => {
468
+ const verdicts = await Promise.all(
469
+ // `mapKey` is the normalized `files` key (used for deletion); stat the
470
+ // record's real display path, not the lowercased key (#1020).
471
+ entries.map(async ([mapKey, rec]) => {
472
+ let mtimeMs;
246
473
  try {
247
- const st = await stat(filePath);
248
- // +1ms tolerance: a freshly-recorded file has touchedAt >= mtime.
249
- return st.mtimeMs > rec.touchedAt + 1 ? filePath : undefined;
474
+ mtimeMs = (await stat(rec.filePath)).mtimeMs;
250
475
  }
251
476
  catch {
252
- return filePath; // deleted / unreadable → drop
477
+ return { mapKey, action: "drop" }; // deleted / unreadable → drop
478
+ }
479
+ // A clean record (no findings) has no per-entry stamps to consult —
480
+ // gate it on the record's own `touchedAt` exactly as before, so a ✓
481
+ // entry for a file that changed on disk still drops.
482
+ if (rec.allDiagnostics.length === 0) {
483
+ return mtimeMs > rec.touchedAt + 1
484
+ ? { mapKey, action: "drop" }
485
+ : { mapKey, action: "keep" };
486
+ }
487
+ // #1186 per-ENTRY gate: drop only the entries observed BEFORE the file's
488
+ // current mtime; keep the rest. A merged record can hold a fresh
489
+ // preserved entry beside an entry replayed from an aging snapshot, so a
490
+ // per-RECORD gate over-cleared the whole record (the residual documented
491
+ // at dispatch/integration.ts). A missing per-entry stamp (a migrated
492
+ // pre-#1186 record) inherits the record's `touchedAt`. +1ms tolerance:
493
+ // a freshly-recorded file has observedAt >= mtime.
494
+ const survivors = rec.allDiagnostics.filter((d) => !(mtimeMs > (d.observedAt ?? rec.touchedAt) + 1));
495
+ if (survivors.length === rec.allDiagnostics.length) {
496
+ return { mapKey, action: "keep" }; // nothing stale
497
+ }
498
+ if (survivors.length === 0) {
499
+ return { mapKey, action: "drop" }; // every entry stale → drop record
253
500
  }
501
+ return { mapKey, action: "prune", survivors };
254
502
  }));
255
503
  let dropped = 0;
256
- for (const key of staleKeys) {
257
- if (key !== undefined) {
258
- files.delete(key);
504
+ for (const v of verdicts) {
505
+ if (v.action === "keep")
506
+ continue;
507
+ if (v.action === "drop") {
508
+ files.delete(v.mapKey);
259
509
  dropped += 1;
510
+ continue;
511
+ }
512
+ // prune: the file changed and shed its stale entries but retains fresher
513
+ // ones — keep the record, recompute counts/cap from the survivors, and
514
+ // still count it as a changed file so the agent is told to rescan.
515
+ const rec = files.get(v.mapKey);
516
+ if (rec) {
517
+ rec.allDiagnostics = v.survivors;
518
+ rec.diagnostics = capStoredDiagnostics(v.survivors);
519
+ rec.diagnosticCounts = countDiagnostics(v.survivors);
260
520
  }
521
+ dropped += 1;
261
522
  }
262
523
  if (dropped > 0)
263
524
  requestRenderFn?.();
@@ -313,12 +574,14 @@ export function getFileDiagnosticSummaries() {
313
574
  * been recorded (caller must not confuse "never seen" with "seen and clean";
314
575
  * an explicit `[]` from `recordDiagnostics` is a real empty array here).
315
576
  *
316
- * NOTE: `filePath` must be the exact string used to record the file — the
317
- * `files` map key is NOT normalized (pre-existing; see `getOrCreate`), so
318
- * callers should pass through the same value they gave `recordDiagnostics`.
577
+ * The `files` map key is normalized through `fileMapKey` (#1020), so any path
578
+ * form of the same file — forward-slash, backslash, or a different Windows
579
+ * drive-letter case — resolves to the same record. This read-side fold MUST
580
+ * stay identical to the write-side fold, or a file recorded under one form
581
+ * would silently read as `undefined` under another (e.g. via bus-publish).
319
582
  */
320
583
  export function getFileDiagnostics(filePath) {
321
- const rec = files.get(filePath);
584
+ const rec = files.get(fileMapKey(filePath));
322
585
  if (!rec)
323
586
  return undefined;
324
587
  return rec.allDiagnostics.map((d) => ({ ...d }));
@@ -338,13 +601,20 @@ export const __testing = {
338
601
  },
339
602
  };
340
603
  export function recordLsp(serverId, root, status, durationMs) {
341
- const key = `${serverId}@${root}`;
604
+ const normalizedRoot = normalizeMapKey(root);
605
+ const key = `${serverId}@${normalizedRoot}`;
342
606
  const mapped = status === "spawn_start"
343
607
  ? "spawning"
344
608
  : status === "spawn_success"
345
609
  ? "ready"
346
610
  : "failed";
347
611
  lspServers.set(key, { serverId, root, status: mapped, durationMs });
612
+ while (lspServers.size > MAX_LSP_SERVER_RECORDS) {
613
+ const oldest = lspServers.keys().next().value;
614
+ if (oldest === undefined)
615
+ break;
616
+ lspServers.delete(oldest);
617
+ }
348
618
  requestRender();
349
619
  }
350
620
  // ── Render ────────────────────────────────────────────────────────────────────
@@ -454,11 +724,17 @@ function formatFileRowVertical(rec, theme) {
454
724
  const blocking = rec.diagnosticCounts.blocking;
455
725
  const errors = rec.diagnosticCounts.errors;
456
726
  const warnings = rec.diagnosticCounts.warnings;
727
+ const formatterFailed = hasFailedFormatter(rec);
728
+ // Diagnostic severity outranks formatter failure (#1348 review): a file
729
+ // with blocking diagnostics shows the blocking dot even if a format also
730
+ // failed -- same precedence as the horizontal renderer.
457
731
  const dot = blocking > 0
458
732
  ? red("●")
459
- : warnings > 0 || errors > 0
460
- ? yellow("!")
461
- : green("✓");
733
+ : formatterFailed
734
+ ? red("x")
735
+ : warnings > 0 || errors > 0
736
+ ? yellow("!")
737
+ : green("✓");
462
738
  const runnerNames = [...rec.runners.entries()]
463
739
  .filter(([, r]) => r.status !== "skipped")
464
740
  .map(([id]) => id)
@@ -471,11 +747,15 @@ function formatFileRowVertical(rec, theme) {
471
747
  ? " " + yellow(`${warnings}W`)
472
748
  : " " + dim("clean");
473
749
  const changedFormatters = [...rec.formatters.entries()]
474
- .filter(([, f]) => f.changed)
750
+ .filter(([, f]) => f.changed && f.success)
475
751
  .map(([name]) => name);
476
- const formatMark = changedFormatters.length > 0
477
- ? dim(` fmt:${changedFormatters.join(",")}`)
478
- : "";
752
+ const failedFormatters = [...rec.formatters.entries()]
753
+ .filter(([, f]) => !f.success)
754
+ .map(([name]) => name);
755
+ const formatMark = (failedFormatters.length > 0
756
+ ? red(` fmt-failed:${failedFormatters.join(",")}`)
757
+ : "") +
758
+ (changedFormatters.length > 0 ? dim(` fmt:${changedFormatters.join(",")}`) : "");
479
759
  return ` ${dot} ${base} ${dim(runnerNames)}${formatMark}${counts}`;
480
760
  }
481
761
  function packHorizontalRow(recs, totalWidth, theme) {
@@ -543,6 +823,7 @@ function formatFileTokenHorizontal(rec, remainingWidth, theme) {
543
823
  const errors = rec.diagnosticCounts.errors;
544
824
  const warnings = rec.diagnosticCounts.warnings;
545
825
  const formatterChanged = hasChangedFormatter(rec);
826
+ const formatterFailed = hasFailedFormatter(rec);
546
827
  let dotChar;
547
828
  if (blocking > 0)
548
829
  dotChar = red("●");
@@ -552,6 +833,9 @@ function formatFileTokenHorizontal(rec, remainingWidth, theme) {
552
833
  dotChar = dim("✎");
553
834
  else
554
835
  dotChar = dim("·");
836
+ if (formatterFailed && blocking === 0 && errors === 0 && warnings === 0) {
837
+ dotChar = red("x");
838
+ }
555
839
  let countsStyled = "";
556
840
  if (errors > 0 && warnings > 0) {
557
841
  const eColor = blocking > 0 ? red : yellow;
@@ -588,8 +872,11 @@ function truncateBasename(name, maxWidth) {
588
872
  return stem.slice(0, keep) + "…" + ext;
589
873
  }
590
874
  // ── Helpers ──────────────────────────────────────────────────────────────────
591
- function getOrCreate(filePath) {
592
- return (files.get(filePath) ?? {
875
+ function getOrCreate(filePath, key = fileMapKey(filePath)) {
876
+ maybePruneInactiveFileRecords();
877
+ // Look up by the normalized key so mixed path forms of the same file share
878
+ // ONE record (#1020); keep the caller's verbatim path as the display path.
879
+ return (files.get(key) ?? {
593
880
  filePath,
594
881
  runners: new Map(),
595
882
  formatters: new Map(),
@@ -601,10 +888,16 @@ function getOrCreate(filePath) {
601
888
  });
602
889
  }
603
890
  function hasChangedFormatter(rec) {
604
- return [...rec.formatters.values()].some((f) => f.changed);
891
+ return [...rec.formatters.values()].some((f) => f.changed && f.success);
892
+ }
893
+ function hasFailedFormatter(rec) {
894
+ return [...rec.formatters.values()].some((f) => !f.success);
605
895
  }
606
896
  function shouldRenderFile(rec) {
607
- return rec.hasFinalDiagnosticsSnapshot || hasChangedFormatter(rec);
897
+ return rec.hasFinalDiagnosticsSnapshot || hasChangedFormatter(rec) || hasFailedFormatter(rec);
898
+ }
899
+ function hasLiveDiagnostic(rec) {
900
+ return rec.hasFinalDiagnosticsSnapshot && rec.diagnostics.length > 0;
608
901
  }
609
902
  function isPendingAnalysis(rec) {
610
903
  return rec.runners.size > 0 && !rec.hasFinalDiagnosticsSnapshot;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Persistent NDJSON trace of word-index build / refresh / persist outcomes
3
+ * (#926 durable reporting, extended for #958 incremental cross-session refresh).
4
+ *
5
+ * Every word-index signal used to ride solely on the optional `dbg` callback
6
+ * threaded through runtime-session / word-index. `dbg` varies by host and is a
7
+ * documented no-op in the MCP host (clients/mcp/session.ts's `dbg: noop`) —
8
+ * exactly the host where `pilens_symbol_search` READS this index. So the one
9
+ * decision that governs a query's freshness (full rebuild vs incremental
10
+ * refresh, and how many docs were refreshed/dropped/skipped/reused, whether the
11
+ * index is `truncated`, #928) and the one failure that silently corrupts every
12
+ * later query (a swallowed snapshot persist) were invisible precisely where
13
+ * they mattered — the same blind spot bus-events-logger.ts and
14
+ * review-graph-logger.ts were created to close.
15
+ *
16
+ * This gives the word index the same durable trace those siblings have via the
17
+ * shared `createNdjsonLogger` writer (self-registers for log-cleanup rotation),
18
+ * with `isTestMode()` gating writes off inside the test runner. Callers log
19
+ * DIRECTLY here — never through `dbg` — so the trace exists regardless of the
20
+ * host's `dbg` wiring.
21
+ */
22
+ import * as path from "node:path";
23
+ import { isTestMode } from "./env-utils.js";
24
+ import { getGlobalPiLensDir } from "./file-utils.js";
25
+ import { getMaxLogSizeMB } from "./log-cleanup.js";
26
+ import { createNdjsonLogger } from "./ndjson-logger.js";
27
+ const WORD_INDEX_LOG_FILE = path.join(getGlobalPiLensDir(), "word-index.log");
28
+ const writer = createNdjsonLogger({
29
+ filePath: WORD_INDEX_LOG_FILE,
30
+ maxBytes: getMaxLogSizeMB() * 1024 * 1024,
31
+ });
32
+ export function logWordIndex(entry) {
33
+ if (isTestMode()) {
34
+ return;
35
+ }
36
+ writer.log({ ts: new Date().toISOString(), ...entry });
37
+ }
38
+ export function getWordIndexLogPath() {
39
+ return WORD_INDEX_LOG_FILE;
40
+ }
41
+ /** Resolve once all enqueued word-index writes are on disk (tests/shutdown). */
42
+ export function flushWordIndexLog() {
43
+ return writer.flush();
44
+ }