gitnexus 1.6.8-rc.9 → 1.6.8

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 (430) hide show
  1. package/README.md +3 -3
  2. package/dist/_shared/graph/types.d.ts +31 -1
  3. package/dist/_shared/graph/types.d.ts.map +1 -1
  4. package/dist/_shared/language-detection.d.ts.map +1 -1
  5. package/dist/_shared/language-detection.js +11 -1
  6. package/dist/_shared/language-detection.js.map +1 -1
  7. package/dist/_shared/lbug/schema-constants.d.ts +1 -1
  8. package/dist/_shared/lbug/schema-constants.d.ts.map +1 -1
  9. package/dist/_shared/lbug/schema-constants.js +6 -0
  10. package/dist/_shared/lbug/schema-constants.js.map +1 -1
  11. package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
  12. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
  13. package/dist/_shared/scope-resolution/symbol-definition.d.ts +6 -0
  14. package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -1
  15. package/dist/cli/ai-context.d.ts +31 -1
  16. package/dist/cli/ai-context.js +18 -16
  17. package/dist/cli/analyze-config.js +11 -0
  18. package/dist/cli/analyze.d.ts +14 -0
  19. package/dist/cli/analyze.js +98 -0
  20. package/dist/cli/embedding-dims.d.ts +33 -0
  21. package/dist/cli/embedding-dims.js +39 -0
  22. package/dist/cli/eval-server.d.ts +8 -0
  23. package/dist/cli/eval-server.js +242 -2
  24. package/dist/cli/help-i18n.js +19 -0
  25. package/dist/cli/i18n/en.d.ts +15 -1
  26. package/dist/cli/i18n/en.js +15 -1
  27. package/dist/cli/i18n/resources.d.ts +29 -1
  28. package/dist/cli/i18n/zh-CN.d.ts +14 -0
  29. package/dist/cli/i18n/zh-CN.js +15 -1
  30. package/dist/cli/index.js +89 -1
  31. package/dist/cli/mcp.d.ts +6 -1
  32. package/dist/cli/mcp.js +26 -1
  33. package/dist/cli/optional-grammars.d.ts +6 -8
  34. package/dist/cli/optional-grammars.js +8 -11
  35. package/dist/cli/setup.d.ts +3 -1
  36. package/dist/cli/setup.js +67 -17
  37. package/dist/cli/skill-gen.js +2 -1
  38. package/dist/cli/tool.d.ts +19 -1
  39. package/dist/cli/tool.js +96 -3
  40. package/dist/core/embeddings/hf-env.d.ts +0 -66
  41. package/dist/core/embeddings/http-client.d.ts +7 -0
  42. package/dist/core/embeddings/http-client.js +5 -3
  43. package/dist/core/embeddings/server-mapping.d.ts +1 -1
  44. package/dist/core/embeddings/server-mapping.js +7 -3
  45. package/dist/core/graph/import-cycles.d.ts +10 -0
  46. package/dist/core/graph/import-cycles.js +103 -0
  47. package/dist/core/group/cross-impact.d.ts +2 -1
  48. package/dist/core/group/cross-impact.js +5 -1
  49. package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
  50. package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
  51. package/dist/core/group/extractors/http-route-extractor.d.ts +24 -0
  52. package/dist/core/group/extractors/http-route-extractor.js +22 -4
  53. package/dist/core/group/extractors/include-extractor.js +22 -13
  54. package/dist/core/group/sync.d.ts +27 -1
  55. package/dist/core/group/sync.js +0 -0
  56. package/dist/core/incremental/subgraph-extract.js +18 -1
  57. package/dist/core/ingestion/cfg/cfg-builder.d.ts +114 -0
  58. package/dist/core/ingestion/cfg/cfg-builder.js +196 -0
  59. package/dist/core/ingestion/cfg/collect.d.ts +51 -0
  60. package/dist/core/ingestion/cfg/collect.js +77 -0
  61. package/dist/core/ingestion/cfg/control-dependence.d.ts +58 -0
  62. package/dist/core/ingestion/cfg/control-dependence.js +182 -0
  63. package/dist/core/ingestion/cfg/control-flow-context.d.ts +106 -0
  64. package/dist/core/ingestion/cfg/control-flow-context.js +124 -0
  65. package/dist/core/ingestion/cfg/emit.d.ts +293 -0
  66. package/dist/core/ingestion/cfg/emit.js +646 -0
  67. package/dist/core/ingestion/cfg/post-dominators.d.ts +79 -0
  68. package/dist/core/ingestion/cfg/post-dominators.js +176 -0
  69. package/dist/core/ingestion/cfg/reaching-def-reason-codec.d.ts +106 -0
  70. package/dist/core/ingestion/cfg/reaching-def-reason-codec.js +133 -0
  71. package/dist/core/ingestion/cfg/reaching-defs-graph.d.ts +18 -0
  72. package/dist/core/ingestion/cfg/reaching-defs-graph.js +312 -0
  73. package/dist/core/ingestion/cfg/reaching-defs.d.ts +162 -0
  74. package/dist/core/ingestion/cfg/reaching-defs.js +785 -0
  75. package/dist/core/ingestion/cfg/synthetic-escape.d.ts +124 -0
  76. package/dist/core/ingestion/cfg/synthetic-escape.js +300 -0
  77. package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
  78. package/dist/core/ingestion/cfg/traversal-result.js +2 -0
  79. package/dist/core/ingestion/cfg/types.d.ts +274 -0
  80. package/dist/core/ingestion/cfg/types.js +13 -0
  81. package/dist/core/ingestion/cfg/visitors/c-cpp-harvest.d.ts +111 -0
  82. package/dist/core/ingestion/cfg/visitors/c-cpp-harvest.js +541 -0
  83. package/dist/core/ingestion/cfg/visitors/c-cpp.d.ts +74 -0
  84. package/dist/core/ingestion/cfg/visitors/c-cpp.js +584 -0
  85. package/dist/core/ingestion/cfg/visitors/call-site-harvest.d.ts +195 -0
  86. package/dist/core/ingestion/cfg/visitors/call-site-harvest.js +350 -0
  87. package/dist/core/ingestion/cfg/visitors/csharp-harvest.d.ts +124 -0
  88. package/dist/core/ingestion/cfg/visitors/csharp-harvest.js +589 -0
  89. package/dist/core/ingestion/cfg/visitors/csharp.d.ts +87 -0
  90. package/dist/core/ingestion/cfg/visitors/csharp.js +867 -0
  91. package/dist/core/ingestion/cfg/visitors/dart-harvest.d.ts +278 -0
  92. package/dist/core/ingestion/cfg/visitors/dart-harvest.js +870 -0
  93. package/dist/core/ingestion/cfg/visitors/dart.d.ts +119 -0
  94. package/dist/core/ingestion/cfg/visitors/dart.js +836 -0
  95. package/dist/core/ingestion/cfg/visitors/go-harvest.d.ts +160 -0
  96. package/dist/core/ingestion/cfg/visitors/go-harvest.js +621 -0
  97. package/dist/core/ingestion/cfg/visitors/go.d.ts +108 -0
  98. package/dist/core/ingestion/cfg/visitors/go.js +638 -0
  99. package/dist/core/ingestion/cfg/visitors/java-harvest.d.ts +108 -0
  100. package/dist/core/ingestion/cfg/visitors/java-harvest.js +513 -0
  101. package/dist/core/ingestion/cfg/visitors/java.d.ts +101 -0
  102. package/dist/core/ingestion/cfg/visitors/java.js +812 -0
  103. package/dist/core/ingestion/cfg/visitors/kotlin-harvest.d.ts +253 -0
  104. package/dist/core/ingestion/cfg/visitors/kotlin-harvest.js +719 -0
  105. package/dist/core/ingestion/cfg/visitors/kotlin.d.ts +121 -0
  106. package/dist/core/ingestion/cfg/visitors/kotlin.js +809 -0
  107. package/dist/core/ingestion/cfg/visitors/php-harvest.d.ts +172 -0
  108. package/dist/core/ingestion/cfg/visitors/php-harvest.js +626 -0
  109. package/dist/core/ingestion/cfg/visitors/php.d.ts +96 -0
  110. package/dist/core/ingestion/cfg/visitors/php.js +721 -0
  111. package/dist/core/ingestion/cfg/visitors/python-harvest.d.ts +212 -0
  112. package/dist/core/ingestion/cfg/visitors/python-harvest.js +739 -0
  113. package/dist/core/ingestion/cfg/visitors/python.d.ts +103 -0
  114. package/dist/core/ingestion/cfg/visitors/python.js +558 -0
  115. package/dist/core/ingestion/cfg/visitors/ruby-harvest.d.ts +223 -0
  116. package/dist/core/ingestion/cfg/visitors/ruby-harvest.js +587 -0
  117. package/dist/core/ingestion/cfg/visitors/ruby.d.ts +112 -0
  118. package/dist/core/ingestion/cfg/visitors/ruby.js +756 -0
  119. package/dist/core/ingestion/cfg/visitors/rust-harvest.d.ts +324 -0
  120. package/dist/core/ingestion/cfg/visitors/rust-harvest.js +873 -0
  121. package/dist/core/ingestion/cfg/visitors/rust.d.ts +95 -0
  122. package/dist/core/ingestion/cfg/visitors/rust.js +558 -0
  123. package/dist/core/ingestion/cfg/visitors/scope-tree-harvest.d.ts +87 -0
  124. package/dist/core/ingestion/cfg/visitors/scope-tree-harvest.js +116 -0
  125. package/dist/core/ingestion/cfg/visitors/swift-harvest.d.ts +255 -0
  126. package/dist/core/ingestion/cfg/visitors/swift-harvest.js +679 -0
  127. package/dist/core/ingestion/cfg/visitors/swift.d.ts +116 -0
  128. package/dist/core/ingestion/cfg/visitors/swift.js +787 -0
  129. package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +149 -0
  130. package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +1056 -0
  131. package/dist/core/ingestion/cfg/visitors/typescript.d.ts +66 -0
  132. package/dist/core/ingestion/cfg/visitors/typescript.js +583 -0
  133. package/dist/core/ingestion/import-resolvers/utils.js +2 -0
  134. package/dist/core/ingestion/language-provider.d.ts +11 -0
  135. package/dist/core/ingestion/languages/c-cpp.js +6 -1
  136. package/dist/core/ingestion/languages/cobol.js +4 -0
  137. package/dist/core/ingestion/languages/cpp/arity-metadata.js +41 -2
  138. package/dist/core/ingestion/languages/cpp/captures.js +50 -1
  139. package/dist/core/ingestion/languages/cpp/conversion-rank.d.ts +2 -0
  140. package/dist/core/ingestion/languages/cpp/conversion-rank.js +89 -0
  141. package/dist/core/ingestion/languages/cpp/header-scan.d.ts +1 -1
  142. package/dist/core/ingestion/languages/cpp/header-scan.js +2 -2
  143. package/dist/core/ingestion/languages/cpp/inline-namespaces.js +7 -2
  144. package/dist/core/ingestion/languages/cpp/member-lookup.js +2 -1
  145. package/dist/core/ingestion/languages/cpp/query.js +23 -0
  146. package/dist/core/ingestion/languages/cpp/scope-resolver.js +2 -1
  147. package/dist/core/ingestion/languages/csharp.js +2 -0
  148. package/dist/core/ingestion/languages/dart.js +2 -0
  149. package/dist/core/ingestion/languages/go.js +2 -0
  150. package/dist/core/ingestion/languages/java.js +3 -0
  151. package/dist/core/ingestion/languages/kotlin/query.js +3 -2
  152. package/dist/core/ingestion/languages/kotlin.js +3 -0
  153. package/dist/core/ingestion/languages/php.js +2 -0
  154. package/dist/core/ingestion/languages/python.js +2 -0
  155. package/dist/core/ingestion/languages/ruby.js +2 -0
  156. package/dist/core/ingestion/languages/rust.js +2 -0
  157. package/dist/core/ingestion/languages/swift.js +2 -0
  158. package/dist/core/ingestion/languages/typescript.js +5 -0
  159. package/dist/core/ingestion/languages/vue.js +6 -0
  160. package/dist/core/ingestion/method-extractors/configs/c-cpp.js +6 -13
  161. package/dist/core/ingestion/method-extractors/generic.js +1 -0
  162. package/dist/core/ingestion/method-types.d.ts +2 -0
  163. package/dist/core/ingestion/model/symbol-table.d.ts +1 -0
  164. package/dist/core/ingestion/model/symbol-table.js +1 -0
  165. package/dist/core/ingestion/mro-processor.js +109 -0
  166. package/dist/core/ingestion/parsing-processor.js +23 -0
  167. package/dist/core/ingestion/pipeline-phases/call-summaries.d.ts +26 -0
  168. package/dist/core/ingestion/pipeline-phases/call-summaries.js +46 -0
  169. package/dist/core/ingestion/pipeline-phases/index.d.ts +2 -0
  170. package/dist/core/ingestion/pipeline-phases/index.js +2 -0
  171. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +0 -21
  172. package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
  173. package/dist/core/ingestion/pipeline-phases/routes.d.ts +10 -0
  174. package/dist/core/ingestion/pipeline-phases/routes.js +30 -1
  175. package/dist/core/ingestion/pipeline-phases/taint-summaries.d.ts +30 -0
  176. package/dist/core/ingestion/pipeline-phases/taint-summaries.js +81 -0
  177. package/dist/core/ingestion/pipeline.d.ts +92 -10
  178. package/dist/core/ingestion/pipeline.js +11 -2
  179. package/dist/core/ingestion/scope-extractor.js +12 -2
  180. package/dist/core/ingestion/scope-resolution/contract/scope-resolver.d.ts +9 -0
  181. package/dist/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.d.ts +75 -0
  182. package/dist/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.js +67 -0
  183. package/dist/core/ingestion/scope-resolution/graph-bridge/edges.d.ts +12 -2
  184. package/dist/core/ingestion/scope-resolution/graph-bridge/edges.js +14 -2
  185. package/dist/core/ingestion/scope-resolution/graph-bridge/references-to-edges.d.ts +6 -1
  186. package/dist/core/ingestion/scope-resolution/graph-bridge/references-to-edges.js +13 -1
  187. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.d.ts +9 -1
  188. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +28 -2
  189. package/dist/core/ingestion/scope-resolution/passes/overload-narrowing.d.ts +4 -0
  190. package/dist/core/ingestion/scope-resolution/passes/overload-narrowing.js +9 -0
  191. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.d.ts +6 -1
  192. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +174 -40
  193. package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +24 -0
  194. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +276 -190
  195. package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +43 -3
  196. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +75 -0
  197. package/dist/core/ingestion/scope-resolution/pipeline/run.js +349 -1
  198. package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
  199. package/dist/core/ingestion/taint/call-summary-codec.d.ts +75 -0
  200. package/dist/core/ingestion/taint/call-summary-codec.js +128 -0
  201. package/dist/core/ingestion/taint/call-summary-emit.d.ts +43 -0
  202. package/dist/core/ingestion/taint/call-summary-emit.js +70 -0
  203. package/dist/core/ingestion/taint/call-summary-harvest.d.ts +77 -0
  204. package/dist/core/ingestion/taint/call-summary-harvest.js +179 -0
  205. package/dist/core/ingestion/taint/call-summary-model.d.ts +47 -0
  206. package/dist/core/ingestion/taint/call-summary-model.js +25 -0
  207. package/dist/core/ingestion/taint/emit.d.ts +125 -0
  208. package/dist/core/ingestion/taint/emit.js +208 -0
  209. package/dist/core/ingestion/taint/interproc-emit.d.ts +50 -0
  210. package/dist/core/ingestion/taint/interproc-emit.js +88 -0
  211. package/dist/core/ingestion/taint/interproc-solver.d.ts +110 -0
  212. package/dist/core/ingestion/taint/interproc-solver.js +312 -0
  213. package/dist/core/ingestion/taint/match.d.ts +153 -0
  214. package/dist/core/ingestion/taint/match.js +278 -0
  215. package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
  216. package/dist/core/ingestion/taint/path-codec.js +190 -0
  217. package/dist/core/ingestion/taint/propagate.d.ts +216 -0
  218. package/dist/core/ingestion/taint/propagate.js +659 -0
  219. package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
  220. package/dist/core/ingestion/taint/site-safety.js +98 -0
  221. package/dist/core/ingestion/taint/source-sink-config.d.ts +107 -23
  222. package/dist/core/ingestion/taint/source-sink-config.js +35 -12
  223. package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
  224. package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
  225. package/dist/core/ingestion/taint/summary-harvest-driver.d.ts +80 -0
  226. package/dist/core/ingestion/taint/summary-harvest-driver.js +179 -0
  227. package/dist/core/ingestion/taint/summary-harvest.d.ts +89 -0
  228. package/dist/core/ingestion/taint/summary-harvest.js +537 -0
  229. package/dist/core/ingestion/taint/summary-model.d.ts +200 -0
  230. package/dist/core/ingestion/taint/summary-model.js +86 -0
  231. package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
  232. package/dist/core/ingestion/taint/typescript-model.js +102 -0
  233. package/dist/core/ingestion/type-extractors/c-cpp.js +21 -0
  234. package/dist/core/ingestion/utils/env.d.ts +7 -0
  235. package/dist/core/ingestion/utils/env.js +12 -0
  236. package/dist/core/ingestion/utils/method-props.js +1 -0
  237. package/dist/core/ingestion/workers/parse-worker.d.ts +13 -0
  238. package/dist/core/ingestion/workers/parse-worker.js +76 -17
  239. package/dist/core/ingestion/workers/worker-pool.d.ts +9 -40
  240. package/dist/core/ingestion/workers/worker-pool.js +15 -2
  241. package/dist/core/lbug/csv-generator.d.ts +51 -3
  242. package/dist/core/lbug/csv-generator.js +369 -255
  243. package/dist/core/lbug/lbug-adapter.d.ts +58 -18
  244. package/dist/core/lbug/lbug-adapter.js +346 -84
  245. package/dist/core/lbug/lbug-config.d.ts +19 -0
  246. package/dist/core/lbug/lbug-config.js +29 -0
  247. package/dist/core/lbug/pdg-emit-sink.d.ts +127 -0
  248. package/dist/core/lbug/pdg-emit-sink.js +358 -0
  249. package/dist/core/lbug/pool-adapter.d.ts +35 -0
  250. package/dist/core/lbug/pool-adapter.js +90 -1
  251. package/dist/core/lbug/rel-pair-routing.d.ts +54 -0
  252. package/dist/core/lbug/rel-pair-routing.js +140 -0
  253. package/dist/core/lbug/schema.d.ts +2 -2
  254. package/dist/core/lbug/schema.js +3 -0
  255. package/dist/core/logger.d.ts +0 -5
  256. package/dist/core/run-analyze.d.ts +96 -0
  257. package/dist/core/run-analyze.js +207 -3
  258. package/dist/core/tree-sitter/parser-loader.js +5 -4
  259. package/dist/core/tree-sitter/safe-parse.d.ts +0 -5
  260. package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
  261. package/dist/core/tree-sitter/vendored-grammars.js +57 -0
  262. package/dist/core/wiki/generator.js +3 -1
  263. package/dist/core/wiki/graph-queries.d.ts +6 -0
  264. package/dist/core/wiki/graph-queries.js +9 -1
  265. package/dist/mcp/http-transport.d.ts +134 -0
  266. package/dist/mcp/http-transport.js +503 -0
  267. package/dist/mcp/local/local-backend.d.ts +110 -24
  268. package/dist/mcp/local/local-backend.js +1225 -19
  269. package/dist/mcp/local/pdg-impact.d.ts +384 -0
  270. package/dist/mcp/local/pdg-impact.js +1972 -0
  271. package/dist/mcp/resources.js +13 -1
  272. package/dist/mcp/tools.d.ts +12 -0
  273. package/dist/mcp/tools.js +214 -7
  274. package/dist/server/analyze-launch.d.ts +29 -0
  275. package/dist/server/analyze-launch.js +137 -0
  276. package/dist/server/analyze-upload.d.ts +33 -0
  277. package/dist/server/analyze-upload.js +123 -0
  278. package/dist/server/analyze-worker.d.ts +17 -1
  279. package/dist/server/api.d.ts +17 -0
  280. package/dist/server/api.js +132 -168
  281. package/dist/server/git-clone.d.ts +43 -2
  282. package/dist/server/git-clone.js +211 -16
  283. package/dist/server/mcp-http.d.ts +5 -6
  284. package/dist/server/mcp-http.js +9 -82
  285. package/dist/server/middleware.d.ts +40 -0
  286. package/dist/server/middleware.js +92 -0
  287. package/dist/server/private-ip.d.ts +1 -0
  288. package/dist/server/private-ip.js +15 -0
  289. package/dist/server/upload-ingest.d.ts +56 -0
  290. package/dist/server/upload-ingest.js +276 -0
  291. package/dist/server/upload-paths.d.ts +37 -0
  292. package/dist/server/upload-paths.js +57 -0
  293. package/dist/server/upload-sweep.d.ts +20 -0
  294. package/dist/server/upload-sweep.js +57 -0
  295. package/dist/storage/git.js +5 -8
  296. package/dist/storage/parse-cache.d.ts +22 -1
  297. package/dist/storage/parse-cache.js +41 -10
  298. package/dist/storage/repo-manager.d.ts +121 -10
  299. package/dist/storage/repo-manager.js +43 -5
  300. package/dist/types/pipeline.d.ts +9 -0
  301. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +110 -10
  302. package/hooks/claude/gitnexus-hook.cjs +110 -13
  303. package/hooks/claude/hook-db-lock-probe.cjs +513 -26
  304. package/package.json +8 -2
  305. package/scripts/assert-publish-grammar-coverage.cjs +31 -0
  306. package/scripts/build-tree-sitter-grammars.cjs +16 -10
  307. package/scripts/cross-platform-tests.ts +1 -0
  308. package/skills/gitnexus-debugging.md +16 -4
  309. package/skills/gitnexus-exploring.md +3 -3
  310. package/skills/gitnexus-guide.md +33 -0
  311. package/skills/gitnexus-pdg-query.md +89 -0
  312. package/skills/gitnexus-refactoring.md +1 -1
  313. package/skills/gitnexus-taint-analysis.md +178 -0
  314. package/vendor/tree-sitter-c/package.json +1 -1
  315. package/vendor/tree-sitter-dart/package.json +1 -1
  316. package/vendor/tree-sitter-kotlin/package.json +1 -1
  317. package/vendor/tree-sitter-proto/package.json +1 -1
  318. package/vendor/tree-sitter-swift/package.json +1 -1
  319. package/web/assets/{agent-CKPMqImC.js → agent-D3Xd7zZE.js} +123 -123
  320. package/web/assets/architecture-7EHR7CIX-6QZW5X65-DxJw65fT.js +1 -0
  321. package/web/assets/architectureDiagram-UL44E2DR-ChH9xW-N.js +36 -0
  322. package/web/assets/{blockDiagram-7IZFK4PR-B4niSLvR.js → blockDiagram-7IZFK4PR-BkWhpzWB.js} +1 -1
  323. package/web/assets/{c4Diagram-Y2BXMSZH-DIzEXalR.js → c4Diagram-Y2BXMSZH-DtBvpzWD.js} +1 -1
  324. package/web/assets/{chunk-3SSMPTDK-BzGeeheI.js → chunk-3SSMPTDK-B4_etUhr.js} +2 -2
  325. package/web/assets/{chunk-5IMINLNL-CnTLsXIV.js → chunk-5IMINLNL-DOdTeQri.js} +1 -1
  326. package/web/assets/{chunk-5VCL7Z4A-w1BCx98Z.js → chunk-5VCL7Z4A-CQ-2dYWj.js} +1 -1
  327. package/web/assets/chunk-6764PJDD-DpIS7hxD.js +1 -0
  328. package/web/assets/{chunk-67TQ5CYL-CChC2cAv.js → chunk-67TQ5CYL-4sNPJU1q.js} +3 -3
  329. package/web/assets/{chunk-7W6UQGC5-CgFyfZ6k.js → chunk-7W6UQGC5-KyEG0HQg.js} +1 -1
  330. package/web/assets/{chunk-AZZRMDJM-5E9ARdDQ.js → chunk-AZZRMDJM-y-xJBuMz.js} +1 -1
  331. package/web/assets/{chunk-INKRHTLW-ELpfuk9h.js → chunk-INKRHTLW-Nu4ri9P-.js} +1 -1
  332. package/web/assets/{chunk-JQRUD6KW-Dcq_Qnyy.js → chunk-JQRUD6KW-Dbva2Z17.js} +1 -1
  333. package/web/assets/{chunk-KGFNY3KK-W-VR57xb.js → chunk-KGFNY3KK-JGMJvKCK.js} +1 -1
  334. package/web/assets/chunk-KGYTTC2M-CWC_c3H9.js +161 -0
  335. package/web/assets/{chunk-KNLZD3CH-BuZBOcso.js → chunk-KNLZD3CH-BGlP6X9R.js} +1 -1
  336. package/web/assets/{chunk-KRXBNO2N-DH2NPM7I.js → chunk-KRXBNO2N-BW5o0KUN.js} +1 -1
  337. package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-zoomRtWX.js} +1 -1
  338. package/web/assets/{chunk-LII3EMHJ-3X33tCpU.js → chunk-LII3EMHJ-D-jm-dLa.js} +1 -1
  339. package/web/assets/{chunk-QA3QBVWF-D5Ha3GgB.js → chunk-QA3QBVWF-BgRW3SXd.js} +1 -1
  340. package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-DD0B2N2B.js} +1 -1
  341. package/web/assets/{chunk-T2UQINTJ-kGYG7ppP.js → chunk-T2UQINTJ-DSBqkzH7.js} +1 -1
  342. package/web/assets/chunk-T5OCTHI4-DdZvN-9i.js +1 -0
  343. package/web/assets/{chunk-UY5QBCOK-Du-c56gs.js → chunk-UY5QBCOK-DyF0vyGd.js} +1 -1
  344. package/web/assets/{chunk-W44A43WB-lrCpuupA.js → chunk-W44A43WB-JqMLIpR0.js} +1 -1
  345. package/web/assets/{chunk-ZXARS5L4-CSZRiyOf.js → chunk-ZXARS5L4-CxN8oiwI.js} +1 -1
  346. package/web/assets/classDiagram-KGZ6W3CR-DJm9BgF-.js +1 -0
  347. package/web/assets/classDiagram-v2-72OJOZXJ-DJm9BgF-.js +1 -0
  348. package/web/assets/{context-builder-_HS0v2ma.js → context-builder-BHiFUA8O.js} +10 -2
  349. package/web/assets/{cose-bilkent-UX7MHV2Q-BHv2bbtl.js → cose-bilkent-UX7MHV2Q-D71wNYRJ.js} +1 -1
  350. package/web/assets/dagre-ND4H6XIP-BIkxmhcu.js +4 -0
  351. package/web/assets/diagram-3NCE3AQN-BTa6iH0T.js +43 -0
  352. package/web/assets/diagram-GF46GFSD-DZDV0vBj.js +24 -0
  353. package/web/assets/{diagram-HNR7UZ2L-fUWreZzx.js → diagram-HNR7UZ2L-DFCfd5LI.js} +2 -2
  354. package/web/assets/diagram-QXG6HAR7-BzM--Jj0.js +24 -0
  355. package/web/assets/diagram-WEQXMOUZ-DFFwz0k4.js +10 -0
  356. package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-CfHMJJqo.js} +1 -1
  357. package/web/assets/eventmodeling-FCH6USID-MREXMVOE-BQm9QzEa.js +1 -0
  358. package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-Be805QiJ.js} +3 -3
  359. package/web/assets/{ganttDiagram-JCBTUEKG-BHQssTz6.js → ganttDiagram-JCBTUEKG-D3iL2Aet.js} +1 -1
  360. package/web/assets/gitGraph-WXDBUCRP-R675I2BI-Cr_Bm2Nb.js +1 -0
  361. package/web/assets/gitGraphDiagram-S2ZK5IYY-CPIDgxGm.js +106 -0
  362. package/web/assets/index-BvYev1UJ.js +635 -0
  363. package/web/assets/index-_lgn7hs5.css +2 -0
  364. package/web/assets/info-J43DQDTF-KCYPFFUO-Cs4p2oyk.js +1 -0
  365. package/web/assets/infoDiagram-3YFTVSEB-Y7BKSoke.js +2 -0
  366. package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CT4SHFuI.js} +1 -1
  367. package/web/assets/{journeyDiagram-M6C3CM3L-DNPYCpvq.js → journeyDiagram-M6C3CM3L-CvArszLo.js} +1 -1
  368. package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-C6yDxr7N.js} +3 -3
  369. package/web/assets/{katex-K3KEBU37-CrVLz8l7.js → katex-K3KEBU37-CTc5BslQ.js} +1 -1
  370. package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-BOc2WEne.js} +1 -1
  371. package/web/assets/packet-YPE3B663-LP52Z2RK-CKCrztD2.js +1 -0
  372. package/web/assets/pie-LRSECV5Y-TCRJHUBD-BB7pHqoD.js +1 -0
  373. package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-Cvc0f_Pd.js} +1 -1
  374. package/web/assets/{quadrantDiagram-VICAPDV7-bAm6Hi_5.js → quadrantDiagram-VICAPDV7-yXSZ2lLz.js} +1 -1
  375. package/web/assets/radar-GUYGQ44K-RDLRG3WG-DhoTOcuK.js +1 -0
  376. package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-BRcZvHs4.js} +1 -1
  377. package/web/assets/{sankeyDiagram-URQDO5SZ-LFJz53u5.js → sankeyDiagram-URQDO5SZ-1qEjlFIB.js} +1 -1
  378. package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-PcSCuvrB.js} +3 -3
  379. package/web/assets/stateDiagram-7D4R322I-D6qYSV1E.js +1 -0
  380. package/web/assets/stateDiagram-v2-36443NZ5-Bg-ImOSX.js +1 -0
  381. package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-QWkh01xw.js} +1 -1
  382. package/web/assets/treeView-BLDUP644-QA4HXRO3-ODehyGKL.js +1 -0
  383. package/web/assets/treemap-LRROVOQU-LLAWBHMP-BfpgC-7o.js +1 -0
  384. package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-ak4DfwuM.js} +3 -3
  385. package/web/assets/wardley-L42UT6IY-5TKZOOLJ-CNeL6VHE.js +1 -0
  386. package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-CTzR4Loq.js} +1 -1
  387. package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-DA1BEaQz.js} +1 -1
  388. package/web/index.html +13 -15
  389. package/scripts/materialize-vendor-grammars.cjs +0 -97
  390. package/web/assets/architecture-7EHR7CIX-6QZW5X65-BETj2x4g.js +0 -1
  391. package/web/assets/architectureDiagram-UL44E2DR-D1EXI0zA.js +0 -36
  392. package/web/assets/chunk-2T2R6R2M-n6s9JZqv.js +0 -4
  393. package/web/assets/chunk-2UTLFMKG-CMBB1TMN.js +0 -1
  394. package/web/assets/chunk-4R4BOZG6-mKhL59ul.js +0 -159
  395. package/web/assets/chunk-6764PJDD-ChwMM2z4.js +0 -1
  396. package/web/assets/chunk-7J6CGLKN-DT-b53FT.js +0 -10
  397. package/web/assets/chunk-C62D2QBJ-CDAWj26E.js +0 -1
  398. package/web/assets/chunk-CEXFNPSA-D68Tk6ls.js +0 -1
  399. package/web/assets/chunk-CilyBKbf.js +0 -1
  400. package/web/assets/chunk-J5EP6P6S-DxWW0yvu.js +0 -1
  401. package/web/assets/chunk-KGYTTC2M-C6PHeuay.js +0 -1
  402. package/web/assets/chunk-RERM46MO-DQNbXtfw.js +0 -1
  403. package/web/assets/chunk-RKZBBQEN-BmTPLSyv.js +0 -1
  404. package/web/assets/chunk-RLI5ZMPA-DExu2DOK.js +0 -1
  405. package/web/assets/chunk-T5OCTHI4--9wWpVws.js +0 -1
  406. package/web/assets/chunk-UP6H54XL-DsKdC6jC.js +0 -1
  407. package/web/assets/chunk-UXSXWOXI-DR81EqLr.js +0 -1
  408. package/web/assets/classDiagram-KGZ6W3CR-Bv93af_b.js +0 -1
  409. package/web/assets/classDiagram-v2-72OJOZXJ-CLfEqHUa.js +0 -1
  410. package/web/assets/dagre-ND4H6XIP-DsIr9MOv.js +0 -4
  411. package/web/assets/diagram-3NCE3AQN-BqAtKUpW.js +0 -43
  412. package/web/assets/diagram-GF46GFSD-CaBG6n6o.js +0 -24
  413. package/web/assets/diagram-QXG6HAR7-CZ-O3rcV.js +0 -24
  414. package/web/assets/diagram-WEQXMOUZ-P4lSL4GH.js +0 -10
  415. package/web/assets/eventmodeling-FCH6USID-MREXMVOE-B3myHUst.js +0 -1
  416. package/web/assets/gitGraph-WXDBUCRP-R675I2BI-DF3rtf4A.js +0 -1
  417. package/web/assets/gitGraphDiagram-S2ZK5IYY-CCwVLT__.js +0 -106
  418. package/web/assets/index-BKWA-m7o.css +0 -2
  419. package/web/assets/index-CG6q8eTs.js +0 -626
  420. package/web/assets/info-J43DQDTF-KCYPFFUO-R4XV_akJ.js +0 -1
  421. package/web/assets/infoDiagram-3YFTVSEB-DmLICZx1.js +0 -2
  422. package/web/assets/packet-YPE3B663-LP52Z2RK-CgQPdNZa.js +0 -1
  423. package/web/assets/pie-LRSECV5Y-TCRJHUBD-BzWOwc_c.js +0 -1
  424. package/web/assets/radar-GUYGQ44K-RDLRG3WG-B7KG0FN7.js +0 -1
  425. package/web/assets/stateDiagram-7D4R322I-CB2nABwH.js +0 -1
  426. package/web/assets/stateDiagram-v2-36443NZ5-COBGd2RL.js +0 -1
  427. package/web/assets/treeView-BLDUP644-QA4HXRO3-CRSxEwLO.js +0 -1
  428. package/web/assets/treemap-LRROVOQU-LLAWBHMP-a0PfKoVn.js +0 -1
  429. package/web/assets/wardley-L42UT6IY-5TKZOOLJ-BKQ6e5mC.js +0 -1
  430. /package/web/assets/{chunk-AQ6EADP3-CCFje6lL.js → chunk-AQ6EADP3-CZhslHi-.js} +0 -0
