mixdog 0.9.3 → 0.9.5

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 (382) hide show
  1. package/README.md +112 -38
  2. package/package.json +10 -3
  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/r5-orchestrated-task.json +7 -0
  7. package/scripts/bench/review-tasks.json +20 -0
  8. package/scripts/bench/round-codex.json +114 -0
  9. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  10. package/scripts/bench/round-mixdog-lead.json +269 -0
  11. package/scripts/bench/round-mixdog.json +126 -0
  12. package/scripts/bench/round-r10-bigsample.json +679 -0
  13. package/scripts/bench/round-r11-codexalign.json +257 -0
  14. package/scripts/bench/round-r13-clientmeta.json +464 -0
  15. package/scripts/bench/round-r14-betafeatures.json +466 -0
  16. package/scripts/bench/round-r15-fulldefault.json +462 -0
  17. package/scripts/bench/round-r16-sessionid.json +466 -0
  18. package/scripts/bench/round-r17-wirebytes.json +456 -0
  19. package/scripts/bench/round-r18-prewarm.json +468 -0
  20. package/scripts/bench/round-r19-clean.json +472 -0
  21. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  22. package/scripts/bench/round-r21-delta-retry.json +473 -0
  23. package/scripts/bench/round-r22-full-probe.json +693 -0
  24. package/scripts/bench/round-r23-itemprobe.json +701 -0
  25. package/scripts/bench/round-r24-shapefix.json +677 -0
  26. package/scripts/bench/round-r25-serial.json +464 -0
  27. package/scripts/bench/round-r26-parallel3.json +671 -0
  28. package/scripts/bench/round-r27-parallel10.json +894 -0
  29. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  30. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  31. package/scripts/bench/round-r30-instid.json +253 -0
  32. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  33. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  34. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  35. package/scripts/bench/round-r34-orchestrated.json +120 -0
  36. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  37. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  38. package/scripts/bench/round-r4-codex.json +114 -0
  39. package/scripts/bench/round-r4-mixed.json +225 -0
  40. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  41. package/scripts/bench/round-r6-codex.json +114 -0
  42. package/scripts/bench/round-r6-solo.json +257 -0
  43. package/scripts/bench/round-r7-full.json +254 -0
  44. package/scripts/bench/round-r8-fulldefault.json +255 -0
  45. package/scripts/bench-run.mjs +251 -32
  46. package/scripts/freevar-smoke.mjs +95 -0
  47. package/scripts/internal-comms-bench.mjs +3 -4
  48. package/scripts/internal-comms-smoke.mjs +10 -9
  49. package/scripts/model-catalog-audit.mjs +209 -0
  50. package/scripts/model-list-sanitize-test.mjs +37 -0
  51. package/scripts/mouse-probe.mjs +45 -0
  52. package/scripts/output-style-bench.mjs +13 -6
  53. package/scripts/output-style-smoke.mjs +4 -4
  54. package/scripts/provider-toolcall-test.mjs +7 -3
  55. package/scripts/recall-bench.mjs +76 -13
  56. package/scripts/recall-quality-cases.json +12 -0
  57. package/scripts/recall-usecase-cases.json +18 -0
  58. package/scripts/session-bench.mjs +152 -6
  59. package/scripts/tool-smoke.mjs +25 -65
  60. package/scripts/tui-render-smoke.mjs +90 -0
  61. package/scripts/webhook-smoke.mjs +208 -0
  62. package/src/agents/debugger/AGENT.md +4 -1
  63. package/src/agents/heavy-worker/AGENT.md +9 -8
  64. package/src/agents/maintainer/AGENT.md +4 -0
  65. package/src/agents/reviewer/AGENT.md +2 -1
  66. package/src/agents/scheduler-task/AGENT.md +2 -3
  67. package/src/agents/webhook-handler/AGENT.md +2 -3
  68. package/src/agents/worker/AGENT.md +10 -7
  69. package/src/app.mjs +12 -1
  70. package/src/headless-role.mjs +7 -1
  71. package/src/lib/rules-builder.cjs +4 -0
  72. package/src/mixdog-session-runtime.mjs +647 -2056
  73. package/src/output-styles/default.md +30 -9
  74. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  75. package/src/output-styles/minimal.md +8 -6
  76. package/src/output-styles/simple.md +21 -7
  77. package/src/rules/agent/00-common.md +6 -3
  78. package/src/rules/agent/30-explorer.md +16 -5
  79. package/src/rules/lead/01-general.md +5 -5
  80. package/src/rules/lead/lead-brief.md +15 -0
  81. package/src/rules/lead/lead-tool.md +6 -15
  82. package/src/rules/shared/01-tool.md +17 -21
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  84. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  85. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  86. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  87. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  88. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  89. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  90. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  91. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  92. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  93. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  95. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  97. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  101. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  103. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +105 -11
  104. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +356 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  108. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  110. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +740 -0
  111. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +349 -2131
  113. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +143 -1002
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +229 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +465 -0
  117. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  119. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  120. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  121. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  122. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  123. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  124. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  125. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  126. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  127. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  128. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  129. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  130. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +275 -0
  132. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  133. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  134. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  135. package/src/runtime/agent/orchestrator/session/loop.mjs +278 -402
  136. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  137. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  138. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  139. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  140. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  143. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  144. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  145. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  146. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  147. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  148. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +35 -44
  149. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  152. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  155. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  157. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  158. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  159. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  161. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  163. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  164. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  165. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  166. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  167. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  168. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  169. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  170. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  171. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  172. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  173. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  174. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  175. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  176. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  177. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  178. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  179. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  180. package/src/runtime/channels/backends/discord.mjs +27 -318
  181. package/src/runtime/channels/backends/telegram.mjs +8 -12
  182. package/src/runtime/channels/index.mjs +247 -701
  183. package/src/runtime/channels/lib/backend-dispatch.mjs +46 -0
  184. package/src/runtime/channels/lib/config.mjs +37 -149
  185. package/src/runtime/channels/lib/event-pipeline.mjs +22 -5
  186. package/src/runtime/channels/lib/event-queue.mjs +78 -13
  187. package/src/runtime/channels/lib/inbound-routing.mjs +74 -0
  188. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  189. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  190. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  191. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  192. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  193. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  194. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  195. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  196. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  197. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  198. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  199. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  200. package/src/runtime/channels/lib/webhook/deliveries.mjs +313 -0
  201. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  202. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  203. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  204. package/src/runtime/channels/lib/webhook.mjs +43 -616
  205. package/src/runtime/channels/tool-defs.mjs +11 -130
  206. package/src/runtime/memory/index.mjs +210 -1948
  207. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  208. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  209. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  210. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  211. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  212. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  213. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  214. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  215. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  216. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  217. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  218. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  219. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  220. package/src/runtime/memory/lib/memory-recall-store.mjs +69 -12
  221. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  222. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  223. package/src/runtime/memory/lib/query-handlers.mjs +802 -0
  224. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  225. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  226. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  227. package/src/runtime/memory/tool-defs.mjs +5 -13
  228. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  229. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  230. package/src/runtime/search/lib/web-tools.mjs +24 -602
  231. package/src/runtime/shared/atomic-file.mjs +26 -1
  232. package/src/runtime/shared/config.mjs +14 -4
  233. package/src/runtime/shared/launcher-control.mjs +2 -2
  234. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  235. package/src/runtime/shared/schedules-store.mjs +13 -3
  236. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  237. package/src/runtime/shared/tool-primitives.mjs +308 -0
  238. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  239. package/src/runtime/shared/tool-surface.mjs +80 -898
  240. package/src/runtime/shared/transcript-writer.mjs +23 -0
  241. package/src/runtime/shared/update-checker.mjs +7 -4
  242. package/src/session-runtime/config-helpers.mjs +119 -2
  243. package/src/session-runtime/config-lifecycle.mjs +232 -0
  244. package/src/session-runtime/cwd-plugins.mjs +226 -0
  245. package/src/session-runtime/mcp-glue.mjs +177 -0
  246. package/src/session-runtime/model-recency.mjs +111 -0
  247. package/src/session-runtime/native-search.mjs +247 -0
  248. package/src/session-runtime/output-styles.mjs +11 -9
  249. package/src/session-runtime/prewarm.mjs +142 -0
  250. package/src/session-runtime/provider-models.mjs +278 -0
  251. package/src/session-runtime/provider-usage.mjs +120 -0
  252. package/src/session-runtime/quick-model-rows.mjs +205 -0
  253. package/src/session-runtime/quick-search-models.mjs +47 -0
  254. package/src/session-runtime/session-hooks.mjs +93 -0
  255. package/src/session-runtime/settings-api.mjs +352 -0
  256. package/src/session-runtime/tool-catalog.mjs +29 -29
  257. package/src/session-runtime/tool-defs.mjs +84 -0
  258. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  259. package/src/session-runtime/workflow.mjs +1 -1
  260. package/src/standalone/agent-tool/helpers.mjs +237 -0
  261. package/src/standalone/agent-tool/notify.mjs +107 -0
  262. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  263. package/src/standalone/agent-tool/render.mjs +152 -0
  264. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  265. package/src/standalone/agent-tool.mjs +138 -669
  266. package/src/standalone/channel-admin.mjs +102 -90
  267. package/src/standalone/channel-worker.mjs +4 -7
  268. package/src/standalone/explore-tool.mjs +64 -14
  269. package/src/standalone/hook-bus/config.mjs +207 -0
  270. package/src/standalone/hook-bus/constants.mjs +90 -0
  271. package/src/standalone/hook-bus/handlers.mjs +481 -0
  272. package/src/standalone/hook-bus/payload.mjs +31 -0
  273. package/src/standalone/hook-bus/rules.mjs +77 -0
  274. package/src/standalone/hook-bus.mjs +77 -870
  275. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  276. package/src/standalone/opencode-go-login.mjs +5 -1
  277. package/src/standalone/provider-admin.mjs +1 -16
  278. package/src/standalone/usage-dashboard.mjs +3 -1
  279. package/src/tui/App.jsx +1059 -8110
  280. package/src/tui/app/app-format.mjs +213 -0
  281. package/src/tui/app/channel-pickers.mjs +508 -0
  282. package/src/tui/app/clipboard.mjs +67 -0
  283. package/src/tui/app/core-memory-picker.mjs +210 -0
  284. package/src/tui/app/extension-pickers.mjs +506 -0
  285. package/src/tui/app/input-parsers.mjs +193 -0
  286. package/src/tui/app/maintenance-pickers.mjs +356 -0
  287. package/src/tui/app/model-options.mjs +334 -0
  288. package/src/tui/app/model-picker.mjs +365 -0
  289. package/src/tui/app/onboarding-steps.mjs +400 -0
  290. package/src/tui/app/project-picker.mjs +247 -0
  291. package/src/tui/app/provider-setup-picker.mjs +580 -0
  292. package/src/tui/app/resume-picker.mjs +55 -0
  293. package/src/tui/app/route-pickers.mjs +419 -0
  294. package/src/tui/app/settings-picker.mjs +489 -0
  295. package/src/tui/app/slash-commands.mjs +101 -0
  296. package/src/tui/app/slash-dispatch.mjs +427 -0
  297. package/src/tui/app/text-layout.mjs +46 -0
  298. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  299. package/src/tui/app/transcript-window.mjs +677 -0
  300. package/src/tui/app/use-mouse-input.mjs +460 -0
  301. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  302. package/src/tui/app/use-transcript-scroll.mjs +512 -0
  303. package/src/tui/app/use-transcript-window.mjs +607 -0
  304. package/src/tui/components/ConfirmBar.jsx +10 -7
  305. package/src/tui/components/Picker.jsx +64 -15
  306. package/src/tui/components/PromptInput.jsx +33 -102
  307. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  308. package/src/tui/components/StatusLine.jsx +69 -15
  309. package/src/tui/components/TextEntryPanel.jsx +11 -0
  310. package/src/tui/components/ToolExecution.jsx +52 -594
  311. package/src/tui/components/TranscriptItem.jsx +105 -0
  312. package/src/tui/components/UsagePanel.jsx +18 -4
  313. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  314. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  315. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  316. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  317. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  318. package/src/tui/display-width.mjs +20 -3
  319. package/src/tui/dist/index.mjs +13553 -12384
  320. package/src/tui/engine/agent-job-feed.mjs +133 -0
  321. package/src/tui/engine/notification-plan.mjs +76 -0
  322. package/src/tui/engine/render-timing.mjs +17 -0
  323. package/src/tui/engine/tool-approval.mjs +94 -0
  324. package/src/tui/engine/tool-card-results.mjs +234 -0
  325. package/src/tui/engine/tool-result-status.mjs +135 -0
  326. package/src/tui/engine.mjs +170 -574
  327. package/src/tui/figures.mjs +5 -0
  328. package/src/tui/index.jsx +65 -1
  329. package/src/tui/input-editing.mjs +2 -2
  330. package/src/tui/markdown/format-token.mjs +4 -1
  331. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  332. package/src/tui/theme.mjs +6 -0
  333. package/src/ui/statusline-agents.mjs +213 -0
  334. package/src/ui/statusline-format.mjs +146 -0
  335. package/src/ui/statusline-segments.mjs +148 -0
  336. package/src/ui/statusline.mjs +77 -501
  337. package/src/ui/tool-card.mjs +0 -1
  338. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  339. package/src/workflows/default/WORKFLOW.md +16 -18
  340. package/src/workflows/sequential/WORKFLOW.md +16 -18
  341. package/vendor/ink/build/display-width.js +19 -3
  342. package/vendor/ink/build/ink.js +112 -7
  343. package/vendor/ink/build/log-update.js +17 -3
  344. package/vendor/ink/build/wrap-text.js +125 -0
  345. package/scripts/_test-folder-dialog.mjs +0 -30
  346. package/scripts/fix-brief-fn.mjs +0 -35
  347. package/scripts/fix-format-tool-surface.mjs +0 -24
  348. package/scripts/fix-tool-exec-visible.mjs +0 -42
  349. package/scripts/patch-agent-brief.mjs +0 -48
  350. package/scripts/patch-app.mjs +0 -21
  351. package/scripts/patch-app2.mjs +0 -18
  352. package/scripts/patch-dist-brief.mjs +0 -96
  353. package/scripts/patch-tool-exec.mjs +0 -70
  354. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  355. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  356. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  357. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  358. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  359. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  360. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  361. package/src/runtime/channels/lib/holidays.mjs +0 -138
  362. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  363. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  364. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  365. package/src/standalone/agent-task-status.test.mjs +0 -76
  366. package/src/tui/components/tool-output-format.test.mjs +0 -399
  367. package/src/tui/display-width.test.mjs +0 -35
  368. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  369. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  370. package/src/tui/input-editing.selection.test.mjs +0 -75
  371. package/src/tui/markdown/format-token.test.mjs +0 -354
  372. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  373. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  374. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  375. package/src/tui/paste-fix.test.mjs +0 -119
  376. package/src/tui/prompt-history-store.test.mjs +0 -52
  377. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  378. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  379. package/src/ui/markdown.test.mjs +0 -70
  380. package/src/ui/statusline-context-label.test.mjs +0 -15
  381. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  382. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -1,2970 +1,47 @@
1
1
  // apply_patch — one-turn multi-file edits from a unified diff.
2
2
  //
3
- // Typical Lead workflow without this tool is `read` `edit` per file, which
4
- // costs N+1 turns for an N-file refactor. A unified diff already encodes
5
- // every hunk's surrounding context, so we can apply the whole patch
6
- // server-side and skip the read round-trips entirely.
3
+ // This file is now a FACADE. The implementation was split into cohesive
4
+ // modules under ./patch/ during a behavior-preserving refactor; this module
5
+ // re-exports the identical public surface so existing importers
6
+ // (session/loop.mjs, scripts/*, code-graph, memory, etc.) are unaffected:
7
7
  //
8
- // Backend: NATIVE-ONLY. Every supported case is dispatched to the
9
- // mixdog-patch Rust engine via the persistent stdio server. There is NO
10
- // JS apply fallback: unsupported / unsafe input returns a clean Error
11
- // string ("Error: …"), never silently degrades to a different engine.
12
- // `parsePatch(str)` splits a multi-file diff into one object per file
13
- // with `{oldFileName, newFileName, hunks}`; the parsed entries are
14
- // consulted only to derive display path/lines stats and to pre-validate
15
- // path-escape safety before dispatch.
8
+ // ./patch/native-server.mjs NativePatchServer transport, env gating,
9
+ // binary resolution, prewarm/idle lifecycle, runServerEdit,
10
+ // closeNativePatchServerForTests, globalThis.__mixdogCloseNativePatchServers.
11
+ // ./patch/constants.mjs — shared literals / hunk-header regexes.
12
+ // ./patch/paths.mjs — path resolution, entry classification,
13
+ // preValidateNativeBatch, header rewrite.
14
+ // ./patch/matcher.mjs — byte-parity diagnostic matcher, line splitters,
15
+ // typographic normalization, nearest-line hints, V4A line-sequence search.
16
+ // ./patch/parsing.mjs — V4A + unified-as-V4A parsers, format detection.
17
+ // ./patch/v4a-convert.mjs — V4A hunk apply, rename sections, V4A→unified
18
+ // conversion.
19
+ // ./patch/dispatch.mjs — native dispatch + failure-context formatting.
20
+ // ./patch/orchestrator.mjs — apply_patch orchestration + executePatchTool
21
+ // + replay capture + UI-diff side-channel.
16
22
  //
17
- // Safety model:
18
- // - No separate read gate. Hunk context lines are themselves the
19
- // match proof — if they don't match, the native engine
20
- // rejects the hunk and nothing is written.
21
- // - Path-escape pre-validator throws on out-of-base `..` segments and
22
- // symlink/junction escapes (realpath verifies the target stays inside
23
- // the basePath even when an intermediate symlink points outside).
24
- // - `reject_partial:true` (default) — file-batch atomic. Native engine
25
- // errors out before touching disk.
26
- // - `reject_partial:false` — file-level isolation. Native engine
27
- // responds with OK_PARTIAL plus a hex-encoded failures payload that
28
- // the JS side surfaces per-entry.
23
+ // Backend is unchanged: NATIVE-ONLY dispatch to the mixdog-patch Rust engine,
24
+ // no JS apply fallback. Public exports below match the pre-split surface.
29
25
 
