aura-basetools-dsh 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.
- package/LICENSE +21 -0
- package/README.md +9 -2
- package/aura-edit/index.js +1871 -0
- package/aura-read/index.js +1134 -0
- package/cordis.patch.yml +13 -0
- package/package.json +44 -4
|
@@ -0,0 +1,1871 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tool-patch —— 锚点补丁工具。
|
|
3
|
+
*
|
|
4
|
+
* 命题:agent 改文件不该输出"最终字节",而该输出"一个声明式操作 + 一段允许有偏差的锚点"。
|
|
5
|
+
* 匹配、消歧、落盘、回滚由宿主承担。相对 `edit` 的变化:
|
|
6
|
+
* 1. 四级降级匹配(exact → 空白折叠 → 行 strip → 相似度),容错缩进/空白/改词/重排;
|
|
7
|
+
* 2. `near` 位置提示把歧义锚点收敛为确定命中(grep 早就知道行号,`edit` 却只认字节);
|
|
8
|
+
* 3. 一次调用多个 hunk,且整体事务化:任一 hunk 失败则磁盘不变;
|
|
9
|
+
* 4. 降级命中时把"实际改的那段原文"作为回执带回,把不可见的错配变成可见。
|
|
10
|
+
*
|
|
11
|
+
* fs 契约与 `dsh-tool-fs` 完全一致:resolve → 尝试 fs/edit-intent 取守 → fs.editText →
|
|
12
|
+
* fs/observed。因此 fs-observation-policy 与 fs-sandbox 对本工具同样生效。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-tool-patch
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import z from '@deepseek-ai/schemastery'
|
|
18
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
19
|
+
// 结构定位器:零依赖、跨语言。只在**锚点失败**时用来给模型一份符号目录作为恢复路径,
|
|
20
|
+
// 不参与成功的定位——实测符号级定位对文本扰动免疫(100% vs 58.6%),而锚点失败里
|
|
21
|
+
// 100% 都能被符号名救回(见 locator-lab/recoverable.mjs)。
|
|
22
|
+
import { langOf } from 'aura-basetools/locator-lab/struct-core.mjs'
|
|
23
|
+
// 定位引擎本身是框架无关的,住在 locator-lab;本文件是它的 **DSH 适配器**。
|
|
24
|
+
// 这条依赖方向是刻意的:引擎不认识 Cordis,而 Cordis 侧认识引擎。
|
|
25
|
+
// 换一个 agent 框架接进来,只需要另写一个适配器,引擎一行不用动。
|
|
26
|
+
import { locate, directoryHint } from 'aura-basetools/locator-lab/locator-api.mjs'
|
|
27
|
+
// 归一化只有一份实现,住在引擎里。插件里再写一份会让"锚点匹配"与"expect 校验"两处判断
|
|
28
|
+
// 可能不一致,而那种不一致极难发现——它只在特定空白组合下才现形。
|
|
29
|
+
import { normSpace } from 'aura-basetools/locator-lab/anchor-core.mjs'
|
|
30
|
+
// 单个 hunk 的“平铺形状”容错**只有一份**(住在 args.mjs,与行区间收敛同一层):
|
|
31
|
+
// DSH 壳与 CLI 共用它。同一套容错规则写两处,就一定会有一处与另一处不一致。
|
|
32
|
+
import { assertEditTargets, assertKnownArgs, assertTargetShape, flatHunk } from 'aura-basetools/locator-lab/args.mjs'
|
|
33
|
+
// BOM 的字符形态与字节判据都住在引擎里:读路径(aura_read 的令牌)与写路径(这里)共用一份,
|
|
34
|
+
// 否则"令牌说没 BOM、写回却带 BOM"这类不自洽会两处各修一遍。
|
|
35
|
+
import {
|
|
36
|
+
BYTE_ORDER_MARK,
|
|
37
|
+
BYTE_ORDER_MARK_BYTES,
|
|
38
|
+
contentToken,
|
|
39
|
+
countByteOrderMarks,
|
|
40
|
+
latestObservation,
|
|
41
|
+
leadingByteOrderMarks,
|
|
42
|
+
rememberRead,
|
|
43
|
+
} from 'aura-basetools/locator-lab/read-core.mjs'
|
|
44
|
+
import {
|
|
45
|
+
PatchError,
|
|
46
|
+
splitLinesWithEndings,
|
|
47
|
+
hasMixedLineEndings,
|
|
48
|
+
// 区域级新鲜度闸门:判据 + 收据都在引擎里(`staleConflicts` / `staleRegionRefusal`),
|
|
49
|
+
// 壳只调决策函数 `regionGate`。此前判据在本文件里也有一份,已删——
|
|
50
|
+
// 同一个决策写在两处,就一定会有一处漏改(本文件被这条坑过一次,见 `symbolHint`)。
|
|
51
|
+
regionGate,
|
|
52
|
+
staleConflicts,
|
|
53
|
+
regionTextIn,
|
|
54
|
+
joinLinesWithEndings,
|
|
55
|
+
planHunks,
|
|
56
|
+
// C49:「已落地」的**判据本体**(`old` 两层都不在 + `replace` 整行命中且全文唯一 + 去空白 ≥8 字符)。
|
|
57
|
+
// 与 `planHunks` 同源、同住引擎:壳层只负责把它接进 outcome 通道,**不在这里写第二份判据**——
|
|
58
|
+
// 引擎与壳层各写一份「已经改好了吗」的判断,就是两个判决面(本文件已被这类分叉坑过一次)。
|
|
59
|
+
alreadyAppliedHits,
|
|
60
|
+
fileByteOrderMarkCount,
|
|
61
|
+
carryingByteOrderMarks,
|
|
62
|
+
READBACK_PROBE_EXTRA_BYTES,
|
|
63
|
+
CARRIAGE_RETURN,
|
|
64
|
+
LINE_FEED,
|
|
65
|
+
providerView,
|
|
66
|
+
firstDifference,
|
|
67
|
+
lineEndingCensus,
|
|
68
|
+
verifyWriteBack,
|
|
69
|
+
verifyMismatchError,
|
|
70
|
+
errorCodeOf,
|
|
71
|
+
// fs-contract-v2 §2.2/§3:多目标写侧的**唯一入口**。三阶段提交(全量规划 →
|
|
72
|
+
// 全部可落才提交 → 回读校验)与逐项回执都在引擎里,壳层只把 `ctx.fs` 接成它要的 provider
|
|
73
|
+
// 并把逐项回执渲染出来——本文件里**不写第二份**提交纪律。
|
|
74
|
+
applyEditsToProvider,
|
|
75
|
+
} from 'aura-basetools/locator-lab/write-path.mjs'
|
|
76
|
+
|
|
77
|
+
export const name = 'dsh-tool-aura-edit'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 硬依赖,与 `dsh-tool-fs` 声明的一致。
|
|
81
|
+
*
|
|
82
|
+
* `systemPrompt` **必须声明**,不能靠 `ctx.get` 兜:用 `ctx.get` 时若服务不可见就静默跳过,
|
|
83
|
+
* 于是"工具注册了但没有指导文本"这种半成品状态**不报错**,表现为模型永远不用这个工具。
|
|
84
|
+
* 声明进 inject 后服务缺失会让插件进入等待,问题在装载期就暴露。
|
|
85
|
+
*
|
|
86
|
+
* 注意:Cordis 的 guard 会拒绝任何未在 inject 里声明的 `ctx.<name>` 访问——
|
|
87
|
+
* `ctx.tools` 不声明就会在 apply 阶段直接抛 "cannot get property ... without inject"。
|
|
88
|
+
*/
|
|
89
|
+
export const inject = ['fs', 'tools', 'systemPrompt']
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 一个 hunk 的**属性声明**(唯一一份)。
|
|
93
|
+
*
|
|
94
|
+
* 它出现两次:`hunks.items.properties`(数组形状)与顶层(**平铺形状**)。
|
|
95
|
+
* 两处必须是同一份对象,而不是各写一遍——同一条描述写两处必然漂,
|
|
96
|
+
* 而这里漂的后果是“两种写法接受不同的键”,那比拒收更难排查。
|
|
97
|
+
*/
|
|
98
|
+
const HUNK_PROPERTIES = {
|
|
99
|
+
target: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
description:
|
|
102
|
+
'Symbol to replace, e.g. `toTodoList`, `function:apply`, `Outer/Inner`, `name#2`. Skips all text matching, so it is immune to misremembered content.',
|
|
103
|
+
},
|
|
104
|
+
// 与 aura_read 同一套宽容声明:模型把区间写成 `4, 6` 时那不是合法 JSON(宿主在参数层就拒),
|
|
105
|
+
// 写成 `"4,6"` / `4` 则是合法 JSON 但形状不对——声明宽一点,能容的都容下来。
|
|
106
|
+
lines: {
|
|
107
|
+
oneOf: [{ type: 'array' }, { type: 'string' }, { type: 'integer' }, { type: 'object', additionalProperties: true }],
|
|
108
|
+
description:
|
|
109
|
+
'Exact line range `[start, end]` (1-based, inclusive), as printed by the read tool. Use this to edit inside a function without reproducing its text. `[n]` means one line. Also accepts "4,6" or a single 4. Preferred instead: `start_line` + `end_line`.',
|
|
110
|
+
},
|
|
111
|
+
start_line: {
|
|
112
|
+
type: 'integer',
|
|
113
|
+
description: 'Preferred over `lines`: first line of the range, 1-based inclusive.',
|
|
114
|
+
},
|
|
115
|
+
end_line: {
|
|
116
|
+
type: 'integer',
|
|
117
|
+
description: 'Preferred over `lines`: last line of the range, 1-based inclusive. Defaults to start_line.',
|
|
118
|
+
},
|
|
119
|
+
line_cue: {
|
|
120
|
+
type: 'string',
|
|
121
|
+
description:
|
|
122
|
+
'A single word that appears on exactly one line of the file — use when you know roughly where the line is but not its number. Ambiguous cues are rejected with candidate lines.',
|
|
123
|
+
},
|
|
124
|
+
anchor: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description:
|
|
127
|
+
'The text to find. May carry whitespace drift; keep at least 2 lines for safety. The fallback when no address above is available.',
|
|
128
|
+
},
|
|
129
|
+
/**
|
|
130
|
+
* 内容地址(fs-contract-v2 / C2 的契约):`old` 就是地址,坐标降为提示。
|
|
131
|
+
*
|
|
132
|
+
* 名字在这里**冻结**:schema、未知参数守卫(`EDIT_ARG_KEYS` 由本表派生)、参数校验
|
|
133
|
+
* 都读同一份,改名会同时改三处。**语义不在这里**——「怎么用 old 定位、不唯一时怎么扩
|
|
134
|
+
* context 重试」在引擎(`locator-lab/write-path.mjs`,由 C2 实现);壳层只保证两件事:
|
|
135
|
+
* schema **收得下**、字段**原样传得到引擎**。
|
|
136
|
+
*/
|
|
137
|
+
old: {
|
|
138
|
+
type: 'string',
|
|
139
|
+
description:
|
|
140
|
+
'Content address: the exact text you are replacing. `old` (+ optional `context`) locates the region by content, so coordinates become a hint rather than the address. Read the file first and copy the text verbatim.',
|
|
141
|
+
},
|
|
142
|
+
context: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description:
|
|
145
|
+
'Optional disambiguator for `old`: text that sits next to the region you mean. Only needed when `old` alone matches more than one place.',
|
|
146
|
+
},
|
|
147
|
+
replace: {
|
|
148
|
+
type: 'string',
|
|
149
|
+
/**
|
|
150
|
+
* `required: true` 是**本框架 DSL 的写法**(写在属性里),编译期会被提升成
|
|
151
|
+
* `items.required: ["replace"]`——`aura-edit/load-test` 断言的正是编译后的那个形状。
|
|
152
|
+
*
|
|
153
|
+
* 不能直接写 `items.required: [...]`:那样会被拒(`parameters.hunks.items.required is not
|
|
154
|
+
* supported by the value schema DSL`,插件直接注册失败)。两种写法只差一个位置,实测踩过。
|
|
155
|
+
*/
|
|
156
|
+
required: true,
|
|
157
|
+
description: 'Replacement text. Empty string deletes the addressed region.',
|
|
158
|
+
},
|
|
159
|
+
expect: {
|
|
160
|
+
type: 'string',
|
|
161
|
+
description:
|
|
162
|
+
'Guard: the content you believe is at that location. A mismatch fails the whole patch instead of editing an uncertain place. Works with every addressing mode.',
|
|
163
|
+
},
|
|
164
|
+
near: {
|
|
165
|
+
type: 'integer',
|
|
166
|
+
description: 'Optional 1-based line number hint that resolves an otherwise ambiguous match.',
|
|
167
|
+
},
|
|
168
|
+
/**
|
|
169
|
+
* fs-contract-v2 / 波H:**区间句柄**(契约 §5)。它把"被编辑的那段字节"从模型手里拿走,
|
|
170
|
+
* 换成工具自己账本里那份逐字节副本的凭据——147 条残余失败里 78.2% 正是"引用本身失真"。
|
|
171
|
+
*
|
|
172
|
+
* 壳层在这里只做两件事:schema **收得下**(否则参数层就把调用拦掉,模型连原因都看不到)、
|
|
173
|
+
* 字段**原样传得到引擎**。句柄的形状校验、过期判定、身份标签核对、区间内相对定位
|
|
174
|
+
* 全在引擎(`locator-lab/handle-core.mjs` + `write-path.mjs`):壳层**不重写第二份判据**。
|
|
175
|
+
*
|
|
176
|
+
* 名字在这里冻结,并随 `HUNK_PROPERTIES` 自动进 `EDIT_ARG_KEYS`——于是平铺形状
|
|
177
|
+
* `{file_path, handle, replace}` 也被运行期守卫认,不必再登记一处名单。
|
|
178
|
+
*/
|
|
179
|
+
handle: {
|
|
180
|
+
type: 'string',
|
|
181
|
+
description:
|
|
182
|
+
'Opaque region handle handed out by `aura_read` (e.g. `h9f3ac1b2e4d5:120-160`) — the byte-exact region the tool read, so you never have to retype that text. Combine with `line` or `position` to address inside or beside the region. The handle wins over `old`/coordinates; whatever gets ignored is reported back in the receipt.',
|
|
183
|
+
},
|
|
184
|
+
/**
|
|
185
|
+
* 段内**相对**定位:区间内第 k 行(1-based,相对区间起点)——整段替换与"只换其中一行"
|
|
186
|
+
* 由此分开。越界是**结果**(`PATCH_HANDLE_LINE_OUT_OF_RANGE`),不是抛错:
|
|
187
|
+
* 那是"你给的坐标不在你给的区间里"这个可判定的事实。
|
|
188
|
+
*/
|
|
189
|
+
line: {
|
|
190
|
+
type: 'integer',
|
|
191
|
+
description:
|
|
192
|
+
'With `handle`: the 1-based line **inside** that region (relative to its first line) — swaps just that line without retyping the region. Out of range is reported as a normal not_found receipt.',
|
|
193
|
+
},
|
|
194
|
+
/**
|
|
195
|
+
* 区间**边界**插入:不改动区间本身(契约 §5 的第四种形状)。与 `line` 互斥地表达
|
|
196
|
+
* "在这段之前/之后",而"在这段里面"由 `line` / `old` 表达。
|
|
197
|
+
*/
|
|
198
|
+
position: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
enum: ['before', 'after'],
|
|
201
|
+
description:
|
|
202
|
+
'With `handle`: insert the replacement at the region’s edge instead of replacing the region — `before` or `after`. The region itself is left untouched.',
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 把 `HUNK_PROPERTIES` 里的 `required` 语句剥掉,得到可**展开到另一个对象**的属性表。
|
|
208
|
+
*
|
|
209
|
+
* 为什么需要两份:真框架的 `compilePropertyMap` 会把属性上的 `required: true`
|
|
210
|
+
* 提升到**所属对象**的 `required` 数组。于是同一份属性表在 `hunks.items` 里是对的
|
|
211
|
+
* (一个 hunk 必须有 `replace`),直接展开到顶层就错了——顶层的 `required` 会变成
|
|
212
|
+
* `["file_path","replace"]`,而 `hunks` 与 `create` 两条路都不需要顶层 `replace`。
|
|
213
|
+
* 实测后果:`{file_path, hunks:[…]}` 被 `missing required property "replace"` 拒掉
|
|
214
|
+
* (`aura-edit/load-test` 第 157 行当场抛 INVALID_ARGS)。
|
|
215
|
+
*
|
|
216
|
+
* 两份表由**同一个循环**产出,所以新增一个 hunk 键时不会漏掉任何一份。
|
|
217
|
+
*/
|
|
218
|
+
const HUNK_PROPERTIES_OPTIONAL = Object.fromEntries(
|
|
219
|
+
Object.entries(HUNK_PROPERTIES).map(([key, spec]) => {
|
|
220
|
+
const { required: _required, ...rest } = spec
|
|
221
|
+
return [key, rest]
|
|
222
|
+
}),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 平铺形状下的**内容地址**(`old` + 可选 `context`)。
|
|
227
|
+
*
|
|
228
|
+
* 为什么本文件里还得有这一段:平铺形状由 `flatHunk()`(`locator-lab/args.mjs`)拼装,
|
|
229
|
+
* 而它认的地址键名单(`FLAT_ADDRESS_KEYS`)里还没有这两个名字——那个文件不在本单的
|
|
230
|
+
* 施工范围里。而 schema 一旦声明了 `old`/`context`(它们随 `HUNK_PROPERTIES` 展开到顶层),
|
|
231
|
+
* "声明得比产出宽"就成了一口新的雷:`{file_path, old, context, replace}` 会以**不相关的报错**
|
|
232
|
+
* ("hunks must be a non-empty array")失败,而模型看不到任何真实原因。
|
|
233
|
+
*
|
|
234
|
+
* 所以这里只补一件事:**顶层确实带了内容地址时**,把它连同 `flatHunk` 已经收好的其余键
|
|
235
|
+
* (replace/expect/near/lines/…)拼成一个 hunk。判据与 `flatHunk` 保持一致——没拿到 `replace`
|
|
236
|
+
* 就不算一个 hunk(返回 null),于是"半成品仍报原错"那条不变。
|
|
237
|
+
*
|
|
238
|
+
* 待办(记在这里以免被忘掉):`FLAT_ADDRESS_KEYS` 应当吸收 old/context,那时本函数可以删掉——
|
|
239
|
+
* 同一个决策不该有两份判据,这里只是为了不等另一个文件。
|
|
240
|
+
*/
|
|
241
|
+
function contentFlatHunk(source) {
|
|
242
|
+
const hasOld = typeof source.old === 'string' && source.old.length > 0
|
|
243
|
+
const hasContext = typeof source.context === 'string' && source.context.length > 0
|
|
244
|
+
if (!hasOld && !hasContext) return null
|
|
245
|
+
/**
|
|
246
|
+
* `flatHunk` 在**认不出地址**时会把整个 hunk 判成 null(它只看 `FLAT_ADDRESS_KEYS`),
|
|
247
|
+
* 而 `{file_path, old, context, replace}` 恰好落在那个分支里——于是它手里的
|
|
248
|
+
* replace/expect/near 也一并没了,这里得补回来。
|
|
249
|
+
*
|
|
250
|
+
* 取值判据与 `flatHunk` **逐字一致**(跳过 undefined / null / 空串):两处对"这个键
|
|
251
|
+
* 算不算给了"的判断一旦不同,同一个入参在两种形状下会得到不同结果。
|
|
252
|
+
*/
|
|
253
|
+
const hunk = flatHunk(source) ?? {}
|
|
254
|
+
for (const key of ['replace', 'expect', 'near']) {
|
|
255
|
+
if (hunk[key] !== undefined) continue
|
|
256
|
+
const value = source[key]
|
|
257
|
+
if (value === undefined || value === null) continue
|
|
258
|
+
if (typeof value === 'string' && value.length === 0) continue
|
|
259
|
+
hunk[key] = value
|
|
260
|
+
}
|
|
261
|
+
if (hasOld) hunk.old = source.old
|
|
262
|
+
if (hasContext) hunk.context = source.context
|
|
263
|
+
return typeof hunk.replace === 'string' ? hunk : null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 本次调用**有效的** hunk 列表(唯一一份判据)。
|
|
268
|
+
*
|
|
269
|
+
* 它有两个消费者:`execute` 要知道改什么,渲染要知道**句柄 hunk 忽略了什么**。
|
|
270
|
+
* 两处各写一遍的话,"这次哪些 hunk 走了句柄"就有了两个真值来源——本仓为这类漂移付过账。
|
|
271
|
+
*/
|
|
272
|
+
function hunkListOf(args) {
|
|
273
|
+
const flat = flatHunk(args) ?? contentFlatHunk(args)
|
|
274
|
+
return Array.isArray(args.hunks) && args.hunks.length > 0 ? args.hunks : flat === null ? null : [flat]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 句柄之外的地址键:`old`/`context` 是**内容**地址,其余是**坐标**地址。
|
|
279
|
+
*
|
|
280
|
+
* 名单在这里是为了"被忽略的一方"能被逐字点名——不是第二份寻址判据:
|
|
281
|
+
* 谁真的生效由引擎决定(`write-path.mjs`),这里只报告"调用方还给了这些"。
|
|
282
|
+
*/
|
|
283
|
+
const ADDRESS_KEYS_BESIDE_HANDLE = ['old', 'context', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'target']
|
|
284
|
+
|
|
285
|
+
/** 这个键这次**真的给了**吗(判据与 `flatHunk` 一致:跳过 undefined / null / 空串 / 空数组)。 */
|
|
286
|
+
function gaveKey(source, key) {
|
|
287
|
+
const value = source[key]
|
|
288
|
+
if (value === undefined || value === null) return false
|
|
289
|
+
if (typeof value === 'string' && value.length === 0) return false
|
|
290
|
+
if (Array.isArray(value) && value.length === 0) return false
|
|
291
|
+
return true
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 句柄 hunk 的**披露**(契约 §9 的 D7:句柄优先,但**沉默忽略不行**)。
|
|
296
|
+
*
|
|
297
|
+
* 为什么由壳层算:调用方给的那份入参只有壳层见过(引擎拿到的是一个个 hunk),而这一段
|
|
298
|
+
* 的全部职责就是"把被判决忽略掉的那一半说出来"。它不改任何判决,也不碰寻址语义——
|
|
299
|
+
* 既有的 `old` 裁决早就这么做了(引擎的 `coordinateNote`),句柄照做一份。
|
|
300
|
+
*
|
|
301
|
+
* 为什么不把句柄本身抄进正文:正文是宿主剪枝器真正量的东西(契约 §2 不变式 5 / §4),
|
|
302
|
+
* 而调用方知道自己发的是哪个句柄——需要被提醒的是"还有哪些字段这次没被用上"。
|
|
303
|
+
*
|
|
304
|
+
* 没有句柄 hunk 时返回空数组 ⇒ 既有回执**逐字不变**(不动的判据不许被这次改动碰)。
|
|
305
|
+
*/
|
|
306
|
+
function handleNotes(args) {
|
|
307
|
+
if (args === null || typeof args !== 'object') return []
|
|
308
|
+
const hunks = hunkListOf(args)
|
|
309
|
+
if (!Array.isArray(hunks)) return []
|
|
310
|
+
const notes = []
|
|
311
|
+
hunks.forEach((hunk, index) => {
|
|
312
|
+
if (hunk === null || typeof hunk !== 'object') return
|
|
313
|
+
if (!gaveKey(hunk, 'handle')) return
|
|
314
|
+
const which = hunks.length > 1 ? `hunk ${index + 1}` : 'this hunk'
|
|
315
|
+
const ignored = ADDRESS_KEYS_BESIDE_HANDLE.filter((key) => gaveKey(hunk, key))
|
|
316
|
+
notes.push(
|
|
317
|
+
` (${which} was addressed by a handle — the region and its bytes come from the tool's own copy of what you read, not from text retyped by you.)`,
|
|
318
|
+
)
|
|
319
|
+
if (ignored.length > 0) {
|
|
320
|
+
notes.push(
|
|
321
|
+
` ignored because the handle wins: ${ignored.map((key) => `\`${key}\``).join(', ')} — drop the handle if you meant those instead.`,
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
return notes
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 把披露附在正文末尾(没有句柄 hunk 时一个字符都不加)。 */
|
|
329
|
+
function appendHandleNotes(text, args) {
|
|
330
|
+
const notes = handleNotes(args)
|
|
331
|
+
return notes.length === 0 ? text : `${text}\n${notes.join('\n')}`
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 本工具声明的**顶层参数名**(唯一一份,供未知参数守卫用)。
|
|
336
|
+
*
|
|
337
|
+
* 它必须等于 `parameters` 真实声明的键集合,否则会出现“schema 里有、守卫却拒”
|
|
338
|
+
* 或反过来的情况。`real-framework-smoke-test` 的段C 会拿真框架的校验器核对这一点。
|
|
339
|
+
*/
|
|
340
|
+
export const EDIT_ARG_KEYS = [
|
|
341
|
+
'file_path',
|
|
342
|
+
'hunks',
|
|
343
|
+
'dry_run',
|
|
344
|
+
'create',
|
|
345
|
+
// fs-contract-v2 §2.2:多目标形状。它与 `file_path`/`hunks` **互斥**——
|
|
346
|
+
// 判据只有一份(引擎与壳共调 `assertEditTargets`),这里只登记名字。
|
|
347
|
+
'targets',
|
|
348
|
+
...Object.keys(HUNK_PROPERTIES),
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
export const Config = z.object({})
|
|
352
|
+
|
|
353
|
+
// ── 与 dsh-tool-fs 一致的观测契约 ──────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 模型侧诊断:把 fs 错误码翻成可执行的恢复指令。
|
|
357
|
+
*
|
|
358
|
+
* 两条写回路径的“变了”是不同判据报出来的,但含义一样,因此合成同一句恢复指令:
|
|
359
|
+
* · `writeText` + `replaceIfVersion`(主路径)⇒ `FS_STALE_VERSION`(提供方级版本 CAS)
|
|
360
|
+
* · `editText`(后端无 writeText 时的退化路径)⇒ `FS_EDIT_NOT_FOUND`(整份内容匹配不上)
|
|
361
|
+
* 错误码**原样保留**:调用方与测试要能区分“第三方改了文件”与“锚点对不上”。
|
|
362
|
+
* @param error - 捕获到的错误
|
|
363
|
+
* @param displayPath - 面向模型的路径
|
|
364
|
+
* @returns 可直接抛出的错误
|
|
365
|
+
*/
|
|
366
|
+
function remediate(error, displayPath) {
|
|
367
|
+
const code = errorCodeOf(error)
|
|
368
|
+
if (code === 'FS_EDIT_NOT_FOUND' || code === 'FS_STALE_VERSION') {
|
|
369
|
+
return Object.assign(new Error(`cannot patch "${displayPath}": the file changed while patching — read it again, then retry`), { code })
|
|
370
|
+
}
|
|
371
|
+
if (code === 'FS_AMBIGUOUS_EDIT') {
|
|
372
|
+
return new Error(`cannot patch "${displayPath}": the replacement was not unique — report this, it is a bug`)
|
|
373
|
+
}
|
|
374
|
+
return error
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 把 lab 的符号目录渲染成错误消息里的一段文本。
|
|
379
|
+
*
|
|
380
|
+
* **不再自己挑前 24 个符号。** 这里原本写的是 `listSymbols().slice(0, 24)`,即**声明顺序**——
|
|
381
|
+
* 而实测声明顺序与重要性零相关(前 24 个里 49% 是函数内局部量)。
|
|
382
|
+
* 我在审计时已把 `directoryHint` 改成用共享的 `rankSymbols`,却漏了本文件这一份:
|
|
383
|
+
* **同一个决策写在两处,就一定会有一处漏改**,这次轮到它。
|
|
384
|
+
* 现在只从 lab 取,不再自行挑选。
|
|
385
|
+
*/
|
|
386
|
+
function symbolHint(displayPath, content) {
|
|
387
|
+
const lang = langOf(displayPath)
|
|
388
|
+
if (lang === null) return ''
|
|
389
|
+
try {
|
|
390
|
+
const dir = directoryHint(content, lang)
|
|
391
|
+
if (dir === null) return ''
|
|
392
|
+
// 英文界面:工具结果会被送进英文会话。此前这里漏出中文"行"字——
|
|
393
|
+
// 我在别处修过同一个决策,却漏了本文件这一份(同一个决策写在两处,必有一处漏改)。
|
|
394
|
+
const rows = dir.symbols.map(
|
|
395
|
+
(s) =>
|
|
396
|
+
`${s.kind} ${s.name} (lines ${s.startLine}-${s.endLine})${s.exported ? ' ★' : ''}` +
|
|
397
|
+
// 跨度判不了的符号不能出现在"改用 target="的建议里而不加标记:
|
|
398
|
+
// 那会让模型重试同一个必然失败的动作。
|
|
399
|
+
(s.spanComplete === false ? ' [span not determinable — use lines=]' : ''),
|
|
400
|
+
)
|
|
401
|
+
return (
|
|
402
|
+
`\n\nThis file's structural directory — locate by name instead of guessing text:\n ` +
|
|
403
|
+
rows.join('\n ') +
|
|
404
|
+
`\nRetry with target=<name> (also accepts kind:name, Outer/Inner) and no anchor.`
|
|
405
|
+
)
|
|
406
|
+
} catch {
|
|
407
|
+
// 结构索引失败不能掩盖原始错误——恢复建议是加分项,不是必需品
|
|
408
|
+
return ''
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── C55:非 UTF-8 文件 ⇒ 可执行的拒绝 ──────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 这次失败是不是「这份文件不是 UTF-8 文本」?
|
|
416
|
+
*
|
|
417
|
+
* 两种后端形状都要认(都是实测出来的):
|
|
418
|
+
* · `ERR_ENCODING_INVALID_ENCODED_DATA` —— `new TextDecoder('utf-8',{fatal:true}).decode()`
|
|
419
|
+
* 在裸 Node 侧的原始错误,消息是 `The encoded data was not valid for encoding utf-8`,
|
|
420
|
+
* **不带文件名**(BASE 段的实测读数就是它);
|
|
421
|
+
* · `FS_NOT_TEXT` + 消息里有 `utf-8` —— `dsh-fs-local` 的 `decodeUtf8` 把上面那个 TypeError
|
|
422
|
+
* 翻成了 `cannot read "<路径>": invalid UTF-8 text`。
|
|
423
|
+
*
|
|
424
|
+
* 为什么不能只看码:`FS_NOT_TEXT` 同时也用于「含 NUL 的二进制文件」(消息是 `binary file`)。
|
|
425
|
+
* 那是另一件事,它的说法本来就够(有路径、有原因),不该被这一段的文案盖住。
|
|
426
|
+
*
|
|
427
|
+
* 反过来,判据也**不能**用「解码失败」去反推:本函数只在**已经捕获到**错误时被调用,
|
|
428
|
+
* 带 BOM 的 UTF-8(`TextDecoder` 默认 `ignoreBOM:false`,只吃掉第一个 BOM)根本走不到这里,
|
|
429
|
+
* 因此不会被误判成非 UTF-8。
|
|
430
|
+
*/
|
|
431
|
+
function isNonUtf8TextFailure(error) {
|
|
432
|
+
if (error === null || typeof error !== 'object') return false
|
|
433
|
+
if (error.code === 'ERR_ENCODING_INVALID_ENCODED_DATA') return true
|
|
434
|
+
const message = typeof error.message === 'string' ? error.message : ''
|
|
435
|
+
return /invalid utf-?8|not valid for encoding utf-?8/i.test(message)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 「这份文件不是 UTF-8 文本」的拒绝回执:**三件事必须同一次说清**。
|
|
440
|
+
*
|
|
441
|
+
* ① 是哪个文件——路径原样带引号,可以直接复制去查;
|
|
442
|
+
* ② 为什么读不了——它的字节不是 UTF-8 文本,因此这里根本没有可寻址的文本;并点明这是
|
|
443
|
+
* **环境级拒绝**,换参数重发不可能成功(否则调用方会去改地址、改模式,
|
|
444
|
+
* 而它真正要做的只是把文件转成 UTF-8);
|
|
445
|
+
* ③ 下一步怎么办——先确认编码、再转成 UTF-8、再调一次;并明说**这次一个字节都没改**。
|
|
446
|
+
*
|
|
447
|
+
* 为什么走抛错(工具级故障)而不是结果通道:按本仓口径,「判定不出你的意图」才走结果,
|
|
448
|
+
* 而这一条是环境故障。把「读不了这个文件」伪装成「判不出你要哪一段」是两件不同的事,
|
|
449
|
+
* 会让调用方去重试一个必然失败的形状。写路径上还有一条**结构性**的加强:这一抛发生在
|
|
450
|
+
* `planHunks` 与 `writeText` 之前,所以「没有写盘」不是承诺,而是这条路上不可能有写。
|
|
451
|
+
*
|
|
452
|
+
* 同一段判据与同一句话在三个面上各有一份(本波写范围不共享模块):
|
|
453
|
+
* aura-read/index.js · aura-edit/index.js · aura-standalone/lib/ops.mjs。
|
|
454
|
+
* 抄漏一处由 `locator-lab/encoding-refusal-test.mjs` 段C 逐处抽出来比对 ⇒ 会红。
|
|
455
|
+
*/
|
|
456
|
+
function nonUtf8RefusalMessage(verb, displayPath, tool) {
|
|
457
|
+
return (
|
|
458
|
+
`cannot ${verb} "${displayPath}": this file is not UTF-8 text.\n` +
|
|
459
|
+
`why: its bytes are not a UTF-8 encoding of text, so there is no text here to ${verb === 'read' ? 'read' : 'address'}. ` +
|
|
460
|
+
`This is an environment-level refusal, not a problem with the arguments you passed — re-sending the same call cannot succeed.\n` +
|
|
461
|
+
`next: (1) find out which encoding the file really uses — look at its bytes (a hex view) or at your editor's encoding indicator (\`file -i\` where available); ` +
|
|
462
|
+
`(2) convert it to UTF-8 (for example \`iconv -f GBK -t UTF-8\`, or the editor's "save with encoding: UTF-8"); ` +
|
|
463
|
+
`(3) then call ${tool} again on the converted file.\n` +
|
|
464
|
+
`nothing was written: this call did not touch the file — its bytes on disk are exactly as they were.`
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── 失败通道:判定不出调用方意图 ⇒ 走结果;工具级故障 ⇒ 继续抛错 ─────────────
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* outcome 的取值(**冻结的名字**,schema 的 `enum` 与下面的判决表读同一份)。
|
|
472
|
+
*
|
|
473
|
+
* applied 这次调用按调用方的意图执行完毕(改成了 / 建成了 / 预演过了)
|
|
474
|
+
* needs_choice 有多处都能对上,得由调用方从候选里挑一个
|
|
475
|
+
* not_found 要改的原文不在文件里(含给的坐标在文件里不存在)
|
|
476
|
+
* already_applied 要改的原文不在,但结果**已经**是调用方给的样子了(C49 补的第四态:
|
|
477
|
+
* 这条路上一个字节都没写,且事实上也不需要写)
|
|
478
|
+
*
|
|
479
|
+
* 第四态与 `not_found` 的差别是**事实差别**,不是措辞差别:一个是「你得去改」
|
|
480
|
+
* (重读、重挑地址),另一个是「这次不用改」(调用方上一次其实成功了,或者目标文本
|
|
481
|
+
* 本来就在那儿)。把它们合成一句话,代价是一次重试、或者一次把文件改成错的样子
|
|
482
|
+
* —— 而这条路上磁盘从未被碰过,所以「事实」本来就能逐字核对。
|
|
483
|
+
*
|
|
484
|
+
* `enum` 必须与这份名单同一份(见下面 output.schema 那条注释):名单加了而 enum 没加,
|
|
485
|
+
* 框架会拿 `INVALID_TOOL_OUTPUT` 把**整条回执**拒掉——模型连这段文字都看不到。
|
|
486
|
+
*/
|
|
487
|
+
export const OUTCOMES = ['applied', 'needs_choice', 'not_found', 'already_applied']
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 引擎错误码 → outcome(**缺省态**)。**不在表里的码一律按工具级故障抛错。**
|
|
491
|
+
*
|
|
492
|
+
* 表里的值只记「这个码通常意味着什么」;`PATCH_NOT_FOUND` 还有**第二个出口**
|
|
493
|
+
* (见紧随其后的 `SECOND_EXIT_BY_CODE`),两张表成对读才完整。
|
|
494
|
+
*
|
|
495
|
+
* 方向是刻意选的,两边代价不对称:
|
|
496
|
+
* · 漏收一个**可判定**码 ⇒ 这条失败还按旧契约报错。是没改进,不是退化;
|
|
497
|
+
* · 误吞一个**工具级**故障 ⇒ 磁盘没动,调用方却拿到一条"正常结果"并据此认为
|
|
498
|
+
* 事情已经办完。那是静默失败,比报错坏得多。
|
|
499
|
+
* 所以这里用白名单,而不是"default: not_found"。
|
|
500
|
+
*
|
|
501
|
+
* 判据来自真实调用日志(16757 次调用、1565 次失败)里出现过的码:
|
|
502
|
+
* `PATCH_EXPECT_MISMATCH` 936、`LOCATOR_AMBIGUOUS` 155、`PATCH_ANCHOR_NOT_FOUND` 97、
|
|
503
|
+
* `LOCATOR_OUT_OF_RANGE` 32 —— 前四名里有三个正是本表要收的"判定不出意图"。
|
|
504
|
+
*
|
|
505
|
+
* **刻意不收的**(保持抛错,各有理由):
|
|
506
|
+
* · `PATCH_STALE_REGION`:安全闸门(地址漂移),不是"读不懂意图"。它的恢复动作是
|
|
507
|
+
* **重读**,不是重挑地址;降级成结果会让"磁盘没动"看起来像已完成。
|
|
508
|
+
* · `WRITE_VERIFY_FAILED` / `PATCH_NO_BYTE_EXACT_WRITE` / 任何 FS_* 码:工具级故障。
|
|
509
|
+
* · `PATCH_NO_ADDRESS`("这个 hunk 一个地址都没给",含 `start_line` 写坏那条):
|
|
510
|
+
* 它是**参数没成形**,与"地址对不上"是两件事。混淆的代价很具体:调用方会去改地址,
|
|
511
|
+
* 而它真正要做的只是把字段写全。同一条边界还有 execute 里那两处 throw。
|
|
512
|
+
*/
|
|
513
|
+
const OUTCOME_BY_CODE = new Map([
|
|
514
|
+
// 要改的原文不在文件里(含坐标在文件里不存在)
|
|
515
|
+
['PATCH_NOT_FOUND', 'not_found'],
|
|
516
|
+
['PATCH_ANCHOR_NOT_FOUND', 'not_found'],
|
|
517
|
+
['LOCATOR_CUE_NOT_FOUND', 'not_found'],
|
|
518
|
+
['PATCH_EXPECT_MISMATCH', 'not_found'],
|
|
519
|
+
['LOCATOR_ASSERT_MISMATCH', 'not_found'],
|
|
520
|
+
['LOCATOR_OUT_OF_RANGE', 'not_found'],
|
|
521
|
+
['LOCATOR_BAD_LINES', 'not_found'],
|
|
522
|
+
// 需要调用方从候选里选一个
|
|
523
|
+
['PATCH_AMBIGUOUS', 'needs_choice'],
|
|
524
|
+
['LOCATOR_AMBIGUOUS', 'needs_choice'],
|
|
525
|
+
['LOCATOR_CUE_AMBIGUOUS', 'needs_choice'],
|
|
526
|
+
/**
|
|
527
|
+
* 句柄路的四种**如实拒绝**(契约 §5 解析次序 / §9 的 D6):句柄过期、身份标签不符、
|
|
528
|
+
* 段内行号越界、`{handle, old}` 而引文不在该区间内。
|
|
529
|
+
*
|
|
530
|
+
* 它们**不是**工具级故障:这四条路上磁盘一个字节都没动,调用方拿到的是一个可判定的
|
|
531
|
+
* 事实,恢复动作是"重读、重寻址"。复用 `PATCH_NOT_FOUND` 会语义说谎(句柄路上引文
|
|
532
|
+
* 是对的那一半),所以码是新的;判决仍是 `not_found`——四态里的第三态。
|
|
533
|
+
*
|
|
534
|
+
* 唯一**仍然抛错**的是"句柄形状不对"(连正则都不匹配):那是参数面没成形,与
|
|
535
|
+
* `PATCH_NO_ADDRESS` 同族,见 {@link OUTCOME_BY_CODE} 上方的边界说明。
|
|
536
|
+
*/
|
|
537
|
+
['PATCH_HANDLE_STALE', 'not_found'],
|
|
538
|
+
['PATCH_HANDLE_IDENTITY_MISMATCH', 'not_found'],
|
|
539
|
+
['PATCH_HANDLE_LINE_OUT_OF_RANGE', 'not_found'],
|
|
540
|
+
['PATCH_HANDLE_QUOTE_OUTSIDE', 'not_found'],
|
|
541
|
+
])
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* 上面那张判决表的**导出面**:表里的键,按表里的顺序,冻结。
|
|
545
|
+
*
|
|
546
|
+
* 它是**派生视图**,不是第二份判据 —— 判据仍在 `OUTCOME_BY_CODE` 上:表的形状、键名、值、
|
|
547
|
+
* 顺序一个都没动,三处按文本解析这张表的套件(`expect-single-source-test` /
|
|
548
|
+
* `success-regression` / `notfound-receipt-test`)照旧活着,它们盯的仍然是那张表本身。
|
|
549
|
+
*
|
|
550
|
+
* 导出它只为一件事:让量具能**程序可读**地拿到这条边界,而不是手抄一份。
|
|
551
|
+
* 唯一的使用者 `probes/tool-error-rate.mjs` 靠它算「引擎抛的码是工具级故障,还是壳层会转成
|
|
552
|
+
* 正常结果」那一格;同一趟里它还会用文本正则把源码里这张表**再解析一遍**,断言两边逐字相等
|
|
553
|
+
* —— 导出面一旦与表漂移,那条断言当场变红。
|
|
554
|
+
*
|
|
555
|
+
* 判据(与上表同一条):`code ∈ INTENT_CODES` ⇒ 壳层把它转成**正常结果**(`isError=false`);
|
|
556
|
+
* `code ∉ INTENT_CODES` ⇒ 壳层**抛错**(`isError=true`)。判决本体见 {@link intentVerdict}:
|
|
557
|
+
* `mapped === undefined` 返回 null,调用方拿到 null 就原样抛。
|
|
558
|
+
*/
|
|
559
|
+
export const INTENT_CODES = Object.freeze([...OUTCOME_BY_CODE.keys()])
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* 「同一个码,两种事实」的**第二出口**:码 → { 细分态, 判据 }。
|
|
563
|
+
*
|
|
564
|
+
* 为什么单独摆一张表、而不把两个名字塞进上面那份值里:上面那张表被**三处按文本解析**,
|
|
565
|
+
* 三处都在找「一条形如 `['CODE', 'outcome']` 的项」:
|
|
566
|
+
* · `src/locator-lab/expect-single-source-test.mjs` 直接断言 `/\['PATCH_NOT_FOUND',\s*'not_found'\]/`;
|
|
567
|
+
* · `probes/success-regression.mjs` 用正则把它整张表从壳层源码里解析出来(少一条码就报错);
|
|
568
|
+
* · `src/locator-lab/notfound-receipt-test.mjs` 锁着「候选 ≥2 ⇒ needs_choice」那一行。
|
|
569
|
+
* 改值的形状会把这三处一起弄红,而它们要防的正是「壳层判决表漏了一条码」——
|
|
570
|
+
* 用「把表改成别的形状」去满足第四态,代价是丢掉三只已经在那里的眼睛。
|
|
571
|
+
*
|
|
572
|
+
* `PATCH_NOT_FOUND` 为什么会有第二出口:这个码对应的事实有两种——
|
|
573
|
+
* 真的不在(缺省 `not_found`),与「原文不在、但结果**已经**在文件里了」(`already_applied`)。
|
|
574
|
+
* 两件事都是「没写盘」,但恢复动作相反:前者要重读重寻址,后者是「这次不用改」。
|
|
575
|
+
*
|
|
576
|
+
* `hits` 就是引擎那份判据本体(`write-path.mjs` 的 `alreadyAppliedHits`):全过 ⇒ 回执数组,
|
|
577
|
+
* 任一条不成立 ⇒ `null`。壳层不重写它,也不拓宽它——门槛(整行命中 / 全文唯一 / 去空白≥8字符)
|
|
578
|
+
* 一放宽,「文件里恰好有一行是 `}`」这种巧遇就会被卖成「已经改好了」。
|
|
579
|
+
*
|
|
580
|
+
* 只对 `PATCH_NOT_FOUND` 生效:`PATCH_AMBIGUOUS`(有好几处像)与断言不符是另外两态,
|
|
581
|
+
* 不该被这句话盖住。
|
|
582
|
+
*/
|
|
583
|
+
const SECOND_EXIT_BY_CODE = new Map([
|
|
584
|
+
['PATCH_NOT_FOUND', { outcome: 'already_applied', hits: alreadyAppliedHits }],
|
|
585
|
+
])
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 第二出口的证据:这个码有细分态吗?有的话,判据命中了吗?
|
|
589
|
+
*
|
|
590
|
+
* 调用方(`execute` 的 catch)只调这一个函数,不必知道哪几条码有第二出口;
|
|
591
|
+
* 没命中(或这个码压根没有第二出口)一律 `null` ⇒ 原样走缺省态那条路。
|
|
592
|
+
*/
|
|
593
|
+
/**
|
|
594
|
+
* 第四态的 `message`(事实 first):**不再抄**引擎那句 `not_found` 的正文。
|
|
595
|
+
*
|
|
596
|
+
* 引擎的 `PATCH_NOT_FOUND` 正文有两大段:一句“你要的原文不在这里,重读文件、
|
|
597
|
+
* 按那里的实际内容重新寻址”,加一段“最近的一带长什么样”。它们在失败那一态是对的,
|
|
598
|
+
* 到这一态却**句句都不成立**(没有要改的东西,也就没什么可重寻址的),而模型对
|
|
599
|
+
* `message` 的敏感度高于任何别的字段。所以这里只留下**两件真事**:
|
|
600
|
+
* · `old` 确实不在文件里(这是这一支成立的前提,仍然是真话);
|
|
601
|
+
* · 你要的结果**已经逐字在那儿了**,落在哪几行——并且请核对那几行是不是你要的地方。
|
|
602
|
+
*
|
|
603
|
+
* 丢掉“最近的一带”不是丢证据:可核的证据在 `already_applied[].actual`(就是命中那一行的原文),
|
|
604
|
+
* 它比“附近长什么样”直接得多。
|
|
605
|
+
*/
|
|
606
|
+
function alreadyAppliedMessage(hits) {
|
|
607
|
+
const where = hits.map((h) => `line ${h.line}`).join(', ')
|
|
608
|
+
return `the \`old\` text you gave is not in the file — but nothing needs to change: the text you asked to end up there is already there verbatim (${where}). Check that line is the region you meant; this call wrote nothing.`
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function secondExitEvidence(error, content, hunks) {
|
|
612
|
+
const exit = SECOND_EXIT_BY_CODE.get(errorCodeOf(error))
|
|
613
|
+
return exit === undefined ? null : exit.hits(content, hunks)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** 判定不出意图时的恢复指令(引擎没给 `next` 时才用;引擎给了就以引擎的为准)。 */
|
|
617
|
+
const NEXT_BY_OUTCOME = {
|
|
618
|
+
/**
|
|
619
|
+
* 第四态的 `next` **刻意不取 `error.next`**(其余两态取)。
|
|
620
|
+
*
|
|
621
|
+
* 引擎的 `PATCH_NOT_FOUND` 错误对象上确实带一句 `next`,内容是「重读文件、按那里的实际
|
|
622
|
+
* 内容重新寻址」——那是 `not_found` 的恢复动作。到这一态它已经**不成立**了:没有要改的东西。
|
|
623
|
+
* 照抄它等于把「这次不用改」重新卖成「去改点什么」,而模型会真去改。
|
|
624
|
+
* 实测(本轮探针):`PATCH_NOT_FOUND` 的 `error.next` = "re-read the file (aura_read) and pass
|
|
625
|
+
* the text exactly as it is there — no line of your old text is in this file"。
|
|
626
|
+
*/
|
|
627
|
+
already_applied:
|
|
628
|
+
'nothing to do: this call wrote nothing because the file already holds the replacement you gave — the line(s) above are the file as it is now; re-send this hunk only if you meant to change that text further',
|
|
629
|
+
needs_choice:
|
|
630
|
+
'more than one place matches — pick one of the candidate lines and re-send that hunk addressed by `start_line` + `end_line`, or add `old`/`context` to say which one you mean',
|
|
631
|
+
not_found:
|
|
632
|
+
'the addressed text is not in the file as it is now — re-read it (aura_read), then address what is actually there',
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* 候选位置归一成**一种形状**:`{line, text?}`。
|
|
637
|
+
*
|
|
638
|
+
* 引擎两处给的候选不同形:编辑侧给行号数组(`[2, 6]`,见 anchor-core 的 `PATCH_AMBIGUOUS`),
|
|
639
|
+
* 读取侧给 `{line, text}` 对象。回执要能被一个消费方读懂,就不能让同一个字段名有两种含义——
|
|
640
|
+
* 归一是**壳层的责任**,因为壳层才是"对外的那个契约面"。认不出的条目丢弃而不是硬塞,
|
|
641
|
+
* 免得为了凑形状编出一个不存在的行号。
|
|
642
|
+
*/
|
|
643
|
+
function normalizeCandidates(raw) {
|
|
644
|
+
if (!Array.isArray(raw)) return []
|
|
645
|
+
const out = []
|
|
646
|
+
for (const entry of raw) {
|
|
647
|
+
if (Number.isInteger(entry)) {
|
|
648
|
+
out.push({ line: entry })
|
|
649
|
+
continue
|
|
650
|
+
}
|
|
651
|
+
if (entry === null || typeof entry !== 'object' || !Number.isInteger(entry.line)) continue
|
|
652
|
+
out.push(typeof entry.text === 'string' ? { line: entry.line, text: entry.text } : { line: entry.line })
|
|
653
|
+
}
|
|
654
|
+
return out
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* 判决一次定位失败:**返回判决**(可判定)或 **null**(工具级故障,调用方必须原样抛出)。
|
|
659
|
+
*
|
|
660
|
+
* 只在**定位阶段**调用:目标文件已读到、内容在手、任何字节都还没写。因此这条路上的失败
|
|
661
|
+
* 天然满足"磁盘不变"——不必额外回滚,也不必担心半个事务。第四态也是从这条路出去的,
|
|
662
|
+
* 所以它"一个字节都没写"同样是**结构性**的(不是靠回滚),与引擎侧 `applyEditToProvider`
|
|
663
|
+
* 里那条同源:判决与它描述的事实,必须在同一个位置上成立。
|
|
664
|
+
*
|
|
665
|
+
* @param error - `planHunks` 抛出的错误(PatchError,带 code / candidates / next)
|
|
666
|
+
* @param already - 第二出口的证据({@link secondExitEvidence} 的返回值):命中就翻成细分态,
|
|
667
|
+
* `null` 则走缺省态。**由调用方算好传进来**,而不是在这里 import 引擎:本函数只管
|
|
668
|
+
* 「什么码能落到哪个态」,判据本体住在引擎(`write-path.mjs` 的 `alreadyAppliedHits`)。
|
|
669
|
+
* @returns {{outcome: string, code: string, message: string, candidates?: Array, already_applied?: Array, next: string} | null}
|
|
670
|
+
*/
|
|
671
|
+
function intentVerdict(error, already = null) {
|
|
672
|
+
if (error === null || typeof error !== 'object') return null
|
|
673
|
+
const code = typeof error.code === 'string' ? error.code : ''
|
|
674
|
+
const mapped = OUTCOME_BY_CODE.get(code)
|
|
675
|
+
// 白名单未命中 ⇒ 工具级故障。**不要在这里改成"默认 not_found"**(理由见上表)。
|
|
676
|
+
if (mapped === undefined) return null
|
|
677
|
+
/**
|
|
678
|
+
* 第二出口:判据命中 ⇒ 这个码指向的是**另一种事实**。
|
|
679
|
+
*
|
|
680
|
+
* 它排在 `candidates` 那条闸门**之前**,因为两者不会同时成立(`already_applied` 要求
|
|
681
|
+
* `replace` 全文唯一,而"有好几处像"要求不唯一);而先判它有个具体好处:
|
|
682
|
+
* `PATCH_NOT_FOUND` 上不可能带候选,所以这里不会接错东西。
|
|
683
|
+
*
|
|
684
|
+
* `next` 用表里那一份,**不用 `error.next`**(理由写在 `NEXT_BY_OUTCOME.already_applied`):
|
|
685
|
+
* 引擎给的那句是 `not_found` 的恢复动作,到这一态已经不成立。
|
|
686
|
+
*/
|
|
687
|
+
const refined = SECOND_EXIT_BY_CODE.get(code)
|
|
688
|
+
if (refined !== undefined && Array.isArray(already) && already.length > 0) {
|
|
689
|
+
return {
|
|
690
|
+
outcome: refined.outcome,
|
|
691
|
+
code,
|
|
692
|
+
message: alreadyAppliedMessage(already),
|
|
693
|
+
already_applied: already,
|
|
694
|
+
next: NEXT_BY_OUTCOME[refined.outcome],
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const candidates = normalizeCandidates(error.candidates)
|
|
698
|
+
// 候选列表本身就是"需要选"的证据:引擎只要给出 ≥2 个候选,这次失败就是可判定的,
|
|
699
|
+
// 无论它的 code 叫什么。反过来,码说"歧义"却一个候选都没给时,退回 not_found——
|
|
700
|
+
// "从候选里选"却列不出候选,对调用方是一句空话。
|
|
701
|
+
const outcome = candidates.length > 1 ? 'needs_choice' : mapped === 'needs_choice' ? 'not_found' : mapped
|
|
702
|
+
/**
|
|
703
|
+
* 引擎算好的**可粘回地址**(`error.nearest.retry.handle`):能力面对齐的一处补全。
|
|
704
|
+
*
|
|
705
|
+
* 为什么必须转出来:地址本来就在 `message` 的正文里(模型看得见),但**结构化那半在壳这层被丢了** ——
|
|
706
|
+
* 同一个失败在 CLI 上是 `nearest.retry.handle`、在引擎上是 `error.retry.handle`,唯独 DSH 回执
|
|
707
|
+
* 一个结构化字段都没有。消费方(宿主、写自动化的人)只能去正则抠那段正文;
|
|
708
|
+
* 而“可粘回的地址”的全部价值就在于**机器也能直接用**。
|
|
709
|
+
*
|
|
710
|
+
* 只在引擎真给了的时候带上(没铸出柄就没有地址,不编一个空壳)。
|
|
711
|
+
*/
|
|
712
|
+
const retry = error.nearest?.retry
|
|
713
|
+
return {
|
|
714
|
+
outcome,
|
|
715
|
+
code,
|
|
716
|
+
message: String(error.message ?? ''),
|
|
717
|
+
...(candidates.length === 0 ? {} : { candidates }),
|
|
718
|
+
...(typeof retry?.handle === 'string' ? { retry: { handle: retry.handle } } : {}),
|
|
719
|
+
next: typeof error.next === 'string' && error.next.length > 0 ? error.next : NEXT_BY_OUTCOME[outcome],
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* 判定得出意图时的**正常结果**(不是错误)——三态失败与第四态「已落地」共用这一个构造器。
|
|
725
|
+
*
|
|
726
|
+
* 每个字段都必须在 `output.schema` 里声明过:那是 `additionalProperties:false`,
|
|
727
|
+
* 凭空多一个字段会让整条路径被框架以 `INVALID_TOOL_OUTPUT` 拒掉——
|
|
728
|
+
* 于是"可判定的失败"又变回工具故障,正好是本次要拆掉的那个形状。
|
|
729
|
+
*
|
|
730
|
+
* `applied: []` 是**结构性**的:调用方不必解析文本就能看出"一个 hunk 都没落盘"。
|
|
731
|
+
* 把它写成缺省字段会让消费方在 `value.applied.length` 上拿到 TypeError。
|
|
732
|
+
* 第四态也走这条(它同样一个 hunk 都没落盘),只是额外把证据放进 `already_applied`
|
|
733
|
+
* 并把 `unchanged` 置真;这两个字段只在那一态出现,前三态的回执形状**一字未变**
|
|
734
|
+
* (既有断言直接读 `value.outcome` / `value.code` / `value.candidates`,不受影响)。
|
|
735
|
+
*/
|
|
736
|
+
function intentReceipt(displayPath, verdict, dryRun) {
|
|
737
|
+
return {
|
|
738
|
+
path: displayPath,
|
|
739
|
+
outcome: verdict.outcome,
|
|
740
|
+
code: verdict.code,
|
|
741
|
+
message: verdict.message,
|
|
742
|
+
...(verdict.candidates === undefined ? {} : { candidates: verdict.candidates }),
|
|
743
|
+
// 可粘回的地址(见 intentVerdict 的注释):结构化那半,与正文里那行 `retry:` 同源同名。
|
|
744
|
+
...(verdict.retry === undefined ? {} : { retry: verdict.retry }),
|
|
745
|
+
// 第四态的可核证据:命中的行号、层级、那一行的**原文**,以及调用方给的那份文本。
|
|
746
|
+
...(verdict.already_applied === undefined
|
|
747
|
+
? {}
|
|
748
|
+
: { already_applied: verdict.already_applied, unchanged: true }),
|
|
749
|
+
next: verdict.next,
|
|
750
|
+
applied: [],
|
|
751
|
+
dryRun,
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* 失败回执抬头里那一句「磁盘动了没有」——**两种事实两个句子**。
|
|
757
|
+
*
|
|
758
|
+
* `landed: true` 只有一个来源:多目标的**阶段三**(回读校验)失败,引擎在那一条上如实标了
|
|
759
|
+
* `landed: true`(`write-path.mjs` 的 `failureItem(..., 'verify', true, ...)`)——磁盘上那份文件
|
|
760
|
+
* **已经被改**,只是回读证明不了它(`WRITE_VERIFY_FAILED`:“the write landed but …”)。
|
|
761
|
+
* 此时抬头若照旧印 “nothing was written”,回执就与磁盘、也与它自己的 `landed` 字段同时矛盾:
|
|
762
|
+
* 调用方会以为可以安心原样重发,而真实的下一步是**先重读**。
|
|
763
|
+
*
|
|
764
|
+
* 单目标那条路上这一态**抛错**(`WRITE_VERIFY_FAILED` 是工具级故障),拿不到回执、也不会带
|
|
765
|
+
* `landed` ⇒ 这一格是批内专有的:取值一改,单目标正文一个字节不动。
|
|
766
|
+
*/
|
|
767
|
+
const notWrittenClause = (value) =>
|
|
768
|
+
value.landed === true ? 'the write landed but this call could not verify it' : 'nothing was written'
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* 失败回执的渲染。
|
|
772
|
+
*
|
|
773
|
+
* 失败**不再抛错**,所以"模型看得见的那段文本"从此由这里给——而它必须与回执里的
|
|
774
|
+
* 结构化字段说同一件事:一个 hunk 都没落盘(不是"可能没落盘"),为什么,以及下一步。
|
|
775
|
+
*/
|
|
776
|
+
function renderFailure(value) {
|
|
777
|
+
const head = [
|
|
778
|
+
`${value.path}: ${value.dryRun === true ? 'dry run — ' : ''}${notWrittenClause(value)}`,
|
|
779
|
+
`outcome: ${value.outcome}${typeof value.code === 'string' && value.code.length > 0 ? ` (${value.code})` : ''}`,
|
|
780
|
+
].join(' — ')
|
|
781
|
+
const lines = [head, value.message]
|
|
782
|
+
if (Array.isArray(value.candidates) && value.candidates.length > 0) {
|
|
783
|
+
lines.push('Candidates:')
|
|
784
|
+
for (const c of value.candidates) {
|
|
785
|
+
lines.push(` line ${c.line}${typeof c.text === 'string' ? `: ${c.text}` : ''}`)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (typeof value.next === 'string' && value.next.length > 0) lines.push(`next: ${value.next}`)
|
|
789
|
+
return lines.join('\n')
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* 「已落地」回执的渲染(C49):**说清三件事**——没写盘、为什么没写、以及可核的证据。
|
|
794
|
+
*
|
|
795
|
+
* 为什么不能复用 `renderFailure`:它开头那句是 `nothing was written` 加上引擎的失败正文
|
|
796
|
+
* ("要改的原文不在文件里"),读起来就是"你得去改"——而这一态的全部内容恰好相反。
|
|
797
|
+
* 模型的下一步动作由这两句话决定,而它们的代价不对称:把"已经改好了"报成"找不到"会让它
|
|
798
|
+
* 重读、重寻址、甚至把文件改成错的样子;把"找不到"报成"已经改好了"则是静默失败。
|
|
799
|
+
* 所以两者必须是**两个不同的句子**,且各自带上自己的证据。
|
|
800
|
+
*
|
|
801
|
+
* 证据就是命中的那一行(`line` + `tier` + 原文)。它是**引擎逐字算出来的**:`actual` 是文件
|
|
802
|
+
* 里那一行的原文、`replaced` 是调用方给的那份文本——两者逐字相等才是"已经是你给的样子"。
|
|
803
|
+
* 引用时用 `JSON.stringify` 而不是裸拼:制表符、不可见空白、CR 都会在裸拼里变成"看着一样",
|
|
804
|
+
* 而这一态的全部价值就是让调用方**能自己核对**。
|
|
805
|
+
*/
|
|
806
|
+
function renderAlreadyApplied(value) {
|
|
807
|
+
const head = [
|
|
808
|
+
`${value.path}: ${value.dryRun === true ? 'dry run — ' : ''}nothing was written`,
|
|
809
|
+
`outcome: ${value.outcome}${typeof value.code === 'string' && value.code.length > 0 ? ` (${value.code})` : ''}`,
|
|
810
|
+
].join(' — ')
|
|
811
|
+
const lines = [head]
|
|
812
|
+
/**
|
|
813
|
+
* “为什么没写”不在这里另写一句,而是直接用回执里的 `message`(单源)。
|
|
814
|
+
* 两处各写一句的代价本仓已经付过:同一个判决写在两处,必有一处漏改。
|
|
815
|
+
* 它只在该字段缺位的 mock/降级形状下才回退到一句固定的。
|
|
816
|
+
*/
|
|
817
|
+
if (typeof value.message === 'string' && value.message.length > 0) lines.push(value.message)
|
|
818
|
+
else lines.push('nothing needs to change: the text you asked to end up there is already in the file as you gave it.')
|
|
819
|
+
lines.push('evidence (the file as it is now — check this is the region you meant):')
|
|
820
|
+
for (const hit of value.already_applied) {
|
|
821
|
+
lines.push(` line ${hit.line} [${hit.tier}]: ${quoteHit(hit.actual)}`)
|
|
822
|
+
}
|
|
823
|
+
if (typeof value.next === 'string' && value.next.length > 0) lines.push(`next: ${value.next}`)
|
|
824
|
+
return lines.join('\n')
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* 把一条命中原文引成**一行**:首行 JSON 转义 + 多行时自报还差几行。
|
|
829
|
+
*
|
|
830
|
+
* 转义是必须的(见 `renderAlreadyApplied` 的注释);截断只影响显示,不影响 `already_applied`
|
|
831
|
+
* 里那个逐字的字段——回执同时给了两份,要逐字核对的读字段,要一眼看懂的读这行。
|
|
832
|
+
*/
|
|
833
|
+
function quoteHit(text) {
|
|
834
|
+
const rows = String(text).split('\n')
|
|
835
|
+
const first = rows[0]
|
|
836
|
+
const clipped = first.length > 120 ? `${first.slice(0, 120)}…` : first
|
|
837
|
+
return `${JSON.stringify(clipped)}${rows.length > 1 ? ` +${rows.length - 1} more line(s)` : ''}`
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const FUZZY_TIERS = new Set(['norm-space', 'trim-line', 'similarity', 'aligned'])
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* 回执:**报告替换后的内容**,并说明这次命中的是哪个区间。
|
|
844
|
+
*
|
|
845
|
+
* 这里改过一次,起因是真实模型的一句反馈。原版引用的是 `actual`,也就是**改之前**那一段:
|
|
846
|
+
*
|
|
847
|
+
* line 6 [lines] " if (value === 0) return { kind: 'zero' };"
|
|
848
|
+
*
|
|
849
|
+
* 模型的评论逐字如下:
|
|
850
|
+
* "the tool's receipt echoes the addressed line … that reads as the *pre-replacement* content,
|
|
851
|
+
* not evidence the replacement failed."
|
|
852
|
+
*
|
|
853
|
+
* 它说得对:回执的全部用处就是让模型确认改对了,而显示改前内容做不到这一点——
|
|
854
|
+
* 反而让人怀疑是不是没改。现在报告**改后**内容,改前内容仅在两者都短时一并显示。
|
|
855
|
+
*/
|
|
856
|
+
function renderResult(value) {
|
|
857
|
+
const lines = [`${value.path}: ${value.applied.length} hunk(s) applied.`]
|
|
858
|
+
for (const a of value.applied) {
|
|
859
|
+
const flags = [a.tier, a.byNear ? 'by near' : null].filter(Boolean).join(', ')
|
|
860
|
+
const clip = (t) => {
|
|
861
|
+
const one = t.split('\n')[0]
|
|
862
|
+
const multi = t.includes('\n')
|
|
863
|
+
const short = one.length <= 64
|
|
864
|
+
return multi || !short ? `${JSON.stringify(short ? one : `${one.slice(0, 64)}…`)}${multi ? ` +${t.split('\n').length - 1} more line(s)` : ''}` : JSON.stringify(one)
|
|
865
|
+
}
|
|
866
|
+
const after = a.replaced ?? ''
|
|
867
|
+
// 单行 → 单行的替换,把 before → after 都给出;否则只给改后,避免刷屏
|
|
868
|
+
const singleLineSwap = !a.actual.includes('\n') && !after.includes('\n')
|
|
869
|
+
lines.push(
|
|
870
|
+
singleLineSwap
|
|
871
|
+
? ` line ${a.line} [${flags}] ${clip(a.actual)} → ${clip(after)}`
|
|
872
|
+
: ` line ${a.line} [${flags}] now: ${clip(after)}`,
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
if (value.applied.some((a) => FUZZY_TIERS.has(a.tier))) {
|
|
876
|
+
lines.push(' (a hunk matched by fallback — confirm the quoted text is the region you meant.)')
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* 靠 `near` 收敛出来的命中,把**那条确定地址**一并给出。
|
|
880
|
+
*
|
|
881
|
+
* 理由:`near` 是模型唯一的"歧义时也能试一下"的手段,而它这次之所以生效,是因为文件里
|
|
882
|
+
* 确实存在那个提示行。把这个事实变成可复用的坐标,下一次就不必再赌一次 `near`。
|
|
883
|
+
* 不增加任何代价:坐标在 `applied[].line` 里已经算好了,只是没说出来。
|
|
884
|
+
*/
|
|
885
|
+
const byNear = value.applied.filter((a) => a.byNear === true)
|
|
886
|
+
if (byNear.length > 0) {
|
|
887
|
+
lines.push(
|
|
888
|
+
` (${byNear.length} hunk(s) resolved by the \`near\` hint — next time address ${byNear
|
|
889
|
+
.map((a) => `line ${a.line}`)
|
|
890
|
+
.join(', ')} directly, no hint needed.)`,
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
return lines.join('\n')
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* 单目标回执**正文**的四种分叉(成功 / 失败 / 已是你给的样子 / 预演)——**唯一一份**。
|
|
898
|
+
*
|
|
899
|
+
* 它原来是写在 `render` 里的一个三目链;多目标逐项要的正是同一段文本,所以提出来共用
|
|
900
|
+
* (表达式逐字未改 ⇒ 单目标正文逐字节不变)。句柄披露不在里面:它由调用处append(见 `render`),
|
|
901
|
+
* 而多目标按**元素**逐项 append(每一项目己的 `handle` hunk 自己披露)。
|
|
902
|
+
*/
|
|
903
|
+
function receiptBody(value) {
|
|
904
|
+
if (value.outcome === 'already_applied') return renderAlreadyApplied(value)
|
|
905
|
+
if (value.outcome !== 'applied') return renderFailure(value)
|
|
906
|
+
return value.dryRun === true
|
|
907
|
+
? `Dry run for ${value.path} — every hunk matched; nothing was written.\n${renderResult(value)}`
|
|
908
|
+
: renderResult(value)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* 引擎逐项那格 `outcome`(`applied` / `created` / `already_applied` / `dry_run`)→
|
|
913
|
+
* **壳层回执**那格 `outcome`({@link OUTCOMES} 里那几格)。
|
|
914
|
+
*
|
|
915
|
+
* 两个词表问的不是同一件事,所以不能直接对拷:
|
|
916
|
+
* · 引擎按“**它怎么做到的**”分类(改成了 / 建成了 / 预演过了 / 已经是你要的样子);
|
|
917
|
+
* · 壳层的成功词表只有两格——`applied` 与 `already_applied`,而“建成”与“预演”
|
|
918
|
+
* 由**别的字段**表达(`applied[].tier === 'create'`、`dryRun === true`)。单目标那条路上
|
|
919
|
+
* 它们也正是 `outcome: 'applied'`(见 `registerTools` 里三个成功 return 与 `intentReceipt`)。
|
|
920
|
+
*
|
|
921
|
+
* 逐格有据,且逐格只对应引擎 {@link receiptKindOf} 的一个分支(`write-path.mjs`):
|
|
922
|
+
* 那张表只有这四种产出,第五种不存在,所以这里不需要缺省分支去猜。
|
|
923
|
+
*/
|
|
924
|
+
const SHELL_OUTCOME_BY_ENGINE_KIND = new Map([
|
|
925
|
+
['applied', 'applied'],
|
|
926
|
+
['created', 'applied'],
|
|
927
|
+
['dry_run', 'applied'],
|
|
928
|
+
['already_applied', 'already_applied'],
|
|
929
|
+
])
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* 逐项回执进**同一个** `receiptBody` 之前,把壳层那格 `outcome` 补齐。
|
|
933
|
+
*
|
|
934
|
+
* 为什么非补不可(实测缺陷,0.6.12):`receiptBody` 按 `value.outcome` 分四支,而单目标那条路上
|
|
935
|
+
* 这一格是**壳层自己填的**;引擎回执只有 `already_applied` 那一态自带 `outcome`,成功 / 创建 /
|
|
936
|
+
* 预演三态把“怎么做到的”记在 `applied[].tier` / `operation` / `dryRun` 上。不补这一格,
|
|
937
|
+
* `undefined !== 'applied'` 会把一次**已经落盘**的编辑交给失败渲染器,正文印出
|
|
938
|
+
* 「nothing was written — outcome: undefined」:抬头那行说 `applied`、正文说“什么都没写”,
|
|
939
|
+
* 同一条回执自相矛盾,模型据此去重试一次本来就成功的编辑。
|
|
940
|
+
*
|
|
941
|
+
* 取的是引擎**已经算好**的那个事实(逐项 `outcome`,见 `write-path.mjs` 的 `receiptKindOf`),
|
|
942
|
+
* 壳层本来就在读它(`batchOutcomeOf` 的整批判决、正文抬头那行)。这里只做**词表对齐**:
|
|
943
|
+
* 不新增判据、不新增渲染分支、不写第二份排版——分叉仍全部由 `receiptBody` 那三行决定。
|
|
944
|
+
*
|
|
945
|
+
* `already_applied` 那一态引擎自带 `outcome`,原样透传(逐字节不动)。冒出一个表外的
|
|
946
|
+
* 引擎态时**不编**一个壳层词:原样交下去,宁可让缺陷可见,也不把未知说成成功。
|
|
947
|
+
*/
|
|
948
|
+
function shellReceiptOf(edit) {
|
|
949
|
+
const receipt = edit.receipt
|
|
950
|
+
if (typeof receipt.outcome === 'string') return receipt
|
|
951
|
+
const outcome = SHELL_OUTCOME_BY_ENGINE_KIND.get(edit.outcome)
|
|
952
|
+
return outcome === undefined ? receipt : { ...receipt, outcome }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* 逐项正文的失败那一支:引擎的 `error`(`code`/`message`/`next`)拼成**同一个**失败渲染器
|
|
957
|
+
* 认得的那本书——本文件里不写第二份失败排版。
|
|
958
|
+
*/
|
|
959
|
+
/**
|
|
960
|
+
* 逐项正文:`receipt` 在场就走 `receiptBody`(**同一个**渲染器,成功/失败/已改好/预演四种分叉
|
|
961
|
+
* 一个不差);没成的那些(阶段一/二的失败,以及“同批里另一项没规划成 ⇒ 本轮一字未写”)
|
|
962
|
+
* 由引擎给了 `error`(`code`/`message`/`next`)或 `note`,拼成 `renderFailure` 认得的形状
|
|
963
|
+
* ——本文件里**不写第二份**失败排版。
|
|
964
|
+
*
|
|
965
|
+
* 句柄披露是按**元素**逐项 append 的(每一项目己的 `handle` hunk 自己披露),
|
|
966
|
+
* 而单目标那条路的披露仍然只在 `render` 里 append 一次(无句柄 hunk 时一个字符不加)。
|
|
967
|
+
*/
|
|
968
|
+
function batchItemBody(edit, element) {
|
|
969
|
+
if (edit.receipt !== undefined) return appendHandleNotes(receiptBody(shellReceiptOf(edit)), element)
|
|
970
|
+
const failure = {
|
|
971
|
+
path: edit.file_path,
|
|
972
|
+
outcome: typeof edit.outcome === 'string' ? edit.outcome : 'not_found',
|
|
973
|
+
// 已落地却没收尾(阶段三回读校验失败)那一项:抬头那句不能再说“没写盘”(见 `notWrittenClause`)。
|
|
974
|
+
// 只在真的为 true 时带上,其余形状逐字不变。
|
|
975
|
+
...(edit.landed === true ? { landed: true } : {}),
|
|
976
|
+
...(edit.error === undefined ? {} : { code: edit.error.code }),
|
|
977
|
+
message: edit.error?.message ?? edit.note ?? '',
|
|
978
|
+
...(edit.error?.next === undefined ? {} : { next: edit.error.next }),
|
|
979
|
+
}
|
|
980
|
+
return appendHandleNotes(renderFailure(failure), element)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* 整批的三态 `outcome`(输出 schema 的 enum 只有 `OUTCOMES` 那几格)。
|
|
985
|
+
*
|
|
986
|
+
* 判决本体是**逐项**的(契约 §1.3):这一格只回答“还有没有得看的”。全落地 ⇒ `applied`;
|
|
987
|
+
* 全部早就是你给的样子 ⇒ `already_applied`;否则取**第一条失败**的码去查本文件既有那张
|
|
988
|
+
* `OUTCOME_BY_CODE` 表(与单目标同一个映射面),查不到就不编一个:回 `not_found`。
|
|
989
|
+
*/
|
|
990
|
+
function batchOutcomeOf(edits) {
|
|
991
|
+
const failed = edits.filter((edit) => edit.outcome === 'failed')
|
|
992
|
+
if (failed.length === 0) {
|
|
993
|
+
return edits.every((edit) => edit.outcome === 'already_applied') ? 'already_applied' : 'applied'
|
|
994
|
+
}
|
|
995
|
+
const mapped = OUTCOME_BY_CODE.get(failed[0].error?.code)
|
|
996
|
+
/**
|
|
997
|
+
* `OUTCOME_BY_CODE` 的值就是**壳层那格词本身**(字符串),不是 `{outcome, …}` 那种对象
|
|
998
|
+
* (对象形状只有 `SECOND_EXIT_BY_CODE` 那张表才有)。这里曾经写成 `mapped?.outcome`:
|
|
999
|
+
* 在字符串上取 `.outcome` 永远是 `undefined` ⇒ 任何一批带失败的调用都落回 `not_found`,
|
|
1000
|
+
* 于是一批“两处都能对上、等你挑一处”的编辑被整批报成 `not_found`——两者要的恢复动作
|
|
1001
|
+
* 正好相反(重挑地址 vs 重读文件)。判据:`src/aura-edit/outcome-test.mjs` 段I 的 I5。
|
|
1002
|
+
*/
|
|
1003
|
+
return typeof mapped === 'string' ? mapped : 'not_found'
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* **多目标写**(fs-contract-v2 §2.2/§3):`targets` ⇒ 引擎入口 `applyEditsToProvider`。
|
|
1008
|
+
*
|
|
1009
|
+
* ## provider 怎么接
|
|
1010
|
+
*
|
|
1011
|
+
* 引擎的 provider 契约是 `(displayPath, signal)`——它**不碰** `node:fs`,路径解析与沙箱策略
|
|
1012
|
+
* 都必须在壳这层递进去(沙箱策略照第 5 个参数,与单目标那条路同一个位置;漏传会让本工具在
|
|
1013
|
+
* `workspace-write` 下写不动)。同一路径不得解析多次(真实后端上是 realpath)⇒ 按路径
|
|
1014
|
+
* 记忆化一个 Promise,每条路径最多一次 `ctx.fs.resolve`。
|
|
1015
|
+
*
|
|
1016
|
+
* ## 单目标那条路
|
|
1017
|
+
*
|
|
1018
|
+
* 一个字没动:`targets` 缺席时 `assertEditTargets` 立即返回 undefined(纯计算,零 provider 调用),
|
|
1019
|
+
* `execute` 继续走它今天的 `ctx.fs.resolve/stat/readText/writeText` 序列。
|
|
1020
|
+
*/
|
|
1021
|
+
async function applyEditTargets(ctx, exec, args, targets, policy) {
|
|
1022
|
+
const cwd = policy?.workspaceRoot ?? exec.agent?.session?.header?.cwd
|
|
1023
|
+
const signal = exec.signal
|
|
1024
|
+
const pending = new Map()
|
|
1025
|
+
const resolved = (displayPath) => {
|
|
1026
|
+
let promise = pending.get(displayPath)
|
|
1027
|
+
if (promise === undefined) {
|
|
1028
|
+
promise = Promise.resolve(ctx.fs.resolve(displayPath, { ...(cwd === undefined ? {} : { cwd }), signal }))
|
|
1029
|
+
pending.set(displayPath, promise)
|
|
1030
|
+
}
|
|
1031
|
+
return promise
|
|
1032
|
+
}
|
|
1033
|
+
const provider = {
|
|
1034
|
+
stat: async (displayPath, at) => ctx.fs.stat(await resolved(displayPath), at ?? signal),
|
|
1035
|
+
readText: async (displayPath, at) => ctx.fs.readText(await resolved(displayPath), at ?? signal),
|
|
1036
|
+
readByteRange: async (displayPath, range, at) =>
|
|
1037
|
+
ctx.fs.readByteRange(await resolved(displayPath), { offset: range.offset, length: range.length }, at ?? signal),
|
|
1038
|
+
writeText: async (displayPath, content, expected, at) =>
|
|
1039
|
+
ctx.fs.writeText(await resolved(displayPath), content, expected, at ?? signal, policy),
|
|
1040
|
+
}
|
|
1041
|
+
const batch = await applyEditsToProvider(provider, {
|
|
1042
|
+
targets,
|
|
1043
|
+
...(args.dry_run === undefined ? {} : { dryRun: args.dry_run }),
|
|
1044
|
+
...(signal === undefined ? {} : { signal }),
|
|
1045
|
+
})
|
|
1046
|
+
const elements = Array.isArray(args.targets) ? args.targets : []
|
|
1047
|
+
const edits = batch.edits.map((edit) => ({ ...edit, text: batchItemBody(edit, elements[edit.index]) }))
|
|
1048
|
+
return {
|
|
1049
|
+
// `path` 是必填字段,而这一批没有“一个路径”——给它整批的事实(逐项路径在 `edits` 里)。
|
|
1050
|
+
path: `${batch.targets} targets`,
|
|
1051
|
+
outcome: batchOutcomeOf(edits),
|
|
1052
|
+
// 顶层 `applied` = 全批 hunk 的并集(逐项各自的 `applied` 在 `edits[i].receipt` 里,一字未改)。
|
|
1053
|
+
applied: edits.flatMap((edit) => edit.receipt?.applied ?? []),
|
|
1054
|
+
dryRun: edits.some((edit) => edit.receipt?.dryRun === true),
|
|
1055
|
+
targets: batch.targets,
|
|
1056
|
+
// 汇总行**只在这里**(引擎算的那一行原样带回来):不进任何一项的正文。
|
|
1057
|
+
summary: batch.summary,
|
|
1058
|
+
unchanged: batch.unchanged,
|
|
1059
|
+
// “哪些已落地、哪些没有”(契约 §3.4):阶段一失败时 landed_paths 为空、not_landed_paths 全在。
|
|
1060
|
+
landed_paths: batch.landed_paths,
|
|
1061
|
+
...(batch.not_landed_paths === undefined ? {} : { not_landed_paths: batch.not_landed_paths }),
|
|
1062
|
+
...(batch.aborted === undefined ? {} : { aborted: batch.aborted }),
|
|
1063
|
+
...(batch.partial === undefined ? {} : { partial: batch.partial }),
|
|
1064
|
+
edits,
|
|
1065
|
+
// 正文 = 逐项正文的顺序拼接(每项一行 `路径 — outcome` 抬头,否则 N 段正文认不出归属)。
|
|
1066
|
+
text: edits.map((edit) => `${edit.file_path} — ${edit.outcome}\n${edit.text}`).join('\n\n'),
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// ── 插件 ────────────────────────────────────────────────────────────────────
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* 注册 `aura_edit` 工具,以及配套的提示段。
|
|
1074
|
+
*
|
|
1075
|
+
* ## 为什么必须同时注册提示段
|
|
1076
|
+
*
|
|
1077
|
+
* 只注册工具是不够的——实测结论。内置 `tool-fs` 会注入命令式指导:
|
|
1078
|
+
* "Use the edit tool for targeted changes to existing UTF-8 text files",
|
|
1079
|
+
* 而本工具只有 schema、没有一句话指导。于是模型面对"改文件"时,提示词明确说用 `edit`。
|
|
1080
|
+
*
|
|
1081
|
+
* 位置放在 `TOOL_EDIT`(1300) 之后:提示词里靠后的指导压过靠前的。
|
|
1082
|
+
*
|
|
1083
|
+
* ## 为什么策略服务是可选注入
|
|
1084
|
+
*
|
|
1085
|
+
* Resolved through `ctx.inject([...])` instead of being declared in this plugin's own
|
|
1086
|
+
* `inject` — the same treatment `dsh-tool-fs` gives the optional `attachments` service.
|
|
1087
|
+
* The distinction matters: a hard declaration parks the whole plugin in a waiting state
|
|
1088
|
+
* when the service is absent, so the tools would never register, yet a deployment with no
|
|
1089
|
+
* sandbox is exactly the one that needs no policy. With `ctx.inject([...])` the callback
|
|
1090
|
+
* binds when the service appears and simply does not bind otherwise.
|
|
1091
|
+
*
|
|
1092
|
+
* Once resolved, the {@link registerTools} closure holds it and computes the calling
|
|
1093
|
+
* session's policy on every execution.
|
|
1094
|
+
*
|
|
1095
|
+
* @param ctx - 携带文件系统、工具注册表与提示词注册表的上下文。
|
|
1096
|
+
*/
|
|
1097
|
+
export function apply(ctx) {
|
|
1098
|
+
let policyService
|
|
1099
|
+
ctx.inject(['sandboxPolicy'], (policyCtx) => {
|
|
1100
|
+
policyService = policyCtx.sandboxPolicy
|
|
1101
|
+
})
|
|
1102
|
+
registerTools(ctx, () => policyService)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* 注册工具与提示段。
|
|
1107
|
+
*
|
|
1108
|
+
* 与 `dsh-tool-fs` 的 `apply(ctx, sandbox)` 同形:策略是**参数**而不是闭包里的全局,
|
|
1109
|
+
* 于是"哪一份策略生效"在调用点一眼可见。
|
|
1110
|
+
*
|
|
1111
|
+
* @param ctx - 上下文。
|
|
1112
|
+
* @param sandbox - 返回当前 `sandboxPolicy` 服务的取值函数(可能返回 undefined)。
|
|
1113
|
+
*/
|
|
1114
|
+
function registerTools(ctx, sandbox) {
|
|
1115
|
+
// 与 dsh-tool-fs 一致:按 scope 检查工具可见性。`systemPrompt` 已声明进 inject,
|
|
1116
|
+
// 因此直接访问——用 `ctx.get` 兜会静默跳过,而"工具注册了却没有指导"正是踩过的坑。
|
|
1117
|
+
ctx.systemPrompt.section({
|
|
1118
|
+
name: 'tool:aura-edit',
|
|
1119
|
+
order: 1320,
|
|
1120
|
+
text: ({ scope }) =>
|
|
1121
|
+
ctx.tools.get('aura_edit', scope) === undefined
|
|
1122
|
+
? ''
|
|
1123
|
+
: [
|
|
1124
|
+
// 措辞刻意与内置指导**同形**。这一点是量出来的:
|
|
1125
|
+
// 内置注入的是命令式——"Use the edit tool for targeted changes to existing UTF-8 text files",
|
|
1126
|
+
// 而我先前写的是建议式——"prefer aura_edit over edit"。
|
|
1127
|
+
// 实测同一份建议式指导,新建会话里 4 次选了 Aura、2 次选了内置——**它在跟先验拔河**。
|
|
1128
|
+
// 语气不对等,就是在让一条软建议去对抗一条硬指令。
|
|
1129
|
+
// 开头不给平级选项:上一版写了"兜底可用内置 edit",中性任务下它直接拿来当依据(实测)。
|
|
1130
|
+
'Use the aura_edit tool — not edit — for every change to an existing UTF-8 text file: it is the default.',
|
|
1131
|
+
// 三条保证是**量出来的差异**(不是宣传):字节保真、陈旧区域拒绝、落盘后回读。
|
|
1132
|
+
// 内置 edit 同条件下会丢 BOM、把混合行尾整份归一(2026-09-22 两侧实测)。
|
|
1133
|
+
'It writes back byte-for-byte (a leading BOM and each line\'s own ending survive), refuses a hunk whose addressed region changed since your last read, and reads the file back to verify the write.',
|
|
1134
|
+
'Address each hunk the cheapest way: `target` (a symbol name) when you know it, else `start_line` + `end_line` (or `lines: [a, b]`) for the range a read printed, else `line_cue` (one word unique to that line); `anchor` (remembered text) is the last resort and tolerates whitespace drift, rewording, and added or removed lines.',
|
|
1135
|
+
// 措辞从"可以传"改成"应当传",依据是一次受控并发实验(N 个写者改同一文件):
|
|
1136
|
+
// 当编辑**改变行数**、而一次重试又用**行号地址**时,重试会把改动写到**错误的行**上
|
|
1137
|
+
// 并回一句成功回执(实测:~50% 的轮次以"非预期内容 + 成功回执"结束,每个轮次约 1.5 行附带损伤)。
|
|
1138
|
+
// 加上 `expect` 后附带损伤降到 0.00,100% 得到干净合入;
|
|
1139
|
+
// 而对**不改行数**的编辑,它是**零成本**的(收敛率不变)。
|
|
1140
|
+
// 也就是说:这一项是唯一把"静默写错行"变成诚实失败的机制,而它的代价是零。
|
|
1141
|
+
'Pass `expect` (the text you believe is at that location) on every hunk when you address by `lines`, `start_line`/`end_line`, or `line_cue`: if the file shifted since you read it, `expect` turns a silent wrong-line write into a loud failure, and it costs nothing on edits that do not move lines.',
|
|
1142
|
+
// 刻意**不再**写"未知扩展名请用内置 edit"。那句话是错的:只有 `target` 需要语言配置
|
|
1143
|
+
// (符号表只对已索引的扩展名存在),而 `lines`/`line_cue`/`anchor` 三种寻址根本不看语言,
|
|
1144
|
+
// 创建路径更是与寻址无关。它恰好在工具可用的地方把模型赶回内置工具。
|
|
1145
|
+
'Extensions aura_edit cannot index (config, data, markup) are still fully editable — address them with `lines`, `line_cue`, or `anchor` instead of `target`.',
|
|
1146
|
+
// 多目标:**这一步是为了采用率**(判据⑤)。`targets` 在参数表里一直就有,但模型不会因为
|
|
1147
|
+
// “参数表里有个数组”就想到要批量;指导文本是它最先读的一段。措辞命令式、与上下文同形。
|
|
1148
|
+
// 语义只说真的(不吹“要么全成要么全不落”):规划阶段任何一项失败 ⇒ **一个字节都不写**;
|
|
1149
|
+
// 而写入阶段失败时,回执会逐项点名哪些落了、哪些没落(`landed_paths` / `not_landed_paths`)。
|
|
1150
|
+
'To change several files at once, pass `targets` (each item the same single-target arguments): if any target fails to plan, the whole batch writes nothing — and if a write itself fails, the receipt names exactly which targets landed.',
|
|
1151
|
+
// 兜底:内置 `write` 是整份覆写的唯一手段
|
|
1152
|
+
// 兜底只列 aura_edit **刻意不做**的两件事(整份覆写、拒绝改行尾的文件),以及无替代的读图;
|
|
1153
|
+
// 刻意不把内置 `edit` 写进来:它在同样场景会丢 BOM / 归一行尾,不是等价兜底。
|
|
1154
|
+
'The built-in `write` remains for what aura_edit deliberately does not do — replacing a whole file, or writing one whose line endings it refuses to touch; `read_image` is still the only way to read an image.',
|
|
1155
|
+
].join(' '),
|
|
1156
|
+
})
|
|
1157
|
+
|
|
1158
|
+
ctx.tools.register(
|
|
1159
|
+
defineTool({
|
|
1160
|
+
name: 'aura_edit',
|
|
1161
|
+
description:
|
|
1162
|
+
'Edit or create a UTF-8 text file with hunks, in a single call. ' +
|
|
1163
|
+
'For several files, pass `targets` — an array of the same single-target arguments (`file_path` + `hunks`); it is mutually exclusive with `file_path`. ' +
|
|
1164
|
+
'Every target is planned before anything is written: if any target fails to plan, nothing is written at all, and if a write itself fails the receipt names exactly which targets landed. ' +
|
|
1165
|
+
'Address each hunk in the cheapest way you can: `target` (a symbol name — immune to misremembered content), or `start_line` + `end_line` (preferred: two plain numbers, no array syntax to get wrong — same as `lines: [a, b]`), or `line_cue` (one word unique to that line), or `anchor` (remembered text, matched with graceful degradation). Only `anchor` requires you to reproduce content; prefer the other three. Pass `expect` to assert what is at that location — it is checked in every mode. All hunks must locate or nothing is written. Omit keys you do not need — an explicit `null` is rejected, not ignored.',
|
|
1166
|
+
parameters: {
|
|
1167
|
+
/**
|
|
1168
|
+
* `file_path` 不再声明 `required: true` —— 这是多目标形状唯一逼出来的一处**声明面**改动。
|
|
1169
|
+
*
|
|
1170
|
+
* 宿主把 `required: true` 编译成参数面的 `required: ['file_path']`(`load-test` 段直接
|
|
1171
|
+
* 读编译结果),于是 `{targets:[…]}` 会在参数层就被拒、根本到不了 execute。
|
|
1172
|
+
*
|
|
1173
|
+
* “二选一”想回 schema:MT7 试过 `anyOf: [{required:['file_path']}, {required:['targets']}]`,
|
|
1174
|
+
* **本框架方言里不可表达**——`anyOf`/`allOf` 不在允许关键字子集内(
|
|
1175
|
+
* `type/oneOf/properties/required/additionalProperties/items/enum/const`),而校验器对 `anyOf`
|
|
1176
|
+
* 是**静默忽略**(`{}` 照样过),所以那会是一条没有效果的声明;能表达“恰取其一”的 `oneOf`
|
|
1177
|
+
* 又与根上的 `type: 'object'` 不能共存。完整证据与两条端点断言见 `aura-read/index.js`
|
|
1178
|
+
* 里 `file_path` 声明处那段注释(两壳同一条理由,不重抄)。
|
|
1179
|
+
*
|
|
1180
|
+
* 所以“单目标形状必须有 file_path”**由运行期判**:execute 里那句
|
|
1181
|
+
* `file_path must be a non-empty string` 一字未改,而单目标调用本来就带 file_path
|
|
1182
|
+
* ⇒ 行为、回执、provider 调用序列全部不变;“两个都给”由唯一判据 `assertEditTargets`
|
|
1183
|
+
* (`ARGS_SHAPE_EXCLUSIVE`)拒,两条阵地在 `arg-shape-test` 段C 与
|
|
1184
|
+
* `real-framework-smoke-test` 段C **端到端**锤着。
|
|
1185
|
+
*/
|
|
1186
|
+
file_path: {
|
|
1187
|
+
type: 'string',
|
|
1188
|
+
description: 'Path to patch, resolved by the filesystem backend.',
|
|
1189
|
+
},
|
|
1190
|
+
/**
|
|
1191
|
+
* 多目标:一次调用改多个文件(互斥于 `file_path`/`hunks`/平铺形状)。
|
|
1192
|
+
*
|
|
1193
|
+
* 元素就是**今天那套单目标参数**(`file_path` + `hunks`,或 `create`,或平铺的
|
|
1194
|
+
* `target`/`lines`/… + `replace`),元素的键在这里 `additionalProperties: true`:
|
|
1195
|
+
* 逐个声明等于把单目标那 14 个键抄第二遍,而两份清单必然漂。逐项校验由引擎走同一套
|
|
1196
|
+
* 单目标校验器(契约 §2.2)。
|
|
1197
|
+
*/
|
|
1198
|
+
targets: {
|
|
1199
|
+
type: 'array',
|
|
1200
|
+
items: { type: 'object', additionalProperties: true },
|
|
1201
|
+
description:
|
|
1202
|
+
'Patch several files in one call: an array of the same single-target arguments. ' +
|
|
1203
|
+
'Mutually exclusive with `file_path` — pass one or the other. Nothing is written unless every target can be planned; ' +
|
|
1204
|
+
'the receipt names the files that landed and the ones that did not. Output order = input order.',
|
|
1205
|
+
},
|
|
1206
|
+
hunks: {
|
|
1207
|
+
type: 'array',
|
|
1208
|
+
// 不在 schema 层声明 required:创建路径(`create`)不需要 hunks。
|
|
1209
|
+
// "必须给 hunks 或 create 之一"这条约束由运行期判——schema 层表达不了"二选一",
|
|
1210
|
+
// 硬写成必填会让创建功能被自己的 schema 挡住。
|
|
1211
|
+
description:
|
|
1212
|
+
'The changes to apply. Every hunk must locate, or the file is left untouched. Each hunk locates either by `target` (a symbol name — preferred when you know it) or by `anchor` (remembered text). Not needed when `create` is used.',
|
|
1213
|
+
/**
|
|
1214
|
+
* `items` 里**不写 `required`**。
|
|
1215
|
+
*
|
|
1216
|
+
* 本框架的 `required` 是对象上的**数组关键字**(顶层 `required: [...]` 被接受、`{}` 被拒),
|
|
1217
|
+
* 但把它放进 `items` 会被拒:`parameters.hunks.items.required is not supported by the value schema DSL`
|
|
1218
|
+
* (实测,插件直接注册失败)。而写数组本身无效——实测嵌套的 `items.required: ['a']`
|
|
1219
|
+
* 虽然被接受,却**不产生任何约束**(`{}` 照样通过)。
|
|
1220
|
+
*
|
|
1221
|
+
* 所以“一个 hunk 必须有 replace”这条约束**只在运行期判**:`planHunks` 读不到
|
|
1222
|
+
* `replace` 会报 `PATCH_NO_HUNKS`/定位失败,而 `aura-edit/load-test` 守着那条回执。
|
|
1223
|
+
* 把一条**没有效果的**声明留在 schema 里,比不写更坏——它让人以为有闸门。
|
|
1224
|
+
*/
|
|
1225
|
+
items: { type: 'object', additionalProperties: false, properties: HUNK_PROPERTIES },
|
|
1226
|
+
},
|
|
1227
|
+
// ── 单个 hunk 的键**也**在顶层声明(平铺形状)──────────────────────
|
|
1228
|
+
//
|
|
1229
|
+
// `flatHunk()` 从 02:37 起就支持“一个 hunk 不包数组、直接写在顶层”,
|
|
1230
|
+
// 但顶层 schema 一直没声明那 9 个键。后果有二,都由实测确认:
|
|
1231
|
+
// · 真框架的参数校验对未声明键**零违规**(只在 `additionalProperties:false` 时才拒)⇒
|
|
1232
|
+
// 长久以来没有任何东西拦住它们,属于“声明与实装不一致”;
|
|
1233
|
+
// · 会话日志里 35 次平铺调用有 29 次失败于 `hunks must be a non-empty array`——
|
|
1234
|
+
// 那些发生在支持实装**之前**,而支持之后日志里的平铺调用**是成功的**,
|
|
1235
|
+
// 说明框架**不会**剥掉未声明参数、平铺真的能用。
|
|
1236
|
+
// 所以这里不是新增能力,而是**把已实装的能力补进声明面**,并让它与守卫的名单一致。
|
|
1237
|
+
/**
|
|
1238
|
+
* 平铺形状:把 hunk 属性展开到顶层——用**剥过 `required`** 的那一份。
|
|
1239
|
+
*
|
|
1240
|
+
* 平铺形状下“必须有 replace”由 `flatHunk()` 在运行期判:
|
|
1241
|
+
* 顶层有地址键但没 replace ⇒ 报“缺 hunks/create”(`arg-shape-test` 段B 守着)。
|
|
1242
|
+
* 这里**不能再展开一次完整表**:那会把 `replace` 提升成顶层 required,
|
|
1243
|
+
* 于是 `{file_path, hunks:[…]}` 与 `{file_path, create:…}` 两条正常路径全被拒
|
|
1244
|
+
* (实测:`aura-edit/load-test` 与 `real-framework-smoke-test` 同时变红)。
|
|
1245
|
+
*/
|
|
1246
|
+
...HUNK_PROPERTIES_OPTIONAL,
|
|
1247
|
+
dry_run: {
|
|
1248
|
+
type: 'boolean',
|
|
1249
|
+
description: 'Locate every hunk and report where they would land, without touching the file.',
|
|
1250
|
+
},
|
|
1251
|
+
create: {
|
|
1252
|
+
type: 'string',
|
|
1253
|
+
description:
|
|
1254
|
+
'Full content for a file that does not exist yet. Only used when the path is absent — if the file exists this is ignored and the hunks apply normally instead, so an accidental overwrite is impossible.',
|
|
1255
|
+
},
|
|
1256
|
+
},
|
|
1257
|
+
/**
|
|
1258
|
+
* 与 {@link READ_ARG_KEYS} 同理:参数面**也要** `additionalProperties: false`。
|
|
1259
|
+
* 旧版只有 `output.schema` 写了它 ⇒ `{file_path, mode:'auto'}` 这种属于别的工具的参数
|
|
1260
|
+
* 可以零违规穿过框架,然后被本工具静默忽略。
|
|
1261
|
+
*/
|
|
1262
|
+
additionalProperties: false,
|
|
1263
|
+
output: {
|
|
1264
|
+
schema: {
|
|
1265
|
+
type: 'object',
|
|
1266
|
+
additionalProperties: false,
|
|
1267
|
+
properties: {
|
|
1268
|
+
path: { type: 'string', required: true },
|
|
1269
|
+
applied: {
|
|
1270
|
+
type: 'array',
|
|
1271
|
+
required: true,
|
|
1272
|
+
items: {
|
|
1273
|
+
type: 'object',
|
|
1274
|
+
additionalProperties: false,
|
|
1275
|
+
properties: {
|
|
1276
|
+
line: { type: 'integer', required: true },
|
|
1277
|
+
tier: { type: 'string', required: true },
|
|
1278
|
+
byNear: { type: 'boolean', required: true },
|
|
1279
|
+
actual: { type: 'string', required: true },
|
|
1280
|
+
replaced: { type: 'string', required: true },
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
},
|
|
1284
|
+
dryRun: { type: 'boolean', required: true },
|
|
1285
|
+
/**
|
|
1286
|
+
* outcome(**必填**):每一次调用都能回答"到底改成了没有"。
|
|
1287
|
+
*
|
|
1288
|
+
* 为什么要把它设成 required:失败现在走**正常结果**而不是抛错,调用方若只看
|
|
1289
|
+
* `applied` 的空/不空来判成败,就得先猜"空数组是真的没改,还是工具坏了"。
|
|
1290
|
+
* 一个必填字段把这条猜测变成读取。
|
|
1291
|
+
*
|
|
1292
|
+
* `enum` 与 `OUTCOMES` 是同一份名字:schema 是**验证面**,常量是**产出面**,
|
|
1293
|
+
* 两边不一致会让工具在运行时吐出一个自己声明为非法值——那比不声明更坏。
|
|
1294
|
+
*
|
|
1295
|
+
* C49 实测的正是这个失败模式:`defineTool` 会把这份声明编译成 JSON Schema
|
|
1296
|
+
* (`dsh-tools/lib/index.js:847` 的 `valueSchemaSpecToJsonSchema`),execute 的返回值
|
|
1297
|
+
* 是真过一遭 `validateJsonSchemaValue` 的(`:3417`),不合格就抛
|
|
1298
|
+
* `INVALID_TOOL_OUTPUT`(`:2458`)把**整条回执**顶掉。所以第四态不进 enum 的后果
|
|
1299
|
+
* 不是"渲染得难看",而是模型什么也看不到——"半接"里最坏的那一格。
|
|
1300
|
+
*/
|
|
1301
|
+
outcome: { type: 'string', enum: OUTCOMES, required: true },
|
|
1302
|
+
// 引擎原样的判决码(`LOCATOR_ASSERT_MISMATCH` / `PATCH_AMBIGUOUS` …)。
|
|
1303
|
+
// outcome 是粗分类,码才是精确原因;丢掉它就得从 message 里正则抠回来。
|
|
1304
|
+
code: { type: 'string' },
|
|
1305
|
+
// 判定不出意图时的诊断正文(含"改用 target= 重试"那类恢复建议)。
|
|
1306
|
+
message: { type: 'string' },
|
|
1307
|
+
/**
|
|
1308
|
+
* 候选位置——`needs_choice` 的全部价值就在这个字段里。
|
|
1309
|
+
*
|
|
1310
|
+
* 形状**只有一种**(`{line, text?}`):引擎编辑侧给行号数组、读取侧给对象,
|
|
1311
|
+
* 同一个字段名两种含义是本仓已经栽过的坑(见 read-core 里 `truncated` 的注释)。
|
|
1312
|
+
*/
|
|
1313
|
+
candidates: {
|
|
1314
|
+
type: 'array',
|
|
1315
|
+
items: {
|
|
1316
|
+
type: 'object',
|
|
1317
|
+
additionalProperties: false,
|
|
1318
|
+
properties: {
|
|
1319
|
+
line: { type: 'integer', required: true },
|
|
1320
|
+
text: { type: 'string' },
|
|
1321
|
+
},
|
|
1322
|
+
},
|
|
1323
|
+
},
|
|
1324
|
+
/**
|
|
1325
|
+
* 引擎算好的**可粘回地址**:`{ handle }`,把它配自己的 `replace` 再发一次即可。
|
|
1326
|
+
*
|
|
1327
|
+
* 为什么它必须在**声明面**上:`output.schema` 是 `additionalProperties:false`,
|
|
1328
|
+
* 没声明的字段会让整条回执被框架以 `INVALID_TOOL_OUTPUT` 顶掉;
|
|
1329
|
+
* 而能力面三面同判据要求“同一能力在三面都可用**且**有声明”——
|
|
1330
|
+
* 引擎与 CLI/MCP 早就有这份结构化地址,DSH 回执到本波才补上。
|
|
1331
|
+
* 正文里那行 `retry: {"handle":…}` 写法未变:模型看到的还是同一句话。
|
|
1332
|
+
*/
|
|
1333
|
+
retry: {
|
|
1334
|
+
type: 'object',
|
|
1335
|
+
additionalProperties: false,
|
|
1336
|
+
properties: { handle: { type: 'string', required: true } },
|
|
1337
|
+
},
|
|
1338
|
+
// 下一步做什么。与 aura_read 的同名字段对齐(那里是续读指令,这里是恢复指令)。
|
|
1339
|
+
next: { type: 'string' },
|
|
1340
|
+
/**
|
|
1341
|
+
* C49 第四态的可核证据:**这次没写盘,但结果已经是你要的样子**。
|
|
1342
|
+
*
|
|
1343
|
+
* 形状与引擎 `applyEditToProvider` 的回执逐字一致(同一个字段名、同一组键):
|
|
1344
|
+
* `hunk` 是第几条 hunk、`line` 是命中的行号(与 `applied[].line` 同一把尺子)、
|
|
1345
|
+
* `tier` 说清在哪一层命中(`already-applied~exact` …)、`actual` 是文件**那一行的
|
|
1346
|
+
* 原文**、`replaced` 是调用方给的那份文本——后两者逐字相等才是这一态成立的依据。
|
|
1347
|
+
*
|
|
1348
|
+
* 每个键都 `required: true`:一条缺了正文的证据,比没有证据更难核对。
|
|
1349
|
+
*/
|
|
1350
|
+
already_applied: {
|
|
1351
|
+
type: 'array',
|
|
1352
|
+
items: {
|
|
1353
|
+
type: 'object',
|
|
1354
|
+
additionalProperties: false,
|
|
1355
|
+
properties: {
|
|
1356
|
+
hunk: { type: 'integer', required: true },
|
|
1357
|
+
line: { type: 'integer', required: true },
|
|
1358
|
+
tier: { type: 'string', required: true },
|
|
1359
|
+
actual: { type: 'string', required: true },
|
|
1360
|
+
replaced: { type: 'string', required: true },
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
/**
|
|
1365
|
+
* 第四态的标量副产物,与引擎侧回执里的 `unchanged` 同名同义:**磁盘内容一字未动**。
|
|
1366
|
+
*
|
|
1367
|
+
* 它与 `applied: []` 看起来重复,实则不是:`applied: []` 说的是"没有 hunk 落盘"
|
|
1368
|
+
* (三态失败也这么回),而 `unchanged` 说的是"**文件本来就是对的**"——只有第四态为真。
|
|
1369
|
+
* 一个消费方只看布尔值就能分流,不必去数数组长度、也不必解析文本。
|
|
1370
|
+
*/
|
|
1371
|
+
unchanged: { type: 'boolean' },
|
|
1372
|
+
// C13:这次编辑没有可信的“上次观测”(文件从没被 aura_read 读过)⇒ true。
|
|
1373
|
+
// 声明在这里是必须的:output.schema 是 additionalProperties:false,
|
|
1374
|
+
// 未声明的字段会让整条成功路径被框架拒绝。
|
|
1375
|
+
unverified_region: { type: 'boolean' },
|
|
1376
|
+
// C14:这次写入被**回读校验**过。`level: 'bytes'` = 逐字节整份比对(看得见 BOM 与行尾改写);
|
|
1377
|
+
// `level: 'text'` = 后端没有字节接口,只比对了文本(看不见 BOM / 行尾),如实写在这里。
|
|
1378
|
+
// 对不上时走不到这里:那条路抛 WRITE_VERIFY_FAILED(错误对象上带两边字节数与普查)。
|
|
1379
|
+
write_verify: {
|
|
1380
|
+
type: 'object',
|
|
1381
|
+
additionalProperties: false,
|
|
1382
|
+
properties: {
|
|
1383
|
+
level: { type: 'string', required: true },
|
|
1384
|
+
bytes: { type: 'integer' },
|
|
1385
|
+
chars: { type: 'integer' },
|
|
1386
|
+
},
|
|
1387
|
+
},
|
|
1388
|
+
// 创建路径会带上 operation,让调用方能区分"改了文件"与"建了文件"。
|
|
1389
|
+
operation: { type: 'string' },
|
|
1390
|
+
/**
|
|
1391
|
+
* 多目标(契约 §2.2/§3/§5):整批的四个标量 + 逐项。
|
|
1392
|
+
*
|
|
1393
|
+
* `applied`(顶层那个数组)在这里是**全批 hunk 的并集**(逐项各自的 `applied`
|
|
1394
|
+
* 在 `edits[i].receipt` 里,一个字节不改)——它不能退化成空数组:“一个 hunk 都没落盘”
|
|
1395
|
+
* 与“落了 4 个”是回执必须分清的两件事(单目标那条路的同一条纪律)。
|
|
1396
|
+
*
|
|
1397
|
+
* `landed_paths` / `not_landed_paths` 是三阶段提交失败时回答“哪些已落地、哪些没有”
|
|
1398
|
+
* 的那个字段(契约 §3.4);`edits[].receipt` 是单目标回执本体,这里只能
|
|
1399
|
+
* `additionalProperties: true`:把它的 12 个字段抄第二遍必然漂开。
|
|
1400
|
+
*/
|
|
1401
|
+
targets: { type: 'integer' },
|
|
1402
|
+
summary: { type: 'string' },
|
|
1403
|
+
// 整批正文(逐项正文按输入顺序拼接)。单目标那条路**不发**这个字段:
|
|
1404
|
+
// 它的正文由 `render` 从单目标回执现算(同一个 `receiptBody`)。
|
|
1405
|
+
text: { type: 'string' },
|
|
1406
|
+
landed_paths: { type: 'array', items: { type: 'string' } },
|
|
1407
|
+
not_landed_paths: { type: 'array', items: { type: 'string' } },
|
|
1408
|
+
aborted: { type: 'string' },
|
|
1409
|
+
partial: { type: 'boolean' },
|
|
1410
|
+
edits: {
|
|
1411
|
+
type: 'array',
|
|
1412
|
+
items: {
|
|
1413
|
+
type: 'object',
|
|
1414
|
+
additionalProperties: false,
|
|
1415
|
+
properties: {
|
|
1416
|
+
index: { type: 'integer', required: true },
|
|
1417
|
+
file_path: { type: 'string', required: true },
|
|
1418
|
+
outcome: { type: 'string', required: true },
|
|
1419
|
+
landed: { type: 'boolean', required: true },
|
|
1420
|
+
text: { type: 'string', required: true },
|
|
1421
|
+
receipt: { type: 'object', additionalProperties: true },
|
|
1422
|
+
error: {
|
|
1423
|
+
type: 'object',
|
|
1424
|
+
additionalProperties: false,
|
|
1425
|
+
properties: {
|
|
1426
|
+
code: { type: 'string', required: true },
|
|
1427
|
+
message: { type: 'string', required: true },
|
|
1428
|
+
next: { type: 'string' },
|
|
1429
|
+
},
|
|
1430
|
+
},
|
|
1431
|
+
stage: { type: 'string' },
|
|
1432
|
+
planned: { type: 'string' },
|
|
1433
|
+
note: { type: 'string' },
|
|
1434
|
+
shared_with: { type: 'array', items: { type: 'integer' } },
|
|
1435
|
+
original: { type: 'string' },
|
|
1436
|
+
original_chars: { type: 'integer' },
|
|
1437
|
+
original_truncated: { type: 'boolean' },
|
|
1438
|
+
},
|
|
1439
|
+
},
|
|
1440
|
+
},
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
1443
|
+
/**
|
|
1444
|
+
* 渲染按 outcome 分叉:**失败回执也要有一段给模型看的文本**。
|
|
1445
|
+
*
|
|
1446
|
+
* 抛错时代这段文本由框架从 `error.message` 里取;现在失败是正常结果,
|
|
1447
|
+
* 若还按成功路径渲染,模型会看到"0 hunk(s) applied"——它无法分辨"没改成"
|
|
1448
|
+
* 与"改成了但一条回执都没有"。所以先判 outcome,再决定说什么。
|
|
1449
|
+
*
|
|
1450
|
+
* C49 加了第三支:第四态**不能**落在 `renderFailure` 那一支。失败正文写的是
|
|
1451
|
+
* "要改的原文不在文件里",而这态要说的是"已经是你给的样子了、这次没写盘"——
|
|
1452
|
+
* 两句话指向相反的动作(重读重寻址 vs 不用改)。共用一支就等于把这一态重新藏起来,
|
|
1453
|
+
* 只不过藏在一个更体面的说法后面。
|
|
1454
|
+
*/
|
|
1455
|
+
/**
|
|
1456
|
+
* 分叉不变,只在**末尾**追加句柄披露(`appendHandleNotes` 在没有句柄 hunk 时
|
|
1457
|
+
* 一个字符都不加 ⇒ 既有回执逐字不变)。披露必须走这里而不是渲染器内部:
|
|
1458
|
+
* 成功 / 被拒 / dry run / 已经是你给的样子,四种回执都要说清同一件事。
|
|
1459
|
+
*/
|
|
1460
|
+
render: (args, value) => [
|
|
1461
|
+
{
|
|
1462
|
+
type: 'text',
|
|
1463
|
+
// 多目标:正文已在壳层按输入顺序装配好(逐项各自 append 过句柄披露)。
|
|
1464
|
+
text: Array.isArray(args?.targets) ? value.text : appendHandleNotes(receiptBody(value), args),
|
|
1465
|
+
},
|
|
1466
|
+
],
|
|
1467
|
+
},
|
|
1468
|
+
async execute(args, exec) {
|
|
1469
|
+
/**
|
|
1470
|
+
* 多目标入口(fs-contract-v2 §2.2):`targets` 在场 ⇒ 引擎入口(三阶段提交),
|
|
1471
|
+
* `file_path` 那条路一个字不动。
|
|
1472
|
+
*
|
|
1473
|
+
* 形状判据(含“与单目标形状互斥”)只有一份,住在 `args.mjs` 的 `assertEditTargets`——
|
|
1474
|
+
* 壳里再写一遍必然漂开。它抛的是**参数面**错(`ARGS_SHAPE_EXCLUSIVE` / `ARGS_TARGETS_SHAPE`
|
|
1475
|
+
* / `ARGS_TARGETS_LIMIT`);逐项失败则是结果通道(引擎已经把每项封成结果,不抛)。
|
|
1476
|
+
*/
|
|
1477
|
+
const batchTargets = assertEditTargets(args, { toolName: 'aura_edit' })
|
|
1478
|
+
if (batchTargets !== undefined) {
|
|
1479
|
+
// 多目标形状同样要过未知参数守卫:拼错的键不因为换了形状就被放行。
|
|
1480
|
+
assertKnownArgs(args, EDIT_ARG_KEYS, 'aura_edit')
|
|
1481
|
+
const batchPolicyService = sandbox()
|
|
1482
|
+
const batchPolicy =
|
|
1483
|
+
batchPolicyService === undefined
|
|
1484
|
+
? undefined
|
|
1485
|
+
: batchPolicyService.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
|
1486
|
+
return await applyEditTargets(ctx, exec, args, batchTargets, batchPolicy)
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* “二选一”的**另一半**(两个都没给 ⇒ 也拒)只有一份判据,住在 `args.mjs` 的
|
|
1490
|
+
* `assertTargetShape`(`src/locator-lab/args.mjs:455`)——壳里原来那句
|
|
1491
|
+
* `file_path must be a non-empty string` 是特例写法,已删(aura_read 壳里长着同一句,
|
|
1492
|
+
* 两处必然会漂)。抛的仍是参数面同族码 `ARGS_SHAPE_EXCLUSIVE`;
|
|
1493
|
+
* 单目标调用照旧带 `file_path`,故这条路一字未变。
|
|
1494
|
+
*/
|
|
1495
|
+
assertTargetShape(args, { toolName: 'aura_edit' })
|
|
1496
|
+
// 创建路径不经过 hunks:给 `create` 时 hunks 可以为空(甚至省略)。
|
|
1497
|
+
// 第一版把"hunks 非空"当无条件校验,于是**创建被自己的校验挡住**,
|
|
1498
|
+
// 而报错信息("hunks must be a non-empty array")与真正的原因(文件不存在)毫无关系。
|
|
1499
|
+
// 未知参数不回默认行为:拼错的字段被静默忽略时,模型看到的是**成功**。
|
|
1500
|
+
// (实测:`{file_path, mode:'auto'}` 这种属于 aura_read 的参数曾零违规穿过框架。)
|
|
1501
|
+
assertKnownArgs(args, EDIT_ARG_KEYS, 'aura_edit')
|
|
1502
|
+
const creating = typeof args.create === 'string'
|
|
1503
|
+
/**
|
|
1504
|
+
* 单个 hunk **平铺在顶层**也认。
|
|
1505
|
+
*
|
|
1506
|
+
* 实测依据(403 会话日志):报 `hunks must be a non-empty array` 的失败有 27 次,
|
|
1507
|
+
* 入参形状一律是 `{file_path, start_line, end_line, replace}`——模型把“一个 hunk”
|
|
1508
|
+
* 直接写在了顶层,而不是包进 `hunks: [...]`。这与已采纳的宽容方向是同一条:
|
|
1509
|
+
* 模型把 `lines` 写成 `"4,6"` / `4` / `start_line`+`end_line` 都容得下(见上面 schema 的注释),
|
|
1510
|
+
* 因为“回一句参数不对让它重试一遍,纯是浪费一轮”。
|
|
1511
|
+
*
|
|
1512
|
+
* 安全性不变:被容忍的字段(target/lines/…/near)在顶层**本来就不是合法参数**,
|
|
1513
|
+
* 所以不存在“把一个真参数误当 hunk”的可能;而地址与 `expect` 闸门一个都不少。
|
|
1514
|
+
*/
|
|
1515
|
+
const hunks = hunkListOf(args)
|
|
1516
|
+
if (!creating && hunks === null) {
|
|
1517
|
+
/**
|
|
1518
|
+
* 仍然抛错,且这是**刻意**的(本次只改"判定不出意图"那一类失败):
|
|
1519
|
+
* 这条报错说的是"你根本没给出一个 hunk"——参数面没成形,与"地址对不上"
|
|
1520
|
+
* 是两件事。把它降级成 `not_found` 会让模型以为文件里没有那段文字,
|
|
1521
|
+
* 而真相是它把字段名写错了(或漏了 replace)。
|
|
1522
|
+
* 测试面:`locator-lab/arg-shape-test.mjs` 段B 守着这条("不许误报定位失败")。
|
|
1523
|
+
*/
|
|
1524
|
+
throw new Error(
|
|
1525
|
+
'hunks must be a non-empty array (or pass `create` to make a new file) — a single hunk may also be passed flat: file_path + one of target/lines/line_cue/anchor + replace',
|
|
1526
|
+
)
|
|
1527
|
+
}
|
|
1528
|
+
// 每一次 mutation 都必须带上**调用会话那一份**沙箱策略。
|
|
1529
|
+
//
|
|
1530
|
+
// 这不是可选的:`dsh-fs-sandbox` 的 `checkedTarget` 读的是它第 5 个参数
|
|
1531
|
+
// (`sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()`),漏传就退回 fs 插件自己的
|
|
1532
|
+
// 默认策略——那是一个**没有 session** 的策略,`writableRoots` 取的是部署兜底根,
|
|
1533
|
+
// 而不是调用会话的 cwd。后果不是"更严格",而是**本工具彻底写不动**:
|
|
1534
|
+
// 对工作区内的文件也报 `file access denied under workspace-write mode`。
|
|
1535
|
+
//
|
|
1536
|
+
// 服务本身来自 apply 期的可选注入(见上方 apply 的注释),这里每次现算,
|
|
1537
|
+
// 因为模式可以按会话变化(`sandbox/mode` 事件)。
|
|
1538
|
+
const policyService = sandbox()
|
|
1539
|
+
const policy =
|
|
1540
|
+
policyService === undefined
|
|
1541
|
+
? undefined
|
|
1542
|
+
: policyService.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
|
1543
|
+
// 这两条曾经只是推理,现已实测确认(探针跑完即删):
|
|
1544
|
+
// injectBound: true —— `ctx.inject(['sandboxPolicy'])` 在整体 preset 挂载下确实绑得上
|
|
1545
|
+
// hasAgent: true —— 执行期 exec.agent 在场,于是 resolve 真的带上了 session
|
|
1546
|
+
// resolved: {mode:'danger-full-access', workspaceRoot:<会话 cwd>, sessionId:…}
|
|
1547
|
+
// 即"落到哪个模式"取决于会话策略,而不是部署默认。
|
|
1548
|
+
// 解析路径也要用策略里的 workspaceRoot(与内置 tool-fs 的 sessionResolveOptions 一致):
|
|
1549
|
+
// 相对路径必须相对本次 mutation 的沙箱根解析,否则"被检查的路径"与"被写入的路径"可能不同。
|
|
1550
|
+
const cwd = policy?.workspaceRoot ?? exec.agent?.session?.header?.cwd
|
|
1551
|
+
const target = await ctx.fs.resolve(args.file_path, {
|
|
1552
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
1553
|
+
signal: exec.signal,
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
const info = await ctx.fs.stat(target, exec.signal)
|
|
1557
|
+
if (info === undefined) {
|
|
1558
|
+
/**
|
|
1559
|
+
* 目标不存在:**先记一次 absent 观测**,再按"创建"或"报错"处理。
|
|
1560
|
+
*
|
|
1561
|
+
* 这是 dsh-tool-fs 的契约:`fs/observed` 的 `{kind:'absent'}` 让后续写入
|
|
1562
|
+
* 能走"带防护的创建"而不被观察策略拒绝。先前这里直接抛错、不记录,
|
|
1563
|
+
* 于是"读一个还不存在的文件、然后创建它"这条完全正常的流程会在写入侧失败——
|
|
1564
|
+
* 且失败原因看起来与本工具毫无关系。
|
|
1565
|
+
*/
|
|
1566
|
+
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
|
|
1567
|
+
|
|
1568
|
+
// 创建模式:停掉内置 `write` 之后,这是唯一的建文件路径,因此必须支持。
|
|
1569
|
+
// 语义刻意与内置 `write` 对齐 —— 只在文件不存在时创建,存在则报错,
|
|
1570
|
+
// 而不是静默覆盖(覆盖现有文件必须走 address + replace 那条路,那条路有 expect 闸门)。
|
|
1571
|
+
const createContent = args.create
|
|
1572
|
+
if (typeof createContent !== 'string') {
|
|
1573
|
+
throw new PatchError(
|
|
1574
|
+
`cannot patch "${target.displayPath}": not found — to create it, pass \`create\` with the full file content`,
|
|
1575
|
+
'PATCH_NOT_FOUND',
|
|
1576
|
+
)
|
|
1577
|
+
}
|
|
1578
|
+
const created = await ctx.fs.writeText(
|
|
1579
|
+
target,
|
|
1580
|
+
createContent,
|
|
1581
|
+
{ kind: 'createIfAbsent' },
|
|
1582
|
+
exec.signal,
|
|
1583
|
+
policy,
|
|
1584
|
+
)
|
|
1585
|
+
ctx.emit('fs/observed', target, { kind: 'present', version: created.version }, exec)
|
|
1586
|
+
// 建文件也是一次“写成功” ⇒ 同样回读校验(create 被偷偷改写的后果与编辑一样真)。
|
|
1587
|
+
const createdVerification = await verifyWriteBack(ctx.fs, target, createContent, target.displayPath, exec.signal)
|
|
1588
|
+
// 新建的内容也是“自己刚写过的内容” ⇒ 记成最新观测,随后编辑它不必带 unverified_region。
|
|
1589
|
+
// 记账同样取**提供方视角**:内容自带前导 U+FEFF 时,下一次 readText 会剥掉一个,
|
|
1590
|
+
// 存原样就会让随后的未断言编辑第 1 行被误拒(F1 就是这条)。
|
|
1591
|
+
const createdView = providerView(createContent)
|
|
1592
|
+
const createdByteOrderMark = createContent.startsWith(BYTE_ORDER_MARK)
|
|
1593
|
+
rememberRead(
|
|
1594
|
+
target.displayPath,
|
|
1595
|
+
contentToken(createdView, createdByteOrderMark),
|
|
1596
|
+
createdView,
|
|
1597
|
+
createdByteOrderMark,
|
|
1598
|
+
target.targetKey,
|
|
1599
|
+
)
|
|
1600
|
+
return {
|
|
1601
|
+
path: target.displayPath,
|
|
1602
|
+
operation: 'create',
|
|
1603
|
+
outcome: 'applied',
|
|
1604
|
+
applied: [
|
|
1605
|
+
{
|
|
1606
|
+
line: 1,
|
|
1607
|
+
tier: 'create',
|
|
1608
|
+
byNear: false,
|
|
1609
|
+
actual: '',
|
|
1610
|
+
replaced: created.after ?? createContent,
|
|
1611
|
+
},
|
|
1612
|
+
],
|
|
1613
|
+
dryRun: false,
|
|
1614
|
+
write_verify: createdVerification,
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* C55:非 UTF-8 文件 ⇒ **可执行的拒绝**。
|
|
1619
|
+
*
|
|
1620
|
+
* 只换说法,不换通道:它仍是抛错(工具级/环境级故障),不是结果通道——这一点是
|
|
1621
|
+
* 刻意留住的(否则「读不了这个文件」会被伪装成「判不出你要哪一段」,调用方会去改
|
|
1622
|
+
* 地址、改模式,而它真正要做的是把文件转成 UTF-8)。它也不进下面 `planHunks` 那个
|
|
1623
|
+
* catch:那一支的判断表(`OUTCOME_BY_CODE`)只管定位失败。
|
|
1624
|
+
*
|
|
1625
|
+
* 位置也是判据的一部分:它在 `readText` 上,早于 `regionGate`、`writeText`,
|
|
1626
|
+
* 所以回执里那句「没有写盘」是**结构性**成立的,不靠任何回滚去兼。
|
|
1627
|
+
*/
|
|
1628
|
+
let content
|
|
1629
|
+
try {
|
|
1630
|
+
content = await ctx.fs.readText(target, exec.signal)
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
if (!isNonUtf8TextFailure(error)) throw error
|
|
1633
|
+
throw Object.assign(new Error(nonUtf8RefusalMessage('patch', target.displayPath, 'aura_edit')), {
|
|
1634
|
+
code: typeof error.code === 'string' && error.code.length > 0 ? error.code : 'ERR_ENCODING_INVALID_ENCODED_DATA',
|
|
1635
|
+
cause: error,
|
|
1636
|
+
})
|
|
1637
|
+
}
|
|
1638
|
+
// BOM 在解码时已经丢了(只丢第一个),写回前先问字节:它是唯一能回答“原来有几个 BOM”的地方。
|
|
1639
|
+
const byteOrderMarks = await fileByteOrderMarkCount(ctx.fs, target, exec.signal)
|
|
1640
|
+
let planned
|
|
1641
|
+
try {
|
|
1642
|
+
/**
|
|
1643
|
+
* 第 4 参(`{ identityKey, byteOrderMark }`)**不是可选的**,两个值上面都已算好:
|
|
1644
|
+
* 句柄里的**身份标签**按 `ledgerIdentityKey(filePath, identityKey)` 算、**区间摘要**
|
|
1645
|
+
* 按"含 BOM 的区间字节"算,而读侧记账时递的正是这两样(`aura-read/index.js` 同样
|
|
1646
|
+
* 递 `target.targetKey`)。
|
|
1647
|
+
*
|
|
1648
|
+
* 漏传的后果**不是改错地方**,而是这两个口径与句柄里那份对不上 ⇒ BOM 文件与
|
|
1649
|
+
* 别名/符号链接路径上句柄**永远**判成 `PATCH_HANDLE_STALE`(一律拒绝)。
|
|
1650
|
+
* 与下面 `regionGate` 递的是同一对值(那里传的是 `byteOrderMark: byteOrderMarks > 0`
|
|
1651
|
+
* 与 `identityKey: target.targetKey`)——两处必须同源,否则同一份文件在"闸门"与"句柄核对"
|
|
1652
|
+
* 里会有两种身份。判据:段G 三档端到端(普通 / BOM / 别名路径),mutation 去掉本参数即变红。
|
|
1653
|
+
*/
|
|
1654
|
+
planned = planHunks(content, hunks, target.displayPath, {
|
|
1655
|
+
byteOrderMark: byteOrderMarks > 0,
|
|
1656
|
+
...(typeof target.targetKey === 'string' && target.targetKey.length > 0
|
|
1657
|
+
? { identityKey: target.targetKey }
|
|
1658
|
+
: {}),
|
|
1659
|
+
})
|
|
1660
|
+
} catch (error) {
|
|
1661
|
+
/**
|
|
1662
|
+
* 定位失败分两种,**在这里分道**(fs-contract-v2 的核心改动):
|
|
1663
|
+
*
|
|
1664
|
+
* · 判定得出意图(原文不在文件里 / 断言不符 / 多处都能对上 / 坐标越界,
|
|
1665
|
+
* 以及 C49 补的第四态「原文不在、但结果已经是你给的样子了」)
|
|
1666
|
+
* ⇒ 回一条**正常结果**,`outcome` 说清是哪一种。抛错会把
|
|
1667
|
+
* `candidates`/`next`/`already_applied` 这些结构化字段一起丢掉——框架只取
|
|
1668
|
+
* `error.message`(`dsh-tools/lib/index.js` 的 errorMessage),于是"选哪一个"
|
|
1669
|
+
* "已经改好了没有"这份信息在错误通道里**根本传不出去**。
|
|
1670
|
+
* · 工具级故障(IO、沙箱、引擎内部 bug)⇒ 原样抛出,一个字不少。
|
|
1671
|
+
*
|
|
1672
|
+
* 判定表与理由见 {@link OUTCOME_BY_CODE}(缺省态)与 {@link SECOND_EXIT_BY_CODE}
|
|
1673
|
+
* (同一码的第二种事实);判决本体见 {@link intentVerdict}。
|
|
1674
|
+
*
|
|
1675
|
+
* 顺序也是判据的一部分:证据先算(`secondExitEvidence`,读的是**已读到的全文**)、
|
|
1676
|
+
* 再判决。位置更讲究:`planHunks` 刚抛、`regionGate` 还没跑、`writeText` 更没轮到 ⇒
|
|
1677
|
+
* 这条路上"任何字节都没写"是**结构性的**(事务性来自"先全部规划完再落盘"),
|
|
1678
|
+
* 第四态那句"这次没写盘"不需要任何回滚去兼。也正因此这里**不**刷新观测账本
|
|
1679
|
+
* (`rememberRead`):什么都没写,就没有"自己刚写的内容"要记。
|
|
1680
|
+
*/
|
|
1681
|
+
const already = secondExitEvidence(error, content, hunks)
|
|
1682
|
+
const verdict = intentVerdict(error, already)
|
|
1683
|
+
if (verdict !== null) {
|
|
1684
|
+
/**
|
|
1685
|
+
* 第四态的回执**不带**符号目录:那不是失败,而是"已经是你给的样子了"。
|
|
1686
|
+
* 给一条根本没发生的失败附上恢复路径,等于把模型赶去修一个不存在的问题。
|
|
1687
|
+
*/
|
|
1688
|
+
if (verdict.outcome === 'already_applied') {
|
|
1689
|
+
return intentReceipt(target.displayPath, verdict, args.dry_run === true)
|
|
1690
|
+
}
|
|
1691
|
+
// 锚点失败时**不要只报错**:把该文件的结构目录一并交回,给模型一条可执行的恢复路径。
|
|
1692
|
+
// 实测依据:patch 的失败用例里 100% 都能被符号名救回(locator-lab/recoverable.mjs),
|
|
1693
|
+
// 而符号级定位对文本扰动免疫——文本怎么错都影响不到它。
|
|
1694
|
+
// 这一步把一个死胡同变成一次可判定的重试,且只在失败时付费。
|
|
1695
|
+
// 现在它进的是**回执正文**而不是错误消息:失败不再抛错,这段建议要是留在 throw 里
|
|
1696
|
+
// 就永远到不了模型眼前。
|
|
1697
|
+
return intentReceipt(
|
|
1698
|
+
target.displayPath,
|
|
1699
|
+
{ ...verdict, message: `${verdict.message}${symbolHint(target.displayPath, content)}` },
|
|
1700
|
+
args.dry_run === true,
|
|
1701
|
+
)
|
|
1702
|
+
}
|
|
1703
|
+
throw new PatchError(
|
|
1704
|
+
`${error.message}${symbolHint(target.displayPath, content)}`,
|
|
1705
|
+
error.code ?? 'PATCH_FAILED',
|
|
1706
|
+
error.candidates === undefined ? {} : { candidates: error.candidates },
|
|
1707
|
+
)
|
|
1708
|
+
}
|
|
1709
|
+
const { content: next, applied } = planned
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* `dry_run` **不能排在闸门之前**(真机实测的结论)。
|
|
1713
|
+
*
|
|
1714
|
+
* 旧顺序下会出现:预演说 "every hunk matched",而同样的 hunks 真落盘时闸门报
|
|
1715
|
+
* "the text you addressed moved: line 3 no longer holds it (it is now line 1)"。
|
|
1716
|
+
* 也就是**预演没预演出真正的判决**——而预演的全部价值就在于“先看会发生什么”。
|
|
1717
|
+
* 一个只在写入路径上生效的闸门,配上一个说“没事”的预演,比没有预演更坏:
|
|
1718
|
+
* 它会把“会被拒的编辑”报成“可以放心提交”。所以闸门先行,预演在它之后。
|
|
1719
|
+
*/
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* 区域级新鲜度闸门(C13):未带 `expect` 的 hunk,落盘前核对**被寻址区域**
|
|
1723
|
+
* 是否在“本次观测”之后被改过(判据形状见 {@link staleConflicts})。
|
|
1724
|
+
*
|
|
1725
|
+
* 为什么不能退化成全局版本守:v1 就是因为“整份版本变了就拒”被否掉的——
|
|
1726
|
+
* 它会误杀“别人改了无关区域”的正常编辑(段C)。这里只看被寻址区域的文本。
|
|
1727
|
+
* 也从没读过(`latestObservation` 为 null)时不拦:那种情况下没有可比的基准,
|
|
1728
|
+
* 诚实地在回执里带 `unverified_region`,而不是拿别的信号冒充新鲜。
|
|
1729
|
+
*
|
|
1730
|
+
* 判据与收据都住在 write-path.mjs(`staleConflicts` / `staleRegionRefusal`)——
|
|
1731
|
+
* 这里只做编排。读侧、写侧、独立 CLI 共用同一份,正是为了不再出现
|
|
1732
|
+
* “同一个决策写在两处,必有一处漏改”(本文件已经被这条坑过一次,见 `symbolHint`)。
|
|
1733
|
+
*/
|
|
1734
|
+
// `byteOrderMarks` 已在上面从**字节**量过(第 511 行):BOM 在解码后看不见,
|
|
1735
|
+
// 而账本令牌是按 `contentToken(读到的文本, 字节里有 BOM 吗)` 记的。
|
|
1736
|
+
// 漏传会让所有 BOM 文件的回执被标成 `unverified_region`("核对过"被报成"没核对过")。
|
|
1737
|
+
/**
|
|
1738
|
+
* **显示路径 vs 账本身份**(C61):`displayPath` 是调用方给的那个写法(回执 / 报错 /
|
|
1739
|
+
* 候选行 / 诊断里的路径一字不许换);`identityKey` 是"这份盘上文件是谁",只给闸门记账用。
|
|
1740
|
+
*
|
|
1741
|
+
* 事实来源是宿主自己:`ctx.fs.resolve()` 的 `targetKey`——`dsh-fs` 的类型契约把它定义为
|
|
1742
|
+
* 「Opaque key for stale guards and target lookup」(`lib/types/types.d.ts:10-14`、`:54`),
|
|
1743
|
+
* 本地后端(`dsh-fs-local/lib/index.js:159`)的实现是 `await realpath(displayPath)`。
|
|
1744
|
+
*
|
|
1745
|
+
* 为什么必须在这一层递:junction / 符号链接 / 8.3 短名只有文件系统认得,而引擎
|
|
1746
|
+
* (`locator-lab/*.mjs`)被 `build/make-bundle.mjs` 的 `copyFile` 守卫禁止直接碰 `node:fs`。
|
|
1747
|
+
* 实测后果在 `locator-lab/alias-identity-test.mjs` 段A/段B:读 `…\linkdir\Gamma.js`、
|
|
1748
|
+
* 第三方改掉被寻址的第 3 行、再用 `…\realdir\Gamma.js` 发一个无 `expect` 的 hunk
|
|
1749
|
+
* ⇒ 改前 `outcome=applied` 且磁盘被静默覆盖(两个方向各自复现过一次)。
|
|
1750
|
+
*
|
|
1751
|
+
* 归一化只有一处实现:引擎的 `read-core.mjs` 的 `ledgerIdentityKey`(`regionGate`
|
|
1752
|
+
* 内部走它)。这里不重复实现,只把宿主已有的事实递进去。
|
|
1753
|
+
*/
|
|
1754
|
+
const gate = regionGate(hunks, content, planned, target.displayPath, {
|
|
1755
|
+
byteOrderMark: byteOrderMarks > 0,
|
|
1756
|
+
...(typeof target.targetKey === 'string' && target.targetKey.length > 0
|
|
1757
|
+
? { identityKey: target.targetKey }
|
|
1758
|
+
: {}),
|
|
1759
|
+
})
|
|
1760
|
+
if (!gate.clean) throw new PatchError(gate.message, 'PATCH_STALE_REGION', gate.extras)
|
|
1761
|
+
// 闸门过了才轮到预演:此刻“预演通过”与“真写会成功”是同一句话。
|
|
1762
|
+
if (args.dry_run === true) {
|
|
1763
|
+
return { path: target.displayPath, outcome: 'applied', applied, dryRun: true }
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// 本次读取就是一次合法观测,先记上:策略随后对**其它**改动方仍然有效,
|
|
1767
|
+
// 而本工具自己的新鲜度由锚点保证——见下。
|
|
1768
|
+
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* 写回:**提供方级 CAS 的整份写**(writeText + replaceIfVersion),不再用 editText。
|
|
1772
|
+
*
|
|
1773
|
+
* 为什么必须换:`editText` 内部先把整份内容归一到 LF、匹配替换,再按 `detectLineEndings`
|
|
1774
|
+
* 的**多数派**把整份内容统一成一种行尾(dsh-fs-local 的 normalizeLineEndings /
|
|
1775
|
+
* restoreLineEndings)⇒ **没被寻址**的行的行尾也会跟着变。真机实测:
|
|
1776
|
+
* `alpha\n beta\r\n gamma\n delta\n` 改第 4 行 ⇒ 第 2 行的 `\r\n` 变 `\n`(24 → 23 字节)。
|
|
1777
|
+
* `writeText` 逐字节落盘(`:821-839`),因此混合行尾也能只改被寻址那一段。
|
|
1778
|
+
*
|
|
1779
|
+
* 守卫不弱于旧的:`replaceIfVersion` 在提供方的 per-target 临界区里比对版本
|
|
1780
|
+
* (版本 = dev:ino:size:mtimeNs:ctimeNs,原子发布还换 inode),读后被第三方改过 ⇒
|
|
1781
|
+
* `FS_STALE_VERSION` 且磁盘不变(locator-lab/byte-fidelity.mjs 段 F 有反向断言)。
|
|
1782
|
+
* 内容一侧的守卫也仍在:`planHunks` 已经按**实际读到的全文**逐条定位与核对过,
|
|
1783
|
+
* 交出去的 `next` 就是那个内容加上本次改动。
|
|
1784
|
+
*
|
|
1785
|
+
* 沙箱策略照第 5 个参数传:`dsh-fs-sandbox` 的 writeText 与 editText 用同一个
|
|
1786
|
+
* `checkedTarget`(`dsh-fs-sandbox/lib/index.js:125` 与 `:139`),漏传才会退回
|
|
1787
|
+
* “没有会话的策略”——那会让本工具在 workspace-write 下写不动。
|
|
1788
|
+
*
|
|
1789
|
+
* 后端没有 writeText 时(仓库里几个 mock 就是这种形状)退回原来的 editText:
|
|
1790
|
+
* 那条路对**行尾统一**的文件逐字节等价(normalize/restore 在纯 LF / 纯 CRLF / 纯 CR 上
|
|
1791
|
+
* 是恒等变换),与改前行为一致,不会更坏;真实后端(dsh-fs-local / dsh-fs-sandbox)都有它。
|
|
1792
|
+
*/
|
|
1793
|
+
const newContent = carryingByteOrderMarks(next, byteOrderMarks)
|
|
1794
|
+
const byteExact = typeof ctx.fs.writeText === 'function'
|
|
1795
|
+
if (!byteExact && hasMixedLineEndings(newContent)) {
|
|
1796
|
+
// 后端没有整份写能力,而这份文件是混合行尾 ⇒ 两条路都会改坏非目标行。拒绝,不降级。
|
|
1797
|
+
throw new PatchError(
|
|
1798
|
+
`cannot patch "${target.displayPath}": this file mixes CRLF and LF line endings and the filesystem backend cannot write it back byte-for-byte (no writeText) — normalize the line endings first, then retry`,
|
|
1799
|
+
'PATCH_NO_BYTE_EXACT_WRITE',
|
|
1800
|
+
)
|
|
1801
|
+
}
|
|
1802
|
+
let outcome
|
|
1803
|
+
try {
|
|
1804
|
+
outcome = byteExact
|
|
1805
|
+
? await ctx.fs.writeText(target, newContent, { kind: 'replaceIfVersion', version: info.version }, exec.signal, policy)
|
|
1806
|
+
: await ctx.fs.editText(target, { oldString: content, newString: newContent, replaceAll: false }, undefined, exec.signal, policy)
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
throw remediate(error, target.displayPath)
|
|
1809
|
+
}
|
|
1810
|
+
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
|
1811
|
+
/**
|
|
1812
|
+
* 落盘后**回读校验**(C14):先验证,再记账。
|
|
1813
|
+
*
|
|
1814
|
+
* 顺序是故意的:验证失败时**不刷新观测**——那一刻我们并不知道磁盘上是“我以为写下的内容”,
|
|
1815
|
+
* 把它记成最新观测等于替提供方的改写背书。失败回执让模型重读,重读会写下真观测。
|
|
1816
|
+
*/
|
|
1817
|
+
const verification = await verifyWriteBack(ctx.fs, target, newContent, target.displayPath, exec.signal)
|
|
1818
|
+
/**
|
|
1819
|
+
* 自己刚写的内容就是**最新观测**:不刷新它,同一轮里对同一区域的第二次编辑
|
|
1820
|
+
* 会被上面那道闸门拦下(段E 就是这条)。存的版本按内容指纹算(与 `aura_read` 同一把尺子),
|
|
1821
|
+
* 存的内容是**提供方视角文本**(见 {@link providerView})——与下一次读所见逐字一致。
|
|
1822
|
+
*/
|
|
1823
|
+
const writtenView = providerView(newContent)
|
|
1824
|
+
const writtenByteOrderMark = newContent.startsWith(BYTE_ORDER_MARK)
|
|
1825
|
+
/**
|
|
1826
|
+
* 身份键一并递下去(C61):事件簿要记在**同一处**,别名索引也要按调用方那种写法登记
|
|
1827
|
+
* —— 否则"读别名 → 写真实路径 → 再写别名"的第三步查不到刚刚写下的观测。
|
|
1828
|
+
* 这里与上面 `regionGate` 用的是同一个事实(`target.targetKey`),两处都不是自己算的。
|
|
1829
|
+
*/
|
|
1830
|
+
rememberRead(
|
|
1831
|
+
target.displayPath,
|
|
1832
|
+
contentToken(writtenView, writtenByteOrderMark),
|
|
1833
|
+
writtenView,
|
|
1834
|
+
writtenByteOrderMark,
|
|
1835
|
+
target.targetKey,
|
|
1836
|
+
)
|
|
1837
|
+
return {
|
|
1838
|
+
path: target.displayPath,
|
|
1839
|
+
// 三态里的"改成了"。这三个 return(create / dry_run / 真写)都必须带上它——
|
|
1840
|
+
// 它是必填字段,漏一个就是一条成功路径被自己的 schema 拒掉。
|
|
1841
|
+
outcome: 'applied',
|
|
1842
|
+
applied,
|
|
1843
|
+
dryRun: false,
|
|
1844
|
+
write_verify: verification,
|
|
1845
|
+
// 没有基准(从未观测过 / 基准已被淘汰)时如实标注,与 CLI 侧同一句话:
|
|
1846
|
+
// "没核对过"与"核对过且没变"必须是两句话,否则回执在撒谎。
|
|
1847
|
+
...(gate.basis === 'none' ? { unverified_region: true } : {}),
|
|
1848
|
+
}
|
|
1849
|
+
},
|
|
1850
|
+
presentCall: (args) => {
|
|
1851
|
+
// 多目标的卡片:`file_path` 不在场,照单目标那行拼出来会是 “Patch undefined”。
|
|
1852
|
+
if (Array.isArray(args?.targets)) {
|
|
1853
|
+
return {
|
|
1854
|
+
card: 'generic',
|
|
1855
|
+
title: `Patch ${args.targets.length} targets`,
|
|
1856
|
+
kind: 'edit',
|
|
1857
|
+
rawInput: { targets: args.targets.length },
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return {
|
|
1861
|
+
card: 'generic',
|
|
1862
|
+
title: `Patch ${args.file_path}`,
|
|
1863
|
+
kind: 'edit',
|
|
1864
|
+
rawInput: hunks,
|
|
1865
|
+
}
|
|
1866
|
+
},
|
|
1867
|
+
}),
|
|
1868
|
+
)
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
export { planHunks } from 'aura-basetools/locator-lab/write-path.mjs'
|