dsh-config-manager 0.1.76 → 0.1.77

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 (71) 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.js +92 -12
  6. package/lib/client.d.ts +104 -9
  7. package/lib/client.js +1796 -714
  8. package/lib/core/messages.d.ts +1 -0
  9. package/lib/core/messages.js +2 -0
  10. package/lib/core/rollback-attribution.d.ts +81 -0
  11. package/lib/core/rollback-attribution.js +236 -0
  12. package/lib/core/rollback-merge.d.ts +22 -0
  13. package/lib/core/rollback-merge.js +203 -0
  14. package/lib/core/rollback-point.d.ts +114 -0
  15. package/lib/core/rollback-point.js +534 -17
  16. package/lib/core/types.d.ts +18 -0
  17. package/lib/index.d.ts +26 -3
  18. package/lib/index.js +129 -16
  19. package/lib/routes/recovery.js +11 -0
  20. package/lib/schema/section-registry.js +5 -5
  21. package/lib/ui/badge-tone.d.ts +16 -0
  22. package/lib/ui/badge-tone.js +20 -0
  23. package/lib/ui/i18n.d.ts +4 -4
  24. package/lib/ui/i18n.js +8 -8
  25. package/lib/ui/rollback-view.d.ts +95 -0
  26. package/lib/ui/rollback-view.js +55 -0
  27. package/lib/ui/selection-model.d.ts +140 -1
  28. package/lib/ui/selection-model.js +241 -9
  29. package/package.json +1 -1
  30. package/src/adapters/plugin-data-declared.ts +60 -0
  31. package/src/adapters/plugin-data.ts +7 -33
  32. package/src/adapters/plugins.ts +98 -13
  33. package/src/adapters/units.test.ts +26 -0
  34. package/src/client/chat/ChatEntryView.tsx +147 -46
  35. package/src/client/chat/chat-entry-view.test.ts +277 -1
  36. package/src/client/chat/chat-entry-view.ts +98 -4
  37. package/src/client/chat/chat-node-registration.ts +71 -0
  38. package/src/client/chat/chat-slots.ts +30 -12
  39. package/src/client/common/ContentPicker.tsx +581 -175
  40. package/src/client/common/host-primitives.ts +64 -1
  41. package/src/client/common/ui.tsx +17 -3
  42. package/src/client/config-manager.module.css +630 -397
  43. package/src/client/css-scale-guard.test.ts +41 -3
  44. package/src/client/css-token-guard.test.ts +10 -4
  45. package/src/client/export/ExportView.tsx +2 -0
  46. package/src/client/index.ts +15 -3
  47. package/src/client/locales.ts +54 -10
  48. package/src/client/recovery/RollbackCard.tsx +56 -2
  49. package/src/client/recovery/RollbackDiffPane.tsx +5 -0
  50. package/src/client/recovery/RollbackFileChanges.tsx +46 -2
  51. package/src/client/recovery/recovery-locales.ts +22 -0
  52. package/src/client/recovery/rollback-file-changes.test.ts +1 -1
  53. package/src/core/messages.ts +2 -0
  54. package/src/core/rollback-attribution.test.ts +120 -0
  55. package/src/core/rollback-attribution.ts +267 -0
  56. package/src/core/rollback-merge.test.ts +133 -0
  57. package/src/core/rollback-merge.ts +213 -0
  58. package/src/core/rollback-point.test.ts +412 -0
  59. package/src/core/rollback-point.ts +560 -20
  60. package/src/core/types.ts +19 -0
  61. package/src/index.facade.test.ts +60 -0
  62. package/src/index.ts +133 -19
  63. package/src/routes/recovery.ts +11 -0
  64. package/src/schema/section-registry.ts +5 -5
  65. package/src/ui/badge-tone.test.ts +23 -0
  66. package/src/ui/badge-tone.ts +27 -0
  67. package/src/ui/i18n.ts +8 -8
  68. package/src/ui/rollback-view.test.ts +27 -0
  69. package/src/ui/rollback-view.ts +139 -0
  70. package/src/ui/selection-model.test.ts +240 -2
  71. package/src/ui/selection-model.ts +349 -1
