gitnexus 1.6.8-rc.8 → 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 (439) 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 +23 -2
  135. package/dist/core/ingestion/languages/c-cpp.js +17 -5
  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 +8 -1
  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 +44 -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/clone-safety.d.ts +109 -0
  238. package/dist/core/ingestion/workers/clone-safety.js +465 -0
  239. package/dist/core/ingestion/workers/parse-worker.d.ts +23 -0
  240. package/dist/core/ingestion/workers/parse-worker.js +81 -53
  241. package/dist/core/ingestion/workers/post-result.d.ts +22 -0
  242. package/dist/core/ingestion/workers/post-result.js +87 -0
  243. package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
  244. package/dist/core/ingestion/workers/result-merge.js +43 -0
  245. package/dist/core/ingestion/workers/worker-pool.d.ts +15 -40
  246. package/dist/core/ingestion/workers/worker-pool.js +43 -16
  247. package/dist/core/lbug/csv-generator.d.ts +51 -3
  248. package/dist/core/lbug/csv-generator.js +369 -255
  249. package/dist/core/lbug/lbug-adapter.d.ts +58 -18
  250. package/dist/core/lbug/lbug-adapter.js +346 -84
  251. package/dist/core/lbug/lbug-config.d.ts +19 -0
  252. package/dist/core/lbug/lbug-config.js +29 -0
  253. package/dist/core/lbug/pdg-emit-sink.d.ts +127 -0
  254. package/dist/core/lbug/pdg-emit-sink.js +358 -0
  255. package/dist/core/lbug/pool-adapter.d.ts +35 -0
  256. package/dist/core/lbug/pool-adapter.js +90 -1
  257. package/dist/core/lbug/rel-pair-routing.d.ts +54 -0
  258. package/dist/core/lbug/rel-pair-routing.js +140 -0
  259. package/dist/core/lbug/schema.d.ts +2 -2
  260. package/dist/core/lbug/schema.js +3 -0
  261. package/dist/core/logger.d.ts +0 -5
  262. package/dist/core/run-analyze.d.ts +96 -0
  263. package/dist/core/run-analyze.js +207 -3
  264. package/dist/core/tree-sitter/parser-loader.js +5 -4
  265. package/dist/core/tree-sitter/safe-parse.d.ts +0 -5
  266. package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
  267. package/dist/core/tree-sitter/vendored-grammars.js +57 -0
  268. package/dist/core/wiki/generator.js +3 -1
  269. package/dist/core/wiki/graph-queries.d.ts +6 -0
  270. package/dist/core/wiki/graph-queries.js +9 -1
  271. package/dist/mcp/http-transport.d.ts +134 -0
  272. package/dist/mcp/http-transport.js +503 -0
  273. package/dist/mcp/local/local-backend.d.ts +110 -24
  274. package/dist/mcp/local/local-backend.js +1225 -19
  275. package/dist/mcp/local/pdg-impact.d.ts +384 -0
  276. package/dist/mcp/local/pdg-impact.js +1972 -0
  277. package/dist/mcp/resources.js +13 -1
  278. package/dist/mcp/tools.d.ts +12 -0
  279. package/dist/mcp/tools.js +214 -7
  280. package/dist/server/analyze-launch.d.ts +29 -0
  281. package/dist/server/analyze-launch.js +137 -0
  282. package/dist/server/analyze-upload.d.ts +33 -0
  283. package/dist/server/analyze-upload.js +123 -0
  284. package/dist/server/analyze-worker-ipc.d.ts +58 -0
  285. package/dist/server/analyze-worker-ipc.js +16 -0
  286. package/dist/server/analyze-worker.d.ts +17 -1
  287. package/dist/server/analyze-worker.js +7 -1
  288. package/dist/server/api.d.ts +17 -0
  289. package/dist/server/api.js +132 -168
  290. package/dist/server/git-clone.d.ts +43 -2
  291. package/dist/server/git-clone.js +211 -16
  292. package/dist/server/mcp-http.d.ts +5 -6
  293. package/dist/server/mcp-http.js +9 -82
  294. package/dist/server/middleware.d.ts +40 -0
  295. package/dist/server/middleware.js +92 -0
  296. package/dist/server/private-ip.d.ts +1 -0
  297. package/dist/server/private-ip.js +15 -0
  298. package/dist/server/upload-ingest.d.ts +56 -0
  299. package/dist/server/upload-ingest.js +276 -0
  300. package/dist/server/upload-paths.d.ts +37 -0
  301. package/dist/server/upload-paths.js +57 -0
  302. package/dist/server/upload-sweep.d.ts +20 -0
  303. package/dist/server/upload-sweep.js +57 -0
  304. package/dist/storage/git.js +5 -8
  305. package/dist/storage/parse-cache.d.ts +22 -1
  306. package/dist/storage/parse-cache.js +44 -10
  307. package/dist/storage/repo-manager.d.ts +121 -10
  308. package/dist/storage/repo-manager.js +43 -5
  309. package/dist/types/pipeline.d.ts +9 -0
  310. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +110 -10
  311. package/hooks/claude/gitnexus-hook.cjs +110 -13
  312. package/hooks/claude/hook-db-lock-probe.cjs +513 -26
  313. package/package.json +8 -2
  314. package/scripts/assert-publish-grammar-coverage.cjs +31 -0
  315. package/scripts/build-tree-sitter-grammars.cjs +16 -10
  316. package/scripts/cross-platform-tests.ts +1 -0
  317. package/skills/gitnexus-debugging.md +16 -4
  318. package/skills/gitnexus-exploring.md +3 -3
  319. package/skills/gitnexus-guide.md +33 -0
  320. package/skills/gitnexus-pdg-query.md +89 -0
  321. package/skills/gitnexus-refactoring.md +1 -1
  322. package/skills/gitnexus-taint-analysis.md +178 -0
  323. package/vendor/tree-sitter-c/package.json +1 -1
  324. package/vendor/tree-sitter-dart/package.json +1 -1
  325. package/vendor/tree-sitter-kotlin/package.json +1 -1
  326. package/vendor/tree-sitter-proto/package.json +1 -1
  327. package/vendor/tree-sitter-swift/package.json +1 -1
  328. package/web/assets/{agent-CKPMqImC.js → agent-D3Xd7zZE.js} +123 -123
  329. package/web/assets/architecture-7EHR7CIX-6QZW5X65-DxJw65fT.js +1 -0
  330. package/web/assets/architectureDiagram-UL44E2DR-ChH9xW-N.js +36 -0
  331. package/web/assets/{blockDiagram-7IZFK4PR-B4niSLvR.js → blockDiagram-7IZFK4PR-BkWhpzWB.js} +1 -1
  332. package/web/assets/{c4Diagram-Y2BXMSZH-DIzEXalR.js → c4Diagram-Y2BXMSZH-DtBvpzWD.js} +1 -1
  333. package/web/assets/{chunk-3SSMPTDK-BzGeeheI.js → chunk-3SSMPTDK-B4_etUhr.js} +2 -2
  334. package/web/assets/{chunk-5IMINLNL-CnTLsXIV.js → chunk-5IMINLNL-DOdTeQri.js} +1 -1
  335. package/web/assets/{chunk-5VCL7Z4A-w1BCx98Z.js → chunk-5VCL7Z4A-CQ-2dYWj.js} +1 -1
  336. package/web/assets/chunk-6764PJDD-DpIS7hxD.js +1 -0
  337. package/web/assets/{chunk-67TQ5CYL-CChC2cAv.js → chunk-67TQ5CYL-4sNPJU1q.js} +3 -3
  338. package/web/assets/{chunk-7W6UQGC5-CgFyfZ6k.js → chunk-7W6UQGC5-KyEG0HQg.js} +1 -1
  339. package/web/assets/{chunk-AZZRMDJM-5E9ARdDQ.js → chunk-AZZRMDJM-y-xJBuMz.js} +1 -1
  340. package/web/assets/{chunk-INKRHTLW-ELpfuk9h.js → chunk-INKRHTLW-Nu4ri9P-.js} +1 -1
  341. package/web/assets/{chunk-JQRUD6KW-Dcq_Qnyy.js → chunk-JQRUD6KW-Dbva2Z17.js} +1 -1
  342. package/web/assets/{chunk-KGFNY3KK-W-VR57xb.js → chunk-KGFNY3KK-JGMJvKCK.js} +1 -1
  343. package/web/assets/chunk-KGYTTC2M-CWC_c3H9.js +161 -0
  344. package/web/assets/{chunk-KNLZD3CH-BuZBOcso.js → chunk-KNLZD3CH-BGlP6X9R.js} +1 -1
  345. package/web/assets/{chunk-KRXBNO2N-DH2NPM7I.js → chunk-KRXBNO2N-BW5o0KUN.js} +1 -1
  346. package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-zoomRtWX.js} +1 -1
  347. package/web/assets/{chunk-LII3EMHJ-3X33tCpU.js → chunk-LII3EMHJ-D-jm-dLa.js} +1 -1
  348. package/web/assets/{chunk-QA3QBVWF-D5Ha3GgB.js → chunk-QA3QBVWF-BgRW3SXd.js} +1 -1
  349. package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-DD0B2N2B.js} +1 -1
  350. package/web/assets/{chunk-T2UQINTJ-kGYG7ppP.js → chunk-T2UQINTJ-DSBqkzH7.js} +1 -1
  351. package/web/assets/chunk-T5OCTHI4-DdZvN-9i.js +1 -0
  352. package/web/assets/{chunk-UY5QBCOK-Du-c56gs.js → chunk-UY5QBCOK-DyF0vyGd.js} +1 -1
  353. package/web/assets/{chunk-W44A43WB-lrCpuupA.js → chunk-W44A43WB-JqMLIpR0.js} +1 -1
  354. package/web/assets/{chunk-ZXARS5L4-CSZRiyOf.js → chunk-ZXARS5L4-CxN8oiwI.js} +1 -1
  355. package/web/assets/classDiagram-KGZ6W3CR-DJm9BgF-.js +1 -0
  356. package/web/assets/classDiagram-v2-72OJOZXJ-DJm9BgF-.js +1 -0
  357. package/web/assets/{context-builder-_HS0v2ma.js → context-builder-BHiFUA8O.js} +10 -2
  358. package/web/assets/{cose-bilkent-UX7MHV2Q-BHv2bbtl.js → cose-bilkent-UX7MHV2Q-D71wNYRJ.js} +1 -1
  359. package/web/assets/dagre-ND4H6XIP-BIkxmhcu.js +4 -0
  360. package/web/assets/diagram-3NCE3AQN-BTa6iH0T.js +43 -0
  361. package/web/assets/diagram-GF46GFSD-DZDV0vBj.js +24 -0
  362. package/web/assets/{diagram-HNR7UZ2L-fUWreZzx.js → diagram-HNR7UZ2L-DFCfd5LI.js} +2 -2
  363. package/web/assets/diagram-QXG6HAR7-BzM--Jj0.js +24 -0
  364. package/web/assets/diagram-WEQXMOUZ-DFFwz0k4.js +10 -0
  365. package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-CfHMJJqo.js} +1 -1
  366. package/web/assets/eventmodeling-FCH6USID-MREXMVOE-BQm9QzEa.js +1 -0
  367. package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-Be805QiJ.js} +3 -3
  368. package/web/assets/{ganttDiagram-JCBTUEKG-BHQssTz6.js → ganttDiagram-JCBTUEKG-D3iL2Aet.js} +1 -1
  369. package/web/assets/gitGraph-WXDBUCRP-R675I2BI-Cr_Bm2Nb.js +1 -0
  370. package/web/assets/gitGraphDiagram-S2ZK5IYY-CPIDgxGm.js +106 -0
  371. package/web/assets/index-BvYev1UJ.js +635 -0
  372. package/web/assets/index-_lgn7hs5.css +2 -0
  373. package/web/assets/info-J43DQDTF-KCYPFFUO-Cs4p2oyk.js +1 -0
  374. package/web/assets/infoDiagram-3YFTVSEB-Y7BKSoke.js +2 -0
  375. package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CT4SHFuI.js} +1 -1
  376. package/web/assets/{journeyDiagram-M6C3CM3L-DNPYCpvq.js → journeyDiagram-M6C3CM3L-CvArszLo.js} +1 -1
  377. package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-C6yDxr7N.js} +3 -3
  378. package/web/assets/{katex-K3KEBU37-CrVLz8l7.js → katex-K3KEBU37-CTc5BslQ.js} +1 -1
  379. package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-BOc2WEne.js} +1 -1
  380. package/web/assets/packet-YPE3B663-LP52Z2RK-CKCrztD2.js +1 -0
  381. package/web/assets/pie-LRSECV5Y-TCRJHUBD-BB7pHqoD.js +1 -0
  382. package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-Cvc0f_Pd.js} +1 -1
  383. package/web/assets/{quadrantDiagram-VICAPDV7-bAm6Hi_5.js → quadrantDiagram-VICAPDV7-yXSZ2lLz.js} +1 -1
  384. package/web/assets/radar-GUYGQ44K-RDLRG3WG-DhoTOcuK.js +1 -0
  385. package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-BRcZvHs4.js} +1 -1
  386. package/web/assets/{sankeyDiagram-URQDO5SZ-LFJz53u5.js → sankeyDiagram-URQDO5SZ-1qEjlFIB.js} +1 -1
  387. package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-PcSCuvrB.js} +3 -3
  388. package/web/assets/stateDiagram-7D4R322I-D6qYSV1E.js +1 -0
  389. package/web/assets/stateDiagram-v2-36443NZ5-Bg-ImOSX.js +1 -0
  390. package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-QWkh01xw.js} +1 -1
  391. package/web/assets/treeView-BLDUP644-QA4HXRO3-ODehyGKL.js +1 -0
  392. package/web/assets/treemap-LRROVOQU-LLAWBHMP-BfpgC-7o.js +1 -0
  393. package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-ak4DfwuM.js} +3 -3
  394. package/web/assets/wardley-L42UT6IY-5TKZOOLJ-CNeL6VHE.js +1 -0
  395. package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-CTzR4Loq.js} +1 -1
  396. package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-DA1BEaQz.js} +1 -1
  397. package/web/index.html +13 -15
  398. package/scripts/materialize-vendor-grammars.cjs +0 -97
  399. package/web/assets/architecture-7EHR7CIX-6QZW5X65-BETj2x4g.js +0 -1
  400. package/web/assets/architectureDiagram-UL44E2DR-D1EXI0zA.js +0 -36
  401. package/web/assets/chunk-2T2R6R2M-n6s9JZqv.js +0 -4
  402. package/web/assets/chunk-2UTLFMKG-CMBB1TMN.js +0 -1
  403. package/web/assets/chunk-4R4BOZG6-mKhL59ul.js +0 -159
  404. package/web/assets/chunk-6764PJDD-ChwMM2z4.js +0 -1
  405. package/web/assets/chunk-7J6CGLKN-DT-b53FT.js +0 -10
  406. package/web/assets/chunk-C62D2QBJ-CDAWj26E.js +0 -1
  407. package/web/assets/chunk-CEXFNPSA-D68Tk6ls.js +0 -1
  408. package/web/assets/chunk-CilyBKbf.js +0 -1
  409. package/web/assets/chunk-J5EP6P6S-DxWW0yvu.js +0 -1
  410. package/web/assets/chunk-KGYTTC2M-C6PHeuay.js +0 -1
  411. package/web/assets/chunk-RERM46MO-DQNbXtfw.js +0 -1
  412. package/web/assets/chunk-RKZBBQEN-BmTPLSyv.js +0 -1
  413. package/web/assets/chunk-RLI5ZMPA-DExu2DOK.js +0 -1
  414. package/web/assets/chunk-T5OCTHI4--9wWpVws.js +0 -1
  415. package/web/assets/chunk-UP6H54XL-DsKdC6jC.js +0 -1
  416. package/web/assets/chunk-UXSXWOXI-DR81EqLr.js +0 -1
  417. package/web/assets/classDiagram-KGZ6W3CR-Bv93af_b.js +0 -1
  418. package/web/assets/classDiagram-v2-72OJOZXJ-CLfEqHUa.js +0 -1
  419. package/web/assets/dagre-ND4H6XIP-DsIr9MOv.js +0 -4
  420. package/web/assets/diagram-3NCE3AQN-BqAtKUpW.js +0 -43
  421. package/web/assets/diagram-GF46GFSD-CaBG6n6o.js +0 -24
  422. package/web/assets/diagram-QXG6HAR7-CZ-O3rcV.js +0 -24
  423. package/web/assets/diagram-WEQXMOUZ-P4lSL4GH.js +0 -10
  424. package/web/assets/eventmodeling-FCH6USID-MREXMVOE-B3myHUst.js +0 -1
  425. package/web/assets/gitGraph-WXDBUCRP-R675I2BI-DF3rtf4A.js +0 -1
  426. package/web/assets/gitGraphDiagram-S2ZK5IYY-CCwVLT__.js +0 -106
  427. package/web/assets/index-BKWA-m7o.css +0 -2
  428. package/web/assets/index-CG6q8eTs.js +0 -626
  429. package/web/assets/info-J43DQDTF-KCYPFFUO-R4XV_akJ.js +0 -1
  430. package/web/assets/infoDiagram-3YFTVSEB-DmLICZx1.js +0 -2
  431. package/web/assets/packet-YPE3B663-LP52Z2RK-CgQPdNZa.js +0 -1
  432. package/web/assets/pie-LRSECV5Y-TCRJHUBD-BzWOwc_c.js +0 -1
  433. package/web/assets/radar-GUYGQ44K-RDLRG3WG-B7KG0FN7.js +0 -1
  434. package/web/assets/stateDiagram-7D4R322I-CB2nABwH.js +0 -1
  435. package/web/assets/stateDiagram-v2-36443NZ5-COBGd2RL.js +0 -1
  436. package/web/assets/treeView-BLDUP644-QA4HXRO3-CRSxEwLO.js +0 -1
  437. package/web/assets/treemap-LRROVOQU-LLAWBHMP-a0PfKoVn.js +0 -1
  438. package/web/assets/wardley-L42UT6IY-5TKZOOLJ-BKQ6e5mC.js +0 -1
  439. /package/web/assets/{chunk-AQ6EADP3-CCFje6lL.js → chunk-AQ6EADP3-CZhslHi-.js} +0 -0
