aura-basetools 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -2
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/bin/aura-mcp.mjs +424 -0
- package/bin/aura.mjs +396 -0
- package/lib/fs-provider.mjs +355 -0
- package/lib/observation-store.mjs +236 -0
- package/lib/ops.mjs +498 -0
- package/lib/transaction-store.mjs +350 -0
- package/lib/workspace-lock.mjs +130 -0
- package/locator-lab/anchor-core.mjs +586 -0
- package/locator-lab/args.mjs +491 -0
- package/locator-lab/handle-core.mjs +207 -0
- package/locator-lab/locator-api.mjs +921 -0
- package/locator-lab/read-core.mjs +3013 -0
- package/locator-lab/repo-map.mjs +768 -0
- package/locator-lab/struct-core.mjs +3026 -0
- package/locator-lab/write-path.mjs +3446 -0
- package/package.json +50 -4
- package/test/standalone-test.mjs +1575 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* args.mjs —— 把「模型写出来的参数形状」收敛成引擎要的那一种。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独有这一层:工具参数是模型写的 JSON,它最常见的失误不是"不知道该读哪几行",
|
|
5
|
+
* 而是**把行区间写坏**——`"lines": 412, 500`(这已经不是合法 JSON)、`"lines": "412,500"`、
|
|
6
|
+
* `"lines": 412`、`start_line`/`end_line` 混用。实测报错长这样:
|
|
7
|
+
*
|
|
8
|
+
* Error: invalid arguments: "arguments" must be an object
|
|
9
|
+
*
|
|
10
|
+
* 那是**宿主**在参数进工具之前拒的(`dsh-tools` 的 JSON Schema 校验,code `INVALID_ARGS`)——
|
|
11
|
+
* 参数根本没成为对象时,工具无能为力。但只要参数**对象**到了,能容的就该容下来:
|
|
12
|
+
* 多容一种形状,就少一次"报错 → 重试 → 再报错"的来回。
|
|
13
|
+
*
|
|
14
|
+
* 本文件是纯函数(不用 node:fs、无平台分支),DSH 工具壳 / CLI / MCP 三面共用同一套判据。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 句柄的**形状**只由 `handle-core.mjs` 一处定义(`HANDLE_RE`),这里只把它包成参数面的判词。
|
|
19
|
+
*
|
|
20
|
+
* 为什么不在这里再写一份正则:两个真值来源必然漂开,而漂开之后"写坏的句柄"与"不是句柄"
|
|
21
|
+
* 会有两种说法——判据只有一份这条规矩,本仓已经为同类问题裁过一次(见 `HANDLE_RE` 的注释)。
|
|
22
|
+
*/
|
|
23
|
+
import { HANDLE_RE, parseHandle } from './handle-core.mjs'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 把 number / "412" 收敛成整数行号;小数、空串、非数字一律 undefined。
|
|
28
|
+
*
|
|
29
|
+
* **刻意不在这里判"行号是否合法"**(0、负数、start>end 都照原样传下去):越界与倒序该由引擎
|
|
30
|
+
* 回它自己那把结构化错误(READ_BAD_RANGE 等),而不是在这层被悄悄变成"没给行号"——
|
|
31
|
+
* 那样错误码会从"区间不合法"变成"缺参数",调用方看到的就完全不是一回事了(实测踩过:
|
|
32
|
+
* 11 个套件变红,全是拿越界行号在验结构化拒绝)。
|
|
33
|
+
*/
|
|
34
|
+
export function toLineNumber(value) {
|
|
35
|
+
if (typeof value === 'number') return Number.isInteger(value) ? value : undefined
|
|
36
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) return Number(value.trim())
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 单个 hunk 允许**平铺在一层里**(不必包进 `hunks: [...]`)。
|
|
42
|
+
*
|
|
43
|
+
* 实测依据(403 会话日志):报 `hunks must be a non-empty array` 的失败有 27 次,
|
|
44
|
+
* 入参形状一律是 `{file_path, start_line, end_line, replace}`——模型把“一个 hunk”
|
|
45
|
+
* 直接写在了顶层。CLI 那边同理:`--lines 5 --replace X` 比手写转义过的 JSON 数组好用。
|
|
46
|
+
*
|
|
47
|
+
* 取值就是 hunk 声明里的那几个键。**只取有值的**:平铺形状下常把不用的键留成 `null` / `""`
|
|
48
|
+
* (`{anchor: null, lines: [3,3]}`),而 `planHunks` 判“有没有这个地址”用的是
|
|
49
|
+
* `typeof x === 'string' && x.trim().length > 0`——把空值搬进去等于给它一个**假地址**,
|
|
50
|
+
* 会把“缺地址”变成更难懂的定位失败。
|
|
51
|
+
*
|
|
52
|
+
* 判“是不是平铺形状”只认**地址键**,不认 `replace`:`replace` 单独出现说明不了什么
|
|
53
|
+
* (没有地址的 `replace` 在两种形状下都该报“缺 hunks”),而 `expect` 也不是地址键。
|
|
54
|
+
*
|
|
55
|
+
* 三个调用面(DSH 壳 / CLI / 以后接的框架)**共用这一份**,不各写一套容错规则——
|
|
56
|
+
* 这正是 args.mjs 开头说的那层:能容的就容下来,但不能容出两套判据。
|
|
57
|
+
*
|
|
58
|
+
* @param source - 工具入参(或 CLI 的旗标对象)
|
|
59
|
+
* @returns 一个 hunk 对象;不是平铺形状则 null
|
|
60
|
+
*/
|
|
61
|
+
export function flatHunk(source) {
|
|
62
|
+
if (source === null || typeof source !== 'object') return null
|
|
63
|
+
const ADDRESS_KEYS = FLAT_ADDRESS_KEYS
|
|
64
|
+
if (!ADDRESS_KEYS.some((k) => source[k] !== undefined)) return null
|
|
65
|
+
const hunk = {}
|
|
66
|
+
for (const key of [...ADDRESS_KEYS, ...HANDLE_SHAPE_KEYS, 'replace', 'expect', 'near']) {
|
|
67
|
+
|
|
68
|
+
const value = source[key]
|
|
69
|
+
if (value === undefined || value === null) continue
|
|
70
|
+
if (typeof value === 'string' && value.length === 0) continue
|
|
71
|
+
hunk[key] = value
|
|
72
|
+
}
|
|
73
|
+
return typeof hunk.replace === 'string' ? hunk : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 平铺形状的**地址键**({@link flatHunk} 与未知参数守卫共用同一份名单)。
|
|
78
|
+
*
|
|
79
|
+
* 导出它是为了不再出现第二份:守卫要拿它算“合法键集合”,而如果有两份名单,
|
|
80
|
+
* 新增一种地址写法时必有一处漏改——那正是本项目反复出现的形状。
|
|
81
|
+
*/
|
|
82
|
+
export const FLAT_ADDRESS_KEYS = ['target', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'handle']
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 句柄形状的**从属键**(fs-contract-v2 / HD2):它们只在 `handle` 在场时有意义,
|
|
86
|
+
* 因此**不算地址键**(单给一个 `line` / `position` 不构成一次编辑),但平铺时必须被带上。
|
|
87
|
+
*
|
|
88
|
+
* `line` 是段内相对行(1-based,相对区间起点);`position` 是 `before` / `after` 的边界插入。
|
|
89
|
+
* 两个名字都和既有键不冲突:`lines` 是调用方抄来的绝对坐标,`line` 从来不是入口——
|
|
90
|
+
* 平铺形状里带上它,才不会出现"参数到了、引擎却没收到"这种静默丢字段。
|
|
91
|
+
*
|
|
92
|
+
* **带上 ≠ 成立**:两者单独在场(或与别的地址形状混用而没 `handle`)一律拒绝,
|
|
93
|
+
* 判据本体在 `write-path.mjs` 的 `planHunks`(`PATCH_NO_ADDRESS`)——只有一处,
|
|
94
|
+
* 因为入口不止一个(数组形 / 平铺形 / CLI / MCP),每个入口各自兜住就是两份判据。
|
|
95
|
+
*/
|
|
96
|
+
export const HANDLE_SHAPE_KEYS = ['line', 'position']
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 句柄参数的**形状守卫**(参数面):非空字符串、且匹配 `HANDLE_RE` 才算成形。
|
|
100
|
+
*
|
|
101
|
+
* 形状不对在编辑侧是**抛错**而不是结果(契约 §5 第 0 步):它是"参数没成形",
|
|
102
|
+
* 与 `ARGS_UNKNOWN_KEY` / `PATCH_NO_ADDRESS` 同族,与"地址对不上"是两件事。
|
|
103
|
+
*
|
|
104
|
+
* @param value - 调用方给的 `handle`
|
|
105
|
+
* @returns `{ok: true, parsed}` 或 `{ok: false, reason}`(`reason` 直接进 message,要短)
|
|
106
|
+
*/
|
|
107
|
+
export function handleShapeVerdict(value) {
|
|
108
|
+
if (typeof value !== 'string') return { ok: false, reason: 'must be a string' }
|
|
109
|
+
if (value.length === 0) return { ok: false, reason: 'is empty' }
|
|
110
|
+
const parsed = parseHandle(value)
|
|
111
|
+
if (parsed.ok) return { ok: true, parsed }
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: `does not look like ${HANDLE_RE.source} (expected h<4 hex><8 hex>:<startLine>-<endLine>)`,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 拒绝**工具不认识的顶层参数**——而不是静默忽略它们。
|
|
121
|
+
*
|
|
122
|
+
* ## 为什么必须有这道守卫(实测出来的)
|
|
123
|
+
*
|
|
124
|
+
* 真框架**不会**剥掉未声明的参数:实测 `aura_read` 收到 `{file_path, mod: 'map'}` 时
|
|
125
|
+
* `args.mod` 原样到达 `execute`,而工具只读 `args.mode` ⇒ 回一个**默认模式的成功回执**,零反馈。
|
|
126
|
+
* 模型把 `mode` 写成 `mod`、`lines` 写成 `line`、`maxChars` 写成 `max_chars` 时,
|
|
127
|
+
* 它看到的是成功——比报错坏得多(本项目原则:宁可大声失败,不可静默换语义)。
|
|
128
|
+
*
|
|
129
|
+
* 真框架的 schema 校验器也拦不住:它只在 `additionalProperties: false` 时拒未声明属性
|
|
130
|
+
* (`json-schema.js:492`)。两个工具的旧 schema 没写这一条,所以那是**真空白**。
|
|
131
|
+
* 现在两处一起补:schema 里声明 `additionalProperties: false`(让声明面自洽)
|
|
132
|
+
* + 这里运行期守卫(给**可执行**的报错,而 schema 层只能给一句框架的通用话)。
|
|
133
|
+
*
|
|
134
|
+
* ## 实测代价(16757 次真实调用)
|
|
135
|
+
*
|
|
136
|
+
* 会被这道守卫拒的:`limit×13`、`offset×10`、`line_cue×2`、`start_mode×1`、`sandbox_permissions×1`
|
|
137
|
+
* (全在 `aura_read` 侧;`aura_edit` 侧**一条都没有**)。
|
|
138
|
+
* 其中 `limit`与`offset` 本意是分页——而本工具的分页参数叫 `start_line`/`end_line`,
|
|
139
|
+
* 静默忽略的结果是“模型以为自己翻页了、实际拿到的是同一段”。报错是**修好**,不是收紧。
|
|
140
|
+
*
|
|
141
|
+
* @param source - 工具入参
|
|
142
|
+
* @param allowed - 本工具声明的顶层键(含名与平铺键)
|
|
143
|
+
* @param toolName - 回执里点名的工具
|
|
144
|
+
* @throws {Error} 带 `code: 'ARGS_UNKNOWN_KEY'`,并在 message 里给出合法键名
|
|
145
|
+
*/
|
|
146
|
+
export function assertKnownArgs(source, allowed, toolName) {
|
|
147
|
+
if (source === null || typeof source !== 'object') return
|
|
148
|
+
const known = new Set(allowed)
|
|
149
|
+
const unknown = Object.keys(source).filter((k) => !known.has(k))
|
|
150
|
+
if (unknown.length === 0) return
|
|
151
|
+
const hint =
|
|
152
|
+
unknown.length === 1
|
|
153
|
+
? `unknown argument ${JSON.stringify(unknown[0])}`
|
|
154
|
+
: `unknown arguments ${unknown.map((k) => JSON.stringify(k)).join(', ')}`
|
|
155
|
+
// 只列键名、不列描述:回执要能一眼看完,而参数说明在工具描述里。
|
|
156
|
+
const valid = [...known].sort().join(', ')
|
|
157
|
+
throw Object.assign(
|
|
158
|
+
new Error(`${toolName} got ${hint} — this tool ignores unrecognised arguments, so passing one would silently do nothing. Valid arguments: ${valid}.`),
|
|
159
|
+
{ code: 'ARGS_UNKNOWN_KEY', unknown },
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 调用方到底有没有给"行"这类寻址参数(给了但解析不出来时要报错,而不是静默退回 auto)。 */
|
|
164
|
+
export function gaveLineAddress(source) {
|
|
165
|
+
if (source === null || typeof source !== 'object') return false
|
|
166
|
+
return (
|
|
167
|
+
source.lines !== undefined ||
|
|
168
|
+
source.line !== undefined ||
|
|
169
|
+
source.range !== undefined ||
|
|
170
|
+
source.start_line !== undefined ||
|
|
171
|
+
source.startLine !== undefined ||
|
|
172
|
+
source.end_line !== undefined ||
|
|
173
|
+
source.endLine !== undefined
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 取"行区间"。接受(任何一种都行):
|
|
179
|
+
* · `start_line` + `end_line`(推荐:两个整数,没有数组语法可写错)
|
|
180
|
+
* · `lines: [412, 500]` / `[412]`
|
|
181
|
+
* · `lines: "412,500"` / `"412-500"` / `"412 500"` / `"412"`
|
|
182
|
+
* · `lines: 412`
|
|
183
|
+
* · `lines: { start, end }`(也认 start_line/end_line、from/to、first/last)
|
|
184
|
+
* @returns {{start: number, end: number} | undefined}
|
|
185
|
+
*/
|
|
186
|
+
export function lineRangeOf(source) {
|
|
187
|
+
if (source === null || typeof source !== 'object') return undefined
|
|
188
|
+
const pair = (start, end) => (start === undefined ? undefined : { start, end: end ?? start })
|
|
189
|
+
|
|
190
|
+
// **给了末端但解析不出来 → 返回 undefined(拒绝),而不是悄悄缩成单行**。
|
|
191
|
+
// `lines: [412, "abc"]` / `start_line: 4, end_line: "abc"` 这种半坏的输入,
|
|
192
|
+
// 如果按"末端缺失就用起端"处理,调用方以为读了一段,实际只读了一行 ——
|
|
193
|
+
// 和 0.4.1 修掉的那类静默换语义是同一个病,不能在收敛层自己又犯一次。
|
|
194
|
+
const gaveStart = source.start_line !== undefined || source.startLine !== undefined
|
|
195
|
+
const gaveEnd = source.end_line !== undefined || source.endLine !== undefined
|
|
196
|
+
const startLine = toLineNumber(source.start_line ?? source.startLine)
|
|
197
|
+
const endLine = toLineNumber(source.end_line ?? source.endLine)
|
|
198
|
+
if (gaveStart || gaveEnd) {
|
|
199
|
+
if ((gaveStart && startLine === undefined) || (gaveEnd && endLine === undefined)) return undefined
|
|
200
|
+
return pair(startLine ?? endLine, endLine ?? startLine)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const raw = source.lines ?? source.range ?? source.line
|
|
204
|
+
if (raw === undefined || raw === null) return undefined
|
|
205
|
+
if (Array.isArray(raw)) {
|
|
206
|
+
if (raw.length === 0) return undefined
|
|
207
|
+
const start = toLineNumber(raw[0])
|
|
208
|
+
if (start === undefined) return undefined
|
|
209
|
+
if (raw.length === 1) return { start, end: start }
|
|
210
|
+
const end = toLineNumber(raw[1])
|
|
211
|
+
return end === undefined ? undefined : { start, end }
|
|
212
|
+
}
|
|
213
|
+
if (typeof raw === 'number' || typeof raw === 'string') {
|
|
214
|
+
const match = /^(\d+)\s*(?:[,;.\-–~]|\s+)?\s*(\d+)?$/.exec(String(raw).trim())
|
|
215
|
+
if (match === null) return undefined
|
|
216
|
+
return pair(Number(match[1]), match[2] === undefined ? Number(match[1]) : Number(match[2]))
|
|
217
|
+
}
|
|
218
|
+
if (typeof raw === 'object') {
|
|
219
|
+
const start = toLineNumber(raw.start ?? raw.start_line ?? raw.from ?? raw.first)
|
|
220
|
+
const endValues = [raw.end, raw.end_line, raw.to, raw.last, raw.stop]
|
|
221
|
+
const gaveEndKey = endValues.some((value) => value !== undefined)
|
|
222
|
+
const end = toLineNumber(endValues.find((value) => value !== undefined))
|
|
223
|
+
if (gaveEndKey && end === undefined) return undefined // 同上:末端写坏就拒绝,不静默缩成单行
|
|
224
|
+
return pair(start, end)
|
|
225
|
+
}
|
|
226
|
+
return undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 行区间参数写坏时的统一说法:告诉调用方**接受哪些形状**,而不是只说"参数不对"。 */
|
|
230
|
+
/**
|
|
231
|
+
* 引擎两处要的形状不同,而且必须是它们各自那个:
|
|
232
|
+
* · 读侧 `read-core.renderLines` 只认**数组** `[start, end]`(不然 READ_NO_RANGE);
|
|
233
|
+
* · 写侧 `locate` 只认 **`{start, end}`**。
|
|
234
|
+
* 收敛层统一给 `{start, end}`,到边界各转一次——转错一边就是“参数对了却报参数错”。
|
|
235
|
+
*/
|
|
236
|
+
export const asLinePair = (range) => (range === undefined ? undefined : [range.start, range.end])
|
|
237
|
+
|
|
238
|
+
export const LINE_ADDRESS_HINT =
|
|
239
|
+
'lines 解析不出来:接受 start_line + end_line(推荐)、lines: [412, 500]、lines: "412,500"、lines: 412'
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 读的模式推断:给了哪类定位参数就用哪个模式,省掉一个"顺手写错 mode"的机会。
|
|
243
|
+
* 显式给了 mode 就一律听它(哪怕同时给了 lines)。
|
|
244
|
+
* @returns {string | undefined} undefined = 交给引擎的 auto
|
|
245
|
+
*/
|
|
246
|
+
export function inferReadMode(args, range) {
|
|
247
|
+
if (typeof args?.mode === 'string' && args.mode.trim() !== '') return args.mode
|
|
248
|
+
if (range !== undefined) return 'lines'
|
|
249
|
+
if (args?.around !== undefined) return 'around'
|
|
250
|
+
if (args?.search !== undefined) return 'search'
|
|
251
|
+
if (args?.since !== undefined) return 'changed'
|
|
252
|
+
if (args?.kind !== undefined) return 'map'
|
|
253
|
+
return undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 多目标形状的**契约上限**(fs-contract-v2 §2.1):一次调用最多 32 个目标。
|
|
258
|
+
*
|
|
259
|
+
* 为什么宁可控拒绝也不静默截断:回执体积与宿主载荷都有上限,截断会让调用方以为
|
|
260
|
+
* “这一批都改了”。判据只有这一份 —— 引擎入口与两个壳都调 {@link assertEditTargets},
|
|
261
|
+
* 任何一处都不许再写一遍 `32`。
|
|
262
|
+
*/
|
|
263
|
+
export const MT_MAX_TARGETS = 32
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 并发池大小(契约 §4:`MT_POOL = 8`)。
|
|
267
|
+
*
|
|
268
|
+
* 为什么住在参数面:读侧(`read-core.mjs`)与写侧(`write-path.mjs`)曾**各写一份**
|
|
269
|
+
* `export const MT_POOL = 8` —— 两份活真值恰好同值,于是两边套件同时绿,缝被盖到
|
|
270
|
+
* “有人只改一处”的那一天。与 {@link MT_MAX_TARGETS} 同一条纪律:池大小进的是
|
|
271
|
+
* “同时在飞几个 I/O”这条不变式,不是各引擎可自调的配置。引擎侧改为转发:
|
|
272
|
+
* `import { MT_POOL } from './args.mjs'` 建本地绑定,再 `export { MT_POOL }` 转出去
|
|
273
|
+
* (**不是** `export … from`:那种写法不建本地绑定,引擎自己那两处会当场 `ReferenceError`)。
|
|
274
|
+
*
|
|
275
|
+
* 导出是为了让测试**断言**它,而不是让调用方改写它。
|
|
276
|
+
*/
|
|
277
|
+
export const MT_POOL = 8
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 目标元素的路径键。`file_path` 是既有写侧那个键(契约 §2.2 的例子就是它),`path` 是回执里
|
|
281
|
+
* 印的那个名字,`displayPath` 是引擎层那个名字(壳层平铺透传时原样带过来也认)。
|
|
282
|
+
*
|
|
283
|
+
* 三个都认是**容错**,不是三套判据:取值只有 {@link targetPathOf} 一处。
|
|
284
|
+
*/
|
|
285
|
+
export const MT_TARGET_PATH_KEYS = ['file_path', 'path', 'displayPath']
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 顶层**单目标形状**的键:它们与 `targets` **互斥**(契约 §1.2)。
|
|
289
|
+
*
|
|
290
|
+
* 多给就是“两种形状同时成形”,谁说了算没有正确答案 —— 参数面必须拒绝,而不是挑一个执行:
|
|
291
|
+
* 挑错的那一次会是一次**静默换语义**的编辑,正是本仓最忌的那一类。
|
|
292
|
+
*/
|
|
293
|
+
export const MT_SINGLE_TOP_KEYS = ['file_path', 'path', 'displayPath', 'hunks']
|
|
294
|
+
|
|
295
|
+
/** 参数面报错时的类型说法(`got a string` / `got an array`)。 */
|
|
296
|
+
function typeWordOf(value) {
|
|
297
|
+
if (value === null) return 'null'
|
|
298
|
+
if (Array.isArray(value)) return 'an array'
|
|
299
|
+
return `a ${typeof value}`
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* 目标元素的路径(第一个非空字符串)—— 取值**只有这一处**。
|
|
304
|
+
* @returns {string | undefined}
|
|
305
|
+
*/
|
|
306
|
+
export function targetPathOf(element) {
|
|
307
|
+
if (element === null || typeof element !== 'object') return undefined
|
|
308
|
+
for (const key of MT_TARGET_PATH_KEYS) {
|
|
309
|
+
const value = element[key]
|
|
310
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
311
|
+
}
|
|
312
|
+
return undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* `targets` 的**形状判据**(参数面,唯一一份):供引擎入口与两个壳复用。
|
|
317
|
+
*
|
|
318
|
+
* 为什么放在 args.mjs 而不是壳里:壳有两个(DSH 壳 / CLI·MCP 的 ops),判据写两遍必然漂开 ——
|
|
319
|
+
* 而漂开之后同一个载荷在一面被拒、在另一面被执行。与 `lineRangeOf`、`flatHunk` 同一条纪律。
|
|
320
|
+
*
|
|
321
|
+
* 只判**形状**(是不是数组、几项、每项有没有路径、跟单目标形状撞不撞),
|
|
322
|
+
* 元素内部(hunks、地址、`expect`)一律交给引擎那份既有单目标校验器(契约 §2.2:同一个校验器)。
|
|
323
|
+
*
|
|
324
|
+
* @param source - 工具入参
|
|
325
|
+
* @param options.toolName - 回执里点名的工具
|
|
326
|
+
* @param options.targetKey - 多目标那个键,默认 `targets`
|
|
327
|
+
* @returns 校验过的 `targets` 数组;**没有 `targets` 键**时返回 undefined(单目标形状,壳层照旧走)
|
|
328
|
+
* @throws {Error & {code}} `ARGS_TARGETS_SHAPE` / `ARGS_TARGETS_LIMIT` / `ARGS_SHAPE_EXCLUSIVE`
|
|
329
|
+
*/
|
|
330
|
+
export function assertEditTargets(source, options = {}) {
|
|
331
|
+
const toolName = options.toolName ?? 'this tool'
|
|
332
|
+
const targetKey = options.targetKey ?? 'targets'
|
|
333
|
+
if (source === null || typeof source !== 'object') return undefined
|
|
334
|
+
const raw = source[targetKey]
|
|
335
|
+
// 没有这个键 ⇒ 单目标形状:这里一个字都不说,壳层继续走它今天那条路(契约 §1.1 零回归)。
|
|
336
|
+
if (raw === undefined || raw === null) return undefined
|
|
337
|
+
/**
|
|
338
|
+
* 形状提示语**可换**(读侧的元素是 `{file_path, mode, …}`,写侧是 `{file_path, hunks}`)。
|
|
339
|
+
* 只换措辞,判据一个字不动;缺省值与写侧改前**逐字相同**(`multi-edit-test` 段里比过 message)。
|
|
340
|
+
*/
|
|
341
|
+
const shapeHint = options.shapeHint ?? `each one is { ${MT_TARGET_PATH_KEYS[0]}: \"...\", hunks: [...] }`
|
|
342
|
+
if (!Array.isArray(raw)) {
|
|
343
|
+
throw Object.assign(
|
|
344
|
+
new Error(
|
|
345
|
+
`${toolName}: \`${targetKey}\` must be an array of targets (${shapeHint}) — got ${typeWordOf(raw)}; pass one object per file`,
|
|
346
|
+
),
|
|
347
|
+
{ code: 'ARGS_TARGETS_SHAPE', got: typeWordOf(raw) },
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
/** 互斥判据(契约 §1.2)就在这里,壳层不许再写一遍。 */
|
|
351
|
+
const clashed = MT_SINGLE_TOP_KEYS.filter((key) => source[key] !== undefined)
|
|
352
|
+
if (clashed.length > 0) {
|
|
353
|
+
throw Object.assign(
|
|
354
|
+
new Error(
|
|
355
|
+
`${toolName}: pass \`${targetKey}\` or the single-target shape (${clashed.join(', ')}), not both — they are two ways to say the same call, and nothing was written`,
|
|
356
|
+
),
|
|
357
|
+
{ code: 'ARGS_SHAPE_EXCLUSIVE', clashed },
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
if (raw.length === 0) {
|
|
361
|
+
throw Object.assign(
|
|
362
|
+
new Error(`${toolName}: \`${targetKey}\` is empty — pass at least one target (${shapeHint})`),
|
|
363
|
+
{ code: 'ARGS_TARGETS_SHAPE', got: 'an empty array' },
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
if (raw.length > MT_MAX_TARGETS) {
|
|
367
|
+
throw Object.assign(
|
|
368
|
+
new Error(
|
|
369
|
+
`${toolName}: too many targets — the limit is ${MT_MAX_TARGETS}, got ${raw.length}; split the call into batches of ${MT_MAX_TARGETS}`,
|
|
370
|
+
),
|
|
371
|
+
{ code: 'ARGS_TARGETS_LIMIT', limit: MT_MAX_TARGETS, got: raw.length },
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
raw.forEach((element, index) => {
|
|
375
|
+
if (element === null || typeof element !== 'object' || Array.isArray(element)) {
|
|
376
|
+
throw Object.assign(
|
|
377
|
+
new Error(`${toolName}: \`${targetKey}[${index}]\` must be an object (${shapeHint}) — got ${typeWordOf(element)}`),
|
|
378
|
+
{ code: 'ARGS_TARGETS_SHAPE', index, got: typeWordOf(element) },
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
if (element[targetKey] !== undefined) {
|
|
382
|
+
throw Object.assign(
|
|
383
|
+
new Error(
|
|
384
|
+
`${toolName}: \`${targetKey}[${index}]\` carries its own \`${targetKey}\` — nested batches are not a shape this tool accepts; flatten it into one array`,
|
|
385
|
+
),
|
|
386
|
+
{ code: 'ARGS_TARGETS_SHAPE', index, got: `an object with \`${targetKey}\`` },
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
if (targetPathOf(element) === undefined) {
|
|
390
|
+
throw Object.assign(
|
|
391
|
+
new Error(
|
|
392
|
+
`${toolName}: \`${targetKey}[${index}]\` has no path — give it \`${MT_TARGET_PATH_KEYS[0]}\` (or \`${MT_TARGET_PATH_KEYS[1]}\`)`,
|
|
393
|
+
),
|
|
394
|
+
{ code: 'ARGS_TARGETS_SHAPE', index, got: 'an object without a path' },
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
return raw
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 把参数面的目标元素收敛成**引擎请求形状**(`{displayPath, hunks, create?, identityKey?, dryRun?}`)。
|
|
403
|
+
*
|
|
404
|
+
* 为什么也放在这里:形状判据与取值归一一旦分家,就会出现“判的是 A、读的是 B”那种错位;
|
|
405
|
+
* 两个壳 + 引擎入口共用的必须是同一份。**元素内部一个字都不解读** —— hunks、地址、`expect`
|
|
406
|
+
* 原样递下去,由既有单目标校验器判(契约 §2.2)。
|
|
407
|
+
*
|
|
408
|
+
* @param targets - {@link assertEditTargets} 放行的数组
|
|
409
|
+
* @returns `Array<{index, displayPath, hunks, create?, identityKey?, dryRun?}>`(顺序 = 输入顺序)
|
|
410
|
+
*/
|
|
411
|
+
/**
|
|
412
|
+
* **读侧**的 `targets` 形状校验(契约 §2.1):与写侧**同一个判据**,只换形状提示语。
|
|
413
|
+
*
|
|
414
|
+
* 为什么不是另一份实现:`assertEditTargets` 里那条互斥判据(`targets` 与单目标形状同时给 ⇒
|
|
415
|
+
* 参数面拒绝)是本仓的**唯一一份**。读侧再写一遍就会漂开 —— 而漂开之后,同一个载荷
|
|
416
|
+
* 在 `aura_read` 被放行、在 `aura_edit` 被拒(或反过来),且两边都不会报错。
|
|
417
|
+
*
|
|
418
|
+
* 读侧元素内部(mode / lines / search / since / kind)本函数**一个字都不解读**:
|
|
419
|
+
* 它们由引擎 `readManyWith` 逐项走今天那套单目标判定器(契约 §2.1「同一个校验器」)。
|
|
420
|
+
*
|
|
421
|
+
* @param source - 工具入参
|
|
422
|
+
* @param options.toolName - 回执里点名的工具
|
|
423
|
+
* @returns 校验过的 `targets` 数组;**没有 `targets` 键**时返回 undefined(单目标形状,壳层照旧走)
|
|
424
|
+
* @throws {Error & {code}} 同 {@link assertEditTargets}(`ARGS_TARGETS_SHAPE` / `ARGS_TARGETS_LIMIT` / `ARGS_SHAPE_EXCLUSIVE`)
|
|
425
|
+
*/
|
|
426
|
+
export function assertReadTargets(source, options = {}) {
|
|
427
|
+
return assertEditTargets(source, {
|
|
428
|
+
...options,
|
|
429
|
+
shapeHint: `each one is { ${MT_TARGET_PATH_KEYS[0]}: \"...\", mode: \"...\" }`,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* **顶层形状判据**(参数面,唯一一份):`targets` 与单目标形状**必居其一**。
|
|
435
|
+
*
|
|
436
|
+
* 与 {@link assertEditTargets} 里那条“互斥”判据是一对:
|
|
437
|
+
* · **两个都给** ⇒ {@link assertEditTargets} 先判,抛 `ARGS_SHAPE_EXCLUSIVE`(语义一字未动);
|
|
438
|
+
* · **两个都没给** ⇒ 本函数抛**同族码** `ARGS_SHAPE_EXCLUSIVE`。壳里原来那句
|
|
439
|
+
* `file_path must be a non-empty string` 就是这条判据的半个身子——它写成特例,
|
|
440
|
+
* 于是“什么都没给”在回执里长得像“file_path 写错了”,而真正的原因是**没有形状可用**。
|
|
441
|
+
*
|
|
442
|
+
* 为什么只能由行为面判:把“二选一”写回 schema **已实测不可行**(本框架方言不支持 `anyOf`,
|
|
443
|
+
* 且校验器对它静默忽略),证据记在 `aura-read/index.js` 的 `file_path` 声明处。而行为面有
|
|
444
|
+
* 两个壳(`aura-read` / `aura-edit`),判据写两遍必然漂开 ⇒ 只此一处,两个壳各调一次。
|
|
445
|
+
*
|
|
446
|
+
* message 里**逐字保留** `file_path must be a non-empty string`:`arg-shape-test` 段C 用这句
|
|
447
|
+
* 正则钉着“两个都没给 ⇒ 也拒”。措辞改了那条断言就红,而那条断言**不许改**。
|
|
448
|
+
*
|
|
449
|
+
* @param source - 工具入参
|
|
450
|
+
* @param options.toolName - 回执里点名的工具
|
|
451
|
+
* @param options.targetKey - 多目标那个键,默认 `targets`
|
|
452
|
+
* @returns `targets` 的值(多目标形状)或 undefined(单目标形状成形)
|
|
453
|
+
* @throws {Error & {code}} `ARGS_SHAPE_EXCLUSIVE`(两个都没给)
|
|
454
|
+
*/
|
|
455
|
+
export function assertTargetShape(source, options = {}) {
|
|
456
|
+
const toolName = options.toolName ?? 'this tool'
|
|
457
|
+
const targetKey = options.targetKey ?? 'targets'
|
|
458
|
+
const object = source !== null && typeof source === 'object' ? source : undefined
|
|
459
|
+
const raw = object === undefined ? undefined : object[targetKey]
|
|
460
|
+
// `targets` 在场 ⇒ 已经是多目标形状(“与单目标形状互斥”由 assertEditTargets 先判过)。
|
|
461
|
+
if (raw !== undefined && raw !== null) return raw
|
|
462
|
+
// 单目标形状**成形**的唯一条件与两个壳逐字相同:`file_path` 是非空字符串。
|
|
463
|
+
const filePath = object === undefined ? undefined : object.file_path
|
|
464
|
+
if (typeof filePath === 'string' && filePath.trim().length > 0) return undefined
|
|
465
|
+
throw Object.assign(
|
|
466
|
+
new Error(
|
|
467
|
+
`${toolName}: pass \`${targetKey}\` or the single-target shape, not neither — they are two ways to say the same call and exactly one of them has to be there; the single-target shape starts with \`file_path must be a non-empty string\``,
|
|
468
|
+
),
|
|
469
|
+
{ code: 'ARGS_SHAPE_EXCLUSIVE', missing: [targetKey, 'file_path'] },
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 把参数面的目标元素收敛成**引擎请求形状**(`{displayPath, hunks, create?, identityKey?, dryRun?}`)。
|
|
475
|
+
*
|
|
476
|
+
* 为什么也放在这里:形状判据与取值归一一旦分家,就会出现“判的是 A、读的是 B”那种错位;
|
|
477
|
+
* 两个壳 + 引擎入口共用的必须是同一份。**元素内部一个字都不解读** —— hunks、地址、`expect`
|
|
478
|
+
* 原样递下去,由既有单目标校验器判(契约 §2.2)。
|
|
479
|
+
*
|
|
480
|
+
* @param targets - {@link assertEditTargets} 放行的数组
|
|
481
|
+
* @returns `Array<{index, displayPath, hunks, create?, identityKey?, dryRun?}>`(顺序 = 输入顺序)
|
|
482
|
+
*/
|
|
483
|
+
export function editTargetRequests(targets) {
|
|
484
|
+
return targets.map((element, index) => {
|
|
485
|
+
const request = { index, displayPath: targetPathOf(element), hunks: element.hunks }
|
|
486
|
+
for (const key of ['create', 'identityKey', 'dryRun']) {
|
|
487
|
+
if (element[key] !== undefined) request[key] = element[key]
|
|
488
|
+
}
|
|
489
|
+
return request
|
|
490
|
+
})
|
|
491
|
+
}
|