@herbertgao/pi-extensions 2026.8.14 → 2026.9.0

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 (270) hide show
  1. package/README.md +19 -13
  2. package/THIRD_PARTY_NOTICES.md +26 -1
  3. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +8 -1
  4. package/node_modules/@czottmann/pi-automode/README.md +4 -0
  5. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +71 -9
  6. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  7. package/node_modules/@herbertgao/pi-bark/LICENSE +21 -0
  8. package/node_modules/@herbertgao/pi-bark/README.md +44 -0
  9. package/node_modules/@herbertgao/pi-bark/assets/pi-icon.png +0 -0
  10. package/node_modules/@herbertgao/pi-bark/package.json +53 -0
  11. package/node_modules/@herbertgao/pi-bark/src/index.ts +178 -0
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +20 -0
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +46 -3
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +6 -0
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/scroll.ts +5 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +34 -30
  18. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  19. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  20. package/node_modules/@narumitw/pi-btw/README.md +129 -88
  21. package/node_modules/@narumitw/pi-btw/dist/index.ts +283 -59
  22. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
  23. package/node_modules/@narumitw/pi-btw/package.json +5 -8
  24. package/node_modules/@narumitw/pi-btw/src/btw.ts +31 -26
  25. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +264 -24
  26. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  27. package/node_modules/@narumitw/pi-btw/src/settings.ts +16 -0
  28. package/node_modules/@narumitw/pi-btw/src/text.ts +18 -0
  29. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +5 -16
  30. package/node_modules/@narumitw/pi-caffeinate/LICENSE +21 -0
  31. package/node_modules/@narumitw/pi-caffeinate/README.md +224 -0
  32. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +1112 -0
  33. package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +7 -0
  34. package/node_modules/@narumitw/pi-caffeinate/package.json +53 -0
  35. package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +811 -0
  36. package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +153 -0
  37. package/node_modules/@narumitw/pi-caffeinate/src/index.ts +1 -0
  38. package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +43 -0
  39. package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +167 -0
  40. package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +202 -0
  41. package/node_modules/@tifan/pi-handoff/README.md +39 -13
  42. package/node_modules/@tifan/pi-handoff/package.json +2 -2
  43. package/node_modules/@tifan/pi-handoff/src/index.ts +166 -5
  44. package/node_modules/@tifan/pi-recap/README.md +1 -1
  45. package/node_modules/@tifan/pi-recap/package.json +2 -2
  46. package/node_modules/@tifan/pi-recap/src/index.ts +15 -0
  47. package/node_modules/@tifan/pi-rename/README.md +42 -21
  48. package/node_modules/@tifan/pi-rename/package.json +1 -1
  49. package/node_modules/@tifan/pi-rename/src/index.ts +62 -11
  50. package/node_modules/@tifan/pi-rename/src/language.ts +18 -0
  51. package/node_modules/@tifan/pi-rename/src/models.ts +69 -13
  52. package/node_modules/@tifan/pi-rename/src/naming.ts +36 -7
  53. package/node_modules/@tifan/pi-rename/src/sanitize.ts +22 -14
  54. package/node_modules/pi-lens/CHANGELOG.md +320 -0
  55. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +79 -0
  56. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +16 -1
  57. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +13 -6
  58. package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +12 -0
  59. package/node_modules/pi-lens/dist/clients/biome-client.js +7 -0
  60. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +20 -0
  61. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +56 -19
  62. package/node_modules/pi-lens/dist/clients/build-identity.js +95 -0
  63. package/node_modules/pi-lens/dist/clients/bus-publish.js +3 -0
  64. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +2 -1
  65. package/node_modules/pi-lens/dist/clients/cache-observability.js +2 -1
  66. package/node_modules/pi-lens/dist/clients/cascade-logger.js +15 -1
  67. package/node_modules/pi-lens/dist/clients/dead-code-client.js +14 -11
  68. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +56 -4
  69. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +14 -0
  70. package/node_modules/pi-lens/dist/clients/dependency-checker.js +20 -3
  71. package/node_modules/pi-lens/dist/clients/deps/minimatch.js +1 -1
  72. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +39 -0
  73. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +150 -9
  74. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +231 -9
  75. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +205 -100
  76. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +87 -0
  77. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +21 -5
  78. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +174 -55
  79. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +5 -1
  80. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +16 -3
  81. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +54 -9
  82. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +1 -3
  83. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -1
  84. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -1
  85. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +11 -4
  86. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -0
  87. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +8 -3
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +76 -25
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +7 -13
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  93. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +66 -1
  94. package/node_modules/pi-lens/dist/clients/file-utils.js +157 -7
  95. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +5 -0
  96. package/node_modules/pi-lens/dist/clients/formatters.js +28 -3
  97. package/node_modules/pi-lens/dist/clients/git-guard.js +197 -47
  98. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +32 -1
  99. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +5 -1
  100. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +7 -0
  101. package/node_modules/pi-lens/dist/clients/installer/index.js +131 -34
  102. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +3 -1
  103. package/node_modules/pi-lens/dist/clients/instance-reaper.js +4 -13
  104. package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +145 -0
  105. package/node_modules/pi-lens/dist/clients/instance-registry.js +432 -122
  106. package/node_modules/pi-lens/dist/clients/jscpd-client.js +6 -1
  107. package/node_modules/pi-lens/dist/clients/language-profile.js +4 -2
  108. package/node_modules/pi-lens/dist/clients/latency-logger.js +19 -1
  109. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  110. package/node_modules/pi-lens/dist/clients/lens-map.js +3 -1
  111. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +4 -0
  112. package/node_modules/pi-lens/dist/clients/lsp/client.js +516 -31
  113. package/node_modules/pi-lens/dist/clients/lsp/config.js +25 -2
  114. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +9 -4
  115. package/node_modules/pi-lens/dist/clients/lsp/index.js +509 -182
  116. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +5 -0
  117. package/node_modules/pi-lens/dist/clients/lsp/launch.js +36 -8
  118. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +116 -4
  119. package/node_modules/pi-lens/dist/clients/lsp/server.js +142 -13
  120. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +99 -0
  121. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +16 -6
  122. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  123. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +16 -2
  124. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +12 -6
  125. package/node_modules/pi-lens/dist/clients/mcp/session.js +14 -1
  126. package/node_modules/pi-lens/dist/clients/memory-sampler.js +23 -1
  127. package/node_modules/pi-lens/dist/clients/message-end-attribution.js +30 -0
  128. package/node_modules/pi-lens/dist/clients/metrics-history.js +21 -4
  129. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +232 -18
  130. package/node_modules/pi-lens/dist/clients/package-manager.js +6 -18
  131. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +14 -1
  132. package/node_modules/pi-lens/dist/clients/path-utils.js +32 -0
  133. package/node_modules/pi-lens/dist/clients/pipeline.js +0 -8
  134. package/node_modules/pi-lens/dist/clients/process-singletons.js +210 -0
  135. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  136. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +10 -4
  137. package/node_modules/pi-lens/dist/clients/project-snapshot.js +33 -11
  138. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +24 -2
  139. package/node_modules/pi-lens/dist/clients/read-guard.js +74 -7
  140. package/node_modules/pi-lens/dist/clients/recent-touches.js +17 -5
  141. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +639 -62
  142. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +5 -1
  143. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +21 -1
  144. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +68 -0
  145. package/node_modules/pi-lens/dist/clients/runtime-session.js +30 -12
  146. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +15 -0
  147. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +106 -7
  148. package/node_modules/pi-lens/dist/clients/runtime-turn.js +242 -27
  149. package/node_modules/pi-lens/dist/clients/safe-spawn.js +158 -18
  150. package/node_modules/pi-lens/dist/clients/security-scan-client.js +48 -17
  151. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +238 -42
  152. package/node_modules/pi-lens/dist/clients/sg-runner.js +27 -15
  153. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +338 -0
  154. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +37 -0
  155. package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +15 -1
  156. package/node_modules/pi-lens/dist/clients/startup-scan.js +6 -7
  157. package/node_modules/pi-lens/dist/clients/startup-timing.js +69 -4
  158. package/node_modules/pi-lens/dist/clients/string-utils.js +20 -0
  159. package/node_modules/pi-lens/dist/clients/test-runner-client.js +419 -88
  160. package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
  161. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +11 -5
  162. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +17 -1
  163. package/node_modules/pi-lens/dist/clients/warm-attach.js +8 -10
  164. package/node_modules/pi-lens/dist/clients/word-index-logger.js +13 -1
  165. package/node_modules/pi-lens/dist/clients/word-index-store.js +448 -0
  166. package/node_modules/pi-lens/dist/clients/word-index.js +582 -104
  167. package/node_modules/pi-lens/dist/clients/workspace-topology.js +17 -0
  168. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -6
  169. package/node_modules/pi-lens/dist/index.js +11243 -6651
  170. package/node_modules/pi-lens/dist/mcp/build-staleness.js +8 -7
  171. package/node_modules/pi-lens/dist/mcp/cli.js +3 -1
  172. package/node_modules/pi-lens/dist/mcp/server.js +6 -1
  173. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +4 -0
  174. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +14 -5
  175. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +3 -1
  176. package/node_modules/pi-lens/docs/agent-guide.md +4 -2
  177. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +41 -10
  178. package/node_modules/pi-lens/docs/astplayground.md +26 -17
  179. package/node_modules/pi-lens/package.json +5 -1
  180. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  181. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  182. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  183. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  184. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  185. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  186. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  187. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  188. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  189. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  190. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  191. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  192. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  193. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  194. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  195. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  196. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  197. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  198. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  199. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  200. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  201. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  202. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  203. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  204. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  205. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  206. package/node_modules/pi-lens/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  207. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  208. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  209. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  210. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  211. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  212. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  213. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  214. package/node_modules/pi-lens/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  215. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  216. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  217. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  218. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  219. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  220. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  221. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  222. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  223. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-important.yml +21 -0
  224. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  225. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  226. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  227. package/node_modules/pi-lens/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  228. package/node_modules/pi-lens/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  229. package/node_modules/pi-lens/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  230. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  231. package/node_modules/pi-lens/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  232. package/node_modules/pi-mcp-adapter/CHANGELOG.md +42 -0
  233. package/node_modules/pi-mcp-adapter/README.md +15 -15
  234. package/node_modules/pi-mcp-adapter/commands.ts +59 -22
  235. package/node_modules/pi-mcp-adapter/config.ts +18 -4
  236. package/node_modules/pi-mcp-adapter/direct-tools.ts +26 -6
  237. package/node_modules/pi-mcp-adapter/dist/config.d.ts +4 -0
  238. package/node_modules/pi-mcp-adapter/dist/config.js +13 -4
  239. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  240. package/node_modules/pi-mcp-adapter/dist/types.d.ts +12 -4
  241. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  242. package/node_modules/pi-mcp-adapter/error-signal.ts +15 -4
  243. package/node_modules/pi-mcp-adapter/errors.ts +141 -0
  244. package/node_modules/pi-mcp-adapter/host-html-template.ts +58 -5
  245. package/node_modules/pi-mcp-adapter/index.ts +2 -3
  246. package/node_modules/pi-mcp-adapter/init.ts +5 -0
  247. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +105 -32
  248. package/node_modules/pi-mcp-adapter/mcp-panel.ts +30 -9
  249. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +66 -28
  250. package/node_modules/pi-mcp-adapter/mcp-status.ts +2 -0
  251. package/node_modules/pi-mcp-adapter/package.json +2 -1
  252. package/node_modules/pi-mcp-adapter/proxy-modes.ts +88 -25
  253. package/node_modules/pi-mcp-adapter/sandbox-proxy-template.ts +217 -0
  254. package/node_modules/pi-mcp-adapter/server-manager.ts +343 -6
  255. package/node_modules/pi-mcp-adapter/skills/mcp-scripting/SKILL.md +1 -0
  256. package/node_modules/pi-mcp-adapter/types.ts +19 -4
  257. package/node_modules/pi-mcp-adapter/ui-resource-handler.ts +18 -2
  258. package/node_modules/pi-mcp-adapter/ui-server.ts +179 -54
  259. package/node_modules/pi-mcp-adapter/ui-session.ts +28 -8
  260. package/node_modules/pi-web-access/CHANGELOG.md +20 -0
  261. package/node_modules/pi-web-access/README.md +15 -2
  262. package/node_modules/pi-web-access/extract.ts +57 -12
  263. package/node_modules/pi-web-access/github-extract.ts +47 -3
  264. package/node_modules/pi-web-access/index.ts +1 -1
  265. package/node_modules/pi-web-access/package.json +1 -1
  266. package/node_modules/pi-web-access/page-query.ts +61 -8
  267. package/package.json +21 -17
  268. package/node_modules/@tifan/pi-titlebar-spinner/README.md +0 -24
  269. package/node_modules/@tifan/pi-titlebar-spinner/package.json +0 -39
  270. package/node_modules/@tifan/pi-titlebar-spinner/src/index.ts +0 -49
