@herbertgao/pi-extensions 2026.9.15 → 2026.9.17

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 (197) hide show
  1. package/README.md +5 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/package.json +1 -1
  4. package/node_modules/@herbertgao/sol-pi/package.json +1 -1
  5. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +11 -53
  6. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -6
  7. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  8. package/node_modules/@narumitw/pi-btw/package.json +4 -4
  9. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +2 -0
  10. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +16 -4
  11. package/node_modules/pi-lens/CHANGELOG.md +498 -0
  12. package/node_modules/pi-lens/README.md +45 -2
  13. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +23 -7
  14. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +187 -51
  15. package/node_modules/pi-lens/dist/clients/cache-manager.js +64 -5
  16. package/node_modules/pi-lens/dist/clients/child-unref.js +17 -8
  17. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  18. package/node_modules/pi-lens/dist/clients/config-locations.js +237 -9
  19. package/node_modules/pi-lens/dist/clients/config-resolve.js +7 -1
  20. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +33 -3
  21. package/node_modules/pi-lens/dist/clients/dependency-checker.js +87 -64
  22. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +39 -3
  23. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +118 -1
  24. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +8 -2
  25. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +23 -4
  26. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +14 -7
  27. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +26 -28
  28. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +38 -25
  29. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +32 -49
  30. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +16 -14
  31. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +25 -7
  32. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +28 -47
  33. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +11 -4
  34. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +27 -22
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +31 -46
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +29 -46
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +91 -11
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +16 -2
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +38 -23
  40. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +27 -15
  41. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +17 -8
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +31 -21
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +34 -60
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +6 -5
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +12 -5
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +22 -6
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +39 -16
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +1 -1
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +27 -18
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +18 -19
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +6 -1
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +6 -2
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -3
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +11 -2
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +10 -2
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +11 -2
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +12 -6
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +30 -15
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +23 -0
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +8 -2
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +11 -1
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +15 -3
  64. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +12 -2
  65. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +27 -39
  66. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +13 -3
  67. package/node_modules/pi-lens/dist/clients/effective-config.js +3 -1
  68. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +42 -11
  69. package/node_modules/pi-lens/dist/clients/formatters.js +49 -11
  70. package/node_modules/pi-lens/dist/clients/git-guard.js +247 -34
  71. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +121 -25
  72. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +186 -0
  73. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +19 -6
  74. package/node_modules/pi-lens/dist/clients/installer/index.js +281 -32
  75. package/node_modules/pi-lens/dist/clients/jscpd-client.js +8 -0
  76. package/node_modules/pi-lens/dist/clients/latency-logger.js +8 -0
  77. package/node_modules/pi-lens/dist/clients/lens-config.js +66 -8
  78. package/node_modules/pi-lens/dist/clients/lsp/client.js +139 -11
  79. package/node_modules/pi-lens/dist/clients/lsp/config.js +9 -7
  80. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +41 -0
  81. package/node_modules/pi-lens/dist/clients/lsp/index.js +1 -1
  82. package/node_modules/pi-lens/dist/clients/lsp/launch.js +23 -2
  83. package/node_modules/pi-lens/dist/clients/lsp/server.js +106 -25
  84. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +105 -16
  86. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +232 -44
  87. package/node_modules/pi-lens/dist/clients/mcp/review.js +53 -7
  88. package/node_modules/pi-lens/dist/clients/opengrep-client.js +11 -6
  89. package/node_modules/pi-lens/dist/clients/pipeline.js +121 -66
  90. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +21 -3
  91. package/node_modules/pi-lens/dist/clients/project-lens-config.js +11 -3
  92. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +34 -26
  93. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +16 -10
  94. package/node_modules/pi-lens/dist/clients/ruff-client.js +31 -4
  95. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +38 -4
  96. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +16 -5
  97. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +27 -12
  98. package/node_modules/pi-lens/dist/clients/runtime-turn.js +447 -280
  99. package/node_modules/pi-lens/dist/clients/safe-spawn.js +256 -65
  100. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +84 -1
  101. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +119 -58
  102. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +51 -0
  103. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +112 -0
  104. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +204 -0
  105. package/node_modules/pi-lens/dist/clients/warm-attach.js +59 -0
  106. package/node_modules/pi-lens/dist/clients/widget-state.js +145 -14
  107. package/node_modules/pi-lens/dist/clients/workspace-topology.js +57 -3
  108. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -13
  109. package/node_modules/pi-lens/dist/index.js +5695 -3733
  110. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +4 -0
  111. package/node_modules/pi-lens/dist/mcp/server.js +33 -21
  112. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  113. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +43 -16
  114. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +118 -7
  115. package/node_modules/pi-lens/docs/adr/0001-stale-advisory-live-arm.md +29 -0
  116. package/node_modules/pi-lens/docs/adr/0002-workspace-hash-rendezvous.md +30 -0
  117. package/node_modules/pi-lens/docs/adr/0003-git-guard-latch-writer.md +203 -0
  118. package/node_modules/pi-lens/docs/adr/0004-disposition-policy-seam.md +38 -0
  119. package/node_modules/pi-lens/docs/adr/0005-tool-availability-enforcement-seam.md +29 -0
  120. package/node_modules/pi-lens/docs/adr/0006-derived-state-benchmark-first.md +31 -0
  121. package/node_modules/pi-lens/docs/adr/0007-end-to-end-witness-per-seam-slice.md +38 -0
  122. package/node_modules/pi-lens/docs/adr/0008-turn-end-lane-interface.md +165 -0
  123. package/node_modules/pi-lens/docs/adr/0009-reported-path-attribution.md +114 -0
  124. package/node_modules/pi-lens/docs/adr/README.md +26 -0
  125. package/node_modules/pi-lens/docs/agent-tools.md +18 -1
  126. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +4 -3
  127. package/node_modules/pi-lens/docs/configuration.md +55 -1
  128. package/node_modules/pi-lens/docs/engineering-principles.md +202 -0
  129. package/node_modules/pi-lens/docs/environment-variables.md +61 -4
  130. package/node_modules/pi-lens/docs/features.md +2 -2
  131. package/node_modules/pi-lens/docs/globalconfig.md +6 -1
  132. package/node_modules/pi-lens/docs/language-coverage.md +17 -0
  133. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +98 -31
  134. package/node_modules/pi-lens/docs/pi-lens-fixer.md +57 -3
  135. package/node_modules/pi-lens/docs/pi-lens-investigator.md +9 -0
  136. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +16 -0
  137. package/node_modules/pi-lens/docs/pi-lens-subagent.md +9 -0
  138. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  139. package/node_modules/pi-lens/docs/release-qa-baseline.md +2 -0
  140. package/node_modules/pi-lens/docs/servercapabilities.md +21 -11
  141. package/node_modules/pi-lens/docs/settings.md +12 -3
  142. package/node_modules/pi-lens/docs/subagent-compat.md +1 -1
  143. package/node_modules/pi-lens/package.json +5 -3
  144. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-assert-tuple-test.yml +13 -0
  145. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-boolean-in-except-test.yml +26 -0
  146. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-http-headers-bracket-access-test.yml +10 -0
  147. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-numeric-parse-python-test.yml +18 -0
  148. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/unchecked-throwing-call-python-test.yml +4 -3
  149. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +2 -2
  150. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-assert-tuple.yml +23 -13
  151. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-boolean-in-except.yml +7 -3
  152. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error-js.yml +2 -2
  153. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-console-except-error.yml +2 -2
  154. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-http-headers-bracket-access.yml +12 -7
  155. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-numeric-parse-python.yml +44 -0
  156. package/node_modules/pi-lens/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +5 -3
  157. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml +2 -2
  158. package/node_modules/pi-lens/scripts/install-selftest.mjs +21 -17
  159. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +123 -0
  160. package/node_modules/pi-mcp-adapter/CHANGELOG.md +29 -0
  161. package/node_modules/pi-mcp-adapter/OAUTH.md +369 -0
  162. package/node_modules/pi-mcp-adapter/README.md +12 -5
  163. package/node_modules/pi-mcp-adapter/config.ts +31 -8
  164. package/node_modules/pi-mcp-adapter/direct-tool-surface.ts +1 -1
  165. package/node_modules/pi-mcp-adapter/dist/config.js +33 -8
  166. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  167. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js +15 -2
  168. package/node_modules/pi-mcp-adapter/dist/json-schema-validator.js.map +1 -1
  169. package/node_modules/pi-mcp-adapter/dist/mcp-auth.d.ts +2 -0
  170. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js +27 -2
  171. package/node_modules/pi-mcp-adapter/dist/mcp-auth.js.map +1 -1
  172. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js +1 -1
  173. package/node_modules/pi-mcp-adapter/dist/metadata-cache.js.map +1 -1
  174. package/node_modules/pi-mcp-adapter/dist/server-manager.js +6 -4
  175. package/node_modules/pi-mcp-adapter/dist/server-manager.js.map +1 -1
  176. package/node_modules/pi-mcp-adapter/dist/types.d.ts +2 -2
  177. package/node_modules/pi-mcp-adapter/dist/utils.d.ts +2 -0
  178. package/node_modules/pi-mcp-adapter/dist/utils.js +10 -3
  179. package/node_modules/pi-mcp-adapter/dist/utils.js.map +1 -1
  180. package/node_modules/pi-mcp-adapter/index.ts +37 -20
  181. package/node_modules/pi-mcp-adapter/init.ts +14 -15
  182. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +16 -2
  183. package/node_modules/pi-mcp-adapter/mcp-auth.ts +27 -2
  184. package/node_modules/pi-mcp-adapter/metadata-cache.ts +1 -1
  185. package/node_modules/pi-mcp-adapter/package.json +3 -2
  186. package/node_modules/pi-mcp-adapter/proxy-modes.ts +1 -1
  187. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -3
  188. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +99 -6
  189. package/node_modules/pi-mcp-adapter/types.ts +2 -2
  190. package/node_modules/pi-mcp-adapter/ui-session.ts +60 -27
  191. package/node_modules/pi-mcp-adapter/utils.ts +11 -3
  192. package/node_modules/pi-next-cue/.github/workflows/publish.yml +20 -0
  193. package/node_modules/pi-next-cue/LICENSE +21 -0
  194. package/node_modules/pi-next-cue/README.md +81 -0
  195. package/node_modules/pi-next-cue/index.ts +358 -0
  196. package/node_modules/pi-next-cue/package.json +28 -0
  197. package/package.json +9 -6
