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
@@ -10,14 +10,57 @@
10
10
  * Design: File-level targeted testing — only runs tests for the
11
11
  * specific file being edited, not the entire suite.
12
12
  */
13
- import { createSubsystemLogger } from "./extension-log.js";
13
+ import { createHash } from "node:crypto";
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
+ import { BoundedFifoMap } from "./bounded-cache.js";
17
+ import { emitBounded } from "./bounded-telemetry.js";
18
+ import { LEDGER_FIELD_MAX } from "./degradation-ledger.js";
16
19
  import { minimatch } from "./deps/minimatch.js";
20
+ import { createSubsystemLogger } from "./extension-log.js";
17
21
  import { detectFileRole } from "./file-role.js";
18
22
  import { findGlobalBinary } from "./package-manager.js";
23
+ import { PathKeyedMap } from "./path-keyed-map.js";
24
+ import { augmentPythonEnvironment, detectPythonEnvironment, } from "./python-environment.js";
25
+ import { normalizeEphemeralMapKey, normalizeMapKey, toPosix, } from "./path-utils.js";
19
26
  import { isMeasuredDuration, toMeasuredDurationMs } from "./run-duration.js";
20
27
  import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
28
+ import { stripAnsi } from "./sanitize.js";
29
+ /**
30
+ * #2532: the ONE classification seam for "this `TestResult` carries NO
31
+ * counted failure the agent needs to fix, even though the runner also
32
+ * reported an `error`" — i.e. `failed === 0 && !!error`. This is NOT "the
33
+ * suite never started": a runner can report BOTH counted failures and an
34
+ * error (pytest exit 2 "Interrupted" after `2 failed, 1 passed` already
35
+ * printed — `parsePytestOutput` sets `error` from the exit code
36
+ * independently of the parsed counts, review round 1 S2). The rule is
37
+ * exactly `failed === 0`, nothing about `error`'s presence or cause: a
38
+ * result with counted failures stays blocking even when `error` is also
39
+ * set (`testResultToProjectDiagnostics`/`formatResult` still mention the
40
+ * error in that case, they just don't let it downgrade the verdict).
41
+ *
42
+ * `hasRealFailure`/`runnerErrorOnly` (`runtime-turn.ts`, #2522) key off the
43
+ * exact same fact for the turn-end delivery framing — a batch made
44
+ * entirely of `isRunnerErrorResult` results is advisory, one containing
45
+ * even one counted failure keeps "fix before continuing". Three more
46
+ * surfaces route through this SAME predicate instead of re-deriving it
47
+ * (review round 1 S1's class sweep), so the identical `TestResult` cannot
48
+ * classify differently across them: `testResultToProjectDiagnostics`
49
+ * (`lens_diagnostics mode=full`), the `--lens-guard` merge call in
50
+ * `handleTurnEnd`, and `TestRunnerClient.formatResult`/the turn-end dbg
51
+ * summary (both in this file / `runtime-turn.ts`).
52
+ *
53
+ * Two call sites intentionally test the COMPLEMENT (a truly clean result,
54
+ * no counted failure AND no error) rather than this predicate, and are not
55
+ * a missed fourth spelling: `runtime-turn.ts`'s `cleanFiles` filter
56
+ * (`--lens-guard`'s clear-blocker list) and `testResultToProjectDiagnostics`'s
57
+ * own early "nothing to report" return. Both need "genuinely clean", which
58
+ * `!isRunnerErrorResult(result)` cannot express (it is also true for a
59
+ * counted failure).
60
+ */
61
+ export function isRunnerErrorResult(result) {
62
+ return result.failed === 0 && !!result.error;
63
+ }
21
64
  // Source file → test file patterns (reverse lookup)
