pi-lens 4.1.2 → 4.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (359) hide show
  1. package/CHANGELOG.md +1308 -0
  2. package/README.md +3 -0
  3. package/config/biome/core.jsonc +11 -2
  4. package/config/dependency-cruiser-eager-allowlist.json +91 -0
  5. package/dist/clients/actionable-warnings-logger.js +18 -3
  6. package/dist/clients/actionable-warnings.js +927 -80
  7. package/dist/clients/agent-behavior-client.js +13 -4
  8. package/dist/clients/ast-grep-client.js +136 -9
  9. package/dist/clients/ast-grep-rule-manager.js +60 -5
  10. package/dist/clients/ast-grep-tool-logger.js +2 -2
  11. package/dist/clients/availability-probe-flight.js +12 -0
  12. package/dist/clients/bash-file-access.js +1 -3
  13. package/dist/clients/biome-client.js +16 -2
  14. package/dist/clients/blocker-freshness.js +34 -0
  15. package/dist/clients/bootstrap.js +509 -73
  16. package/dist/clients/bounded-cache.js +152 -12
  17. package/dist/clients/bounded-telemetry.js +115 -23
  18. package/dist/clients/build-identity.js +95 -0
  19. package/dist/clients/bundled-resource-health.js +113 -0
  20. package/dist/clients/bus-events-logger.js +2 -2
  21. package/dist/clients/bus-publish.js +3 -0
  22. package/dist/clients/cache/rule-cache.js +33 -5
  23. package/dist/clients/cache-manager.js +105 -6
  24. package/dist/clients/cache-observability.js +46 -19
  25. package/dist/clients/cargo-manifest.js +422 -0
  26. package/dist/clients/cascade-logger.js +17 -3
  27. package/dist/clients/code-quality-warnings.js +13 -3
  28. package/dist/clients/complexity-client.js +16 -5
  29. package/dist/clients/config-core/deny.js +221 -0
  30. package/dist/clients/config-core/index.js +47 -0
  31. package/dist/clients/config-core/merge.js +357 -0
  32. package/dist/clients/config-core/normalize.js +340 -0
  33. package/dist/clients/config-core/process-spec.js +248 -0
  34. package/dist/clients/config-core/provenance.js +171 -0
  35. package/dist/clients/config-core/records.js +218 -0
  36. package/dist/clients/config-core/resolve.js +125 -0
  37. package/dist/clients/config-core/safe-object.js +78 -0
  38. package/dist/clients/config-core/schema.js +167 -0
  39. package/dist/clients/config-diagnostic-codes.js +281 -0
  40. package/dist/clients/config-locations.js +160 -0
  41. package/dist/clients/config-resolve.js +789 -0
  42. package/dist/clients/config-schema.js +197 -0
  43. package/dist/clients/config-warn.js +407 -0
  44. package/dist/clients/dead-code-client.js +33 -25
  45. package/dist/clients/dead-code-logger.js +2 -2
  46. package/dist/clients/deadline-utils.js +178 -0
  47. package/dist/clients/debug-handles.js +2 -2
  48. package/dist/clients/debug-heap.js +3 -3
  49. package/dist/clients/deferred-lsp-work.js +106 -0
  50. package/dist/clients/degradation-ledger.js +166 -8
  51. package/dist/clients/demoted-finding-render.js +14 -0
  52. package/dist/clients/dependency-checker.js +28 -22
  53. package/dist/clients/deps/minimatch.js +1 -1
  54. package/dist/clients/diagnostic-line-freshness.js +12 -9
  55. package/dist/clients/diagnostic-logger.js +14 -4
  56. package/dist/clients/dispatch/collect-later-tier.js +39 -0
  57. package/dist/clients/dispatch/dispatcher.js +304 -19
  58. package/dist/clients/dispatch/fact-store.js +328 -9
  59. package/dist/clients/dispatch/integration.js +214 -107
  60. package/dist/clients/dispatch/pending-runner-findings.js +87 -0
  61. package/dist/clients/dispatch/plan.js +21 -5
  62. package/dist/clients/dispatch/runners/actionlint.js +0 -1
  63. package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
  64. package/dist/clients/dispatch/runners/biome-check.js +0 -1
  65. package/dist/clients/dispatch/runners/cpp-check.js +0 -1
  66. package/dist/clients/dispatch/runners/credo.js +0 -1
  67. package/dist/clients/dispatch/runners/cue-vet.js +0 -1
  68. package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  69. package/dist/clients/dispatch/runners/detekt.js +0 -1
  70. package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  71. package/dist/clients/dispatch/runners/elixir-check.js +0 -1
  72. package/dist/clients/dispatch/runners/eslint.js +5 -2
  73. package/dist/clients/dispatch/runners/fact-rules.js +0 -1
  74. package/dist/clients/dispatch/runners/fish-indent.js +0 -1
  75. package/dist/clients/dispatch/runners/gleam-check.js +0 -1
  76. package/dist/clients/dispatch/runners/go-vet.js +1 -3
  77. package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  78. package/dist/clients/dispatch/runners/hadolint.js +0 -1
  79. package/dist/clients/dispatch/runners/helm-lint.js +16 -4
  80. package/dist/clients/dispatch/runners/helm-render.js +54 -10
  81. package/dist/clients/dispatch/runners/htmlhint.js +0 -1
  82. package/dist/clients/dispatch/runners/index.js +1 -3
  83. package/dist/clients/dispatch/runners/javac.js +0 -1
  84. package/dist/clients/dispatch/runners/ktlint.js +0 -1
  85. package/dist/clients/dispatch/runners/lsp.js +37 -2
  86. package/dist/clients/dispatch/runners/markdownlint.js +4 -2
  87. package/dist/clients/dispatch/runners/mypy.js +0 -1
  88. package/dist/clients/dispatch/runners/oxlint.js +24 -22
  89. package/dist/clients/dispatch/runners/php-lint.js +0 -1
  90. package/dist/clients/dispatch/runners/phpstan.js +0 -1
  91. package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  92. package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
  93. package/dist/clients/dispatch/runners/pyright.js +7 -4
  94. package/dist/clients/dispatch/runners/rubocop.js +0 -1
  95. package/dist/clients/dispatch/runners/ruff.js +0 -1
  96. package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
  97. package/dist/clients/dispatch/runners/shellcheck.js +0 -1
  98. package/dist/clients/dispatch/runners/shfmt.js +0 -1
  99. package/dist/clients/dispatch/runners/spellcheck.js +0 -1
  100. package/dist/clients/dispatch/runners/spotbugs.js +0 -1
  101. package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  102. package/dist/clients/dispatch/runners/stylelint.js +0 -1
  103. package/dist/clients/dispatch/runners/swiftlint.js +0 -1
  104. package/dist/clients/dispatch/runners/taplo.js +0 -1
  105. package/dist/clients/dispatch/runners/terragrunt.js +0 -1
  106. package/dist/clients/dispatch/runners/tflint.js +0 -1
  107. package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  108. package/dist/clients/dispatch/runners/trivy-config.js +0 -1
  109. package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  110. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  111. package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
  112. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  113. package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  114. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
  115. package/dist/clients/dispatch/runners/vale.js +0 -1
  116. package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  117. package/dist/clients/dispatch/runners/yamllint.js +0 -1
  118. package/dist/clients/dispatch/runners/zig-check.js +0 -1
  119. package/dist/clients/disposition-logger.js +3 -3
  120. package/dist/clients/effective-config.js +403 -0
  121. package/dist/clients/error-class.js +23 -0
  122. package/dist/clients/event-loop-hold.js +274 -0
  123. package/dist/clients/event-loop-monitor.js +66 -1
  124. package/dist/clients/extension-log.js +2 -2
  125. package/dist/clients/feature-hints.js +2 -1
  126. package/dist/clients/file-time.js +6 -1
  127. package/dist/clients/file-utils.js +266 -13
  128. package/dist/clients/finding-delivery-gate.js +55 -13
  129. package/dist/clients/format-service.js +6 -1
  130. package/dist/clients/formatters.js +303 -104
  131. package/dist/clients/freshness-cadence.js +17 -0
  132. package/dist/clients/generated-artifacts.js +114 -19
  133. package/dist/clients/generation-guard.js +4 -9
  134. package/dist/clients/git-guard.js +198 -48
  135. package/dist/clients/git-tracked-ignore.js +32 -1
  136. package/dist/clients/go-client.js +39 -0
  137. package/dist/clients/govulncheck-client.js +5 -1
  138. package/dist/clients/gradle-ktfmt-style.js +252 -0
  139. package/dist/clients/gzip-stage-write.js +7 -0
  140. package/dist/clients/hashline-anchor.js +424 -0
  141. package/dist/clients/hook-budgets.js +76 -0
  142. package/dist/clients/host-edit-normalize.js +5 -2
  143. package/dist/clients/host-ports.js +1 -1
  144. package/dist/clients/installer/index.js +200 -84
  145. package/dist/clients/installer/managed-tool-refresh.js +3 -1
  146. package/dist/clients/instance-reaper.js +108 -243
  147. package/dist/clients/instance-registry-lock.js +145 -0
  148. package/dist/clients/instance-registry.js +435 -122
  149. package/dist/clients/jscpd-client.js +6 -1
  150. package/dist/clients/language-profile.js +4 -2
  151. package/dist/clients/language-registry.js +595 -0
  152. package/dist/clients/latency-logger.js +179 -3
  153. package/dist/clients/ledger-bounds.js +13 -0
  154. package/dist/clients/lens-config.js +141 -30
  155. package/dist/clients/lens-engine.js +30 -37
  156. package/dist/clients/lens-flag-registry.js +67 -6
  157. package/dist/clients/lens-map.js +3 -1
  158. package/dist/clients/log-cleanup.js +2 -2
  159. package/dist/clients/lsp/aggregation.js +3 -1
  160. package/dist/clients/lsp/cascade-tier.js +4 -0
  161. package/dist/clients/lsp/client.js +672 -49
  162. package/dist/clients/lsp/config.js +504 -156
  163. package/dist/clients/lsp/diagnostic-binding.js +10 -5
  164. package/dist/clients/lsp/edits.js +15 -2
  165. package/dist/clients/lsp/index.js +1179 -247
  166. package/dist/clients/lsp/jvm-runtime.js +5 -0
  167. package/dist/clients/lsp/language.js +18 -169
  168. package/dist/clients/lsp/launch.js +38 -9
  169. package/dist/clients/lsp/pending-aux-coverage.js +113 -12
  170. package/dist/clients/lsp/server.js +426 -160
  171. package/dist/clients/lsp/session-roots.js +192 -0
  172. package/dist/clients/lsp/spawn-history.js +2 -7
  173. package/dist/clients/lsp/tsserver-sync.js +9 -2
  174. package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
  175. package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  176. package/dist/clients/lsp-mutation.js +179 -23
  177. package/dist/clients/map-with-concurrency.js +36 -0
  178. package/dist/clients/mcp/analyze.js +45 -10
  179. package/dist/clients/mcp/session.js +19 -17
  180. package/dist/clients/memory-sampler.js +28 -1
  181. package/dist/clients/message-end-attribution.js +30 -0
  182. package/dist/clients/metrics-history.js +21 -4
  183. package/dist/clients/middle-man-analysis.js +2 -4
  184. package/dist/clients/module-report.js +20 -45
  185. package/dist/clients/mutating-tool.js +651 -0
  186. package/dist/clients/mutation-attribution.js +368 -0
  187. package/dist/clients/mutation-bridge.js +240 -0
  188. package/dist/clients/ndjson-logger.js +247 -20
  189. package/dist/clients/observed-mutation-sources.js +101 -0
  190. package/dist/clients/observed-mutation.js +1215 -0
  191. package/dist/clients/opaque-mutation-scan.js +300 -40
  192. package/dist/clients/opengrep-config.js +4 -0
  193. package/dist/clients/package-manager.js +201 -44
  194. package/dist/clients/partial-edit-apply.js +359 -80
  195. package/dist/clients/path-attribution-telemetry.js +23 -7
  196. package/dist/clients/path-keyed-map.js +35 -3
  197. package/dist/clients/path-utils.js +460 -13
  198. package/dist/clients/persist-debounce.js +8 -1
  199. package/dist/clients/php-cs-fixer-config.js +114 -0
  200. package/dist/clients/pipeline.js +77 -24
  201. package/dist/clients/probe-home-state.js +228 -0
  202. package/dist/clients/process-bridge.js +66 -0
  203. package/dist/clients/process-singletons.js +210 -0
  204. package/dist/clients/process-snapshot.js +68 -0
  205. package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  206. package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  207. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  208. package/dist/clients/project-diagnostics/scanner.js +61 -24
  209. package/dist/clients/project-lens-config.js +393 -122
  210. package/dist/clients/project-snapshot.js +85 -35
  211. package/dist/clients/python-environment.js +217 -0
  212. package/dist/clients/python-provenance.js +639 -0
  213. package/dist/clients/quiet-window.js +6 -1
  214. package/dist/clients/read-bridge.js +13 -21
  215. package/dist/clients/read-expansion.js +9 -46
  216. package/dist/clients/read-guard-logger.js +39 -6
  217. package/dist/clients/read-guard-tool-lines.js +364 -212
  218. package/dist/clients/read-guard.js +95 -7
  219. package/dist/clients/recent-touches.js +17 -5
  220. package/dist/clients/resource-sampler.js +195 -96
  221. package/dist/clients/review-graph/builder.js +752 -132
  222. package/dist/clients/review-graph/service.js +55 -3
  223. package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  224. package/dist/clients/review-graph/tsconfig-paths.js +5 -1
  225. package/dist/clients/review-graph/workspace-modules.js +10 -74
  226. package/dist/clients/review-graph-logger.js +30 -4
  227. package/dist/clients/runtime-agent-end.js +18 -2
  228. package/dist/clients/runtime-context.js +49 -16
  229. package/dist/clients/runtime-coordinator.js +93 -6
  230. package/dist/clients/runtime-session.js +169 -34
  231. package/dist/clients/runtime-tool-call.js +183 -35
  232. package/dist/clients/runtime-tool-result.js +894 -241
  233. package/dist/clients/runtime-turn.js +1264 -74
  234. package/dist/clients/rust-client.js +31 -0
  235. package/dist/clients/safe-spawn.js +191 -28
  236. package/dist/clients/sanitize.js +7 -1
  237. package/dist/clients/scratch-tree-policy.js +1 -3
  238. package/dist/clients/security-scan-client.js +51 -17
  239. package/dist/clients/session-lifecycle.js +238 -42
  240. package/dist/clients/session-start-observability.js +79 -0
  241. package/dist/clients/sessionstart-logger.js +12 -3
  242. package/dist/clients/sg-runner.js +27 -15
  243. package/dist/clients/sgconfig.js +6 -1
  244. package/dist/clients/shared-checkout-guard.js +338 -0
  245. package/dist/clients/skills-resolver.js +105 -0
  246. package/dist/clients/smells-rollup.js +2 -2
  247. package/dist/clients/spawn-output-cap.js +37 -0
  248. package/dist/clients/spawn-timeout-cooldown.js +15 -1
  249. package/dist/clients/startup-scan.js +6 -7
  250. package/dist/clients/startup-timing.js +69 -4
  251. package/dist/clients/string-utils.js +33 -0
  252. package/dist/clients/subagent-mode.js +17 -4
  253. package/dist/clients/test-runner-client.js +586 -118
  254. package/dist/clients/test-runner-delivery.js +239 -0
  255. package/dist/clients/tool-definition.js +41 -1
  256. package/dist/clients/tool-policy.js +92 -22
  257. package/dist/clients/tree-sitter-cache.js +40 -40
  258. package/dist/clients/tree-sitter-client.js +97 -53
  259. package/dist/clients/tree-sitter-logger.js +19 -3
  260. package/dist/clients/tree-sitter-query-loader.js +81 -1
  261. package/dist/clients/tree-sitter-shared.js +51 -46
  262. package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  263. package/dist/clients/typos-config.js +5 -0
  264. package/dist/clients/user-notify.js +6 -2
  265. package/dist/clients/warm-attach.js +8 -10
  266. package/dist/clients/widget-state.js +191 -26
  267. package/dist/clients/word-index-logger.js +15 -3
  268. package/dist/clients/word-index-store.js +463 -0
  269. package/dist/clients/word-index.js +815 -125
  270. package/dist/clients/workspace-topology.js +25 -1
  271. package/dist/clients/zizmor-config.js +29 -6
  272. package/dist/index.js +68577 -53950
  273. package/dist/mcp/analyze-cli.js +3 -1
  274. package/dist/mcp/build-staleness.js +8 -7
  275. package/dist/mcp/cli.js +3 -1
  276. package/dist/mcp/server.js +133 -6
  277. package/dist/scripts/lib/process-scan.mjs +583 -0
  278. package/dist/scripts/lib/skills-predicate.mjs +129 -0
  279. package/dist/tools/effective-config.js +89 -0
  280. package/dist/tools/lens-diagnostics.js +99 -24
  281. package/dist/tools/lsp-diagnostics.js +81 -85
  282. package/dist/tools/lsp-navigation.js +40 -17
  283. package/dist/tools/shared.js +0 -1
  284. package/docs/agent-guide.md +68 -3
  285. package/docs/ast-grep_rules_catalog.md +41 -10
  286. package/docs/astplayground.md +26 -17
  287. package/docs/configuration.md +222 -0
  288. package/docs/dependencies.md +3 -3
  289. package/docs/features.md +44 -5
  290. package/docs/language-coverage.md +2 -2
  291. package/docs/pi-lens-fixer.md +25 -0
  292. package/docs/pi-lens-investigator.md +25 -0
  293. package/docs/pi-lens-reviewer.md +27 -0
  294. package/docs/pi-lens-subagent.md +38 -0
  295. package/docs/pi-lens-warden.md +55 -0
  296. package/docs/public-api-stability.md +359 -0
  297. package/docs/release-qa-baseline.md +182 -0
  298. package/docs/subagent-compat.md +110 -30
  299. package/docs/tree-sitter_rules_catalog.md +2 -2
  300. package/package.json +17 -21
  301. package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  302. package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  303. package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  304. package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  305. package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  306. package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  307. package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  308. package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  309. package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  310. package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  311. package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  312. package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  313. package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  314. package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  315. package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  316. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  317. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  318. package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  319. package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  320. package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  321. package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  322. package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  323. package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  324. package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  325. package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  326. package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  327. package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  328. package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  329. package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  330. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  331. package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  332. package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  333. package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  334. package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  335. package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  336. package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  337. package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  338. package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  339. package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  340. package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  341. package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  342. package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  343. package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  344. package/rules/ast-grep-rules/rules/no-important.yml +21 -0
  345. package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  346. package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  347. package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  348. package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  349. package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  350. package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  351. package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  352. package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  353. package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  354. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  355. package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  356. package/scripts/analyze-pi-lens-logs.mjs +192 -1
  357. package/scripts/install-selftest.mjs +58 -2
  358. package/scripts/lib/skills-predicate.mjs +129 -0
  359. package/scripts/rpc-load-check.mjs +3 -1
