@herbertgao/pi-extensions 2026.8.4 → 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 (288) hide show
  1. package/README.md +4 -4
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +10 -2
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +10 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +5 -8
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  13. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  14. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  15. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  16. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +15 -2
  20. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  21. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  22. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  25. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  30. package/node_modules/@narumitw/pi-btw/README.md +8 -7
  31. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  32. package/node_modules/@narumitw/pi-btw/src/menu.ts +1 -0
  33. package/node_modules/pi-lens/CHANGELOG.md +2440 -954
  34. package/node_modules/pi-lens/README.md +13 -0
  35. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  36. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  37. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  38. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  39. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  40. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  41. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  42. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  43. package/node_modules/pi-lens/dist/clients/biome-client.js +29 -25
  44. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  45. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  46. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  47. package/node_modules/pi-lens/dist/clients/bus-publish.js +10 -3
  48. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  49. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  50. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  51. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  52. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  53. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  54. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  55. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  56. package/node_modules/pi-lens/dist/clients/console-guard-install.js +14 -0
  57. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  58. package/node_modules/pi-lens/dist/clients/dead-code-client.js +2 -1
  59. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  60. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  61. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  62. package/node_modules/pi-lens/dist/clients/dependency-checker.js +347 -75
  63. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +61 -11
  64. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  65. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +63 -20
  66. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  67. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  68. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  69. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  70. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  71. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1085 -681
  72. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  73. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  74. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  75. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  76. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  77. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  78. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  79. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  80. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  81. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  82. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  83. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +14 -6
  84. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  85. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  86. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  87. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +277 -26
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  99. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  100. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  101. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  102. package/node_modules/pi-lens/dist/clients/extension-log.js +139 -0
  103. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  104. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  105. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  106. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  107. package/node_modules/pi-lens/dist/clients/format-events-publish.js +16 -3
  108. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  109. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  110. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  111. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  112. package/node_modules/pi-lens/dist/clients/git-guard.js +681 -11
  113. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  114. package/node_modules/pi-lens/dist/clients/go-client.js +2 -1
  115. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +8 -0
  116. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  117. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  118. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  119. package/node_modules/pi-lens/dist/clients/installer/index.js +390 -99
  120. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  121. package/node_modules/pi-lens/dist/clients/jscpd-client.js +31 -54
  122. package/node_modules/pi-lens/dist/clients/knip-client.js +15 -36
  123. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  124. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  125. package/node_modules/pi-lens/dist/clients/latency-logger.js +23 -1
  126. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  127. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  128. package/node_modules/pi-lens/dist/clients/lens-events.js +27 -4
  129. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  130. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  131. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +30 -0
  132. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  133. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +1 -1
  134. package/node_modules/pi-lens/dist/clients/lsp/client.js +493 -48
  135. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  136. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +86 -0
  137. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  138. package/node_modules/pi-lens/dist/clients/lsp/index.js +1807 -780
  139. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  140. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  141. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  142. package/node_modules/pi-lens/dist/clients/lsp/server.js +196 -32
  143. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  144. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  145. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  146. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +123 -32
  147. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  148. package/node_modules/pi-lens/dist/clients/mcp/session.js +216 -8
  149. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  150. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  151. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  152. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  153. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  154. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  155. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  156. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  157. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  158. package/node_modules/pi-lens/dist/clients/pipeline.js +47 -13
  159. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  160. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  161. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  162. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  163. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  164. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  165. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  166. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  167. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  168. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  169. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  170. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  171. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  172. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  173. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  174. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  175. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  176. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  177. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  178. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  179. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  180. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  181. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  182. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +107 -73
  183. package/node_modules/pi-lens/dist/clients/runtime-context.js +36 -12
  184. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +74 -6
  185. package/node_modules/pi-lens/dist/clients/runtime-session.js +405 -117
  186. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  187. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +218 -23
  188. package/node_modules/pi-lens/dist/clients/runtime-turn.js +237 -64
  189. package/node_modules/pi-lens/dist/clients/rust-client.js +2 -1
  190. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  191. package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -1
  192. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  193. package/node_modules/pi-lens/dist/clients/sg-runner.js +284 -174
  194. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  195. package/node_modules/pi-lens/dist/clients/smells-rollup.js +224 -0
  196. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  197. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  198. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  199. package/node_modules/pi-lens/dist/clients/startup-timing.js +1 -1
  200. package/node_modules/pi-lens/dist/clients/test-runner-client.js +38 -8
  201. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  202. package/node_modules/pi-lens/dist/clients/tool-policy.js +388 -39
  203. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  204. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  205. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  206. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  207. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  208. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  209. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  210. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  211. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  212. package/node_modules/pi-lens/dist/clients/warm-attach.js +33 -13
  213. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  214. package/node_modules/pi-lens/dist/clients/word-index.js +391 -64
  215. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  216. package/node_modules/pi-lens/dist/index.js +49361 -36888
  217. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  218. package/node_modules/pi-lens/dist/mcp/server.js +211 -44
  219. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  220. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +200 -36
  221. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +153 -40
  222. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  223. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  224. package/node_modules/pi-lens/docs/agent-guide.md +2 -2
  225. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  226. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  227. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  228. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +5 -2
  229. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  230. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  231. package/node_modules/pi-lens/docs/fable.md +27 -0
  232. package/node_modules/pi-lens/docs/features.md +31 -2
  233. package/node_modules/pi-lens/docs/globalconfig.md +45 -1
  234. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  235. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  236. package/node_modules/pi-lens/docs/mcp.md +37 -4
  237. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  238. package/node_modules/pi-lens/docs/settings.md +12 -1
  239. package/node_modules/pi-lens/docs/tools.md +4 -1
  240. package/node_modules/pi-lens/docs/usage.md +18 -0
  241. package/node_modules/pi-lens/package.json +10 -10
  242. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  243. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  244. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  245. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  246. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  247. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  248. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  249. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  250. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  251. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  252. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  253. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  254. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +55 -2
  255. package/node_modules/pi-mcp-adapter/CHANGELOG.md +25 -0
  256. package/node_modules/pi-mcp-adapter/README.md +34 -3
  257. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  258. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  259. package/node_modules/pi-mcp-adapter/direct-tools.ts +54 -7
  260. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  261. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  262. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  263. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  264. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  265. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  266. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  267. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  268. package/node_modules/pi-mcp-adapter/metadata-cache.ts +27 -4
  269. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  270. package/node_modules/pi-mcp-adapter/package.json +3 -3
  271. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  272. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  273. package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
  274. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  275. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  276. package/node_modules/pi-mcp-adapter/tool-metadata.ts +42 -4
  277. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  278. package/node_modules/pi-mcp-adapter/types.ts +93 -27
  279. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  280. package/package.json +11 -12
  281. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  282. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  283. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  284. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  285. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  286. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  287. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  288. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -12,6 +12,7 @@ import { detectFileKind, KIND_EXTENSIONS } from "../file-kinds.js";
12
12
  import { detectFileRole } from "../file-role.js";
13
13
  import { getProjectDataDir } from "../file-utils.js";
14
14
  import { collectUntrackedIgnoredIds } from "../git-tracked-ignore.js";
15
+ import { realIsPidAlive } from "../instance-reaper.js";
15
16
  import { logLatency } from "../latency-logger.js";
16
17
  import { containerNameChain, getOpenDocumentSymbols, lspSymbolKindName, } from "../lsp-document-symbols.js";
17
18
  import { isAtOrAboveHomeDir, normalizeFilePath, normalizeMapKey, toProjectRelativePath, } from "../path-utils.js";
@@ -22,7 +23,7 @@ import { RUNTIME_CONFIG } from "../runtime-config.js";
22
23
  import { buildReverseDependencyIndexFromGraph, rankFilesByReverseDependencyCentrality, } from "../reverse-deps.js";
23
24
  import { buildQualifiedName, findOwnerName } from "../symbol-containment.js";
24
25
  import { logTreeSitterCacheStats } from "../tree-sitter-logger.js";
25
- import { flushReviewGraphLogSync, logReviewGraph, } from "../review-graph-logger.js";
26
+ import { flushReviewGraphLogSync, logReviewGraph, makeReviewGraphBuildMetadata, } from "../review-graph-logger.js";
26
27
  import { getSharedTreeSitterClient } from "../tree-sitter-shared.js";
27
28
  import { TreeSitterSymbolExtractor, } from "../tree-sitter-symbol-extractor.js";
28
29
  import { withTreeSitterRoot } from "../tree-sitter-shared.js";
