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,16 +10,20 @@ import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { logSessionStart } from "../../../sessionstart-logger.js";
13
+ import { incrementDegradationCount } from "../../../degradation-ledger.js";
13
14
  import { getGlobalPiLensDir } from "../../../file-utils.js";
14
15
  import { createGenerationSource, } from "../../../generation-guard.js";
15
16
  import { PathKeyedMap } from "../../../path-keyed-map.js";
16
17
  import { normalizeEphemeralMapKey, normalizeMapKey, } from "../../../path-utils.js";
17
- import { ensureTool, getInstallAttempt, getLastEnsureResolutionSource, getToolInstallStrategy, isSpawnableCommand, resetPathWalkMemo, } from "../../../installer/index.js";
18
+ import { ensureTool, findManagedToolBinary, getInstallAttempt, getLastEnsureResolutionSource, getToolInstallStrategy, isSpawnableCommand, resetPathWalkMemo, } from "../../../installer/index.js";
18
19
  import { getServersForFileWithConfig, isServerDisabled, } from "../../../lsp/config.js";
19
- import { findGlobalBinary } from "../../../package-manager.js";
20
+ import { findGlobalBinary, findLocalBinAt, findLocalBinsUpwards, findLocalBinUpwards, VENDOR_BIN_DIRS, VENV_BIN_DIRS, } from "../../../package-manager.js";
21
+ import { logLatency } from "../../../latency-logger.js";
20
22
  import { safeSpawnAsync } from "../../../safe-spawn.js";
23
+ import { compareOrdinal } from "../../../string-utils.js";
21
24
  import { getToolCommandSpec, shouldAutoInstallTool, } from "../../../tool-policy.js";
22
25
  import { isInSpawnTimeoutCooldown } from "../../../spawn-timeout-cooldown.js";
26
+ import { createAvailabilityProbeFlight } from "../../../availability-probe-flight.js";
23
27
  import { classifyProbeFailure, createAvailabilityLatch, describeInstallAttempt, describeProbeEvidence, isLatchingOutcome, logAvailabilityDecision, startHostStallSampler, transientRetryDelayMs, } from "./availability-policy.js";
24
28
  export { createAvailabilityLatch, classifyProbeFailure, describeProbeEvidence, describeUnavailability, isTransientDecision, logAvailabilityDecision, startHostStallSampler, } from "./availability-policy.js";
25
29
  /**
@@ -42,29 +46,21 @@ export function lspPrimaryCoversFile(ctx, serverId) {
42
46
  const primary = getServersForFileWithConfig(ctx.filePath).find((s) => s.role !== "auxiliary");
43
47
  return primary?.id === serverId;
44
48
  }
49
+ const _realThisDir = (() => {
50
+ if (typeof __dirname !== "undefined")
51
+ return __dirname;
52
+ return path.dirname(fileURLToPath(import.meta.url));
53
+ })();
54
+ let _thisDir = _realThisDir;
45
55
  /**
46
- * Walk up from startDir until we find a directory containing node_modules/.bin.
47
- * Returns all such roots found up to the filesystem root — not just the nearest —
48
- * so callers can search them all for a specific binary.
56
+ * Test-only override for pi-lens's OWN install-tree root (#2544 round 4 F1).
57
+ * `_thisDir` is fixed at module load from the running file's own location, so
58
+ * a test cannot otherwise place it under a synthetic HOME to prove the
59
+ * own-install walk's `ceiling: false` exemption without writing into this
60
+ * process's REAL, shared `node_modules` tree.
49
61
  */
