@herbertgao/pi-extensions 2026.8.3 → 2026.8.5

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 (408) hide show
  1. package/README.md +45 -4
  2. package/examples/pi-footer.json +275 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +10 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.md +10 -2
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +5 -8
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  14. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  15. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  16. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +15 -2
  21. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  22. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  25. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  31. package/node_modules/@narumitw/pi-btw/README.md +8 -7
  32. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  33. package/node_modules/@narumitw/pi-btw/src/menu.ts +1 -0
  34. package/node_modules/pi-footer/CHANGELOG.md +145 -0
  35. package/node_modules/pi-footer/LICENSE +21 -0
  36. package/node_modules/pi-footer/README.md +354 -0
  37. package/node_modules/pi-footer/package.json +68 -0
  38. package/node_modules/pi-footer/src/cache.ts +106 -0
  39. package/node_modules/pi-footer/src/colors.ts +237 -0
  40. package/node_modules/pi-footer/src/config.ts +202 -0
  41. package/node_modules/pi-footer/src/event-widgets.ts +41 -0
  42. package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
  43. package/node_modules/pi-footer/src/git.ts +135 -0
  44. package/node_modules/pi-footer/src/index.ts +268 -0
  45. package/node_modules/pi-footer/src/metrics.ts +141 -0
  46. package/node_modules/pi-footer/src/presets.ts +383 -0
  47. package/node_modules/pi-footer/src/render.ts +114 -0
  48. package/node_modules/pi-footer/src/separators.ts +52 -0
  49. package/node_modules/pi-footer/src/types.ts +122 -0
  50. package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
  51. package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
  52. package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
  53. package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
  54. package/node_modules/pi-footer/src/ui/events.ts +35 -0
  55. package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
  56. package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
  57. package/node_modules/pi-footer/src/ui/fields.ts +252 -0
  58. package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
  59. package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
  60. package/node_modules/pi-footer/src/ui/layout.ts +17 -0
  61. package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
  62. package/node_modules/pi-footer/src/ui/model.ts +64 -0
  63. package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
  64. package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
  65. package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
  66. package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
  67. package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
  68. package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
  69. package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
  70. package/node_modules/pi-footer/src/ui/screen.ts +243 -0
  71. package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
  72. package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
  73. package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
  74. package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
  75. package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
  76. package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
  77. package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
  78. package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
  79. package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
  80. package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
  81. package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
  82. package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
  83. package/node_modules/pi-footer/src/ui/theme.ts +48 -0
  84. package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
  85. package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
  86. package/node_modules/pi-footer/src/ui.ts +66 -0
  87. package/node_modules/pi-footer/src/widgets/context.ts +28 -0
  88. package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
  89. package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
  90. package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
  91. package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
  92. package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
  93. package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
  94. package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
  95. package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
  96. package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
  97. package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
  98. package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
  99. package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
  100. package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
  101. package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
  102. package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
  103. package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
  104. package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
  105. package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
  106. package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
  107. package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
  108. package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
  109. package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
  110. package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
  111. package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
  112. package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
  113. package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
  114. package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
  115. package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
  116. package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
  117. package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
  118. package/node_modules/pi-footer/src/widgets/options.ts +122 -0
  119. package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
  120. package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
  121. package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
  122. package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
  123. package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
  124. package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
  125. package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
  126. package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
  127. package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
  128. package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
  129. package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
  130. package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
  131. package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
  132. package/node_modules/pi-footer/src/widgets/store.ts +26 -0
  133. package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
  134. package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
  135. package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
  136. package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
  137. package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
  138. package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
  139. package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
  140. package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
  141. package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
  142. package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
  143. package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
  144. package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
  145. package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
  146. package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
  147. package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
  148. package/node_modules/pi-footer/src/widgets/types.ts +181 -0
  149. package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
  150. package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
  151. package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
  152. package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
  153. package/node_modules/pi-lens/CHANGELOG.md +2440 -954
  154. package/node_modules/pi-lens/README.md +13 -0
  155. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  156. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  157. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  158. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  159. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  160. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  161. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  162. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  163. package/node_modules/pi-lens/dist/clients/biome-client.js +29 -25
  164. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  165. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  166. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  167. package/node_modules/pi-lens/dist/clients/bus-publish.js +10 -3
  168. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  169. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  170. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  171. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  172. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  173. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  174. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  175. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  176. package/node_modules/pi-lens/dist/clients/console-guard-install.js +14 -0
  177. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  178. package/node_modules/pi-lens/dist/clients/dead-code-client.js +2 -1
  179. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  180. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  181. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  182. package/node_modules/pi-lens/dist/clients/dependency-checker.js +347 -75
  183. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +61 -11
  184. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  185. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +63 -20
  186. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  187. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  188. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  189. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  190. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  191. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1085 -681
  192. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  193. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  194. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  195. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  196. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  197. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  198. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  199. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  200. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  201. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  202. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  203. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +14 -6
  204. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  205. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  206. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  207. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  208. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  209. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  210. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  211. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  212. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  213. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  214. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  215. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  216. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +277 -26
  217. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  218. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  219. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  220. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  221. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  222. package/node_modules/pi-lens/dist/clients/extension-log.js +139 -0
  223. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  224. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  225. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  226. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  227. package/node_modules/pi-lens/dist/clients/format-events-publish.js +16 -3
  228. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  229. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  230. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  231. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  232. package/node_modules/pi-lens/dist/clients/git-guard.js +681 -11
  233. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  234. package/node_modules/pi-lens/dist/clients/go-client.js +2 -1
  235. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +8 -0
  236. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  237. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  238. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  239. package/node_modules/pi-lens/dist/clients/installer/index.js +390 -99
  240. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  241. package/node_modules/pi-lens/dist/clients/jscpd-client.js +31 -54
  242. package/node_modules/pi-lens/dist/clients/knip-client.js +15 -36
  243. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  244. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  245. package/node_modules/pi-lens/dist/clients/latency-logger.js +23 -1
  246. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  247. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  248. package/node_modules/pi-lens/dist/clients/lens-events.js +27 -4
  249. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  250. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  251. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +30 -0
  252. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  253. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +1 -1
  254. package/node_modules/pi-lens/dist/clients/lsp/client.js +493 -48
  255. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  256. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +86 -0
  257. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  258. package/node_modules/pi-lens/dist/clients/lsp/index.js +1807 -780
  259. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  260. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  261. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  262. package/node_modules/pi-lens/dist/clients/lsp/server.js +196 -32
  263. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  264. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  265. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  266. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +123 -32
  267. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  268. package/node_modules/pi-lens/dist/clients/mcp/session.js +216 -8
  269. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  270. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  271. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  272. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  273. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  274. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  275. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  276. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  277. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  278. package/node_modules/pi-lens/dist/clients/pipeline.js +47 -13
  279. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  280. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  281. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  282. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  283. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  284. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  285. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  286. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  287. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  288. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  289. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  290. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  291. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  292. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  293. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  294. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  295. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  296. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  297. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  298. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  299. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  300. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  301. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  302. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +107 -73
  303. package/node_modules/pi-lens/dist/clients/runtime-context.js +36 -12
  304. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +74 -6
  305. package/node_modules/pi-lens/dist/clients/runtime-session.js +405 -117
  306. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  307. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +218 -23
  308. package/node_modules/pi-lens/dist/clients/runtime-turn.js +237 -64
  309. package/node_modules/pi-lens/dist/clients/rust-client.js +2 -1
  310. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  311. package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -1
  312. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  313. package/node_modules/pi-lens/dist/clients/sg-runner.js +284 -174
  314. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  315. package/node_modules/pi-lens/dist/clients/smells-rollup.js +224 -0
  316. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  317. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  318. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  319. package/node_modules/pi-lens/dist/clients/startup-timing.js +1 -1
  320. package/node_modules/pi-lens/dist/clients/test-runner-client.js +38 -8
  321. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  322. package/node_modules/pi-lens/dist/clients/tool-policy.js +388 -39
  323. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  324. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  325. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  326. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  327. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  328. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  329. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  330. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  331. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  332. package/node_modules/pi-lens/dist/clients/warm-attach.js +33 -13
  333. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  334. package/node_modules/pi-lens/dist/clients/word-index.js +391 -64
  335. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  336. package/node_modules/pi-lens/dist/index.js +49361 -36888
  337. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  338. package/node_modules/pi-lens/dist/mcp/server.js +211 -44
  339. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  340. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +200 -36
  341. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +153 -40
  342. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  343. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  344. package/node_modules/pi-lens/docs/agent-guide.md +2 -2
  345. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  346. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  347. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  348. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +5 -2
  349. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  350. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  351. package/node_modules/pi-lens/docs/fable.md +27 -0
  352. package/node_modules/pi-lens/docs/features.md +31 -2
  353. package/node_modules/pi-lens/docs/globalconfig.md +45 -1
  354. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  355. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  356. package/node_modules/pi-lens/docs/mcp.md +37 -4
  357. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  358. package/node_modules/pi-lens/docs/settings.md +12 -1
  359. package/node_modules/pi-lens/docs/tools.md +4 -1
  360. package/node_modules/pi-lens/docs/usage.md +18 -0
  361. package/node_modules/pi-lens/package.json +10 -10
  362. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  363. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  364. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  365. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  366. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  367. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  368. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  369. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  370. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  371. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  372. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  373. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  374. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +55 -2
  375. package/node_modules/pi-mcp-adapter/CHANGELOG.md +25 -0
  376. package/node_modules/pi-mcp-adapter/README.md +34 -3
  377. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  378. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  379. package/node_modules/pi-mcp-adapter/direct-tools.ts +54 -7
  380. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  381. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  382. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  383. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  384. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  385. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  386. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  387. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  388. package/node_modules/pi-mcp-adapter/metadata-cache.ts +27 -4
  389. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  390. package/node_modules/pi-mcp-adapter/package.json +3 -3
  391. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  392. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  393. package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
  394. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  395. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  396. package/node_modules/pi-mcp-adapter/tool-metadata.ts +42 -4
  397. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  398. package/node_modules/pi-mcp-adapter/types.ts +93 -27
  399. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  400. package/package.json +17 -13
  401. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  402. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  403. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  404. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  405. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  406. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  407. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  408. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -21,17 +21,23 @@ import { logCascade } from "../cascade-logger.js";
21
21
  import { getDiagnosticTracker } from "../diagnostic-tracker.js";
