@colbymchenry/codegraph-darwin-x64 0.9.8 → 1.0.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 (301) hide show
  1. package/lib/dist/bin/codegraph.d.ts +1 -0
  2. package/lib/dist/bin/codegraph.d.ts.map +1 -1
  3. package/lib/dist/bin/codegraph.js +247 -39
  4. package/lib/dist/bin/codegraph.js.map +1 -1
  5. package/lib/dist/context/index.d.ts +9 -0
  6. package/lib/dist/context/index.d.ts.map +1 -1
  7. package/lib/dist/context/index.js +102 -6
  8. package/lib/dist/context/index.js.map +1 -1
  9. package/lib/dist/context/markers.d.ts +19 -0
  10. package/lib/dist/context/markers.d.ts.map +1 -0
  11. package/lib/dist/context/markers.js +22 -0
  12. package/lib/dist/context/markers.js.map +1 -0
  13. package/lib/dist/db/index.d.ts.map +1 -1
  14. package/lib/dist/db/index.js +2 -1
  15. package/lib/dist/db/index.js.map +1 -1
  16. package/lib/dist/db/migrations.d.ts +1 -1
  17. package/lib/dist/db/migrations.d.ts.map +1 -1
  18. package/lib/dist/db/migrations.js +10 -1
  19. package/lib/dist/db/migrations.js.map +1 -1
  20. package/lib/dist/db/queries.d.ts +43 -0
  21. package/lib/dist/db/queries.d.ts.map +1 -1
  22. package/lib/dist/db/queries.js +103 -7
  23. package/lib/dist/db/queries.js.map +1 -1
  24. package/lib/dist/db/schema.sql +1 -0
  25. package/lib/dist/db/sqlite-adapter.d.ts +7 -0
  26. package/lib/dist/db/sqlite-adapter.d.ts.map +1 -1
  27. package/lib/dist/db/sqlite-adapter.js +3 -0
  28. package/lib/dist/db/sqlite-adapter.js.map +1 -1
  29. package/lib/dist/directory.d.ts +34 -2
  30. package/lib/dist/directory.d.ts.map +1 -1
  31. package/lib/dist/directory.js +129 -35
  32. package/lib/dist/directory.js.map +1 -1
  33. package/lib/dist/extraction/astro-extractor.d.ts +79 -0
  34. package/lib/dist/extraction/astro-extractor.d.ts.map +1 -0
  35. package/lib/dist/extraction/astro-extractor.js +320 -0
  36. package/lib/dist/extraction/astro-extractor.js.map +1 -0
  37. package/lib/dist/extraction/extraction-version.d.ts +25 -0
  38. package/lib/dist/extraction/extraction-version.d.ts.map +1 -0
  39. package/lib/dist/extraction/extraction-version.js +28 -0
  40. package/lib/dist/extraction/extraction-version.js.map +1 -0
  41. package/lib/dist/extraction/function-ref.d.ts +118 -0
  42. package/lib/dist/extraction/function-ref.d.ts.map +1 -0
  43. package/lib/dist/extraction/function-ref.js +727 -0
  44. package/lib/dist/extraction/function-ref.js.map +1 -0
  45. package/lib/dist/extraction/generated-detection.d.ts.map +1 -1
  46. package/lib/dist/extraction/generated-detection.js +3 -0
  47. package/lib/dist/extraction/generated-detection.js.map +1 -1
  48. package/lib/dist/extraction/grammars.d.ts +7 -1
  49. package/lib/dist/extraction/grammars.d.ts.map +1 -1
  50. package/lib/dist/extraction/grammars.js +52 -4
  51. package/lib/dist/extraction/grammars.js.map +1 -1
  52. package/lib/dist/extraction/index.d.ts +34 -0
  53. package/lib/dist/extraction/index.d.ts.map +1 -1
  54. package/lib/dist/extraction/index.js +346 -62
  55. package/lib/dist/extraction/index.js.map +1 -1
  56. package/lib/dist/extraction/languages/c-cpp.d.ts +8 -0
  57. package/lib/dist/extraction/languages/c-cpp.d.ts.map +1 -1
  58. package/lib/dist/extraction/languages/c-cpp.js +87 -28
  59. package/lib/dist/extraction/languages/c-cpp.js.map +1 -1
  60. package/lib/dist/extraction/languages/csharp.d.ts +22 -0
  61. package/lib/dist/extraction/languages/csharp.d.ts.map +1 -1
  62. package/lib/dist/extraction/languages/csharp.js +84 -2
  63. package/lib/dist/extraction/languages/csharp.js.map +1 -1
  64. package/lib/dist/extraction/languages/dart.d.ts.map +1 -1
  65. package/lib/dist/extraction/languages/dart.js +161 -1
  66. package/lib/dist/extraction/languages/dart.js.map +1 -1
  67. package/lib/dist/extraction/languages/go.d.ts.map +1 -1
  68. package/lib/dist/extraction/languages/go.js +43 -2
  69. package/lib/dist/extraction/languages/go.js.map +1 -1
  70. package/lib/dist/extraction/languages/index.d.ts.map +1 -1
  71. package/lib/dist/extraction/languages/index.js +2 -0
  72. package/lib/dist/extraction/languages/index.js.map +1 -1
  73. package/lib/dist/extraction/languages/java.d.ts.map +1 -1
  74. package/lib/dist/extraction/languages/java.js +42 -1
  75. package/lib/dist/extraction/languages/java.js.map +1 -1
  76. package/lib/dist/extraction/languages/javascript.d.ts.map +1 -1
  77. package/lib/dist/extraction/languages/javascript.js +16 -0
  78. package/lib/dist/extraction/languages/javascript.js.map +1 -1
  79. package/lib/dist/extraction/languages/kotlin.d.ts.map +1 -1
  80. package/lib/dist/extraction/languages/kotlin.js +69 -0
  81. package/lib/dist/extraction/languages/kotlin.js.map +1 -1
  82. package/lib/dist/extraction/languages/objc.d.ts.map +1 -1
  83. package/lib/dist/extraction/languages/objc.js +42 -0
  84. package/lib/dist/extraction/languages/objc.js.map +1 -1
  85. package/lib/dist/extraction/languages/pascal.d.ts.map +1 -1
  86. package/lib/dist/extraction/languages/pascal.js +11 -0
  87. package/lib/dist/extraction/languages/pascal.js.map +1 -1
  88. package/lib/dist/extraction/languages/php.d.ts.map +1 -1
  89. package/lib/dist/extraction/languages/php.js +90 -1
  90. package/lib/dist/extraction/languages/php.js.map +1 -1
  91. package/lib/dist/extraction/languages/r.d.ts +3 -0
  92. package/lib/dist/extraction/languages/r.d.ts.map +1 -0
  93. package/lib/dist/extraction/languages/r.js +314 -0
  94. package/lib/dist/extraction/languages/r.js.map +1 -0
  95. package/lib/dist/extraction/languages/ruby.d.ts.map +1 -1
  96. package/lib/dist/extraction/languages/ruby.js +35 -0
  97. package/lib/dist/extraction/languages/ruby.js.map +1 -1
  98. package/lib/dist/extraction/languages/rust.d.ts.map +1 -1
  99. package/lib/dist/extraction/languages/rust.js +35 -2
  100. package/lib/dist/extraction/languages/rust.js.map +1 -1
  101. package/lib/dist/extraction/languages/scala.d.ts.map +1 -1
  102. package/lib/dist/extraction/languages/scala.js +61 -1
  103. package/lib/dist/extraction/languages/scala.js.map +1 -1
  104. package/lib/dist/extraction/languages/swift.d.ts.map +1 -1
  105. package/lib/dist/extraction/languages/swift.js +61 -0
  106. package/lib/dist/extraction/languages/swift.js.map +1 -1
  107. package/lib/dist/extraction/languages/typescript.d.ts +13 -0
  108. package/lib/dist/extraction/languages/typescript.d.ts.map +1 -1
  109. package/lib/dist/extraction/languages/typescript.js +38 -0
  110. package/lib/dist/extraction/languages/typescript.js.map +1 -1
  111. package/lib/dist/extraction/liquid-extractor.d.ts +7 -0
  112. package/lib/dist/extraction/liquid-extractor.d.ts.map +1 -1
  113. package/lib/dist/extraction/liquid-extractor.js +53 -9
  114. package/lib/dist/extraction/liquid-extractor.js.map +1 -1
  115. package/lib/dist/extraction/razor-extractor.d.ts +42 -0
  116. package/lib/dist/extraction/razor-extractor.d.ts.map +1 -0
  117. package/lib/dist/extraction/razor-extractor.js +285 -0
  118. package/lib/dist/extraction/razor-extractor.js.map +1 -0
  119. package/lib/dist/extraction/svelte-extractor.d.ts.map +1 -1
  120. package/lib/dist/extraction/svelte-extractor.js +6 -3
  121. package/lib/dist/extraction/svelte-extractor.js.map +1 -1
  122. package/lib/dist/extraction/tree-sitter-helpers.d.ts.map +1 -1
  123. package/lib/dist/extraction/tree-sitter-helpers.js +59 -10
  124. package/lib/dist/extraction/tree-sitter-helpers.js.map +1 -1
  125. package/lib/dist/extraction/tree-sitter-types.d.ts +33 -0
  126. package/lib/dist/extraction/tree-sitter-types.d.ts.map +1 -1
  127. package/lib/dist/extraction/tree-sitter.d.ts +237 -0
  128. package/lib/dist/extraction/tree-sitter.d.ts.map +1 -1
  129. package/lib/dist/extraction/tree-sitter.js +1820 -68
  130. package/lib/dist/extraction/tree-sitter.js.map +1 -1
  131. package/lib/dist/extraction/vue-extractor.d.ts +15 -0
  132. package/lib/dist/extraction/vue-extractor.d.ts.map +1 -1
  133. package/lib/dist/extraction/vue-extractor.js +94 -3
  134. package/lib/dist/extraction/vue-extractor.js.map +1 -1
  135. package/lib/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  136. package/lib/dist/extraction/wasm/tree-sitter-r.wasm +0 -0
  137. package/lib/dist/graph/queries.d.ts.map +1 -1
  138. package/lib/dist/graph/queries.js +13 -40
  139. package/lib/dist/graph/queries.js.map +1 -1
  140. package/lib/dist/graph/traversal.d.ts.map +1 -1
  141. package/lib/dist/graph/traversal.js +16 -4
  142. package/lib/dist/graph/traversal.js.map +1 -1
  143. package/lib/dist/index.d.ts +41 -3
  144. package/lib/dist/index.d.ts.map +1 -1
  145. package/lib/dist/index.js +99 -9
  146. package/lib/dist/index.js.map +1 -1
  147. package/lib/dist/installer/index.d.ts.map +1 -1
  148. package/lib/dist/installer/index.js +52 -2
  149. package/lib/dist/installer/index.js.map +1 -1
  150. package/lib/dist/installer/instructions-template.d.ts +34 -11
  151. package/lib/dist/installer/instructions-template.d.ts.map +1 -1
  152. package/lib/dist/installer/instructions-template.js +44 -12
  153. package/lib/dist/installer/instructions-template.js.map +1 -1
  154. package/lib/dist/installer/targets/claude.d.ts.map +1 -1
  155. package/lib/dist/installer/targets/claude.js +6 -10
  156. package/lib/dist/installer/targets/claude.js.map +1 -1
  157. package/lib/dist/installer/targets/codex.js +4 -6
  158. package/lib/dist/installer/targets/codex.js.map +1 -1
  159. package/lib/dist/installer/targets/gemini.js +4 -6
  160. package/lib/dist/installer/targets/gemini.js.map +1 -1
  161. package/lib/dist/installer/targets/opencode.d.ts +9 -1
  162. package/lib/dist/installer/targets/opencode.d.ts.map +1 -1
  163. package/lib/dist/installer/targets/opencode.js +91 -40
  164. package/lib/dist/installer/targets/opencode.js.map +1 -1
  165. package/lib/dist/installer/targets/shared.d.ts +14 -0
  166. package/lib/dist/installer/targets/shared.d.ts.map +1 -1
  167. package/lib/dist/installer/targets/shared.js +19 -2
  168. package/lib/dist/installer/targets/shared.js.map +1 -1
  169. package/lib/dist/mcp/daemon.d.ts +60 -1
  170. package/lib/dist/mcp/daemon.d.ts.map +1 -1
  171. package/lib/dist/mcp/daemon.js +221 -8
  172. package/lib/dist/mcp/daemon.js.map +1 -1
  173. package/lib/dist/mcp/dynamic-boundaries.d.ts +41 -0
  174. package/lib/dist/mcp/dynamic-boundaries.d.ts.map +1 -0
  175. package/lib/dist/mcp/dynamic-boundaries.js +359 -0
  176. package/lib/dist/mcp/dynamic-boundaries.js.map +1 -0
  177. package/lib/dist/mcp/index.d.ts.map +1 -1
  178. package/lib/dist/mcp/index.js +18 -9
  179. package/lib/dist/mcp/index.js.map +1 -1
  180. package/lib/dist/mcp/ppid-watchdog.d.ts +44 -0
  181. package/lib/dist/mcp/ppid-watchdog.d.ts.map +1 -0
  182. package/lib/dist/mcp/ppid-watchdog.js +27 -0
  183. package/lib/dist/mcp/ppid-watchdog.js.map +1 -0
  184. package/lib/dist/mcp/proxy.d.ts +6 -0
  185. package/lib/dist/mcp/proxy.d.ts.map +1 -1
  186. package/lib/dist/mcp/proxy.js +153 -24
  187. package/lib/dist/mcp/proxy.js.map +1 -1
  188. package/lib/dist/mcp/server-instructions.d.ts +12 -1
  189. package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
  190. package/lib/dist/mcp/server-instructions.js +58 -32
  191. package/lib/dist/mcp/server-instructions.js.map +1 -1
  192. package/lib/dist/mcp/session.d.ts +2 -0
  193. package/lib/dist/mcp/session.d.ts.map +1 -1
  194. package/lib/dist/mcp/session.js +49 -2
  195. package/lib/dist/mcp/session.js.map +1 -1
  196. package/lib/dist/mcp/stdin-teardown.d.ts +27 -0
  197. package/lib/dist/mcp/stdin-teardown.d.ts.map +1 -0
  198. package/lib/dist/mcp/stdin-teardown.js +49 -0
  199. package/lib/dist/mcp/stdin-teardown.js.map +1 -0
  200. package/lib/dist/mcp/tools.d.ts +110 -49
  201. package/lib/dist/mcp/tools.d.ts.map +1 -1
  202. package/lib/dist/mcp/tools.js +1222 -972
  203. package/lib/dist/mcp/tools.js.map +1 -1
  204. package/lib/dist/mcp/transport.d.ts.map +1 -1
  205. package/lib/dist/mcp/transport.js +18 -2
  206. package/lib/dist/mcp/transport.js.map +1 -1
  207. package/lib/dist/resolution/callback-synthesizer.d.ts +3 -3
  208. package/lib/dist/resolution/callback-synthesizer.d.ts.map +1 -1
  209. package/lib/dist/resolution/callback-synthesizer.js +549 -21
  210. package/lib/dist/resolution/callback-synthesizer.js.map +1 -1
  211. package/lib/dist/resolution/frameworks/astro.d.ts +9 -0
  212. package/lib/dist/resolution/frameworks/astro.d.ts.map +1 -0
  213. package/lib/dist/resolution/frameworks/astro.js +169 -0
  214. package/lib/dist/resolution/frameworks/astro.js.map +1 -0
  215. package/lib/dist/resolution/frameworks/expo-modules.d.ts.map +1 -1
  216. package/lib/dist/resolution/frameworks/expo-modules.js +6 -1
  217. package/lib/dist/resolution/frameworks/expo-modules.js.map +1 -1
  218. package/lib/dist/resolution/frameworks/index.d.ts +1 -0
  219. package/lib/dist/resolution/frameworks/index.d.ts.map +1 -1
  220. package/lib/dist/resolution/frameworks/index.js +5 -1
  221. package/lib/dist/resolution/frameworks/index.js.map +1 -1
  222. package/lib/dist/resolution/frameworks/java.js +6 -1
  223. package/lib/dist/resolution/frameworks/java.js.map +1 -1
  224. package/lib/dist/resolution/frameworks/python.d.ts.map +1 -1
  225. package/lib/dist/resolution/frameworks/python.js +7 -3
  226. package/lib/dist/resolution/frameworks/python.js.map +1 -1
  227. package/lib/dist/resolution/frameworks/react-native.d.ts.map +1 -1
  228. package/lib/dist/resolution/frameworks/react-native.js +53 -3
  229. package/lib/dist/resolution/frameworks/react-native.js.map +1 -1
  230. package/lib/dist/resolution/frameworks/react.d.ts.map +1 -1
  231. package/lib/dist/resolution/frameworks/react.js +15 -3
  232. package/lib/dist/resolution/frameworks/react.js.map +1 -1
  233. package/lib/dist/resolution/frameworks/svelte.js +5 -1
  234. package/lib/dist/resolution/frameworks/svelte.js.map +1 -1
  235. package/lib/dist/resolution/frameworks/vue.js +24 -27
  236. package/lib/dist/resolution/frameworks/vue.js.map +1 -1
  237. package/lib/dist/resolution/import-resolver.d.ts +10 -0
  238. package/lib/dist/resolution/import-resolver.d.ts.map +1 -1
  239. package/lib/dist/resolution/import-resolver.js +564 -2
  240. package/lib/dist/resolution/import-resolver.js.map +1 -1
  241. package/lib/dist/resolution/index.d.ts +80 -0
  242. package/lib/dist/resolution/index.d.ts.map +1 -1
  243. package/lib/dist/resolution/index.js +457 -7
  244. package/lib/dist/resolution/index.js.map +1 -1
  245. package/lib/dist/resolution/name-matcher.d.ts +61 -0
  246. package/lib/dist/resolution/name-matcher.d.ts.map +1 -1
  247. package/lib/dist/resolution/name-matcher.js +590 -14
  248. package/lib/dist/resolution/name-matcher.js.map +1 -1
  249. package/lib/dist/resolution/types.d.ts +27 -3
  250. package/lib/dist/resolution/types.d.ts.map +1 -1
  251. package/lib/dist/resolution/workspace-packages.d.ts +48 -0
  252. package/lib/dist/resolution/workspace-packages.d.ts.map +1 -0
  253. package/lib/dist/resolution/workspace-packages.js +208 -0
  254. package/lib/dist/resolution/workspace-packages.js.map +1 -0
  255. package/lib/dist/search/query-utils.d.ts +35 -1
  256. package/lib/dist/search/query-utils.d.ts.map +1 -1
  257. package/lib/dist/search/query-utils.js +109 -10
  258. package/lib/dist/search/query-utils.js.map +1 -1
  259. package/lib/dist/sync/watcher.d.ts +124 -32
  260. package/lib/dist/sync/watcher.d.ts.map +1 -1
  261. package/lib/dist/sync/watcher.js +326 -111
  262. package/lib/dist/sync/watcher.js.map +1 -1
  263. package/lib/dist/telemetry/index.d.ts +146 -0
  264. package/lib/dist/telemetry/index.d.ts.map +1 -0
  265. package/lib/dist/telemetry/index.js +544 -0
  266. package/lib/dist/telemetry/index.js.map +1 -0
  267. package/lib/dist/types.d.ts +25 -2
  268. package/lib/dist/types.d.ts.map +1 -1
  269. package/lib/dist/types.js +3 -0
  270. package/lib/dist/types.js.map +1 -1
  271. package/lib/dist/upgrade/index.d.ts +132 -0
  272. package/lib/dist/upgrade/index.d.ts.map +1 -0
  273. package/lib/dist/upgrade/index.js +462 -0
  274. package/lib/dist/upgrade/index.js.map +1 -0
  275. package/lib/dist/utils.d.ts +30 -24
  276. package/lib/dist/utils.d.ts.map +1 -1
  277. package/lib/dist/utils.js +64 -48
  278. package/lib/dist/utils.js.map +1 -1
  279. package/lib/node_modules/.package-lock.json +1 -29
  280. package/lib/package.json +1 -2
  281. package/package.json +1 -1
  282. package/lib/node_modules/chokidar/LICENSE +0 -21
  283. package/lib/node_modules/chokidar/README.md +0 -305
  284. package/lib/node_modules/chokidar/esm/handler.d.ts +0 -90
  285. package/lib/node_modules/chokidar/esm/handler.js +0 -629
  286. package/lib/node_modules/chokidar/esm/index.d.ts +0 -215
  287. package/lib/node_modules/chokidar/esm/index.js +0 -798
  288. package/lib/node_modules/chokidar/esm/package.json +0 -1
  289. package/lib/node_modules/chokidar/handler.d.ts +0 -90
  290. package/lib/node_modules/chokidar/handler.js +0 -635
  291. package/lib/node_modules/chokidar/index.d.ts +0 -215
  292. package/lib/node_modules/chokidar/index.js +0 -804
  293. package/lib/node_modules/chokidar/package.json +0 -69
  294. package/lib/node_modules/readdirp/LICENSE +0 -21
  295. package/lib/node_modules/readdirp/README.md +0 -120
  296. package/lib/node_modules/readdirp/esm/index.d.ts +0 -108
  297. package/lib/node_modules/readdirp/esm/index.js +0 -257
  298. package/lib/node_modules/readdirp/esm/package.json +0 -1
  299. package/lib/node_modules/readdirp/index.d.ts +0 -108
  300. package/lib/node_modules/readdirp/index.js +0 -263
  301. package/lib/node_modules/readdirp/package.json +0 -70