@@ -13,12 +13,18 @@ import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import * as fs from "node:fs/promises";
14
14
  import * as path from "node:path";
15
15
  import { BoundedLruCache } from "./bounded-cache.js";
16
+ import { createGenerationSource } from "./generation-guard.js";
16
17
  import { normalizeMapKey } from "./path-utils.js";
18
+ import { resolveCargoPackageEdition } from "./cargo-manifest.js";
19
+ import { resolveKtfmtGradleStyle } from "./gradle-ktfmt-style.js";
20
+ import { resolvePhpCsFixerConfig } from "./php-cs-fixer-config.js";
17
21
  import { TERRAGRUNT_FILENAMES } from "./file-kinds.js";
22
+ import { stripAnsi } from "./sanitize.js";
18
23
  import { detectIndentation, hasDetectableIndentation, } from "./dispatch/indent-detect.js";
19
24
  import { logLatency } from "./latency-logger.js";
25
+ import { compareOrdinal } from "./string-utils.js";
20
26
  import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
21
- import { findGlobalBinary, findLocalBinUpwards } from "./package-manager.js";
27
+ import { findGlobalBinary, findLocalBinUpwards, VENDOR_BIN_DIRS, VENV_BIN_DIRS, } from "./package-manager.js";
22
28
  import { safeSpawnAsync } from "./safe-spawn.js";
