dsh-config-manager 0.1.76 → 0.1.78

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 (84) hide show
  1. package/lib/adapters/plugin-data-declared.d.ts +12 -0
  2. package/lib/adapters/plugin-data-declared.js +57 -0
  3. package/lib/adapters/plugin-data.d.ts +4 -6
  4. package/lib/adapters/plugin-data.js +8 -36
  5. package/lib/adapters/plugins.d.ts +16 -0
  6. package/lib/adapters/plugins.js +223 -16
  7. package/lib/client.d.ts +104 -9
  8. package/lib/client.js +1800 -718
  9. package/lib/core/analyzer.js +3 -1
  10. package/lib/core/backup.d.ts +9 -0
  11. package/lib/core/backup.js +16 -1
  12. package/lib/core/messages.d.ts +11 -0
  13. package/lib/core/messages.js +22 -0
  14. package/lib/core/plugin-cli.d.ts +26 -0
  15. package/lib/core/plugin-cli.js +62 -2
  16. package/lib/core/rollback-attribution.d.ts +81 -0
  17. package/lib/core/rollback-attribution.js +236 -0
  18. package/lib/core/rollback-merge.d.ts +22 -0
  19. package/lib/core/rollback-merge.js +203 -0
  20. package/lib/core/rollback-point.d.ts +114 -0
  21. package/lib/core/rollback-point.js +534 -17
  22. package/lib/core/types.d.ts +28 -0
  23. package/lib/index.d.ts +26 -3
  24. package/lib/index.js +129 -16
  25. package/lib/routes/recovery.js +11 -0
  26. package/lib/schema/section-registry.js +5 -5
  27. package/lib/schema/types.d.ts +14 -0
  28. package/lib/ui/badge-tone.d.ts +16 -0
  29. package/lib/ui/badge-tone.js +20 -0
  30. package/lib/ui/i18n.d.ts +4 -4
  31. package/lib/ui/i18n.js +8 -8
  32. package/lib/ui/rollback-view.d.ts +95 -0
  33. package/lib/ui/rollback-view.js +55 -0
  34. package/lib/ui/selection-model.d.ts +140 -1
  35. package/lib/ui/selection-model.js +241 -9
  36. package/package.json +1 -1
  37. package/src/adapters/plugin-data-declared.ts +60 -0
  38. package/src/adapters/plugin-data.ts +7 -33
  39. package/src/adapters/plugins.test.ts +162 -0
  40. package/src/adapters/plugins.ts +236 -17
  41. package/src/adapters/units.test.ts +26 -0
  42. package/src/client/chat/ChatEntryView.tsx +147 -46
  43. package/src/client/chat/chat-entry-view.test.ts +277 -1
  44. package/src/client/chat/chat-entry-view.ts +98 -4
  45. package/src/client/chat/chat-node-registration.ts +71 -0
  46. package/src/client/chat/chat-slots.ts +30 -12
  47. package/src/client/common/ContentPicker.tsx +581 -175
  48. package/src/client/common/host-primitives.ts +64 -1
  49. package/src/client/common/ui.tsx +17 -3
  50. package/src/client/config-manager.module.css +630 -397
  51. package/src/client/css-scale-guard.test.ts +41 -3
  52. package/src/client/css-token-guard.test.ts +10 -4
  53. package/src/client/export/ExportView.tsx +2 -0
  54. package/src/client/index.ts +15 -3
  55. package/src/client/locales.ts +54 -10
  56. package/src/client/recovery/RollbackCard.tsx +56 -2
  57. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  58. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  59. package/src/client/recovery/recovery-locales.ts +22 -0
  60. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  61. package/src/core/analyzer.ts +3 -1
  62. package/src/core/backup.ts +16 -1
  63. package/src/core/messages.ts +22 -0
  64. package/src/core/plugin-cli.fs.test.ts +65 -3
  65. package/src/core/plugin-cli.ts +55 -2
  66. package/src/core/rollback-attribution.test.ts +120 -0
  67. package/src/core/rollback-attribution.ts +267 -0
  68. package/src/core/rollback-merge.test.ts +133 -0
  69. package/src/core/rollback-merge.ts +213 -0
  70. package/src/core/rollback-point.test.ts +412 -0
  71. package/src/core/rollback-point.ts +560 -20
  72. package/src/core/types.ts +29 -0
  73. package/src/index.facade.test.ts +60 -0
  74. package/src/index.ts +133 -19
  75. package/src/routes/recovery.ts +11 -0
  76. package/src/schema/section-registry.ts +5 -5
  77. package/src/schema/types.ts +14 -0
  78. package/src/ui/badge-tone.test.ts +23 -0
  79. package/src/ui/badge-tone.ts +27 -0
  80. package/src/ui/i18n.ts +8 -8
  81. package/src/ui/rollback-view.test.ts +27 -0
  82. package/src/ui/rollback-view.ts +139 -0
  83. package/src/ui/selection-model.test.ts +240 -2
  84. package/src/ui/selection-model.ts +349 -1
