pi-lens 4.1.2 → 4.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (359) hide show
  1. package/CHANGELOG.md +1308 -0
  2. package/README.md +3 -0
  3. package/config/biome/core.jsonc +11 -2
  4. package/config/dependency-cruiser-eager-allowlist.json +91 -0
  5. package/dist/clients/actionable-warnings-logger.js +18 -3
  6. package/dist/clients/actionable-warnings.js +927 -80
  7. package/dist/clients/agent-behavior-client.js +13 -4
  8. package/dist/clients/ast-grep-client.js +136 -9
  9. package/dist/clients/ast-grep-rule-manager.js +60 -5
  10. package/dist/clients/ast-grep-tool-logger.js +2 -2
  11. package/dist/clients/availability-probe-flight.js +12 -0
  12. package/dist/clients/bash-file-access.js +1 -3
  13. package/dist/clients/biome-client.js +16 -2
  14. package/dist/clients/blocker-freshness.js +34 -0
  15. package/dist/clients/bootstrap.js +509 -73
  16. package/dist/clients/bounded-cache.js +152 -12
  17. package/dist/clients/bounded-telemetry.js +115 -23
  18. package/dist/clients/build-identity.js +95 -0
  19. package/dist/clients/bundled-resource-health.js +113 -0
  20. package/dist/clients/bus-events-logger.js +2 -2
  21. package/dist/clients/bus-publish.js +3 -0
  22. package/dist/clients/cache/rule-cache.js +33 -5
  23. package/dist/clients/cache-manager.js +105 -6
  24. package/dist/clients/cache-observability.js +46 -19
  25. package/dist/clients/cargo-manifest.js +422 -0
  26. package/dist/clients/cascade-logger.js +17 -3
  27. package/dist/clients/code-quality-warnings.js +13 -3
  28. package/dist/clients/complexity-client.js +16 -5
  29. package/dist/clients/config-core/deny.js +221 -0
  30. package/dist/clients/config-core/index.js +47 -0
  31. package/dist/clients/config-core/merge.js +357 -0
  32. package/dist/clients/config-core/normalize.js +340 -0
  33. package/dist/clients/config-core/process-spec.js +248 -0
  34. package/dist/clients/config-core/provenance.js +171 -0
  35. package/dist/clients/config-core/records.js +218 -0
  36. package/dist/clients/config-core/resolve.js +125 -0
  37. package/dist/clients/config-core/safe-object.js +78 -0
  38. package/dist/clients/config-core/schema.js +167 -0
  39. package/dist/clients/config-diagnostic-codes.js +281 -0
  40. package/dist/clients/config-locations.js +160 -0
  41. package/dist/clients/config-resolve.js +789 -0
  42. package/dist/clients/config-schema.js +197 -0
  43. package/dist/clients/config-warn.js +407 -0
  44. package/dist/clients/dead-code-client.js +33 -25
  45. package/dist/clients/dead-code-logger.js +2 -2
  46. package/dist/clients/deadline-utils.js +178 -0
  47. package/dist/clients/debug-handles.js +2 -2
  48. package/dist/clients/debug-heap.js +3 -3
  49. package/dist/clients/deferred-lsp-work.js +106 -0
  50. package/dist/clients/degradation-ledger.js +166 -8
  51. package/dist/clients/demoted-finding-render.js +14 -0
  52. package/dist/clients/dependency-checker.js +28 -22
  53. package/dist/clients/deps/minimatch.js +1 -1
  54. package/dist/clients/diagnostic-line-freshness.js +12 -9
  55. package/dist/clients/diagnostic-logger.js +14 -4
  56. package/dist/clients/dispatch/collect-later-tier.js +39 -0
  57. package/dist/clients/dispatch/dispatcher.js +304 -19
  58. package/dist/clients/dispatch/fact-store.js +328 -9
  59. package/dist/clients/dispatch/integration.js +214 -107
  60. package/dist/clients/dispatch/pending-runner-findings.js +87 -0
  61. package/dist/clients/dispatch/plan.js +21 -5
  62. package/dist/clients/dispatch/runners/actionlint.js +0 -1
  63. package/dist/clients/dispatch/runners/ast-grep-napi.js +550 -77
  64. package/dist/clients/dispatch/runners/biome-check.js +0 -1
  65. package/dist/clients/dispatch/runners/cpp-check.js +0 -1
  66. package/dist/clients/dispatch/runners/credo.js +0 -1
  67. package/dist/clients/dispatch/runners/cue-vet.js +0 -1
  68. package/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  69. package/dist/clients/dispatch/runners/detekt.js +0 -1
  70. package/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  71. package/dist/clients/dispatch/runners/elixir-check.js +0 -1
  72. package/dist/clients/dispatch/runners/eslint.js +5 -2
  73. package/dist/clients/dispatch/runners/fact-rules.js +0 -1
  74. package/dist/clients/dispatch/runners/fish-indent.js +0 -1
  75. package/dist/clients/dispatch/runners/gleam-check.js +0 -1
  76. package/dist/clients/dispatch/runners/go-vet.js +1 -3
  77. package/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  78. package/dist/clients/dispatch/runners/hadolint.js +0 -1
  79. package/dist/clients/dispatch/runners/helm-lint.js +16 -4
  80. package/dist/clients/dispatch/runners/helm-render.js +54 -10
  81. package/dist/clients/dispatch/runners/htmlhint.js +0 -1
  82. package/dist/clients/dispatch/runners/index.js +1 -3
  83. package/dist/clients/dispatch/runners/javac.js +0 -1
  84. package/dist/clients/dispatch/runners/ktlint.js +0 -1
  85. package/dist/clients/dispatch/runners/lsp.js +37 -2
  86. package/dist/clients/dispatch/runners/markdownlint.js +4 -2
  87. package/dist/clients/dispatch/runners/mypy.js +0 -1
  88. package/dist/clients/dispatch/runners/oxlint.js +24 -22
  89. package/dist/clients/dispatch/runners/php-lint.js +0 -1
  90. package/dist/clients/dispatch/runners/phpstan.js +0 -1
  91. package/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  92. package/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -1
  93. package/dist/clients/dispatch/runners/pyright.js +7 -4
  94. package/dist/clients/dispatch/runners/rubocop.js +0 -1
  95. package/dist/clients/dispatch/runners/ruff.js +0 -1
  96. package/dist/clients/dispatch/runners/rust-clippy.js +9 -6
  97. package/dist/clients/dispatch/runners/shellcheck.js +0 -1
  98. package/dist/clients/dispatch/runners/shfmt.js +0 -1
  99. package/dist/clients/dispatch/runners/spellcheck.js +0 -1
  100. package/dist/clients/dispatch/runners/spotbugs.js +0 -1
  101. package/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  102. package/dist/clients/dispatch/runners/stylelint.js +0 -1
  103. package/dist/clients/dispatch/runners/swiftlint.js +0 -1
  104. package/dist/clients/dispatch/runners/taplo.js +0 -1
  105. package/dist/clients/dispatch/runners/terragrunt.js +0 -1
  106. package/dist/clients/dispatch/runners/tflint.js +0 -1
  107. package/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  108. package/dist/clients/dispatch/runners/trivy-config.js +0 -1
  109. package/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  110. package/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  111. package/dist/clients/dispatch/runners/utils/runner-helpers.js +270 -105
  112. package/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  113. package/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  114. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +89 -26
  115. package/dist/clients/dispatch/runners/vale.js +0 -1
  116. package/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  117. package/dist/clients/dispatch/runners/yamllint.js +0 -1
  118. package/dist/clients/dispatch/runners/zig-check.js +0 -1
  119. package/dist/clients/disposition-logger.js +3 -3
  120. package/dist/clients/effective-config.js +403 -0
  121. package/dist/clients/error-class.js +23 -0
  122. package/dist/clients/event-loop-hold.js +274 -0
  123. package/dist/clients/event-loop-monitor.js +66 -1
  124. package/dist/clients/extension-log.js +2 -2
  125. package/dist/clients/feature-hints.js +2 -1
  126. package/dist/clients/file-time.js +6 -1
  127. package/dist/clients/file-utils.js +266 -13
  128. package/dist/clients/finding-delivery-gate.js +55 -13
  129. package/dist/clients/format-service.js +6 -1
  130. package/dist/clients/formatters.js +303 -104
  131. package/dist/clients/freshness-cadence.js +17 -0
  132. package/dist/clients/generated-artifacts.js +114 -19
  133. package/dist/clients/generation-guard.js +4 -9
  134. package/dist/clients/git-guard.js +198 -48
  135. package/dist/clients/git-tracked-ignore.js +32 -1
  136. package/dist/clients/go-client.js +39 -0
  137. package/dist/clients/govulncheck-client.js +5 -1
  138. package/dist/clients/gradle-ktfmt-style.js +252 -0
  139. package/dist/clients/gzip-stage-write.js +7 -0
  140. package/dist/clients/hashline-anchor.js +424 -0
  141. package/dist/clients/hook-budgets.js +76 -0
  142. package/dist/clients/host-edit-normalize.js +5 -2
  143. package/dist/clients/host-ports.js +1 -1
  144. package/dist/clients/installer/index.js +200 -84
  145. package/dist/clients/installer/managed-tool-refresh.js +3 -1
  146. package/dist/clients/instance-reaper.js +108 -243
  147. package/dist/clients/instance-registry-lock.js +145 -0
  148. package/dist/clients/instance-registry.js +435 -122
  149. package/dist/clients/jscpd-client.js +6 -1
  150. package/dist/clients/language-profile.js +4 -2
  151. package/dist/clients/language-registry.js +595 -0
  152. package/dist/clients/latency-logger.js +179 -3
  153. package/dist/clients/ledger-bounds.js +13 -0
  154. package/dist/clients/lens-config.js +141 -30
  155. package/dist/clients/lens-engine.js +30 -37
  156. package/dist/clients/lens-flag-registry.js +67 -6
  157. package/dist/clients/lens-map.js +3 -1
  158. package/dist/clients/log-cleanup.js +2 -2
  159. package/dist/clients/lsp/aggregation.js +3 -1
  160. package/dist/clients/lsp/cascade-tier.js +4 -0
  161. package/dist/clients/lsp/client.js +672 -49
  162. package/dist/clients/lsp/config.js +504 -156
  163. package/dist/clients/lsp/diagnostic-binding.js +10 -5
  164. package/dist/clients/lsp/edits.js +15 -2
  165. package/dist/clients/lsp/index.js +1179 -247
  166. package/dist/clients/lsp/jvm-runtime.js +5 -0
  167. package/dist/clients/lsp/language.js +18 -169
  168. package/dist/clients/lsp/launch.js +38 -9
  169. package/dist/clients/lsp/pending-aux-coverage.js +113 -12
  170. package/dist/clients/lsp/server.js +426 -160
  171. package/dist/clients/lsp/session-roots.js +192 -0
  172. package/dist/clients/lsp/spawn-history.js +2 -7
  173. package/dist/clients/lsp/tsserver-sync.js +9 -2
  174. package/dist/clients/lsp/workspace-diagnostics-cache.js +17 -7
  175. package/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  176. package/dist/clients/lsp-mutation.js +179 -23
  177. package/dist/clients/map-with-concurrency.js +36 -0
  178. package/dist/clients/mcp/analyze.js +45 -10
  179. package/dist/clients/mcp/session.js +19 -17
  180. package/dist/clients/memory-sampler.js +28 -1
  181. package/dist/clients/message-end-attribution.js +30 -0
  182. package/dist/clients/metrics-history.js +21 -4
  183. package/dist/clients/middle-man-analysis.js +2 -4
  184. package/dist/clients/module-report.js +20 -45
  185. package/dist/clients/mutating-tool.js +651 -0
  186. package/dist/clients/mutation-attribution.js +368 -0
  187. package/dist/clients/mutation-bridge.js +240 -0
  188. package/dist/clients/ndjson-logger.js +247 -20
  189. package/dist/clients/observed-mutation-sources.js +101 -0
  190. package/dist/clients/observed-mutation.js +1215 -0
  191. package/dist/clients/opaque-mutation-scan.js +300 -40
  192. package/dist/clients/opengrep-config.js +4 -0
  193. package/dist/clients/package-manager.js +201 -44
  194. package/dist/clients/partial-edit-apply.js +359 -80
  195. package/dist/clients/path-attribution-telemetry.js +23 -7
  196. package/dist/clients/path-keyed-map.js +35 -3
  197. package/dist/clients/path-utils.js +460 -13
  198. package/dist/clients/persist-debounce.js +8 -1
  199. package/dist/clients/php-cs-fixer-config.js +114 -0
  200. package/dist/clients/pipeline.js +77 -24
  201. package/dist/clients/probe-home-state.js +228 -0
  202. package/dist/clients/process-bridge.js +66 -0
  203. package/dist/clients/process-singletons.js +210 -0
  204. package/dist/clients/process-snapshot.js +68 -0
  205. package/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  206. package/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  207. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  208. package/dist/clients/project-diagnostics/scanner.js +61 -24
  209. package/dist/clients/project-lens-config.js +393 -122
  210. package/dist/clients/project-snapshot.js +85 -35
  211. package/dist/clients/python-environment.js +217 -0
  212. package/dist/clients/python-provenance.js +639 -0
  213. package/dist/clients/quiet-window.js +6 -1
  214. package/dist/clients/read-bridge.js +13 -21
  215. package/dist/clients/read-expansion.js +9 -46
  216. package/dist/clients/read-guard-logger.js +39 -6
  217. package/dist/clients/read-guard-tool-lines.js +364 -212
  218. package/dist/clients/read-guard.js +95 -7
  219. package/dist/clients/recent-touches.js +17 -5
  220. package/dist/clients/resource-sampler.js +195 -96
  221. package/dist/clients/review-graph/builder.js +752 -132
  222. package/dist/clients/review-graph/service.js +55 -3
  223. package/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  224. package/dist/clients/review-graph/tsconfig-paths.js +5 -1
  225. package/dist/clients/review-graph/workspace-modules.js +10 -74
  226. package/dist/clients/review-graph-logger.js +30 -4
  227. package/dist/clients/runtime-agent-end.js +18 -2
  228. package/dist/clients/runtime-context.js +49 -16
  229. package/dist/clients/runtime-coordinator.js +93 -6
  230. package/dist/clients/runtime-session.js +169 -34
  231. package/dist/clients/runtime-tool-call.js +183 -35
  232. package/dist/clients/runtime-tool-result.js +894 -241
  233. package/dist/clients/runtime-turn.js +1264 -74
  234. package/dist/clients/rust-client.js +31 -0
  235. package/dist/clients/safe-spawn.js +191 -28
  236. package/dist/clients/sanitize.js +7 -1
  237. package/dist/clients/scratch-tree-policy.js +1 -3
  238. package/dist/clients/security-scan-client.js +51 -17
  239. package/dist/clients/session-lifecycle.js +238 -42
  240. package/dist/clients/session-start-observability.js +79 -0
  241. package/dist/clients/sessionstart-logger.js +12 -3
  242. package/dist/clients/sg-runner.js +27 -15
  243. package/dist/clients/sgconfig.js +6 -1
  244. package/dist/clients/shared-checkout-guard.js +338 -0
  245. package/dist/clients/skills-resolver.js +105 -0
  246. package/dist/clients/smells-rollup.js +2 -2
  247. package/dist/clients/spawn-output-cap.js +37 -0
  248. package/dist/clients/spawn-timeout-cooldown.js +15 -1
  249. package/dist/clients/startup-scan.js +6 -7
  250. package/dist/clients/startup-timing.js +69 -4
  251. package/dist/clients/string-utils.js +33 -0
  252. package/dist/clients/subagent-mode.js +17 -4
  253. package/dist/clients/test-runner-client.js +586 -118
  254. package/dist/clients/test-runner-delivery.js +239 -0
  255. package/dist/clients/tool-definition.js +41 -1
  256. package/dist/clients/tool-policy.js +92 -22
  257. package/dist/clients/tree-sitter-cache.js +40 -40
  258. package/dist/clients/tree-sitter-client.js +97 -53
  259. package/dist/clients/tree-sitter-logger.js +19 -3
  260. package/dist/clients/tree-sitter-query-loader.js +81 -1
  261. package/dist/clients/tree-sitter-shared.js +51 -46
  262. package/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  263. package/dist/clients/typos-config.js +5 -0
  264. package/dist/clients/user-notify.js +6 -2
  265. package/dist/clients/warm-attach.js +8 -10
  266. package/dist/clients/widget-state.js +191 -26
  267. package/dist/clients/word-index-logger.js +15 -3
  268. package/dist/clients/word-index-store.js +463 -0
  269. package/dist/clients/word-index.js +815 -125
  270. package/dist/clients/workspace-topology.js +25 -1
  271. package/dist/clients/zizmor-config.js +29 -6
  272. package/dist/index.js +68577 -53950
  273. package/dist/mcp/analyze-cli.js +3 -1
  274. package/dist/mcp/build-staleness.js +8 -7
  275. package/dist/mcp/cli.js +3 -1
  276. package/dist/mcp/server.js +133 -6
  277. package/dist/scripts/lib/process-scan.mjs +583 -0
  278. package/dist/scripts/lib/skills-predicate.mjs +129 -0
  279. package/dist/tools/effective-config.js +89 -0
  280. package/dist/tools/lens-diagnostics.js +99 -24
  281. package/dist/tools/lsp-diagnostics.js +81 -85
  282. package/dist/tools/lsp-navigation.js +40 -17
  283. package/dist/tools/shared.js +0 -1
  284. package/docs/agent-guide.md +68 -3
  285. package/docs/ast-grep_rules_catalog.md +41 -10
  286. package/docs/astplayground.md +26 -17
  287. package/docs/configuration.md +222 -0
  288. package/docs/dependencies.md +3 -3
  289. package/docs/features.md +44 -5
  290. package/docs/language-coverage.md +2 -2
  291. package/docs/pi-lens-fixer.md +25 -0
  292. package/docs/pi-lens-investigator.md +25 -0
  293. package/docs/pi-lens-reviewer.md +27 -0
  294. package/docs/pi-lens-subagent.md +38 -0
  295. package/docs/pi-lens-warden.md +55 -0
  296. package/docs/public-api-stability.md +359 -0
  297. package/docs/release-qa-baseline.md +182 -0
  298. package/docs/subagent-compat.md +110 -30
  299. package/docs/tree-sitter_rules_catalog.md +2 -2
  300. package/package.json +17 -21
  301. package/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  302. package/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  303. package/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  304. package/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  305. package/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  306. package/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  307. package/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  308. package/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  309. package/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  310. package/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  311. package/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  312. package/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  313. package/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  314. package/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  315. package/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  316. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  317. package/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  318. package/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  319. package/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  320. package/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  321. package/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  322. package/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  323. package/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  324. package/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  325. package/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  326. package/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  327. package/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  328. package/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  329. package/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  330. package/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  331. package/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  332. package/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  333. package/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  334. package/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  335. package/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  336. package/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  337. package/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  338. package/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  339. package/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  340. package/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  341. package/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  342. package/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  343. package/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  344. package/rules/ast-grep-rules/rules/no-important.yml +21 -0
  345. package/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  346. package/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  347. package/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  348. package/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  349. package/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  350. package/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  351. package/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  352. package/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  353. package/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  354. package/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  355. package/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  356. package/scripts/analyze-pi-lens-logs.mjs +192 -1
  357. package/scripts/install-selftest.mjs +58 -2
  358. package/scripts/lib/skills-predicate.mjs +129 -0
  359. package/scripts/rpc-load-check.mjs +3 -1
