dsh-config-manager 0.1.78 → 0.1.79

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 (290) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +400 -112
  26. package/lib/client.js +2845 -1524
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/autosync-config.d.ts +73 -0
  82. package/lib/sync/autosync-config.js +89 -0
  83. package/lib/sync/autosync-scheduler.d.ts +80 -6
  84. package/lib/sync/autosync-scheduler.js +263 -40
  85. package/lib/sync/gist/gist-transport.d.ts +15 -1
  86. package/lib/sync/gist/gist-transport.js +66 -5
  87. package/lib/sync/gist/messages.d.ts +3 -0
  88. package/lib/sync/gist/messages.js +7 -0
  89. package/lib/sync/git/git-transport.d.ts +42 -3
  90. package/lib/sync/git/git-transport.js +146 -3
  91. package/lib/sync/merge.d.ts +47 -14
  92. package/lib/sync/merge.js +242 -222
  93. package/lib/sync/messages.d.ts +8 -0
  94. package/lib/sync/messages.js +19 -0
  95. package/lib/sync/risk.d.ts +19 -2
  96. package/lib/sync/risk.js +10 -3
  97. package/lib/sync/s3/messages.d.ts +6 -0
  98. package/lib/sync/s3/messages.js +13 -0
  99. package/lib/sync/s3/s3-transport.d.ts +9 -1
  100. package/lib/sync/s3/s3-transport.js +78 -6
  101. package/lib/sync/sync-engine.d.ts +193 -4
  102. package/lib/sync/sync-engine.js +450 -37
  103. package/lib/sync/sync-history.d.ts +11 -0
  104. package/lib/sync/sync-history.js +20 -0
  105. package/lib/sync/sync-state.d.ts +43 -0
  106. package/lib/sync/sync-state.js +44 -0
  107. package/lib/sync/sync-watcher.d.ts +76 -0
  108. package/lib/sync/sync-watcher.js +202 -0
  109. package/lib/sync/text-merge.d.ts +85 -0
  110. package/lib/sync/text-merge.js +513 -0
  111. package/lib/sync/transport.d.ts +48 -0
  112. package/lib/sync/transport.js +12 -0
  113. package/lib/sync/watch-scope.d.ts +38 -0
  114. package/lib/sync/watch-scope.js +95 -0
  115. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  116. package/lib/sync/webdav/webdav-transport.js +128 -11
  117. package/lib/ui/foreign-view.js +10 -0
  118. package/lib/ui/i18n.d.ts +8 -0
  119. package/lib/ui/i18n.js +20 -0
  120. package/lib/ui/rollback-view.d.ts +12 -0
  121. package/lib/ui/rollback-view.js +2 -0
  122. package/lib/ui/sync-settings-view.d.ts +47 -0
  123. package/lib/ui/sync-settings-view.js +24 -0
  124. package/lib/ui/types.d.ts +21 -0
  125. package/lib/utils/env-lock.d.ts +1 -0
  126. package/lib/utils/env-lock.js +7 -4
  127. package/package.json +6 -6
  128. package/src/adapters/credentials.ts +22 -4
  129. package/src/adapters/file-collection.ts +2 -1
  130. package/src/adapters/mcp.test.ts +235 -1
  131. package/src/adapters/mcp.ts +276 -5
  132. package/src/adapters/messages.ts +7 -0
  133. package/src/adapters/plugin-files.ts +2 -1
  134. package/src/adapters/plugins.test.ts +176 -2
  135. package/src/adapters/plugins.ts +107 -21
  136. package/src/adapters/pnpm-workspace.test.ts +89 -1
  137. package/src/adapters/pnpm-workspace.ts +127 -2
  138. package/src/adapters/prompts.ts +20 -3
  139. package/src/adapters/providers.ts +62 -4
  140. package/src/adapters/settings.ts +16 -3
  141. package/src/adapters/skills.test.ts +141 -0
  142. package/src/adapters/skills.ts +58 -2
  143. package/src/adapters/ui.ts +16 -3
  144. package/src/adapters/units-entries.test.ts +190 -0
  145. package/src/adapters/units.ts +50 -0
  146. package/src/adapters/workspaces.ts +2 -1
  147. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  148. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  149. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  150. package/src/client/common/Skeleton.tsx +32 -0
  151. package/src/client/common/routes.ts +9 -0
  152. package/src/client/config-manager.module.css +321 -11
  153. package/src/client/home/HomePanel.tsx +14 -6
  154. package/src/client/import/ForeignImportView.tsx +3 -1
  155. package/src/client/import/import-wizard-steps.tsx +2 -1
  156. package/src/client/library/ArtifactRow.tsx +12 -2
  157. package/src/client/library/LibraryActions.tsx +6 -3
  158. package/src/client/library/LibraryPanel.tsx +21 -3
  159. package/src/client/library/RestorePlanView.tsx +4 -2
  160. package/src/client/locales.ts +3 -0
  161. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  162. package/src/client/recovery/RollbackCard.tsx +7 -2
  163. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  164. package/src/client/recovery/recovery-locales.ts +20 -2
  165. package/src/client/recovery/recovery-view.test.ts +39 -1
  166. package/src/client/recovery/recovery-view.ts +23 -1
  167. package/src/client/run-store.test.ts +1 -1
  168. package/src/client/run-store.ts +4 -0
  169. package/src/client/sync/AutosyncCard.tsx +49 -6
  170. package/src/client/sync/MergeModal.tsx +219 -0
  171. package/src/client/sync/MergePreviewView.tsx +306 -0
  172. package/src/client/sync/SyncActivityBar.tsx +31 -0
  173. package/src/client/sync/SyncConfirmView.tsx +2 -1
  174. package/src/client/sync/SyncPanel.tsx +130 -87
  175. package/src/client/sync/SyncProbeRow.tsx +162 -0
  176. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  177. package/src/client/sync/WorkDirCard.tsx +44 -6
  178. package/src/client/sync/history-model.ts +4 -1
  179. package/src/client/sync/merge-view.test.ts +234 -0
  180. package/src/client/sync/merge-view.ts +371 -0
  181. package/src/client/sync/probe-view.test.ts +90 -0
  182. package/src/client/sync/probe-view.ts +63 -0
  183. package/src/client/sync/remote-device-view.test.ts +35 -0
  184. package/src/client/sync/remote-device-view.ts +25 -0
  185. package/src/client/sync/sync-api.ts +212 -2
  186. package/src/client/sync/sync-entry.test.ts +78 -0
  187. package/src/client/sync/sync-entry.ts +53 -0
  188. package/src/client/sync/sync-locales.ts +234 -2
  189. package/src/client/sync/sync-view.test.ts +18 -1
  190. package/src/client/sync/sync-view.ts +20 -0
  191. package/src/core/backup.test.ts +41 -0
  192. package/src/core/backup.ts +20 -0
  193. package/src/core/mcp-connector-store.test.ts +265 -0
  194. package/src/core/mcp-connector-store.ts +516 -0
  195. package/src/core/messages-core.ts +4 -0
  196. package/src/core/messages.ts +46 -0
  197. package/src/core/phase3-host.ts +18 -1
  198. package/src/core/phase3-production-integration.test.ts +4 -0
  199. package/src/core/plugin-cli.test.ts +26 -0
  200. package/src/core/plugin-cli.ts +30 -4
  201. package/src/core/recovery-orchestrator.test.ts +104 -5
  202. package/src/core/recovery-orchestrator.ts +74 -17
  203. package/src/core/rollback-activity.test.ts +311 -0
  204. package/src/core/rollback-activity.ts +367 -0
  205. package/src/core/rollback-cross-session.test.ts +694 -0
  206. package/src/core/rollback-interrupted.test.ts +137 -0
  207. package/src/core/rollback-ledger.ts +32 -0
  208. package/src/core/rollback-merge.test.ts +130 -0
  209. package/src/core/rollback-merge.ts +78 -14
  210. package/src/core/rollback-point.ts +468 -91
  211. package/src/core/rollback-undo-journal.test.ts +508 -0
  212. package/src/core/rollback-undo-journal.ts +571 -0
  213. package/src/core/self-update.ts +2 -5
  214. package/src/core/text-fidelity.test.ts +189 -0
  215. package/src/core/text-fidelity.ts +197 -0
  216. package/src/core/types.ts +11 -3
  217. package/src/core/update-check.ts +2 -5
  218. package/src/core/validator.ts +14 -0
  219. package/src/foreign/bundle-origin.test.ts +265 -0
  220. package/src/foreign/bundle.ts +45 -5
  221. package/src/foreign/claude-code.test.ts +268 -0
  222. package/src/foreign/claude-code.ts +406 -25
  223. package/src/foreign/claude-plugins.test.ts +234 -0
  224. package/src/foreign/claude-sessions.test.ts +141 -0
  225. package/src/foreign/claude-sessions.ts +179 -23
  226. package/src/foreign/file-budget.test.ts +4 -1
  227. package/src/foreign/read-claude-code.test.ts +320 -0
  228. package/src/foreign/read-claude-code.ts +490 -71
  229. package/src/foreign/read-claude-plugins.ts +465 -0
  230. package/src/foreign/registry.ts +25 -2
  231. package/src/foreign/session-ir.test.ts +65 -0
  232. package/src/foreign/truth-table.test.ts +81 -0
  233. package/src/foreign/truth-table.ts +6 -3
  234. package/src/foreign/types.ts +162 -2
  235. package/src/index.facade.test.ts +26 -4
  236. package/src/index.ts +76 -19
  237. package/src/market/git-file-writer.test.ts +4 -0
  238. package/src/market/git-file-writer.ts +7 -1
  239. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  240. package/src/profiles/dsh-profile-launcher.ts +56 -1
  241. package/src/profiles/dsh-profile-shared.ts +8 -2
  242. package/src/routes/foreign.ts +8 -1
  243. package/src/routes/index.ts +1 -1
  244. package/src/routes/kit.ts +2 -2
  245. package/src/routes/sync.ts +176 -9
  246. package/src/schema/manifest-schema.test.ts +87 -2
  247. package/src/schema/manifest.ts +57 -1
  248. package/src/schema/types.ts +45 -2
  249. package/src/sync/autosync-config.test.ts +73 -1
  250. package/src/sync/autosync-config.ts +124 -0
  251. package/src/sync/autosync-risk-tier.test.ts +38 -2
  252. package/src/sync/autosync-scheduler.test.ts +264 -12
  253. package/src/sync/autosync-scheduler.ts +296 -44
  254. package/src/sync/gist/gist-conditional-write.test.ts +223 -0
  255. package/src/sync/gist/gist-transport.ts +73 -7
  256. package/src/sync/gist/messages.ts +7 -0
  257. package/src/sync/git/git-probe.test.ts +80 -0
  258. package/src/sync/git/git-transport.test.ts +185 -0
  259. package/src/sync/git/git-transport.ts +178 -5
  260. package/src/sync/merge.test.ts +188 -55
  261. package/src/sync/merge.ts +282 -224
  262. package/src/sync/messages.ts +19 -0
  263. package/src/sync/risk.ts +29 -5
  264. package/src/sync/s3/messages.ts +13 -0
  265. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  266. package/src/sync/s3/s3-transport.ts +87 -9
  267. package/src/sync/sync-device-identity.test.ts +267 -0
  268. package/src/sync/sync-engine.test.ts +379 -0
  269. package/src/sync/sync-engine.ts +535 -36
  270. package/src/sync/sync-history.test.ts +16 -1
  271. package/src/sync/sync-history.ts +26 -0
  272. package/src/sync/sync-state.test.ts +64 -0
  273. package/src/sync/sync-state.ts +79 -0
  274. package/src/sync/sync-watcher.test.ts +197 -0
  275. package/src/sync/sync-watcher.ts +246 -0
  276. package/src/sync/text-merge.test.ts +358 -0
  277. package/src/sync/text-merge.ts +554 -0
  278. package/src/sync/transport.ts +57 -0
  279. package/src/sync/watch-scope.test.ts +122 -0
  280. package/src/sync/watch-scope.ts +120 -0
  281. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  282. package/src/sync/webdav/webdav-transport.ts +137 -14
  283. package/src/ui/foreign-view.test.ts +34 -0
  284. package/src/ui/foreign-view.ts +10 -0
  285. package/src/ui/i18n.ts +20 -0
  286. package/src/ui/rollback-view.ts +14 -0
  287. package/src/ui/sync-settings-view.test.ts +28 -1
  288. package/src/ui/sync-settings-view.ts +43 -0
  289. package/src/ui/types.ts +22 -0
  290. package/src/utils/env-lock.ts +8 -5
