mixdog 0.7.18 → 0.8.1

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 (847) hide show
  1. package/README.md +37 -331
  2. package/package.json +67 -99
  3. package/scripts/boot-smoke.mjs +94 -0
  4. package/scripts/build-tui.mjs +52 -0
  5. package/scripts/compact-smoke.mjs +199 -0
  6. package/scripts/lead-workflow-smoke.mjs +598 -0
  7. package/scripts/live-worker-smoke.mjs +239 -0
  8. package/scripts/output-style-smoke.mjs +101 -0
  9. package/scripts/session-context-bench.mjs +172 -0
  10. package/scripts/smoke-loop-report.mjs +221 -0
  11. package/scripts/smoke-loop.mjs +201 -0
  12. package/scripts/smoke.mjs +113 -0
  13. package/scripts/tool-failures.mjs +143 -0
  14. package/scripts/tool-smoke.mjs +452 -0
  15. package/src/agents/debugger/AGENT.md +3 -0
  16. package/src/agents/debugger/agent.json +6 -0
  17. package/src/agents/explore/AGENT.md +4 -0
  18. package/src/agents/explore/agent.json +6 -0
  19. package/src/agents/heavy-worker/AGENT.md +3 -0
  20. package/src/agents/heavy-worker/agent.json +6 -0
  21. package/src/agents/maintainer/AGENT.md +3 -0
  22. package/src/agents/maintainer/agent.json +6 -0
  23. package/src/agents/reviewer/AGENT.md +3 -0
  24. package/src/agents/reviewer/agent.json +6 -0
  25. package/src/agents/scheduler-task.md +3 -0
  26. package/src/agents/web-researcher/AGENT.md +3 -0
  27. package/src/agents/web-researcher/agent.json +6 -0
  28. package/src/agents/webhook-handler.md +3 -0
  29. package/src/agents/worker/AGENT.md +3 -0
  30. package/src/agents/worker/agent.json +6 -0
  31. package/src/app.mjs +90 -0
  32. package/src/cli.mjs +11 -0
  33. package/src/defaults/hidden-roles.json +72 -0
  34. package/src/defaults/mixdog-config.template.json +15 -0
  35. package/src/hooks/lib/permission-evaluator.cjs +488 -0
  36. package/src/hooks/lib/settings-loader.cjs +112 -0
  37. package/src/lib/keychain-cjs.cjs +332 -0
  38. package/src/lib/plugin-paths.cjs +28 -0
  39. package/src/lib/rules-builder.cjs +315 -0
  40. package/src/mixdog-session-runtime.mjs +3813 -0
  41. package/src/output-styles/default.md +38 -0
  42. package/src/output-styles/extreme-simple.md +17 -0
  43. package/src/output-styles/simple.md +17 -0
  44. package/src/repl.mjs +330 -0
  45. package/src/rules/bridge/00-common.md +5 -0
  46. package/src/rules/bridge/20-skip-protocol.md +11 -0
  47. package/src/rules/bridge/30-explorer.md +4 -0
  48. package/src/rules/bridge/40-cycle1-agent.md +28 -0
  49. package/src/rules/bridge/41-cycle2-agent.md +59 -0
  50. package/src/rules/lead/00-tool-lead.md +5 -0
  51. package/src/rules/lead/01-general.md +5 -0
  52. package/src/rules/lead/02-channels.md +3 -0
  53. package/src/rules/lead/04-workflow.md +12 -0
  54. package/src/rules/shared/00-language.md +3 -0
  55. package/src/rules/shared/01-tool.md +3 -0
  56. package/src/runtime/agent/orchestrator/bridge-trace.mjs +814 -0
  57. package/src/runtime/agent/orchestrator/cache-mtime.mjs +60 -0
  58. package/src/runtime/agent/orchestrator/config.mjs +446 -0
  59. package/src/runtime/agent/orchestrator/context/collect.mjs +796 -0
  60. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +417 -0
  61. package/src/runtime/agent/orchestrator/internal-roles.mjs +188 -0
  62. package/src/runtime/agent/orchestrator/internal-tools.mjs +88 -0
  63. package/src/runtime/agent/orchestrator/mcp/client.mjs +345 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2104 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +784 -0
  66. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +341 -0
  67. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1679 -0
  68. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +959 -0
  69. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +213 -0
  70. package/src/runtime/agent/orchestrator/providers/model-cache.mjs +38 -0
  71. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +471 -0
  72. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +615 -0
  73. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +808 -0
  74. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +1719 -0
  75. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2587 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1953 -0
  77. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +136 -0
  78. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +317 -0
  79. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +109 -0
  80. package/src/runtime/agent/orchestrator/providers/registry.mjs +247 -0
  81. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +332 -0
  82. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +11 -0
  83. package/src/runtime/agent/orchestrator/providers/trace-utils.mjs +50 -0
  84. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
  85. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
  86. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +318 -0
  87. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +367 -0
  88. package/src/runtime/agent/orchestrator/session/compact.mjs +882 -0
  89. package/src/runtime/agent/orchestrator/session/context-utils.mjs +233 -0
  90. package/src/runtime/agent/orchestrator/session/loop.mjs +2269 -0
  91. package/src/runtime/agent/orchestrator/session/manager.mjs +2972 -0
  92. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  93. package/src/runtime/agent/orchestrator/session/store.mjs +870 -0
  94. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +166 -0
  95. package/src/runtime/agent/orchestrator/smart-bridge/bridge-llm.mjs +339 -0
  96. package/src/runtime/agent/orchestrator/smart-bridge/cache-strategy.mjs +419 -0
  97. package/src/runtime/agent/orchestrator/stall-policy.mjs +227 -0
  98. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +235 -0
  99. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +723 -0
  100. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
  101. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +389 -0
  102. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +637 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +165 -0
  104. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +104 -0
  105. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +194 -0
  106. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +596 -0
  107. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +110 -0
  108. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +153 -0
  109. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
  110. package/src/runtime/agent/orchestrator/tools/builtin/read-open.mjs +189 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +731 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +168 -0
  113. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +465 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +160 -0
  116. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +982 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +1087 -0
  118. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +231 -0
  119. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +223 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin.mjs +478 -0
  121. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
  122. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4102 -0
  123. package/src/runtime/agent/orchestrator/tools/destructive-warning.mjs +323 -0
  124. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +154 -0
  125. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +26 -0
  126. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +143 -0
  127. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +18 -0
  128. package/src/runtime/agent/orchestrator/tools/patch.mjs +2772 -0
  129. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +114 -0
  130. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +880 -0
  131. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +312 -0
  132. package/src/runtime/channels/backends/discord.mjs +781 -0
  133. package/src/runtime/channels/data/voice-runtime-manifest.json +138 -0
  134. package/src/runtime/channels/index.mjs +3309 -0
  135. package/src/runtime/channels/lib/config.mjs +285 -0
  136. package/src/runtime/channels/lib/drop-trace.mjs +71 -0
  137. package/src/runtime/channels/lib/event-pipeline.mjs +81 -0
  138. package/src/runtime/channels/lib/holidays.mjs +138 -0
  139. package/src/runtime/channels/lib/hook-pipe-server.mjs +671 -0
  140. package/src/runtime/channels/lib/output-forwarder.mjs +765 -0
  141. package/src/runtime/channels/lib/runtime-paths.mjs +497 -0
  142. package/src/runtime/channels/lib/scheduler.mjs +710 -0
  143. package/src/runtime/channels/lib/session-discovery.mjs +102 -0
  144. package/src/runtime/channels/lib/state-file.mjs +68 -0
  145. package/src/runtime/channels/lib/status-snapshot.mjs +224 -0
  146. package/src/runtime/channels/lib/tool-format.mjs +122 -0
  147. package/src/runtime/channels/lib/transcript-discovery.mjs +195 -0
  148. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +734 -0
  149. package/src/runtime/channels/lib/webhook.mjs +1288 -0
  150. package/src/runtime/channels/tool-defs.mjs +177 -0
  151. package/src/runtime/lib/keychain-cjs.cjs +289 -0
  152. package/src/runtime/memory/data/runtime-manifest.json +40 -0
  153. package/src/runtime/memory/index.mjs +3706 -0
  154. package/src/runtime/memory/lib/core-memory-store.mjs +336 -0
  155. package/src/runtime/memory/lib/embedding-provider.mjs +275 -0
  156. package/src/runtime/memory/lib/embedding-worker.mjs +331 -0
  157. package/src/runtime/memory/lib/memory-cycle-requests.mjs +276 -0
  158. package/src/runtime/memory/lib/memory-cycle1.mjs +783 -0
  159. package/src/runtime/memory/lib/memory-cycle2.mjs +1389 -0
  160. package/src/runtime/memory/lib/memory-cycle3.mjs +646 -0
  161. package/src/runtime/memory/lib/memory-embed.mjs +300 -0
  162. package/src/runtime/memory/lib/memory-ops-policy.mjs +149 -0
  163. package/src/runtime/memory/lib/memory-recall-store.mjs +644 -0
  164. package/src/runtime/memory/lib/memory.mjs +418 -0
  165. package/src/runtime/memory/lib/pg/adapter.mjs +328 -0
  166. package/src/runtime/memory/lib/pg/process.mjs +366 -0
  167. package/src/runtime/memory/lib/pg/supervisor.mjs +495 -0
  168. package/src/runtime/memory/lib/runtime-fetcher.mjs +464 -0
  169. package/src/runtime/memory/lib/trace-store.mjs +734 -0
  170. package/src/runtime/memory/tool-defs.mjs +79 -0
  171. package/src/runtime/search/index.mjs +925 -0
  172. package/src/runtime/search/lib/config.mjs +61 -0
  173. package/src/runtime/search/lib/web-tools.mjs +1278 -0
  174. package/src/runtime/search/tool-defs.mjs +64 -0
  175. package/src/runtime/shared/atomic-file.mjs +435 -0
  176. package/src/runtime/shared/background-tasks.mjs +376 -0
  177. package/src/runtime/shared/child-guardian.mjs +98 -0
  178. package/src/runtime/shared/config.mjs +393 -0
  179. package/src/runtime/shared/err-text.mjs +121 -0
  180. package/src/runtime/shared/launcher-control.mjs +259 -0
  181. package/src/runtime/shared/llm/http-agent.mjs +129 -0
  182. package/src/runtime/shared/open-url.mjs +37 -0
  183. package/src/runtime/shared/plugin-paths.mjs +25 -0
  184. package/src/runtime/shared/process-shutdown.mjs +147 -0
  185. package/src/runtime/shared/schedules-store.mjs +70 -0
  186. package/src/runtime/shared/tool-execution-contract.mjs +104 -0
  187. package/src/runtime/shared/tool-surface.mjs +947 -0
  188. package/src/runtime/shared/user-cwd.mjs +221 -0
  189. package/src/runtime/shared/user-data-guard.mjs +232 -0
  190. package/src/runtime/shared/workspace-router.mjs +259 -0
  191. package/src/standalone/bridge-tool.mjs +1414 -0
  192. package/src/standalone/channel-admin.mjs +366 -0
  193. package/src/standalone/channel-worker-preload.cjs +3 -0
  194. package/src/standalone/channel-worker.mjs +353 -0
  195. package/src/standalone/explore-tool.mjs +233 -0
  196. package/src/standalone/hook-bus.mjs +246 -0
  197. package/src/standalone/plugin-admin.mjs +247 -0
  198. package/src/standalone/provider-admin.mjs +338 -0
  199. package/src/standalone/seeds.mjs +94 -0
  200. package/src/standalone/usage-dashboard.mjs +510 -0
  201. package/src/tui/App.jsx +5446 -0
  202. package/src/tui/components/AnsiText.jsx +199 -0
  203. package/src/tui/components/ContextPanel.jsx +265 -0
  204. package/src/tui/components/Markdown.jsx +205 -0
  205. package/src/tui/components/MarkdownTable.jsx +204 -0
  206. package/src/tui/components/Message.jsx +103 -0
  207. package/src/tui/components/Picker.jsx +317 -0
  208. package/src/tui/components/PromptInput.jsx +584 -0
  209. package/src/tui/components/QueuedCommands.jsx +47 -0
  210. package/src/tui/components/SlashCommandPalette.jsx +114 -0
  211. package/src/tui/components/Spinner.jsx +317 -0
  212. package/src/tui/components/StatusLine.jsx +87 -0
  213. package/src/tui/components/TextEntryPanel.jsx +323 -0
  214. package/src/tui/components/ToolExecution.jsx +791 -0
  215. package/src/tui/components/TurnDone.jsx +78 -0
  216. package/src/tui/components/UsagePanel.jsx +331 -0
  217. package/src/tui/dist/index.mjs +12420 -0
  218. package/src/tui/engine.mjs +2410 -0
  219. package/src/tui/figures.mjs +50 -0
  220. package/src/tui/hooks/useEngine.mjs +16 -0
  221. package/src/tui/index.jsx +254 -0
  222. package/src/tui/input-editing.mjs +242 -0
  223. package/src/tui/markdown/format-token.mjs +194 -0
  224. package/src/tui/paste-attachments.mjs +198 -0
  225. package/src/tui/runtime/shared/process-shutdown.mjs +1 -0
  226. package/src/tui/spinner-verbs.mjs +45 -0
  227. package/src/tui/theme.mjs +67 -0
  228. package/src/tui/time-format.mjs +53 -0
  229. package/src/ui/ansi.mjs +115 -0
  230. package/src/ui/markdown.mjs +195 -0
  231. package/src/ui/statusline.mjs +730 -0
  232. package/src/ui/tool-card.mjs +99 -0
  233. package/src/vendor/statusline/bin/statusline-lib.mjs +805 -0
  234. package/src/vendor/statusline/bin/statusline-route.mjs +596 -0
  235. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +285 -0
  236. package/src/vendor/statusline/src/gateway/claude-current.mjs +320 -0
  237. package/src/vendor/statusline/src/gateway/route-meta.mjs +753 -0
  238. package/src/vendor/statusline/src/gateway/session-routes.mjs +244 -0
  239. package/src/workflows/default/WORKFLOW.md +7 -0
  240. package/src/workflows/default/workflow.json +14 -0
  241. package/vendor/ink/build/ansi-tokenizer.d.ts +38 -0
  242. package/vendor/ink/build/ansi-tokenizer.js +316 -0
  243. package/vendor/ink/build/ansi-tokenizer.js.map +1 -0
  244. package/vendor/ink/build/colorize.d.ts +3 -0
  245. package/vendor/ink/build/colorize.js +48 -0
  246. package/vendor/ink/build/colorize.js.map +1 -0
  247. package/vendor/ink/build/components/AccessibilityContext.d.ts +3 -0
  248. package/vendor/ink/build/components/AccessibilityContext.js +5 -0
  249. package/vendor/ink/build/components/AccessibilityContext.js.map +1 -0
  250. package/vendor/ink/build/components/AnimationContext.d.ts +9 -0
  251. package/vendor/ink/build/components/AnimationContext.js +13 -0
  252. package/vendor/ink/build/components/AnimationContext.js.map +1 -0
  253. package/vendor/ink/build/components/App.d.ts +24 -0
  254. package/vendor/ink/build/components/App.js +554 -0
  255. package/vendor/ink/build/components/App.js.map +1 -0
  256. package/vendor/ink/build/components/AppContext.d.ts +80 -0
  257. package/vendor/ink/build/components/AppContext.js +25 -0
  258. package/vendor/ink/build/components/AppContext.js.map +1 -0
  259. package/vendor/ink/build/components/BackgroundContext.d.ts +4 -0
  260. package/vendor/ink/build/components/BackgroundContext.js +3 -0
  261. package/vendor/ink/build/components/BackgroundContext.js.map +1 -0
  262. package/vendor/ink/build/components/Box.d.ts +130 -0
  263. package/vendor/ink/build/components/Box.js +34 -0
  264. package/vendor/ink/build/components/Box.js.map +1 -0
  265. package/vendor/ink/build/components/CursorContext.d.ts +11 -0
  266. package/vendor/ink/build/components/CursorContext.js +8 -0
  267. package/vendor/ink/build/components/CursorContext.js.map +1 -0
  268. package/vendor/ink/build/components/ErrorBoundary.d.ts +18 -0
  269. package/vendor/ink/build/components/ErrorBoundary.js +23 -0
  270. package/vendor/ink/build/components/ErrorBoundary.js.map +1 -0
  271. package/vendor/ink/build/components/ErrorOverview.d.ts +6 -0
  272. package/vendor/ink/build/components/ErrorOverview.js +90 -0
  273. package/vendor/ink/build/components/ErrorOverview.js.map +1 -0
  274. package/vendor/ink/build/components/FocusContext.d.ts +16 -0
  275. package/vendor/ink/build/components/FocusContext.js +17 -0
  276. package/vendor/ink/build/components/FocusContext.js.map +1 -0
  277. package/vendor/ink/build/components/Newline.d.ts +13 -0
  278. package/vendor/ink/build/components/Newline.js +8 -0
  279. package/vendor/ink/build/components/Newline.js.map +1 -0
  280. package/vendor/ink/build/components/Spacer.d.ts +7 -0
  281. package/vendor/ink/build/components/Spacer.js +11 -0
  282. package/vendor/ink/build/components/Spacer.js.map +1 -0
  283. package/vendor/ink/build/components/Static.d.ts +24 -0
  284. package/vendor/ink/build/components/Static.js +28 -0
  285. package/vendor/ink/build/components/Static.js.map +1 -0
  286. package/vendor/ink/build/components/StderrContext.d.ts +15 -0
  287. package/vendor/ink/build/components/StderrContext.js +13 -0
  288. package/vendor/ink/build/components/StderrContext.js.map +1 -0
  289. package/vendor/ink/build/components/StdinContext.d.ts +28 -0
  290. package/vendor/ink/build/components/StdinContext.js +20 -0
  291. package/vendor/ink/build/components/StdinContext.js.map +1 -0
  292. package/vendor/ink/build/components/StdoutContext.d.ts +15 -0
  293. package/vendor/ink/build/components/StdoutContext.js +13 -0
  294. package/vendor/ink/build/components/StdoutContext.js.map +1 -0
  295. package/vendor/ink/build/components/Text.d.ts +55 -0
  296. package/vendor/ink/build/components/Text.js +50 -0
  297. package/vendor/ink/build/components/Text.js.map +1 -0
  298. package/vendor/ink/build/components/Transform.d.ts +16 -0
  299. package/vendor/ink/build/components/Transform.js +15 -0
  300. package/vendor/ink/build/components/Transform.js.map +1 -0
  301. package/vendor/ink/build/cursor-helpers.d.ts +39 -0
  302. package/vendor/ink/build/cursor-helpers.js +62 -0
  303. package/vendor/ink/build/cursor-helpers.js.map +1 -0
  304. package/vendor/ink/build/devtools-window-polyfill.d.ts +1 -0
  305. package/vendor/ink/build/devtools-window-polyfill.js +68 -0
  306. package/vendor/ink/build/devtools-window-polyfill.js.map +1 -0
  307. package/vendor/ink/build/devtools.d.ts +1 -0
  308. package/vendor/ink/build/devtools.js +36 -0
  309. package/vendor/ink/build/devtools.js.map +1 -0
  310. package/vendor/ink/build/dom.d.ts +62 -0
  311. package/vendor/ink/build/dom.js +143 -0
  312. package/vendor/ink/build/dom.js.map +1 -0
  313. package/vendor/ink/build/get-max-width.d.ts +3 -0
  314. package/vendor/ink/build/get-max-width.js +10 -0
  315. package/vendor/ink/build/get-max-width.js.map +1 -0
  316. package/vendor/ink/build/hooks/use-animation.d.ts +49 -0
  317. package/vendor/ink/build/hooks/use-animation.js +87 -0
  318. package/vendor/ink/build/hooks/use-animation.js.map +1 -0
  319. package/vendor/ink/build/hooks/use-app.d.ts +5 -0
  320. package/vendor/ink/build/hooks/use-app.js +8 -0
  321. package/vendor/ink/build/hooks/use-app.js.map +1 -0
  322. package/vendor/ink/build/hooks/use-box-metrics.d.ts +59 -0
  323. package/vendor/ink/build/hooks/use-box-metrics.js +81 -0
  324. package/vendor/ink/build/hooks/use-box-metrics.js.map +1 -0
  325. package/vendor/ink/build/hooks/use-cursor.d.ts +12 -0
  326. package/vendor/ink/build/hooks/use-cursor.js +29 -0
  327. package/vendor/ink/build/hooks/use-cursor.js.map +1 -0
  328. package/vendor/ink/build/hooks/use-focus-manager.d.ts +43 -0
  329. package/vendor/ink/build/hooks/use-focus-manager.js +18 -0
  330. package/vendor/ink/build/hooks/use-focus-manager.js.map +1 -0
  331. package/vendor/ink/build/hooks/use-focus.d.ts +30 -0
  332. package/vendor/ink/build/hooks/use-focus.js +43 -0
  333. package/vendor/ink/build/hooks/use-focus.js.map +1 -0
  334. package/vendor/ink/build/hooks/use-input.d.ts +132 -0
  335. package/vendor/ink/build/hooks/use-input.js +126 -0
  336. package/vendor/ink/build/hooks/use-input.js.map +1 -0
  337. package/vendor/ink/build/hooks/use-is-screen-reader-enabled.d.ts +6 -0
  338. package/vendor/ink/build/hooks/use-is-screen-reader-enabled.js +12 -0
  339. package/vendor/ink/build/hooks/use-is-screen-reader-enabled.js.map +1 -0
  340. package/vendor/ink/build/hooks/use-paste.d.ts +35 -0
  341. package/vendor/ink/build/hooks/use-paste.js +62 -0
  342. package/vendor/ink/build/hooks/use-paste.js.map +1 -0
  343. package/vendor/ink/build/hooks/use-stderr.d.ts +5 -0
  344. package/vendor/ink/build/hooks/use-stderr.js +8 -0
  345. package/vendor/ink/build/hooks/use-stderr.js.map +1 -0
  346. package/vendor/ink/build/hooks/use-stdin.d.ts +7 -0
  347. package/vendor/ink/build/hooks/use-stdin.js +9 -0
  348. package/vendor/ink/build/hooks/use-stdin.js.map +1 -0
  349. package/vendor/ink/build/hooks/use-stdout.d.ts +5 -0
  350. package/vendor/ink/build/hooks/use-stdout.js +8 -0
  351. package/vendor/ink/build/hooks/use-stdout.js.map +1 -0
  352. package/vendor/ink/build/hooks/use-window-size.d.ts +18 -0
  353. package/vendor/ink/build/hooks/use-window-size.js +22 -0
  354. package/vendor/ink/build/hooks/use-window-size.js.map +1 -0
  355. package/vendor/ink/build/index.d.ts +42 -0
  356. package/vendor/ink/build/index.js +24 -0
  357. package/vendor/ink/build/index.js.map +1 -0
  358. package/vendor/ink/build/ink.d.ts +146 -0
  359. package/vendor/ink/build/ink.js +1022 -0
  360. package/vendor/ink/build/ink.js.map +1 -0
  361. package/vendor/ink/build/input-parser.d.ts +10 -0
  362. package/vendor/ink/build/input-parser.js +194 -0
  363. package/vendor/ink/build/input-parser.js.map +1 -0
  364. package/vendor/ink/build/instances.d.ts +3 -0
  365. package/vendor/ink/build/instances.js +8 -0
  366. package/vendor/ink/build/instances.js.map +1 -0
  367. package/vendor/ink/build/kitty-keyboard.d.ts +23 -0
  368. package/vendor/ink/build/kitty-keyboard.js +32 -0
  369. package/vendor/ink/build/kitty-keyboard.js.map +1 -0
  370. package/vendor/ink/build/log-update.d.ts +20 -0
  371. package/vendor/ink/build/log-update.js +261 -0
  372. package/vendor/ink/build/log-update.js.map +1 -0
  373. package/vendor/ink/build/measure-element.d.ts +20 -0
  374. package/vendor/ink/build/measure-element.js +13 -0
  375. package/vendor/ink/build/measure-element.js.map +1 -0
  376. package/vendor/ink/build/measure-text.d.ts +6 -0
  377. package/vendor/ink/build/measure-text.js +21 -0
  378. package/vendor/ink/build/measure-text.js.map +1 -0
  379. package/vendor/ink/build/output.d.ts +35 -0
  380. package/vendor/ink/build/output.js +328 -0
  381. package/vendor/ink/build/output.js.map +1 -0
  382. package/vendor/ink/build/parse-keypress.d.ts +20 -0
  383. package/vendor/ink/build/parse-keypress.js +495 -0
  384. package/vendor/ink/build/parse-keypress.js.map +1 -0
  385. package/vendor/ink/build/reconciler.d.ts +4 -0
  386. package/vendor/ink/build/reconciler.js +306 -0
  387. package/vendor/ink/build/reconciler.js.map +1 -0
  388. package/vendor/ink/build/render-background.d.ts +4 -0
  389. package/vendor/ink/build/render-background.js +25 -0
  390. package/vendor/ink/build/render-background.js.map +1 -0
  391. package/vendor/ink/build/render-border.d.ts +4 -0
  392. package/vendor/ink/build/render-border.js +84 -0
  393. package/vendor/ink/build/render-border.js.map +1 -0
  394. package/vendor/ink/build/render-node-to-output.d.ts +14 -0
  395. package/vendor/ink/build/render-node-to-output.js +162 -0
  396. package/vendor/ink/build/render-node-to-output.js.map +1 -0
  397. package/vendor/ink/build/render-to-string.d.ts +38 -0
  398. package/vendor/ink/build/render-to-string.js +116 -0
  399. package/vendor/ink/build/render-to-string.js.map +1 -0
  400. package/vendor/ink/build/render.d.ts +176 -0
  401. package/vendor/ink/build/render.js +71 -0
  402. package/vendor/ink/build/render.js.map +1 -0
  403. package/vendor/ink/build/renderer.d.ts +8 -0
  404. package/vendor/ink/build/renderer.js +64 -0
  405. package/vendor/ink/build/renderer.js.map +1 -0
  406. package/vendor/ink/build/sanitize-ansi.d.ts +2 -0
  407. package/vendor/ink/build/sanitize-ansi.js +27 -0
  408. package/vendor/ink/build/sanitize-ansi.js.map +1 -0
  409. package/vendor/ink/build/squash-text-nodes.d.ts +3 -0
  410. package/vendor/ink/build/squash-text-nodes.js +36 -0
  411. package/vendor/ink/build/squash-text-nodes.js.map +1 -0
  412. package/vendor/ink/build/styles.d.ts +302 -0
  413. package/vendor/ink/build/styles.js +303 -0
  414. package/vendor/ink/build/styles.js.map +1 -0
  415. package/vendor/ink/build/utils.d.ts +9 -0
  416. package/vendor/ink/build/utils.js +19 -0
  417. package/vendor/ink/build/utils.js.map +1 -0
  418. package/vendor/ink/build/wrap-text.d.ts +3 -0
  419. package/vendor/ink/build/wrap-text.js +38 -0
  420. package/vendor/ink/build/wrap-text.js.map +1 -0
  421. package/vendor/ink/build/write-synchronized.d.ts +4 -0
  422. package/vendor/ink/build/write-synchronized.js +9 -0
  423. package/vendor/ink/build/write-synchronized.js.map +1 -0
  424. package/vendor/ink/license +10 -0
  425. package/vendor/ink/package.json +137 -0
  426. package/.claude-plugin/marketplace.json +0 -34
  427. package/.claude-plugin/plugin.json +0 -20
  428. package/.gitattributes +0 -34
  429. package/.mcp.json +0 -14
  430. package/ARCHITECTURE.md +0 -77
  431. package/CHANGELOG.md +0 -30
  432. package/CONTRIBUTING.md +0 -45
  433. package/DATA-FLOW.md +0 -79
  434. package/LICENSE +0 -21
  435. package/SECURITY.md +0 -138
  436. package/UNINSTALL.md +0 -115
  437. package/agents/maintenance.md +0 -5
  438. package/agents/memory-classification.md +0 -30
  439. package/agents/scheduler-task.md +0 -18
  440. package/agents/webhook-handler.md +0 -27
  441. package/agents/worker.md +0 -24
  442. package/bin/bridge +0 -133
  443. package/bin/statusline-launcher.mjs +0 -82
  444. package/bin/statusline-lib.mjs +0 -581
  445. package/bin/statusline-route.mjs +0 -273
  446. package/bin/statusline.mjs +0 -638
  447. package/bun.lock +0 -927
  448. package/commands/config.md +0 -16
  449. package/commands/doctor.md +0 -13
  450. package/commands/model.md +0 -61
  451. package/commands/setup.md +0 -17
  452. package/defaults/hidden-roles.json +0 -68
  453. package/defaults/memory-chunk-prompt.md +0 -63
  454. package/defaults/mixdog-config.template.json +0 -27
  455. package/defaults/user-workflow.json +0 -8
  456. package/defaults/user-workflow.md +0 -17
  457. package/hooks/hooks.json +0 -73
  458. package/hooks/lib/active-instance.cjs +0 -77
  459. package/hooks/lib/permission-evaluator.cjs +0 -411
  460. package/hooks/lib/permission-route.cjs +0 -63
  461. package/hooks/lib/settings-loader.cjs +0 -117
  462. package/hooks/post-tool-use.cjs +0 -84
  463. package/hooks/pre-mcp-sandbox.cjs +0 -158
  464. package/hooks/pre-tool-subagent.cjs +0 -258
  465. package/hooks/session-start.cjs +0 -1493
  466. package/hooks/shim-launcher.cjs +0 -65
  467. package/hooks/turn-timer.cjs +0 -82
  468. package/lib/claude-md-writer.cjs +0 -386
  469. package/lib/keychain-cjs.cjs +0 -290
  470. package/lib/plugin-paths.cjs +0 -69
  471. package/lib/rules-builder.cjs +0 -241
  472. package/native/README.md +0 -117
  473. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  474. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  475. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  476. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  477. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  478. package/prompts/code-review.txt +0 -16
  479. package/prompts/security-audit.txt +0 -17
  480. package/rules/bridge/00-common.md +0 -39
  481. package/rules/bridge/20-skip-protocol.md +0 -18
  482. package/rules/bridge/30-explorer.md +0 -33
  483. package/rules/bridge/40-cycle1-agent.md +0 -52
  484. package/rules/bridge/41-cycle2-agent.md +0 -62
  485. package/rules/lead/00-tool-lead.md +0 -61
  486. package/rules/lead/01-general.md +0 -26
  487. package/rules/lead/02-channels.md +0 -49
  488. package/rules/lead/03-team.md +0 -27
  489. package/rules/lead/04-workflow.md +0 -20
  490. package/rules/shared/00-language.md +0 -14
  491. package/rules/shared/01-tool.md +0 -138
  492. package/scripts/bootstrap.mjs +0 -130
  493. package/scripts/bridge-unify-smoke.mjs +0 -308
  494. package/scripts/build-runtime-linux.sh +0 -348
  495. package/scripts/build-runtime-macos.sh +0 -217
  496. package/scripts/build-runtime-windows.ps1 +0 -242
  497. package/scripts/builtin-utils-smoke.mjs +0 -398
  498. package/scripts/bump.mjs +0 -80
  499. package/scripts/check-json.mjs +0 -45
  500. package/scripts/check-syntax-changed.mjs +0 -102
  501. package/scripts/check-syntax.mjs +0 -58
  502. package/scripts/code-graph-batch.test.mjs +0 -33
  503. package/scripts/config-preserve-smoke.mjs +0 -180
  504. package/scripts/doctor.mjs +0 -489
  505. package/scripts/edit-normalize-fuzz.mjs +0 -130
  506. package/scripts/edit-normalize-smoke.mjs +0 -401
  507. package/scripts/edit-operation-smoke.mjs +0 -369
  508. package/scripts/edit2-smoke.mjs +0 -63
  509. package/scripts/ensure-deps.mjs +0 -259
  510. package/scripts/fuzzy-e2e.mjs +0 -28
  511. package/scripts/fuzzy-smoke.mjs +0 -26
  512. package/scripts/gateway-model.mjs +0 -596
  513. package/scripts/generate-runtime-manifest.mjs +0 -166
  514. package/scripts/guard-smoke.mjs +0 -66
  515. package/scripts/hidden-role-schema-smoke.mjs +0 -162
  516. package/scripts/hook-routing-smoke.mjs +0 -29
  517. package/scripts/inject-input.ps1 +0 -204
  518. package/scripts/io-complex-smoke.mjs +0 -667
  519. package/scripts/io-explore-bench.mjs +0 -424
  520. package/scripts/io-guardrails-smoke.mjs +0 -205
  521. package/scripts/io-mini-bench-baseline.json +0 -11
  522. package/scripts/io-mini-bench.mjs +0 -216
  523. package/scripts/io-route-harness.mjs +0 -933
  524. package/scripts/io-telemetry-report.mjs +0 -691
  525. package/scripts/lib/gateway-inventory.mjs +0 -178
  526. package/scripts/lib/gateway-settings.mjs +0 -78
  527. package/scripts/mutation-bench.mjs +0 -564
  528. package/scripts/mutation-io-smoke.mjs +0 -1097
  529. package/scripts/native-patch-bridge-smoke.mjs +0 -288
  530. package/scripts/native-patch-smoke.mjs +0 -304
  531. package/scripts/openai-oauth-catalog-smoke.mjs +0 -53
  532. package/scripts/patch-interior-context-smoke.mjs +0 -49
  533. package/scripts/patch-newline-utf8-smoke.mjs +0 -157
  534. package/scripts/perf-hook-smoke.mjs +0 -71
  535. package/scripts/permission-eval-smoke.mjs +0 -443
  536. package/scripts/prep-patch.mjs +0 -53
  537. package/scripts/prep-shim.mjs +0 -96
  538. package/scripts/provider-cache-smoke.mjs +0 -687
  539. package/scripts/report-runtime-health.mjs +0 -132
  540. package/scripts/resolve-bun.mjs +0 -60
  541. package/scripts/run-mcp.mjs +0 -1473
  542. package/scripts/salvage-v4a-shatter.test.mjs +0 -58
  543. package/scripts/scoped-cache-io-smoke.mjs +0 -103
  544. package/scripts/shell-policy-round3-smoke.mjs +0 -46
  545. package/scripts/smoke-runtime-negative.ps1 +0 -100
  546. package/scripts/smoke-runtime-negative.sh +0 -95
  547. package/scripts/stall-policy-smoke.mjs +0 -50
  548. package/scripts/start-memory-worker.mjs +0 -23
  549. package/scripts/statusline-launcher-smoke.mjs +0 -235
  550. package/scripts/stress-atomic-write.mjs +0 -1028
  551. package/scripts/test-fault-inject.mjs +0 -164
  552. package/scripts/test-large-file.mjs +0 -174
  553. package/scripts/tool-edge-smoke.mjs +0 -209
  554. package/scripts/uninstall.mjs +0 -238
  555. package/scripts/webhook-selfheal-smoke.mjs +0 -27
  556. package/scripts/write-overwrite-guard-smoke.mjs +0 -56
  557. package/server-main.mjs +0 -3350
  558. package/server.mjs +0 -468
  559. package/setup/config-merge.mjs +0 -246
  560. package/setup/install.mjs +0 -574
  561. package/setup/launch-core.mjs +0 -617
  562. package/setup/launch.mjs +0 -101
  563. package/setup/locate-claude.mjs +0 -56
  564. package/setup/mixdog-cli.mjs +0 -122
  565. package/setup/setup-server.mjs +0 -3305
  566. package/setup/setup.html +0 -3740
  567. package/setup/tui.mjs +0 -325
  568. package/skills/retro-skill-proposer/SKILL.md +0 -92
  569. package/skills/schedule-add/SKILL.md +0 -77
  570. package/skills/setup/SKILL.md +0 -356
  571. package/skills/webhook-add/SKILL.md +0 -81
  572. package/src/agent/bridge-stall-watchdog.mjs +0 -337
  573. package/src/agent/index.mjs +0 -2229
  574. package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +0 -1010
  575. package/src/agent/orchestrator/bridge-retry.mjs +0 -220
  576. package/src/agent/orchestrator/bridge-trace.mjs +0 -601
  577. package/src/agent/orchestrator/cache-mtime.mjs +0 -58
  578. package/src/agent/orchestrator/config.mjs +0 -405
  579. package/src/agent/orchestrator/context/collect.mjs +0 -651
  580. package/src/agent/orchestrator/dispatch-persist.mjs +0 -549
  581. package/src/agent/orchestrator/drain-registry.mjs +0 -50
  582. package/src/agent/orchestrator/explore-validator.mjs +0 -8
  583. package/src/agent/orchestrator/internal-roles.mjs +0 -118
  584. package/src/agent/orchestrator/internal-tools.mjs +0 -88
  585. package/src/agent/orchestrator/jobs.mjs +0 -116
  586. package/src/agent/orchestrator/mcp/client.mjs +0 -364
  587. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +0 -1884
  588. package/src/agent/orchestrator/providers/anthropic.mjs +0 -598
  589. package/src/agent/orchestrator/providers/gemini.mjs +0 -1530
  590. package/src/agent/orchestrator/providers/grok-oauth.mjs +0 -779
  591. package/src/agent/orchestrator/providers/model-catalog.mjs +0 -374
  592. package/src/agent/orchestrator/providers/openai-compat-stream.mjs +0 -366
  593. package/src/agent/orchestrator/providers/openai-compat.mjs +0 -1511
  594. package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1891
  595. package/src/agent/orchestrator/providers/openai-oauth.mjs +0 -1456
  596. package/src/agent/orchestrator/providers/openai-ws.mjs +0 -127
  597. package/src/agent/orchestrator/providers/registry.mjs +0 -192
  598. package/src/agent/orchestrator/providers/retry-classifier.mjs +0 -325
  599. package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +0 -42
  600. package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +0 -142
  601. package/src/agent/orchestrator/session/cache/read-cache.mjs +0 -319
  602. package/src/agent/orchestrator/session/cache/scoped-cache.mjs +0 -361
  603. package/src/agent/orchestrator/session/loop.mjs +0 -1619
  604. package/src/agent/orchestrator/session/manager.mjs +0 -1991
  605. package/src/agent/orchestrator/session/result-classification.mjs +0 -65
  606. package/src/agent/orchestrator/session/store.mjs +0 -632
  607. package/src/agent/orchestrator/session/stream-watchdog.mjs +0 -130
  608. package/src/agent/orchestrator/session/tool-result-offload.mjs +0 -166
  609. package/src/agent/orchestrator/session/trim.mjs +0 -491
  610. package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +0 -115
  611. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +0 -331
  612. package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +0 -150
  613. package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +0 -228
  614. package/src/agent/orchestrator/smart-bridge/index.mjs +0 -215
  615. package/src/agent/orchestrator/smart-bridge/profiles.mjs +0 -37
  616. package/src/agent/orchestrator/smart-bridge/registry.mjs +0 -348
  617. package/src/agent/orchestrator/stall-policy.mjs +0 -201
  618. package/src/agent/orchestrator/tool-loop-guard.mjs +0 -75
  619. package/src/agent/orchestrator/tools/bash-session.mjs +0 -722
  620. package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +0 -171
  621. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +0 -511
  622. package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +0 -480
  623. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +0 -256
  624. package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +0 -58
  625. package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +0 -240
  626. package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +0 -113
  627. package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +0 -74
  628. package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +0 -242
  629. package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +0 -211
  630. package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +0 -1364
  631. package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +0 -126
  632. package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +0 -141
  633. package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +0 -194
  634. package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +0 -60
  635. package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +0 -168
  636. package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +0 -173
  637. package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +0 -48
  638. package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -99
  639. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +0 -193
  640. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +0 -597
  641. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  642. package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +0 -300
  643. package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +0 -152
  644. package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +0 -118
  645. package/src/agent/orchestrator/tools/builtin/read-open.mjs +0 -190
  646. package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +0 -728
  647. package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +0 -173
  648. package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +0 -602
  649. package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +0 -196
  650. package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +0 -422
  651. package/src/agent/orchestrator/tools/builtin/search-builders.mjs +0 -158
  652. package/src/agent/orchestrator/tools/builtin/search-tool.mjs +0 -869
  653. package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +0 -962
  654. package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +0 -223
  655. package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +0 -206
  656. package/src/agent/orchestrator/tools/builtin/write-tool.mjs +0 -401
  657. package/src/agent/orchestrator/tools/builtin.mjs +0 -503
  658. package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +0 -24
  659. package/src/agent/orchestrator/tools/code-graph.mjs +0 -4095
  660. package/src/agent/orchestrator/tools/cwd-tool.mjs +0 -298
  661. package/src/agent/orchestrator/tools/destructive-warning.mjs +0 -323
  662. package/src/agent/orchestrator/tools/edit-normalize.mjs +0 -603
  663. package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +0 -154
  664. package/src/agent/orchestrator/tools/graph-manifest.json +0 -26
  665. package/src/agent/orchestrator/tools/host-input.mjs +0 -204
  666. package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +0 -143
  667. package/src/agent/orchestrator/tools/patch-manifest.json +0 -26
  668. package/src/agent/orchestrator/tools/patch-tool-defs.mjs +0 -20
  669. package/src/agent/orchestrator/tools/patch.mjs +0 -2754
  670. package/src/agent/orchestrator/tools/progress-message.mjs +0 -118
  671. package/src/agent/orchestrator/tools/shell-command.mjs +0 -865
  672. package/src/agent/orchestrator/tools/shell-policy-imports.mjs +0 -7
  673. package/src/agent/orchestrator/tools/shell-snapshot.mjs +0 -313
  674. package/src/agent/orchestrator/workflow-store.mjs +0 -93
  675. package/src/agent/tool-defs.mjs +0 -110
  676. package/src/channels/backends/discord.mjs +0 -784
  677. package/src/channels/data/voice-runtime-manifest.json +0 -138
  678. package/src/channels/index.mjs +0 -3268
  679. package/src/channels/lib/config.mjs +0 -292
  680. package/src/channels/lib/drop-trace.mjs +0 -71
  681. package/src/channels/lib/event-pipeline.mjs +0 -81
  682. package/src/channels/lib/holidays.mjs +0 -138
  683. package/src/channels/lib/hook-pipe-server.mjs +0 -822
  684. package/src/channels/lib/output-forwarder.mjs +0 -765
  685. package/src/channels/lib/runtime-paths.mjs +0 -552
  686. package/src/channels/lib/scheduler.mjs +0 -723
  687. package/src/channels/lib/session-discovery.mjs +0 -103
  688. package/src/channels/lib/state-file.mjs +0 -68
  689. package/src/channels/lib/status-snapshot.mjs +0 -219
  690. package/src/channels/lib/tool-format.mjs +0 -140
  691. package/src/channels/lib/transcript-discovery.mjs +0 -195
  692. package/src/channels/lib/voice-runtime-fetcher.mjs +0 -734
  693. package/src/channels/lib/webhook.mjs +0 -1318
  694. package/src/channels/tool-defs.mjs +0 -170
  695. package/src/daemon/host.mjs +0 -118
  696. package/src/daemon/mcp-transport.mjs +0 -47
  697. package/src/daemon/session.mjs +0 -100
  698. package/src/daemon/thin-client.mjs +0 -71
  699. package/src/daemon/transport.mjs +0 -163
  700. package/src/gateway/claude-current.mjs +0 -255
  701. package/src/gateway/oauth-usage.mjs +0 -598
  702. package/src/gateway/route-meta.mjs +0 -629
  703. package/src/gateway/server.mjs +0 -713
  704. package/src/memory/data/runtime-manifest.json +0 -40
  705. package/src/memory/index.mjs +0 -3332
  706. package/src/memory/lib/core-memory-store.mjs +0 -330
  707. package/src/memory/lib/embedding-provider.mjs +0 -269
  708. package/src/memory/lib/embedding-worker.mjs +0 -323
  709. package/src/memory/lib/memory-cycle1.mjs +0 -645
  710. package/src/memory/lib/memory-cycle2.mjs +0 -1284
  711. package/src/memory/lib/memory-cycle3.mjs +0 -540
  712. package/src/memory/lib/memory-embed.mjs +0 -299
  713. package/src/memory/lib/memory-ops-policy.mjs +0 -190
  714. package/src/memory/lib/memory-recall-store.mjs +0 -638
  715. package/src/memory/lib/memory.mjs +0 -412
  716. package/src/memory/lib/pg/adapter.mjs +0 -308
  717. package/src/memory/lib/pg/process.mjs +0 -360
  718. package/src/memory/lib/pg/supervisor.mjs +0 -396
  719. package/src/memory/lib/runtime-fetcher.mjs +0 -458
  720. package/src/memory/lib/trace-store.mjs +0 -728
  721. package/src/memory/tool-defs.mjs +0 -79
  722. package/src/search/index.mjs +0 -1173
  723. package/src/search/lib/backends/anthropic-oauth.mjs +0 -98
  724. package/src/search/lib/backends/exa.mjs +0 -50
  725. package/src/search/lib/backends/firecrawl.mjs +0 -61
  726. package/src/search/lib/backends/gemini-api.mjs +0 -83
  727. package/src/search/lib/backends/grok-oauth.mjs +0 -86
  728. package/src/search/lib/backends/index.mjs +0 -150
  729. package/src/search/lib/backends/openai-api.mjs +0 -144
  730. package/src/search/lib/backends/openai-oauth.mjs +0 -102
  731. package/src/search/lib/backends/openai-web-search.mjs +0 -76
  732. package/src/search/lib/backends/tavily.mjs +0 -55
  733. package/src/search/lib/backends/xai-api.mjs +0 -113
  734. package/src/search/lib/config.mjs +0 -192
  735. package/src/search/lib/provider-usage.mjs +0 -67
  736. package/src/search/lib/providers.mjs +0 -47
  737. package/src/search/lib/search-intent.mjs +0 -109
  738. package/src/search/lib/setup-handler.mjs +0 -261
  739. package/src/search/lib/web-tools.mjs +0 -1219
  740. package/src/search/tool-defs.mjs +0 -83
  741. package/src/setup/defender-exclusion.mjs +0 -183
  742. package/src/shared/atomic-file.mjs +0 -436
  743. package/src/shared/config.mjs +0 -372
  744. package/src/shared/daemon-recycle.mjs +0 -108
  745. package/src/shared/disable-claude-builtins.mjs +0 -91
  746. package/src/shared/err-text.mjs +0 -12
  747. package/src/shared/llm/http-agent.mjs +0 -123
  748. package/src/shared/open-url.mjs +0 -62
  749. package/src/shared/plugin-paths.mjs +0 -58
  750. package/src/shared/schedules-store.mjs +0 -70
  751. package/src/shared/seed.mjs +0 -161
  752. package/src/shared/user-cwd.mjs +0 -225
  753. package/src/shared/user-data-guard.mjs +0 -244
  754. package/src/status/aggregator.mjs +0 -584
  755. package/src/status/server.mjs +0 -413
  756. package/tools.json +0 -1671
  757. /package/{defaults → src/defaults}/cycle3-review-prompt.md +0 -0
  758. /package/{defaults → src/defaults}/memory-promote-prompt.md +0 -0
  759. /package/{hooks → src/hooks}/lib/permission-rules.cjs +0 -0
  760. /package/{lib → src/lib}/config-cjs.cjs +0 -0
  761. /package/{lib → src/lib}/hook-pipe-path.cjs +0 -0
  762. /package/{lib → src/lib}/mixdog-debug.cjs +0 -0
  763. /package/{lib → src/lib}/text-utils.cjs +0 -0
  764. /package/{rules → src/rules}/bridge/42-cycle3-agent.md +0 -0
  765. /package/src/{agent → runtime/agent}/orchestrator/activity-bus.mjs +0 -0
  766. /package/src/{agent → runtime/agent}/orchestrator/providers/anthropic-betas.mjs +0 -0
  767. /package/src/{agent → runtime/agent}/orchestrator/session/abort-lookup.mjs +0 -0
  768. /package/src/{agent → runtime/agent}/orchestrator/session/cache/scoped-cache-outcome.mjs +0 -0
  769. /package/src/{agent → runtime/agent}/orchestrator/session/cache/util.mjs +0 -0
  770. /package/src/{agent → runtime/agent}/orchestrator/session/read-dedup.mjs +0 -0
  771. /package/src/{agent → runtime/agent}/orchestrator/session/save-session-worker.mjs +0 -0
  772. /package/src/{agent → runtime/agent}/orchestrator/smart-bridge/session-builder.mjs +0 -0
  773. /package/src/{agent → runtime/agent}/orchestrator/tools/bash-policy-scan.mjs +0 -0
  774. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/atomic-write.mjs +0 -0
  775. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/binary-file.mjs +0 -0
  776. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/cache-layers.mjs +0 -0
  777. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/cwd-utils.mjs +0 -0
  778. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/device-paths.mjs +0 -0
  779. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -0
  780. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/diff-utils.mjs +0 -0
  781. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/fs-reachability.mjs +0 -0
  782. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/grep-formatting.mjs +0 -0
  783. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/hash-utils.mjs +0 -0
  784. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/list-formatting.mjs +0 -0
  785. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/open-config-tool.mjs +0 -0
  786. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/path-locks.mjs +0 -0
  787. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/path-utils.mjs +0 -0
  788. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-args.mjs +0 -0
  789. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-batch.mjs +0 -0
  790. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-constants.mjs +0 -0
  791. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-image-resize.mjs +0 -0
  792. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-image.mjs +0 -0
  793. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-lines.mjs +0 -0
  794. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-mode-tool.mjs +0 -0
  795. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-range-index.mjs +0 -0
  796. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-ranges.mjs +0 -0
  797. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-special-files.mjs +0 -0
  798. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-tool.mjs +0 -0
  799. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/read-windows.mjs +0 -0
  800. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/shell-analysis.mjs +0 -0
  801. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/shell-output.mjs +0 -0
  802. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/snapshot-helpers.mjs +0 -0
  803. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/snapshot-validation.mjs +0 -0
  804. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/text-stats.mjs +0 -0
  805. /package/src/{agent → runtime/agent}/orchestrator/tools/builtin/windows-roots.mjs +0 -0
  806. /package/src/{agent → runtime/agent}/orchestrator/tools/code-graph-prewarm-worker.mjs +0 -0
  807. /package/src/{agent → runtime/agent}/orchestrator/tools/env-scrub.mjs +0 -0
  808. /package/src/{agent → runtime/agent}/orchestrator/tools/mutation-content-cache.mjs +0 -0
  809. /package/src/{agent → runtime/agent}/orchestrator/tools/mutation-planner.mjs +0 -0
  810. /package/src/{agent → runtime/agent}/orchestrator/tools/next-call-utils.mjs +0 -0
  811. /package/src/{agent → runtime/agent}/orchestrator/tools/result-compression.mjs +0 -0
  812. /package/src/{agent → runtime/agent}/orchestrator/tools/shell-exec-policy.mjs +0 -0
  813. /package/src/{agent → runtime/agent}/orchestrator/tools/shell-policy-danger-target.mjs +0 -0
  814. /package/src/{agent → runtime/agent}/orchestrator/tools/shell-policy.mjs +0 -0
  815. /package/src/{channels → runtime/channels}/lib/cli-worker-host.mjs +0 -0
  816. /package/src/{channels → runtime/channels}/lib/config-lock.mjs +0 -0
  817. /package/src/{channels → runtime/channels}/lib/event-queue.mjs +0 -0
  818. /package/src/{channels → runtime/channels}/lib/executor.mjs +0 -0
  819. /package/src/{channels → runtime/channels}/lib/format.mjs +0 -0
  820. /package/src/{channels → runtime/channels}/lib/interaction-workflows.mjs +0 -0
  821. /package/src/{channels → runtime/channels}/lib/memory-client.mjs +0 -0
  822. /package/src/{channels → runtime/channels}/lib/session-control.mjs +0 -0
  823. /package/src/{channels → runtime/channels}/lib/settings.mjs +0 -0
  824. /package/src/{channels → runtime/channels}/lib/whisper-server.mjs +0 -0
  825. /package/src/{memory → runtime/memory}/lib/agent-ipc.mjs +0 -0
  826. /package/src/{memory → runtime/memory}/lib/bridge-trace-queries.mjs +0 -0
  827. /package/src/{memory → runtime/memory}/lib/llm-worker-host.mjs +0 -0
  828. /package/src/{memory → runtime/memory}/lib/memory-cycle.mjs +0 -0
  829. /package/src/{memory → runtime/memory}/lib/memory-extraction.mjs +0 -0
  830. /package/src/{memory → runtime/memory}/lib/memory-maintenance-store.mjs +0 -0
  831. /package/src/{memory → runtime/memory}/lib/memory-recall-id-patch.mjs +0 -0
  832. /package/src/{memory → runtime/memory}/lib/memory-recall-read-query.mjs +0 -0
  833. /package/src/{memory → runtime/memory}/lib/memory-recall-scope-filter.mjs +0 -0
  834. /package/src/{memory → runtime/memory}/lib/memory-retrievers.mjs +0 -0
  835. /package/src/{memory → runtime/memory}/lib/memory-score.mjs +0 -0
  836. /package/src/{memory → runtime/memory}/lib/memory-text-utils.mjs +0 -0
  837. /package/src/{memory → runtime/memory}/lib/model-profile.mjs +0 -0
  838. /package/src/{memory → runtime/memory}/lib/project-id-resolver.mjs +0 -0
  839. /package/src/{search → runtime/search}/lib/cache.mjs +0 -0
  840. /package/src/{search → runtime/search}/lib/formatter.mjs +0 -0
  841. /package/src/{search → runtime/search}/lib/state.mjs +0 -0
  842. /package/src/{shared → runtime/shared}/abort-controller.mjs +0 -0
  843. /package/src/{shared → runtime/shared}/llm/cost.mjs +0 -0
  844. /package/src/{shared → runtime/shared}/llm/index.mjs +0 -0
  845. /package/src/{shared → runtime/shared}/llm/pid-cleanup.mjs +0 -0
  846. /package/src/{shared → runtime/shared}/llm/usage-log.mjs +0 -0
  847. /package/src/{shared → runtime/shared}/wsl.mjs +0 -0