@@ -18,6 +18,8 @@ import { containerNameChain, getOpenDocumentSymbols, lspSymbolKindName, } from "
18
18
  import { isAtOrAboveHomeDir, normalizeFilePath, normalizeMapKey, toProjectRelativePath, } from "../path-utils.js";
19
19
  import { collectProjectSourceFilesWithBudgetAsync } from "../project-scan-policy.js";
20
20
  import { getReviewGraphMaxFilesDerived } from "../project-scale.js";
21
+ import { compareOrdinal } from "../string-utils.js";
22
+ import { BoundedLruCache } from "../bounded-cache.js";
21
23
  import { jsTsCandidatePaths, resolveAliasedImport, resolveImportToFiles, resolveProjectReferenceImport, resolveWorkspacePackageImport, } from "./import-resolvers.js";
22
24
  import { RUNTIME_CONFIG } from "../runtime-config.js";
23
25
  import { buildReverseDependencyIndexFromGraph, rankFilesByReverseDependencyCentrality, } from "../reverse-deps.js";
@@ -94,6 +96,52 @@ const MAIN_KIND_EXTENSIONS = Array.from(MAIN_KINDS).flatMap((kind) => KIND_EXTEN
94
96
  export const REVIEW_GRAPH_SOURCE_EXTENSIONS = MAIN_KIND_EXTENSIONS;
95
97
  const CHANGED_SYMBOLS_PREFIX = "session.reviewGraph.changedSymbols:";
96
98
  const extractorCache = new Map();
99
+ const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
100
+ // Walker output is raw, so its spelling still needs the canonical path seam.
101
+ // Keep that expensive raw-to-canonical step per project across builds, bounded
102
+ // like TestRunnerClient's instance-lifetime canonicalRootMemo (#2058). A new
103
+ // spelling is resolved once; repeated builds reuse the result. The project
104
+ // memo is bounded and workspace-cache clears provide a freshness boundary.
105
+ const REVIEW_GRAPH_SOURCE_PATH_MEMO_ENTRIES = 16_384;
106
+ const _sourcePathMemos = new Map();
107
+ function sourcePathMemo(cwd) {
108
+ const key = normalizeMapKey(path.resolve(cwd));
109
+ const existing = _sourcePathMemos.get(key);
110
+ if (existing) {
111
+ // The workspace map is also bounded LRU; a hit must refresh its recency.
112
+ _sourcePathMemos.delete(key);
113
+ _sourcePathMemos.set(key, existing);
114
+ return existing;
115
+ }
116
+ const memo = {
117
+ cache: new BoundedLruCache(REVIEW_GRAPH_SOURCE_PATH_MEMO_ENTRIES),
118
+ normalizeCalls: { value: 0 },
119
+ };
120
+ _sourcePathMemos.set(key, memo);
121
+ while (_sourcePathMemos.size > REVIEW_GRAPH_MAX_WARM_WORKSPACES) {
122
+ const oldest = _sourcePathMemos.keys().next().value;
123
+ if (oldest === undefined)
124
+ break;
125
+ _sourcePathMemos.delete(oldest);
126
+ }
127
+ return memo;
128
+ }
129
+ function normalizeGraphSourcePath(memo, raw) {
130
+ const cached = memo.cache.get(raw);
131
+ if (cached !== undefined)
132
+ return cached;
133
+ const normalized = normalizeMapKey(raw);
134
+ // A missing file is normalized through resolveNonExisting, which lowercases
135
+ // its tail. It may be recreated with different casing before the next build,
136
+ // so never make that spelling a process-lifetime memo entry (#2072 F2).
137
+ if (fs.existsSync(raw))
138
+ memo.cache.set(raw, normalized);
139
+ memo.normalizeCalls.value++;
140
+ return normalized;
141
+ }
142
+ export function _resetReviewGraphSourcePathMemoForTests() {
143
+ _sourcePathMemos.clear();
144
+ }
97
145
  // IN-FLIGHT Promise cache: deduplicates CONCURRENT buildOrUpdateGraph calls for
98
146
  // the same (cwd, changedFiles). A separate workspace cache below preserves the
99
147
  // expensive parsed graph across invocations when source file mtimes/sizes have
@@ -111,7 +159,6 @@ const extractorCache = new Map();
111
159
  // operation's, not the process's.
112
160
  const _buildCache = new Map();
113
161
  const _workspaceGraphCache = new Map();
114
- const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
115
162
  const REVIEW_GRAPH_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
116
163
  // A workspace-wide clear must invalidate builds for workspaces that are not
117
164
  // resident yet, too. This process-wide component therefore survives cache
@@ -166,6 +213,7 @@ function evictWorkspaceGraph(key, entry) {
166
213
  _buildCache.delete(buildKey);
167
214
  }
168
215
  }
216
+ _sourcePathMemos.delete(key);
169
217
  _workspaceCacheEpochs.set(key, (_workspaceCacheEpochs.get(key) ?? 0) + 1);
170
218
  _workspaceGraphCache.delete(key);
171
219
  }