@@ -0,0 +1,246 @@
1
+ /**
2
+ * m-autosync-realtime:本地文件监听器(fs.watch + 去抖 + 自写回声屏蔽)。
3
+ *
4
+ * 存在的理由:改造前的自动同步**只靠定时器**(最短 5 分钟),本地改完配置得等下一轮才上行,
5
+ * 与「像 VS Code 那样改完就同步」的预期不符(issue: 自动同步不自动)。
6
+ *
7
+ * 设计要点:
8
+ * - **文件目标监听父目录**(非递归)+ 按 basename 过滤:原子写是 temp + rename,
9
+ * 直接监听文件本身会在 rename 后失去句柄(Linux inode 变化),监听父目录才稳;
10
+ * - **去抖**(缺省 1500ms):一次保存往往连写多个文件(settings.yaml + patch),
11
+ * 合并成一次触发,绝不为每个字节排一轮同步;
12
+ * - **自写回声屏蔽**:调度器在 runOnce 前后 suspend()/resume(),resume 后再静默
13
+ * `quietAfterSuspendMs`(缺省 2000ms)——否则「同步把远端内容写回本地」自身就会触发下一轮,
14
+ * 形成 push→apply→push 的死循环;
15
+ * - **静默窗口只「推迟」不「丢弃」**:窗口内到达的事件记进 pending 并排一枚窗口结束后的补跑定时器
16
+ * (真机实测:窗口内保存若直接丢弃,用户改完要等一整轮兜底间隔才上行 —— 与「改完就同步」不符)。
17
+ * 自家回声由引擎的分区 hash 比对判成「无变化」,多跑一轮不产生任何传输;
18
+ * - **纯监听、不做判定**:这里只说「有东西变了」(附带 home 相对路径),究竟哪些分区真的变了
19
+ * 由引擎的 `hasLocalChanges()`(分区 hash 比基线)回答 —— 监听器绝不猜。
20
+ *
21
+ * 可靠性取舍:监听失败(ENOENT / 平台不支持 recursive)只 `log.debug/warn`,绝不抛错 ——
22
+ * 自动同步的兜底始终是定时器,监听掉线只是退回「等下一轮」,不能让宿主启动失败。
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ import type { Logger } from '../utils/logger.ts';
28
+ import type { WatchTarget } from './watch-scope.ts';
29
+ import { isNoiseRelPath, targetAbsPath, toHomeRelPath } from './watch-scope.ts';
30
+
31
+ /** fs.watch 返回的句柄(只用到 close)。 */
32
+ export interface WatchHandle {
33
+ close(): void;
34
+ }
35
+
36
+ /** fs.watch 的最小签名(测试注入假实现)。 */
37
+ export type WatchFactory = (
38
+ absPath: string,
39
+ opts: { recursive: boolean },
40
+ listener: (event: string, filename: string | null) => void,
41
+ ) => WatchHandle;
42
+
43
+ /** 缺省去抖窗口:一次「保存配置」产生的连续事件合并为一次触发。 */
44
+ export const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
45
+ /** 缺省自写回声窗口:resume 后这段时间内的事件一律忽略。 */
46
+ export const DEFAULT_WATCH_QUIET_MS = 2000;
47
+
48
+ export interface SyncWatcherOptions {
49
+ /** `$DSH_HOME` 绝对路径 */
50
+ homeDir: string;
51
+ /** 监听目标(`watch-scope.buildWatchTargets` 的产物) */
52
+ targets: readonly WatchTarget[];
53
+ log: Logger;
54
+ /** 去抖窗口到期:把这一批变更的 home 相对路径交给上层(已过滤噪声)。 */
55
+ onFlush: (changed: readonly string[]) => void;
56
+ debounceMs?: number;
57
+ quietAfterSuspendMs?: number;
58
+ watchFactory?: WatchFactory;
59
+ /** 时间源 / 计时器(测试注入) */
60
+ now?: () => number;
61
+ setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
62
+ clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
63
+ }
64
+
65
+ interface Attachment {
66
+ target: WatchTarget;
67
+ /** 实际被监听的目录绝对路径 */
68
+ watchedDir: string;
69
+ /** 文件目标:该目录下需要匹配的 basename;目录目标为 null */
70
+ fileBase: string | null;
71
+ handle: WatchHandle;
72
+ }
73
+
74
+ export class SyncWatcher {
75
+ private readonly homeDir: string;
76
+ private readonly log: Logger;
77
+ private readonly onFlush: (changed: readonly string[]) => void;
78
+ private readonly debounceMs: number;
79
+ private readonly quietAfterSuspendMs: number;
80
+ private readonly watchFactory: WatchFactory;
81
+ private readonly now: () => number;
82
+ private readonly setTimer: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
83
+ private readonly clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
84
+
85
+ private targets: readonly WatchTarget[];
86
+ private attachments: Attachment[] = [];
87
+ private pending: Set<string> = new Set();
88
+ private timer: ReturnType<typeof setTimeout> | null = null;
89
+ private started = false;
90
+ private suspended = false;
91
+ private quietUntil = 0;
92
+
93
+ constructor(opts: SyncWatcherOptions) {
94
+ this.homeDir = opts.homeDir;
95
+ this.targets = opts.targets;
96
+ this.log = opts.log;
97
+ this.onFlush = opts.onFlush;
98
+ this.debounceMs = opts.debounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS;
99
+ this.quietAfterSuspendMs = opts.quietAfterSuspendMs ?? DEFAULT_WATCH_QUIET_MS;
100
+ this.watchFactory = opts.watchFactory ?? defaultWatchFactory;
101
+ this.now = opts.now ?? (() => Date.now());
102
+ this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
103
+ this.clearTimer = opts.clearTimer ?? ((t) => clearTimeout(t));
104
+ }
105
+
106
+ /** 是否已启动。 */
107
+ isStarted(): boolean {
108
+ return this.started;
109
+ }
110
+
111
+ /** 当前实际挂上的监听数(诊断/测试用)。 */
112
+ attachmentCount(): number {
113
+ return this.attachments.length;
114
+ }
115
+
116
+ /** 启动监听(重复调用是幂等的)。 */
117
+ start(): void {
118
+ if (this.started) return;
119
+ this.started = true;
120
+ this.attachAll();
121
+ }
122
+
123
+ /** 停止监听并丢弃未触发的变更(插件卸载 / 自动同步关闭时必须调)。 */
124
+ stop(): void {
125
+ this.started = false;
126
+ this.suspended = false;
127
+ this.quietUntil = 0;
128
+ this.clearPendingTimer();
129
+ this.pending.clear();
130
+ for (const a of this.attachments) {
131
+ try {
132
+ a.handle.close();
133
+ } catch {
134
+ /* 尽力而为:句柄可能已被平台回收 */
135
+ }
136
+ }
137
+ this.attachments = [];
138
+ }
139
+
140
+ /** 换监听目标(分区选择/配置变化);已启动则原子重启。 */
141
+ setTargets(targets: readonly WatchTarget[]): void {
142
+ this.targets = targets;
143
+ if (!this.started) return;
144
+ this.stop();
145
+ this.started = true;
146
+ this.attachAll();
147
+ }
148
+
149
+ /**
150
+ * 进入「自写中」:屏蔽事件并丢弃已积累的变更。
151
+ *
152
+ * 在 runOnce 开始时调用 —— 同步自家的 apply/push 会写 settings.yaml / patch / skills,
153
+ * 那些写入不是用户改动。
154
+ */
155
+ suspend(): void {
156
+ this.suspended = true;
157
+ this.clearPendingTimer();
158
+ this.pending.clear();
159
+ }
160
+
161
+ /** 退出「自写中」:随后进入静默窗口,避免收尾写入的回声立刻触发下一轮。 */
162
+ resume(): void {
163
+ if (!this.suspended) return;
164
+ this.suspended = false;
165
+ this.quietUntil = this.now() + this.quietAfterSuspendMs;
166
+ }
167
+
168
+ /** 立即处理当前积累的变更(测试用;正常路径靠定时器)。 */
169
+ flushNow(): void {
170
+ this.flush();
171
+ }
172
+
173
+ private attachAll(): void {
174
+ for (const target of this.targets) this.attach(target);
175
+ }
176
+
177
+ private attach(target: WatchTarget): void {
178
+ const abs = targetAbsPath(this.homeDir, target);
179
+ const isDir = target.kind === 'dir';
180
+ const watchedDir = isDir ? abs : path.dirname(abs);
181
+ const fileBase = isDir ? null : path.basename(abs);
182
+ try {
183
+ const handle = this.watchFactory(watchedDir, { recursive: isDir ? target.recursive : false }, (event, filename) => {
184
+ this.onRawEvent(target, watchedDir, fileBase, filename);
185
+ });
186
+ this.attachments.push({ target, watchedDir, fileBase, handle });
187
+ this.log.debug(`自动同步监听已挂载:${target.relPath}`, { watchedDir });
188
+ } catch (err) {
189
+ // 路径不存在 / 平台不支持:不阻断启动,兜底是定时器
190
+ this.log.debug(`自动同步监听未挂载:${target.relPath}`, { error: err instanceof Error ? err.message : String(err) });
191
+ }
192
+ }
193
+
194
+ private onRawEvent(target: WatchTarget, watchedDir: string, fileBase: string | null, filename: string | null): void {
195
+ if (!this.started || this.suspended) return;
196
+ // 静默窗口内到达的事件:先记账,窗口一结束就补跑一次判定(丢弃会让用户改动漏掉一整轮兜底间隔)
197
+ const quietRemainingMs = this.quietUntil - this.now();
198
+ let abs: string;
199
+ if (fileBase !== null) {
200
+ // 文件目标:只关心父目录里同名文件的事件(原子写的 temp 名字被噪声规则挡掉)
201
+ if (filename !== null && path.basename(filename) !== fileBase) return;
202
+ abs = path.join(watchedDir, fileBase);
203
+ } else {
204
+ if (filename === null) return;
205
+ abs = path.join(watchedDir, filename);
206
+ }
207
+ const rel = toHomeRelPath(this.homeDir, abs);
208
+ if (rel === null || isNoiseRelPath(rel)) return;
209
+ void target; // sections 仅用于诊断;判定交给引擎的分区 hash
210
+ this.pending.add(rel);
211
+ this.scheduleFlush(quietRemainingMs > 0 ? quietRemainingMs + this.debounceMs : this.debounceMs);
212
+ }
213
+
214
+ private scheduleFlush(delayMs: number = this.debounceMs): void {
215
+ if (this.timer !== null) return;
216
+ this.timer = this.setTimer(() => {
217
+ this.timer = null;
218
+ this.flush();
219
+ }, delayMs);
220
+ }
221
+
222
+ private flush(): void {
223
+ if (this.pending.size === 0) return;
224
+ const quietRemainingMs = this.quietUntil - this.now();
225
+ if (quietRemainingMs > 0) {
226
+ // 去抖定时器提前到期:推迟到窗口结束,既不丢用户改动、也不把自家收尾写入当触发
227
+ this.scheduleFlush(quietRemainingMs + this.debounceMs);
228
+ return;
229
+ }
230
+ const changed = [...this.pending];
231
+ this.pending.clear();
232
+ try {
233
+ this.onFlush(changed);
234
+ } catch (err) {
235
+ this.log.warn('自动同步监听回调失败', { error: err instanceof Error ? err.message : String(err) });
236
+ }
237
+ }
238
+
239
+ private clearPendingTimer(): void {
240
+ if (this.timer === null) return;
241
+ this.clearTimer(this.timer);
242
+ this.timer = null;
243
+ }
244
+ }
245
+
246
+ const defaultWatchFactory: WatchFactory = (absPath, opts, listener) => fs.watch(absPath, { recursive: opts.recursive }, listener);
@@ -0,0 +1,358 @@
1
+ /**
2
+ * m-sync-flow:text-merge(行级文本三方合并 + 递归 JSON 结构化合并)单元测试。
3
+ *
4
+ * 覆盖:形态判定 / 首次同步(缺祖先)/ 单边 / 双边不同行 / 双边同行冲突 / 两侧改成相同结果 /
5
+ * 空文件 / 缺失文件 / 纯二进制 / 超大 / 非法 JSON / 深层嵌套 / 数组单边 / 数组双改冲突 /
6
+ * JSON 文本成功合并 / CRLF 与 LF 混用 / 末尾无换行 / 重复行不错位 /
7
+ * 键删除语义与「未知键绝不静默丢弃」。
8
+ */
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+
12
+ import {
13
+ mergeBytesThreeWay,
14
+ mergeJsonValues,
15
+ mergeTextThreeWay,
16
+ looksLikeText,
17
+ tryParseJson,
18
+ TEXT_MERGE_MAX_BYTES,
19
+ type TextMergeConflict,
20
+ } from './text-merge.ts';
21
+
22
+ const enc = (text: string): Uint8Array => new TextEncoder().encode(text);
23
+ const dec = (bytes: Uint8Array): string => new TextDecoder().decode(bytes);
24
+
25
+ /** 断言无冲突并返回合并结果(失败信息带 conflicts,便于定位)。 */
26
+ function mergedOrThrow(result: { ok: boolean; merged?: string; conflicts: TextMergeConflict[] }): string {
27
+ assert.equal(result.ok, true, '期望合并没有冲突,实际 conflicts=' + JSON.stringify(result.conflicts));
28
+ assert.ok(result.merged !== undefined, '期望有 merged');
29
+ return result.merged;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // 形态判定
34
+ // ---------------------------------------------------------------------------
35
+
36
+ test('looksLikeText: 合法 UTF-8 文本 → true', () => {
37
+ assert.equal(looksLikeText(enc('hello 世界\n')), true);
38
+ assert.equal(looksLikeText(new Uint8Array(0)), true);
39
+ });
40
+
41
+ test('looksLikeText: 含 NUL 字节 → false', () => {
42
+ assert.equal(looksLikeText(new Uint8Array([0x61, 0x00, 0x62])), false);
43
+ });
44
+
45
+ test('looksLikeText: 非法 UTF-8 → false', () => {
46
+ assert.equal(looksLikeText(new Uint8Array([0xff, 0xfe, 0x41])), false);
47
+ });
48
+
49
+ test('looksLikeText: 超过 maxBytes → false(含显式传入的上限)', () => {
50
+ assert.equal(looksLikeText(enc('hello'), 3), false);
51
+ const big = new Uint8Array(TEXT_MERGE_MAX_BYTES + 1).fill(0x61);
52
+ assert.equal(looksLikeText(big), false);
53
+ assert.equal(looksLikeText(new Uint8Array(TEXT_MERGE_MAX_BYTES).fill(0x61)), true);
54
+ });
55
+
56
+ test('tryParseJson: 合法 JSON 返回 value,非法返回 ok:false', () => {
57
+ const ok = tryParseJson('{"a":[1,2]}');
58
+ assert.equal(ok.ok, true);
59
+ if (ok.ok) assert.deepEqual(ok.value, { a: [1, 2] });
60
+ assert.equal(tryParseJson('{"a":1').ok, false);
61
+ assert.equal(tryParseJson('').ok, false);
62
+ assert.equal(tryParseJson('{a:1}').ok, false);
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // JSON 结构化合并
67
+ // ---------------------------------------------------------------------------
68
+
69
+ test('mergeJsonValues: 单侧改动 → 采纳该侧,另一侧未改的键原样保留', () => {
70
+ const ancestor = { a: 1, keep: 'x' };
71
+ const local = { a: 2, keep: 'x' };
72
+ const remote = { a: 1, keep: 'x' };
73
+ const r = mergeJsonValues(ancestor, local, remote);
74
+ assert.equal(r.ok, true);
75
+ assert.deepEqual(r.merged, { a: 2, keep: 'x' });
76
+ });
77
+
78
+ test('mergeJsonValues: 深层嵌套(3 层以上)双侧改不同叶子 → 全部保住', () => {
79
+ const ancestor = { a: { b: { c: { d: 1, e: 2 }, f: [1, 2] } }, top: true };
80
+ const local = { a: { b: { c: { d: 10, e: 2 }, f: [1, 2] } }, top: true };
81
+ const remote = { a: { b: { c: { d: 1, e: 20 }, f: [1, 2] } }, top: true };
82
+ const r = mergeJsonValues(ancestor, local, remote);
83
+ assert.equal(r.ok, true);
84
+ assert.deepEqual(r.merged, { a: { b: { c: { d: 10, e: 20 }, f: [1, 2] } }, top: true });
85
+ });
86
+
87
+ test('mergeJsonValues: 同一叶子双侧改成不同值 → 冲突(点号路径),ok=false 且无 merged', () => {
88
+ const ancestor = { a: { b: { c: 1 } } };
89
+ const local = { a: { b: { c: 2 } } };
90
+ const remote = { a: { b: { c: 3 } } };
91
+ const r = mergeJsonValues(ancestor, local, remote);
92
+ assert.equal(r.ok, false);
93
+ assert.equal(r.merged, undefined);
94
+ assert.equal(r.conflicts.length, 1);
95
+ assert.equal(r.conflicts[0]!.path, 'a.b.c');
96
+ assert.equal(r.conflicts[0]!.reason, 'overlap');
97
+ assert.equal(r.conflicts[0]!.local, 2);
98
+ assert.equal(r.conflicts[0]!.remote, 3);
99
+ assert.equal(r.conflicts[0]!.ancestor, 1);
100
+ });
101
+
102
+ test('mergeJsonValues: 数组单侧改动 → 采纳改动侧', () => {
103
+ const ancestor = { list: [1, 2] };
104
+ const local = { list: [1, 2, 3] };
105
+ const remote = { list: [1, 2] };
106
+ const r = mergeJsonValues(ancestor, local, remote);
107
+ assert.equal(r.ok, true);
108
+ assert.deepEqual(r.merged, { list: [1, 2, 3] });
109
+ });
110
+
111
+ test('mergeJsonValues: 数组双侧都改且不 deepEqual → conflict(reason=array),绝不按索引合并', () => {
112
+ const ancestor = { list: [1, 2] };
113
+ const local = { list: [1, 3] };
114
+ const remote = { list: [1, 2, 9] };
115
+ const r = mergeJsonValues(ancestor, local, remote);
116
+ assert.equal(r.ok, false);
117
+ assert.equal(r.conflicts.length, 1);
118
+ assert.equal(r.conflicts[0]!.path, 'list');
119
+ assert.equal(r.conflicts[0]!.reason, 'array');
120
+ assert.deepEqual(r.conflicts[0]!.local, [1, 3]);
121
+ assert.deepEqual(r.conflicts[0]!.remote, [1, 2, 9]);
122
+ });
123
+
124
+ test('mergeJsonValues: 双侧数组改成逐字相同 → 收敛,不报冲突', () => {
125
+ const r = mergeJsonValues({ list: [1] }, { list: [1, 2] }, { list: [1, 2] });
126
+ assert.equal(r.ok, true);
127
+ assert.deepEqual(r.merged, { list: [1, 2] });
128
+ });
129
+
130
+ test('mergeJsonValues: 单侧新增键(祖先没有)→ 采纳;双侧新增不同值 → 冲突', () => {
131
+ const single = mergeJsonValues({ a: 1 }, { a: 1, added: 'l' }, { a: 1 });
132
+ assert.equal(single.ok, true);
133
+ assert.deepEqual(single.merged, { a: 1, added: 'l' });
134
+
135
+ const both = mergeJsonValues({ a: 1 }, { a: 1, added: 1 }, { a: 1, added: 2 });
136
+ assert.equal(both.ok, false);
137
+ assert.equal(both.conflicts[0]!.path, 'added');
138
+ assert.equal(both.conflicts[0]!.ancestor, undefined);
139
+ });
140
+
141
+ test('mergeJsonValues: 缺失键 = 删除语义(一侧删、另一侧没改 → 删除胜出)', () => {
142
+ const r = mergeJsonValues({ a: 1, b: 2 }, { a: 1 }, { a: 1, b: 2 });
143
+ assert.equal(r.ok, true);
144
+ assert.deepEqual(r.merged, { a: 1 });
145
+ });
146
+
147
+ test('mergeJsonValues: 一侧删除、另一侧修改同一键 → 冲突(不静默丢改动)', () => {
148
+ const r = mergeJsonValues({ a: 1, b: 2 }, { a: 1 }, { a: 1, b: 99 });
149
+ assert.equal(r.ok, false);
150
+ assert.equal(r.conflicts.length, 1);
151
+ assert.equal(r.conflicts[0]!.path, 'b');
152
+ assert.equal(r.conflicts[0]!.local, undefined);
153
+ assert.equal(r.conflicts[0]!.remote, 99);
154
+ assert.equal(r.conflicts[0]!.ancestor, 2);
155
+ });
156
+
157
+ test('mergeJsonValues: 类型互换(对象 ↔ 标量)→ 冲突,绝不猜', () => {
158
+ const r = mergeJsonValues({ a: { x: 1 } }, { a: [{ x: 1 }] }, { a: { x: 2 } });
159
+ assert.equal(r.ok, false);
160
+ assert.equal(r.conflicts[0]!.path, 'a');
161
+ });
162
+
163
+ test('mergeJsonValues: 未知键绝不静默丢弃(双侧各自新增不同键)', () => {
164
+ const r = mergeJsonValues({}, { fromLocal: 1 }, { fromRemote: 2 });
165
+ assert.equal(r.ok, true);
166
+ assert.deepEqual(r.merged, { fromLocal: 1, fromRemote: 2 });
167
+ });
168
+
169
+ test('mergeJsonValues: 根值(非对象)单侧改动 → 采纳该侧', () => {
170
+ assert.deepEqual(mergeJsonValues(undefined, undefined, 5), { ok: true, merged: 5, conflicts: [] });
171
+ const conflict = mergeJsonValues(1, 2, 3);
172
+ assert.equal(conflict.ok, false);
173
+ assert.equal(conflict.conflicts[0]!.path, '');
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // 文本三方合并
178
+ // ---------------------------------------------------------------------------
179
+
180
+ test('mergeTextThreeWay: 仅本地改动 → 本地胜出', () => {
181
+ const r = mergeTextThreeWay('a\nb\nc\n', 'a\nB\nc\n', 'a\nb\nc\n');
182
+ assert.equal(r.kind, 'text');
183
+ assert.equal(mergedOrThrow(r), 'a\nB\nc\n');
184
+ });
185
+
186
+ test('mergeTextThreeWay: 仅远端改动 → 远端胜出', () => {
187
+ const r = mergeTextThreeWay('a\nb\nc\n', 'a\nb\nc\n', 'a\nb\nC\n');
188
+ assert.equal(mergedOrThrow(r), 'a\nb\nC\n');
189
+ });
190
+
191
+ test('mergeTextThreeWay: 双侧改不同行 → 两侧改动都保住', () => {
192
+ const r = mergeTextThreeWay('a\nb\nc\nd\n', 'a\nB\nc\nd\n', 'a\nb\nc\nD\n');
193
+ assert.equal(mergedOrThrow(r), 'a\nB\nc\nD\n');
194
+ });
195
+
196
+ test('mergeTextThreeWay: 双侧改同一行且结果不同 → 冲突,path 形如 line 12-14', () => {
197
+ const r = mergeTextThreeWay('a\nb\nc\n', 'a\nL\nc\n', 'a\nR\nc\n');
198
+ assert.equal(r.ok, false);
199
+ assert.equal(r.kind, 'text');
200
+ assert.equal(r.merged, undefined);
201
+ assert.equal(r.conflicts.length, 1);
202
+ assert.equal(r.conflicts[0]!.path, 'line 2');
203
+ assert.equal(r.conflicts[0]!.reason, 'overlap');
204
+ assert.equal(r.conflicts[0]!.local, 'L');
205
+ assert.equal(r.conflicts[0]!.remote, 'R');
206
+ assert.equal(r.conflicts[0]!.ancestor, 'b');
207
+ });
208
+
209
+ test('mergeTextThreeWay: 多行相邻区间双侧冲突 → path 覆盖并集(line 2-3)', () => {
210
+ const r = mergeTextThreeWay('a\nb\nc\nd\n', 'a\nL1\nL2\nd\n', 'a\nR1\nR2\nd\n');
211
+ assert.equal(r.ok, false);
212
+ assert.equal(r.conflicts[0]!.path, 'line 2-3');
213
+ });
214
+
215
+ test('mergeTextThreeWay: 双侧改成逐字相同的结果 → 收敛为一份,不报冲突', () => {
216
+ const r = mergeTextThreeWay('a\nb\nc\n', 'a\nX\nc\n', 'a\nX\nc\n');
217
+ assert.equal(r.ok, true);
218
+ assert.equal(mergedOrThrow(r), 'a\nX\nc\n');
219
+ });
220
+
221
+ test('mergeTextThreeWay: 空文件(双侧新增不同内容)→ 冲突,不把两份内容拼起来', () => {
222
+ const r = mergeTextThreeWay('', 'A\n', 'B\n');
223
+ assert.equal(r.ok, false);
224
+ assert.equal(r.conflicts.length, 1);
225
+ assert.equal(r.conflicts[0]!.local, 'A');
226
+ assert.equal(r.conflicts[0]!.remote, 'B');
227
+ });
228
+
229
+ test('mergeTextThreeWay: 空文件(仅一侧新增)→ 采纳;三段全空 → 空结果', () => {
230
+ assert.equal(mergedOrThrow(mergeTextThreeWay('', '', 'B\n')), 'B\n');
231
+ assert.equal(mergedOrThrow(mergeTextThreeWay('', '', '')), '');
232
+ });
233
+
234
+ test('mergeTextThreeWay: 一侧清空文件、另一侧未改 → 空结果(删除胜出)', () => {
235
+ assert.equal(mergedOrThrow(mergeTextThreeWay('a\nb\n', '', 'a\nb\n')), '');
236
+ });
237
+
238
+ test('mergeTextThreeWay: 非法 JSON 回落到行级合并(kind=text,不因解析失败而冲突)', () => {
239
+ const r = mergeTextThreeWay('// 注释\n{\n "a": 1\n}\n', '// 注释\n{\n "a": 1\n}\n', '// 注释\n{\n "a": 2\n}\n');
240
+ assert.equal(r.kind, 'text');
241
+ assert.equal(mergedOrThrow(r), '// 注释\n{\n "a": 2\n}\n');
242
+ });
243
+
244
+ test('mergeTextThreeWay: 三段都是合法 JSON → 走结构化合并(kind=json)', () => {
245
+ const ancestor = '{\n "a": 1,\n "b": 2\n}\n';
246
+ const local = '{\n "a": 1,\n "b": 3\n}\n';
247
+ const remote = '{\n "a": 5,\n "b": 2\n}\n';
248
+ const r = mergeTextThreeWay(ancestor, local, remote);
249
+ assert.equal(r.kind, 'json');
250
+ assert.equal(r.ok, true);
251
+ assert.equal(r.merged, JSON.stringify({ a: 5, b: 3 }, null, 2) + '\n');
252
+ });
253
+
254
+ test('mergeTextThreeWay: 合法 JSON 但结构化合并有冲突 → ok=false, kind=json,冲突精确到叶子', () => {
255
+ const r = mergeTextThreeWay('{"a":1}', '{"a":2}', '{"a":3}');
256
+ assert.equal(r.kind, 'json');
257
+ assert.equal(r.ok, false);
258
+ assert.equal(r.conflicts.length, 1);
259
+ assert.equal(r.conflicts[0]!.path, 'a');
260
+ });
261
+
262
+ test('mergeTextThreeWay: CRLF 与 LF 混用不误报冲突,且保留各自行尾', () => {
263
+ const ancestor = 'a\r\nb\r\nc\r\n';
264
+ const local = 'a\nB\nc\n';
265
+ const remote = 'a\r\nb\r\nC\r\n';
266
+ const r = mergeTextThreeWay(ancestor, local, remote);
267
+ assert.equal(mergedOrThrow(r), 'a\r\nB\nC\r\n');
268
+ });
269
+
270
+ test('mergeTextThreeWay: 三种输入都是 CRLF → 输出 CRLF', () => {
271
+ const r = mergeTextThreeWay('a\r\nb\r\n', 'a\r\nB\r\n', 'a\r\nb\r\n');
272
+ assert.equal(mergedOrThrow(r), 'a\r\nB\r\n');
273
+ });
274
+
275
+ test('mergeTextThreeWay: 末尾无换行时不得凭空补换行(改动末行)', () => {
276
+ const r = mergeTextThreeWay('a\nb', 'a\nB', 'a\nb');
277
+ assert.equal(mergedOrThrow(r), 'a\nB');
278
+ });
279
+
280
+ test('mergeTextThreeWay: 末尾无换行 + 追加行 → 需要时补出正确行尾(不粘连)', () => {
281
+ const r = mergeTextThreeWay('a', 'a\nnew', 'a');
282
+ assert.equal(mergedOrThrow(r), 'a\nnew');
283
+ });
284
+
285
+ test('mergeTextThreeWay: 含重复行时按 LCS 对齐,不错位到第一个相同行', () => {
286
+ const r = mergeTextThreeWay('x\nx\ny\n', 'x\nX\ny\n', 'x\nx\ny\nz\n');
287
+ assert.equal(mergedOrThrow(r), 'x\nX\ny\nz\n');
288
+ });
289
+
290
+ test('mergeTextThreeWay: 双侧在同一位置插入相同内容 → 收敛一份', () => {
291
+ const r = mergeTextThreeWay('a\n', 'a\nx\n', 'a\nx\n');
292
+ assert.equal(mergedOrThrow(r), 'a\nx\n');
293
+ });
294
+
295
+ test('mergeTextThreeWay: 双侧在同一位置插入不同内容 → 冲突', () => {
296
+ const r = mergeTextThreeWay('a\n', 'a\nx\n', 'a\ny\n');
297
+ assert.equal(r.ok, false);
298
+ assert.equal(r.conflicts[0]!.path, 'line 2');
299
+ });
300
+
301
+ test('mergeTextThreeWay: 双侧独立追加内容(不同位置语义同一行尾)→ 不误报', () => {
302
+ const r = mergeTextThreeWay('a\n', 'a\nx\n', 'a\n');
303
+ assert.equal(mergedOrThrow(r), 'a\nx\n');
304
+ });
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // 字节三方合并
308
+ // ---------------------------------------------------------------------------
309
+
310
+ test('mergeBytesThreeWay: 缺共同祖先(首次同步)→ ok=false,绝不猜', () => {
311
+ const r = mergeBytesThreeWay(undefined, enc('a\n'), enc('b\n'));
312
+ assert.equal(r.ok, false);
313
+ assert.equal(r.merged, undefined);
314
+ assert.equal(r.conflicts.length, 0);
315
+ });
316
+
317
+ test('mergeBytesThreeWay: 缺失文件(ancestor undefined)+ 二进制 → kind=binary', () => {
318
+ const r = mergeBytesThreeWay(undefined, new Uint8Array([0, 1]), new Uint8Array([0, 2]));
319
+ assert.equal(r.ok, false);
320
+ assert.equal(r.kind, 'binary');
321
+ });
322
+
323
+ test('mergeBytesThreeWay: 纯二进制 → ok=false, kind=binary, reason=binary', () => {
324
+ const r = mergeBytesThreeWay(enc('a\n'), new Uint8Array([0, 1, 2]), new Uint8Array([0, 3]));
325
+ assert.equal(r.ok, false);
326
+ assert.equal(r.kind, 'binary');
327
+ assert.equal(r.conflicts.length, 1);
328
+ assert.equal(r.conflicts[0]!.reason, 'binary');
329
+ });
330
+
331
+ test('mergeBytesThreeWay: 超过 1 MiB → ok=false, kind=too-large', () => {
332
+ const big = new Uint8Array(TEXT_MERGE_MAX_BYTES + 1).fill(0x61);
333
+ const r = mergeBytesThreeWay(enc('a\n'), big, enc('a\n'));
334
+ assert.equal(r.ok, false);
335
+ assert.equal(r.kind, 'too-large');
336
+ assert.equal(r.conflicts[0]!.reason, 'too-large');
337
+ });
338
+
339
+ test('mergeBytesThreeWay: 正常文本 → 合并后字节内容正确(kind=text)', () => {
340
+ const r = mergeBytesThreeWay(enc('a\nb\n'), enc('a\nB\n'), enc('a\nb\nc\n'));
341
+ assert.equal(r.ok, true);
342
+ assert.equal(r.kind, 'text');
343
+ assert.equal(dec(r.merged!), 'a\nB\nc\n');
344
+ });
345
+
346
+ test('mergeBytesThreeWay: 合法 JSON 文本 → kind=json,字节按 UTF-8 回写', () => {
347
+ const r = mergeBytesThreeWay(enc('{"a":1,"b":2}'), enc('{"a":1,"b":9}'), enc('{"a":7,"b":2}'));
348
+ assert.equal(r.ok, true);
349
+ assert.equal(r.kind, 'json');
350
+ assert.equal(dec(r.merged!), JSON.stringify({ a: 7, b: 9 }, null, 2) + '\n');
351
+ });
352
+
353
+ test('mergeBytesThreeWay: 文本冲突 → ok=false 且 kind 如实为 text', () => {
354
+ const r = mergeBytesThreeWay(enc('a\nb\n'), enc('a\nL\n'), enc('a\nR\n'));
355
+ assert.equal(r.ok, false);
356
+ assert.equal(r.kind, 'text');
357
+ assert.equal(r.conflicts[0]!.path, 'line 2');
358
+ });