23
29
  import { assertInstallAllowed } from "./project-trust.js";
24
30
  import { tryLazyInstallForFormatter } from "./dispatch/runners/utils/lazy-installer.js";
@@ -48,6 +54,19 @@ export async function tryLazyInstallFormatterTool(tool, cwd) {
48
54
  * stock command there is exactly the stock-style imposition the fix bans.
49
55
  */
50
56
  export const SKIP_FORMATTING = "skip-formatting";
57
+ /**
58
+ * Sentinel a `resolveCommand` returns when it has PROVEN the selected
59
+ * formatter has no executable — the binary is absent from every place the
60
+ * resolver probed (node_modules/.bin, a venv, a managed install dir, PATH) and
61
+ * no alternative resolved (#2413). Distinct from `null` ("no override, fall
62
+ * back to the static `command`") and from SKIP_FORMATTING ("style-preserving
63
+ * refusal"): the static fallback for these formatters is the SAME bare binary
64
+ * the resolver just proved missing, so spawning it only re-triggers the ENOENT
65
+ * the resolver already observed — and reporting that as a formatting failure is
66
+ * the exact defect this fixes (recurring defect shape 10: an unavailable
67
+ * producer collapsed into an ordinary failed result).
68
+ */
69
+ export const FORMATTER_UNAVAILABLE = "formatter-unavailable";
51
70
  // --- Utility Functions ---
52
71
  async function fileExists(filePath) {
53
72
  try {
@@ -62,12 +81,47 @@ async function findUp(targets, startDir, stopDir = path.parse(startDir).root) {
62
81
  const found = [];
63
82
  let currentDir = startDir;
64
83
  while (currentDir !== stopDir) {
65
- for (const target of targets) {
66
- const checkPath = path.join(currentDir, target);
67
- if (await fileExists(checkPath)) {
68
- found.push(checkPath);
84
+ // One `readdir` per directory instead of one `access` per target: the
85
+ // per-target probe made this loop's cost O(targets), so a long target
86
+ // list (e.g. `FORMATTER_CONFIG_FILES`) paid for every entry at every
87
+ // ancestor directory even when almost none of them exist (#1603).
88
+ let entries;
89
+ try {
90
+ entries = await fs.readdir(currentDir);
91
+ }
92
+ catch {
93
+ entries = [];
94
+ }
95
+ if (entries.length > 0) {
96
+ // On default-case-insensitive platforms the old `fs.access` probe
97
+ // matched a config file regardless of on-disk casing; fold so the
98
+ // readdir membership check keeps that behavior.
99
+ const foldCase = process.platform === "win32" || process.platform === "darwin";
100
+ const entrySet = new Set(foldCase ? entries.map((e) => e.toLowerCase()) : entries);
101
+ for (const target of targets) {
102
+ // Nested candidates (for example node_modules/.bin/biome) do
103
+ // not appear as direct readdir members. They retain the old
104
+ // bounded candidate probe; direct names use the O(depth + matches)
105
+ // directory-membership path above.
106
+ if (target.includes("/") || target.includes("\\"))
107
+ continue;
108
+ if (entrySet.has(foldCase ? target.toLowerCase() : target)) {
109
+ const candidate = path.join(currentDir, target);
110
+ // Directory membership is only a cheap candidate filter. Keep the
111
+ // old access check for matched entries so dangling links and entries
112
+ // that cannot be read do not become config evidence (#1603 R2).
113
+ if (await fileExists(candidate))
114
+ found.push(candidate);
115
+ }
69
116
  }
70
117
  }
118
+ for (const target of targets) {
119
+ if (!target.includes("/") && !target.includes("\\"))
120
+ continue;
121
+ const candidate = path.join(currentDir, target);
122
+ if (await fileExists(candidate))
123
+ found.push(candidate);
124
+ }
71
125
  const parent = path.dirname(currentDir);
72
126
  if (parent === currentDir)
73
127
  break;
@@ -75,6 +129,10 @@ async function findUp(targets, startDir, stopDir = path.parse(startDir).root) {
75
129
  }
76
130
  return found;
77
131
  }
132
+ /** Test-only access to the real filesystem walker and its candidate filter. */
133
+ export function _findUpForTests(targets, startDir, stopDir) {
134
+ return findUp(targets, startDir, stopDir);
135
+ }
78
136
  const WHICH_BUDGET_MS = 5000;
79
137
  /**
80
138
  * PATH lookups, governed by the shared availability policy (#1495).
@@ -224,6 +282,9 @@ function noteCooldownServedVerdict(command, latch) {
224
282
  retryAfterMs: Math.max(1, retryAtMs - Date.now()),
225
283
  budgetMs: WHICH_BUDGET_MS,
226
284
  servedFromCooldown: true,
285
+ // No probe ran here: the latch's own remembered cause is replayed
286
+ // as-is, so the call site is the one asserting it (#2209).
287
+ classifiedBy: "caller",
227
288
  });
228
289
  }
229
290
  async function which(command) {
@@ -261,6 +322,7 @@ async function which(command) {
261
322
  latched: true,
262
323
  hostStallMs,
263
324
  budgetMs: WHICH_BUDGET_MS,
325
+ classifiedBy: "probe",
264
326
  });
265
327
  return resolved;
266
328
  }
@@ -281,9 +343,15 @@ async function which(command) {
281
343
  ...(proberRan && { unclassifiedFailureOutcome: "missing" }),
282
344
  });
283
345
  let { outcome, cause } = classified;
346
+ // This call site OVERRIDES classifyProbeFailure's own answer below, so a
347
+ // row it forced is a caller assertion, not a probe classification — the
348
+ // scrutiny availability-policy.ts:253-258 reserves for "caller" rows must
349
+ // not be laundered away by mislabeling this one "probe" (#2226 review F1).
350
+ let outcomeForced = false;
284
351
  if (!proberRan && outcome !== "transient") {
285
352
  outcome = "transient";
286
353
  cause = "probe-rejected";
354
+ outcomeForced = true;
287
355
  }
288
356
  const retryAfterMs = entry.latch.noteUnavailable(outcome, cause);
289
357
  if (outcome === "transient")
@@ -303,6 +371,7 @@ async function which(command) {
303
371
  hostStallMs,
304
372
  ...(retryAfterMs > 0 && { retryAfterMs }),
305
373
  budgetMs: WHICH_BUDGET_MS,
374
+ classifiedBy: outcomeForced ? "caller" : "probe",
306
375
  });
307
376
  return null;
308
377
  }
@@ -325,79 +394,58 @@ async function resolveGoFmtBinary() {
325
394
  /**
326
395
  * Walk up from cwd looking for a binary in .venv or venv.
327
396
  * Returns the absolute path if found, null otherwise.
397
+ *
398
+ * Tool-resolution walker, not a config lookup (#2514/#2517 policy): escaping
399
+ * the project upward past HOME means STOP, not keep reading — a `.venv`/`venv`
400
+ * bin found at or above HOME can never be THIS project's own virtualenv. The
401
+ * ceiling lives in `findLocalBinUpwards` and is default-on there; the tests
402
+ * that pin a fake HOME drive that walker directly rather than threading a
403
+ * `homeDir` no production caller ever passed (#2544 review F3).
404
+ *
405
+ * On Windows the candidate order is EXT-OUTER, DIR-INNER — `.venv/Scripts/x.exe`,
406
+ * `venv/Scripts/x.exe`, then the bare names — which is what the pre-fold copy
407
+ * did and what `findLocalBinUpwards` preserves.
328
408
  */
329
409
  async function findInVenv(binary, cwd) {
330
- const isWin = process.platform === "win32";
331
- const candidates = isWin
332
- ? [
333
- `.venv/Scripts/${binary}.exe`,
334
- `venv/Scripts/${binary}.exe`,
335
- `.venv/Scripts/${binary}`,
336
- `venv/Scripts/${binary}`,
337
- ]
338
- : [`.venv/bin/${binary}`, `venv/bin/${binary}`];
339
- let dir = cwd;
340
- const root = path.parse(dir).root;
341
- while (dir !== root) {
342
- for (const candidate of candidates) {
343
- const full = path.join(dir, candidate);
344
- if (await fileExists(full))
345
- return full;
346
- }
347
- const parent = path.dirname(dir);
348
- if (parent === dir)
349
- break;
350
- dir = parent;
351
- }
352
- return null;
410
+ return (findLocalBinUpwards(binary, cwd, {
411
+ windowsExt: ".exe",
412
+ binDirs: VENV_BIN_DIRS,
413
+ }) ?? null);
353
414
  }
354
415
  /**
355
416
  * Check vendor/bin for PHP Composer-managed tools.
356
417
  * Walks up from cwd to find vendor/bin/<binary>.
418
+ *
419
+ * Tool-resolution walker, not a config lookup (#2514/#2517 policy): escaping
420
+ * the project upward past HOME means STOP, not keep reading — a `vendor/bin`
421
+ * match found at or above HOME can never be THIS project's own Composer
422
+ * install. Same single walker as `findInVenv` above; only `binDirs` and the
423
+ * Windows extension differ.
357
424
  */
358
425
  async function findInVendorBin(binary, cwd) {
359
- const isWin = process.platform === "win32";
360
- const names = isWin ? [`${binary}.bat`, binary] : [binary];
361
- let dir = cwd;
362
- const root = path.parse(dir).root;
363
- while (dir !== root) {
364
- for (const name of names) {
365
- const full = path.join(dir, "vendor", "bin", name);
366
- if (await fileExists(full))
367
- return full;
368
- }
369
- const parent = path.dirname(dir);
370
- if (parent === dir)
371
- break;
372
- dir = parent;
373
- }
374
- return null;
426
+ return (findLocalBinUpwards(binary, cwd, {
427
+ windowsExt: ".bat",
428
+ binDirs: VENDOR_BIN_DIRS,
429
+ }) ?? null);
375
430
  }
376
431
  /**
377
432
  * Check node_modules/.bin for locally installed Node tools.
378
433
  * Walks up from cwd to find node_modules/.bin/<binary>.
434
+ *
435
+ * #2514: this WAS a private, byte-for-byte duplicate of
436
+ * `findLocalBinUpwards` (`package-manager.ts`) — the same walk `stylua`
437
+ * below, `taplo.ts`, `knip-client.ts`, and (via `findNodeToolBinary`)
438
+ * `dependency-checker.ts`/`jscpd-client.ts` already reuse. A stray
439
+ * `~/node_modules/.bin/oxfmt.cmd` (the home-level pi-extensions manifest
440
+ * installs its own bins) was picked up as the project's formatter because
441
+ * this copy had no HOME ceiling; fixing only this copy would have left every
442
+ * other caller of `findLocalBinUpwards` with the same defect. Delegating
443
+ * here instead of hand-rolling a second ceiling keeps there being exactly one
444
+ * `node_modules/.bin` walker — see that function for the tool-resolution
445
+ * "escaping the project means STOP" policy (#2517).
379
446
  */
380
447
  async function findInNodeModules(binary, cwd) {
381
- const isWin = process.platform === "win32";
382
- let dir = cwd;
383
- const root = path.parse(dir).root;
384
- while (dir !== root) {
385
- const candidates = isWin
386
- ? [
387
- path.join(dir, "node_modules", ".bin", `${binary}.cmd`),
388
- path.join(dir, "node_modules", ".bin", binary),
389
- ]
390
- : [path.join(dir, "node_modules", ".bin", binary)];
391
- for (const full of candidates) {
392
- if (await fileExists(full))
393
- return full;
394
- }
395
- const parent = path.dirname(dir);
396
- if (parent === dir)
397
- break;
398
- dir = parent;
399
- }
400
- return null;
448
+ return findLocalBinUpwards(binary, cwd) ?? null;
401
449
  }
402
450
  /**
403
451
  * Returns true if `bundle exec <gem>` should be used:
@@ -735,7 +783,11 @@ export const oxfmtFormatter = {
735
783
  const found = await which("oxfmt");
736
784
  if (found)
737
785
  return [found, OXFMT_NO_ERROR_ON_UNMATCHED, filePath];
738
- return null;
786
+ // #2413: neither node_modules/.bin nor PATH has oxfmt, and `detect()` is
787
+ // config-only (it never probes the binary), so selection can reach here
788
+ // with nothing installed. The static command is bare `oxfmt` — spawning it
789
+ // only reproduces the reported `spawn oxfmt ENOENT`. Prove it unavailable.
790
+ return FORMATTER_UNAVAILABLE;
739
791
  },
740
792
  // Single source of truth: OXFMT_SUPPORTED_EXTENSIONS in tool-policy.ts.
741
793
  // Do not hand-maintain a second copy of this list (#1134 — previously two
@@ -772,7 +824,12 @@ export const ruffFormatter = {
772
824
  const installed = await ensureTool(toolId);
773
825
  if (installed)
774
826
  return [installed, ...args, filePath];
775
- return null;
827
+ // #2413: `ensureTool` already resolves PATH, global bins and the managed
828
+ // dir (and attempts an install) before returning falsy, so reaching here
829
+ // means ruff is genuinely nowhere. `detect()`'s `hasRuffConfig` branch has
830
+ // no binary check, so a config-only project selects ruff with nothing
831
+ // installed; the static bare `ruff` would then ENOENT. Prove it unavailable.
832
+ return FORMATTER_UNAVAILABLE;
776
833
  },
777
834
  async detect(cwd) {
778
835
  if (hasRuffConfig(cwd))
@@ -835,6 +892,18 @@ export const rustfmtFormatter = {
835
892
  name: "rustfmt",
836
893
  command: ["rustfmt", "$FILE"],
837
894
  extensions: [".rs"],
895
+ // #2466: rustfmt defaults to an OLDER edition than the file's actual Cargo
896
+ // package when invoked bare, so it can reject valid newer-edition syntax
897
+ // (e.g. Rust 2024). Carry the nearest package's `edition` (honoring
898
+ // `edition.workspace = true` inheritance) through `--edition`; `undefined`
899
+ // (unreadable/unparseable manifest) falls back to the static command
900
+ // above, unchanged from pre-#2466 behavior.
901
+ async resolveCommand(filePath, _cwd) {
902
+ const edition = await resolveCargoPackageEdition(filePath);
903
+ if (edition === undefined)
904
+ return null;
905
+ return ["rustfmt", "--edition", edition, filePath];
906
+ },
838
907
  async detect(cwd) {
839
908
  if ((await which("rustfmt")) !== null)
840
909
  return true;
@@ -959,13 +1028,27 @@ export const ktfmtFormatter = {
959
1028
  // ktfmt formats in place when given a file path (no flag needed).
960
1029
  command: ["ktfmt", "$FILE"],
961
1030
  extensions: [".kt", ".kts"],
1031
+ // #2468: ktfmt's CLI never reads a project's Gradle `ktfmt { googleStyle()
1032
+ // | kotlinLangStyle() }` selection — style is CLI-flag-only
1033
+ // (`--google-style`/`--kotlinlang-style`, verified against ktfmt v0.63's
1034
+ // own arg parser). Carry the nearest module's declared style through;
1035
+ // `undefined` (no declaration, unreadable/unparseable manifest, or an
1036
+ // unsupported style like the removed `dropboxStyle()`) falls back to the
1037
+ // bare invocation, unchanged from pre-#2468 behavior.
962
1038
  async resolveCommand(filePath, _cwd) {
1039
+ const styleFlag = await resolveKtfmtGradleStyle(filePath);
963
1040
  const inPath = await which("ktfmt");
964
1041
  if (inPath)
965
- return [inPath, filePath];
1042
+ return styleFlag ? [inPath, styleFlag, filePath] : [inPath, filePath];
966
1043
  const { ensureTool } = await import("./installer/index.js");
967
1044
  const installed = await ensureTool("ktfmt");
968
- return installed ? [installed, filePath] : null;
1045
+ // #2413: which() and ensureTool (PATH/global/managed + install) both
1046
+ // failed, and `detect()` gates only on hasKtfmtConfig — no binary probe —
1047
+ // so a config-only project reaches here with ktfmt absent. The static
1048
+ // command is bare `ktfmt`; spawning it reproduces the ENOENT class.
1049
+ if (!installed)
1050
+ return FORMATTER_UNAVAILABLE;
1051
+ return styleFlag ? [installed, styleFlag, filePath] : [installed, filePath];
969
1052
  },
970
1053
  async detect(cwd) {
971
1054
  // Opt-in only: ktfmt becomes the formatter when the project elects it,
@@ -1051,18 +1134,49 @@ export const phpCsFixerFormatter = {
1051
1134
  name: "php-cs-fixer",
1052
1135
  command: ["php-cs-fixer", "fix", "$FILE"],
1053
1136
  extensions: [".php"],
1137
+ // #2472: php-cs-fixer does NOT walk up parent directories looking for its
1138
+ // own config the way prettier/biome/eslint do (verified against upstream
1139
+ // `computeConfigFiles()` — see `resolvePhpCsFixerConfig`'s doc comment),
1140
+ // and `formatFile` spawns with cwd = the FILE's own directory, which is
1141
+ // not necessarily where the ancestor config `detect()` found actually
1142
+ // lives. Always resolve the binary explicitly here (vendor/bin first,
1143
+ // then global) rather than falling through to the static `command` above
1144
+ // — that static command can never carry `--config`, so a config found at
1145
+ // an ancestor would silently be dropped whenever the vendor lookup here
1146
+ // missed but a global binary still resolved. `--config` is attached
1147
+ // whenever a config resolves, even when it sits in the file's own
1148
+ // directory (AC3): unlike the pre-#2472 code, correctness no longer
1149
+ // depends on php-cs-fixer's own (nonexistent) upward search.
1054
1150
  async resolveCommand(filePath, cwd) {
1055
- const vendor = await findInVendorBin("php-cs-fixer", cwd);
1056
- if (vendor)
1057
- return [vendor, "fix", filePath];
1058
- return null;
1151
+ const configPath = resolvePhpCsFixerConfig(filePath);
1152
+ const binary = (await findInVendorBin("php-cs-fixer", cwd)) ??
1153
+ (await which("php-cs-fixer"));
1154
+ // #2413/#2472 review F4: both probes (vendor/bin, then PATH) have
1155
+ // PROVEN the binary is absent — returning `null` here would fall back
1156
+ // to the static `command` above, which is the SAME bare
1157
+ // `php-cs-fixer` this just failed to find, spawning it only to
1158
+ // re-observe the ENOENT already known. Report the proven-missing
1159
+ // state instead so `formatFile` skips the wasted spawn.
1160
+ if (!binary)
1161
+ return FORMATTER_UNAVAILABLE;
1162
+ return configPath
1163
+ ? [binary, "fix", "--config", configPath, filePath]
1164
+ : [binary, "fix", filePath];
1059
1165
  },
1060
1166
  async detect(cwd) {
1061
1167
  const vendorBin = await findInVendorBin("php-cs-fixer", cwd);
1062
1168
  const globalBin = await which("php-cs-fixer");
1063
1169
  if (!vendorBin && !globalBin)
1064
1170
  return false;
1065
- // Only run if project has explicit config
1171
+ // Only run if project has explicit config. This is a presence-only
1172
+ // climb from the project `cwd` (not necessarily the formatted file's
1173
+ // own directory) via this file's own `findUp` — deliberately NOT
1174
+ // merged with `resolvePhpCsFixerConfig` above (#2472 AC4): that
1175
+ // resolver climbs from the FILE's directory and needs the exact
1176
+ // winning path for `--config`, while this only needs a yes/no answer
1177
+ // from whatever `cwd` the caller passed. `rustfmtFormatter.detect`
1178
+ // keeps the same non-merged shape against `resolveCargoPackageEdition`
1179
+ // for the identical reason.
1066
1180
  const configs = [".php-cs-fixer.php", ".php-cs-fixer.dist.php"];
1067
1181
  const found = await findUp(configs, cwd);
1068
1182
  return found.length > 0;
@@ -1321,9 +1435,15 @@ export const ALL_FORMATTERS = [
1321
1435
  const FILENAME_FORMATTER_BASENAMES = new Set(ALL_FORMATTERS.flatMap((f) => f.filenames ?? []));
1322
1436
  // Cache for detection results - stores array of enabled formatter names per cwd+ext
1323
1437
  const detectionCache = new BoundedLruCache(32);
1438
+ // The signature is immutable for a cache generation. This memo is separate
1439
+ // from detectionCache because a cwd can have several extension entries, and a
1440
+ // warm lookup must not repeat the ancestor walk or stat matched configs.
1441
+ const formatterSignatureFlights = new Map();
1442
+ const formatterCacheGeneration = createGenerationSource("formatter-cache");
1324
1443
  // These are the formatter configuration files consulted by the policy helpers
1325
- // above. Their metadata is part of detection cache identity: changing a file
1326
- // must re-run detection even when PATH and installed tools are unchanged. A
1444
+ // above. Their metadata is captured by the cold detection signature. The
1445
+ // write-result seam invalidates that signature when a config path changes, so
1446
+ // detection re-runs even when PATH and installed tools are unchanged. A
1327
1447
  // filename a `has*Config` check reads but this list omits is invisible to the
1328
1448
  // cache: the signature never moves when that file is added, so a project that
1329
1449
  // opts in AFTER the first `getFormattersForFile` call for its cwd keeps
@@ -1413,9 +1533,7 @@ const FORMATTER_CONFIG_FILES = [
1413
1533
  ];
1414
1534
  async function formatterConfigSignature(cwd) {
1415
1535
  const paths = await findUp(FORMATTER_CONFIG_FILES, cwd);
1416
- const parts = await Promise.all(paths
1417
- .sort((a, b) => a.localeCompare(b))
1418
- .map(async (filePath) => {
1536
+ const parts = await Promise.all(paths.sort(compareOrdinal).map(async (filePath) => {
1419
1537
  try {
1420
1538
  const stat = await fs.stat(filePath);
1421
1539
  return `${filePath}:${stat.mtimeMs}:${stat.size}`;
@@ -1426,6 +1544,18 @@ async function formatterConfigSignature(cwd) {
1426
1544
  }));
1427
1545
  return parts.join("|");
1428
1546
  }
1547
+ async function getFormatterConfigSignature(cwd, normalizedCwd) {
1548
+ const existing = formatterSignatureFlights.get(normalizedCwd);
1549
+ if (existing)
1550
+ return existing.promise;
1551
+ const promise = formatterConfigSignature(cwd).finally(() => {
1552
+ const current = formatterSignatureFlights.get(normalizedCwd);
1553
+ if (current?.promise === promise)
1554
+ formatterSignatureFlights.delete(normalizedCwd);
1555
+ });
1556
+ formatterSignatureFlights.set(normalizedCwd, { promise });
1557
+ return promise;
1558
+ }
1429
1559
  // --- Public API ---
1430
1560
  export async function getFormattersForFile(filePath, cwd) {
1431
1561
  const ext = path.extname(filePath).toLowerCase();
@@ -1439,31 +1569,50 @@ export async function getFormattersForFile(filePath, cwd) {
1439
1569
  const cacheKey = FILENAME_FORMATTER_BASENAMES.has(base)
1440
1570
  ? `${normalizedCwd}:${ext}:${base}`
1441
1571
  : `${normalizedCwd}:${ext}`;
1442
- const configSignature = await formatterConfigSignature(cwd);
1443
- // Check cache
1572
+ // A warm entry is authoritative until the write-result seam invalidates its
1573
+ // config path. This is the only way to make the warm path free of config
1574
+ // polling while still reacting immediately to pi-authored create/remove/
1575
+ // change events. External editor changes remain a documented session-boundary
1576
+ // limitation, like other write-result-owned freshness seams.
1444
1577
  let cached = detectionCache.get(normalizedCwd);
1445
- if (cached && cached.signature !== configSignature) {
1446
- // An EXISTING entry whose signature moved is the user telling us the world
1447
- // changed. Re-probe PATH too, so "install the tool, touch the config" still
1448
- // works within one session now that a durable absence latches (#1495).
1449
- // Scoped to a real change on purpose: keying this off "no entry yet" would
1450
- // drop every PATH verdict on the first save in each new directory, which in
1451
- // a monorepo means re-probing forever.
1452
- detectionCache.delete(normalizedCwd);
1453
- cached = undefined;
1454
- resetWhichLatches();
1455
- }
1456
- if (!cached) {
1457
- cached = { signature: configSignature, entries: new Map() };
1458
- detectionCache.set(normalizedCwd, cached);
1459
- }
1460
- if (cached.entries.has(cacheKey)) {
1578
+ if (cached?.entries.has(cacheKey)) {
1461
1579
  const enabledNames = cached.entries.get(cacheKey);
1580
+ const selectedFormatterName = enabledNames && enabledNames.length > 0 ? enabledNames[0] : null;
1581
+ logLatency({
1582
+ type: "phase",
1583
+ phase: "formatter_selected",
1584
+ filePath,
1585
+ durationMs: 0,
1586
+ metadata: {
1587
+ formatter: selectedFormatterName,
1588
+ reason: "cache",
1589
+ cached: true,
1590
+ outcome: "hit",
1591
+ cwd,
1592
+ },
1593
+ });
1462
1594
  if (!enabledNames || enabledNames.length === 0)
1463
1595
  return [];
1464
- // Return cached formatters by name (preserves priority order)
1465
1596
  return ALL_FORMATTERS.filter((f) => enabledNames.includes(f.name));
1466
1597
  }
1598
+ if (!cached) {
1599
+ const generation = formatterCacheGeneration.capture();
1600
+ const configSignature = await getFormatterConfigSignature(cwd, normalizedCwd);
1601
+ // An invalidation can happen while the first signature walk is in flight.
1602
+ // Do not publish a pre-invalidation signature into the new generation.
1603
+ if (!generation.isCurrent()) {
1604
+ return getFormattersForFile(filePath, cwd);
1605
+ }
1606
+ // Another cold caller for this cwd can finish the shared signature flight
1607
+ // first. Re-read the LRU before installing an object so the later caller
1608
+ // merges its extension entry into the existing object instead of replacing
1609
+ // the earlier entry.
1610
+ cached = detectionCache.get(normalizedCwd);
1611
+ if (!cached) {
1612
+ cached = { signature: configSignature, entries: new Map() };
1613
+ detectionCache.set(normalizedCwd, cached);
1614
+ }
1615
+ }
1467
1616
  // Detect formatters for this extension (or exact filename, e.g. terragrunt.hcl)
1468
1617
  const matching = ALL_FORMATTERS.filter((f) => f.extensions.includes(ext) || f.filenames?.includes(base));
1469
1618
  const formatterPolicy = getFormatterPolicyForFile(filePath);
@@ -1601,6 +1750,7 @@ export async function getFormattersForFile(filePath, cwd) {
1601
1750
  metadata: {
1602
1751
  formatter: selected?.name ?? null,
1603
1752
  reason: selectionReason,
1753
+ outcome: "miss",
1604
1754
  cwd,
1605
1755
  ...(provisional && {
1606
1756
  cached: false,
@@ -1616,7 +1766,11 @@ export async function getFormattersForFile(filePath, cwd) {
1616
1766
  return enabled;
1617
1767
  // Store the list of enabled formatter names in cache
1618
1768
  const enabledNames = enabled.map((f) => f.name);
1619
- cached.entries.set(cacheKey, enabledNames);
1769
+ // An invalidation may have removed or replaced this object while detection
1770
+ // awaited a tool probe. Never repopulate the old generation.
1771
+ if (detectionCache.get(normalizedCwd) === cached) {
1772
+ cached.entries.set(cacheKey, enabledNames);
1773
+ }
1620
1774
  return enabled;
1621
1775
  }
1622
1776
  /**
@@ -1630,9 +1784,21 @@ export async function getFormattersForFile(filePath, cwd) {
1630
1784
  * therefore re-arms every directory EXCEPT the one the user is working in.
1631
1785
  */
1632
1786
  export function clearFormatterCache() {
1787
+ formatterCacheGeneration.bump();
1788
+ formatterSignatureFlights.clear();
1633
1789
  detectionCache.clear();
1634
1790
  resetWhichLatches();
1635
1791
  }
1792
+ const formatterConfigBasenames = new Set(FORMATTER_CONFIG_FILES.map((fileName) => fileName.toLowerCase()));
1793
+ /** Invalidate selection when the write-result seam reports a config path. */
1794
+ export function invalidateFormatterCacheForPath(filePath) {
1795
+ const basename = filePath.includes("\\")
1796
+ ? path.win32.basename(filePath)
1797
+ : path.basename(filePath);
1798
+ if (!formatterConfigBasenames.has(basename.toLowerCase()))
1799
+ return;
1800
+ clearFormatterCache();
1801
+ }
1636
1802
  /**
1637
1803
  * Test-only internals access for the session-state registry probe (#1895):
1638
1804
  * the conformance suite must arm all four pieces of state this reset claims
@@ -1647,16 +1813,13 @@ export function _getFormatterResetStateForTests() {
1647
1813
  };
1648
1814
  }
1649
1815
  export function clearFormatterRuntimeState() {
1650
- detectionCache.clear();
1651
- resetWhichLatches();
1816
+ clearFormatterCache();
1652
1817
  // NO `resetLazyInstallAttempts()` here (#1537 review F1). This function runs
1653
1818
  // from `resetFormatService()`, which `handleTurnEnd` calls every turn — so
1654
1819
  // clearing the lazy-install hold here made "held for the session" mean "held
1655
1820
  // for a turn", and a failing install re-ran every turn. The hold's only reset
1656
1821
  // is `session_start`'s block in runtime-session.ts.
1657
1822
  }
1658
- // ESC is built via fromCharCode so no raw control byte sits in the source.
1659
- const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, "g");
1660
1823
  const BOX_DRAWING_GLOBAL = /[\u2500-\u257F]/g;
1661
1824
  const HAS_BOX_DRAWING = /[\u2500-\u257F]/;
1662
1825
  /**
@@ -1673,7 +1836,7 @@ const HAS_BOX_DRAWING = /[\u2500-\u257F]/;
1673
1836
  */
1674
1837
  export function firstDiagnosticLine(text) {
1675
1838
  for (const raw of (text ?? "").split("\n")) {
1676
- const line = raw.replace(ANSI_ESCAPE, "").trimEnd();
1839
+ const line = stripAnsi(raw).trimEnd();
1677
1840
  const stripped = line.replace(BOX_DRAWING_GLOBAL, "").trim();
1678
1841
  if (!stripped)
1679
1842
  continue;
@@ -1699,6 +1862,12 @@ async function resolveFormatterCommand(formatter, absolutePath, cwd) {
1699
1862
  : null;
1700
1863
  if (resolved === SKIP_FORMATTING)
1701
1864
  return SKIP_FORMATTING;
1865
+ // The resolver proved the executable absent — DO NOT fall through to the
1866
+ // static bare command, which is the same binary it just probed and missing
1867
+ // (#2413). The caller turns this into a typed `unavailable` outcome, never a
1868
+ // spawn and never a failure.
1869
+ if (resolved === FORMATTER_UNAVAILABLE)
1870
+ return FORMATTER_UNAVAILABLE;
1702
1871
  if (resolved !== null)
1703
1872
  return resolved;
1704
1873
  const fallback = formatter.command.map((c) => c.replace("$FILE", absolutePath));
@@ -1726,13 +1895,40 @@ export async function formatFile(filePath, formatter) {
1726
1895
  if (cmd === SKIP_FORMATTING) {
1727
1896
  // Style-preserving refusal (#1144): no repo config and no detectable
1728
1897
  // indentation to pin — formatting would impose the tool's stock style.
1729
- return { success: true, changed: false };
1898
+ return { success: true, changed: false, outcome: "skipped" };
1899
+ }
1900
+ if (cmd === FORMATTER_UNAVAILABLE) {
1901
+ // The resolver proved the executable absent (#2413): the oxfmt ENOENT
1902
+ // trap and its siblings. This is unavailable infrastructure, not a code
1903
+ // failure — success stays true so it is never counted as a failed file
1904
+ // or requeued, and the typed outcome keeps it out of the failure bucket.
1905
+ return {
1906
+ success: true,
1907
+ changed: false,
1908
+ outcome: "unavailable",
1909
+ error: `${formatter.name}: formatter executable not found`,
1910
+ };
1730
1911
  }
1731
1912
  // Run formatter without blocking the event loop.
1732
1913
  const result = await safeSpawnAsync(cmd[0], cmd.slice(1), {
1733
1914
  timeout: 15000,
1734
1915
  cwd,
1735
1916
  });
1917
+ // A resolver that could NOT prove absence (it never probed PATH — e.g.
1918
+ // black/sqlfluff, which only look in a venv) legitimately falls back to the
1919
+ // bare static command. When that bare command is missing, the spawn boundary
1920
+ // reports a typed `tool-not-found` failure — never a nonzero exit. Treat it
1921
+ // as unavailable, not a formatting failure, closing the same #2413 class for
1922
+ // the formatters whose `null` correctly means "static untried" (no
1923
+ // oxfmt-specific string matching — this reads the typed spawn-failure kind).
1924
+ if (result.spawnFailure?.kind === "tool-not-found") {
1925
+ return {
1926
+ success: true,
1927
+ changed: false,
1928
+ outcome: "unavailable",
1929
+ error: result.spawnFailure.message,
1930
+ };
1931
+ }
1736
1932
  // Strict by default (#1337): only a formatter with a documented
1737
1933
  // benign-nonzero mode (`lenientExitCode`) may exit nonzero and still be
1738
1934
  // read as a successful run. Everything else that exits nonzero never
@@ -1745,6 +1941,7 @@ export async function formatFile(filePath, formatter) {
1745
1941
  return {
1746
1942
  success: false,
1747
1943
  changed: false,
1944
+ outcome: "failed",
1748
1945
  error: result.error?.message ||
1749
1946
  firstDiagnosticLine(result.stderr) ||
1750
1947
  // biome, ktlint and `mix format` report on STDOUT; without this
@@ -1760,12 +1957,14 @@ export async function formatFile(filePath, formatter) {
1760
1957
  return {
1761
1958
  success: true,
1762
1959
  changed,
1960
+ outcome: changed ? "formatted" : "unchanged",
1763
1961
  };
1764
1962
  }
1765
1963
  catch (err) {
1766
1964
  return {
1767
1965
  success: false,
1768
1966
  changed: false,
1967
+ outcome: "failed",
1769
1968
  error: err instanceof Error ? err.message : String(err),
1770
1969
  };
1771
1970
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The shared re-check cadence for a cached "nothing here" verdict — the
3
+ * bound both `file-utils.ts`'s nested `.gitignore`/`.pi-lens.json` matcher
4
+ * (#2159/#2071) and `project-lens-config.ts`'s no-config discovery cache
5
+ * (#2483 round 2) use so a negative result is neither re-statted on every
6
+ * call nor cached for the life of the process.
7
+ *
8
+ * A declared leaf module, the same shape as `ledger-bounds.ts` /
9
+ * `spawn-output-cap.ts`: it imports nothing from `clients/`, because
10
+ * `file-utils.ts` already imports `project-lens-config.ts`, and the
11
+ * `clients/` acyclic-imports rule (`no-client-cycles`,
12
+ * `.dependency-cruiser.cjs`) would reject either of those two files
13
+ * importing a constant that lived inside the other. A shared value neither
14
+ * owns is the only way both stay on the same cadence without one importing
15
+ * the other.
16
+ */
17
+ export const FRESHNESS_CADENCE_MS = 2_000;