@quolu/lattice 0.1.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 (769) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +20 -0
  3. package/bin/lattice-mcp.mjs +134 -0
  4. package/bin/lattice.mjs +78 -0
  5. package/package.json +47 -0
  6. package/sensor/LICENSE +21 -0
  7. package/sensor/NOTICE +30 -0
  8. package/sensor/dist/bin/codegraph.d.ts +26 -0
  9. package/sensor/dist/bin/codegraph.d.ts.map +1 -0
  10. package/sensor/dist/bin/codegraph.js +2279 -0
  11. package/sensor/dist/bin/codegraph.js.map +1 -0
  12. package/sensor/dist/bin/command-supervision.d.ts +49 -0
  13. package/sensor/dist/bin/command-supervision.d.ts.map +1 -0
  14. package/sensor/dist/bin/command-supervision.js +95 -0
  15. package/sensor/dist/bin/command-supervision.js.map +1 -0
  16. package/sensor/dist/bin/fatal-handler.d.ts +20 -0
  17. package/sensor/dist/bin/fatal-handler.d.ts.map +1 -0
  18. package/sensor/dist/bin/fatal-handler.js +118 -0
  19. package/sensor/dist/bin/fatal-handler.js.map +1 -0
  20. package/sensor/dist/bin/node-version-check.d.ts +37 -0
  21. package/sensor/dist/bin/node-version-check.d.ts.map +1 -0
  22. package/sensor/dist/bin/node-version-check.js +79 -0
  23. package/sensor/dist/bin/node-version-check.js.map +1 -0
  24. package/sensor/dist/bin/uninstall.d.ts +14 -0
  25. package/sensor/dist/bin/uninstall.d.ts.map +1 -0
  26. package/sensor/dist/bin/uninstall.js +36 -0
  27. package/sensor/dist/bin/uninstall.js.map +1 -0
  28. package/sensor/dist/context/formatter.d.ts +30 -0
  29. package/sensor/dist/context/formatter.d.ts.map +1 -0
  30. package/sensor/dist/context/formatter.js +263 -0
  31. package/sensor/dist/context/formatter.js.map +1 -0
  32. package/sensor/dist/context/index.d.ts +119 -0
  33. package/sensor/dist/context/index.d.ts.map +1 -0
  34. package/sensor/dist/context/index.js +1326 -0
  35. package/sensor/dist/context/index.js.map +1 -0
  36. package/sensor/dist/context/markers.d.ts +19 -0
  37. package/sensor/dist/context/markers.d.ts.map +1 -0
  38. package/sensor/dist/context/markers.js +22 -0
  39. package/sensor/dist/context/markers.js.map +1 -0
  40. package/sensor/dist/db/index.d.ts +264 -0
  41. package/sensor/dist/db/index.d.ts.map +1 -0
  42. package/sensor/dist/db/index.js +603 -0
  43. package/sensor/dist/db/index.js.map +1 -0
  44. package/sensor/dist/db/migrations.d.ts +44 -0
  45. package/sensor/dist/db/migrations.d.ts.map +1 -0
  46. package/sensor/dist/db/migrations.js +241 -0
  47. package/sensor/dist/db/migrations.js.map +1 -0
  48. package/sensor/dist/db/queries.d.ts +563 -0
  49. package/sensor/dist/db/queries.d.ts.map +1 -0
  50. package/sensor/dist/db/queries.js +2179 -0
  51. package/sensor/dist/db/queries.js.map +1 -0
  52. package/sensor/dist/db/schema.sql +196 -0
  53. package/sensor/dist/db/sqlite-adapter.d.ts +55 -0
  54. package/sensor/dist/db/sqlite-adapter.d.ts.map +1 -0
  55. package/sensor/dist/db/sqlite-adapter.js +135 -0
  56. package/sensor/dist/db/sqlite-adapter.js.map +1 -0
  57. package/sensor/dist/db/wal-valve.d.ts +106 -0
  58. package/sensor/dist/db/wal-valve.d.ts.map +1 -0
  59. package/sensor/dist/db/wal-valve.js +208 -0
  60. package/sensor/dist/db/wal-valve.js.map +1 -0
  61. package/sensor/dist/directory.d.ts +183 -0
  62. package/sensor/dist/directory.d.ts.map +1 -0
  63. package/sensor/dist/directory.js +797 -0
  64. package/sensor/dist/directory.js.map +1 -0
  65. package/sensor/dist/errors.d.ts +136 -0
  66. package/sensor/dist/errors.d.ts.map +1 -0
  67. package/sensor/dist/errors.js +219 -0
  68. package/sensor/dist/errors.js.map +1 -0
  69. package/sensor/dist/extraction/astro-extractor.d.ts +79 -0
  70. package/sensor/dist/extraction/astro-extractor.d.ts.map +1 -0
  71. package/sensor/dist/extraction/astro-extractor.js +320 -0
  72. package/sensor/dist/extraction/astro-extractor.js.map +1 -0
  73. package/sensor/dist/extraction/cfml-extractor.d.ts +107 -0
  74. package/sensor/dist/extraction/cfml-extractor.d.ts.map +1 -0
  75. package/sensor/dist/extraction/cfml-extractor.js +494 -0
  76. package/sensor/dist/extraction/cfml-extractor.js.map +1 -0
  77. package/sensor/dist/extraction/dfm-extractor.d.ts +31 -0
  78. package/sensor/dist/extraction/dfm-extractor.d.ts.map +1 -0
  79. package/sensor/dist/extraction/dfm-extractor.js +151 -0
  80. package/sensor/dist/extraction/dfm-extractor.js.map +1 -0
  81. package/sensor/dist/extraction/dynamic-import.d.ts +82 -0
  82. package/sensor/dist/extraction/dynamic-import.d.ts.map +1 -0
  83. package/sensor/dist/extraction/dynamic-import.js +313 -0
  84. package/sensor/dist/extraction/dynamic-import.js.map +1 -0
  85. package/sensor/dist/extraction/extraction-version.d.ts +25 -0
  86. package/sensor/dist/extraction/extraction-version.d.ts.map +1 -0
  87. package/sensor/dist/extraction/extraction-version.js +28 -0
  88. package/sensor/dist/extraction/extraction-version.js.map +1 -0
  89. package/sensor/dist/extraction/function-ref.d.ts +118 -0
  90. package/sensor/dist/extraction/function-ref.d.ts.map +1 -0
  91. package/sensor/dist/extraction/function-ref.js +727 -0
  92. package/sensor/dist/extraction/function-ref.js.map +1 -0
  93. package/sensor/dist/extraction/generated-detection.d.ts +30 -0
  94. package/sensor/dist/extraction/generated-detection.d.ts.map +1 -0
  95. package/sensor/dist/extraction/generated-detection.js +83 -0
  96. package/sensor/dist/extraction/generated-detection.js.map +1 -0
  97. package/sensor/dist/extraction/grammars.d.ts +138 -0
  98. package/sensor/dist/extraction/grammars.d.ts.map +1 -0
  99. package/sensor/dist/extraction/grammars.js +648 -0
  100. package/sensor/dist/extraction/grammars.js.map +1 -0
  101. package/sensor/dist/extraction/index.d.ts +251 -0
  102. package/sensor/dist/extraction/index.d.ts.map +1 -0
  103. package/sensor/dist/extraction/index.js +2563 -0
  104. package/sensor/dist/extraction/index.js.map +1 -0
  105. package/sensor/dist/extraction/kernel/decode.d.ts +9 -0
  106. package/sensor/dist/extraction/kernel/decode.d.ts.map +1 -0
  107. package/sensor/dist/extraction/kernel/decode.js +156 -0
  108. package/sensor/dist/extraction/kernel/decode.js.map +1 -0
  109. package/sensor/dist/extraction/kernel/index.d.ts +62 -0
  110. package/sensor/dist/extraction/kernel/index.d.ts.map +1 -0
  111. package/sensor/dist/extraction/kernel/index.js +165 -0
  112. package/sensor/dist/extraction/kernel/index.js.map +1 -0
  113. package/sensor/dist/extraction/kernel/layout.d.ts +92 -0
  114. package/sensor/dist/extraction/kernel/layout.d.ts.map +1 -0
  115. package/sensor/dist/extraction/kernel/layout.js +95 -0
  116. package/sensor/dist/extraction/kernel/layout.js.map +1 -0
  117. package/sensor/dist/extraction/kernel/loader.d.ts +61 -0
  118. package/sensor/dist/extraction/kernel/loader.d.ts.map +1 -0
  119. package/sensor/dist/extraction/kernel/loader.js +146 -0
  120. package/sensor/dist/extraction/kernel/loader.js.map +1 -0
  121. package/sensor/dist/extraction/languages/arkts.d.ts +3 -0
  122. package/sensor/dist/extraction/languages/arkts.d.ts.map +1 -0
  123. package/sensor/dist/extraction/languages/arkts.js +127 -0
  124. package/sensor/dist/extraction/languages/arkts.js.map +1 -0
  125. package/sensor/dist/extraction/languages/c-cpp.d.ts +111 -0
  126. package/sensor/dist/extraction/languages/c-cpp.d.ts.map +1 -0
  127. package/sensor/dist/extraction/languages/c-cpp.js +878 -0
  128. package/sensor/dist/extraction/languages/c-cpp.js.map +1 -0
  129. package/sensor/dist/extraction/languages/cfquery.d.ts +12 -0
  130. package/sensor/dist/extraction/languages/cfquery.d.ts.map +1 -0
  131. package/sensor/dist/extraction/languages/cfquery.js +28 -0
  132. package/sensor/dist/extraction/languages/cfquery.js.map +1 -0
  133. package/sensor/dist/extraction/languages/cfscript.d.ts +3 -0
  134. package/sensor/dist/extraction/languages/cfscript.d.ts.map +1 -0
  135. package/sensor/dist/extraction/languages/cfscript.js +73 -0
  136. package/sensor/dist/extraction/languages/cfscript.js.map +1 -0
  137. package/sensor/dist/extraction/languages/cobol.d.ts +33 -0
  138. package/sensor/dist/extraction/languages/cobol.d.ts.map +1 -0
  139. package/sensor/dist/extraction/languages/cobol.js +499 -0
  140. package/sensor/dist/extraction/languages/cobol.js.map +1 -0
  141. package/sensor/dist/extraction/languages/csharp.d.ts +25 -0
  142. package/sensor/dist/extraction/languages/csharp.d.ts.map +1 -0
  143. package/sensor/dist/extraction/languages/csharp.js +175 -0
  144. package/sensor/dist/extraction/languages/csharp.js.map +1 -0
  145. package/sensor/dist/extraction/languages/dart.d.ts +3 -0
  146. package/sensor/dist/extraction/languages/dart.d.ts.map +1 -0
  147. package/sensor/dist/extraction/languages/dart.js +374 -0
  148. package/sensor/dist/extraction/languages/dart.js.map +1 -0
  149. package/sensor/dist/extraction/languages/erlang.d.ts +3 -0
  150. package/sensor/dist/extraction/languages/erlang.d.ts.map +1 -0
  151. package/sensor/dist/extraction/languages/erlang.js +350 -0
  152. package/sensor/dist/extraction/languages/erlang.js.map +1 -0
  153. package/sensor/dist/extraction/languages/go.d.ts +3 -0
  154. package/sensor/dist/extraction/languages/go.d.ts.map +1 -0
  155. package/sensor/dist/extraction/languages/go.js +111 -0
  156. package/sensor/dist/extraction/languages/go.js.map +1 -0
  157. package/sensor/dist/extraction/languages/index.d.ts +10 -0
  158. package/sensor/dist/extraction/languages/index.d.ts.map +1 -0
  159. package/sensor/dist/extraction/languages/index.js +71 -0
  160. package/sensor/dist/extraction/languages/index.js.map +1 -0
  161. package/sensor/dist/extraction/languages/java.d.ts +3 -0
  162. package/sensor/dist/extraction/languages/java.d.ts.map +1 -0
  163. package/sensor/dist/extraction/languages/java.js +315 -0
  164. package/sensor/dist/extraction/languages/java.js.map +1 -0
  165. package/sensor/dist/extraction/languages/javascript.d.ts +3 -0
  166. package/sensor/dist/extraction/languages/javascript.d.ts.map +1 -0
  167. package/sensor/dist/extraction/languages/javascript.js +106 -0
  168. package/sensor/dist/extraction/languages/javascript.js.map +1 -0
  169. package/sensor/dist/extraction/languages/kotlin.d.ts +3 -0
  170. package/sensor/dist/extraction/languages/kotlin.d.ts.map +1 -0
  171. package/sensor/dist/extraction/languages/kotlin.js +379 -0
  172. package/sensor/dist/extraction/languages/kotlin.js.map +1 -0
  173. package/sensor/dist/extraction/languages/lua.d.ts +3 -0
  174. package/sensor/dist/extraction/languages/lua.d.ts.map +1 -0
  175. package/sensor/dist/extraction/languages/lua.js +150 -0
  176. package/sensor/dist/extraction/languages/lua.js.map +1 -0
  177. package/sensor/dist/extraction/languages/luau.d.ts +3 -0
  178. package/sensor/dist/extraction/languages/luau.d.ts.map +1 -0
  179. package/sensor/dist/extraction/languages/luau.js +37 -0
  180. package/sensor/dist/extraction/languages/luau.js.map +1 -0
  181. package/sensor/dist/extraction/languages/nix.d.ts +3 -0
  182. package/sensor/dist/extraction/languages/nix.d.ts.map +1 -0
  183. package/sensor/dist/extraction/languages/nix.js +294 -0
  184. package/sensor/dist/extraction/languages/nix.js.map +1 -0
  185. package/sensor/dist/extraction/languages/objc.d.ts +3 -0
  186. package/sensor/dist/extraction/languages/objc.d.ts.map +1 -0
  187. package/sensor/dist/extraction/languages/objc.js +175 -0
  188. package/sensor/dist/extraction/languages/objc.js.map +1 -0
  189. package/sensor/dist/extraction/languages/pascal.d.ts +3 -0
  190. package/sensor/dist/extraction/languages/pascal.d.ts.map +1 -0
  191. package/sensor/dist/extraction/languages/pascal.js +77 -0
  192. package/sensor/dist/extraction/languages/pascal.js.map +1 -0
  193. package/sensor/dist/extraction/languages/php.d.ts +3 -0
  194. package/sensor/dist/extraction/languages/php.d.ts.map +1 -0
  195. package/sensor/dist/extraction/languages/php.js +196 -0
  196. package/sensor/dist/extraction/languages/php.js.map +1 -0
  197. package/sensor/dist/extraction/languages/python.d.ts +3 -0
  198. package/sensor/dist/extraction/languages/python.d.ts.map +1 -0
  199. package/sensor/dist/extraction/languages/python.js +56 -0
  200. package/sensor/dist/extraction/languages/python.js.map +1 -0
  201. package/sensor/dist/extraction/languages/r.d.ts +3 -0
  202. package/sensor/dist/extraction/languages/r.d.ts.map +1 -0
  203. package/sensor/dist/extraction/languages/r.js +314 -0
  204. package/sensor/dist/extraction/languages/r.js.map +1 -0
  205. package/sensor/dist/extraction/languages/ruby.d.ts +3 -0
  206. package/sensor/dist/extraction/languages/ruby.d.ts.map +1 -0
  207. package/sensor/dist/extraction/languages/ruby.js +149 -0
  208. package/sensor/dist/extraction/languages/ruby.js.map +1 -0
  209. package/sensor/dist/extraction/languages/rust.d.ts +3 -0
  210. package/sensor/dist/extraction/languages/rust.d.ts.map +1 -0
  211. package/sensor/dist/extraction/languages/rust.js +142 -0
  212. package/sensor/dist/extraction/languages/rust.js.map +1 -0
  213. package/sensor/dist/extraction/languages/scala.d.ts +3 -0
  214. package/sensor/dist/extraction/languages/scala.d.ts.map +1 -0
  215. package/sensor/dist/extraction/languages/scala.js +209 -0
  216. package/sensor/dist/extraction/languages/scala.js.map +1 -0
  217. package/sensor/dist/extraction/languages/solidity.d.ts +3 -0
  218. package/sensor/dist/extraction/languages/solidity.d.ts.map +1 -0
  219. package/sensor/dist/extraction/languages/solidity.js +293 -0
  220. package/sensor/dist/extraction/languages/solidity.js.map +1 -0
  221. package/sensor/dist/extraction/languages/swift.d.ts +3 -0
  222. package/sensor/dist/extraction/languages/swift.d.ts.map +1 -0
  223. package/sensor/dist/extraction/languages/swift.js +152 -0
  224. package/sensor/dist/extraction/languages/swift.js.map +1 -0
  225. package/sensor/dist/extraction/languages/terraform.d.ts +3 -0
  226. package/sensor/dist/extraction/languages/terraform.d.ts.map +1 -0
  227. package/sensor/dist/extraction/languages/terraform.js +641 -0
  228. package/sensor/dist/extraction/languages/terraform.js.map +1 -0
  229. package/sensor/dist/extraction/languages/typescript.d.ts +16 -0
  230. package/sensor/dist/extraction/languages/typescript.d.ts.map +1 -0
  231. package/sensor/dist/extraction/languages/typescript.js +167 -0
  232. package/sensor/dist/extraction/languages/typescript.js.map +1 -0
  233. package/sensor/dist/extraction/languages/vbnet.d.ts +11 -0
  234. package/sensor/dist/extraction/languages/vbnet.d.ts.map +1 -0
  235. package/sensor/dist/extraction/languages/vbnet.js +141 -0
  236. package/sensor/dist/extraction/languages/vbnet.js.map +1 -0
  237. package/sensor/dist/extraction/liquid-extractor.d.ts +59 -0
  238. package/sensor/dist/extraction/liquid-extractor.d.ts.map +1 -0
  239. package/sensor/dist/extraction/liquid-extractor.js +357 -0
  240. package/sensor/dist/extraction/liquid-extractor.js.map +1 -0
  241. package/sensor/dist/extraction/mybatis-extractor.d.ts +68 -0
  242. package/sensor/dist/extraction/mybatis-extractor.d.ts.map +1 -0
  243. package/sensor/dist/extraction/mybatis-extractor.js +300 -0
  244. package/sensor/dist/extraction/mybatis-extractor.js.map +1 -0
  245. package/sensor/dist/extraction/parse-pool.d.ts +160 -0
  246. package/sensor/dist/extraction/parse-pool.d.ts.map +1 -0
  247. package/sensor/dist/extraction/parse-pool.js +402 -0
  248. package/sensor/dist/extraction/parse-pool.js.map +1 -0
  249. package/sensor/dist/extraction/parse-worker.d.ts +8 -0
  250. package/sensor/dist/extraction/parse-worker.d.ts.map +1 -0
  251. package/sensor/dist/extraction/parse-worker.js +138 -0
  252. package/sensor/dist/extraction/parse-worker.js.map +1 -0
  253. package/sensor/dist/extraction/razor-extractor.d.ts +42 -0
  254. package/sensor/dist/extraction/razor-extractor.d.ts.map +1 -0
  255. package/sensor/dist/extraction/razor-extractor.js +285 -0
  256. package/sensor/dist/extraction/razor-extractor.js.map +1 -0
  257. package/sensor/dist/extraction/spawn-invokes.d.ts +101 -0
  258. package/sensor/dist/extraction/spawn-invokes.d.ts.map +1 -0
  259. package/sensor/dist/extraction/spawn-invokes.js +327 -0
  260. package/sensor/dist/extraction/spawn-invokes.js.map +1 -0
  261. package/sensor/dist/extraction/store-worker.d.ts +24 -0
  262. package/sensor/dist/extraction/store-worker.d.ts.map +1 -0
  263. package/sensor/dist/extraction/store-worker.js +110 -0
  264. package/sensor/dist/extraction/store-worker.js.map +1 -0
  265. package/sensor/dist/extraction/store-writer.d.ts +63 -0
  266. package/sensor/dist/extraction/store-writer.d.ts.map +1 -0
  267. package/sensor/dist/extraction/store-writer.js +174 -0
  268. package/sensor/dist/extraction/store-writer.js.map +1 -0
  269. package/sensor/dist/extraction/svelte-extractor.d.ts +56 -0
  270. package/sensor/dist/extraction/svelte-extractor.d.ts.map +1 -0
  271. package/sensor/dist/extraction/svelte-extractor.js +275 -0
  272. package/sensor/dist/extraction/svelte-extractor.js.map +1 -0
  273. package/sensor/dist/extraction/tree-sitter-helpers.d.ts +28 -0
  274. package/sensor/dist/extraction/tree-sitter-helpers.d.ts.map +1 -0
  275. package/sensor/dist/extraction/tree-sitter-helpers.js +153 -0
  276. package/sensor/dist/extraction/tree-sitter-helpers.js.map +1 -0
  277. package/sensor/dist/extraction/tree-sitter-types.d.ts +258 -0
  278. package/sensor/dist/extraction/tree-sitter-types.d.ts.map +1 -0
  279. package/sensor/dist/extraction/tree-sitter-types.js +10 -0
  280. package/sensor/dist/extraction/tree-sitter-types.js.map +1 -0
  281. package/sensor/dist/extraction/tree-sitter.d.ts +724 -0
  282. package/sensor/dist/extraction/tree-sitter.d.ts.map +1 -0
  283. package/sensor/dist/extraction/tree-sitter.js +6721 -0
  284. package/sensor/dist/extraction/tree-sitter.js.map +1 -0
  285. package/sensor/dist/extraction/vue-extractor.d.ts +51 -0
  286. package/sensor/dist/extraction/vue-extractor.d.ts.map +1 -0
  287. package/sensor/dist/extraction/vue-extractor.js +254 -0
  288. package/sensor/dist/extraction/vue-extractor.js.map +1 -0
  289. package/sensor/dist/extraction/wasm/tree-sitter-arkts.wasm +0 -0
  290. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  291. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  292. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  293. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  294. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  295. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  296. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  297. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  298. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  299. package/sensor/dist/extraction/wasm/tree-sitter-lua.wasm +0 -0
  300. package/sensor/dist/extraction/wasm/tree-sitter-luau.wasm +0 -0
  301. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  302. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  303. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  304. package/sensor/dist/extraction/wasm/tree-sitter-r.wasm +0 -0
  305. package/sensor/dist/extraction/wasm/tree-sitter-scala.wasm +0 -0
  306. package/sensor/dist/extraction/wasm/tree-sitter-terraform.wasm +0 -0
  307. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  308. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  309. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  310. package/sensor/dist/extraction/wasm-runtime-flags.d.ts +53 -0
  311. package/sensor/dist/extraction/wasm-runtime-flags.d.ts.map +1 -0
  312. package/sensor/dist/extraction/wasm-runtime-flags.js +130 -0
  313. package/sensor/dist/extraction/wasm-runtime-flags.js.map +1 -0
  314. package/sensor/dist/graph/index.d.ts +8 -0
  315. package/sensor/dist/graph/index.d.ts.map +1 -0
  316. package/sensor/dist/graph/index.js +13 -0
  317. package/sensor/dist/graph/index.js.map +1 -0
  318. package/sensor/dist/graph/queries.d.ts +106 -0
  319. package/sensor/dist/graph/queries.d.ts.map +1 -0
  320. package/sensor/dist/graph/queries.js +339 -0
  321. package/sensor/dist/graph/queries.js.map +1 -0
  322. package/sensor/dist/graph/traversal.d.ts +127 -0
  323. package/sensor/dist/graph/traversal.d.ts.map +1 -0
  324. package/sensor/dist/graph/traversal.js +599 -0
  325. package/sensor/dist/graph/traversal.js.map +1 -0
  326. package/sensor/dist/index.d.ts +653 -0
  327. package/sensor/dist/index.d.ts.map +1 -0
  328. package/sensor/dist/index.js +1646 -0
  329. package/sensor/dist/index.js.map +1 -0
  330. package/sensor/dist/installer/beta-signup.d.ts +54 -0
  331. package/sensor/dist/installer/beta-signup.d.ts.map +1 -0
  332. package/sensor/dist/installer/beta-signup.js +178 -0
  333. package/sensor/dist/installer/beta-signup.js.map +1 -0
  334. package/sensor/dist/installer/config-writer.d.ts +28 -0
  335. package/sensor/dist/installer/config-writer.d.ts.map +1 -0
  336. package/sensor/dist/installer/config-writer.js +91 -0
  337. package/sensor/dist/installer/config-writer.js.map +1 -0
  338. package/sensor/dist/installer/index.d.ts +142 -0
  339. package/sensor/dist/installer/index.d.ts.map +1 -0
  340. package/sensor/dist/installer/index.js +622 -0
  341. package/sensor/dist/installer/index.js.map +1 -0
  342. package/sensor/dist/installer/instructions-template.d.ts +41 -0
  343. package/sensor/dist/installer/instructions-template.d.ts.map +1 -0
  344. package/sensor/dist/installer/instructions-template.js +53 -0
  345. package/sensor/dist/installer/instructions-template.js.map +1 -0
  346. package/sensor/dist/installer/targets/antigravity.d.ts +57 -0
  347. package/sensor/dist/installer/targets/antigravity.d.ts.map +1 -0
  348. package/sensor/dist/installer/targets/antigravity.js +308 -0
  349. package/sensor/dist/installer/targets/antigravity.js.map +1 -0
  350. package/sensor/dist/installer/targets/claude.d.ts +62 -0
  351. package/sensor/dist/installer/targets/claude.d.ts.map +1 -0
  352. package/sensor/dist/installer/targets/claude.js +454 -0
  353. package/sensor/dist/installer/targets/claude.js.map +1 -0
  354. package/sensor/dist/installer/targets/codex.d.ts +18 -0
  355. package/sensor/dist/installer/targets/codex.d.ts.map +1 -0
  356. package/sensor/dist/installer/targets/codex.js +185 -0
  357. package/sensor/dist/installer/targets/codex.js.map +1 -0
  358. package/sensor/dist/installer/targets/cursor.d.ts +35 -0
  359. package/sensor/dist/installer/targets/cursor.d.ts.map +1 -0
  360. package/sensor/dist/installer/targets/cursor.js +254 -0
  361. package/sensor/dist/installer/targets/cursor.js.map +1 -0
  362. package/sensor/dist/installer/targets/gemini.d.ts +26 -0
  363. package/sensor/dist/installer/targets/gemini.d.ts.map +1 -0
  364. package/sensor/dist/installer/targets/gemini.js +165 -0
  365. package/sensor/dist/installer/targets/gemini.js.map +1 -0
  366. package/sensor/dist/installer/targets/hermes.d.ts +18 -0
  367. package/sensor/dist/installer/targets/hermes.d.ts.map +1 -0
  368. package/sensor/dist/installer/targets/hermes.js +359 -0
  369. package/sensor/dist/installer/targets/hermes.js.map +1 -0
  370. package/sensor/dist/installer/targets/kiro.d.ts +27 -0
  371. package/sensor/dist/installer/targets/kiro.d.ts.map +1 -0
  372. package/sensor/dist/installer/targets/kiro.js +178 -0
  373. package/sensor/dist/installer/targets/kiro.js.map +1 -0
  374. package/sensor/dist/installer/targets/opencode.d.ts +38 -0
  375. package/sensor/dist/installer/targets/opencode.d.ts.map +1 -0
  376. package/sensor/dist/installer/targets/opencode.js +288 -0
  377. package/sensor/dist/installer/targets/opencode.js.map +1 -0
  378. package/sensor/dist/installer/targets/registry.d.ts +35 -0
  379. package/sensor/dist/installer/targets/registry.d.ts.map +1 -0
  380. package/sensor/dist/installer/targets/registry.js +91 -0
  381. package/sensor/dist/installer/targets/registry.js.map +1 -0
  382. package/sensor/dist/installer/targets/shared.d.ts +101 -0
  383. package/sensor/dist/installer/targets/shared.d.ts.map +1 -0
  384. package/sensor/dist/installer/targets/shared.js +264 -0
  385. package/sensor/dist/installer/targets/shared.js.map +1 -0
  386. package/sensor/dist/installer/targets/toml.d.ts +52 -0
  387. package/sensor/dist/installer/targets/toml.d.ts.map +1 -0
  388. package/sensor/dist/installer/targets/toml.js +147 -0
  389. package/sensor/dist/installer/targets/toml.js.map +1 -0
  390. package/sensor/dist/installer/targets/types.d.ts +108 -0
  391. package/sensor/dist/installer/targets/types.d.ts.map +1 -0
  392. package/sensor/dist/installer/targets/types.js +16 -0
  393. package/sensor/dist/installer/targets/types.js.map +1 -0
  394. package/sensor/dist/mcp/daemon-manager.d.ts +42 -0
  395. package/sensor/dist/mcp/daemon-manager.d.ts.map +1 -0
  396. package/sensor/dist/mcp/daemon-manager.js +129 -0
  397. package/sensor/dist/mcp/daemon-manager.js.map +1 -0
  398. package/sensor/dist/mcp/daemon-paths.d.ts +73 -0
  399. package/sensor/dist/mcp/daemon-paths.d.ts.map +1 -0
  400. package/sensor/dist/mcp/daemon-paths.js +170 -0
  401. package/sensor/dist/mcp/daemon-paths.js.map +1 -0
  402. package/sensor/dist/mcp/daemon-registry.d.ts +54 -0
  403. package/sensor/dist/mcp/daemon-registry.d.ts.map +1 -0
  404. package/sensor/dist/mcp/daemon-registry.js +241 -0
  405. package/sensor/dist/mcp/daemon-registry.js.map +1 -0
  406. package/sensor/dist/mcp/daemon.d.ts +290 -0
  407. package/sensor/dist/mcp/daemon.d.ts.map +1 -0
  408. package/sensor/dist/mcp/daemon.js +867 -0
  409. package/sensor/dist/mcp/daemon.js.map +1 -0
  410. package/sensor/dist/mcp/dynamic-boundaries.d.ts +41 -0
  411. package/sensor/dist/mcp/dynamic-boundaries.d.ts.map +1 -0
  412. package/sensor/dist/mcp/dynamic-boundaries.js +360 -0
  413. package/sensor/dist/mcp/dynamic-boundaries.js.map +1 -0
  414. package/sensor/dist/mcp/early-ppid.d.ts +26 -0
  415. package/sensor/dist/mcp/early-ppid.d.ts.map +1 -0
  416. package/sensor/dist/mcp/early-ppid.js +29 -0
  417. package/sensor/dist/mcp/early-ppid.js.map +1 -0
  418. package/sensor/dist/mcp/engine.d.ts +130 -0
  419. package/sensor/dist/mcp/engine.d.ts.map +1 -0
  420. package/sensor/dist/mcp/engine.js +375 -0
  421. package/sensor/dist/mcp/engine.js.map +1 -0
  422. package/sensor/dist/mcp/index.d.ts +113 -0
  423. package/sensor/dist/mcp/index.d.ts.map +1 -0
  424. package/sensor/dist/mcp/index.js +551 -0
  425. package/sensor/dist/mcp/index.js.map +1 -0
  426. package/sensor/dist/mcp/liveness-watchdog.d.ts +35 -0
  427. package/sensor/dist/mcp/liveness-watchdog.d.ts.map +1 -0
  428. package/sensor/dist/mcp/liveness-watchdog.js +267 -0
  429. package/sensor/dist/mcp/liveness-watchdog.js.map +1 -0
  430. package/sensor/dist/mcp/ppid-watchdog.d.ts +62 -0
  431. package/sensor/dist/mcp/ppid-watchdog.d.ts.map +1 -0
  432. package/sensor/dist/mcp/ppid-watchdog.js +64 -0
  433. package/sensor/dist/mcp/ppid-watchdog.js.map +1 -0
  434. package/sensor/dist/mcp/proxy.d.ts +99 -0
  435. package/sensor/dist/mcp/proxy.d.ts.map +1 -0
  436. package/sensor/dist/mcp/proxy.js +737 -0
  437. package/sensor/dist/mcp/proxy.js.map +1 -0
  438. package/sensor/dist/mcp/query-pool.d.ts +108 -0
  439. package/sensor/dist/mcp/query-pool.d.ts.map +1 -0
  440. package/sensor/dist/mcp/query-pool.js +315 -0
  441. package/sensor/dist/mcp/query-pool.js.map +1 -0
  442. package/sensor/dist/mcp/query-worker.d.ts +24 -0
  443. package/sensor/dist/mcp/query-worker.d.ts.map +1 -0
  444. package/sensor/dist/mcp/query-worker.js +87 -0
  445. package/sensor/dist/mcp/query-worker.js.map +1 -0
  446. package/sensor/dist/mcp/server-instructions.d.ts +34 -0
  447. package/sensor/dist/mcp/server-instructions.d.ts.map +1 -0
  448. package/sensor/dist/mcp/server-instructions.js +106 -0
  449. package/sensor/dist/mcp/server-instructions.js.map +1 -0
  450. package/sensor/dist/mcp/session.d.ts +92 -0
  451. package/sensor/dist/mcp/session.d.ts.map +1 -0
  452. package/sensor/dist/mcp/session.js +355 -0
  453. package/sensor/dist/mcp/session.js.map +1 -0
  454. package/sensor/dist/mcp/startup-handshake.d.ts +44 -0
  455. package/sensor/dist/mcp/startup-handshake.d.ts.map +1 -0
  456. package/sensor/dist/mcp/startup-handshake.js +73 -0
  457. package/sensor/dist/mcp/startup-handshake.js.map +1 -0
  458. package/sensor/dist/mcp/stdin-teardown.d.ts +27 -0
  459. package/sensor/dist/mcp/stdin-teardown.d.ts.map +1 -0
  460. package/sensor/dist/mcp/stdin-teardown.js +49 -0
  461. package/sensor/dist/mcp/stdin-teardown.js.map +1 -0
  462. package/sensor/dist/mcp/tools.d.ts +651 -0
  463. package/sensor/dist/mcp/tools.d.ts.map +1 -0
  464. package/sensor/dist/mcp/tools.js +4574 -0
  465. package/sensor/dist/mcp/tools.js.map +1 -0
  466. package/sensor/dist/mcp/transport.d.ts +188 -0
  467. package/sensor/dist/mcp/transport.d.ts.map +1 -0
  468. package/sensor/dist/mcp/transport.js +377 -0
  469. package/sensor/dist/mcp/transport.js.map +1 -0
  470. package/sensor/dist/mcp/version.d.ts +30 -0
  471. package/sensor/dist/mcp/version.d.ts.map +1 -0
  472. package/sensor/dist/mcp/version.js +85 -0
  473. package/sensor/dist/mcp/version.js.map +1 -0
  474. package/sensor/dist/project-config.d.ts +94 -0
  475. package/sensor/dist/project-config.d.ts.map +1 -0
  476. package/sensor/dist/project-config.js +374 -0
  477. package/sensor/dist/project-config.js.map +1 -0
  478. package/sensor/dist/resolution/c-fnptr-synthesizer.d.ts +6 -0
  479. package/sensor/dist/resolution/c-fnptr-synthesizer.d.ts.map +1 -0
  480. package/sensor/dist/resolution/c-fnptr-synthesizer.js +1070 -0
  481. package/sensor/dist/resolution/c-fnptr-synthesizer.js.map +1 -0
  482. package/sensor/dist/resolution/callback-synthesizer.d.ts +59 -0
  483. package/sensor/dist/resolution/callback-synthesizer.d.ts.map +1 -0
  484. package/sensor/dist/resolution/callback-synthesizer.js +3961 -0
  485. package/sensor/dist/resolution/callback-synthesizer.js.map +1 -0
  486. package/sensor/dist/resolution/cooperative-yield.d.ts +40 -0
  487. package/sensor/dist/resolution/cooperative-yield.d.ts.map +1 -0
  488. package/sensor/dist/resolution/cooperative-yield.js +44 -0
  489. package/sensor/dist/resolution/cooperative-yield.js.map +1 -0
  490. package/sensor/dist/resolution/frameworks/astro.d.ts +9 -0
  491. package/sensor/dist/resolution/frameworks/astro.d.ts.map +1 -0
  492. package/sensor/dist/resolution/frameworks/astro.js +169 -0
  493. package/sensor/dist/resolution/frameworks/astro.js.map +1 -0
  494. package/sensor/dist/resolution/frameworks/cargo-workspace.d.ts +18 -0
  495. package/sensor/dist/resolution/frameworks/cargo-workspace.d.ts.map +1 -0
  496. package/sensor/dist/resolution/frameworks/cargo-workspace.js +225 -0
  497. package/sensor/dist/resolution/frameworks/cargo-workspace.js.map +1 -0
  498. package/sensor/dist/resolution/frameworks/cics.d.ts +20 -0
  499. package/sensor/dist/resolution/frameworks/cics.d.ts.map +1 -0
  500. package/sensor/dist/resolution/frameworks/cics.js +90 -0
  501. package/sensor/dist/resolution/frameworks/cics.js.map +1 -0
  502. package/sensor/dist/resolution/frameworks/csharp.d.ts +8 -0
  503. package/sensor/dist/resolution/frameworks/csharp.d.ts.map +1 -0
  504. package/sensor/dist/resolution/frameworks/csharp.js +241 -0
  505. package/sensor/dist/resolution/frameworks/csharp.js.map +1 -0
  506. package/sensor/dist/resolution/frameworks/drupal.d.ts +51 -0
  507. package/sensor/dist/resolution/frameworks/drupal.d.ts.map +1 -0
  508. package/sensor/dist/resolution/frameworks/drupal.js +367 -0
  509. package/sensor/dist/resolution/frameworks/drupal.js.map +1 -0
  510. package/sensor/dist/resolution/frameworks/expo-modules.d.ts +3 -0
  511. package/sensor/dist/resolution/frameworks/expo-modules.d.ts.map +1 -0
  512. package/sensor/dist/resolution/frameworks/expo-modules.js +148 -0
  513. package/sensor/dist/resolution/frameworks/expo-modules.js.map +1 -0
  514. package/sensor/dist/resolution/frameworks/express.d.ts +8 -0
  515. package/sensor/dist/resolution/frameworks/express.d.ts.map +1 -0
  516. package/sensor/dist/resolution/frameworks/express.js +308 -0
  517. package/sensor/dist/resolution/frameworks/express.js.map +1 -0
  518. package/sensor/dist/resolution/frameworks/fabric.d.ts +3 -0
  519. package/sensor/dist/resolution/frameworks/fabric.d.ts.map +1 -0
  520. package/sensor/dist/resolution/frameworks/fabric.js +354 -0
  521. package/sensor/dist/resolution/frameworks/fabric.js.map +1 -0
  522. package/sensor/dist/resolution/frameworks/go.d.ts +8 -0
  523. package/sensor/dist/resolution/frameworks/go.d.ts.map +1 -0
  524. package/sensor/dist/resolution/frameworks/go.js +184 -0
  525. package/sensor/dist/resolution/frameworks/go.js.map +1 -0
  526. package/sensor/dist/resolution/frameworks/goframe.d.ts +41 -0
  527. package/sensor/dist/resolution/frameworks/goframe.d.ts.map +1 -0
  528. package/sensor/dist/resolution/frameworks/goframe.js +112 -0
  529. package/sensor/dist/resolution/frameworks/goframe.js.map +1 -0
  530. package/sensor/dist/resolution/frameworks/index.d.ts +50 -0
  531. package/sensor/dist/resolution/frameworks/index.d.ts.map +1 -0
  532. package/sensor/dist/resolution/frameworks/index.js +175 -0
  533. package/sensor/dist/resolution/frameworks/index.js.map +1 -0
  534. package/sensor/dist/resolution/frameworks/java.d.ts +8 -0
  535. package/sensor/dist/resolution/frameworks/java.d.ts.map +1 -0
  536. package/sensor/dist/resolution/frameworks/java.js +517 -0
  537. package/sensor/dist/resolution/frameworks/java.js.map +1 -0
  538. package/sensor/dist/resolution/frameworks/laravel.d.ts +13 -0
  539. package/sensor/dist/resolution/frameworks/laravel.d.ts.map +1 -0
  540. package/sensor/dist/resolution/frameworks/laravel.js +257 -0
  541. package/sensor/dist/resolution/frameworks/laravel.js.map +1 -0
  542. package/sensor/dist/resolution/frameworks/nestjs.d.ts +26 -0
  543. package/sensor/dist/resolution/frameworks/nestjs.d.ts.map +1 -0
  544. package/sensor/dist/resolution/frameworks/nestjs.js +698 -0
  545. package/sensor/dist/resolution/frameworks/nestjs.js.map +1 -0
  546. package/sensor/dist/resolution/frameworks/play.d.ts +19 -0
  547. package/sensor/dist/resolution/frameworks/play.d.ts.map +1 -0
  548. package/sensor/dist/resolution/frameworks/play.js +111 -0
  549. package/sensor/dist/resolution/frameworks/play.js.map +1 -0
  550. package/sensor/dist/resolution/frameworks/python.d.ts +10 -0
  551. package/sensor/dist/resolution/frameworks/python.d.ts.map +1 -0
  552. package/sensor/dist/resolution/frameworks/python.js +400 -0
  553. package/sensor/dist/resolution/frameworks/python.js.map +1 -0
  554. package/sensor/dist/resolution/frameworks/react-native.d.ts +3 -0
  555. package/sensor/dist/resolution/frameworks/react-native.d.ts.map +1 -0
  556. package/sensor/dist/resolution/frameworks/react-native.js +410 -0
  557. package/sensor/dist/resolution/frameworks/react-native.js.map +1 -0
  558. package/sensor/dist/resolution/frameworks/react.d.ts +8 -0
  559. package/sensor/dist/resolution/frameworks/react.d.ts.map +1 -0
  560. package/sensor/dist/resolution/frameworks/react.js +334 -0
  561. package/sensor/dist/resolution/frameworks/react.js.map +1 -0
  562. package/sensor/dist/resolution/frameworks/ruby.d.ts +8 -0
  563. package/sensor/dist/resolution/frameworks/ruby.d.ts.map +1 -0
  564. package/sensor/dist/resolution/frameworks/ruby.js +302 -0
  565. package/sensor/dist/resolution/frameworks/ruby.js.map +1 -0
  566. package/sensor/dist/resolution/frameworks/rust.d.ts +8 -0
  567. package/sensor/dist/resolution/frameworks/rust.d.ts.map +1 -0
  568. package/sensor/dist/resolution/frameworks/rust.js +304 -0
  569. package/sensor/dist/resolution/frameworks/rust.js.map +1 -0
  570. package/sensor/dist/resolution/frameworks/svelte.d.ts +9 -0
  571. package/sensor/dist/resolution/frameworks/svelte.d.ts.map +1 -0
  572. package/sensor/dist/resolution/frameworks/svelte.js +253 -0
  573. package/sensor/dist/resolution/frameworks/svelte.js.map +1 -0
  574. package/sensor/dist/resolution/frameworks/swift-objc.d.ts +37 -0
  575. package/sensor/dist/resolution/frameworks/swift-objc.d.ts.map +1 -0
  576. package/sensor/dist/resolution/frameworks/swift-objc.js +252 -0
  577. package/sensor/dist/resolution/frameworks/swift-objc.js.map +1 -0
  578. package/sensor/dist/resolution/frameworks/swift.d.ts +10 -0
  579. package/sensor/dist/resolution/frameworks/swift.d.ts.map +1 -0
  580. package/sensor/dist/resolution/frameworks/swift.js +400 -0
  581. package/sensor/dist/resolution/frameworks/swift.js.map +1 -0
  582. package/sensor/dist/resolution/frameworks/terraform.d.ts +38 -0
  583. package/sensor/dist/resolution/frameworks/terraform.d.ts.map +1 -0
  584. package/sensor/dist/resolution/frameworks/terraform.js +277 -0
  585. package/sensor/dist/resolution/frameworks/terraform.js.map +1 -0
  586. package/sensor/dist/resolution/frameworks/vue.d.ts +9 -0
  587. package/sensor/dist/resolution/frameworks/vue.d.ts.map +1 -0
  588. package/sensor/dist/resolution/frameworks/vue.js +303 -0
  589. package/sensor/dist/resolution/frameworks/vue.js.map +1 -0
  590. package/sensor/dist/resolution/go-module.d.ts +26 -0
  591. package/sensor/dist/resolution/go-module.d.ts.map +1 -0
  592. package/sensor/dist/resolution/go-module.js +78 -0
  593. package/sensor/dist/resolution/go-module.js.map +1 -0
  594. package/sensor/dist/resolution/goframe-synthesizer.d.ts +29 -0
  595. package/sensor/dist/resolution/goframe-synthesizer.d.ts.map +1 -0
  596. package/sensor/dist/resolution/goframe-synthesizer.js +163 -0
  597. package/sensor/dist/resolution/goframe-synthesizer.js.map +1 -0
  598. package/sensor/dist/resolution/import-resolver.d.ts +84 -0
  599. package/sensor/dist/resolution/import-resolver.d.ts.map +1 -0
  600. package/sensor/dist/resolution/import-resolver.js +2172 -0
  601. package/sensor/dist/resolution/import-resolver.js.map +1 -0
  602. package/sensor/dist/resolution/index.d.ts +309 -0
  603. package/sensor/dist/resolution/index.d.ts.map +1 -0
  604. package/sensor/dist/resolution/index.js +2050 -0
  605. package/sensor/dist/resolution/index.js.map +1 -0
  606. package/sensor/dist/resolution/lru-cache.d.ts +24 -0
  607. package/sensor/dist/resolution/lru-cache.d.ts.map +1 -0
  608. package/sensor/dist/resolution/lru-cache.js +62 -0
  609. package/sensor/dist/resolution/lru-cache.js.map +1 -0
  610. package/sensor/dist/resolution/name-matcher.d.ts +126 -0
  611. package/sensor/dist/resolution/name-matcher.d.ts.map +1 -0
  612. package/sensor/dist/resolution/name-matcher.js +1968 -0
  613. package/sensor/dist/resolution/name-matcher.js.map +1 -0
  614. package/sensor/dist/resolution/path-aliases.d.ts +68 -0
  615. package/sensor/dist/resolution/path-aliases.d.ts.map +1 -0
  616. package/sensor/dist/resolution/path-aliases.js +238 -0
  617. package/sensor/dist/resolution/path-aliases.js.map +1 -0
  618. package/sensor/dist/resolution/resolver-pool.d.ts +66 -0
  619. package/sensor/dist/resolution/resolver-pool.d.ts.map +1 -0
  620. package/sensor/dist/resolution/resolver-pool.js +244 -0
  621. package/sensor/dist/resolution/resolver-pool.js.map +1 -0
  622. package/sensor/dist/resolution/resolver-worker.d.ts +17 -0
  623. package/sensor/dist/resolution/resolver-worker.d.ts.map +1 -0
  624. package/sensor/dist/resolution/resolver-worker.js +114 -0
  625. package/sensor/dist/resolution/resolver-worker.js.map +1 -0
  626. package/sensor/dist/resolution/strip-comments.d.ts +27 -0
  627. package/sensor/dist/resolution/strip-comments.d.ts.map +1 -0
  628. package/sensor/dist/resolution/strip-comments.js +492 -0
  629. package/sensor/dist/resolution/strip-comments.js.map +1 -0
  630. package/sensor/dist/resolution/swift-objc-bridge.d.ts +134 -0
  631. package/sensor/dist/resolution/swift-objc-bridge.d.ts.map +1 -0
  632. package/sensor/dist/resolution/swift-objc-bridge.js +256 -0
  633. package/sensor/dist/resolution/swift-objc-bridge.js.map +1 -0
  634. package/sensor/dist/resolution/types.d.ts +265 -0
  635. package/sensor/dist/resolution/types.d.ts.map +1 -0
  636. package/sensor/dist/resolution/types.js +8 -0
  637. package/sensor/dist/resolution/types.js.map +1 -0
  638. package/sensor/dist/resolution/workspace-packages.d.ts +58 -0
  639. package/sensor/dist/resolution/workspace-packages.d.ts.map +1 -0
  640. package/sensor/dist/resolution/workspace-packages.js +346 -0
  641. package/sensor/dist/resolution/workspace-packages.js.map +1 -0
  642. package/sensor/dist/search/identifier-segments.d.ts +60 -0
  643. package/sensor/dist/search/identifier-segments.d.ts.map +1 -0
  644. package/sensor/dist/search/identifier-segments.js +176 -0
  645. package/sensor/dist/search/identifier-segments.js.map +1 -0
  646. package/sensor/dist/search/query-parser.d.ts +57 -0
  647. package/sensor/dist/search/query-parser.d.ts.map +1 -0
  648. package/sensor/dist/search/query-parser.js +177 -0
  649. package/sensor/dist/search/query-parser.js.map +1 -0
  650. package/sensor/dist/search/query-utils.d.ts +87 -0
  651. package/sensor/dist/search/query-utils.d.ts.map +1 -0
  652. package/sensor/dist/search/query-utils.js +449 -0
  653. package/sensor/dist/search/query-utils.js.map +1 -0
  654. package/sensor/dist/sync/git-hooks.d.ts +45 -0
  655. package/sensor/dist/sync/git-hooks.d.ts.map +1 -0
  656. package/sensor/dist/sync/git-hooks.js +227 -0
  657. package/sensor/dist/sync/git-hooks.js.map +1 -0
  658. package/sensor/dist/sync/index.d.ts +19 -0
  659. package/sensor/dist/sync/index.d.ts.map +1 -0
  660. package/sensor/dist/sync/index.js +35 -0
  661. package/sensor/dist/sync/index.js.map +1 -0
  662. package/sensor/dist/sync/watch-policy.d.ts +48 -0
  663. package/sensor/dist/sync/watch-policy.d.ts.map +1 -0
  664. package/sensor/dist/sync/watch-policy.js +124 -0
  665. package/sensor/dist/sync/watch-policy.js.map +1 -0
  666. package/sensor/dist/sync/watcher.d.ts +372 -0
  667. package/sensor/dist/sync/watcher.d.ts.map +1 -0
  668. package/sensor/dist/sync/watcher.js +879 -0
  669. package/sensor/dist/sync/watcher.js.map +1 -0
  670. package/sensor/dist/sync/worktree.d.ts +63 -0
  671. package/sensor/dist/sync/worktree.d.ts.map +1 -0
  672. package/sensor/dist/sync/worktree.js +182 -0
  673. package/sensor/dist/sync/worktree.js.map +1 -0
  674. package/sensor/dist/telemetry/index.d.ts +157 -0
  675. package/sensor/dist/telemetry/index.d.ts.map +1 -0
  676. package/sensor/dist/telemetry/index.js +556 -0
  677. package/sensor/dist/telemetry/index.js.map +1 -0
  678. package/sensor/dist/types.d.ts +458 -0
  679. package/sensor/dist/types.d.ts.map +1 -0
  680. package/sensor/dist/types.js +117 -0
  681. package/sensor/dist/types.js.map +1 -0
  682. package/sensor/dist/ui/color.d.ts +21 -0
  683. package/sensor/dist/ui/color.d.ts.map +1 -0
  684. package/sensor/dist/ui/color.js +42 -0
  685. package/sensor/dist/ui/color.js.map +1 -0
  686. package/sensor/dist/ui/glyphs.d.ts +67 -0
  687. package/sensor/dist/ui/glyphs.d.ts.map +1 -0
  688. package/sensor/dist/ui/glyphs.js +125 -0
  689. package/sensor/dist/ui/glyphs.js.map +1 -0
  690. package/sensor/dist/ui/shimmer-progress.d.ts +11 -0
  691. package/sensor/dist/ui/shimmer-progress.d.ts.map +1 -0
  692. package/sensor/dist/ui/shimmer-progress.js +164 -0
  693. package/sensor/dist/ui/shimmer-progress.js.map +1 -0
  694. package/sensor/dist/ui/shimmer-worker.d.ts +2 -0
  695. package/sensor/dist/ui/shimmer-worker.d.ts.map +1 -0
  696. package/sensor/dist/ui/shimmer-worker.js +126 -0
  697. package/sensor/dist/ui/shimmer-worker.js.map +1 -0
  698. package/sensor/dist/ui/types.d.ts +15 -0
  699. package/sensor/dist/ui/types.d.ts.map +1 -0
  700. package/sensor/dist/ui/types.js +3 -0
  701. package/sensor/dist/ui/types.js.map +1 -0
  702. package/sensor/dist/upgrade/index.d.ts +171 -0
  703. package/sensor/dist/upgrade/index.d.ts.map +1 -0
  704. package/sensor/dist/upgrade/index.js +659 -0
  705. package/sensor/dist/upgrade/index.js.map +1 -0
  706. package/sensor/dist/upgrade/remove-binary.d.ts +87 -0
  707. package/sensor/dist/upgrade/remove-binary.d.ts.map +1 -0
  708. package/sensor/dist/upgrade/remove-binary.js +289 -0
  709. package/sensor/dist/upgrade/remove-binary.js.map +1 -0
  710. package/sensor/dist/upgrade/update-check.d.ts +92 -0
  711. package/sensor/dist/upgrade/update-check.d.ts.map +1 -0
  712. package/sensor/dist/upgrade/update-check.js +258 -0
  713. package/sensor/dist/upgrade/update-check.js.map +1 -0
  714. package/sensor/dist/utils.d.ts +224 -0
  715. package/sensor/dist/utils.d.ts.map +1 -0
  716. package/sensor/dist/utils.js +583 -0
  717. package/sensor/dist/utils.js.map +1 -0
  718. package/sensor/package.json +61 -0
  719. package/src/artifact-contracts-v2.mjs +325 -0
  720. package/src/artifact-contracts.mjs +895 -0
  721. package/src/bootstrap.mjs +88 -0
  722. package/src/boundary-compiler.mjs +712 -0
  723. package/src/boundary-observation-compiler-v2.mjs +339 -0
  724. package/src/codegraph-adapter.mjs +422 -0
  725. package/src/control-compiler.mjs +532 -0
  726. package/src/factory-diagnostics.mjs +187 -0
  727. package/src/isolation-runner.mjs +333 -0
  728. package/src/node-version-guard.mjs +43 -0
  729. package/src/rc1-black-box-oracle.mjs +906 -0
  730. package/src/rc1-comparison.mjs +154 -0
  731. package/src/rc1-evidence-bundle.mjs +456 -0
  732. package/src/rc1-v4-campaign.mjs +707 -0
  733. package/src/rc1-v4-transform.mjs +467 -0
  734. package/src/rc1-v5-artifact-set.mjs +855 -0
  735. package/src/rc1-v5-behavior-evidence.mjs +594 -0
  736. package/src/rc1-v5-campaign.mjs +796 -0
  737. package/src/rc1-v5-transform.mjs +421 -0
  738. package/src/rc1-v6-artifact-set.mjs +807 -0
  739. package/src/rc1-v6-behavior-evidence.mjs +273 -0
  740. package/src/rc1-v6-campaign.mjs +623 -0
  741. package/src/rc1-v6-causal-binding.mjs +471 -0
  742. package/src/rc1-v6-measurement.mjs +331 -0
  743. package/src/rc2-artifact-set.mjs +1579 -0
  744. package/src/rc2-campaign.mjs +1520 -0
  745. package/src/rc2-delivery-policy-front-end.mjs +1079 -0
  746. package/src/rc2-delivery-policy-oracle.mjs +134 -0
  747. package/src/rc2-delivery-policy-transform.mjs +1127 -0
  748. package/src/rc2-rc1-transfer-front-end.mjs +511 -0
  749. package/src/rc3-actual-dogfood.mjs +648 -0
  750. package/src/rc3-dogfood-scaffold.mjs +310 -0
  751. package/src/rc3-scripted-campaign.mjs +1301 -0
  752. package/src/rc4-stage1-dogfood.mjs +652 -0
  753. package/src/runtime-cli.mjs +504 -0
  754. package/src/runtime-contracts.mjs +631 -0
  755. package/src/runtime-decision-verifier.mjs +607 -0
  756. package/src/runtime-diff-observer.mjs +265 -0
  757. package/src/runtime-engine.mjs +674 -0
  758. package/src/runtime-errors.mjs +356 -0
  759. package/src/runtime-event-store.mjs +217 -0
  760. package/src/runtime-front-end.mjs +764 -0
  761. package/src/runtime-hold-recompile.mjs +606 -0
  762. package/src/runtime-projection.mjs +180 -0
  763. package/src/runtime-scripted-executor.mjs +163 -0
  764. package/src/runtime-worktree-executor.mjs +199 -0
  765. package/src/schedulability-compiler-v2.mjs +303 -0
  766. package/src/schedulability-verifier-v2.mjs +317 -0
  767. package/src/seam-transform.mjs +554 -0
  768. package/src/treatment-compiler.mjs +728 -0
  769. package/src/treatment-runner.mjs +658 -0