22
22
  import { isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
23
23
  import { classifyCascadeWaitTier } from "../lsp/wait-policy/index.js";
24
+ import { bindingStateLabel, } from "../lsp/diagnostic-binding.js";
24
25
  import { getServersForFileWithConfig } from "../lsp/config.js";
25
26
  import { getLSPService } from "../lsp/index.js";
26
27
  import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
27
28
  import { getProjectIgnoreMatcher } from "../file-utils.js";
28
- import { clearReviewGraphWorkspaceCache, getGraphImportChanges, getLastGraphBuildInfo, } from "../review-graph/builder.js";
29
+ import { resetAstGrepUnsupportedLanguageLog } from "./runners/ast-grep-napi.js";
30
+ import { isTestRoleCollateral } from "../collateral-test-role.js";
31
+ import { clearReviewGraphWorkspaceCache, getGraphBuildInfoForGraph, getGraphImportChanges, graphBuildInfoIsTrustworthy, } from "../review-graph/builder.js";
29
32
  import { buildReverseDependencyIndexFromGraph, getAffectedFilesFromIndex, patchReverseDependencyIndex, writeReverseDependencyIndexToSnapshot, } from "../reverse-deps.js";
30
33
  import { buildOrUpdateGraph, computeImpactCascade, computeTransitiveImpact, formatImpactCascade, } from "../review-graph/service.js";
31
34
  import { clearModuleGraphCache } from "../review-graph/workspace-modules.js";
35
+ import { releaseWorkspaceTopologyIdleTimers } from "../workspace-topology.js";
32
36
  import { RUNTIME_CONFIG } from "../runtime-config.js";
33
37
  import { findCompiledClassesDir, hasJavaBuildDescriptor, } from "../tool-policy.js";
34
38
  import { removeWordIndexDocument, updateWordIndexDocument, WORD_INDEX_MAX_BYTES, } from "../word-index.js";
39
+ import { reconcileCascadeNeighborLspErrors } from "../widget-state.js";
40
+ import { findAuxiliaryProfileForSource } from "./auxiliary-lsp.js";
35
41
  // Register fact providers. All register eagerly here (the dispatch entry) — the
36
42
  // tree-sitter-backed providers included, since the parsing stack loads
37
43
  // `web-tree-sitter` lazily inside client.init(), not at module import, so it
@@ -292,6 +298,7 @@ export function getDispatchGroupsForKind(kind, pi) {
292
298
  export function resetDispatchBaselines(cwd) {
293
299
  if (cwd)
294
300
  applyProjectLensConfig(cwd);
301
+ resetAstGrepUnsupportedLanguageLog();
295
302
  sessionFacts.clearAll();
296
303
  resetSessionSlopScore();
297
304
  clearCoverageNoticeState();
@@ -321,6 +328,14 @@ export function getCascadeSessionStats() {
321
328
  }
322
329
  const neighborTouchCache = new Map();
323
330
  const recentlyCleanNeighborCache = new Map();
331
+ /** O(1) entry counts of this module's turn-bounded caches (#1123 item 2
332
+ * memory attribution) — both are `Map.size` reads, never iterated. */
333
+ export function getDispatchCascadeCacheStats() {
334
+ return {
335
+ neighborTouchCacheSize: neighborTouchCache.size,
336
+ recentlyCleanNeighborCacheSize: recentlyCleanNeighborCache.size,
337
+ };
338
+ }
324
339
  const RECENTLY_CLEAN_TTL_TURNS = 5;
325
340
  // B10: tracks files that were the *primary* edited file this turn.
326
341
  // These are excluded from cascade neighbor results — their own pipeline run
@@ -342,14 +357,136 @@ function ensureCascadeTurnScope(turnSeq) {
342
357
  const CASCADE_TTL_MS = 240_000;
343
358
  const MAX_PER_FILE = RUNTIME_CONFIG.pipeline.cascadeMaxDiagnosticsPerFile;
344
359
  const MAX_FILES = RUNTIME_CONFIG.pipeline.cascadeMaxFiles;
360
+ /**
361
+ * The genuine language-server ERROR diagnostics from a cascade neighbor
362
+ * re-check, ready to reconcile into the footer widget (#1093).
363
+ *
364
+ * Drops any diagnostic whose `source` matches an auxiliary-LSP profile
365
+ * (opengrep/ast-grep/zizmor/typos — `findAuxiliaryProfileForSource`). The
366
+ * cascade's `convertLspDiagnostics` tags everything `tool: "lsp"`,
367
+ * `semantic: "blocking"` and — unlike every OTHER widget writer
368
+ * (`runners/lsp.ts`, `tools/lsp-diagnostics.ts`, `tools/lens-diagnostics.ts`) —
369
+ * never runs `retagAuxiliaryDiagnostics`. Since `getAllDiagnostics` /
370
+ * `touchFile({clientScope:"all"})` include the auxiliary servers' findings,
371
+ * writing them here would (a) DOUBLE-COUNT a neighbor's own correctly-tagged aux
372
+ * entry and (b) ESCALATE an advisory/suppressed aux finding into a blocking
373
+ * `tool:"lsp"` error, bypassing aux policy (semantic downgrade, native
374
+ * `# nosemgrep`/`zizmor:ignore` suppression, `skipTestFiles`). Aux findings are
375
+ * per-file lint/security signals the neighbor's OWN per-edit runners own; they
376
+ * are not cross-file impact, so the cascade simply excludes them and the merge
377
+ * (`reconcileCascadeNeighborLspErrors`) preserves the neighbor's existing aux
378
+ * entries untouched. This keeps `isLspErrorEntry`'s "tool === 'lsp' uniquely
379
+ * identifies a genuine language-server entry" contract true for what we write.
380
+ *
381
+ * The DISPLAY list (`diags` at each call site) is deliberately left as-is —
382
+ * that is pre-existing cascade output behavior, out of scope for #1093.
383
+ */
384
+ function cascadeReconcilableLspErrors(rawDiags, neighborPath) {
385
+ return convertLspDiagnostics(rawDiags
386
+ .filter((d) => d.severity === 1 && !findAuxiliaryProfileForSource(d.source))
387
+ .slice(0, MAX_PER_FILE), neighborPath);
388
+ }
389
+ /**
390
+ * Read the content `binding` verdict off a diagnostics result (#1095).
391
+ *
392
+ * `touchFile` carries `binding` as an EXPLICIT enumerable field on its
393
+ * `TouchFileResult` wrapper (#1179), so it survives any copy of `.diags`.
394
+ * `getAllDiagnostics` still attaches `binding` as a lazy, disk-verifying
395
+ * NON-enumerable getter on each Map entry — reading it directly off the
396
+ * producer's own entry (never a spread/clone, which drops non-enumerables)
397
+ * triggers the disk verify exactly once (memoized per entry). Accepts either
398
+ * shape; returns `undefined` when no binding was attached at all (a partially-
399
+ * mocked client, a non-collecting touch) — indistinguishable from "unknown" at
400
+ * every call site, which is the intended pre-#1095 fall-through.
401
+ */
402
+ function readBoundToCurrentDisk(rawDiags) {
403
+ return rawDiags
404
+ ?.binding?.boundToCurrentDisk;
405
+ }
406
+ /**
407
+ * #1095: is this active-touch result a CONFIRMED observation of the neighbor's
408
+ * current on-disk LSP-error state — safe to reconcile into the footer widget and
409
+ * (when clean) to seed the recently-clean neighbor cache?
410
+ *
411
+ * Composes the two independent disqualifiers into ONE clearly-named predicate so
412
+ * a future flag cannot be silently missed at just one of the several gate sites:
413
+ * - `inconclusive` (#1093/#571): the notify/diagnostics wait lapsed its deadline,
414
+ * so a resolved `[]` is NOT a confirmed clean (the #533 false-clean trap).
415
+ * - `binding.boundToCurrentDisk === false` (#1095): the diagnostics were computed
416
+ * against a DIFFERENT disk state than what is on disk now (the server's view
417
+ * diverged / a pre-fix buffer) — not an observation of current disk. `true` and
418
+ * `"unknown"` both pass; `"unknown"` preserves pre-#1095 behavior for a
419
+ * version-less server exactly.
420
+ *
421
+ * #1179: both flags are EXPLICIT enumerable fields on the `touchFile`
422
+ * `TouchFileResult` wrapper — read them off the wrapper (`rawDiags`), whose
423
+ * `.diags` a downstream `.filter()`/copy operates on without touching the flags.
424
+ */
425
+ function readInconclusive(rawDiags) {
426
+ return rawDiags?.inconclusive === true;
427
+ }
428
+ function isConfirmedTouch(rawDiags) {
429
+ return !readInconclusive(rawDiags) && readBoundToCurrentDisk(rawDiags) !== false;
430
+ }
345
431
  const reverseDepsIndexCache = new Map();
432
+ const REVERSE_DEPS_MAX_WARM_ROOTS = 8;
433
+ const REVERSE_DEPS_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
434
+ function reverseDepsIdleEvictMs() {
435
+ const value = Number.parseInt(process.env.PI_LENS_REVERSE_DEPS_IDLE_EVICT_MS ?? "", 10);
436
+ return Number.isSafeInteger(value) && value > 0 ? value : REVERSE_DEPS_IDLE_EVICT_MS_DEFAULT;
437
+ }
438
+ function deleteReverseDepsEntry(key) {
439
+ const entry = reverseDepsIndexCache.get(key);
440
+ if (entry?.idleTimer !== undefined)
441
+ clearTimeout(entry.idleTimer);
442
+ if (entry)
443
+ entry.idleTimer = undefined;
444
+ reverseDepsIndexCache.delete(key);
445
+ }
446
+ function touchReverseDepsEntry(key, entry, armIdleTimer = true) {
447
+ entry.lastUsedAt = Date.now();
448
+ if (entry.idleTimer !== undefined)
449
+ clearTimeout(entry.idleTimer);
450
+ entry.idleTimer = undefined;
451
+ if (!armIdleTimer)
452
+ return;
453
+ const stamp = entry.lastUsedAt;
454
+ entry.idleTimer = setTimeout(() => {
455
+ if (reverseDepsIndexCache.get(key) !== entry || entry.lastUsedAt !== stamp)
456
+ return;
457
+ deleteReverseDepsEntry(key);
458
+ }, reverseDepsIdleEvictMs());
459
+ entry.idleTimer.unref?.();
460
+ }
461
+ function setReverseDepsEntry(key, entry, armIdleTimer = true) {
462
+ const resident = { ...entry, lastUsedAt: Date.now() };
463
+ reverseDepsIndexCache.set(key, resident);
464
+ touchReverseDepsEntry(key, resident, armIdleTimer);
465
+ while (reverseDepsIndexCache.size > REVERSE_DEPS_MAX_WARM_ROOTS) {
466
+ const victim = [...reverseDepsIndexCache.entries()].sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)[0];
467
+ if (!victim)
468
+ break;
469
+ deleteReverseDepsEntry(victim[0]);
470
+ }
471
+ }
346
472
  function reverseDepsReuseEnabled() {
347
473
  const raw = process.env.PI_LENS_REVERSE_DEPS_REUSE;
348
474
  return raw !== "0" && raw !== "false";
349
475
  }
350
476
  /** Test-reset hook — mirrors clearReviewGraphWorkspaceCache's scope. */
351
477
  export function clearReverseDepsIndexCache() {
352
- reverseDepsIndexCache.clear();
478
+ for (const key of reverseDepsIndexCache.keys())
479
+ deleteReverseDepsEntry(key);
480
+ }
481
+ /** Test-only visibility for Tier-2 eviction/recovery tests. */
482
+ export function _getReverseDepsIndexCacheKeysForTests() {
483
+ return [...reverseDepsIndexCache.entries()]
484
+ .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
485
+ .map(([key]) => key);
486
+ }
487
+ /** Test-only seed that still exercises the production timer/cap seam. */
488
+ export function _seedReverseDepsIndexCacheForTests(key, index, generation) {
489
+ setReverseDepsEntry(key, { index, savedToSnapshot: false, generation });
353
490
  }
354
491
  // Bounded transitive cascade (#162): expand neighbour derivation beyond the
355
492
  // one-hop importers/callers to depth-2 dependents, so an edit's blast radius
@@ -460,758 +597,994 @@ function updateWordIndexForCascade(args) {
460
597
  onUpdated?.(wordIndex);
461
598
  }
462
599
  export async function computeCascadeForFile(filePath, cwd, options = {}) {
463
- const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState, fileContent, wordIndex, onWordIndexUpdated, } = options;
464
- ensureCascadeTurnScope(turnSeq);
465
- if (hasBlockers) {
466
- logCascade({
467
- phase: "cascade_skip",
468
- filePath,
469
- reason: "primary_has_blockers",
470
- });
471
- return {
472
- filePath,
473
- result: undefined,
474
- neighborCount: 0,
475
- diagnosticCount: 0,
476
- skipReason: "blockers",
477
- };
478
- }
479
- const fileKind = detectFileKind(filePath);
480
- if (!fileKind) {
481
- logCascade({ phase: "cascade_skip", filePath, reason: "non_code_file" });
482
- return {
483
- filePath,
484
- result: undefined,
485
- neighborCount: 0,
486
- diagnosticCount: 0,
487
- skipReason: "non_code",
488
- };
489
- }
490
- const normalizedFile = resolveRunnerPath(cwd, filePath);
491
- const normalizedFileKey = normalizeMapKey(normalizedFile);
492
- // B10: record this file as a primary edit so later cascade calls in the same
493
- // turn won't show it as a neighbor.
494
- primaryFilesThisTurn.add(normalizedFileKey);
495
- // #348 phase 2: warm per-edit word-index maintenance, review-graph style —
496
- // update at the SAME seam as the graph rebuild below, using content the
497
- // pipeline already read (no extra I/O). See computeCascadeForFile's
498
- // `wordIndex`/`fileContent` doc comments for the cold/no-forward-index
499
- // no-op rules.
500
- //
501
- // Keyed by `path.resolve(filePath)` (native separators, tool-input casing).
502
- // Since #1025 the word index's path maps are `PathKeyedMap`s that fold every
503
- // key through `wordIndexKey` (`normalizeEphemeralMapKey` — slash-fold +
504
- // win32-lowercase) INTERNALLY, so this per-edit key and the build path's own
505
- // walk-derived key (`collectWordIndexDocs` → `collectSourceFilesAsync`)
506
- // collapse to the same entry regardless of on-disk-vs-input casing/separator.
507
- // The old hazard — this update silently orphaning a SECOND entry next to the
508
- // walker's original-cased one — is now structurally impossible at the map
509
- // layer, so this seam no longer has to hand-match the build path's key shape.
510
- updateWordIndexForCascade({
511
- wordIndex,
512
- filePath: nodePath.resolve(filePath),
513
- content: fileContent,
514
- onUpdated: onWordIndexUpdated,
515
- dbg,
516
- });
517
- let impact = {
518
- filePath: normalizedFile,
519
- changedSymbols: [],
520
- directImporters: [],
521
- directCallers: [],
522
- neighborFiles: [],
523
- riskFlags: [],
524
- };
525
- let sortedNeighbors = [];
526
- let importerSet = new Set();
527
- let callerSet = new Set();
528
- let referenceCount = 0;
529
- if (CASCADE_GRAPH_KINDS.has(fileKind)) {
530
- const graphStart = Date.now();
531
- const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
532
- const graphMs = Date.now() - graphStart;
533
- // #459: the reuse decision keys on graph.buildGeneration — a stamp that
534
- // travels WITH the graph instance this cascade holds. Deliberately NOT the
535
- // global last-build-info slot: post-#450 cascades overlap, and another
536
- // cascade's cache-hit build can overwrite that slot (graphChanged:false)
537
- // between this build mutating the graph and this read — which would turn a
538
- // changed graph into a spurious reuse of a stale index that steady-state
539
- // cache hits then never heal. Generation equality can't be clobbered into
540
- // a false positive: a graph-mutating build always mints a new generation.
541
- // An unstamped graph (mode "skipped") always rebuilds.
542
- const graphBuildInfo = getLastGraphBuildInfo();
543
- const workspaceKey = normalizeMapKey(cwd);
544
- const cachedReverseDeps = reverseDepsIndexCache.get(workspaceKey);
545
- const importDelta = getGraphImportChanges(graph);
546
- // A one-step delta is only usable against an index cached at exactly the
547
- // delta's predecessor generation. Builds minted elsewhere (mcp analyze,
548
- // lens-map, session warm) advance generations whose import changes this
549
- // delta does not cover — reusing/patching across that gap stamps a stale
550
- // index as current, and generation equality then hides the loss forever
551
- // (#939 review finding 1).
552
- const deltaContiguous = importDelta !== undefined &&
553
- importDelta.fromGeneration !== undefined &&
554
- cachedReverseDeps !== undefined &&
555
- cachedReverseDeps.generation === importDelta.fromGeneration;
556
- const importChanges = deltaContiguous ? importDelta.changes : undefined;
557
- const importsChanged = importChanges?.some((change) => change.existedBefore !== change.existsAfter ||
558
- change.priorTargets.length !== change.newTargets.length ||
559
- change.priorTargets.some((target, index) => target !== change.newTargets[index]));
560
- const canReuse = reverseDepsReuseEnabled() &&
561
- cachedReverseDeps !== undefined &&
562
- ((graph.buildGeneration !== undefined &&
563
- cachedReverseDeps.generation === graph.buildGeneration) ||
564
- (importChanges !== undefined && !importsChanged));
565
- let reverseDepsIndex;
566
- let reverseDepsSaved;
567
- if (canReuse && cachedReverseDeps) {
568
- reverseDepsIndex = cachedReverseDeps.index;
569
- reverseDepsSaved = cachedReverseDeps.savedToSnapshot;
570
- cachedReverseDeps.generation = graph.buildGeneration;
600
+ const reverseDepsTimersToRelease = new Set();
601
+ const reverseDepsEntriesAtStart = new Set(reverseDepsIndexCache.values());
602
+ try {
603
+ const { hasBlockers = false, dbg, turnSeq = 0, writeSeq, seqState, fileContent, wordIndex, onWordIndexUpdated, } = options;
604
+ ensureCascadeTurnScope(turnSeq);
605
+ if (hasBlockers) {
571
606
  logCascade({
572
- phase: "reverse_deps_cache",
607
+ phase: "cascade_skip",
573
608
  filePath,
574
- durationMs: Date.now() - graphStart,
575
- metadata: {
576
- action: "reused_unchanged",
577
- savedToSnapshot: reverseDepsSaved,
578
- importsFileCount: Object.keys(reverseDepsIndex.imports).length,
579
- importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
580
- },
581
- });
582
- }
583
- else if (cachedReverseDeps && importChanges && importsChanged) {
584
- reverseDepsIndex = patchReverseDependencyIndex(cachedReverseDeps.index, importChanges);
585
- reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
586
- cwd,
587
- index: reverseDepsIndex,
588
- dbg,
609
+ reason: "primary_has_blockers",
589
610
  });
590
- reverseDepsIndexCache.set(workspaceKey, {
591
- index: reverseDepsIndex,
592
- savedToSnapshot: reverseDepsSaved,
593
- generation: graph.buildGeneration,
594
- });
595
- logCascade({
596
- phase: "reverse_deps_cache",
611
+ return {
597
612
  filePath,
598
- durationMs: Date.now() - graphStart,
599
- metadata: {
600
- action: "patched_import_changes",
601
- changedFileCount: importChanges.length,
602
- savedToSnapshot: reverseDepsSaved,
603
- },
604
- });
613
+ result: undefined,
614
+ neighborCount: 0,
615
+ diagnosticCount: 0,
616
+ skipReason: "blockers",
617
+ };
605
618
  }
606
- else {
607
- reverseDepsIndex = buildReverseDependencyIndexFromGraph({
608
- cwd,
609
- graph,
610
- });
611
- reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
612
- cwd,
613
- index: reverseDepsIndex,
614
- dbg,
615
- });
616
- reverseDepsIndexCache.set(workspaceKey, {
617
- index: reverseDepsIndex,
618
- savedToSnapshot: reverseDepsSaved,
619
- generation: graph.buildGeneration,
620
- });
621
- logCascade({
622
- phase: "reverse_deps_cache",
619
+ const fileKind = detectFileKind(filePath);
620
+ if (!fileKind) {
621
+ logCascade({ phase: "cascade_skip", filePath, reason: "non_code_file" });
622
+ return {
623
623
  filePath,
624
- durationMs: Date.now() - graphStart,
625
- metadata: {
626
- action: "refresh_from_review_graph",
627
- savedToSnapshot: reverseDepsSaved,
628
- importsFileCount: Object.keys(reverseDepsIndex.imports).length,
629
- importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
630
- importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
631
- },
632
- });
633
- }
634
- // Count files represented in the graph (nodes with a filePath).
635
- const graphFileCount = new Set([...graph.nodes.values()].flatMap((n) => n.filePath ? [n.filePath] : [])).size;
636
- logCascade({
637
- phase: "graph_build",
638
- filePath,
639
- graphBuiltMs: graphMs,
640
- graphReused: graphBuildInfo.reused,
641
- graphNodeCount: graph.nodes.size,
642
- graphFileCount,
643
- graphChangedSymbolCount: (graph.changedSymbolsByFile.get(normalizedFileKey) ?? []).length,
644
- metadata: {
645
- graphBuildMode: graphBuildInfo.mode,
646
- skipReason: graphBuildInfo.skipReason,
647
- sourceFileCount: graphBuildInfo.sourceFileCount,
648
- maxFileCount: graphBuildInfo.maxFileCount,
649
- // #451: when the seq fast path fell back (or was skipped), why — so
650
- // cascade.log surfaces the fast-path hit/miss rate.
651
- seqFastpathFallback: graphBuildInfo.seqFastpathFallback,
652
- },
653
- });
654
- impact = computeImpactCascade(graph, normalizedFile, cwd);
655
- // #1023: buildOrUpdateGraph returns an EMPTY graph (seeded only with the
656
- // changed file's own symbols) when the repo is over
657
- // PI_LENS_REVIEW_GRAPH_MAX_FILES (`too_many_files`) or the root is unsafe
658
- // (`unsafe_root`) — both stamp mode "skipped" on the already-read
659
- // graphBuildInfo. Computing impact against that empty graph yields zero
660
- // neighbors for EVERY edit, indistinguishable from a genuine clean leaf.
661
- // Thread the ALREADY-KNOWN degraded state (never re-derived) onto the
662
- // result so the turn-end seam surfaces an honest advisory instead of a
663
- // silent all-clear (#533). Keyed strictly off the degraded build mode —
664
- // NOT off `neighborFiles.length === 0` — so a healthy build with a real
665
- // empty dependent set stays silent (no crying wolf).
666
- if (graphBuildInfo.mode === "skipped" && !impact.indeterminate) {
667
- impact.indeterminate = {
668
- reason: "graph_degraded",
669
- detail: graphBuildInfo.skipReason === "too_many_files"
670
- ? `review graph disabled — ${graphBuildInfo.sourceFileCount ?? "?"} files over the ${graphBuildInfo.maxFileCount ?? "?"} cap`
671
- : graphBuildInfo.skipReason === "unsafe_root"
672
- ? "review graph skipped — workspace root is at/above home dir"
673
- : `review graph unavailable (${graphBuildInfo.skipReason ?? "skipped"})`,
674
- sourceFileCount: graphBuildInfo.sourceFileCount,
675
- maxFileCount: graphBuildInfo.maxFileCount,
624
+ result: undefined,
625
+ neighborCount: 0,
626
+ diagnosticCount: 0,
627
+ skipReason: "non_code",
676
628
  };
677
629
  }
678
- const reverseDepNeighbors = getAffectedFilesFromIndex(reverseDepsIndex, normalizedFile, 1, MAX_FILES * 2);
679
- logCascade({
680
- phase: "reverse_deps_cache",
681
- filePath,
682
- metadata: {
683
- action: "merge_neighbors",
684
- depth: 1,
685
- neighborCount: reverseDepNeighbors.length,
686
- neighbors: reverseDepNeighbors.slice(0, 10),
687
- },
630
+ const normalizedFile = resolveRunnerPath(cwd, filePath);
631
+ const normalizedFileKey = normalizeMapKey(normalizedFile);
632
+ // B10: record this file as a primary edit so later cascade calls in the same
633
+ // turn won't show it as a neighbor.
634
+ primaryFilesThisTurn.add(normalizedFileKey);
635
+ // #348 phase 2: warm per-edit word-index maintenance, review-graph style —
636
+ // update at the SAME seam as the graph rebuild below, using content the
637
+ // pipeline already read (no extra I/O). See computeCascadeForFile's
638
+ // `wordIndex`/`fileContent` doc comments for the cold/no-forward-index
639
+ // no-op rules.
640
+ //
641
+ // Keyed by `path.resolve(filePath)` (native separators, tool-input casing).
642
+ // Since #1025 the word index's path maps are `PathKeyedMap`s that fold every
643
+ // key through `wordIndexKey` (`normalizeEphemeralMapKey` — slash-fold +
644
+ // win32-lowercase) INTERNALLY, so this per-edit key and the build path's own
645
+ // walk-derived key (`collectWordIndexDocs` → `collectSourceFilesAsync`)
646
+ // collapse to the same entry regardless of on-disk-vs-input casing/separator.
647
+ // The old hazard — this update silently orphaning a SECOND entry next to the
648
+ // walker's original-cased one — is now structurally impossible at the map
649
+ // layer, so this seam no longer has to hand-match the build path's key shape.
650
+ updateWordIndexForCascade({
651
+ wordIndex,
652
+ filePath: nodePath.resolve(filePath),
653
+ content: fileContent,
654
+ onUpdated: onWordIndexUpdated,
655
+ dbg,
688
656
  });
689
- if (reverseDepNeighbors.length > 0) {
690
- impact.directImporters = [
691
- ...new Set([...impact.directImporters, ...reverseDepNeighbors]),
692
- ];
693
- impact.neighborFiles = [
694
- ...new Set([...impact.neighborFiles, ...reverseDepNeighbors]),
695
- ];
696
- logCascade({
697
- phase: "neighbor_snapshot",
698
- filePath,
699
- neighborFile: "[reverse-deps-cache]",
700
- diagnosticCount: reverseDepNeighbors.length,
701
- autoPropagate: false,
702
- metadata: { reverseDepsCache: true },
703
- });
704
- }
705
- // Symbol-level blast radius via LSP references (precision upgrade over
706
- // file-level import edges). Only when changed symbols are detected.
707
- // Keep the budget tight: 750ms per symbol, 1200ms total, max 3 symbols.
708
- if (impact.changedSymbols.length > 0) {
709
- const lspService = getLSPService();
710
- const symbolNodeIds = graph.symbolNodesByFile.get(normalizedFileKey) ?? [];
711
- const refFiles = new Set();
712
- const refsStart = Date.now();
713
- for (const symbolName of impact.changedSymbols.slice(0, 3)) {
714
- const symbolNodeId = symbolNodeIds.find((id) => {
715
- const node = graph.nodes.get(id);
716
- return node?.symbolName === symbolName;
657
+ let impact = {
658
+ filePath: normalizedFile,
659
+ changedSymbols: [],
660
+ directImporters: [],
661
+ directCallers: [],
662
+ neighborFiles: [],
663
+ riskFlags: [],
664
+ };
665
+ let sortedNeighbors = [];
666
+ let importerSet = new Set();
667
+ let callerSet = new Set();
668
+ let referenceCount = 0;
669
+ if (CASCADE_GRAPH_KINDS.has(fileKind)) {
670
+ const graphStart = Date.now();
671
+ const graph = await buildOrUpdateGraph(cwd, [normalizedFile], sessionFacts, seqState);
672
+ const graphMs = Date.now() - graphStart;
673
+ // #459: the reuse decision keys on graph.buildGeneration — a stamp that
674
+ // travels WITH the graph instance this cascade holds. Deliberately NOT the
675
+ // global last-build-info slot: post-#450 cascades overlap, and another
676
+ // cascade's cache-hit build can overwrite that slot (graphChanged:false)
677
+ // between this build mutating the graph and this read — which would turn a
678
+ // changed graph into a spurious reuse of a stale index that steady-state
679
+ // cache hits then never heal. Generation equality can't be clobbered into
680
+ // a false positive: a graph-mutating build always mints a new generation.
681
+ // An unstamped graph (mode "skipped") always rebuilds.
682
+ const graphBuildInfo = getGraphBuildInfoForGraph(graph);
683
+ // #1179 fail-closed: `getGraphBuildInfoForGraph` falls back to the global
684
+ // last-build slot on a WeakMap identity miss, which — once some real build
685
+ // has stamped — could carry a SIBLING graph's healthy `mode: "cached"`. The
686
+ // degraded-marker gate below must not read a sibling's state, so it checks
687
+ // trustworthiness and, when the slot cannot be trusted for THIS graph, treats
688
+ // coverage as unknown (degraded) rather than clean. Always trustworthy on the
689
+ // live path — every `_doBuildGraph` return stamps the graph before the cascade
690
+ // reads it — so this is inert today and only hardens a future unstamped graph.
691
+ const graphBuildInfoTrustworthy = graphBuildInfoIsTrustworthy(graph);
692
+ const workspaceKey = normalizeMapKey(cwd);
693
+ const cachedReverseDeps = reverseDepsIndexCache.get(workspaceKey);
694
+ if (cachedReverseDeps) {
695
+ touchReverseDepsEntry(workspaceKey, cachedReverseDeps, false);
696
+ reverseDepsTimersToRelease.add(workspaceKey);
697
+ }
698
+ const importDelta = getGraphImportChanges(graph);
699
+ // A one-step delta is only usable against an index cached at exactly the
700
+ // delta's predecessor generation. Builds minted elsewhere (mcp analyze,
701
+ // lens-map, session warm) advance generations whose import changes this
702
+ // delta does not cover — reusing/patching across that gap stamps a stale
703
+ // index as current, and generation equality then hides the loss forever
704
+ // (#939 review finding 1).
705
+ const deltaContiguous = importDelta !== undefined &&
706
+ importDelta.fromGeneration !== undefined &&
707
+ cachedReverseDeps !== undefined &&
708
+ cachedReverseDeps.generation === importDelta.fromGeneration;
709
+ const importChanges = deltaContiguous ? importDelta.changes : undefined;
710
+ const importsChanged = importChanges?.some((change) => change.existedBefore !== change.existsAfter ||
711
+ change.priorTargets.length !== change.newTargets.length ||
712
+ change.priorTargets.some((target, index) => target !== change.newTargets[index]));
713
+ const canReuse = reverseDepsReuseEnabled() &&
714
+ cachedReverseDeps !== undefined &&
715
+ ((graph.buildGeneration !== undefined &&
716
+ cachedReverseDeps.generation === graph.buildGeneration) ||
717
+ (importChanges !== undefined && !importsChanged));
718
+ let reverseDepsIndex;
719
+ let reverseDepsSaved;
720
+ if (canReuse && cachedReverseDeps) {
721
+ reverseDepsIndex = cachedReverseDeps.index;
722
+ reverseDepsSaved = cachedReverseDeps.savedToSnapshot;
723
+ cachedReverseDeps.generation = graph.buildGeneration;
724
+ logCascade({
725
+ phase: "reverse_deps_cache",
726
+ filePath,
727
+ durationMs: Date.now() - graphStart,
728
+ metadata: {
729
+ action: "reused_unchanged",
730
+ savedToSnapshot: reverseDepsSaved,
731
+ importsFileCount: Object.keys(reverseDepsIndex.imports).length,
732
+ importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
733
+ },
717
734
  });
718
- if (!symbolNodeId)
719
- continue;
720
- const node = graph.nodes.get(symbolNodeId);
721
- const line = Number(node?.metadata?.line ?? 0);
722
- const column = Number(node?.metadata?.column ?? 0);
723
- if (line <= 0)
724
- continue;
725
- try {
726
- const refs = await Promise.race([
727
- lspService.references(normalizedFile, line - 1, column - 1, false),
728
- new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 750)),
729
- ]);
730
- for (const ref of refs) {
731
- let resolved;
732
- try {
733
- resolved = ref.uri.startsWith("file://")
734
- ? fileURLToPath(ref.uri)
735
- : ref.uri;
736
- }
737
- catch {
738
- continue;
739
- }
740
- if (normalizeMapKey(resolved) !== normalizedFileKey &&
741
- nodeFs.existsSync(resolved)) {
742
- refFiles.add(normalizeMapKey(resolved));
743
- }
744
- }
745
- }
746
- catch {
747
- // Timeout or LSP error — fall back to import-graph neighbors
748
- }
749
- if (Date.now() - refsStart > 1200)
750
- break; // Hard ceiling
751
735
  }
752
- if (refFiles.size > 0) {
753
- impact.neighborFiles = [
754
- ...new Set([...impact.neighborFiles, ...refFiles]),
755
- ];
736
+ else if (cachedReverseDeps && importChanges && importsChanged) {
737
+ reverseDepsIndex = patchReverseDependencyIndex(cachedReverseDeps.index, importChanges);
738
+ reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
739
+ cwd,
740
+ index: reverseDepsIndex,
741
+ dbg,
742
+ });
743
+ setReverseDepsEntry(workspaceKey, {
744
+ index: reverseDepsIndex,
745
+ savedToSnapshot: reverseDepsSaved,
746
+ generation: graph.buildGeneration,
747
+ }, false);
748
+ reverseDepsTimersToRelease.add(workspaceKey);
756
749
  logCascade({
757
- phase: "neighbor_snapshot",
750
+ phase: "reverse_deps_cache",
758
751
  filePath,
759
- neighborFile: "[lsp-references]",
760
- diagnosticCount: refFiles.size,
761
- durationMs: Date.now() - refsStart,
762
- autoPropagate: false,
763
- metadata: { lspReferences: true },
752
+ durationMs: Date.now() - graphStart,
753
+ metadata: {
754
+ action: "patched_import_changes",
755
+ changedFileCount: importChanges.length,
756
+ savedToSnapshot: reverseDepsSaved,
757
+ },
764
758
  });
765
759
  }
766
- }
767
- // Bounded transitive expansion: add depth>1 dependents (indirect
768
- // importers/callers/referencers) so the blast radius isn't limited to one
769
- // hop. The one-hop sets above remain the floor (they sort first); these
770
- // fill the remaining budget. Graph BFS is in-memory + capped.
771
- if (CASCADE_TRANSITIVE_DEPTH > 1) {
772
- const transitive = computeTransitiveImpact(graph, normalizedFile, {
773
- maxDepth: CASCADE_TRANSITIVE_DEPTH,
774
- maxHits: CASCADE_NEIGHBOUR_BUDGET,
775
- });
776
- const added = [
777
- ...new Set(transitive.hits
778
- .map((hit) => hit.file)
779
- .filter((file) => file && normalizeMapKey(file) !== normalizedFileKey)),
780
- ].filter((file) => !impact.neighborFiles.includes(file));
781
- if (added.length > 0) {
782
- impact.neighborFiles = [...impact.neighborFiles, ...added];
760
+ else {
761
+ reverseDepsIndex = buildReverseDependencyIndexFromGraph({
762
+ cwd,
763
+ graph,
764
+ });
765
+ reverseDepsSaved = writeReverseDependencyIndexToSnapshot({
766
+ cwd,
767
+ index: reverseDepsIndex,
768
+ dbg,
769
+ });
770
+ setReverseDepsEntry(workspaceKey, {
771
+ index: reverseDepsIndex,
772
+ savedToSnapshot: reverseDepsSaved,
773
+ generation: graph.buildGeneration,
774
+ }, false);
775
+ reverseDepsTimersToRelease.add(workspaceKey);
783
776
  logCascade({
784
- phase: "neighbor_snapshot",
777
+ phase: "reverse_deps_cache",
785
778
  filePath,
786
- neighborFile: "[transitive-impact]",
787
- diagnosticCount: added.length,
788
- autoPropagate: false,
779
+ durationMs: Date.now() - graphStart,
789
780
  metadata: {
790
- transitive: true,
791
- maxDepth: CASCADE_TRANSITIVE_DEPTH,
792
- maxDepthReached: transitive.maxDepthReached,
793
- truncated: transitive.truncated,
781
+ action: "refresh_from_review_graph",
782
+ savedToSnapshot: reverseDepsSaved,
783
+ importsFileCount: Object.keys(reverseDepsIndex.imports).length,
784
+ importedByFileCount: Object.keys(reverseDepsIndex.importedBy).length,
785
+ importEdgeCount: Object.values(reverseDepsIndex.imports).reduce((total, imports) => total + imports.length, 0),
794
786
  },
795
787
  });
796
788
  }
797
- }
798
- // Sort by relationship strength (B6) then cap to the neighbour budget.
799
- // directImporters are most impactful, then callers, then reference edges.
800
- importerSet = new Set(impact.directImporters);
801
- callerSet = new Set(impact.directCallers);
802
- // neighbors that are neither direct importers nor callers are reference-edge neighbors
803
- const importerOrCallerSet = new Set([
804
- ...impact.directImporters,
805
- ...impact.directCallers,
806
- ]);
807
- referenceCount = impact.neighborFiles.filter((n) => !importerOrCallerSet.has(n)).length;
808
- sortedNeighbors = [...impact.neighborFiles]
809
- .filter((n) => nodeFs.existsSync(n))
810
- .filter((n) => !isExternalOrVendorFile(n, cwd))
811
- // Honour the project's ignore config: a user-ignored neighbour (e.g.
812
- // `**/*.test.ts`) must not surface as collateral cascade noise (#297).
813
- .filter((n) => !isIgnoredCascadeNeighbor(n, cwd))
814
- // B10: exclude files already edited as primary this turn — their own pipeline
815
- // run is the authoritative diagnostic source; showing them as neighbors is noise.
816
- .filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
817
- .sort((a, b) => {
818
- const rank = (p) => importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
819
- return rank(a) - rank(b);
820
- })
821
- .slice(0, CASCADE_NEIGHBOUR_BUDGET);
822
- }
823
- else {
824
- logCascade({
825
- phase: "cascade_skip",
826
- filePath,
827
- reason: "unsupported_graph_kind",
828
- metadata: { fileKind },
829
- });
830
- return {
831
- filePath,
832
- result: undefined,
833
- neighborCount: 0,
834
- diagnosticCount: 0,
835
- skipReason: "non_code",
836
- };
837
- }
838
- logCascade({
839
- phase: "neighbors_computed",
840
- filePath,
841
- neighborCount: sortedNeighbors.length,
842
- totalNeighborCount: impact.neighborFiles.length,
843
- importerCount: impact.directImporters.length,
844
- callerCount: impact.directCallers.length,
845
- referenceCount: Math.max(0, referenceCount),
846
- riskFlags: impact.riskFlags,
847
- metadata: { neighbors: sortedNeighbors.slice(0, 10) },
848
- });
849
- const lspService = getLSPService();
850
- // Hoist passive snapshot once — used for auto-propagating LSPs and fallback path.
851
- const allDiags = await lspService.getAllDiagnostics();
852
- const neighbors = [];
853
- let producedLspData = false;
854
- let coldSnapshotPaths = [];
855
- if (sortedNeighbors.length > 0) {
856
- const snapshotPaths = sortedNeighbors.filter(shouldReadCascadeFromSnapshot);
857
- const activePaths = sortedNeighbors.filter((n) => !shouldReadCascadeFromSnapshot(n));
858
- // Auto-propagating LSPs (TypeScript/Deno) — read passive snapshot with normalized key.
859
- // When the snapshot is valid, use it immediately (no touch needed — server already has
860
- // fresh data from auto-propagation). When missing or stale, fall through to the active
861
- // touch pool below so we get real diagnostics instead of silently returning zero.
862
- coldSnapshotPaths = [];
863
- for (const neighborPath of snapshotPaths) {
864
- const neighborStart = Date.now();
865
- const entry = allDiags.get(normalizeMapKey(neighborPath));
866
- const snapshotAgeSec = entry
867
- ? Math.round((Date.now() - entry.ts) / 1000)
868
- : undefined;
869
- const snapshotValid = entry != null && Date.now() - entry.ts < CASCADE_TTL_MS;
870
- if (!snapshotValid) {
871
- // No usable snapshot — queue for active touch alongside non-jsts neighbors.
789
+ // Count files represented in the graph (nodes with a filePath).
790
+ const graphFileCount = new Set([...graph.nodes.values()].flatMap((n) => n.filePath ? [n.filePath] : [])).size;
791
+ logCascade({
792
+ phase: "graph_build",
793
+ filePath,
794
+ graphBuiltMs: graphMs,
795
+ graphReused: graphBuildInfo.reused,
796
+ graphNodeCount: graph.nodes.size,
797
+ graphFileCount,
798
+ graphChangedSymbolCount: (graph.changedSymbolsByFile.get(normalizedFileKey) ?? []).length,
799
+ metadata: {
800
+ graphBuildMode: graphBuildInfo.mode,
801
+ skipReason: graphBuildInfo.skipReason,
802
+ sourceFileCount: graphBuildInfo.sourceFileCount,
803
+ maxFileCount: graphBuildInfo.maxFileCount,
804
+ // #451: when the seq fast path fell back (or was skipped), why — so
805
+ // cascade.log surfaces the fast-path hit/miss rate.
806
+ seqFastpathFallback: graphBuildInfo.seqFastpathFallback,
807
+ },
808
+ });
809
+ impact = computeImpactCascade(graph, normalizedFile, cwd);
810
+ // #1023: buildOrUpdateGraph returns an EMPTY graph (seeded only with the
811
+ // changed file's own symbols) when the repo is over
812
+ // PI_LENS_REVIEW_GRAPH_MAX_FILES (`too_many_files`) or the root is unsafe
813
+ // (`unsafe_root`) — both stamp mode "skipped" on the already-read
814
+ // graphBuildInfo. A capped or entry-budget-truncated graph is also not a
815
+ // complete dependent set even though it has nodes, so it must not look like
816
+ // a clean leaf. Thread the ALREADY-KNOWN degraded state (never re-derived)
817
+ // onto the result so the turn-end seam surfaces an honest advisory instead
818
+ // of a silent all-clear (#533). Keyed strictly off the graph's explicit
819
+ // degraded marker, NOT off `neighborFiles.length === 0`.
820
+ if ((graphBuildInfo.mode === "skipped" ||
821
+ graph.persistCoverage?.partial === true ||
822
+ !graphBuildInfoTrustworthy) &&
823
+ !impact.indeterminate) {
824
+ const coverage = graph.persistCoverage;
825
+ impact.indeterminate = {
826
+ reason: "graph_degraded",
827
+ detail: !graphBuildInfoTrustworthy
828
+ ? // #1179 fail-closed: the graph's own build-info was not found under
829
+ // its identity and the global slot may be a sibling build's — don't
830
+ // trust its mode. Surface an honest "unknown" advisory rather than a
831
+ // (possibly sibling-derived) all-clear.
832
+ "review graph coverage unknown — build state unavailable for this graph"
833
+ : graphBuildInfo.mode === "skipped"
834
+ ? graphBuildInfo.skipReason === "too_many_files"
835
+ ? graphBuildInfo.sourceFileCountTruncated
836
+ ? `review graph disabled — more than ${graphBuildInfo.maxFileCount ?? "?"} files (cap ${graphBuildInfo.maxFileCount ?? "?"})`
837
+ : `review graph disabled — ${graphBuildInfo.sourceFileCount ?? "?"} files over the ${graphBuildInfo.maxFileCount ?? "?"} cap`
838
+ : graphBuildInfo.skipReason === "unsafe_root"
839
+ ? "review graph skipped — workspace root is at/above home dir"
840
+ : `review graph unavailable (${graphBuildInfo.skipReason ?? "skipped"})`
841
+ : coverage?.sourceFilesTruncated
842
+ ? "review graph partial — source walk stopped at its visited-entry budget"
843
+ : "review graph partial — persisted graph coverage is incomplete",
844
+ sourceFileCount: graphBuildInfo.sourceFileCount,
845
+ maxFileCount: graphBuildInfo.maxFileCount,
846
+ };
847
+ }
848
+ const reverseDepNeighbors = getAffectedFilesFromIndex(reverseDepsIndex, normalizedFile, 1, MAX_FILES * 2);
849
+ logCascade({
850
+ phase: "reverse_deps_cache",
851
+ filePath,
852
+ metadata: {
853
+ action: "merge_neighbors",
854
+ depth: 1,
855
+ neighborCount: reverseDepNeighbors.length,
856
+ neighbors: reverseDepNeighbors.slice(0, 10),
857
+ },
858
+ });
859
+ if (reverseDepNeighbors.length > 0) {
860
+ impact.directImporters = [
861
+ ...new Set([...impact.directImporters, ...reverseDepNeighbors]),
862
+ ];
863
+ impact.neighborFiles = [
864
+ ...new Set([...impact.neighborFiles, ...reverseDepNeighbors]),
865
+ ];
872
866
  logCascade({
873
867
  phase: "neighbor_snapshot",
874
868
  filePath,
875
- neighborFile: neighborPath,
876
- diagnosticCount: 0,
877
- durationMs: Date.now() - neighborStart,
878
- autoPropagate: true,
879
- snapshotMissing: entry == null,
880
- snapshotAgeSec,
881
- coldSnapshot: true,
869
+ neighborFile: "[reverse-deps-cache]",
870
+ diagnosticCount: reverseDepNeighbors.length,
871
+ autoPropagate: false,
872
+ metadata: { reverseDepsCache: true },
873
+ });
874
+ }
875
+ // Symbol-level blast radius via LSP references (precision upgrade over
876
+ // file-level import edges). Only when changed symbols are detected.
877
+ // Keep the budget tight: 750ms per symbol, 1200ms total, max 3 symbols.
878
+ if (impact.changedSymbols.length > 0) {
879
+ const lspService = getLSPService();
880
+ const symbolNodeIds = graph.symbolNodesByFile.get(normalizedFileKey) ?? [];
881
+ const refFiles = new Set();
882
+ const refsStart = Date.now();
883
+ for (const symbolName of impact.changedSymbols.slice(0, 3)) {
884
+ const symbolNodeId = symbolNodeIds.find((id) => {
885
+ const node = graph.nodes.get(id);
886
+ return node?.symbolName === symbolName;
887
+ });
888
+ if (!symbolNodeId)
889
+ continue;
890
+ const node = graph.nodes.get(symbolNodeId);
891
+ const line = Number(node?.metadata?.line ?? 0);
892
+ const column = Number(node?.metadata?.column ?? 0);
893
+ if (line <= 0)
894
+ continue;
895
+ // #1109: store the timer and clear it once the race settles. Without
896
+ // this, when `references()` wins (the common case), the losing
897
+ // `setTimeout` stays a REF'D pending timer for the remaining 750ms —
898
+ // harmless in a long-lived session, but a keep-alive tail in a
899
+ // one-shot `pi --print` process (same uncleared-race-timeout class
900
+ // fixed for the LSP client-wait leak in clients/lsp/index.ts, #1097).
901
+ let refsTimer;
902
+ try {
903
+ const refs = await Promise.race([
904
+ lspService.references(normalizedFile, line - 1, column - 1, false),
905
+ new Promise((_, reject) => {
906
+ refsTimer = setTimeout(() => reject(new Error("timeout")), 750);
907
+ }),
908
+ ]);
909
+ for (const ref of refs) {
910
+ let resolved;
911
+ try {
912
+ resolved = ref.uri.startsWith("file://")
913
+ ? fileURLToPath(ref.uri)
914
+ : ref.uri;
915
+ }
916
+ catch {
917
+ continue;
918
+ }
919
+ if (normalizeMapKey(resolved) !== normalizedFileKey &&
920
+ nodeFs.existsSync(resolved)) {
921
+ refFiles.add(normalizeMapKey(resolved));
922
+ }
923
+ }
924
+ }
925
+ catch {
926
+ // Timeout or LSP error — fall back to import-graph neighbors
927
+ }
928
+ finally {
929
+ if (refsTimer !== undefined)
930
+ clearTimeout(refsTimer);
931
+ }
932
+ if (Date.now() - refsStart > 1200)
933
+ break; // Hard ceiling
934
+ }
935
+ if (refFiles.size > 0) {
936
+ impact.neighborFiles = [
937
+ ...new Set([...impact.neighborFiles, ...refFiles]),
938
+ ];
939
+ logCascade({
940
+ phase: "neighbor_snapshot",
941
+ filePath,
942
+ neighborFile: "[lsp-references]",
943
+ diagnosticCount: refFiles.size,
944
+ durationMs: Date.now() - refsStart,
945
+ autoPropagate: false,
946
+ metadata: { lspReferences: true },
947
+ });
948
+ }
949
+ }
950
+ // Bounded transitive expansion: add depth>1 dependents (indirect
951
+ // importers/callers/referencers) so the blast radius isn't limited to one
952
+ // hop. The one-hop sets above remain the floor (they sort first); these
953
+ // fill the remaining budget. Graph BFS is in-memory + capped.
954
+ if (CASCADE_TRANSITIVE_DEPTH > 1) {
955
+ const transitive = computeTransitiveImpact(graph, normalizedFile, {
956
+ maxDepth: CASCADE_TRANSITIVE_DEPTH,
957
+ maxHits: CASCADE_NEIGHBOUR_BUDGET,
882
958
  });
883
- coldSnapshotPaths.push(neighborPath);
884
- continue;
959
+ const added = [
960
+ ...new Set(transitive.hits
961
+ .map((hit) => hit.file)
962
+ .filter((file) => file && normalizeMapKey(file) !== normalizedFileKey)),
963
+ ].filter((file) => !impact.neighborFiles.includes(file));
964
+ if (added.length > 0) {
965
+ impact.neighborFiles = [...impact.neighborFiles, ...added];
966
+ logCascade({
967
+ phase: "neighbor_snapshot",
968
+ filePath,
969
+ neighborFile: "[transitive-impact]",
970
+ diagnosticCount: added.length,
971
+ autoPropagate: false,
972
+ metadata: {
973
+ transitive: true,
974
+ maxDepth: CASCADE_TRANSITIVE_DEPTH,
975
+ maxDepthReached: transitive.maxDepthReached,
976
+ truncated: transitive.truncated,
977
+ },
978
+ });
979
+ }
885
980
  }
886
- // #692: `source: "cascade"` used to be passed here to label `rule`
887
- // (`cascade:<code>`) — that override is gone (identity must come from
888
- // the diagnostic's own source; see `scanOrigin`'s doc comment), and
889
- // cascade neighbor diagnostics are ephemeral display-only output
890
- // (never reconciled into persisted widget/dedup state), so the label
891
- // had no remaining purpose and is simply dropped rather than migrated.
892
- const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
893
- producedLspData = true;
894
- const durationMs = Date.now() - neighborStart;
981
+ // #1080: exclude KNOWN test-role files from every collateral impact
982
+ // surface — the formatted header (formatImpactCascade reads `impact`
983
+ // directly for `Direct importers`/`Direct callers`/`Check next` counts and
984
+ // names), the active-touch/passive-snapshot neighbor set (sortedNeighbors is
985
+ // derived from `impact.neighborFiles` below), and the returned `impact`
986
+ // object. Applied HERE — after graph neighbors, reverse-deps, LSP reference
987
+ // expansion, and transitive expansion have all been merged in — so it covers
988
+ // every neighbor source (incl. module-level downstream files that entered via
989
+ // computeImpactCascade and reference URIs pointing at `*.test.*`). Filtering
990
+ // upstream of `sortedNeighbors` also means a test URI is never actively
991
+ // touched solely for cascade diagnostics. Composes the shared `detectFileRole`
992
+ // seam; a classifier failure RETAINS the candidate (honest — never a false
993
+ // clean). The project ignore filter below is separate and unchanged (#297).
994
+ impact.directImporters = impact.directImporters.filter((f) => !isTestRoleCollateral(f));
995
+ impact.directCallers = impact.directCallers.filter((f) => !isTestRoleCollateral(f));
996
+ impact.neighborFiles = impact.neighborFiles.filter((f) => !isTestRoleCollateral(f));
997
+ // Sort by relationship strength (B6) then cap to the neighbour budget.
998
+ // directImporters are most impactful, then callers, then reference edges.
999
+ importerSet = new Set(impact.directImporters);
1000
+ callerSet = new Set(impact.directCallers);
1001
+ // neighbors that are neither direct importers nor callers are reference-edge neighbors
1002
+ const importerOrCallerSet = new Set([
1003
+ ...impact.directImporters,
1004
+ ...impact.directCallers,
1005
+ ]);
1006
+ referenceCount = impact.neighborFiles.filter((n) => !importerOrCallerSet.has(n)).length;
1007
+ sortedNeighbors = [...impact.neighborFiles]
1008
+ .filter((n) => nodeFs.existsSync(n))
1009
+ .filter((n) => !isExternalOrVendorFile(n, cwd))
1010
+ // Honour the project's ignore config: a user-ignored neighbour (e.g.
1011
+ // `**/*.test.ts`) must not surface as collateral cascade noise (#297).
1012
+ .filter((n) => !isIgnoredCascadeNeighbor(n, cwd))
1013
+ // B10: exclude files already edited as primary this turn — their own pipeline
1014
+ // run is the authoritative diagnostic source; showing them as neighbors is noise.
1015
+ .filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
1016
+ .sort((a, b) => {
1017
+ const rank = (p) => importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
1018
+ return rank(a) - rank(b);
1019
+ })
1020
+ .slice(0, CASCADE_NEIGHBOUR_BUDGET);
1021
+ }
1022
+ else {
895
1023
  logCascade({
896
- phase: "neighbor_snapshot",
1024
+ phase: "cascade_skip",
897
1025
  filePath,
898
- neighborFile: neighborPath,
899
- diagnosticCount: diags.length,
900
- durationMs,
901
- autoPropagate: true,
902
- snapshotMissing: false,
903
- snapshotAgeSec,
904
- });
905
- neighbors.push({
906
- filePath: neighborPath,
907
- reason: neighborReason(importerSet, callerSet, neighborPath),
908
- diagnostics: diags,
909
- lspTouched: false,
910
- durationMs,
1026
+ reason: "unsupported_graph_kind",
1027
+ metadata: { fileKind },
911
1028
  });
1029
+ return {
1030
+ filePath,
1031
+ result: undefined,
1032
+ neighborCount: 0,
1033
+ diagnosticCount: 0,
1034
+ skipReason: "non_code",
1035
+ };
912
1036
  }
913
- // fan-out active touches in parallel (A3):
914
- // - non-jsts neighbors (always touched)
915
- // - autoPropagate neighbors whose snapshot was missing/stale (coldSnapshotPaths)
916
- // use a tighter 1000ms budget since the server is expected to be warm already.
917
- const touchResults = await Promise.allSettled([...activePaths, ...coldSnapshotPaths].map(async (neighborPath) => {
918
- const isColdSnapshot = coldSnapshotPaths.includes(neighborPath);
919
- const neighborStart = Date.now();
920
- const cacheKey = normalizeMapKey(neighborPath);
921
- const passiveEntry = allDiags.get(cacheKey);
922
- const hasFreshPassiveErrors = passiveEntry != null &&
923
- Date.now() - passiveEntry.ts < CASCADE_TTL_MS &&
924
- passiveEntry.diags.some((d) => d.severity === 1);
925
- const recentlyClean = recentlyCleanNeighborCache.get(cacheKey);
926
- if (recentlyClean &&
927
- turnSeq - recentlyClean.turnSeq <= RECENTLY_CLEAN_TTL_TURNS &&
928
- !hasFreshPassiveErrors) {
1037
+ logCascade({
1038
+ phase: "neighbors_computed",
1039
+ filePath,
1040
+ neighborCount: sortedNeighbors.length,
1041
+ totalNeighborCount: impact.neighborFiles.length,
1042
+ importerCount: impact.directImporters.length,
1043
+ callerCount: impact.directCallers.length,
1044
+ referenceCount: Math.max(0, referenceCount),
1045
+ riskFlags: impact.riskFlags,
1046
+ metadata: { neighbors: sortedNeighbors.slice(0, 10) },
1047
+ });
1048
+ const lspService = getLSPService();
1049
+ // Hoist passive snapshot once — used for auto-propagating LSPs and fallback path.
1050
+ // #1095 memo-freeze caveat: each getAllDiagnostics() result attaches `.binding`
1051
+ // as a LAZY getter that memoizes its disk verdict PER RESULT OBJECT. This cascade
1052
+ // re-calls getAllDiagnostics() every run and never retains the Map across turns,
1053
+ // so every run reads a FRESH verdict against current disk. If cross-turn retention
1054
+ // of this Map is ever introduced, the memoized binding would freeze stale — re-read
1055
+ // a fresh getAllDiagnostics() result at binding-read time instead of caching it.
1056
+ const allDiags = await lspService.getAllDiagnostics();
1057
+ const neighbors = [];
1058
+ let producedLspData = false;
1059
+ let coldSnapshotPaths = [];
1060
+ // #1104: did any DEGRADED-fallback display path (touch-error fallback,
1061
+ // appendFallbackNeighbors) withhold a TTL-fresh entry solely because its
1062
+ // content binding was rejected (`boundToCurrentDisk === false` — computed
1063
+ // against a diverged/pre-fix-edit disk state)? Tracked separately from
1064
+ // `producedLspData` so the HONESTY check below can tell "genuinely nothing to
1065
+ // show" apart from "something existed but was untrustworthy and was hidden" —
1066
+ // the latter must not collapse into a clean-looking result (#1104 honesty
1067
+ // rule, same doctrine as #1023's graph-degraded indeterminate marker).
1068
+ let fallbackBindingRejected = false;
1069
+ if (sortedNeighbors.length > 0) {
1070
+ const snapshotPaths = sortedNeighbors.filter(shouldReadCascadeFromSnapshot);
1071
+ const activePaths = sortedNeighbors.filter((n) => !shouldReadCascadeFromSnapshot(n));
1072
+ // Auto-propagating LSPs (TypeScript/Deno) — read passive snapshot with normalized key.
1073
+ // When the snapshot is valid, use it immediately (no touch needed — server already has
1074
+ // fresh data from auto-propagation). When missing or stale, fall through to the active
1075
+ // touch pool below so we get real diagnostics instead of silently returning zero.
1076
+ coldSnapshotPaths = [];
1077
+ for (const neighborPath of snapshotPaths) {
1078
+ const neighborStart = Date.now();
1079
+ const entry = allDiags.get(normalizeMapKey(neighborPath));
1080
+ const snapshotAgeSec = entry
1081
+ ? Math.round((Date.now() - entry.ts) / 1000)
1082
+ : undefined;
1083
+ const ttlFresh = entry != null && Date.now() - entry.ts < CASCADE_TTL_MS;
1084
+ // #1095: content binding is the INNER gate; TTL stays the outer bound.
1085
+ // false → the server's diagnostics were computed against a DIFFERENT
1086
+ // disk state (e.g. the PRE-fix content) — don't trust or
1087
+ // reconcile this snapshot; fall through to an active touch on
1088
+ // the same (cold-snapshot) budget as a TTL-stale entry. This
1089
+ // kills the window where the first cascade after a fix-edit
1090
+ // replays the neighbor's pre-fix snapshot.
1091
+ // "unknown" → version-less/unreadable: keep EXACTLY the pre-#1095 TTL-only
1092
+ // behavior (reconcile if TTL-fresh).
1093
+ // true → bound to current disk: reconcile (TTL still the outer bound).
1094
+ // Reading `.binding` triggers the lazy disk verify on the getAllDiagnostics
1095
+ // result — done ONLY when TTL-fresh so a doomed (stale) entry never pays the
1096
+ // stat+hash.
1097
+ const boundToDisk = ttlFresh
1098
+ ? readBoundToCurrentDisk(entry)
1099
+ : undefined;
1100
+ const bindingRejected = boundToDisk === false;
1101
+ const snapshotValid = ttlFresh && !bindingRejected;
1102
+ if (!snapshotValid) {
1103
+ // No usable snapshot — queue for active touch alongside non-jsts neighbors.
1104
+ logCascade({
1105
+ phase: "neighbor_snapshot",
1106
+ filePath,
1107
+ neighborFile: neighborPath,
1108
+ diagnosticCount: 0,
1109
+ durationMs: Date.now() - neighborStart,
1110
+ autoPropagate: true,
1111
+ snapshotMissing: entry == null,
1112
+ snapshotAgeSec,
1113
+ coldSnapshot: true,
1114
+ // #1095: distinguish a binding-rejected fall-through (TTL-fresh but the
1115
+ // server's view diverged from disk) from a plain TTL-stale/missing one.
1116
+ ...(bindingRejected && {
1117
+ metadata: { bindingState: bindingStateLabel(boundToDisk) },
1118
+ }),
1119
+ });
1120
+ coldSnapshotPaths.push(neighborPath);
1121
+ continue;
1122
+ }
1123
+ // #692: `source: "cascade"` used to be passed here to label `rule`
1124
+ // (`cascade:<code>`) — that override is gone (identity must come from
1125
+ // the diagnostic's own source; see `scanOrigin`'s doc comment), and
1126
+ // cascade neighbor diagnostics are ephemeral display-only output
1127
+ // (never reconciled into persisted widget/dedup state), so the label
1128
+ // had no remaining purpose and is simply dropped rather than migrated.
1129
+ const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
929
1130
  producedLspData = true;
930
1131
  const durationMs = Date.now() - neighborStart;
931
1132
  logCascade({
932
1133
  phase: "neighbor_snapshot",
933
1134
  filePath,
934
1135
  neighborFile: neighborPath,
935
- diagnosticCount: 0,
1136
+ diagnosticCount: diags.length,
936
1137
  durationMs,
937
- autoPropagate: false,
1138
+ autoPropagate: true,
938
1139
  snapshotMissing: false,
939
- metadata: {
940
- recentlyClean: true,
941
- cleanTurnSeq: recentlyClean.turnSeq,
942
- },
1140
+ snapshotAgeSec,
943
1141
  });
944
- return {
1142
+ // #1093: a valid passive snapshot IS a confirmed observation of this
1143
+ // neighbor's current LSP-error state (#571 semantics) — reconcile it into
1144
+ // the footer widget, INCLUDING the confirmed-clean `[]` case, so a
1145
+ // fix-edit to the primary that resolves a cross-file error in this
1146
+ // neighbor clears the neighbor's now-stale footer entry (the #1092
1147
+ // defect). MERGE (genuine LSP errors only — auxiliary findings excluded,
1148
+ // see `cascadeReconcilableLspErrors`) so a live biome/ruff/aux finding or
1149
+ // LSP warning on the neighbor is preserved. Keyed by the primary edit's
1150
+ // `writeSeq` so a genuinely newer per-edit write still wins the
1151
+ // WriteOrderingGuard. `observedAt = entry.ts` (the snapshot's own publish
1152
+ // time, up to CASCADE_TTL_MS old) — NOT now() — so replaying an aging
1153
+ // snapshot never re-arms the mtime-staleness gate (the same #1092
1154
+ // re-arming defect this PR fixes for cache hits).
1155
+ //
1156
+ // #1186: `observedAt` here stamps only the INCOMING LSP-error entries.
1157
+ // PRESERVED entries keep their own (possibly fresher) per-entry
1158
+ // `observedAt`, and `reconcileStaleWidgetFiles` now gates per ENTRY — so
1159
+ // if the neighbor's mtime later falls between this `entry.ts` and a
1160
+ // preserved entry's real observation time, only the stale incoming entry
1161
+ // drops and the fresher preserved finding survives (previously the whole
1162
+ // record was over-cleared; that residual is now fixed).
1163
+ reconcileCascadeNeighborLspErrors(neighborPath, cascadeReconcilableLspErrors(entry.diags, neighborPath), writeSeq, entry.ts);
1164
+ neighbors.push({
945
1165
  filePath: neighborPath,
946
1166
  reason: neighborReason(importerSet, callerSet, neighborPath),
947
- diagnostics: [],
1167
+ diagnostics: diags,
948
1168
  lspTouched: false,
949
1169
  durationMs,
950
- };
1170
+ });
951
1171
  }
952
- // A5: skip re-touch if this neighbor was already diagnosed at the current
953
- // write sequence. A new write (higher writeSeq) invalidates the cache entry.
954
- const cached = writeSeq != null ? neighborTouchCache.get(cacheKey) : undefined;
955
- if (cached?.turnSeq === turnSeq && cached?.writeSeq === writeSeq) {
956
- producedLspData = true;
1172
+ // fan-out active touches in parallel (A3):
1173
+ // - non-jsts neighbors (always touched)
1174
+ // - autoPropagate neighbors whose snapshot was missing/stale (coldSnapshotPaths)
1175
+ // use a tighter 1000ms budget since the server is expected to be warm already.
1176
+ const touchResults = await Promise.allSettled([...activePaths, ...coldSnapshotPaths].map(async (neighborPath) => {
1177
+ const isColdSnapshot = coldSnapshotPaths.includes(neighborPath);
1178
+ const neighborStart = Date.now();
1179
+ const cacheKey = normalizeMapKey(neighborPath);
1180
+ const passiveEntry = allDiags.get(cacheKey);
1181
+ const hasFreshPassiveErrors = passiveEntry != null &&
1182
+ Date.now() - passiveEntry.ts < CASCADE_TTL_MS &&
1183
+ passiveEntry.diags.some((d) => d.severity === 1);
1184
+ const recentlyClean = recentlyCleanNeighborCache.get(cacheKey);
1185
+ if (recentlyClean &&
1186
+ turnSeq - recentlyClean.turnSeq <= RECENTLY_CLEAN_TTL_TURNS &&
1187
+ !hasFreshPassiveErrors) {
1188
+ producedLspData = true;
1189
+ const durationMs = Date.now() - neighborStart;
1190
+ logCascade({
1191
+ phase: "neighbor_snapshot",
1192
+ filePath,
1193
+ neighborFile: neighborPath,
1194
+ diagnosticCount: 0,
1195
+ durationMs,
1196
+ autoPropagate: false,
1197
+ snapshotMissing: false,
1198
+ metadata: {
1199
+ recentlyClean: true,
1200
+ cleanTurnSeq: recentlyClean.turnSeq,
1201
+ },
1202
+ });
1203
+ return {
1204
+ filePath: neighborPath,
1205
+ reason: neighborReason(importerSet, callerSet, neighborPath),
1206
+ diagnostics: [],
1207
+ lspTouched: false,
1208
+ durationMs,
1209
+ };
1210
+ }
1211
+ // A5: skip re-touch if this neighbor was already diagnosed at the current
1212
+ // write sequence. A new write (higher writeSeq) invalidates the cache entry.
1213
+ const cached = writeSeq != null ? neighborTouchCache.get(cacheKey) : undefined;
1214
+ if (cached?.turnSeq === turnSeq && cached?.writeSeq === writeSeq) {
1215
+ producedLspData = true;
1216
+ const durationMs = Date.now() - neighborStart;
1217
+ logCascade({
1218
+ phase: "neighbor_snapshot",
1219
+ filePath,
1220
+ neighborFile: neighborPath,
1221
+ diagnosticCount: cached.diagnostics.length,
1222
+ durationMs,
1223
+ autoPropagate: false,
1224
+ snapshotMissing: false,
1225
+ metadata: { cachedWriteSeq: writeSeq },
1226
+ });
1227
+ return {
1228
+ filePath: neighborPath,
1229
+ reason: neighborReason(importerSet, callerSet, neighborPath),
1230
+ diagnostics: cached.diagnostics,
1231
+ lspTouched: false,
1232
+ durationMs,
1233
+ };
1234
+ }
1235
+ const configuredServerCount = getServersForFileWithConfig(neighborPath).length;
1236
+ if (configuredServerCount === 0) {
1237
+ logCascade({
1238
+ phase: "neighbor_fallback",
1239
+ filePath,
1240
+ neighborFile: neighborPath,
1241
+ fallbackUsed: false,
1242
+ error: "no_lsp_server_configured",
1243
+ });
1244
+ return undefined;
1245
+ }
1246
+ // A6: async read to avoid blocking event loop on network-mounted drives
1247
+ const content = await nodeFs.promises.readFile(neighborPath, "utf8");
1248
+ // #458: tier-aware cascade-lane wait. A Tier-3 (push-only,
1249
+ // silent-on-clean — typescript is the lone core-set instance today)
1250
+ // primary can never give this in-lane wait an affirmative clean
1251
+ // signal, so the budget is pure cost. Fire the touch (didOpen/
1252
+ // didChange still happens — the server starts real work) and record
1253
+ // it as outstanding for the agent_settled quiet window to reconcile
1254
+ // instead of waiting here. Ambiguous/missing capability data always
1255
+ // classifies as "waits" (today's behavior) — see cascade-tier.ts.
1256
+ // The whole attempt is try/caught: any surprise (a service shape
1257
+ // that doesn't expose getCapabilitySnapshots/getClientForFile, a
1258
+ // thrown rejection) falls through to the existing full-wait path
1259
+ // below rather than skip the wait on a failure.
1260
+ if (isTierAwareCascadeEnabled()) {
1261
+ try {
1262
+ const snapshots = (await lspService.getCapabilitySnapshots?.(neighborPath)) ?? [];
1263
+ const tier = classifyCascadeWaitTier(lspService, neighborPath, snapshots);
1264
+ if (tier === "tier3-silent") {
1265
+ const spawnedForTouch = await lspService.getClientForFile(neighborPath);
1266
+ if (spawnedForTouch) {
1267
+ // Sampled BEFORE the touchFile notify: a publish landing
1268
+ // in the notify→record gap must read as post-touch at
1269
+ // reconcile time, never be misclassified as pre-touch
1270
+ // (the reconcile compares this against the client's
1271
+ // PER-FILE publish timestamp — see cascade-tier.ts).
1272
+ const touchedAt = Date.now();
1273
+ await lspService.touchFile(neighborPath, content, {
1274
+ diagnostics: "none",
1275
+ collectDiagnostics: false,
1276
+ silent: true,
1277
+ source: "cascade",
1278
+ clientScope: "primary",
1279
+ });
1280
+ recordOutstandingCascadeTouch({
1281
+ filePath: neighborPath,
1282
+ serverId: spawnedForTouch.client.serverId,
1283
+ touchedAt,
1284
+ });
1285
+ const durationMs = Date.now() - neighborStart;
1286
+ logCascade({
1287
+ phase: "cascade_tier3_skip",
1288
+ filePath,
1289
+ neighborFile: neighborPath,
1290
+ durationMs,
1291
+ lspServerCount: configuredServerCount,
1292
+ coldSnapshot: isColdSnapshot,
1293
+ metadata: { serverId: spawnedForTouch.client.serverId },
1294
+ });
1295
+ // Deliberately NOT cached as clean/diagnosed — the wait was
1296
+ // skipped, not resolved, so neither neighborTouchCache nor
1297
+ // recentlyCleanNeighborCache may treat this as a real answer
1298
+ // (#240 doctrine). Return undefined: the degraded-fallback
1299
+ // path below still has a chance to surface a passive/stale
1300
+ // snapshot, same as any other "no fresh data this touch" case.
1301
+ return undefined;
1302
+ }
1303
+ }
1304
+ }
1305
+ catch (tierErr) {
1306
+ dbg?.(`cascade tier-aware skip attempt failed for ${neighborPath}, falling back to full wait: ${tierErr}`);
1307
+ }
1308
+ }
1309
+ // Open with silent=true (suppresses didChangeWatchedFiles rechecks, C2)
1310
+ // and collect diagnostics from the same touched clients.
1311
+ // Cold-snapshot neighbors (autoPropagate LSP, server warm) use a tighter
1312
+ // 1000ms budget — they should respond quickly; we'd rather return zero
1313
+ // than block cascade for 2s on a slow open.
1314
+ const rawDiags = await lspService.touchFile(neighborPath, content, {
1315
+ diagnostics: "document",
1316
+ collectDiagnostics: true,
1317
+ maxClientWaitMs: isColdSnapshot ? 1000 : 2000,
1318
+ silent: true,
1319
+ source: "cascade",
1320
+ clientScope: "all",
1321
+ });
1322
+ if (!rawDiags)
1323
+ return undefined;
1324
+ // #1093/#571/#1095: a touch result is only a CONFIRMED observation of the
1325
+ // neighbor's current on-disk state when it is neither `inconclusive` (the
1326
+ // notify/diagnostics wait lapsed — e.g. the tight 1000ms cold-snapshot
1327
+ // budget on a slow server) NOR bound-false (`binding.boundToCurrentDisk
1328
+ // === false` — the diagnostics were computed against a different disk state
1329
+ // than what's on disk now). Either disqualifier means a resolved `[]` is
1330
+ // NOT a confirmed clean: treating it as one would WIPE a live footer finding
1331
+ // (the #533 false-clean trap, worse than the stale-display bug). Both flags
1332
+ // are folded into `isConfirmedTouch` so no gate below can miss one. A
1333
+ // confirmed result reconciles and may seed the recently-clean cache; an
1334
+ // unconfirmed one does neither (else the short-circuit on the next cascade
1335
+ // would make the wipe self-sustain).
1336
+ const confirmed = isConfirmedTouch(rawDiags);
1337
+ const bindingRejected = readBoundToCurrentDisk(rawDiags) === false;
1338
+ const inconclusive = readInconclusive(rawDiags);
1339
+ // #692: `source: "cascade"` no longer overrides `rule` (see the
1340
+ // doc comment on the sibling call above) — dropped rather than
1341
+ // migrated to `scanOrigin` since cascade output never touches
1342
+ // persisted widget/dedup state.
1343
+ // #1179: `.filter()` here operates on `rawDiags.diags`; the
1344
+ // `inconclusive`/`binding` flags read above stay on the `rawDiags`
1345
+ // wrapper and are unaffected by this copy (the shape-5 fix).
1346
+ const diags = convertLspDiagnostics(rawDiags.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
957
1347
  const durationMs = Date.now() - neighborStart;
1348
+ // Update cache for this neighbor at the current write sequence
1349
+ if (writeSeq != null) {
1350
+ neighborTouchCache.set(cacheKey, {
1351
+ turnSeq,
1352
+ writeSeq,
1353
+ diagnostics: diags,
1354
+ });
1355
+ }
1356
+ if (diags.length === 0) {
1357
+ // Only a CONFIRMED clean touch may seed the recently-clean cache
1358
+ // (#1095: a bound-false touch is unconfirmed, exactly like inconclusive).
1359
+ if (confirmed) {
1360
+ recentlyCleanNeighborCache.set(cacheKey, {
1361
+ turnSeq,
1362
+ checkedAt: Date.now(),
1363
+ });
1364
+ }
1365
+ }
1366
+ else {
1367
+ recentlyCleanNeighborCache.delete(cacheKey);
1368
+ }
1369
+ producedLspData = true;
958
1370
  logCascade({
959
- phase: "neighbor_snapshot",
1371
+ phase: "neighbor_touch",
960
1372
  filePath,
961
1373
  neighborFile: neighborPath,
962
- diagnosticCount: cached.diagnostics.length,
1374
+ diagnosticCount: diags.length,
963
1375
  durationMs,
964
- autoPropagate: false,
965
- snapshotMissing: false,
966
- metadata: { cachedWriteSeq: writeSeq },
1376
+ lspTouched: true,
1377
+ lspServerCount: configuredServerCount,
1378
+ coldSnapshot: isColdSnapshot,
1379
+ // #1104: an unconfirmed touch has two independent, otherwise
1380
+ // indistinguishable causes — `inconclusive` (the notify/diagnostics
1381
+ // wait lapsed its deadline) and bound-false (`bindingState`, #1095 —
1382
+ // diagnostics computed against a diverged disk state). Surface
1383
+ // `inconclusive` unconditionally so cascade.log alone (no
1384
+ // cross-referencing latency.log) tells them apart; `bindingState`
1385
+ // stays conditional since "bound" carries no extra signal.
1386
+ metadata: {
1387
+ inconclusive,
1388
+ ...(bindingRejected && { bindingState: bindingStateLabel(false) }),
1389
+ },
967
1390
  });
1391
+ // #1093/#1095: a completed, CONFIRMED active touch is a confirmed
1392
+ // observation of this neighbor's current LSP-error state (#571) —
1393
+ // reconcile it into the footer widget, INCLUDING the confirmed-clean `[]`
1394
+ // case, so a fix-edit to the primary that resolves a cross-file error in
1395
+ // this neighbor clears the neighbor's now-stale footer entry (the #1092
1396
+ // defect). MERGE (genuine LSP errors only — auxiliary findings excluded,
1397
+ // see `cascadeReconcilableLspErrors`) so a live biome/ruff/aux finding or
1398
+ // LSP warning survives this errors-only re-check. Keyed by the primary
1399
+ // edit's `writeSeq` so a genuinely newer per-edit write still wins the
1400
+ // WriteOrderingGuard. `observedAt` stays now (a fresh touch). The
1401
+ // inconclusive touch, the BOUND-FALSE touch (#1095 — computed against a
1402
+ // diverged disk state), the tier-3-silent skip, the recently-clean
1403
+ // short-circuit, the within-turn cache hit, and the rejected-touch
1404
+ // fallback are all deliberately NOT reconciled — none is a confirmed
1405
+ // observation.
1406
+ if (confirmed) {
1407
+ reconcileCascadeNeighborLspErrors(neighborPath, cascadeReconcilableLspErrors(rawDiags.diags, neighborPath), writeSeq);
1408
+ }
968
1409
  return {
969
1410
  filePath: neighborPath,
970
1411
  reason: neighborReason(importerSet, callerSet, neighborPath),
971
- diagnostics: cached.diagnostics,
972
- lspTouched: false,
1412
+ diagnostics: diags,
1413
+ lspTouched: true,
973
1414
  durationMs,
974
1415
  };
975
- }
976
- const configuredServerCount = getServersForFileWithConfig(neighborPath).length;
977
- if (configuredServerCount === 0) {
978
- logCascade({
979
- phase: "neighbor_fallback",
980
- filePath,
981
- neighborFile: neighborPath,
982
- fallbackUsed: false,
983
- error: "no_lsp_server_configured",
984
- });
985
- return undefined;
986
- }
987
- // A6: async read to avoid blocking event loop on network-mounted drives
988
- const content = await nodeFs.promises.readFile(neighborPath, "utf8");
989
- // #458: tier-aware cascade-lane wait. A Tier-3 (push-only,
990
- // silent-on-clean — typescript is the lone core-set instance today)
991
- // primary can never give this in-lane wait an affirmative clean
992
- // signal, so the budget is pure cost. Fire the touch (didOpen/
993
- // didChange still happens — the server starts real work) and record
994
- // it as outstanding for the agent_settled quiet window to reconcile
995
- // instead of waiting here. Ambiguous/missing capability data always
996
- // classifies as "waits" (today's behavior) — see cascade-tier.ts.
997
- // The whole attempt is try/caught: any surprise (a service shape
998
- // that doesn't expose getCapabilitySnapshots/getClientForFile, a
999
- // thrown rejection) falls through to the existing full-wait path
1000
- // below rather than skip the wait on a failure.
1001
- if (isTierAwareCascadeEnabled()) {
1002
- try {
1003
- const snapshots = (await lspService.getCapabilitySnapshots?.(neighborPath)) ?? [];
1004
- const tier = classifyCascadeWaitTier(lspService, neighborPath, snapshots);
1005
- if (tier === "tier3-silent") {
1006
- const spawnedForTouch = await lspService.getClientForFile(neighborPath);
1007
- if (spawnedForTouch) {
1008
- // Sampled BEFORE the touchFile notify: a publish landing
1009
- // in the notify→record gap must read as post-touch at
1010
- // reconcile time, never be misclassified as pre-touch
1011
- // (the reconcile compares this against the client's
1012
- // PER-FILE publish timestamp — see cascade-tier.ts).
1013
- const touchedAt = Date.now();
1014
- await lspService.touchFile(neighborPath, content, {
1015
- diagnostics: "none",
1016
- collectDiagnostics: false,
1017
- silent: true,
1018
- source: "cascade",
1019
- clientScope: "primary",
1020
- });
1021
- recordOutstandingCascadeTouch({
1022
- filePath: neighborPath,
1023
- serverId: spawnedForTouch.client.serverId,
1024
- touchedAt,
1025
- });
1026
- const durationMs = Date.now() - neighborStart;
1027
- logCascade({
1028
- phase: "cascade_tier3_skip",
1029
- filePath,
1030
- neighborFile: neighborPath,
1031
- durationMs,
1032
- lspServerCount: configuredServerCount,
1033
- coldSnapshot: isColdSnapshot,
1034
- metadata: { serverId: spawnedForTouch.client.serverId },
1035
- });
1036
- // Deliberately NOT cached as clean/diagnosed — the wait was
1037
- // skipped, not resolved, so neither neighborTouchCache nor
1038
- // recentlyCleanNeighborCache may treat this as a real answer
1039
- // (#240 doctrine). Return undefined: the degraded-fallback
1040
- // path below still has a chance to surface a passive/stale
1041
- // snapshot, same as any other "no fresh data this touch" case.
1042
- return undefined;
1043
- }
1044
- }
1416
+ }));
1417
+ const allTouchPaths = [...activePaths, ...coldSnapshotPaths];
1418
+ for (let i = 0; i < touchResults.length; i++) {
1419
+ const result = touchResults[i];
1420
+ const neighborPath = allTouchPaths[i];
1421
+ if (result.status === "fulfilled") {
1422
+ if (result.value)
1423
+ neighbors.push(result.value);
1045
1424
  }
1046
- catch (tierErr) {
1047
- dbg?.(`cascade tier-aware skip attempt failed for ${neighborPath}, falling back to full wait: ${tierErr}`);
1425
+ else {
1426
+ // A3: one failed LSP doesn't kill the rest — fall back to passive snapshot
1427
+ dbg?.(`cascade neighbor touch error for ${neighborPath}: ${result.reason}`);
1428
+ const entry = allDiags.get(normalizeMapKey(neighborPath));
1429
+ const ttlFresh = entry != null && Date.now() - entry.ts < CASCADE_TTL_MS;
1430
+ // #1104: consult binding before trusting a TTL-fresh fallback snapshot —
1431
+ // MATCH #1100/#1095 semantics (false → skip, "unknown" → keep the
1432
+ // pre-#1104 TTL-only behavior, true → use). Without this, a failed
1433
+ // active touch could still re-display a bound-false (pre-fix-edit)
1434
+ // snapshot even though the reconcile path (#1100) already refuses to
1435
+ // trust it for the widget — the widget is protected but the display
1436
+ // wasn't. Reading `.binding` triggers the lazy disk verify — done ONLY
1437
+ // when TTL-fresh, same discipline as the snapshot-tier gate above.
1438
+ const boundToDisk = ttlFresh
1439
+ ? readBoundToCurrentDisk(entry)
1440
+ : undefined;
1441
+ const bindingRejected = boundToDisk === false;
1442
+ if (bindingRejected)
1443
+ fallbackBindingRejected = true;
1444
+ logCascade({
1445
+ phase: "neighbor_fallback",
1446
+ filePath,
1447
+ neighborFile: neighborPath,
1448
+ fallbackUsed: true,
1449
+ error: String(result.reason),
1450
+ // #1104: distinguish a binding-rejected fallback (TTL-fresh but the
1451
+ // server's view diverged from disk) from a plain TTL-stale/missing
1452
+ // one — same conditional pattern as the neighbor_touch/neighbor_snapshot
1453
+ // phases above.
1454
+ ...(bindingRejected && {
1455
+ metadata: { bindingState: bindingStateLabel(boundToDisk) },
1456
+ }),
1457
+ });
1458
+ // #692: `source: "cascade"` dropped (see the doc comment above the
1459
+ // first cascade call site in this file) — no longer affects `rule`
1460
+ // and cascade output never touches persisted widget/dedup state.
1461
+ const diags = ttlFresh && !bindingRejected
1462
+ ? convertLspDiagnostics(entry.diags
1463
+ .filter((d) => d.severity === 1)
1464
+ .slice(0, MAX_PER_FILE), neighborPath)
1465
+ : [];
1466
+ neighbors.push({
1467
+ filePath: neighborPath,
1468
+ reason: "fallback",
1469
+ diagnostics: diags,
1470
+ lspTouched: false,
1471
+ });
1048
1472
  }
1049
1473
  }
1050
- // Open with silent=true (suppresses didChangeWatchedFiles rechecks, C2)
1051
- // and collect diagnostics from the same touched clients.
1052
- // Cold-snapshot neighbors (autoPropagate LSP, server warm) use a tighter
1053
- // 1000ms budget — they should respond quickly; we'd rather return zero
1054
- // than block cascade for 2s on a slow open.
1055
- const rawDiags = await lspService.touchFile(neighborPath, content, {
1056
- diagnostics: "document",
1057
- collectDiagnostics: true,
1058
- maxClientWaitMs: isColdSnapshot ? 1000 : 2000,
1059
- silent: true,
1060
- source: "cascade",
1061
- clientScope: "all",
1062
- });
1063
- if (!rawDiags)
1064
- return undefined;
1065
- // #692: `source: "cascade"` no longer overrides `rule` (see the
1066
- // doc comment on the sibling call above) — dropped rather than
1067
- // migrated to `scanOrigin` since cascade output never touches
1068
- // persisted widget/dedup state.
1069
- const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
1070
- const durationMs = Date.now() - neighborStart;
1071
- // Update cache for this neighbor at the current write sequence
1072
- if (writeSeq != null) {
1073
- neighborTouchCache.set(cacheKey, {
1074
- turnSeq,
1075
- writeSeq,
1076
- diagnostics: diags,
1077
- });
1078
- }
1079
- if (diags.length === 0) {
1080
- recentlyCleanNeighborCache.set(cacheKey, {
1081
- turnSeq,
1082
- checkedAt: Date.now(),
1083
- });
1084
- }
1085
- else {
1086
- recentlyCleanNeighborCache.delete(cacheKey);
1087
- }
1088
- producedLspData = true;
1089
- logCascade({
1090
- phase: "neighbor_touch",
1091
- filePath,
1092
- neighborFile: neighborPath,
1093
- diagnosticCount: diags.length,
1094
- durationMs,
1095
- lspTouched: true,
1096
- lspServerCount: configuredServerCount,
1097
- coldSnapshot: isColdSnapshot,
1098
- });
1099
- return {
1100
- filePath: neighborPath,
1101
- reason: neighborReason(importerSet, callerSet, neighborPath),
1102
- diagnostics: diags,
1103
- lspTouched: true,
1104
- durationMs,
1105
- };
1106
- }));
1107
- const allTouchPaths = [...activePaths, ...coldSnapshotPaths];
1108
- for (let i = 0; i < touchResults.length; i++) {
1109
- const result = touchResults[i];
1110
- const neighborPath = allTouchPaths[i];
1111
- if (result.status === "fulfilled") {
1112
- if (result.value)
1113
- neighbors.push(result.value);
1114
- }
1115
- else {
1116
- // A3: one failed LSP doesn't kill the rest — fall back to passive snapshot
1117
- dbg?.(`cascade neighbor touch error for ${neighborPath}: ${result.reason}`);
1474
+ }
1475
+ // CR-3/A2: degraded fallback when no neighbor produced trustworthy LSP data —
1476
+ // not merely when the graph returned zero neighbors.
1477
+ if (!producedLspData) {
1478
+ const bindingRejected = appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath);
1479
+ if (bindingRejected)
1480
+ fallbackBindingRejected = true;
1481
+ if (neighbors.some((n) => n.reason === "fallback")) {
1118
1482
  logCascade({
1119
1483
  phase: "neighbor_fallback",
1120
1484
  filePath,
1121
- neighborFile: neighborPath,
1122
1485
  fallbackUsed: true,
1123
- error: String(result.reason),
1124
- });
1125
- const entry = allDiags.get(normalizeMapKey(neighborPath));
1126
- // #692: `source: "cascade"` dropped (see the doc comment above the
1127
- // first cascade call site in this file) — no longer affects `rule`
1128
- // and cascade output never touches persisted widget/dedup state.
1129
- const diags = entry && Date.now() - entry.ts < CASCADE_TTL_MS
1130
- ? convertLspDiagnostics(entry.diags
1131
- .filter((d) => d.severity === 1)
1132
- .slice(0, MAX_PER_FILE), neighborPath)
1133
- : [];
1134
- neighbors.push({
1135
- filePath: neighborPath,
1136
- reason: "fallback",
1137
- diagnostics: diags,
1138
- lspTouched: false,
1486
+ neighborCount: neighbors.length,
1139
1487
  });
1140
1488
  }
1141
1489
  }
1142
- }
1143
- // CR-3/A2: degraded fallback when no neighbor produced trustworthy LSP data —
1144
- // not merely when the graph returned zero neighbors.
1145
- if (!producedLspData) {
1146
- appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd);
1147
- if (neighbors.some((n) => n.reason === "fallback")) {
1148
- logCascade({
1149
- phase: "neighbor_fallback",
1150
- filePath,
1151
- fallbackUsed: true,
1152
- neighborCount: neighbors.length,
1153
- });
1490
+ const visibleNeighbors = applyCascadeDeltaBaselines(neighbors);
1491
+ const formatted = formatCascadeResult(cwd, impact, visibleNeighbors, impact.neighborFiles.length);
1492
+ // #1104 HONESTY: filtering a bound-false display candidate must not turn a
1493
+ // degraded/indeterminate cascade into a clean-looking one (same doctrine as
1494
+ // #1023's graph-degraded marker). If every candidate the degraded-fallback
1495
+ // paths considered this run was binding-rejected and nothing else produced
1496
+ // output, thread the SAME indeterminate marker #1023 built so the turn-end
1497
+ // advisory (clients/runtime-turn.ts) surfaces an honest note instead of
1498
+ // silence — never let a withheld-stale-snapshot run look like a genuine
1499
+ // clean leaf. `!impact.indeterminate` preserves a graph-degraded marker that
1500
+ // already exists (never overwritten).
1501
+ if (!formatted && fallbackBindingRejected && !impact.indeterminate) {
1502
+ impact.indeterminate = {
1503
+ reason: "lsp_binding_rejected",
1504
+ detail: "cascade fallback diagnostics were withheld — stale snapshot content did not match current disk (binding rejected)",
1505
+ };
1154
1506
  }
1155
- }
1156
- const visibleNeighbors = applyCascadeDeltaBaselines(neighbors);
1157
- const formatted = formatCascadeResult(cwd, impact, visibleNeighbors, impact.neighborFiles.length);
1158
- const filesWithErrors = visibleNeighbors.filter((n) => n.diagnostics.length > 0).length;
1159
- logCascade({
1160
- phase: "cascade_result",
1161
- filePath,
1162
- neighborCount: visibleNeighbors.length,
1163
- diagnosticCount: visibleNeighbors.reduce((sum, n) => sum + n.diagnostics.length, 0),
1164
- metadata: {
1165
- filesWithErrors,
1166
- hasOutput: formatted.length > 0,
1167
- // Log when cascade ran but found nothing — distinguishes "clean" from "no signal"
1168
- noNeighbors: visibleNeighbors.length === 0,
1169
- noErrors: visibleNeighbors.length > 0 && filesWithErrors === 0,
1170
- neighbors: visibleNeighbors.slice(0, 10).map((n) => ({
1171
- file: n.filePath.replace(/\\/g, "/").split("/").slice(-2).join("/"),
1172
- diagnostics: n.diagnostics.length,
1173
- })),
1174
- },
1175
- });
1176
- const diagCount = visibleNeighbors.reduce((sum, n) => sum + n.diagnostics.length, 0);
1177
- cascadeSessionStats.runs += 1;
1178
- cascadeSessionStats.diagnosticsSurfaced += diagCount;
1179
- cascadeSessionStats.coldSnapshotTouches += coldSnapshotPaths.length;
1180
- if (!formatted) {
1181
- // #1023: an indeterminate compute (degraded/cold/missing-node graph) must
1182
- // NOT collapse into "no_neighbors" — that is the exact silent all-clear the
1183
- // bug is about. Distinguish it by the marker threaded onto `impact`, never
1184
- // by `visibleNeighbors.length === 0` alone (a healthy leaf is also empty).
1185
- if (impact.indeterminate) {
1507
+ const filesWithErrors = visibleNeighbors.filter((n) => n.diagnostics.length > 0).length;
1508
+ logCascade({
1509
+ phase: "cascade_result",
1510
+ filePath,
1511
+ neighborCount: visibleNeighbors.length,
1512
+ diagnosticCount: visibleNeighbors.reduce((sum, n) => sum + n.diagnostics.length, 0),
1513
+ metadata: {
1514
+ filesWithErrors,
1515
+ hasOutput: formatted.length > 0,
1516
+ // Log when cascade ran but found nothing — distinguishes "clean" from "no signal"
1517
+ noNeighbors: visibleNeighbors.length === 0,
1518
+ noErrors: visibleNeighbors.length > 0 && filesWithErrors === 0,
1519
+ neighbors: visibleNeighbors.slice(0, 10).map((n) => ({
1520
+ file: n.filePath.replace(/\\/g, "/").split("/").slice(-2).join("/"),
1521
+ diagnostics: n.diagnostics.length,
1522
+ })),
1523
+ },
1524
+ });
1525
+ const diagCount = visibleNeighbors.reduce((sum, n) => sum + n.diagnostics.length, 0);
1526
+ cascadeSessionStats.runs += 1;
1527
+ cascadeSessionStats.diagnosticsSurfaced += diagCount;
1528
+ cascadeSessionStats.coldSnapshotTouches += coldSnapshotPaths.length;
1529
+ if (!formatted) {
1530
+ // #1023: an indeterminate compute (degraded/cold/missing-node graph) must
1531
+ // NOT collapse into "no_neighbors" — that is the exact silent all-clear the
1532
+ // bug is about. Distinguish it by the marker threaded onto `impact`, never
1533
+ // by `visibleNeighbors.length === 0` alone (a healthy leaf is also empty).
1534
+ if (impact.indeterminate) {
1535
+ return {
1536
+ filePath,
1537
+ result: undefined,
1538
+ neighborCount: visibleNeighbors.length,
1539
+ diagnosticCount: diagCount,
1540
+ skipReason: "indeterminate",
1541
+ indeterminate: impact.indeterminate,
1542
+ };
1543
+ }
1544
+ const skipReason = visibleNeighbors.length === 0 ? "no_neighbors" : "clean";
1186
1545
  return {
1187
1546
  filePath,
1188
1547
  result: undefined,
1189
1548
  neighborCount: visibleNeighbors.length,
1190
1549
  diagnosticCount: diagCount,
1191
- skipReason: "indeterminate",
1192
- indeterminate: impact.indeterminate,
1550
+ skipReason,
1193
1551
  };
1194
1552
  }
1195
- const skipReason = visibleNeighbors.length === 0 ? "no_neighbors" : "clean";
1553
+ getDiagnosticTracker().trackShown(visibleNeighbors.flatMap((n) => n.diagnostics));
1196
1554
  return {
1197
1555
  filePath,
1198
- result: undefined,
1556
+ result: { filePath, impact, neighbors: visibleNeighbors, formatted },
1199
1557
  neighborCount: visibleNeighbors.length,
1200
1558
  diagnosticCount: diagCount,
1201
- skipReason,
1559
+ // #1023: even when some fallback neighbors surfaced, a degraded graph means
1560
+ // the dependent set is INCOMPLETE — carry the marker so the turn-end seam
1561
+ // still notes downstream impact was under-computed this turn.
1562
+ ...(impact.indeterminate && { indeterminate: impact.indeterminate }),
1202
1563
  };
1203
1564
  }
1204
- getDiagnosticTracker().trackShown(visibleNeighbors.flatMap((n) => n.diagnostics));
1205
- return {
1206
- filePath,
1207
- result: { filePath, impact, neighbors: visibleNeighbors, formatted },
1208
- neighborCount: visibleNeighbors.length,
1209
- diagnosticCount: diagCount,
1210
- // #1023: even when some fallback neighbors surfaced, a degraded graph means
1211
- // the dependent set is INCOMPLETE — carry the marker so the turn-end seam
1212
- // still notes downstream impact was under-computed this turn.
1213
- ...(impact.indeterminate && { indeterminate: impact.indeterminate }),
1214
- };
1565
+ finally {
1566
+ // Keep the cache entry warm, but do not let a one-shot cascade leave an
1567
+ // idle handle behind. The next consumer re-arms it through touch.
1568
+ for (const key of reverseDepsTimersToRelease) {
1569
+ const entry = reverseDepsIndexCache.get(key);
1570
+ if (entry?.idleTimer !== undefined)
1571
+ clearTimeout(entry.idleTimer);
1572
+ if (entry)
1573
+ entry.idleTimer = undefined;
1574
+ }
1575
+ for (const [key, entry] of reverseDepsIndexCache) {
1576
+ if (entry.idleTimer !== undefined)
1577
+ clearTimeout(entry.idleTimer);
1578
+ entry.idleTimer = undefined;
1579
+ if (!reverseDepsTimersToRelease.has(key) && reverseDepsEntriesAtStart.has(entry)) {
1580
+ reverseDepsTimersToRelease.add(key);
1581
+ }
1582
+ }
1583
+ for (const timer of astGrepWarnDebounceTimers.values())
1584
+ clearTimeout(timer);
1585
+ astGrepWarnDebounceTimers.clear();
1586
+ releaseWorkspaceTopologyIdleTimers();
1587
+ }
1215
1588
  }
1216
1589
  function diagnosticDeltaKey(diagnostic) {
1217
1590
  return [
@@ -1238,10 +1611,17 @@ function applyCascadeDeltaBaselines(neighbors) {
1238
1611
  };
1239
1612
  });
1240
1613
  }
1241
- function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
1614
+ /**
1615
+ * Returns `true` when at least one otherwise-eligible candidate was withheld
1616
+ * because its content binding was rejected (#1104) — the caller folds this
1617
+ * into the run-level `fallbackBindingRejected` flag for the honesty check.
1618
+ */
1619
+ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd, filePath) {
1242
1620
  const now = Date.now();
1243
1621
  const seen = new Set(neighbors.map((n) => normalizeMapKey(n.filePath)));
1244
- for (const [diagPath, { diags, ts }] of allDiags) {
1622
+ let bindingRejected = false;
1623
+ for (const [diagPath, entry] of allDiags) {
1624
+ const { diags, ts } = entry;
1245
1625
  const diagKey = normalizeMapKey(diagPath);
1246
1626
  if (diagKey === normalizedFileKey || seen.has(diagKey))
1247
1627
  continue;
@@ -1251,10 +1631,33 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
1251
1631
  continue;
1252
1632
  if (isIgnoredCascadeNeighbor(diagPath, cwd))
1253
1633
  continue;
1634
+ // #1080: a KNOWN test-role file must not surface as a collateral fallback
1635
+ // neighbor either (the passive-snapshot path the graph/reference filters
1636
+ // above never reach). Ignore filtering (#297) stays separate and above.
1637
+ if (isTestRoleCollateral(diagPath))
1638
+ continue;
1254
1639
  if (!nodeFs.existsSync(diagPath))
1255
1640
  continue;
1256
1641
  if (now - ts > CASCADE_TTL_MS)
1257
1642
  continue;
1643
+ // #1104: a TTL-fresh entry is not automatically trustworthy — consult
1644
+ // binding the same way the reconcile path (#1100) and the touch-error
1645
+ // fallback above do. `false` → skip (a stale/pre-fix-edit snapshot whose
1646
+ // server view diverged from current disk); "unknown"/`true` → unchanged
1647
+ // (the pre-#1104 fallback contract). Reading `.binding` triggers the lazy
1648
+ // disk verify — done ONLY when TTL-fresh, per the established discipline.
1649
+ const boundToDisk = readBoundToCurrentDisk(entry);
1650
+ if (boundToDisk === false) {
1651
+ bindingRejected = true;
1652
+ logCascade({
1653
+ phase: "neighbor_fallback",
1654
+ filePath,
1655
+ neighborFile: diagPath,
1656
+ fallbackUsed: false,
1657
+ metadata: { bindingState: bindingStateLabel(false) },
1658
+ });
1659
+ continue;
1660
+ }
1258
1661
  // #692: `source: "cascade"` dropped — see the doc comment above the
1259
1662
  // first cascade `convertLspDiagnostics` call site in this file.
1260
1663
  const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath);
@@ -1270,6 +1673,7 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
1270
1673
  if (neighbors.length >= MAX_FILES)
1271
1674
  break;
1272
1675
  }
1676
+ return bindingRejected;
1273
1677
  }
1274
1678
  function shouldReadCascadeFromSnapshot(filePath) {
1275
1679
  return getServersForFileWithConfig(filePath).some((server) => server.autoPropagateDiagnostics === true);
@@ -1314,11 +1718,11 @@ function formatCascadeResult(cwd, impact, neighbors, totalNeighbors) {
1314
1718
  * @param pi - Pi agent API (for flags)
1315
1719
  * @returns Output string to display to user
1316
1720
  */
1317
- export async function dispatchLint(filePath, cwd, pi, modifiedRanges) {
1721
+ export async function dispatchLint(filePath, cwd, pi, modifiedRanges, projectRoot) {
1318
1722
  // By default, only run BLOCKING rules for fast feedback on file write
1319
1723
  // Uses persistent sessionBaselines so delta mode actually filters
1320
1724
  // pre-existing issues after the first write.
1321
- const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, true, modifiedRanges);
1725
+ const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, true, modifiedRanges, projectRoot);
1322
1726
  sessionFacts.clearFileFactsFor(ctx.filePath);
1323
1727
  const kind = ctx.kind;
1324
1728
  if (!kind)
@@ -1338,7 +1742,7 @@ export async function dispatchLintWithResult(filePath, cwd, pi, modifiedRanges,
1338
1742
  // Default true preserves the per-edit fast path (errors only). Callers that
1339
1743
  // want the full picture (warnings + structural smells), e.g. the MCP review
1340
1744
  // facade, pass blockingOnly=false to run every runner.
1341
- const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? true, modifiedRanges);
1745
+ const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? true, modifiedRanges, options?.projectRoot, options?.writeIndex);
1342
1746
  sessionFacts.clearFileFactsFor(ctx.filePath);
1343
1747
  const kind = ctx.kind;
1344
1748
  if (!kind) {
@@ -1400,7 +1804,7 @@ export async function dispatchLintDetailed(filePath, cwd, pi, options) {
1400
1804
  blockerOutput: "",
1401
1805
  hasBlockers: false,
1402
1806
  };
1403
- const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? false, options?.modifiedRanges);
1807
+ const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? false, options?.modifiedRanges, options?.projectRoot);
1404
1808
  sessionFacts.clearFileFactsFor(ctx.filePath);
1405
1809
  const kind = ctx.kind;
1406
1810
  if (!kind)