@@ -27,7 +27,41 @@ import { getExactScanLimit, isVectorExtensionSupportedByPlatform, } from '../../
27
27
  import { PhaseTimer } from '../../core/search/phase-timer.js';
28
28
  import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
29
29
  import { logger } from '../../core/logger.js';
30
- import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
30
+ import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT, EXPLAIN_DEFAULT_LIMIT, EXPLAIN_MAX_LIMIT, PDG_QUERY_DEFAULT_LIMIT, PDG_QUERY_MAX_LIMIT, } from '../tools.js';
31
+ import { findImportCycles } from '../../core/graph/import-cycles.js';
32
+ import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js';
33
+ import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js';
34
+ import { EXTENSIONS } from '../../core/ingestion/import-resolvers/utils.js';
35
+ import { fnLineOf, isPdgDegradedLayerStatus, makePdgImpactErrorResult, makePdgLayerDegradedResult, pdgLayerStatus, pdgStampForMode, runImpactPDG, validateImpactMode, pdgBridgeEvidenceForImpact, betterBridgeEvidence, composeUnifiedPdgImpactResult, splitCalleeIds, } from './pdg-impact.js';
36
+ /** Real source-file extensions (`.ts`, `.py`, …) from the resolver's list,
37
+ * excluding the empty entry and the `/index.*` forms — used to decide whether
38
+ * an `explain` target is a file path vs a (possibly dotted) symbol name. */
39
+ const SOURCE_FILE_EXTENSIONS = EXTENSIONS.filter((e) => e.startsWith('.') && !e.includes('/'));
40
+ /** A target is path-ish if it has a path separator or ends in a known source
41
+ * extension. A bare dotted symbol (`UserController.create`) is NOT path-ish. */
42
+ function looksLikeFilePath(target) {
43
+ if (/[\\/]/.test(target))
44
+ return true;
45
+ const lower = target.toLowerCase();
46
+ return SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
47
+ }
48
+ /**
49
+ * Resolve a string tool param from its canonical name or legacy alias (#2175).
50
+ * Returns the first NON-BLANK string of [canonical, legacy] — the canonical (new)
51
+ * name is preferred when it carries a real value, otherwise the legacy value is used.
52
+ * A blank/whitespace new value therefore does NOT clobber a valid legacy value (e.g. a
53
+ * gradually-migrating client that always emits the new key, blank when unset). A
54
+ * non-string value (the MCP envelope is not schema-validated, so clients can send any
55
+ * JSON type) and an all-blank input resolve to `undefined`, so the caller returns a
56
+ * friendly required-param error instead of throwing `TypeError` on `.trim()`.
57
+ */
58
+ function resolveAliasString(canonical, legacy) {
59
+ for (const value of [canonical, legacy]) {
60
+ if (typeof value === 'string' && value.trim())
61
+ return value;
62
+ }
63
+ return undefined;
64
+ }
31
65
  // AI context generation is CLI-only (gitnexus analyze)
32
66
  // import { generateAIContextFiles } from '../../cli/ai-context.js';
33
67
  /**
@@ -1015,6 +1049,15 @@ export class LocalBackend {
1015
1049
  return this.handleGroupTool(method, params || {});
1016
1050
  }
1017
1051
  const p = params && typeof params === 'object' ? params : {};
1052
+ // #2175: Claude Code drops a tool-call argument named exactly "query", so the
1053
+ // query/cypher tools advertise "search_query"/"statement" while still accepting the
1054
+ // legacy "query" key for backward compat. The alias is resolved with `?? ` (new name
1055
+ // wins) at every consumer site rather than by mutating params here, so precedence is
1056
+ // uniform and there is no hidden mutation: query()/cypher() read it directly, the
1057
+ // legacy "search" alias routes through query(), and the cross-repo group-forward
1058
+ // resolves it self-contained in callToolAtGroupRepo. This is permanent compatibility
1059
+ // — third-party MCP clients may legitimately send "query", so the alias is not slated
1060
+ // for removal even if Claude Code's argument handling later changes.
1018
1061
  if ((method === 'impact' || method === 'query' || method === 'context') &&
1019
1062
  typeof p.repo === 'string' &&
1020
1063
  p.repo.startsWith('@')) {
@@ -1033,10 +1076,16 @@ export class LocalBackend {
1033
1076
  }
1034
1077
  case 'context':
1035
1078
  return this.context(repo, params);
1079
+ case 'explain':
1080
+ return this.explain(repo, params);
1081
+ case 'pdg_query':
1082
+ return this.pdgQuery(repo, params);
1036
1083
  case 'impact':
1037
1084
  return this.impact(repo, params);
1038
1085
  case 'detect_changes':
1039
1086
  return this.detectChanges(repo, params);
1087
+ case 'check':
1088
+ return this.check(repo, params);
1040
1089
  case 'rename':
1041
1090
  return this.rename(repo, params);
1042
1091
  // Legacy aliases for backwards compatibility
@@ -1054,11 +1103,44 @@ export class LocalBackend {
1054
1103
  return this.toolMap(repo, params);
1055
1104
  case 'api_impact':
1056
1105
  return this.apiImpact(repo, params);
1106
+ case 'trace':
1107
+ return this.trace(repo, params);
1057
1108
  default:
1058
1109
  throw new Error(`Unknown tool: ${method}`);
1059
1110
  }
1060
1111
  }
1061
1112
  // ─── Tool Implementations ────────────────────────────────────────
1113
+ /** Check repository graph invariants that are suitable for CI gating. */
1114
+ async check(repo, params) {
1115
+ if (params?.cycles === false) {
1116
+ return { error: 'No checks selected. Set "cycles" to true.' };
1117
+ }
1118
+ await this.ensureInitialized(repo);
1119
+ const rowLimit = 100_001;
1120
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (source:File)-[r:CodeRelation]->(target:File)
1121
+ WHERE r.type = 'IMPORTS'
1122
+ AND (r.reason IS NULL OR (
1123
+ r.reason <> 'swift-scope: implicit module visibility'
1124
+ AND r.reason <> 'markdown-link'
1125
+ ))
1126
+ RETURN source.filePath AS source, target.filePath AS target
1127
+ LIMIT ${rowLimit}`, {});
1128
+ if (rows.length === rowLimit) {
1129
+ return {
1130
+ error: `Import graph exceeds the ${rowLimit - 1} edge safety limit.`,
1131
+ truncated: true,
1132
+ };
1133
+ }
1134
+ const cycles = findImportCycles(rows.map((row) => ({
1135
+ source: String(row.source ?? row[0] ?? ''),
1136
+ target: String(row.target ?? row[1] ?? ''),
1137
+ })));
1138
+ return {
1139
+ status: cycles.length === 0 ? 'clean' : 'cycles_found',
1140
+ cycleCount: cycles.length,
1141
+ cycles: cycles.map((files) => ({ files })),
1142
+ };
1143
+ }
1062
1144
  /**
1063
1145
  * Query tool — process-grouped search.
1064
1146
  *
@@ -1068,14 +1150,18 @@ export class LocalBackend {
1068
1150
  * 4. Return: { processes, process_symbols, definitions }
1069
1151
  */
1070
1152
  async query(repo, params) {
1071
- if (!params.query?.trim()) {
1072
- return { error: 'query parameter is required and cannot be empty.' };
1153
+ // #2175: each consumer resolves the search_query/query alias itself (there is no
1154
+ // chokepoint mutation in callTool). This also serves the GroupService port, which
1155
+ // reaches query() carrying only the legacy `query` key.
1156
+ const rawQuery = resolveAliasString(params.search_query, params.query);
1157
+ if (!rawQuery?.trim()) {
1158
+ return { error: 'search_query (or legacy query) parameter is required and cannot be empty.' };
1073
1159
  }
1074
1160
  await this.ensureInitialized(repo);
1075
1161
  const processLimit = params.limit || 5;
1076
1162
  const maxSymbolsPerProcess = params.max_symbols || 10;
1077
1163
  const includeContent = params.include_content ?? false;
1078
- const searchQuery = params.query.trim();
1164
+ const searchQuery = rawQuery.trim();
1079
1165
  // Per-phase timing instrumentation (#553). Records wall time for each
1080
1166
  // observable sub-step of the search pipeline so production latency can
1081
1167
  // be aggregated offline for Pareto analysis and bottleneck detection.
@@ -1389,9 +1475,15 @@ export class LocalBackend {
1389
1475
  WHERE n.id IN $nodeIds
1390
1476
  RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
1391
1477
  `, { nodeIds })
