aura-basetools 0.7.0 → 0.13.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.
@@ -98,6 +98,36 @@ function replaceTextOf(text) {
98
98
  return splitLinesWithEndings(text).lines.join('\n')
99
99
  }
100
100
 
101
+ /**
102
+ * 0.10.0:`replace` 的换行风格。
103
+ *
104
+ * 内置 `Edit` 会把新行的 LF 适配成文件已有的 CRLF;aura 原先严格按 `replace` 原样写,
105
+ * 模型写 `\n` 就会在 CRLF 文件里制造混合行尾。判据分三档:
106
+ * · `auto`(默认):用**被替换区域末行**的行尾(没有就看首行/文件里第一个非空行尾)作目标;
107
+ * · `preserve`:保持 `replace` 原样(旧的逐字节口径,含混合行尾);
108
+ * · `lf` / `crlf`:强制成统一行尾。
109
+ */
110
+ function targetEolFor(endings, startLine, endLine) {
111
+ const last = endings[endLine - 1]
112
+ if (typeof last === 'string' && last !== '') return last
113
+ const first = endings[startLine - 1]
114
+ if (typeof first === 'string' && first !== '') return first
115
+ return endings.find((ending) => typeof ending === 'string' && ending !== '') ?? '\n'
116
+ }
117
+
118
+ function adaptReplaceEol(text, mode, targetEol) {
119
+ if (mode === 'preserve' || text === '') return { text, adapted: false }
120
+ const target = mode === 'auto' ? targetEol : mode === 'lf' ? '\n' : mode === 'crlf' ? '\r\n' : null
121
+ if (target === null) {
122
+ throw new PatchError(
123
+ `unknown eol mode ${JSON.stringify(mode)}; use auto, preserve, lf, or crlf — nothing was written`,
124
+ 'PATCH_BAD_EOL',
125
+ )
126
+ }
127
+ const normalized = text.replace(/\r?\n/g, target)
128
+ return { text: normalized, adapted: normalized !== text }
129
+ }
130
+
101
131
  /**
102
132
  * 这份内容是不是**混合行尾**(同时存在 `\r\n` 与裸 `\n`)。
103
133
  *
@@ -173,9 +203,44 @@ function staleConflicts(observedSource, content, hunks, matches, filePath, revis
173
203
  // 这里曾经挂着 `currentToken`(当前内容的版本令牌),但全函数没有任何地方用它——死码,删掉;
174
204
  // 账本令牌的 BOM 那一半只有 {@link regionGate} 算 `basis` 时用得上。
175
205
  const conflicts = []
206
+ /**
207
+ * `replace_all` 的观测侧判据:**出现次数**前后一致。
208
+ *
209
+ * 它没有“一处区域”可比——一次 hunk 对应 N 处,`regionTextIn` 只会给其中一处(或因为不唯一
210
+ * 返回 null)。真正会发生的事故是“你读的时候有 3 处,现在只剩 2 处”:再全量替换就会
211
+ * 少改一处,且回执不会说。次数变了就拒;次数没变时,内容地址本来就该吸收位移。
212
+ */
213
+ const replaceAllObservedCounts = new Map()
214
+ const observedAllCount = (hunk) => {
215
+ const oldText = oldAddressOf(hunk)
216
+ if (typeof oldText !== 'string') return 0
217
+ /**
218
+ * 层序必须与 {@link locateAllByOld} 逐字一致(片段优先):两边取不同层时,
219
+ * 同一个 `old` 会在“现在有几处”和“读过时有几处”上给出两个数,闸门就会误拒。
220
+ */
221
+ const fragment = findFragmentTier(observedRows(), oldText)
222
+ if (fragment.spans.length > 0) return nonOverlappingCandidates(fragment.spans, true).length
223
+ const rows = addressRows(oldText)
224
+ const aligned = findWindowTier(observedRows(), rows)
225
+ return nonOverlappingCandidates(aligned.starts.map((start) => ({ start, span: rows.length })), false).length
226
+ }
227
+ const replaceAllReported = new Set()
176
228
  for (const match of matches) {
177
229
  const hunk = hunks[match.hunk]
178
230
  if (hunk === undefined) continue
231
+ if (hunk.replace_all === true) {
232
+ if (replaceAllReported.has(match.hunk)) continue
233
+ replaceAllReported.add(match.hunk)
234
+ if (!replaceAllObservedCounts.has(match.hunk)) {
235
+ replaceAllObservedCounts.set(match.hunk, observedAllCount(hunk))
236
+ }
237
+ const was = replaceAllObservedCounts.get(match.hunk)
238
+ const now = matches.filter((candidate) => candidate.hunk === match.hunk).length
239
+ if (was !== now) {
240
+ conflicts.push({ lines: [match.line, match.end], tier: match.tier, reason: 'occurrences-changed', was, now })
241
+ }
242
+ continue
243
+ }
179
244
  // 带了 `expect` 的 hunk 不走这里:它自己的断言就是闸门(且报错里会贴出原文)。
180
245
  // **句柄 hunk 是例外**(D3):句柄是"更准的读过的区间",它要**喂给**闸门,
181
246
  // 而不是借 `expect` 从闸门下面绕过去——句柄不是绕过 `PATCH_STALE_REGION` 的路。
@@ -333,6 +398,8 @@ function staleRegionRefusal(displayPath, conflicts) {
333
398
  ? `the text you addressed moved: line ${from} no longer holds it (it is now line ${first.nowAt})`
334
399
  : first.reason === 'moved-out'
335
400
  ? `the text you addressed is no longer at line ${from} and cannot be located unambiguously elsewhere`
401
+ : first.reason === 'occurrences-changed'
402
+ ? `\`replace_all\` matched ${first.now} occurrence(s) in the current file, but the version you read had ${first.was} (it would change a different set of places)`
336
403
  : first.reason === 'unobserved-revision'
337
404
  ? `the file now holds a revision this session has never read, so lines ${from}-${to} cannot be checked against what you saw (the text above them changed at some point)`
338
405
  : `lines ${from}-${to} were not present in the last observation — the file has changed shape since you read it`
@@ -353,6 +420,7 @@ function staleRegionRefusal(displayPath, conflicts) {
353
420
  lines: [from, to],
354
421
  ...(first.nowAt === undefined || first.nowAt === null ? {} : { now_at_line: first.nowAt }),
355
422
  },
423
+ ...(first.reason === 'occurrences-changed' ? { occurrence_count: { was: first.was, now: first.now } } : {}),
356
424
  others: conflicts.map((c) => ({
357
425
  lines: c.lines,
358
426
  by: 'unknown-writer',
@@ -514,6 +582,19 @@ function regionGate(hunks, content, planned, displayPath, options = {}) {
514
582
  const ledgerKey = ledgerIdentityKey(displayPath, options.identityKey)
515
583
  const observation = latestObservation(ledgerKey, { source: content })
516
584
  if (observation === null) return { clean: true, basis: 'none' }
585
+ /**
586
+ * 大文件观测只存令牌、不存全文(`observation-store` 的 `MAX_SOURCE`)。没有全文就没有
587
+ * 区域级比对可言——如实退回 `basis:'none'`,但**不改写**“这一版读过没有”的答案:
588
+ * 当前令牌在账本里 ⇒ `basis:'observation'`,否则 `none`。
589
+ */
590
+ if (typeof observation.source !== 'string') {
591
+ /**
592
+ * 只认**最新观测那一版**与当前内容相同。`hasObservedVersion` 还会命中更早的条目;
593
+ * 但“当前内容等于很久以前读过的某一版”不等于“它与最近一次观测一致”,
594
+ * 在没有全文可比时宁可标 `none`,也不冒充核对过。
595
+ */
596
+ return { clean: true, basis: observation.version === contentToken(content, byteOrderMark) ? 'observation' : 'none' }
597
+ }
517
598
  /**
518
599
  * 快路:**要判断的那份内容就是刚观测过的那一份**(逐字符相同)⇒ 没有陈旧可言(见顶部注释)。
519
600
  *
@@ -925,10 +1006,13 @@ function fragmentSpans(lines, fragment, norm) {
925
1006
  const from = rawOf(at)
926
1007
  const lastRaw = rawOf(at + chunk.length - 1)
927
1008
  if (from === -1 || lastRaw === -1) return { from: 0, to: 0 }
928
- /** 末字本身是空白时,把原文里同一段空白的整段都算进来(归一化把一段空白看成一个空格)。 */
929
- let to = lastRaw + 1
930
- while (to < rawLine.length && rawLine[to] === rawLine[lastRaw]) to += 1
931
- return { from, to }
1009
+ /**
1010
+ * 末列就是命中末字本身(`to` 为 0-based 排他)。旧代码还会把“与末字相同的后续字符”
1011
+ * 一并吞掉——那对 **exact** 层是实打实的越界(`old:"ax "` 会吃掉两个空格),
1012
+ * 对 norm-space 层也从未生效(`collapse` 每行都 trim,归一化片段不会以空白结尾)。
1013
+ * 片段层的承诺是“只换命中跨度自己”,所以这里不再做任何扩展。
1014
+ */
1015
+ return { from, to: lastRaw + 1 }
932
1016
  }
933
1017
  const spans = []
934
1018
  const seen = new Set()
@@ -977,17 +1061,18 @@ function fragmentSpans(lines, fragment, norm) {
977
1061
  */
978
1062
  const firstText = start === end ? want : lines[start].slice(head.from, head.to) + normLines[start].slice(startAt + firstWidth)
979
1063
  const lastText = start === end ? want : lines[end].slice(0, tail.to)
980
- /** `want` 里首尾两段之间夹着的那些**整行**(跨行时才非空)。 */
981
- const middleText = normLines.slice(start + 1, end).join('\n')
1064
+ /** `want` 里首尾两段之间夹着的那些**整行**(跨行时才非空;空行也必须保留)。 */
1065
+ const middleRows = normLines.slice(start + 1, end)
982
1066
  /**
983
1067
  * 单行时 `text` **从原文里取**(`lines[start].slice(startCol - 1, endCol)`)——归一化只用来
984
1068
  * 回答“在哪”,回执里那一段必须是文件里逐字的原文,否则空白折叠层下调用方拿到的引文
985
- * 在文件里根本搜不到。跨行时首尾各取自己那一半。
1069
+ * 在文件里根本搜不到。跨行时首尾各取自己那一半;中间行**逐个**拼回去,
1070
+ * 不能用 `join` 后过滤空串(那会把 `a\n\nb` 的空白行丢掉,`actual` 与 `expect` 随之错位)。
986
1071
  */
987
1072
  const text =
988
1073
  start === end
989
1074
  ? lines[start].slice(startCol - 1, endCol)
990
- : [firstText, middleText, lastText].filter((part) => part !== '').join('\n')
1075
+ : [firstText, ...middleRows, lastText].join('\n')
991
1076
  spans.push({ start, span: end - start + 1, startCol, endCol, endLineCol: tail.to, text })
992
1077
  }
993
1078
  return spans
@@ -1207,7 +1292,7 @@ function clipReceiptLine(text) {
1207
1292
  * 行尾不入摘要、不裁行、不加行号前缀)。它逐字节来自**磁盘上读到的内容**,
1208
1293
  * 不是模型转述的引文,也不是回执里那份被 `clipReceiptLine` 剪过的展示文本。
1209
1294
  * · **行号**(`:` 后那对):取该段在**文件里的绝对行号**(1-based、含两端),与
1210
- * `aura_read` 印的行号是同一把尺子——所以回执里 `!N| ` 的那些 N 与句柄里的数字对得上。
1295
+ * `aura_basetools_read` 印的行号是同一把尺子——所以回执里 `!N| ` 的那些 N 与句柄里的数字对得上。
1211
1296
  *
1212
1297
  * 铸出来的柄因此**自描述**:写路径 `resolveHandleRegion` 拿当前文件那几行现算一遍摘要就能核对,
1213
1298
  * 不需要任何句柄表(`handle-core.mjs` 顶部那三条形状约束里的第 3 条)。
@@ -1543,7 +1628,7 @@ function notFoundDetail(lines, oldRows, hunk, budget, ctx = null) {
1543
1628
  `\`NOT copyable\`, which means the receipt clipped that one to stay inside its budget and only ` +
1544
1629
  `its beginning is shown. Two marks in front of each one are the receipt's own margin, not ` +
1545
1630
  `part of the file: the \`=\` / \`!\` says whether your old text has that line, and the \`N| \` ` +
1546
- `after it is a line number (the same numbering aura_read prints).\n`
1631
+ `after it is a line number (the same numbering aura_basetools_read prints).\n`
1547
1632
  const fixed =
1548
1633
  `Closest region: lines ${region.startLine}-${region.endLine} of ${region.fileLines} — ${matchNote}${runNote}${diffNote}.\n` +
1549
1634
  bandIdentity
@@ -1615,8 +1700,8 @@ function notFoundDetail(lines, oldRows, hunk, budget, ctx = null) {
1615
1700
  region.run.len > 0
1616
1701
  ? `pass lines ${region.run.fileFrom}-${region.run.fileTo} exactly as they are on disk as \`old\` (your line(s) ${region.run.quoteFrom}-${region.run.quoteTo} are byte-for-byte those), then re-send the hunk`
1617
1702
  : region.basis === 'hint'
1618
- ? `re-read lines ${region.startLine}-${region.endLine} (aura_read), then pass the text exactly as it is there`
1619
- : 're-read the file (aura_read) and pass the text exactly as it is there — no line of your old text is in this file',
1703
+ ? `re-read lines ${region.startLine}-${region.endLine} (aura_basetools_read), then pass the text exactly as it is there`
1704
+ : 're-read the file (aura_basetools_read) and pass the text exactly as it is there — no line of your old text is in this file',
1620
1705
  }
1621
1706
  }
1622
1707
 
@@ -1955,7 +2040,6 @@ function locateByOld(lines, hunk, oldText, label, ctx = null) {
1955
2040
  * 片段层候选多带 `{startCol, endCol, text}`:这一层替换的**就是那一处跨度**,不是整行。
1956
2041
  * 收敛/就近两轮只看 `{start, span}`(行读数),所以它们原样沿用两层同一份代码。
1957
2042
  */
1958
- const frag = (c) => byFragment && typeof c?.text === 'string'
1959
2043
  let narrowed = ''
1960
2044
  if (candidates.length > 1 && ctxRows.length > 0) {
1961
2045
  const tight = candidates.filter((c) => contextSitsAt(lines, c.start, c.span, ctxRows, norm, 0))
@@ -1982,9 +2066,20 @@ function locateByOld(lines, hunk, oldText, label, ctx = null) {
1982
2066
  }
1983
2067
  if (candidates.length > 1) throw oldAmbiguityError(lines, candidates, label, total, ctx)
1984
2068
  const [chosen] = candidates
2069
+ return oldHitFromCandidate(lines, hunk, chosen, { tierKey, narrowed, byNear })
2070
+ }
2071
+
2072
+ /**
2073
+ * 把一个 `old` 候选变成一份定位命中(单处与 replace_all 共用,判据只写一次)。
2074
+ *
2075
+ * `tierPrefix` 只改回执里的层名:`old`(单处)/ `old-all`(全量替换)。除此之外两者逐字同形——
2076
+ * 回执字段、坐标核对、片段跨度、`after` 的取法都不分叉。
2077
+ */
2078
+ function oldHitFromCandidate(lines, hunk, chosen, { tierKey, narrowed = '', byNear = false, tierPrefix = 'old' } = {}) {
2079
+ const byFragment = typeof chosen.text === 'string'
1985
2080
  const startLine = chosen.start + 1
1986
2081
  const endLine = chosen.start + chosen.span
1987
- const tierBase = `old${tierKey === 'exact' ? '' : `~${tierKey}`}${narrowed}`
2082
+ const tierBase = `${tierPrefix}${tierKey === 'exact' ? '' : `~${tierKey}`}${narrowed}`
1988
2083
  const range = lineRangeOf(hunk)
1989
2084
  const coordinatesAgree = range !== undefined && range.start === startLine && range.end === endLine
1990
2085
  const coordinateNote =
@@ -1995,8 +2090,8 @@ function locateByOld(lines, hunk, oldText, label, ctx = null) {
1995
2090
  * 受寻址区间的原文:整行对齐层是一整行(或几行);片段层是**命中跨度自己**。
1996
2091
  * `expect` 闸门({@link expectVerdict})因此按真正被替换的那一段判,而不是按一行判。
1997
2092
  */
1998
- const addressed = frag(chosen) ? chosen.text : lines.slice(chosen.start, endLine).join('\n')
1999
- const fragmentNote = frag(chosen) ? fragmentNoteFor(startLine, chosen.startCol, chosen.endCol, chosen.text) : null
2093
+ const addressed = byFragment ? chosen.text : lines.slice(chosen.start, endLine).join('\n')
2094
+ const fragmentNote = byFragment ? fragmentNoteFor(startLine, chosen.startCol, chosen.endCol, chosen.text) : null
2000
2095
  /** 两条披露可能同时成立(片段层的跨度比整行窄 + 坐标与落点不符):都写进 `tier`,各说各的事实。 */
2001
2096
  const notes = [fragmentNote, coordinateNote].filter((note) => note !== null)
2002
2097
  return {
@@ -2011,7 +2106,7 @@ function locateByOld(lines, hunk, oldText, label, ctx = null) {
2011
2106
  * 跨行时 `columns[1]` 是**首行坐标**(拼接层拿它切 `prefix` / `tail`),
2012
2107
  * 末行那一侧的列号另放 `endLineCol`。
2013
2108
  */
2014
- ...(frag(chosen)
2109
+ ...(byFragment
2015
2110
  ? {
2016
2111
  fragment: {
2017
2112
  line: startLine,
@@ -2030,6 +2125,97 @@ function locateByOld(lines, hunk, oldText, label, ctx = null) {
2030
2125
  }
2031
2126
  }
2032
2127
 
2128
+ /**
2129
+ * `replace_all`:把 `old` 在文件里的**全部非重叠出现**各变成一份命中。
2130
+ *
2131
+ * 与 {@link locateByOld} 共用两层定位(整行对齐 → 行内片段)和同一份命中装配;差别有三点:
2132
+ * · 不再要求唯一,而是**逐处**返回(调用方一次改完);
2133
+ * · **优先用片段层**。整行层对 `norm-space` 的语义是"换掉整行",而 `replace_all` 的承诺是
2134
+ * "换掉这段文字"——整行优先会让同一段文字在行内出现时被漏掉(实测:
2135
+ * `old:"beta gamma"` 在 ` beta gamma` 与独立一行各出现一次,整行层只看见后一处);
2136
+ * · 重叠出现只保留最左边那一个(从左到右、不回头),与 `String.replaceAll` 的非重叠口径一致。
2137
+ * 例:`old="aa"` 打在 `aaaa` 上得到两处(1-2、3-4),不会把重叠的三处叠进一次替换。
2138
+ *
2139
+ * 上下文/坐标/`near` 在这里没有意义(它们都是"收敛到一处"的工具),参数面已拒绝;
2140
+ * 找不到任何一处时仍走 {@link oldNotFoundError},回执与单处地址同一份。
2141
+ */
2142
+ function locateAllByOld(lines, hunk, oldText, label, ctx = null) {
2143
+ const oldRows = addressRows(oldText)
2144
+ const fragment = findFragmentTier(lines, oldText)
2145
+ let candidates
2146
+ let tierKey
2147
+ let byFragment = true
2148
+ if (fragment.spans.length > 0) {
2149
+ candidates = fragment.spans
2150
+ tierKey = fragment.tier
2151
+ } else {
2152
+ /**
2153
+ * 片段层找不到、整行层却有时才走这里(正常输入不会出现:整行原文本来就是片段层的一个子串)。
2154
+ * 保留它是为了不把“找不到”的判决提前成另一种沉默——两层都空才是真的没有。
2155
+ */
2156
+ const aligned = findWindowTier(lines, oldRows)
2157
+ if (aligned.starts.length === 0) throw oldNotFoundError(label, lines, oldRows, hunk, ctx)
2158
+ candidates = aligned.starts.map((start) => ({ start, span: oldRows.length }))
2159
+ tierKey = aligned.tier
2160
+ byFragment = false
2161
+ }
2162
+ return nonOverlappingCandidates(candidates, byFragment).map((chosen) =>
2163
+ oldHitFromCandidate(lines, hunk, chosen, { tierKey, tierPrefix: 'old-all' }),
2164
+ )
2165
+ }
2166
+
2167
+ /**
2168
+ * 按“从左到右、不回头”筛掉重叠候选(保持输入顺序之外的确定性:先按行/列排序再筛)。
2169
+ *
2170
+ * 整行候选只比行区间;片段候选还要比列——同一行里的两处 `old` 是两次不同的替换,
2171
+ * 不能因为落在同一行就当成重叠(那会把 `replace_all` 静默降成“只改每行第一处”)。
2172
+ */
2173
+ function nonOverlappingCandidates(candidates, byFragment) {
2174
+ const sorted = [...candidates].sort(
2175
+ (a, b) => a.start - b.start || (byFragment ? a.startCol - b.startCol : 0),
2176
+ )
2177
+ const kept = []
2178
+ for (const candidate of sorted) {
2179
+ const previous = kept[kept.length - 1]
2180
+ if (previous === undefined || !candidateOverlaps(previous, candidate, byFragment)) kept.push(candidate)
2181
+ }
2182
+ return kept
2183
+ }
2184
+
2185
+ /** 两个候选在**被替换的字节**上是否重叠(整行层按行,片段层按行列)。 */
2186
+ function candidateOverlaps(a, b, byFragment) {
2187
+ if (!byFragment) return a.start + a.span > b.start
2188
+ if (a.start === b.start) return a.startCol <= b.endCol && b.startCol <= a.endCol
2189
+ const [first, second] = a.start < b.start ? [a, b] : [b, a]
2190
+ const firstEndExclusive = first.start + first.span
2191
+ if (second.start > firstEndExclusive - 1) return false
2192
+ if (second.start < firstEndExclusive - 1) return true
2193
+ /**
2194
+ * `endLineCol` 是末行的 0-based 排他列;转换成 1-based 闭区间末列正好等于它。
2195
+ * 第二处从同一行更靠后的列开始 ⇒ 两段不相交。
2196
+ */
2197
+ const firstEndCol = first.endLineCol ?? first.endCol
2198
+ return second.startCol <= firstEndCol
2199
+ }
2200
+
2201
+ /**
2202
+ * 已定好的两处替换是否重叠(拼接前最后一道)。
2203
+ *
2204
+ * 整行命中仍按行区间判(保守);两处**片段**命中允许落在同一行,只要字节不相交——
2205
+ * 这正是 `replace_all` 在同一行改多处所需要的那条路(`a` 与 `b` 各换各的那一段)。
2206
+ */
2207
+ function matchesOverlap(a, b) {
2208
+ if (a.fragment === undefined || b.fragment === undefined) return a.start < b.end && b.start < a.end
2209
+ const asCandidate = (match) => ({
2210
+ start: match.start,
2211
+ span: match.end - match.start,
2212
+ startCol: match.fragment.columns[0],
2213
+ endCol: match.fragment.columns[1],
2214
+ ...(match.fragment.endLineCol === undefined ? {} : { endLineCol: match.fragment.endLineCol }),
2215
+ })
2216
+ return candidateOverlaps(asCandidate(a), asCandidate(b), true)
2217
+ }
2218
+
2033
2219
  // ── 内容寻址的第二条出路:这次编辑**已经落地**了 ─────────────────────────────
2034
2220
  //
2035
2221
  // 形状:`old` 不在文件里(两层都找不到),但**结果已经在文件里了** —— 调用方给的 `replace`
@@ -2082,6 +2268,16 @@ const ALREADY_APPLIED_TIER = 'already-applied'
2082
2268
  */
2083
2269
  const ALREADY_APPLIED_MIN_CHARS = 8
2084
2270
 
2271
+ /**
2272
+ * 回执里“改动后行内区域”的**体量上限**(0.8.1)。
2273
+ *
2274
+ * 只对片段层(`fragment`)回填整行上下文:整行替换的 `replaced` 已经是完整的新区域,
2275
+ * 再回一份 `after` 只是把同一段文本说两遍。片段层不同——`replaced` 只是行内那一段,
2276
+ * 调用方看不到整行改完的样子;给整行才能确认前后缀没被挤坏。
2277
+ * 上限 400 字符:足够覆盖常见一行,又不会让回执因为一个超长行翻倍。
2278
+ */
2279
+ const RECEIPT_AFTER_CHARS = 400
2280
+
2085
2281
  /**
2086
2282
  * 逐条判「已落地」:全过 ⇒ 回执数组(每条 hunk 一项);任一条不成立 ⇒ `null`。
2087
2283
  *
@@ -2171,7 +2367,12 @@ function resolveHandleRegion(lines, parsed, ctx) {
2171
2367
  return { startLine: parsed.startLine, endLine: parsed.endLine, rows: own, moved: false }
2172
2368
  }
2173
2369
  const observation = latestObservation(ctx.ledgerKey, { source: ctx.content })
2174
- if (observation === null) throw handleStaleError(ctx.label)
2370
+ /**
2371
+ * `source === null` 是**大文件的令牌-only 观测**(`observation-store` 的 `MAX_SOURCE`):
2372
+ * 没有全文就无法核对句柄携带的那段字节,与“账本里根本没有这条观测”同一条出路(`handleStaleError`)。
2373
+ * 旧代码直接 `.split`,在 >256 KiB 的陈旧句柄上抛 TypeError,把可判定的拒绝变成工具故障。
2374
+ */
2375
+ if (observation === null || typeof observation.source !== 'string') throw handleStaleError(ctx.label)
2175
2376
  const thenRows = regionRowsAt(observation.source.split(/\r?\n/), parsed.startLine, parsed.endLine)
2176
2377
  if (thenRows === null) throw handleStaleError(ctx.label)
2177
2378
  const thenText = thenRows.join('\n')
@@ -2376,6 +2577,36 @@ function locateByHandle(lines, hunk, parsed, ctx) {
2376
2577
  })
2377
2578
  }
2378
2579
 
2580
+ /**
2581
+ * `intent` 与哪些字段**不能同时出现**(T16)。
2582
+ *
2583
+ * `intent` 是**自成一体的一层地址**:它由引擎决定落到哪个精确区间。若同时给出任何别的地址,
2584
+ * 谁说了算就没有正确答案——挑一个执行就是一次静默换语义(调用方写的两个地址指向不同地方时,
2585
+ * 改错的那次不会有任何提示)。所以参数面直接拒绝,而不是挑一个。
2586
+ *
2587
+ * 名单与 {@link planHunks} 的地址判据同源:`target` / `lines`(含别名 `range`)/ `start_line` /
2588
+ * `end_line` / `line_cue` / `anchor` / `old` / `context` / `handle`。
2589
+ * `near` / `expect` / `replace` **不在**名单里:前两个是修饰,第三个是动作。
2590
+ *
2591
+ * 「在场」的判据逐字段照搬它自己那一层的:字符串看非空(`anchor` 看长度、其余看 trim),
2592
+ * 坐标看 `!== undefined && !== null`。判据写宽或写窄都会让某个形状在参数面与引擎里得到两种结论。
2593
+ *
2594
+ * @returns 冲突的字段名(按上面的固定顺序);没有冲突时为空数组
2595
+ */
2596
+ function intentAddressConflicts(hunk) {
2597
+ const clashes = []
2598
+ if (typeof hunk.target === 'string' && hunk.target.trim().length > 0) clashes.push('target')
2599
+ for (const key of ['lines', 'range', 'start_line', 'end_line']) {
2600
+ if (hunk[key] !== undefined && hunk[key] !== null) clashes.push(key)
2601
+ }
2602
+ if (typeof hunk.line_cue === 'string' && hunk.line_cue.trim().length > 0) clashes.push('line_cue')
2603
+ if (typeof hunk.anchor === 'string' && hunk.anchor.length > 0) clashes.push('anchor')
2604
+ if (oldAddressOf(hunk) !== null) clashes.push('old')
2605
+ if (typeof hunk.context === 'string' && hunk.context.trim().length > 0) clashes.push('context')
2606
+ if (typeof hunk.handle === 'string' && hunk.handle.trim().length > 0) clashes.push('handle')
2607
+ return clashes
2608
+ }
2609
+
2379
2610
  /**
2380
2611
  * 规划所有 hunk:逐个定位 → expect 核对 → 重叠检查 → 倒序拼接。
2381
2612
  * 任一 hunk 失败即抛错,调用方据此保证磁盘不变(事务性来自"先全部规划完再落盘")。
@@ -2425,6 +2656,75 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
2425
2656
  const hasCue = typeof hunk.line_cue === 'string' && hunk.line_cue.trim().length > 0
2426
2657
  const hasAnchor = typeof hunk.anchor === 'string' && hunk.anchor.length > 0
2427
2658
  const oldText = oldAddressOf(hunk)
2659
+ /**
2660
+ * `replace_all`(0.10.2):把 `old` 的**全部非重叠出现**各改一次。
2661
+ *
2662
+ * 它不是第六种地址,而是 `old` 的一种作用范围;因此只认 `old` + 动作字段(`replace` /
2663
+ * `expect` / `eol`)。把 `target`/`lines`/`context`/`near` 混进来意味着"既想全改又想收敛到一处",
2664
+ * 没有正确答案 —— 参数面直接拒绝,与 `intent` 的互斥同一条理由。
2665
+ */
2666
+ const replaceAll = hunk.replace_all
2667
+ if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
2668
+ throw new PatchError(
2669
+ '`replace_all` must be true or false — nothing was written',
2670
+ 'PATCH_REPLACE_ALL_CONFLICT',
2671
+ )
2672
+ }
2673
+ if (replaceAll === true) {
2674
+ const clashes = []
2675
+ if (hasTarget) clashes.push('target')
2676
+ if (typeof hunk.intent === 'string' && hunk.intent.trim().length > 0) clashes.push('intent')
2677
+ for (const key of ['lines', 'range', 'start_line', 'end_line']) {
2678
+ if (hunk[key] !== undefined && hunk[key] !== null) clashes.push(key)
2679
+ }
2680
+ if (hasCue) clashes.push('line_cue')
2681
+ if (hasAnchor) clashes.push('anchor')
2682
+ if (typeof hunk.context === 'string' && hunk.context.trim().length > 0) clashes.push('context')
2683
+ if (typeof hunk.handle === 'string' && hunk.handle.trim().length > 0) clashes.push('handle')
2684
+ for (const key of ['line', 'position', 'near']) {
2685
+ if (hunk[key] !== undefined && hunk[key] !== null) clashes.push(key)
2686
+ }
2687
+ if (oldText === null) {
2688
+ throw new PatchError(
2689
+ '`replace_all` needs `old`: the exact text to replace everywhere — nothing was written',
2690
+ 'PATCH_REPLACE_ALL_CONFLICT',
2691
+ )
2692
+ }
2693
+ /**
2694
+ * 片段层会把 `old` 首尾的换行去掉(那是“按行引用”的约定)。对 `replace_all` 来说,
2695
+ * 这个静默归一很危险:`old:"a\n"` 会退化成在所有行里搜 `a`(`banana` 也中),
2696
+ * 而单处地址至少还有唯一性兜底。这里直接拒绝,让调用方去掉尾换行后重试。
2697
+ */
2698
+ if (/^\n|\n$/.test(oldText.replace(/\r\n/g, '\n'))) {
2699
+ throw new PatchError(
2700
+ '`replace_all` cannot take `old` with a leading or trailing newline: the region is addressed by its text, not by the line ending — drop that newline from `old` and retry (the file\'s own ending stays) — nothing was written',
2701
+ 'PATCH_REPLACE_ALL_CONFLICT',
2702
+ )
2703
+ }
2704
+ if (clashes.length > 0) {
2705
+ throw new PatchError(
2706
+ `\`replace_all\` applies \`old\` to every occurrence, so it cannot be combined with ${clashes.join(', ')} (replace / expect / eol are fine); drop either replace_all or those fields — nothing was written`,
2707
+ 'PATCH_REPLACE_ALL_CONFLICT',
2708
+ )
2709
+ }
2710
+ }
2711
+ /**
2712
+ * 意图地址(T16):`intent` 是**自成一体**的一层地址,与其余地址字段互斥。
2713
+ *
2714
+ * 冲突在**定位之前**就拒(参数面没成形,与 `PATCH_NO_ADDRESS` 同族),而不是在引擎里挑一个:
2715
+ * 调用方写了两个地址说明它自己也没定,工具替它选一次就是一次看不见的换语义。
2716
+ * `near` / `expect` / `replace` 不在此列——前两个是修饰,第三个是动作。
2717
+ */
2718
+ const hasIntent = typeof hunk.intent === 'string' && hunk.intent.trim().length > 0
2719
+ if (hasIntent) {
2720
+ const clashed = intentAddressConflicts(hunk)
2721
+ if (clashed.length > 0) {
2722
+ throw new PatchError(
2723
+ `\`intent\` is a complete address by itself — it cannot be combined with ${clashed.join(', ')} (near / expect / replace are fine); drop either intent or those fields — nothing was written`,
2724
+ 'PATCH_INTENT_CONFLICT',
2725
+ )
2726
+ }
2727
+ }
2428
2728
  /** 句柄的形状在这里判(不对就抛错);`{handle, old}` 那一支的 `old` 仍然要命中区间内部。 */
2429
2729
  const parsedHandle = handleAddressOf(hunk)
2430
2730
  /**
@@ -2452,105 +2752,127 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
2452
2752
  'PATCH_NO_ADDRESS',
2453
2753
  )
2454
2754
  }
2455
- if (oldText === null && parsedHandle === null && !hasTarget && range === undefined && !hasCue && !hasAnchor) {
2755
+ if (oldText === null && parsedHandle === null && !hasTarget && range === undefined && !hasCue && !hasAnchor && !hasIntent) {
2456
2756
  throw new PatchError(
2457
- gaveLineAddress(hunk) ? LINE_ADDRESS_HINT : 'each hunk needs one of: old, target, lines, line_cue, anchor',
2757
+ gaveLineAddress(hunk) ? LINE_ADDRESS_HINT : 'each hunk needs one of: old, intent, target, lines, line_cue, anchor',
2458
2758
  'PATCH_NO_ADDRESS',
2459
2759
  )
2460
2760
  }
2461
- let hit
2761
+ let hits
2462
2762
  if (parsedHandle !== null) {
2463
2763
  /**
2464
2764
  * 句柄优先(D7):它在场时 `target` / `lines` / `line_cue` / `anchor` **一律不决定位置**,
2465
2765
  * 坐标(含 `expect` 之外的地址字段)与句柄冲突时被让开的一方走 `tier` 披露。
2466
2766
  * 句柄是工具自己那份逐字节副本,拄的 `old` 正是不可信的那一半。
2467
2767
  */
2468
- hit = locateByHandle(lines, hunk, parsedHandle, handleCtx)
2768
+ hits = [locateByHandle(lines, hunk, parsedHandle, handleCtx)]
2469
2769
  } else if (oldText !== null) {
2470
2770
  /**
2471
2771
  * `old` 在场 ⇒ 走内容寻址,**其余地址字段一律不再决定位置**(坐标只用于核对与披露)。
2472
2772
  * 与 `target` 同时给时以 `old` 为准,同 {@link locateByOld} 的注释:
2473
2773
  * 一段可以被验证的原文,比一个可能来自旧视图的坐标/名字更接近“你想改的是这里”。
2474
2774
  */
2475
- hit = locateByOld(lines, hunk, oldText, filePath === null ? 'this file' : filePath, handleCtx)
2775
+ hits = replaceAll === true
2776
+ ? locateAllByOld(lines, hunk, oldText, filePath === null ? 'this file' : filePath, handleCtx)
2777
+ : [locateByOld(lines, hunk, oldText, filePath === null ? 'this file' : filePath, handleCtx)]
2476
2778
  } else {
2477
- const found = locate(content, {
2478
- ...(hasTarget ? { target: hunk.target } : {}),
2479
- ...(range === undefined ? {} : { lines: range }),
2480
- ...(hunk.line_cue === undefined ? {} : { lineCue: hunk.line_cue }),
2481
- ...(hasTarget || range !== undefined || hunk.line_cue !== undefined ? {} : { anchor: hunk.anchor }),
2482
- ...(hunk.near === undefined ? {} : { near: hunk.near }),
2483
- ...(hunk.expect === undefined || hunk.expect === null ? {} : { assert: hunk.expect }),
2484
- ...(filePath === null ? {} : { filePath }),
2485
- })
2779
+ /**
2780
+ * 意图地址(T16)单独走一条请求:只带 `intent` / `near` / `expect` / `filePath`。
2781
+ * 冲突字段在上面已经拒了,这里刻意**不再把其余地址字段递下去**——即便哪天冲突判据放宽,
2782
+ * 引擎也不该从第二个地址里"顺手"拿到位置(那正是静默换语义的来路)。
2783
+ */
2784
+ const request = hasIntent
2785
+ ? {
2786
+ intent: hunk.intent,
2787
+ ...(hunk.near === undefined ? {} : { near: hunk.near }),
2788
+ ...(hunk.expect === undefined || hunk.expect === null ? {} : { assert: hunk.expect }),
2789
+ ...(filePath === null ? {} : { filePath }),
2790
+ ...(options.allowLoneCr === true ? { allowLoneCr: true } : {}),
2791
+ }
2792
+ : {
2793
+ ...(hasTarget ? { target: hunk.target } : {}),
2794
+ ...(range === undefined ? {} : { lines: range }),
2795
+ ...(hunk.line_cue === undefined ? {} : { lineCue: hunk.line_cue }),
2796
+ ...(hasTarget || range !== undefined || hunk.line_cue !== undefined ? {} : { anchor: hunk.anchor }),
2797
+ ...(hunk.near === undefined ? {} : { near: hunk.near }),
2798
+ ...(hunk.expect === undefined || hunk.expect === null ? {} : { assert: hunk.expect }),
2799
+ ...(filePath === null ? {} : { filePath }),
2800
+ ...(options.allowLoneCr === true ? { allowLoneCr: true } : {}),
2801
+ }
2802
+ const found = locate(content, request)
2486
2803
  if (!found.ok) {
2487
2804
  throw new PatchError(found.message, found.code, {
2488
2805
  ...(found.candidates === undefined ? {} : { candidates: found.candidates }),
2489
2806
  })
2490
2807
  }
2491
- hit = {
2808
+ hits = [{
2492
2809
  startLine: found.startLine,
2493
2810
  endLine: found.endLine,
2494
2811
  tier: found.tier,
2495
2812
  actual: found.actual,
2496
2813
  ...(found.byNear === true ? { byNear: true } : {}),
2497
- }
2814
+ }]
2498
2815
  }
2499
- const loc = {
2500
- start: hit.startLine - 1,
2501
- end: hit.endLine,
2502
- line: hit.startLine,
2503
- tier: hit.tier,
2504
- byNear: hit.byNear === true,
2505
- /**
2506
- * 片段层:替换范围是**命中跨度**(`fragment.columns`)而不是整行——拼接层据此只换这一段。
2507
- * 整行对齐层不带这个字段(它的跨度就是行首到行尾)。
2508
- */
2509
- ...(hit.fragment === undefined ? {} : { fragment: hit.fragment }),
2816
+ for (const hit of hits) {
2817
+ const loc = {
2818
+ start: hit.startLine - 1,
2819
+ end: hit.endLine,
2820
+ line: hit.startLine,
2821
+ tier: hit.tier,
2822
+ byNear: hit.byNear === true,
2823
+ /**
2824
+ * 片段层:替换范围是**命中跨度**(`fragment.columns`)而不是整行——拼接层据此只换这一段。
2825
+ * 整行对齐层不带这个字段(它的跨度就是行首到行尾)。
2826
+ */
2827
+ ...(hit.fragment === undefined ? {} : { fragment: hit.fragment }),
2828
+ /**
2829
+ * 地址形态与调用方字面写法不符的**结构化**记录(`matches` 上;`applied` 不能加字段,见
2830
+ * `locateByOld`):片段层的跨度比整行窄、坐标与落点不一致——两条都回指事实,
2831
+ * 让消费方能自己判断,而不必去解析 `tier` 里那句话。
2832
+ */
2833
+ ...(hit.fragmentNote === undefined ? {} : { fragmentNote: hit.fragmentNote }),
2834
+ /**
2835
+ * 句柄 hunk 多带一条:**句柄定下的整段区间**(坐标 + 字节)。
2836
+ *
2837
+ * 闸门拿它去和账本里那一段比对({@link staleConflicts}):句柄的凭据覆盖整个区间,
2838
+ * 而 `line: k` / `old` 只是区间**内部**的相对定位——比错了一方就会误报。
2839
+ */
2840
+ ...(hit.handleRegion === undefined ? {} : { handleRegion: hit.handleRegion }),
2841
+ ...(hit.coordinateNote === undefined
2842
+ ? {}
2843
+ : { coordinateNote: hit.coordinateNote, coordinateConflict: hit.coordinateConflict }),
2844
+ }
2845
+ const actual = hit.actual
2510
2846
  /**
2511
- * 地址形态与调用方字面写法不符的**结构化**记录(`matches` 上;`applied` 不能加字段,见
2512
- * `locateByOld`):片段层的跨度比整行窄、坐标与落点不一致——两条都回指事实,
2513
- * 让消费方能自己判断,而不必去解析 `tier` 里那句话。
2847
+ * `expect` 闸门 —— 判据本体是 {@link expectVerdict}(`locator-api.mjs`,**唯一一份**;C10)。
2848
+ *
2849
+ * 为什么只有**内容寻址**这一支在这里判:另一支(`locate` 的四层)已经在 `locate()` 的 `done()`
2850
+ * 里用同一个函数判过同一个区间了(`hunk.expect` 就是作为 `assert` 传下去的),再判一次是同一件事
2851
+ * 做两遍——每次都要全文件扫描一遍。两支合起来,一个 hunk 的 `expect` 恰好判一次,
2852
+ * 而判它的永远是同一份实现。
2853
+ *
2854
+ * 这里曾经是 `normSpace(actual) !== normSpace(hunk.expect)`:与 `locate()` 的"包含且唯一"是
2855
+ * **两份判据**,于是同一个 `expect` 在两条路径上判出两种结果——片段引文在 `locate()` 里通过,
2856
+ * 到这里必被拒(实测:`planHunks` 的三种 `lines` 写法全拒)。改后这里不再有任何比较:
2857
+ * 判据、阈值、候选清单形状都只在 `expectVerdict` 里,本函数只把结论翻成码。
2514
2858
  */
2515
- ...(hit.fragmentNote === undefined ? {} : { fragmentNote: hit.fragmentNote }),
2516
2859
  /**
2517
- * 句柄 hunk 多带一条:**句柄定下的整段区间**(坐标 + 字节)。
2518
- *
2519
- * 闸门拿它去和账本里那一段比对({@link staleConflicts}):句柄的凭据覆盖整个区间,
2520
- * 而 `line: k` / `old` 只是区间**内部**的相对定位——比错了一方就会误报。
2860
+ * 句柄 hunk 也判 `expect`(只多不多):句柄的摘要已经钉住了那段字节,而 `expect` 是调用方
2861
+ * 显式写的断言——两条都成立才放行。零宽度的边界插入(`position`)没有可断言的区间,跳过。
2521
2862
  */
2522
- ...(hit.handleRegion === undefined ? {} : { handleRegion: hit.handleRegion }),
2523
- ...(hit.coordinateNote === undefined
2524
- ? {}
2525
- : { coordinateNote: hit.coordinateNote, coordinateConflict: hit.coordinateConflict }),
2526
- }
2527
- const actual = hit.actual
2528
- /**
2529
- * `expect` 闸门 —— 判据本体是 {@link expectVerdict}(`locator-api.mjs`,**唯一一份**;C10)。
2530
- *
2531
- * 为什么只有**内容寻址**这一支在这里判:另一支(`locate` 的四层)已经在 `locate()` 的 `done()`
2532
- * 里用同一个函数判过同一个区间了(`hunk.expect` 就是作为 `assert` 传下去的),再判一次是同一件事
2533
- * 做两遍——每次都要全文件扫描一遍。两支合起来,一个 hunk 的 `expect` 恰好判一次,
2534
- * 而判它的永远是同一份实现。
2535
- *
2536
- * 这里曾经是 `normSpace(actual) !== normSpace(hunk.expect)`:与 `locate()` 的"包含且唯一"是
2537
- * **两份判据**,于是同一个 `expect` 在两条路径上判出两种结果——片段引文在 `locate()` 里通过,
2538
- * 到这里必被拒(实测:`planHunks` 的三种 `lines` 写法全拒)。改后这里不再有任何比较:
2539
- * 判据、阈值、候选清单形状都只在 `expectVerdict` 里,本函数只把结论翻成码。
2540
- */
2541
- /**
2542
- * 句柄 hunk 也判 `expect`(只多不多):句柄的摘要已经钉住了那段字节,而 `expect` 是调用方
2543
- * 显式写的断言——两条都成立才放行。零宽度的边界插入(`position`)没有可断言的区间,跳过。
2544
- */
2545
- if ((oldText !== null || parsedHandle !== null) && actual.length > 0 && hunk.expect !== undefined && hunk.expect !== null) {
2546
- const bad = expectVerdict(lines, loc.line, hit.endLine, actual, hunk.expect)
2547
- if (bad !== null) throw expectGateError(bad, loc.line, hit.tier, actual)
2863
+ if ((oldText !== null || parsedHandle !== null) && actual.length > 0 && hunk.expect !== undefined && hunk.expect !== null) {
2864
+ const bad = expectVerdict(lines, loc.line, hit.endLine, actual, hunk.expect)
2865
+ if (bad !== null) throw expectGateError(bad, loc.line, hit.tier, actual)
2866
+ }
2867
+ const eol = adaptReplaceEol(hunk.replace ?? '', hunk.eol ?? 'auto', targetEolFor(endings, loc.line, hit.endLine))
2868
+ matches.push({ ...loc, hunk: hunkIndex, replace: eol.text, actual, ...(eol.adapted ? { eolAdapted: true } : {}) })
2548
2869
  }
2549
- matches.push({ ...loc, hunk: hunkIndex, replace: hunk.replace ?? '', actual })
2550
2870
  }
2551
- matches.sort((a, b) => a.start - b.start)
2871
+ matches.sort(
2872
+ (a, b) => a.start - b.start || (a.fragment?.columns[0] ?? 1) - (b.fragment?.columns[0] ?? 1),
2873
+ )
2552
2874
  for (let i = 1; i < matches.length; i += 1) {
2553
- if (matches[i].start < matches[i - 1].end) {
2875
+ if (matchesOverlap(matches[i - 1], matches[i])) {
2554
2876
  throw new PatchError(
2555
2877
  `hunks overlap: line ${matches[i - 1].line} and line ${matches[i].line}`,
2556
2878
  'PATCH_OVERLAP',
@@ -2573,29 +2895,34 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
2573
2895
  */
2574
2896
  const rep = m.replace === '' ? { lines: [], endings: [] } : splitLinesWithEndings(m.replace)
2575
2897
  if (m.fragment !== undefined) {
2576
- const line = out[m.start]
2898
+ const firstLine = out[m.start]
2577
2899
  const [startCol, endCol] = m.fragment.columns
2578
- const prefix = line.slice(0, startCol - 1)
2579
- const tail = line.slice(endCol)
2900
+ const prefix = firstLine.slice(0, startCol - 1)
2580
2901
  /**
2581
- * **去掉 `splitLinesWithEndings` 末尾那个构造空行**:`"a\nb"` 切成 `['a','b','']`、行尾
2582
- * `['\n','','']`——真写进文件就是凭空多一行。整行版无所谓(那一个空行正好被丢掉),
2583
- * 行内版留着就会把原行的行尾挤到下一行去(实测:`beta GAMMA\n` + `\n` + `delta`)。
2902
+ * 跨行片段的后缀在**末行**,不在首行。旧代码从首行切 `line.slice(endCol)`,于是
2903
+ * `old: "START\nEND"` 替换后把末行 `END bb` 的 ` bb` 整段丢了——回执还写“只换了列 4-11”。
2904
+ * `endLineCol` 是 `fragmentSpans` 从原文量出的末行列号(0-based 排他);单行时它等于 `endCol`。
2584
2905
  */
2906
+ const tail = out[m.end - 1].slice(m.fragment.endLineCol ?? endCol)
2907
+ const endsWithNewline = m.replace.endsWith('\n')
2585
2908
  const repLines = rep.lines.length > 1 && rep.lines[rep.lines.length - 1] === '' ? rep.lines.slice(0, -1) : rep.lines
2586
2909
  const parts =
2587
2910
  repLines.length === 0
2588
2911
  ? [`${prefix}${tail}`]
2589
- : [`${prefix}${repLines[0]}`, ...repLines.slice(1), `${tail}`]
2590
- /**
2591
- * 行尾:行尾**不在被替换的跨度里**(跨度首尾都落在行的边界内)⇒ 无论 `replace` 怎么写,
2592
- * 原来那一行的行尾逐字不动。`parts` 里多出来的中间行用 `replace` 自己的行尾。
2593
- */
2594
- const partEndings = [...rep.endings.slice(0, parts.length - 1), outEndings[m.start] ?? '']
2912
+ : repLines.length === 1
2913
+ ? endsWithNewline
2914
+ ? [`${prefix}${repLines[0]}`, `${tail}`]
2915
+ : [`${prefix}${repLines[0]}${tail}`]
2916
+ : endsWithNewline
2917
+ ? [`${prefix}${repLines[0]}`, ...repLines.slice(1), `${tail}`]
2918
+ : [`${prefix}${repLines[0]}`, ...repLines.slice(1, -1), `${repLines[repLines.length - 1]}${tail}`]
2919
+ // 逻辑行数:replace 末尾的换行会把后缀顶到下一行(`old:'hello', replace:'A\n'` 是两行)。
2920
+ m.newLineCount = repLines.length === 0 ? 1 : repLines.length + (endsWithNewline ? 1 : 0)
2595
2921
  /**
2596
- * 回执里的 `replaced`:**被替换那一段的新文本**,不掺前后缀——就是调用方给的 `replace`
2597
- * (拿 `parts` 当 `replaced` 会把前后缀也算进去,回执就不再是“什么被换成了什么”)。
2922
+ * 行尾:被替换跨度**末行**的行尾由原文继承;`parts` 里中间行用 replace 自己的行尾。
2923
+ * 跨行时必须是 `m.end - 1`(末行),用 `m.start` 会把首行的行尾错接到新末行上。
2598
2924
  */
2925
+ const partEndings = [...rep.endings.slice(0, parts.length - 1), outEndings[m.end - 1] ?? '']
2599
2926
  m.replacedText = rep.lines.join('\n')
2600
2927
  out = out.slice(0, m.start).concat(parts, out.slice(m.end))
2601
2928
  outEndings = outEndings.slice(0, m.start).concat(partEndings, outEndings.slice(m.end))
@@ -2607,12 +2934,61 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
2607
2934
  * `m.end === 0` 只有零宽度的边界插入(`position: before` 且区间从第 1 行开始)能造出来:
2608
2935
  * 既有七种地址形状下 `m.end ≥ 1` 恒成立,所以这一改**动不到既有语义**。
2609
2936
  */
2937
+ /**
2938
+ * 在**无尾换行**的文件末尾插入(`position:'after'` 最后一行):前一行 ending 是 `''`,
2939
+ * 直接拼会把 `c` 与 `d` 粘成 `cd`。补一个分隔行尾(沿用文件已有行尾),
2940
+ * 但插入块自己的末行仍保持原来的 `''`——不能凭空给文件加尾换行。
2941
+ */
2942
+ const insertionAtEof = rep.lines.length > 0 && m.start > 0 && m.end === m.start && (outEndings[m.start - 1] ?? '') === ''
2943
+ const tailEnding = insertionAtEof ? '' : (outEndings[m.end - 1] ?? outEndings[m.end] ?? '')
2944
+ if (insertionAtEof) outEndings[m.start - 1] = outEndings.find((ending) => ending !== '') ?? '\n'
2610
2945
  if (rep.lines.length > 0) {
2611
- rep.endings[rep.endings.length - 1] = outEndings[m.end - 1] ?? outEndings[m.end] ?? ''
2946
+ rep.endings[rep.endings.length - 1] = tailEnding
2612
2947
  }
2948
+ m.newLineCount = rep.lines.length
2613
2949
  out = out.slice(0, m.start).concat(rep.lines, out.slice(m.end))
2614
2950
  outEndings = outEndings.slice(0, m.start).concat(rep.endings, outEndings.slice(m.end))
2615
2951
  }
2952
+ /**
2953
+ * 改动后的坐标、行数变化与新句柄(0.8.1):让调用方**不必回读**就能继续编辑。
2954
+ *
2955
+ * `applied[].line` 是**改前**坐标(契约,黄金集钉着);`afterLine` / `afterEndLine` 是同一处
2956
+ * 在**改后**文件里的坐标。两者不能互相替代:一次多 hunk 调用里,高位 hunk 的改后行号取决于
2957
+ * 低位 hunk 增删了几行(`drift`),调用方手里只有改前文件,算不出来。
2958
+ *
2959
+ * `handle` 用改后内容现场铸一把:句柄只承诺“这段字节 + 这个区间”,不依赖读写两侧有共享状态;
2960
+ * 下一次 `edit` 直接拿它寻址即可。删除(`newLineCount === 0`)没有可指的区间,不铸柄。
2961
+ */
2962
+ let drift = 0
2963
+ /**
2964
+ * 终态文本的**逻辑行**视图。片段层的 `out` 里 prefix/tail 是两个数组项、合起来仍是一行,
2965
+ * 直接拿 `out.slice` 会多算行;以最终拼接文本为准,行号、回填文本与句柄三者同源。
2966
+ */
2967
+ const finalLines = joinLinesWithEndings(out, outEndings).split(/\r?\n/)
2968
+ for (const m of matches) {
2969
+ const oldCount = m.end - m.start
2970
+ const newCount = m.newLineCount ?? oldCount
2971
+ const afterLine = m.start + 1 + drift
2972
+ const afterEndLine = afterLine + newCount - 1
2973
+ m.afterLine = afterLine
2974
+ m.afterEndLine = afterEndLine
2975
+ m.delta = newCount - oldCount
2976
+ const regionText = newCount > 0 ? finalLines.slice(afterLine - 1, afterEndLine).join('\n') : ''
2977
+ if (m.fragment !== undefined) {
2978
+ m.after = regionText.length > RECEIPT_AFTER_CHARS ? `${regionText.slice(0, RECEIPT_AFTER_CHARS)}…` : regionText
2979
+ m.afterTruncated = regionText.length > RECEIPT_AFTER_CHARS
2980
+ }
2981
+ if (newCount > 0 && typeof handleCtx.ledgerKey === 'string' && handleCtx.ledgerKey.length > 0) {
2982
+ m.handle = mintHandle({
2983
+ ledgerKey: handleCtx.ledgerKey,
2984
+ regionText,
2985
+ byteOrderMark: handleCtx.byteOrderMark,
2986
+ startLine: afterLine,
2987
+ endLine: afterEndLine,
2988
+ })
2989
+ }
2990
+ drift += m.delta
2991
+ }
2616
2992
  /**
2617
2993
  * 逐条回执:**一条 hunk 一条记录,每条只描述它自己那条 hunk**;数组顺序 = 调用方的**输入顺序**。
2618
2994
  *
@@ -2652,6 +3028,10 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
2652
3028
  byNear: m.byNear === true,
2653
3029
  actual: m.actual,
2654
3030
  replaced: m.replacedText ?? replaceTextOf(m.replace),
3031
+ ...(m.afterLine === undefined ? {} : { afterLine: m.afterLine, afterEndLine: m.afterEndLine, delta: m.delta }),
3032
+ ...(m.after === undefined ? {} : { after: m.after, afterTruncated: m.afterTruncated === true }),
3033
+ ...(m.handle === undefined ? {} : { handle: m.handle }),
3034
+ ...(m.eolAdapted === true ? { eolAdapted: true } : {}),
2655
3035
  }))
2656
3036
  return { matches, content: joinLinesWithEndings(out, outEndings), applied }
2657
3037
  }
@@ -2851,6 +3231,38 @@ function errorCodeOf(error) {
2851
3231
  return error && typeof error === 'object' && typeof error.code === 'string' ? error.code : undefined
2852
3232
  }
2853
3233
 
3234
+ /**
3235
+ * 严格新鲜度(0.10.3):要求**当前这版内容**在本会话里被读过(或由本工具写过)。
3236
+ *
3237
+ * 这是内置 `Edit` / `Write` 的 read-before-write 规则,在 aura 里的对应物。默认只在
3238
+ * `aura_basetools_write` 的覆写路径开启;`aura_basetools_edit` 用 `require_observation` / `--require-observation`
3239
+ * 显式开启。判据不是“账本里有这个路径”,而是**账本里有这个版本**:
3240
+ * · 从没读过 ⇒ `never-read`;
3241
+ * · 读过但之后变了(内容令牌不同)⇒ `changed-since-read`。
3242
+ * 两种情况都拒绝,并让调用方先 `aura_basetools_read` 再重试——与区域级闸门(只看被寻址区域)不同,
3243
+ * 这里明确要求整份修订被看过。
3244
+ *
3245
+ * 写成功后 `finishEditPlan` 会把这版内容记进账本,因此“自己刚写的内容”可以直接继续写,
3246
+ * 不需要额外再读一次。
3247
+ *
3248
+ * @throws PatchError('PATCH_UNOBSERVED')
3249
+ */
3250
+ export function requireObservedRevision({ displayPath, identityKey, content, byteOrderMark = false }) {
3251
+ const ledgerKey = ledgerIdentityKey(displayPath, identityKey)
3252
+ const observation = latestObservation(ledgerKey, { source: content })
3253
+ const currentToken = contentToken(content, byteOrderMark)
3254
+ if (observation !== null && observation.version === currentToken) return
3255
+ const changed = observation !== null
3256
+ throw new PatchError(
3257
+ `cannot patch "${displayPath}": ${changed ? 'the file changed since your last read' : 'this file has not been read in this session'} — read it first (aura_basetools_read), then retry${changed ? ' against the current revision' : ''} — nothing was written`,
3258
+ 'PATCH_UNOBSERVED',
3259
+ {
3260
+ reason: changed ? 'changed-since-read' : 'never-read',
3261
+ ...(changed ? { observed_version: observation.version, current_version: currentToken } : {}),
3262
+ },
3263
+ )
3264
+ }
3265
+
2854
3266
  /**
2855
3267
  * 一次完整写回:读 → BOM 计数 → 规划 → 陈旧闸门 → 逐字节写(CAS) → 回读校验 → 刷新观测。
2856
3268
  *
@@ -2859,16 +3271,30 @@ function errorCodeOf(error) {
2859
3271
  * 而本机 provider 总是逐字节写,因此这里没有退化路径,也没有 `PATCH_NO_BYTE_EXACT_WRITE`。
2860
3272
  *
2861
3273
  * @param provider - 四方法契约,见本文件顶部注释
2862
- * @param request - { displayPath, hunks, dryRun?, create?, signal?, identityKey? }
3274
+ * @param request - { displayPath, hunks, dryRun?, create?, signal?, identityKey?, requireObservation?,
3275
+ * wholeFile?, eol?, skipStaleGate? }
2863
3276
  * `identityKey` = **宿主给的身份键**(同一份盘上文件必给同一个串):有它时账本记在它上面,
2864
3277
  * 而 `displayPath` 只管回执与诊断(模型看到的路径必须是它自己给的那个);
2865
3278
  * 不给 ⇒ 与本参数存在之前**逐字节一致**(独立面 CLI/MCP 的那条路由磁盘存储自己做归一,见
2866
3279
  * `aura-standalone/lib/observation-store.mjs` 的 `identityKey`)。
3280
+ * `requireObservation` = 严格新鲜度(见 {@link requireObservedRevision});`create` 不受它约束。
3281
+ * `wholeFile` = 整份替换(`aura_basetools_write` 覆写;与 `hunks` 互斥):行区间**在本次读到的内容上**
3282
+ * 现算,避免“先读一次算总行数、规划时又读一次”的 TOCTOU 尾巴。
3283
+ * `skipStaleGate` = 只允许与 `wholeFile` 同用:显式盲写逃生口(`allow_unobserved`)跳过区域闸。
2867
3284
  * @returns 回执字段(与 DSH 版同形:applied / dryRun / write_verify / unverified_region / operation)
2868
- * @throws PatchError(PATCH_STALE_REGION / WRITE_VERIFY_FAILED / PATCH_NOT_FOUND …)
3285
+ * @throws PatchError(PATCH_STALE_REGION / PATCH_UNOBSERVED / WRITE_VERIFY_FAILED / PATCH_NOT_FOUND …)
2869
3286
  */
2870
3287
  async function planEditToProvider(provider, request) {
2871
- const { displayPath, hunks = [], dryRun = false, create, signal } = request
3288
+ const { displayPath, hunks = [], dryRun = false, create, signal, wholeFile, eol, skipStaleGate } = request
3289
+ if (wholeFile !== undefined && typeof wholeFile !== 'string') {
3290
+ throw new PatchError('`wholeFile` must be a string', 'PATCH_BAD_WHOLE_FILE')
3291
+ }
3292
+ if (wholeFile !== undefined && hunks.length > 0) {
3293
+ throw new PatchError('`wholeFile` replaces the entire file and cannot be combined with `hunks`', 'PATCH_BAD_WHOLE_FILE')
3294
+ }
3295
+ if (skipStaleGate === true && wholeFile === undefined) {
3296
+ throw new PatchError('`skipStaleGate` is only valid together with `wholeFile`', 'PATCH_BAD_WHOLE_FILE')
3297
+ }
2872
3298
  /** 账本的键只算一次({@link ledgerIdentityKey} 是全引擎唯一那道归一)。 */
2873
3299
  const ledgerKey = ledgerIdentityKey(displayPath, request.identityKey)
2874
3300
  const info = await provider.stat(displayPath, signal)
@@ -2878,7 +3304,7 @@ async function planEditToProvider(provider, request) {
2878
3304
  if (info !== undefined && typeof create === 'string') {
2879
3305
  throw new PatchError(`cannot create "${displayPath}": it already exists — pass hunks to change it`, 'PATCH_EXISTS')
2880
3306
  }
2881
- if (info !== undefined && hunks.length === 0) {
3307
+ if (info !== undefined && hunks.length === 0 && wholeFile === undefined) {
2882
3308
  throw new PatchError(`cannot patch "${displayPath}": no hunks — pass at least one, or use \`create\` for a new file`, 'PATCH_NO_HUNKS')
2883
3309
  }
2884
3310
 
@@ -2889,20 +3315,43 @@ async function planEditToProvider(provider, request) {
2889
3315
  'PATCH_NOT_FOUND',
2890
3316
  )
2891
3317
  }
2892
- return { kind: 'create', ledgerKey, displayPath, content: create, create: true, dryRun: false }
3318
+ return { kind: 'create', ledgerKey, displayPath, content: create, create: true, dryRun: dryRun === true }
2893
3319
  }
2894
3320
 
2895
3321
  const content = await provider.readText(displayPath, signal)
2896
3322
  const byteOrderMarks = await fileByteOrderMarkCount(provider, displayPath, signal)
3323
+ /**
3324
+ * 严格新鲜度排在规划之前:预演与真写拿到同一个判决(预演必须能预演出真写的拒绝)。
3325
+ * `create` 已在上面返回,不受这条约束——建新文件没有“读过”可言。
3326
+ */
3327
+ if (request.requireObservation === true) {
3328
+ requireObservedRevision({
3329
+ displayPath,
3330
+ ...(request.identityKey === undefined ? {} : { identityKey: request.identityKey }),
3331
+ content,
3332
+ byteOrderMark: byteOrderMarks > 0,
3333
+ })
3334
+ }
3335
+ /**
3336
+ * 整份替换的行区间**在这里现算**:它必须与 `content` 是同一次读到的版本。
3337
+ * 旧实现由调用方先读一次算总行数、这里再读一次,两次之间文件变长就会只替换前缀、
3338
+ * 把旧尾巴留在盘上(`allow_unobserved` 下没有观测闸兜底,实测可复现)。
3339
+ */
3340
+ const planningHunks =
3341
+ wholeFile === undefined
3342
+ ? hunks
3343
+ : [{ lines: [1, content.split(/\r?\n/).length], replace: wholeFile, ...(typeof eol === 'string' ? { eol } : {}) }]
2897
3344
  let planned
2898
3345
  try {
2899
3346
  /**
2900
3347
  * 句柄路要的**两个事实**在这里一并递下去:身份键(句柄里的身份标签拿同一口径算)
2901
3348
  * 与"这份文件有没有 BOM"(摘要的另一半)。两者与 {@link regionGate} 拿到的是同一对。
2902
3349
  */
2903
- planned = planHunks(content, hunks, displayPath, {
3350
+ planned = planHunks(content, planningHunks, displayPath, {
2904
3351
  ...(request.identityKey === undefined ? {} : { identityKey: request.identityKey }),
2905
3352
  byteOrderMark: byteOrderMarks > 0,
3353
+ // wholeFile 是整份字节替换,不依赖行号模型;允许它越过 lone CR 的行地址闸。
3354
+ ...(wholeFile === undefined ? {} : { allowLoneCr: true }),
2906
3355
  })
2907
3356
  } catch (error) {
2908
3357
  /**
@@ -2918,7 +3367,7 @@ async function planEditToProvider(provider, request) {
2918
3367
  * `already_applied` 这份结构化字段根本到不了模型眼前。
2919
3368
  */
2920
3369
  if (errorCodeOf(error) !== 'PATCH_NOT_FOUND') throw error
2921
- const hits = alreadyAppliedHits(content, hunks)
3370
+ const hits = alreadyAppliedHits(content, planningHunks)
2922
3371
  if (hits === null) throw error
2923
3372
  return { kind: 'already_applied', ledgerKey, displayPath, hits, dryRun }
2924
3373
  }
@@ -2936,13 +3385,24 @@ async function planEditToProvider(provider, request) {
2936
3385
  * 而同样的 hunks 真数落盘时闸门报 "the text you addressed moved" —— **预演没能预演出真正的判决**。
2937
3386
  * 一个只在写入路径上生效的闸门,配上一个说“没事”的预演,比没有预演更坏。
2938
3387
  */
2939
- const gate = regionGate(hunks, content, planned, displayPath, {
2940
- byteOrderMark: byteOrderMarks > 0,
2941
- // 身份键往下传(有才传):闸门按**同一个磁盘文件**记账,而回执与诊断里的路径仍是 displayPath。
2942
- ...(request.identityKey === undefined ? {} : { identityKey: request.identityKey }),
2943
- })
3388
+ const gate =
3389
+ skipStaleGate === true
3390
+ ? { clean: true, basis: request.requireObservation === true ? 'observation' : 'none' }
3391
+ : regionGate(planningHunks, content, planned, displayPath, {
3392
+ byteOrderMark: byteOrderMarks > 0,
3393
+ // 身份键往下传(有才传):闸门按**同一个磁盘文件**记账,而回执与诊断里的路径仍是 displayPath。
3394
+ ...(request.identityKey === undefined ? {} : { identityKey: request.identityKey }),
3395
+ })
2944
3396
  if (!gate.clean) throw new PatchError(gate.message, 'PATCH_STALE_REGION', gate.extras)
2945
- if (dryRun) return { kind: 'dry_run', ledgerKey, displayPath, applied: planned.applied, dryRun: true }
3397
+ if (dryRun) {
3398
+ /**
3399
+ * 预演**不铸可复用句柄**:句柄指向“盘上那段字节”,而 dry-run 一个字节都没写;
3400
+ * 把它发出去,下一次拿它编辑必然 `PATCH_HANDLE_STALE`。afterLine/delta 仍然给,
3401
+ * 足以让调用方判断“会落到哪几行”;要句柄就真写(或写完再读)。
3402
+ */
3403
+ const applied = planned.applied.map(({ handle: _handle, ...rest }) => rest)
3404
+ return { kind: 'dry_run', ledgerKey, displayPath, applied, basis: gate.basis, dryRun: true }
3405
+ }
2946
3406
  return {
2947
3407
  kind: 'patch',
2948
3408
  ledgerKey,
@@ -2975,6 +3435,7 @@ async function planEditToProvider(provider, request) {
2975
3435
  */
2976
3436
  async function writeEditPlan(provider, request, plan) {
2977
3437
  if (plan.kind === 'create') {
3438
+ if (plan.dryRun === true) return false
2978
3439
  await provider.writeText(request.displayPath, plan.content, { kind: 'createIfAbsent' }, request.signal)
2979
3440
  return true
2980
3441
  }
@@ -3013,7 +3474,27 @@ async function finishEditPlan(provider, request, plan) {
3013
3474
  dryRun: plan.dryRun,
3014
3475
  }
3015
3476
  }
3016
- if (plan.kind === 'dry_run') return { path: displayPath, applied: plan.applied, dryRun: true }
3477
+ if (plan.kind === 'dry_run') {
3478
+ return {
3479
+ path: displayPath,
3480
+ applied: plan.applied,
3481
+ dryRun: true,
3482
+ // 预演也要如实说“这次没有可核对的基准”,否则它比不上真写诚实。
3483
+ ...(plan.basis === 'none' ? { unverified_region: true } : {}),
3484
+ }
3485
+ }
3486
+ /**
3487
+ * 预演的“新建”**不落盘**,也不能回读校验(盘上没有东西)。旧实现只把 `dryRun` 用在已有文件
3488
+ * 的路径上,`create + dry_run` 会真的建文件并把回执写成 `dryRun:false`——预演做了真事。
3489
+ */
3490
+ if (plan.kind === 'create' && plan.dryRun === true) {
3491
+ return {
3492
+ path: displayPath,
3493
+ operation: 'create',
3494
+ applied: [{ line: 1, tier: 'create', actual: '', replaced: plan.content }],
3495
+ dryRun: true,
3496
+ }
3497
+ }
3017
3498
  const verification = await verifyWriteBack(provider, displayPath, plan.content, displayPath, signal)
3018
3499
  const view = providerView(plan.content)
3019
3500
  const byteOrderMark = plan.content.startsWith(BYTE_ORDER_MARK)
@@ -3122,6 +3603,9 @@ function singleRequestFrom(item, request) {
3122
3603
  if (identityKey !== undefined) out.identityKey = identityKey
3123
3604
  const dryRun = item.dryRun === undefined ? request?.dryRun : item.dryRun
3124
3605
  if (dryRun !== undefined) out.dryRun = dryRun
3606
+ /** 严格新鲜度是整批的默认值;元素自己给了就听元素的(与 `dryRun` 同一口径)。 */
3607
+ const requireObservation = item.requireObservation === undefined ? request?.requireObservation : item.requireObservation
3608
+ if (requireObservation !== undefined) out.requireObservation = requireObservation
3125
3609
  if (request?.signal !== undefined) out.signal = request.signal
3126
3610
  return out
3127
3611
  }
@@ -3187,6 +3671,15 @@ function groupRequest(group, request) {
3187
3671
  create: first.create,
3188
3672
  identityKey: first.identityKey,
3189
3673
  dryRun: first.dryRun,
3674
+ /**
3675
+ * 同文件合并时把元素级 `requireObservation` 一并带过去:否则它只对“单独一个目标”
3676
+ * 那条路生效,多个目标命中同一文件时被静默丢掉。任一成员要求严格 ⇒ 整组严格(安全方向)。
3677
+ */
3678
+ ...(group.members.some((member) =>
3679
+ member.requireObservation === undefined ? request?.requireObservation === true : member.requireObservation === true,
3680
+ )
3681
+ ? { requireObservation: true }
3682
+ : {}),
3190
3683
  },
3191
3684
  request,
3192
3685
  )
@@ -3194,9 +3687,10 @@ function groupRequest(group, request) {
3194
3687
 
3195
3688
  /** 回执是哪一态(`applied` / `created` / `already_applied` / `dry_run`)—— 汇总行与 `landed` 都用它。 */
3196
3689
  function receiptKindOf(receipt) {
3690
+ // 预演优先:`create + dry_run` 的 operation 仍是 create,但盘上一个字节都没写。
3691
+ if (receipt.dryRun === true) return 'dry_run'
3197
3692
  if (receipt.operation === 'create') return 'created'
3198
3693
  if (receipt.outcome === 'already_applied') return 'already_applied'
3199
- if (receipt.dryRun === true) return 'dry_run'
3200
3694
  return 'applied'
3201
3695
  }
3202
3696
 
@@ -3298,6 +3792,8 @@ function mtEnvelope(edits, meta = {}) {
3298
3792
  return {
3299
3793
  targets: edits.length,
3300
3794
  applied,
3795
+ // 整批都是预演时才把顶层的 dryRun 置 true;混有真写时不能声称“什么都没写”。
3796
+ dryRun: edits.every((edit) => edit.receipt?.dryRun === true),
3301
3797
  summary: parts.join(' | '),
3302
3798
  unchanged: landed === 0,
3303
3799
  landed_paths: paths(true),
@@ -3308,6 +3804,59 @@ function mtEnvelope(edits, meta = {}) {
3308
3804
  }
3309
3805
  }
3310
3806
 
3807
+ /**
3808
+ * 事务回滚之后,把**已经装配好的批回执**改写成最终磁盘状态。
3809
+ *
3810
+ * `applyEditsToProvider` 只负责写;事务层(CLI/MCP)在 `ok:false` 时回滚已落地的文件。
3811
+ * 旧版把回滚前的批回执原样返回,于是 `landed_paths` / `applied` / `summary` 仍说“改成了”,
3812
+ * 而盘上已经恢复原文——回执与磁盘相反,是比失败更坏的形态。
3813
+ *
3814
+ * 判据:
3815
+ * · `rollback.status === 'rolled_back'` 且确实有文件落地过 ⇒ 逐项改成 `rolled_back`、
3816
+ * `landed:false`、`applied:0`、`landed_paths:[]`、`unchanged:true`,并由 `mtEnvelope`
3817
+ * 重算汇总行(不再出现 `applied 1` / `landed 1`)。
3818
+ * · 回滚有冲突(`recovery_required`)⇒ **不谎称**已全部恢复:保留逐项 landed,标
3819
+ * `recovery_required:true`,把冲突原样交给调用方。
3820
+ * · 根本没落地过(规划阶段失败)⇒ 保留原回执,只附 `rollback` 事实。
3821
+ *
3822
+ * @param batch - 引擎刚返回的批回执(含 `edits`)
3823
+ * @param rollback - `tx.rollback()` 的返回值
3824
+ * @returns 新回执;`ok` 由调用方(事务层)决定,这里不伪造成功
3825
+ */
3826
+ export function rebuildAfterRollback(batch, rollback) {
3827
+ const edits = Array.isArray(batch?.edits) ? batch.edits : []
3828
+ const hadLanded = edits.some((edit) => edit.landed === true)
3829
+ if (!hadLanded || rollback?.status !== 'rolled_back') {
3830
+ return {
3831
+ ...batch,
3832
+ rollback,
3833
+ ...(rollback?.status === 'recovery_required' ? { recovery_required: true } : {}),
3834
+ }
3835
+ }
3836
+ const finalEdits = edits.map((edit) => {
3837
+ if (edit.landed !== true) return { ...edit, landed: false }
3838
+ return {
3839
+ ...edit,
3840
+ outcome: 'rolled_back',
3841
+ landed: false,
3842
+ rolled_back: true,
3843
+ note: 'written, then rolled back — the file is back to its pre-call content',
3844
+ // 旧的 receipt 描述的是“写下去的内容”;回滚后它不再等于磁盘,不能原样留着。
3845
+ receipt: {
3846
+ path: edit.file_path,
3847
+ applied: [],
3848
+ dryRun: false,
3849
+ rolled_back: true,
3850
+ },
3851
+ }
3852
+ })
3853
+ return {
3854
+ ...mtEnvelope(finalEdits, { aborted: 'rollback' }),
3855
+ rolled_back: true,
3856
+ rollback,
3857
+ }
3858
+ }
3859
+
3311
3860
  /**
3312
3861
  * 多目标写侧的**唯一入口**(fs-contract-v2 / MT3;形状见契约 §2.2,提交语义见 §3)。
3313
3862
  *