@flotiarenor/dsh-tool-text-editor 1.0.0

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.
package/lib/core.mjs ADDED
@@ -0,0 +1,973 @@
1
+ // SPDX-FileCopyrightText: 2026 Flotiarenor
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * core.mjs —— 文本编辑核心。纯 Node,只用 `node:` 内置模块,不启动任何子进程。
5
+ *
6
+ * 它承担 dsh 原生 `write`/`edit` 在 Windows 上做不到的事:
7
+ * * **保留 UTF-8 BOM**(原生工具会丢);
8
+ * * **行尾跟随文件**(原生 `write` 会把 CRLF 文件拍成 LF);
9
+ * * 默认先出 unified diff,落盘前自动备份,记编辑台账;
10
+ * * 锚点可以从目标取(`grep` 正则 / `lines` 行号),不必手抄旧文本;
11
+ * * 匹配失败时给"最接近的候选",歧义时拒绝写盘而不是猜。
12
+ *
13
+ * 两条落盘保证:
14
+ * * **原子写**:同目录临时文件 + fsync + rename —— 中途被杀不会留下半个文件,也不会出现
15
+ * 截断写造成的空文件;
16
+ * * **同目标串行**:进程内按目标路径排队,并行工具调用不会互相覆盖(跨进程不串行,
17
+ * 那是另一件事,README 里写明了)。
18
+ *
19
+ * 备份与台账的格式:`<工作区>/.dsh/backups/<扁平化绝对路径>@<时间戳>` 与
20
+ * `<工作区>/.dsh/edits.log` 里的 JSONL 记录(字段见 `appendLedger`)。
21
+ */
22
+
23
+ import {
24
+ closeSync,
25
+ existsSync,
26
+ fsyncSync,
27
+ mkdirSync,
28
+ openSync,
29
+ readFileSync,
30
+ readdirSync,
31
+ renameSync,
32
+ statSync,
33
+ unlinkSync,
34
+ writeFileSync,
35
+ writeSync,
36
+ } from 'node:fs'
37
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
38
+
39
+ export const UTF8_BOM_BYTES = Buffer.from([0xef, 0xbb, 0xbf])
40
+ export const GUARD_DIRS = ['.git', '.dsh']
41
+ export const DEFAULT_CONTEXT = 3
42
+ /** LCS 动态规划的格子上限:超过就退化成"整块替换",避免大文件吃光内存。 */
43
+ const MAX_DIFF_CELLS = 4_000_000
44
+
45
+ /** 一次编辑用法错误(调用方原样返回给模型,不写盘)。 */
46
+ export class UsageError extends Error {}
47
+
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ // 一、文本与字节
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+
52
+ /** 任意来源的文本归一为「无 BOM、\n 行尾」的逻辑文本。 */
53
+ export function toLf(text) {
54
+ if (typeof text !== 'string') return text
55
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
56
+ }
57
+
58
+ /**
59
+ * 只看字节,判断 BOM / 行尾风格 / 是否可编辑。
60
+ * @param bytes - 文件原始字节。
61
+ * @returns { bom, eol, crlf, lf, mixed, binary, invalidUtf8 }
62
+ */
63
+ export function analyzeBytes(bytes) {
64
+ const bom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
65
+ const body = bom ? bytes.subarray(3) : bytes
66
+ let crlf = 0
67
+ let lf = 0
68
+ let binary = false
69
+ for (let i = 0; i < body.length; i += 1) {
70
+ const byte = body[i]
71
+ if (byte === 0) { binary = true; break }
72
+ if (byte === 0x0a) {
73
+ if (i > 0 && body[i - 1] === 0x0d) crlf += 1
74
+ else lf += 1
75
+ }
76
+ }
77
+ // 判定规则:有 CRLF 且 CRLF >= 独立 LF ⇒ CRLF(跟随多数派,平局算 CRLF)
78
+ const eol = crlf > 0 && crlf >= lf ? '\r\n' : '\n'
79
+ return { bom, eol, crlf, lf, mixed: crlf > 0 && lf > 0, binary, invalidUtf8: false }
80
+ }
81
+
82
+ /**
83
+ * 解码为逻辑文本(无 BOM、\n 行尾)。非法 UTF-8 或二进制内容会被拒绝。
84
+ * @param bytes - 文件原始字节。
85
+ * @param label - 出错信息里用的显示路径。
86
+ * @returns { text, info }
87
+ * @throws {UsageError} 内容不可安全编辑时。
88
+ */
89
+ export function decodeText(bytes, label) {
90
+ const info = analyzeBytes(bytes)
91
+ if (info.binary) {
92
+ throw new UsageError(`${label}: 含 NUL 字节,看起来是二进制文件,拒绝编辑(本工具只处理 UTF-8 文本)`)
93
+ }
94
+ const body = info.bom ? bytes.subarray(3) : bytes
95
+ let text
96
+ try {
97
+ text = new TextDecoder('utf-8', { fatal: true }).decode(body)
98
+ } catch (error) {
99
+ throw new UsageError(`${label}: 不是合法 UTF-8(${error && error.message ? error.message : 'decode error'}),拒绝写盘以免损坏文件`)
100
+ }
101
+ return { text: toLf(text), info }
102
+ }
103
+
104
+ /** 逻辑文本 → 目标字节(恢复行尾 + 恢复 BOM)。绝不经过任何换行翻译层。 */
105
+ export function encodeText(text, info) {
106
+ const body = info.eol === '\n' ? text : text.replace(/\n/g, info.eol)
107
+ const raw = Buffer.from(body, 'utf8')
108
+ return info.bom ? Buffer.concat([UTF8_BOM_BYTES, raw]) : raw
109
+ }
110
+
111
+ /** 拆成"不带换行的行数组" + 末尾是否有换行。行号 = 下标 + 1。 */
112
+ export function splitLines(text) {
113
+ const endsWithNl = text.endsWith('\n')
114
+ if (text === '') return { lines: [], endsWithNl: false }
115
+ const lines = text.split('\n')
116
+ if (endsWithNl) lines.pop()
117
+ return { lines, endsWithNl }
118
+ }
119
+
120
+ /** splitLines 的逆运算。 */
121
+ export function joinLines(lines, endsWithNl) {
122
+ if (lines.length === 0) return ''
123
+ return lines.join('\n') + (endsWithNl ? '\n' : '')
124
+ }
125
+
126
+ /** 逻辑文本 → 字符下标 → 行号(1-based)的查找表。 */
127
+ function lineStarts(text) {
128
+ const starts = [0]
129
+ for (let i = 0; i < text.length; i += 1) if (text[i] === '\n') starts.push(i + 1)
130
+ return starts
131
+ }
132
+
133
+ function offsetToLine(starts, offset) {
134
+ let lo = 0
135
+ let hi = starts.length - 1
136
+ while (lo < hi) {
137
+ const mid = (lo + hi + 1) >> 1
138
+ if (starts[mid] <= offset) lo = mid
139
+ else hi = mid - 1
140
+ }
141
+ return lo + 1
142
+ }
143
+
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+ // 二、路径护栏与显示
146
+ // ─────────────────────────────────────────────────────────────────────────────
147
+
148
+ function normalizeKey(path) {
149
+ return resolve(path).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
150
+ }
151
+
152
+ /**
153
+ * 危险路径护栏:拒绝 `.git/`、`.dsh/` 内部,以及工作区之外的路径。
154
+ * @param absPath - 绝对目标路径。
155
+ * @param root - 工作区根。
156
+ * @returns 错误说明,`null` 表示放行。
157
+ */
158
+ export function guardTarget(absPath, root) {
159
+ const norm = normalizeKey(absPath)
160
+ const parts = norm.split('/')
161
+ for (const dir of GUARD_DIRS) {
162
+ if (parts.includes(dir)) {
163
+ return `拒绝写入 ${dir}/ 内部(${absPath})——那是 git / dsh 自己的地盘`
164
+ }
165
+ }
166
+ const rootKey = normalizeKey(root)
167
+ if (rootKey !== '' && norm !== rootKey && !norm.startsWith(rootKey + '/')) {
168
+ return `目标在工作区之外:${absPath}(工作区根=${root})`
169
+ }
170
+ return null
171
+ }
172
+
173
+ /** 工作区内的相对路径(用 / 分隔),用于 diff 头与台账。 */
174
+ export function relativeLabel(root, absPath) {
175
+ const rel = relative(root, absPath)
176
+ if (rel === '') return basename(absPath)
177
+ if (rel.startsWith('..') || isAbsolute(rel)) return absPath.replace(/\\/g, '/')
178
+ return rel.split(sep).join('/')
179
+ }
180
+
181
+ // ─────────────────────────────────────────────────────────────────────────────
182
+ // 三、锚点定位:--lines / --grep
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * `263` | `263:270` | `263-270` | 负数从末尾算 → 1-based 闭区间。
187
+ * @throws {UsageError} 格式错或越界。
188
+ */
189
+ export function parseLinespec(spec, text) {
190
+ const cleaned = String(spec).trim()
191
+ const range = /^(-?\d+)\s*[:,-]\s*(-?\d+)$/.exec(cleaned)
192
+ const single = /^-?\d+$/.exec(cleaned)
193
+ let a
194
+ let b
195
+ if (range) {
196
+ a = Number(range[1])
197
+ b = Number(range[2])
198
+ } else if (single) {
199
+ a = Number(single[0])
200
+ b = a
201
+ } else {
202
+ throw new UsageError(`lines 格式应为 263 或 263:270,收到 ${JSON.stringify(spec)}`)
203
+ }
204
+ const total = splitLines(text).lines.length
205
+ if (a < 0) a = total + 1 + a
206
+ if (b < 0) b = total + 1 + b
207
+ if (a < 1 || b < 1 || a > total || b > total) {
208
+ throw new UsageError(`lines ${spec} 超出范围(该文件共 ${total} 行)`)
209
+ }
210
+ return a > b ? { start: b, end: a } : { start: a, end: b }
211
+ }
212
+
213
+ /** 取第 startLine..endLine 行的**原文**(含各行的换行符,因此替换/删除不会留下空行)。 */
214
+ function rawRange(text, starts, startLine, endLine) {
215
+ const from = starts[startLine - 1]
216
+ const to = endLine < starts.length ? starts[endLine] : text.length
217
+ return text.slice(from, to)
218
+ }
219
+
220
+ /**
221
+ * 正则锚点:命中的那一行(多行正则取整块)。命中多处且未声明 count 时报错并列出候选行号。
222
+ * @throws {UsageError} 未命中或多处歧义。
223
+ */
224
+ export function grepSpan(pattern, text, ctx = 0, count = undefined) {
225
+ let re
226
+ try {
227
+ re = new RegExp(pattern, 'gm')
228
+ } catch (error) {
229
+ throw new UsageError(`grep 不是合法正则:${error.message}`)
230
+ }
231
+ const starts = lineStarts(text)
232
+ const { lines } = splitLines(text)
233
+ const hits = []
234
+ let match
235
+ while ((match = re.exec(text)) !== null) {
236
+ hits.push({ start: match.index, end: match.index + match[0].length })
237
+ if (match[0].length === 0) re.lastIndex += 1
238
+ if (hits.length > 5000) break
239
+ }
240
+ if (hits.length === 0) throw new UsageError(`grep ${JSON.stringify(pattern)} 在目标文件中没有命中`)
241
+ if (hits.length > 1 && count !== hits.length) {
242
+ const where = hits.slice(0, 8).map((h) => offsetToLine(starts, h.start)).join('、')
243
+ throw new UsageError(`grep ${JSON.stringify(pattern)} 命中 ${hits.length} 处(行 ${where})——写得更精确,或改用 lines/old_text,或用 count 声明命中数`)
244
+ }
245
+ const spans = hits.map((h) => ({
246
+ start: Math.max(1, offsetToLine(starts, h.start) - ctx),
247
+ end: Math.min(lines.length, offsetToLine(starts, Math.max(h.start, h.end - 1)) + ctx),
248
+ }))
249
+ if (spans.length === 1) {
250
+ return { ...spans[0], raw: rawRange(text, starts, spans[0].start, spans[0].end) }
251
+ }
252
+ spans.sort((x, y) => x.start - y.start)
253
+ const merged = []
254
+ for (const span of spans) {
255
+ const last = merged[merged.length - 1]
256
+ if (last && span.start <= last.end) last.end = Math.max(last.end, span.end)
257
+ else merged.push({ ...span })
258
+ }
259
+ const raw = merged.map((span) => rawRange(text, starts, span.start, span.end)).join('')
260
+ return { start: merged[0].start, end: merged[merged.length - 1].end, raw }
261
+ }
262
+
263
+ /** `lines`(纯数字/区间)与 `grep`(正则)统一入口。 */
264
+ export function resolveAnchor(spec, text, ctx = 0, count = undefined) {
265
+ const cleaned = String(spec).trim()
266
+ const numeric = /^-?\d+(\s*[:,-]\s*-?\d+)?$/.test(cleaned)
267
+ const span = numeric ? parseLinespec(cleaned, text) : grepSpan(cleaned, text, ctx, count)
268
+ const starts = lineStarts(text)
269
+ return { start: span.start, end: span.end, raw: span.raw ?? rawRange(text, starts, span.start, span.end) }
270
+ }
271
+
272
+ // ─────────────────────────────────────────────────────────────────────────────
273
+ // 四、匹配:精确 → 宽松 → 最接近候选
274
+ // ─────────────────────────────────────────────────────────────────────────────
275
+
276
+ function normalizeLine(kind, line) {
277
+ if (kind === 'ws') return line.replace(/[ \t]/g, '')
278
+ if (kind === 'trail') return line.replace(/\s+$/, '')
279
+ return line.trim()
280
+ }
281
+
282
+ /** 两段文本的相似度(0..1),基于字符级 LCS。 */
283
+ export function similarity(a, b) {
284
+ if (a === b) return 1
285
+ if (a.length === 0 || b.length === 0) return 0
286
+ const n = a.length
287
+ const m = b.length
288
+ if (n * m > MAX_DIFF_CELLS) {
289
+ // 太大就不做 DP:用长度比当粗略下界
290
+ return Math.min(n, m) / Math.max(n, m)
291
+ }
292
+ const width = m + 1
293
+ const dp = new Int32Array((n + 1) * width)
294
+ for (let i = n - 1; i >= 0; i -= 1) {
295
+ for (let j = m - 1; j >= 0; j -= 1) {
296
+ dp[i * width + j] = a[i] === b[j]
297
+ ? dp[(i + 1) * width + (j + 1)] + 1
298
+ : Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)])
299
+ }
300
+ }
301
+ return (2 * dp[0]) / (n + m)
302
+ }
303
+
304
+ function findAllExact(text, old) {
305
+ const spans = []
306
+ let from = 0
307
+ while (true) {
308
+ const index = text.indexOf(old, from)
309
+ if (index < 0) break
310
+ spans.push([index, index + old.length])
311
+ from = index + Math.max(1, old.length)
312
+ }
313
+ return spans
314
+ }
315
+
316
+ /** 宽松匹配:滑窗比较归一化后的行块,相似度 ≥ 0.9 视为命中。 */
317
+ function relaxedMatch(text, old, kind) {
318
+ const { lines } = splitLines(text)
319
+ const oldLines = splitLines(old).lines
320
+ if (oldLines.length === 0) return null
321
+ const keys = lines.map((line) => normalizeLine(kind, line))
322
+ const oldKeys = oldLines.map((line) => normalizeLine(kind, line))
323
+ const n = oldKeys.length
324
+ const starts = lineStarts(text)
325
+ let best = null
326
+ for (let i = 0; i + n <= lines.length; i += 1) {
327
+ if (keys[i] !== oldKeys[0] || keys[i + n - 1] !== oldKeys[n - 1]) continue
328
+ const ratio = similarity(keys.slice(i, i + n).join('\n'), oldKeys.join('\n'))
329
+ if (ratio >= 0.9 && (best === null || ratio > best.ratio)) best = { ratio, index: i }
330
+ }
331
+ if (best === null) return null
332
+ const startOffset = starts[best.index]
333
+ const endOffset = best.index + n < starts.length ? starts[best.index + n] : text.length
334
+ return [startOffset, endOffset]
335
+ }
336
+
337
+ /** 失败时给最接近的几处(行号 + 相似度 + 期望 vs 实际)。 */
338
+ export function nearestCandidates(text, old, limit = 3) {
339
+ const { lines } = splitLines(text)
340
+ const oldLines = splitLines(old).lines.length > 0 ? splitLines(old).lines : [old]
341
+ const n = oldLines.length
342
+ const firstKey = normalizeLine('loose', oldLines[0])
343
+ const scored = []
344
+ for (let i = 0; i + n <= lines.length; i += 1) {
345
+ const ratio = similarity(lines.slice(i, i + n).join('\n'), oldLines.join('\n'))
346
+ const anchored = normalizeLine('loose', lines[i]) === firstKey ? 0.05 : 0
347
+ scored.push({ index: i, ratio: ratio + anchored })
348
+ }
349
+ scored.sort((a, b) => b.ratio - a.ratio)
350
+ return scored
351
+ .filter((entry) => entry.ratio >= 0.35)
352
+ .slice(0, limit)
353
+ .map((entry) => ({
354
+ line: entry.index + 1,
355
+ endLine: Math.min(entry.index + n, lines.length),
356
+ ratio: Math.round(entry.ratio * 1000) / 1000,
357
+ expected: oldLines.slice(0, 12).join('\n'),
358
+ actual: lines.slice(entry.index, Math.min(entry.index + n, lines.length)).slice(0, 12).join('\n'),
359
+ }))
360
+ }
361
+
362
+ /**
363
+ * 在 text 里定位 old。
364
+ * @param text - 逻辑文本。
365
+ * @param old - 旧片段。
366
+ * @param options - `nth`(只取第 k 次)/ `strict`(禁用宽松匹配)/ `expect`(要求恰好 N 处)。
367
+ * @returns { ok, spans, mode, note, hits, candidates }
368
+ */
369
+ export function matchLiteral(text, old, options = {}) {
370
+ const { nth = 0, strict = false, expect } = options
371
+ const hits = findAllExact(text, old)
372
+ if (old === '') return { ok: false, spans: [], mode: 'miss', hits, note: 'old 不能为空', candidates: [] }
373
+ if (nth > 0) {
374
+ if (hits.length < nth) {
375
+ return { ok: false, spans: [], mode: 'miss', hits, note: `old 只出现 ${hits.length} 次,取不到第 ${nth} 次`, candidates: nearestCandidates(text, old) }
376
+ }
377
+ return { ok: true, spans: [hits[nth - 1]], mode: nth === 1 ? 'exact' : `exact:nth(${nth})`, hits, note: nth === 1 ? '' : `取第 ${nth} 次出现`, candidates: [] }
378
+ }
379
+ if (expect !== undefined && hits.length === expect) {
380
+ return { ok: true, spans: hits, mode: expect === 1 ? 'exact' : `exact:count(${expect})`, hits, note: expect === 1 ? '' : `命中 ${expect} 处,全部替换`, candidates: [] }
381
+ }
382
+ if (hits.length === 1) return { ok: true, spans: hits, mode: 'exact', hits, note: '', candidates: [] }
383
+ if (hits.length > 1) {
384
+ const starts = lineStarts(text)
385
+ const where = hits.slice(0, 10).map(([s]) => offsetToLine(starts, s)).join('、')
386
+ const reason = expect !== undefined
387
+ ? `old 出现 ${hits.length} 次(行 ${where}),与要求的 ${expect} 次不符 —— 已拒绝写盘`
388
+ : `old 出现 ${hits.length} 次(行 ${where})—— 用 nth 指定第几次,用 count 声明命中数,或写更长的 old`
389
+ return { ok: false, spans: [], mode: 'ambiguous', hits, note: reason, candidates: [] }
390
+ }
391
+ if (expect !== undefined) {
392
+ return { ok: false, spans: [], mode: 'miss', hits, note: `old 精确出现 0 处,要求 ${expect} 处`, candidates: nearestCandidates(text, old) }
393
+ }
394
+ if (strict) {
395
+ return { ok: false, spans: [], mode: 'miss', hits, note: 'old 在目标文件中不存在(strict 已禁用宽松匹配)', candidates: nearestCandidates(text, old) }
396
+ }
397
+ for (const [kind, label] of [['trail', '忽略行尾空白'], ['loose', '忽略行首/行尾空白'], ['ws', '忽略全部空白差异']]) {
398
+ const span = relaxedMatch(text, old, kind)
399
+ if (span) {
400
+ return {
401
+ ok: true,
402
+ spans: [span],
403
+ mode: kind,
404
+ hits,
405
+ note: `精确匹配失败,已用宽松模式命中(${label})—— 请核对 diff 的行号`,
406
+ candidates: [],
407
+ }
408
+ }
409
+ }
410
+ return { ok: false, spans: [], mode: 'miss', hits, note: 'old 在目标文件中不存在(精确与宽松匹配均失败)', candidates: nearestCandidates(text, old) }
411
+ }
412
+
413
+ // ─────────────────────────────────────────────────────────────────────────────
414
+ // 五、unified diff
415
+ // ─────────────────────────────────────────────────────────────────────────────
416
+
417
+ /** 行级 LCS 编辑脚本。 */
418
+ function lineOps(a, b) {
419
+ const n = a.length
420
+ const m = b.length
421
+ if (n === 0) return b.map((_, bi) => ({ t: '+', bi }))
422
+ if (m === 0) return a.map((_, ai) => ({ t: '-', ai }))
423
+ if (n * m > MAX_DIFF_CELLS) {
424
+ const ops = a.map((_, ai) => ({ t: '-', ai }))
425
+ for (let bi = 0; bi < m; bi += 1) ops.push({ t: '+', bi })
426
+ return ops
427
+ }
428
+ const width = m + 1
429
+ const dp = new Int32Array((n + 1) * width)
430
+ for (let i = n - 1; i >= 0; i -= 1) {
431
+ for (let j = m - 1; j >= 0; j -= 1) {
432
+ dp[i * width + j] = a[i] === b[j]
433
+ ? dp[(i + 1) * width + (j + 1)] + 1
434
+ : Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)])
435
+ }
436
+ }
437
+ const ops = []
438
+ let i = 0
439
+ let j = 0
440
+ while (i < n && j < m) {
441
+ if (a[i] === b[j]) {
442
+ ops.push({ t: '=', ai: i, bi: j })
443
+ i += 1
444
+ j += 1
445
+ } else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
446
+ ops.push({ t: '-', ai: i })
447
+ i += 1
448
+ } else {
449
+ ops.push({ t: '+', bi: j })
450
+ j += 1
451
+ }
452
+ }
453
+ while (i < n) { ops.push({ t: '-', ai: i }); i += 1 }
454
+ while (j < m) { ops.push({ t: '+', bi: j }); j += 1 }
455
+ return ops
456
+ }
457
+
458
+ function diffOpsFor(a, b) {
459
+ let prefix = 0
460
+ while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix += 1
461
+ let suffix = 0
462
+ while (
463
+ suffix < a.length - prefix
464
+ && suffix < b.length - prefix
465
+ && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]
466
+ ) suffix += 1
467
+ const midA = a.slice(prefix, a.length - suffix)
468
+ const midB = b.slice(prefix, b.length - suffix)
469
+ const ops = []
470
+ for (let i = 0; i < prefix; i += 1) ops.push({ t: '=', ai: i, bi: i })
471
+ for (const op of lineOps(midA, midB)) {
472
+ if (op.t === '=') ops.push({ t: '=', ai: prefix + op.ai, bi: prefix + op.bi })
473
+ else if (op.t === '-') ops.push({ t: '-', ai: prefix + op.ai })
474
+ else ops.push({ t: '+', bi: prefix + op.bi })
475
+ }
476
+ for (let i = 0; i < suffix; i += 1) {
477
+ ops.push({ t: '=', ai: a.length - suffix + i, bi: b.length - suffix + i })
478
+ }
479
+ return ops
480
+ }
481
+
482
+ /**
483
+ * unified diff 文本;`label` 只用于 `a/` `b/` 头。
484
+ * 与 GNU diff 一样标出"末尾没有换行"的那一侧。
485
+ */
486
+ export function unifiedDiff(label, before, after, context = DEFAULT_CONTEXT) {
487
+ if (before === after) return ''
488
+ const a = splitLines(before)
489
+ const b = splitLines(after)
490
+ const ops = diffOpsFor(a.lines, b.lines)
491
+ const noEolA = a.lines.length > 0 && !a.endsWithNl
492
+ const noEolB = b.lines.length > 0 && !b.endsWithNl
493
+ const changeIndexes = []
494
+ for (let i = 0; i < ops.length; i += 1) if (ops[i].t !== '=') changeIndexes.push(i)
495
+ if (changeIndexes.length === 0 && noEolA === noEolB) return ''
496
+
497
+ // 逐行内容相同、只差"文件末尾那个换行符":LCS 看不出差异,手工补一个 hunk
498
+ if (changeIndexes.length === 0) {
499
+ const last = a.lines.length - 1
500
+ const from = Math.max(0, last - context)
501
+ const count = last - from + 1
502
+ const out = [`--- a/${label}`, `+++ b/${label}`, `@@ -${from + 1},${count} +${from + 1},${count} @@`]
503
+ for (let i = from; i < last; i += 1) out.push(' ' + a.lines[i])
504
+ out.push('-' + a.lines[last])
505
+ if (noEolA) out.push('\')
506
+ out.push('+' + b.lines[last])
507
+ if (noEolB) out.push('\')
508
+ return out.join('\n') + '\n'
509
+ }
510
+
511
+ const blocks = []
512
+ for (const index of changeIndexes) {
513
+ const last = blocks[blocks.length - 1]
514
+ if (last && index - last[last.length - 1] <= context * 2 + 1) last.push(index)
515
+ else blocks.push([index])
516
+ }
517
+
518
+ const out = [`--- a/${label}`, `+++ b/${label}`]
519
+ for (const block of blocks) {
520
+ const first = block[0]
521
+ const last = block[block.length - 1]
522
+ const from = Math.max(0, first - context)
523
+ const to = Math.min(ops.length - 1, last + context)
524
+ let aConsumed = 0
525
+ let bConsumed = 0
526
+ for (let i = 0; i < from; i += 1) {
527
+ if (ops[i].t !== '+') aConsumed += 1
528
+ if (ops[i].t !== '-') bConsumed += 1
529
+ }
530
+ let aLen = 0
531
+ let bLen = 0
532
+ for (let i = from; i <= to; i += 1) {
533
+ if (ops[i].t !== '+') aLen += 1
534
+ if (ops[i].t !== '-') bLen += 1
535
+ }
536
+ const aStart = aLen === 0 ? aConsumed : aConsumed + 1
537
+ const bStart = bLen === 0 ? bConsumed : bConsumed + 1
538
+ out.push(`@@ -${aStart},${aLen} +${bStart},${bLen} @@`)
539
+ for (let i = from; i <= to; i += 1) {
540
+ const op = ops[i]
541
+ if (op.t === '=') {
542
+ out.push(' ' + a.lines[op.ai])
543
+ if (noEolA && op.ai === a.lines.length - 1) out.push('\')
544
+ } else if (op.t === '-') {
545
+ out.push('-' + a.lines[op.ai])
546
+ if (noEolA && op.ai === a.lines.length - 1) out.push('\')
547
+ } else {
548
+ out.push('+' + b.lines[op.bi])
549
+ if (noEolB && op.bi === b.lines.length - 1) out.push('\')
550
+ }
551
+ }
552
+ }
553
+ return out.join('\n') + '\n'
554
+ }
555
+
556
+ /** +/- 行数统计:直接数 diff 里的增删行。 */
557
+ export function diffStat(before, after) {
558
+ let added = 0
559
+ let removed = 0
560
+ for (const line of (before === after ? '' : unifiedDiff('stat', before, after)).split('\n')) {
561
+ if (line.startsWith('+++') || line.startsWith('---')) continue
562
+ if (line.startsWith('+')) added += 1
563
+ else if (line.startsWith('-')) removed += 1
564
+ }
565
+ return { added, removed }
566
+ }
567
+
568
+ // ─────────────────────────────────────────────────────────────────────────────
569
+ // 六、备份、台账、原子写、同目标串行
570
+ // ─────────────────────────────────────────────────────────────────────────────
571
+
572
+ function two(n, width = 2) {
573
+ return String(n).padStart(width, '0')
574
+ }
575
+
576
+ function stamp(date) {
577
+ return `${date.getFullYear()}${two(date.getMonth() + 1)}${two(date.getDate())}-`
578
+ + `${two(date.getHours())}${two(date.getMinutes())}${two(date.getSeconds())}-`
579
+ + `${two(date.getMilliseconds(), 3)}`
580
+ }
581
+
582
+ /** 备份文件名:<绝对路径扁平化>@<时间戳>。 */
583
+ export function backupFileNameFor(absPath, date = new Date()) {
584
+ const flat = resolve(absPath).replace(/:/g, '').replace(/[\\/]/g, '_')
585
+ return `${flat}@${stamp(date)}`
586
+ }
587
+
588
+ /** 落盘前的原件备份。 */
589
+ export function makeBackup(artifactsDir, absPath, bytes, date = new Date()) {
590
+ const dir = join(artifactsDir, 'backups')
591
+ mkdirSync(dir, { recursive: true })
592
+ const name = backupFileNameFor(absPath, date)
593
+ writeFileSync(join(dir, name), bytes)
594
+ return name
595
+ }
596
+
597
+ /** 追加一条 JSONL 台账:每行一个对象,字段见 record。 */
598
+ export function appendLedger(artifactsDir, entry) {
599
+ const file = join(artifactsDir, 'edits.log')
600
+ mkdirSync(artifactsDir, { recursive: true })
601
+ const time = new Date()
602
+ const record = {
603
+ time: `${time.getFullYear()}-${two(time.getMonth() + 1)}-${two(time.getDate())} `
604
+ + `${two(time.getHours())}:${two(time.getMinutes())}:${two(time.getSeconds())}`,
605
+ ...entry,
606
+ }
607
+ let id = 1
608
+ if (existsSync(file)) {
609
+ const raw = readFileSync(file, 'utf8')
610
+ id = raw.split('\n').filter((line) => line.trim() !== '').length + 1
611
+ }
612
+ record.id = id
613
+ writeFileSync(file, JSON.stringify(record) + '\n', { encoding: 'utf8', flag: 'a' })
614
+ return record
615
+ }
616
+
617
+ /**
618
+ * 原子写:同目录临时文件 → fsync → rename 覆盖。
619
+ * 中途被杀只会留下一个 `.tmp`,目标文件永远是完整的旧内容或完整的新内容。
620
+ */
621
+ export function writeFileAtomic(absPath, buffer) {
622
+ const dir = dirname(absPath)
623
+ let mode
624
+ try {
625
+ mode = statSync(absPath).mode
626
+ } catch {
627
+ mode = undefined
628
+ }
629
+ const tmp = join(dir, `.${basename(absPath)}.${process.pid.toString(36)}${Date.now().toString(36)}.tmp`)
630
+ let fd
631
+ try {
632
+ fd = openSync(tmp, 'wx', mode)
633
+ writeSync(fd, buffer)
634
+ fsyncSync(fd)
635
+ } finally {
636
+ if (fd !== undefined) closeSync(fd)
637
+ }
638
+ try {
639
+ renameSync(tmp, absPath)
640
+ } catch (error) {
641
+ try { unlinkSync(tmp) } catch { /* 尽力清理 */ }
642
+ throw error
643
+ }
644
+ }
645
+
646
+ const locks = new Map()
647
+
648
+ /** 同一目标路径的编辑在进程内串行(并行工具调用不会互相覆盖)。 */
649
+ export function withTargetLock(key, fn) {
650
+ const previous = locks.get(key) ?? Promise.resolve()
651
+ const next = previous.then(fn, fn)
652
+ const tail = next.then(() => {}, () => {})
653
+ locks.set(key, tail)
654
+ tail.finally(() => {
655
+ if (locks.get(key) === tail) locks.delete(key)
656
+ })
657
+ return next
658
+ }
659
+
660
+ /** 新建文件的行尾:同目录**多数派**(同扩展名优先),没有依据时 LF。 */
661
+ export function inferNewline(absPath) {
662
+ const configured = (process.env.DSH_TEXT_EDITOR_EOL ?? '').toLowerCase()
663
+ if (configured === 'lf') return '\n'
664
+ if (configured === 'crlf' || configured === 'cr') return '\r\n'
665
+ const dir = dirname(absPath)
666
+ const ext = basename(absPath).includes('.') ? basename(absPath).slice(basename(absPath).lastIndexOf('.')) : ''
667
+ let sameExtCrlf = 0
668
+ let sameExtLf = 0
669
+ let otherCrlf = 0
670
+ let otherLf = 0
671
+ let names
672
+ try {
673
+ names = readdirSync(dir).slice(0, 400)
674
+ } catch {
675
+ return '\n'
676
+ }
677
+ for (const name of names) {
678
+ const full = join(dir, name)
679
+ if (full === absPath) continue
680
+ let stat
681
+ try {
682
+ stat = statSync(full)
683
+ } catch {
684
+ continue
685
+ }
686
+ if (!stat.isFile() || stat.size === 0 || stat.size > 1024 * 1024) continue
687
+ let sample
688
+ try {
689
+ sample = readFileSync(full).subarray(0, 8192)
690
+ } catch {
691
+ continue
692
+ }
693
+ if (sample.includes(0)) continue
694
+ const crlf = countCrlf(sample)
695
+ const lf = countLf(sample) - crlf
696
+ if (crlf === 0 && lf === 0) continue
697
+ const isCrlf = crlf > 0 && crlf >= lf
698
+ if (name.endsWith(ext)) {
699
+ if (isCrlf) sameExtCrlf += 1
700
+ else sameExtLf += 1
701
+ } else if (isCrlf) otherCrlf += 1
702
+ else otherLf += 1
703
+ }
704
+ if (sameExtCrlf + sameExtLf > 0) return sameExtCrlf >= sameExtLf ? '\r\n' : '\n'
705
+ if (otherCrlf + otherLf > 0) return otherCrlf >= otherLf ? '\r\n' : '\n'
706
+ return '\n'
707
+ }
708
+
709
+ function countCrlf(buffer) {
710
+ let count = 0
711
+ for (let i = 1; i < buffer.length; i += 1) if (buffer[i] === 0x0a && buffer[i - 1] === 0x0d) count += 1
712
+ return count
713
+ }
714
+
715
+ function countLf(buffer) {
716
+ let count = 0
717
+ for (const byte of buffer) if (byte === 0x0a) count += 1
718
+ return count
719
+ }
720
+
721
+ // ─────────────────────────────────────────────────────────────────────────────
722
+ // 七、编辑模型
723
+ // ─────────────────────────────────────────────────────────────────────────────
724
+
725
+ function fail(path, message) {
726
+ return { path, ok: false, wrote: false, dryRun: false, stdout: '', stderr: message }
727
+ }
728
+
729
+ function hintText(match) {
730
+ if (match.mode === 'ambiguous') {
731
+ const spans = match.hits.slice(0, 12).map(([s]) => s)
732
+ return ` 候选:old 命中 ${match.hits.length} 处。用 nth 指定第几次,或 count 声明命中数。`
733
+ }
734
+ if (!match.candidates || match.candidates.length === 0) {
735
+ return ' 没有近似候选:核对文件是否搞错,或用 grep/lines 直接从目标取锚点(不必手抄)。'
736
+ }
737
+ const lines = [' 最接近的候选(行号 | 相似度):']
738
+ for (const candidate of match.candidates) {
739
+ lines.push(` - 行 ${candidate.line}-${candidate.endLine} 相似度 ${candidate.ratio.toFixed(2)}`)
740
+ lines.push(` 目标实际:${JSON.stringify(candidate.actual.slice(0, 200))}`)
741
+ lines.push(` 你给的 old:${JSON.stringify(candidate.expected.slice(0, 200))}`)
742
+ }
743
+ return lines.join('\n')
744
+ }
745
+
746
+ /**
747
+ * 执行一次编辑/写入。后端无关:调用方只需给出归一后的 plan。
748
+ *
749
+ * plan(edit):`{ kind:'edit', filePath, mode, newText, oldText, anchor:{value}|null, count, nth, strict, dryRun, note }`
750
+ * plan(write):`{ kind:'write', filePath, content, dryRun, note }`
751
+ *
752
+ * @param context - `{ root, artifactsDir, backup, log, tool, context, newFileBom }`
753
+ * @returns 规范结果 `{ path, ok, wrote, dryRun, stdout, stderr }`
754
+ */
755
+ export async function applyPlan(plan, context) {
756
+ const {
757
+ root,
758
+ artifactsDir = join(root, '.dsh'),
759
+ backup = true,
760
+ log = true,
761
+ tool = 'edit_text',
762
+ context: diffContext = DEFAULT_CONTEXT,
763
+ newFileBom = false,
764
+ } = context
765
+ const absPath = isAbsolute(plan.filePath) ? resolve(plan.filePath) : resolve(root, plan.filePath)
766
+ const label = relativeLabel(root, absPath)
767
+
768
+ const guarded = guardTarget(absPath, root)
769
+ if (guarded) return fail(plan.filePath, guarded)
770
+
771
+ return await withTargetLock(absPath, async () => {
772
+ const exists = existsSync(absPath)
773
+ const kind = plan.kind === 'write' ? 'write' : 'edit'
774
+ if (kind === 'edit' && !exists) {
775
+ return fail(plan.filePath, `目标不存在:${plan.filePath}(新建请用 write_text)`)
776
+ }
777
+
778
+ let original = ''
779
+ let info
780
+ let bytesBefore = Buffer.alloc(0)
781
+ if (exists) {
782
+ bytesBefore = readFileSync(absPath)
783
+ try {
784
+ const decoded = decodeText(bytesBefore, label)
785
+ original = decoded.text
786
+ info = decoded.info
787
+ } catch (error) {
788
+ if (error instanceof UsageError) return fail(plan.filePath, error.message)
789
+ throw error
790
+ }
791
+ } else {
792
+ info = { bom: newFileBom, eol: inferNewline(absPath), crlf: 0, lf: 0, mixed: false, binary: false }
793
+ }
794
+
795
+ const warnings = []
796
+ if (info.mixed) {
797
+ warnings.push(`[warn] 目标文件行尾混用;写回统一为 ${info.eol === '\r\n' ? 'CRLF' : 'LF'}`)
798
+ }
799
+
800
+ let output
801
+ const applied = []
802
+
803
+ if (kind === 'write') {
804
+ output = toLf(plan.content ?? '')
805
+ if (output === original && exists) {
806
+ return fail(plan.filePath, '没有产生任何变化(新内容与现有内容一致)')
807
+ }
808
+ if (!output.endsWith('\n') && output !== '') {
809
+ warnings.push('[warn] 新内容不以换行结尾,文件末尾将没有换行符')
810
+ }
811
+ applied.push(['write', 1, Math.max(1, splitLines(output).lines.length), 'write', plan.note ?? ''])
812
+ } else {
813
+ const usage = []
814
+ const { lines } = splitLines(original)
815
+ if (plan.mode === 'append' || plan.mode === 'prepend') {
816
+ output = plan.mode === 'append' ? original : ''
817
+ if (plan.mode === 'append') {
818
+ let add = plan.newText
819
+ if (output !== '' && !output.endsWith('\n')) {
820
+ add = '\n' + add
821
+ warnings.push('[warn] 目标末尾本来没有换行符,已在追加前补一个')
822
+ }
823
+ if (add !== '' && !add.endsWith('\n')) warnings.push('[warn] 追加内容不以换行结尾,文件末尾将没有换行符')
824
+ output += add
825
+ } else {
826
+ let add = plan.newText
827
+ if (original !== '' && add !== '' && !add.endsWith('\n')) add += '\n'
828
+ output += add + original
829
+ }
830
+ applied.push([plan.mode, 1, 1, plan.mode, plan.note ?? ''])
831
+ } else {
832
+ const plans = []
833
+ if (plan.mode === 'replace') {
834
+ let target = plan.oldText
835
+ let match
836
+ if (plan.anchor) {
837
+ let anchorSpan
838
+ try {
839
+ anchorSpan = resolveAnchor(plan.anchor.value, original, 0)
840
+ } catch (error) {
841
+ usage.push(error instanceof UsageError ? error.message : String(error))
842
+ anchorSpan = null
843
+ }
844
+ if (anchorSpan === null) return fail(plan.filePath, usage.join('\n'))
845
+ target = anchorSpan.raw
846
+ const exact = matchLiteral(original, target, { nth: plan.nth ?? 0, strict: true })
847
+ match = exact.ok ? exact : matchLiteral(original, target, { nth: plan.nth ?? 0, strict: false })
848
+ if (match.ok) match.mode = `anchor:${match.mode}`
849
+ } else {
850
+ match = matchLiteral(original, target ?? '', {
851
+ nth: plan.nth ?? 0,
852
+ strict: plan.strict === true,
853
+ expect: plan.count,
854
+ })
855
+ }
856
+ if (!match.ok) {
857
+ return fail(plan.filePath, `${label}:${match.note}\n${hintText(match)}`)
858
+ }
859
+ if (plan.count !== undefined && (plan.nth ?? 0) === 0 && match.hits.length !== plan.count) {
860
+ return fail(plan.filePath, `${label}:old 出现 ${match.hits.length} 次,要求 ${plan.count} 次 —— 拒绝写盘`)
861
+ }
862
+ for (const [start, end] of match.spans) {
863
+ const startLine = offsetToLine(lineStarts(original), start)
864
+ const endLine = end > start ? offsetToLine(lineStarts(original), end - 1) : startLine
865
+ plans.push({ start, end, mode: match.mode, startLine, endLine })
866
+ }
867
+ if (match.mode === 'trail' || match.mode === 'loose' || match.mode === 'ws' || String(match.mode).includes('trail') || String(match.mode).includes('loose')) {
868
+ warnings.push(`[warn] ${match.note}`)
869
+ }
870
+ } else {
871
+ let anchorSpan
872
+ try {
873
+ anchorSpan = resolveAnchor(plan.anchor.value, original, 0)
874
+ } catch (error) {
875
+ return fail(plan.filePath, error instanceof UsageError ? error.message : String(error))
876
+ }
877
+ const starts = lineStarts(original)
878
+ const position = plan.mode === 'after'
879
+ ? (anchorSpan.end < starts.length ? starts[anchorSpan.end] : original.length)
880
+ : starts[anchorSpan.start - 1]
881
+ plans.push({
882
+ start: position,
883
+ end: position,
884
+ mode: plan.mode,
885
+ startLine: anchorSpan.start,
886
+ endLine: anchorSpan.end,
887
+ })
888
+ }
889
+
890
+ const sorted = [...plans].sort((a, b) => a.start - b.start || a.end - b.end)
891
+ for (let i = 1; i < sorted.length; i += 1) {
892
+ const previous = sorted[i - 1]
893
+ const current = sorted[i]
894
+ if (current.start < previous.end) {
895
+ return fail(plan.filePath, `两次编辑区间重叠(行 ${previous.startLine} 与 ${current.startLine})——请拆成两次调用`)
896
+ }
897
+ }
898
+
899
+ output = original
900
+ for (const item of [...plans].sort((a, b) => b.start - a.start)) {
901
+ output = output.slice(0, item.start) + plan.newText + output.slice(item.end)
902
+ applied.push(['replace', item.startLine, item.endLine, item.mode, plan.note ?? ''])
903
+ }
904
+ void lines
905
+ }
906
+ }
907
+
908
+ if (output === original && exists) {
909
+ return fail(plan.filePath, '没有产生任何变化(old 与 new 相同,或内容已一致)')
910
+ }
911
+ if (!exists && output === '') {
912
+ return fail(plan.filePath, '新建内容为空 —— 没有产生任何变化')
913
+ }
914
+
915
+ const diff = unifiedDiff(label, original, output, diffContext)
916
+ const stat = diffStat(original, output)
917
+ const created = !exists
918
+ const head = plan.dryRun
919
+ ? `=== ${label}${created ? '(新建)' : ''} | DRY RUN(未落盘)===`
920
+ : `=== ${label}${created ? '(新建)' : ''} | 已写入 ===`
921
+ const kinds = [...new Set(applied.map(([k, s, e]) => (k === 'replace' ? `replace@${s}${e !== s ? `-${e}` : ''}` : k)))]
922
+ .join('、')
923
+
924
+ if (plan.dryRun) {
925
+ return {
926
+ path: plan.filePath,
927
+ ok: true,
928
+ wrote: false,
929
+ dryRun: true,
930
+ stdout: [...warnings, head, diff.trimEnd(), `DRY RUN ${label}:${kinds}(+${stat.added}/-${stat.removed})`].filter((s) => s !== '').join('\n') + '\n',
931
+ stderr: '',
932
+ }
933
+ }
934
+
935
+ let backupName = null
936
+ if (exists && backup) {
937
+ backupName = makeBackup(artifactsDir, absPath, bytesBefore)
938
+ }
939
+ try {
940
+ writeFileAtomic(absPath, encodeText(output, info))
941
+ } catch (error) {
942
+ return fail(plan.filePath, `写入失败:${error && error.message ? error.message : String(error)}`)
943
+ }
944
+
945
+ if (log) {
946
+ const first = applied[0] ?? ['?', 0, 0, '', '']
947
+ appendLedger(artifactsDir, {
948
+ tool,
949
+ file: label,
950
+ abspath: absPath,
951
+ action: created ? 'create' : 'write',
952
+ kinds: applied.map(([k]) => k),
953
+ line_start: first[1],
954
+ line_end: first[2],
955
+ added: stat.added,
956
+ removed: stat.removed,
957
+ bom: info.bom,
958
+ eol: info.eol === '\r\n' ? 'CRLF' : 'LF',
959
+ backup: backupName,
960
+ summary: `${kinds}${plan.note ? `;${plan.note}` : ''}`,
961
+ })
962
+ }
963
+
964
+ return {
965
+ path: plan.filePath,
966
+ ok: true,
967
+ wrote: true,
968
+ dryRun: false,
969
+ stdout: [...warnings, head, diff.trimEnd(), `OK ${label}:${kinds}(+${stat.added}/-${stat.removed})${backupName ? `;备份 ${backupName}` : ''}`].filter((s) => s !== '').join('\n') + '\n',
970
+ stderr: '',
971
+ }
972
+ })
973
+ }