@@ -28,6 +28,8 @@ import fs from 'node:fs/promises'
28
28
  import os from 'node:os'
29
29
  import path from 'node:path'
30
30
 
31
+ import { RollbackWriteAttribution } from './rollback-attribution.ts'
32
+
31
33
  import {
32
34
  ROLLBACK_ANCESTOR_MAX_DEPTH,
33
35
  ROLLBACK_DEFAULT_CAPTURE_INFLIGHT_BYTES,
@@ -3426,3 +3428,413 @@ test('R-41 回退期走查也有时间预算:中断只让候选变少(安全
3426
3428
  })
3427
3429
 
3428
3430
 
3431
+
3432
+ /* ------------------------------------------------------------------ A+B(2026-10-17):路径级选择性恢复 + 写入归属隔离 */
3433
+
3434
+ test('A-1 路径级选择性恢复:只恢复点名的文件,其余原样保留并如实计数', async (t) => {
3435
+ const fx = await makeFixture()
3436
+ t.after(fx.cleanup)
3437
+ const engine = makeEngine(fx)
3438
+ const captured = await capturePoint(engine, fx)
3439
+ const id = captured.point?.id
3440
+ assert.ok(id !== undefined)
3441
+ const a = path.join(fx.workspaceDir, 'a.txt')
3442
+ const b = path.join(fx.workspaceDir, 'src', 'b.txt')
3443
+ await fs.writeFile(a, 'A2')
3444
+ await fs.writeFile(b, 'B2')
3445
+ const result = await engine.rewind({ id, userConfirmed: true, mode: 'files', onlyPaths: [a] })
3446
+ assert.equal(result.outcome, 'restored')
3447
+ assert.equal(await readText(a), 'A1', '点名的文件被恢复')
3448
+ assert.equal(await readText(b), 'B2', '没点名的文件一个字节都不动')
3449
+ const segment = result.segments.find((s) => s.state === 'workspace')
3450
+ assert.equal(segment?.selectionSkipped, 1, '跳过的条目数必须如实回传')
3451
+ })
3452
+
3453
+ test('A-2 点名一个「既未记录、也不在删除候选」的路径 ⇒ fail-closed 零写入', async (t) => {
3454
+ const fx = await makeFixture()
3455
+ t.after(fx.cleanup)
3456
+ const engine = makeEngine(fx)
3457
+ const captured = await capturePoint(engine, fx)
3458
+ const id = captured.point?.id
3459
+ assert.ok(id !== undefined)
3460
+ const a = path.join(fx.workspaceDir, 'a.txt')
3461
+ await fs.writeFile(a, 'A2')
3462
+ const result = await engine.rewind({ id, userConfirmed: true, mode: 'files', onlyPaths: [path.join(fx.workspaceDir, 'nope.txt'), a] })
3463
+ assert.equal(result.outcome, 'failed')
3464
+ // 顶层 code 是既有口径(工作区段失败恒 restore-failed)—— 具体原因逐字在段里,可查
3465
+ assert.equal(result.segments[0]?.reasonCode, 'invalid-input')
3466
+ assert.equal(result.segments[0]?.detail.some((line) => line.includes('selection-unknown-path')), true)
3467
+ assert.equal(result.segments[0]?.restored, 0)
3468
+ assert.equal(await readText(a), 'A2', '拒绝时零写入(连点名的那条也不写)')
3469
+ })
3470
+
3471
+ test('B-1 归属隔离:留点后被**别的会话**写过的文件,回退不动它(并如实计数)', async (t) => {
3472
+ const fx = await makeFixture()
3473
+ t.after(fx.cleanup)
3474
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3475
+ const engine = makeEngine(fx, { attribution: attr })
3476
+ const captured = await capturePoint(engine, fx)
3477
+ const id = captured.point?.id
3478
+ assert.ok(id !== undefined)
3479
+ const a = path.join(fx.workspaceDir, 'a.txt')
3480
+ await fs.writeFile(a, 'A2')
3481
+ attr.noteToolCall('session-OTHER', { name: 'write', callId: 'c1', arguments: JSON.stringify({ file_path: a }) })
3482
+ const result = await engine.rewind({ id, userConfirmed: true, mode: 'files' })
3483
+ assert.equal(result.outcome, 'restored')
3484
+ assert.equal(await readText(a), 'A2', '别的会话的改动不被这次回退撤销')
3485
+ const segment = result.segments.find((s) => s.state === 'workspace')
3486
+ assert.equal(segment?.foreignSkipped, 1)
3487
+ })
3488
+
3489
+ test('B-2 归属是**本会话** / 未判定 ⇒ 照常恢复(未判定绝不当作别的会话写的)', async (t) => {
3490
+ const fx = await makeFixture()
3491
+ t.after(fx.cleanup)
3492
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3493
+ const engine = makeEngine(fx, { attribution: attr })
3494
+ const captured = await capturePoint(engine, fx)
3495
+ const id = captured.point?.id
3496
+ assert.ok(id !== undefined)
3497
+ const a = path.join(fx.workspaceDir, 'a.txt')
3498
+ await fs.writeFile(a, 'A2')
3499
+ // 本会话写的 ⇒ 归属有、但不是「别的会话」⇒ 照常恢复
3500
+ attr.noteToolCall('session-1111', { name: 'write', callId: 'c2', arguments: JSON.stringify({ file_path: a }) })
3501
+ const result = await engine.rewind({ id, userConfirmed: true, mode: 'files' })
3502
+ assert.equal(await readText(a), 'A1')
3503
+ assert.equal(result.segments.find((s) => s.state === 'workspace')?.foreignSkipped, undefined)
3504
+ })
3505
+
3506
+ test('B-3 显式点名压过归属隔离:用户明确要它,归属不再拦', async (t) => {
3507
+ const fx = await makeFixture()
3508
+ t.after(fx.cleanup)
3509
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3510
+ const engine = makeEngine(fx, { attribution: attr })
3511
+ const captured = await capturePoint(engine, fx)
3512
+ const id = captured.point?.id
3513
+ assert.ok(id !== undefined)
3514
+ const a = path.join(fx.workspaceDir, 'a.txt')
3515
+ await fs.writeFile(a, 'A2')
3516
+ attr.noteToolCall('session-OTHER', { name: 'write', callId: 'c3', arguments: JSON.stringify({ file_path: a }) })
3517
+ const result = await engine.rewind({ id, userConfirmed: true, mode: 'files', onlyPaths: [a] })
3518
+ assert.equal(await readText(a), 'A1', '显式点名 = 用户要它,归属隔离不再生效')
3519
+ assert.equal(result.segments.find((s) => s.state === 'workspace')?.foreignSkipped, undefined)
3520
+ })
3521
+
3522
+ /* ------------------------------------------------------------------ 跨对话三方合并(2026-10-17) */
3523
+
3524
+ const V0_TEXT = 'line1\nline2\nline3\nline4\nline5\n'
3525
+
3526
+ test('C-1 A、B 改同一文件的不同区域 ⇒ 撤销 A、保留 B(按最终内容断言)', async (t) => {
3527
+ const fx = await makeFixture()
3528
+ t.after(fx.cleanup)
3529
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3530
+ // 引擎时钟每次 +1ms ⇒ 三个时刻严格递增(同毫秒会让「目标点之后最早的点」找不到)
3531
+ const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
3532
+ const file = path.join(fx.workspaceDir, 'a.txt')
3533
+ await fs.writeFile(file, V0_TEXT)
3534
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3535
+ const idA = pa.point?.id
3536
+ assert.ok(idA !== undefined)
3537
+ // A 改第 1 行 ⇒ V1
3538
+ const v1 = 'A-changed\nline2\nline3\nline4\nline5\n'
3539
+ await fs.writeFile(file, v1)
3540
+ // B 的回合开始 ⇒ 它的点内容 = V1(三方合并的 base)
3541
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3542
+ // B 改第 5 行 ⇒ V2
3543
+ await fs.writeFile(file, 'A-changed\nline2\nline3\nline4\nB-changed\n')
3544
+ attr.noteToolCall('session-B', { name: 'write', callId: 'c1', arguments: JSON.stringify({ file_path: file }) })
3545
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3546
+ assert.equal(await readText(file), 'line1\nline2\nline3\nline4\nB-changed\n', 'A 的第 1 行被撤销、B 的第 5 行被保留')
3547
+ const segment = result.segments.find((s) => s.state === 'workspace')
3548
+ assert.equal(segment?.mergedCount, 1)
3549
+ assert.equal(segment?.conflictCount, undefined)
3550
+ })
3551
+
3552
+ test('C-2 A、B 改同一行 ⇒ 报冲突、文件保持 B 的版本(绝不自动选边)', async (t) => {
3553
+ const fx = await makeFixture()
3554
+ t.after(fx.cleanup)
3555
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3556
+ const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
3557
+ const file = path.join(fx.workspaceDir, 'a.txt')
3558
+ await fs.writeFile(file, V0_TEXT)
3559
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3560
+ const idA = pa.point?.id
3561
+ assert.ok(idA !== undefined)
3562
+ await fs.writeFile(file, 'line1\nline2\nA-same\nline4\nline5\n')
3563
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3564
+ const v2 = 'line1\nline2\nB-same\nline4\nline5\n'
3565
+ await fs.writeFile(file, v2)
3566
+ attr.noteToolCall('session-B', { name: 'write', callId: 'c2', arguments: JSON.stringify({ file_path: file }) })
3567
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3568
+ assert.equal(await readText(file), v2, '冲突时一个字节都不写')
3569
+ const segment = result.segments.find((s) => s.state === 'workspace')
3570
+ assert.equal(segment?.conflictCount, 1)
3571
+ assert.deepEqual(segment?.conflictPaths, [file])
3572
+ })
3573
+
3574
+ test('C-3 B 在留点后删除了文件 ⇒ 不重建(theirs-missing,如实报告)', async (t) => {
3575
+ const fx = await makeFixture()
3576
+ t.after(fx.cleanup)
3577
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3578
+ const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
3579
+ const file = path.join(fx.workspaceDir, 'a.txt')
3580
+ await fs.writeFile(file, V0_TEXT)
3581
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3582
+ const idA = pa.point?.id
3583
+ assert.ok(idA !== undefined)
3584
+ await fs.writeFile(file, 'A-changed\nline2\nline3\nline4\nline5\n')
3585
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3586
+ await fs.rm(file)
3587
+ attr.noteToolCall('session-B', { name: 'write', callId: 'c3', arguments: JSON.stringify({ file_path: file }) })
3588
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3589
+ await assert.rejects(fs.stat(file), '别人删掉的文件不得被这次回退悄悄重建')
3590
+ const segment = result.segments.find((s) => s.state === 'workspace')
3591
+ assert.equal(segment?.unavailableCount, 1)
3592
+ assert.equal(segment?.mergedCount, undefined)
3593
+ })
3594
+
3595
+ test('C-4 删除集保护:留点后由别的会话新建/写入的路径不被删', async (t) => {
3596
+ const fx = await makeFixture()
3597
+ t.after(fx.cleanup)
3598
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3599
+ const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
3600
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3601
+ const idA = pa.point?.id
3602
+ assert.ok(idA !== undefined)
3603
+ const created = path.join(fx.workspaceDir, 'other.txt')
3604
+ await fs.writeFile(created, 'by B')
3605
+ attr.noteToolCall('session-B', { name: 'write', callId: 'c4', arguments: JSON.stringify({ file_path: created }) })
3606
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', removeCreated: true })
3607
+ assert.equal(await readText(created), 'by B', '别的会话新建的文件不得被删')
3608
+ const segment = result.segments.find((s) => s.state === 'workspace')
3609
+ assert.equal((segment?.createdSkipped ?? []).includes(created), true)
3610
+ })
3611
+
3612
+ test('C-5 合并之后、写入之前文件又被改过 ⇒ 放弃写入(并发保护)', async (t) => {
3613
+ const fx = await makeFixture()
3614
+ t.after(fx.cleanup)
3615
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3616
+ const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
3617
+ const file = path.join(fx.workspaceDir, 'a.txt')
3618
+ await fs.writeFile(file, V0_TEXT)
3619
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3620
+ const idA = pa.point?.id
3621
+ assert.ok(idA !== undefined)
3622
+ await fs.writeFile(file, 'A-changed\nline2\nline3\nline4\nline5\n')
3623
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3624
+ await fs.writeFile(file, 'A-changed\nline2\nline3\nline4\nB-changed\n')
3625
+ attr.noteToolCall('session-B', { name: 'write', callId: 'c5', arguments: JSON.stringify({ file_path: file }) })
3626
+ // 在「前像暂存」这一步模拟外部再改一次(它紧跟在合并与 needed 判定之后、真正写入之前)
3627
+ const self = engine as unknown as { openUndoStager: () => Promise<unknown> }
3628
+ const original = self.openUndoStager.bind(engine)
3629
+ self.openUndoStager = async () => {
3630
+ await fs.writeFile(file, 'EXTERNAL-TOUCH\n')
3631
+ return await original()
3632
+ }
3633
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3634
+ assert.equal(await readText(file), 'EXTERNAL-TOUCH\n', '比我们更新的内容绝不被覆盖')
3635
+ const segment = result.segments.find((s) => s.state === 'workspace')
3636
+ assert.equal(segment?.concurrentCount, 1)
3637
+ })
3638
+
3639
+ test('C-6 没有中间版本 ⇒ 不写、如实报告(no-base)', async (t) => {
3640
+ const fx = await makeFixture()
3641
+ t.after(fx.cleanup)
3642
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3643
+ const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
3644
+ const file = path.join(fx.workspaceDir, 'a.txt')
3645
+ await fs.writeFile(file, V0_TEXT)
3646
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3647
+ const idA = pa.point?.id
3648
+ assert.ok(idA !== undefined)
3649
+ const later = 'line1\nline2\nline3\nline4\nSOMEONE-ELSE\n'
3650
+ await fs.writeFile(file, later)
3651
+ attr.noteToolCall('session-B', { name: 'write', callId: 'c6', arguments: JSON.stringify({ file_path: file }) })
3652
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3653
+ assert.equal(await readText(file), later, '拿不到共同祖先就不动它')
3654
+ const segment = result.segments.find((s) => s.state === 'workspace')
3655
+ assert.equal(segment?.unavailableCount, 1)
3656
+ })
3657
+
3658
+ test('C-7 对方没改过(theirs==base)⇒ 照常恢复本会话的目标版本', async (t) => {
3659
+ const fx = await makeFixture()
3660
+ t.after(fx.cleanup)
3661
+ const attr = new RollbackWriteAttribution({ file: path.join(fx.dataDir, 'rollback-points', 'attribution.json'), flushDelayMs: 0 })
3662
+ const engine = makeEngine(fx, { attribution: attr, now: steppingClock(1_000_000, 1) })
3663
+ const file = path.join(fx.workspaceDir, 'a.txt')
3664
+ await fs.writeFile(file, V0_TEXT)
3665
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3666
+ const idA = pa.point?.id
3667
+ assert.ok(idA !== undefined)
3668
+ const v1 = 'A-changed\nline2\nline3\nline4\nline5\n'
3669
+ await fs.writeFile(file, v1)
3670
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3671
+ // 归属表明「别的会话碰过它」,但内容其实没变(= base)⇒ 合并结果 = ours ⇒ 照常回退
3672
+ attr.noteToolCall('session-B', { name: 'write', callId: 'c7', arguments: JSON.stringify({ file_path: file }) })
3673
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3674
+ assert.equal(await readText(file), V0_TEXT, '对方没动过 ⇒ 回到目标版本')
3675
+ const segment = result.segments.find((s) => s.state === 'workspace')
3676
+ assert.equal(segment?.mergedCount, 1)
3677
+ })
3678
+
3679
+ test('C-8 **无归属也要合并**:归属缺失不得退回整文件覆盖(真机 2026-10-17 回归)', async (t) => {
3680
+ const fx = await makeFixture()
3681
+ t.after(fx.cleanup)
3682
+ // 刻意**不传 attribution**:归属可能因为工具白名单 / 跨进程整体覆盖 / 采集时机而整条缺失
3683
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
3684
+ const file = path.join(fx.workspaceDir, 'a.txt')
3685
+ await fs.writeFile(file, V0_TEXT)
3686
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3687
+ const idA = pa.point?.id
3688
+ assert.ok(idA !== undefined)
3689
+ await fs.writeFile(file, 'A-line\nline2\nline3\nline4\nline5\n')
3690
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3691
+ await fs.writeFile(file, 'A-line\nline2\nline3\nline4\nB-line\n')
3692
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3693
+ assert.equal(await readText(file), 'line1\nline2\nline3\nline4\nB-line\n',
3694
+ '没有归属证据时也必须三方合并:撤销 A、保留 B,绝不整文件覆盖')
3695
+ assert.equal(result.segments[0]?.mergedCount, 1)
3696
+ assert.equal(result.segments[0]?.conflictCount, undefined)
3697
+ })
3698
+
3699
+ test('C-9 只有本会话的点 ⇒ 保持旧语义(整文件覆盖回目标版本)', async (t) => {
3700
+ const fx = await makeFixture()
3701
+ t.after(fx.cleanup)
3702
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
3703
+ const file = path.join(fx.workspaceDir, 'a.txt')
3704
+ await fs.writeFile(file, V0_TEXT)
3705
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3706
+ const idA = pa.point?.id
3707
+ assert.ok(idA !== undefined)
3708
+ await fs.writeFile(file, 'A1\nline2\nline3\nline4\nline5\n')
3709
+ // 同一个会话的第二个回合点:它**不是**别人的共同祖先,不得拿来当 base
3710
+ await capturePoint(engine, fx, { sessionId: 'session-A' })
3711
+ await fs.writeFile(file, 'A1\nline2\nline3\nline4\nA2\n')
3712
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3713
+ assert.equal(await readText(file), V0_TEXT, '纯本会话回退仍是整文件回到目标版本')
3714
+ assert.equal(result.segments[0]?.mergedCount, undefined)
3715
+ })
3716
+
3717
+ test('C-10 删除保护不依赖归属:别的会话点里记录过的路径不删', async (t) => {
3718
+ const fx = await makeFixture()
3719
+ t.after(fx.cleanup)
3720
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
3721
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3722
+ const idA = pa.point?.id
3723
+ assert.ok(idA !== undefined)
3724
+ const created = path.join(fx.workspaceDir, 'other.txt')
3725
+ await fs.writeFile(created, 'by B')
3726
+ // B 的回合点记到了这个文件(= 它确实在留点之后被别人的回合捕获过)
3727
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3728
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files', removeCreated: true })
3729
+ assert.equal(await readText(created), 'by B', '别人碰过的文件不得被删(不依赖归属台账)')
3730
+ assert.equal((result.segments[0]?.createdSkipped ?? []).includes(created), true)
3731
+ })
3732
+
3733
+ test('C-11 预览与执行同口径:别人的那行不得算成「会被删」', async (t) => {
3734
+ const fx = await makeFixture()
3735
+ t.after(fx.cleanup)
3736
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
3737
+ const file = path.join(fx.workspaceDir, 'a.txt')
3738
+ await fs.writeFile(file, V0_TEXT)
3739
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3740
+ const idA = pa.point?.id
3741
+ assert.ok(idA !== undefined)
3742
+ await fs.writeFile(file, 'A-line\nline2\nline3\nline4\nline5\n')
3743
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3744
+ await fs.writeFile(file, 'A-line\nline2\nline3\nline4\nB-line\n')
3745
+ const preview = await engine.preview(idA)
3746
+ const row = (preview.workspace?.files ?? []).find((item) => item.path === file)
3747
+ assert.ok(row !== undefined, '该文件必须出现在「会被改动」清单里')
3748
+ assert.equal(row?.skipped, undefined, '能合并且确实要写 ⇒ 不是「不会改动」')
3749
+ // 只涉及 A 的那一行:写回 line1(+1)、删掉 A-line(−1);B 的 B-line 一个字都不算
3750
+ assert.equal(row?.added, 1)
3751
+ assert.equal(row?.removed, 1)
3752
+ })
3753
+
3754
+ test('C-12 预览标注「不会改动」:冲突文件的差异不给数字(不谎报会被改)', async (t) => {
3755
+ const fx = await makeFixture()
3756
+ t.after(fx.cleanup)
3757
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
3758
+ const file = path.join(fx.workspaceDir, 'a.txt')
3759
+ await fs.writeFile(file, V0_TEXT)
3760
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3761
+ const idA = pa.point?.id
3762
+ assert.ok(idA !== undefined)
3763
+ await fs.writeFile(file, 'line1\nline2\nA-same\nline4\nline5\n')
3764
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3765
+ await fs.writeFile(file, 'line1\nline2\nB-same\nline4\nline5\n')
3766
+ const preview = await engine.preview(idA)
3767
+ const row = (preview.workspace?.files ?? []).find((item) => item.path === file)
3768
+ assert.equal(row?.skipped, true, '冲突 ⇒ 预览必须标「不会改动」')
3769
+ assert.equal(row?.added, null, '「不改动」不是「0 行变化」')
3770
+ assert.equal(row?.removed, null)
3771
+ assert.equal(typeof row?.mergeReason, 'string')
3772
+ })
3773
+
3774
+ test('D-1 单文件差异与执行同口径:「回退后」一侧是合并结果,不是记录原文', async (t) => {
3775
+ const fx = await makeFixture()
3776
+ t.after(fx.cleanup)
3777
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
3778
+ const file = path.join(fx.workspaceDir, 'a.txt')
3779
+ await fs.writeFile(file, V0_TEXT)
3780
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3781
+ const idA = pa.point?.id
3782
+ assert.ok(idA !== undefined)
3783
+ await fs.writeFile(file, 'A-line\nline2\nline3\nline4\nline5\n')
3784
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3785
+ await fs.writeFile(file, 'A-line\nline2\nline3\nline4\nB-line\n')
3786
+ const diff = await engine.fileDiff(idA, file)
3787
+ assert.equal(diff.ok, true)
3788
+ assert.equal(diff.merged, true, '「回退后」必须标成合并结果')
3789
+ assert.equal(diff.mergeSkipped, undefined)
3790
+ assert.equal(diff.added, 1, '只涉及 A 那一行')
3791
+ assert.equal(diff.removed, 1)
3792
+ assert.equal(diff.after.lines, 5, '合并结果仍是 5 行(B 那一行还在)')
3793
+ })
3794
+
3795
+ test('D-2 冲突时单文件差异说「不会改动」:两侧相同、不给假的覆盖差异', async (t) => {
3796
+ const fx = await makeFixture()
3797
+ t.after(fx.cleanup)
3798
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
3799
+ const file = path.join(fx.workspaceDir, 'a.txt')
3800
+ await fs.writeFile(file, V0_TEXT)
3801
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3802
+ const idA = pa.point?.id
3803
+ assert.ok(idA !== undefined)
3804
+ await fs.writeFile(file, 'line1\nline2\nA-same\nline4\nline5\n')
3805
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3806
+ await fs.writeFile(file, 'line1\nline2\nB-same\nline4\nline5\n')
3807
+ const diff = await engine.fileDiff(idA, file)
3808
+ assert.equal(diff.mergeSkipped, true, '冲突 ⇒ 必须说「不会改动」')
3809
+ assert.equal(diff.merged, undefined)
3810
+ assert.equal(diff.identical, true, '「回退后」= 现状 ⇒ 两侧一致(不是「覆盖成记录原文」)')
3811
+ assert.equal(typeof diff.mergeReason, 'string')
3812
+ })
3813
+
3814
+ test('E-1 合并结论若等于现状 ⇒ 报「无法安全撤销」,绝不静默当成「无需改动」', async (t) => {
3815
+ const fx = await makeFixture()
3816
+ t.after(fx.cleanup)
3817
+ const engine = makeEngine(fx, { now: steppingClock(1_000_000, 1) })
3818
+ const file = path.join(fx.workspaceDir, 'a.txt')
3819
+ // V0(目标点)→ A 改动 → B 改动 → 之后又出现「两改动都在」的更晚快照(别的会话)
3820
+ await fs.writeFile(file, V0_TEXT)
3821
+ const pa = await capturePoint(engine, fx, { sessionId: 'session-A' })
3822
+ const idA = pa.point?.id
3823
+ assert.ok(idA !== undefined)
3824
+ await fs.writeFile(file, 'A-line\nline2\nline3\nline4\nline5\n')
3825
+ await capturePoint(engine, fx, { sessionId: 'session-B' })
3826
+ await fs.writeFile(file, 'A-line\nline2\nline3\nline4\nB-line\n')
3827
+ // 目标点之后、别的会话的一份**更晚**快照,其内容把两个改动都包含进去(真机里的 base 来源)
3828
+ await capturePoint(engine, fx, { sessionId: 'session-C' })
3829
+ // 现在磁盘上只剩 B 的改动(模拟「A 已被回退」之后的状态)
3830
+ const theirs = 'line1\nline2\nline3\nline4\nB-line\n'
3831
+ await fs.writeFile(file, theirs)
3832
+ const preview = await engine.preview(idA)
3833
+ const row = (preview.workspace?.files ?? []).find((item) => item.path === file)
3834
+ assert.equal(row?.skipped, true, '必须如实标「不会改动」,而不是显示成 0 个文件')
3835
+ assert.equal(row?.mergeReason, 'ambiguous')
3836
+ const result = await engine.rewind({ id: idA, userConfirmed: true, mode: 'files' })
3837
+ assert.equal(await readText(file), theirs, '无法安全撤销 ⇒ 一个字节都不写')
3838
+ const segment = result.segments.find((s) => s.state === 'workspace')
3839
+ assert.equal(segment?.unavailableCount, 1)
3840
+ })