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
@@ -167,18 +167,95 @@ function hasGeneratedArtifactContent(content) {
167
167
  const header = content.slice(0, DEFAULT_HEADER_BYTES);
168
168
  return GENERATED_HEADER_PATTERNS.some((pattern) => pattern.test(header));
169
169
  }
170
- // Memoize the generated-banner verdict per (path, mtimeMs, size, maxBytes).
171
- // The 4 KB header read (openSync + readSync + closeSync) is ~70% of the
172
- // per-file cost in `collectSourceFiles`, and the same files are re-scanned by
173
- // multiple background scanners (todo, project-diagnostics, language-profile)
170
+ /**
171
+ * Content-shape classifier for machine-emitted text (refs #2346).
172
+ *
173
+ * A saved/scraped page, a minified bundle, or an embedded data blob is one
174
+ * giant line or a handful, so mean non-empty line length on already-loaded
175
+ * content is a language-agnostic generated signature. Extensions covered by
176
+ * name patterns can never cover it (`.html` has no `.min.`-style convention),
177
+ * which is the exact gap #2346 hit: a 269 KB scraped page on 38 lines (mean
178
+ * ~7084/line) drew the full auxiliary scanner stack.
179
+ *
180
+ * The threshold is derived from measurement ON THE SHIPPED STATISTIC (the
181
+ * 4096-byte header/prefix mean, not full-content means — the two differ by
182
+ * 3-7x on long-paragraph prose). Measured 2026-08-28 across all 3,331 text
183
+ * files in this repo: the maximum hand-authored prefix mean is 453.2
184
+ * (`docs/analysisall.md`, unwrapped prose paragraphs); the widest
185
+ * hand-authored one-line shape observed anywhere probed is a ~2048-char
186
+ * barrel re-export. The threshold of 2500 sits ~5.5x above the repo's worst
187
+ * prose file and above every observed hand-authored shape, while the #2346
188
+ * evidence file (269 KB / 38 lines) remains far above it. Direction of
189
+ * error is deliberate per #1107: a borderline machine-emitted file that
190
+ * slips through (false KEEP) costs noise once; a hand-authored file
191
+ * silently classified generated (false DROP) vanishes from scans with no
192
+ * signal. Both constants are exported so the tuning claim is checkable and
193
+ * the regression tests pin the margin probes on both sides.
194
+ *
195
+ * Prefix-path quantization: on the 4096-byte header/prefix paths the
196
+ * achievable means are quantized (~4096 for a one-line prefix, ~2048 for
197
+ * two lines), so any threshold in (2048, 4096] behaves as "is the first
198
+ * line longer than 4096 bytes". A machine-emitted file with 2500-4000-char
199
+ * lines is missed on the prefix path even though its full content would
200
+ * classify — the deliberate false-KEEP direction, not a continuous 5.5x
201
+ * margin there. The full-content path (dispatch) has no such quantization.
202
+ *
203
+ * Degenerate cases are guarded: content below the minimum byte length never
204
+ * classifies generated on line shape alone (a 500-byte single-line config
205
+ * file is not machine-emitted evidence), and an empty or whitespace-only
206
+ * file yields a zero mean and stays source.
207
+ */
208
+ export const MACHINE_EMITTED_LINE_SHAPE_MIN_CONTENT = 2048;
209
+ export const MACHINE_EMITTED_LINE_SHAPE_MEAN_THRESHOLD = 2500;
210
+ export function classifyMachineEmittedLineShape(content) {
211
+ const contentLength = content.length;
212
+ if (contentLength < MACHINE_EMITTED_LINE_SHAPE_MIN_CONTENT) {
213
+ return { generated: false, meanLineLength: 0 };
214
+ }
215
+ // Single O(contentLength) pass. Deliberately no `split(/\r?\n/)`: a
216
+ // minified blob's single line can be megabytes, and splitting allocates a
217
+ // copy of the whole content. `\r` contributes nothing to line length so a
218
+ // CRLF line is measured by its display width.
219
+ let nonEmptyLineTotal = 0;
220
+ let nonEmptyLineCount = 0;
221
+ let currentLineLength = 0;
222
+ for (let i = 0; i < contentLength; i++) {
223
+ const code = content.charCodeAt(i);
224
+ if (code === 10 /* \n */) {
225
+ if (currentLineLength > 0) {
226
+ nonEmptyLineTotal += currentLineLength;
227
+ nonEmptyLineCount += 1;
228
+ }
229
+ currentLineLength = 0;
230
+ }
231
+ else if (code !== 13 /* \r */) {
232
+ currentLineLength += 1;
233
+ }
234
+ }
235
+ if (currentLineLength > 0) {
236
+ nonEmptyLineTotal += currentLineLength;
237
+ nonEmptyLineCount += 1;
238
+ }
239
+ if (nonEmptyLineCount === 0)
240
+ return { generated: false, meanLineLength: 0 };
241
+ const meanLineLength = nonEmptyLineTotal / nonEmptyLineCount;
242
+ return {
243
+ generated: meanLineLength > MACHINE_EMITTED_LINE_SHAPE_MEAN_THRESHOLD,
244
+ meanLineLength,
245
+ };
246
+ }
247
+ // Memoize the header analysis (banner + line shape) per (path, mtimeMs, size,
248
+ // maxBytes). The 4 KB header read (openSync + readSync + closeSync) is ~70% of
249
+ // the per-file cost in `collectSourceFiles`, and the same files are re-scanned
250
+ // by multiple background scanners (todo, project-diagnostics, language-profile)
174
251
  // every session/turn. Keying on mtime+size makes the memo self-invalidating:
175
252
  // an edited file misses the cache and is re-read. A single stat replaces the
176
253
  // open/read/close on a hit. Bounded to avoid unbounded growth on giant repos.
177
254
  const HEADER_VERDICT_MEMO_CAP = 50_000;
178
- const headerVerdictMemo = new BoundedLruCache(HEADER_VERDICT_MEMO_CAP);
179
- /** Test-only: drop the header-verdict memo so fixtures don't leak across cases. */
255
+ const headerAnalysisMemo = new BoundedLruCache(HEADER_VERDICT_MEMO_CAP);
256
+ /** Test-only: drop the header-analysis memo so fixtures don't leak across cases. */
180
257
  export function _resetGeneratedArtifactCaches() {
181
- headerVerdictMemo.clear();
258
+ headerAnalysisMemo.clear();
182
259
  }
183
260
  function readFileHeader(filePath, maxBytes = DEFAULT_HEADER_BYTES) {
184
261
  let fd;
@@ -202,27 +279,29 @@ function readFileHeader(filePath, maxBytes = DEFAULT_HEADER_BYTES) {
202
279
  }
203
280
  }
204
281
  }
205
- /**
206
- * Memoized form of "does this file's header contain a generated-code banner?".
207
- * Returns `false` when the file cannot be stat'd/read (same fallback as the
208
- * inline read path). Keyed on path + mtime + size so edits invalidate it.
209
- */
210
- function fileHeaderLooksGenerated(filePath, maxBytes) {
282
+ function analyzeFileHeader(filePath, maxBytes) {
211
283
  let key;
212
284
  try {
213
285
  const st = fs.statSync(filePath);
214
286
  key = `${st.mtimeMs}:${st.size}:${maxBytes}:${filePath}`;
215
287
  }
216
288
  catch {
217
- return false;
289
+ return undefined;
218
290
  }
219
- const cached = headerVerdictMemo.get(key);
291
+ const cached = headerAnalysisMemo.get(key);
220
292
  if (cached !== undefined)
221
293
  return cached;
222
294
  const header = readFileHeader(filePath, maxBytes);
223
- const verdict = header !== undefined && hasGeneratedArtifactContent(header);
224
- headerVerdictMemo.set(key, verdict);
225
- return verdict;
295
+ // A header that cannot be read classifies as clean (no banner, no shape
296
+ // signal) and is memoized as such, mirroring the pre-#2346 fallback.
297
+ const analysis = header === undefined
298
+ ? { banner: false, shape: { generated: false, meanLineLength: 0 } }
299
+ : {
300
+ banner: hasGeneratedArtifactContent(header),
301
+ shape: classifyMachineEmittedLineShape(header),
302
+ };
303
+ headerAnalysisMemo.set(key, analysis);
304
+ return analysis;
226
305
  }
227
306
  /**
228
307
  * The full verdict (+ evidence tier) behind {@link isGeneratedOrArtifact}'s
@@ -272,12 +351,28 @@ export function classifyGeneratedOrArtifactDetailed(filePath, options = {}) {
272
351
  if (hasGeneratedArtifactContent(options.content)) {
273
352
  return { verdict: "generated", evidence: "content" };
274
353
  }
354
+ const shape = classifyMachineEmittedLineShape(options.content);
355
+ if (shape.generated) {
356
+ return {
357
+ verdict: "generated",
358
+ evidence: "line-shape",
359
+ lineShapeMean: shape.meanLineLength,
360
+ };
361
+ }
275
362
  return { verdict: weakNameMatch ? "override" : "clean" };
276
363
  }
277
364
  if (options.readContentHeader) {
278
- if (fileHeaderLooksGenerated(filePath, options.maxHeaderBytes ?? DEFAULT_HEADER_BYTES)) {
365
+ const analysis = analyzeFileHeader(filePath, options.maxHeaderBytes ?? DEFAULT_HEADER_BYTES);
366
+ if (analysis?.banner) {
279
367
  return { verdict: "generated", evidence: "content" };
280
368
  }
369
+ if (analysis?.shape.generated) {
370
+ return {
371
+ verdict: "generated",
372
+ evidence: "line-shape",
373
+ lineShapeMean: analysis.shape.meanLineLength,
374
+ };
375
+ }
281
376
  return { verdict: weakNameMatch ? "override" : "clean" };
282
377
  }
283
378
  // No content/header probe was supplied or enabled: there is no cheap way
@@ -50,6 +50,7 @@
50
50
  * `GenerationMapOptions.normalizeKey` for the correctness reason the slow
51
51
  * path exists at all.
52
52
  */
53
+ import { BoundedFifoMap } from "./bounded-cache.js";
53
54
  import { incrementDegradationCount } from "./degradation-ledger.js";
54
55
  /**
55
56
  * Names of every generation-carrying store created through this module.
@@ -136,7 +137,7 @@ export function createGenerationMap(name, options = {}) {
136
137
  // Insertion-ordered: the oldest key is evicted first. Re-stamping a key
137
138
  // refreshes its position so a hot cwd is not evicted by a burst of
138
139
  // one-shot ones.
139
- const stamps = new Map();
140
+ const stamps = new BoundedFifoMap(maxKeys);
140
141
  // ONE ticket counter for the whole map. See GenerationMap's doc comment:
141
142
  // per-key counters starting at 0 make eviction and forget() fail OPEN.
142
143
  // 0 is reserved for "this key holds no stamp" and is never issued.
@@ -149,14 +150,8 @@ export function createGenerationMap(name, options = {}) {
149
150
  function issue(key) {
150
151
  nextTicket += 1;
151
152
  stamps.delete(key);
152
- stamps.set(key, nextTicket);
153
- while (stamps.size > maxKeys) {
154
- const oldest = stamps.keys().next().value;
155
- if (oldest === undefined)
156
- break;
157
- stamps.delete(oldest);
158
- invalidations += 1;
159
- }
153
+ const evicted = stamps.set(key, nextTicket);
154
+ invalidations += evicted.length;
160
155
  return nextTicket;
161
156
  }
162
157
  return {
@@ -207,7 +207,13 @@ function delimitedSubstitutionBody(command, start, opener, closer) {
207
207
  }
208
208
  return undefined;
209
209
  }
210
- function containsGuardedSubstitution(command, depth) {
210
+ const COMMIT_PUSH_MATCHER = {
211
+ id: "commit-push",
212
+ matchesVerb: (verb) => verb === "commit" || verb === "push",
213
+ indirectAlwaysMatches: true,
214
+ suppressPostVerbHelp: false,
215
+ };
216
+ function containsGuardedSubstitution(command, depth, matcher) {
211
217
  if (depth > 3)
212
218
  return false;
213
219
  let quote;
@@ -249,8 +255,8 @@ function containsGuardedSubstitution(command, depth) {
249
255
  : undefined;
250
256
  if (substitution && substitution.end >= 0) {
251
257
  const nested = canonicalizeGuardCommand(substitution.body);
252
- if (containsGuardedSubstitution(nested, depth + 1) ||
253
- tokenizeShellCommand(nested).some((segment) => containsCommitOrPush(segment.tokens, depth + 1))) {
258
+ if (containsGuardedSubstitution(nested, depth + 1, matcher) ||
259
+ tokenizeShellCommand(nested).some((segment) => containsGuardedGitVerb(segment.tokens, depth + 1, matcher))) {
254
260
  return true;
255
261
  }
256
262
  i = substitution.end;
@@ -258,7 +264,169 @@ function containsGuardedSubstitution(command, depth) {
258
264
  }
259
265
  return false;
260
266
  }
261
- function containsCommitOrPush(tokens, depth) {
267
+ /** Git global options that consume the following token as their value. */
268
+ const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set([
269
+ "-C",
270
+ "-c",
271
+ "--config-env",
272
+ "--git-dir",
273
+ "--work-tree",
274
+ "--exec-path",
275
+ "--namespace",
276
+ ]);
277
+ const GIT_GLOBAL_OPTIONS_WITH_INLINE_VALUE = [
278
+ "--config-env",
279
+ "--git-dir",
280
+ "--work-tree",
281
+ "--exec-path",
282
+ "--namespace",
283
+ ];
284
+ /**
285
+ * Index of git's subcommand token in `gitTokens` (which starts at the `git`
286
+ * executable), after skipping global options and their values. Returns
287
+ * `undefined` when the invocation has no subcommand at all — `git --help`,
288
+ * `git --version`, or a trailing option with no verb behind it.
289
+ *
290
+ * Shared by the direct and indirect paths so both ask the same question about
291
+ * the same position (#2007).
292
+ */
293
+ function gitVerbIndex(gitTokens) {
294
+ let i = 1;
295
+ while (i < gitTokens.length && gitTokens[i].startsWith("-")) {
296
+ const option = gitTokens[i];
297
+ if (["--help", "-h", "--version", "-v", "-V"].includes(option)) {
298
+ return undefined;
299
+ }
300
+ if (option === "--")
301
+ return i + 1 < gitTokens.length ? i + 1 : undefined;
302
+ if (["-C", "-c"].some((prefix) => option.startsWith(prefix) && option.length > prefix.length)) {
303
+ i += 1;
304
+ continue;
305
+ }
306
+ if (GIT_GLOBAL_OPTIONS_WITH_INLINE_VALUE.some((prefix) => option.startsWith(`${prefix}=`))) {
307
+ i += 1;
308
+ continue;
309
+ }
310
+ i += GIT_GLOBAL_OPTIONS_WITH_VALUE.has(option) ? 2 : 1;
311
+ }
312
+ return i < gitTokens.length ? i : undefined;
313
+ }
314
+ /**
315
+ * True when the subcommand in git's command position is one the matcher
316
+ * governs. `gitTokens[0]` is the `git` executable token.
317
+ */
318
+ export function matchGitVerbAtCommandPosition(gitTokens, matcher) {
319
+ const index = gitVerbIndex(gitTokens);
320
+ if (index === undefined)
321
+ return false;
322
+ const argsAfterVerb = gitTokens.slice(index + 1);
323
+ // `git checkout --help` prints documentation and touches nothing, and the
324
+ // global-option walk above cannot see it, because it sits AFTER the verb.
325
+ //
326
+ // Only the FIRST argument counts, and only for matchers that opt in. `-h`
327
+ // deeper in the argv may be an option VALUE rather than a help request —
328
+ // `git commit -m -h` commits with the message `-h`, `git clean -e -h`
329
+ // excludes a pattern named `-h`. Reading any position would need a
330
+ // per-verb table of value-taking options for every governed verb, which is
331
+ // exactly the hand-maintained parallel list this repo forbids, and every
332
+ // gap in it would silently UNDER-block. Leading-position-only needs no
333
+ // table, covers how help is actually invoked, and every other spelling
334
+ // falls through to a match, which is the safe direction for a guard.
335
+ if (matcher.suppressPostVerbHelp &&
336
+ (argsAfterVerb[0] === "--help" || argsAfterVerb[0] === "-h")) {
337
+ return false;
338
+ }
339
+ const verbs = expandGuardVerbToken(gitTokens[index] ?? "");
340
+ return verbs.length === 1 && matcher.matchesVerb(verbs[0], argsAfterVerb);
341
+ }
342
+ /** Strip one layer of shell quoting the lexer preserved on a value token. */
343
+ function unquoteGuardValue(value) {
344
+ const trimmed = value.trim();
345
+ if (trimmed.length >= 2) {
346
+ const first = trimmed[0];
347
+ const last = trimmed[trimmed.length - 1];
348
+ if ((first === '"' || first === "'") && first === last) {
349
+ return trimmed.slice(1, -1);
350
+ }
351
+ }
352
+ return trimmed;
353
+ }
354
+ /**
355
+ * The directory a git invocation actually targets (#2007).
356
+ *
357
+ * `git -C <dir>` and `--work-tree` retarget the command at a DIFFERENT
358
+ * directory, so a guard that evaluates the caller's cwd would inspect the
359
+ * wrong working tree and allow a destructive command against a shared one.
360
+ * `-C` composes cumulatively, exactly as git applies it; `--work-tree` names
361
+ * the working tree directly and therefore wins.
362
+ *
363
+ * Returns the resolved absolute directory, or `cwd` when the invocation
364
+ * carries no retargeting option.
365
+ */
366
+ export function resolveGitTargetDirectory(gitTokens, cwd) {
367
+ let base = cwd;
368
+ let workTree;
369
+ let i = 1;
370
+ while (i < gitTokens.length && gitTokens[i].startsWith("-")) {
371
+ const option = gitTokens[i];
372
+ if (["--help", "-h", "--version", "-v", "-V"].includes(option))
373
+ break;
374
+ if (option === "--")
375
+ break;
376
+ if (option === "-C" && i + 1 < gitTokens.length) {
377
+ base = path.resolve(base, unquoteGuardValue(gitTokens[i + 1]));
378
+ i += 2;
379
+ continue;
380
+ }
381
+ if (option.startsWith("-C") && option.length > 2) {
382
+ base = path.resolve(base, unquoteGuardValue(option.slice(2)));
383
+ i += 1;
384
+ continue;
385
+ }
386
+ if (option === "--work-tree" && i + 1 < gitTokens.length) {
387
+ workTree = unquoteGuardValue(gitTokens[i + 1]);
388
+ i += 2;
389
+ continue;
390
+ }
391
+ if (option.startsWith("--work-tree=")) {
392
+ workTree = unquoteGuardValue(option.slice("--work-tree=".length));
393
+ i += 1;
394
+ continue;
395
+ }
396
+ if (option.startsWith("-c") &&
397
+ option.length > 2 &&
398
+ !option.startsWith("--")) {
399
+ i += 1;
400
+ continue;
401
+ }
402
+ if (GIT_GLOBAL_OPTIONS_WITH_INLINE_VALUE.some((prefix) => option.startsWith(`${prefix}=`))) {
403
+ i += 1;
404
+ continue;
405
+ }
406
+ i += GIT_GLOBAL_OPTIONS_WITH_VALUE.has(option) ? 2 : 1;
407
+ }
408
+ return workTree === undefined ? base : path.resolve(base, workTree);
409
+ }
410
+ /**
411
+ * Every git invocation the command contains, as token arrays starting at the
412
+ * `git` executable. Used by callers that must know WHICH directory a matched
413
+ * command targets, not merely that it matched (#2007).
414
+ */
415
+ export function collectGitInvocations(toolName, input) {
416
+ if (toolName !== "bash")
417
+ return [];
418
+ const command = getShellCommand(input);
419
+ if (!command)
420
+ return [];
421
+ const invocations = [];
422
+ for (const segment of tokenizeShellCommand(canonicalizeGuardCommand(command))) {
423
+ const gitIndex = segment.tokens.findIndex((token) => isGitExecutable(token));
424
+ if (gitIndex >= 0)
425
+ invocations.push(segment.tokens.slice(gitIndex));
426
+ }
427
+ return invocations;
428
+ }
429
+ function containsGuardedGitVerb(tokens, depth, matcher) {
262
430
  if (depth > 3 || tokens.length === 0)
263
431
  return false;
264
432
  let commandTokens = tokens;
@@ -276,7 +444,7 @@ function containsCommitOrPush(tokens, depth) {
276
444
  const runIndex = lower.findIndex((token) => token === "--run");
277
445
  if (runIndex >= 0 && runIndex + 1 < commandTokens.length) {
278
446
  const nestedCommand = commandTokens.slice(runIndex + 2).join(" ");
279
- return tokenizeShellCommand(canonicalizeGuardCommand(nestedCommand)).some((segment) => containsCommitOrPush(segment.tokens, depth + 1));
447
+ return tokenizeShellCommand(canonicalizeGuardCommand(nestedCommand)).some((segment) => containsGuardedGitVerb(segment.tokens, depth + 1, matcher));
280
448
  }
281
449
  }
282
450
  // These commands consume their following words as text/patterns; a bare
@@ -290,43 +458,14 @@ function containsCommitOrPush(tokens, depth) {
290
458
  if (gitIndex >= 0) {
291
459
  // Any non-leading git invocation is indirect. Do not maintain a wrapper
292
460
  // or flag allowlist: unknown launchers are the security boundary here.
293
- if (gitIndex > 0)
461
+ if (gitIndex > 0 && matcher.indirectAlwaysMatches)
294
462
  return true;
295
- const gitTokens = commandTokens.slice(gitIndex);
296
- let i = 1;
297
- const takesValue = new Set([
298
- "-C",
299
- "-c",
300
- "--config-env",
301
- "--git-dir",
302
- "--work-tree",
303
- "--exec-path",
304
- "--namespace",
305
- ]);
306
- while (i < gitTokens.length && gitTokens[i].startsWith("-")) {
307
- const option = gitTokens[i];
308
- if (["--help", "-h", "--version", "-v", "-V"].includes(option))
309
- return false;
310
- if (option === "--")
311
- return gitTokens[i + 1] === "commit" || gitTokens[i + 1] === "push";
312
- if (["-C", "-c"].some((prefix) => option.startsWith(prefix) && option.length > prefix.length)) {
313
- i += 1;
314
- continue;
315
- }
316
- if ([
317
- "--config-env",
318
- "--git-dir",
319
- "--work-tree",
320
- "--exec-path",
321
- "--namespace",
322
- ].some((prefix) => option.startsWith(`${prefix}=`))) {
323
- i += 1;
324
- continue;
325
- }
326
- i += takesValue.has(option) ? 2 : 1;
327
- }
328
- const verbs = expandGuardVerbToken(gitTokens[i] ?? "");
329
- return verbs.length === 1 && (verbs[0] === "commit" || verbs[0] === "push");
463
+ // #2007: a guard that protects the agent from its own accident must not
464
+ // decline `xargs git status`, so the indirect path stays armed only when
465
+ // a governed verb sits in git's real COMMAND POSITION. Scanning every
466
+ // token instead would fire on `find . -name checkout | xargs git add`,
467
+ // where `checkout` is a positional value and not a subcommand at all.
468
+ return matchGitVerbAtCommandPosition(commandTokens.slice(gitIndex), matcher);
330
469
  }
331
470
  const leadingExecutable = commandTokens[0] ?? "";
332
471
  const knownCommandStringWrapper = isShellWrapper(leadingExecutable) ||
@@ -344,7 +483,7 @@ function containsCommitOrPush(tokens, depth) {
344
483
  if (commandIndex >= commandTokens.length)
345
484
  return false;
346
485
  const nestedCommand = commandTokens.slice(commandIndex).join(" ");
347
- return tokenizeShellCommand(canonicalizeGuardCommand(nestedCommand)).some((segment) => containsCommitOrPush(segment.tokens, depth + 1));
486
+ return tokenizeShellCommand(canonicalizeGuardCommand(nestedCommand)).some((segment) => containsGuardedGitVerb(segment.tokens, depth + 1, matcher));
348
487
  }
349
488
  const lower = commandTokens.slice(1).map((token) => token.toLowerCase());
350
489
  const switchIndex = lower.findIndex((token) => token === "-c" ||
@@ -369,19 +508,30 @@ function containsCommitOrPush(tokens, depth) {
369
508
  if (commandTokens[commandIndex] === "--")
370
509
  commandIndex += 1;
371
510
  const nestedCommand = commandTokens.slice(commandIndex).join(" ");
372
- return tokenizeShellCommand(canonicalizeGuardCommand(nestedCommand)).some((segment) => containsCommitOrPush(segment.tokens, depth + 1));
511
+ return tokenizeShellCommand(canonicalizeGuardCommand(nestedCommand)).some((segment) => containsGuardedGitVerb(segment.tokens, depth + 1, matcher));
373
512
  }
374
- /** Analyze actual executable invocations, not substrings in shell text. */
375
- export function isGitCommitOrPushAttempt(toolName, input) {
513
+ /**
514
+ * Analyze actual executable invocations, not substrings in shell text.
515
+ *
516
+ * Shared entry point for every guard that needs "is this bash input really a
517
+ * git invocation of a verb I govern" (#2007). The verb question is the only
518
+ * parameter; wrapper, substitution, and text-consumer analysis are identical
519
+ * for all callers by construction.
520
+ */
521
+ export function detectGuardedGitVerb(toolName, input, matcher) {
376
522
  if (toolName !== "bash")
377
523
  return false;
378
524
  const command = getShellCommand(input);
379
525
  if (!command)
380
526
  return false;
381
527
  const canonical = canonicalizeGuardCommand(command);
382
- if (containsGuardedSubstitution(canonical, 0))
528
+ if (containsGuardedSubstitution(canonical, 0, matcher))
383
529
  return true;
384
- return tokenizeShellCommand(canonical).some((segment) => containsCommitOrPush(segment.tokens, 0));
530
+ return tokenizeShellCommand(canonical).some((segment) => containsGuardedGitVerb(segment.tokens, 0, matcher));
531
+ }
532
+ /** The #1063 commit gate's own question. */
533
+ export function isGitCommitOrPushAttempt(toolName, input) {
534
+ return detectGuardedGitVerb(toolName, input, COMMIT_PUSH_MATCHER);
385
535
  }
386
536
  function isTurnEndFindingsCache(value) {
387
537
  if (!value || typeof value !== "object")
@@ -416,7 +566,7 @@ function markCacheUnknown(runtime, reason) {
416
566
  /** Persist a complete, bounded, content-bound guard record. */
417
567
  export function writeGitGuardRecord(cacheManager, runtime, cwd, record) {
418
568
  const capped = capAffectedFiles(Array.isArray(record.affectedFiles) ? record.affectedFiles : [], cwd);
419
- const fileSeqByPath = { ...(record.fileSeqByPath ?? {}) };
569
+ const fileSeqByPath = { ...record.fileSeqByPath };
420
570
  for (const file of capped.files) {
421
571
  const key = guardPathKey(file, cwd);
422
572
  if (fileSeqByPath[key] === undefined) {
@@ -33,8 +33,25 @@
33
33
  */
34
34
  import * as path from "node:path";
35
35
  import { isExcludedDirName } from "./file-utils.js";
36
+ import { recordDegradationOnce } from "./degradation-ledger.js";
36
37
  import { normalizeEphemeralMapKey, normalizeMapKey } from "./path-utils.js";
37
38
  import { safeSpawnAsync } from "./safe-spawn.js";
39
+ import { truncatedByOutputCap } from "./spawn-output-cap.js";
40
+ function recordTruncatedLsFiles(parseSite) {
41
+ recordDegradationOnce({
42
+ kind: "git-tracked-ignore-truncated",
43
+ subject: `git-ls-files:${parseSite}`,
44
+ reason: "safeSpawnAsync capped git ls-files stdout",
45
+ });
46
+ }
47
+ // The binding listing is the untracked-ignored one: a mid-size project's
48
+ // node_modules alone yields a ~66k-line ignored list (roughly 4-6 MiB), and
49
+ // a monorepo with several dependency trees can multiply that. 16 MiB leaves
50
+ // that headroom while stopping safeSpawnAsync from retaining unbounded
51
+ // stdout (an uncapped probe held 20 MiB in full). The tracked listing is
52
+ // far smaller (~133 KiB in this repository). A capped read fails closed to
53
+ // unavailable with one bounded ledger record per site.
54
+ const MAX_LS_FILES_OUTPUT_BYTES = 16 * 1024 * 1024;
38
55
  /**
39
56
  * Parses `git ls-files --others --ignored --exclude-standard` output
40
57
  * (repo-relative paths, one per line) into normalized map-key file ids.
@@ -62,7 +79,16 @@ export function parseUntrackedIgnoredOutput(stdout, cwd) {
62
79
  }
63
80
  async function fetchUntrackedIgnoredIds(cwd) {
64
81
  try {
65
- const result = await safeSpawnAsync("git", ["ls-files", "--others", "--ignored", "--exclude-standard"], { cwd, timeout: 10_000, resourceLabel: "git-tracked-ignore" });
82
+ const result = await safeSpawnAsync("git", ["ls-files", "--others", "--ignored", "--exclude-standard"], {
83
+ cwd,
84
+ timeout: 10_000,
85
+ maxOutputBytes: MAX_LS_FILES_OUTPUT_BYTES,
86
+ resourceLabel: "git-tracked-ignore",
87
+ });
88
+ if (truncatedByOutputCap(result)) {
89
+ recordTruncatedLsFiles("untracked-ignored");
90
+ return undefined;
91
+ }
66
92
  if (result.error || result.status !== 0)
67
93
  return undefined;
68
94
  return parseUntrackedIgnoredOutput(result.stdout, cwd);
@@ -141,8 +167,13 @@ async function fetchTrackedFiles(cwd) {
141
167
  const result = await safeSpawnAsync("git", ["ls-files"], {
142
168
  cwd,
143
169
  timeout: 10_000,
170
+ maxOutputBytes: MAX_LS_FILES_OUTPUT_BYTES,
144
171
  resourceLabel: "git-tracked-ignore",
145
172
  });
173
+ if (truncatedByOutputCap(result)) {
174
+ recordTruncatedLsFiles("tracked");
175
+ return undefined;
176
+ }
146
177
  if (result.error || result.status !== 0)
147
178
  return undefined;
148
179
  return parseTrackedFilesOutput(result.stdout, cwd);
@@ -62,4 +62,43 @@ export class GoClient {
62
62
  isGoFile(filePath) {
63
63
  return path.extname(filePath).toLowerCase() === ".go";
64
64
  }
65
+ /** Forget the memoized go path and latched availability verdict — #2455. */
66
+ resetAvailability() {
67
+ this.availability.reset();
68
+ }
69
+ }
70
+ /**
71
+ * The one `GoClient` this process owns (#2455 fix round 4, F2).
72
+ *
73
+ * `GoClient` is not stateless: it wraps a `createToolchainAvailability` latch
74
+ * whose probe-class "missing" verdict never expires (`isLatchingOutcome`), and
75
+ * `resetGoAvailability` below re-arms exactly ONE such latch. Round 2 shipped
76
+ * with two instances — `dispatch/runners/go-vet.ts` built one for the runner
77
+ * and `bootstrap.ts` built another for `BootstrapClients.goClient`, which is
78
+ * the object `handleSessionStart` reads for its "Active tools" line. The
79
+ * session reset cleared the runner's copy and left the session-start copy
80
+ * latched, so a go toolchain installed between sessions stayed invisible on
81
+ * the surface a user actually sees. One instance, in the module that owns the
82
+ * class, is what makes the reset total; `tests/clients/toolchain-client-singleton.test.ts`
83
+ * holds the line.
84
+ */
85
+ export const goClient = new GoClient();
86
+ /**
87
+ * Forget `goClient`'s memoized go path and latched availability verdict —
88
+ * #2455. Same #1496/#1535 shape as `resetZizmorTokenAvailability`; wired into
89
+ * `handleSessionStart` beside it (`clients/runtime-session.ts`) so a go
90
+ * toolchain installed mid-process is observed by the next session instead of
91
+ * staying "missing" for the rest of the process's life.
92
+ *
93
+ * This module — not `go-vet.ts` — because the reset must sit beside the single
94
+ * instance it clears (#2455 fix round 4, F2). It was invisible to the
95
+ * session-state sweep before round 2, but not for the reason an earlier draft
96
+ * of this comment gave (#2455 fix round 3, F4): the sweep skips any file
97
+ * exporting no reset at all, so widening the container predicate to "any class
98
+ * declared in `clients/`" could not have surfaced it. Adding this reset is
99
+ * what made the file visible — the fix preceded the detection, and the
100
+ * pair-with-reset blind spot (MISS 3 in `SWEEP_HEURISTIC_LIMITS`) is unchanged.
101
+ */
102
+ export function resetGoAvailability() {
103
+ goClient.resetAvailability();
65
104
  }
@@ -110,6 +110,7 @@ export class GovulncheckClient extends SecurityScanClient {
110
110
  hostStallMs: goHostStallMs,
111
111
  ...(retryAfterMs > 0 && { retryAfterMs }),
112
112
  budgetMs: 5000,
113
+ classifiedBy: "probe",
113
114
  });
114
115
  return false;
115
116
  }
@@ -188,8 +189,10 @@ export class GovulncheckClient extends SecurityScanClient {
188
189
  // row says so and carries the install facts behind it. Every retry
189
190
  // DID run a `go install` that failed, so `install: "failed"` is
190
191
  // earned, and the reason names the ceiling rather than the spawn.
192
+ // Below the ceiling, outcome/cause are fresh off `classifyProbeFailure`
193
+ // above, so that row is still `"probe"` (#2209).
194
+ classifiedBy: exhausted ? "caller" : "probe",
191
195
  ...(exhausted && {
192
- classifiedBy: "caller",
193
196
  evidence: {
194
197
  ...describeProbeEvidence(install, "go install"),
195
198
  install: "failed",
@@ -313,6 +316,7 @@ export class GovulncheckClient extends SecurityScanClient {
313
316
  hostStallMs: reprobeHostStallMs,
314
317
  ...(retryAfterMs > 0 && { retryAfterMs }),
315
318
  budgetMs: 5000,
319
+ classifiedBy: "probe",
316
320
  });
317
321
  return false;
318
322
  }