@@ -18,6 +18,8 @@ import { containerNameChain, getOpenDocumentSymbols, lspSymbolKindName, } from "
18
18
  import { isAtOrAboveHomeDir, normalizeFilePath, normalizeMapKey, toProjectRelativePath, } from "../path-utils.js";
19
19
  import { collectProjectSourceFilesWithBudgetAsync } from "../project-scan-policy.js";
20
20
  import { getReviewGraphMaxFilesDerived } from "../project-scale.js";
21
+ import { compareOrdinal, escapeRegExp } from "../string-utils.js";
22
+ import { BoundedLruCache } from "../bounded-cache.js";
21
23
  import { jsTsCandidatePaths, resolveAliasedImport, resolveImportToFiles, resolveProjectReferenceImport, resolveWorkspacePackageImport, } from "./import-resolvers.js";
22
24
  import { RUNTIME_CONFIG } from "../runtime-config.js";
23
25
  import { buildReverseDependencyIndexFromGraph, rankFilesByReverseDependencyCentrality, } from "../reverse-deps.js";
@@ -25,7 +27,7 @@ import { buildQualifiedName, findOwnerName } from "../symbol-containment.js";
25
27
  import { logTreeSitterCacheStats } from "../tree-sitter-logger.js";
26
28
  import { flushReviewGraphLogSync, logReviewGraph, makeReviewGraphBuildMetadata, } from "../review-graph-logger.js";
27
29
  import { getSharedTreeSitterClient } from "../tree-sitter-shared.js";
28
- import { TreeSitterSymbolExtractor, } from "../tree-sitter-symbol-extractor.js";
30
+ import { symbolExtractionGrammar, TreeSitterSymbolExtractor, } from "../tree-sitter-symbol-extractor.js";
29
31
  import { withTreeSitterRoot } from "../tree-sitter-shared.js";
30
32
  import { incrementDegradationCount } from "../degradation-ledger.js";
31
33
  import { resolveGitIdentity } from "./git-identity.js";
@@ -94,6 +96,45 @@ const MAIN_KIND_EXTENSIONS = Array.from(MAIN_KINDS).flatMap((kind) => KIND_EXTEN
94
96
  export const REVIEW_GRAPH_SOURCE_EXTENSIONS = MAIN_KIND_EXTENSIONS;
95
97
  const CHANGED_SYMBOLS_PREFIX = "session.reviewGraph.changedSymbols:";
96
98
  const extractorCache = new Map();
99
+ const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
100
+ // Walker output is raw, so its spelling still needs the canonical path seam.
101
+ // Keep that expensive raw-to-canonical step per project across builds, bounded
102
+ // like TestRunnerClient's instance-lifetime canonicalRootMemo (#2058). A new
103
+ // spelling is resolved once; repeated builds reuse the result. The project
104
+ // memo is bounded and workspace-cache clears provide a freshness boundary.
105
+ const REVIEW_GRAPH_SOURCE_PATH_MEMO_ENTRIES = 16_384;
106
+ // BoundedLruCache, not BoundedFifoMap: a hit here must refresh the workspace's
107
+ // recency (the hand-rolled version did its own delete+set for exactly that),
108
+ // so a workspace still being built is never the one evicted (#2442).
109
+ const _sourcePathMemos = new BoundedLruCache(REVIEW_GRAPH_MAX_WARM_WORKSPACES);
110
+ function sourcePathMemo(cwd) {
111
+ const key = normalizeMapKey(path.resolve(cwd));
112
+ const existing = _sourcePathMemos.get(key);
113
+ if (existing)
114
+ return existing;
115
+ const memo = {
116
+ cache: new BoundedLruCache(REVIEW_GRAPH_SOURCE_PATH_MEMO_ENTRIES),
117
+ normalizeCalls: { value: 0 },
118
+ };
119
+ _sourcePathMemos.set(key, memo);
120
+ return memo;
121
+ }
122
+ function normalizeGraphSourcePath(memo, raw) {
123
+ const cached = memo.cache.get(raw);
124
+ if (cached !== undefined)
125
+ return cached;
126
+ const normalized = normalizeMapKey(raw);
127
+ // A missing file is normalized through resolveNonExisting, which lowercases
128
+ // its tail. It may be recreated with different casing before the next build,
129
+ // so never make that spelling a process-lifetime memo entry (#2072 F2).
130
+ if (fs.existsSync(raw))
131
+ memo.cache.set(raw, normalized);
132
+ memo.normalizeCalls.value++;
133
+ return normalized;
134
+ }
135
+ export function _resetReviewGraphSourcePathMemoForTests() {
136
+ _sourcePathMemos.clear();
137
+ }
97
138
  // IN-FLIGHT Promise cache: deduplicates CONCURRENT buildOrUpdateGraph calls for
98
139
  // the same (cwd, changedFiles). A separate workspace cache below preserves the
99
140
  // expensive parsed graph across invocations when source file mtimes/sizes have
@@ -111,7 +152,6 @@ const extractorCache = new Map();
111
152
  // operation's, not the process's.
112
153
  const _buildCache = new Map();
113
154
  const _workspaceGraphCache = new Map();
114
- const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
115
155
  const REVIEW_GRAPH_IDLE_EVICT_MS_DEFAULT = 20 * 60_000;
116
156
  // A workspace-wide clear must invalidate builds for workspaces that are not
117
157
  // resident yet, too. This process-wide component therefore survives cache
@@ -166,6 +206,7 @@ function evictWorkspaceGraph(key, entry) {
166
206
  _buildCache.delete(buildKey);
167
207
  }
168
208
  }