50
- function findNodeBinRoots(startDir) {
51
- const roots = [];
52
- let current = startDir;
53
- const fsRoot = path.parse(current).root;
54
- while (current !== fsRoot) {
55
- if (fs.existsSync(path.join(current, "node_modules", ".bin"))) {
56
- roots.push(current);
57
- }
58
- const parent = path.dirname(current);
59
- if (parent === current)
60
- break;
61
- current = parent;
62
- }
63
- return roots;
64
- }
65
- let _thisDir = path.dirname(fileURLToPath(import.meta.url));
66
- if (typeof __dirname !== "undefined") {
67
- _thisDir = __dirname;
62
+ export function _setThisDirForTests(dir) {
63
+ _thisDir = dir ?? _realThisDir;
68
64
  }
69
65
  // Managed tools directory (~/.pi-lens/tools) — where ensureTool() installs binaries
70
66
  const _managedToolsDir = path.join(getGlobalPiLensDir(), "tools");
@@ -86,9 +82,10 @@ function managedNodeToolCandidates(tool) {
86
82
  * Verification verdicts for managed shims, keyed by path + mtime + size, so a
87
83
  * reinstall re-verifies and a session start re-arms.
88
84
  *
89
- * A plain `Map`, not a `PathKeyedMap`: `managedNodeToolCandidates` is the ONLY
90
- * producer of these paths, so the write and read forms are the same string by
91
- * construction and cannot diverge on case or separator.
85
+ * A plain `Map`, not a `PathKeyedMap`: every path keyed here is produced by
86
+ * `path.join` from one of two producers — `managedNodeToolCandidates` and the
87
+ * installer's `findManagedToolBinary` (#2140) — so the write and read forms are
88
+ * the same string by construction and cannot diverge on case or separator.
92
89
  */
93
90
  const managedBinaryVerdicts = new Map();
94
91
  /**
@@ -118,14 +115,14 @@ const MANAGED_VERIFY_COOLDOWN_MAX_MS = 15 * 60_000;
118
115
  function managedVerifyCooldownMs(attempts) {
119
116
  return Math.min(MANAGED_VERIFY_COOLDOWN_MAX_MS, MANAGED_VERIFY_COOLDOWN_MS * 2 ** (attempts - 1));
120
117
  }
121
- async function runManagedVerification(candidate, stamp, priorAttempts, generation) {
118
+ async function runManagedVerification(candidate, stamp, priorAttempts, generation, verificationArgs = ["--version"]) {
122
119
  let transient = false;
123
120
  let ok;
124
121
  try {
125
122
  const { verifyToolBinary } = await import("../../../installer/index.js");
126
123
  ok = await verifyToolBinary(candidate, undefined, () => {
127
124
  transient = true;
128
- }, MANAGED_VERIFY_TIMEOUT_MS);
125
+ }, MANAGED_VERIFY_TIMEOUT_MS, verificationArgs);
129
126
  }
130
127
  catch {
131
128
  // The verifier itself could not run — installer-isolated unit tests mock
@@ -164,7 +161,7 @@ async function runManagedVerification(candidate, stamp, priorAttempts, generatio
164
161
  }
165
162
  return ok ? "ok" : "broken";
166
163
  }
167
- async function verifyManagedCandidate(candidate) {
164
+ async function verifyManagedCandidate(candidate, verificationArgs = ["--version"]) {
168
165
  let stamp;
169
166
  try {
170
167
  const stat = fs.statSync(candidate);
@@ -186,7 +183,7 @@ async function verifyManagedCandidate(candidate) {
186
183
  if (existing)
187
184
  return existing;
188
185
  const generation = availabilityGeneration.capture();
189
- const probe = runManagedVerification(candidate, stamp, priorAttempts, generation).finally(() => {
186
+ const probe = runManagedVerification(candidate, stamp, priorAttempts, generation, verificationArgs).finally(() => {
190
187
  // A settling old-session probe must not evict the live entry a new
191
188
  // session already started for the same shim (#1674 review F5).
192
189
  generation.guardedWrite(stamp, () => {
@@ -218,14 +215,40 @@ async function verifyManagedCandidate(candidate) {
218
215
  * stalls on a cold cache still gets re-probed later (#1674 review F1).
219
216
  * Concurrent first touches share one probe (#1674 review F2).
220
217
  */
221
- export async function findManagedNodeToolBinary(tool) {
218
+ export async function findManagedNodeToolBinary(tool, verificationArgs = ["--version"]) {
222
219
  for (const candidate of managedNodeToolCandidates(tool)) {
223
- const verdict = await verifyManagedCandidate(candidate);
220
+ const verdict = await verifyManagedCandidate(candidate, verificationArgs);
224
221
  if (verdict === "ok" || verdict === "unverified")
225
222
  return candidate;
226
223
  }
227
224
  return null;
228
225
  }
226
+ /**
227
+ * The release-managed binary for a tool (`~/.pi-lens/bin/<tool>`), or null when
228
+ * the registry does not put this tool there / nothing on disk actually runs.
229
+ *
230
+ * `findManagedToolBinary` is the INSTALLER's own lookup — the one `getToolPath`
231
+ * runs before PATH for every github/maven/archive-strategy tool, and the one
232
+ * `getToolEnvironment` puts at the front of a spawn's PATH. Calling it here is
233
+ * what makes probe and spawn resolve through a single definition instead of two
234
+ * (#2140): before this, the probe fell straight to the bare command name, missed
235
+ * PATH, and latched `missing` — while the install fallback a few hundred ms
236
+ * later handed the caller the very binary sitting in that directory. Every
237
+ * session, for every tool the user already had (7 unavailable/available pairs in
238
+ * one 3h dogfood window).
239
+ *
240
+ * Verification is the same `verifyManagedCandidate` the npm-shim rung above
241
+ * uses, for the same reason (#1657): `findManagedToolBinary` answers from a bare
242
+ * `fs.access`, and an on-disk binary that cannot run must not shadow a working
243
+ * PATH binary — it falls through instead.
244
+ */
245
+ async function findManagedReleaseBinary(tool, verificationArgs = ["--version"]) {
246
+ const candidate = await findManagedToolBinary(tool);
247
+ if (!candidate)
248
+ return null;
249
+ const verdict = await verifyManagedCandidate(candidate, verificationArgs);
250
+ return verdict === "ok" || verdict === "unverified" ? candidate : null;
251
+ }
229
252
  // =============================================================================
230
253
  // VENV-AWARE COMMAND FINDER
231
254
  // =============================================================================
@@ -236,21 +259,30 @@ export async function findManagedNodeToolBinary(tool) {
236
259
  * The resolved path is returned verbatim: every spawn consumer runs with
237
260
  * `shell: false` (safe-spawn, #817), so wrapping it in quotes would make it
238
261
  * a literal filename that ENOENTs on every platform (#1508).
262
+ *
263
+ * The venv directory list is `VENV_BIN_DIRS` (`package-manager.ts`), shared
264
+ * with `formatters.ts`'s `findInVenv` and `dead-code-client.ts`'s vulture
265
+ * candidates — three independent spellings of the same four directories before
266
+ * #2544 review F1.
267
+ *
268
+ * Resolution is a fixed lookup at `cwd` ONLY (`findLocalBinAt`, no ancestor
269
+ * walk). #2544 review round 3 folded this onto the shared ANCESTOR walker,
270
+ * on the reasoning that a monorepo package should find its repo-root venv —
271
+ * but that was an unrequested behaviour change with no #2514 defect behind
272
+ * it (#2514 is about the HOME ceiling, not about climbing at all), and the
273
+ * inversion is real: `~/code/app` with no venv of its own would resolve
274
+ * `~/code/.venv/bin/ruff` — an unrelated SIBLING project's interpreter —
275
+ * ahead of PATH. Reverted in round 4 F2. The HOME ceiling is moot here: a
276
+ * lookup at exactly `cwd` can never climb past it.
239
277
  */
240
- export function createVenvFinder(command, windowsExt = "") {
278
+ export function createVenvFinder(command, windowsExt = "", verificationArgs = ["--version"]) {
241
279
  return async (cwd) => {
242
- const venvPaths = [
243
- `.venv/bin/${command}`,
244
- `venv/bin/${command}`,
245
- `.venv/Scripts/${command}${windowsExt}`,
246
- `venv/Scripts/${command}${windowsExt}`,
247
- ];
248
- for (const venvPath of venvPaths) {
249
- const fullPath = path.join(cwd, venvPath);
250
- if (fs.existsSync(fullPath)) {
251
- return fullPath;
252
- }
253
- }
280
+ const venvBin = findLocalBinAt(command, cwd, {
281
+ windowsExt,
282
+ binDirs: VENV_BIN_DIRS,
283
+ });
284
+ if (venvBin)
285
+ return venvBin;
254
286
  // Managed-dir install (~/.pi-lens/tools/node_modules/.bin/<command>) — the
255
287
  // same shim `ensureTool()` installs npm-strategy tools into. Checked BEFORE
256
288
  // the bare-name PATH fallback (#1638): without this, a tool that only
@@ -261,9 +293,15 @@ export function createVenvFinder(command, windowsExt = "") {
261
293
  // check settles it without a spawn — after one verification per shim per
262
294
  // session, so a shim that cannot run falls through to PATH instead of
263
295
  // shadowing a working binary (#1657).
264
- const managed = await findManagedNodeToolBinary(command);
296
+ const managed = await findManagedNodeToolBinary(command, verificationArgs);
265
297
  if (managed)
266
298
  return managed;
299
+ // Release-managed install (`~/.pi-lens/bin/<command>`) — where `ensureTool`
300
+ // puts every github/maven/archive-strategy tool. Same rung, same reason as
301
+ // the npm shim above, for the other half of the managed families (#2140).
302
+ const release = await findManagedReleaseBinary(command, verificationArgs);
303
+ if (release)
304
+ return release;
267
305
  // Fall back to global
268
306
  return command;
269
307
  };
@@ -366,6 +404,19 @@ const latchedUnavailableByCwd = new PathKeyedMap(normalizeMapKey);
366
404
  // instead of five hand-rolled ones, and a dropped straddling write is visible
367
405
  // in the degradation ledger instead of silent.
368
406
  const availabilityGeneration = createGenerationSource("dispatch-availability");
407
+ export const getDispatchAvailabilityGeneration = () => availabilityGeneration.current();
408
+ const checkerProbeFlights = createAvailabilityProbeFlight({ generation: getDispatchAvailabilityGeneration });
409
+ export function recordAvailabilityProbeOverrun(tool, key, elapsedMs, budgetMs, failure) {
410
+ if (budgetMs === undefined || elapsedMs <= budgetMs)
411
+ return;
412
+ if (failure === "timeout" && elapsedMs <= budgetMs * 2)
413
+ return;
414
+ incrementDegradationCount({
415
+ kind: "availability-probe-overrun",
416
+ subject: `${tool}:${key}`,
417
+ reason: `probe took ${Math.round(elapsedMs)}ms; budget was ${Math.round(budgetMs)}ms`,
418
+ });
419
+ }
369
420
  function installStateFor(cwd, toolId) {
370
421
  let states = installAttemptsByCwd.get(cwd);
371
422
  if (!states) {
@@ -488,6 +539,41 @@ function sourceTagForToolId(toolId) {
488
539
  return undefined;
489
540
  }
490
541
  }
542
+ /**
543
+ * `binary`/`source` for a probe that resolved through one of pi-lens's OWN
544
+ * managed installs rather than through PATH or a project venv (#2140). A reader
545
+ * of latency.log could otherwise not tell the two apart, and the whole point of
546
+ * the fix is that the managed directories now answer where PATH used to miss.
547
+ *
548
+ * BOTH managed rungs are asked, in the order `createVenvFinder` walks them
549
+ * (#2140 review F1). The first version asked only about the release directory,
550
+ * so every npm-shim hit — knip, jscpd, madge, pyright, biome, htmlhint,
551
+ * stylelint — logged an evidence-free row indistinguishable from a PATH hit,
552
+ * while the doc claimed the opposite. Each rung is asked of THE function that
553
+ * produced its own paths (`managedNodeToolCandidates`, `findManagedToolBinary`)
554
+ * and settled by string identity, never by a path-prefix predicate over a
555
+ * directory: a second opinion about which paths are managed is exactly the
556
+ * parallel-list drift `sourceTagForToolId` exists to avoid, and it would answer
557
+ * differently for case or separator variants.
558
+ *
559
+ * `binary` present IS the managed hit; `source` names the family whenever the
560
+ * command is a registry id (`sourceTagForToolId` reads the registry's own
561
+ * `installStrategy`). A managed shim whose COMMAND is not itself a registry id
562
+ * — `markdownlint-cli2`, installed under registry id `markdownlint` — carries
563
+ * `binary` alone rather than a guessed family. `binary` is a BASENAME, never
564
+ * the absolute path — same rule as every other evidence field (#1568 review).
565
+ */
566
+ async function describeManagedResolution(tool, resolved) {
567
+ const managed = managedNodeToolCandidates(tool).includes(resolved) ||
568
+ (await findManagedToolBinary(tool)) === resolved;
569
+ if (!managed)
570
+ return {};
571
+ const source = sourceTagForToolId(tool);
572
+ return {
573
+ binary: path.basename(resolved),
574
+ ...(source !== undefined && { source }),
575
+ };
576
+ }
491
577
  /**
492
578
  * How to tag a not-fresh install resolution in the compensating row's
493
579
  * evidence (#1636 review, carried over from #1615's verify). `getInstallAttempt`
@@ -598,6 +684,9 @@ export function resetDispatchAvailabilityState() {
598
684
  managedVerifyInFlight.clear();
599
685
  resetPathWalkMemo();
600
686
  availabilityGeneration.bump();
687
+ checkerProbeFlights.clear();
688
+ cwdProbeFlights.clear();
689
+ // The generation bump invalidates dispatcherProbeFlights in its owner module.
601
690
  }
602
691
  /**
603
692
  * Create a cached availability checker for a command.
@@ -620,18 +709,21 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
620
709
  const cacheByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
621
710
  const inFlightByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
622
711
  let checkerGeneration = availabilityGeneration.current();
623
- const findCommand = createVenvFinder(command, windowsExt);
712
+ let checkerFlightGeneration = 0;
713
+ const findCommand = createVenvFinder(command, windowsExt, versionArgs);
624
714
  function ensureCurrentGeneration() {
625
715
  if (checkerGeneration === availabilityGeneration.current())
626
716
  return;
627
717
  cacheByCwd.clear();
628
718
  inFlightByCwd.clear();
629
719
  checkerGeneration = availabilityGeneration.current();
720
+ checkerFlightGeneration++;
630
721
  }
631
722
  const reset = () => {
632
723
  cacheByCwd.clear();
633
724
  inFlightByCwd.clear();
634
725
  checkerGeneration = availabilityGeneration.current();
726
+ checkerFlightGeneration++;
635
727
  };
636
728
  function getCache(cwd) {
637
729
  ensureCurrentGeneration();
@@ -692,9 +784,9 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
692
784
  cause: verdict.cause,
693
785
  elapsedMs: verdict.elapsedMs,
694
786
  latched,
695
- ...(verdict.classifiedBy !== undefined && {
696
- classifiedBy: verdict.classifiedBy,
697
- }),
787
+ // Every caller passes this (#2209); a direct key, not a conditional
788
+ // spread, keeps it visible to the call-shaped classifiedBy sweep.
789
+ classifiedBy: verdict.classifiedBy,
698
790
  ...(verdict.evidence !== undefined && { evidence: verdict.evidence }),
699
791
  ...(verdict.hostStallMs !== undefined && {
700
792
  hostStallMs: verdict.hostStallMs,
@@ -777,7 +869,16 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
777
869
  });
778
870
  return false;
779
871
  }
872
+ // Resolution is measured separately from the spawn (#2140 review F2).
873
+ // It is no longer free — the managed rungs stat two directories and,
874
+ // on a first touch, pay their own verification spawn — and it runs
875
+ // BEFORE `startedAt`, so neither `durationMs` nor
876
+ // `recordAvailabilityProbeOverrun` can see it. Folding it into
877
+ // `durationMs` would instead charge the probe budget for work that
878
+ // budget does not govern, so the two spans are reported side by side.
879
+ const resolveStartedAt = Date.now();
780
880
  const cmd = await findCommand(resolvedCwd);
881
+ const resolveMs = Date.now() - resolveStartedAt;
781
882
  // #1995: a command cooling down after a RUNTIME timeout (lint or
782
883
  // autofix lane blew its real budget) must not re-probe on every
783
884
  // edit - the positive verdict is effectively cooled. Consult-only:
@@ -807,17 +908,22 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
807
908
  const startedAt = Date.now();
808
909
  let result;
809
910
  let hostStallMs;
911
+ let probeJoined = false;
810
912
  try {
811
- result = await safeSpawnAsync(cmd, versionArgs, {
913
+ const shared = checkerProbeFlights.run(`checker:${command}|${versionArgs.join("|")}|${key}|${checkerFlightGeneration}|${windowsExt}|${cmd}|${JSON.stringify(Object.entries(env ?? {}).sort(([a], [b]) => compareOrdinal(a, b)))}`, () => safeSpawnAsync(cmd, versionArgs, {
812
914
  timeout: options.probeTimeout ?? 5000,
813
915
  cwd: resolvedCwd,
814
916
  env,
815
- });
917
+ input: "",
918
+ }));
919
+ probeJoined = shared.joined;
920
+ result = await shared.promise;
816
921
  }
817
922
  finally {
818
923
  hostStallMs = stallSampler.stop();
819
924
  }
820
925
  const elapsedMs = Date.now() - startedAt;
926
+ recordAvailabilityProbeOverrun(command, resolvedCwd, elapsedMs, options.probeTimeout ?? 5000, result.failure);
821
927
  if (!result.error && result.status === 0) {
822
928
  cache.command = cmd;
823
929
  noteDecision(cache, resolvedCwd, {
@@ -826,8 +932,12 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
826
932
  cause: "ok",
827
933
  elapsedMs,
828
934
  hostStallMs,
829
- classifiedBy: "probe",
830
- evidence: describeProbeEvidence(result),
935
+ classifiedBy: probeJoined ? "joined" : "probe",
936
+ evidence: {
937
+ ...describeProbeEvidence(result),
938
+ ...(await describeManagedResolution(command, cmd)),
939
+ resolveMs,
940
+ },
831
941
  });
832
942
  return true;
833
943
  }
@@ -846,7 +956,7 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
846
956
  cause,
847
957
  elapsedMs,
848
958
  hostStallMs,
849
- classifiedBy: "probe",
959
+ classifiedBy: probeJoined ? "joined" : "probe",
850
960
  evidence,
851
961
  });
852
962
  return false;
@@ -885,6 +995,9 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
885
995
  }
886
996
  return { isAvailableAsync, getCommand, getOutcome, getVerdict, reset };
887
997
  }
998
+ const cwdProbeFlights = createAvailabilityProbeFlight({
999
+ generation: getDispatchAvailabilityGeneration,
1000
+ });
888
1001
  /**
889
1002
  * Per-cwd cached availability probe for spawn signatures that don't fit
890
1003
  * `createAvailabilityChecker` — multi-arg subcommands like `cargo clippy
@@ -957,7 +1070,7 @@ export function createCwdCachedProbe(probe, options) {
957
1070
  ...(retryAfterMs !== undefined && { retryAfterMs }),
958
1071
  ...(options.budgetMs !== undefined && { budgetMs: options.budgetMs }),
959
1072
  // Every verdict here is derived from the probe this seam just ran.
960
- classifiedBy: "probe",
1073
+ classifiedBy: verdict.classifiedBy ?? "probe",
961
1074
  ...(verdict.evidence !== undefined && { evidence: verdict.evidence }),
962
1075
  }, key);
963
1076
  }
@@ -981,20 +1094,24 @@ export function createCwdCachedProbe(probe, options) {
981
1094
  const startedAt = Date.now();
982
1095
  let result;
983
1096
  let thrown;
1097
+ let probeJoined = false;
984
1098
  try {
985
- result = await probe(key);
1099
+ const shared = cwdProbeFlights.run(`cwd:${options.tool}|${options.flightKeyComponent ?? ""}|${key}`, () => probe(key));
1100
+ probeJoined = shared.joined;
1101
+ result = await shared.promise;
986
1102
  }
987
1103
  catch (error) {
988
1104
  thrown = error;
989
1105
  }
990
1106
  const hostStallMs = stallSampler.stop();
991
1107
  const elapsedMs = Date.now() - startedAt;
992
- // A probe that threw carries its errno in the Error, which is exactly
993
- // what the taxonomy reads — so a thrown EAGAIN stays transient instead
994
- // of collapsing into an untyped `false`.
995
1108
  const shape = result ?? {
996
1109
  error: thrown instanceof Error ? thrown : new Error(String(thrown)),
997
1110
  };
1111
+ recordAvailabilityProbeOverrun(options.tool, key, elapsedMs, options.budgetMs, shape.failure);
1112
+ // A probe that threw carries its errno in the Error, which is exactly
1113
+ // what the taxonomy reads — so a thrown EAGAIN stays transient instead
1114
+ // of collapsing into an untyped `false`.
998
1115
  if (result && !result.error && result.status === 0) {
999
1116
  note(latch, key, {
1000
1117
  available: true,
@@ -1002,6 +1119,7 @@ export function createCwdCachedProbe(probe, options) {
1002
1119
  cause: "ok",
1003
1120
  elapsedMs,
1004
1121
  hostStallMs,
1122
+ classifiedBy: probeJoined ? "joined" : "probe",
1005
1123
  evidence: describeProbeEvidence(result, options.tool),
1006
1124
  });
1007
1125
  return true;
@@ -1017,6 +1135,7 @@ export function createCwdCachedProbe(probe, options) {
1017
1135
  cause,
1018
1136
  elapsedMs,
1019
1137
  hostStallMs,
1138
+ classifiedBy: probeJoined ? "joined" : "probe",
1020
1139
  evidence,
1021
1140
  });
1022
1141
  return false;
@@ -1069,30 +1188,20 @@ export function resolveToolCommand(cwd, toolId) {
1069
1188
  return null;
1070
1189
  return resolveNodeToolCommand(cwd, spec.command, spec.windowsExt ?? ".cmd");
1071
1190
  }
1191
+ /**
1192
+ * Composer's `vendor/bin/<tool>` from the nearest ancestor that has one.
1193
+ *
1194
+ * Delegates to THE shared bin walker (#2544 review F1): this was a fifth
1195
+ * private copy of that climb, unceilinged, so a `vendor/bin` at or above
1196
+ * `$HOME` — which can never be this project's own Composer install — was
1197
+ * resolved as the project's tool (#2514, same class as `findInVendorBin`,
1198
+ * which had already been folded).
1199
+ */
1072
1200
  export function resolveVendorToolCommand(cwd, toolName, windowsExt = ".bat") {
1073
- const isWin = process.platform === "win32";
1074
- const candidates = isWin
1075
- ? [
1076
- path.join("vendor", "bin", `${toolName}${windowsExt}`),
1077
- path.join("vendor", "bin", toolName),
1078
- ]
1079
- : [path.join("vendor", "bin", toolName)];
1080
- let dir = cwd;
1081
- const root = path.parse(dir).root;
1082
- while (true) {
1083
- for (const candidate of candidates) {
1084
- const full = path.join(dir, candidate);
1085
- if (fs.existsSync(full))
1086
- return full;
1087
- }
1088
- if (dir === root)
1089
- break;
1090
- const parent = path.dirname(dir);
1091
- if (parent === dir)
1092
- break;
1093
- dir = parent;
1094
- }
1095
- return null;
1201
+ return (findLocalBinUpwards(toolName, cwd, {
1202
+ windowsExt,
1203
+ binDirs: VENDOR_BIN_DIRS,
1204
+ }) ?? null);
1096
1205
  }
1097
1206
  export async function resolveToolCommandWithInstallFallback(cwd, toolId, timeout = 5000) {
1098
1207
  const spec = getToolCommandSpec(toolId);
@@ -1108,6 +1217,7 @@ async function verifyOrInstallCommand(command, toolId, cwd, versionArgs = ["--ve
1108
1217
  const versionCheck = await safeSpawnAsync(command, versionArgs, {
1109
1218
  timeout,
1110
1219
  cwd,
1220
+ input: "",
1111
1221
  });
1112
1222
  if (!versionCheck.error && versionCheck.status === 0) {
1113
1223
  return command;
@@ -1141,7 +1251,7 @@ async function verifyOrInstallCommand(command, toolId, cwd, versionArgs = ["--ve
1141
1251
  return null;
1142
1252
  }
1143
1253
  export async function resolveCommandArgsWithInstallFallback(command, toolId, cwd, versionArgs = ["--version"], timeout = 5000) {
1144
- const versionCheck = await safeSpawnAsync(command.cmd, [...command.args, ...versionArgs], { timeout, cwd });
1254
+ const versionCheck = await safeSpawnAsync(command.cmd, [...command.args, ...versionArgs], { timeout, cwd, input: "" });
1145
1255
  if (!versionCheck.error && versionCheck.status === 0) {
1146
1256
  return command;
1147
1257
  }
@@ -1318,17 +1428,40 @@ function buildSgLocalBins() {
1318
1428
  : [".cmd", ".exe", ""]
1319
1429
  : [""];
1320
1430
  const binaryCandidates = ["ast-grep", "sg"].flatMap((base) => extensions.map((ext) => `${base}${ext}`));
1321
- const binRoots = [
1322
- ...findNodeBinRoots(_thisDir),
1323
- ...findNodeBinRoots(process.cwd()),
1324
- _managedToolsDir,
1431
+ // THE shared bin walk (#2544 review F1). This used to be a private
1432
+ // `findNodeBinRoots` climb with no HOME ceiling, so on a box with a
1433
+ // home-level `node_modules` (the pi-extensions manifest installs its own
1434
+ // bins, #2514) `$HOME` itself came back as a bin root and an unrelated
1435
+ // ast-grep was probed as if it were this project's. `windowsExt: ""`
1436
+ // because the candidate names already carry their extensions — in a
1437
+ // shell-dependent ORDER (`hasBash` above) that a single `windowsExt`
1438
+ // cannot express.
1439
+ //
1440
+ // The PROJECT walk (from `process.cwd()`) keeps the default HOME ceiling —
1441
+ // a `node_modules/.bin/ast-grep` at or above `$HOME` can never be THIS
1442
+ // project's own dependency. pi-lens's OWN install-tree walk (from
1443
+ // `_thisDir`) is exempt (`ceiling: false`, #2544 round 4 F1): `pi install
1444
+ // npm:pi-lens` installs into the HOME-level manifest
1445
+ // (`~/package.json` → `~/node_modules/pi-lens`), so pi-lens's bundled
1446
+ // `@ast-grep/cli` shim can sit AT `~/node_modules/.bin/ast-grep` — exactly
1447
+ // the directory the PROJECT ceiling exists to stop at. A previous version
1448
+ // of this comment claimed the own install always lives "under"
1449
+ // `~/.pi/extensions/…`, below the ceiling; that is false for the
1450
+ // HOME-level manifest layout, and the round-2 ceiling silently made this
1451
+ // walk return nothing for it (PATH, `findGlobalBinary` and npx do not
1452
+ // cover this location either). `_managedToolsDir` (`~/.pi-lens/tools`) is
1453
+ // a fixed location, not a walk, so it is checked directly as before —
1454
+ // also own-install, since `ensureTool()` puts npm-strategy installs there.
1455
+ const projectWalkOptions = { windowsExt: "" };
1456
+ const ownInstallWalkOptions = { windowsExt: "", ceiling: false };
1457
+ const bins = [
1458
+ ...findLocalBinsUpwards(binaryCandidates, _thisDir, ownInstallWalkOptions).map((binPath) => ({ path: binPath, source: "own-install" })),
1459
+ ...findLocalBinsUpwards(binaryCandidates, process.cwd(), projectWalkOptions).map((binPath) => ({ path: binPath, source: "project" })),
1325
1460
  ];
1326
- const bins = [];
1327
- for (const root of binRoots) {
1328
- for (const candidate of binaryCandidates) {
1329
- const localBin = path.join(root, "node_modules", ".bin", candidate);
1330
- if (fs.existsSync(localBin))
1331
- bins.push(localBin);
1461
+ for (const candidate of binaryCandidates) {
1462
+ const managedBin = path.join(_managedToolsDir, "node_modules", ".bin", candidate);
1463
+ if (fs.existsSync(managedBin)) {
1464
+ bins.push({ path: managedBin, source: "own-install" });
1332
1465
  }
1333
1466
  }
1334
1467
  return bins;
@@ -1353,19 +1486,19 @@ export async function isSgAvailableAsync() {
1353
1486
  return memo;
1354
1487
  if (sgAvailableInFlight)
1355
1488
  return sgAvailableInFlight;
1356
- sgAvailableInFlight = (async () => {
1489
+ const flight = (async () => {
1357
1490
  const startedAt = Date.now();
1358
1491
  sgSweepSawTransient = false;
1359
1492
  sgSweepTransientCause = "probe-timeout";
1360
1493
  sgSweepHostStallMs = 0;
1361
1494
  sgSweepUnreachable = [];
1362
1495
  sgSweepDurablyMissing = [];
1363
- // 1. Local node_modules/.bin
1364
- for (const localBin of buildSgLocalBins()) {
1365
- if (await probeAstGrepCommandAsync(localBin)) {
1366
- sgCmd = localBin;
1496
+ // 1. Local node_modules/.bin (own-install tree, then project tree)
1497
+ for (const candidate of buildSgLocalBins()) {
1498
+ if (await probeAstGrepCommandAsync(candidate.path)) {
1499
+ sgCmd = candidate.path;
1367
1500
  sgCmdArgs = [];
1368
- noteSgAvailable(startedAt);
1501
+ noteSgAvailable(startedAt, { source: candidate.source });
1369
1502
  return true;
1370
1503
  }
1371
1504
  }
@@ -1374,7 +1507,7 @@ export async function isSgAvailableAsync() {
1374
1507
  if (await probeAstGrepCommandAsync(cmd)) {
1375
1508
  sgCmd = cmd;
1376
1509
  sgCmdArgs = [];
1377
- noteSgAvailable(startedAt);
1510
+ noteSgAvailable(startedAt, { source: "global" });
1378
1511
  return true;
1379
1512
  }
1380
1513
  }
@@ -1385,7 +1518,7 @@ export async function isSgAvailableAsync() {
1385
1518
  if (globalBin && (await probeAstGrepCommandAsync(globalBin))) {
1386
1519
  sgCmd = globalBin;
1387
1520
  sgCmdArgs = [];
1388
- noteSgAvailable(startedAt);
1521
+ noteSgAvailable(startedAt, { source: "global" });
1389
1522
  return true;
1390
1523
  }
1391
1524
  }
@@ -1393,7 +1526,7 @@ export async function isSgAvailableAsync() {
1393
1526
  if (await probeAstGrepCommandAsync("npx", ["--no", "--", "ast-grep"])) {
1394
1527
  sgCmd = "npx";
1395
1528
  sgCmdArgs = ["--no", "--", "ast-grep"];
1396
- noteSgAvailable(startedAt);
1529
+ noteSgAvailable(startedAt, { source: "npx" });
1397
1530
  return true;
1398
1531
  }
1399
1532
  // Nothing answered, and nothing answered TRANSIENTLY, while the verdict we
@@ -1415,9 +1548,19 @@ export async function isSgAvailableAsync() {
1415
1548
  noteSgUnavailable(startedAt, sgSweepSawTransient ? "transient" : "missing", sgSweepSawTransient ? sgSweepTransientCause : "not-found");
1416
1549
  return false;
1417
1550
  })().finally(() => {
1418
- sgAvailableInFlight = null;
1551
+ // Identity-guarded release (#1968's pattern): clear the slot only if THIS
1552
+ // flight still owns it. `ensureCurrentSgGeneration()` above nulls the slot
1553
+ // on a generation bump (a session reset arriving mid-flight) so the NEXT
1554
+ // caller starts a fresh flight rather than sharing this one's stale-generation
1555
+ // answer — that fresh flight is a live successor. Without the guard, this
1556
+ // flight's own late settlement would stomp the slot back to null out from
1557
+ // under that successor, and the caller after it would start a redundant
1558
+ // THIRD probe instead of sharing the still-running second one.
1559
+ if (sgAvailableInFlight === flight)
1560
+ sgAvailableInFlight = null;
1419
1561
  });
1420
- return sgAvailableInFlight;
1562
+ sgAvailableInFlight = flight;
1563
+ return flight;
1421
1564
  }
1422
1565
  /**
1423
1566
  * Record a successful shared-ast-grep sweep, with one decision record.
@@ -1432,6 +1575,11 @@ export async function isSgAvailableAsync() {
1432
1575
  * `retained` marks the other provisional case (#1568 review F1): no candidate
1433
1576
  * answered at all, so the winner being reported is the one the previous sweep
1434
1577
  * found, kept rather than discarded on a timeout.
1578
+ *
1579
+ * `source` (omitted for a `retained` win, which resolved nothing new this
1580
+ * sweep) logs one `tool_bin_resolved` phase so F1's own-install-vs-project
1581
+ * ceiling distinction is visible in `latency.log` instead of only inferable
1582
+ * from a bin's absence (#2544 round 4 F4).
1435
1583
  */
1436
1584
  function noteSgAvailable(startedAt, opts = {}) {
1437
1585
  const provisional = sgSweepSawTransient;
@@ -1442,6 +1590,19 @@ function noteSgAvailable(startedAt, opts = {}) {
1442
1590
  else {
1443
1591
  sgLatch.noteAvailable();
1444
1592
  }
1593
+ if (opts.source) {
1594
+ logLatency({
1595
+ type: "phase",
1596
+ phase: "tool_bin_resolved",
1597
+ filePath: "<pi-lens>",
1598
+ durationMs: Math.round(Date.now() - startedAt),
1599
+ metadata: {
1600
+ tool: "ast-grep",
1601
+ source: opts.source,
1602
+ ceilingApplied: opts.source === "project",
1603
+ },
1604
+ });
1605
+ }
1445
1606
  logAvailabilityDecision({
1446
1607
  tool: "ast-grep",
1447
1608
  verdict: "available",
@@ -1449,6 +1610,7 @@ function noteSgAvailable(startedAt, opts = {}) {
1449
1610
  cause: provisional ? sgSweepTransientCause : "ok",
1450
1611
  elapsedMs: Date.now() - startedAt,
1451
1612
  latched: !provisional,
1613
+ classifiedBy: "probe",
1452
1614
  hostStallMs: sgSweepHostStallMs,
1453
1615
  budgetMs: 5000,
1454
1616
  ...(provisional && {
@@ -1472,6 +1634,9 @@ function noteSgUnavailable(startedAt, outcome, cause) {
1472
1634
  hostStallMs: sgSweepHostStallMs,
1473
1635
  ...(retryAfterMs > 0 && { retryAfterMs }),
1474
1636
  budgetMs: 5000,
1637
+ // Sibling of noteSgAvailable's "probe": both report the same
1638
+ // candidate sweep this function ran (#2209).
1639
+ classifiedBy: "probe",
1475
1640
  });
1476
1641
  }
1477
1642
  export function getSgCommand() {