@@ -4,41 +4,8 @@
4
4
  *
5
5
  * Defines the tools exposed by the CodeGraph MCP server.
6
6
  */
7
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
- if (k2 === undefined) k2 = k;
9
- var desc = Object.getOwnPropertyDescriptor(m, k);
10
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
- desc = { enumerable: true, get: function() { return m[k]; } };
12
- }
13
- Object.defineProperty(o, k2, desc);
14
- }) : (function(o, m, k, k2) {
15
- if (k2 === undefined) k2 = k;
16
- o[k2] = m[k];
17
- }));
18
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
- Object.defineProperty(o, "default", { enumerable: true, value: v });
20
- }) : function(o, v) {
21
- o["default"] = v;
22
- });
23
- var __importStar = (this && this.__importStar) || (function () {
24
- var ownKeys = function(o) {
25
- ownKeys = Object.getOwnPropertyNames || function (o) {
26
- var ar = [];
27
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
- return ar;
29
- };
30
- return ownKeys(o);
31
- };
32
- return function (mod) {
33
- if (mod && mod.__esModule) return mod;
34
- var result = {};
35
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
- __setModuleDefault(result, mod);
37
- return result;
38
- };
39
- })();
40
7
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.ToolHandler = exports.tools = void 0;
8
+ exports.ToolHandler = exports.tools = exports.PathRefusalError = exports.NotIndexedError = void 0;
42
9
  exports.getExploreBudget = getExploreBudget;
43
10
  exports.getExploreOutputBudget = getExploreOutputBudget;
44
11
  exports.formatStaleBanner = formatStaleBanner;
@@ -52,12 +19,32 @@ const directory_1 = require("../directory");
52
19
  // sync + cached (CommonJS build).
53
20
  const loadCodeGraph = () => require('../index').default;
54
21
  const worktree_1 = require("../sync/worktree");
55
- const crypto_1 = require("crypto");
22
+ const query_utils_1 = require("../search/query-utils");
56
23
  const fs_1 = require("fs");
57
24
  const utils_1 = require("../utils");
58
25
  const generated_detection_1 = require("../extraction/generated-detection");
59
- const os_1 = require("os");
60
- const pathModule = __importStar(require("path"));
26
+ const dynamic_boundaries_1 = require("./dynamic-boundaries");
27
+ /**
28
+ * An expected, recoverable "codegraph can't serve this" condition — most
29
+ * importantly a project with no index. The dispatch catch converts these to
30
+ * SUCCESS-shaped responses (guidance text, NO isError): an `isError: true`
31
+ * early in a session teaches the agent the toolset is broken and it stops
32
+ * calling codegraph entirely (observed repeatedly), which is exactly wrong
33
+ * for conditions the agent can simply work around (use built-in tools for
34
+ * that codebase / pass projectPath). isError is reserved for "stop trying"
35
+ * cases: security refusals ({@link PathRefusalError}) and genuine
36
+ * malfunctions.
37
+ */
38
+ class NotIndexedError extends Error {
39
+ }
40
+ exports.NotIndexedError = NotIndexedError;
41
+ /**
42
+ * A security refusal (sensitive system path). Stays `isError: true` WITHOUT
43
+ * retry guidance — abandoning this path is the desired agent reaction.
44
+ */
45
+ class PathRefusalError extends Error {
46
+ }
47
+ exports.PathRefusalError = PathRefusalError;
61
48
  const path_1 = require("path");
62
49
  /** Maximum output length to prevent context bloat (characters) */
63
50
  const MAX_OUTPUT_LENGTH = 15000;
@@ -114,13 +101,24 @@ function getExploreBudget(fileCount) {
114
101
  return 5;
115
102
  }
