aura-basetools 0.0.0-stage → 0.7.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.
@@ -0,0 +1,3446 @@
1
+ /**
2
+ * write-path.mjs —— 写路径的**判据层**:从"读到的内容"到"落盘并验回"的全部规则。
3
+ *
4
+ * 这里刻意**不碰任何框架**:不 import DSH、不 import node:fs。文件读写、版本比对、策略解析
5
+ * 由调用方的 `provider` 提供(契约见 `README` 顶部注释),因此 DSH 壳(`aura-edit/index.js`)、
6
+ * 独立 CLI、MCP 服务三者共用同一份判据 —— 一份实现,三处调用。
7
+ *
8
+ * 本文件由 `tools/_extract-write-path.mjs` 从 `aura-edit/index.js` 机械搬迁而来
9
+ * (用引擎自己的 `outline()` 取跨度,不是手抄);改这里,别在壳里再写第二份。
10
+ *
11
+ * provider 契约(四个方法,全部可选 signal):
12
+ * stat(target) -> { version, size } | undefined
13
+ * readText(target) -> string (提供方视角:解码时剥掉恰好一个前导 BOM)
14
+ * readByteRange(target, {offset,length}) -> Uint8Array
15
+ * writeText(target, content, expected) -> { version } (expected = {kind:'replaceIfVersion', version} 时为 CAS)
16
+ */
17
+ import { normSpace } from './anchor-core.mjs'
18
+ import { ASSERT_RELAX_MIN_CHARS, clipReceiptLabel, expectVerdict, locate, quoteForMessage } from './locator-api.mjs'
19
+ import {
20
+ LINE_ADDRESS_HINT,
21
+ MT_MAX_TARGETS,
22
+ MT_POOL,
23
+ assertEditTargets,
24
+ editTargetRequests,
25
+ gaveLineAddress,
26
+ handleShapeVerdict,
27
+ lineRangeOf,
28
+ toLineNumber,
29
+ } from './args.mjs'
30
+ /**
31
+ * 区间句柄(HD2):写侧**解析**句柄;读侧怎么发见 `read-core.mjs`。
32
+ *
33
+ * `handle-core.mjs` 是叶子(导入表为空)⇒ 本文件 import 它不会造出
34
+ * `write-path → read-core → handle-core` 那类环。
35
+ */
36
+ import { identityTag, mintHandle, parseHandle, regionFingerprint } from './handle-core.mjs'
37
+ import {
38
+ BYTE_ORDER_MARK,
39
+ BYTE_ORDER_MARK_BYTES,
40
+ contentToken,
41
+ countByteOrderMarks,
42
+ hasObservedVersion,
43
+ latestObservation,
44
+ leadingByteOrderMarks,
45
+ ledgerIdentityKey,
46
+ rememberRead,
47
+ } from './read-core.mjs'
48
+
49
+
50
+ /**
51
+ * 补丁自身的错误类型:带稳定 code 与可回报的候选行号。
52
+ */
53
+ class PatchError extends Error {
54
+ constructor(message, code, extra = {}) {
55
+ super(message)
56
+ this.code = code
57
+ Object.assign(this, extra)
58
+ }
59
+ }
60
+
61
+ /**
62
+ * 把内容切成“行 + 每行自己的行尾”。
63
+ *
64
+ * 为什么不能再用“整份文件一个主流行尾”:混合行尾的文件里,那会把**没被寻址**的行的行尾一起改掉——
65
+ * 实测(`%TEMP%\aura-byte\mixed.md`:`alpha\r\nbeta\ngamma\r\n`,CRLF 是多数派)改第 2 行 ⇒
66
+ * 第 2 行从 LF 变成 CRLF(19 → 22 字节),而“我没让你改行尾”这件事没有任何提示。
67
+ *
68
+ * 行尾只认 `\n` 与 `\r\n`(与 {@link locate} 里的 `split(/\r?\n/)` 同一把尺子,行号因此不会错位);
69
+ * 单 `\r` 不是行尾,这类文件由 `locator-api` 给出明确的拒绝说明。
70
+ *
71
+ * @param content - 文件全文
72
+ * @returns {{lines: string[], endings: string[]}} `endings[i]` 是第 i 行后面的行尾(最后一行通常为 '')
73
+ */
74
+ function splitLinesWithEndings(content) {
75
+ const lines = []
76
+ const endings = []
77
+ const breaks = /\r\n|\n/g
78
+ let start = 0
79
+ let hit
80
+ while ((hit = breaks.exec(content)) !== null) {
81
+ lines.push(content.slice(start, hit.index))
82
+ endings.push(hit[0])
83
+ start = hit.index + hit[0].length
84
+ }
85
+ lines.push(content.slice(start))
86
+ endings.push('')
87
+ return { lines, endings }
88
+ }
89
+
90
+ /**
91
+ * `replace` 在回执里的**行文本**口径:按 `\r\n|\n` 切行、用 `\n` 接回。
92
+ *
93
+ * 唯一一份:{@link planHunks} 的 `applied[].replaced` 与片段层就地记下的 `replacedText` 都读它。
94
+ * 行尾不进回执 —— 被替换区域**末尾**的行尾是继承来的(见 `planHunks` 拼接段),不是调用方
95
+ * 写的那一份;把它放进来只会在展示时漏出 `\r` 并凭空多算一行。
96
+ */
97
+ function replaceTextOf(text) {
98
+ return splitLinesWithEndings(text).lines.join('\n')
99
+ }
100
+
101
+ /**
102
+ * 这份内容是不是**混合行尾**(同时存在 `\r\n` 与裸 `\n`)。
103
+ *
104
+ * 为什么需要它:`editText` 只在行尾**统一**时逐字节等价(normalize/restore 在纯 LF / 纯 CRLF / 纯 CR
105
+ * 上是恒等变换),混合行尾必被整份归一。因此后端没有 writeText 时,混合行尾文件只能**拒绝**,
106
+ * 不能降级去走 editText——那会把“静默改掉非目标行的行尾”重新放回来。
107
+ */
108
+ function hasMixedLineEndings(content) {
109
+ return content.includes('\r\n') && /(^|[^\r])\n/.test(content)
110
+ }
111
+
112
+ // ── 区域级新鲜度闸门(C13)─────────────────────────────────────────────────
113
+ //
114
+ // 未带 `expect` 的 hunk,落盘前核对**被寻址区域**是否在“本次观测”之后被动过。
115
+ // 为什么不能退化成全局版本守:v1 就是因为“整份版本变了就拒”被否掉的——
116
+ // 它会误杀“别人改了无关区域”的正常编辑(`stale-gate-test` 段C)。
117
+ // 带 `expect` 的 hunk 不进这道闸门:它已经用 `expect` 把内容钉住了。
118
+
119
+ /**
120
+ * 陈旧冲突:这个地址**还作数吗**?
121
+ *
122
+ * 判据分两步,缺一不可:
123
+ * 1. **文本**:同一个 hunk 再对上次观测到的全文跑一遍定位(`planHunks` 是纯函数,
124
+ * `lines=` / `line_cue=` / `anchor=` / `target=` 四层因此共用一条判据,不必各写一套),
125
+ * 比对两次解析出的区域文本;
126
+ * 2. **坐标**:被寻址的那段文本,在**当前**内容的行号还对不对得上。
127
+ *
128
+ * 第 2 步是**实测补上的**(2026-09-22,探针 `_t21-probe` 五例):只有第 1 步时,
129
+ * 行号整体位移 + 同址文本恰好相符 ⇒ 放行,而模型给的坐标指的是**别处**:
130
+ * · 观测 `one/two/three/four/five`,第三方删掉前两行 ⇒ 当前 `three/four/five`;
131
+ * · 模型发 `lines=[3,3]`:观测第 3 行是 `three`,当前第 3 行是 `five` ⇒ 第 1 步就拦(这是老判据);
132
+ * · 但观测第 3 行是 `three`、当前第 3 行**也是** `three` 的情形(重复行 / 固定块的文件)⇒ 老判据放行。
133
+ * 第 2 步把“同一段文本现在在第几行”也算进来,于是位移不再需要靠行号恰好对齐来蒙对。
134
+ *
135
+ * ## 为什么**不报“偏移了多少行”**(一次尝试后的结论,记下来免得重做)
136
+ *
137
+ * 看上去很自然:既然陈旧多因“行号移位”,那就把新行号报给模型,让它一次重发就能成。
138
+ * 我实现过一版,结果它**永不可能触发**,而且会报**假偏移**:
139
+ *
140
+ * · 用“首次出现”当原处,在文本于别处也出现时报出偏移 0——“你给的行号现在指着别的东西”
141
+ * 这句真话会被报成“没动过”。**假数字比没有数字更坏**,所以这里只在“唯一出现”时给行号,
142
+ * 其余一律 `null`(收据里说“无法唯一确定”)。
143
+ * · 就算算对了,**用处也有限**:模型拿到新行号后**仍然要重读**才知道那一段现在长什么样。
144
+ *
145
+ * 结论:地址与版本必须一起走(T21 那条账本不变式的方向),而不是在回执里事后补一个偏移。
146
+ *
147
+ * @param observedSource - 上次观测到的内容
148
+ * @param content - 本次实际读到的内容(坐标核对要在它身上做)
149
+ * @param hunks - 本次的 hunk
150
+ * @param matches - 在当前内容上已定到的区域
151
+ * @param filePath - 用于判定语言
152
+ * @returns `{lines, tier, reason, nowAt}[]`;`reason` ∈ absent-in-observation / moved-out / moved / changed
153
+ */
154
+ function staleConflicts(observedSource, content, hunks, matches, filePath, revision = {}) {
155
+ const contentPath = revision.contentPath ?? filePath
156
+ /**
157
+ * 这一份闸门里,**观测那一版与当前内容的行数组各切一次**。
158
+ *
159
+ * 改前每个 hunk 都要重切好几遍整份:`regionTextIn` 里一遍、坐标核对里两遍、短路里再两遍——
160
+ * 90001 行档一份 5.3MB 内容切一次实测 5.1ms,乘上 hunk 数与调用点数就是闸门那一格的大头。
161
+ * 尺子一个字没换:还是 `split(/\r?\n/)`(见 {@link regionLinesIn} 的注释)。
162
+ */
163
+ let observedRowsMemo = null
164
+ const observedRows = () => (observedRowsMemo ??= observedSource.split(/\r?\n/))
165
+ let contentRowsMemo = null
166
+ const contentRows = () => (contentRowsMemo ??= content.split(/\r?\n/))
167
+ /**
168
+ * “两份内容逐行相同”是**两份内容**的性质,不是某个 hunk 的性质 ⇒ 一次闸门只算一遍。
169
+ * (这条短路是旧代码就有的正确性判据,下面用到它的地方一字未改,只是不再每个 hunk 重算。)
170
+ */
171
+ let sameRowsMemo = null
172
+ const sameRows = () => (sameRowsMemo ??= observedRows().join('\n') === contentRows().join('\n'))
173
+ // 这里曾经挂着 `currentToken`(当前内容的版本令牌),但全函数没有任何地方用它——死码,删掉;
174
+ // 账本令牌的 BOM 那一半只有 {@link regionGate} 算 `basis` 时用得上。
175
+ const conflicts = []
176
+ for (const match of matches) {
177
+ const hunk = hunks[match.hunk]
178
+ if (hunk === undefined) continue
179
+ // 带了 `expect` 的 hunk 不走这里:它自己的断言就是闸门(且报错里会贴出原文)。
180
+ // **句柄 hunk 是例外**(D3):句柄是"更准的读过的区间",它要**喂给**闸门,
181
+ // 而不是借 `expect` 从闸门下面绕过去——句柄不是绕过 `PATCH_STALE_REGION` 的路。
182
+ if (hunk.expect !== undefined && hunk.expect !== null && !hasHandleArg(hunk)) continue
183
+ /**
184
+ * 观测那一版里,这个地址落在哪一段文本上 —— **只算这一段,不重跑整份**(C18)。
185
+ *
186
+ * `lines=` 是纯坐标地址:观测那一版的区域就是那几行,按坐标取即可({@link observedRegionByLines})。
187
+ * 等价性逐条对齐:`planHunks` 对纯行号地址走 `locate` 的第 2 层,`done()` 回的 `actual`
188
+ * 就是 `lines.slice(start-1, end).join('\n')`(同一把 `split(/\r?\n/)` 尺子),
189
+ * 行号写坏 / 越界则抛 `LOCATOR_*` ⇒ `regionTextIn` 回 null ⇒ 这里同样回 null。
190
+ *
191
+ * 其余形态(`old` / `target` / `line_cue` / `anchor`)是**内容寻址**:
192
+ * “这段文本在观测那一版里指哪儿”只有定位本身答得出来 ⇒ 仍旧走 {@link regionTextIn}。
193
+ * 那是**地址**,不是可以省掉的重算。
194
+ */
195
+ /**
196
+ * **句柄 hunk 的观测侧**(HD2):坐标取自**句柄**(读侧当时自己写下的那几个行号),
197
+ * 不是调用方拄的 `lines`。它与另外两条路是同一个问题("这段地址在观测那一版里指着哪些字节"),
198
+ * 所以只多一个取坐标的来源,不新开一条旁路:比对、冲突分类、收据全部沿用下面同一份代码。
199
+ */
200
+ const handleRegion = match.handleRegion
201
+ const observed = handleRegion !== undefined
202
+ ? observedRegionByHandle(hunk.handle, observedRows())
203
+ : byLineNumbers(hunk)
204
+ ? observedRegionByLines(hunk, observedRows())
205
+ : regionTextIn(observedSource, hunk, filePath)
206
+ if (observed === null) {
207
+ // 观测里根本没有这块区域(当时行数更少 / 地址当时就无效)⇒ 坐标无从核对。
208
+ conflicts.push({ lines: [match.line, match.end], tier: match.tier, reason: 'absent-in-observation' })
209
+ continue
210
+ }
211
+ /**
212
+ * 比对的是**句柄定下的整段区间**(`handleRegion.text`),不是这次替换的那一小段:
213
+ * 句柄的凭据覆盖整个区间,而 `line: k` / `old` 只是区间**内部**的相对定位。
214
+ */
215
+ if (observed !== (handleRegion === undefined ? match.actual : handleRegion.text)) {
216
+ // 文本变了。若**原文还在**(在别的行号上)⇒ 是被整体推移,而不是被改写:
217
+ // 这两句对模型的下一步完全不同(前者要重定位,后者内容可能已被别人改了),所以分开报。
218
+ const origin = regionLinesIn(content, observed, contentRows()).first
219
+ conflicts.push({
220
+ lines: [match.line, match.end],
221
+ tier: match.tier,
222
+ reason: origin === null ? 'changed' : 'moved',
223
+ nowAt: origin,
224
+ })
225
+ continue
226
+ }
227
+ /**
228
+ * 只在**模型自己给出行号**时做坐标核对(`lines=` / `start_line+end_line`)。
229
+ *
230
+ * 为什么不能对所有寻址都做:`target=` / `anchor=` / `line_cue=` 是**内容寻址**——
231
+ * 定位器本来就是靠那段文本(或那个符号名)在**当前**内容里重新定位的,位移正是它该吸收的东西。
232
+ * 对它们再要求“还在原来那个行号”会把该成功的编辑拒掉,`stale-gate-test` 段J 两条断言
233
+ * (符号层 / 锚点层:插入两行后同一函数文本未变 ⇒ 必须成功)实测就是这么红的。
234
+ * 而 `lines=` 不一样:那串数字是模型从上一版视图里抄下来的**绝对坐标**,
235
+ * 一旦行数变了就可能指着别处,而且没有任何东西能替它发现这件事。
236
+ */
237
+ if (!byLineNumbers(hunk)) continue
238
+ /**
239
+ * 行号地址的漂移核对。判据经过**四次证伪**才成形,四版错误全记在这里免得重做:
240
+ *
241
+ * · 一版「这段文本在文件里只出现一次,且还在这个行号上」:第⑥例证伪——
242
+ * 文本重复是**常态**(`}`、空行、许可证头、`import` 行),于是“唯一”恒不成立,
243
+ * 没位移的正常编辑也一起被拒。重复行不是缺陷,是文件本来的样子。
244
+ * · 二版「文件总行数变了就拒」:第②例证伪——那是**整份**的属性,
245
+ * 第三方在下方删两行时总行数变了,可被寻址的那一行逐字节没动。
246
+ * · 三版「从文件头到本区域末尾必须逐字相同」:段C 证伪——那是文档化的非回归基准
247
+ * (“不许退化成全局版本守”),第三方改的是**第 1 行**,被寻址的第 3 行一字未动。
248
+ * · 四版「总行数必须相同」:段C 第二例(只在尾部追加)证伪,理由同二版。
249
+ *
250
+ * 四版错在同一个地方:都想用**整份**的性质去回答**区域**的问题。现在改成两步:
251
+ *
252
+ * 1. **锚定**:观测那一版里,这段文本出现在第几行?它必须就在模型说的那一行。
253
+ * 不是 ⇒ 模型手上的行号来自别的一版,它以为在改 A、其实在改 B ⇒ 拒。
254
+ * 这一步能拦住上/下方删行造成的位移(第①⑤例、段C 之前的 f3)。
255
+ * 2. **行数旁证**(只在锚定不成立、文本又重复出现时才用得上):
256
+ * 若观测与当前**行数不同**,就可能存在“前面删两行、后面添两行”这种
257
+ * 位移量恰好自偿的情形——那时文本对得上、行数也对得上,只有“区域出现次数”变了。
258
+ * 行数一致 ⇒ 没有插删 ⇒ 放行;不一致且区域非唯一 ⇒ 拒(第③例)。
259
+ */
260
+ /**
261
+ * 短路:**观测到的就是现在这份内容**(逐字节,行尾不参与)⇒ 不可能有漂。
262
+ *
263
+ * 这条不是优化,是正确性:下面那套锚定判据对**重复文本**无能为力——
264
+ * 区域 `three` 在文件里出现两次时,`regionLinesIn` 只能给第一次出现(第 1 行),
265
+ * 而模型编的是第二次(第 3 行),于是“锚点对不上”会把**一字未改的文件**判成漂移
266
+ * (探针第⑥例当场如此)。而“两份内容逐字相同”时,行号必然还对得上,不需要任何推断。
267
+ */
268
+ if (sameRows()) continue
269
+ const thenAt = regionLinesIn(observedSource, observed, observedRows())
270
+ if (thenAt.first !== match.line) {
271
+ const nowAt = regionLinesIn(content, match.actual, contentRows())
272
+ conflicts.push({
273
+ lines: [match.line, match.end],
274
+ tier: match.tier,
275
+ reason: nowAt.first === null ? 'moved-out' : 'moved',
276
+ nowAt: nowAt.first,
277
+ })
278
+ continue
279
+ }
280
+ if (observedRows().length !== contentRows().length) {
281
+ const nowAt = regionLinesIn(content, match.actual, contentRows())
282
+ // 区域在两侧都不唯一时才敢下结论:不唯一说明这份内容里不止一处长得像,
283
+ // 那么“行数变了但区域还在原处”就不足以证明它没漂。
284
+ if (thenAt.count !== 1 || nowAt.count !== 1) {
285
+ conflicts.push({ lines: [match.line, match.end], tier: match.tier, reason: 'moved', nowAt: nowAt.first })
286
+ }
287
+ }
288
+ }
289
+ return conflicts
290
+ }
291
+
292
+ /**
293
+ * 这段文本在**哪一行**
294
+ *
295
+ * 用途只有一个:核对“被寻址的那段文本现在还在不在这个行号上”。
296
+ * 与 {@link splitLinesWithEndings} 用同一把行尾尺子,所以行号与 `planHunks` 报的行号是同一个坐标。
297
+ *
298
+ * 只回答“在不在、在哪”,**不回答“偏移了多少行”**:
299
+ * · 用“首次出现”当原处会在文本重复时报出**假偏移**(同一段文本在别处也出现时,首次出现往往就是原处,
300
+ * 于是算出偏移 0,等于把“你给的行号现在指着别的东西”报成“没动过”)——假数字比没有数字更坏;
301
+ * · 偏移量对模型也没有额外用处:它仍然得重读那一段才知道现在长什么样。
302
+ *
303
+ * @param source - 要在其中查找的内容(**整份**内容,不是那一段文本自己)
304
+ * @param text - 被寻址区域的原文(`match.actual` / 观测侧解析结果)
305
+ * @returns {{count: number, first: number|null}} 出现次数与首次出现的行号;`first` 仅在唯一时给出
306
+ */
307
+ /**
308
+ * 闸门拒绝的收据:先说**哪个区域**,再说**为什么**,最后说**下一步怎么做**。
309
+ *
310
+ * “为什么”必须分情形,因为三种原因对模型的下一步不同(实测依据见 {@link staleConflicts}):
311
+ * · `changed` —— 区域文本被改过;
312
+ * · `moved` —— 文本还在,但已不在那个行号上(唯一位置时点名新行号);
313
+ * · `moved-out` —— 文本已不唯一存在,**给不出新行号**。这里必须说“无法唯一定位”,
314
+ * 不能随便挑一个行号充数——假数字比没有数字更坏(同 {@link regionLinesIn})。
315
+ *
316
+ * “下一步”里 `expect` 不能省:单纯重读后用行号地址重试,可能把改动写到错行上
317
+ * (其他写者已把行号移位);带 `expect` 的重试才会在错行上停下来。
318
+ *
319
+ * 冲突可能不止一处。挂在错误对象上的 `minimal_next` / `others` 框架**不读**
320
+ * (只取 `error.message`,见 `dsh-tools/lib/index.js:2495-2503`),所以“还有几处”必须写进 message。
321
+ *
322
+ * @param displayPath - 面向模型的路径
323
+ * @param conflicts - {@link staleConflicts} 的产出(至少一条)
324
+ * @returns {{message: string, extras: object}}
325
+ */
326
+ function staleRegionRefusal(displayPath, conflicts) {
327
+ const [first] = conflicts
328
+ const [from, to] = first.lines
329
+ const why =
330
+ first.reason === 'changed'
331
+ ? 'the addressed region changed after the last observation'
332
+ : first.reason === 'moved'
333
+ ? `the text you addressed moved: line ${from} no longer holds it (it is now line ${first.nowAt})`
334
+ : first.reason === 'moved-out'
335
+ ? `the text you addressed is no longer at line ${from} and cannot be located unambiguously elsewhere`
336
+ : first.reason === 'unobserved-revision'
337
+ ? `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
+ : `lines ${from}-${to} were not present in the last observation — the file has changed shape since you read it`
339
+ const more =
340
+ conflicts.length > 1
341
+ ? `; ${conflicts.length - 1} more addressed region(s) also changed: ${conflicts
342
+ .slice(1, 5)
343
+ .map((c) => `lines ${c.lines[0]}-${c.lines[1]}`)
344
+ .join(', ')}${conflicts.length > 5 ? `, +${conflicts.length - 5} more` : ''}`
345
+ : ''
346
+ return {
347
+ message:
348
+ `cannot patch "${displayPath}": ${why} — re-read those lines, then retry with \`expect\` set to what you find there ` +
349
+ `(that keeps a shifted retry from landing on the wrong line)${more}`,
350
+ extras: {
351
+ // 只有“唯一位置”才是可信的重读坐标;不确定就不给,免得模型照着一个假行号重试。
352
+ minimal_next: {
353
+ lines: [from, to],
354
+ ...(first.nowAt === undefined || first.nowAt === null ? {} : { now_at_line: first.nowAt }),
355
+ },
356
+ others: conflicts.map((c) => ({
357
+ lines: c.lines,
358
+ by: 'unknown-writer',
359
+ ...(c.reason === undefined ? {} : { reason: c.reason }),
360
+ ...(c.nowAt === undefined || c.nowAt === null ? {} : { now_at_line: c.nowAt }),
361
+ })),
362
+ },
363
+ }
364
+ }
365
+
366
+ /**
367
+ * 账本里那条记录的**令牌长度**(令牌前半是内容指纹,后半是长度,见 `contentToken`)。
368
+ *
369
+ * 为什么快路上需要它:快路要同时把 `basis` 说准(“这份内容我们观测过吗”),而完整答案要
370
+ * `contentToken`(5.3MB 实测 8.9ms,占端到端 3%+)——快路上这个答案**不必再哈希一遍**:
371
+ * · 观测条目就是用它自己的令牌存进去的,`latestObservation` 又是从同一条里取出来的
372
+ * ⇒ 只要**这次算出来的令牌与它逐字符相同**,`hasObservedVersion` 必为 true;
373
+ * · 令牌 = `hash(有效串):有效串长度`,而有效串 =(BOM 约定 ? U+FEFF : '')+ 内容;
374
+ * · 调用方已确认 `observation.source === content` ⇒ 两个内容长度相同
375
+ * ⇒ 有效串长度相同 ⇔ 两次的 BOM 约定相同 ⇒ 有效串**逐字符相同** ⇒ hash 也相同。
376
+ * 反过来,长度对不上就说明两次的 BOM 约定不同(那就可能不是同一条令牌),
377
+ * 这时**不走快路**,交回老路径完整算一遍——宁可多算一次,不猜。
378
+ *
379
+ * @param version - 账本条目的令牌(`hash:length`);形状不对时回 -1(与任何长度都不等)
380
+ * @returns 令牌里的长度那一半
381
+ */
382
+ function storedTokenLength(version) {
383
+ const at = typeof version === 'string' ? version.lastIndexOf(':') : -1
384
+ return at === -1 ? -1 : Number(version.slice(at + 1))
385
+ }
386
+
387
+ /**
388
+ * 区域级新鲜度闸门的**决策**:读一次账本 → 有基准就比 → 有冲突就给出收据。
389
+ *
390
+ * 为什么单独成一个函数:这个决策有三个调用方(DSH 壳 `aura-edit`、独立 CLI/MCP 的
391
+ * `applyEditToProvider`、以及测试),而"同一个决策写在两处,就一定会有一处漏改"
392
+ * 在本仓已被实证过一次(`symbolHint` 的 `slice(0, 24)`)。判据({@link staleConflicts})
393
+ * 与收据({@link staleRegionRefusal})都在这里,调用方只负责编排。
394
+ *
395
+ * 三个返回形态的语义是**穷尽**的,调用方可以据此原样写回执:
396
+ * · `clean: true, basis: 'observation'` —— 有基准且无冲突(可以真心说已核对过);
397
+ * · `clean: true, basis: 'none'` —— **没有可比基准**,且冲突判据没发现任何问题。
398
+ * 调用方要如实标注(DSH 壳 / CLI 都在回执里带 `unverified_region`),不得冒充新鲜;
399
+ * · `clean: false` —— 有冲突,收据在 `message` / `extras` 里。
400
+ *
401
+ * `basis: 'none'` 有两种来源,**必须当成同一种诚实**:
402
+ * · 从未观测过这个文件(账本为空);
403
+ * · 观测缓存有界(默认 8 条 / 256 KiB),基准已被淘汰 ⇒ 未命中就是没有基准可比,
404
+ * 而不是"看起来没变"。实测依据在 `range-bytes-test` 段G:>256 KiB 的文件不入缓存,
405
+ * 那条回执本来就必须带 `unverified_region`。
406
+ *
407
+ * ## 这份"当前内容"是不是我们观测过的那一版
408
+ *
409
+ * 账本的键是 **`路径@内容令牌`**(`rememberRead` 的注释写着为什么是内容令牌而不是行号/时间戳):
410
+ * 因此 `hasObservedVersion(路径, 当前内容的令牌)` 回答的是"**这份**内容我们见过吗"。
411
+ * 它答 false 有两种情形,回执都给"未核对",但**说辞不同**:
412
+ * · 账本为空 ⇒ 没读过,根本没有基准;
413
+ * · 账本非空 ⇒ 这份内容是在我们最后一次观测**之后**由别人写下的。
414
+ * 模型手上的行号来自更早的一版,而没有任何东西能替它发现这件事:
415
+ * 写侧的版本 CAS 在一次调用**内部**取 `stat`(`write-path` 顶部注释与 `stale-gate-test` 头部
416
+ * 都记着这条),调用之前发生的第三方写入对它是"当前状态",不是冲突。
417
+ * 所以这里把它标成 `unobserved-revision` —— 诚实地说"我核对不了",而不是默默放行。
418
+ * 注意它与 `basis: 'none'` 的分工:`none` 是闸门**没找到**基准,
419
+ * `unobserved-revision` 是闸门找到了基准、但基准对不上**当前这一版**。
420
+ *
421
+ * ## 基准换成了什么(C18)
422
+ *
423
+ * 闸门要回答的问题只有一个:**被寻址区域在这次观测之后有没有被改过**。既然如此,
424
+ * **最少需要重算的是「观测那一份还在不在」,而不是「每个 hunk 落在哪」**:两份内容逐字符相同时,
425
+ * “哪一段被改过”这个问题是自明的(一个字符都没变),而逐 hunk 重放一遍观测全文的规划,
426
+ * 算出来的必然是“没有冲突”。
427
+ *
428
+ * · 换掉的是:**逐 hunk 把整份观测内容重新 `planHunks` 一遍**,外加每个 hunk 几遍整份
429
+ * `split(/\r?\n/).join('\n')`。90001 行档一次两行改动的写侧端到端实测(`write-gate-cost-test` 段A):
430
+ * 闸门 87.61ms / 端到端 268.84ms = 32.6%,而闸门那一格几乎全是这份重复规划。
431
+ * · 换来的是:一次 `observation.source === content` 的整串比较加一次令牌长度比对,之后直接回
432
+ * “已核对、无冲突”。90001 行档(两份各 5.3MB 的不同字符串)闸门那一格实测 1.73-1.89ms,
433
+ * 逐轮 ms = [1.78, 1.70, 1.66, 1.73, 1.65, 1.75, 3.75];同一个字符串对象相比是 0.004ms
434
+ * (指针级),真实形态走的是 memcmp 级的那条路,所以这里报的是前一串数,不是后一个。
435
+ *
436
+ * ## 保住了什么(判据零放松,逐条对齐)
437
+ *
438
+ * · 逐字符相同 ⇒ 旧路径的每一步都**不可能**报冲突:`regionTextIn(观测, hunk)` 与
439
+ * `regionTextIn(当前, hunk)` 是同一个纯函数、同一组入参(内容逐字符相同、hunk 同一个、filePath 同一个),
440
+ * 不可能一个回 null、另一个给出别的文本;
441
+ * · 坐标核对那一步本来就被“两份内容逐行相同”这条短路跳过——而那条短路是**旧代码自己**
442
+ * 用来判“不可能有漂”的判据(见 {@link staleConflicts} 里那处注释),这里只是把它放到
443
+ * “不必先重算一遍”的位置上;
444
+ * · `basis` 的诚实性不打折:令牌的另一半是长度,长度对得上就意味着当初记账用的 BOM 约定与这次一致,
445
+ * 两个有效串因此逐字符相同、令牌必然相同(推导见 {@link storedTokenLength});
446
+ * 对不上就**不走这条路**,交回老路径完整算一遍令牌,如实标 `basis: 'none'`。
447
+ * · 内容只要差一个字符(哪怕只差行尾),快路就不成立,走的仍是原来那条逐 hunk 判据的路。
448
+ *
449
+ * ## 为什么不拿“行尾归一后相同”当快路
450
+ *
451
+ * 那个条件比逐字符相同宽:两版内容在定位器眼里未必等价(`old` 的整行对齐第一级是**逐字**的),
452
+ * 拿它当快路等于**悄悄放松**判据。所以快路只认逐字符相同——先证等价,再换实现。
453
+ *
454
+ * @param hunks - 本次的 hunk(原始 DSL 形状)
455
+ * @param content - 本次实际读到的内容
456
+ * @param planned - `planHunks(content, hunks, ...)` 的结果
457
+ * @param displayPath - 账本钥匙与回执里的路径
458
+ * @param options.byteOrderMark - 当前文件是否有前导 BOM(账本令牌的一半;缺省 false)
459
+ * @returns {{clean: true, basis: 'observation'|'none'} | {clean: false, message: string, extras: object}}
460
+ */
461
+ /**
462
+ * 区域级新鲜度闸门:手上的这份内容与**观测过的那一版**比,被寻址的区域是不是动过了。
463
+ *
464
+ * ## 快路:观测到的就是现在这份内容
465
+ *
466
+ * 闸门原来每次都要把**每个 hunk 对观测版重跑一遍定位**({@link staleConflicts}),
467
+ * 代价随文件规模线性增长:90001 行档实测 46.98ms,占端到端 105ms 的 45%。
468
+ * 而绝大多数编辑里,模型手上的内容**逐字符就是**它刚读过的那一版(工具在两次调用之间
469
+ * 什么也没做)——那时"区域是否漂移"这个问题不需要算:没有漂移可言。
470
+ *
471
+ * 快路因此只认**逐字符相同**:
472
+ * · 判据成本 O(1)(比长度 + 首个不同处),与文件规模无关;
473
+ * · `basis` 的诚实性不打折:令牌的另一半是长度,长度对得上就意味着当初记账用的 BOM 约定与这次一致,
474
+ * 两个有效串因此逐字符相同、令牌必然相同(推导见 {@link storedTokenLength});
475
+ * 对不上就**不走这条路**,交回老路径完整算一遍令牌,如实标 `basis: 'none'`。
476
+ * · 内容只要差一个字符(哪怕只差行尾),快路就不成立,走的仍是原来那条逐 hunk 判据的路。
477
+ *
478
+ * ## 为什么不拿“行尾归一后相同”当快路
479
+ *
480
+ * 那个条件比逐字符相同宽:两版内容在定位器眼里未必等价(`old` 的整行对齐第一级是**逐字**的),
481
+ * 拿它当快路等于**悄悄放松**判据。所以快路只认逐字符相同——先证等价,再换实现。
482
+ *
483
+ * @param hunks - 本次的 hunk(原始 DSL 形状)
484
+ * @param content - 本次实际读到的内容
485
+ * @param planned - `planHunks(content, hunks, ...)` 的结果
486
+ * @param displayPath - 回执与诊断里的路径(**调用方给的那个**,不许换成别的写法)
487
+ * @param options.identityKey - **宿主给的身份键**(同一份盘上文件必给同一个串)。
488
+ * 它只决定**账本记在哪个键下**,不参与任何回执文本;引擎拿不到文件系统事实
489
+ * (junction / 符号链接 / 8.3 短名),所以这个事实只能由宿主递进来。
490
+ * @param options.byteOrderMark - 当前文件是否有前导 BOM(账本令牌的一半;缺省 false)
491
+ * @returns {{clean: true, basis: 'observation'|'none'} | {clean: false, message: string, extras: object}}
492
+ */
493
+ function regionGate(hunks, content, planned, displayPath, options = {}) {
494
+ const byteOrderMark = options.byteOrderMark === true
495
+ /**
496
+ * 账本的键是**同一个磁盘文件**的键,不是**同一个路径字符串**的键(C54),
497
+ * 也不是**同一种路径写法**的键(C61)。
498
+ *
499
+ * C54 把词法那一半(`.` 段 / 冗余分隔符 / 正反斜线 / 盘符大小写)与进程内折叠别名
500
+ * 收进了 `read-core.mjs`。剩下的那一半——`realpath` 才认识的 junction / 符号链接 / 8.3 短名——
501
+ * 词法层永远拿不到(`PROGRA~1` 与真长名在词法上不共享任何前缀),而引擎被 `make-bundle.mjs`
502
+ * 的 `copyFile` 守卫禁直接碰 `node:fs`。实测后果:读 `…\linkdir\Gamma.js`、
503
+ * 第三方改掉被寻址的第 3 行、再用 `…\realdir\Gamma.js` 发一个**无 `expect`** 的 hunk
504
+ * ⇒ `outcome=applied` 且磁盘被静默覆盖(两个方向各自复现过一次)。
505
+ *
506
+ * 现在这一半由**宿主**在 `resolve()` 那一层给出(`dsh-fs` 契约里的 `targetKey`:
507
+ * 「stale guards and target lookup」用的不透明键,本地后端实现为 `realpath`),本函数只把它
508
+ * 递给同一个 {@link ledgerIdentityKey}——归一化仍然只有一处,这里不重复实现。
509
+ *
510
+ * 为什么要递 `content`:同一个折叠类里出现过 ≥2 种写法时(例如两种写法都读过同一个文件),
511
+ * 归一化**只能靠证据**选一份基准。手上这份全文就是那条证据:内容逐字节相同的两份观测是
512
+ * **可互换**的,拿哪份算结论都一样。代价:只在"一个折叠类里出现过 ≥2 种写法"时才读到它。
513
+ */
514
+ const ledgerKey = ledgerIdentityKey(displayPath, options.identityKey)
515
+ const observation = latestObservation(ledgerKey, { source: content })
516
+ if (observation === null) return { clean: true, basis: 'none' }
517
+ /**
518
+ * 快路:**要判断的那份内容就是刚观测过的那一份**(逐字符相同)⇒ 没有陈旧可言(见顶部注释)。
519
+ *
520
+ * 逐字符相同是**可以真的断言**的:两个字符串同一个长度、逐字符相等,V8 里走的是 memcmp 级别的比较,
521
+ * 5.3MB 实测 0.004ms——相对于这里省掉的 87.61ms 重复规划,这一比可以忽略。
522
+ * 第二条(令牌长度)只为把 `basis` 说准:它是“这份内容在账本里”的**充要条件的一半**,
523
+ * 对不上就不下结论、交回老路径(宁可多算一次,不猜)。
524
+ */
525
+ if (observation.source === content && storedTokenLength(observation.version) === content.length + (byteOrderMark ? 1 : 0)) {
526
+ return { clean: true, basis: 'observation' }
527
+ }
528
+ /**
529
+ * 当前内容不是账本里那一版 ⇒ 没有针对**它**的基准,回执必须如实标注(不冒充新鲜)。
530
+ *
531
+ * 这里**不能**再套一层 `providerView`:`content` 已经是 `readText` 的解码结果(第一个 BOM 已被
532
+ * 解码器吃掉,其余留在文本里),而账本里的令牌是 `contentToken(读到的文本, 字节里有 BOM 吗)`。
533
+ * 多剥一层会让**多 BOM 文件**的令牌对不上——实测 `bom2` / `bom3`:账本 `contentToken(„alpha…", true)`
534
+ * 有两个 BOM,而 `providerView` 后的那个只有一个 ⇒ 整个文件被报成"没核对过"。
535
+ * 单 BOM 文件恰好不受影响(`providerView` 是恒等),这类"只在多重形态下露头"的错最难发现。
536
+ *
537
+ * 令牌这里已经算出来了(`contentToken` 是要哈希全文的,5.3MB 实测 8.9ms),所以直接当证据递进去,
538
+ * 不另算一份。
539
+ */
540
+ const currentToken = contentToken(content, byteOrderMark)
541
+ const contentIsKnown = hasObservedVersion(ledgerKey, currentToken)
542
+ const basis = contentIsKnown ? 'observation' : 'none'
543
+ /**
544
+ * 冲突判据里用的是**显示路径**:它的产物是给人/模型看的(`nowAt` / 引文 / "line 3 no longer holds it"),
545
+ * 而模型看到的路径必须是它自己给的那个——换成 realpath 会让它下次照着回执发调用时对不上。
546
+ */
547
+ const conflicts = staleConflicts(observation.source, content, hunks, planned.matches, displayPath, {
548
+ contentPath: displayPath,
549
+ byteOrderMark,
550
+ })
551
+ if (conflicts.length === 0) return { clean: true, basis }
552
+ const refusal = staleRegionRefusal(displayPath, conflicts)
553
+ return { clean: false, message: refusal.message, extras: refusal.extras }
554
+ }
555
+
556
+ /**
557
+ * 这个 hunk 是不是**按行号**寻址的(而不是按内容)。
558
+ * 这个 hunk 是不是**按行号**寻址的(而不是按内容)。
559
+ *
560
+ * 判据必须与 {@link planHunks} / {@link locate} 的优先级一致:定位器先看 `target`,再看行区间。
561
+ * 换句话说,同时给了 `target` 和 `lines` 时,真正决定位置的是 `target`——
562
+ * 那么“行号可能漂了”这件事就不成立,不该拦。
563
+ * 两份判据若各写一套,迟早出现“闸门以为在用行号、定位器其实在用符号”。
564
+ *
565
+ * @param hunk - 原始 DSL 形状的 hunk
566
+ * @returns 该 hunk 的地址是否完全由行号决定
567
+ */
568
+ function byLineNumbers(hunk) {
569
+ /**
570
+ * **句柄 hunk 一律不是行号地址**(HD2)。
571
+ *
572
+ * 它确实带行号,但那行号是**句柄自己**写的:`line: k` 是段内相对行(相对区间起点),
573
+ * 不是从上一版视图抄来的绝对坐标。把 `{handle, line: 2}` 当 `lines: [2, 2]` 读,
574
+ * 就是拿一个相对量去和绝对坐标比——闸门会因此误报漂移,而定位器**根本不需要**
575
+ * 那个数(位置由句柄定)。这条正是 D7「句柄优先」在闸门这一层的写法。
576
+ */
577
+ if (hasHandleArg(hunk)) return false
578
+ /**
579
+ * `old` 是**内容地址**(见 {@link locateByOld}):位置由原文决定,坐标只是提示,
580
+ * 位移正是它该吸收的东西——与下面 `target` / `line_cue` 同一条理由。
581
+ * 这一行不能漏:`{old, lines}` 同时在场时,坐标若仍被当成地址,
582
+ * 闸门会把一次正确的“按原文改到新行号”判成 `moved` 拒掉。
583
+ */
584
+ if (oldAddressOf(hunk) !== null) return false
585
+ if (typeof hunk.target === 'string' && hunk.target.trim().length > 0) return false
586
+ if (typeof hunk.line_cue === 'string' && hunk.line_cue.trim().length > 0) return false
587
+ return lineRangeOf(hunk) !== undefined
588
+ }
589
+ /**
590
+ * 这段文本在**哪一行**(按行边界匹配,不匹配行中间的巧合)。
591
+ *
592
+ * 只回答“在不在、在哪”,**不回答“偏移了多少行”**:
593
+ * · 用“首次出现”当原处会在文本重复时报出**假偏移**(同一段文本在别处也出现时,首次出现往往就是原处,
594
+ * 于是算出偏移 0,等于把“你给的行号现在指着别的东西”报成“没动过”)——假数字比没有数字更坏;
595
+ * · 偏移量对模型也没有额外用处:它仍然得重读那一段才知道现在长什么样。
596
+ *
597
+ * @param source - 要在其中查找的内容(**整份**内容,不是那一段文本自己)
598
+ * @param text - 被寻址区域的原文(`match.actual` / 观测侧解析结果)
599
+ * @param rows - 已经切好的行数组(可选)。同一份内容在一次闸门里被问多次,行数组只切一次;
600
+ * 缺省时自己切,语义完全一样(`rows ?? source.split(/\r?\n/)`)
601
+ * @returns {{count: number, first: number|null}} 出现次数与首次出现的行号;`first` 仅在唯一时给出
602
+ */
603
+ function regionLinesIn(source, text, rows = null) {
604
+ if (typeof text !== 'string' || text.length === 0) return { count: 0, first: null }
605
+ if (rows === null && typeof source !== 'string') return { count: 0, first: null }
606
+ // 与 {@link locate} 里 `source.split(/\r?\n/)` **同一把尺子**:行号一致,且行尾不参与比较。
607
+ // 这一点踩过:`match.actual` 是 `split(/\r?\n/).join('\n')` 的产物(行尾归一为 LF),
608
+ // 而按 `splitLinesWithEndings` 逐行比会留下行尾的 `\r`(`'line three\r' !== 'line three'`),
609
+ // 于是**每一行都算“找不到”**,闸门退化成“凡是 CRLF 文件一律拒绝”——6 条断言当场变红。
610
+ let count = 0
611
+ let first = null
612
+ const list = rows ?? source.split(/\r?\n/)
613
+ for (const [i, line] of list.entries()) {
614
+ // 只认**整行相等**:`three` 不是 `thirty three` 的一次命中。
615
+ if (line !== text) continue
616
+ count += 1
617
+ if (first === null) first = i + 1
618
+ // 命中两次就够了:判据只区分“唯一/不唯一”,全扫完没有任何好处。
619
+ if (count > 1) break
620
+ }
621
+ return { count, first: count === 1 ? first : null }
622
+ }
623
+
624
+ /**
625
+ * 纯坐标地址(`lines=` / `start_line`+`end_line`)在**观测那一版**里的区域原文;观测里没有这块区域时回 null。
626
+ *
627
+ * 这是闸门对“被寻址区域”的**最小重算**(C18):区域就是那几行,按坐标取即可,
628
+ * 不必拿整份观测内容重跑一遍定位。与 {@link regionTextIn} 的等价性逐条对齐
629
+ * (那一份走的是 `planHunks` → `locate` 第 2 层 → `done()`):
630
+ * · `actual` = `lines.slice(start - 1, end).join('\n')`(`lines = source.split(/\r?\n/)`,同一把尺子);
631
+ * · 行号不是整数 / 越界 / 起止倒序 ⇒ `locate` 回 `LOCATOR_BAD_LINES` / `LOCATOR_OUT_OF_RANGE`
632
+ * ⇒ `planHunks` 抛错 ⇒ `regionTextIn` 回 null ⇒ 这里同样回 null。
633
+ * 调用方只管 `byLineNumbers(hunk) === true` 的 hunk:其余形态是**内容寻址**,必须真的去定位。
634
+ *
635
+ * @param hunk - 原始 DSL 形状的 hunk
636
+ * @param rows - 观测那一版的行数组(`split(/\r?\n/)`)
637
+ * @returns 区域原文,或 null(观测里没有这块区域)
638
+ */
639
+ function observedRegionByLines(hunk, rows) {
640
+ const range = lineRangeOf(hunk)
641
+ if (range === undefined) return null
642
+ const start = range.start
643
+ const end = range.end ?? start
644
+ if (!Number.isInteger(start) || !Number.isInteger(end)) return null
645
+ if (start < 1 || end < start || end > rows.length) return null
646
+ return rows.slice(start - 1, end).join('\n')
647
+ }
648
+
649
+ // ── 句柄寻址(HD2)─────────────────────────────────────────────────────
650
+ //
651
+ // 句柄是**工具自己那份逐字节副本**的凭据(`handle-core.mjs`),治的是残余失败里最大的一类:
652
+ // 调用方转述原文时失真。所以句柄路**不读调用方的任何引文**——它只要一个 21 字符的短串。
653
+ //
654
+ // 四种形状与解析次序(契约 §5,已冻结;形状不对抛错,其余一律走结果通道):
655
+ //
656
+ // {handle, replace} 整段替换
657
+ // {handle, line: k, replace} 段内第 k 行(1-based,相对区间起点)
658
+ // {handle, old, replace} 段内内容定位(区间之外不找)
659
+ // {handle, position, replace} 区间边界插入(before / after,不改动区间本身)
660
+ //
661
+ // 相对定位器之间定优先级而不是抛错:`position` > `line` > `old` > 整段。
662
+ // 被让开的那个**必须在回执里披露**(D7:沉默忽略不行)——披露写在 `tier` 里,
663
+ // 因为 `applied[]` 上加未声明字段会让 DSH 壳整条成功路径被判成 `INVALID_TOOL_OUTPUT`。
664
+
665
+ /** 地址键 `handle` 是不是真的在场(平铺写法里空值很常见,那不是"给了句柄")。 */
666
+ function hasHandleArg(hunk) {
667
+ return typeof hunk?.handle === 'string' && hunk.handle.trim().length > 0
668
+ }
669
+
670
+ /**
671
+ * 句柄参数 → 它自己带的坐标与摘要。**形状不对在这里抛错**(契约 §5 第 0 步)。
672
+ *
673
+ * 形状不对为什么是抛错而不是结果:它是"参数没成形"(与 `ARGS_UNKNOWN_KEY` / `PATCH_NO_ADDRESS`
674
+ * 同族),不是"判不出意图"。拿一条 `outcome=not_found` 去回答它,调用方会去改地址,
675
+ * 而它真正要做的只是把那个字段写对。
676
+ */
677
+ function handleAddressOf(hunk) {
678
+ if (hunk.handle === undefined || hunk.handle === null) return null
679
+ if (typeof hunk.handle === 'string' && hunk.handle.trim().length === 0) return null
680
+ const verdict = handleShapeVerdict(hunk.handle)
681
+ if (!verdict.ok) {
682
+ throw new PatchError(
683
+ `\`handle\` ${verdict.reason} — pass the handle exactly as the read receipt printed it`,
684
+ 'PATCH_HANDLE_SHAPE',
685
+ )
686
+ }
687
+ return verdict.parsed
688
+ }
689
+
690
+ /** 某一段行区间在 `rows` 里的原文;越界 / 倒序 ⇒ null(行号尺子与 `locate` 同一把)。 */
691
+ function regionRowsAt(rows, startLine, endLine) {
692
+ if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) return null
693
+ if (startLine < 1 || endLine < startLine || endLine > rows.length) return null
694
+ return rows.slice(startLine - 1, endLine)
695
+ }
696
+
697
+ /**
698
+ * 句柄 hunk 在**观测那一版**里的区域原文:句柄自己带的坐标就是它读时那几行。
699
+ *
700
+ * 与 {@link observedRegionByLines} 同一条路(都是"按坐标取那几行"),差别只在坐标从哪来:
701
+ * 那条从调用方写的 `lines` 来,这条从**句柄**来——句柄里的行号是读侧当时自己写下的,
702
+ * 比调用方拄的坐标可信。回 null 的情形同那条:观测里没有这块区域(当时行数更少 / 不是这份文件)。
703
+ */
704
+ function observedRegionByHandle(handleText, rows) {
705
+ const parsed = parseHandle(handleText)
706
+ if (!parsed.ok) return null
707
+ const region = regionRowsAt(rows, parsed.startLine, parsed.endLine)
708
+ return region === null ? null : region.join('\n')
709
+ }
710
+
711
+
712
+ /**
713
+ * 同一个 hunk 在**另一份内容**里解析出的区域文本;解析不到返回 null。
714
+ *
715
+ * “解析不到”是**正常结果**(观测里根本没有这块区域),不是吞错:
716
+ * 只对定位类错误这样说,其它错误(实现 bug)必须继续往上抛。
717
+ *
718
+ * @param source - 参照内容
719
+ * @param hunk - 待解析的 hunk
720
+ * @param filePath - 用于判定语言
721
+ * @returns 该区域文本,或 null
722
+ */
723
+ function regionTextIn(source, hunk, filePath) {
724
+ try {
725
+ const planned = planHunks(source, [{ ...hunk, replace: hunk.replace ?? '' }], filePath)
726
+ return planned.matches[0] === undefined ? null : planned.matches[0].actual
727
+ } catch (error) {
728
+ if (/^(PATCH_|LOCATOR_|STRUCT_)/.test(String(error?.code ?? ''))) return null
729
+ throw error
730
+ }
731
+ }
732
+
733
+ /** 把“行 + 行尾”拼回文本(与 {@link splitLinesWithEndings} 互逆)。 */
734
+ function joinLinesWithEndings(lines, endings) {
735
+ return lines.map((line, i) => line + (endings[i] ?? '')).join('')
736
+ }
737
+
738
+ // ── 内容寻址:`old`(+可选 `context`)────────────────────────────────────────
739
+ //
740
+ // 地址的**主**形态是内容,不是坐标:`old` 是要替换的原文,`context` 是消歧用的额外原文。
741
+ // 坐标(`lines` / `start_line` / `end_line`)仍然被读,但只当**提示**:
742
+ // · 它不决定位置——与 `old` 冲突时以 `old` 为准;
743
+ // · 落点与它不一致时,回执必须明说“坐标与原文不符,已按原文定位”(见 {@link locateByOld})。
744
+ //
745
+ // 为什么不让坐标在歧义时“就近挑一个”:`anchor-core` 的 `pickNear` 只在调用方**显式**给了
746
+ // `near` 提示时才收敛;而一串从上一版视图里抄来的坐标,并不比 `old` 更知道“你想改的是哪一个”。
747
+ // 让坐标当裁决者,正是本工具存在的理由(内置 `edit` 多处匹配静默取第一个)所要防的事。
748
+ // 所以坐标只做两件事:与落点核对、不符就如实报告。
749
+
750
+ /** `old` 是非空、且不全是空白的字符串时,才算给了内容地址。 */
751
+ function oldAddressOf(hunk) {
752
+ return typeof hunk.old === 'string' && hunk.old.trim().length > 0 ? hunk.old : null
753
+ }
754
+
755
+ /**
756
+ * 把调用方给的原文切成行:行尾按 LF 归一,末尾那个空元素不参与
757
+ * (与 `locateWith` 对 `anchor` 的处理是同一把尺子)。
758
+ */
759
+ function addressRows(text) {
760
+ const rows = text.replace(/\r\n/g, '\n').split('\n')
761
+ if (rows.length > 1 && rows[rows.length - 1] === '') rows.pop()
762
+ return rows
763
+ }
764
+
765
+ /**
766
+ * 行对齐的两级匹配,由严到松:逐字 → 行内空白折叠(`normSpace`)。
767
+ *
768
+ * 与 `anchor-core` 用同一套归一化(`normSpace`),但这一层**只认整行对齐**:`old` 覆盖完整的行(或
769
+ * 连续几整行)时按行区间命中。落在行**中间**的匹配由下一层({@link FRAGMENT_TIERS})接手——
770
+ * 那一层替换**命中跨度自己**,行内其余字节逐字不动(C13)。
771
+ * 为什么只有两级、也不跟到 T4/T5 的相似度层:`old` 是“要替换的原文”,一段**根本不在文件里**的
772
+ * 文本若被相似度层接住,就会改到另一段文本上——正是本工具要消灭的静默错配(段E 钉住这条)。
773
+ * 为什么不需要单独一层 strip-line(`anchor-core` 的 T3):`normSpace` 逐行折叠空白并去首尾,
774
+ * 因此 strip 相等的两行折叠后必然相等——再加一层是死码。
775
+ */
776
+ const ADDRESS_TIERS = [
777
+ { tier: 'exact', norm: (text) => text },
778
+ { tier: 'norm-space', norm: normSpace },
779
+ ]
780
+
781
+ /** `rows` 在 `lines` 里所有**行对齐**出现的位置(0-based 起点)。 */
782
+ function windowStarts(lines, rows, norm) {
783
+ const span = rows.length
784
+ if (span === 0) return []
785
+ const needle = norm(rows.join('\n'))
786
+ const starts = []
787
+ for (let i = 0; i + span <= lines.length; i += 1) {
788
+ if (norm(lines.slice(i, i + span).join('\n')) === needle) starts.push(i)
789
+ }
790
+ return starts
791
+ }
792
+
793
+ /** 在 `lines` 里找 `rows`:取第一个有命中的匹配级,返回 `{tier, norm, starts}`(一个都没有时 tier 为 null)。 */
794
+ function findWindowTier(lines, rows) {
795
+ for (const { tier, norm } of ADDRESS_TIERS) {
796
+ const starts = windowStarts(lines, rows, norm)
797
+ if (starts.length > 0) return { tier, norm, starts }
798
+ }
799
+ return { tier: null, norm: null, starts: [] }
800
+ }
801
+
802
+ /**
803
+ * 行内片段层的两级匹配,由严到松(同 {@link ADDRESS_TIERS} 的读法):逐字 → 行内空白折叠。
804
+ *
805
+ * 为什么需要这一层:`old` 是“要替换的原文”,而人(与模型)引用原文时给的是**片段**——
806
+ * 原文写着 `beta gamma`,它只写 `gamma`。整行对齐那一层要求 `old` 覆盖完整的行,于是这类
807
+ * “位置对、说法不对”的调用被一律判成 `PATCH_NOT_FOUND`。实测(`failure-replay` 重放语料):
808
+ * 旧契约下成功的调用在新契约下死掉 23 条,逐条看,19 条的引文**就在原文里**,只是不是整行。
809
+ *
810
+ * 为什么只到「片段逐字出现」为止、不再往下跟相似度层:理由与上面 {@link ADDRESS_TIERS} 那段一字不变——
811
+ * 片段在文件里没有出现就是没有出现,相似度层会把改动落到**另一段文本**上,那正是本工具
812
+ * 要消灭的静默错配。剩下 4 条正好是这条边界的实证:它们的引文与原文**实质不同**
813
+ * (引用的是改后文本 / 语料副本里那句话根本不在),片段层如实放它们继续 `PATCH_NOT_FOUND`。
814
+ */
815
+ const FRAGMENT_TIERS = [
816
+ { tier: 'fragment', norm: (text) => text },
817
+ { tier: 'fragment~norm-space', norm: normSpace },
818
+ ]
819
+
820
+ /**
821
+ * `fragment` 在 `lines` 里的每一处出现,**扩成覆盖它的整行区间**:`{start, span}`(0-based)。
822
+ *
823
+ * 替换范围取「包含片段的整行」而不是「片段自己那一段」,三条理由:
824
+ * · 拼接层是按行换的({@link planHunks} 的倒序拼接用的是 `splitLinesWithEndings` 的行与行尾),
825
+ * 调用方给的 `replace` 也是按行写的;把 `replace` 塞回行的中间,等于让一段**整行**替换文本
826
+ * 长在一条半行的缝里;
827
+ * · 行粒度才说得清“改了多大一片”:回执的 `actual` / `replaced` 都是按行比对的,
828
+ * “行内插入”没有任何字段能表达它替换掉了什么;
829
+ * · 与整行对齐层同一个落点形状(`{start, span}`),收敛/就近/歧义收据两层共用一份代码。
830
+ * 代价明说:调用方给的 `old` 只是行的一部分,被换掉的却是整行——所以命中后回执必须**披露**
831
+ * 这一点(见 {@link fragmentNoteOf}),否则“我只写了 `gamma`”与“整行没了”之间少一句话。
832
+ *
833
+ * 归一化与 `expectVerdict` 的全文扫描是同一把尺子(`normSpace` 逐行折叠、保留换行、把行拼成一篇
834
+ * 再找偏移),因此命中偏移映射回行号是精确的;两处各写一套断行约定,同一个“第 7 行”就会指向
835
+ * 不同字节(本仓在 BOM / CRLF 上已经栽过同族的一次)。
836
+ *
837
+ * 同一行里出现两次只算**一个**候选:替换范围是整行,两次出现落在同一个区间上——
838
+ * 那不是“两处让你挑”,是同一处。
839
+ */
840
+ /**
841
+ * `fragment` 在 `lines` 里的每一处出现,连同**它自己那一段原文**:`{start, span, startCol, endCol, text}`(0-based)。
842
+ *
843
+ * 替换范围就是这一处出现本身(C13)。**不许**扩成包含它的整行:`old` 是“要替换的原文”,
844
+ * 调用方写 `gamma`、磁盘上整行没了,是“成功但结果不是调用方要的”的静默失效。实测形状:
845
+ * `{old:"x = 1", replace:"x = 2"}` 打在 `const x = 1` 上,整行版把 `const ` 静默吞掉。
846
+ * 所以这里把**命中跨度**算准(起止列 + 那一段原文),拼接层据此只换这一段(见 {@link planHunks})。
847
+ *
848
+ * 为什么仍带行的行区间 `{start, span}`:收敛/就近/歧义两轮与整行对齐层共用一份候选代码
849
+ * ({@link contextSitsAt} / {@link nearestOnly} 都按行读数),跨度与它在同一行上是自洽的。
850
+ *
851
+ * 归一化与 `expectVerdict` 的全文扫描是同一把尺子(`normSpace` 逐行折叠、保留换行、把行拼成一篇
852
+ * 再找偏移),因此命中偏移映射回行列是精确的;两处各写一套断行约定,同一个“第 7 行”就会指向
853
+ * 不同字节(本仓在 BOM / CRLF 上已经栽过同族的一次)。
854
+ *
855
+ * 归一化层下行内的偏移可能与文件里的偏移不同(`beta gamma` 折叠成 `beta gamma`),
856
+ * 所以原文**从归一化行里取**、列号**从原文里量**——两个坐标系各自取自己那一份,不互相换算。
857
+ *
858
+ * 同一行里出现两次算**两个**候选:两处片段落在同一行的不同列上,替换的是不同的字节——
859
+ * 那正是“两处让你挑”,不许挑一个(同 `old` 出现在两行的口径,见 {@link oldAmbiguityError})。
860
+ */
861
+ function fragmentSpans(lines, fragment, norm) {
862
+ const want = norm(fragment.replace(/\r\n/g, '\n')).replace(/^\n+/, '').replace(/\n+$/, '')
863
+ if (want.length === 0) return []
864
+ const normLines = lines.map((line) => norm(line))
865
+ const offsets = []
866
+ for (let i = 0, off = 0; i < normLines.length; i += 1) {
867
+ offsets.push(off)
868
+ off += normLines[i].length + 1
869
+ }
870
+ const hay = normLines.join('\n')
871
+ const lineAt = (offset) => {
872
+ let i = offsets.length - 1
873
+ while (i > 0 && offsets[i] > offset) i -= 1
874
+ return i
875
+ }
876
+ /**
877
+ * “归一化行内偏移 → **原文**行列”的映射。为什么要有它:`startAt` / `endAt` 是**归一化后**的
878
+ * 行内偏移,而拼接层是按**原文**列号切的——`normSpace` 剪掉了行首空白、又把行内连续空白折成
879
+ * 一个,两个坐标系按空白量差开。做法走一遍行,O(行长度):非空白字在原文里占一列,
880
+ * 空白字在原文里占掉**一整段**(只吃同一种空白:吃“所有空白”会把行尾的 `\n` 一并吞掉)。
881
+ * 不能拿原文去 `indexOf` 那一段:归一化剪过之后它根本不是子串。
882
+ *
883
+ * 切点用 `map[n]`(**归一化前 n 个字的切点**):它与调用方那句 `old` 同一个尺度——
884
+ * `want` 自己已经被去掉首尾换行,所以首/末两侧贴着的空白本来就不属于这一段。
885
+ */
886
+ /**
887
+ /**
888
+ * **归一化的一段文字**在**原文行**里的起止(`from` / `to`,0-based 开区间)。
889
+ *
890
+ * 归一化只用来回答“在哪”(`normLines` 与 `offsets`),列号必须**从原文量**:`normSpace` 剪掉了
891
+ * 行首空白、又把行内连续空白折成一个空格,两个坐标系按空白量差开。做法分两步:
892
+ * 1. 先在归一化行里定位那段文字(`indexOf`),拿到它在**归一化坐标**里的起点;
893
+ * 2. 从那个字开始逐字对齐原文(`rawLine.indexOf(字符, 上一个命中 + 1)`)——归一化只删空白、
894
+ * 不增删非空白字符,所以每个非空白字符在原文里的下一个出现就是它自己,被折掉的空格自动跳过。
895
+ *
896
+ * 不能拿原文去 `indexOf` 整段引文:归一化剪过空白之后它在原文里根本不是子串
897
+ * (实测:` beta gamma` 的引文 `beta gamma` 在原文里搜不到,列号算成 0)。
898
+ */
899
+ const rawSpanIn = (i, chunk, at) => {
900
+ const rawLine = lines[i]
901
+ const lineNorm = normLines[i]
902
+ if (chunk.length === 0 || at < 0) return { from: 0, to: 0 }
903
+ if (chunk.length > lineNorm.length - at) return { from: 0, to: 0 }
904
+ /**
905
+ * 先把这一行“原文列 ↔ 归一化列”的对应表算出来:两个指针同步往前走,对得上就记一笔,
906
+ * 对不上说明原文多了一段空白(`normSpace` 折掉的那些),原文指针多走一步再看。
907
+ * 两个指针都只向一个方向走,所以这层是 O(行长度)、也不需要任何 `indexOf` 猜测。
908
+ */
909
+ const pairs = []
910
+ let r = 0
911
+ let n = 0
912
+ while (r < rawLine.length && n < lineNorm.length) {
913
+ if (rawLine[r] === lineNorm[n]) {
914
+ pairs.push([r, n])
915
+ r += 1
916
+ n += 1
917
+ } else {
918
+ r += 1
919
+ }
920
+ }
921
+ const rawOf = (normAt) => {
922
+ const pair = pairs.find(([, n2]) => n2 === normAt)
923
+ return pair === undefined ? -1 : pair[0]
924
+ }
925
+ const from = rawOf(at)
926
+ const lastRaw = rawOf(at + chunk.length - 1)
927
+ 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 }
932
+ }
933
+ const spans = []
934
+ const seen = new Set()
935
+ for (let p = hay.indexOf(want); p !== -1; p = hay.indexOf(want, p + 1)) {
936
+ const start = lineAt(p)
937
+ const end = lineAt(p + want.length - 1)
938
+ /** 命中在首/末行里的**归一化偏移**(行内)。同一行时两者是同一行内的两个位置。 */
939
+ const startAt = p - offsets[start]
940
+ const endAt = p + want.length - 1 - offsets[end]
941
+ // 去重键唯一:同一行里的两处出现列号不同,不合并(它们替换的字节本来就不同)。
942
+ const key = `${start}:${startAt}-${end}:${endAt}`
943
+ if (seen.has(key)) continue
944
+ seen.add(key)
945
+ /**
946
+ * 列号必须落到**原文**的坐标系里(拼接层按列号切原文)。
947
+ *
948
+ * `startAt` / `endAt` 是**归一化行内**的偏移(“第几个字”),两个坐标系按被折掉的空白量差开;
949
+ * 首列取首段在原文里的起点、末列取末段在原文里的终点,两组都由 {@link rawSpanIn} **从原文量**
950
+ * ——不能按“归一化宽度”递推:空白折叠层下 `beta gamma` 与 `beta gamma` 宽度不同,
951
+ * 递推出来的末列会少算被折掉的那几个空格(实测:跨度只盖到片段中间,替换后留下 `BETAamma`)。
952
+ */
953
+ /**
954
+ * `want` 里属于**首行 / 末行**的那两段:单行时整段就是这一段(起点 `startAt`);跨行时首段到
955
+ * 首行行尾为止(其余都是中间整行与末行那一段),末段占末行行首 `endAt + 1` 个字
956
+ * ({@link addressRows} 已把 `want` 首尾的空行去掉,所以首尾两行都是实打实有内容的)。
957
+ */
958
+ const firstWidth = start === end ? want.length : want.length - (endAt + 1)
959
+ const lastAt = start === end ? startAt : 0
960
+ const head = rawSpanIn(start, normLines[start].slice(startAt, startAt + firstWidth), startAt)
961
+ /** 末段落在**末行**里,它的列号是末行内的坐标(`fragment.lines` 那一侧用它)。 */
962
+ const tail = rawSpanIn(end, normLines[end].slice(lastAt, endAt + 1), lastAt)
963
+ const startCol = head.from + 1
964
+ /**
965
+ * 末列:单行时就是这一段的末字(`head.to`);跨行时再往末行推 `endAt + 1` 列——末段从末行
966
+ * 行首起,在原文里的宽度与归一化一致(只差被折掉的空白,而它前面没有折叠)。
967
+ * 不能取 `tail.to`:那是**末行内**的列号,而这里要的是**首行内**的列号
968
+ * (`planHunks` 的片段拼接按 `fragment.columns` 在**首行**上切 `prefix` / `tail`)。
969
+ */
970
+ const endCol = start === end ? head.to : head.to + endAt + 1
971
+ /**
972
+ * 首行从命中起点截、末行取到命中终点:`text` 因此**逐字**是文件里那一段(含它自己的空白),
973
+ * 而 `startCol` / `endCol` 是它在**原文**里的列号(1-based 闭区间)——两者同一把尺子:
974
+ * 单行时 `text === lines[start].slice(startCol - 1, endCol)`,回执可核对的前提就是这条恒等式。
975
+ * 跨行时首行只要“从命中起点起的后缀”、末行只要“到命中终点为止的前缀”,
976
+ * 各拼回原文里被剪掉的那一头(首行的前导空白、末行的后继空白)。
977
+ */
978
+ const firstText = start === end ? want : lines[start].slice(head.from, head.to) + normLines[start].slice(startAt + firstWidth)
979
+ const lastText = start === end ? want : lines[end].slice(0, tail.to)
980
+ /** `want` 里首尾两段之间夹着的那些**整行**(跨行时才非空)。 */
981
+ const middleText = normLines.slice(start + 1, end).join('\n')
982
+ /**
983
+ * 单行时 `text` **从原文里取**(`lines[start].slice(startCol - 1, endCol)`)——归一化只用来
984
+ * 回答“在哪”,回执里那一段必须是文件里逐字的原文,否则空白折叠层下调用方拿到的引文
985
+ * 在文件里根本搜不到。跨行时首尾各取自己那一半。
986
+ */
987
+ const text =
988
+ start === end
989
+ ? lines[start].slice(startCol - 1, endCol)
990
+ : [firstText, middleText, lastText].filter((part) => part !== '').join('\n')
991
+ spans.push({ start, span: end - start + 1, startCol, endCol, endLineCol: tail.to, text })
992
+ }
993
+ return spans
994
+ }
995
+
996
+ /** 片段层取第一个有命中的匹配级,返回 `{tier, norm, spans}`(都没有时 tier 为 null,同 {@link findWindowTier})。 */
997
+ function findFragmentTier(lines, fragment) {
998
+ for (const { tier, norm } of FRAGMENT_TIERS) {
999
+ const spans = fragmentSpans(lines, fragment, norm)
1000
+ if (spans.length > 0) return { tier, norm, spans }
1001
+ }
1002
+ return { tier: null, norm: null, spans: [] }
1003
+ }
1004
+
1005
+ /**
1006
+ * 片段层命中的**披露**:`old` 只是行的一部分,被替换的是整行(或整片行)。
1007
+ *
1008
+ * 与坐标的说明({@link locateByOld} 的 `coordinateNote`)同一条规矩:地址落到哪里与调用方字面写的
1009
+ * 不同,回执就必须说清——否则调用方看到“我明明只写了 `gamma`”而磁盘上整行没了,中间没有任何
1010
+ * 一句话解释这多出来的改动面。`tier` 是 `applied[]` 上唯一能承载这句话的**已声明**字段
1011
+ * (DSH 壳的 `output.schema` 是 `additionalProperties: false`,加未声明字段会让整条成功路径
1012
+ * 被判成 `INVALID_TOOL_OUTPUT`)。
1013
+ */
1014
+ /**
1015
+ * 片段层命中的**披露**:被替换的**就是这一处命中跨度本身**,行内其余字节逐字不动。
1016
+ *
1017
+ * 与坐标的说明({@link locateByOld} 的 `coordinateNote`)同一条规矩:地址落到哪里与调用方字面写的
1018
+ * 不同,回执就必须说清这次替换**覆盖了哪一段**(起止列 + 原样片段),让调用方能核对。
1019
+ * `tier` 是 `applied[]` 上唯一能承载这句话的**已声明**字段(DSH 壳的 `output.schema` 是
1020
+ * `additionalProperties: false`,加未声明字段会让整条成功路径被判成 `INVALID_TOOL_OUTPUT`)。
1021
+ *
1022
+ * `text` 原样进回执、一字不剪:剪了它就不再是“被替换的那一段”——核对的前提是它逐字可对。
1023
+ */
1024
+ function fragmentNoteFor(line, startCol, endCol, text) {
1025
+ /**
1026
+ * 引文放在**最后**:它可能是多行的忠实块(跨行片段,见 {@link fragmentSpans}),
1027
+ * 而块尾自带换行 —— 后面再跟半句话,那句话就会另起一行、把句子读断。
1028
+ * 换成"先说完替换范围,再把被替换的那一段贴出来",两种形态都读得通。
1029
+ */
1030
+ return (
1031
+ `\`old\` matches inside line ${line} — only columns ${startCol}-${endCol} are replaced; ` +
1032
+ `the rest of the line is kept byte-for-byte. Replaced text: ${quoteForMessage(text, line)}`
1033
+ )
1034
+ }
1035
+ /**
1036
+ * `context` 是否落在某个候选的**邻域**里。
1037
+ *
1038
+ * 邻域 = 候选窗口向上下各放宽 `context.length + radius` 行。同一个判据服务两个轮次:
1039
+ * · `radius = 0`(第一轮):context 紧邻候选(正上方 / 正下方),或 context 自身就是包含
1040
+ * `old` 的那一段(那时 context 的出现位置与候选窗口重叠,仍在邻域内);
1041
+ * · `radius = context.length`(第二轮,**自动扩大上下文重试一次**):专门救“context 与 old
1042
+ * 之间还隔着几行”这种半对地址。
1043
+ *
1044
+ * 为什么放宽量取“一个 context 长度”而不是“整份文件”:放宽到全文就等于“context 在文件里出现过”,
1045
+ * 每个候选都会通过,这个判据就退化成没有判据(`anchor-core` 里 anchor 相似度层踩过同族的坑)。
1046
+ */
1047
+ function contextSitsAt(lines, start, span, ctxRows, norm, radius) {
1048
+ const from = Math.max(0, start - ctxRows.length - radius)
1049
+ const to = Math.min(lines.length, start + span + ctxRows.length + radius)
1050
+ return windowStarts(lines.slice(from, to), ctxRows, norm).length > 0
1051
+ }
1052
+
1053
+ /**
1054
+ * 从候选里取**唯一最近**的一个(按行号距离);并列最近 ⇒ null(不猜)。
1055
+ *
1056
+ * 与 `anchor-core` 的 `pickNear` 同一条规矩:`near` 是调用方显式给的歧义提示,
1057
+ * 而“等距时挑一个”就是替调用方掷骰子——那正是本工具要消灭的行为。
1058
+ */
1059
+ function nearestOnly(starts, near) {
1060
+ let best = null
1061
+ let bestDistance = Infinity
1062
+ let ties = 0
1063
+ for (const start of starts) {
1064
+ const distance = Math.abs(start + 1 - near)
1065
+ if (distance < bestDistance) {
1066
+ bestDistance = distance
1067
+ best = start
1068
+ ties = 1
1069
+ } else if (distance === bestDistance) ties += 1
1070
+ }
1071
+ return ties === 1 ? best : null
1072
+ }
1073
+
1074
+ /**
1075
+ * 引用原文的两个引用点({@link fragmentNoteFor} / {@link expectGateError})
1076
+ * 与候选清单({@link candidateListing})都走 `quoteForMessage` —— 本文件不再自带一份同形实现。
1077
+ */
1078
+
1079
+ /**
1080
+ * 「找不到」回执的**体量预算**(判据:`notfound-receipt-test` 段D)。
1081
+ *
1082
+ * 上限不是拍的,是宿主的:DSH 的 `dsh-compaction-tool-result-pruner` 对超过
1083
+ * `toolResultPruner.config.thresholdChars` 的**工具结果**做头尾裁剪——中间那一段换成一行
1084
+ * `[... tool result middle pruned ...]`,只留头 `headChars` 与尾 `tailChars`。
1085
+ * 本轮部署实测(`~/.dsh/profiles/node_modules/@deepseek-ai/dsh-base/cordis.patch.yml:396-399`,
1086
+ * 三个 preset 与 `DEFAULTS` 同值):`thresholdChars: 8192`、`headChars: 4096`、`tailChars: 1024`。
1087
+ * 超阈的代价正好落在本回执上:被剪掉的是中间,而"那一带原文"就在中间。
1088
+ *
1089
+ * 6000 < 8192,且 6000 < 4096 + 1024 + 标记长度 ⇒ 整份回执连"只留头窗"那种形态也装得下。
1090
+ *
1091
+ * ## 这笔预算怎么分(每层都被上一层夹住,合计可证不越 6000)
1092
+ *
1093
+ * · 头部(钉住的两句 + `label`):`label` 最多占 `6000 − {@link NOTFOUND_DETAIL_FLOOR} − 定句长度`;
1094
+ * · "那一带"的说明句:≤ ~520 字符(计数是数字,两处引文各被 {@link quoteForMessage} 夹在自己的限额里);
1095
+ * · 行号/标记的**身份声明**:≤ ~410 字符(见 {@link notFoundDetail} 的 `bandIdentity`)——
1096
+ * 它写在 `fixed` 里,所以已被下面那条"剩下的额度"扣掉,不必另算一份;
1097
+ * · 那一带**原文本身**:≤ {@link NOTFOUND_BAND_CHARS},且不超过"剩下的额度";
1098
+ * 裁剪过的行另付 ~40 字符的 `[clipped — NOT copyable]` 标记(见 {@link clipReceiptLine}),
1099
+ * 所以单行占地上限是 `NOTFOUND_LINE_CHARS` + 那个标记,不是 `NOTFOUND_LINE_CHARS`;
1100
+ * · 截断披露:≤ {@link NOTFOUND_NOTE_CHARS}。
1101
+ *
1102
+ * 原文那一层的下限是 {@link NOTFOUND_DETAIL_FLOOR}:路径再病态也不许把原文挤没。
1103
+ * 因此不需要末尾再兜一刀——每一层各自夹在自己那一份里,等式是算出来的,不是估出来的。
1104
+ */
1105
+ const NOTFOUND_RECEIPT_CHARS = 6000
1106
+ const NOTFOUND_DETAIL_FLOOR = 1400
1107
+ const NOTFOUND_NOTE_CHARS = 300
1108
+ /** 那一带最多展示几行原文:够看清"对得上的那一段"与"从哪一行开始对不上"。 */
1109
+ const NOTFOUND_BAND_LINES = 12
1110
+ /**
1111
+ * 回执末尾那段**修法**的字符上限(`repairBlock` 的护栏)。
1112
+ *
1113
+ * 它不是拍出来留余量的:那一段的**定时句**是常量、而句柄长度由行号位数决定(
1114
+ * `handle-core.handleCharsFor`:15 + 两个行号的位数,本仓任何上千行的文件都是 21)。
1115
+ * 所以 `定时句 + 三个最长句柄` 恒 < 220,这个数只用来在病态输入下把整段**剪掉**
1116
+ * (剪掉时正文照旧给 `next` 那句重读指令,不会变成一句空话),不让它把
1117
+ * {@link NOTFOUND_RECEIPT_CHARS} 顶穿。
1118
+ */
1119
+ const NOTFOUND_RECOVERY_CHARS = 220
1120
+ /** 单行原文在回执里的上限(剪了就写明剪了多少字符)。 */
1121
+ const NOTFOUND_LINE_CHARS = 200
1122
+ /** 那一带原文**合计**的上限(超出就少展示几行,并写明剪了几行 / 几字符)。 */
1123
+ const NOTFOUND_BAND_CHARS = 1600
1124
+ /**
1125
+ * 对齐打分的**工作量上限**(逐行比较的总次数)。
1126
+ *
1127
+ * 为什么需要它:代价是 Σ(引文每一行在文件里的出现次数),而重复行极多的文件(生成出来的数据表
1128
+ * 就是这种)配上一份同样长的引文,这个乘积能到 10^8 量级——一次**拒绝**会变成好几秒的停顿。
1129
+ * 诊断路径不能这样:它只是把"找不到"说清楚,不是定位判据本身。
1130
+ *
1131
+ * 到顶后不再收新的起点,取的是"已经收到的那些里最优"——仍然**确定**(同一份输入同一结果),
1132
+ * 只是不再保证全局最优。正常文件远到不了这个量级:本仓最大的源文件也只有几万行,
1133
+ * 而要计数还得引文的每一行**真在文件里出现过**。
1134
+ */
1135
+ const NOTFOUND_ALIGN_WORK = 2000000
1136
+ /** 打分阶段最多看几个对齐起点(起点表也按它封顶)。 */
1137
+ const NOTFOUND_ALIGN_CAP = 2000
1138
+
1139
+ /**
1140
+ * 引文的**匹配基准**,由严到松(与 {@link ADDRESS_TIERS} 同一把尺子:逐字 → 行内空白折叠)。
1141
+ *
1142
+ * 再往下就没有第三档:相似度会给出一个说不清理由的落点,而这份回执的全部价值在于
1143
+ * 它说的每一句都能被调用方**逐字核对**。所以这里只回答"有多少行逐字对得上、从哪一行开始对不上",
1144
+ * 不回答"有多像"——后者是定位判据的事,而判据仍然是 {@link findWindowTier} / {@link findFragmentTier},
1145
+ * 本函数一个字节都不改它们的结论。
1146
+ */
1147
+ const NOTFOUND_ALIGN_BASES = [
1148
+ { basis: 'exact', norm: (text) => text, how: 'byte-for-byte' },
1149
+ { basis: 'norm-space', norm: normSpace, how: 'once whitespace is folded' },
1150
+ ]
1151
+
1152
+ /** 钉住的两句:`old-fragment-fallback-test` 段E1 逐字钉着它们,不许改写。 */
1153
+ function notFoundPinned(label) {
1154
+ return (
1155
+ `the old text you gave is not in ${label} — not as whole lines, and not inside any line either. ` +
1156
+ `Coordinates are only a hint, so there is nothing to address — re-read the file and pass the text exactly as it is there.`
1157
+ )
1158
+ }
1159
+
1160
+ /** 钉住那两句里**与 label 无关**的那部分长度(预算就是从这里减出来的)。 */
1161
+ const NOTFOUND_PINNED_CHARS = notFoundPinned('').length
1162
+
1163
+ /**
1164
+ * 回执头部:钉住的两句 + 指代这份文件的那句话。
1165
+ *
1166
+ * `label` 也按预算夹(夹子本体是 {@link clipReceiptLabel},与 `old` 歧义那一支**共用一份**)。
1167
+ * 它通常是一个路径(几十字符),但它的长度**不由本工具决定**,
1168
+ * 而它一旦病态地长,挤掉的正好是后面那段原文。夹过就在原地写明夹了多少字符。
1169
+ */
1170
+ function notFoundHead(label) {
1171
+ const room = Math.max(40, NOTFOUND_RECEIPT_CHARS - NOTFOUND_DETAIL_FLOOR - NOTFOUND_PINNED_CHARS)
1172
+ return notFoundPinned(clipReceiptLabel(label, room))
1173
+ }
1174
+
1175
+ /**
1176
+ * 回执里一行原文的截断 —— 剪掉的必须**自己说出来**,而且要一并说清“这一行不可回抄”。
1177
+ *
1178
+ * 为什么“不可回抄”这四个字是这段代码的一部分:{@link notFoundDetail} 的身份声明
1179
+ * (`bandIdentity`)宣称这些行是“这份文件自己的文本、逐字节”,而**裁剪过的行不是** ——
1180
+ * 它只是那一行的前 {@link NOTFOUND_LINE_CHARS} 个字符。少了这个标记,身份句在长行上就是
1181
+ * 一句**假话**:实测行宽 600 时回执给 217 字符、文件那一行 615 字符;行宽 200 时 216 对 215
1182
+ * —— 两处都说“逐字节”,而两处都不是。调用方按“verbatim / byte-for-byte”抄回去必然失配,
1183
+ * 而它正是照着回执自己的话抄的。
1184
+ *
1185
+ * 标记与身份句是**一对**:身份句里那句例外条款点名 `NOT copyable`,这里负责把它贴上。
1186
+ * 两处任缺一处,`quote-render-test` 段B 的长行判据当场红。
1187
+ */
1188
+ function clipReceiptLine(text) {
1189
+ if (text.length <= NOTFOUND_LINE_CHARS) return text
1190
+ return (
1191
+ `${text.slice(0, NOTFOUND_LINE_CHARS)}\u2026(${text.length - NOTFOUND_LINE_CHARS} more chars) ` +
1192
+ `[clipped \u2014 NOT copyable]`
1193
+ )
1194
+ }
1195
+
1196
+ /**
1197
+ * 失败回执里的**可修复地址**(MT6):把区间句柄铸给"回执刚刚展示的那一段"。
1198
+ *
1199
+ * ## 三样东西各取自什么(缺一样就会铸出一个对不上的柄)
1200
+ *
1201
+ * · **身份标签**(`h` 后 4 位):取**这一次编辑**算出的 `ledgerKey` ——
1202
+ * `planHunks` 里那份 `handleCtx.ledgerKey = ledgerIdentityKey(filePath, identityKey)`,
1203
+ * 与读侧发句柄时是**同一个口径、同一条实现**(`read-core.mjs` 也走 `ledgerIdentityKey`)。
1204
+ * 不从回执文本里反推路径:回执里的 `label` 是被 `clipReceiptLabel` 夹过的**说法**,
1205
+ * 不是键——拿它算标签会铸出一个"身份不符"的柄,而调用方拿到的是一句 `PATCH_HANDLE_IDENTITY_MISMATCH`。
1206
+ * · **区间摘要**(`h` 后 8 位):取**文件里那几行的原始字节**(`lines.slice(a-1, b).join('\n')`,
1207
+ * 行尾不入摘要、不裁行、不加行号前缀)。它逐字节来自**磁盘上读到的内容**,
1208
+ * 不是模型转述的引文,也不是回执里那份被 `clipReceiptLine` 剪过的展示文本。
1209
+ * · **行号**(`:` 后那对):取该段在**文件里的绝对行号**(1-based、含两端),与
1210
+ * `aura_read` 印的行号是同一把尺子——所以回执里 `!N| ` 的那些 N 与句柄里的数字对得上。
1211
+ *
1212
+ * 铸出来的柄因此**自描述**:写路径 `resolveHandleRegion` 拿当前文件那几行现算一遍摘要就能核对,
1213
+ * 不需要任何句柄表(`handle-core.mjs` 顶部那三条形状约束里的第 3 条)。
1214
+ *
1215
+ * 取不到就返回 `null`(而不是抛错、也不是铸一个"看着像"的柄):每次调用都具备这三个事实,
1216
+ * 但 `planHunks` 有一个三参数调用点({@link regionTextIn} 的内部预演)**不给** `options`,
1217
+ * 那里没有账本键 ⇒ 无柄可铸。回执照旧,只是少一行修法。
1218
+ *
1219
+ * @param ctx - `planHunks` 的 `handleCtx`(`{content, ledgerKey, byteOrderMark, label}`)
1220
+ * @param startLine - 该段在文件里的起始行(1-based)
1221
+ * @param endLine - 该段在文件里的结束行(1-based,含)
1222
+ * @returns 句柄字符串,或 `null`
1223
+ */
1224
+ function mintRegionHandle(ctx, startLine, endLine) {
1225
+ if (ctx === null || ctx === undefined) return null
1226
+ if (typeof ctx.ledgerKey !== 'string') return null
1227
+ const rows = regionRowsAt(ctx.content.split('\n'), startLine, endLine)
1228
+ if (rows === null) return null
1229
+ return mintHandle({
1230
+ ledgerKey: ctx.ledgerKey,
1231
+ regionText: rows.join('\n'),
1232
+ byteOrderMark: ctx.byteOrderMark === true,
1233
+ startLine,
1234
+ endLine,
1235
+ })
1236
+ }
1237
+
1238
+ /** 回执里"抄这个地址"那一行的字面形状。**模型只看得见这一行**,所以它必须自带 `retry:` 标签。 */
1239
+ const RETRY_LABEL = 'retry: '
1240
+
1241
+ /**
1242
+ * 一段能直接照抄的重试参数:`retry: {"handle":"h…"}`。
1243
+ *
1244
+ * 为什么写成**纯参数对象**而不是一句自然语言:调用方下一轮是发一次工具调用,
1245
+ * 而工具调用的入参就是一个对象。给它一句散文,它得先翻译成参数——那一步正是本单要省掉的。
1246
+ * 形状与契约 §3 的句柄示例逐字同形(`h9f3ac1b2e4d5:42-42`),便于判据与解析器共用一条正则。
1247
+ */
1248
+ const retryLine = (handle) => `${RETRY_LABEL}${JSON.stringify({ handle })}`
1249
+
1250
+ /**
1251
+ * 回执末尾那段**修法**(`next` 之外的可照抄地址)。
1252
+ *
1253
+ * 单目标:给**最近那一带**的柄与一句说明——"把 `replace` 配上这个地址再发一次,不必重抄原文"。
1254
+ * 多候选:给**每个候选**各一行 `retry:`(让它从"挑一个"变成"抄一个")。
1255
+ *
1256
+ * 这段**只描述怎么修**,一个字都不改判定:失败仍是失败、磁盘仍是一个字节不动、码与 `next` 都不变。
1257
+ * 体量封在 {@link NOTFOUND_RECOVERY_CHARS};越限就整段不印(`next` 仍给重读指令),不静默截半句。
1258
+ *
1259
+ * @param handles - 已铸好的句柄(按回执里出现的顺序)
1260
+ * @param lines - 每行前面的说明(与 `handles` 等长);单目标那一支用同一句
1261
+ */
1262
+ function repairBlock(handles, lines) {
1263
+ if (handles.length === 0) return ''
1264
+ const body = handles.map((handle, i) => `${lines[i] ?? ''}${retryLine(handle)}`).join('\n')
1265
+ return body.length > NOTFOUND_RECOVERY_CHARS ? '' : `${body}\n`
1266
+ }
1267
+
1268
+ /**
1269
+ * 单目标"找不到"的修法那一行:一句人话 + 地址。
1270
+ *
1271
+ * 措辞刻意**不用** `candidates` / `ambiguous` / `not unique` 这几个词:`failure-replay` 与
1272
+ * `residual-census` 的三态归类按这些词认,`PATCH_NOT_FOUND` 的回执里出现它们会被记成
1273
+ * `needs_choice`({@link notFoundDetail} 的 `bandIdentity` 同一条理由)。
1274
+ */
1275
+ const NOTFOUND_REPAIR_NOTE = `paste this address with your own \`replace\` and send it again — no need to re-quote the text: `
1276
+
1277
+ /**
1278
+ * 引文逐行在文件里的**对齐起点**:把「引文第 j 行 = 文件第 i 行」读成「整块从 `i-j` 开始」。
1279
+ *
1280
+ * 为什么用行号代数和而不是滑窗相似度:这里要回答的是"你引的那块**本该**落在哪一带",
1281
+ * 判据只有一条——有多少行**逐字**对得上。相似度给出的是一个说不清理由的分数,
1282
+ * 而这个回执的全部价值在于它说的每一句都能被调用方逐字核对。
1283
+ *
1284
+ * 先按"每一行出现在哪些行号"建索引再代数和,是为了不落进 O(引文行数 × 文件行数):
1285
+ * 只在**引文里真出现过**的那些行上花时间。重复行极多时这个"只在真出现过的行上花时间"
1286
+ * 本身也会炸开(每一行都出现几万次),所以再有 {@link NOTFOUND_ALIGN_CAP} 与
1287
+ * {@link NOTFOUND_ALIGN_WORK} 两道封顶——到顶就带着已收到的起点返回,不再继续收。
1288
+ */
1289
+ function alignmentStarts(normLines, normRows) {
1290
+ const index = new Map()
1291
+ for (let i = 0; i < normLines.length; i += 1) {
1292
+ const key = normLines[i]
1293
+ if (key.length === 0) continue
1294
+ const hit = index.get(key)
1295
+ if (hit === undefined) index.set(key, [i])
1296
+ else hit.push(i)
1297
+ }
1298
+ const byStart = new Map()
1299
+ let work = NOTFOUND_ALIGN_WORK
1300
+ for (let j = 0; j < normRows.length; j += 1) {
1301
+ const hits = normRows[j].length === 0 ? undefined : index.get(normRows[j])
1302
+ if (hits === undefined) continue
1303
+ for (const i of hits) {
1304
+ if (work <= 0 || byStart.size >= NOTFOUND_ALIGN_CAP) return byStart
1305
+ work -= 1
1306
+ const start = i - j
1307
+ const rec = byStart.get(start)
1308
+ if (rec === undefined) byStart.set(start, { start, hits: 1 })
1309
+ else rec.hits += 1
1310
+ }
1311
+ }
1312
+ return byStart
1313
+ }
1314
+
1315
+ /**
1316
+ * 某个对齐起点上**逐字对上**的引文行号(升序)。
1317
+ *
1318
+ * 比对**整行**而不是"行里含有":引文的一行与文件的一行要么逐字是同一条,要么不是。
1319
+ * 空行只与空行对得上——否则空行会与文件里任何一行"对上",把计数和连续段一起撑虚。
1320
+ */
1321
+ function rowsMatchedAt(normLines, normRows, start) {
1322
+ const rows = []
1323
+ for (let j = 0; j < normRows.length; j += 1) {
1324
+ const i = start + j
1325
+ if (i < 0 || i >= normLines.length) continue
1326
+ if (normLines[i] === normRows[j]) rows.push(j)
1327
+ }
1328
+ return rows
1329
+ }
1330
+
1331
+ /** 两段连续区间里更长的那个(`len` 为 0 表示一个都没有)。 */
1332
+ function longerRun(best, from, to) {
1333
+ const len = to - from + 1
1334
+ return best === null || len > best.len ? { from, to, len } : best
1335
+ }
1336
+
1337
+ /** 一串**升序**行号里最长的那段连续区间。 */
1338
+ function longestMatchedRun(rows) {
1339
+ let best = null
1340
+ let from = null
1341
+ let prev = null
1342
+ for (const row of rows) {
1343
+ if (prev !== null && row === prev + 1) {
1344
+ prev = row
1345
+ continue
1346
+ }
1347
+ if (from !== null) best = longerRun(best, from, prev)
1348
+ from = row
1349
+ prev = row
1350
+ }
1351
+ if (from !== null) best = longerRun(best, from, prev)
1352
+ return best ?? { from: 0, to: -1, len: 0 }
1353
+ }
1354
+
1355
+ /**
1356
+ * 引文落在文件里的**最佳对齐**:逐字对上的行最多者胜;并列看最长连续段;仍并列就取离调用方
1357
+ * 给的坐标 / `near` 最近的那个(再并列取行号小的)。
1358
+ *
1359
+ * 每一步都是可复述的规则,**不掷骰子**(同 `anchor-core` 的 `pickNear` 那条口径):
1360
+ * 这份回执要说"最接近的那一带是哪一带",而"等距时随便挑一个"会让同一份输入给出两种说法。
1361
+ */
1362
+ function bestAlignment(lines, oldRows, norm, hint) {
1363
+ /**
1364
+ * 归一化**只算一遍**:两层基准下 `normSpace` 不便宜,而这个函数要把它用到
1365
+ * “文件一次 + 引文一次 + 每个候选各一遍”上,就地重算会把两百万次预算白白花在重复劳动上。
1366
+ */
1367
+ const normLines = lines.map(norm)
1368
+ const normRows = oldRows.map(norm)
1369
+ const starts = [...alignmentStarts(normLines, normRows).values()]
1370
+ if (starts.length === 0) return null
1371
+ starts.sort((a, b) => b.hits - a.hits || a.start - b.start)
1372
+ let best = null
1373
+ let work = NOTFOUND_ALIGN_WORK
1374
+ for (const { start } of starts) {
1375
+ if (work < normRows.length) break
1376
+ work -= normRows.length
1377
+ const rows = rowsMatchedAt(normLines, normRows, start)
1378
+ if (rows.length === 0) continue
1379
+ const run = longestMatchedRun(rows)
1380
+ const distance = hint === null ? 0 : Math.abs(start + 1 - hint)
1381
+ const better =
1382
+ best === null ||
1383
+ rows.length > best.rows.length ||
1384
+ (rows.length === best.rows.length && run.len > best.run.len) ||
1385
+ (rows.length === best.rows.length && run.len === best.run.len && distance < best.distance) ||
1386
+ (rows.length === best.rows.length && run.len === best.run.len && distance === best.distance && start < best.start)
1387
+ if (better) best = { start, rows, run, distance }
1388
+ }
1389
+ return best
1390
+ }
1391
+
1392
+ /** 调用方给的坐标提示(`lines` / `start_line` / `near`)里的**起始行**;一个都没给就是 null。 */
1393
+ function hintLineOf(hunk) {
1394
+ if (hunk === null || hunk === undefined) return null
1395
+ const range = lineRangeOf(hunk)
1396
+ if (range !== undefined) return range.start
1397
+ return Number.isInteger(hunk.near) ? hunk.near : null
1398
+ }
1399
+
1400
+ /**
1401
+ * 把一次对齐翻成回执要的那一带:**行号 + 该处原文的位置信息**。
1402
+ *
1403
+ * 区间按引文的行数铺开(`对齐起点 .. 对齐起点 + 引文行数 − 1`),再夹到文件范围内——
1404
+ * 引文比文件长时(整块伸到文件外)只有落在文件里的那一截是真的,夹掉的部分**没有原文可贴**,
1405
+ * 所以 `matched` / `run` 只统计仍在文件内的那些行(不把不存在的那几行算成"对不上")。
1406
+ */
1407
+ function regionOf(lines, oldRows, aligned, basis, how) {
1408
+ const startLine = Math.max(1, aligned.start + 1)
1409
+ const endLine = Math.min(lines.length, aligned.start + oldRows.length)
1410
+ const inFile = aligned.rows.filter((j) => {
1411
+ const n = aligned.start + j + 1
1412
+ return n >= startLine && n <= endLine
1413
+ })
1414
+ const run = longestMatchedRun(inFile)
1415
+ const hit = new Set(inFile)
1416
+ let mismatch = null
1417
+ for (let j = 0; j < oldRows.length; j += 1) {
1418
+ const i = aligned.start + j
1419
+ if (i < 0 || i >= lines.length || hit.has(j)) continue
1420
+ mismatch = { quoteLine: j + 1, fileLine: i + 1, wrote: oldRows[j], there: lines[i] }
1421
+ break
1422
+ }
1423
+ return {
1424
+ basis,
1425
+ how,
1426
+ startLine,
1427
+ endLine,
1428
+ fileLines: lines.length,
1429
+ quoteLines: oldRows.length,
1430
+ matched: inFile.length,
1431
+ matchedLines: inFile.map((j) => aligned.start + j + 1),
1432
+ run: {
1433
+ len: run.len,
1434
+ quoteFrom: run.from + 1,
1435
+ quoteTo: run.to + 1,
1436
+ fileFrom: aligned.start + run.from + 1,
1437
+ fileTo: aligned.start + run.to + 1,
1438
+ },
1439
+ mismatch,
1440
+ }
1441
+ }
1442
+
1443
+ /** 一处**没有对齐**的区间(坐标带 / 文件头):只有行号与原文,没有命中数。 */
1444
+ function bareRegion(lines, startLine, endLine, basis, how) {
1445
+ return {
1446
+ basis,
1447
+ how,
1448
+ startLine,
1449
+ endLine,
1450
+ fileLines: lines.length,
1451
+ quoteLines: null,
1452
+ matched: 0,
1453
+ matchedLines: [],
1454
+ run: { len: 0, quoteFrom: 1, quoteTo: 0, fileFrom: startLine, fileTo: endLine },
1455
+ mismatch: null,
1456
+ }
1457
+ }
1458
+
1459
+ /**
1460
+ * 「找不到」时那一带的取法,由强到弱(每一步都写明这一步是哪一种,不含糊):
1461
+ * 1. **逐字**对齐({@link NOTFOUND_ALIGN_BASES} 第一档)——最好的一档:行号能直接照着用;
1462
+ * 2. **空白折叠后**对齐——次好,且回执明说这个前提;
1463
+ * 3. 一个逐字行都没有,但调用方给了坐标 / `near` ⇒ 把它指的**那一带**的真原文贴出来
1464
+ * (它以为那儿有东西,而那儿实际是什么,正是它要看的);
1465
+ * 4. 什么都没有 ⇒ 贴文件头,并明说"这不是命中"。
1466
+ *
1467
+ * 第 3、4 档都**不假装**是候选:`basis` 与说明句都写着"没有一行对上"。宁可说清"这不是匹配",
1468
+ * 也不能让调用方把一段无关的原文当成落点——那是本工具要消灭的静默错配。
1469
+ */
1470
+ function notFoundDetail(lines, oldRows, hunk, budget, ctx = null) {
1471
+ const hint = hintLineOf(hunk)
1472
+ let region = null
1473
+ for (const { basis, norm, how } of NOTFOUND_ALIGN_BASES) {
1474
+ const aligned = bestAlignment(lines, oldRows, norm, hint)
1475
+ if (aligned === null) continue
1476
+ region = regionOf(lines, oldRows, aligned, basis, how)
1477
+ break
1478
+ }
1479
+ if (region === null) {
1480
+ /**
1481
+ * 第 3 档用**调用方自己写的那个区间**铺开(它以为东西在那儿),比按引文行数铺更贴它的意图;
1482
+ * 第 4 档没有区间可用,才退回引文行数。两档都夹在 {@link NOTFOUND_BAND_LINES} 里。
1483
+ */
1484
+ const declared = hint === null ? undefined : lineRangeOf(hunk)
1485
+ const span = Math.max(
1486
+ 1,
1487
+ Math.min(declared === undefined ? oldRows.length : declared.end - declared.start + 1, NOTFOUND_BAND_LINES),
1488
+ )
1489
+ const start = hint === null ? 1 : Math.min(Math.max(hint, 1), lines.length)
1490
+ const end = Math.min(lines.length, start + span - 1)
1491
+ region =
1492
+ hint === null
1493
+ ? bareRegion(
1494
+ lines,
1495
+ 1,
1496
+ end,
1497
+ 'head',
1498
+ 'not one line of your old text is anywhere in this file; what follows is the head of the file — it is not a match',
1499
+ )
1500
+ : bareRegion(
1501
+ lines,
1502
+ start,
1503
+ end,
1504
+ 'hint',
1505
+ 'not one line of your old text is anywhere in this file; this is where your coordinates point — it is not a match',
1506
+ )
1507
+ }
1508
+
1509
+ const matchNote =
1510
+ region.quoteLines === null
1511
+ ? region.how
1512
+ : `${region.matched} of your ${region.quoteLines} line(s) match a line in that region ${
1513
+ region.basis === 'exact' ? 'byte-for-byte' : 'once whitespace is folded'
1514
+ }`
1515
+ const runNote =
1516
+ region.run.len > 1
1517
+ ? `; longest unbroken run: your lines ${region.run.quoteFrom}-${region.run.quoteTo} = file lines ${region.run.fileFrom}-${region.run.fileTo}`
1518
+ : region.run.len === 1
1519
+ ? `; only your line ${region.run.quoteFrom} = file line ${region.run.fileFrom} stands alone`
1520
+ : ''
1521
+ const diffNote =
1522
+ region.mismatch === null
1523
+ ? ''
1524
+ : `; first difference: your line ${region.mismatch.quoteLine} is ${quoteForMessage(region.mismatch.wrote)} but file line ${region.mismatch.fileLine} is ${quoteForMessage(region.mismatch.there, region.mismatch.fileLine)}`
1525
+ /**
1526
+ * 原文那一层的**身份声明**(C38 ③):回执贴了原文,就必须说清"哪些字节是文件内容"。
1527
+ * 行号前缀与 `=` / `!` 标记都是回执加的**边距**,不是文件的一部分 —— 不写明,
1528
+ * 调用方整块抄走必然失配(`probes/quote-hygiene.mjs` 把"行首 `数字: `"单列为一类引文污染)。
1529
+ *
1530
+ * 措辞避开 `candidates` / `occurrence` / `not unique` / `ambiguous`:这几个词是
1531
+ * `failure-replay` 与 `residual-census` 三态归类用的触发词,出现在这条回执里会把"找不到"
1532
+ * 记成 `needs_choice`(`notfound-receipt-test` 段E5/E6 钉着它)。本句一个都不含。
1533
+ *
1534
+ * 它进 `fixed` 而不是进 `hold`:下一段那段 `room` 是由 `budget - fixed.length - NOTFOUND_NOTE_CHARS`
1535
+ * 反推的,声明句写进 `fixed` 才被同一笔账算进去,`fixed + hold ≤ budget` 这条等式才仍然成立。
1536
+ */
1537
+ /**
1538
+ * 收尾那句必须与 {@link quoteForMessage} 的身份句**同形**(都以 `not part of the file:` 收尾):
1539
+ * 调用方与判据据同一句话定位"块从哪里开始、边距到哪为止",两处写法不同就等于两套规矩。
1540
+ */
1541
+ const bandIdentity =
1542
+ `The lines below are this file's own text, byte-for-byte \u2014 unless a line ends in ` +
1543
+ `\`NOT copyable\`, which means the receipt clipped that one to stay inside its budget and only ` +
1544
+ `its beginning is shown. Two marks in front of each one are the receipt's own margin, not ` +
1545
+ `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`
1547
+ const fixed =
1548
+ `Closest region: lines ${region.startLine}-${region.endLine} of ${region.fileLines} — ${matchNote}${runNote}${diffNote}.\n` +
1549
+ bandIdentity
1550
+
1551
+ /**
1552
+ * 原文那一层:先按"还剩多少额度"算上限,再逐行填进去;填不下就停,并把**没展示的**
1553
+ * 行数与字符数一起写出来。`room` 由 `budget` 反推,所以 `fixed + hold` 合计恒 ≤ `budget`。
1554
+ */
1555
+ const matchedLines = new Set(region.matchedLines)
1556
+ const legend =
1557
+ region.matched > 0 ? ' ("=" marks a line your old text has byte-for-byte, "!" one it does not)' : ''
1558
+ const room = Math.min(NOTFOUND_BAND_CHARS, Math.max(0, budget - fixed.length - NOTFOUND_NOTE_CHARS))
1559
+ const entries = []
1560
+ let used = 0
1561
+ let hiddenLines = 0
1562
+ let hiddenChars = 0
1563
+ for (let n = region.startLine; n <= region.endLine; n += 1) {
1564
+ const entry = `${matchedLines.has(n) ? '=' : '!'}${n}| ${clipReceiptLine(lines[n - 1])}`
1565
+ if (entries.length >= NOTFOUND_BAND_LINES || used + entry.length + 1 > room) {
1566
+ for (let k = n; k <= region.endLine; k += 1) {
1567
+ hiddenLines += 1
1568
+ hiddenChars += lines[k - 1].length + 1
1569
+ }
1570
+ break
1571
+ }
1572
+ entries.push(entry)
1573
+ used += entry.length + 1
1574
+ }
1575
+ const bandTotal = region.endLine - region.startLine + 1
1576
+ const hold =
1577
+ entries.length === 0
1578
+ ? `The text of those line(s) is not shown: the receipt budget left no room (${bandTotal} line(s), ${hiddenChars} char(s) omitted).`
1579
+ : `What those line(s) actually hold${legend}:\n${entries.join('\n')}\n(${entries.length} of ${bandTotal} line(s) in this region shown${
1580
+ hiddenLines === 0
1581
+ ? ''
1582
+ : `; ${hiddenLines} more line(s) / ${hiddenChars} char(s) cut to stay inside this receipt's budget`
1583
+ })`
1584
+
1585
+ /**
1586
+ * 修法那一行(MT6):柄铸在**回执刚刚展示的那一段**上——`region.startLine`–`region.endLine`,
1587
+ * 就是 `Closest region:` 那句与 `!N| ` 那些行号所指的同一段。
1588
+ *
1589
+ * 铸的是**整段**而不是"逐字命中的那段连续行"(`region.run`):回执明确写给它看的就是这一段,
1590
+ * 给一个比它看到的更窄的区间,等于让它在没看见的字节上落笔;而 `{handle, replace}` 的语义正是
1591
+ * "整段替换"。展示被预算剪过(`entries.length < bandTotal`)也照铸:摘要取自**文件字节**,
1592
+ * 与回执展示了几行无关(回执自己已声明剪了多少行)。
1593
+ */
1594
+ const nearestHandle = mintRegionHandle(ctx, region.startLine, region.endLine)
1595
+ const repair = nearestHandle === null ? '' : repairBlock([nearestHandle], [`${NOTFOUND_REPAIR_NOTE}\n`])
1596
+ return {
1597
+ text: `${fixed}${hold}\n${repair}`,
1598
+ nearest: {
1599
+ ...(nearestHandle === null ? {} : { retry: { handle: nearestHandle } }),
1600
+ line: region.startLine,
1601
+ endLine: region.endLine,
1602
+ fileLines: region.fileLines,
1603
+ basis: region.basis,
1604
+ ...(region.quoteLines === null ? {} : { quoteLines: region.quoteLines, matched: region.matched }),
1605
+ ...(region.run.len === 0
1606
+ ? {}
1607
+ : {
1608
+ run: {
1609
+ quoteLines: [region.run.quoteFrom, region.run.quoteTo],
1610
+ fileLines: [region.run.fileFrom, region.run.fileTo],
1611
+ },
1612
+ }),
1613
+ },
1614
+ next:
1615
+ region.run.len > 0
1616
+ ? `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
+ : 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',
1620
+ }
1621
+ }
1622
+
1623
+ /**
1624
+ * `old` 在文件里找不到时的拒绝 —— 带回**最近的那一带真实原文**与**可执行的下一步**。
1625
+ *
1626
+ * 这是**负向不变量**:坐标再合理也救不了它——坐标已经降为提示,能决定位置的只有原文。
1627
+ * 走到这里说明两条路都走完了(整行对齐 {@link findWindowTier} 与行内片段 {@link findFragmentTier}),
1628
+ * 所以这句话是字面意义的:**这段文字在文件里没有出现**,连行内片段都不是。
1629
+ *
1630
+ * 这里曾经附一句“第 N 行包含它,把那一行原样贴过来”——片段层落地后那句提示不再有可达的情形
1631
+ * (整行包含它 ⇒ 片段层必然命中),留着只会给出一条自相矛盾的指令(“片段不被接受”)。
1632
+ *
1633
+ * ## 为什么还要贴原文(本轮新增)
1634
+ *
1635
+ * 只回一句 `not in`,调用方拿不到任何可用于修正的信息:它既不知道**差在哪**(是这几行对不上,
1636
+ * 还是整块都不在这份文件里),也不知道该往哪儿改。实测(`residual-census` 重放语料):
1637
+ * 新契约下仍失败的 147 条(C33 读数 174;入口换成编排层 `applyEditToProvider` 之后 27 条归
1638
+ * `already_applied`,见 `probes/already-applied-census.mjs`)里两次尝试的候选数都是 0、message 只有
1639
+ * 这一句,每条只能靠一次重读自救;其中 64 条的引文**有一部分连续行逐字就在原文里**(C33 那 174 条
1640
+ * 里同一读数是 71 = 64 + 转出的 7 条,见 `residual-census` 的机制分布 B04×7;最接近的 F0059 是
1641
+ * 22/32 行、F0447 是 16/20 行),
1642
+ * 那是可定位的一层,却没有任何东西把它交出去。所以拒绝照样拒绝,但回执要给出:
1643
+ * · 最接近的那一带的**行号 + 该处原文**(`=` / `!` 标出哪几行对得上);
1644
+ * · **从哪一行开始对不上**(引文那一行与文件那一行各贴一份,都可逐字核对);
1645
+ * · 一个**下次可以直接用的地址**(`next`):从最长的那段连续逐字命中里取行区间
1646
+ * (DSH 壳会把它放进结果的 `next` 字段,见 `aura-edit/index.js` 的 `intentVerdict`)。
1647
+ *
1648
+ * ## 三条不许破
1649
+ *
1650
+ * · **拒绝照旧**:码仍是 `PATCH_NOT_FOUND`(壳层判决表把它收进 `not_found`),磁盘一个字节不动;
1651
+ * · **不当候选卖**:结构化字段叫 `nearest` 而**不叫** `candidates`——`intentVerdict` 见到
1652
+ * `candidates.length > 1` 就把结果翻成 `needs_choice`(`aura-edit/index.js:387`),
1653
+ * 而"这段文字不在文件里"不是"有多个落点让你挑"。message 里也刻意不用 candidates / ambiguous
1654
+ * 这些词:`failure-replay` 与 `residual-census` 的三态归类按这些词认,给找不到的回执配上
1655
+ * "候选"的措辞,等于让测量面把它记成 `needs_choice`——那是把"找不到"换个名字说成"有得选";
1656
+ * · **体量不顶穿宿主阈值**:见 {@link NOTFOUND_RECEIPT_CHARS},且剪了多少一律明写。
1657
+ *
1658
+ * `expectVerdict` 仍是 `expect` 的唯一判据——本函数只**描述** `old` 的落点,不判任何东西。
1659
+ */
1660
+ function oldNotFoundError(label, lines = null, oldRows = null, hunk = null, ctx = null) {
1661
+ const head = notFoundHead(label)
1662
+ const detail =
1663
+ lines === null || oldRows === null || oldRows.length === 0
1664
+ ? { text: '', nearest: undefined, next: undefined }
1665
+ : notFoundDetail(lines, oldRows, hunk, NOTFOUND_RECEIPT_CHARS - head.length, ctx)
1666
+ return new PatchError(detail.text === '' ? head : `${head} ${detail.text}`, 'PATCH_NOT_FOUND', {
1667
+ ...(detail.nearest === undefined ? {} : { nearest: detail.nearest }),
1668
+ ...(detail.next === undefined ? {} : { next: detail.next }),
1669
+ })
1670
+ }
1671
+
1672
+ /**
1673
+ * 收敛不到一个候选时的拒绝:候选清单(行号 + 各候选原文)必须写进 **message**。
1674
+ *
1675
+ * 为什么写进 message 而不只挂在 `candidates` 字段上:DSH 框架对抛出的错误只取 `error.message`
1676
+ * (`dsh-tools/lib/index.js` 的 `errorMessage`),其余字段一律不读(`locator-api.failure` 同)。
1677
+ */
1678
+ /**
1679
+ * 收敛不到一个候选时的拒绝:候选清单(行号 + 各候选原文)必须写进 **message**。
1680
+ *
1681
+ * 候选一律是 `{start, span}`(0-based):整行对齐那一层的 span 恒为 `old` 的行数,片段层每个候选的
1682
+ * span 由“片段落在哪些行里”决定、可以互不相同——两层共用这一份收据,不各写一套候选形状。
1683
+ *
1684
+ * 为什么写进 message 而不只挂在 `candidates` 字段上:DSH 框架对抛出的错误只取 `error.message`
1685
+ * (`dsh-tools/lib/index.js` 的 `errorMessage`),其余字段一律不读(`locator-api.failure` 同)。
1686
+ */
1687
+ /**
1688
+ * 收敛不到一个候选时的拒绝:候选清单(行号 + 各候选原文)必须写进 **message**。
1689
+ *
1690
+ * 候选形状:整行对齐那一层是 `{start, span}`(span 恒为 `old` 的行数);片段层多带 `startCol` /
1691
+ * `endCol` / `text`——片段层替换的**就是那一处跨度**,候选若只说“第 3 行”,调用方拿到的位置比它
1692
+ * 实际要改的范围宽,照着候选重发一次又会落回同一个歧义里。
1693
+ *
1694
+ * 为什么写进 message 而不只挂在 `candidates` 字段上:DSH 框架对抛出的错误只取 `error.message`
1695
+ * (`dsh-tools/lib/index.js` 的 `errorMessage`),其余字段一律不读(`locator-api.failure` 同)。
1696
+ */
1697
+ function oldAmbiguityError(lines, candidates, label, total, ctx = null) {
1698
+ const rows = candidates.map((c) => ({
1699
+ line: c.start + 1,
1700
+ endLine: c.start + c.span,
1701
+ text: c.text ?? lines.slice(c.start, c.start + c.span).join('\n'),
1702
+ ...(c.startCol === undefined ? {} : { startCol: c.startCol, endCol: c.endCol }),
1703
+ }))
1704
+ const { listing, hidden } = candidateListing(rows, lines, ctx)
1705
+ /** 清单多行时它自带收尾换行,那句收尾建议就不能再顶一个 `.` 上去。 */
1706
+ const gap = listing.endsWith('\n') ? '' : '. '
1707
+ const omitted = hidden === 0 ? '' : ` (+${hidden} more, not shown: this receipt's output budget)`
1708
+ const narrowed = total > rows.length ? ` (of ${total}; \`context\` narrowed it to these)` : ''
1709
+ /**
1710
+ * 预算的**分法**(C41 缺口三):候选清单先算(它是这条回执的正文,也正是调用方要挑的东西),
1711
+ * 剩下的才给 `label`。反过来分——先给 label 一整份、再让清单去挤——量出来就是
1712
+ * {@link AMBIGUITY_RECEIPT_CHARS} 注释里那几个数:22479 / 8277 / 8479。
1713
+ * `label` 被夹时夹口就在原地写明夹了多少字符,文件名是什么仍然看得见。
1714
+ */
1715
+ const head = `the old text matches ${rows.length} place(s) in `
1716
+ const tail =
1717
+ `${narrowed} — cannot tell which one you meant; candidates: ${listing}${omitted}${gap}` +
1718
+ `Add the surrounding lines as \`context\` (or make \`old\` unique) — nothing was written` +
1719
+ /**
1720
+ * 挑一个的**机械做法**(MT6):候选上面已经各自带了一行 `retry:`,这里只说清"把它抄下来即可"。
1721
+ * 措辞不碰判定:不说"更准"、不说"找到多个"以外的任何新结论。
1722
+ */
1723
+ (listing.includes(RETRY_LABEL)
1724
+ ? ' — or paste one handle from a `retry:` line above with your own `replace`: each one already names exactly one of these lines, so nothing has to be re-quoted'
1725
+ : '')
1726
+ const room = Math.max(40, AMBIGUITY_RECEIPT_CHARS - head.length - tail.length)
1727
+ return new PatchError(
1728
+ `${head}${clipReceiptLabel(label, room)}${tail}`,
1729
+ 'PATCH_AMBIGUOUS',
1730
+ { candidates: rows },
1731
+ )
1732
+ }
1733
+
1734
+ /**
1735
+ * 候选清单的**合计**字符预算(与 `locator-api` 的 `CANDIDATE_LIST_CHARS` 同值、同一条理由)。
1736
+ *
1737
+ * 条数封不住体量:C38 给每条引文配了**忠实块**(真换行 + 行号前缀,单条可到 ~1200 字符),
1738
+ * 20 条就是两万四,而宿主 `toolResultPruner.thresholdChars` 是 8192 —— 超了中间会被静默剪掉,
1739
+ * 候选恰恰在中间。到顶就停,并把没展示的条数写在原地。
1740
+ */
1741
+ const CANDIDATE_LIST_CHARS = 2400
1742
+
1743
+ /**
1744
+ * 一条失败回执里最多为**几个候选**铸柄(MT6 的条数上限)。
1745
+ *
1746
+ * 为什么是 5 而不是候选上限 20:柄本身只有 21 字符,但它每一条都占一整行,
1747
+ * 而回执体量是宿主剪枝器(`thresholdChars: 8192`)量的东西。5 条正好把"歧义里最常见的
1748
+ * 2~4 处"全覆盖,再多的候选本来也只能靠调用方**看一眼原文**去认——那件事回执已经在做。
1749
+ * 超限不静默:候选照旧逐条报行号与原文(今天的形状),只是不带 `retry:` 行。
1750
+ */
1751
+ const HANDLE_CANDIDATE_LIMIT = 5
1752
+
1753
+ /**
1754
+ * `old` 歧义回执的**体量预算**(`label` 与正文**合计**,不是各算一份)。
1755
+ *
1756
+ * 与 {@link NOTFOUND_RECEIPT_CHARS} 同值、同一条依据:6000 < 8192(宿主阈值),
1757
+ * 也 < 4096 + 1024 + 标记长度 —— 连“只留头窗”那种剪法也装得下。
1758
+ *
1759
+ * 为什么这一支也需要一个数:这条回执里的 `label` 是**调用方给的原文**(通常是个路径),
1760
+ * 长度不由本工具决定,而它与候选清单写在同一条消息里。实测(C41,20 条候选 × 每条 2 行):
1761
+ * label 两万字符时这条回执 22479 字符,是宿主阈值的 2.7 倍;label 5802 时 8277、6004 时 8479
1762
+ * —— 两处都越过了 8192。而在 C38 给每条候选配上忠实块之前,同一夹具的同一长度是 6923(合规):
1763
+ * **净增量 1354 字符把一段原本合规的 label 区间顶过了线**(上一波独立验收在它自己的夹具上
1764
+ * 量到的是 7223 → 8193,拐点落在同一段长度上)。
1765
+ * 所以这里不是“留点余量”,而是先把清单算出来,剩下的才给 `label`
1766
+ * (分法见 {@link oldAmbiguityError},夹子本体是 `locator-api` 的 `clipReceiptLabel`)。
1767
+ */
1768
+ const AMBIGUITY_RECEIPT_CHARS = 6000
1769
+
1770
+ /**
1771
+ * 候选清单的渲染:`line N[-M] = <引文>` 用 `, ` 连成一行,合计体量封在 {@link CANDIDATE_LIST_CHARS}。
1772
+ *
1773
+ * 引文本身走 {@link quoteForMessage}(与 `the content there is:` 那处**同一份渲染**):
1774
+ * 候选也是"引用原文给调用方看",在这里另写一套等于同一件事又有了两份判定。
1775
+ *
1776
+ * @param rows - `{line, endLine, text, startCol?, endCol?}` 形状的行
1777
+ * @returns `{listing, hidden}`:`listing` 是拼好的清单,`hidden` 是因预算没展示的条数
1778
+ */
1779
+ function candidateListing(rows, lines = null, ctx = null) {
1780
+ const parts = []
1781
+ let spent = 0
1782
+ /** 已发出去的候选柄数(封在 {@link HANDLE_CANDIDATE_LIMIT};超出的候选只报行号,照今天的方式)。 */
1783
+ let handles = 0
1784
+ for (const row of rows) {
1785
+ const place =
1786
+ row.startCol === undefined
1787
+ ? `line ${row.line}${row.endLine === row.line ? '' : `-${row.endLine}`}`
1788
+ : `line ${row.line} columns ${row.startCol}-${row.endCol}`
1789
+ /**
1790
+ * 每个候选各铸一个柄(MT6):候选的 `line`–`endLine` 是**文件里的绝对行号**,
1791
+ * 拿它去 `lines` 取那段原始字节铸柄——铸法与 {@link mintRegionHandle} 同一处,
1792
+ * 所以身份标签/摘要/行号三样与"最近那一带"那一支口径完全一致。
1793
+ *
1794
+ * 上限是**条数**({@link HANDLE_CANDIDATE_LIMIT}):候选最多 20 条,每条一个 21 字符的柄
1795
+ * 叠上散文就有几百字符,而回执体量是宿主剪枝器量的东西。超限的候选照旧只报行号。
1796
+ */
1797
+ const handle =
1798
+ handles < HANDLE_CANDIDATE_LIMIT && lines !== null ? mintRegionHandle(ctx, row.line, row.endLine) : null
1799
+ const entry =
1800
+ `${place} = ${quoteForMessage(row.text, row.line)}` + (handle === null ? '' : `\n${retryLine(handle)}`)
1801
+ // 第一条无论多长都要放:一条候选都不印的清单等于没给,"有候选"当场变成一句空话。
1802
+ if (parts.length > 0 && spent + entry.length > CANDIDATE_LIST_CHARS) break
1803
+ parts.push(entry)
1804
+ spent += entry.length
1805
+ if (handle !== null) handles += 1
1806
+ }
1807
+ /**
1808
+ * 分隔符看内容:全是单行就给一行 `, ` 连到底(与改前逐字相同),
1809
+ * 只要有一条自带忠实块(真换行),就**一条一行** —— 否则上一条的原文块会把下一条的
1810
+ * `line N = ` 前缀吞进自己的最后一行,回抄时两条候选粘成一段。
1811
+ */
1812
+ const sep = parts.some((p) => p.includes('\n')) ? '' : ', '
1813
+ return { listing: parts.join(sep), hidden: rows.length - parts.length }
1814
+ }
1815
+
1816
+ /**
1817
+ * 写路径的 `expect` 拒绝 —— 把 {@link expectVerdict} 的**同一份结论**翻成 `PatchError`。
1818
+ *
1819
+ * 本函数**不做任何判定**:没有比较、没有归一化、没有阈值。`kind` 已经是判据给的结论,
1820
+ * 这里只决定两件事:用哪个码说、怎么说。阈值也是从 `locator-api` 取的同一个常量
1821
+ * ({@link ASSERT_RELAX_MIN_CHARS}),不在本文件重写一个 12。
1822
+ *
1823
+ * 码的分派与 DSH 壳的三态判决表(`aura-edit/index.js` 的 `OUTCOME_BY_CODE`)对齐:
1824
+ * · `short_mismatch` ⇒ `PATCH_EXPECT_MISMATCH`("这段文字与这里对不上";短引文仍要求整段相等,
1825
+ * 与改前同码 —— `content-address-test` 段F5 钉着它)⇒ outcome `not_found`;
1826
+ * · `not_unique` ⇒ `PATCH_AMBIGUOUS`(多处都能对上,得由调用方挑一处 ⇒ outcome `needs_choice`;
1827
+ * 候选清单必须一起给,否则"从候选里选"是一句空话);
1828
+ * · `nowhere` / `spans_wider` / `outside` ⇒ `PATCH_NOT_FOUND`(按这段 `expect` 找不到要改的原文
1829
+ * ⇒ outcome `not_found`)。
1830
+ *
1831
+ * 消息里必须带**区间原文**与**候选行**:DSH 框架对抛出的错误只取 `error.message`
1832
+ * (`dsh-tools/lib/index.js` 的 `errorMessage`),其余字段一概不读。
1833
+ *
1834
+ * @param verdict - {@link expectVerdict} 的结论(非 null)
1835
+ * @param line - 命中区间的起始行(1-based)
1836
+ * @param tier - 命中层,写进消息
1837
+ * @param actual - 受寻址区间原文
1838
+ * @returns PatchError(码见上)
1839
+ */
1840
+ function expectGateError(verdict, line, tier, actual) {
1841
+ const where = `hunk at line ${line} matched via ${tier}`
1842
+ const there = `the content there is: ${quoteForMessage(actual, line)}`
1843
+
1844
+ if (verdict.kind === 'not_unique') {
1845
+ const { listing, hidden } = candidateListing(verdict.places)
1846
+ /** 清单多行时它自带收尾换行,`(+N more)` 就不能再顶在最后一行原文的尾巴上。 */
1847
+ const omitted = hidden === 0 ? '' : `${listing.endsWith('\n') ? '' : ' '}(+${hidden} more, not shown: this receipt's output budget)`
1848
+ return new PatchError(
1849
+ `${where}, but expect is not unique in this file — it appears on ${verdict.places.length} places, so it ` +
1850
+ `cannot say WHICH one you mean; quote more of the surrounding text (or say which one with \`old\`/\`context\`) ` +
1851
+ `so that it is unique — ${there}; candidates: ${listing}${omitted}`,
1852
+ 'PATCH_AMBIGUOUS',
1853
+ { line, actual, candidates: verdict.places },
1854
+ )
1855
+ }
1856
+
1857
+ if (verdict.kind === 'short_mismatch') {
1858
+ return new PatchError(
1859
+ `${where}, but expect is shorter than ${ASSERT_RELAX_MIN_CHARS} chars, so it must equal the addressed ` +
1860
+ `region exactly — ${there}`,
1861
+ 'PATCH_EXPECT_MISMATCH',
1862
+ { line, actual },
1863
+ )
1864
+ }
1865
+
1866
+ if (verdict.kind === 'nowhere') {
1867
+ return new PatchError(
1868
+ `${where}, but expect appears nowhere in this file (whole-file scan, whitespace-normalized) — ${there}`,
1869
+ 'PATCH_NOT_FOUND',
1870
+ { line, actual },
1871
+ )
1872
+ }
1873
+
1874
+ /** 剩下两支都带 `at`(唯一出现处):`spans_wider` 与 `outside`。 */
1875
+ const at = verdict.at
1876
+ if (verdict.kind === 'spans_wider') {
1877
+ return new PatchError(
1878
+ `${where}, but expect spans ${verdict.spans} lines while the addressed region is ${verdict.regionLines} ` +
1879
+ `line(s) (lines ${at.line}-${at.endLine} hold it); address the whole quote, or trim expect down to the ` +
1880
+ `addressed text — ${there}`,
1881
+ 'PATCH_NOT_FOUND',
1882
+ { line, actual },
1883
+ )
1884
+ }
1885
+
1886
+ return new PatchError(
1887
+ `${where}, but expect's only occurrence in this file is ` +
1888
+ `${at.line === at.endLine ? `line ${at.line}` : `lines ${at.line}-${at.endLine}`}, which ` +
1889
+ (verdict.overlaps
1890
+ ? 'only partly overlaps the addressed region (the quote has to fit entirely inside it)'
1891
+ : 'is outside the addressed region') +
1892
+ ` — ${there}`,
1893
+ 'PATCH_NOT_FOUND',
1894
+ { line, actual },
1895
+ )
1896
+ }
1897
+
1898
+ /**
1899
+ * 用**内容**定位一个 hunk:`old` 找候选,`context` 收敛,收敛不了就报候选清单。
1900
+ *
1901
+ * 收敛顺序(每一步只**筛掉**候选,绝不凭空造出一个):
1902
+ * 1. **整行对齐**({@link findWindowTier}):`old` 是一整行(或连续几整行)原文时按行区间命中;
1903
+ * 一处都没有 ⇒ 退一层(这一层有命中时不跑下一层:片段层的候选集是它的超集,不会收敛得更好);
1904
+ * 2. **行内片段**({@link findFragmentTier}):`old` 只是某一行的**一部分**时,按「片段在文件里
1905
+ * 唯一出现」定位,替换范围**就是那一处出现本身**(起止列见 {@link fragmentSpans});
1906
+ * 两层都没有 ⇒ {@link oldNotFoundError}(这段文字在文件里没有出现);
1907
+ * 3. `context` 在邻域内(紧邻 / 包含 `old`)⇒ 命中(`old+context`);
1908
+ * 4. **自动扩大上下文重试一次**:把邻域放宽一个 context 长度再看 ⇒ 命中(`old+expanded`);
1909
+ * 5. `near` 显式提示从剩下的候选里收敛(唯一最近才算,并列不猜);
1910
+ * 6. 仍不唯一 ⇒ `PATCH_AMBIGUOUS` + 候选清单,磁盘一个字节都不动
1911
+ * (`planHunks` 是纯函数,“先全部规划完再落盘”的事务性由调用方承接)。
1912
+ *
1913
+ * 唯一性在片段层**不打折**:片段出现多处(哪怕每处都是行内片段)照样拒绝并给候选,不挑一个。
1914
+ *
1915
+ * @param lines - 文件的行({@link splitLinesWithEndings} 的产物,行号与 `locate` 同一把尺子)
1916
+ * @param hunk - 原始 DSL 形状的 hunk(读 `old` / `context` / `near` / 坐标)
1917
+ * @param oldText - {@link oldAddressOf} 的产物(非空)
1918
+ * @param label - 回执里指代这份文件的说法
1919
+ * @returns `{startLine, endLine, tier, actual, addressed, fragmentNote?, coordinateNote?,
1920
+ * coordinateConflict?, byNear?}`;
1921
+ * 地址形态与调用方字面写的不符时(片段层的命中跨度比整行窄、坐标与落点不一致),`tier` 里已经带上
1922
+ * 那句话(DSH 壳的 `output.schema` 是
1923
+ * `additionalProperties: false`,`applied[]` 上加未声明字段会让整条成功路径被判成
1924
+ * `INVALID_TOOL_OUTPUT`,`tier` 是那里唯一能承载这句话的已声明字段)。
1925
+ * @throws PatchError:`PATCH_NOT_FOUND`(old 不在文件里)/ `PATCH_AMBIGUOUS`(收敛不到一个)
1926
+ */
1927
+ function locateByOld(lines, hunk, oldText, label, ctx = null) {
1928
+ const oldRows = addressRows(oldText)
1929
+ const aligned = findWindowTier(lines, oldRows)
1930
+ /**
1931
+ * 候选统一写成 `{start, span}`:整行对齐那一层 span 恒等于 `old` 的行数;片段层每个候选的 span
1932
+ * 由“片段落在哪些行里”决定,可以互不相同(两者都另带它自己那段的起止列)。
1933
+ * 下面收敛/就近两轮因此对两层是**同一份代码**——不为片段层再抄一遍(抄一份就等于将来只改一处)。
1934
+ */
1935
+ let candidates = aligned.starts.map((start) => ({ start, span: oldRows.length }))
1936
+ let tierKey = aligned.tier
1937
+ let norm = aligned.norm
1938
+ let byFragment = false
1939
+ if (candidates.length === 0) {
1940
+ const fragment = findFragmentTier(lines, oldText)
1941
+ /**
1942
+ * 「找不到」的回执要贴**原文那一带**,所以定位器手里那三样东西一并交给它:
1943
+ * 全文的行(行号与 `locate` 同一把尺)、`old` 的行、以及原始 hunk(坐标 / `near` 只当提示)。
1944
+ * 这三样都是**只读**的:回执只描述落点,不改任何结论——判据仍是上面那两层。
1945
+ */
1946
+ if (fragment.spans.length === 0) throw oldNotFoundError(label, lines, oldRows, hunk, ctx)
1947
+ candidates = fragment.spans
1948
+ tierKey = fragment.tier
1949
+ norm = fragment.norm
1950
+ byFragment = true
1951
+ }
1952
+ const ctxRows = typeof hunk.context === 'string' && hunk.context.trim().length > 0 ? addressRows(hunk.context) : []
1953
+ const total = candidates.length
1954
+ /**
1955
+ * 片段层候选多带 `{startCol, endCol, text}`:这一层替换的**就是那一处跨度**,不是整行。
1956
+ * 收敛/就近两轮只看 `{start, span}`(行读数),所以它们原样沿用两层同一份代码。
1957
+ */
1958
+ const frag = (c) => byFragment && typeof c?.text === 'string'
1959
+ let narrowed = ''
1960
+ if (candidates.length > 1 && ctxRows.length > 0) {
1961
+ const tight = candidates.filter((c) => contextSitsAt(lines, c.start, c.span, ctxRows, norm, 0))
1962
+ if (tight.length === 1) {
1963
+ candidates = tight
1964
+ narrowed = '+context'
1965
+ } else {
1966
+ // 收紧不到一个(含“收紧到 0 个”)⇒ 自动扩大上下文**重试一次**。
1967
+ const base = tight.length > 0 ? tight : candidates
1968
+ const wide = base.filter((c) => contextSitsAt(lines, c.start, c.span, ctxRows, norm, ctxRows.length))
1969
+ if (wide.length === 1) {
1970
+ candidates = wide
1971
+ narrowed = '+expanded'
1972
+ } else if (wide.length > 0) candidates = wide
1973
+ }
1974
+ }
1975
+ let byNear = false
1976
+ if (candidates.length > 1 && hunk.near !== undefined && hunk.near !== null) {
1977
+ const chosen = nearestOnly(candidates.map((c) => c.start), hunk.near)
1978
+ if (chosen !== null) {
1979
+ candidates = candidates.filter((c) => c.start === chosen)
1980
+ byNear = true
1981
+ }
1982
+ }
1983
+ if (candidates.length > 1) throw oldAmbiguityError(lines, candidates, label, total, ctx)
1984
+ const [chosen] = candidates
1985
+ const startLine = chosen.start + 1
1986
+ const endLine = chosen.start + chosen.span
1987
+ const tierBase = `old${tierKey === 'exact' ? '' : `~${tierKey}`}${narrowed}`
1988
+ const range = lineRangeOf(hunk)
1989
+ const coordinatesAgree = range !== undefined && range.start === startLine && range.end === endLine
1990
+ const coordinateNote =
1991
+ range === undefined || coordinatesAgree
1992
+ ? null
1993
+ : `coordinates lines ${range.start}-${range.end} do not hold this old text — addressed by old at line ${startLine} instead`
1994
+ /**
1995
+ * 受寻址区间的原文:整行对齐层是一整行(或几行);片段层是**命中跨度自己**。
1996
+ * `expect` 闸门({@link expectVerdict})因此按真正被替换的那一段判,而不是按一行判。
1997
+ */
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
2000
+ /** 两条披露可能同时成立(片段层的跨度比整行窄 + 坐标与落点不符):都写进 `tier`,各说各的事实。 */
2001
+ const notes = [fragmentNote, coordinateNote].filter((note) => note !== null)
2002
+ return {
2003
+ startLine,
2004
+ endLine,
2005
+ tier: notes.length === 0 ? tierBase : `${tierBase} (${notes.join('; ')})`,
2006
+ actual: addressed,
2007
+ addressed,
2008
+ /**
2009
+ * 命中跨度的结构:只对片段层有意义(整行对齐层的跨度恒为行首到行尾)。
2010
+ * `columns` 是**替换范围**在原文里的起止列(1-based 闭区间),`text` 是它逐字的原文。
2011
+ * 跨行时 `columns[1]` 是**首行坐标**(拼接层拿它切 `prefix` / `tail`),
2012
+ * 末行那一侧的列号另放 `endLineCol`。
2013
+ */
2014
+ ...(frag(chosen)
2015
+ ? {
2016
+ fragment: {
2017
+ line: startLine,
2018
+ endLine,
2019
+ columns: [chosen.startCol, chosen.endCol],
2020
+ ...(chosen.endLineCol === undefined ? {} : { endLineCol: chosen.endLineCol }),
2021
+ text: chosen.text,
2022
+ },
2023
+ }
2024
+ : {}),
2025
+ ...(fragmentNote === null ? {} : { fragmentNote }),
2026
+ ...(coordinateNote === null
2027
+ ? {}
2028
+ : { coordinateNote, coordinateConflict: { given: [range.start, range.end], actual: [startLine, endLine] } }),
2029
+ ...(byNear ? { byNear: true } : {}),
2030
+ }
2031
+ }
2032
+
2033
+ // ── 内容寻址的第二条出路:这次编辑**已经落地**了 ─────────────────────────────
2034
+ //
2035
+ // 形状:`old` 不在文件里(两层都找不到),但**结果已经在文件里了** —— 调用方给的 `replace`
2036
+ // 逐字就在原文中。历史数据里这一类是真实存在的:`residual-census` 重放 353 条历史失败,
2037
+ // C33 读数里归 fail 的 174 条中,有 27 条是这个形状 —— 入口换成编排层之后它们从残余里单列成
2038
+ // `already_applied`(fail 174 → 147);这 27 个编号在 `already-applied-census` 的 pin 里列着
2039
+ // (`treeNew.already_applied` 27 / `landedIds` 27 条),
2040
+ // 其中 F0040 的实测记录里那句“命中的原文已等于 `replace`”就是这个形状的直白写法。
2041
+ //
2042
+ // 为什么值得单列一态:调用方的调用**被中断/失败后重试**时(本仓会话日志里 17 次「调用被记录
2043
+ // 但没有结果落盘」、111 次「事务未提交」),如果上一次其实已经写成功了,工具只回一句
2044
+ // “你要改的原文不在文件里”,调用方拿不到任何信息知道“它已经改好了” —— 于是它会去重读、
2045
+ // 去改地址、去改写法,而正确答案是“这次不用改”。
2046
+ //
2047
+ // ## 判据(三档,全部只在**内存**里算)
2048
+ //
2049
+ // ① `old` 两层都找不到 —— 判据与 {@link locateByOld} 是**同两个函数**({@link findWindowTier}
2050
+ // 与 {@link findFragmentTier})。`old` 只要在任一层命中,本条就不成立(那种情形要么已经
2051
+ // 被 `planHunks` 接住,要么是歧义/断言不符,都不是这个形状)。
2052
+ // ② `replace` 是**整行**命中(`findWindowTier` 那一层,比片段层**严**),且**全文唯一**。
2053
+ // 唯一性是 `old` 被应用时本来就要求的门槛(多处命中 ⇒ `PATCH_AMBIGUOUS`);对 `replace`
2054
+ // 用同一把尺子,才谈得上“`replace` 不比 `old` 更宽松地匹配”。**不许**用片段层接住它:
2055
+ // `{old:'zzz-nowhere', replace:'X'}` 打在随便哪个含 `X` 的文件上都会“命中”,那不是证据。
2056
+ // ③ `replace` 去掉空白后有 {@link ALREADY_APPLIED_MIN_CHARS} 个字符。整行命中挡得住
2057
+ // “行内片段巧遇”,挡不住“文件里恰好有一整行是 `X`/`}`”这种巧遇;这条门槛是本单实测
2058
+ // 这一类里最短的一条(19 字符)之下取的整数,只做**下限**用,不参与定位。
2059
+ //
2060
+ // 三条全过 ⇒ 这一条 hunk 判「已落地」。**多 hunk 调用必须逐条全过**才算:一条已落地、
2061
+ // 另一条真的找不到时,这次编辑**并没有全部落地**,回执就不能说“已经是你给的样子了”——
2062
+ // 那种情形原样抛 `PATCH_NOT_FOUND`(宁可少说,不许说错)。
2063
+ //
2064
+ // ## 不许破的
2065
+ //
2066
+ // · **它不写盘**:判据在 {@link applyEditToProvider} 里、任何 `writeText` **之前**,
2067
+ // 所以“已落地”这条路的磁盘字节数恒等于进来时那份(`already-applied-test` 段B 逐字节钉住);
2068
+ // · **它不改 `planHunks` 的判决**:`planHunks` 照旧抛 `PATCH_NOT_FOUND`(拿不到原文的调用方
2069
+ // 仍然拿到那条带最近一带原文与 `next` 的回执),本函数是**第二条出路**,不是对它的放宽;
2070
+ // · **幂等**:同一份 `{old, replace}` 打第二次仍然是「已落地」,第二次也不写盘。
2071
+
2072
+ /** 「已落地」在回执 `tier` 里的前缀(有已声明字段可用,就不新造顶层字段)。 */
2073
+ const ALREADY_APPLIED_TIER = 'already-applied'
2074
+
2075
+ /**
2076
+ * `replace` 去掉空白后的**下限**(字符)。见上面 ③:只挡巧遇,不参与定位。
2077
+ *
2078
+ * 取 8 的依据是本单的实测分布:27 条“已落地”里最短的一条是 19 个字符(条数与编号集以
2079
+ * `already-applied-census.mjs` 的 pin 为准 —— `treeNew.already_applied` 27 / `landedIds` 27 条;
2080
+ * 本条早先手抄的 26 已过期。F0243 的 ` "status": "done",` 那一行),而单字符/单词级的 `replace`
2081
+ * (`X`、`}`、`-`、`ok`)在文件里出现一次与“调用方要的结果已经在那儿”不可区分。
2082
+ */
2083
+ const ALREADY_APPLIED_MIN_CHARS = 8
2084
+
2085
+ /**
2086
+ * 逐条判「已落地」:全过 ⇒ 回执数组(每条 hunk 一项);任一条不成立 ⇒ `null`。
2087
+ *
2088
+ * 返回的每一项都能**逐字核对**:`line` 是 `replace` 已经在的那一行(1-based,与 `applied[].line`
2089
+ * 同一把尺子)、`tier` 说清它是在哪一层命中的、`actual` 是那一处的**原文**(就等于 `replace`)、
2090
+ * `replaced` 是调用方给的那份文本。
2091
+ *
2092
+ * @param content - 文件全文
2093
+ * @param hunks - 原始 DSL 形状的 hunk 数组(只读 `old` / `replace`)
2094
+ * @returns `Array<{hunk, line, tier, actual, replaced}> | null`
2095
+ */
2096
+ export function alreadyAppliedHits(content, hunks) {
2097
+ if (!Array.isArray(hunks) || hunks.length === 0) return null
2098
+ const { lines } = splitLinesWithEndings(content)
2099
+ const hits = []
2100
+ for (const [index, hunk] of hunks.entries()) {
2101
+ const hit = alreadyAppliedHitOf(lines, hunk)
2102
+ if (hit === null) return null
2103
+ hits.push({ hunk: index, ...hit })
2104
+ }
2105
+ return hits
2106
+ }
2107
+
2108
+ /**
2109
+ * 单条 hunk 的判据本体(三条,见上面那一段)。命中返回回执那一项,否则 `null`。
2110
+ *
2111
+ * 三条都不看 `expect`:`expect` 说的是“**改之前**那里是什么”,而这一态的全部内容是
2112
+ * “**改之后**那里已经是什么” —— 拿前者给后者作证,是把两件事混成一件。
2113
+ */
2114
+ function alreadyAppliedHitOf(lines, hunk) {
2115
+ const oldText = oldAddressOf(hunk)
2116
+ if (oldText === null) return null
2117
+ const replace = typeof hunk.replace === 'string' ? hunk.replace : ''
2118
+ if (replace.trim().length === 0) return null
2119
+ if (replace.replace(/\s+/g, '').length < ALREADY_APPLIED_MIN_CHARS) return null
2120
+ // ① `old` 两层都没有(判据与 locateByOld 同源;`old` 在场就不是这一态)。
2121
+ if (findWindowTier(lines, addressRows(oldText)).starts.length > 0) return null
2122
+ if (findFragmentTier(lines, oldText).spans.length > 0) return null
2123
+ // ② `replace` 整行命中且唯一 —— 比片段层严,且与 `old` 被应用时的唯一性门槛同一把尺子。
2124
+ const rows = addressRows(replace)
2125
+ const aligned = findWindowTier(lines, rows)
2126
+ if (aligned.starts.length !== 1) return null
2127
+ const [start] = aligned.starts
2128
+ return {
2129
+ line: start + 1,
2130
+ tier: `${ALREADY_APPLIED_TIER}~${aligned.tier}`,
2131
+ actual: lines.slice(start, start + rows.length).join('\n'),
2132
+ replaced: replace,
2133
+ }
2134
+ }
2135
+
2136
+ /** 句柄路的层名:进 `applied[].tier`,短是硬要求(回执体积)。 */
2137
+ const HANDLE_TIER = 'handle'
2138
+
2139
+ /** 句柄指不到那一段字节时的统一拒绝(结果通道 `not_found`,磁盘一个字节不动)。 */
2140
+ function handleStaleError(label) {
2141
+ return new PatchError(
2142
+ `the bytes this handle names are no longer in ${label} — re-read that region and use the handle from that read; nothing was written`,
2143
+ 'PATCH_HANDLE_STALE',
2144
+ )
2145
+ }
2146
+
2147
+ /**
2148
+ * 句柄 → 当前文件里的那一段(契约 §5 的第 1、2 步)。
2149
+ *
2150
+ * ① 身份标签(同一个 `resolveLedgerKey` 口径)不符 ⇒ 拒(D5:句柄不能拄到另一份文件上);
2151
+ * ② 拿句柄带的坐标在当前文件里算**同一份摘要**:
2152
+ * 相等 ⇒ **主命中**,就用这一段;
2153
+ * 不等 ⇒ **尽力重找**:拿账本里那条整文件观测(读侧 `rememberRead` 记的,本来就在)里
2154
+ * 读时文本的同段字节,在当前文件里**逐字整行**搜——
2155
+ * 唯一 ⇒ 按新坐标应用并在回执里披露"区间已移动";多处 ⇒ 既有 `needs_choice`;
2156
+ * 零处 ⇒ 拒。
2157
+ *
2158
+ * 「账本里那条观测不在了」「观测里那段字节的摘要与句柄不符」都归零处命中:**不猜**(契约 §3)。
2159
+ * 搜索只走逐字层(不用 {@link findWindowTier} 的空白折叠层):折叠层会把"内容已被别人
2160
+ * 改过"读成一次移动,而移动意味着 `{handle, replace}` 要去覆盖一段**不是调用方读过**的字节。
2161
+ */
2162
+ function resolveHandleRegion(lines, parsed, ctx) {
2163
+ if (identityTag(ctx.ledgerKey) !== parsed.idTag) {
2164
+ throw new PatchError(
2165
+ `this handle was minted for another file (or another path spelling) than ${ctx.label} — re-read that file and use the handle from that read; nothing was written`,
2166
+ 'PATCH_HANDLE_IDENTITY_MISMATCH',
2167
+ )
2168
+ }
2169
+ const own = regionRowsAt(lines, parsed.startLine, parsed.endLine)
2170
+ if (own !== null && regionFingerprint(own.join('\n'), ctx.byteOrderMark) === parsed.fingerprint) {
2171
+ return { startLine: parsed.startLine, endLine: parsed.endLine, rows: own, moved: false }
2172
+ }
2173
+ const observation = latestObservation(ctx.ledgerKey, { source: ctx.content })
2174
+ if (observation === null) throw handleStaleError(ctx.label)
2175
+ const thenRows = regionRowsAt(observation.source.split(/\r?\n/), parsed.startLine, parsed.endLine)
2176
+ if (thenRows === null) throw handleStaleError(ctx.label)
2177
+ const thenText = thenRows.join('\n')
2178
+ // 观测里那段字节的摘要必须逐字等于句柄带的摘要:不等 ⇒ 这个句柄不是从这条观测铸出来的,不猜。
2179
+ if (regionFingerprint(thenText, ctx.byteOrderMark) !== parsed.fingerprint) throw handleStaleError(ctx.label)
2180
+ const starts = windowStarts(lines, thenRows, (text) => text)
2181
+ if (starts.length === 0) throw handleStaleError(ctx.label)
2182
+ if (starts.length > 1) {
2183
+ throw oldAmbiguityError(
2184
+ lines,
2185
+ starts.map((start) => ({ start, span: thenRows.length })),
2186
+ ctx.label,
2187
+ starts.length,
2188
+ ctx,
2189
+ )
2190
+ }
2191
+ const [start] = starts
2192
+ return { startLine: start + 1, endLine: start + thenRows.length, rows: thenRows, moved: true }
2193
+ }
2194
+
2195
+ /**
2196
+ * 句柄在场时**不决定位置**的那些地址字段(D7:句柄优先,但让开的一方必须披露)。
2197
+ *
2198
+ * 只列**真的在场**的:`lines` / `start_line` / `end_line` 是调用方从上一版视图拄来的绝对坐标,
2199
+ * `target` / `line_cue` / `anchor` 是另外三种地址形态,`old` / `line` / `position` 是三种相对
2200
+ * 定位器(被选中时不算让开——只有 `{handle, old}` 这一种组合要求 `old` 命中区间内)。
2201
+ */
2202
+ function ignoredHandleNotes(hunk, chosen) {
2203
+ const names = []
2204
+ if (chosen !== 'old' && oldAddressOf(hunk) !== null) names.push('old')
2205
+ if (chosen !== 'line' && hunk.line !== undefined && hunk.line !== null) names.push('line')
2206
+ if (chosen !== 'position' && hunk.position !== undefined && hunk.position !== null) names.push('position')
2207
+ const coords = ['lines', 'range', 'start_line', 'end_line'].filter(
2208
+ (key) => hunk[key] !== undefined && hunk[key] !== null,
2209
+ )
2210
+ if (coords.length > 0) names.push(coords.join('/'))
2211
+ for (const key of ['target', 'line_cue', 'anchor']) {
2212
+ if (typeof hunk[key] === 'string' && hunk[key].trim().length > 0) names.push(key)
2213
+ }
2214
+ return names.length === 0 ? [] : [`ignored (the handle decides the region): ${names.join(', ')}`]
2215
+ }
2216
+
2217
+ /**
2218
+ * `{handle, old}`:在**句柄那段区间内部**按逐字片段定位,区间之外不找。
2219
+ *
2220
+ * 与 {@link locateByOld} 同一套层(整行对齐 → 片段)、同一套收敛(`context` 收紧 / 自动扩大
2221
+ * 重试一次 / `near` 就近),差别只有两处:**搜索面是区间自己**,以及找不到时的码不是
2222
+ * `PATCH_NOT_FOUND` 而是 `PATCH_HANDLE_QUOTE_OUTSIDE`——那时真正的问题是"你给的引文不在
2223
+ * 你给的区间里",报"文件里找不到"会把调用方赶去重读整份文件,而它只需重新挑一段引文。
2224
+ *
2225
+ * **不许**在找不到时退化成整段替换:那是把"你说的这一段"偷偷换成"整个区间",
2226
+ * 正是本项目最忌的那类静默错配(契约 §5 第 4 条)。
2227
+ */
2228
+ function locateOldInRegion(lines, region, hunk, oldText, ctx) {
2229
+ const regionRows = region.rows
2230
+ const oldRows = addressRows(oldText)
2231
+ const aligned = findWindowTier(regionRows, oldRows)
2232
+ /** 候选统一写成 `{start, span}` 且 start 是**区间内**的 0-based 行号(与 {@link locateByOld} 同形)。 */
2233
+ let candidates = aligned.starts.map((start) => ({ start, span: oldRows.length }))
2234
+ let tierKey = aligned.tier
2235
+ let norm = aligned.norm
2236
+ let byFragment = false
2237
+ if (candidates.length === 0) {
2238
+ const fragment = findFragmentTier(regionRows, oldText)
2239
+ if (fragment.spans.length === 0) {
2240
+ throw new PatchError(
2241
+ `the \`old\` text you gave is not inside the region this handle covers (lines ${region.startLine}-${region.endLine} in ${ctx.label}) — nothing was written`,
2242
+ 'PATCH_HANDLE_QUOTE_OUTSIDE',
2243
+ )
2244
+ }
2245
+ candidates = fragment.spans
2246
+ tierKey = fragment.tier
2247
+ norm = fragment.norm
2248
+ byFragment = true
2249
+ }
2250
+ const ctxRows =
2251
+ typeof hunk.context === 'string' && hunk.context.trim().length > 0 ? addressRows(hunk.context) : []
2252
+ const total = candidates.length
2253
+ const frag = (c) => byFragment && typeof c?.text === 'string'
2254
+ let narrowed = ''
2255
+ if (candidates.length > 1 && ctxRows.length > 0) {
2256
+ const tight = candidates.filter((c) => contextSitsAt(regionRows, c.start, c.span, ctxRows, norm, 0))
2257
+ if (tight.length === 1) {
2258
+ candidates = tight
2259
+ narrowed = '+context'
2260
+ } else {
2261
+ const base = tight.length > 0 ? tight : candidates
2262
+ const wide = base.filter((c) => contextSitsAt(regionRows, c.start, c.span, ctxRows, norm, ctxRows.length))
2263
+ if (wide.length === 1) {
2264
+ candidates = wide
2265
+ narrowed = '+expanded'
2266
+ } else if (wide.length > 0) candidates = wide
2267
+ }
2268
+ }
2269
+ let byNear = false
2270
+ if (candidates.length > 1 && hunk.near !== undefined && hunk.near !== null) {
2271
+ const chosen = nearestOnly(candidates.map((c) => region.startLine + c.start), hunk.near)
2272
+ if (chosen !== null) {
2273
+ candidates = candidates.filter((c) => region.startLine + c.start === chosen)
2274
+ byNear = true
2275
+ }
2276
+ }
2277
+ if (candidates.length > 1) {
2278
+ throw oldAmbiguityError(
2279
+ lines,
2280
+ candidates.map((c) => ({ ...c, start: region.startLine - 1 + c.start })),
2281
+ ctx.label,
2282
+ total,
2283
+ ctx,
2284
+ )
2285
+ }
2286
+ const [hit] = candidates
2287
+ const startLine = region.startLine + hit.start
2288
+ const endLine = startLine + hit.span - 1
2289
+ const addressed = frag(hit) ? hit.text : regionRows.slice(hit.start, hit.start + hit.span).join('\n')
2290
+ return {
2291
+ startLine,
2292
+ endLine,
2293
+ tier: `${HANDLE_TIER}+old${tierKey === 'exact' ? '' : `~${tierKey}`}${narrowed}`,
2294
+ actual: addressed,
2295
+ ...(frag(hit)
2296
+ ? {
2297
+ fragment: {
2298
+ line: startLine,
2299
+ endLine,
2300
+ columns: [hit.startCol, hit.endCol],
2301
+ ...(hit.endLineCol === undefined ? {} : { endLineCol: hit.endLineCol }),
2302
+ text: hit.text,
2303
+ },
2304
+ }
2305
+ : {}),
2306
+ ...(frag(hit) ? { fragmentNote: fragmentNoteFor(startLine, hit.startCol, hit.endCol, hit.text) } : {}),
2307
+ ...(byNear ? { byNear: true } : {}),
2308
+ }
2309
+ }
2310
+
2311
+ /**
2312
+ * 句柄寻址的定位本体:四种形状的**次序**就在这里(契约 §5 已冻结)。
2313
+ *
2314
+ * 优先级:`position` > `line` > `old` > 整段。它们都是**区间内**的相对定位,
2315
+ * 所以句柄先定下区间,形状再在区间里定点;被让开的字段走 `tier` 披露({@link ignoredHandleNotes})。
2316
+ *
2317
+ * `handleRegion` 是给闸门用的:句柄的凭据覆盖**整个区间**,而 `line: k` / `old` 只是区间内部的
2318
+ * 相对定位 ⇒ 闸门要比对的是整个区间,不是这次替换的那一小段({@link staleConflicts})。
2319
+ */
2320
+ function locateByHandle(lines, hunk, parsed, ctx) {
2321
+ const region = resolveHandleRegion(lines, parsed, ctx)
2322
+ const total = region.rows.length
2323
+ const regionText = region.rows.join('\n')
2324
+ const handleRegion = { startLine: region.startLine, endLine: region.endLine, text: regionText }
2325
+ const notes = region.moved
2326
+ ? [`region moved: lines ${parsed.startLine}-${parsed.endLine} → ${region.startLine}-${region.endLine}`]
2327
+ : []
2328
+ const position = hunk.position === undefined || hunk.position === null ? null : hunk.position
2329
+ if (position !== null && position !== 'before' && position !== 'after') {
2330
+ throw new PatchError(
2331
+ `\`position\` must be "before" or "after" (got ${JSON.stringify(position)}) — nothing was written`,
2332
+ 'PATCH_HANDLE_SHAPE',
2333
+ )
2334
+ }
2335
+ const gaveLine = position === null && hunk.line !== undefined && hunk.line !== null
2336
+ const relLine = gaveLine ? toLineNumber(hunk.line) : null
2337
+ if (gaveLine && relLine === null) {
2338
+ throw new PatchError(
2339
+ `\`line\` must be an integer row inside the handle's region (got ${JSON.stringify(hunk.line)}) — nothing was written`,
2340
+ 'PATCH_HANDLE_SHAPE',
2341
+ )
2342
+ }
2343
+ const oldText = position !== null || relLine !== null ? null : oldAddressOf(hunk)
2344
+ const chosen = position !== null ? 'position' : relLine !== null ? 'line' : oldText !== null ? 'old' : 'region'
2345
+ notes.push(...ignoredHandleNotes(hunk, chosen))
2346
+ const finish = (shape) => ({
2347
+ ...shape,
2348
+ handleRegion,
2349
+ tier: notes.length === 0 ? shape.tier : `${shape.tier} (${notes.join('; ')})`,
2350
+ })
2351
+ if (chosen === 'position') {
2352
+ /**
2353
+ * 边界插入:零宽度区间(`loc.start === loc.end`)⇒ 拼接层把它当插入,区间本身一个字节不动。
2354
+ * `before` 插在区间首行之前,`after` 插在区间末行之后(都不是"区间外"的模糊地带,就是边界)。
2355
+ */
2356
+ const at = position === 'before' ? region.startLine : region.endLine + 1
2357
+ return finish({ startLine: at, endLine: at - 1, tier: `${HANDLE_TIER} ${position}`, actual: '' })
2358
+ }
2359
+ if (chosen === 'line') {
2360
+ if (relLine < 1 || relLine > total) {
2361
+ throw new PatchError(
2362
+ `handle region lines ${region.startLine}-${region.endLine} has ${total} line(s); line ${relLine} is outside it (\`line\` counts from the region start, 1-based) — nothing was written`,
2363
+ 'PATCH_HANDLE_LINE_OUT_OF_RANGE',
2364
+ )
2365
+ }
2366
+ const at = region.startLine + relLine - 1
2367
+ const text = region.rows[relLine - 1]
2368
+ return finish({ startLine: at, endLine: at, tier: `${HANDLE_TIER} line ${relLine}`, actual: text })
2369
+ }
2370
+ if (chosen === 'old') return finish(locateOldInRegion(lines, region, hunk, oldText, ctx))
2371
+ return finish({
2372
+ startLine: region.startLine,
2373
+ endLine: region.endLine,
2374
+ tier: HANDLE_TIER,
2375
+ actual: regionText,
2376
+ })
2377
+ }
2378
+
2379
+ /**
2380
+ * 规划所有 hunk:逐个定位 → expect 核对 → 重叠检查 → 倒序拼接。
2381
+ * 任一 hunk 失败即抛错,调用方据此保证磁盘不变(事务性来自"先全部规划完再落盘")。
2382
+ *
2383
+ * 定位本身不在这里——它调 {@link locate}(框架无关的定位服务)或 {@link locateByOld}
2384
+ * (内容寻址),本函数只负责 DSH 侧的编排:多 hunk 的事务语义、expect 闸门、倒序拼接。
2385
+ *
2386
+ * ## 地址的主次(fs-contract-v2)
2387
+ *
2388
+ * `old`(要替换的原文,+可选 `context`)是**主地址**:给了它就按内容定位,不跟到符号/线索层。
2389
+ * 坐标(`lines` / `start_line` / `end_line`)降为**提示**:与 `old` 冲突时以 `old` 为准,
2390
+ * 并在回执里明说“坐标与原文不符,已按原文定位”(`applied[].tier` 里带那句话)。
2391
+ *
2392
+ * @param content - 文件全文
2393
+ * @param hunks - 每个 { handle?, line?, position?, anchor?, target?, old?, context?, replace, expect?, near? }
2394
+ * (`handle` / `line` / `position` 三种是 fs-contract-v2 的句柄形状,见 {@link locateByHandle})
2395
+ * @param filePath - 用于判定语言(符号路径需要);缺省则符号路径自动不可用
2396
+ * @param options.identityKey - **宿主给的身份键**(同一份盘上文件必给同一个串):句柄里的身份标签
2397
+ * 就是拿同一个 `ledgerIdentityKey` 口径算的,不给就只按 `filePath` 自己算(与读侧同形)。
2398
+ * @param options.byteOrderMark - 当前文件是否有前导 BOM(**摘要的一半**,只能从字节取)。
2399
+ * 漏传的后果是句柄在这份文件上永远对不上 ⇒ 一律 `PATCH_HANDLE_STALE`(拒绝,不是改错地方)。
2400
+ * @returns {{matches: Array, content: string, applied: Array}} 新内容与逐条回执
2401
+ */
2402
+ export function planHunks(content, hunks, filePath = null, options = {}) {
2403
+ const { lines, endings } = splitLinesWithEndings(content)
2404
+ const matches = []
2405
+ /**
2406
+ * 句柄路的上下文:账本键、BOM、回执里的说法。
2407
+ *
2408
+ * 账本键只算一次({@link ledgerIdentityKey} 是全引擎唯一那道归一):句柄里的身份标签
2409
+ * 就是拿它算的,这里重算一遍才能核对(D5:两面各算各的,不一致就拒)。
2410
+ */
2411
+ const handleCtx = {
2412
+ content,
2413
+ ledgerKey: ledgerIdentityKey(filePath, options.identityKey),
2414
+ byteOrderMark: options.byteOrderMark === true,
2415
+ label: filePath === null ? 'this file' : filePath,
2416
+ }
2417
+ // `hunk: index` 是给区域级新鲜度闸门用的回指:matches 会按位置排序,
2418
+ // 而闸门要按**逐个 hunk** 比对它的区域,不能靠顺序猜。
2419
+ for (const [hunkIndex, hunk] of hunks.entries()) {
2420
+ const hasTarget = typeof hunk.target === 'string' && hunk.target.trim().length > 0
2421
+ // 行区间走共用收敛层:`[4,6]`、`"4,6"`、`4`、`start_line/end_line` 都认。
2422
+ // 模型把 `lines` 写成 `"4,6"`、只给单行、或写成 `start_line` 是常态;
2423
+ // 为此回一句"参数不对"让它重试一遍,纯是浪费一轮。
2424
+ const range = lineRangeOf(hunk)
2425
+ const hasCue = typeof hunk.line_cue === 'string' && hunk.line_cue.trim().length > 0
2426
+ const hasAnchor = typeof hunk.anchor === 'string' && hunk.anchor.length > 0
2427
+ const oldText = oldAddressOf(hunk)
2428
+ /** 句柄的形状在这里判(不对就抛错);`{handle, old}` 那一支的 `old` 仍然要命中区间内部。 */
2429
+ const parsedHandle = handleAddressOf(hunk)
2430
+ /**
2431
+ * `line` / `position` 是**句柄形状的从属键**:只有和 `handle` 同时在场才成立。
2432
+ *
2433
+ * 为什么必须在这里拒绝(复核实测出来的一条真回归):{@link lineRangeOf} 把 `source.line`
2434
+ * 当行区间读(那是历史写法),于是 `{line: 2, replace}` —— `lines` 最经典的那一次手误 ——
2435
+ * 会走 `lines` 那一档**成功**:一次我行我素的错误变成一次静默改到别处的编辑,
2436
+ * 正是本项目最忌的那一类。本仓本来就有只眼睛盯着它(`range-args-test` 段B
2437
+ * 「hunk 字段名写错(line 而非 lines)」),这里把那条判据钉在**引擎**里——
2438
+ * 每个入口各自兜住就是两份判据,而不止一个入口(数组形 / 平铺形 / CLI / MCP)。
2439
+ *
2440
+ * 码取 `PATCH_NO_ADDRESS`:它是"这个 hunk 一种地址都没成形"那一族,且**刻意不进**壳层的
2441
+ * 结果通道(参数没成形要大声抛错,与"地址对不上"是两件事)。
2442
+ */
2443
+ if (parsedHandle === null && hunk.line !== undefined && hunk.line !== null) {
2444
+ throw new PatchError(
2445
+ '`line` needs `handle`: it addresses a row **inside** the region a handle names — for an absolute line range use `lines` / `start_line`+`end_line`; nothing was written',
2446
+ 'PATCH_NO_ADDRESS',
2447
+ )
2448
+ }
2449
+ if (parsedHandle === null && hunk.position !== undefined && hunk.position !== null) {
2450
+ throw new PatchError(
2451
+ '`position` needs `handle`: it inserts at the edge of the region a handle names — for an absolute line range use `lines` / `start_line`+`end_line`; nothing was written',
2452
+ 'PATCH_NO_ADDRESS',
2453
+ )
2454
+ }
2455
+ if (oldText === null && parsedHandle === null && !hasTarget && range === undefined && !hasCue && !hasAnchor) {
2456
+ throw new PatchError(
2457
+ gaveLineAddress(hunk) ? LINE_ADDRESS_HINT : 'each hunk needs one of: old, target, lines, line_cue, anchor',
2458
+ 'PATCH_NO_ADDRESS',
2459
+ )
2460
+ }
2461
+ let hit
2462
+ if (parsedHandle !== null) {
2463
+ /**
2464
+ * 句柄优先(D7):它在场时 `target` / `lines` / `line_cue` / `anchor` **一律不决定位置**,
2465
+ * 坐标(含 `expect` 之外的地址字段)与句柄冲突时被让开的一方走 `tier` 披露。
2466
+ * 句柄是工具自己那份逐字节副本,拄的 `old` 正是不可信的那一半。
2467
+ */
2468
+ hit = locateByHandle(lines, hunk, parsedHandle, handleCtx)
2469
+ } else if (oldText !== null) {
2470
+ /**
2471
+ * `old` 在场 ⇒ 走内容寻址,**其余地址字段一律不再决定位置**(坐标只用于核对与披露)。
2472
+ * 与 `target` 同时给时以 `old` 为准,同 {@link locateByOld} 的注释:
2473
+ * 一段可以被验证的原文,比一个可能来自旧视图的坐标/名字更接近“你想改的是这里”。
2474
+ */
2475
+ hit = locateByOld(lines, hunk, oldText, filePath === null ? 'this file' : filePath, handleCtx)
2476
+ } 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
+ })
2486
+ if (!found.ok) {
2487
+ throw new PatchError(found.message, found.code, {
2488
+ ...(found.candidates === undefined ? {} : { candidates: found.candidates }),
2489
+ })
2490
+ }
2491
+ hit = {
2492
+ startLine: found.startLine,
2493
+ endLine: found.endLine,
2494
+ tier: found.tier,
2495
+ actual: found.actual,
2496
+ ...(found.byNear === true ? { byNear: true } : {}),
2497
+ }
2498
+ }
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 }),
2510
+ /**
2511
+ * 地址形态与调用方字面写法不符的**结构化**记录(`matches` 上;`applied` 不能加字段,见
2512
+ * `locateByOld`):片段层的跨度比整行窄、坐标与落点不一致——两条都回指事实,
2513
+ * 让消费方能自己判断,而不必去解析 `tier` 里那句话。
2514
+ */
2515
+ ...(hit.fragmentNote === undefined ? {} : { fragmentNote: hit.fragmentNote }),
2516
+ /**
2517
+ * 句柄 hunk 多带一条:**句柄定下的整段区间**(坐标 + 字节)。
2518
+ *
2519
+ * 闸门拿它去和账本里那一段比对({@link staleConflicts}):句柄的凭据覆盖整个区间,
2520
+ * 而 `line: k` / `old` 只是区间**内部**的相对定位——比错了一方就会误报。
2521
+ */
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)
2548
+ }
2549
+ matches.push({ ...loc, hunk: hunkIndex, replace: hunk.replace ?? '', actual })
2550
+ }
2551
+ matches.sort((a, b) => a.start - b.start)
2552
+ for (let i = 1; i < matches.length; i += 1) {
2553
+ if (matches[i].start < matches[i - 1].end) {
2554
+ throw new PatchError(
2555
+ `hunks overlap: line ${matches[i - 1].line} and line ${matches[i].line}`,
2556
+ 'PATCH_OVERLAP',
2557
+ )
2558
+ }
2559
+ }
2560
+ let out = lines.slice()
2561
+ let outEndings = endings.slice()
2562
+ for (const m of [...matches].reverse()) {
2563
+ /**
2564
+ * 替换区域的拼接:
2565
+ * · `replace` 内部的空行/行尾**照 replace 自己写**(段C:文件是 CRLF,replace 带 LF 就写 LF);
2566
+ * · 区域**末尾**的行尾继承被替换区域最后一行的行尾——这就是“只改被寻址的那一段”中
2567
+ * “行尾不变”的那一半。
2568
+ *
2569
+ * 片段层(`m.fragment`)多一步:被替换的不是整行,而是行内的一段。这时把该行前缀接在
2570
+ * `replace` 第一行前、后缀接在最后一行后(C13)。代价是行内替换没有“行尾可继承”——
2571
+ * 行尾本来就不在被替换的那一段里,自然逐字不动。`replace` 为空时前后缀在同一条行上合拢,
2572
+ * 于是“删掉行内一段”不会凭空多出一个空行。
2573
+ */
2574
+ const rep = m.replace === '' ? { lines: [], endings: [] } : splitLinesWithEndings(m.replace)
2575
+ if (m.fragment !== undefined) {
2576
+ const line = out[m.start]
2577
+ const [startCol, endCol] = m.fragment.columns
2578
+ const prefix = line.slice(0, startCol - 1)
2579
+ const tail = line.slice(endCol)
2580
+ /**
2581
+ * **去掉 `splitLinesWithEndings` 末尾那个构造空行**:`"a\nb"` 切成 `['a','b','']`、行尾
2582
+ * `['\n','','']`——真写进文件就是凭空多一行。整行版无所谓(那一个空行正好被丢掉),
2583
+ * 行内版留着就会把原行的行尾挤到下一行去(实测:`beta GAMMA\n` + `\n` + `delta`)。
2584
+ */
2585
+ const repLines = rep.lines.length > 1 && rep.lines[rep.lines.length - 1] === '' ? rep.lines.slice(0, -1) : rep.lines
2586
+ const parts =
2587
+ repLines.length === 0
2588
+ ? [`${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] ?? '']
2595
+ /**
2596
+ * 回执里的 `replaced`:**被替换那一段的新文本**,不掺前后缀——就是调用方给的 `replace`
2597
+ * (拿 `parts` 当 `replaced` 会把前后缀也算进去,回执就不再是“什么被换成了什么”)。
2598
+ */
2599
+ m.replacedText = rep.lines.join('\n')
2600
+ out = out.slice(0, m.start).concat(parts, out.slice(m.end))
2601
+ outEndings = outEndings.slice(0, m.start).concat(partEndings, outEndings.slice(m.end))
2602
+ continue
2603
+ }
2604
+ /**
2605
+ * 区域末行的行尾继承前一行;**插入在文件头**(`m.end === 0`)时前面没有行可继承,
2606
+ * 取后一行的行尾——否则插进去的那一行会与第 1 行粘成一行。
2607
+ * `m.end === 0` 只有零宽度的边界插入(`position: before` 且区间从第 1 行开始)能造出来:
2608
+ * 既有七种地址形状下 `m.end ≥ 1` 恒成立,所以这一改**动不到既有语义**。
2609
+ */
2610
+ if (rep.lines.length > 0) {
2611
+ rep.endings[rep.endings.length - 1] = outEndings[m.end - 1] ?? outEndings[m.end] ?? ''
2612
+ }
2613
+ out = out.slice(0, m.start).concat(rep.lines, out.slice(m.end))
2614
+ outEndings = outEndings.slice(0, m.start).concat(rep.endings, outEndings.slice(m.end))
2615
+ }
2616
+ /**
2617
+ * 逐条回执:**一条 hunk 一条记录,每条只描述它自己那条 hunk**;数组顺序 = 调用方的**输入顺序**。
2618
+ *
2619
+ * ## 顺序为什么是输入顺序
2620
+ *
2621
+ * 调用方给的是一个数组。`applied[k] ↔ 输入的第 k 条 hunk` 是唯一不需要任何换算的对应关系,
2622
+ * 而回执的全部用处就是让调用方逐条核对自己给的那几条 hunk。这里曾经是 `[...matches].reverse()`:
2623
+ * 那是**倒序拼接**(从文件尾往头改,行号才不会被自己改乱)的实现顺序泄漏进了回执 —— 数组按
2624
+ * 文档位置从后往前排,多目标逐项切分({@link splitReceiptForMember})也照那个倒序索引,
2625
+ * 于是调用方按输入顺序读回执会整批错位。
2626
+ *
2627
+ * ## `line` 用**改动前**的坐标(1-based)
2628
+ *
2629
+ * 契约就是它:红线套件 `single-target-nonregression-test` 的黄金集逐字节钉着 `line` 的取值,
2630
+ * 而那里的 `line` 一直是改前行号。它也是唯一一套调用方**自己算得出来**的坐标:改后的行号
2631
+ * 取决于同一次调用里**别的** hunk 增删了几行,而调用方手里只有改前那份文件。
2632
+ * 于是同一次调用的多条记录各自说的是“我落在改前文件的第几行”,互相之间可比、可核对。
2633
+ *
2634
+ * ## `replaced` = 这一条 hunk **自己写下去的文本**
2635
+ *
2636
+ * 不再从拼接结果里按 `m.start` 回读。`m.start` 是**改动前**的行下标,而 `out` 是全部 hunk
2637
+ * 拼完之后的结果 ⇒ 只要位置更低的那条 hunk 增删过行,高位这条就会读到**别的行**:实测
2638
+ * (把 `a2` 换成两行、把 `A6` 换成 `Y6`)回执给第 6 行报的 `replaced` 是 `a5`。真实语料里
2639
+ * 18,851 个落点中 5,555 个可观测、其中 1,268 个因此对不回调用方给的任何一条 hunk
2640
+ * (`probes/indent-loss-census.mjs` 的读数)—— 而“核对是不是改成了我要的样子”正是回执存在
2641
+ * 的理由,串了行的 `replaced` 恰好把这件事变成一次猜测。
2642
+ *
2643
+ * 口径 = `replace` 的**行文本**({@link replaceTextOf}):行尾是被替换区域继承的、不是调用方
2644
+ * 写的那一份,不进回执。片段层在拼接时记下的 `replacedText` 与此同式,这里沿用它,
2645
+ * 免得同一样东西出现第二份口径。
2646
+ */
2647
+ const applied = [...matches]
2648
+ .sort((a, b) => a.hunk - b.hunk)
2649
+ .map((m) => ({
2650
+ line: m.line,
2651
+ tier: m.tier,
2652
+ byNear: m.byNear === true,
2653
+ actual: m.actual,
2654
+ replaced: m.replacedText ?? replaceTextOf(m.replace),
2655
+ }))
2656
+ return { matches, content: joinLinesWithEndings(out, outEndings), applied }
2657
+ }
2658
+
2659
+ /**
2660
+ * 这份文件开头有几个 UTF-8 BOM。
2661
+ *
2662
+ * 只能看字节:提供方的 `readText` 用 `new TextDecoder('utf-8', {fatal:true})` 解码,
2663
+ * `ignoreBOM` 默认 false ⇒ **第一个** BOM 不进字符串(后面的会以 U+FEFF 留在文本里);
2664
+ * 回写用 TextEncoder,从不产 BOM。两者叠起来就是“任何一次编辑都永久丢掉 BOM”,没有信号——
2665
+ * 因此判据取原始字节(`readByteRange`;aura-read 里有一份同形的探测,两处都是胶水,判据只有一份)。
2666
+ *
2667
+ * 窗口按需翻倍:头部三个字节里没有 BOM ⇒ 0 个(一次读完);正好是 BOM ⇒ 可能还有,
2668
+ * 把窗口加倍再读,直到“出现非 BOM”或“窗口没被填满”(读到文件尾)。
2669
+ * 不设固定上限,也就不会把三个 BOM 的文件静默当成两个;每次读都从 offset 0 开始,
2670
+ * 实际代价是 1~2 次几字节的读(只有文件确实以 BOM 开头时才会多读一次)。
2671
+ *
2672
+ * 测试 mock 可能没有字节接口:那时返回 0(没拿到字节,就没有 BOM 可保)。
2673
+ */
2674
+ async function fileByteOrderMarkCount(fsService, target, signal) {
2675
+ if (typeof fsService.readByteRange !== 'function') return 0
2676
+ const width = BYTE_ORDER_MARK_BYTES.length
2677
+ let length = width
2678
+ for (;;) {
2679
+ const head = await fsService.readByteRange(target, { offset: 0, length }, signal)
2680
+ const count = countByteOrderMarks(head)
2681
+ if (count * width < head.length) return count
2682
+ const wider = length * 2
2683
+ if (head.length < length || wider === length) return count
2684
+ length = wider
2685
+ }
2686
+ }
2687
+
2688
+ /**
2689
+ * 把文件原有的前导 BOM **个数**补齐到内容里(而不是指望提供方自己保留)。
2690
+ *
2691
+ * 补齐个数 = 字节里的个数 − 内容里已有的个数,只补不删:
2692
+ * · `BOM+BOM` 的文件解码后文本里还留着一个 U+FEFF ⇒ 补 1 个(不是“已经有了就不加”);
2693
+ * · `replace` 自己把一个 U+FEFF 写进内容开头 ⇒ 那是调用方的显式意图,不删(同理于“replace 自带行尾”)。
2694
+ * 幂等因此来自**个数比较**,不来自“开头是不是 U+FEFF”这个布尔。
2695
+ */
2696
+ function carryingByteOrderMarks(text, count) {
2697
+ const have = leadingByteOrderMarks(text)
2698
+ if (have >= count) return text
2699
+ return BYTE_ORDER_MARK.repeat(count - have) + text
2700
+ }
2701
+
2702
+ /**
2703
+ * 回读窗口比“我以为写下的字节数”多读 1 字节。
2704
+ *
2705
+ * 不是经验值:窗口若正好等于意图长度,磁盘上多出来的尾部会被窗口**截断**,
2706
+ * 于是“写多了”这种最容易被忽略的改写会被比对成相等。多读 1 字节就能让它现形(长度不等)。
2707
+ */
2708
+ const READBACK_PROBE_EXTRA_BYTES = 1
2709
+ /** 行尾普查用的字节值:这两个在这里是**数据**,不是格式。 */
2710
+ const CARRIAGE_RETURN = 0x0d
2711
+ const LINE_FEED = 0x0a
2712
+
2713
+ /**
2714
+ * 提供方视角的文本 = `readText` 对同一份字节会返回的文本:解码时剥掉**恰好一个**前导 BOM
2715
+ * (`TextDecoder` 的 `ignoreBOM` 默认 false),其余 U+FEFF 原样留在文本里。
2716
+ *
2717
+ * 写后记账必须用它:存“我以为写下的内容”会在前导 BOM 上比真值多一个字符,
2718
+ * 于是下一次未断言编辑第 1 行被新鲜度闸门误拒——C13 打回项(两套内容约定)的同一族,
2719
+ * 只是这次的分歧出在 create 与 `replace` 自带 BOM 的那一侧(段F 钉住)。
2720
+ */
2721
+ function providerView(text) {
2722
+ return text.startsWith(BYTE_ORDER_MARK) ? text.slice(BYTE_ORDER_MARK.length) : text
2723
+ }
2724
+
2725
+ /** 逐元素比对:返回第一个不同的下标,完全相同返回 -1(字符串与字节数组通用)。 */
2726
+ function firstDifference(expected, actual) {
2727
+ const width = Math.min(expected.length, actual.length)
2728
+ for (let i = 0; i < width; i += 1) if (expected[i] !== actual[i]) return i
2729
+ return expected.length === actual.length ? -1 : width
2730
+ }
2731
+
2732
+ /**
2733
+ * 行尾普查(按字节)。
2734
+ *
2735
+ * 为什么失败回执要带它:C10 的 BOM 丢失与 C12 的行尾归一化都是**外面拿探针量出来的**,
2736
+ * 而“两边不相等”这句话对模型毫无用处。crlf/lf/cr 三个数直接把
2737
+ * “提供方把整份行尾归一到多数派”这类改写点名出来。
2738
+ */
2739
+ function lineEndingCensus(bytes) {
2740
+ const census = { crlf: 0, lf: 0, cr: 0 }
2741
+ for (let i = 0; i < bytes.length; i += 1) {
2742
+ if (bytes[i] === CARRIAGE_RETURN) {
2743
+ if (bytes[i + 1] === LINE_FEED) {
2744
+ census.crlf += 1
2745
+ i += 1
2746
+ } else census.cr += 1
2747
+ } else if (bytes[i] === LINE_FEED) census.lf += 1
2748
+ }
2749
+ return census
2750
+ }
2751
+
2752
+ /**
2753
+ * 落盘后**回读校验**(C14):把磁盘上的内容读回来,与“我以为写下的”逐字节比。
2754
+ *
2755
+ * 为什么取字节而不是解码文本:BOM 是**解码后彻底看不见**的改写(`readText` 剥掉一个前导 BOM),
2756
+ * 行尾在文本里虽仍可见、但“文本相等”远不等于“字节相等”。C10 与 C12 那两次踩过的坑
2757
+ * 恰好都落在字节层,而两次都是被外面的探针量出来的——这个函数就是那次探针。
2758
+ *
2759
+ * 粒度:**整份**,不抽样、不设大小阈值(窗口 = 意图字节数 + 1,见常量注释)。
2760
+ * 后端没有 `readByteRange` 时退化到文本级,回执里 `level: 'text'` 如实写明
2761
+ * “这次只比对了文本”(不把弱校验伪装成强校验)。
2762
+ *
2763
+ * 失败分两种,都抛 `WRITE_VERIFY_FAILED` 并**保留写入结果**(不回滚、也不假装成功):
2764
+ * · 读不回来(文件被删 / 不可读)⇒ `write_verify.readback_error` 带底层错误码;
2765
+ * · 读回来了但对不上 ⇒ 带两边字节数、首个差异偏移、BOM 与行尾普查。
2766
+ *
2767
+ * @param service - 文件服务
2768
+ * @param target - 已写入的目标
2769
+ * @param written - 交给提供方的**内容**(它决定了落盘字节)
2770
+ * @param displayPath - 面向模型的路径
2771
+ * @returns {Promise<{level: string, bytes?: number, chars?: number}>} 验证证据
2772
+ */
2773
+ async function verifyWriteBack(service, target, written, displayPath, signal) {
2774
+ const level = typeof service.readByteRange === 'function' ? 'bytes' : 'text'
2775
+ let expected
2776
+ let actual
2777
+ try {
2778
+ if (level === 'bytes') {
2779
+ expected = new TextEncoder().encode(written)
2780
+ actual = await service.readByteRange(
2781
+ target,
2782
+ { offset: 0, length: expected.length + READBACK_PROBE_EXTRA_BYTES },
2783
+ signal,
2784
+ )
2785
+ } else {
2786
+ expected = providerView(written)
2787
+ actual = await service.readText(target, signal)
2788
+ }
2789
+ } catch (error) {
2790
+ const readback = errorCodeOf(error)
2791
+ throw new PatchError(
2792
+ `cannot patch "${displayPath}": the write landed but could not be read back to verify it (${readback ?? 'unknown error'}) — the write was not rolled back; read the file to see what is actually there`,
2793
+ 'WRITE_VERIFY_FAILED',
2794
+ { write_verify: { level, matches: false, readback_error: readback ?? null } },
2795
+ )
2796
+ }
2797
+ const offset = firstDifference(expected, actual)
2798
+ if (offset === -1) {
2799
+ return level === 'bytes' ? { level, bytes: expected.length } : { level, chars: expected.length }
2800
+ }
2801
+ throw verifyMismatchError(level, expected, actual, offset, displayPath)
2802
+ }
2803
+
2804
+ /**
2805
+ * “读回来了但对不上”的错误:文本级只说字符数与首个差异,字节级再给 BOM 与行尾普查。
2806
+ *
2807
+ * 普查不是装饰:C10 的 BOM 丢失与 C12 的行尾归一化都是**外面拿探针量出来的**。
2808
+ * 把 crlf/lf/cr 与 BOM 有无写进错误里,模型(以及下一个读这份回执的人)不必再自己造探针。
2809
+ */
2810
+ function verifyMismatchError(level, expected, actual, offset, displayPath) {
2811
+ if (level === 'text') {
2812
+ return new PatchError(
2813
+ `cannot patch "${displayPath}": the write landed but reading it back does not match what was written (expected ${expected.length} characters, found ${actual.length}; first difference at character ${offset}) — the write was not rolled back, so re-read the file before further edits`,
2814
+ 'WRITE_VERIFY_FAILED',
2815
+ {
2816
+ write_verify: {
2817
+ level,
2818
+ matches: false,
2819
+ expected_chars: expected.length,
2820
+ actual_chars: actual.length,
2821
+ first_diff_offset: offset,
2822
+ },
2823
+ },
2824
+ )
2825
+ }
2826
+ const before = lineEndingCensus(expected)
2827
+ const after = lineEndingCensus(actual)
2828
+ const bomBefore = countByteOrderMarks(expected) > 0
2829
+ const bomAfter = countByteOrderMarks(actual) > 0
2830
+ return new PatchError(
2831
+ `cannot patch "${displayPath}": the write landed but reading it back does not match what was written (expected ${expected.length} bytes, found ${actual.length}; first difference at byte ${offset}; line endings expected crlf=${before.crlf}/lf=${before.lf}/cr=${before.cr}, found crlf=${after.crlf}/lf=${after.lf}/cr=${after.cr}; byte-order mark expected ${bomBefore ? 'present' : 'absent'}, found ${bomAfter ? 'present' : 'absent'}) — the filesystem provider stored something other than what was written; the write was not rolled back, so re-read the file before further edits`,
2832
+ 'WRITE_VERIFY_FAILED',
2833
+ {
2834
+ write_verify: {
2835
+ level,
2836
+ matches: false,
2837
+ expected_bytes: expected.length,
2838
+ actual_bytes: actual.length,
2839
+ first_diff_offset: offset,
2840
+ expected_bom: bomBefore,
2841
+ actual_bom: bomAfter,
2842
+ expected_line_endings: before,
2843
+ actual_line_endings: after,
2844
+ },
2845
+ },
2846
+ )
2847
+ }
2848
+
2849
+ /** 只读取第一层 code,避免展开 FsError 的 cause 链。 */
2850
+ function errorCodeOf(error) {
2851
+ return error && typeof error === 'object' && typeof error.code === 'string' ? error.code : undefined
2852
+ }
2853
+
2854
+ /**
2855
+ * 一次完整写回:读 → BOM 计数 → 规划 → 陈旧闸门 → 逐字节写(CAS) → 回读校验 → 刷新观测。
2856
+ *
2857
+ * 这是**与框架无关的编排**:DSH 壳、独立 CLI、MCP 服务都走它,只有 provider 不同。
2858
+ * 与 DSH 壳那边唯一的差别是能力探测——那边后端可能没有 `writeText`(要退回 `editText`),
2859
+ * 而本机 provider 总是逐字节写,因此这里没有退化路径,也没有 `PATCH_NO_BYTE_EXACT_WRITE`。
2860
+ *
2861
+ * @param provider - 四方法契约,见本文件顶部注释
2862
+ * @param request - { displayPath, hunks, dryRun?, create?, signal?, identityKey? }
2863
+ * `identityKey` = **宿主给的身份键**(同一份盘上文件必给同一个串):有它时账本记在它上面,
2864
+ * 而 `displayPath` 只管回执与诊断(模型看到的路径必须是它自己给的那个);
2865
+ * 不给 ⇒ 与本参数存在之前**逐字节一致**(独立面 CLI/MCP 的那条路由磁盘存储自己做归一,见
2866
+ * `aura-standalone/lib/observation-store.mjs` 的 `identityKey`)。
2867
+ * @returns 回执字段(与 DSH 版同形:applied / dryRun / write_verify / unverified_region / operation)
2868
+ * @throws PatchError(PATCH_STALE_REGION / WRITE_VERIFY_FAILED / PATCH_NOT_FOUND …)
2869
+ */
2870
+ async function planEditToProvider(provider, request) {
2871
+ const { displayPath, hunks = [], dryRun = false, create, signal } = request
2872
+ /** 账本的键只算一次({@link ledgerIdentityKey} 是全引擎唯一那道归一)。 */
2873
+ const ledgerKey = ledgerIdentityKey(displayPath, request.identityKey)
2874
+ const info = await provider.stat(displayPath, signal)
2875
+
2876
+ // 语义与内置 `write` 对齐:`create` **只**在文件不存在时用。它撞上已存在的文件 = 调用方意图搞错了,
2877
+ // 必须明确报错——静默忽略它去跑一个"零改动成功",最容易被误读成"文件已经建好了"。
2878
+ if (info !== undefined && typeof create === 'string') {
2879
+ throw new PatchError(`cannot create "${displayPath}": it already exists — pass hunks to change it`, 'PATCH_EXISTS')
2880
+ }
2881
+ if (info !== undefined && hunks.length === 0) {
2882
+ throw new PatchError(`cannot patch "${displayPath}": no hunks — pass at least one, or use \`create\` for a new file`, 'PATCH_NO_HUNKS')
2883
+ }
2884
+
2885
+ if (info === undefined) {
2886
+ if (typeof create !== 'string') {
2887
+ throw new PatchError(
2888
+ `cannot patch "${displayPath}": not found — to create it, pass \`create\` with the full file content`,
2889
+ 'PATCH_NOT_FOUND',
2890
+ )
2891
+ }
2892
+ return { kind: 'create', ledgerKey, displayPath, content: create, create: true, dryRun: false }
2893
+ }
2894
+
2895
+ const content = await provider.readText(displayPath, signal)
2896
+ const byteOrderMarks = await fileByteOrderMarkCount(provider, displayPath, signal)
2897
+ let planned
2898
+ try {
2899
+ /**
2900
+ * 句柄路要的**两个事实**在这里一并递下去:身份键(句柄里的身份标签拿同一口径算)
2901
+ * 与"这份文件有没有 BOM"(摘要的另一半)。两者与 {@link regionGate} 拿到的是同一对。
2902
+ */
2903
+ planned = planHunks(content, hunks, displayPath, {
2904
+ ...(request.identityKey === undefined ? {} : { identityKey: request.identityKey }),
2905
+ byteOrderMark: byteOrderMarks > 0,
2906
+ })
2907
+ } catch (error) {
2908
+ /**
2909
+ * 第二条出路:这次编辑**已经落地**了 —— 判据与理由在 {@link alreadyAppliedHits} 那一段。
2910
+ *
2911
+ * 位置上必须是这里:`planHunks` 刚抛、**任何字节都还没写**、陈旧闸门也还没跑。于是
2912
+ * “这条路一个字节都不动磁盘”是**结构性**的(不是靠回滚),与旁边那条“`dry_run` 必须排在
2913
+ * 闸门之后”的教训同源:判决与它描述的事实,必须在同一个位置上成立。
2914
+ *
2915
+ * 只对 `PATCH_NOT_FOUND` 生效。`PATCH_AMBIGUOUS`(有多处都能对上)与断言不符是另外两态。
2916
+ * 走**结果通道**而不是抛错:调用的全部价值就是让调用方知道“这次不用改”,而抛出去的错误
2917
+ * 在框架那一层只剩 `error.message`(`dsh-tools/lib/index.js` 的 `errorMessage`),
2918
+ * `already_applied` 这份结构化字段根本到不了模型眼前。
2919
+ */
2920
+ if (errorCodeOf(error) !== 'PATCH_NOT_FOUND') throw error
2921
+ const hits = alreadyAppliedHits(content, hunks)
2922
+ if (hits === null) throw error
2923
+ return { kind: 'already_applied', ledgerKey, displayPath, hits, dryRun }
2924
+ }
2925
+
2926
+ /**
2927
+ * 闸门:判据与收据都在 {@link regionGate},三个调用方共用(不在壳里再写第二份)。
2928
+ *
2929
+ * BOM 这个事实**只能从字节取**:`content` 是 `readText` 的解码结果,前导 BOM 已被解码器吃掉,
2930
+ * 所以 `content.startsWith(BOM)` **恒为 false**。而账本里的令牌是 `contentToken(source, bom)`!
2931
+ * 错一个字就永远查不到——实测后果:所有 BOM 文件的编辑回执都被标成 `unverified_region: true`。
2932
+ *
2933
+ * ## 它必须排在 `dry_run` **之前**(一次真机实测的结论)
2934
+ *
2935
+ * `dryRun` 原先排在闸门之前直接 return,于是 `dry_run: true` 的回执说 "every hunk matched",
2936
+ * 而同样的 hunks 真数落盘时闸门报 "the text you addressed moved" —— **预演没能预演出真正的判决**。
2937
+ * 一个只在写入路径上生效的闸门,配上一个说“没事”的预演,比没有预演更坏。
2938
+ */
2939
+ const gate = regionGate(hunks, content, planned, displayPath, {
2940
+ byteOrderMark: byteOrderMarks > 0,
2941
+ // 身份键往下传(有才传):闸门按**同一个磁盘文件**记账,而回执与诊断里的路径仍是 displayPath。
2942
+ ...(request.identityKey === undefined ? {} : { identityKey: request.identityKey }),
2943
+ })
2944
+ 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 }
2946
+ return {
2947
+ kind: 'patch',
2948
+ ledgerKey,
2949
+ displayPath,
2950
+ planned,
2951
+ applied: planned.applied,
2952
+ /**
2953
+ * 新内容:BOM 的**个数**只能从字节补(`content` 里那个前导 BOM 已被解码器吃掉)。
2954
+ * 计算位置与改前一致 —— 在 `dry_run` 那条早退之后(预演不做这份拼接)。
2955
+ */
2956
+ content: carryingByteOrderMarks(planned.content, byteOrderMarks),
2957
+ version: info.version,
2958
+ basis: gate.basis,
2959
+ /**
2960
+ * 落地**之前**的原文。只有多目标在“整批失败”时才会把它放进回执
2961
+ * (契约 §3.4:已落地文件的原内容要留在回执里可核对);单目标回执里没有这个字段。
2962
+ */
2963
+ original: content,
2964
+ dryRun: false,
2965
+ }
2966
+ }
2967
+
2968
+ /**
2969
+ * 阶段二:**才写**(多目标契约 §3.2)—— 每个文件一次 temp+rename 原子提交。
2970
+ *
2971
+ * `already_applied` / `dry_run` 两种规划本来就不写:**零 provider 调用**地空转过去
2972
+ * (判据在阶段一已经拿全,这一阶段不重新判任何东西)。
2973
+ *
2974
+ * @returns 是否真的调了 `writeText`
2975
+ */
2976
+ async function writeEditPlan(provider, request, plan) {
2977
+ if (plan.kind === 'create') {
2978
+ await provider.writeText(request.displayPath, plan.content, { kind: 'createIfAbsent' }, request.signal)
2979
+ return true
2980
+ }
2981
+ if (plan.kind === 'patch') {
2982
+ await provider.writeText(
2983
+ request.displayPath,
2984
+ plan.content,
2985
+ { kind: 'replaceIfVersion', version: plan.version },
2986
+ request.signal,
2987
+ )
2988
+ return true
2989
+ }
2990
+ return false
2991
+ }
2992
+
2993
+ /**
2994
+ * 阶段三:**写后回读校验**(多目标契约 §3.3)—— 与今天同一条路径:
2995
+ * `verifyWriteBack` → 刷新观测账本 → 装配回执(`applied` / `write_verify` / `unverified_region`)。
2996
+ *
2997
+ * 回读失败**不回滚**(与今天单目标同一条:`WRITE_VERIFY_FAILED` 保留写入结果并如实报出)。
2998
+ */
2999
+ async function finishEditPlan(provider, request, plan) {
3000
+ const { displayPath, signal } = request
3001
+ if (plan.kind === 'already_applied') {
3002
+ return {
3003
+ path: displayPath,
3004
+ /**
3005
+ * 结果里的第四态(另外三态见 DSH 壳的 `OUTCOMES`)。它**不**进 `applied`:
3006
+ * `applied` 的语义是“这次写下去的 hunk”,而这一态一个字节都没写 ——
3007
+ * 把它塞进 `applied`,会让“到底改成了没有”重新变成一次猜测。
3008
+ */
3009
+ outcome: 'already_applied',
3010
+ applied: [],
3011
+ already_applied: plan.hits,
3012
+ unchanged: true,
3013
+ dryRun: plan.dryRun,
3014
+ }
3015
+ }
3016
+ if (plan.kind === 'dry_run') return { path: displayPath, applied: plan.applied, dryRun: true }
3017
+ const verification = await verifyWriteBack(provider, displayPath, plan.content, displayPath, signal)
3018
+ const view = providerView(plan.content)
3019
+ const byteOrderMark = plan.content.startsWith(BYTE_ORDER_MARK)
3020
+ rememberRead(plan.ledgerKey, contentToken(view, byteOrderMark), view, byteOrderMark)
3021
+ if (plan.kind === 'create') {
3022
+ return {
3023
+ path: displayPath,
3024
+ operation: 'create',
3025
+ applied: [{ line: 1, tier: 'create', actual: '', replaced: plan.content }],
3026
+ dryRun: false,
3027
+ write_verify: verification,
3028
+ }
3029
+ }
3030
+ return {
3031
+ path: displayPath,
3032
+ applied: plan.applied,
3033
+ dryRun: false,
3034
+ write_verify: verification,
3035
+ // 没有基准时如实标注。调用方(CLI/MCP/DSH 壳)都把它透进回执:
3036
+ // "没核对过"与"核对过且没变"必须是两句话,否则回执在撒谎。
3037
+ ...(plan.basis === 'none' ? { unverified_region: true } : {}),
3038
+ }
3039
+ }
3040
+
3041
+ export async function applyEditToProvider(provider, request) {
3042
+ const plan = await planEditToProvider(provider, request)
3043
+ await writeEditPlan(provider, request, plan)
3044
+ return finishEditPlan(provider, request, plan)
3045
+ }
3046
+
3047
+ /**
3048
+ * 多目标写侧的并发池大小(契约 §4):跨文件提交 / 读盘最多 8 个在飞。
3049
+ *
3050
+ * 这个数**不是本地真值**,而是参数面 `args.mjs` 那一份的转发(与 `MT_MAX_TARGETS` 同一条纪律):
3051
+ * 这里与读侧 `read-core.mjs` 曾各写一句 `export const MT_POOL = 8`,两份活真值恰好同值,
3052
+ * 于是两边套件同时绿、把缝盖住了。取值只有一个来源:上面的 `import` 建本地绑定供下面三处
3053
+ * `mtPool(MT_POOL, …)` 用,再用 `export { MT_POOL }` 把这个**同一绑定**转出去
3054
+ * (**不是** `export … from`:那种写法不建本地绑定,本文件自己那三处会当场 `ReferenceError`)。
3055
+ * 导出名与取值逐字未变(`multi-edit-test.mjs` 从这里取 `MT_POOL`)。
3056
+ */
3057
+ export { MT_POOL }
3058
+
3059
+ /**
3060
+ * 多目标逐项回执里带“落地前原文”的上限(契约 §3.4 与 §5 一起读:**要能核对**,也**不许撑爆回执**)。
3061
+ * 超了就截断并在同一项里标 `original_truncated`,不静默少给。
3062
+ */
3063
+ const MT_ORIGINAL_CHARS = 4000
3064
+
3065
+ /**
3066
+ * 错误码 → **下一步怎么走**(多目标回执每项的 `next`)。
3067
+ * 一句话、可执行:模型拿到的若是“失败了”,它下一轮只能是猜。
3068
+ */
3069
+ const MT_NEXT = {
3070
+ PATCH_NOT_FOUND: 're-read the file, then address the hunk again — the text you quoted is not in it',
3071
+ PATCH_AMBIGUOUS: 'quote more of the surrounding text so the address picks exactly one place',
3072
+ PATCH_STALE_REGION: 're-read the region first — the lines you addressed have moved',
3073
+ PATCH_OVERLAP: 'merge the overlapping hunks into one',
3074
+ PATCH_NO_ADDRESS: 'give each hunk one of: old, target, lines, line_cue, anchor, handle',
3075
+ PATCH_NO_HUNKS: 'pass at least one hunk, or use `create` for a new file',
3076
+ PATCH_EXISTS: 'the file already exists — address it with hunks instead of `create`',
3077
+ PATCH_HANDLE_STALE: 're-read that region and use the handle from that read',
3078
+ MT_CREATE_MERGE: 'give that file a single target (or, if it exists, address it with hunks)',
3079
+ WRITE_VERIFY_FAILED: 're-read the file before further edits — the write was not rolled back',
3080
+ }
3081
+
3082
+ const nextForCode = (code) => MT_NEXT[code] ?? 're-read the file, then retry that target'
3083
+
3084
+ /** 错误码 → 汇总行里的短名(`PATCH_NOT_FOUND` ⇒ `not_found`,契约 §5 那个例子就是这个口径)。 */
3085
+ const outcomeLabelOf = (code) =>
3086
+ String(code ?? 'error')
3087
+ .replace(/^(PATCH|WRITE|READ|ARGS)_/, '')
3088
+ .toLowerCase()
3089
+
3090
+ /**
3091
+ * 并发池(契约 §4 唯一允许的形式):只跑 async 的 `provider.*` 与纯计算,
3092
+ * **不 import 任何 `node:` 内置**(打包守卫盯着这一条),也不用 worker_threads。
3093
+ *
3094
+ * 结果一律由 worker 自己写进**预分配的下标槽**,所以`Promise.all` 之后装配顺序
3095
+ * 恒等于输入顺序 —— 完成顺序不外泄(契约 §1.4)。worker 自己吞异常,一个失败不会拆掉整条流水线。
3096
+ */
3097
+ async function mtPool(size, workers) {
3098
+ let next = 0
3099
+ const lane = async () => {
3100
+ for (;;) {
3101
+ const index = next
3102
+ next += 1
3103
+ if (index >= workers.length) return
3104
+ await workers[index]()
3105
+ }
3106
+ }
3107
+ const lanes = []
3108
+ for (let i = 0; i < Math.min(size, workers.length); i += 1) lanes.push(lane())
3109
+ await Promise.all(lanes)
3110
+ }
3111
+
3112
+ /**
3113
+ * 参数面的目标元素 → **单目标入口那次调用**的请求。
3114
+ *
3115
+ * 顶层 `identityKey` / `dryRun` / `signal` 是整批的默认值,元素自己给了就听元素的
3116
+ * (与“显式给的压过默认”同一口径)。
3117
+ */
3118
+ function singleRequestFrom(item, request) {
3119
+ const out = { displayPath: item.displayPath, hunks: item.hunks === undefined ? [] : item.hunks }
3120
+ if (item.create !== undefined) out.create = item.create
3121
+ const identityKey = item.identityKey === undefined ? request?.identityKey : item.identityKey
3122
+ if (identityKey !== undefined) out.identityKey = identityKey
3123
+ const dryRun = item.dryRun === undefined ? request?.dryRun : item.dryRun
3124
+ if (dryRun !== undefined) out.dryRun = dryRun
3125
+ if (request?.signal !== undefined) out.signal = request.signal
3126
+ return out
3127
+ }
3128
+
3129
+ /**
3130
+ * 按**文件**分组(契约 §1.6 同文件单写者)。
3131
+ *
3132
+ * 分组的键是 {@link ledgerIdentityKey} —— 账本、句柄、陈旧闸门用的都是它;
3133
+ * 这里若另用一份“路径相等”的判据,就会出现“账本认为是同一个文件、而分组认为是两个”
3134
+ * 那种两边都自洽、合起来错的形状。顺序 = 各文件**首次出现**的输入顺序。
3135
+ */
3136
+ function groupItemsByFile(items, request) {
3137
+ const groups = []
3138
+ const byKey = new Map()
3139
+ for (const item of items) {
3140
+ const key = ledgerIdentityKey(item.displayPath, item.identityKey ?? request?.identityKey)
3141
+ let group = byKey.get(key)
3142
+ if (group === undefined) {
3143
+ group = { index: groups.length, key, displayPath: item.displayPath, members: [], plan: null }
3144
+ byKey.set(key, group)
3145
+ groups.push(group)
3146
+ }
3147
+ item.group = group
3148
+ item.hunkOffset = group.members.reduce((sum, m) => sum + (Array.isArray(m.hunks) ? m.hunks.length : 0), 0)
3149
+ group.members.push(item)
3150
+ }
3151
+ for (const group of groups) {
3152
+ for (const member of group.members) {
3153
+ member.hunkCount = Array.isArray(member.hunks) ? member.hunks.length : 0
3154
+ member.group = group
3155
+ }
3156
+ }
3157
+ return groups
3158
+ }
3159
+
3160
+ /**
3161
+ * 一个分组的**请求**:同一文件多个目标 ⇒ hunks 合并成一次规划、一次提交(契约 §1.6)。
3162
+ *
3163
+ * 合并就是把各目标的 hunks 按输入顺序接起来交给**同一个** `planHunks` —— 于是
3164
+ * 校验、重叠判定、陈旧闸门都对**并起来的那一批**生效一次,而不是各判各的。
3165
+ *
3166
+ * `create` 是唯一不能合并的东西:它写的是一整份文件,两个 `create` 没有“并起来”的定义,
3167
+ * 因此在这里明确拒绝(阶段一抛 ⇒ 零写入),而不是挑一个执行。
3168
+ */
3169
+ function groupRequest(group, request) {
3170
+ const first = group.members[0]
3171
+ if (group.members.length > 1 && group.members.some((m) => typeof m.create === 'string')) {
3172
+ throw new PatchError(
3173
+ `cannot patch "${group.displayPath}": ${group.members.length} targets in this call name the same file and one of them uses \`create\` — \`create\` writes a whole file, so two targets cannot be merged into one commit; give that file a single target; nothing was written`,
3174
+ 'MT_CREATE_MERGE',
3175
+ )
3176
+ }
3177
+ const hunks =
3178
+ group.members.length === 1
3179
+ ? first.hunks === undefined
3180
+ ? []
3181
+ : first.hunks
3182
+ : group.members.flatMap((m) => (Array.isArray(m.hunks) ? m.hunks : []))
3183
+ return singleRequestFrom(
3184
+ {
3185
+ displayPath: group.displayPath,
3186
+ hunks,
3187
+ create: first.create,
3188
+ identityKey: first.identityKey,
3189
+ dryRun: first.dryRun,
3190
+ },
3191
+ request,
3192
+ )
3193
+ }
3194
+
3195
+ /** 回执是哪一态(`applied` / `created` / `already_applied` / `dry_run`)—— 汇总行与 `landed` 都用它。 */
3196
+ function receiptKindOf(receipt) {
3197
+ if (receipt.operation === 'create') return 'created'
3198
+ if (receipt.outcome === 'already_applied') return 'already_applied'
3199
+ if (receipt.dryRun === true) return 'dry_run'
3200
+ return 'applied'
3201
+ }
3202
+
3203
+ const inMember = (item, hunkIndex) => hunkIndex >= item.hunkOffset && hunkIndex < item.hunkOffset + item.hunkCount
3204
+
3205
+ /**
3206
+ * 合并提交**一次**,但逐项回执要对上各自那些 hunk(契约 §5):按 hunk 下标切回来。
3207
+ *
3208
+ * `applied[i]` 描述的是**输入的第 i 条 hunk**(`planHunks` 按 `hunk` 下标排),切分就用同一把尺子:
3209
+ * `matches` 是按**位置**排的,先按 `hunk` 还原成输入顺序,再逐格对齐。切完**不重排**,各项内部顺序
3210
+ * 与单目标那次一模一样。单目标分组(只有一个成员)直接返回**同一个对象**——逐字节相同因此是
3211
+ * 结构性的,不是比对出来的。
3212
+ */
3213
+ function splitReceiptForMember(receipt, group, item) {
3214
+ if (group.members.length === 1) return receipt
3215
+ if (receipt.outcome === 'already_applied') {
3216
+ return { ...receipt, already_applied: receipt.already_applied.filter((hit) => inMember(item, hit.hunk)) }
3217
+ }
3218
+ const matches = group.plan?.planned?.matches ?? []
3219
+ const byHunk = [...matches].sort((a, b) => a.hunk - b.hunk)
3220
+ const applied = receipt.applied ?? []
3221
+ const mine = []
3222
+ for (let i = 0; i < applied.length; i += 1) {
3223
+ const match = byHunk[i]
3224
+ if (match !== undefined && inMember(item, match.hunk)) mine.push(applied[i])
3225
+ }
3226
+ return mine.length === applied.length ? receipt : { ...receipt, applied: mine }
3227
+ }
3228
+
3229
+ /** 逐项回执的一条:`receipt` 就是单目标回执本体(壳层用同一套渲染)。 */
3230
+ function receiptItem(item, receipt, group) {
3231
+ const outcome = receiptKindOf(receipt)
3232
+ return {
3233
+ index: item.index,
3234
+ file_path: item.displayPath,
3235
+ outcome,
3236
+ landed: outcome === 'applied' || outcome === 'created',
3237
+ receipt: splitReceiptForMember(receipt, group, item),
3238
+ }
3239
+ }
3240
+
3241
+ /** 逐项回执的一条(失败):哪一项、什么码、`next` 怎么走(契约 §3.4)。 */
3242
+ function failureItem(item, error, stage, landed, extra = {}) {
3243
+ const code = errorCodeOf(error) ?? 'ERROR'
3244
+ const shared = item.group !== undefined && item.group.members.length > 1
3245
+ ? { shared_with: item.group.members.filter((m) => m !== item).map((m) => m.index) }
3246
+ : {}
3247
+ return {
3248
+ index: item.index,
3249
+ file_path: item.displayPath,
3250
+ outcome: 'failed',
3251
+ landed,
3252
+ stage,
3253
+ error: { code, message: error?.message ?? String(error), next: nextForCode(code) },
3254
+ ...shared,
3255
+ ...extra,
3256
+ }
3257
+ }
3258
+
3259
+ /** 已落地文件的原内容(截断到 {@link MT_ORIGINAL_CHARS},并在同一项里标明截断)。 */
3260
+ function originalEvidence(group) {
3261
+ const text = typeof group?.plan?.original === 'string' ? group.plan.original : null
3262
+ if (text === null) return {}
3263
+ if (text.length <= MT_ORIGINAL_CHARS) return { original: text, original_chars: text.length }
3264
+ return { original: text.slice(0, MT_ORIGINAL_CHARS), original_chars: text.length, original_truncated: true }
3265
+ }
3266
+
3267
+ /** 规划结果在“本轮没写”的那一项里怎么称呼(不假装已经落地)。 */
3268
+ const planKindOf = (plan) => (plan === null || plan === undefined ? 'none' : plan.kind)
3269
+
3270
+ /**
3271
+ * 多目标回执的**信封**:逐项 + 一行汇总(契约 §5)。
3272
+ *
3273
+ * 汇总行**只在这里**成形 —— 壳层把它放在正文 body 的末尾,不在别处另塞一份
3274
+ * (正文是宿主剪枝器量的东西)。逐项里的 `receipt` 是单目标回执本体,一个字都不加。
3275
+ */
3276
+ function mtEnvelope(edits, meta = {}) {
3277
+ const counts = new Map()
3278
+ let applied = 0
3279
+ let landed = 0
3280
+ for (const edit of edits) {
3281
+ const key = edit.outcome === 'failed' ? outcomeLabelOf(edit.error?.code) : edit.outcome
3282
+ counts.set(key, (counts.get(key) ?? 0) + 1)
3283
+ if (edit.outcome === 'applied' || edit.outcome === 'created') applied += 1
3284
+ if (edit.landed === true) landed += 1
3285
+ }
3286
+ const parts = [`targets: ${edits.length}`, `applied ${applied}`]
3287
+ for (const key of ['created', 'already_applied', 'dry_run', 'not_applied']) {
3288
+ if (counts.has(key)) parts.push(`${key} ${counts.get(key)}`)
3289
+ }
3290
+ const failures = [...counts.keys()].filter((key) => key !== 'applied' && !['created', 'already_applied', 'dry_run', 'not_applied'].includes(key))
3291
+ failures.sort((a, b) => counts.get(b) - counts.get(a) || (a < b ? -1 : a > b ? 1 : 0))
3292
+ for (const key of failures) parts.push(`${key} ${counts.get(key)}`)
3293
+ // 同一文件出现过多次时会合并提交:`files` 只在真的合并过时才出现(读回执的人据此知道写了几次)。
3294
+ if (meta.groups !== undefined && meta.groups !== edits.length) parts.push(`files ${meta.groups}`)
3295
+ const paths = (flag) => [...new Set(edits.filter((edit) => edit.landed === flag).map((edit) => edit.file_path))]
3296
+ if (landed === 0) parts.push('no files were changed')
3297
+ else if (landed !== edits.length) parts.push(`landed ${landed} | not_landed ${edits.length - landed}`)
3298
+ return {
3299
+ targets: edits.length,
3300
+ applied,
3301
+ summary: parts.join(' | '),
3302
+ unchanged: landed === 0,
3303
+ landed_paths: paths(true),
3304
+ ...(landed === edits.length ? {} : { not_landed_paths: paths(false) }),
3305
+ ...(meta.aborted === undefined ? {} : { aborted: meta.aborted }),
3306
+ ...(meta.partial === true ? { partial: true } : {}),
3307
+ edits,
3308
+ }
3309
+ }
3310
+
3311
+ /**
3312
+ * 多目标写侧的**唯一入口**(fs-contract-v2 / MT3;形状见契约 §2.2,提交语义见 §3)。
3313
+ *
3314
+ * 与单目标入口的分工是结构性的,不是约定:
3315
+ * · `N === 1` ⇒ **直调** {@link applyEditToProvider},零池、零包装、零额外 provider 调用;
3316
+ * 单目标那条路因此**跑不到**本文件里任何一段并发代码。
3317
+ * · `N > 1` ⇒ 阶段一(只读规划,按文件合并)→ 阶段二(逐文件原子提交,并发池)→
3318
+ * 阶段三(逐文件回读校验,与今天同一条路径)。
3319
+ *
3320
+ * 失败语义(契约 §3.4):**阶段一任一目标失败 ⇒ 零写入**,逐项报(哪一项、什么码、`next`),
3321
+ * 汇总行明说 `no files were changed`;阶段二/三中途失败 ⇒ 逐项标 `landed`,已落地那几项
3322
+ * 带上落地前原文(截断)可核对,汇总行给出 `landed` / `not_landed` 计数。**不做 `partial` 开关。**
3323
+ *
3324
+ * @param provider - 四方法契约(同单目标)
3325
+ * @param request - `{ targets: [{file_path, hunks, create?, identityKey?, dryRun?}], dryRun?, identityKey?, signal? }`
3326
+ * @returns `{targets, applied, summary, unchanged, landed_paths, edits:[{index, file_path, outcome, landed, receipt|error}]}`
3327
+ * @throws PatchError / Error(参数形状没成形:`ARGS_TARGETS_SHAPE` / `ARGS_TARGETS_LIMIT` / `ARGS_SHAPE_EXCLUSIVE`)
3328
+ */
3329
+ export async function applyEditsToProvider(provider, request) {
3330
+ const targets = assertEditTargets(request, { toolName: 'applyEditsToProvider' })
3331
+ if (targets === undefined) {
3332
+ throw Object.assign(
3333
+ new Error(
3334
+ 'applyEditsToProvider needs `targets: [{ file_path, hunks }, ...]` — the single-target shape (`file_path` + `hunks`) belongs to the single-target entry; nothing was written',
3335
+ ),
3336
+ { code: 'ARGS_TARGETS_SHAPE' },
3337
+ )
3338
+ }
3339
+ const items = editTargetRequests(targets)
3340
+ if (items.length === 1) {
3341
+ const receipt = await applyEditToProvider(provider, singleRequestFrom(items[0], request))
3342
+ const group = { index: 0, displayPath: items[0].displayPath, members: [items[0]], plan: null }
3343
+ items[0].group = group
3344
+ items[0].hunkOffset = 0
3345
+ items[0].hunkCount = Array.isArray(items[0].hunks) ? items[0].hunks.length : 0
3346
+ return mtEnvelope([receiptItem(items[0], receipt, group)], { groups: 1 })
3347
+ }
3348
+
3349
+ const groups = groupItemsByFile(items, request)
3350
+
3351
+ /** 阶段一(只读):每个文件完整规划 + 校验;一个字节都不写。并发只在读盘与纯计算里。 */
3352
+ const planFailed = new Array(groups.length).fill(null)
3353
+ await mtPool(
3354
+ MT_POOL,
3355
+ groups.map((group, index) => async () => {
3356
+ try {
3357
+ group.plan = await planEditToProvider(provider, groupRequest(group, request))
3358
+ } catch (error) {
3359
+ planFailed[index] = error
3360
+ }
3361
+ }),
3362
+ )
3363
+ if (planFailed.some((error) => error !== null)) {
3364
+ const edits = items.map((item) => {
3365
+ const failure = planFailed[item.group.index]
3366
+ if (failure !== null) return failureItem(item, failure, 'plan', false)
3367
+ return {
3368
+ index: item.index,
3369
+ file_path: item.displayPath,
3370
+ outcome: 'not_applied',
3371
+ landed: false,
3372
+ planned: planKindOf(item.group.plan),
3373
+ note: 'not written: another target in this call failed to plan, and this call writes either all of its targets or none',
3374
+ }
3375
+ })
3376
+ return mtEnvelope(edits, { groups: groups.length, aborted: 'plan' })
3377
+ }
3378
+
3379
+ /** 阶段二(才写):逐文件提交。同文件已经合并成一个分组 ⇒ 每个分组恰好一次写事务。 */
3380
+ const writeFailed = new Array(groups.length).fill(null)
3381
+ await mtPool(
3382
+ MT_POOL,
3383
+ groups.map((group, index) => async () => {
3384
+ try {
3385
+ await writeEditPlan(provider, groupRequest(group, request), group.plan)
3386
+ } catch (error) {
3387
+ writeFailed[index] = error
3388
+ }
3389
+ }),
3390
+ )
3391
+
3392
+ /** 阶段三(回读校验):只对写到这一步为止没报错的文件跑,走的是与今天同一条路径。 */
3393
+ const verified = new Array(groups.length).fill(null)
3394
+ const verifyFailed = new Array(groups.length).fill(null)
3395
+ await mtPool(
3396
+ MT_POOL,
3397
+ groups.map((group, index) => async () => {
3398
+ if (writeFailed[index] !== null) return
3399
+ try {
3400
+ verified[index] = await finishEditPlan(provider, groupRequest(group, request), group.plan)
3401
+ } catch (error) {
3402
+ verifyFailed[index] = error
3403
+ }
3404
+ }),
3405
+ )
3406
+
3407
+ const partial = writeFailed.some((error) => error !== null) || verifyFailed.some((error) => error !== null)
3408
+ const edits = items.map((item) => {
3409
+ const index = item.group.index
3410
+ if (writeFailed[index] !== null) {
3411
+ return failureItem(item, writeFailed[index], 'write', false, originalEvidence(item.group))
3412
+ }
3413
+ if (verifyFailed[index] !== null) {
3414
+ return failureItem(item, verifyFailed[index], 'verify', true, originalEvidence(item.group))
3415
+ }
3416
+ const edit = receiptItem(item, verified[index], item.group)
3417
+ // 批次没全成 ⇒ 已落地的那几项带上落地前原文,让调用方能核对磁盘上现在是什么(契约 §3.4)。
3418
+ return partial && edit.landed === true ? { ...edit, ...originalEvidence(item.group) } : edit
3419
+ })
3420
+ return mtEnvelope(edits, { groups: groups.length, partial })
3421
+ }
3422
+
3423
+ export {
3424
+ PatchError,
3425
+ // 多目标的参数面上限由 `args.mjs` 拥有(唯一一份判据);这里转出去,是为了壳层能从写侧一处拿全。
3426
+ MT_MAX_TARGETS,
3427
+ splitLinesWithEndings,
3428
+ hasMixedLineEndings,
3429
+ // 闸门三件套:判据 / 收据 / 决策。DSH 壳只调最后一个,CLI 走 `applyEditToProvider`。
3430
+ regionGate,
3431
+ staleConflicts,
3432
+ staleRegionRefusal,
3433
+ regionTextIn,
3434
+ joinLinesWithEndings,
3435
+ fileByteOrderMarkCount,
3436
+ carryingByteOrderMarks,
3437
+ READBACK_PROBE_EXTRA_BYTES,
3438
+ CARRIAGE_RETURN,
3439
+ LINE_FEED,
3440
+ providerView,
3441
+ firstDifference,
3442
+ lineEndingCensus,
3443
+ verifyWriteBack,
3444
+ verifyMismatchError,
3445
+ errorCodeOf,
3446
+ }