22
65
  const SOURCE_TO_TEST_PATTERNS = [
23
66
  {
@@ -65,6 +108,57 @@ const MAX_NODE_MODULES_WALK_UP = 5;
65
108
  // test type rather than mirroring source layout) — capped depth, never an
66
109
  // unbounded walk of the whole tests tree.
67
110
  const MAX_PYTEST_RECURSE_DEPTH = 3;
111
+ /**
112
+ * #2522: built-in exclusion list for the turn-end auto-fired test selection.
113
+ *
114
+ * `getTestRunTarget`'s three strategies (failed-first / related / self) will
115
+ * happily resolve to ANY test file on disk, including integration/e2e suites
116
+ * that spawn external processes. A plegma dogfooding turn resolved
117
+ * `tests/integration/opencode-delegate.test.ts` this way — it spawns
118
+ * `opencode` and needs a configured provider, so on a box without one it took
119
+ * 17s to fail and was reported to the agent as "3/3 failed, fix before
120
+ * proceeding" on an unrelated model-switch turn (#2522, refs #2504/#2509).
121
+ *
122
+ * One hard-coded list, no per-project config knob (maintainer decision
123
+ * 2026-09-03) — this is a safety bound on what turn_end may auto-fire, not a
124
+ * project preference. Documented in AGENTS.md and docs/. Matched against the
125
+ * resolved test file's project-relative, POSIX-folded path so it applies
126
+ * uniformly to whichever strategy produced the target.
127
+ */
128
+ export const TURN_END_EXCLUDED_TEST_GLOBS = [
129
+ "**/integration/**",
130
+ "**/e2e/**",
131
+ "**/*.integration.*",
132
+ "**/*.e2e.*",
133
+ ];
134
+ /**
135
+ * Whether a resolved test target falls under the built-in turn-end
136
+ * exclusion list (#2522). `testFilePath` may be absolute or relative, and
137
+ * may use either path-separator form — folded through `toPosix` after being
138
+ * made cwd-relative so `\`- and `/`-separated inputs match identically
139
+ * (AGENTS.md cross-form-path screen).
140
+ */
141
+ export function isExcludedTestTarget(testFilePath, cwd) {
142
+ const rel = toPosix(path.relative(cwd, path.resolve(cwd, testFilePath)));
143
+ // #2522 review round 2, F6: a target that resolves OUTSIDE the project root
144
+ // yields a `..`-leading relative path (or, across Windows drives, a still
145
+ // absolute one), which matches none of the globs — so the bare `some()`
146
+ // below reported it as "not excluded" and turn_end would have auto-spawned a
147
+ // runner against a file outside the project the turn is running in. An
148
+ // out-of-tree target, and the project root itself, fail CLOSED.
149
+ if (rel === "" ||
150
+ rel === ".." ||
151
+ rel.startsWith("../") ||
152
+ path.isAbsolute(rel))
153
+ return true;
154
+ // #2522 review round 2, F5: case-INSENSITIVE. `tests/Integration/`,
155
+ // `tests/E2E/` and `foo.E2E.test.ts` are the same hazard as their lowercase
156
+ // spellings, and on the case-insensitive filesystems Windows and macOS ship
157
+ // by default they are literally the same files — so a case-sensitive match
158
+ // made the SAME repo excluded on one box and unbounded on another. A safety
159
+ // bound a capital letter defeats is not a bound.
160
+ return TURN_END_EXCLUDED_TEST_GLOBS.some((glob) => minimatch(rel, glob, { dot: true, nocase: true }));
161
+ }
68
162
  // --- Runner Detection ---
69
163
  export const RUNNERS = {
70
164
  vitest: {
@@ -194,21 +288,158 @@ export function stripWrapperArgs(binName, args) {
194
288
  return args;
195
289
  }
196
290
  // --- Client ---
291
+ const MAX_FAILED_TARGETS_PER_RUNNER = 32;
292
+ const MAX_FAILED_TARGET_CHECKS_PER_SELECTION = 8;
293
+ const FAILED_TARGET_DETAIL_CAP_PER_TURN = 8;
294
+ function canonicalFailedPath(filePath) {
295
+ const absolute = path.resolve(filePath);
296
+ try {
297
+ return normalizeMapKey(fs.realpathSync.native(absolute));
298
+ }
299
+ catch {
300
+ return normalizeMapKey(absolute);
301
+ }
302
+ }
303
+ function canonicalProjectRoot(cwd) {
304
+ const absolute = path.resolve(cwd);
305
+ try {
306
+ return {
307
+ key: normalizeEphemeralMapKey(fs.realpathSync.native(absolute)),
308
+ resolved: true,
309
+ };
310
+ }
311
+ catch {
312
+ return { key: normalizeEphemeralMapKey(absolute), resolved: false };
313
+ }
314
+ }
315
+ export const MAX_CANONICAL_ROOT_MEMO_ENTRIES = 512;
316
+ function filesystemErrorCode(error) {
317
+ if (error !== null &&
318
+ typeof error === "object" &&
319
+ "code" in error &&
320
+ typeof error.code === "string") {
321
+ return error.code;
322
+ }
323
+ return undefined;
324
+ }
325
+ const PYTEST_SUMMARY_OUTCOME = "(?:passed|failed|skipped|reruns?|errors?|warnings?|deselected|xfailed|xpassed)";
326
+ const PYTEST_SUMMARY_LINE = new RegExp(`^\\s*(?:=+\\s*)?\\d+\\s+${PYTEST_SUMMARY_OUTCOME}` +
327
+ `(?:\\s*,\\s*\\d+\\s+${PYTEST_SUMMARY_OUTCOME})*` +
328
+ `\\s+in\\s+[\\d.]+s(?:\\s*=+)?\\s*$`, "i");
329
+ /** Extract aggregate values from pytest's final outcome line only. */
330
+ function parsePytestSummary(output) {
331
+ // Pytest may color the whole summary or individual tokens. Strip ANSI before
332
+ // selecting and extracting so formatting cannot turn the first count into 0.
333
+ const normalizedOutput = stripAnsi(output);
334
+ const summaryLine = normalizedOutput
335
+ .split(/\r?\n/)
336
+ .reverse()
337
+ .find((line) => PYTEST_SUMMARY_LINE.test(line));
338
+ let passed = 0;
339
+ let failed = 0;
340
+ let skipped = 0;
341
+ if (!summaryLine)
342
+ return { passed, failed, skipped };
343
+ const summaryBody = summaryLine.replace(/^=+\s*|\s*=+\s*$/g, "");
344
+ for (const field of summaryBody.split(",")) {
345
+ const [countText, outcome] = field.trim().split(/\s+/, 2);
346
+ const count = Number.parseInt(countText, 10);
347
+ if (!Number.isFinite(count))
348
+ continue;
349
+ switch (outcome) {
350
+ case "passed":
351
+ passed = count;
352
+ break;
353
+ case "failed":
354
+ failed = count;
355
+ break;
356
+ case "skipped":
357
+ skipped = count;
358
+ break;
359
+ }
360
+ }
361
+ const durationMatch = /in\s+([\d.]+)s/.exec(summaryLine);
362
+ // Rounded, like `jsonRunDurationMs` and PHPUnit's legacy path:
363
+ // `in 2.01s` is 2009.9999999999998 in binary floating point.
364
+ const duration = durationMatch
365
+ ? Math.round(Number.parseFloat(durationMatch[1]) * 1000)
366
+ : undefined;
367
+ return { passed, failed, skipped, duration };
368
+ }
197
369
  export class TestRunnerClient {
198
370
  log;
199
- availableRunners = new Map();
371
+ // This is an instance-lifetime memo of RESOLVED spellings only, which leaves
372
+ // two temporal edges. A symlink retargeted mid-session keeps its old
373
+ // resolution until a new client instance — acceptable because round 2's
374
+ // evidence re-validation already handles verdict-level staleness (positive
375
+ // verdicts re-stat their config file). A spelling that did NOT resolve is
376
+ // never memoized (#2077): the fallback key is a guess about a path that does
377
+ // not exist yet, so memoizing it would pin an alias probed before its
378
+ // symlink was created to a stale verdict for the instance's life. Re-probing
379
+ // costs one failing realpath per call, and only for a cwd that does not
380
+ // resolve — a state where `detectRunner` already walks node_modules on every
381
+ // call. Keep the memo bounded so pathological spelling churn cannot grow it
382
+ // without limit.
383
+ canonicalRootMemo = new BoundedFifoMap(MAX_CANONICAL_ROOT_MEMO_ENTRIES);
384
+ availableRunners = new PathKeyedMap(normalizeEphemeralMapKey);
200
385
  failedTestsByRunner = new Map();
386
+ failedTargetSequence = 0;
387
+ statFailedTarget;
201
388
  // Best-effort vitest config `test.include`/`test.exclude` globs, scraped as
202
389
  // plain text (never executed) and cached per cwd so the config file is
203
390
  // only read/parsed once, not on every edit. `null` means "no config found
204
391
  // or it couldn't be parsed in the simple shape we look for" — callers
205
392
  // treat that as "no additional signal" and fall back to naming-convention
206
393
  // detection only.
207
- vitestTestGlobsCache = new Map();
208
- constructor(verbose = false) {
209
- this.log = verbose
210
- ? createSubsystemLogger("test-runner")
211
- : () => { };
394
+ vitestTestGlobsCache = new PathKeyedMap(normalizeEphemeralMapKey);
395
+ constructor(verbose = false, options = {}) {
396
+ this.log = verbose ? createSubsystemLogger("test-runner") : () => { };
397
+ this.statFailedTarget =
398
+ options.statFailedTarget ?? ((filePath) => void fs.statSync(filePath));
399
+ }
400
+ getCanonicalProjectRoot(cwd) {
401
+ const cached = this.canonicalRootMemo.get(cwd);
402
+ if (cached !== undefined)
403
+ return cached;
404
+ const { key, resolved } = canonicalProjectRoot(cwd);
405
+ if (!resolved)
406
+ return key;
407
+ this.canonicalRootMemo.set(cwd, key);
408
+ return key;
409
+ }
410
+ /** #2442 test-only: exercise canonicalRootMemo's bounded eviction directly. */
411
+ _getCanonicalProjectRootForTests(cwd) {
412
+ return this.getCanonicalProjectRoot(cwd);
413
+ }
414
+ getRunnerAvailability(byRunner, runner) {
415
+ const cached = byRunner?.get(runner);
416
+ if (!cached)
417
+ return undefined;
418
+ if (cached.available &&
419
+ cached.evidencePath !== undefined &&
420
+ !fs.existsSync(cached.evidencePath)) {
421
+ byRunner?.delete(runner);
422
+ return undefined;
423
+ }
424
+ return cached.available;
425
+ }
426
+ /**
427
+ * #2252: only a POSITIVE verdict is memoized. A negative one has no
428
+ * `evidencePath` to re-stat, so `getRunnerAvailability` had no way to tell
429
+ * "still absent" from "a config file just appeared" and served the first
430
+ * miss for the client's whole process lifetime — measured live: probe an
431
+ * empty directory, add `vitest.config.ts`, and the SAME client kept
432
+ * answering "no runner". Same precedent as #2242's alias-canonicalization
433
+ * fix (`clients/test-runner-client.ts`'s `getCanonicalProjectRoot`): drop
434
+ * the memo write on the failure branch rather than adding a TTL or a
435
+ * re-arm signal. The cost is bounded and already paid today — a cache miss
436
+ * re-runs the exact same `configFiles.some(fs.existsSync)` walk this
437
+ * method's caller already does on every FIRST probe of a runner.
438
+ */
439
+ setRunnerAvailability(byRunner, runner, available, evidencePath) {
440
+ if (!available)
441
+ return;
442
+ byRunner.set(runner, { available, evidencePath });
212
443
  }
213
444
  /**
214
445
  * Check if a test runner is available in the project
@@ -218,15 +449,22 @@ export class TestRunnerClient {
218
449
  * 3. node_modules presence
219
450
  */
220
451
  detectRunner(cwd, sourceFilePath) {
452
+ const rootKey = this.getCanonicalProjectRoot(cwd);
453
+ let byRunner = this.availableRunners.get(rootKey);
454
+ if (!byRunner) {
455
+ byRunner = new Map();
456
+ this.availableRunners.set(rootKey, byRunner);
457
+ }
221
458
  // Priority 1: Config files
222
459
  for (const [name, config] of Object.entries(RUNNERS)) {
223
- const cacheKey = `${cwd}:${name}:config`;
224
- if (this.availableRunners.has(cacheKey)) {
225
- if (this.availableRunners.get(cacheKey)) {
460
+ const cached = this.getRunnerAvailability(byRunner, name);
461
+ if (cached !== undefined) {
462
+ if (cached) {
226
463
  return { runner: name, config };
227
464
  }
228
465
  continue;
229
466
  }
467
+ let configEvidencePath;
230
468
  const found = config.configFiles.some((cf) => {
231
469
  if (name === "pytest" && cf === "pyproject.toml") {
232
470
  const pyprojectPath = path.join(cwd, cf);
@@ -234,7 +472,10 @@ export class TestRunnerClient {
234
472
  return false;
235
473
  try {
236
474
  const pyproject = fs.readFileSync(pyprojectPath, "utf-8");
237
- return pyproject.includes("[tool.pytest.ini_options]");
475
+ const matches = pyproject.includes("[tool.pytest.ini_options]");
476
+ if (matches)
477
+ configEvidencePath = pyprojectPath;
478
+ return matches;
238
479
  }
239
480
  catch {
240
481
  return false;
@@ -250,15 +491,22 @@ export class TestRunnerClient {
250
491
  ...composer.require,
251
492
  ...composer["require-dev"],
252
493
  };
253
- return Boolean(allDeps["phpunit/phpunit"]);
494
+ const matches = Boolean(allDeps["phpunit/phpunit"]);
495
+ if (matches)
496
+ configEvidencePath = composerPath;
497
+ return matches;
254
498
  }
255
499
  catch {
256
500
  return false;
257
501
  }
258
502
  }
259
- return fs.existsSync(path.join(cwd, cf));
503
+ const candidate = path.join(cwd, cf);
504
+ const matches = fs.existsSync(candidate);
505
+ if (matches)
506
+ configEvidencePath = candidate;
507
+ return matches;
260
508
  });
261
- this.availableRunners.set(cacheKey, found);
509
+ this.setRunnerAvailability(byRunner, name, found, configEvidencePath);
262
510
  if (found) {
263
511
  this.log(`Detected runner via config: ${name}`);
264
512
  return { runner: name, config };
@@ -274,17 +522,17 @@ export class TestRunnerClient {
274
522
  // Check for vitest first (more specific than jest)
275
523
  if (allDeps.vitest) {
276
524
  this.log("Detected vitest in package.json");
277
- this.availableRunners.set(`${cwd}:vitest:config`, true);
525
+ this.setRunnerAvailability(byRunner, "vitest", true, packageJsonPath);
278
526
  return { runner: "vitest", config: RUNNERS.vitest };
279
527
  }
280
528
  if (allDeps.jest) {
281
529
  this.log("Detected jest in package.json");
282
- this.availableRunners.set(`${cwd}:jest:config`, true);
530
+ this.setRunnerAvailability(byRunner, "jest", true, packageJsonPath);
283
531
  return { runner: "jest", config: RUNNERS.jest };
284
532
  }
285
533
  if (allDeps.pytest || allDeps["pytest-cov"]) {
286
534
  this.log("Detected pytest in package.json (unusual)");
287
- this.availableRunners.set(`${cwd}:pytest:config`, true);
535
+ this.setRunnerAvailability(byRunner, "pytest", true, packageJsonPath);
288
536
  return { runner: "pytest", config: RUNNERS.pytest };
289
537
  }
290
538
  }
@@ -428,7 +676,10 @@ export class TestRunnerClient {
428
676
  relativeSourceDir(sourceFilePath, cwd) {
429
677
  const dir = path.dirname(sourceFilePath);
430
678
  const relDir = path.relative(cwd, path.resolve(cwd, dir));
431
- if (!relDir || relDir === "." || relDir.startsWith("..") || path.isAbsolute(relDir)) {
679
+ if (!relDir ||
680
+ relDir === "." ||
681
+ relDir.startsWith("..") ||
682
+ path.isAbsolute(relDir)) {
432
683
  return null;
433
684
  }
434
685
  return relDir;
@@ -447,21 +698,40 @@ export class TestRunnerClient {
447
698
  * template expression) — anything more dynamic than that is out of
448
699
  * scope for this heuristic.
449
700
  *
450
- * Cached per `cwd` so the file is only read/parsed once per project,
451
- * not on every edit.
701
+ * Cached per `cwd`, including a `null` result — #2252 F2: a project with
702
+ * no vitest config, or one this heuristic can't scrape, is a COMMON shape
703
+ * (every non-vitest project pays this on every edit otherwise; measured
704
+ * ~1500x — 0.4µs cached vs. 598.7µs re-reading the candidate list every
705
+ * call). The cache entry is revalidated on every read, the same shape
706
+ * `getRunnerAvailability` uses for a positive verdict: bounded
707
+ * `fs.existsSync` checks against the config file the result was derived
708
+ * from (or, when none existed, against the candidate list itself), never
709
+ * a full re-read/re-parse on a cache hit. So a config file appearing (or
710
+ * a broken one being fixed) still converges — only the FULL parse is
711
+ * paid once, not the existence check.
452
712
  */
453
713
  parseVitestTestGlobs(cwd) {
454
- if (this.vitestTestGlobsCache.has(cwd)) {
455
- return this.vitestTestGlobsCache.get(cwd) ?? null;
456
- }
714
+ const rootKey = this.getCanonicalProjectRoot(cwd);
457
715
  // .mts isn't in RUNNERS.vitest.configFiles (that list drives runner
458
716
  // *detection* priority) but is a legal vitest config extension, so it's
459
717
  // included here for the scrape even though detectRunner doesn't check it.
460
718
  const candidates = [...RUNNERS.vitest.configFiles, "vitest.config.mts"];
719
+ const cached = this.vitestTestGlobsCache.get(rootKey);
720
+ if (cached !== undefined) {
721
+ const stillValid = cached.evidencePath !== undefined
722
+ ? fs.existsSync(cached.evidencePath)
723
+ : !candidates.some((cf) => fs.existsSync(path.join(cwd, cf)));
724
+ if (stillValid)
725
+ return cached.result;
726
+ this.vitestTestGlobsCache.delete(rootKey);
727
+ }
461
728
  let content = null;
729
+ let foundPath;
462
730
  for (const cf of candidates) {
731
+ const candidatePath = path.join(cwd, cf);
463
732
  try {
464
- content = fs.readFileSync(path.join(cwd, cf), "utf-8");
733
+ content = fs.readFileSync(candidatePath, "utf-8");
734
+ foundPath = candidatePath;
465
735
  break;
466
736
  }
467
737
  catch {
@@ -480,7 +750,7 @@ export class TestRunnerClient {
480
750
  result.exclude = exclude;
481
751
  }
482
752
  }
483
- this.vitestTestGlobsCache.set(cwd, result);
753
+ this.vitestTestGlobsCache.set(rootKey, { result, evidencePath: foundPath });
484
754
  return result;
485
755
  }
486
756
  /**
@@ -706,12 +976,11 @@ export class TestRunnerClient {
706
976
  * 1) If there are known failing tests, rerun those first (fast feedback loop).
707
977
  * 2) Otherwise run related tests for the edited file.
708
978
  */
709
- getTestRunTarget(sourceFilePath, cwd) {
979
+ getTestRunTarget(sourceFilePath, cwd, turnIndex) {
710
980
  const detected = this.detectRunner(cwd, sourceFilePath);
711
981
  if (!detected)
712
982
  return null;
713
- const key = this.failedKey(cwd, detected.runner);
714
- const failedSet = this.failedTestsByRunner.get(key);
983
+ const failedSet = this.getFailedTargets(cwd, detected.runner);
715
984
  // If the edited file is itself a test file, there's no "related test"
716
985
  // to discover — running findTestFile on it would strip its own
717
986
  // extension and search for nonsense like foo.test.test.ts. Skip
@@ -721,34 +990,22 @@ export class TestRunnerClient {
721
990
  ? null
722
991
  : this.findTestFile(sourceFilePath, cwd, detected.runner);
723
992
  if (failedSet && failedSet.size > 0) {
724
- if (related) {
725
- const relatedAbs = path.resolve(related.testFile);
726
- if (failedSet.has(relatedAbs)) {
727
- return {
728
- testFile: relatedAbs,
729
- runner: detected.runner,
730
- config: detected.config,
731
- strategy: "failed-first",
732
- };
733
- }
734
- }
735
- if (selfIsTest) {
736
- const selfAbs = path.resolve(sourceFilePath);
737
- if (failedSet.has(selfAbs)) {
738
- return {
739
- testFile: selfAbs,
740
- runner: detected.runner,
741
- config: detected.config,
742
- strategy: "failed-first",
743
- };
744
- }
745
- }
746
- return {
747
- testFile: [...failedSet][0],
993
+ const failedFirst = this.retireMissingFailedTargets({
994
+ cwd,
748
995
  runner: detected.runner,
749
- config: detected.config,
750
- strategy: "failed-first",
751
- };
996
+ failedTargets: failedSet,
997
+ relatedAbs: related ? path.resolve(related.testFile) : undefined,
998
+ selfAbs: selfIsTest ? path.resolve(sourceFilePath) : undefined,
999
+ turnIndex,
1000
+ });
1001
+ if (failedFirst) {
1002
+ return {
1003
+ testFile: failedFirst,
1004
+ runner: detected.runner,
1005
+ config: detected.config,
1006
+ strategy: "failed-first",
1007
+ };
1008
+ }
752
1009
  }
753
1010
  if (selfIsTest) {
754
1011
  return {
@@ -767,22 +1024,32 @@ export class TestRunnerClient {
767
1024
  strategy: "related",
768
1025
  };
769
1026
  }
770
- /**
771
- * Run tests for a specific file without blocking the event loop, so LSP
772
- * messages, other file writes, and all async operations continue while
773
- * tests run.
774
- */
775
- async runTestFileAsync(testFile, cwd, runner, config) {
1027
+ async runTestFileAsync(testFile, cwd, runnerOrRequest, legacyConfig) {
776
1028
  const absoluteTestFile = path.resolve(testFile);
1029
+ let request;
1030
+ if (typeof runnerOrRequest === "string") {
1031
+ if (!legacyConfig) {
1032
+ return this.emptyResult(absoluteTestFile, "", runnerOrRequest, "Runner configuration missing");
1033
+ }
1034
+ request = { runner: runnerOrRequest, config: legacyConfig };
1035
+ }
1036
+ else {
1037
+ request = runnerOrRequest;
1038
+ }
1039
+ const { runner, config, turnIndex, signal } = request;
777
1040
  if (!fs.existsSync(absoluteTestFile)) {
778
1041
  return this.emptyResult(absoluteTestFile, "", runner, "Test file not found");
779
1042
  }
780
1043
  try {
781
- const { command, args } = await this.resolveExec(runner, config, absoluteTestFile, cwd);
1044
+ const { command, args, env } = await this.resolveExec(runner, config, absoluteTestFile, cwd);
782
1045
  this.log(`Running (async): ${command} ${args.join(" ")}`);
783
1046
  const result = await safeSpawnAsync(command, args, {
784
1047
  cwd,
785
1048
  timeout: 60000,
1049
+ env,
1050
+ // #2522 R2 F1. `safeSpawnAsync` resolves `options.signal ?? ambient`,
1051
+ // so an absent batch signal keeps the pre-#2522 ambient behaviour.
1052
+ signal,
786
1053
  });
787
1054
  const stdout = result.stdout || "";
788
1055
  const stderr = result.stderr || "";
@@ -811,7 +1078,13 @@ export class TestRunnerClient {
811
1078
  parsed = this.parseGenericRunnerOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
812
1079
  break;
813
1080
  }
814
- this.recordResult(cwd, runner, absoluteTestFile, parsed);
1081
+ this.recordResult({
1082
+ cwd,
1083
+ runner,
1084
+ testFile: absoluteTestFile,
1085
+ result: parsed,
1086
+ turnIndex,
1087
+ });
815
1088
  return parsed;
816
1089
  }
817
1090
  catch (err) {
@@ -819,6 +1092,172 @@ export class TestRunnerClient {
819
1092
  return this.emptyResult(absoluteTestFile, "", runner, err.message);
820
1093
  }
821
1094
  }
1095
+ getFailedTargets(cwd, runner, create = false) {
1096
+ let roots = this.failedTestsByRunner.get(runner);
1097
+ if (!roots && create) {
1098
+ roots = new PathKeyedMap(canonicalFailedPath);
1099
+ this.failedTestsByRunner.set(runner, roots);
1100
+ }
1101
+ if (!roots)
1102
+ return undefined;
1103
+ const root = canonicalFailedPath(cwd);
1104
+ let targets = roots.get(root);
1105
+ if (!targets && create) {
1106
+ targets = new PathKeyedMap(canonicalFailedPath);
1107
+ roots.set(root, targets);
1108
+ }
1109
+ return targets;
1110
+ }
1111
+ deleteFailedRoot(cwd, runner) {
1112
+ const roots = this.failedTestsByRunner.get(runner);
1113
+ if (!roots)
1114
+ return;
1115
+ roots.delete(canonicalFailedPath(cwd));
1116
+ if (roots.size === 0)
1117
+ this.failedTestsByRunner.delete(runner);
1118
+ }
1119
+ failedTargetCount(runner) {
1120
+ const roots = this.failedTestsByRunner.get(runner);
1121
+ if (!roots)
1122
+ return 0;
1123
+ let count = 0;
1124
+ for (const targets of roots.values())
1125
+ count += targets.size;
1126
+ return count;
1127
+ }
1128
+ evictOldestFailedTarget(runner) {
1129
+ const roots = this.failedTestsByRunner.get(runner);
1130
+ if (!roots)
1131
+ return undefined;
1132
+ let oldest;
1133
+ for (const [root, targets] of roots) {
1134
+ for (const [identity, entry] of targets) {
1135
+ if (!oldest || entry.sequence < oldest.entry.sequence) {
1136
+ oldest = { root, identity, entry };
1137
+ }
1138
+ }
1139
+ }
1140
+ if (!oldest)
1141
+ return undefined;
1142
+ const targets = roots.get(oldest.root);
1143
+ targets?.delete(oldest.identity);
1144
+ if (targets?.size === 0)
1145
+ roots.delete(oldest.root);
1146
+ if (roots.size === 0)
1147
+ this.failedTestsByRunner.delete(runner);
1148
+ return oldest.entry.displayPath;
1149
+ }
1150
+ classifyFailedTarget(candidate) {
1151
+ try {
1152
+ this.statFailedTarget(candidate);
1153
+ return { status: "present" };
1154
+ }
1155
+ catch (error) {
1156
+ const errorCode = filesystemErrorCode(error);
1157
+ if (errorCode === "ENOENT" || errorCode === "ENOTDIR") {
1158
+ return { status: "missing", errorCode };
1159
+ }
1160
+ return { status: "indeterminate", errorCode };
1161
+ }
1162
+ }
1163
+ recordFailedTargetState(record) {
1164
+ const { outcome, runner, candidate, errorCode, turnIndex } = record;
1165
+ const boundedTarget = candidate.length <= LEDGER_FIELD_MAX
1166
+ ? candidate
1167
+ : `…${candidate.slice(1 - LEDGER_FIELD_MAX)}`;
1168
+ const targetIdentity = createHash("sha256")
1169
+ .update(runner)
1170
+ .update("\0")
1171
+ .update(candidate)
1172
+ .digest("hex");
1173
+ const identity = `${outcome}:${targetIdentity}`;
1174
+ const payload = {
1175
+ durationMs: 0,
1176
+ filePath: boundedTarget,
1177
+ metadata: {
1178
+ outcome,
1179
+ runner,
1180
+ errorCode: errorCode ?? "unknown",
1181
+ },
1182
+ };
1183
+ const options = {
1184
+ ledgerKind: "test-runner-failed-target-state",
1185
+ risingEdgePer: "identity",
1186
+ reason: `${outcome}: ${boundedTarget}`,
1187
+ };
1188
+ emitBounded("test_runner_failed_target_state", identity, payload, turnIndex === undefined
1189
+ ? options
1190
+ : {
1191
+ ...options,
1192
+ capPerTurn: {
1193
+ limit: FAILED_TARGET_DETAIL_CAP_PER_TURN,
1194
+ turnIndex,
1195
+ },
1196
+ });
1197
+ }
1198
+ /**
1199
+ * Retire only confirmed-missing failed-first paths, then return one usable
1200
+ * target. A bounded prefix is checked per selection; remaining candidates
1201
+ * carry over to the next selection instead of adding unbounded synchronous
1202
+ * filesystem work to turn_end. Related/self targets keep priority.
1203
+ */
1204
+ retireMissingFailedTargets(selection) {
1205
+ const { cwd, runner, failedTargets, relatedAbs, selfAbs, turnIndex } = selection;
1206
+ let checked = 0;
1207
+ const inspected = new Set();
1208
+ const inspect = (identity, entry) => {
1209
+ if (checked >= MAX_FAILED_TARGET_CHECKS_PER_SELECTION)
1210
+ return undefined;
1211
+ checked += 1;
1212
+ inspected.add(identity);
1213
+ const candidate = entry.displayPath;
1214
+ const verdict = this.classifyFailedTarget(candidate);
1215
+ if (verdict.status === "missing") {
1216
+ failedTargets.delete(identity);
1217
+ this.recordFailedTargetState({
1218
+ outcome: "retired-missing",
1219
+ runner,
1220
+ candidate,
1221
+ errorCode: verdict.errorCode,
1222
+ turnIndex,
1223
+ });
1224
+ return undefined;
1225
+ }
1226
+ if (verdict.status === "indeterminate") {
1227
+ this.recordFailedTargetState({
1228
+ outcome: "retained-indeterminate",
1229
+ runner,
1230
+ candidate,
1231
+ errorCode: verdict.errorCode,
1232
+ turnIndex,
1233
+ });
1234
+ }
1235
+ return candidate;
1236
+ };
1237
+ for (const preferred of [relatedAbs, selfAbs]) {
1238
+ if (!preferred)
1239
+ continue;
1240
+ const identity = canonicalFailedPath(preferred);
1241
+ const entry = failedTargets.get(identity);
1242
+ if (!entry)
1243
+ continue;
1244
+ const selected = inspect(identity, entry);
1245
+ if (selected)
1246
+ return selected;
1247
+ }
1248
+ for (const [identity, entry] of failedTargets) {
1249
+ if (inspected.has(identity))
1250
+ continue;
1251
+ if (checked >= MAX_FAILED_TARGET_CHECKS_PER_SELECTION)
1252
+ break;
1253
+ const selected = inspect(identity, entry);
1254
+ if (selected)
1255
+ return selected;
1256
+ }
1257
+ if (failedTargets.size === 0)
1258
+ this.deleteFailedRoot(cwd, runner);
1259
+ return undefined;
1260
+ }
822
1261
  /**
823
1262
  * Check if a source file has corresponding tests (without running them)
824
1263
  */
@@ -970,31 +1409,9 @@ export class TestRunnerClient {
970
1409
  parsePytestOutput(stdout, stderr, exitCode, testFile, _cwd, runner) {
971
1410
  const failures = [];
972
1411
  const output = `${stdout}\n${stderr}`;
973
- // Parse summary line: "5 passed, 2 failed, 1 skipped in 0.23s"
974
- const summaryMatch = output.match(/(\d+)\s+passed?.*?(\d+)\s+failed.*?in\s+([\d.]+)s/i) ||
975
- output.match(/(\d+)\s+passed.*?in\s+([\d.]+)s/i);
976
- let passed = 0;
977
- let failed = 0;
978
- let skipped = 0;
979
- // #1479: undefined until pytest's own `in N.NNs` is read. `in 0.00s` is
980
- // a real pytest summary, so 0 has to stay available as a measurement.
981
- let duration;
982
- if (summaryMatch) {
983
- // Extract numbers from various patterns
984
- const passedMatch = output.match(/(\d+)\s+passed/);
985
- const failedMatch = output.match(/(\d+)\s+failed/);
986
- const skippedMatch = output.match(/(\d+)\s+skipped/);
987
- const durationMatch = output.match(/in\s+([\d.]+)s/);
988
- passed = passedMatch ? parseInt(passedMatch[1], 10) : 0;
989
- failed = failedMatch ? parseInt(failedMatch[1], 10) : 0;
990
- skipped = skippedMatch ? parseInt(skippedMatch[1], 10) : 0;
991
- // Rounded, like `jsonRunDurationMs` and PHPUnit's legacy path:
992
- // `in 2.01s` is 2009.9999999999998 in binary floating point, and
993
- // the turn-end log prints the number as it stands.
994
- // (Routing this through `toMeasuredDurationMs` is #1484, not this.)
995
- if (durationMatch)
996
- duration = Math.round(parseFloat(durationMatch[1]) * 1000);
997
- }
1412
+ // #1479: `duration` stays undefined unless pytest emits its own `in N.NNs`
1413
+ // summary. A measured `in 0.00s` remains a real zero.
1414
+ const { passed, failed, skipped, duration } = parsePytestSummary(output);
998
1415
  // Parse individual failures: "FAILED tests/test_foo.py::test_something - AssertionError: ..."
999
1416
  const failureRegex = /FAILED\s+(\S+::\S+)\s*-\s*(.+?)(?:\n|$)/g;
1000
1417
  let match;
@@ -1022,7 +1439,11 @@ export class TestRunnerClient {
1022
1439
  skipped,
1023
1440
  failures,
1024
1441
  duration,
1025
- error: exitCode === 2 ? "Pytest configuration error" : undefined,
1442
+ error: exitCode === 4
1443
+ ? "Pytest configuration error"
1444
+ : exitCode === 2
1445
+ ? "Pytest interrupted"
1446
+ : undefined,
1026
1447
  };
1027
1448
  }
1028
1449
  // --- PHPUnit Parser (text-based, default CLI output) ---
@@ -1043,7 +1464,9 @@ export class TestRunnerClient {
1043
1464
  const errorsMatch = output.match(/Errors:\s*(\d+)/i);
1044
1465
  const skippedMatch = output.match(/Skipped:\s*(\d+)/i);
1045
1466
  const total = testsMatch ? Number.parseInt(testsMatch[1], 10) : 0;
1046
- const failures = failuresMatch ? Number.parseInt(failuresMatch[1], 10) : 0;
1467
+ const failures = failuresMatch
1468
+ ? Number.parseInt(failuresMatch[1], 10)
1469
+ : 0;
1047
1470
  const errors = errorsMatch ? Number.parseInt(errorsMatch[1], 10) : 0;
1048
1471
  skipped = skippedMatch ? Number.parseInt(skippedMatch[1], 10) : 0;
1049
1472
  failed = failures + errors;
@@ -1123,7 +1546,9 @@ export class TestRunnerClient {
1123
1546
  if (summaryMatch) {
1124
1547
  const total = Number.parseInt(summaryMatch[1], 10);
1125
1548
  failed = Number.parseInt(summaryMatch[2], 10);
1126
- const excluded = summaryMatch[3] ? Number.parseInt(summaryMatch[3], 10) : 0;
1549
+ const excluded = summaryMatch[3]
1550
+ ? Number.parseInt(summaryMatch[3], 10)
1551
+ : 0;
1127
1552
  const skippedCount = summaryMatch[4]
1128
1553
  ? Number.parseInt(summaryMatch[4], 10)
1129
1554
  : 0;
@@ -1499,9 +1924,7 @@ export class TestRunnerClient {
1499
1924
  // Package-line counts, like the go duration probe above, take the
1500
1925
  // FIRST package summary only — the same known limit already
1501
1926
  // documented on `parseGenericRunnerDuration`.
1502
- const goFailNames = [
1503
- ...output.matchAll(/--- FAIL:[^\S\n]+([^\s(]+)/g),
1504
- ];
1927
+ const goFailNames = [...output.matchAll(/--- FAIL:[^\S\n]+([^\s(]+)/g)];
1505
1928
  // #1524-r4: only COUNT unparented failures. go prints a `--- FAIL:`
1506
1929
  // line for every level of a failing subtest tree — `TestA` AND
1507
1930
  // `TestA/sub` each get their own line for what is really one
@@ -1595,7 +2018,10 @@ export class TestRunnerClient {
1595
2018
  // runner error they actually are). They still label a name onto
1596
2019
  // `failures` above when `matched` or `goFailNames` already settled
1597
2020
  // the question some other way, but they no longer settle it alone.
1598
- const runnerError = exitCode !== 0 && !matched && goFailNames.length === 0 && lower.includes("error")
2021
+ const runnerError = exitCode !== 0 &&
2022
+ !matched &&
2023
+ goFailNames.length === 0 &&
2024
+ lower.includes("error")
1599
2025
  ? `Runner ${runner} exited with ${exitCode}`
1600
2026
  : undefined;
1601
2027
  // #1524-r4 (tidy): a runner-error result reports through
@@ -1653,9 +2079,15 @@ export class TestRunnerClient {
1653
2079
  * Format test result for LLM consumption
1654
2080
  */
1655
2081
  formatResult(result) {
1656
- if (result.error && result.passed === 0 && result.failed === 0) {
1657
- // Runner error, not test failure
1658
- return `[Tests] ⚠ Could not run tests: ${result.error}`;
2082
+ // #2532 review S1: folded onto `isRunnerErrorResult` instead of the old
2083
+ // local `error && passed === 0 && failed === 0` spelling — that missed a
2084
+ // runner error reported alongside partial passes (pytest `Interrupted`
2085
+ // after some tests already ran clean), which fell through to the normal
2086
+ // "N/N passed" branch below and silently dropped the interruption.
2087
+ if (isRunnerErrorResult(result)) {
2088
+ return result.passed > 0
2089
+ ? `[Tests] ⚠ Could not complete tests: ${result.error} (${result.passed} passed before)`
2090
+ : `[Tests] ⚠ Could not run tests: ${result.error}`;
1659
2091
  }
1660
2092
  const total = result.passed + result.failed + result.skipped;
1661
2093
  if (total === 0) {
@@ -1720,9 +2152,7 @@ export class TestRunnerClient {
1720
2152
  const relDir = path.relative(cwd, dir);
1721
2153
  const segments = relDir.split(path.sep).filter(Boolean);
1722
2154
  if (segments.length > 1 && knownSourceRoots.has(segments[0])) {
1723
- return [
1724
- path.join(cwd, testDir, ...segments.slice(1), testFilename),
1725
- ];
2155
+ return [path.join(cwd, testDir, ...segments.slice(1), testFilename)];
1726
2156
  }
1727
2157
  return [];
1728
2158
  }
@@ -1788,6 +2218,19 @@ export class TestRunnerClient {
1788
2218
  * runners like vitest/jest/pytest).
1789
2219
  */
1790
2220
  async resolveExec(runner, config, testFile, cwd) {
2221
+ // Run pytest through the project interpreter itself, not a generic `python`
2222
+ // resolved from the host PATH. The child-only environment also keeps tools
2223
+ // spawned by tests inside the same project environment.
2224
+ if (runner === "pytest") {
2225
+ const pythonEnvironment = await detectPythonEnvironment(cwd);
2226
+ if (pythonEnvironment) {
2227
+ return {
2228
+ command: pythonEnvironment.pythonPath,
2229
+ args: config.args(testFile, cwd),
2230
+ env: augmentPythonEnvironment(process.env, pythonEnvironment),
2231
+ };
2232
+ }
2233
+ }
1791
2234
  // PHPUnit has no npx-style automatic local-binary resolution — Composer's
1792
2235
  // standard local-install location is vendor/bin/phpunit, so check that
1793
2236
  // explicitly before falling back to a global `phpunit` on PATH.
@@ -1806,12 +2249,18 @@ export class TestRunnerClient {
1806
2249
  // itself, so the leading wrapper-name arg(s) that named it (e.g. "vitest",
1807
2250
  // or "-m pytest") are stripped from args() — see stripWrapperArgs.
1808
2251
  if (fs.existsSync(localBin)) {
1809
- return { command: localBin, args: stripWrapperArgs(binName, config.args(testFile, cwd)) };
2252
+ return {
2253
+ command: localBin,
2254
+ args: stripWrapperArgs(binName, config.args(testFile, cwd)),
2255
+ };
1810
2256
  }
1811
2257
  // Any package manager's global bin dir (npm/pnpm/yarn/bun) before npx (#375).
1812
2258
  const globalBin = await findGlobalBinary(binName);
1813
2259
  if (globalBin) {
1814
- return { command: globalBin, args: stripWrapperArgs(binName, config.args(testFile, cwd)) };
2260
+ return {
2261
+ command: globalBin,
2262
+ args: stripWrapperArgs(binName, config.args(testFile, cwd)),
2263
+ };
1815
2264
  }
1816
2265
  return { command: config.command, args: config.args(testFile, cwd) };
1817
2266
  }
@@ -1836,24 +2285,43 @@ export class TestRunnerClient {
1836
2285
  const lines = stack.split("\n").slice(0, 3);
1837
2286
  return lines.join("\n").slice(0, 500);
1838
2287
  }
1839
- failedKey(cwd, runner) {
1840
- return `${path.resolve(cwd)}:${runner}`;
1841
- }
1842
- recordResult(cwd, runner, testFile, result) {
1843
- const key = this.failedKey(cwd, runner);
1844
- const abs = path.resolve(testFile);
1845
- const set = this.failedTestsByRunner.get(key) ?? new Set();
2288
+ recordResult(record) {
2289
+ const { cwd, runner, testFile, result, turnIndex } = record;
2290
+ const targetPath = path.resolve(testFile);
2291
+ const target = canonicalFailedPath(targetPath);
2292
+ let failedTargets = this.getFailedTargets(cwd, runner, result.failed > 0);
2293
+ if (!failedTargets)
2294
+ return;
1846
2295
  if (result.failed > 0) {
1847
- set.add(abs);
1848
- this.failedTestsByRunner.set(key, set);
2296
+ const alreadyRecorded = failedTargets.has(target);
2297
+ if (!alreadyRecorded &&
2298
+ this.failedTargetCount(runner) >= MAX_FAILED_TARGETS_PER_RUNNER) {
2299
+ const evicted = this.evictOldestFailedTarget(runner);
2300
+ if (evicted) {
2301
+ this.recordFailedTargetState({
2302
+ outcome: "capacity-evicted",
2303
+ runner,
2304
+ candidate: evicted,
2305
+ turnIndex,
2306
+ });
2307
+ }
2308
+ // Eviction can remove this root's final prior target. Reacquire the
2309
+ // root map before inserting so the newest failure never lands in a
2310
+ // detached PathKeyedMap.
2311
+ failedTargets = this.getFailedTargets(cwd, runner, true);
2312
+ if (!failedTargets)
2313
+ return;
2314
+ }
2315
+ // Sequence is global across roots, so a refreshed target becomes the
2316
+ // newest failure without relying on one root map's insertion order.
2317
+ failedTargets.set(target, {
2318
+ displayPath: targetPath,
2319
+ sequence: ++this.failedTargetSequence,
2320
+ });
1849
2321
  return;
1850
2322
  }
1851
- if (set.has(abs)) {
1852
- set.delete(abs);
1853
- if (set.size === 0)
1854
- this.failedTestsByRunner.delete(key);
1855
- else
1856
- this.failedTestsByRunner.set(key, set);
2323
+ if (failedTargets.delete(target) && failedTargets.size === 0) {
2324
+ this.deleteFailedRoot(cwd, runner);
1857
2325
  }
1858
2326
  }
1859
2327
  }