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
@@ -259,6 +259,8 @@ async function installedRefreshCandidates() {
259
259
  candidates.push({
260
260
  toolId: tool.toolId,
261
261
  strategy: tool.strategy,
262
+ checkArgs: tool.checkArgs,
263
+ verificationTimeoutMs: tool.verificationTimeoutMs ?? 10_000,
262
264
  ...(tool.packageName !== undefined && { packageName: tool.packageName }),
263
265
  ...(tool.binaryName !== undefined && { binaryName: tool.binaryName }),
264
266
  });
@@ -513,7 +515,7 @@ async function performNpmRefresh(candidate, packageName, now) {
513
515
  // drops npm entries that do not.
514
516
  const binaryName = candidate.binaryName;
515
517
  const binPath = resolveManagedNpmBinPath(binaryName);
516
- const verified = await verifyToolBinary(binPath);
518
+ const verified = await verifyToolBinary(binPath, undefined, undefined, candidate.verificationTimeoutMs, candidate.checkArgs);
517
519
  if (!verified) {
518
520
  recordDegradationOnce({
519
521
  kind: "managed-tool-refresh",
@@ -67,11 +67,12 @@ import * as path from "node:path";
67
67
  import { writeFileAtomicAsync } from "./atomic-write.js";
68
68
  import { sweepOwnStagingFiles, } from "./atomic-write-staging.js";
69
69
  import { acquireQuarantinePidFileLock } from "./bounded-pid-file-lock.js";
70
- import { spawnCollectStdoutResult, unrefChildAndPipes, } from "./child-unref.js";
70
+ import { unrefChildAndPipes, } from "./child-unref.js";
71
71
  import { incrementDegradationCount, recordDegradationOnce, } from "./degradation-ledger.js";
72
72
  import { getGlobalPiLensDir } from "./file-utils.js";
73
- import { isInstanceRegistryEnabled, readInstanceRegistry, } from "./instance-registry.js";
73
+ import { isInstanceRegistryEnabled, pruneDeadInstances as pruneDeadRegistryInstances, readInstanceRegistry, } from "./instance-registry.js";
74
74
  import { logLatency } from "./latency-logger.js";
75
+ import { queryProcessTable, windowsExe } from "./process-snapshot.js";
75
76
  const isWindows = process.platform === "win32";
76
77
  /**
77
78
  * KILL eligibility: pid-confirmed-dead ONLY. Heartbeat staleness must never
@@ -249,31 +250,14 @@ export async function sweepAtomicWriteStages(directories, options = {}) {
249
250
  const isPidAlive = options.isPidAlive ?? realIsPidAlive;
250
251
  return sweepOwnStagingFiles(directories, { maxEntries, isPidAlive });
251
252
  }
252
- function windowsExe(name) {
253
- return path.join(process.env.SystemRoot ?? String.raw `C:\Windows`, "System32", name);
254
- }
255
- /** Resolve an absolute path to `ps` (S4036: never spawn via bare PATH lookup).
256
- * Prefers `/bin/ps` (present on virtually every POSIX system), falls back to
257
- * `/usr/bin/ps`, and defaults back to `/bin/ps` if neither probe succeeds
258
- * (spawn will then fail closed rather than silently resolving via PATH). */
259
- function posixPsPath() {
260
- if (fs.existsSync("/bin/ps"))
261
- return "/bin/ps";
262
- if (fs.existsSync("/usr/bin/ps"))
263
- return "/usr/bin/ps";
264
- return "/bin/ps";
265
- }
266
- /** Escape a value for embedding in a WQL LIKE clause: WQL uses `'` as the
267
- * string delimiter (doubled to escape) and `%`/`_` as wildcards — the marker
268
- * is an opaque path string, so escape all three before interpolating. */
269
- function escapeWqlLikeValue(value) {
270
- return value.replaceAll("'", "''").replaceAll(/[%_]/g, (ch) => `[${ch}]`);
271
- }
272
- /** Escape a value for a WQL EQUALITY comparison: only the string delimiter
273
- * needs escaping — `%`/`_` are literal outside `LIKE`. */
274
- function escapeWqlStringValue(value) {
275
- return value.replaceAll("'", "''");
276
- }
253
+ /**
254
+ * Interpreter resolution, WQL escaping and the platform listing itself all
255
+ * live in the ONE process-table seam (#2443): `scripts/lib/process-scan.mjs`,
256
+ * reached from clients/ through `clients/process-snapshot.ts`. This file used
257
+ * to carry its own copy of each, which is how the three queries below drifted
258
+ * apart from the two in `clients/resource-sampler.ts` and the two in
259
+ * `scripts/`. `windowsExe` is re-exported by the seam and imported above.
260
+ */
277
261
  /** Search running processes whose command line contains `marker` (Windows,
278
262
  * via CIM/WQL). Returns matching pids. Best-effort: any failure ⇒ [], but a
279
263
  * failure is RECORDED (#1857 class sweep) — "the search found no orphan" and
@@ -282,14 +266,26 @@ function escapeWqlStringValue(value) {
282
266
  async function findPidsByMarkerWindows(marker) {
283
267
  if (!isWindows || !marker)
284
268
  return [];
285
- const escaped = escapeWqlLikeValue(marker);
286
- // $PID exclusion: the query's own powershell.exe command line embeds the
287
- // marker string, so it would match itself.
288
- const psScript = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${escaped}%'" ` +
289
- `| Where-Object { $_.ProcessId -ne $PID } ` +
290
- `| Select-Object -ExpandProperty ProcessId`;
291
- const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
292
- const result = await spawnCollectStdoutResult(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }, { timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS });
269
+ const result = await queryProcessTable({
270
+ fields: ["pid"],
271
+ filter: { column: "CommandLine", op: "like", values: [marker] },
272
+ // The query's own powershell.exe command line embeds the marker, so
273
+ // without this exclusion the search matches itself.
274
+ excludeSelfPid: true,
275
+ }, {
276
+ timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS,
277
+ // #2527 review F2: omitting `onTimeout` falls through to
278
+ // child-unref.ts's default — one bare, unverified `child.kill()`, no
279
+ // tree kill, no identity-carrying record — inside the orphan backstop
280
+ // path, the exact abandonment `terminateScannerChild`'s own doc
281
+ // comment says it exists to prevent. This query shares the backstop's
282
+ // scanner identity and budget (same producer as
283
+ // `enumerateManagedProcesses`), so it gets the same kind.
284
+ onTimeout: (child) => terminateScannerChild(child, {
285
+ kind: "orphan-backstop-scanner-escalated",
286
+ timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS,
287
+ }),
288
+ });
293
289
  if (result.status !== "ok") {
294
290
  recordDegradationOnce({
295
291
  kind: "orphan-backstop-scan-failed",
@@ -297,10 +293,7 @@ async function findPidsByMarkerWindows(marker) {
297
293
  reason: `marker command-line search ${result.status}`,
298
294
  });
299
295
  }
300
- return result.stdout
301
- .split(/\r?\n/)
302
- .map((line) => Number(line.trim()))
303
- .filter((n) => Number.isFinite(n) && n > 0);
296
+ return result.rows.map((row) => row.pid);
304
297
  }
305
298
  /** Fetch command lines for a set of pids in one query (Windows: CIM; POSIX:
306
299
  * `ps`). Returns a pid → command-line map; pids that can't be resolved are
@@ -317,59 +310,35 @@ async function queryCommandLines(pids) {
317
310
  const map = new Map();
318
311
  if (valid.length === 0)
319
312
  return map;
320
- const noteFailure = (status) => {
321
- if (status === "ok")
322
- return;
313
+ const result = await queryProcessTable({
314
+ fields: ["pid", "command"],
315
+ filter: { column: "ProcessId", op: "eq", values: valid },
316
+ }, {
317
+ timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS,
318
+ // #2527 review F2: same rationale as findPidsByMarkerWindows above —
319
+ // route through the reaper's own tree-kill-and-verify machinery with
320
+ // the backstop's kind rather than falling through to the unverified
321
+ // default kill with no ledger record.
322
+ onTimeout: (child) => terminateScannerChild(child, {
323
+ kind: "orphan-backstop-scanner-escalated",
324
+ timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS,
325
+ }),
326
+ });
327
+ // POSIX `ps -p` exits nonzero when NONE of the requested pids exist, which
328
+ // is a legitimate clean result for this caller, so that one status is not
329
+ // recorded on that platform. The shared collector calls it an exit failure;
330
+ // this caller's command contract makes it the expected empty table. Windows
331
+ // CIM has no such convention, so an exit failure there is a real failure.
332
+ const psReportedNoSuchPid = !isWindows && result.status === "exit-error";
333
+ if (result.status !== "ok" && !psReportedNoSuchPid) {
323
334
  recordDegradationOnce({
324
335
  kind: "orphan-backstop-scan-failed",
325
336
  subject: "identity-query",
326
- reason: `command-line identity query ${status}; reap suppressed this sweep`,
337
+ reason: `command-line identity query ${result.status}; reap suppressed this sweep`,
327
338
  });
328
- };
329
- if (isWindows) {
330
- const filter = valid.map((p) => `ProcessId=${p}`).join(" OR ");
331
- const psScript = `Get-CimInstance Win32_Process -Filter "${filter}" ` +
332
- `| ForEach-Object { "$($_.ProcessId)\t$($_.CommandLine)" }`;
333
- const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
334
- const result = await spawnCollectStdoutResult(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }, { timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS });
335
- noteFailure(result.status);
336
- for (const line of result.stdout.split(/\r?\n/)) {
337
- const tab = line.indexOf("\t");
338
- if (tab <= 0)
339
- continue;
340
- const pid = Number(line.slice(0, tab).trim());
341
- if (Number.isFinite(pid) && pid > 0)
342
- map.set(pid, line.slice(tab + 1));
343
- }
344
- return map;
345
- }
346
- const result = await spawnCollectStdoutResult(posixPsPath(), ["-p", valid.join(","), "-o", "pid=,args="], { shell: false, stdio: ["ignore", "pipe", "ignore"] }, { timeoutMs: BACKSTOP_SCAN_TIMEOUT_MS });
347
- // `ps -p` exits nonzero when NONE of the pids exist, which is a legitimate
348
- // clean result here, so only spawn/timeout failures are recorded. The
349
- // shared collector calls that an exit failure, but this caller's command
350
- // contract makes that status the expected empty-table result.
351
- if (result.status !== "exit-error")
352
- noteFailure(result.status);
353
- for (const line of result.stdout.split(/\r?\n/)) {
354
- // Linear parse (S8786/S6594: avoid regex backtracking on
355
- // attacker-lengthenable ps output) — trim leading whitespace,
356
- // then split on the first whitespace run: " 1234 args here".
357
- const trimmed = line.trimStart();
358
- if (!trimmed)
359
- continue;
360
- let i = 0;
361
- while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
362
- i++;
363
- if (i === 0)
364
- continue;
365
- const pidStr = trimmed.slice(0, i);
366
- let j = i;
367
- while (j < trimmed.length && (trimmed[j] === " " || trimmed[j] === "\t"))
368
- j++;
369
- const pid = Number(pidStr);
370
- if (Number.isFinite(pid) && pid > 0)
371
- map.set(pid, trimmed.slice(j));
372
339
  }
340
+ for (const row of result.rows)
341
+ map.set(row.pid, row.command);
373
342
  return map;
374
343
  }
375
344
  /**
@@ -605,104 +574,42 @@ export function partitionBackstopCandidates(processes, registry, isPidAlive, opt
605
574
  }
606
575
  /** Enumerate live OS processes whose command line contains one of
607
576
  * `MANAGED_BINARY_NAMES` (#658), independent of the instance registry.
608
- * Windows: one batched CIM/WQL query (mirrors `findPidsByMarkerWindows`'s
609
- * query pattern). POSIX: `ps -eo pid=,ppid=,args=`, filtered in JS. Returns
610
- * `{pid, parentPid, command}` rows. Best-effort: any failure ⇒ []. */
577
+ * One projected process-table query through the shared seam, narrowed to
578
+ * managed binaries in JS; returns `{pid, parentPid, command, ageMs}` rows.
579
+ * Best-effort: any failure ⇒ [], with the scan status carried alongside so
580
+ * an empty table is never mistaken for a scan that did not run. */
611
581
  async function enumerateManagedProcesses(options = {}) {
612
- const timeoutMs = options.timeoutMs ?? BACKSTOP_SCAN_TIMEOUT_MS;
613
- const collect = {
614
- timeoutMs,
615
- onTimeout: (child) => terminateScannerChild(child, options),
616
- };
617
- if (isWindows) {
618
- // #1857: `Name = '…'` equality replaces eight leading-wildcard
619
- // `CommandLine LIKE '%…%'` clauses, and the query now also projects
620
- // `CreationDate` so the spawn-grace guard has an age to work with. The
621
- // image-name set is DERIVED from MANAGED_BINARIES (MANAGED_IMAGE_NAMES),
622
- // so adding a managed binary cannot leave a stale parallel list behind.
623
- const clauses = MANAGED_IMAGE_NAMES.map((name) => `Name = '${escapeWqlStringValue(name)}'`).join(" OR ");
624
- // The age column is computed IN PowerShell as a millisecond delta, not
625
- // exported as `CreationDate.Ticks`. `Ticks` is the LOCAL-time
626
- // representation, so subtracting the Unix epoch in JS produced an age
627
- // wrong by the machine's UTC offset — measured on a UTC+3 host as
628
- // -8358s for a process started 40 minutes earlier. A delta computed on
629
- // one side of the boundary has no timezone to get wrong. The explicit
630
- // `[datetime]` test matters: `(Get-Date) - $null` yields a two-thousand
631
- // year TimeSpan, which would read as "ancient" and defeat the grace
632
- // guard exactly where the data is missing.
633
- const psScript = `Get-CimInstance -Query "SELECT ProcessId,ParentProcessId,CreationDate,CommandLine FROM Win32_Process WHERE ${clauses}" ` +
634
- `| ForEach-Object { $age = if ($_.CreationDate -is [datetime]) { [int64]((Get-Date) - $_.CreationDate).TotalMilliseconds } else { '' }; ` +
635
- `"$($_.ProcessId)\t$($_.ParentProcessId)\t$age\t$($_.CommandLine)" }`;
636
- const powershell = windowsExe("WindowsPowerShell\\v1.0\\powershell.exe");
637
- const result = await spawnCollectStdoutResult(powershell, ["-NoProfile", "-NonInteractive", "-Command", psScript], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }, collect);
638
- const processes = [];
639
- for (const line of result.stdout.split(/\r?\n/)) {
640
- const firstTab = line.indexOf("\t");
641
- if (firstTab <= 0)
642
- continue;
643
- const secondTab = line.indexOf("\t", firstTab + 1);
644
- if (secondTab <= 0)
645
- continue;
646
- const thirdTab = line.indexOf("\t", secondTab + 1);
647
- if (thirdTab <= 0)
648
- continue;
649
- const pid = Number(line.slice(0, firstTab).trim());
650
- const parentPid = Number(line.slice(firstTab + 1, secondTab).trim());
651
- const ageMs = parseNonNegativeMs(line.slice(secondTab + 1, thirdTab));
652
- const command = line.slice(thirdTab + 1);
653
- if (!Number.isFinite(pid) || pid <= 0)
654
- continue;
655
- processes.push({ pid, parentPid, command, ageMs });
656
- }
657
- return narrowToManagedBinaries(processes, result);
658
- }
659
- // POSIX: enumerate everything, filter in JS by managed-name substring —
660
- // there is no single-query WQL-style server-side filter available.
661
- // `etime` is the POSIX-standard elapsed-time column (Linux and macOS both
662
- // support it; `etimes` is Linux-only), and supplies the spawn-grace age.
663
- const result = await spawnCollectStdoutResult(posixPsPath(), [...POSIX_PS_ARGS], { shell: false, stdio: ["ignore", "pipe", "ignore"] }, collect);
664
- const processes = [];
665
- for (const line of result.stdout.split(/\r?\n/)) {
666
- // Linear parse (S8786/S6594): " pid ppid etime args here".
667
- const trimmed = line.trimStart();
668
- if (!trimmed)
669
- continue;
670
- let i = 0;
671
- while (i < trimmed.length && trimmed[i] >= "0" && trimmed[i] <= "9")
672
- i++;
673
- if (i === 0)
674
- continue;
675
- const pid = Number(trimmed.slice(0, i));
676
- let rest = trimmed.slice(i);
677
- let k = 0;
678
- while (k < rest.length && (rest[k] === " " || rest[k] === "\t"))
679
- k++;
680
- rest = rest.slice(k);
681
- let j = 0;
682
- while (j < rest.length && rest[j] >= "0" && rest[j] <= "9")
683
- j++;
684
- if (j === 0)
685
- continue;
686
- const parentPid = Number(rest.slice(0, j));
687
- rest = rest.slice(j);
688
- let m = 0;
689
- while (m < rest.length && (rest[m] === " " || rest[m] === "\t"))
690
- m++;
691
- rest = rest.slice(m);
692
- // etime token: a non-whitespace run of digits, ':' and '-'.
693
- let e = 0;
694
- while (e < rest.length && rest[e] !== " " && rest[e] !== "\t")
695
- e++;
696
- const ageMs = ageMsFromPosixEtime(rest.slice(0, e));
697
- let n = e;
698
- while (n < rest.length && (rest[n] === " " || rest[n] === "\t"))
699
- n++;
700
- const args = rest.slice(n);
701
- if (!Number.isFinite(pid) || pid <= 0)
702
- continue;
703
- processes.push({ pid, parentPid, command: args, ageMs });
704
- }
705
- return narrowToManagedBinaries(processes, result);
582
+ // #1857: `Name = '…'` equality replaces eight leading-wildcard
583
+ // `CommandLine LIKE '%…%'` clauses, and the projection carries an age so
584
+ // the spawn-grace guard has something to reason from. The image-name set is
585
+ // DERIVED from MANAGED_BINARIES (MANAGED_IMAGE_NAMES), so adding a managed
586
+ // binary cannot leave a stale parallel list behind.
587
+ //
588
+ // POSIX `ps` cannot express that filter — the seam reports it as
589
+ // `serverSideFiltered: false` and hands back the whole table — which is why
590
+ // `narrowToManagedBinaries` runs on BOTH platforms rather than only on the
591
+ // POSIX branch. Windows over-collects for its own reason anyway
592
+ // (`node.exe` is a superset image name covering every node process on the
593
+ // machine).
594
+ const scanTimeoutMs = options.timeoutMs ?? BACKSTOP_SCAN_TIMEOUT_MS;
595
+ const result = await queryProcessTable({
596
+ fields: ["pid", "ppid", "ageMs", "command"],
597
+ filter: { column: "Name", op: "eq", values: MANAGED_IMAGE_NAMES },
598
+ }, {
599
+ timeoutMs: scanTimeoutMs,
600
+ onTimeout: (child) => terminateScannerChild(child, {
601
+ verifyAttempts: options.verifyAttempts,
602
+ verifyIntervalMs: options.verifyIntervalMs,
603
+ kind: "orphan-backstop-scanner-escalated",
604
+ timeoutMs: scanTimeoutMs,
605
+ }),
606
+ });
607
+ return narrowToManagedBinaries(result.rows.map((row) => ({
608
+ pid: row.pid,
609
+ parentPid: row.ppid,
610
+ command: row.command,
611
+ ageMs: row.ageMs,
612
+ })), result);
706
613
  }
707
614
  /**
708
615
  * The ONE place a raw OS-process snapshot is narrowed to managed binaries.
@@ -730,6 +637,18 @@ function narrowToManagedBinaries(processes, result) {
730
637
  * exists to fix, so the scanner now gets exactly what an orphan gets:
731
638
  * `taskkill /F /T` or a POSIX group kill, then a verified liveness poll. The
732
639
  * escalation is recorded with the scanner's identity, never swallowed.
640
+ *
641
+ * The kill attempt itself is real regardless of what happens next: this
642
+ * handler is invoked the instant the caller's timer elapses, which can race
643
+ * a query child that is genuinely about to close successfully (its own
644
+ * "close" event may still win the overall settle — see
645
+ * `child-unref.ts#spawnCollectStdoutResult`, which resolves on whichever of
646
+ * "close" or the timeout handler's own settle lands first). This function has
647
+ * no way to observe that outcome — it runs strictly BEFORE the caller's
648
+ * promise resolves — so it always records the escalation it actually
649
+ * attempted; callers whose escalation kind can fire on that race classify it
650
+ * as informational rather than a warning (see
651
+ * `degradation-ledger.ts`'s `resource-sampler-scanner-escalated` doc comment).
733
652
  */
734
653
  export async function terminateScannerChild(child, options) {
735
654
  const pid = child.pid;
@@ -740,9 +659,9 @@ export async function terminateScannerChild(child, options) {
740
659
  })
741
660
  : "invalid";
742
661
  incrementDegradationCount({
743
- kind: "orphan-backstop-scanner-escalated",
662
+ kind: options.kind,
744
663
  subject: `${isWindows ? "win32-cim" : "posix-ps"}#${pid ?? "no-pid"}`,
745
- reason: `scan exceeded its timeout; tree kill reported ${outcome}`,
664
+ reason: `scan exceeded its ${options.timeoutMs}ms timeout; tree kill reported ${outcome}`,
746
665
  });
747
666
  return outcome;
748
667
  }
@@ -755,51 +674,6 @@ function matchesManagedBinary(command) {
755
674
  const lower = command.toLowerCase();
756
675
  return MANAGED_BINARY_NAMES.some((name) => lower.includes(name.toLowerCase()));
757
676
  }
758
- /**
759
- * Parse the Windows age column: a non-negative integer millisecond count, or
760
- * anything else. A negative or malformed value is `undefined`, not zero and
761
- * not a clamp — a nonsensical age means the age is UNKNOWN, and the grace
762
- * guard must spare an unknown-age process rather than reason from a number it
763
- * cannot trust. That rule is what surfaced the local-time tick bug during the
764
- * host probe instead of silently sparing everything.
765
- */
766
- function parseNonNegativeMs(raw) {
767
- const token = raw.trim();
768
- if (!/^\d+$/.test(token))
769
- return undefined;
770
- const value = Number(token);
771
- return Number.isFinite(value) ? value : undefined;
772
- }
773
- /**
774
- * POSIX enumeration columns. Exported so a real-`ps` test can assert this
775
- * exact argument vector is accepted by the host's `ps` — a rejected column
776
- * would make the whole backstop silently return zero rows, and this is the
777
- * one part of the fix that cannot be checked from a Windows dev machine.
778
- */
779
- export const POSIX_PS_ARGS = [
780
- "-eo",
781
- "pid=,ppid=,etime=,args=",
782
- ];
783
- /** Parse `ps -o etime` output: `[[dd-]hh:]mm:ss`. Returns undefined for any
784
- * shape the column did not produce (a `ps` without the column emits the
785
- * command line where the token was expected). */
786
- export function ageMsFromPosixEtime(raw) {
787
- const token = raw.trim();
788
- if (!/^(?:\d+-)?(?:\d+:)?\d+:\d+$/.test(token))
789
- return undefined;
790
- let days = 0;
791
- let rest = token;
792
- const dash = rest.indexOf("-");
793
- if (dash >= 0) {
794
- days = Number(rest.slice(0, dash));
795
- rest = rest.slice(dash + 1);
796
- }
797
- const parts = rest.split(":").map(Number);
798
- if (parts.some((part) => !Number.isFinite(part)))
799
- return undefined;
800
- const [hours, minutes, seconds] = parts.length === 3 ? parts : [0, parts[0], parts[1]];
801
- return ((days * 24 + hours) * 60 * 60 + minutes * 60 + seconds) * 1000;
802
- }
803
677
  /**
804
678
  * Fire-and-forget REGISTRY-INDEPENDENT backstop sweep (#658): finds
805
679
  * pi-lens-managed LSP/scanner processes the registry-driven `sweepOrphans`
@@ -1226,8 +1100,8 @@ export async function sweepOrphans() {
1226
1100
  /** Drop dead-parent AND stale-heartbeat (#525, record-cleanup-only)
1227
1101
  * instances from the registry. Re-reads immediately before
1228
1102
  * writing (rather than reusing the earlier `readInstanceRegistry()` snapshot)
1229
- * to narrow — not eliminate — the last-writer-wins race already accepted for
1230
- * slice 1's read-modify-write model.
1103
+ * under the instance-registry lock so the reaper cannot lose a registration
1104
+ * or heartbeat while pruning.
1231
1105
  *
1232
1106
  * Exported for the #1217 concurrency regression test; `sweepOrphans` is the
1233
1107
  * only production caller.
@@ -1244,17 +1118,8 @@ export async function sweepOrphans() {
1244
1118
  * rename, which the hand-rolled copy did not, and puts this writer back on
1245
1119
  * the same scheme as the other writer of this same file. */
1246
1120
  export async function pruneDeadInstances(deadPids) {
1247
- const target = path.join(getGlobalPiLensDir(), "instances.json");
1248
1121
  try {
1249
- const raw = await fs.promises.readFile(target, "utf-8");
1250
- const parsed = JSON.parse(raw);
1251
- if (!parsed || !Array.isArray(parsed.instances))
1252
- return;
1253
- const remaining = parsed.instances.filter((entry) => !deadPids.has(entry.pid));
1254
- if (remaining.length === parsed.instances.length)
1255
- return;
1256
- await fs.promises.mkdir(getGlobalPiLensDir(), { recursive: true });
1257
- await writeFileAtomicAsync(target, JSON.stringify({ instances: remaining }));
1122
+ await pruneDeadRegistryInstances(deadPids);
1258
1123
  }
1259
1124
  catch {
1260
1125
  // best-effort
@@ -0,0 +1,145 @@
1
+ /** Cross-process mutual exclusion for the machine-global instance registry. */
2
+ import * as fs from "node:fs";
3
+ import { randomInt } from "node:crypto";
4
+ import * as path from "node:path";
5
+ import { incrementDegradationCount } from "./degradation-ledger.js";
6
+ const LOCK_STALE_MS = 5_000;
7
+ const LOCK_WAIT_MS = 500;
8
+ const LOCK_MIN_BACKOFF_MS = 5;
9
+ const LOCK_MAX_BACKOFF_MS = 25;
10
+ function lockPath(target) {
11
+ return `${target}.lock`;
12
+ }
13
+ function ownerPid(lock) {
14
+ try {
15
+ const pid = Number(fs.readFileSync(lock, "utf8").trim().split(/\s+/)[0]);
16
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ function isPidAlive(pid) {
23
+ if (pid === undefined)
24
+ return false;
25
+ try {
26
+ process.kill(pid, 0);
27
+ return true;
28
+ }
29
+ catch (error) {
30
+ return error?.code !== "ESRCH";
31
+ }
32
+ }
33
+ function staleLock(lock) {
34
+ try {
35
+ const stat = fs.statSync(lock);
36
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS)
37
+ return true;
38
+ const pid = ownerPid(lock);
39
+ return pid !== undefined && !isPidAlive(pid);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ function recordLockTimeout(target) {
46
+ incrementDegradationCount({
47
+ kind: "instance-registry-lock-timeout",
48
+ subject: path.resolve(target),
49
+ reason: `lock acquisition exhausted for ${path.basename(target)}`,
50
+ });
51
+ }
52
+ function backoffMs() {
53
+ return randomInt(LOCK_MIN_BACKOFF_MS, LOCK_MAX_BACKOFF_MS + 1);
54
+ }
55
+ function backoff() {
56
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, backoffMs());
57
+ }
58
+ function ownsLock(lock) {
59
+ return ownerPid(lock) === process.pid;
60
+ }
61
+ async function releaseLock(lock) {
62
+ // Residual TOCTOU: ownership and unlink are separate syscalls, so a stale
63
+ // takeover can still replace the lock between them.
64
+ if (ownsLock(lock))
65
+ await fs.promises.unlink(lock).catch(() => { });
66
+ }
67
+ function releaseLockSync(lock) {
68
+ if (!ownsLock(lock))
69
+ return;
70
+ try {
71
+ fs.unlinkSync(lock);
72
+ }
73
+ catch {
74
+ // A stale takeover may have displaced the lock after ownership was read.
75
+ }
76
+ }
77
+ function takeOverStale(lock) {
78
+ if (!staleLock(lock))
79
+ return;
80
+ const displaced = `${lock}.stale-${process.pid}-${Date.now()}`;
81
+ try {
82
+ fs.renameSync(lock, displaced);
83
+ fs.unlinkSync(displaced);
84
+ }
85
+ catch {
86
+ // Another owner may have released or taken over it first.
87
+ }
88
+ }
89
+ function isLockContention(error) {
90
+ const code = error?.code;
91
+ return code === "EEXIST" || code === "EPERM" || code === "EBUSY";
92
+ }
93
+ export async function withInstanceRegistryLock(target, op) {
94
+ const lock = lockPath(target);
95
+ const deadline = Date.now() + LOCK_WAIT_MS;
96
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
97
+ while (Date.now() <= deadline) {
98
+ try {
99
+ await fs.promises.writeFile(lock, `${process.pid} ${Date.now()}\n`, {
100
+ flag: "wx",
101
+ });
102
+ }
103
+ catch (error) {
104
+ if (!isLockContention(error))
105
+ throw error;
106
+ takeOverStale(lock);
107
+ if (Date.now() <= deadline)
108
+ await new Promise((resolve) => setTimeout(resolve, backoffMs()));
109
+ continue;
110
+ }
111
+ try {
112
+ return await op();
113
+ }
114
+ finally {
115
+ await releaseLock(lock);
116
+ }
117
+ }
118
+ recordLockTimeout(target);
119
+ return undefined;
120
+ }
121
+ export function withInstanceRegistryLockSync(target, op) {
122
+ const lock = lockPath(target);
123
+ const deadline = Date.now() + LOCK_WAIT_MS;
124
+ fs.mkdirSync(path.dirname(target), { recursive: true });
125
+ while (Date.now() <= deadline) {
126
+ try {
127
+ fs.writeFileSync(lock, `${process.pid} ${Date.now()}\n`, { flag: "wx" });
128
+ try {
129
+ return op();
130
+ }
131
+ finally {
132
+ releaseLockSync(lock);
133
+ }
134
+ }
135
+ catch (error) {
136
+ if (!isLockContention(error))
137
+ throw error;
138
+ takeOverStale(lock);
139
+ if (Date.now() <= deadline)
140
+ backoff();
141
+ }
142
+ }
143
+ recordLockTimeout(target);
144
+ return undefined;
145
+ }