@@ -0,0 +1,4574 @@
1
+ "use strict";
2
+ /**
3
+ * MCP Tool Definitions
4
+ *
5
+ * Defines the tools exposed by the CodeGraph MCP server.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.ToolHandler = exports.tools = exports.IndexOpenError = exports.PathRefusalError = exports.NotIndexedError = void 0;
9
+ exports.normalizeQuerySpelling = normalizeQuerySpelling;
10
+ exports.getExploreBudget = getExploreBudget;
11
+ exports.getExploreOutputBudget = getExploreOutputBudget;
12
+ exports.formatStaleBanner = formatStaleBanner;
13
+ exports.formatStaleFooter = formatStaleFooter;
14
+ exports.formatDegradedBanner = formatDegradedBanner;
15
+ exports.getStaticTools = getStaticTools;
16
+ const directory_1 = require("../directory");
17
+ const version_1 = require("./version");
18
+ // Lazy-load the heavy CodeGraph chain off the MCP startup path — see the same
19
+ // helper in engine.ts. ToolHandler must load to answer tools/list (static
20
+ // schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
21
+ // CodeGraph is pulled in only when a tool actually opens a project. require() is
22
+ // sync + cached (CommonJS build).
23
+ const loadCodeGraph = () => require('../index').default;
24
+ const worktree_1 = require("../sync/worktree");
25
+ const query_utils_1 = require("../search/query-utils");
26
+ const fs_1 = require("fs");
27
+ const utils_1 = require("../utils");
28
+ const generated_detection_1 = require("../extraction/generated-detection");
29
+ const dynamic_boundaries_1 = require("./dynamic-boundaries");
30
+ /**
31
+ * An expected, recoverable "codegraph can't serve this" condition — most
32
+ * importantly a project with no index. The dispatch catch converts these to
33
+ * SUCCESS-shaped responses (guidance text, NO isError): an `isError: true`
34
+ * early in a session teaches the agent the toolset is broken and it stops
35
+ * calling codegraph entirely (observed repeatedly), which is exactly wrong
36
+ * for conditions the agent can simply work around (use built-in tools for
37
+ * that codebase / pass projectPath). isError is reserved for "stop trying"
38
+ * cases: security refusals ({@link PathRefusalError}) and genuine
39
+ * malfunctions.
40
+ */
41
+ class NotIndexedError extends Error {
42
+ }
43
+ exports.NotIndexedError = NotIndexedError;
44
+ /**
45
+ * A security refusal (sensitive system path). Stays `isError: true` WITHOUT
46
+ * retry guidance — abandoning this path is the desired agent reaction.
47
+ */
48
+ class PathRefusalError extends Error {
49
+ }
50
+ exports.PathRefusalError = PathRefusalError;
51
+ /**
52
+ * ADR 0049 Decision 5②: a genuine malfunction opening the default project's
53
+ * index — DB open failure, schema mismatch, integrity error, or lock
54
+ * contention — as OPPOSED to the project simply having no `.codegraph/` at
55
+ * all (that stays {@link NotIndexedError}, the success-shaped guidance case).
56
+ * This is fail-closed by design: it stays `isError: true` (via `execute`'s
57
+ * catch, same as {@link PathRefusalError}) rather than the NotIndexedError
58
+ * success-shaped guidance, because silently degrading to "no default
59
+ * project" here would hide a broken index behind text that reads as "just
60
+ * run `codegraph init`" — actively misleading for a project that WAS
61
+ * indexed and IS now broken.
62
+ */
63
+ class IndexOpenError extends Error {
64
+ }
65
+ exports.IndexOpenError = IndexOpenError;
66
+ const path_1 = require("path");
67
+ /** Maximum output length to prevent context bloat (characters) */
68
+ const MAX_OUTPUT_LENGTH = 15000;
69
+ /**
70
+ * Maximum length for free-form string inputs (query, task, symbol).
71
+ * Bounds memory and CPU when a buggy or hostile MCP client sends a
72
+ * huge payload — without this an attacker could ship a 100MB string
73
+ * and force a full FTS5 scan / OOM the server. 10 000 characters is
74
+ * far beyond any realistic legitimate query.
75
+ */
76
+ const MAX_INPUT_LENGTH = 10_000;
77
+ /**
78
+ * Maximum length for path-like string inputs (projectPath, path
79
+ * filter, glob pattern). Paths beyond a few thousand chars are
80
+ * never legitimate and signal abuse or a bug upstream.
81
+ */
82
+ const MAX_PATH_LENGTH = 4_096;
83
+ /**
84
+ * Rust path roots that have no file-system equivalent — `crate` is the
85
+ * current crate, `super` is the parent module, `self` is the current
86
+ * module. Used by `matchesSymbol` to strip these before file-path
87
+ * matching so `crate::configurator::stage_apply::run` resolves the
88
+ * same as `configurator::stage_apply::run`.
89
+ */
90
+ const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
91
+ /**
92
+ * Node kinds that contain other symbols. For these, `codegraph_node` with
93
+ * `includeCode=true` returns a structural outline (member names + signatures
94
+ * + line numbers) instead of the full body, which for a large class is a
95
+ * multi-thousand-character wall of source that bloats the agent's context.
96
+ */
97
+ const CONTAINER_NODE_KINDS = new Set([
98
+ 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
99
+ ]);
100
+ /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
101
+ function lastQualifierPart(symbol) {
102
+ const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
103
+ return parts[parts.length - 1] ?? symbol;
104
+ }
105
+ /**
106
+ * Normalize Erlang-native symbol spellings in an explore query into the shapes
107
+ * the rest of the pipeline already understands. Agents working Erlang code
108
+ * name symbols the way the language spells them — `mod:fn/3`, `init/2` — and
109
+ * those tokens previously died in both consumers: the flow-builder's token
110
+ * filter rejects `:` and `/arity` outright, and the search-side field parser
111
+ * eats `mod:fn` as an unknown `field:value`. Measured on cowboy: the agent
112
+ * named `cowboy_stream_h:request_process/3` in two queries, got no body back
113
+ * either time, and fell back to Read.
114
+ *
115
+ * - `fn/3` → `fn` (arity tail after an identifier; a path segment like
116
+ * `src/2fa` doesn't match because the tail must be all digits)
117
+ * - `mod:fn` → `mod.fn` (exactly one colon between identifiers, so it rides
118
+ * the existing Class.method qualified handling; `::`, URLs, drive letters,
119
+ * and times don't match, and the query language's own field prefixes —
120
+ * kind:/lang:/language:/path:/name: — are left alone)
121
+ *
122
+ * Safe cross-language: Lua's `t:m` spelling maps to the same `t.m` its
123
+ * qualified names use, and no other supported spelling contains a bare
124
+ * single-colon identifier pair.
125
+ */
126
+ function normalizeQuerySpelling(query) {
127
+ return query
128
+ .replace(/\b([A-Za-z_][\w@]*)\/(\d{1,3})(?=$|[\s,()[\]/])/g, '$1')
129
+ .replace(/(^|[\s,()[\]])(?!(?:kind|lang|language|path|name):)([a-z_][\w@]*):([A-Za-z_][\w@]*)(?=$|[\s,()[\]])/g, '$1$2.$3');
130
+ }
131
+ /**
132
+ * Calculate the recommended number of codegraph_explore calls based on project size.
133
+ * Larger codebases need more exploration calls to cover their surface area,
134
+ * but smaller ones should use fewer to avoid unnecessary overhead.
135
+ */
136
+ function getExploreBudget(fileCount) {
137
+ if (fileCount < 500)
138
+ return 1;
139
+ if (fileCount < 5000)
140
+ return 2;
141
+ if (fileCount < 15000)
142
+ return 3;
143
+ if (fileCount < 25000)
144
+ return 4;
145
+ return 5;
146
+ }
147
+ function getExploreOutputBudget(fileCount) {
148
+ // Tiered budget, scaled to project size. The budget is a CEILING (relevance
149
+ // still gates WHAT is included), and it MUST stay under the agent's INLINE
150
+ // tool-result cap (~25K chars). Above that, the host externalizes the result
151
+ // to a file the agent then Reads back — re-introducing a read AND the
152
+ // cache-write cost — which is exactly what a 35K vscode explore did in the
153
+ // n=4 README A/B. So even large repos cap at ~24K: the answer is the handful
154
+ // of ~100-line flow windows the agent would have grep-located and read (it
155
+ // natively reads ~6–9 files, median 100-line ranges), NOT a sprawl of 12
156
+ // files. Concentration onto the flow emerges from this cap + the named-file-
157
+ // first sort dropping peripheral files. Invariant: a larger tier must never
158
+ // get a smaller `maxCharsPerFile` than a smaller tier.
159
+ if (fileCount < 150) {
160
+ return {
161
+ // ITER3: revert iter2's aggressive body shrink (forced Read fallback —
162
+ // the per-file 2.5K cap pushed the agent to Read instead of node).
163
+ // Back to the iter1 shape (13K/4/3.8K) but keep the test-file
164
+ // hard-exclude. The cost lever for this tier lives in steering the
165
+ // agent to stop after 1-2 calls, not in this budget.
166
+ maxOutputChars: 13000,
167
+ defaultMaxFiles: 4,
168
+ maxCharsPerFile: 3800,
169
+ gapThreshold: 7,
170
+ maxSymbolsInFileHeader: 5,
171
+ maxEdgesPerRelationshipKind: 4,
172
+ includeRelationships: false,
173
+ includeAdditionalFiles: false,
174
+ includeCompletenessSignal: false,
175
+ includeBudgetNote: false,
176
+ excludeLowValueFiles: true,
177
+ };
178
+ }
179
+ if (fileCount < 500) {
180
+ return {
181
+ // ITER3: same revert/keep-filter pattern as <150.
182
+ maxOutputChars: 18000,
183
+ defaultMaxFiles: 5,
184
+ maxCharsPerFile: 3800,
185
+ gapThreshold: 8,
186
+ maxSymbolsInFileHeader: 6,
187
+ maxEdgesPerRelationshipKind: 6,
188
+ includeRelationships: false,
189
+ includeAdditionalFiles: false,
190
+ includeCompletenessSignal: false,
191
+ includeBudgetNote: false,
192
+ excludeLowValueFiles: true,
193
+ };
194
+ }
195
+ if (fileCount < 5000) {
196
+ return {
197
+ // ~150-line per-file window (the native read unit) × ~6 files, capped at
198
+ // the ~24K inline ceiling so the response is never externalized. Per-file
199
+ // stays ≥ the <500 tier (3800) — monotonic.
200
+ maxOutputChars: 24000,
201
+ defaultMaxFiles: 8,
202
+ maxCharsPerFile: 6500,
203
+ gapThreshold: 12,
204
+ maxSymbolsInFileHeader: 10,
205
+ maxEdgesPerRelationshipKind: 10,
206
+ includeRelationships: true,
207
+ includeAdditionalFiles: true,
208
+ includeCompletenessSignal: true,
209
+ includeBudgetNote: true,
210
+ excludeLowValueFiles: false,
211
+ };
212
+ }
213
+ // Large + very-large repos: SAME ~24K inline ceiling (a bigger response just
214
+ // externalizes — see vscode). More files indexed → more CALLS via
215
+ // getExploreBudget, not a bigger single response. Per-file 7000 (≥ smaller
216
+ // tiers) gives the central file a ~180-line orientation window.
217
+ if (fileCount < 15000) {
218
+ return {
219
+ maxOutputChars: 24000,
220
+ defaultMaxFiles: 8,
221
+ maxCharsPerFile: 7000,
222
+ gapThreshold: 15,
223
+ maxSymbolsInFileHeader: 15,
224
+ maxEdgesPerRelationshipKind: 15,
225
+ includeRelationships: true,
226
+ includeAdditionalFiles: true,
227
+ includeCompletenessSignal: true,
228
+ includeBudgetNote: true,
229
+ excludeLowValueFiles: false,
230
+ };
231
+ }
232
+ return {
233
+ maxOutputChars: 24000,
234
+ defaultMaxFiles: 8,
235
+ maxCharsPerFile: 7000,
236
+ gapThreshold: 15,
237
+ maxSymbolsInFileHeader: 15,
238
+ maxEdgesPerRelationshipKind: 15,
239
+ includeRelationships: true,
240
+ includeAdditionalFiles: true,
241
+ includeCompletenessSignal: true,
242
+ includeBudgetNote: true,
243
+ excludeLowValueFiles: false,
244
+ };
245
+ }
246
+ /**
247
+ * Whether `codegraph_explore` should prefix source lines with their line
248
+ * numbers (cat -n style: `<num>\t<code>`).
249
+ *
250
+ * Line numbers let the agent cite `file:line` straight from the explore
251
+ * payload instead of re-Reading the file just to find a line number — the
252
+ * dominant residual cost on precise-tracing questions (#185 follow-up).
253
+ *
254
+ * Defaults ON. Set `CODEGRAPH_EXPLORE_LINENUMS=0` to disable (used by the
255
+ * A/B harness to measure the payload-cost vs. read-savings tradeoff).
256
+ */
257
+ function exploreLineNumbersEnabled() {
258
+ return process.env.CODEGRAPH_EXPLORE_LINENUMS !== '0';
259
+ }
260
+ /**
261
+ * Adaptive explore sizing (default ON). `codegraph_explore` skeletonizes OFF-SPINE
262
+ * polymorphic-sibling files — a file whose class is one of ≥3 interchangeable
263
+ * implementations of a shared interface (e.g. OkHttp's `: Interceptor` classes) —
264
+ * to class + member signatures (bodies elided), keeping the on-spine exemplar full.
265
+ * This sizes the response to the answer instead of the budget cap on sibling-heavy
266
+ * flows (OkHttp interceptor-chain explore 28.5k→16.6k, ~28% cheaper than native
267
+ * search, reads flat). It is PROVABLY INERT elsewhere: distinct pipeline steps (no
268
+ * ≥3-implementer supertype, e.g. Excalidraw's `renderStaticScene`) and on-spine
269
+ * files keep full source — output is byte-identical to shipped on excalidraw /
270
+ * tokio / django / vscode / gin. Set `CODEGRAPH_ADAPTIVE_EXPLORE=0` to disable.
271
+ */
272
+ function adaptiveExploreEnabled() {
273
+ return process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== '0' && process.env.CODEGRAPH_ADAPTIVE_EXPLORE !== 'false';
274
+ }
275
+ /**
276
+ * How long the FIRST tool call waits on the post-open catch-up reconcile before
277
+ * giving up and serving anyway (issue #905). On a normal repo the reconcile
278
+ * finishes in well under this, so the gate is fully honored and nothing changes.
279
+ * On a very large repo (~100k files) the reconcile takes minutes — blocking the
280
+ * first call on all of it presents as a multi-minute hang — so we wait briefly
281
+ * for a clean answer, then serve and let the reconcile finish in the background
282
+ * (it yields to the event loop, so a concurrent read still runs).
283
+ *
284
+ * `CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS` overrides the default; `0` restores the
285
+ * old unbounded-wait behavior (always block until the reconcile completes).
286
+ */
287
+ const DEFAULT_CATCHUP_GATE_TIMEOUT_MS = 3000;
288
+ function resolveCatchUpGateTimeoutMs() {
289
+ const raw = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
290
+ if (raw === undefined || raw === '')
291
+ return DEFAULT_CATCHUP_GATE_TIMEOUT_MS;
292
+ const n = Number(raw);
293
+ if (!Number.isFinite(n) || n < 0)
294
+ return DEFAULT_CATCHUP_GATE_TIMEOUT_MS;
295
+ return Math.floor(n);
296
+ }
297
+ /**
298
+ * Prefix each line of a source slice with its 1-based line number, matching
299
+ * the Read tool's `cat -n` convention (number + tab) so the agent treats it
300
+ * the same way it treats Read output.
301
+ *
302
+ * @param slice contiguous source text (already extracted from the file)
303
+ * @param firstLineNumber the 1-based line number of the slice's first line
304
+ */
305
+ function numberSourceLines(slice, firstLineNumber) {
306
+ const out = [];
307
+ const split = slice.split('\n');
308
+ for (let i = 0; i < split.length; i++) {
309
+ out.push(`${firstLineNumber + i}\t${split[i]}`);
310
+ }
311
+ return out.join('\n');
312
+ }
313
+ /**
314
+ * Unique line-prefix for a per-file source section in codegraph_explore output.
315
+ * Issue #778: tool results dropped ATX headings (`####`, `##`, `###`) for bold
316
+ * labels so Markdown-rendering MCP clients (e.g. the Claude Code VSCode
317
+ * extension) stop blowing every header up to H1–H4. The path is bold + a code
318
+ * span so it still reads as a header, and the leading ``**` `` stays a UNIQUE,
319
+ * greppable marker — no other explore line begins with it — that the explore
320
+ * truncation boundary (`handleExplore`) keys off to cut on whole file sections.
321
+ */
322
+ const FILE_SECTION_PREFIX = '**`';
323
+ // Placeholder for codegraph_explore's "Found N symbols across M files." line.
324
+ // The honest N/M can only be known after the final truncation drops trailing
325
+ // sections (#1046), so the header is emitted as this sentinel and substituted
326
+ // at the very end. This bracketed token never occurs in rendered source or a
327
+ // file path, so the final string-replace can't collide.
328
+ const SUMMARY_SENTINEL = '[[codegraph-explore-summary]]';
329
+ function fileSectionHeader(filePath, suffix) {
330
+ return suffix
331
+ ? `${FILE_SECTION_PREFIX}${filePath}\`** — ${suffix}`
332
+ : `${FILE_SECTION_PREFIX}${filePath}\`**`;
333
+ }
334
+ /**
335
+ * Per-file staleness banner emitted at the top of a tool response when the
336
+ * file watcher has pending events for files referenced by the response.
337
+ * The agent uses this to fall back to Read for those specific files
338
+ * without waiting for the debounced sync (issue #403).
339
+ */
340
+ function formatStaleBanner(stale) {
341
+ const now = Date.now();
342
+ const lines = stale.map((p) => {
343
+ const ageMs = Math.max(0, now - p.lastSeenMs);
344
+ const label = p.indexing ? 'indexing in progress' : 'pending sync';
345
+ return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
346
+ });
347
+ return ('⚠️ Some files referenced below were edited since the last index sync — ' +
348
+ 'their codegraph entries may be stale:\n' +
349
+ lines.join('\n') +
350
+ '\nFor accurate content of those specific files, Read them directly. ' +
351
+ 'The rest of this response is fresh.');
352
+ }
353
+ /**
354
+ * Compact footer listing pending files that are NOT referenced in this
355
+ * response. Gives the agent a complete project-wide freshness picture
356
+ * without bloating the main banner.
357
+ */
358
+ function formatStaleFooter(stale) {
359
+ const MAX = 5;
360
+ const now = Date.now();
361
+ const shown = stale.slice(0, MAX);
362
+ const lines = shown.map((p) => {
363
+ const ageMs = Math.max(0, now - p.lastSeenMs);
364
+ return ` - ${p.path} (edited ${ageMs}ms ago)`;
365
+ });
366
+ const more = stale.length > MAX ? `\n - …and ${stale.length - MAX} more` : '';
367
+ return (`(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
368
+ `sync but were not referenced above:\n${lines.join('\n')}${more})`);
369
+ }
370
+ /**
371
+ * Whole-index degradation banner (issue #876). Emitted at the top of a read
372
+ * tool response when live watching has permanently stopped — at which point
373
+ * `getPendingFiles()` is empty, so the per-file banner above can't fire even
374
+ * though the index is now FROZEN and silently drifting stale. Leads with the
375
+ * agent-actionable instruction (Read directly) and carries the reason, which
376
+ * already names the operator remedy (`codegraph sync` / git hooks).
377
+ */
378
+ function formatDegradedBanner(reason) {
379
+ return ('⚠️ CodeGraph auto-sync is DISABLED — live file watching stopped, so the index is ' +
380
+ 'frozen and any file edited since then is stale here. Read files directly to confirm ' +
381
+ 'current content before relying on it.' +
382
+ (reason ? `\n Reason: ${reason}` : ''));
383
+ }
384
+ /**
385
+ * Common projectPath property for cross-project queries
386
+ */
387
+ const projectPathProperty = {
388
+ type: 'string',
389
+ description: 'Absolute path to the project to query (or any directory inside it) — codegraph uses the nearest .codegraph/ index at or above that path. Omit to use this session\'s default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).',
390
+ };
391
+ /**
392
+ * EVERY codegraph tool is query-only: it reads the pre-built index and never
393
+ * mutates the workspace (indexing is the user's explicit CLI call, never the
394
+ * agent's). Advertising this read-only contract lets clients that gate on it run
395
+ * the tools where a possibly-mutating tool would be blocked — most concretely,
396
+ * Cursor's Ask mode, which rejects any MCP tool lacking `readOnlyHint: true`
397
+ * (issue #1018). `idempotentHint`: a repeated query has no additional effect.
398
+ * `openWorldHint: false`: the domain is the closed local index, not an open
399
+ * external world. Shared so the contract is declared once; a hypothetical
400
+ * mutating tool would simply not reference it.
401
+ */
402
+ const READ_ONLY_ANNOTATIONS = {
403
+ readOnlyHint: true,
404
+ destructiveHint: false,
405
+ idempotentHint: true,
406
+ openWorldHint: false,
407
+ };
408
+ /**
409
+ * All CodeGraph MCP tools
410
+ *
411
+ * Designed for minimal context usage - use codegraph_explore as the primary tool
412
+ * (one call usually answers the whole question), and only use other tools for
413
+ * targeted follow-up queries.
414
+ *
415
+ * All tools support cross-project queries via the optional `projectPath` parameter.
416
+ */
417
+ exports.tools = [
418
+ {
419
+ name: 'codegraph_search',
420
+ description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_explore instead to get the actual source / understand an area in one call.',
421
+ inputSchema: {
422
+ type: 'object',
423
+ properties: {
424
+ query: {
425
+ type: 'string',
426
+ description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
427
+ },
428
+ kind: {
429
+ type: 'string',
430
+ description: 'Filter by node kind',
431
+ enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
432
+ },
433
+ limit: {
434
+ type: 'number',
435
+ description: 'Maximum results (default: 10)',
436
+ default: 10,
437
+ },
438
+ projectPath: projectPathProperty,
439
+ },
440
+ required: ['query'],
441
+ },
442
+ annotations: READ_ONLY_ANNOTATIONS,
443
+ },
444
+ {
445
+ name: 'codegraph_callers',
446
+ description: 'List functions that call <symbol>. For the full flow, use codegraph_explore.',
447
+ inputSchema: {
448
+ type: 'object',
449
+ properties: {
450
+ symbol: {
451
+ type: 'string',
452
+ description: 'Name of the function, method, or class to find callers for',
453
+ },
454
+ file: {
455
+ type: 'string',
456
+ description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist (e.g. one UserService per app in a monorepo)',
457
+ },
458
+ limit: {
459
+ type: 'number',
460
+ description: 'Maximum number of callers to return (default: 20)',
461
+ default: 20,
462
+ },
463
+ projectPath: projectPathProperty,
464
+ },
465
+ required: ['symbol'],
466
+ },
467
+ annotations: READ_ONLY_ANNOTATIONS,
468
+ },
469
+ {
470
+ name: 'codegraph_callees',
471
+ description: 'List functions that <symbol> calls. For the full flow, use codegraph_explore.',
472
+ inputSchema: {
473
+ type: 'object',
474
+ properties: {
475
+ symbol: {
476
+ type: 'string',
477
+ description: 'Name of the function, method, or class to find callees for',
478
+ },
479
+ file: {
480
+ type: 'string',
481
+ description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
482
+ },
483
+ limit: {
484
+ type: 'number',
485
+ description: 'Maximum number of callees to return (default: 20)',
486
+ default: 20,
487
+ },
488
+ projectPath: projectPathProperty,
489
+ },
490
+ required: ['symbol'],
491
+ },
492
+ annotations: READ_ONLY_ANNOTATIONS,
493
+ },
494
+ {
495
+ name: 'codegraph_impact',
496
+ description: 'List symbols affected by changing <symbol>. Use before a refactor.',
497
+ inputSchema: {
498
+ type: 'object',
499
+ properties: {
500
+ symbol: {
501
+ type: 'string',
502
+ description: 'Name of the symbol to analyze impact for',
503
+ },
504
+ file: {
505
+ type: 'string',
506
+ description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
507
+ },
508
+ depth: {
509
+ type: 'number',
510
+ description: 'How many levels of dependencies to traverse (default: 2)',
511
+ default: 2,
512
+ },
513
+ projectPath: projectPathProperty,
514
+ },
515
+ required: ['symbol'],
516
+ },
517
+ annotations: READ_ONLY_ANNOTATIONS,
518
+ },
519
+ {
520
+ name: 'codegraph_node',
521
+ description: 'Two modes. (1) READ A FILE — use INSTEAD of the Read tool: pass `file` (a path or basename) with no `symbol` and it returns that file\'s current on-disk source with line numbers, exactly the shape Read gives you (`<n>\\t<line>`, safe to Edit from), narrowable with `offset`/`limit` just like Read — PLUS a one-line note of which files depend on it. Same bytes as Read, faster (served from the index), with the blast radius attached. Use it whenever you would Read a source file. (2) ONE SYMBOL you can name — its location, signature, verbatim source (includeCode=true) and caller/callee trail in one call, so before changing it you see what calls it and what your edit would break. For an AMBIGUOUS name it returns EVERY matching definition\'s body in one call (so you never Read a file to find the right overload); pass `file`/`line` to pin one. Use codegraph_explore for several related symbols or the full flow.',
522
+ inputSchema: {
523
+ type: 'object',
524
+ properties: {
525
+ symbol: {
526
+ type: 'string',
527
+ description: 'Name of the symbol to read (symbol mode). Omit it and pass `file` alone to read a whole file like Read.',
528
+ },
529
+ includeCode: {
530
+ type: 'boolean',
531
+ description: 'Symbol mode: include the symbol\'s full body (default: false). Ignored in file mode, which always returns source unless `symbolsOnly` is set.',
532
+ default: false,
533
+ },
534
+ file: {
535
+ type: 'string',
536
+ description: 'A file path or basename (e.g. "harness.rs", "src/auth/session.ts"). Pass it ALONE (no symbol) to READ the file like the Read tool — its full source with line numbers + which files depend on it. Or pass it WITH a symbol to disambiguate an overloaded name to the definition in this file.',
537
+ },
538
+ offset: {
539
+ type: 'number',
540
+ description: 'File mode: 1-based line to start reading from, exactly like Read\'s offset. Defaults to the start of the file.',
541
+ },
542
+ limit: {
543
+ type: 'number',
544
+ description: 'File mode: maximum number of lines to return, exactly like Read\'s limit. Defaults to the whole file (capped at 2000 lines, like Read).',
545
+ },
546
+ symbolsOnly: {
547
+ type: 'boolean',
548
+ description: 'File mode: return just the file\'s symbol map + dependents (a cheap structural overview) instead of its source.',
549
+ default: false,
550
+ },
551
+ line: {
552
+ type: 'number',
553
+ description: 'Symbol mode only: disambiguate to the definition at/around this line (use with the file:line a trail showed you).',
554
+ },
555
+ projectPath: projectPathProperty,
556
+ },
557
+ required: [],
558
+ },
559
+ annotations: READ_ONLY_ANNOTATIONS,
560
+ },
561
+ {
562
+ name: 'codegraph_explore',
563
+ description: 'PRIMARY TOOL — call FIRST for almost any question OR before an edit: how does X work, architecture, a bug, where/what is X, surveying an area, or the symbols you are about to change. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — treat the shown source as already Read; do NOT re-open those files), plus the call path among them. Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — more accurate context, in far fewer tokens and round-trips than a search/Read/Grep loop.',
564
+ inputSchema: {
565
+ type: 'object',
566
+ properties: {
567
+ query: {
568
+ type: 'string',
569
+ description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). For a flow question, name the symbols spanning the flow (e.g. "mutateElement renderScene"). A natural-language question works too — no prior codegraph_search needed.',
570
+ },
571
+ maxFiles: {
572
+ type: 'number',
573
+ description: 'Maximum number of files to include source code from (default: 12)',
574
+ default: 12,
575
+ },
576
+ projectPath: projectPathProperty,
577
+ },
578
+ required: ['query'],
579
+ },
580
+ annotations: READ_ONLY_ANNOTATIONS,
581
+ },
582
+ {
583
+ name: 'codegraph_status',
584
+ description: 'Index health check (files / nodes / edges) AND the version/execution-mode declaration surface: reports the running Lattice sensor version, the index schema stats, and a machine-readable `mode: daemon|direct` + `reason` field describing how this session is served (see ADR 0049). Skip unless debugging.',
585
+ inputSchema: {
586
+ type: 'object',
587
+ properties: {
588
+ projectPath: projectPathProperty,
589
+ },
590
+ },
591
+ annotations: READ_ONLY_ANNOTATIONS,
592
+ },
593
+ {
594
+ name: 'codegraph_files',
595
+ description: 'Indexed file tree with language + symbol counts. Faster than Glob for project layout.',
596
+ inputSchema: {
597
+ type: 'object',
598
+ properties: {
599
+ path: {
600
+ type: 'string',
601
+ description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
602
+ },
603
+ pattern: {
604
+ type: 'string',
605
+ description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
606
+ },
607
+ format: {
608
+ type: 'string',
609
+ description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
610
+ enum: ['tree', 'flat', 'grouped'],
611
+ default: 'tree',
612
+ },
613
+ includeMetadata: {
614
+ type: 'boolean',
615
+ description: 'Include file metadata like language and symbol count (default: true)',
616
+ default: true,
617
+ },
618
+ maxDepth: {
619
+ type: 'number',
620
+ description: 'Maximum directory depth to show (default: unlimited)',
621
+ },
622
+ projectPath: projectPathProperty,
623
+ },
624
+ },
625
+ annotations: READ_ONLY_ANNOTATIONS,
626
+ },
627
+ ];
628
+ /**
629
+ * Return `defs` with `projectPath` marked `required` in each tool's inputSchema.
630
+ *
631
+ * Used for the NO-DEFAULT-PROJECT tool surface (issue #993): when the MCP server
632
+ * has no default project to fall back to — a gateway server started outside any
633
+ * repo, or a monorepo root whose `.codegraph/` indexes live only in sub-projects
634
+ * — every call MUST carry an explicit `projectPath`, so the schema should say so.
635
+ * A `required` field is a HIGH-salience channel (MCP clients surface and often
636
+ * validate it), unlike the instructions text the reporter found too weak to stop
637
+ * the agent omitting the param. When a default project IS open, callers leave
638
+ * projectPath optional and never call this.
639
+ *
640
+ * Pure: clones each tool's schema rather than mutating the shared module-level
641
+ * `tools` array (reused by every session and the static surface). A tool that
642
+ * doesn't expose projectPath, or already requires it, is returned untouched;
643
+ * explore's `['query']` becomes `['query', 'projectPath']`, and a tool with no
644
+ * `required` list (status/files) gains `['projectPath']`.
645
+ */
646
+ function withRequiredProjectPath(defs) {
647
+ return defs.map((tool) => {
648
+ if (!tool.inputSchema.properties.projectPath)
649
+ return tool;
650
+ const required = tool.inputSchema.required ?? [];
651
+ if (required.includes('projectPath'))
652
+ return tool;
653
+ return {
654
+ ...tool,
655
+ inputSchema: { ...tool.inputSchema, required: [...required, 'projectPath'] },
656
+ };
657
+ });
658
+ }
659
+ /**
660
+ * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
661
+ * proxy answers `tools/list` with before any project is open. Mirrors
662
+ * `ToolHandler.getTools()` in the no-CodeGraph case (the dynamic per-repo budget
663
+ * note in a description only adds once `cg` is loaded; the schemas are static).
664
+ */
665
+ function getStaticTools() {
666
+ const raw = process.env.CODEGRAPH_MCP_TOOLS;
667
+ if (!raw || !raw.trim()) {
668
+ return exports.tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
669
+ }
670
+ const allow = new Set(raw.split(',').map(s => s.trim().replace(/^codegraph_/, '')).filter(Boolean));
671
+ return allow.size ? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, ''))) : exports.tools;
672
+ }
673
+ /**
674
+ * The MCP tools served by DEFAULT (short names). Pared to ONLY `codegraph_explore`
675
+ * — the single tool that reliably earns its place: one capped call returns the
676
+ * verbatim source of the relevant symbols grouped by file. Every other tool is a
677
+ * narrower slice of what explore already does, and presence itself steers
678
+ * mis-picks, so they are no longer LISTED to agents.
679
+ *
680
+ * The other defined tools (`node`, `search`, `callers`, plus callees/impact/files/
681
+ * status) remain fully functional — handlers stay, the library API and CLI are
682
+ * untouched, and `CODEGRAPH_MCP_TOOLS=explore,node,...` re-enables any of them.
683
+ */
684
+ const DEFAULT_MCP_TOOLS = new Set(['explore']);
685
+ /**
686
+ * Tool handler that executes tools against a CodeGraph instance
687
+ *
688
+ * Supports cross-project queries via the projectPath parameter.
689
+ * Other projects are opened on-demand and cached for performance.
690
+ */
691
+ class ToolHandler {
692
+ cg;
693
+ // Cache of opened CodeGraph instances for cross-project queries
694
+ projectCache = new Map();
695
+ // The directory the server last searched for a default project. Surfaced in
696
+ // the "not initialized" error so users can see why detection missed.
697
+ defaultProjectHint = null;
698
+ // Per-start-path cache of the git worktree/index mismatch (issue #155). The
699
+ // mismatch is a fixed property of (where the request came from → which
700
+ // .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
701
+ // once and every later tool call reuses the result — never shelling out to
702
+ // git on the hot path. `undefined` = not computed yet; `null` = no mismatch.
703
+ worktreeMismatchCache = new Map();
704
+ // Gate that the MCP engine pokes after `cg.open()` so the first tool call
705
+ // blocks on the post-open filesystem reconcile (catch-up sync). Without
706
+ // this, a tool call that races past `catchUpSync()` serves rows for files
707
+ // that were deleted (or edited) while no MCP server was running — and the
708
+ // per-file staleness banner can't help, because `getPendingFiles()` is
709
+ // populated by the watcher, not by catch-up. The wait is time-boxed
710
+ // (see {@link resolveCatchUpGateTimeoutMs}) so a minutes-long reconcile on a
711
+ // huge repo can't hang the first call (#905); cleared on first await so
712
+ // subsequent calls don't pay any cost.
713
+ catchUpGate = null;
714
+ // Optional worker-thread pool for off-loop read-tool dispatch (daemon mode).
715
+ // When set + healthy, the heavy read tools run on a worker so the daemon's
716
+ // main loop stays free for the MCP transport under concurrent load. Null in
717
+ // direct/in-process mode (one client, no concurrency to parallelize).
718
+ queryPool = null;
719
+ // ADR 0049 Decision 5②: the reason the DEFAULT project's index failed to
720
+ // open (DB open failure, schema mismatch, integrity error, lock
721
+ // contention) — set by the engine's init path, distinct from "no
722
+ // .codegraph/ found" (which leaves this null and falls through to
723
+ // NotIndexedError's success-shaped guidance). Cleared on a successful
724
+ // {@link setDefaultCodeGraph}.
725
+ openFailure = null;
726
+ // ADR 0049 Decision 5④: this session's execution mode, reported by
727
+ // codegraph_status as a machine-readable field. Defaults to a direct/
728
+ // no-daemon placeholder — every real caller (MCPServer.startDirect, the
729
+ // shared Daemon, the local-handshake proxy's fallback engine) calls
730
+ // {@link setExecutionMode} before any tool call can reach a live engine.
731
+ executionMode = { mode: 'direct', reason: 'no-daemon' };
732
+ constructor(cg) {
733
+ this.cg = cg;
734
+ }
735
+ /**
736
+ * Engine-only: attach (or detach with null) the worker-thread query pool. The
737
+ * shared daemon sets this once its default project is open; the workers each
738
+ * hold their own WAL read connection and run {@link executeReadTool}. A
739
+ * worker's own ToolHandler never has a pool, so there is no nested off-loading.
740
+ */
741
+ setQueryPool(pool) {
742
+ this.queryPool = pool;
743
+ }
744
+ /**
745
+ * Update the default CodeGraph instance (e.g. after lazy initialization).
746
+ * A successful open supersedes any previously recorded open failure.
747
+ */
748
+ setDefaultCodeGraph(cg) {
749
+ this.cg = cg;
750
+ this.openFailure = null;
751
+ }
752
+ /**
753
+ * ADR 0049 Decision 5②: record that the DEFAULT project's index failed to
754
+ * open for a reason OTHER than "no .codegraph/ found" — the engine calls
755
+ * this from its init catch block. `getCodeGraph()` throws it (fail closed)
756
+ * instead of the NotIndexedError guidance the next time a tool call needs
757
+ * the default project, so the failure surfaces as `isError: true` rather
758
+ * than being silently absorbed into "just run codegraph init" prose.
759
+ */
760
+ setOpenFailure(err) {
761
+ this.openFailure = err;
762
+ }
763
+ /**
764
+ * ADR 0049 Decision 5④: record this session's execution mode
765
+ * (`daemon`/`direct`) and the typed reason direct mode was chosen (or
766
+ * `'daemon'` when routed through the shared daemon). Surfaced verbatim by
767
+ * `codegraph_status`.
768
+ */
769
+ setExecutionMode(mode, reason) {
770
+ this.executionMode = { mode, reason };
771
+ }
772
+ /** The execution mode last recorded via {@link setExecutionMode}. */
773
+ getExecutionMode() {
774
+ return this.executionMode;
775
+ }
776
+ /**
777
+ * Engine-only: register the catch-up sync promise so the next `execute()`
778
+ * call awaits it before serving. The handler swallows rejections (the
779
+ * engine logs them) so a sync failure never propagates as a tool error;
780
+ * we still want to serve a best-effort result over the same potentially-
781
+ * stale data, which is what would have happened without the gate.
782
+ */
783
+ setCatchUpGate(p) {
784
+ this.catchUpGate = p;
785
+ }
786
+ /**
787
+ * Await the catch-up gate, but no longer than the configured timeout (#905).
788
+ * If the reconcile settles first, we got the fully-reconciled answer. If the
789
+ * timeout wins, we serve the call now and let the reconcile finish in the
790
+ * background — it yields to the event loop (see SYNC_RECONCILE_YIELD_INTERVAL),
791
+ * so a concurrent read still runs against the same connection. Never throws:
792
+ * a failed reconcile is logged by the engine, and we serve best-effort over
793
+ * the same potentially-stale data the un-gated path would have.
794
+ */
795
+ async awaitCatchUpGate(gate) {
796
+ const timeoutMs = resolveCatchUpGateTimeoutMs();
797
+ if (timeoutMs <= 0) {
798
+ // 0 = opt back into the original unbounded wait.
799
+ try {
800
+ await gate;
801
+ }
802
+ catch { /* engine already logged */ }
803
+ return;
804
+ }
805
+ let timer;
806
+ const timedOut = new Promise((resolve) => {
807
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
808
+ timer.unref?.();
809
+ });
810
+ try {
811
+ const outcome = await Promise.race([
812
+ gate.then(() => 'done', () => 'done'),
813
+ timedOut,
814
+ ]);
815
+ if (outcome === 'timeout') {
816
+ process.stderr.write(`[CodeGraph MCP] Catch-up reconcile still running after ${timeoutMs}ms; serving this tool call now and finishing the reconcile in the background (#905). ` +
817
+ `Set CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS=0 to always wait for it.\n`);
818
+ }
819
+ }
820
+ finally {
821
+ if (timer)
822
+ clearTimeout(timer);
823
+ }
824
+ }
825
+ /**
826
+ * Record the directory the server tried to resolve the default project from.
827
+ * Used only to make the "no default project" error actionable.
828
+ */
829
+ setDefaultProjectHint(searchedPath) {
830
+ this.defaultProjectHint = searchedPath;
831
+ }
832
+ /**
833
+ * Whether a default CodeGraph instance is available
834
+ */
835
+ hasDefaultCodeGraph() {
836
+ return this.cg !== null;
837
+ }
838
+ /**
839
+ * Optional allowlist of exposed tools, parsed from the CODEGRAPH_MCP_TOOLS
840
+ * env var (comma-separated short names, e.g. "trace,search,node,context").
841
+ * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
842
+ * trim the tool surface without rebuilding the client config; the ablated
843
+ * tool is then truly absent from ListTools rather than merely denied on call.
844
+ * Matching is on the short form, so "node" and "codegraph_node" both work.
845
+ */
846
+ toolAllowlist() {
847
+ const raw = process.env.CODEGRAPH_MCP_TOOLS;
848
+ if (!raw || !raw.trim())
849
+ return null;
850
+ const short = (s) => s.trim().replace(/^codegraph_/, '');
851
+ const set = new Set(raw.split(',').map(short).filter(Boolean));
852
+ return set.size ? set : null;
853
+ }
854
+ /** Whether a tool name passes the CODEGRAPH_MCP_TOOLS allowlist (if any). */
855
+ isToolAllowed(name) {
856
+ const allow = this.toolAllowlist();
857
+ return !allow || allow.has(name.replace(/^codegraph_/, ''));
858
+ }
859
+ /**
860
+ * Get tool definitions with dynamic descriptions based on project size.
861
+ * The codegraph_explore tool description includes a budget recommendation
862
+ * scaled to the number of indexed files. Honors the CODEGRAPH_MCP_TOOLS
863
+ * allowlist so a trimmed surface is reflected in ListTools.
864
+ */
865
+ getTools() {
866
+ const allow = this.toolAllowlist();
867
+ // No explicit allowlist → the default 4-tool surface (see
868
+ // DEFAULT_MCP_TOOLS for the evidence). An allowlist replaces the
869
+ // default entirely, so any defined tool can be re-enabled.
870
+ let visible = allow
871
+ ? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
872
+ : exports.tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
873
+ // No default project loaded → no-root-index case (#993): a gateway server
874
+ // started outside any repo, or a monorepo root whose indexes live in
875
+ // sub-projects. With nothing to fall back to, EVERY call needs an explicit
876
+ // projectPath, so mark it required in the schema — a high-salience nudge the
877
+ // agent acts on, where SERVER_INSTRUCTIONS_NO_ROOT_INDEX's prose alone
878
+ // wasn't enough (the reporter had to add an AGENTS.md note). `this.cg` is
879
+ // settled by `retryInitIfNeeded()` before `handleToolsList` calls us, so a
880
+ // null here means "genuinely no default", not a startup race. When a default
881
+ // IS open we leave projectPath optional (below): a bare call falls back to
882
+ // it, exactly as in the common single-project launch.
883
+ if (!this.cg)
884
+ return withRequiredProjectPath(visible);
885
+ try {
886
+ const stats = this.cg.getStats();
887
+ const budget = getExploreBudget(stats.fileCount);
888
+ // Tiny-repo tool gating: on projects under TINY_REPO_FILE_THRESHOLD
889
+ // files, only expose the core trio (search, node, explore) — one
890
+ // below even the 4-tool default: at this scale callers, too, reduces
891
+ // to one grep. (Historical note: the audit below ran when context and
892
+ // trace still existed; its "5 core tools" are today's trio.)
893
+ //
894
+ // n=2 audits ruled out cutting below 5 tools:
895
+ // - 3-tool gate (search + context + trace): cost regressed on
896
+ // cobra/ky/sinatra. The agent fell back to raw Reads to cover
897
+ // what codegraph_node + codegraph_explore would have answered.
898
+ // - 1-tool gate (search only): catastrophic regression — express
899
+ // went from -43% WIN to +107% LOSS. With only search, the agent
900
+ // can't navigate the call graph structurally and reads everything.
901
+ //
902
+ // 5 is the empirical lower bound. Tools beyond search/context/
903
+ // node/explore/trace pay overhead that the agent doesn't recoup
904
+ // on tiny-repo flow questions.
905
+ // ITER4: raise threshold 150 → 500 so single-file frameworks
906
+ // (sinatra at 159, slim_framework around 200) also get the
907
+ // 5-tool surface. The empirical 5-tool floor was set on <150
908
+ // probes; iter3 measurement showed sinatra is structurally the
909
+ // SAME problem as cobra (single-file WITHOUT-arm Read wins),
910
+ // so it deserves the same gating.
911
+ const TINY_REPO_FILE_THRESHOLD = 500;
912
+ const TINY_REPO_CORE_TOOLS = new Set([
913
+ 'codegraph_explore',
914
+ 'codegraph_search',
915
+ 'codegraph_node',
916
+ ]);
917
+ if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
918
+ visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
919
+ }
920
+ return visible.map(tool => {
921
+ if (tool.name === 'codegraph_explore') {
922
+ return {
923
+ ...tool,
924
+ description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
925
+ };
926
+ }
927
+ return tool;
928
+ });
929
+ }
930
+ catch {
931
+ return visible;
932
+ }
933
+ }
934
+ /**
935
+ * Get CodeGraph instance for a project
936
+ *
937
+ * If projectPath is provided, opens that project's CodeGraph (cached).
938
+ * Otherwise returns the default CodeGraph instance.
939
+ *
940
+ * Walks up parent directories to find the nearest .codegraph/ folder,
941
+ * similar to how git finds .git/ directories.
942
+ */
943
+ getCodeGraph(projectPath) {
944
+ if (!projectPath) {
945
+ if (!this.cg) {
946
+ // ADR 0049 Decision 5②: a recorded open failure (DB open error,
947
+ // schema mismatch, integrity error, lock contention) is a genuine
948
+ // malfunction, NOT "no index" — fail closed (isError: true via
949
+ // execute()'s catch-all) instead of falling into the success-shaped
950
+ // NotIndexedError guidance below, which would misleadingly read as
951
+ // "this project just isn't indexed yet".
952
+ if (this.openFailure) {
953
+ throw new IndexOpenError(`The default project's CodeGraph index failed to open: ${this.openFailure.message}\n` +
954
+ 'This is a genuine malfunction (corrupt/locked database, schema mismatch, or ' +
955
+ 'similar) — NOT a missing index. Retry the call once; if it persists, the index ' +
956
+ 'may need to be rebuilt (`codegraph init` after removing `.codegraph/`) or a ' +
957
+ 'stale lock cleared.');
958
+ }
959
+ const searched = this.defaultProjectHint ?? process.cwd();
960
+ throw new NotIndexedError('No CodeGraph project is loaded for this session.\n' +
961
+ `Searched for a .codegraph/ directory starting from: ${searched}\n` +
962
+ 'Either the server root has no index of its own (e.g. a monorepo where only ' +
963
+ "sub-projects are indexed), or the MCP client launched the server outside your " +
964
+ 'project without reporting the workspace root. Either way, target the project ' +
965
+ 'explicitly:\n' +
966
+ ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project" ' +
967
+ '(any project that has a .codegraph/ — including a sub-project of a monorepo)\n' +
968
+ ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
969
+ 'If a project simply has no index, use your built-in tools (Read/Grep/Glob) for THAT ' +
970
+ "project (the user can run 'codegraph init' there to enable it) — you can still query " +
971
+ 'other indexed projects by projectPath in the same session.');
972
+ }
973
+ return this.freshen(this.cg);
974
+ }
975
+ // Reject sensitive system directories before opening. Only validate a
976
+ // path that actually exists — a nested or not-yet-created sub-path of a
977
+ // real project must still be allowed to resolve UP to its .codegraph/
978
+ // root below (issue #238), so we don't run the existence-checking
979
+ // validator on paths that are meant to walk up.
980
+ if ((0, fs_1.existsSync)(projectPath)) {
981
+ const pathError = (0, utils_1.validateProjectPath)(projectPath);
982
+ if (pathError) {
983
+ throw new PathRefusalError(pathError);
984
+ }
985
+ }
986
+ // Always RE-RESOLVE the nearest .codegraph/ from the input path. The walk
987
+ // is cheap (a few existsSync up the tree) and is the only thing that
988
+ // notices a path whose index root CHANGED since it was first seen — most
989
+ // importantly a git worktree that gained its own .codegraph/ after the
990
+ // (long-lived) server first resolved it up to the parent checkout. We used
991
+ // to short-circuit on a `projectCache[projectPath]` entry before resolving,
992
+ // which pinned that first resolution for the server's whole lifetime, so a
993
+ // worktree kept being served the parent checkout's index until restart
994
+ // (#926). The DB connection itself is still cached (by resolved root,
995
+ // below), so re-resolving costs only the stat walk, never a reopen.
996
+ const resolvedRoot = (0, directory_1.findNearestCodeGraphRoot)(projectPath);
997
+ if (!resolvedRoot) {
998
+ throw new NotIndexedError(`The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` +
999
+ 'walking up from it), so codegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
1000
+ "for that codebase instead, and don't call codegraph for it again this session. " +
1001
+ "Indexing is the user's decision — they can run 'codegraph init' in that project to enable it.");
1002
+ }
1003
+ // If the path resolves to the default project, reuse the already-open
1004
+ // default instance rather than opening a SECOND connection to the same DB.
1005
+ // A duplicate connection serializes reads against the watcher's auto-sync
1006
+ // writes; when WAL isn't in effect (e.g. a filesystem without shared-memory
1007
+ // support) that surfaces as intermittent
1008
+ // "database is locked" on concurrent tool calls. See issue #238. The
1009
+ // default instance is owned/closed by the server, so it's never cached.
1010
+ if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
1011
+ return this.freshen(this.cg);
1012
+ }
1013
+ // Cache the open DB connection by RESOLVED ROOT only — never by the input
1014
+ // path. One key per instance means closeAll() closes each exactly once, and
1015
+ // a changed resolution maps to a different entry instead of a stale hit.
1016
+ const cached = this.projectCache.get(resolvedRoot);
1017
+ if (cached)
1018
+ return this.freshen(cached);
1019
+ const cg = loadCodeGraph().openSync(resolvedRoot);
1020
+ this.projectCache.set(resolvedRoot, cg);
1021
+ return cg;
1022
+ }
1023
+ /**
1024
+ * Heal a long-lived connection whose `.codegraph/` was removed and recreated
1025
+ * at the same path (a worktree recreated, or `rm -rf .codegraph` + re-init)
1026
+ * before handing it to a tool. Otherwise the daemon keeps serving the
1027
+ * pre-removal snapshot from its now-unlinked file handle until restart — and
1028
+ * because the daemon registry is keyed by path, a same-path recreate routes
1029
+ * new clients straight back to this same stale daemon (#925). The check is one
1030
+ * stat() and a no-op unless the inode actually changed; it never throws into a
1031
+ * tool call.
1032
+ */
1033
+ freshen(cg) {
1034
+ try {
1035
+ if (cg.reopenIfReplaced()) {
1036
+ process.stderr.write('[CodeGraph MCP] The index was replaced on disk (e.g. a git worktree ' +
1037
+ 'recreated at the same path); reopened the live database in place.\n');
1038
+ }
1039
+ }
1040
+ catch {
1041
+ // Best-effort self-heal — a failed reopen must never break the tool call;
1042
+ // the (still stale) handle keeps serving and the next call retries.
1043
+ }
1044
+ return cg;
1045
+ }
1046
+ /**
1047
+ * Close all cached project connections
1048
+ */
1049
+ closeAll() {
1050
+ for (const cg of this.projectCache.values()) {
1051
+ cg.close();
1052
+ }
1053
+ this.projectCache.clear();
1054
+ this.worktreeMismatchCache.clear();
1055
+ }
1056
+ /**
1057
+ * Validate that a value is a non-empty string within length bounds.
1058
+ *
1059
+ * The `maxLength` cap protects against MCP clients that ship huge
1060
+ * payloads (10MB+ query strings either by accident or maliciously).
1061
+ * Without this, a single oversized input can pin the FTS5 index or
1062
+ * exhaust memory before any real work runs.
1063
+ */
1064
+ validateString(value, name, maxLength = MAX_INPUT_LENGTH) {
1065
+ if (typeof value !== 'string' || value.length === 0) {
1066
+ return this.errorResult(`${name} must be a non-empty string`);
1067
+ }
1068
+ if (value.length > maxLength) {
1069
+ return this.errorResult(`${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`);
1070
+ }
1071
+ return value;
1072
+ }
1073
+ /**
1074
+ * Validate an optional path-like string input. Returns the value if
1075
+ * valid (or undefined), or a ToolResult with the error.
1076
+ */
1077
+ validateOptionalPath(value, name) {
1078
+ if (value === undefined || value === null)
1079
+ return undefined;
1080
+ if (typeof value !== 'string') {
1081
+ return this.errorResult(`${name} must be a string`);
1082
+ }
1083
+ if (value.length > MAX_PATH_LENGTH) {
1084
+ return this.errorResult(`${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`);
1085
+ }
1086
+ return value;
1087
+ }
1088
+ /**
1089
+ * Cached git worktree/index mismatch for a tool call's effective project.
1090
+ *
1091
+ * The "effective project" is what the request targets: an explicit
1092
+ * `projectPath` arg, else the directory the server resolved its default
1093
+ * project from (`defaultProjectHint`), else cwd. Memoized per start path —
1094
+ * see `worktreeMismatchCache`. Best-effort: if the project can't be resolved
1095
+ * (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never
1096
+ * broken by this check.
1097
+ */
1098
+ worktreeMismatchFor(projectPath) {
1099
+ const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
1100
+ // The verdict depends on BOTH the start path AND the index root it resolves
1101
+ // to, so the cache must be keyed on the pair. Resolve the index root first
1102
+ // (cheap — getCodeGraph re-walks to the nearest .codegraph/, no git), then
1103
+ // key on `(startPath, indexRoot)`. The moment that root changes — most
1104
+ // importantly when a git worktree gains its own index and the walk-up stops
1105
+ // there instead of at the parent checkout — the key changes and the verdict
1106
+ // is recomputed, instead of serving the stale "borrowed the parent's index"
1107
+ // warning for the server's whole lifetime. Keying on startPath alone pinned
1108
+ // that first verdict until restart (#926).
1109
+ let indexRoot;
1110
+ try {
1111
+ indexRoot = this.getCodeGraph(projectPath).getProjectRoot();
1112
+ }
1113
+ catch {
1114
+ // No resolvable project (or any other resolution error) → nothing to warn.
1115
+ return null;
1116
+ }
1117
+ const cacheKey = `${startPath}\u0000${indexRoot}`;
1118
+ const cached = this.worktreeMismatchCache.get(cacheKey);
1119
+ if (cached !== undefined)
1120
+ return cached;
1121
+ const mismatch = (0, worktree_1.detectWorktreeIndexMismatch)(startPath, indexRoot);
1122
+ this.worktreeMismatchCache.set(cacheKey, mismatch);
1123
+ return mismatch;
1124
+ }
1125
+ /**
1126
+ * Prefix a successful read-tool result with a compact worktree-mismatch
1127
+ * notice when the resolved index belongs to a different git working tree than
1128
+ * the caller's (issue #155). Without this, an agent in a nested worktree
1129
+ * silently trusts main-branch results. No-op on error results and when there
1130
+ * is no mismatch. `codegraph_status` is excluded — it embeds its own verbose
1131
+ * warning — so it stays out of this path.
1132
+ */
1133
+ withWorktreeNotice(result, projectPath) {
1134
+ if (result.isError)
1135
+ return result;
1136
+ const mismatch = this.worktreeMismatchFor(projectPath);
1137
+ if (!mismatch)
1138
+ return result;
1139
+ const notice = (0, worktree_1.worktreeMismatchNotice)(mismatch);
1140
+ const [first, ...rest] = result.content;
1141
+ if (first && first.type === 'text') {
1142
+ return { ...result, content: [{ type: 'text', text: `${notice}\n\n${first.text}` }, ...rest] };
1143
+ }
1144
+ return result;
1145
+ }
1146
+ /**
1147
+ * Annotate a successful read-tool result with per-file staleness — the
1148
+ * non-blocking answer to issue #403. The file watcher tracks every event
1149
+ * it sees per path; here we intersect "files referenced in this response"
1150
+ * against that pending set and prepend a compact banner so the agent can
1151
+ * fall back to Read for those *specific* files without waiting for the
1152
+ * debounced sync to fire. Other pending files in the project (not
1153
+ * referenced by this response) get a small footer so the agent has a
1154
+ * complete picture without bloating the banner.
1155
+ *
1156
+ * Cost when nothing is pending — the common case — is one boolean check.
1157
+ * No I/O, no parsing of markdown beyond a per-pending-file substring scan.
1158
+ */
1159
+ withStalenessNotice(result, projectPath) {
1160
+ if (result.isError)
1161
+ return result;
1162
+ let cg;
1163
+ try {
1164
+ cg = this.getCodeGraph(projectPath);
1165
+ }
1166
+ catch {
1167
+ return result; // no default project — leave as is
1168
+ }
1169
+ // Cross-project `projectPath` calls open a cached CodeGraph WITHOUT a
1170
+ // watcher (watchers are only attached to the default session project).
1171
+ // When the cross-project path happens to be the same project as the
1172
+ // default cg, the cached instance is the wrong one — its pendingFiles is
1173
+ // permanently empty. Detect the equal-path case and prefer the default
1174
+ // cg so the staleness signal still fires when an agent passes the
1175
+ // explicit projectPath form of its own project.
1176
+ if (this.cg && cg !== this.cg) {
1177
+ try {
1178
+ const sameProject = (0, path_1.resolve)(this.cg.getProjectRoot()) === (0, path_1.resolve)(cg.getProjectRoot());
1179
+ if (sameProject)
1180
+ cg = this.cg;
1181
+ }
1182
+ catch {
1183
+ /* getProjectRoot may throw on a closed instance — leave cg as is */
1184
+ }
1185
+ }
1186
+ // Whole-index degradation (#876): once live watching has permanently
1187
+ // stopped, getPendingFiles() is empty so the per-file banner below can't
1188
+ // fire — but the index is now FROZEN and silently drifting stale. Surface
1189
+ // one global notice instead, so the agent Reads for current content rather
1190
+ // than trusting a response off a no-longer-updating index. (Cross-project
1191
+ // calls open a watcher-less CodeGraph, so this is false there — correct: we
1192
+ // only know degraded state for the default session project.)
1193
+ let degraded = false;
1194
+ try {
1195
+ degraded = cg.isWatcherDegraded?.() ?? false;
1196
+ }
1197
+ catch {
1198
+ degraded = false;
1199
+ }
1200
+ if (degraded) {
1201
+ const [head, ...tail] = result.content;
1202
+ if (!head || head.type !== 'text')
1203
+ return result;
1204
+ let reason = null;
1205
+ try {
1206
+ reason = cg.getWatcherDegradedReason?.() ?? null;
1207
+ }
1208
+ catch {
1209
+ reason = null;
1210
+ }
1211
+ const composed = `${formatDegradedBanner(reason)}\n\n${head.text}`;
1212
+ return { ...result, content: [{ type: 'text', text: composed }, ...tail] };
1213
+ }
1214
+ // Defensive: some test fakes inject a partial CodeGraph stub without the
1215
+ // newer pending-files API. Treat missing/throwing as "no pending files."
1216
+ let pending = [];
1217
+ try {
1218
+ pending = cg.getPendingFiles?.() ?? [];
1219
+ }
1220
+ catch {
1221
+ return result;
1222
+ }
1223
+ if (pending.length === 0)
1224
+ return result;
1225
+ const [first, ...rest] = result.content;
1226
+ if (!first || first.type !== 'text')
1227
+ return result;
1228
+ const text = first.text;
1229
+ const inResponse = [];
1230
+ const elsewhere = [];
1231
+ for (const p of pending) {
1232
+ // Substring match against the project-relative POSIX path — that's
1233
+ // exactly the format both the watcher and every codegraph response
1234
+ // emit, so a plain includes() is sufficient and avoids regex pitfalls.
1235
+ if (text.includes(p.path))
1236
+ inResponse.push(p);
1237
+ else
1238
+ elsewhere.push(p);
1239
+ }
1240
+ let banner = '';
1241
+ if (inResponse.length > 0) {
1242
+ banner = formatStaleBanner(inResponse);
1243
+ }
1244
+ let footer = '';
1245
+ if (elsewhere.length > 0) {
1246
+ footer = formatStaleFooter(elsewhere);
1247
+ }
1248
+ if (!banner && !footer)
1249
+ return result;
1250
+ const composed = [banner, text, footer].filter(Boolean).join('\n\n');
1251
+ return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
1252
+ }
1253
+ /**
1254
+ * Execute a tool by name
1255
+ */
1256
+ async execute(toolName, args) {
1257
+ try {
1258
+ // Block the first tool call on the engine's post-open reconcile so we
1259
+ // never serve rows for files deleted/edited while no MCP server was
1260
+ // running. The wait is time-boxed (#905): a huge-repo reconcile takes
1261
+ // minutes, and blocking the first call on all of it reads as a hang, so
1262
+ // we wait briefly then serve and let it finish in the background. The
1263
+ // gate is cleared after first await — subsequent calls pay nothing.
1264
+ // Catch-up failures are logged by the engine; we proceed regardless so a
1265
+ // transient sync error never breaks tools.
1266
+ if (this.catchUpGate) {
1267
+ const gate = this.catchUpGate;
1268
+ this.catchUpGate = null;
1269
+ await this.awaitCatchUpGate(gate);
1270
+ }
1271
+ // Honor the optional tool allowlist (CODEGRAPH_MCP_TOOLS): a trimmed
1272
+ // surface rejects ablated tools defensively even if a client cached them.
1273
+ if (!this.isToolAllowed(toolName)) {
1274
+ return this.errorResult(`Tool ${toolName} is disabled via CODEGRAPH_MCP_TOOLS`);
1275
+ }
1276
+ // Cross-cutting input validation. All tools accept an optional
1277
+ // `projectPath` and most accept either `query`, `task`, or
1278
+ // `symbol` — bound their lengths centrally so individual handlers
1279
+ // can stay focused on tool-specific logic.
1280
+ const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
1281
+ if (typeof pathCheck === 'object' && pathCheck !== undefined) {
1282
+ return pathCheck;
1283
+ }
1284
+ // The `path` and `pattern` properties used by codegraph_files are
1285
+ // also path-shaped — apply the same cap.
1286
+ if (args.path !== undefined) {
1287
+ const check = this.validateOptionalPath(args.path, 'path');
1288
+ if (typeof check === 'object' && check !== undefined)
1289
+ return check;
1290
+ }
1291
+ if (args.pattern !== undefined) {
1292
+ const check = this.validateOptionalPath(args.pattern, 'pattern');
1293
+ if (typeof check === 'object' && check !== undefined)
1294
+ return check;
1295
+ }
1296
+ // codegraph_status reports watcher state (pending files, degraded mode,
1297
+ // worktree warning) and embeds its own sections — it must run on the MAIN
1298
+ // thread against the watched default instance, so it is NEVER off-loaded to
1299
+ // a worker (whose read connection has no watcher). It also skips the
1300
+ // auto-banner wrapper to avoid duplicating its own pending-files section.
1301
+ if (toolName === 'codegraph_status') {
1302
+ return await this.handleStatus(args);
1303
+ }
1304
+ // Read tools: off-load the CPU-heavy dispatch to the worker pool when one
1305
+ // is attached, healthy, AND has finished its first cold start (daemon
1306
+ // mode), so the daemon's single event loop stays free for the MCP
1307
+ // transport under concurrent load — otherwise N concurrent explores
1308
+ // serialize AND starve the transport until the whole batch drains
1309
+ // (clients then time out). Before the first worker is warm, calls run
1310
+ // in-process: a call queued behind a cold start sat invisible until the
1311
+ // 45s busy backstop — the daemon's first tool call stalling for however
1312
+ // long a worker spawn takes on a loaded machine (the #662 flake). With
1313
+ // no pool (direct mode) or a degraded one, dispatch runs in-process
1314
+ // exactly as before. Either way the result flows through the
1315
+ // cross-cutting notices — worktree-index mismatch (#155) and per-file
1316
+ // staleness (#403) — which need the watched MAIN instance and so are
1317
+ // always applied here, never in the worker.
1318
+ const result = (this.queryPool && this.queryPool.healthy && this.queryPool.ready)
1319
+ ? await this.queryPool.run(toolName, args)
1320
+ : await this.executeReadTool(toolName, args);
1321
+ const withWorktree = this.withWorktreeNotice(result, args.projectPath);
1322
+ return this.withStalenessNotice(withWorktree, args.projectPath);
1323
+ }
1324
+ catch (err) {
1325
+ // Expected condition, not a malfunction: answer as a SUCCESS so the
1326
+ // agent keeps trusting the toolset for projects that ARE indexed.
1327
+ // (An isError here teaches session-long abandonment — see NotIndexedError.)
1328
+ if (err instanceof NotIndexedError) {
1329
+ return this.textResult(err.message);
1330
+ }
1331
+ // Security refusal: a clean error, no retry encouragement.
1332
+ if (err instanceof PathRefusalError) {
1333
+ return this.errorResult(err.message);
1334
+ }
1335
+ return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
1336
+ 'This is an internal codegraph error — retry the call once; if it persists, ' +
1337
+ 'continue without codegraph for this task.');
1338
+ }
1339
+ }
1340
+ /**
1341
+ * Run a single read tool to completion and return its raw {@link ToolResult},
1342
+ * classifying expected failures the same way {@link execute}'s catch does so
1343
+ * the SHAPE is identical whether dispatch runs in-process or on a worker:
1344
+ * NotIndexed → success-shaped guidance, PathRefusal → clean error, anything
1345
+ * else → internal-error-with-retry. Never throws.
1346
+ *
1347
+ * This is the worker thread's entry point (see {@link ./query-worker}) and the
1348
+ * in-process fallback for {@link execute}. It deliberately does NOT run the
1349
+ * catch-up gate or the staleness/worktree notices — those need the daemon's
1350
+ * watched main instance and stay on the main thread. Cross-cutting allowlist +
1351
+ * path validation already ran in {@link execute} before routing here.
1352
+ */
1353
+ async executeReadTool(toolName, args) {
1354
+ try {
1355
+ return await this.dispatchTool(toolName, args);
1356
+ }
1357
+ catch (err) {
1358
+ if (err instanceof NotIndexedError) {
1359
+ return this.textResult(err.message);
1360
+ }
1361
+ if (err instanceof PathRefusalError) {
1362
+ return this.errorResult(err.message);
1363
+ }
1364
+ return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
1365
+ 'This is an internal codegraph error — retry the call once; if it persists, ' +
1366
+ 'continue without codegraph for this task.');
1367
+ }
1368
+ }
1369
+ /**
1370
+ * Pure dispatch over the read tools — the switch, with no gate, no notices, no
1371
+ * allowlist/validation (the caller owns those). `codegraph_status` is handled
1372
+ * on the main thread in {@link execute} and never reaches here. May throw
1373
+ * NotIndexed/PathRefusal, which {@link executeReadTool} classifies.
1374
+ */
1375
+ async dispatchTool(toolName, args) {
1376
+ switch (toolName) {
1377
+ case 'codegraph_search': return await this.handleSearch(args);
1378
+ case 'codegraph_callers': return await this.handleCallers(args);
1379
+ case 'codegraph_callees': return await this.handleCallees(args);
1380
+ case 'codegraph_impact': return await this.handleImpact(args);
1381
+ case 'codegraph_explore': return await this.handleExplore(args);
1382
+ case 'codegraph_node': return await this.handleNode(args);
1383
+ case 'codegraph_files': return await this.handleFiles(args);
1384
+ default: return this.errorResult(`Unknown tool: ${toolName}`);
1385
+ }
1386
+ }
1387
+ /**
1388
+ * Handle codegraph_search
1389
+ */
1390
+ async handleSearch(args) {
1391
+ const query = this.validateString(args.query, 'query');
1392
+ if (typeof query !== 'string')
1393
+ return query;
1394
+ const cg = this.getCodeGraph(args.projectPath);
1395
+ const rawKind = args.kind;
1396
+ // The schema enum says 'type' (what agents naturally reach for); the
1397
+ // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
1398
+ // matched nothing — a filter value we advertise must work.
1399
+ const kind = rawKind === 'type' ? 'type_alias' : rawKind;
1400
+ const rawLimit = Number(args.limit) || 10;
1401
+ const limit = (0, utils_1.clamp)(rawLimit, 1, 100);
1402
+ const results = cg.searchNodes(query, {
1403
+ limit,
1404
+ kinds: kind ? [kind] : undefined,
1405
+ });
1406
+ if (results.length === 0) {
1407
+ return this.textResult(`No results found for "${query}"`);
1408
+ }
1409
+ // Down-rank generated files within the FTS-returned set so a search
1410
+ // for "Send" surfaces the hand-written keeper before .pb.go stubs
1411
+ // that share the name. Stable: only reorders generated vs. not.
1412
+ const ranked = [...results].sort((a, b) => {
1413
+ const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
1414
+ const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
1415
+ return aGen - bGen;
1416
+ });
1417
+ const formatted = this.formatSearchResults(ranked);
1418
+ return this.textResult(this.truncateOutput(formatted));
1419
+ }
1420
+ /**
1421
+ * Group symbol matches into DISTINCT DEFINITIONS — one group per
1422
+ * (filePath, qualifiedName), so same-file overloads stay together while
1423
+ * unrelated same-named classes across a monorepo's apps (#764: one
1424
+ * `UserService` per NestJS app) are kept apart. Optionally narrowed by a
1425
+ * `file` path/suffix first.
1426
+ */
1427
+ groupDefinitions(nodes, fileFilter) {
1428
+ let pool = nodes;
1429
+ let filteredOut = false;
1430
+ if (fileFilter) {
1431
+ const wanted = fileFilter.replace(/^\.\//, '');
1432
+ const narrowed = pool.filter((n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`));
1433
+ if (narrowed.length > 0) {
1434
+ pool = narrowed;
1435
+ }
1436
+ else {
1437
+ filteredOut = true;
1438
+ }
1439
+ }
1440
+ const byDef = new Map();
1441
+ for (const n of pool) {
1442
+ const key = `${n.filePath}|${n.qualifiedName}`;
1443
+ const group = byDef.get(key);
1444
+ if (group)
1445
+ group.push(n);
1446
+ else
1447
+ byDef.set(key, [n]);
1448
+ }
1449
+ return { groups: [...byDef.values()], filteredOut };
1450
+ }
1451
+ /** Section heading for one distinct definition in grouped output. */
1452
+ definitionHeading(group) {
1453
+ const head = group[0];
1454
+ const line = head.startLine ? `:${head.startLine}` : '';
1455
+ return `**${head.qualifiedName}** (${head.kind}) — ${head.filePath}${line}`;
1456
+ }
1457
+ /**
1458
+ * Handle codegraph_callers
1459
+ */
1460
+ async handleCallers(args) {
1461
+ const symbol = this.validateString(args.symbol, 'symbol');
1462
+ if (typeof symbol !== 'string')
1463
+ return symbol;
1464
+ const cg = this.getCodeGraph(args.projectPath);
1465
+ const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
1466
+ const fileFilter = typeof args.file === 'string' ? args.file : undefined;
1467
+ const allMatches = this.findAllSymbols(cg, symbol);
1468
+ if (allMatches.nodes.length === 0) {
1469
+ return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1470
+ }
1471
+ const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
1472
+ const filterNote = filteredOut
1473
+ ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
1474
+ : '';
1475
+ const collect = (defNodes) => {
1476
+ const seen = new Set();
1477
+ const callers = [];
1478
+ const labels = new Map();
1479
+ for (const node of defNodes) {
1480
+ for (const c of cg.getCallers(node.id)) {
1481
+ if (!seen.has(c.node.id)) {
1482
+ seen.add(c.node.id);
1483
+ callers.push(c.node);
1484
+ const label = this.edgeLabel(c.edge);
1485
+ if (label)
1486
+ labels.set(c.node.id, label);
1487
+ }
1488
+ }
1489
+ }
1490
+ return { callers, labels };
1491
+ };
1492
+ // Single definition (or same-file overloads): the familiar flat list.
1493
+ if (groups.length === 1) {
1494
+ const { callers, labels } = collect(groups[0]);
1495
+ if (callers.length === 0) {
1496
+ return this.textResult(`No callers found for "${symbol}"${allMatches.note}${filterNote}`);
1497
+ }
1498
+ // A successful `file` narrowing makes the multi-symbol aggregation note
1499
+ // stale — suppress it.
1500
+ const note = fileFilter && !filteredOut ? '' : allMatches.note;
1501
+ const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote;
1502
+ return this.textResult(this.truncateOutput(formatted));
1503
+ }
1504
+ // Multiple DISTINCT definitions (#764): one section per definition so an
1505
+ // agent never mistakes one app's callers for another's. Narrow with
1506
+ // `file` to focus a single definition.
1507
+ const lines = [
1508
+ `**Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
1509
+ ];
1510
+ for (const group of groups) {
1511
+ const { callers, labels } = collect(group);
1512
+ lines.push('', this.definitionHeading(group));
1513
+ if (callers.length === 0) {
1514
+ lines.push('- (no callers)');
1515
+ continue;
1516
+ }
1517
+ for (const node of callers.slice(0, limit)) {
1518
+ const location = node.startLine ? `:${node.startLine}` : '';
1519
+ const label = labels.get(node.id);
1520
+ lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
1521
+ }
1522
+ }
1523
+ return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
1524
+ }
1525
+ /**
1526
+ * Handle codegraph_callees
1527
+ */
1528
+ async handleCallees(args) {
1529
+ const symbol = this.validateString(args.symbol, 'symbol');
1530
+ if (typeof symbol !== 'string')
1531
+ return symbol;
1532
+ const cg = this.getCodeGraph(args.projectPath);
1533
+ const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
1534
+ const fileFilter = typeof args.file === 'string' ? args.file : undefined;
1535
+ const allMatches = this.findAllSymbols(cg, symbol);
1536
+ if (allMatches.nodes.length === 0) {
1537
+ return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1538
+ }
1539
+ const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
1540
+ const filterNote = filteredOut
1541
+ ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
1542
+ : '';
1543
+ const collect = (defNodes) => {
1544
+ const seen = new Set();
1545
+ const callees = [];
1546
+ const labels = new Map();
1547
+ for (const node of defNodes) {
1548
+ for (const c of cg.getCallees(node.id)) {
1549
+ if (!seen.has(c.node.id)) {
1550
+ seen.add(c.node.id);
1551
+ callees.push(c.node);
1552
+ const label = this.edgeLabel(c.edge);
1553
+ if (label)
1554
+ labels.set(c.node.id, label);
1555
+ }
1556
+ }
1557
+ }
1558
+ return { callees, labels };
1559
+ };
1560
+ if (groups.length === 1) {
1561
+ const { callees, labels } = collect(groups[0]);
1562
+ if (callees.length === 0) {
1563
+ return this.textResult(`No callees found for "${symbol}"${allMatches.note}${filterNote}`);
1564
+ }
1565
+ // A successful `file` narrowing makes the multi-symbol aggregation note
1566
+ // stale — suppress it.
1567
+ const note = fileFilter && !filteredOut ? '' : allMatches.note;
1568
+ const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + note + filterNote;
1569
+ return this.textResult(this.truncateOutput(formatted));
1570
+ }
1571
+ // Multiple DISTINCT definitions (#764): per-definition sections.
1572
+ const lines = [
1573
+ `**Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)**`,
1574
+ ];
1575
+ for (const group of groups) {
1576
+ const { callees, labels } = collect(group);
1577
+ lines.push('', this.definitionHeading(group));
1578
+ if (callees.length === 0) {
1579
+ lines.push('- (no callees)');
1580
+ continue;
1581
+ }
1582
+ for (const node of callees.slice(0, limit)) {
1583
+ const location = node.startLine ? `:${node.startLine}` : '';
1584
+ const label = labels.get(node.id);
1585
+ lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
1586
+ }
1587
+ }
1588
+ return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
1589
+ }
1590
+ /**
1591
+ * Handle codegraph_impact
1592
+ */
1593
+ async handleImpact(args) {
1594
+ const symbol = this.validateString(args.symbol, 'symbol');
1595
+ if (typeof symbol !== 'string')
1596
+ return symbol;
1597
+ const cg = this.getCodeGraph(args.projectPath);
1598
+ const depth = (0, utils_1.clamp)(args.depth || 2, 1, 10);
1599
+ const fileFilter = typeof args.file === 'string' ? args.file : undefined;
1600
+ const allMatches = this.findAllSymbols(cg, symbol);
1601
+ if (allMatches.nodes.length === 0) {
1602
+ return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1603
+ }
1604
+ const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
1605
+ const filterNote = filteredOut
1606
+ ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
1607
+ : '';
1608
+ const impactOf = (defNodes) => {
1609
+ const mergedNodes = new Map();
1610
+ const mergedEdges = [];
1611
+ const seenEdges = new Set();
1612
+ for (const node of defNodes) {
1613
+ const impact = cg.getImpactRadius(node.id, depth);
1614
+ for (const [id, n] of impact.nodes) {
1615
+ mergedNodes.set(id, n);
1616
+ }
1617
+ for (const e of impact.edges) {
1618
+ const key = `${e.source}->${e.target}:${e.kind}`;
1619
+ if (!seenEdges.has(key)) {
1620
+ seenEdges.add(key);
1621
+ mergedEdges.push(e);
1622
+ }
1623
+ }
1624
+ }
1625
+ return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) };
1626
+ };
1627
+ // Single definition (or same-file overloads): the familiar merged report.
1628
+ if (groups.length === 1) {
1629
+ const formatted = this.formatImpact(symbol, impactOf(groups[0])) + (fileFilter && !filteredOut ? "" : allMatches.note) + filterNote;
1630
+ return this.textResult(this.truncateOutput(formatted));
1631
+ }
1632
+ // Multiple DISTINCT definitions (#764): a blast radius PER definition —
1633
+ // merging unrelated same-named classes (one UserService per monorepo app)
1634
+ // overstated impact and confused agents. Narrow with `file`.
1635
+ const sections = [
1636
+ `**Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)**`,
1637
+ ];
1638
+ for (const group of groups) {
1639
+ const head = group[0];
1640
+ const line = head.startLine ? `:${head.startLine}` : '';
1641
+ sections.push('', this.formatImpact(`${head.qualifiedName} (${head.filePath}${line})`, impactOf(group)));
1642
+ }
1643
+ return this.textResult(this.truncateOutput(sections.join('\n') + filterNote));
1644
+ }
1645
+ /**
1646
+ * Describe a synthesized (dynamic-dispatch) edge for human output: how the
1647
+ * callback was wired up — the bridge static parsing can't see. Returns null
1648
+ * for ordinary static edges. Used by trace + the node trail so a synthesized
1649
+ * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
1650
+ */
1651
+ synthEdgeNote(edge) {
1652
+ if (!edge || edge.provenance !== 'heuristic')
1653
+ return null;
1654
+ const m = edge.metadata;
1655
+ const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
1656
+ const at = registeredAt ? ` @${registeredAt}` : '';
1657
+ if (m?.synthesizedBy === 'callback') {
1658
+ const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
1659
+ const field = m.field ? ` on .${String(m.field)}` : '';
1660
+ return {
1661
+ label: `callback — registered via ${via}${field} (dynamic dispatch)`,
1662
+ compact: `dynamic: callback via ${via}${at}`,
1663
+ registeredAt,
1664
+ };
1665
+ }
1666
+ if (m?.synthesizedBy === 'event-emitter') {
1667
+ const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
1668
+ return {
1669
+ label: `event ${ev} — emit → handler (dynamic dispatch)`,
1670
+ compact: `dynamic: event ${ev}${at}`,
1671
+ registeredAt,
1672
+ };
1673
+ }
1674
+ if (m?.synthesizedBy === 'react-render') {
1675
+ return {
1676
+ label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
1677
+ compact: `dynamic: React re-render via setState${at}`,
1678
+ registeredAt,
1679
+ };
1680
+ }
1681
+ if (m?.synthesizedBy === 'jsx-render') {
1682
+ const child = m.via ? `<${String(m.via)}>` : 'a child component';
1683
+ return {
1684
+ label: `renders ${child} (JSX child — dynamic dispatch)`,
1685
+ compact: `dynamic: renders ${child}`,
1686
+ registeredAt,
1687
+ };
1688
+ }
1689
+ if (m?.synthesizedBy === 'vue-handler') {
1690
+ const ev = m.event ? `@${String(m.event)}` : 'a template event';
1691
+ return {
1692
+ label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
1693
+ compact: `dynamic: Vue ${ev} handler`,
1694
+ registeredAt,
1695
+ };
1696
+ }
1697
+ if (m?.synthesizedBy === 'interface-impl') {
1698
+ return {
1699
+ label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
1700
+ compact: `dynamic: interface → impl${at}`,
1701
+ registeredAt,
1702
+ };
1703
+ }
1704
+ if (m?.synthesizedBy === 'closure-collection') {
1705
+ const field = m.field ? `\`${String(m.field)}\`` : 'a collection';
1706
+ return {
1707
+ label: `closure collection — runs handlers appended to ${field} (dynamic dispatch)`,
1708
+ compact: `dynamic: runs ${field} handlers${at}`,
1709
+ registeredAt,
1710
+ };
1711
+ }
1712
+ if (m?.synthesizedBy === 'fn-pointer-dispatch') {
1713
+ const via = m.via ? `\`${String(m.via)}\`` : 'a function pointer';
1714
+ return {
1715
+ label: `function-pointer dispatch via ${via} (dynamic dispatch)`,
1716
+ compact: `dynamic: fn-pointer ${m.via ? String(m.via) : ''}${at}`,
1717
+ registeredAt,
1718
+ };
1719
+ }
1720
+ if (m?.synthesizedBy === 'goframe-route') {
1721
+ const route = m.route ? `\`${String(m.route)}\`` : 'a route';
1722
+ return {
1723
+ label: `GoFrame route ${route} — reflective Bind → controller method (dynamic dispatch)`,
1724
+ compact: `dynamic: GoFrame route ${m.route ? String(m.route) : ''}${at}`,
1725
+ registeredAt,
1726
+ };
1727
+ }
1728
+ // Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain,
1729
+ // flutter-build, …): a synthesized hop must never read as a bare static `calls`.
1730
+ // It's a dynamic-dispatch bridge — label it as one and keep its wiring site.
1731
+ if (typeof m?.synthesizedBy === 'string') {
1732
+ const kind = m.synthesizedBy.replace(/-/g, ' ');
1733
+ return { label: `${kind} (dynamic dispatch)`, compact: `dynamic: ${kind}${at}`, registeredAt };
1734
+ }
1735
+ return null;
1736
+ }
1737
+ /**
1738
+ * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
1739
+ * symbol names that usually spans the flow it's investigating (e.g.
1740
+ * "PmsProductController getList PmsProductService list PmsProductServiceImpl").
1741
+ * Surface the longest call chain AMONG those named symbols — scoped to what the
1742
+ * agent explicitly named, so (unlike a fuzzy relevance set) there's no
1743
+ * wrong-feature wandering. Rides synthesized edges, so controller→service-
1744
+ * interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
1745
+ *
1746
+ * Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
1747
+ * CO-NAMING: the agent names the class too, so we keep only `list` candidates
1748
+ * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
1749
+ * dropping unrelated `OmsOrderService::list`.
1750
+ */
1751
+ buildFlowFromNamedSymbols(cg, query) {
1752
+ // spineCallSites: for each spine node, the line where it CALLS the next hop —
1753
+ // lets the source assembler window an oversize spine method (e.g. n8n's 962-line
1754
+ // processRunExecutionData) to the call site instead of dumping the whole body.
1755
+ const EMPTY = { text: '', pathNodeIds: new Set(), namedNodeIds: new Set(), uniqueNamedNodeIds: new Set(), spineCallSites: new Map() };
1756
+ try {
1757
+ const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
1758
+ // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
1759
+ // names (Class.method / Class::method) — the agent's most precise input,
1760
+ // resolved exactly by findAllSymbols. (The old strip mangled Class.method
1761
+ // into Class, throwing the method away.)
1762
+ const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
1763
+ const tokens = [...new Set(query.split(/[\s,()[\]]+/)
1764
+ .map((t) => t.replace(FILE_EXT, '').trim())
1765
+ .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
1766
+ if (tokens.length < 2)
1767
+ return EMPTY;
1768
+ // Pool of name SEGMENTS (Class + method from every token) used to
1769
+ // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
1770
+ // CONTAINER class is itself named in the query.
1771
+ const segPool = new Set();
1772
+ for (const t of tokens)
1773
+ for (const s of t.toLowerCase().split(/::|\./))
1774
+ if (s)
1775
+ segPool.add(s);
1776
+ const named = new Map();
1777
+ // Nodes whose token is SPECIFIC — a (near-)unique callable name (<=3 defs in
1778
+ // the whole graph). These are safe to SPARE a file on: the agent named THIS
1779
+ // method (`getResponseWithInterceptorChain`, 1 def). A hyper-polymorphic name
1780
+ // (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
1781
+ // so naming it doesn't keep every backend variant full and flood the budget.
1782
+ const uniqueNamedNodeIds = new Set();
1783
+ // token → resolved node ids: drives the token-coverage check that gates
1784
+ // the dynamic-boundary scan (a token is covered when ANY of its nodes
1785
+ // lands on the main chain — overloads off the chain don't count against).
1786
+ const tokenNodes = new Map();
1787
+ // token → its full same-name callable family (before the container filter).
1788
+ // A LARGE family that fails to connect on the chain is a polymorphic
1789
+ // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
1790
+ const tokenFamily = new Map();
1791
+ // Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
1792
+ // edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
1793
+ // is constant→constant — the CALLABLE-only `named` set can't hold it, and
1794
+ // without this the hop is invisible to the Flow path at every tier (the
1795
+ // Relationships section catches it only on repos ≥500 files). Kept SEPARATE
1796
+ // from `named` (which drives the call-chain + source sizing, callable-only);
1797
+ // fed only to the dynamic-dispatch-links scan below.
1798
+ const dynNamed = new Map();
1799
+ const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
1800
+ const hasHeuristicEdge = (id) => [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
1801
+ for (const t of tokens) {
1802
+ const hits = this.findAllSymbols(cg, t).nodes;
1803
+ const cands = hits.filter((n) => CALLABLE.has(n.kind));
1804
+ tokenFamily.set(t, cands);
1805
+ // A qualified or otherwise-specific name (<=3 hits) keeps all; an
1806
+ // ambiguous simple name keeps only candidates whose container is named.
1807
+ const specific = cands.length <= 3;
1808
+ const pick = specific
1809
+ ? cands
1810
+ : cands.filter((n) => {
1811
+ const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
1812
+ const container = segs.length >= 2 ? segs[segs.length - 2] : '';
1813
+ return !!container && segPool.has(container);
1814
+ });
1815
+ const kept = pick.slice(0, 6);
1816
+ tokenNodes.set(t, kept.map((n) => n.id));
1817
+ for (const n of kept) {
1818
+ named.set(n.id, n);
1819
+ if (specific)
1820
+ uniqueNamedNodeIds.add(n.id);
1821
+ }
1822
+ // Same token, non-callable synth endpoints (capped, precision-gated on an
1823
+ // actual heuristic edge so plain config constants never qualify).
1824
+ // Per-token sub-cap so one token's many endpoints (10 nix option writes
1825
+ // of `programs.git.enable` across test configs) can't fill the pool
1826
+ // before later tokens (`home.file`) get a slot.
1827
+ if (dynNamed.size < 12) {
1828
+ let tokenDyn = 0;
1829
+ for (const n of hits) {
1830
+ if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id))
1831
+ continue;
1832
+ if (hasHeuristicEdge(n.id)) {
1833
+ dynNamed.set(n.id, n);
1834
+ tokenDyn++;
1835
+ }
1836
+ if (dynNamed.size >= 12 || tokenDyn >= 4)
1837
+ break;
1838
+ }
1839
+ }
1840
+ if (named.size > 40)
1841
+ break;
1842
+ }
1843
+ // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
1844
+ // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
1845
+ // already shown in the rendered main chain (a 2-node chain renders nothing, so a
1846
+ // direct named→named synth hop still surfaces — #687).
1847
+ const collectSynthLinks = (skipInChain) => {
1848
+ const synthLines = [];
1849
+ const synthSeen = new Set();
1850
+ for (const n of [...named.values(), ...dynNamed.values()]) {
1851
+ if (synthLines.length >= 6)
1852
+ break;
1853
+ for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
1854
+ if (synthLines.length >= 6)
1855
+ break;
1856
+ if (edge.provenance !== 'heuristic' || other.id === n.id)
1857
+ continue;
1858
+ if (skipInChain && skipInChain(edge))
1859
+ continue;
1860
+ const src = edge.source === n.id ? n : other;
1861
+ const tgt = edge.source === n.id ? other : n;
1862
+ const key = `${src.name}>${tgt.name}`;
1863
+ if (synthSeen.has(key))
1864
+ continue;
1865
+ synthSeen.add(key);
1866
+ const note = this.synthEdgeNote(edge);
1867
+ synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
1868
+ }
1869
+ }
1870
+ return synthLines;
1871
+ };
1872
+ if (named.size < 2) {
1873
+ // <2 CALLABLES resolved. Two recoveries before giving up: (1) synthesized
1874
+ // edges among named CONSTANT/VARIABLE endpoints — RTK thunk→thunk is
1875
+ // constant→constant, so `named` can be empty while `dynNamed` holds the
1876
+ // whole chain; (2) the one resolved callable's body may hold the
1877
+ // dynamic-dispatch site that EXPLAINS a half-connected flow.
1878
+ const synthLines = collectSynthLinks(null);
1879
+ const boundaries = named.size === 0 ? '' : (this.buildDynamicBoundaries(cg, [...named.values()], named) || '');
1880
+ if (synthLines.length === 0 && !boundaries)
1881
+ return EMPTY;
1882
+ const out = [];
1883
+ if (synthLines.length)
1884
+ out.push('**Dynamic-dispatch links among your symbols**', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
1885
+ if (boundaries)
1886
+ out.push(boundaries);
1887
+ out.push('> Full source for these symbols is below.\n');
1888
+ return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map() };
1889
+ }
1890
+ const MAX_HOPS = 7;
1891
+ let best = null;
1892
+ // BFS the full call graph (incl. synth edges) from each named seed, but
1893
+ // only ACCEPT a sink that is also named — both ends anchored to symbols the
1894
+ // agent named, so the chain stays on-topic while bridging intermediates
1895
+ // (e.g. the exact interface overload) that the token resolution missed.
1896
+ for (const seed of [...named.values()].slice(0, 8)) {
1897
+ const parent = new Map();
1898
+ parent.set(seed.id, { prev: null, edge: null, node: seed });
1899
+ const q = [{ id: seed.id, depth: 0, streak: 0 }];
1900
+ let deep = null, deepDepth = 0;
1901
+ const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
1902
+ for (let h = 0; h < q.length && parent.size < 1500; h++) {
1903
+ const { id, depth, streak } = q[h];
1904
+ if (id !== seed.id && named.has(id) && depth > deepDepth) {
1905
+ deep = id;
1906
+ deepDepth = depth;
1907
+ }
1908
+ if (depth >= MAX_HOPS - 1)
1909
+ continue;
1910
+ for (const c of cg.getCallees(id)) {
1911
+ if (c.edge.kind !== 'calls' || parent.has(c.node.id))
1912
+ continue;
1913
+ const newStreak = named.has(c.node.id) ? 0 : streak + 1;
1914
+ if (newStreak > MAX_BRIDGE)
1915
+ continue;
1916
+ parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
1917
+ q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
1918
+ }
1919
+ }
1920
+ if (!deep)
1921
+ continue;
1922
+ const chain = [];
1923
+ let cur = deep;
1924
+ while (cur) {
1925
+ const p = parent.get(cur);
1926
+ if (!p)
1927
+ break;
1928
+ chain.push({ node: p.node, edge: p.edge });
1929
+ cur = p.prev;
1930
+ }
1931
+ chain.reverse();
1932
+ if (!best || chain.length > best.length)
1933
+ best = chain;
1934
+ }
1935
+ const hasMain = !!best && best.length >= 3;
1936
+ const pathIds = new Set((best ?? []).map((s) => s.node.id));
1937
+ // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
1938
+ // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
1939
+ // the assembler window an oversize spine method to the call instead of dumping it.
1940
+ const spineCallSites = new Map();
1941
+ if (best)
1942
+ for (let i = 0; i < best.length - 1; i++) {
1943
+ const ln = best[i + 1]?.edge?.line;
1944
+ if (ln && ln > 0 && !spineCallSites.has(best[i].node.id))
1945
+ spineCallSites.set(best[i].node.id, ln);
1946
+ }
1947
+ // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
1948
+ // asked about did not fully connect: some token resolved to nodes but
1949
+ // none of them sit on the main chain (or there is no chain at all). A
1950
+ // healthy flow skips this entirely. Scan order: the chain's dead end
1951
+ // first (where the partial flow stops), then the disconnected symbols,
1952
+ // agent-specific (unique-named) ones first.
1953
+ let boundaryText = '';
1954
+ {
1955
+ const uncovered = [];
1956
+ if (!hasMain) {
1957
+ // No rendered chain — but a 2-node chain still CONNECTS its two
1958
+ // endpoints (e.g. via one synthesized hop, surfaced below as a
1959
+ // dynamic-dispatch link). Only nodes off that short chain are
1960
+ // unexplained breaks worth scanning.
1961
+ for (const n of named.values())
1962
+ if (!pathIds.has(n.id))
1963
+ uncovered.push(n);
1964
+ }
1965
+ else {
1966
+ for (const ids of tokenNodes.values()) {
1967
+ if (ids.length === 0 || ids.some((id) => pathIds.has(id)))
1968
+ continue;
1969
+ for (const id of ids) {
1970
+ const n = named.get(id);
1971
+ if (n)
1972
+ uncovered.push(n);
1973
+ }
1974
+ }
1975
+ }
1976
+ if (uncovered.length > 0) {
1977
+ const scanList = [];
1978
+ if (hasMain)
1979
+ scanList.push(best[best.length - 1].node);
1980
+ scanList.push(...uncovered.sort((a, b) => (uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (uniqueNamedNodeIds.has(a.id) ? 1 : 0)));
1981
+ boundaryText = this.buildDynamicBoundaries(cg, scanList, named);
1982
+ }
1983
+ }
1984
+ // Interface/registry-dispatch announcement (extends #687 to GRAPH-visible
1985
+ // polymorphism). A method the agent NAMED that resolves to a large same-name
1986
+ // family AND did not land on the main chain is almost always a runtime
1987
+ // dispatch (plugin/strategy/handler interface): the concrete target is chosen
1988
+ // at runtime from N implementations, so no single static edge is the answer.
1989
+ // The body-scan above can't see this — `nodeType.execute()` is textually an
1990
+ // ordinary call; the polymorphism lives in the graph (implements edges), so
1991
+ // detect it there. Fires ONLY for an uncovered named token; a connected flow
1992
+ // stays silent.
1993
+ let polyText = '';
1994
+ {
1995
+ const POLY_MIN_FAMILY = 8; // smaller families are overload sets, not dispatch
1996
+ const polyCands = [];
1997
+ for (const [t, fam] of tokenFamily) {
1998
+ if (fam.length < POLY_MIN_FAMILY)
1999
+ continue;
2000
+ const ids = tokenNodes.get(t) || [];
2001
+ if (ids.some((id) => pathIds.has(id)))
2002
+ continue; // covered by the flow — silent
2003
+ polyCands.push({ token: t, family: fam });
2004
+ }
2005
+ if (polyCands.length)
2006
+ polyText = this.buildPolymorphicBoundaries(cg, polyCands, named);
2007
+ }
2008
+ // Supplementary: dynamic-dispatch (synthesized) edges incident to a named
2009
+ // symbol (incl. the non-callable CONSTANT endpoints in `dynNamed`) — the
2010
+ // indirect hops an agent would otherwise grep/Read to reconstruct ("where do
2011
+ // the appended `validators` actually run?"). Surfaced even when the OTHER end
2012
+ // wasn't named. The skip drops a hop already in the rendered main chain; a
2013
+ // 2-node chain renders nothing (hasMain false) so a direct named→named synth
2014
+ // hop still surfaces — too short for Flow, but #687-visible here.
2015
+ const synthLines = collectSynthLinks(hasMain ? (e) => pathIds.has(e.source) && pathIds.has(e.target) : null);
2016
+ if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText)
2017
+ return EMPTY;
2018
+ const out = [];
2019
+ if (hasMain) {
2020
+ out.push('**Flow (call path among the symbols you queried)**', '');
2021
+ for (let i = 0; i < best.length; i++) {
2022
+ const step = best[i];
2023
+ if (step.edge) {
2024
+ const sy = this.synthEdgeNote(step.edge);
2025
+ out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`);
2026
+ }
2027
+ out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
2028
+ }
2029
+ out.push('');
2030
+ }
2031
+ if (synthLines.length) {
2032
+ out.push('**Dynamic-dispatch links among your symbols**', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
2033
+ }
2034
+ if (boundaryText)
2035
+ out.push(boundaryText);
2036
+ if (polyText)
2037
+ out.push(polyText);
2038
+ out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
2039
+ // namedNodeIds = every callable the agent explicitly named (a superset of
2040
+ // the spine). A file holding one is something the agent asked to SEE, so it
2041
+ // must keep full source even if it's an off-spine polymorphic sibling — the
2042
+ // agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
2043
+ // as the mechanism, not as an interchangeable leaf. See the skeleton gate.
2044
+ return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites };
2045
+ }
2046
+ catch {
2047
+ return EMPTY;
2048
+ }
2049
+ }
2050
+ /**
2051
+ * Dynamic-boundary surfacing (#687): when the flow among the agent's named
2052
+ * symbols does not fully connect, scan the disconnected symbols' bodies for
2053
+ * dynamic-dispatch sites (computed member calls, getattr, reflection, typed
2054
+ * message buses, runtime-keyed emits) and ANNOUNCE the boundary — the exact
2055
+ * site, the form, and (when a key is statically visible) candidate targets —
2056
+ * instead of guessing edges. The answer to "how does A reach B" when no
2057
+ * static path exists IS the dispatch site: that's where the flow continues
2058
+ * at runtime. Query-time, deterministic, zero graph mutation; a fully
2059
+ * connected flow never reaches this method.
2060
+ */
2061
+ buildDynamicBoundaries(cg, scanList, named) {
2062
+ const MAX_NOTES = 4; // boundary bullets per explore
2063
+ const MAX_SCAN = 8; // bodies scanned
2064
+ const MAX_TOTAL_CHARS = 200_000;
2065
+ let projectRoot;
2066
+ try {
2067
+ projectRoot = cg.getProjectRoot();
2068
+ }
2069
+ catch {
2070
+ return '';
2071
+ }
2072
+ const notes = [];
2073
+ const seenNode = new Set();
2074
+ const seenSite = new Set();
2075
+ let scanned = 0, charsScanned = 0;
2076
+ for (const node of scanList) {
2077
+ if (notes.length >= MAX_NOTES || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS)
2078
+ break;
2079
+ if (seenNode.has(node.id) || !node.startLine || !node.endLine)
2080
+ continue;
2081
+ seenNode.add(node.id);
2082
+ const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, node.filePath);
2083
+ if (!absPath || !(0, fs_1.existsSync)(absPath))
2084
+ continue;
2085
+ let content;
2086
+ try {
2087
+ content = (0, fs_1.readFileSync)(absPath, 'utf-8');
2088
+ }
2089
+ catch {
2090
+ continue;
2091
+ }
2092
+ const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
2093
+ scanned++;
2094
+ charsScanned += body.length;
2095
+ for (const m of (0, dynamic_boundaries_1.scanDynamicDispatch)(body, node.language || '', node.startLine)) {
2096
+ if (notes.length >= MAX_NOTES)
2097
+ break;
2098
+ const siteKey = `${node.filePath}:${m.line}:${m.form}`;
2099
+ if (seenSite.has(siteKey))
2100
+ continue;
2101
+ seenSite.add(siteKey);
2102
+ const more = m.moreSites ? ` (+${m.moreSites} more such site${m.moreSites > 1 ? 's' : ''} in this body)` : '';
2103
+ notes.push(`- \`${node.name}\` (${node.filePath}:${m.line}) — ${m.label}: \`${m.snippet}\`${more}`);
2104
+ if (m.key) {
2105
+ const cand = this.boundaryCandidates(cg, m.key, !!m.keyIsType, named, node.id);
2106
+ if (cand)
2107
+ notes.push(` ${cand}`);
2108
+ }
2109
+ }
2110
+ }
2111
+ if (notes.length === 0)
2112
+ return '';
2113
+ return [
2114
+ '**Dynamic boundaries (the static path ends at runtime dispatch)**',
2115
+ '',
2116
+ ...notes,
2117
+ '',
2118
+ '> These sites choose their call target at runtime (registry / bus / reflection) — the site shown IS where the flow continues. To follow it, run codegraph_explore or codegraph_node on a candidate; source for the sites above is included below.',
2119
+ '',
2120
+ ].join('\n');
2121
+ }
2122
+ /**
2123
+ * Interface/registry-dispatch announcement — #687 extended to GRAPH-visible
2124
+ * polymorphism (the body-scan can't see it: `nodeType.execute()` is textually
2125
+ * an ordinary call; the polymorphism lives in the `implements`/`extends` edges).
2126
+ *
2127
+ * A method the agent named that resolves to a large same-name family whose
2128
+ * definers overwhelmingly implement/extend ONE supertype is a runtime dispatch:
2129
+ * the concrete target is chosen at runtime from N implementations, so no single
2130
+ * static edge is "the answer" — the implementations ARE the continuations. We
2131
+ * announce the supertype, its TRUE implementer count, and a few concrete targets,
2132
+ * then steer to codegraph_explore. Graph-only, query-time, zero mutation; the
2133
+ * caller fires it ONLY for an UNCOVERED named token, so a connected flow is silent.
2134
+ *
2135
+ * Robust to FTS sampling bias: the same-name family is a capped FTS sample that
2136
+ * over-represents whatever FTS ranks first (n8n: DB `TableOperation.execute`
2137
+ * outnumbered `INodeType.execute` in the sample 7:6 even though INodeType has
2138
+ * 611 implementers vs a handful). So candidate supertypes are ranked by their
2139
+ * TRUE graph-wide implementer count, NOT their frequency in the sample.
2140
+ */
2141
+ buildPolymorphicBoundaries(cg, candidates, named) {
2142
+ const CLASSY = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'abstract']);
2143
+ const MIN_IMPL = 8; // a supertype needs >= this many implementers to count as "polymorphic"
2144
+ const MIN_SUPPORT = 2; // >= this many sampled definers must share the supertype (ties it to the token)
2145
+ const SAMPLE = 40; // family members inspected per token
2146
+ const MAX_NOTES = 3;
2147
+ const rel = (p) => p.replace(/\\/g, '/');
2148
+ const containerOf = (m) => {
2149
+ try {
2150
+ const ce = cg.getIncomingEdges(m.id).find((e) => e.kind === 'contains');
2151
+ return ce ? cg.getNode(ce.source) : null;
2152
+ }
2153
+ catch {
2154
+ return null;
2155
+ }
2156
+ };
2157
+ const notes = [];
2158
+ const seenSuper = new Set();
2159
+ for (const { token, family } of candidates) {
2160
+ if (notes.length >= MAX_NOTES)
2161
+ break;
2162
+ // supertype id → how many sampled definers share it + a few example definers
2163
+ const supers = new Map();
2164
+ for (const m of family.slice(0, SAMPLE)) {
2165
+ const container = containerOf(m);
2166
+ if (!container || !CLASSY.has(container.kind))
2167
+ continue;
2168
+ let sups = [];
2169
+ try {
2170
+ sups = cg.getOutgoingEdges(container.id)
2171
+ .filter((e) => e.kind === 'implements' || e.kind === 'extends')
2172
+ .map((e) => { try {
2173
+ return cg.getNode(e.target);
2174
+ }
2175
+ catch {
2176
+ return null;
2177
+ } })
2178
+ .filter((n) => !!n && CLASSY.has(n.kind) && (n.name?.length || 0) >= 3);
2179
+ }
2180
+ catch { /* no supertypes — free function or unresolved */ }
2181
+ for (const s of sups) {
2182
+ const e = supers.get(s.id) || { node: s, count: 0, targets: [] };
2183
+ e.count++;
2184
+ if (e.targets.length < 6)
2185
+ e.targets.push(m);
2186
+ supers.set(s.id, e);
2187
+ }
2188
+ }
2189
+ // Pick the supertype with the most TRUE implementers (graph-wide), among
2190
+ // those genuinely shared by the token's definers.
2191
+ let best = null;
2192
+ for (const { node, count, targets } of supers.values()) {
2193
+ if (count < MIN_SUPPORT)
2194
+ continue;
2195
+ let impl = 0;
2196
+ try {
2197
+ impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length;
2198
+ }
2199
+ catch { /* leave 0 — gated out below */ }
2200
+ if (impl < MIN_IMPL)
2201
+ continue;
2202
+ if (!best || impl > best.impl)
2203
+ best = { node, impl, targets };
2204
+ }
2205
+ if (!best || seenSuper.has(best.node.id))
2206
+ continue;
2207
+ seenSuper.add(best.node.id);
2208
+ const namedNames = new Set([...named.values()].map((n) => n.name));
2209
+ const eg = best.targets.slice(0, 4).map((m) => {
2210
+ const cont = containerOf(m);
2211
+ const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name);
2212
+ const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : '';
2213
+ return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`;
2214
+ });
2215
+ const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : '';
2216
+ notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`);
2217
+ }
2218
+ if (notes.length === 0)
2219
+ return '';
2220
+ return [
2221
+ '**Interface dispatch (a named method has many implementations)**',
2222
+ '',
2223
+ ...notes,
2224
+ '',
2225
+ '> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) — there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run codegraph_explore on a listed target.',
2226
+ '',
2227
+ ].join('\n');
2228
+ }
2229
+ /**
2230
+ * Shortlist candidate runtime targets for a dispatch key surfaced by
2231
+ * {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
2232
+ * `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
2233
+ * normalized-containment post-filter (FTS camel-splitting is fuzzier than a
2234
+ * candidate list should be). Symbols the agent already named sort first and
2235
+ * are marked — that's the "you were right, here's the wiring" case.
2236
+ */
2237
+ boundaryCandidates(cg, key, keyIsType, named, selfId) {
2238
+ const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
2239
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
2240
+ const keyNorm = norm(key);
2241
+ if (keyNorm.length < 3)
2242
+ return '';
2243
+ const cands = new Map();
2244
+ const consider = (n) => {
2245
+ if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id))
2246
+ return;
2247
+ const nameNorm = norm(n.name || '');
2248
+ if (nameNorm.length < 3)
2249
+ return;
2250
+ if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm))
2251
+ return;
2252
+ cands.set(n.id, n);
2253
+ };
2254
+ const cap = key.charAt(0).toUpperCase() + key.slice(1);
2255
+ const probes = keyIsType
2256
+ ? [`${key}Handler`, key]
2257
+ : [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
2258
+ for (const p of probes) {
2259
+ try {
2260
+ for (const n of cg.getNodesByName(p))
2261
+ consider(n);
2262
+ }
2263
+ catch { /* exact probe miss is fine */ }
2264
+ }
2265
+ let raw = 0;
2266
+ try {
2267
+ const results = cg.searchNodes(key, { limit: 12 });
2268
+ raw = results.length;
2269
+ for (const r of results)
2270
+ consider(r.node);
2271
+ }
2272
+ catch { /* FTS syntax edge — exact probes already ran */ }
2273
+ if (cands.size === 0) {
2274
+ return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
2275
+ }
2276
+ // A constructor candidate duplicates its class: extractors emit ctors as
2277
+ // METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
2278
+ const all = [...cands.values()];
2279
+ const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
2280
+ const namedNames = new Set([...named.values()].map((n) => n.name));
2281
+ const isNamed = (n) => named.has(n.id) || namedNames.has(n.name); // the flow's named set holds callables only — transfer the mark to the class
2282
+ const list = all
2283
+ .filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
2284
+ .sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
2285
+ .slice(0, 4)
2286
+ .map((n) => {
2287
+ // Typed-bus convention: the runtime target is the candidate class's
2288
+ // Handle/Execute/Consume method — name the exact node, not just the class.
2289
+ let display = n.qualifiedName || n.name;
2290
+ let at = `${n.filePath}:${n.startLine}`;
2291
+ if (keyIsType && n.kind === 'class') {
2292
+ try {
2293
+ const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
2294
+ const method = cg.getOutgoingEdges(n.id)
2295
+ .filter((e) => e.kind === 'contains')
2296
+ .map((e) => { try {
2297
+ return cg.getNode(e.target);
2298
+ }
2299
+ catch {
2300
+ return null;
2301
+ } })
2302
+ .find((c) => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
2303
+ if (method) {
2304
+ display = `${n.name}.${method.name}`;
2305
+ at = `${method.filePath}:${method.startLine}`;
2306
+ }
2307
+ }
2308
+ catch { /* class without resolvable members — show the class itself */ }
2309
+ }
2310
+ return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
2311
+ });
2312
+ return `candidates for key \`${key}\`: ${list.join(', ')}`;
2313
+ }
2314
+ /**
2315
+ * Compact "blast radius" for the entry symbols of an explore result: who
2316
+ * depends on each (callers) and which test files cover it — LOCATIONS ONLY,
2317
+ * no source, so the agent knows what to update / re-verify before editing
2318
+ * without reaching for a separate impact call. Always-on, but skips symbols
2319
+ * that have no dependents (nothing to warn about), and returns '' when none
2320
+ * qualify so a leaf-only exploration stays clean.
2321
+ */
2322
+ buildBlastRadiusSection(cg, subgraph) {
2323
+ const ROOT_CAP = 5; // only the symbols the query actually targeted
2324
+ const FILE_CAP = 4; // caller files listed per symbol before "+N more"
2325
+ const MEANINGFUL = new Set([
2326
+ 'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol',
2327
+ 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field',
2328
+ ]);
2329
+ const rel = (p) => p.replace(/\\/g, '/');
2330
+ const roots = subgraph.roots
2331
+ .map((id) => subgraph.nodes.get(id))
2332
+ .filter((n) => !!n && MEANINGFUL.has(n.kind))
2333
+ .slice(0, ROOT_CAP);
2334
+ if (roots.length === 0)
2335
+ return '';
2336
+ const entries = [];
2337
+ for (const root of roots) {
2338
+ let callers = [];
2339
+ try {
2340
+ callers = cg.getCallers(root.id);
2341
+ }
2342
+ catch { /* skip this root */ }
2343
+ const seen = new Set();
2344
+ const uniq = [];
2345
+ for (const c of callers) {
2346
+ if (c?.node && !seen.has(c.node.id)) {
2347
+ seen.add(c.node.id);
2348
+ uniq.push(c.node);
2349
+ }
2350
+ }
2351
+ if (uniq.length === 0)
2352
+ continue; // no blast radius → nothing to flag
2353
+ const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
2354
+ const testFiles = callerFiles.filter((f) => (0, query_utils_1.isTestFile)(f));
2355
+ const nonTest = callerFiles.filter((f) => !(0, query_utils_1.isTestFile)(f));
2356
+ const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
2357
+ const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
2358
+ const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
2359
+ const tests = testFiles.length > 0
2360
+ ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
2361
+ : '; ⚠️ no covering tests found';
2362
+ entries.push(`- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`);
2363
+ }
2364
+ if (entries.length === 0)
2365
+ return '';
2366
+ return [
2367
+ '**Blast radius — what depends on these (update/verify before editing)**',
2368
+ '',
2369
+ ...entries,
2370
+ '',
2371
+ ].join('\n');
2372
+ }
2373
+ /**
2374
+ * Graph-connectivity relevance via Random-Walk-with-Restart (personalized
2375
+ * PageRank) from the query's matched SEED nodes over the call/reference graph.
2376
+ *
2377
+ * This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
2378
+ * codegraph's home turf: relevance by STRUCTURE, not words. A file whose
2379
+ * symbols are call-connected to the matched cluster accrues walk mass and
2380
+ * ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
2381
+ * "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
2382
+ * — gets only its own restart probability and ranks ~0. Immune to the
2383
+ * tokenization trap that fools term matching, deterministic, no embeddings.
2384
+ *
2385
+ * Undirected adjacency (reachability both ways), restart α=0.25 to the seeds,
2386
+ * power iteration to convergence. Bounded to the already-relevant subgraph, so
2387
+ * it's a few hundred nodes × ~25 iterations — negligible cost.
2388
+ */
2389
+ computeGraphRelevance(nodeIds, edges, seedIds) {
2390
+ const out = new Map();
2391
+ const n = nodeIds.length;
2392
+ if (n === 0)
2393
+ return out;
2394
+ const idx = new Map();
2395
+ for (let i = 0; i < n; i++)
2396
+ idx.set(nodeIds[i], i);
2397
+ const RANK_EDGES = new Set([
2398
+ 'calls', 'references', 'extends', 'implements', 'overrides',
2399
+ 'instantiates', 'returns', 'type_of', 'imports',
2400
+ ]);
2401
+ const adj = Array.from({ length: n }, () => []);
2402
+ for (const e of edges) {
2403
+ if (!RANK_EDGES.has(e.kind))
2404
+ continue;
2405
+ const i = idx.get(e.source);
2406
+ const j = idx.get(e.target);
2407
+ if (i === undefined || j === undefined || i === j)
2408
+ continue;
2409
+ adj[i].push(j);
2410
+ adj[j].push(i); // undirected — reachable either direction
2411
+ }
2412
+ // Restart vector: uniform over seeds present in the candidate set. (Falls
2413
+ // back to uniform-over-all if no seed landed in the set, so we never return
2414
+ // all-zero.)
2415
+ const r = new Array(n).fill(0);
2416
+ let rsum = 0;
2417
+ for (const id of seedIds) {
2418
+ const i = idx.get(id);
2419
+ if (i !== undefined) {
2420
+ r[i] = 1;
2421
+ rsum += 1;
2422
+ }
2423
+ }
2424
+ if (rsum === 0) {
2425
+ for (let i = 0; i < n; i++)
2426
+ r[i] = 1;
2427
+ rsum = n;
2428
+ }
2429
+ for (let i = 0; i < n; i++)
2430
+ r[i] /= rsum;
2431
+ const alpha = 0.25;
2432
+ let s = r.slice();
2433
+ for (let iter = 0; iter < 25; iter++) {
2434
+ const next = new Array(n).fill(0);
2435
+ for (let i = 0; i < n; i++) {
2436
+ const si = s[i];
2437
+ if (si === 0)
2438
+ continue;
2439
+ const d = adj[i].length;
2440
+ if (d === 0) {
2441
+ next[i] += si;
2442
+ continue;
2443
+ } // dangling: keep its mass
2444
+ const share = si / d;
2445
+ for (const j of adj[i])
2446
+ next[j] += share;
2447
+ }
2448
+ for (let i = 0; i < n; i++)
2449
+ s[i] = (1 - alpha) * next[i] + alpha * r[i];
2450
+ }
2451
+ for (let i = 0; i < n; i++)
2452
+ out.set(nodeIds[i], s[i]);
2453
+ return out;
2454
+ }
2455
+ /**
2456
+ * Handle codegraph_explore — deep exploration in a single call
2457
+ *
2458
+ * Strategy: find relevant symbols via graph traversal, group by file,
2459
+ * then read contiguous file sections covering all symbols per file.
2460
+ * This replaces multiple codegraph_node + Read calls.
2461
+ *
2462
+ * Output size is adaptive to project file count via
2463
+ * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
2464
+ * tax on small projects while earning its keep on large ones.
2465
+ */
2466
+ async handleExplore(args) {
2467
+ const rawQuery = this.validateString(args.query, 'query');
2468
+ if (typeof rawQuery !== 'string')
2469
+ return rawQuery;
2470
+ // One normalization point so the flow-builder, relevance search, and
2471
+ // ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
2472
+ const query = normalizeQuerySpelling(rawQuery);
2473
+ const cg = this.getCodeGraph(args.projectPath);
2474
+ const projectRoot = cg.getProjectRoot();
2475
+ // Resolve adaptive output budget from project size. Falls back to the
2476
+ // largest-tier defaults if stats aren't available, which preserves
2477
+ // pre-#185 behavior for callers that hit the rare stats failure.
2478
+ let budget;
2479
+ try {
2480
+ budget = getExploreOutputBudget(cg.getStats().fileCount);
2481
+ }
2482
+ catch {
2483
+ budget = getExploreOutputBudget(Infinity);
2484
+ }
2485
+ const maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
2486
+ // Step 1: Find relevant context with generous parameters.
2487
+ // Use a large maxNodes budget — explore has its own 35k char output limit
2488
+ // that prevents context bloat, so more nodes just means better coverage
2489
+ // across entry points (especially for large files like Svelte components).
2490
+ const subgraph = await cg.findRelevantContext(query, {
2491
+ searchLimit: 8,
2492
+ traversalDepth: 3,
2493
+ maxNodes: 200,
2494
+ minScore: 0.2,
2495
+ });
2496
+ if (subgraph.nodes.size === 0) {
2497
+ return this.textResult(`No relevant code found for "${query}"`);
2498
+ }
2499
+ // Graph-aware glue: findRelevantContext builds the subgraph from name/text
2500
+ // search, so a method that BRIDGES named symbols — e.g. App.tsx's
2501
+ // triggerRender, which calls the named triggerUpdate — is never a search hit
2502
+ // and gets missed, forcing the agent to Read the file to trace it. Pull in
2503
+ // the callers/callees of the entry (root) nodes, but ONLY those that live in
2504
+ // files the subgraph already surfaces (where the agent reads to fill gaps),
2505
+ // so we add wiring without dragging in unrelated files. These get an
2506
+ // importance boost below so they survive the per-file cluster budget.
2507
+ const glueNodeIds = new Set();
2508
+ const subgraphFiles = new Set();
2509
+ for (const n of subgraph.nodes.values())
2510
+ subgraphFiles.add(n.filePath);
2511
+ const GLUE_NODE_CAP = 60;
2512
+ for (const rootId of subgraph.roots) {
2513
+ if (glueNodeIds.size >= GLUE_NODE_CAP)
2514
+ break;
2515
+ let neighbors = [];
2516
+ try {
2517
+ neighbors = [
2518
+ ...cg.getCallers(rootId).map(c => c.node),
2519
+ ...cg.getCallees(rootId).map(c => c.node),
2520
+ ];
2521
+ }
2522
+ catch {
2523
+ continue;
2524
+ }
2525
+ for (const nb of neighbors) {
2526
+ if (glueNodeIds.size >= GLUE_NODE_CAP)
2527
+ break;
2528
+ if (subgraph.nodes.has(nb.id))
2529
+ continue;
2530
+ if (!subgraphFiles.has(nb.filePath))
2531
+ continue;
2532
+ subgraph.nodes.set(nb.id, nb);
2533
+ glueNodeIds.add(nb.id);
2534
+ }
2535
+ }
2536
+ // Named-symbol seeding: findRelevantContext is an FTS/text rank, so a query
2537
+ // that's a BAG of symbol names skewed toward one phase (Alamofire: 5 build
2538
+ // terms, each a high-frequency name, vs 3 validate terms) lets the
2539
+ // lower-frequency names fall below the search cut — their definitions, and
2540
+ // whole files (Validation.swift), never get gathered, so they can never
2541
+ // render and the agent Reads them. Resolve EACH named token to its
2542
+ // substantive definition (skip empty stubs + test files, same relevance the
2543
+ // trace endpoint picker uses) and inject it as an entry, so every symbol the
2544
+ // agent explicitly named is in the subgraph and its file is scored.
2545
+ const namedSeedIds = new Set();
2546
+ // The subset of named seeds that earns the named-FIRST sort tier. We still
2547
+ // SEED every ≤3-def name (so RWR / flow ranking is unchanged), but only the
2548
+ // most-substantive def is tiered — a bare name's unrelated namesakes (Go's
2549
+ // `NewClient` = real client + test fake + xds pool) must not fill the tier
2550
+ // and crowd out the real answer file (grpc's `dialoptions.go`). Corroborated
2551
+ // overloads (the query also named the type) all earn it. (#1064)
2552
+ const tierSeedIds = new Set();
2553
+ {
2554
+ const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
2555
+ const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
2556
+ const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
2557
+ const bodyLines = (n) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
2558
+ const callerCount = (n) => { try {
2559
+ return cg.getCallers(n.id).length;
2560
+ }
2561
+ catch {
2562
+ return 0;
2563
+ } };
2564
+ const tokens = [...new Set(query.split(/[\s,()[\]]+/)
2565
+ .map((t) => t.replace(FILE_EXT, '').trim())
2566
+ .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
2567
+ // PascalCase tokens in the query are type/file disambiguators — when the
2568
+ // agent writes "DataRequest task validate", the `task`/`validate` it wants
2569
+ // are DataRequest's, NOT the same-named overloads in Validation.swift /
2570
+ // Concurrency.swift / the abstract base. Used below to bias overloaded
2571
+ // names toward the file/class the query also names. EXCLUDE the project
2572
+ // name (a PascalCase token a user naturally includes) — it names the whole
2573
+ // repo, so biasing toward it just pulls overloads to whichever stack
2574
+ // embeds it, re-burying the rest (#720).
2575
+ const projectNameTokens = cg.getProjectNameTokens();
2576
+ const typeTokens = tokens.filter((o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o) && !projectNameTokens.has((0, query_utils_1.normalizeNameToken)(o)));
2577
+ const inNamedContext = (n) => typeTokens.some((ct) => {
2578
+ const lc = ct.toLowerCase();
2579
+ return n.filePath.toLowerCase().includes(lc) || n.qualifiedName.toLowerCase().includes(lc);
2580
+ });
2581
+ // NL-stopword guard: this seeding treats every token as "a symbol the
2582
+ // agent named", but explore also takes natural-language questions, whose
2583
+ // ordinary English words collide with real callables — "…check the latest
2584
+ // version…" exact-matched a lone `check()` method, which then earned the
2585
+ // named-FIRST sort tier and displaced the corroborated answer files from
2586
+ // the whole render budget (the agent fell back to Read). A shape-precise
2587
+ // token (camelCase, PascalCase, snake_case, qualified) is an unambiguous
2588
+ // symbol reference and seeds unconditionally; a BARE lowercase word seeds
2589
+ // only where the query corroborates the file — another query token is
2590
+ // itself a symbol defined in that same file (the "check drain fire"
2591
+ // sibling-bag case), which an incidental English-word collision never is.
2592
+ const lcTokens = new Set(tokens.map((x) => x.toLowerCase()));
2593
+ const isPreciseToken = (x) => /[._$]|::|\//.test(x) || /[a-z][A-Z]/.test(x) || /^[A-Z]/.test(x);
2594
+ const fileNameSets = new Map();
2595
+ const coNamedInFile = (t, fp) => {
2596
+ let names = fileNameSets.get(fp);
2597
+ if (!names) {
2598
+ names = new Set();
2599
+ try {
2600
+ for (const n of cg.getNodesInFile(fp))
2601
+ names.add(n.name.toLowerCase());
2602
+ }
2603
+ catch { /* unreadable file entry — treat as uncorroborated */ }
2604
+ fileNameSets.set(fp, names);
2605
+ }
2606
+ const self = t.toLowerCase();
2607
+ for (const o of lcTokens) {
2608
+ if (o !== self && names.has(o))
2609
+ return true;
2610
+ }
2611
+ return false;
2612
+ };
2613
+ for (const t of tokens) {
2614
+ // Enumerate ALL defs of a bare token via the direct index, not FTS — a
2615
+ // 50+-overload name (tokio `poll`) ranks the wanted def (`Harness::poll`)
2616
+ // below the FTS cut, so findAllSymbols would never see it and the
2617
+ // type-token bias below couldn't pick the harness.rs one. (Same fix as
2618
+ // codegraph_node's findSymbolMatches.) Qualified tokens keep findAllSymbols.
2619
+ const isQual = /[.\/]|::/.test(t);
2620
+ const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
2621
+ let cands = raw
2622
+ .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
2623
+ .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
2624
+ // Field-name seeding fallback (#1196): a camelCase token that names NO
2625
+ // definition of its own is usually an object-literal key / API field
2626
+ // (`profileInfo`) — no node exists, so it contributed zero seeds and
2627
+ // the files that DEFINE it (`getProfileInfoV2` in profileController)
2628
+ // never surfaced. Seed its camel-infix definers instead: callables
2629
+ // whose name contains the token at a hump boundary or as a prefix.
2630
+ // Exact-empty + camel-shaped only (bare words keep the NL-stopword
2631
+ // guard below), shortest-first, capped so a hot infix can't flood.
2632
+ if (cands.length === 0 && !isQual && /[a-z][A-Z]/.test(t)) {
2633
+ const lcToken = t.toLowerCase();
2634
+ cands = cg
2635
+ .getNodesByNameSubstring(t, {
2636
+ kinds: ['function', 'method', 'component'],
2637
+ limit: 60,
2638
+ })
2639
+ .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
2640
+ .filter((n) => {
2641
+ const idx = n.name.toLowerCase().indexOf(lcToken);
2642
+ if (idx < 0)
2643
+ return false;
2644
+ if (idx === 0)
2645
+ return n.name.length > t.length; // prefix definer
2646
+ return /[A-Z]/.test(n.name.charAt(idx)); // camel-hump boundary
2647
+ })
2648
+ .sort((a, b) => a.name.length - b.name.length)
2649
+ .slice(0, 3);
2650
+ }
2651
+ // Bare lowercase words only seed defs their query-siblings corroborate
2652
+ // (see the NL-stopword guard above). Filtering CANDS (not picks) applies
2653
+ // the guard uniformly to both branches below, including the >3-def
2654
+ // single-pick fallback — an uncorroborated bare `run` must not tier its
2655
+ // most-substantive namesake any more than a 1-def `check` may.
2656
+ if (!isPreciseToken(t)) {
2657
+ cands = cands.filter((n) => coNamedInFile(t, n.filePath));
2658
+ }
2659
+ // A specific name (<=3 defs) injects all its defs. An overloaded name
2660
+ // (`validate` = 10, `request` = 44) would flood the subgraph, so inject
2661
+ // only: the overloads whose file/class the query ALSO names (the agent
2662
+ // told us which one it wants — DataRequest's, not Validation.swift's),
2663
+ // capped; else fall back to the single most-substantive def. This is the
2664
+ // explore-side mirror of codegraph_node's overload disambiguation.
2665
+ let picks;
2666
+ let tierPicks; // subset that earns the named-first tier (#1064)
2667
+ if (cands.length <= 3) {
2668
+ picks = cands;
2669
+ // Centrality de-noise: tier the most-substantive def PLUS any co-named
2670
+ // def of comparable centrality (a real overload/wrapper — excalidraw's
2671
+ // `mutateElement` lives in mutateElement.ts, App.tsx AND Scene.ts, all
2672
+ // within ~2x callers). EXCLUDE a vastly-less-central namesake (Go's
2673
+ // `NewClient`: real client 492 callers vs xds-pool 11, test-fake 3 →
2674
+ // ratio <0.025) so it doesn't fill the tier and crowd out the answer.
2675
+ const counts = new Map(cands.map((c) => [c.id, callerCount(c)]));
2676
+ const maxCallers = Math.max(1, ...counts.values());
2677
+ tierPicks = cands.filter((c, i) => i === 0 || (counts.get(c.id) ?? 0) >= maxCallers * 0.25);
2678
+ }
2679
+ else {
2680
+ const ctx = cands.filter(inNamedContext);
2681
+ picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
2682
+ tierPicks = picks; // corroborated overloads (or the single fallback) all earn it
2683
+ }
2684
+ for (const n of picks) {
2685
+ if (!subgraph.nodes.has(n.id))
2686
+ subgraph.nodes.set(n.id, n);
2687
+ // Mark as a named seed EVEN IF the FTS gather already had it — being
2688
+ // "named by the agent" is independent of whether search happened to
2689
+ // surface it, and it drives the +50 score, the gate, and the
2690
+ // named-file sort below. (Previously only NEW injections were marked,
2691
+ // so a named symbol FTS already gathered never sorted to the top.)
2692
+ namedSeedIds.add(n.id);
2693
+ }
2694
+ for (const n of tierPicks)
2695
+ tierSeedIds.add(n.id);
2696
+ }
2697
+ }
2698
+ // Step 2: Group nodes by file, score by relevance
2699
+ const fileGroups = new Map();
2700
+ const entryNodeIds = new Set([...subgraph.roots, ...namedSeedIds]);
2701
+ // Build a set of nodes directly connected to entry points (depth 1)
2702
+ const connectedToEntry = new Set();
2703
+ for (const edge of subgraph.edges) {
2704
+ if (entryNodeIds.has(edge.source))
2705
+ connectedToEntry.add(edge.target);
2706
+ if (entryNodeIds.has(edge.target))
2707
+ connectedToEntry.add(edge.source);
2708
+ }
2709
+ // CHANGE SURFACE (#1064): a named method's signature types — its parameter
2710
+ // and return types — are part of what you'd edit to "add a parameter to X",
2711
+ // yet they can be lexically dissimilar to the query ("add a parameter to
2712
+ // NewClient" shares no words with `dialoptions.go`, which defines NewClient's
2713
+ // `DialOption`) and sit a hop away. COLLECT them here from each named-seed
2714
+ // callable's outgoing signature edges (full graph — the type is often not in
2715
+ // the subgraph); the decision to surface one is DEFERRED to the buried-rescue
2716
+ // pass below, which fires only when the type's file would otherwise be
2717
+ // dropped — so a well-connected type (excalidraw's element types, Alamofire's
2718
+ // `DataRequest` on a flow query) is left to rank on its own and never
2719
+ // displaces a flow-central file. Bounded: only the few named seeds, only the
2720
+ // types in their signatures.
2721
+ const CALLABLE_KINDS = new Set(['method', 'function', 'component', 'constructor']);
2722
+ const TYPE_KINDS = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'type_alias']);
2723
+ const SIG_EDGE = new Set(['references', 'type_of', 'returns']);
2724
+ const changeSurfaceCandidates = [];
2725
+ const seenChangeSurface = new Set();
2726
+ for (const seedId of tierSeedIds) {
2727
+ const seedNode = subgraph.nodes.get(seedId);
2728
+ if (!seedNode || !CALLABLE_KINDS.has(seedNode.kind))
2729
+ continue;
2730
+ let outs = [];
2731
+ try {
2732
+ outs = cg.getOutgoingEdges(seedId);
2733
+ }
2734
+ catch {
2735
+ continue;
2736
+ }
2737
+ for (const e of outs) {
2738
+ if (!SIG_EDGE.has(e.kind))
2739
+ continue;
2740
+ const tgt = cg.getNode(e.target);
2741
+ if (!tgt || !TYPE_KINDS.has(tgt.kind) || namedSeedIds.has(tgt.id))
2742
+ continue;
2743
+ if (seenChangeSurface.has(tgt.id))
2744
+ continue;
2745
+ seenChangeSurface.add(tgt.id);
2746
+ changeSurfaceCandidates.push(tgt);
2747
+ }
2748
+ }
2749
+ for (const node of subgraph.nodes.values()) {
2750
+ // Skip import/export nodes — they add noise without information
2751
+ if (node.kind === 'import' || node.kind === 'export')
2752
+ continue;
2753
+ // SECURITY (#383): never render the on-disk source of a config-leaf
2754
+ // (Spring application.{yml,properties} key) — its line is `key = <secret>`,
2755
+ // so whole-file/cluster rendering here would push secrets into context
2756
+ // unbidden. The key still appears in the flow/symbol listing above.
2757
+ if ((0, utils_1.isConfigLeafNode)(node))
2758
+ continue;
2759
+ const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
2760
+ group.nodes.push(node);
2761
+ // Score: a NAMED-SEED node (a symbol the agent named that FTS missed, now
2762
+ // injected) is worth far more than a mere reference — its file is where the
2763
+ // answer lives. Without this, an incidental file that name-drops the flow
2764
+ // (Combine.swift references request/task → score 23 from connected nodes)
2765
+ // outranks the file that DEFINES a named symbol (Validation.swift's
2766
+ // `validate` → 10) and steals its render slot. Definition ≫ reference.
2767
+ if (namedSeedIds.has(node.id)) {
2768
+ group.score += 50;
2769
+ }
2770
+ else if (entryNodeIds.has(node.id)) {
2771
+ group.score += 10;
2772
+ }
2773
+ else if (connectedToEntry.has(node.id)) {
2774
+ group.score += 3;
2775
+ }
2776
+ else {
2777
+ group.score += 1;
2778
+ }
2779
+ fileGroups.set(node.filePath, group);
2780
+ }
2781
+ // Only include files that have entry points or nodes directly connected to entry points
2782
+ let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
2783
+ // Extract query terms for relevance checking
2784
+ const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
2785
+ // Test/spec/icon/i18n file detector — used both for the pre-sort hard
2786
+ // filter (tiny tier) and the comparator deprioritization (all tiers).
2787
+ const isLowValue = (p) => {
2788
+ const lp = p.toLowerCase();
2789
+ return (/\/(tests?|__tests?__|spec)\//.test(lp) ||
2790
+ /_test\.go$/.test(lp) ||
2791
+ /(?:^|\/)test_[^/]+\.py$/.test(lp) ||
2792
+ /_test\.py$/.test(lp) ||
2793
+ /_spec\.rb$/.test(lp) ||
2794
+ /_test\.rb$/.test(lp) ||
2795
+ /\.(test|spec)\.[jt]sx?$/.test(lp) ||
2796
+ /(test|spec|tests)\.(java|kt|scala)$/.test(lp) ||
2797
+ /(tests?|spec)\.cs$/.test(lp) ||
2798
+ /tests?\.swift$/.test(lp) ||
2799
+ /_test\.dart$/.test(lp) ||
2800
+ /\bicons?\b/.test(lp) ||
2801
+ /\bi18n\b/.test(lp));
2802
+ };
2803
+ // Hard-exclude test/spec files (ALL tiers, not just tiny). One slipped test
2804
+ // file dominates the per-file budget on small repos (cobra's `command_test.go`
2805
+ // displaced `args.go`) AND wastes budget on large ones (Django's
2806
+ // `custom_lookups/tests.py` ate ~2.3 KB of the 28 KB cap, crowding out the
2807
+ // SQLCompiler mechanism the agent then Read). A test file almost never answers
2808
+ // an architecture question. Skip when the query itself is about tests — the
2809
+ // legitimate "explore the tests" case — and only cut if ≥2 non-test candidates
2810
+ // remain (else tests are the only signal for this area).
2811
+ {
2812
+ const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(query);
2813
+ if (!queryMentionsTests) {
2814
+ const nonLow = relevantFiles.filter(([p]) => !isLowValue(p));
2815
+ if (nonLow.length >= 2) {
2816
+ relevantFiles = nonLow;
2817
+ }
2818
+ }
2819
+ }
2820
+ // Secondary signal: how many DISTINCT query terms each file matches (path +
2821
+ // symbol names). Kept only as a tiebreak — the PRIMARY relevance is graph
2822
+ // connectivity below. (Term counting alone tied the real central file with
2823
+ // incidental same-word matches; it's a weak text signal, not the ranker.)
2824
+ const uniqueQueryTerms = [...new Set(queryTerms)].filter(t => t.length >= 3);
2825
+ const fileTermHits = new Map();
2826
+ for (const [fp, group] of relevantFiles) {
2827
+ const hay = fp.toLowerCase() + ' ' + group.nodes.map(n => n.name.toLowerCase()).join(' ');
2828
+ let hits = 0;
2829
+ for (const t of uniqueQueryTerms)
2830
+ if (hay.includes(t))
2831
+ hits++;
2832
+ fileTermHits.set(fp, hits);
2833
+ }
2834
+ // PRIMARY relevance: graph connectivity (Random-Walk-with-Restart from the
2835
+ // matched seeds — see computeGraphRelevance). Aggregate each file's nodes'
2836
+ // walk mass. This is the signal text search lacks: the real cluster
2837
+ // (org-user.storage.ts, call-connected to the matches) accrues mass; a lone
2838
+ // text match (LensSwitcher.swift, matched "switch" but calls nothing in the
2839
+ // flow) gets only its restart probability → ~0, and is dropped by the gate.
2840
+ const nodeRwr = this.computeGraphRelevance([...subgraph.nodes.keys()], subgraph.edges, entryNodeIds);
2841
+ const fileGraphScore = new Map();
2842
+ for (const node of subgraph.nodes.values()) {
2843
+ fileGraphScore.set(node.filePath, (fileGraphScore.get(node.filePath) ?? 0) + (nodeRwr.get(node.id) ?? 0));
2844
+ }
2845
+ const maxGraph = Math.max(0, ...fileGraphScore.values());
2846
+ // Central file(s): the 1-2 most graph-central files that also match the
2847
+ // query textually (so a connected hub-utility with no term match isn't
2848
+ // mistaken for the subject). The heart of the answer — they earn the larger
2849
+ // WHOLE-FILE ceiling below (a god-file central file still exceeds it and
2850
+ // falls to generous full-method sectioning — never a whole dump).
2851
+ const centralFiles = new Set([...fileGraphScore.entries()]
2852
+ .filter(([fp, g]) => g > 0 && (fileTermHits.get(fp) ?? 0) >= 1)
2853
+ .sort((a, b) => b[1] - a[1] || (fileTermHits.get(b[0]) ?? 0) - (fileTermHits.get(a[0]) ?? 0))
2854
+ .slice(0, 2)
2855
+ .map(([f]) => f));
2856
+ // Files that DEFINE a symbol the agent named (or a subgraph root). These are
2857
+ // the highest-relevance files there are — the agent asked for them by name —
2858
+ // so the connectivity gate below must never drop them, even when their RWR
2859
+ // mass is low (a leaf family file like codec.ts is call-connected to little
2860
+ // but is exactly what the agent queried). Without this protection the gate
2861
+ // prunes a named file and the agent Reads it back.
2862
+ const entryFiles = new Set();
2863
+ for (const id of entryNodeIds) {
2864
+ const n = subgraph.nodes.get(id);
2865
+ if (n)
2866
+ entryFiles.add(n.filePath);
2867
+ }
2868
+ // Buried-rescue pass (#1064): surface a named method's signature type ONLY
2869
+ // when its file is genuinely buried — near-zero graph mass AND not lexically
2870
+ // matched. That is the invisible case (grpc's `DialOption` → `dialoptions.go`,
2871
+ // g≈0, 0 term hits): reachable but ranked nowhere, so the agent greps. A
2872
+ // well-connected type file (excalidraw element types, Alamofire `DataRequest`)
2873
+ // is NOT buried and is left alone — rescuing it would displace a flow-central
2874
+ // file (App.tsx, Validation.swift). Buried is judged on the PRE-rescue graph,
2875
+ // so injecting the type below can't make it look connected. A rescued file is
2876
+ // injected (so it renders), force-kept (gate + relevantFiles), and tiered.
2877
+ const changeSurfaceFiles = new Set();
2878
+ for (const t of changeSurfaceCandidates) {
2879
+ const fp = t.filePath;
2880
+ const buried = (fileGraphScore.get(fp) ?? 0) < maxGraph * 0.06
2881
+ && (fileTermHits.get(fp) ?? 0) < 2;
2882
+ if (!buried)
2883
+ continue;
2884
+ changeSurfaceFiles.add(fp);
2885
+ if (!subgraph.nodes.has(t.id))
2886
+ subgraph.nodes.set(t.id, t);
2887
+ let group = fileGroups.get(fp);
2888
+ if (!group) {
2889
+ group = { nodes: [], score: 0 };
2890
+ fileGroups.set(fp, group);
2891
+ }
2892
+ if (!group.nodes.some((n) => n.id === t.id))
2893
+ group.nodes.push(t);
2894
+ group.score = Math.max(group.score, 45);
2895
+ if (!relevantFiles.some(([f]) => f === fp))
2896
+ relevantFiles.push([fp, group]);
2897
+ }
2898
+ // Relevance gate (so the generous budget is a CEILING, not a target): keep a
2899
+ // file only if it is STRUCTURALLY relevant by ANY of:
2900
+ // - graph score within a fraction of the top (it's on/near the flow), OR
2901
+ // - central (a query entry-point lives here), OR
2902
+ // - it DEFINES a symbol the agent named (entryFiles), OR
2903
+ // - it matches >= 2 DISTINCT named query terms — a strong text signal that
2904
+ // the agent is asking about this file even when nothing calls it (codec.ts:
2905
+ // the agent named `encode`/`Codec`/`JsonCodec`, all leaf classes with zero
2906
+ // RWR mass — graph alone wrongly drops it).
2907
+ // A lone text match on one shared word (LensSwitcher: term=1, g~0) is still
2908
+ // dropped, so the budget never fills with incidental files. Guarded so it
2909
+ // never prunes below 2.
2910
+ if (maxGraph > 0) {
2911
+ const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
2912
+ || centralFiles.has(fp)
2913
+ || entryFiles.has(fp)
2914
+ || changeSurfaceFiles.has(fp)
2915
+ || (fileTermHits.get(fp) ?? 0) >= 2);
2916
+ if (gated.length >= 2)
2917
+ relevantFiles = gated;
2918
+ }
2919
+ // Sort files: graph-central first, then distinct-term match, then the
2920
+ // existing low-value/generated/score tiebreaks.
2921
+ // Files that DEFINE a symbol the agent NAMED. These sort first — ahead of
2922
+ // graph connectivity — because the agent asked for them by name. Without
2923
+ // this, a named leaf override reached only by dynamic dispatch (Alamofire's
2924
+ // `DataRequest.task`/`validate`, low RWR mass) sorts below the high-
2925
+ // connectivity abstract base (`Request.swift`) and the same-named overloads
2926
+ // in other files (`Validation.swift`), falls outside the budget, and the
2927
+ // agent Reads it. The named file is the answer — rank it at the top.
2928
+ const namedSeedFiles = new Set();
2929
+ for (const id of tierSeedIds) {
2930
+ const n = subgraph.nodes.get(id);
2931
+ if (n)
2932
+ namedSeedFiles.add(n.filePath);
2933
+ }
2934
+ // A rescued change-surface file (only the genuinely-buried ones — see the
2935
+ // buried-rescue pass) is the lexically-dissimilar answer; give it the named
2936
+ // tier so it isn't buried under files that merely share surface words (#1064).
2937
+ for (const fp of changeSurfaceFiles)
2938
+ namedSeedFiles.add(fp);
2939
+ // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
2940
+ // (a search root, named seed, or graph-central hub — i.e. structurally part of
2941
+ // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
2942
+ // graph-centrality mass that accrued to a denser-but-off-topic cluster. In a
2943
+ // cross-layer monorepo (an API server alongside a much larger, internally dense
2944
+ // frontend that mirrors the same domain words) the Random-Walk-with-Restart mass
2945
+ // — seeded from text matches that skew to the bigger layer — floats hits=0
2946
+ // frontend files above the hits=2/3 backend service that IS the answer (its many
2947
+ // callers don't help: it's call-isolated from the frontend seed cluster). The
2948
+ // entry/central GUARD keeps this safe: an INCIDENTAL multi-term file that is
2949
+ // neither entry nor central (a type/util file that matches "element"+x but isn't
2950
+ // the flow) is NOT promoted, so it can't displace the graph-central answer file
2951
+ // (hits=1) the way a blunt hits-only tier would. Single-layer repos with one
2952
+ // cluster are unaffected (no competing mass). Set CODEGRAPH_RANK_NO_MULTITERM=1
2953
+ // to disable.
2954
+ const MULTITERM_OFF = process.env.CODEGRAPH_RANK_NO_MULTITERM === '1';
2955
+ const isCorroborated = (fp) => !MULTITERM_OFF &&
2956
+ (fileTermHits.get(fp) ?? 0) >= 2 &&
2957
+ (entryFiles.has(fp) || centralFiles.has(fp));
2958
+ const sortedFiles = relevantFiles.sort((a, b) => {
2959
+ const aPath = a[0].toLowerCase();
2960
+ const bPath = b[0].toLowerCase();
2961
+ // Agent-named files first (it asked for a symbol defined here by name).
2962
+ const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
2963
+ const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
2964
+ if (aNamed !== bNamed)
2965
+ return bNamed - aNamed;
2966
+ // Corroborated (entry/central + ≥2 terms) tier, above the graph signal.
2967
+ const aCorr = isCorroborated(a[0]) ? 1 : 0;
2968
+ const bCorr = isCorroborated(b[0]) ? 1 : 0;
2969
+ if (aCorr !== bCorr)
2970
+ return bCorr - aCorr;
2971
+ // Graph connectivity is the next key (small epsilon so near-ties fall
2972
+ // through to the text signal rather than coin-flipping on float noise).
2973
+ const aG = fileGraphScore.get(a[0]) ?? 0;
2974
+ const bG = fileGraphScore.get(b[0]) ?? 0;
2975
+ if (Math.abs(aG - bG) > maxGraph * 0.01)
2976
+ return bG - aG;
2977
+ const aHits = fileTermHits.get(a[0]) ?? 0;
2978
+ const bHits = fileTermHits.get(b[0]) ?? 0;
2979
+ if (aHits !== bHits)
2980
+ return bHits - aHits;
2981
+ const aLow = isLowValue(aPath);
2982
+ const bLow = isLowValue(bPath);
2983
+ if (aLow !== bLow)
2984
+ return aLow ? 1 : -1;
2985
+ // Deprioritize generated source (.pb.go / .pulsar.go / _mocks.go / …) —
2986
+ // the agent rarely needs to see the protobuf scaffold or gomock output
2987
+ // when asking about the actual flow, and dumping their bodies inflates
2988
+ // the response (the cosmos Q3 explore otherwise leads with
2989
+ // `expected_keepers_mocks.go`, displacing the real `tally.go` content
2990
+ // and forcing the agent to Read tally.go anyway).
2991
+ const aGen = (0, generated_detection_1.isGeneratedFile)(a[0]);
2992
+ const bGen = (0, generated_detection_1.isGeneratedFile)(b[0]);
2993
+ if (aGen !== bGen)
2994
+ return aGen ? 1 : -1;
2995
+ if (a[1].score !== b[1].score)
2996
+ return b[1].score - a[1].score;
2997
+ return b[1].nodes.length - a[1].nodes.length;
2998
+ });
2999
+ // Step 3: Build relationship map
3000
+ const lines = [
3001
+ `**Exploration: ${query}**`,
3002
+ '',
3003
+ // Curated summary — filled in after the source loop (see below). We do NOT
3004
+ // report `subgraph.nodes.size` / `fileGroups.size` here: that's the raw
3005
+ // candidate gather, which a broad natural-language query inflates wildly
3006
+ // (260 symbols / 124 files on a 636-file repo) even though only a handful
3007
+ // render. Reporting the pool read as "260 results to wade through" when the
3008
+ // real, correctly-ranked answer is the few files below (#1046).
3009
+ '',
3010
+ '',
3011
+ ];
3012
+ const summaryLineIdx = 2;
3013
+ // Blast radius (always-on, compact): for the entry symbols, who depends on
3014
+ // them + which tests cover them — locations only, no source — so the agent
3015
+ // knows what to update/verify before editing without a separate call.
3016
+ const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
3017
+ if (blastRadius)
3018
+ lines.push(blastRadius);
3019
+ // Relationship map — show how symbols connect
3020
+ const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
3021
+ );
3022
+ if (budget.includeRelationships && significantEdges.length > 0) {
3023
+ lines.push('**Relationships**');
3024
+ lines.push('');
3025
+ // Group edges by kind for readability
3026
+ const byKind = new Map();
3027
+ for (const edge of significantEdges) {
3028
+ const sourceNode = subgraph.nodes.get(edge.source);
3029
+ const targetNode = subgraph.nodes.get(edge.target);
3030
+ if (!sourceNode || !targetNode)
3031
+ continue;
3032
+ const group = byKind.get(edge.kind) || [];
3033
+ group.push({ source: sourceNode.name, target: targetNode.name });
3034
+ byKind.set(edge.kind, group);
3035
+ }
3036
+ for (const [kind, edges] of byKind) {
3037
+ const cap = budget.maxEdgesPerRelationshipKind;
3038
+ const shown = edges.slice(0, cap);
3039
+ lines.push(`**${kind}:**`);
3040
+ for (const e of shown) {
3041
+ lines.push(`- ${e.source} → ${e.target}`);
3042
+ }
3043
+ if (edges.length > cap) {
3044
+ lines.push(`- ... and ${edges.length - cap} more`);
3045
+ }
3046
+ lines.push('');
3047
+ }
3048
+ }
3049
+ // Step 4: Read contiguous file sections
3050
+ // Compute the flow spine once — used both to prepend the Flow section (below)
3051
+ // and to gate adaptive source sizing: files on the spine get full source,
3052
+ // off-spine peers skeletonize.
3053
+ const flow = this.buildFlowFromNamedSymbols(cg, query);
3054
+ // Polymorphic-sibling detector for adaptive sizing. A class that implements/
3055
+ // extends a supertype shared by >= MIN_SIBLINGS classes is one of many
3056
+ // INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
3057
+ // showing one + the rest as signatures is enough), as opposed to a DISTINCT
3058
+ // pipeline step (Excalidraw's `renderStaticScene`, which shares no supertype and
3059
+ // must stay full or the agent loses real content). Only off-spine sibling files
3060
+ // skeletonize; distinct steps and on-spine files keep full source. Cache
3061
+ // supertype→(has ≥N implementers) so this stays a handful of edge queries.
3062
+ const MIN_SIBLINGS = 3;
3063
+ const siblingSuper = new Map();
3064
+ const isPolymorphicSibling = (nodes) => {
3065
+ for (const n of nodes) {
3066
+ for (const e of cg.getOutgoingEdges(n.id)) {
3067
+ if (e.kind !== 'implements' && e.kind !== 'extends')
3068
+ continue;
3069
+ let many = siblingSuper.get(e.target);
3070
+ if (many === undefined) {
3071
+ many = cg.getIncomingEdges(e.target)
3072
+ .filter((x) => x.kind === 'implements' || x.kind === 'extends').length >= MIN_SIBLINGS;
3073
+ siblingSuper.set(e.target, many);
3074
+ }
3075
+ if (many)
3076
+ return true;
3077
+ }
3078
+ }
3079
+ return false;
3080
+ };
3081
+ // A file that DEFINES a polymorphic supertype (a class/interface with ≥
3082
+ // MIN_SIBLINGS implementers) AND co-locates its subclasses is a redundant
3083
+ // "family" file — Django's compiler.py holds `SQLCompiler` + its 4 subclasses
3084
+ // (SQLInsert/Update/Delete/AggregateCompiler) in 2,266 lines. Such files are
3085
+ // huge and read-anyway, so they should STILL skeletonize even when the agent
3086
+ // named a method in them: a full one eats ~6.5K of the explore budget (Django
3087
+ // is pinned at the 28K cap, truncating), starving the sibling files the agent
3088
+ // then Reads. This flag OVERRIDES the named-callable spare below — it does NOT
3089
+ // by itself spare a file. (OkHttp's RealCall implements the `Lockable` mixin
3090
+ // but defines no ≥3-impl supertype, so the named spare keeps it full.)
3091
+ const superMany = new Map();
3092
+ const definesPolymorphicSupertype = (nodes) => {
3093
+ for (const n of nodes) {
3094
+ if (n.kind !== 'class' && n.kind !== 'interface' && n.kind !== 'struct'
3095
+ && n.kind !== 'trait' && n.kind !== 'protocol' && n.kind !== 'type_alias')
3096
+ continue;
3097
+ let many = superMany.get(n.id);
3098
+ if (many === undefined) {
3099
+ many = cg.getIncomingEdges(n.id)
3100
+ .filter((x) => x.kind === 'implements' || x.kind === 'extends').length >= MIN_SIBLINGS;
3101
+ superMany.set(n.id, many);
3102
+ }
3103
+ if (many)
3104
+ return true;
3105
+ }
3106
+ return false;
3107
+ };
3108
+ lines.push('**Source Code**');
3109
+ lines.push('');
3110
+ lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
3111
+ lines.push('');
3112
+ let totalChars = lines.join('\n').length;
3113
+ let filesIncluded = 0;
3114
+ // Paths we actually render source for below. Drives the curated header count
3115
+ // (#1046) — it must reflect what we show, not the raw candidate gather.
3116
+ const renderedFilePaths = [];
3117
+ let anyFileTrimmed = false;
3118
+ for (const [filePath, group] of sortedFiles) {
3119
+ if (filesIncluded >= maxFiles)
3120
+ break;
3121
+ // A file DEFINES a named/spine symbol (the answer) vs merely references the
3122
+ // flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
3123
+ // for necessary ones, which render even past the cap (bounded by maxFiles).
3124
+ // Without this `continue` (was an unconditional `break`), the loop stopped
3125
+ // after the build + validators-exec files and never reached the ranked-in
3126
+ // validate-logic file (Alamofire's Validation.swift).
3127
+ const fileNecessary = group.nodes.some(n => entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
3128
+ if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9)
3129
+ continue;
3130
+ const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, filePath);
3131
+ if (!absPath || !(0, fs_1.existsSync)(absPath))
3132
+ continue;
3133
+ let fileContent;
3134
+ try {
3135
+ fileContent = (0, fs_1.readFileSync)(absPath, 'utf-8');
3136
+ }
3137
+ catch {
3138
+ continue;
3139
+ }
3140
+ const fileLines = fileContent.split('\n');
3141
+ const lang = group.nodes[0]?.language || '';
3142
+ // Adaptive sizing (CODEGRAPH_ADAPTIVE_EXPLORE, default on): collapse a file
3143
+ // to a per-symbol view when it's a redundant member of a polymorphic family.
3144
+ // Engages iff ALL hold:
3145
+ // 1. a flow spine exists,
3146
+ // 2. no symbol in the file is on that spine (it's not the mechanism path),
3147
+ // 3. it IS a polymorphic sibling (≥ MIN_SIBLINGS impls of a shared supertype),
3148
+ // 4. it is NOT SPARED, where a file is spared iff the agent named a
3149
+ // (near-)UNIQUE callable in it (`getResponseWithInterceptorChain`, 1 def →
3150
+ // keep RealCall.kt full) UNLESS the file DEFINES the family supertype (a
3151
+ // base+subclasses "family" file like Django's compiler.py — collapse it).
3152
+ // Uniqueness matters: `as_sql` has 110 defs across every Compiler/Expression
3153
+ // subclass; naming it must NOT keep every backend variant + test file full
3154
+ // and flood the budget. That's why the spare reads uniqueNamedNodeIds.
3155
+ // Within a collapsed file the render is PER-SYMBOL (condition B): a method the
3156
+ // agent NAMED or that's on the spine is shown with its FULL body (so the agent
3157
+ // doesn't Read the file back for it — Django's SQLCompiler.execute_sql/as_sql);
3158
+ // every other symbol is just its signature. So the base mechanism survives while
3159
+ // the file's other ~80 symbols + the redundant subclasses collapse to one line each.
3160
+ const spareNamed = group.nodes.some(n => flow.uniqueNamedNodeIds.has(n.id));
3161
+ const fileDefinesSuper = definesPolymorphicSupertype(group.nodes);
3162
+ const spared = spareNamed && !fileDefinesSuper;
3163
+ const CALLABLE_BODY = new Set(['method', 'function', 'constructor', 'component']);
3164
+ const hasSpineNode = group.nodes.some(n => flow.pathNodeIds.has(n.id));
3165
+ // On-spine god-file: the flow path runs THROUGH this file, but it also holds
3166
+ // many OTHER named methods, and rendering all of them in full blows the
3167
+ // per-file budget and starves the other flow files (Alamofire: the agent
3168
+ // names ~7 Session.swift methods — the build spine PLUS off-path
3169
+ // task/didCompleteTask — far past the whole response budget). Engage the
3170
+ // per-symbol view to keep the SPINE full and collapse the off-path named
3171
+ // methods to signatures. Only when there IS off-path content to shed —
3172
+ // otherwise the spine is irreducible (a sequential flow has no redundancy),
3173
+ // so leave it to the normal full render.
3174
+ const namedBodyChars = group.nodes
3175
+ .filter(n => CALLABLE_BODY.has(n.kind) && (flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)))
3176
+ .reduce((s, n) => s + fileLines.slice(n.startLine - 1, n.endLine).join('\n').length, 0);
3177
+ const onSpineGodFile = hasSpineNode
3178
+ && namedBodyChars > budget.maxCharsPerFile
3179
+ && group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
3180
+ if (adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
3181
+ && (onSpineGodFile || (!hasSpineNode && isPolymorphicSibling(group.nodes) && !spared))) {
3182
+ const syms = group.nodes
3183
+ .filter(n => n.kind !== 'import' && n.kind !== 'export' && n.startLine > 0)
3184
+ .sort((a, b) => a.startLine - b.startLine);
3185
+ // Pass 1: choose which symbols get a FULL body, by priority, greedily within
3186
+ // a per-file body cap — so one huge family file can't body every named method
3187
+ // and crowd out the other flow files (Django's query.py). A symbol earns a
3188
+ // body if it's on-spine, or UNIQUELY named (`SQLCompiler.execute_sql`), or a
3189
+ // co-named method WHEN this file DEFINES the family supertype (so the base
3190
+ // `SQLCompiler.as_sql` body shows, but the 110 leaf `as_sql` overrides — and
3191
+ // OkHttp's 5 `intercept`s if the agent names `intercept` — stay signatures).
3192
+ const prio = (n) => !CALLABLE_BODY.has(n.kind) ? 99
3193
+ : flow.pathNodeIds.has(n.id) ? 0
3194
+ : flow.uniqueNamedNodeIds.has(n.id) ? 1
3195
+ : (fileDefinesSuper && flow.namedNodeIds.has(n.id)) ? 2 : 99;
3196
+ // One ~250-line WINDOW per file. syms are taken by priority (spine first,
3197
+ // then uniquely-named, then family-base), and the cap applies to ALL of
3198
+ // them — including the spine — so a big-spine god-file (tokio's worker.rs:
3199
+ // run→run_task→next_task→steal_work) can't eat the whole response and
3200
+ // starve the co-flow file (harness.rs's poll). The native agent windows
3201
+ // such a file too (~190 lines at a time), so this mimics, not truncates.
3202
+ // Always emit ≥1 (never an empty section).
3203
+ const bodyCap = budget.maxCharsPerFile * 1.5;
3204
+ const bodyIds = new Set();
3205
+ let bodyChars = 0;
3206
+ for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
3207
+ const sz = fileLines.slice(n.startLine - 1, n.endLine).join('\n').length;
3208
+ if (bodyChars + sz > bodyCap && bodyIds.size > 0)
3209
+ continue;
3210
+ bodyIds.add(n.id);
3211
+ bodyChars += sz;
3212
+ }
3213
+ // Pass 2: render in line order — full body for chosen symbols, else the
3214
+ // signature line (capped, with a "+N more" tail so the structure map of a
3215
+ // god-file doesn't itself bloat the budget).
3216
+ const skel = [];
3217
+ let coveredUntil = 0; // skip symbols already inside an emitted body
3218
+ let sigCount = 0, sigDropped = 0;
3219
+ const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2);
3220
+ for (const n of syms) {
3221
+ if (n.startLine <= coveredUntil)
3222
+ continue;
3223
+ if (bodyIds.has(n.id)) {
3224
+ const end = n.endLine;
3225
+ const body = fileLines.slice(n.startLine - 1, end).join('\n');
3226
+ skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
3227
+ coveredUntil = end;
3228
+ }
3229
+ else {
3230
+ // Elide the body, emit the signature. node.startLine can point at a
3231
+ // decorator/annotation, so scan forward for the line that names the symbol.
3232
+ let lineNo = n.startLine;
3233
+ for (let k = 0; k < 4; k++) {
3234
+ if ((fileLines[n.startLine - 1 + k] || '').includes(n.name)) {
3235
+ lineNo = n.startLine + k;
3236
+ break;
3237
+ }
3238
+ }
3239
+ if (lineNo <= coveredUntil)
3240
+ continue;
3241
+ if (sigCount >= SIG_MAX) {
3242
+ sigDropped++;
3243
+ continue;
3244
+ }
3245
+ const sig = (fileLines[lineNo - 1] || '').trim();
3246
+ if (sig) {
3247
+ skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig);
3248
+ sigCount++;
3249
+ }
3250
+ }
3251
+ }
3252
+ if (sigDropped > 0)
3253
+ skel.push(`… +${sigDropped} more (signatures elided)`);
3254
+ if (skel.length > 0) {
3255
+ const names = [...new Set(group.nodes.filter(n => n.kind !== 'import' && n.kind !== 'export').map(n => n.name))]
3256
+ .slice(0, budget.maxSymbolsInFileHeader).join(', ');
3257
+ // Steer the agent to codegraph_explore for an elided body — NEVER to
3258
+ // Read. The old "Read for more" / "Read for a full body" tags invited
3259
+ // a Read of the very file just skeletonized; on a central, wanted file
3260
+ // (Session.swift, DataRequest.swift) that fired an over-investigation
3261
+ // spiral (the agent Read the skeletonized file, then kept digging).
3262
+ // CLAUDE.md: explore output must never tell the agent to Read.
3263
+ const tag = bodyIds.size > 0
3264
+ ? 'focused (the methods you named in full, the rest as signatures — codegraph_explore a signature by name for its body; do NOT Read)'
3265
+ : 'skeleton (signatures only — codegraph_explore a name for its full body; do NOT Read)';
3266
+ lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
3267
+ totalChars += skel.join('\n').length + 120;
3268
+ renderedFilePaths.push(filePath);
3269
+ filesIncluded++;
3270
+ continue;
3271
+ }
3272
+ }
3273
+ // Whole-file rule: if a relevant file is small enough to afford, return it
3274
+ // ENTIRELY instead of clustering. Clustering exists to tame god-files
3275
+ // (App.tsx ~13k lines); on a ~134-line component a cluster is a lossy
3276
+ // subset of a file the agent will just Read in full anyway — costing a
3277
+ // round-trip and a re-read every later turn. Reserve clustering for files
3278
+ // too big to ship whole. Still bounded by the total maxOutputChars check.
3279
+ //
3280
+ // CENTRAL files (where the query's entry points live) get a larger — but
3281
+ // bounded — ceiling: they're the heart of the answer, the file(s) the agent
3282
+ // would Read whole, so a genuinely small one comes back whole rather than as
3283
+ // thin clusters. A LARGE central file (the 791-line org-user store) exceeds
3284
+ // the ceiling and falls through to sectioning/clustering below — full method
3285
+ // bodies + signatures — so we never dump (or overflow on) a whole god-file.
3286
+ const isCentralFile = centralFiles.has(filePath);
3287
+ // Central files get a slightly larger whole-file window than peripheral ones,
3288
+ // but a TIGHT one (~1.5× the per-file cap): the native read of a central file
3289
+ // is a ~150–250 line orientation window, NOT the whole file. A flat "whole
3290
+ // central file" both overflowed the inline cap AND starved the co-flow files
3291
+ // (worker.rs ate the budget, dropping harness.rs's poll). A larger central
3292
+ // file falls through to per-method windowing/clustering below.
3293
+ const WHOLE_FILE_MAX_LINES = isCentralFile ? 280 : 220;
3294
+ const WHOLE_FILE_MAX_CHARS = isCentralFile
3295
+ ? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
3296
+ : budget.maxCharsPerFile * 3;
3297
+ if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
3298
+ const body = fileContent.replace(/\n+$/, '');
3299
+ let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
3300
+ const uniqSymbols = [...new Set(group.nodes
3301
+ .filter(n => n.kind !== 'import' && n.kind !== 'export')
3302
+ .map(n => `${n.name}(${n.kind})`))];
3303
+ const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
3304
+ const omitted = uniqSymbols.length - headerNames.length;
3305
+ const wholeHeader = fileSectionHeader(filePath, omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', '));
3306
+ if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
3307
+ // Don't slice a whole file mid-method: an incidental file that doesn't
3308
+ // fit is skipped; a necessary one (below) renders in full. Half a file
3309
+ // forces the Read this is meant to prevent.
3310
+ anyFileTrimmed = true;
3311
+ continue;
3312
+ }
3313
+ lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
3314
+ totalChars += wholeSection.length + 200;
3315
+ renderedFilePaths.push(filePath);
3316
+ filesIncluded++;
3317
+ continue;
3318
+ }
3319
+ // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
3320
+ // Sort by start line, then merge overlapping/adjacent ranges (within the
3321
+ // adaptive gap threshold). Include both node ranges AND edge source
3322
+ // locations so template sections with component usages/calls are
3323
+ // covered (not just script block symbols).
3324
+ //
3325
+ // Each range carries an `importance` score so we can rank clusters
3326
+ // when the per-file budget forces us to drop some: entry-point nodes
3327
+ // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
3328
+ // bare edge-source lines 2 (less than a connected node but more than
3329
+ // a peripheral one — they hint at a reference but aren't a definition).
3330
+ // Container kinds whose body can span most/all of a file. When such a
3331
+ // node covers most of the file we drop it from the ranges: keeping it
3332
+ // would merge every method inside it into one giant cluster spanning
3333
+ // the whole file, which then tail-trims down to just the container's
3334
+ // opening lines (its header/declarations) and buries the methods the
3335
+ // query actually asked about (#185 follow-up — Session.swift in
3336
+ // Alamofire is the canonical case: the `Session` class spans ~1,400
3337
+ // lines). We want the granular symbols inside, not the envelope.
3338
+ const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
3339
+ // Cluster from this file's gathered nodes PLUS any callable the agent NAMED that
3340
+ // lives here. Explore's relevance gather can miss a named method def in a huge
3341
+ // non-sibling file — Django's query.py is 3,040 lines and `_fetch_all` (L2237)
3342
+ // was gathered only as call-reference edges, never as a def, so it formed no
3343
+ // cluster and the agent Read it back. Inject named defs directly and rank them
3344
+ // ABOVE connected/glue nodes (importance 9) so their cluster wins the per-file
3345
+ // budget — the agent explicitly asked for these symbols.
3346
+ const rangeNodes = new Map();
3347
+ for (const n of group.nodes)
3348
+ if (n.startLine > 0 && n.endLine > 0)
3349
+ rangeNodes.set(n.id, n);
3350
+ for (const id of flow.namedNodeIds) {
3351
+ if (rangeNodes.has(id))
3352
+ continue;
3353
+ const n = cg.getNode(id);
3354
+ if (n && n.filePath === filePath && n.startLine > 0 && n.endLine > 0)
3355
+ rangeNodes.set(id, n);
3356
+ }
3357
+ const ranges = [...rangeNodes.values()]
3358
+ // Drop whole-file envelope nodes (containers covering >50% of the file).
3359
+ .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
3360
+ .map(n => {
3361
+ let importance = 1;
3362
+ if (entryNodeIds.has(n.id))
3363
+ importance = 10;
3364
+ else if (flow.namedNodeIds.has(n.id))
3365
+ importance = 9; // agent named it → keep its cluster
3366
+ else if (glueNodeIds.has(n.id))
3367
+ importance = 6; // bridging caller/callee of an entry
3368
+ else if (connectedToEntry.has(n.id))
3369
+ importance = 3;
3370
+ // On the rendered call-path spine? That IS the flow answer — its cluster
3371
+ // must never be dropped by the per-file budget (n8n's huge workflow-execute.ts:
3372
+ // processRunExecutionData, the named flow ENTRY at L1562, is a large
3373
+ // low-density method that lost the budget to denser blocks and got cut, so
3374
+ // the agent Read it back — the very thing explore exists to prevent).
3375
+ return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id) };
3376
+ });
3377
+ // Add edge source locations in this file — captures template references
3378
+ // (component usages, event handlers) that aren't nodes themselves.
3379
+ // Query edges directly from the DB (not just the subgraph) because BFS
3380
+ // traversal may have pruned template reference targets due to node budget.
3381
+ const edgeLines = new Set(); // dedup by "line:name"
3382
+ for (const node of group.nodes) {
3383
+ const outgoing = cg.getOutgoingEdges(node.id);
3384
+ for (const edge of outgoing) {
3385
+ if (!edge.line || edge.line <= 0 || edge.kind === 'contains')
3386
+ continue;
3387
+ const key = `${edge.line}:${edge.target}`;
3388
+ if (edgeLines.has(key))
3389
+ continue;
3390
+ edgeLines.add(key);
3391
+ // Look up target name from subgraph first, fall back to edge kind
3392
+ const targetNode = subgraph.nodes.get(edge.target);
3393
+ const targetName = targetNode?.name ?? edge.kind;
3394
+ ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false });
3395
+ }
3396
+ }
3397
+ ranges.sort((a, b) => a.start - b.start);
3398
+ if (ranges.length === 0)
3399
+ continue;
3400
+ const gapThreshold = budget.gapThreshold;
3401
+ const clusters = [];
3402
+ let current = {
3403
+ start: ranges[0].start,
3404
+ end: ranges[0].end,
3405
+ symbols: [`${ranges[0].name}(${ranges[0].kind})`],
3406
+ score: ranges[0].importance,
3407
+ maxImportance: ranges[0].importance,
3408
+ hasSpine: ranges[0].spine,
3409
+ spineCallLine: ranges[0].spineCallLine,
3410
+ };
3411
+ for (let i = 1; i < ranges.length; i++) {
3412
+ const r = ranges[i];
3413
+ if (r.start <= current.end + gapThreshold) {
3414
+ current.end = Math.max(current.end, r.end);
3415
+ current.symbols.push(`${r.name}(${r.kind})`);
3416
+ current.score += r.importance;
3417
+ current.maxImportance = Math.max(current.maxImportance, r.importance);
3418
+ current.hasSpine = current.hasSpine || r.spine;
3419
+ current.spineCallLine = current.spineCallLine ?? r.spineCallLine;
3420
+ }
3421
+ else {
3422
+ clusters.push(current);
3423
+ current = {
3424
+ start: r.start,
3425
+ end: r.end,
3426
+ symbols: [`${r.name}(${r.kind})`],
3427
+ score: r.importance,
3428
+ maxImportance: r.importance,
3429
+ hasSpine: r.spine,
3430
+ spineCallLine: r.spineCallLine,
3431
+ };
3432
+ }
3433
+ }
3434
+ clusters.push(current);
3435
+ // Build file section output from clusters, capped by per-file budget.
3436
+ // The pathological case (#185): a file like Session.swift where every
3437
+ // method is adjacent collapses into one cluster spanning the whole
3438
+ // file, and dumping that into the agent's context is most of the
3439
+ // token cost on small projects. We pick clusters in priority order
3440
+ // until the per-file char cap is hit. Truly enormous single clusters
3441
+ // get tail-trimmed with a marker.
3442
+ const contextPadding = 3;
3443
+ const withLineNumbers = exploreLineNumbersEnabled();
3444
+ // Language-neutral separator (no `//` — not a comment in Python, Ruby,
3445
+ // etc.). With line numbers on, the line-number jump also signals the gap.
3446
+ const GAP_MARKER = '\n\n... (gap) ...\n\n';
3447
+ // An oversize spine method (the call path runs THROUGH a god-method — n8n's
3448
+ // processRunExecutionData is 962 lines) is windowed to its next-hop CALL site
3449
+ // plus the signature head, NOT dumped whole. Without this the cluster is too big
3450
+ // for any per-file cap and gets dropped, so the agent Reads the method back —
3451
+ // the exact gap this closes. Bounded, so a god-method can't blow the budget yet
3452
+ // the spine's call still appears in context.
3453
+ const OVERSIZE_SPINE_LINES = 200;
3454
+ const SPINE_WINDOW = 28; // lines each side of the next-hop call site
3455
+ const buildSection = (c) => {
3456
+ if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) {
3457
+ const call = c.spineCallLine;
3458
+ const winStart = Math.max(c.start, call - SPINE_WINDOW);
3459
+ const winEnd = Math.min(c.end, call + SPINE_WINDOW);
3460
+ const parts = [];
3461
+ // Signature head, only when it sits clearly above the window (else the
3462
+ // window already covers the method opening).
3463
+ const headEnd = Math.min(c.start + 4, winStart - 2);
3464
+ if (headEnd >= c.start) {
3465
+ const head = fileLines.slice(c.start - 1, headEnd).join('\n');
3466
+ parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head);
3467
+ }
3468
+ const win = fileLines.slice(winStart - 1, winEnd).join('\n');
3469
+ parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win);
3470
+ return parts.join(GAP_MARKER);
3471
+ }
3472
+ const startIdx = Math.max(0, c.start - 1 - contextPadding);
3473
+ const endIdx = Math.min(fileLines.length, c.end + contextPadding);
3474
+ const slice = fileLines.slice(startIdx, endIdx).join('\n');
3475
+ // startIdx is 0-based, so the slice's first line is line startIdx + 1.
3476
+ return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
3477
+ };
3478
+ // Rank clusters for inclusion under the per-file cap. Entry-point
3479
+ // clusters come first: a cluster containing a query entry point
3480
+ // (importance 10) must outrank a dense block of mere declarations,
3481
+ // otherwise on a large file like Session.swift the top-of-file class
3482
+ // header + property list (many adjacent low-importance nodes, high
3483
+ // density) wins the budget and buries the actual methods the query
3484
+ // asked about (perform/didCreateURLRequest/task live deep in the
3485
+ // file). Within the same importance tier, prefer density (score per
3486
+ // line) so we still favor focused clusters over sprawling ones, then
3487
+ // smaller span as a cheap-to-include tiebreak.
3488
+ const rankedClusters = clusters
3489
+ .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
3490
+ .sort((a, b) => {
3491
+ // Spine clusters first — the rendered call path IS the flow answer, so it
3492
+ // outranks any denser block of peripheral declarations (a low-density entry
3493
+ // method must not lose the budget to them). Within spine / within non-spine,
3494
+ // the existing importance → density → score → span order holds.
3495
+ if (a.c.hasSpine !== b.c.hasSpine)
3496
+ return (b.c.hasSpine ? 1 : 0) - (a.c.hasSpine ? 1 : 0);
3497
+ if (b.c.maxImportance !== a.c.maxImportance)
3498
+ return b.c.maxImportance - a.c.maxImportance;
3499
+ const densityA = a.c.score / a.span;
3500
+ const densityB = b.c.score / b.span;
3501
+ if (densityB !== densityA)
3502
+ return densityB - densityA;
3503
+ if (b.c.score !== a.c.score)
3504
+ return b.c.score - a.c.score;
3505
+ return a.span - b.span;
3506
+ });
3507
+ // Per-file budget is the SMALLER of the per-file cap and what's left of the
3508
+ // total output cap — so selection (which ranks by importance) keeps the
3509
+ // high-importance clusters and drops peripheral ones, instead of the
3510
+ // downstream source-order trim slicing off whatever comes last in the file.
3511
+ // That source-order slice is what cut Django's `_fetch_all` (L2237, importance
3512
+ // 9 — agent-named) when query.py was the last of four big files to be emitted.
3513
+ const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
3514
+ // Spine ceiling: a flow-path cluster may exceed the per-file cap (the call
3515
+ // path is the answer), but bounded — at most ~2.5× the per-file cap and never
3516
+ // past what's left of the total output cap — so a pathological long in-file
3517
+ // spine can't run away or starve co-flow files entirely.
3518
+ const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, Math.max(0, budget.maxOutputChars - totalChars - 200));
3519
+ const chosenIndices = new Set();
3520
+ let projectedChars = 0;
3521
+ for (const rc of rankedClusters) {
3522
+ const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
3523
+ // Always take the top-ranked cluster, even if oversize, so we don't
3524
+ // return an empty file section (agent would then re-Read the file,
3525
+ // negating the savings).
3526
+ if (chosenIndices.size === 0) {
3527
+ chosenIndices.add(rc.idx);
3528
+ projectedChars += sectionLen;
3529
+ continue;
3530
+ }
3531
+ // A spine cluster (the rendered call path) is the flow answer — include it
3532
+ // past the per-file budget up to the spine ceiling; non-spine clusters obey
3533
+ // the normal per-file budget.
3534
+ const fits = projectedChars + sectionLen <= fileBudget;
3535
+ const spineFits = rc.c.hasSpine && projectedChars + sectionLen <= SPINE_CEILING;
3536
+ if (!fits && !spineFits)
3537
+ continue;
3538
+ chosenIndices.add(rc.idx);
3539
+ projectedChars += sectionLen;
3540
+ }
3541
+ // Emit chosen clusters in source order so the file reads top-to-bottom.
3542
+ let fileSection = '';
3543
+ const allSymbols = [];
3544
+ for (let i = 0; i < clusters.length; i++) {
3545
+ if (!chosenIndices.has(i))
3546
+ continue;
3547
+ const cluster = clusters[i];
3548
+ const section = buildSection(cluster);
3549
+ if (fileSection.length > 0)
3550
+ fileSection += GAP_MARKER;
3551
+ fileSection += section;
3552
+ allSymbols.push(...cluster.symbols);
3553
+ }
3554
+ // A chosen cluster is a COMPLETE method-range — we never cut through a body.
3555
+ // An oversize single cluster (a long monolithic function) renders in FULL:
3556
+ // half a method is useless (the agent just Reads the rest for the other half),
3557
+ // which is the very fallback explore exists to prevent. A pathological file is
3558
+ // bounded by the per-file cluster SELECTION above + the total hard ceiling.
3559
+ if (chosenIndices.size < clusters.length) {
3560
+ anyFileTrimmed = true;
3561
+ }
3562
+ // Dedupe + cap the symbols list shown in the per-file header. Some
3563
+ // files (Session.swift in Alamofire) produced 3.4KB symbol lists
3564
+ // from cluster scoring + edge-source lines, dwarfing the per-file
3565
+ // body cap. Show top names by frequency, with a "+N more" tail.
3566
+ const symbolCounts = new Map();
3567
+ for (const s of allSymbols) {
3568
+ symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
3569
+ }
3570
+ const sortedSymbols = [...symbolCounts.entries()]
3571
+ .sort((a, b) => b[1] - a[1])
3572
+ .map(([name]) => name);
3573
+ const headerCap = budget.maxSymbolsInFileHeader;
3574
+ const headerSymbols = sortedSymbols.slice(0, headerCap);
3575
+ const omittedCount = sortedSymbols.length - headerSymbols.length;
3576
+ const headerSuffix = omittedCount > 0
3577
+ ? `${headerSymbols.join(', ')}, +${omittedCount} more`
3578
+ : headerSymbols.join(', ');
3579
+ const fileHeader = fileSectionHeader(filePath, headerSuffix);
3580
+ // The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
3581
+ // the agent named (or that's on the flow spine) renders even when the
3582
+ // nominal total is used up — it's the answer, and the set is bounded by
3583
+ // maxFiles AND by true-spine/named-seeding having already trimmed each file
3584
+ // to its necessary content. A file that merely REFERENCES the flow
3585
+ // (Combine.swift name-drops request/task) is incidental → still capped, so
3586
+ // freed budget never leaks into noise. This is the last god-file layer:
3587
+ // build (Session, true-spined) + validators-exec (Request) + validate
3588
+ // (DataRequest/Validation) all render, instead of the cap dropping whichever
3589
+ // phase the file order happened to put last.
3590
+ if (!fileNecessary && totalChars + fileSection.length + 200 > budget.maxOutputChars) {
3591
+ // Incidental file that doesn't fit: SKIP it whole — never slice mid-method.
3592
+ // Keep scanning for necessary files (which bypass this cap and render in
3593
+ // full, bounded by the hard ceiling).
3594
+ anyFileTrimmed = true;
3595
+ continue;
3596
+ }
3597
+ lines.push(fileHeader);
3598
+ lines.push('');
3599
+ lines.push('```' + lang);
3600
+ lines.push(fileSection);
3601
+ lines.push('```');
3602
+ lines.push('');
3603
+ totalChars += fileSection.length + 200;
3604
+ renderedFilePaths.push(filePath);
3605
+ filesIncluded++;
3606
+ }
3607
+ // The curated header count is computed from the files that SURVIVE the final
3608
+ // truncation (see end of method) — `filesIncluded` can over-count when the
3609
+ // hard ceiling drops trailing sections — so leave a sentinel here and fill it
3610
+ // in once the output is final.
3611
+ lines[summaryLineIdx] = SUMMARY_SENTINEL;
3612
+ // Add remaining files as references (from both relevant and peripheral files).
3613
+ // Small projects (per budget) skip this — the relevant story already fits
3614
+ // in the source section, and a trailing pointer list is pure overhead.
3615
+ if (budget.includeAdditionalFiles) {
3616
+ const remainingRelevant = sortedFiles.slice(filesIncluded);
3617
+ const peripheralFiles = [...fileGroups.entries()]
3618
+ .filter(([, group]) => group.score < 3)
3619
+ .sort((a, b) => b[1].score - a[1].score);
3620
+ const remainingFiles = [...remainingRelevant, ...peripheralFiles];
3621
+ if (remainingFiles.length > 0) {
3622
+ lines.push('**Not shown above — explore these names for their source**');
3623
+ lines.push('');
3624
+ for (const [filePath, group] of remainingFiles.slice(0, 10)) {
3625
+ const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
3626
+ lines.push(`- ${filePath}: ${symbols}`);
3627
+ }
3628
+ if (remainingFiles.length > 10) {
3629
+ lines.push(`- ... and ${remainingFiles.length - 10} more files`);
3630
+ }
3631
+ }
3632
+ }
3633
+ // Add completeness signal so agents know they don't need to re-read these files.
3634
+ // On small projects the budget gates this off — but if we actually had to
3635
+ // trim or drop clusters, surface a brief note so the agent knows it can
3636
+ // still Read for more detail.
3637
+ if (budget.includeCompletenessSignal) {
3638
+ lines.push('');
3639
+ lines.push('---');
3640
+ lines.push(`> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER codegraph_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`);
3641
+ }
3642
+ else if (anyFileTrimmed) {
3643
+ lines.push('');
3644
+ lines.push(`> Some file sections were trimmed for size. For a specific symbol you still need, run another \`codegraph_explore\` (or \`codegraph_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`);
3645
+ }
3646
+ // Add explore budget note based on project size
3647
+ if (budget.includeBudgetNote) {
3648
+ try {
3649
+ const stats = cg.getStats();
3650
+ const callBudget = getExploreBudget(stats.fileCount);
3651
+ lines.push('');
3652
+ lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
3653
+ }
3654
+ catch {
3655
+ // Stats unavailable — skip budget note
3656
+ }
3657
+ }
3658
+ // Final ceiling — an ABSOLUTE inline cap, not a multiple of the budget. The
3659
+ // render loop renders necessary (named/spine) files even a bit past
3660
+ // maxOutputChars and caps only incidental ones, so this is the last safety.
3661
+ // It MUST stay under the host's inline tool-result limit (~25K chars): above
3662
+ // that the result is externalized to a file the agent Reads back (a 35K
3663
+ // vscode explore did exactly this in the n=4 A/B). So allow a little
3664
+ // necessary overflow above the 24K budget, but hard-stop at 25K — never into
3665
+ // externalize territory.
3666
+ const output = flow.text + lines.join('\n');
3667
+ const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
3668
+ let finalText;
3669
+ if (output.length > hardCeiling) {
3670
+ // Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
3671
+ // ceiling) so we drop whole trailing file-sections rather than slicing
3672
+ // through a method body — a half-rendered method just forces the Read this
3673
+ // tool exists to prevent. Fall back to a line boundary only if no section
3674
+ // header sits in the back half (degenerate single-giant-section case).
3675
+ const cut = output.slice(0, hardCeiling);
3676
+ const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
3677
+ const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
3678
+ const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
3679
+ finalText = safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another codegraph_explore with the specific names — do NOT Read these files.)';
3680
+ }
3681
+ else {
3682
+ finalText = output;
3683
+ }
3684
+ // Curated header (#1046): substitute the sentinel with the count of files
3685
+ // whose source SURVIVES in the final text — not `subgraph`/`fileGroups` (the
3686
+ // raw gather a broad query inflates) and not `filesIncluded` (which can
3687
+ // over-count when the ceiling above drops trailing sections). A file counts
3688
+ // only if its section header is still present; its relevant (non-import)
3689
+ // symbols are summed for N. Files we couldn't fit are still named under "Not
3690
+ // shown above" + the budget note, so nothing is silently dropped.
3691
+ const survivors = renderedFilePaths.filter((fp) => finalText.includes(`${FILE_SECTION_PREFIX}${fp}\``));
3692
+ const shownSymbols = survivors.reduce((sum, fp) => {
3693
+ const g = fileGroups.get(fp);
3694
+ if (!g)
3695
+ return sum;
3696
+ return sum + new Set(g.nodes.filter((n) => n.kind !== 'import' && n.kind !== 'export').map((n) => n.id)).size;
3697
+ }, 0);
3698
+ const summaryLine = survivors.length > 0
3699
+ ? `Found ${shownSymbols} symbol${shownSymbols === 1 ? '' : 's'} across ${survivors.length} file${survivors.length === 1 ? '' : 's'}.`
3700
+ : `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
3701
+ finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
3702
+ return this.textResult(finalText);
3703
+ }
3704
+ /**
3705
+ * Handle codegraph_node
3706
+ */
3707
+ async handleNode(args) {
3708
+ const cg = this.getCodeGraph(args.projectPath);
3709
+ // Default to false to minimize context usage
3710
+ const includeCode = args.includeCode === true;
3711
+ const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
3712
+ const lineHint = typeof args.line === 'number' && args.line > 0 ? args.line : undefined;
3713
+ const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : undefined;
3714
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : undefined;
3715
+ const symbolsOnly = args.symbolsOnly === true;
3716
+ const symbolRaw = typeof args.symbol === 'string' ? args.symbol.trim() : '';
3717
+ // FILE READ MODE: a `file` with no `symbol` reads that file like the Read
3718
+ // tool — its current on-disk source with line numbers, narrowable with
3719
+ // `offset`/`limit` exactly as Read does — PLUS a one-line blast-radius
3720
+ // header (which files depend on it). `symbolsOnly` returns just the
3721
+ // structural map instead. Backed by the index: same bytes Read gives you.
3722
+ if (!symbolRaw && fileHint) {
3723
+ return this.handleFileView(cg, fileHint, { offset, limit, symbolsOnly });
3724
+ }
3725
+ const symbol = this.validateString(args.symbol, 'symbol');
3726
+ if (typeof symbol !== 'string')
3727
+ return symbol;
3728
+ let matches = this.findSymbolMatches(cg, symbol);
3729
+ if (matches.length === 0) {
3730
+ return this.textResult(`Symbol "${symbol}" not found in the codebase`);
3731
+ }
3732
+ // Disambiguate a heavily-overloaded name to a specific definition the caller
3733
+ // pinned by file/line (the `file:line` a trail or another tool showed it) —
3734
+ // so it can fetch e.g. `Harness::poll` at harness.rs:153 out of 50+ `poll`s
3735
+ // instead of Reading. file matches by path suffix/substring; line prefers the
3736
+ // def whose body contains it, else the nearest start. Only narrows (never
3737
+ // empties — if a hint matches nothing it's ignored).
3738
+ if (matches.length > 1 && (fileHint || lineHint !== undefined)) {
3739
+ const norm = (p) => p.replace(/\\/g, '/').toLowerCase();
3740
+ let narrowed = matches;
3741
+ if (fileHint) {
3742
+ const fh = norm(fileHint);
3743
+ const byFile = narrowed.filter((n) => norm(n.filePath).endsWith(fh) || norm(n.filePath).includes(fh));
3744
+ if (byFile.length > 0)
3745
+ narrowed = byFile;
3746
+ }
3747
+ if (lineHint !== undefined && narrowed.length > 1) {
3748
+ const containing = narrowed.filter((n) => n.startLine <= lineHint && (n.endLine ?? n.startLine) >= lineHint);
3749
+ narrowed = containing.length > 0
3750
+ ? containing
3751
+ : [...narrowed].sort((a, b) => Math.abs(a.startLine - lineHint) - Math.abs(b.startLine - lineHint)).slice(0, 1);
3752
+ }
3753
+ if (narrowed.length > 0)
3754
+ matches = narrowed;
3755
+ }
3756
+ // Single definition — the common case.
3757
+ if (matches.length === 1) {
3758
+ return this.textResult(this.truncateOutput(await this.renderNodeSection(cg, matches[0], includeCode)));
3759
+ }
3760
+ // Multiple definitions share this name — overloads, or same-named methods on
3761
+ // different types (Alamofire `didCompleteTask`/`task`/`validate`, gin
3762
+ // `reset`). Returning ONE forces the agent to guess, and when it guesses
3763
+ // wrong it READS the file to find the right overload — the dominant
3764
+ // codegraph_node read cause on Swift/Go. So return them ALL: pack as many
3765
+ // FULL bodies as fit a char budget (the agent gets the one it needs in this
3766
+ // one call, no follow-up parameter to learn), and list any remainder by
3767
+ // file:line so a large overload set can't overflow the per-tool cap.
3768
+ const header = `**${matches.length} definitions named "${symbol}"**`;
3769
+ if (!includeCode) {
3770
+ const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
3771
+ return this.textResult(this.truncateOutput([header, '', 'Re-query with `includeCode: true` to get every body in one call — no need to pick one first.', '', ...list].join('\n')));
3772
+ }
3773
+ const BODY_BUDGET = 12000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
3774
+ // The CHAR budget is the real limiter — keep the count cap high so a set of
3775
+ // SHORT overloads (Alamofire's 10 `validate` variants, each a few lines) all
3776
+ // render in full rather than relegating the one the agent wanted to a
3777
+ // bodiless list. Only a set of many LARGE bodies hits the char budget first.
3778
+ const HARD_CAP = 16;
3779
+ const rendered = [];
3780
+ const listed = [];
3781
+ let used = 0;
3782
+ for (const n of matches) {
3783
+ if (rendered.length >= HARD_CAP) {
3784
+ listed.push(n);
3785
+ continue;
3786
+ }
3787
+ const section = await this.renderNodeSection(cg, n, true);
3788
+ // Always emit the first; emit the rest only while within the char budget.
3789
+ if (rendered.length === 0 || used + section.length <= BODY_BUDGET) {
3790
+ rendered.push(section);
3791
+ used += section.length;
3792
+ }
3793
+ else {
3794
+ listed.push(n);
3795
+ }
3796
+ }
3797
+ const out = [
3798
+ header,
3799
+ `Returning ${rendered.length} in full${listed.length ? `; ${listed.length} more listed below` : ''} — pick the one you need (no Read required).`,
3800
+ '',
3801
+ rendered.join('\n\n---\n\n'),
3802
+ ];
3803
+ if (listed.length) {
3804
+ const LIST_CAP = 20;
3805
+ const shownList = listed.slice(0, LIST_CAP);
3806
+ out.push('', '**Other definitions**', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
3807
+ if (listed.length > LIST_CAP)
3808
+ out.push(`- … +${listed.length - LIST_CAP} more`);
3809
+ out.push('', `> Need one of these in full? Call codegraph_node again with \`file\` (e.g. \`"${listed[0].filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`);
3810
+ }
3811
+ return this.textResult(this.truncateOutput(out.join('\n')));
3812
+ }
3813
+ /**
3814
+ * FILE READ MODE: resolve `fileArg` (path or basename) to an indexed file and
3815
+ * read it like the Read tool — its current on-disk source with line numbers,
3816
+ * narrowable with `offset`/`limit` exactly as Read's are — preceded by a
3817
+ * one-line blast-radius header (which files depend on it). `symbolsOnly`
3818
+ * returns just the structural map (symbols + dependents) instead of source.
3819
+ *
3820
+ * Parity goal: the numbered source block is byte-for-byte the shape Read
3821
+ * returns (`<n>\t<line>`, no padding), so the agent treats it as a Read — only
3822
+ * faster (served from the index) and with the blast radius attached. Security:
3823
+ * yaml/properties files are summarized by key, never dumped (#383); reads go
3824
+ * through validatePathWithinRoot (#527).
3825
+ */
3826
+ async handleFileView(cg, fileArg, opts = {}) {
3827
+ const normalize = (p) => p.replace(/\\/g, '/').replace(/^(?:\.?\/+)+/, '').replace(/\/+$/, '');
3828
+ const wantLower = normalize(fileArg).toLowerCase();
3829
+ const allFiles = cg.getFiles();
3830
+ if (allFiles.length === 0)
3831
+ return this.textResult('No files indexed. Run `codegraph index` first.');
3832
+ let resolved = allFiles.find((f) => f.path.toLowerCase() === wantLower);
3833
+ let candidates = [];
3834
+ if (!resolved) {
3835
+ candidates = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower));
3836
+ if (candidates.length === 1)
3837
+ resolved = candidates[0];
3838
+ }
3839
+ if (!resolved && candidates.length === 0) {
3840
+ candidates = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower));
3841
+ if (candidates.length === 1)
3842
+ resolved = candidates[0];
3843
+ }
3844
+ if (!resolved && candidates.length > 1) {
3845
+ return this.textResult([`"${fileArg}" matches ${candidates.length} indexed files — pass a longer path:`, '',
3846
+ ...candidates.slice(0, 25).map((f) => `- ${f.path}`)].join('\n'));
3847
+ }
3848
+ if (!resolved) {
3849
+ return this.textResult(`No indexed file matches "${fileArg}". Codegraph indexes source files; configs/docs it doesn't parse won't appear — Read those directly.`);
3850
+ }
3851
+ const filePath = resolved.path;
3852
+ const nodes = cg.getNodesInFile(filePath)
3853
+ .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
3854
+ .sort((a, b) => a.startLine - b.startLine);
3855
+ const dependents = cg.getFileDependents(filePath);
3856
+ // Compact, one-line blast radius (codegraph's value-add over a plain Read).
3857
+ const depSummary = dependents.length
3858
+ ? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}`
3859
+ : 'no other indexed file depends on it';
3860
+ // Symbol-map renderer — for symbolsOnly, the config fallback, and read errors.
3861
+ const symbolMap = (heading, limit = 200) => {
3862
+ const lines = [heading];
3863
+ for (const n of nodes.slice(0, limit)) {
3864
+ const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : '';
3865
+ lines.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`);
3866
+ }
3867
+ if (nodes.length > limit)
3868
+ lines.push(`- … +${nodes.length - limit} more`);
3869
+ return lines;
3870
+ };
3871
+ // symbolsOnly → the cheap structural overview, no source.
3872
+ if (opts.symbolsOnly) {
3873
+ const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
3874
+ if (nodes.length)
3875
+ out.push(...symbolMap('**Symbols**'));
3876
+ else
3877
+ out.push('_No indexed symbols in this file._');
3878
+ out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
3879
+ return this.textResult(this.truncateOutput(out.join('\n')));
3880
+ }
3881
+ // SECURITY (#383): never dump a raw config/data file — a yaml/properties
3882
+ // line is `key: <secret>`. Summarize by key and point to a real Read.
3883
+ if (utils_1.CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
3884
+ const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
3885
+ if (nodes.length)
3886
+ out.push(...symbolMap('**Keys (values withheld for safety)**'));
3887
+ out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
3888
+ return this.textResult(this.truncateOutput(out.join('\n')));
3889
+ }
3890
+ // Read the current bytes from disk through the security chokepoint
3891
+ // (validatePathWithinRoot: blocks `../` traversal and symlink escapes, #527).
3892
+ const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
3893
+ let content = null;
3894
+ if (abs) {
3895
+ try {
3896
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
3897
+ }
3898
+ catch {
3899
+ content = null;
3900
+ }
3901
+ }
3902
+ if (content === null) {
3903
+ const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
3904
+ if (nodes.length)
3905
+ out.push(...symbolMap('**Symbols**'));
3906
+ out.push('', `> Read \`${filePath}\` directly for its current content.`);
3907
+ return this.textResult(this.truncateOutput(out.join('\n')));
3908
+ }
3909
+ // Split exactly as Read does — keep the trailing empty line a final newline
3910
+ // produces (Read numbers it too), so line numbers line up byte-for-byte.
3911
+ const fileLines = content.split('\n');
3912
+ const total = fileLines.length;
3913
+ // Read-parity windowing: `offset`/`limit` mean exactly what they do on Read
3914
+ // (1-based start line; max line count). Default: the whole file, capped like
3915
+ // Read at 2000 lines and bounded by a char budget that tracks explore's
3916
+ // proven-safe ~38k response ceiling. Overflow is stated explicitly (Read
3917
+ // paginates too) — never the silent 15k truncateOutput chop.
3918
+ const CHAR_BUDGET = 38000;
3919
+ const DEFAULT_LIMIT = 2000;
3920
+ const offset = Math.max(1, opts.offset ?? 1);
3921
+ if (offset > total) {
3922
+ return this.textResult(`**${filePath}** has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
3923
+ }
3924
+ const maxLines = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
3925
+ const start = offset - 1; // 0-based
3926
+ const header = `**${filePath}** — ${total} lines, ${nodes.length} symbol${nodes.length === 1 ? '' : 's'} · ${depSummary}`;
3927
+ // Numbered lines, byte-for-byte Read's shape: `<n>\t<line>`, no left-pad.
3928
+ const numbered = [];
3929
+ let used = header.length + 8;
3930
+ let i = start;
3931
+ for (; i < total && numbered.length < maxLines; i++) {
3932
+ const ln = `${i + 1}\t${fileLines[i]}`;
3933
+ if (used + ln.length + 1 > CHAR_BUDGET && numbered.length > 0)
3934
+ break;
3935
+ numbered.push(ln);
3936
+ used += ln.length + 1;
3937
+ }
3938
+ const shownEnd = start + numbered.length;
3939
+ const complete = offset === 1 && shownEnd >= total;
3940
+ const out = [header, '', ...numbered];
3941
+ if (!complete) {
3942
+ out.push('', `(lines ${offset}–${shownEnd} of ${total} — pass \`offset\`/\`limit\` for another range, or \`codegraph_node <symbol>\` for one symbol in full)`);
3943
+ }
3944
+ // Self-bounded to CHAR_BUDGET — do NOT route through truncateOutput (15k).
3945
+ return this.textResult(out.join('\n'));
3946
+ }
3947
+ /** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
3948
+ async renderNodeSection(cg, node, includeCode) {
3949
+ let code = null;
3950
+ let outline = null;
3951
+ if (includeCode) {
3952
+ // For container symbols (class/interface/struct/…), the full body is the
3953
+ // sum of every method body — a wall of source. Return a structural outline
3954
+ // (members + signatures + line numbers) instead; leaf symbols return their
3955
+ // full body.
3956
+ if (CONTAINER_NODE_KINDS.has(node.kind)) {
3957
+ outline = this.buildContainerOutline(cg, node);
3958
+ }
3959
+ if (!outline) {
3960
+ code = await cg.getCode(node.id);
3961
+ }
3962
+ }
3963
+ return this.formatNodeDetails(node, code, outline) + this.formatTrail(cg, node);
3964
+ }
3965
+ /**
3966
+ * Build the "trail" for a symbol: its direct callees (what it calls) and
3967
+ * callers (what calls it), each with file:line — so codegraph_node doubles as
3968
+ * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
3969
+ * Capped to stay cheap. Walk the graph by calling codegraph_node on a trail
3970
+ * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
3971
+ * dynamic dispatch the static graph couldn't resolve — that absence is itself
3972
+ * a signal (read that one hop) rather than a dead end.
3973
+ */
3974
+ formatTrail(cg, node) {
3975
+ const TRAIL_CAP = 12;
3976
+ const fmt = (e) => {
3977
+ const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
3978
+ const synth = this.synthEdgeNote(e.edge);
3979
+ return synth ? `${base} [${synth.compact}]` : base;
3980
+ };
3981
+ const collect = (edges) => {
3982
+ const seen = new Set([node.id]);
3983
+ const out = [];
3984
+ for (const e of edges) {
3985
+ if (seen.has(e.node.id))
3986
+ continue;
3987
+ seen.add(e.node.id);
3988
+ out.push(e);
3989
+ }
3990
+ return out;
3991
+ };
3992
+ const callees = collect(cg.getCallees(node.id));
3993
+ const callers = collect(cg.getCallers(node.id));
3994
+ if (callees.length === 0 && callers.length === 0)
3995
+ return '';
3996
+ const lines = ['', '**Trail — codegraph_node any of these to follow it (no Read needed)**'];
3997
+ if (callees.length > 0) {
3998
+ lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
3999
+ }
4000
+ if (callers.length > 0) {
4001
+ lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
4002
+ }
4003
+ return lines.join('\n');
4004
+ }
4005
+ /**
4006
+ * Handle codegraph_status
4007
+ */
4008
+ async handleStatus(args) {
4009
+ let cg = this.getCodeGraph(args.projectPath);
4010
+ // Same trick as withStalenessNotice — when an explicit projectPath
4011
+ // resolves to the same project as the default session cg, prefer the
4012
+ // default so getPendingFiles() (only populated by the default's watcher)
4013
+ // is non-empty when there are pending edits.
4014
+ if (this.cg && cg !== this.cg) {
4015
+ try {
4016
+ if ((0, path_1.resolve)(this.cg.getProjectRoot()) === (0, path_1.resolve)(cg.getProjectRoot())) {
4017
+ cg = this.cg;
4018
+ }
4019
+ }
4020
+ catch { /* closed instance — leave as is */ }
4021
+ }
4022
+ const stats = cg.getStats();
4023
+ // Warn when this index actually belongs to a different git working tree
4024
+ // (e.g. the server resolved up from a nested worktree to the main checkout).
4025
+ // Queries then reflect that tree's branch, not the worktree being edited.
4026
+ // status shows the verbose, multi-line form; the read tools get the compact
4027
+ // one-liner via withWorktreeNotice. Both share the cached detection.
4028
+ const mismatch = this.worktreeMismatchFor(args.projectPath);
4029
+ const lines = [
4030
+ '**CodeGraph Status**',
4031
+ '',
4032
+ ];
4033
+ if (mismatch) {
4034
+ lines.push(`> ⚠ ${(0, worktree_1.worktreeMismatchWarning)(mismatch).replace(/\n/g, '\n> ')}`, '');
4035
+ }
4036
+ // ADR 0049 Decision 5④: machine-readable execution-mode declaration —
4037
+ // `mode: daemon|direct` plus a typed `reason` (`daemon` when routed
4038
+ // through the shared daemon; `opt-out`/`no-root-index`/`no-daemon`/
4039
+ // `connection-lost`/`version-skew`/`proxy-setup-failed` for direct). Kept
4040
+ // as plain `key: value` lines (not buried in prose) so a caller can parse
4041
+ // them without depending on the surrounding Markdown.
4042
+ const executionMode = this.executionMode;
4043
+ lines.push(`**Lattice sensor version:** ${version_1.CodeGraphPackageVersion}`, `mode: ${executionMode.mode}`, `reason: ${executionMode.reason}`, '', `**Files indexed:** ${stats.fileCount}`, `**Total nodes:** ${stats.nodeCount}`, `**Total edges:** ${stats.edgeCount}`, `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`);
4044
+ // Surface the active SQLite backend (node:sqlite, Node's built-in real
4045
+ // SQLite — full WAL + FTS5, no native build).
4046
+ lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
4047
+ // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
4048
+ // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
4049
+ // everywhere, so a non-wal mode means the filesystem can't (network/
4050
+ // virtualized mounts, WSL2 /mnt). See issue #238.
4051
+ const journalMode = cg.getJournalMode();
4052
+ if (journalMode === 'wal') {
4053
+ lines.push(`**Journal mode:** wal (concurrent reads safe)`);
4054
+ }
4055
+ else {
4056
+ lines.push(`**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
4057
+ `can block on a concurrent write (WAL appears unsupported on this filesystem)`);
4058
+ }
4059
+ // ADR 0049 Decision 4: the upstream update-availability notice (#1243)
4060
+ // is intentionally not surfaced here — the MCP surface must not mix an
4061
+ // upstream-GitHub-sourced update notice into tool output.
4062
+ // Non-zero at rest means a resolution pass was interrupted mid-run, so
4063
+ // some files' call/impact edges are missing until the next sync sweeps
4064
+ // the leftovers (#1187). Surface it — an agent trusting an incomplete
4065
+ // blast radius is worse than one that knows to re-sync.
4066
+ const pendingRefs = cg.getPendingReferenceCount();
4067
+ if (pendingRefs > 0) {
4068
+ lines.push(`**Pending resolution:** ⚠ ${pendingRefs} references from an interrupted ` +
4069
+ `index run — some caller/impact edges are missing until the next sync ` +
4070
+ `(any file change triggers it, or run \`codegraph sync\`)`);
4071
+ }
4072
+ lines.push('', '**Nodes by Kind:**');
4073
+ for (const [kind, count] of Object.entries(stats.nodesByKind)) {
4074
+ if (count > 0) {
4075
+ lines.push(`- ${kind}: ${count}`);
4076
+ }
4077
+ }
4078
+ lines.push('', '**Languages:**');
4079
+ for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
4080
+ if (count > 0) {
4081
+ lines.push(`- ${lang}: ${count}`);
4082
+ }
4083
+ }
4084
+ // Whole-index degradation (#876): when live watching has permanently
4085
+ // stopped, getPendingFiles() is empty (so no "Pending sync" section below)
4086
+ // but the index is frozen — call that out explicitly here, the one place an
4087
+ // agent asks "is the index caught up?".
4088
+ if (cg.isWatcherDegraded()) {
4089
+ lines.push('', '**Auto-sync disabled:**', `- ${cg.getWatcherDegradedReason() ?? 'live file watching stopped'}`, '- The index is frozen; Read files directly for current content.');
4090
+ }
4091
+ // Per-file freshness — the inverse of the auto-prepended staleness banner
4092
+ // (issue #403). Surfacing it inside `status` gives the agent a single
4093
+ // place to ask "is the index caught up?" rather than inferring from
4094
+ // banners on other tool calls.
4095
+ const pending = cg.getPendingFiles();
4096
+ if (pending.length > 0) {
4097
+ lines.push('', '**Pending sync:**');
4098
+ const now = Date.now();
4099
+ for (const p of pending) {
4100
+ const ageMs = Math.max(0, now - p.lastSeenMs);
4101
+ const label = p.indexing ? 'indexing in progress' : 'pending sync';
4102
+ lines.push(`- ${p.path} (edited ${ageMs}ms ago, ${label})`);
4103
+ }
4104
+ }
4105
+ return this.textResult(lines.join('\n'));
4106
+ }
4107
+ /**
4108
+ * Handle codegraph_files - get project file structure from the index
4109
+ */
4110
+ async handleFiles(args) {
4111
+ const cg = this.getCodeGraph(args.projectPath);
4112
+ const pathFilter = args.path;
4113
+ const pattern = args.pattern;
4114
+ const format = args.format || 'tree';
4115
+ const includeMetadata = args.includeMetadata !== false;
4116
+ const maxDepth = args.maxDepth != null ? (0, utils_1.clamp)(args.maxDepth, 1, 20) : undefined;
4117
+ // Get all files from the index
4118
+ const allFiles = cg.getFiles();
4119
+ if (allFiles.length === 0) {
4120
+ return this.textResult('No files indexed. Run `codegraph index` first.');
4121
+ }
4122
+ // Filter by path prefix. Stored paths are project-relative POSIX (e.g.
4123
+ // "src/foo.ts"), but agents commonly pass project-root variants like "/",
4124
+ // ".", "./", "" or Windows-style "src\foo" — and prefixes with leading
4125
+ // "/", "./" or "\". Normalize all of those before matching so the agent
4126
+ // gets results instead of falling back to Read/Glob (see #426).
4127
+ const normalizedFilter = pathFilter
4128
+ ? pathFilter
4129
+ .replace(/\\/g, '/')
4130
+ .replace(/^(?:\.?\/+)+/, '')
4131
+ .replace(/^\.$/, '')
4132
+ .replace(/\/+$/, '')
4133
+ : '';
4134
+ let files = normalizedFilter
4135
+ ? allFiles.filter(f => f.path === normalizedFilter || f.path.startsWith(normalizedFilter + '/'))
4136
+ : allFiles;
4137
+ // Filter by glob pattern
4138
+ if (pattern) {
4139
+ const regex = this.globToRegex(pattern);
4140
+ files = files.filter(f => regex.test(f.path));
4141
+ }
4142
+ if (files.length === 0) {
4143
+ return this.textResult(`No files found matching the criteria.`);
4144
+ }
4145
+ // Format output
4146
+ let output;
4147
+ switch (format) {
4148
+ case 'flat':
4149
+ output = this.formatFilesFlat(files, includeMetadata);
4150
+ break;
4151
+ case 'grouped':
4152
+ output = this.formatFilesGrouped(files, includeMetadata);
4153
+ break;
4154
+ case 'tree':
4155
+ default:
4156
+ output = this.formatFilesTree(files, includeMetadata, maxDepth);
4157
+ break;
4158
+ }
4159
+ return this.textResult(this.truncateOutput(output));
4160
+ }
4161
+ /**
4162
+ * Convert glob pattern to regex
4163
+ */
4164
+ globToRegex(pattern) {
4165
+ const escaped = pattern
4166
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
4167
+ .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
4168
+ .replace(/\*/g, '[^/]*') // * matches anything except /
4169
+ .replace(/\?/g, '[^/]') // ? matches single char except /
4170
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
4171
+ return new RegExp(escaped);
4172
+ }
4173
+ /**
4174
+ * Format files as a flat list
4175
+ */
4176
+ formatFilesFlat(files, includeMetadata) {
4177
+ const lines = [`**Files (${files.length})**`, ''];
4178
+ for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
4179
+ if (includeMetadata) {
4180
+ lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
4181
+ }
4182
+ else {
4183
+ lines.push(`- ${file.path}`);
4184
+ }
4185
+ }
4186
+ return lines.join('\n');
4187
+ }
4188
+ /**
4189
+ * Format files grouped by language
4190
+ */
4191
+ formatFilesGrouped(files, includeMetadata) {
4192
+ const byLang = new Map();
4193
+ for (const file of files) {
4194
+ const existing = byLang.get(file.language) || [];
4195
+ existing.push(file);
4196
+ byLang.set(file.language, existing);
4197
+ }
4198
+ const lines = [`**Files by Language (${files.length} total)**`, ''];
4199
+ // Sort languages by file count (descending)
4200
+ const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
4201
+ for (const [lang, langFiles] of sortedLangs) {
4202
+ lines.push(`**${lang} (${langFiles.length})**`);
4203
+ for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
4204
+ if (includeMetadata) {
4205
+ lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
4206
+ }
4207
+ else {
4208
+ lines.push(`- ${file.path}`);
4209
+ }
4210
+ }
4211
+ lines.push('');
4212
+ }
4213
+ return lines.join('\n');
4214
+ }
4215
+ /**
4216
+ * Format files as a tree structure
4217
+ */
4218
+ formatFilesTree(files, includeMetadata, maxDepth) {
4219
+ const root = { name: '', children: new Map() };
4220
+ for (const file of files) {
4221
+ const parts = file.path.split('/');
4222
+ let current = root;
4223
+ for (let i = 0; i < parts.length; i++) {
4224
+ const part = parts[i];
4225
+ if (!part)
4226
+ continue;
4227
+ if (!current.children.has(part)) {
4228
+ current.children.set(part, { name: part, children: new Map() });
4229
+ }
4230
+ current = current.children.get(part);
4231
+ // If this is the last part, it's a file
4232
+ if (i === parts.length - 1) {
4233
+ current.file = { language: file.language, nodeCount: file.nodeCount };
4234
+ }
4235
+ }
4236
+ }
4237
+ // Render tree
4238
+ const lines = [`**Project Structure (${files.length} files)**`, ''];
4239
+ const renderNode = (node, prefix, isLast, depth) => {
4240
+ if (maxDepth !== undefined && depth > maxDepth)
4241
+ return;
4242
+ const connector = isLast ? '└── ' : '├── ';
4243
+ const childPrefix = isLast ? ' ' : '│ ';
4244
+ if (node.name) {
4245
+ let line = prefix + connector + node.name;
4246
+ if (node.file && includeMetadata) {
4247
+ line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
4248
+ }
4249
+ lines.push(line);
4250
+ }
4251
+ const children = [...node.children.values()];
4252
+ // Sort: directories first, then files, both alphabetically
4253
+ children.sort((a, b) => {
4254
+ const aIsDir = a.children.size > 0 && !a.file;
4255
+ const bIsDir = b.children.size > 0 && !b.file;
4256
+ if (aIsDir !== bIsDir)
4257
+ return aIsDir ? -1 : 1;
4258
+ return a.name.localeCompare(b.name);
4259
+ });
4260
+ for (let i = 0; i < children.length; i++) {
4261
+ const child = children[i];
4262
+ const nextPrefix = node.name ? prefix + childPrefix : prefix;
4263
+ renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
4264
+ }
4265
+ };
4266
+ renderNode(root, '', true, 0);
4267
+ return lines.join('\n');
4268
+ }
4269
+ // =========================================================================
4270
+ // Symbol resolution helpers
4271
+ // =========================================================================
4272
+ /**
4273
+ * Find a symbol by name, handling disambiguation when multiple matches exist.
4274
+ * Returns the best match and a note about alternatives if any.
4275
+ */
4276
+ /**
4277
+ * Check if a node matches a symbol query.
4278
+ *
4279
+ * Accepts simple names (`run`) and three flavors of qualifier:
4280
+ * - dotted `Session.request` (TS/JS/Python)
4281
+ * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
4282
+ * - slash `configurator/stage_apply` (path-ish)
4283
+ *
4284
+ * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
4285
+ * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
4286
+ * the canonical `crate::module::symbol` form resolves.
4287
+ *
4288
+ * Resolution order, last part must always equal `node.name`:
4289
+ * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
4290
+ * where the extractor builds the qualified name from the AST stack)
4291
+ * 2. File-path containment (handles file-derived modules in Rust/
4292
+ * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
4293
+ */
4294
+ matchesSymbol(node, symbol) {
4295
+ // Simple name match
4296
+ if (node.name === symbol)
4297
+ return true;
4298
+ // File basename match (e.g., "product-card" matches "product-card.liquid")
4299
+ if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol)
4300
+ return true;
4301
+ // Qualified-name lookups: split on any supported separator. `\w` keeps
4302
+ // identifier chars (incl. `_`) intact; everything else is treated as
4303
+ // a separator we tolerate.
4304
+ if (!/[.\/]|::/.test(symbol))
4305
+ return false;
4306
+ const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
4307
+ if (parts.length < 2)
4308
+ return false;
4309
+ const lastPart = parts[parts.length - 1];
4310
+ if (node.name !== lastPart)
4311
+ return false;
4312
+ // Stage 1: qualified-name suffix match. The extractor joins the
4313
+ // semantic hierarchy with `::`, so `Session.request` and
4314
+ // `Session::request` both become `Session::request` here.
4315
+ const colonSuffix = parts.join('::');
4316
+ if (node.qualifiedName.includes(colonSuffix))
4317
+ return true;
4318
+ // Stage 2: file-path containment. Rust modules and Python packages
4319
+ // are not in `qualifiedName` — they're encoded in the file path. So
4320
+ // `stage_apply::run` matches a `run` in any file whose path
4321
+ // contains a `stage_apply` segment (with or without an extension).
4322
+ //
4323
+ // Filter out Rust path prefixes that have no file-system equivalent.
4324
+ const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
4325
+ if (containerHints.length === 0)
4326
+ return false;
4327
+ const segments = node.filePath.split('/').filter((s) => s.length > 0);
4328
+ return containerHints.every((hint) => segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint));
4329
+ }
4330
+ /**
4331
+ * Find ALL definitions matching a name, ranked, so codegraph_node can return
4332
+ * every overload instead of guessing one (the wrong guess → a Read). Keepers
4333
+ * rank before generated stubs (.pb.go etc.); stable within a group preserves
4334
+ * FTS order. Returns [] when nothing matches; a qualified lookup that finds no
4335
+ * exact match returns [] rather than a misleading fuzzy file hit (#173); a
4336
+ * bare name with no exact match falls back to the single top fuzzy result.
4337
+ */
4338
+ findSymbolMatches(cg, symbol) {
4339
+ const isQualified = /[.\/]|::/.test(symbol);
4340
+ // For a bare name, enumerate EVERY exact-name definition via the direct index
4341
+ // (not FTS, which caps + ranks): tokio's `poll` has 50+ defs and the one the
4342
+ // caller wants (`Harness::poll` at harness.rs:153) ranks below any search cut,
4343
+ // so it could be neither rendered nor pinned by the file/line disambiguator —
4344
+ // and the agent Read it. With the full set, the multi-overload render + the
4345
+ // file/line filter can both reach it.
4346
+ if (!isQualified) {
4347
+ const exact = cg.getNodesByName(symbol);
4348
+ if (exact.length > 0) {
4349
+ return [...exact].sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.filePath) ? 1 : 0));
4350
+ }
4351
+ // No exact match — use the single top fuzzy result (e.g. a file basename).
4352
+ const fuzzy = cg.searchNodes(symbol, { limit: 10 });
4353
+ return fuzzy[0] ? [fuzzy[0].node] : [];
4354
+ }
4355
+ // Qualified lookup (`Session.request`, `stage_apply::run`): FTS + matchesSymbol.
4356
+ const limit = 50;
4357
+ let results = cg.searchNodes(symbol, { limit });
4358
+ // FTS strips colons, so `stage_apply::run` searches the literal
4359
+ // `stage_applyrun` and finds nothing. Re-search by the bare last part and
4360
+ // let `matchesSymbol` filter by qualifier.
4361
+ if (isQualified && results.length === 0) {
4362
+ const tail = lastQualifierPart(symbol);
4363
+ if (tail && tail !== symbol)
4364
+ results = cg.searchNodes(tail, { limit });
4365
+ }
4366
+ if (results.length === 0)
4367
+ return [];
4368
+ const exactMatches = results.filter((r) => this.matchesSymbol(r.node, symbol));
4369
+ if (exactMatches.length === 0) {
4370
+ // No exact match — a qualified lookup must not fall back to a fuzzy file
4371
+ // hit (#173); a bare name may use the single top fuzzy result.
4372
+ return isQualified ? [] : results[0] ? [results[0].node] : [];
4373
+ }
4374
+ // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …) so a flow
4375
+ // query prefers the keeper implementation over the protobuf-generated stub.
4376
+ return [...exactMatches]
4377
+ .sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0))
4378
+ .map((r) => r.node);
4379
+ }
4380
+ /**
4381
+ * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
4382
+ * results across all matching symbols (e.g., multiple classes with an `execute` method).
4383
+ */
4384
+ findAllSymbols(cg, symbol) {
4385
+ // Nix option paths: the declaration is stored as `options.<path>` and
4386
+ // config writes carry longer/quoted tails (`<path>."git/config".text`),
4387
+ // so a dotted option token (`xdg.configFile`, `launchd.user.agents`) has
4388
+ // no exact-name node and would degrade to bare-tail FTS soup — burying
4389
+ // the declaration hub the nix-option-path edges hang off. Resolve the
4390
+ // convention directly: declaration first, then the exact write, then a
4391
+ // capped prefix scan of write sites. Three index hits; non-nix graphs
4392
+ // fall straight through.
4393
+ if (/^[a-z][\w'-]*(?:\.[\w'-]+)+$/.test(symbol)) {
4394
+ const optionHits = [
4395
+ ...cg.getNodesByName(`options.${symbol}`),
4396
+ ...cg.getNodesByName(symbol),
4397
+ ...cg.getNodesByNamePrefix(`${symbol}.`, 12),
4398
+ ].filter((n) => n.language === 'nix');
4399
+ if (optionHits.length > 0) {
4400
+ const seen = new Set();
4401
+ const nodes = optionHits.filter((n) => !seen.has(n.id) && !!seen.add(n.id)).slice(0, 10);
4402
+ return { nodes, note: '' };
4403
+ }
4404
+ }
4405
+ let results = cg.searchNodes(symbol, { limit: 50 });
4406
+ // Mirror the fallback in `findSymbol` for qualified queries — FTS
4407
+ // strips colons, so a module-qualified lookup needs a second pass
4408
+ // by the bare last part.
4409
+ if (results.length === 0 && /[.\/]|::/.test(symbol)) {
4410
+ const tail = lastQualifierPart(symbol);
4411
+ if (tail && tail !== symbol)
4412
+ results = cg.searchNodes(tail, { limit: 50 });
4413
+ }
4414
+ if (results.length === 0) {
4415
+ return { nodes: [], note: '' };
4416
+ }
4417
+ const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
4418
+ if (exactMatches.length <= 1) {
4419
+ const node = exactMatches[0]?.node ?? results[0].node;
4420
+ return { nodes: [node], note: '' };
4421
+ }
4422
+ // Same generated-file down-rank as findSymbol — keeps callers/callees
4423
+ // /impact aggregation aligned (a query against "Send" returns the
4424
+ // hand-written implementations before the protobuf scaffold).
4425
+ const ranked = [...exactMatches].sort((a, b) => {
4426
+ const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
4427
+ const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
4428
+ return aGen - bGen;
4429
+ });
4430
+ const locations = ranked.map(r => `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`);
4431
+ const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
4432
+ return { nodes: ranked.map(r => r.node), note };
4433
+ }
4434
+ /**
4435
+ * Truncate output if it exceeds the maximum length
4436
+ */
4437
+ truncateOutput(text) {
4438
+ if (text.length <= MAX_OUTPUT_LENGTH)
4439
+ return text;
4440
+ const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
4441
+ const lastNewline = truncated.lastIndexOf('\n');
4442
+ const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
4443
+ return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
4444
+ }
4445
+ // =========================================================================
4446
+ // Formatting helpers (compact by default to reduce context usage)
4447
+ // =========================================================================
4448
+ formatSearchResults(results) {
4449
+ const lines = [`**Search Results (${results.length} found)**`, ''];
4450
+ for (const result of results) {
4451
+ const { node } = result;
4452
+ const location = node.startLine ? `:${node.startLine}` : '';
4453
+ // Compact format: one line per result with key info
4454
+ lines.push(`**${node.name}** (${node.kind})`);
4455
+ lines.push(`${node.filePath}${location}`);
4456
+ if (node.signature)
4457
+ lines.push(`\`${node.signature}\``);
4458
+ lines.push('');
4459
+ }
4460
+ return lines.join('\n');
4461
+ }
4462
+ formatNodeList(nodes, title, labels) {
4463
+ const lines = [`**${title} (${nodes.length} found)**`, ''];
4464
+ for (const node of nodes) {
4465
+ const location = node.startLine ? `:${node.startLine}` : '';
4466
+ // Compact: just name, kind, location — plus the relationship when it
4467
+ // isn't a plain call (callback registration, instantiation, …).
4468
+ const label = labels?.get(node.id);
4469
+ lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
4470
+ }
4471
+ return lines.join('\n');
4472
+ }
4473
+ /**
4474
+ * Relationship label for a non-`calls` edge in callers/callees lists. A
4475
+ * function-as-value edge (#756) is the high-signal one: `callers(cb)`
4476
+ * showing "via callback registration" tells the agent this is where the
4477
+ * callback is WIRED, not where it's invoked.
4478
+ */
4479
+ edgeLabel(edge) {
4480
+ if (edge.kind === 'calls')
4481
+ return null;
4482
+ if (edge.metadata?.fnRef === true)
4483
+ return 'callback registration';
4484
+ if (edge.kind === 'instantiates')
4485
+ return 'instantiation';
4486
+ if (edge.kind === 'imports')
4487
+ return 'import';
4488
+ if (edge.kind === 'references')
4489
+ return 'reference';
4490
+ return edge.kind;
4491
+ }
4492
+ formatImpact(symbol, impact) {
4493
+ const nodeCount = impact.nodes.size;
4494
+ // Compact format: just list affected symbols grouped by file
4495
+ const lines = [
4496
+ `**Impact: "${symbol}" affects ${nodeCount} symbols**`,
4497
+ '',
4498
+ ];
4499
+ // Group by file
4500
+ const byFile = new Map();
4501
+ for (const node of impact.nodes.values()) {
4502
+ const existing = byFile.get(node.filePath) || [];
4503
+ existing.push(node);
4504
+ byFile.set(node.filePath, existing);
4505
+ }
4506
+ for (const [file, nodes] of byFile) {
4507
+ lines.push(`**${file}:**`);
4508
+ // Compact: inline list
4509
+ const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
4510
+ lines.push(nodeList);
4511
+ lines.push('');
4512
+ }
4513
+ return lines.join('\n');
4514
+ }
4515
+ /**
4516
+ * Build a compact structural outline of a container symbol from its
4517
+ * indexed children (methods, fields, properties, …) — name, kind,
4518
+ * line number, and signature — so the agent gets the shape of a class
4519
+ * without the full source of every method. Returns '' when the container
4520
+ * has no indexed children, so the caller can fall back to full source.
4521
+ */
4522
+ buildContainerOutline(cg, node) {
4523
+ const children = cg.getChildren(node.id)
4524
+ .filter(c => c.kind !== 'import' && c.kind !== 'export')
4525
+ .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
4526
+ if (children.length === 0)
4527
+ return '';
4528
+ const lines = [`**Members (${children.length}):**`, ''];
4529
+ for (const c of children) {
4530
+ const loc = c.startLine ? `:${c.startLine}` : '';
4531
+ const sig = c.signature ? ` — \`${c.signature}\`` : '';
4532
+ lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
4533
+ }
4534
+ return lines.join('\n');
4535
+ }
4536
+ formatNodeDetails(node, code, outline) {
4537
+ const location = node.startLine ? `:${node.startLine}` : '';
4538
+ const lines = [
4539
+ `**${node.name}** (${node.kind})`,
4540
+ '',
4541
+ `**Location:** ${node.filePath}${location}`,
4542
+ ];
4543
+ if (node.signature) {
4544
+ lines.push(`**Signature:** \`${node.signature}\``);
4545
+ }
4546
+ // Only include docstring if it's short and useful
4547
+ if (node.docstring && node.docstring.length < 200) {
4548
+ lines.push('', node.docstring);
4549
+ }
4550
+ if (outline) {
4551
+ lines.push('', outline, '', `> Structural outline only. Read \`${node.filePath}\` or call codegraph_node on a specific member for its body.`);
4552
+ }
4553
+ else if (code) {
4554
+ // Line-numbered (cat -n style, like codegraph_explore and Read) so the
4555
+ // agent can cite/edit exact lines without re-Reading the file for them.
4556
+ const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
4557
+ lines.push('', '```' + node.language, numbered, '```');
4558
+ }
4559
+ return lines.join('\n');
4560
+ }
4561
+ textResult(text) {
4562
+ return {
4563
+ content: [{ type: 'text', text }],
4564
+ };
4565
+ }
4566
+ errorResult(message) {
4567
+ return {
4568
+ content: [{ type: 'text', text: `Error: ${message}` }],
4569
+ isError: true,
4570
+ };
4571
+ }
4572
+ }
4573
+ exports.ToolHandler = ToolHandler;
4574
+ //# sourceMappingURL=tools.js.map