30
- import { existsSync, readFileSync, realpathSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
31
- import { unlink } from 'node:fs/promises';
32
- import { spawn } from 'node:child_process';
33
- import { createInterface } from 'node:readline';
34
- import { fileURLToPath } from 'node:url';
35
- import { resolve as pathResolve, relative as pathRelative, isAbsolute, dirname as pathDirname, join as pathJoin } from 'node:path';
36
- import { performance } from 'node:perf_hooks';
37
- import { parsePatch } from 'diff';
38
- import { getAbortSignalForSession } from '../session/abort-lookup.mjs';
39
- import { startChildGuardian } from '../../../shared/child-guardian.mjs';
40
- import {
41
- normalizeInputPath,
42
- normalizeOutputPath,
43
- resolveAgainstCwd,
44
- invalidateBuiltinResultCache,
45
- recordReadSnapshotForPath,
46
- clearReadSnapshotForPath,
47
- withBuiltinPathLocks,
48
- } from './builtin.mjs';
49
- import { withAdvisoryLocks } from './builtin/advisory-lock.mjs';
50
- import { markCodeGraphDirtyPaths } from './code-graph-state.mjs';
51
- import { wrapMutationRouteOutput } from './mutation-planner.mjs';
52
- import { getPluginData } from '../config.mjs';
53
- import { ensurePatchBinary, findCachedPatchBinary } from './patch-binary-fetcher.mjs';
54
- import {
55
- rawContentCacheGet,
56
- rawContentCacheSet,
57
- } from './builtin/cache-layers.mjs';
58
- import { atomicWrite } from './builtin/atomic-write.mjs';
59
- import { assertPathReachable, assertPathsReachable } from './builtin/fs-reachability.mjs';
60
- export { PATCH_TOOL_DEFS } from './patch-tool-defs.mjs';
61
-
62
- const DEV_NULL = /^\/dev\/null$/;
63
- const V4A_EOF_MARKER = '*** End of File';
64
- const V4A_MOVE_TO_PREFIX = '*** Move to:';
65
- const PLUGIN_ROOT = process.env.MIXDOG_ROOT
66
- || pathResolve(pathDirname(fileURLToPath(import.meta.url)), '../../../..');
67
- const NATIVE_PATCH_DEFAULT_BIN = pathJoin(
68
- PLUGIN_ROOT,
69
- 'native/mixdog-patch/target/release',
70
- process.platform === 'win32' ? 'mixdog-patch.exe' : 'mixdog-patch',
71
- );
72
- let _nativePatchServer = null;
73
- let _nativePatchPrewarmTimer = null;
74
- let _nativeEditServer = null;
75
-
76
- function markNativePatchRuntimeTouched() {
77
- try { globalThis.__mixdogNativePatchRuntimeTouched = true; } catch {}
78
- }
79
-
80
- function nativePatchMode() {
81
- return String(process.env.MIXDOG_PATCH_NATIVE || 'auto').toLowerCase();
82
- }
83
-
84
- function nativePatchEnabled() {
85
- return !/^(0|false|no|off|js|legacy)$/i.test(nativePatchMode());
86
- }
87
-
88
- function nativePatchTraceEnabled() {
89
- return /^(1|true|yes)$/i.test(process.env.MIXDOG_PATCH_NATIVE_TRACE || '');
90
- }
91
-
92
- function ioTraceEnabled() {
93
- return /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_IO_TRACE || ''));
94
- }
95
-
96
- function ioTrace(event, fields = {}) {
97
- if (!ioTraceEnabled()) return;
98
- try {
99
- process.stderr.write(`[io-trace] ${JSON.stringify({ event, ts: Date.now(), ...fields })}\n`);
100
- } catch {}
101
- }
102
-
103
- function patchTraceEnabled() {
104
- return ioTraceEnabled()
105
- || nativePatchTraceEnabled()
106
- || /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_PATCH_TRACE || ''));
107
- }
108
-
109
- function nativePatchPrewarmEnabled() {
110
- if (!nativePatchEnabled()) return false;
111
- if (process.env.MIXDOG_PATCH_NATIVE_BIN && !existsSync(nativePatchBinPath())) return false;
112
- return !/^(0|false|no)$/i.test(process.env.MIXDOG_PATCH_NATIVE_PREWARM || '');
113
- }
114
-
115
- function nativePatchPersistent() {
116
- return /^(1|true|yes|server|persistent)$/i.test(nativePatchMode());
117
- }
118
-
119
- function nativePatchBinPath() {
120
- if (process.env.MIXDOG_PATCH_NATIVE_BIN) return process.env.MIXDOG_PATCH_NATIVE_BIN;
121
- // Local cargo build first, then a fetched/cached prebuilt; absence is
122
- // a hard error at dispatch (no JS fallback in native-only mode).
123
- if (existsSync(NATIVE_PATCH_DEFAULT_BIN)) return NATIVE_PATCH_DEFAULT_BIN;
124
- return findCachedPatchBinary(getPluginData()) || NATIVE_PATCH_DEFAULT_BIN;
125
- }
126
-
127
- async function ensureNativePatchBinaryAvailable() {
128
- if (!nativePatchEnabled()) {
129
- throw new Error('apply_patch: native engine disabled via MIXDOG_PATCH_NATIVE; set it to "auto" or "1" to apply patches.');
130
- }
131
- const current = nativePatchBinPath();
132
- if (existsSync(current)) return current;
133
- if (process.env.MIXDOG_PATCH_NATIVE_BIN) {
134
- throw new Error(`apply_patch: native patch binary not found at MIXDOG_PATCH_NATIVE_BIN=${current}.`);
135
- }
136
- try {
137
- const fetched = await ensurePatchBinary(getPluginData());
138
- if (fetched && existsSync(fetched)) return fetched;
139
- } catch (err) {
140
- throw new Error(`apply_patch: native patch binary unavailable — ${err?.message || String(err)}`);
141
- }
142
- const resolved = nativePatchBinPath();
143
- if (existsSync(resolved)) return resolved;
144
- throw new Error(`apply_patch: native patch binary not found at ${resolved}.`);
145
- }
146
-
147
- // Decode the hex-encoded failures payload that accompanies OK_PARTIAL:
148
- // the Rust side emits utf-8 bytes (`<path>\t<reason>` records joined by
149
- // `\n`) hex-encoded so they can ride the tab-separated response line
150
- // without escaping. An empty / unparseable payload becomes an empty list
151
- // so a missing field never crashes the caller.
152
- function decodeNativeFailures(hexPayload) {
153
- if (typeof hexPayload !== 'string' || hexPayload.length === 0) return [];
154
- if (!/^[0-9a-fA-F]+$/.test(hexPayload) || hexPayload.length % 2 !== 0) return [];
155
- let text = '';
156
- try { text = Buffer.from(hexPayload, 'hex').toString('utf-8'); }
157
- catch { return []; }
158
- const out = [];
159
- for (const raw of text.split('\n')) {
160
- if (!raw) continue;
161
- const tab = raw.indexOf('\t');
162
- if (tab === -1) out.push({ path: '', reason: raw });
163
- else out.push({ path: raw.slice(0, tab), reason: raw.slice(tab + 1) });
164
- }
165
- return out;
166
- }
167
-
168
- class NativePatchServer {
169
- constructor(binPath) {
170
- this.binPath = binPath;
171
- // windowsHide: mixdog-patch.exe is a console binary; without this each spawn
172
- // flashes an empty console window on Windows. Especially visible now that the
173
- // idle watchdog exits the server and it respawns on the next request.
174
- this.child = spawn(binPath, ['--server'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
175
- startChildGuardian({ childPid: this.child.pid, label: 'native-patch-server', orphanGraceMs: 5000, forceGraceMs: 2000 });
176
- this.stderr = '';
177
- this.lines = [];
178
- this.waiters = [];
179
- this.exited = false;
180
- this.child.stderr.setEncoding('utf8');
181
- this.child.stderr.on('data', (chunk) => { this.stderr += chunk; });
182
- this.rl = createInterface({ input: this.child.stdout });
183
- this.rl.on('line', (line) => {
184
- const waiter = this.waiters.shift();
185
- if (waiter) waiter.resolve(line);
186
- else this.lines.push(line);
187
- });
188
- this.child.on('exit', (code, signal) => {
189
- this.exited = true;
190
- const err = new Error(`native patch server exited code=${code} signal=${signal} stderr=${this.stderr}`);
191
- for (const waiter of this.waiters.splice(0)) waiter.reject(err);
192
- try { this.rl.close(); } catch {}
193
- });
194
- }
195
-
196
- abort(signal) {
197
- const err = new Error(signal?.reason?.message || signal?.reason || 'native patch aborted');
198
- err.name = 'AbortError';
199
- if (_nativePatchServer === this) _nativePatchServer = null;
200
- for (const waiter of this.waiters.splice(0)) waiter.reject(err);
201
- try { this.child.kill('SIGTERM'); } catch {}
202
- return err;
203
- }
204
-
205
- nextLine() {
206
- if (this.lines.length > 0) return Promise.resolve(this.lines.shift());
207
- if (this.exited) return Promise.reject(new Error(`native patch server already exited: ${this.stderr}`));
208
- return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
209
- }
210
-
211
- ref() {
212
- try { this.child.ref(); } catch {}
213
- try { this.child.stdin.ref?.(); } catch {}
214
- try { this.child.stdout.ref?.(); } catch {}
215
- try { this.child.stderr.ref?.(); } catch {}
216
- }
217
-
218
- unref() {
219
- try { this.child.unref(); } catch {}
220
- try { this.child.stdin.unref?.(); } catch {}
221
- try { this.child.stdout.unref?.(); } catch {}
222
- try { this.child.stderr.unref?.(); } catch {}
223
- }
224
-
225
- async ping() {
226
- this.ref();
227
- const linePromise = this.nextLine();
228
- this.child.stdin.write('PING\n');
229
- const line = await linePromise;
230
- if (line !== 'OK\tPONG') {
231
- throw new Error(`native patch server ping failed: ${line || 'no native response'}`);
232
- }
233
- }
234
-
235
- async apply(basePath, patchText, { fuzz = 2, rejectPartial = true, dryRun = false, signal = null } = {}) {
236
- this.ref();
237
- if (signal?.aborted) {
238
- const err = new Error(signal.reason?.message || signal.reason || 'native patch aborted');
239
- err.name = 'AbortError';
240
- throw err;
241
- }
242
- const started = performance.now();
243
- const baseBuf = Buffer.from(basePath, 'utf8');
244
- const patchBuf = Buffer.from(patchText, 'utf8');
245
- const linePromise = this.nextLine();
246
- if (signal) linePromise.catch(() => {});
247
- let abortListener = null;
248
- const abortPromise = signal ? new Promise((_, reject) => {
249
- abortListener = () => {
250
- reject(this.abort(signal));
251
- };
252
- signal.addEventListener('abort', abortListener, { once: true });
253
- }) : null;
254
- // 7-token APPLY protocol: APPLY <base_len> <patch_len> <timing> <dry_run> <fuzz> <reject_partial>
255
- // - timing=1 keeps the server emitting per-phase ms fields
256
- // - dry_run=1 validates without writing; useful for tests and explicit callers
257
- // - fuzz=0 means strict context match; fuzz=2 absorbs minor outer-context drift and context trailing spaces/tabs
258
- // - reject_partial=0 unlocks file-level isolation (OK_PARTIAL response)
259
- const fuzzTok = Number.isFinite(fuzz) && fuzz >= 0 ? Math.floor(fuzz) : 2;
260
- const rpTok = rejectPartial ? 1 : 0;
261
- const dryTok = dryRun ? 1 : 0;
262
- this.child.stdin.write(`APPLY ${baseBuf.length} ${patchBuf.length} 1 ${dryTok} ${fuzzTok} ${rpTok}\n`);
263
- this.child.stdin.write(baseBuf);
264
- this.child.stdin.write(patchBuf);
265
- let line;
266
- try {
267
- line = abortPromise ? await Promise.race([linePromise, abortPromise]) : await linePromise;
268
- } finally {
269
- if (abortListener) {
270
- try { signal.removeEventListener('abort', abortListener); } catch {}
271
- }
272
- }
273
- if (!line) throw new Error('no native response');
274
- if (line.startsWith('ERR\t')) throw new Error(line.slice(4));
275
- const okFull = line.startsWith('OK\t');
276
- const okPartial = line.startsWith('OK_PARTIAL\t');
277
- if (!okFull && !okPartial) throw new Error(line);
278
- const fields = line.split('\t');
279
- // fields[0] = "OK" | "OK_PARTIAL".
280
- // OK layout: <files> <readMs> <applyMs> <writeMs> <totalMs> <hashMs> <contentHashes>
281
- // OK_PARTIAL layout: <files> <failed> <readMs> <applyMs> <writeMs> <totalMs> <hashMs> <contentHashes> <hexFailures>
282
- // The OK_PARTIAL line carries an extra <failed> count between <files>
283
- // and the timing block, plus a trailing <hexFailures> column — keep
284
- // the two decodes separate so SKIP failure counts stay accurate.
285
- let files; let readMs; let applyMs; let writeMs; let totalMs; let hashMs;
286
- let contentHashesRaw; let hexFailures;
287
- if (okPartial) {
288
- files = fields[1];
289
- // fields[2] = <failed> count; the JS layer already derives a failure
290
- // count from decodeNativeFailures(hexFailures), so skip the raw cell.
291
- readMs = fields[3];
292
- applyMs = fields[4];
293
- writeMs = fields[5];
294
- totalMs = fields[6];
295
- hashMs = fields[7];
296
- contentHashesRaw = fields[8];
297
- hexFailures = fields[9];
298
- } else {
299
- files = fields[1];
300
- readMs = fields[2];
301
- applyMs = fields[3];
302
- writeMs = fields[4];
303
- totalMs = fields[5];
304
- hashMs = fields[6];
305
- contentHashesRaw = fields[7];
306
- }
307
- const contentHashes = String(contentHashesRaw || '')
308
- .split(',')
309
- .filter((value) => value.length > 0)
310
- .map((value) => (/^[a-f0-9]{64}$/i.test(value) ? value.toLowerCase() : null));
311
- const failures = okPartial ? decodeNativeFailures(hexFailures) : [];
312
- return {
313
- partial: okPartial,
314
- files: Number(files) || 0,
315
- readMs: Number(readMs) || 0,
316
- applyMs: Number(applyMs) || 0,
317
- writeMs: Number(writeMs) || 0,
318
- hashMs: Number(hashMs) || 0,
319
- totalMs: Number(totalMs) || 0,
320
- contentHashes,
321
- contentHash: contentHashes.length === 1 ? contentHashes[0] : null,
322
- failures,
323
- roundtripMs: performance.now() - started,
324
- };
325
- }
326
-
327
- // EDIT protocol client: invariant-safe char-indexed edit. Mirrors apply()'s
328
- // abort/await-line handling. EDIT <path_len> <old_len> <new_len> <replace_all>
329
- // <dry_run> then path+old+new bytes; response is the 8-field OK line with the
330
- // matched tier.
331
- async edit(fullPath, oldBuf, newBuf, { replaceAll = false, dryRun = false, signal = null } = {}) {
332
- this.ref();
333
- if (signal?.aborted) {
334
- const err = new Error(signal.reason?.message || signal.reason || 'native edit aborted');
335
- err.name = 'AbortError';
336
- throw err;
337
- }
338
- const started = performance.now();
339
- const pathBuf = Buffer.from(fullPath, 'utf8');
340
- const linePromise = this.nextLine();
341
- if (signal) linePromise.catch(() => {});
342
- let abortListener = null;
343
- const abortPromise = signal ? new Promise((_, reject) => {
344
- abortListener = () => { reject(this.abort(signal)); };
345
- signal.addEventListener('abort', abortListener, { once: true });
346
- }) : null;
347
- this.child.stdin.write(
348
- `EDIT ${pathBuf.length} ${oldBuf.length} ${newBuf.length} ${replaceAll ? 1 : 0} ${dryRun ? 1 : 0}\n`,
349
- );
350
- this.child.stdin.write(pathBuf);
351
- this.child.stdin.write(oldBuf);
352
- this.child.stdin.write(newBuf);
353
- let line;
354
- try {
355
- line = abortPromise ? await Promise.race([linePromise, abortPromise]) : await linePromise;
356
- } finally {
357
- if (abortListener) {
358
- try { signal.removeEventListener('abort', abortListener); } catch {}
359
- }
360
- }
361
- if (!line) throw new Error('no native response');
362
- if (line.startsWith('ERR\t')) throw new Error(line.slice(4));
363
- if (!line.startsWith('OK\t')) throw new Error(line);
364
- const f = line.split('\t');
365
- // OK \t replacements \t readMs \t applyMs \t writeMs \t totalMs \t tier \t hash
366
- return {
367
- replacements: Number(f[1]) || 0,
368
- readMs: Number(f[2]) || 0,
369
- applyMs: Number(f[3]) || 0,
370
- writeMs: Number(f[4]) || 0,
371
- totalMs: Number(f[5]) || 0,
372
- tier: f[6] || 'exact',
373
- contentHash: /^[a-f0-9]{64}$/i.test(f[7] || '') ? f[7].toLowerCase() : null,
374
- roundtripMs: performance.now() - started,
375
- };
376
- }
377
-
378
- async close(options = {}) {
379
- if (this.exited) return;
380
- const waitForExit = options?.waitForExit !== false;
381
- if (!waitForExit) {
382
- try { this.child.stdin.end('QUIT\n'); } catch {}
383
- this.unref();
384
- return;
385
- }
386
- this.ref();
387
- try { this.child.stdin.end('QUIT\n'); } catch {}
388
- await new Promise((resolve) => this.child.once('exit', resolve));
389
- try { this.rl.close(); } catch {}
390
- }
391
- }
392
-
393
- function getNativePatchServer() {
394
- markNativePatchRuntimeTouched();
395
- const binPath = nativePatchBinPath();
396
- if (!existsSync(binPath)) {
397
- throw new Error(`native patch binary not found: ${binPath}`);
398
- }
399
- if (!_nativePatchServer || _nativePatchServer.exited || _nativePatchServer.binPath !== binPath) {
400
- _nativePatchServer = new NativePatchServer(binPath);
401
- }
402
- return _nativePatchServer;
403
- }
404
-
405
- function getNativeEditServer() {
406
- markNativePatchRuntimeTouched();
407
- // Honor MIXDOG_EDIT_NATIVE_BIN (the same override the edit gating checks) so
408
- // the gated binary and the spawned server binary cannot diverge.
409
- const binPath = process.env.MIXDOG_EDIT_NATIVE_BIN || nativePatchBinPath();
410
- if (!existsSync(binPath)) {
411
- throw new Error(`native patch binary not found: ${binPath}`);
412
- }
413
- if (!_nativeEditServer || _nativeEditServer.exited || _nativeEditServer.binPath !== binPath) {
414
- _nativeEditServer = new NativePatchServer(binPath);
415
- }
416
- return _nativeEditServer;
417
- }
418
-
419
- // Invariant-safe char-indexed edit over the persistent server (B2). Shares the
420
- // NativePatchServer transport but runs on a DEDICATED instance so edit and
421
- // patch requests never interleave their stdin framing on one stdout stream.
422
- export async function runServerEdit({ fullPath, oldBuf, newBuf, replaceAll = false, dryRun = false, signal = null }) {
423
- const server = getNativeEditServer();
424
- return server.edit(fullPath, oldBuf, newBuf, { replaceAll, dryRun, signal });
425
- }
426
-
427
- function scheduleNativePatchPrewarm() {
428
- if (!nativePatchPrewarmEnabled() || _nativePatchPrewarmTimer || _nativePatchServer) return;
429
- _nativePatchPrewarmTimer = setImmediate(() => {
430
- void (async () => {
431
- _nativePatchPrewarmTimer = null;
432
- const started = performance.now();
433
- try {
434
- // Ensure the native binary is present (local build or fetched
435
- // prebuilt) before starting the server. Best-effort: failures
436
- // surface as a hard error at dispatch (no JS fallback in the
437
- // native-only path).
438
- if (!existsSync(nativePatchBinPath())) {
439
- try { await ensurePatchBinary(getPluginData()); } catch { /* surfaces at dispatch */ }
440
- }
441
- await getNativePatchServer().ping();
442
- if (!nativePatchPersistent() && (_nativePatchServer?.waiters?.length || 0) === 0) {
443
- _nativePatchServer?.unref();
444
- }
445
- if (nativePatchTraceEnabled()) {
446
- process.stderr.write(`[patch-native-trace] prewarm_ms=${(performance.now() - started).toFixed(3)}\n`);
447
- }
448
- } catch (err) {
449
- if (nativePatchTraceEnabled()) {
450
- process.stderr.write(`[patch-native-trace] prewarm_failed=${err?.message || String(err)}\n`);
451
- }
452
- }
453
- })();
454
- });
455
- if (_nativePatchPrewarmTimer?.unref) _nativePatchPrewarmTimer.unref();
456
- }
457
-
458
- scheduleNativePatchPrewarm();
459
-
460
- function scheduleNativePatchIdleClose() {
461
- if (nativePatchPersistent() || !_nativePatchServer) return;
462
- if (process.versions?.bun) {
463
- const server = _nativePatchServer;
464
- _nativePatchServer = null;
465
- void server?.close().catch(() => {});
466
- return;
467
- }
468
- _nativePatchServer.unref();
469
- }
470
-
471
- export async function closeNativePatchServerForTests(options = {}) {
472
- if (_nativePatchPrewarmTimer) {
473
- try { clearImmediate(_nativePatchPrewarmTimer); } catch {}
474
- _nativePatchPrewarmTimer = null;
475
- }
476
- const server = _nativePatchServer;
477
- _nativePatchServer = null;
478
- const editServer = _nativeEditServer;
479
- _nativeEditServer = null;
480
- await server?.close(options);
481
- await editServer?.close(options);
482
- }
483
-
484
- try { globalThis.__mixdogCloseNativePatchServers = closeNativePatchServerForTests; } catch {}
485
-
486
- // Strip the leading `a/` or `b/` prefix that `diff -u` / git emit by
487
- // default, plus timestamp suffixes (`\t2024-...`) that some tools append
488
- // to header lines. parsePatch already splits the name from the header
489
- // so timestamps land in `oldHeader` / `newHeader`, but be defensive.
490
- function stripDiffPrefix(name) {
491
- if (!name) return name;
492
- if (isAbsolute(name) || /^[A-Za-z]:[\\/]/.test(name)) return name;
493
- const m = /^[ab]\/(.+)$/.exec(name);
494
- return m ? m[1] : name;
495
- }
496
-
497
- function resolveEntryPath(basePath, rawName) {
498
- const stripped = stripDiffPrefix(rawName);
499
- const norm = normalizeInputPath(stripped);
500
- return isAbsolute(norm) ? pathResolve(norm) : resolveAgainstCwd(norm, basePath);
501
- }
502
-
503
- // V4A section paths are real repository paths and never carry the unified
504
- // diff `a/`·`b/` prefix, so resolution must NOT apply stripDiffPrefix — a
505
- // legitimate top-level `a/` or `b/` directory would otherwise be silently
506
- // rewritten to its child, reading/writing the wrong file.
507
- function resolveV4AEntryPath(basePath, rawName) {
508
- const norm = normalizeInputPath(rawName);
509
- return isAbsolute(norm) ? pathResolve(norm) : resolveAgainstCwd(norm, basePath);
510
- }
511
-
512
- function resolveBasePath(cwd, basePath) {
513
- if (!basePath) return cwd;
514
- const norm = normalizeInputPath(basePath);
515
- return isAbsolute(norm) ? pathResolve(norm) : resolveAgainstCwd(norm, cwd);
516
- }
517
-
518
- // Categorise the per-file entry. A unified diff can describe:
519
- // - modify : both files named, oldFileName exists on disk
520
- // - create : oldFileName === /dev/null (or file doesn't exist + hunks start at 0)
521
- // - delete : newFileName === /dev/null
522
- function classifyEntry(entry) {
523
- const oldIsNull = DEV_NULL.test(entry.oldFileName || '');
524
- const newIsNull = DEV_NULL.test(entry.newFileName || '');
525
- if (oldIsNull && !newIsNull) return 'create';
526
- if (!oldIsNull && newIsNull) return 'delete';
527
- return 'modify';
528
- }
529
-
530
- function parsedEntryTargetKey(entry, basePath) {
531
- if (classifyEntry(entry) !== 'modify') return '';
532
- const headerName = entry.oldFileName || entry.newFileName;
533
- if (!headerName || DEV_NULL.test(headerName)) return '';
534
- const fullPath = resolveEntryPath(basePath, headerName);
535
- return process.platform === 'win32' ? fullPath.toLowerCase() : fullPath;
536
- }
537
-
538
- function mergeDuplicateParsedModifyEntries(parsed, basePath) {
539
- const out = [];
540
- const byTarget = new Map();
541
- let changed = false;
542
- for (const entry of parsed || []) {
543
- const key = parsedEntryTargetKey(entry, basePath);
544
- if (!key) {
545
- out.push(entry);
546
- continue;
547
- }
548
- const existing = byTarget.get(key);
549
- if (!existing) {
550
- byTarget.set(key, entry);
551
- out.push(entry);
552
- continue;
553
- }
554
- existing.hunks.push(...(entry.hunks || []));
555
- changed = true;
556
- }
557
- return { parsed: out, changed };
558
- }
559
-
560
- function unifiedRange(start, lines) {
561
- const s = Math.max(0, Number(start) || 0);
562
- const n = Math.max(0, Number(lines) || 0);
563
- return `${s},${n}`;
564
- }
565
-
566
- function renderParsedUnifiedPatch(parsed) {
567
- const out = [];
568
- for (const entry of parsed || []) {
569
- out.push(`--- ${entry.oldFileName || '/dev/null'}`);
570
- out.push(`+++ ${entry.newFileName || '/dev/null'}`);
571
- for (const hunk of entry.hunks || []) {
572
- const section = hunk.section ? ` ${hunk.section}` : '';
573
- out.push(`@@ -${unifiedRange(hunk.oldStart, hunk.oldLines)} +${unifiedRange(hunk.newStart, hunk.newLines)} @@${section}`);
574
- for (const line of hunk.lines || []) out.push(line);
575
- }
576
- }
577
- return `${out.join('\n')}\n`;
578
- }
579
-
580
- function isPatchErrorText(text) {
581
- return /^Error:/i.test(String(text ?? '').trimStart());
582
- }
583
-
584
- // ── UI-only unified-diff side-channel ───────────────────────────────────────
585
- // apply_patch's MODEL-VISIBLE result stays the compact summary ("Applied 1
586
- // File (Native)\n OK Modify path — +1 Line · -1 Line"). The standard unified
587
- // diff it builds internally (nativePatchStr) is stashed here, keyed by the
588
- // tool_use id, so the agent loop can attach it to the TUI tool-result message
589
- // as a UI-only field (never sent to the model). The TUI's expanded (ctrl+o)
590
- // raw view then colorizes it via the existing formatExpandedResult/diff path.
591
- // Size-capped per entry and FIFO-bounded so a huge patch (or a leaked entry
592
- // whose take() never ran) cannot grow the transcript item or this Map.
593
- const APPLY_PATCH_UI_DIFF_MAX_CHARS = 64 * 1024;
594
- const APPLY_PATCH_UI_DIFF_REGISTRY_MAX = 64;
595
- const _applyPatchUiDiffByCallId = new Map();
596
-
597
- function registerApplyPatchUiDiff(callId, diff) {
598
- if (!callId || typeof diff !== 'string' || !diff.trim()) return;
599
- let text = diff;
600
- if (text.length > APPLY_PATCH_UI_DIFF_MAX_CHARS) {
601
- text = `${text.slice(0, APPLY_PATCH_UI_DIFF_MAX_CHARS)}\n… [diff truncated for display]`;
602
- }
603
- if (_applyPatchUiDiffByCallId.size >= APPLY_PATCH_UI_DIFF_REGISTRY_MAX) {
604
- const oldest = _applyPatchUiDiffByCallId.keys().next().value;
605
- if (oldest !== undefined) _applyPatchUiDiffByCallId.delete(oldest);
606
- }
607
- _applyPatchUiDiffByCallId.set(callId, text);
608
- }
609
-
610
- // Consume (read + remove) the UI-only unified diff for a tool_use id. Returns
611
- // null when none was registered (error/rename-only/partial-fail/dry-skip).
612
- export function takeApplyPatchUiDiff(callId) {
613
- if (!callId) return null;
614
- const value = _applyPatchUiDiffByCallId.get(callId) || null;
615
- if (value != null) _applyPatchUiDiffByCallId.delete(callId);
616
- return value;
617
- }
618
-
619
- // Count how many source lines a hunk consumes vs produces so we can
620
- // surface a concise `lines_changed` figure without re-diffing.
621
- function countHunkChanges(hunks) {
622
- let added = 0;
623
- let removed = 0;
624
- for (const h of hunks || []) {
625
- for (const line of h.lines || []) {
626
- if (line.startsWith('+')) added++;
627
- else if (line.startsWith('-')) removed++;
628
- }
629
- }
630
- return { added, removed };
631
- }
632
-
633
- // Header-shape pre-validator (lexical only): a missing header, a
634
- // `..` segment, or an absolute path that does not resolve inside the
635
- // basePath returns false. Caller wraps the negative result into a clean
636
- // throw so unsupported headers surface as a clean error instead of
637
- // silently degrading to a JS fallback.
638
- function nativeHeaderSupported(entry, basePath) {
639
- const kind = classifyEntry(entry);
640
- const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
641
- if (!headerName || DEV_NULL.test(headerName)) return false;
642
- // Reject any `..` segment on EITHER oldFileName or newFileName (every
643
- // non-/dev/null header) — a modify whose newFileName traverses out of
644
- // base must still be refused even when the resolved path lands inside.
645
- for (const which of ['oldFileName', 'newFileName']) {
646
- const raw = entry[which];
647
- if (!raw || DEV_NULL.test(raw)) continue;
648
- const segs = normalizeInputPath(stripDiffPrefix(raw)).split(/[\\/]+/);
649
- if (segs.some((part) => part === '..')) return false;
650
- }
651
- const stripped = stripDiffPrefix(headerName);
652
- const norm = normalizeInputPath(stripped);
653
- if (isAbsolute(norm) || /^[A-Za-z]:[\\/]/.test(norm)) {
654
- if (!basePath) return false;
655
- const absHeader = pathResolve(norm);
656
- const absBase = pathResolve(basePath);
657
- const rel = pathRelative(absBase, absHeader);
658
- if (!rel || rel.startsWith('..') || isAbsolute(rel)) return false;
659
- if (rel.split(/[\\/]+/).some((part) => part === '..')) return false;
660
- return true;
661
- }
662
- return true;
663
- }
664
-
665
- // Resolve `absPath` via fs.realpathSync, walking up to the nearest existing
666
- // ancestor when the leaf does not yet exist (e.g. a create-mode target).
667
- // Returns the resolved real path, or the lexically-resolved path if no
668
- // ancestor can be realpath'd.
669
- function realpathNearestExistingAncestor(absPath) {
670
- let cur = pathResolve(absPath);
671
- while (true) {
672
- try {
673
- return realpathSync(cur);
674
- } catch {
675
- const parent = pathDirname(cur);
676
- if (!parent || parent === cur) return cur;
677
- cur = parent;
678
- }
679
- }
680
- }
681
-
682
- // Pre-validator (throws): walks every parsed entry and enforces
683
- // - native engine is enabled
684
- // - native binary exists on disk
685
- // - each header is shape-supported (no `..`, no out-of-base absolute)
686
- // - realpath of each non-/dev/null header stays inside the real basePath
687
- // (catches symlink/junction escapes that lexical checks miss)
688
- // - no duplicate target paths in the patch (case-insensitive on win32)
689
- // Returns the list of normalized entry rows the dispatcher uses to format
690
- // output, plus the header-rewrite map for absolute-path normalization.
691
- async function preValidateNativeBatch(parsed, basePath) {
692
- if (!nativePatchEnabled()) {
693
- throw new Error('apply_patch: native engine disabled via MIXDOG_PATCH_NATIVE; set it to "auto" or "1" to apply patches.');
694
- }
695
- const binPath = nativePatchBinPath();
696
- if (!existsSync(binPath)) {
697
- throw new Error(`apply_patch: native patch binary not found at ${binPath}; build native/mixdog-patch or fetch the prebuilt before invoking apply_patch.`);
698
- }
699
- if (!Array.isArray(parsed) || parsed.length === 0) {
700
- throw new Error('apply_patch: patch contained no file sections');
701
- }
702
- await assertPathReachable(basePath);
703
- const reachabilityPaths = [];
704
- for (const entry of parsed) {
705
- for (const which of ['oldFileName', 'newFileName']) {
706
- const checkName = entry[which];
707
- if (!checkName || DEV_NULL.test(checkName)) continue;
708
- reachabilityPaths.push(resolveEntryPath(basePath, checkName));
709
- }
710
- }
711
- await assertPathsReachable(reachabilityPaths);
712
- let realBase;
713
- try {
714
- realBase = realpathSync(pathResolve(basePath));
715
- } catch (err) {
716
- throw new Error(`apply_patch: base_path unreadable (${err?.code || err?.message || String(err)}): ${basePath}`);
717
- }
718
- const entries = [];
719
- const seenPaths = new Set();
720
- const headerRewrites = [];
721
- for (const entry of parsed) {
722
- const kind = classifyEntry(entry);
723
- const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
724
- if (!nativeHeaderSupported(entry, basePath)) {
725
- // Two distinct failure modes share nativeHeaderSupported()===false:
726
- // (a) no usable target path could be parsed from the section header
727
- // (oldFileName/newFileName empty or /dev/null) — a SHAPE problem,
728
- // (b) a parsed path that escapes base_path (`..` segment / out-of-base
729
- // absolute) — a SECURITY refusal.
730
- // Collapsing both into the "path escapes base_path or contains `..`"
731
- // message mislabels (a) as (b): the model sees a nonsensical reason for a
732
- // patch that uses no `..`, cannot self-correct, and re-submits the same
733
- // patch until the iteration cap. Emit a shape-specific, actionable hint
734
- // for (a) so the loop breaks on the first failure.
735
- const hasUsableHeader = headerName && !DEV_NULL.test(headerName);
736
- if (!hasUsableHeader) {
737
- throw new Error(
738
- 'apply_patch: a file section header could not be parsed (no target path). '
739
- + 'Each section must start with a valid header: `*** Update File: <path>` / '
740
- + '`*** Add File: <path>` / `*** Delete File: <path>` (V4A), or a '
741
- + '`--- a/<path>` + `+++ b/<path>` pair (unified). Wrap multi-hunk V4A '
742
- + 'edits in a `*** Begin Patch` / `*** End Patch` envelope and pass format:"v4a".',
743
- );
744
- }
745
- const display = normalizeOutputPath(stripDiffPrefix(headerName));
746
- throw new Error(`apply_patch: header ${display} is unsupported (path escapes base_path or contains \`..\`).`);
747
- }
748
- if (kind !== 'delete' && !(entry.hunks?.length > 0)) {
749
- const display = headerName ? normalizeOutputPath(stripDiffPrefix(headerName)) : '(unknown)';
750
- throw new Error(`apply_patch: entry ${display} has no hunks — patch header malformed (use \`@@ -A,B +C,D @@\` per hunk).`);
751
- }
752
- // Realpath each non-/dev/null header; nearest-existing-ancestor handles
753
- // create-mode leaves that do not yet exist. A symlink/junction whose
754
- // target escapes basePath fails here even when the lexical check above
755
- // looked safe.
756
- for (const which of ['oldFileName', 'newFileName']) {
757
- const checkName = entry[which];
758
- if (!checkName || DEV_NULL.test(checkName)) continue;
759
- const checkFull = resolveEntryPath(basePath, checkName);
760
- const checkReal = realpathNearestExistingAncestor(checkFull);
761
- const checkRel = pathRelative(realBase, checkReal);
762
- if (checkRel.split(/[\\/]+/).some((part) => part === '..') || isAbsolute(checkRel)) {
763
- const display = normalizeOutputPath(stripDiffPrefix(checkName));
764
- throw new Error(`apply_patch: ${display} resolves outside base_path via symlink/junction; refusing to apply.`);
765
- }
766
- }
767
- const fullPath = resolveEntryPath(basePath, headerName);
768
- const pathKey = process.platform === 'win32' ? fullPath.toLowerCase() : fullPath;
769
- if (seenPaths.has(pathKey)) {
770
- const display = normalizeOutputPath(stripDiffPrefix(headerName));
771
- throw new Error(`apply_patch: duplicate target ${display} — patch lists the same path twice.`);
772
- }
773
- seenPaths.add(pathKey);
774
- const displayPath = normalizeOutputPath(stripDiffPrefix(headerName));
775
- const { added, removed } = countHunkChanges(entry.hunks);
776
- entries.push({
777
- kind,
778
- fullPath,
779
- displayPath,
780
- added,
781
- removed,
782
- hunks: entry.hunks?.length || 0,
783
- linesChanged: added + removed,
784
- });
785
- // Absolute-form headers must be rewritten to repo-relative before the
786
- // native server, which joins headers to basePath, sees them.
787
- for (const which of ['oldFileName', 'newFileName']) {
788
- const raw = entry[which];
789
- if (!raw || DEV_NULL.test(raw)) continue;
790
- const stripped = stripDiffPrefix(raw);
791
- const norm = normalizeInputPath(stripped);
792
- if (!isAbsolute(norm) && !/^[A-Za-z]:[\\/]/.test(norm)) continue;
793
- const rel = pathRelative(pathResolve(basePath), pathResolve(norm)).replace(/\\/g, '/');
794
- if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
795
- const display = normalizeOutputPath(stripDiffPrefix(raw));
796
- throw new Error(`apply_patch: absolute header ${display} does not resolve inside base_path.`);
797
- }
798
- headerRewrites.push({ from: raw, to: rel });
799
- }
800
- }
801
- return { entries, headerRewrites };
802
- }
803
-
804
- // Rewrite ONLY the file-section header lines (`--- old`/`+++ new`) that
805
- // precede each hunk so the native server, which joins headers to
806
- // basePath, never sees an absolute header. A hunk DELETION line is `-`
807
- // + content, so a deleted line whose body text is `-- C:/...` renders as
808
- // `--- C:/...`; track hunk-body state by consuming each `@@ -a,b +c,d @@`
809
- // header's declared line counts so only lines outside any hunk body are
810
- // eligible for rewrite.
811
- function rewriteHeaderPaths(patchStr, headerRewrites) {
812
- if (!headerRewrites || headerRewrites.length === 0) return patchStr;
813
- const lines = patchStr.split('\n');
814
- let i = 0;
815
- while (i < lines.length) {
816
- const line = lines[i];
817
- if (line.startsWith('@@ ')) {
818
- const m = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line);
819
- let oldRem = m && m[1] !== undefined ? Number(m[1]) : 1;
820
- let newRem = m && m[2] !== undefined ? Number(m[2]) : 1;
821
- i++;
822
- while (i < lines.length && (oldRem > 0 || newRem > 0)) {
823
- const body = lines[i];
824
- const c = body.charAt(0);
825
- if (c === ' ') { oldRem--; newRem--; }
826
- else if (c === '-') { oldRem--; }
827
- else if (c === '+') { newRem--; }
828
- else if (c === '\\') { /* "" marker */ }
829
- else break;
830
- i++;
831
- }
832
- continue;
833
- }
834
- if (line.startsWith('--- ') || line.startsWith('+++ ')) {
835
- const prefix = line.slice(0, 4);
836
- const rest = line.slice(4);
837
- const tabIdx = rest.indexOf('\t');
838
- const pathPart = tabIdx === -1 ? rest : rest.slice(0, tabIdx);
839
- const suffix = tabIdx === -1 ? '' : rest.slice(tabIdx);
840
- for (const { from, to } of headerRewrites) {
841
- if (pathPart === from) {
842
- lines[i] = `${prefix}${to}${suffix}`;
843
- break;
844
- }
845
- }
846
- }
847
- i++;
848
- }
849
- return lines.join('\n');
850
- }
851
-
852
- function collectUnifiedOldLines(hunk) {
853
- const oldLines = [];
854
- // Track whether the immediately preceding hunk line contributed an OLD
855
- // (context/delete) line, so a following "" marker
856
- // can flip the trailing-newline flag on the right entry. hasNewline is EXTRA
857
- // metadata: the push condition stays IDENTICAL to before so old-line offsets
858
- // and the change-band coordinates are unaffected.
859
- let lastWasOld = false;
860
- for (const raw of hunk?.lines || []) {
861
- if (typeof raw !== 'string' || raw.length === 0) continue;
862
- const tag = raw[0];
863
- if (tag === '-' || tag === ' ') {
864
- oldLines.push({ tag, line: raw.slice(1), hasNewline: true });
865
- lastWasOld = true;
866
- continue;
867
- }
868
- if (tag === '\\') {
869
- // Unified "": the immediately preceding line
870
- // has no trailing newline. Mirror native (main.rs:1500-1526), which flips
871
- // the has_newline flag on the preceding line. Only apply to OLD lines —
872
- // an add-line marker does not affect the context/delete old-line list.
873
- if (lastWasOld && oldLines.length > 0) oldLines[oldLines.length - 1].hasNewline = false;
874
- lastWasOld = false;
875
- continue;
876
- }
877
- lastWasOld = false;
878
- }
879
- return oldLines;
880
- }
881
-
882
- // Ordered op sequence for a hunk (Context/Delete/Add), in source order. Mirrors
883
- // native parts.ops: needed for the change-band computation because Add lines do
884
- // not live in the old-line list yet still bound the interior context region.
885
- // The push condition for context/delete is kept IDENTICAL to
886
- // collectUnifiedOldLines so old-line offsets line up across both views.
887
- function collectUnifiedOps(hunk) {
888
- const ops = [];
889
- for (const raw of hunk?.lines || []) {
890
- if (typeof raw !== 'string' || raw.length === 0) continue;
891
- const tag = raw[0];
892
- if (tag === ' ') ops.push('context');
893
- else if (tag === '-') ops.push('delete');
894
- else if (tag === '+') ops.push('add');
895
- }
896
- return ops;
897
- }
898
-
899
- // Mirror native evaluate_fuzzy_candidate change-band (main.rs:1744-1766): map
900
- // the ordered op sequence into a doubled+shifted old-index coordinate space.
901
- // old_cursor counts consumed OLD lines; Delete at cursor o -> (o+1)*2, Add at
902
- // cursor k -> k*2+1 (strictly between neighbouring old lines). first/last span
903
- // over Adds AND Deletes. {first:null,last:null} means no changes at all.
904
- function computeUnifiedChangeBand(ops) {
905
- let first = null;
906
- let last = null;
907
- let oldCursor = 0;
908
- for (const op of ops) {
909
- if (op === 'context') {
910
- oldCursor += 1;
911
- } else if (op === 'delete') {
912
- const pos = (oldCursor + 1) * 2;
913
- first = first === null ? pos : Math.min(first, pos);
914
- last = last === null ? pos : Math.max(last, pos);
915
- oldCursor += 1;
916
- } else {
917
- const pos = oldCursor * 2 + 1;
918
- first = first === null ? pos : Math.min(first, pos);
919
- last = last === null ? pos : Math.max(last, pos);
920
- }
921
- }
922
- return { first, last };
923
- }
924
-
925
- function firstMeaningfulUnifiedHunkLine(hunk) {
926
- for (const { line } of collectUnifiedOldLines(hunk)) {
927
- if (line.trim()) return { line, preferredLine: Math.max(0, (Number(hunk?.oldStart) || 1) - 1) };
928
- }
929
- return null;
930
- }
931
-
932
- // First FAILING old line within a hunk — not its first line. Anchor at the
933
- // deepest-matching prefix (declared oldStart first, then any position where
934
- // the first old line matches) and report the line where the match breaks.
935
- // Without this the rejection message shows the hunk's FIRST context line,
936
- // which often matches perfectly, next to a "nearest" source line identical
937
- // to it — telling the caller nothing about the actual mismatch.
938
- function firstFailingUnifiedHunkLineDetail(sourceLines, hunk) {
939
- const oldLines = collectUnifiedOldLines(hunk);
940
- if (!oldLines.length) return null;
941
- const lineEq = (actual, expected) => {
942
- const ab = toLineBytes(actual);
943
- const eb = toLineBytes(expected);
944
- return ab.equals(eb) || byteTrimPatchWhitespace(ab).equals(byteTrimPatchWhitespace(eb));
945
- };
946
- const prefixDepth = (start) => {
947
- let d = 0;
948
- while (d < oldLines.length && start + d < sourceLines.length && lineEq(sourceLines[start + d], oldLines[d].line)) d += 1;
949
- return d;
950
- };
951
- const declared = Math.max(0, (Number(hunk?.oldStart) || 1) - 1);
952
- const candidates = [];
953
- if (declared < sourceLines.length) candidates.push(declared);
954
- for (let i = 0; i < sourceLines.length && candidates.length < 8; i++) {
955
- if (i !== declared && lineEq(sourceLines[i], oldLines[0].line)) candidates.push(i);
956
- }
957
- let best = null;
958
- for (const start of candidates) {
959
- const depth = prefixDepth(start);
960
- if (depth >= oldLines.length) continue;
961
- if (!best || depth > best.depth) best = { start, depth };
962
- }
963
- // depth 0 → the hunk's first line itself never anchored; the existing
964
- // first-meaningful-line message is already the right diagnostic there.
965
- if (!best || best.depth === 0) return null;
966
- return {
967
- line: oldLines[best.depth].line,
968
- preferredLine: best.start + best.depth,
969
- };
970
- }
971
-
972
- function firstMeaningfulUnifiedEntryLine(entry) {
973
- const hunks = Array.isArray(entry?.hunks) ? entry.hunks : [];
974
- for (const hunk of hunks) {
975
- const expected = firstMeaningfulUnifiedHunkLine(hunk);
976
- if (expected) return expected;
977
- }
978
- return null;
979
- }
980
-
981
- // --- Byte-level diagnostic-matcher substrate (diagnostics ONLY) ---------------
982
- // The native engine compares RAW BYTES (main.rs:1867-1875 exact, 1891-1899 ws,
983
- // 1936-1944 normalize). The JS diagnostic matcher mirrors that on Buffer line
984
- // slices so invalid-UTF-8 source bytes are NOT pre-collapsed to U+FFFD. These
985
- // helpers feed unifiedOldLinesMatchAt / findFirstFailingUnifiedHunk and nothing
986
- // on the V4A conversion path.
987
-
988
- // Coerce a matcher line value to bytes. Buffer (byte source view OR a raw-byte
989
- // expected line injected by a test) is used as-is; a string (parsed-patch
990
- // expected body, already valid UTF-8 — or a legacy/test plain source array) is
991
- // encoded as UTF-8. Mirrors the fact that native holds both sides as bytes.
992
- function toLineBytes(v) {
993
- return Buffer.isBuffer(v) ? v : Buffer.from(String(v ?? ''), 'utf8');
994
- }
995
-
996
- // Byte version of trim_patch_ws (main.rs:1891-1899): strip ONLY 0x20/0x09 from
997
- // BOTH ends, returning a (zero-copy) subview.
998
- function byteTrimPatchWhitespace(buf) {
999
- let start = 0;
1000
- let end = buf.length;
1001
- while (start < end && (buf[start] === 0x20 || buf[start] === 0x09)) start++;
1002
- while (end > start && (buf[end - 1] === 0x20 || buf[end - 1] === 0x09)) end--;
1003
- return buf.subarray(start, end);
1004
- }
1005
-
1006
- // Reused fatal UTF-8 decoder: mirror native's `from_utf8` validity gate
1007
- // (main.rs:1936-1944). Returns the decoded string when the bytes are valid
1008
- // UTF-8, else null (the normalize tier is then refused for that line).
1009
- const __utf8FatalDecoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
1010
- function decodeValidUtf8OrNull(buf) {
1011
- try {
1012
- return __utf8FatalDecoder.decode(buf);
1013
- } catch {
1014
- return null;
1015
- }
1016
- }
1017
-
1018
- function unifiedOldLinesMatchAt(sourceLines, oldLines, startIdx, fuzz, band) {
1019
- if (startIdx < 0 || startIdx + oldLines.length > sourceLines.length) return null;
1020
- let fuzzUsed = 0;
1021
- let normCount = 0;
1022
- // A source line has a trailing newline EXCEPT the final line when the file
1023
- // body did not end in '\n' (metadata stashed on the array by
1024
- // splitTextLinesForPatch). Default true when metadata is absent (callers that
1025
- // pass a plain literal array, e.g. tests/legacy) so behaviour is unchanged
1026
- // unless they opt in by setting hasFinalNewline.
1027
- const srcFinalNewline = sourceLines.hasFinalNewline !== false;
1028
- const lastSrcIdx = sourceLines.length - 1;
1029
- for (let offset = 0; offset < oldLines.length; offset++) {
1030
- const expected = oldLines[offset];
1031
- const actualIdx = startIdx + offset;
1032
- // Byte substrate (diagnostics-only): compare RAW BYTES like native instead
1033
- // of JS strings, so invalid-UTF-8 source bytes are not pre-collapsed to
1034
- // U+FFFD. `actualBytes` is a per-line Buffer slice when sourceLines is the
1035
- // byte view (formatNativeFailureContext) and an on-the-fly UTF-8 encode for
1036
- // a plain-string/legacy/test source array; `expectedBytes` is the UTF-8
1037
- // encoding of the parsed-patch old line (or a raw Buffer when a test injects
1038
- // invalid bytes directly).
1039
- const actualBytes = toLineBytes(sourceLines[actualIdx]);
1040
- const expectedBytes = toLineBytes(expected.line);
1041
- // Per-line newline guard (mirror native exact-newline guard main.rs:1867-1875
1042
- // and ws/normalize guards 1891-1899/1928-1947): a tier matches ONLY when the
1043
- // expected and actual trailing-newline state agree. expected.hasNewline is
1044
- // the unified old line's flag (default true, cleared by a "\ No newline"
1045
- // marker); the source line lacks a newline only when it is the final line of
1046
- // a no-trailing-newline file. When they differ, NO tier accepts this line —
1047
- // for a delete line this then falls through to reject (mirror native
1048
- // 1832-1835).
1049
- const expectedNL = expected.hasNewline !== false;
1050
- const actualNL = !(actualIdx === lastSrcIdx && !srcFinalNewline);
1051
- const newlineOk = expectedNL === actualNL;
1052
- // Exact tier (main.rs:1867-1875): raw byte equality.
1053
- if (newlineOk && actualBytes.equals(expectedBytes)) continue;
1054
- // Whitespace tier: mirror native (gate main.rs:1787-1790) which accepts a
1055
- // Context OR Delete line that matches after trimming space/tab (0x20/0x09)
1056
- // from BOTH ends (byte trim, main.rs:1891-1899). Costs no fuzz and does NOT
1057
- // increment normCount.
1058
- if (newlineOk && fuzz > 0 && (expected.tag === ' ' || expected.tag === '-') && byteTrimPatchWhitespace(actualBytes).equals(byteTrimPatchWhitespace(expectedBytes))) continue;
1059
- // Unicode-normalization tier: mirror the native engine, which accepts a
1060
- // context OR delete line that matches only after typographic normalization
1061
- // (dashes/quotes/NBSP) at zero fuzz cost (native gate: Context|Delete). The
1062
- // count of such normalization-only matches is tracked so the candidate
1063
- // selector can prefer a block that anchored without normalization. Without
1064
- // this the diagnostic matcher mislabels which hunk first failed when the
1065
- // source carries exotic code-points.
1066
- //
1067
- // UTF-8 validity guard (exact mirror of native main.rs:1936-1944, which
1068
- // normalizes ONLY when BOTH bodies are valid UTF-8): decode each side with a
1069
- // fatal TextDecoder; if EITHER side is invalid UTF-8 the normalize tier is
1070
- // refused (matching native's `from_utf8` gate). This replaces the old
1071
- // string-level U+FFFD heuristic — a VALID literal U+FFFD present in both
1072
- // sides now normalize-matches, exactly as native does.
1073
- if (
1074
- newlineOk
1075
- && fuzz > 0
1076
- && (expected.tag === ' ' || expected.tag === '-')
1077
- ) {
1078
- const actualStr = decodeValidUtf8OrNull(actualBytes);
1079
- const expectedStr = actualStr === null ? null : decodeValidUtf8OrNull(expectedBytes);
1080
- if (
1081
- actualStr !== null
1082
- && expectedStr !== null
1083
- && normalizeTypographic(actualStr) === normalizeTypographic(expectedStr)
1084
- ) {
1085
- normCount++;
1086
- continue;
1087
- }
1088
- }
1089
- if (fuzz > 0 && expected.tag === ' ') {
1090
- // Mirror native (main.rs:1808-1830): content drift on a context line is
1091
- // only fuzz-consumable when the line is OUTER context (before the first
1092
- // change or after the last). Interior context (strictly inside the change
1093
- // band) must match exactly/ws/normalize — content drift there means the
1094
- // hunk is binding to a different block, so reject. ctx_pos maps the old
1095
- // line at this offset into the band coordinate space: (offset + 1) * 2.
1096
- const ctxPos = (offset + 1) * 2;
1097
- const isOuter = (!band || band.first === null || band.last === null)
1098
- ? true
1099
- : (ctxPos < band.first || ctxPos > band.last);
1100
- if (!isOuter) return null;
1101
- fuzzUsed++;
1102
- if (fuzzUsed <= fuzz) continue;
1103
- }
1104
- return null;
1105
- }
1106
- return { fuzzUsed, normCount };
1107
- }
1108
-
1109
- function findUnifiedHunkMatch(sourceLines, hunk, minStartIdx, fuzz) {
1110
- const oldLines = collectUnifiedOldLines(hunk);
1111
- const band = computeUnifiedChangeBand(collectUnifiedOps(hunk));
1112
- const oldStart = Math.max(0, (Number(hunk?.oldStart) || 1) - 1);
1113
- if (oldLines.length === 0) {
1114
- const insertIdx = Math.max(0, Number(hunk?.oldStart) || 0);
1115
- return insertIdx >= minStartIdx && insertIdx <= sourceLines.length ? { start: insertIdx, end: insertIdx } : null;
1116
- }
1117
- if (oldStart >= minStartIdx && unifiedOldLinesMatchAt(sourceLines, oldLines, oldStart, 0, band) !== null) {
1118
- return { start: oldStart, end: oldStart + oldLines.length };
1119
- }
1120
- if (fuzz <= 0) return null;
1121
- let best = null;
1122
- for (let start = minStartIdx; start <= sourceLines.length - oldLines.length; start++) {
1123
- const matched = unifiedOldLinesMatchAt(sourceLines, oldLines, start, fuzz, band);
1124
- if (matched === null) continue;
1125
- const { fuzzUsed, normCount } = matched;
1126
- const distance = Math.abs(start - oldStart);
1127
- // Ordering mirrors native: lower fuzz, THEN fewer normalization-only
1128
- // matches, THEN smaller distance. A block that anchored without
1129
- // normalization always beats a nearer one that needed it.
1130
- if (
1131
- !best ||
1132
- fuzzUsed < best.fuzzUsed ||
1133
- (fuzzUsed === best.fuzzUsed && normCount < best.normCount) ||
1134
- (fuzzUsed === best.fuzzUsed && normCount === best.normCount && distance < best.distance)
1135
- ) {
1136
- best = { start, distance, fuzzUsed, normCount };
1137
- }
1138
- }
1139
- return best ? { start: best.start, end: best.start + oldLines.length } : null;
1140
- }
1141
-
1142
- function findFirstFailingUnifiedHunk(entry, sourceLines, fuzz) {
1143
- const hunks = Array.isArray(entry?.hunks) ? entry.hunks : [];
1144
- let minStartIdx = 0;
1145
- for (const hunk of hunks) {
1146
- const match = findUnifiedHunkMatch(sourceLines, hunk, minStartIdx, fuzz);
1147
- if (!match) return hunk;
1148
- minStartIdx = Math.max(minStartIdx, match.end);
1149
- }
1150
- return null;
1151
- }
1152
-
1153
- function nativeFailurePathCandidates(parsed) {
1154
- const candidates = new Set();
1155
- for (const entry of Array.isArray(parsed) ? parsed : []) {
1156
- const kind = classifyEntry(entry);
1157
- const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
1158
- if (!headerName) continue;
1159
- const stripped = stripDiffPrefix(headerName);
1160
- const display = normalizeOutputPath(stripped);
1161
- candidates.add(headerName);
1162
- candidates.add(stripped);
1163
- candidates.add(display);
1164
- if (display) {
1165
- candidates.add(`a/${display}`);
1166
- candidates.add(`b/${display}`);
1167
- }
1168
- }
1169
- return [...candidates].filter(Boolean).sort((a, b) => b.length - a.length);
1170
- }
1171
-
1172
- function extractNativeFailurePath(message, parsed) {
1173
- const text = String(message || '').trim();
1174
- if (!text) return '';
1175
- const candidates = nativeFailurePathCandidates(parsed);
1176
- for (const candidate of candidates) {
1177
- if (text.startsWith(`${candidate}:`)) return candidate;
1178
- }
1179
- const hunkMatch = /(?:^|\b)hunk rejected in (.+?)(?: \(|$)/i.exec(text);
1180
- if (hunkMatch?.[1]) return hunkMatch[1].trim();
1181
- for (const candidate of candidates) {
1182
- if (text.includes(candidate)) return candidate;
1183
- }
1184
- return '';
1185
- }
1186
-
1187
- function nativeFailureMatchesEntry(entry, failedPath) {
1188
- if (!failedPath) return true;
1189
- const kind = classifyEntry(entry);
1190
- const headerName = kind === 'create' ? entry.newFileName : entry.oldFileName;
1191
- if (!headerName) return false;
1192
- const failed = normalizeOutputPath(stripDiffPrefix(failedPath));
1193
- const display = normalizeOutputPath(stripDiffPrefix(headerName));
1194
- if (!failed || !display) return false;
1195
- // Exact match: single-file / relative-header case.
1196
- if (failed === display) return true;
1197
- // Path-segment-aware containment, both directions:
1198
- // - display.endsWith('/'+failed): an accepted ABSOLUTE header is rewritten
1199
- // to a repo-relative path before native dispatch, so the native failure
1200
- // reports the RELATIVE path while this parsed entry still carries its
1201
- // ORIGINAL absolute (or longer) path — `.../src/b.js` matches `src/b.js`.
1202
- // - failed.endsWith('/'+display): the inverse (native path longer than the
1203
- // parsed header).
1204
- // Anchoring on a leading `/` keeps this segment-boundary aware, so
1205
- // `.../notsrc/app.js` never matches `src/app.js` via raw substring.
1206
- return display.endsWith(`/${failed}`) || failed.endsWith(`/${display}`);
1207
- }
1208
-
1209
- function formatNativeFailureContext(parsed, basePath, failedPath = '', options = {}) {
1210
- const entries = Array.isArray(parsed) ? parsed : [];
1211
- const entry = entries.find((candidate) => classifyEntry(candidate) !== 'create' && nativeFailureMatchesEntry(candidate, failedPath))
1212
- || entries.find((candidate) => classifyEntry(candidate) !== 'create');
1213
- const headerName = entry?.oldFileName;
1214
- const displayPath = headerName ? normalizeOutputPath(stripDiffPrefix(headerName)) : '';
1215
- const fuzz = Number.isFinite(options?.fuzz) && options.fuzz > 0 ? Math.floor(options.fuzz) : 0;
1216
- // Two SEPARATE views of the same source file (this is the ONLY shared-source
1217
- // site): `sourceLines` stays a decoded UTF-8 string[] for the downstream hint
1218
- // heuristics (nearestPatchLineHint, .trim(), normalizeTypographic below) which
1219
- // are inherently string-based; `sourceByteLines` is an independent raw-byte
1220
- // view (per-line Buffer slices) for the byte-parity diagnostic matcher so
1221
- // invalid-UTF-8 bytes are not pre-collapsed to U+FFFD. The V4A conversion path
1222
- // (splitTextLinesForPatch at ~1644/~1660 feeding findLineSequence) is NOT
1223
- // touched — it keeps reading utf8 strings.
1224
- let sourceLines = null;
1225
- let sourceByteLines = null;
1226
- try {
1227
- const fullPath = resolveEntryPath(basePath, entry.oldFileName);
1228
- const raw = readFileSync(fullPath); // Buffer — no 'utf8' decode
1229
- sourceByteLines = splitBufferLinesForPatch(raw);
1230
- sourceLines = splitTextLinesForPatch(raw.toString('utf8'));
1231
- } catch {}
1232
- const failingHunk = sourceByteLines ? findFirstFailingUnifiedHunk(entry, sourceByteLines, fuzz) : null;
1233
- const failingDetail = (failingHunk && sourceByteLines)
1234
- ? firstFailingUnifiedHunkLineDetail(sourceByteLines, failingHunk)
1235
- : null;
1236
- const expected = failingDetail || firstMeaningfulUnifiedHunkLine(failingHunk) || firstMeaningfulUnifiedEntryLine(entry);
1237
- if (!entry || !expected?.line) return '';
1238
- const expectedText = JSON.stringify(compactPatchPreviewLine(expected.line));
1239
- let nearest = '';
1240
- let normalizeHint = '';
1241
- if (sourceLines) {
1242
- nearest = nearestPatchLineHint(sourceLines, expected.line, expected.preferredLine);
1243
- // Typographic-mismatch hint: if the expected context line matches a source
1244
- // line ONLY after Unicode normalization, the source likely carries
1245
- // typographic dashes/quotes/NBSP that an ASCII patch can't match exactly.
1246
- const wantNorm = normalizeTypographic(expected.line);
1247
- if (wantNorm) {
1248
- for (let i = 0; i < sourceLines.length; i++) {
1249
- if (sourceLines[i] === expected.line) break; // exact match exists; not a normalization issue
1250
- // normalizeTypographic also .trim()s, so a pure trailing/leading
1251
- // whitespace drift would otherwise fire this typographic hint. Require
1252
- // a genuine code-point difference: the lines must still differ after a
1253
- // plain trim, yet become equal after typographic normalization.
1254
- if (
1255
- sourceLines[i].trim() !== expected.line.trim()
1256
- && normalizeTypographic(sourceLines[i]) === wantNorm
1257
- ) {
1258
- normalizeHint = `context matches after Unicode normalization at line ${i + 1} — source may contain typographic dashes/quotes/NBSP`;
1259
- break;
1260
- }
1261
- }
1262
- }
1263
- }
1264
- return ` expected first old/context line${displayPath ? ` in ${displayPath}` : ''}: ${expectedText}${nearest ? `; ${nearest}` : ''}${normalizeHint ? `; ${normalizeHint}` : ''}; use exact current lines, no stubs.`;
1265
- }
1266
-
1267
- // Dispatch the (already validated, header-rewritten) patch to the native
1268
- // engine. Throws on any native error; on success returns the formatted
1269
- // human-readable response string. Never silently falls back to JS — the
1270
- // caller MUST surface throws as `Error: ...` strings.
1271
- async function dispatchNativePatch({ entries, basePath, nativePatchStr, fuzz, rejectPartial, dryRun, readStateScope, signal, parsed }) {
1272
- const nativeStart = performance.now();
1273
- let stats;
1274
- try {
1275
- stats = await getNativePatchServer().apply(basePath, nativePatchStr, { fuzz, rejectPartial, dryRun, signal });
1276
- } catch (err) {
1277
- scheduleNativePatchIdleClose();
1278
- const msg = err?.message || String(err);
1279
- const failedPath = extractNativeFailurePath(msg, parsed);
1280
- return `Error: native patch failed — ${msg}${formatNativeFailureContext(parsed, basePath, failedPath, { fuzz })}`;
1281
- }
1282
- const afterInvalidateStart = performance.now();
1283
- // Only invalidate / snapshot entries that actually landed. In isolation
1284
- // mode (OK_PARTIAL) skipped entries still have their original disk state
1285
- // and must not be re-snapshotted.
1286
- const failedDisplaySet = new Set();
1287
- for (const f of stats.failures || []) {
1288
- if (!f?.path) continue;
1289
- failedDisplaySet.add(normalizeOutputPath(f.path));
1290
- failedDisplaySet.add(normalizeOutputPath(stripDiffPrefix(f.path)));
1291
- }
1292
- const writtenEntries = entries.filter((entry) => !failedDisplaySet.has(entry.displayPath));
1293
- const fullPaths = writtenEntries.map((entry) => entry.fullPath);
1294
- if (!dryRun) invalidateBuiltinResultCache(fullPaths);
1295
- const afterInvalidate = performance.now();
1296
- if (!dryRun) markCodeGraphDirtyPaths(fullPaths);
1297
- const afterDirty = performance.now();
1298
- if (!dryRun) {
1299
- for (let i = 0; i < writtenEntries.length; i++) {
1300
- const entry = writtenEntries[i];
1301
- if (entry.kind === 'delete') {
1302
- clearReadSnapshotForPath(entry.fullPath, readStateScope);
1303
- } else {
1304
- const snapshotMeta = {
1305
- source: 'apply_patch_native',
1306
- isPartialView: false,
1307
- };
1308
- const contentHash = stats.contentHashes?.[i] || null;
1309
- if (contentHash) snapshotMeta.contentHash = contentHash;
1310
- recordReadSnapshotForPath(entry.fullPath, readStateScope, snapshotMeta);
1311
- }
1312
- }
1313
- }
1314
- const afterSnapshot = performance.now();
1315
- ioTrace('apply_patch_native', {
1316
- files: writtenEntries.length,
1317
- dryRun,
1318
- partial: stats.partial,
1319
- failed: stats.failures.length,
1320
- roundtripMs: Number(stats.roundtripMs.toFixed(3)),
1321
- rustTotalMs: Number(stats.totalMs.toFixed(3)),
1322
- invalidateMs: Number((afterInvalidate - afterInvalidateStart).toFixed(3)),
1323
- dirtyMs: Number((afterDirty - afterInvalidate).toFixed(3)),
1324
- snapshotMs: Number((afterSnapshot - afterDirty).toFixed(3)),
1325
- contentHashes: (stats.contentHashes || []).filter(Boolean).length,
1326
- });
1327
- if (nativePatchTraceEnabled()) {
1328
- process.stderr.write(
1329
- `[patch-native-trace] files=${writtenEntries.length} partial=${stats.partial ? 1 : 0} failed=${stats.failures.length} roundtrip_ms=${stats.roundtripMs.toFixed(3)} rust_total_ms=${stats.totalMs.toFixed(3)} rust_hash_ms=${stats.hashMs.toFixed(3)} invalidate_ms=${(afterInvalidate - afterInvalidateStart).toFixed(3)} dirty_ms=${(afterDirty - afterInvalidate).toFixed(3)} snapshot_ms=${(afterSnapshot - afterDirty).toFixed(3)} total_js_ms=${(afterSnapshot - nativeStart).toFixed(3)} content_hashes=${(stats.contentHashes || []).filter(Boolean).length}\n`
1330
- );
1331
- }
1332
- if (patchTraceEnabled()) {
1333
- process.stderr.write(`[patch-native] applied files=${writtenEntries.length} partial=${stats.partial ? 1 : 0} ms=${stats.totalMs.toFixed(3)}\n`);
1334
- }
1335
- scheduleNativePatchIdleClose();
1336
- const verb = dryRun ? 'checked' : 'applied';
1337
- const verbLabel = dryRun ? 'Checked' : 'Applied';
1338
- const countLabel = (count, singular, plural = `${singular}s`) => `${count} ${count === 1 ? singular : plural}`;
1339
- const kindLabel = (kind) => {
1340
- const text = String(kind || '').trim();
1341
- return text ? `${text.charAt(0).toUpperCase()}${text.slice(1).toLowerCase()}` : 'Update';
1342
- };
1343
- const summary = stats.partial
1344
- ? `Error: Patch Partially ${verbLabel} (${countLabel(writtenEntries.length, 'File')} ${verb} · ${countLabel(stats.failures.length, 'File')} Skipped) (Native)`
1345
- : `${verbLabel} ${countLabel(writtenEntries.length, 'File')} (Native)${dryRun ? ' Dry Run' : ''}`;
1346
- const lines = [summary];
1347
- for (const entry of writtenEntries) {
1348
- const added = entry.added || 0;
1349
- const removed = entry.removed || 0;
1350
- const parts = [];
1351
- if (added > 0) parts.push(`+${countLabel(added, 'Line')}`);
1352
- if (removed > 0) parts.push(`-${countLabel(removed, 'Line')}`);
1353
- const detail = parts.join(' · ');
1354
- lines.push(detail
1355
- ? ` OK ${kindLabel(entry.kind)} ${entry.displayPath} — ${detail}`
1356
- : ` OK ${kindLabel(entry.kind)} ${entry.displayPath}`);
1357
- }
1358
- for (const f of stats.failures || []) {
1359
- lines.push(` SKIP ${f.path || '(unknown)'} — ${f.reason}${formatNativeFailureContext(parsed, basePath, f.path, { fuzz })}`);
1360
- }
1361
- return lines.join('\n');
1362
- }
1363
-
1364
- // Strip BOM + normalize CRLF→LF only. Idempotent and structural — no
1365
- // hunk metadata is rewritten.
1366
- function prepareInput(patchStr) {
1367
- return String(patchStr).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
1368
- }
1369
-
1370
- function isApplyPatchEnvelope(patchStr) {
1371
- const text = prepareInput(patchStr).trimStart();
1372
- return text.startsWith('*** Begin Patch')
1373
- || text.startsWith('*** Add File:')
1374
- || text.startsWith('*** Update File:')
1375
- || text.startsWith('*** Delete File:');
1376
- }
1377
-
1378
- function isV4APatchInput(patchStr, format) {
1379
- return String(format || '').toLowerCase() === 'v4a'
1380
- || isApplyPatchEnvelope(patchStr);
1381
- }
1382
-
1383
- const UNIFIED_HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/;
1384
- const UNIFIED_HUNK_HEADER_CAPTURE_RE = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@(.*)$/;
1385
-
1386
- function hasUnifiedBareV4AHunk(patchStr) {
1387
- const text = prepareInput(patchStr);
1388
- if (!/^--- /m.test(text) || !/^\+\+\+ /m.test(text)) return false;
1389
- return text.split('\n').some((line) => line.startsWith('@@') && !UNIFIED_HUNK_HEADER_RE.test(line));
1390
- }
26
+ // Side-effect import: schedules the native-patch prewarm on module load, as
27
+ // the original patch.mjs did at top level.
28
+ import { scheduleNativePatchPrewarm } from './patch/native-server.mjs';
1391
29
 
1392
- function isUnifiedHunkCountError(err) {
1393
- const message = String(err?.message || err || '');
1394
- return /Hunk at line .*more lines than expected|Hunk at line .*less lines than expected|expected \d+ old lines|line count did not match/i.test(message);
1395
- }
1396
-
1397
- function canFallbackCountedUnified(patchStr, requestedFormat, err) {
1398
- if (requestedFormat === 'unified') return false;
1399
- if (isV4APatchInput(patchStr, requestedFormat)) return false;
1400
- const text = prepareInput(patchStr);
1401
- return /^--- /m.test(text)
1402
- && /^\+\+\+ /m.test(text)
1403
- && UNIFIED_HUNK_HEADER_RE.test(text.split('\n').find((line) => line.startsWith('@@')) || '')
1404
- && isUnifiedHunkCountError(err);
1405
- }
1406
-
1407
- function planApplyPatchMutationRoute(args, patchStr, requestedFormat) {
1408
- const v4aInput = isV4APatchInput(patchStr, requestedFormat)
1409
- || (requestedFormat !== 'unified' && hasUnifiedBareV4AHunk(patchStr));
1410
- return {
1411
- sourceTool: 'apply_patch',
1412
- engine: v4aInput ? 'v4a-patch' : 'unified-patch',
1413
- reason: 'direct',
1414
- };
1415
- }
1416
-
1417
- function wrapPatchMutationOutput(text, plan, extras = {}) {
1418
- if (isPatchErrorText(text)) return text;
1419
- return wrapMutationRouteOutput(text, plan, extras);
1420
- }
1421
-
1422
- function stripPatchPathMetadata(rawPath) {
1423
- let text = String(rawPath || '').trim();
1424
- if (!text) return '';
1425
- const tabIdx = text.indexOf('\t');
1426
- if (tabIdx !== -1) text = text.slice(0, tabIdx).trimEnd();
1427
- const quote = text[0];
1428
- if ((quote === '"' || quote === "'") && text.length > 1) {
1429
- const end = text.indexOf(quote, 1);
1430
- if (end > 0) text = text.slice(1, end);
1431
- }
1432
- return text;
1433
- }
1434
-
1435
- function stripV4APathHeader(line, prefix) {
1436
- return stripPatchPathMetadata(String(line || '').slice(prefix.length));
1437
- }
1438
-
1439
- function normaliseV4APath(rawPath) {
1440
- const p = stripPatchPathMetadata(rawPath);
1441
- if (!p) return '';
1442
- return p.replace(/^["']|["']$/g, '').replace(/\\/g, '/');
1443
- }
1444
-
1445
- function normaliseV4AAnchor(rawAnchor) {
1446
- return String(rawAnchor || '').replace(/\s*@@\s*$/, '').trim();
1447
- }
1448
-
1449
- function stripV4AMovePathHeader(line) {
1450
- return normaliseV4APath(String(line || '').slice(V4A_MOVE_TO_PREFIX.length));
1451
- }
1452
-
1453
- function isV4AEndOfFileMarker(rawLine) {
1454
- return String(rawLine || '').trim() === V4A_EOF_MARKER;
1455
- }
1456
-
1457
- function v4aEnsureUpdateHunk(current, pendingAnchors) {
1458
- return { anchors: pendingAnchors.slice(), lines: [] };
1459
- }
1460
-
1461
- function v4aPushBlankContextLine(currentHunk, pendingAnchors) {
1462
- if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(null, pendingAnchors);
1463
- currentHunk.lines.push(' ');
1464
- return currentHunk;
1465
- }
1466
-
1467
- function v4aMarkHunkEndOfFile(currentHunk, finishHunk) {
1468
- if (!currentHunk || currentHunk.lines.length === 0) {
1469
- throw new Error('V4A update hunk does not contain any lines before *** End of File');
1470
- }
1471
- currentHunk.isEndOfFile = true;
1472
- finishHunk();
1473
- }
1474
-
1475
- // Split a patch string into lines, dropping the single trailing empty element
1476
- // produced by the patch's terminal newline. Invariant: a well-formed patch
1477
- // ends with "\n", so `"...\n".split("\n")` always yields a spurious final ""
1478
- // that is a line *terminator*, not a content line. Absorbing it as a blank
1479
- // context line corrupts the last hunk (phantom trailing "" in oldLines) and
1480
- // breaks anchoring whenever the matched source region is not followed by a
1481
- // blank line. A genuine trailing blank context line survives as the
1482
- // second-to-last element, so only the terminator artifact is removed.
1483
- function splitPatchLines(patchStr) {
1484
- const lines = prepareInput(patchStr).split('\n');
1485
- if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
1486
- return lines;
1487
- }
1488
-
1489
- function parseV4APatch(patchStr) {
1490
- const lines = splitPatchLines(patchStr);
1491
- const files = [];
1492
- let current = null;
1493
- let pendingAnchors = [];
1494
- let currentHunk = null;
1495
-
1496
- const finishHunk = () => {
1497
- if (!current || !currentHunk) return;
1498
- if (currentHunk.lines.length > 0) current.hunks.push(currentHunk);
1499
- currentHunk = null;
1500
- };
1501
- const finishFile = () => {
1502
- finishHunk();
1503
- current = null;
1504
- pendingAnchors = [];
1505
- };
1506
- const startFile = (kind, path) => {
1507
- finishFile();
1508
- current = { kind, path: normaliseV4APath(path), hunks: [], lines: [], movePath: null };
1509
- files.push(current);
1510
- };
1511
-
1512
- for (const rawLine of lines) {
1513
- if (rawLine === '*** Begin Patch' || rawLine === '*** End Patch') continue;
1514
- if (rawLine.startsWith('*** Update File:')) {
1515
- startFile('update', stripV4APathHeader(rawLine, '*** Update File:'));
1516
- continue;
1517
- }
1518
- if (rawLine.startsWith('*** Add File:')) {
1519
- startFile('add', stripV4APathHeader(rawLine, '*** Add File:'));
1520
- continue;
1521
- }
1522
- if (rawLine.startsWith('*** Delete File:')) {
1523
- startFile('delete', stripV4APathHeader(rawLine, '*** Delete File:'));
1524
- continue;
1525
- }
1526
- if (!current) {
1527
- throw new Error(`V4A patch line appears before a file header: ${rawLine}`);
1528
- }
1529
- if (current.kind === 'update' && rawLine.startsWith(V4A_MOVE_TO_PREFIX)) {
1530
- if (current.movePath) {
1531
- throw new Error(`V4A patch lists multiple ${V4A_MOVE_TO_PREFIX} directives for ${current.path}`);
1532
- }
1533
- const dest = stripV4AMovePathHeader(rawLine);
1534
- if (!dest) throw new Error('V4A patch contains an empty move destination path');
1535
- current.movePath = dest;
1536
- continue;
1537
- }
1538
- if (current.kind === 'add') {
1539
- current.lines.push(rawLine.startsWith('+') ? rawLine.slice(1) : rawLine);
1540
- continue;
1541
- }
1542
- if (current.kind === 'delete') {
1543
- continue;
1544
- }
1545
- if (rawLine === '') {
1546
- if (currentHunk) currentHunk = v4aPushBlankContextLine(currentHunk, pendingAnchors);
1547
- continue;
1548
- }
1549
- if (isV4AEndOfFileMarker(rawLine)) {
1550
- v4aMarkHunkEndOfFile(currentHunk, finishHunk);
1551
- currentHunk = null;
1552
- continue;
1553
- }
1554
- if (rawLine.startsWith('@@')) {
1555
- const anchor = normaliseV4AAnchor(rawLine.slice(2));
1556
- if (currentHunk && currentHunk.lines.length > 0) finishHunk();
1557
- pendingAnchors.push(anchor);
1558
- currentHunk = { anchors: pendingAnchors.slice(), lines: [] };
1559
- pendingAnchors = [];
1560
- continue;
1561
- }
1562
- const tag = rawLine[0];
1563
- if (tag !== ' ' && tag !== '-' && tag !== '+') {
1564
- if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(current, pendingAnchors);
1565
- pendingAnchors = [];
1566
- currentHunk.lines.push(` ${rawLine}`);
1567
- continue;
1568
- }
1569
- if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(current, pendingAnchors);
1570
- currentHunk.lines.push(rawLine);
1571
- }
1572
- finishFile();
1573
- const bad = files.find((file) => !file.path);
1574
- if (bad) throw new Error('V4A patch contains an empty file path');
1575
- if (files.length === 0) throw new Error('V4A patch contained no file sections');
1576
- return files;
1577
- }
1578
-
1579
- function stripUnifiedV4APathHeader(line, prefix) {
1580
- return stripDiffPrefix(normaliseV4APath(String(line || '').slice(prefix.length)));
1581
- }
1582
-
1583
- // Shared parser for unified-input -> V4A sections. The bare and counted
1584
- // fallbacks are byte-identical except for (a) the error label and (b) how an
1585
- // `@@` line yields its anchor: bare rejects counted headers and takes the raw
1586
- // tail; counted requires a counted header and takes its capture group. The
1587
- // difference is injected as `resolveAnchor`; everything else is shared.
1588
- function parseUnifiedAsV4APatch(patchStr, { label, resolveAnchor }) {
1589
- const lines = splitPatchLines(patchStr);
1590
- const files = [];
1591
- let current = null;
1592
- let pendingAnchors = [];
1593
- let currentHunk = null;
1594
-
1595
- const finishHunk = () => {
1596
- if (!current || !currentHunk) return;
1597
- if (current.kind === 'update' && currentHunk.lines.length > 0) current.hunks.push(currentHunk);
1598
- currentHunk = null;
1599
- };
1600
- const finishFile = () => {
1601
- finishHunk();
1602
- current = null;
1603
- pendingAnchors = [];
1604
- };
1605
- const startFile = (oldPath, newPath) => {
1606
- finishFile();
1607
- const oldIsNull = DEV_NULL.test(oldPath || '');
1608
- const newIsNull = DEV_NULL.test(newPath || '');
1609
- const kind = oldIsNull ? 'add' : (newIsNull ? 'delete' : 'update');
1610
- const path = kind === 'add' ? newPath : oldPath;
1611
- current = { kind, path: normaliseV4APath(path), hunks: [], lines: [] };
1612
- files.push(current);
1613
- };
1614
-
1615
- for (let i = 0; i < lines.length; i++) {
1616
- const rawLine = lines[i];
1617
- if (rawLine.startsWith('diff --git ') || rawLine.startsWith('index ') || rawLine.startsWith('new file mode ') || rawLine.startsWith('deleted file mode ')) {
1618
- continue;
1619
- }
1620
- if (rawLine.startsWith('--- ')) {
1621
- const next = lines[i + 1] || '';
1622
- if (next.startsWith('+++ ')) {
1623
- startFile(stripUnifiedV4APathHeader(rawLine, '--- '), stripUnifiedV4APathHeader(next, '+++ '));
1624
- i++;
1625
- continue;
1626
- }
1627
- // A real unified file header `--- X` is ALWAYS immediately followed by a
1628
- // `+++ Y` line. Without the pair, outside a file this is a malformed
1629
- // patch (keep the diagnostic); INSIDE a file it is a hunk-body deletion
1630
- // line whose content starts with `-- ` (rawLine `--- foo`) — fall through
1631
- // to body handling instead of misreading it as a file header.
1632
- if (!current) throw new Error(`${label} missing +++ header after: ${rawLine}`);
1633
- }
1634
- if (!current) continue;
1635
- if (rawLine === '') {
1636
- if (current.kind !== 'update') continue;
1637
- if (currentHunk) currentHunk = v4aPushBlankContextLine(currentHunk, pendingAnchors);
1638
- continue;
1639
- }
1640
- if (current.kind === 'update' && isV4AEndOfFileMarker(rawLine)) {
1641
- v4aMarkHunkEndOfFile(currentHunk, finishHunk);
1642
- currentHunk = null;
1643
- continue;
1644
- }
1645
- if (rawLine.startsWith('@@')) {
1646
- const anchor = resolveAnchor(rawLine);
1647
- if (currentHunk && currentHunk.lines.length > 0) finishHunk();
1648
- pendingAnchors.push(anchor);
1649
- currentHunk = { anchors: pendingAnchors.slice(), lines: [] };
1650
- pendingAnchors = [];
1651
- continue;
1652
- }
1653
- if (current.kind === 'add') {
1654
- if (rawLine[0] === '+') current.lines.push(rawLine.slice(1));
1655
- continue;
1656
- }
1657
- if (current.kind === 'delete') {
1658
- continue;
1659
- }
1660
- const tag = rawLine[0];
1661
- if (tag !== ' ' && tag !== '-' && tag !== '+') {
1662
- if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(current, pendingAnchors);
1663
- pendingAnchors = [];
1664
- currentHunk.lines.push(` ${rawLine}`);
1665
- continue;
1666
- }
1667
- if (!currentHunk) currentHunk = v4aEnsureUpdateHunk(current, pendingAnchors);
1668
- currentHunk.lines.push(rawLine);
1669
- }
1670
- finishFile();
1671
- const bad = files.find((file) => !file.path);
1672
- if (bad) throw new Error(`${label} contains an empty file path`);
1673
- if (files.length === 0) throw new Error(`${label} contained no file sections`);
1674
- return files;
1675
- }
1676
-
1677
- function parseUnifiedBareV4APatch(patchStr) {
1678
- return parseUnifiedAsV4APatch(patchStr, {
1679
- label: 'unified bare patch',
1680
- resolveAnchor: (rawLine) => {
1681
- if (UNIFIED_HUNK_HEADER_RE.test(rawLine)) {
1682
- throw new Error('unified bare patch cannot mix counted unified hunks with bare @@ anchors');
1683
- }
1684
- return normaliseV4AAnchor(rawLine.slice(2));
1685
- },
1686
- });
1687
- }
1688
-
1689
- function parseUnifiedCountedAsV4APatch(patchStr) {
1690
- return parseUnifiedAsV4APatch(patchStr, {
1691
- label: 'unified fallback',
1692
- resolveAnchor: (rawLine) => {
1693
- const match = UNIFIED_HUNK_HEADER_CAPTURE_RE.exec(rawLine);
1694
- if (!match) throw new Error(`unified fallback requires counted hunk header: ${rawLine}`);
1695
- return normaliseV4AAnchor(match[1] || '');
1696
- },
1697
- });
1698
- }
1699
-
1700
- function splitTextLinesForPatch(text) {
1701
- const body = String(text ?? '').replace(/\r\n/g, '\n');
1702
- if (body.length === 0) {
1703
- const empty = [];
1704
- // No content -> no final source line that could lack a newline.
1705
- empty.hasFinalNewline = true;
1706
- return empty;
1707
- }
1708
- const lines = body.split('\n');
1709
- // Per-line hasNewline tracking (mirror native): every line carries a trailing
1710
- // newline EXCEPT possibly the final one. The metadata rides on the array as a
1711
- // non-indexed `hasFinalNewline` property so the return value stays a plain
1712
- // string[] for every existing exact/ws/normalize/LCS consumer; only the
1713
- // newline-aware matcher reads it. When `body` ends in '\n', split yields a
1714
- // trailing '' sentinel -> popped -> all real lines had newlines. Otherwise
1715
- // the final element IS real content with no trailing newline.
1716
- let hasFinalNewline = true;
1717
- if (lines[lines.length - 1] === '') lines.pop();
1718
- else hasFinalNewline = false;
1719
- lines.hasFinalNewline = hasFinalNewline;
1720
- return lines;
1721
- }
1722
-
1723
- // Byte-aware variant of splitTextLinesForPatch for the DIAGNOSTIC matcher only.
1724
- // Takes a raw file Buffer and yields an array of per-line Buffer slices split on
1725
- // 0x0A (LF), stripping a single trailing 0x0D (CR) per line so CRLF files behave
1726
- // like the string splitter's \r\n -> \n normalization — all WITHOUT lossy UTF-8
1727
- // decoding, so invalid bytes survive for native-parity raw byte compares. The
1728
- // same non-indexed `hasFinalNewline` metadata rides on the array; values are
1729
- // Buffers, which toLineBytes/unifiedOldLinesMatchAt consume directly.
1730
- function splitBufferLinesForPatch(buf) {
1731
- const empty = [];
1732
- if (!buf || buf.length === 0) {
1733
- empty.hasFinalNewline = true;
1734
- return empty;
1735
- }
1736
- const lines = [];
1737
- let start = 0;
1738
- for (let i = 0; i < buf.length; i++) {
1739
- if (buf[i] === 0x0a) {
1740
- let end = i;
1741
- if (end > start && buf[end - 1] === 0x0d) end--; // strip CR of CRLF
1742
- lines.push(buf.subarray(start, end));
1743
- start = i + 1;
1744
- }
1745
- }
1746
- let hasFinalNewline;
1747
- if (start === buf.length) {
1748
- // Buffer ended exactly on a newline -> no dangling final line.
1749
- hasFinalNewline = true;
1750
- } else {
1751
- // Native strips \r ONLY when immediately before \n (CRLF). The FINAL
1752
- // unterminated line has no \n, so a bare trailing \r is KEPT in the
1753
- // compared body to mirror native's exact byte compare.
1754
- lines.push(buf.subarray(start, buf.length));
1755
- hasFinalNewline = false;
1756
- }
1757
- lines.hasFinalNewline = hasFinalNewline;
1758
- return lines;
1759
- }
1760
-
1761
- function v4AHunkLineStats(hunk) {
1762
- let oldCount = 0;
1763
- let newCount = 0;
1764
- const oldLines = [];
1765
- const newLines = [];
1766
- for (const raw of hunk.lines || []) {
1767
- if (!raw) continue;
1768
- const tag = raw[0];
1769
- const body = raw.slice(1);
1770
- if (tag === ' ') {
1771
- oldCount++;
1772
- newCount++;
1773
- oldLines.push(body);
1774
- newLines.push(body);
1775
- } else if (tag === '-') {
1776
- oldCount++;
1777
- oldLines.push(body);
1778
- } else if (tag === '+') {
1779
- newCount++;
1780
- newLines.push(body);
1781
- }
1782
- }
1783
- return { oldCount, newCount, oldLines, newLines };
1784
- }
1785
-
1786
- function findAnchorLine(lines, anchors, fromLine) {
1787
- let cursor = Math.max(0, fromLine || 0);
1788
- for (const anchorRaw of anchors || []) {
1789
- const anchor = String(anchorRaw || '').trim();
1790
- if (!anchor) continue;
1791
- const found = lines.findIndex((line, idx) => idx >= cursor && line.includes(anchor));
1792
- if (found === -1) return -1;
1793
- cursor = found + 1;
1794
- }
1795
- return cursor;
1796
- }
1797
-
1798
- // Length of the longest common (contiguous) substring of `a` and `b`.
1799
- // Capped per side so a pathological multi-KB line cannot blow up the
1800
- // O(N*M) inner loop; lines beyond `cap` are truncated for the LCS only.
1801
- // Used by both the long-single-line context fallback in findLineSequence
1802
- // and the nearest-line hint scorer.
1803
- function longestCommonSubstringLen(a, b, cap = 4000) {
1804
- if (!a || !b) return 0;
1805
- const A = a.length > cap ? a.slice(0, cap) : a;
1806
- const B = b.length > cap ? b.slice(0, cap) : b;
1807
- const la = A.length;
1808
- const lb = B.length;
1809
- if (la === 0 || lb === 0) return 0;
1810
- let prev = new Int32Array(lb + 1);
1811
- let curr = new Int32Array(lb + 1);
1812
- let best = 0;
1813
- for (let i = 1; i <= la; i++) {
1814
- const ca = A.charCodeAt(i - 1);
1815
- for (let j = 1; j <= lb; j++) {
1816
- curr[j] = ca === B.charCodeAt(j - 1) ? prev[j - 1] + 1 : 0;
1817
- if (curr[j] > best) best = curr[j];
1818
- }
1819
- const tmp = prev; prev = curr; curr = tmp;
1820
- curr.fill(0);
1821
- }
1822
- return best;
1823
- }
1824
-
1825
- // Normalize common typographic code-points to their ASCII equivalents, then
1826
- // trim. Mirrors the Rust mixdog-patch normalize_typographic() and V4A
1827
- // apply_patch's normalise() so V4A->unified anchor resolution stays consistent
1828
- // across engines: an ASCII-authored patch can still anchor on source that
1829
- // carries curly quotes, em/en dashes, NBSP and other exotic spaces.
1830
- // Rust str::trim() strips Unicode White_Space at both ends. JS String.trim()
1831
- // diverges: it trims U+FEFF (BOM/ZWNBSP) but NOT U+0085 (NEL). To stay
1832
- // byte-for-byte consistent with native normalise(), trim EXACTLY the Rust
1833
- // White_Space set here — include U+0085, exclude U+FEFF.
1834
- const RUST_WS = '\\u0009\\u000A\\u000B\\u000C\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000';
1835
- const RUST_TRIM_RE = new RegExp(`^[${RUST_WS}]+|[${RUST_WS}]+$`, 'g');
1836
- function rustTrim(s) {
1837
- return s.replace(RUST_TRIM_RE, '');
1838
- }
1839
- function normalizeTypographic(s) {
1840
- // Mirror Rust normalise() ORDER: trim FIRST, then apply the dash/quote/space
1841
- // code-point map. Trimming before mapping matches `s.trim().chars().map(...)`.
1842
- return rustTrim(String(s ?? ''))
1843
- .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, '-')
1844
- .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
1845
- .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
1846
- .replace(/[\u00A0\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000]/g, ' ');
1847
- }
1848
-
1849
- function findLineSequence(lines, needle, fromLine, preferredLine = 0, options = {}) {
1850
- if (!Array.isArray(needle) || needle.length === 0) return Math.max(0, preferredLine || fromLine || 0);
1851
- const eof = options?.eof === true;
1852
- let minStart = Math.max(0, fromLine || 0);
1853
- if (eof && needle.length <= lines.length) {
1854
- minStart = Math.max(minStart, lines.length - needle.length);
1855
- }
1856
- const preferred = Math.max(0, preferredLine || 0);
1857
- const fuzzy = options && options.fuzzy === false ? false : true;
1858
- const tiers = fuzzy
1859
- ? [
1860
- (a, b) => a === b,
1861
- (a, b) => a.replace(/\s+$/, '') === b.replace(/\s+$/, ''),
1862
- (a, b) => a.trim() === b.trim(),
1863
- // Internal-whitespace-collapse tier: catches reformatted long lines
1864
- // (e.g. re-indented JSON values) where exact bytes drift but the
1865
- // semantic content matches. Runs strictly after stricter tiers so
1866
- // exact / rstrip / trim still win when they match.
1867
- (a, b) => a.replace(/\s+/g, ' ').trim() === b.replace(/\s+/g, ' ').trim(),
1868
- // Unicode-normalization tier (LAST): typographic dashes/quotes/NBSP in
1869
- // the source vs an ASCII-authored patch. Deterministic code-point map,
1870
- // runs after every whitespace tier so stricter matches always win.
1871
- (a, b) => normalizeTypographic(a) === normalizeTypographic(b),
1872
- ]
1873
- : [
1874
- (a, b) => a === b,
1875
- ];
1876
- for (const eq of tiers) {
1877
- const starts = [];
1878
- for (let i = minStart; i <= lines.length - needle.length; i++) {
1879
- let ok = true;
1880
- for (let k = 0; k < needle.length; k++) {
1881
- if (!eq(lines[i + k], needle[k])) { ok = false; break; }
1882
- }
1883
- if (ok) starts.push(i);
1884
- }
1885
- if (starts.length) {
1886
- starts.sort((a, b) => Math.abs(a - preferred) - Math.abs(b - preferred) || a - b);
1887
- return starts[0];
1888
- }
1889
- }
1890
- // Long single-line context fallback. When the entire needle is one long
1891
- // line (>=40 chars after whitespace-collapse) and every equality tier
1892
- // failed, accept a UNIQUE source line whose longest-common-substring
1893
- // with the needle is the file-wide maximum and covers at least half of
1894
- // the needle. Uniqueness is the invariant: ambiguous best-matches
1895
- // return -1, so real mismatches still surface as "context not found"
1896
- // instead of silently anchoring on the wrong line.
1897
- if (fuzzy && needle.length === 1) {
1898
- const want = String(needle[0] ?? '').replace(/\s+/g, ' ').trim();
1899
- if (want.length >= 40) {
1900
- const minLcs = Math.max(40, Math.floor(want.length / 2));
1901
- let bestIdx = -1;
1902
- let bestLcs = 0;
1903
- let bestTies = 0;
1904
- for (let i = minStart; i < lines.length; i++) {
1905
- const cand = String(lines[i] ?? '').replace(/\s+/g, ' ').trim();
1906
- if (cand.length === 0) continue;
1907
- const lcs = longestCommonSubstringLen(cand, want);
1908
- if (lcs < minLcs) continue;
1909
- if (lcs > bestLcs) { bestLcs = lcs; bestIdx = i; bestTies = 1; }
1910
- else if (lcs === bestLcs) { bestTies++; }
1911
- }
1912
- if (bestIdx >= 0 && bestTies === 1) return bestIdx;
1913
- }
1914
- }
1915
- return -1;
1916
- }
1917
-
1918
- function compactPatchPreviewLine(line, maxLen = 140) {
1919
- const text = String(line ?? '').replace(/\t/g, '\\t');
1920
- return text.length > maxLen ? `${text.slice(0, maxLen - 1)}…` : text;
1921
- }
1922
-
1923
- // Bundled/transpiled sources often store non-ASCII inside string literals as
1924
- // literal `\uXXXX` escape sequences (6 ASCII chars). A patch authored with the
1925
- // real character can then never match verbatim. These helpers let the V4A
1926
- // locator accept a window where each patch line matches the source either
1927
- // verbatim or via "patch's real char == file's \uXXXX escape of it".
1928
- function escapeNonAsciiForPatch(line) {
1929
- const s = String(line ?? '');
1930
- let out = '';
1931
- for (let i = 0; i < s.length; i++) {
1932
- const code = s.charCodeAt(i);
1933
- out += code > 0x7f
1934
- ? String.fromCharCode(92) + 'u' + code.toString(16).padStart(4, '0')
1935
- : s[i];
1936
- }
1937
- return out;
1938
- }
1939
-
1940
- function findLineSequenceEscapeEquiv(sourceLines, pattern, minStart, preferred) {
1941
- if (!pattern || pattern.length === 0) return -1;
1942
- const starts = [];
1943
- const from = Math.max(0, Number.isFinite(minStart) ? minStart : 0);
1944
- outer: for (let i = from; i + pattern.length <= sourceLines.length; i++) {
1945
- let usedEquiv = false;
1946
- for (let k = 0; k < pattern.length; k++) {
1947
- const pat = pattern[k];
1948
- const src = sourceLines[i + k];
1949
- if (src === pat) continue;
1950
- if (src === escapeNonAsciiForPatch(pat)) { usedEquiv = true; continue; }
1951
- continue outer;
1952
- }
1953
- // Require at least one escape-equivalent line: an all-verbatim window
1954
- // would have been found by the primary matcher already.
1955
- if (usedEquiv) starts.push(i);
1956
- }
1957
- if (starts.length === 0) return -1;
1958
- const pref = Number.isFinite(preferred) && preferred >= 0 ? preferred : 0;
1959
- starts.sort((a, b) => Math.abs(a - pref) - Math.abs(b - pref) || a - b);
1960
- return starts[0];
1961
- }
1962
-
1963
- function firstMeaningfulPatchLine(lines) {
1964
- return (lines || []).find((line) => String(line ?? '').trim().length > 0) || '';
1965
- }
1966
-
1967
- function scoreSimilarPatchLine(candidate, target) {
1968
- const cand = String(candidate ?? '').trim().replace(/\s+/g, ' ');
1969
- const want = String(target ?? '').trim().replace(/\s+/g, ' ');
1970
- if (!cand || !want) return 0;
1971
- if (cand === want) return 100000;
1972
- let score = 0;
1973
- // Longest common substring drives similarity for long lines: weighting
1974
- // shared-byte run length keeps the "nearest line" hint anchored on the
1975
- // line that actually shares the most content, instead of a short line
1976
- // that happens to embed in (or share a few tokens with) the long target.
1977
- const lcs = longestCommonSubstringLen(cand, want);
1978
- score += lcs * 20;
1979
- if (cand.includes(want) || want.includes(cand)) score += 5000 + Math.min(cand.length, want.length);
1980
- const words = new Set(want.split(/[^A-Za-z0-9_$]+/).filter((word) => word.length > 1));
1981
- for (const word of words) {
1982
- if (cand.includes(word)) score += Math.min(200, word.length * 12);
1983
- }
1984
- // Length-delta penalty only meaningful for short lines; a long line with
1985
- // a large shared-byte run should not be crushed by a modest length gap.
1986
- if (Math.max(cand.length, want.length) < 80) {
1987
- score -= Math.abs(cand.length - want.length);
1988
- }
1989
- return score;
1990
- }
1991
-
1992
- function nearestPatchLineHint(sourceLines, expectedLine, preferredLine) {
1993
- const expected = String(expectedLine || '');
1994
- if (!expected.trim()) return '';
1995
- let best = null;
1996
- const preferred = Number.isFinite(preferredLine) && preferredLine >= 0 ? preferredLine : 0;
1997
- for (let i = 0; i < sourceLines.length; i++) {
1998
- const score = scoreSimilarPatchLine(sourceLines[i], expected) - (Math.abs(i - preferred) * 0.01);
1999
- if (!best || score > best.score) best = { score, index: i, line: sourceLines[i] };
2000
- }
2001
- if (!best || best.score <= 0) return '';
2002
- return `nearest line ${best.index + 1}: ${JSON.stringify(compactPatchPreviewLine(best.line))}`;
2003
- }
2004
-
2005
- function formatV4AHunkLocator(hunk) {
2006
- return (hunk.anchors || []).filter(Boolean).join(' > ') || '(no anchor)';
2007
- }
2008
-
2009
- function formatV4AAnchorMissHint(sourceLines, hunk) {
2010
- const anchors = (hunk?.anchors || []).filter(Boolean);
2011
- const nearest = anchors.length > 0
2012
- ? anchors.map((anchor) => nearestPatchLineHint(sourceLines, anchor, 0)).find(Boolean)
2013
- : null;
2014
- return anchors.length === 0
2015
- ? ' use an existing @@ anchor from the current file or add exact context lines.'
2016
- : ` use an existing @@ anchor from the current file or add exact context lines; no stubs.${nearest ? ` nearest anchor candidate: ${nearest}.` : ''}`;
2017
- }
2018
-
2019
- function formatV4AContextMissHint(sourceLines, stats, anchorLine) {
2020
- const expected = firstMeaningfulPatchLine(stats.oldLines);
2021
- const parts = [];
2022
- if (expected) {
2023
- const nearest = nearestPatchLineHint(sourceLines, expected, anchorLine);
2024
- parts.push(`expected first old line: ${JSON.stringify(compactPatchPreviewLine(expected))}`);
2025
- if (nearest) parts.push(nearest);
2026
- const divergence = firstV4ADivergenceHint(sourceLines, stats.oldLines, anchorLine);
2027
- if (divergence) parts.push(divergence);
2028
- }
2029
- parts.push('use exact current context or a broader @@ anchor; no stubs.');
2030
- return ` ${parts.join('; ')}`;
2031
- }
2032
-
2033
- // When the FIRST old line does exist verbatim in the source, the real
2034
- // mismatch is some later line of the block — name it, with both sides
2035
- // JSON-escaped so invisible differences (real char vs literal \uXXXX
2036
- // escape, tabs, trailing spaces) become visible in the error.
2037
- function firstV4ADivergenceHint(sourceLines, oldLines, anchorLine) {
2038
- const lines = oldLines || [];
2039
- const firstIdx = lines.findIndex((l) => String(l ?? '').trim().length > 0);
2040
- if (firstIdx < 0) return '';
2041
- const first = lines[firstIdx];
2042
- const starts = [];
2043
- for (let i = 0; i < sourceLines.length; i++) {
2044
- if (sourceLines[i] === first) starts.push(i - firstIdx);
2045
- }
2046
- const pref = Number.isFinite(anchorLine) && anchorLine >= 0 ? anchorLine : 0;
2047
- const start = starts.filter((s) => s >= 0)
2048
- .sort((a, b) => Math.abs(a - pref) - Math.abs(b - pref) || a - b)[0];
2049
- if (start === undefined) return '';
2050
- for (let k = 0; k < lines.length; k++) {
2051
- const exp = lines[k];
2052
- const act = sourceLines[start + k];
2053
- if (act !== exp) {
2054
- const actText = act === undefined ? '(past EOF)' : JSON.stringify(compactPatchPreviewLine(act));
2055
- return `first divergent line: old[${k + 1}] expected ${JSON.stringify(compactPatchPreviewLine(exp))} vs file line ${start + k + 1} actual ${actText}`;
2056
- }
2057
- }
2058
- return '';
2059
- }
2060
-
2061
- function joinTextLinesForPatch(lines) {
2062
- const body = (lines || []).join('\n');
2063
- return lines?.hasFinalNewline !== false ? `${body}\n` : body;
2064
- }
2065
-
2066
- function cloneTextLinesForPatch(sourceLines) {
2067
- const lines = [...(sourceLines || [])];
2068
- lines.hasFinalNewline = sourceLines?.hasFinalNewline !== false;
2069
- return lines;
2070
- }
2071
-
2072
- function resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, options = {}) {
2073
- const stats = v4AHunkLineStats(hunk);
2074
- if (stats.oldCount === 0 && stats.newCount === 0) return { skip: true };
2075
- const fuzzy = options.fuzzy !== false;
2076
- const eof = hunk?.isEndOfFile === true;
2077
- const anchorLine = findAnchorLine(sourceLines, hunk.anchors, nextSearchLine);
2078
- if (anchorLine < 0) {
2079
- const msg = `V4A hunk anchor not found: ${formatV4AHunkLocator(hunk)};${formatV4AAnchorMissHint(sourceLines, hunk)}`;
2080
- return { error: msg };
2081
- }
2082
- let oldLinesPattern = stats.oldLines;
2083
- let newLinesPattern = stats.newLines;
2084
- let oldStartIdx;
2085
- let trimmedTrailing = 0;
2086
- let trimmedTrailingNew = 0;
2087
- if (stats.oldCount === 0) {
2088
- oldStartIdx = eof ? sourceLines.length : anchorLine;
2089
- } else {
2090
- const searchFrom = Math.max(0, anchorLine - 1);
2091
- oldStartIdx = findLineSequence(
2092
- sourceLines,
2093
- oldLinesPattern,
2094
- searchFrom,
2095
- searchFrom,
2096
- { fuzzy, eof },
2097
- );
2098
- if (eof && oldStartIdx < 0 && oldLinesPattern.length > 0 && oldLinesPattern[oldLinesPattern.length - 1] === '') {
2099
- oldLinesPattern = oldLinesPattern.slice(0, -1);
2100
- trimmedTrailing = 1;
2101
- if (newLinesPattern.length > 0 && newLinesPattern[newLinesPattern.length - 1] === '') {
2102
- newLinesPattern = newLinesPattern.slice(0, -1);
2103
- trimmedTrailingNew = 1;
2104
- }
2105
- oldStartIdx = findLineSequence(
2106
- sourceLines,
2107
- oldLinesPattern,
2108
- searchFrom,
2109
- searchFrom,
2110
- { fuzzy, eof },
2111
- );
2112
- }
2113
- }
2114
- // Escape-equivalence fallback (fuzzy, non-EOF only): accept a window where each old
2115
- // line matches the source verbatim OR as the file's literal `\uXXXX` escape
2116
- // of the patch's real character. On match, remap old/context lines to the
2117
- // file's on-disk form so untouched context stays byte-identical and the
2118
- // escape representation survives the edit.
2119
- if (oldStartIdx < 0 && fuzzy && !eof && oldLinesPattern.length > 0) {
2120
- const from = Math.max(0, anchorLine - 1);
2121
- const alt = findLineSequenceEscapeEquiv(sourceLines, oldLinesPattern, from, from);
2122
- if (alt >= 0) {
2123
- const remapped = new Map();
2124
- // Text-keyed remap is only safe when unambiguous: if the SAME patch
2125
- // line text maps to DIFFERENT on-disk forms at different window
2126
- // positions (one verbatim, one escaped), rewriting newLines by text
2127
- // would corrupt an untouched context line — reject the match instead.
2128
- let ambiguous = false;
2129
- for (let k = 0; k < oldLinesPattern.length; k++) {
2130
- const pat = oldLinesPattern[k];
2131
- const src = sourceLines[alt + k];
2132
- if (remapped.has(pat) && remapped.get(pat) !== src) { ambiguous = true; break; }
2133
- remapped.set(pat, src);
2134
- }
2135
- if (!ambiguous) {
2136
- newLinesPattern = newLinesPattern.map((l) => remapped.get(l) ?? l);
2137
- oldLinesPattern = oldLinesPattern.map((_, k) => sourceLines[alt + k]);
2138
- oldStartIdx = alt;
2139
- }
2140
- }
2141
- }
2142
- if (oldStartIdx < 0) {
2143
- const msg = `V4A hunk context not found: ${formatV4AHunkLocator(hunk)};${formatV4AContextMissHint(sourceLines, stats, anchorLine)}`;
2144
- return { error: msg };
2145
- }
2146
- const matchLen = stats.oldCount === 0 ? 0 : oldLinesPattern.length;
2147
- return {
2148
- oldStartIdx,
2149
- matchLen,
2150
- newLines: newLinesPattern,
2151
- nextSearchLine: oldStartIdx + Math.max(1, matchLen),
2152
- trimmedTrailing,
2153
- trimmedTrailingNew,
2154
- };
2155
- }
2156
-
2157
- function applyV4AHunksToLines(sourceLines, hunks, options = {}) {
2158
- const lines = cloneTextLinesForPatch(sourceLines);
2159
- const orderedHunks = orderV4AHunksByFilePosition(lines, hunks, options.fuzzy !== false);
2160
- let nextSearchLine = 0;
2161
- const replacements = [];
2162
- for (const hunk of orderedHunks) {
2163
- const loc = resolveV4AHunkPosition(lines, hunk, nextSearchLine, options);
2164
- if (loc.skip) continue;
2165
- if (loc.error) throw new Error(loc.error);
2166
- replacements.push({
2167
- oldStartIdx: loc.oldStartIdx,
2168
- oldLen: loc.matchLen,
2169
- newLines: loc.newLines,
2170
- });
2171
- nextSearchLine = loc.nextSearchLine;
2172
- }
2173
- for (const rep of replacements.reverse()) {
2174
- lines.splice(rep.oldStartIdx, rep.oldLen, ...rep.newLines);
2175
- }
2176
- return lines;
2177
- }
2178
-
2179
- // Order-independent hunk ordering for the V4A apply / V4A→unified conversion.
2180
- // V4A hunks carry no line numbers and may be authored out of file order (a
2181
- // later edit's hunk listed before an earlier one) or against pre-shift line
2182
- // numbers. The cursor loops that consume hunks locate each one with a
2183
- // forward-only `nextSearchLine`, which rejects an out-of-order hunk even when
2184
- // its context is uniquely present ("context not found; nearest line N").
2185
- //
2186
- // Two-phase, semantics-preserving:
2187
- // Phase 1 — replay the SAME forward-cursor over the input order. If every
2188
- // hunk resolves, the existing cursor semantics are authoritative — they
2189
- // own duplicate-context AND insert-only @@-anchor disambiguation (a later
2190
- // hunk binds to the NEXT matching occurrence after the previous hunk), so
2191
- // we return the hunks UNCHANGED. An already-in-order patch is a guaranteed
2192
- // no-op; nothing about the existing behaviour shifts.
2193
- // Phase 2 (invariant-based recovery) — only when the input order is NOT
2194
- // forward-locatable (a hunk targets a position before a prior hunk).
2195
- // Reorder a hunk ONLY when its old-block (context+delete body lines)
2196
- // appears EXACTLY ONCE in the source as a literal line sequence — that
2197
- // hunk then has a single order-independent position. If ANY hunk is
2198
- // insert-only (no old body) or its old-block is absent / appears more than
2199
- // once (cursor-sensitive), reordering is unsafe: return the input order
2200
- // unchanged so the loop surfaces the original error instead of binding to
2201
- // the wrong occurrence. Direct literal counting (NOT resolveV4AHunkPosition)
2202
- // sidesteps the anchor/cursor/EOF off-by-one quirks of a re-probe.
2203
- function orderV4AHunksByFilePosition(sourceLines, hunks, fuzzy) {
2204
- const list = hunks || [];
2205
- if (list.length <= 1) return list;
2206
- // Phase 1: is the input order already forward-locatable? Mirror the
2207
- // conversion/apply loop's `nextSearchLine` advance exactly.
2208
- let nextSearchLine = 0;
2209
- let inputOrderValid = true;
2210
- for (const hunk of list) {
2211
- const stats = v4AHunkLineStats(hunk);
2212
- if (stats.oldCount === 0 && stats.newCount === 0) continue;
2213
- let loc;
2214
- try { loc = resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, { fuzzy }); }
2215
- catch { loc = { error: true }; }
2216
- if (!loc || loc.error || loc.skip || typeof loc.nextSearchLine !== 'number') {
2217
- inputOrderValid = false;
2218
- break;
2219
- }
2220
- nextSearchLine = loc.nextSearchLine;
2221
- }
2222
- if (inputOrderValid) return list;
2223
- // Phase 2: reorder only hunks whose old-block is a UNIQUE literal sequence.
2224
- const keyed = [];
2225
- for (let idx = 0; idx < list.length; idx++) {
2226
- const hunk = list[idx];
2227
- const stats = v4AHunkLineStats(hunk);
2228
- if (stats.oldCount === 0 && stats.newCount === 0) {
2229
- keyed.push({ hunk, key: Number.MAX_SAFE_INTEGER, idx });
2230
- continue;
2231
- }
2232
- // Old-block = context + delete body lines (prefix-stripped), excluding the
2233
- // EOF marker. Empty = insert-only → no order-independent position.
2234
- const seq = [];
2235
- for (const ln of hunk.lines || []) {
2236
- if (isV4AEndOfFileMarker(ln)) continue;
2237
- const p = ln[0];
2238
- if (p === ' ' || p === '-') seq.push(ln.slice(1));
2239
- }
2240
- if (seq.length === 0) return list;
2241
- // Count exact file-wide occurrences (early-out at 2). Must be exactly one.
2242
- let pos = -1;
2243
- let count = 0;
2244
- for (let i = 0; i + seq.length <= sourceLines.length; i++) {
2245
- let match = true;
2246
- for (let j = 0; j < seq.length; j++) {
2247
- if (sourceLines[i + j] !== seq[j]) { match = false; break; }
2248
- }
2249
- if (match) {
2250
- if (pos < 0) pos = i;
2251
- count++;
2252
- if (count >= 2) break;
2253
- }
2254
- }
2255
- if (count !== 1) return list;
2256
- keyed.push({ hunk, key: pos, idx });
2257
- }
2258
- keyed.sort((a, b) => (a.key - b.key) || (a.idx - b.idx));
2259
- return keyed.map((e) => e.hunk);
2260
- }
2261
-
2262
- function isV4ARenameSection(section) {
2263
- return section?.kind === 'update' && !!section?.movePath;
2264
- }
2265
-
2266
- function v4aRenamePathKey(absPath) {
2267
- return process.platform === 'win32' ? String(absPath || '').toLowerCase() : String(absPath || '');
2268
- }
2269
-
2270
- function v4aRenamePathInsideRealBase(absPath, realBase) {
2271
- const checkReal = realpathNearestExistingAncestor(absPath);
2272
- const checkRel = pathRelative(realBase, checkReal);
2273
- if (!checkRel || checkRel.startsWith('..') || isAbsolute(checkRel)) return false;
2274
- return !checkRel.split(/[\\/]+/).some((part) => part === '..');
2275
- }
2276
-
2277
- function validateV4ARenameSection(section, basePath, seenDestKeys, realBase) {
2278
- const escapeErr = v4aSectionPathEscapeError(section, basePath);
2279
- if (escapeErr) return escapeErr;
2280
- const escapeDest = v4aSectionPathEscapeError({ path: section.movePath }, basePath);
2281
- if (escapeDest) return escapeDest;
2282
- const srcFull = resolveV4AEntryPath(basePath, section.path);
2283
- const destFull = resolveV4AEntryPath(basePath, section.movePath);
2284
- if (realBase) {
2285
- if (!v4aRenamePathInsideRealBase(srcFull, realBase)) {
2286
- return `apply_patch: ${normalizeOutputPath(section.path)} resolves outside base_path via symlink/junction; refusing V4A rename.`;
2287
- }
2288
- if (!v4aRenamePathInsideRealBase(destFull, realBase)) {
2289
- return `apply_patch: ${normalizeOutputPath(section.movePath)} resolves outside base_path via symlink/junction; refusing V4A rename.`;
2290
- }
2291
- }
2292
- if (v4aRenamePathKey(srcFull) === v4aRenamePathKey(destFull)) {
2293
- return `apply_patch: V4A rename source and destination are the same path (${normalizeOutputPath(section.path)})`;
2294
- }
2295
- const destKey = v4aRenamePathKey(destFull);
2296
- if (seenDestKeys.has(destKey)) {
2297
- return `apply_patch: duplicate V4A rename destination ${normalizeOutputPath(section.movePath)}`;
2298
- }
2299
- seenDestKeys.add(destKey);
2300
- try {
2301
- const st = statSync(srcFull);
2302
- if (!st.isFile()) {
2303
- return `apply_patch: V4A rename source is not a regular file: ${normalizeOutputPath(section.path)}`;
2304
- }
2305
- } catch (err) {
2306
- return `apply_patch: V4A rename source missing or unreadable: ${normalizeOutputPath(section.path)} (${err?.code || err?.message || String(err)})`;
2307
- }
2308
- try {
2309
- const destSt = statSync(destFull);
2310
- if (destSt.isDirectory()) {
2311
- return `apply_patch: V4A rename destination is a directory: ${normalizeOutputPath(section.movePath)}`;
2312
- }
2313
- if (!destSt.isFile()) {
2314
- return `apply_patch: V4A rename destination is not a regular file: ${normalizeOutputPath(section.movePath)}`;
2315
- }
2316
- } catch (err) {
2317
- if (err?.code !== 'ENOENT') {
2318
- return `apply_patch: V4A rename destination unreadable: ${normalizeOutputPath(section.movePath)} (${err?.code || err?.message || String(err)})`;
2319
- }
2320
- }
2321
- if (!section.hunks?.length) {
2322
- return `apply_patch: V4A rename for ${normalizeOutputPath(section.path)} has no update hunks`;
2323
- }
2324
- return null;
2325
- }
2326
-
2327
- async function applyV4ARenameSection(section, basePath, options = {}) {
2328
- const srcFull = resolveV4AEntryPath(basePath, section.path);
2329
- const destFull = resolveV4AEntryPath(basePath, section.movePath);
2330
- const displaySrc = normalizeOutputPath(section.path);
2331
- const displayDest = normalizeOutputPath(section.movePath);
2332
- let sourceLines;
2333
- try {
2334
- sourceLines = v4aConversionSourceLines(srcFull, options.linesCache || new Map());
2335
- } catch (err) {
2336
- throw new Error(`apply_patch: V4A rename source unreadable: ${displaySrc} (${err?.code || err?.message || String(err)})`);
2337
- }
2338
- let updatedLines;
2339
- try {
2340
- updatedLines = applyV4AHunksToLines(sourceLines, section.hunks, options);
2341
- } catch (err) {
2342
- throw err;
2343
- }
2344
- const newContent = joinTextLinesForPatch(updatedLines);
2345
- if (options.dryRun) {
2346
- return {
2347
- ok: true,
2348
- dryRun: true,
2349
- displayPath: displayDest,
2350
- linesChanged: section.hunks.reduce((n, h) => n + (h.lines?.length || 0), 0),
2351
- srcFull,
2352
- destFull,
2353
- };
2354
- }
2355
- const originalContent = readFileSync(srcFull);
2356
- let destBefore = null;
2357
- try {
2358
- destBefore = readFileSync(destFull);
2359
- } catch (err) {
2360
- if (err?.code !== 'ENOENT') throw err;
2361
- }
2362
- mkdirSync(pathDirname(destFull), { recursive: true });
2363
- try {
2364
- await atomicWrite(destFull, newContent, { sessionId: options.readStateScope });
2365
- await unlink(srcFull);
2366
- } catch (err) {
2367
- try {
2368
- if (destBefore === null) {
2369
- try { await unlink(destFull); } catch {}
2370
- } else {
2371
- await atomicWrite(destFull, destBefore, { sessionId: options.readStateScope });
2372
- }
2373
- } catch {}
2374
- try {
2375
- await atomicWrite(srcFull, originalContent, { sessionId: options.readStateScope });
2376
- } catch {}
2377
- throw new Error(`apply_patch: V4A rename failed for ${displaySrc} → ${displayDest} (${err?.message || String(err)})`);
2378
- }
2379
- invalidateBuiltinResultCache([srcFull, destFull]);
2380
- markCodeGraphDirtyPaths([srcFull, destFull]);
2381
- clearReadSnapshotForPath(srcFull, options.readStateScope);
2382
- clearReadSnapshotForPath(destFull, options.readStateScope);
2383
- return {
2384
- ok: true,
2385
- displayPath: displayDest,
2386
- fromPath: displaySrc,
2387
- linesChanged: section.hunks.reduce((n, h) => n + (h.lines?.length || 0), 0),
2388
- srcFull,
2389
- destFull,
2390
- };
2391
- }
2392
-
2393
- function formatV4ARenameSuccessLines(results) {
2394
- return (results || [])
2395
- .filter((r) => r?.ok && !r.skipped)
2396
- .map((r) => `OK ${r.displayPath} (renamed from ${r.fromPath}, ~${r.linesChanged} lines touched, engine=v4a-rename)`);
2397
- }
2398
-
2399
- async function planV4ARenameSections(sections, basePath) {
2400
- const renameSections = (sections || []).filter(isV4ARenameSection);
2401
- const remainingSections = (sections || []).filter((s) => !isV4ARenameSection(s));
2402
- if (renameSections.length === 0) {
2403
- return { renameSections: [], remainingSections };
2404
- }
2405
- if (renameSections.length > 1) {
2406
- throw new Error('apply_patch: only one V4A rename (*** Move to:) per patch is supported; split into separate patches.');
2407
- }
2408
- if (remainingSections.length > 0) {
2409
- throw new Error('apply_patch: V4A rename cannot be combined with other add/update/delete sections in the same patch; apply file edits in a separate patch first.');
2410
- }
2411
- await assertPathReachable(basePath);
2412
- const renameReachPaths = renameSections.flatMap((section) => [
2413
- resolveV4AEntryPath(basePath, section.path),
2414
- resolveV4AEntryPath(basePath, section.movePath),
2415
- ]);
2416
- await assertPathsReachable(renameReachPaths);
2417
- let realBase;
2418
- try {
2419
- realBase = realpathSync(pathResolve(basePath));
2420
- } catch (err) {
2421
- throw new Error(`apply_patch: base_path unreadable (${err?.code || err?.message || String(err)}): ${basePath}`);
2422
- }
2423
- const seenDestKeys = new Set();
2424
- for (const section of renameSections) {
2425
- const errText = validateV4ARenameSection(section, basePath, seenDestKeys, realBase);
2426
- if (errText) throw new Error(errText);
2427
- }
2428
- return {
2429
- renameSections,
2430
- remainingSections,
2431
- };
2432
- }
2433
-
2434
- async function applyV4ARenameSections(renameSections, basePath, options = {}) {
2435
- const linesCache = new Map();
2436
- const results = [];
2437
- for (const section of renameSections || []) {
2438
- results.push(await applyV4ARenameSection(section, basePath, { ...options, linesCache }));
2439
- }
2440
- return results;
2441
- }
2442
-
2443
- function convertUnifiedBareV4AToUnifiedPatch(patchStr, basePath, options = {}) {
2444
- return convertV4ASectionsToUnifiedPatch(parseUnifiedBareV4APatch(patchStr), basePath, options);
2445
- }
2446
-
2447
- function convertUnifiedCountedToUnifiedPatchViaV4A(patchStr, basePath, options = {}) {
2448
- return convertV4ASectionsToUnifiedPatch(parseUnifiedCountedAsV4APatch(patchStr), basePath, options);
2449
- }
2450
-
2451
- // Lexical path-escape guard for V4A section paths. Mirrors the check
2452
- // `nativeHeaderSupported` runs on unified headers: a `..` segment or an
2453
- // absolute path that does not resolve inside basePath is unsupported.
2454
- // Run BEFORE the V4A readFileSync so an escape surfaces with a clear
2455
- // reason instead of masquerading as an ENOENT "update target unreadable".
2456
- function v4aSectionPathEscapeError(section, basePath) {
2457
- const raw = section?.path;
2458
- if (!raw) return null;
2459
- const norm = normalizeInputPath(raw);
2460
- const segs = norm.split(/[\\/]+/);
2461
- if (segs.some((part) => part === '..')) {
2462
- return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
2463
- }
2464
- if (isAbsolute(norm) || /^[A-Za-z]:[\\/]/.test(norm)) {
2465
- if (!basePath) return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
2466
- const absHeader = pathResolve(norm);
2467
- const absBase = pathResolve(basePath);
2468
- const rel = pathRelative(absBase, absHeader);
2469
- if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(/[\\/]+/).some((part) => part === '..')) {
2470
- return `apply_patch: header ${normalizeOutputPath(raw)} is unsupported (path escapes base_path or contains \`..\`).`;
2471
- }
2472
- }
2473
- return null;
2474
- }
2475
-
2476
- function readRawBufForV4AConversion(fullPath) {
2477
- // Fresh statSync (NOT the 5s STAT_CACHE) for raw-cache generation validation:
2478
- // an external modify/delete that bypasses invalidateBuiltinResultCache could
2479
- // otherwise let a stale STAT_CACHE entry match stale raw bytes, anchoring V4A
2480
- // hunks on out-of-date source. Fresh stat is cheap and keeps the byte-read
2481
- // savings on the unchanged common path while rejecting stale generations.
2482
- const st = statSync(fullPath);
2483
- const cached = rawContentCacheGet(fullPath, st);
2484
- if (cached) return cached;
2485
- const rawBuf = readFileSync(fullPath);
2486
- const buf = Buffer.isBuffer(rawBuf) ? rawBuf : Buffer.from(rawBuf);
2487
- rawContentCacheSet(fullPath, st, buf);
2488
- return buf;
2489
- }
2490
-
2491
- function v4aConversionSourceLines(fullPath, linesCache) {
2492
- if (linesCache.has(fullPath)) return linesCache.get(fullPath);
2493
- const lines = splitTextLinesForPatch(readRawBufForV4AConversion(fullPath).toString('utf-8'));
2494
- linesCache.set(fullPath, lines);
2495
- return lines;
2496
- }
2497
-
2498
- // options.rejectPartial (default true)
2499
- // true — anchor/context miss on any hunk throws and aborts the whole patch
2500
- // false — hunk-level isolation in the V4A→unified conversion: a hunk
2501
- // whose anchor/context cannot be located is dropped; the rest of
2502
- // the file's hunks continue. A file section whose hunks all fail
2503
- // emits no header so the downstream unified-diff parser does not
2504
- // see an empty section. Dropped hunks are appended to
2505
- // options.rejectedHunks for the caller to surface.
2506
- async function convertV4ASectionsToUnifiedPatch(sections, basePath, options = {}) {
2507
- // Reachability preflight for update/delete targets: v4aConversionSourceLines
2508
- // does statSync/readFileSync on each non-add section's source file. A
2509
- // dead-mounted target under a reachable basePath would freeze the event loop
2510
- // here, before preValidateNativeBatch's guard. resolveEntryPath is FS-pure.
2511
- {
2512
- const reachPaths = [];
2513
- const _seenReach = new Set();
2514
- for (const s of (sections || [])) {
2515
- if (!s || s.kind === 'add' || typeof s.path !== 'string' || !s.path) continue;
2516
- const fp = resolveV4AEntryPath(basePath, s.path);
2517
- if (_seenReach.has(fp)) continue;
2518
- _seenReach.add(fp);
2519
- reachPaths.push(fp);
2520
- }
2521
- await assertPathsReachable(reachPaths);
2522
- }
2523
- const rejectPartial = options.rejectPartial !== false;
2524
- const rejectedHunks = Array.isArray(options.rejectedHunks) ? options.rejectedHunks : null;
2525
- const fuzzy = options.fuzzy !== false;
2526
- const out = [];
2527
- const v4aLinesCache = new Map();
2528
- for (const section of sections) {
2529
- // Explicit path-escape guard runs BEFORE any readFileSync attempt so
2530
- // a header containing `..` or an out-of-base absolute path surfaces
2531
- // with a clear reason instead of being masked as ENOENT (the V4A
2532
- // "update target unreadable" path) when the escaped target doesn't
2533
- // happen to exist on disk.
2534
- const escapeErr = v4aSectionPathEscapeError(section, basePath);
2535
- if (escapeErr) throw new Error(escapeErr);
2536
- const displayPath = section.path.replace(/\\/g, '/');
2537
- if (section.kind === 'add') {
2538
- out.push('--- /dev/null');
2539
- out.push(`+++ b/${displayPath}`);
2540
- out.push(`@@ -0,0 +1,${section.lines.length} @@`);
2541
- for (const line of section.lines) out.push(`+${line}`);
2542
- continue;
2543
- }
2544
- if (section.kind === 'delete') {
2545
- const fullPath = resolveV4AEntryPath(basePath, section.path);
2546
- let fileLines = [];
2547
- try {
2548
- // Non-UTF-8 targets (UTF-16 BOM / binary) cannot round-trip through
2549
- // decoded `-` lines — the native byte compare rejects the hunk and the
2550
- // file becomes UNDELETABLE through apply_patch. A delete needs no
2551
- // content match; emit the header-only form (already the unreadable-
2552
- // file shape below) and let the engine remove the file by intent.
2553
- const _delRaw = readFileSync(fullPath);
2554
- // decodeValidUtf8OrNull (fatal TextDecoder) instead of Buffer.isUtf8:
2555
- // the daemon may run under a runtime where Buffer.isUtf8 is absent,
2556
- // and a missing-API fallback of "assume UTF-8" silently re-enables
2557
- // the content hunks this gate exists to suppress.
2558
- if (decodeValidUtf8OrNull(_delRaw) !== null) {
2559
- fileLines = v4aConversionSourceLines(fullPath, v4aLinesCache);
2560
- }
2561
- } catch {
2562
- fileLines = [];
2563
- }
2564
- out.push(`--- a/${displayPath}`);
2565
- out.push('+++ /dev/null');
2566
- if (fileLines.length > 0) {
2567
- out.push(`@@ -1,${fileLines.length} +0,0 @@`);
2568
- for (const line of fileLines) out.push(`-${line}`);
2569
- }
2570
- continue;
2571
- }
2572
-
2573
- const fullPath = resolveV4AEntryPath(basePath, section.path);
2574
- let sourceLines;
2575
- try {
2576
- sourceLines = v4aConversionSourceLines(fullPath, v4aLinesCache);
2577
- } catch (err) {
2578
- throw new Error(`V4A update target unreadable: ${section.path} (${err?.code || err?.message || String(err)}).`);
2579
- }
2580
- const sectionHunks = [];
2581
- const orderedHunks = orderV4AHunksByFilePosition(sourceLines, section.hunks, fuzzy);
2582
- let nextSearchLine = 0;
2583
- for (const hunk of orderedHunks) {
2584
- const stats = v4AHunkLineStats(hunk);
2585
- if (stats.oldCount === 0 && stats.newCount === 0) continue;
2586
- const loc = resolveV4AHunkPosition(sourceLines, hunk, nextSearchLine, { fuzzy });
2587
- if (loc.skip) continue;
2588
- if (loc.error) {
2589
- const msg = `${loc.error.replace(/^V4A hunk /, `V4A hunk ${section.path}: `)}`;
2590
- if (rejectPartial) throw new Error(msg);
2591
- if (rejectedHunks) rejectedHunks.push({ file: section.path, hunk, reason: msg });
2592
- continue;
2593
- }
2594
- const oldStart = stats.oldCount === 0 ? loc.oldStartIdx : loc.oldStartIdx + 1;
2595
- const newStart = oldStart;
2596
- const tail = (hunk.anchors || []).filter(Boolean).join(' ');
2597
- const oldCount = stats.oldCount === 0 ? 0 : loc.matchLen;
2598
- const newCount = stats.newCount - (loc.trimmedTrailingNew || 0);
2599
- sectionHunks.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${tail ? ` ${tail}` : ''}`);
2600
- // EOF-trim: resolveV4AHunkPosition dropped the trailing empty line from
2601
- // oldLinesPattern (and optionally newLinesPattern). Drop the matching
2602
- // trailing body line(s) so old/new body counts equal the header counts.
2603
- let dropOldAt = -1;
2604
- let dropNewAt = -1;
2605
- if (loc.trimmedTrailing) {
2606
- for (let i = hunk.lines.length - 1; i >= 0; i--) {
2607
- const ln = hunk.lines[i];
2608
- if (isV4AEndOfFileMarker(ln)) continue;
2609
- const p = ln[0];
2610
- if (dropOldAt < 0 && (p === ' ' || p === '-')) dropOldAt = i;
2611
- if (dropNewAt < 0 && loc.trimmedTrailingNew && (p === ' ' || p === '+')) dropNewAt = i;
2612
- if (dropOldAt >= 0 && (!loc.trimmedTrailingNew || dropNewAt >= 0)) break;
2613
- }
2614
- }
2615
- let srcIdx = loc.oldStartIdx;
2616
- const srcEnd = loc.oldStartIdx + loc.matchLen;
2617
- for (let i = 0; i < hunk.lines.length; i++) {
2618
- const line = hunk.lines[i];
2619
- if (isV4AEndOfFileMarker(line)) continue;
2620
- const prefix = line[0];
2621
- if (prefix === ' ' || prefix === '-') {
2622
- if (i === dropOldAt || i === dropNewAt) continue;
2623
- if (srcIdx < srcEnd && srcIdx < sourceLines.length) {
2624
- sectionHunks.push(prefix + sourceLines[srcIdx]);
2625
- } else {
2626
- sectionHunks.push(line);
2627
- }
2628
- srcIdx++;
2629
- } else {
2630
- if (i === dropNewAt) continue;
2631
- sectionHunks.push(line);
2632
- }
2633
- }
2634
- nextSearchLine = loc.nextSearchLine;
2635
- }
2636
- if (sectionHunks.length > 0) {
2637
- out.push(`--- a/${displayPath}`);
2638
- out.push(`+++ b/${displayPath}`);
2639
- for (const line of sectionHunks) out.push(line);
2640
- }
2641
- }
2642
- return out.join('\n') + '\n';
2643
- }
2644
-
2645
- // Native-only apply_patch entry point.
2646
- // - Pre-validates security (path-escape, symlink-escape, duplicates).
2647
- // - V4A / unified-bare V4A inputs are converted to standard unified first.
2648
- // - parsePatch errors / unsupported headers / missing binary throw clean
2649
- // Error strings — they DO NOT fall back to a JS engine.
2650
- // - Native engine handles fuzz / reject_partial / hunkless-delete /
2651
- // zero-length-delete entirely. fuzzy:false → fuzz 0 (strict), else 2.
2652
- // - On OK the response includes a per-entry success line + native trace.
2653
- // - On OK_PARTIAL the response prefix is "Error: patch partially applied"
2654
- // and per-entry SKIP lines surface the Rust failure reasons.
2655
- // Some providers (notably grok-composer) serialize a multi-line V4A `patch`
2656
- // argument as a flat key:value object: the `*** Update File: <path>` header's
2657
- // colon-space plus the newlines make the tool-arg decoder split each patch line
2658
- // into alternating keys/values, so `patch` arrives as just "*** Begin Patch"
2659
- // and the real body leaks into stray top-level keys. Rebuild the original by
2660
- // re-joining the patch value with the stray entries in insertion order; the V4A
2661
- // parser + native engine then apply it normally. The trigger is tight (an
2662
- // incomplete `*** Begin Patch` opener with no newline, plus keys outside the
2663
- // schema) so well-formed calls are untouched. Keys on the shape, not the model.
2664
- const APPLY_PATCH_SCHEMA_KEYS = new Set(['patch', 'format', 'base_path', 'dry_run', 'reject_partial', 'fuzzy']);
2665
- function salvageShatteredV4APatchArgs(args) {
2666
- if (!args || typeof args !== 'object') return args;
2667
- const rawPatch = typeof args.patch === 'string' ? args.patch : '';
2668
- if (!rawPatch.startsWith('*** Begin Patch') || rawPatch.includes('\n') || rawPatch.includes('*** End Patch')) return args;
2669
- const stray = Object.keys(args).filter((k) => !APPLY_PATCH_SCHEMA_KEYS.has(k));
2670
- if (stray.length === 0) return args;
2671
- const lines = [rawPatch];
2672
- for (const key of Object.keys(args)) {
2673
- if (APPLY_PATCH_SCHEMA_KEYS.has(key)) continue;
2674
- lines.push(key);
2675
- lines.push(String(args[key] ?? ''));
2676
- }
2677
- while (lines.length && lines[lines.length - 1] === '') lines.pop();
2678
- const cleaned = {};
2679
- for (const key of Object.keys(args)) if (APPLY_PATCH_SCHEMA_KEYS.has(key)) cleaned[key] = args[key];
2680
- cleaned.patch = lines.join('\n');
2681
- return cleaned;
2682
- }
2683
- async function apply_patch(args, cwd, options = {}) {
2684
- args = salvageShatteredV4APatchArgs(args);
2685
- // Strip a leading UTF-8 BOM up-front: editors / PowerShell redirections
2686
- // sometimes prepend `\uFEFF` to text files and the bare BOM trips the
2687
- // unified envelope check.
2688
- const patchStr = (typeof args?.patch === 'string' ? args.patch : '').replace(/^\uFEFF/, '');
2689
- if (!patchStr.trim()) {
2690
- throw new Error('apply_patch: "patch" is required (unified diff or V4A patch string)');
2691
- }
2692
- const requestedFormat = String(args?.format || '').toLowerCase();
2693
- if (requestedFormat && requestedFormat !== 'unified' && requestedFormat !== 'v4a') {
2694
- throw new Error('apply_patch: "format" must be "unified" or "v4a"');
2695
- }
2696
- let mutationPlan = options?.mutationPlan || planApplyPatchMutationRoute(args, patchStr, requestedFormat);
2697
- const readStateScope = options?.readStateScope ?? options?.sessionId ?? null;
2698
- let abortSignal = options?.signal || options?.abortSignal || null;
2699
- if (!abortSignal && options?.sessionId) {
2700
- try { abortSignal = await getAbortSignalForSession(options.sessionId); } catch { abortSignal = null; }
2701
- }
2702
- if (abortSignal?.aborted) {
2703
- throw new Error(abortSignal.reason?.message || abortSignal.reason || 'apply_patch aborted');
2704
- }
2705
- const basePath = resolveBasePath(cwd, args?.base_path);
2706
- try {
2707
- await assertPathReachable(basePath);
2708
- } catch (err) {
2709
- return `Error: ${err?.message || String(err)}`;
2710
- }
2711
- // Default true — file-batch atomic. reject_partial:false unlocks the
2712
- // native engine's OK_PARTIAL isolation mode.
2713
- const rejectPartial = args?.reject_partial !== false;
2714
- const dryRun = args?.dry_run === true;
2715
- const fuzzy = args?.fuzzy !== false;
2716
- // fuzzy:false → strict context match (fuzz 0); else allow 2 lines of
2717
- // outer-context drift and ignore context trailing spaces/tabs. The same
2718
- // fuzz value is forwarded to the V4A line-sequence search so both layers agree.
2719
- const fuzz = fuzzy ? 2 : 0;
2720
-
2721
- // V4A → unified conversion (in JS). Hunk anchor/context miss in the
2722
- // conversion stage surfaces a clean Error — no JS apply fallback.
2723
- let inputPatchStr = patchStr;
2724
- const rejectedV4AHunks = [];
2725
- const v4aConvertOpts = { rejectPartial, rejectedHunks: rejectedV4AHunks, fuzzy, dryRun, readStateScope };
2726
- let v4aRenamePlan = null;
2727
- if (isV4APatchInput(patchStr, requestedFormat)) {
2728
- try {
2729
- const allSections = parseV4APatch(patchStr);
2730
- v4aRenamePlan = await planV4ARenameSections(allSections, basePath);
2731
- inputPatchStr = await convertV4ASectionsToUnifiedPatch(v4aRenamePlan.remainingSections, basePath, v4aConvertOpts);
2732
- if (v4aRenamePlan.renameSections.length > 0) {
2733
- mutationPlan = v4aRenamePlan.remainingSections.length > 0
2734
- ? { sourceTool: 'apply_patch', engine: 'v4a-patch', reason: 'v4a-mixed' }
2735
- : { sourceTool: 'apply_patch', engine: 'v4a-rename', reason: 'v4a-move' };
2736
- }
2737
- } catch (err) {
2738
- throw new Error(`apply_patch: V4A parse failed — ${err?.message || String(err)}`);
2739
- }
2740
- } else if (requestedFormat !== 'unified' && hasUnifiedBareV4AHunk(patchStr)) {
2741
- try {
2742
- inputPatchStr = await convertUnifiedBareV4AToUnifiedPatch(patchStr, basePath, v4aConvertOpts);
2743
- } catch (err) {
2744
- throw new Error(`apply_patch: bare @@ parse failed — ${err?.message || String(err)}`);
2745
- }
2746
- }
2747
- let normalizedPatchStr = prepareInput(inputPatchStr);
2748
- const v4aRenameOnly = v4aRenamePlan?.renameSections?.length > 0 && v4aRenamePlan.remainingSections.length === 0;
2749
-
2750
- // parsePatch remains strict. In auto mode only, counted unified diffs
2751
- // with bad @@ counts can be reinterpreted through the V4A converter so
2752
- // exact old/context lines are still verified before native apply.
2753
- let parsed = [];
2754
- if (!v4aRenameOnly) try {
2755
- parsed = parsePatch(normalizedPatchStr);
2756
- } catch (err) {
2757
- if (!canFallbackCountedUnified(patchStr, requestedFormat, err)) {
2758
- throw new Error(`apply_patch: parse failed — ${err?.message || String(err)}; prefer V4A envelope for multi-hunk edits (no @@ line counts)`);
2759
- }
2760
- try {
2761
- inputPatchStr = await convertUnifiedCountedToUnifiedPatchViaV4A(patchStr, basePath, v4aConvertOpts);
2762
- normalizedPatchStr = prepareInput(inputPatchStr);
2763
- parsed = parsePatch(normalizedPatchStr);
2764
- mutationPlan = {
2765
- sourceTool: 'apply_patch',
2766
- engine: 'v4a-patch',
2767
- reason: 'unified-count-fallback',
2768
- };
2769
- } catch (fallbackErr) {
2770
- throw new Error(`apply_patch: parse failed — ${err?.message || String(err)}; V4A fallback failed — ${fallbackErr?.message || String(fallbackErr)}`);
2771
- }
2772
- }
2773
- if (!v4aRenameOnly && (!Array.isArray(parsed) || parsed.length === 0)) {
2774
- return 'Error: patch contained no file sections';
2775
- }
2776
- if (!v4aRenameOnly) {
2777
- const merged = mergeDuplicateParsedModifyEntries(parsed, basePath);
2778
- if (merged.changed) {
2779
- parsed = merged.parsed;
2780
- normalizedPatchStr = renderParsedUnifiedPatch(parsed);
2781
- }
2782
- }
2783
-
2784
- // Pre-validate paths / duplicates / symlink escapes — throws on any
2785
- // unsupported entry. Throws bubble out to the tool dispatcher as clean
2786
- // "Error: ..." strings.
2787
- if (!v4aRenameOnly) {
2788
- try {
2789
- await ensureNativePatchBinaryAvailable();
2790
- } catch (err) {
2791
- return `Error: ${err?.message || String(err)}`;
2792
- }
2793
- }
2794
- let entries = [];
2795
- let headerRewrites = [];
2796
- if (!v4aRenameOnly) {
2797
- try {
2798
- ({ entries, headerRewrites } = await preValidateNativeBatch(parsed, basePath));
2799
- } catch (err) {
2800
- return `Error: ${err?.message || String(err)}`;
2801
- }
2802
- }
2803
-
2804
- const _lockPaths = [
2805
- ...entries.map((entry) => entry.fullPath),
2806
- ...(v4aRenamePlan?.renameSections || []).flatMap((section) => {
2807
- const src = resolveV4AEntryPath(basePath, section.path);
2808
- const dest = resolveV4AEntryPath(basePath, section.movePath);
2809
- return [src, dest];
2810
- }),
2811
- ];
2812
-
2813
- return withBuiltinPathLocks(_lockPaths, () =>
2814
- withAdvisoryLocks(_lockPaths, async () => {
2815
- let v4aRenameResults = [];
2816
- if (v4aRenamePlan?.renameSections?.length) {
2817
- v4aRenameResults = await applyV4ARenameSections(v4aRenamePlan.renameSections, basePath, v4aConvertOpts);
2818
- }
2819
- if (v4aRenameOnly) {
2820
- const lines = formatV4ARenameSuccessLines(v4aRenameResults);
2821
- if (lines.length === 0) return 'Error: patch contained no applicable file sections';
2822
- return wrapPatchMutationOutput(`${lines.join('\n')}\n`, mutationPlan, { backend: 'v4a-rename' });
2823
- }
2824
- const nativePatchStr = rewriteHeaderPaths(normalizedPatchStr, headerRewrites);
2825
- const nativeResult = await dispatchNativePatch({
2826
- entries,
2827
- basePath,
2828
- nativePatchStr,
2829
- fuzz,
2830
- rejectPartial,
2831
- dryRun,
2832
- readStateScope,
2833
- signal: abortSignal,
2834
- parsed,
2835
- });
2836
- // V4A conversion may have isolated some hunks (rejectPartial:false).
2837
- // Surface them as additional REJECT lines so callers see every dropped
2838
- // change, native or JS-side.
2839
- let combined = nativeResult;
2840
- const renameLines = formatV4ARenameSuccessLines(v4aRenameResults);
2841
- if (renameLines.length > 0 && !isPatchErrorText(nativeResult)) {
2842
- combined = `${renameLines.join('\n')}\n${nativeResult}`;
2843
- }
2844
- // UI-only: stash the standard unified diff (nativePatchStr) keyed by the
2845
- // tool_use id so the agent loop can attach it to the TUI tool-result for the
2846
- // expanded (ctrl+o) colored-diff view. NEVER added to the model-visible
2847
- // result string. Only on clean success — partial/failed cases surface as an
2848
- // Error card with the failure text instead.
2849
- if (!isPatchErrorText(combined) && options?.toolCallId) {
2850
- registerApplyPatchUiDiff(options.toolCallId, nativePatchStr);
2851
- }
2852
- if (!isPatchErrorText(combined) && rejectedV4AHunks.length > 0) {
2853
- const tail = [
2854
- '',
2855
- `hunk-level rejected (rejectPartial=false, V4A): ${rejectedV4AHunks.length}`,
2856
- ...rejectedV4AHunks.map((r) => ` REJECT ${r.file || '(unknown)'} — ${String(r.reason || '').split(';')[0].trim()}`),
2857
- ];
2858
- return wrapPatchMutationOutput(`${combined}\n${tail.join('\n')}`, mutationPlan, { backend: 'native-patch' });
2859
- }
2860
- return wrapPatchMutationOutput(combined, mutationPlan, { backend: 'native-patch' });
2861
- }));
2862
- }
30
+ export { PATCH_TOOL_DEFS } from './patch-tool-defs.mjs';
31
+ export { runServerEdit, closeNativePatchServerForTests } from './patch/native-server.mjs';
32
+ export { takeApplyPatchUiDiff, executePatchTool } from './patch/orchestrator.mjs';
2863
33
 
2864
34
  // Test-only export: lets the regression harness exercise the interior-vs-outer
2865
35
  // change-band logic in findFirstFailingUnifiedHunk without spawning the native
2866
- // binary.
2867
- export const __patchTestHooks = { findFirstFailingUnifiedHunk, computeUnifiedChangeBand, collectUnifiedOps, unifiedOldLinesMatchAt, splitBufferLinesForPatch };
2868
-
2869
- export async function executePatchTool(name, args, cwd, options = {}) {
2870
- // --- Opt-in failure replay capture -------------------------------------
2871
- // When MIXDOG_PATCH_REPLAY_CAPTURE=1, a FAILED apply_patch (throw or
2872
- // "Error:" body) is frozen to a replay file with the ORIGINAL patch args,
2873
- // cwd, error, and a full snapshot of every target file's current bytes, so
2874
- // scripts/patch-replay.mjs can re-run the exact failing call against updated
2875
- // code. Local dev only; the patch body is stored verbatim (no redaction).
2876
- return _executePatchTool(name, args, cwd, options);
2877
- }
2878
-
2879
- function patchReplayDir() {
2880
- const base = process.env.MIXDOG_PATCH_REPLAY_DIR
2881
- || pathJoin(getPluginDataDir(), 'history', 'patch-replays');
2882
- return base;
2883
- }
2884
-
2885
- function getPluginDataDir() {
2886
- try { return getPluginData(); } catch { /* fall through */ }
2887
- return process.env.MIXDOG_DATA_DIR || pathJoin(process.env.USERPROFILE || process.env.HOME || '.', '.mixdog', 'data');
2888
- }
2889
-
2890
- function patchTargetPaths(patchStr, basePath) {
2891
- const out = [];
2892
- const re = /^\*\*\* (?:Update|Add|Delete) File:\s*(.+)$/gm;
2893
- let m;
2894
- while ((m = re.exec(String(patchStr || '')))) {
2895
- const rel = m[1].trim();
2896
- if (rel) out.push(rel);
2897
- }
2898
- // unified headers
2899
- const ure = /^\+\+\+ (?:b\/)?(.+)$/gm;
2900
- while ((m = ure.exec(String(patchStr || '')))) {
2901
- const rel = m[1].trim();
2902
- if (rel && rel !== '/dev/null') out.push(rel);
2903
- }
2904
- return [...new Set(out)];
2905
- }
36
+ // binary. Assembled here to preserve the original `__patchTestHooks` surface.
37
+ import {
38
+ findFirstFailingUnifiedHunk,
39
+ computeUnifiedChangeBand,
40
+ collectUnifiedOps,
41
+ unifiedOldLinesMatchAt,
42
+ splitBufferLinesForPatch,
43
+ } from './patch/matcher.mjs';
2906
44
 
2907
- function maybeCapturePatchReplay(args, cwd, errorText) {
2908
- if (process.env.MIXDOG_PATCH_REPLAY_CAPTURE !== '1') return;
2909
- try {
2910
- const patchStr = typeof args?.patch === 'string' ? args.patch : '';
2911
- const basePath = pathResolve(String(args?.base_path || cwd || process.cwd()));
2912
- const dir = patchReplayDir();
2913
- mkdirSync(dir, { recursive: true, mode: 0o700 });
2914
- const rels = patchTargetPaths(patchStr, basePath);
2915
- const files = {};
2916
- for (const rel of rels) {
2917
- try {
2918
- const abs = isAbsolute(rel) ? rel : pathResolve(basePath, rel);
2919
- files[rel] = existsSync(abs) ? readFileSync(abs, 'utf8') : null;
2920
- } catch { files[rel] = null; }
2921
- }
2922
- const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2923
- const record = {
2924
- id,
2925
- ts: Date.now(),
2926
- tool: 'apply_patch',
2927
- args: { patch: patchStr, base_path: args?.base_path ?? null, format: args?.format ?? null, dry_run: args?.dry_run ?? null, fuzzy: args?.fuzzy ?? null, reject_partial: args?.reject_partial ?? null },
2928
- cwd: basePath,
2929
- error_first_line: String(errorText || '').split('\n')[0].slice(0, 400),
2930
- targets: rels,
2931
- file_snapshots: files,
2932
- };
2933
- writeFileSync(pathJoin(dir, `${id}.json`), JSON.stringify(record, null, 2), { mode: 0o600 });
2934
- } catch { /* capture is best-effort; never affect the tool result */ }
2935
- }
45
+ export const __patchTestHooks = { findFirstFailingUnifiedHunk, computeUnifiedChangeBand, collectUnifiedOps, unifiedOldLinesMatchAt, splitBufferLinesForPatch };
2936
46
 
2937
- async function _executePatchTool(name, args, cwd, options = {}) {
2938
- const effectiveCwd = cwd || process.cwd();
2939
- switch (name) {
2940
- case 'apply_patch': {
2941
- let result;
2942
- try {
2943
- result = await apply_patch(args || {}, effectiveCwd, options);
2944
- } catch (err) {
2945
- const errText = `Error: ${err?.message || String(err)}`;
2946
- maybeCapturePatchReplay(args, effectiveCwd, errText);
2947
- return errText;
2948
- }
2949
- if (isPatchErrorText(String(result))) maybeCapturePatchReplay(args, effectiveCwd, String(result));
2950
- // ② completion progress ("Found N" summary line). Best-effort, no-op
2951
- // when onProgress is absent (no progressToken). Never throws — only
2952
- // emits on success (an "Error:" body is left to the tool result alone).
2953
- if (typeof options?.onProgress === 'function') {
2954
- try {
2955
- const _body = String(result);
2956
- if (!/^Error[\s:[]/.test(_body)) {
2957
- if (args?.dry_run === true) options.onProgress('validated');
2958
- else {
2959
- const _m = /^(?:applied|checked)\s+(\d+)\b/m.exec(_body);
2960
- const _n = _m ? Number(_m[1]) : (_body.match(/^\s*OK\s/gm) || []).length;
2961
- options.onProgress(`applied ${_n} files`);
2962
- }
2963
- }
2964
- } catch { /* best-effort */ }
2965
- }
2966
- return result;
2967
- }
2968
- default: throw new Error(`Unknown patch tool: ${name}`);
2969
- }
2970
- }
47
+ scheduleNativePatchPrewarm();