@@ -0,0 +1,4102 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { resolve as pathResolve, isAbsolute, dirname, relative as pathRelative, join } from 'node:path';
3
+ import { readdirSync, readFileSync, statSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
4
+ import { Worker } from 'node:worker_threads';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ normalizeInputPath,
8
+ toDisplayPath,
9
+ } from './builtin.mjs';
10
+ import { getPluginData } from '../config.mjs';
11
+ import { ensureGraphBinary, findCachedGraphBinary } from './graph-binary-fetcher.mjs';
12
+ import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
13
+ import { CODE_GRAPH_TOOL_DEFS } from './code-graph-tool-defs.mjs';
14
+ import { markScopedCacheIncomplete } from '../session/cache/scoped-cache-outcome.mjs';
15
+
16
+ const CODE_GRAPH_TTL_MS = 30_000;
17
+ const CODE_GRAPH_MAX_FILES = 10_000;
18
+ const CODE_GRAPH_WORKER_TIMEOUT_MS = 120_000;
19
+ // Legacy single-file cache. Kept as a constant for the one-shot migration
20
+ // path; new writes go into the per-cwd directory layout below.
21
+ const CODE_GRAPH_DISK_FILE = 'code-graph-cache.json';
22
+ // Per-cwd cache: <data>/code-graph-cache/manifest.json + <hash>.json per
23
+ // indexed root. Avoids the unbounded single-file blob (observed >50 MB on
24
+ // long-running workspaces) that had to be JSON.parsed in full on every
25
+ // fresh process startup.
26
+ const CODE_GRAPH_DISK_DIR = 'code-graph-cache';
27
+ const CODE_GRAPH_DISK_MAX_ENTRIES = 24;
28
+ const CODE_GRAPH_DISK_MAX_BYTES = Math.max(
29
+ 1 * 1024 * 1024,
30
+ Math.floor((Number(process.env.MIXDOG_CODE_GRAPH_CACHE_MAX_MB) || 80) * 1024 * 1024),
31
+ );
32
+ const CODE_GRAPH_MEMORY_MAX_ENTRIES = Math.max(
33
+ 1,
34
+ Math.floor(Number(process.env.MIXDOG_CODE_GRAPH_MEMORY_MAX_ENTRIES) || 6),
35
+ );
36
+ const CODE_GRAPH_MEMORY_MAX_SOURCE_BYTES = Math.max(
37
+ 1 * 1024 * 1024,
38
+ Math.floor((Number(process.env.MIXDOG_CODE_GRAPH_MEMORY_MAX_MB) || 48) * 1024 * 1024),
39
+ );
40
+ const _codeGraphCache = new Map();
41
+ const _diskCodeGraphCache = new Map();
42
+ const _codeGraphDirtyPaths = new Map();
43
+ let _diskCodeGraphCacheLoaded = false;
44
+ let _diskCodeGraphCacheFlushTimer = null;
45
+ // Per-cwd manifest read at boot; per-cwd entries load on demand via
46
+ // _ensureCwdLoaded(cwd). Avoids the cold-start I/O spike that hit every
47
+ // fresh process when the legacy single-file cache grew unbounded.
48
+ let _diskManifest = null;
49
+ // In-flight async builds keyed by canonical graphCwd. Same-cwd parallel
50
+ // callers (prewarm + cache-miss + multiple find_symbol) share one Worker
51
+ // spawn instead of fanning out. Entry removed on settle so the next caller
52
+ // after a failure can retry.
53
+ const _inflightAsyncBuilds = new Map();
54
+ // Dirty-generation guard. markCodeGraphDirtyPaths() bumps the per-cwd
55
+ // counter every time a write invalidates a root. A build captures the
56
+ // generation at start and only commits its result (in-memory + disk) if
57
+ // the generation is unchanged on completion. An in-flight worker build
58
+ // that started BEFORE a write would otherwise repopulate _codeGraphCache
59
+ // with the stale pre-edit graph AFTER markCodeGraphDirtyPaths cleared it,
60
+ // and the TTL fast path would then serve that stale graph for up to
61
+ // CODE_GRAPH_TTL_MS.
62
+ const _codeGraphDirtyGen = new Map();
63
+ function _getCodeGraphGen(graphCwd) {
64
+ return _codeGraphDirtyGen.get(graphCwd) || 0;
65
+ }
66
+ function _bumpCodeGraphGen(graphCwd) {
67
+ _codeGraphDirtyGen.set(graphCwd, (_codeGraphDirtyGen.get(graphCwd) || 0) + 1);
68
+ }
69
+
70
+ function _codeGraphDiskDir() {
71
+ return join(getPluginData(), CODE_GRAPH_DISK_DIR);
72
+ }
73
+
74
+ function _hashCwd(cwd) {
75
+ // Memoize SHA256(canonical cwd) — the same canonical cwd is hashed
76
+ // repeatedly on persist/sweep hot paths. Keyed by canonical cwd; capped
77
+ // so a long-lived process cycling through many cwds can't grow unbounded.
78
+ const canon = _canonicalGraphCwd(cwd);
79
+ const cached = _hashCwdCache.get(canon);
80
+ if (cached !== undefined) return cached;
81
+ const hash = createHash('sha256').update(canon).digest('hex').slice(0, 16);
82
+ if (_hashCwdCache.size >= _HASH_CWD_CACHE_MAX) {
83
+ // Evict oldest insertion (Map preserves insertion order).
84
+ _hashCwdCache.delete(_hashCwdCache.keys().next().value);
85
+ }
86
+ _hashCwdCache.set(canon, hash);
87
+ return hash;
88
+ }
89
+
90
+ const _HASH_CWD_CACHE_MAX = 50;
91
+ const _hashCwdCache = new Map();
92
+
93
+ function _migrateLegacyDiskCache() {
94
+ const legacy = join(getPluginData(), CODE_GRAPH_DISK_FILE);
95
+ if (!existsSync(legacy)) return;
96
+ try {
97
+ const parsed = JSON.parse(readFileSync(legacy, 'utf8'));
98
+ if (parsed && typeof parsed === 'object') {
99
+ for (const [cwd, entry] of Object.entries(parsed)) {
100
+ if (!entry || typeof entry !== 'object') continue;
101
+ _diskCodeGraphCache.set(_canonicalGraphCwd(cwd), entry);
102
+ }
103
+ }
104
+ // Rename rather than delete so a rollback can recover the blob if the
105
+ // per-cwd layout misbehaves. Next persist round writes the new layout
106
+ // and the legacy path no longer exists, so this branch is a one-shot.
107
+ renameSync(legacy, `${legacy}.bak-${Date.now()}`);
108
+ // Schedule an immediate flush so the in-memory entries we just loaded
109
+ // get written out as per-cwd files now, instead of waiting for the
110
+ // next graph rebuild to trigger _setDiskCodeGraphEntry. Without this,
111
+ // the layout transition is half-complete (legacy renamed, new layout
112
+ // empty) until an unrelated build happens to land.
113
+ _scheduleDiskCodeGraphCacheFlush();
114
+ } catch (err) {
115
+ process.stderr.write(`[code-graph] legacy cache migration failed: ${err?.message || err}\n`);
116
+ }
117
+ }
118
+
119
+ function _canonicalGraphCwd(cwd) {
120
+ if (!cwd) throw new Error('code_graph requires cwd — caller did not provide a working directory');
121
+ const full = pathResolve(cwd);
122
+ return process.platform === 'win32' ? full.toLowerCase() : full;
123
+ }
124
+
125
+ function _canonicalGraphPath(p) {
126
+ const full = pathResolve(String(p || ''));
127
+ return process.platform === 'win32' ? full.toLowerCase() : full;
128
+ }
129
+
130
+ // Bump when the per-symbol record SHAPE changes (e.g. adding endLine). The
131
+ // version is folded into the cache signature so graphs built by an older
132
+ // binary/schema (symbols without a finite endLine) no longer match and are
133
+ // rebuilt instead of served — otherwise a stale cache would feed endLine-less
134
+ // symbols and silently defeat body-span containment in _nearestEnclosingSymbol.
135
+ const SYMBOL_SCHEMA_VERSION = 'sym-range-v3-rustimports';
136
+ function _computeGraphSignature(fileMetas) {
137
+ const hash = createHash('sha1');
138
+ hash.update(`${SYMBOL_SCHEMA_VERSION}\n`);
139
+ // R5-③: include rel/path alongside fp so renames and path-swaps (same
140
+ // bytes moved to a different rel, or two files exchanging paths) flip
141
+ // the signature and invalidate the memory/disk cache checks at the
142
+ // call sites just below in buildCodeGraphAsync. Without rel, an fp-only
143
+ // hash collides across rename pairs and the cache serves stale graph
144
+ // topology where node.rel no longer matches what's on disk.
145
+ for (const meta of fileMetas) hash.update(`${meta.rel || ''}\0${meta.fp}\n`);
146
+ return hash.digest('hex');
147
+ }
148
+
149
+ function _serializeGraph(graph) {
150
+ // Compact-on-disk: omit empty / falsy fields. Saves ~30-50% on disk
151
+ // for typical mixed-language graphs because most nodes don't carry
152
+ // packageName / namespaceName / topLevelTypes. Smaller
153
+ // payload → faster JSON.parse on cold-process boot. _deserializeGraph
154
+ // tolerates missing fields by defaulting to '' / [].
155
+ return {
156
+ schemaVersion: SYMBOL_SCHEMA_VERSION,
157
+ builtAt: Number(graph?.builtAt || Date.now()),
158
+ signature: String(graph?.signature || ''),
159
+ truncated: Boolean(graph?.truncated),
160
+ maxFiles: CODE_GRAPH_MAX_FILES,
161
+ nodes: [...(graph?.nodes?.values?.() || [])].map((node) => {
162
+ const out = {
163
+ rel: node.rel,
164
+ lang: node.lang,
165
+ };
166
+ if (node.fingerprint) out.fingerprint = node.fingerprint;
167
+ if (Array.isArray(node.rawImports) && node.rawImports.length) out.rawImports = node.rawImports;
168
+ if (Array.isArray(node.resolvedImportsRel) && node.resolvedImportsRel.length) {
169
+ out.resolvedImports = node.resolvedImportsRel;
170
+ }
171
+ if (Array.isArray(node.importedBy) && node.importedBy.length) {
172
+ out.importedBy = node.importedBy;
173
+ }
174
+ if (node.packageName) out.packageName = node.packageName;
175
+ if (node.namespaceName) out.namespaceName = node.namespaceName;
176
+ if (node.goPackageName) out.goPackageName = node.goPackageName;
177
+ if (Array.isArray(node.topLevelTypes) && node.topLevelTypes.length) {
178
+ out.topLevelTypes = node.topLevelTypes;
179
+ }
180
+ if (Array.isArray(node.tokenSymbols) && node.tokenSymbols.length) {
181
+ out.tokenSymbols = node.tokenSymbols;
182
+ }
183
+ if (Array.isArray(node.symbols) && node.symbols.length) {
184
+ out.symbols = node.symbols;
185
+ }
186
+ return out;
187
+ }),
188
+ };
189
+ }
190
+
191
+ function _deserializeGraph(cwd, payload) {
192
+ if (!payload || typeof payload !== 'object' || !Array.isArray(payload.nodes)) return null;
193
+ const nodes = new Map();
194
+ const reverse = new Map();
195
+ for (const item of payload.nodes) {
196
+ if (!item || typeof item.rel !== 'string' || typeof item.lang !== 'string') continue;
197
+ // Persisted fields are repo-relative, mirroring the live build. The
198
+ // JS resolution layer is gone — resolvedImports/resolvedImportsRel are
199
+ // restored straight from disk; the reverse index is rederived below from
200
+ // the forward edges of every node.
201
+ const resolvedImportsRel = Array.isArray(item.resolvedImports) ? item.resolvedImports.filter((v) => typeof v === 'string') : [];
202
+ const importedBy = Array.isArray(item.importedBy) ? item.importedBy.filter((v) => typeof v === 'string') : [];
203
+ const node = {
204
+ abs: pathResolve(cwd, item.rel),
205
+ rel: item.rel,
206
+ lang: item.lang,
207
+ fingerprint: item.fingerprint || '',
208
+ rawImports: Array.isArray(item.rawImports) ? item.rawImports : [],
209
+ resolvedImportsRel,
210
+ resolvedImports: resolvedImportsRel.map((rel) => pathResolve(cwd, rel)),
211
+ importedBy,
212
+ packageName: item.packageName || '',
213
+ namespaceName: item.namespaceName || '',
214
+ goPackageName: item.goPackageName || '',
215
+ topLevelTypes: Array.isArray(item.topLevelTypes) ? item.topLevelTypes : [],
216
+ tokenSymbols: Array.isArray(item.tokenSymbols) ? item.tokenSymbols : null,
217
+ symbols: Array.isArray(item.symbols) ? item.symbols : [],
218
+ };
219
+ nodes.set(node.rel, node);
220
+ // reverse is derived from the FORWARD edges of every node, not from the
221
+ // persisted importedBy. On the incremental --files path reused nodes carry
222
+ // a stale importedBy, so a fresh edge A→B (A parsed, B reused) would drop
223
+ // B's reverse entry. Walking resolvedImportsRel keeps reverse self-consistent.
224
+ for (const rel of resolvedImportsRel) {
225
+ if (!reverse.has(rel)) reverse.set(rel, new Set());
226
+ reverse.get(rel).add(node.rel);
227
+ }
228
+ }
229
+ const graph = _attachGraphRuntimeCaches({
230
+ cwd,
231
+ nodes,
232
+ reverse,
233
+ // Pre-endLine disk payloads have no schemaVersion → null → dropped by the
234
+ // previousGraph schema guard so their endLine-less nodes never seed reuse.
235
+ schemaVersion: typeof payload.schemaVersion === 'string' ? payload.schemaVersion : null,
236
+ builtAt: Number(payload.builtAt || Date.now()),
237
+ signature: String(payload.signature || ''),
238
+ });
239
+ // Restore the truncation flag persisted from the live build so disk-cache
240
+ // hits keep emitting the WARN line in find_symbol/overview output instead
241
+ // of silently working with a partial graph.
242
+ if (graph && payload.truncated) graph.truncated = true;
243
+ return graph;
244
+ }
245
+
246
+ function _attachGraphRuntimeCaches(graph) {
247
+ if (!graph || typeof graph !== 'object') return graph;
248
+ if (!graph._referenceSearchCache) graph._referenceSearchCache = new Map();
249
+ if (!graph._maskedLinesCache) graph._maskedLinesCache = new Map();
250
+ if (!graph._sourceLinesCache) graph._sourceLinesCache = new Map();
251
+ if (!graph._sourceTextCache) graph._sourceTextCache = new Map();
252
+ if (!graph._symbolTokenIndex) graph._symbolTokenIndex = new Map();
253
+ if (typeof graph._symbolTokenIndexDirty !== 'boolean') graph._symbolTokenIndexDirty = true;
254
+ return graph;
255
+ }
256
+
257
+ function _langUsesDollarInIdentifiers(lang) {
258
+ // `$` is a valid identifier char only in JS/TS/PHP. The 5 new langs are
259
+ // deliberately excluded: kotlin/swift/scala/lua have no `$` in identifiers,
260
+ // and bash's `$` is a variable-expansion sigil (`$var`), not an identifier
261
+ // char — treating it as a word-boundary char would mis-tokenize.
262
+ // Second batch (dart/objc/elixir/zig/r) likewise excluded: none use `$` as
263
+ // an identifier char (objc `$` is invalid; elixir/dart/zig/r have no `$` in
264
+ // names), so they stay out.
265
+ return lang === 'javascript' || lang === 'typescript' || lang === 'php';
266
+ }
267
+
268
+ function _langAllowsBangQuestionSuffix(lang) {
269
+ // Method names may end in `!`/`?` only in ruby (`save!`/`empty?`) and rust
270
+ // (`!` macros). Kotlin is NOT here: its `!!` is the not-null assertion
271
+ // OPERATOR, not an identifier suffix — including it would fold `foo!!` into
272
+ // the `foo` reference and break matching. swift `?`/`!` are optional/
273
+ // force-unwrap operators (not name chars); scala/bash/lua have no suffix.
274
+ // Second batch: elixir function names may end in `?`/`!` (`valid?`/`save!`)
275
+ // exactly like ruby → included. dart/objc/zig/r have no such suffix.
276
+ return lang === 'ruby' || lang === 'rust' || lang === 'elixir';
277
+ }
278
+
279
+ function _estimateGraphRuntimeCacheBytes(graph) {
280
+ if (!graph) return 0;
281
+ let total = 0;
282
+ for (const entry of graph._sourceTextCache?.values() || []) {
283
+ total += Buffer.byteLength(String(entry?.text || ''), 'utf8');
284
+ }
285
+ for (const lines of graph._maskedLinesCache?.values() || []) {
286
+ if (!Array.isArray(lines)) continue;
287
+ for (const line of lines) total += Buffer.byteLength(String(line || ''), 'utf8');
288
+ }
289
+ for (const lines of graph._sourceLinesCache?.values() || []) {
290
+ if (!Array.isArray(lines)) continue;
291
+ for (const line of lines) total += Buffer.byteLength(String(line || ''), 'utf8');
292
+ }
293
+ for (const memo of graph._referenceSearchCache?.values() || []) {
294
+ total += Buffer.byteLength(String(memo || ''), 'utf8');
295
+ }
296
+ return total;
297
+ }
298
+
299
+ function _clearGraphRuntimeCaches(graph) {
300
+ if (!graph) return;
301
+ graph._sourceTextCache?.clear();
302
+ graph._maskedLinesCache?.clear();
303
+ graph._sourceLinesCache?.clear();
304
+ graph._referenceSearchCache?.clear();
305
+ graph._symbolTokenIndex?.clear();
306
+ graph._symbolTokenIndexDirty = true;
307
+ }
308
+
309
+ function _touchCodeGraphCache(graphCwd) {
310
+ const key = _canonicalGraphCwd(graphCwd);
311
+ const entry = _codeGraphCache.get(key);
312
+ if (!entry) return;
313
+ _codeGraphCache.delete(key);
314
+ entry.lastAccess = Date.now();
315
+ _codeGraphCache.set(key, entry);
316
+ }
317
+
318
+ function _setCodeGraphCache(graphCwd, entry) {
319
+ const key = _canonicalGraphCwd(graphCwd);
320
+ const payload = { ...entry, lastAccess: Date.now() };
321
+ if (_codeGraphCache.has(key)) _codeGraphCache.delete(key);
322
+ _codeGraphCache.set(key, payload);
323
+ _pruneCodeGraphMemoryCache();
324
+ }
325
+
326
+ export function _pruneCodeGraphMemoryCache(options = {}) {
327
+ const maxEntries = Number.isFinite(options.maxEntries)
328
+ ? Math.max(1, Math.floor(options.maxEntries))
329
+ : CODE_GRAPH_MEMORY_MAX_ENTRIES;
330
+ const maxBytes = Number.isFinite(options.maxBytes)
331
+ ? Math.max(0, Math.floor(options.maxBytes))
332
+ : CODE_GRAPH_MEMORY_MAX_SOURCE_BYTES;
333
+ const rows = [..._codeGraphCache.entries()].map(([cwd, entry]) => ({
334
+ cwd,
335
+ entry,
336
+ lastAccess: Number(entry?.lastAccess || entry?.ts || 0),
337
+ runtimeBytes: _estimateGraphRuntimeCacheBytes(entry?.graph),
338
+ }));
339
+ rows.sort((a, b) => (a.lastAccess - b.lastAccess) || String(a.cwd).localeCompare(String(b.cwd)));
340
+ const evicted = [];
341
+ let totalRuntimeBytes = rows.reduce((sum, row) => sum + row.runtimeBytes, 0);
342
+ for (const row of rows) {
343
+ if (totalRuntimeBytes <= maxBytes) break;
344
+ if (!row.entry?.graph || row.runtimeBytes <= 0) continue;
345
+ const freed = row.runtimeBytes;
346
+ _clearGraphRuntimeCaches(row.entry.graph);
347
+ row.runtimeBytes = 0;
348
+ totalRuntimeBytes -= freed;
349
+ evicted.push({ cwd: row.cwd, reason: 'max-bytes-runtime', freed });
350
+ }
351
+ while (_codeGraphCache.size > maxEntries) {
352
+ const oldestKey = _codeGraphCache.keys().next().value;
353
+ if (!oldestKey) break;
354
+ _codeGraphCache.delete(oldestKey);
355
+ evicted.push({ cwd: oldestKey, reason: 'max-entries' });
356
+ }
357
+ return { evicted, totalRuntimeBytes: Math.max(0, totalRuntimeBytes), entries: _codeGraphCache.size };
358
+ }
359
+
360
+ function _pruneDiskCodeGraphEntries(_now = Date.now()) {
361
+ for (const [cwd, entry] of _diskCodeGraphCache) {
362
+ if (!entry || typeof entry !== 'object') {
363
+ _diskCodeGraphCache.delete(cwd);
364
+ continue;
365
+ }
366
+ // Disk entries are not TTL-evicted: signature validation on load/build
367
+ // plus _pruneCodeGraphManifestForBudget (MIXDOG_CODE_GRAPH_CACHE_MAX_MB)
368
+ // govern freshness and size. Memory cache keeps CODE_GRAPH_TTL_MS.
369
+ }
370
+ while (_diskCodeGraphCache.size > CODE_GRAPH_DISK_MAX_ENTRIES) {
371
+ const oldest = _diskCodeGraphCache.keys().next().value;
372
+ if (!oldest) break;
373
+ _diskCodeGraphCache.delete(oldest);
374
+ }
375
+ }
376
+
377
+ function _isCodeGraphCacheHash(value) {
378
+ return /^[0-9a-f]{8,64}$/i.test(String(value || ''));
379
+ }
380
+
381
+ export function _pruneCodeGraphManifestForBudget(manifest, dir, options = {}) {
382
+ const maxEntries = Number.isFinite(options.maxEntries)
383
+ ? Math.max(0, Math.floor(options.maxEntries))
384
+ : CODE_GRAPH_DISK_MAX_ENTRIES;
385
+ const maxBytes = Number.isFinite(options.maxBytes)
386
+ ? Math.max(0, Math.floor(options.maxBytes))
387
+ : CODE_GRAPH_DISK_MAX_BYTES;
388
+ const rows = [];
389
+ for (const [cwd, meta] of Object.entries(manifest || {})) {
390
+ const hash = String(meta?.hash || '');
391
+ if (!cwd || !_isCodeGraphCacheHash(hash)) continue;
392
+ const file = join(dir, `${hash}.json`);
393
+ let size = 0;
394
+ try { size = statSync(file).size; } catch { continue; }
395
+ rows.push({
396
+ cwd,
397
+ hash,
398
+ builtAt: Number(meta?.builtAt) || 0,
399
+ size: Math.max(0, Number(size) || 0),
400
+ });
401
+ }
402
+ rows.sort((a, b) => (a.builtAt - b.builtAt) || a.cwd.localeCompare(b.cwd));
403
+ const keep = new Set(rows.map((row) => row.cwd));
404
+ let totalBytes = rows.reduce((sum, row) => sum + row.size, 0);
405
+ const evicted = [];
406
+
407
+ const evict = (row, reason) => {
408
+ if (!row || !keep.has(row.cwd)) return false;
409
+ keep.delete(row.cwd);
410
+ totalBytes -= row.size;
411
+ evicted.push({ ...row, reason });
412
+ return true;
413
+ };
414
+
415
+ for (const row of rows) {
416
+ if (keep.size <= maxEntries) break;
417
+ evict(row, 'max-entries');
418
+ }
419
+ for (const row of rows) {
420
+ if (totalBytes <= maxBytes) break;
421
+ evict(row, 'max-bytes');
422
+ }
423
+
424
+ const pruned = {};
425
+ for (const row of rows) {
426
+ if (!keep.has(row.cwd)) continue;
427
+ pruned[row.cwd] = { hash: row.hash, builtAt: row.builtAt };
428
+ }
429
+ return { manifest: pruned, evicted, totalBytes: Math.max(0, totalBytes) };
430
+ }
431
+
432
+ function _loadDiskCodeGraphCache(now = Date.now()) {
433
+ if (_diskCodeGraphCacheLoaded) return;
434
+ _diskCodeGraphCacheLoaded = true;
435
+
436
+ // One-shot migration from the legacy single-file cache. Subsequent boots
437
+ // skip this branch because the source file was renamed to .bak.
438
+ _migrateLegacyDiskCache();
439
+
440
+ // Manifest-only load: per-cwd entries are picked up by _ensureCwdLoaded()
441
+ // at lookup time. Cold start now pays a single small JSON.parse instead
442
+ // of reading every per-cwd file (~24 × ~2 MB on long-running workspaces).
443
+ try {
444
+ const manifestFile = join(_codeGraphDiskDir(), 'manifest.json');
445
+ if (existsSync(manifestFile)) {
446
+ const parsed = JSON.parse(readFileSync(manifestFile, 'utf8'));
447
+ if (parsed && typeof parsed === 'object') _diskManifest = parsed;
448
+ }
449
+ } catch (err) {
450
+ process.stderr.write(`[code-graph] disk manifest load failed: ${err?.message || err}\n`);
451
+ }
452
+ if (!_diskManifest) _diskManifest = {};
453
+ _pruneDiskCodeGraphEntries(now);
454
+ }
455
+
456
+ // Demand-load one cwd's per-file entry. Callers invoke this right before
457
+ // reading `_diskCodeGraphCache.get(cwd)` so the in-memory cache stays
458
+ // populated only for cwds actually looked up in this process lifetime.
459
+ function _ensureCwdLoaded(cwd) {
460
+ const key = _canonicalGraphCwd(cwd);
461
+ if (_diskCodeGraphCache.has(key)) return;
462
+ if (!_diskManifest) return;
463
+ const meta = _diskManifest[key];
464
+ if (!meta || typeof meta !== 'object' || !meta.hash) return;
465
+ try {
466
+ const file = join(_codeGraphDiskDir(), `${meta.hash}.json`);
467
+ if (!existsSync(file)) return;
468
+ const entry = JSON.parse(readFileSync(file, 'utf8'));
469
+ if (entry && typeof entry === 'object') _diskCodeGraphCache.set(key, entry);
470
+ } catch { /* skip corrupt per-cwd file */ }
471
+ }
472
+
473
+ function _persistDiskCodeGraphCacheNow() {
474
+ try {
475
+ _loadDiskCodeGraphCache();
476
+ _pruneDiskCodeGraphEntries();
477
+ const dir = _codeGraphDiskDir();
478
+ mkdirSync(dir, { recursive: true });
479
+
480
+ // Read the on-disk manifest BEFORE writing so cwd entries owned by
481
+ // other instances (MIXDOG_MULTI_INSTANCE=1) survive. Without this,
482
+ // our orphan sweep below would happily unlink another instance's
483
+ // per-cwd files just because we don't have them in our in-memory map.
484
+ let preserved = {};
485
+ try {
486
+ const raw = readFileSync(join(dir, 'manifest.json'), 'utf8');
487
+ const parsed = JSON.parse(raw);
488
+ if (parsed && typeof parsed === 'object') preserved = parsed;
489
+ } catch { /* no existing manifest yet */ }
490
+
491
+ let manifest = { ...preserved };
492
+ const validHashes = new Set();
493
+ for (const [cwd, entry] of _diskCodeGraphCache) {
494
+ const hash = _hashCwd(cwd);
495
+ const file = join(dir, `${hash}.json`);
496
+ writeJsonAtomicSync(file, entry, { compact: true, lock: true });
497
+ manifest[cwd] = { hash, builtAt: entry.builtAt || Date.now() };
498
+ }
499
+ const pruned = _pruneCodeGraphManifestForBudget(manifest, dir);
500
+ manifest = pruned.manifest;
501
+ for (const row of pruned.evicted) {
502
+ _diskCodeGraphCache.delete(row.cwd);
503
+ }
504
+ for (const meta of Object.values(manifest)) {
505
+ if (meta && typeof meta === 'object' && meta.hash) validHashes.add(meta.hash);
506
+ }
507
+
508
+ const manifestFile = join(dir, 'manifest.json');
509
+ writeJsonAtomicSync(manifestFile, manifest, { compact: true, lock: true });
510
+ _diskManifest = manifest;
511
+
512
+ // Sweep orphan per-cwd files. validHashes now includes every hash in
513
+ // the merged manifest (preserved + ours) so cross-instance cache files
514
+ // are never collateral damage.
515
+ try {
516
+ for (const f of readdirSync(dir)) {
517
+ if (f === 'manifest.json' || !f.endsWith('.json')) continue;
518
+ const hash = f.slice(0, -5);
519
+ if (!validHashes.has(hash)) {
520
+ try { unlinkSync(join(dir, f)); } catch { /* best-effort */ }
521
+ }
522
+ }
523
+ } catch { /* sweep best-effort */ }
524
+ } catch (err) {
525
+ process.stderr.write(`[code-graph] disk cache persist failed (target: ${_codeGraphDiskDir()}): ${err?.message || err}\n`);
526
+ }
527
+ }
528
+
529
+ function _scheduleDiskCodeGraphCacheFlush() {
530
+ if (_diskCodeGraphCacheFlushTimer) return;
531
+ _diskCodeGraphCacheFlushTimer = setTimeout(() => {
532
+ _diskCodeGraphCacheFlushTimer = null;
533
+ _persistDiskCodeGraphCacheNow();
534
+ }, 250);
535
+ if (typeof _diskCodeGraphCacheFlushTimer.unref === 'function') _diskCodeGraphCacheFlushTimer.unref();
536
+ }
537
+
538
+ /**
539
+ * Sync-flush any pending code-graph disk cache write before process exit.
540
+ * Cancels the 250ms scheduled-flush timer and runs _persistDiskCodeGraphCacheNow
541
+ * directly so newly-built graphs land on disk regardless of exit timing.
542
+ */
543
+ export function drainCodeGraphCache() {
544
+ if (_diskCodeGraphCacheFlushTimer) {
545
+ clearTimeout(_diskCodeGraphCacheFlushTimer);
546
+ _diskCodeGraphCacheFlushTimer = null;
547
+ _persistDiskCodeGraphCacheNow();
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Fire-and-forget prewarm — schedule a code-graph build for `cwd` on the
553
+ * next tick so the first find_symbol call hits a warm cache instead of
554
+ * paying the cold-build outlier (PG telemetry: avg 4117ms, max 93645ms).
555
+ * Mirrors the warmupCatalogs pattern in providers/registry.mjs (catch-all
556
+ * silent so prewarm never affects the caller). Effect requires that the
557
+ * caller-supplied cwd matches the cwd of the first lookup.
558
+ */
559
+ export function prewarmCodeGraph(cwd) {
560
+ if (!cwd) return;
561
+ // Reuse the buildCodeGraphAsync single-flight path. Fire-and-forget —
562
+ // caller does not await. If buildCodeGraphAsync already has a Worker
563
+ // running for this cwd (or the cache is fresh under TTL), prewarm
564
+ // collapses onto it instead of spawning a duplicate thread.
565
+ buildCodeGraphAsync(cwd).catch(() => { /* best-effort */ });
566
+ }
567
+
568
+ /**
569
+ * Symbol-aware prewarm. After graph build, populate the lazy per-symbol
570
+ * candidate cache for each name in `symbols` so the first find_symbol
571
+ * lookup on those names skips the ~50ms O(N) node scan. Best paired
572
+ * with bridge prefetch args (prefetch.callers / prefetch.references)
573
+ * that already name the symbols the worker plans to query. Fire-and-
574
+ * forget; caller does not await.
575
+ */
576
+ export function prewarmCodeGraphSymbols(cwd, symbols, { language = null } = {}) {
577
+ if (!cwd) return;
578
+ const wanted = (Array.isArray(symbols) ? symbols : [symbols])
579
+ .map((s) => String(s || '').trim())
580
+ .filter(Boolean);
581
+ buildCodeGraphAsync(cwd).then((graph) => {
582
+ if (!graph) return;
583
+ for (const symbol of wanted) {
584
+ try { _lookupCandidateNodes(graph, symbol, language); } catch { /* best-effort */ }
585
+ }
586
+ }).catch(() => { /* best-effort */ });
587
+ }
588
+
589
+ /**
590
+ * Guarded directory prewarm. Schedules a build ONLY when `cwd` sits inside a
591
+ * real project (sentinel at it or an ancestor), and prewarms the detected
592
+ * project ROOT — not an arbitrary subdir — so a later unscoped query (which
593
+ * re-roots to that same project root in executeCodeGraphTool) lands on a warm
594
+ * cache instead of paying the cold build on the query's critical path. Refuses
595
+ * non-project trees (home dir, multi-repo container, plugin cache) so a stray
596
+ * `cwd set` never burns a worker indexing a giant unrelated tree. Fire-and-
597
+ * forget (single-flight + silent via prewarmCodeGraph); returns whether a
598
+ * prewarm was scheduled.
599
+ */
600
+ export function prewarmCodeGraphIfProject(cwd) {
601
+ if (!cwd) return false;
602
+ const root = _findDirProjectRoot(cwd);
603
+ if (!root) return false;
604
+ prewarmCodeGraph(root);
605
+ return true;
606
+ }
607
+
608
+ export async function buildCodeGraphAsync(cwd, signal = null) {
609
+ if (signal?.aborted) throw new Error('aborted');
610
+ const graphCwd = _canonicalGraphCwd(cwd);
611
+ // TTL-bounded cache hit. Signature re-validation requires a sync fs
612
+ // walk (main-loop work we explicitly avoid), so we delegate full
613
+ // re-check to the worker which calls _buildCodeGraph and runs the
614
+ // signature comparison itself. Stale entries past CODE_GRAPH_TTL_MS
615
+ // fall through to a worker rebuild.
616
+ const cached = _codeGraphCache.get(graphCwd);
617
+ if (cached?.graph && Date.now() - cached.ts < CODE_GRAPH_TTL_MS) {
618
+ _touchCodeGraphCache(graphCwd);
619
+ return cached.graph;
620
+ }
621
+ // Single-flight: parallel callers for the same graphCwd collapse onto
622
+ // one Worker spawn. Same Promise is returned to every caller until it
623
+ // settles, then the entry is removed so subsequent callers can retry
624
+ // after a failure or after the next TTL expiry.
625
+ const existing = _inflightAsyncBuilds.get(graphCwd);
626
+ if (existing) {
627
+ if (!signal) return existing;
628
+ let onAbort = null;
629
+ const abortP = new Promise((_, reject) => {
630
+ onAbort = () => reject(new Error('aborted'));
631
+ signal.addEventListener('abort', onAbort, { once: true });
632
+ });
633
+ const cleanup = () => {
634
+ if (onAbort) {
635
+ try { signal.removeEventListener('abort', onAbort); } catch {}
636
+ onAbort = null;
637
+ }
638
+ };
639
+ return Promise.race([existing, abortP]).then(
640
+ (v) => { cleanup(); return v; },
641
+ (e) => { cleanup(); throw e; },
642
+ );
643
+ }
644
+ // Capture the dirty generation at build start. If a write bumps it
645
+ // before the worker returns, the result describes a pre-edit tree and
646
+ // must be dropped rather than cached/persisted.
647
+ const _genAtStart = _getCodeGraphGen(graphCwd);
648
+ let _worker = null;
649
+ const promise = new Promise((resolve, reject) => {
650
+ let settled = false;
651
+ let timeout = null;
652
+ let _onSignalAbort = null;
653
+ const settle = (val) => {
654
+ if (settled) return;
655
+ settled = true;
656
+ if (timeout) { clearTimeout(timeout); timeout = null; }
657
+ if (_onSignalAbort && signal) {
658
+ try { signal.removeEventListener('abort', _onSignalAbort); } catch {}
659
+ _onSignalAbort = null;
660
+ }
661
+ _inflightAsyncBuilds.delete(graphCwd);
662
+ if (val instanceof Error) reject(val);
663
+ else resolve(val);
664
+ };
665
+ try {
666
+ const workerUrl = new URL('./code-graph-prewarm-worker.mjs', import.meta.url);
667
+ _worker = new Worker(workerUrl, {
668
+ workerData: { cwd },
669
+ execArgv: [],
670
+ });
671
+ const w = _worker;
672
+ timeout = setTimeout(() => {
673
+ try { _worker?.terminate(); } catch {}
674
+ settle(new Error(`code-graph worker timed out after ${CODE_GRAPH_WORKER_TIMEOUT_MS}ms for cwd=${graphCwd}`));
675
+ }, CODE_GRAPH_WORKER_TIMEOUT_MS);
676
+ timeout.unref?.();
677
+ if (signal) {
678
+ _onSignalAbort = () => {
679
+ try { _worker?.terminate(); } catch {}
680
+ settle(new Error('aborted'));
681
+ };
682
+ signal.addEventListener('abort', _onSignalAbort, { once: true });
683
+ }
684
+ w.once('message', (msg) => {
685
+ try {
686
+ if (msg && msg.ok && msg.graph && typeof msg.signature === 'string') {
687
+ // Dirty-generation guard: only commit if no write invalidated
688
+ // this root since build start. A stale result is still returned
689
+ // to in-flight callers but never cached or persisted, so the TTL
690
+ // fast path won't serve a pre-edit graph after the next rebuild.
691
+ if (_getCodeGraphGen(graphCwd) === _genAtStart) {
692
+ _setCodeGraphCache(graphCwd, { ts: Date.now(), signature: msg.signature, graph: msg.graph });
693
+ // Mirror the sync build path: persist to disk so the next
694
+ // process boot can hit the cache cold. Without this, async
695
+ // prewarm / find_symbol via worker thread populated only the
696
+ // in-memory map and the per-cwd directory stayed empty until
697
+ // the rare sync rebuild path landed.
698
+ _setDiskCodeGraphEntry(graphCwd, msg.graph);
699
+ }
700
+ settle(msg.graph);
701
+ } else {
702
+ settle(new Error('code-graph prewarm worker failed'));
703
+ }
704
+ } catch (e) { settle(e instanceof Error ? e : new Error(String(e))); }
705
+ });
706
+ w.once('error', (e) => settle(e instanceof Error ? e : new Error(String(e))));
707
+ } catch (e) { settle(e instanceof Error ? e : new Error(String(e))); }
708
+ });
709
+ _inflightAsyncBuilds.set(graphCwd, promise);
710
+ return promise;
711
+ }
712
+
713
+ function _setDiskCodeGraphEntry(cwd, graph) {
714
+ _loadDiskCodeGraphCache();
715
+ // Stamp the cache entry with the persistence timestamp (not the build
716
+ // start) so manifest/signature metadata stays fresh. Disk retention is
717
+ // governed by signature validation and MIXDOG_CODE_GRAPH_CACHE_MAX_MB,
718
+ // not CODE_GRAPH_TTL_MS (memory cache only).
719
+ const serialized = _serializeGraph(graph);
720
+ serialized.builtAt = Date.now();
721
+ _diskCodeGraphCache.set(_canonicalGraphCwd(cwd), serialized);
722
+ _pruneDiskCodeGraphEntries();
723
+ _scheduleDiskCodeGraphCacheFlush();
724
+ }
725
+
726
+ // P0: accept absolute written paths only; resolve affected indexed roots centrally.
727
+ // Callers pass absolute paths only — no cwd parameter.
728
+ export function markCodeGraphDirtyPaths(paths) {
729
+ const values = Array.isArray(paths) ? paths : [paths];
730
+ const cleaned = values
731
+ .filter(Boolean)
732
+ .map((p) => _canonicalGraphPath(p));
733
+ if (cleaned.length === 0) return;
734
+ // Mark every known indexed root that contains any of the written paths.
735
+ const knownRoots = new Set([..._codeGraphDirtyPaths.keys(), ..._codeGraphCache.keys()]);
736
+ const affectedRoots = new Set();
737
+ for (const absPath of cleaned) {
738
+ // P0: per-path tracking. Previous logic used the global affectedRoots.size,
739
+ // which meant once any earlier path matched an indexed root, every later
740
+ // unmatched path skipped the ancestor-chain derivation entirely.
741
+ let matchedThisPath = false;
742
+ for (const root of knownRoots) {
743
+ const canonRoot = _canonicalGraphPath(root);
744
+ if (absPath.startsWith(canonRoot + '/') || absPath.startsWith(canonRoot + '\\') || absPath === canonRoot) {
745
+ affectedRoots.add(root);
746
+ matchedThisPath = true;
747
+ }
748
+ }
749
+ // Derive project root from file's ancestor chain when this specific path
750
+ // matched no indexed root.
751
+ if (!matchedThisPath) {
752
+ let dir = dirname(absPath);
753
+ while (dir && dir !== dirname(dir)) {
754
+ if (existsSync(join(dir, 'package.json')) || existsSync(join(dir, '.git'))) {
755
+ affectedRoots.add(_canonicalGraphCwd(dir));
756
+ break;
757
+ }
758
+ dir = dirname(dir);
759
+ }
760
+ }
761
+ }
762
+ for (const root of affectedRoots) {
763
+ if (!_codeGraphDirtyPaths.has(root)) _codeGraphDirtyPaths.set(root, new Set());
764
+ const set = _codeGraphDirtyPaths.get(root);
765
+ for (const p of cleaned) set.add(p);
766
+ // P0: invalidate the in-memory TTL cache entry for any root that just
767
+ // received dirty paths. Otherwise buildCodeGraphAsync's TTL fast path
768
+ // returns the stale pre-edit graph for up to CODE_GRAPH_TTL_MS (30s)
769
+ // after every write, ignoring the dirty queue entirely.
770
+ const _canonRoot = _canonicalGraphCwd(root);
771
+ _codeGraphCache.delete(_canonRoot);
772
+ // Bump the dirty generation so any build that started before this
773
+ // write drops its now-stale result instead of repopulating the cache.
774
+ _bumpCodeGraphGen(_canonRoot);
775
+ }
776
+ }
777
+
778
+ function _consumeCodeGraphDirtyPaths(cwd) {
779
+ const key = _canonicalGraphCwd(cwd);
780
+ const set = _codeGraphDirtyPaths.get(key);
781
+ if (!set || set.size === 0) return [];
782
+ _codeGraphDirtyPaths.delete(key);
783
+ return [...set];
784
+ }
785
+
786
+ // Unicode-aware word-boundary wrapper for an already-regex-escaped
787
+ // symbol. JS `\b` only fires at ASCII [A-Za-z0-9_] transitions, so
788
+ // CJK / Cyrillic / Greek identifiers never matched the legacy shape.
789
+ // `$` is part of the boundary only for JS/TS/PHP; Ruby/Kotlin/Rust
790
+ // `!?` suffixes are kept distinct from the unsuffixed name when searching.
791
+ function _unicodeBoundaryPattern(escaped, lang = null, symbol = null) {
792
+ const allowDollar = !lang || _langUsesDollarInIdentifiers(lang);
793
+ const before = allowDollar ? '(?<![\\p{ID_Continue}$])' : '(?<![\\p{ID_Continue}])';
794
+ let after = allowDollar ? '(?![\\p{ID_Continue}$])' : '(?![\\p{ID_Continue}])';
795
+ const sym = symbol == null ? '' : String(symbol);
796
+ if (lang && _langAllowsBangQuestionSuffix(lang) && sym && !/[!?]$/.test(sym)) {
797
+ after = allowDollar ? '(?![\\p{ID_Continue}$!?])' : '(?![\\p{ID_Continue}!?])';
798
+ }
799
+ return `${before}${escaped}${after}`;
800
+ }
801
+
802
+ function _extractIdentifierTokens(text, lang = null) {
803
+ const out = new Set();
804
+ const allowDollar = !lang || _langUsesDollarInIdentifiers(lang);
805
+ const before = allowDollar ? '(?<![\\p{ID_Continue}$])' : '(?<![\\p{ID_Continue}])';
806
+ const suffix = lang && _langAllowsBangQuestionSuffix(lang) ? '[!?]?' : '';
807
+ const after = allowDollar ? '(?![\\p{ID_Continue}$])' : '(?![\\p{ID_Continue}])';
808
+ const re = new RegExp(`${before}[$@]?[\\p{ID_Start}_][\\p{ID_Continue}]*${suffix}${after}`, 'gu');
809
+ let match = null;
810
+ const src = String(text || '');
811
+ while ((match = re.exec(src))) {
812
+ out.add(match[0]);
813
+ }
814
+ return [...out];
815
+ }
816
+
817
+ function _getTokenSymbolsForNode(graph, node) {
818
+ if (Array.isArray(node?.tokenSymbols)) return node.tokenSymbols;
819
+ const text = _getSourceTextForNode(graph, node);
820
+ const tokens = _extractIdentifierTokens(text, node.lang);
821
+ node.tokenSymbols = tokens;
822
+ return tokens;
823
+ }
824
+
825
+ function _cloneSymbolTokenIndex(index) {
826
+ const out = new Map();
827
+ for (const [key, rels] of index || []) {
828
+ out.set(key, Array.isArray(rels) ? [...rels] : []);
829
+ }
830
+ return out;
831
+ }
832
+
833
+ // Legacy full-index builder. Kept callable for explicit prewarms, but
834
+ // no longer invoked from lookup paths — those use _lookupCandidateNodes
835
+ // which lazily builds only the requested (language, symbol) bucket.
836
+ // Full build was the dominant cold-process cost (~1-2s on refs/'s
837
+ // 7000-node × ~50-tokens graph) and provided no benefit for the typical
838
+ // 1-3 lookups per bridge worker.
839
+ function _ensureSymbolTokenIndex(graph) {
840
+ if (!graph?._symbolTokenIndex) return;
841
+ if (!graph._symbolTokenIndexDirty && graph._symbolTokenIndex.size > 0) return;
842
+ graph._symbolTokenIndex.clear();
843
+ for (const node of graph.nodes.values()) {
844
+ for (const symbol of _getTokenSymbolsForNode(graph, node)) {
845
+ const langKey = `${node.lang}|${symbol}`;
846
+ const wildKey = `*|${symbol}`;
847
+ if (!graph._symbolTokenIndex.has(langKey)) graph._symbolTokenIndex.set(langKey, []);
848
+ graph._symbolTokenIndex.get(langKey).push(node.rel);
849
+ if (!graph._symbolTokenIndex.has(wildKey)) graph._symbolTokenIndex.set(wildKey, []);
850
+ graph._symbolTokenIndex.get(wildKey).push(node.rel);
851
+ }
852
+ }
853
+ graph._symbolTokenIndexDirty = false;
854
+ }
855
+
856
+ // Lazy per-symbol candidate lookup. Caches the result back into
857
+ // `_symbolTokenIndex` so repeat lookups are O(1). Compared to a full
858
+ // _ensureSymbolTokenIndex sweep, the per-symbol scan is O(N) where N is
859
+ // the node count (~7000 on refs/), and each node's check is a cheap
860
+ // Array.includes on its pre-extracted tokenSymbols. Cold-process first
861
+ // lookup drops from ~1-2s to ~50ms.
862
+ export function _lookupCandidateNodes(graph, symbol, language = null) {
863
+ if (!graph?.nodes) return [];
864
+ const cacheKey = `${language || '*'}|${symbol}`;
865
+ if (graph._symbolTokenIndex?.has(cacheKey)) {
866
+ const rels = graph._symbolTokenIndex.get(cacheKey);
867
+ return rels.map((rel) => graph.nodes.get(rel)).filter(Boolean);
868
+ }
869
+ const candidates = [];
870
+ for (const node of graph.nodes.values()) {
871
+ if (language && node.lang !== language) continue;
872
+ const tokens = _getTokenSymbolsForNode(graph, node);
873
+ if (tokens.includes(symbol)) candidates.push(node);
874
+ }
875
+ if (candidates.length > 0) {
876
+ if (graph._symbolTokenIndex) {
877
+ graph._symbolTokenIndex.set(cacheKey, candidates.map((n) => n.rel));
878
+ }
879
+ return candidates;
880
+ }
881
+ // Token-index miss → fall back to language-filtered full graph scan.
882
+ // _extractIdentifierTokens uses ASCII `\b` word-boundary which misses
883
+ // unicode (Korean/CJK), $-prefixed identifiers in some positions, and
884
+ // certain multi-byte language tokens (Rust raw idents, Go method
885
+ // receivers). The downstream search loop's sourceText.includes()
886
+ // still catches these — we just need to give it the full node set.
887
+ // Not cached: caching the fallback would mask token-extractor
888
+ // improvements and would also keep returning the heavy scan after a
889
+ // future graph rebuild populated the token map for the symbol.
890
+ const fallback = [];
891
+ for (const node of graph.nodes.values()) {
892
+ if (language && node.lang !== language) continue;
893
+ fallback.push(node);
894
+ }
895
+ return fallback;
896
+ }
897
+
898
+ function _extractSymbolsCheap(text, lang) {
899
+ const all = _collectCheapSymbols(text, lang).map((item) => `${item.kind} ${item.name} (L${item.line})`);
900
+ return all.length ? _capGraphList(all).join('\n') : '(no symbols)';
901
+ }
902
+
903
+ // Control-flow keywords that the bare `name(args) {?$` patterns below
904
+ // would otherwise mis-collect as function/method symbols (e.g. an
905
+ // `if (...) {` line). Excluding at the collection stage keeps the
906
+ // invariant out of every downstream label/summarizer.
907
+ const _CHEAP_SYMBOL_CONTROL_FLOW = new Set([
908
+ 'if', 'else', 'elif', 'for', 'foreach', 'while', 'do',
909
+ 'switch', 'case', 'default', 'when', 'select',
910
+ 'try', 'catch', 'finally', 'throw', 'throws',
911
+ 'return', 'yield', 'await', 'goto', 'break', 'continue',
912
+ 'with', 'using', 'lock', 'synchronized', 'unless',
913
+ ]);
914
+
915
+ function _collectCheapSymbols(text, lang) {
916
+ const lines = String(text || '').split(/\r?\n/);
917
+ const out = [];
918
+ const push = (kind, name, idx) => {
919
+ if (!name) return;
920
+ // Skip control-flow keywords so `if(...) {`, `for(...) {`,
921
+ // `while(...) {`, `switch(...) {`, `catch(...) {` no longer leak
922
+ // as function/method symbols through the bare `name(args)` shapes.
923
+ if (_CHEAP_SYMBOL_CONTROL_FLOW.has(name)) return;
924
+ out.push({ kind, name, line: idx + 1 });
925
+ };
926
+ // Slash (`//` `/*`) comments: all C-family langs incl. new kotlin/swift/
927
+ // scala. Excluded: python/ruby (hash), bash (hash), lua (`--`; also `//`
928
+ // is lua integer-division, so slash-stripping would delete code). Second
929
+ // batch: dart/objc/zig are C-family slash-comment (kept by the default);
930
+ // elixir/r are hash-comment (excluded below).
931
+ const supportsSlash = lang !== 'python' && lang !== 'ruby'
932
+ && lang !== 'bash' && lang !== 'lua'
933
+ && lang !== 'elixir' && lang !== 'r';
934
+ // Hash (`#`) comments: python/ruby/php and bash. lua uses `--` (handled by
935
+ // _maskNonCodeText, not needed here since lua has no cheap-symbol matcher).
936
+ // Second batch: elixir and r are `#`-only line-comment langs → included.
937
+ const supportsHash = lang === 'python' || lang === 'ruby' || lang === 'php'
938
+ || lang === 'bash' || lang === 'elixir' || lang === 'r';
939
+ let inBlockComment = false;
940
+ for (let i = 0; i < lines.length; i++) {
941
+ // Per-line comment stripping at the collection stage so header/JSDoc
942
+ // words like "These", "side", "effects" cannot bleed into the
943
+ // overview `symbols:` token list or the cheap summarizer output.
944
+ // An unclosed `/*` keeps the code before it and flips the block flag
945
+ // so code-before-comment lines (and spaced generators like `* gen()`)
946
+ // still reach the per-language matchers below.
947
+ let line = lines[i];
948
+ if (supportsSlash) {
949
+ if (inBlockComment) {
950
+ const endIdx = line.indexOf('*/');
951
+ if (endIdx < 0) continue;
952
+ line = line.slice(endIdx + 2);
953
+ inBlockComment = false;
954
+ }
955
+ while (true) {
956
+ const startIdx = line.indexOf('/*');
957
+ if (startIdx < 0) break;
958
+ const endIdx = line.indexOf('*/', startIdx + 2);
959
+ if (endIdx < 0) {
960
+ line = line.slice(0, startIdx);
961
+ inBlockComment = true;
962
+ break;
963
+ }
964
+ line = line.slice(0, startIdx) + ' ' + line.slice(endIdx + 2);
965
+ }
966
+ const slashIdx = line.indexOf('//');
967
+ if (slashIdx >= 0) line = line.slice(0, slashIdx);
968
+ }
969
+ if (supportsHash) {
970
+ if (/^\s*#/.test(line)) continue;
971
+ }
972
+ if (!line.trim()) continue;
973
+ let m = null;
974
+ if (lang === 'typescript' || lang === 'javascript') {
975
+ if ((m = /\b(class|interface|type|enum)\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(line))) push(m[1], m[2], i);
976
+ else if ((m = /\bfunction\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(line))) push('function', m[1], i);
977
+ else if ((m = /\b(?:const|let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\b/.exec(line))) push('binding', m[1], i);
978
+ else if ((m = /^\s*(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*\{?$/.exec(line))) push('method', m[1], i);
979
+ } else if (lang === 'python') {
980
+ if ((m = /^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(line))) push('class', m[1], i);
981
+ else if ((m = /^\s*def\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(line))) push('function', m[1], i);
982
+ } else if (lang === 'go') {
983
+ if ((m = /^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s+struct\b/.exec(line))) push('struct', m[1], i);
984
+ else if ((m = /^\s*func(?:\s*\([^)]*\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(line))) push('function', m[1], i);
985
+ } else if (lang === 'rust') {
986
+ if ((m = /^\s*(?:pub\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(line))) push('struct', m[1], i);
987
+ else if ((m = /^\s*(?:pub\s+)?fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(line))) push('function', m[1], i);
988
+ } else if (lang === 'kotlin') {
989
+ // Kotlin: `fun name(...)` is the canonical function declaration whether
990
+ // the body is a `{` block or an `= expr` expression body. The shared
991
+ // Java/C#-style `name(...) {` pattern misses expression bodies that
992
+ // end with the expression itself (no trailing `{` or `;`), so caller
993
+ // names disappear for those functions.
994
+ if ((m = /\b(class|interface|enum|object)\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(line))) push(m[1], m[2], i);
995
+ else if ((m = /^\s*(?:public\s+|private\s+|protected\s+|internal\s+)?(?:open\s+|abstract\s+|final\s+)?(?:override\s+)?(?:suspend\s+)?(?:inline\s+)?fun\s+(?:<[^>]+>\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(line))) push('function', m[1], i);
996
+ else if ((m = /^\s*(?:public\s+|private\s+|protected\s+|internal\s+)?(?:const\s+)?(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\b/.exec(line))) push('binding', m[1], i);
997
+ } else if (lang === 'java' || lang === 'csharp') {
998
+ if ((m = /\b(class|interface|enum|record)\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(line))) push(m[1], m[2], i);
999
+ else if ((m = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*\{?$/.exec(line))) push('function', m[1], i);
1000
+ } else if (lang === 'c' || lang === 'cpp') {
1001
+ if ((m = /\b(class|struct|enum)\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(line))) push(m[1], m[2], i);
1002
+ else if ((m = /^\s*[A-Za-z_][\w\s:*<>~]*\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;]*\)\s*\{?$/.exec(line))) push('function', m[1], i);
1003
+ } else if (lang === 'ruby' || lang === 'php') {
1004
+ if ((m = /^\s*class\s+([A-Za-z_][A-Za-z0-9_:]*)/.exec(line))) push('class', m[1], i);
1005
+ else if ((m = /^\s*def\s+([A-Za-z_][A-Za-z0-9_!?=]*)/.exec(line))) push('function', m[1], i);
1006
+ }
1007
+ // No cheap-regex matcher for swift/scala/bash/lua or the second batch
1008
+ // (dart/objc/elixir/zig/r): the native indexer now emits symbols for these
1009
+ // langs, so _collectCheapSymbols runs only as a fallback when native
1010
+ // symbols are absent. They are deliberately left without a branch (yield no
1011
+ // cheap anchors) rather than guessing with a loose pattern; callers
1012
+ // (overview/anchors) fall back to native symbols.
1013
+ }
1014
+ return out;
1015
+ }
1016
+
1017
+ // Raised from 6 to 50 after HS-A6 surfaced that overview on a ~46KB file
1018
+ // returned only the first 6 anchors (all within the first 87 lines, 5%
1019
+ // of the file). tail-trim still bounds output payload, so a higher cap
1020
+ // surfaces full structure on large files without hurting small ones.
1021
+ function _extractExplainerAnchorLines(node, graph, { limit = 50, maxLineChars = 180 } = {}) {
1022
+ const sourceLines = _getSourceTextForNode(graph, node).split(/\r?\n/);
1023
+ const symbols = Array.isArray(node.symbols) && node.symbols.length
1024
+ ? node.symbols
1025
+ : _collectCheapSymbols(sourceLines.join('\n'), node.lang);
1026
+ const out = [];
1027
+ const seen = new Set();
1028
+ for (const item of symbols) {
1029
+ if (out.length >= limit) break;
1030
+ const idx = item.line - 1;
1031
+ const line = String(sourceLines[idx] || '').trim();
1032
+ if (!line) continue;
1033
+ const key = `${item.name}:${item.line}`;
1034
+ if (seen.has(key)) continue;
1035
+ seen.add(key);
1036
+ out.push(`${item.kind} ${item.name} (L${item.line}): ${line.slice(0, maxLineChars)}`);
1037
+ }
1038
+ return out;
1039
+ }
1040
+
1041
+ function _graphRel(absPath, cwd) {
1042
+ return toDisplayPath(absPath, cwd);
1043
+ }
1044
+
1045
+
1046
+ function _supportsHashComments(lang) {
1047
+ // Hash-comment langs: python/ruby/php plus bash. lua is NOT hash — it uses
1048
+ // `--` line + `--[[ ]]` block comments (see _maskNonCodeText). kotlin/
1049
+ // swift/scala are slash-comment C-family (see _supportsSlashComments).
1050
+ // Second batch: elixir and r are `#`-only line-comment langs → included.
1051
+ // (dart/objc/zig are slash-comment, handled by _supportsSlashComments.)
1052
+ return lang === 'python' || lang === 'ruby' || lang === 'php'
1053
+ || lang === 'bash' || lang === 'elixir' || lang === 'r';
1054
+ }
1055
+
1056
+ function _supportsSlashComments(lang) {
1057
+ // Slash-comment langs: everything C-family, incl. new kotlin/swift/scala.
1058
+ // Excluded: python/ruby/bash (hash) and lua (`--` comments; `//` is lua
1059
+ // integer division, so it must not be treated as a comment opener).
1060
+ // Second batch: dart/objc/zig are C-family slash-comment (kept by the
1061
+ // default). Excluded here: elixir/r (hash-only, see _supportsHashComments).
1062
+ return lang !== 'python' && lang !== 'ruby'
1063
+ && lang !== 'bash' && lang !== 'lua'
1064
+ && lang !== 'elixir' && lang !== 'r';
1065
+ }
1066
+
1067
+ function _supportsSingleQuoteStrings(lang) {
1068
+ return lang === 'typescript'
1069
+ || lang === 'javascript'
1070
+ || lang === 'python'
1071
+ || lang === 'ruby'
1072
+ || lang === 'php'
1073
+ // New langs with single-quote string literals: swift uses double quotes
1074
+ // only (excluded); kotlin uses double/triple-double (excluded); scala
1075
+ // single-quotes are Char literals not strings (excluded); bash and lua
1076
+ // both support `'...'` single-quoted strings.
1077
+ || lang === 'bash'
1078
+ || lang === 'lua'
1079
+ // Second batch. dart: `'...'` is a primary string form → included. r:
1080
+ // `'...'` is a string literal equivalent to `"..."` → included. objc:
1081
+ // `'x'` is a char literal — INCLUDED here so its contents are masked as a
1082
+ // single-quote string. This deliberately DIVERGES from c/cpp, which are
1083
+ // NOT in this list: objc's masker benefits from neutralizing char-literal
1084
+ // bytes, whereas c/cpp char literals are left unmasked.
1085
+ // elixir: EXCLUDED — `'...'` is a charlist, not a string; but charlists
1086
+ // are single-line and `\\`-escaped just like a string, so masking them as
1087
+ // strings would be safe — they are nonetheless left out to keep elixir
1088
+ // string handling limited to `"..."`/`"""` (charlist contents are rare in
1089
+ // code-graph anchors and excluding avoids masking a stray apostrophe in a
1090
+ // comment-less context). zig: EXCLUDED — `'c'` is a char literal only and
1091
+ // zig multiline strings are `\\`-prefixed lines (out of scope), so no
1092
+ // single-quote string form applies.
1093
+ || lang === 'dart'
1094
+ || lang === 'r'
1095
+ || lang === 'objc';
1096
+ }
1097
+
1098
+ function _supportsBacktickStrings(lang) {
1099
+ return lang === 'typescript' || lang === 'javascript' || lang === 'go';
1100
+ }
1101
+
1102
+ function _supportsTripleSingleQuoteStrings(lang) {
1103
+ // `'''` triple-single-quote strings: python, and dart (which supports BOTH
1104
+ // `'''` and `"""` multiline strings). kotlin/scala/swift have `"""` but NOT
1105
+ // `'''`; treating `'''` as a string opener there would mis-mask a
1106
+ // single-quote char/string followed by an empty string.
1107
+ return lang === 'python' || lang === 'dart';
1108
+ }
1109
+
1110
+ function _supportsTripleDoubleQuoteStrings(lang) {
1111
+ // `"""` triple-double-quote raw/multiline strings: python, kotlin, scala
1112
+ // and swift. bash/lua have no triple-quote form (lua long strings use
1113
+ // `[[ ]]`).
1114
+ // Second batch: elixir `"""` heredoc docstrings → included. dart supports
1115
+ // BOTH `'''` and `"""` multiline strings → included here (and in the triple-
1116
+ // single predicate). objc/zig/r have no `"""` form.
1117
+ return lang === 'python' || lang === 'kotlin'
1118
+ || lang === 'scala' || lang === 'swift' || lang === 'elixir'
1119
+ || lang === 'dart';
1120
+ }
1121
+
1122
+ function _isJsLike(lang) {
1123
+ return lang === 'javascript' || lang === 'typescript';
1124
+ }
1125
+
1126
+ function _isWordStartChar(c) {
1127
+ return c === '_' || c === '$'
1128
+ || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
1129
+ }
1130
+
1131
+ function _isWordChar(c) {
1132
+ return _isWordStartChar(c) || (c >= '0' && c <= '9');
1133
+ }
1134
+
1135
+ // ECMAScript expression-context keywords that can precede a regex literal.
1136
+ // After any of these, a `/` opens a RegExp literal; after a value (identifier,
1137
+ // number, `)`, `]`), `/` is the division operator. This list is from the
1138
+ // language spec — not a heuristic — and resolves the `/`-ambiguity.
1139
+ const REGEX_PRECEDENT_KEYWORDS = new Set([
1140
+ 'return', 'typeof', 'delete', 'void', 'new', 'throw', 'await', 'yield',
1141
+ 'in', 'of', 'instanceof', 'case', 'do', 'else', 'if', 'while',
1142
+ ]);
1143
+
1144
+ const REGEX_PRECEDENT_CHARS = new Set([
1145
+ '=', '(', ',', ';', ':', '?', '!', '~', '&', '|', '^', '+', '-',
1146
+ '*', '%', '<', '>', '{', '[',
1147
+ ]);
1148
+
1149
+ // Mask a JS regex literal body starting at `start` (which points at `/`).
1150
+ // Handles `\` escapes and `[...]` character classes per ECMAScript spec.
1151
+ // Returns the index just past the closing `/flags`. Bytes between the
1152
+ // delimiters are replaced with spaces in `out` so downstream identifier
1153
+ // searches do not see them.
1154
+ function _maskJsRegexLiteral(src, out, start) {
1155
+ if (src[start] !== '\n') out[start] = ' ';
1156
+ let j = start + 1;
1157
+ let inCharClass = false;
1158
+ while (j < src.length) {
1159
+ const c = src[j];
1160
+ if (c === '\n') return j;
1161
+ if (c === '\\') {
1162
+ if (src[j] !== '\n') out[j] = ' ';
1163
+ if (j + 1 < src.length && src[j + 1] !== '\n') out[j + 1] = ' ';
1164
+ j += 2;
1165
+ continue;
1166
+ }
1167
+ if (c === '[' && !inCharClass) {
1168
+ inCharClass = true;
1169
+ if (src[j] !== '\n') out[j] = ' ';
1170
+ j++;
1171
+ continue;
1172
+ }
1173
+ if (c === ']' && inCharClass) {
1174
+ inCharClass = false;
1175
+ if (src[j] !== '\n') out[j] = ' ';
1176
+ j++;
1177
+ continue;
1178
+ }
1179
+ if (c === '/' && !inCharClass) {
1180
+ if (src[j] !== '\n') out[j] = ' ';
1181
+ j++;
1182
+ while (j < src.length && src[j] >= 'a' && src[j] <= 'z') {
1183
+ if (src[j] !== '\n') out[j] = ' ';
1184
+ j++;
1185
+ }
1186
+ return j;
1187
+ }
1188
+ if (src[j] !== '\n') out[j] = ' ';
1189
+ j++;
1190
+ }
1191
+ return j;
1192
+ }
1193
+
1194
+ function _maskNonCodeText(text, lang) {
1195
+ const src = String(text || '');
1196
+ const out = src.split('');
1197
+ let i = 0;
1198
+ let blockComment = false;
1199
+ // Stack of scanner frames. Top describes current state:
1200
+ // { kind: 'string', delim } — inside single-line string literal (mask body)
1201
+ // { kind: 'triple', delim } — inside triple-quote string (mask body)
1202
+ // { kind: 'interp', braceDepth } — inside backtick `${...}` interpolation
1203
+ // (code mode; bytes preserved so callers
1204
+ // analysis can see fn-calls inside)
1205
+ // Empty stack = top-level code.
1206
+ const stack = [];
1207
+ const top = () => (stack.length ? stack[stack.length - 1] : null);
1208
+ // prevToken tracks ECMAScript token context for the `/`-disambiguation:
1209
+ // 'expr' = expression-start (regex literal may follow)
1210
+ // 'value' = value/operand (`/` is division)
1211
+ // Start of file = expression context.
1212
+ let prevToken = 'expr';
1213
+ while (i < src.length) {
1214
+ if (blockComment) {
1215
+ if (src.startsWith('*/', i)) {
1216
+ out[i] = ' ';
1217
+ if (i + 1 < out.length) out[i + 1] = ' ';
1218
+ i += 2;
1219
+ blockComment = false;
1220
+ continue;
1221
+ }
1222
+ if (src[i] !== '\n') out[i] = ' ';
1223
+ i++;
1224
+ continue;
1225
+ }
1226
+ const t = top();
1227
+ if (t && t.kind === 'triple') {
1228
+ if (src.startsWith(t.delim, i)) {
1229
+ for (let j = 0; j < t.delim.length; j++) {
1230
+ if (src[i + j] !== '\n') out[i + j] = ' ';
1231
+ }
1232
+ i += t.delim.length;
1233
+ stack.pop();
1234
+ prevToken = 'value';
1235
+ continue;
1236
+ }
1237
+ if (src[i] !== '\n') out[i] = ' ';
1238
+ i++;
1239
+ continue;
1240
+ }
1241
+ if (t && t.kind === 'luablock') {
1242
+ // Lua long-bracket comment `--[=*[ ... ]=*]` — mask until the EXACT
1243
+ // matching close delimiter (`]` + same number of `=` + `]`) recorded
1244
+ // on the frame, so `--[==[ ]] ]==]` closes only at `]==]`.
1245
+ if (t.close && src.startsWith(t.close, i)) {
1246
+ for (let j = 0; j < t.close.length; j++) {
1247
+ if (src[i + j] !== '\n') out[i + j] = ' ';
1248
+ }
1249
+ i += t.close.length;
1250
+ stack.pop();
1251
+ prevToken = 'value';
1252
+ continue;
1253
+ }
1254
+ if (src[i] !== '\n') out[i] = ' ';
1255
+ i++;
1256
+ continue;
1257
+ }
1258
+ if (t && t.kind === 'string') {
1259
+ const d = t.delim;
1260
+ if (d === '`' && src.startsWith('${', i)) {
1261
+ // Enter interpolation. `${` itself is code-relevant — leave bytes intact.
1262
+ stack.push({ kind: 'interp', braceDepth: 1 });
1263
+ i += 2;
1264
+ prevToken = 'expr';
1265
+ continue;
1266
+ }
1267
+ // In bash single-quotes `'...'`, backslash is literal (no escape) — the
1268
+ // string closes at the first `'`. Skip the escape consumption there so
1269
+ // `'\'` is not mis-read as an escaped quote. bash `"..."` and all other
1270
+ // langs keep backslash-escape handling.
1271
+ const bashLiteralSingle = t.lang === 'bash' && d === '\'';
1272
+ if (!bashLiteralSingle && src[i] === '\\' && (d === '\'' || d === '"' || d === '`')) {
1273
+ if (src[i] !== '\n') out[i] = ' ';
1274
+ if (i + 1 < src.length && src[i + 1] !== '\n') out[i + 1] = ' ';
1275
+ i += 2;
1276
+ continue;
1277
+ }
1278
+ if (src[i] === d) {
1279
+ if (src[i] !== '\n') out[i] = ' ';
1280
+ i++;
1281
+ stack.pop();
1282
+ prevToken = 'value';
1283
+ continue;
1284
+ }
1285
+ // JS forbids a raw newline inside '...' or "..." — defensive reset. bash
1286
+ // quoted strings legally span newlines, so do NOT reset bash frames.
1287
+ if (src[i] === '\n' && t.lang !== 'bash' && (d === '\'' || d === '"')) {
1288
+ stack.pop();
1289
+ prevToken = 'value';
1290
+ i++;
1291
+ continue;
1292
+ }
1293
+ if (src[i] !== '\n') out[i] = ' ';
1294
+ i++;
1295
+ continue;
1296
+ }
1297
+ if (t && t.kind === 'interp') {
1298
+ // Code mode inside `${...}`. Bytes preserved; track brace depth and
1299
+ // nested constructs so masking resumes once interpolation closes.
1300
+ if (src[i] === '{') {
1301
+ t.braceDepth++;
1302
+ prevToken = 'expr';
1303
+ i++;
1304
+ continue;
1305
+ }
1306
+ if (src[i] === '}') {
1307
+ t.braceDepth--;
1308
+ i++;
1309
+ if (t.braceDepth === 0) {
1310
+ stack.pop();
1311
+ prevToken = 'value';
1312
+ } else {
1313
+ prevToken = 'value';
1314
+ }
1315
+ continue;
1316
+ }
1317
+ if (_supportsSlashComments(lang) && src.startsWith('/*', i)) {
1318
+ out[i] = ' ';
1319
+ if (i + 1 < out.length) out[i + 1] = ' ';
1320
+ i += 2;
1321
+ blockComment = true;
1322
+ continue;
1323
+ }
1324
+ if (_supportsSlashComments(lang) && src.startsWith('//', i)) {
1325
+ while (i < src.length && src[i] !== '\n') {
1326
+ out[i] = ' ';
1327
+ i++;
1328
+ }
1329
+ continue;
1330
+ }
1331
+ if (src[i] === '/' && _isJsLike(lang) && prevToken === 'expr') {
1332
+ i = _maskJsRegexLiteral(src, out, i);
1333
+ prevToken = 'value';
1334
+ continue;
1335
+ }
1336
+ if (src[i] === '"' || (_supportsSingleQuoteStrings(lang) && src[i] === '\'') || (_supportsBacktickStrings(lang) && src[i] === '`')) {
1337
+ if (src[i] !== '\n') out[i] = ' ';
1338
+ stack.push({ kind: 'string', delim: src[i], lang });
1339
+ i++;
1340
+ continue;
1341
+ }
1342
+ if (_isWordStartChar(src[i])) {
1343
+ const start = i;
1344
+ while (i < src.length && _isWordChar(src[i])) i++;
1345
+ const word = src.substring(start, i);
1346
+ prevToken = REGEX_PRECEDENT_KEYWORDS.has(word) ? 'expr' : 'value';
1347
+ continue;
1348
+ }
1349
+ if (src[i] >= '0' && src[i] <= '9') {
1350
+ while (i < src.length && (src[i] === '.' || (src[i] >= '0' && src[i] <= '9'))) i++;
1351
+ prevToken = 'value';
1352
+ continue;
1353
+ }
1354
+ if (src[i] === ' ' || src[i] === '\t' || src[i] === '\r' || src[i] === '\n') {
1355
+ i++;
1356
+ continue;
1357
+ }
1358
+ if (REGEX_PRECEDENT_CHARS.has(src[i])) {
1359
+ prevToken = 'expr';
1360
+ } else {
1361
+ prevToken = 'value';
1362
+ }
1363
+ i++;
1364
+ continue;
1365
+ }
1366
+ // Top-level code.
1367
+ if (_supportsSlashComments(lang) && src.startsWith('/*', i)) {
1368
+ out[i] = ' ';
1369
+ if (i + 1 < out.length) out[i + 1] = ' ';
1370
+ i += 2;
1371
+ blockComment = true;
1372
+ continue;
1373
+ }
1374
+ if (_supportsSlashComments(lang) && src.startsWith('//', i)) {
1375
+ while (i < src.length && src[i] !== '\n') {
1376
+ out[i] = ' ';
1377
+ i++;
1378
+ }
1379
+ continue;
1380
+ }
1381
+ if (_supportsHashComments(lang) && src[i] === '#') {
1382
+ // Bash `#` is a comment ONLY at line start or after whitespace. When it
1383
+ // follows a non-space char it is part of `${var#pat}` / `${var##pat}`
1384
+ // parameter expansion (or `$#`, `arr[#]`, etc.), NOT a comment — masking
1385
+ // there would erase the rest of the line. `#!` shebang sits at file
1386
+ // start (a line start) so it is still masked.
1387
+ if (lang === 'bash') {
1388
+ const prev = i > 0 ? src[i - 1] : '\n';
1389
+ const atCommentPos = prev === '\n' || prev === ' ' || prev === '\t' || prev === '\r';
1390
+ if (!atCommentPos) {
1391
+ prevToken = 'value';
1392
+ i++;
1393
+ continue;
1394
+ }
1395
+ }
1396
+ while (i < src.length && src[i] !== '\n') {
1397
+ out[i] = ' ';
1398
+ i++;
1399
+ }
1400
+ continue;
1401
+ }
1402
+ // Lua comments: `--[=*[ ... ]=*]` long-bracket block and `--` line. Lua is
1403
+ // neither slash nor hash (see comment predicates), so it needs this
1404
+ // dedicated branch. Checked before number/operator handling so the leading
1405
+ // `--` is consumed as a comment, not as two minus operators.
1406
+ if (lang === 'lua' && src.startsWith('--', i)) {
1407
+ // Long-bracket opener: `--` then `[` + zero-or-more `=` + `[`. The level
1408
+ // (`=` count) selects the matching close `]` + same `=` + `]`.
1409
+ const lb = /^--\[(=*)\[/.exec(src.slice(i, i + 64));
1410
+ if (lb) {
1411
+ const open = lb[0];
1412
+ for (let j = 0; j < open.length; j++) {
1413
+ if (src[i + j] !== '\n') out[i + j] = ' ';
1414
+ }
1415
+ i += open.length;
1416
+ stack.push({ kind: 'luablock', close: `]${lb[1]}]` });
1417
+ continue;
1418
+ }
1419
+ // Plain `--` line comment (no long-bracket opener follows).
1420
+ while (i < src.length && src[i] !== '\n') {
1421
+ out[i] = ' ';
1422
+ i++;
1423
+ }
1424
+ continue;
1425
+ }
1426
+ if (_supportsTripleSingleQuoteStrings(lang) && src.startsWith("'''", i)) {
1427
+ out[i] = ' ';
1428
+ if (i + 1 < out.length) out[i + 1] = ' ';
1429
+ if (i + 2 < out.length) out[i + 2] = ' ';
1430
+ i += 3;
1431
+ stack.push({ kind: 'triple', delim: "'''" });
1432
+ continue;
1433
+ }
1434
+ if (_supportsTripleDoubleQuoteStrings(lang) && src.startsWith('"""', i)) {
1435
+ out[i] = ' ';
1436
+ if (i + 1 < out.length) out[i + 1] = ' ';
1437
+ if (i + 2 < out.length) out[i + 2] = ' ';
1438
+ i += 3;
1439
+ stack.push({ kind: 'triple', delim: '"""' });
1440
+ continue;
1441
+ }
1442
+ if (src[i] === '/' && _isJsLike(lang) && prevToken === 'expr') {
1443
+ i = _maskJsRegexLiteral(src, out, i);
1444
+ prevToken = 'value';
1445
+ continue;
1446
+ }
1447
+ if (src[i] === '"' || (_supportsSingleQuoteStrings(lang) && src[i] === '\'') || (_supportsBacktickStrings(lang) && src[i] === '`')) {
1448
+ if (src[i] !== '\n') out[i] = ' ';
1449
+ stack.push({ kind: 'string', delim: src[i], lang });
1450
+ i++;
1451
+ continue;
1452
+ }
1453
+ if (_isWordStartChar(src[i])) {
1454
+ const start = i;
1455
+ while (i < src.length && _isWordChar(src[i])) i++;
1456
+ const word = src.substring(start, i);
1457
+ prevToken = REGEX_PRECEDENT_KEYWORDS.has(word) ? 'expr' : 'value';
1458
+ continue;
1459
+ }
1460
+ if (src[i] >= '0' && src[i] <= '9') {
1461
+ while (i < src.length && (src[i] === '.' || (src[i] >= '0' && src[i] <= '9'))) i++;
1462
+ prevToken = 'value';
1463
+ continue;
1464
+ }
1465
+ if (src[i] === ' ' || src[i] === '\t' || src[i] === '\r' || src[i] === '\n') {
1466
+ i++;
1467
+ continue;
1468
+ }
1469
+ if (REGEX_PRECEDENT_CHARS.has(src[i])) {
1470
+ prevToken = 'expr';
1471
+ } else {
1472
+ prevToken = 'value';
1473
+ }
1474
+ i++;
1475
+ }
1476
+ return out.join('');
1477
+ }
1478
+
1479
+ function _symbolMatchIndices(text, symbol, lang) {
1480
+ const escaped = String(symbol || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1481
+ if (!escaped) return [];
1482
+ const masked = _maskNonCodeText(text, lang);
1483
+ const re = new RegExp(_unicodeBoundaryPattern(escaped, lang, symbol), 'gu');
1484
+ const indices = [];
1485
+ let match = null;
1486
+ while ((match = re.exec(masked))) {
1487
+ indices.push(match.index);
1488
+ }
1489
+ return indices;
1490
+ }
1491
+
1492
+ function _getSourceTextForNode(graph, node, fallbackText = null) {
1493
+ const cached = graph?._sourceTextCache?.get(node.rel);
1494
+ if (cached && cached.fingerprint === (node.fingerprint || '')) {
1495
+ return cached.text;
1496
+ }
1497
+ if (typeof fallbackText === 'string') {
1498
+ graph?._sourceTextCache?.set(node.rel, {
1499
+ fingerprint: node.fingerprint || '',
1500
+ text: fallbackText,
1501
+ });
1502
+ return fallbackText;
1503
+ }
1504
+ let text = '';
1505
+ let readOk = false;
1506
+ try { text = readFileSync(node.abs, 'utf8'); readOk = true; } catch { text = ''; readOk = false; }
1507
+ if (readOk) {
1508
+ graph?._sourceTextCache?.set(node.rel, {
1509
+ fingerprint: node.fingerprint || '',
1510
+ text,
1511
+ });
1512
+ }
1513
+ return text;
1514
+ }
1515
+
1516
+ function _buildExplainerFileSummary(node, graph, cwd) {
1517
+ const topTypes = Array.isArray(node?.topLevelTypes) ? node.topLevelTypes.slice(0, 8) : [];
1518
+ const importsAll = Array.isArray(node?.resolvedImports) ? node.resolvedImports.map((p) => _graphRel(p, cwd)) : [];
1519
+ const imports = importsAll.slice(0, 8);
1520
+ const tokensAll = _getTokenSymbolsForNode(graph, node);
1521
+ // Prefer native tree-sitter symbol names (declarations only — no
1522
+ // comment/string/keyword leakage); fall back to the regex token dump
1523
+ // only when the native graph path didn't populate node.symbols.
1524
+ const hasNativeSymbols = Array.isArray(node?.symbols) && node.symbols.length > 0;
1525
+ const symbolsAll = hasNativeSymbols
1526
+ ? [...new Set(node.symbols.map((s) => s.name))]
1527
+ : tokensAll;
1528
+ const symbolNames = symbolsAll.slice(0, hasNativeSymbols ? 30 : 20);
1529
+ const anchors = _extractExplainerAnchorLines(node, graph);
1530
+ const sourceHead = _getSourceTextForNode(graph, node)
1531
+ .split(/\r?\n/)
1532
+ .slice(0, 6)
1533
+ .join('\n')
1534
+ .trim()
1535
+ .slice(0, 420);
1536
+ const parts = [
1537
+ `file: ${node.rel}`,
1538
+ `language: ${node.lang}`,
1539
+ ];
1540
+ if (topTypes.length) parts.push(`top-level: ${topTypes.join(', ')}`);
1541
+ // A capped list with no marker reads as "this is everything" — when cut,
1542
+ // say so and point at the uncapped mode.
1543
+ if (symbolNames.length) {
1544
+ const more = symbolsAll.length - symbolNames.length;
1545
+ parts.push(`symbols: ${symbolNames.join(', ')}${more > 0 ? `, … +${more} more (mode:symbols for full list)` : ''}`);
1546
+ }
1547
+ if (imports.length) {
1548
+ const more = importsAll.length - imports.length;
1549
+ parts.push(`imports: ${imports.join(', ')}${more > 0 ? `, … +${more} more (mode:imports for full list)` : ''}`);
1550
+ }
1551
+ if (anchors.length) parts.push(`anchors:\n${anchors.join('\n')}`);
1552
+ if (sourceHead) parts.push(`head:\n${sourceHead}`);
1553
+ return parts.join('\n');
1554
+ }
1555
+
1556
+ function _getSourceLinesForNode(graph, node) {
1557
+ const cached = graph?._sourceLinesCache?.get(node.rel);
1558
+ if (cached && cached.fingerprint === (node.fingerprint || '')) {
1559
+ return cached.lines;
1560
+ }
1561
+ const text = _getSourceTextForNode(graph, node);
1562
+ const lines = text.split(/\r?\n/);
1563
+ graph?._sourceLinesCache?.set(node.rel, {
1564
+ fingerprint: node.fingerprint || '',
1565
+ lines,
1566
+ });
1567
+ return lines;
1568
+ }
1569
+
1570
+ function _getMaskedLinesForNode(graph, node) {
1571
+ const cached = graph?._maskedLinesCache?.get(node.rel);
1572
+ if (cached && cached.fingerprint === (node.fingerprint || '')) {
1573
+ return cached.lines;
1574
+ }
1575
+ const text = _getSourceTextForNode(graph, node);
1576
+ const lines = _maskNonCodeText(text, node.lang).split(/\r?\n/);
1577
+ graph?._maskedLinesCache?.set(node.rel, {
1578
+ fingerprint: node.fingerprint || '',
1579
+ lines,
1580
+ });
1581
+ return lines;
1582
+ }
1583
+
1584
+ function _pickCalleeDeclHit(hits, preferRel) {
1585
+ if (!hits?.length) return null;
1586
+ const sameFileDecl = preferRel ? hits.find((h) => h.rel === preferRel && h.declarationLike) : null;
1587
+ if (sameFileDecl) return sameFileDecl;
1588
+ const depthOf = (rel) => String(rel || '').split('/').length;
1589
+ const isCanonicalSrc = (rel) => /^src\//.test(rel || '');
1590
+ const sorted = [...hits].sort((a, b) =>
1591
+ Number(b.declarationLike) - Number(a.declarationLike)
1592
+ || Number(isCanonicalSrc(b.rel)) - Number(isCanonicalSrc(a.rel))
1593
+ || depthOf(a.rel) - depthOf(b.rel)
1594
+ || b.matchCount - a.matchCount
1595
+ || a.rel.localeCompare(b.rel)
1596
+ || a.line - b.line
1597
+ );
1598
+ return sorted.find((h) => h.declarationLike) || sorted[0];
1599
+ }
1600
+
1601
+ function _resolveCalleeDeclaration(graph, name, { language = null, preferRel = null } = {}) {
1602
+ return _pickCalleeDeclHit(_findSymbolHits(graph, name, { language }), preferRel);
1603
+ }
1604
+
1605
+ // Parallel pre-read source text for the indexed candidate files.
1606
+ // Without this, _cheapReferenceSearch performs ~200 sequential
1607
+ // readFileSync calls on warm-cache lookups (the in-memory text cache
1608
+ // is fresh on each new process). For cross-codebase queries like
1609
+ // `parseInt callers cwd=refs`, this was the dominant cost (~3-5s of
1610
+ // the ~6s warm-lookup wall). Reads are dispatched concurrently via
1611
+ // fs/promises so OS I/O scheduler can overlap them.
1612
+ async function _prewarmReferenceSourceText(graph, symbol, language) {
1613
+ const candidateNodes = _lookupCandidateNodes(graph, symbol, language);
1614
+ if (!candidateNodes.length) return;
1615
+ const uncached = [];
1616
+ for (const node of candidateNodes) {
1617
+ const cached = graph._sourceTextCache?.get(node.rel);
1618
+ if (!cached || cached.fingerprint !== (node.fingerprint || '')) {
1619
+ uncached.push(node);
1620
+ }
1621
+ }
1622
+ if (uncached.length === 0) return;
1623
+ const { readFile } = await import('fs/promises');
1624
+ const concurrency = 64;
1625
+ let next = 0;
1626
+ async function worker() {
1627
+ while (true) {
1628
+ const index = next++;
1629
+ if (index >= uncached.length) return;
1630
+ const node = uncached[index];
1631
+ try {
1632
+ const text = await readFile(node.abs, 'utf8');
1633
+ graph._sourceTextCache?.set(node.rel, { fingerprint: node.fingerprint || '', text });
1634
+ } catch { /* skip unreadable file */ }
1635
+ }
1636
+ }
1637
+ const workerCount = Math.min(Math.max(1, concurrency), uncached.length);
1638
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1639
+ }
1640
+
1641
+ function _cheapReferenceSearch(graph, symbol, cwd, { language = null, limit = null, fileRel = null, scopeRelPrefix = null } = {}) {
1642
+ const escaped = String(symbol || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1643
+ if (!escaped) return '(no references)';
1644
+ // Include the effective cap + file scope in the cache key so a follow-up
1645
+ // call with a larger limit or a different file filter doesn't get served
1646
+ // the previously-trimmed/wide result.
1647
+ // Default `d` marks the env-default cap (REFERENCE_HIT_CAP).
1648
+ const cacheKey = `${language || '*'}|${symbol}|${Number.isFinite(limit) && limit > 0 ? String(Math.floor(limit)) : 'd'}|${fileRel || '*'}|${scopeRelPrefix || '*'}`;
1649
+ const cached = graph?._referenceSearchCache?.get(cacheKey);
1650
+ if (typeof cached === 'string') {
1651
+ return cached;
1652
+ }
1653
+ const lines = [];
1654
+ let candidateNodes = _lookupCandidateNodes(graph, symbol, language);
1655
+ if (fileRel) candidateNodes = candidateNodes.filter((node) => node.rel === fileRel);
1656
+ if (scopeRelPrefix) candidateNodes = candidateNodes.filter((node) => node.rel === scopeRelPrefix.slice(0, -1) || node.rel.startsWith(scopeRelPrefix));
1657
+ // Output cap. Default raised from 40 to 200 (HS-A5 retry showed the
1658
+ // formatter-layer cap raise was masked because the SEARCH-layer cap
1659
+ // here caps `lines` at 40 before the formatter sees them). 80 chars
1660
+ // per lineText is unchanged.
1661
+ // Per-call cap takes priority over env default so user-supplied limit
1662
+ // bounds the search loop (early break) instead of paying the full env
1663
+ // cap scan + trimming at the formatter.
1664
+ const ENV_CAP = Math.max(1, Number(process.env.REFERENCE_HIT_CAP) || 200);
1665
+ const REFERENCE_HIT_CAP = limit !== null && Number.isFinite(limit) && limit > 0
1666
+ ? Math.min(Math.max(1, Math.floor(limit)), ENV_CAP)
1667
+ : ENV_CAP;
1668
+ const REFERENCE_LINE_CAP = Math.max(20, Number(process.env.REFERENCE_LINE_CAP) || 80);
1669
+ let cappedOut = false;
1670
+ outer: for (const node of candidateNodes) {
1671
+ const sourceText = _getSourceTextForNode(graph, node);
1672
+ if (!sourceText.includes(symbol)) continue;
1673
+ const fileLines = _getMaskedLinesForNode(graph, node);
1674
+ // Masked lines are for MATCHING only (no hits inside strings/comments).
1675
+ // Display must use the RAW line: masking blanks string contents, which
1676
+ // mangles snippets containing template literals / quoted paths. Offsets
1677
+ // are preserved by the space-fill masking, so i / match.index still map.
1678
+ const rawLines = _getSourceLinesForNode(graph, node);
1679
+ for (let i = 0; i < fileLines.length; i++) {
1680
+ const line = fileLines[i];
1681
+ if (!line.trim()) continue;
1682
+ const boundaryLang = language || node.lang;
1683
+ const re = new RegExp(_unicodeBoundaryPattern(escaped, boundaryLang, symbol), 'gu');
1684
+ let match = null;
1685
+ while ((match = re.exec(line))) {
1686
+ if (lines.length < REFERENCE_HIT_CAP) {
1687
+ const trimmed = (rawLines[i] ?? line).trim().slice(0, REFERENCE_LINE_CAP);
1688
+ lines.push(`${node.rel}:${i + 1}:${match.index + 1} ${trimmed}`);
1689
+ } else {
1690
+ // Stop as soon as the per-call cap is reached. The previous
1691
+ // 4x-cap scan was used to estimate totalHits for the
1692
+ // "+ N more" footer, but with limit propagation that estimate
1693
+ // is no longer meaningful; users who need accurate totals
1694
+ // pass a higher limit or set REFERENCE_HIT_CAP env.
1695
+ cappedOut = true;
1696
+ break outer;
1697
+ }
1698
+ }
1699
+ }
1700
+ }
1701
+ const result = lines.length ? lines.join('\n') : '(no references)';
1702
+ const finalResult = cappedOut
1703
+ ? `${result}\n\n[truncated — total hits exceeded ${REFERENCE_HIT_CAP * 4}, showing first ${REFERENCE_HIT_CAP}; raise REFERENCE_HIT_CAP env var for more]`
1704
+ : result;
1705
+ graph?._referenceSearchCache?.set(cacheKey, finalResult);
1706
+ return finalResult;
1707
+ }
1708
+
1709
+ function _nativeEndLineForDecl(node, symbolName, declLine) {
1710
+ const symbols = Array.isArray(node?.symbols) ? node.symbols : [];
1711
+ if (!symbols.length || !symbolName) return null;
1712
+ const dl = Number(declLine);
1713
+ if (!Number.isFinite(dl)) return null;
1714
+ let exact = null;
1715
+ let nearest = null;
1716
+ let nearestDist = Infinity;
1717
+ for (const s of symbols) {
1718
+ if (!s || s.name !== symbolName) continue;
1719
+ const sl = Number(s.startLine ?? s.line);
1720
+ const el = Number(s.endLine);
1721
+ if (!Number.isFinite(sl) || !Number.isFinite(el)) continue;
1722
+ if (sl === dl && el >= dl) exact = el;
1723
+ const dist = Math.abs(sl - dl);
1724
+ if (dist < nearestDist) {
1725
+ nearestDist = dist;
1726
+ nearest = el >= sl ? el : null;
1727
+ }
1728
+ }
1729
+ if (exact != null) return exact;
1730
+ return nearestDist <= 2 ? nearest : null;
1731
+ }
1732
+
1733
+ function _formatSymbolHitLocation(hit) {
1734
+ const line = Number(hit.line);
1735
+ const col = Number(hit.col) || 1;
1736
+ const end = Number(hit.endLine);
1737
+ if (Number.isFinite(end) && end >= line) return `${hit.rel}:${line}-${end}:${col}`;
1738
+ return `${hit.rel}:${line}:${col}`;
1739
+ }
1740
+
1741
+ function _sortSymbolHits(hits) {
1742
+ if (!hits?.length) return hits;
1743
+ const depthOf = (rel) => String(rel || '').split('/').length;
1744
+ const isCanonicalSrc = (rel) => /^src\//.test(rel || '');
1745
+ hits.sort((a, b) =>
1746
+ Number(b.declarationLike) - Number(a.declarationLike)
1747
+ || Number(isCanonicalSrc(b.rel)) - Number(isCanonicalSrc(a.rel))
1748
+ || depthOf(a.rel) - depthOf(b.rel)
1749
+ || b.matchCount - a.matchCount
1750
+ || a.rel.localeCompare(b.rel)
1751
+ || a.line - b.line
1752
+ );
1753
+ const declCount = hits.reduce((n, h) => n + (h.declarationLike ? 1 : 0), 0);
1754
+ if (declCount > 1 && hits[0]) hits[0].ambiguousDeclaration = declCount;
1755
+ return hits;
1756
+ }
1757
+
1758
+ function _findSymbolHits(graph, symbol, { language = null } = {}) {
1759
+ const cleanSymbol = String(symbol || '').trim();
1760
+ if (!cleanSymbol) return [];
1761
+ const candidateNodes = _lookupCandidateNodes(graph, cleanSymbol, language);
1762
+ return _findSymbolHitsOnNodes(graph, cleanSymbol, candidateNodes, { language });
1763
+ }
1764
+
1765
+ function _findSymbolHitsOnNodes(graph, cleanSymbol, candidateNodes, { language = null } = {}) {
1766
+ if (!cleanSymbol) return [];
1767
+
1768
+ const escaped = cleanSymbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1769
+ // Declaration regex must anchor the symbol immediately after a
1770
+ // declaration keyword. The previous pattern (`\bkeyword\b[^\n]*\bX\b`)
1771
+ // matched ordinary callsites like `const result = doFoo(X)` as a
1772
+ // declaration of X, producing a wrong "best declaration candidate".
1773
+ // Allow optional `export [default]` / `async` modifiers and `function*`.
1774
+ // Declaration keyword set spans JS/TS, Python (def/class), Go (func/type),
1775
+ // Rust (fn/struct/enum/trait/mod), C/C++ (struct/union/typedef), C#/Java/
1776
+ // Kotlin (class/interface/record/object/struct), Ruby/PHP (def/function).
1777
+ // Restricting to only the JS/Py set was producing false "no declaration"
1778
+ // results for cross-language hits.
1779
+ const declRe = new RegExp(
1780
+ `(?:^|[\\s;{(,])(?:export\\s+(?:default\\s+)?)?(?:public\\s+|private\\s+|protected\\s+|internal\\s+|static\\s+|abstract\\s+|final\\s+|sealed\\s+|virtual\\s+|override\\s+|async\\s+|pub\\s+(?:\\([^)]*\\)\\s+)?)*(?:const|let|var|function\\*?|class|interface|type|enum|def|func|fn|struct|union|trait|impl|mod|record|object|typedef|namespace|package)\\s+${escaped}\\b`
1781
+ );
1782
+ // Assignment-style declarations: `const|let|var NAME = (…) =>` and
1783
+ // `const|let|var NAME = function`. tree-sitter often records these as a
1784
+ // variable binding the regex `declRe` already matches, but when native
1785
+ // symbols exist the regex path is gated off (see :declRe usage below), so
1786
+ // the const-arrow/const-function form was understated as `[ref]`. This
1787
+ // regex is consulted regardless of native-symbol presence so a real
1788
+ // function value bound to a name is classified `[decl]`.
1789
+ const assignDeclRe = new RegExp(
1790
+ `(?:^|[\\s;{(,])(?:export\\s+(?:default\\s+)?)?(?:const|let|var)\\s+${escaped}\\s*=\\s*(?:async\\s+)?(?:function\\b|(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*=>)`
1791
+ );
1792
+ const hits = [];
1793
+
1794
+ for (const node of candidateNodes) {
1795
+ const sourceText = _getSourceTextForNode(graph, node);
1796
+ if (!sourceText.includes(cleanSymbol)) continue;
1797
+ const boundaryLang = language || node.lang;
1798
+ const re = new RegExp(_unicodeBoundaryPattern(escaped, boundaryLang, cleanSymbol), 'gu');
1799
+ const sourceLines = _getSourceLinesForNode(graph, node);
1800
+ const lines = _getMaskedLinesForNode(graph, node);
1801
+ let firstLine = null;
1802
+ let firstCol = null;
1803
+ let matchCount = 0;
1804
+ let firstContent = '';
1805
+ let contextLines = [];
1806
+ let declarationLike = Array.isArray(node.topLevelTypes) && node.topLevelTypes.includes(cleanSymbol);
1807
+ let declLine = null;
1808
+ let declCol = null;
1809
+ let declContent = '';
1810
+ let declContext = [];
1811
+ // Native declaration lines for `cleanSymbol`, mirroring the references
1812
+ // path (_collectDeclLines / _formatCallerReferences). The regex declRe
1813
+ // cannot recognise tree-sitter method / keyword-less function decls
1814
+ // (Java/C#/C++ `[type] name(args)`), so those were mis-reported as
1815
+ // references / "no declaration". node.symbols already carries the
1816
+ // authoritative {name,line} decl records; consult it (falling back to
1817
+ // the cheap scanner only when the native graph didn't populate it).
1818
+ const hasNativeSymbols = Array.isArray(node.symbols) && node.symbols.length > 0;
1819
+ const nativeDeclLines = new Set();
1820
+ const nativeSymbolSource = hasNativeSymbols ? node.symbols : _collectCheapSymbols(sourceText, node.lang);
1821
+ for (const sym of nativeSymbolSource) {
1822
+ if (sym && sym.name === cleanSymbol) nativeDeclLines.add(sym.line);
1823
+ }
1824
+ let nativeDeclLine = null;
1825
+ let nativeDeclCol = null;
1826
+ let nativeDeclContent = '';
1827
+ let nativeDeclContext = [];
1828
+ for (let i = 0; i < lines.length; i++) {
1829
+ const line = lines[i];
1830
+ if (!line.trim()) continue;
1831
+ re.lastIndex = 0;
1832
+ let localHit = false;
1833
+ let match = null;
1834
+ while ((match = re.exec(line))) {
1835
+ matchCount += 1;
1836
+ localHit = true;
1837
+ if (firstLine == null) {
1838
+ firstLine = i + 1;
1839
+ firstCol = match.index + 1;
1840
+ firstContent = String(sourceLines[i] || '').trim();
1841
+ contextLines = sourceLines.slice(i, i + 3).map((line) => String(line || '').trim()).filter(Boolean);
1842
+ }
1843
+ if (declLine == null && (assignDeclRe.test(line) || (!hasNativeSymbols && declRe.test(line)))) {
1844
+ declLine = i + 1;
1845
+ declCol = match.index + 1;
1846
+ declContent = String(sourceLines[i] || '').trim();
1847
+ declContext = sourceLines.slice(i, i + 3).map((l) => String(l || '').trim()).filter(Boolean);
1848
+ }
1849
+ if (nativeDeclLine == null && nativeDeclLines.has(i + 1)) {
1850
+ nativeDeclLine = i + 1;
1851
+ nativeDeclCol = match.index + 1;
1852
+ nativeDeclContent = String(sourceLines[i] || '').trim();
1853
+ nativeDeclContext = sourceLines.slice(i, i + 3).map((l) => String(l || '').trim()).filter(Boolean);
1854
+ }
1855
+ }
1856
+ if (localHit && (nativeDeclLines.has(i + 1) || assignDeclRe.test(line) || (!hasNativeSymbols && declRe.test(line)))) declarationLike = true;
1857
+ }
1858
+ if (firstLine == null) continue;
1859
+ // Prefer the native decl record over the regex-derived position when they
1860
+ // disagree: tree-sitter knows about keyword-less / method declarations the
1861
+ // regex misses, so it is the more reliable declaration reporter.
1862
+ if (nativeDeclLine != null) {
1863
+ declLine = nativeDeclLine;
1864
+ declCol = nativeDeclCol;
1865
+ declContent = nativeDeclContent;
1866
+ declContext = nativeDeclContext;
1867
+ }
1868
+ const hasDeclPos = declLine != null;
1869
+ const declLineForEnd = hasDeclPos ? declLine : firstLine;
1870
+ const endLine = _nativeEndLineForDecl(node, cleanSymbol, declLineForEnd);
1871
+ hits.push({
1872
+ rel: node.rel,
1873
+ lang: node.lang,
1874
+ line: hasDeclPos ? declLine : firstLine,
1875
+ col: hasDeclPos ? declCol : (firstCol || 1),
1876
+ ...(Number.isFinite(endLine) && endLine >= declLineForEnd ? { endLine } : {}),
1877
+ declarationLike,
1878
+ matchCount,
1879
+ content: hasDeclPos ? declContent : firstContent,
1880
+ context: hasDeclPos ? declContext : contextLines,
1881
+ firstLine,
1882
+ firstCol: firstCol || 1,
1883
+ firstContent,
1884
+ firstContext: contextLines,
1885
+ });
1886
+ }
1887
+
1888
+ if (!hits.length) return [];
1889
+ return _sortSymbolHits(hits);
1890
+ }
1891
+
1892
+ // Brace-delimited languages the callee body scanner supports. Non-brace
1893
+ // languages (Python, Ruby, and the new bash/lua) get a deterministic skip
1894
+ // downstream. kotlin/swift/scala ARE brace-bodied (C-style) so they stay in.
1895
+ // bash uses `do`/`done`/`fi`/`}` function bodies and lua uses `function`/`end`
1896
+ // — neither is `{ }`-delimited in the C sense, so both are deliberately
1897
+ // omitted and fall through to the `(callees unsupported for <lang>)` skip.
1898
+ // Second batch: dart/objc/zig are C-style `{ }`-bodied → included. elixir uses
1899
+ // `do`/`end` blocks (not braces) → excluded like ruby. r IS brace-bodied
1900
+ // (`f <- function(x) { ... }`), but it is excluded for a DIFFERENT reason than
1901
+ // bash/ruby: bash/ruby are excluded as non-C-brace-body languages, whereas r IS
1902
+ // C-brace-bodied — its problem is that the body scanner below only masks `//`
1903
+ // and `/*` comments and does NOT understand r's `#` line comments, so a `}` or
1904
+ // an unbalanced quote inside an r `#` comment would corrupt brace/quote
1905
+ // tracking. r is therefore deliberately omitted and falls through to the
1906
+ // `(callees unsupported for r)` skip.
1907
+ const _CALLEES_BRACE_LANGS = new Set([
1908
+ 'javascript', 'typescript', 'java', 'csharp', 'kotlin', 'go',
1909
+ 'rust', 'c', 'cpp', 'php', 'swift', 'scala', 'dart', 'objc', 'zig',
1910
+ ]);
1911
+
1912
+ // JS/TS reserved words / syntactic keywords that look like call
1913
+ // expressions but are not function invocations.
1914
+ const _CALLEES_JS_KEYWORDS = new Set([
1915
+ 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'default',
1916
+ 'return', 'yield', 'await', 'throw', 'try', 'catch', 'finally',
1917
+ 'break', 'continue', 'with', 'in', 'of', 'new', 'delete', 'typeof',
1918
+ 'void', 'instanceof', 'function', 'class', 'const', 'let', 'var',
1919
+ 'this', 'super', 'extends', 'import', 'export', 'from', 'as',
1920
+ 'static', 'async', 'true', 'false', 'null', 'undefined',
1921
+ 'sizeof', 'using', 'namespace', 'interface', 'type', 'enum',
1922
+ ]);
1923
+
1924
+ // JS/TS built-in globals / constructors / namespaces. Filtered only when
1925
+ // scanning JS/TS bodies so Go/Rust/etc. callees named Map/Set/parse/get
1926
+ // are not suppressed.
1927
+ const _CALLEES_JS_BUILTINS = new Set([
1928
+ // Constructors / wrappers
1929
+ 'Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError',
1930
+ 'EvalError', 'URIError', 'AggregateError',
1931
+ 'String', 'Number', 'Boolean', 'Array', 'Object', 'Function',
1932
+ 'Set', 'Map', 'WeakSet', 'WeakMap', 'WeakRef', 'FinalizationRegistry',
1933
+ 'Promise', 'Symbol', 'BigInt', 'Date', 'RegExp', 'Proxy',
1934
+ 'ArrayBuffer', 'SharedArrayBuffer', 'DataView', 'Int8Array', 'Uint8Array',
1935
+ 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', 'Int32Array', 'Uint32Array',
1936
+ 'Float32Array', 'Float64Array', 'BigInt64Array', 'BigUint64Array',
1937
+ // Coercion / parsing
1938
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURI',
1939
+ 'encodeURIComponent', 'decodeURI', 'decodeURIComponent', 'eval',
1940
+ 'globalThis', 'NaN', 'Infinity',
1941
+ // Namespaces (called as `Math(...)` etc. is invalid but appears as
1942
+ // `Math.floor(` — bare `Math` won't match the regex but include for
1943
+ // safety against `Math` callable patterns)
1944
+ 'JSON', 'Math', 'Reflect', 'Atomics', 'Intl', 'console', 'process',
1945
+ // Web / DOM globals commonly invoked
1946
+ 'fetch', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
1947
+ 'queueMicrotask', 'structuredClone', 'requestAnimationFrame',
1948
+ 'cancelAnimationFrame', 'alert', 'confirm', 'prompt',
1949
+ // Eval-shaped / introspection
1950
+ 'require',
1951
+ ]);
1952
+
1953
+ /**
1954
+ * Extract forward callees from a symbol's declaration: locate the body
1955
+ * by brace-depth scan, mask non-code text, harvest `identifier(` and
1956
+ * `obj.method(` calls, filter keywords + JS builtins, resolve each
1957
+ * callee against the graph (preferring same-file decls), and return
1958
+ * structured rows enriched for flow-tracing.
1959
+ *
1960
+ * Returns an array of `{ name, callsitePath, callsiteLine, declPath,
1961
+ * declLine, enclosing, snippet }`. `callsite*` points at the actual
1962
+ * invocation in declHit's file; `decl*` points at the callee's
1963
+ * declaration when the graph can resolve it (else empty); `enclosing`
1964
+ * is the nearest enclosing symbol at the call site.
1965
+ */
1966
+ function _extractCallees(graph, declHit, _cwd, { cap = 200, callerSymbol = null, language = null } = {}) {
1967
+ if (!declHit || !_CALLEES_BRACE_LANGS.has(declHit.lang)) return [];
1968
+ const declNode = graph.nodes.get(declHit.rel);
1969
+ if (!declNode) return [];
1970
+ const sourceText = _getSourceTextForNode(graph, declNode);
1971
+ if (!sourceText) return [];
1972
+
1973
+ // Fast-forward to the declaration line, then walk to the first `{`
1974
+ // outside the parameter parens. Skip braces inside comments/strings.
1975
+ // Anchor body discovery at the DECLARATION's start. Prefer the NATIVE symbol
1976
+ // record for callerSymbol over declHit's regex position: declHit.col can land
1977
+ // on an earlier same-line REFERENCE of the symbol (e.g.
1978
+ // `function a(){ if (b()){...} } function b(){...}` — the `b()` call precedes
1979
+ // `function b` on the line), which would lock body discovery onto the wrong
1980
+ // function. The native record marks the actual declaration, so call-site
1981
+ // occurrences cannot be mistaken for it. Native columns are UTF-8 byte
1982
+ // columns → converted to code units for sourceText indexing. Fall back to
1983
+ // declHit's regex line/col (already code-unit) when no native record matches.
1984
+ let declLineIdx = Math.max(0, (declHit.line || 1) - 1);
1985
+ let nativeStartCol = null;
1986
+ if (callerSymbol && Array.isArray(declNode.symbols)) {
1987
+ const rec = declNode.symbols
1988
+ .filter((s) => s && s.name === callerSymbol
1989
+ && Number.isFinite(Number(s.startLine)) && Number.isFinite(Number(s.startCol)))
1990
+ .sort((a, b) => Math.abs(Number(a.startLine) - (declHit.line || 1))
1991
+ - Math.abs(Number(b.startLine) - (declHit.line || 1)))[0];
1992
+ if (rec) {
1993
+ declLineIdx = Math.max(0, Number(rec.startLine) - 1);
1994
+ nativeStartCol = Number(rec.startCol);
1995
+ }
1996
+ }
1997
+ let i = 0;
1998
+ {
1999
+ let ln = 0;
2000
+ while (i < sourceText.length && ln < declLineIdx) {
2001
+ if (sourceText[i] === '\n') ln += 1;
2002
+ i += 1;
2003
+ }
2004
+ }
2005
+ // Declaration column in code units: from the native byte column (converted
2006
+ // against this line's text) or declHit's regex char column.
2007
+ let declColChar;
2008
+ if (nativeStartCol != null) {
2009
+ const lineEnd0 = sourceText.indexOf('\n', i);
2010
+ const lineText0 = sourceText.slice(i, lineEnd0 < 0 ? sourceText.length : lineEnd0);
2011
+ declColChar = _byteColToCharCol(lineText0, nativeStartCol);
2012
+ } else {
2013
+ declColChar = (Number.isFinite(declHit.col) && declHit.col > 1) ? declHit.col : 1;
2014
+ }
2015
+ // Advance to the declaration column (skips earlier same-line siblings /
2016
+ // references). Clamp to line end defensively.
2017
+ if (declColChar > 1) {
2018
+ const lineEnd = sourceText.indexOf('\n', i);
2019
+ const maxI = lineEnd < 0 ? sourceText.length : lineEnd;
2020
+ i = Math.min(i + (declColChar - 1), maxI);
2021
+ }
2022
+ let inLineComment = false;
2023
+ let inBlockComment = false;
2024
+ let quote = '';
2025
+ let scanI = i;
2026
+ let parenDepth = 0;
2027
+ let bodyStart = -1;
2028
+ while (scanI < sourceText.length) {
2029
+ const ch = sourceText[scanI];
2030
+ const next = sourceText[scanI + 1];
2031
+ if (inLineComment) {
2032
+ if (ch === '\n') inLineComment = false;
2033
+ scanI += 1; continue;
2034
+ }
2035
+ if (inBlockComment) {
2036
+ if (ch === '*' && next === '/') { inBlockComment = false; scanI += 2; continue; }
2037
+ scanI += 1; continue;
2038
+ }
2039
+ if (quote) {
2040
+ if (ch === '\\') { scanI += 2; continue; }
2041
+ if (ch === quote) { quote = ''; }
2042
+ scanI += 1; continue;
2043
+ }
2044
+ if (ch === '/' && next === '/') { inLineComment = true; scanI += 2; continue; }
2045
+ if (ch === '/' && next === '*') { inBlockComment = true; scanI += 2; continue; }
2046
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; scanI += 1; continue; }
2047
+ if (ch === '(') { parenDepth += 1; scanI += 1; continue; }
2048
+ if (ch === ')') { if (parenDepth > 0) parenDepth -= 1; scanI += 1; continue; }
2049
+ if (ch === '{' && parenDepth === 0) { bodyStart = scanI; break; }
2050
+ if (ch === ';' && parenDepth === 0) break;
2051
+ scanI += 1;
2052
+ }
2053
+ if (bodyStart < 0) return [];
2054
+
2055
+ // Walk from bodyStart to matching `}` at depth 0.
2056
+ let depth = 0;
2057
+ let bodyEnd = -1;
2058
+ inLineComment = false; inBlockComment = false; quote = '';
2059
+ let j = bodyStart;
2060
+ while (j < sourceText.length) {
2061
+ const ch = sourceText[j];
2062
+ const next = sourceText[j + 1];
2063
+ if (inLineComment) {
2064
+ if (ch === '\n') inLineComment = false;
2065
+ j += 1; continue;
2066
+ }
2067
+ if (inBlockComment) {
2068
+ if (ch === '*' && next === '/') { inBlockComment = false; j += 2; continue; }
2069
+ j += 1; continue;
2070
+ }
2071
+ if (quote) {
2072
+ if (ch === '\\') { j += 2; continue; }
2073
+ if (ch === quote) { quote = ''; }
2074
+ j += 1; continue;
2075
+ }
2076
+ if (ch === '/' && next === '/') { inLineComment = true; j += 2; continue; }
2077
+ if (ch === '/' && next === '*') { inBlockComment = true; j += 2; continue; }
2078
+ if (ch === '"' || ch === "'" || ch === '`') { quote = ch; j += 1; continue; }
2079
+ if (ch === '{') depth += 1;
2080
+ else if (ch === '}') {
2081
+ depth -= 1;
2082
+ if (depth === 0) { bodyEnd = j; break; }
2083
+ }
2084
+ j += 1;
2085
+ }
2086
+ if (bodyEnd < 0) bodyEnd = sourceText.length;
2087
+
2088
+ const rawBody = sourceText.slice(bodyStart + 1, bodyEnd);
2089
+ const maskedBody = _maskNonCodeText(rawBody, declNode.lang);
2090
+ const bodyStartLine = sourceText.slice(0, bodyStart + 1).split('\n').length;
2091
+
2092
+ // Two passes:
2093
+ // 1) Bare identifier calls — `(?<![\p{ID_Continue}$.])foo(` excludes
2094
+ // `.`-preceded, so member-call methods are missed by this pass.
2095
+ // 2) Member-call methods — `obj.method(` / `obj?.method(`. Captures
2096
+ // only the `method` token (the `obj.` part is identifier-bound but
2097
+ // can be anything from a parameter to a chain). Real edges like
2098
+ // `proc.send(`, `server.setRequestHandler(`, `emitter.on(` flow
2099
+ // through this pass.
2100
+ const callRe = /(?<![\p{ID_Continue}$.])([\p{ID_Start}_][\p{ID_Continue}]*)(?=\s*\()/gu;
2101
+ const memberCallRe = /\.\s*\??\.?\s*([\p{ID_Start}_][\p{ID_Continue}]*)(?=\s*\()/gu;
2102
+ const seen = new Map(); // name -> { line }
2103
+ const selfName = callerSymbol || null;
2104
+ // Builtin prototype/static method names. A member call `x.method(` whose
2105
+ // method is one of these is a JS builtin (Array/String/Object/Promise/Map/
2106
+ // Set/Math/JSON/Number/EventTarget) — NOT a navigable user edge; listing it
2107
+ // adds noise and resolves to a bogus same-named decl. Applied ONLY to the
2108
+ // member-call pass so real library edges (send / on / emit /
2109
+ // setRequestHandler) survive and a bare user `parse(` / `map(` is kept.
2110
+ const _CALLEES_JS_METHODS = new Set([
2111
+ 'trim','trimStart','trimEnd','slice','splice','substring','substr','split',
2112
+ 'join','concat','includes','indexOf','lastIndexOf','startsWith','endsWith',
2113
+ 'padStart','padEnd','repeat','charAt','charCodeAt','codePointAt','at',
2114
+ 'toUpperCase','toLowerCase','normalize','match','matchAll','search',
2115
+ 'replace','replaceAll','push','pop','shift','unshift','reverse','sort',
2116
+ 'flat','flatMap','forEach','map','filter','every','some','reduce',
2117
+ 'reduceRight','find','findIndex','findLast','findLastIndex','fill',
2118
+ 'copyWithin','toString','valueOf','hasOwnProperty','keys','values',
2119
+ 'entries','assign','freeze','then','catch','finally','resolve','reject',
2120
+ 'all','allSettled','race','any','get','set','has','add','delete','clear',
2121
+ 'max','min','floor','ceil','round','abs','sqrt','pow','log','sign','trunc',
2122
+ 'random','hypot','parse','stringify','parseInt','parseFloat','isInteger',
2123
+ 'isFinite','isNaN','toFixed','isArray','from','of','addEventListener',
2124
+ 'removeEventListener','dispatchEvent','bind','call','apply',
2125
+ ]);
2126
+ const recordHit = (name, index, isMember) => {
2127
+ if (!name) return;
2128
+ if (_CALLEES_JS_KEYWORDS.has(name)) return;
2129
+ if (_isJsLike(declHit.lang)) {
2130
+ if (_CALLEES_JS_BUILTINS.has(name)) return;
2131
+ if (isMember && _CALLEES_JS_METHODS.has(name)) return;
2132
+ }
2133
+ if (selfName && name === selfName) return;
2134
+ if (seen.has(name)) return;
2135
+ const upto = maskedBody.slice(0, index);
2136
+ const lineInBody = upto.split('\n').length - 1;
2137
+ const absLine = bodyStartLine + lineInBody;
2138
+ // 1-based char column of the call in its physical line, for column-precise
2139
+ // enclosing resolution on same-line / minified bodies (mirrors callers).
2140
+ const absIndex = bodyStart + 1 + index;
2141
+ const lineStart = sourceText.lastIndexOf('\n', absIndex - 1) + 1;
2142
+ const charCol = absIndex - lineStart + 1;
2143
+ seen.set(name, { line: absLine, col: charCol, isMember });
2144
+ };
2145
+ let m = null;
2146
+ while ((m = callRe.exec(maskedBody))) recordHit(m[1], m.index, false);
2147
+ let mm = null;
2148
+ while ((mm = memberCallRe.exec(maskedBody))) {
2149
+ // mm.index points at the `.`; the method name itself starts after
2150
+ // the dot + optional `?` / whitespace. Use the capture-group offset
2151
+ // for line bucketing so the call-site line is precise.
2152
+ const methodStart = mm.index + mm[0].length - mm[1].length;
2153
+ recordHit(mm[1], methodStart, true);
2154
+ }
2155
+ if (seen.size === 0) return [];
2156
+
2157
+ const allUnique = [...seen.entries()];
2158
+ const sliced = allUnique.slice(0, cap);
2159
+ const sourceLines = sourceText.split(/\r?\n/);
2160
+ const rows = [];
2161
+ for (const [name, info] of sliced) {
2162
+ // Resolve callee declaration via the same graph machinery used by
2163
+ // find_symbol. Precision fix: prefer a same-file declaration over
2164
+ // any cross-file same-named decl so local helpers like `fail`/`ok`
2165
+ // bind to the local copy instead of an unrelated file's symbol.
2166
+ let resolvedPath = '';
2167
+ let resolvedLine = 0;
2168
+ let resolvedDecl = false;
2169
+ try {
2170
+ const calleeDecl = _resolveCalleeDeclaration(graph, name, { language, preferRel: declHit.rel });
2171
+ // INVARIANT: only treat the callee as resolved when the graph bound it
2172
+ // to a GENUINE declaration (declarationLike). _pickCalleeDeclHit falls
2173
+ // back to sorted[0] when nothing is declaration-like, which makes Node
2174
+ // builtins / external-module names (readdirSync, join, statSync) bind to
2175
+ // whatever project file merely USES or IMPORTS the same name. Reject that
2176
+ // fallback so the row renders as external instead of a bogus decl + a
2177
+ // wasted next-hint.
2178
+ if (calleeDecl && calleeDecl.declarationLike) {
2179
+ // MEMBER calls (`x.write(`) carry no receiver identity — a same-named
2180
+ // free function elsewhere in the project (state-file.mjs `write` for
2181
+ // `process.stderr.write`) is NOT evidence of an edge. Accept the decl
2182
+ // only when it lives in the caller's own file or a file the caller
2183
+ // DIRECTLY imports. Bare calls keep name resolution as-is: their decl
2184
+ // may legitimately arrive via a re-export chain the import edge check
2185
+ // cannot see (e.g. smartReadTruncate via './tools/builtin.mjs').
2186
+ const memberOk = !info.isMember
2187
+ || calleeDecl.rel === declHit.rel
2188
+ || (Array.isArray(declNode.resolvedImports)
2189
+ && declNode.resolvedImports.some((p) => _graphRel(p, _cwd) === calleeDecl.rel));
2190
+ if (memberOk) {
2191
+ resolvedPath = calleeDecl.rel;
2192
+ resolvedLine = calleeDecl.line || 0;
2193
+ resolvedDecl = true;
2194
+ }
2195
+ }
2196
+ } catch {
2197
+ // Identifier shapes that trip the lookup regex fall through.
2198
+ }
2199
+ const snippetRaw = String(sourceLines[info.line - 1] || '').trim();
2200
+ const snippet = snippetRaw.slice(0, 80);
2201
+ // Enclosing-symbol lookup at the call site. Reuses the same
2202
+ // nearest-enclosing scanner the callers/references formatter uses
2203
+ // so flow-trace output is consistent across modes.
2204
+ let enclosing = '';
2205
+ try {
2206
+ const _encByteCol = _toByteColumn(sourceLines[info.line - 1] || '', info.col);
2207
+ const enc = _nearestEnclosingSymbol(declNode, sourceText, info.line, _encByteCol);
2208
+ enclosing = enc?.name || '';
2209
+ } catch {
2210
+ // Falls through to empty enclosing — non-fatal.
2211
+ }
2212
+ rows.push({
2213
+ name,
2214
+ callsitePath: declHit.rel,
2215
+ callsiteLine: info.line,
2216
+ declPath: resolvedPath,
2217
+ declLine: resolvedLine,
2218
+ external: !resolvedDecl,
2219
+ enclosing,
2220
+ snippet,
2221
+ });
2222
+ }
2223
+ if (allUnique.length > sliced.length) {
2224
+ rows.push({
2225
+ name: '...',
2226
+ callsitePath: '',
2227
+ callsiteLine: 0,
2228
+ declPath: '',
2229
+ declLine: 0,
2230
+ enclosing: '',
2231
+ snippet: `+${allUnique.length - sliced.length} more callees (cap=${cap})`,
2232
+ truncationFooter: true,
2233
+ });
2234
+ }
2235
+ return rows;
2236
+ }
2237
+
2238
+ // Format a callee row for flow-traceable output. Shape:
2239
+ // `name\tcallsite <path:line>\tdecl <path:line>\t(in <enclosing>)\tnext: find_symbol({symbol:"name"})`
2240
+ // `decl` collapses to `(unresolved)` when the graph could not bind the
2241
+ // callee to a declaration; `(in ?)` when no enclosing symbol was found.
2242
+ // When the callee could not be bound to a genuine project declaration
2243
+ // (`row.external` — a Node builtin or external-module name whose only graph
2244
+ // match is an import/usage of the same name), render `decl (external/builtin)`
2245
+ // and OMIT the `next:` hint so the caller is not sent on a wasted find_symbol.
2246
+ function _formatCalleeRow(row) {
2247
+ if (row.truncationFooter) return `... ${row.snippet}`;
2248
+ const callsite = row.callsitePath ? `callsite ${row.callsitePath}:${row.callsiteLine}` : 'callsite (unknown)';
2249
+ if (row.external) {
2250
+ const enclosingExt = row.enclosing ? `(in ${row.enclosing})` : '(in ?)';
2251
+ return `${row.name}\t${callsite}\tdecl (external/builtin)\t${enclosingExt}`;
2252
+ }
2253
+ const decl = row.declPath ? `decl ${row.declPath}:${row.declLine}` : 'decl (unresolved)';
2254
+ const enclosing = row.enclosing ? `(in ${row.enclosing})` : '(in ?)';
2255
+ const next = `next: find_symbol({symbol:"${row.name}"})`;
2256
+ return `${row.name}\t${callsite}\t${decl}\t${enclosing}\t${next}`;
2257
+ }
2258
+
2259
+ function _keywordSymbolSortKey(symbolName, keyword) {
2260
+ const lowerName = String(symbolName || '').toLowerCase();
2261
+ const lowerKey = String(keyword || '').toLowerCase();
2262
+ const idx = lowerName.indexOf(lowerKey);
2263
+ if (idx < 0) return null;
2264
+ const atStart = idx === 0 ? 0 : 1;
2265
+ return [lowerName.length, atStart, idx, symbolName];
2266
+ }
2267
+
2268
+ // Tokenize a search keyword on camelCase boundaries and non-alphanumeric
2269
+ // separators: "capOutput" -> ["cap","output"], "smart_read" -> ["smart","read"].
2270
+ function _tokenizeKeyword(s) {
2271
+ return String(s || '')
2272
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
2273
+ .split(/[^a-zA-Z0-9]+/)
2274
+ .filter(Boolean)
2275
+ .map((t) => t.toLowerCase());
2276
+ }
2277
+
2278
+ // Token START offsets within `sym`, using the SAME boundary rules as
2279
+ // _tokenizeKeyword: a token starts at string start, after any non-alphanumeric
2280
+ // separator, and at a lower/digit -> Upper camelCase transition.
2281
+ function _tokenStartOffsets(sym) {
2282
+ const starts = new Set();
2283
+ let prevAlnum = false;
2284
+ let prevUpper = false;
2285
+ for (let i = 0; i < sym.length; i += 1) {
2286
+ const c = sym[i];
2287
+ const isAlnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
2288
+ if (!isAlnum) { prevAlnum = false; prevUpper = false; continue; }
2289
+ const isUpper = c >= 'A' && c <= 'Z';
2290
+ if (!prevAlnum) starts.add(i); // string start / after separator
2291
+ else if (isUpper && !prevUpper) starts.add(i); // camelCase boundary
2292
+ prevAlnum = true;
2293
+ prevUpper = isUpper;
2294
+ }
2295
+ return starts;
2296
+ }
2297
+
2298
+ // True when `lowerKey` occurs in `sym` as a TOKEN-ALIGNED contiguous substring:
2299
+ // some occurrence either starts at a token boundary, or lies entirely within a
2300
+ // single token (no token boundary strictly inside the matched span). This
2301
+ // rejects raw substring noise that crosses a camelCase boundary — e.g. keyword
2302
+ // "redact" inside "sharedActual" ("sha|red" + "act|ual") — while keeping genuine
2303
+ // hits like "redact" in "redactString" or a within-token partial like "edact"
2304
+ // in "redactString".
2305
+ function _contiguousMatchTokenAligned(sym, lowerKey) {
2306
+ const len = lowerKey.length;
2307
+ if (!len) return false;
2308
+ const symLower = sym.toLowerCase();
2309
+ const starts = _tokenStartOffsets(sym);
2310
+ // Align on the first ALPHANUMERIC char of the match: a keyword may carry
2311
+ // leading separators (e.g. "_redact") that _tokenStartOffsets does not count
2312
+ // as token starts, so the boundary check must skip past them.
2313
+ let lead = 0;
2314
+ while (lead < len) {
2315
+ const c = lowerKey[lead];
2316
+ const isAlnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
2317
+ if (isAlnum) break;
2318
+ lead += 1;
2319
+ }
2320
+ let from = 0;
2321
+ for (;;) {
2322
+ const idx = symLower.indexOf(lowerKey, from);
2323
+ if (idx < 0) return false;
2324
+ const end = idx + len;
2325
+ const effectiveIdx = idx + lead;
2326
+ if (starts.has(effectiveIdx)) return true;
2327
+ let interiorBoundary = false;
2328
+ for (const s of starts) {
2329
+ if (s > effectiveIdx && s < end) { interiorBoundary = true; break; }
2330
+ }
2331
+ if (!interiorBoundary) return true; // wholly inside one token
2332
+ from = idx + 1;
2333
+ }
2334
+ }
2335
+
2336
+ // Ordered multi-token match: every token must appear in sequence (each after
2337
+ // the previous match end) inside symLower. This is a deliberate widening of the
2338
+ // keyword-match semantics — NOT an error-recovery fallback — so a keyword that
2339
+ // drops a middle camelCase token ("capOutput") still resolves the full symbol
2340
+ // ("capToolOutput", where "capoutput" is not a contiguous substring). The
2341
+ // caller applies the precise contiguous includes() check first and only reaches
2342
+ // this for multi-token keywords, which bounds false positives.
2343
+ function _orderedTokenMatch(symLower, tokens) {
2344
+ let from = 0;
2345
+ for (const t of tokens) {
2346
+ const i = symLower.indexOf(t, from);
2347
+ if (i < 0) return false;
2348
+ from = i + t.length;
2349
+ }
2350
+ return true;
2351
+ }
2352
+
2353
+ function _collectKeywordSymbolNames(graph, keyword, { language = null } = {}) {
2354
+ _ensureSymbolTokenIndex(graph);
2355
+ const lowerKey = String(keyword || '').toLowerCase();
2356
+ if (!lowerKey) return [];
2357
+ const keyTokens = _tokenizeKeyword(keyword);
2358
+ const seen = new Set();
2359
+ const out = [];
2360
+ const index = graph?._symbolTokenIndex;
2361
+ if (!index) return out;
2362
+ for (const key of index.keys()) {
2363
+ if (!key.startsWith('*|')) continue;
2364
+ const sym = key.slice(2);
2365
+ if (!sym || seen.has(sym)) continue;
2366
+ const symLower = sym.toLowerCase();
2367
+ // Tighten the contiguous check: a raw substring that crosses a camelCase
2368
+ // boundary (keyword "redact" inside "sharedActual" = "sha|red"+"act|ual")
2369
+ // is noise, not a real match. Require the contiguous hit to be
2370
+ // token-aligned; only then fall back to the ordered multi-token widening
2371
+ // (multi-token keywords only, to keep single-token searches tight).
2372
+ if (!_contiguousMatchTokenAligned(sym, lowerKey)) {
2373
+ if (keyTokens.length < 2 || !_orderedTokenMatch(symLower, keyTokens)) continue;
2374
+ }
2375
+ if (language) {
2376
+ const langKey = `${language}|${sym}`;
2377
+ if (!index.has(langKey)) continue;
2378
+ }
2379
+ seen.add(sym);
2380
+ out.push(sym);
2381
+ }
2382
+ out.sort((a, b) => {
2383
+ const ka = _keywordSymbolSortKey(a, keyword);
2384
+ const kb = _keywordSymbolSortKey(b, keyword);
2385
+ // Contiguous matches (non-null key) always rank before token-only matches
2386
+ // (null key) so cap=N never hides a better contiguous hit behind a loose
2387
+ // camelCase-token match.
2388
+ if (ka && !kb) return -1;
2389
+ if (!ka && kb) return 1;
2390
+ if (!ka && !kb) return a.localeCompare(b);
2391
+ for (let i = 0; i < 3; i += 1) {
2392
+ if (ka[i] !== kb[i]) return ka[i] - kb[i];
2393
+ }
2394
+ return a.localeCompare(b);
2395
+ });
2396
+ return out;
2397
+ }
2398
+
2399
+ function _formatSearchSymbolRow(name, hit) {
2400
+ const loc = hit ? _formatSymbolHitLocation(hit) : '(unresolved)';
2401
+ const next = `next: find_symbol({symbol:"${name}"})`;
2402
+ return `${name}\t${loc}\t${next}`;
2403
+ }
2404
+
2405
+ function _searchSymbolsByKeyword(graph, keyword, cwd, { language = null, limit = 30 } = {}) {
2406
+ const clean = String(keyword || '').trim();
2407
+ if (!clean) return '(no keyword)';
2408
+ const cap = Math.max(1, Math.min(100, Math.floor(Number(limit) || 30)));
2409
+ const allNames = _collectKeywordSymbolNames(graph, clean, { language });
2410
+ if (!allNames.length) {
2411
+ const nodeCount = graph?.nodes?.size ?? 0;
2412
+ return `(no symbol keyword matches in cwd=${cwd})\ngraph: nodes=${nodeCount}${language ? `, language=${language}` : ''}`;
2413
+ }
2414
+ const entries = allNames.map((name) => {
2415
+ const hits = _sortSymbolHits(_findSymbolHits(graph, name, { language }));
2416
+ const hit = _pickCalleeDeclHit(hits) || hits[0] || null;
2417
+ return { name, hit, resolved: Boolean(hit) };
2418
+ });
2419
+ entries.sort((a, b) => {
2420
+ const rank = Number(b.resolved) - Number(a.resolved);
2421
+ if (rank !== 0) return rank;
2422
+ const ka = _keywordSymbolSortKey(a.name, keyword);
2423
+ const kb = _keywordSymbolSortKey(b.name, keyword);
2424
+ // Contiguous matches rank before token-only matches (see _collectKeywordSymbolNames).
2425
+ if (ka && !kb) return -1;
2426
+ if (!ka && kb) return 1;
2427
+ if (!ka && !kb) return a.name.localeCompare(b.name);
2428
+ for (let i = 0; i < 3; i += 1) {
2429
+ if (ka[i] !== kb[i]) return ka[i] - kb[i];
2430
+ }
2431
+ return a.name.localeCompare(b.name);
2432
+ });
2433
+ const resolvedEntries = entries.filter((e) => e.resolved);
2434
+ const unresolvedNames = entries.filter((e) => !e.resolved).map((e) => e.name);
2435
+ const shownResolved = resolvedEntries.slice(0, cap);
2436
+ const lines = [`# search keyword=${clean} matches=${allNames.length} shown=${shownResolved.length}`];
2437
+ for (const { name, hit } of shownResolved) {
2438
+ lines.push(_formatSearchSymbolRow(name, hit));
2439
+ }
2440
+ if (resolvedEntries.length > shownResolved.length) {
2441
+ lines.push(`...+${resolvedEntries.length - shownResolved.length} more resolved (cap=${cap})`);
2442
+ }
2443
+ if (unresolvedNames.length) {
2444
+ lines.push(`+${unresolvedNames.length} unresolved name variants (token-only, no declaration — find_symbol will miss these; grep to locate): ${unresolvedNames.join(', ')}`);
2445
+ }
2446
+ if (graph?.truncated) {
2447
+ lines.push(`WARN: graph truncated at CODE_GRAPH_MAX_FILES=${CODE_GRAPH_MAX_FILES} — matches may be incomplete. Re-run with a narrower cwd.`);
2448
+ }
2449
+ // Sufficiency steer: this listing IS the symbol index. The next step is a
2450
+ // STRUCTURED find_symbol on the best match (body:true → location + full body
2451
+ // in ONE call), NOT a grep/read sweep across the matches. Once find_symbol
2452
+ // answers the question, report.
2453
+ lines.push(`# NEXT — pick the row that best fits and find_symbol it (body:true returns its location + full body in one call). These are the index; do NOT grep/read to hunt. If find_symbol answers it, report.`);
2454
+ return lines.join('\n');
2455
+ }
2456
+
2457
+ function _findSymbolAcrossGraph(graph, symbol, cwd, { language = null, limit = 5, fileRel = null, body = true } = {}) {
2458
+ // Caller-supplied `language` is a hard scope: never widen to other
2459
+ // languages on miss. Returning a different-language hit was producing
2460
+ // misleading results when callers wanted strict language-narrowed
2461
+ // analysis.
2462
+ const allHits = _findSymbolHits(graph, symbol, { language });
2463
+ // SCOPE ISOLATION: when `file` is set, the caller wants this file's
2464
+ // declaration + refs only — not every same-named symbol across other
2465
+ // files. Filter rather than widen.
2466
+ const hits = fileRel ? allHits.filter((h) => h.rel === fileRel) : allHits;
2467
+
2468
+ if (!hits.length) {
2469
+ // Silent (no match) was burning iters — caller had no signal whether to retry
2470
+ // with a different cwd or accept the miss. Surface graph stats + actionable hint.
2471
+ const nodeCount = graph?.nodes?.size ?? 0;
2472
+ const scopeNote = fileRel ? ` file=${fileRel}` : '';
2473
+ const lines = [`(no symbol matches in cwd=${cwd}${scopeNote})`];
2474
+ lines.push(`graph: nodes=${nodeCount}${language ? `, language=${language}` : ''}`);
2475
+ if (graph?.truncated) {
2476
+ lines.push(`WARN: graph truncated at CODE_GRAPH_MAX_FILES=${CODE_GRAPH_MAX_FILES} — symbol may exist in an un-indexed file. Re-run with a narrower cwd.`);
2477
+ }
2478
+ // Case-insensitive "did you mean" scan over the symbol token index. Catches
2479
+ // common typos (callworker → callWorker) without forcing a paraphrased retry.
2480
+ const lowerSym = symbol.toLowerCase();
2481
+ const ciHits = [];
2482
+ if (graph?._symbolTokenIndex && nodeCount > 0) {
2483
+ for (const key of graph._symbolTokenIndex.keys()) {
2484
+ const idx = key.indexOf('|');
2485
+ if (idx < 0) continue;
2486
+ const symPart = key.slice(idx + 1);
2487
+ if (symPart !== symbol && symPart.toLowerCase() === lowerSym) {
2488
+ if (!ciHits.includes(symPart)) ciHits.push(symPart);
2489
+ if (ciHits.length >= 3) break;
2490
+ }
2491
+ }
2492
+ }
2493
+ return lines.join('\n');
2494
+ }
2495
+
2496
+ const topHits = hits.slice(0, Math.max(1, limit));
2497
+ const primary = topHits[0];
2498
+ const declHits = hits.filter((h) => h.declarationLike);
2499
+ const declCount = declHits.length;
2500
+ const lines = [];
2501
+ // Ambiguity guard: with 2+ genuine declarations of the same name, a caller
2502
+ // acting on the "best candidate" alone may patch the wrong definition.
2503
+ // Prepend an explicit warning listing every declaration's file:line.
2504
+ if (declCount > 1) {
2505
+ lines.push(`⚠ ${declCount} declarations found — verify which one you intend`);
2506
+ for (const h of declHits) lines.push(` ${_formatSymbolHitLocation(h)} [${h.lang}]`);
2507
+ lines.push('');
2508
+ }
2509
+ if (primary?.declarationLike) {
2510
+ // When the graph is truncated, the "best" candidate is only best AMONG
2511
+ // indexed files — the canonical declaration may live in an un-indexed file
2512
+ // (e.g. src/** dropped past CODE_GRAPH_MAX_FILES at a huge cwd). Flag the
2513
+ // caveat inline at the prominent claim, not just the scope footer, so the
2514
+ // confident heading never reads as authoritative under truncation.
2515
+ lines.push(graph?.truncated
2516
+ ? '# best declaration candidate (GRAPH TRUNCATED — may not be canonical; re-run with a narrower cwd to confirm)'
2517
+ : '# best declaration candidate');
2518
+ const multi = declCount > 1 ? `, declarations=${declCount}` : '';
2519
+ lines.push(`${_formatSymbolHitLocation(primary)} (${primary.lang}, matches=${primary.matchCount}${multi})`);
2520
+ // body:true → emit the full declaration span (cap 300 lines) so review/debug
2521
+ // gets the function in ONE call instead of find_symbol + a follow-up read.
2522
+ // Opt-in so plain locate/callee-trace lookups stay compact.
2523
+ let bodyEmitted = false;
2524
+ if (body === true && Number.isFinite(Number(primary.line))) {
2525
+ const node = graph.nodes.get(primary.rel);
2526
+ const srcText = node ? _getSourceTextForNode(graph, node) : null;
2527
+ if (srcText) {
2528
+ const all = srcText.split('\n');
2529
+ const start = Math.max(1, Number(primary.line));
2530
+ let end = Number(primary.endLine);
2531
+ // Assignment-style declarations (`const f = (…) => {`) carry no
2532
+ // endLine in the graph; falling back to the bare declaration line
2533
+ // emits a 1-line body. Recover the span from indentation first.
2534
+ if (!Number.isFinite(end) || end < start) {
2535
+ end = _inferSpanEndByIndent(all, start) ?? start;
2536
+ }
2537
+ end = Math.min(end, start + 299);
2538
+ // Large bodies (up to the 300-line cap) flood context when the caller
2539
+ // only needed location+callees, so above a threshold emit head+tail with
2540
+ // an elision marker; small spans stay whole to keep the one-call utility.
2541
+ const BODY_FULL_MAX = 120; // spans ≤ this emit verbatim
2542
+ const BODY_HEAD = 90; // leading lines kept when eliding
2543
+ const BODY_TAIL = 20; // trailing lines kept when eliding
2544
+ const fmt = (i) => `${start + i}: ${all[start - 1 + i]}`;
2545
+ const span = end - start + 1;
2546
+ if (span > BODY_FULL_MAX) {
2547
+ const head = Array.from({ length: BODY_HEAD }, (_, i) => fmt(i));
2548
+ const tail = Array.from({ length: BODY_TAIL }, (_, i) => fmt(span - BODY_TAIL + i));
2549
+ const elided = span - BODY_HEAD - BODY_TAIL;
2550
+ head.push(`... [${elided} lines elided — full body: read ${primary.rel} symbol=${symbol}]`);
2551
+ lines.push([...head, ...tail].join('\n'));
2552
+ } else {
2553
+ lines.push(all.slice(start - 1, end).map((l, i) => `${start + i}: ${l}`).join('\n'));
2554
+ }
2555
+ bodyEmitted = true;
2556
+ }
2557
+ }
2558
+ if (!bodyEmitted) {
2559
+ if (primary.content) lines.push(primary.content.slice(0, 100));
2560
+ if (Array.isArray(primary.context) && primary.context.length > 1) {
2561
+ lines.push(`context: ${primary.context.slice(0, 2).join(' | ').slice(0, 120)}`);
2562
+ }
2563
+ }
2564
+ if (declCount > 1) {
2565
+ const others = declHits.slice(1, 3).map((h) => `${_formatSymbolHitLocation(h)} [${h.lang}]`);
2566
+ if (others.length) lines.push(`other declarations: ${others.join(', ')}`);
2567
+ }
2568
+ lines.push('');
2569
+ }
2570
+ lines.push('# candidates');
2571
+ lines.push(...topHits.map((hit, idx) => {
2572
+ const kind = hit.declarationLike ? 'decl' : 'ref';
2573
+ const suffix = hit.content ? ` — ${hit.content.slice(0, 100)}` : '';
2574
+ return `${idx + 1}. ${_formatSymbolHitLocation(hit)} [${kind}, ${hit.lang}, matches=${hit.matchCount}]${suffix}`;
2575
+ }));
2576
+ // BUILTIN-COLLISION NOTE: every hit is a `[ref]` with no `[decl]` —
2577
+ // surface that the user has no declaration of this name so the caller
2578
+ // can stop hunting for one (e.g. global `fetch`/`read`/`console`).
2579
+ if (declCount === 0 && hits.length > 0) {
2580
+ lines.push('');
2581
+ lines.push(`(no user declaration found; likely a global/builtin identifier — all ${hits.length} hits are references)`);
2582
+ }
2583
+ // STRUCTURAL FORWARD GRAPH: append the symbol's callees inline so a
2584
+ // single `find_symbol({symbol})` returns declaration + what it calls.
2585
+ // Unconditional — no query-type branch, no opt-in flag. The cap is
2586
+ // smaller than the explicit `callees` mode (which uses 200) to keep
2587
+ // the declaration response compact.
2588
+ if (primary?.declarationLike) {
2589
+ const calleeRows = _extractCallees(graph, primary, cwd, {
2590
+ cap: 25,
2591
+ callerSymbol: symbol,
2592
+ language,
2593
+ });
2594
+ lines.push('');
2595
+ lines.push('# callees');
2596
+ if (calleeRows.length) {
2597
+ for (const row of calleeRows) {
2598
+ lines.push(_formatCalleeRow(row));
2599
+ }
2600
+ } else {
2601
+ lines.push('(no callees)');
2602
+ }
2603
+ }
2604
+ // Footer: surface active scope so the caller sees which graph answered.
2605
+ // Reduces "looks fine, but is this the right project?" doubt and the
2606
+ // wrong-cwd retry that follows when the answer was from an unintended tree.
2607
+ const _nodeCount = graph?.nodes?.size ?? 0;
2608
+ const truncatedSuffix = graph?.truncated
2609
+ ? ` [WARN: graph truncated at CODE_GRAPH_MAX_FILES=${CODE_GRAPH_MAX_FILES} — some files not indexed]`
2610
+ : '';
2611
+ const fileScopeSuffix = fileRel ? ` file=${fileRel}` : '';
2612
+ lines.push(`\n# scope: cwd=${cwd} graph=${_nodeCount}-nodes${language ? ` language=${language}` : ''}${fileScopeSuffix}${truncatedSuffix}`);
2613
+ return lines.join('\n');
2614
+ }
2615
+
2616
+ function _resolveReferenceLanguageNode(graph, symbol, rel, cwd, language = null) {
2617
+ if (rel) {
2618
+ const node = graph.nodes.get(rel);
2619
+ if (!node) {
2620
+ // Path was supplied but the graph never indexed it (typo,
2621
+ // unsupported extension, or outside cwd). Distinct from the
2622
+ // "indexed-but-symbol-absent" miss below so callers can render
2623
+ // a precise error instead of the generic "file not found".
2624
+ return { kind: 'file-not-found', node: null, file: rel };
2625
+ }
2626
+ // P0: verify the symbol actually appears in the file. Returning a
2627
+ // node solely because the path was indexed was producing language
2628
+ // bleed: a caller asking for `references(symbol=Foo, file=bar.py)`
2629
+ // would get bar.py's language even when Foo never appears in it,
2630
+ // narrowing the broader reference search to the wrong language.
2631
+ const tokens = _getTokenSymbolsForNode(graph, node);
2632
+ if (Array.isArray(tokens) && tokens.includes(String(symbol || ''))) {
2633
+ return { kind: 'ok', node, file: rel };
2634
+ }
2635
+ // Fallback: substring scan over source for non-identifier shapes
2636
+ // (e.g. method calls on values whose tokenSymbols misses the name).
2637
+ const text = _getSourceTextForNode(graph, node);
2638
+ if (typeof text === 'string' && text.includes(String(symbol || ''))) {
2639
+ return { kind: 'ok', node, file: rel };
2640
+ }
2641
+ return { kind: 'symbol-not-present', node: null, file: rel };
2642
+ }
2643
+ const hits = _findSymbolHits(graph, symbol, { language });
2644
+ // Caller-specified language is a hard filter — refuse to widen on miss so
2645
+ // a `language: 'python'` query never bleeds into TS/JS results.
2646
+ if (!hits.length) return { kind: 'symbol-not-present', node: null, file: null };
2647
+ const primary = hits.find((hit) => hit.declarationLike) || hits[0];
2648
+ const node = primary?.rel ? graph.nodes.get(primary.rel) || null : null;
2649
+ return node
2650
+ ? { kind: 'ok', node, file: node.rel }
2651
+ : { kind: 'symbol-not-present', node: null, file: null };
2652
+ }
2653
+
2654
+ function _collapseReferenceLinesToCallers(referenceText) {
2655
+ if (typeof referenceText !== 'string' || !referenceText.trim() || referenceText === '(no references)') {
2656
+ return '(no callers)';
2657
+ }
2658
+ const files = new Set();
2659
+ for (const line of referenceText.split('\n')) {
2660
+ const trimmed = line.trim();
2661
+ if (!trimmed) continue;
2662
+ const m = /^(.+?):\d+:\d+(?:[\s\t]+.*)?$/.exec(trimmed);
2663
+ if (m) files.add(m[1]);
2664
+ }
2665
+ if (files.size === 0) return '(no callers)';
2666
+ return [...files].sort().join('\n');
2667
+ }
2668
+
2669
+ function _referenceKind(line, symbol, lang = null) {
2670
+ const escaped = String(symbol || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2671
+ if (!escaped) return 'reference';
2672
+ const text = String(line || '');
2673
+ // Declaration keywords across every language _collectCheapSymbols supports
2674
+ // (JS/TS, Python, Go, Rust, Java/Kotlin/C#, C/C++, Ruby/PHP). A line where
2675
+ // any of these introduce the target symbol is the declaration site itself,
2676
+ // not a call site, and must be excluded from find_callers. JS-only keywords
2677
+ // previously caused Python `def`, Go `func`, Rust `fn`, Kotlin `fun`,
2678
+ // C/C++ `struct/union/typedef`, Ruby `module` declaration lines to be
2679
+ // classified as `call` (self-match), making caller counts inconsistent
2680
+ // across languages.
2681
+ if (new RegExp(
2682
+ `\\b(?:` +
2683
+ // type-like declarations
2684
+ `function|class|interface|type|enum|record|struct|union` +
2685
+ // scope-like declarations
2686
+ `|namespace|module|package|trait|impl|object` +
2687
+ // binding declarations
2688
+ `|const|let|var|val|typedef` +
2689
+ // single-word function declarations
2690
+ `|def|fn|fun` +
2691
+ `)\\s+${escaped}\\b`,
2692
+ ).test(text)) return 'declaration';
2693
+ // Go `func name(...)` or `func (recv) name(...)` with optional receiver.
2694
+ if (new RegExp(`\\bfunc(?:\\s*\\([^)]*\\))?\\s+${escaped}\\b`).test(text)) return 'declaration';
2695
+ if (new RegExp(`\\bimport\\b[\\s\\S]*${_unicodeBoundaryPattern(escaped, lang, symbol)}`, 'u').test(text)) return 'import';
2696
+ if (new RegExp(`${_unicodeBoundaryPattern(escaped, lang, symbol)}\\s*\\(`, 'u').test(text)) return 'call';
2697
+ return 'reference';
2698
+ }
2699
+
2700
+ // Convert a 1-based UTF-16 char column (the reference scanner emits
2701
+ // `match.index + 1`, a JS string index) into a 1-based UTF-8 byte column,
2702
+ // matching the native symbol's tree-sitter byte columns. Without this, a
2703
+ // same-line non-ASCII prefix before a declaration misaligns JS code-unit
2704
+ // columns against native byte columns and could exclude the correct sibling.
2705
+ function _toByteColumn(lineText, charCol) {
2706
+ if (!Number.isFinite(charCol) || charCol < 1) return charCol;
2707
+ const prefix = String(lineText || '').slice(0, charCol - 1);
2708
+ return Buffer.byteLength(prefix, 'utf8') + 1;
2709
+ }
2710
+
2711
+ // Inverse of _toByteColumn: a 1-based UTF-8 byte column (as emitted by the
2712
+ // native tree-sitter symbol records) back to a 1-based UTF-16 code-unit column
2713
+ // for indexing into a JS string. Walks codepoints so surrogate pairs (e.g.
2714
+ // emoji) advance the code-unit index by 2 while counting their real byte width.
2715
+ function _byteColToCharCol(lineText, byteCol) {
2716
+ if (!Number.isFinite(byteCol) || byteCol < 1) return 1;
2717
+ const s = String(lineText || '');
2718
+ let bytes = 0;
2719
+ let k = 0;
2720
+ while (k < s.length && bytes < byteCol - 1) {
2721
+ const cp = s.codePointAt(k);
2722
+ bytes += Buffer.byteLength(String.fromCodePoint(cp), 'utf8');
2723
+ k += cp > 0xFFFF ? 2 : 1;
2724
+ }
2725
+ return k + 1;
2726
+ }
2727
+
2728
+ function _nearestEnclosingSymbol(node, sourceText, lineNumber, col = null) {
2729
+ // SINGLE SOURCE OF TRUTH: the native graph's per-symbol records, each with a
2730
+ // finite endLine. The cheap regex scanner is intentionally NOT consulted for
2731
+ // enclosing resolution — its loose `name(args){` shapes carry no end-of-body
2732
+ // span, so using them would reintroduce the endLine-less nearest-declaration
2733
+ // mis-attribution this fix removes (the `caller=_pfAbsPath` class). A file
2734
+ // whose language the native binary does not extract symbols for yields no
2735
+ // candidates and resolves to null (no enclosing symbol) rather than a guess.
2736
+ // (kotlin/swift ARE natively extracted now, so they are no longer examples
2737
+ // of the no-extraction case.)
2738
+ const FUNCTION_LIKE = new Set([
2739
+ 'function', 'method', 'arrow', 'class', 'generator', 'fn', 'async-function',
2740
+ // Body-bearing constructs whose kinds come from the native extractor:
2741
+ // constructor_declaration -> 'constructor', local_function_statement ->
2742
+ // 'local-function', record_declaration -> 'record'.
2743
+ 'constructor', 'record', 'local-function',
2744
+ ]);
2745
+ const symbols = Array.isArray(node?.symbols) ? node.symbols : [];
2746
+ // Body-span containment by [line, endLine]. When the call-site column is
2747
+ // known, refine the SAME-LINE boundaries with it so multiple declarations
2748
+ // sharing one physical line (minified / compact code) are disambiguated: a
2749
+ // decl that opens after the call column on its start line, or closes before
2750
+ // the call column on its end line, is excluded. Column is consulted ONLY on
2751
+ // the boundary line(s); ordinary multi-line code is judged exactly as before
2752
+ // (line range only) — no regression. Columns are 1-based to match the
2753
+ // reference scanner's `match.index + 1`.
2754
+ const inRange = (item) => {
2755
+ if (item.line > lineNumber || Number(item.endLine) < lineNumber) return false;
2756
+ if (col != null) {
2757
+ const sl = Number(item.startLine);
2758
+ const sc = Number(item.startCol);
2759
+ const ec = Number(item.endCol);
2760
+ if (Number.isFinite(sl) && sl === lineNumber && Number.isFinite(sc) && col < sc) return false;
2761
+ if (Number(item.endLine) === lineNumber && Number.isFinite(ec) && col > ec) return false;
2762
+ }
2763
+ return true;
2764
+ };
2765
+ // Nearest enclosing wins: latest start line, then rightmost start column on a
2766
+ // tie (innermost of same-line siblings). Prefer a function-like; else the
2767
+ // nearest containing symbol of any kind; else null (no enclosing symbol).
2768
+ const candidates = symbols
2769
+ .filter(inRange)
2770
+ .sort((a, b) => (b.line - a.line) || ((Number(b.startCol) || 0) - (Number(a.startCol) || 0)));
2771
+ const fn = candidates.find((item) => FUNCTION_LIKE.has(String(item.kind || '').toLowerCase()));
2772
+ return fn || candidates[0] || null;
2773
+ }
2774
+
2775
+ // Raised from 40 to 200 after HS-A5 surfaced that callers on a cross-
2776
+ // codebase symbol (`parseInt` across refs/) silently truncated at 40
2777
+ // callers, hiding all codex/ and warp/ matches. tail-trim still bounds
2778
+ // the payload; a higher cap is the invariant-correct fix vs. asking
2779
+ // every caller to pass an explicit limit.
2780
+ // Classify each reference of `symbol` into a structured entry
2781
+ // {file,line,col, kind, caller, lineText}. `caller` is the enclosing function
2782
+ // name for `call` kind (else ''). Shared by the string formatter and the
2783
+ // transitive-callers walker so the latter never has to re-parse formatted text.
2784
+ function _collectCallerEntries(graph, symbol, referenceText) {
2785
+ const entries = _parseReferenceEntries(referenceText);
2786
+ const detailed = [];
2787
+ // Per-file cache of declaration line numbers for `symbol`. Populated
2788
+ // lazily so files that never need the keyword-less-method fallback pay
2789
+ // nothing.
2790
+ const declLinesCache = new Map();
2791
+ for (const entry of entries) {
2792
+ const node = graph.nodes.get(entry.file);
2793
+ if (!node) continue;
2794
+ const sourceText = _getSourceTextForNode(graph, node);
2795
+ const sourceLines = sourceText.split(/\r?\n/);
2796
+ const line = String(sourceLines[entry.line - 1] || '').trim();
2797
+ if (!line) continue;
2798
+ let kind = _referenceKind(line, symbol, node.lang);
2799
+ // Keyword-less method declaration guard. The keyword-based regex in
2800
+ // _referenceKind cannot recognise Java/C#/C++ method declarations
2801
+ // shaped `[modifier] [type] name(args) [{|;]` because they introduce
2802
+ // the symbol with no declaration keyword. The cheap-symbol scanner
2803
+ // already classifies those lines as `function`/`method`/`class`, so
2804
+ // if a `call` line coincides with a cheap-symbol decl of the same
2805
+ // name, promote it to `declaration` and drop it from call sites.
2806
+ if (kind === 'call') {
2807
+ let declLines = declLinesCache.get(node.rel);
2808
+ if (!declLines) {
2809
+ declLines = new Set();
2810
+ for (const sym of (Array.isArray(node.symbols) && node.symbols.length ? node.symbols : _collectCheapSymbols(sourceText, node.lang))) {
2811
+ if (sym && sym.name === symbol) declLines.add(sym.line);
2812
+ }
2813
+ declLinesCache.set(node.rel, declLines);
2814
+ }
2815
+ if (declLines.has(entry.line)) kind = 'declaration';
2816
+ }
2817
+ const _encByteCol = _toByteColumn(sourceLines[entry.line - 1] || '', entry.col);
2818
+ const enclosing = _nearestEnclosingSymbol(node, sourceText, entry.line, _encByteCol);
2819
+ detailed.push({
2820
+ ...entry,
2821
+ kind,
2822
+ caller: kind === 'call' ? (enclosing?.name || '') : '',
2823
+ lineText: line,
2824
+ });
2825
+ }
2826
+ return detailed;
2827
+ }
2828
+
2829
+ function _formatCallerReferences(graph, symbol, referenceText, { limit = 200 } = {}) {
2830
+ const detailed = _collectCallerEntries(graph, symbol, referenceText);
2831
+ if (!detailed.length) return '(no callers)';
2832
+
2833
+ const callSites = detailed.filter((entry) => entry.kind === 'call');
2834
+ const format = (entry) => {
2835
+ const caller = entry.caller ? `\tcaller=${entry.caller}` : '';
2836
+ return `${entry.file}:${entry.line}:${entry.col}\t${entry.kind}${caller}\t${entry.lineText.slice(0, 80)}`;
2837
+ };
2838
+ if (callSites.length) {
2839
+ const total = callSites.length;
2840
+ const head = callSites.slice(0, limit).map(format);
2841
+ const overflow = total > limit ? [`... +${total - limit} more call sites`] : [];
2842
+ return ['# call sites', ...head, ...overflow].join('\n');
2843
+ }
2844
+
2845
+ const NON_CALL_CAP = 40;
2846
+ const nonCallEntries = detailed.slice(0, NON_CALL_CAP);
2847
+ const overflow = detailed.length > NON_CALL_CAP
2848
+ ? `\n... +${detailed.length - NON_CALL_CAP} more non-call references`
2849
+ : '';
2850
+ return [
2851
+ '(no call sites)',
2852
+ nonCallEntries.length ? `# non-call references\n${nonCallEntries.map(format).join('\n')}${overflow}` : '',
2853
+ ].filter(Boolean).join('\n');
2854
+ }
2855
+
2856
+ // Distinct enclosing-function names that call `symbol` (the recursion frontier
2857
+ // for transitive callers). Reads STRUCTURED entries (not formatted text), so a
2858
+ // stray "caller=" inside raw source lineText can never be misread as a caller.
2859
+ // Name-based, like callers mode.
2860
+ function _callerNamesOf(graph, symbol, cwd, language) {
2861
+ const refs = _cheapReferenceSearch(graph, symbol, cwd, { language });
2862
+ // Named callers (recursable), keyed by name → first call-site location, so
2863
+ // the transitive tree annotates each node with `file:line` and a consumer
2864
+ // need not re-grep/read to find where it lives.
2865
+ const byName = new Map();
2866
+ // Anonymous call sites — invocations whose enclosing context has no named
2867
+ // function: setInterval/timer callbacks, event handlers (`backend.onMessage`
2868
+ // = arrow), module top-level boot blocks, fs.watch callbacks. They have no
2869
+ // name to recurse through, so the old name-only walk DROPPED them — yet they
2870
+ // are exactly the entry points a thorough consumer then greps for. Surface
2871
+ // each as a terminal leaf (keyed by location, labelled with its call-site
2872
+ // source line) so the tree is genuinely complete and the chase stops.
2873
+ const leaves = new Map();
2874
+ for (const e of _collectCallerEntries(graph, symbol, refs)) {
2875
+ if (e.kind !== 'call') continue;
2876
+ if (e.caller && e.caller !== symbol) {
2877
+ if (!byName.has(e.caller)) byName.set(e.caller, { name: e.caller, loc: `${e.file}:${e.line}`, leaf: false });
2878
+ } else if (!e.caller) {
2879
+ const loc = `${e.file}:${e.line}`;
2880
+ if (!leaves.has(loc)) {
2881
+ const snippet = String(e.lineText || 'call').replace(/\s+/g, ' ').trim().slice(0, 48);
2882
+ leaves.set(loc, { name: `«${snippet}»`, loc, leaf: true });
2883
+ }
2884
+ }
2885
+ }
2886
+ // Hub guard: a symbol with MANY anonymous call sites is a generic hub
2887
+ // (e.g. handleToolCall, called from test scripts + cross-module dispatch) —
2888
+ // listing them is noise, not entry points. Surface anonymous leaves only
2889
+ // when there are few enough to be this symbol's distinctive entry set
2890
+ // (timer/event/boot triggers); above the threshold, drop them all.
2891
+ const ANON_LEAF_MAX = 6;
2892
+ const leafList = leaves.size <= ANON_LEAF_MAX ? [...leaves.values()] : [];
2893
+ return [...byName.values(), ...leafList];
2894
+ }
2895
+
2896
+ // Transitive upstream caller TREE: BFS over enclosing-function names up to
2897
+ // `depth` levels, returned as an indented tree in ONE call (replaces the
2898
+ // manual per-level callers batching). NAME-BASED like callers mode — two
2899
+ // different functions sharing a name are merged, so this is an upstream-chain
2900
+ // OVERVIEW, not a precise per-declaration graph. Bounded by nodeCap; a symbol
2901
+ // whose callers were already listed is shown once and marked (shared-caller /
2902
+ // cycle guard) so the payload can't blow up.
2903
+ function _formatTransitiveCallers(graph, rootSymbol, cwd, { language = null, depth = 2, pageSize = 100, page = 1, hardMax = 1000 } = {}) {
2904
+ // Walk the whole transitive tree ONCE into a flat, ordered list of
2905
+ // { indent, label } entries (cycle-guarded; bounded by hardMax as an
2906
+ // anti-runaway ceiling), then PAGINATE that list `pageSize` nodes at a time.
2907
+ // Pagination beats a hard node cap: an oversized tree is no longer truncated
2908
+ // into an incomplete "go grep the rest" state — the consumer just asks for
2909
+ // page:N+1 and stays inside code_graph.
2910
+ const expanded = new Set();
2911
+ const collected = [];
2912
+ let overflow = false;
2913
+ const walk = (symbol, level) => {
2914
+ if (overflow || level >= depth) return;
2915
+ if (expanded.has(symbol)) {
2916
+ collected.push({ indent: level + 1, label: `${symbol} … (callers expanded above)` });
2917
+ return;
2918
+ }
2919
+ expanded.add(symbol);
2920
+ for (const entry of _callerNamesOf(graph, symbol, cwd, language)) {
2921
+ if (collected.length >= hardMax) { overflow = true; return; }
2922
+ // Each node carries its call-site file:line so the tree is
2923
+ // self-sufficient — no per-node re-grep/read needed to locate it.
2924
+ collected.push({ indent: level + 1, label: `${entry.name}\t${entry.loc}` });
2925
+ // Leaves are anonymous call sites (timer/event/boot) with no name to
2926
+ // walk through — terminal by construction.
2927
+ if (!entry.leaf) walk(entry.name, level + 1);
2928
+ }
2929
+ };
2930
+ walk(rootSymbol, 0);
2931
+ if (collected.length === 0) return _augmentNoHitDiagnostic('(no callers)', '(no callers)', graph, cwd, rootSymbol);
2932
+
2933
+ const size = Math.max(1, Math.floor(Number(pageSize) || 100));
2934
+ const pg = Math.max(1, Math.floor(Number(page) || 1));
2935
+ const total = collected.length;
2936
+ const lastPage = Math.ceil(total / size);
2937
+ const start = (pg - 1) * size;
2938
+ if (start >= total) {
2939
+ return `# transitive callers of ${rootSymbol} (depth=${depth}) — page ${pg} is past the end (total ${total}${overflow ? '+' : ''} node(s); last page is ${lastPage}).`;
2940
+ }
2941
+ const slice = collected.slice(start, start + size);
2942
+ const hasMore = overflow || (start + slice.length) < total;
2943
+ const lines = [
2944
+ `# transitive callers of ${rootSymbol} (depth=${depth}) — page ${pg}, nodes ${start + 1}-${start + slice.length} of ${total}${overflow ? '+' : ''}; INDENTED children are ITS callers`,
2945
+ rootSymbol,
2946
+ ...slice.map((e) => `${' '.repeat(e.indent)}${e.label}`),
2947
+ ];
2948
+ if (hasMore) {
2949
+ // More nodes remain — steer the continuation into the SAME tool (next
2950
+ // page), never a grep/read sweep.
2951
+ lines.push(`# NEXT — more callers remain; re-run callers with the SAME symbol + depth + page:${pg + 1} for the next ${size} node(s). Every node carries file:line — do NOT grep/read.`);
2952
+ } else {
2953
+ // Final page reached: the full transitive set has now been delivered.
2954
+ lines.push(`# END — complete caller set delivered (page ${pg} of ${lastPage}): named callers PLUS timer/event/module-level call sites (the «…» leaves), each with file:line. No further callers/grep/read is needed.`);
2955
+ }
2956
+ return lines.join('\n');
2957
+ }
2958
+
2959
+ function _referenceFiles(referenceText) {
2960
+ if (typeof referenceText !== 'string' || !referenceText.trim() || referenceText === '(no references)') {
2961
+ return [];
2962
+ }
2963
+ const files = new Set();
2964
+ for (const line of referenceText.split('\n')) {
2965
+ const trimmed = line.trim();
2966
+ if (!trimmed) continue;
2967
+ const m = /^(.+?):\d+:\d+(?:[\s\t]+.*)?$/.exec(trimmed);
2968
+ if (m) files.add(m[1]);
2969
+ }
2970
+ return [...files].sort();
2971
+ }
2972
+
2973
+ function _parseReferenceEntries(referenceText) {
2974
+ if (typeof referenceText !== 'string' || !referenceText.trim() || referenceText === '(no references)') {
2975
+ return [];
2976
+ }
2977
+ const out = [];
2978
+ for (const line of referenceText.split('\n')) {
2979
+ const trimmed = line.trim();
2980
+ if (!trimmed) continue;
2981
+ const m = /^(.+?):(\d+):(\d+)(?:[\s\t]+(.*))?$/.exec(trimmed);
2982
+ if (!m) continue;
2983
+ out.push({
2984
+ file: m[1],
2985
+ line: Number(m[2]),
2986
+ col: Number(m[3]),
2987
+ text: m[4] ? m[4].trim() : '',
2988
+ });
2989
+ }
2990
+ return out;
2991
+ }
2992
+
2993
+ function _formatSymbolImpactLine(item) {
2994
+ const callerSuffix = item.callers.length ? ` -> ${item.callers.join(', ')}` : '';
2995
+ return `${item.symbol}\trefs=${item.references}\tcallers=${item.callers.length}${callerSuffix}`;
2996
+ }
2997
+
2998
+ function _collectImpactSymbols(node, graph) {
2999
+ const names = new Set();
3000
+ for (const typeName of Array.isArray(node?.topLevelTypes) ? node.topLevelTypes : []) names.add(typeName);
3001
+ const text = _getSourceTextForNode(graph, node);
3002
+ for (const item of _collectCheapSymbols(text, node.lang)) names.add(item.name);
3003
+ return [...names];
3004
+ }
3005
+
3006
+ function _buildImpactSummary(node, graph, cwd, targetSymbol = '') {
3007
+ const imports = node.resolvedImports.map((p) => _graphRel(p, cwd));
3008
+ const dependents = [...(graph.reverse.get(node.rel) || [])].sort();
3009
+ const related = [...new Set([...imports, ...dependents])].sort();
3010
+ const symbols = targetSymbol ? [targetSymbol] : _collectImpactSymbols(node, graph).slice(0, 8);
3011
+ const symbolImpact = [];
3012
+ const externalCallers = new Set();
3013
+ let externalReferences = 0;
3014
+ for (const symbol of symbols) {
3015
+ const refs = _parseReferenceEntries(_cheapReferenceSearch(graph, symbol, cwd, { language: node.lang }))
3016
+ .filter((entry) => entry.file !== node.rel);
3017
+ if (refs.length === 0) continue;
3018
+ const callers = [...new Set(refs.map((entry) => entry.file))].sort();
3019
+ for (const caller of callers) externalCallers.add(caller);
3020
+ externalReferences += refs.length;
3021
+ symbolImpact.push({
3022
+ symbol,
3023
+ references: refs.length,
3024
+ callers,
3025
+ });
3026
+ }
3027
+ symbolImpact.sort((a, b) => (b.references - a.references) || a.symbol.localeCompare(b.symbol));
3028
+ return {
3029
+ imports,
3030
+ dependents,
3031
+ related,
3032
+ symbolImpact,
3033
+ externalCallers: [...externalCallers].sort(),
3034
+ externalReferences,
3035
+ scannedSymbols: symbols.length,
3036
+ };
3037
+ }
3038
+
3039
+ // Bound model-facing structural list output (imports/dependents/related,
3040
+ // symbols, external callers) so a high fan-in/fan-out or symbol-dense file
3041
+ // cannot inject an unbounded result — mirrors the find_imports/find_dependents
3042
+ // cap. Function declaration is hoisted, so callers earlier in the file resolve.
3043
+ function _capGraphList(arr, cap = 200) {
3044
+ return arr.length > cap
3045
+ ? [...arr.slice(0, cap), `[truncated — showing first ${cap} of ${arr.length}]`]
3046
+ : arr;
3047
+ }
3048
+
3049
+ function _formatRelated(node, graph, cwd) {
3050
+ const imports = node.resolvedImports.map((p) => _graphRel(p, cwd));
3051
+ const dependents = [...(graph.reverse.get(node.rel) || [])].sort();
3052
+ const related = [...new Set([...imports, ...dependents])].sort();
3053
+ // Align with `impact` mode's schema: emit summary counts + the related
3054
+ // array so callers reading either mode see consistent header fields
3055
+ // (file/language/imports/dependents/related) before the bodies.
3056
+ const lines = [
3057
+ `file\t${node.rel}`,
3058
+ `language\t${node.lang}`,
3059
+ `imports\t${imports.length}`,
3060
+ `dependents\t${dependents.length}`,
3061
+ `related\t${related.length}`,
3062
+ ];
3063
+ lines.push('');
3064
+ lines.push('# imports');
3065
+ lines.push(imports.length ? _capGraphList(imports).join('\n') : '(none)');
3066
+ lines.push('');
3067
+ lines.push('# dependents');
3068
+ lines.push(dependents.length ? _capGraphList(dependents).join('\n') : '(none)');
3069
+ if (related.length) {
3070
+ lines.push('');
3071
+ lines.push('# related');
3072
+ lines.push(..._capGraphList(related));
3073
+ }
3074
+ return lines.join('\n');
3075
+ }
3076
+
3077
+ function _formatImpact(node, graph, cwd, targetSymbol = '') {
3078
+ const summary = _buildImpactSummary(node, graph, cwd, targetSymbol);
3079
+ const lines = [
3080
+ `file\t${node.rel}`,
3081
+ `language\t${node.lang}`,
3082
+ `imports\t${summary.imports.length}`,
3083
+ `dependents\t${summary.dependents.length}`,
3084
+ `related\t${summary.related.length}`,
3085
+ `scanned_symbols\t${summary.scannedSymbols}`,
3086
+ `external_references\t${summary.externalReferences}`,
3087
+ `external_callers\t${summary.externalCallers.length}`,
3088
+ ];
3089
+ if (targetSymbol) lines.push(`symbol\t${targetSymbol}`);
3090
+ if (summary.related.length) {
3091
+ lines.push('');
3092
+ lines.push('# structural');
3093
+ lines.push(..._capGraphList(summary.related));
3094
+ }
3095
+ if (summary.symbolImpact.length) {
3096
+ lines.push('');
3097
+ lines.push(targetSymbol ? '# symbol impact' : '# top symbol impact');
3098
+ lines.push(...summary.symbolImpact.slice(0, 5).map(_formatSymbolImpactLine));
3099
+ }
3100
+ if (summary.externalCallers.length) {
3101
+ lines.push('');
3102
+ lines.push('# external callers');
3103
+ lines.push(..._capGraphList(summary.externalCallers));
3104
+ }
3105
+ return lines.join('\n');
3106
+ }
3107
+
3108
+ // ── Native graph binary (mixdog-graph) — single source of truth for
3109
+ // per-file parsing. There is NO JS parsing fallback: if the binary is
3110
+ // absent the build throws so the caller surfaces a clear, fixable error
3111
+ // instead of silently degrading to a slow path.
3112
+ function _graphBinaryPath() {
3113
+ const override = process.env.MIXDOG_GRAPH_BIN;
3114
+ if (override && existsSync(override)) return override;
3115
+ // fileURLToPath correctly decodes percent-encoded bytes (spaces, non-ASCII)
3116
+ // and strips the leading-slash/drive-letter quirk on Windows. Using
3117
+ // URL.pathname directly leaves `%20` etc. encoded, breaking paths with
3118
+ // spaces or non-ASCII characters.
3119
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
3120
+ const binName = process.platform === 'win32' ? 'mixdog-graph.exe' : 'mixdog-graph';
3121
+ // Prefer a local cargo build, then a previously fetched/cached prebuilt.
3122
+ const localBuild = pathResolve(moduleDir, '../../../../native/mixdog-graph/target/release', binName);
3123
+ if (existsSync(localBuild)) return localBuild;
3124
+ try { return findCachedGraphBinary(getPluginData()); } catch { return null; }
3125
+ }
3126
+
3127
+ async function _runGraphBinaryJsonl(absRoot, extraArgs, stdinLines = null) {
3128
+ let binPath = _graphBinaryPath();
3129
+ if (!binPath) {
3130
+ // No local build or cached binary — fetch the prebuilt from the release
3131
+ // manifest (sha256-verified). No JS parse fallback: if the platform has
3132
+ // no asset or the download fails, the build throws with a fixable error.
3133
+ try {
3134
+ binPath = await ensureGraphBinary(getPluginData());
3135
+ } catch (err) {
3136
+ throw new Error(
3137
+ `[code-graph] mixdog-graph binary unavailable and could not be fetched: ${err?.message || err}. `
3138
+ + 'Build it (cargo build --release in native/mixdog-graph) or check network/release manifest.',
3139
+ );
3140
+ }
3141
+ }
3142
+ const { spawn } = await import('node:child_process');
3143
+ return await new Promise((resolve, reject) => {
3144
+ // When stdinLines is supplied (--files mode), stream one JSON object per
3145
+ // line to the child's STDIN — the reused nodes' metadata — so Rust can
3146
+ // resolve imports across the WHOLE tree (fresh + reused) while only
3147
+ // full-parsing the changed subset passed as argv.
3148
+ const wantsStdin = Array.isArray(stdinLines);
3149
+ const proc = spawn(binPath, [absRoot, ...extraArgs], {
3150
+ stdio: [wantsStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
3151
+ // windowsHide: native code-graph binary is a console exe; without this each
3152
+ // call flashes a console window when spawned under the detached daemon.
3153
+ windowsHide: true,
3154
+ });
3155
+ const chunks = [];
3156
+ let stderrText = '';
3157
+ const STDERR_CAP = 8 * 1024;
3158
+ proc.stdout.on('data', (c) => chunks.push(c));
3159
+ proc.stderr.on('data', (c) => {
3160
+ if (stderrText.length >= STDERR_CAP) return;
3161
+ const piece = c.toString('utf8');
3162
+ const room = STDERR_CAP - stderrText.length;
3163
+ stderrText += piece.length > room ? piece.slice(0, room) : piece;
3164
+ });
3165
+ proc.on('error', (err) => reject(err));
3166
+ if (wantsStdin) {
3167
+ proc.stdin.on('error', () => { /* child may close stdin early; ignore EPIPE */ });
3168
+ proc.stdin.write(stdinLines.length ? `${stdinLines.join('\n')}\n` : '');
3169
+ proc.stdin.end();
3170
+ }
3171
+ proc.on('close', (code) => {
3172
+ if (code !== 0) {
3173
+ reject(new Error(`[code-graph] mixdog-graph exited ${code}: ${stderrText.trim().slice(0, 200)}`));
3174
+ return;
3175
+ }
3176
+ const out = [];
3177
+ const buf = Buffer.concat(chunks).toString('utf8');
3178
+ for (const line of buf.split('\n')) {
3179
+ const trimmed = line.trim();
3180
+ if (!trimmed) continue;
3181
+ try {
3182
+ const rec = JSON.parse(trimmed);
3183
+ if (rec && typeof rec.rel === 'string') out.push(rec);
3184
+ } catch { /* skip malformed line */ }
3185
+ }
3186
+ resolve(out);
3187
+ });
3188
+ });
3189
+ }
3190
+
3191
+ function _runGraphManifest(absRoot) { return _runGraphBinaryJsonl(absRoot, ['--manifest']); }
3192
+ function _runGraphWalk(absRoot) { return _runGraphBinaryJsonl(absRoot, []); }
3193
+ // --files (design A: full-graph resolution) full-parses only `rels` (argv) but
3194
+ // resolves imports across the WHOLE tree. The reused nodes' metas are streamed
3195
+ // to the child via STDIN as JSONL — one JSON object per line:
3196
+ // {rel, lang, rawImports, packageName, namespaceName, goPackageName,
3197
+ // topLevelTypes}. Rust builds the index + resolves over ALL nodes (fresh +
3198
+ // reused) and emits fresh rels as full records, reused rels as lightweight
3199
+ // {rel, resolvedImports, importedBy}.
3200
+ function _runGraphFiles(absRoot, rels, reusedMetas) {
3201
+ const lines = Array.isArray(reusedMetas)
3202
+ ? reusedMetas.map((m) => JSON.stringify({
3203
+ rel: m.rel,
3204
+ lang: m.lang,
3205
+ rawImports: Array.isArray(m.rawImports) ? m.rawImports : [],
3206
+ packageName: m.packageName || '',
3207
+ namespaceName: m.namespaceName || '',
3208
+ goPackageName: m.goPackageName || '',
3209
+ topLevelTypes: Array.isArray(m.topLevelTypes) ? m.topLevelTypes : [],
3210
+ }))
3211
+ : [];
3212
+ return _runGraphBinaryJsonl(absRoot, ['--files', ...rels], lines);
3213
+ }
3214
+
3215
+ // Map a Rust FileRecord (rel/lang/fp/tokens/rawImports/resolvedImports/
3216
+ // importedBy/...) onto the JS fileInfo shape the graph assembler expects.
3217
+ // Import resolution — including Go module paths — now happens entirely in
3218
+ // Rust; resolvedImports/importedBy are repo-relative path lists passed
3219
+ // straight through.
3220
+ function _fileInfoFromRustRecord(rec, absRoot) {
3221
+ const rel = rec.rel;
3222
+ const abs = pathResolve(absRoot, rel);
3223
+ const lang = rec.lang;
3224
+ return {
3225
+ abs,
3226
+ rel,
3227
+ lang,
3228
+ fingerprint: typeof rec.fp === 'string' ? rec.fp : '',
3229
+ sourceText: null,
3230
+ rawImports: Array.isArray(rec.rawImports) ? rec.rawImports : [],
3231
+ resolvedImports: Array.isArray(rec.resolvedImports)
3232
+ ? rec.resolvedImports.filter((v) => typeof v === 'string')
3233
+ : [],
3234
+ importedBy: Array.isArray(rec.importedBy)
3235
+ ? rec.importedBy.filter((v) => typeof v === 'string')
3236
+ : [],
3237
+ packageName: typeof rec.packageName === 'string' ? rec.packageName : '',
3238
+ namespaceName: typeof rec.namespaceName === 'string' ? rec.namespaceName : '',
3239
+ goPackageName: typeof rec.goPackageName === 'string' ? rec.goPackageName : '',
3240
+ topLevelTypes: Array.isArray(rec.topLevelTypes) ? rec.topLevelTypes : [],
3241
+ tokenSymbols: Array.isArray(rec.tokens) ? rec.tokens : null,
3242
+ symbols: Array.isArray(rec.symbols) ? rec.symbols : [],
3243
+ };
3244
+ }
3245
+
3246
+ // Reuse a node from the previous graph whose fp is unchanged — skips both
3247
+ // the Rust call and re-parsing for files that did not change.
3248
+ function _reuseFileInfo(prevNode, previousGraph, absRoot) {
3249
+ const rel = prevNode.rel;
3250
+ const fp = prevNode.fingerprint || '';
3251
+ const cachedText = previousGraph?._sourceTextCache?.get(rel);
3252
+ return {
3253
+ abs: prevNode.abs || pathResolve(absRoot, rel),
3254
+ rel,
3255
+ lang: prevNode.lang,
3256
+ fingerprint: fp,
3257
+ sourceText: cachedText?.fingerprint === fp ? cachedText.text : null,
3258
+ rawImports: Array.isArray(prevNode.rawImports) ? prevNode.rawImports : [],
3259
+ resolvedImports: Array.isArray(prevNode.resolvedImportsRel) ? prevNode.resolvedImportsRel : [],
3260
+ importedBy: Array.isArray(prevNode.importedBy) ? prevNode.importedBy : [],
3261
+ packageName: prevNode.packageName || '',
3262
+ namespaceName: prevNode.namespaceName || '',
3263
+ goPackageName: prevNode.goPackageName || '',
3264
+ topLevelTypes: Array.isArray(prevNode.topLevelTypes) ? prevNode.topLevelTypes : [],
3265
+ tokenSymbols: Array.isArray(prevNode.tokenSymbols) ? prevNode.tokenSymbols : null,
3266
+ symbols: Array.isArray(prevNode.symbols) ? prevNode.symbols : [],
3267
+ };
3268
+ }
3269
+
3270
+ /**
3271
+ * Internal — exported solely for `code-graph-prewarm-worker.mjs` to import.
3272
+ * NOT part of the public API. External callers should use `buildCodeGraphAsync`
3273
+ * (worker-thread isolated) or the `code_graph` / `find_symbol` tools, never
3274
+ * this synchronous form on the main event loop.
3275
+ */
3276
+ export async function _buildCodeGraph(cwd) {
3277
+ const now = Date.now();
3278
+ let _tp = performance.now();
3279
+ const _trace = (label) => { if (process.env.MIXDOG_GRAPH_TRACE) { const n = performance.now(); process.stderr.write(`[cg-trace] ${label}=${(n - _tp).toFixed(0)}ms\n`); _tp = n; } };
3280
+ const graphCwd = _canonicalGraphCwd(cwd);
3281
+ const absRoot = graphCwd;
3282
+ // Capture the dirty generation at build start. This build awaits the
3283
+ // manifest/walk; a write landing meanwhile bumps the generation and the
3284
+ // result must not be cached/persisted (it describes a pre-edit tree).
3285
+ const _genAtStart = _getCodeGraphGen(graphCwd);
3286
+ const cached = _codeGraphCache.get(graphCwd);
3287
+ let previousGraph = cached?.graph || null;
3288
+ // Dirty paths are subsumed by the manifest fp-diff below; drain the set
3289
+ // so it does not grow unbounded between builds.
3290
+ _consumeCodeGraphDirtyPaths(graphCwd);
3291
+
3292
+ // 1. Change-detect via Rust --manifest (fp/rel/size only, no parse).
3293
+ // The manifest is the FULL file list; the signature hashes every fp
3294
+ // so a change beyond CODE_GRAPH_MAX_FILES still invalidates the cache
3295
+ // and refreshes the `truncated` flag. Only `indexed` is built.
3296
+ const manifest = await _runGraphManifest(absRoot);
3297
+ const signature = _computeGraphSignature(manifest);
3298
+ _trace('manifest+sig');
3299
+ const truncated = manifest.length > CODE_GRAPH_MAX_FILES;
3300
+ const indexed = truncated ? manifest.slice(0, CODE_GRAPH_MAX_FILES) : manifest;
3301
+
3302
+ // 2. Memory cache hit.
3303
+ if (cached && cached.signature === signature && now - cached.ts < CODE_GRAPH_TTL_MS) {
3304
+ _touchCodeGraphCache(graphCwd);
3305
+ return cached.graph;
3306
+ }
3307
+
3308
+ // 3. Disk cache hit.
3309
+ _loadDiskCodeGraphCache(now);
3310
+ _ensureCwdLoaded(graphCwd);
3311
+ const diskEntry = _diskCodeGraphCache.get(graphCwd);
3312
+ if (diskEntry?.signature === signature) {
3313
+ const graph = _deserializeGraph(graphCwd, diskEntry);
3314
+ if (graph) {
3315
+ // Dirty-generation guard: skip caching if a write invalidated this
3316
+ // root since build start; still return the graph to the caller.
3317
+ if (_getCodeGraphGen(graphCwd) === _genAtStart) {
3318
+ _setCodeGraphCache(graphCwd, { ts: now, signature, graph });
3319
+ }
3320
+ return graph;
3321
+ }
3322
+ }
3323
+ if (!previousGraph && diskEntry) previousGraph = _deserializeGraph(graphCwd, diskEntry);
3324
+ // Schema guard: a graph built under an older symbol schema (pre-endLine)
3325
+ // must not seed incremental reuse. The schema-versioned signature already
3326
+ // blocks it from being SERVED as a direct cache hit, but its unchanged-fp
3327
+ // nodes would still be copied verbatim by _reuseFileInfo into the rebuilt
3328
+ // graph — carrying endLine-less symbols that defeat body-span containment.
3329
+ // Drop it so every node is re-parsed by the current binary.
3330
+ if (previousGraph && previousGraph.schemaVersion !== SYMBOL_SCHEMA_VERSION) {
3331
+ previousGraph = null;
3332
+ }
3333
+
3334
+ // 4. Build fileInfos. Reuse unchanged nodes by fp; parse the rest in
3335
+ // Rust — incrementally (--files) when only a subset changed, else a
3336
+ // full cold walk. There is no JS parse path.
3337
+ const reusable = [];
3338
+ const freshRels = [];
3339
+ for (const meta of indexed) {
3340
+ const previousNode = previousGraph?.nodes?.get(meta.rel) || null;
3341
+ if (previousNode && previousNode.fingerprint === meta.fp) {
3342
+ reusable.push(_reuseFileInfo(previousNode, previousGraph, absRoot));
3343
+ } else {
3344
+ freshRels.push(meta.rel);
3345
+ }
3346
+ }
3347
+ let fileInfos;
3348
+ if (freshRels.length === 0) {
3349
+ fileInfos = reusable;
3350
+ } else if (reusable.length > 0 && freshRels.length <= 256) {
3351
+ // Design A — full-graph resolution. Send the reused nodes' metas to the
3352
+ // child via STDIN so Rust resolves imports over ALL nodes (fresh +
3353
+ // reused), not just freshRels. Rust returns fresh rels as FULL records and
3354
+ // reused rels as lightweight {rel, resolvedImports, importedBy}. Refresh
3355
+ // each reused node's resolved edges in place (its tokens/symbols/rawImports/
3356
+ // package* stay) so newly-satisfied/broken edges and package resolution no
3357
+ // longer go stale until a cold rebuild.
3358
+ const recs = await _runGraphFiles(absRoot, freshRels, reusable);
3359
+ const reusedByRel = new Map(reusable.map((info) => [info.rel, info]));
3360
+ const freshSet = new Set(freshRels);
3361
+ fileInfos = [...reusable];
3362
+ for (const rec of recs) {
3363
+ if (freshSet.has(rec.rel)) {
3364
+ // fresh rel → full new node.
3365
+ fileInfos.push(_fileInfoFromRustRecord(rec, absRoot));
3366
+ } else {
3367
+ // reused rel → keep the existing reused node, overwrite its resolved
3368
+ // edges (rel + abs) with the refreshed full-graph resolution.
3369
+ const reusedInfo = reusedByRel.get(rec.rel);
3370
+ if (!reusedInfo) continue;
3371
+ const resolved = Array.isArray(rec.resolvedImports)
3372
+ ? rec.resolvedImports.filter((v) => typeof v === 'string')
3373
+ : [];
3374
+ reusedInfo.resolvedImports = resolved;
3375
+ if (Array.isArray(rec.importedBy)) {
3376
+ reusedInfo.importedBy = rec.importedBy.filter((v) => typeof v === 'string');
3377
+ }
3378
+ }
3379
+ }
3380
+ } else {
3381
+ // Rust caps --walk at MAX_FILES; this slice is a defensive safety net.
3382
+ // `truncated` is already set from the full manifest above.
3383
+ let recs = await _runGraphWalk(absRoot);
3384
+ if (recs.length > CODE_GRAPH_MAX_FILES) recs = recs.slice(0, CODE_GRAPH_MAX_FILES);
3385
+ fileInfos = recs.map((rec) => _fileInfoFromRustRecord(rec, absRoot));
3386
+ }
3387
+ _trace('walk+parse');
3388
+ const nodes = new Map();
3389
+ const reverse = new Map();
3390
+ for (const info of fileInfos) {
3391
+ // Rust now emits repo-relative resolved edges directly. Keep the
3392
+ // downstream node shape stable: resolvedImports STAYS ABSOLUTE,
3393
+ // resolvedImportsRel is the rel list as-is, and reverse is rederived
3394
+ // below from the forward edges of every node.
3395
+ const resolvedImportsRel = Array.isArray(info.resolvedImports) ? info.resolvedImports : [];
3396
+ const importedBy = Array.isArray(info.importedBy) ? info.importedBy : [];
3397
+ const node = {
3398
+ abs: info.abs,
3399
+ rel: info.rel,
3400
+ lang: info.lang,
3401
+ fingerprint: info.fingerprint,
3402
+ rawImports: info.rawImports,
3403
+ resolvedImportsRel,
3404
+ resolvedImports: resolvedImportsRel.map((rel) => pathResolve(absRoot, rel)),
3405
+ importedBy,
3406
+ packageName: info.packageName,
3407
+ namespaceName: info.namespaceName,
3408
+ goPackageName: info.goPackageName,
3409
+ topLevelTypes: info.topLevelTypes,
3410
+ tokenSymbols: info.tokenSymbols,
3411
+ symbols: Array.isArray(info.symbols) ? info.symbols : [],
3412
+ };
3413
+ nodes.set(info.rel, node);
3414
+ // reverse is derived from the FORWARD edges of every node, not from
3415
+ // importedBy. On the incremental --files path Rust only emits records for
3416
+ // the parsed subset and reused nodes keep a stale importedBy, so a fresh
3417
+ // edge A→B (A parsed, B reused) would drop B's reverse entry until a cold
3418
+ // rebuild. Walking resolvedImportsRel keeps reverse self-consistent.
3419
+ for (const rel of resolvedImportsRel) {
3420
+ if (!reverse.has(rel)) reverse.set(rel, new Set());
3421
+ reverse.get(rel).add(node.rel);
3422
+ }
3423
+ }
3424
+ _trace('assemble');
3425
+ const graph = _attachGraphRuntimeCaches({ cwd: graphCwd, nodes, reverse, schemaVersion: SYMBOL_SCHEMA_VERSION, builtAt: now, signature });
3426
+ // Surface truncation so downstream output (find_symbol, overview) can
3427
+ // warn callers that the graph stopped at CODE_GRAPH_MAX_FILES rather
3428
+ // than indexing every eligible file under cwd.
3429
+ graph.truncated = Boolean(truncated);
3430
+ for (const info of fileInfos) {
3431
+ if (typeof info.sourceText === 'string') {
3432
+ graph._sourceTextCache.set(info.rel, {
3433
+ fingerprint: info.fingerprint || '',
3434
+ text: info.sourceText,
3435
+ });
3436
+ }
3437
+ }
3438
+ graph._symbolTokenIndexDirty = true;
3439
+ // Dirty-generation guard: a write that landed during the manifest/walk
3440
+ // bumped the generation; drop the now-stale result (no cache, no disk)
3441
+ // and return it only to the awaiting caller.
3442
+ if (_getCodeGraphGen(graphCwd) === _genAtStart) {
3443
+ _setCodeGraphCache(graphCwd, { ts: now, signature, graph });
3444
+ _setDiskCodeGraphEntry(graphCwd, graph);
3445
+ _trace('cache+disk');
3446
+ }
3447
+ return graph;
3448
+ }
3449
+
3450
+ // Modes that operate on a single named symbol and can be looped to serve a
3451
+ // multi-symbol request in one call (the graph is cwd-cached, so per-symbol
3452
+ // re-entry is cheap). impact is excluded — it is file-scoped, not symbol-list.
3453
+ const CODE_GRAPH_BATCHABLE_MODES = new Set(['symbol', 'find_symbol', 'callers', 'callees', 'references']);
3454
+ // Collect requested symbol names from symbols[] (array), symbols (comma/space
3455
+ // string), or symbol (single name OR comma/space-separated multi), de-duped in
3456
+ // request order.
3457
+ function _collectGraphSymbolList(args) {
3458
+ const split = (s) => String(s || '').split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
3459
+ return [...new Set([
3460
+ ...(Array.isArray(args?.symbols) ? args.symbols.map((s) => String(s || '').trim()).filter(Boolean) : []),
3461
+ ...(typeof args?.symbols === 'string' ? split(args.symbols) : []),
3462
+ ...(typeof args?.symbol === 'string' ? split(args.symbol) : []),
3463
+ ])];
3464
+ }
3465
+
3466
+ async function codeGraph(args, cwd, signal = null, options = {}) {
3467
+ const mode = String(args?.mode || '').trim();
3468
+ if (!mode) throw new Error('code_graph: "mode" is required');
3469
+
3470
+ if (mode === 'prewarm') {
3471
+ // R5-③: TRUE fire-and-forget. Previously this function awaited
3472
+ // buildCodeGraphAsync above before branching into the prewarm path,
3473
+ // which defeated the prewarm contract — the caller blocked on the
3474
+ // very build prewarm is supposed to schedule. Handle prewarm BEFORE
3475
+ // the await so the caller returns immediately and the build runs
3476
+ // in the background.
3477
+ //
3478
+ // Build code graph + populate lazy per-symbol candidate cache for
3479
+ // the requested symbols. Caller does not block on the actual build;
3480
+ // returns immediately so the caller can pipeline its real
3481
+ // find_symbol calls right after.
3482
+ // Accepts symbols via: args.symbols (array), args.symbols (comma/space
3483
+ // separated string), or args.symbol (single name OR comma/space
3484
+ // separated multi). Client-side mcp schema caches sometimes strip
3485
+ // unknown fields, so the multi-form via the always-known `symbol`
3486
+ // field is the most portable.
3487
+ const _splitMulti = (s) => String(s || '').split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
3488
+ const fromSymbolsArr = Array.isArray(args?.symbols)
3489
+ ? args.symbols.map((s) => String(s || '').trim()).filter(Boolean)
3490
+ : [];
3491
+ const fromSymbolsStr = typeof args?.symbols === 'string' ? _splitMulti(args.symbols) : [];
3492
+ const fromSymbolField = typeof args?.symbol === 'string' ? _splitMulti(args.symbol) : [];
3493
+ const symbols = [...new Set([...fromSymbolsArr, ...fromSymbolsStr, ...fromSymbolField])];
3494
+ if (symbols.length > 0) prewarmCodeGraphSymbols(cwd, symbols);
3495
+ else prewarmCodeGraph(cwd);
3496
+ return `prewarm scheduled: cwd=${cwd} symbols=${symbols.length}${symbols.length ? ` (${symbols.slice(0, 5).join(',')}${symbols.length > 5 ? `,+${symbols.length - 5}` : ''})` : ''}`;
3497
+ }
3498
+
3499
+ const graph = await buildCodeGraphAsync(cwd, signal);
3500
+ if (!graph || graph.nodes.size === 0) {
3501
+ throw new Error(`code_graph: cwd '${cwd}' is not an indexed/known project root or contains zero eligible files`);
3502
+ }
3503
+ if (options?.scopedCacheOutcome && graph.truncated) {
3504
+ markScopedCacheIncomplete(options.scopedCacheOutcome);
3505
+ }
3506
+ const normFile = normalizeInputPath(args?.file);
3507
+ const abs = normFile ? (isAbsolute(normFile) ? pathResolve(normFile) : pathResolve(cwd, normFile)) : null;
3508
+ let fileIsDirectory = false;
3509
+ if (abs) {
3510
+ try { fileIsDirectory = statSync(abs).isDirectory(); } catch { fileIsDirectory = false; }
3511
+ }
3512
+ const rel = abs && !fileIsDirectory ? _graphRel(abs, cwd) : null;
3513
+ const scopeRelPrefix = abs && fileIsDirectory
3514
+ ? (() => {
3515
+ const r = _graphRel(abs, cwd).replace(/\\/g, '/').replace(/\/+$/, '');
3516
+ return (!r || r === '.') ? null : `${r}/`;
3517
+ })()
3518
+ : null;
3519
+ const node = rel ? graph.nodes.get(rel) : null;
3520
+
3521
+ if (mode === 'overview') {
3522
+ if (rel && !node) return `Error: code_graph overview: file not found in graph: ${normFile}`;
3523
+ if (node) return _buildExplainerFileSummary(node, graph, cwd);
3524
+ const byLang = new Map();
3525
+ for (const node of graph.nodes.values()) {
3526
+ byLang.set(node.lang, (byLang.get(node.lang) || 0) + 1);
3527
+ }
3528
+ const lines = [
3529
+ `files\t${graph.nodes.size}`,
3530
+ `edges\t${Array.from(graph.nodes.values()).reduce((sum, n) => sum + n.resolvedImports.length, 0)}`,
3531
+ ];
3532
+ for (const [lang, count] of [...byLang.entries()].sort((a, b) => b[1] - a[1])) {
3533
+ lines.push(`${lang}\t${count}`);
3534
+ }
3535
+ if (graph?.truncated) {
3536
+ lines.push(`WARN: graph truncated at CODE_GRAPH_MAX_FILES=${CODE_GRAPH_MAX_FILES} — some files under cwd were not indexed`);
3537
+ }
3538
+ return lines.join('\n');
3539
+ }
3540
+
3541
+ if (mode === 'imports') {
3542
+ if (!node) return `Error: code_graph imports: file not found in graph: ${normFile || '(missing file)'}`;
3543
+ const GRAPH_LIST_CAP = 200;
3544
+ const resolvedAll = node.resolvedImports.map((p) => _graphRel(p, cwd));
3545
+ const rawAll = node.rawImports;
3546
+ const resolved = resolvedAll.slice(0, GRAPH_LIST_CAP);
3547
+ const raw = rawAll.slice(0, GRAPH_LIST_CAP);
3548
+ const parts = [];
3549
+ if (resolved.length) parts.push(resolved.join('\n'));
3550
+ if (raw.length) parts.push(`# raw\n${raw.join('\n')}`);
3551
+ if (resolvedAll.length > resolved.length || rawAll.length > raw.length) {
3552
+ parts.push(`[truncated — showing first ${GRAPH_LIST_CAP} of ${resolvedAll.length} resolved / ${rawAll.length} raw imports]`);
3553
+ }
3554
+ return parts.join('\n\n') || '(no imports)';
3555
+ }
3556
+
3557
+ if (mode === 'dependents') {
3558
+ if (!rel) throw new Error('code_graph dependents: "file" is required');
3559
+ // Validate the path is actually indexed before answering. Without
3560
+ // this check, a typo or unsupported extension silently returns
3561
+ // "(no dependents)" — indistinguishable from a real zero-dependent
3562
+ // file and a frequent source of "graph says nothing depends on X"
3563
+ // false negatives.
3564
+ if (!node) return `Error: code_graph dependents: file not found in graph: ${normFile || '(missing file)'}`;
3565
+ const GRAPH_LIST_CAP = 200;
3566
+ const depsAll = [...(graph.reverse.get(rel) || [])].sort();
3567
+ if (!depsAll.length) return '(no dependents)';
3568
+ const deps = depsAll.slice(0, GRAPH_LIST_CAP);
3569
+ // Enrich each dependent with the import line so callers do not need
3570
+ // a follow-up grep for `file:line`. Best-effort: if the importer
3571
+ // file cannot be read or no matching import line is found, fall back
3572
+ // to the bare relative path.
3573
+ const basename = rel.split('/').pop();
3574
+ const stem = basename.replace(/\.[^/.]+$/, '');
3575
+ const enriched = deps.map((dep) => {
3576
+ const depNode = graph.nodes.get(dep);
3577
+ if (!depNode) return dep;
3578
+ let text;
3579
+ try { text = readFileSync(depNode.abs, 'utf8'); } catch { return dep; }
3580
+ const linesArr = text.split(/\r?\n/);
3581
+ for (let i = 0; i < linesArr.length; i++) {
3582
+ const ln = linesArr[i];
3583
+ // The specifier line of a MULTI-LINE import (`} from './x.mjs';`) and
3584
+ // re-exports (`export ... from`) carry no import/require keyword on
3585
+ // that line — match the `from '...'` tail too, or those dependents
3586
+ // lose their :line.
3587
+ if (!/(?:^|\W)(?:import|require)\b|\bfrom\s*['"]/.test(ln)) continue;
3588
+ if (ln.includes(`/${basename}`) || ln.includes(`/${stem}`) || ln.includes(`'${basename}'`) || ln.includes(`"${basename}"`)) {
3589
+ return `${dep}:${i + 1}`;
3590
+ }
3591
+ }
3592
+ return dep;
3593
+ });
3594
+ const out = enriched.join('\n');
3595
+ return depsAll.length > deps.length
3596
+ ? `${out}\n[truncated — showing first ${GRAPH_LIST_CAP} of ${depsAll.length} dependents]`
3597
+ : out;
3598
+ }
3599
+
3600
+ if (mode === 'related') {
3601
+ if (!node) return `Error: code_graph related: file not found in graph: ${normFile || '(missing file)'}`;
3602
+ return _formatRelated(node, graph, cwd);
3603
+ }
3604
+
3605
+ if (mode === 'impact') {
3606
+ if (!node) return `Error: code_graph impact: file not found in graph: ${normFile || '(missing file)'}`;
3607
+ const targetSymbol = String(args?.symbol || '').trim();
3608
+ return _formatImpact(node, graph, cwd, targetSymbol);
3609
+ }
3610
+
3611
+ if (mode === 'callees') {
3612
+ // FORWARD call navigation: mirror of `callers` (reverse). Given a
3613
+ // symbol X, locate its declaration via the existing find_symbol path,
3614
+ // then delegate body extraction + callee resolution to the shared
3615
+ // `_extractCallees` helper. The default `find_symbol` declaration
3616
+ // path also calls the same helper so structural forward-graph results
3617
+ // are returned without the caller having to pass mode:"callees".
3618
+ const symbol = String(args?.symbol || '').trim();
3619
+ if (!symbol) throw new Error('code_graph callees: "symbol" is required.');
3620
+ const explicitLanguage = String(args?.language || '').trim() || null;
3621
+ if (rel && !node) return `Error: code_graph callees: file not found in graph: ${normFile || '(missing file)'}`;
3622
+ const allHits = _findSymbolHits(graph, symbol, { language: explicitLanguage });
3623
+ const hits = rel ? allHits.filter((h) => h.rel === rel) : allHits;
3624
+ const declHit = hits.find((h) => h.declarationLike) || hits[0];
3625
+ if (!declHit) {
3626
+ const scopeNote = rel ? ` file=${rel}` : '';
3627
+ return `(no symbol matches in cwd=${cwd}${scopeNote})`;
3628
+ }
3629
+ if (!_CALLEES_BRACE_LANGS.has(declHit.lang)) {
3630
+ return `(callees unsupported for ${declHit.lang})`;
3631
+ }
3632
+ const rows = _extractCallees(graph, declHit, cwd, {
3633
+ cap: 200,
3634
+ callerSymbol: symbol,
3635
+ language: explicitLanguage,
3636
+ });
3637
+ if (!rows.length) return `(no callees)`;
3638
+ const out = ['# callees'];
3639
+ for (const row of rows) out.push(_formatCalleeRow(row));
3640
+ return out.join('\n');
3641
+ }
3642
+
3643
+ if (mode === 'symbols') {
3644
+ if (!node) return `Error: code_graph symbols: file not found in graph: ${normFile || '(missing file)'}`;
3645
+ let text = '';
3646
+ try { text = readFileSync(node.abs, 'utf8'); } catch { return '(no symbols)'; }
3647
+ return _extractSymbolsCheap(text, node.lang);
3648
+ }
3649
+
3650
+ if (mode === 'find_symbol') {
3651
+ const symbol = String(args?.symbol || '').trim();
3652
+ if (!symbol) throw new Error('code_graph find_symbol: "symbol" is required.');
3653
+ const language = String(args?.language || '').trim() || null;
3654
+ const limit = Math.max(1, Math.min(50, Number(args?.limit || 20)));
3655
+ // SCOPE ISOLATION: if caller narrowed by `file`, validate it's indexed
3656
+ // then restrict hits to that file only (drop same-named symbols in
3657
+ // unrelated files).
3658
+ if (rel && !node) return `Error: code_graph find_symbol: file not found in graph: ${normFile || '(missing file)'}`;
3659
+ return _findSymbolAcrossGraph(graph, symbol, cwd, { language, limit, fileRel: rel, body: args?.body !== false });
3660
+ }
3661
+
3662
+ if (mode === 'search') {
3663
+ const keyword = String(args?.symbol || '').trim();
3664
+ if (!keyword) throw new Error('code_graph search: "symbol" is required.');
3665
+ const language = String(args?.language || '').trim() || null;
3666
+ const limit = Math.max(1, Math.min(100, Number(args?.limit || 30)));
3667
+ return _searchSymbolsByKeyword(graph, keyword, cwd, { language, limit });
3668
+ }
3669
+
3670
+ if (mode === 'references') {
3671
+ const symbol = String(args?.symbol || '').trim();
3672
+ if (!symbol) throw new Error('code_graph references: "symbol" is required.');
3673
+ const explicitLanguage = String(args?.language || '').trim() || null;
3674
+ if (explicitLanguage) {
3675
+ const langHasFiles = [...graph.nodes.values()].some((n) => n.lang === explicitLanguage);
3676
+ if (!langHasFiles) {
3677
+ throw new Error(`code_graph references: language '${explicitLanguage}' has no adapter topLevelTypes and is not in supportedRegexLangs for this project`);
3678
+ }
3679
+ }
3680
+ const narrowedByCaller = Boolean(rel || scopeRelPrefix || explicitLanguage);
3681
+ const resolved = _resolveReferenceLanguageNode(graph, symbol, rel, cwd, explicitLanguage);
3682
+ // Distinguish "file path was never indexed" from "file is indexed but the
3683
+ // symbol never appears in it". The former is a path/scope problem (typo,
3684
+ // unsupported extension); the latter is a real zero-hit answer scoped to
3685
+ // the requested file. Both still terminate the request when the caller
3686
+ // narrowed by file, but the message lets the caller pick the right
3687
+ // recovery (fix the path vs. drop the file filter / widen the search).
3688
+ if (rel && resolved.kind === 'file-not-found') {
3689
+ return `Error: code_graph references: file not found in graph: ${normFile || '(missing file)'}`;
3690
+ }
3691
+ if (rel && resolved.kind === 'symbol-not-present') {
3692
+ return `Error: code_graph references: symbol "${symbol}" not found in ${normFile || rel}`;
3693
+ }
3694
+ const resolvedNode = resolved.kind === 'ok' ? resolved.node : null;
3695
+ // Bare references (no file/language narrow) → search every language so
3696
+ // a symbol with the same name in TS+PY isn't quietly truncated to
3697
+ // whichever language the first hit happened to land in.
3698
+ // Explicit language is a hard scope — preserve it even when the resolver
3699
+ // failed to land on a node, so the search doesn't silently widen to every
3700
+ // language (mirrors callers mode at the matching site). Bare refs with no
3701
+ // file/language narrow still search all languages.
3702
+ const lang = explicitLanguage
3703
+ || ((narrowedByCaller && resolvedNode) ? resolvedNode.lang : null);
3704
+ // Only use args.limit when it's a positive finite number. 0/negative/
3705
+ // missing all fall back to null → ENV_CAP (REFERENCE_HIT_CAP) so the
3706
+ // no-limit caller gets the full result set as before. Clamp upper
3707
+ // bound at 500 to keep payloads sane.
3708
+ const rawLimit = Number(args?.limit);
3709
+ const userLimit = Number.isFinite(rawLimit) && rawLimit > 0
3710
+ ? Math.min(500, Math.floor(rawLimit))
3711
+ : null;
3712
+ // Parallel pre-read so the sync search inside _cheapReferenceSearch
3713
+ // hits the in-memory text cache instead of paying ~200 serial disk reads.
3714
+ await _prewarmReferenceSourceText(graph, symbol, lang);
3715
+ // SCOPE ISOLATION: when `file` is set, restrict reference search to
3716
+ // that single file so a caller asking "refs in foo.mjs" doesn't get
3717
+ // hits from every other file that happens to share the identifier.
3718
+ const refResult = _cheapReferenceSearch(graph, symbol, cwd, { language: lang, limit: userLimit, fileRel: rel, scopeRelPrefix });
3719
+ return narrowedByCaller ? refResult : _augmentNoHitDiagnostic(refResult, '(no references)', graph, cwd, symbol);
3720
+ }
3721
+
3722
+ if (mode === 'callers') {
3723
+ const symbol = String(args?.symbol || '').trim();
3724
+ if (!symbol) throw new Error('code_graph callers: "symbol" is required.');
3725
+ const explicitLanguage = String(args?.language || '').trim() || null;
3726
+ // Validate explicit-language scope up front so callers mode mirrors the
3727
+ // references-mode contract: an unrecognised/unindexed language is a
3728
+ // hard scope error, not a silent fall-through to a broader search.
3729
+ if (explicitLanguage) {
3730
+ const langHasFiles = [...graph.nodes.values()].some((n) => n.lang === explicitLanguage);
3731
+ if (!langHasFiles) {
3732
+ throw new Error(`code_graph callers: language '${explicitLanguage}' has no adapter topLevelTypes and is not in supportedRegexLangs for this project`);
3733
+ }
3734
+ }
3735
+ const narrowedByCaller = Boolean(rel || scopeRelPrefix || explicitLanguage);
3736
+ const resolved = _resolveReferenceLanguageNode(graph, symbol, rel, cwd, explicitLanguage);
3737
+ if (rel && resolved.kind === 'file-not-found') {
3738
+ return `Error: code_graph callers: file not found in graph: ${normFile || '(missing file)'}`;
3739
+ }
3740
+ if (rel && resolved.kind === 'symbol-not-present') {
3741
+ return `Error: code_graph callers: symbol "${symbol}" not found in ${normFile || rel}`;
3742
+ }
3743
+ const resolvedNode = resolved.kind === 'ok' ? resolved.node : null;
3744
+ // Explicit language is a hard scope — keep it even when the resolver
3745
+ // failed to land on a node, so the downstream cheap reference search
3746
+ // doesn't silently widen to every language.
3747
+ const lang = explicitLanguage
3748
+ || ((narrowedByCaller && resolvedNode) ? resolvedNode.lang : null);
3749
+ // Only positive finite limits propagate. 0/negative/missing fall back
3750
+ // to ENV_CAP via the formatter+search defaults.
3751
+ const rawLimit = Number(args?.limit);
3752
+ const userLimit = Number.isFinite(rawLimit) && rawLimit > 0
3753
+ ? Math.min(500, Math.floor(rawLimit))
3754
+ : null;
3755
+ // Parallel pre-read so the sync search hits the in-memory text cache.
3756
+ await _prewarmReferenceSourceText(graph, symbol, lang);
3757
+ // Transitive upstream tree: depth>1 walks caller-of-caller up to `depth`
3758
+ // levels in ONE call (replaces manual per-level callers batching). depth<=1
3759
+ // keeps the single-level path byte-identical. Graph-wide by design (the
3760
+ // chain crosses modules); file: scope is ignored for the transitive walk.
3761
+ const depth = Math.max(1, Math.min(5, Math.floor(Number(args?.depth) || 1)));
3762
+ if (depth > 1) {
3763
+ return _formatTransitiveCallers(graph, symbol, cwd, { language: lang, depth, page: args?.page });
3764
+ }
3765
+ // SCOPE ISOLATION: file-narrowed callers stays within that file too.
3766
+ const refs = _cheapReferenceSearch(graph, symbol, cwd, { language: lang, limit: userLimit, fileRel: rel, scopeRelPrefix });
3767
+ const callerResult = _formatCallerReferences(graph, symbol, refs, userLimit ? { limit: userLimit } : undefined);
3768
+ return narrowedByCaller ? callerResult : _augmentNoHitDiagnostic(callerResult, '(no callers)', graph, cwd, symbol);
3769
+ }
3770
+
3771
+ throw new Error(`code_graph: unknown mode "${mode}"`);
3772
+ }
3773
+
3774
+ async function findSymbolTool(args, cwd, signal = null, options = {}) {
3775
+ // Prewarm short-circuit: no graph build await, fire-and-forget. Returns
3776
+ // immediately so Lead can issue prewarm at session start then pipeline
3777
+ // real find_symbol calls without blocking on the cold-process scan.
3778
+ if (args?.mode === 'prewarm') {
3779
+ const _splitMulti = (s) => String(s || '').split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
3780
+ const fromSymbolsArr = Array.isArray(args?.symbols)
3781
+ ? args.symbols.map((s) => String(s || '').trim()).filter(Boolean)
3782
+ : [];
3783
+ const fromSymbolsStr = typeof args?.symbols === 'string' ? _splitMulti(args.symbols) : [];
3784
+ const fromSymbolField = typeof args?.symbol === 'string' ? _splitMulti(args.symbol) : [];
3785
+ const symbols = [...new Set([...fromSymbolsArr, ...fromSymbolsStr, ...fromSymbolField])];
3786
+ if (symbols.length > 0) prewarmCodeGraphSymbols(cwd, symbols);
3787
+ else prewarmCodeGraph(cwd);
3788
+ return `prewarm scheduled: cwd=${cwd} symbols=${symbols.length}${symbols.length ? ` (${symbols.slice(0, 5).join(',')}${symbols.length > 5 ? `,+${symbols.length - 5}` : ''})` : ''}`;
3789
+ }
3790
+ const graph = await buildCodeGraphAsync(cwd, signal);
3791
+ if (!graph) throw new Error(`find_symbol: cwd '${cwd}' is not an indexed/known project root or contains zero eligible files`);
3792
+ if (options?.scopedCacheOutcome && graph.truncated) {
3793
+ markScopedCacheIncomplete(options.scopedCacheOutcome);
3794
+ }
3795
+ const symbol = String(args?.symbol || '').trim();
3796
+ const language = String(args?.language || '').trim() || null;
3797
+ const limit = Math.max(1, Math.min(50, Number(args?.limit || 20)));
3798
+ // SCOPE ISOLATION: when `file` is supplied, restrict hits to that file's
3799
+ // declaration + refs (don't return every same-named symbol across the
3800
+ // tree). Validates the path is actually indexed so a typo surfaces a
3801
+ // clear error instead of a silent "(no symbol matches)".
3802
+ const normFile = normalizeInputPath(args?.file);
3803
+ const abs = normFile ? (isAbsolute(normFile) ? pathResolve(normFile) : pathResolve(cwd, normFile)) : null;
3804
+ const fileRel = abs ? _graphRel(abs, cwd) : null;
3805
+ if (fileRel && !graph.nodes.get(fileRel)) {
3806
+ return `Error: find_symbol: file not found in graph: ${normFile}`;
3807
+ }
3808
+ // FILE-OVERVIEW MODE: `symbol` omitted but `file` given → list that file's
3809
+ // symbols (mirrors the dispatcher's `symbols` mode). The tool spec allows
3810
+ // "symbol (to locate) OR file (to list its symbols)"; the bare-`symbol`
3811
+ // guard here used to reject this otherwise-valid file-only call.
3812
+ if (!symbol) {
3813
+ if (fileRel) {
3814
+ const node = graph.nodes.get(fileRel);
3815
+ let text = '';
3816
+ try { text = readFileSync(node.abs, 'utf8'); } catch { return '(no symbols)'; }
3817
+ return _extractSymbolsCheap(text, node.lang);
3818
+ }
3819
+ throw new Error('find_symbol: provide "symbol" (to locate) or "file" (to list its symbols).');
3820
+ }
3821
+ return _findSymbolAcrossGraph(graph, symbol, cwd, { language, limit, fileRel, body: args?.body !== false });
3822
+ }
3823
+
3824
+
3825
+
3826
+ export { CODE_GRAPH_TOOL_DEFS } from './code-graph-tool-defs.mjs';
3827
+
3828
+ /**
3829
+ * Resolve a symbol name to a 1-based [startLine, endLine] declaration span for read().
3830
+ * Returns `{ offset, limit, startLine, endLine, rel, note? }` or `{ error }`.
3831
+ */
3832
+ // Recover the end line of a brace-delimited declaration whose endLine the
3833
+ // graph does not record (assignment-style decls): the body closes at the
3834
+ // first `}`-leading line indented at or left of the declaration line. Exact
3835
+ // for conventionally-indented code; returns null (caller falls back) when no
3836
+ // such line exists within the scan window — e.g. minified or single-line.
3837
+ const SYMBOL_SPAN_SCAN_MAX_LINES = 400;
3838
+ function _inferSpanEndByIndent(allLines, startLine) {
3839
+ const decl = allLines[startLine - 1];
3840
+ if (typeof decl !== 'string' || !/[{([]\s*$/.test(decl.trimEnd())) return null;
3841
+ const declIndent = decl.match(/^[ \t]*/)[0].length;
3842
+ const last = Math.min(allLines.length, startLine - 1 + SYMBOL_SPAN_SCAN_MAX_LINES);
3843
+ for (let i = startLine; i < last; i++) {
3844
+ const line = allLines[i];
3845
+ if (!/^[ \t]*[})\]]/.test(line)) continue;
3846
+ const indent = line.match(/^[ \t]*/)[0].length;
3847
+ if (indent <= declIndent) return i + 1;
3848
+ }
3849
+ return null;
3850
+ }
3851
+
3852
+ export async function resolveSymbolReadSpan(cwd, { symbol, path = null, language = null, line = null } = {}) {
3853
+ const cleanSymbol = String(symbol || '').trim();
3854
+ if (!cleanSymbol) return { error: 'symbol is required' };
3855
+ let graph;
3856
+ try {
3857
+ graph = await buildCodeGraphAsync(cwd);
3858
+ } catch (err) {
3859
+ return { error: `symbol read: code graph unavailable (${err?.message || err})` };
3860
+ }
3861
+ if (!graph) return { error: 'symbol read: code graph could not be built for cwd' };
3862
+
3863
+ const normFile = path ? normalizeInputPath(path) : null;
3864
+ const abs = normFile ? (isAbsolute(normFile) ? pathResolve(normFile) : pathResolve(cwd, normFile)) : null;
3865
+ const fileRel = abs ? _graphRel(abs, cwd) : null;
3866
+ if (fileRel && !graph.nodes.get(fileRel)) {
3867
+ return { error: `symbol '${cleanSymbol}' not found — file not indexed: ${path}; use find_symbol` };
3868
+ }
3869
+
3870
+ let hits = _findSymbolHits(graph, cleanSymbol, { language });
3871
+ if (fileRel) hits = hits.filter((h) => h.rel === fileRel);
3872
+ if (!hits.length) {
3873
+ const scope = fileRel ? ` in ${fileRel}` : '';
3874
+ return { error: `symbol '${cleanSymbol}' not found${scope}; use find_symbol to locate it` };
3875
+ }
3876
+
3877
+ const disambigLine = Number(line);
3878
+ let primary;
3879
+ if (Number.isFinite(disambigLine) && disambigLine > 0) {
3880
+ const onLine = hits.filter((h) => h.line === disambigLine);
3881
+ primary = _pickCalleeDeclHit(onLine.length ? onLine : hits, fileRel);
3882
+ } else {
3883
+ primary = _pickCalleeDeclHit(hits, fileRel);
3884
+ }
3885
+ if (!primary) return { error: `symbol '${cleanSymbol}' not found; use find_symbol` };
3886
+
3887
+ const startLine = Number(primary.line);
3888
+ let endLine = Number(primary.endLine);
3889
+ let approximate = false;
3890
+ if (!Number.isFinite(startLine) || startLine < 1) {
3891
+ return { error: `symbol '${cleanSymbol}' has no valid declaration line; use find_symbol` };
3892
+ }
3893
+ if (!Number.isFinite(endLine) || endLine < startLine) {
3894
+ // Assignment-style decls record no endLine; a fixed +79 window over-reads
3895
+ // short arrows ~4x and truncates longer ones. Recover the real span from
3896
+ // indentation; fall back to the fixed window only when the scan fails.
3897
+ const node = graph.nodes.get(primary.rel);
3898
+ const srcText = node ? _getSourceTextForNode(graph, node) : null;
3899
+ const inferred = srcText ? _inferSpanEndByIndent(srcText.split('\n'), startLine) : null;
3900
+ if (inferred) {
3901
+ endLine = inferred;
3902
+ } else {
3903
+ endLine = startLine + 79;
3904
+ approximate = true;
3905
+ }
3906
+ }
3907
+ const declCount = hits.filter((h) => h.declarationLike).length;
3908
+ const notes = [];
3909
+ if (approximate) notes.push('end line unknown — approximate range from declaration line');
3910
+ if (!fileRel && (hits.length > 1 || declCount > 1)) {
3911
+ notes.push('other matches exist — pass path= (and line= to disambiguate) to scope');
3912
+ } else if (fileRel && declCount > 1) {
3913
+ notes.push(
3914
+ `${declCount} declarations of '${cleanSymbol}' in this file — reading the first; pass line= to pick another`,
3915
+ );
3916
+ }
3917
+
3918
+ return {
3919
+ rel: primary.rel,
3920
+ startLine,
3921
+ endLine,
3922
+ offset: startLine - 1,
3923
+ limit: endLine - startLine + 1,
3924
+ approximate,
3925
+ note: notes.length ? notes.join('; ') : undefined,
3926
+ };
3927
+ }
3928
+
3929
+ // MCP clients sometimes inject empty-string defaults for optional schema
3930
+ // fields (e.g. `file: ""`). That empty path round-trips through
3931
+ // normalizeInputPath as a literal string, populating `rel` and tripping
3932
+ // the "file not found in graph" early-return in callers/references modes
3933
+ // even when the caller intended bare-symbol search. Strip empty/null
3934
+ // optional path-like fields before dispatch.
3935
+ function _stripEmptyArgs(args) {
3936
+ const a = { ...(args || {}) };
3937
+ for (const k of ['file', 'language']) {
3938
+ if (a[k] === '' || a[k] === null) delete a[k];
3939
+ }
3940
+ return a;
3941
+ }
3942
+
3943
+ // P1: project-root sentinels. A directory containing any of these (or with one
3944
+ // at an ancestor) is treated as a real project we may index. Used to (a) re-root
3945
+ // a file that sits outside cwd to its own project, and (b) refuse to index an
3946
+ // arbitrary non-project tree (home dir, multi-repo container, plugin cache) on
3947
+ // an implicit cwd.
3948
+ const _PROJECT_ROOT_SENTINELS = ['package.json', '.git', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'setup.py', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'build.sbt', 'Package.swift'];
3949
+
3950
+ // P1: resolve a file to its nearest project root (sentinel ancestor).
3951
+ // Returns null when no root found; caller throws rather than falling back silently.
3952
+ function _resolveFileProjectRoot(file) {
3953
+ if (!file) return null;
3954
+ const abs = pathResolve(file);
3955
+ let dir = dirname(abs);
3956
+ while (dir && dir !== dirname(dir)) {
3957
+ if (_PROJECT_ROOT_SENTINELS.some((s) => existsSync(join(dir, s)))) return dir;
3958
+ dir = dirname(dir);
3959
+ }
3960
+ return null;
3961
+ }
3962
+
3963
+ // P1: nearest project root for a DIRECTORY (the dir itself or any ancestor).
3964
+ // Returns null when the dir sits in no project — the signal to refuse an
3965
+ // unscoped, implicit-cwd index of an arbitrary tree.
3966
+ function _findDirProjectRoot(dir) {
3967
+ if (!dir) return null;
3968
+ let d = pathResolve(dir);
3969
+ while (d && d !== dirname(d)) {
3970
+ if (_PROJECT_ROOT_SENTINELS.some((s) => existsSync(join(d, s)))) return d;
3971
+ d = dirname(d);
3972
+ }
3973
+ return null;
3974
+ }
3975
+
3976
+ // #4: when an UNSCOPED refs/callers query comes back empty, the symbol is absent
3977
+ // from the graph entirely — often because cwd points at the wrong tree. Append
3978
+ // the graph root + indexed-file count so the caller can tell "genuinely no
3979
+ // callers" from "wrong cwd". A file/language-scoped empty result is a real
3980
+ // scoped answer and is left untouched (caller passes narrowedByCaller).
3981
+ function _augmentNoHitDiagnostic(result, emptyToken, graph, cwd, symbol) {
3982
+ if (typeof result !== 'string' || result.trim() !== emptyToken) return result;
3983
+ const n = graph?.nodes?.size || 0;
3984
+ const trunc = graph?.truncated ? `, graph truncated at ${CODE_GRAPH_MAX_FILES} files` : '';
3985
+ // Distinguish "defined but no edge" from "not indexed at all". An empty
3986
+ // callers/references/callees result for a symbol that HAS a declaration in
3987
+ // this graph means it is genuinely unreferenced here — NOT missing. Telling
3988
+ // the caller it is "not present / likely outside cwd" sends them on a
3989
+ // needless re-scope/grep hunt.
3990
+ let declHit = null;
3991
+ try { declHit = (_sortSymbolHits(_findSymbolHits(graph, symbol, {})) || [])[0] || null; } catch {}
3992
+ if (declHit) {
3993
+ return `${emptyToken}\n# '${symbol}' IS defined (${_formatSymbolHitLocation(declHit)}) but is genuinely unreferenced in this graph — present, not missing. No re-scope / grep needed.`;
3994
+ }
3995
+ return `${emptyToken}\n# '${symbol}' not present in graph rooted at ${cwd} (${n} files indexed${trunc}). `
3996
+ + `If it should exist, the target is likely outside this cwd — pass an explicit 'cwd' (repo root) or 'file' anchor, or run 'cwd set <repo>'.`;
3997
+ }
3998
+
3999
+ export async function executeCodeGraphTool(name, args, cwd, signal = null, options = {}) {
4000
+ if (!cwd) throw new Error('find_symbol/code_graph requires cwd — caller did not provide a working directory');
4001
+ const fileArg = (args && typeof args.file === 'string' && args.file.trim()) ? args.file.trim() : '';
4002
+ const baseCwd = (args && typeof args.cwd === 'string' && args.cwd.trim()) ? args.cwd.trim() : cwd;
4003
+ let effectiveCwd = baseCwd;
4004
+ if (fileArg) {
4005
+ const abs = isAbsolute(fileArg) ? pathResolve(fileArg) : pathResolve(baseCwd, fileArg);
4006
+ const rel = pathRelative(pathResolve(baseCwd), abs);
4007
+ const insideCwd = rel && !rel.startsWith('..') && !isAbsolute(rel);
4008
+ if (!insideCwd) {
4009
+ // P1: file outside cwd — require explicit cwd arg or detectable project root; throw otherwise.
4010
+ const hasExplicitCwd = args && typeof args.cwd === 'string' && args.cwd.trim();
4011
+ if (!hasExplicitCwd) {
4012
+ const fileRoot = _resolveFileProjectRoot(abs);
4013
+ if (!fileRoot) {
4014
+ throw new Error(`find_symbol: file '${fileArg}' is outside cwd '${baseCwd}' and has no detectable project root (no package.json/.git ancestor). Provide an explicit cwd.`);
4015
+ }
4016
+ effectiveCwd = fileRoot;
4017
+ }
4018
+ }
4019
+ }
4020
+ // P1 (fail-loud root): an UNSCOPED query (no file anchor) on an IMPLICIT cwd
4021
+ // must sit inside a real project. Otherwise we would index whatever giant tree
4022
+ // the session cwd points at (home dir, a multi-repo container, a plugin cache)
4023
+ // — burning the worker-build budget and then silently answering refs/callers
4024
+ // from the wrong graph. An explicit `cwd` arg is trusted (the caller opted in,
4025
+ // e.g. a large monorepo). Refuse loudly otherwise.
4026
+ if (!fileArg && !(args && typeof args.cwd === 'string' && args.cwd.trim())) {
4027
+ const projectRoot = _findDirProjectRoot(effectiveCwd);
4028
+ if (!projectRoot) {
4029
+ throw new Error(
4030
+ `${name}: cwd '${effectiveCwd}' is not inside a project (no `
4031
+ + `${_PROJECT_ROOT_SENTINELS.join('/')} at it or any ancestor). Refusing to `
4032
+ + `index an arbitrary tree. Run 'cwd set <repo>', or pass an explicit `
4033
+ + `'cwd' (repo root) or a 'file' anchor.`);
4034
+ }
4035
+ // ② Re-root an implicit SUBDIR cwd up to its project root so an unscoped
4036
+ // query covers the whole repo (e.g. callers in sibling dirs like scripts/,
4037
+ // not just the subtree under cwd). effectiveCwd flows consistently into the
4038
+ // build root, rel-keys, output scope, and cache key downstream — the same
4039
+ // dispatch-boundary re-root the file-anchor branch performs above. A cwd
4040
+ // that is already its own project root re-roots to itself (no-op).
4041
+ effectiveCwd = projectRoot;
4042
+ }
4043
+ if (signal?.aborted) throw new Error('aborted');
4044
+ const _work = (() => {
4045
+ switch (name) {
4046
+ case 'code_graph': {
4047
+ // `find_symbol` mode keeps the legacy plain-declaration lookup that the
4048
+ // standalone find_symbol tool used to provide (prewarm + file-overview
4049
+ // without a symbol). All other modes flow through codeGraph().
4050
+ const rawMode = String(args?.mode || '').trim();
4051
+ const declModes = new Set(['symbol', 'find_symbol']);
4052
+ const dispatchOne = (a) => (declModes.has(rawMode)
4053
+ ? findSymbolTool(_stripEmptyArgs(a), effectiveCwd, signal, options)
4054
+ : codeGraph(a, effectiveCwd, signal, options));
4055
+ // Multi-symbol batch: run a batchable mode once per requested name and
4056
+ // concatenate, so N lookups cost ONE call. A single name falls through
4057
+ // unchanged (no header) — existing single calls are byte-identical.
4058
+ if (CODE_GRAPH_BATCHABLE_MODES.has(rawMode)) {
4059
+ const symbolList = _collectGraphSymbolList(args);
4060
+ if (symbolList.length > 1) {
4061
+ return (async () => {
4062
+ const sections = [];
4063
+ for (const sym of symbolList) {
4064
+ let body;
4065
+ try { body = await dispatchOne({ ...args, symbol: sym, symbols: undefined }); }
4066
+ catch (e) { body = `Error: ${e?.message || String(e)}`; }
4067
+ sections.push(`# ${rawMode} ${sym}\n${body}`);
4068
+ }
4069
+ return sections.join('\n\n');
4070
+ })();
4071
+ }
4072
+ if (symbolList.length === 1 && args?.symbol !== symbolList[0]) {
4073
+ return dispatchOne({ ...args, symbol: symbolList[0], symbols: undefined });
4074
+ }
4075
+ }
4076
+ return dispatchOne(args);
4077
+ }
4078
+ default: throw new Error(`Unknown code-graph tool: ${name}`);
4079
+ }
4080
+ })();
4081
+ if (!signal) return _work;
4082
+ let onAbort = null;
4083
+ const abortP = new Promise((_, reject) => {
4084
+ if (signal.aborted) { reject(new Error('aborted')); return; }
4085
+ onAbort = () => reject(new Error('aborted'));
4086
+ signal.addEventListener('abort', onAbort, { once: true });
4087
+ });
4088
+ const cleanup = () => {
4089
+ if (onAbort) {
4090
+ try { signal.removeEventListener('abort', onAbort); } catch {}
4091
+ onAbort = null;
4092
+ }
4093
+ };
4094
+ return Promise.race([_work, abortP]).then(
4095
+ (v) => { cleanup(); return v; },
4096
+ (e) => { cleanup(); throw e; },
4097
+ );
4098
+ }
4099
+
4100
+ export function isCodeGraphTool(name) {
4101
+ return CODE_GRAPH_TOOL_DEFS.some((t) => t.name === name);
4102
+ }