@@ -86,6 +86,8 @@ flowchart TD
86
86
 
87
87
  SESSION["Session lifecycle<br/>primary, sequential replacement, concurrent secondary"]
88
88
  SINKS["Observability sinks<br/>latency.log, degradation ledger, bounded telemetry,<br/>cache observability, cascade and tree-sitter logs"]
89
+ CONFIG["Config-core<br/>resolve, merge, deny"]
90
+ REVIEWGRAPH["Review-graph intelligence<br/>call graph, reverse deps, import resolution"]
89
91
 
90
92
  HOST --> WRAP
91
93
  HOST -->|tool_call, raw| RG
@@ -100,6 +102,10 @@ flowchart TD
100
102
  PLAN --> STRUCT
101
103
  PIPE --> POOL
102
104
  PIPE --> BUS
105
+ CONFIG --> PIPE
106
+ CONFIG --> PLAN
107
+ STRUCT --> REVIEWGRAPH
108
+ REVIEWGRAPH --> CASC
103
109
  POOL --> DIAGS
104
110
  DIAGS --> CASC
105
111
  RUN --> STORES
@@ -130,8 +136,8 @@ flowchart TD
130
136
  Architecture-level view, updated when a lane changes. Per-tool inventories live
131
137
  in [features](docs/features.md) and
132
138
  [language coverage](docs/language-coverage.md). Today the edit-time lane carries
133
- 45+ runner modules over 35+ file kinds, and the LSP lane speaks to a dozen-plus
134
- language servers.
139
+ 45+ runner modules over 35+ file kinds, and the LSP lane speaks to 46
140
+ language server definitions.
135
141
 
136
142
  The gating box is an abstraction, not a call order. Freshness covers several
137
143
  independent mechanisms: path freshness against scan time, past-EOF line checks,
@@ -194,6 +200,8 @@ expects to be operated.
194
200
  - [Custom rules](docs/custom-rules.md) — project ast-grep and tree-sitter rules
195
201
  - [MCP server](docs/mcp.md) — experimental MCP server for Claude Code and
196
202
  other MCP clients
203
+ - [Architecture decision records](docs/adr/README.md) — settled design
204
+ decisions, linked from the engineering-defect catalog
197
205
 