@@ -0,0 +1,1972 @@
1
+ /**
2
+ * PDG-backed impact helpers.
3
+ *
4
+ * Extracted from `local-backend.ts` so LocalBackend owns dispatch/repo lifecycle
5
+ * while this module owns the PDG layer probe, statement traversal, block
6
+ * projection, and result assembly contract.
7
+ */
8
+ import path from 'path';
9
+ import { loadMeta } from '../../storage/repo-manager.js';
10
+ import { IMPACT_MAX_DEPTH, PDG_QUERY_DEFAULT_LIMIT, PDG_QUERY_MAX_LIMIT } from '../tools.js';
11
+ import { CALLEES_TRUNCATED_SENTINEL, CALLEE_ID_SEP } from '../../core/ingestion/cfg/emit.js';
12
+ import { decodeCallSummary } from '../../core/ingestion/taint/call-summary-codec.js';
13
+ import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js';
14
+ import { getProviderForFile } from '../../core/ingestion/languages/index.js';
15
+ import { SupportedLanguages } from '../../_shared/index.js';
16
+ /**
17
+ * Parse the `<fnLine>` segment out of a `BasicBlock` id (1-based function start
18
+ * line). The id template is
19
+ * `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>`
20
+ * and `<filePath>` may itself contain `':'` (a Windows drive letter), so the
21
+ * segments are taken from the RIGHT: `<blockIdx>` is last, `<fnCol>` second-last,
22
+ * `<fnLine>` third-last. Extracted from the `_pdgQueryImpl` closure (#2086) into
23
+ * a shared module-scope helper so the PDG impact traversal (U3/U4) reuses the
24
+ * exact same parse — the `pdg_query` read path is byte-identical to before.
25
+ */
26
+ export function fnLineOf(id) {
27
+ const parts = id.split(':');
28
+ return Number(parts[parts.length - 3]);
29
+ }
30
+ /**
31
+ * Parse the `<filePath>` segment out of a `BasicBlock` id, the COUNTERPART to
32
+ * `fnLineOf`. The id template is `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>`,
33
+ * so the file path is everything BETWEEN the `BasicBlock:` prefix and the last
34
+ * THREE colon-segments (`<fnLine>:<fnCol>:<blockIdx>`). `<filePath>` may itself
35
+ * contain `':'` (a Windows drive letter), so we strip from both ends rather than
36
+ * split-and-pick. Returns `''` for an unparseable id (treated as unresolved).
37
+ */
38
+ function fnFileOf(id) {
39
+ const parts = id.split(':');
40
+ // Need: `BasicBlock` + filePath(≥1) + fnLine + fnCol + blockIdx ⇒ ≥5 segments.
41
+ if (parts.length < 5 || parts[0] !== 'BasicBlock')
42
+ return '';
43
+ // Drop the leading `BasicBlock` token and the trailing fnLine/fnCol/blockIdx,
44
+ // rejoin the middle on ':' to restore a path that itself contained colons.
45
+ return parts.slice(1, parts.length - 3).join(':');
46
+ }
47
+ /**
48
+ * Default inter-procedural FUNCTION-hop budget for the U1 forward closure (the
49
+ * number of call boundaries the slice descends through). Distinct from the per-
50
+ * hop intra-callee step/edge budget (which reuses `stepLimit`). Kept small (3)
51
+ * because context-insensitive descent over-includes with depth.
52
+ */
53
+ const INTERPROC_DEPTH_BUDGET = 3;
54
+ /**
55
+ * Total newly-reached-block cap across all inter-procedural hops — the secondary
56
+ * termination guard against a pathological fan-out inside the depth budget.
57
+ * Stamps `truncated` with the `'limit'` reason when it fires (it is a SIZE/budget
58
+ * cap, not dependence-level depth exhaustion — FIX 7).
59
+ */
60
+ const INTERPROC_NODE_BUDGET = 5000;
61
+ /**
62
+ * Split a tab-joined ({@link CALLEE_ID_SEP}) `BasicBlock.calleeIds` cell into its resolved callee
63
+ * symbol ids, dropping the truncation sentinel (a capped block carries the
64
+ * sentinel to mark an incomplete call-site list; it is NOT a resolved symbol id
65
+ * and must never enter a `has(realId)` set). Empty/whitespace cells yield no ids.
66
+ *
67
+ * Extracted here (U1) so the two callers — `LocalBackend.calleeIdsOfBlocks` (the
68
+ * statement-precise bridge key) and the inter-procedural descent's
69
+ * `calleeIdsFromCalleeRows` — cannot diverge on the split-and-drop-sentinel
70
+ * logic. Both consume rows of `BasicBlock.calleeIds`; this is the single source.
71
+ */
72
+ export function splitCalleeIds(raw) {
73
+ const out = [];
74
+ // Split on the SHARED CALLEE_ID_SEP (tab) — ids embed file paths / multi-word
75
+ // C++ type tokens that can contain a space, so a space split would fragment
76
+ // them. Producer (calleeIdsOfBlock) joins with the same constant.
77
+ for (const id of String(raw ?? '').split(CALLEE_ID_SEP)) {
78
+ if (id && id !== CALLEES_TRUNCATED_SENTINEL)
79
+ out.push(id);
80
+ }
81
+ return out;
82
+ }
83
+ /**
84
+ * Contract version of the mode:'pdg' impact result shape. A stable discriminator
85
+ * for external MCP/agent consumers — distinct from the DB INCREMENTAL_SCHEMA_VERSION.
86
+ * Bump on any breaking change to the PDG result fields.
87
+ */
88
+ export const PDG_RESULT_VERSION = 1;
89
+ /**
90
+ * FU-B-2 intra-block def→use line walk. Given a block's self REACHING_DEF
91
+ * def→use line PAIRS (every `defLine → useLine` step decoded from the edge
92
+ * `reason`'s pair list), walk FORWARD from a set of entry lines and return every
93
+ * interior USE line transitively reached. This is the principled statement-
94
+ * granular recovery for a coalesced straight-line BasicBlock, for BOTH chain
95
+ * shapes:
96
+ * - DISTINCT bindings: `chainCompute` coalesces `a@7; b@8; c@9` into one block;
97
+ * each binding is its own (block-pair, binding) group, so `a@7→b@8` and
98
+ * `b@8→c@9` arrive as separate self-edges, and walking from line 7 recovers
99
+ * {8, 9}.
100
+ * - SAME binding (reassignment): `acc = f(acc); acc = g(acc); acc = h(acc)`
101
+ * coalesces into one self-block whose `acc@24→acc@25`, `acc@25→acc@26`,
102
+ * `acc@26→acc@27` steps ALL share the one (self-block, accIdx) group — the
103
+ * dedup collapses them onto one edge, but the FU-B-2 pair LIST carries all
104
+ * three, so walking from line 24 chains 24→25→26→27 to fixpoint. (A
105
+ * first-pair-only annotation would stop at 25.)
106
+ *
107
+ * Pure: forward adjacency `defLine → Set<useLine>`, BFS from `entryLines`,
108
+ * bounded by the (finite) pair set + a `reached` set so it always terminates,
109
+ * even on a cycle — a self-referential line (`x = x + 1`, def@L→use@L) re-adds
110
+ * nothing new. The entry lines themselves are NOT emitted (they are the seed /
111
+ * the block's representative line already surfaced elsewhere); only newly-reached
112
+ * interior use lines are.
113
+ */
114
+ function walkIntraBlockChain(selfEdges, entryLines) {
115
+ const succ = new Map();
116
+ for (const { defLine, useLine } of selfEdges) {
117
+ if (!Number.isInteger(defLine) || !Number.isInteger(useLine))
118
+ continue;
119
+ const set = succ.get(defLine) ?? new Set();
120
+ set.add(useLine);
121
+ succ.set(defLine, set);
122
+ }
123
+ const reached = new Set();
124
+ const frontier = [];
125
+ for (const e of entryLines)
126
+ frontier.push(e);
127
+ const seenSeed = new Set(frontier);
128
+ while (frontier.length > 0) {
129
+ const cur = frontier.pop();
130
+ for (const next of succ.get(cur) ?? []) {
131
+ if (reached.has(next) || seenSeed.has(next))
132
+ continue;
133
+ reached.add(next);
134
+ frontier.push(next);
135
+ }
136
+ }
137
+ return reached;
138
+ }
139
+ /**
140
+ * Fetch the self REACHING_DEF edges (`(a)-[REACHING_DEF]->(a)`) for a set of
141
+ * blocks, grouped by block id, with each edge's def/use source LINES decoded
142
+ * from the FU-B-2 `reason` annotation (`<name>|1:<defLine>:<useLine>`). A
143
+ * pre-FU-B-2 (un-annotated) `reason` decodes to no line info → the edge is
144
+ * dropped (the block then projects at block-start granularity exactly as before,
145
+ * the documented graceful degrade for an older index). A query error propagates.
146
+ */
147
+ async function selfReachingDefEdgesByBlock(lbugPath, blockIds, exec) {
148
+ const out = new Map();
149
+ if (blockIds.length === 0)
150
+ return out;
151
+ const rows = await exec(lbugPath, `MATCH (a:BasicBlock)-[r:CodeRelation]->(a)
152
+ WHERE r.type = 'REACHING_DEF' AND a.id IN $ids
153
+ RETURN a.id AS id, r.reason AS reason`, { ids: blockIds });
154
+ for (const r of rows) {
155
+ const id = String(r['id'] ?? '');
156
+ if (!id)
157
+ continue;
158
+ const decoded = decodeReachingDefReason(r['reason']);
159
+ // FU-B-2: the annotation carries the FULL ordered (defLine, useLine) pair
160
+ // list for this (block-pair, binding) group — push EVERY pair so the walk can
161
+ // chain a same-binding reassignment (`acc@24->acc@25->acc@26`), which the
162
+ // dedup coalesces onto this one edge. A pre-FU-B-2 (un-annotated) reason
163
+ // decodes to no pairs → contributes nothing (block-start granularity).
164
+ if (decoded.pairs.length === 0)
165
+ continue;
166
+ const list = out.get(id) ?? [];
167
+ for (const p of decoded.pairs)
168
+ list.push({ defLine: p.defLine, useLine: p.useLine });
169
+ out.set(id, list);
170
+ }
171
+ return out;
172
+ }
173
+ /**
174
+ * Resolve a set of BasicBlock ids to their source statements (line + text),
175
+ * deduped by `(filePath, line, block id)` and sorted by line. This is the useful
176
+ * output of a statement-anchored PDG slice — the dependent statements the change
177
+ * reaches. A query error propagates (no `.catch` swallow) so a DB failure is
178
+ * never silently reported as "no affected statements".
179
+ *
180
+ * FU-B-2 statement granularity: a coalesced straight-line BasicBlock is projected
181
+ * to its single `startLine` by default, which UNDER-reports the interior
182
+ * statements that genuinely depend on the criterion (lines 8/9 of a `7-9` block).
183
+ * For each block in `chainWalkBlocks` we walk its self REACHING_DEF def→use LINE
184
+ * chain (decoded from the FU-B-2 `reason` annotation) FORWARD from that block's
185
+ * entry line(s) and emit one statement per reached INTERIOR line (its own text
186
+ * from the block text). This is the SINGLE principled statement-granular
187
+ * mechanism — it replaces FU-C's blind all-interior-lines expansion of ascent
188
+ * blocks (now routed through the same walk, seeded at the block's start line) and
189
+ * also recovers the seed block's own interior dependents (the U2/intra-dataflow-
190
+ * chain recall gap). A pre-FU-B-2 index (no line annotation) yields no self-edge
191
+ * lines → the block degrades to its block-start projection (byte-identical to the
192
+ * old behavior).
193
+ */
194
+ async function pdgStatementsForBlocks(lbugPath, blockIds, exec, criterionFile, ownerFnLine,
195
+ /**
196
+ * Blocks whose interior should be expanded to statement granularity via the
197
+ * self-edge def→use line walk, each mapped to the entry line(s) the walk
198
+ * starts from. Two contributors:
199
+ * - the SEED block, seeded at the criterion line — recovers the interior
200
+ * dependents of the changed statement inside its own coalesced block (the
201
+ * U2 intra-dataflow-chain gap: line 7's block 7-9 yields {8,9});
202
+ * - each U-C4 ascent-confirmed CALL block, seeded at its start line — the
203
+ * statement-granular realisation of the return-value ascent (replacing the
204
+ * FU-C blind interior-line stop-gap with the principled walk).
205
+ * Empty/absent ⇒ every block projects at block-start granularity (byte-
206
+ * identical to the pre-FU-B-2 behavior).
207
+ */
208
+ chainWalkBlocks = new Map()) {
209
+ if (blockIds.length === 0 && chainWalkBlocks.size === 0)
210
+ return [];
211
+ // The seed block(s) we walk are NOT in `blockIds` (seeds are excluded from the
212
+ // reachable slice), so the block-text fetch must cover BOTH the reachable
213
+ // blocks and the chain-walk blocks. Union, de-duplicated.
214
+ const fetchIds = [...new Set([...blockIds, ...chainWalkBlocks.keys()])];
215
+ const [rows, selfEdgesByBlock] = await Promise.all([
216
+ exec(lbugPath, `MATCH (b:BasicBlock) WHERE b.id IN $ids
217
+ RETURN b.id AS id, b.startLine AS line, b.endLine AS endLine, b.text AS text`, { ids: fetchIds }),
218
+ chainWalkBlocks.size > 0
219
+ ? selfReachingDefEdgesByBlock(lbugPath, [...chainWalkBlocks.keys()], exec)
220
+ : Promise.resolve(new Map()),
221
+ ]);
222
+ const byKey = new Map();
223
+ const reachableIds = new Set(blockIds);
224
+ // Narrow the awaited rows ONCE at the boundary to a typed record shape; read
225
+ // the aliased cells via bracket access with String()/Number() coercion.
226
+ for (const r of rows) {
227
+ const id = String(r['id'] ?? '');
228
+ const line = Number(r['line'] ?? 0);
229
+ if (!id || !Number.isFinite(line) || line <= 0)
230
+ continue;
231
+ const filePath = fnFileOf(id);
232
+ const text = String(r['text'] ?? '').trim();
233
+ // INTRA iff this block's owning function (file + 1-based start line) is the
234
+ // criterion's own function; otherwise it was reached across a call boundary
235
+ // (INTER). Pure key comparison parsed from the block id — no extra DB query.
236
+ const scope = filePath === criterionFile && fnLineOf(id) === ownerFnLine ? 'intra' : 'inter';
237
+ const textLines = String(r['text'] ?? '').split('\n');
238
+ // ── FU-B-2 chain-walk block: expand to its interior dependent lines ───────
239
+ // Walk the block's self REACHING_DEF def→use LINE chain forward from the
240
+ // entry line(s). Each reached interior line is a statement transitively
241
+ // data-dependent on the entry (the chain the coalesced block lost). Emit it
242
+ // with its own physical-line text (`BasicBlock.text` is `lines.join('\n')`,
243
+ // 1-based from `startLine`). This is the principled replacement for FU-C's
244
+ // blind all-interior expansion — only lines the def→use chain proves are
245
+ // surfaced. A block that is ALSO a reachable block keeps its block-start
246
+ // statement too (added below); the seed block (not reachable) contributes
247
+ // ONLY its walked interior lines.
248
+ const entryLines = chainWalkBlocks.get(id);
249
+ if (entryLines !== undefined) {
250
+ // An empty entry set means "seed from the block's own start line" — the
251
+ // FU-C ascent blocks, whose call statements chain forward from the block
252
+ // start (the caller does not know the start line at map-build time). A
253
+ // non-empty set (the seed block, seeded at the criterion line) is used
254
+ // verbatim. `line` is the resolved block start line for this row.
255
+ const seeds = entryLines.size > 0 ? entryLines : new Set([line]);
256
+ const reached = walkIntraBlockChain(selfEdgesByBlock.get(id) ?? [], seeds);
257
+ for (const ln of reached) {
258
+ const idx = ln - line;
259
+ if (idx < 0 || idx >= textLines.length)
260
+ continue; // out of this block's text
261
+ const lineText = textLines[idx].trim();
262
+ const key = `${filePath}:${ln}:${id}`;
263
+ if (!byKey.has(key))
264
+ byKey.set(key, { line: ln, filePath, text: lineText, scope });
265
+ }
266
+ }
267
+ // A reachable block always surfaces its representative block-start statement.
268
+ // A pure chain-walk seed block (not reachable) does NOT (its start line is
269
+ // the criterion / the call block already surfaced elsewhere — only its
270
+ // walked-forward dependents are new). When the reachable block is ALSO a
271
+ // chain-walk block (a coalesced call block whose interior lines we expanded),
272
+ // its block-start statement is surfaced at the SAME statement granularity —
273
+ // the first physical line's own text, not the whole multi-statement block
274
+ // text — so all of its statements are consistently single-line. A reachable
275
+ // block that is NOT chain-walked keeps the full trimmed block text (byte-
276
+ // identical to the pre-FU-B-2 projection).
277
+ if (reachableIds.has(id)) {
278
+ const key = `${filePath}:${line}:${id}`;
279
+ const startText = entryLines !== undefined && textLines.length > 0 ? textLines[0].trim() : text;
280
+ if (!byKey.has(key))
281
+ byKey.set(key, { line, filePath, text: startText, scope });
282
+ }
283
+ }
284
+ return [...byKey.values()].sort((a, b) => {
285
+ if (a.filePath !== b.filePath)
286
+ return a.filePath < b.filePath ? -1 : 1;
287
+ if (a.line !== b.line)
288
+ return a.line - b.line;
289
+ return a.text < b.text ? -1 : a.text > b.text ? 1 : 0;
290
+ });
291
+ }
292
+ /**
293
+ * Net-new block → owning-symbol resolver (U4) — the REVERSE of
294
+ * `resolveBlockAnchor` (which goes symbol→blocks). No precedent exists:
295
+ * `_pdgQueryImpl` only ever extracts a raw `functionLine`, never an owning
296
+ * symbol. Lives in the extracted PDG impact engine and takes injected deps so
297
+ * LocalBackend keeps repo lifecycle/dispatch while this module owns projection.
298
+ *
299
+ * For each reachable block id `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>`:
300
+ * - `fnLineOf` → 1-based function start line; `fnFileOf` → file path.
301
+ * - Query `Function`/`Method`/`Constructor` `WHERE filePath = $f AND startLine = (fnLine-1)`
302
+ * — block `fnLine` is 1-based, symbol `startLine` is 0-based, so subtract one
303
+ * (the `[symStart+1]` convention from `resolveBlockAnchor`, applied in
304
+ * reverse; NOT re-derived).
305
+ *
306
+ * Two non-happy paths, BOTH surfaced (never silent):
307
+ * - **>1 match** (same-line different-name functions): `fnCol` rides the block
308
+ * id but the schema has NO `startColumn` column and the symbol id encodes only
309
+ * the name, so a `(filePath, startLine)` join cannot disambiguate. Report ALL
310
+ * colliding symbols, each `ambiguous: true` (R4 / Feasibility Finding 1).
311
+ * - **0 matches** (top-level/free-statement block, or a lambda whose start line
312
+ * ≠ a symbol `startLine`): one `unresolved` entry (`id: null`) under the
313
+ * block's file (R9 shadow path).
314
+ *
315
+ * Distinct `(filePath, fnLine)` pairs are queried once each (a block and its
316
+ * siblings in the same function share a pair), so the cost is O(distinct
317
+ * functions), not O(blocks).
318
+ */
319
+ async function projectBlocksToSymbols(deps) {
320
+ const { lbugPath, blockIds, executeParameterized: exec } = deps;
321
+ // Group blocks by their (filePath, fnLine) owning-function key so each owning
322
+ // function is resolved with a single query regardless of block count.
323
+ const byFnKey = new Map();
324
+ for (const id of blockIds) {
325
+ const filePath = fnFileOf(id);
326
+ const fnLine = fnLineOf(id); // 1-based
327
+ if (!filePath || !Number.isFinite(fnLine)) {
328
+ // Unparseable block id — record an unresolved key so it is reported, never
329
+ // dropped. Use the raw id as the key so duplicates collapse.
330
+ byFnKey.set(`#bad#${id}`, { filePath: filePath || id, symStart: NaN });
331
+ continue;
332
+ }
333
+ const symStart = fnLine - 1; // 0-based symbol startLine (reverse [symStart+1])
334
+ byFnKey.set(`${filePath}#${symStart}`, { filePath, symStart });
335
+ }
336
+ const resolved = [];
337
+ let unresolvedCount = 0;
338
+ let ambiguousCount = 0;
339
+ await Promise.all(Array.from(byFnKey.values()).map(async ({ filePath, symStart }) => {
340
+ if (!Number.isFinite(symStart)) {
341
+ // Unparseable id — shadow-path unresolved under (best-effort) file.
342
+ resolved.push({ id: null, name: '(unresolved)', type: 'unresolved', filePath });
343
+ unresolvedCount += 1;
344
+ return;
345
+ }
346
+ // `Function`/`Method`/`Constructor` carry name+filePath+startLine; the schema has NO
347
+ // `startColumn`, so the join is on (filePath, startLine) only. `filePath`
348
+ // and `symStart` are BOUND as params (KTD11 — never interpolated). A
349
+ // UNION ALL across explicit labels is used rather than a
350
+ // `(s:Function OR s:Method OR s:Constructor)` disjunction (unsupported in the LadybugDB
351
+ // Cypher subset — the established cross-label pattern, see
352
+ // `enrichCandidateLabels`).
353
+ // FIX 6: do NOT swallow a query failure as `[]`. A DB error (lock /
354
+ // corruption / missing path) must NOT masquerade as a genuine no-owning-
355
+ // symbol result — that would silently inflate `unresolvedCount` and hide
356
+ // the failure. Letting it reject propagates through `Promise.all` →
357
+ // `projectBlocksToSymbols` → `_runImpactPDG` → `_impactImpl` up to the
358
+ // `impact()` structured-error catch, where it surfaces as a real error
359
+ // with a recovery suggestion (rather than a clean-looking partial radius).
360
+ const rows = await exec(lbugPath, `MATCH (s:\`Function\`)
361
+ WHERE s.filePath = $filePath AND s.startLine = $symStart
362
+ RETURN s.id AS id, s.name AS name, 'Function' AS label, s.startLine AS startLine
363
+ UNION ALL
364
+ MATCH (s:\`Method\`)
365
+ WHERE s.filePath = $filePath AND s.startLine = $symStart
366
+ RETURN s.id AS id, s.name AS name, 'Method' AS label, s.startLine AS startLine
367
+ UNION ALL
368
+ MATCH (s:\`Constructor\`)
369
+ WHERE s.filePath = $filePath AND s.startLine = $symStart
370
+ RETURN s.id AS id, s.name AS name, 'Constructor' AS label, s.startLine AS startLine`, { filePath, symStart });
371
+ if (rows.length === 0) {
372
+ // No owning symbol — top-level/free-statement block or a lambda whose
373
+ // start line ≠ a symbol startLine. Shadow path: report under its file.
374
+ resolved.push({
375
+ id: null,
376
+ name: '(unresolved)',
377
+ type: 'unresolved',
378
+ filePath,
379
+ startLine: symStart,
380
+ });
381
+ unresolvedCount += 1;
382
+ return;
383
+ }
384
+ // >1 ⇒ ambiguous-projection (same-line, different-name functions). Report
385
+ // ALL colliding symbols, NEVER silently pick one (R4 / Feasibility 1).
386
+ const isAmbiguous = rows.length > 1;
387
+ // Narrow the rows ONCE at the boundary to a typed record shape and read the
388
+ // aliased cells via bracket access (with positional `['0']`… fallback for a
389
+ // non-aliased row shape) — no per-field `as any`, matching the typed-row
390
+ // pattern used elsewhere in this file (e.g. lines ~264, ~1309, ~1386).
391
+ for (const r of rows) {
392
+ resolved.push({
393
+ id: String(r['id'] ?? r['0'] ?? ''),
394
+ name: String(r['name'] ?? r['1'] ?? ''),
395
+ type: String(r['label'] ?? r['2'] ?? 'Function'),
396
+ filePath,
397
+ startLine: Number(r['startLine'] ?? r['3'] ?? symStart),
398
+ ...(isAmbiguous ? { ambiguous: true } : {}),
399
+ });
400
+ }
401
+ if (isAmbiguous)
402
+ ambiguousCount += 1;
403
+ }));
404
+ // Deterministic order: by filePath, then startLine, then id (unresolved last
405
+ // within a file). Order-independence matters for the parity/fingerprint
406
+ // contract (KTD8 standing interchangeability) and for stable consumer output.
407
+ resolved.sort((a, b) => {
408
+ if (a.filePath !== b.filePath)
409
+ return a.filePath < b.filePath ? -1 : 1;
410
+ const al = a.startLine ?? Number.MAX_SAFE_INTEGER;
411
+ const bl = b.startLine ?? Number.MAX_SAFE_INTEGER;
412
+ if (al !== bl)
413
+ return al - bl;
414
+ const ai = a.id ?? '￿';
415
+ const bi = b.id ?? '￿';
416
+ return ai < bi ? -1 : ai > bi ? 1 : 0;
417
+ });
418
+ return { symbols: resolved, unresolvedCount, ambiguousCount };
419
+ }
420
+ /**
421
+ * The KTD8 parity fields a PDG impact result carries even when it short-circuits
422
+ * to an empty radius (degraded layer / no PDG body / no dependence reachability).
423
+ *
424
+ * A programmatic consumer iterating `byDepth`, reading `byDepthCounts[1]`, or
425
+ * coalescing `affected_processes`/`affected_modules` must find a well-formed
426
+ * (empty) shape on EVERY early return, not `undefined` (which would render as
427
+ * "isolated"/"no data" instead of "inconclusive"). The CLI branches on
428
+ * `pdgLayer` first so it is safe regardless, but the JSON contract must be
429
+ * uniform across all three early returns — this single source guarantees that.
430
+ */
431
+ function emptyPdgParityFields() {
432
+ return {
433
+ byDepth: {},
434
+ byDepthCounts: { 1: 0 },
435
+ summary: { direct: 0, processes_affected: 0, modules_affected: 0 },
436
+ affected_processes: [],
437
+ affected_modules: [],
438
+ };
439
+ }
440
+ export function makePdgImpactErrorResult(input) {
441
+ return {
442
+ ...(input.mode ? { mode: input.mode } : {}),
443
+ pdgResultVersion: PDG_RESULT_VERSION,
444
+ error: input.error,
445
+ target: input.target,
446
+ direction: input.direction,
447
+ impactedCount: 0,
448
+ risk: 'UNKNOWN',
449
+ ...(input.suggestion ? { suggestion: input.suggestion } : {}),
450
+ ...(input.recoverySuggestion ? { recoverySuggestion: input.recoverySuggestion } : {}),
451
+ };
452
+ }
453
+ export function isPdgDegradedLayerStatus(layer) {
454
+ return layer.state !== 'ready';
455
+ }
456
+ export function makePdgLayerDegradedResult(input) {
457
+ return {
458
+ mode: input.mode,
459
+ pdgResultVersion: PDG_RESULT_VERSION,
460
+ pdgLayer: input.layer.state,
461
+ ...(input.layer.missingSubLayer ? { missingSubLayer: input.layer.missingSubLayer } : {}),
462
+ ...(input.layer.probeError ? { probeError: input.layer.probeError } : {}),
463
+ ...(input.layer.recoverySuggestion
464
+ ? { recoverySuggestion: input.layer.recoverySuggestion }
465
+ : {}),
466
+ note: input.layer.note,
467
+ target: input.target,
468
+ direction: input.direction,
469
+ impactedCount: 0,
470
+ risk: 'UNKNOWN',
471
+ ...emptyPdgParityFields(),
472
+ };
473
+ }
474
+ /**
475
+ * Assemble the consumer-safe PDG impact result (U4 / KTD8 parity matrix).
476
+ *
477
+ * Takes the U3 traversal output (reachable block set + truncation signalling)
478
+ * plus the U4 block→symbol projection, and shapes a result STRUCTURALLY
479
+ * substitutable for the call-graph `_runImpactBFS` result so every consumer
480
+ * (CLI `formatImpactResult`, group `collectImpactSymbolUids`/`mergeRisk`,
481
+ * `impactByUid`) renders it without misrendering. This is a STANDING
482
+ * interchangeability contract, not a one-time check.
483
+ *
484
+ * Field-by-field vs the call-graph result (KTD8):
485
+ * - `target.id/name/type/filePath` — identical shape (`collectImpactSymbolUids`
486
+ * keys on `target.id`/`target.filePath`).
487
+ * - `byDepth` — same `{ [depth]: item[] }` map shape, but COLLAPSED to a single
488
+ * bucket (`1`): intra-procedural dependence has no meaningful inter-symbol hop
489
+ * count (block-hops are NOT call-hops). Items carry `{ id, name, type,
490
+ * filePath, … }` exactly like the call-graph items so `collectImpactSymbolUids`
491
+ * collects their UIDs. `unresolved` shadow-path entries keep `id: null` (they
492
+ * are surfaced, never dropped — but collect as no UID).
493
+ * - `byDepthCounts` — `{ 1: <symbolCount> }`, same shape.
494
+ * - `affected_processes` / `affected_modules` — empty `[]` (no
495
+ * STEP_IN_PROCESS/module edges originate from BasicBlocks; consumers coalesce
496
+ * `[]` safely).
497
+ * - `epistemic` — a PDG-specific marker (`'pdg-intra-procedural'`), NOT the
498
+ * callgraph DI/dynamic-dispatch `'lower-bound'` copy. `note` carries the
499
+ * PDG framing so the CLI prints PDG text, not callgraph boundary text.
500
+ * - `risk` — the existing `'UNKNOWN'` sentinel (NOT a new label). `mergeRisk`
501
+ * already coalesces `'UNKNOWN'` correctly (never a confident `LOW`).
502
+ * - `impactedCount` — count of DISTINCT owning SYMBOLS (resolved UIDs), the
503
+ * meaningful unit for the impact question ("which symbols are affected").
504
+ * `blockCount` is retained separately as the raw reachable-block count.
505
+ */
506
+ function assemblePdgImpactResult(input) {
507
+ const { target, direction, reachableBlocks, projection } = input;
508
+ const { symbols, unresolvedCount, ambiguousCount } = projection;
509
+ const affectedStatements = input.affectedStatements ?? [];
510
+ const statementMode = typeof input.criterionLine === 'number';
511
+ // Items for the single collapsed bucket. Shaped like the call-graph byDepth
512
+ // items (`{ depth, id, name, type, filePath, processes }`) so consumers that
513
+ // iterate byDepth read the same fields. `unresolved` entries keep `id: null`
514
+ // (surfaced under their file; `collectImpactSymbolUids` skips a null id, which
515
+ // is correct — there is no symbol UID to attribute).
516
+ const items = symbols.map((s) => ({
517
+ depth: 1,
518
+ id: s.id,
519
+ name: s.name,
520
+ type: s.type,
521
+ filePath: s.filePath,
522
+ ...(s.startLine !== undefined ? { startLine: s.startLine } : {}),
523
+ ...(s.ambiguous ? { ambiguous: true } : {}),
524
+ ...(s.id === null ? { unresolved: true } : {}),
525
+ pdgEvidence: (s.id === null ? 'degraded' : 'owner-projection'),
526
+ pdgEvidenceReason: s.id === null
527
+ ? 'reachable BasicBlock has no owning Function/Method/Constructor projection'
528
+ : 'reachable BasicBlock projected to its owning symbol',
529
+ processes: [],
530
+ }));
531
+ // impactedCount = distinct owning SYMBOLS (resolved UIDs). Unresolved shadow
532
+ // entries are surfaced in byDepth but do NOT inflate the symbol count.
533
+ const resolvedUids = new Set(symbols.filter((s) => s.id !== null).map((s) => s.id));
534
+ const impactedCount = resolvedUids.size;
535
+ const byDepth = items.length > 0 ? { 1: items } : {};
536
+ const byDepthCounts = { 1: items.length };
537
+ const noteParts = statementMode
538
+ ? [
539
+ `mode:'pdg' — intra-procedural slice from line ${input.criterionLine} of ` +
540
+ `'${target.name}'. ${affectedStatements.length} ` +
541
+ `${affectedStatements.length === 1 ? 'statement is' : 'statements are'} ${direction}-` +
542
+ `dependent on it (over CDG + REACHING_DEF). Inter-procedural symbol reach ` +
543
+ `is attached by impact mode's unified PDG dispatcher in interproceduralByDepth/byDepth.`,
544
+ ]
545
+ : [
546
+ `mode:'pdg' — intra-procedural Program Dependence Graph. ${impactedCount} owning ` +
547
+ `${impactedCount === 1 ? 'symbol' : 'symbols'} reached via ${reachableBlocks.length} ` +
548
+ `dependence ${reachableBlocks.length === 1 ? 'block' : 'blocks'} ` +
549
+ `(${direction} over CDG + REACHING_DEF). Inter-procedural symbol reach ` +
550
+ `is attached by impact mode's unified PDG dispatcher in interproceduralByDepth/byDepth.`,
551
+ ];
552
+ const interproceduralHops = input.interproceduralHops ?? 0;
553
+ if (interproceduralHops > 0) {
554
+ noteParts.push(`The statement slice (affectedStatements) crosses ${interproceduralHops} ` +
555
+ `inter-procedural ${interproceduralHops === 1 ? 'hop' : 'hops'} via resolved call ` +
556
+ `sites (HRB context-insensitive forward closure, downstream). SOUNDNESS CAVEATS: ` +
557
+ `(1) context-insensitive — a dependence may be attributed to a callee only reachable ` +
558
+ `from a DIFFERENT call site of the same function (bounded over-inclusion, the same ` +
559
+ `imprecision callgraph mode has); (2) return-value ascent IS captured (via ` +
560
+ `CALL_SUMMARY): a caller statement that depends on a callee's RETURN value is in the ` +
561
+ `slice when the callee has a persisted return-flow summary. Out-parameter / mutated-` +
562
+ `argument ascent, callee-written shared / captured variables, and exception ascent (a ` +
563
+ `throw the caller catches) remain deferred (they need an alias / try-catch model). A ` +
564
+ `PRE-FU-C (v3) --pdg index has no CALL_SUMMARY edges, so return-value ascent is absent ` +
565
+ `there until a re-index; (3) no cross-boundary alias ` +
566
+ `model; (4) precision is bounded by the call RESOLVER's precision (multi-candidate ` +
567
+ `dispatch / C++ overload under-resolution flow through faithfully — sound, never drops a ` +
568
+ `real target).`);
569
+ // FU-C degradation: the slice crossed a call boundary but this index predates
570
+ // CALL_SUMMARY (a v3 `--pdg` index). The intra slice is served, but no
571
+ // return-value ascent ran — steer to a re-index.
572
+ if (input.callSummaryAvailable === false) {
573
+ noteParts.push(`no return-value ascent (re-index for CALL_SUMMARY): this --pdg index predates the ` +
574
+ `FU-C return-value-ascent layer, so a caller statement depending on a callee's ` +
575
+ `RETURN value is NOT in the slice. Re-run gitnexus analyze --pdg to record ` +
576
+ `CALL_SUMMARY edges and enable it.`);
577
+ }
578
+ else if (input.callSummaryAvailable === true) {
579
+ // The CALL_SUMMARY layer is present, but return-value ascent is populated
580
+ // ONLY for TypeScript/JavaScript today (the formal-index it needs is set
581
+ // solely by the TS/JS harvester). For a criterion in any other language the
582
+ // ascent is structurally empty, so say so rather than letting the omission
583
+ // read as "ascent ran and found nothing". Sound — never claims ascent fired.
584
+ // Language is derived HERE in mcp/local, which may name languages; the
585
+ // shared core/ingestion pipeline must not.
586
+ const lang = getProviderForFile(target.filePath)?.id;
587
+ const ascentLanguage = lang === SupportedLanguages.TypeScript || lang === SupportedLanguages.JavaScript;
588
+ if (!ascentLanguage) {
589
+ noteParts.push(`return-value ascent is currently TypeScript/JavaScript-only (only the TS/JS harvester ` +
590
+ `records the formal-index it needs), so a caller statement depending on a non-TS/JS ` +
591
+ `callee's RETURN value is not in the slice. Descent and the intra slice are unaffected.`);
592
+ }
593
+ }
594
+ }
595
+ if (ambiguousCount > 0) {
596
+ noteParts.push(`${ambiguousCount} owning-symbol ${ambiguousCount === 1 ? 'projection is' : 'projections are'} ` +
597
+ `ambiguous: same-line functions cannot be disambiguated by start line alone (no startColumn ` +
598
+ `in the schema), so ALL colliding symbols are reported — none is silently picked.`);
599
+ }
600
+ if (unresolvedCount > 0) {
601
+ noteParts.push(`${unresolvedCount} reachable ${unresolvedCount === 1 ? 'block maps' : 'blocks map'} to no ` +
602
+ `owning Function/Method/Constructor (top-level statement or a lambda whose start line is not a symbol ` +
603
+ `start) — surfaced under their file as 'unresolved', never dropped.`);
604
+ }
605
+ return {
606
+ mode: 'pdg',
607
+ pdgResultVersion: PDG_RESULT_VERSION,
608
+ target,
609
+ direction,
610
+ impactedCount,
611
+ // KTD8: reuse the existing UNKNOWN sentinel — never a confident LOW (which
612
+ // would read as "safe to refactor"; #2129/#1858 false-safe lineage). PDG
613
+ // mode is intra-procedural, so its count is a per-function lower bound on the
614
+ // true blast radius and risk is genuinely UNKNOWN at the program level.
615
+ risk: 'UNKNOWN',
616
+ // PDG-specific epistemic marker — NOT the callgraph 'lower-bound'/DI copy.
617
+ epistemic: 'pdg-intra-procedural',
618
+ note: noteParts.join(' '),
619
+ pdgEvidence: {
620
+ statements: 'local-dependence',
621
+ localSymbols: unresolvedCount > 0 ? 'degraded' : 'owner-projection',
622
+ localSymbolCount: impactedCount,
623
+ unresolvedBlockCount: unresolvedCount,
624
+ ambiguousProjectionCount: ambiguousCount,
625
+ },
626
+ // Statement-level slice: the dependent source statements (line + text) the
627
+ // change reaches. This is the primary useful output of statement mode; the
628
+ // accuracy harness scores against these lines.
629
+ ...(statementMode ? { criterionLine: input.criterionLine } : {}),
630
+ affectedStatements,
631
+ affectedStatementCount: affectedStatements.length,
632
+ // Raw block-level detail retained alongside the symbol projection (U3 tests
633
+ // and the accuracy harness read these).
634
+ reachableBlocks,
635
+ intraReachableBlocks: input.intraReachableBlocks,
636
+ seedBlocks: input.seedBlocks,
637
+ blockCount: reachableBlocks.length,
638
+ depthReached: input.depthReached,
639
+ unresolvedBlockCount: unresolvedCount,
640
+ ambiguousProjectionCount: ambiguousCount,
641
+ ...(input.truncated ? { truncated: true } : {}),
642
+ ...(input.truncatedBy ? { truncatedBy: input.truncatedBy } : {}),
643
+ ...(input.truncatedByReasons ? { truncatedByReasons: input.truncatedByReasons } : {}),
644
+ summary: {
645
+ direct: impactedCount,
646
+ processes_affected: 0,
647
+ modules_affected: 0,
648
+ },
649
+ byDepthCounts,
650
+ affected_processes: [],
651
+ affected_modules: [],
652
+ byDepth,
653
+ };
654
+ }
655
+ /**
656
+ * Validate the `impact` `mode` param (KTD5 — backend hard-gate).
657
+ *
658
+ * The MCP JSON-schema `enum` is advisory only (server.ts forwards args
659
+ * unvalidated and `callTool` is reachable directly), so this backend check is
660
+ * the real boundary — mirroring `_pdgQueryImpl`'s `mode` enum validation. A
661
+ * typo'd mode silently running callgraph is exactly the silent fallback this
662
+ * forbids (it would make the accuracy harness compare callgraph-vs-callgraph
663
+ * and report perfect parity).
664
+ *
665
+ * Absent / `undefined` / `'callgraph'` all resolve to `'callgraph'` (the
666
+ * unchanged default path). `'pdg'` is valid. Anything else — `'PDG'`, `'pgd'`,
667
+ * `''`, or a non-string (`0`, `null`, …) — returns a structured `{ error }`,
668
+ * never a callgraph result.
669
+ */
670
+ export function validateImpactMode(rawMode) {
671
+ if (rawMode === undefined || rawMode === 'callgraph')
672
+ return { mode: 'callgraph' };
673
+ if (rawMode === 'pdg')
674
+ return { mode: 'pdg' };
675
+ return {
676
+ error: `Invalid "mode": expected "callgraph" or "pdg", got ${JSON.stringify(rawMode)}.`,
677
+ };
678
+ }
679
+ /**
680
+ * Read the two PDG sub-layer caps from the on-disk `meta.json` stamp — the
681
+ * single shared meta-probe both `_pdgQueryImpl` (one cap) and the PDG impact
682
+ * mode (both caps) key on. Never scans the DB. An unreadable / missing meta
683
+ * yields `metaReadable: false` (the `'unknown'` seam); a readable meta with no
684
+ * `pdg` stamp yields `metaReadable: true` with both caps `false` (no-layer).
685
+ */
686
+ async function readPdgMetaCaps(lbugPath, loadMetaFn) {
687
+ try {
688
+ const meta = await loadMetaFn(path.dirname(lbugPath));
689
+ if (!meta)
690
+ return { metaReadable: false, cdg: false, rd: false, callSummary: false };
691
+ return {
692
+ metaReadable: true,
693
+ cdg: meta.pdg?.maxCdgEdgesPerFunction !== undefined,
694
+ rd: meta.pdg?.maxReachingDefEdgesPerFunction !== undefined,
695
+ callSummary: meta.pdg?.hasCallSummary === true,
696
+ };
697
+ }
698
+ catch {
699
+ // Meta unreadable — the caller decides from the DB (the `'unknown'` state).
700
+ return { metaReadable: false, cdg: false, rd: false, callSummary: false };
701
+ }
702
+ }
703
+ /**
704
+ * Project the both-caps PDG meta read down to the single mode-relevant cap that
705
+ * `_pdgQueryImpl` keys on (`controls` → CDG, `flows` → REACHING_DEF), preserving
706
+ * its established tri-state `boolean | undefined` contract byte-for-byte
707
+ * (Feasibility Issue 4):
708
+ * - `false` — meta readable and the relevant cap absent → definitive
709
+ * no-layer (short-circuits before any DB scan).
710
+ * - `true` — meta readable and the relevant cap present → proceed.
711
+ * - `undefined` — meta unreadable → defer to the post-anchored-query probe.
712
+ *
713
+ * `_pdgQueryImpl` needs only ONE cap, so it collapses the both-caps read here
714
+ * rather than consuming `pdgLayerStatus` directly (whose `'unknown'` state does
715
+ * an upfront global probe — wrong timing/order for the anchored-query path).
716
+ */
717
+ export async function pdgStampForMode(lbugPath, mode, loadMetaFn = loadMeta) {
718
+ const caps = await readPdgMetaCaps(lbugPath, loadMetaFn);
719
+ if (!caps.metaReadable)
720
+ return undefined;
721
+ return mode === 'controls' ? caps.cdg : caps.rd;
722
+ }
723
+ /**
724
+ * PDG-layer presence/degradation check for the `impact` PDG mode (KTD7).
725
+ *
726
+ * Returns the four distinct states WITHOUT scanning the DB except for the single
727
+ * bounded `LIMIT 1` probe the `'unknown'` (meta-unreadable) case requires. The
728
+ * caller (`_impactImpl` PDG branch, and the accuracy harness) surfaces a
729
+ * distinct signal per state so a missing `--pdg` layer / partial layer is never
730
+ * silently misread as a confident empty blast radius. Impact needs BOTH the CDG
731
+ * and the REACHING_DEF sub-layer, so a partial stamp degrades, not proceeds.
732
+ */
733
+ export async function pdgLayerStatus(deps) {
734
+ const loadMetaFn = deps.loadMetaFn ?? loadMeta;
735
+ const caps = await readPdgMetaCaps(deps.lbugPath, loadMetaFn);
736
+ if (caps.metaReadable) {
737
+ // Meta is readable — the stamp is authoritative, no DB scan needed.
738
+ // `hasCallSummary` rides on every meta-readable state (it is NOT part of the
739
+ // `'ready'` gate — a v3 index without it is still ready for the intra slice).
740
+ if (caps.cdg && caps.rd)
741
+ return { state: 'ready', hasCallSummary: caps.callSummary };
742
+ if (caps.cdg !== caps.rd) {
743
+ // Exactly one sub-layer stamped (XOR) — partial layer; impact needs both.
744
+ const missingSubLayer = caps.cdg ? 'REACHING_DEF' : 'CDG';
745
+ return {
746
+ state: 'sub-layer-missing',
747
+ missingSubLayer,
748
+ hasCallSummary: caps.callSummary,
749
+ note: `PDG layer is incomplete — the ${missingSubLayer} sub-layer is missing ` +
750
+ `(impact's PDG mode needs both CDG and REACHING_DEF). ` +
751
+ `Re-run gitnexus analyze --pdg to record it.`,
752
+ };
753
+ }
754
+ // Neither cap stamped (meta.pdg absent, or present with no caps) → the layer
755
+ // was never recorded. Definitive, no DB scan.
756
+ return {
757
+ state: 'no-layer',
758
+ hasCallSummary: caps.callSummary,
759
+ note: 'no PDG layer — run gitnexus analyze --pdg to record CDG + REACHING_DEF edges for this repo',
760
+ };
761
+ }
762
+ // Meta unreadable (e.g. a seeded test DB): one bounded probe confirms the
763
+ // layer status is genuinely undeterminable from the DB. A missing layer is
764
+ // indistinguishable from an all-linear (edge-free) one (#2188), so whether the
765
+ // probe finds a row or not the state stays `'unknown'` (never the definitive
766
+ // no-layer wording). The probe is bounded (`LIMIT 1`) and anchored on the
767
+ // BasicBlock→BasicBlock partition (the `(:BasicBlock)…(:BasicBlock)` label pair
768
+ // restricts it to the sparse pdg-edge partition, never a global rel scan — the
769
+ // established `_explainImpl` anchoring pattern), and it is wrapped so a db-lock
770
+ // / missing-path throw degrades to the same `'unknown'` signal rather than
771
+ // propagating and losing it.
772
+ //
773
+ // The probe result is NOT discarded: a visible CDG/REACHING_DEF edge (with
774
+ // meta unreadable) is a weak-but-real "edges are present, but completeness is
775
+ // unprovable" signal, distinct from "no edges visible at all". Both stay
776
+ // `'unknown'` (inconclusive), but the note distinguishes them so the operator
777
+ // gets the more useful hint.
778
+ let edgesVisible = false;
779
+ let probeError;
780
+ try {
781
+ const rows = await deps.executeParameterized(deps.lbugPath, `MATCH (:BasicBlock)-[r:CodeRelation]->(:BasicBlock) WHERE r.type IN ['CDG', 'REACHING_DEF'] RETURN r.type AS type LIMIT 1`, {});
782
+ edgesVisible = Array.isArray(rows) && rows.length > 0;
783
+ }
784
+ catch (err) {
785
+ // db-lock / missing-path / corrupt probe — fall through as not-visible, but
786
+ // keep the `'unknown'` signal AND preserve the probe failure. Reporting the
787
+ // failed probe as "no edges visible" hides a DB-health problem from operators.
788
+ probeError = err instanceof Error ? err.message : String(err);
789
+ edgesVisible = false;
790
+ }
791
+ if (probeError) {
792
+ return {
793
+ state: 'unknown',
794
+ probeError,
795
+ recoverySuggestion: 'Check for a LadybugDB lock/corruption or missing index path. Stop overlapping GitNexus processes, retry, or re-run gitnexus analyze --pdg.',
796
+ note: `PDG layer status unknown — CDG/REACHING_DEF probe failed: ${probeError}. ` +
797
+ `The layer cannot be confirmed complete; this is distinct from "no edges visible".`,
798
+ };
799
+ }
800
+ return {
801
+ state: 'unknown',
802
+ note: edgesVisible
803
+ ? 'PDG layer status unknown — CDG/REACHING_DEF edges ARE visible but meta is unreadable, so the layer cannot be confirmed complete (a partial layer looks the same); was this repo fully indexed with gitnexus analyze --pdg?'
804
+ : 'PDG layer status unknown — no CDG/REACHING_DEF edges visible and meta is unreadable; was this repo indexed with gitnexus analyze --pdg?',
805
+ };
806
+ }
807
+ /**
808
+ * Build the SAME BasicBlock seed anchor (`anchorClause` + `queryParams`) as
809
+ * `resolveBlockAnchor`'s symbol branch, but from an ALREADY-RESOLVED symbol —
810
+ * WITHOUT re-running `resolveSymbolCandidates`.
811
+ *
812
+ * Why this exists (correctness keystone): `_impactImpl` already resolves the
813
+ * target to a confident single symbol honoring the caller's
814
+ * `target_uid`/`file_path`/`kind` hints. Re-resolving by the bare `sym.name`
815
+ * inside `_runImpactPDG` would (a) RE-AMBIGUATE a globally-ambiguous name the
816
+ * caller had disambiguated (returning the "ambiguous" early payload instead of
817
+ * the PDG result), or (b) anchor the seed on a DIFFERENT same-name symbol in
818
+ * another file → a wrong-symbol blast radius. Anchoring directly from the
819
+ * resolved `{ filePath, startLine, endLine }` preserves the disambiguation.
820
+ *
821
+ * The window is byte-identical to `resolveBlockAnchor`'s symbol branch: BOTH
822
+ * span bounds are shifted `+1` (1-based BasicBlock `startLine` vs the 0-based
823
+ * symbol span — the lower `+1` excludes a neighbor's block on the line above,
824
+ * the upper `+1` keeps a guard/def/use on the final line). A symbol with no
825
+ * usable span degrades to the same file-level id-prefix filter. This is the
826
+ * resolved-symbol counterpart, NOT a second window convention.
827
+ */
828
+ function blockAnchorForResolvedSymbol(sym) {
829
+ const idPrefix = `BasicBlock:${sym.filePath}:`;
830
+ if (typeof sym.startLine === 'number' &&
831
+ typeof sym.endLine === 'number' &&
832
+ sym.endLine >= sym.startLine) {
833
+ return {
834
+ anchorClause: 'a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd',
835
+ queryParams: { idPrefix, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 },
836
+ };
837
+ }
838
+ return { anchorClause: 'a.id STARTS WITH $idPrefix', queryParams: { idPrefix } };
839
+ }
840
+ /**
841
+ * Build a STATEMENT seed anchor: the BasicBlock(s) starting at a specific
842
+ * 1-based source `line` WITHIN the resolved symbol. This is what makes
843
+ * `mode:'pdg'` useful — seeding the dependence slice on a single statement
844
+ * (the thing being changed) rather than the whole symbol. A whole-symbol seed
845
+ * captures every intra-procedural block, so the reachable-minus-seed set is
846
+ * empty (all intra reach is within the seed); a statement seed leaves the
847
+ * other dependent statements reachable. `BasicBlock.startLine` is 1-based and
848
+ * matches the source line, so no `+1` offset applies here (unlike the symbol
849
+ * span, where the 0-based symbol bounds are shifted). Bounded to the symbol's
850
+ * own span when known, so a line shared with a sibling symbol can't leak.
851
+ */
852
+ function blockAnchorForStatement(sym, line) {
853
+ const idPrefix = `BasicBlock:${sym.filePath}:`;
854
+ if (typeof sym.startLine === 'number' &&
855
+ typeof sym.endLine === 'number' &&
856
+ sym.endLine >= sym.startLine) {
857
+ return {
858
+ anchorClause: 'a.id STARTS WITH $idPrefix AND a.startLine = $line AND a.startLine >= $symStart AND a.startLine <= $symEnd',
859
+ queryParams: { idPrefix, line, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 },
860
+ };
861
+ }
862
+ return {
863
+ anchorClause: 'a.id STARTS WITH $idPrefix AND a.startLine = $line',
864
+ queryParams: { idPrefix, line },
865
+ };
866
+ }
867
+ /**
868
+ * One bounded, direction-aware BFS over CDG + REACHING_DEF starting from a set
869
+ * of seed blocks. Extracted (U1) from `runImpactPDG`'s intra loop so the
870
+ * inter-procedural descent reuses the EXACT same edge query and step/limit
871
+ * semantics — no reimplementation. The query, the one-past-`stepLimit` probe,
872
+ * the per-step truncation flag, and the depth-exhaustion flag are byte-identical
873
+ * to the original inline loop.
874
+ *
875
+ * `visited` is the caller's shared cycle/recursion guard: seeds are pre-added so
876
+ * they are never re-collected (the seed-minus-reachable convention), and any
877
+ * block already visited (a prior callee's seed, or already-reached) is skipped.
878
+ * Newly-discovered blocks are added to `visited` AND returned in `reachable`.
879
+ */
880
+ async function bfsReachableBlocks(input) {
881
+ const { lbugPath, exec, seedBlocks, visited, direction, depthBudget, stepLimit, probeLimit } = input;
882
+ const reachable = new Set();
883
+ // Seeds are pre-added to `visited` so they are never re-COLLECTED (the
884
+ // seed-minus-reachable convention), but the seed list still drives the first
885
+ // step's frontier — the guard blocks re-collection, not seed re-expansion.
886
+ for (const id of seedBlocks)
887
+ visited.add(id);
888
+ let frontier = [...seedBlocks];
889
+ let depthReached = 0;
890
+ let truncatedByDepth = false;
891
+ let truncatedByLimit = false;
892
+ const matchEndpoint = direction === 'downstream' ? 'a' : 'b';
893
+ const collectEndpoint = direction === 'downstream' ? 'b' : 'a';
894
+ for (let depth = 0; depth < depthBudget; depth++) {
895
+ if (frontier.length === 0)
896
+ break;
897
+ const rawRows = await exec(lbugPath, `MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
898
+ WHERE r.type IN ['CDG', 'REACHING_DEF'] AND ${matchEndpoint}.id IN $frontier
899
+ RETURN DISTINCT ${collectEndpoint}.id AS id
900
+ LIMIT ${probeLimit}`, { frontier });
901
+ // Narrow the awaited rows ONCE at the boundary (executeParameterized returns
902
+ // any[]) to a typed record shape, then read the aliased `id` via bracket
903
+ // access — no `as any` sprayed per field.
904
+ const rows = rawRows.slice(0, stepLimit);
905
+ depthReached = depth + 1;
906
+ if (rawRows.length > stepLimit)
907
+ truncatedByLimit = true;
908
+ const next = [];
909
+ for (const r of rows) {
910
+ const id = String(r['id'] ?? '');
911
+ if (!id || visited.has(id))
912
+ continue;
913
+ visited.add(id);
914
+ reachable.add(id);
915
+ next.push(id);
916
+ }
917
+ frontier = next;
918
+ }
919
+ if (frontier.length > 0)
920
+ truncatedByDepth = true;
921
+ return { reachable, depthReached, truncatedByDepth, truncatedByLimit };
922
+ }
923
+ /**
924
+ * Gather the resolved callee symbol ids invoked across a set of slice blocks
925
+ * (`BasicBlock.calleeIds`). Reuses the SHARED `splitCalleeIds` so the descent
926
+ * cannot diverge from `LocalBackend.calleeIdsOfBlocks` on the split/drop-sentinel
927
+ * logic. A pre-namespace-v4 index (no `calleeIds` column → empty cells) yields no
928
+ * ids, so the descent degrades cleanly to intra-only (no inter-procedural hop).
929
+ */
930
+ async function calleeIdsFromBlocks(lbugPath, blockIds, exec) {
931
+ const ids = new Set();
932
+ for (const { calleeIds } of await calleeIdsByBlock(lbugPath, blockIds, exec)) {
933
+ for (const id of calleeIds)
934
+ ids.add(id);
935
+ }
936
+ return ids;
937
+ }
938
+ /**
939
+ * Per-block variant of {@link calleeIdsFromBlocks}: keep the CALL block → its
940
+ * `calleeIds` mapping rather than flattening it. The return-value ascent (U-C4)
941
+ * needs this association — it re-seeds the caller's intra closure FROM the
942
+ * specific call block whose callee's `CALL_SUMMARY` licenses the ascent, so the
943
+ * flattened id-only set is insufficient. Reuses the SHARED `splitCalleeIds` so
944
+ * the split/drop-sentinel logic cannot diverge from the flattening caller. A
945
+ * block with no callee ids (empty/whitespace cell, or a pre-v4 index with no
946
+ * `calleeIds` column) yields an empty `calleeIds` — skipped by the consumer.
947
+ */
948
+ async function calleeIdsByBlock(lbugPath, blockIds, exec) {
949
+ if (blockIds.length === 0)
950
+ return [];
951
+ const rows = await exec(lbugPath, `MATCH (b:BasicBlock) WHERE b.id IN $ids RETURN b.id AS id, b.calleeIds AS calleeIds`, { ids: blockIds });
952
+ const out = [];
953
+ // Narrow the awaited rows ONCE at the boundary to a typed record shape; read
954
+ // the aliased cells via bracket access — no per-field `as any`.
955
+ for (const r of rows) {
956
+ const blockId = String(r['id'] ?? '');
957
+ if (!blockId)
958
+ continue;
959
+ const calleeIds = splitCalleeIds(r['calleeIds']);
960
+ if (calleeIds.length > 0)
961
+ out.push({ blockId, calleeIds });
962
+ }
963
+ return out;
964
+ }
965
+ /**
966
+ * Of a set of resolved callee symbol ids, which ones have a persisted
967
+ * `CALL_SUMMARY` self-loop edge recording a NON-EMPTY return-value ascent
968
+ * (≥1 formal parameter flows to the callee's return). This is the FU-C consumer
969
+ * side of the producer's per-callee summary (see `call-summary-codec.ts`).
970
+ *
971
+ * The summary is a self-loop on the Function/Method/Constructor node:
972
+ * `(c)-[r:CodeRelation {type:'CALL_SUMMARY'}]->(c) WHERE c.id IN $ids`. The
973
+ * `reason` carries the param→return bitset; `decodeCallSummary` unpacks it and
974
+ * NEVER throws — a malformed / absent / empty (`r:0`) summary yields NO entry
975
+ * (the sound default: never claim a false return-flow). A PRE-FU-C (v3) `--pdg`
976
+ * index has NO `CALL_SUMMARY` edges, so this returns the empty set and the
977
+ * ascent is a clean no-op (the intra slice is unchanged — the documented
978
+ * "re-index for CALL_SUMMARY" degradation).
979
+ */
980
+ async function calleesWithReturnFlow(lbugPath, calleeIds, exec) {
981
+ const out = new Set();
982
+ if (calleeIds.length === 0)
983
+ return out;
984
+ const rows = await exec(lbugPath, `MATCH (c)-[r:CodeRelation]->(c)
985
+ WHERE r.type = 'CALL_SUMMARY' AND c.id IN $ids
986
+ RETURN c.id AS id, r.reason AS reason`, { ids: calleeIds });
987
+ for (const r of rows) {
988
+ const id = String(r['id'] ?? '');
989
+ if (!id)
990
+ continue;
991
+ const decoded = decodeCallSummary(r['reason']);
992
+ // ARG→FORMAL trace precision: the conservative-but-sound default — ascend if
993
+ // ANY formal is return-flowing (the call site's argument is, by construction
994
+ // of the descent, in the slice: the call block is itself a slice block). A
995
+ // specific positional arg↔formal mapping is not cleanly recoverable at the
996
+ // coalesced call block (`BasicBlock.calleeIds` is an unordered set, not a
997
+ // per-arg list), so this never drops a real ascent; it may over-include
998
+ // (bounded — the result still flows to a slice statement). See the descent
999
+ // doc-comment + the result `note` caveat.
1000
+ if (decoded.ok && decoded.returnFlowParams.length > 0)
1001
+ out.add(id);
1002
+ }
1003
+ return out;
1004
+ }
1005
+ /**
1006
+ * Batch-resolve resolved callee symbol ids → their `{id,filePath,startLine,endLine}`
1007
+ * spans via ONE `s.id IN $ids` UNION-ALL query over Function/Method/Constructor —
1008
+ * the SAME query shape as `projectBlocksToSymbols`, but keyed on the RESOLVED
1009
+ * `s.id` (no same-line ambiguity, unlike the reverse block-to-symbol join). Ids
1010
+ * with no matching symbol (a callee resolved to a node kind without a CFG body,
1011
+ * or an out-of-repo id) simply produce no span and are skipped — never an error.
1012
+ */
1013
+ async function resolveCalleeSpans(lbugPath, calleeIds, exec) {
1014
+ if (calleeIds.length === 0)
1015
+ return [];
1016
+ const rows = await exec(lbugPath, `MATCH (s:\`Function\`) WHERE s.id IN $ids
1017
+ RETURN s.id AS id, s.filePath AS filePath, s.startLine AS startLine, s.endLine AS endLine
1018
+ UNION ALL
1019
+ MATCH (s:\`Method\`) WHERE s.id IN $ids
1020
+ RETURN s.id AS id, s.filePath AS filePath, s.startLine AS startLine, s.endLine AS endLine
1021
+ UNION ALL
1022
+ MATCH (s:\`Constructor\`) WHERE s.id IN $ids
1023
+ RETURN s.id AS id, s.filePath AS filePath, s.startLine AS startLine, s.endLine AS endLine`, { ids: calleeIds });
1024
+ const spans = [];
1025
+ // Narrow the awaited rows ONCE at the boundary to a typed record shape; read
1026
+ // the aliased columns via bracket access with Number()/String() coercion —
1027
+ // no per-field `as any` (the same boundary-narrowing the typed helpers use).
1028
+ for (const r of rows) {
1029
+ const id = String(r['id'] ?? '');
1030
+ const filePath = String(r['filePath'] ?? '');
1031
+ const startLine = Number(r['startLine']);
1032
+ const endLine = Number(r['endLine']);
1033
+ if (!id || !filePath || !Number.isFinite(startLine) || !Number.isFinite(endLine))
1034
+ continue;
1035
+ spans.push({ id, filePath, startLine, endLine });
1036
+ }
1037
+ return spans;
1038
+ }
1039
+ /**
1040
+ * Bounded inter-procedural forward closure (U1) — HRB context-INSENSITIVE
1041
+ * forward slicing, the shipped Joern approach. Starting from the intra-procedural
1042
+ * slice blocks, descend DOWNSTREAM through resolved call sites: per hop, gather
1043
+ * the slice blocks' `calleeIds`, resolve them to callee spans, seed each callee's
1044
+ * blocks, run the SAME CDG+REACHING_DEF BFS within the callee, and union the
1045
+ * newly-reachable blocks into the slice. Recurses to `depthBudget` FUNCTION hops.
1046
+ *
1047
+ * Termination is guaranteed by the shared `visited` set (a block is expanded at
1048
+ * most once across all hops) plus the depth cap. A total node cap (`nodeBudget`)
1049
+ * is the secondary guard against a pathological fan-out within the budget.
1050
+ *
1051
+ * Caveats (documented in the result note + bench/impact-pdg/README.md):
1052
+ * (1) context-insensitive — a dependence may be attributed to a callee only
1053
+ * reachable from a DIFFERENT call site of the same function (bounded
1054
+ * over-inclusion, the same imprecision callgraph mode already has);
1055
+ * (2) return-value ascent IS captured (via CALL_SUMMARY, U-C4): a caller
1056
+ * statement that depends on a callee's RETURN value is re-seeded into the
1057
+ * caller's continuation when the callee carries a persisted return-flow
1058
+ * summary. Out-parameter / mutated-argument ascent, callee-written shared /
1059
+ * captured variables, and exception ascent (a throw the caller catches)
1060
+ * remain deferred (they need an alias / try-catch model). A pre-FU-C (v3)
1061
+ * --pdg index has no CALL_SUMMARY edges → no return-value ascent there;
1062
+ * (3) no cross-boundary alias model;
1063
+ * (4) precision is bounded by the call RESOLVER's precision (multi-candidate
1064
+ * dispatch / C++ overload under-resolution flow through faithfully — sound,
1065
+ * never drops a real target).
1066
+ */
1067
+ async function interproceduralDescent(input) {
1068
+ const { lbugPath, exec, visited, depthBudget, intraDepthBudget, stepLimit, probeLimit, nodeBudget, } = input;
1069
+ const reachable = new Set();
1070
+ let truncatedByDepth = false;
1071
+ let truncatedByLimit = false;
1072
+ let truncatedByNodeCap = false;
1073
+ let hopsReached = 0;
1074
+ // The slice the next hop gathers callees FROM — starts as the intra slice, then
1075
+ // becomes each hop's newly-reached callee blocks.
1076
+ let sliceBlocks = [...input.initialSliceBlocks];
1077
+ // Guard against re-seeding the same callee function across hops. Pre-seeded
1078
+ // with the statement seed's owner function so recursion never re-broadens it.
1079
+ const seededCalleeIds = new Set(input.preSeededCalleeIds ?? []);
1080
+ // U-C4 return-value ascent: CALL blocks whose callee has a non-empty
1081
+ // CALL_SUMMARY return-flow → the call's result depends on the slice.
1082
+ const ascentBlocks = new Set();
1083
+ hopLoop: for (let hop = 0; hop < depthBudget; hop++) {
1084
+ if (sliceBlocks.length === 0)
1085
+ break;
1086
+ // Keep the CALL block → callee association (U-C4 needs it to re-seed the
1087
+ // caller's intra closure FROM the specific call block the ascent licenses);
1088
+ // the flattened id set still drives the descent's fresh-callee bookkeeping.
1089
+ const blockCallees = await calleeIdsByBlock(lbugPath, sliceBlocks, exec);
1090
+ const calleeIds = new Set();
1091
+ for (const { calleeIds: ids } of blockCallees)
1092
+ for (const id of ids)
1093
+ calleeIds.add(id);
1094
+ // ── U-C4 ascent: re-seed the caller's intra closure THROUGH call results ──
1095
+ // For each call block in THIS hop's slice, if ANY of its callees' return
1096
+ // values flow back (a non-empty CALL_SUMMARY), the call result is
1097
+ // slice-dependent (the call block is itself a slice block). Re-seed a
1098
+ // BOUNDED downstream BFS FROM the call block so the caller's continuation
1099
+ // that consumes the result is captured. Monotone: only ADDS to `reachable`,
1100
+ // reusing the shared `visited` set, so it stays bounded + terminating. A
1101
+ // pre-v4 index (no CALL_SUMMARY) yields no return-flowing callees → no-op.
1102
+ const returnFlowing = await calleesWithReturnFlow(lbugPath, [...calleeIds], exec);
1103
+ if (returnFlowing.size > 0) {
1104
+ for (const { blockId, calleeIds: ids } of blockCallees) {
1105
+ // Bound the ascent re-seeds the same way the descent bounds its per-span
1106
+ // BFS (line ~1496): a wide fan-out of return-flowing call blocks must not
1107
+ // run unbounded re-seeds inside a single hop. Mirror the descent's
1108
+ // node-cap short-circuit at the TOP of the loop, stamping the same flag.
1109
+ if (reachable.size > nodeBudget) {
1110
+ truncatedByNodeCap = true;
1111
+ break hopLoop;
1112
+ }
1113
+ if (!ids.some((id) => returnFlowing.has(id)))
1114
+ continue;
1115
+ // The call block is in the slice by construction; record it so the
1116
+ // statement projection surfaces the call statements whose results chain
1117
+ // through it (a coalesced call block spans several call lines).
1118
+ ascentBlocks.add(blockId);
1119
+ // Continue the caller's intra closure from the call block. The block is
1120
+ // already in `visited` (it is a slice block), so the shared-visited BFS
1121
+ // only adds genuinely-new downstream caller blocks (e.g. a SEPARATE
1122
+ // statement that uses the call result), never re-expanding the seed.
1123
+ const ascent = await bfsReachableBlocks({
1124
+ lbugPath,
1125
+ exec,
1126
+ seedBlocks: [blockId],
1127
+ visited,
1128
+ direction: 'downstream',
1129
+ depthBudget: intraDepthBudget,
1130
+ stepLimit,
1131
+ probeLimit,
1132
+ });
1133
+ if (ascent.truncatedByLimit)
1134
+ truncatedByLimit = true;
1135
+ for (const id of ascent.reachable)
1136
+ reachable.add(id);
1137
+ }
1138
+ }
1139
+ const freshIds = [...calleeIds].filter((id) => !seededCalleeIds.has(id));
1140
+ if (freshIds.length === 0)
1141
+ break;
1142
+ for (const id of freshIds)
1143
+ seededCalleeIds.add(id);
1144
+ const spans = await resolveCalleeSpans(lbugPath, freshIds, exec);
1145
+ if (spans.length === 0)
1146
+ break;
1147
+ hopsReached = hop + 1;
1148
+ // U13: fetch every callee's seed blocks CONCURRENTLY. The seed fetch was the
1149
+ // only per-span round-trip; running them in one wave collapses N sequential
1150
+ // round-trips. Each span runs the IDENTICAL query as before — same anchor,
1151
+ // same LIMIT, same slice — so the per-span seed set and the `exceeded`
1152
+ // (truncatedByLimit) flag are byte-identical; only the latency changes. The
1153
+ // flag is still APPLIED per span in the sequential loop below, so a span past
1154
+ // the node-budget short-circuit never sets it (prior semantics preserved).
1155
+ const spanSeeds = await Promise.all(spans.map(async (span) => {
1156
+ const { anchorClause, queryParams } = blockAnchorForResolvedSymbol(span);
1157
+ const rawSeedRows = await exec(lbugPath, `MATCH (a:BasicBlock) WHERE ${anchorClause} RETURN a.id AS id LIMIT ${probeLimit}`, queryParams);
1158
+ const exceeded = rawSeedRows.length > stepLimit;
1159
+ const seeds = rawSeedRows
1160
+ .slice(0, stepLimit)
1161
+ .map((r) => String(r['id'] ?? ''))
1162
+ .filter((id) => id.length > 0);
1163
+ return { exceeded, seeds };
1164
+ }));
1165
+ // U14: run each callee's BFS CONCURRENTLY against a PRIVATE clone of the
1166
+ // hop-start `visited` snapshot, then merge IN SPAN ORDER below. Cross-span
1167
+ // sharing of the mutable `visited` set was the ONLY thing forcing the BFS
1168
+ // sequential; the clones remove the race. The reachable set is the monotone
1169
+ // union of the per-callee closures (order-independent — `bfsReachableBlocks`
1170
+ // keys its query on the frontier, not `visited`; `visited` only gates
1171
+ // re-collection), so only the per-callee BFS DB round-trips run in parallel
1172
+ // while the merged result stays byte-identical. In a degraded case where a
1173
+ // per-callee BFS hits its depth/step limit, a sibling may expand through the
1174
+ // truncated region — a bounded, sound OVER-approximation, never fewer blocks.
1175
+ const visitedSnapshot = new Set(visited);
1176
+ const spanBfs = await Promise.all(spanSeeds.map(async ({ seeds }) => seeds.length === 0
1177
+ ? null
1178
+ : bfsReachableBlocks({
1179
+ lbugPath,
1180
+ exec,
1181
+ seedBlocks: seeds,
1182
+ visited: new Set(visitedSnapshot), // private clone — no cross-span race
1183
+ direction: 'downstream',
1184
+ // Per-callee intra DEPTH clamp (block-hops within the callee), NOT
1185
+ // the row budget — mirrors the top-level intra BFS depth clamp.
1186
+ depthBudget: intraDepthBudget,
1187
+ stepLimit,
1188
+ probeLimit,
1189
+ })));
1190
+ const hopReached = new Set();
1191
+ for (let si = 0; si < spans.length; si++) {
1192
+ // Node budget is checked INSIDE the per-span MERGE (in span order) so the
1193
+ // mid-hop short-circuit stays byte-identical: the cumulative reachable size
1194
+ // after merging spans 0..k equals the sequential path's, so the break fires
1195
+ // at the SAME span. A span past the break is never merged (its parallel BFS
1196
+ // result is discarded), exactly as the sequential loop never processed it.
1197
+ if (reachable.size > nodeBudget) {
1198
+ truncatedByNodeCap = true;
1199
+ break hopLoop;
1200
+ }
1201
+ const { exceeded, seeds: calleeSeeds } = spanSeeds[si];
1202
+ if (exceeded)
1203
+ truncatedByLimit = true;
1204
+ if (calleeSeeds.length === 0)
1205
+ continue;
1206
+ // A callee seed block IS reachable (the callee is invoked from the slice),
1207
+ // unlike the top-level seed which is the target itself.
1208
+ for (const id of calleeSeeds) {
1209
+ if (!visited.has(id)) {
1210
+ visited.add(id);
1211
+ reachable.add(id);
1212
+ hopReached.add(id);
1213
+ }
1214
+ }
1215
+ const bfs = spanBfs[si];
1216
+ if (bfs === null)
1217
+ continue;
1218
+ if (bfs.truncatedByLimit)
1219
+ truncatedByLimit = true;
1220
+ // The per-callee BFS ran against a clone, so fold its discovered blocks
1221
+ // into the shared `visited`/`reachable` here (the sequential path did this
1222
+ // inside the BFS); Sets dedup, so order across siblings is irrelevant.
1223
+ for (const id of bfs.reachable) {
1224
+ visited.add(id);
1225
+ reachable.add(id);
1226
+ hopReached.add(id);
1227
+ }
1228
+ }
1229
+ if (reachable.size > nodeBudget) {
1230
+ truncatedByNodeCap = true;
1231
+ break;
1232
+ }
1233
+ sliceBlocks = [...hopReached];
1234
+ }
1235
+ // Frontier of callees still expandable after the hop budget ⇒ depth truncation.
1236
+ // (Conservative: if the last hop reached blocks AND we used the full budget,
1237
+ // deeper callees may exist.)
1238
+ if (hopsReached >= depthBudget && sliceBlocks.length > 0)
1239
+ truncatedByDepth = true;
1240
+ return {
1241
+ reachable,
1242
+ hopsReached,
1243
+ truncatedByDepth,
1244
+ truncatedByLimit,
1245
+ truncatedByNodeCap,
1246
+ ascentBlocks,
1247
+ };
1248
+ }
1249
+ export async function runImpactPDG(deps) {
1250
+ const { repo, sym, direction, maxDepth, line, executeParameterized: exec } = deps;
1251
+ const callSummaryAvailable = deps.callSummaryAvailable === true;
1252
+ // `line` present ⇒ statement-anchored slice (the useful mode); absent ⇒
1253
+ // whole-symbol seed (intra-procedural reach collapses to empty for a
1254
+ // function — kept for back-compat, with a note steering the caller to `line`).
1255
+ const statementMode = typeof line === 'number' && Number.isInteger(line) && line >= 1;
1256
+ // `target` carries the call-graph-compatible shape (id/name/type/filePath) so
1257
+ // `collectImpactSymbolUids` keys on it identically to a callgraph result.
1258
+ const target = {
1259
+ id: sym.id,
1260
+ name: sym.name,
1261
+ type: deps.symType || 'Function',
1262
+ filePath: sym.filePath,
1263
+ };
1264
+ // Validate the per-step LIMIT as a positive integer (KTD11 — interpolated,
1265
+ // so it must be sanitised, never user-string-passed). A non-integer / out-of
1266
+ // range value (NaN, 1.5, negative, huge) is CLAMPED to the bounded default
1267
+ // rather than rejected: impact's `limit` is a soft page hint, and a clamp
1268
+ // keeps the safety tool producing a (flagged-bounded) radius instead of a
1269
+ // hard error. The clamp ceiling matches `pdg_query`'s validated max.
1270
+ const rawLimit = deps.limit;
1271
+ const stepLimit = Number.isInteger(rawLimit) && rawLimit >= 1 && rawLimit <= PDG_QUERY_MAX_LIMIT
1272
+ ? rawLimit
1273
+ : PDG_QUERY_DEFAULT_LIMIT;
1274
+ // Depth: clamp to the documented impact server max. The BFS issues one DB
1275
+ // query per depth level, so direct callTool callers must not bypass the
1276
+ // schema's maxDepth cap.
1277
+ const depthBudget = Number.isInteger(maxDepth) && maxDepth >= 1 ? Math.min(maxDepth, IMPACT_MAX_DEPTH) : 3;
1278
+ // ── Seed: anchor the target's BasicBlocks from the ALREADY-RESOLVED symbol ─
1279
+ // `_impactImpl` already resolved `sym` to a confident single match honoring
1280
+ // the caller's target_uid/file_path/kind hints. Re-resolving by the bare
1281
+ // `sym.name` here would RE-AMBIGUATE a disambiguated name (returning the
1282
+ // "ambiguous" early payload instead of the PDG result) or anchor the seed on
1283
+ // a DIFFERENT same-name symbol in another file (wrong-symbol blast radius).
1284
+ // So build the seed anchor DIRECTLY from the resolved symbol's
1285
+ // [startLine+1, endLine+1] window — the same window `resolveBlockAnchor`'s
1286
+ // symbol branch produces, without re-running `resolveSymbolCandidates`.
1287
+ const { anchorClause, queryParams } = statementMode
1288
+ ? blockAnchorForStatement(sym, line)
1289
+ : blockAnchorForResolvedSymbol(sym);
1290
+ const probeLimit = stepLimit + 1;
1291
+ const rawSeedRows = await exec(repo.lbugPath, `MATCH (a:BasicBlock) WHERE ${anchorClause} RETURN a.id AS id LIMIT ${probeLimit}`, queryParams);
1292
+ const seedRows = rawSeedRows.slice(0, stepLimit);
1293
+ let seedBlocks = seedRows
1294
+ .map((r) => String(r['id'] ?? ''))
1295
+ .filter((id) => id.length > 0);
1296
+ // Pin the OWNING function for a statement seed: a closure body block that
1297
+ // starts on the SAME source line as the seeded statement satisfies the
1298
+ // (forgiving) symbol-span window but belongs to a different function, so its
1299
+ // intra slice would leak in. The block id encodes the 1-based function start
1300
+ // line, and a block of THIS symbol has fnLine === sym.startLine + 1 (block
1301
+ // lines 1-based, symbol startLine 0-based). Drop foreign-fn seed blocks —
1302
+ // defensively: only when it leaves ≥1 seed, so a kind whose fnLine convention
1303
+ // differs never loses a real seed (it keeps the prior, slightly-loose set).
1304
+ if (statementMode && typeof sym.startLine === 'number') {
1305
+ const ownerFnLine = sym.startLine + 1;
1306
+ const owned = seedBlocks.filter((id) => fnLineOf(id) === ownerFnLine);
1307
+ if (owned.length > 0)
1308
+ seedBlocks = owned;
1309
+ }
1310
+ // FIX 7: the seed query probes one row past `stepLimit`, then processes at
1311
+ // most `stepLimit` rows like every BFS step. A function with more seed blocks
1312
+ // than `stepLimit` would silently under-seed (and thus under-report) — flag
1313
+ // it so the result carries the same truncation
1314
+ // signal the BFS steps do, never a silent partial seed.
1315
+ const seedTruncated = rawSeedRows.length > stepLimit;
1316
+ // ── KTD6 no-body contract: distinguish "no PDG body" from "no dependence" ──
1317
+ // A symbol that resolves but produces ZERO anchored blocks has no CFG body
1318
+ // (interface / type alias / abstract / ambient / one-line const). A bare
1319
+ // impactedCount:0 / risk:'LOW' would read as "safe to refactor" — the exact
1320
+ // false-safe `impact` exists to prevent (#2129/#1858). Surface an explicit
1321
+ // note + a non-LOW epistemic marker, never a silent confident zero.
1322
+ if (seedBlocks.length === 0) {
1323
+ return {
1324
+ mode: 'pdg',
1325
+ pdgResultVersion: PDG_RESULT_VERSION,
1326
+ target,
1327
+ direction,
1328
+ ...(statementMode ? { criterionLine: line } : {}),
1329
+ reachableBlocks: [],
1330
+ intraReachableBlocks: [],
1331
+ seedBlocks: [],
1332
+ blockCount: 0,
1333
+ affectedStatements: [],
1334
+ affectedStatementCount: 0,
1335
+ truncated: false,
1336
+ depthReached: 0,
1337
+ // statementMode: the requested line has no statement block inside the
1338
+ // symbol (blank line, comment, outside the body, or a line the CFG did
1339
+ // not materialise). Distinct from "no PDG body".
1340
+ epistemic: statementMode ? 'pdg-no-block-at-line' : 'no-pdg-body',
1341
+ note: statementMode
1342
+ ? `No PDG statement block starts at line ${line} within '${sym.name}' ` +
1343
+ `(${sym.filePath}). The line may be blank, a comment, a brace, or outside ` +
1344
+ `the symbol's body. Pass a line that begins an executable statement.`
1345
+ : `'${sym.name}' has no PDG body — no BasicBlocks / control- or data-dependence ` +
1346
+ `edges exist for this symbol (e.g. an interface, type alias, abstract/ambient ` +
1347
+ `member, or a one-line declaration with no CFG). This is NOT a confident ` +
1348
+ `"no impact": the local PDG statement slice cannot model this symbol kind. ` +
1349
+ `Inter-procedural symbol reach may still be attached by the unified impact dispatcher.`,
1350
+ impactedCount: 0,
1351
+ risk: 'UNKNOWN',
1352
+ // KTD8 parity fields so a consumer iterating byDepth / reading the
1353
+ // depth counts on a no-body result still finds a well-formed (empty)
1354
+ // shape rather than `undefined` (which would render as "isolated").
1355
+ ...emptyPdgParityFields(),
1356
+ unresolvedBlockCount: 0,
1357
+ ambiguousProjectionCount: 0,
1358
+ };
1359
+ }
1360
+ // ── Bounded direction-aware BFS over CDG + REACHING_DEF (KTD4, KTD11) ──────
1361
+ // Seed blocks are NOT counted as reachable (they ARE the target); the
1362
+ // reachable set is everything the BFS discovers from them. Visited tracks
1363
+ // BOTH seeds and discovered blocks so a cycle never re-expands. The traversal
1364
+ // is the shared `bfsReachableBlocks` — the SAME edge query / step-limit / probe
1365
+ // semantics the inter-procedural descent (U1) reuses, so the two cannot diverge.
1366
+ const visited = new Set(seedBlocks);
1367
+ const intra = await bfsReachableBlocks({
1368
+ lbugPath: repo.lbugPath,
1369
+ exec,
1370
+ seedBlocks,
1371
+ visited,
1372
+ direction,
1373
+ depthBudget,
1374
+ stepLimit,
1375
+ probeLimit,
1376
+ });
1377
+ const reachable = intra.reachable;
1378
+ // FIX 6: snapshot the INTRA-procedural reachable set BEFORE the U1 descent
1379
+ // expands `reachable` with inter-procedurally-reached callee blocks. The
1380
+ // callgraph bridge keys its "first-hop proven" set on seed ∪ intra-reachable
1381
+ // (the original statement slice) — feeding it the interproc-expanded superset
1382
+ // would mark transitively-reached (2+ hop) callgraph targets as first-hop
1383
+ // proven, silently shifting the established statementPrecision semantics.
1384
+ const intraReachableBlocks = [...reachable].sort();
1385
+ const depthReached = intra.depthReached;
1386
+ // `truncatedByDepth`: the BFS still had a non-empty frontier when the depth
1387
+ // budget ran out (more reachable blocks exist past `maxDepth`).
1388
+ // `truncatedByLimit`: a step's neighbour query hit the one-past LIMIT probe.
1389
+ // The SEED query is one-past-probed too, so `seedTruncated` seeds the flag — a
1390
+ // partial seed is a lower-bound expansion just like a partial step.
1391
+ let truncatedByDepth = intra.truncatedByDepth;
1392
+ let truncatedByLimit = seedTruncated || intra.truncatedByLimit;
1393
+ // ── U1: bounded inter-procedural forward closure (DOWNSTREAM only) ──────────
1394
+ // After the intra slice completes and BEFORE block→symbol projection, descend
1395
+ // through resolved call sites so the slice crosses function boundaries (HRB
1396
+ // context-insensitive forward closure — Joern's shipped approach). Gated to
1397
+ // downstream (the dispatcher's direction gate mirrors this): forward closure is
1398
+ // only meaningful when following calls forward. A pre-namespace-v4 index (no
1399
+ // `calleeIds`) yields no callee ids → the descent is a no-op → byte-identical
1400
+ // intra-only behavior. The inter-procedural reach DEEPENS `reachableBlocks`
1401
+ // (and thus `affectedStatements`); the owning-symbol `byDepth` stays a single
1402
+ // collapsed bucket (block-hops are not call-hops — the standing KTD8 contract;
1403
+ // every byDepth consumer iterates generically, so deepening the statement slice
1404
+ // — not the bucket count — is the correctness-preserving surface).
1405
+ let interproceduralHops = 0;
1406
+ // U-C4: CALL blocks whose callee's CALL_SUMMARY licensed a return-value ascent
1407
+ // (empty for an upstream slice, a pre-FU-C v3 index, or no return-flowing
1408
+ // callee). The statement projection expands these blocks to their interior
1409
+ // call lines (a coalesced call block spans several statements whose results
1410
+ // chain through it — the statement-granularity realisation of the ascent).
1411
+ let ascentBlocks = new Set();
1412
+ if (direction === 'downstream') {
1413
+ const interproc = await interproceduralDescent({
1414
+ lbugPath: repo.lbugPath,
1415
+ exec,
1416
+ // The slice the first hop gathers callees from = seeds ∪ intra reach. The
1417
+ // seed block(s) are included because a callee invoked directly on the
1418
+ // changed line is the most-directly-impacted one (seed-minus-reachable
1419
+ // excludes seeds from `reachable`, but they still call out to callees).
1420
+ initialSliceBlocks: [...seedBlocks, ...reachable],
1421
+ visited,
1422
+ depthBudget: INTERPROC_DEPTH_BUDGET,
1423
+ // FIX 1: the per-callee intra BFS depth is the SAME depth clamp the
1424
+ // top-level intra BFS uses — NOT `stepLimit` (a row/probe budget). A callee
1425
+ // must not be traversable up to PDG_QUERY_MAX_LIMIT dependence-levels deep.
1426
+ intraDepthBudget: depthBudget,
1427
+ stepLimit,
1428
+ probeLimit,
1429
+ nodeBudget: INTERPROC_NODE_BUDGET,
1430
+ // FIX 3: pre-seed the descent with the statement seed's OWNER FUNCTION so
1431
+ // direct/mutual recursion back to it never re-seeds the whole-function span
1432
+ // the statement slice deliberately excluded. The resolved target symbol id
1433
+ // (`sym.id`) is exactly that owner — `_impactImpl` already resolved it.
1434
+ preSeededCalleeIds: statementMode ? [sym.id] : [],
1435
+ });
1436
+ interproceduralHops = interproc.hopsReached;
1437
+ ascentBlocks = interproc.ascentBlocks;
1438
+ for (const id of interproc.reachable)
1439
+ reachable.add(id);
1440
+ if (interproc.truncatedByDepth)
1441
+ truncatedByDepth = true;
1442
+ if (interproc.truncatedByLimit)
1443
+ truncatedByLimit = true;
1444
+ // FIX 7: the node cap is a SIZE/budget limit, semantically 'limit' — NOT
1445
+ // 'depth' (which means dependence-level exhaustion). Map it to truncatedByLimit.
1446
+ if (interproc.truncatedByNodeCap)
1447
+ truncatedByLimit = true;
1448
+ // FIX 5: do NOT fold the inter-procedural FUNCTION-hop count into
1449
+ // `depthReached` (which means intra BLOCK-hop depth — a different unit). The
1450
+ // function-hop count is plumbed separately via `interproceduralHops`.
1451
+ }
1452
+ const reachableBlocks = [...reachable].sort();
1453
+ const truncated = truncatedByDepth || truncatedByLimit;
1454
+ // truncatedBy PRECEDENCE (U3): when BOTH depth and limit truncation fire, the
1455
+ // scalar `truncatedBy` reports 'depth' (the depth ternary is tested first), while
1456
+ // `truncatedByReasons` lists BOTH ['depth','limit']. Depth wins the scalar slot
1457
+ // because dependence-level exhaustion is the stronger "the slice is incomplete"
1458
+ // signal; the reasons array preserves that a size/limit cap also fired. This is a
1459
+ // standing precedence contract — keep the depth-first ternary and the both-fire
1460
+ // reasons array consistent.
1461
+ const truncatedBy = truncatedByDepth
1462
+ ? 'depth'
1463
+ : truncatedByLimit
1464
+ ? 'limit'
1465
+ : undefined;
1466
+ const truncatedByReasons = truncatedByDepth && truncatedByLimit ? ['depth', 'limit'] : undefined;
1467
+ // ── Resolve the reachable blocks to source statements (line + text) ────────
1468
+ // This is the useful output of statement mode: the dependent statements the
1469
+ // change at `line` reaches. Fetched once for the whole reachable set; sorted
1470
+ // by line. Failure surfaces (no `.catch` swallow) rather than masquerading
1471
+ // as "no affected statements".
1472
+ // Scope tag (FU-A): the criterion's OWN function is (sym.filePath, fnLine),
1473
+ // where fnLine follows the BasicBlock 1-based convention sym.startLine + 1
1474
+ // (the same window used to anchor the seed above). A symbol without a numeric
1475
+ // startLine has no owning-fn line to match, so `ownerFnLine` is NaN and every
1476
+ // statement tags as 'inter' (no false intra claim).
1477
+ const criterionFile = sym.filePath;
1478
+ const ownerFnLine = typeof sym.startLine === 'number' ? sym.startLine + 1 : Number.NaN;
1479
+ // ── FU-B-2 chain-walk blocks: statement-granular interior recovery ─────────
1480
+ // The SINGLE principled mechanism that expands a coalesced straight-line
1481
+ // BasicBlock to its interior dependent statements via the self REACHING_DEF
1482
+ // def→use LINE chain. Two contributors:
1483
+ // - the SEED block(s), seeded at the criterion `line` (statement mode only):
1484
+ // recovers the interior statements of the criterion's own coalesced block
1485
+ // that the block-granular slice lost (the U2 intra-dataflow-chain gap —
1486
+ // line 7's `7-9` block yields {8,9}). The seed block is NOT in
1487
+ // `reachableBlocks` (seeds are excluded), so the walk surfaces lines no
1488
+ // other projection reaches.
1489
+ // - each U-C4 ascent CALL block, seeded from its own start line (empty entry
1490
+ // set ⇒ the projection default-seeds at the block start): the principled
1491
+ // replacement for FU-C's blind all-interior-lines expansion.
1492
+ const chainWalkBlocks = new Map();
1493
+ if (statementMode) {
1494
+ for (const id of seedBlocks)
1495
+ chainWalkBlocks.set(id, new Set([line]));
1496
+ }
1497
+ // Every REACHABLE coalesced block is also walked, seeded from its own start
1498
+ // line (empty entry set ⇒ the projection default-seeds at the block start): a
1499
+ // block reached via CDG (a control-dependent body block) or REACHING_DEF can
1500
+ // itself coalesce a straight-line data-dep chain whose interior statements the
1501
+ // block-start projection would lose (e.g. the guard fixture's `const y; const
1502
+ // z = y + 1;` post-guard body — line 12 chains from line 11 via `y`). This is
1503
+ // the SAME principled self-edge walk, applied to the whole slice, not just the
1504
+ // seed.
1505
+ for (const id of reachableBlocks) {
1506
+ if (!chainWalkBlocks.has(id))
1507
+ chainWalkBlocks.set(id, new Set());
1508
+ }
1509
+ for (const id of ascentBlocks) {
1510
+ if (!chainWalkBlocks.has(id))
1511
+ chainWalkBlocks.set(id, new Set());
1512
+ }
1513
+ const affectedStatements = await pdgStatementsForBlocks(repo.lbugPath, reachableBlocks, exec, criterionFile, ownerFnLine, chainWalkBlocks);
1514
+ // ── Has a PDG body but no inter-block dependence reachability ──────────────
1515
+ // Distinct from "no PDG body": the function exists and has blocks, but no
1516
+ // CDG/REACHING_DEF edge leaves the target's blocks in this direction (no
1517
+ // DISTINCT downstream BasicBlock is reached). For a WHOLE-SYMBOL seed this is
1518
+ // the expected (and uninformative) result — every intra-procedural block is
1519
+ // already a seed — so the note steers to `line`. Still not a confident zero —
1520
+ // explicit note + UNKNOWN (KTD6/KTD8).
1521
+ //
1522
+ // Item 5 robustness: `affectedStatements` was ALREADY computed above with the
1523
+ // seed block(s) in `chainWalkBlocks`, so a criterion whose only dependents are
1524
+ // INTERIOR to its own coalesced seed block (a straight-line chain with no
1525
+ // distinct downstream block — e.g. `const a; const b = a*2; const c = b-3`
1526
+ // with no `return`) still has those recovered interior statements here. Surface
1527
+ // them rather than hardcoding `[]` (which would silently drop the seed-block
1528
+ // chain-walk on this path). `reachableBlocks` stays `[]` — those lines belong to
1529
+ // the seed block, correctly not a reachable BLOCK — but the statement slice is
1530
+ // not lost.
1531
+ if (reachableBlocks.length === 0) {
1532
+ return {
1533
+ mode: 'pdg',
1534
+ pdgResultVersion: PDG_RESULT_VERSION,
1535
+ target,
1536
+ direction,
1537
+ ...(statementMode ? { criterionLine: line } : {}),
1538
+ impactedCount: 0,
1539
+ risk: 'UNKNOWN',
1540
+ epistemic: 'pdg-intra-procedural',
1541
+ note: statementMode
1542
+ ? `No statement in '${sym.name}' is in a DISTINCT ${direction}-dependent block of ` +
1543
+ `line ${line} (no CDG/REACHING_DEF edge leaves the seed block in this direction). ` +
1544
+ `Any interior statements of the seed's own coalesced block that depend on line ${line} ` +
1545
+ `are surfaced in affectedStatements.`
1546
+ : `'${sym.name}' has a PDG body but a WHOLE-SYMBOL ${direction} slice is empty: ` +
1547
+ `intra-procedural dependence stays inside the function, so every reachable block ` +
1548
+ `is already part of the seed. Pass line:<N> to slice from a specific statement ` +
1549
+ `(what depends on the code at that line). Inter-procedural symbol reach is attached ` +
1550
+ `separately by the unified impact dispatcher.`,
1551
+ reachableBlocks: [],
1552
+ // Empty intra reach (reachableBlocks is empty here) — the bridge keys on
1553
+ // seed ∪ intra-reachable, so an empty intra slice leaves the bridge to seed
1554
+ // from the seed blocks alone (FIX 6 parity field).
1555
+ intraReachableBlocks: [],
1556
+ // Carry the real seed blocks (non-empty here — the function HAS blocks, they
1557
+ // are all seeds): a callee invoked directly on the seeded line must still be
1558
+ // provable even when the line has no downstream dependents (the seed-line FN
1559
+ // the tri-review found). Empty reachableBlocks must NOT zero the seed callees.
1560
+ seedBlocks,
1561
+ blockCount: 0,
1562
+ affectedStatements,
1563
+ affectedStatementCount: affectedStatements.length,
1564
+ depthReached,
1565
+ unresolvedBlockCount: 0,
1566
+ ambiguousProjectionCount: 0,
1567
+ ...(truncated ? { truncated: true } : {}),
1568
+ ...(truncatedBy ? { truncatedBy } : {}),
1569
+ ...(truncatedByReasons ? { truncatedByReasons } : {}),
1570
+ ...emptyPdgParityFields(),
1571
+ };
1572
+ }
1573
+ // ── U4: project reachable blocks → owning symbols, assemble parity result ──
1574
+ const projection = await projectBlocksToSymbols({
1575
+ lbugPath: repo.lbugPath,
1576
+ blockIds: reachableBlocks,
1577
+ executeParameterized: exec,
1578
+ });
1579
+ return assemblePdgImpactResult({
1580
+ target: {
1581
+ id: sym.id,
1582
+ name: sym.name,
1583
+ type: deps.symType || 'Function',
1584
+ filePath: sym.filePath,
1585
+ },
1586
+ direction,
1587
+ reachableBlocks,
1588
+ intraReachableBlocks,
1589
+ seedBlocks,
1590
+ affectedStatements,
1591
+ criterionLine: statementMode ? line : undefined,
1592
+ projection,
1593
+ depthReached,
1594
+ truncated,
1595
+ truncatedBy,
1596
+ truncatedByReasons,
1597
+ interproceduralHops,
1598
+ callSummaryAvailable,
1599
+ });
1600
+ }
1601
+ export function pdgBridgeEvidenceForImpact(input) {
1602
+ const { bridge, depth, calleeName, calleeId, inherited } = input;
1603
+ if (depth > 1) {
1604
+ return (inherited ?? {
1605
+ evidence: 'unproven-bridge',
1606
+ basis: 'first-hop evidence unavailable for inherited symbol-graph reach',
1607
+ });
1608
+ }
1609
+ const sliceCalleeNames = bridge.sliceCalleeNames;
1610
+ // Whole-symbol compatibility bridge ONLY when NEITHER key discriminates. The
1611
+ // empty-names guard must also require empty ids: `local-backend` builds the
1612
+ // bridge when names OR ids have signal, so an id-only slice (names empty/absent,
1613
+ // ids present — e.g. a block whose calls resolve to ids but carry no static leaf
1614
+ // name) must fall through to the resolved-id branch, not short-circuit to
1615
+ // "prove everything". (PR #2227 tri-review-2 headline.)
1616
+ if (sliceCalleeNames.size === 0 && bridge.sliceCalleeIds.size === 0) {
1617
+ return {
1618
+ evidence: 'callgraph-bridge',
1619
+ basis: 'whole-symbol PDG result uses symbol graph as compatibility bridge',
1620
+ };
1621
+ }
1622
+ // A slice block whose call sites were truncated at the per-statement cap has an
1623
+ // INCOMPLETE callee list, so absence from the set does not prove absence from
1624
+ // the slice. Keep such reach callgraph-equal rather than under-proving. (A capped
1625
+ // block always carries the sentinel in `callees`/names — the per-statement cap is
1626
+ // name-agnostic — so checking names suffices. `sliceCalleeNames` is always
1627
+ // present now (an id-only slice has it as an empty set, never capped), so the
1628
+ // direct `.has` is sound.)
1629
+ if (sliceCalleeNames.has(CALLEES_TRUNCATED_SENTINEL)) {
1630
+ return {
1631
+ evidence: 'callgraph-bridge',
1632
+ basis: 'a slice block truncated its call sites — callee set is incomplete (callee-unknown)',
1633
+ };
1634
+ }
1635
+ // Sound primary key (KTD3): when the slice carries resolved callee ids and the
1636
+ // block is not capped (sentinel handled above), prove by exact symbol-id match.
1637
+ // An id that is NOT in the present set is a real proof failure (`unproven-bridge`),
1638
+ // NOT a fall-through to the name predicate — the name path would re-leak the
1639
+ // same-name collision this key exists to eliminate.
1640
+ const sliceCalleeIds = bridge.sliceCalleeIds;
1641
+ if (sliceCalleeIds.size > 0) {
1642
+ const id = typeof calleeId === 'string' ? calleeId : '';
1643
+ if (id && sliceCalleeIds.has(id)) {
1644
+ return {
1645
+ evidence: 'callgraph-bridge',
1646
+ basis: 'callee id is invoked in a block of the local PDG dependence slice (resolved-symbol match)',
1647
+ };
1648
+ }
1649
+ return {
1650
+ evidence: 'unproven-bridge',
1651
+ basis: 'callee id is not invoked in any block of the local PDG dependence slice',
1652
+ };
1653
+ }
1654
+ // R3 graceful fallback: no captured ids (pre-v3 index / upstream / whole-symbol)
1655
+ // ⇒ use the leaf-name match.
1656
+ const name = typeof calleeName === 'string' ? calleeName : '';
1657
+ if (name && sliceCalleeNames.has(name)) {
1658
+ return {
1659
+ evidence: 'callgraph-bridge',
1660
+ basis: 'callee is invoked in a block of the local PDG dependence slice',
1661
+ };
1662
+ }
1663
+ return {
1664
+ evidence: 'unproven-bridge',
1665
+ basis: 'callee is not invoked in any block of the local PDG dependence slice',
1666
+ };
1667
+ }
1668
+ /**
1669
+ * Pick the stronger of two bridge-evidence verdicts for the same reached symbol.
1670
+ * `callgraph-bridge` (proven) beats `unproven-bridge`, so a node reachable from
1671
+ * multiple parents is proven if ANY parent proves it. This makes the
1672
+ * proven/unproven label order-independent of DB row iteration — a diamond-reached
1673
+ * symbol gets the same label regardless of which parent the BFS visits first
1674
+ * (PR #2227 tri-review, P3).
1675
+ */
1676
+ export function betterBridgeEvidence(existing, candidate) {
1677
+ if (!existing)
1678
+ return candidate;
1679
+ if (existing.evidence === 'callgraph-bridge')
1680
+ return existing;
1681
+ if (candidate.evidence === 'callgraph-bridge')
1682
+ return candidate;
1683
+ return existing;
1684
+ }
1685
+ function normalizePdgBridgeByDepth(byDepth) {
1686
+ const normalized = {};
1687
+ for (const [depthKey, items] of Object.entries(byDepth ?? {})) {
1688
+ const depth = Number(depthKey);
1689
+ if (!Number.isFinite(depth) || !Array.isArray(items))
1690
+ continue;
1691
+ normalized[depth] = items.map((item) => {
1692
+ if (!item || typeof item !== 'object')
1693
+ return item;
1694
+ const record = item;
1695
+ const evidence = typeof record.pdgEvidence === 'string'
1696
+ ? record.pdgEvidence
1697
+ : 'callgraph-bridge';
1698
+ return {
1699
+ ...record,
1700
+ pdgEvidence: evidence,
1701
+ ...(record.pdgEvidenceReason
1702
+ ? {}
1703
+ : {
1704
+ pdgEvidenceReason: evidence === 'unproven-bridge'
1705
+ ? 'symbol reached through the resolved symbol graph, but the existing graph did not prove the first-hop call site is in the local PDG slice'
1706
+ : 'symbol reached through the resolved symbol graph compatibility bridge',
1707
+ }),
1708
+ };
1709
+ });
1710
+ }
1711
+ return normalized;
1712
+ }
1713
+ function countPdgEvidence(byDepth) {
1714
+ const counts = {};
1715
+ for (const items of Object.values(byDepth ?? {})) {
1716
+ if (!Array.isArray(items))
1717
+ continue;
1718
+ for (const item of items) {
1719
+ if (!item || typeof item !== 'object')
1720
+ continue;
1721
+ const evidence = item.pdgEvidence;
1722
+ if (typeof evidence !== 'string')
1723
+ continue;
1724
+ counts[evidence] = (counts[evidence] ?? 0) + 1;
1725
+ }
1726
+ }
1727
+ return counts;
1728
+ }
1729
+ function dominantInterproceduralEvidence(counts) {
1730
+ if ((counts['unproven-bridge'] ?? 0) > 0)
1731
+ return 'unproven-bridge';
1732
+ if ((counts['callgraph-bridge'] ?? 0) > 0)
1733
+ return 'callgraph-bridge';
1734
+ return undefined;
1735
+ }
1736
+ /**
1737
+ * Statement-precise projection of the inter-procedural bridge: the subset PROVEN
1738
+ * to be invoked from the criterion's dependence slice (`callgraph-bridge`),
1739
+ * dropping `unproven-bridge` symbols — reachable in the call graph but only from
1740
+ * statements the changed line does not reach. Additive: the full
1741
+ * `interproceduralByDepth` is unchanged and still preserves callgraph reach; this
1742
+ * answers the tighter "which other functions does changing THIS line affect?".
1743
+ * For an upstream / whole-symbol seed there is no discriminating slice, so every
1744
+ * symbol is `callgraph-bridge` and the projection equals the full reach
1745
+ * (`statementPrecision` = 1). When the slice carries resolved callee ids the
1746
+ * projection is SOUND — a reached symbol is proven iff its resolved id matches a
1747
+ * slice-block id (resolved-symbol match), so neither a same-named out-of-slice
1748
+ * callee nor an aliased import perturbs the set. The leaf-name match is the
1749
+ * documented fallback (pre-v3 index / no captured ids), and only in that
1750
+ * name-fallback path is the projection a conservative SUPERSET (a callee invoked
1751
+ * from both an in-slice and out-of-slice site resolves to proven).
1752
+ */
1753
+ function projectStatementPreciseByDepth(byDepth) {
1754
+ const out = {};
1755
+ for (const [depthKey, items] of Object.entries(byDepth ?? {})) {
1756
+ const depth = Number(depthKey);
1757
+ if (!Number.isFinite(depth) || !Array.isArray(items))
1758
+ continue;
1759
+ const proven = items.filter((item) => item &&
1760
+ typeof item === 'object' &&
1761
+ item.pdgEvidence !== 'unproven-bridge');
1762
+ if (proven.length > 0)
1763
+ out[depth] = proven;
1764
+ }
1765
+ return out;
1766
+ }
1767
+ /**
1768
+ * Compose `mode:'pdg'` into one user-facing impact result:
1769
+ *
1770
+ * - `affectedStatements` / `reachableBlocks` stay owned by the persisted PDG
1771
+ * layer (CDG + REACHING_DEF), preserving the statement-level intra result.
1772
+ * - `interproceduralByDepth` / `pdgInterprocedural` expose the symbol reach;
1773
+ * `byDepth` stays as the compatibility symbol bucket so existing consumers
1774
+ * still see one PDG result shape.
1775
+ *
1776
+ * The callgraph option remains available as the comparator/default path; this
1777
+ * helper only changes the `pdg` result contract from intra-only to unified.
1778
+ */
1779
+ export function composeUnifiedPdgImpactResult(pdgResult, interproceduralResult, interproceduralError) {
1780
+ if ('error' in pdgResult || 'pdgLayer' in pdgResult)
1781
+ return pdgResult;
1782
+ const localByDepth = pdgResult.byDepth ?? {};
1783
+ const localByDepthCounts = pdgResult.byDepthCounts ?? {};
1784
+ const interproceduralByDepth = normalizePdgBridgeByDepth(interproceduralResult?.byDepth ?? {});
1785
+ const interproceduralByDepthCounts = interproceduralResult?.byDepthCounts ?? {};
1786
+ const interproceduralEvidenceCounts = countPdgEvidence(interproceduralByDepth);
1787
+ const interproceduralEvidence = dominantInterproceduralEvidence(interproceduralEvidenceCounts);
1788
+ // Additive statement-precise projection (see projectStatementPreciseByDepth):
1789
+ // the proven subset of the inter-procedural reach. `interproceduralByDepth`
1790
+ // above is unchanged and still preserves full callgraph reach.
1791
+ const statementPreciseByDepth = projectStatementPreciseByDepth(interproceduralByDepth);
1792
+ const statementPreciseByDepthCounts = {};
1793
+ for (const [depthKey, items] of Object.entries(statementPreciseByDepth)) {
1794
+ statementPreciseByDepthCounts[Number(depthKey)] = items.length;
1795
+ }
1796
+ const provenBridgeCount = interproceduralEvidenceCounts['callgraph-bridge'] ?? 0;
1797
+ const unprovenBridgeCount = interproceduralEvidenceCounts['unproven-bridge'] ?? 0;
1798
+ const statementPrecision = provenBridgeCount + unprovenBridgeCount > 0
1799
+ ? provenBridgeCount / (provenBridgeCount + unprovenBridgeCount)
1800
+ : null;
1801
+ const byDepth = {};
1802
+ const byDepthCounts = {};
1803
+ const depthKeys = Array.from(new Set([
1804
+ ...Object.keys(localByDepth),
1805
+ ...Object.keys(interproceduralByDepth),
1806
+ ...Object.keys(localByDepthCounts),
1807
+ ...Object.keys(interproceduralByDepthCounts),
1808
+ ]))
1809
+ .map((d) => Number(d))
1810
+ .filter((d) => Number.isFinite(d))
1811
+ .sort((a, b) => a - b);
1812
+ // Resolved symbol id of a byDepth item, or null for an unresolved/shadow item.
1813
+ const itemId = (item) => {
1814
+ if (item && typeof item === 'object' && 'id' in item) {
1815
+ const id = item.id;
1816
+ return typeof id === 'string' && id.length > 0 ? id : null;
1817
+ }
1818
+ return null;
1819
+ };
1820
+ // Cross-bucket dedup: a callee can surface in BOTH the local PDG block-expansion
1821
+ // AND the inter-procedural callgraph reach, so the raw `local + interproc` sums
1822
+ // double-count it. Track each layer's resolved ids to subtract the overlap from
1823
+ // the headline (cross-depth) and the per-depth counts.
1824
+ const localAllIds = new Set();
1825
+ const interAllIds = new Set();
1826
+ for (const depth of depthKeys) {
1827
+ const localItems = localByDepth[depth] ?? localByDepth[String(depth)] ?? [];
1828
+ const interItems = interproceduralByDepth[depth] ?? interproceduralByDepth[String(depth)] ?? [];
1829
+ const localDepthIds = new Set();
1830
+ for (const it of localItems) {
1831
+ const id = itemId(it);
1832
+ if (id !== null) {
1833
+ localDepthIds.add(id);
1834
+ localAllIds.add(id);
1835
+ }
1836
+ }
1837
+ // Drop an interproc item whose resolved id is already present locally AT THIS
1838
+ // DEPTH — keep the local item (richer projection). The SAME id reached at a
1839
+ // DIFFERENT depth is a legitimate distinct per-depth bucket and is retained
1840
+ // (so sum(byDepthCounts) can exceed the cross-depth `impactedCount`).
1841
+ let perDepthOverlap = 0;
1842
+ const dedupedInter = [];
1843
+ for (const it of interItems) {
1844
+ const id = itemId(it);
1845
+ if (id !== null)
1846
+ interAllIds.add(id);
1847
+ if (id !== null && localDepthIds.has(id)) {
1848
+ perDepthOverlap += 1;
1849
+ continue;
1850
+ }
1851
+ dedupedInter.push(it);
1852
+ }
1853
+ const items = [...localItems, ...dedupedInter];
1854
+ if (items.length > 0)
1855
+ byDepth[depth] = items;
1856
+ const localCount = localByDepthCounts[depth] ?? localByDepthCounts[String(depth)] ?? localItems.length;
1857
+ const interCount = interproceduralByDepthCounts[depth] ??
1858
+ interproceduralByDepthCounts[String(depth)] ??
1859
+ interItems.length;
1860
+ const totalCount = Math.max(0, localCount + interCount - perDepthOverlap);
1861
+ if (totalCount > 0)
1862
+ byDepthCounts[depth] = totalCount;
1863
+ }
1864
+ // Cross-depth overlap of resolved ids reached by BOTH layers. Computed from the
1865
+ // visible byDepth ids; if a layer's byDepth was display-truncated this is a
1866
+ // lower bound, so the resulting count is a safe over-estimate (never the old
1867
+ // double-count, never below the larger single layer).
1868
+ let crossOverlap = 0;
1869
+ for (const id of interAllIds)
1870
+ if (localAllIds.has(id))
1871
+ crossOverlap += 1;
1872
+ if (Object.keys(byDepthCounts).length === 0) {
1873
+ const localZero = localByDepthCounts[1] ?? localByDepthCounts['1'];
1874
+ const interZero = interproceduralByDepthCounts[1] ?? interproceduralByDepthCounts['1'];
1875
+ if (typeof localZero === 'number' || typeof interZero === 'number') {
1876
+ byDepthCounts[1] =
1877
+ (typeof localZero === 'number' ? localZero : 0) +
1878
+ (typeof interZero === 'number' ? interZero : 0);
1879
+ }
1880
+ }
1881
+ const localImpactedCount = typeof pdgResult.impactedCount === 'number' ? pdgResult.impactedCount : 0;
1882
+ const interproceduralImpactedCount = typeof interproceduralResult?.impactedCount === 'number'
1883
+ ? interproceduralResult.impactedCount
1884
+ : 0;
1885
+ // DISTINCT owning symbols across both layers (was localImpactedCount +
1886
+ // interproceduralImpactedCount, which double-counted a symbol reached by both).
1887
+ const impactedCount = Math.max(0, localImpactedCount + interproceduralImpactedCount - crossOverlap);
1888
+ // `direct` = distinct depth-1 reach. The deduped byDepthCounts[1] is exactly
1889
+ // that union, so it stays consistent with impactedCount (no separate layer sum).
1890
+ const directCount = byDepthCounts[1] ?? pdgResult.summary?.direct ?? localImpactedCount;
1891
+ const summary = interproceduralResult?.summary
1892
+ ? {
1893
+ ...interproceduralResult.summary,
1894
+ direct: directCount,
1895
+ }
1896
+ : {
1897
+ direct: directCount,
1898
+ processes_affected: 0,
1899
+ modules_affected: 0,
1900
+ };
1901
+ const affectedProcesses = interproceduralResult?.affected_processes ?? [];
1902
+ const affectedModules = interproceduralResult?.affected_modules ?? [];
1903
+ const partial = Boolean(interproceduralResult?.partial || interproceduralError);
1904
+ const errorMessage = interproceduralError instanceof Error
1905
+ ? interproceduralError.message
1906
+ : interproceduralError
1907
+ ? String(interproceduralError)
1908
+ : undefined;
1909
+ const noteParts = [
1910
+ pdgResult.note,
1911
+ `Inter-procedural symbol reach is included using the resolved symbol graph; ` +
1912
+ `statement-level PDG reach remains in affectedStatements. The symbol reach is ` +
1913
+ `labeled as a PDG evidence bridge, not as pure statement-level dependence.`,
1914
+ ];
1915
+ if (errorMessage) {
1916
+ noteParts.push(`Inter-procedural symbol reach failed (${errorMessage}); byDepth is therefore a lower bound.`);
1917
+ }
1918
+ else if (interproceduralResult?.epistemic === 'lower-bound') {
1919
+ noteParts.push(`The inter-procedural symbol reach is a lower bound because unresolved indirection was detected.`);
1920
+ }
1921
+ if ((interproceduralEvidenceCounts['unproven-bridge'] ?? 0) > 0) {
1922
+ noteParts.push(`${interproceduralEvidenceCounts['unproven-bridge']} inter-procedural ` +
1923
+ `symbol(s) are labeled unproven-bridge: the resolved symbol graph reaches them, ` +
1924
+ `but the current graph did not prove their first-hop call site is in the local PDG slice.`);
1925
+ }
1926
+ return {
1927
+ ...pdgResult,
1928
+ // Explicit (also carried by the `...pdgResult` spread) so the unified
1929
+ // mode:'pdg' exit always advertises the contract version.
1930
+ pdgResultVersion: PDG_RESULT_VERSION,
1931
+ impactedCount,
1932
+ note: noteParts.filter(Boolean).join(' '),
1933
+ summary,
1934
+ byDepthCounts,
1935
+ interproceduralByDepth,
1936
+ interproceduralByDepthCounts,
1937
+ // Statement-precise (proven) inter-procedural reach is emitted ONLY under
1938
+ // `pdgInterprocedural` below — a single source, no top-level duplicate.
1939
+ affected_processes: affectedProcesses,
1940
+ affected_modules: affectedModules,
1941
+ byDepth,
1942
+ ...(partial ? { partial: true } : {}),
1943
+ ...(interproceduralResult?.epistemic
1944
+ ? { interproceduralEpistemic: interproceduralResult.epistemic }
1945
+ : {}),
1946
+ ...(interproceduralResult?.boundaries
1947
+ ? { interproceduralBoundaries: interproceduralResult.boundaries }
1948
+ : {}),
1949
+ ...(errorMessage ? { interproceduralError: errorMessage } : {}),
1950
+ pdgEvidence: {
1951
+ // pdgResult is narrowed to the success/empty slice result by the
1952
+ // `'error' in / 'pdgLayer' in` guard at the top of this function, so
1953
+ // `pdgEvidence` is typed (optional) — no `as any`.
1954
+ ...(pdgResult.pdgEvidence ?? {}),
1955
+ ...(interproceduralEvidence ? { interprocedural: interproceduralEvidence } : {}),
1956
+ interproceduralEvidenceCounts,
1957
+ },
1958
+ pdgInterprocedural: {
1959
+ engine: 'symbol-graph',
1960
+ evidence: interproceduralEvidence ?? 'callgraph-bridge',
1961
+ impactedCount: interproceduralImpactedCount,
1962
+ byDepthCounts: interproceduralByDepthCounts,
1963
+ byDepth: interproceduralByDepth,
1964
+ evidenceCounts: interproceduralEvidenceCounts,
1965
+ statementPreciseByDepth,
1966
+ statementPreciseByDepthCounts,
1967
+ statementPreciseImpactedCount: provenBridgeCount,
1968
+ statementPrecision,
1969
+ partial,
1970
+ },
1971
+ };
1972
+ }