209
+ _sourcePathMemos.delete(key);
169
210
  _workspaceCacheEpochs.set(key, (_workspaceCacheEpochs.get(key) ?? 0) + 1);
170
211
  _workspaceGraphCache.delete(key);
171
212
  }
@@ -191,8 +232,16 @@ function touchWorkspaceGraph(key) {
191
232
  function setWorkspaceGraph(key, entry, epoch) {
192
233
  if (epoch !== undefined && workspaceCacheEpoch(key) !== epoch)
193
234
  return false;
235
+ const previous = _workspaceGraphCache.get(key);
236
+ if (previous)
237
+ clearWorkspaceGraphTimer(previous);
238
+ // #2255: bound what the cache RETAINS. The caller keeps its full-graph
239
+ // reference for the current turn; only the retained copy is trimmed, so an
240
+ // over-budget repo never accumulates an unbounded graph across the process.
241
+ const boundedGraph = retainedGraph(key, entry.graph);
194
242
  const resident = {
195
243
  ...entry,
244
+ graph: boundedGraph,
196
245
  lastUsedAt: Date.now(),
197
246
  };
198
247
  _workspaceGraphCache.set(key, resident);
@@ -292,8 +341,14 @@ export function clearReviewGraphWorkspaceCache(cwd) {
292
341
  for (const entry of _workspaceGraphCache.values())
293
342
  clearWorkspaceGraphTimer(entry);
294
343
  _workspaceGraphCache.clear();
344
+ _sourcePathMemos.clear();
295
345
  _workspaceCacheEpoch++;
296
346
  _sizeSkipVerdicts.clear();
347
+ // #2255 review V2: the non-cache retention registry is memory-attribution
348
+ // state for THIS session's graphs. Without this, a new session's
349
+ // `memory_sample` kept attributing the previous session's graph, which the
350
+ // registry still held a live `WeakRef` to.
351
+ _retainedGraphSites.clear();
297
352
  }
298
353
  else {
299
354
  const normalized = normalizeMapKey(cwd);
@@ -311,6 +366,7 @@ export function clearReviewGraphWorkspaceCache(cwd) {
311
366
  clearWorkspaceGraphTimer(entry);
312
367
  _workspaceCacheEpochs.set(normalized, (_workspaceCacheEpochs.get(normalized) ?? 0) + 1);
313
368
  _workspaceGraphCache.delete(normalized);
369
+ _sourcePathMemos.delete(normalized);
314
370
  _sizeSkipVerdicts.delete(normalized);
315
371
  }
316
372
  _lastGraphBuildInfo = { reused: false, mode: "full", graphChanged: true };
@@ -324,23 +380,217 @@ export function _setReviewGraphBuildGateForTests(gate) {
324
380
  _reviewGraphBuildGateForTests = gate;
325
381
  }
326
382
  /**
327
- * O(cache-entries) snapshot of the resident workspace graph cache — NOT
328
- * O(nodes)/O(edges): only `.size`/`.length` are read per entry, never the
329
- * graph contents themselves (#1123 item 2 memory-attribution sample). Cache
330
- * entries are per-cwd and normally number 1 (a session_start clears the
331
- * cache), so this is cheap enough for a per-N-turn sample.
383
+ * Measured heap coefficients for the graph's current object shape. An
384
+ * isolated store of the production node and edge objects was forced through
385
+ * GC and divided by its census: 450.5 bytes per node and 243.0 bytes per
386
+ * edge, including the two edge-index references. The measurement used 18,695
387
+ * nodes and 48,815 edges across three identical runs with --expose-gc.
388
+ */
389
+ const REVIEW_GRAPH_NODE_RESIDENT_BYTES = 450.5;
390
+ const REVIEW_GRAPH_EDGE_RESIDENT_BYTES = 243.0;
391
+ export function estimateReviewGraphStoreBytes(totalNodes, totalEdges) {
392
+ return (totalNodes * REVIEW_GRAPH_NODE_RESIDENT_BYTES +
393
+ totalEdges * REVIEW_GRAPH_EDGE_RESIDENT_BYTES);
394
+ }
395
+ // --- In-memory live-graph byte bound (#2255) ---
396
+ // The persist element cap (`GRAPH_PERSIST_MAX_ELEMENTS_DEFAULT`) guards the
397
+ // synchronous serialize+gzip spike; it trims only the on-disk snapshot. The live
398
+ // `ReviewGraph` had no bound and grew with project size — the second unbounded
399
+ // dispatch store behind the #2240 OOM (FactStore's fileFacts was the first,
400
+ // bounded in #2243). This bound caps the RETAINED live graph by estimated
401
+ // resident bytes, using the same centrality-ranked induced-subgraph selection the
402
+ // snapshot uses (`capGraphForPersist`).
403
+ //
404
+ // A graph has TWO process-lifetime retention sites, not one: the workspace cache,
405
+ // and `session.reviewGraph` on a caller's FactStore — and two of those stores are
406
+ // module-scope (`dispatch/integration.ts`, `mcp/analyze.ts`, the latter never
407
+ // cleared). Bounding only the cache left the full graph resident on the fact, so
408
+ // both sites go through `retainedGraph` below, memoized per graph instance so the
409
+ // two sites share ONE bounded object instead of trimming twice (#2255 review F2).
410
+ export const GRAPH_MAX_IN_MEMORY_BYTES_DEFAULT = 512 * 1024 * 1024;
411
+ function graphMaxInMemoryBytes() {
412
+ const raw = Number(process.env.PI_LENS_GRAPH_MAX_IN_MEMORY_BYTES);
413
+ return Number.isFinite(raw) && raw > 0
414
+ ? raw
415
+ : GRAPH_MAX_IN_MEMORY_BYTES_DEFAULT;
416
+ }
417
+ /**
418
+ * The graph any process-lifetime holder may RETAIN, bounded to the in-memory byte
419
+ * budget (#2255). Callers keep the full graph for the current turn; only what
420
+ * outlives the turn goes through here.
421
+ *
422
+ * In budget, this is an O(1) size read returning the same object, so a normal
423
+ * repository sees no behavior change. Over budget, the byte budget converts to an
424
+ * element cap using the graph's own node/edge split, then `capGraphForPersist`
425
+ * runs one centrality selection.
426
+ *
427
+ * Deliberately NOT memoized. A memo keyed on the source graph short-circuits the
428
+ * budget check, so a graph mutated in place after its first trim keeps answering
429
+ * with the stale earlier result (#2255 review R4). Callers that retain the same
430
+ * graph at two sites dedupe at the CALL SITE by capping once and sharing the
431
+ * result, which is both simpler and staleness-free.
432
+ *
433
+ * The result is marked `partial` AND `capTrimmed`. `partial` keeps every
434
+ * coverage-reporting consumer honest; `capTrimmed` records the narrower fact that
435
+ * the source WALK was complete and only size was cut, which is what lets the two
436
+ * incremental-base gates rebuild from it instead of forcing a full walk every
437
+ * turn. `capTrimmed` is process-local and stripped before persist.
438
+ */
439
+ function retainedGraph(cwd, graph) {
440
+ const nodes = graph.nodes.size;
441
+ const edges = graph.edges.length;
442
+ const bytes = estimateReviewGraphStoreBytes(nodes, edges);
443
+ const budget = graphMaxInMemoryBytes();
444
+ // Deliberately NOT skipped for an already-partial graph. A full build over the
445
+ // source-walk entry budget sets `partial: true` on a graph that is still the
446
+ // full walked set, so skipping partials exempted the exact population this
447
+ // bound targets (#2255 review F3). The byte check below is the only
448
+ // idempotence this needs: a graph already under budget is returned as-is.
449
+ if (bytes <= budget)
450
+ return graph;
451
+ // Scale both axes by the same budget/bytes ratio so the element cap preserves
452
+ // the graph's node/edge split; `capGraphForPersist` re-splits the cap by that
453
+ // same ratio, landing the capped graph at or under the budget.
454
+ const elementCap = Math.max(1, Math.floor(((nodes + edges) * budget) / bytes));
455
+ let capped = capGraphForPersist(cwd, graph, elementCap);
456
+ // Floor: a selection that retains NOTHING is never an acceptable answer for a
457
+ // non-empty input — it silently converts "too big" into "no graph at all", and
458
+ // every query against it reads as a clean empty result (#2255 review F4).
459
+ // Fall back to a deterministic head slice, which is still bounded by the same
460
+ // element cap but cannot be empty, and say so under its own ledger kind so the
461
+ // two outcomes are never blended into one record.
462
+ if (capped.nodes.size === 0 && nodes > 0) {
463
+ capped = headSliceGraph(graph, elementCap);
464
+ incrementDegradationCount({
465
+ kind: "review-graph-memory-cap-floor",
466
+ subject: cwd,
467
+ reason: `centrality selection retained 0 of ${nodes} nodes at cap ${elementCap}; fell back to a ${capped.nodes.size}-node head slice`,
468
+ });
469
+ }
470
+ const cappedNodes = capped.nodes.size;
471
+ const cappedEdges = capped.edges.length;
472
+ capped.persistCoverage = {
473
+ ...(capped.persistCoverage ?? graphCoverage(capped, elementCap)),
474
+ partial: true,
475
+ capTrimmed: true,
476
+ };
477
+ incrementDegradationCount({
478
+ kind: "review-graph-memory-cap",
479
+ subject: cwd,
480
+ reason: `live graph ${nodes}n/${edges}e ~${Math.round(bytes / (1024 * 1024))}MiB over ${Math.round(budget / (1024 * 1024))}MiB budget; trimmed to ${cappedNodes}n/${cappedEdges}e ~${Math.round(estimateReviewGraphStoreBytes(cappedNodes, cappedEdges) / (1024 * 1024))}MiB`,
481
+ });
482
+ return capped;
483
+ }
484
+ /**
485
+ * Deterministic non-empty fallback selection: take whole per-file node groups in
486
+ * stable path order until the element cap is reached, then the induced edges.
487
+ * Used only when centrality selection returns nothing (#2255 review F4).
488
+ */
489
+ function headSliceGraph(graph, cap) {
490
+ const nodeBudget = Math.max(1, Math.floor((cap * graph.nodes.size) / (graph.nodes.size + graph.edges.length)));
491
+ const keptIds = new Set();
492
+ for (const [id] of graph.nodes) {
493
+ if (keptIds.size >= nodeBudget)
494
+ break;
495
+ keptIds.add(id);
496
+ }
497
+ const nodes = new Map([...graph.nodes].filter(([id]) => keptIds.has(id)));
498
+ const edgeBudget = Math.max(0, cap - nodes.size);
499
+ const edges = graph.edges
500
+ .filter((edge) => keptIds.has(edge.from) && keptIds.has(edge.to))
501
+ .slice(0, edgeBudget);
502
+ const sliced = {
503
+ ...graph,
504
+ nodes,
505
+ edges,
506
+ edgesByFrom: new Map(),
507
+ edgesByTo: new Map(),
508
+ fileNodes: new Map(),
509
+ symbolNodesByFile: new Map(),
510
+ changedSymbolsByFile: new Map(),
511
+ persistCoverage: undefined,
512
+ };
513
+ rebuildIndexes(sliced);
514
+ return sliced;
515
+ }
516
+ /**
517
+ * Store the graph on `session.reviewGraph` bounded (#2255 review F2). Two of the
518
+ * FactStores this reaches are module-scope, so an unbounded value here outlives
519
+ * every session and defeats the cache bound entirely.
520
+ *
521
+ * This bounds the VALUE, not the store, and deliberately adds no second bounding
522
+ * mechanism to `FactStore` (#2243 owns the store-level discipline).
523
+ *
524
+ * `sessionFacts` entry COUNT (a separate #2240 sibling, #2282) is now bounded
525
+ * too: `session.baseline.${path}` (`dispatch/dispatcher.ts`),
526
+ * `session.baseline.cascade.${path}` (`dispatch/integration.ts`), and this
527
+ * module's own `changedSymbols`/`entitySnapshot` per-file keys all go through
528
+ * `FactStore.setBoundedSessionFact`/`getBoundedSessionFact`, an LRU-count-cap
529
+ * sibling of the per-file `fileFacts` bound reusing the SAME discipline
530
+ * (#2243), not a second mechanism. `session.reviewGraph` itself stays on the
531
+ * plain, unbounded `sessionFacts` map — its key is a fixed singleton, so the
532
+ * VALUE bound below is what keeps IT small, not an entry-count cap.
533
+ */
534
+ function setSessionReviewGraphFact(cwd, facts, graph) {
535
+ const retained = retainedGraph(cwd, graph);
536
+ // The raw `cwd` is the registry key on purpose. Folding it here would add a
537
+ // `realpath` probe to EVERY build, and the key only has to be stable: the
538
+ // snapshot deduplicates by graph object IDENTITY, so two spellings of one
539
+ // workspace cost one extra bounded registry slot and never a double count.
540
+ registerRetainedGraph(`fact:${cwd}`, retained);
541
+ facts.setSessionFact("session.reviewGraph", retained);
542
+ }
543
+ // Retention sites OUTSIDE the workspace cache — today `session.reviewGraph` on a
544
+ // caller's FactStore. Held through `WeakRef` so registering adds no retention of
545
+ // its own: a graph whose only remaining referent is this registry is collectable,
546
+ // and its slot is pruned on the next write or read. Keyed by site, so the map is
547
+ // bounded by the number of distinct workspaces, not by builds (#2255 review F2).
548
+ const _retainedGraphSites = new Map();
549
+ function registerRetainedGraph(site, graph) {
550
+ for (const [key, ref] of _retainedGraphSites) {
551
+ if (ref.deref() === undefined)
552
+ _retainedGraphSites.delete(key);
553
+ }
554
+ _retainedGraphSites.set(site, new WeakRef(graph));
555
+ }
556
+ /**
557
+ * O(retention-sites) snapshot of every resident review graph — NOT
558
+ * O(nodes)/O(edges): only `.size`/`.length` are read per graph, never the graph
559
+ * contents themselves (#1123 item 2 memory-attribution sample).
560
+ *
561
+ * Counts the workspace cache AND the non-cache retention sites, deduplicated by
562
+ * object IDENTITY. Reading the cache alone reported zero bytes while a full graph
563
+ * was still resident on `session.reviewGraph` — the sample read clean during the
564
+ * exact heap exhaustion it is cited to prove against (#2255 review F2). The
565
+ * dedupe matters because the bound hands both sites the same bounded object;
566
+ * summing them would double-count the common case.
332
567
  */
333
568
  export function getReviewGraphWorkspaceCacheSnapshot() {
334
569
  let totalNodes = 0;
335
570
  let totalEdges = 0;
336
- for (const entry of _workspaceGraphCache.values()) {
337
- totalNodes += entry.graph.nodes.size;
338
- totalEdges += entry.graph.edges.length;
571
+ const counted = new Set();
572
+ const count = (graph) => {
573
+ if (counted.has(graph))
574
+ return;
575
+ counted.add(graph);
576
+ totalNodes += graph.nodes.size;
577
+ totalEdges += graph.edges.length;
578
+ };
579
+ for (const entry of _workspaceGraphCache.values())
580
+ count(entry.graph);
581
+ for (const [key, ref] of _retainedGraphSites) {
582
+ const graph = ref.deref();
583
+ if (graph === undefined) {
584
+ _retainedGraphSites.delete(key);
585
+ continue;
586
+ }
587
+ count(graph);
339
588
  }
340
589
  return {
341
590
  cacheEntries: _workspaceGraphCache.size,
342
591
  totalNodes,
343
592
  totalEdges,
593
+ residentBytes: estimateReviewGraphStoreBytes(totalNodes, totalEdges),
344
594
  };
345
595
  }
346
596
  /** Test-only cache keys, including the LRU order from oldest to newest. */
@@ -349,6 +599,26 @@ export function _getReviewGraphWorkspaceCacheKeysForTests() {
349
599
  .sort(([, a], [, b]) => a.lastUsedAt - b.lastUsedAt)
350
600
  .map(([key]) => key);
351
601
  }
602
+ /** Test-only replacement seam for the workspace idle-eviction family. */
603
+ export function _setReviewGraphWorkspaceEntryForTests(key, graph) {
604
+ setWorkspaceGraph(key, {
605
+ signature: "test",
606
+ fileSignatures: new Map(),
607
+ graph,
608
+ });
609
+ }
610
+ /** Test-only drive of the session-fact retention seam the build paths use. */
611
+ export function _setSessionReviewGraphFactForTests(cwd, facts, graph) {
612
+ setSessionReviewGraphFact(cwd, facts, graph);
613
+ }
614
+ /** Test-only view of what a graph's coverage looks like once persisted. */
615
+ export function _persistedCoverageForTests(coverage) {
616
+ return stripProcessLocalCoverage(coverage);
617
+ }
618
+ /** Test-only read of the exact retained graph for a raw cache key (#2255). */
619
+ export function _getReviewGraphWorkspaceGraphForTests(key) {
620
+ return _workspaceGraphCache.get(key)?.graph;
621
+ }
352
622
  export function _getReviewGraphCacheStateForTests(cwd) {
353
623
  const cached = _workspaceGraphCache.get(normalizeMapKey(cwd));
354
624
  if (!cached)
@@ -552,9 +822,6 @@ function makeCtx(filePath, cwd, facts) {
552
822
  log: () => { },
553
823
  };
554
824
  }
555
- function escapeRegExp(string) {
556
- return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
557
- }
558
825
  function createEmptyGraph() {
559
826
  return {
560
827
  version: REVIEW_GRAPH_VERSION,
@@ -628,7 +895,7 @@ async function sourceSignatureMapAsync(files) {
628
895
  }
629
896
  function sourceSignatureFromMap(signatures) {
630
897
  return [...signatures.entries()]
631
- .sort(([a], [b]) => a.localeCompare(b))
898
+ .sort(([a], [b]) => compareOrdinal(a, b))
632
899
  .map(([file, signature]) => `${file}:${signature}`)
633
900
  .join("|");
634
901
  }
@@ -850,6 +1117,8 @@ export function _setReviewGraphEntryCounterForTests(counter) {
850
1117
  _reviewGraphEntryCounterForTests = counter;
851
1118
  }
852
1119
  export async function getGraphSourceFiles(cwd) {
1120
+ const sourceMemo = sourcePathMemo(cwd);
1121
+ const normalizeCallsBefore = sourceMemo.normalizeCalls.value;
853
1122
  // Async, chunked-yield walk (identical output to the sync collector) so the
854
1123
  // per-edit cascade graph rebuild doesn't block the event loop on a large repo.
855
1124
  //
@@ -889,6 +1158,7 @@ export async function getGraphSourceFiles(cwd) {
889
1158
  // so the caller's `length > maxGraphFiles` check still trips.
890
1159
  return {
891
1160
  files: collected,
1161
+ pathNormalizeCalls: sourceMemo.normalizeCalls.value - normalizeCallsBefore,
892
1162
  sourceFileCount: collected.length,
893
1163
  maxFileCount: maxGraphFiles,
894
1164
  entryBudgetExceeded,
@@ -897,7 +1167,7 @@ export async function getGraphSourceFiles(cwd) {
897
1167
  const result = [];
898
1168
  let sinceYield = 0;
899
1169
  for (const raw of collected) {
900
- const file = normalizeMapKey(raw);
1170
+ const file = normalizeGraphSourcePath(sourceMemo, raw);
901
1171
  const kind = detectFileKind(file);
902
1172
  // isWithinReviewGraphSizeLimit does a statSync per file — yield periodically
903
1173
  // so the size-limit filter (one stat each) can't hold the loop in one burst.
@@ -917,19 +1187,30 @@ export async function getGraphSourceFiles(cwd) {
917
1187
  }
918
1188
  return {
919
1189
  files: result,
1190
+ pathNormalizeCalls: sourceMemo.normalizeCalls.value - normalizeCallsBefore,
920
1191
  sourceFileCount: result.length,
921
1192
  maxFileCount: maxGraphFiles,
922
1193
  entryBudgetExceeded,
923
1194
  };
924
1195
  }
925
1196
  function addNode(graph, node) {
1197
+ // #2074: keep symbolNodesByFile live here, not only in rebuildIndexes, so the
1198
+ // incremental path can drop its terminal O(graph) reindex. Re-adding an id
1199
+ // already in the map must not push a duplicate — rebuildIndexes starts from
1200
+ // empty maps, so the two producers stay consistent.
1201
+ const isNew = !graph.nodes.has(node.id);
926
1202
  graph.nodes.set(node.id, node);
927
1203
  if (node.kind === "file" && node.filePath) {
928
1204
  graph.fileNodes.set(node.filePath, node.id);
1205
+ return;
1206
+ }
1207
+ if (isNew && node.kind === "symbol" && node.filePath) {
1208
+ const ids = graph.symbolNodesByFile.get(node.filePath) ?? [];
1209
+ ids.push(node.id);
1210
+ graph.symbolNodesByFile.set(node.filePath, ids);
929
1211
  }
930
1212
  }
931
- function addEdge(graph, edge) {
932
- graph.edges.push(edge);
1213
+ function indexEdge(graph, edge) {
933
1214
  const from = graph.edgesByFrom.get(edge.from) ?? [];
934
1215
  from.push(edge);
935
1216
  graph.edgesByFrom.set(edge.from, from);
@@ -937,6 +1218,79 @@ function addEdge(graph, edge) {
937
1218
  to.push(edge);
938
1219
  graph.edgesByTo.set(edge.to, to);
939
1220
  }
1221
+ /**
1222
+ * Drop `edge` from both adjacency buckets. This is kept for the single-edge
1223
+ * dedupe path; multi-file removal uses `unindexEdges` below so a shared hub
1224
+ * bucket is scanned once rather than once per removed edge (#2074).
1225
+ */
1226
+ function unindexEdge(graph, edge) {
1227
+ const from = graph.edgesByFrom.get(edge.from);
1228
+ if (from) {
1229
+ // Count the full bucket as the linear `indexOf` scan's bounded work.
1230
+ _rebuildCounters.removeOwnedEdgePositions += from.length;
1231
+ const at = from.indexOf(edge);
1232
+ if (at >= 0)
1233
+ from.splice(at, 1);
1234
+ if (from.length === 0)
1235
+ graph.edgesByFrom.delete(edge.from);
1236
+ }
1237
+ const to = graph.edgesByTo.get(edge.to);
1238
+ if (to) {
1239
+ _rebuildCounters.removeOwnedEdgePositions += to.length;
1240
+ const at = to.indexOf(edge);
1241
+ if (at >= 0)
1242
+ to.splice(at, 1);
1243
+ if (to.length === 0)
1244
+ graph.edgesByTo.delete(edge.to);
1245
+ }
1246
+ }
1247
+ /**
1248
+ * Remove a batch of edges from both live adjacency indexes. Each touched
1249
+ * bucket is filtered once, so the work is proportional to the bucket lengths,
1250
+ * not to the product of a hub's fan-in and its removed-edge count (#2074).
1251
+ */
1252
+ function unindexEdges(graph, removedEdges) {
1253
+ const fromIds = new Set();
1254
+ const toIds = new Set();
1255
+ for (const edge of removedEdges) {
1256
+ fromIds.add(edge.from);
1257
+ toIds.add(edge.to);
1258
+ }
1259
+ for (const fromId of fromIds) {
1260
+ const bucket = graph.edgesByFrom.get(fromId);
1261
+ if (!bucket)
1262
+ continue;
1263
+ const kept = [];
1264
+ for (const edge of bucket) {
1265
+ _rebuildCounters.removeOwnedEdgePositions++;
1266
+ if (!removedEdges.has(edge))
1267
+ kept.push(edge);
1268
+ }
1269
+ if (kept.length === 0)
1270
+ graph.edgesByFrom.delete(fromId);
1271
+ else
1272
+ graph.edgesByFrom.set(fromId, kept);
1273
+ }
1274
+ for (const toId of toIds) {
1275
+ const bucket = graph.edgesByTo.get(toId);
1276
+ if (!bucket)
1277
+ continue;
1278
+ const kept = [];
1279
+ for (const edge of bucket) {
1280
+ _rebuildCounters.removeOwnedEdgePositions++;
1281
+ if (!removedEdges.has(edge))
1282
+ kept.push(edge);
1283
+ }
1284
+ if (kept.length === 0)
1285
+ graph.edgesByTo.delete(toId);
1286
+ else
1287
+ graph.edgesByTo.set(toId, kept);
1288
+ }
1289
+ }
1290
+ function addEdge(graph, edge) {
1291
+ graph.edges.push(edge);
1292
+ indexEdge(graph, edge);
1293
+ }
940
1294
  function rebuildIndexes(graph) {
941
1295
  graph.edgesByFrom = new Map();
942
1296
  graph.edgesByTo = new Map();
@@ -1191,17 +1545,31 @@ function persistedData(pending) {
1191
1545
  : undefined,
1192
1546
  nodes: Array.from(pending.graph.nodes.entries()),
1193
1547
  edges: pending.graph.edges,
1194
- coverage: pending.graph.persistCoverage,
1548
+ coverage: stripProcessLocalCoverage(pending.graph.persistCoverage),
1195
1549
  gitStamp: pending.gitStamp,
1196
1550
  };
1197
1551
  }
1552
+ /**
1553
+ * Drop coverage fields that are true only of THIS process's graph before the
1554
+ * snapshot is written (#2255 review F5).
1555
+ *
1556
+ * `capTrimmed` means "this process walked every file, then cut for size", which
1557
+ * is what makes a graph a safe incremental base. A snapshot read back in a later
1558
+ * process carries no such guarantee — the tree may have changed underneath it —
1559
+ * so persisting the marker would let a hydrated graph claim base-eligibility
1560
+ * nobody established, the #936 laundering shape. `partial` itself is preserved,
1561
+ * so the snapshot still reports honestly that it is incomplete.
1562
+ */
1563
+ function stripProcessLocalCoverage(coverage) {
1564
+ if (!coverage?.capTrimmed)
1565
+ return coverage;
1566
+ const { capTrimmed: _capTrimmed, ...persisted } = coverage;
1567
+ return persisted;
1568
+ }
1198
1569
  function countRetainedSourceFiles(graph, sourceFilePaths) {
1199
1570
  if (sourceFilePaths === undefined)
1200
1571
  return graph.fileNodes.size;
1201
- const sourceKeys = new Set();
1202
- for (const filePath of sourceFilePaths) {
1203
- sourceKeys.add(normalizeMapKey(filePath));
1204
- }
1572
+ const sourceKeys = new Set(sourceFilePaths);
1205
1573
  let retained = 0;
1206
1574
  for (const filePath of sourceKeys) {
1207
1575
  if (graph.fileNodes.has(filePath))
@@ -1235,13 +1603,52 @@ function capGraphForPersist(cwd, graph, cap, options = {}) {
1235
1603
  const nodeBudget = Math.max(1, Math.floor((cap * graph.nodes.size) / totalElements));
1236
1604
  const reverseDeps = buildReverseDependencyIndexFromGraph({ cwd, graph });
1237
1605
  const rankedFiles = rankFilesByReverseDependencyCentrality(reverseDeps);
1238
- const nodeIdsByFile = new Map();
1239
- for (const [id, node] of graph.nodes) {
1240
- if (!node.filePath)
1241
- continue;
1242
- const ids = nodeIdsByFile.get(node.filePath) ?? [];
1243
- ids.push(id);
1244
- nodeIdsByFile.set(node.filePath, ids);
1606
+ // Both sides of this lookup must agree on path spelling. `rankedFiles` comes
1607
+ // from the reverse-dependency index, which keys by `normalizeMapKey`
1608
+ // (reverse-deps.ts), while this map keys by the RAW `node.filePath`. They agree
1609
+ // because production paths already arrive folded (normalizeGraphSourcePath), so
1610
+ // the raw pass is both correct and free. An unfolded path missed its lookup and
1611
+ // was dropped from the ranking with no signal (#2255 review F1).
1612
+ //
1613
+ // Folding every node up front would fix that but costs a `realpath` probe PER
1614
+ // NODE — measured at 93.5us per node, about 14 seconds at 150k nodes — on the
1615
+ // pre-existing persist cap that every over-cap build already pays. So fold
1616
+ // LAZILY: index raw first, and pay one folding pass only when the raw index
1617
+ // leaves nodes the ranking cannot reach. Reach, not emptiness, is the trigger:
1618
+ // a PARTIAL raw match would otherwise silently under-select the rest.
1619
+ const indexNodesByFile = (fold) => {
1620
+ const byFile = new Map();
1621
+ for (const [id, node] of graph.nodes) {
1622
+ if (!node.filePath)
1623
+ continue;
1624
+ const fileKey = fold ? normalizeMapKey(node.filePath) : node.filePath;
1625
+ const ids = byFile.get(fileKey) ?? [];
1626
+ ids.push(id);
1627
+ byFile.set(fileKey, ids);
1628
+ }
1629
+ return byFile;
1630
+ };
1631
+ // Measure reach in NODES, not files. Counting files is too weak: when only some
1632
+ // of a file's nodes are unfolded, the file still appears covered while its group
1633
+ // is short, and the missing nodes drop out of the ranking with no signal.
1634
+ const reachableNodes = (byFile) => {
1635
+ let reached = 0;
1636
+ for (const filePath of rankedFiles)
1637
+ reached += byFile.get(filePath)?.length ?? 0;
1638
+ return reached;
1639
+ };
1640
+ let placeableNodes = 0;
1641
+ for (const node of graph.nodes.values())
1642
+ if (node.filePath)
1643
+ placeableNodes++;
1644
+ let nodeIdsByFile = indexNodesByFile(false);
1645
+ if (reachableNodes(nodeIdsByFile) < placeableNodes) {
1646
+ const folded = indexNodesByFile(true);
1647
+ // Keep whichever index the ranking can actually reach. Folding repairs
1648
+ // unfolded input; it is never a downgrade for input that was already correct.
1649
+ if (reachableNodes(folded) > reachableNodes(nodeIdsByFile)) {
1650
+ nodeIdsByFile = folded;
1651
+ }
1245
1652
  }
1246
1653
  const keptIds = new Set();
1247
1654
  for (const filePath of rankedFiles) {
@@ -1305,7 +1712,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
1305
1712
  type: "phase",
1306
1713
  phase: "review_graph_persist",
1307
1714
  filePath: pending.cachePath,
1308
- durationMs: stats.serializeMs + stats.writeMs,
1715
+ durationMs: stats.durationMs,
1309
1716
  metadata: { elements: pending.elementCount, ...stats },
1310
1717
  });
1311
1718
  logReviewGraph({
@@ -1313,6 +1720,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
1313
1720
  phase: "persist_succeeded",
1314
1721
  elements: pending.elementCount,
1315
1722
  ...stats,
1723
+ durationMs: stats.durationMs,
1316
1724
  observability: persistObservability(pending, {
1317
1725
  status: "succeeded",
1318
1726
  workerStarted: workerState.started,
@@ -1322,6 +1730,7 @@ function logPersistSuccess(key, pending, stats, workerState = { started: false,
1322
1730
  });
1323
1731
  }
1324
1732
  function writePendingOnMainThread(key, pending, reason, workerState = { started: false, completed: false }) {
1733
+ const persistStarted = performance.now();
1325
1734
  const serializeStarted = performance.now();
1326
1735
  try {
1327
1736
  const json = JSON.stringify(persistedData(pending));
@@ -1339,6 +1748,7 @@ function writePendingOnMainThread(key, pending, reason, workerState = { started:
1339
1748
  gzBytes: gzip.byteLength,
1340
1749
  serializeMs,
1341
1750
  writeMs: performance.now() - writeStarted,
1751
+ durationMs: performance.now() - persistStarted,
1342
1752
  offloaded: false,
1343
1753
  }, {
1344
1754
  started: workerState.started,
@@ -1427,7 +1837,8 @@ function handleWorkerResult(result) {
1427
1837
  result.rawBytes === undefined ||
1428
1838
  result.gzBytes === undefined ||
1429
1839
  result.serializeMs === undefined ||
1430
- result.writeMs === undefined) {
1840
+ result.writeMs === undefined ||
1841
+ result.durationMs === undefined) {
1431
1842
  fs.rm(result.stagePath, { force: true }, () => { });
1432
1843
  writePendingOnMainThread(key, pending, result.error ?? "invalid worker result", {
1433
1844
  started: true,
@@ -1446,6 +1857,7 @@ function handleWorkerResult(result) {
1446
1857
  gzBytes: result.gzBytes,
1447
1858
  serializeMs: result.serializeMs,
1448
1859
  writeMs: result.writeMs,
1860
+ durationMs: result.durationMs,
1449
1861
  offloaded: true,
1450
1862
  }, { started: true, completed: true });
1451
1863
  }
@@ -1937,7 +2349,7 @@ function hashIgnoredIds(ignoredIds) {
1937
2349
  if (ignoredIds === undefined)
1938
2350
  return "unavailable";
1939
2351
  const joined = [...ignoredIds]
1940
- .sort((a, b) => a.localeCompare(b))
2352
+ .sort((a, b) => compareOrdinal(a, b))
1941
2353
  .join("\u0000");
1942
2354
  return createHash("sha256").update(joined).digest("hex");
1943
2355
  }
@@ -2248,9 +2660,16 @@ async function tryResumeFromCheckpoint(cwd, filesToBuild, ignoredIds) {
2248
2660
  }
2249
2661
  const graph = loaded.graph;
2250
2662
  // Evict every stale (content-changed) processed file so its outdated
2251
- // contribution is replaced by a fresh walk below.
2663
+ // contribution is replaced by a fresh walk below. One shared drop set,
2664
+ // compacted once (#2074) — pruneOrphanNonFileNodes reads graph.edges next,
2665
+ // so the compaction must land before it runs.
2666
+ const removedEdges = new Set();
2252
2667
  for (const file of stale)
2253
- removeFileOwnedGraphData(graph, file);
2668
+ removeFileOwnedGraphData(graph, file, removedEdges);
2669
+ if (removedEdges.size > 0) {
2670
+ unindexEdges(graph, removedEdges);
2671
+ graph.edges = graph.edges.filter((edge) => !removedEdges.has(edge));
2672
+ }
2254
2673
  pruneOrphanNonFileNodes(graph);
2255
2674
  graph.changedSymbolsByFile = new Map();
2256
2675
  const remaining = filesToBuild.filter((file) => !reusableHashes.has(file));
@@ -2281,7 +2700,7 @@ export function _readReviewGraphCheckpointForTests(cwd) {
2281
2700
  }
2282
2701
  /** Test hook: force any pending debounced persist to write immediately. */
2283
2702
  export function flushReviewGraphPersistsForTests() {
2284
- for (const key of [..._pendingPersist.keys()]) {
2703
+ for (const key of _pendingPersist.keys()) {
2285
2704
  const pending = _pendingPersist.get(key);
2286
2705
  if (!pending)
2287
2706
  continue;
@@ -2345,7 +2764,7 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
2345
2764
  let pending = _pendingPersist.get(key);
2346
2765
  const removedWorkerRequests = [];
2347
2766
  let selectedWorkerRequest;
2348
- for (const [id, request] of [..._workerRequests]) {
2767
+ for (const [id, request] of _workerRequests) {
2349
2768
  if (request.key !== key)
2350
2769
  continue;
2351
2770
  _workerRequests.delete(id);
@@ -2418,6 +2837,7 @@ export function flushReviewGraphPersist(cwd, source = "cli") {
2418
2837
  logReviewGraph({
2419
2838
  cwd,
2420
2839
  phase: "persist_succeeded",
2840
+ durationMs: performance.now() - startedAt,
2421
2841
  elements: pending.elementCount,
2422
2842
  rawBytes,
2423
2843
  gzBytes: gzip.byteLength,
@@ -2509,7 +2929,7 @@ function upsertChangedSymbols(graph, facts, filePath) {
2509
2929
  if (detectFileRole(filePath) === "test")
2510
2930
  return;
2511
2931
  const normalized = normalizeMapKey(filePath);
2512
- const changed = facts.getSessionFact(`${CHANGED_SYMBOLS_PREFIX}${normalized}`);
2932
+ const changed = facts.getBoundedSessionFact(`${CHANGED_SYMBOLS_PREFIX}${normalized}`);
2513
2933
  if (changed && changed.length > 0) {
2514
2934
  graph.changedSymbolsByFile.set(normalized, [...changed]);
2515
2935
  }
@@ -2784,45 +3204,13 @@ function addJsTsFile(graph, cwd, filePath, facts, ignoredIds) {
2784
3204
  }
2785
3205
  }
2786
3206
  }
2787
- function mapKindToTreeSitterLanguage(kind, filePath) {
2788
- switch (kind) {
2789
- case "python":
2790
- return "python";
2791
- case "go":
2792
- return "go";
2793
- case "rust":
2794
- return "rust";
2795
- case "ruby":
2796
- return "ruby";
2797
- case "cxx": {
2798
- const ext = filePath ? path.extname(filePath).toLowerCase() : "";
2799
- return ext === ".c" || ext === ".h" ? "c" : "cpp";
2800
- }
2801
- case "java":
2802
- return "java";
2803
- case "kotlin":
2804
- return "kotlin";
2805
- case "dart":
2806
- return "dart";
2807
- case "elixir":
2808
- return "elixir";
2809
- case "csharp":
2810
- return "csharp";
2811
- case "php":
2812
- return "php";
2813
- case "swift":
2814
- return "swift";
2815
- case "lua":
2816
- return "lua";
2817
- case "ocaml":
2818
- return "ocaml";
2819
- case "zig":
2820
- return "zig";
2821
- case "shell":
2822
- return "bash";
2823
- default:
2824
- return undefined;
2825
- }
3207
+ // The review graph's kind -> tree-sitter grammar answer. Delegates to the
3208
+ // registry-derived resolver (#2424): this used to be a hand-written switch that
3209
+ // re-derived the `.c`/`.h` split inline and drifted from the ext -> grammar
3210
+ // authority. Exported for the language-identity golden snapshot
3211
+ // (scripts/gen-language-snapshot.mjs).
3212
+ export function mapKindToTreeSitterLanguage(kind, filePath) {
3213
+ return symbolExtractionGrammar(kind, filePath);
2826
3214
  }
2827
3215
  async function getExtractor(languageId) {
2828
3216
  if (extractorCache.has(languageId))
@@ -3248,37 +3636,76 @@ function addCxxIncludeEdges(graph, cwd, filePath, ignoredIds, contentOverride) {
3248
3636
  });
3249
3637
  }
3250
3638
  }
3251
- function removeFileOwnedGraphData(graph, filePath) {
3639
+ /**
3640
+ * Drop the nodes/edges a changed file owns, and index-maintain as it goes.
3641
+ * `removedEdges` accumulates edges to drop from `graph.edges` — the CALLER
3642
+ * compacts the array once for the whole batch (#2074): before this, each call
3643
+ * ran its own `graph.edges.filter()`, so a batch of N changed files scanned
3644
+ * the whole edge array N times (changedFiles x graph, not changedFiles x
3645
+ * fan-in/out).
3646
+ *
3647
+ * `removedIds` is exactly `fileNodes`/`symbolNodesByFile`'s entries for this
3648
+ * file — `file` and `symbol` are the only node kinds that ever set `filePath`
3649
+ * (`module`/`external` placeholders never do) — so no `graph.nodes` scan is
3650
+ * needed either, and the owned edges are read straight off `edgesByFrom` /
3651
+ * `edgesByTo` instead of scanning `graph.edges`.
3652
+ */
3653
+ function removeFileOwnedGraphData(graph, filePath, removedEdges) {
3252
3654
  const normalized = normalizeMapKey(filePath);
3253
- const fileNodeId = `file:${normalized}`;
3254
- const removedIds = new Set();
3255
- const removedSymbolIds = new Set();
3256
- for (const [id, node] of graph.nodes) {
3257
- if (node.filePath !== normalized)
3258
- continue;
3259
- removedIds.add(id);
3260
- if (node.kind === "symbol")
3261
- removedSymbolIds.add(id);
3262
- }
3655
+ const fileNodeId = graph.fileNodes.get(normalized) ?? `file:${normalized}`;
3656
+ const removedSymbolIds = new Set(graph.symbolNodesByFile.get(normalized));
3657
+ const removedIds = new Set(removedSymbolIds);
3263
3658
  if (graph.nodes.has(fileNodeId))
3264
3659
  removedIds.add(fileNodeId);
3660
+ const candidates = new Set();
3661
+ for (const id of removedIds) {
3662
+ for (const edge of graph.edgesByFrom.get(id) ?? []) {
3663
+ _rebuildCounters.removeOwnedEdgeVisits++;
3664
+ candidates.add(edge);
3665
+ }
3666
+ for (const edge of graph.edgesByTo.get(id) ?? []) {
3667
+ _rebuildCounters.removeOwnedEdgeVisits++;
3668
+ candidates.add(edge);
3669
+ }
3670
+ }
3265
3671
  const preservedIncomingSymbolEdges = [];
3266
- graph.edges = graph.edges.filter((edge) => {
3672
+ for (const edge of candidates) {
3673
+ // A cross-edge can be discovered from both changed files' adjacency
3674
+ // buckets. Preserve and remove it only once, matching eager unindexing
3675
+ // while the batch indexes remain live until the end.
3676
+ if (removedEdges.has(edge))
3677
+ continue;
3267
3678
  const fromRemoved = removedIds.has(edge.from);
3268
- const toRemoved = removedIds.has(edge.to);
3269
- if (fromRemoved)
3270
- return false;
3271
- if (removedSymbolIds.has(edge.to)) {
3679
+ // Preserve importer edges to the stable file node id; the node is
3680
+ // re-added below.
3681
+ if (!fromRemoved && edge.to === fileNodeId)
3682
+ continue;
3683
+ if (!fromRemoved && removedSymbolIds.has(edge.to)) {
3272
3684
  preservedIncomingSymbolEdges.push({ ...edge });
3273
- return false;
3274
3685
  }
3275
- // Preserve importer edges to the stable file node id; the node is re-added below.
3276
- if (toRemoved && edge.to === fileNodeId)
3277
- return true;
3278
- return !toRemoved;
3279
- });
3280
- for (const id of removedIds)
3686
+ removedEdges.add(edge);
3687
+ }
3688
+ for (const id of removedIds) {
3689
+ const node = graph.nodes.get(id);
3281
3690
  graph.nodes.delete(id);
3691
+ if (!node?.filePath)
3692
+ continue;
3693
+ if (node.kind === "file") {
3694
+ if (graph.fileNodes.get(node.filePath) === id) {
3695
+ graph.fileNodes.delete(node.filePath);
3696
+ }
3697
+ }
3698
+ else if (node.kind === "symbol") {
3699
+ const ids = graph.symbolNodesByFile.get(node.filePath);
3700
+ if (!ids)
3701
+ continue;
3702
+ const at = ids.indexOf(id);
3703
+ if (at >= 0)
3704
+ ids.splice(at, 1);
3705
+ if (ids.length === 0)
3706
+ graph.symbolNodesByFile.delete(node.filePath);
3707
+ }
3708
+ }
3282
3709
  return preservedIncomingSymbolEdges;
3283
3710
  }
3284
3711
  async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverride) {
@@ -3362,16 +3789,102 @@ async function addFileToGraph(graph, cwd, file, facts, ignoredIds, contentOverri
3362
3789
  addCxxIncludeEdges(graph, cwd, file, ignoredIds, contentOverride);
3363
3790
  }
3364
3791
  }
3792
+ /**
3793
+ * #2074 acceptance instrumentation. `restoreComparisons` counts edge-metadata
3794
+ * stringifications inside `restoreValidIncomingEdges`; `importTargetEdgeScans`
3795
+ * counts edges visited by `importTargetsForFile`; `removeOwnedEdgeVisits`
3796
+ * counts edges visited while collecting a changed file's owned edges in
3797
+ * `removeFileOwnedGraphData`. Before this change all three grew with the whole
3798
+ * graph on every one-file rebuild, and `removeOwnedEdgeVisits` grew with
3799
+ * changedFiles x graph on a multi-file batch (one full `graph.edges` scan per
3800
+ * file). `removeOwnedEdgePositions` counts adjacency positions examined while
3801
+ * removing edges; batching keeps a high-fan-in bucket linear instead of
3802
+ * rescanning its prefix for every edge. They are the count-based signal the
3803
+ * issue asks for, because wall time on this hardware is IO-noisy (#1920).
3804
+ */
3805
+ const _rebuildCounters = {
3806
+ restoreComparisons: 0,
3807
+ importTargetEdgeScans: 0,
3808
+ removeOwnedEdgeVisits: 0,
3809
+ removeOwnedEdgePositions: 0,
3810
+ };
3811
+ export function _getReviewGraphRebuildCountersForTests() {
3812
+ return { ..._rebuildCounters };
3813
+ }
3814
+ export function _resetReviewGraphRebuildCountersForTests() {
3815
+ _rebuildCounters.restoreComparisons = 0;
3816
+ _rebuildCounters.importTargetEdgeScans = 0;
3817
+ _rebuildCounters.removeOwnedEdgeVisits = 0;
3818
+ _rebuildCounters.removeOwnedEdgePositions = 0;
3819
+ }
3820
+ /**
3821
+ * Identity of an edge for dedupe purposes, WITHIN one target bucket: the array
3822
+ * form needs no separator sentinel and cannot collide across fields.
3823
+ */
3824
+ function edgeIdentityKey(edge) {
3825
+ _rebuildCounters.restoreComparisons++;
3826
+ return JSON.stringify([edge.from, edge.kind, edge.metadata ?? {}]);
3827
+ }
3365
3828
  function restoreValidIncomingEdges(graph, edges) {
3366
- const existing = new Set(graph.edges.map((edge) => `${edge.from}\u0000${edge.to}\u0000${edge.kind}\u0000${JSON.stringify(edge.metadata ?? {})}`));
3829
+ // #2074: dedupe per TARGET, using `edgesByTo`, instead of building a key set
3830
+ // over every edge in the graph. Preserved incoming edges point at symbols of
3831
+ // the files just re-extracted, and `removeFileOwnedGraphData` has already
3832
+ // dropped those symbols' incoming edges, so each bucket read here is tiny.
3833
+ // The per-target `Set` keeps the restore linear even when one changed file is
3834
+ // a hub with thousands of preserved incoming edges — scanning the bucket per
3835
+ // edge instead would be quadratic in that fan-in.
3836
+ const seenByTarget = new Map();
3367
3837
  for (const edge of edges) {
3368
3838
  if (!graph.nodes.has(edge.from) || !graph.nodes.has(edge.to))
3369
3839
  continue;
3370
- const key = `${edge.from}\u0000${edge.to}\u0000${edge.kind}\u0000${JSON.stringify(edge.metadata ?? {})}`;
3371
- if (existing.has(key))
3840
+ let seen = seenByTarget.get(edge.to);
3841
+ if (!seen) {
3842
+ seen = new Set();
3843
+ for (const candidate of graph.edgesByTo.get(edge.to) ?? []) {
3844
+ seen.add(edgeIdentityKey(candidate));
3845
+ }
3846
+ seenByTarget.set(edge.to, seen);
3847
+ }
3848
+ const key = edgeIdentityKey(edge);
3849
+ if (seen.has(key))
3372
3850
  continue;
3373
- graph.edges.push(edge);
3374
- existing.add(key);
3851
+ seen.add(key);
3852
+ addEdge(graph, edge);
3853
+ }
3854
+ }
3855
+ /**
3856
+ * Remove duplicates created when deferred targets converge after restoration.
3857
+ * The scan is limited to resolved edges and their target buckets, not the
3858
+ * complete graph, so same-batch repairs remain proportional to the resolved
3859
+ * edges and the fan-in of their target buckets.
3860
+ */
3861
+ function dedupeResolvedEdges(graph, replacements) {
3862
+ const resolvedEdges = new Set(replacements.map(([, after]) => after));
3863
+ const seenByTarget = new Map();
3864
+ const removed = new Set();
3865
+ for (const [, edge] of replacements) {
3866
+ if (removed.has(edge))
3867
+ continue;
3868
+ let seen = seenByTarget.get(edge.to);
3869
+ if (!seen) {
3870
+ seen = new Set();
3871
+ for (const candidate of graph.edgesByTo.get(edge.to) ?? []) {
3872
+ if (!resolvedEdges.has(candidate)) {
3873
+ seen.add(edgeIdentityKey(candidate));
3874
+ }
3875
+ }
3876
+ seenByTarget.set(edge.to, seen);
3877
+ }
3878
+ const key = edgeIdentityKey(edge);
3879
+ if (seen.has(key)) {
3880
+ removed.add(edge);
3881
+ unindexEdge(graph, edge);
3882
+ continue;
3883
+ }
3884
+ seen.add(key);
3885
+ }
3886
+ if (removed.size > 0) {
3887
+ graph.edges = graph.edges.filter((edge) => !removed.has(edge));
3375
3888
  }
3376
3889
  }
3377
3890
  const _graphImportChanges = new WeakMap();
@@ -3383,33 +3896,52 @@ function importTargetsForFile(graph, filePath) {
3383
3896
  const normalized = normalizeMapKey(filePath);
3384
3897
  const fileNodeId = graph.fileNodes.get(normalized) ?? `file:${normalized}`;
3385
3898
  const targets = new Set();
3386
- // Cached graph snapshots intentionally omit derived indexes; read the
3387
- // canonical edge collection so the delta is correct before the first rebuild.
3388
- for (const edge of graph.edges) {
3389
- if (edge.from !== fileNodeId)
3390
- continue;
3899
+ // #2074: read this file's own out-edges from `edgesByFrom` rather than
3900
+ // scanning every edge in the graph. The only caller, `updateGraphFiles`,
3901
+ // indexes the graph before its first call and keeps the indexes live for the
3902
+ // rest of the update, so the bucket is always authoritative here.
3903
+ for (const edge of graph.edgesByFrom.get(fileNodeId) ?? []) {
3904
+ _rebuildCounters.importTargetEdgeScans++;
3391
3905
  if (edge.kind !== "imports")
3392
3906
  continue;
3393
3907
  const target = graph.nodes.get(edge.to)?.filePath;
3394
3908
  if (target)
3395
3909
  targets.add(normalizeMapKey(target));
3396
3910
  }
3397
- return [...targets].sort((a, b) => a.localeCompare(b));
3911
+ return [...targets].sort((a, b) => compareOrdinal(a, b));
3398
3912
  }
3399
3913
  async function updateGraphFiles(graph, cwd, files, facts, ignoredIds) {
3914
+ // #2074: index ONCE, up front, then keep the indexes live through the update.
3915
+ // This is the same single O(graph) pass the terminal `rebuildIndexes` used to
3916
+ // cost, moved to the front, and it buys three things: the two
3917
+ // `importTargetsForFile` calls per changed file become bucket lookups,
3918
+ // `restoreValidIncomingEdges` dedupes against a fan-in bucket instead of the
3919
+ // whole edge list, and `existedBefore` finally reads a POPULATED `fileNodes`.
3920
+ // `cloneGraph` returns empty indexes, so before this the first file's
3921
+ // `existedBefore` was always false on every update path — which forced
3922
+ // `importsChanged` true in `clients/dispatch/integration.ts:1077` and blocked
3923
+ // reverse-dependency index reuse on every incremental build.
3924
+ rebuildIndexes(graph);
3400
3925
  const prior = files.map((file) => ({
3401
3926
  filePath: normalizeMapKey(file),
3402
3927
  existedBefore: graph.fileNodes.has(normalizeMapKey(file)),
3403
3928
  priorTargets: importTargetsForFile(graph, file),
3404
3929
  }));
3405
3930
  const preservedIncoming = [];
3931
+ // #2074: one shared drop set for the whole batch, compacted into graph.edges
3932
+ // exactly once below — not once per file, which made a multi-file rebuild
3933
+ // scan the edge array changedFiles times over.
3934
+ const removedEdges = new Set();
3406
3935
  for (const file of files) {
3407
- preservedIncoming.push(...removeFileOwnedGraphData(graph, file));
3936
+ preservedIncoming.push(...removeFileOwnedGraphData(graph, file, removedEdges));
3408
3937
  await addFileToGraph(graph, cwd, file, facts, ignoredIds);
3409
3938
  }
3939
+ if (removedEdges.size > 0) {
3940
+ unindexEdges(graph, removedEdges);
3941
+ graph.edges = graph.edges.filter((edge) => !removedEdges.has(edge));
3942
+ }
3410
3943
  restoreValidIncomingEdges(graph, preservedIncoming);
3411
3944
  resolveDeferredSymbolEdges(graph, false);
3412
- rebuildIndexes(graph);
3413
3945
  graph.changedSymbolsByFile.clear();
3414
3946
  for (const file of files) {
3415
3947
  upsertChangedSymbols(graph, facts, file);
@@ -3433,6 +3965,12 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
3433
3965
  ids.push(node.id);
3434
3966
  symbolNameToIds.set(node.symbolName, ids);
3435
3967
  }
3968
+ // #2074: this pass REPLACES edge objects, so the adjacency buckets that hold
3969
+ // the old objects go stale. Callers that keep the indexes live (the
3970
+ // incremental path, which no longer reindexes afterwards) need each
3971
+ // replacement patched into the buckets; collect them here rather than paying
3972
+ // a whole-graph reindex for a handful of newly resolved edges.
3973
+ const replacements = [];
3436
3974
  graph.edges = graph.edges.map((edge) => {
3437
3975
  const targetNode = graph.nodes.get(edge.to);
3438
3976
  if (!targetNode?.metadata?.unresolvedName)
@@ -3447,13 +3985,25 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
3447
3985
  if (importHintFile) {
3448
3986
  const scoped = candidates.filter((id) => graph.nodes.get(id)?.filePath === importHintFile);
3449
3987
  if (scoped.length === 1) {
3450
- return { ...edge, to: scoped[0], resolution: "import" };
3988
+ const resolved = {
3989
+ ...edge,
3990
+ to: scoped[0],
3991
+ resolution: "import",
3992
+ };
3993
+ replacements.push([edge, resolved]);
3994
+ return resolved;
3451
3995
  }
3452
3996
  }
3453
3997
  if (candidates.length === 1) {
3454
3998
  // Exactly one same-named real symbol exists graph-wide: the bare-name
3455
3999
  // match is provably unambiguous (refs #655 — resolution confidence).
3456
- return { ...edge, to: candidates[0], resolution: "exact" };
4000
+ const resolved = {
4001
+ ...edge,
4002
+ to: candidates[0],
4003
+ resolution: "exact",
4004
+ };
4005
+ replacements.push([edge, resolved]);
4006
+ return resolved;
3457
4007
  }
3458
4008
  // 0 or 2+ candidates (and no import hint narrowed it): stays on the
3459
4009
  // unresolved placeholder, resolution stays "name-only" (set at edge
@@ -3461,8 +4011,47 @@ function resolveDeferredSymbolEdges(graph, rebuild = true) {
3461
4011
  // confirmed graph node.
3462
4012
  return edge;
3463
4013
  });
3464
- if (rebuild)
4014
+ if (rebuild) {
3465
4015
  rebuildIndexes(graph);
4016
+ return;
4017
+ }
4018
+ if (replacements.length === 0)
4019
+ return;
4020
+ // Bucket ORDER is part of the contract, not just bucket membership:
4021
+ // `resolveUsedBy` in module-report walks `edgesByTo` and truncates at a cap,
4022
+ // so the order decides which callers a reader sees. `rebuildIndexes` orders
4023
+ // every bucket by position in `graph.edges`, and the incremental path must
4024
+ // match it exactly.
4025
+ //
4026
+ // `from` is unchanged by a resolution, so the from-bucket only needs the new
4027
+ // object swapped in at the OLD object's position — order is preserved for
4028
+ // free. `to` moves from the placeholder's bucket to the resolved symbol's,
4029
+ // and appending there would put an early edge behind later ones. Rebuild
4030
+ // exactly the affected to-buckets from `graph.edges`, which restores
4031
+ // canonical order without touching the rest of the index.
4032
+ const affectedTargets = new Set();
4033
+ for (const [before, after] of replacements) {
4034
+ const fromBucket = graph.edgesByFrom.get(before.from);
4035
+ if (fromBucket) {
4036
+ const at = fromBucket.indexOf(before);
4037
+ if (at >= 0)
4038
+ fromBucket[at] = after;
4039
+ }
4040
+ affectedTargets.add(before.to);
4041
+ affectedTargets.add(after.to);
4042
+ }
4043
+ for (const target of affectedTargets)
4044
+ graph.edgesByTo.set(target, []);
4045
+ for (const edge of graph.edges) {
4046
+ if (affectedTargets.has(edge.to))
4047
+ graph.edgesByTo.get(edge.to)?.push(edge);
4048
+ }
4049
+ for (const target of affectedTargets) {
4050
+ if (graph.edgesByTo.get(target)?.length === 0) {
4051
+ graph.edgesByTo.delete(target);
4052
+ }
4053
+ }
4054
+ dedupeResolvedEdges(graph, replacements);
3466
4055
  }
3467
4056
  /**
3468
4057
  * #451: the freshness-provenance fields written onto a workspace cache entry by
@@ -3537,7 +4126,7 @@ async function tryIncrementalFromCache(cached, ctx) {
3537
4126
  graphChanged: false,
3538
4127
  });
3539
4128
  graph.buildGeneration = generation;
3540
- ctx.facts.setSessionFact("session.reviewGraph", graph);
4129
+ setSessionReviewGraphFact(ctx.cwd, ctx.facts, graph);
3541
4130
  return graph;
3542
4131
  }
3543
4132
  // Record content hashes for the newly-added files too, so the next run can
@@ -3577,7 +4166,7 @@ async function tryIncrementalFromCache(cached, ctx) {
3577
4166
  fromGeneration: priorGeneration,
3578
4167
  changes: importChanges,
3579
4168
  });
3580
- ctx.facts.setSessionFact("session.reviewGraph", graph);
4169
+ setSessionReviewGraphFact(ctx.cwd, ctx.facts, graph);
3581
4170
  return graph;
3582
4171
  }
3583
4172
  function hasGraphKindExtension(file) {
@@ -3594,10 +4183,16 @@ function hasGraphKindExtension(file) {
3594
4183
  */
3595
4184
  async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, facts, seqHint, seqAtBuildStart, ignoredIds, cacheEpoch) {
3596
4185
  const cached = _workspaceGraphCache.get(normalizedCwd);
3597
- // Condition 2: need an in-process complete entry that recorded a build seq.
3598
- // A capped or entry-budget-truncated graph is read-only orientation data, not
3599
- // a safe incremental base; force the next build through a complete walk.
3600
- if (cached?.graph.persistCoverage?.partial) {
4186
+ // Condition 2: need an in-process entry that recorded a build seq and whose
4187
+ // SOURCE WALK was complete. An entry-budget-truncated or checkpoint graph has
4188
+ // UNSEEN files, so it is read-only orientation data and must force a complete
4189
+ // walk. A `capTrimmed` graph is a different case: this process walked every
4190
+ // file and then cut the graph for size, so rebuilding from it is safe, and
4191
+ // refusing it puts an over-budget repository on a full walk every single turn
4192
+ // (#2255 review F5). The marker is process-local and never hydrated from disk,
4193
+ // so a snapshot-derived partial still takes the refusal above.
4194
+ const cachedCoverage = cached?.graph.persistCoverage;
4195
+ if (cachedCoverage?.partial && cachedCoverage.capTrimmed !== true) {
3601
4196
  return { fallback: "partial-base" };
3602
4197
  }
3603
4198
  if (!cached || cached.builtAtProjectSeq === undefined) {
@@ -3688,7 +4283,7 @@ async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, fa
3688
4283
  graphChanged: false,
3689
4284
  });
3690
4285
  graph.buildGeneration = generation;
3691
- facts.setSessionFact("session.reviewGraph", graph);
4286
+ setSessionReviewGraphFact(cwd, facts, graph);
3692
4287
  return { graph };
3693
4288
  }
3694
4289
  // Incremental re-extract over exactly the content-changed files. Reuses the
@@ -3746,7 +4341,7 @@ async function trySeqFastpath(cwd, buildId, normalizedCwd, normalizedChanged, fa
3746
4341
  fromGeneration: priorGeneration,
3747
4342
  changes: importChanges,
3748
4343
  });
3749
- facts.setSessionFact("session.reviewGraph", graph);
4344
+ setSessionReviewGraphFact(cwd, facts, graph);
3750
4345
  return { graph };
3751
4346
  }
3752
4347
  async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
@@ -3793,7 +4388,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3793
4388
  // treat as changed so dependents never trust stale derived state.
3794
4389
  graphChanged: true,
3795
4390
  });
3796
- facts.setSessionFact("session.reviewGraph", graph);
4391
+ setSessionReviewGraphFact(cwd, facts, graph);
3797
4392
  return graph;
3798
4393
  }
3799
4394
  // #451: capture the seq BEFORE any await — every builtAtProjectSeq stamp in
@@ -3820,6 +4415,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3820
4415
  const sourceCollection = await getGraphSourceFiles(cwd);
3821
4416
  const filesToBuild = sourceCollection.files;
3822
4417
  const sourceFileCount = sourceCollection.sourceFileCount;
4418
+ const pathNormalizeCalls = sourceCollection.pathNormalizeCalls;
3823
4419
  const sourceFilesTruncated = sourceCollection.entryBudgetExceeded;
3824
4420
  const ignoredIds = await ignoredIdsPromise;
3825
4421
  const maxGraphFiles = sourceCollection.maxFileCount;
@@ -3877,6 +4473,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3877
4473
  sourceFileCount,
3878
4474
  sourceFileCountTruncated: sourceFilesTruncated || filesToBuild.length > maxGraphFiles,
3879
4475
  maxFileCount: maxGraphFiles,
4476
+ pathNormalizeCalls,
3880
4477
  seqFastpathFallback,
3881
4478
  // #459: a fresh empty graph is returned every call on this path (never
3882
4479
  // persisted/reused) — treat it as changed so dependents never trust stale
@@ -3884,7 +4481,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3884
4481
  // with a buildGeneration: absent ⇒ derived caches rebuild every time.
3885
4482
  graphChanged: true,
3886
4483
  });
3887
- facts.setSessionFact("session.reviewGraph", graph);
4484
+ setSessionReviewGraphFact(cwd, facts, graph);
3888
4485
  return graph;
3889
4486
  }
3890
4487
  // #782: the repo is within the cap on this build attempt — drop any
@@ -3905,8 +4502,17 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3905
4502
  // re-persisting it would launder partial coverage onto disk as a complete
3906
4503
  // snapshot (#936 review). Ignore it here — the disk tier rejects a partial
3907
4504
  // base too, so the build falls through to a full rebuild.
3908
- if (memCached?.graph.persistCoverage?.partial)
4505
+ //
4506
+ // `capTrimmed` is the one partial cause this does NOT refuse: this process
4507
+ // walked every file and then cut the graph to the memory budget, so no file is
4508
+ // unaccounted for. Refusing it forced a full walk on every turn for exactly
4509
+ // the repositories the memory bound targets (#2255 review F5). The marker is
4510
+ // process-local and stripped at persist, so a snapshot-hydrated partial —
4511
+ // whose completeness this process cannot vouch for — is still refused.
4512
+ const memCoverage = memCached?.graph.persistCoverage;
4513
+ if (memCoverage?.partial && memCoverage.capTrimmed !== true) {
3909
4514
  memCached = undefined;
4515
+ }
3910
4516
  if (memCached?.signature === signature) {
3911
4517
  touchWorkspaceGraph(normalizedCwd);
3912
4518
  const graph = cloneGraph(memCached.graph);
@@ -3929,7 +4535,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3929
4535
  graphChanged: false,
3930
4536
  });
3931
4537
  graph.buildGeneration = generation;
3932
- facts.setSessionFact("session.reviewGraph", graph);
4538
+ setSessionReviewGraphFact(cwd, facts, graph);
3933
4539
  return graph;
3934
4540
  }
3935
4541
  if (memCached) {
@@ -3984,7 +4590,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3984
4590
  graphChanged: false,
3985
4591
  });
3986
4592
  graph.buildGeneration = generation;
3987
- facts.setSessionFact("session.reviewGraph", graph);
4593
+ setSessionReviewGraphFact(cwd, facts, graph);
3988
4594
  return graph;
3989
4595
  }
3990
4596
  if (diskCached) {
@@ -4143,8 +4749,17 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
4143
4749
  // so change detection does not reread every file after the graph is built.
4144
4750
  // #459: full rebuild ⇒ new generation.
4145
4751
  const generation = ++_graphGenerationCounter;
4146
- const graphSnapshot = cloneGraph(graph);
4147
- rebuildIndexes(graphSnapshot);
4752
+ // #2255 review R1: cap ONCE and let both retention sites share the result. The
4753
+ // cache took a clone while the session fact took the original, two objects a
4754
+ // `cloneGraph` apart, so each ran its own centrality pass and retained its own
4755
+ // trimmed copy — two budgets resident, not one. Over budget the cap already
4756
+ // returns a fresh, fully-indexed graph, so it IS the snapshot and no clone is
4757
+ // needed. In budget nothing is trimmed and the clone happens exactly as before.
4758
+ const retained = retainedGraph(cwd, graph);
4759
+ const wasCapped = retained !== graph;
4760
+ const graphSnapshot = wasCapped ? retained : cloneGraph(graph);
4761
+ if (!wasCapped)
4762
+ rebuildIndexes(graphSnapshot);
4148
4763
  // Keep the content generation on the persisted snapshot instance too, so
4149
4764
  // scheduled persistence logs join the same graph identity as build success.
4150
4765
  graphSnapshot.buildGeneration = generation;
@@ -4169,12 +4784,15 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
4169
4784
  mode: "full",
4170
4785
  sourceFileCount,
4171
4786
  sourceFileCountTruncated: sourceFilesTruncated,
4787
+ pathNormalizeCalls,
4172
4788
  seqFastpathFallback,
4173
4789
  ...(persistReason ? { persistReason } : {}),
4174
4790
  graphChanged: true,
4175
4791
  });
4176
4792
  graph.buildGeneration = generation;
4177
- facts.setSessionFact("session.reviewGraph", graph);
4793
+ // Share the cache's object, so an over-budget build retains ONE trimmed graph
4794
+ // across both sites. In budget this stores the equivalent clone the cache holds.
4795
+ setSessionReviewGraphFact(cwd, facts, graphSnapshot);
4178
4796
  return graph;
4179
4797
  }
4180
4798
  /**
@@ -4196,7 +4814,7 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
4196
4814
  * rather than by coincidence.
4197
4815
  */
4198
4816
  function buildCacheKey(cwd, changedFiles) {
4199
- return `${buildCacheWorkspaceKey(cwd)}|${[...changedFiles].sort((a, b) => a.localeCompare(b)).join(",")}`;
4817
+ return `${buildCacheWorkspaceKey(cwd)}|${[...changedFiles].sort((a, b) => compareOrdinal(a, b)).join(",")}`;
4200
4818
  }
4201
4819
  /**
4202
4820
  * Whether a build for this exact (cwd, changedFiles) key is PENDING right now
@@ -4254,6 +4872,7 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
4254
4872
  mode: buildInfo.mode,
4255
4873
  sourceFileCount: buildInfo.sourceFileCount,
4256
4874
  sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
4875
+ pathNormalizeCalls: buildInfo.pathNormalizeCalls,
4257
4876
  }),
4258
4877
  },
4259
4878
  });
@@ -4276,6 +4895,7 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
4276
4895
  mode: buildInfo.mode,
4277
4896
  sourceFileCount: buildInfo.sourceFileCount,
4278
4897
  sourceFileCountTruncated: buildInfo.sourceFileCountTruncated,
4898
+ pathNormalizeCalls: buildInfo.pathNormalizeCalls,
4279
4899
  }),
4280
4900
  },
4281
4901
  });