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
@@ -63,4 +63,35 @@ export class RustClient {
63
63
  isRustFile(filePath) {
64
64
  return path.extname(filePath).toLowerCase() === ".rs";
65
65
  }
66
+ /** Forget the memoized cargo path and latched availability verdict — #2455. */
67
+ resetAvailability() {
68
+ this.availability.reset();
69
+ }
70
+ }
71
+ /**
72
+ * The one `RustClient` this process owns (#2455 fix round 4, F2).
73
+ *
74
+ * Same shape as `go-client.ts`'s `goClient`, and the same round-2 defect: the
75
+ * runner (`dispatch/runners/rust-clippy.ts`) and `bootstrap.ts` each built
76
+ * their own instance, so `resetRustAvailability` re-armed one latch while
77
+ * `handleSessionStart`'s "Active tools" line read the other's never-expiring
78
+ * probe-class "missing" verdict. One instance, in the module that owns the
79
+ * class; `tests/clients/toolchain-client-singleton.test.ts` holds the line.
80
+ */
81
+ export const rustClient = new RustClient();
82
+ /**
83
+ * Forget `rustClient`'s memoized cargo path and latched availability verdict —
84
+ * #2455. Same #1496/#1535 shape as `resetZizmorTokenAvailability`; wired into
85
+ * `handleSessionStart` beside it (`clients/runtime-session.ts`) so a Rust
86
+ * toolchain installed mid-process is observed by the next session instead of
87
+ * staying "missing" for the rest of the process's life.
88
+ *
89
+ * This module — not `rust-clippy.ts` — because the reset must sit beside the
90
+ * single instance it clears (#2455 fix round 4, F2). `rust-clippy.ts`'s
91
+ * `clippyAvailabilityByCargo` is a separate, already-covered latch: it rides
92
+ * `createCwdCachedProbe`'s shared `availabilityGeneration` counter (see
93
+ * `runner-helpers.ts`'s `resetDispatchAvailabilityState`), not this reset.
94
+ */
95
+ export function resetRustAvailability() {
96
+ rustClient.resetAvailability();
66
97
  }
@@ -16,11 +16,13 @@
16
16
  import { spawn, spawnSync, } from "node:child_process";
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
+ import { BoundedFifoMap } from "./bounded-cache.js";
19
20
  import { logLatency } from "./latency-logger.js";
20
21
  import { recordDegradation } from "./degradation-ledger.js";
21
22
  import { logExtension } from "./extension-log.js";
22
23
  import { isFullyQualifiedWin32 } from "./path-utils.js";
23
24
  import { startSpawnUsageSampler } from "./resource-sampler.js";
25
+ import { compareOrdinal } from "./string-utils.js";
24
26
  /** Intent-level spawn failure. `cause` retains the original OS Error/errno. */