1392
- : await executeParameterized(repo.lbugPath, `
1478
+ : await executeParameterized(repo.lbugPath,
1479
+ // Same BasicBlock exclusion as detect_changes (#2082 U7): on a
1480
+ // --pdg index a function-heavy file has far more BasicBlock rows
1481
+ // than symbols, so an unfiltered LIMIT 3 would surface nameless
1482
+ // substrate rows and displace the real symbols.
1483
+ `
1393
1484
  MATCH (n)
1394
1485
  WHERE n.filePath = $filePath
1486
+ AND NOT n.id STARTS WITH 'BasicBlock:'
1395
1487
  RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
1396
1488
  LIMIT 3
1397
1489
  `, { filePath: fullPath });
@@ -1546,7 +1638,10 @@ export class LocalBackend {
1546
1638
  const repo = await this.resolveRepo(repoName);
1547
1639
  return this.cypher(repo, { query, params });
1548
1640
  }
1549
- async cypher(repo, request) {
1641
+ async cypher(repo,
1642
+ // #2175: "statement" is the advertised param; "query" is the legacy alias,
1643
+ // still accepted (and the field the internal executeCypher() passes). New wins.
1644
+ request) {
1550
1645
  await this.ensureInitialized(repo);
1551
1646
  if (!isLbugReady(repo.lbugPath)) {
1552
1647
  return { error: 'LadybugDB not ready. Index may be corrupted.' };
@@ -1556,8 +1651,14 @@ export class LocalBackend {
1556
1651
  error: '"params" must be a plain object with scalar values (string/number/boolean/null).',
1557
1652
  };
1558
1653
  }
1654
+ const cypherText = resolveAliasString(request.statement, request.query) ?? '';
1655
+ if (!cypherText.trim()) {
1656
+ // Mirror query()'s friendly required-param error instead of letting an empty
1657
+ // string fall through to a raw LadybugDB prepare error (#2175 review).
1658
+ return { error: 'statement (or legacy query) parameter is required and cannot be empty.' };
1659
+ }
1559
1660
  try {
1560
- const result = await executeParameterized(repo.lbugPath, request.query, request.params ?? {});
1661
+ const result = await executeParameterized(repo.lbugPath, cypherText, request.params ?? {});
1561
1662
  return result;
1562
1663
  }
1563
1664
  catch (err) {
@@ -2207,6 +2308,510 @@ export class LocalBackend {
2207
2308
  })),
2208
2309
  };
2209
2310
  }
2311
+ /**
2312
+ * Resolve a `target` (file path OR symbol/function name) into a BasicBlock
2313
+ * SOURCE-block anchor, shared by `explain` (TAINTED) and `pdg_query`
2314
+ * (CDG/REACHING_DEF) — both reconstruct the symbol↔block join the same way
2315
+ * (there is no Function→BasicBlock edge). #2188 review: extracted from two
2316
+ * near-identical copies that had DRIFTED — `_explainImpl` used a 0-based,
2317
+ * un-widened span window that dropped a function's final-line block and could
2318
+ * leak a neighbor's line-above block; this single resolver applies the correct
2319
+ * `[symStart+1, symEnd+1]` window (1-based BasicBlock startLine vs 0-based
2320
+ * symbol span) to BOTH callers.
2321
+ *
2322
+ * Returns a BARE `anchorClause` (no leading `AND`) so each caller composes its
2323
+ * own `WHERE`; `early` carries the not-found/ambiguous payload (caller returns
2324
+ * it verbatim). `target` / symbol names flow only through `queryParams` bind
2325
+ * params — never interpolated into Cypher.
2326
+ */
2327
+ async resolveBlockAnchor(repo, target, toolName) {
2328
+ if (looksLikeFilePath(target)) {
2329
+ return {
2330
+ anchorClause: '(a.id STARTS WITH $idPrefix OR a.filePath = $targetPath OR a.filePath ENDS WITH $targetSuffix)',
2331
+ queryParams: {
2332
+ idPrefix: `BasicBlock:${target}:`,
2333
+ targetPath: target,
2334
+ targetSuffix: `/${target}`,
2335
+ },
2336
+ anchor: { file: target },
2337
+ };
2338
+ }
2339
+ const outcome = await this.resolveSymbolCandidates(repo, { name: target }, {});
2340
+ if (outcome.kind === 'not_found') {
2341
+ return {
2342
+ anchorClause: '',
2343
+ queryParams: {},
2344
+ anchor: { file: '' },
2345
+ early: { error: `Symbol '${target}' not found` },
2346
+ };
2347
+ }
2348
+ if (outcome.kind === 'ambiguous') {
2349
+ return {
2350
+ anchorClause: '',
2351
+ queryParams: {},
2352
+ anchor: { file: '' },
2353
+ early: {
2354
+ status: 'ambiguous',
2355
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'. Re-call ${toolName} with the file path, or disambiguate via context() first.`,
2356
+ candidates: outcome.candidates.map((c) => ({
2357
+ uid: c.id,
2358
+ name: c.name,
2359
+ kind: c.type,
2360
+ filePath: c.filePath,
2361
+ line: c.startLine,
2362
+ score: Number(c.score.toFixed(2)),
2363
+ })),
2364
+ },
2365
+ };
2366
+ }
2367
+ const sym = outcome.symbol;
2368
+ const idPrefix = `BasicBlock:${sym.filePath}:`;
2369
+ if (typeof sym.startLine === 'number' &&
2370
+ typeof sym.endLine === 'number' &&
2371
+ sym.endLine >= sym.startLine) {
2372
+ // BasicBlock startLine is 1-based; the symbol span is 0-based. Shift BOTH
2373
+ // bounds +1 so the window is the function's true block span: the lower +1
2374
+ // excludes a neighbor's block on the line directly above, the upper +1
2375
+ // keeps a guard/def/use on the final line (#2188 review).
2376
+ return {
2377
+ anchorClause: 'a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd',
2378
+ queryParams: { idPrefix, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 },
2379
+ anchor: {
2380
+ file: sym.filePath,
2381
+ symbol: sym.name,
2382
+ startLine: sym.startLine,
2383
+ endLine: sym.endLine,
2384
+ },
2385
+ };
2386
+ }
2387
+ // No usable span — degrade to the file-level filter (documented).
2388
+ return {
2389
+ anchorClause: 'a.id STARTS WITH $idPrefix',
2390
+ queryParams: { idPrefix },
2391
+ anchor: { file: sym.filePath, symbol: sym.name },
2392
+ };
2393
+ }
2394
+ /**
2395
+ * Explain tool (#2083 M3 U6) — persisted taint-finding explanation.
2396
+ * WAL-aware wrapper mirroring `context`.
2397
+ */
2398
+ async explain(repo, params) {
2399
+ try {
2400
+ return await this._explainImpl(repo, params);
2401
+ }
2402
+ catch (err) {
2403
+ const msg = (err instanceof Error ? err.message : String(err)) || 'Explain query failed';
2404
+ if (isWalCorruptionError(err)) {
2405
+ return {
2406
+ error: msg,
2407
+ recoverySuggestion: WAL_RECOVERY_SUGGESTION,
2408
+ };
2409
+ }
2410
+ throw err;
2411
+ }
2412
+ }
2413
+ /**
2414
+ * Taint findings are persisted as `TAINTED` rows in CodeRelation whose
2415
+ * endpoints are BOTH BasicBlock nodes — the label anchor restricts every
2416
+ * query here to the BasicBlock→BasicBlock partition of the rel table
2417
+ * (which holds only the sparse, per-function-capped pdg layers), never a
2418
+ * global symbol-space scan (the S1 verdict; LadybugDB has no rel-property
2419
+ * index, so the label anchor IS the bound).
2420
+ *
2421
+ * Anchoring granularity:
2422
+ * - file target → BasicBlock id prefix (`BasicBlock:<filePath>:` — the
2423
+ * shared `basicBlockId` template) with an exact-or-suffix path match so
2424
+ * `vuln.ts` finds `src/vuln.ts`.
2425
+ * - symbol target → resolved via `resolveSymbolCandidates` (the context()
2426
+ * path: ambiguous ⇒ ranked candidates, unknown ⇒ not-found), then the
2427
+ * file id-prefix PLUS source-block startLine within the symbol's
2428
+ * [startLine, endLine] span. Findings are intra-procedural, so filtering
2429
+ * the SOURCE endpoint is sufficient — both endpoints share the function.
2430
+ * Symbols without a line span degrade to the file-level filter.
2431
+ *
2432
+ * The per-finding `sinkKind` and hop path decode from the persisted
2433
+ * `reason` via the SHARED `taint/path-codec.ts` (the U4 write path encodes
2434
+ * with the same module — `;<kind>` header + ordered `variable:line` hops).
2435
+ */
2436
+ async _explainImpl(repo, params) {
2437
+ await this.ensureInitialized(repo);
2438
+ const rawLimit = params.limit ?? EXPLAIN_DEFAULT_LIMIT;
2439
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > EXPLAIN_MAX_LIMIT) {
2440
+ return {
2441
+ error: `Invalid "limit": expected an integer in [1, ${EXPLAIN_MAX_LIMIT}], got ${JSON.stringify(params.limit)}.`,
2442
+ };
2443
+ }
2444
+ const limit = rawLimit;
2445
+ const NO_TAINT_NOTE = 'no taint layer — run gitnexus analyze --pdg to record taint findings for this repo';
2446
+ // Cheap meta probe: the TAINT layer exists iff the pdg stamp carries a
2447
+ // `taintModelVersion` (the field M3 added). An M1/M2-era `--pdg` index has
2448
+ // `meta.pdg` defined but no taintModelVersion — BasicBlock/REACHING_DEF
2449
+ // exist, zero TAINTED rows do — so it must surface the no-taint-layer hint,
2450
+ // not the generic "analyzed, nothing found" note. An unreadable meta (e.g.
2451
+ // a seeded test DB) falls through to the row-existence probe below.
2452
+ let pdgStamped;
2453
+ try {
2454
+ const meta = await loadMeta(path.dirname(repo.lbugPath));
2455
+ if (meta)
2456
+ pdgStamped = meta.pdg?.taintModelVersion !== undefined;
2457
+ }
2458
+ catch {
2459
+ /* meta unreadable — decide from the DB below */
2460
+ }
2461
+ if (pdgStamped === false) {
2462
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2463
+ }
2464
+ // Resolve the optional anchor into a WHERE clause on the SOURCE block.
2465
+ const target = typeof params.target === 'string' ? params.target.trim() : '';
2466
+ let anchorClause = '';
2467
+ let queryParams = {};
2468
+ let anchor;
2469
+ // Bounded by construction: the BasicBlock→BasicBlock partition holds only
2470
+ // the sparse pdg layers, TAINTED rows are per-function-capped at analyze
2471
+ // time, and the page is LIMIT-bounded (the limit is a validated integer —
2472
+ // interpolated because LadybugDB does not parameterize LIMIT).
2473
+ const runAnchoredQuery = async () => {
2474
+ const matchClause = `
2475
+ MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
2476
+ WHERE r.type = 'TAINTED'${anchorClause ? ` AND ${anchorClause}` : ''}`;
2477
+ const [qRows, countRows] = await Promise.all([
2478
+ executeParameterized(repo.lbugPath, `${matchClause}
2479
+ RETURN a.id AS sourceBlockId, a.filePath AS file, a.startLine AS sourceStart,
2480
+ b.startLine AS sinkStart, r.reason AS reason, b.id AS sinkBlockId
2481
+ ORDER BY sourceBlockId, sinkBlockId, reason
2482
+ LIMIT ${limit}`, queryParams),
2483
+ executeParameterized(repo.lbugPath, `${matchClause}
2484
+ RETURN COUNT(*) AS total`, queryParams),
2485
+ ]);
2486
+ return {
2487
+ rows: qRows,
2488
+ totalFindings: Number(countRows[0]?.total ?? countRows[0]?.[0] ?? 0),
2489
+ };
2490
+ };
2491
+ if (target) {
2492
+ // Shared symbol↔block anchor resolver (#2188): file id-prefix OR symbol
2493
+ // span, with the corrected [symStart+1, symEnd+1] window. A bare/dotted
2494
+ // symbol name resolves as a symbol rather than silently file-anchoring.
2495
+ const resolved = await this.resolveBlockAnchor(repo, target, 'explain');
2496
+ if (resolved.early)
2497
+ return resolved.early;
2498
+ anchorClause = resolved.anchorClause;
2499
+ queryParams = resolved.queryParams;
2500
+ anchor = resolved.anchor;
2501
+ }
2502
+ const { rows, totalFindings } = await runAnchoredQuery();
2503
+ // M4 (#2084 U7): cross-function findings ride TAINT_PATH edges (Function/
2504
+ // Method → Function/Method), separate from the intra-procedural TAINTED
2505
+ // BasicBlock rows above. Enumerate them too so `explain` is the discovery
2506
+ // surface for interprocedural flows (TAINT_PATH stays out of
2507
+ // VALID_RELATION_TYPES + the web schema, like TAINTED). File-anchored:
2508
+ // filter on the source function's file; symbol-anchored: either endpoint
2509
+ // matches the symbol name; anchorless: all (bounded by LIMIT). Computed
2510
+ // BEFORE the no-taint early returns — a repo with ONLY cross-function
2511
+ // findings (no intra-procedural TAINTED rows) must not look empty.
2512
+ const runInterprocQuery = async () => {
2513
+ const where = [`r.type = 'TAINT_PATH'`];
2514
+ const p = {};
2515
+ if (anchor?.symbol) {
2516
+ where.push('(a.name = $ipSym OR b.name = $ipSym)');
2517
+ p.ipSym = anchor.symbol;
2518
+ }
2519
+ else if (anchor?.file) {
2520
+ // Match EITHER endpoint's file — a cross-function flow anchored on the
2521
+ // SINK's file (b) is as relevant as one anchored on the source's (a).
2522
+ where.push('(a.filePath = $ipFile OR a.filePath ENDS WITH $ipSuffix OR ' +
2523
+ 'b.filePath = $ipFile OR b.filePath ENDS WITH $ipSuffix)');
2524
+ p.ipFile = anchor.file;
2525
+ p.ipSuffix = `/${anchor.file}`;
2526
+ }
2527
+ const matchClause = `MATCH (a)-[r:CodeRelation]->(b)\n WHERE ${where.join(' AND ')}`;
2528
+ // Page query + a separate COUNT (#2084 review P2-4): the page is
2529
+ // LIMIT-capped, so its row count cannot stand in for the true total —
2530
+ // run a COUNT with the same WHERE (no LIMIT) like the intra layer does.
2531
+ const [ipRows, ipCountRows] = await Promise.all([
2532
+ executeParameterized(repo.lbugPath, `${matchClause}
2533
+ RETURN a.filePath AS file, a.name AS sourceFn, a.startLine AS sourceLine,
2534
+ b.name AS sinkFn, b.startLine AS sinkLine, r.reason AS reason
2535
+ ORDER BY sourceFn, sinkFn, reason
2536
+ LIMIT ${limit}`, p),
2537
+ executeParameterized(repo.lbugPath, `${matchClause}\n RETURN COUNT(*) AS total`, p),
2538
+ ]);
2539
+ const total = Number(ipCountRows[0]?.total ?? ipCountRows[0]?.[0] ?? 0);
2540
+ const findings = ipRows.map((r) => {
2541
+ const decoded = decodeTaintPath(r.reason ?? r[5]);
2542
+ const hops = decoded.ok
2543
+ ? decoded.hops.map((h) => ({ function: h.variable, line: h.line }))
2544
+ : [];
2545
+ return {
2546
+ interprocedural: true,
2547
+ file: String(r.file ?? r[0] ?? ''),
2548
+ sinkKind: decoded.ok ? (decoded.kind ?? 'unknown') : 'unknown',
2549
+ source: { function: String(r.sourceFn ?? r[1] ?? ''), line: r.sourceLine ?? r[2] },
2550
+ sink: { function: String(r.sinkFn ?? r[3] ?? ''), line: r.sinkLine ?? r[4] },
2551
+ hops,
2552
+ ...(decoded.ok && decoded.truncated ? { pathIncomplete: true } : {}),
2553
+ };
2554
+ });
2555
+ return { findings, total };
2556
+ };
2557
+ const { findings: interprocFindings, total: interprocTotal } = await runInterprocQuery();
2558
+ if (totalFindings === 0 &&
2559
+ interprocFindings.length === 0 &&
2560
+ pdgStamped === undefined &&
2561
+ !target) {
2562
+ // Meta was unreadable and the repo-wide enumerate (both layers) found
2563
+ // nothing — the counts above WERE the existence probe; surface the hint.
2564
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2565
+ }
2566
+ if (totalFindings === 0 &&
2567
+ interprocFindings.length === 0 &&
2568
+ pdgStamped === undefined &&
2569
+ target) {
2570
+ // Anchored miss with unreadable meta: one extra bounded probe decides
2571
+ // "no findings for this anchor" vs "no taint layer at all". Probe BOTH
2572
+ // intra (TAINTED) and inter (TAINT_PATH) existence.
2573
+ const probe = await executeParameterized(repo.lbugPath, `MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock) WHERE r.type = 'TAINTED' RETURN r.reason AS reason LIMIT 1`, {});
2574
+ const ipProbe = probe.length === 0
2575
+ ? await executeParameterized(repo.lbugPath, `MATCH (a)-[r:CodeRelation]->(b) WHERE r.type = 'TAINT_PATH' RETURN r.reason AS reason LIMIT 1`, {})
2576
+ : [];
2577
+ if (probe.length === 0 && ipProbe.length === 0) {
2578
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2579
+ }
2580
+ }
2581
+ const findings = rows.map((r) => {
2582
+ const sourceBlockId = String(r.sourceBlockId ?? r[0] ?? '');
2583
+ const file = String(r.file ?? r[1] ?? '');
2584
+ const sourceStart = (r.sourceStart ?? r[2]);
2585
+ const sinkStart = (r.sinkStart ?? r[3]);
2586
+ const reason = r.reason ?? r[4];
2587
+ // basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` —
2588
+ // split from the RIGHT (the filePath may itself contain ':').
2589
+ const idParts = sourceBlockId.split(':');
2590
+ const fnLine = Number(idParts[idParts.length - 3]);
2591
+ const decoded = decodeTaintPath(reason);
2592
+ if (!decoded.ok) {
2593
+ // Unreadable reason (foreign/corrupt row): surface the finding's
2594
+ // existence with its block anchors, never throw.
2595
+ return {
2596
+ file,
2597
+ ...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
2598
+ sinkKind: 'unknown',
2599
+ source: { line: sourceStart },
2600
+ sink: { line: sinkStart },
2601
+ hops: [],
2602
+ pathIncomplete: true,
2603
+ };
2604
+ }
2605
+ const hops = decoded.hops.map((h) => ({
2606
+ variable: h.variable,
2607
+ line: h.line,
2608
+ ...(h.viaCall ? { viaCall: true } : {}),
2609
+ }));
2610
+ const first = hops[0];
2611
+ const last = hops[hops.length - 1];
2612
+ return {
2613
+ file,
2614
+ ...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
2615
+ sinkKind: decoded.kind ?? 'unknown',
2616
+ source: first ? { variable: first.variable, line: first.line } : { line: sourceStart },
2617
+ sink: { line: last?.line ?? sinkStart },
2618
+ hops,
2619
+ ...(decoded.truncated ? { pathIncomplete: true } : {}),
2620
+ };
2621
+ });
2622
+ // Combine both layers and re-apply the page LIMIT to the union — each
2623
+ // layer was queried with its own LIMIT, so the union can hold up to 2×;
2624
+ // cap it so `findings.length` honours the caller's `limit`. `truncated`
2625
+ // reflects EITHER layer overflowing OR the union being trimmed here, and
2626
+ // `totalFindings` counts both layers' matched rows (the intra COUNT plus
2627
+ // the interproc rows returned — interproc has no separate COUNT, so a
2628
+ // capped interproc layer is reflected via `truncated`, never undercounted
2629
+ // into a false "complete" signal). Review: code-review #2/#4 (explain
2630
+ // accounting + sink-file anchoring) — both layers now accounted.
2631
+ const combined = [...findings, ...interprocFindings];
2632
+ const pageFindings = combined.length > limit ? combined.slice(0, limit) : combined;
2633
+ // Truncated iff EITHER layer overflowed its own LIMIT (strict `>` — exactly
2634
+ // `limit` rows is not truncated), OR the combined union was trimmed to the
2635
+ // page (#2084 review P2-4). `totalFindings` uses the interproc COUNT, not
2636
+ // the capped slice length, so it never undercounts.
2637
+ const truncated = totalFindings > findings.length ||
2638
+ interprocTotal > interprocFindings.length ||
2639
+ combined.length > pageFindings.length;
2640
+ return {
2641
+ ...(anchor ? { anchor } : {}),
2642
+ findings: pageFindings,
2643
+ totalFindings: totalFindings + interprocTotal,
2644
+ ...(truncated ? { truncated: true } : {}),
2645
+ note: 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled. Closure/callback, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. Cross-function findings are context-insensitive and may over-attribute among same-named callees. SANITIZES (kill) edges are queryable via cypher.',
2646
+ };
2647
+ }
2648
+ async pdgQuery(repo, params) {
2649
+ try {
2650
+ return await this._pdgQueryImpl(repo, params);
2651
+ }
2652
+ catch (err) {
2653
+ const msg = (err instanceof Error ? err.message : String(err)) || 'pdg_query failed';
2654
+ if (isWalCorruptionError(err)) {
2655
+ return { error: msg, recoverySuggestion: WAL_RECOVERY_SUGGESTION };
2656
+ }
2657
+ throw err;
2658
+ }
2659
+ }
2660
+ /**
2661
+ * Query the persisted PDG (#2086 M6) — the control/data-dependence analog of
2662
+ * `explain`. `controls` reads CDG ("under what condition does X run?", branch
2663
+ * sense 'T'|'F' in `reason`); `flows` reads REACHING_DEF (def→use, variable
2664
+ * name in `reason`). Intra-procedural, basic-block granular.
2665
+ *
2666
+ * Bounded by construction: the BasicBlock→BasicBlock partition holds only the
2667
+ * sparse, per-function-capped pdg layers, the query is anchored to one file/
2668
+ * symbol, and the page is LIMIT-bounded (validated integer, interpolated
2669
+ * because LadybugDB does not parameterize LIMIT). LadybugDB has no rel-
2670
+ * property index, so the anchor IS the bound — there is no anchorless mode.
2671
+ *
2672
+ * Symbol↔block join: there is no Function→BasicBlock edge; the SOURCE block
2673
+ * (`a` — controller for CDG, def for REACHING_DEF) is filtered by the
2674
+ * BasicBlock id-prefix (`basicBlockId` template) plus its `startLine` within
2675
+ * the symbol's span. BasicBlock `startLine` is 1-based while symbol-node
2676
+ * `startLine`/`endLine` are 0-based, so BOTH bounds are shifted +1
2677
+ * (`[symStart+1, symEnd+1]`) onto the block basis: the upper +1 keeps a
2678
+ * guard/def/use on the function's final line, and the lower +1 excludes an
2679
+ * adjacent function's block on the line directly above (#2188 review). Both
2680
+ * endpoints share the function (intra-procedural), so filtering the source
2681
+ * endpoint suffices.
2682
+ */
2683
+ async _pdgQueryImpl(repo, params = {}) {
2684
+ await this.ensureInitialized(repo);
2685
+ // Mode validation — the JSON-schema enum is advisory for MCP clients, so
2686
+ // the backend enforces it (an unhandled mode would otherwise fall through).
2687
+ const mode = params.mode;
2688
+ if (mode !== 'controls' && mode !== 'flows') {
2689
+ return {
2690
+ error: `Invalid "mode": expected "controls" or "flows", got ${JSON.stringify(params.mode)}.`,
2691
+ };
2692
+ }
2693
+ const rawLimit = params.limit ?? PDG_QUERY_DEFAULT_LIMIT;
2694
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > PDG_QUERY_MAX_LIMIT) {
2695
+ return {
2696
+ error: `Invalid "limit": expected an integer in [1, ${PDG_QUERY_MAX_LIMIT}], got ${JSON.stringify(params.limit)}.`,
2697
+ };
2698
+ }
2699
+ const limit = rawLimit;
2700
+ // PDG queries are always anchored (no rel-property index ⇒ an unanchored
2701
+ // basic-block path scan is unbounded). `target` is required.
2702
+ const target = typeof params.target === 'string' ? params.target.trim() : '';
2703
+ if (!target) {
2704
+ return {
2705
+ error: 'pdg_query requires a "target" (a file path or symbol/function name) — PDG queries are always anchored.',
2706
+ };
2707
+ }
2708
+ const edgeType = mode === 'controls' ? 'CDG' : 'REACHING_DEF';
2709
+ // Definitive: the meta stamp says this layer was never recorded.
2710
+ const NO_PDG_NOTE = `no PDG layer — run gitnexus analyze --pdg to record ${edgeType} edges for this repo`;
2711
+ // Inconclusive: meta is unreadable AND a global probe found zero rows of this
2712
+ // edge type — but a genuinely edge-free layer (all-linear functions) looks
2713
+ // identical to a missing one, so don't assert absence (#2188 review).
2714
+ const PDG_LAYER_UNKNOWN_NOTE = `no ${edgeType} edges found for this target; PDG layer status unknown — was this repo indexed with gitnexus analyze --pdg?`;
2715
+ // Cheap meta probe: the layer exists iff the pdg stamp carries the
2716
+ // mode-relevant cap (maxCdgEdgesPerFunction for CDG, maxReachingDef…
2717
+ // for REACHING_DEF). Absent ⇒ the no-layer hint without a DB scan.
2718
+ // `pdgStampForMode` is the shared meta read (the both-caps `pdgLayerStatus`
2719
+ // helper consumes the same underlying read for impact); here we project it
2720
+ // down to this one mode's cap, preserving the tri-state `boolean | undefined`
2721
+ // contract byte-for-byte: `false` ⇒ definitive no-layer (short-circuit
2722
+ // below), `true` ⇒ proceed, `undefined` ⇒ meta unreadable, defer to the
2723
+ // post-anchored-query probe (Feasibility Issue 4).
2724
+ const pdgStamped = await pdgStampForMode(repo.lbugPath, mode);
2725
+ if (pdgStamped === false) {
2726
+ return { mode, results: [], total: 0, note: NO_PDG_NOTE };
2727
+ }
2728
+ // Resolve the anchor on the SOURCE block via the shared resolver also used
2729
+ // by explain (#2188): file id-prefix OR symbol span on the corrected
2730
+ // [symStart+1, symEnd+1] window. `target` is required, so the early cases
2731
+ // (not-found/ambiguous) return here and `anchor`/`anchorClause` are always
2732
+ // set below (anchor stays non-optional — no `| undefined` — #2188 CodeQL).
2733
+ const resolved = await this.resolveBlockAnchor(repo, target, 'pdg_query');
2734
+ if (resolved.early)
2735
+ return resolved.early;
2736
+ const { anchorClause, anchor } = resolved;
2737
+ const queryParams = resolved.queryParams;
2738
+ // Optional variable filter (flows mode) — REACHING_DEF stores the variable
2739
+ // name in `reason`. FU-B-2 prefixes the name with a `<name>|1:<def>:<use>`
2740
+ // annotation (name FIRST), so match BOTH a legacy bare-name reason (`=`) AND
2741
+ // an annotated one (`STARTS WITH <name>|`). Source identifiers never contain
2742
+ // `|`, so the `<name>|` prefix is exact (it cannot collide with a longer name
2743
+ // — `ab|…` is not a prefix of `abc|…`).
2744
+ let reasonClause = '';
2745
+ if (mode === 'flows' && typeof params.variable === 'string' && params.variable.trim()) {
2746
+ reasonClause = ' AND (r.reason = $variable OR r.reason STARTS WITH $variablePrefix)';
2747
+ const variable = params.variable.trim();
2748
+ queryParams.variable = variable;
2749
+ queryParams.variablePrefix = `${variable}|`;
2750
+ }
2751
+ // edgeType is a hardcoded per-mode literal (never user input); `target` /
2752
+ // `variable` flow only through bind params (no Cypher interpolation).
2753
+ const matchClause = `
2754
+ MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
2755
+ WHERE r.type = '${edgeType}' AND ${anchorClause}${reasonClause}`;
2756
+ const [rows, countRows] = await Promise.all([
2757
+ executeParameterized(repo.lbugPath, `${matchClause}
2758
+ RETURN a.id AS srcId, a.startLine AS srcLine, b.startLine AS dstLine, b.text AS dstText, r.reason AS reason
2759
+ ORDER BY srcId, dstLine, reason
2760
+ LIMIT ${limit}`, queryParams),
2761
+ executeParameterized(repo.lbugPath, `${matchClause}\n RETURN COUNT(*) AS total`, queryParams),
2762
+ ]);
2763
+ const total = Number(countRows[0]?.total ?? countRows[0]?.[0] ?? 0);
2764
+ // Unreadable meta + anchored miss: one bounded probe distinguishes "no rows
2765
+ // for this anchor" from "no rows of this edge type at all". With meta
2766
+ // unreadable we cannot tell a missing layer from an edge-free one, so the
2767
+ // note is the inconclusive "status unknown" form, not the definitive
2768
+ // NO_PDG_NOTE (which is reserved for the meta-stamped absence above).
2769
+ if (total === 0 && pdgStamped === undefined) {
2770
+ const probe = await executeParameterized(repo.lbugPath, `MATCH (:BasicBlock)-[r:CodeRelation]->(:BasicBlock) WHERE r.type = '${edgeType}' RETURN r.reason AS reason LIMIT 1`, {});
2771
+ if (probe.length === 0)
2772
+ return { mode, results: [], total: 0, note: PDG_LAYER_UNKNOWN_NOTE };
2773
+ }
2774
+ // basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` — split
2775
+ // from the RIGHT (filePath may contain ':'). Shared module-scope `fnLineOf`.
2776
+ const results = mode === 'controls'
2777
+ ? rows.map((r) => {
2778
+ const fnLine = fnLineOf(String(r.srcId ?? r[0] ?? ''));
2779
+ const dstText = String(r.dstText ?? r[3] ?? '');
2780
+ // A CDG edge into an early-exit block is a guard clause (subsumes
2781
+ // #559): the controller predicate gates the dependent via `label`.
2782
+ const isGuardExit = /^\s*(return|throw|continue|break)\b/.test(dstText);
2783
+ return {
2784
+ ...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
2785
+ controller: { line: (r.srcLine ?? r[1]) },
2786
+ dependent: { line: (r.dstLine ?? r[2]), text: dstText },
2787
+ label: String(r.reason ?? r[4] ?? ''),
2788
+ ...(isGuardExit ? { guard: true } : {}),
2789
+ };
2790
+ })
2791
+ : rows.map((r) => {
2792
+ const fnLine = fnLineOf(String(r.srcId ?? r[0] ?? ''));
2793
+ // FU-B-2: REACHING_DEF `reason` is `<name>` (legacy) or
2794
+ // `<name>|1:<defLine>:<useLine>` — decode to surface the bare
2795
+ // variable name, not the encoded annotation.
2796
+ const variable = decodeReachingDefReason(r.reason ?? r[4] ?? '').name;
2797
+ return {
2798
+ ...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
2799
+ variable,
2800
+ def: { line: (r.srcLine ?? r[1]) },
2801
+ use: {
2802
+ line: (r.dstLine ?? r[2]),
2803
+ text: String(r.dstText ?? r[3] ?? ''),
2804
+ },
2805
+ };
2806
+ });
2807
+ return {
2808
+ mode,
2809
+ anchor,
2810
+ results,
2811
+ total,
2812
+ ...(total > results.length ? { truncated: true } : {}),
2813
+ };
2814
+ }
2210
2815
  /**
2211
2816
  * Legacy explore — kept for backwards compatibility with resources.ts.
2212
2817
  * Routes cluster/process types to direct graph queries.
@@ -2402,8 +3007,20 @@ export class LocalBackend {
2402
3007
  queryParams[`hunkStart${i}`] = hunk.startLine;
2403
3008
  queryParams[`hunkEnd${i}`] = hunk.endLine;
2404
3009
  });
3010
+ // Exclude BasicBlock rows by id prefix: on a --pdg index every edited
3011
+ // function otherwise contributes N nameless BasicBlock pseudo-"symbols"
3012
+ // (they carry filePath/start/end but no name), inflating changed_count
3013
+ // and risk level with rows no consumer can act on (#2082 U7). Blocks
3014
+ // are implementation substrate, not symbols — the owning Function row
3015
+ // already represents the change. The id prefix (`BasicBlock:<file>:…`,
3016
+ // cfg/emit.ts basicBlockId) beats a label predicate (`labels(n)[0]` is
3017
+ // known to come back empty for several node types — see
3018
+ // enrichCandidateLabels) AND beats `n.name IS NOT NULL` (which would
3019
+ // also drop legitimate symbols whose name loaded as NULL, e.g.
3020
+ // quoted-empty CSV fields for anonymous constructs).
2405
3021
  const symbolQuery = `
2406
3022
  MATCH (n) WHERE n.filePath ENDS WITH $filePath
3023
+ AND NOT n.id STARTS WITH 'BasicBlock:'
2407
3024
  AND n.startLine IS NOT NULL AND n.endLine IS NOT NULL
2408
3025
  AND (${overlapConditions})
2409
3026
  RETURN n.id AS id, n.name AS name, labels(n)[0] AS type,
@@ -2648,26 +3265,317 @@ export class LocalBackend {
2648
3265
  applied: !dry_run,
2649
3266
  };
2650
3267
  }
3268
+ async trace(repo, params) {
3269
+ try {
3270
+ return await this._traceImpl(repo, params);
3271
+ }
3272
+ catch (err) {
3273
+ return {
3274
+ status: 'error',
3275
+ error: (err instanceof Error ? err.message : String(err)) || 'Trace analysis failed',
3276
+ from: { name: params.from },
3277
+ to: { name: params.to },
3278
+ suggestion: 'The graph query failed — try gitnexus context <symbol> to see connections, ' +
3279
+ 'or check if an interface bridges them.',
3280
+ ...(isWalCorruptionError(err) ? { recoverySuggestion: WAL_RECOVERY_SUGGESTION } : {}),
3281
+ };
3282
+ }
3283
+ }
3284
+ async _traceImpl(repo, params) {
3285
+ await this.ensureInitialized(repo);
3286
+ // resolveSymbolCandidates feeds `from`/`to` into string operations
3287
+ // (e.g. name.includes), so a non-string param would surface a low-level
3288
+ // "x.includes is not a function". Reject it with a clear message instead.
3289
+ const isStringOrAbsent = (v) => v === undefined || typeof v === 'string';
3290
+ if (!isStringOrAbsent(params.from) ||
3291
+ !isStringOrAbsent(params.to) ||
3292
+ !isStringOrAbsent(params.from_uid) ||
3293
+ !isStringOrAbsent(params.to_uid)) {
3294
+ return {
3295
+ status: 'error',
3296
+ error: "'from', 'to', and their *_uid variants must be strings.",
3297
+ suggestion: 'Pass symbol names or UIDs as strings, e.g. trace from="A" to="B".',
3298
+ };
3299
+ }
3300
+ const fromOutcome = await this.resolveSymbolCandidates(repo, { uid: params.from_uid, name: params.from }, { file_path: params.from_file });
3301
+ if (fromOutcome.kind === 'not_found') {
3302
+ return {
3303
+ status: 'not_found',
3304
+ error: `Source symbol '${params.from_uid ?? params.from}' not found.`,
3305
+ suggestion: 'Check the symbol name or use --from-uid for zero-ambiguity.',
3306
+ };
3307
+ }
3308
+ if (fromOutcome.kind === 'ambiguous') {
3309
+ return {
3310
+ status: 'ambiguous',
3311
+ role: 'from',
3312
+ message: `Found ${fromOutcome.candidates.length} symbols matching '${params.from}'. Disambiguate with --from-uid.`,
3313
+ candidates: fromOutcome.candidates,
3314
+ };
3315
+ }
3316
+ const toOutcome = await this.resolveSymbolCandidates(repo, { uid: params.to_uid, name: params.to }, { file_path: params.to_file });
3317
+ if (toOutcome.kind === 'not_found') {
3318
+ return {
3319
+ status: 'not_found',
3320
+ error: `Target symbol '${params.to_uid ?? params.to}' not found.`,
3321
+ suggestion: 'Check the symbol name or use --to-uid for zero-ambiguity.',
3322
+ };
3323
+ }
3324
+ if (toOutcome.kind === 'ambiguous') {
3325
+ return {
3326
+ status: 'ambiguous',
3327
+ role: 'to',
3328
+ message: `Found ${toOutcome.candidates.length} symbols matching '${params.to}'. Disambiguate with --to-uid.`,
3329
+ candidates: toOutcome.candidates,
3330
+ };
3331
+ }
3332
+ const fromSym = fromOutcome.symbol;
3333
+ const toSym = toOutcome.symbol;
3334
+ if (fromSym.id === toSym.id) {
3335
+ return {
3336
+ status: 'ok',
3337
+ from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
3338
+ to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
3339
+ hopCount: 0,
3340
+ hops: [{ name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine }],
3341
+ edges: [],
3342
+ };
3343
+ }
3344
+ // Sanitize maxDepth at the real boundary: the MCP inputSchema's
3345
+ // minimum/maximum is advisory only (callTool is reachable directly), so a
3346
+ // caller can pass 0, a negative, NaN, or a non-integer. `??` does NOT
3347
+ // recover 0/NaN, and Math.min has no lower bound — left unguarded, any of
3348
+ // those makes the BFS loop run zero iterations and return a false no_path.
3349
+ const DEFAULT_TRACE_DEPTH = 10;
3350
+ const MAX_TRACE_DEPTH = 30;
3351
+ const requestedDepth = Number.isInteger(params.maxDepth) && params.maxDepth > 0
3352
+ ? params.maxDepth
3353
+ : DEFAULT_TRACE_DEPTH;
3354
+ const maxDepth = Math.min(requestedDepth, MAX_TRACE_DEPTH);
3355
+ const includeTests = params.includeTests ?? false;
3356
+ // Traversal vocabulary: CALLS for actual calls, HAS_METHOD so a class-rooted
3357
+ // trace can descend into its methods. Not "calls only" — per-hop edge type is
3358
+ // surfaced in edges[] so containment hops stay distinguishable.
3359
+ const TRAVERSAL_EDGE_TYPES = ['CALLS', 'HAS_METHOD'];
3360
+ // Bound the traversal so a high-fanout hub (a logger/util reached by many
3361
+ // symbols) can't materialize an unbounded frontier. Per-level rows are
3362
+ // capped and the total visited set is capped; either cap sets `truncated`
3363
+ // so a resulting no_path is never reported as if the graph was exhausted.
3364
+ const PER_NODE_FANOUT_CAP = 200;
3365
+ const ABS_ROW_CAP = 5000;
3366
+ const MAX_VISITED = 50000;
3367
+ let truncated = false;
3368
+ const visited = new Set([fromSym.id]);
3369
+ let frontier = [fromSym.id];
3370
+ const parent = new Map();
3371
+ let found = false;
3372
+ // The last node discovered at the deepest reached level — surfaced as
3373
+ // `furthest` in the no_path response to hint where the chain breaks.
3374
+ let lastReached = null;
3375
+ let reachedDepth = 0;
3376
+ for (let depth = 1; depth <= maxDepth && frontier.length > 0 && !found; depth++) {
3377
+ const nextFrontier = [];
3378
+ // LadybugDB/Kuzu does not support a parameterized LIMIT, so the cap is
3379
+ // interpolated (it is a derived integer, not user input).
3380
+ const rowCap = Math.min(frontier.length * PER_NODE_FANOUT_CAP, ABS_ROW_CAP);
3381
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (n)-[r:CodeRelation]->(m)
3382
+ WHERE n.id IN $frontierIds AND r.type IN $edgeTypes
3383
+ RETURN n.id AS sourceId, m.id AS id, m.name AS name, labels(m)[0] AS type,
3384
+ m.filePath AS filePath, m.startLine AS startLine,
3385
+ r.type AS edgeType, r.confidence AS confidence
3386
+ LIMIT ${rowCap}`, { frontierIds: frontier, edgeTypes: TRAVERSAL_EDGE_TYPES });
3387
+ // A clipped level may have dropped a node that lies on the only shortest
3388
+ // path, so any subsequent no_path is not authoritative.
3389
+ if (rows.length >= rowCap)
3390
+ truncated = true;
3391
+ for (const row of rows) {
3392
+ // Decode once. The `?? row[N]` fallback handles LadybugDB tuple-mode
3393
+ // returns; the positional indices mirror the RETURN column order above.
3394
+ const nodeId = (row.id ?? row[1]);
3395
+ const sourceId = (row.sourceId ?? row[0]);
3396
+ const name = (row.name ?? row[2]);
3397
+ const filePath = (row.filePath ?? row[4]);
3398
+ const startLine = (row.startLine ?? row[5]);
3399
+ const edgeType = (row.edgeType ?? row[6]);
3400
+ const storedConfidence = row.confidence ?? row[7];
3401
+ const confidence = typeof storedConfidence === 'number' && storedConfidence > 0
3402
+ ? storedConfidence
3403
+ : confidenceForRelType(edgeType);
3404
+ // Match the explicitly-requested target before the test-file filter.
3405
+ // resolveSymbolCandidates does not exclude test-file symbols, so a
3406
+ // target (or a required hop) that lives in a test file would otherwise
3407
+ // be dropped by the includeTests guard below and produce a false
3408
+ // no_path even when a direct edge exists.
3409
+ if (nodeId === toSym.id) {
3410
+ parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence });
3411
+ found = true;
3412
+ break;
3413
+ }
3414
+ // Skip non-target nodes that live in test files unless includeTests.
3415
+ if (!includeTests && isTestFilePath(filePath))
3416
+ continue;
3417
+ if (!visited.has(nodeId)) {
3418
+ visited.add(nodeId);
3419
+ parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence });
3420
+ nextFrontier.push(nodeId);
3421
+ lastReached = { name, filePath, startLine };
3422
+ reachedDepth = depth;
3423
+ }
3424
+ }
3425
+ frontier = nextFrontier;
3426
+ if (visited.size >= MAX_VISITED) {
3427
+ truncated = true;
3428
+ break;
3429
+ }
3430
+ }
3431
+ if (found) {
3432
+ const path = [];
3433
+ const edges = [];
3434
+ let current = toSym.id;
3435
+ while (current !== fromSym.id) {
3436
+ const info = parent.get(current);
3437
+ path.unshift({ name: info.name, filePath: info.filePath, startLine: info.startLine });
3438
+ edges.unshift({ relType: info.edgeType, confidence: info.confidence });
3439
+ current = info.from;
3440
+ }
3441
+ path.unshift({
3442
+ name: fromSym.name,
3443
+ filePath: fromSym.filePath,
3444
+ startLine: fromSym.startLine,
3445
+ });
3446
+ return {
3447
+ status: 'ok',
3448
+ from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
3449
+ to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
3450
+ hopCount: edges.length,
3451
+ hops: path,
3452
+ edges,
3453
+ };
3454
+ }
3455
+ return {
3456
+ status: 'no_path',
3457
+ from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
3458
+ to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
3459
+ furthest: lastReached ? { ...lastReached, depth: reachedDepth } : null,
3460
+ ...(truncated ? { truncated: true } : {}),
3461
+ suggestion: truncated
3462
+ ? 'Search was truncated at a traversal cap before exhausting the graph — a path ' +
3463
+ 'may still exist. Narrow the search (a lower --depth, or trace from a more ' +
3464
+ 'specific symbol), or use gitnexus context <symbol> to inspect connections.'
3465
+ : 'No directed path found. The call chain likely breaks at dynamic dispatch, ' +
3466
+ 'reflection, or an external API boundary. Try gitnexus context <symbol> to see ' +
3467
+ "both symbols' connections, or check if an interface/abstraction bridges them.",
3468
+ };
3469
+ }
2651
3470
  async impact(repo, params) {
2652
3471
  try {
2653
3472
  return await this._impactImpl(repo, params);
2654
3473
  }
2655
3474
  catch (err) {
2656
3475
  // Return structured error instead of crashing (#321)
3476
+ const message = (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed';
3477
+ const suggestion = 'The graph query failed — try gitnexus context <symbol> as a fallback';
3478
+ const recoverySuggestion = isWalCorruptionError(err) ? WAL_RECOVERY_SUGGESTION : undefined;
3479
+ if (params.mode === 'pdg') {
3480
+ // Symbol resolution never reached the catch with a resolved symbol (the
3481
+ // throw can originate before/within resolution), so the envelope carries
3482
+ // the partial-but-typed target — typed as PdgImpactTarget so the partial
3483
+ // is type-checked, not an inline literal in a Promise<any> hole.
3484
+ const target = { name: params.target };
3485
+ const pdgErr = makePdgImpactErrorResult({
3486
+ mode: 'pdg',
3487
+ error: message,
3488
+ target,
3489
+ direction: params.direction,
3490
+ suggestion,
3491
+ recoverySuggestion,
3492
+ });
3493
+ return pdgErr;
3494
+ }
2657
3495
  return {
2658
- error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed',
3496
+ error: message,
2659
3497
  target: { name: params.target },
2660
3498
  direction: params.direction,
2661
3499
  impactedCount: 0,
2662
3500
  risk: 'UNKNOWN',
2663
- suggestion: 'The graph query failed — try gitnexus context <symbol> as a fallback',
2664
- ...(isWalCorruptionError(err) ? { recoverySuggestion: WAL_RECOVERY_SUGGESTION } : {}),
3501
+ suggestion,
3502
+ ...(recoverySuggestion ? { recoverySuggestion } : {}),
2665
3503
  };
2666
3504
  }
2667
3505
  }
2668
3506
  async _impactImpl(repo, params) {
2669
3507
  await this.ensureInitialized(repo);
2670
3508
  const { target, direction } = params;
3509
+ // ── Dispatch order (KTD5) ──────────────────────────────────────────
3510
+ // (1) Validate `mode`. Absent/'callgraph' → unchanged path; 'pdg' → the
3511
+ // intra-procedural PDG engine; anything else → hard error.
3512
+ // This MUST come before resolveSymbolCandidates so the ambiguous branch can
3513
+ // fork on the validated mode and never run the callgraph fan-out under pdg.
3514
+ const modeResult = validateImpactMode(params.mode);
3515
+ if ('error' in modeResult) {
3516
+ return {
3517
+ error: modeResult.error,
3518
+ target: { name: target },
3519
+ direction,
3520
+ impactedCount: 0,
3521
+ risk: 'UNKNOWN',
3522
+ };
3523
+ }
3524
+ const mode = modeResult.mode;
3525
+ // `line` is a PDG-only statement anchor. Reject it on the callgraph path
3526
+ // rather than silently ignore (the symbol→symbol BFS has no statement notion).
3527
+ if (params.line !== undefined && mode !== 'pdg') {
3528
+ return {
3529
+ error: `Parameter 'line' is only supported with mode:'pdg' (it anchors the dependence slice on a statement). Remove it or set mode:'pdg'.`,
3530
+ target: { name: params.target },
3531
+ direction: params.direction,
3532
+ impactedCount: 0,
3533
+ risk: 'UNKNOWN',
3534
+ };
3535
+ }
3536
+ // A provided `line` must be a positive integer.
3537
+ if (params.line !== undefined &&
3538
+ (!Number.isInteger(params.line) || params.line < 1)) {
3539
+ // Line param fails validation before target resolution → partial-but-typed
3540
+ // target on the pdg path (typed PdgImpactTarget, not an inline literal).
3541
+ const badLineTarget = { name: params.target };
3542
+ return mode === 'pdg'
3543
+ ? makePdgImpactErrorResult({
3544
+ mode: 'pdg',
3545
+ error: `Parameter 'line' must be a positive integer (1-based source line), got ${JSON.stringify(params.line)}.`,
3546
+ target: badLineTarget,
3547
+ direction: params.direction,
3548
+ })
3549
+ : {
3550
+ error: `Parameter 'line' must be a positive integer (1-based source line), got ${JSON.stringify(params.line)}.`,
3551
+ target: { name: params.target },
3552
+ direction: params.direction,
3553
+ impactedCount: 0,
3554
+ risk: 'UNKNOWN',
3555
+ };
3556
+ }
3557
+ if (mode === 'pdg') {
3558
+ // PDG mode is now unified inside a single repo: it combines the local
3559
+ // CDG/RD statement slice with the same inter-symbol reach used for the
3560
+ // option-driven comparison path. Cross-repo fan-out remains a callgraph
3561
+ // feature, so crossDepth is still a loud error rather than a silent ignore.
3562
+ const incompatible = [];
3563
+ if (params.crossDepth !== undefined)
3564
+ incompatible.push('crossDepth');
3565
+ if (incompatible.length > 0) {
3566
+ // crossDepth is rejected before target resolution → partial-but-typed
3567
+ // target (typed PdgImpactTarget).
3568
+ const crossDepthTarget = { name: target };
3569
+ const pdgErr = makePdgImpactErrorResult({
3570
+ mode: 'pdg',
3571
+ error: `Parameter(s) ${incompatible.join(', ')} are not supported with mode:'pdg' ` +
3572
+ `(single-repo PDG impact). Remove them or use mode:'callgraph' for cross-repo fan-out.`,
3573
+ target: crossDepthTarget,
3574
+ direction,
3575
+ });
3576
+ return pdgErr;
3577
+ }
3578
+ }
2671
3579
  const maxDepth = params.maxDepth || 3;
2672
3580
  // Map legacy relation type names before filtering (backward compat for OVERRIDES → METHOD_OVERRIDES)
2673
3581
  const mappedRelTypes = params.relationTypes?.flatMap((t) => t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t]);
@@ -2710,15 +3618,64 @@ export class LocalBackend {
2710
3618
  const outcome = await this.resolveSymbolCandidates(repo, { uid: params.target_uid, name: target }, { file_path: params.file_path, kind: params.kind });
2711
3619
  if (outcome.kind === 'not_found') {
2712
3620
  const missing = params.target_uid ?? target;
2713
- return {
2714
- error: `Target '${missing}' not found`,
2715
- target: { name: target },
2716
- direction,
2717
- impactedCount: 0,
2718
- risk: 'UNKNOWN',
2719
- };
3621
+ // not_found = no resolved symbol, so the envelope keeps the partial-but-
3622
+ // typed target (typed PdgImpactTarget — there is no id/type/filePath yet).
3623
+ const notFoundTarget = { name: target };
3624
+ return mode === 'pdg'
3625
+ ? makePdgImpactErrorResult({
3626
+ mode: 'pdg',
3627
+ error: `Target '${missing}' not found`,
3628
+ target: notFoundTarget,
3629
+ direction,
3630
+ })
3631
+ : {
3632
+ error: `Target '${missing}' not found`,
3633
+ target: { name: target },
3634
+ direction,
3635
+ impactedCount: 0,
3636
+ risk: 'UNKNOWN',
3637
+ };
2720
3638
  }
2721
3639
  if (outcome.kind === 'ambiguous') {
3640
+ // Shared truncation cap for the ambiguous candidate list — both the pdg
3641
+ // branch (shows candidates) and the callgraph branch (probes candidates)
3642
+ // bound to this many.
3643
+ const AMBIGUOUS_MAX_CANDIDATES = 6;
3644
+ // KTD5 ambiguous trap — under mode:'pdg' we MUST NOT fall into the
3645
+ // callgraph fan-out below: it runs `_runImpactBFS` per candidate, which
3646
+ // would silently execute the call-graph engine under a `pdg` call (the
3647
+ // exact silent fallback KTD5 forbids). For U1 the pdg ambiguous path
3648
+ // returns the candidate list WITHOUT any callgraph probe; the full pdg
3649
+ // ambiguous handling (per-candidate PDG summaries / ranking) lands in U4.
3650
+ if (mode === 'pdg') {
3651
+ const truncated = outcome.candidates.length > AMBIGUOUS_MAX_CANDIDATES;
3652
+ const shown = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
3653
+ return {
3654
+ status: 'ambiguous',
3655
+ mode,
3656
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'` +
3657
+ (truncated ? ` (showing ${shown.length} of ${outcome.candidates.length})` : '') +
3658
+ `. Disambiguate with target_uid (or file_path/kind) for a single ` +
3659
+ `authoritative PDG result.`,
3660
+ target: { name: target },
3661
+ direction,
3662
+ totalCandidates: outcome.candidates.length,
3663
+ // No single resolved symbol → impactedCount stays 0 / risk UNKNOWN
3664
+ // (UNKNOWN must never read as "safe to refactor"). No callgraph
3665
+ // fan-out runs, so there is no per-candidate blast radius here yet.
3666
+ impactedCount: 0,
3667
+ risk: 'UNKNOWN',
3668
+ ...(truncated && { candidatesTruncated: true }),
3669
+ candidates: shown.map((c) => ({
3670
+ uid: c.id,
3671
+ name: c.name,
3672
+ kind: c.type,
3673
+ filePath: c.filePath,
3674
+ line: c.startLine,
3675
+ score: Number(c.score.toFixed(2)),
3676
+ })),
3677
+ };
3678
+ }
2722
3679
  // #2129 — a bare name that collides with several symbols must NOT report a
2723
3680
  // bare `impactedCount: 0`. The real blast radius lives under whichever
2724
3681
  // candidate the caller meant; a flat zero here is precisely the silent
@@ -2728,7 +3685,6 @@ export class LocalBackend {
2728
3685
  // summary-only BFS per candidate so each one's true count + risk is
2729
3686
  // visible, and surface the maximum at the top level so the headline can
2730
3687
  // never read as "safe to refactor". Candidates arrive sorted by score.
2731
- const AMBIGUOUS_MAX_CANDIDATES = 6;
2732
3688
  const probed = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
2733
3689
  // `partialProbe` is intentionally a SECOND incompleteness flag, distinct
2734
3690
  // from the traversal-interrupted `partial` flag used elsewhere: it means
@@ -2822,13 +3778,127 @@ export class LocalBackend {
2822
3778
  id: outcome.symbol.id,
2823
3779
  name: outcome.symbol.name,
2824
3780
  filePath: outcome.symbol.filePath,
3781
+ // Carry the resolved span so the PDG seed anchors on THIS symbol directly,
3782
+ // without re-resolving its (possibly ambiguous) name (FIX 1).
3783
+ startLine: outcome.symbol.startLine,
3784
+ endLine: outcome.symbol.endLine,
2825
3785
  };
2826
3786
  const symType = outcome.resolvedLabel || outcome.symbol.type || '';
3787
+ // (2) PDG-layer presence probe (U2, KTD7) — the four-state degradation
3788
+ // contract after target resolution, before traversal. A repo never analyzed
3789
+ // with `--pdg` (no-layer), one with only a partial layer (sub-layer-missing
3790
+ // — impact needs BOTH CDG and REACHING_DEF), or one whose meta is unreadable
3791
+ // (unknown) each returns a distinct guidance note here rather than a
3792
+ // confusing empty blast radius. Resolve first so target-known degraded
3793
+ // responses keep the same id/type/filePath envelope as successful PDG
3794
+ // responses; only `ready` falls through to traversal.
3795
+ // Hoisted so the (4) traversal branch can read `layer.hasCallSummary` (FU-C):
3796
+ // the same single meta-stamp probe serves both the degradation gate and the
3797
+ // ascent-availability note — no second probe.
3798
+ let layer;
3799
+ if (mode === 'pdg') {
3800
+ layer = await pdgLayerStatus({
3801
+ lbugPath: repo.lbugPath,
3802
+ executeParameterized,
3803
+ });
3804
+ if (isPdgDegradedLayerStatus(layer)) {
3805
+ // Degradation occurs AFTER target resolution → thread the FULL typed
3806
+ // envelope ({ id, name, type, filePath }) so degraded responses keep the
3807
+ // same target shape as a successful PDG result (typed PdgImpactTarget).
3808
+ const degradedTarget = {
3809
+ id: sym.id,
3810
+ name: sym.name,
3811
+ type: symType || 'Function',
3812
+ filePath: sym.filePath,
3813
+ };
3814
+ return makePdgLayerDegradedResult({
3815
+ mode,
3816
+ layer,
3817
+ target: degradedTarget,
3818
+ direction,
3819
+ });
3820
+ }
3821
+ }
2827
3822
  const effectiveRelationTypes = (symType === 'Class' || symType === 'Interface') &&
2828
3823
  !hasExplicitRelationTypes &&
2829
3824
  !relationTypes.includes('ACCESSES')
2830
3825
  ? [...relationTypes, 'ACCESSES']
2831
3826
  : relationTypes;
3827
+ // (4) single → route the resolved symbol to the engine selected by `mode`.
3828
+ if (mode === 'pdg') {
3829
+ const pdgResult = await this._runImpactPDG({
3830
+ repo,
3831
+ sym,
3832
+ symType,
3833
+ direction,
3834
+ maxDepth,
3835
+ line: params.line,
3836
+ limit: Number.isFinite(params.limit) ? params.limit : 100,
3837
+ // KTD2 extraction-seam discipline: hand the engine its DB dependency
3838
+ // explicitly rather than `this.`-binding it. LocalBackend owns repo
3839
+ // lifecycle; `pdg-impact.ts` owns traversal/projection.
3840
+ executeParameterized,
3841
+ // FU-C: thread the CALL_SUMMARY layer presence read above (meta stamp) so
3842
+ // the engine notes "no return-value ascent (re-index)" on a pre-FU-C (v3)
3843
+ // index. `layer.hasCallSummary` is set on every meta-readable state.
3844
+ callSummaryAvailable: layer?.hasCallSummary === true,
3845
+ });
3846
+ // Statement-precise inter-procedural reach: a first-hop callee is "proven"
3847
+ // iff it is invoked in a block of the criterion's dependence slice. The
3848
+ // slice = the seed block(s) (the changed line itself) UNION the dependent
3849
+ // reachable blocks — both carry the leaf callee names they call
3850
+ // (`BasicBlock.callees`). The seed block is included because a callee
3851
+ // invoked directly on the changed line is the most-directly-impacted one,
3852
+ // yet `reachableBlocks` excludes the seed by the seed-minus-reachable
3853
+ // convention. Upstream seeds carry no discriminating slice, so the bridge
3854
+ // falls back to preserving callgraph reach.
3855
+ // `_runImpactPDG` returns the PdgImpactResult union; only the success/empty
3856
+ // slice results carry intraReachableBlocks/seedBlocks (degraded and error
3857
+ // results do not). Narrow via the same discriminant the composer uses, then
3858
+ // read the typed string[] slices — no `as any`.
3859
+ const sliceResult = 'error' in pdgResult || 'pdgLayer' in pdgResult ? null : pdgResult;
3860
+ // FIX 6: key the "first-hop proven" set on the INTRA-procedural slice only
3861
+ // (seed ∪ intra-reachable), NOT `reachableBlocks` — which the U1 descent now
3862
+ // EXPANDS with inter-procedurally-reached callee blocks. Using the expanded
3863
+ // superset would mark transitively-reached (2+ hop) callgraph targets as
3864
+ // first-hop "proven", silently shifting the established statementPrecision
3865
+ // semantics. The interproc-reached blocks are routed into the statement
3866
+ // slice / block→symbol projection inside `_runImpactPDG` only.
3867
+ const intraReachableBlocks = sliceResult?.intraReachableBlocks ?? [];
3868
+ const seedBlocks = sliceResult?.seedBlocks ?? [];
3869
+ const sliceBlocks = [...seedBlocks, ...intraReachableBlocks];
3870
+ const sliceCalleeNames = direction === 'downstream' && sliceBlocks.length > 0
3871
+ ? await this.calleesOfBlocks(repo, sliceBlocks)
3872
+ : new Set();
3873
+ // Resolved-id slice set (sound primary key, KTD3): unioned from the SAME
3874
+ // seed ∪ reachable block set as the names — so a callee invoked only on the
3875
+ // seeded line is still provable by id. Absent on a pre-v3 index (no
3876
+ // `calleeIds` column) → empty → the bridge falls back to the leaf-name match.
3877
+ const sliceCalleeIds = direction === 'downstream' && sliceBlocks.length > 0
3878
+ ? await this.calleeIdsOfBlocks(repo, sliceBlocks)
3879
+ : new Set();
3880
+ // Build the bridge when EITHER the name fallback or the id key has signal —
3881
+ // an id-only index (names empty but ids present) must still seed the bridge.
3882
+ const pdgBridge = sliceCalleeNames.size > 0 || sliceCalleeIds.size > 0
3883
+ ? { sliceCalleeNames, sliceCalleeIds }
3884
+ : undefined;
3885
+ try {
3886
+ const interproceduralResult = await this._runImpactBFS(repo, sym, symType, direction, {
3887
+ maxDepth,
3888
+ relationTypes: effectiveRelationTypes,
3889
+ includeTests,
3890
+ minConfidence,
3891
+ limit: Number.isFinite(params.limit) ? params.limit : 100,
3892
+ offset: Number.isFinite(params.offset) ? params.offset : 0,
3893
+ pdgBridge,
3894
+ });
3895
+ return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult);
3896
+ }
3897
+ catch (e) {
3898
+ logQueryError('impact:pdg-interprocedural-reach', e);
3899
+ return composeUnifiedPdgImpactResult(pdgResult, null, e);
3900
+ }
3901
+ }
2832
3902
  return this._runImpactBFS(repo, sym, symType, direction, {
2833
3903
  maxDepth,
2834
3904
  relationTypes: effectiveRelationTypes,
@@ -2839,6 +3909,79 @@ export class LocalBackend {
2839
3909
  summaryOnly: params.summaryOnly,
2840
3910
  });
2841
3911
  }
3912
+ /**
3913
+ * Union of the leaf callee names invoked across a set of dependence-slice
3914
+ * blocks (`BasicBlock.callees`, space-joined at emit). Drives statement-precise
3915
+ * inter-procedural evidence: a first-hop callee reached from the criterion is
3916
+ * "proven" (callgraph-bridge) iff its name is in this set, else unproven-bridge.
3917
+ * Empty when the slice blocks call nothing or carry no harvested callees
3918
+ * (non-TS/JS or synthetic ENTRY/EXIT blocks) — the bridge then preserves
3919
+ * callgraph reach. A query failure is logged and degrades to empty (no proof),
3920
+ * never throws (the inter-procedural reach is still returned).
3921
+ */
3922
+ async calleesOfBlocks(repo, blockIds) {
3923
+ const names = new Set();
3924
+ if (blockIds.length === 0)
3925
+ return names;
3926
+ try {
3927
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (b:BasicBlock) WHERE b.id IN $ids RETURN b.callees AS callees`, { ids: blockIds });
3928
+ for (const r of rows) {
3929
+ const raw = String(r.callees ?? r[0] ?? '');
3930
+ for (const n of raw.split(' '))
3931
+ if (n)
3932
+ names.add(n);
3933
+ }
3934
+ }
3935
+ catch (e) {
3936
+ logQueryError('impact:pdg-slice-callees', e);
3937
+ }
3938
+ return names;
3939
+ }
3940
+ /**
3941
+ * Union of the RESOLVED callee symbol ids invoked across a set of
3942
+ * dependence-slice blocks (`BasicBlock.calleeIds`, space-joined at emit —
3943
+ * sibling of `callees`). This is the SOUND key the bridge prefers: a first-hop
3944
+ * callee is proven statement-precise iff its resolved id is in this set, which
3945
+ * eliminates the same-leaf-name collision (false-positive) and import-alias
3946
+ * (false-negative) the name set cannot distinguish. Empty when the slice blocks
3947
+ * carry no captured ids (pre-v3 index without the `calleeIds` column, or
3948
+ * non-overloading/synthetic blocks) — the bridge then falls back to the
3949
+ * leaf-name match per U5. A query failure is logged and degrades to empty (no
3950
+ * proof), never throws (the inter-procedural reach is still returned). Mirrors
3951
+ * `calleesOfBlocks` exactly — same shape, same swallow-on-error contract.
3952
+ */
3953
+ async calleeIdsOfBlocks(repo, blockIds) {
3954
+ const ids = new Set();
3955
+ if (blockIds.length === 0)
3956
+ return ids;
3957
+ try {
3958
+ const rows = await executeParameterized(repo.lbugPath, `MATCH (b:BasicBlock) WHERE b.id IN $ids RETURN b.calleeIds AS calleeIds`, { ids: blockIds });
3959
+ for (const r of rows) {
3960
+ // Shared split-and-drop-sentinel logic (`splitCalleeIds`) so this bridge
3961
+ // key and the inter-procedural descent cannot diverge. The sentinel marks
3962
+ // a capped block (handled by the names-sentinel check in the bridge) and
3963
+ // is not a resolved symbol id, so it never enters the `has(realId)` set.
3964
+ for (const id of splitCalleeIds(r.calleeIds ?? r[0]))
3965
+ ids.add(id);
3966
+ }
3967
+ }
3968
+ catch (e) {
3969
+ logQueryError('impact:pdg-slice-callee-ids', e);
3970
+ }
3971
+ return ids;
3972
+ }
3973
+ /**
3974
+ * Delegates the PDG impact engine to `pdg-impact.ts`.
3975
+ *
3976
+ * The private method remains as the LocalBackend dispatch seam so existing
3977
+ * tests can keep asserting that `mode:'pdg'` routes through the PDG
3978
+ * statement engine before LocalBackend attaches interprocedural symbol reach.
3979
+ * The traversal/projection/result assembly lives in the extracted helper
3980
+ * module.
3981
+ */
3982
+ async _runImpactPDG(deps) {
3983
+ return runImpactPDG(deps);
3984
+ }
2842
3985
  /**
2843
3986
  * #1858 — epistemic lower-bound detection.
2844
3987
  *
@@ -2972,6 +4115,7 @@ export class LocalBackend {
2972
4115
  : this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]));
2973
4116
  const impacted = [];
2974
4117
  const visited = new Set([symId]);
4118
+ const pdgBridgeEvidenceById = new Map();
2975
4119
  let frontier = [symId];
2976
4120
  let traversalComplete = true;
2977
4121
  // Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges
@@ -3049,10 +4193,31 @@ export class LocalBackend {
3049
4193
  ...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}),
3050
4194
  });
3051
4195
  for (const rel of related) {
4196
+ const sourceId = String(rel.sourceId ?? rel[0] ?? '');
3052
4197
  const relId = rel.id || rel[1];
3053
4198
  const filePath = rel.filePath || rel[4] || '';
3054
4199
  if (!includeTests && isTestFilePath(filePath))
3055
4200
  continue;
4201
+ // Bridge evidence is computed for EVERY edge (not just the first to
4202
+ // reach a node) and the strongest verdict across all parents is kept
4203
+ // (`callgraph-bridge` wins). This makes a diamond-reachable node's
4204
+ // proven/unproven label order-independent of DB row iteration; the
4205
+ // final label is stamped onto the impacted items after the depth loop.
4206
+ if (opts.pdgBridge) {
4207
+ const ev = pdgBridgeEvidenceForImpact({
4208
+ bridge: opts.pdgBridge,
4209
+ depth,
4210
+ calleeName: rel.name || rel[2],
4211
+ // Sound primary key (KTD3): the reached callee's RESOLVED id — the
4212
+ // same `relId` (`rel.id`) the BFS keys its visited/frontier sets on,
4213
+ // which equals the CALLS targetId captured into `BasicBlock.calleeIds`.
4214
+ // The bridge proves by id ∈ `sliceCalleeIds` first, falling back to
4215
+ // `calleeName` only when ids are absent or the block is capped.
4216
+ calleeId: relId,
4217
+ inherited: pdgBridgeEvidenceById.get(sourceId),
4218
+ });
4219
+ pdgBridgeEvidenceById.set(String(relId), betterBridgeEvidence(pdgBridgeEvidenceById.get(String(relId)), ev));
4220
+ }
3056
4221
  if (!visited.has(relId)) {
3057
4222
  visited.add(relId);
3058
4223
  nextFrontier.push(relId);
@@ -3063,6 +4228,8 @@ export class LocalBackend {
3063
4228
  const effectiveConfidence = typeof storedConfidence === 'number' && storedConfidence > 0
3064
4229
  ? storedConfidence
3065
4230
  : confidenceForRelType(relationType);
4231
+ // pdgEvidence is stamped after the depth loop from the finalized,
4232
+ // order-independent pdgBridgeEvidenceById map.
3066
4233
  impacted.push({
3067
4234
  depth,
3068
4235
  id: relId,
@@ -3084,6 +4251,18 @@ export class LocalBackend {
3084
4251
  }
3085
4252
  frontier = nextFrontier;
3086
4253
  }
4254
+ // Stamp the finalized, order-independent bridge evidence (strongest across
4255
+ // all parents) onto each impacted item. Deferred from the BFS loop so a
4256
+ // diamond-reachable node reflects a proven parent regardless of visit order.
4257
+ if (opts.pdgBridge) {
4258
+ for (const item of impacted) {
4259
+ const ev = pdgBridgeEvidenceById.get(String(item.id));
4260
+ if (ev) {
4261
+ item.pdgEvidence = ev.evidence;
4262
+ item.pdgBridgeBasis = ev.basis;
4263
+ }
4264
+ }
4265
+ }
3087
4266
  const grouped = {};
3088
4267
  for (const item of impacted) {
3089
4268
  if (!grouped[item.depth])
@@ -3577,6 +4756,30 @@ export class LocalBackend {
3577
4756
  return { error: resolved.error };
3578
4757
  const svc = this.getGroupService();
3579
4758
  if (method === 'impact') {
4759
+ // KTD5/KTD12 — validate `mode` at the group-forward boundary too (the
4760
+ // JSON-schema enum is advisory). An invalid mode errors; `mode:'pdg'` is
4761
+ // rejected for @group targets because PDG impact is single-repo and
4762
+ // intra-procedural — there is no cross-repo dependence graph to walk.
4763
+ // Rejecting here (before groupImpact) is the KTD12 @group hard error.
4764
+ const groupModeResult = validateImpactMode(params.mode);
4765
+ if ('error' in groupModeResult)
4766
+ return { error: groupModeResult.error };
4767
+ if (groupModeResult.mode === 'pdg') {
4768
+ // @group reject: no single-repo symbol is ever resolved on the group path,
4769
+ // so the envelope carries the partial-but-typed target (PdgImpactTarget).
4770
+ // Routed through the typed builder so this exit is a PdgImpactResult union
4771
+ // member, never a bare { error } object.
4772
+ const groupRejectTarget = { name: String(params.target ?? '') };
4773
+ const pdgErr = makePdgImpactErrorResult({
4774
+ mode: 'pdg',
4775
+ error: "mode:'pdg' is not supported for @group targets — PDG impact is " +
4776
+ 'single-repo and intra-procedural. Run pdg impact against an ' +
4777
+ 'individual indexed repository instead.',
4778
+ target: groupRejectTarget,
4779
+ direction: (params.direction === 'downstream' ? 'downstream' : 'upstream'),
4780
+ });
4781
+ return pdgErr;
4782
+ }
3580
4783
  const impactArgs = {
3581
4784
  name: groupName,
3582
4785
  repo: resolved.repoPath,
@@ -3609,7 +4812,10 @@ export class LocalBackend {
3609
4812
  if (method === 'query') {
3610
4813
  const queryArgs = {
3611
4814
  name: groupName,
3612
- query: params.query,
4815
+ // #2175: resolve the search_query alias here (new name wins, same rule as the
4816
+ // local query() handler) so the group path is self-contained and does not depend
4817
+ // on params being normalized upstream. groupQuery() reads `query`.
4818
+ query: resolveAliasString(params.search_query, params.query),
3613
4819
  };
3614
4820
  if (typeof params.task_context === 'string')
3615
4821
  queryArgs.task_context = params.task_context;