198
206
  ## Contributing
199
207
 
@@ -349,6 +357,41 @@ Thanks goes to these wonderful people:
349
357
  <tr>
350
358
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/amasen02"><img src="https://avatars.githubusercontent.com/u/97525823?v=4" width="100px;" alt=""/><br /><sub><b>Ama Senevirathne</b></sub></a><br /><a href="#code-amasen02" title="Code">💻</a></td>
351
359
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/AcademyKUB8"><img src="https://avatars.githubusercontent.com/u/217174854?v=4" width="100px;" alt=""/><br /><sub><b>AcademyKUB8</b></sub></a><br /><a href="#code-AcademyKUB8" title="Code">💻</a></td>
360
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/soulocker"><img src="https://avatars.githubusercontent.com/u/9692331?v=4" width="100px;" alt=""/><br /><sub><b>soulocker</b></sub></a><br /><a href="#bug-soulocker" title="Bug reports">🐛</a></td>
361
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/sonic182"><img src="https://avatars.githubusercontent.com/u/4070466?v=4" width="100px;" alt=""/><br /><sub><b>Johanderson Mogollon</b></sub></a><br /><a href="#ideas-sonic182" title="Ideas & Planning">🤔</a></td>
362
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/kurihada"><img src="https://avatars.githubusercontent.com/u/63988604?v=4" width="100px;" alt=""/><br /><sub><b>kurihada</b></sub></a><br /><a href="#bug-kurihada" title="Bug reports">🐛</a></td>
363
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/Giraffe224"><img src="https://avatars.githubusercontent.com/u/193071526?v=4" width="100px;" alt=""/><br /><sub><b>Giraffe224</b></sub></a><br /><a href="#bug-Giraffe224" title="Bug reports">🐛</a></td>
364
+ <td align="center" valign="top" width="14.28%"><a href="http://www.crshman.com"><img src="https://avatars.githubusercontent.com/u/39948?v=4" width="100px;" alt=""/><br /><sub><b>Robert Navarro</b></sub></a><br /><a href="#bug-rnavarro" title="Bug reports">🐛</a> <a href="#code-rnavarro" title="Code">💻</a></td>
365
+ </tr>
366
+ <tr>
367
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/kapkema"><img src="https://avatars.githubusercontent.com/u/774573?v=4" width="100px;" alt=""/><br /><sub><b>Marc</b></sub></a><br /><a href="#bug-kapkema" title="Bug reports">🐛</a></td>
368
+ <td align="center" valign="top" width="14.28%"><a href="https://javymarmol.com"><img src="https://avatars.githubusercontent.com/u/3501269?v=4" width="100px;" alt=""/><br /><sub><b>Heyner Javier Marmol Verbel</b></sub></a><br /><a href="#bug-javymarmol" title="Bug reports">🐛</a></td>
369
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/Stijnus"><img src="https://avatars.githubusercontent.com/u/72551117?v=4" width="100px;" alt=""/><br /><sub><b>Stijnus</b></sub></a><br /><a href="#bug-Stijnus" title="Bug reports">🐛</a></td>
370
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/Macrox"><img src="https://avatars.githubusercontent.com/u/571851?v=4" width="100px;" alt=""/><br /><sub><b>macroxing</b></sub></a><br /><a href="#bug-Macrox" title="Bug reports">🐛</a></td>
371
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/jwtracy"><img src="https://avatars.githubusercontent.com/u/58991818?v=4" width="100px;" alt=""/><br /><sub><b>John Tracy</b></sub></a><br /><a href="#code-jwtracy" title="Code">💻</a></td>
372
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/woyxiang"><img src="https://avatars.githubusercontent.com/u/119841810?v=4" width="100px;" alt=""/><br /><sub><b>woyxiang</b></sub></a><br /><a href="#ideas-woyxiang" title="Ideas & Planning">🤔</a></td>
373
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/Juncanthia"><img src="https://avatars.githubusercontent.com/u/17041926?v=4" width="100px;" alt=""/><br /><sub><b>Juncanthia</b></sub></a><br /><a href="#bug-Juncanthia" title="Bug reports">🐛</a></td>
374
+ </tr>
375
+ <tr>
376
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/kevinkirkup"><img src="https://avatars.githubusercontent.com/u/38406?v=4" width="100px;" alt=""/><br /><sub><b>Kevin S Kirkup</b></sub></a><br /><a href="#bug-kevinkirkup" title="Bug reports">🐛</a></td>
377
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/georgeharker"><img src="https://avatars.githubusercontent.com/u/2169888?v=4" width="100px;" alt=""/><br /><sub><b>George Harker</b></sub></a><br /><a href="#code-georgeharker" title="Code">💻</a> <a href="#bug-georgeharker" title="Bug reports">🐛</a> <a href="#ideas-georgeharker" title="Ideas & Planning">🤔</a></td>
378
+ <td align="center" valign="top" width="14.28%"><a href="https://zuey.me"><img src="https://avatars.githubusercontent.com/u/6857382?v=4" width="100px;" alt=""/><br /><sub><b>Duy /zuey/</b></sub></a><br /><a href="#ideas-mrgoonie" title="Ideas & Planning">🤔</a> <a href="#code-mrgoonie" title="Code">💻</a></td>
379
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/luw2007"><img src="https://avatars.githubusercontent.com/u/1415541?v=4" width="100px;" alt=""/><br /><sub><b>luw2007</b></sub></a><br /><a href="#bug-luw2007" title="Bug reports">🐛</a></td>
380
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/WrongWorry"><img src="https://avatars.githubusercontent.com/u/188037855?v=4" width="100px;" alt=""/><br /><sub><b>WrongWorry</b></sub></a><br /><a href="#bug-WrongWorry" title="Bug reports">🐛</a></td>
381
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/nanaxx131-gif"><img src="https://avatars.githubusercontent.com/u/310295749?v=4" width="100px;" alt=""/><br /><sub><b>nanaxx131-gif</b></sub></a><br /><a href="#bug-nanaxx131-gif" title="Bug reports">🐛</a></td>
382
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/CIECJS"><img src="https://avatars.githubusercontent.com/u/130489549?v=4" width="100px;" alt=""/><br /><sub><b>MateusCx</b></sub></a><br /><a href="#bug-CIECJS" title="Bug reports">🐛</a></td>
383
+ </tr>
384
+ <tr>
385
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/HughZadora"><img src="https://avatars.githubusercontent.com/u/32498443?v=4" width="100px;" alt=""/><br /><sub><b>Hugh Zadora</b></sub></a><br /><a href="#bug-HughZadora" title="Bug reports">🐛</a></td>
386
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/cxh0312"><img src="https://avatars.githubusercontent.com/u/10069427?v=4" width="100px;" alt=""/><br /><sub><b>撑伞的鱼</b></sub></a><br /><a href="#bug-cxh0312" title="Bug reports">🐛</a></td>
387
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/LvGitHub-9"><img src="https://avatars.githubusercontent.com/u/69997168?v=4" width="100px;" alt=""/><br /><sub><b>Lv</b></sub></a><br /><a href="#bug-LvGitHub-9" title="Bug reports">🐛</a></td>
388
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/apmcodes"><img src="https://avatars.githubusercontent.com/u/13360356?v=4" width="100px;" alt=""/><br /><sub><b>apmcodes</b></sub></a><br /><a href="#bug-apmcodes" title="Bug reports">🐛</a></td>
389
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/jondong"><img src="https://avatars.githubusercontent.com/u/700736?v=4" width="100px;" alt=""/><br /><sub><b>Jonathan Dong</b></sub></a><br /><a href="#bug-jondong" title="Bug reports">🐛</a></td>
390
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/alchemistklk"><img src="https://avatars.githubusercontent.com/u/56862773?v=4" width="100px;" alt=""/><br /><sub><b>Caprika</b></sub></a><br /><a href="#bug-alchemistklk" title="Bug reports">🐛</a></td>
391
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/dmontague-crwv"><img src="https://avatars.githubusercontent.com/u/289768493?v=4" width="100px;" alt=""/><br /><sub><b>dmontague-crwv</b></sub></a><br /><a href="#bug-dmontague-crwv" title="Bug reports">🐛</a></td>
392
+ </tr>
393
+ <tr>
394
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/glyh"><img src="https://avatars.githubusercontent.com/u/12870742?v=4" width="100px;" alt=""/><br /><sub><b>Yihang "Corvo" Liu</b></sub></a><br /><a href="#bug-glyh" title="Bug reports">🐛</a></td>
352
395
  </tr>