25
27
  export class SpawnFailureError extends Error {
26
28
  kind;
@@ -569,23 +571,21 @@ function resolveWindowsPathEntry(entry, effectiveCwd, env) {
569
571
  * cache is also count-bounded with oldest-entry eviction because a long-lived
570
572
  * process can encounter unbounded cwd/environment combinations.
571
573
  */
572
- const WINDOWS_COMMAND_CACHE_MAX_ENTRIES = 256;
574
+ export const WINDOWS_COMMAND_CACHE_MAX_ENTRIES = 256;
573
575
  const WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS = 1000;
574
- const windowsCommandCache = new Map();
576
+ const windowsCommandCache = new BoundedFifoMap(WINDOWS_COMMAND_CACHE_MAX_ENTRIES);
575
577
  /** Reset after session replacement or a successful managed install. */
576
578
  export function resetSafeSpawnWindowsCommandCache() {
577
579
  windowsCommandCache.clear();
578
580
  loggedSpawnClassifications.clear();
579
581
  }
580
582
  function cacheWindowsCommandResult(key, resolved) {
581
- if (windowsCommandCache.size >= WINDOWS_COMMAND_CACHE_MAX_ENTRIES) {
582
- // Resolution entries are session-scoped and cheap to recompute. Evict the
583
- // oldest insertion first so a long-lived process cannot retain every cwd /
584
- // environment it has ever touched.
585
- const oldest = windowsCommandCache.keys().next().value;
586
- if (oldest !== undefined)
587
- windowsCommandCache.delete(oldest);
588
- }
583
+ // Resolution entries are session-scoped and cheap to recompute. The
584
+ // caller always deletes any stale entry for `key` before reaching here
585
+ // (see resolveWindowsCommandForEnvironment), so this can only grow the
586
+ // map; BoundedFifoMap evicts the oldest insertion first once the map
587
+ // exceeds capacity, so a long-lived process cannot retain every cwd /
588
+ // environment it has ever touched.
589
589
  windowsCommandCache.set(key, { resolved, checkedAt: Date.now() });
590
590
  }
591
591
  function getPathExts(env) {
@@ -701,7 +701,7 @@ export function resolveWindowsCommandForEnvironment(command, cwd, env) {
701
701
  .flatMap(([key, value]) => /^=[A-Za-z]:$/.test(key)
702
702
  ? [[key.toLowerCase(), value]]
703
703
  : [])
704
- .sort(([left], [right]) => left.localeCompare(right));
704
+ .sort(([left], [right]) => compareOrdinal(left, right));
705
705
  // Keep presence separate from value: absent PATHEXT means the Windows
706
706
  // default extension list, while PATHEXT="" means no implicit extensions.
707
707
  // The per-drive snapshot is equally important: it is provenance for
@@ -792,6 +792,13 @@ function ensureUtf8ConsoleCodePageOnce() {
792
792
  spawnSync(cmdExe, ["/d", "/s", "/c", "chcp 65001 >nul 2>&1"], {
793
793
  shell: false,
794
794
  windowsHide: true,
795
+ // #1980 population sweep: a synchronous spawn with no timeout parks
796
+ // the event loop for as long as the child takes, and nothing here
797
+ // bounds that. `chcp` is normally instant, but this runs before the
798
+ // FIRST real spawn of the process, which is exactly when a cold or
799
+ // antivirus-throttled cmd.exe is slowest. Matches the 5s bound every
800
+ // other synchronous child-process site in this repo already carries.
801
+ timeout: 5000,
795
802
  });
796
803
  }
797
804
  catch {
@@ -840,6 +847,30 @@ const EXIT_PIPE_IDLE_MAX_WAIT_MS = 2000;
840
847
  * - Kills the process on timeout (preventing zombies)
841
848
  * - Supports cancellation via AbortSignal
842
849
  *
850
+ * ## Why this bound is NOT `bounded()` — the one documented exception (#2523)
851
+ *
852
+ * #2523 slice 2 folded every other "deadline AND abort signal" implementation
853
+ * in the tree onto `clients/deadline-utils.ts#bounded`. This one stays, and it
854
+ * is not a copy of the same idea:
855
+ *
856
+ * `bounded()` ABANDONS a promise — it stops waiting and lets the work run on
857
+ * unobserved, which is safe precisely because everything it wraps is in-process
858
+ * (a dynamic import, an LSP round trip, a `stat`). This function's work is a
859
+ * CHILD PROCESS, and abandoning one leaks it. So its timer does not settle a
860
+ * race at all: `timeoutId` sets `timedOut`, then runs `killTree()` — SIGTERM,
861
+ * an escalation timer, SIGKILL, and on Windows a `taskkill /T` of the whole
862
+ * tree — and the promise settles only when the child's own `close` arrives (or
863
+ * the escalation gives up). The result it produces carries the teardown
864
+ * EVIDENCE (`timeoutTeardown`, #2010): which signal killed it, whether the
865
+ * tree actually died, how long it took. Replacing that with `bounded()` would
866
+ * return `undefined` at the deadline and leave a live `vitest`/`gradle` behind
867
+ * — the orphan class #2504 measured as 59 concurrent spawns starving a turn.
868
+ *
869
+ * The rule the exception preserves: a bound that can CANCEL its work must
870
+ * cancel it. `bounded()` is for work that cannot be cancelled; this is not
871
+ * that. Anything wrapping THIS function still gets `bounded()` around its own
872
+ * await if it needs one.
873
+ *
843
874
  * @example
844
875
  * const result = await safeSpawnAsync("npm", ["test"], { timeout: 30000 });
845
876
  * if (result.error) console.error("Failed:", result.error);
@@ -891,6 +922,8 @@ export async function safeSpawnAsync(command, args, options) {
891
922
  let aborted = false;
892
923
  let killed = false;
893
924
  let outputTruncated = false;
925
+ let killedForOutputCap = false;
926
+ let streamingMatch = false;
894
927
  // #1651 review: a single boolean the close/error handlers both check
895
928
  // AND set, so whichever one decides the outcome first wins outright —
896
929
  // the DECISION itself is shape-based (see the `close` handler below),
@@ -937,21 +970,116 @@ export async function safeSpawnAsync(command, args, options) {
937
970
  options.maxOutputBytes > 0
938
971
  ? Math.floor(options.maxOutputBytes)
939
972
  : undefined;
940
- const appendOutput = (current, chunk) => {
973
+ const outputTruncationMarker = "\n...[output truncated]...\n";
974
+ let retainedHead = [];
975
+ let retainedTail = [];
976
+ let retainedTailBytes = 0;
977
+ let retainedTailLimit = 0;
978
+ let truncationStream = "stderr";
979
+ const streamingMatcher = options?.matchWhileStreaming;
980
+ const streamingCarryBytes = streamingMatcher
981
+ ? Math.max(0, streamingMatcher.source.length - 1)
982
+ : 0;
983
+ const streamingCarry = {
984
+ stdout: "",
985
+ stderr: "",
986
+ };
987
+ const bytePrefix = (text, bytes) => Buffer.from(text).subarray(0, bytes).toString();
988
+ const byteSuffix = (text, bytes) => {
989
+ const buffer = Buffer.from(text);
990
+ return buffer.subarray(Math.max(0, buffer.byteLength - bytes)).toString();
991
+ };
992
+ const appendTail = (part, maxBytes) => {
993
+ if (maxBytes <= 0)
994
+ return;
995
+ retainedTail.push({ stream: part.stream, text: part.text });
996
+ retainedTailBytes += Buffer.byteLength(part.text);
997
+ while (retainedTailBytes > maxBytes && retainedTail.length > 0) {
998
+ const first = retainedTail[0];
999
+ const excess = retainedTailBytes - maxBytes;
1000
+ const firstBytes = Buffer.byteLength(first.text);
1001
+ if (firstBytes <= excess) {
1002
+ retainedTail.shift();
1003
+ retainedTailBytes -= firstBytes;
1004
+ }
1005
+ else {
1006
+ first.text = byteSuffix(first.text, firstBytes - excess);
1007
+ retainedTailBytes -= excess;
1008
+ }
1009
+ }
1010
+ };
1011
+ const renderOutput = (stream) => {
1012
+ const head = retainedHead
1013
+ .filter((part) => part.stream === stream)
1014
+ .map((part) => part.text)
1015
+ .join("");
1016
+ const tail = retainedTail
1017
+ .filter((part) => part.stream === stream)
1018
+ .map((part) => part.text)
1019
+ .join("");
1020
+ const marker = stream === truncationStream ? outputTruncationMarker : "";
1021
+ return head + marker + tail;
1022
+ };
1023
+ const refreshRetainedOutputs = () => {
1024
+ stdout = renderOutput("stdout");
1025
+ stderr = renderOutput("stderr");
1026
+ };
1027
+ const appendOutput = (stream, current, chunk) => {
941
1028
  const text = typeof chunk === "string" ? chunk : chunk.toString();
942
- if (maxOutputBytes === undefined)
943
- return current + text;
944
- const used = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
945
- const remaining = maxOutputBytes - used;
946
- if (remaining <= 0) {
947
- outputTruncated = true;
948
- return current;
1029
+ if (maxOutputBytes === undefined || outputTruncated) {
1030
+ if (maxOutputBytes === undefined)
1031
+ return current + text;
1032
+ appendTail({ stream, text }, retainedTailLimit);
1033
+ truncationStream = stream;
1034
+ return renderOutput(stream);
949
1035
  }
1036
+ const used = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
950
1037
  const bytes = Buffer.byteLength(text);
951
- if (bytes <= remaining)
1038
+ if (used + bytes <= maxOutputBytes)
952
1039
  return current + text;
953
1040
  outputTruncated = true;
954
- return current + Buffer.from(text).subarray(0, remaining).toString();
1041
+ truncationStream = stream;
1042
+ const available = Math.max(0, maxOutputBytes - Buffer.byteLength(outputTruncationMarker));
1043
+ const headBytes = Math.floor(available / 2);
1044
+ const tailBytes = available - headBytes;
1045
+ retainedTailLimit = tailBytes;
1046
+ const parts = [
1047
+ { stream: "stdout", text: stdout },
1048
+ { stream: "stderr", text: stderr },
1049
+ { stream, text },
1050
+ ];
1051
+ let headRemaining = headBytes;
1052
+ for (const part of parts) {
1053
+ if (headRemaining <= 0)
1054
+ break;
1055
+ const kept = bytePrefix(part.text, headRemaining);
1056
+ if (kept)
1057
+ retainedHead.push({ stream: part.stream, text: kept });
1058
+ headRemaining -= Buffer.byteLength(kept);
1059
+ }
1060
+ let tailRemaining = tailBytes;
1061
+ for (let index = parts.length - 1; index >= 0 && tailRemaining > 0; index--) {
1062
+ const part = parts[index];
1063
+ const kept = byteSuffix(part.text, tailRemaining);
1064
+ if (kept) {
1065
+ retainedTail.unshift({ stream: part.stream, text: kept });
1066
+ tailRemaining -= Buffer.byteLength(kept);
1067
+ }
1068
+ }
1069
+ retainedTailBytes = tailBytes - tailRemaining;
1070
+ return renderOutput(stream);
1071
+ };
1072
+ const matchStreamingChunk = (stream, chunk) => {
1073
+ if (streamingMatch || !streamingMatcher)
1074
+ return;
1075
+ const text = typeof chunk === "string" ? chunk : chunk.toString();
1076
+ streamingMatcher.lastIndex = 0;
1077
+ streamingMatch = streamingMatcher.test(streamingCarry[stream] + text);
1078
+ if (streamingCarryBytes > 0) {
1079
+ streamingCarry[stream] = Buffer.from(streamingCarry[stream] + text)
1080
+ .subarray(-streamingCarryBytes)
1081
+ .toString();
1082
+ }
955
1083
  };
956
1084
  // Spawn the process (non-blocking). Keeping Node's `shell` option false
957
1085
  // is important on every path here: shell:true concatenates tainted
@@ -1091,6 +1219,12 @@ export async function safeSpawnAsync(command, args, options) {
1091
1219
  }));
1092
1220
  return;
1093
1221
  }
1222
+ // Keep historical open stdin by default. An opted-in payload is written
1223
+ // completely and followed by EOF; input: "" closes stdin immediately.
1224
+ if (options?.input !== undefined) {
1225
+ child.stdin?.on("error", () => { });
1226
+ child.stdin?.end(options.input);
1227
+ }
1094
1228
  if (child.pid && (posixProcessGroup || options?.lifetimeCoupled)) {
1095
1229
  // #2026: POSIX registers unconditionally - detached children no
1096
1230
  // longer receive terminal signals directly, so the lifetime
@@ -1201,11 +1335,17 @@ export async function safeSpawnAsync(command, args, options) {
1201
1335
  };
1202
1336
  abortSignal?.addEventListener("abort", onAbort, { once: true });
1203
1337
  // Output-cap kills are awaited by finalize() below, just like
1204
- // timeout/abort kills. This keeps a noisy CLI from continuing in the
1205
- // background after its retained output has been bounded.
1338
+ // timeout/abort kills. An armed, unmatched streaming matcher is the one
1339
+ // exception: retention remains bounded, but the caller's timeout is the
1340
+ // bound while the child gets a chance to emit its rescue line.
1206
1341
  let killPromise;
1207
1342
  const stopForOutputLimit = () => {
1208
- if (outputTruncated && !killed && !child.killed) {
1343
+ const waitingForStreamingMatch = streamingMatcher !== undefined && !streamingMatch;
1344
+ if (outputTruncated &&
1345
+ !waitingForStreamingMatch &&
1346
+ !killed &&
1347
+ !child.killed) {
1348
+ killedForOutputCap = true;
1209
1349
  killed = true;
1210
1350
  killPromise = killTree();
1211
1351
  }
@@ -1214,12 +1354,18 @@ export async function safeSpawnAsync(command, args, options) {
1214
1354
  child.stdout?.setEncoding("utf-8");
1215
1355
  child.stderr?.setEncoding("utf-8");
1216
1356
  child.stdout?.on("data", (data) => {
1217
- stdout = appendOutput(stdout, data);
1357
+ matchStreamingChunk("stdout", data);
1358
+ stdout = appendOutput("stdout", stdout, data);
1359
+ if (outputTruncated)
1360
+ refreshRetainedOutputs();
1218
1361
  stopForOutputLimit();
1219
1362
  rearmIdleGrace?.();
1220
1363
  });
1221
1364
  child.stderr?.on("data", (data) => {
1222
- stderr = appendOutput(stderr, data);
1365
+ matchStreamingChunk("stderr", data);
1366
+ stderr = appendOutput("stderr", stderr, data);
1367
+ if (outputTruncated)
1368
+ refreshRetainedOutputs();
1223
1369
  stopForOutputLimit();
1224
1370
  rearmIdleGrace?.();
1225
1371
  });
@@ -1415,7 +1561,11 @@ export async function safeSpawnAsync(command, args, options) {
1415
1561
  resolved = true;
1416
1562
  await waitForPipeIdle();
1417
1563
  const resourceUsage = finishResourceUsage();
1418
- const outputInfo = outputTruncated ? { outputTruncated: true } : {};
1564
+ const outputInfo = {
1565
+ ...(outputTruncated ? { outputTruncated: true } : {}),
1566
+ ...(killedForOutputCap ? { killedForOutputCap: true } : {}),
1567
+ };
1568
+ const streamingMatchInfo = streamingMatch ? { streamingMatch: true } : {};
1419
1569
  // #1816: surface the signal name as a field on every path where one
1420
1570
  // exists, so callers can NAME it instead of scraping `error.message`.
1421
1571
  const signalInfo = signal ? { signal } : {};
@@ -1440,6 +1590,7 @@ export async function safeSpawnAsync(command, args, options) {
1440
1590
  ...(timeoutTeardown && { timeoutTeardown }),
1441
1591
  spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
1442
1592
  ...outputInfo,
1593
+ ...streamingMatchInfo,
1443
1594
  resourceUsage,
1444
1595
  });
1445
1596
  }
@@ -1454,6 +1605,7 @@ export async function safeSpawnAsync(command, args, options) {
1454
1605
  ...signalInfo,
1455
1606
  spawnFailure: new SpawnFailureError("killed", cause.message, cause),
1456
1607
  ...outputInfo,
1608
+ ...streamingMatchInfo,
1457
1609
  resourceUsage,
1458
1610
  });
1459
1611
  }
@@ -1468,6 +1620,7 @@ export async function safeSpawnAsync(command, args, options) {
1468
1620
  ...signalInfo,
1469
1621
  spawnFailure: new SpawnFailureError("killed", cause.message, cause),
1470
1622
  ...outputInfo,
1623
+ ...streamingMatchInfo,
1471
1624
  resourceUsage,
1472
1625
  });
1473
1626
  }
@@ -1497,11 +1650,19 @@ export async function safeSpawnAsync(command, args, options) {
1497
1650
  failure: "spawn",
1498
1651
  spawnFailure,
1499
1652
  ...outputInfo,
1653
+ ...streamingMatchInfo,
1500
1654
  resourceUsage,
1501
1655
  });
1502
1656
  }
1503
1657
  else {
1504
- resolve({ stdout, stderr, status: code, ...outputInfo, resourceUsage });
1658
+ resolve({
1659
+ stdout,
1660
+ stderr,
1661
+ status: code,
1662
+ ...outputInfo,
1663
+ ...streamingMatchInfo,
1664
+ resourceUsage,
1665
+ });
1505
1666
  }
1506
1667
  };
1507
1668
  child.on("exit", onChildSettle);
@@ -1545,6 +1706,8 @@ export async function safeSpawnAsync(command, args, options) {
1545
1706
  failure,
1546
1707
  spawnFailure,
1547
1708
  ...(outputTruncated ? { outputTruncated: true } : {}),
1709
+ ...(killedForOutputCap ? { killedForOutputCap: true } : {}),
1710
+ ...(streamingMatch ? { streamingMatch: true } : {}),
1548
1711
  resourceUsage,
1549
1712
  });
1550
1713
  if (controlFailure)
@@ -6,8 +6,14 @@
6
6
  */
7
7
  // --- Constants ---
8
8
  // ANSI escape codes for colors and formatting
9
+ // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is the literal ANSI escape-sequence lead byte this pattern strips, not accidental input.
9
10
  const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
10
- const ANSI_ESCAPE_EXTENDED = /\x1b\[[0-9;]*[A-Za-z]/g;
11
+ // Full ECMA-48 CSI grammar (parameter bytes 0-9;? , intermediate bytes
12
+ // space-/, final byte @-~) — matches every CSI sequence ANSI_ESCAPE does
13
+ // plus non-SGR ones (cursor moves, private-mode `?` sequences like
14
+ // `\x1b[?25l`, etc.), not just the `m`-terminated color codes.
15
+ // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is the literal ANSI escape-sequence lead byte this pattern strips, not accidental input.
16
+ const ANSI_ESCAPE_EXTENDED = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
11
17
  // Common error patterns from different tools
12
18
  const ERROR_INDICATORS = [
13
19
  /\berror\b/i,
@@ -54,6 +54,7 @@
54
54
  * fully in scope.
55
55
  */
56
56
  import { EXCLUDED_DIRS, getExcludedDirGlobs } from "./file-utils.js";
57
+ import { escapeRegExp } from "./string-utils.js";
57
58
  /** Directory-name entries only — drops glob entries (e.g. `*.dSYM`) that a
58
59
  * bare directory-name/regex exclude can't express. */
59
60
  function literalExcludedDirNames() {
@@ -68,9 +69,6 @@ function literalExcludedDirNames() {
68
69
  export function getScratchTreeGlobPatterns() {
69
70
  return getExcludedDirGlobs();
70
71
  }
71
- function escapeRegExp(literal) {
72
- return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
73
- }
74
72
  /** Bare directory names, for tools (opengrep/semgrep `--exclude`) that treat a
75
73
  * slash-free pattern as "this directory name, anywhere in the tree". */
76
74
  export function getScratchTreeDirNames() {
@@ -16,6 +16,9 @@ import * as path from "node:path";
16
16
  import { createSubsystemLogger } from "./extension-log.js";
17
17
  import { safeSpawnAsync } from "./safe-spawn.js";
18
18
  import { classifyProbeFailure, createAvailabilityLatch, describeInstallAttempt, describeProbeEvidence, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
19
+ import { createAvailabilityProbeFlight } from "./availability-probe-flight.js";
20
+ import { createSingleFlight } from "./single-flight.js";
21
+ const securityProbeFlights = createAvailabilityProbeFlight();
19
22
  export class SecurityScanClient {
20
23
  toolName;
21
24
  /**
@@ -68,6 +71,7 @@ export class SecurityScanClient {
68
71
  logDurableAbsence(evidence, elapsedMs = 0) {
69
72
  logAvailabilityDecision({
70
73
  tool: this.toolName,
74
+ producer: "security-scan",
71
75
  verdict: "unavailable",
72
76
  outcome: "missing",
73
77
  cause: "not-found",
@@ -79,7 +83,8 @@ export class SecurityScanClient {
79
83
  }
80
84
  /** Outcome of the most recent `probeVersion` call. */
81
85
  lastProbeOutcome = null;
82
- ensureInFlight = null;
86
+ /** Availability resolution is instance-owned; only its external probe is shared. */
87
+ ensureFlight = createSingleFlight();
83
88
  inFlight = new Map();
84
89
  binaryPath = null;
85
90
  log;
@@ -99,15 +104,7 @@ export class SecurityScanClient {
99
104
  async ensureAvailable() {
100
105
  if (this.available !== null)
101
106
  return this.available;
102
- if (this.ensureInFlight)
103
- return this.ensureInFlight;
104
- this.ensureInFlight = this.doEnsureAvailable();
105
- try {
106
- return await this.ensureInFlight;
107
- }
108
- finally {
109
- this.ensureInFlight = null;
110
- }
107
+ return this.ensureFlight.run("availability", () => this.doEnsureAvailable());
111
108
  }
112
109
  /**
113
110
  * Spawn `toolName <versionArgs>` and report whether it answered cleanly.
@@ -128,10 +125,26 @@ export class SecurityScanClient {
128
125
  const startedAt = Date.now();
129
126
  let probe;
130
127
  let hostStallMs;
128
+ let resolvedBinaryPath = null;
129
+ let probeJoined = false;
131
130
  try {
132
- probe = await safeSpawnAsync(this.toolName, versionArgs, {
133
- timeout: 5000,
131
+ const shared = securityProbeFlights.run(`security:${this.toolName}|${versionArgs.join("|")}`, async () => {
132
+ const { findManagedToolBinary } = await import("./installer/index.js");
133
+ const managed = await findManagedToolBinary(this.toolName);
134
+ return {
135
+ probe: await safeSpawnAsync(managed ?? this.toolName, versionArgs, {
136
+ timeout: 5000,
137
+ }),
138
+ binaryPath: managed ?? null,
139
+ };
134
140
  });
141
+ probeJoined = shared.joined;
142
+ const flightResult = await shared.promise;
143
+ probe = flightResult.probe;
144
+ resolvedBinaryPath = flightResult.binaryPath;
145
+ if (!probe.error && probe.status === 0 && flightResult.binaryPath) {
146
+ this.binaryPath = flightResult.binaryPath;
147
+ }
135
148
  }
136
149
  finally {
137
150
  hostStallMs = sampler.stop();
@@ -142,6 +155,7 @@ export class SecurityScanClient {
142
155
  this.log(`${this.toolName} found: ${probe.stdout.trim().split("\n")[0]}`);
143
156
  logAvailabilityDecision({
144
157
  tool: this.toolName,
158
+ producer: "security-scan",
145
159
  verdict: "available",
146
160
  outcome: "success",
147
161
  cause: "ok",
@@ -149,8 +163,14 @@ export class SecurityScanClient {
149
163
  latched: true,
150
164
  hostStallMs,
151
165
  budgetMs: 5000,
152
- classifiedBy: "probe",
153
- evidence: describeProbeEvidence(probe),
166
+ classifiedBy: probeJoined ? "joined" : "probe",
167
+ evidence: {
168
+ ...describeProbeEvidence(probe),
169
+ ...(resolvedBinaryPath && {
170
+ binary: path.basename(resolvedBinaryPath),
171
+ source: "managed-dir",
172
+ }),
173
+ },
154
174
  });
155
175
  return true;
156
176
  }
@@ -167,6 +187,7 @@ export class SecurityScanClient {
167
187
  : 0;
168
188
  logAvailabilityDecision({
169
189
  tool: this.toolName,
190
+ producer: "security-scan",
170
191
  verdict: "unavailable",
171
192
  outcome,
172
193
  cause,
@@ -175,8 +196,14 @@ export class SecurityScanClient {
175
196
  hostStallMs,
176
197
  ...(retryAfterMs > 0 && { retryAfterMs }),
177
198
  budgetMs: 5000,
178
- classifiedBy: "probe",
179
- evidence,
199
+ classifiedBy: probeJoined ? "joined" : "probe",
200
+ evidence: {
201
+ ...evidence,
202
+ ...(resolvedBinaryPath && {
203
+ binary: path.basename(resolvedBinaryPath),
204
+ source: "managed-dir",
205
+ }),
206
+ },
180
207
  });
181
208
  return false;
182
209
  }
@@ -311,7 +338,14 @@ export class SecurityScanClient {
311
338
  this.log(`Scan already in flight for ${key}; sharing result`);
312
339
  return existing;
313
340
  }
314
- const promise = run().finally(() => this.inFlight.delete(key));
341
+ // Identity-guarded release (#1968's pattern): delete only if THIS run is
342
+ // still the registered one. A bare delete-by-key lets a late-settling run
343
+ // evict a live successor a second writer registered under the same key
344
+ // mid-flight, after which the next caller starts a duplicate scan.
345
+ const promise = run().finally(() => {
346
+ if (this.inFlight.get(key) === promise)
347
+ this.inFlight.delete(key);
348
+ });
315
349
  this.inFlight.set(key, promise);
316
350
  return promise;
317
351
  }