@optave/codegraph 3.12.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (524) hide show
  1. package/README.md +83 -46
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +38 -40
  4. package/dist/ast-analysis/engine.js.map +1 -1
  5. package/dist/ast-analysis/rules/b2.d.ts +7 -0
  6. package/dist/ast-analysis/rules/b2.d.ts.map +1 -0
  7. package/dist/ast-analysis/rules/b2.js +240 -0
  8. package/dist/ast-analysis/rules/b2.js.map +1 -0
  9. package/dist/ast-analysis/rules/b3.d.ts +6 -0
  10. package/dist/ast-analysis/rules/b3.d.ts.map +1 -0
  11. package/dist/ast-analysis/rules/b3.js +105 -0
  12. package/dist/ast-analysis/rules/b3.js.map +1 -0
  13. package/dist/ast-analysis/rules/b4.d.ts +9 -0
  14. package/dist/ast-analysis/rules/b4.d.ts.map +1 -0
  15. package/dist/ast-analysis/rules/b4.js +361 -0
  16. package/dist/ast-analysis/rules/b4.js.map +1 -0
  17. package/dist/ast-analysis/rules/b5.d.ts +4 -0
  18. package/dist/ast-analysis/rules/b5.d.ts.map +1 -0
  19. package/dist/ast-analysis/rules/b5.js +52 -0
  20. package/dist/ast-analysis/rules/b5.js.map +1 -0
  21. package/dist/ast-analysis/rules/c.d.ts +4 -0
  22. package/dist/ast-analysis/rules/c.d.ts.map +1 -0
  23. package/dist/ast-analysis/rules/c.js +143 -0
  24. package/dist/ast-analysis/rules/c.js.map +1 -0
  25. package/dist/ast-analysis/rules/index.d.ts.map +1 -1
  26. package/dist/ast-analysis/rules/index.js +34 -0
  27. package/dist/ast-analysis/rules/index.js.map +1 -1
  28. package/dist/ast-analysis/rules/javascript.d.ts.map +1 -1
  29. package/dist/ast-analysis/rules/javascript.js +3 -0
  30. package/dist/ast-analysis/rules/javascript.js.map +1 -1
  31. package/dist/ast-analysis/shared.d.ts.map +1 -1
  32. package/dist/ast-analysis/shared.js +2 -0
  33. package/dist/ast-analysis/shared.js.map +1 -1
  34. package/dist/ast-analysis/visitor-utils.d.ts +1 -0
  35. package/dist/ast-analysis/visitor-utils.d.ts.map +1 -1
  36. package/dist/ast-analysis/visitor-utils.js +5 -0
  37. package/dist/ast-analysis/visitor-utils.js.map +1 -1
  38. package/dist/ast-analysis/visitor.d.ts.map +1 -1
  39. package/dist/ast-analysis/visitor.js +60 -47
  40. package/dist/ast-analysis/visitor.js.map +1 -1
  41. package/dist/ast-analysis/visitors/cfg-visitor.d.ts.map +1 -1
  42. package/dist/ast-analysis/visitors/cfg-visitor.js +126 -76
  43. package/dist/ast-analysis/visitors/cfg-visitor.js.map +1 -1
  44. package/dist/ast-analysis/visitors/complexity-visitor.d.ts.map +1 -1
  45. package/dist/ast-analysis/visitors/complexity-visitor.js +27 -15
  46. package/dist/ast-analysis/visitors/complexity-visitor.js.map +1 -1
  47. package/dist/ast-analysis/visitors/dataflow-visitor.d.ts.map +1 -1
  48. package/dist/ast-analysis/visitors/dataflow-visitor.js +54 -21
  49. package/dist/ast-analysis/visitors/dataflow-visitor.js.map +1 -1
  50. package/dist/cli/commands/audit.d.ts.map +1 -1
  51. package/dist/cli/commands/audit.js +2 -1
  52. package/dist/cli/commands/audit.js.map +1 -1
  53. package/dist/cli/commands/batch.d.ts.map +1 -1
  54. package/dist/cli/commands/batch.js +1 -0
  55. package/dist/cli/commands/batch.js.map +1 -1
  56. package/dist/cli/commands/build.d.ts.map +1 -1
  57. package/dist/cli/commands/build.js +6 -1
  58. package/dist/cli/commands/build.js.map +1 -1
  59. package/dist/cli/commands/config.d.ts +3 -0
  60. package/dist/cli/commands/config.d.ts.map +1 -0
  61. package/dist/cli/commands/config.js +275 -0
  62. package/dist/cli/commands/config.js.map +1 -0
  63. package/dist/cli/commands/roles.d.ts.map +1 -1
  64. package/dist/cli/commands/roles.js +6 -1
  65. package/dist/cli/commands/roles.js.map +1 -1
  66. package/dist/cli/commands/triage.js +1 -1
  67. package/dist/cli/commands/triage.js.map +1 -1
  68. package/dist/cli/index.d.ts.map +1 -1
  69. package/dist/cli/index.js +10 -0
  70. package/dist/cli/index.js.map +1 -1
  71. package/dist/cli/shared/options.d.ts +2 -1
  72. package/dist/cli/shared/options.d.ts.map +1 -1
  73. package/dist/cli/shared/options.js +11 -1
  74. package/dist/cli/shared/options.js.map +1 -1
  75. package/dist/cli/types.d.ts +2 -0
  76. package/dist/cli/types.d.ts.map +1 -1
  77. package/dist/db/better-sqlite3.d.ts +2 -1
  78. package/dist/db/better-sqlite3.d.ts.map +1 -1
  79. package/dist/db/better-sqlite3.js.map +1 -1
  80. package/dist/db/connection.d.ts +7 -1
  81. package/dist/db/connection.d.ts.map +1 -1
  82. package/dist/db/connection.js +20 -5
  83. package/dist/db/connection.js.map +1 -1
  84. package/dist/db/index.d.ts +1 -1
  85. package/dist/db/index.d.ts.map +1 -1
  86. package/dist/db/index.js +1 -1
  87. package/dist/db/index.js.map +1 -1
  88. package/dist/db/migrations.d.ts.map +1 -1
  89. package/dist/db/migrations.js +69 -1
  90. package/dist/db/migrations.js.map +1 -1
  91. package/dist/db/repository/build-stmts.d.ts.map +1 -1
  92. package/dist/db/repository/build-stmts.js +18 -0
  93. package/dist/db/repository/build-stmts.js.map +1 -1
  94. package/dist/db/repository/dataflow.d.ts +5 -0
  95. package/dist/db/repository/dataflow.d.ts.map +1 -1
  96. package/dist/db/repository/dataflow.js +14 -0
  97. package/dist/db/repository/dataflow.js.map +1 -1
  98. package/dist/db/repository/index.d.ts +1 -1
  99. package/dist/db/repository/index.d.ts.map +1 -1
  100. package/dist/db/repository/index.js +1 -1
  101. package/dist/db/repository/index.js.map +1 -1
  102. package/dist/db/repository/native-repository.d.ts.map +1 -1
  103. package/dist/db/repository/native-repository.js +47 -34
  104. package/dist/db/repository/native-repository.js.map +1 -1
  105. package/dist/domain/analysis/context.d.ts +2 -2
  106. package/dist/domain/analysis/dependencies.d.ts +2 -2
  107. package/dist/domain/analysis/diff-impact.d.ts +2 -2
  108. package/dist/domain/analysis/fn-impact.d.ts +3 -1
  109. package/dist/domain/analysis/fn-impact.d.ts.map +1 -1
  110. package/dist/domain/analysis/fn-impact.js +4 -0
  111. package/dist/domain/analysis/fn-impact.js.map +1 -1
  112. package/dist/domain/analysis/implementations.d.ts +2 -2
  113. package/dist/domain/analysis/module-map.d.ts.map +1 -1
  114. package/dist/domain/analysis/module-map.js +32 -5
  115. package/dist/domain/analysis/module-map.js.map +1 -1
  116. package/dist/domain/analysis/roles.d.ts +7 -1
  117. package/dist/domain/analysis/roles.d.ts.map +1 -1
  118. package/dist/domain/analysis/roles.js +16 -0
  119. package/dist/domain/analysis/roles.js.map +1 -1
  120. package/dist/domain/analysis/symbol-lookup.d.ts +4 -4
  121. package/dist/domain/graph/builder/call-resolver.d.ts +29 -13
  122. package/dist/domain/graph/builder/call-resolver.d.ts.map +1 -1
  123. package/dist/domain/graph/builder/call-resolver.js +125 -205
  124. package/dist/domain/graph/builder/call-resolver.js.map +1 -1
  125. package/dist/domain/graph/builder/cha.d.ts +9 -1
  126. package/dist/domain/graph/builder/cha.d.ts.map +1 -1
  127. package/dist/domain/graph/builder/cha.js +17 -2
  128. package/dist/domain/graph/builder/cha.js.map +1 -1
  129. package/dist/domain/graph/builder/context.d.ts +1 -0
  130. package/dist/domain/graph/builder/context.d.ts.map +1 -1
  131. package/dist/domain/graph/builder/context.js.map +1 -1
  132. package/dist/domain/graph/builder/helpers.d.ts +24 -1
  133. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  134. package/dist/domain/graph/builder/helpers.js +174 -65
  135. package/dist/domain/graph/builder/helpers.js.map +1 -1
  136. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  137. package/dist/domain/graph/builder/incremental.js +166 -97
  138. package/dist/domain/graph/builder/incremental.js.map +1 -1
  139. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  140. package/dist/domain/graph/builder/pipeline.js +46 -5
  141. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  142. package/dist/domain/graph/builder/stages/build-edges.d.ts +0 -2
  143. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  144. package/dist/domain/graph/builder/stages/build-edges.js +554 -538
  145. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  146. package/dist/domain/graph/builder/stages/collect-files.d.ts.map +1 -1
  147. package/dist/domain/graph/builder/stages/collect-files.js +10 -7
  148. package/dist/domain/graph/builder/stages/collect-files.js.map +1 -1
  149. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  150. package/dist/domain/graph/builder/stages/detect-changes.js +3 -2
  151. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  152. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  153. package/dist/domain/graph/builder/stages/finalize.js +4 -0
  154. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  155. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -1
  156. package/dist/domain/graph/builder/stages/native-orchestrator.js +952 -343
  157. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -1
  158. package/dist/domain/graph/builder/stages/resolve-imports.js +1 -1
  159. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  160. package/dist/domain/graph/resolver/points-to.d.ts.map +1 -1
  161. package/dist/domain/graph/resolver/points-to.js +105 -57
  162. package/dist/domain/graph/resolver/points-to.js.map +1 -1
  163. package/dist/domain/graph/resolver/strategy.d.ts +61 -0
  164. package/dist/domain/graph/resolver/strategy.d.ts.map +1 -0
  165. package/dist/domain/graph/resolver/strategy.js +222 -0
  166. package/dist/domain/graph/resolver/strategy.js.map +1 -0
  167. package/dist/domain/graph/watcher.d.ts.map +1 -1
  168. package/dist/domain/graph/watcher.js +16 -9
  169. package/dist/domain/graph/watcher.js.map +1 -1
  170. package/dist/domain/parser.d.ts +16 -5
  171. package/dist/domain/parser.d.ts.map +1 -1
  172. package/dist/domain/parser.js +58 -17
  173. package/dist/domain/parser.js.map +1 -1
  174. package/dist/domain/queries.d.ts +1 -1
  175. package/dist/domain/queries.d.ts.map +1 -1
  176. package/dist/domain/queries.js +1 -1
  177. package/dist/domain/queries.js.map +1 -1
  178. package/dist/domain/wasm-worker-entry.js +13 -2
  179. package/dist/domain/wasm-worker-entry.js.map +1 -1
  180. package/dist/domain/wasm-worker-pool.d.ts.map +1 -1
  181. package/dist/domain/wasm-worker-pool.js +26 -5
  182. package/dist/domain/wasm-worker-pool.js.map +1 -1
  183. package/dist/domain/wasm-worker-protocol.d.ts +8 -0
  184. package/dist/domain/wasm-worker-protocol.d.ts.map +1 -1
  185. package/dist/extractors/cpp.d.ts.map +1 -1
  186. package/dist/extractors/cpp.js +42 -1
  187. package/dist/extractors/cpp.js.map +1 -1
  188. package/dist/extractors/cuda.d.ts.map +1 -1
  189. package/dist/extractors/cuda.js +42 -1
  190. package/dist/extractors/cuda.js.map +1 -1
  191. package/dist/extractors/dart.js +48 -3
  192. package/dist/extractors/dart.js.map +1 -1
  193. package/dist/extractors/groovy.js +62 -3
  194. package/dist/extractors/groovy.js.map +1 -1
  195. package/dist/extractors/helpers.d.ts +15 -2
  196. package/dist/extractors/helpers.d.ts.map +1 -1
  197. package/dist/extractors/helpers.js +45 -1
  198. package/dist/extractors/helpers.js.map +1 -1
  199. package/dist/extractors/java.d.ts.map +1 -1
  200. package/dist/extractors/java.js +85 -8
  201. package/dist/extractors/java.js.map +1 -1
  202. package/dist/extractors/javascript.d.ts.map +1 -1
  203. package/dist/extractors/javascript.js +686 -169
  204. package/dist/extractors/javascript.js.map +1 -1
  205. package/dist/extractors/kotlin.js +58 -3
  206. package/dist/extractors/kotlin.js.map +1 -1
  207. package/dist/extractors/objc.js +25 -2
  208. package/dist/extractors/objc.js.map +1 -1
  209. package/dist/extractors/scala.js +62 -2
  210. package/dist/extractors/scala.js.map +1 -1
  211. package/dist/extractors/swift.js +52 -3
  212. package/dist/extractors/swift.js.map +1 -1
  213. package/dist/features/audit.js +26 -23
  214. package/dist/features/audit.js.map +1 -1
  215. package/dist/features/boundaries.d.ts.map +1 -1
  216. package/dist/features/boundaries.js +12 -9
  217. package/dist/features/boundaries.js.map +1 -1
  218. package/dist/features/cfg.d.ts.map +1 -1
  219. package/dist/features/cfg.js +25 -18
  220. package/dist/features/cfg.js.map +1 -1
  221. package/dist/features/check.d.ts.map +1 -1
  222. package/dist/features/check.js +18 -5
  223. package/dist/features/check.js.map +1 -1
  224. package/dist/features/communities.d.ts +4 -2
  225. package/dist/features/communities.d.ts.map +1 -1
  226. package/dist/features/communities.js +6 -4
  227. package/dist/features/communities.js.map +1 -1
  228. package/dist/features/dataflow.d.ts +60 -0
  229. package/dist/features/dataflow.d.ts.map +1 -1
  230. package/dist/features/dataflow.js +530 -6
  231. package/dist/features/dataflow.js.map +1 -1
  232. package/dist/features/manifesto.d.ts.map +1 -1
  233. package/dist/features/manifesto.js +59 -72
  234. package/dist/features/manifesto.js.map +1 -1
  235. package/dist/features/sequence.d.ts.map +1 -1
  236. package/dist/features/sequence.js +27 -22
  237. package/dist/features/sequence.js.map +1 -1
  238. package/dist/features/snapshot.d.ts.map +1 -1
  239. package/dist/features/snapshot.js +36 -28
  240. package/dist/features/snapshot.js.map +1 -1
  241. package/dist/features/structure-query.d.ts +1 -1
  242. package/dist/features/structure-query.d.ts.map +1 -1
  243. package/dist/features/structure-query.js +6 -6
  244. package/dist/features/structure-query.js.map +1 -1
  245. package/dist/features/structure.d.ts.map +1 -1
  246. package/dist/features/structure.js +150 -62
  247. package/dist/features/structure.js.map +1 -1
  248. package/dist/features/triage.d.ts.map +1 -1
  249. package/dist/features/triage.js +18 -11
  250. package/dist/features/triage.js.map +1 -1
  251. package/dist/graph/algorithms/bfs.d.ts +1 -1
  252. package/dist/graph/algorithms/bfs.d.ts.map +1 -1
  253. package/dist/graph/algorithms/bfs.js +14 -13
  254. package/dist/graph/algorithms/bfs.js.map +1 -1
  255. package/dist/graph/algorithms/tarjan.d.ts.map +1 -1
  256. package/dist/graph/algorithms/tarjan.js +5 -0
  257. package/dist/graph/algorithms/tarjan.js.map +1 -1
  258. package/dist/graph/builders/dependency.js +28 -22
  259. package/dist/graph/builders/dependency.js.map +1 -1
  260. package/dist/graph/classifiers/roles.d.ts +10 -1
  261. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  262. package/dist/graph/classifiers/roles.js +60 -6
  263. package/dist/graph/classifiers/roles.js.map +1 -1
  264. package/dist/index.d.ts +1 -1
  265. package/dist/index.d.ts.map +1 -1
  266. package/dist/index.js +1 -1
  267. package/dist/index.js.map +1 -1
  268. package/dist/infrastructure/config.d.ts +87 -4
  269. package/dist/infrastructure/config.d.ts.map +1 -1
  270. package/dist/infrastructure/config.js +424 -22
  271. package/dist/infrastructure/config.js.map +1 -1
  272. package/dist/infrastructure/registry.d.ts +27 -7
  273. package/dist/infrastructure/registry.d.ts.map +1 -1
  274. package/dist/infrastructure/registry.js +79 -5
  275. package/dist/infrastructure/registry.js.map +1 -1
  276. package/dist/infrastructure/update-check.d.ts.map +1 -1
  277. package/dist/infrastructure/update-check.js +49 -31
  278. package/dist/infrastructure/update-check.js.map +1 -1
  279. package/dist/mcp/server.d.ts +2 -10
  280. package/dist/mcp/server.d.ts.map +1 -1
  281. package/dist/mcp/server.js.map +1 -1
  282. package/dist/mcp/tools/ast-query.d.ts +1 -1
  283. package/dist/mcp/tools/ast-query.d.ts.map +1 -1
  284. package/dist/mcp/tools/audit.d.ts +1 -1
  285. package/dist/mcp/tools/audit.d.ts.map +1 -1
  286. package/dist/mcp/tools/batch-query.d.ts +1 -1
  287. package/dist/mcp/tools/batch-query.d.ts.map +1 -1
  288. package/dist/mcp/tools/branch-compare.d.ts +1 -1
  289. package/dist/mcp/tools/branch-compare.d.ts.map +1 -1
  290. package/dist/mcp/tools/brief.d.ts +1 -1
  291. package/dist/mcp/tools/brief.d.ts.map +1 -1
  292. package/dist/mcp/tools/cfg.d.ts +1 -1
  293. package/dist/mcp/tools/cfg.d.ts.map +1 -1
  294. package/dist/mcp/tools/check.d.ts +1 -1
  295. package/dist/mcp/tools/check.d.ts.map +1 -1
  296. package/dist/mcp/tools/co-changes.d.ts +1 -1
  297. package/dist/mcp/tools/co-changes.d.ts.map +1 -1
  298. package/dist/mcp/tools/code-owners.d.ts +1 -1
  299. package/dist/mcp/tools/code-owners.d.ts.map +1 -1
  300. package/dist/mcp/tools/communities.d.ts +1 -1
  301. package/dist/mcp/tools/communities.d.ts.map +1 -1
  302. package/dist/mcp/tools/complexity.d.ts +1 -1
  303. package/dist/mcp/tools/complexity.d.ts.map +1 -1
  304. package/dist/mcp/tools/context.d.ts +1 -1
  305. package/dist/mcp/tools/context.d.ts.map +1 -1
  306. package/dist/mcp/tools/dataflow.d.ts +1 -1
  307. package/dist/mcp/tools/dataflow.d.ts.map +1 -1
  308. package/dist/mcp/tools/diff-impact.d.ts +1 -1
  309. package/dist/mcp/tools/diff-impact.d.ts.map +1 -1
  310. package/dist/mcp/tools/execution-flow.d.ts +1 -1
  311. package/dist/mcp/tools/execution-flow.d.ts.map +1 -1
  312. package/dist/mcp/tools/export-graph.d.ts +1 -1
  313. package/dist/mcp/tools/export-graph.d.ts.map +1 -1
  314. package/dist/mcp/tools/file-deps.d.ts +1 -1
  315. package/dist/mcp/tools/file-deps.d.ts.map +1 -1
  316. package/dist/mcp/tools/file-exports.d.ts +1 -1
  317. package/dist/mcp/tools/file-exports.d.ts.map +1 -1
  318. package/dist/mcp/tools/find-cycles.d.ts +1 -1
  319. package/dist/mcp/tools/find-cycles.d.ts.map +1 -1
  320. package/dist/mcp/tools/fn-impact.d.ts +1 -1
  321. package/dist/mcp/tools/fn-impact.d.ts.map +1 -1
  322. package/dist/mcp/tools/impact-analysis.d.ts +1 -1
  323. package/dist/mcp/tools/impact-analysis.d.ts.map +1 -1
  324. package/dist/mcp/tools/implementations.d.ts +1 -1
  325. package/dist/mcp/tools/implementations.d.ts.map +1 -1
  326. package/dist/mcp/tools/index.d.ts +2 -5
  327. package/dist/mcp/tools/index.d.ts.map +1 -1
  328. package/dist/mcp/tools/index.js.map +1 -1
  329. package/dist/mcp/tools/interfaces.d.ts +1 -1
  330. package/dist/mcp/tools/interfaces.d.ts.map +1 -1
  331. package/dist/mcp/tools/list-functions.d.ts +1 -1
  332. package/dist/mcp/tools/list-functions.d.ts.map +1 -1
  333. package/dist/mcp/tools/list-repos.d.ts +1 -1
  334. package/dist/mcp/tools/list-repos.d.ts.map +1 -1
  335. package/dist/mcp/tools/module-map.d.ts +1 -1
  336. package/dist/mcp/tools/module-map.d.ts.map +1 -1
  337. package/dist/mcp/tools/node-roles.d.ts +1 -1
  338. package/dist/mcp/tools/node-roles.d.ts.map +1 -1
  339. package/dist/mcp/tools/path.d.ts +1 -1
  340. package/dist/mcp/tools/path.d.ts.map +1 -1
  341. package/dist/mcp/tools/query.d.ts +1 -1
  342. package/dist/mcp/tools/query.d.ts.map +1 -1
  343. package/dist/mcp/tools/semantic-search.d.ts +1 -1
  344. package/dist/mcp/tools/semantic-search.d.ts.map +1 -1
  345. package/dist/mcp/tools/sequence.d.ts +1 -1
  346. package/dist/mcp/tools/sequence.d.ts.map +1 -1
  347. package/dist/mcp/tools/structure.d.ts +1 -1
  348. package/dist/mcp/tools/structure.d.ts.map +1 -1
  349. package/dist/mcp/tools/symbol-children.d.ts +1 -1
  350. package/dist/mcp/tools/symbol-children.d.ts.map +1 -1
  351. package/dist/mcp/tools/triage.d.ts +1 -1
  352. package/dist/mcp/tools/triage.d.ts.map +1 -1
  353. package/dist/mcp/tools/where.d.ts +1 -1
  354. package/dist/mcp/tools/where.d.ts.map +1 -1
  355. package/dist/mcp/types.d.ts +19 -0
  356. package/dist/mcp/types.d.ts.map +1 -0
  357. package/dist/mcp/types.js +6 -0
  358. package/dist/mcp/types.js.map +1 -0
  359. package/dist/presentation/queries-cli/index.d.ts +1 -1
  360. package/dist/presentation/queries-cli/index.d.ts.map +1 -1
  361. package/dist/presentation/queries-cli/index.js +1 -1
  362. package/dist/presentation/queries-cli/index.js.map +1 -1
  363. package/dist/presentation/queries-cli/overview.d.ts +1 -0
  364. package/dist/presentation/queries-cli/overview.d.ts.map +1 -1
  365. package/dist/presentation/queries-cli/overview.js +20 -1
  366. package/dist/presentation/queries-cli/overview.js.map +1 -1
  367. package/dist/presentation/queries-cli.d.ts +1 -1
  368. package/dist/presentation/queries-cli.d.ts.map +1 -1
  369. package/dist/presentation/queries-cli.js +1 -1
  370. package/dist/presentation/queries-cli.js.map +1 -1
  371. package/dist/presentation/structure.d.ts +1 -1
  372. package/dist/presentation/structure.d.ts.map +1 -1
  373. package/dist/presentation/structure.js +2 -2
  374. package/dist/presentation/structure.js.map +1 -1
  375. package/dist/presentation/viewer.d.ts.map +1 -1
  376. package/dist/presentation/viewer.js +45 -32
  377. package/dist/presentation/viewer.js.map +1 -1
  378. package/dist/shared/constants.d.ts +21 -0
  379. package/dist/shared/constants.d.ts.map +1 -1
  380. package/dist/shared/constants.js +25 -0
  381. package/dist/shared/constants.js.map +1 -1
  382. package/dist/shared/normalize.d.ts.map +1 -1
  383. package/dist/shared/normalize.js +12 -22
  384. package/dist/shared/normalize.js.map +1 -1
  385. package/dist/shared/paginate.d.ts +4 -17
  386. package/dist/shared/paginate.d.ts.map +1 -1
  387. package/dist/shared/paginate.js.map +1 -1
  388. package/dist/types.d.ts +113 -1
  389. package/dist/types.d.ts.map +1 -1
  390. package/grammars/tree-sitter-erlang.wasm +0 -0
  391. package/grammars/tree-sitter-gleam.wasm +0 -0
  392. package/package.json +7 -8
  393. package/src/ast-analysis/engine.ts +43 -63
  394. package/src/ast-analysis/rules/b2.ts +263 -0
  395. package/src/ast-analysis/rules/b3.ts +127 -0
  396. package/src/ast-analysis/rules/b4.ts +378 -0
  397. package/src/ast-analysis/rules/b5.ts +65 -0
  398. package/src/ast-analysis/rules/c.ts +157 -0
  399. package/src/ast-analysis/rules/index.ts +34 -0
  400. package/src/ast-analysis/rules/javascript.ts +3 -0
  401. package/src/ast-analysis/shared.ts +2 -0
  402. package/src/ast-analysis/visitor-utils.ts +5 -0
  403. package/src/ast-analysis/visitor.ts +82 -52
  404. package/src/ast-analysis/visitors/cfg-visitor.ts +198 -84
  405. package/src/ast-analysis/visitors/complexity-visitor.ts +44 -16
  406. package/src/ast-analysis/visitors/dataflow-visitor.ts +68 -29
  407. package/src/cli/commands/audit.ts +2 -1
  408. package/src/cli/commands/batch.ts +1 -0
  409. package/src/cli/commands/build.ts +6 -1
  410. package/src/cli/commands/config.ts +353 -0
  411. package/src/cli/commands/roles.ts +6 -1
  412. package/src/cli/commands/triage.ts +1 -1
  413. package/src/cli/index.ts +10 -0
  414. package/src/cli/shared/options.ts +11 -1
  415. package/src/cli/types.ts +2 -0
  416. package/src/db/better-sqlite3.ts +5 -4
  417. package/src/db/connection.ts +23 -5
  418. package/src/db/index.ts +1 -0
  419. package/src/db/migrations.ts +69 -1
  420. package/src/db/repository/build-stmts.ts +30 -0
  421. package/src/db/repository/dataflow.ts +16 -0
  422. package/src/db/repository/index.ts +1 -1
  423. package/src/db/repository/native-repository.ts +56 -40
  424. package/src/domain/analysis/fn-impact.ts +4 -0
  425. package/src/domain/analysis/module-map.ts +38 -6
  426. package/src/domain/analysis/roles.ts +23 -0
  427. package/src/domain/graph/builder/call-resolver.ts +156 -218
  428. package/src/domain/graph/builder/cha.ts +18 -1
  429. package/src/domain/graph/builder/context.ts +1 -0
  430. package/src/domain/graph/builder/helpers.ts +205 -67
  431. package/src/domain/graph/builder/incremental.ts +249 -119
  432. package/src/domain/graph/builder/pipeline.ts +59 -6
  433. package/src/domain/graph/builder/stages/build-edges.ts +783 -652
  434. package/src/domain/graph/builder/stages/collect-files.ts +12 -6
  435. package/src/domain/graph/builder/stages/detect-changes.ts +4 -2
  436. package/src/domain/graph/builder/stages/finalize.ts +4 -0
  437. package/src/domain/graph/builder/stages/native-orchestrator.ts +1214 -398
  438. package/src/domain/graph/builder/stages/resolve-imports.ts +1 -1
  439. package/src/domain/graph/resolver/points-to.ts +182 -59
  440. package/src/domain/graph/resolver/strategy.ts +265 -0
  441. package/src/domain/graph/watcher.ts +19 -9
  442. package/src/domain/parser.ts +57 -16
  443. package/src/domain/queries.ts +1 -1
  444. package/src/domain/wasm-worker-entry.ts +13 -2
  445. package/src/domain/wasm-worker-pool.ts +29 -4
  446. package/src/domain/wasm-worker-protocol.ts +5 -0
  447. package/src/extractors/cpp.ts +44 -1
  448. package/src/extractors/cuda.ts +44 -1
  449. package/src/extractors/dart.ts +48 -3
  450. package/src/extractors/groovy.ts +62 -2
  451. package/src/extractors/helpers.ts +48 -2
  452. package/src/extractors/java.ts +88 -8
  453. package/src/extractors/javascript.ts +693 -167
  454. package/src/extractors/kotlin.ts +57 -3
  455. package/src/extractors/objc.ts +25 -1
  456. package/src/extractors/scala.ts +63 -1
  457. package/src/extractors/swift.ts +46 -3
  458. package/src/features/audit.ts +43 -34
  459. package/src/features/boundaries.ts +17 -9
  460. package/src/features/cfg.ts +31 -22
  461. package/src/features/check.ts +21 -5
  462. package/src/features/communities.ts +28 -19
  463. package/src/features/dataflow.ts +755 -6
  464. package/src/features/manifesto.ts +76 -75
  465. package/src/features/sequence.ts +29 -23
  466. package/src/features/snapshot.ts +36 -25
  467. package/src/features/structure-query.ts +7 -7
  468. package/src/features/structure.ts +185 -55
  469. package/src/features/triage.ts +28 -15
  470. package/src/graph/algorithms/bfs.ts +13 -12
  471. package/src/graph/algorithms/tarjan.ts +5 -0
  472. package/src/graph/builders/dependency.ts +35 -23
  473. package/src/graph/classifiers/roles.ts +74 -7
  474. package/src/index.ts +5 -1
  475. package/src/infrastructure/config.ts +511 -23
  476. package/src/infrastructure/registry.ts +117 -12
  477. package/src/infrastructure/update-check.ts +55 -33
  478. package/src/mcp/server.ts +2 -8
  479. package/src/mcp/tools/ast-query.ts +1 -1
  480. package/src/mcp/tools/audit.ts +1 -1
  481. package/src/mcp/tools/batch-query.ts +1 -1
  482. package/src/mcp/tools/branch-compare.ts +1 -1
  483. package/src/mcp/tools/brief.ts +1 -1
  484. package/src/mcp/tools/cfg.ts +1 -1
  485. package/src/mcp/tools/check.ts +1 -1
  486. package/src/mcp/tools/co-changes.ts +1 -1
  487. package/src/mcp/tools/code-owners.ts +1 -1
  488. package/src/mcp/tools/communities.ts +1 -1
  489. package/src/mcp/tools/complexity.ts +1 -1
  490. package/src/mcp/tools/context.ts +1 -1
  491. package/src/mcp/tools/dataflow.ts +1 -1
  492. package/src/mcp/tools/diff-impact.ts +1 -1
  493. package/src/mcp/tools/execution-flow.ts +1 -1
  494. package/src/mcp/tools/export-graph.ts +1 -1
  495. package/src/mcp/tools/file-deps.ts +1 -1
  496. package/src/mcp/tools/file-exports.ts +1 -1
  497. package/src/mcp/tools/find-cycles.ts +1 -1
  498. package/src/mcp/tools/fn-impact.ts +1 -1
  499. package/src/mcp/tools/impact-analysis.ts +1 -1
  500. package/src/mcp/tools/implementations.ts +1 -1
  501. package/src/mcp/tools/index.ts +2 -5
  502. package/src/mcp/tools/interfaces.ts +1 -1
  503. package/src/mcp/tools/list-functions.ts +1 -1
  504. package/src/mcp/tools/list-repos.ts +1 -1
  505. package/src/mcp/tools/module-map.ts +1 -1
  506. package/src/mcp/tools/node-roles.ts +1 -1
  507. package/src/mcp/tools/path.ts +1 -1
  508. package/src/mcp/tools/query.ts +1 -1
  509. package/src/mcp/tools/semantic-search.ts +1 -1
  510. package/src/mcp/tools/sequence.ts +1 -1
  511. package/src/mcp/tools/structure.ts +1 -1
  512. package/src/mcp/tools/symbol-children.ts +1 -1
  513. package/src/mcp/tools/triage.ts +1 -1
  514. package/src/mcp/tools/where.ts +1 -1
  515. package/src/mcp/types.ts +21 -0
  516. package/src/presentation/queries-cli/index.ts +1 -1
  517. package/src/presentation/queries-cli/overview.ts +35 -1
  518. package/src/presentation/queries-cli.ts +1 -0
  519. package/src/presentation/structure.ts +3 -3
  520. package/src/presentation/viewer.ts +98 -87
  521. package/src/shared/constants.ts +26 -0
  522. package/src/shared/normalize.ts +13 -22
  523. package/src/shared/paginate.ts +4 -18
  524. package/src/types.ts +127 -1