@@ -191,8 +239,16 @@ function touchWorkspaceGraph(key) {
191
239
  function setWorkspaceGraph(key, entry, epoch) {
192
240
  if (epoch !== undefined && workspaceCacheEpoch(key) !== epoch)
193
241
  return false;
242
+ const previous = _workspaceGraphCache.get(key);
243
+ if (previous)
244
+ clearWorkspaceGraphTimer(previous);
245
+ // #2255: bound what the cache RETAINS. The caller keeps its full-graph
246
+ // reference for the current turn; only the retained copy is trimmed, so an
247
+ // over-budget repo never accumulates an unbounded graph across the process.
248
+ const boundedGraph = retainedGraph(key, entry.graph);
194
249
  const resident = {
195
250
  ...entry,
251
+ graph: boundedGraph,
196
252
  lastUsedAt: Date.now(),
197
253
  };
198
254
  _workspaceGraphCache.set(key, resident);
@@ -292,8 +348,14 @@ export function clearReviewGraphWorkspaceCache(cwd) {
292
348
  for (const entry of _workspaceGraphCache.values())
293
349
  clearWorkspaceGraphTimer(entry);
294
350
  _workspaceGraphCache.clear();
351
+ _sourcePathMemos.clear();
295
352
  _workspaceCacheEpoch++;
296
353
  _sizeSkipVerdicts.clear();
354
+ // #2255 review V2: the non-cache retention registry is memory-attribution
355
+ // state for THIS session's graphs. Without this, a new session's
356
+ // `memory_sample` kept attributing the previous session's graph, which the
357
+ // registry still held a live `WeakRef` to.
358
+ _retainedGraphSites.clear();
297
359
  }
298
360
  else {
299
361
  const normalized = normalizeMapKey(cwd);
@@ -311,6 +373,7 @@ export function clearReviewGraphWorkspaceCache(cwd) {
311
373
  clearWorkspaceGraphTimer(entry);
312
374
  _workspaceCacheEpochs.set(normalized, (_workspaceCacheEpochs.get(normalized) ?? 0) + 1);
313
375
  _workspaceGraphCache.delete(normalized);
376
+ _sourcePathMemos.delete(normalized);
314
377
  _sizeSkipVerdicts.delete(normalized);
315
378
  }
316
379
  _lastGraphBuildInfo = { reused: false, mode: "full", graphChanged: true };
@@ -324,23 +387,214 @@ export function _setReviewGraphBuildGateForTests(gate) {
324
387
  _reviewGraphBuildGateForTests = gate;
325
388
  }
326
389
  /**
327
- * O(cache-entries) snapshot of the resident workspace graph cache — NOT
328
- * O(nodes)/O(edges): only `.size`/`.length` are read per entry, never the
329
- * graph contents themselves (#1123 item 2 memory-attribution sample). Cache
330
- * entries are per-cwd and normally number 1 (a session_start clears the
331
- * cache), so this is cheap enough for a per-N-turn sample.
390
+ * Measured heap coefficients for the graph's current object shape. An
391
+ * isolated store of the production node and edge objects was forced through
392
+ * GC and divided by its census: 450.5 bytes per node and 243.0 bytes per
393
+ * edge, including the two edge-index references. The measurement used 18,695
394
+ * nodes and 48,815 edges across three identical runs with --expose-gc.
395
+ */
396
+ const REVIEW_GRAPH_NODE_RESIDENT_BYTES = 450.5;
397
+ const REVIEW_GRAPH_EDGE_RESIDENT_BYTES = 243.0;
398
+ export function estimateReviewGraphStoreBytes(totalNodes, totalEdges) {
399
+ return (totalNodes * REVIEW_GRAPH_NODE_RESIDENT_BYTES +
400
+ totalEdges * REVIEW_GRAPH_EDGE_RESIDENT_BYTES);
401
+ }
402
+ // --- In-memory live-graph byte bound (#2255) ---
403
+ // The persist element cap (`GRAPH_PERSIST_MAX_ELEMENTS_DEFAULT`) guards the
404
+ // synchronous serialize+gzip spike; it trims only the on-disk snapshot. The live
405
+ // `ReviewGraph` had no bound and grew with project size — the second unbounded
406
+ // dispatch store behind the #2240 OOM (FactStore's fileFacts was the first,
407
+ // bounded in #2243). This bound caps the RETAINED live graph by estimated
408
+ // resident bytes, using the same centrality-ranked induced-subgraph selection the
409
+ // snapshot uses (`capGraphForPersist`).
410
+ //
411
+ // A graph has TWO process-lifetime retention sites, not one: the workspace cache,
412
+ // and `session.reviewGraph` on a caller's FactStore — and two of those stores are
413
+ // module-scope (`dispatch/integration.ts`, `mcp/analyze.ts`, the latter never
414
+ // cleared). Bounding only the cache left the full graph resident on the fact, so
415
+ // both sites go through `retainedGraph` below, memoized per graph instance so the
416
+ // two sites share ONE bounded object instead of trimming twice (#2255 review F2).
417
+ export const GRAPH_MAX_IN_MEMORY_BYTES_DEFAULT = 512 * 1024 * 1024;
418
+ function graphMaxInMemoryBytes() {
419
+ const raw = Number(process.env.PI_LENS_GRAPH_MAX_IN_MEMORY_BYTES);
420
+ return Number.isFinite(raw) && raw > 0
421
+ ? raw
422
+ : GRAPH_MAX_IN_MEMORY_BYTES_DEFAULT;
423
+ }
424
+ /**
425
+ * The graph any process-lifetime holder may RETAIN, bounded to the in-memory byte
426
+ * budget (#2255). Callers keep the full graph for the current turn; only what
427
+ * outlives the turn goes through here.
428
+ *
429
+ * In budget, this is an O(1) size read returning the same object, so a normal
430
+ * repository sees no behavior change. Over budget, the byte budget converts to an
431
+ * element cap using the graph's own node/edge split, then `capGraphForPersist`
432
+ * runs one centrality selection.
433
+ *
434
+ * Deliberately NOT memoized. A memo keyed on the source graph short-circuits the
435
+ * budget check, so a graph mutated in place after its first trim keeps answering
436
+ * with the stale earlier result (#2255 review R4). Callers that retain the same
437
+ * graph at two sites dedupe at the CALL SITE by capping once and sharing the
438
+ * result, which is both simpler and staleness-free.
439
+ *
440
+ * The result is marked `partial` AND `capTrimmed`. `partial` keeps every
441
+ * coverage-reporting consumer honest; `capTrimmed` records the narrower fact that
442
+ * the source WALK was complete and only size was cut, which is what lets the two
443
+ * incremental-base gates rebuild from it instead of forcing a full walk every
444
+ * turn. `capTrimmed` is process-local and stripped before persist.
445
+ */
446
+ function retainedGraph(cwd, graph) {
447
+ const nodes = graph.nodes.size;
448
+ const edges = graph.edges.length;
449
+ const bytes = estimateReviewGraphStoreBytes(nodes, edges);
450
+ const budget = graphMaxInMemoryBytes();
451
+ // Deliberately NOT skipped for an already-partial graph. A full build over the
452
+ // source-walk entry budget sets `partial: true` on a graph that is still the
453
+ // full walked set, so skipping partials exempted the exact population this
454
+ // bound targets (#2255 review F3). The byte check below is the only
455
+ // idempotence this needs: a graph already under budget is returned as-is.
456
+ if (bytes <= budget)
457
+ return graph;
458
+ // Scale both axes by the same budget/bytes ratio so the element cap preserves
459
+ // the graph's node/edge split; `capGraphForPersist` re-splits the cap by that
460
+ // same ratio, landing the capped graph at or under the budget.
461
+ const elementCap = Math.max(1, Math.floor(((nodes + edges) * budget) / bytes));
462
+ let capped = capGraphForPersist(cwd, graph, elementCap);
463
+ // Floor: a selection that retains NOTHING is never an acceptable answer for a
464
+ // non-empty input — it silently converts "too big" into "no graph at all", and
465
+ // every query against it reads as a clean empty result (#2255 review F4).
466
+ // Fall back to a deterministic head slice, which is still bounded by the same
467
+ // element cap but cannot be empty, and say so under its own ledger kind so the
468
+ // two outcomes are never blended into one record.
469
+ if (capped.nodes.size === 0 && nodes > 0) {
470
+ capped = headSliceGraph(graph, elementCap);
471
+ incrementDegradationCount({
472
+ kind: "review-graph-memory-cap-floor",
473
+ subject: cwd,
474
+ reason: `centrality selection retained 0 of ${nodes} nodes at cap ${elementCap}; fell back to a ${capped.nodes.size}-node head slice`,
475
+ });
476
+ }
477
+ const cappedNodes = capped.nodes.size;
478
+ const cappedEdges = capped.edges.length;
479
+ capped.persistCoverage = {
480
+ ...(capped.persistCoverage ?? graphCoverage(capped, elementCap)),
481
+ partial: true,
482
+ capTrimmed: true,
483
+ };
484
+ incrementDegradationCount({
485
+ kind: "review-graph-memory-cap",
486
+ subject: cwd,
487
+ reason: `live graph ${nodes}n/${edges}e ~${Math.round(bytes / (1024 * 1024))}MiB over ${Math.round(budget / (1024 * 1024))}MiB budget; trimmed to ${cappedNodes}n/${cappedEdges}e ~${Math.round(estimateReviewGraphStoreBytes(cappedNodes, cappedEdges) / (1024 * 1024))}MiB`,
488
+ });
489
+ return capped;
490
+ }
491
+ /**
492
+ * Deterministic non-empty fallback selection: take whole per-file node groups in
493
+ * stable path order until the element cap is reached, then the induced edges.
494
+ * Used only when centrality selection returns nothing (#2255 review F4).
495
+ */
496
+ function headSliceGraph(graph, cap) {
497
+ const nodeBudget = Math.max(1, Math.floor((cap * graph.nodes.size) / (graph.nodes.size + graph.edges.length)));
498
+ const keptIds = new Set();
499
+ for (const [id] of graph.nodes) {
500
+ if (keptIds.size >= nodeBudget)
501
+ break;
502
+ keptIds.add(id);
503
+ }
504
+ const nodes = new Map([...graph.nodes].filter(([id]) => keptIds.has(id)));
505
+ const edgeBudget = Math.max(0, cap - nodes.size);
506
+ const edges = graph.edges
507
+ .filter((edge) => keptIds.has(edge.from) && keptIds.has(edge.to))
508
+ .slice(0, edgeBudget);
509
+ const sliced = {
510
+ ...graph,
511
+ nodes,
512
+ edges,
513
+ edgesByFrom: new Map(),
514
+ edgesByTo: new Map(),
515
+ fileNodes: new Map(),
516
+ symbolNodesByFile: new Map(),
517
+ changedSymbolsByFile: new Map(),
518
+ persistCoverage: undefined,
519
+ };
520
+ rebuildIndexes(sliced);
521
+ return sliced;
522
+ }
523
+ /**
524
+ * Store the graph on `session.reviewGraph` bounded (#2255 review F2). Two of the
525
+ * FactStores this reaches are module-scope, so an unbounded value here outlives
526
+ * every session and defeats the cache bound entirely.
527
+ *
528
+ * This bounds the VALUE, not the store, and deliberately adds no second bounding
529
+ * mechanism to `FactStore` (#2243 owns the store-level discipline).
530
+ *
531
+ * `sessionFacts` entry COUNT is not fully bounded — `session.baseline.${path}`
532
+ * (`dispatch/dispatcher.ts`) and `session.baseline.cascade.${path}`
533
+ * (`dispatch/integration.ts`) mint one key per file touched and clear only on
534
+ * `clearAll`. That is #2282, a separate #2240 sibling; it is
535
+ * per-file `Diagnostic[]`, not a whole project graph. What this seam fixes is the
536
+ * one fact whose single VALUE is unbounded in project size.
537
+ */
538
+ function setSessionReviewGraphFact(cwd, facts, graph) {
539
+ const retained = retainedGraph(cwd, graph);
540
+ // The raw `cwd` is the registry key on purpose. Folding it here would add a
541
+ // `realpath` probe to EVERY build, and the key only has to be stable: the
542
+ // snapshot deduplicates by graph object IDENTITY, so two spellings of one
543
+ // workspace cost one extra bounded registry slot and never a double count.
544
+ registerRetainedGraph(`fact:${cwd}`, retained);
545
+ facts.setSessionFact("session.reviewGraph", retained);
546
+ }
547
+ // Retention sites OUTSIDE the workspace cache — today `session.reviewGraph` on a
548
+ // caller's FactStore. Held through `WeakRef` so registering adds no retention of
549
+ // its own: a graph whose only remaining referent is this registry is collectable,
550
+ // and its slot is pruned on the next write or read. Keyed by site, so the map is
551
+ // bounded by the number of distinct workspaces, not by builds (#2255 review F2).
552
+ const _retainedGraphSites = new Map();
553
+ function registerRetainedGraph(site, graph) {
554
+ for (const [key, ref] of _retainedGraphSites) {
555
+ if (ref.deref() === undefined)
556
+ _retainedGraphSites.delete(key);
557
+ }
558
+ _retainedGraphSites.set(site, new WeakRef(graph));
559
+ }
560
+ /**
561
+ * O(retention-sites) snapshot of every resident review graph — NOT
562
+ * O(nodes)/O(edges): only `.size`/`.length` are read per graph, never the graph
563
+ * contents themselves (#1123 item 2 memory-attribution sample).
564
+ *
565
+ * Counts the workspace cache AND the non-cache retention sites, deduplicated by
566
+ * object IDENTITY. Reading the cache alone reported zero bytes while a full graph
567
+ * was still resident on `session.reviewGraph` — the sample read clean during the
568
+ * exact heap exhaustion it is cited to prove against (#2255 review F2). The
569
+ * dedupe matters because the bound hands both sites the same bounded object;
570
+ * summing them would double-count the common case.
332
571
  */
333
572
  export function getReviewGraphWorkspaceCacheSnapshot() {
334
573
  let totalNodes = 0;
335
574
  let totalEdges = 0;
336
- for (const entry of _workspaceGraphCache.values()) {
337
- totalNodes += entry.graph.nodes.size;
338
- totalEdges += entry.graph.edges.length;
575
+ const counted = new Set();
576
+ const count = (graph) => {
577
+ if (counted.has(graph))
578
+ return;
579
+ counted.add(graph);
580
+ totalNodes += graph.nodes.size;
581
+ totalEdges += graph.edges.length;
582
+ };
583
+ for (const entry of _workspaceGraphCache.values())
584
+ count(entry.graph);
585
+ for (const [key, ref] of _retainedGraphSites) {
586
+ const graph = ref.deref();
587
+ if (graph === undefined) {
588
+ _retainedGraphSites.delete(key);
589
+ continue;
590
+ }
591
+ count(graph);
339
592
  }
340
593
  return {
341
594
  cacheEntries: _workspaceGraphCache.size,
342
595
  totalNodes,
343
596
  totalEdges,
597
+ residentBytes: estimateReviewGraphStoreBytes(totalNodes, totalEdges),
344
598
  };
345
599
  }
346
600
  /** Test-only cache keys, including the LRU order from oldest to newest. */
@@ -349,6 +603,26 @@ export function _getReviewGraphWorkspaceCacheKeysForTests() {
349
603
  .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
350
604
  .map(([key]) => key);
351
605
  }
606
+ /** Test-only replacement seam for the workspace idle-eviction family. */
607
+ export function _setReviewGraphWorkspaceEntryForTests(key, graph) {
608
+ setWorkspaceGraph(key, {
609
+ signature: "test",
610
+ fileSignatures: new Map(),
611
+ graph,
612
+ });
613
+ }
614
+ /** Test-only drive of the session-fact retention seam the build paths use. */
615
+ export function _setSessionReviewGraphFactForTests(cwd, facts, graph) {
616
+ setSessionReviewGraphFact(cwd, facts, graph);
617
+ }
618
+ /** Test-only view of what a graph's coverage looks like once persisted. */
619
+ export function _persistedCoverageForTests(coverage) {
620
+ return stripProcessLocalCoverage(coverage);
621
+ }
622
+ /** Test-only read of the exact retained graph for a raw cache key (#2255). */
623
+ export function _getReviewGraphWorkspaceGraphForTests(key) {
624
+ return _workspaceGraphCache.get(key)?.graph;
625
+ }
352
626
  export function _getReviewGraphCacheStateForTests(cwd) {
353
627
  const cached = _workspaceGraphCache.get(normalizeMapKey(cwd));
354
628
  if (!cached)
@@ -628,7 +902,7 @@ async function sourceSignatureMapAsync(files) {
628
902
  }
629
903
  function sourceSignatureFromMap(signatures) {
630
904
  return [...signatures.entries()]
631
- .sort(([a], [b]) => a.localeCompare(b))
905
+ .sort(([a], [b]) => compareOrdinal(a, b))
632
906
  .map(([file, signature]) => `${file}:${signature}`)
633
907
  .join("|");
634
908
  }
@@ -850,6 +1124,8 @@ export function _setReviewGraphEntryCounterForTests(counter) {
850
1124
  _reviewGraphEntryCounterForTests = counter;
851
1125
  }
852
1126
  export async function getGraphSourceFiles(cwd) {
1127
+ const sourceMemo = sourcePathMemo(cwd);
1128
+ const normalizeCallsBefore = sourceMemo.normalizeCalls.value;
853
1129
  // Async, chunked-yield walk (identical output to the sync collector) so the
854
1130
  // per-edit cascade graph rebuild doesn't block the event loop on a large repo.
855
1131
  //
@@ -889,6 +1165,7 @@ export async function getGraphSourceFiles(cwd) {
889
1165
  // so the caller's `length > maxGraphFiles` check still trips.
890
1166
  return {
891
1167
  files: collected,
1168
+ pathNormalizeCalls: sourceMemo.normalizeCalls.value - normalizeCallsBefore,
892
1169
  sourceFileCount: collected.length,
893
1170
  maxFileCount: maxGraphFiles,
894
1171
  entryBudgetExceeded,
@@ -897,7 +1174,7 @@ export async function getGraphSourceFiles(cwd) {
897
1174
  const result = [];
898
1175
  let sinceYield = 0;
899
1176
  for (const raw of collected) {
900
- const file = normalizeMapKey(raw);
1177
+ const file = normalizeGraphSourcePath(sourceMemo, raw);
901
1178
  const kind = detectFileKind(file);
902
1179
  // isWithinReviewGraphSizeLimit does a statSync per file — yield periodically
903
1180
  // so the size-limit filter (one stat each) can't hold the loop in one burst.
@@ -917,19 +1194,30 @@ export async function getGraphSourceFiles(cwd) {
917
1194
  }
918
1195
  return {
919
1196
  files: result,
1197
+ pathNormalizeCalls: sourceMemo.normalizeCalls.value - normalizeCallsBefore,
920
1198
  sourceFileCount: result.length,
921
1199
  maxFileCount: maxGraphFiles,
922
1200
  entryBudgetExceeded,
923
1201
  };
924
1202
  }
925
1203
  function addNode(graph, node) {
1204
+ // #2074: keep symbolNodesByFile live here, not only in rebuildIndexes, so the
1205
+ // incremental path can drop its terminal O(graph) reindex. Re-adding an id
1206
+ // already in the map must not push a duplicate — rebuildIndexes starts from
1207
+ // empty maps, so the two producers stay consistent.
1208
+ const isNew = !graph.nodes.has(node.id);
926
1209
  graph.nodes.set(node.id, node);
927
1210
  if (node.kind === "file" && node.filePath) {
928
1211
  graph.fileNodes.set(node.filePath, node.id);
1212
+ return;
1213
+ }
1214
+ if (isNew && node.kind === "symbol" && node.filePath) {
1215
+ const ids = graph.symbolNodesByFile.get(node.filePath) ?? [];
1216
+ ids.push(node.id);
1217
+ graph.symbolNodesByFile.set(node.filePath, ids);
929
1218
  }
930
1219
  }
931
- function addEdge(graph, edge) {
932
- graph.edges.push(edge);
1220
+ function indexEdge(graph, edge) {
933
1221
  const from = graph.edgesByFrom.get(edge.from) ?? [];
934
1222
  from.push(edge);
935
1223
  graph.edgesByFrom.set(edge.from, from);
@@ -937,6 +1225,33 @@ function addEdge(graph, edge) {
937
1225
  to.push(edge);
938
1226
  graph.edgesByTo.set(edge.to, to);
939
1227
  }
1228
+ /**
1229
+ * Drop `edge` from both adjacency buckets (#2074). Costs one scan of the two
1230
+ * buckets the edge belongs to, never a scan of `graph.edges`, so removing a
1231
+ * changed file's edges stays proportional to that file's fan-in/fan-out.
1232
+ */
1233
+ function unindexEdge(graph, edge) {
1234
+ const from = graph.edgesByFrom.get(edge.from);
1235
+ if (from) {
1236
+ const at = from.indexOf(edge);
1237
+ if (at >= 0)
1238
+ from.splice(at, 1);
1239
+ if (from.length === 0)
1240
+ graph.edgesByFrom.delete(edge.from);
1241
+ }
1242
+ const to = graph.edgesByTo.get(edge.to);
1243
+ if (to) {
1244
+ const at = to.indexOf(edge);
1245
+ if (at >= 0)
1246
+ to.splice(at, 1);
1247
+ if (to.length === 0)
1248
+ graph.edgesByTo.delete(edge.to);
1249
+ }
1250
+ }
1251
+ function addEdge(graph, edge) {
1252
+ graph.edges.push(edge);
1253
+ indexEdge(graph, edge);
1254
+ }
940
1255
  function rebuildIndexes(graph) {
941
1256
  graph.edgesByFrom = new Map();
942
1257
  graph.edgesByTo = new Map();
@@ -1191,17 +1506,31 @@ function persistedData(pending) {
1191
1506
  : undefined,
1192
1507
  nodes: Array.from(pending.graph.nodes.entries()),
1193
1508
  edges: pending.graph.edges,
1194
- coverage: pending.graph.persistCoverage,
1509
+ coverage: stripProcessLocalCoverage(pending.graph.persistCoverage),
1195
1510
  gitStamp: pending.gitStamp,
1196
1511
  };
1197
1512
  }
1513
+ /**
1514
+ * Drop coverage fields that are true only of THIS process's graph before the
1515
+ * snapshot is written (#2255 review F5).
1516
+ *
1517
+ * `capTrimmed` means "this process walked every file, then cut for size", which
1518
+ * is what makes a graph a safe incremental base. A snapshot read back in a later
1519
+ * process carries no such guarantee — the tree may have changed underneath it —
1520
+ * so persisting the marker would let a hydrated graph claim base-eligibility
1521
+ * nobody established, the #936 laundering shape. `partial` itself is preserved,
1522
+ * so the snapshot still reports honestly that it is incomplete.
1523
+ */
1524
+ function stripProcessLocalCoverage(coverage) {
1525
+ if (!coverage?.capTrimmed)
1526
+ return coverage;
1527
+ const { capTrimmed: _capTrimmed, ...persisted } = coverage;
1528
+ return persisted;
1529
+ }
1198
1530
  function countRetainedSourceFiles(graph, sourceFilePaths) {
1199
1531
  if (sourceFilePaths === undefined)
1200
1532
  return graph.fileNodes.size;
1201
- const sourceKeys = new Set();
1202
- for (const filePath of sourceFilePaths) {
1203
- sourceKeys.add(normalizeMapKey(filePath));
1204
- }
1533
+ const sourceKeys = new Set(sourceFilePaths);
1205
1534
  let retained = 0;
1206
1535
  for (const filePath of sourceKeys) {
1207
1536
  if (graph.fileNodes.has(filePath))
@@ -1235,13 +1564,52 @@ function capGraphForPersist(cwd, graph, cap, options = {}) {
1235
1564
  const nodeBudget = Math.max(1, Math.floor((cap * graph.nodes.size) / totalElements));
1236
1565
  const reverseDeps = buildReverseDependencyIndexFromGraph({ cwd, graph });
1237
1566
  const rankedFiles = rankFilesByReverseDependencyCentrality(reverseDeps);
1238
- const nodeIdsByFile = new Map();
1239
- for (const [id, node] of graph.nodes) {
1240
- if (!node.filePath)
1241
- continue;
1242
- const ids = nodeIdsByFile.get(node.filePath) ?? [];
1243
- ids.push(id);
1244
- nodeIdsByFile.set(node.filePath, ids);
1567
+ // Both sides of this lookup must agree on path spelling. `rankedFiles` comes
1568
+ // from the reverse-dependency index, which keys by `normalizeMapKey`
1569
+ // (reverse-deps.ts), while this map keys by the RAW `node.filePath`. They agree
1570
+ // because production paths already arrive folded (normalizeGraphSourcePath), so
1571
+ // the raw pass is both correct and free. An unfolded path missed its lookup and
1572
+ // was dropped from the ranking with no signal (#2255 review F1).
1573
+ //
1574
+ // Folding every node up front would fix that but costs a `realpath` probe PER
1575
+ // NODE — measured at 93.5us per node, about 14 seconds at 150k nodes — on the
1576
+ // pre-existing persist cap that every over-cap build already pays. So fold
1577
+ // LAZILY: index raw first, and pay one folding pass only when the raw index
1578
+ // leaves nodes the ranking cannot reach. Reach, not emptiness, is the trigger:
1579
+ // a PARTIAL raw match would otherwise silently under-select the rest.
1580
+ const indexNodesByFile = (fold) => {
1581
+ const byFile = new Map();
1582
+ for (const [id, node] of graph.nodes) {
1583
+ if (!node.filePath)
1584
+ continue;
1585
+ const fileKey = fold ? normalizeMapKey(node.filePath) : node.filePath;
1586
+ const ids = byFile.get(fileKey) ?? [];
1587
+ ids.push(id);
1588
+ byFile.set(fileKey, ids);
1589
+ }
1590
+ return byFile;
1591
+ };
1592
+ // Measure reach in NODES, not files. Counting files is too weak: when only some
1593
+ // of a file's nodes are unfolded, the file still appears covered while its group
1594
+ // is short, and the missing nodes drop out of the ranking with no signal.
1595
+ const reachableNodes = (byFile) => {
1596
+ let reached = 0;
1597
+ for (const filePath of rankedFiles)
1598
+ reached += byFile.get(filePath)?.length ?? 0;
1599
+ return reached;
1600
+ };
1601
+ let placeableNodes = 0;
1602
+ for (const node of graph.nodes.values())
1603
+ if (node.filePath)
1604
+ placeableNodes++;
1605
+ let nodeIdsByFile = indexNodesByFile(false);
1606
+ if (reachableNodes(nodeIdsByFile) < placeableNodes) {
1607
+ const folded = indexNodesByFile(true);
1608
+ // Keep whichever index the ranking can actually reach. Folding repairs
1609
+ // unfolded input; it is never a downgrade for input that was already correct.
1610
+ if (reachableNodes(folded) > reachableNodes(nodeIdsByFile)) {
1611
+ nodeIdsByFile = folded;
1612
+ }
1245
1613
  }
1246
1614
  const keptIds = new Set();
1247
1615
  for (const filePath of rankedFiles) {
@@ -1305,7 +1673,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
1305
1673
  type: "phase",
1306
1674
  phase: "review_graph_persist",
1307
1675
  filePath: pending.cachePath,
1308
- durationMs: stats.serializeMs + stats.writeMs,
1676
+ durationMs: stats.durationMs,
1309
1677
  metadata: { elements: pending.elementCount, ...stats },
1310
1678
  });
1311
1679
  logReviewGraph({
@@ -1313,6 +1681,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
1313
1681
  phase: "persist_succeeded",
1314
1682
  elements: pending.elementCount,
1315
1683
  ...stats,
1684
+ durationMs: stats.durationMs,
1316
1685
  observability: persistObservability(pending, {
1317
1686
  status: "succeeded",
1318
1687
  workerStarted: workerState.started,
@@ -1322,6 +1691,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
1322
1691
  });
1323
1692
  }
1324
1693
  function writePendingOnMainThread(key, pending, reason, workerState = { started: false, completed: false }) {
1694
+ const persistStarted = performance.now();
1325
1695
  const serializeStarted = performance.now();
1326
1696
  try {
1327
1697
  const json = JSON.stringify(persistedData(pending));
@@ -1339,6 +1709,7 @@ function writePendingOnMainThread(key, pending, reason, workerState = { started:
1339
1709
  gzBytes: gzip.byteLength,
1340
1710
  serializeMs,
1341
1711
  writeMs: performance.now() - writeStarted,
1712
+ durationMs: performance.now() - persistStarted,
1342
1713
  offloaded: false,
1343
1714
  }, {
1344
1715
  started: workerState.started,
@@ -1427,7 +1798,8 @@ function handleWorkerResult(result) {
1427
1798
  result.rawBytes === undefined ||
1428
1799
  result.gzBytes === undefined ||
1429
1800
  result.serializeMs === undefined ||
1430
- result.writeMs === undefined) {
1801
+ result.writeMs === undefined ||
1802
+ result.durationMs === undefined) {
1431
1803
  fs.rm(result.stagePath, { force: true }, () => { });
1432
1804
  writePendingOnMainThread(key, pending, result.error ?? "invalid worker result", {
1433
1805
  started: true,
@@ -1446,6 +1818,7 @@ function handleWorkerResult(result) {
1446
1818
  gzBytes: result.gzBytes,
1447
1819
  serializeMs: result.serializeMs,
1448
1820
  writeMs: result.writeMs,
1821
+ durationMs: result.durationMs,
1449
1822
  offloaded: true,
1450
1823
  }, { started: true, completed: true });
1451
1824
  }
@@ -1937,7 +2310,7 @@ function hashIgnoredIds(ignoredIds) {
1937
2310
  if (ignoredIds === undefined)
1938
2311
  return "unavailable";
1939
2312
  const joined = [...ignoredIds]
1940
- .sort((a, b) => a.localeCompare(b))
2313
+ .sort((a, b) => compareOrdinal(a, b))
1941
2314
  .join("\u0000");
1942
2315
  return createHash("sha256").update(joined).digest("hex");
1943
2316
  }
@@ -2418,6 +2791,7 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
2418
2791
  logReviewGraph({
2419
2792
  cwd,
2420
2793
  phase: "persist_succeeded",
2794
+ durationMs: performance.now() - startedAt,
2421
2795
  elements: pending.elementCount,
2422
2796
  rawBytes,
2423
2797
  gzBytes: gzip.byteLength,
@@ -3263,22 +3637,52 @@ function removeFileOwnedGraphData(graph, filePath) {
3263
3637
  if (graph.nodes.has(fileNodeId))
3264
3638
  removedIds.add(fileNodeId);
3265
3639
  const preservedIncomingSymbolEdges = [];
3640
+ const droppedEdges = [];
3266
3641
  graph.edges = graph.edges.filter((edge) => {
3267
3642
  const fromRemoved = removedIds.has(edge.from);
3268
3643
  const toRemoved = removedIds.has(edge.to);
3269
- if (fromRemoved)
3644
+ if (fromRemoved) {
3645
+ droppedEdges.push(edge);
3270
3646
  return false;
3647
+ }
3271
3648
  if (removedSymbolIds.has(edge.to)) {
3272
3649
  preservedIncomingSymbolEdges.push({ ...edge });
3650
+ droppedEdges.push(edge);
3273
3651
  return false;
3274
3652
  }
3275
3653
  // Preserve importer edges to the stable file node id; the node is re-added below.
3276
3654
  if (toRemoved && edge.to === fileNodeId)
3277
3655
  return true;
3656
+ if (toRemoved)
3657
+ droppedEdges.push(edge);
3278
3658
  return !toRemoved;
3279
3659
  });
3280
- for (const id of removedIds)
3660
+ // #2074: keep the adjacency indexes live instead of rebuilding them over the
3661
+ // whole graph afterwards. Only the dropped edges' own buckets are touched.
3662
+ // Unconditional: on an unindexed graph every bucket lookup simply misses.
3663
+ for (const edge of droppedEdges)
3664
+ unindexEdge(graph, edge);
3665
+ for (const id of removedIds) {
3666
+ const node = graph.nodes.get(id);
3281
3667
  graph.nodes.delete(id);
3668
+ if (!node?.filePath)
3669
+ continue;
3670
+ if (node.kind === "file") {
3671
+ if (graph.fileNodes.get(node.filePath) === id) {
3672
+ graph.fileNodes.delete(node.filePath);
3673
+ }
3674
+ }
3675
+ else if (node.kind === "symbol") {
3676
+ const ids = graph.symbolNodesByFile.get(node.filePath);
3677
+ if (!ids)
3678
+ continue;
3679
+ const at = ids.indexOf(id);
3680
+ if (at >= 0)
3681
+ ids.splice(at, 1);
3682
+ if (ids.length === 0)
3683
+ graph.symbolNodesByFile.delete(node.filePath);
3684
+ }
3685
+ }
3282
3686
  return preservedIncomingSymbolEdges;
3283
3687
  }
3284
3688
  async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverride) {
@@ -3362,16 +3766,90 @@ async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverri
3362
3766
  addCxxIncludeEdges(graph, cwd, file, ignoredIds, contentOverride);
3363
3767
  }
3364
3768
  }
3769
+ /**
3770
+ * #2074 acceptance instrumentation. `restoreComparisons` counts edge-metadata
3771
+ * stringifications inside `restoreValidIncomingEdges`; `importTargetEdgeScans`
3772
+ * counts edges visited by `importTargetsForFile`. Before this change both grew
3773
+ * with the whole graph on every one-file rebuild. They are the count-based
3774
+ * signal the issue asks for, because wall time on this hardware is IO-noisy
3775
+ * (#1920).
3776
+ */
3777
+ const _rebuildCounters = { restoreComparisons: 0, importTargetEdgeScans: 0 };
3778
+ export function _getReviewGraphRebuildCountersForTests() {
3779
+ return { ..._rebuildCounters };
3780
+ }
3781
+ export function _resetReviewGraphRebuildCountersForTests() {
3782
+ _rebuildCounters.restoreComparisons = 0;
3783
+ _rebuildCounters.importTargetEdgeScans = 0;
3784
+ }
3785
+ /**
3786
+ * Identity of an edge for dedupe purposes, WITHIN one target bucket: the array
3787
+ * form needs no separator sentinel and cannot collide across fields.
3788
+ */
3789
+ function edgeIdentityKey(edge) {
3790
+ _rebuildCounters.restoreComparisons++;
3791
+ return JSON.stringify([edge.from, edge.kind, edge.metadata ?? {}]);
3792
+ }
3365
3793
  function restoreValidIncomingEdges(graph, edges) {
3366
- const existing = new Set(graph.edges.map((edge) => `${edge.from}\u0000${edge.to}\u0000${edge.kind}\u0000${JSON.stringify(edge.metadata ?? {})}`));
3794
+ // #2074: dedupe per TARGET, using `edgesByTo`, instead of building a key set
3795
+ // over every edge in the graph. Preserved incoming edges point at symbols of
3796
+ // the files just re-extracted, and `removeFileOwnedGraphData` has already
3797
+ // dropped those symbols' incoming edges, so each bucket read here is tiny.
3798
+ // The per-target `Set` keeps the restore linear even when one changed file is
3799
+ // a hub with thousands of preserved incoming edges — scanning the bucket per
3800
+ // edge instead would be quadratic in that fan-in.
3801
+ const seenByTarget = new Map();
3367
3802
  for (const edge of edges) {
3368
3803
  if (!graph.nodes.has(edge.from) || !graph.nodes.has(edge.to))
3369
3804
  continue;
3370
- const key = `${edge.from}\u0000${edge.to}\u0000${edge.kind}\u0000${JSON.stringify(edge.metadata ?? {})}`;
3371
- if (existing.has(key))
3805
+ let seen = seenByTarget.get(edge.to);
3806
+ if (!seen) {
3807
+ seen = new Set();
3808
+ for (const candidate of graph.edgesByTo.get(edge.to) ?? []) {
3809
+ seen.add(edgeIdentityKey(candidate));
3810
+ }
3811
+ seenByTarget.set(edge.to, seen);
3812
+ }
3813
+ const key = edgeIdentityKey(edge);
3814
+ if (seen.has(key))
3372
3815
  continue;
3373
- graph.edges.push(edge);
3374
- existing.add(key);
3816
+ seen.add(key);
3817
+ addEdge(graph, edge);
3818
+ }
3819
+ }
3820
+ /**
3821
+ * Remove duplicates created when deferred targets converge after restoration.
3822
+ * The scan is limited to resolved edges and their target buckets, not the
3823
+ * complete graph, so same-batch repairs remain proportional to the resolved
3824
+ * edges and the fan-in of their target buckets.
3825
+ */
3826
+ function dedupeResolvedEdges(graph, replacements) {
3827
+ const resolvedEdges = new Set(replacements.map(([, after]) => after));
3828
+ const seenByTarget = new Map();
3829
+ const removed = new Set();
3830
+ for (const [, edge] of replacements) {
3831
+ if (removed.has(edge))
3832
+ continue;
3833
+ let seen = seenByTarget.get(edge.to);
3834
+ if (!seen) {
3835
+ seen = new Set();
3836
+ for (const candidate of graph.edgesByTo.get(edge.to) ?? []) {
3837
+ if (!resolvedEdges.has(candidate)) {
3838
+ seen.add(edgeIdentityKey(candidate));
3839
+ }
3840
+ }
3841
+ seenByTarget.set(edge.to, seen);
3842
+ }
3843
+ const key = edgeIdentityKey(edge);
3844
+ if (seen.has(key)) {
3845
+ removed.add(edge);
3846
+ unindexEdge(graph, edge);
3847
+ continue;
3848
+ }
3849
+ seen.add(key);
3850
+ }
3851
+ if (removed.size > 0) {
3852
+ graph.edges = graph.edges.filter((edge) => !removed.has(edge));
3375
3853
  }
3376
3854
  }
3377
3855
  const _graphImportChanges = new WeakMap();
@@ -3383,20 +3861,32 @@ function importTargetsForFile(graph, filePath) {
3383
3861
  const normalized = normalizeMapKey(filePath);
3384
3862
  const fileNodeId = graph.fileNodes.get(normalized) ?? `file:${normalized}`;
3385
3863
  const targets = new Set();
3386
- // Cached graph snapshots intentionally omit derived indexes; read the
3387
- // canonical edge collection so the delta is correct before the first rebuild.
3388
- for (const edge of graph.edges) {
3389
- if (edge.from !== fileNodeId)
3390
- continue;
3864
+ // #2074: read this file's own out-edges from `edgesByFrom` rather than
3865
+ // scanning every edge in the graph. The only caller, `updateGraphFiles`,
3866
+ // indexes the graph before its first call and keeps the indexes live for the
3867
+ // rest of the update, so the bucket is always authoritative here.
3868
+ for (const edge of graph.edgesByFrom.get(fileNodeId) ?? []) {
3869
+ _rebuildCounters.importTargetEdgeScans++;
3391
3870
  if (edge.kind !== "imports")
3392
3871
  continue;
3393
3872
  const target = graph.nodes.get(edge.to)?.filePath;
3394
3873
  if (target)
3395
3874
  targets.add(normalizeMapKey(target));
3396
3875
  }
3397
- return [...targets].sort((a, b) => a.localeCompare(b));
3876
+ return [...targets].sort((a, b) => compareOrdinal(a, b));
3398
3877
  }
3399
3878
  async function updateGraphFiles(graph, cwd, files, facts, ignoredIds) {
3879
+ // #2074: index ONCE, up front, then keep the indexes live through the update.
3880
+ // This is the same single O(graph) pass the terminal `rebuildIndexes` used to
3881
+ // cost, moved to the front, and it buys three things: the two
3882
+ // `importTargetsForFile` calls per changed file become bucket lookups,
3883
+ // `restoreValidIncomingEdges` dedupes against a fan-in bucket instead of the
3884
+ // whole edge list, and `existedBefore` finally reads a POPULATED `fileNodes`.
3885
+ // `cloneGraph` returns empty indexes, so before this the first file's
3886
+ // `existedBefore` was always false on every update path — which forced
3887
+ // `importsChanged` true in `clients/dispatch/integration.ts:1077` and blocked
3888
+ // reverse-dependency index reuse on every incremental build.
3889
+ rebuildIndexes(graph);
3400
3890
  const prior = files.map((file) => ({
3401
3891
  filePath: normalizeMapKey(file),
3402
3892
  existedBefore: graph.fileNodes.has(normalizeMapKey(file)),
@@ -3409,7 +3899,6 @@ async function updateGraphFiles(graph, cwd, files, facts, ignoredIds) {
3409
3899
  }
3410
3900
  restoreValidIncomingEdges(graph, preservedIncoming);
3411
3901
  resolveDeferredSymbolEdges(graph, false);
3412
- rebuildIndexes(graph);
3413
3902
  graph.changedSymbolsByFile.clear();
3414
3903
  for (const file of files) {
3415
3904
  upsertChangedSymbols(graph, facts, file);
@@ -3433,6 +3922,12 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
3433
3922
  ids.push(node.id);
3434
3923
  symbolNameToIds.set(node.symbolName, ids);
3435
3924
  }
3925
+ // #2074: this pass REPLACES edge objects, so the adjacency buckets that hold
3926
+ // the old objects go stale. Callers that keep the indexes live (the
3927
+ // incremental path, which no longer reindexes afterwards) need each
3928
+ // replacement patched into the buckets; collect them here rather than paying
3929
+ // a whole-graph reindex for a handful of newly resolved edges.
3930
+ const replacements = [];
3436
3931
  graph.edges = graph.edges.map((edge) => {
3437
3932
  const targetNode = graph.nodes.get(edge.to);
3438
3933
  if (!targetNode?.metadata?.unresolvedName)
@@ -3447,13 +3942,25 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
3447
3942
  if (importHintFile) {
3448
3943
  const scoped = candidates.filter((id) => graph.nodes.get(id)?.filePath === importHintFile);
3449
3944
  if (scoped.length === 1) {
3450
- return { ...edge, to: scoped[0], resolution: "import" };
3945
+ const resolved = {
3946
+ ...edge,
3947
+ to: scoped[0],
3948
+ resolution: "import",
3949
+ };
3950
+ replacements.push([edge, resolved]);
3951
+ return resolved;
3451
3952
  }
3452
3953
  }
3453
3954
  if (candidates.length === 1) {
3454
3955
  // Exactly one same-named real symbol exists graph-wide: the bare-name
3455
3956
  // match is provably unambiguous (refs #655 — resolution confidence).
3456
- return { ...edge, to: candidates[0], resolution: "exact" };
3957
+ const resolved = {
3958
+ ...edge,
3959
+ to: candidates[0],
3960
+ resolution: "exact",
3961
+ };
3962
+ replacements.push([edge, resolved]);
3963
+ return resolved;
3457
3964
  }
3458
3965
  // 0 or 2+ candidates (and no import hint narrowed it): stays on the
3459
3966
  // unresolved placeholder, resolution stays "name-only" (set at edge
@@ -3461,8 +3968,47 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
3461
3968
  // confirmed graph node.
3462
3969
  return edge;
3463
3970
  });
3464
- if (rebuild)
3971
+ if (rebuild) {
3465
3972
  rebuildIndexes(graph);
3973
+ return;
3974
+ }
3975
+ if (replacements.length === 0)
3976
+ return;
3977
+ // Bucket ORDER is part of the contract, not just bucket membership:
3978
+ // `resolveUsedBy` in module-report walks `edgesByTo` and truncates at a cap,
3979
+ // so the order decides which callers a reader sees. `rebuildIndexes` orders
3980
+ // every bucket by position in `graph.edges`, and the incremental path must
3981
+ // match it exactly.
3982
+ //
3983
+ // `from` is unchanged by a resolution, so the from-bucket only needs the new
3984
+ // object swapped in at the OLD object's position — order is preserved for
3985
+ // free. `to` moves from the placeholder's bucket to the resolved symbol's,
3986
+ // and appending there would put an early edge behind later ones. Rebuild
3987
+ // exactly the affected to-buckets from `graph.edges`, which restores
3988
+ // canonical order without touching the rest of the index.
3989
+ const affectedTargets = new Set();
3990
+ for (const [before, after] of replacements) {
3991
+ const fromBucket = graph.edgesByFrom.get(before.from);
3992
+ if (fromBucket) {
3993
+ const at = fromBucket.indexOf(before);
3994
+ if (at >= 0)
3995
+ fromBucket[at] = after;
3996
+ }
3997
+ affectedTargets.add(before.to);
3998
+ affectedTargets.add(after.to);
3999
+ }
4000
+ for (const target of affectedTargets)
4001
+ graph.edgesByTo.set(target, []);
4002
+ for (const edge of graph.edges) {
4003
+ if (affectedTargets.has(edge.to))
4004
+ graph.edgesByTo.get(edge.to)?.push(edge);
4005
+ }
4006
+ for (const target of affectedTargets) {
4007
+ if (graph.edgesByTo.get(target)?.length === 0) {
4008
+ graph.edgesByTo.delete(target);
4009
+ }
4010
+ }
4011
+ dedupeResolvedEdges(graph, replacements);
3466
4012
  }
3467
4013
  /**
3468
4014
  * #451: the freshness-provenance fields written onto a workspace cache entry by
@@ -3537,7 +4083,7 @@ async function tryIncrementalFromCache(cached, ctx) {
3537
4083
  graphChanged: false,
3538
4084
  });
3539
4085
  graph.buildGeneration = generation;
3540
- ctx.facts.setSessionFact("session.reviewGraph", graph);
4086
+ setSessionReviewGraphFact(ctx.cwd, ctx.facts, graph);
3541
4087
  return graph;
3542
4088
  }
3543
4089
  // Record content hashes for the newly-added files too, so the next run can
@@ -3577,7 +4123,7 @@ async function tryIncrementalFromCache(cached, ctx) {
3577
4123
  fromGeneration: priorGeneration,
3578
4124
  changes: importChanges,
3579
4125
  });
3580
- ctx.facts.setSessionFact("session.reviewGraph", graph);
4126
+ setSessionReviewGraphFact(ctx.cwd, ctx.facts, graph);
3581
4127
  return graph;
3582
4128
  }
3583
4129
  function hasGraphKindExtension(file) {
@@ -3594,10 +4140,16 @@ function hasGraphKindExtension(file) {
3594
4140
  */
3595
4141
  async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, ignoredIds, cacheEpoch) {
3596
4142
  const cached = _workspaceGraphCache.get(normalizedCwd);
3597
- // Condition 2: need an in-process complete entry that recorded a build seq.
3598
- // A capped or entry-budget-truncated graph is read-only orientation data, not
3599
- // a safe incremental base; force the next build through a complete walk.
3600
- if (cached?.graph.persistCoverage?.partial) {
4143
+ // Condition 2: need an in-process entry that recorded a build seq and whose
4144
+ // SOURCE WALK was complete. An entry-budget-truncated or checkpoint graph has
4145
+ // UNSEEN files, so it is read-only orientation data and must force a complete
4146
+ // walk. A `capTrimmed` graph is a different case: this process walked every
4147
+ // file and then cut the graph for size, so rebuilding from it is safe, and
4148
+ // refusing it puts an over-budget repository on a full walk every single turn
4149
+ // (#2255 review F5). The marker is process-local and never hydrated from disk,
4150
+ // so a snapshot-derived partial still takes the refusal above.
4151
+ const cachedCoverage = cached?.graph.persistCoverage;
4152
+ if (cachedCoverage?.partial && cachedCoverage.capTrimmed !== true) {
3601
4153
  return { fallback: "partial-base" };
3602
4154
  }
3603
4155
  if (!cached || cached.builtAtProjectSeq === undefined) {
@@ -3688,7 +4240,7 @@ async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, fa
3688
4240
  graphChanged: false,
3689
4241
  });
3690
4242
  graph.buildGeneration = generation;
3691
- facts.setSessionFact("session.reviewGraph", graph);
4243
+ setSessionReviewGraphFact(cwd, facts, graph);
3692
4244
  return { graph };
3693
4245
  }
3694
4246
  // Incremental re-extract over exactly the content-changed files. Reuses the
@@ -3746,7 +4298,7 @@ async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, fa
3746
4298
  fromGeneration: priorGeneration,
3747
4299
  changes: importChanges,
3748
4300
  });
3749
- facts.setSessionFact("session.reviewGraph", graph);
4301
+ setSessionReviewGraphFact(cwd, facts, graph);
3750
4302
  return { graph };
3751
4303
  }
3752
4304
  async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
@@ -3793,7 +4345,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3793
4345
  // treat as changed so dependents never trust stale derived state.
3794
4346
  graphChanged: true,
3795
4347
  });
3796
- facts.setSessionFact("session.reviewGraph", graph);
4348
+ setSessionReviewGraphFact(cwd, facts, graph);
3797
4349
  return graph;
3798
4350
  }
3799
4351
  // #451: capture the seq BEFORE any await — every builtAtProjectSeq stamp in
@@ -3820,6 +4372,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3820
4372
  const sourceCollection = await getGraphSourceFiles(cwd);
3821
4373
  const filesToBuild = sourceCollection.files;
3822
4374
  const sourceFileCount = sourceCollection.sourceFileCount;
4375
+ const pathNormalizeCalls = sourceCollection.pathNormalizeCalls;
3823
4376
  const sourceFilesTruncated = sourceCollection.entryBudgetExceeded;
3824
4377
  const ignoredIds = await ignoredIdsPromise;
3825
4378
  const maxGraphFiles = sourceCollection.maxFileCount;
@@ -3877,6 +4430,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3877
4430
  sourceFileCount,
3878
4431
  sourceFileCountTruncated: sourceFilesTruncated || filesToBuild.length > maxGraphFiles,
3879
4432
  maxFileCount: maxGraphFiles,
4433
+ pathNormalizeCalls,
3880
4434
  seqFastpathFallback,
3881
4435
  // #459: a fresh empty graph is returned every call on this path (never
3882
4436
  // persisted/reused) — treat it as changed so dependents never trust stale
@@ -3884,7 +4438,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3884
4438
  // with a buildGeneration: absent ⇒ derived caches rebuild every time.
3885
4439
  graphChanged: true,
3886
4440
  });
3887
- facts.setSessionFact("session.reviewGraph", graph);
4441
+ setSessionReviewGraphFact(cwd, facts, graph);
3888
4442
  return graph;
3889
4443
  }
3890
4444
  // #782: the repo is within the cap on this build attempt — drop any
@@ -3905,8 +4459,17 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3905
4459
  // re-persisting it would launder partial coverage onto disk as a complete
3906
4460
  // snapshot (#936 review). Ignore it here — the disk tier rejects a partial
3907
4461
  // base too, so the build falls through to a full rebuild.
3908
- if (memCached?.graph.persistCoverage?.partial)
4462
+ //
4463
+ // `capTrimmed` is the one partial cause this does NOT refuse: this process
4464
+ // walked every file and then cut the graph to the memory budget, so no file is
4465
+ // unaccounted for. Refusing it forced a full walk on every turn for exactly
4466
+ // the repositories the memory bound targets (#2255 review F5). The marker is
4467
+ // process-local and stripped at persist, so a snapshot-hydrated partial —
4468
+ // whose completeness this process cannot vouch for — is still refused.
4469
+ const memCoverage = memCached?.graph.persistCoverage;
4470
+ if (memCoverage?.partial && memCoverage.capTrimmed !== true) {
3909
4471
  memCached = undefined;
4472
+ }
3910
4473
  if (memCached?.signature === signature) {
3911
4474
  touchWorkspaceGraph(normalizedCwd);
3912
4475
  const graph = cloneGraph(memCached.graph);
@@ -3929,7 +4492,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3929
4492
  graphChanged: false,
3930
4493
  });
3931
4494
  graph.buildGeneration = generation;
3932
- facts.setSessionFact("session.reviewGraph", graph);
4495
+ setSessionReviewGraphFact(cwd, facts, graph);
3933
4496
  return graph;
3934
4497
  }
3935
4498
  if (memCached) {
@@ -3984,7 +4547,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3984
4547
  graphChanged: false,
3985
4548
  });
3986
4549
  graph.buildGeneration = generation;
3987
- facts.setSessionFact("session.reviewGraph", graph);
4550
+ setSessionReviewGraphFact(cwd, facts, graph);
3988
4551
  return graph;
3989
4552
  }
3990
4553
  if (diskCached) {
@@ -4143,8 +4706,17 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
4143
4706
  // so change detection does not reread every file after the graph is built.
4144
4707
  // #459: full rebuild ⇒ new generation.
4145
4708
  const generation = ++_graphGenerationCounter;
4146
- const graphSnapshot = cloneGraph(graph);
4147
- rebuildIndexes(graphSnapshot);
4709
+ // #2255 review R1: cap ONCE and let both retention sites share the result. The
4710
+ // cache took a clone while the session fact took the original, two objects a
4711
+ // `cloneGraph` apart, so each ran its own centrality pass and retained its own
4712
+ // trimmed copy — two budgets resident, not one. Over budget the cap already
4713
+ // returns a fresh, fully-indexed graph, so it IS the snapshot and no clone is
4714
+ // needed. In budget nothing is trimmed and the clone happens exactly as before.
4715
+ const retained = retainedGraph(cwd, graph);
4716
+ const wasCapped = retained !== graph;
4717
+ const graphSnapshot = wasCapped ? retained : cloneGraph(graph);
4718
+ if (!wasCapped)
4719
+ rebuildIndexes(graphSnapshot);
4148
4720
  // Keep the content generation on the persisted snapshot instance too, so
4149
4721
  // scheduled persistence logs join the same graph identity as build success.
4150
4722
  graphSnapshot.buildGeneration = generation;
@@ -4169,12 +4741,15 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
4169
4741
  mode: "full",
4170
4742
  sourceFileCount,
4171
4743
  sourceFileCountTruncated: sourceFilesTruncated,
4744
+ pathNormalizeCalls,
4172
4745
  seqFastpathFallback,
4173
4746
  ...(persistReason ? { persistReason } : {}),
4174
4747
  graphChanged: true,
4175
4748
  });
4176
4749
  graph.buildGeneration = generation;
4177
- facts.setSessionFact("session.reviewGraph", graph);
4750
+ // Share the cache's object, so an over-budget build retains ONE trimmed graph
4751
+ // across both sites. In budget this stores the equivalent clone the cache holds.
4752
+ setSessionReviewGraphFact(cwd, facts, graphSnapshot);
4178
4753
  return graph;
4179
4754
  }
4180
4755
  /**
@@ -4196,7 +4771,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
4196
4771
  * rather than by coincidence.
4197
4772
  */
4198
4773
  function buildCacheKey(cwd, changedFiles) {
4199
- return `${buildCacheWorkspaceKey(cwd)}|${[...changedFiles].sort((a, b) => a.localeCompare(b)).join(",")}`;
4774
+ return `${buildCacheWorkspaceKey(cwd)}|${[...changedFiles].sort((a, b) => compareOrdinal(a, b)).join(",")}`;
4200
4775
  }
4201
4776
  /**
4202
4777
  * Whether a build for this exact (cwd, changedFiles) key is PENDING right now
@@ -4254,6 +4829,7 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
4254
4829
  mode: buildInfo.mode,
4255
4830
  sourceFileCount: buildInfo.sourceFileCount,
4256
4831
  sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
4832
+ pathNormalizeCalls: buildInfo.pathNormalizeCalls,
4257
4833
  }),
4258
4834
  },
4259
4835
  });
@@ -4276,6 +4852,7 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
4276
4852
  mode: buildInfo.mode,
4277
4853
  sourceFileCount: buildInfo.sourceFileCount,
4278
4854
  sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
4855
+ pathNormalizeCalls: buildInfo.pathNormalizeCalls,
4279
4856
  }),
4280
4857
  },
4281
4858
  });