353
396
  </tbody>
354
397
  </table>
@@ -1547,22 +1547,35 @@ export async function applyConservativeActionableWarningFixes(args) {
1547
1547
  * unread under `~/.pi-lens/projects/<slug>/cache/`. An OPTIONAL `cwd` would
1548
1548
  * have kept that bug alive behind a default, so callers must pass it.
1549
1549
  */
1550
- export function formatActionableWarningsAdvisory(report, cwd, host = "pi") {
1551
- if (report.summary.unsuppressed === 0)
1550
+ export function formatActionableWarningsAdvisory(report, cwd, host = "pi", filterBuiltReport) {
1551
+ // The cache record is deliberately kept raw: lens_diagnostics mode=delta
1552
+ // applies dispositions on read. The turn-end advisory is a separate
1553
+ // delivery surface, so filter the report AFTER its builder and after the
1554
+ // deferred merge (which is where LSP-enriched rows enter), then derive the
1555
+ // summary from the same per-file rows before rendering.
1556
+ const filtered = filterBuiltReport?.(report);
1557
+ const advisoryReport = filtered
1558
+ ? {
1559
+ ...report,
1560
+ files: filtered.files,
1561
+ summary: summarizeReportFiles(filtered.files),
1562
+ }
1563
+ : report;
1564
+ if (advisoryReport.summary.unsuppressed === 0)
1552
1565
  return undefined;
1553
- const files = report.files.filter((file) => file.warnings.some((warning) => !warning.suppressed));
1566
+ const files = advisoryReport.files.filter((file) => file.warnings.some((warning) => !warning.suppressed));
1554
1567
  const fileList = files
1555
1568
  .slice(0, 5)
1556
1569
  .map((file) => ` ${file.displayPath}: ${file.warnings.filter((warning) => !warning.suppressed).length}${file.reVerifyIncomplete ? " (re-verify incomplete)" : ""}`)
1557
1570
  .join("\n");
1558
1571
  const more = files.length > 5 ? `\n ... and ${files.length - 5} more file(s)` : "";
1559
- const safe = report.summary.autoFixEligible > 0
1560
- ? ` ${report.summary.autoFixEligible} appear to have conservative preferred quickfixes.`
1572
+ const safe = advisoryReport.summary.autoFixEligible > 0
1573
+ ? ` ${advisoryReport.summary.autoFixEligible} appear to have conservative preferred quickfixes.`
1561
1574
  : "";
1562
1575
  // #1777: hint and info are style opinions, so say how much of the count is
1563
1576
  // opinion. The line appears only when a quiet tier is actually present —
1564
1577
  // an all-warning turn already says everything in the count above.
1565
- const byTier = report.summary.byTier;
1578
+ const byTier = advisoryReport.summary.byTier;
1566
1579
  const quiet = byTier ? byTier.hint + byTier.info : 0;
1567
1580
  const tierLine = quiet > 0
1568
1581
  ? `${quiet} of those are hint/info tier — style opinions, worth fixing only while you are already in that code.`
@@ -1580,7 +1593,10 @@ export function formatActionableWarningsAdvisory(report, cwd, host = "pi") {
1580
1593
  // defensive only.
1581
1594
  const diagnosticsTool = resolveLensToolName("lens_diagnostics", host);
1582
1595
  return [
1583
- `🟡 Fixable warnings introduced this turn: ${report.summary.unsuppressed}.${safe}`,
1596
+ `🟡 Fixable warnings introduced this turn: ${advisoryReport.summary.unsuppressed}.${safe}`,
1597
+ filtered && filtered.suppressed > 0
1598
+ ? `suppressed by disposition: ${filtered.suppressed} finding(s).`
1599
+ : undefined,
1584
1600
  tierLine,
1585
1601
  diagnosticsTool
1586
1602
  ? `Use ${diagnosticsTool} with mode=delta to inspect these warnings.`
@@ -248,25 +248,39 @@ const MAX_LOGGED_DEAD_PATHS = 3;
248
248
  * truth for the measured skew.
249
249
  */
250
250
  export function findingPathFreshness(resolvedPath, scannedAtMs) {
251
+ return freshnessFromFacts(statFindingPath(resolvedPath), scannedAtMs);
252
+ }
253
+ /** The one filesystem touch. Injectable via `probePath` so rules stay testable. */
254
+ function statFindingPath(resolvedPath) {
251
255
  try {
252
- const stat = fs.statSync(resolvedPath);
253
- // No scan timestamp recorded: the pre-kernel behavior treated the
254
- // path as live (no reference to be stale against) - preserved.
255
- if (scannedAtMs === undefined)
256
- return "live";
257
- const verdict = freshnessFromMtime({
258
- mtimeMs: stat.mtimeMs,
259
- referenceMs: scannedAtMs,
260
- });
261
- return verdict.verdict === "stale" ? "stale" : "live";
256
+ return { state: "present", mtimeMs: fs.statSync(resolvedPath).mtimeMs };
262
257
  }
263
258
  catch (error) {
264
259
  const code = error.code ?? "unknown";
265
260
  // ENOTDIR: an ancestor component is no longer a directory — the cited
266
261
  // path cannot exist either, same as ENOENT.
267
- return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
262
+ return code === "ENOENT" || code === "ENOTDIR"
263
+ ? { state: "missing" }
264
+ : { state: "unknown" };
268
265
  }
269
266
  }
267
+ /** Pure: one store's verdict for one path's facts. No filesystem, no policy. */
268
+ function freshnessFromFacts(facts, scannedAtMs) {
269
+ if (facts.state === "missing")
270
+ return "missing";
271
+ if (facts.state === "unknown")
272
+ return "unknown";
273
+ // No scan timestamp recorded: the pre-kernel behavior treated the path as
274
+ // live (no reference to be stale against) - preserved.
275
+ if (scannedAtMs === undefined)
276
+ return "live";
277
+ return freshnessFromMtime({
278
+ mtimeMs: facts.mtimeMs,
279
+ referenceMs: scannedAtMs,
280
+ }).verdict === "stale"
281
+ ? "stale"
282
+ : "live";
283
+ }
270
284
  /** Existence-only probe. Retained for callers that have no scan timestamp. */
271
285
  export function findingPathExistence(resolvedPath) {
272
286
  return findingPathFreshness(resolvedPath);
@@ -294,8 +308,17 @@ export function parseScannedAtMs(scannedAt) {
294
308
  * every later turn, which is the very defect this gate exists to close.
295
309
  */
296
310
  export function partitionFindingsByCitedPath(args) {
297
- const limit = args.maxUniquePaths ?? MAX_FINDING_PATH_STATS;
298
- const probe = args.existence ?? findingPathFreshness;
311
+ return partitionOneSource({ ...args, store: args.store ?? "" }, createPathFactsMemo(args));
312
+ }
313
+ function createPathFactsMemo(args) {
314
+ return {
315
+ facts: new Map(),
316
+ probe: args.probePath ?? statFindingPath,
317
+ limit: args.maxUniquePaths ?? MAX_FINDING_PATH_STATS,
318
+ starved: new Map(),
319
+ };
320
+ }
321
+ function partitionOneSource(args, memo) {
299
322
  const scannedAtMs = parseScannedAtMs(args.scannedAt);
300
323
  const onMissing = args.onMissing ?? "drop";
301
324
  // Two dedup layers, cheapest first. `rawVerdicts` collapses findings that
@@ -303,19 +326,24 @@ export function partitionFindingsByCitedPath(args) {
303
326
  // (#1460's live record: 126 findings over a handful of distinct `file`
304
327
  // strings). Only a raw string not seen before pays for
305
328
  // `resolveRunnerPath`/`advisoryPathKey`, which folds FS-confirmed spelling
306
- // variants (case, separators, ancestor walk-up) into `verdicts`, the
329
+ // variants (case, separators, ancestor walk-up) into `memo.facts`, the
307
330
  // canonical-key map that `statCount` reports. Keying the expensive work by
308
331
  // the RESOLVED path instead of the raw one (the pre-#1461-HIGH-2 shape)
309
332
  // still called it once per finding, since the dedup lookup came after the
310
333
  // cost it was meant to dedupe.
334
+ //
335
+ // `rawVerdicts` is per SOURCE (the verdict is this store's), `memo.facts` is
336
+ // per DELIVERY (the stat is nobody's). #1892.
311
337
  const rawVerdicts = new Map();
312
- const verdicts = new Map();
313
338
  const live = [];
314
339
  const stale = [];
315
340
  const dropped = [];
316
341
  const deadPaths = [];
317
342
  const stalePaths = [];
318
- let truncated = false;
343
+ const seenPaths = new Set();
344
+ // This SOURCE's own probes. A memo hit costs nothing, so an earlier source
345
+ // having paid for a path only ever helps this one (#3264 review F2).
346
+ let probed = 0;
319
347
  for (const finding of args.findings) {
320
348
  const cited = args.citedPath(finding);
321
349
  if (!cited) {
@@ -332,30 +360,37 @@ export function partitionFindingsByCitedPath(args) {
332
360
  // `normalizeMapKey`, so the resolved path IS the canonical key — a
333
361
  // second `advisoryPathKey` pass would re-pay the same realpath cost.
334
362
  const resolved = resolveRunnerPath(args.cwd, cited);
335
- const key = resolved;
336
- verdict = verdicts.get(key);
337
- if (verdict === undefined) {
338
- if (verdicts.size >= limit) {
339
- // Budget spent on paths we have not seen before — deliver rather
340
- // than guess. Already-probed paths keep their cached verdict.
341
- truncated = true;
342
- verdict = "live";
363
+ let facts = memo.facts.get(resolved);
364
+ if (facts === undefined) {
365
+ if (probed >= memo.limit) {
366
+ // Allowance spent on paths nobody in this delivery has seen —
367
+ // deliver rather than guess, exactly as the pre-fold per-lane cap
368
+ // did. Guessing a verdict for a path nobody looked at is the worse
369
+ // error; what the fold adds is that the guess is now RECORDED.
370
+ memo.starved.set(args.store, (memo.starved.get(args.store) ?? 0) + 1);
371
+ facts = { state: "unknown" };
343
372
  }
344
373
  else {
345
- verdict = probe(resolved, scannedAtMs);
346
- // A store that treats deletion as evidence-loss rather than
347
- // remediation-loss folds `missing` into the demote arm here, so
348
- // every downstream list and record sees one consistent verdict.
349
- if (verdict === "missing" && onMissing === "demote") {
350
- verdict = "stale";
351
- }
352
- verdicts.set(key, verdict);
353
- if (verdict === "missing")
354
- deadPaths.push(resolved);
355
- else if (verdict === "stale")
356
- stalePaths.push(resolved);
374
+ probed += 1;
375
+ facts = memo.probe(resolved);
376
+ memo.facts.set(resolved, facts);
357
377
  }
358
378
  }
379
+ verdict = freshnessFromFacts(facts, scannedAtMs);
380
+ // A store that treats deletion as evidence-loss rather than
381
+ // remediation-loss folds `missing` into the demote arm here, so
382
+ // every downstream list and record sees one consistent verdict. It is
383
+ // applied per SOURCE, after the shared lookup: `onMissing` is this
384
+ // store's policy and must never be cached into another store's answer.
385
+ if (verdict === "missing" && onMissing === "demote")
386
+ verdict = "stale";
387
+ if (!seenPaths.has(resolved)) {
388
+ seenPaths.add(resolved);
389
+ if (verdict === "missing")
390
+ deadPaths.push(resolved);
391
+ else if (verdict === "stale")
392
+ stalePaths.push(resolved);
393
+ }
359
394
  rawVerdicts.set(cited, verdict);
360
395
  }
361
396
  if (verdict === "missing")
@@ -371,8 +406,8 @@ export function partitionFindingsByCitedPath(args) {
371
406
  dropped,
372
407
  deadPaths,
373
408
  stalePaths,
374
- statCount: verdicts.size,
375
- truncated,
409
+ statCount: memo.facts.size,
410
+ truncated: (memo.starved.get(args.store) ?? 0) > 0,
376
411
  };
377
412
  }
378
413
  /**
@@ -386,32 +421,93 @@ export function partitionFindingsByCitedPath(args) {
386
421
  export function dropFindingsForMissingPaths(args) {
387
422
  // No `scannedAt` — existence-only, so `partition.stale` is always empty.
388
423
  const partition = partitionFindingsByCitedPath(args);
389
- emitDeadPathDropRecord(args.store, args.cwd, partition);
424
+ emitDeadPathDropRecord(args.cwd, partition, {
425
+ [args.store]: partition.dropped.length,
426
+ });
390
427
  return partition.live;
391
428
  }
392
429
  /**
393
- * Delivery-seam wrapper with the #1622 freshness verdict. Emits at most one
394
- * `finding_dead_path_drop` and one `finding_stale_line_demote` record per store
395
- * per delivery, each with a capped path sample; never one per finding.
430
+ * Delivery-seam wrapper with the #1622 freshness verdict, over one or more
431
+ * stores at once. The store name is the KEY of `sources`, and it is also the
432
+ * finding's source identity inside the gate: every store's `scannedAt` and
433
+ * `onMissing` decide that store's findings and only that store's.
434
+ *
435
+ * #1892: the three turn-end scanner lanes used to call this once each, so one
436
+ * file cited by gitleaks AND trivy-secrets was stat'd twice, spent two separate
437
+ * stat budgets, and wrote two `finding_stale_line_demote` rows for ONE decision.
438
+ * Passing them together makes the filesystem pass shared and the record one per
439
+ * delivery, without letting one store's clean/stale/missing answer speak for
440
+ * another's — the two directions this seam has to keep apart.
396
441
  *
397
442
  * Callers that have no scan timestamp keep using `dropFindingsForMissingPaths`.
398
443
  */
399
444
  export function gateFindingsByPathFreshness(args) {
400
- const partition = partitionFindingsByCitedPath(args);
401
- emitDeadPathDropRecord(args.store, args.cwd, partition);
402
- emitStaleLineDemoteRecord(args.store, args.cwd, args.scannedAt, partition);
403
- return { live: partition.live, stale: partition.stale };
445
+ const memo = createPathFactsMemo(args);
446
+ // Sorted, not literal order: which source pays for a shared path, and the
447
+ // order stores appear in the records, must not depend on how the CALLER
448
+ // happened to write the object (#3264 review F2).
449
+ const names = Object.keys(args.sources).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
450
+ const gates = {};
451
+ const dropped = {};
452
+ const demoted = {};
453
+ const merged = {
454
+ live: [],
455
+ stale: [],
456
+ dropped: [],
457
+ deadPaths: [],
458
+ stalePaths: [],
459
+ statCount: 0,
460
+ truncated: false,
461
+ };
462
+ const scannedAtByStore = {};
463
+ for (const name of names) {
464
+ const source = args.sources[name];
465
+ const partition = partitionOneSource({ ...source, store: name, cwd: args.cwd }, memo);
466
+ gates[name] = { live: partition.live, stale: partition.stale };
467
+ if (partition.dropped.length > 0)
468
+ dropped[name] = partition.dropped.length;
469
+ if (partition.stale.length > 0)
470
+ demoted[name] = partition.stale.length;
471
+ if (source.scannedAt !== undefined) {
472
+ scannedAtByStore[name] = String(source.scannedAt);
473
+ }
474
+ merged.live.push(...partition.live);
475
+ merged.stale.push(...partition.stale);
476
+ merged.dropped.push(...partition.dropped);
477
+ for (const deadPath of partition.deadPaths) {
478
+ if (!merged.deadPaths.includes(deadPath))
479
+ merged.deadPaths.push(deadPath);
480
+ }
481
+ for (const stalePath of partition.stalePaths) {
482
+ if (!merged.stalePaths.includes(stalePath)) {
483
+ merged.stalePaths.push(stalePath);
484
+ }
485
+ }
486
+ }
487
+ merged.statCount = memo.facts.size;
488
+ merged.truncated = memo.starved.size > 0;
489
+ emitDeadPathDropRecord(args.cwd, merged, dropped);
490
+ emitStaleLineDemoteRecord(args.cwd, scannedAtByStore, merged, demoted);
491
+ emitStatBudgetRecord(args.cwd, memo);
492
+ return gates;
404
493
  }
405
- function emitDeadPathDropRecord(store, cwd, partition) {
494
+ /**
495
+ * `store` is the joined source names, so a single-source delivery still reads
496
+ * `store: "gitleaks"` exactly as it did pre-#1892; `byStore` is what makes the
497
+ * merged record discriminating — which store's findings this decision retired.
498
+ */
499
+ function emitDeadPathDropRecord(cwd, partition, byStore) {
406
500
  if (partition.dropped.length === 0)
407
501
  return;
502
+ const stores = Object.keys(byStore);
408
503
  logLatency({
409
504
  type: "phase",
410
505
  phase: "finding_dead_path_drop",
411
506
  filePath: cwd,
412
507
  durationMs: 0,
413
508
  metadata: {
414
- store,
509
+ store: stores.join("+"),
510
+ byStore,
415
511
  droppedDeadPaths: partition.dropped.length,
416
512
  deadPathCount: partition.deadPaths.length,
417
513
  deliveredCount: partition.live.length,
@@ -428,16 +524,22 @@ function emitDeadPathDropRecord(store, cwd, partition) {
428
524
  * path. Before this, a stale-line replay window was reconstructible only by
429
525
  * proxy — the blocker payload is written nowhere.
430
526
  */
431
- function emitStaleLineDemoteRecord(store, cwd, scannedAt, partition) {
527
+ function emitStaleLineDemoteRecord(cwd, scannedAtByStore, partition, byStore) {
432
528
  if (partition.stale.length === 0)
433
529
  return;
530
+ // Only the stores that actually contributed to THIS decision are named, so
531
+ // `store` still reads `gitleaks` for a gitleaks-only demotion whether the
532
+ // delivery gated one store or three.
533
+ const stores = Object.keys(byStore);
534
+ const single = stores.length === 1 ? scannedAtByStore[stores[0]] : undefined;
434
535
  logLatency({
435
536
  type: "phase",
436
537
  phase: "finding_stale_line_demote",
437
538
  filePath: cwd,
438
539
  durationMs: 0,
439
540
  metadata: {
440
- store,
541
+ store: stores.join("+"),
542
+ byStore,
441
543
  demotedStalePaths: partition.stale.length,
442
544
  stalePathCount: partition.stalePaths.length,
443
545
  deliveredCount: partition.live.length,
@@ -445,11 +547,45 @@ function emitStaleLineDemoteRecord(store, cwd, scannedAt, partition) {
445
547
  samplePaths: partition.stalePaths
446
548
  .slice(0, MAX_LOGGED_DEAD_PATHS)
447
549
  .map((stalePath) => toProjectRelativePath(stalePath, cwd)),
448
- ...(scannedAt === undefined ? {} : { scannedAt: String(scannedAt) }),
550
+ // One store: the flat `scannedAt` field, unchanged. Several: the
551
+ // per-store map, because the whole point of the fold is that they
552
+ // differ — one flattened timestamp here would be the same authority
553
+ // collapse the gate refuses to make on the findings themselves.
554
+ ...(single === undefined ? {} : { scannedAt: single }),
555
+ ...(stores.length > 1
556
+ ? {
557
+ scannedAtByStore: Object.fromEntries(stores.map((name) => [name, scannedAtByStore[name] ?? ""])),
558
+ }
559
+ : {}),
449
560
  ...(partition.truncated ? { truncated: true } : {}),
450
561
  },
451
562
  });
452
563
  }
564
+ /**
565
+ * #3264 review F2: the drop and demote records both return early when nothing
566
+ * was dropped or demoted, so a delivery whose only casualty was the stat
567
+ * allowance — every starved finding failing OPEN, delivered at full severity
568
+ * against a path nobody looked at — wrote no row at all. This is that row: one
569
+ * per delivery, naming the cap and every source that hit it, never one per
570
+ * finding.
571
+ */
572
+ function emitStatBudgetRecord(cwd, memo) {
573
+ if (memo.starved.size === 0)
574
+ return;
575
+ const stores = [...memo.starved.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
576
+ logLatency({
577
+ type: "phase",
578
+ phase: "finding_path_stat_budget_exhausted",
579
+ filePath: cwd,
580
+ durationMs: 0,
581
+ metadata: {
582
+ store: stores.join("+"),
583
+ byStore: Object.fromEntries(stores.map((name) => [name, memo.starved.get(name) ?? 0])),
584
+ maxUniquePaths: memo.limit,
585
+ statCount: memo.facts.size,
586
+ },
587
+ });
588
+ }
453
589
  export function provenanceStamp(provenance) {
454
590
  if (!isWellFormed(provenance))
455
591
  return "session unknown / turn unknown / generation unknown";
@@ -17,7 +17,7 @@ import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
18
  import { getProjectDataDir } from "./file-utils.js";
19
19
  import { writeFileAtomic } from "./atomic-write.js";
20
- import { readJsonCache } from "./json-cache-read.js";
20
+ import { readJsonCache, readJsonCacheAsync } from "./json-cache-read.js";
21
21
  import { isUnderDir, normalizeMapKey } from "./path-utils.js";
22
22
  // --- Defaults ---
23
23
  const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
@@ -93,8 +93,69 @@ export class CacheManager {
93
93
  return state.files[key];
94
94
  }
95
95
  // ---- Scanner Cache ----
96
+ /**
97
+ * The ONE staleness rule both scanner reads apply (#3274): the envelope's
98
+ * age in ms when it is inside `maxAgeMs`, or `null` — having logged the
99
+ * stale verdict — when it is not.
100
+ *
101
+ * Extracted rather than copied into {@link readCacheAsync} because the TTL
102
+ * boundary is exactly what the two seams must not disagree about while
103
+ * #3300 migrates callers across: a delivery that read one store through
104
+ * each method would otherwise be able to see it fresh and stale at once.
105
+ */
106
+ freshAgeMs(scanner, meta, maxAgeMs) {
107
+ const age = Date.now() - new Date(meta.timestamp).getTime();
108
+ if (age > maxAgeMs) {
109
+ this.log(`Cache stale: ${scanner} (age: ${Math.round(age / 1000)}s, max: ${maxAgeMs / 1000}s)`);
110
+ return null;
111
+ }
112
+ return age;
113
+ }
114
+ /**
115
+ * Read a scanner cache entry WITHOUT blocking the event loop (#3274).
116
+ *
117
+ * `readCache` below is synchronous: two `existsSync` plus two
118
+ * `readFileSync`+`JSON.parse`, all of which complete during ARGUMENT
119
+ * EVALUATION. `bounded()` (`clients/deadline-utils.ts`) takes a promise, so
120
+ * a hook that wrapped the sync read registered a budget that could never be
121
+ * spent — probed on #3274 with an already-aborted signal and `ms: 0`:
122
+ * `bounded()` returned `undefined` and the read had already parsed its JSON.
123
+ * This sibling suspends on `fs.promises`, so the turn_end budget and the
124
+ * hook's `ctx.signal` can actually abandon it.
125
+ *
126
+ * Same outcomes as the sync seam — an envelope, or `null` for missing,
127
+ * stale, corrupt or unreadable — and the same parser
128
+ * (`readJsonCacheAsync`, the async sibling in `clients/json-cache-read.ts`;
129
+ * there is no second parser). ONE deliberate difference, in the verbose log
130
+ * only: a missing store is reported as `Cache miss: <scanner> — <err>` from
131
+ * the read's own ENOENT instead of a preceding `existsSync` pair, because
132
+ * the pair is half the blocking cost this method exists to remove.
133
+ */
134
+ async readCacheAsync(scanner, cwd, maxAgeMs = DEFAULT_MAX_AGE_MS) {
135
+ const cachePath = path.join(getCacheDir(cwd), `${scanner}.json`);
136
+ const metaPath = path.join(getCacheDir(cwd), `${scanner}.meta.json`);
137
+ const onReadError = (err) => {
138
+ this.log(`Cache miss: ${scanner} — ${err}`);
139
+ };
140
+ const meta = await readJsonCacheAsync(metaPath, (parsed) => parsed, onReadError);
141
+ if (meta === undefined)
142
+ return null;
143
+ const age = this.freshAgeMs(scanner, meta, maxAgeMs);
144
+ if (age === null)
145
+ return null;
146
+ const data = await readJsonCacheAsync(cachePath, (parsed) => parsed, onReadError);
147
+ if (data === undefined)
148
+ return null;
149
+ this.log(`Cache hit: ${scanner} (age: ${Math.round(age / 1000)}s)`);
150
+ return { data, meta };
151
+ }
96
152
  /**
97
153
  * Read a scanner cache entry. Returns null if not found or stale.
154
+ *
155
+ * Synchronous, and therefore unboundable on a hook path — see
156
+ * {@link readCacheAsync}, and the `sync-hook-read` population in
157
+ * `tests/config/hook-await-bounds.test.ts` that counts the callers still
158
+ * on it. #3300 migrates them and deletes this method in its last slice.
98
159
  */
99
160
  readCache(scanner, cwd, maxAgeMs = DEFAULT_MAX_AGE_MS) {
100
161
  const cachePath = path.join(getCacheDir(cwd), `${scanner}.json`);
@@ -110,11 +171,9 @@ export class CacheManager {
110
171
  const meta = readJsonCache(metaPath, (parsed) => parsed, onReadError);
111
172
  if (meta === undefined)
112
173
  return null;
113
- const age = Date.now() - new Date(meta.timestamp).getTime();
114
- if (age > maxAgeMs) {
115
- this.log(`Cache stale: ${scanner} (age: ${Math.round(age / 1000)}s, max: ${maxAgeMs / 1000}s)`);
174
+ const age = this.freshAgeMs(scanner, meta, maxAgeMs);
175
+ if (age === null)
116
176
  return null;
117
- }
118
177
  const data = readJsonCache(cachePath, (parsed) => parsed, onReadError);
119
178
  if (data === undefined)
120
179
  return null;
@@ -11,12 +11,16 @@
11
11
  * new-code-duplication finding: two near-identical spawn→collect-stdout
12
12
  * blocks in `clients/resource-sampler.ts` collapsed to one shared helper).
13
13
  *
14
- * Deliberately has NO imports beyond `node:child_process` types, so both
14
+ * Deliberately has NO imports beyond `node:child_process` types and
15
+ * `spawn-output-cap.ts` (itself import-free, #3383), so both
15
16
  * `clients/instance-reaper.ts` and `clients/resource-sampler.ts` (which
16
17
  * `clients/safe-spawn.ts` itself depends on) can import this without risking
17
- * a circular-import chain.
18
+ * a circular-import chain. That is also why the output bound below reports
19
+ * nothing to the degradation ledger: importing the ledger here would put
20
+ * `safe-spawn.ts`'s own dependency above it in the graph.
18
21
  */
19
22
  import { spawn as nodeSpawn } from "node:child_process";
23
+ import { createBoundedOutputSink } from "./spawn-output-cap.js";
20
24
  /**
21
25
  * Detach a best-effort, fire-and-forget child process from the event loop.
22
26
  *
@@ -64,13 +68,18 @@ export function spawnCollectStdoutResult(command, args, options, collectOptions
64
68
  try {
65
69
  const child = nodeSpawn(command, args, options);
66
70
  unrefChildAndPipes(child);
67
- let out = "";
68
- child.stdout?.on("data", (chunk) => {
69
- out += chunk.toString();
70
- });
71
+ // #3383: bounded, not `out += chunk`. The producer is a process-table
72
+ // query (`ps`, `wmic`, `powershell`) whose output nothing else limits,
73
+ // and the concatenation runs inside a `data` handler where V8's
74
+ // `RangeError: Invalid string length` would be an uncaught exception
75
+ // rather than this promise's result. A truncated table is the ending
76
+ // this contract already documents for a timed-out child: whatever was
77
+ // kept is parsed, and the caller reads `status`.
78
+ const out = createBoundedOutputSink();
79
+ child.stdout?.on("data", (chunk) => out.append(chunk));
71
80
  child.once("error", (error) => settle({ stdout: "", status: "spawn-error", error }));
72
81
  child.once("close", (code, signal) => settle(code === 0 && signal === null
73
- ? { stdout: out, status: "ok" }
82
+ ? { stdout: out.text, status: "ok" }
74
83
  : {
75
84
  stdout: "",
76
85
  status: "exit-error",
@@ -83,7 +92,7 @@ export function spawnCollectStdoutResult(command, args, options, collectOptions
83
92
  // Settle only AFTER the child's fate is known. Killing and
84
93
  // resolving in the same tick bounds the caller and abandons the
85
94
  // child, which is the leak this timeout exists to prevent.
86
- void terminateTimedOutChild(child, collectOptions.onTimeout).then((timeoutKill) => settle({ stdout: out, status: "timeout", timeoutKill }));
95
+ void terminateTimedOutChild(child, collectOptions.onTimeout).then((timeoutKill) => settle({ stdout: out.text, status: "timeout", timeoutKill }));
87
96
  }, timeoutMs);
88
97
  timer.unref();
89
98
  }