mixdog 0.9.2 → 0.9.4

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