@@ -177,6 +177,7 @@ export declare const zh: {
177
177
  readonly 'host.sessionStoreShapeUnknown': '宿主会话存储列举(sessionPersistence.list())的元素形状无法解析(探测形状 {shape},{count} 个元素全部认不出):父子关系判定放弃,绝不当作「本机没有父子关系」(请升级 DSH,或把该形状反馈给插件)';
178
178
  readonly 'host.sessionStorePurposeReindex': '会话 header 重新索引';
179
179
  readonly 'host.sessionStorePurposeParentRelations': '父子关系判定';
180
+ readonly 'host.sessionStorePurposeRewindAttach': '回退子会话归位';
180
181
  readonly 'host.sessionStoreListFailed': '会话存储列举失败({purpose}):{reason}';
181
182
  readonly 'host.sessionStoreListNotArray': '会话存储列举返回非数组({purpose}):形状未知,按不可用处理';
182
183
  readonly 'host.sessionStorePartialUnresolved': '会话存储列举中有 {count}/{total} 个元素形状无法解析({purpose}):已解析的照常使用';
@@ -166,6 +166,7 @@ export const zh = {
166
166
  'host.sessionStoreShapeUnknown': '宿主会话存储列举(sessionPersistence.list())的元素形状无法解析(探测形状 {shape},{count} 个元素全部认不出):父子关系判定放弃,绝不当作「本机没有父子关系」(请升级 DSH,或把该形状反馈给插件)',
167
167
  'host.sessionStorePurposeReindex': '会话 header 重新索引',
168
168
  'host.sessionStorePurposeParentRelations': '父子关系判定',
169
+ 'host.sessionStorePurposeRewindAttach': '回退子会话归位',
169
170
  'host.sessionStoreListFailed': '会话存储列举失败({purpose}):{reason}',
170
171
  'host.sessionStoreListNotArray': '会话存储列举返回非数组({purpose}):形状未知,按不可用处理',
171
172
  'host.sessionStorePartialUnresolved': '会话存储列举中有 {count}/{total} 个元素形状无法解析({purpose}):已解析的照常使用',
@@ -603,6 +604,7 @@ export const en = {
603
604
  'host.sessionStoreShapeUnknown': 'Cannot parse the elements returned by the host session store (sessionPersistence.list(); probed shape {shape}, none of the {count} elements is recognizable): parent/child detection is abandoned rather than treated as "no parent relations on this machine" (please upgrade DSH or report this shape to the plugin)',
604
605
  'host.sessionStorePurposeReindex': 'session header re-index',
605
606
  'host.sessionStorePurposeParentRelations': 'parent/child detection',
607
+ 'host.sessionStorePurposeRewindAttach': 'rewind child session placement',
606
608
  'host.sessionStoreListFailed': 'Session store listing failed ({purpose}): {reason}',
607
609
  'host.sessionStoreListNotArray': 'Session store listing returned a non-array ({purpose}): unknown shape, treated as unavailable',
608
610
  'host.sessionStorePartialUnresolved': 'Session store listing: {count}/{total} elements have an unrecognized shape ({purpose}); the parsed ones are still used',
@@ -0,0 +1,81 @@
1
+ /** 台账文件名(与回滚点库同目录:dataDir/rollback-points/attribution.json) */
2
+ export declare const ROLLBACK_ATTRIBUTION_FILE_NAME = "attribution.json";
3
+ /** 条目上限(超出按 at 淘汰最旧)—— 台账无界增长会把宿主拖垮(见 issue #89 的教训) */
4
+ export declare const ROLLBACK_ATTRIBUTION_MAX_ENTRIES = 20000;
5
+ /**
6
+ * 认得的**写类**工具名。
7
+ *
8
+ * 为什么用白名单而不是「参数里有 path 就算写」:`read` / `grep` / `ops_inspect_local_file`
9
+ * 的参数里也有 path —— 把它们当写入者会让**读**污染归属,进而让回退误跳过本该恢复的文件。
10
+ */
11
+ export declare const ROLLBACK_WRITE_TOOL_NAMES: readonly string[];
12
+ /** 一条写入归属:谁(会话)在什么时候用哪个工具写了这个路径 */
13
+ export interface RollbackWriteFact {
14
+ sessionId: string;
15
+ tool: string;
16
+ callId: string;
17
+ /** 毫秒时间戳(本进程时钟) */
18
+ at: number;
19
+ turn: number | null;
20
+ }
21
+ /** 引擎只读这一面 */
22
+ export interface RollbackWriteAttributionPort {
23
+ /** 该路径最后一次的写入归属;undefined = **未判定**(不是「没人写过」) */
24
+ writerOf(target: string): RollbackWriteFact | undefined;
25
+ }
26
+ /** 采集面(只有留点器的 noteEvent 用得上) */
27
+ export interface RollbackWriteRecorder {
28
+ /**
29
+ * 记一次 `tool/call` 事件载荷。返回**记录到的路径条数**:
30
+ * 0 = 不是写类工具 / 解析不出路径 = **未判定**(绝不代表「没写过」)。
31
+ */
32
+ noteToolCall(sessionId: string, data: unknown): number;
33
+ }
34
+ /**
35
+ * 从一次 `tool/call` 事件载荷里抽出**会被写的路径**。
36
+ *
37
+ * `arguments` 在 DSH 的事件里是 **JSON 字符串**(不是对象)——两种形态都收,
38
+ * 解不出来就返回空数组(= 未判定),绝不抛错。
39
+ */
40
+ export declare function toolCallWritePaths(name: unknown, argumentsValue: unknown): string[];
41
+ export interface RollbackWriteAttributionOptions {
42
+ /** 台账文件绝对路径 */
43
+ file: string;
44
+ now?: () => number;
45
+ maxEntries?: number;
46
+ /** 落盘延迟(毫秒);<= 0 表示不自动落盘(测试用) */
47
+ flushDelayMs?: number;
48
+ warn?: (line: string) => void;
49
+ }
50
+ /**
51
+ * 内存台账 + best-effort 落盘。
52
+ *
53
+ * 落盘的作用只有一个:**进程重启后**打开旧会话回退时,归属还在。
54
+ * 因此写入是延迟合并的(一次 tool/call 一行,立即落盘会把 I/O 打成筛子)。
55
+ */
56
+ export declare class RollbackWriteAttribution implements RollbackWriteAttributionPort, RollbackWriteRecorder {
57
+ private readonly entries;
58
+ private readonly file;
59
+ private readonly now;
60
+ private readonly maxEntries;
61
+ private readonly flushDelayMs;
62
+ private readonly warn;
63
+ private loadPromise;
64
+ private flushTimer;
65
+ private writing;
66
+ constructor(options: RollbackWriteAttributionOptions);
67
+ /** 台账条目数(测试与诊断用) */
68
+ get size(): number;
69
+ /** 惰性读盘(幂等;读失败 = 空表,只 warn) */
70
+ load(): Promise<void>;
71
+ private read;
72
+ /** 记一次 tool/call(返回记录到的路径条数;0 = 不是写类工具或解析不出路径 = 未判定) */
73
+ noteToolCall(sessionId: string, data: unknown): number;
74
+ /** 淘汰最旧条目(按 at 升序删到上限内) */
75
+ private evictOldest;
76
+ writerOf(target: string): RollbackWriteFact | undefined;
77
+ /** 延迟落盘(多次调用只落一次) */
78
+ scheduleFlush(): void;
79
+ /** 立刻落盘(原子:同目录临时文件 + rename;失败只 warn,绝不上抛) */
80
+ flush(): Promise<void>;
81
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * 工具调用的**写入归属台账** —— 「这个路径最后一次是哪个会话的哪次工具调用写的」。
3
+ *
4
+ * 为什么需要(2026-10-17 用户反馈):回滚点的「工作区态」是**整个工作区**的快照,
5
+ * 回退也按整棵树执行 ⇒ 同一工作区里对话 A 改了文件,对话 B 回退时会把 A 的改动一起撤销。
6
+ * 有了归属,引擎才能在预览里标出「这个文件在留点之后是**别的**会话写的」,
7
+ * 让界面把它默认排除(用户仍可手动勾回来)。
8
+ *
9
+ * 三条不变量(不得放宽):
10
+ * ① 采集**尽力而为**:只有能解析出路径的写类工具(write/edit/ops_replace…)才记。
11
+ * `pwsh` / `run_code` / 外部编辑器写的文件**没有归属** ⇒ 查不到 = **未判定**。
12
+ * ② **未判定 ≠ 别的会话写的**:只有确知最后写入者是别的会话时才敢标 foreign。
13
+ * 拿不准就按老口径(照常恢复)—— 少标一次,比误判一次安全。
14
+ * ③ 落盘失败只 warn:台账丢了只让归属退化成「未判定」,绝不参与留点 / 回退的成败判定。
15
+ */
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ /** 台账文件名(与回滚点库同目录:dataDir/rollback-points/attribution.json) */
19
+ export const ROLLBACK_ATTRIBUTION_FILE_NAME = 'attribution.json';
20
+ /** 条目上限(超出按 at 淘汰最旧)—— 台账无界增长会把宿主拖垮(见 issue #89 的教训) */
21
+ export const ROLLBACK_ATTRIBUTION_MAX_ENTRIES = 20000;
22
+ /**
23
+ * 认得的**写类**工具名。
24
+ *
25
+ * 为什么用白名单而不是「参数里有 path 就算写」:`read` / `grep` / `ops_inspect_local_file`
26
+ * 的参数里也有 path —— 把它们当写入者会让**读**污染归属,进而让回退误跳过本该恢复的文件。
27
+ */
28
+ export const ROLLBACK_WRITE_TOOL_NAMES = [
29
+ 'write',
30
+ 'edit',
31
+ 'ops_replace',
32
+ 'ops_edit',
33
+ 'write_file',
34
+ 'str_replace',
35
+ 'multi_edit',
36
+ 'apply_patch',
37
+ ];
38
+ /** 写类工具里代表「目标路径」的参数名(DSH 官方 write/edit 用 file_path;本插件工具用 path) */
39
+ const WRITE_PATH_ARG_KEYS = ['file_path', 'path', 'filePath'];
40
+ /** 路径比较键(与 `rollback-point.ts` 的 pathKey 同一口径:resolve + win32 折叠大小写) */
41
+ function attributionPathKey(value) {
42
+ const resolved = path.resolve(value);
43
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
44
+ }
45
+ /**
46
+ * 从一次 `tool/call` 事件载荷里抽出**会被写的路径**。
47
+ *
48
+ * `arguments` 在 DSH 的事件里是 **JSON 字符串**(不是对象)——两种形态都收,
49
+ * 解不出来就返回空数组(= 未判定),绝不抛错。
50
+ */
51
+ export function toolCallWritePaths(name, argumentsValue) {
52
+ if (typeof name !== 'string' || !ROLLBACK_WRITE_TOOL_NAMES.includes(name))
53
+ return [];
54
+ let parsed = argumentsValue;
55
+ if (typeof argumentsValue === 'string') {
56
+ try {
57
+ parsed = JSON.parse(argumentsValue);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ }
63
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
64
+ return [];
65
+ const record = parsed;
66
+ const out = [];
67
+ for (const key of WRITE_PATH_ARG_KEYS) {
68
+ const value = record[key];
69
+ if (typeof value === 'string' && value.trim() !== '')
70
+ out.push(value);
71
+ }
72
+ // 同一个调用里两个键指向同一路径是常见的(别名)⇒ 去重,避免同一路径记两次
73
+ return [...new Set(out)];
74
+ }
75
+ function readFact(value) {
76
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
77
+ return null;
78
+ const record = value;
79
+ const sessionId = record.sessionId;
80
+ const at = record.at;
81
+ if (typeof sessionId !== 'string' || sessionId === '')
82
+ return null;
83
+ if (typeof at !== 'number' || !Number.isFinite(at))
84
+ return null;
85
+ return {
86
+ sessionId,
87
+ at,
88
+ tool: typeof record.tool === 'string' ? record.tool : '',
89
+ callId: typeof record.callId === 'string' ? record.callId : '',
90
+ turn: typeof record.turn === 'number' && Number.isFinite(record.turn) ? record.turn : null,
91
+ };
92
+ }
93
+ /**
94
+ * 内存台账 + best-effort 落盘。
95
+ *
96
+ * 落盘的作用只有一个:**进程重启后**打开旧会话回退时,归属还在。
97
+ * 因此写入是延迟合并的(一次 tool/call 一行,立即落盘会把 I/O 打成筛子)。
98
+ */
99
+ export class RollbackWriteAttribution {
100
+ entries = new Map();
101
+ file;
102
+ now;
103
+ maxEntries;
104
+ flushDelayMs;
105
+ warn;
106
+ loadPromise = null;
107
+ flushTimer = null;
108
+ writing = null;
109
+ constructor(options) {
110
+ this.file = path.resolve(options.file);
111
+ this.now = options.now ?? (() => Date.now());
112
+ this.maxEntries = Math.max(1, options.maxEntries ?? ROLLBACK_ATTRIBUTION_MAX_ENTRIES);
113
+ this.flushDelayMs = options.flushDelayMs ?? 1000;
114
+ this.warn = options.warn ?? (() => { });
115
+ }
116
+ /** 台账条目数(测试与诊断用) */
117
+ get size() {
118
+ return this.entries.size;
119
+ }
120
+ /** 惰性读盘(幂等;读失败 = 空表,只 warn) */
121
+ async load() {
122
+ if (this.loadPromise === null)
123
+ this.loadPromise = this.read();
124
+ return this.loadPromise;
125
+ }
126
+ async read() {
127
+ try {
128
+ const text = await fs.readFile(this.file, 'utf8');
129
+ const parsed = JSON.parse(text);
130
+ if (parsed.version !== 1 || parsed.entries === null || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
131
+ this.warn('rollback attribution ignored (shape): ' + this.file);
132
+ return;
133
+ }
134
+ for (const [key, value] of Object.entries(parsed.entries)) {
135
+ const fact = readFact(value);
136
+ // 读盘期间可能已经有本进程新记的条目 ⇒ **新记录优先**(磁盘只是旧快照)
137
+ if (fact !== null && !this.entries.has(key))
138
+ this.entries.set(key, fact);
139
+ }
140
+ }
141
+ catch (error) {
142
+ const code = error.code;
143
+ // ENOENT = 还没写过台账(正常);其它错误如实留痕但不抛
144
+ if (code !== 'ENOENT')
145
+ this.warn('rollback attribution unreadable: ' + this.file + ' ' + String(code ?? error));
146
+ }
147
+ }
148
+ /** 记一次 tool/call(返回记录到的路径条数;0 = 不是写类工具或解析不出路径 = 未判定) */
149
+ noteToolCall(sessionId, data) {
150
+ if (typeof sessionId !== 'string' || sessionId === '')
151
+ return 0;
152
+ if (data === null || typeof data !== 'object')
153
+ return 0;
154
+ const record = data;
155
+ const paths = toolCallWritePaths(record.name, record.arguments);
156
+ if (paths.length === 0)
157
+ return 0;
158
+ const fact = {
159
+ sessionId,
160
+ tool: typeof record.name === 'string' ? record.name : '',
161
+ callId: typeof record.callId === 'string' ? record.callId : '',
162
+ at: this.now(),
163
+ turn: typeof record.turn === 'number' && Number.isFinite(record.turn) ? record.turn : null,
164
+ };
165
+ for (const target of paths)
166
+ this.entries.set(attributionPathKey(target), fact);
167
+ if (this.entries.size > this.maxEntries)
168
+ this.evictOldest();
169
+ this.scheduleFlush();
170
+ return paths.length;
171
+ }
172
+ /** 淘汰最旧条目(按 at 升序删到上限内) */
173
+ evictOldest() {
174
+ const sorted = [...this.entries.entries()].sort((a, b) => a[1].at - b[1].at);
175
+ const over = this.entries.size - this.maxEntries;
176
+ for (let i = 0; i < over; i += 1) {
177
+ const item = sorted[i];
178
+ if (item !== undefined)
179
+ this.entries.delete(item[0]);
180
+ }
181
+ }
182
+ writerOf(target) {
183
+ return this.entries.get(attributionPathKey(target));
184
+ }
185
+ /** 延迟落盘(多次调用只落一次) */
186
+ scheduleFlush() {
187
+ if (this.flushDelayMs <= 0 || this.flushTimer !== null)
188
+ return;
189
+ this.flushTimer = setTimeout(() => {
190
+ this.flushTimer = null;
191
+ void this.flush();
192
+ }, this.flushDelayMs);
193
+ this.flushTimer.unref?.();
194
+ }
195
+ /** 立刻落盘(原子:同目录临时文件 + rename;失败只 warn,绝不上抛) */
196
+ async flush() {
197
+ if (this.writing !== null)
198
+ return this.writing;
199
+ this.writing = (async () => {
200
+ try {
201
+ await fs.mkdir(path.dirname(this.file), { recursive: true });
202
+ const entriesSnapshot = Object.fromEntries(this.entries);
203
+ // **写前合并**(2026-10-17 修):本进程内存里只有自己见过的写入,而同一个工作区可能同时被
204
+ // 另一个 DSH 进程写归属 —— 各自「整体覆盖」会把对方的记录抹掉。真机实测:归属里缺了对方
205
+ // 那次编辑,导致回退时识别不到「别人改过这个文件」,进而把对方的改动静默覆盖掉。
206
+ // 同键取 `at` 较新者:时间戳是这里唯一可信的先后判据。
207
+ try {
208
+ const existing = JSON.parse(await fs.readFile(this.file, 'utf8'));
209
+ if (existing.version === 1 && existing.entries !== null && typeof existing.entries === 'object' && !Array.isArray(existing.entries)) {
210
+ for (const [key, value] of Object.entries(existing.entries)) {
211
+ const fact = readFact(value);
212
+ if (fact === null)
213
+ continue;
214
+ const mine = entriesSnapshot[key];
215
+ if (mine === undefined || fact.at > mine.at)
216
+ entriesSnapshot[key] = fact;
217
+ }
218
+ }
219
+ }
220
+ catch { /* 读不到就当没有别人的记录:只写自己这份(绝不因为读失败而放弃落盘) */ }
221
+ const payload = { version: 1, entries: entriesSnapshot };
222
+ const tmp = this.file + '.tmp';
223
+ await fs.writeFile(tmp, JSON.stringify(payload), 'utf8');
224
+ await fs.rename(tmp, this.file);
225
+ }
226
+ catch (error) {
227
+ this.warn('rollback attribution write failed: ' + this.file + ' ' + String(error.code ?? error));
228
+ }
229
+ finally {
230
+ this.writing = null;
231
+ }
232
+ })();
233
+ return this.writing;
234
+ }
235
+ }
236
+ //# sourceMappingURL=rollback-attribution.js.map
@@ -0,0 +1,22 @@
1
+ /** 合并结论(`ok:false` 时调用方必须**零写入**) */
2
+ export type ThreeWayMergeReason = 'clean' | 'conflict' | 'degraded' | 'binary';
3
+ export interface ThreeWayMergeResult {
4
+ /** true = 结果可直接写入;false = 必须放弃写入并如实报告 */
5
+ ok: boolean;
6
+ /** 合并结果(`ok:false` 时是 theirs 原文 —— 方便调用方做「保持现状」的比较,绝不写入) */
7
+ text: string;
8
+ reason: ThreeWayMergeReason;
9
+ /** 冲突块数(>0 ⇒ ok=false) */
10
+ conflicts: number;
11
+ /** 本次合并**撤销**的行数(base 有、ours 没有)与**保留**的对方新增行数(诊断用) */
12
+ revertedLines: number;
13
+ /** 依赖风险提示(启发式;空数组 = 未发现明显风险,**不代表安全**) */
14
+ risks: string[];
15
+ }
16
+ /**
17
+ * 三方合并(纯函数、无 I/O)。
18
+ *
19
+ * 语义:结果是 `ours` 上再应用 `(theirs - base)` —— 撤销「我们从 base 改到 ours 的那部分」,
20
+ * 同时保留「别人从 base 改到 theirs 的那部分」。
21
+ */
22
+ export declare function mergeThreeWay(ours: string, base: string, theirs: string): ThreeWayMergeResult;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * 行级**三方合并**(base / ours / theirs)—— 回退时「撤销本会话的改动、保留别人的改动」的判定内核。
3
+ *
4
+ * 为什么需要它(2026-10-17 跨对话安全回退):
5
+ * 回滚点的「工作区态」是整棵树的快照,回退若直接整文件覆盖,会把别的对话在留点之后
6
+ * 产生的修改一起抹掉。正确语义是:把 **base→ours**(本会话那次改动的反向)与
7
+ * **base→theirs**(别人后来的改动)同时应用到文件上 —— 即标准三方合并。
8
+ *
9
+ * 三方是谁(由调用方给定,本模块只管算):
10
+ * · `base` = 共同祖先:本会话改动之后、别人的改动之前的那个版本;
11
+ * · `ours` = 回退目标:回滚点里记录的内容(= 本会话改之前的版本);
12
+ * · `theirs` = 当前磁盘上的内容。
13
+ * 结果为 `ours + (theirs - base)`,即撤销本会话、保留别人。
14
+ *
15
+ * 四条硬规矩:
16
+ * ① **不确定就不合并**:任一方向的 diff 被规模预算降级(`degraded`)⇒ 直接拒绝(reason=`degraded`),
17
+ * 绝不拿一个「整块替换」的近似编辑脚本去算合并。
18
+ * ② **冲突绝不自动选边**:两侧改到同一处(含同一插入点)就报冲突,返回 theirs 原文 + `ok:false`,
19
+ * 调用方必须**不写文件**并把冲突交给用户。
20
+ * ③ **二进制不合并**(含 NUL 即二进制,与 git 同口径)。
21
+ * ④ **依赖风险只提示、不假装证明**:合并要删掉的行里的标识符若仍出现在结果中,
22
+ * 给出**启发式**风险提示(`risks`)—— 它不构成「删除不安全」的证明,也绝不因此声称合并正确。
23
+ */
24
+ import { diffLines, looksBinary } from '../utils/line-diff.js';
25
+ /** 标识符取样用的最小长度(太短的词(i、id)同名词满天飞,只会制造噪声) */
26
+ const RISK_TOKEN_MIN = 4;
27
+ /** 不参与依赖提示的通用词(语言关键字 / 极常见标识符) */
28
+ const RISK_STOPWORDS = new Set([
29
+ 'const', 'let', 'var', 'function', 'return', 'import', 'export', 'from', 'class', 'interface', 'type',
30
+ 'public', 'private', 'readonly', 'async', 'await', 'true', 'false', 'null', 'undefined', 'this', 'void',
31
+ 'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue', 'new', 'throw', 'try', 'catch',
32
+ 'string', 'number', 'boolean', 'object', 'Array', 'Promise', 'Record', 'Partial', 'default',
33
+ ]);
34
+ /**
35
+ * 一段文本相对 base 的**变更块**(按 base 的行区间表示)。
36
+ * 返回 null = 该方向的 diff 被降级(规模预算),调用方必须放弃合并。
37
+ */
38
+ function changeBlocks(base, target) {
39
+ // context=0:只要变更行,块边界自己算(省得剔上下文)
40
+ const diff = diffLines(base, target, { context: 0 });
41
+ if (diff.degraded)
42
+ return null;
43
+ const blocks = [];
44
+ for (const hunk of diff.hunks) {
45
+ // 当前块与「已消耗到 base 的哪一行」
46
+ let block = null;
47
+ let cursor = Math.max(0, hunk.oldStart - 1);
48
+ const flush = () => {
49
+ if (block !== null)
50
+ blocks.push(block);
51
+ block = null;
52
+ };
53
+ for (const line of hunk.lines) {
54
+ if (line.kind === 'context') {
55
+ flush();
56
+ if (line.oldNo !== null)
57
+ cursor = line.oldNo;
58
+ continue;
59
+ }
60
+ if (line.kind === 'del') {
61
+ const at = line.oldNo === null ? cursor : line.oldNo - 1;
62
+ if (block === null)
63
+ block = { start: at, end: at, lines: [] };
64
+ block.end = at + 1;
65
+ cursor = at + 1;
66
+ continue;
67
+ }
68
+ // add:没有 del 时是纯插入(位置 = 当前游标)
69
+ if (block === null)
70
+ block = { start: cursor, end: cursor, lines: [] };
71
+ block.lines.push(line.text);
72
+ }
73
+ flush();
74
+ }
75
+ return blocks;
76
+ }
77
+ /** 两个块在 base 上是否**互相触碰**(保守:含边界与纯插入点) */
78
+ function touches(a, b) {
79
+ const aPoint = a.start === a.end;
80
+ const bPoint = b.start === b.end;
81
+ if (aPoint && bPoint)
82
+ return a.start === b.start;
83
+ // **纯插入点落在区间端点不算冲突**:「删掉 `[s,e)`」与「在 e 处插入」可以共存(先删、再在删除后
84
+ // 的位置插入)。真机 2026-10-17 的「撤销 A 的描述 + 保留 B 的描述」正是这种形状:A 删掉了尾部
85
+ // 一行,B 又在其后追加了一行 —— 旧判据把它们当冲突,于是回退变成「什么都不做」。
86
+ // 只有落在区间**内部**(严格介于 s 与 e 之间)的插入才是真正说不清的冲突。
87
+ if (aPoint)
88
+ return a.start > b.start && a.start < b.end;
89
+ if (bPoint)
90
+ return b.start > a.start && b.start < a.end;
91
+ return a.start < b.end && b.start < a.end;
92
+ }
93
+ /** 两侧的块是否「改成了同一件事」(内容逐行相同即可取其一,不算冲突) */
94
+ function sameChange(a, b) {
95
+ if (a.lines.length !== b.lines.length)
96
+ return false;
97
+ for (let i = 0; i < a.lines.length; i += 1)
98
+ if (a.lines[i] !== b.lines[i])
99
+ return false;
100
+ return true;
101
+ }
102
+ /** 从被撤销的行里取「可能仍被引用」的标识符(启发式,见模块头 ④) */
103
+ function riskTokens(lines) {
104
+ const seen = new Set();
105
+ for (const line of lines) {
106
+ for (const token of line.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []) {
107
+ if (token.length < RISK_TOKEN_MIN)
108
+ continue;
109
+ if (RISK_STOPWORDS.has(token))
110
+ continue;
111
+ seen.add(token);
112
+ }
113
+ }
114
+ return [...seen];
115
+ }
116
+ /**
117
+ * 三方合并(纯函数、无 I/O)。
118
+ *
119
+ * 语义:结果是 `ours` 上再应用 `(theirs - base)` —— 撤销「我们从 base 改到 ours 的那部分」,
120
+ * 同时保留「别人从 base 改到 theirs 的那部分」。
121
+ */
122
+ export function mergeThreeWay(ours, base, theirs) {
123
+ const empty = (reason, conflicts = 0, risks = []) => ({
124
+ ok: false, text: theirs, reason, conflicts, revertedLines: 0, risks,
125
+ });
126
+ // 没有基线就退化成「谁的改动都不认识」—— 调用方负责在无 base 时根本不调用本函数
127
+ if (looksBinary(base) || looksBinary(ours) || looksBinary(theirs))
128
+ return empty('binary');
129
+ if (ours === base) {
130
+ // 本会话没改过 ⇒ 结论就是「保持别人现在的内容」(不是失败)
131
+ return { ok: true, text: theirs, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] };
132
+ }
133
+ if (theirs === base) {
134
+ // 别人没改过 ⇒ 直接回到 ours
135
+ return { ok: true, text: ours, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] };
136
+ }
137
+ if (ours === theirs) {
138
+ // 双方最终内容一致 ⇒ 谁都不用动
139
+ return { ok: true, text: theirs, reason: 'clean', conflicts: 0, revertedLines: 0, risks: [] };
140
+ }
141
+ const oursBlocks = changeBlocks(base, ours);
142
+ const theirsBlocks = changeBlocks(base, theirs);
143
+ if (oursBlocks === null || theirsBlocks === null)
144
+ return empty('degraded');
145
+ // 冲突扫描:任一 ours 块与任一 theirs 块触碰且内容不同 ⇒ 冲突
146
+ let conflicts = 0;
147
+ for (const a of oursBlocks) {
148
+ for (const b of theirsBlocks) {
149
+ if (!touches(a, b))
150
+ continue;
151
+ if (sameChange(a, b))
152
+ continue;
153
+ conflicts += 1;
154
+ }
155
+ }
156
+ if (conflicts > 0)
157
+ return empty('conflict', conflicts);
158
+ const baseLines = splitLinesOf(base);
159
+ const merged = [];
160
+ const reverted = [];
161
+ let cursor = 0;
162
+ // 把两侧的块按 base 位置归并(位置相同的「同一改动」只应用一次)
163
+ const all = [
164
+ ...oursBlocks.map((block) => ({ block, owner: 'ours' })),
165
+ ...theirsBlocks.map((block) => ({ block, owner: 'theirs' })),
166
+ ].sort((left, right) => left.block.start - right.block.start);
167
+ for (const item of all) {
168
+ const { block, owner } = item;
169
+ if (block.start < cursor) {
170
+ // 已被同一位置的另一个块消费(只可能是「同一改动」)—— 跳过重复应用
171
+ continue;
172
+ }
173
+ for (let i = cursor; i < block.start && i < baseLines.length; i += 1)
174
+ merged.push(baseLines[i]);
175
+ if (owner === 'ours') {
176
+ // 这一段就是「被撤销的本会话改动」:记下它删掉了哪些行,供依赖提示用
177
+ for (let i = block.start; i < block.end && i < baseLines.length; i += 1)
178
+ reverted.push(baseLines[i]);
179
+ }
180
+ for (const line of block.lines)
181
+ merged.push(line);
182
+ cursor = Math.max(cursor, block.end);
183
+ }
184
+ for (let i = cursor; i < baseLines.length; i += 1)
185
+ merged.push(baseLines[i]);
186
+ // 尾换行以**当前磁盘**(theirs)为准:我们是在它的基础上撤销本会话的改动,不擅改它原本的收尾形态
187
+ const text = merged.join('\n') + (merged.length > 0 && theirs.endsWith('\n') ? '\n' : '');
188
+ // 依赖风险(启发式):被撤销的行里出现过、且合并结果里仍能读到的标识符
189
+ const tokens = riskTokens(reverted);
190
+ const risks = tokens.filter((token) => new RegExp('\\b' + token + '\\b').test(text)).slice(0, 8);
191
+ return { ok: true, text, reason: 'clean', conflicts: 0, revertedLines: reverted.length, risks };
192
+ }
193
+ /** 与 `splitLines` 同口径的本地折行(避免为了一个函数再引一层依赖) */
194
+ function splitLinesOf(text) {
195
+ if (text === '')
196
+ return [];
197
+ const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
198
+ const lines = normalized.split('\n');
199
+ if (lines.length > 0 && lines[lines.length - 1] === '')
200
+ lines.pop();
201
+ return lines;
202
+ }
203
+ //# sourceMappingURL=rollback-merge.js.map