@@ -58,7 +59,10 @@ import { clearReviewGraphFileIr, getFreshReviewGraphFileIr, reviewGraphIrContent
58
59
  // safe-rebuild mechanism as the v2→v3/v3→v4/v4→v5 bumps above.
59
60
  // v7 (#939): the canonical snapshot is streamed gzip. A v7 payload in the
60
61
  // legacy uncompressed filename remains readable for one compatibility release.
61
- const REVIEW_GRAPH_VERSION = "v7";
62
+ // v8 (#1070): call/reference evidence now records call-like vs type-only
63
+ // references and tree-sitter query coverage, so call-graph consumers cannot
64
+ // mistake incomplete extraction for a clean zero.
65
+ export const REVIEW_GRAPH_VERSION = "v8";
62
66
  const MAIN_KINDS = new Set([
63
67
  "jsts",
64
68
  "python",
@@ -84,6 +88,8 @@ const MAIN_KINDS = new Set([
84
88
  // so a repo heavy in JSON/YAML/Markdown doesn't trip the cap on files the graph
85
89
  // would have filtered out anyway (the cap is on the walk, not on noise). #250.
86
90
  const MAIN_KIND_EXTENSIONS = Array.from(MAIN_KINDS).flatMap((kind) => KIND_EXTENSIONS[kind] ?? []);
91
+ /** The bounded, source-filtered extension set shared by graph cache readers. */
92
+ export const REVIEW_GRAPH_SOURCE_EXTENSIONS = MAIN_KIND_EXTENSIONS;
87
93
  const CHANGED_SYMBOLS_PREFIX = "session.reviewGraph.changedSymbols:";
88
94
  const extractorCache = new Map();
89
95
  // Per-invocation Promise cache: deduplicates concurrent buildOrUpdateGraph calls
@@ -92,17 +98,87 @@ const extractorCache = new Map();
92
98
  // graph across invocations when source file mtimes/sizes have not changed.
93
99
  const _buildCache = new Map();
94
100
  const _workspaceGraphCache = new Map();
101
+ const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
102
+ const REVIEW_GRAPH_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
103
+ // A workspace-wide clear must invalidate builds for workspaces that are not
104
+ // resident yet, too. This process-wide component therefore survives cache
105
+ // deletion; per-workspace eviction/reset increments the map component below.
106
+ let _workspaceCacheEpoch = 0;
107
+ const _workspaceCacheEpochs = new Map();
108
+ function workspaceCacheEpoch(key) {
109
+ return _workspaceCacheEpoch + (_workspaceCacheEpochs.get(key) ?? 0);
110
+ }
111
+ function reviewGraphIdleEvictMs() {
112
+ const value = Number.parseInt(process.env.PI_LENS_REVIEW_GRAPH_IDLE_EVICT_MS ?? "", 10);
113
+ return Number.isSafeInteger(value) && value > 0 ? value : REVIEW_GRAPH_IDLE_EVICT_MS_DEFAULT;
114
+ }
115
+ function clearWorkspaceGraphTimer(entry) {
116
+ if (entry.idleTimer)
117
+ clearTimeout(entry.idleTimer);
118
+ entry.idleTimer = undefined;
119
+ }
120
+ function evictWorkspaceGraph(key, entry) {
121
+ if (_workspaceGraphCache.get(key) !== entry)
122
+ return;
123
+ clearWorkspaceGraphTimer(entry);
124
+ for (const buildKey of _buildCache.keys()) {
125
+ const separator = buildKey.indexOf("|");
126
+ if (separator >= 0 && normalizeMapKey(buildKey.slice(0, separator)) === key) {
127
+ _buildCache.delete(buildKey);
128
+ }
129
+ }
130
+ _workspaceCacheEpochs.set(key, (_workspaceCacheEpochs.get(key) ?? 0) + 1);
131
+ _workspaceGraphCache.delete(key);
132
+ }
133
+ function scheduleWorkspaceGraphEviction(key, entry) {
134
+ clearWorkspaceGraphTimer(entry);
135
+ const epoch = workspaceCacheEpoch(key);
136
+ entry.idleTimer = setTimeout(() => {
137
+ entry.idleTimer = undefined;
138
+ if (_workspaceGraphCache.get(key) !== entry || workspaceCacheEpoch(key) !== epoch)
139
+ return;
140
+ evictWorkspaceGraph(key, entry);
141
+ }, reviewGraphIdleEvictMs());
142
+ entry.idleTimer.unref?.();
143
+ }
144
+ function touchWorkspaceGraph(key) {
145
+ const entry = _workspaceGraphCache.get(key);
146
+ if (!entry)
147
+ return;
148
+ entry.lastUsedAt = Date.now();
149
+ scheduleWorkspaceGraphEviction(key, entry);
150
+ }
151
+ function setWorkspaceGraph(key, entry, epoch) {
152
+ if (epoch !== undefined && workspaceCacheEpoch(key) !== epoch)
153
+ return false;
154
+ const resident = { ...entry, lastUsedAt: Date.now() };
155
+ _workspaceGraphCache.set(key, resident);
156
+ scheduleWorkspaceGraphEviction(key, resident);
157
+ while (_workspaceGraphCache.size > REVIEW_GRAPH_MAX_WARM_WORKSPACES) {
158
+ const victim = [..._workspaceGraphCache.entries()].sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)[0];
159
+ if (!victim)
160
+ break;
161
+ evictWorkspaceGraph(victim[0], victim[1]);
162
+ }
163
+ return true;
164
+ }
95
165
  // #459: process-wide monotonic source for ReviewGraph.buildGeneration stamps.
96
166
  // Never reset (uniqueness is the invariant — a workspace-cache clear must not
97
167
  // let a new build collide with a generation a derived-data cache recorded
98
168
  // earlier in the same process).
99
169
  let _graphGenerationCounter = 0;
170
+ // Build invocation identity is separate from graph content generation: cached
171
+ // builds can reuse content while still needing a stable lifecycle join key.
172
+ let _buildIdCounter = 0;
100
173
  /** Beyond this many seq-changed files, incremental re-extract nears sweep cost — just sweep (#451). */
101
174
  const SEQ_FASTPATH_MAX_CHANGES = 32;
102
175
  /** Force a full walk+stat re-verify at least this often in wall time (external-edit safety valve, #451). */
103
176
  const SEQ_FASTPATH_REVERIFY_MS = 5 * 60_000;
104
177
  /** ...and at least every Nth fast-path build per workspace. */
105
178
  const SEQ_FASTPATH_REVERIFY_EVERY = 20;
179
+ function graphLogMetadata(graph, options = {}) {
180
+ return makeReviewGraphBuildMetadata(graph, options);
181
+ }
106
182
  function seqFastpathEnabled() {
107
183
  const raw = process.env.PI_LENS_GRAPH_SEQ_FASTPATH;
108
184
  return raw !== "0" && raw !== "false";
@@ -112,13 +188,68 @@ let _lastGraphBuildInfo = {
112
188
  mode: "full",
113
189
  graphChanged: true,
114
190
  };
191
+ // The global slot above is retained for legacy status surfaces, but overlapping
192
+ // deferred builds need a per-result identity for lifecycle telemetry.
193
+ const _graphBuildInfoByGraph = new WeakMap();
194
+ // #1179: true once ANY graph has been stamped since the last cache clear — i.e.
195
+ // `_lastGraphBuildInfo` may now hold a REAL build's info (a potential sibling)
196
+ // rather than the pristine `mode: "full"` default. Used only by the fail-closed
197
+ // safety read below to decide whether the global slot is safe to serve on a
198
+ // WeakMap identity miss.
199
+ let _anyGraphStamped = false;
200
+ function setGraphBuildInfo(graph, info) {
201
+ _lastGraphBuildInfo = info;
202
+ _graphBuildInfoByGraph.set(graph, info);
203
+ _anyGraphStamped = true;
204
+ }
205
+ export function getGraphBuildInfoForGraph(graph) {
206
+ return _graphBuildInfoByGraph.get(graph) ?? _lastGraphBuildInfo;
207
+ }
208
+ /**
209
+ * #1179 (fail-closed guard, latent P3 from the #1108/#1180 side-channel audit).
210
+ * Whether `getGraphBuildInfoForGraph(graph)` can be TRUSTED to describe `graph`
211
+ * itself — for a SAFETY gate that must never read a sibling graph's state.
212
+ *
213
+ * `getGraphBuildInfoForGraph` deliberately falls back to the global
214
+ * `_lastGraphBuildInfo` slot on a WeakMap identity miss — fine for the telemetry
215
+ * and `updateGraphBuildInfo` base-read callers, where a sibling build's `mode` is
216
+ * only informational (and the first stamp of a graph legitimately bases off the
217
+ * prior slot). But a gate that decides degraded-vs-clean must not read a DIFFERENT
218
+ * graph's `mode: "cached"` (healthy) off that fallback, or it would mistake an
219
+ * unstamped/rehydrated graph for a clean leaf and serve a silent all-clear (the
220
+ * #533 false-clean trap). Such a caller reads THIS first and, when it returns
221
+ * false, fails CLOSED (treats coverage as unknown/degraded) instead of trusting
222
+ * the slot.
223
+ *
224
+ * Trustworthy iff EITHER the graph's own identity is present in the WeakMap (the
225
+ * fallback is not even taken), OR nothing has been stamped since the last cache
226
+ * clear (`_lastGraphBuildInfo` is still the pristine `mode: "full"` default, which
227
+ * cannot be a sibling's real state). Only a miss AFTER some real build has stamped
228
+ * — where the global slot could be a sibling's info — is untrustworthy.
229
+ *
230
+ * Live path: always trustworthy. Every `_doBuildGraph` return stamps the graph via
231
+ * `setGraphBuildInfo` before returning it, so the cascade reads the freshly-stamped
232
+ * same-identity instance (`has(graph)` true) and the miss branch is unreachable
233
+ * today. This only hardens a future path that ever surfaces an unstamped graph.
234
+ */
235
+ export function graphBuildInfoIsTrustworthy(graph) {
236
+ return _graphBuildInfoByGraph.has(graph) || !_anyGraphStamped;
237
+ }
238
+ function updateGraphBuildInfo(graph, patch) {
239
+ const info = { ...getGraphBuildInfoForGraph(graph), ...patch };
240
+ setGraphBuildInfo(graph, info);
241
+ return info;
242
+ }
115
243
  export function clearGraphCache() {
116
244
  _buildCache.clear();
117
245
  }
118
246
  export function clearReviewGraphWorkspaceCache(cwd) {
119
247
  if (cwd === undefined) {
120
248
  _buildCache.clear();
249
+ for (const entry of _workspaceGraphCache.values())
250
+ clearWorkspaceGraphTimer(entry);
121
251
  _workspaceGraphCache.clear();
252
+ _workspaceCacheEpoch++;
122
253
  _sizeSkipVerdicts.clear();
123
254
  }
124
255
  else {
@@ -128,21 +259,93 @@ export function clearReviewGraphWorkspaceCache(cwd) {
128
259
  _buildCache.delete(key);
129
260
  }
130
261
  }
262
+ const entry = _workspaceGraphCache.get(normalized);
263
+ if (entry)
264
+ clearWorkspaceGraphTimer(entry);
265
+ _workspaceCacheEpochs.set(normalized, (_workspaceCacheEpochs.get(normalized) ?? 0) + 1);
131
266
  _workspaceGraphCache.delete(normalized);
132
267
  _sizeSkipVerdicts.delete(normalized);
133
268
  }
134
269
  _lastGraphBuildInfo = { reused: false, mode: "full", graphChanged: true };
270
+ // #1179: the global slot is back to the pristine default, so a subsequent
271
+ // identity miss can safely serve it again (it cannot be a sibling's state).
272
+ _anyGraphStamped = false;
273
+ }
274
+ let _reviewGraphBuildGateForTests;
275
+ /** Test seam for deterministically interleaving a build with cache eviction. */
276
+ export function _setReviewGraphBuildGateForTests(gate) {
277
+ _reviewGraphBuildGateForTests = gate;
278
+ }
279
+ /**
280
+ * O(cache-entries) snapshot of the resident workspace graph cache — NOT
281
+ * O(nodes)/O(edges): only `.size`/`.length` are read per entry, never the
282
+ * graph contents themselves (#1123 item 2 memory-attribution sample). Cache
283
+ * entries are per-cwd and normally number 1 (a session_start clears the
284
+ * cache), so this is cheap enough for a per-N-turn sample.
285
+ */
286
+ export function getReviewGraphWorkspaceCacheSnapshot() {
287
+ let totalNodes = 0;
288
+ let totalEdges = 0;
289
+ for (const entry of _workspaceGraphCache.values()) {
290
+ totalNodes += entry.graph.nodes.size;
291
+ totalEdges += entry.graph.edges.length;
292
+ }
293
+ return {
294
+ cacheEntries: _workspaceGraphCache.size,
295
+ totalNodes,
296
+ totalEdges,
297
+ };
298
+ }
299
+ /** Test-only cache keys, including the LRU order from oldest to newest. */
300
+ export function _getReviewGraphWorkspaceCacheKeysForTests() {
301
+ return [..._workspaceGraphCache.entries()]
302
+ .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
303
+ .map(([key]) => key);
135
304
  }
136
305
  export function _getReviewGraphCacheStateForTests(cwd) {
137
306
  const cached = _workspaceGraphCache.get(normalizeMapKey(cwd));
138
307
  if (!cached)
139
308
  return undefined;
309
+ touchWorkspaceGraph(normalizeMapKey(cwd));
140
310
  return {
141
311
  signature: cached.signature,
142
312
  fileSignatures: new Map(cached.fileSignatures),
143
313
  fileHashes: cached.fileHashes ? new Map(cached.fileHashes) : undefined,
144
314
  };
145
315
  }
316
+ export function getReviewGraphCacheIdentity(cwd, graph) {
317
+ const cached = _workspaceGraphCache.get(normalizeMapKey(cwd));
318
+ if (!cached)
319
+ return undefined;
320
+ touchWorkspaceGraph(normalizeMapKey(cwd));
321
+ // #1088: `version` is the constant schema tag ("v8" today) — identical for
322
+ // every live graph, so comparing it can never detect that `graph` is a
323
+ // stale instance the workspace cache has since replaced (e.g. a concurrent
324
+ // build racing between this caller's projection snapshot and its identity
325
+ // lookup). Compare the ENTRY's generation stamp (#459), not the stored
326
+ // graph object's: reuse paths store an unstamped `cloneGraph` copy and
327
+ // stamp only the returned instance, so `cached.graph.buildGeneration` is
328
+ // undefined on every drift-reuse / disk-hit entry while the caller's graph
329
+ // carries the entry's generation. The both-undefined case is legitimate
330
+ // only for the tier-3 hydration entry, where the caller holds the exact
331
+ // stored instance — anything else unstamped must not resolve an identity
332
+ // (e.g. the size-skip empty graph racing a hydrated entry).
333
+ if (graph) {
334
+ if (cached.buildGeneration === undefined && graph.buildGeneration === undefined) {
335
+ if (cached.graph !== graph)
336
+ return undefined;
337
+ }
338
+ else if (cached.buildGeneration !== graph.buildGeneration) {
339
+ return undefined;
340
+ }
341
+ }
342
+ const version = graph?.version ?? cached.graph.version;
343
+ if (typeof version !== "string" || version.length === 0 ||
344
+ typeof cached.signature !== "string") {
345
+ return undefined;
346
+ }
347
+ return { version, signature: cached.signature };
348
+ }
146
349
  // #300 Edge 2: the review-graph's cross-worktree isolation is INCIDENTAL to
147
350
  // the cwd-derived data-dir slug (getProjectDataDir) — it holds only because
148
351
  // every process is launched with its own worktree as cwd. If a host ever
@@ -225,6 +428,7 @@ export function getCachedReviewGraph(cwd) {
225
428
  return undefined;
226
429
  const cached = _workspaceGraphCache.get(key);
227
430
  if (cached) {
431
+ touchWorkspaceGraph(key);
228
432
  ensureIndexed(cached.graph);
229
433
  return cached.graph;
230
434
  }
@@ -242,7 +446,7 @@ export function getCachedReviewGraph(cwd) {
242
446
  });
243
447
  if (!disk)
244
448
  return undefined;
245
- _workspaceGraphCache.set(key, {
449
+ setWorkspaceGraph(key, {
246
450
  signature: disk.signature,
247
451
  fileSignatures: disk.fileSignatures,
248
452
  fileHashes: disk.fileHashes,
@@ -301,6 +505,10 @@ function cloneGraph(graph) {
301
505
  persistCoverage: graph.persistCoverage,
302
506
  };
303
507
  }
508
+ /** Refresh the content timestamp only when an incremental path changed graph data. */
509
+ function refreshGraphBuiltAt(graph) {
510
+ graph.builtAt = new Date().toISOString();
511
+ }
304
512
  function sourceSignatureEntry(file) {
305
513
  try {
306
514
  const stat = fs.statSync(file);
@@ -410,7 +618,7 @@ function diffSignatureMaps(previous, next) {
410
618
  // `RUNTIME_CONFIG.reviewGraph.maxFiles` constant as the fallback — the ratio
411
619
  // table reproduces that same 1,000-file default at the default base, so this
412
620
  // is behavior-neutral when nothing is configured.
413
- function getReviewGraphMaxFiles(cwd) {
621
+ export function getReviewGraphMaxFiles(cwd) {
414
622
  const override = Number.parseInt(process.env.PI_LENS_REVIEW_GRAPH_MAX_FILES ?? "", 10);
415
623
  return Number.isFinite(override) && override > 0
416
624
  ? override
@@ -463,26 +671,50 @@ export function getLastReviewGraphBuildAttempt(cwd) {
463
671
  export function _resetReviewGraphBuildAttemptsForTests() {
464
672
  _buildAttempts.clear();
465
673
  }
466
- function recordBuildAttempt(cwd, outcome, reason) {
467
- _buildAttempts.set(normalizeMapKey(cwd), {
674
+ function recordBuildAttempt(cwd, outcome, reason, buildId) {
675
+ const key = normalizeMapKey(cwd);
676
+ const prior = _buildAttempts.get(key);
677
+ // Build IDs are assigned at start, so a terminal event from an older
678
+ // overlapping build must never overwrite the newest build's status/reason.
679
+ // Keep the newest started attempt as the project-report truth source.
680
+ if (prior?.buildId !== undefined &&
681
+ (buildId === undefined || buildId < prior.buildId)) {
682
+ return;
683
+ }
684
+ _buildAttempts.set(key, {
468
685
  when: new Date().toISOString(),
469
686
  outcome,
687
+ ...(buildId === undefined ? {} : { buildId }),
470
688
  ...(reason ? { reason } : {}),
471
689
  });
472
690
  }
473
- function recordPersistFailure(cwd, reason, error) {
691
+ function recordPersistFailure(cwd, reason, error, pending, workerState) {
474
692
  // A debounced persist can fail AFTER a newer build already recorded
475
693
  // "failed" or "running" for this cwd — don't relabel a dead/in-flight
476
694
  // build as succeeded; only annotate a record that says succeeded.
477
695
  const prior = _buildAttempts.get(normalizeMapKey(cwd));
478
- if (prior === undefined || prior.outcome === "succeeded") {
479
- recordBuildAttempt(cwd, "succeeded", `graph built but persistence failed: ${error}`);
696
+ const pendingBuildId = pending?.graphMetadata.buildId;
697
+ const belongsToCurrentBuild = pendingBuildId === undefined || prior?.buildId === pendingBuildId;
698
+ if (belongsToCurrentBuild &&
699
+ (prior === undefined || prior.outcome === "succeeded")) {
700
+ recordBuildAttempt(cwd, "succeeded", `graph built but persistence failed: ${error}`, pendingBuildId);
480
701
  }
481
702
  logReviewGraph({
482
703
  cwd,
483
704
  phase: "persist_failed",
484
705
  reason,
485
706
  error,
707
+ ...(pending
708
+ ? {
709
+ observability: persistObservability(pending, {
710
+ status: "failed",
711
+ reason: workerState?.fallbackReason ?? reason,
712
+ workerStarted: workerState?.started,
713
+ workerCompleted: workerState?.completed,
714
+ ...(workerState?.fallbackReason ? { workerFallback: true } : {}),
715
+ }),
716
+ }
717
+ : {}),
486
718
  });
487
719
  }
488
720
  const _sizeSkipVerdicts = new Map();
@@ -494,12 +726,18 @@ function recordReviewGraphSizeSkip(cwd, sourceFileCount, maxFileCount) {
494
726
  _sizeSkipVerdicts.set(normalizeMapKey(cwd), {
495
727
  sourceFileCount,
496
728
  maxFileCount,
729
+ sourceFileCountTruncated: true,
497
730
  skippedAt: Date.now(),
498
731
  });
499
732
  }
500
733
  function clearReviewGraphSizeSkip(cwd) {
501
734
  _sizeSkipVerdicts.delete(normalizeMapKey(cwd));
502
735
  }
736
+ const REVIEW_GRAPH_SIZE_NEAR_MISS_RATIO = 0.05;
737
+ function isReviewGraphSizeNearMiss(sourceFileCount, maxFileCount) {
738
+ return (sourceFileCount > maxFileCount &&
739
+ sourceFileCount <= maxFileCount * (1 + REVIEW_GRAPH_SIZE_NEAR_MISS_RATIO));
740
+ }
503
741
  /**
504
742
  * The most recent size-skip verdict for `cwd`, if one was recorded and it's
505
743
  * still within its TTL — undefined once expired (a shrink or a raised cap
@@ -519,7 +757,17 @@ export function getReviewGraphSizeSkipVerdict(cwd, now = Date.now()) {
519
757
  }
520
758
  return verdict;
521
759
  }
522
- async function getGraphSourceFiles(cwd) {
760
+ // Test-only override so the entry-budget propagation contract can be exercised
761
+ // without constructing a 200k-entry fixture tree.
762
+ let _reviewGraphEntryBudgetForTests;
763
+ export function _setReviewGraphEntryBudgetForTests(maxScanEntries) {
764
+ _reviewGraphEntryBudgetForTests = maxScanEntries;
765
+ }
766
+ let _reviewGraphEntryCounterForTests;
767
+ export function _setReviewGraphEntryCounterForTests(counter) {
768
+ _reviewGraphEntryCounterForTests = counter;
769
+ }
770
+ export async function getGraphSourceFiles(cwd) {
523
771
  // Async, chunked-yield walk (identical output to the sync collector) so the
524
772
  // per-edit cascade graph rebuild doesn't block the event loop on a large repo.
525
773
  //
@@ -529,17 +777,21 @@ async function getGraphSourceFiles(cwd) {
529
777
  // the cap is hit the caller skips the build on count alone, so the unfiltered
530
778
  // over-limit list is all it needs — see _doBuildGraph's too_many_files branch.
531
779
  const maxGraphFiles = getReviewGraphMaxFiles(cwd);
532
- // #760: the maxFiles cap above bounds results FOUND, not entries VISITED —
533
- // a mixed tree with few source files among a huge pile of non-source files
534
- // never trips it. The walk's default entry budget (DEFAULT_MAX_SCAN_ENTRIES)
535
- // bounds the visit count; a truncated best-effort list is acceptable for the
536
- // graph (it degrades to a partial graph, same as maxFiles trimming), so just
537
- // log the truncation for observability rather than failing the build.
780
+ // #760: the maxFiles cap bounds results FOUND, not entries VISITED. A mixed
781
+ // tree with few source files among a huge pile of non-source files can trip the
782
+ // entry budget first. That result is a useful partial graph, but its lower-bound
783
+ // count MUST travel with the graph and persistence metadata; it is never clean.
538
784
  const { files: collected, entryBudgetExceeded } = await collectProjectSourceFilesWithBudgetAsync(cwd, {
539
785
  // Only walk graph-relevant extensions so the cap counts what the graph
540
786
  // keeps (post-filter), not JSON/YAML/MD noise it would discard anyway.
541
787
  extensions: MAIN_KIND_EXTENSIONS,
542
788
  maxFiles: maxGraphFiles + 1,
789
+ ...(_reviewGraphEntryBudgetForTests === undefined
790
+ ? {}
791
+ : { maxScanEntries: _reviewGraphEntryBudgetForTests }),
792
+ ...(_reviewGraphEntryCounterForTests === undefined
793
+ ? {}
794
+ : { onEntryVisited: _reviewGraphEntryCounterForTests }),
543
795
  });
544
796
  if (entryBudgetExceeded) {
545
797
  logLatency({
@@ -553,7 +805,12 @@ async function getGraphSourceFiles(cwd) {
553
805
  if (collected.length > maxGraphFiles) {
554
806
  // Contents are unused by the too_many_files branch; return the capped list
555
807
  // so the caller's `length > maxGraphFiles` check still trips.
556
- return collected;
808
+ return {
809
+ files: collected,
810
+ sourceFileCount: collected.length,
811
+ maxFileCount: maxGraphFiles,
812
+ entryBudgetExceeded,
813
+ };
557
814
  }
558
815
  const result = [];
559
816
  let sinceYield = 0;
@@ -576,7 +833,12 @@ async function getGraphSourceFiles(cwd) {
576
833
  await yieldToLoop();
577
834
  }
578
835
  }
579
- return result;
836
+ return {
837
+ files: result,
838
+ sourceFileCount: result.length,
839
+ maxFileCount: maxGraphFiles,
840
+ entryBudgetExceeded,
841
+ };
580
842
  }
581
843
  function addNode(graph, node) {
582
844
  graph.nodes.set(node.id, node);
@@ -635,7 +897,13 @@ function loadPersistedGraph(cwd, opts) {
635
897
  ? gunzipSync(fs.readFileSync(cachePath)).toString("utf-8")
636
898
  : fs.readFileSync(legacyPath, "utf-8");
637
899
  const data = JSON.parse(raw);
638
- if (data.version !== REVIEW_GRAPH_VERSION)
900
+ // Derived projections require a canonical source identity. Legacy or
901
+ // malformed snapshots are unavailable, never a clean empty graph.
902
+ if (data.version !== REVIEW_GRAPH_VERSION ||
903
+ typeof data.signature !== "string" ||
904
+ typeof data.builtAt !== "string" ||
905
+ !Array.isArray(data.nodes) ||
906
+ !Array.isArray(data.edges))
639
907
  return null;
640
908
  if (data.coverage?.partial && !opts?.allowPartial)
641
909
  return null;
@@ -727,7 +995,9 @@ function getPersistedReviewGraphVersion(cwd) {
727
995
  fd = fs.openSync(legacyPath, "r");
728
996
  const buf = Buffer.alloc(200);
729
997
  const n = fs.readSync(fd, buf, 0, 200, 0);
730
- const match = buf.toString("utf-8", 0, n).match(/"version"\s*:\s*"([^"]+)"/);
998
+ const match = buf
999
+ .toString("utf-8", 0, n)
1000
+ .match(/"version"\s*:\s*"([^"]+)"/);
731
1001
  return match ? match[1] : null;
732
1002
  }
733
1003
  catch {
@@ -785,7 +1055,9 @@ const _persistGenerations = new Map();
785
1055
  const _workerRequests = new Map();
786
1056
  let _persistWorker;
787
1057
  let _persistWorkerRequestId = 0;
1058
+ let _persistAttemptId = 0;
788
1059
  let _workerDisabled = false;
1060
+ let _persistWorkerUnavailableReason;
789
1061
  let _lastWorkerFallbackReasonForTests;
790
1062
  const _checkpointGenerations = new Map();
791
1063
  const _checkpointWorkerRequests = new Map();
@@ -808,14 +1080,33 @@ function persistedData(pending) {
808
1080
  gitStamp: pending.gitStamp,
809
1081
  };
810
1082
  }
811
- function graphCoverage(graph, cap) {
1083
+ function countRetainedSourceFiles(graph, sourceFilePaths) {
1084
+ if (sourceFilePaths === undefined)
1085
+ return graph.fileNodes.size;
1086
+ const sourceKeys = new Set();
1087
+ for (const filePath of sourceFilePaths) {
1088
+ sourceKeys.add(normalizeMapKey(filePath));
1089
+ }
1090
+ let retained = 0;
1091
+ for (const filePath of sourceKeys) {
1092
+ if (graph.fileNodes.has(filePath))
1093
+ retained++;
1094
+ }
1095
+ return retained;
1096
+ }
1097
+ function graphCoverage(graph, cap, sourceFileCount = graph.persistCoverage?.totalFiles ?? graph.fileNodes.size, sourceFilesTruncated = graph.persistCoverage?.sourceFilesTruncated === true, sourceFilePaths) {
1098
+ const inherited = graph.persistCoverage;
812
1099
  return {
813
- partial: false,
1100
+ partial: inherited?.partial === true || sourceFilesTruncated,
814
1101
  cap,
815
1102
  totalNodes: graph.nodes.size,
816
1103
  totalEdges: graph.edges.length,
817
1104
  persistedNodes: graph.nodes.size,
818
1105
  persistedEdges: graph.edges.length,
1106
+ totalFiles: sourceFileCount,
1107
+ persistedFiles: countRetainedSourceFiles(graph, sourceFilePaths),
1108
+ ...(sourceFilesTruncated ? { sourceFilesTruncated: true } : {}),
1109
+ ...(inherited?.inProgress ? { inProgress: true } : {}),
819
1110
  };
820
1111
  }
821
1112
  /**
@@ -824,7 +1115,7 @@ function graphCoverage(graph, cap) {
824
1115
  * graph's node/edge ratio so symbol-dense and edge-dense repositories both
825
1116
  * retain a useful mix instead of allowing either side to consume the cap.
826
1117
  */
827
- function capGraphForPersist(cwd, graph, cap) {
1118
+ function capGraphForPersist(cwd, graph, cap, options = {}) {
828
1119
  const totalElements = graph.nodes.size + graph.edges.length;
829
1120
  const nodeBudget = Math.max(1, Math.floor((cap * graph.nodes.size) / totalElements));
830
1121
  const reverseDeps = buildReverseDependencyIndexFromGraph({ cwd, graph });
@@ -842,7 +1133,9 @@ function capGraphForPersist(cwd, graph, cap) {
842
1133
  const ids = nodeIdsByFile.get(filePath) ?? [];
843
1134
  if (ids.length === 0)
844
1135
  continue;
845
- const effectiveNodeBudget = keptIds.size === 0 ? Math.max(nodeBudget, Math.min(cap, ids.length)) : nodeBudget;
1136
+ const effectiveNodeBudget = keptIds.size === 0
1137
+ ? Math.max(nodeBudget, Math.min(cap, ids.length))
1138
+ : nodeBudget;
846
1139
  if (keptIds.size + ids.length > effectiveNodeBudget)
847
1140
  continue;
848
1141
  for (const id of ids)
@@ -860,6 +1153,15 @@ function capGraphForPersist(cwd, graph, cap) {
860
1153
  totalEdges: graph.edges.length,
861
1154
  persistedNodes: nodes.size,
862
1155
  persistedEdges: edges.length,
1156
+ totalFiles: options.sourceFileCount ??
1157
+ graph.persistCoverage?.totalFiles ??
1158
+ graph.fileNodes.size,
1159
+ persistedFiles: 0,
1160
+ ...(options.sourceFilesTruncated ||
1161
+ graph.persistCoverage?.sourceFilesTruncated
1162
+ ? { sourceFilesTruncated: true }
1163
+ : {}),
1164
+ ...(graph.persistCoverage?.inProgress ? { inProgress: true } : {}),
863
1165
  };
864
1166
  const capped = {
865
1167
  ...graph,
@@ -870,12 +1172,20 @@ function capGraphForPersist(cwd, graph, cap) {
870
1172
  fileNodes: new Map(),
871
1173
  symbolNodesByFile: new Map(),
872
1174
  changedSymbolsByFile: new Map(),
873
- persistCoverage: coverage,
1175
+ persistCoverage: undefined,
874
1176
  };
875
1177
  rebuildIndexes(capped);
1178
+ coverage.persistedFiles = countRetainedSourceFiles(capped, options.sourceFilePaths);
1179
+ capped.persistCoverage = coverage;
876
1180
  return capped;
877
1181
  }
878
- function logPersistSuccess(key, pending, stats) {
1182
+ function persistObservability(pending, patch) {
1183
+ return {
1184
+ graph: pending.graphMetadata,
1185
+ persistence: { ...pending.persistenceMetadata, ...patch },
1186
+ };
1187
+ }
1188
+ function logPersistSuccess(key, pending, stats, workerState = { started: false, completed: false }) {
879
1189
  logLatency({
880
1190
  type: "phase",
881
1191
  phase: "review_graph_persist",
@@ -888,9 +1198,15 @@ function logPersistSuccess(key, pending, stats) {
888
1198
  phase: "persist_succeeded",
889
1199
  elements: pending.elementCount,
890
1200
  ...stats,
1201
+ observability: persistObservability(pending, {
1202
+ status: "succeeded",
1203
+ workerStarted: workerState.started,
1204
+ workerCompleted: workerState.completed,
1205
+ ...(workerState.fallback ? { workerFallback: true } : {}),
1206
+ }),
891
1207
  });
892
1208
  }
893
- function writePendingOnMainThread(key, pending, reason) {
1209
+ function writePendingOnMainThread(key, pending, reason, workerState = { started: false, completed: false }) {
894
1210
  const serializeStarted = performance.now();
895
1211
  try {
896
1212
  const json = JSON.stringify(persistedData(pending));
@@ -909,6 +1225,10 @@ function writePendingOnMainThread(key, pending, reason) {
909
1225
  serializeMs,
910
1226
  writeMs: performance.now() - writeStarted,
911
1227
  offloaded: false,
1228
+ }, {
1229
+ started: workerState.started,
1230
+ completed: workerState.completed,
1231
+ fallback: Boolean(reason),
912
1232
  });
913
1233
  if (reason) {
914
1234
  // The persist SUCCEEDED via fallback — log the degradation under its
@@ -921,13 +1241,22 @@ function writePendingOnMainThread(key, pending, reason) {
921
1241
  reason: "worker_fallback",
922
1242
  error: reason,
923
1243
  offloaded: false,
1244
+ observability: persistObservability(pending, {
1245
+ status: "fallback",
1246
+ workerStarted: workerState.started,
1247
+ workerCompleted: workerState.completed,
1248
+ workerFallback: true,
1249
+ reason: workerState.fallbackReason ?? "worker_fallback",
1250
+ }),
924
1251
  });
925
1252
  }
926
1253
  }
927
1254
  catch (err) {
928
1255
  const message = err instanceof Error ? err.message : String(err);
929
- recordPersistFailure(key, "cache_write_failed", message);
930
- console.error("[review-graph] cache persist failed:", message);
1256
+ recordPersistFailure(key, "cache_write_failed", message, pending, workerState);
1257
+ // #1333: recordPersistFailure already emits `phase: "persist_failed"` to
1258
+ // review-graph.log with this same message — the console.error was a
1259
+ // duplicate RAW write into pi's frame, not a second destination.
931
1260
  }
932
1261
  }
933
1262
  function handleWorkerResult(result) {
@@ -946,17 +1275,50 @@ function handleWorkerResult(result) {
946
1275
  }
947
1276
  _workerRequests.delete(result.id);
948
1277
  const { key, pending } = request;
1278
+ const currentGeneration = _persistGenerations.get(key);
1279
+ if (currentGeneration !== result.generation) {
1280
+ // Removing the request makes completion observable to test/CLI waiters.
1281
+ // Reap our completed loser synchronously first so "no requests in flight"
1282
+ // also means its stage namespace is clean (#1318). Caught (#1361 review):
1283
+ // force suppresses ENOENT but not EBUSY/EPERM (Windows AV/backup can
1284
+ // briefly hold the handle) -- a failed reap must never abort this
1285
+ // callback, or the WINNING generation's completion is lost with it. The
1286
+ // startup sweep reclaims anything a failed unlink leaves behind.
1287
+ try {
1288
+ fs.rmSync(result.stagePath, { force: true });
1289
+ }
1290
+ catch (reapErr) {
1291
+ logReviewGraph({
1292
+ cwd: key,
1293
+ phase: "persist_failed",
1294
+ reason: `superseded-stage reap failed: ${String(reapErr)}`,
1295
+ });
1296
+ }
1297
+ logReviewGraph({
1298
+ cwd: key,
1299
+ phase: "persist_skipped",
1300
+ reason: "superseded",
1301
+ observability: persistObservability(pending, {
1302
+ status: "superseded",
1303
+ supersededByGeneration: currentGeneration,
1304
+ reason: "newer_generation_scheduled",
1305
+ workerStarted: true,
1306
+ workerCompleted: true,
1307
+ }),
1308
+ });
1309
+ return;
1310
+ }
949
1311
  if (result.error ||
950
1312
  result.rawBytes === undefined ||
951
1313
  result.gzBytes === undefined ||
952
1314
  result.serializeMs === undefined ||
953
1315
  result.writeMs === undefined) {
954
1316
  fs.rm(result.stagePath, { force: true }, () => { });
955
- writePendingOnMainThread(key, pending, result.error ?? "invalid worker result");
956
- return;
957
- }
958
- if (_persistGenerations.get(key) !== result.generation) {
959
- fs.rm(result.stagePath, { force: true }, () => { });
1317
+ writePendingOnMainThread(key, pending, result.error ?? "invalid worker result", {
1318
+ started: true,
1319
+ completed: true,
1320
+ fallbackReason: result.error ? "worker_error" : "invalid_worker_result",
1321
+ });
960
1322
  return;
961
1323
  }
962
1324
  try {
@@ -970,11 +1332,15 @@ function handleWorkerResult(result) {
970
1332
  serializeMs: result.serializeMs,
971
1333
  writeMs: result.writeMs,
972
1334
  offloaded: true,
973
- });
1335
+ }, { started: true, completed: true });
974
1336
  }
975
1337
  catch (err) {
976
1338
  fs.rm(result.stagePath, { force: true }, () => { });
977
- writePendingOnMainThread(key, pending, err instanceof Error ? err.message : String(err));
1339
+ writePendingOnMainThread(key, pending, err instanceof Error ? err.message : String(err), {
1340
+ started: true,
1341
+ completed: true,
1342
+ fallbackReason: "promotion_failed",
1343
+ });
978
1344
  }
979
1345
  }
980
1346
  function handleCheckpointWorkerResult(cp, result) {
@@ -1024,12 +1390,32 @@ function handleCheckpointWorkerResult(cp, result) {
1024
1390
  }
1025
1391
  }
1026
1392
  function handleWorkerDeath(reason) {
1393
+ _persistWorkerUnavailableReason = reason;
1027
1394
  _persistWorker = undefined;
1028
1395
  _workerDisabled = true;
1029
1396
  const requests = [..._workerRequests.values()];
1030
1397
  _workerRequests.clear();
1031
1398
  for (const { key, pending } of requests) {
1032
- writePendingOnMainThread(key, pending, reason);
1399
+ if (_persistGenerations.get(key) !== pending.generation) {
1400
+ logReviewGraph({
1401
+ cwd: key,
1402
+ phase: "persist_skipped",
1403
+ reason: "superseded",
1404
+ observability: persistObservability(pending, {
1405
+ status: "superseded",
1406
+ supersededByGeneration: _persistGenerations.get(key),
1407
+ reason: "worker_death_after_newer_generation",
1408
+ workerStarted: true,
1409
+ workerCompleted: false,
1410
+ }),
1411
+ });
1412
+ continue;
1413
+ }
1414
+ writePendingOnMainThread(key, pending, reason, {
1415
+ started: true,
1416
+ completed: false,
1417
+ fallbackReason: "worker_death",
1418
+ });
1033
1419
  }
1034
1420
  // Best-effort checkpoints don't fall back (no retained DTO to pin heap); drop
1035
1421
  // their in-flight requests and clean up any stage files they may have left.
@@ -1080,22 +1466,27 @@ function getPersistWorker() {
1080
1466
  return undefined;
1081
1467
  }
1082
1468
  const worker = new Worker(workerPath);
1083
- worker.unref();
1084
1469
  worker.on("message", handleWorkerResult);
1085
1470
  worker.on("error", (err) => handleWorkerDeath(err.message));
1086
1471
  worker.on("exit", (code) => {
1087
1472
  if (_persistWorker !== worker)
1088
1473
  return;
1089
- if (code !== 0) {
1090
- handleWorkerDeath(`persist worker exited with code ${code}`);
1474
+ const hasPendingRequests = _workerRequests.size > 0 || _checkpointWorkerRequests.size > 0;
1475
+ if (code !== 0 || hasPendingRequests) {
1476
+ handleWorkerDeath(code !== 0
1477
+ ? `persist worker exited with code ${code}`
1478
+ : "persist worker exited with pending requests");
1091
1479
  }
1092
1480
  else {
1093
- // Clean exit (unref'd worker at teardown, or host recycling):
1094
- // drop the stale reference so a later persist respawns instead
1095
- // of posting into a dead worker (#950 review F7).
1481
+ // Clean exit with no pending work (unref'd worker at teardown, or host
1482
+ // recycling): drop the stale reference so a later persist respawns
1483
+ // instead of posting into a dead worker (#950 review F7).
1096
1484
  _persistWorker = undefined;
1097
1485
  }
1098
1486
  });
1487
+ // #1148: adding a message listener refs the Worker's public MessagePort.
1488
+ // Unref only after every listener is installed so it stays background-only.
1489
+ worker.unref();
1099
1490
  _persistWorker = worker;
1100
1491
  return worker;
1101
1492
  }
@@ -1116,7 +1507,12 @@ function writePending(key) {
1116
1507
  }
1117
1508
  const worker = getPersistWorker();
1118
1509
  if (!worker) {
1119
- writePendingOnMainThread(key, pending, "persist worker unavailable");
1510
+ const unavailableReason = _persistWorkerUnavailableReason ?? "persist worker unavailable";
1511
+ writePendingOnMainThread(key, pending, unavailableReason, {
1512
+ started: false,
1513
+ completed: false,
1514
+ fallbackReason: unavailableReason,
1515
+ });
1120
1516
  return;
1121
1517
  }
1122
1518
  const id = ++_persistWorkerRequestId;
@@ -1139,31 +1535,81 @@ function writePending(key) {
1139
1535
  // snapshot isn't lost. Sync writes only (no child spawn — see the teardown
1140
1536
  // libuv hazard); best-effort.
1141
1537
  let _persistExitHookInstalled = false;
1538
+ function flushPendingReviewGraphPersistsAtExit() {
1539
+ const keys = new Set([
1540
+ ..._pendingPersist.keys(),
1541
+ ...[..._workerRequests.values()].map((request) => request.key),
1542
+ ]);
1543
+ for (const key of keys) {
1544
+ // Shared with the CLI's forced flush — same persistedData DTO, same
1545
+ // atomic writer (#762), distinct failure label per source.
1546
+ flushReviewGraphPersist(key, "exit_hook");
1547
+ }
1548
+ // The review-graph logger's shared process-exit flusher was registered
1549
+ // before this hook. Flush again after emitting exit-hook outcomes so a
1550
+ // successful forced write cannot leave its lifecycle event buffered.
1551
+ flushReviewGraphLogSync();
1552
+ void _persistWorker?.terminate();
1553
+ }
1554
+ /** Test-only seam for the exit-hook ordering/durability contract. */
1555
+ export function flushReviewGraphPersistsForExitForTests() {
1556
+ flushPendingReviewGraphPersistsAtExit();
1557
+ }
1142
1558
  function ensurePersistExitHook() {
1143
1559
  if (_persistExitHookInstalled)
1144
1560
  return;
1145
1561
  _persistExitHookInstalled = true;
1146
- process.once("exit", () => {
1147
- const keys = new Set([
1148
- ..._pendingPersist.keys(),
1149
- ...[..._workerRequests.values()].map((request) => request.key),
1150
- ]);
1151
- for (const key of keys) {
1152
- // Shared with the CLI's forced flush — same persistedData DTO, same
1153
- // atomic writer (#762), distinct failure label per source.
1154
- const result = flushReviewGraphPersist(key, "exit_hook");
1155
- if (!result.ok)
1156
- flushReviewGraphLogSync();
1157
- }
1158
- void _persistWorker?.terminate();
1159
- });
1562
+ process.once("exit", flushPendingReviewGraphPersistsAtExit);
1160
1563
  }
1161
1564
  // #950 review F3: a process that dies between a worker's staged write and its
1162
1565
  // promotion leaves review-graph.json.gz.stage-<pid>-<gen> (and the worker's
1163
- // .tmp-<pid>) behind forever — the exit hook can't run handleWorkerResult's rm.
1164
- // Sweep leftovers from PRIOR processes once per cache dir; our own live stage
1165
- // files carry this pid and are skipped.
1566
+ // <stage>.tmp-<pid>) behind forever — the exit hook can't run
1567
+ // handleWorkerResult's rm. Sweep leftovers from PRIOR processes once per cache
1568
+ // dir.
1569
+ //
1570
+ // #1206: `cacheDir` is the SHARED project cache dir (getProjectDataDir/cache)
1571
+ // that every durable store stages into via writeFileAtomic's generic
1572
+ // `<target>.tmp-<pid>`. The old predicate also matched that shape, so this
1573
+ // sweep deleted OTHER modules' in-flight staging files in the window between
1574
+ // their write and their rename — turning their rename into ENOENT (propagated
1575
+ // out of markDisposition for bestEffort:false stores) or silently dropping the
1576
+ // update. The sweep is therefore scoped to artifacts the review graph itself
1577
+ // produces, all of which are `<review-graph.*>.stage-<pid>-<gen>`:
1578
+ // review-graph.json.gz.stage-<pid>-<gen> (persistGraph, L1861)
1579
+ // review-graph.checkpoint.json.gz.stage-<pid>-<gen> (checkpoint, L2259)
1580
+ // ...plus either's worker tmp `<stage>.tmp-<pid>`, which still carries both
1581
+ // the `review-graph.` prefix and the `.stage-` marker.
1582
+ // (LEGACY_GRAPH_CACHE_FILENAME, `review-graph.json`, is never staged — it is
1583
+ // only ever `rmSync`'d as a one-time migration cleanup — so it is not a
1584
+ // producer here despite matching the prefix.)
1585
+ // The bare `.tmp-<pid>` shape is never matched, which also makes this sweep
1586
+ // independent of any change to atomic-write's staging name (#1205). Dropping
1587
+ // that shape means the review-graph sweep is no longer the incidental GC for
1588
+ // other stores' orphaned atomic-write temps; the generic `.tmp-*` namespace is
1589
+ // now swept at session_start by instance-reaper (#1228), not by this graph
1590
+ // artifact-specific pass.
1591
+ //
1592
+ // Liveness: an entry whose embedded stage pid is still alive belongs to a
1593
+ // concurrent healthy owner (or to us) and is skipped, reusing the reaper's
1594
+ // conservative `realIsPidAlive` (ESRCH-only-means-dead) rather than inventing
1595
+ // a second liveness probe. A recycled pid can therefore leave one stale stage
1596
+ // file behind instead of destroying a live one — deliberately the safe
1597
+ // direction; a later process whose pid table has moved on sweeps it.
1166
1598
  const _sweptStageDirs = new Set();
1599
+ const REVIEW_GRAPH_ARTIFACT_PREFIX = "review-graph.";
1600
+ const STAGE_PID_PATTERN = /\.stage-(\d+)-/;
1601
+ /** True only for a review-graph stage artifact left behind by a dead process. */
1602
+ function isStaleReviewGraphStageFile(entry) {
1603
+ if (!entry.startsWith(REVIEW_GRAPH_ARTIFACT_PREFIX))
1604
+ return false;
1605
+ const match = STAGE_PID_PATTERN.exec(entry);
1606
+ if (!match)
1607
+ return false;
1608
+ const pid = Number(match[1]);
1609
+ if (pid === process.pid)
1610
+ return false; // our own live stage file
1611
+ return !realIsPidAlive(pid);
1612
+ }
1167
1613
  function sweepStaleStageFiles(cacheDir) {
1168
1614
  if (_sweptStageDirs.has(cacheDir))
1169
1615
  return;
@@ -1171,22 +1617,35 @@ function sweepStaleStageFiles(cacheDir) {
1171
1617
  fs.readdir(cacheDir, (err, entries) => {
1172
1618
  if (err)
1173
1619
  return;
1174
- const ownMarker = `.stage-${process.pid}-`;
1175
1620
  for (const entry of entries) {
1176
- const isStage = entry.includes(".stage-") || /\.tmp-\d+$/.test(entry);
1177
- if (!isStage || entry.includes(ownMarker))
1621
+ if (!isStaleReviewGraphStageFile(entry))
1178
1622
  continue;
1179
1623
  fs.rm(path.join(cacheDir, entry), { force: true }, () => { });
1180
1624
  }
1181
1625
  });
1182
1626
  }
1183
- function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
1627
+ /** Test seam: the sweep runs at most once per cache dir per process. */
1628
+ export function _resetReviewGraphStageSweepForTests() {
1629
+ _sweptStageDirs.clear();
1630
+ }
1631
+ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph, options = {}) {
1184
1632
  const totalElementCount = graph.nodes.size + graph.edges.length;
1185
1633
  const cap = graphPersistMaxElements();
1634
+ const sourceFileCount = options.sourceFileCount ?? fileSignatures.size;
1635
+ const sourceFilePaths = options.sourceFilePaths ?? fileSignatures.keys();
1186
1636
  const persistedGraph = totalElementCount > cap
1187
- ? capGraphForPersist(cwd, graph, cap)
1188
- : { ...graph, persistCoverage: graphCoverage(graph, cap) };
1637
+ ? capGraphForPersist(cwd, graph, cap, {
1638
+ sourceFileCount,
1639
+ sourceFilesTruncated: options.sourceFilesTruncated,
1640
+ sourceFilePaths,
1641
+ })
1642
+ : {
1643
+ ...graph,
1644
+ persistCoverage: graphCoverage(graph, cap, sourceFileCount, options.sourceFilesTruncated, sourceFilePaths),
1645
+ };
1189
1646
  const elementCount = persistedGraph.nodes.size + persistedGraph.edges.length;
1647
+ const sourceWalkPartial = persistedGraph.persistCoverage?.sourceFilesTruncated === true;
1648
+ let persistReason;
1190
1649
  if (totalElementCount > cap) {
1191
1650
  const coverage = persistedGraph.persistCoverage;
1192
1651
  if (!coverage)
@@ -1203,18 +1662,13 @@ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
1203
1662
  cap,
1204
1663
  },
1205
1664
  });
1206
- const reason = `persisted partial review graph (${elementCount}/${totalElementCount} elements; ` +
1207
- `${coverage.persistedNodes}/${coverage.totalNodes} nodes, ` +
1208
- `${coverage.persistedEdges}/${coverage.totalEdges} edges; ${cap} cap)`;
1209
- recordBuildAttempt(cwd, "succeeded", reason);
1210
- logReviewGraph({
1211
- cwd,
1212
- phase: "persist_partial",
1213
- reason: "element_cap_exceeded",
1214
- elements: totalElementCount,
1215
- persistedElements: elementCount,
1216
- cap,
1217
- });
1665
+ persistReason =
1666
+ `persisted partial review graph (${elementCount}/${totalElementCount} elements; ` +
1667
+ `${coverage.persistedNodes}/${coverage.totalNodes} nodes, ` +
1668
+ `${coverage.persistedEdges}/${coverage.totalEdges} edges; ${cap} cap)`;
1669
+ }
1670
+ else if (sourceWalkPartial) {
1671
+ persistReason = "persisted partial review graph (source walk entry budget)";
1218
1672
  }
1219
1673
  const cacheDir = path.join(getProjectDataDir(cwd), "cache");
1220
1674
  const cachePath = path.join(cacheDir, GRAPH_CACHE_FILENAME);
@@ -1228,9 +1682,31 @@ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
1228
1682
  // arrays only after the quiet window. Replacing a pending entry during an edit
1229
1683
  // burst now avoids both serialization and the pre-serialization full copies.
1230
1684
  const key = normalizeMapKey(cwd);
1231
- const generation = (_persistGenerations.get(key) ?? 0) + 1;
1685
+ const priorGeneration = _persistGenerations.get(key);
1686
+ const existingPending = _pendingPersist.get(key);
1687
+ const existingTimer = _persistTimers.get(key);
1688
+ const generation = (priorGeneration ?? 0) + 1;
1689
+ const attemptId = ++_persistAttemptId;
1690
+ const coalesced = Boolean(existingPending || existingTimer);
1232
1691
  _persistGenerations.set(key, generation);
1233
- _pendingPersist.set(key, {
1692
+ const graphMetadata = graphLogMetadata(persistedGraph, {
1693
+ ...options,
1694
+ sourceFileCount,
1695
+ sourceFileCountTruncated: persistedGraph.persistCoverage?.sourceFilesTruncated === true,
1696
+ });
1697
+ const persistenceMetadata = {
1698
+ generation,
1699
+ attemptId,
1700
+ status: "scheduled",
1701
+ ...(priorGeneration === undefined
1702
+ ? {}
1703
+ : {
1704
+ supersededGeneration: priorGeneration,
1705
+ coalesced,
1706
+ reason: coalesced ? "debounced_coalescing" : "in_flight_supersession",
1707
+ }),
1708
+ };
1709
+ const pending = {
1234
1710
  cacheDir,
1235
1711
  cachePath,
1236
1712
  signature,
@@ -1240,12 +1716,50 @@ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
1240
1716
  gitStamp,
1241
1717
  elementCount,
1242
1718
  generation,
1243
- });
1719
+ attemptId,
1720
+ graphMetadata,
1721
+ persistenceMetadata,
1722
+ };
1723
+ if (existingPending) {
1724
+ logReviewGraph({
1725
+ cwd: key,
1726
+ phase: "persist_skipped",
1727
+ reason: "superseded",
1728
+ observability: persistObservability(existingPending, {
1729
+ status: "superseded",
1730
+ supersededByGeneration: generation,
1731
+ reason: "newer_generation_scheduled",
1732
+ workerStarted: false,
1733
+ workerCompleted: false,
1734
+ }),
1735
+ });
1736
+ }
1737
+ _pendingPersist.set(key, pending);
1738
+ if (totalElementCount > cap || sourceWalkPartial) {
1739
+ logReviewGraph({
1740
+ cwd,
1741
+ phase: "persist_partial",
1742
+ reason: totalElementCount > cap
1743
+ ? "element_cap_exceeded"
1744
+ : "source_walk_entry_budget",
1745
+ elements: totalElementCount,
1746
+ persistedElements: elementCount,
1747
+ cap,
1748
+ observability: {
1749
+ graph: graphMetadata,
1750
+ persistence: persistenceMetadata,
1751
+ },
1752
+ });
1753
+ }
1244
1754
  logReviewGraph({
1245
1755
  cwd,
1246
1756
  phase: "persist_scheduled",
1247
1757
  elements: elementCount,
1248
1758
  cap,
1759
+ observability: {
1760
+ graph: graphMetadata,
1761
+ persistence: persistenceMetadata,
1762
+ },
1249
1763
  });
1250
1764
  ensurePersistExitHook();
1251
1765
  const debounce = graphPersistDebounceMs();
@@ -1254,13 +1768,14 @@ function persistGraph(cwd, signature, fileSignatures, fileHashes, graph) {
1254
1768
  clearTimeout(existing);
1255
1769
  if (debounce === 0) {
1256
1770
  writePending(key);
1257
- return;
1771
+ return persistReason;
1258
1772
  }
1259
1773
  const timer = setTimeout(() => writePending(key), debounce);
1260
1774
  // Don't keep the event loop alive solely for a cache write.
1261
1775
  if (typeof timer.unref === "function")
1262
1776
  timer.unref();
1263
1777
  _persistTimers.set(key, timer);
1778
+ return persistReason;
1264
1779
  }
1265
1780
  // --- Cross-session resumable full build (checkpointing, #936 limit 2) ---
1266
1781
  // A full build walks + tree-sitter-parses every source file, then resolves
@@ -1306,7 +1821,7 @@ function reviewGraphCheckpointPath(cwd) {
1306
1821
  function hashIgnoredIds(ignoredIds) {
1307
1822
  if (ignoredIds === undefined)
1308
1823
  return "unavailable";
1309
- const joined = [...ignoredIds].sort((a, b) => a.localeCompare(b)).join("");
1824
+ const joined = [...ignoredIds].sort((a, b) => a.localeCompare(b)).join("\u0000");
1310
1825
  return createHash("sha256").update(joined).digest("hex");
1311
1826
  }
1312
1827
  /** Assemble the checkpoint DTO from the current PRE-resolution graph. Isolated
@@ -1501,9 +2016,14 @@ function loadReviewGraphCheckpoint(cwd) {
1501
2016
  totalEdges,
1502
2017
  persistedNodes: totalNodes,
1503
2018
  persistedEdges: totalEdges,
2019
+ totalFiles: data.targetFileCount,
2020
+ persistedFiles: 0,
1504
2021
  },
1505
2022
  };
1506
2023
  rebuildIndexes(graph);
2024
+ if (graph.persistCoverage) {
2025
+ graph.persistCoverage.persistedFiles = countRetainedSourceFiles(graph, data.processedFiles.map(([filePath]) => filePath));
2026
+ }
1507
2027
  return {
1508
2028
  graph,
1509
2029
  processedHashes: new Map(data.processedFiles),
@@ -1670,6 +2190,8 @@ export async function terminateReviewGraphPersistWorkerForTests() {
1670
2190
  /** Test-only: restore worker creation after a deliberate death. */
1671
2191
  export function resetReviewGraphPersistWorkerForTests() {
1672
2192
  _workerDisabled = false;
2193
+ _persistWorker = undefined;
2194
+ _persistWorkerUnavailableReason = undefined;
1673
2195
  _lastWorkerFallbackReasonForTests = undefined;
1674
2196
  _checkpointWorkerRequests.clear();
1675
2197
  _checkpointGenerations.clear();
@@ -1701,16 +2223,39 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
1701
2223
  // the flush. Removed requests' late results hit the no-request branch in
1702
2224
  // handleWorkerResult, which deletes their stage files.
1703
2225
  let pending = _pendingPersist.get(key);
2226
+ const removedWorkerRequests = [];
2227
+ let selectedWorkerRequest;
1704
2228
  for (const [id, request] of [..._workerRequests]) {
1705
2229
  if (request.key !== key)
1706
2230
  continue;
1707
2231
  _workerRequests.delete(id);
2232
+ removedWorkerRequests.push(request.pending);
1708
2233
  if (!pending || request.pending.generation > pending.generation) {
1709
2234
  pending = request.pending;
2235
+ selectedWorkerRequest = request.pending;
1710
2236
  }
1711
2237
  }
2238
+ for (const removed of removedWorkerRequests) {
2239
+ if (!pending || removed.generation === pending.generation)
2240
+ continue;
2241
+ logReviewGraph({
2242
+ cwd: key,
2243
+ phase: "persist_skipped",
2244
+ reason: "superseded",
2245
+ observability: persistObservability(removed, {
2246
+ status: "superseded",
2247
+ supersededByGeneration: pending.generation,
2248
+ reason: "forced_flush_newest_generation",
2249
+ workerStarted: true,
2250
+ workerCompleted: false,
2251
+ }),
2252
+ });
2253
+ }
1712
2254
  if (!pending) {
1713
- return { ok: false, reason: "no graph snapshot was queued for persistence" };
2255
+ return {
2256
+ ok: false,
2257
+ reason: "no graph snapshot was queued for persistence",
2258
+ };
1714
2259
  }
1715
2260
  // Invalidate every staged worker completion before doing the forced write.
1716
2261
  // Workers never promote their own stage file, so a late result can only be
@@ -1759,6 +2304,12 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
1759
2304
  serializeMs,
1760
2305
  writeMs,
1761
2306
  offloaded: false,
2307
+ observability: persistObservability(pending, {
2308
+ status: "succeeded",
2309
+ reason: source === "exit_hook" ? "exit_flush" : "forced_flush",
2310
+ workerStarted: selectedWorkerRequest !== undefined,
2311
+ workerCompleted: false,
2312
+ }),
1762
2313
  });
1763
2314
  return {
1764
2315
  ok: true,
@@ -1770,7 +2321,13 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
1770
2321
  }
1771
2322
  catch (err) {
1772
2323
  const reason = err instanceof Error ? err.message : String(err);
1773
- recordPersistFailure(cwd, source === "exit_hook" ? "exit_flush_failed" : "forced_flush_failed", reason);
2324
+ recordPersistFailure(cwd, source === "exit_hook" ? "exit_flush_failed" : "forced_flush_failed", reason, pending, selectedWorkerRequest
2325
+ ? {
2326
+ started: true,
2327
+ completed: false,
2328
+ fallbackReason: "forced_flush_write_failed",
2329
+ }
2330
+ : undefined);
1774
2331
  return { ok: false, reason };
1775
2332
  }
1776
2333
  }
@@ -1814,7 +2371,11 @@ function localImportToFile(cwd, filePath, source, ignoredIds) {
1814
2371
  }
1815
2372
  const root = path.resolve(cwd);
1816
2373
  for (const candidate of jsTsCandidatePaths(filePath, source)) {
1817
- if (!candidate.startsWith(root) || !fs.existsSync(candidate))
2374
+ const relative = path.relative(root, candidate);
2375
+ if ((relative.startsWith("..") &&
2376
+ (relative.length === 2 || relative.startsWith(`..${path.sep}`))) ||
2377
+ path.isAbsolute(relative) ||
2378
+ !fs.existsSync(candidate))
1818
2379
  continue;
1819
2380
  const normalized = normalizeMapKey(candidate);
1820
2381
  if (ignoredIds?.has(normalized))
@@ -1862,7 +2423,12 @@ async function ensureReviewGraphFacts(filePath, cwd, facts, contentOverride) {
1862
2423
  // pi-lens-ignore: missing-error-propagation
1863
2424
  }
1864
2425
  catch (err) {
1865
- console.error(`[pi-lens] review-graph structural facts disabled (degraded mode): ${err?.message ?? String(err)}`);
2426
+ logReviewGraph({
2427
+ cwd,
2428
+ phase: "build_skipped",
2429
+ reason: "structural_facts_disabled",
2430
+ error: err?.message ?? String(err),
2431
+ });
1866
2432
  }
1867
2433
  }
1868
2434
  function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
@@ -1870,6 +2436,14 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
1870
2436
  const hintPath = toProjectRelativePath(normalized, cwd);
1871
2437
  const content = facts.getFileFact(normalized, "file.content") ?? "";
1872
2438
  const fileNodeId = `file:${normalized}`;
2439
+ // The function-facts provider uses the shared tree-sitter integration for
2440
+ // both TypeScript and JavaScript-family grammars. Do not suppress JS call
2441
+ // evidence merely because import resolution may canonicalize a compiled twin
2442
+ // onto its source; the graph must describe every supported source file, and
2443
+ // lens-map performs the separate presentation-level twin merge.
2444
+ const warmCallCoverage = facts.getFileFact(normalized, "file.functionFactsCoverage") ??
2445
+ "unavailable";
2446
+ const importCoverage = facts.getFileFact(normalized, "file.importFactsCoverage") ?? "unavailable";
1873
2447
  addNode(graph, {
1874
2448
  id: fileNodeId,
1875
2449
  kind: "file",
@@ -1877,6 +2451,12 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
1877
2451
  filePath: normalized,
1878
2452
  metadata: {
1879
2453
  lineCount: content.split("\n").length,
2454
+ extractionCoverage: {
2455
+ definitions: warmCallCoverage,
2456
+ references: warmCallCoverage,
2457
+ imports: importCoverage,
2458
+ calls: warmCallCoverage,
2459
+ },
1880
2460
  ...featureHintMetadata(hintPath),
1881
2461
  },
1882
2462
  });
@@ -1953,6 +2533,7 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
1953
2533
  exported: new RegExp(String.raw `export\s+(?:async\s+)?(?:function|const|let|var)\s+${escapeRegExp(fn.name)}\b`).test(content),
1954
2534
  metadata: {
1955
2535
  line: fn.line,
2536
+ endLine: fn.endLine,
1956
2537
  column: fn.column,
1957
2538
  cyclomaticComplexity: fn.cyclomaticComplexity,
1958
2539
  maxNestingDepth: fn.maxNestingDepth,
@@ -2007,7 +2588,10 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
2007
2588
  language: "jsts",
2008
2589
  symbolName: site.method,
2009
2590
  qualifiedName: `${receiverClass}.${site.method}`,
2010
- metadata: { unresolvedName: callText, ambiguousCandidates: candidates.length },
2591
+ metadata: {
2592
+ unresolvedName: callText,
2593
+ ambiguousCandidates: candidates.length,
2594
+ },
2011
2595
  });
2012
2596
  }
2013
2597
  addEdge(graph, {
@@ -2081,26 +2665,42 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
2081
2665
  }
2082
2666
  function mapKindToTreeSitterLanguage(kind, filePath) {
2083
2667
  switch (kind) {
2084
- case "python": return "python";
2085
- case "go": return "go";
2086
- case "rust": return "rust";
2087
- case "ruby": return "ruby";
2668
+ case "python":
2669
+ return "python";
2670
+ case "go":
2671
+ return "go";
2672
+ case "rust":
2673
+ return "rust";
2674
+ case "ruby":
2675
+ return "ruby";
2088
2676
  case "cxx": {
2089
2677
  const ext = filePath ? path.extname(filePath).toLowerCase() : "";
2090
2678
  return ext === ".c" || ext === ".h" ? "c" : "cpp";
2091
2679
  }
2092
- case "java": return "java";
2093
- case "kotlin": return "kotlin";
2094
- case "dart": return "dart";
2095
- case "elixir": return "elixir";
2096
- case "csharp": return "csharp";
2097
- case "php": return "php";
2098
- case "swift": return "swift";
2099
- case "lua": return "lua";
2100
- case "ocaml": return "ocaml";
2101
- case "zig": return "zig";
2102
- case "shell": return "bash";
2103
- default: return undefined;
2680
+ case "java":
2681
+ return "java";
2682
+ case "kotlin":
2683
+ return "kotlin";
2684
+ case "dart":
2685
+ return "dart";
2686
+ case "elixir":
2687
+ return "elixir";
2688
+ case "csharp":
2689
+ return "csharp";
2690
+ case "php":
2691
+ return "php";
2692
+ case "swift":
2693
+ return "swift";
2694
+ case "lua":
2695
+ return "lua";
2696
+ case "ocaml":
2697
+ return "ocaml";
2698
+ case "zig":
2699
+ return "zig";
2700
+ case "shell":
2701
+ return "bash";
2702
+ default:
2703
+ return undefined;
2104
2704
  }
2105
2705
  }
2106
2706
  async function getExtractor(languageId) {
@@ -2123,7 +2723,16 @@ async function getExtractor(languageId) {
2123
2723
  return extractor;
2124
2724
  }
2125
2725
  async function extractTreeSitterSymbols(filePath, languageId, contentOverride) {
2126
- const empty = { symbols: [], refs: [], imports: [] };
2726
+ const empty = {
2727
+ symbols: [],
2728
+ refs: [],
2729
+ imports: [],
2730
+ coverage: {
2731
+ definitions: "unavailable",
2732
+ references: "unavailable",
2733
+ imports: "unavailable",
2734
+ },
2735
+ };
2127
2736
  if (contentOverride === null)
2128
2737
  return empty;
2129
2738
  const treeSitterClient = getSharedTreeSitterClient();
@@ -2158,6 +2767,19 @@ export async function captureReviewGraphStructuralIr(filePath, cwd, content, fac
2158
2767
  const parsed = await withTreeSitterRoot(filePath, content, () => true);
2159
2768
  if (!parsed.parsed)
2160
2769
  return { complete: false };
2770
+ const functionCoverage = facts.getFileFact(filePath, "file.functionFactsCoverage") ??
2771
+ "unavailable";
2772
+ const importCoverage = facts.getFileFact(filePath, "file.importFactsCoverage") ??
2773
+ "unavailable";
2774
+ const coverage = {
2775
+ definitions: functionCoverage,
2776
+ references: functionCoverage,
2777
+ imports: importCoverage,
2778
+ calls: functionCoverage,
2779
+ };
2780
+ if (Object.values(coverage).some((status) => status !== "complete")) {
2781
+ return { complete: false };
2782
+ }
2161
2783
  return {
2162
2784
  complete: true,
2163
2785
  structural: {
@@ -2165,12 +2787,15 @@ export async function captureReviewGraphStructuralIr(filePath, cwd, content, fac
2165
2787
  imports: facts.getFileFact(filePath, "file.imports") ?? [],
2166
2788
  reexports: facts.getFileFact(filePath, "file.reexports") ?? [],
2167
2789
  functionSummaries: facts.getFileFact(filePath, "file.functionSummaries") ?? [],
2790
+ coverage,
2168
2791
  },
2169
2792
  };
2170
2793
  }
2171
2794
  const languageId = mapKindToTreeSitterLanguage(kind, filePath);
2795
+ // A graph-relevant kind without a tree-sitter mapping is unsupported, not
2796
+ // an empty successful extraction. Do not publish a complete IR for it.
2172
2797
  if (!languageId)
2173
- return { complete: true };
2798
+ return { complete: false };
2174
2799
  const client = getSharedTreeSitterClient();
2175
2800
  if (!client || !(await client.init()))
2176
2801
  return { complete: false };
@@ -2232,7 +2857,10 @@ function addTreeSitterFile(graph, cwd, filePath, languageId, extracted, ignoredI
2232
2857
  kind: "file",
2233
2858
  language: languageId,
2234
2859
  filePath: normalized,
2235
- metadata: featureHintMetadata(hintPath),
2860
+ metadata: {
2861
+ ...featureHintMetadata(hintPath),
2862
+ extractionCoverage: extracted.coverage,
2863
+ },
2236
2864
  });
2237
2865
  const dedupedSymbols = dedupeSamePositionSymbols(extracted.symbols);
2238
2866
  // refs #655 phase 2: qualified (owner-chain) display name, computed via the
@@ -2261,6 +2889,7 @@ function addTreeSitterFile(graph, cwd, filePath, languageId, extracted, ignoredI
2261
2889
  exported: symbol.isExported,
2262
2890
  metadata: {
2263
2891
  line: symbol.line,
2892
+ endLine: symbol.endLine,
2264
2893
  column: symbol.column,
2265
2894
  signature: symbol.signature,
2266
2895
  ...featureHintMetadata(`${symbol.name} ${hintPath}`),
@@ -2270,21 +2899,26 @@ function addTreeSitterFile(graph, cwd, filePath, languageId, extracted, ignoredI
2270
2899
  addEdge(graph, { from: fileNodeId, to: symbolId, kind: "defines" });
2271
2900
  }
2272
2901
  for (const ref of extracted.refs) {
2273
- const targetId = `symbol-name:${ref.symbolId.split(":").pop() ?? ref.symbolId}`;
2902
+ const refName = ref.symbolName ?? ref.symbolId;
2903
+ const targetId = `symbol-name:${refName}`;
2274
2904
  if (!graph.nodes.has(targetId)) {
2275
2905
  addNode(graph, {
2276
2906
  id: targetId,
2277
2907
  kind: "symbol",
2278
2908
  language: languageId,
2279
- symbolName: ref.symbolId.split(":").pop() ?? ref.symbolId,
2280
- metadata: { unresolvedName: ref.symbolId },
2909
+ symbolName: refName,
2910
+ metadata: { unresolvedName: refName },
2281
2911
  });
2282
2912
  }
2283
2913
  addEdge(graph, {
2284
2914
  from: fileNodeId,
2285
2915
  to: targetId,
2286
2916
  kind: "references",
2287
- metadata: { line: ref.line, column: ref.column },
2917
+ metadata: {
2918
+ line: ref.line,
2919
+ column: ref.column,
2920
+ referenceKind: ref.referenceKind ?? "unknown",
2921
+ },
2288
2922
  // Always starts bare-name-only (the extractor has no scope/type info);
2289
2923
  // resolveDeferredSymbolEdges may upgrade this to "exact" below.
2290
2924
  resolution: "name-only",
@@ -2367,12 +3001,8 @@ export function addLspFallbackSymbols(graph, filePath, languageId, symbols) {
2367
3001
  // Shared with the read-path enrichment (#951 Sonar dedup): flat
2368
3002
  // results qualify through the full containerName chain, not just
2369
3003
  // the immediate owner.
2370
- const owners = ancestry.length > 0
2371
- ? ancestry
2372
- : containerNameChain(symbol, symbols);
2373
- const qualifiedName = owners.length > 0
2374
- ? [...owners, symbol.name].join(".")
2375
- : undefined;
3004
+ const owners = ancestry.length > 0 ? ancestry : containerNameChain(symbol, symbols);
3005
+ const qualifiedName = owners.length > 0 ? [...owners, symbol.name].join(".") : undefined;
2376
3006
  addNode(graph, {
2377
3007
  id: symbolId,
2378
3008
  kind: "symbol",
@@ -2561,6 +3191,8 @@ async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverri
2561
3191
  facts.setFileFact(file, "file.imports", sharedIr.imports);
2562
3192
  facts.setFileFact(file, "file.reexports", sharedIr.reexports);
2563
3193
  facts.setFileFact(file, "file.functionSummaries", sharedIr.functionSummaries);
3194
+ facts.setFileFact(file, "file.functionFactsCoverage", sharedIr.coverage.calls);
3195
+ facts.setFileFact(file, "file.importFactsCoverage", sharedIr.coverage.imports);
2564
3196
  }
2565
3197
  else {
2566
3198
  await ensureReviewGraphFacts(file, cwd, facts, contentOverride);
@@ -2578,8 +3210,7 @@ async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverri
2578
3210
  const languageId = mapKindToTreeSitterLanguage(kind, file);
2579
3211
  if (!languageId)
2580
3212
  return;
2581
- const irExtracted = sharedIr?.kind === "tree-sitter" &&
2582
- sharedIr.languageId === languageId
3213
+ const irExtracted = sharedIr?.kind === "tree-sitter" && sharedIr.languageId === languageId
2583
3214
  ? sharedIr.extracted
2584
3215
  : undefined;
2585
3216
  const extracted = irExtracted ??
@@ -2772,16 +3403,20 @@ async function tryIncrementalFromCache(cached, ctx) {
2772
3403
  for (const file of ctx.normalizedChanged) {
2773
3404
  upsertChangedSymbols(graph, ctx.facts, file);
2774
3405
  }
2775
- _workspaceGraphCache.set(ctx.normalizedCwd, {
3406
+ setWorkspaceGraph(ctx.normalizedCwd, {
2776
3407
  signature: ctx.signature,
2777
3408
  fileSignatures: new Map(ctx.fileSignatures),
2778
3409
  fileHashes: hashes,
2779
3410
  graph: cloneGraph(cached.graph),
2780
3411
  buildGeneration: generation,
2781
3412
  ...verifiedCacheFields(ctx.seqAtBuildStart),
2782
- });
3413
+ }, ctx.cacheEpoch);
2783
3414
  // #260: pure drift leaves the graph unchanged — don't rewrite the disk blob.
2784
- _lastGraphBuildInfo = { reused: true, mode: "cached", graphChanged: false };
3415
+ setGraphBuildInfo(graph, {
3416
+ reused: true,
3417
+ mode: "cached",
3418
+ graphChanged: false,
3419
+ });
2785
3420
  graph.buildGeneration = generation;
2786
3421
  ctx.facts.setSessionFact("session.reviewGraph", graph);
2787
3422
  return graph;
@@ -2795,19 +3430,30 @@ async function tryIncrementalFromCache(cached, ctx) {
2795
3430
  const priorGeneration = cached.graph.buildGeneration;
2796
3431
  const graph = cloneGraph(cached.graph);
2797
3432
  const importChanges = await updateGraphFiles(graph, ctx.cwd, filesToUpdate, ctx.facts, ctx.ignoredIds);
3433
+ refreshGraphBuiltAt(graph);
2798
3434
  // #459: real re-extract ⇒ new generation.
2799
3435
  const generation = ++_graphGenerationCounter;
2800
3436
  graph.buildGeneration = generation;
2801
- _workspaceGraphCache.set(ctx.normalizedCwd, {
3437
+ setWorkspaceGraph(ctx.normalizedCwd, {
2802
3438
  signature: ctx.signature,
2803
3439
  fileSignatures: new Map(ctx.fileSignatures),
2804
3440
  fileHashes: hashes,
2805
3441
  graph,
2806
3442
  buildGeneration: generation,
2807
3443
  ...verifiedCacheFields(ctx.seqAtBuildStart),
3444
+ }, ctx.cacheEpoch);
3445
+ const persistReason = persistGraph(ctx.cwd, ctx.signature, ctx.fileSignatures, hashes, graph, {
3446
+ buildId: ctx.buildId,
3447
+ projectSeq: ctx.seqAtBuildStart,
3448
+ seqHint: ctx.seqHint,
3449
+ mode: ctx.mode,
3450
+ });
3451
+ setGraphBuildInfo(graph, {
3452
+ reused: true,
3453
+ mode: "incremental",
3454
+ ...(persistReason ? { persistReason } : {}),
3455
+ graphChanged: true,
2808
3456
  });
2809
- persistGraph(ctx.cwd, ctx.signature, ctx.fileSignatures, hashes, graph);
2810
- _lastGraphBuildInfo = { reused: true, mode: "incremental", graphChanged: true };
2811
3457
  _graphImportChanges.set(graph, {
2812
3458
  fromGeneration: priorGeneration,
2813
3459
  changes: importChanges,
@@ -2827,9 +3473,14 @@ function hasGraphKindExtension(file) {
2827
3473
  * `_lastGraphBuildInfo` (the caller's full sweep sets the mode and stamps the
2828
3474
  * fallback reason). Correctness bar is HIGH: any doubt ⇒ fall back.
2829
3475
  */
2830
- async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, ignoredIds) {
3476
+ async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, ignoredIds, cacheEpoch) {
2831
3477
  const cached = _workspaceGraphCache.get(normalizedCwd);
2832
- // Condition 2: need an in-process entry that recorded a build seq.
3478
+ // Condition 2: need an in-process complete entry that recorded a build seq.
3479
+ // A capped or entry-budget-truncated graph is read-only orientation data, not
3480
+ // a safe incremental base; force the next build through a complete walk.
3481
+ if (cached?.graph.persistCoverage?.partial) {
3482
+ return { fallback: "partial-base" };
3483
+ }
2833
3484
  if (!cached || cached.builtAtProjectSeq === undefined) {
2834
3485
  return { fallback: "no-seq" };
2835
3486
  }
@@ -2840,7 +3491,8 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
2840
3491
  ? Number.POSITIVE_INFINITY
2841
3492
  : now - cached.lastFullVerifyMs;
2842
3493
  const sinceVerify = cached.fastPathSinceVerify ?? 0;
2843
- if (ageMs > SEQ_FASTPATH_REVERIFY_MS || sinceVerify >= SEQ_FASTPATH_REVERIFY_EVERY) {
3494
+ if (ageMs > SEQ_FASTPATH_REVERIFY_MS ||
3495
+ sinceVerify >= SEQ_FASTPATH_REVERIFY_EVERY) {
2844
3496
  return { fallback: "verify-due" };
2845
3497
  }
2846
3498
  // Condition 3: bounded change set. changed ∪ changedFiles(param), normalized.
@@ -2858,7 +3510,7 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
2858
3510
  // (a genuine new file — updateGraphFiles' remove-then-add handles the add). A
2859
3511
  // changed file that no longer exists on disk is a DELETION: incremental has no
2860
3512
  // node-removal here, so fall back to the sweep (simple + correct).
2861
- const filesToUpdate = [];
3513
+ const candidateFiles = [];
2862
3514
  for (const file of changed) {
2863
3515
  const known = cached.fileSignatures.has(file);
2864
3516
  let existsOnDisk = false;
@@ -2883,9 +3535,13 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
2883
3535
  if (!hasGraphKindExtension(file))
2884
3536
  continue;
2885
3537
  }
2886
- filesToUpdate.push(file);
3538
+ candidateFiles.push(file);
2887
3539
  }
2888
- if (filesToUpdate.length === 0) {
3540
+ // A pi-observed write can advance projectSeq without changing bytes (format
3541
+ // no-op, save, or an idempotent edit). Confirm content before re-extracting so
3542
+ // the seq fast path does not refresh builtAt or claim graphChanged for drift.
3543
+ const { trulyChanged, hashes } = await confirmContentChanged(candidateFiles, cached.fileHashes);
3544
+ if (trulyChanged.length === 0) {
2889
3545
  // Nothing graph-relevant actually changed. Reuse the cached graph as-is,
2890
3546
  // refresh changed-symbol annotations, bump the fast-path counter.
2891
3547
  const graph = cloneGraph(cached.graph);
@@ -2896,18 +3552,30 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
2896
3552
  }
2897
3553
  // Stamp the seq captured at BUILD START — a bump that raced in during this
2898
3554
  // build has seq > stamp and is re-diffed next build, never missed.
3555
+ const nextSignatures = new Map(cached.fileSignatures);
3556
+ for (const file of candidateFiles) {
3557
+ nextSignatures.set(file, sourceSignatureEntry(file));
3558
+ }
3559
+ cached.signature = sourceSignatureFromMap(nextSignatures);
3560
+ cached.fileSignatures = nextSignatures;
3561
+ cached.fileHashes = hashes;
2899
3562
  cached.builtAtProjectSeq = seqAtBuildStart;
2900
3563
  cached.fastPathSinceVerify = sinceVerify + 1;
2901
3564
  // #459: nothing graph-relevant changed — this is a genuine no-op reuse.
2902
3565
  const generation = (cached.buildGeneration ??= ++_graphGenerationCounter);
2903
- _lastGraphBuildInfo = { reused: true, mode: "seq-fastpath", graphChanged: false };
3566
+ setGraphBuildInfo(graph, {
3567
+ reused: true,
3568
+ mode: "seq-fastpath",
3569
+ graphChanged: false,
3570
+ });
2904
3571
  graph.buildGeneration = generation;
2905
3572
  facts.setSessionFact("session.reviewGraph", graph);
2906
3573
  return { graph };
2907
3574
  }
2908
- // Incremental re-extract over exactly the changed files. Reuses the SAME
2909
- // machinery as the signature-diff incremental path (updateGraphFiles), so
3575
+ // Incremental re-extract over exactly the content-changed files. Reuses the
3576
+ // SAME machinery as the signature-diff incremental path (updateGraphFiles), so
2910
3577
  // there's no second incremental implementation.
3578
+ const filesToUpdate = trulyChanged;
2911
3579
  const priorGeneration = cached.graph.buildGeneration;
2912
3580
  const graph = cloneGraph(cached.graph);
2913
3581
  let importChanges;
@@ -2917,23 +3585,22 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
2917
3585
  catch {
2918
3586
  return { fallback: "stat-error" };
2919
3587
  }
3588
+ refreshGraphBuiltAt(graph);
2920
3589
  // Update fileSignatures/fileHashes for ONLY the touched files (stat/hash just
2921
3590
  // those — the whole point of the fast path). Recompute the aggregate signature
2922
3591
  // the same way the incremental branch does (sourceSignatureFromMap).
2923
3592
  const nextSignatures = new Map(cached.fileSignatures);
2924
- const nextHashes = new Map(cached.fileHashes ?? new Map());
2925
- for (const file of filesToUpdate) {
3593
+ for (const file of candidateFiles) {
2926
3594
  nextSignatures.set(file, sourceSignatureEntry(file));
2927
- nextHashes.set(file, contentHashEntry(file));
2928
3595
  }
2929
3596
  const nextSignature = sourceSignatureFromMap(nextSignatures);
2930
3597
  // #459: real re-extract ⇒ new generation.
2931
3598
  const generation = ++_graphGenerationCounter;
2932
3599
  graph.buildGeneration = generation;
2933
- _workspaceGraphCache.set(normalizedCwd, {
3600
+ setWorkspaceGraph(normalizedCwd, {
2934
3601
  signature: nextSignature,
2935
3602
  fileSignatures: nextSignatures,
2936
- fileHashes: nextHashes,
3603
+ fileHashes: hashes,
2937
3604
  graph,
2938
3605
  buildGeneration: generation,
2939
3606
  // Build-start seq, not stamp-time: see verifiedCacheFields — a bump that
@@ -2941,11 +3608,21 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
2941
3608
  builtAtProjectSeq: seqAtBuildStart,
2942
3609
  lastFullVerifyMs: cached.lastFullVerifyMs,
2943
3610
  fastPathSinceVerify: sinceVerify + 1,
3611
+ }, cacheEpoch);
3612
+ const persistReason = persistGraph(cwd, nextSignature, nextSignatures, hashes, graph, {
3613
+ buildId,
3614
+ projectSeq: seqAtBuildStart,
3615
+ seqHint: true,
3616
+ mode: "seq-fastpath",
2944
3617
  });
2945
- persistGraph(cwd, nextSignature, nextSignatures, nextHashes, graph);
2946
3618
  // #459: filesToUpdate was non-empty — this fastpath re-extracted real files,
2947
3619
  // so (unlike the no-op branch above) the graph object did change.
2948
- _lastGraphBuildInfo = { reused: true, mode: "seq-fastpath", graphChanged: true };
3620
+ setGraphBuildInfo(graph, {
3621
+ reused: true,
3622
+ mode: "seq-fastpath",
3623
+ ...(persistReason ? { persistReason } : {}),
3624
+ graphChanged: true,
3625
+ });
2949
3626
  _graphImportChanges.set(graph, {
2950
3627
  fromGeneration: priorGeneration,
2951
3628
  changes: importChanges,
@@ -2953,8 +3630,14 @@ async function trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqH
2953
3630
  facts.setSessionFact("session.reviewGraph", graph);
2954
3631
  return { graph };
2955
3632
  }
2956
- async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3633
+ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
2957
3634
  const normalizedCwd = normalizeMapKey(cwd);
3635
+ const cacheEpoch = workspaceCacheEpoch(normalizedCwd);
3636
+ // `await undefined` still yields a microtask, which reorders overlapping
3637
+ // builds in production where no test gate is installed — only await a gate
3638
+ // that exists.
3639
+ if (_reviewGraphBuildGateForTests)
3640
+ await _reviewGraphBuildGateForTests();
2958
3641
  const normalizedChanged = changedFiles.map((file) => normalizeMapKey(file));
2959
3642
  const normalizedChangedSet = new Set(normalizedChanged);
2960
3643
  logCwdWorktreeMismatchOnce(cwd);
@@ -2983,14 +3666,14 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
2983
3666
  for (const file of normalizedChanged) {
2984
3667
  upsertChangedSymbols(graph, facts, file);
2985
3668
  }
2986
- _lastGraphBuildInfo = {
3669
+ setGraphBuildInfo(graph, {
2987
3670
  reused: false,
2988
3671
  mode: "skipped",
2989
3672
  skipReason: "unsafe_root",
2990
3673
  // #459: never persisted/reused, same as the too_many_files skip below —
2991
3674
  // treat as changed so dependents never trust stale derived state.
2992
3675
  graphChanged: true,
2993
- };
3676
+ });
2994
3677
  facts.setSessionFact("session.reviewGraph", graph);
2995
3678
  return graph;
2996
3679
  }
@@ -3010,14 +3693,17 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3010
3693
  // cascade.log can watch the fast-path hit/miss rate.
3011
3694
  let seqFastpathFallback;
3012
3695
  if (seqHint && seqAtBuildStart !== undefined && seqFastpathEnabled()) {
3013
- const fast = await trySeqFastpath(cwd, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, await ignoredIdsPromise);
3696
+ const fast = await trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, await ignoredIdsPromise, cacheEpoch);
3014
3697
  if ("graph" in fast)
3015
3698
  return fast.graph;
3016
3699
  seqFastpathFallback = fast.fallback;
3017
3700
  }
3018
- const filesToBuild = await getGraphSourceFiles(cwd);
3701
+ const sourceCollection = await getGraphSourceFiles(cwd);
3702
+ const filesToBuild = sourceCollection.files;
3703
+ const sourceFileCount = sourceCollection.sourceFileCount;
3704
+ const sourceFilesTruncated = sourceCollection.entryBudgetExceeded;
3019
3705
  const ignoredIds = await ignoredIdsPromise;
3020
- const maxGraphFiles = getReviewGraphMaxFiles(cwd);
3706
+ const maxGraphFiles = sourceCollection.maxFileCount;
3021
3707
  if (filesToBuild.length > maxGraphFiles) {
3022
3708
  const graph = createEmptyGraph();
3023
3709
  graph.version = REVIEW_GRAPH_VERSION;
@@ -3029,7 +3715,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3029
3715
  // any graph cached/persisted from before the repo crossed the cap, and so
3030
3716
  // project_report can render an honest "disabled at N files" hint instead
3031
3717
  // of "retry shortly" — see getReviewGraphSizeSkipVerdict.
3032
- recordReviewGraphSizeSkip(cwd, filesToBuild.length, maxGraphFiles);
3718
+ recordReviewGraphSizeSkip(cwd, sourceFileCount, maxGraphFiles);
3033
3719
  // #775 R3: `_lastGraphBuildInfo`/the size-skip verdict above are only
3034
3720
  // SURFACED by callers that happen to read them (dispatch/integration.ts's
3035
3721
  // cascade path logs a `graph_build` phase; lens-map.ts, project-report.ts,
@@ -3044,15 +3730,33 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3044
3730
  durationMs: 0,
3045
3731
  metadata: {
3046
3732
  cwd,
3047
- sourceFileCount: filesToBuild.length,
3733
+ sourceFileCount,
3048
3734
  maxFileCount: maxGraphFiles,
3735
+ sourceFileCountLabel: `more than ${maxGraphFiles} files`,
3736
+ sourceFileCountTruncated: true,
3049
3737
  },
3050
3738
  });
3051
- _lastGraphBuildInfo = {
3739
+ if (isReviewGraphSizeNearMiss(sourceFileCount, maxGraphFiles)) {
3740
+ logLatency({
3741
+ type: "phase",
3742
+ phase: "review_graph_size_near_miss",
3743
+ filePath: cwd,
3744
+ durationMs: 0,
3745
+ metadata: {
3746
+ cwd,
3747
+ maxFileCount: maxGraphFiles,
3748
+ sourceFileCount,
3749
+ sourceFileCountLabel: `more than ${maxGraphFiles} files`,
3750
+ sourceFileCountTruncated: true,
3751
+ },
3752
+ });
3753
+ }
3754
+ setGraphBuildInfo(graph, {
3052
3755
  reused: false,
3053
3756
  mode: "skipped",
3054
3757
  skipReason: "too_many_files",
3055
- sourceFileCount: filesToBuild.length,
3758
+ sourceFileCount,
3759
+ sourceFileCountTruncated: sourceFilesTruncated || filesToBuild.length > maxGraphFiles,
3056
3760
  maxFileCount: maxGraphFiles,
3057
3761
  seqFastpathFallback,
3058
3762
  // #459: a fresh empty graph is returned every call on this path (never
@@ -3060,7 +3764,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3060
3764
  // derived state across skip/unskip transitions. Deliberately NOT stamped
3061
3765
  // with a buildGeneration: absent ⇒ derived caches rebuild every time.
3062
3766
  graphChanged: true,
3063
- };
3767
+ });
3064
3768
  facts.setSessionFact("session.reviewGraph", graph);
3065
3769
  return graph;
3066
3770
  }
@@ -3068,11 +3772,14 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3068
3772
  // previously recorded size-skip verdict immediately (rather than waiting
3069
3773
  // out the TTL) so a shrink or a raised cap re-enables reads the moment a
3070
3774
  // build actually succeeds.
3071
- clearReviewGraphSizeSkip(cwd);
3775
+ if (!sourceFilesTruncated)
3776
+ clearReviewGraphSizeSkip(cwd);
3072
3777
  const fileSignatures = await sourceSignatureMapAsync(filesToBuild);
3073
3778
  const signature = sourceSignatureFromMap(fileSignatures);
3074
3779
  // Tier 1: in-memory cache (hot path — same process, already built this session)
3075
- let memCached = _workspaceGraphCache.get(normalizedCwd);
3780
+ let memCached = sourceFilesTruncated
3781
+ ? undefined
3782
+ : _workspaceGraphCache.get(normalizedCwd);
3076
3783
  // A partial graph (hydrated from a capped snapshot for read-only orientation
3077
3784
  // via getCachedReviewGraph) can share this cache. It MUST NOT seed a build:
3078
3785
  // serving it silently drops the capped-away nodes/edges, and extending then
@@ -3082,6 +3789,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3082
3789
  if (memCached?.graph.persistCoverage?.partial)
3083
3790
  memCached = undefined;
3084
3791
  if (memCached?.signature === signature) {
3792
+ touchWorkspaceGraph(normalizedCwd);
3085
3793
  const graph = cloneGraph(memCached.graph);
3086
3794
  rebuildIndexes(graph);
3087
3795
  graph.changedSymbolsByFile.clear();
@@ -3093,13 +3801,14 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3093
3801
  // later fast path diffs from here and the periodic re-verify resets.
3094
3802
  Object.assign(memCached, verifiedCacheFields(seqAtBuildStart));
3095
3803
  // #459: content unchanged ⇒ carry the entry's generation forward.
3096
- const generation = (memCached.buildGeneration ??= ++_graphGenerationCounter);
3097
- _lastGraphBuildInfo = {
3804
+ const generation = (memCached.buildGeneration ??=
3805
+ ++_graphGenerationCounter);
3806
+ setGraphBuildInfo(graph, {
3098
3807
  reused: true,
3099
3808
  mode: "cached",
3100
3809
  seqFastpathFallback,
3101
3810
  graphChanged: false,
3102
- };
3811
+ });
3103
3812
  graph.buildGeneration = generation;
3104
3813
  facts.setSessionFact("session.reviewGraph", graph);
3105
3814
  return graph;
@@ -3107,6 +3816,9 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3107
3816
  if (memCached) {
3108
3817
  const incremental = await tryIncrementalFromCache(memCached, {
3109
3818
  cwd,
3819
+ buildId,
3820
+ seqHint: seqHint !== undefined,
3821
+ mode: "incremental",
3110
3822
  normalizedCwd,
3111
3823
  normalizedChanged,
3112
3824
  fileSignatures,
@@ -3114,9 +3826,10 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3114
3826
  facts,
3115
3827
  seqAtBuildStart,
3116
3828
  ignoredIds,
3829
+ cacheEpoch,
3117
3830
  });
3118
3831
  if (incremental) {
3119
- _lastGraphBuildInfo.seqFastpathFallback = seqFastpathFallback;
3832
+ updateGraphBuildInfo(incremental, { seqFastpathFallback });
3120
3833
  return incremental;
3121
3834
  }
3122
3835
  }
@@ -3125,7 +3838,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3125
3838
  // #202 content-hash confirm below already content-verify the load, and
3126
3839
  // dropping on every HEAD move would force a full whole-repo rebuild after
3127
3840
  // each plain `git commit` (HEAD moves, files unchanged).
3128
- const diskCached = loadPersistedGraph(cwd);
3841
+ const diskCached = sourceFilesTruncated ? null : loadPersistedGraph(cwd);
3129
3842
  if (diskCached?.signature === signature) {
3130
3843
  const graph = cloneGraph(diskCached.graph);
3131
3844
  rebuildIndexes(graph);
@@ -3137,20 +3850,20 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3137
3850
  // process's derived caches don't exist here; in-process derived caches from
3138
3851
  // before a workspace-cache clear must not match it).
3139
3852
  const generation = ++_graphGenerationCounter;
3140
- _workspaceGraphCache.set(normalizedCwd, {
3853
+ setWorkspaceGraph(normalizedCwd, {
3141
3854
  signature,
3142
3855
  fileSignatures: new Map(fileSignatures),
3143
3856
  fileHashes: diskCached.fileHashes,
3144
3857
  graph: cloneGraph(diskCached.graph),
3145
3858
  buildGeneration: generation,
3146
3859
  ...verifiedCacheFields(seqAtBuildStart),
3147
- });
3148
- _lastGraphBuildInfo = {
3860
+ }, cacheEpoch);
3861
+ setGraphBuildInfo(graph, {
3149
3862
  reused: true,
3150
3863
  mode: "cached",
3151
3864
  seqFastpathFallback,
3152
3865
  graphChanged: false,
3153
- };
3866
+ });
3154
3867
  graph.buildGeneration = generation;
3155
3868
  facts.setSessionFact("session.reviewGraph", graph);
3156
3869
  return graph;
@@ -3167,6 +3880,9 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3167
3880
  graph: diskCached.graph,
3168
3881
  }, {
3169
3882
  cwd,
3883
+ buildId,
3884
+ seqHint: seqHint !== undefined,
3885
+ mode: "incremental",
3170
3886
  normalizedCwd,
3171
3887
  normalizedChanged,
3172
3888
  fileSignatures,
@@ -3176,13 +3892,15 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3176
3892
  ignoredIds,
3177
3893
  });
3178
3894
  if (incremental) {
3179
- _lastGraphBuildInfo.seqFastpathFallback = seqFastpathFallback;
3895
+ updateGraphBuildInfo(incremental, { seqFastpathFallback });
3180
3896
  return incremental;
3181
3897
  }
3182
3898
  }
3183
3899
  // Tier 3: full build — resumed from a prior session's checkpoint when one is
3184
3900
  // present and still current (#936 limit 2), else cold from an empty graph.
3185
- const resumed = await tryResumeFromCheckpoint(cwd, filesToBuild, ignoredIds);
3901
+ const resumed = sourceFilesTruncated
3902
+ ? null
3903
+ : await tryResumeFromCheckpoint(cwd, filesToBuild, ignoredIds);
3186
3904
  const graph = resumed?.graph ?? createEmptyGraph();
3187
3905
  const filesToExtract = resumed?.remaining ?? filesToBuild;
3188
3906
  const treeSitterClient = getSharedTreeSitterClient();
@@ -3285,28 +4003,44 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint) {
3285
4003
  // in-progress/partial marker a resumed seed carried so the finished graph is
3286
4004
  // never mistaken for (or persisted as) a partial one, and retire the
3287
4005
  // checkpoint now that an authoritative snapshot supersedes it.
3288
- graph.persistCoverage = undefined;
4006
+ graph.persistCoverage = sourceFilesTruncated
4007
+ ? graphCoverage(graph, graphPersistMaxElements(), sourceFileCount, true, filesToBuild)
4008
+ : undefined;
3289
4009
  deleteReviewGraphCheckpoint(cwd);
3290
4010
  // #202: the full-build pass hashes the same bytes it supplies to extraction,
3291
4011
  // so change detection does not reread every file after the graph is built.
3292
4012
  // #459: full rebuild ⇒ new generation.
3293
4013
  const generation = ++_graphGenerationCounter;
3294
4014
  const graphSnapshot = cloneGraph(graph);
3295
- _workspaceGraphCache.set(normalizedCwd, {
4015
+ rebuildIndexes(graphSnapshot);
4016
+ // Keep the content generation on the persisted snapshot instance too, so
4017
+ // scheduled persistence logs join the same graph identity as build success.
4018
+ graphSnapshot.buildGeneration = generation;
4019
+ setWorkspaceGraph(normalizedCwd, {
3296
4020
  signature,
3297
4021
  fileSignatures: new Map(fileSignatures),
3298
4022
  fileHashes,
3299
4023
  graph: graphSnapshot,
3300
4024
  buildGeneration: generation,
3301
4025
  ...verifiedCacheFields(seqAtBuildStart),
3302
- });
3303
- persistGraph(cwd, signature, fileSignatures, fileHashes, graphSnapshot); // fire-and-forget
3304
- _lastGraphBuildInfo = {
4026
+ }, cacheEpoch);
4027
+ const persistReason = persistGraph(cwd, signature, fileSignatures, fileHashes, graphSnapshot, {
4028
+ buildId,
4029
+ projectSeq: seqAtBuildStart,
4030
+ seqHint: seqHint !== undefined,
4031
+ mode: "full",
4032
+ sourceFileCount,
4033
+ sourceFilesTruncated,
4034
+ }); // fire-and-forget
4035
+ setGraphBuildInfo(graph, {
3305
4036
  reused: false,
3306
4037
  mode: "full",
4038
+ sourceFileCount,
4039
+ sourceFileCountTruncated: sourceFilesTruncated,
3307
4040
  seqFastpathFallback,
4041
+ ...(persistReason ? { persistReason } : {}),
3308
4042
  graphChanged: true,
3309
- };
4043
+ });
3310
4044
  graph.buildGeneration = generation;
3311
4045
  facts.setSessionFact("session.reviewGraph", graph);
3312
4046
  return graph;
@@ -3317,34 +4051,67 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
3317
4051
  if (cached)
3318
4052
  return cached;
3319
4053
  const startedAt = Date.now();
3320
- recordBuildAttempt(cwd, "running");
3321
- logReviewGraph({ cwd, phase: "build_started" });
3322
- const promise = _doBuildGraph(cwd, changedFiles, facts, seqHint)
4054
+ const buildId = ++_buildIdCounter;
4055
+ recordBuildAttempt(cwd, "running", undefined, buildId);
4056
+ const startedProjectSeq = seqHint?.projectSeq();
4057
+ logReviewGraph({
4058
+ cwd,
4059
+ phase: "build_started",
4060
+ observability: {
4061
+ graph: {
4062
+ buildId,
4063
+ ...(startedProjectSeq === undefined
4064
+ ? {}
4065
+ : { projectSeq: startedProjectSeq }),
4066
+ ...(seqHint === undefined ? {} : { seqHint: true }),
4067
+ nodes: 0,
4068
+ edges: 0,
4069
+ },
4070
+ },
4071
+ });
4072
+ const promise = _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId)
3323
4073
  .then((graph) => {
3324
- const sizeSkip = getReviewGraphSizeSkipVerdict(cwd);
3325
- const unsafeRoot = isAtOrAboveHomeDir(path.resolve(cwd));
3326
- if (sizeSkip || unsafeRoot) {
3327
- const reason = sizeSkip
3328
- ? `source file cap exceeded (${sizeSkip.sourceFileCount} > ${sizeSkip.maxFileCount})`
3329
- : "unsafe_root";
3330
- recordBuildAttempt(cwd, "skipped", reason);
4074
+ const buildInfo = getGraphBuildInfoForGraph(graph);
4075
+ if (buildInfo.mode === "skipped") {
4076
+ const reason = buildInfo.skipReason ?? "skipped";
4077
+ recordBuildAttempt(cwd, "skipped", reason, buildId);
3331
4078
  logReviewGraph({
3332
4079
  cwd,
3333
4080
  phase: "build_skipped",
3334
4081
  reason,
3335
4082
  durationMs: Date.now() - startedAt,
4083
+ observability: {
4084
+ graph: graphLogMetadata(graph, {
4085
+ buildId,
4086
+ projectSeq: startedProjectSeq,
4087
+ seqHint: seqHint !== undefined,
4088
+ mode: buildInfo.mode,
4089
+ sourceFileCount: buildInfo.sourceFileCount,
4090
+ sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
4091
+ }),
4092
+ },
3336
4093
  });
3337
4094
  }
3338
4095
  else {
3339
- const prior = getLastReviewGraphBuildAttempt(cwd);
3340
- recordBuildAttempt(cwd, "succeeded", prior?.reason);
4096
+ const reason = buildInfo.persistReason;
4097
+ recordBuildAttempt(cwd, "succeeded", reason, buildId);
3341
4098
  logReviewGraph({
3342
4099
  cwd,
3343
4100
  phase: "build_succeeded",
3344
4101
  durationMs: Date.now() - startedAt,
3345
4102
  nodes: graph.nodes.size,
3346
4103
  edges: graph.edges.length,
3347
- ...(prior?.reason ? { reason: prior.reason } : {}),
4104
+ ...(reason ? { reason } : {}),
4105
+ observability: {
4106
+ graph: graphLogMetadata(graph, {
4107
+ buildId,
4108
+ projectSeq: startedProjectSeq,
4109
+ seqHint: seqHint !== undefined,
4110
+ mode: buildInfo.mode,
4111
+ sourceFileCount: buildInfo.sourceFileCount,
4112
+ sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
4113
+ }),
4114
+ },
3348
4115
  });
3349
4116
  }
3350
4117
  return graph;
@@ -3352,13 +4119,21 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
3352
4119
  .catch((err) => {
3353
4120
  _buildCache.delete(cacheKey);
3354
4121
  const reason = err instanceof Error ? err.message : String(err);
3355
- recordBuildAttempt(cwd, "failed", reason);
4122
+ recordBuildAttempt(cwd, "failed", reason, buildId);
3356
4123
  logReviewGraph({
3357
4124
  cwd,
3358
4125
  phase: "build_failed",
3359
4126
  reason,
3360
4127
  durationMs: Date.now() - startedAt,
3361
4128
  error: reason,
4129
+ observability: {
4130
+ graph: {
4131
+ buildId,
4132
+ ...(seqHint === undefined ? {} : { seqHint: true }),
4133
+ nodes: 0,
4134
+ edges: 0,
4135
+ },
4136
+ },
3362
4137
  });
3363
4138
  throw err;
3364
4139
  });
@@ -3366,46 +4141,172 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
3366
4141
  return promise;
3367
4142
  }
3368
4143
  /**
3369
- * Extract symbols and refs from an already-built ReviewGraph for call graph construction.
3370
- * Reuses parsed data without re-running tree-sitter — symbols come from "symbol" nodes,
3371
- * refs come from "references" edges. Line numbers are unavailable here (not stored in graph
3372
- * nodes), so caller attribution falls back to file-level keys in buildCallGraph.
4144
+ * Normalize graph symbol/call/reference evidence for call-graph construction.
4145
+ *
4146
+ * Graph node ids are opaque canonical identities (kind and line are part of the
4147
+ * id), so this adapter always reads target metadata from the target node. It
4148
+ * retains unresolved/type-only evidence for bounded coverage accounting, but
4149
+ * buildCallGraph will not turn those records into concrete edges.
3373
4150
  */
3374
4151
  export function extractSymbolsAndRefsFromGraph(graph) {
3375
4152
  const allSymbols = new Map();
3376
4153
  const allRefs = new Map();
3377
- for (const node of graph.nodes.values()) {
3378
- if (node.kind === "symbol" && node.filePath && node.symbolName) {
3379
- const sym = {
3380
- id: `${node.filePath}:${node.symbolName}`,
3381
- name: node.symbolName,
3382
- kind: "function",
3383
- filePath: node.filePath,
3384
- line: 1,
3385
- column: 1,
3386
- isExported: false,
3387
- };
3388
- const list = allSymbols.get(node.filePath) ?? [];
3389
- list.push(sym);
3390
- allSymbols.set(node.filePath, list);
4154
+ const nodeSymbols = new Map();
4155
+ const coverage = {
4156
+ totalEvidence: 0,
4157
+ callsEvidence: 0,
4158
+ referencesEvidence: 0,
4159
+ eligibleEvidence: 0,
4160
+ resolvedEvidence: 0,
4161
+ unresolvedEvidence: 0,
4162
+ typeOnlyEvidence: 0,
4163
+ unsupportedEvidence: 0,
4164
+ sameFileEvidence: 0,
4165
+ duplicateEvidence: 0,
4166
+ complete: graph.persistCoverage?.partial !== true && graph.persistCoverage?.inProgress !== true,
4167
+ languages: {},
4168
+ };
4169
+ const numberMetadata = (node, key) => {
4170
+ const value = node?.metadata?.[key];
4171
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
4172
+ };
4173
+ const symbolKind = (node) => {
4174
+ switch (node.symbolKind) {
4175
+ case "class":
4176
+ case "interface":
4177
+ case "type":
4178
+ case "variable":
4179
+ case "method":
4180
+ case "property":
4181
+ case "function":
4182
+ return node.symbolKind;
4183
+ default:
4184
+ return "function";
3391
4185
  }
3392
- }
4186
+ };
4187
+ for (const node of graph.nodes.values()) {
4188
+ if (node.kind !== "symbol" || !node.filePath || !node.symbolName)
4189
+ continue;
4190
+ const sym = {
4191
+ id: node.id,
4192
+ name: node.symbolName,
4193
+ kind: symbolKind(node),
4194
+ filePath: node.filePath,
4195
+ line: numberMetadata(node, "line") ?? 1,
4196
+ endLine: numberMetadata(node, "endLine"),
4197
+ column: numberMetadata(node, "column") ?? 1,
4198
+ isExported: node.exported === true,
4199
+ };
4200
+ nodeSymbols.set(node.id, sym);
4201
+ const list = allSymbols.get(node.filePath) ?? [];
4202
+ list.push(sym);
4203
+ allSymbols.set(node.filePath, list);
4204
+ }
4205
+ const addRef = (ref) => {
4206
+ const list = allRefs.get(ref.filePath) ?? [];
4207
+ list.push(ref);
4208
+ allRefs.set(ref.filePath, list);
4209
+ };
3393
4210
  for (const edge of graph.edges) {
3394
- if (edge.kind === "references" && edge.from.startsWith("file:")) {
3395
- const callerFile = edge.from.slice("file:".length);
3396
- const refName = edge.to.startsWith("symbol-name:")
4211
+ if (edge.kind !== "calls" && edge.kind !== "references")
4212
+ continue;
4213
+ coverage.totalEvidence++;
4214
+ if (edge.kind === "calls")
4215
+ coverage.callsEvidence++;
4216
+ else
4217
+ coverage.referencesEvidence++;
4218
+ const fromNode = graph.nodes.get(edge.from);
4219
+ const targetNode = graph.nodes.get(edge.to);
4220
+ const callerFile = fromNode?.kind === "symbol"
4221
+ ? fromNode.filePath
4222
+ : fromNode?.kind === "file"
4223
+ ? fromNode.filePath
4224
+ : edge.from.startsWith("file:")
4225
+ ? edge.from.slice("file:".length)
4226
+ : undefined;
4227
+ if (!callerFile) {
4228
+ coverage.unsupportedEvidence++;
4229
+ continue;
4230
+ }
4231
+ const metadata = edge.metadata ?? {};
4232
+ const referenceKind = edge.kind === "calls"
4233
+ ? "call"
4234
+ : metadata.referenceKind === "call"
4235
+ ? "call"
4236
+ : metadata.referenceKind === "type"
4237
+ ? "type"
4238
+ : "unknown";
4239
+ const resolution = edge.resolution ?? (targetNode && !targetNode.metadata?.unresolvedName ? "exact" : "unresolved");
4240
+ const targetSymbol = nodeSymbols.get(edge.to);
4241
+ const targetName = targetNode?.symbolName ??
4242
+ (edge.to.startsWith("symbol-name:")
3397
4243
  ? edge.to.slice("symbol-name:".length)
3398
- : edge.to.split(":").pop() ?? edge.to;
3399
- const ref = {
3400
- symbolId: `${callerFile}:${refName}`,
3401
- filePath: callerFile,
3402
- line: edge.metadata?.line ?? 1,
3403
- column: edge.metadata?.column ?? 1,
3404
- };
3405
- const list = allRefs.get(callerFile) ?? [];
3406
- list.push(ref);
3407
- allRefs.set(callerFile, list);
3408
- }
3409
- }
3410
- return { allSymbols, allRefs };
4244
+ : undefined);
4245
+ const line = typeof metadata.line === "number" ? metadata.line : numberMetadata(fromNode, "line") ?? 1;
4246
+ const column = typeof metadata.column === "number" ? metadata.column : numberMetadata(fromNode, "column") ?? 1;
4247
+ addRef({
4248
+ symbolId: targetName ? `${callerFile}:${targetName}` : edge.to,
4249
+ filePath: callerFile,
4250
+ line,
4251
+ column,
4252
+ evidenceKind: edge.kind,
4253
+ referenceKind,
4254
+ targetId: edge.to,
4255
+ callerSymbolId: fromNode?.kind === "symbol" ? fromNode.id : undefined,
4256
+ resolution,
4257
+ targetName,
4258
+ targetKind: targetNode?.symbolKind,
4259
+ targetFilePath: targetNode?.filePath,
4260
+ targetLine: numberMetadata(targetNode, "line"),
4261
+ targetColumn: numberMetadata(targetNode, "column"),
4262
+ });
4263
+ if (referenceKind === "type")
4264
+ coverage.typeOnlyEvidence++;
4265
+ else if (referenceKind !== "call")
4266
+ coverage.unsupportedEvidence++;
4267
+ else if (!targetSymbol || resolution === "name-only" || resolution === "unresolved") {
4268
+ coverage.unresolvedEvidence++;
4269
+ }
4270
+ else {
4271
+ coverage.eligibleEvidence++;
4272
+ coverage.resolvedEvidence++;
4273
+ }
4274
+ }
4275
+ // Capped/partial graph persistence and file-level extractor metadata are
4276
+ // explicit degradation signals for read-only consumers. Missing metadata is
4277
+ // also unavailable: old/synthetic file nodes cannot prove that definitions,
4278
+ // references, or the TS/TSX warm function facts were attempted successfully.
4279
+ let sawRelevantFileNode = false;
4280
+ for (const node of graph.nodes.values()) {
4281
+ if (node.kind !== "file")
4282
+ continue;
4283
+ sawRelevantFileNode = true;
4284
+ const extraction = node.metadata?.extractionCoverage;
4285
+ const statuses = extraction ? Object.values(extraction) : [];
4286
+ const languageStatus = !extraction || statuses.length === 0 || statuses.includes("unavailable")
4287
+ ? "unavailable"
4288
+ : statuses.includes("partial")
4289
+ ? "partial"
4290
+ : "complete";
4291
+ if (languageStatus !== "complete")
4292
+ coverage.complete = false;
4293
+ if (node.language) {
4294
+ const prior = coverage.languages[node.language];
4295
+ coverage.languages[node.language] =
4296
+ prior === "unavailable" || languageStatus === "unavailable"
4297
+ ? "unavailable"
4298
+ : prior === "partial" || languageStatus === "partial"
4299
+ ? "partial"
4300
+ : languageStatus;
4301
+ }
4302
+ }
4303
+ // A graph with symbols/evidence but no file coverage metadata is a
4304
+ // synthetic/legacy shape that cannot prove which source files were
4305
+ // actually extracted. Do not let it masquerade as a complete scan.
4306
+ // No file node means there is no bounded source/extractor coverage to
4307
+ // justify a clean empty call graph (including an entirely empty or
4308
+ // external-only synthetic graph).
4309
+ if (!sawRelevantFileNode)
4310
+ coverage.complete = false;
4311
+ return { allSymbols, allRefs, coverage };
3411
4312
  }