116
103
  function getExploreOutputBudget(fileCount) {
104
+ // Tiered budget, scaled to project size. The budget is a CEILING (relevance
105
+ // still gates WHAT is included), and it MUST stay under the agent's INLINE
106
+ // tool-result cap (~25K chars). Above that, the host externalizes the result
107
+ // to a file the agent then Reads back — re-introducing a read AND the
108
+ // cache-write cost — which is exactly what a 35K vscode explore did in the
109
+ // n=4 README A/B. So even large repos cap at ~24K: the answer is the handful
110
+ // of ~100-line flow windows the agent would have grep-located and read (it
111
+ // natively reads ~6–9 files, median 100-line ranges), NOT a sprawl of 12
112
+ // files. Concentration onto the flow emerges from this cap + the named-file-
113
+ // first sort dropping peripheral files. Invariant: a larger tier must never
114
+ // get a smaller `maxCharsPerFile` than a smaller tier.
117
115
  if (fileCount < 150) {
118
116
  return {
119
117
  // ITER3: revert iter2's aggressive body shrink (forced Read fallback —
120
118
  // the per-file 2.5K cap pushed the agent to Read instead of node).
121
119
  // Back to the iter1 shape (13K/4/3.8K) but keep the test-file
122
- // hard-exclude. The cost lever for this tier lives in handleContext
123
- // (steering the agent to stop after 1-2 calls), not in this budget.
120
+ // hard-exclude. The cost lever for this tier lives in steering the
121
+ // agent to stop after 1-2 calls, not in this budget.
124
122
  maxOutputChars: 13000,
125
123
  defaultMaxFiles: 4,
126
124
  maxCharsPerFile: 3800,
@@ -152,13 +150,11 @@ function getExploreOutputBudget(fileCount) {
152
150
  }
153
151
  if (fileCount < 5000) {
154
152
  return {
155
- // Sized so ONE explore can cover a flow that centers on a god-file (e.g.
156
- // excalidraw's 415 KB App.tsx): the previous 2500/file returned <1% of such
157
- // a file, forcing the agent to Read it anyway. Per-file must also stay ≥ the
158
- // smaller <500 tier (3800) — the old 2500 was non-monotonic. Tokens are
159
- // cheap relative to a 5–10 Read round-trip spiral; favor sufficiency.
160
- maxOutputChars: 28000,
161
- defaultMaxFiles: 10,
153
+ // ~150-line per-file window (the native read unit) × ~6 files, capped at
154
+ // the ~24K inline ceiling so the response is never externalized. Per-file
155
+ // stays the <500 tier (3800) monotonic.
156
+ maxOutputChars: 24000,
157
+ defaultMaxFiles: 8,
162
158
  maxCharsPerFile: 6500,
163
159
  gapThreshold: 12,
164
160
  maxSymbolsInFileHeader: 10,
@@ -170,10 +166,14 @@ function getExploreOutputBudget(fileCount) {
170
166
  excludeLowValueFiles: false,
171
167
  };
172
168
  }
169
+ // Large + very-large repos: SAME ~24K inline ceiling (a bigger response just
170
+ // externalizes — see vscode). More files indexed → more CALLS via
171
+ // getExploreBudget, not a bigger single response. Per-file 7000 (≥ smaller
172
+ // tiers) gives the central file a ~180-line orientation window.
173
173
  if (fileCount < 15000) {
174
174
  return {
175
- maxOutputChars: 35000,
176
- defaultMaxFiles: 12,
175
+ maxOutputChars: 24000,
176
+ defaultMaxFiles: 8,
177
177
  maxCharsPerFile: 7000,
178
178
  gapThreshold: 15,
179
179
  maxSymbolsInFileHeader: 15,
@@ -186,8 +186,8 @@ function getExploreOutputBudget(fileCount) {
186
186
  };
187
187
  }
188
188
  return {
189
- maxOutputChars: 38000,
190
- defaultMaxFiles: 14,
189
+ maxOutputChars: 24000,
190
+ defaultMaxFiles: 8,
191
191
  maxCharsPerFile: 7000,
192
192
  gapThreshold: 15,
193
193
  maxSymbolsInFileHeader: 15,
@@ -244,55 +244,6 @@ function numberSourceLines(slice, firstLineNumber) {
244
244
  }
245
245
  return out.join('\n');
246
246
  }
247
- /**
248
- * Mark a Claude session as having consulted MCP tools.
249
- * This enables Grep/Glob/Bash commands that would otherwise be blocked.
250
- *
251
- * Why the explicit openSync + O_NOFOLLOW dance instead of plain writeFileSync:
252
- * tmpdir() is world-writable on Linux (mode 1777), so on a shared multi-user
253
- * machine any other local user can pre-create `codegraph-consulted-<hash>` as
254
- * a symlink pointing at a file the victim owns. The old `writeFileSync` would
255
- * happily follow that link and overwrite the target's contents with the ISO
256
- * timestamp string (CWE-59). The session-id hash provides the predictability
257
- * gate, but it's defense-in-depth: if a session id ever surfaces in logs,
258
- * argv, or telemetry the attack becomes trivial, and the right fix is to not
259
- * follow links from /tmp paths in the first place.
260
- */
261
- function markSessionConsulted(sessionId) {
262
- try {
263
- const hash = (0, crypto_1.createHash)('md5').update(sessionId).digest('hex').slice(0, 16);
264
- const markerPath = (0, path_1.join)((0, os_1.tmpdir)(), `codegraph-consulted-${hash}`);
265
- // Refuse to follow a pre-planted symlink at the marker path (CWE-59).
266
- // O_NOFOLLOW (below) is the atomic, TOCTOU-free guard on POSIX, but it is
267
- // `undefined` on Windows (libuv ignores it), so the bitwise-OR silently
268
- // drops it and openSync would follow the link. This lstat check closes that
269
- // gap cross-platform; ENOENT (path is free) falls through to create it.
270
- try {
271
- if ((0, fs_1.lstatSync)(markerPath).isSymbolicLink())
272
- return;
273
- }
274
- catch {
275
- // No existing entry (or stat failed) — nothing to refuse; proceed.
276
- }
277
- // O_NOFOLLOW makes openSync throw ELOOP if markerPath is already a symlink.
278
- // O_CREAT + O_TRUNC keep the original "create-or-overwrite" semantics, and
279
- // mode 0o600 prevents readback by other local users (the marker payload is
280
- // benign, but narrowing the exposure costs nothing).
281
- const flags = fs_1.constants.O_WRONLY | fs_1.constants.O_CREAT | fs_1.constants.O_TRUNC | fs_1.constants.O_NOFOLLOW;
282
- const fd = (0, fs_1.openSync)(markerPath, flags, 0o600);
283
- try {
284
- (0, fs_1.writeSync)(fd, new Date().toISOString());
285
- }
286
- finally {
287
- (0, fs_1.closeSync)(fd);
288
- }
289
- }
290
- catch {
291
- // Silently fail - don't break MCP on marker write failure. ELOOP from a
292
- // planted symlink lands here too, which is the intended behavior: refuse
293
- // to write rather than overwrite an attacker-chosen target.
294
- }
295
- }
296
247
  /**
297
248
  * Per-file staleness banner emitted at the top of a tool response when the
298
249
  * file watcher has pending events for files referenced by the response.
@@ -339,15 +290,16 @@ const projectPathProperty = {
339
290
  /**
340
291
  * All CodeGraph MCP tools
341
292
  *
342
- * Designed for minimal context usage - use codegraph_context as the primary tool,
343
- * and only use other tools for targeted follow-up queries.
293
+ * Designed for minimal context usage - use codegraph_explore as the primary tool
294
+ * (one call usually answers the whole question), and only use other tools for
295
+ * targeted follow-up queries.
344
296
  *
345
297
  * All tools support cross-project queries via the optional `projectPath` parameter.
346
298
  */
347
299
  exports.tools = [
348
300
  {
349
301
  name: 'codegraph_search',
350
- description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.',
302
+ 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.',
351
303
  inputSchema: {
352
304
  type: 'object',
353
305
  properties: {
@@ -370,34 +322,9 @@ exports.tools = [
370
322
  required: ['query'],
371
323
  },
372
324
  },
373
- {
374
- name: 'codegraph_context',
375
- description: 'PRIMARY TOOL — call FIRST for any "how does X work"/architecture/bug question. Returns entry points + related symbols + key code in one call; usually answers without further search/Read/Grep. Provides CODE context, not product requirements.',
376
- inputSchema: {
377
- type: 'object',
378
- properties: {
379
- task: {
380
- type: 'string',
381
- description: 'Description of the task, bug, or feature to build context for',
382
- },
383
- maxNodes: {
384
- type: 'number',
385
- description: 'Maximum symbols to include (default: 20)',
386
- default: 20,
387
- },
388
- includeCode: {
389
- type: 'boolean',
390
- description: 'Include code snippets for key symbols (default: true)',
391
- default: true,
392
- },
393
- projectPath: projectPathProperty,
394
- },
395
- required: ['task'],
396
- },
397
- },
398
325
  {
399
326
  name: 'codegraph_callers',
400
- description: 'List functions that call <symbol>. For deep flow use codegraph_trace.',
327
+ description: 'List functions that call <symbol>. For the full flow, use codegraph_explore.',
401
328
  inputSchema: {
402
329
  type: 'object',
403
330
  properties: {
@@ -405,6 +332,10 @@ exports.tools = [
405
332
  type: 'string',
406
333
  description: 'Name of the function, method, or class to find callers for',
407
334
  },
335
+ file: {
336
+ type: 'string',
337
+ 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)',
338
+ },
408
339
  limit: {
409
340
  type: 'number',
410
341
  description: 'Maximum number of callers to return (default: 20)',
@@ -417,7 +348,7 @@ exports.tools = [
417
348
  },
418
349
  {
419
350
  name: 'codegraph_callees',
420
- description: 'List functions that <symbol> calls. For deep flow use codegraph_trace.',
351
+ description: 'List functions that <symbol> calls. For the full flow, use codegraph_explore.',
421
352
  inputSchema: {
422
353
  type: 'object',
423
354
  properties: {
@@ -425,6 +356,10 @@ exports.tools = [
425
356
  type: 'string',
426
357
  description: 'Name of the function, method, or class to find callees for',
427
358
  },
359
+ file: {
360
+ type: 'string',
361
+ description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
362
+ },
428
363
  limit: {
429
364
  type: 'number',
430
365
  description: 'Maximum number of callees to return (default: 20)',
@@ -445,6 +380,10 @@ exports.tools = [
445
380
  type: 'string',
446
381
  description: 'Name of the symbol to analyze impact for',
447
382
  },
383
+ file: {
384
+ type: 'string',
385
+ description: 'Narrow to the definition in this file (path or suffix) when several same-named symbols exist',
386
+ },
448
387
  depth: {
449
388
  type: 'number',
450
389
  description: 'How many levels of dependencies to traverse (default: 2)',
@@ -457,33 +396,54 @@ exports.tools = [
457
396
  },
458
397
  {
459
398
  name: 'codegraph_node',
460
- description: 'One symbol\'s location, signature, callers/callees trail. includeCode=true returns the verbatim body. Use codegraph_trace for full paths instead of chaining nodes.',
399
+ 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.',
461
400
  inputSchema: {
462
401
  type: 'object',
463
402
  properties: {
464
403
  symbol: {
465
404
  type: 'string',
466
- description: 'Name of the symbol to get details for',
405
+ description: 'Name of the symbol to read (symbol mode). Omit it and pass `file` alone to read a whole file like Read.',
467
406
  },
468
407
  includeCode: {
469
408
  type: 'boolean',
470
- description: 'Include full source code (default: false to minimize context)',
409
+ description: 'Symbol mode: include the symbol\'s full body (default: false). Ignored in file mode, which always returns source unless `symbolsOnly` is set.',
410
+ default: false,
411
+ },
412
+ file: {
413
+ type: 'string',
414
+ 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.',
415
+ },
416
+ offset: {
417
+ type: 'number',
418
+ description: 'File mode: 1-based line to start reading from, exactly like Read\'s offset. Defaults to the start of the file.',
419
+ },
420
+ limit: {
421
+ type: 'number',
422
+ 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).',
423
+ },
424
+ symbolsOnly: {
425
+ type: 'boolean',
426
+ description: 'File mode: return just the file\'s symbol map + dependents (a cheap structural overview) instead of its source.',
471
427
  default: false,
472
428
  },
429
+ line: {
430
+ type: 'number',
431
+ description: 'Symbol mode only: disambiguate to the definition at/around this line (use with the file:line a trail showed you).',
432
+ },
473
433
  projectPath: projectPathProperty,
474
434
  },
475
- required: ['symbol'],
435
+ required: [],
476
436
  },
477
437
  },
478
438
  {
479
439
  name: 'codegraph_explore',
480
- description: 'Source of SEVERAL related symbols grouped by file, in one capped call. Query is a bag of symbol/file names (not a question). Returned source is verbatim Read-equivalent do not re-open shown files. Prefer over chained codegraph_node.',
440
+ 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.',
481
441
  inputSchema: {
482
442
  type: 'object',
483
443
  properties: {
484
444
  query: {
485
445
  type: 'string',
486
- description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use codegraph_search first to find relevant names.',
446
+ 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.',
487
447
  },
488
448
  maxFiles: {
489
449
  type: 'number',
@@ -538,25 +498,6 @@ exports.tools = [
538
498
  },
539
499
  },
540
500
  },
541
- {
542
- name: 'codegraph_trace',
543
- description: 'Call path between two symbols — "how does <from> reach <to>?" Returns the chain with each hop\'s body inlined plus the destination\'s callees, in ONE call. Ideal for flow questions (update→render, request→handler, QuerySet→SQL). If no static path exists the chain broke at dynamic dispatch — the failure response inlines both endpoints + their TO-file siblings.',
544
- inputSchema: {
545
- type: 'object',
546
- properties: {
547
- from: {
548
- type: 'string',
549
- description: 'Symbol the flow starts at (e.g., "QuerySet", "handleRequest", "mutateElement")',
550
- },
551
- to: {
552
- type: 'string',
553
- description: 'Symbol the flow should reach (e.g., "execute_sql", "render", "setState")',
554
- },
555
- projectPath: projectPathProperty,
556
- },
557
- required: ['from', 'to'],
558
- },
559
- },
560
501
  ];
561
502
  /**
562
503
  * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
@@ -566,11 +507,35 @@ exports.tools = [
566
507
  */
567
508
  function getStaticTools() {
568
509
  const raw = process.env.CODEGRAPH_MCP_TOOLS;
569
- if (!raw || !raw.trim())
570
- return exports.tools;
510
+ if (!raw || !raw.trim()) {
511
+ return exports.tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
512
+ }
571
513
  const allow = new Set(raw.split(',').map(s => s.trim().replace(/^codegraph_/, '')).filter(Boolean));
572
514
  return allow.size ? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, ''))) : exports.tools;
573
515
  }
516
+ /**
517
+ * The MCP tools served by DEFAULT (short names). The other defined tools
518
+ * (callees, impact, files, status) remain fully functional — handlers stay,
519
+ * the library API and CLI are untouched, and `CODEGRAPH_MCP_TOOLS` re-enables
520
+ * any of them — they just aren't LISTED to agents anymore.
521
+ *
522
+ * Evidence for the cut (the "adapt the tool to the agent" principle —
523
+ * fewer tools = fewer mis-picks, and presence itself steers):
524
+ * - `codegraph_impact` appears in ZERO recorded eval runs ever — its
525
+ * blast-radius info already arrives inline on explore (the "Blast radius"
526
+ * section) and node (the dependents note), so agents never need the
527
+ * standalone tool.
528
+ * - `codegraph_callees` is redundant by construction: a symbol's body (which
529
+ * node returns) IS its callee list, plus the caller/callee trail.
530
+ * - `codegraph_files` / `codegraph_status`: the tiny-repo audit (see
531
+ * getTools) found they "reduce to one grep"; staleness banners already
532
+ * inline the pending-sync info on every read tool, and the CLI covers
533
+ * diagnostics.
534
+ * - `codegraph_callers` stays: exhaustive call-site enumeration (every
535
+ * caller with file:line, callback registrations labeled, one section per
536
+ * same-named definition) is the one job explore/node don't replicate.
537
+ */
538
+ const DEFAULT_MCP_TOOLS = new Set(['explore', 'node', 'search', 'callers']);
574
539
  /**
575
540
  * Tool handler that executes tools against a CodeGraph instance
576
541
  *
@@ -636,7 +601,7 @@ class ToolHandler {
636
601
  * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
637
602
  * trim the tool surface without rebuilding the client config; the ablated
638
603
  * tool is then truly absent from ListTools rather than merely denied on call.
639
- * Matching is on the short form, so "trace" and "codegraph_trace" both work.
604
+ * Matching is on the short form, so "node" and "codegraph_node" both work.
640
605
  */
641
606
  toolAllowlist() {
642
607
  const raw = process.env.CODEGRAPH_MCP_TOOLS;
@@ -659,18 +624,22 @@ class ToolHandler {
659
624
  */
660
625
  getTools() {
661
626
  const allow = this.toolAllowlist();
627
+ // No explicit allowlist → the default 4-tool surface (see
628
+ // DEFAULT_MCP_TOOLS for the evidence). An allowlist replaces the
629
+ // default entirely, so any defined tool can be re-enabled.
662
630
  let visible = allow
663
631
  ? exports.tools.filter(t => allow.has(t.name.replace(/^codegraph_/, '')))
664
- : exports.tools;
632
+ : exports.tools.filter(t => DEFAULT_MCP_TOOLS.has(t.name.replace(/^codegraph_/, '')));
665
633
  if (!this.cg)
666
634
  return visible;
667
635
  try {
668
636
  const stats = this.cg.getStats();
669
637
  const budget = getExploreBudget(stats.fileCount);
670
638
  // Tiny-repo tool gating: on projects under TINY_REPO_FILE_THRESHOLD
671
- // files, only expose the 5 core tools (search, context, node,
672
- // explore, trace). The 5 omitted tools (callers, callees, impact,
673
- // status, files) reduce to one grep at this scale.
639
+ // files, only expose the core trio (search, node, explore) — one
640
+ // below even the 4-tool default: at this scale callers, too, reduces
641
+ // to one grep. (Historical note: the audit below ran when context and
642
+ // trace still existed; its "5 core tools" are today's trio.)
674
643
  //
675
644
  // n=2 audits ruled out cutting below 5 tools:
676
645
  // - 3-tool gate (search + context + trace): cost regressed on
@@ -691,11 +660,9 @@ class ToolHandler {
691
660
  // so it deserves the same gating.
692
661
  const TINY_REPO_FILE_THRESHOLD = 500;
693
662
  const TINY_REPO_CORE_TOOLS = new Set([
663
+ 'codegraph_explore',
694
664
  'codegraph_search',
695
- 'codegraph_context',
696
665
  'codegraph_node',
697
- 'codegraph_explore',
698
- 'codegraph_trace',
699
666
  ]);
700
667
  if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
701
668
  visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
@@ -727,13 +694,15 @@ class ToolHandler {
727
694
  if (!projectPath) {
728
695
  if (!this.cg) {
729
696
  const searched = this.defaultProjectHint ?? process.cwd();
730
- throw new Error('No CodeGraph project is loaded for this session.\n' +
697
+ throw new NotIndexedError('No CodeGraph project is loaded for this session.\n' +
731
698
  `Searched for a .codegraph/ directory starting from: ${searched}\n` +
732
- 'The index is likely fine this is a working-directory detection issue: ' +
699
+ 'If this project IS indexed, this is a working-directory detection issue: ' +
733
700
  "the MCP client launched the server outside your project and didn't report the " +
734
701
  'workspace root. Fix it either way:\n' +
735
702
  ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
736
- ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]');
703
+ ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
704
+ 'If the project simply has no index, continue with your built-in tools (Read/Grep/Glob) ' +
705
+ "and don't call codegraph again this session — the user can run 'codegraph init' to enable it.");
737
706
  }
738
707
  return this.cg;
739
708
  }
@@ -749,13 +718,16 @@ class ToolHandler {
749
718
  if ((0, fs_1.existsSync)(projectPath)) {
750
719
  const pathError = (0, utils_1.validateProjectPath)(projectPath);
751
720
  if (pathError) {
752
- throw new Error(pathError);
721
+ throw new PathRefusalError(pathError);
753
722
  }
754
723
  }
755
724
  // Walk up parent directories to find nearest .codegraph/
756
725
  const resolvedRoot = (0, directory_1.findNearestCodeGraphRoot)(projectPath);
757
726
  if (!resolvedRoot) {
758
- throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
727
+ throw new NotIndexedError(`The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` +
728
+ 'walking up from it), so codegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
729
+ "for that codebase instead, and don't call codegraph for it again this session. " +
730
+ "Indexing is the user's decision — they can run 'codegraph init' in that project to enable it.");
759
731
  }
760
732
  // If the path resolves to the default project, reuse the already-open
761
733
  // default instance rather than opening a SECOND connection to the same DB.
@@ -1004,9 +976,6 @@ class ToolHandler {
1004
976
  case 'codegraph_search':
1005
977
  result = await this.handleSearch(args);
1006
978
  break;
1007
- case 'codegraph_context':
1008
- result = await this.handleContext(args);
1009
- break;
1010
979
  case 'codegraph_callers':
1011
980
  result = await this.handleCallers(args);
1012
981
  break;
@@ -1030,9 +999,6 @@ class ToolHandler {
1030
999
  case 'codegraph_files':
1031
1000
  result = await this.handleFiles(args);
1032
1001
  break;
1033
- case 'codegraph_trace':
1034
- result = await this.handleTrace(args);
1035
- break;
1036
1002
  default:
1037
1003
  return this.errorResult(`Unknown tool: ${toolName}`);
1038
1004
  }
@@ -1040,7 +1006,19 @@ class ToolHandler {
1040
1006
  return this.withStalenessNotice(withWorktree, args.projectPath);
1041
1007
  }
1042
1008
  catch (err) {
1043
- return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
1009
+ // Expected condition, not a malfunction: answer as a SUCCESS so the
1010
+ // agent keeps trusting the toolset for projects that ARE indexed.
1011
+ // (An isError here teaches session-long abandonment — see NotIndexedError.)
1012
+ if (err instanceof NotIndexedError) {
1013
+ return this.textResult(err.message);
1014
+ }
1015
+ // Security refusal: a clean error, no retry encouragement.
1016
+ if (err instanceof PathRefusalError) {
1017
+ return this.errorResult(err.message);
1018
+ }
1019
+ return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
1020
+ 'This is an internal codegraph error — retry the call once; if it persists, ' +
1021
+ 'continue without codegraph for this task.');
1044
1022
  }
1045
1023
  }
1046
1024
  /**
@@ -1051,7 +1029,11 @@ class ToolHandler {
1051
1029
  if (typeof query !== 'string')
1052
1030
  return query;
1053
1031
  const cg = this.getCodeGraph(args.projectPath);
1054
- const kind = args.kind;
1032
+ const rawKind = args.kind;
1033
+ // The schema enum says 'type' (what agents naturally reach for); the
1034
+ // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
1035
+ // matched nothing — a filter value we advertise must work.
1036
+ const kind = rawKind === 'type' ? 'type_alias' : rawKind;
1055
1037
  const rawLimit = Number(args.limit) || 10;
1056
1038
  const limit = (0, utils_1.clamp)(rawLimit, 1, 100);
1057
1039
  const results = cg.searchNodes(query, {
@@ -1073,259 +1055,41 @@ class ToolHandler {
1073
1055
  return this.textResult(this.truncateOutput(formatted));
1074
1056
  }
1075
1057
  /**
1076
- * Handle codegraph_context
1058
+ * Group symbol matches into DISTINCT DEFINITIONS — one group per
1059
+ * (filePath, qualifiedName), so same-file overloads stay together while
1060
+ * unrelated same-named classes across a monorepo's apps (#764: one
1061
+ * `UserService` per NestJS app) are kept apart. Optionally narrowed by a
1062
+ * `file` path/suffix first.
1077
1063
  */
1078
- async handleContext(args) {
1079
- const task = this.validateString(args.task, 'task');
1080
- if (typeof task !== 'string')
1081
- return task;
1082
- // Mark session as consulted (enables Grep/Glob/Bash)
1083
- const sessionId = process.env.CLAUDE_SESSION_ID;
1084
- if (sessionId) {
1085
- markSessionConsulted(sessionId);
1086
- }
1087
- const cg = this.getCodeGraph(args.projectPath);
1088
- // On tiny repos (<150 files), trim maxNodes hard — the entire repo
1089
- // is grep-able in a turn so a 20-node context is wasted budget.
1090
- // 8 covers the typical 1-3 entry-point + their immediate neighbors
1091
- // without dragging in the rest of the small codebase.
1092
- let defaultMaxNodes = 20;
1093
- let isTinyRepo = false;
1094
- let isSmallRepo = false;
1095
- try {
1096
- const stats = cg.getStats();
1097
- if (stats.fileCount < 150) {
1098
- defaultMaxNodes = 8;
1099
- isTinyRepo = true;
1064
+ groupDefinitions(nodes, fileFilter) {
1065
+ let pool = nodes;
1066
+ let filteredOut = false;
1067
+ if (fileFilter) {
1068
+ const wanted = fileFilter.replace(/^\.\//, '');
1069
+ const narrowed = pool.filter((n) => n.filePath === wanted || n.filePath.endsWith(wanted) || n.filePath.endsWith(`/${wanted}`));
1070
+ if (narrowed.length > 0) {
1071
+ pool = narrowed;
1100
1072
  }
1101
- else if (stats.fileCount < 500) {
1102
- isSmallRepo = true;
1103
- }
1104
- }
1105
- catch {
1106
- // stats failure — fall back to the standard default
1107
- }
1108
- const maxNodes = args.maxNodes || defaultMaxNodes;
1109
- const includeCode = args.includeCode !== false;
1110
- const context = await cg.buildContext(task, {
1111
- maxNodes,
1112
- includeCode,
1113
- format: 'markdown',
1114
- });
1115
- // Detect if this looks like a feature request (vs bug fix or exploration)
1116
- const isFeatureQuery = this.looksLikeFeatureRequest(task);
1117
- const reminder = isFeatureQuery
1118
- ? '\n\n⚠️ **Ask user:** UX preferences, edge cases, acceptance criteria'
1119
- : '';
1120
- // Auto-trace for flow queries: when the task is asking "how does X
1121
- // reach/flow/propagate from A to B", run the trace internally and
1122
- // append its body to the context response. Saves the agent the
1123
- // follow-up codegraph_trace call that was the #2 cost driver on
1124
- // multi-module flow questions (Q3 / etcd Q2 in the audit).
1125
- const flowTrace = await this.maybeInlineFlowTrace(task, cg);
1126
- // Iter3 — sufficiency steering on small repos.
1127
- //
1128
- // Measured economics on tiny (<150) and small (<500) projects: every
1129
- // additional MCP tool call costs ~$0.02-0.05 in cache-write tokens
1130
- // (5K-15K per response at $3.75/1M). The agent reflexively follows
1131
- // codegraph_context with explore/node even when the context response
1132
- // is already sufficient — that pattern drove the cost gap that
1133
- // smaller bodies (iter2) failed to close (smaller bodies just shifted
1134
- // the agent to Read instead). Direct directive on small-repo
1135
- // responses: tell the agent the context call IS the comprehensive
1136
- // pass for a project of this size and that follow-ups should be
1137
- // narrow (trace from→to, node single-symbol) — not another broad
1138
- // explore that re-bundles the same content.
1139
- // ITER4: unified strong directive for both tiny (<150) and small
1140
- // (<500) tiers — measured iter3 result was that the soft <500
1141
- // wording was IGNORED on sinatra (5 tool calls, +92% loss) while
1142
- // the strong <150 wording was followed on cobra/slim (3 calls,
1143
- // -21%/-22% wins). The single-file-framework problem (sinatra)
1144
- // is structurally the same as cobra's; both deserve the same
1145
- // sufficiency steering.
1146
- let smallRepoTail = '';
1147
- let smallRepoRouteInline = '';
1148
- if (isTinyRepo || isSmallRepo) {
1149
- // Iter12: backend-computed routing manifest for routing queries.
1150
- // Builds a URL → handler map directly from the graph (each route
1151
- // node has a `references` edge to its handler), then inlines the
1152
- // top handler file's source. The agent gets the canonical
1153
- // routing answer in one MCP call — no need to parse framework
1154
- // DSL or grep for handlers.
1155
- //
1156
- // Replaces iter10's raw route-file inline. The manifest is more
1157
- // information-dense (parsed URL→handler map vs raw config DSL)
1158
- // and we still inline the top handler file's source so the agent
1159
- // has the implementation bodies inline too.
1160
- const isRouteQuery = /\b(route|routes|routing|request|handler|endpoint|api|controller|middleware|dispatch|invok)/i.test(task);
1161
- if (isRouteQuery) {
1162
- try {
1163
- const manifest = cg.getRoutingManifest(40);
1164
- if (manifest) {
1165
- // 1) Compact URL→handler list (~30-60 lines, ~1-2KB).
1166
- const lines = [
1167
- `\n\n## Routing manifest (${manifest.totalRoutes} routes, top handler file holds ${manifest.topHandlerFileCount})`,
1168
- '',
1169
- '| URL | Handler | Location |',
1170
- '|---|---|---|',
1171
- ];
1172
- for (const e of manifest.entries) {
1173
- lines.push(`| \`${e.url}\` | \`${e.handler}\` | ${e.handlerFile}:${e.handlerLine} |`);
1174
- }
1175
- // 2) Inline the top handler file's source.
1176
- if (manifest.topHandlerFile && manifest.topHandlerFileCount >= 2) {
1177
- try {
1178
- const fullPath = pathModule.join(cg.getProjectRoot(), manifest.topHandlerFile);
1179
- const stat = (0, fs_1.statSync)(fullPath);
1180
- if (stat.size > 0 && stat.size <= 16000) {
1181
- const source = (0, fs_1.readFileSync)(fullPath, 'utf-8');
1182
- const capped = source.length > 7000 ? source.slice(0, 7000) + '\n... (truncated)' : source;
1183
- const ext = (manifest.topHandlerFile.match(/\.([a-z]+)$/i)?.[1] || '').toLowerCase();
1184
- const lang = ext === 'rb' ? 'ruby' : ext === 'py' ? 'python' :
1185
- ext === 'go' ? 'go' : ext === 'rs' ? 'rust' :
1186
- ext === 'js' || ext === 'jsx' ? 'javascript' :
1187
- ext === 'ts' || ext === 'tsx' ? 'typescript' :
1188
- ext === 'java' ? 'java' : ext === 'kt' ? 'kotlin' :
1189
- ext === 'cs' ? 'csharp' : ext === 'php' ? 'php' :
1190
- ext === 'swift' ? 'swift' : ext === 'yml' || ext === 'yaml' ? 'yaml' : '';
1191
- lines.push('');
1192
- lines.push(`### Top handler file (\`${manifest.topHandlerFile}\` — ${manifest.topHandlerFileCount}/${manifest.totalRoutes} routes, full source inlined — do NOT Read)`);
1193
- lines.push('');
1194
- lines.push('```' + lang);
1195
- lines.push(capped);
1196
- lines.push('```');
1197
- }
1198
- }
1199
- catch { /* file read failed, skip the source inline */ }
1200
- }
1201
- smallRepoRouteInline = lines.join('\n');
1202
- }
1203
- }
1204
- catch {
1205
- // Manifest build failed — drop silently
1206
- }
1073
+ else {
1074
+ filteredOut = true;
1207
1075
  }
1208
- const sizeQualifier = isTinyRepo ? 'under 150' : 'under 500';
1209
- const routingClause = smallRepoRouteInline
1210
- ? ' The URL→handler manifest and top handler file are also inlined above — answer routing questions from them.'
1211
- : '';
1212
- smallRepoTail = `\n\n---\n> **This project is small** (${sizeQualifier} indexed files). The entry points and code above cover the relevant surface — **do NOT call codegraph_explore as a follow-up; its content will largely duplicate this response**. If you need a specific flow, call \`codegraph_trace from→to\`. If you need one specific symbol's body, call \`codegraph_node <name>\`.${routingClause} Otherwise, answer from what is above.`;
1213
- }
1214
- // buildContext returns string when format is 'markdown'
1215
- if (typeof context === 'string') {
1216
- return this.textResult(this.truncateOutput(context + flowTrace + reminder + smallRepoRouteInline + smallRepoTail));
1217
1076
  }
1218
- // If it returns TaskContext, format it
1219
- return this.textResult(this.truncateOutput(this.formatTaskContext(context) + flowTrace + reminder + smallRepoRouteInline + smallRepoTail));
1220
- }
1221
- /**
1222
- * Detect a flow-style task ("how does X reach Y", "trace the path from A to B")
1223
- * and pre-run trace between the most likely endpoints, returning the trace
1224
- * body to splice into the context response. Returns '' for non-flow queries
1225
- * or when no plausible endpoint pair can be extracted.
1226
- *
1227
- * Conservative by design: only fires when the task has both a clear flow
1228
- * keyword AND at least two distinct PascalCase / camelCase identifiers.
1229
- * False positives waste a graph query; false negatives just fall back to
1230
- * the agent calling trace itself (existing path-proximity wiring handles
1231
- * disambiguation either way).
1232
- */
1233
- async maybeInlineFlowTrace(task, cg) {
1234
- const lower = task.toLowerCase();
1235
- const FLOW_KEYWORDS = [
1236
- 'trace ',
1237
- 'from ',
1238
- 'reach ',
1239
- 'flow ',
1240
- 'propagat',
1241
- 'how does ',
1242
- 'how do ',
1243
- ];
1244
- if (!FLOW_KEYWORDS.some((k) => lower.includes(k)))
1245
- return '';
1246
- // Extract candidate symbols — PascalCase or camelCase identifiers ≥3 chars.
1247
- // Filter out common non-symbol words and the flow keywords themselves.
1248
- const STOP_WORDS = new Set([
1249
- 'how', 'does', 'the', 'and', 'from', 'through', 'reach', 'reaches',
1250
- 'flow', 'path', 'trace', 'cross', 'module', 'modules', 'where',
1251
- 'update', 'updates', 'updated', 'when', 'what', 'this', 'that',
1252
- ]);
1253
- const ids = [];
1254
- const seen = new Set();
1255
- const re = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)+|[a-z]+[A-Z][a-z]*(?:[A-Z][a-z]*)*)\b/g;
1256
- let m;
1257
- while ((m = re.exec(task)) !== null) {
1258
- const sym = m[1];
1259
- if (sym.length < 3)
1260
- continue;
1261
- const key = sym.toLowerCase();
1262
- if (STOP_WORDS.has(key) || seen.has(key))
1263
- continue;
1264
- seen.add(key);
1265
- ids.push(sym);
1266
- }
1267
- if (ids.length < 2)
1268
- return '';
1269
- // The first two distinct symbols, in order of appearance, are the most
1270
- // likely from/to endpoints — "from X ... through to Y" naturally places
1271
- // them in that order in the prose. If the trace fails to connect, it
1272
- // still returns the inlined endpoint bodies (the trace-failure rewrite).
1273
- const fromSym = ids[0];
1274
- const toSym = ids[1];
1275
- let traceResult;
1276
- try {
1277
- traceResult = await this.handleTrace({
1278
- from: fromSym,
1279
- to: toSym,
1280
- projectPath: cg.getProjectRoot(),
1281
- });
1282
- }
1283
- catch {
1284
- return '';
1077
+ const byDef = new Map();
1078
+ for (const n of pool) {
1079
+ const key = `${n.filePath}|${n.qualifiedName}`;
1080
+ const group = byDef.get(key);
1081
+ if (group)
1082
+ group.push(n);
1083
+ else
1084
+ byDef.set(key, [n]);
1285
1085
  }
1286
- // Extract the textual body. Defensive: handleTrace's contract is the
1287
- // standard tool-result shape used elsewhere in this file.
1288
- const body = traceResult.content
1289
- ?.map((c) => (c.type === 'text' ? c.text : ''))
1290
- .filter(Boolean)
1291
- .join('\n')
1292
- .trim();
1293
- if (!body)
1294
- return '';
1295
- return [
1296
- '',
1297
- '## Inline flow trace',
1298
- '',
1299
- `Auto-traced \`${fromSym}\` → \`${toSym}\` because the query looks like a flow question. No follow-up codegraph_trace is needed for this pair.`,
1300
- '',
1301
- body,
1302
- ].join('\n');
1086
+ return { groups: [...byDef.values()], filteredOut };
1303
1087
  }
1304
- /**
1305
- * Heuristic to detect if a query looks like a feature request
1306
- */
1307
- looksLikeFeatureRequest(task) {
1308
- const featureKeywords = [
1309
- 'add', 'create', 'implement', 'build', 'enable', 'allow',
1310
- 'new feature', 'support for', 'ability to', 'want to',
1311
- 'should be able', 'need to add', 'swap', 'edit', 'modify'
1312
- ];
1313
- const bugKeywords = [
1314
- 'fix', 'bug', 'error', 'broken', 'crash', 'issue', 'problem',
1315
- 'not working', 'fails', 'undefined', 'null'
1316
- ];
1317
- const explorationKeywords = [
1318
- 'how does', 'where is', 'what is', 'find', 'show me',
1319
- 'explain', 'understand', 'explore'
1320
- ];
1321
- const lowerTask = task.toLowerCase();
1322
- // If it's clearly a bug or exploration, not a feature
1323
- if (bugKeywords.some(k => lowerTask.includes(k)))
1324
- return false;
1325
- if (explorationKeywords.some(k => lowerTask.includes(k)))
1326
- return false;
1327
- // If it matches feature keywords, it's likely a feature request
1328
- return featureKeywords.some(k => lowerTask.includes(k));
1088
+ /** Section heading for one distinct definition in grouped output. */
1089
+ definitionHeading(group) {
1090
+ const head = group[0];
1091
+ const line = head.startLine ? `:${head.startLine}` : '';
1092
+ return `### ${head.qualifiedName} (${head.kind}) — ${head.filePath}${line}`;
1329
1093
  }
1330
1094
  /**
1331
1095
  * Handle codegraph_callers
@@ -1336,26 +1100,64 @@ class ToolHandler {
1336
1100
  return symbol;
1337
1101
  const cg = this.getCodeGraph(args.projectPath);
1338
1102
  const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
1103
+ const fileFilter = typeof args.file === 'string' ? args.file : undefined;
1339
1104
  const allMatches = this.findAllSymbols(cg, symbol);
1340
1105
  if (allMatches.nodes.length === 0) {
1341
1106
  return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1342
1107
  }
1343
- // Aggregate callers across all matching symbols
1344
- const seen = new Set();
1345
- const allCallers = [];
1346
- for (const node of allMatches.nodes) {
1347
- for (const c of cg.getCallers(node.id)) {
1348
- if (!seen.has(c.node.id)) {
1349
- seen.add(c.node.id);
1350
- allCallers.push(c.node);
1108
+ const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
1109
+ const filterNote = filteredOut
1110
+ ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
1111
+ : '';
1112
+ const collect = (defNodes) => {
1113
+ const seen = new Set();
1114
+ const callers = [];
1115
+ const labels = new Map();
1116
+ for (const node of defNodes) {
1117
+ for (const c of cg.getCallers(node.id)) {
1118
+ if (!seen.has(c.node.id)) {
1119
+ seen.add(c.node.id);
1120
+ callers.push(c.node);
1121
+ const label = this.edgeLabel(c.edge);
1122
+ if (label)
1123
+ labels.set(c.node.id, label);
1124
+ }
1351
1125
  }
1352
1126
  }
1127
+ return { callers, labels };
1128
+ };
1129
+ // Single definition (or same-file overloads): the familiar flat list.
1130
+ if (groups.length === 1) {
1131
+ const { callers, labels } = collect(groups[0]);
1132
+ if (callers.length === 0) {
1133
+ return this.textResult(`No callers found for "${symbol}"${allMatches.note}${filterNote}`);
1134
+ }
1135
+ // A successful `file` narrowing makes the multi-symbol aggregation note
1136
+ // stale — suppress it.
1137
+ const note = fileFilter && !filteredOut ? '' : allMatches.note;
1138
+ const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels) + note + filterNote;
1139
+ return this.textResult(this.truncateOutput(formatted));
1140
+ }
1141
+ // Multiple DISTINCT definitions (#764): one section per definition so an
1142
+ // agent never mistakes one app's callers for another's. Narrow with
1143
+ // `file` to focus a single definition.
1144
+ const lines = [
1145
+ `## Callers of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
1146
+ ];
1147
+ for (const group of groups) {
1148
+ const { callers, labels } = collect(group);
1149
+ lines.push('', this.definitionHeading(group));
1150
+ if (callers.length === 0) {
1151
+ lines.push('- (no callers)');
1152
+ continue;
1153
+ }
1154
+ for (const node of callers.slice(0, limit)) {
1155
+ const location = node.startLine ? `:${node.startLine}` : '';
1156
+ const label = labels.get(node.id);
1157
+ lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
1158
+ }
1353
1159
  }
1354
- if (allCallers.length === 0) {
1355
- return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
1356
- }
1357
- const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
1358
- return this.textResult(this.truncateOutput(formatted));
1160
+ return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
1359
1161
  }
1360
1162
  /**
1361
1163
  * Handle codegraph_callees
@@ -1366,26 +1168,61 @@ class ToolHandler {
1366
1168
  return symbol;
1367
1169
  const cg = this.getCodeGraph(args.projectPath);
1368
1170
  const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
1171
+ const fileFilter = typeof args.file === 'string' ? args.file : undefined;
1369
1172
  const allMatches = this.findAllSymbols(cg, symbol);
1370
1173
  if (allMatches.nodes.length === 0) {
1371
1174
  return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1372
1175
  }
1373
- // Aggregate callees across all matching symbols
1374
- const seen = new Set();
1375
- const allCallees = [];
1376
- for (const node of allMatches.nodes) {
1377
- for (const c of cg.getCallees(node.id)) {
1378
- if (!seen.has(c.node.id)) {
1379
- seen.add(c.node.id);
1380
- allCallees.push(c.node);
1176
+ const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
1177
+ const filterNote = filteredOut
1178
+ ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
1179
+ : '';
1180
+ const collect = (defNodes) => {
1181
+ const seen = new Set();
1182
+ const callees = [];
1183
+ const labels = new Map();
1184
+ for (const node of defNodes) {
1185
+ for (const c of cg.getCallees(node.id)) {
1186
+ if (!seen.has(c.node.id)) {
1187
+ seen.add(c.node.id);
1188
+ callees.push(c.node);
1189
+ const label = this.edgeLabel(c.edge);
1190
+ if (label)
1191
+ labels.set(c.node.id, label);
1192
+ }
1381
1193
  }
1382
1194
  }
1195
+ return { callees, labels };
1196
+ };
1197
+ if (groups.length === 1) {
1198
+ const { callees, labels } = collect(groups[0]);
1199
+ if (callees.length === 0) {
1200
+ return this.textResult(`No callees found for "${symbol}"${allMatches.note}${filterNote}`);
1201
+ }
1202
+ // A successful `file` narrowing makes the multi-symbol aggregation note
1203
+ // stale — suppress it.
1204
+ const note = fileFilter && !filteredOut ? '' : allMatches.note;
1205
+ const formatted = this.formatNodeList(callees.slice(0, limit), `Callees of ${symbol}`, labels) + note + filterNote;
1206
+ return this.textResult(this.truncateOutput(formatted));
1207
+ }
1208
+ // Multiple DISTINCT definitions (#764): per-definition sections.
1209
+ const lines = [
1210
+ `## Callees of ${symbol} — ${groups.length} distinct definitions (narrow with \`file\`)`,
1211
+ ];
1212
+ for (const group of groups) {
1213
+ const { callees, labels } = collect(group);
1214
+ lines.push('', this.definitionHeading(group));
1215
+ if (callees.length === 0) {
1216
+ lines.push('- (no callees)');
1217
+ continue;
1218
+ }
1219
+ for (const node of callees.slice(0, limit)) {
1220
+ const location = node.startLine ? `:${node.startLine}` : '';
1221
+ const label = labels.get(node.id);
1222
+ lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
1223
+ }
1383
1224
  }
1384
- if (allCallees.length === 0) {
1385
- return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
1386
- }
1387
- const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
1388
- return this.textResult(this.truncateOutput(formatted));
1225
+ return this.textResult(this.truncateOutput(lines.join('\n') + filterNote));
1389
1226
  }
1390
1227
  /**
1391
1228
  * Handle codegraph_impact
@@ -1396,323 +1233,51 @@ class ToolHandler {
1396
1233
  return symbol;
1397
1234
  const cg = this.getCodeGraph(args.projectPath);
1398
1235
  const depth = (0, utils_1.clamp)(args.depth || 2, 1, 10);
1236
+ const fileFilter = typeof args.file === 'string' ? args.file : undefined;
1399
1237
  const allMatches = this.findAllSymbols(cg, symbol);
1400
1238
  if (allMatches.nodes.length === 0) {
1401
1239
  return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1402
1240
  }
1403
- // Aggregate impact across all matching symbols
1404
- const mergedNodes = new Map();
1405
- const mergedEdges = [];
1406
- const seenEdges = new Set();
1407
- for (const node of allMatches.nodes) {
1408
- const impact = cg.getImpactRadius(node.id, depth);
1409
- for (const [id, n] of impact.nodes) {
1410
- mergedNodes.set(id, n);
1411
- }
1412
- for (const e of impact.edges) {
1413
- const key = `${e.source}->${e.target}:${e.kind}`;
1414
- if (!seenEdges.has(key)) {
1415
- seenEdges.add(key);
1416
- mergedEdges.push(e);
1417
- }
1418
- }
1419
- }
1420
- const mergedImpact = {
1421
- nodes: mergedNodes,
1422
- edges: mergedEdges,
1423
- roots: allMatches.nodes.map(n => n.id),
1424
- };
1425
- const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
1426
- return this.textResult(this.truncateOutput(formatted));
1427
- }
1428
- /**
1429
- * Handle codegraph_trace — shortest CALL PATH between two symbols.
1430
- *
1431
- * Exposes GraphTraverser.findPath: the chain of functions from `from` to `to`,
1432
- * each hop annotated with file:line and the call-site line. This is the
1433
- * capability grep/Read structurally cannot provide. When no static path
1434
- * exists, the chain has almost certainly broken at dynamic dispatch
1435
- * (callbacks, descriptors, metaclasses) — we say so and surface the start
1436
- * symbol's outgoing calls so the agent bridges the one missing hop with
1437
- * codegraph_node rather than blindly reading.
1438
- */
1439
- async handleTrace(args) {
1440
- const from = this.validateString(args.from, 'from');
1441
- if (typeof from !== 'string')
1442
- return from;
1443
- const to = this.validateString(args.to, 'to');
1444
- if (typeof to !== 'string')
1445
- return to;
1446
- const cg = this.getCodeGraph(args.projectPath);
1447
- const fromMatches = this.findAllSymbols(cg, from);
1448
- if (fromMatches.nodes.length === 0)
1449
- return this.textResult(`Symbol "${from}" not found in the codebase`);
1450
- const toMatches = this.findAllSymbols(cg, to);
1451
- if (toMatches.nodes.length === 0)
1452
- return this.textResult(`Symbol "${to}" not found in the codebase`);
1453
- // Trace along call edges only — a true call path. Names can map to several
1454
- // nodes, so try a few from×to candidate pairs until a usable path turns up.
1455
- //
1456
- // MAX_HOPS guard: a BFS shortest path longer than this on a dense call graph
1457
- // is almost always a spurious wander through unrelated code (django's
1458
- // `_fetch_all → … → execute_sql` BFS detours through prefetch/filter), not
1459
- // the real execution flow — and a confident-but-wrong 15-hop trace is worse
1460
- // than none. Over-cap paths are rejected and reported as "no direct path"
1461
- // (which, on real code, means the flow breaks at dynamic dispatch).
1462
- const edgeKinds = ['calls'];
1463
- const MAX_HOPS = 7;
1464
- // Path-proximity pairing: in a multi-module repo a symbol name like
1465
- // `EndBlocker` exists in 20+ modules. FTS picks one almost arbitrarily;
1466
- // the WRONG pair (e.g. simapp's wrapper EndBlocker paired with gov's Tally)
1467
- // has no static path, falls through to the dynamic-dispatch failure branch,
1468
- // and surfaces unrelated bodies — exactly the cosmos-Q3 trace failure mode.
1469
- // Score every from×to combo by shared file-path prefix length; try the
1470
- // most-co-located pair first (e.g. `x/gov/abci.go::EndBlocker` ×
1471
- // `x/gov/keeper/tally.go::Tally` share `x/gov/`).
1472
- //
1473
- // Consider the FULL candidate set, not just the FTS top-5: the right
1474
- // EndBlocker for a gov-module flow may rank 8th in FTS but share the
1475
- // entire `x/gov/` prefix with the destination. Path-proximity supersedes
1476
- // FTS for this disambiguation. Findpath trials are still capped by
1477
- // FINDPATH_PAIR_BUDGET below to bound graph traversal cost.
1478
- const sharedDirPrefixLen = (a, b) => {
1479
- const aDir = a.replace(/[^/]+$/, '');
1480
- const bDir = b.replace(/[^/]+$/, '');
1481
- let i = 0;
1482
- while (i < aDir.length && i < bDir.length && aDir[i] === bDir[i])
1483
- i++;
1484
- return i;
1485
- };
1486
- // Cosmos-Q3 surfaced a second-order failure: `enterprise/group/x/group/`
1487
- // SHARES MORE of its path with `enterprise/group/x/group/keeper/tally.go`
1488
- // (24 chars) than `x/gov/abci.go` shares with `x/gov/keeper/tally.go`
1489
- // (6 chars), so pure shared-prefix prefers the side-experiment module
1490
- // over the canonical one — even though the user's question is clearly
1491
- // about the main gov module. Penalize candidates living under prefixes
1492
- // that conventionally hold extensions / experiments / vendored code, so
1493
- // the canonical-path pair wins even when its shared prefix is short.
1494
- const isLessCanonicalPath = (p) => /^(enterprise|contrib|examples?|sample|playground|vendor|third[_-]?party|deprecated|legacy)\//i.test(p);
1495
- const LESS_CANONICAL_PENALTY = 100; // any canonical candidate beats any less-canonical one
1496
- const scorePair = (a, b) => sharedDirPrefixLen(a, b)
1497
- - (isLessCanonicalPath(a) ? LESS_CANONICAL_PENALTY : 0)
1498
- - (isLessCanonicalPath(b) ? LESS_CANONICAL_PENALTY : 0);
1499
- const fromCands = fromMatches.nodes;
1500
- const toCands = toMatches.nodes;
1501
- // Candidate relevance: an overloaded name (Alamofire has 44 `request`s, most
1502
- // of them EMPTY EventMonitor protocol-conformance stubs `func request(…){}`)
1503
- // floods the pool with no-op decls. Shared-dir-prefix alone then MISLEADS —
1504
- // two unrelated `Source/Features/` delegate stubs outscore the real
1505
- // `Source/Core/Session.request` × `Source/Core/…task` pair the agent meant,
1506
- // so trace resolves to stubs, finds no path, and the agent reads by line.
1507
- // Penalize empty stubs and test-file symbols so a substantive entry point
1508
- // wins; among real methods this is ~flat, so path-proximity still decides
1509
- // (cosmos EndBlocker disambiguation is unaffected — none of its candidates
1510
- // are stubs/tests).
1511
- const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
1512
- const nodeRelevance = (n) => {
1513
- const bodyLines = Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
1514
- let s = Math.min(bodyLines, 20); // a substantive body is more likely the meant symbol
1515
- if (bodyLines <= 1)
1516
- s -= 40; // empty/one-line stub (protocol no-op, decl-only) — almost never the trace endpoint
1517
- if (isTestPath(n.filePath))
1518
- s -= 150; // a Source/ symbol is meant over a Tests/ same-named one
1519
- return s;
1520
- };
1521
- const pairs = [];
1522
- for (const f of fromCands) {
1523
- for (const t of toCands) {
1524
- pairs.push({ f, t, score: scorePair(f.filePath, t.filePath) + nodeRelevance(f) + nodeRelevance(t) });
1525
- }
1526
- }
1527
- // Sort by shared prefix desc, then by FTS order (already encoded in the
1528
- // pairs' insertion order — both for f and t). The tiebreaker preserves
1529
- // findAllSymbols' generated-file-last ranking.
1530
- pairs.sort((a, b) => b.score - a.score);
1531
- // Cap how many graph-path probes we attempt so a 50×50 cross-product
1532
- // doesn't blow up on a god-named symbol like `Get` (well-named flows have
1533
- // their good pair near the top of the sort anyway).
1534
- const FINDPATH_PAIR_BUDGET = 20;
1535
- const fromTry = fromCands;
1536
- const toTry = toCands;
1537
- let path = null;
1538
- let overCap = null;
1539
- let bestPair = null;
1540
- let triedPairs = 0;
1541
- for (const { f, t } of pairs) {
1542
- if (path)
1543
- break;
1544
- if (triedPairs >= FINDPATH_PAIR_BUDGET)
1545
- break;
1546
- triedPairs++;
1547
- const p = cg.findPath(f.id, t.id, edgeKinds);
1548
- if (p && p.length > 1) {
1549
- if (p.length <= MAX_HOPS) {
1550
- path = p;
1551
- bestPair = { f, t };
1552
- break;
1553
- }
1554
- if (!overCap || p.length < overCap.length) {
1555
- overCap = p;
1556
- bestPair = { f, t };
1557
- }
1558
- }
1559
- else if (!bestPair) {
1560
- // No path yet — remember the top-scored pair so the failure branch
1561
- // surfaces the most-co-located candidates' bodies, not whatever FTS
1562
- // happened to put first.
1563
- bestPair = { f, t };
1564
- }
1565
- }
1566
- if (!path) {
1567
- // No static path — almost always a dynamic-dispatch break. INSTEAD of
1568
- // telling the agent to chase the gap with codegraph_node/callers/callees
1569
- // (which fans out into 3-4 follow-up tool calls + a Read), inline the
1570
- // material those would have returned right here. Measured on cosmos-Q3:
1571
- // the failed-trace + subsequent fan-out used to cost ~2× a single
1572
- // sufficient trace call; this branch closes that gap.
1573
- // Prefer the path-proximity-best pair we identified above (e.g. gov's
1574
- // EndBlocker × gov's Tally) over the FTS top-pick (simapp's wrapper).
1575
- const start = bestPair?.f ?? fromTry[0];
1576
- const end = bestPair?.t ?? toTry[0];
1577
- const fileCache = new Map();
1578
- const lines = [
1579
- `No direct static call path from "${from}" to "${to}" — the chain almost certainly breaks at dynamic dispatch (a callback / interface dispatch / framework hook / metaclass). Both endpoint bodies + their immediate neighbors are inlined below; answer from them — a follow-up codegraph_node/callers/callees on these would just return what is already here.`,
1580
- '',
1581
- ];
1582
- if (overCap) {
1583
- lines.push(`> Indirect chain of ${overCap.length} hops exists but is over the ${MAX_HOPS}-hop cap (usually a BFS wander through unrelated code, not the real execution flow).`, '');
1584
- }
1585
- // Track which node IDs we've already inlined a body for so we don't
1586
- // double-emit when a callee of FROM is also surfaced separately.
1587
- const inlinedBodies = new Set();
1588
- const inlineBody = (n, lineCap, charCap) => {
1589
- if (inlinedBodies.has(n.id))
1590
- return false;
1591
- inlinedBodies.add(n.id);
1592
- const body = this.sourceRangeAt(cg, n.filePath, n.startLine, n.endLine, fileCache, lineCap, charCap);
1593
- if (body) {
1594
- lines.push(body);
1595
- return true;
1596
- }
1597
- return false;
1598
- };
1599
- const inlineEndpoint = (label, node) => {
1600
- lines.push(`### ${label}: \`${node.name}\` (${node.filePath}:${node.startLine}-${node.endLine})`);
1601
- inlineBody(node, 120, 3600);
1602
- const callers = cg.getCallers(node.id).slice(0, 6);
1603
- if (callers.length > 0) {
1604
- lines.push(`**Callers of \`${node.name}\`:** ` +
1605
- callers.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`).join(', '));
1606
- }
1607
- const callees = cg.getCallees(node.id).slice(0, 8);
1608
- if (callees.length > 0) {
1609
- lines.push(`**\`${node.name}\` calls:** ` +
1610
- callees.map(c => `${c.node.name} (${c.node.filePath}:${c.node.startLine})`).join(', '));
1611
- }
1612
- lines.push('');
1613
- };
1614
- inlineEndpoint('FROM', start);
1615
- if (end.id !== start.id)
1616
- inlineEndpoint('TO', end);
1617
- // Inline the OTHER top-level functions/methods in TO's file — that's
1618
- // where the missing dynamic-dispatch flow usually lives. Concrete
1619
- // measurement from cosmos-Q1: `msgServer.Send` statically calls only
1620
- // utility functions (`StringToBytes`, `Wrapf`); its real next-hop
1621
- // `SendCoins` is invoked via an embedded-interface call (`k.Keeper.SendCoins`)
1622
- // that static parsing CAN'T see. The flow IS in the same file as the
1623
- // destination (`x/bank/keeper/send.go`: SendCoins → subUnlockedCoins →
1624
- // addCoins → setBalance). Pre-inlining those file-mates is what
1625
- // replaces the agent's "trace fail → search SendCoins → node SendCoins
1626
- // → trace again" fan-out.
1627
- const NEIGHBOR_LINES = 40;
1628
- const NEIGHBOR_CHARS = 1200;
1629
- const NEIGHBOR_K = 5;
1630
- const fileSiblings = (anchor) => {
1631
- // Functions and methods in the same file as the anchor, excluding
1632
- // the anchor itself and anything we've already inlined. Sort by
1633
- // distance from the anchor's startLine so the closest symbols come
1634
- // first (the flow is usually adjacent in the file).
1635
- const sameFile = cg
1636
- .getNodesByKind('function')
1637
- .filter((n) => n.filePath === anchor.filePath)
1638
- .concat(cg.getNodesByKind('method').filter((n) => n.filePath === anchor.filePath));
1639
- return sameFile
1640
- .filter((n) => n.id !== anchor.id && !inlinedBodies.has(n.id))
1641
- .sort((a, b) => Math.abs(a.startLine - anchor.startLine) - Math.abs(b.startLine - anchor.startLine))
1642
- .slice(0, NEIGHBOR_K);
1643
- };
1644
- const renderSiblings = (label, siblings) => {
1645
- if (siblings.length === 0)
1646
- return;
1647
- lines.push(`### ${label}`);
1648
- for (const sib of siblings) {
1649
- lines.push('');
1650
- lines.push(`- \`${sib.name}\` (${sib.filePath}:${sib.startLine}-${sib.endLine})`);
1651
- inlineBody(sib, NEIGHBOR_LINES, NEIGHBOR_CHARS);
1241
+ const { groups, filteredOut } = this.groupDefinitions(allMatches.nodes, fileFilter);
1242
+ const filterNote = filteredOut
1243
+ ? `\n\n> **Note:** no definition of "${symbol}" matches file "${fileFilter}" — showing all definitions instead.`
1244
+ : '';
1245
+ const impactOf = (defNodes) => {
1246
+ const mergedNodes = new Map();
1247
+ const mergedEdges = [];
1248
+ const seenEdges = new Set();
1249
+ for (const node of defNodes) {
1250
+ const impact = cg.getImpactRadius(node.id, depth);
1251
+ for (const [id, n] of impact.nodes) {
1252
+ mergedNodes.set(id, n);
1652
1253
  }
1653
- lines.push('');
1654
- };
1655
- renderSiblings(`Other functions in \`${end.filePath}\` (the flow that the dynamic-dispatch hop reaches — bodies inlined)`, fileSiblings(end));
1656
- lines.push('> Endpoint bodies + the other functions in the destination\'s file are inlined above. Together they typically cover the missing dynamic-dispatch boundary (interface-method calls like `k.Keeper.SendCoins` that static parsing can\'t follow). **No further codegraph_node / codegraph_callers / codegraph_callees / Read / Grep is needed for any symbol already shown here** — call them again only if you need to walk DEEPER than what is inlined.');
1657
- return this.textResult(this.truncateOutput(lines.join('\n') + fromMatches.note + toMatches.note));
1658
- }
1659
- const lines = [
1660
- `## Trace: ${from} → ${to}`,
1661
- '',
1662
- `Full execution path below — ${path.length} hops, each with its body, plus what the destination calls. This is the complete flow; answer from it.`,
1663
- '',
1664
- `${path.length} hops:`,
1665
- '',
1666
- ];
1667
- // Inline what each hop needs so the agent doesn't Read/Grep to get it: the
1668
- // call-site source line, the registration site for dynamic-dispatch hops, AND
1669
- // the hop's own body (capped per hop so the trace stays path-scoped). Earlier
1670
- // versions inlined only the call-site line, which left agents calling explore
1671
- // or Read for the bodies — the exact follow-up the ablation experiment measured.
1672
- const fileCache = new Map();
1673
- for (let i = 0; i < path.length; i++) {
1674
- const step = path[i];
1675
- if (step.edge) {
1676
- const synth = this.synthEdgeNote(step.edge);
1677
- if (synth) {
1678
- lines.push(` ↓ ${synth.label}`);
1679
- if (synth.registeredAt) {
1680
- const regSrc = this.sourceLineAt(cg, synth.registeredAt, fileCache);
1681
- lines.push(` ↳ registered at ${synth.registeredAt}${regSrc ? ` ${regSrc}` : ''}`);
1254
+ for (const e of impact.edges) {
1255
+ const key = `${e.source}->${e.target}:${e.kind}`;
1256
+ if (!seenEdges.has(key)) {
1257
+ seenEdges.add(key);
1258
+ mergedEdges.push(e);
1682
1259
  }
1683
1260
  }
1684
- else {
1685
- // The call happens in the PREVIOUS hop's file at edge.line.
1686
- const prev = path[i - 1];
1687
- const ref = prev && step.edge.line ? `${prev.node.filePath}:${step.edge.line}` : undefined;
1688
- const callSrc = this.sourceLineAt(cg, ref, fileCache);
1689
- lines.push(` ↓ ${step.edge.kind}${step.edge.line ? `@${step.edge.line}` : ''}${callSrc ? ` ${callSrc}` : ''}`);
1690
- }
1691
1261
  }
1692
- lines.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine}-${step.node.endLine})`);
1693
- const body = this.sourceRangeAt(cg, step.node.filePath, step.node.startLine, step.node.endLine, fileCache, 60, 1800);
1694
- if (body)
1695
- lines.push(body);
1696
- }
1697
- // The "last mile": what the destination does next. Agents otherwise explore/Read
1698
- // for exactly this (e.g. renderStaticScene → _renderStaticScene → the canvas draw),
1699
- // so inlining the destination's callees is what actually stops the investigation
1700
- // sufficiency, not a "don't explore" instruction.
1701
- const dest = path[path.length - 1].node;
1702
- const destCallees = cg.getCallees(dest.id)
1703
- .filter(c => !path.some(p => p.node.id === c.node.id))
1704
- .slice(0, 6);
1705
- if (destCallees.length > 0) {
1706
- lines.push('', `### \`${dest.name}\` then calls (the destination's immediate work):`);
1707
- for (const c of destCallees) {
1708
- lines.push('', `- ${c.node.name} (${c.node.filePath}:${c.node.startLine}-${c.node.endLine})`);
1709
- const body = this.sourceRangeAt(cg, c.node.filePath, c.node.startLine, c.node.endLine, fileCache, 16, 600);
1710
- if (body)
1711
- lines.push(body);
1712
- }
1713
- }
1714
- lines.push('', '> Full path + every hop body + the destination\'s calls are inlined above — the complete flow. Answer from it; a Read is only needed to chase a specific local variable\'s data-flow.');
1715
- return this.textResult(this.truncateOutput(lines.join('\n')));
1262
+ return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) };
1263
+ };
1264
+ // Single definition (or same-file overloads): the familiar merged report.
1265
+ if (groups.length === 1) {
1266
+ const formatted = this.formatImpact(symbol, impactOf(groups[0])) + (fileFilter && !filteredOut ? "" : allMatches.note) + filterNote;
1267
+ return this.textResult(this.truncateOutput(formatted));
1268
+ }
1269
+ // Multiple DISTINCT definitions (#764): a blast radius PER definition
1270
+ // merging unrelated same-named classes (one UserService per monorepo app)
1271
+ // overstated impact and confused agents. Narrow with `file`.
1272
+ const sections = [
1273
+ `## Impact of ${symbol} — ${groups.length} distinct definitions (each with its own blast radius; narrow with \`file\`)`,
1274
+ ];
1275
+ for (const group of groups) {
1276
+ const head = group[0];
1277
+ const line = head.startLine ? `:${head.startLine}` : '';
1278
+ sections.push('', this.formatImpact(`${head.qualifiedName} (${head.filePath}${line})`, impactOf(group)));
1279
+ }
1280
+ return this.textResult(this.truncateOutput(sections.join('\n') + filterNote));
1716
1281
  }
1717
1282
  /**
1718
1283
  * Describe a synthesized (dynamic-dispatch) edge for human output: how the
@@ -1783,82 +1348,6 @@ class ToolHandler {
1783
1348
  }
1784
1349
  return null;
1785
1350
  }
1786
- /**
1787
- * Read one trimmed source line at "relpath:line" (relative to the project
1788
- * root). `cache` holds split file contents so a multi-hop trace reads each
1789
- * file at most once. Returns null if the file/line can't be resolved.
1790
- */
1791
- sourceLineAt(cg, ref, cache) {
1792
- if (!ref)
1793
- return null;
1794
- const i = ref.lastIndexOf(':');
1795
- if (i < 0)
1796
- return null;
1797
- const filePath = ref.slice(0, i);
1798
- const line = parseInt(ref.slice(i + 1), 10);
1799
- if (!Number.isFinite(line) || line < 1)
1800
- return null;
1801
- let fileLines = cache.get(filePath);
1802
- if (!fileLines) {
1803
- const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
1804
- if (!abs || !(0, fs_1.existsSync)(abs))
1805
- return null;
1806
- try {
1807
- fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
1808
- }
1809
- catch {
1810
- return null;
1811
- }
1812
- cache.set(filePath, fileLines);
1813
- }
1814
- const raw = fileLines[line - 1];
1815
- if (raw == null)
1816
- return null;
1817
- const t = raw.trim();
1818
- return t.length > 160 ? t.slice(0, 157) + '…' : t;
1819
- }
1820
- /**
1821
- * Read a hop's body — filePath lines [startLine..endLine] — for inlining into
1822
- * a trace, capped (lines + chars) so the whole path stays path-scoped even on
1823
- * a 7-hop chain. Dedents to the body's own indentation and marks truncation.
1824
- * Shares `cache` with sourceLineAt so each file is read at most once per trace.
1825
- */
1826
- sourceRangeAt(cg, filePath, startLine, endLine, cache, maxLines = 28, maxChars = 1200) {
1827
- if (!Number.isFinite(startLine) || startLine < 1)
1828
- return null;
1829
- let fileLines = cache.get(filePath);
1830
- if (!fileLines) {
1831
- const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
1832
- if (!abs || !(0, fs_1.existsSync)(abs))
1833
- return null;
1834
- try {
1835
- fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
1836
- }
1837
- catch {
1838
- return null;
1839
- }
1840
- cache.set(filePath, fileLines);
1841
- }
1842
- const end = Number.isFinite(endLine) && endLine >= startLine ? endLine : startLine;
1843
- let slice = fileLines.slice(startLine - 1, end);
1844
- if (slice.length === 0)
1845
- return null;
1846
- let omitted = 0;
1847
- if (slice.length > maxLines) {
1848
- omitted = slice.length - maxLines;
1849
- slice = slice.slice(0, maxLines);
1850
- }
1851
- const nonBlank = slice.filter(l => l.trim().length > 0);
1852
- const dedent = nonBlank.length ? Math.min(...nonBlank.map(l => l.length - l.trimStart().length)) : 0;
1853
- let text = slice.map((l, i) => ` ${startLine + i}\t${l.slice(dedent)}`).join('\n');
1854
- if (text.length > maxChars) {
1855
- text = text.slice(0, maxChars).replace(/\n[^\n]*$/, '');
1856
- omitted = Math.max(omitted, 1);
1857
- }
1858
- if (omitted > 0)
1859
- text += `\n … (+${omitted} more line${omitted === 1 ? '' : 's'})`;
1860
- return text;
1861
- }
1862
1351
  /**
1863
1352
  * Flow-from-named-symbols: an agent's codegraph_explore query is a bag of
1864
1353
  * symbol names that usually spans the flow it's investigating (e.g.
@@ -1881,7 +1370,7 @@ class ToolHandler {
1881
1370
  // names (Class.method / Class::method) — the agent's most precise input,
1882
1371
  // resolved exactly by findAllSymbols. (The old strip mangled Class.method
1883
1372
  // into Class, throwing the method away.)
1884
- 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)$/i;
1373
+ 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)$/i;
1885
1374
  const tokens = [...new Set(query.split(/[\s,()[\]]+/)
1886
1375
  .map((t) => t.replace(FILE_EXT, '').trim())
1887
1376
  .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
@@ -1902,6 +1391,10 @@ class ToolHandler {
1902
1391
  // (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
1903
1392
  // so naming it doesn't keep every backend variant full and flood the budget.
1904
1393
  const uniqueNamedNodeIds = new Set();
1394
+ // token → resolved node ids: drives the token-coverage check that gates
1395
+ // the dynamic-boundary scan (a token is covered when ANY of its nodes
1396
+ // lands on the main chain — overloads off the chain don't count against).
1397
+ const tokenNodes = new Map();
1905
1398
  for (const t of tokens) {
1906
1399
  const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
1907
1400
  // A qualified or otherwise-specific name (<=3 hits) keeps all; an
@@ -1914,7 +1407,9 @@ class ToolHandler {
1914
1407
  const container = segs.length >= 2 ? segs[segs.length - 2] : '';
1915
1408
  return !!container && segPool.has(container);
1916
1409
  });
1917
- for (const n of pick.slice(0, 6)) {
1410
+ const kept = pick.slice(0, 6);
1411
+ tokenNodes.set(t, kept.map((n) => n.id));
1412
+ for (const n of kept) {
1918
1413
  named.set(n.id, n);
1919
1414
  if (specific)
1920
1415
  uniqueNamedNodeIds.add(n.id);
@@ -1922,8 +1417,19 @@ class ToolHandler {
1922
1417
  if (named.size > 40)
1923
1418
  break;
1924
1419
  }
1925
- if (named.size < 2)
1926
- return EMPTY;
1420
+ if (named.size < 2) {
1421
+ // The agent named a flow but only one side resolved (the other end is
1422
+ // anonymous / runtime-registered / not extracted). The resolved side's
1423
+ // body may still hold the dynamic-dispatch site that EXPLAINS the gap —
1424
+ // surface that instead of silently returning nothing.
1425
+ if (named.size === 0)
1426
+ return EMPTY;
1427
+ const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named);
1428
+ if (!boundaries)
1429
+ return EMPTY;
1430
+ const text = boundaries + '> Full source for these symbols is below.\n';
1431
+ return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
1432
+ }
1927
1433
  const MAX_HOPS = 7;
1928
1434
  let best = null;
1929
1435
  // BFS the full call graph (incl. synth edges) from each named seed, but
@@ -1971,6 +1477,43 @@ class ToolHandler {
1971
1477
  }
1972
1478
  const hasMain = !!best && best.length >= 3;
1973
1479
  const pathIds = new Set((best ?? []).map((s) => s.node.id));
1480
+ // Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
1481
+ // asked about did not fully connect: some token resolved to nodes but
1482
+ // none of them sit on the main chain (or there is no chain at all). A
1483
+ // healthy flow skips this entirely. Scan order: the chain's dead end
1484
+ // first (where the partial flow stops), then the disconnected symbols,
1485
+ // agent-specific (unique-named) ones first.
1486
+ let boundaryText = '';
1487
+ {
1488
+ const uncovered = [];
1489
+ if (!hasMain) {
1490
+ // No rendered chain — but a 2-node chain still CONNECTS its two
1491
+ // endpoints (e.g. via one synthesized hop, surfaced below as a
1492
+ // dynamic-dispatch link). Only nodes off that short chain are
1493
+ // unexplained breaks worth scanning.
1494
+ for (const n of named.values())
1495
+ if (!pathIds.has(n.id))
1496
+ uncovered.push(n);
1497
+ }
1498
+ else {
1499
+ for (const ids of tokenNodes.values()) {
1500
+ if (ids.length === 0 || ids.some((id) => pathIds.has(id)))
1501
+ continue;
1502
+ for (const id of ids) {
1503
+ const n = named.get(id);
1504
+ if (n)
1505
+ uncovered.push(n);
1506
+ }
1507
+ }
1508
+ }
1509
+ if (uncovered.length > 0) {
1510
+ const scanList = [];
1511
+ if (hasMain)
1512
+ scanList.push(best[best.length - 1].node);
1513
+ scanList.push(...uncovered.sort((a, b) => (uniqueNamedNodeIds.has(b.id) ? 1 : 0) - (uniqueNamedNodeIds.has(a.id) ? 1 : 0)));
1514
+ boundaryText = this.buildDynamicBoundaries(cg, scanList, named);
1515
+ }
1516
+ }
1974
1517
  // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
1975
1518
  // symbol — the indirect hops an agent would otherwise grep/Read to
1976
1519
  // reconstruct ("where do the appended `validators` actually run?"). The
@@ -1989,8 +1532,13 @@ class ToolHandler {
1989
1532
  break;
1990
1533
  if (edge.provenance !== 'heuristic' || other.id === n.id)
1991
1534
  continue;
1992
- if (pathIds.has(edge.source) && pathIds.has(edge.target))
1993
- continue; // already in the main chain
1535
+ // "Already in the main chain" only applies when a chain RENDERS
1536
+ // (hasMain). A 2-node chain populates pathIds but renders nothing,
1537
+ // so a direct synthesized hop between two named symbols (custom
1538
+ // EventBus emit→handler, #687) was invisible — too short for Flow,
1539
+ // skipped here as in-chain. Surface it.
1540
+ if (hasMain && pathIds.has(edge.source) && pathIds.has(edge.target))
1541
+ continue;
1994
1542
  const src = edge.source === n.id ? n : other;
1995
1543
  const tgt = edge.source === n.id ? other : n;
1996
1544
  const key = `${src.name}>${tgt.name}`;
@@ -2001,7 +1549,7 @@ class ToolHandler {
2001
1549
  synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
2002
1550
  }
2003
1551
  }
2004
- if (!hasMain && synthLines.length === 0)
1552
+ if (!hasMain && synthLines.length === 0 && !boundaryText)
2005
1553
  return EMPTY;
2006
1554
  const out = [];
2007
1555
  if (hasMain) {
@@ -2019,7 +1567,9 @@ class ToolHandler {
2019
1567
  if (synthLines.length) {
2020
1568
  out.push('## Dynamic-dispatch links among your symbols', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
2021
1569
  }
2022
- out.push('> Full source for these symbols is below; codegraph_trace(from,to) for the exact path between two endpoints.', '');
1570
+ if (boundaryText)
1571
+ out.push(boundaryText);
1572
+ out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
2023
1573
  // namedNodeIds = every callable the agent explicitly named (a superset of
2024
1574
  // the spine). A file holding one is something the agent asked to SEE, so it
2025
1575
  // must keep full source even if it's an off-spine polymorphic sibling — the
@@ -2031,6 +1581,304 @@ class ToolHandler {
2031
1581
  return EMPTY;
2032
1582
  }
2033
1583
  }
1584
+ /**
1585
+ * Dynamic-boundary surfacing (#687): when the flow among the agent's named
1586
+ * symbols does not fully connect, scan the disconnected symbols' bodies for
1587
+ * dynamic-dispatch sites (computed member calls, getattr, reflection, typed
1588
+ * message buses, runtime-keyed emits) and ANNOUNCE the boundary — the exact
1589
+ * site, the form, and (when a key is statically visible) candidate targets —
1590
+ * instead of guessing edges. The answer to "how does A reach B" when no
1591
+ * static path exists IS the dispatch site: that's where the flow continues
1592
+ * at runtime. Query-time, deterministic, zero graph mutation; a fully
1593
+ * connected flow never reaches this method.
1594
+ */
1595
+ buildDynamicBoundaries(cg, scanList, named) {
1596
+ const MAX_NOTES = 4; // boundary bullets per explore
1597
+ const MAX_SCAN = 8; // bodies scanned
1598
+ const MAX_TOTAL_CHARS = 200_000;
1599
+ let projectRoot;
1600
+ try {
1601
+ projectRoot = cg.getProjectRoot();
1602
+ }
1603
+ catch {
1604
+ return '';
1605
+ }
1606
+ const notes = [];
1607
+ const seenNode = new Set();
1608
+ const seenSite = new Set();
1609
+ let scanned = 0, charsScanned = 0;
1610
+ for (const node of scanList) {
1611
+ if (notes.length >= MAX_NOTES || scanned >= MAX_SCAN || charsScanned > MAX_TOTAL_CHARS)
1612
+ break;
1613
+ if (seenNode.has(node.id) || !node.startLine || !node.endLine)
1614
+ continue;
1615
+ seenNode.add(node.id);
1616
+ const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, node.filePath);
1617
+ if (!absPath || !(0, fs_1.existsSync)(absPath))
1618
+ continue;
1619
+ let content;
1620
+ try {
1621
+ content = (0, fs_1.readFileSync)(absPath, 'utf-8');
1622
+ }
1623
+ catch {
1624
+ continue;
1625
+ }
1626
+ const body = content.split('\n').slice(node.startLine - 1, node.endLine).join('\n');
1627
+ scanned++;
1628
+ charsScanned += body.length;
1629
+ for (const m of (0, dynamic_boundaries_1.scanDynamicDispatch)(body, node.language || '', node.startLine)) {
1630
+ if (notes.length >= MAX_NOTES)
1631
+ break;
1632
+ const siteKey = `${node.filePath}:${m.line}:${m.form}`;
1633
+ if (seenSite.has(siteKey))
1634
+ continue;
1635
+ seenSite.add(siteKey);
1636
+ const more = m.moreSites ? ` (+${m.moreSites} more such site${m.moreSites > 1 ? 's' : ''} in this body)` : '';
1637
+ notes.push(`- \`${node.name}\` (${node.filePath}:${m.line}) — ${m.label}: \`${m.snippet}\`${more}`);
1638
+ if (m.key) {
1639
+ const cand = this.boundaryCandidates(cg, m.key, !!m.keyIsType, named, node.id);
1640
+ if (cand)
1641
+ notes.push(` ${cand}`);
1642
+ }
1643
+ }
1644
+ }
1645
+ if (notes.length === 0)
1646
+ return '';
1647
+ return [
1648
+ '## Dynamic boundaries (the static path ends at runtime dispatch)',
1649
+ '',
1650
+ ...notes,
1651
+ '',
1652
+ '> 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.',
1653
+ '',
1654
+ ].join('\n');
1655
+ }
1656
+ /**
1657
+ * Shortlist candidate runtime targets for a dispatch key surfaced by
1658
+ * {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
1659
+ * `onSave`/`handleSave`; `CreateCmd` → `CreateCmdHandler`), then FTS, with a
1660
+ * normalized-containment post-filter (FTS camel-splitting is fuzzier than a
1661
+ * candidate list should be). Symbols the agent already named sort first and
1662
+ * are marked — that's the "you were right, here's the wiring" case.
1663
+ */
1664
+ boundaryCandidates(cg, key, keyIsType, named, selfId) {
1665
+ const CALLABLE = new Set(['method', 'function', 'component', 'constructor', 'class']);
1666
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
1667
+ const keyNorm = norm(key);
1668
+ if (keyNorm.length < 3)
1669
+ return '';
1670
+ const cands = new Map();
1671
+ const consider = (n) => {
1672
+ if (!n || n.id === selfId || !CALLABLE.has(n.kind) || cands.has(n.id))
1673
+ return;
1674
+ const nameNorm = norm(n.name || '');
1675
+ if (nameNorm.length < 3)
1676
+ return;
1677
+ if (!nameNorm.includes(keyNorm) && !keyNorm.includes(nameNorm))
1678
+ return;
1679
+ cands.set(n.id, n);
1680
+ };
1681
+ const cap = key.charAt(0).toUpperCase() + key.slice(1);
1682
+ const probes = keyIsType
1683
+ ? [`${key}Handler`, key]
1684
+ : [key, `on${cap}`, `handle${cap}`, `${key}Handler`, `handle_${key}`];
1685
+ for (const p of probes) {
1686
+ try {
1687
+ for (const n of cg.getNodesByName(p))
1688
+ consider(n);
1689
+ }
1690
+ catch { /* exact probe miss is fine */ }
1691
+ }
1692
+ let raw = 0;
1693
+ try {
1694
+ const results = cg.searchNodes(key, { limit: 12 });
1695
+ raw = results.length;
1696
+ for (const r of results)
1697
+ consider(r.node);
1698
+ }
1699
+ catch { /* FTS syntax edge — exact probes already ran */ }
1700
+ if (cands.size === 0) {
1701
+ return raw >= 12 && key.length < 5 ? `key \`${key}\` is too generic to shortlist (${raw}+ matches)` : '';
1702
+ }
1703
+ // A constructor candidate duplicates its class: extractors emit ctors as
1704
+ // METHOD nodes named like the class (C#/Java `Foo::Foo`) — keep the class.
1705
+ const all = [...cands.values()];
1706
+ const classKey = new Set(all.filter((n) => n.kind === 'class').map((n) => `${n.name}|${n.filePath}`));
1707
+ const namedNames = new Set([...named.values()].map((n) => n.name));
1708
+ 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
1709
+ const list = all
1710
+ .filter((n) => !(n.kind !== 'class' && classKey.has(`${n.name}|${n.filePath}`)))
1711
+ .sort((a, b) => (isNamed(b) ? 1 : 0) - (isNamed(a) ? 1 : 0))
1712
+ .slice(0, 4)
1713
+ .map((n) => {
1714
+ // Typed-bus convention: the runtime target is the candidate class's
1715
+ // Handle/Execute/Consume method — name the exact node, not just the class.
1716
+ let display = n.qualifiedName || n.name;
1717
+ let at = `${n.filePath}:${n.startLine}`;
1718
+ if (keyIsType && n.kind === 'class') {
1719
+ try {
1720
+ const HANDLER_METHODS = /^(handle|handleAsync|execute|executeAsync|consume|consumeAsync|run|__invoke)$/i;
1721
+ const method = cg.getOutgoingEdges(n.id)
1722
+ .filter((e) => e.kind === 'contains')
1723
+ .map((e) => { try {
1724
+ return cg.getNode(e.target);
1725
+ }
1726
+ catch {
1727
+ return null;
1728
+ } })
1729
+ .find((c) => !!c && c.kind === 'method' && HANDLER_METHODS.test(c.name));
1730
+ if (method) {
1731
+ display = `${n.name}.${method.name}`;
1732
+ at = `${method.filePath}:${method.startLine}`;
1733
+ }
1734
+ }
1735
+ catch { /* class without resolvable members — show the class itself */ }
1736
+ }
1737
+ return `\`${display}\` (${at})${isNamed(n) ? ' ← you named this' : ''}`;
1738
+ });
1739
+ return `candidates for key \`${key}\`: ${list.join(', ')}`;
1740
+ }
1741
+ /**
1742
+ * Compact "blast radius" for the entry symbols of an explore result: who
1743
+ * depends on each (callers) and which test files cover it — LOCATIONS ONLY,
1744
+ * no source, so the agent knows what to update / re-verify before editing
1745
+ * without reaching for a separate impact call. Always-on, but skips symbols
1746
+ * that have no dependents (nothing to warn about), and returns '' when none
1747
+ * qualify so a leaf-only exploration stays clean.
1748
+ */
1749
+ buildBlastRadiusSection(cg, subgraph) {
1750
+ const ROOT_CAP = 5; // only the symbols the query actually targeted
1751
+ const FILE_CAP = 4; // caller files listed per symbol before "+N more"
1752
+ const MEANINGFUL = new Set([
1753
+ 'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol',
1754
+ 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field',
1755
+ ]);
1756
+ const rel = (p) => p.replace(/\\/g, '/');
1757
+ const roots = subgraph.roots
1758
+ .map((id) => subgraph.nodes.get(id))
1759
+ .filter((n) => !!n && MEANINGFUL.has(n.kind))
1760
+ .slice(0, ROOT_CAP);
1761
+ if (roots.length === 0)
1762
+ return '';
1763
+ const entries = [];
1764
+ for (const root of roots) {
1765
+ let callers = [];
1766
+ try {
1767
+ callers = cg.getCallers(root.id);
1768
+ }
1769
+ catch { /* skip this root */ }
1770
+ const seen = new Set();
1771
+ const uniq = [];
1772
+ for (const c of callers) {
1773
+ if (c?.node && !seen.has(c.node.id)) {
1774
+ seen.add(c.node.id);
1775
+ uniq.push(c.node);
1776
+ }
1777
+ }
1778
+ if (uniq.length === 0)
1779
+ continue; // no blast radius → nothing to flag
1780
+ const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
1781
+ const testFiles = callerFiles.filter((f) => (0, query_utils_1.isTestFile)(f));
1782
+ const nonTest = callerFiles.filter((f) => !(0, query_utils_1.isTestFile)(f));
1783
+ const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
1784
+ const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
1785
+ const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
1786
+ const tests = testFiles.length > 0
1787
+ ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
1788
+ : '; ⚠️ no covering tests found';
1789
+ entries.push(`- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`);
1790
+ }
1791
+ if (entries.length === 0)
1792
+ return '';
1793
+ return [
1794
+ '### Blast radius — what depends on these (update/verify before editing)',
1795
+ '',
1796
+ ...entries,
1797
+ '',
1798
+ ].join('\n');
1799
+ }
1800
+ /**
1801
+ * Graph-connectivity relevance via Random-Walk-with-Restart (personalized
1802
+ * PageRank) from the query's matched SEED nodes over the call/reference graph.
1803
+ *
1804
+ * This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
1805
+ * codegraph's home turf: relevance by STRUCTURE, not words. A file whose
1806
+ * symbols are call-connected to the matched cluster accrues walk mass and
1807
+ * ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
1808
+ * "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
1809
+ * — gets only its own restart probability and ranks ~0. Immune to the
1810
+ * tokenization trap that fools term matching, deterministic, no embeddings.
1811
+ *
1812
+ * Undirected adjacency (reachability both ways), restart α=0.25 to the seeds,
1813
+ * power iteration to convergence. Bounded to the already-relevant subgraph, so
1814
+ * it's a few hundred nodes × ~25 iterations — negligible cost.
1815
+ */
1816
+ computeGraphRelevance(nodeIds, edges, seedIds) {
1817
+ const out = new Map();
1818
+ const n = nodeIds.length;
1819
+ if (n === 0)
1820
+ return out;
1821
+ const idx = new Map();
1822
+ for (let i = 0; i < n; i++)
1823
+ idx.set(nodeIds[i], i);
1824
+ const RANK_EDGES = new Set([
1825
+ 'calls', 'references', 'extends', 'implements', 'overrides',
1826
+ 'instantiates', 'returns', 'type_of', 'imports',
1827
+ ]);
1828
+ const adj = Array.from({ length: n }, () => []);
1829
+ for (const e of edges) {
1830
+ if (!RANK_EDGES.has(e.kind))
1831
+ continue;
1832
+ const i = idx.get(e.source);
1833
+ const j = idx.get(e.target);
1834
+ if (i === undefined || j === undefined || i === j)
1835
+ continue;
1836
+ adj[i].push(j);
1837
+ adj[j].push(i); // undirected — reachable either direction
1838
+ }
1839
+ // Restart vector: uniform over seeds present in the candidate set. (Falls
1840
+ // back to uniform-over-all if no seed landed in the set, so we never return
1841
+ // all-zero.)
1842
+ const r = new Array(n).fill(0);
1843
+ let rsum = 0;
1844
+ for (const id of seedIds) {
1845
+ const i = idx.get(id);
1846
+ if (i !== undefined) {
1847
+ r[i] = 1;
1848
+ rsum += 1;
1849
+ }
1850
+ }
1851
+ if (rsum === 0) {
1852
+ for (let i = 0; i < n; i++)
1853
+ r[i] = 1;
1854
+ rsum = n;
1855
+ }
1856
+ for (let i = 0; i < n; i++)
1857
+ r[i] /= rsum;
1858
+ const alpha = 0.25;
1859
+ let s = r.slice();
1860
+ for (let iter = 0; iter < 25; iter++) {
1861
+ const next = new Array(n).fill(0);
1862
+ for (let i = 0; i < n; i++) {
1863
+ const si = s[i];
1864
+ if (si === 0)
1865
+ continue;
1866
+ const d = adj[i].length;
1867
+ if (d === 0) {
1868
+ next[i] += si;
1869
+ continue;
1870
+ } // dangling: keep its mass
1871
+ const share = si / d;
1872
+ for (const j of adj[i])
1873
+ next[j] += share;
1874
+ }
1875
+ for (let i = 0; i < n; i++)
1876
+ s[i] = (1 - alpha) * next[i] + alpha * r[i];
1877
+ }
1878
+ for (let i = 0; i < n; i++)
1879
+ out.set(nodeIds[i], s[i]);
1880
+ return out;
1881
+ }
2034
1882
  /**
2035
1883
  * Handle codegraph_explore — deep exploration in a single call
2036
1884
  *
@@ -2120,25 +1968,61 @@ class ToolHandler {
2120
1968
  // agent explicitly named is in the subgraph and its file is scored.
2121
1969
  const namedSeedIds = new Set();
2122
1970
  {
2123
- 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)$/i;
1971
+ 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)$/i;
2124
1972
  const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
2125
1973
  const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
2126
1974
  const bodyLines = (n) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
2127
1975
  const tokens = [...new Set(query.split(/[\s,()[\]]+/)
2128
1976
  .map((t) => t.replace(FILE_EXT, '').trim())
2129
1977
  .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
1978
+ // PascalCase tokens in the query are type/file disambiguators — when the
1979
+ // agent writes "DataRequest task validate", the `task`/`validate` it wants
1980
+ // are DataRequest's, NOT the same-named overloads in Validation.swift /
1981
+ // Concurrency.swift / the abstract base. Used below to bias overloaded
1982
+ // names toward the file/class the query also names. EXCLUDE the project
1983
+ // name (a PascalCase token a user naturally includes) — it names the whole
1984
+ // repo, so biasing toward it just pulls overloads to whichever stack
1985
+ // embeds it, re-burying the rest (#720).
1986
+ const projectNameTokens = cg.getProjectNameTokens();
1987
+ const typeTokens = tokens.filter((o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o) && !projectNameTokens.has((0, query_utils_1.normalizeNameToken)(o)));
1988
+ const inNamedContext = (n) => typeTokens.some((ct) => {
1989
+ const lc = ct.toLowerCase();
1990
+ return n.filePath.toLowerCase().includes(lc) || n.qualifiedName.toLowerCase().includes(lc);
1991
+ });
2130
1992
  for (const t of tokens) {
2131
- const cands = this.findAllSymbols(cg, t).nodes
1993
+ // Enumerate ALL defs of a bare token via the direct index, not FTS — a
1994
+ // 50+-overload name (tokio `poll`) ranks the wanted def (`Harness::poll`)
1995
+ // below the FTS cut, so findAllSymbols would never see it and the
1996
+ // type-token bias below couldn't pick the harness.rs one. (Same fix as
1997
+ // codegraph_node's findSymbolMatches.) Qualified tokens keep findAllSymbols.
1998
+ const isQual = /[.\/]|::/.test(t);
1999
+ const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
2000
+ const cands = raw
2132
2001
  .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
2133
2002
  .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
2134
- // A specific name (<=3 defs) injects all its defs; an overloaded name
2135
- // (`request` = 44, mostly stubs) injects only the single most substantive
2136
- // one, so the build-overload flood doesn't crowd the subgraph.
2137
- for (const n of cands.slice(0, cands.length <= 3 ? cands.length : 1)) {
2138
- if (!subgraph.nodes.has(n.id)) {
2003
+ // A specific name (<=3 defs) injects all its defs. An overloaded name
2004
+ // (`validate` = 10, `request` = 44) would flood the subgraph, so inject
2005
+ // only: the overloads whose file/class the query ALSO names (the agent
2006
+ // told us which one it wants DataRequest's, not Validation.swift's),
2007
+ // capped; else fall back to the single most-substantive def. This is the
2008
+ // explore-side mirror of codegraph_node's overload disambiguation.
2009
+ let picks;
2010
+ if (cands.length <= 3) {
2011
+ picks = cands;
2012
+ }
2013
+ else {
2014
+ const ctx = cands.filter(inNamedContext);
2015
+ picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
2016
+ }
2017
+ for (const n of picks) {
2018
+ if (!subgraph.nodes.has(n.id))
2139
2019
  subgraph.nodes.set(n.id, n);
2140
- namedSeedIds.add(n.id);
2141
- }
2020
+ // Mark as a named seed EVEN IF the FTS gather already had it — being
2021
+ // "named by the agent" is independent of whether search happened to
2022
+ // surface it, and it drives the +50 score, the gate, and the
2023
+ // named-file sort below. (Previously only NEW injections were marked,
2024
+ // so a named symbol FTS already gathered never sorted to the top.)
2025
+ namedSeedIds.add(n.id);
2142
2026
  }
2143
2027
  }
2144
2028
  }
@@ -2157,6 +2041,12 @@ class ToolHandler {
2157
2041
  // Skip import/export nodes — they add noise without information
2158
2042
  if (node.kind === 'import' || node.kind === 'export')
2159
2043
  continue;
2044
+ // SECURITY (#383): never render the on-disk source of a config-leaf
2045
+ // (Spring application.{yml,properties} key) — its line is `key = <secret>`,
2046
+ // so whole-file/cluster rendering here would push secrets into context
2047
+ // unbidden. The key still appears in the flow/symbol listing above.
2048
+ if ((0, utils_1.isConfigLeafNode)(node))
2049
+ continue;
2160
2050
  const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
2161
2051
  group.nodes.push(node);
2162
2052
  // Score: a NAMED-SEED node (a symbol the agent named that FTS missed, now
@@ -2218,21 +2108,107 @@ class ToolHandler {
2218
2108
  }
2219
2109
  }
2220
2110
  }
2221
- // Sort files: highest relevance first, deprioritize low-value files
2111
+ // Secondary signal: how many DISTINCT query terms each file matches (path +
2112
+ // symbol names). Kept only as a tiebreak — the PRIMARY relevance is graph
2113
+ // connectivity below. (Term counting alone tied the real central file with
2114
+ // incidental same-word matches; it's a weak text signal, not the ranker.)
2115
+ const uniqueQueryTerms = [...new Set(queryTerms)].filter(t => t.length >= 3);
2116
+ const fileTermHits = new Map();
2117
+ for (const [fp, group] of relevantFiles) {
2118
+ const hay = fp.toLowerCase() + ' ' + group.nodes.map(n => n.name.toLowerCase()).join(' ');
2119
+ let hits = 0;
2120
+ for (const t of uniqueQueryTerms)
2121
+ if (hay.includes(t))
2122
+ hits++;
2123
+ fileTermHits.set(fp, hits);
2124
+ }
2125
+ // PRIMARY relevance: graph connectivity (Random-Walk-with-Restart from the
2126
+ // matched seeds — see computeGraphRelevance). Aggregate each file's nodes'
2127
+ // walk mass. This is the signal text search lacks: the real cluster
2128
+ // (org-user.storage.ts, call-connected to the matches) accrues mass; a lone
2129
+ // text match (LensSwitcher.swift, matched "switch" but calls nothing in the
2130
+ // flow) gets only its restart probability → ~0, and is dropped by the gate.
2131
+ const nodeRwr = this.computeGraphRelevance([...subgraph.nodes.keys()], subgraph.edges, entryNodeIds);
2132
+ const fileGraphScore = new Map();
2133
+ for (const node of subgraph.nodes.values()) {
2134
+ fileGraphScore.set(node.filePath, (fileGraphScore.get(node.filePath) ?? 0) + (nodeRwr.get(node.id) ?? 0));
2135
+ }
2136
+ const maxGraph = Math.max(0, ...fileGraphScore.values());
2137
+ // Central file(s): the 1-2 most graph-central files that also match the
2138
+ // query textually (so a connected hub-utility with no term match isn't
2139
+ // mistaken for the subject). The heart of the answer — they earn the larger
2140
+ // WHOLE-FILE ceiling below (a god-file central file still exceeds it and
2141
+ // falls to generous full-method sectioning — never a whole dump).
2142
+ const centralFiles = new Set([...fileGraphScore.entries()]
2143
+ .filter(([fp, g]) => g > 0 && (fileTermHits.get(fp) ?? 0) >= 1)
2144
+ .sort((a, b) => b[1] - a[1] || (fileTermHits.get(b[0]) ?? 0) - (fileTermHits.get(a[0]) ?? 0))
2145
+ .slice(0, 2)
2146
+ .map(([f]) => f));
2147
+ // Files that DEFINE a symbol the agent named (or a subgraph root). These are
2148
+ // the highest-relevance files there are — the agent asked for them by name —
2149
+ // so the connectivity gate below must never drop them, even when their RWR
2150
+ // mass is low (a leaf family file like codec.ts is call-connected to little
2151
+ // but is exactly what the agent queried). Without this protection the gate
2152
+ // prunes a named file and the agent Reads it back.
2153
+ const entryFiles = new Set();
2154
+ for (const id of entryNodeIds) {
2155
+ const n = subgraph.nodes.get(id);
2156
+ if (n)
2157
+ entryFiles.add(n.filePath);
2158
+ }
2159
+ // Relevance gate (so the generous budget is a CEILING, not a target): keep a
2160
+ // file only if it is STRUCTURALLY relevant by ANY of:
2161
+ // - graph score within a fraction of the top (it's on/near the flow), OR
2162
+ // - central (a query entry-point lives here), OR
2163
+ // - it DEFINES a symbol the agent named (entryFiles), OR
2164
+ // - it matches >= 2 DISTINCT named query terms — a strong text signal that
2165
+ // the agent is asking about this file even when nothing calls it (codec.ts:
2166
+ // the agent named `encode`/`Codec`/`JsonCodec`, all leaf classes with zero
2167
+ // RWR mass — graph alone wrongly drops it).
2168
+ // A lone text match on one shared word (LensSwitcher: term=1, g~0) is still
2169
+ // dropped, so the budget never fills with incidental files. Guarded so it
2170
+ // never prunes below 2.
2171
+ if (maxGraph > 0) {
2172
+ const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
2173
+ || centralFiles.has(fp)
2174
+ || entryFiles.has(fp)
2175
+ || (fileTermHits.get(fp) ?? 0) >= 2);
2176
+ if (gated.length >= 2)
2177
+ relevantFiles = gated;
2178
+ }
2179
+ // Sort files: graph-central first, then distinct-term match, then the
2180
+ // existing low-value/generated/score tiebreaks.
2181
+ // Files that DEFINE a symbol the agent NAMED. These sort first — ahead of
2182
+ // graph connectivity — because the agent asked for them by name. Without
2183
+ // this, a named leaf override reached only by dynamic dispatch (Alamofire's
2184
+ // `DataRequest.task`/`validate`, low RWR mass) sorts below the high-
2185
+ // connectivity abstract base (`Request.swift`) and the same-named overloads
2186
+ // in other files (`Validation.swift`), falls outside the budget, and the
2187
+ // agent Reads it. The named file is the answer — rank it at the top.
2188
+ const namedSeedFiles = new Set();
2189
+ for (const id of namedSeedIds) {
2190
+ const n = subgraph.nodes.get(id);
2191
+ if (n)
2192
+ namedSeedFiles.add(n.filePath);
2193
+ }
2222
2194
  const sortedFiles = relevantFiles.sort((a, b) => {
2223
2195
  const aPath = a[0].toLowerCase();
2224
2196
  const bPath = b[0].toLowerCase();
2225
- // Check if any node name or file path relates to query terms
2226
- const hasQueryRelevance = (filePath, nodes) => {
2227
- const fp = filePath.toLowerCase();
2228
- if (queryTerms.some(t => fp.includes(t)))
2229
- return true;
2230
- return nodes.some(n => queryTerms.some(t => n.name.toLowerCase().includes(t)));
2231
- };
2232
- const aRelevant = hasQueryRelevance(aPath, a[1].nodes);
2233
- const bRelevant = hasQueryRelevance(bPath, b[1].nodes);
2234
- if (aRelevant !== bRelevant)
2235
- return aRelevant ? -1 : 1;
2197
+ // Agent-named files first (it asked for a symbol defined here by name).
2198
+ const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
2199
+ const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
2200
+ if (aNamed !== bNamed)
2201
+ return bNamed - aNamed;
2202
+ // Graph connectivity is the next key (small epsilon so near-ties fall
2203
+ // through to the text signal rather than coin-flipping on float noise).
2204
+ const aG = fileGraphScore.get(a[0]) ?? 0;
2205
+ const bG = fileGraphScore.get(b[0]) ?? 0;
2206
+ if (Math.abs(aG - bG) > maxGraph * 0.01)
2207
+ return bG - aG;
2208
+ const aHits = fileTermHits.get(a[0]) ?? 0;
2209
+ const bHits = fileTermHits.get(b[0]) ?? 0;
2210
+ if (aHits !== bHits)
2211
+ return bHits - aHits;
2236
2212
  const aLow = isLowValue(aPath);
2237
2213
  const bLow = isLowValue(bPath);
2238
2214
  if (aLow !== bLow)
@@ -2258,6 +2234,12 @@ class ToolHandler {
2258
2234
  `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
2259
2235
  '',
2260
2236
  ];
2237
+ // Blast radius (always-on, compact): for the entry symbols, who depends on
2238
+ // them + which tests cover them — locations only, no source — so the agent
2239
+ // knows what to update/verify before editing without a separate call.
2240
+ const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
2241
+ if (blastRadius)
2242
+ lines.push(blastRadius);
2261
2243
  // Relationship map — show how symbols connect
2262
2244
  const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
2263
2245
  );
@@ -2412,7 +2394,7 @@ class ToolHandler {
2412
2394
  // so leave it to the normal full render.
2413
2395
  const namedBodyChars = group.nodes
2414
2396
  .filter(n => CALLABLE_BODY.has(n.kind) && (flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)))
2415
- .reduce((s, n) => s + fileLines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 220)).join('\n').length, 0);
2397
+ .reduce((s, n) => s + fileLines.slice(n.startLine - 1, n.endLine).join('\n').length, 0);
2416
2398
  const onSpineGodFile = hasSpineNode
2417
2399
  && namedBodyChars > budget.maxCharsPerFile
2418
2400
  && group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
@@ -2432,14 +2414,19 @@ class ToolHandler {
2432
2414
  : flow.pathNodeIds.has(n.id) ? 0
2433
2415
  : flow.uniqueNamedNodeIds.has(n.id) ? 1
2434
2416
  : (fileDefinesSuper && flow.namedNodeIds.has(n.id)) ? 2 : 99;
2435
- const bodyCap = budget.maxCharsPerFile * 2;
2417
+ // One ~250-line WINDOW per file. syms are taken by priority (spine first,
2418
+ // then uniquely-named, then family-base), and the cap applies to ALL of
2419
+ // them — including the spine — so a big-spine god-file (tokio's worker.rs:
2420
+ // run→run_task→next_task→steal_work) can't eat the whole response and
2421
+ // starve the co-flow file (harness.rs's poll). The native agent windows
2422
+ // such a file too (~190 lines at a time), so this mimics, not truncates.
2423
+ // Always emit ≥1 (never an empty section).
2424
+ const bodyCap = budget.maxCharsPerFile * 1.5;
2436
2425
  const bodyIds = new Set();
2437
2426
  let bodyChars = 0;
2438
2427
  for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
2439
- const sz = fileLines.slice(n.startLine - 1, Math.min(n.endLine, n.startLine + 220)).join('\n').length;
2440
- // Spine methods (prio 0) ALWAYS get a full body the cap governs the
2441
- // off-path extras (unique-named, family base), never the flow path itself.
2442
- if (prio(n) > 0 && bodyChars + sz > bodyCap && bodyIds.size > 0)
2428
+ const sz = fileLines.slice(n.startLine - 1, n.endLine).join('\n').length;
2429
+ if (bodyChars + sz > bodyCap && bodyIds.size > 0)
2443
2430
  continue;
2444
2431
  bodyIds.add(n.id);
2445
2432
  bodyChars += sz;
@@ -2455,7 +2442,7 @@ class ToolHandler {
2455
2442
  if (n.startLine <= coveredUntil)
2456
2443
  continue;
2457
2444
  if (bodyIds.has(n.id)) {
2458
- const end = Math.min(n.endLine, n.startLine + 220);
2445
+ const end = n.endLine;
2459
2446
  const body = fileLines.slice(n.startLine - 1, end).join('\n');
2460
2447
  skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
2461
2448
  coveredUntil = end;
@@ -2503,14 +2490,30 @@ class ToolHandler {
2503
2490
  continue;
2504
2491
  }
2505
2492
  }
2506
- // Whole-small-file rule: if a relevant file is small enough to afford,
2507
- // return it ENTIRELY instead of clustering. Clustering exists to tame
2508
- // god-files (App.tsx ~13k lines); on a ~134-line component a cluster is a
2509
- // lossy subset of a file the agent will just Read in full anyway — costing
2510
- // a round-trip and a re-read every later turn. Reserve clustering for files
2493
+ // Whole-file rule: if a relevant file is small enough to afford, return it
2494
+ // ENTIRELY instead of clustering. Clustering exists to tame god-files
2495
+ // (App.tsx ~13k lines); on a ~134-line component a cluster is a lossy
2496
+ // subset of a file the agent will just Read in full anyway — costing a
2497
+ // round-trip and a re-read every later turn. Reserve clustering for files
2511
2498
  // too big to ship whole. Still bounded by the total maxOutputChars check.
2512
- const WHOLE_FILE_MAX_LINES = 220;
2513
- const WHOLE_FILE_MAX_CHARS = budget.maxCharsPerFile * 3;
2499
+ //
2500
+ // CENTRAL files (where the query's entry points live) get a larger — but
2501
+ // bounded — ceiling: they're the heart of the answer, the file(s) the agent
2502
+ // would Read whole, so a genuinely small one comes back whole rather than as
2503
+ // thin clusters. A LARGE central file (the 791-line org-user store) exceeds
2504
+ // the ceiling and falls through to sectioning/clustering below — full method
2505
+ // bodies + signatures — so we never dump (or overflow on) a whole god-file.
2506
+ const isCentralFile = centralFiles.has(filePath);
2507
+ // Central files get a slightly larger whole-file window than peripheral ones,
2508
+ // but a TIGHT one (~1.5× the per-file cap): the native read of a central file
2509
+ // is a ~150–250 line orientation window, NOT the whole file. A flat "whole
2510
+ // central file" both overflowed the inline cap AND starved the co-flow files
2511
+ // (worker.rs ate the budget, dropping harness.rs's poll). A larger central
2512
+ // file falls through to per-method windowing/clustering below.
2513
+ const WHOLE_FILE_MAX_LINES = isCentralFile ? 280 : 220;
2514
+ const WHOLE_FILE_MAX_CHARS = isCentralFile
2515
+ ? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
2516
+ : budget.maxCharsPerFile * 3;
2514
2517
  if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
2515
2518
  const body = fileContent.replace(/\n+$/, '');
2516
2519
  let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
@@ -2520,12 +2523,12 @@ class ToolHandler {
2520
2523
  const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
2521
2524
  const omitted = uniqSymbols.length - headerNames.length;
2522
2525
  const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
2523
- if (totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
2524
- const remaining = budget.maxOutputChars - totalChars - 200;
2525
- if (remaining < 500)
2526
- break;
2527
- wholeSection = wholeSection.slice(0, remaining) + '\n... (trimmed) ...';
2526
+ if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
2527
+ // Don't slice a whole file mid-method: an incidental file that doesn't
2528
+ // fit is skipped; a necessary one (below) renders in full. Half a file
2529
+ // forces the Read this is meant to prevent.
2528
2530
  anyFileTrimmed = true;
2531
+ continue;
2529
2532
  }
2530
2533
  lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
2531
2534
  totalChars += wholeSection.length + 200;
@@ -2706,7 +2709,6 @@ class ToolHandler {
2706
2709
  // Emit chosen clusters in source order so the file reads top-to-bottom.
2707
2710
  let fileSection = '';
2708
2711
  const allSymbols = [];
2709
- let fileTrimmed = false;
2710
2712
  for (let i = 0; i < clusters.length; i++) {
2711
2713
  if (!chosenIndices.has(i))
2712
2714
  continue;
@@ -2717,13 +2719,12 @@ class ToolHandler {
2717
2719
  fileSection += section;
2718
2720
  allSymbols.push(...cluster.symbols);
2719
2721
  }
2720
- // If a single chosen cluster is still oversize (long monolithic
2721
- // function), tail-trim it. Better one trimmed view than nothing.
2722
- if (fileSection.length > budget.maxCharsPerFile) {
2723
- fileSection = fileSection.slice(0, budget.maxCharsPerFile) + '\n... (trimmed) ...';
2724
- fileTrimmed = true;
2725
- }
2726
- if (chosenIndices.size < clusters.length || fileTrimmed) {
2722
+ // A chosen cluster is a COMPLETE method-range — we never cut through a body.
2723
+ // An oversize single cluster (a long monolithic function) renders in FULL:
2724
+ // half a method is useless (the agent just Reads the rest for the other half),
2725
+ // which is the very fallback explore exists to prevent. A pathological file is
2726
+ // bounded by the per-file cluster SELECTION above + the total hard ceiling.
2727
+ if (chosenIndices.size < clusters.length) {
2727
2728
  anyFileTrimmed = true;
2728
2729
  }
2729
2730
  // Dedupe + cap the symbols list shown in the per-file header. Some
@@ -2755,11 +2756,11 @@ class ToolHandler {
2755
2756
  // (DataRequest/Validation) all render, instead of the cap dropping whichever
2756
2757
  // phase the file order happened to put last.
2757
2758
  if (!fileNecessary && totalChars + fileSection.length + 200 > budget.maxOutputChars) {
2758
- const remaining = budget.maxOutputChars - totalChars - 200;
2759
- if (remaining < 500)
2760
- continue; // incidental file, no room skip it, keep scanning for necessary ones
2761
- fileSection = fileSection.slice(0, remaining) + '\n... (trimmed) ...';
2759
+ // Incidental file that doesn't fit: SKIP it whole — never slice mid-method.
2760
+ // Keep scanning for necessary files (which bypass this cap and render in
2761
+ // full, bounded by the hard ceiling).
2762
2762
  anyFileTrimmed = true;
2763
+ continue;
2763
2764
  }
2764
2765
  lines.push(fileHeader);
2765
2766
  lines.push('');
@@ -2816,26 +2817,26 @@ class ToolHandler {
2816
2817
  // Stats unavailable — skip budget note
2817
2818
  }
2818
2819
  }
2819
- // Hard-cap to the adaptive budget. The per-file loop bounds the source
2820
- // sections, but the relationship map, additional-files list, and
2821
- // completeness/budget notes can still push the assembled output past
2822
- // maxOutputChars (observed 30k against a 28k tier cap). A fat explore
2823
- // payload persists in the agent's context and is re-read as cache-input
2824
- // on every subsequent turn, so the overrun is paid many times over.
2825
- // Final ceiling. The render loop is now the authority on WHAT to emit — it
2826
- // renders necessary files (named/spine) even past maxOutputChars and caps
2827
- // only incidental ones, all bounded by maxFiles + per-file true-spine — so
2828
- // this is a SAFETY ceiling above that necessary content, not a hard cut
2829
- // through it. Cutting at a flat maxOutputChars here undid the whole point:
2830
- // Alamofire's loop assembles build+validators-exec+validate (~15K) and a 13K
2831
- // slice dropped the validate phase the agent then Read. Allow necessary
2832
- // overflow up to 1.5× (still bounds a pathological monolith).
2820
+ // Final ceiling an ABSOLUTE inline cap, not a multiple of the budget. The
2821
+ // render loop renders necessary (named/spine) files even a bit past
2822
+ // maxOutputChars and caps only incidental ones, so this is the last safety.
2823
+ // It MUST stay under the host's inline tool-result limit (~25K chars): above
2824
+ // that the result is externalized to a file the agent Reads back (a 35K
2825
+ // vscode explore did exactly this in the n=4 A/B). So allow a little
2826
+ // necessary overflow above the 24K budget, but hard-stop at 25K never into
2827
+ // externalize territory.
2833
2828
  const output = flow.text + lines.join('\n');
2834
- const hardCeiling = Math.round(budget.maxOutputChars * 1.5);
2829
+ const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
2835
2830
  if (output.length > hardCeiling) {
2831
+ // Cut at a FILE-SECTION boundary (the last `#### ` header before the
2832
+ // ceiling) so we drop whole trailing file-sections rather than slicing
2833
+ // through a method body — a half-rendered method just forces the Read this
2834
+ // tool exists to prevent. Fall back to a line boundary only if no section
2835
+ // header sits in the back half (degenerate single-giant-section case).
2836
2836
  const cut = output.slice(0, hardCeiling);
2837
- const lastNewline = cut.lastIndexOf('\n');
2838
- const safe = lastNewline > hardCeiling * 0.8 ? cut.slice(0, lastNewline) : cut;
2837
+ const lastSection = cut.lastIndexOf('\n#### ');
2838
+ const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
2839
+ const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
2839
2840
  return this.textResult(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.)');
2840
2841
  }
2841
2842
  return this.textResult(output);
@@ -2844,35 +2845,262 @@ class ToolHandler {
2844
2845
  * Handle codegraph_node
2845
2846
  */
2846
2847
  async handleNode(args) {
2847
- const symbol = this.validateString(args.symbol, 'symbol');
2848
- if (typeof symbol !== 'string')
2849
- return symbol;
2850
2848
  const cg = this.getCodeGraph(args.projectPath);
2851
2849
  // Default to false to minimize context usage
2852
2850
  const includeCode = args.includeCode === true;
2853
- const match = this.findSymbol(cg, symbol);
2854
- if (!match) {
2851
+ const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
2852
+ const lineHint = typeof args.line === 'number' && args.line > 0 ? args.line : undefined;
2853
+ const offset = typeof args.offset === 'number' && args.offset > 0 ? Math.floor(args.offset) : undefined;
2854
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : undefined;
2855
+ const symbolsOnly = args.symbolsOnly === true;
2856
+ const symbolRaw = typeof args.symbol === 'string' ? args.symbol.trim() : '';
2857
+ // FILE READ MODE: a `file` with no `symbol` reads that file like the Read
2858
+ // tool — its current on-disk source with line numbers, narrowable with
2859
+ // `offset`/`limit` exactly as Read does — PLUS a one-line blast-radius
2860
+ // header (which files depend on it). `symbolsOnly` returns just the
2861
+ // structural map instead. Backed by the index: same bytes Read gives you.
2862
+ if (!symbolRaw && fileHint) {
2863
+ return this.handleFileView(cg, fileHint, { offset, limit, symbolsOnly });
2864
+ }
2865
+ const symbol = this.validateString(args.symbol, 'symbol');
2866
+ if (typeof symbol !== 'string')
2867
+ return symbol;
2868
+ let matches = this.findSymbolMatches(cg, symbol);
2869
+ if (matches.length === 0) {
2855
2870
  return this.textResult(`Symbol "${symbol}" not found in the codebase`);
2856
2871
  }
2872
+ // Disambiguate a heavily-overloaded name to a specific definition the caller
2873
+ // pinned by file/line (the `file:line` a trail or another tool showed it) —
2874
+ // so it can fetch e.g. `Harness::poll` at harness.rs:153 out of 50+ `poll`s
2875
+ // instead of Reading. file matches by path suffix/substring; line prefers the
2876
+ // def whose body contains it, else the nearest start. Only narrows (never
2877
+ // empties — if a hint matches nothing it's ignored).
2878
+ if (matches.length > 1 && (fileHint || lineHint !== undefined)) {
2879
+ const norm = (p) => p.replace(/\\/g, '/').toLowerCase();
2880
+ let narrowed = matches;
2881
+ if (fileHint) {
2882
+ const fh = norm(fileHint);
2883
+ const byFile = narrowed.filter((n) => norm(n.filePath).endsWith(fh) || norm(n.filePath).includes(fh));
2884
+ if (byFile.length > 0)
2885
+ narrowed = byFile;
2886
+ }
2887
+ if (lineHint !== undefined && narrowed.length > 1) {
2888
+ const containing = narrowed.filter((n) => n.startLine <= lineHint && (n.endLine ?? n.startLine) >= lineHint);
2889
+ narrowed = containing.length > 0
2890
+ ? containing
2891
+ : [...narrowed].sort((a, b) => Math.abs(a.startLine - lineHint) - Math.abs(b.startLine - lineHint)).slice(0, 1);
2892
+ }
2893
+ if (narrowed.length > 0)
2894
+ matches = narrowed;
2895
+ }
2896
+ // Single definition — the common case.
2897
+ if (matches.length === 1) {
2898
+ return this.textResult(this.truncateOutput(await this.renderNodeSection(cg, matches[0], includeCode)));
2899
+ }
2900
+ // Multiple definitions share this name — overloads, or same-named methods on
2901
+ // different types (Alamofire `didCompleteTask`/`task`/`validate`, gin
2902
+ // `reset`). Returning ONE forces the agent to guess, and when it guesses
2903
+ // wrong it READS the file to find the right overload — the dominant
2904
+ // codegraph_node read cause on Swift/Go. So return them ALL: pack as many
2905
+ // FULL bodies as fit a char budget (the agent gets the one it needs in this
2906
+ // one call, no follow-up parameter to learn), and list any remainder by
2907
+ // file:line so a large overload set can't overflow the per-tool cap.
2908
+ const header = `**${matches.length} definitions named "${symbol}"**`;
2909
+ if (!includeCode) {
2910
+ const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
2911
+ 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')));
2912
+ }
2913
+ const BODY_BUDGET = 12000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
2914
+ // The CHAR budget is the real limiter — keep the count cap high so a set of
2915
+ // SHORT overloads (Alamofire's 10 `validate` variants, each a few lines) all
2916
+ // render in full rather than relegating the one the agent wanted to a
2917
+ // bodiless list. Only a set of many LARGE bodies hits the char budget first.
2918
+ const HARD_CAP = 16;
2919
+ const rendered = [];
2920
+ const listed = [];
2921
+ let used = 0;
2922
+ for (const n of matches) {
2923
+ if (rendered.length >= HARD_CAP) {
2924
+ listed.push(n);
2925
+ continue;
2926
+ }
2927
+ const section = await this.renderNodeSection(cg, n, true);
2928
+ // Always emit the first; emit the rest only while within the char budget.
2929
+ if (rendered.length === 0 || used + section.length <= BODY_BUDGET) {
2930
+ rendered.push(section);
2931
+ used += section.length;
2932
+ }
2933
+ else {
2934
+ listed.push(n);
2935
+ }
2936
+ }
2937
+ const out = [
2938
+ header,
2939
+ `Returning ${rendered.length} in full${listed.length ? `; ${listed.length} more listed below` : ''} — pick the one you need (no Read required).`,
2940
+ '',
2941
+ rendered.join('\n\n---\n\n'),
2942
+ ];
2943
+ if (listed.length) {
2944
+ const LIST_CAP = 20;
2945
+ const shownList = listed.slice(0, LIST_CAP);
2946
+ out.push('', '### Other definitions', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
2947
+ if (listed.length > LIST_CAP)
2948
+ out.push(`- … +${listed.length - LIST_CAP} more`);
2949
+ 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.`);
2950
+ }
2951
+ return this.textResult(this.truncateOutput(out.join('\n')));
2952
+ }
2953
+ /**
2954
+ * FILE READ MODE: resolve `fileArg` (path or basename) to an indexed file and
2955
+ * read it like the Read tool — its current on-disk source with line numbers,
2956
+ * narrowable with `offset`/`limit` exactly as Read's are — preceded by a
2957
+ * one-line blast-radius header (which files depend on it). `symbolsOnly`
2958
+ * returns just the structural map (symbols + dependents) instead of source.
2959
+ *
2960
+ * Parity goal: the numbered source block is byte-for-byte the shape Read
2961
+ * returns (`<n>\t<line>`, no padding), so the agent treats it as a Read — only
2962
+ * faster (served from the index) and with the blast radius attached. Security:
2963
+ * yaml/properties files are summarized by key, never dumped (#383); reads go
2964
+ * through validatePathWithinRoot (#527).
2965
+ */
2966
+ async handleFileView(cg, fileArg, opts = {}) {
2967
+ const normalize = (p) => p.replace(/\\/g, '/').replace(/^(?:\.?\/+)+/, '').replace(/\/+$/, '');
2968
+ const wantLower = normalize(fileArg).toLowerCase();
2969
+ const allFiles = cg.getFiles();
2970
+ if (allFiles.length === 0)
2971
+ return this.textResult('No files indexed. Run `codegraph index` first.');
2972
+ let resolved = allFiles.find((f) => f.path.toLowerCase() === wantLower);
2973
+ let candidates = [];
2974
+ if (!resolved) {
2975
+ candidates = allFiles.filter((f) => f.path.toLowerCase().endsWith('/' + wantLower));
2976
+ if (candidates.length === 1)
2977
+ resolved = candidates[0];
2978
+ }
2979
+ if (!resolved && candidates.length === 0) {
2980
+ candidates = allFiles.filter((f) => f.path.toLowerCase().includes(wantLower));
2981
+ if (candidates.length === 1)
2982
+ resolved = candidates[0];
2983
+ }
2984
+ if (!resolved && candidates.length > 1) {
2985
+ return this.textResult([`"${fileArg}" matches ${candidates.length} indexed files — pass a longer path:`, '',
2986
+ ...candidates.slice(0, 25).map((f) => `- ${f.path}`)].join('\n'));
2987
+ }
2988
+ if (!resolved) {
2989
+ return this.textResult(`No indexed file matches "${fileArg}". Codegraph indexes source files; configs/docs it doesn't parse won't appear — Read those directly.`);
2990
+ }
2991
+ const filePath = resolved.path;
2992
+ const nodes = cg.getNodesInFile(filePath)
2993
+ .filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
2994
+ .sort((a, b) => a.startLine - b.startLine);
2995
+ const dependents = cg.getFileDependents(filePath);
2996
+ // Compact, one-line blast radius (codegraph's value-add over a plain Read).
2997
+ const depSummary = dependents.length
2998
+ ? `used by ${dependents.length} file${dependents.length === 1 ? '' : 's'}: ${dependents.slice(0, 8).join(', ')}${dependents.length > 8 ? `, +${dependents.length - 8} more` : ''}`
2999
+ : 'no other indexed file depends on it';
3000
+ // Symbol-map renderer — for symbolsOnly, the config fallback, and read errors.
3001
+ const symbolMap = (heading, limit = 200) => {
3002
+ const lines = [heading];
3003
+ for (const n of nodes.slice(0, limit)) {
3004
+ const sig = n.signature ? ` ${n.signature.replace(/\s+/g, ' ').trim()}` : '';
3005
+ lines.push(`- \`${n.name}\` (${n.kind})${sig} — :${n.startLine}`);
3006
+ }
3007
+ if (nodes.length > limit)
3008
+ lines.push(`- … +${nodes.length - limit} more`);
3009
+ return lines;
3010
+ };
3011
+ // symbolsOnly → the cheap structural overview, no source.
3012
+ if (opts.symbolsOnly) {
3013
+ const out = [`**${filePath}** — ${nodes.length} symbol${nodes.length === 1 ? '' : 's'}, ${depSummary}`, ''];
3014
+ if (nodes.length)
3015
+ out.push(...symbolMap('### Symbols'));
3016
+ else
3017
+ out.push('_No indexed symbols in this file._');
3018
+ out.push('', '> Drop `symbolsOnly` (or pass `offset`/`limit`) to read the source, like Read.');
3019
+ return this.textResult(this.truncateOutput(out.join('\n')));
3020
+ }
3021
+ // SECURITY (#383): never dump a raw config/data file — a yaml/properties
3022
+ // line is `key: <secret>`. Summarize by key and point to a real Read.
3023
+ if (utils_1.CONFIG_LEAF_LANGUAGES.has(resolved.language)) {
3024
+ const out = [`**${filePath}** — configuration/data file, ${depSummary}`, ''];
3025
+ if (nodes.length)
3026
+ out.push(...symbolMap('### Keys (values withheld for safety)'));
3027
+ out.push('', '> Values may be secrets, so codegraph indexes keys only. Read the file directly if you need a value.');
3028
+ return this.textResult(this.truncateOutput(out.join('\n')));
3029
+ }
3030
+ // Read the current bytes from disk through the security chokepoint
3031
+ // (validatePathWithinRoot: blocks `../` traversal and symlink escapes, #527).
3032
+ const abs = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), filePath);
3033
+ let content = null;
3034
+ if (abs) {
3035
+ try {
3036
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
3037
+ }
3038
+ catch {
3039
+ content = null;
3040
+ }
3041
+ }
3042
+ if (content === null) {
3043
+ const out = [`**${filePath}** — could not read from disk (it may have moved since indexing). ${depSummary}`, ''];
3044
+ if (nodes.length)
3045
+ out.push(...symbolMap('### Symbols'));
3046
+ out.push('', `> Read \`${filePath}\` directly for its current content.`);
3047
+ return this.textResult(this.truncateOutput(out.join('\n')));
3048
+ }
3049
+ // Split exactly as Read does — keep the trailing empty line a final newline
3050
+ // produces (Read numbers it too), so line numbers line up byte-for-byte.
3051
+ const fileLines = content.split('\n');
3052
+ const total = fileLines.length;
3053
+ // Read-parity windowing: `offset`/`limit` mean exactly what they do on Read
3054
+ // (1-based start line; max line count). Default: the whole file, capped like
3055
+ // Read at 2000 lines and bounded by a char budget that tracks explore's
3056
+ // proven-safe ~38k response ceiling. Overflow is stated explicitly (Read
3057
+ // paginates too) — never the silent 15k truncateOutput chop.
3058
+ const CHAR_BUDGET = 38000;
3059
+ const DEFAULT_LIMIT = 2000;
3060
+ const offset = Math.max(1, opts.offset ?? 1);
3061
+ if (offset > total) {
3062
+ return this.textResult(`**${filePath}** has ${total} line${total === 1 ? '' : 's'} — offset ${offset} is past the end. ${depSummary}`);
3063
+ }
3064
+ const maxLines = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
3065
+ const start = offset - 1; // 0-based
3066
+ const header = `**${filePath}** — ${total} lines, ${nodes.length} symbol${nodes.length === 1 ? '' : 's'} · ${depSummary}`;
3067
+ // Numbered lines, byte-for-byte Read's shape: `<n>\t<line>`, no left-pad.
3068
+ const numbered = [];
3069
+ let used = header.length + 8;
3070
+ let i = start;
3071
+ for (; i < total && numbered.length < maxLines; i++) {
3072
+ const ln = `${i + 1}\t${fileLines[i]}`;
3073
+ if (used + ln.length + 1 > CHAR_BUDGET && numbered.length > 0)
3074
+ break;
3075
+ numbered.push(ln);
3076
+ used += ln.length + 1;
3077
+ }
3078
+ const shownEnd = start + numbered.length;
3079
+ const complete = offset === 1 && shownEnd >= total;
3080
+ const out = [header, '', ...numbered];
3081
+ if (!complete) {
3082
+ out.push('', `(lines ${offset}–${shownEnd} of ${total} — pass \`offset\`/\`limit\` for another range, or \`codegraph_node <symbol>\` for one symbol in full)`);
3083
+ }
3084
+ // Self-bounded to CHAR_BUDGET — do NOT route through truncateOutput (15k).
3085
+ return this.textResult(out.join('\n'));
3086
+ }
3087
+ /** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
3088
+ async renderNodeSection(cg, node, includeCode) {
2857
3089
  let code = null;
2858
3090
  let outline = null;
2859
3091
  if (includeCode) {
2860
3092
  // For container symbols (class/interface/struct/…), the full body is the
2861
- // sum of every method body — a wall of source (e.g. a 10k-char class)
2862
- // that bloats context and is rarely needed in full. Return a structural
2863
- // outline (members + signatures + line numbers) instead; the agent can
2864
- // Read or codegraph_node a specific method for its body. Leaf symbols
2865
- // (function/method/etc.) return their full body as before.
2866
- if (CONTAINER_NODE_KINDS.has(match.node.kind)) {
2867
- outline = this.buildContainerOutline(cg, match.node);
3093
+ // sum of every method body — a wall of source. Return a structural outline
3094
+ // (members + signatures + line numbers) instead; leaf symbols return their
3095
+ // full body.
3096
+ if (CONTAINER_NODE_KINDS.has(node.kind)) {
3097
+ outline = this.buildContainerOutline(cg, node);
2868
3098
  }
2869
3099
  if (!outline) {
2870
- code = await cg.getCode(match.node.id);
3100
+ code = await cg.getCode(node.id);
2871
3101
  }
2872
3102
  }
2873
- const trail = this.formatTrail(cg, match.node);
2874
- const formatted = this.formatNodeDetails(match.node, code, outline) + trail + match.note;
2875
- return this.textResult(this.truncateOutput(formatted));
3103
+ return this.formatNodeDetails(node, code, outline) + this.formatTrail(cg, node);
2876
3104
  }
2877
3105
  /**
2878
3106
  * Build the "trail" for a symbol: its direct callees (what it calls) and
@@ -3212,51 +3440,55 @@ class ToolHandler {
3212
3440
  const segments = node.filePath.split('/').filter((s) => s.length > 0);
3213
3441
  return containerHints.every((hint) => segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint));
3214
3442
  }
3215
- findSymbol(cg, symbol) {
3216
- // Use higher limit for qualified lookups (e.g., "Session.request",
3217
- // "stage_apply::run") since the target may rank lower in FTS when
3218
- // there are many partial matches across the qualifier parts.
3443
+ /**
3444
+ * Find ALL definitions matching a name, ranked, so codegraph_node can return
3445
+ * every overload instead of guessing one (the wrong guess a Read). Keepers
3446
+ * rank before generated stubs (.pb.go etc.); stable within a group preserves
3447
+ * FTS order. Returns [] when nothing matches; a qualified lookup that finds no
3448
+ * exact match returns [] rather than a misleading fuzzy file hit (#173); a
3449
+ * bare name with no exact match falls back to the single top fuzzy result.
3450
+ */
3451
+ findSymbolMatches(cg, symbol) {
3219
3452
  const isQualified = /[.\/]|::/.test(symbol);
3220
- const limit = isQualified ? 50 : 10;
3453
+ // For a bare name, enumerate EVERY exact-name definition via the direct index
3454
+ // (not FTS, which caps + ranks): tokio's `poll` has 50+ defs and the one the
3455
+ // caller wants (`Harness::poll` at harness.rs:153) ranks below any search cut,
3456
+ // so it could be neither rendered nor pinned by the file/line disambiguator —
3457
+ // and the agent Read it. With the full set, the multi-overload render + the
3458
+ // file/line filter can both reach it.
3459
+ if (!isQualified) {
3460
+ const exact = cg.getNodesByName(symbol);
3461
+ if (exact.length > 0) {
3462
+ return [...exact].sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.filePath) ? 1 : 0));
3463
+ }
3464
+ // No exact match — use the single top fuzzy result (e.g. a file basename).
3465
+ const fuzzy = cg.searchNodes(symbol, { limit: 10 });
3466
+ return fuzzy[0] ? [fuzzy[0].node] : [];
3467
+ }
3468
+ // Qualified lookup (`Session.request`, `stage_apply::run`): FTS + matchesSymbol.
3469
+ const limit = 50;
3221
3470
  let results = cg.searchNodes(symbol, { limit });
3222
- // FTS strips colons as a special char, so `stage_apply::run` searches
3223
- // for the literal `stage_applyrun` and finds nothing. Re-search by
3224
- // the bare last part and let `matchesSymbol` filter by qualifier.
3471
+ // FTS strips colons, so `stage_apply::run` searches the literal
3472
+ // `stage_applyrun` and finds nothing. Re-search by the bare last part and
3473
+ // let `matchesSymbol` filter by qualifier.
3225
3474
  if (isQualified && results.length === 0) {
3226
3475
  const tail = lastQualifierPart(symbol);
3227
3476
  if (tail && tail !== symbol)
3228
3477
  results = cg.searchNodes(tail, { limit });
3229
3478
  }
3230
- if (results.length === 0 || !results[0]) {
3231
- return null;
3232
- }
3233
- const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
3234
- if (exactMatches.length === 1) {
3235
- return { node: exactMatches[0].node, note: '' };
3236
- }
3237
- if (exactMatches.length > 1) {
3238
- // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …)
3239
- // so a query like "Send" prefers the keeper implementation over
3240
- // the protobuf-generated interface stub. Stable sort preserves
3241
- // FTS order within each group. See generated-detection.ts.
3242
- const ranked = [...exactMatches].sort((a, b) => {
3243
- const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
3244
- const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
3245
- return aGen - bGen;
3246
- });
3247
- // Multiple exact matches - pick first, note the others
3248
- const picked = ranked[0].node;
3249
- const others = ranked.slice(1).map(r => `${r.node.name} (${r.node.kind}) at ${r.node.filePath}:${r.node.startLine}`);
3250
- const note = `\n\n> **Note:** ${ranked.length} symbols named "${symbol}". Showing results for \`${picked.filePath}:${picked.startLine}\`. Others: ${others.join(', ')}`;
3251
- return { node: picked, note };
3252
- }
3253
- // No exact match. For qualified lookups, don't silently fall back
3254
- // to a fuzzy result — the user typed a specific qualifier, and
3255
- // resolving `stage_apply::nonexistent_fn` to the unrelated
3256
- // `stage_apply.rs` file would be actively misleading (#173).
3257
- if (isQualified)
3258
- return null;
3259
- return { node: results[0].node, note: '' };
3479
+ if (results.length === 0)
3480
+ return [];
3481
+ const exactMatches = results.filter((r) => this.matchesSymbol(r.node, symbol));
3482
+ if (exactMatches.length === 0) {
3483
+ // No exact match — a qualified lookup must not fall back to a fuzzy file
3484
+ // hit (#173); a bare name may use the single top fuzzy result.
3485
+ return isQualified ? [] : results[0] ? [results[0].node] : [];
3486
+ }
3487
+ // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …) so a flow
3488
+ // query prefers the keeper implementation over the protobuf-generated stub.
3489
+ return [...exactMatches]
3490
+ .sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0))
3491
+ .map((r) => r.node);
3260
3492
  }
3261
3493
  /**
3262
3494
  * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
@@ -3320,15 +3552,36 @@ class ToolHandler {
3320
3552
  }
3321
3553
  return lines.join('\n');
3322
3554
  }
3323
- formatNodeList(nodes, title) {
3555
+ formatNodeList(nodes, title, labels) {
3324
3556
  const lines = [`## ${title} (${nodes.length} found)`, ''];
3325
3557
  for (const node of nodes) {
3326
3558
  const location = node.startLine ? `:${node.startLine}` : '';
3327
- // Compact: just name, kind, location
3328
- lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
3559
+ // Compact: just name, kind, location — plus the relationship when it
3560
+ // isn't a plain call (callback registration, instantiation, …).
3561
+ const label = labels?.get(node.id);
3562
+ lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}${label ? ` — via ${label}` : ''}`);
3329
3563
  }
3330
3564
  return lines.join('\n');
3331
3565
  }
3566
+ /**
3567
+ * Relationship label for a non-`calls` edge in callers/callees lists. A
3568
+ * function-as-value edge (#756) is the high-signal one: `callers(cb)`
3569
+ * showing "via callback registration" tells the agent this is where the
3570
+ * callback is WIRED, not where it's invoked.
3571
+ */
3572
+ edgeLabel(edge) {
3573
+ if (edge.kind === 'calls')
3574
+ return null;
3575
+ if (edge.metadata?.fnRef === true)
3576
+ return 'callback registration';
3577
+ if (edge.kind === 'instantiates')
3578
+ return 'instantiation';
3579
+ if (edge.kind === 'imports')
3580
+ return 'import';
3581
+ if (edge.kind === 'references')
3582
+ return 'reference';
3583
+ return edge.kind;
3584
+ }
3332
3585
  formatImpact(symbol, impact) {
3333
3586
  const nodeCount = impact.nodes.size;
3334
3587
  // Compact format: just list affected symbols grouped by file
@@ -3398,9 +3651,6 @@ class ToolHandler {
3398
3651
  }
3399
3652
  return lines.join('\n');
3400
3653
  }
3401
- formatTaskContext(context) {
3402
- return context.summary || 'No context found';
3403
- }
3404
3654
  textResult(text) {
3405
3655
  return {
3406
3656
  content: [{ type: 'text', text }],