@@ -11,6 +11,7 @@
11
11
  * The orchestrator-selection strategy lives here so `pipeline.ts` stays a thin
12
12
  * top-level controller: detect changes, try native, fall back to JS stages.
13
13
  */
14
+ import { execFileSync } from 'node:child_process';
14
15
  import path from 'node:path';
15
16
  import { performance } from 'node:perf_hooks';
16
17
  import {
@@ -24,12 +25,13 @@ import {
24
25
  import { debug, info, warn } from '../../../../infrastructure/logger.js';
25
26
  import { loadNative } from '../../../../infrastructure/native.js';
26
27
  import { semverCompare } from '../../../../infrastructure/update-check.js';
27
- import { normalizePath } from '../../../../shared/constants.js';
28
+ import { normalizePath, TS_NATIVE_CONFIDENCE_FLOOR } from '../../../../shared/constants.js';
28
29
  import { toErrorMessage } from '../../../../shared/errors.js';
29
30
  import { CODEGRAPH_VERSION } from '../../../../shared/version.js';
30
31
  import type {
31
32
  BetterSqlite3Database,
32
33
  BuildResult,
34
+ DataflowResult,
33
35
  Definition,
34
36
  ExtractorOutput,
35
37
  SqliteStatement,
@@ -40,6 +42,7 @@ import {
40
42
  getInstalledWasmExtensions,
41
43
  NATIVE_SUPPORTED_EXTENSIONS,
42
44
  parseFilesWasmForBackfill,
45
+ patchDataflowResult,
43
46
  } from '../../../parser.js';
44
47
  import { computeConfidence } from '../../resolve.js';
45
48
  import type { CallNodeLookup } from '../call-resolver.js';
@@ -49,13 +52,14 @@ import type { PipelineContext } from '../context.js';
49
52
  import {
50
53
  batchInsertEdges,
51
54
  batchInsertNodes,
55
+ CHA_DISPATCH_PENALTY,
56
+ CHA_TYPED_DISPATCH_CONFIDENCE,
52
57
  collectFiles as collectFilesUtil,
53
58
  fileHash,
54
59
  fileStat,
55
60
  readFileSafe,
56
61
  } from '../helpers.js';
57
62
  import { NativeDbProxy } from '../native-db-proxy.js';
58
- import { CHA_DISPATCH_PENALTY } from './build-edges.js';
59
63
  import { closeNativeDb } from './native-db-lifecycle.js';
60
64
 
61
65
  // ── Native orchestrator types ──────────────────────────────────────────
@@ -293,6 +297,257 @@ async function runPostNativeStructure(
293
297
  return performance.now() - structureStart;
294
298
  }
295
299
 
300
+ /**
301
+ * P6: Build dataflow_vertices and inter-procedural edges after the Rust
302
+ * orchestrator completes.
303
+ *
304
+ * The Rust pipeline writes flows_to/returns/mutates edges directly to the DB
305
+ * but never writes to dataflow_vertices or dataflow_summary. This pass re-runs
306
+ * the Rust dataflow visitor (via extractDataflowAnalysis — fast, no re-parse)
307
+ * to get the DataflowResult and calls buildDataflowVerticesFromMap.
308
+ *
309
+ * Languages for which Rust has no dataflow rules return null from
310
+ * extractDataflowAnalysis and are silently skipped here. A follow-up issue
311
+ * (#1614 adjacent) will add WASM fallback for those languages.
312
+ */
313
+ async function runDataflowVertexPass(
314
+ ctx: PipelineContext,
315
+ changedFiles: string[] | undefined,
316
+ ): Promise<void> {
317
+ if (ctx.opts.dataflow === false) return;
318
+
319
+ const native = loadNative();
320
+ if (!native?.extractDataflowAnalysis) return;
321
+
322
+ // Determine which files to process: changed files for incremental, all for full builds.
323
+ let filesToProcess: string[];
324
+ if (changedFiles && changedFiles.length > 0) {
325
+ filesToProcess = changedFiles;
326
+ } else {
327
+ // Full build: scope to files that need vertex extraction rather than scanning every
328
+ // file in the project. Two categories:
329
+ // (a) Non-native language files — NATIVE_SUPPORTED_EXTENSIONS doesn't cover them,
330
+ // so extractDataflowAnalysis returns null; the wasmStubs path calls buildDataflowEdges
331
+ // which writes both edges AND vertices for those files.
332
+ // (b) Native-language files with dataflow edges already written by the Rust orchestrator
333
+ // (flows_to/returns/mutates) — those need vertex rows to connect them.
334
+ //
335
+ // Skipping native-language files with no dataflow edges is safe: extractDataflowAnalysis
336
+ // would return argFlows=[], assignments=[], mutations=[] for them, producing zero vertices
337
+ // and zero inter-procedural edges. Excluding them avoids O(n_total_files) re-analysis on
338
+ // every full build (codegraph itself: ~2000 files, ~50-80% with no dataflow edges).
339
+ const filesWithDataflow = new Set(
340
+ (
341
+ ctx.db
342
+ .prepare(
343
+ `SELECT DISTINCT n.file
344
+ FROM dataflow d
345
+ JOIN nodes n ON n.id = d.source_id
346
+ WHERE n.file IS NOT NULL`,
347
+ )
348
+ .all() as { file: string }[]
349
+ ).map((r) => r.file),
350
+ );
351
+
352
+ filesToProcess = (
353
+ ctx.db
354
+ .prepare(`SELECT DISTINCT file FROM nodes WHERE file IS NOT NULL AND kind != 'directory'`)
355
+ .all() as { file: string }[]
356
+ )
357
+ .map((r) => r.file)
358
+ .filter((f) => {
359
+ const ext = path.extname(f).toLowerCase();
360
+ // Non-native files: always include (WASM handles them via wasmStubs path).
361
+ if (!NATIVE_SUPPORTED_EXTENSIONS.has(ext)) return true;
362
+ // Native files: only include when Rust wrote dataflow edges for them.
363
+ return filesWithDataflow.has(f);
364
+ });
365
+ }
366
+
367
+ // Split files into two buckets:
368
+ // nativeDataflow — Rust extracted data (vertex-only pass; edges already in DB)
369
+ // wasmStubs — Rust returned null (WASM will handle edges + vertices)
370
+ const nativeDataflow = new Map<string, DataflowResult>();
371
+ const wasmStubs = new Map<string, { definitions: []; _langId: null; _tree: null }>();
372
+
373
+ const absPaths = filesToProcess.map((relPath) => path.join(ctx.rootDir, relPath));
374
+
375
+ // Batch the per-file dataflow extraction into one NAPI call so the parses run
376
+ // across the rayon thread pool instead of serially on the event loop — this is
377
+ // the dominant cost of a native full build (#perf). Older addons predate the
378
+ // batch export, so fall back to the per-file path when it is unavailable.
379
+ let batchResults: (DataflowResult | null)[] | null = null;
380
+ if (typeof native.extractDataflowAnalysisBatch === 'function') {
381
+ try {
382
+ batchResults = native.extractDataflowAnalysisBatch(absPaths);
383
+ } catch {
384
+ batchResults = null; // fall through to per-file extraction below
385
+ }
386
+ }
387
+
388
+ for (let i = 0; i < filesToProcess.length; i++) {
389
+ const relPath = filesToProcess[i]!;
390
+ let result: DataflowResult | null = null;
391
+ if (batchResults) {
392
+ result = batchResults[i] ?? null;
393
+ } else {
394
+ let source: string;
395
+ try {
396
+ source = readFileSafe(absPaths[i]!);
397
+ } catch {
398
+ // Unreadable file — mirror batch-path behaviour and route to WASM.
399
+ wasmStubs.set(relPath, { definitions: [], _langId: null, _tree: null });
400
+ continue;
401
+ }
402
+ if (!source) {
403
+ // Empty file — same treatment as batch returning null.
404
+ wasmStubs.set(relPath, { definitions: [], _langId: null, _tree: null });
405
+ continue;
406
+ }
407
+ try {
408
+ result = native.extractDataflowAnalysis(source, absPaths[i]!);
409
+ } catch {
410
+ // Language-specific parse failure — fall through to WASM.
411
+ }
412
+ }
413
+ if (result) {
414
+ // Normalise the native DataflowResult: Rust emits `bindingType: string | null`
415
+ // (flat) while the TS dataflow layer expects `binding: { type, index? }` (object).
416
+ // patchNativeResult handles this via patchDataflow for the full parse path;
417
+ // extractDataflowAnalysis(Batch) is a vertex-only fast path that bypasses
418
+ // patchNativeResult, so we apply the same normalisation here.
419
+ patchDataflowResult(result);
420
+ nativeDataflow.set(relPath, result);
421
+ } else {
422
+ // Rust has no dataflow rules for this language; WASM fallback will handle
423
+ // both edge insertion and vertex extraction. Since Rust inserted 0 dataflow
424
+ // edges for these files, there is no risk of duplicates.
425
+ wasmStubs.set(relPath, { definitions: [], _langId: null, _tree: null });
426
+ }
427
+ }
428
+
429
+ const { buildExtToLangMap } = (await import('../../../../ast-analysis/shared.js')) as {
430
+ buildExtToLangMap: () => Map<string, string>;
431
+ };
432
+
433
+ const {
434
+ buildDataflowVerticesFromMap,
435
+ buildDataflowEdges,
436
+ collectCallerStitchCandidates,
437
+ collectFuncIdsForFiles,
438
+ } = (await import('../../../../features/dataflow.js')) as {
439
+ buildDataflowVerticesFromMap: (
440
+ db: BetterSqlite3Database,
441
+ dataflowMap: Map<string, DataflowResult>,
442
+ extraCandidates?: Array<{
443
+ callerFuncId: number;
444
+ calleeFuncId: number;
445
+ argIndex: number;
446
+ bindingType: string;
447
+ bindingIndex: number;
448
+ argName: string;
449
+ expression: string | null;
450
+ line: number;
451
+ confidence: number;
452
+ }>,
453
+ extraCaptures?: Array<{ callerFuncId: number; calleeFuncId: number; varName: string }>,
454
+ ) => number;
455
+ buildDataflowEdges: (
456
+ db: BetterSqlite3Database,
457
+ fileSymbols: Map<string, unknown>,
458
+ rootDir: string,
459
+ engineOpts?: unknown,
460
+ ) => Promise<void>;
461
+ collectCallerStitchCandidates: (
462
+ db: BetterSqlite3Database,
463
+ changedFuncIds: number[],
464
+ changedRelPaths: Set<string>,
465
+ rootDir: string,
466
+ extToLang: Map<string, string>,
467
+ parsers: unknown,
468
+ getParserFn: unknown,
469
+ ) => Promise<{
470
+ candidates: Array<{
471
+ callerFuncId: number;
472
+ calleeFuncId: number;
473
+ argIndex: number;
474
+ bindingType: string;
475
+ bindingIndex: number;
476
+ argName: string;
477
+ expression: string | null;
478
+ line: number;
479
+ confidence: number;
480
+ }>;
481
+ captures: Array<{ callerFuncId: number; calleeFuncId: number; varName: string }>;
482
+ }>;
483
+ collectFuncIdsForFiles: (db: BetterSqlite3Database, relPaths: Iterable<string>) => number[];
484
+ };
485
+
486
+ // Rust-supported languages: build vertices only (edges already written by Rust orchestrator).
487
+ if (nativeDataflow.size > 0) {
488
+ // P4: On incremental builds, unchanged caller files' arg_in edges were deleted when
489
+ // the changed files' param vertices were purged and recreated. Re-collect stitch
490
+ // candidates from those caller files so buildInterproceduralStitch can reconnect them.
491
+ // Skip on full builds (changedFiles absent/empty) — nativeDataflow covers all files.
492
+ let p4Candidates: Array<{
493
+ callerFuncId: number;
494
+ calleeFuncId: number;
495
+ argIndex: number;
496
+ bindingType: string;
497
+ bindingIndex: number;
498
+ argName: string;
499
+ expression: string | null;
500
+ line: number;
501
+ confidence: number;
502
+ }> = [];
503
+ let p4Captures: Array<{ callerFuncId: number; calleeFuncId: number; varName: string }> = [];
504
+
505
+ if (changedFiles && changedFiles.length > 0) {
506
+ const changedSet = new Set(changedFiles);
507
+ const totalFilesInDb = (
508
+ ctx.db.prepare(`SELECT COUNT(DISTINCT file) AS n FROM nodes`).get() as { n: number }
509
+ ).n;
510
+ // Only run P4 when this is a real incremental build (not all files changed).
511
+ if (nativeDataflow.size < totalFilesInDb) {
512
+ const changedFuncIds = collectFuncIdsForFiles(ctx.db, changedSet);
513
+ if (changedFuncIds.length > 0) {
514
+ const extra = await collectCallerStitchCandidates(
515
+ ctx.db,
516
+ changedFuncIds,
517
+ changedSet,
518
+ ctx.rootDir,
519
+ buildExtToLangMap(),
520
+ null, // parsers — lazily loaded inside collectCallerStitchCandidates
521
+ null, // getParserFn — lazily loaded inside collectCallerStitchCandidates
522
+ );
523
+ p4Candidates = extra.candidates as typeof p4Candidates;
524
+ p4Captures = extra.captures;
525
+ }
526
+ }
527
+ }
528
+
529
+ const interCount = buildDataflowVerticesFromMap(
530
+ ctx.db,
531
+ nativeDataflow,
532
+ p4Candidates.length > 0 ? p4Candidates : undefined,
533
+ p4Captures.length > 0 ? p4Captures : undefined,
534
+ );
535
+ if (interCount > 0) {
536
+ info(
537
+ `Dataflow (native orchestrator): ${interCount} inter-procedural edges inserted${p4Candidates.length > 0 ? ` (P4: ${p4Candidates.length} re-stitch candidate(s) from unchanged callers)` : ''}`,
538
+ );
539
+ }
540
+ }
541
+
542
+ // Rust-unsupported languages: run the full WASM extraction (edges + vertices).
543
+ // wasmStubs entries have no `.dataflow` property, so the native bulk-insert
544
+ // fast path in buildDataflowEdges is always skipped for them — WASM runs
545
+ // both edge insertion and vertex extraction end-to-end.
546
+ if (wasmStubs.size > 0) {
547
+ await buildDataflowEdges(ctx.db, wasmStubs, ctx.rootDir);
548
+ }
549
+ }
550
+
296
551
  /**
297
552
  * JS fallback for AST/complexity/CFG/dataflow analysis after native orchestrator.
298
553
  * Used when the Rust addon doesn't include analysis persistence (older addon
@@ -388,37 +643,10 @@ async function runPostNativeAnalysis(
388
643
  return timing;
389
644
  }
390
645
 
391
- /**
392
- * Phase 8.5: CHA expansion post-pass for the native orchestrator path.
393
- *
394
- * The Rust build pipeline resolves typed receiver calls (e.g. `worker.doWork()`
395
- * where `worker: IWorker`) to the interface method declaration only. This
396
- * post-pass reads the class hierarchy (via `implements`/`extends` edges) and
397
- * instantiated types (via `calls` edges to class nodes) from the DB and expands
398
- * each call to an interface/abstract method to ALL RTA-filtered concrete
399
- * implementations.
400
- *
401
- * Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
402
- * which WASM-re-parses JS/TS files to obtain raw call site receiver info.
403
- *
404
- * Returns the count of newly inserted CHA edges plus the set of files containing
405
- * the new edges' endpoints, so the caller can scope role re-classification to the
406
- * nodes whose fan-in/out actually changed. A zero count means no edges were added
407
- * and role re-classification is unnecessary.
408
- */
409
- function runPostNativeCha(db: BetterSqlite3Database): {
410
- newEdgeCount: number;
411
- affectedFiles: Set<string>;
412
- } {
413
- const affectedFiles = new Set<string>();
414
- const empty = { newEdgeCount: 0, affectedFiles };
415
- // Fast guard: no hierarchy edges → no CHA work
416
- const hasHierarchy = db
417
- .prepare(`SELECT 1 FROM edges WHERE kind IN ('extends', 'implements') LIMIT 1`)
418
- .get();
419
- if (!hasHierarchy) return empty;
646
+ // ── CHA post-pass helpers ────────────────────────────────────────────────────
420
647
 
421
- // Build implementors map: parent/interface name → [child/implementing class names]
648
+ /** Build implementors map: parent/interface name → [child/implementing class names]. */
649
+ function buildChaImplementorsMap(db: BetterSqlite3Database): Map<string, string[]> {
422
650
  const hierarchyRows = db
423
651
  .prepare(`
424
652
  SELECT src.name AS child_name, tgt.name AS parent_name
@@ -438,12 +666,22 @@ function runPostNativeCha(db: BetterSqlite3Database): {
438
666
  }
439
667
  if (!list.includes(row.child_name)) list.push(row.child_name);
440
668
  }
441
- if (implementors.size === 0) return empty;
669
+ return implementors;
670
+ }
442
671
 
443
- // RTA: collect class names that are actually instantiated via `new X()`.
444
- // Primary query targets `class`-kind nodes (the canonical schema).
445
- // Fallback also matches `constructor`/`function`-kind nodes because some native
446
- // engine versions record constructor calls against those kinds instead of `class`.
672
+ /**
673
+ * Build RTA set: class names actually instantiated via `new X()`.
674
+ * Primary query targets `class`-kind nodes (the canonical schema).
675
+ * Fallback also matches `constructor`/`function`-kind nodes because some native
676
+ * engine versions record constructor calls against those kinds instead of `class`.
677
+ * Returns `{ instantiated, noRtaEvidence }` where `noRtaEvidence` means no
678
+ * constructor-call evidence exists — skip RTA filtering so interface dispatch
679
+ * still produces edges.
680
+ */
681
+ function buildChaRtaSet(db: BetterSqlite3Database): {
682
+ instantiated: Set<string>;
683
+ noRtaEvidence: boolean;
684
+ } {
447
685
  let rtaRows = db
448
686
  .prepare(`
449
687
  SELECT DISTINCT tgt.name
@@ -465,28 +703,148 @@ function runPostNativeCha(db: BetterSqlite3Database): {
465
703
  .all() as Array<{ name: string }>;
466
704
  }
467
705
  const instantiated = new Set(rtaRows.map((r) => r.name));
468
- // noRtaEvidence: true when no constructor-call evidence exists in the DB (e.g. graph
469
- // built by an older native engine that doesn't emit constructor call edges at all).
470
- // In that case we skip RTA filtering so interface dispatch still produces edges —
471
- // all instantiated implementors are admitted rather than silently dropping everything.
472
706
  const noRtaEvidence = instantiated.size === 0;
473
707
  if (noRtaEvidence) {
474
708
  debug('runPostNativeCha: no constructor-call evidence found — proceeding without RTA filter');
475
709
  }
710
+ return { instantiated, noRtaEvidence };
711
+ }
712
+
713
+ /**
714
+ * Determine CHA candidate scope for incremental builds.
715
+ *
716
+ * Gate A: did a changed file add/change a class hierarchy node?
717
+ * A new `extends`/`implements` edge means a previously-untracked implementor
718
+ * is now in the hierarchy — unchanged call sites in OTHER files may gain new
719
+ * valid expansions, so the full scan is required.
720
+ * Note: *removed* class nodes are safe — Rust's `purge_changed_files` runs
721
+ * before this post-pass and deletes stale nodes and their hierarchy edges, so
722
+ * Gate A queries the post-purge DB. A deleted class returns no row here, which
723
+ * is correct: its stale CHA edges were already cleaned up by the Rust purge.
724
+ *
725
+ * Gate B: did a changed file add new RTA evidence (`new ConcreteX()`)?
726
+ * A new `calls` edge to a class/constructor/function-kind target means the
727
+ * instantiated set grew — previously RTA-filtered expansions in unchanged
728
+ * caller files become admissible, so the full scan is required.
729
+ * (`constructor`/`function` cover the older native engine fallback schema.)
730
+ *
731
+ * Returns `true` when the scan should be scoped to changed-file sources only.
732
+ * Returns `false` (full scan) when changedFiles is null, empty, or either gate fires.
733
+ */
734
+ function computeChaScope(db: BetterSqlite3Database, changedFiles: string[] | null): boolean {
735
+ if (changedFiles === null || changedFiles.length === 0) return false;
736
+
737
+ const CHUNK_SIZE = 500;
738
+ let gateAFired = false;
739
+ for (let i = 0; i < changedFiles.length && !gateAFired; i += CHUNK_SIZE) {
740
+ const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
741
+ const ph = chunk.map(() => '?').join(',');
742
+ const row = db
743
+ .prepare(
744
+ `SELECT 1 FROM nodes
745
+ WHERE file IN (${ph})
746
+ AND kind IN ('class', 'interface', 'trait', 'struct', 'record')
747
+ LIMIT 1`,
748
+ )
749
+ .get(...chunk);
750
+ if (row) gateAFired = true;
751
+ }
752
+
753
+ let gateBFired = false;
754
+ if (!gateAFired) {
755
+ for (let i = 0; i < changedFiles.length && !gateBFired; i += CHUNK_SIZE) {
756
+ const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
757
+ const ph = chunk.map(() => '?').join(',');
758
+ const row = db
759
+ .prepare(
760
+ `SELECT 1 FROM edges e
761
+ JOIN nodes src ON e.source_id = src.id
762
+ JOIN nodes tgt ON e.target_id = tgt.id
763
+ WHERE e.kind = 'calls'
764
+ AND tgt.kind IN ('class', 'interface', 'trait', 'struct', 'record', 'constructor', 'function')
765
+ AND src.file IN (${ph})
766
+ LIMIT 1`,
767
+ )
768
+ .get(...chunk);
769
+ if (row) gateBFired = true;
770
+ }
771
+ }
772
+
773
+ if (!gateAFired && !gateBFired) {
774
+ debug(
775
+ `runPostNativeCha: neither gate fired — scoping candidate scan to ${changedFiles.length} changed file(s)`,
776
+ );
777
+ return true;
778
+ }
779
+ debug(
780
+ `runPostNativeCha: ${gateAFired ? 'Gate A (hierarchy)' : 'Gate B (RTA)'} fired — running full scan`,
781
+ );
782
+ return false;
783
+ }
476
784
 
477
- // Find existing call edges targeting qualified methods (e.g., 'IWorker.doWork').
478
- // Include the caller node's file so confidence can be computed file-pair-aware,
479
- // matching the WASM path's computeConfidence(callerFile, targetFile, null) - CHA_DISPATCH_PENALTY formula.
480
- const callToMethods = db
785
+ type ChaCallRow = {
786
+ source_id: number;
787
+ caller_name: string;
788
+ method_name: string;
789
+ caller_file: string | null;
790
+ };
791
+
792
+ /**
793
+ * Fetch call→method rows that are candidates for CHA expansion.
794
+ * When `scopeToChangedFiles` is true, restricts to source nodes in `changedFiles`.
795
+ */
796
+ function fetchChaCallToMethods(
797
+ db: BetterSqlite3Database,
798
+ changedFiles: string[] | null,
799
+ scopeToChangedFiles: boolean,
800
+ ): ChaCallRow[] {
801
+ if (scopeToChangedFiles && changedFiles && changedFiles.length > 0) {
802
+ const CHUNK_SIZE = 500;
803
+ const rows: ChaCallRow[] = [];
804
+ for (let i = 0; i < changedFiles.length; i += CHUNK_SIZE) {
805
+ const chunk = changedFiles.slice(i, i + CHUNK_SIZE);
806
+ const ph = chunk.map(() => '?').join(',');
807
+ const chunkRows = db
808
+ .prepare(
809
+ `SELECT e.source_id, src.name AS caller_name, tgt.name AS method_name, src.file AS caller_file
810
+ FROM edges e
811
+ JOIN nodes tgt ON e.target_id = tgt.id
812
+ JOIN nodes src ON e.source_id = src.id
813
+ WHERE e.kind = 'calls' AND tgt.kind = 'method'
814
+ AND INSTR(tgt.name, '.') > 0
815
+ AND (e.technique IS NULL OR e.technique != 'cha-expanded')
816
+ AND src.file IN (${ph})`,
817
+ )
818
+ .all(...chunk) as ChaCallRow[];
819
+ rows.push(...chunkRows);
820
+ }
821
+ return rows;
822
+ }
823
+ return db
481
824
  .prepare(`
482
- SELECT e.source_id, tgt.name AS method_name, src.file AS caller_file
825
+ SELECT e.source_id, src.name AS caller_name, tgt.name AS method_name, src.file AS caller_file
483
826
  FROM edges e
484
827
  JOIN nodes tgt ON e.target_id = tgt.id
485
828
  JOIN nodes src ON e.source_id = src.id
486
829
  WHERE e.kind = 'calls' AND tgt.kind = 'method'
487
830
  AND INSTR(tgt.name, '.') > 0
831
+ AND (e.technique IS NULL OR e.technique != 'cha-expanded')
488
832
  `)
489
- .all() as Array<{ source_id: number; method_name: string; caller_file: string | null }>;
833
+ .all() as ChaCallRow[];
834
+ }
835
+
836
+ /**
837
+ * BFS-expand CHA call edges and insert new edges into the DB.
838
+ * Returns `{ newEdgeCount, affectedFiles }` for role re-classification scoping.
839
+ */
840
+ function expandChaEdges(
841
+ db: BetterSqlite3Database,
842
+ callToMethods: ChaCallRow[],
843
+ implementors: Map<string, string[]>,
844
+ instantiated: Set<string>,
845
+ noRtaEvidence: boolean,
846
+ ): { newEdgeCount: number; affectedFiles: Set<string> } {
847
+ const affectedFiles = new Set<string>();
490
848
 
491
849
  // Seed seen-pairs only from the source_ids we'll be expanding — avoids loading every
492
850
  // call edge in the DB (which would be O(all edges)) for large codebases.
@@ -540,16 +898,12 @@ function runPostNativeCha(db: BetterSqlite3Database): {
540
898
  method_file: string | null;
541
899
  }>;
542
900
  for (const methodNode of methodNodes) {
901
+ if (methodNode.id === source_id) continue; // skip self-loops
543
902
  const key = `${source_id}|${methodNode.id}`;
544
903
  if (seen.has(key)) continue;
545
904
  seen.add(key);
546
- // Compute confidence file-pair-aware (mirrors WASM path: computeConfidence - CHA_DISPATCH_PENALTY)
547
- // Skip zero-confidence edges to match buildFileCallEdges / buildChaPostPass behaviour.
548
- const conf =
549
- computeConfidence(caller_file ?? '', methodNode.method_file ?? '', null) -
550
- CHA_DISPATCH_PENALTY;
551
- if (conf <= 0) continue;
552
- newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']);
905
+ const conf = CHA_TYPED_DISPATCH_CONFIDENCE;
906
+ newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha-expanded']);
553
907
  newEdgeCount++;
554
908
  if (caller_file) affectedFiles.add(caller_file);
555
909
  if (methodNode.method_file) affectedFiles.add(methodNode.method_file);
@@ -564,41 +918,85 @@ function runPostNativeCha(db: BetterSqlite3Database): {
564
918
 
565
919
  if (newEdges.length > 0) {
566
920
  db.transaction(() => batchInsertEdges(db, newEdges))();
921
+ // Account for post-pass edges excluded from the build summary line (#1452),
922
+ // mirroring the this/super dispatch post-pass insertion log.
923
+ debug(`CHA expansion post-pass: inserted ${newEdgeCount} edge(s)`);
567
924
  }
568
925
  return { newEdgeCount, affectedFiles };
569
926
  }
570
927
 
571
- // Extensions where `this`/`super` dispatch can occur (JS/TS family)
572
- const THIS_DISPATCH_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
573
-
574
928
  /**
575
- * Phase 8.5: this/super dispatch post-pass for the native orchestrator path.
929
+ * Phase 8.6: CHA expansion post-pass for the native orchestrator path.
576
930
  *
577
- * The Rust build pipeline resolves typed receiver calls but does NOT persist raw
578
- * unresolved call site receiver info (e.g. `this`, `super`) to the DB. This
579
- * hybrid post-pass re-parses JS/TS/TSX files via WASM to collect call sites with
580
- * `this`/`super` receivers, then resolves them through the class hierarchy stored
581
- * in DB `extends` edges mirroring what `buildChaPostPass` does on the WASM path.
931
+ * The Rust build pipeline resolves typed receiver calls (e.g. `worker.doWork()`
932
+ * where `worker: IWorker`) to the interface method declaration only. This
933
+ * post-pass reads the class hierarchy (via `implements`/`extends` edges) and
934
+ * instantiated types (via `calls` edges to class nodes) from the DB and expands
935
+ * each call to an interface/abstract method to ALL RTA-filtered concrete
936
+ * implementations.
582
937
  *
583
- * Only runs when `extends` edges exist in the DB; if there is no inheritance
584
- * hierarchy there is nothing to resolve via `this`/`super` dispatch.
938
+ * Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
939
+ * which WASM-re-parses JS/TS files to obtain raw call site receiver info.
940
+ *
941
+ * `changedFiles` controls candidate scoping on incremental builds:
942
+ * - null → full build; scan all call→method edges (existing behaviour).
943
+ * - array → incremental; two cheap gate queries decide scope:
944
+ * Gate A: any class/interface/trait/struct/record nodes in changed files?
945
+ * If yes, a new implementor may have appeared — full scan required.
946
+ * Gate B: any `calls` edges from changed-file sources targeting
947
+ * class/constructor/function-kind nodes? If yes, the RTA set may
948
+ * have grown (also covers the older-schema fallback where
949
+ * constructor calls target `constructor`/`function` nodes instead
950
+ * of `class` nodes) — full scan required.
951
+ * If neither gate fires: scope `callToMethods` to `src.file IN changedFiles`
952
+ * (safe because no hierarchy or RTA evidence changed).
953
+ *
954
+ * Returns the count of newly inserted CHA edges plus the set of files containing
955
+ * the new edges' endpoints, so the caller can scope role re-classification to the
956
+ * nodes whose fan-in/out actually changed. A zero count means no edges were added
957
+ * and role re-classification is unnecessary.
585
958
  */
586
- async function runPostNativeThisDispatch(
959
+ function runPostNativeCha(
587
960
  db: BetterSqlite3Database,
588
- rootDir: string,
589
- changedFiles: string[] | undefined,
590
- isFullBuild: boolean,
591
- ): Promise<{ elapsedMs: number; targetIds: Set<number>; affectedFiles: Set<string> }> {
592
- const t0 = Date.now();
593
- const targetIds = new Set<number>();
594
- // Files containing endpoints of newly inserted edges — lets the caller scope
595
- // role re-classification to the nodes whose fan-in/out actually changed.
961
+ changedFiles: string[] | null,
962
+ ): {
963
+ newEdgeCount: number;
964
+ affectedFiles: Set<string>;
965
+ } {
596
966
  const affectedFiles = new Set<string>();
597
- // Fast guard: need at least one extends edge for this/super to have meaning
598
- const hasExtends = db.prepare(`SELECT 1 FROM edges WHERE kind = 'extends' LIMIT 1`).get();
599
- if (!hasExtends) return { elapsedMs: 0, targetIds, affectedFiles };
967
+ const empty = { newEdgeCount: 0, affectedFiles };
968
+ // Fast guard: no hierarchy edges no CHA work
969
+ const hasHierarchy = db
970
+ .prepare(`SELECT 1 FROM edges WHERE kind IN ('extends', 'implements') LIMIT 1`)
971
+ .get();
972
+ if (!hasHierarchy) return empty;
973
+
974
+ const implementors = buildChaImplementorsMap(db);
975
+ if (implementors.size === 0) return empty;
976
+
977
+ const { instantiated, noRtaEvidence } = buildChaRtaSet(db);
978
+ const scopeToChangedFiles = computeChaScope(db, changedFiles);
979
+ const callToMethods = fetchChaCallToMethods(db, changedFiles, scopeToChangedFiles);
980
+
981
+ return expandChaEdges(db, callToMethods, implementors, instantiated, noRtaEvidence);
982
+ }
983
+
984
+ // Extensions where `this`/`super` dispatch can occur (JS/TS family)
985
+ const THIS_DISPATCH_EXTS = new Set(['.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
600
986
 
601
- // Build parents map: child class → direct parent class (from `extends` edges)
987
+ // ── this/super dispatch post-pass helpers ───────────────────────────────────
988
+
989
+ /**
990
+ * Build parents map: child class → direct parent class (from `extends` edges).
991
+ * May be empty when only func-prop methods exist (no class inheritance) —
992
+ * resolveThisDispatch handles that case via direct class-prefix lookup.
993
+ */
994
+ function buildThisDispatchParentsMap(
995
+ db: BetterSqlite3Database,
996
+ hasExtends: unknown,
997
+ ): Map<string, string> {
998
+ const parents = new Map<string, string>();
999
+ if (!hasExtends) return parents;
602
1000
  const parentRows = db
603
1001
  .prepare(`
604
1002
  SELECT src.name AS child_name, tgt.name AS parent_name
@@ -608,32 +1006,26 @@ async function runPostNativeThisDispatch(
608
1006
  WHERE e.kind = 'extends'
609
1007
  `)
610
1008
  .all() as Array<{ child_name: string; parent_name: string }>;
611
-
612
- const parents = new Map<string, string>();
613
1009
  for (const row of parentRows) {
614
1010
  if (!parents.has(row.child_name)) parents.set(row.child_name, row.parent_name);
615
1011
  }
616
- if (parents.size === 0) return { elapsedMs: 0, targetIds, affectedFiles };
617
-
618
- const chaCtx: ChaContext = {
619
- implementors: new Map(), // not needed for this/super resolution
620
- parents,
621
- instantiatedTypes: new Set(), // not needed for this/super resolution
622
- };
1012
+ return parents;
1013
+ }
623
1014
 
624
- // Determine which files to re-parse.
625
- //
626
- // On a full build we do NOT re-parse every JS/TS file — that would WASM-parse
627
- // the entire project on top of the native pass, causing a massive regression
628
- // (measured: +358% ms/file on codegraph itself). Instead we restrict to files
629
- // that are part of the class inheritance hierarchy: both subclass files (which
630
- // contain `super.X()` calls dispatching to a parent) and parent-class files
631
- // (whose method bodies contain `this.X()` calls that CHA must resolve). Any
632
- // file not in the hierarchy has no `extends` relationship, so `this`/`super`
633
- // calls in it either resolve locally (same-class dispatch, already handled by
634
- // the direct-call edge) or have no class context — and will be skipped by
635
- // `resolveThisDispatch` anyway.
636
- let relFiles: string[];
1015
+ /**
1016
+ * Determine the set of relative file paths to re-parse for this/super dispatch.
1017
+ *
1018
+ * On a full build we do NOT re-parse every JS/TS file — that would WASM-parse
1019
+ * the entire project on top of the native pass, causing a massive regression
1020
+ * (measured: +358% ms/file on codegraph itself). Instead we restrict to files
1021
+ * that are part of the class inheritance hierarchy OR that contain dot-named
1022
+ * method nodes (func-prop assignments whose bodies may call `this.sibling()`).
1023
+ */
1024
+ function selectThisDispatchFiles(
1025
+ db: BetterSqlite3Database,
1026
+ changedFiles: string[] | undefined,
1027
+ isFullBuild: boolean,
1028
+ ): string[] {
637
1029
  if (isFullBuild || !changedFiles) {
638
1030
  const rows = db
639
1031
  .prepare(`
@@ -647,79 +1039,60 @@ async function runPostNativeThisDispatch(
647
1039
  FROM edges e
648
1040
  JOIN nodes tgt ON e.target_id = tgt.id
649
1041
  WHERE e.kind = 'extends' AND tgt.file IS NOT NULL
1042
+ UNION
1043
+ -- Files with func-prop method definitions (e.g. f.h = function(){this.g()}).
1044
+ -- Only include files where the method's owner prefix is NOT a known class name —
1045
+ -- this keeps the re-parse set small (func-prop files only, not all class-method files).
1046
+ -- AND name IS NOT NULL guards the NOT IN sub-select: if any class node had a NULL
1047
+ -- name the entire NOT IN clause would silently return no rows (SQL NULL semantics).
1048
+ SELECT n.file AS file
1049
+ FROM nodes n
1050
+ WHERE n.kind = 'method'
1051
+ AND INSTR(n.name, '.') > 0
1052
+ AND n.file IS NOT NULL
1053
+ AND SUBSTR(n.name, 1, INSTR(n.name, '.') - 1) NOT IN (
1054
+ SELECT name FROM nodes WHERE kind IN ('class', 'struct', 'interface', 'type')
1055
+ AND name IS NOT NULL
1056
+ )
650
1057
  )
651
1058
  `)
652
1059
  .all() as Array<{ file: string }>;
653
- relFiles = rows
1060
+ return rows
654
1061
  .map((r) => r.file)
655
1062
  .filter((f) => THIS_DISPATCH_EXTS.has(path.extname(f).toLowerCase()));
656
- } else {
657
- // NOTE: Only files explicitly listed in changedFiles are re-parsed.
658
- // If a parent-class method is replaced (new node ID) but the child file is
659
- // unchanged, the stale super.method() edge is not refreshed here. A full
660
- // rebuild (isFullBuild=true) is required to recover in that scenario.
661
- relFiles = changedFiles.filter((f) => THIS_DISPATCH_EXTS.has(path.extname(f).toLowerCase()));
662
- }
663
- if (relFiles.length === 0) return { elapsedMs: 0, targetIds, affectedFiles };
664
-
665
- // DB-backed CallNodeLookup — resolveThisDispatch only calls byName()
666
- const findByNameStmt = db.prepare(`SELECT id, file, kind FROM nodes WHERE name = ?`);
667
- const lookup: CallNodeLookup = {
668
- byName: (name) => findByNameStmt.all(name) as Array<{ id: number; file: string; kind: string }>,
669
- byNameAndFile: (name, file) =>
670
- (findByNameStmt.all(name) as Array<{ id: number; file: string; kind: string }>).filter(
671
- (n) => n.file === file,
672
- ),
673
- isBarrel: () => false,
674
- resolveBarrel: () => null,
675
- nodeId: () => undefined,
676
- };
677
-
678
- // Seed seen-pairs from existing call edges on source nodes in our file set
679
- const seen = new Set<string>();
680
- const CHUNK = 500;
681
- for (let i = 0; i < relFiles.length; i += CHUNK) {
682
- const chunk = relFiles.slice(i, i + CHUNK);
683
- const ph = chunk.map(() => '?').join(',');
684
- const rows = db
685
- .prepare(
686
- `SELECT e.source_id, e.target_id
687
- FROM edges e
688
- JOIN nodes n ON e.source_id = n.id
689
- WHERE e.kind = 'calls' AND n.file IN (${ph})`,
690
- )
691
- .all(...chunk) as Array<{ source_id: number; target_id: number }>;
692
- for (const r of rows) seen.add(`${r.source_id}|${r.target_id}`);
693
1063
  }
1064
+ // NOTE: Only files explicitly listed in changedFiles are re-parsed.
1065
+ // If a parent-class method is replaced (new node ID) but the child file is
1066
+ // unchanged, the stale super.method() edge is not refreshed here. A full
1067
+ // rebuild (isFullBuild=true) is required to recover in that scenario.
1068
+ return changedFiles.filter((f) => THIS_DISPATCH_EXTS.has(path.extname(f).toLowerCase()));
1069
+ }
694
1070
 
695
- // Find the innermost containing method/function for a call at `line` in `file`.
696
- // COALESCE maps NULL end_line to a large sentinel so unbounded nodes sort last
697
- // (SQLite ASC orders NULLs first, so a raw `end_line - line` would pick them first).
698
- const findCallerByLineStmt = db.prepare(`
699
- SELECT id, name FROM nodes
700
- WHERE file = ? AND kind IN ('method', 'function')
701
- AND line <= ? AND (end_line IS NULL OR end_line >= ?)
702
- ORDER BY COALESCE(end_line - line, 999999999) ASC
703
- LIMIT 1
704
- `);
705
-
706
- // Re-parse the files to obtain raw call sites with receiver info. Only
707
- // `calls` (with receivers) are consumed here.
708
- //
709
- // The native engine is preferred: this pass only runs after a native
710
- // orchestrator build, so the addon is already loaded and re-parses the
711
- // hierarchy file set in single-digit milliseconds with the same
712
- // receiver-annotated call sites as the WASM extractor. Booting the WASM
713
- // runtime here instead cost ~40–110ms per full build (in-process
714
- // web-tree-sitter + grammar init dominated) — part of the v3.12.0
715
- // publish-gate regression. Files the native engine cannot parse (extension
716
- // outside NATIVE_SUPPORTED_EXTENSIONS, e.g. .mts/.cts) and native parse
717
- // failures fall back to the WASM backfill path so the sweep stays complete.
718
- const absFiles = relFiles.map((f) => path.join(rootDir, f));
1071
+ /**
1072
+ * Re-parse files via native (preferred) + WASM fallback to obtain call sites
1073
+ * with receiver info. Returns a map of relPath calls array.
1074
+ *
1075
+ * The native engine is preferred: this pass only runs after a native
1076
+ * orchestrator build, so the addon is already loaded and re-parses the
1077
+ * hierarchy file set in single-digit milliseconds with the same
1078
+ * receiver-annotated call sites as the WASM extractor. Booting the WASM
1079
+ * runtime here instead cost ~40–110ms per full build (in-process
1080
+ * web-tree-sitter + grammar init dominated) — part of the v3.12.0
1081
+ * publish-gate regression. Files the native engine cannot parse (extension
1082
+ * outside NATIVE_SUPPORTED_EXTENSIONS, e.g. .mts/.cts) and native parse
1083
+ * failures fall back to the WASM backfill path so the sweep stays complete.
1084
+ */
1085
+ async function parseFilesForThisDispatch(
1086
+ absFiles: string[],
1087
+ rootDir: string,
1088
+ ): Promise<{
1089
+ callsByRel: Map<string, { name: string; receiver?: string; line: number }[]>;
1090
+ wasmResults: Map<string, ExtractorOutput>;
1091
+ }> {
1092
+ const callsByRel = new Map<string, { name: string; receiver?: string; line: number }[]>();
719
1093
  const nativeAbs = absFiles.filter((f) =>
720
1094
  NATIVE_SUPPORTED_EXTENSIONS.has(path.extname(f).toLowerCase()),
721
1095
  );
722
- const callsByRel = new Map<string, { name: string; receiver?: string; line: number }[]>();
723
1096
  // Track native-supported files that returned null (per-file parse error) so
724
1097
  // they can be included in the WASM fallback set below, ensuring no file's
725
1098
  // this/super call sites are silently discarded.
@@ -765,13 +1138,40 @@ async function runPostNativeThisDispatch(
765
1138
  for (const [relPath, symbols] of wasmResults) {
766
1139
  callsByRel.set(relPath, symbols.calls ?? []);
767
1140
  }
1141
+ return { callsByRel, wasmResults };
1142
+ }
768
1143
 
769
- const newEdges: Array<[number, number, string, number, number, string]> = [];
770
-
771
- for (const [relPath, calls] of callsByRel) {
772
- for (const call of calls) {
773
- // Only 'this' and 'super' are class-instance receivers in JS/TS.
774
- // 'self' refers to WindowOrWorkerGlobalScope — not a class instance — so
1144
+ /** Emit this/super dispatch edges from re-parsed call sites. */
1145
+ function emitThisDispatchEdges(
1146
+ db: BetterSqlite3Database,
1147
+ callsByRel: Map<string, { name: string; receiver?: string; line: number }[]>,
1148
+ chaCtx: ChaContext,
1149
+ lookup: CallNodeLookup,
1150
+ seen: Set<string>,
1151
+ ): {
1152
+ newEdges: Array<[number, number, string, number, number, string]>;
1153
+ targetIds: Set<number>;
1154
+ affectedFiles: Set<string>;
1155
+ } {
1156
+ // Find the innermost containing method/function for a call at `line` in `file`.
1157
+ // COALESCE maps NULL end_line to a large sentinel so unbounded nodes sort last
1158
+ // (SQLite ASC orders NULLs first, so a raw `end_line - line` would pick them first).
1159
+ const findCallerByLineStmt = db.prepare(`
1160
+ SELECT id, name FROM nodes
1161
+ WHERE file = ? AND kind IN ('method', 'function')
1162
+ AND line <= ? AND (end_line IS NULL OR end_line >= ?)
1163
+ ORDER BY COALESCE(end_line - line, 999999999) ASC
1164
+ LIMIT 1
1165
+ `);
1166
+
1167
+ const newEdges: Array<[number, number, string, number, number, string]> = [];
1168
+ const targetIds = new Set<number>();
1169
+ const affectedFiles = new Set<string>();
1170
+
1171
+ for (const [relPath, calls] of callsByRel) {
1172
+ for (const call of calls) {
1173
+ // Only 'this' and 'super' are class-instance receivers in JS/TS.
1174
+ // 'self' refers to WindowOrWorkerGlobalScope — not a class instance — so
775
1175
  // filtering it here prevents spurious dispatch edges from Worker call sites.
776
1176
  if (call.receiver !== 'this' && call.receiver !== 'super') continue;
777
1177
 
@@ -786,28 +1186,31 @@ async function runPostNativeThisDispatch(
786
1186
  call.receiver as 'this' | 'super',
787
1187
  chaCtx,
788
1188
  lookup,
1189
+ relPath,
789
1190
  );
790
1191
 
791
1192
  for (const t of targets) {
1193
+ if (t.id === callerRow.id) continue; // skip self-loops
792
1194
  const key = `${callerRow.id}|${t.id}`;
793
1195
  if (seen.has(key)) continue;
794
1196
  seen.add(key);
795
1197
  const conf = computeConfidence(relPath, t.file, null) - CHA_DISPATCH_PENALTY;
796
1198
  if (conf <= 0) continue;
797
- newEdges.push([callerRow.id, t.id, 'calls', conf, 0, 'cha']);
1199
+ // Tag super-dispatch edges distinctly so runPostNativeCha can exclude them
1200
+ // from further CHA expansion (super calls are not virtual dispatch).
1201
+ const technique = call.receiver === 'super' ? 'super-dispatch' : 'cha';
1202
+ newEdges.push([callerRow.id, t.id, 'calls', conf, 0, technique]);
798
1203
  targetIds.add(t.id);
799
1204
  affectedFiles.add(relPath);
800
1205
  if (t.file) affectedFiles.add(t.file);
801
1206
  }
802
1207
  }
803
1208
  }
1209
+ return { newEdges, targetIds, affectedFiles };
1210
+ }
804
1211
 
805
- if (newEdges.length > 0) {
806
- db.transaction(() => batchInsertEdges(db, newEdges))();
807
- debug(`this/super dispatch post-pass: inserted ${newEdges.length} edge(s)`);
808
- }
809
-
810
- // Free WASM parse trees — mirrors the cleanup in backfillNativeDroppedFiles
1212
+ /** Free WASM parse trees after this-dispatch post-pass to prevent memory leaks. */
1213
+ function cleanupThisDispatchWasmTrees(wasmResults: Map<string, ExtractorOutput>): void {
811
1214
  for (const [, symbols] of wasmResults) {
812
1215
  const tree = (symbols as { _tree?: { delete?: () => void } })._tree;
813
1216
  if (tree && typeof tree.delete === 'function') {
@@ -820,8 +1223,119 @@ async function runPostNativeThisDispatch(
820
1223
  (symbols as { _tree?: unknown; _langId?: unknown })._tree = undefined;
821
1224
  (symbols as { _tree?: unknown; _langId?: unknown })._langId = undefined;
822
1225
  }
1226
+ }
1227
+
1228
+ /**
1229
+ * Phase 8.5: this/super dispatch post-pass for the native orchestrator path.
1230
+ *
1231
+ * The Rust build pipeline resolves typed receiver calls but does NOT persist raw
1232
+ * unresolved call site receiver info (e.g. `this`, `super`) to the DB. This
1233
+ * hybrid post-pass re-parses JS/TS/TSX files via WASM to collect call sites with
1234
+ * `this`/`super` receivers, then resolves them through the class hierarchy stored
1235
+ * in DB `extends` edges — mirroring what `buildChaPostPass` does on the WASM path.
1236
+ *
1237
+ * Also handles function-as-object-property methods (`f.h = function() { this.g() }`):
1238
+ * these use `this` to reference sibling properties on the same object (`f`), so
1239
+ * `resolveThisDispatch` resolves them by treating the dot-prefix of the caller name
1240
+ * (`f` from `f.h`) as the class and looking up `f.g` directly — no `extends` edge needed.
1241
+ *
1242
+ * Runs when either `extends` edges exist (class inheritance) OR dot-named `method`
1243
+ * nodes exist (func-prop assignments); skips only when neither is present.
1244
+ */
1245
+ async function runPostNativeThisDispatch(
1246
+ db: BetterSqlite3Database,
1247
+ rootDir: string,
1248
+ changedFiles: string[] | undefined,
1249
+ isFullBuild: boolean,
1250
+ ): Promise<{ elapsedMs: number; targetIds: Set<number>; affectedFiles: Set<string> }> {
1251
+ const t0 = performance.now();
1252
+
1253
+ // Fast guard: need at least one extends edge (class inheritance) OR a dot-named
1254
+ // method node (func-prop assignment: `f.h = function() { this.g() }`) for
1255
+ // this/super dispatch to produce any edges.
1256
+ const hasExtends = db.prepare(`SELECT 1 FROM edges WHERE kind = 'extends' LIMIT 1`).get();
1257
+ const hasFuncPropMethod = db
1258
+ .prepare(`SELECT 1 FROM nodes WHERE kind = 'method' AND INSTR(name, '.') > 0 LIMIT 1`)
1259
+ .get();
1260
+ const emptyResult = {
1261
+ elapsedMs: 0,
1262
+ targetIds: new Set<number>(),
1263
+ affectedFiles: new Set<string>(),
1264
+ };
1265
+ if (!hasExtends && !hasFuncPropMethod) return emptyResult;
1266
+
1267
+ const parents = buildThisDispatchParentsMap(db, hasExtends);
1268
+ // Note: parents may be empty when hasFuncPropMethod but !hasExtends — that is
1269
+ // intentional. resolveThisDispatch still resolves `this.g()` inside `f.h` by
1270
+ // treating `f` (the dot-prefix of callerName `f.h`) as the class and looking
1271
+ // up `f.g` directly via lookup.byName(), without traversing the parents chain.
823
1272
 
824
- return { elapsedMs: Date.now() - t0, targetIds, affectedFiles };
1273
+ const chaCtx: ChaContext = {
1274
+ implementors: new Map(), // not needed for this/super resolution
1275
+ parents,
1276
+ instantiatedTypes: new Set(), // not needed for this/super resolution
1277
+ };
1278
+
1279
+ const relFiles = selectThisDispatchFiles(db, changedFiles, isFullBuild);
1280
+ if (relFiles.length === 0) return emptyResult;
1281
+
1282
+ // DB-backed CallNodeLookup — resolveThisDispatch only calls byName()
1283
+ const findByNameStmt = db.prepare(`SELECT id, file, kind FROM nodes WHERE name = ?`);
1284
+ const lookup: CallNodeLookup = {
1285
+ byName: (name) => findByNameStmt.all(name) as Array<{ id: number; file: string; kind: string }>,
1286
+ byNameAndFile: (name, file) =>
1287
+ (findByNameStmt.all(name) as Array<{ id: number; file: string; kind: string }>).filter(
1288
+ (n) => n.file === file,
1289
+ ),
1290
+ isBarrel: () => false,
1291
+ resolveBarrel: () => null,
1292
+ nodeId: () => undefined,
1293
+ };
1294
+
1295
+ // Seed seen-pairs from existing call edges on source nodes in our file set
1296
+ const seen = new Set<string>();
1297
+ const CHUNK = 500;
1298
+ for (let i = 0; i < relFiles.length; i += CHUNK) {
1299
+ const chunk = relFiles.slice(i, i + CHUNK);
1300
+ const ph = chunk.map(() => '?').join(',');
1301
+ const rows = db
1302
+ .prepare(
1303
+ `SELECT e.source_id, e.target_id
1304
+ FROM edges e
1305
+ JOIN nodes n ON e.source_id = n.id
1306
+ WHERE e.kind = 'calls' AND n.file IN (${ph})`,
1307
+ )
1308
+ .all(...chunk) as Array<{ source_id: number; target_id: number }>;
1309
+ for (const r of rows) seen.add(`${r.source_id}|${r.target_id}`);
1310
+ }
1311
+
1312
+ const absFiles = relFiles.map((f) => path.join(rootDir, f));
1313
+ const { callsByRel, wasmResults } = await parseFilesForThisDispatch(absFiles, rootDir);
1314
+
1315
+ const { newEdges, targetIds, affectedFiles } = emitThisDispatchEdges(
1316
+ db,
1317
+ callsByRel,
1318
+ chaCtx,
1319
+ lookup,
1320
+ seen,
1321
+ );
1322
+
1323
+ if (newEdges.length > 0) {
1324
+ db.transaction(() => batchInsertEdges(db, newEdges))();
1325
+ debug(`this/super dispatch post-pass: inserted ${newEdges.length} edge(s)`);
1326
+ }
1327
+
1328
+ cleanupThisDispatchWasmTrees(wasmResults);
1329
+
1330
+ return { elapsedMs: performance.now() - t0, targetIds, affectedFiles };
1331
+ }
1332
+
1333
+ interface PostPassTimings {
1334
+ gapDetectMs: number;
1335
+ chaMs: number;
1336
+ thisDispatchMs: number;
1337
+ reclassifyMs: number;
1338
+ techniqueBackfillMs: number;
825
1339
  }
826
1340
 
827
1341
  /** Format timing result from native orchestrator phases + JS post-processing. */
@@ -829,7 +1343,7 @@ function formatNativeTimingResult(
829
1343
  p: Record<string, number>,
830
1344
  structurePatchMs: number,
831
1345
  analysisTiming: { astMs: number; complexityMs: number; cfgMs: number; dataflowMs: number },
832
- thisDispatchMs: number,
1346
+ postPass: PostPassTimings,
833
1347
  ): BuildResult {
834
1348
  return {
835
1349
  phases: {
@@ -842,7 +1356,11 @@ function formatNativeTimingResult(
842
1356
  edgesMs: +(p.edgesMs ?? 0).toFixed(1),
843
1357
  structureMs: +((p.structureMs ?? 0) + structurePatchMs).toFixed(1),
844
1358
  rolesMs: +(p.rolesMs ?? 0).toFixed(1),
845
- thisDispatchMs: +thisDispatchMs.toFixed(1),
1359
+ gapDetectMs: +postPass.gapDetectMs.toFixed(1),
1360
+ chaMs: +postPass.chaMs.toFixed(1),
1361
+ thisDispatchMs: +postPass.thisDispatchMs.toFixed(1),
1362
+ reclassifyMs: +postPass.reclassifyMs.toFixed(1),
1363
+ techniqueBackfillMs: +postPass.techniqueBackfillMs.toFixed(1),
846
1364
  astMs: +(analysisTiming.astMs ?? 0).toFixed(1),
847
1365
  complexityMs: +(analysisTiming.complexityMs ?? 0).toFixed(1),
848
1366
  cfgMs: +(analysisTiming.cfgMs ?? 0).toFixed(1),
@@ -923,6 +1441,55 @@ function groupByExtension(relPaths: Iterable<string>): Map<string, string[]> {
923
1441
  return buckets;
924
1442
  }
925
1443
 
1444
+ /**
1445
+ * Return the subset of relative paths that are gitignored in `rootDir`.
1446
+ *
1447
+ * Runs `git check-ignore --stdin` with all candidate paths piped in. Any
1448
+ * path that git echoes back is gitignored. Fails silently (returns an empty
1449
+ * set) when git is unavailable, the directory is not a git repo, or the
1450
+ * check-ignore call throws — the gap-detection logic handles those cases
1451
+ * gracefully without this filter.
1452
+ *
1453
+ * Uses relative paths (forward-slash separated) as both input and output so
1454
+ * the result set can be matched directly against the `expected` set in
1455
+ * `detectDroppedLanguageGap` without any further path manipulation.
1456
+ */
1457
+ function queryGitIgnoredFiles(rootDir: string, relPaths: Iterable<string>): Set<string> {
1458
+ const ignored = new Set<string>();
1459
+ const paths = [...relPaths];
1460
+ if (paths.length === 0) return ignored;
1461
+ try {
1462
+ const stdin = paths.join('\n');
1463
+ const output = execFileSync('git', ['check-ignore', '--stdin'], {
1464
+ cwd: rootDir,
1465
+ input: stdin,
1466
+ encoding: 'utf-8',
1467
+ maxBuffer: 100 * 1024 * 1024,
1468
+ // git check-ignore exits with 1 when none of the paths are ignored —
1469
+ // that is not an error for our purposes. stdio: 'pipe' lets us capture
1470
+ // stdout without swallowing stderr, and the try/catch handles the
1471
+ // non-zero exit from execFileSync when ALL paths are non-ignored
1472
+ // (exit code 1 from git check-ignore means "no matches").
1473
+ stdio: ['pipe', 'pipe', 'pipe'],
1474
+ });
1475
+ for (const line of output.split('\n')) {
1476
+ const trimmed = normalizePath(line.trim());
1477
+ if (trimmed) ignored.add(trimmed);
1478
+ }
1479
+ } catch (e: unknown) {
1480
+ // Exit code 1 means no paths were ignored — not an error. Any other
1481
+ // failure (git unavailable, not a repo, etc.) is silently swallowed
1482
+ // so the caller proceeds with the unfiltered set.
1483
+ const exitCode = (e as { status?: number }).status;
1484
+ if (exitCode !== 1) {
1485
+ debug(`queryGitIgnoredFiles: git check-ignore failed: ${toErrorMessage(e)}`);
1486
+ }
1487
+ // On exit code 1, output is empty so ignored stays empty — correct.
1488
+ // On other errors we also proceed with the empty set (safe degradation).
1489
+ }
1490
+ return ignored;
1491
+ }
1492
+
926
1493
  /**
927
1494
  * Detect files the native orchestrator silently dropped.
928
1495
  *
@@ -952,8 +1519,17 @@ function groupByExtension(relPaths: Iterable<string>): Map<string, string[]> {
952
1519
  */
953
1520
  function detectDroppedLanguageGap(ctx: PipelineContext): DroppedLanguageGap {
954
1521
  const collected = collectFilesUtil(ctx.rootDir, [], ctx.config, new Set<string>());
1522
+ const expectedRaw = collected.files.map((f) => normalizePath(path.relative(ctx.rootDir, f)));
1523
+
1524
+ // The native Rust engine uses the `ignore` crate with git_ignore(true), so it
1525
+ // respects .gitignore and never processes gitignored files. The JS collectFiles
1526
+ // walker has no gitignore awareness, so without this filter gitignored files
1527
+ // (e.g. NAPI-RS generated crates/codegraph-core/index.js / index.d.ts) appear
1528
+ // in `expected` but not in the DB, causing a spurious "native extractor bug"
1529
+ // WARN and triggering an unnecessary WASM backfill (#1626).
1530
+ const gitIgnored = queryGitIgnoredFiles(ctx.rootDir, expectedRaw);
955
1531
  const expected = new Set(
956
- collected.files.map((f) => normalizePath(path.relative(ctx.rootDir, f))),
1532
+ gitIgnored.size > 0 ? expectedRaw.filter((r) => !gitIgnored.has(r)) : expectedRaw,
957
1533
  );
958
1534
 
959
1535
  const existingNodeRows = ctx.db
@@ -997,56 +1573,33 @@ function detectDroppedLanguageGap(ctx: PipelineContext): DroppedLanguageGap {
997
1573
  return { missingRel, missingAbs, staleRel };
998
1574
  }
999
1575
 
1000
- /**
1001
- * Backfill files that the native orchestrator silently dropped during parse.
1002
- * Falls back to WASM + inserts file/symbol nodes so engine counts match (#967).
1003
- *
1004
- * Also purges stale rows for WASM-only files deleted from disk (#1073), which
1005
- * Rust's `detect_removed_files` filter (#1070) skips.
1006
- *
1007
- * Accepts a pre-computed `gap` from `detectDroppedLanguageGap` so the caller
1008
- * can use the same scan for both gating and the actual backfill — avoiding
1009
- * a redundant fs walk when the orchestrator's signals already triggered.
1010
- */
1011
- async function backfillNativeDroppedFiles(
1012
- ctx: PipelineContext,
1013
- gap: DroppedLanguageGap,
1014
- ): Promise<void> {
1015
- const { missingRel, missingAbs, staleRel } = gap;
1016
- if (missingAbs.length === 0 && staleRel.length === 0) return;
1576
+ // ── backfillNativeDroppedFiles helpers ───────────────────────────────────────
1017
1577
 
1018
- // Now that we know there's work to do, hand off to better-sqlite3 (needed
1019
- // for the INSERT path below).
1020
- if (ctx.nativeFirstProxy) {
1021
- closeNativeDb(ctx, 'pre-parity-backfill');
1022
- ctx.db = openDb(ctx.dbPath);
1023
- ctx.nativeFirstProxy = false;
1024
- }
1025
-
1026
- const dbConn = ctx.db as unknown as BetterSqlite3Database;
1027
-
1028
- // Purge WASM-only files that were deleted from disk (#1073). Rust's
1029
- // detect_removed_files skips them and the insert path below never visits
1030
- // them, so without this their rows would persist across rebuilds until the
1031
- // next full rebuild reset the DB.
1032
- if (staleRel.length > 0) {
1033
- // `computeWasmOnlyStaleFiles` guarantees every path here has an extension
1034
- // outside NATIVE_SUPPORTED_EXTENSIONS, so `classifyNativeDrops` would
1035
- // always bucket 100% into `unsupported-by-native`. Build the extension
1036
- // summary directly to avoid a redundant classification pass.
1037
- const staleByExt = groupByExtension(staleRel);
1038
- info(
1039
- `Detected ${staleRel.length} deleted WASM-only file(s) across ${staleByExt.size} extension(s) the native orchestrator skipped; purging stale rows:${formatDropExtensionSummary(staleByExt)}`,
1040
- );
1041
- purgeFilesData(dbConn, staleRel);
1042
- }
1043
-
1044
- if (missingAbs.length === 0) return;
1578
+ /** Purge stale WASM-only files deleted from disk (#1073). */
1579
+ function purgeStaleWasmOnlyFiles(db: BetterSqlite3Database, staleRel: string[]): void {
1580
+ // `computeWasmOnlyStaleFiles` guarantees every path here has an extension
1581
+ // outside NATIVE_SUPPORTED_EXTENSIONS, so `classifyNativeDrops` would
1582
+ // always bucket 100% into `unsupported-by-native`. Build the extension
1583
+ // summary directly to avoid a redundant classification pass.
1584
+ const staleByExt = groupByExtension(staleRel);
1585
+ info(
1586
+ `Detected ${staleRel.length} deleted WASM-only file(s) across ${staleByExt.size} extension(s) the native orchestrator skipped; purging stale rows:${formatDropExtensionSummary(staleByExt)}`,
1587
+ );
1588
+ purgeFilesData(db, staleRel);
1589
+ }
1045
1590
 
1046
- // Classify drops so users see per-extension reasons instead of just a count
1047
- // (#1011). `unsupported-by-native` is a legitimate parser limit (no Rust
1048
- // extractor); `native-extractor-failure` indicates a real native bug since
1049
- // the language IS supported by the addon yet the file was dropped anyway.
1591
+ /**
1592
+ * Classify and log dropped file buckets.
1593
+ * Three-way split of native-extractor-failure files:
1594
+ * realFailureBuckets WASM found symbols real Rust extractor bug (WARN)
1595
+ * emptyFileBuckets — WASM parsed but found 0 symbols → gitignored/empty (debug)
1596
+ * wasmSkipBuckets — WASM skipped entirely → no file-node insert (debug)
1597
+ */
1598
+ function classifyAndLogDroppedFiles(
1599
+ missingRel: string[],
1600
+ wasmParsedFiles: Set<string>,
1601
+ wasmFoundSymbols: Set<string>,
1602
+ ): void {
1050
1603
  const { byReason, totals } = classifyNativeDrops(missingRel);
1051
1604
  if (totals['unsupported-by-native'] > 0) {
1052
1605
  const buckets = byReason['unsupported-by-native'];
@@ -1055,13 +1608,54 @@ async function backfillNativeDroppedFiles(
1055
1608
  );
1056
1609
  }
1057
1610
  if (totals['native-extractor-failure'] > 0) {
1058
- const buckets = byReason['native-extractor-failure'];
1059
- warn(
1060
- `Native orchestrator dropped ${totals['native-extractor-failure']} file(s) across ${buckets.size} extension(s) in natively-supported languages — likely a Rust extractor bug. Backfilling via WASM:${formatDropExtensionSummary(buckets)}`,
1061
- );
1611
+ const allFailurePaths = byReason['native-extractor-failure'];
1612
+ const realFailureBuckets = new Map<string, string[]>();
1613
+ const emptyFileBuckets = new Map<string, string[]>();
1614
+ const wasmSkipBuckets = new Map<string, string[]>();
1615
+ for (const [ext, paths] of allFailurePaths) {
1616
+ for (const relPath of paths) {
1617
+ let bucket: Map<string, string[]>;
1618
+ if (wasmFoundSymbols.has(relPath)) {
1619
+ bucket = realFailureBuckets;
1620
+ } else if (wasmParsedFiles.has(relPath)) {
1621
+ bucket = emptyFileBuckets;
1622
+ } else {
1623
+ bucket = wasmSkipBuckets;
1624
+ }
1625
+ let list = bucket.get(ext);
1626
+ if (!list) {
1627
+ list = [];
1628
+ bucket.set(ext, list);
1629
+ }
1630
+ list.push(relPath);
1631
+ }
1632
+ }
1633
+ if (realFailureBuckets.size > 0) {
1634
+ const realCount = [...realFailureBuckets.values()].reduce((s, a) => s + a.length, 0);
1635
+ warn(
1636
+ `Native orchestrator dropped ${realCount} file(s) across ${realFailureBuckets.size} extension(s) in natively-supported languages — likely a Rust extractor bug. Backfilling via WASM:${formatDropExtensionSummary(realFailureBuckets)}`,
1637
+ );
1638
+ }
1639
+ if (emptyFileBuckets.size > 0) {
1640
+ const emptyCount = [...emptyFileBuckets.values()].reduce((s, a) => s + a.length, 0);
1641
+ debug(
1642
+ `Native orchestrator skipped ${emptyCount} file(s) in natively-supported languages that also produced 0 symbols via WASM (likely gitignored or empty); backfilling file nodes:${formatDropExtensionSummary(emptyFileBuckets)}`,
1643
+ );
1644
+ }
1645
+ if (wasmSkipBuckets.size > 0) {
1646
+ const skipCount = [...wasmSkipBuckets.values()].reduce((s, a) => s + a.length, 0);
1647
+ debug(
1648
+ `Native orchestrator skipped ${skipCount} file(s) in natively-supported languages that WASM also could not parse (unregistered extension or parse error); no file-node inserted:${formatDropExtensionSummary(wasmSkipBuckets)}`,
1649
+ );
1650
+ }
1062
1651
  }
1063
- const wasmResults = await parseFilesWasmForBackfill(missingAbs, ctx.rootDir);
1652
+ }
1064
1653
 
1654
+ /** Insert node rows for all backfilled files and mark exported symbols. */
1655
+ function insertBackfilledNodes(
1656
+ db: BetterSqlite3Database,
1657
+ wasmResults: Map<string, ExtractorOutput>,
1658
+ ): void {
1065
1659
  const rows: unknown[][] = [];
1066
1660
  const exportKeys: unknown[][] = [];
1067
1661
  for (const [relPath, symbols] of wasmResults) {
@@ -1093,7 +1687,6 @@ async function backfillNativeDroppedFiles(
1093
1687
  exportKeys.push([exp.name, exp.kind, relPath, exp.line]);
1094
1688
  }
1095
1689
  }
1096
- const db = dbConn;
1097
1690
  batchInsertNodes(db, rows);
1098
1691
 
1099
1692
  // Mark exported symbols in batches — mirrors insertDefinitionsAndExports.
@@ -1120,18 +1713,26 @@ async function backfillNativeDroppedFiles(
1120
1713
  updateStmt.run(...vals);
1121
1714
  }
1122
1715
  }
1716
+ }
1123
1717
 
1124
- // Persist file_hashes rows for every backfilled file. The Rust orchestrator
1125
- // only hashes files it parsed itself, so without this step files in
1126
- // optional-language extensions (e.g. .clj when no Rust extractor exists)
1127
- // would be missing from `file_hashes` permanently breaking the JS-side
1128
- // fast-skip pre-flight (#1054), which rejects on `collected file missing
1129
- // from file_hashes` and forces every no-op rebuild back through the full
1130
- // ~2s native pipeline (#1068).
1131
- //
1132
- // Iterates `missingRel` (every collected file the Rust orchestrator
1133
- // dropped), not `wasmResults`, so files that produced zero symbols still
1134
- // get a row.
1718
+ /**
1719
+ * Persist file_hashes rows for every backfilled file.
1720
+ *
1721
+ * The Rust orchestrator only hashes files it parsed itself, so without this
1722
+ * step files in optional-language extensions (e.g. .clj when no Rust extractor
1723
+ * exists) would be missing from `file_hashes` permanently breaking the JS-side
1724
+ * fast-skip pre-flight (#1054), which rejects on `collected file missing
1725
+ * from file_hashes` and forces every no-op rebuild back through the full
1726
+ * ~2s native pipeline (#1068).
1727
+ *
1728
+ * Iterates `missingRel` (every collected file the Rust orchestrator dropped),
1729
+ * not `wasmResults`, so files that produced zero symbols still get a row.
1730
+ */
1731
+ function backfillFileHashes(
1732
+ db: BetterSqlite3Database,
1733
+ missingRel: string[],
1734
+ missingAbs: string[],
1735
+ ): void {
1135
1736
  try {
1136
1737
  const upsertHash = db.prepare(
1137
1738
  'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)',
@@ -1161,6 +1762,79 @@ async function backfillNativeDroppedFiles(
1161
1762
  `backfillNativeDroppedFiles: file_hashes write failed (table may not exist): ${toErrorMessage(e)}`,
1162
1763
  );
1163
1764
  }
1765
+ }
1766
+
1767
+ /**
1768
+ * Backfill files that the native orchestrator silently dropped during parse.
1769
+ * Falls back to WASM + inserts file/symbol nodes so engine counts match (#967).
1770
+ *
1771
+ * Also purges stale rows for WASM-only files deleted from disk (#1073), which
1772
+ * Rust's `detect_removed_files` filter (#1070) skips.
1773
+ *
1774
+ * Accepts a pre-computed `gap` from `detectDroppedLanguageGap` so the caller
1775
+ * can use the same scan for both gating and the actual backfill — avoiding
1776
+ * a redundant fs walk when the orchestrator's signals already triggered.
1777
+ */
1778
+ async function backfillNativeDroppedFiles(
1779
+ ctx: PipelineContext,
1780
+ gap: DroppedLanguageGap,
1781
+ ): Promise<void> {
1782
+ const { missingRel, missingAbs, staleRel } = gap;
1783
+ if (missingAbs.length === 0 && staleRel.length === 0) return;
1784
+
1785
+ // Now that we know there's work to do, hand off to better-sqlite3 (needed
1786
+ // for the INSERT path below).
1787
+ if (ctx.nativeFirstProxy) {
1788
+ closeNativeDb(ctx, 'pre-parity-backfill');
1789
+ ctx.db = openDb(ctx.dbPath);
1790
+ ctx.nativeFirstProxy = false;
1791
+ }
1792
+
1793
+ const dbConn = ctx.db as unknown as BetterSqlite3Database;
1794
+
1795
+ // Purge WASM-only files that were deleted from disk (#1073). Rust's
1796
+ // detect_removed_files skips them and the insert path below never visits
1797
+ // them, so without this their rows would persist across rebuilds until the
1798
+ // next full rebuild reset the DB.
1799
+ if (staleRel.length > 0) {
1800
+ purgeStaleWasmOnlyFiles(dbConn, staleRel);
1801
+ }
1802
+
1803
+ if (missingAbs.length === 0) return;
1804
+
1805
+ // Parse all missing files via WASM first so we can distinguish real native
1806
+ // extractor failures (WASM finds symbols but native didn't) from files the
1807
+ // Rust engine legitimately skipped (gitignored artifacts, empty declaration
1808
+ // files, etc. where WASM also produces 0 symbols). Both categories are
1809
+ // backfilled — only the former triggers a WARN (#1566).
1810
+ const wasmResults = await parseFilesWasmForBackfill(missingAbs, ctx.rootDir);
1811
+
1812
+ // Build two sets from wasmResults:
1813
+ // wasmParsedFiles — rel-paths present in wasmResults (WASM succeeded, even 0 symbols)
1814
+ // wasmFoundSymbols — subset where WASM found ≥1 symbol
1815
+ // Files absent from wasmParsedFiles were skipped by WASM entirely (extension
1816
+ // not in _extToLang, wasmExtractSymbols returned null, or a read error).
1817
+ // Those files do NOT end up in the batchInsertNodes loop below.
1818
+ const wasmParsedFiles = new Set<string>();
1819
+ const wasmFoundSymbols = new Set<string>();
1820
+ for (const [relPath, symbols] of wasmResults) {
1821
+ wasmParsedFiles.add(relPath);
1822
+ if ((symbols.definitions?.length ?? 0) > 0 || (symbols.exports?.length ?? 0) > 0) {
1823
+ wasmFoundSymbols.add(relPath);
1824
+ }
1825
+ }
1826
+
1827
+ // Classify drops so users see per-extension reasons instead of just a count
1828
+ // (#1011). `unsupported-by-native` is a legitimate parser limit (no Rust
1829
+ // extractor); `native-extractor-failure` indicates a real native bug since
1830
+ // the language IS supported by the addon yet WASM found symbols the native
1831
+ // engine should have extracted. Files where both engines produce 0 symbols
1832
+ // are legitimately empty (e.g. gitignored napi-generated declaration stubs)
1833
+ // and logged at debug level only.
1834
+ classifyAndLogDroppedFiles(missingRel, wasmParsedFiles, wasmFoundSymbols);
1835
+
1836
+ insertBackfilledNodes(dbConn, wasmResults);
1837
+ backfillFileHashes(dbConn, missingRel, missingAbs);
1164
1838
 
1165
1839
  // Free WASM parse trees from the inline backfill path (#1058).
1166
1840
  // `parseFilesWasmInline` sets `symbols._tree` (a live web-tree-sitter Tree
@@ -1170,23 +1844,15 @@ async function backfillNativeDroppedFiles(
1170
1844
  // sees them. Without this, trees leak WASM memory until process exit —
1171
1845
  // bounded per run but cumulative across in-process integration tests.
1172
1846
  // Mirrors the cleanup discipline established for #931.
1173
- for (const [, symbols] of wasmResults) {
1174
- const tree = (symbols as { _tree?: { delete?: () => void } })._tree;
1175
- if (tree && typeof tree.delete === 'function') {
1176
- try {
1177
- tree.delete();
1178
- } catch {
1179
- /* ignore cleanup errors */
1180
- }
1181
- }
1182
- (symbols as { _tree?: unknown; _langId?: unknown })._tree = undefined;
1183
- (symbols as { _tree?: unknown; _langId?: unknown })._langId = undefined;
1184
- }
1847
+ cleanupThisDispatchWasmTrees(wasmResults);
1185
1848
  }
1186
1849
 
1187
1850
  /**
1188
1851
  * Backfill the `technique` column on `calls` edges written by the native Rust
1189
- * orchestrator, which does not write the column itself.
1852
+ * orchestrator, which does not write the column itself. Also lifts any
1853
+ * resolved ts-native edge whose confidence is below TS_NATIVE_CONFIDENCE_FLOOR
1854
+ * to that floor value so that the name-lookup quality of the native resolver is
1855
+ * reflected in the call-confidence metric.
1190
1856
  *
1191
1857
  * For full builds, all `calls` edges in the DB are new so a global UPDATE is
1192
1858
  * correct. For incremental builds, only changed-file source nodes are updated
@@ -1207,6 +1873,12 @@ function backfillEdgeTechniquesAfterNativeOrchestrator(
1207
1873
  db.prepare(
1208
1874
  "UPDATE edges SET technique = 'ts-native' WHERE kind = 'calls' AND technique IS NULL",
1209
1875
  ).run();
1876
+ // Lift resolved ts-native edges below the confidence floor.
1877
+ db.prepare(
1878
+ `UPDATE edges SET confidence = ?
1879
+ WHERE kind = 'calls' AND technique = 'ts-native'
1880
+ AND confidence > 0 AND confidence < ?`,
1881
+ ).run(TS_NATIVE_CONFIDENCE_FLOOR, TS_NATIVE_CONFIDENCE_FLOOR);
1210
1882
  return;
1211
1883
  }
1212
1884
  // Incremental: scope to source nodes whose file is one of the changed files.
@@ -1223,11 +1895,235 @@ function backfillEdgeTechniquesAfterNativeOrchestrator(
1223
1895
  SELECT id FROM nodes WHERE file IN (${placeholders})
1224
1896
  )`,
1225
1897
  ).run(...chunk);
1898
+ // Lift resolved ts-native edges below the confidence floor for this chunk.
1899
+ db.prepare(
1900
+ `UPDATE edges SET confidence = ?
1901
+ WHERE kind = 'calls' AND technique = 'ts-native'
1902
+ AND confidence > 0 AND confidence < ?
1903
+ AND source_id IN (
1904
+ SELECT id FROM nodes WHERE file IN (${placeholders})
1905
+ )`,
1906
+ ).run(TS_NATIVE_CONFIDENCE_FLOOR, TS_NATIVE_CONFIDENCE_FLOOR, ...chunk);
1226
1907
  }
1227
1908
  });
1228
1909
  tx();
1229
1910
  }
1230
1911
 
1912
+ // ── tryNativeOrchestrator helpers ────────────────────────────────────────────
1913
+
1914
+ /**
1915
+ * Open NativeDatabase on demand — deferred from setupPipeline to skip the
1916
+ * ~60ms cost on no-op/early-exit builds.
1917
+ *
1918
+ * Closes the better-sqlite3 connection first to avoid dual-connection WAL
1919
+ * corruption. On setup failure, falls back to reopening better-sqlite3 and
1920
+ * leaves ctx.nativeDb undefined so the caller falls through to the JS pipeline.
1921
+ */
1922
+ function openNativeDatabase(ctx: PipelineContext): void {
1923
+ if (ctx.nativeDb || !ctx.nativeAvailable) return;
1924
+ const native = loadNative();
1925
+ if (!native?.NativeDatabase) return;
1926
+ try {
1927
+ // Close better-sqlite3 before opening rusqlite to avoid WAL conflicts.
1928
+ // Uses raw close() instead of closeDb() intentionally — the advisory lock
1929
+ // is kept and transferred to the NativeDbProxy below, not released here.
1930
+ ctx.db.close();
1931
+ acquireAdvisoryLock(ctx.dbPath);
1932
+ ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
1933
+ ctx.nativeDb.initSchema();
1934
+ // Replace ctx.db with a NativeDbProxy so post-native JS fallback
1935
+ // (structure, analysis) can use it without reopening better-sqlite3.
1936
+ const proxy = new NativeDbProxy(ctx.nativeDb);
1937
+ proxy.__lockPath = `${ctx.dbPath}.lock`;
1938
+ ctx.db = proxy as unknown as typeof ctx.db;
1939
+ ctx.nativeFirstProxy = true;
1940
+ } catch (err) {
1941
+ warn(`NativeDatabase setup failed, falling back to JS: ${toErrorMessage(err)}`);
1942
+ try {
1943
+ ctx.nativeDb?.close();
1944
+ } catch (e) {
1945
+ debug(`tryNativeOrchestrator: close failed during fallback: ${toErrorMessage(e)}`);
1946
+ }
1947
+ ctx.nativeDb = undefined;
1948
+ ctx.nativeFirstProxy = false; // defensive: reset in case future refactors move the assignment above throwing lines
1949
+ releaseAdvisoryLock(`${ctx.dbPath}.lock`);
1950
+ // Reopen better-sqlite3 for JS pipeline fallback
1951
+ ctx.db = openDb(ctx.dbPath);
1952
+ }
1953
+ }
1954
+
1955
+ /**
1956
+ * Coordinate all post-native edge-writing post-passes, role re-classification,
1957
+ * and technique backfill. Returns timing data for the build result.
1958
+ *
1959
+ * Post-passes run before structure/analysis so role classification sees the
1960
+ * complete graph including CHA + this/super dispatch edges.
1961
+ */
1962
+ async function runPostNativePasses(
1963
+ ctx: PipelineContext,
1964
+ result: NativeOrchestratorResult,
1965
+ ): Promise<PostPassTimings & { backfillHappened: boolean }> {
1966
+ // Engine parity: the native orchestrator silently drops files whose
1967
+ // Rust extractor/grammar is missing or fails (e.g. HCL, Scala, Swift on
1968
+ // stale native binaries). WASM handles those — backfill via WASM so both
1969
+ // engines process the same file set (#967).
1970
+ //
1971
+ // Detect the gap once (fs walk + 2 DB queries) and use it for both gating
1972
+ // and the backfill itself. On quiet incrementals we still pay the walk so
1973
+ // we can detect brand-new files in dropped-language extensions — a gap that
1974
+ // the orchestrator's `detect_removed_files` filter (#1070) leaves open
1975
+ // (#1083, #1091). The pre-check is cheap because the expensive part (WASM
1976
+ // re-parse of the missing set) is gated below.
1977
+ const gapDetectStart = performance.now();
1978
+ const gap = detectDroppedLanguageGap(ctx);
1979
+ const backfillHappened = gap.missingAbs.length > 0 || gap.staleRel.length > 0;
1980
+ if (backfillHappened) {
1981
+ await backfillNativeDroppedFiles(ctx, gap);
1982
+ }
1983
+ const gapDetectMs = performance.now() - gapDetectStart;
1984
+
1985
+ // Phase 8.5: this/super dispatch — hybrid WASM re-parse to resolve call sites
1986
+ // whose raw receiver info the Rust pipeline does not persist to DB.
1987
+ // Runs BEFORE the CHA expansion pass so that super.method() → Parent.method edges
1988
+ // (technique='cha') are in the DB when runPostNativeCha expands them to sibling
1989
+ // class overrides (e.g. PostMixin.m → B.m when PostMixin and B both extend A).
1990
+ const {
1991
+ elapsedMs: thisDispatchMs,
1992
+ targetIds: thisDispatchTargetIds,
1993
+ affectedFiles: thisDispatchAffectedFiles,
1994
+ } = await runPostNativeThisDispatch(
1995
+ ctx.db as unknown as BetterSqlite3Database,
1996
+ ctx.rootDir,
1997
+ result.changedFiles,
1998
+ !!result.isFullBuild,
1999
+ );
2000
+
2001
+ // Phase 8.6: expand CHA call edges (interface dispatch → concrete implementations).
2002
+ // Returns the affected files so role re-classification below can be scoped to
2003
+ // the nodes whose fan-in/out actually changed.
2004
+ //
2005
+ // Runs AFTER this/super dispatch so super.method() edges are already in the DB.
2006
+ // The 'cha-expanded' technique tag on this pass's own output prevents re-expansion
2007
+ // of those edges in subsequent incremental builds, while 'cha'-tagged edges from
2008
+ // this/super dispatch remain eligible for expansion here.
2009
+ //
2010
+ // Function-as-object-property methods (`fn.method = function() {}`) are extracted
2011
+ // natively by the Rust engine (#1432) and resolved in-build by its edge builder, so
2012
+ // no WASM re-parse post-pass is needed for them. `Foo.prototype.bar = fn` likewise.
2013
+ const chaStart = performance.now();
2014
+ const { newEdgeCount: chaEdgeCount, affectedFiles: chaAffectedFiles } = runPostNativeCha(
2015
+ ctx.db as unknown as BetterSqlite3Database,
2016
+ // null = full build (scan all call→method edges); array = incremental (gate queries decide scope)
2017
+ result.isFullBuild ? null : (result.changedFiles ?? null),
2018
+ );
2019
+ const chaMs = performance.now() - chaStart;
2020
+
2021
+ // Role re-classification after the Rust orchestrator build.
2022
+ //
2023
+ // Two reasons to re-classify:
2024
+ //
2025
+ // 1. Post-pass edges (CHA, this-dispatch): the Rust orchestrator classifies
2026
+ // roles before these passes add edges, so fan-in/out for their endpoints
2027
+ // is stale. On incremental builds, scope to the affected files for speed.
2028
+ //
2029
+ // 2. hasActiveFileSiblings parity: the Rust classifier does not implement the
2030
+ // JS hasActiveFileSiblings heuristic. That heuristic promotes functions with
2031
+ // fan_in=0 but fan_out>0 to 'leaf' when their file has other connected
2032
+ // callables — preventing false dead-unresolved classifications for functions
2033
+ // like `main` or `square` that call others but are never called themselves.
2034
+ // On full builds, always run a full JS re-classification so the Rust roles
2035
+ // are replaced by the canonical JS classifier output (#1659).
2036
+ //
2037
+ // Strategy:
2038
+ // - Full build: always run full JS classifyNodeRoles(db, null).
2039
+ // - Incremental build with post-pass edges: run scoped re-classification
2040
+ // for the affected files (same as before). The full-build pass already
2041
+ // produced correct JS roles for all unchanged files on the previous build.
2042
+ // - Incremental build with no post-pass edges: skip re-classification
2043
+ // (Rust roles on unchanged files are not stale, and the heuristic gap
2044
+ // was corrected on the last full build).
2045
+ let reclassifyMs = 0;
2046
+ const needsFullReclassify = !!result.isFullBuild;
2047
+ const needsScopedReclassify =
2048
+ !needsFullReclassify && (chaEdgeCount > 0 || thisDispatchTargetIds.size > 0);
2049
+ if (needsFullReclassify || needsScopedReclassify) {
2050
+ let scopedFiles: string[] | null = null;
2051
+ if (needsScopedReclassify) {
2052
+ const affectedFiles = [...new Set([...chaAffectedFiles, ...thisDispatchAffectedFiles])];
2053
+ // When edges were inserted but all their endpoint nodes have null `file`
2054
+ // columns (rare but possible), affectedFiles stays empty even though
2055
+ // fan-in/out changed. Fall back to full-graph re-classification in that
2056
+ // case — scoped classification with an empty set would be a no-op, leaving
2057
+ // roles stale for those nodes.
2058
+ scopedFiles = affectedFiles.length > 0 ? affectedFiles : null;
2059
+ }
2060
+ const reclassifyStart = performance.now();
2061
+ try {
2062
+ const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
2063
+ classifyNodeRoles: (
2064
+ db: BetterSqlite3Database,
2065
+ changedFiles?: string[] | null,
2066
+ ) => Record<string, number>;
2067
+ };
2068
+ classifyNodeRoles(ctx.db as unknown as BetterSqlite3Database, scopedFiles);
2069
+ debug(
2070
+ scopedFiles
2071
+ ? `Post-pass role re-classification complete (${scopedFiles.length} file(s))`
2072
+ : 'Post-pass role re-classification complete (full graph)',
2073
+ );
2074
+ } catch (err) {
2075
+ debug(`Post-pass role re-classification failed: ${toErrorMessage(err)}`);
2076
+ }
2077
+ reclassifyMs = performance.now() - reclassifyStart;
2078
+ }
2079
+
2080
+ // Backfill the `technique` column on `calls` edges written by the Rust
2081
+ // orchestrator, which does not write the column. Runs after all edge-writing
2082
+ // phases (including the WASM dropped-language backfill, CHA post-pass, and
2083
+ // this/super dispatch) so every new edge in this build cycle gets a label.
2084
+ const techniqueBackfillStart = performance.now();
2085
+ backfillEdgeTechniquesAfterNativeOrchestrator(ctx.db, !!result.isFullBuild, result.changedFiles);
2086
+ const techniqueBackfillMs = performance.now() - techniqueBackfillStart;
2087
+
2088
+ // Re-count nodes/edges now that all edge-writing post-passes have run: the
2089
+ // Rust orchestrator captured its counts before the JS post-passes added
2090
+ // edges, so both its summary and build_meta under-report (#1452).
2091
+ //
2092
+ // Fast path: skip the COUNT(*) scan when no post-pass wrote any edges.
2093
+ // COUNT(*) on large tables (50K+ edges) is non-trivial, especially via the
2094
+ // NativeDbProxy napi-rs round-trip. When all post-passes were no-ops, the
2095
+ // Rust orchestrator's counts are still accurate — no re-count needed.
2096
+ let finalNodeCount = result.nodeCount ?? 0;
2097
+ let finalEdgeCount = result.edgeCount ?? 0;
2098
+ const postPassWroteData = backfillHappened || chaEdgeCount > 0 || thisDispatchTargetIds.size > 0;
2099
+ if (postPassWroteData) {
2100
+ try {
2101
+ const counts = (ctx.db as unknown as BetterSqlite3Database)
2102
+ .prepare('SELECT (SELECT COUNT(*) FROM nodes) AS n, (SELECT COUNT(*) FROM edges) AS e')
2103
+ .get() as { n: number; e: number };
2104
+ if (counts.n !== finalNodeCount || counts.e !== finalEdgeCount) {
2105
+ finalNodeCount = counts.n;
2106
+ finalEdgeCount = counts.e;
2107
+ setBuildMeta(ctx.db, { node_count: finalNodeCount, edge_count: finalEdgeCount });
2108
+ }
2109
+ } catch (err) {
2110
+ debug(`Post-pass node/edge re-count failed: ${toErrorMessage(err)}`);
2111
+ }
2112
+ }
2113
+ info(
2114
+ `Native build orchestrator completed: ${finalNodeCount} nodes, ${finalEdgeCount} edges, ${result.fileCount ?? 0} files`,
2115
+ );
2116
+
2117
+ return {
2118
+ gapDetectMs,
2119
+ chaMs,
2120
+ thisDispatchMs,
2121
+ reclassifyMs,
2122
+ techniqueBackfillMs,
2123
+ backfillHappened,
2124
+ };
2125
+ }
2126
+
1231
2127
  /**
1232
2128
  * Try the native build orchestrator.
1233
2129
  *
@@ -1251,50 +2147,44 @@ export async function tryNativeOrchestrator(
1251
2147
  return undefined;
1252
2148
  }
1253
2149
 
1254
- // Open NativeDatabase on demand — deferred from setupPipeline to skip the
1255
- // ~60ms cost on no-op/early-exit builds. Close the better-sqlite3 connection
1256
- // first to avoid dual-connection WAL corruption.
1257
- if (!ctx.nativeDb && ctx.nativeAvailable) {
1258
- const native = loadNative();
1259
- if (native?.NativeDatabase) {
1260
- try {
1261
- // Close better-sqlite3 before opening rusqlite to avoid WAL conflicts.
1262
- // Uses raw close() instead of closeDb() intentionally — the advisory lock
1263
- // is kept and transferred to the NativeDbProxy below, not released here.
1264
- ctx.db.close();
1265
- acquireAdvisoryLock(ctx.dbPath);
1266
- ctx.nativeDb = native.NativeDatabase.openReadWrite(ctx.dbPath);
1267
- ctx.nativeDb.initSchema();
1268
- // Replace ctx.db with a NativeDbProxy so post-native JS fallback
1269
- // (structure, analysis) can use it without reopening better-sqlite3.
1270
- const proxy = new NativeDbProxy(ctx.nativeDb);
1271
- proxy.__lockPath = `${ctx.dbPath}.lock`;
1272
- ctx.db = proxy as unknown as typeof ctx.db;
1273
- ctx.nativeFirstProxy = true;
1274
- } catch (err) {
1275
- warn(`NativeDatabase setup failed, falling back to JS: ${toErrorMessage(err)}`);
1276
- try {
1277
- ctx.nativeDb?.close();
1278
- } catch (e) {
1279
- debug(`tryNativeOrchestrator: close failed during fallback: ${toErrorMessage(e)}`);
1280
- }
1281
- ctx.nativeDb = undefined;
1282
- ctx.nativeFirstProxy = false; // defensive: reset in case future refactors move the assignment above throwing lines
1283
- releaseAdvisoryLock(`${ctx.dbPath}.lock`);
1284
- // Reopen better-sqlite3 for JS pipeline fallback
1285
- ctx.db = openDb(ctx.dbPath);
1286
- }
1287
- }
1288
- }
2150
+ openNativeDatabase(ctx);
1289
2151
 
1290
2152
  if (!ctx.nativeDb?.buildGraph) return undefined;
1291
2153
 
1292
- const resultJson = ctx.nativeDb.buildGraph(
1293
- ctx.rootDir,
1294
- JSON.stringify(ctx.config),
1295
- JSON.stringify(ctx.aliases),
1296
- JSON.stringify(ctx.opts),
1297
- );
2154
+ // The previous full build's clear_all_graph_data() sets PRAGMA foreign_keys = ON
2155
+ // on the native connection. Older native binaries (< v3.14) do not delete
2156
+ // dataflow_vertices / dataflow_summary / call_edge_id rows before purging
2157
+ // nodes/edges during incremental builds, so FK enforcement causes the purge
2158
+ // statements to fail silently — leaving stale nodes and edges that then get
2159
+ // duplicated when the barrel-candidate re-parse re-inserts them (issue #1644).
2160
+ // Disabling FK before buildGraph() lets the purge succeed; FK is restored in
2161
+ // a finally block so post-passes (gap-repair, structure patch) retain FK protection
2162
+ // even if buildGraph() throws.
2163
+ try {
2164
+ ctx.nativeDb.exec('PRAGMA foreign_keys = OFF');
2165
+ } catch {
2166
+ // exec may not exist on very old addon versions — safe to ignore
2167
+ }
2168
+
2169
+ let resultJson: string;
2170
+ try {
2171
+ resultJson = ctx.nativeDb.buildGraph(
2172
+ ctx.rootDir,
2173
+ JSON.stringify(ctx.config),
2174
+ JSON.stringify(ctx.aliases),
2175
+ JSON.stringify(ctx.opts),
2176
+ );
2177
+ } finally {
2178
+ // Restore FK enforcement so any subsequent writes to this connection
2179
+ // (gap-repair, structure patch) retain FK protection — even if buildGraph()
2180
+ // throws.
2181
+ try {
2182
+ ctx.nativeDb.exec('PRAGMA foreign_keys = ON');
2183
+ } catch {
2184
+ // safe to ignore on very old addon versions
2185
+ }
2186
+ }
2187
+
1298
2188
  const result = JSON.parse(resultJson) as NativeOrchestratorResult;
1299
2189
 
1300
2190
  if (result.earlyExit) {
@@ -1302,7 +2192,7 @@ export async function tryNativeOrchestrator(
1302
2192
  // Even on no-op rebuilds, dropped-language files added since the last
1303
2193
  // full build are still missing from `nodes`/`file_hashes` (#1083), and
1304
2194
  // WASM-only files deleted from disk leave stale rows behind (#1073).
1305
- // The orchestrator's file_collector skipped them, so its earlyExit
2195
+ // The orchestrator's collect_files skipped them, so its earlyExit
1306
2196
  // doesn't imply DB consistency. Run the gap repair before returning.
1307
2197
  const gap = detectDroppedLanguageGap(ctx);
1308
2198
  if (gap.missingAbs.length > 0 || gap.staleRel.length > 0) {
@@ -1344,9 +2234,9 @@ export async function tryNativeOrchestrator(
1344
2234
  built_at: new Date().toISOString(),
1345
2235
  });
1346
2236
 
1347
- info(
1348
- `Native build orchestrator completed: ${result.nodeCount ?? 0} nodes, ${result.edgeCount ?? 0} edges, ${result.fileCount ?? 0} files`,
1349
- );
2237
+ // The build summary is logged after the JS edge-writing post-passes below
2238
+ // (dropped-language backfill, CHA, this/super dispatch) so the reported
2239
+ // counts include their edges (#1452).
1350
2240
 
1351
2241
  // ── Post-native structure + analysis ──────────────────────────────
1352
2242
  let analysisTiming = {
@@ -1381,103 +2271,18 @@ export async function tryNativeOrchestrator(
1381
2271
  ctx.db = openDb(ctx.dbPath);
1382
2272
  ctx.nativeFirstProxy = false;
1383
2273
  } else if (!ctx.nativeFirstProxy && !handoffWalAfterNativeBuild(ctx)) {
1384
- // DB reopen failed — return partial result
1385
- return formatNativeTimingResult(p, 0, analysisTiming, 0);
1386
- }
1387
- }
1388
-
1389
- // ── Edge-writing post-passes (run before structure so roles see full graph) ──
1390
-
1391
- // Engine parity: the native orchestrator silently drops files whose
1392
- // Rust extractor/grammar is missing or fails (e.g. HCL, Scala, Swift on
1393
- // stale native binaries). WASM handles those — backfill via WASM so both
1394
- // engines process the same file set (#967).
1395
- //
1396
- // Detect the gap once (fs walk + 2 DB queries, ~20–30ms) and use it for
1397
- // both gating and the backfill itself. On dirty incrementals/full builds
1398
- // the orchestrator signals trigger backfill, so the walk happens once
1399
- // (instead of redundantly inside backfill). On quiet incrementals we
1400
- // still pay the walk so we can detect brand-new files in dropped-language
1401
- // extensions — a gap that the orchestrator's `detect_removed_files`
1402
- // filter (#1070) leaves open (#1083, #1091). The pre-check is cheap
1403
- // because the expensive part (WASM re-parse of the missing set) is
1404
- // gated below.
1405
- const removedCount = result.removedCount ?? 0;
1406
- const changedCount = result.changedCount ?? 0;
1407
- const gap = detectDroppedLanguageGap(ctx);
1408
- if (
1409
- result.isFullBuild ||
1410
- removedCount > 0 ||
1411
- changedCount > 0 ||
1412
- gap.missingAbs.length > 0 ||
1413
- gap.staleRel.length > 0
1414
- ) {
1415
- await backfillNativeDroppedFiles(ctx, gap);
1416
- }
1417
-
1418
- // Phase 8.5: expand CHA call edges (interface dispatch → concrete implementations).
1419
- // Returns the affected files so role re-classification below can be scoped to
1420
- // the nodes whose fan-in/out actually changed.
1421
- //
1422
- // Function-as-object-property methods (`fn.method = function() {}`) are extracted
1423
- // natively by the Rust engine (#1432) and resolved in-build by its edge builder, so
1424
- // no WASM re-parse post-pass is needed for them. `Foo.prototype.bar = fn` likewise.
1425
- const { newEdgeCount: chaEdgeCount, affectedFiles: chaAffectedFiles } = runPostNativeCha(
1426
- ctx.db as unknown as BetterSqlite3Database,
1427
- );
1428
-
1429
- // Phase 8.5: this/super dispatch — hybrid WASM re-parse to resolve call sites
1430
- // whose raw receiver info the Rust pipeline does not persist to DB.
1431
- const {
1432
- elapsedMs: thisDispatchMs,
1433
- targetIds: thisDispatchTargetIds,
1434
- affectedFiles: thisDispatchAffectedFiles,
1435
- } = await runPostNativeThisDispatch(
1436
- ctx.db as unknown as BetterSqlite3Database,
1437
- ctx.rootDir,
1438
- result.changedFiles,
1439
- !!result.isFullBuild,
1440
- );
1441
-
1442
- // Role re-classification after JS edge-writing post-passes.
1443
- // The Rust orchestrator classifies roles before these post-passes (CHA,
1444
- // this-dispatch) add edges, so roles for the edge endpoints are stale.
1445
- // Scoped to the files containing those endpoints: a new edge only changes
1446
- // fan-in/out for its own source and target nodes, so re-classifying their
1447
- // files restores correctness without re-running the classifier over the
1448
- // whole graph (which cost ~130ms per build on codegraph itself and was a
1449
- // major part of the v3.12.0 native full-build benchmark regression).
1450
- if (chaEdgeCount > 0 || thisDispatchTargetIds.size > 0) {
1451
- const affectedFiles = [...new Set([...chaAffectedFiles, ...thisDispatchAffectedFiles])];
1452
- // When edges were inserted but all their endpoint nodes have null `file`
1453
- // columns (rare but possible), affectedFiles stays empty even though
1454
- // fan-in/out changed. Fall back to full-graph re-classification in that
1455
- // case — scoped classification with an empty set would be a no-op, leaving
1456
- // roles stale for those nodes.
1457
- const scopedFiles = affectedFiles.length > 0 ? affectedFiles : null;
1458
- try {
1459
- const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1460
- classifyNodeRoles: (
1461
- db: BetterSqlite3Database,
1462
- changedFiles?: string[] | null,
1463
- ) => Record<string, number>;
1464
- };
1465
- classifyNodeRoles(ctx.db as unknown as BetterSqlite3Database, scopedFiles);
1466
- debug(
1467
- scopedFiles
1468
- ? `Post-pass role re-classification complete (${scopedFiles.length} file(s))`
1469
- : 'Post-pass role re-classification complete (full graph — null-file endpoints)',
1470
- );
1471
- } catch (err) {
1472
- debug(`Post-pass role re-classification failed: ${toErrorMessage(err)}`);
2274
+ // DB reopen failed — return partial result (no post-pass phases completed)
2275
+ return formatNativeTimingResult(p, 0, analysisTiming, {
2276
+ gapDetectMs: 0,
2277
+ chaMs: 0,
2278
+ thisDispatchMs: 0,
2279
+ reclassifyMs: 0,
2280
+ techniqueBackfillMs: 0,
2281
+ });
1473
2282
  }
1474
2283
  }
1475
2284
 
1476
- // Backfill the `technique` column on `calls` edges written by the Rust
1477
- // orchestrator, which does not write the column. Runs after all edge-writing
1478
- // phases (including the WASM dropped-language backfill, CHA post-pass, and
1479
- // this/super dispatch) so every new edge in this build cycle gets a label.
1480
- backfillEdgeTechniquesAfterNativeOrchestrator(ctx.db, !!result.isFullBuild, result.changedFiles);
2285
+ const postPassTimings = await runPostNativePasses(ctx, result);
1481
2286
 
1482
2287
  // ── Structure and analysis fallback (run after edge-writing so roles see full graph) ──
1483
2288
  // Reconstruct fileSymbols once for both structure and analysis to avoid two
@@ -1500,6 +2305,17 @@ export async function tryNativeOrchestrator(
1500
2305
  }
1501
2306
  }
1502
2307
 
2308
+ // P6: Vertex extraction for the analysisComplete=true path.
2309
+ // When needsAnalysisFallback=false (the normal native case), runPostNativeAnalysis
2310
+ // was skipped, so buildDataflowEdges never ran and dataflow_vertices were never
2311
+ // populated. Re-run the Rust dataflow visitor per file (fast — no re-parse) to
2312
+ // get the DataflowResult, then build vertices and inter-procedural edges.
2313
+ // Languages where Rust has no dataflow rules are silently skipped; a WASM
2314
+ // fallback for those is tracked in issue #1614.
2315
+ if (ctx.opts.dataflow !== false && !needsAnalysisFallback) {
2316
+ await runDataflowVertexPass(ctx, result.changedFiles);
2317
+ }
2318
+
1503
2319
  closeDbPair({ db: ctx.db, nativeDb: ctx.nativeDb });
1504
- return formatNativeTimingResult(p, structurePatchMs, analysisTiming, thisDispatchMs);
2320
+ return formatNativeTimingResult(p, structurePatchMs, analysisTiming, postPassTimings);
1505
2321
  }