aura-basetools 0.0.0-stage → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1649 @@
1
+ /**
2
+ * standalone-test.mjs —— 独立面(CLI + MCP)的验收:不依赖 DSH,也不碰你的 home。
3
+ *
4
+ * 覆盖:
5
+ * A 读:四种取景 + 令牌
6
+ * B 改:按符号改 + 落盘回读证据
7
+ * C 字节保真:BOM / 双 BOM / CRLF / 混合行尾两向 / 无尾换行 / 中文 / 尾 BOM —— 改第 4 行后其余字节不动
8
+ * D 陈旧闸门:读完之后那块区域被外部改过 ⇒ 必须拒绝且磁盘保留外部改动
9
+ * E 建文件:--create
10
+ * F MCP:initialize / tools/list / tools/call 读写往返
11
+ * 运行:node aura-standalone/test/standalone-test.mjs
12
+ */
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { spawnSync } from 'node:child_process'
17
+ import { pathToFileURL } from 'node:url'
18
+ import { createNodeFsProvider } from '../lib/fs-provider.mjs'
19
+ import { createDiskStore } from '../lib/observation-store.mjs'
20
+ import { withWorkspaceLock } from '../lib/workspace-lock.mjs'
21
+
22
+ const here = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'))
23
+ const bin = path.join(here, '..', 'bin', 'aura.mjs')
24
+ const mcpBin = path.join(here, '..', 'bin', 'aura-mcp.mjs')
25
+
26
+ let pass = 0
27
+ let fail = 0
28
+ const check = (ok, label, detail = '') => {
29
+ if (ok) pass += 1
30
+ else fail += 1
31
+ console.log(`${ok ? '✓' : '✗'} ${label}${ok || detail === '' ? '' : ` ${detail}`}`)
32
+ }
33
+ const say = (m) => console.log(`\n=== ${m} ===`)
34
+
35
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aura-standalone-'))
36
+ const cache = path.join(tmp, 'cache') // 观测缓存也放测试目录,绝不写 ~/.aura
37
+ const env = { ...process.env, AURA_CACHE_DIR: cache }
38
+ const run = (args, cwd = tmp) => spawnSync(process.execPath, [bin, ...args], { cwd, encoding: 'utf8', env })
39
+ const json = (res) => {
40
+ try {
41
+ return JSON.parse(res.stdout.trim().split('\n').pop())
42
+ } catch {
43
+ // 把 stderr 一起带出来:否则失败只剩一句"不是 JSON",诊断不了(这个 harness 自己踩过)
44
+ return { ok: false, error: `stdout 不是 JSON:${JSON.stringify(res.stdout)} ${String(res.stderr ?? '').trim().split('\n').slice(0, 3).join(' / ')}` }
45
+ }
46
+ }
47
+ const utf8 = (s) => Buffer.from(s, 'utf8')
48
+ const BOM = Buffer.from([0xef, 0xbb, 0xbf])
49
+ const write = (name, buf) => {
50
+ const p = path.join(tmp, name)
51
+ fs.writeFileSync(p, buf)
52
+ return { path: p, orig: Buffer.from(buf) }
53
+ }
54
+
55
+ /**
56
+ * 本套件自己也可能住在两种布局里(`src/aura-standalone/test/` 或包内摊平的 `test/`,
57
+ * `make-bundle.mjs` 会把这份验收一起发出去)。所以这里不写死"引擎在 `../../locator-lab`":
58
+ * 判据与 `lib/ops.mjs` 的同一条——**哪里真的有引擎本体 `read-core.mjs`**,只有两个固定候选。
59
+ */
60
+ const ENGINE_MARKER = 'read-core.mjs'
61
+ const engineCandidates = ['../locator-lab/', '../../locator-lab/'].map((rel) => path.resolve(here, rel))
62
+ const engineDir = engineCandidates.find((dir) => fs.existsSync(path.join(dir, ENGINE_MARKER)))
63
+
64
+ /**
65
+ * 按**打包器的摊平规则**把独立面摆成包内布局,摆在临时目录里(不碰 dist/,那是派生物)。
66
+ *
67
+ * 规则只有一条(`build/make-bundle.mjs` 的 `STANDALONE` 表):`aura-standalone/{bin,lib,test}`
68
+ * 里的每个文件各自上移一层变成 `{bin,lib,test}`,引擎 `locator-lab/` 同时上移一层 ⇒ 从 `lib/` 看
69
+ * 引擎与它同层(`../locator-lab/`)。这里照抄这一条,并把 `bin/`、`lib/`、`locator-lab/` 三个目录
70
+ * **整份**带过去:测试要证明的是"摆成这样以后解析得到",只抄一份写死的文件清单就测不出漏文件。
71
+ *
72
+ * 摊平后的包根本身**不放 package.json**(解析的判据是 `locator-lab/` 在哪,不是有没有清单),
73
+ * 于是这份临时布局顺带证明:两边都不靠包清单。
74
+ */
75
+ const flatten = path.join(tmp, 'flat', 'aura-basetools')
76
+ const flattenLayout = () => {
77
+ const copyDir = (from, to) => {
78
+ fs.mkdirSync(to, { recursive: true })
79
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
80
+ const src = path.join(from, entry.name)
81
+ const dst = path.join(to, entry.name)
82
+ if (entry.isDirectory()) copyDir(src, dst)
83
+ // 摊平规则只搬 .mjs(靶场 `range-*.mjs` 归项目级,包里不发)
84
+ else if (entry.name.endsWith('.mjs') && !entry.name.startsWith('range-')) fs.copyFileSync(src, dst)
85
+ }
86
+ }
87
+ copyDir(engineDir, path.join(flatten, 'locator-lab'))
88
+ // 摊平时 `bin/`、`lib/`、`test/` 与引擎的相对位置不变:整体上移一层
89
+ const up = (p) => path.relative(path.join(here, '..', '..'), p).split(path.sep).slice(1).join(path.sep)
90
+ for (const dir of ['bin', 'lib']) copyDir(path.join(here, '..', dir), path.join(flatten, up(path.join(here, '..', dir))))
91
+ return flatten
92
+ }
93
+
94
+ /**
95
+ * 在给定布局里**真跑一次读与一次编辑**(不是只 import 一下)。
96
+ * 返回 `{ readOk, readExit, editOk, editExit, text }` —— 读要看见符号,改要真落到磁盘。
97
+ */
98
+ const layoutRoundTrip = (binPath, label) => {
99
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), `aura-${label}-`))
100
+ const file = path.join(work, 'probe.js')
101
+ fs.writeFileSync(file, 'function alpha() {\n const a = 1\n return a\n}\n')
102
+ const env2 = { ...process.env, AURA_CACHE_DIR: path.join(work, 'cache') }
103
+ const spawn = (args) => spawnSync(process.execPath, [binPath, ...args], { cwd: work, encoding: 'utf8', env: env2 })
104
+ const read = spawn(['basetools', 'read', '--file', file, '--mode', 'map'])
105
+ const edited = spawn(['basetools', 'edit', '--file', file, '--hunks', JSON.stringify([{ lines: [2, 2], replace: ' const a = 42' }])])
106
+ const text = fs.readFileSync(file, 'utf8')
107
+ fs.rmSync(work, { recursive: true, force: true })
108
+ const parse = (res) => {
109
+ try {
110
+ return JSON.parse(String(res.stdout).trim().split('\n').pop())
111
+ } catch {
112
+ return { ok: false, error: String(res.stderr ?? '').trim().split('\n')[0] }
113
+ }
114
+ }
115
+ return { read: { ...parse(read), exit: read.status }, edit: { ...parse(edited), exit: edited.status }, text }
116
+ }
117
+
118
+ const split = (s) => {
119
+ const lines = []
120
+ const ends = []
121
+ const re = /\r\n|\n/g
122
+ let st = 0
123
+ let m
124
+ while ((m = re.exec(s)) !== null) {
125
+ lines.push(s.slice(st, m.index))
126
+ ends.push(m[0])
127
+ st = m.index + m[0].length
128
+ }
129
+ lines.push(s.slice(st))
130
+ ends.push('')
131
+ return { lines, ends }
132
+ }
133
+ const countBom = (b) => {
134
+ let n = 0
135
+ while (b.length >= (n + 1) * 3 && BOM.every((x, i) => b[n * 3 + i] === x)) n += 1
136
+ return n
137
+ }
138
+
139
+ // ── A 读 ──────────────────────────────────────────────────────────────────
140
+ say('A 读:取景与令牌')
141
+ {
142
+ const f = write('a.js', utf8('function alpha() {\n const a = 1\n return a\n}\n\nfunction beta() {\n const b = 2\n}\n'))
143
+ const map = json(run(['basetools', 'read', '--file', f.path, '--mode', 'map']))
144
+ // 信封里 `symbols` 是**个数**,渲染好的目录在 `text` —— 断言照实际形状写(我第一版写成数组,当场红)
145
+ check(map.ok === true && typeof map.text === 'string' && map.symbols >= 2, 'map 返回符号目录(text + 计数)', JSON.stringify(map).slice(0, 120))
146
+ check(String(map.text).includes('beta'), 'map 里有 beta')
147
+ const around = json(run(['basetools', 'read', '--file', f.path, '--mode', 'around', '--around', 'beta']))
148
+ check(around.ok === true && String(around.text ?? '').includes('beta'), 'around 窗口里有 beta')
149
+ const full = json(run(['basetools', 'read', '--file', f.path, '--mode', 'full']))
150
+ check(typeof full.version === 'string' && full.version.length > 0, `full 给出令牌 ${full.version}`)
151
+ const changed = json(run(['basetools', 'read', '--file', f.path, '--mode', 'changed', '--since', String(full.version)]))
152
+ check(changed.ok === true && String(changed.note ?? '').includes('no change'), '立即 changed = 无变化', JSON.stringify(changed).slice(0, 120))
153
+ }
154
+
155
+ // ── B 改 ─────────────────────────────────────────────────────────────────
156
+ say('B 改:按符号 + 回读证据')
157
+ {
158
+ const f = write('b.js', utf8('function alpha() {\n const a = 1\n return a\n}\n'))
159
+ const res = run(['basetools', 'edit', '--file', f.path, '--hunks', JSON.stringify([{ target: 'alpha', replace: 'function alpha() {\n return 42\n}' }])])
160
+ const env2 = json(res)
161
+ check(env2.ok === true, '按符号改成功', JSON.stringify(env2).slice(0, 160))
162
+ check(String(fs.readFileSync(f.path, 'utf8')).includes('return 42'), '磁盘上真的改了')
163
+ check(env2.result?.write_verify?.level === 'bytes', `回读证据 level=${env2.result?.write_verify?.level}`)
164
+ const dry = json(run(['basetools', 'edit', '--file', f.path, '--hunks', JSON.stringify([{ target: 'alpha', replace: 'x' }]), '--dry-run']))
165
+ check(dry.ok === true && dry.result?.dryRun === true, 'dry_run 不落盘')
166
+ check(String(fs.readFileSync(f.path, 'utf8')).includes('return 42'), 'dry_run 之后磁盘没变')
167
+ const beforePlan = fs.readFileSync(f.path, 'utf8')
168
+ const plan = json(run(['basetools', 'edit', '--file', f.path, '--hunks', JSON.stringify([{ target: 'alpha', replace: 'x' }]), '--plan']))
169
+ check(plan.ok === true && plan.result?.dryRun === true, '--plan 是 --dry-run 的同义旗标(回执照常给 applied)', JSON.stringify(plan).slice(0, 120))
170
+ check(fs.readFileSync(f.path, 'utf8') === beforePlan, '--plan 之后磁盘逐字节没变')
171
+ }
172
+
173
+ // ── B2 能力清单:运行时可查(0.9.9) ─────────────────────────────────────
174
+ say('B2 能力清单:capabilities 从引擎常量派生')
175
+ {
176
+ const caps = json(run(['basetools', 'capabilities']))
177
+ const readTool = caps.tools?.find((tool) => tool.name === 'aura_basetools_read')
178
+ const editTool = caps.tools?.find((tool) => tool.name === 'aura_basetools_edit')
179
+ check(caps.ok === true && caps.langsCount === caps.langs?.length, `langsCount=${caps.langsCount}`, JSON.stringify(caps.langs?.length))
180
+ check(caps.langsCount === 65, '语言表 65 种(与 LANGS 同源)', String(caps.langsCount))
181
+ check(readTool?.modes?.includes('auto') && readTool.modes.includes('map') && readTool.modes.includes('changed'), '读模式含 auto/map/changed', JSON.stringify(readTool?.modes))
182
+ check(editTool?.addressKinds?.includes('intent') && editTool.addressKinds.includes('target') && editTool.addressKinds.includes('handle'), '编辑地址档含 intent/target/handle', JSON.stringify(editTool?.addressKinds))
183
+ check(editTool?.options?.includes('replace_all'), '编辑选项含 replace_all', JSON.stringify(editTool?.options))
184
+ check(editTool?.options?.includes('require_observation'), '编辑选项含 require_observation', JSON.stringify(editTool?.options))
185
+ check(caps.limits?.multiTargetsMax === 32 && caps.limits?.defaultPruneBudget === 8192 && caps.limits?.contentBudget === 7492, '限制与常量一致', JSON.stringify(caps.limits))
186
+ check(caps.commands?.includes('basetools doctor') && caps.commands.includes('basetools capabilities') && caps.commands.includes('basetools write'), '命令表含 doctor/capabilities/write', JSON.stringify(caps.commands))
187
+ const writeTool = caps.tools?.find((tool) => tool.name === 'aura_basetools_write')
188
+ check(writeTool?.writeSafety?.dsh?.includes('observation-gate') && !writeTool.writeSafety.dsh.includes('transaction'), 'write 能力按入口分:DSH 有观察闸、无事务', JSON.stringify(writeTool?.writeSafety))
189
+ }
190
+
191
+ // ── B3 平铺 CLI 的空 replace = 删除(0.9.10 评审发现) ─────────────────────
192
+ say('B3 平铺 CLI:--replace "" 是删除,不是缺参数')
193
+ {
194
+ const f = write('del.txt', 'a\nb\n')
195
+ const res = run(['basetools', 'edit', '--file', f.path, '--lines', '1,1', '--replace', ''])
196
+ const env = json(res)
197
+ check(res.status === 0 && env.ok === true, '空 replace 被当成删除(exit 0)', JSON.stringify(env).slice(0, 140))
198
+ check(fs.readFileSync(f.path, 'utf8') === 'b\n', '第一行被删除,剩余字节不动', JSON.stringify(fs.readFileSync(f.path, 'utf8')))
199
+ }
200
+
201
+ // ── B4 分页读:内置 Read 的 offset/limit 形状(0.10.0) ───────────────────
202
+ say('B4 分页读:offset/limit 与 lines 互斥')
203
+ {
204
+ const f = write('page.txt', Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join('\n'))
205
+ const page = json(run(['basetools', 'read', '--file', f.path, '--offset', '2', '--limit', '3']))
206
+ check(page.ok === true && page.mode === 'lines' && page.totalLines === 10 && page.shown === '3-5', 'offset=2 limit=3 ⇒ 第 3-5 行', JSON.stringify({ mode: page.mode, totalLines: page.totalLines, shown: page.shown }))
207
+ check(page.text.includes('3: line 3') && page.text.includes('5: line 5') && !page.text.includes('6: line 6'), '正文只含 3-5 行', JSON.stringify(page.text))
208
+ const conflict = json(run(['basetools', 'read', '--file', f.path, '--lines', '1,2', '--offset', '0', '--limit', '2']))
209
+ check(conflict.ok === false && conflict.code === 'READ_BAD_ARGS', 'lines 与 offset/limit 同给 ⇒ 拒绝', JSON.stringify(conflict).slice(0, 160))
210
+ const oob = json(run(['basetools', 'read', '--file', f.path, '--offset', '99', '--limit', '2']))
211
+ check(oob.ok === false && oob.code === 'READ_BAD_ARGS', 'offset 越界 ⇒ 拒绝', JSON.stringify(oob).slice(0, 160))
212
+ const modeOffset = json(run(['basetools', 'read', '--file', f.path, '--mode', 'map', '--offset', '0', '--limit', '2']))
213
+ check(modeOffset.ok === false && modeOffset.code === 'READ_BAD_ARGS', '显式 mode 与 offset/limit 同给 ⇒ 拒绝(不静默换成 lines)', JSON.stringify(modeOffset).slice(0, 160))
214
+ }
215
+
216
+ // ── C 字节保真 ────────────────────────────────────────────────────────────
217
+ say('C 字节保真:改第 4 行,其余字节不动')
218
+ {
219
+ const shapes = {
220
+ 'bom_lf.md': Buffer.concat([BOM, utf8('alpha\nbeta\ngamma\ndelta\n')]),
221
+ 'bom_crlf.md': Buffer.concat([BOM, utf8('alpha\r\nbeta\r\ngamma\r\ndelta\r\n')]),
222
+ 'double_bom.md': Buffer.concat([BOM, BOM, utf8('alpha\nbeta\ngamma\ndelta\n')]),
223
+ 'crlf.md': utf8('alpha\r\nbeta\r\ngamma\r\ndelta\r\n'),
224
+ 'mixed_lf_major.md': utf8('alpha\nbeta\r\ngamma\ndelta\n'),
225
+ 'mixed_crlf_major.md': utf8('alpha\r\nbeta\ngamma\r\ndelta\r\n'),
226
+ 'no_trailing.md': utf8('alpha\nbeta\ngamma\ndelta'),
227
+ 'cjk.md': utf8('第一行\n第二行\n第三行\n第四行\n'),
228
+ 'blank_lines.md': utf8('alpha\n\nbeta\ndelta\n'),
229
+ 'trailing_bom.md': Buffer.concat([utf8('alpha\nbeta\ngamma\ndelta\n'), BOM]),
230
+ }
231
+ for (const [name, buf] of Object.entries(shapes)) {
232
+ const f = write(name, buf)
233
+ const res = run(['basetools', 'edit', '--file', f.path, '--hunks', JSON.stringify([{ lines: [4, 4], replace: 'EDITED' }])])
234
+ const env2 = json(res)
235
+ if (env2.ok !== true) {
236
+ check(false, `${name} 编辑成功`, JSON.stringify(env2).slice(0, 140))
237
+ continue
238
+ }
239
+ const before = split(f.orig.toString('utf8'))
240
+ const after = split(fs.readFileSync(f.path, 'utf8'))
241
+ const problems = []
242
+ if (countBom(f.orig) !== countBom(fs.readFileSync(f.path))) problems.push('前导 BOM 个数变了')
243
+ if (before.lines.length !== after.lines.length) problems.push(`行数 ${before.lines.length}→${after.lines.length}`)
244
+ for (let i = 0; i < Math.min(before.lines.length, after.lines.length); i += 1) {
245
+ if (i === 3) {
246
+ if (after.lines[i] !== 'EDITED') problems.push(`第 4 行不是 EDITED:${JSON.stringify(after.lines[i])}`)
247
+ } else if (before.lines[i] !== after.lines[i]) problems.push(`第 ${i + 1} 行被改动`)
248
+ if (before.ends[i] !== after.ends[i]) problems.push(`第 ${i + 1} 行行尾变了`)
249
+ }
250
+ check(problems.length === 0, `${name}:除目标行外逐字节不变`, problems.join(' | '))
251
+ }
252
+ }
253
+
254
+ // ── D 陈旧闸门 ────────────────────────────────────────────────────────────
255
+ say('D 陈旧闸门:读之后区域被外部改过 ⇒ 拒绝')
256
+ {
257
+ const f = write('d.md', utf8('one\ntwo\nthree\nfour\n'))
258
+ json(run(['basetools', 'read', '--file', f.path, '--mode', 'full'])) // 建"上次观测"(落盘缓存)
259
+ fs.writeFileSync(f.path, 'one\nTWO-BY-SOMEONE-ELSE\nthree\nfour\n') // 外部改第 2 行
260
+ const res = run(['basetools', 'edit', '--file', f.path, '--hunks', JSON.stringify([{ lines: [2, 2], replace: 'mine' }])])
261
+ const env2 = json(res)
262
+ check(env2.ok === false && env2.code === 'PATCH_STALE_REGION', `被拒绝(code=${env2.code})`, JSON.stringify(env2).slice(0, 160))
263
+ check(fs.readFileSync(f.path, 'utf8').includes('TWO-BY-SOMEONE-ELSE'), '磁盘保留外部改动')
264
+ // 带 expect 的老路不受影响:改第 4 行(未被动过)
265
+ const ok = json(run(['basetools', 'edit', '--file', f.path, '--hunks', JSON.stringify([{ lines: [4, 4], expect: 'four', replace: 'FOUR' }])]))
266
+ check(ok.ok === true, '未被动过的区域照常可编辑', JSON.stringify(ok).slice(0, 140))
267
+ }
268
+
269
+ // ── E 建文件 ──────────────────────────────────────────────────────────────
270
+ // 旧的写法是 `--hunks '[]' --create …`——那是 workaround,不是契约(T6 第 4 条:
271
+ // 把 workaround 固化成契约,等于把“创建被自己的校验挡住”这个缺陷锁进测试里)。
272
+ say('E 建文件:create 可单独用(与 DSH 壳同一条规则)')
273
+ {
274
+ const p = path.join(tmp, 'made.md')
275
+ const res = run(['basetools', 'edit', '--file', p, '--create', 'hello\n'])
276
+ const env2 = json(res)
277
+ check(env2.ok === true && env2.result?.operation === 'create', `不给 --hunks 也能建文件 operation=${env2.result?.operation}`, JSON.stringify(env2).slice(0, 140))
278
+ check(fs.readFileSync(p, 'utf8') === 'hello\n', '内容是逐字节写下的')
279
+ // 兼容:显式给 `--hunks '[]'` 仍然可用(旧脚本不该因此变红)
280
+ const p2 = path.join(tmp, 'made2.md')
281
+ check(json(run(['basetools', 'edit', '--file', p2, '--hunks', '[]', '--create', 'hi\n'])).ok === true, "显式 `--hunks '[]' --create` 仍可用(向后兼容)")
282
+ const again = json(run(['basetools', 'edit', '--file', p, '--create', 'other\n']))
283
+ check(again.ok === false, '已存在时不覆盖(create 只用于不存在)', JSON.stringify(again).slice(0, 140))
284
+ }
285
+
286
+ // ── E1 `--create` 的取值:以 `--` 开头、含换行,都不能被吞 ────────────────
287
+ // 实测(T6 第 3 条):`flag()` 把“下一个参数以 `--` 开头”一律当“这个旗标没带值”,
288
+ // 于是 `--create "---\ntitle: x"`(front matter)的值被静默丢掉,报错还是
289
+ // `PATCH_NOT_FOUND … to create it, pass create`——传了 create 却被叫去传 create。
290
+ say('E1 --create 的取值:front matter / 含换行 / 后面还跟旗标')
291
+ {
292
+ const fm = '---\ntitle: x\n---\n\nbody\n'
293
+ const p = path.join(tmp, 'frontmatter.md')
294
+ const res = json(run(['basetools', 'edit', '--file', p, '--create', fm]))
295
+ check(res.ok === true && res.result?.operation === 'create', '值以 `---` 开头也能建文件', JSON.stringify(res).slice(0, 140))
296
+ check(fs.readFileSync(p, 'utf8') === fm, '完整内容逐字节落下(换行与 `--` 都没丢)', JSON.stringify(fs.readFileSync(p, 'utf8')))
297
+ // 值后面还跟着另一个旗标:最容易被误判成“没带值”的形状。
298
+ // 注意**不能**用 `json()` 断这一条:`json()` 取的是 stdout 最后一行,而 `--pretty` 是多行输出
299
+ // (我第一版就是这么写错的——产品是对的,断言在量错东西)。这里直接看退出码与磁盘。
300
+ const p2 = path.join(tmp, 'frontmatter2.md')
301
+ const withFlag = run(['basetools', 'edit', '--file', p2, '--create', fm, '--pretty'])
302
+ check(withFlag.status === 0, `值后面再跟一个旗标仍然正确(exit=${withFlag.status})`, String(withFlag.stderr).slice(0, 90))
303
+ check(fs.readFileSync(p2, 'utf8') === fm, '第二种形状下内容也完整')
304
+ // `@文件` 形式:长内容绕开命令行引号(推荐写法,帮助文本里也写了)
305
+ const bodyFile = path.join(tmp, 'body.txt')
306
+ fs.writeFileSync(bodyFile, fm)
307
+ const p3 = path.join(tmp, 'fromfile.md')
308
+ check(json(run(['basetools', 'edit', '--file', p3, '--create', `@${bodyFile}`])).ok === true, '`--create @文件` 可用')
309
+ check(fs.readFileSync(p3, 'utf8') === fm, '`@文件` 形式下内容也完整')
310
+ }
311
+
312
+ // ── E3 `--hunks-file` 的前导 BOM ─────────────────────────────────────────
313
+ // PowerShell 5.1 的 `Set-Content -Encoding UTF8` 默认写 BOM ⇒ `Unexpected token '\uFEFF'`。
314
+ // 那是**工具能自己处理**的事(剥掉就好),没有理由转嫁给看不见那个字符的用户。
315
+ say('E3 --hunks-file:前导 BOM 不让它失败')
316
+ {
317
+ const f = write('bomhunks.md', utf8('alpha\nbravo\n'))
318
+ json(run(['basetools', 'read', '--file', f.path, '--mode', 'full']))
319
+ const hunksPath = path.join(tmp, 'hunks-bom.json')
320
+ fs.writeFileSync(hunksPath, Buffer.concat([BOM, utf8('[{"lines":[2,2],"replace":"BRAVO"}]')]))
321
+ const res = json(run(['basetools', 'edit', '--file', f.path, '--hunks-file', hunksPath]))
322
+ check(res.ok === true, '带 BOM 的 hunks 文件照样能编辑', JSON.stringify(res).slice(0, 140))
323
+ check(fs.readFileSync(f.path, 'utf8') === 'alpha\nBRAVO\n', '落盘正确', JSON.stringify(fs.readFileSync(f.path, 'utf8')))
324
+ // 真坏 JSON 时要报**实际来源**(旧版一律报 `--hunks`,把排查引到错方向)
325
+ const badPath = path.join(tmp, 'hunks-bad.json')
326
+ fs.writeFileSync(badPath, '{ not json')
327
+ const bad = json(run(['basetools', 'edit', '--file', f.path, '--hunks-file', badPath]))
328
+ check(bad.ok === false && String(bad.error).includes('--hunks-file'), '坏 JSON 报的是实际来源(--hunks-file)', String(bad.error).slice(0, 110))
329
+ }
330
+
331
+ // ── E2 平铺旗标:一个 hunk 不必先包成 JSON 数组 ─────────────────────────
332
+ // 与 DSH 壳那边的容错同一个形状(那边实测 27 次失败是 `{file_path,start_line,end_line,replace}`)。
333
+ // 容错规则只住一份(`locator-lab/args.mjs` 的 `flatHunk`),这里验的是**入口真的接上了**。
334
+ say('E2 平铺旗标:edit --lines / --replace')
335
+ {
336
+ const f = write('flat.md', utf8('alpha\nbravo\ncharlie\n'))
337
+ // 先 read 建立观测(区域新鲜度闸门依赖它);本套件没有 readFull 助手,read 也要走 run。
338
+ const seen = json(run(['basetools', 'read', '--file', f.path, '--mode', 'full']))
339
+ check(seen.ok === true, 'E2 先 read 建立观测', JSON.stringify(seen).slice(0, 120))
340
+ const flat = json(run(['basetools', 'edit', '--file', f.path, '--lines', '3', '--replace', 'CHARLIE', '--expect', 'charlie']))
341
+ check(flat.ok === true, '平铺旗标可以改一行', JSON.stringify(flat).slice(0, 160))
342
+ check(fs.readFileSync(f.path, 'utf8') === 'alpha\nbravo\nCHARLIE\n', '落盘正确', JSON.stringify(fs.readFileSync(f.path, 'utf8')))
343
+ // 半成品(只有 --replace、没有地址)必须继续报“需要 hunks”,不得被当成一个缺地址的 hunk
344
+ const half = json(run(['basetools', 'edit', '--file', f.path, '--replace', 'X']))
345
+ check(half.ok === false && half.code === 'USAGE', '只有 --replace ⇒ USAGE(不给假地址)', `${half.code}: ${String(half.error).slice(0, 70)}`)
346
+ // 两种形状同时给:`--hunks` 优先(显式数组是更强的意图表达)
347
+ json(run(['basetools', 'read', '--file', f.path, '--mode', 'full']))
348
+ const both = json(run(['basetools', 'edit', '--file', f.path, '--lines', '1', '--replace', 'ZZ', '--hunks', '[{"lines":[2,2],"replace":"BRAVO"}]']))
349
+ check(both.ok === true && both.result?.applied?.[0]?.line === 2, '--hunks 与旗标同时给 ⇒ 以 --hunks 为准', JSON.stringify(both).slice(0, 140))
350
+ check(fs.readFileSync(f.path, 'utf8') === 'alpha\nBRAVO\nCHARLIE\n', '两种形状同给时只有 --hunks 那一条生效', JSON.stringify(fs.readFileSync(f.path, 'utf8')))
351
+ }
352
+
353
+ // ── F MCP ────────────────────────────────────────────────────────────────
354
+ say('F MCP:initialize / tools/list / tools/call')
355
+ {
356
+ const f = write('mcp.md', utf8('alpha\nbeta\ngamma\ndelta\n'))
357
+ const frames = [
358
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {} } },
359
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
360
+ { jsonrpc: '2.0', id: 2, method: 'tools/list' },
361
+ { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'aura_basetools_read', arguments: { file_path: f.path, mode: 'map' } } },
362
+ {
363
+ jsonrpc: '2.0',
364
+ id: 4,
365
+ method: 'tools/call',
366
+ params: { name: 'aura_basetools_edit', arguments: { file_path: f.path, hunks: [{ lines: [4, 4], replace: 'EDITED' }] } },
367
+ },
368
+ ]
369
+ const res = spawnSync(process.execPath, [mcpBin], { input: `${frames.map((x) => JSON.stringify(x)).join('\n')}\n`, encoding: 'utf8', env })
370
+ const replies = res.stdout
371
+ .trim()
372
+ .split('\n')
373
+ .filter(Boolean)
374
+ .map((l) => JSON.parse(l))
375
+ const byId = new Map(replies.map((r) => [r.id, r]))
376
+ check(byId.get(1)?.result?.protocolVersion === '2025-06-18', 'initialize 回显协议版本', JSON.stringify(byId.get(1)).slice(0, 120))
377
+ check(byId.get(1)?.result?.serverInfo?.name === 'aura-basetools', 'serverInfo.name = aura-basetools')
378
+ // 版本号曾经写死在 bin 里(包到 0.4.0,服务对外还报 0.2.1)—— 这里钉住它跟包清单一致
379
+ const manifest = (() => {
380
+ try {
381
+ return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
382
+ } catch {
383
+ return null
384
+ }
385
+ })()
386
+ if (manifest !== null) check(byId.get(1)?.result?.serverInfo?.version === manifest.version, `serverInfo.version 跟包一致(${manifest.version})`, String(byId.get(1)?.result?.serverInfo?.version))
387
+ const tools = byId.get(2)?.result?.tools ?? []
388
+ check(tools.length === 5 && tools.some((t) => t.name === 'aura_basetools_read') && tools.some((t) => t.name === 'aura_basetools_edit') && tools.some((t) => t.name === 'aura_basetools_write') && tools.some((t) => t.name === 'aura_basetools_undo') && tools.some((t) => t.name === 'aura_basetools_transactions'), `tools/list 五个工具(${tools.map((t) => t.name).join(',')}`)
389
+ const readEnv = JSON.parse(byId.get(3)?.result?.content?.[0]?.text ?? '{}')
390
+ check(readEnv.ok === true && Array.isArray(readEnv.symbols ?? []), 'aura_basetools_read 经 MCP 可用', JSON.stringify(readEnv).slice(0, 120))
391
+ const editEnv = JSON.parse(byId.get(4)?.result?.content?.[0]?.text ?? '{}')
392
+ check(editEnv.ok === true, 'aura_basetools_edit 经 MCP 可用', JSON.stringify(editEnv).slice(0, 140))
393
+ check(fs.readFileSync(f.path, 'utf8').includes('EDITED'), 'MCP 路径真的落盘了')
394
+ check(replies.every((r) => r.jsonrpc === '2.0'), '每条回包都是 JSON-RPC 2.0')
395
+ }
396
+
397
+ // ── G MCP:坏参数必须回包 ──────────────────────────────────────────────
398
+ // 不回包 = 客户端永久挂起(不是"稳")。实测过一次:参数名写成 file,服务只写 stderr,
399
+ // 客户端一直等到自己的超时。这里把六种坏输入全钉住,并要求服务之后仍然活着。
400
+ say('G MCP:坏参数 / 未知工具 / 服务存活')
401
+ {
402
+ const f = write('mcp-bad.md', utf8('alpha\nbeta\n'))
403
+ const frames = [
404
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} } },
405
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
406
+ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'aura_basetools_read', arguments: { file: f.path, mode: 'map' } } },
407
+ { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'aura_basetools_read', arguments: { file_path: f.path, mode: 'outline' } } },
408
+ { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'aura_basetools_read', arguments: { file_path: path.join(tmp, 'nope.md'), mode: 'map' } } },
409
+ { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'aura_basetools_edit', arguments: { file_path: f.path, hunks: 'nope' } } },
410
+ { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'aura_nope', arguments: {} } },
411
+ { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'aura_basetools_read', arguments: { file_path: f.path, mode: 'map' } } },
412
+ ]
413
+ const res = spawnSync(process.execPath, [mcpBin], { input: `${frames.map((x) => JSON.stringify(x)).join('\n')}\n`, encoding: 'utf8', env })
414
+ const replies = res.stdout.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
415
+ const byId = new Map(replies.map((r) => [r.id, r]))
416
+ const envOf = (id) => JSON.parse(byId.get(id)?.result?.content?.[0]?.text ?? '{}')
417
+ check(replies.length === 7, `7 条请求全部有回包(收到 ${replies.length})`)
418
+ check(envOf(2).code === 'ARGS_UNKNOWN_KEY', `参数名写错 → ARGS_UNKNOWN_KEY(${envOf(2).code})`)
419
+ check(envOf(3).code === 'READ_BAD_MODE', `mode 写错 → READ_BAD_MODE(${envOf(3).code})`)
420
+ check(envOf(4).code === 'FS_NOT_FOUND', `文件不存在 → FS_NOT_FOUND(${envOf(4).code})`)
421
+ check(envOf(5).code === 'BAD_ARGS', `hunks 不是数组 → BAD_ARGS(${envOf(5).code})`)
422
+ check(envOf(6).code === 'UNKNOWN_TOOL', `未知工具 → UNKNOWN_TOOL(${envOf(6).code})`)
423
+ check(envOf(7).ok === true, '连着 5 次坏参数之后,正常调用仍然可用(服务没死)')
424
+ check(res.stderr.trim() === '', '没靠 stderr 兑底(不用抛出当回包)', res.stderr.trim().slice(0, 80))
425
+ }
426
+
427
+ // ── H MCP:宽容的参数形状 ──────────────────────────────────────────────
428
+ // 模型把行区间写坏是常态(`"lines": 412, 500` 甚至不是合法 JSON,那种情况宿主就拒了)。
429
+ // 只要参数对象到了,能容的都该容;MCP 客户端把 arguments 当**字符串**传也要能用。
430
+ say('H MCP:参数形状宽容度')
431
+ {
432
+ const f = write('mcp-shapes.md', utf8('l1\nl2\nl3\nl4\n'))
433
+ const call = (id, args) => ({ jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'aura_basetools_read', arguments: args } })
434
+ const frames = [
435
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} } },
436
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
437
+ call(2, { file_path: f.path, start_line: 1, end_line: 2 }),
438
+ call(3, { file_path: f.path, lines: '2,3' }),
439
+ call(4, { file_path: f.path, lines: 2 }),
440
+ call(5, { file_path: f.path, lines: { start: 1, end: 2 } }),
441
+ // arguments 整体是 JSON 字符串(实测有客户端这么传)
442
+ { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'aura_basetools_read', arguments: JSON.stringify({ file_path: f.path, start_line: 1 }) } },
443
+ // arguments 是解不开的字符串:必须回结构化错误,不能挂死
444
+ { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'aura_basetools_read', arguments: '不是 JSON' } },
445
+ ]
446
+ const res = spawnSync(process.execPath, [mcpBin], { input: `${frames.map((x) => JSON.stringify(x)).join('\n')}\n`, encoding: 'utf8', env })
447
+ const replies = res.stdout.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
448
+ const byId = new Map(replies.map((r) => [r.id, r]))
449
+ const envOf = (id) => JSON.parse(byId.get(id)?.result?.content?.[0]?.text ?? '{}')
450
+ check(replies.length === 7, `7 条全部有回包(收到 ${replies.length})`)
451
+ check(envOf(2).ok === true && envOf(2).mode === 'lines', `start_line/end_line 可用(mode=${envOf(2).mode})`)
452
+ check(envOf(3).ok === true && envOf(3).mode === 'lines', `lines="2,3" 可用(mode=${envOf(3).mode})`)
453
+ check(envOf(4).ok === true && envOf(4).mode === 'lines', `lines=2 单行可用(mode=${envOf(4).mode})`)
454
+ check(envOf(5).ok === true && envOf(5).mode === 'lines', `lines={{start,end}} 可用(mode=${envOf(5).mode})`)
455
+ check(envOf(6).ok === true && envOf(6).mode === 'lines', 'arguments 是 JSON 字符串时也能用(自动解析)')
456
+ check(envOf(7).code === 'BAD_ARGS', `arguments 解不开 → BAD_ARGS(${envOf(7).code})`)
457
+ }
458
+
459
+ // ── I MCP:hunk 的行区间也能用几种写法 ─────────────────────────────────
460
+ say('I MCP:hunk 行区间的宽容度')
461
+ {
462
+ const f = write('mcp-hunks.md', utf8('l1\nl2\nl3\nl4\n'))
463
+ const edit = (id, hunk) => ({
464
+ jsonrpc: '2.0',
465
+ id,
466
+ method: 'tools/call',
467
+ params: { name: 'aura_basetools_edit', arguments: { file_path: f.path, hunks: [hunk] } },
468
+ })
469
+ const frames = [
470
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} } },
471
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
472
+ edit(2, { start_line: 1, end_line: 1, replace: 'A' }),
473
+ edit(3, { lines: '2,2', replace: 'B' }),
474
+ edit(4, { lines: [3, 3], replace: 'C' }),
475
+ edit(5, { lines: 4, replace: 'D' }),
476
+ ]
477
+ const res = spawnSync(process.execPath, [mcpBin], { input: `${frames.map((x) => JSON.stringify(x)).join('\n')}\n`, encoding: 'utf8', env })
478
+ const replies = res.stdout.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
479
+ const byId = new Map(replies.map((r) => [r.id, r]))
480
+ const envOf = (id) => JSON.parse(byId.get(id)?.result?.content?.[0]?.text ?? '{}')
481
+ check(envOf(2).ok === true, `start_line/end_line 定位可用(${JSON.stringify(envOf(2)).slice(0, 60)})`)
482
+ check(envOf(3).ok === true, `lines="2,2" 定位可用(${JSON.stringify(envOf(3)).slice(0, 60)})`)
483
+ check(envOf(4).ok === true, `lines=[3,3] 定位可用(${JSON.stringify(envOf(4)).slice(0, 60)})`)
484
+ check(envOf(5).ok === true, `lines=4 单行定位可用(${JSON.stringify(envOf(5)).slice(0, 60)})`)
485
+ check(fs.readFileSync(f.path, 'utf8') === 'A\nB\nC\nD\n', `四种写法都真的落盘了(${JSON.stringify(fs.readFileSync(f.path, 'utf8'))})`)
486
+ }
487
+
488
+ // ── J 两种布局:src 一份引擎、摊平(包内)一份引擎 ────────────────────────
489
+ /**
490
+ * 为什么这一节是**行为验收**而不是形状断言:本套件从前的 23 条全红,就是从"引擎找不到"来的——
491
+ * 入口写死 `'../locator-lab/…'`,只在摊平布局下成立,src 布局下 CLI 一启动就 ERR_MODULE_NOT_FOUND。
492
+ * 所以这里两个布局都要**真跑读与真跑改**(只看 import 成功会漏掉"能加载但用不了"),
493
+ * 并且额外钉住两种失败:换了布局就静默不报、以及摊平时把引擎内容换掉。
494
+ */
495
+ say('J 两种布局:src 面与摊平(包内)面都解析得到引擎,且都真能读写')
496
+ {
497
+ const srcBin = fs.realpathSync(path.join(here, '..', 'bin', 'aura.mjs'))
498
+ const src = layoutRoundTrip(srcBin, 'srclayout')
499
+ check(src.read.exit === 0 && src.read.ok === true, `src 布局 CLI 能启动并读出目录(exit=${src.read.exit})`, JSON.stringify(src.read).slice(0, 160))
500
+ check(src.read.symbols >= 1, `src 布局读回执带符号(symbols=${src.read.symbols})`, JSON.stringify(src.read).slice(0, 160))
501
+ check(src.edit.exit === 0 && src.edit.ok === true, `src 布局 CLI 能改(exit=${src.edit.exit})`, JSON.stringify(src.edit).slice(0, 160))
502
+ check(src.text.includes('const a = 42'), 'src 布局的编辑真的落盘', JSON.stringify(src.text))
503
+
504
+ const pkgRoot = flattenLayout()
505
+ const flatBin = path.join(pkgRoot, 'bin', 'aura.mjs')
506
+ check(fs.existsSync(path.join(pkgRoot, 'locator-lab', 'read-core.mjs')), '摊平布局摆出了 locator-lab/read-core.mjs')
507
+ check(fs.existsSync(path.join(pkgRoot, 'package.json')) === false, '摊平布局里没有 package.json(解析不靠包清单)')
508
+ const flat = layoutRoundTrip(flatBin, 'flatlayout')
509
+ check(flat.read.exit === 0 && flat.read.ok === true, `摊平布局 CLI 能启动并读出目录(exit=${flat.read.exit})`, JSON.stringify(flat.read).slice(0, 160))
510
+ check(flat.edit.exit === 0 && flat.edit.ok === true, `摊平布局 CLI 能改(exit=${flat.edit.exit})`, JSON.stringify(flat.edit).slice(0, 160))
511
+ check(flat.text.includes('const a = 42'), '摊平布局的编辑真的落盘', JSON.stringify(flat.text))
512
+ // 摊平是**搬位置**,不是改内容:两边引擎逐字节一样时,两边的判据才是同一个引擎给的
513
+ const engineSame = ['read-core.mjs', 'args.mjs', 'write-path.mjs'].every((n) =>
514
+ fs.readFileSync(path.join(engineDir, n)).equals(fs.readFileSync(path.join(pkgRoot, 'locator-lab', n))),
515
+ )
516
+ check(engineSame, '摊平布局的引擎与 src 布局逐字节一致(摊平只搬位置)')
517
+ // 打包器真产出的那份(跑过 `node build/make-bundle.mjs` 时)也必须是同一个答案
518
+ const bundled = path.join(here, '..', '..', '..', 'dist', 'aura-basetools', 'bin', 'aura.mjs')
519
+ if (fs.existsSync(bundled)) {
520
+ const dist = layoutRoundTrip(bundled, 'distlayout')
521
+ check(dist.read.exit === 0 && dist.read.ok === true && dist.edit.exit === 0 && dist.edit.ok === true, '打包器真产出的 dist/aura-basetools/ 也解析得到(读写各一次)', JSON.stringify({ read: dist.read.exit, edit: dist.edit.exit }))
522
+ }
523
+
524
+ // 两种布局都不中时:不静默兜底。把引擎**移开**(不是删),跑一次,再移回来。
525
+ const moved = path.join(tmp, 'engine-moved-away')
526
+ fs.renameSync(path.join(pkgRoot, 'locator-lab'), moved)
527
+ const orphan = spawnSync(process.execPath, [flatBin, '--version'], { encoding: 'utf8', env })
528
+ check(orphan.status !== 0, `两个候选都不中 ⇒ 非零退出,不静默兜底(exit=${orphan.status})`)
529
+ check(/找不到引擎/.test(String(orphan.stderr)), '报错说明判据与试过的两个候选', String(orphan.stderr).split('\n').slice(0, 3).join(' / ').slice(0, 120))
530
+ fs.renameSync(moved, path.join(pkgRoot, 'locator-lab'))
531
+ check(fs.existsSync(path.join(pkgRoot, 'locator-lab', 'read-core.mjs')), '引擎已移回原位(不污染后续断言)')
532
+ }
533
+
534
+ // ══════════════════════════════════════════════════════════════════════════
535
+ // MT 段:独立面(CLI + MCP)的 `targets`(fs-contract-v2 多目标 / 施工单 C1)
536
+ // 段名一律带 `MT-` 前缀:本文件既有的 A–J 是别的验收面,重名会让日志认不出是哪一段。
537
+ // ══════════════════════════════════════════════════════════════════════════
538
+
539
+ /** MT 段共用的场景内容(固定 ⇒ 回执确定;`beta` 在全文件里唯一 ⇒ line_cue 可用)。 */
540
+ const MT_SOURCE = 'function alpha() {\n const a = 1\n return a\n}\n\nfunction beta() {\n const b = 2\n return b\n}\n'
541
+
542
+ /** 一个干净的场景目录:**自带**缓存目录(观测是跨进程的,共用一份缓存会互相顶掉)。 */
543
+ const mtScene = (label) => {
544
+ const dir = fs.mkdtempSync(path.join(tmp, `mt-${label}-`))
545
+ const sceneEnv = { ...process.env, AURA_CACHE_DIR: path.join(dir, 'cache') }
546
+ return {
547
+ dir,
548
+ write: (name, text) => fs.writeFileSync(path.join(dir, name), Buffer.from(text, 'utf8')),
549
+ run: (args) => spawnSync(process.execPath, [bin, ...args], { cwd: dir, encoding: 'utf8', env: sceneEnv }),
550
+ mcp: (frames) =>
551
+ spawnSync(process.execPath, [mcpBin], {
552
+ cwd: dir,
553
+ input: `${frames.map((f) => JSON.stringify(f)).join('\n')}\n`,
554
+ encoding: 'utf8',
555
+ env: sceneEnv,
556
+ }),
557
+ }
558
+ }
559
+
560
+ /** 首个差异(逐字节判据的诊断:只说“不一样”等于没说)。 */
561
+ const byteDiff = (got, want) => {
562
+ const a = Buffer.from(String(got), 'utf8')
563
+ const b = Buffer.from(String(want), 'utf8')
564
+ if (a.equals(b)) return null
565
+ let i = 0
566
+ while (i < a.length && i < b.length && a[i] === b[i]) i += 1
567
+ return `第 ${i} 字节起不同:得到 ${JSON.stringify(a.subarray(i, i + 32).toString('utf8'))},期望 ${JSON.stringify(b.subarray(i, i + 32).toString('utf8'))}`
568
+ }
569
+
570
+ /**
571
+ * 黄金集的**归一**:只抹掉“这台机器把场景放在哪个目录”,其余(字段顺序、空白、每一个字符)逐字节比对。
572
+ * · 场景目录 ⇒ `<DIR>`(路径在 JSON 里是转义过的,所以先换转义形态);
573
+ * · 句柄 `h<4 hex><8 hex>:a-b` 的**前 4 位**是账本键(绝对路径)的哈希 ⇒ `h<ID>`;
574
+ * 后 8 位(内容 + 区间)照旧参与比对。
575
+ */
576
+ const normalizeGolden = (text, dir) => {
577
+ // macOS 的 /var/... 常是 /private/var/... 的符号链接;CLI 写出的是 realpath,
578
+ // 场景目录却是 mkdtemp 的原始写法——两种都必须收敛成 <DIR>。
579
+ const variants = [dir]
580
+ try { variants.push(fs.realpathSync(dir)) } catch { /* 目录已消失时只保留原写法 */ }
581
+ // 长路径先替换:macOS 的 /var/... 是 /private/var/... 的别名;
582
+ // 先换短路径会把 /private 留下来(实测得到 "/private<DIR>")。
583
+ const unique = [...new Set(variants)].sort((a, b) => b.length - a.length)
584
+ let out = String(text)
585
+ for (const variant of unique) {
586
+ const escaped = JSON.stringify(variant).slice(1, -1)
587
+ const posix = variant.split(path.sep).join('/')
588
+ out = out.split(escaped).join('<DIR>').split(posix).join('<DIR>')
589
+ }
590
+ return out
591
+ .replace(/(<DIR>)[\\/]+/g, '$1/')
592
+ .replace(/h[0-9a-f]{4}(?=[0-9a-f]{8}:)/g, 'h<ID>')
593
+ }
594
+
595
+ /**
596
+ * ── MT-J 的用例定义:12 个单目标 CLI 调用(读 7 模式各一 + 写 5 种) ──────────────
597
+ *
598
+ * `prepare` 是“这次调用之前必须发生的那件事”:写之前要先读一次(区域新鲜度闸门的基准),
599
+ * `changed` 之前要先拿到令牌。**采集与比对共用这一份定义** ⇒ 黄金集不可能与用例漂开。
600
+ */
601
+ const CLI_GOLDEN_CALLS = [
602
+ { name: 'read-map', file: 'scene.js', args: () => ['basetools', 'read', '--file', 'scene.js', '--mode', 'map'] },
603
+ { name: 'read-digest', file: 'scene.js', args: () => ['basetools', 'read', '--file', 'scene.js', '--mode', 'digest'] },
604
+ { name: 'read-full', file: 'scene.js', args: () => ['basetools', 'read', '--file', 'scene.js', '--mode', 'full'] },
605
+ { name: 'read-around', file: 'scene.js', args: () => ['basetools', 'read', '--file', 'scene.js', '--mode', 'around', '--around', 'beta'] },
606
+ { name: 'read-lines', file: 'scene.js', args: () => ['basetools', 'read', '--file', 'scene.js', '--mode', 'lines', '--lines', '1,3'] },
607
+ { name: 'read-search', file: 'scene.js', args: () => ['basetools', 'read', '--file', 'scene.js', '--mode', 'search', '--search', 'return'] },
608
+ {
609
+ name: 'read-changed',
610
+ file: 'scene.js',
611
+ // 令牌是内容指纹(`<hash>:<字节数>`):现场读一次取回来,不在黄金集里钉一个手抄的令牌。
612
+ prepare: (spawn) => JSON.parse(spawn(['basetools', 'read', '--file', 'scene.js', '--mode', 'full']).stdout).version,
613
+ args: (token) => ['basetools', 'read', '--file', 'scene.js', '--mode', 'changed', '--since', String(token)],
614
+ },
615
+ {
616
+ name: 'edit-target',
617
+ file: 'w.js',
618
+ prepare: (spawn) => void spawn(['basetools', 'read', '--file', 'w.js', '--mode', 'full']),
619
+ args: () => ['basetools', 'edit', '--file', 'w.js', '--hunks', JSON.stringify([{ target: 'alpha', replace: 'function alpha() {\n return 42\n}' }])],
620
+ },
621
+ {
622
+ name: 'edit-lines',
623
+ file: 'w.js',
624
+ prepare: (spawn) => void spawn(['basetools', 'read', '--file', 'w.js', '--mode', 'full']),
625
+ args: () => ['basetools', 'edit', '--file', 'w.js', '--hunks', JSON.stringify([{ lines: [2, 2], replace: ' const a = 42' }])],
626
+ },
627
+ {
628
+ name: 'edit-line-cue',
629
+ file: 'w.js',
630
+ prepare: (spawn) => void spawn(['basetools', 'read', '--file', 'w.js', '--mode', 'full']),
631
+ args: () => ['basetools', 'edit', '--file', 'w.js', '--hunks', JSON.stringify([{ line_cue: 'beta', replace: 'function beta() { /* cue */' }])],
632
+ },
633
+ {
634
+ name: 'edit-anchor',
635
+ file: 'w.js',
636
+ prepare: (spawn) => void spawn(['basetools', 'read', '--file', 'w.js', '--mode', 'full']),
637
+ args: () => ['basetools', 'edit', '--file', 'w.js', '--hunks', JSON.stringify([{ anchor: ' const b = 2', replace: ' const b = 22' }])],
638
+ },
639
+ // `create` 没有“读一次”这回事:文件本来就还不存在。
640
+ { name: 'edit-create', file: null, args: () => ['basetools', 'edit', '--file', 'made.md', '--create', 'hello\n'] },
641
+ ]
642
+
643
+ /** 跑一次黄金集用例(干净的目录 + 干净的缓存 ⇒ 同一份输入恒定给同一份回执)。 */
644
+ const runGoldenCall = (call) => {
645
+ const dir = fs.mkdtempSync(path.join(tmp, `cli-golden-${call.name}-`))
646
+ const cliEnv = { ...process.env, AURA_CACHE_DIR: path.join(dir, 'cache') }
647
+ const spawn = (args) => spawnSync(process.execPath, [bin, ...args], { cwd: dir, encoding: 'utf8', env: cliEnv })
648
+ if (call.file !== null) fs.writeFileSync(path.join(dir, call.file), Buffer.from(MT_SOURCE, 'utf8'))
649
+ const ctx = call.prepare === undefined ? undefined : call.prepare(spawn)
650
+ const res = spawn(call.args(ctx))
651
+ return { exit: res.status, stdout: normalizeGolden(res.stdout, dir) }
652
+ }
653
+
654
+ /**
655
+ * 单目标 CLI 回执的**黄金集**(本单红线:`--file` 那条路一个字节都不许变)。
656
+ *
657
+ * 它是**脚本生成**的,不许手写 —— 采集走上面那份用例定义(因此采集与比对不可能漂):
658
+ *
659
+ * node aura-basetools/src/aura-standalone/test/standalone-test.mjs --capture-cli-goldens
660
+ *
661
+ * 采完把打印出来的那份 JSON 整段贴回来(本套件在**包内 test 目录**的那个布局里也会跑,
662
+ * 所以黄金集内联在本文件里,而不是另建一个只有源树才有的文件)。
663
+ */
664
+ const CLI_GOLDEN = {
665
+ "read-map": {
666
+ "exit": 0,
667
+ "stdout": "{\"ok\":true,\"mode\":\"map\",\"totalLines\":10,\"contentBudget\":7492,\"version\":\"531e7692:92\",\"symbols\":4,\"shown\":4,\"byKind\":{\"function\":2,\"variable\":2},\"text\":\"10 lines · 4 symbols (function:2 variable:2)\\nexported (0):\\ninternal:\\n function alpha 1-4\\n function beta 6-9\",\"omitted\":\"2 nested locals omitted (inside a function — reach them with mode=lines, not target=)\",\"next\":\"mode=around around=<name> · mode=lines lines=[a,b] · mode=digest for signatures\",\"metrics\":{\"mode\":\"map\",\"lines\":10,\"chars\":154,\"symbols\":4,\"truncated\":false}}\n"
668
+ },
669
+ "read-digest": {
670
+ "exit": 0,
671
+ "stdout": "{\"ok\":true,\"mode\":\"digest\",\"totalLines\":10,\"contentBudget\":7492,\"version\":\"531e7692:92\",\"symbols\":4,\"exported\":[],\"text\":\"10 lines · 4 symbols · 0 control-flow\\nexported: (none)\\nimports: (none)\\nexternal calls: (none)\\n--- signatures ---\\n1-4 ( 4 lines) function alpha()\\n6-9 ( 4 lines) function beta()\",\"next\":\"mode=around around=<name> to see one function\",\"metrics\":{\"mode\":\"digest\",\"lines\":10,\"chars\":177,\"symbols\":4,\"truncated\":false}}\n"
672
+ },
673
+ "read-full": {
674
+ "exit": 0,
675
+ "stdout": "{\"ok\":true,\"mode\":\"full\",\"totalLines\":10,\"contentBudget\":7492,\"version\":\"531e7692:92\",\"center\":1,\"label\":\"full file\",\"relocatable\":[{\"line\":1,\"target\":\"alpha\",\"lines\":[1,4]}],\"shown\":\"1-10\",\"text\":\"1: function alpha() {\\n2: const a = 1\\n3: return a\\n4: }\\n5: \\n6: function beta() {\\n7: const b = 2\\n8: return b\\n9: }\\n10: \",\"metrics\":{\"mode\":\"full\",\"lines\":10,\"chars\":123,\"truncated\":false}}\n"
676
+ },
677
+ "read-around": {
678
+ "exit": 0,
679
+ "stdout": "{\"ok\":true,\"mode\":\"around\",\"totalLines\":10,\"contentBudget\":7492,\"version\":\"531e7692:92\",\"center\":6,\"label\":\"around symbol function:beta (6-9)\",\"relocatable\":[{\"line\":1,\"target\":\"alpha\",\"lines\":[1,4]}],\"shown\":\"1-10\",\"text\":\"1: function alpha() {\\n2: const a = 1\\n3: return a\\n4: }\\n5: \\n6: function beta() {\\n7: const b = 2\\n8: return b\\n9: }\\n10: \",\"metrics\":{\"mode\":\"around\",\"lines\":10,\"chars\":123,\"truncated\":false}}\n"
680
+ },
681
+ "read-lines": {
682
+ "exit": 0,
683
+ "stdout": "{\"ok\":true,\"mode\":\"lines\",\"totalLines\":10,\"contentBudget\":7492,\"version\":\"531e7692:92\",\"center\":1,\"label\":\"requested range\",\"relocatable\":[{\"line\":1,\"target\":\"alpha\",\"lines\":[1,4]}],\"shown\":\"1-3\",\"handle\":\"h<ID>a8d61e88:1-3\",\"text\":\"1: function alpha() { <h<ID>a8d61e88:1-3>\\n2: const a = 1\\n3: return a\",\"metrics\":{\"mode\":\"lines\",\"lines\":10,\"chars\":73,\"truncated\":false}}\n"
684
+ },
685
+ "read-search": {
686
+ "exit": 0,
687
+ "stdout": "{\"ok\":true,\"mode\":\"search\",\"totalLines\":10,\"contentBudget\":7492,\"version\":\"531e7692:92\",\"matches\":2,\"handles\":[{\"line\":3,\"handle\":\"h<ID>82986ddc:3-3\"},{\"line\":8,\"handle\":\"h<ID>85987295:8-8\"}],\"relocatable\":[{\"line\":3,\"target\":\"alpha\",\"lines\":[1,4]},{\"line\":8,\"target\":\"beta\",\"lines\":[6,9]}],\"text\":\"3: return a <h<ID>82986ddc:3-3>\\n8: return b <h<ID>85987295:8-8>\",\"metrics\":{\"mode\":\"search\",\"lines\":10,\"chars\":65,\"truncated\":false}}\n"
688
+ },
689
+ "read-changed": {
690
+ "exit": 0,
691
+ "stdout": "{\"ok\":true,\"mode\":\"changed\",\"totalLines\":10,\"contentBudget\":7492,\"version\":\"531e7692:92\",\"note\":\"no change since that version\",\"text\":\"no change since that version\",\"metrics\":{\"mode\":\"changed\",\"lines\":10,\"chars\":28,\"truncated\":false}}\n"
692
+ },
693
+ "edit-target": {
694
+ "exit": 0,
695
+ "stdout": "{\"ok\":true,\"result\":{\"path\":\"<DIR>/w.js\",\"applied\":[{\"line\":1,\"tier\":\"symbol\",\"byNear\":false,\"actual\":\"function alpha() {\\n const a = 1\\n return a\\n}\",\"replaced\":\"function alpha() {\\n return 42\\n}\",\"afterLine\":1,\"afterEndLine\":3,\"delta\":-1,\"handle\":\"h<ID>370ea564:1-3\"}],\"dryRun\":false,\"write_verify\":{\"level\":\"bytes\",\"bytes\":79}}}\n"
696
+ },
697
+ "edit-lines": {
698
+ "exit": 0,
699
+ "stdout": "{\"ok\":true,\"result\":{\"path\":\"<DIR>/w.js\",\"applied\":[{\"line\":2,\"tier\":\"lines\",\"byNear\":false,\"actual\":\" const a = 1\",\"replaced\":\" const a = 42\",\"afterLine\":2,\"afterEndLine\":2,\"delta\":0,\"handle\":\"h<ID>9edee6fa:2-2\"}],\"dryRun\":false,\"write_verify\":{\"level\":\"bytes\",\"bytes\":93}}}\n"
700
+ },
701
+ "edit-line-cue": {
702
+ "exit": 0,
703
+ "stdout": "{\"ok\":true,\"result\":{\"path\":\"<DIR>/w.js\",\"applied\":[{\"line\":6,\"tier\":\"lineCue\",\"byNear\":false,\"actual\":\"function beta() {\",\"replaced\":\"function beta() { /* cue */\",\"afterLine\":6,\"afterEndLine\":6,\"delta\":0,\"handle\":\"h<ID>dfda3c32:6-6\"}],\"dryRun\":false,\"write_verify\":{\"level\":\"bytes\",\"bytes\":102}}}\n"
704
+ },
705
+ "edit-anchor": {
706
+ "exit": 0,
707
+ "stdout": "{\"ok\":true,\"result\":{\"path\":\"<DIR>/w.js\",\"applied\":[{\"line\":7,\"tier\":\"exact\",\"byNear\":false,\"actual\":\" const b = 2\",\"replaced\":\" const b = 22\",\"afterLine\":7,\"afterEndLine\":7,\"delta\":0,\"handle\":\"h<ID>6f2dcb63:7-7\"}],\"dryRun\":false,\"write_verify\":{\"level\":\"bytes\",\"bytes\":93}}}\n"
708
+ },
709
+ "edit-create": {
710
+ "exit": 0,
711
+ "stdout": "{\"ok\":true,\"result\":{\"path\":\"<DIR>/made.md\",\"operation\":\"create\",\"applied\":[{\"line\":1,\"tier\":\"create\",\"actual\":\"\",\"replaced\":\"hello\\n\"}],\"dryRun\":false,\"write_verify\":{\"level\":\"bytes\",\"bytes\":6}}}\n"
712
+ }
713
+ }
714
+
715
+
716
+ if (process.argv.includes('--capture-cli-goldens')) {
717
+ const captured = {}
718
+ for (const call of CLI_GOLDEN_CALLS) {
719
+ const got = runGoldenCall(call)
720
+ captured[call.name] = { exit: got.exit, stdout: got.stdout }
721
+ say(`· 采到 ${call.name}(exit=${got.exit},${Buffer.byteLength(got.stdout, 'utf8')} 字节)`)
722
+ }
723
+ say('')
724
+ process.stdout.write(`${JSON.stringify(captured, null, 2)}\n`)
725
+ fs.rmSync(tmp, { recursive: true, force: true })
726
+ process.exit(0)
727
+ }
728
+
729
+ // ── MT-J 单目标零回归(本单红线) ───────────────────────────────────────
730
+ say('MT-J 单目标零回归:12 个 CLI 调用的 stdout 与黄金集逐字节相同')
731
+ {
732
+ check(CLI_GOLDEN_CALLS.length === 12, `黄金集覆盖 12 个调用(读 7 + 写 5,实得 ${CLI_GOLDEN_CALLS.length})`)
733
+ check(CLI_GOLDEN !== null && Object.keys(CLI_GOLDEN).length === 12, '黄金集已采集(--capture-cli-goldens)')
734
+ for (const call of CLI_GOLDEN_CALLS) {
735
+ const golden = CLI_GOLDEN === null ? undefined : CLI_GOLDEN[call.name]
736
+ const got = runGoldenCall(call)
737
+ if (golden === undefined) {
738
+ check(false, `MT-J ${call.name}:黄金集里没有这一条`)
739
+ continue
740
+ }
741
+ // 黄金集里 <DIR> 后的分隔符可能是采集平台的写法;CLI 的 path 在两平台不同,
742
+ // 只归一这个位置,不碰正文里的转义。
743
+ const goldenStdout = golden.stdout.replace(/(<DIR>)[\\/]+/g, '$1/')
744
+ const diff = byteDiff(got.stdout, goldenStdout)
745
+ check(got.exit === golden.exit && diff === null, `MT-J ${call.name}:stdout 逐字节相同(exit=${golden.exit})`, diff ?? `exit ${got.exit} ≠ ${golden.exit}`)
746
+ }
747
+ }
748
+
749
+ // ── MT-G 多目标读(CLI) ────────────────────────────────────────────────
750
+ say('MT-G 多目标读:CLI --targets(逐项/顺序/不连坐/汇总行/退出码)')
751
+ {
752
+ const scene = mtScene('g')
753
+ scene.write('a.js', MT_SOURCE)
754
+ scene.write('b.md', 'one\ntwo\nthree\n')
755
+ const res = scene.run([
756
+ 'basetools',
757
+ 'read',
758
+ '--targets',
759
+ JSON.stringify([
760
+ { file_path: 'a.js', mode: 'digest' },
761
+ { file_path: 'b.md', mode: 'lines', lines: [1, 2] },
762
+ ]),
763
+ ])
764
+ const batch = json(res)
765
+ check(res.status === 0 && batch.ok === true, `① 两目标都成 ⇒ ok + exit 0(exit=${res.status})`, JSON.stringify(batch).slice(0, 160))
766
+ check(batch.targets === 2 && Array.isArray(batch.entries) && batch.entries.length === 2, `① entries 长度 2(targets=${batch.targets})`)
767
+ check(
768
+ String(batch.entries?.[0]?.file_path ?? '').endsWith('a.js') && String(batch.entries?.[1]?.file_path ?? '').endsWith('b.md'),
769
+ '① 顺序 = 输入序',
770
+ JSON.stringify(batch.entries?.map((e) => e.file_path)),
771
+ )
772
+ // ① 每项与“单独一次单目标读”的同一字段值相同:拿整个信封逐字节比
773
+ const soloA = json(scene.run(['basetools', 'read', '--file', 'a.js', '--mode', 'digest']))
774
+ const soloB = json(scene.run(['basetools', 'read', '--file', 'b.md', '--mode', 'lines', '--lines', '1,2']))
775
+ const entryA = JSON.stringify(batch.entries?.[0]?.envelope)
776
+ const entryB = JSON.stringify(batch.entries?.[1]?.envelope)
777
+ check(entryA === JSON.stringify(soloA), '① A 项信封与单独读逐字节相同', byteDiff(entryA, JSON.stringify(soloA)) ?? '')
778
+ check(entryB === JSON.stringify(soloB), '① B 项信封与单独读逐字节相同', byteDiff(entryB, JSON.stringify(soloB)) ?? '')
779
+ // ② 一项目标不存在:整批 ok=false,另一项照常可读(不连坐)
780
+ const partial = json(
781
+ scene.run([
782
+ 'basetools',
783
+ 'read',
784
+ '--targets',
785
+ JSON.stringify([
786
+ { file_path: 'a.js', mode: 'digest' },
787
+ { file_path: 'nope.md', mode: 'full' },
788
+ ]),
789
+ ]),
790
+ )
791
+ check(partial.ok === false, '② 一项目标不存在 ⇒ 整批 ok=false', JSON.stringify(partial).slice(0, 140))
792
+ check(partial.entries?.[0]?.ok === true && String(partial.entries?.[0]?.envelope?.text ?? '').length > 0, '② 另一项照常可读(不连坐)')
793
+ check(partial.entries?.[1]?.ok === false && partial.entries?.[1]?.code === 'FS_NOT_FOUND', `② 缺的那项报 FS_NOT_FOUND(${partial.entries?.[1]?.code})`)
794
+ check(partial.summary === 'targets: 2 | ok 1 | not_found 1', `③ 汇总行 ${JSON.stringify(partial.summary)}`)
795
+ check(partial.counts?.ok === 1 && partial.counts?.failed === 1, `③ counts ok=1 failed=1(${JSON.stringify(partial.counts)})`)
796
+ const missingOnly = scene.run(['basetools', 'read', '--targets', JSON.stringify([{ file_path: 'nope.md', mode: 'full' }])])
797
+ check(missingOnly.status === 2 && json(missingOnly).ok === false, `④ 逐项失败 ⇒ 走结果通道、退出码 2(实测 ${missingOnly.status})`)
798
+ }
799
+
800
+ // ── MT-H 多目标写(CLI) ────────────────────────────────────────────────
801
+ say('MT-H 多目标写:CLI --targets(真落盘/零写入/同文件合并/dry-run)')
802
+ {
803
+ // ① 两个文件各一 hunk
804
+ const scene = mtScene('h')
805
+ scene.write('c.md', 'one\ntwo\n')
806
+ scene.write('d.md', 'alpha\nbravo\n')
807
+ scene.run(['basetools', 'read', '--file', 'c.md', '--mode', 'full'])
808
+ scene.run(['basetools', 'read', '--file', 'd.md', '--mode', 'full'])
809
+ const beforeC = fs.readFileSync(path.join(scene.dir, 'c.md'))
810
+ const beforeD = fs.readFileSync(path.join(scene.dir, 'd.md'))
811
+ const res = scene.run([
812
+ 'basetools',
813
+ 'edit',
814
+ '--targets',
815
+ JSON.stringify([
816
+ { file_path: 'c.md', hunks: [{ lines: [1, 1], replace: 'ONE' }] },
817
+ { file_path: 'd.md', hunks: [{ lines: [2, 2], replace: 'BRAVO' }] },
818
+ ]),
819
+ ])
820
+ const batch = json(res)
821
+ check(res.status === 0 && batch.ok === true, `① 两个文件各一 hunk ⇒ ok + exit 0(exit=${res.status})`, JSON.stringify(batch).slice(0, 200))
822
+ check(batch.edits?.length === 2 && batch.edits.every((e) => e.landed === true), `① 逐项 landed(${batch.edits?.map((e) => `${e.outcome}/${e.landed}`).join(', ')})`)
823
+ check(fs.readFileSync(path.join(scene.dir, 'c.md'), 'utf8') === 'ONE\ntwo\n', '① c.md 真落盘', JSON.stringify(fs.readFileSync(path.join(scene.dir, 'c.md'), 'utf8')))
824
+ check(fs.readFileSync(path.join(scene.dir, 'd.md'), 'utf8') === 'alpha\nBRAVO\n', '① d.md 真落盘', JSON.stringify(fs.readFileSync(path.join(scene.dir, 'd.md'), 'utf8')))
825
+ check(!fs.readFileSync(path.join(scene.dir, 'c.md')).equals(beforeC) && !fs.readFileSync(path.join(scene.dir, 'd.md')).equals(beforeD), '① 两个文件的字节都变了(都写过)')
826
+ // ① 逐项回执与单目标同形:字段集合相同(同一个渲染,不是另写一套)
827
+ const soloScene = mtScene('h-solo')
828
+ soloScene.write('solo.md', 'one\ntwo\n')
829
+ soloScene.run(['basetools', 'read', '--file', 'solo.md', '--mode', 'full'])
830
+ const solo = json(soloScene.run(['basetools', 'edit', '--file', 'solo.md', '--hunks', JSON.stringify([{ lines: [1, 1], replace: 'ONE' }])]))
831
+ const keysOf = (o) => Object.keys(o ?? {}).sort().join(',')
832
+ check(keysOf(batch.edits?.[0]?.receipt) === keysOf(solo.result), '① 逐项回执与单目标同形(字段集合相同)', `${keysOf(batch.edits?.[0]?.receipt)} vs ${keysOf(solo.result)}`)
833
+
834
+ // ② 一项规划失败 ⇒ 零写入
835
+ const zero = mtScene('h-zero')
836
+ zero.write('c.md', 'one\ntwo\n')
837
+ zero.write('d.md', 'alpha\nbravo\n')
838
+ zero.run(['basetools', 'read', '--file', 'c.md', '--mode', 'full'])
839
+ zero.run(['basetools', 'read', '--file', 'd.md', '--mode', 'full'])
840
+ const keepC = fs.readFileSync(path.join(zero.dir, 'c.md'))
841
+ const keepD = fs.readFileSync(path.join(zero.dir, 'd.md'))
842
+ const failed = zero.run([
843
+ 'basetools',
844
+ 'edit',
845
+ '--targets',
846
+ JSON.stringify([
847
+ { file_path: 'c.md', hunks: [{ lines: [1, 1], replace: 'ONE' }] },
848
+ { file_path: 'd.md', hunks: [{ lines: [99, 99], replace: 'NOPE' }] },
849
+ ]),
850
+ ])
851
+ const failedEnv = json(failed)
852
+ check(failedEnv.ok === false && failed.status === 2, `② 一项规划失败 ⇒ ok=false + exit 2(exit=${failed.status})`, JSON.stringify(failedEnv).slice(0, 160))
853
+ check(
854
+ fs.readFileSync(path.join(zero.dir, 'c.md')).equals(keepC) && fs.readFileSync(path.join(zero.dir, 'd.md')).equals(keepD),
855
+ '② 零写入:两处磁盘逐字节不变',
856
+ )
857
+ check(
858
+ failedEnv.edits?.[1]?.outcome === 'failed' && typeof failedEnv.edits?.[1]?.error?.code === 'string',
859
+ `② 回执说清哪一项什么码(index=${failedEnv.edits?.[1]?.index},code=${failedEnv.edits?.[1]?.error?.code})`,
860
+ )
861
+ check(/no files were changed/.test(String(failedEnv.summary)), `② 汇总行说清零写入(${failedEnv.summary})`)
862
+ check(failedEnv.aborted === 'plan', `② aborted=plan(${failedEnv.aborted})`)
863
+
864
+ // ③ 同文件两个目标 ⇒ 合并成一次提交
865
+ const merged = mtScene('h-merge')
866
+ merged.write('m.md', 'one\ntwo\nthree\n')
867
+ merged.run(['basetools', 'read', '--file', 'm.md', '--mode', 'full'])
868
+ const mergeEnv = json(
869
+ merged.run([
870
+ 'basetools',
871
+ 'edit',
872
+ '--targets',
873
+ JSON.stringify([
874
+ { file_path: 'm.md', hunks: [{ lines: [1, 1], replace: 'ONE' }] },
875
+ { file_path: 'm.md', hunks: [{ lines: [3, 3], replace: 'THREE' }] },
876
+ ]),
877
+ ]),
878
+ )
879
+ check(mergeEnv.ok === true && mergeEnv.edits?.length === 2 && mergeEnv.edits.every((e) => e.landed === true), `③ 同文件两个目标都落地(${mergeEnv.edits?.map((e) => e.outcome).join(',')})`, JSON.stringify(mergeEnv).slice(0, 160))
880
+ check(fs.readFileSync(path.join(merged.dir, 'm.md'), 'utf8') === 'ONE\ntwo\nTHREE\n', '③ 两处改动都在磁盘上(同一次提交里)', JSON.stringify(fs.readFileSync(path.join(merged.dir, 'm.md'), 'utf8')))
881
+ check(/files 1/.test(String(mergeEnv.summary)), `③ 汇总行说清合并成 1 次提交(${mergeEnv.summary})`)
882
+
883
+ // ④ dry-run 不落盘
884
+ const dry = mtScene('h-dry')
885
+ dry.write('c.md', 'one\ntwo\n')
886
+ dry.run(['basetools', 'read', '--file', 'c.md', '--mode', 'full'])
887
+ const keep = fs.readFileSync(path.join(dry.dir, 'c.md'))
888
+ const dryEnv = json(
889
+ dry.run(['basetools', 'edit', '--dry-run', '--targets', JSON.stringify([{ file_path: 'c.md', hunks: [{ lines: [1, 1], replace: 'ONE' }] }])]),
890
+ )
891
+ check(dryEnv.ok === true && dryEnv.edits?.[0]?.outcome === 'dry_run', `④ dryRun 的回执是 dry_run(${dryEnv.edits?.[0]?.outcome})`, JSON.stringify(dryEnv).slice(0, 160))
892
+ check(fs.readFileSync(path.join(dry.dir, 'c.md')).equals(keep), '④ dryRun 不落盘(逐字节不变)')
893
+
894
+ // ⑤ 结构性读数:provider 的 `writeText` 调用次数(子进程里看不见,只能在进程内对同一个适配入口记账)
895
+ const ops = await import(pathToFileURL(path.join(here, '..', 'lib', 'ops.mjs')).href)
896
+ const { createNodeFsProvider } = await import(pathToFileURL(path.join(here, '..', 'lib', 'fs-provider.mjs')).href)
897
+ /**
898
+ * 与两个真实调用面(CLI / MCP)同一条存储:`openProvider()` 会给引擎换上**落盘**观测
899
+ * (跨进程 + 把相对/绝对两种写法折叠到同一个键)。这里在进程内直调适配入口,所以自己装一次 ——
900
+ * 不装的话观测落在引擎默认那张进程内 Map 上,而它不折叠写法,同一个文件会被当成两个键。
901
+ */
902
+ process.env.AURA_CACHE_DIR = path.join(tmp, 'cache-inproc')
903
+ ops.installDiskObservations()
904
+ /**
905
+ * 绑定缺席时**不许把套件打死**:死在这里会把后面 MT-I / MT-K 的证据一起吞掉,
906
+ * 而“是哪条判据红了”正是这一单要的读数。缺席按空实现降落 ⇒ 下面五条各自报 ✗。
907
+ */
908
+ check(
909
+ typeof ops.readManyWith === 'function' && typeof ops.editManyEnvelope === 'function',
910
+ '⑤ ops.mjs 已从引擎转发 readManyWith / editManyEnvelope(独立面唯一入口)',
911
+ `readManyWith=${typeof ops.readManyWith} editManyEnvelope=${typeof ops.editManyEnvelope}`,
912
+ )
913
+ const readMany = typeof ops.readManyWith === 'function' ? ops.readManyWith : async () => ({ ok: false, entries: [] })
914
+ const editMany = typeof ops.editManyEnvelope === 'function' ? ops.editManyEnvelope : async () => ({ ok: false, edits: [] })
915
+ const counting = (inner) => {
916
+ const calls = []
917
+ const out = { calls }
918
+ for (const method of ['resolve', 'stat', 'readText', 'readByteRange', 'listDir', 'writeText']) {
919
+ if (typeof inner[method] !== 'function') continue
920
+ out[method] = (...methodArgs) => {
921
+ calls.push(`${method}:${String(methodArgs[0])}`)
922
+ return inner[method](...methodArgs)
923
+ }
924
+ }
925
+ return out
926
+ }
927
+ /**
928
+ * 数“写了几次那个文件”:按**文件名**认(引擎递给 provider 的可能是相对写法,
929
+ * 也可能是解析后的绝对路径 —— 那是引擎的内部形状,不是本判据要钉的东西)。
930
+ */
931
+ const writesOf = (calls, base) =>
932
+ calls.filter((c) => c.startsWith('writeText:') && path.basename(c.slice('writeText:'.length)) === base).length
933
+ /**
934
+ * 读一遍建观测(与 CLI 那条路同一个引擎入口),再把账本清零 —— 只数后面那次写的。
935
+ *
936
+ * 路径一律给**绝对**的:写侧的账本键取的是调用方递进去的那个 `displayPath`(引擎不替它解析),
937
+ * 而进程内 `resolveRoot` 与进程 cwd 是两个目录 —— 给相对写法会让“读记下的键”与“写查的键”分家。
938
+ * 真实面没这个问题:子进程的 cwd 就是场景目录,两边自然同键。
939
+ */
940
+ const observed = async (provider, dir, names) => {
941
+ await readMany(
942
+ provider,
943
+ names.map((name) => ({ file_path: path.join(dir, name), mode: 'full' })),
944
+ )
945
+ provider.calls.length = 0
946
+ }
947
+
948
+ const pair = mtScene('h-count-pair')
949
+ pair.write('c.md', 'one\ntwo\n')
950
+ pair.write('d.md', 'alpha\nbravo\n')
951
+ const pairProvider = counting(createNodeFsProvider({ resolveRoot: pair.dir }))
952
+ await observed(pairProvider, pair.dir, ['c.md', 'd.md'])
953
+ const pairEnv = await editMany(pairProvider, {
954
+ targets: [
955
+ { file_path: path.join(pair.dir, 'c.md'), hunks: [{ lines: [1, 1], replace: 'ONE' }] },
956
+ { file_path: path.join(pair.dir, 'd.md'), hunks: [{ lines: [1, 1], replace: 'ALPHA' }] },
957
+ ],
958
+ })
959
+ check(
960
+ pairEnv.ok === true && writesOf(pairProvider.calls, 'c.md') === 1 && writesOf(pairProvider.calls, 'd.md') === 1,
961
+ `⑤ writeText 每文件一次(c.md=${writesOf(pairProvider.calls, 'c.md')},d.md=${writesOf(pairProvider.calls, 'd.md')})`,
962
+ pairProvider.calls.filter((c) => c.startsWith('writeText:')).join(' | '),
963
+ )
964
+
965
+ const zeroCount = mtScene('h-count-zero')
966
+ zeroCount.write('c.md', 'one\ntwo\n')
967
+ zeroCount.write('d.md', 'alpha\nbravo\n')
968
+ const zeroProvider = counting(createNodeFsProvider({ resolveRoot: zeroCount.dir }))
969
+ await observed(zeroProvider, zeroCount.dir, ['c.md', 'd.md'])
970
+ const zeroEnv = await editMany(zeroProvider, {
971
+ targets: [
972
+ { file_path: path.join(zeroCount.dir, 'c.md'), hunks: [{ lines: [1, 1], replace: 'ONE' }] },
973
+ { file_path: path.join(zeroCount.dir, 'd.md'), hunks: [{ lines: [99, 99], replace: 'NOPE' }] },
974
+ ],
975
+ })
976
+ check(
977
+ zeroEnv.ok === false && zeroEnv.edits?.[1]?.outcome === 'failed' && zeroProvider.calls.every((c) => !c.startsWith('writeText:')),
978
+ `⑤ 阶段一失败 ⇒ writeText 一次都没调(${zeroProvider.calls.filter((c) => c.startsWith('writeText:')).length} 次)`,
979
+ JSON.stringify(zeroEnv.edits?.[1]?.error ?? {}).slice(0, 120),
980
+ )
981
+
982
+ const one = mtScene('h-count-merge')
983
+ one.write('m.md', 'one\ntwo\nthree\n')
984
+ const oneProvider = counting(createNodeFsProvider({ resolveRoot: one.dir }))
985
+ await observed(oneProvider, one.dir, ['m.md'])
986
+ const oneEnv = await editMany(oneProvider, {
987
+ targets: [
988
+ { file_path: path.join(one.dir, 'm.md'), hunks: [{ lines: [1, 1], replace: 'ONE' }] },
989
+ { file_path: path.join(one.dir, 'm.md'), hunks: [{ lines: [3, 3], replace: 'THREE' }] },
990
+ ],
991
+ })
992
+ check(
993
+ oneEnv.ok === true && writesOf(oneProvider.calls, 'm.md') === 1,
994
+ `⑤ 同文件两个目标 ⇒ writeText 只一次(${writesOf(oneProvider.calls, 'm.md')} 次)`,
995
+ oneProvider.calls.filter((c) => c.startsWith('writeText:')).join(' | '),
996
+ )
997
+ check(fs.readFileSync(path.join(one.dir, 'm.md'), 'utf8') === 'ONE\ntwo\nTHREE\n', '⑤ 合并提交把两处改动都落下了')
998
+
999
+ const dryCount = mtScene('h-count-dry')
1000
+ dryCount.write('c.md', 'one\ntwo\n')
1001
+ const dryProvider = counting(createNodeFsProvider({ resolveRoot: dryCount.dir }))
1002
+ await observed(dryProvider, dryCount.dir, ['c.md'])
1003
+ const dryEnv2 = await editMany(dryProvider, {
1004
+ dryRun: true,
1005
+ targets: [{ file_path: path.join(dryCount.dir, 'c.md'), hunks: [{ lines: [1, 1], replace: 'ONE' }] }],
1006
+ })
1007
+ check(
1008
+ dryEnv2.ok === true && dryEnv2.edits?.[0]?.outcome === 'dry_run' && dryProvider.calls.every((c) => !c.startsWith('writeText:')),
1009
+ `⑤ dryRun ⇒ writeText 一次都没调(${dryProvider.calls.filter((c) => c.startsWith('writeText:')).length} 次)`,
1010
+ JSON.stringify(dryEnv2.edits?.[0]?.error ?? {}).slice(0, 120),
1011
+ )
1012
+ }
1013
+
1014
+ // ── MT-I 形状与边界(CLI 参数面:exit 1) ──────────────────────────────────
1015
+ say('MT-I 形状与边界:参数面拒绝(exit 1),判据只有在引擎里的那一份')
1016
+ {
1017
+ const scene = mtScene('i')
1018
+ scene.write('a.js', MT_SOURCE)
1019
+ const one = JSON.stringify([{ file_path: 'a.js', mode: 'digest' }])
1020
+ // ① 两种形状同时给 ⇒ 参数面拒绝(读侧与写侧各一次)
1021
+ const readBoth = scene.run(['basetools', 'read', '--file', 'a.js', '--targets', one])
1022
+ const readBothEnv = json(readBoth)
1023
+ check(readBoth.status === 1, `① read:--file 与 --targets 同时给 ⇒ exit 1(实测 ${readBoth.status})`, String(readBothEnv.error).slice(0, 120))
1024
+ check(readBothEnv.ok === false && readBothEnv.shape === 'ARGS_SHAPE_EXCLUSIVE', `① 读侧点名互斥判据(shape=${readBothEnv.shape})`, JSON.stringify(readBothEnv).slice(0, 160))
1025
+ const editBoth = scene.run(['basetools', 'edit', '--file', 'a.js', '--targets', JSON.stringify([{ file_path: 'a.js', hunks: [{ lines: [1, 1], replace: 'X' }] }])])
1026
+ const editBothEnv = json(editBoth)
1027
+ check(editBoth.status === 1 && editBothEnv.shape === 'ARGS_SHAPE_EXCLUSIVE', `① edit:同样被拒(exit=${editBoth.status},shape=${editBothEnv.shape})`, String(editBothEnv.error).slice(0, 120))
1028
+ check(fs.readFileSync(path.join(scene.dir, 'a.js'), 'utf8') === MT_SOURCE, '① 被拒的写没有碰磁盘(逐字节不变)')
1029
+
1030
+ // ② 超 32 项
1031
+ const tooMany = Array.from({ length: 33 }, () => ({ file_path: 'a.js', mode: 'digest' }))
1032
+ const over = scene.run(['basetools', 'read', '--targets', JSON.stringify(tooMany)])
1033
+ const overEnv = json(over)
1034
+ check(over.status === 1, `② 33 项 ⇒ exit 1(实测 ${over.status})`)
1035
+ check(overEnv.shape === 'ARGS_TARGETS_LIMIT' && overEnv.got === 33, `② 报出实得数(got=${overEnv.got},shape=${overEnv.shape})`, String(overEnv.error).slice(0, 120))
1036
+
1037
+ // ③ 空数组不是“成功读了 0 个”
1038
+ const empty = scene.run(['basetools', 'read', '--targets', '[]'])
1039
+ const emptyEnv = json(empty)
1040
+ check(empty.status === 1 && emptyEnv.shape === 'ARGS_TARGETS_SHAPE', `③ 空数组 ⇒ 拒绝(exit=${empty.status},shape=${emptyEnv.shape})`, String(emptyEnv.error).slice(0, 120))
1041
+
1042
+ // ④ 不是 JSON:说清来源与接受的形状
1043
+ const notJson = json(scene.run(['basetools', 'read', '--targets', '[{file_path: a.js}]']))
1044
+ check(notJson.code === 'USAGE' && String(notJson.error).includes('--targets') && String(notJson.error).includes('JSON'), '④ 字面 JSON 坏了 ⇒ 说清来源与形状', String(notJson.error).slice(0, 140))
1045
+ const missingSpec = json(scene.run(['basetools', 'read', '--targets', '@nope.json']))
1046
+ check(missingSpec.code === 'USAGE' && String(missingSpec.error).includes('@nope.json'), '④ @文件 读不到 ⇒ 报的是实际来源', String(missingSpec.error).slice(0, 140))
1047
+ const bare = json(scene.run(['basetools', 'read', '--targets']))
1048
+ check(bare.code === 'USAGE', `④ \`--targets\` 没带值 ⇒ USAGE(收到 ${JSON.stringify(String(bare.error).slice(0, 60))})`)
1049
+ // 与 `--hunks-file` 同一条理由:PowerShell 5.1 默认写 BOM,剥掉即可
1050
+ fs.writeFileSync(path.join(scene.dir, 'spec.json'), Buffer.concat([BOM, Buffer.from(one, 'utf8')]))
1051
+ const bomSpec = scene.run(['basetools', 'read', '--targets', '@spec.json'])
1052
+ check(bomSpec.status === 0 && json(bomSpec).ok === true, `④ \`@文件\` 带 BOM 照样能用(exit=${bomSpec.status})`, String(bomSpec.stderr).slice(0, 100))
1053
+
1054
+ // ⑤ 顶层不认识的键不许静默忽略(`dryrun` 拼错而照写盘是最坏的一种)
1055
+ const unknown = json(
1056
+ scene.run(['basetools', 'edit', '--targets', JSON.stringify({ targets: [{ file_path: 'a.js', hunks: [{ lines: [1, 1], replace: 'X' }] }], dryrun: true })]),
1057
+ )
1058
+ check(unknown.code === 'USAGE' && unknown.shape === 'ARGS_UNKNOWN_KEY', `⑤ 未知顶层键 ⇒ 拒绝(shape=${unknown.shape})`, String(unknown.error).slice(0, 140))
1059
+ check(fs.readFileSync(path.join(scene.dir, 'a.js'), 'utf8') === MT_SOURCE, '⑤ 被拒的写没有碰磁盘(逐字节不变)')
1060
+ }
1061
+
1062
+ // ── MT-K MCP 多目标 ────────────────────────────────────────────────────
1063
+ say('MT-K MCP:targets 的声明、逐项回执、汇总行与既有形状')
1064
+ {
1065
+ const scene = mtScene('k')
1066
+ scene.write('a.js', MT_SOURCE)
1067
+ scene.write('b.md', 'one\ntwo\n')
1068
+ scene.write('c.md', 'alpha\nbravo\n')
1069
+ const call = (id, name, args) => ({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } })
1070
+ const res = scene.mcp([
1071
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} } },
1072
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
1073
+ { jsonrpc: '2.0', id: 2, method: 'tools/list' },
1074
+ call(3, 'aura_basetools_read', { targets: [{ file_path: 'a.js', mode: 'digest' }, { file_path: 'b.md', mode: 'lines', lines: [1, 2] }] }),
1075
+ call(4, 'aura_basetools_read', { targets: [{ file_path: 'a.js', mode: 'digest' }, { file_path: 'nope.md', mode: 'full' }] }),
1076
+ call(5, 'aura_basetools_edit', { targets: [{ file_path: 'c.md', hunks: [{ lines: [1, 1], replace: 'ALPHA' }] }] }),
1077
+ call(6, 'aura_basetools_edit', { file_path: 'c.md', targets: [{ file_path: 'c.md', hunks: [{ lines: [2, 2], replace: 'B' }] }] }),
1078
+ call(7, 'aura_basetools_read', { file: 'a.js', mode: 'digest' }),
1079
+ ])
1080
+ const replies = res.stdout.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
1081
+ const byId = new Map(replies.map((r) => [r.id, r]))
1082
+ const envOf = (id) => JSON.parse(byId.get(id)?.result?.content?.[0]?.text ?? '{}')
1083
+ const tools = byId.get(2)?.result?.tools ?? []
1084
+ const schemaOf = (name) => tools.find((t) => t.name === name)?.inputSchema ?? {}
1085
+ check(
1086
+ schemaOf('aura_basetools_read').properties?.targets?.type === 'array' && schemaOf('aura_basetools_edit').properties?.targets?.type === 'array',
1087
+ 'K 两个工具的 inputSchema 都声明了 targets 数组',
1088
+ JSON.stringify({ read: schemaOf('aura_basetools_read').properties?.targets, edit: schemaOf('aura_basetools_edit').properties?.targets }).slice(0, 160),
1089
+ )
1090
+ const batch = envOf(3)
1091
+ check(batch.ok === true && batch.entries?.length === 2, 'K file_path 缺席 + targets 在场 ⇒ 不再 BAD_ARGS,逐项照给', JSON.stringify(batch).slice(0, 180))
1092
+ check(batch.summary === 'targets: 2 | ok 2', `K 汇总行 ${JSON.stringify(batch.summary)}`)
1093
+ check(byId.get(3)?.result?.isError === false, `K 整批都成 ⇒ isError=false(${byId.get(3)?.result?.isError})`)
1094
+ const partial = envOf(4)
1095
+ check(partial.ok === false && partial.entries?.[0]?.ok === true && partial.entries?.[1]?.code === 'FS_NOT_FOUND', 'K 一项缺 ⇒ 不连坐,整批 ok=false', JSON.stringify(partial).slice(0, 180))
1096
+ check(partial.summary === 'targets: 2 | ok 1 | not_found 1', `K 缺一项时的汇总行 ${JSON.stringify(partial.summary)}`)
1097
+ check(byId.get(4)?.result?.isError === true, `K 有失败 ⇒ isError=true(${byId.get(4)?.result?.isError})`)
1098
+ const editBatch = envOf(5)
1099
+ check(editBatch.ok === true && editBatch.edits?.[0]?.landed === true, 'K aura_basetools_edit 带 targets 走引擎批入口', JSON.stringify(editBatch).slice(0, 180))
1100
+ check(fs.readFileSync(path.join(scene.dir, 'c.md'), 'utf8') === 'ALPHA\nbravo\n', 'K MCP 批编辑真落盘', JSON.stringify(fs.readFileSync(path.join(scene.dir, 'c.md'), 'utf8')))
1101
+ check(envOf(6).code === 'ARGS_SHAPE_EXCLUSIVE', `K file_path 与 targets 同时给 ⇒ 互斥判据(${envOf(6).code})`, String(envOf(6).error).slice(0, 140))
1102
+ check(envOf(7).code === 'ARGS_UNKNOWN_KEY', `K 两种形状都没给仍被未知键守卫点名(${envOf(7).code})`, String(envOf(7).error).slice(0, 140))
1103
+ check(res.stderr.trim() === '', 'K 没靠 stderr 兑底(不用抛出当回包)', res.stderr.trim().slice(0, 80))
1104
+ }
1105
+
1106
+ // ── MT-L 句柄寻址:MCP 声明面 · MCP 调用 · CLI 平铺旗标 · 引擎直调 ────────────
1107
+ say('MT-L 句柄寻址:同一份柄、同一次改动 —— 三面同 tier 同落盘')
1108
+ {
1109
+ /**
1110
+ * 判据(能力面对齐:引擎早就支持,缺的是两个面的**声明与旗标**):
1111
+ * ① MCP 的**声明面**收得下 `handle` / `line` / `position` —— 参数表里没有的键,在那一面就等于不存在
1112
+ * (CLI 读出来的回执里明明带着柄,而工具声明里看不见它);
1113
+ * ② MCP 真用 `{handle, line, replace}` 改一次:`ok`、`tier` 点名句柄、磁盘逐字节只有那一行变;
1114
+ * ③ CLI 的平铺旗标 `--handle h… --line 2 --replace X` 同样能改(改前这条路是 exit 1);
1115
+ * ④ `--line` / `--position` 是**从属键**:没有 `--handle` 就不构成一次编辑(既有行为保持不变);
1116
+ * ⑤ 三面(CLI / MCP / 引擎 `applyEditToProvider` 直调)给出同一个 `tier` 与同样的落盘字节。
1117
+ *
1118
+ * 柄一律从**读侧**拿,且两处必须一致:回执结构化字段 `handle` 与正文首行行尾的 `<h…>`。
1119
+ * 四个场景各自干净;⑤ 的三面共用**同一个文件与同一个缓存目录**(柄是账本里的凭据,
1120
+ * 账本不同本来就该对不上 —— 那是设计,不是本段要证的事)。
1121
+ */
1122
+ const HANDLE_RE = /h[0-9a-f]{12}:\d+-\d+/
1123
+ const LINE2 = ' const a = 42'
1124
+ const EXPECTED = MT_SOURCE.replace(' const a = 1', LINE2)
1125
+ const MCP_HELLO = [
1126
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} } },
1127
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
1128
+ ]
1129
+ const mcpCall = (id, name, args) => ({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } })
1130
+ /** 一次 MCP 进程的回包:`env(id)` 取工具回执,`tools` 取 `tools/list` 的声明面。 */
1131
+ const mcpReplies = (res) => {
1132
+ const byId = new Map(
1133
+ String(res.stdout)
1134
+ .trim()
1135
+ .split('\n')
1136
+ .filter(Boolean)
1137
+ .map((line) => JSON.parse(line))
1138
+ .map((r) => [r.id, r]),
1139
+ )
1140
+ return { env: (id) => JSON.parse(byId.get(id)?.result?.content?.[0]?.text ?? '{}'), tools: byId.get(2)?.result?.tools ?? [] }
1141
+ }
1142
+ const bytesOf = (p) => fs.readFileSync(p)
1143
+ const tierOf = (env) => String(env?.result?.applied?.[0]?.tier ?? env?.applied?.[0]?.tier ?? '')
1144
+ const readHandle = (scene) => json(scene.run(['basetools', 'read', '--file', 'a.js', '--mode', 'lines', '--lines', '1,4'])).handle
1145
+ /** 逐字节只有第 n 行变:行数、每行的行尾字节、其余每一行都要一模一样(返回 null = 通过)。 */
1146
+ const onlyLineChanged = (before, after, lineNo) => {
1147
+ const a = split(String(before))
1148
+ const b = split(String(after))
1149
+ if (a.lines.length !== b.lines.length) return `行数变了:${a.lines.length} → ${b.lines.length}`
1150
+ if (a.ends.some((end, i) => end !== b.ends[i])) return '行尾字节变了'
1151
+ const changed = a.lines.map((line, i) => (line === b.lines[i] ? null : i + 1)).filter((n) => n !== null)
1152
+ return changed.length === 1 && changed[0] === lineNo ? null : `变的行是 ${changed.join(',') || '(无)'},期望只有第 ${lineNo} 行`
1153
+ }
1154
+
1155
+ // ① 声明面:三个键必须出现在 `hunks.items.properties` 里
1156
+ const declScene = mtScene('l-decl')
1157
+ declScene.write('a.js', MT_SOURCE)
1158
+ const decl = mcpReplies(
1159
+ declScene.mcp([...MCP_HELLO, { jsonrpc: '2.0', id: 2, method: 'tools/list' }, mcpCall(3, 'aura_basetools_read', { file_path: 'a.js', mode: 'lines', lines: [1, 4] })]),
1160
+ )
1161
+ const editTool = decl.tools.find((t) => t.name === 'aura_basetools_edit')
1162
+ const hunkProps = Object.keys(editTool?.inputSchema?.properties?.hunks?.items?.properties ?? {})
1163
+ const missing = ['handle', 'line', 'position'].filter((key) => !hunkProps.includes(key))
1164
+ check(
1165
+ missing.length === 0,
1166
+ 'L① MCP 声明面:aura_basetools_edit 的 hunks.items.properties 收得下 handle / line / position',
1167
+ `缺 ${missing.join(',') || '(无)'};实得 ${hunkProps.join(',')}`,
1168
+ )
1169
+ const hunkRequired = editTool?.inputSchema?.properties?.hunks?.items?.required ?? []
1170
+ check(JSON.stringify(hunkRequired) === '["replace"]', 'L① required 仍是 ["replace"](三个从属键一个都不进去)', JSON.stringify(hunkRequired))
1171
+
1172
+ // ② MCP 真改一次:柄从上面那次 `aura_basetools_read` 拿;正文行尾的 <h…> 与结构化 handle 必须同值
1173
+ const mcpRead = decl.env(3)
1174
+ const mcpHandle = mcpRead.handle
1175
+ const marker = (String(mcpRead.text).match(HANDLE_RE) ?? [null])[0]
1176
+ check(
1177
+ typeof mcpHandle === 'string' && HANDLE_RE.test(mcpHandle) && marker === mcpHandle,
1178
+ 'L② 读侧发的柄:结构化 handle 与正文行尾 <h…> 同值',
1179
+ `${JSON.stringify(mcpHandle)} vs ${JSON.stringify(marker)}`,
1180
+ )
1181
+ const mcpBefore = bytesOf(path.join(declScene.dir, 'a.js'))
1182
+ const mcpEdit = mcpReplies(
1183
+ declScene.mcp([...MCP_HELLO, mcpCall(4, 'aura_basetools_edit', { file_path: 'a.js', hunks: [{ handle: mcpHandle, line: 2, replace: LINE2 }] })]),
1184
+ ).env(4)
1185
+ const mcpAfter = bytesOf(path.join(declScene.dir, 'a.js'))
1186
+ check(
1187
+ mcpEdit.ok === true && tierOf(mcpEdit) === 'handle line 2',
1188
+ 'L② MCP {handle, line: 2, replace} ⇒ ok、tier 点名句柄',
1189
+ `${tierOf(mcpEdit)};${JSON.stringify(mcpEdit).slice(0, 160)}`,
1190
+ )
1191
+ check(onlyLineChanged(mcpBefore, mcpAfter, 2) === null, 'L② 磁盘逐字节只有第 2 行变', String(onlyLineChanged(mcpBefore, mcpAfter, 2)))
1192
+ check(mcpAfter.equals(utf8(EXPECTED)), 'L② 落盘就是预期那一份(逐字节)', byteDiff(mcpAfter.toString('utf8'), EXPECTED) ?? '')
1193
+
1194
+ // ③ CLI 的平铺旗标:改前这条路报「需要 hunks/一组地址旗标」并 exit 1
1195
+ const cliScene = mtScene('l-cli')
1196
+ cliScene.write('a.js', MT_SOURCE)
1197
+ const cliHandle = readHandle(cliScene)
1198
+ const cliRun = cliScene.run(['basetools', 'edit', '--file', 'a.js', '--handle', String(cliHandle), '--line', '2', '--replace', LINE2])
1199
+ const cliEdit = json(cliRun)
1200
+ check(
1201
+ cliRun.status === 0 && cliEdit.ok === true,
1202
+ `L③ CLI \`--handle h… --line 2 --replace\` ⇒ exit 0 且 ok(实测 exit=${cliRun.status})`,
1203
+ JSON.stringify(cliEdit).slice(0, 180),
1204
+ )
1205
+ check(tierOf(cliEdit) === 'handle line 2', `L③ 回执 tier 点名句柄(${tierOf(cliEdit)})`, JSON.stringify(cliEdit).slice(0, 160))
1206
+ check(bytesOf(path.join(cliScene.dir, 'a.js')).equals(utf8(EXPECTED)), 'L③ CLI 真落盘(逐字节)', byteDiff(bytesOf(path.join(cliScene.dir, 'a.js')).toString('utf8'), EXPECTED) ?? '')
1207
+
1208
+ // ④ 从属键:`--line` 单独给**不构成一次编辑**(判据在引擎的 HANDLE_SHAPE_KEYS,CLI 不另判一份)
1209
+ const subScene = mtScene('l-sub')
1210
+ subScene.write('a.js', MT_SOURCE)
1211
+ const subBefore = bytesOf(path.join(subScene.dir, 'a.js'))
1212
+ const subRun = subScene.run(['basetools', 'edit', '--file', 'a.js', '--line', '2', '--replace', 'X'])
1213
+ const subEnv = json(subRun)
1214
+ check(
1215
+ subRun.status === 1 && subEnv.code === 'USAGE',
1216
+ `L④ \`--line 2 --replace X\`(无 --handle)⇒ 仍 exit 1(实测 ${subRun.status},code=${subEnv.code})`,
1217
+ String(subEnv.error).slice(0, 140),
1218
+ )
1219
+ check(subBefore.equals(bytesOf(path.join(subScene.dir, 'a.js'))), 'L④ 被拒的写没有碰磁盘(逐字节不变)')
1220
+
1221
+ // `--position after`:在区间**边界**插入,区间本身一个字节不动
1222
+ const posScene = mtScene('l-pos')
1223
+ posScene.write('a.js', MT_SOURCE)
1224
+ const posRun = posScene.run(['basetools', 'edit', '--file', 'a.js', '--handle', String(readHandle(posScene)), '--position', 'after', '--replace', 'POST'])
1225
+ const posEnv = json(posRun)
1226
+ const posLines = split(bytesOf(path.join(posScene.dir, 'a.js')).toString('utf8'))
1227
+ const origLines = split(MT_SOURCE)
1228
+ check(
1229
+ posRun.status === 0 && posEnv.ok === true && tierOf(posEnv) === 'handle after',
1230
+ `L④ \`--position after\` ⇒ ok、tier=${tierOf(posEnv)}(exit=${posRun.status})`,
1231
+ JSON.stringify(posEnv).slice(0, 180),
1232
+ )
1233
+ check(
1234
+ posLines.lines[4] === 'POST' &&
1235
+ posLines.lines.length === origLines.lines.length + 1 &&
1236
+ posLines.lines.slice(0, 4).join('\u0000') === origLines.lines.slice(0, 4).join('\u0000') &&
1237
+ posLines.lines.slice(5).join('\u0000') === origLines.lines.slice(4).join('\u0000'),
1238
+ 'L④ 插入落在区间末行之后,区间 1-4 一个字节不动',
1239
+ JSON.stringify(posLines.lines.slice(0, 6)),
1240
+ )
1241
+
1242
+ // ⑤ 三面一致:同一个文件、同一份柄、同一次改动
1243
+ const triScene = mtScene('l-three')
1244
+ const triFile = path.join(triScene.dir, 'a.js')
1245
+ const reset = () => fs.writeFileSync(triFile, Buffer.from(MT_SOURCE, 'utf8'))
1246
+ reset()
1247
+ const triCliHandle = readHandle(triScene)
1248
+ const triCli = json(triScene.run(['basetools', 'edit', '--file', 'a.js', '--handle', String(triCliHandle), '--line', '2', '--replace', LINE2]))
1249
+ const triCliDisk = bytesOf(triFile)
1250
+
1251
+ // 回到同一份字节:柄是「账本键 + 区间文本 + 行区间」的函数 ⇒ 重读一次的柄必须逐字符相同
1252
+ reset()
1253
+ const triMcpRead = mcpReplies(
1254
+ triScene.mcp([...MCP_HELLO, mcpCall(3, 'aura_basetools_read', { file_path: 'a.js', mode: 'lines', lines: [1, 4] })]),
1255
+ ).env(3)
1256
+ const triMcp = mcpReplies(
1257
+ triScene.mcp([...MCP_HELLO, mcpCall(4, 'aura_basetools_edit', { file_path: 'a.js', hunks: [{ handle: triMcpRead.handle, line: 2, replace: LINE2 }] })]),
1258
+ ).env(4)
1259
+ const triMcpDisk = bytesOf(triFile)
1260
+
1261
+ reset()
1262
+ const ops = await import(pathToFileURL(path.join(here, '..', 'lib', 'ops.mjs')).href)
1263
+ const { createNodeFsProvider } = await import(pathToFileURL(path.join(here, '..', 'lib', 'fs-provider.mjs')).href)
1264
+ // 进程内直调也要落盘观测(MT-H 已装过一次,安装是幂等的;这里重设一遍环境变量,绝不落到 home)
1265
+ process.env.AURA_CACHE_DIR = path.join(tmp, 'cache-inproc')
1266
+ ops.installDiskObservations()
1267
+ const triProvider = createNodeFsProvider({ resolveRoot: triScene.dir })
1268
+ const triEngRead = await ops.readEnvelope(triProvider, { file: triFile, mode: 'lines', lines: [1, 4] })
1269
+ const triEng = await ops.applyEditToProvider(triProvider, { displayPath: triFile, hunks: [{ handle: triEngRead.handle, line: 2, replace: LINE2 }] })
1270
+ const triEngDisk = bytesOf(triFile)
1271
+
1272
+ /**
1273
+ * 柄 = h + 4 位身份标签(账本键) + 8 位内容摘要 + 行区间。
1274
+ * 三个面各自一份账本,身份标签**本来就该不同**(M④ 已钉住);这里比的是内容段。
1275
+ * 旧断言拿整串比,在 macOS 的 /var → /private/var 真身上才暴露:它要求三个账本合成一份。
1276
+ */
1277
+ const handleBody = (handle) => String(handle).slice(5)
1278
+ check(
1279
+ handleBody(triCliHandle) === handleBody(triMcpRead.handle) && handleBody(triMcpRead.handle) === handleBody(triEngRead.handle),
1280
+ `L⑤ 三面柄的内容段相同(CLI=${triCliHandle}/MCP=${triMcpRead.handle}/引擎=${triEngRead.handle})`,
1281
+ )
1282
+ check(
1283
+ tierOf(triCli) === 'handle line 2' && tierOf(triMcp) === tierOf(triCli) && tierOf(triEng) === tierOf(triCli),
1284
+ `L⑤ 三面 tier 相同(CLI=${tierOf(triCli)}/MCP=${tierOf(triMcp)}/引擎=${tierOf(triEng)})`,
1285
+ )
1286
+ check(
1287
+ triCliDisk.equals(triMcpDisk) && triMcpDisk.equals(triEngDisk) && triEngDisk.equals(utf8(EXPECTED)),
1288
+ 'L⑤ 三面落盘逐字节相同(且就是预期那一份)',
1289
+ byteDiff(triCliDisk.toString('utf8'), EXPECTED) ?? byteDiff(triEngDisk.toString('utf8'), EXPECTED) ?? '',
1290
+ )
1291
+ }
1292
+
1293
+ say('MT-M 修复地址:"改错了能自纠"那一半在独立面上看不看得见')
1294
+ {
1295
+ /**
1296
+ * 判据(能力面对齐:引擎早就给地址,缺的是独立面把不把它递到眼前):
1297
+ * ① **看得见**:引文写错 ⇒ CLI 与 MCP 的回执正文里都能解析出 `retry: {"handle":…}`;
1298
+ * ② **抄得动**:把这个地址 + 自己的 `replace` 再发一次,**真的落盘**,落点正是回执印的那个区间;
1299
+ * ③ **三面同源**:CLI / MCP / 引擎给出的候选行号与句柄**逐字相同**(地址只有一个来源);
1300
+ * ④ **多候选逐个带地址**:三处同文 ⇒ 每个候选各一行 `retry:`,且每一行都真的能用;
1301
+ * ⑤ **不夸大**:地址只在**有**最近区间时给;拿地址重试成功也不把原来的码改成成功码。
1302
+ *
1303
+ * 为什么取址**从正文里解**而不读结构化字段:模型只看得见正文(引擎的错误对象只取 `message`),
1304
+ * 所以判据必须钉在正文上。
1305
+ *
1306
+ * 为什么重试要**开新进程**:CLI 的真实用法就是两条命令两次进程;句柄是"那段字节的函",
1307
+ * 只要文件没动在两个进程里就是同一个柄 —— 这条不成立,本段必须红。
1308
+ */
1309
+ const NF_SRC = `${Array.from({ length: 40 }, (_, i) => `const item${String(i + 1).padStart(2, '0')} = make(${i + 1}, 'tag-${i + 1}')`).join('\n')}\n`
1310
+ const NF_LINES = NF_SRC.split('\n')
1311
+ const NF_REGION = { start: 15, end: 20 } // 最近区间:文件第 15–20 行(1-based)
1312
+ const NF_QUOTE = NF_LINES.slice(NF_REGION.start - 1, NF_REGION.end).join('\n').replace('const item20', 'const item20TYPO')
1313
+ const NF_NEW = Array.from({ length: NF_REGION.end - NF_REGION.start + 1 }, (_, i) => `const repaired${i + 1} = ${i + 1}`)
1314
+ // 空白类写 `[ \t]*` 而不是反斜杠-s 那条:打包守卫的"盘符路径"正则带负向前瞻、不看上下文,
1315
+ // 会把**转义序列**(反斜杠紧跟 s)误当成盘符路径 ⇒ 那份守卫会在打包时直接停手(实测踩到)。
1316
+ const RETRY_RE = /retry:[ \t]*(\{[^\n]*?\})/g
1317
+ const nearHandles = (text) => [...String(text).matchAll(RETRY_RE)]
1318
+ .map((m) => { try { return JSON.parse(m[1]) } catch { return null } })
1319
+ .filter((o) => o !== null && typeof o.handle === 'string')
1320
+
1321
+ const notFoundRun = (scene) => scene.run(['basetools', 'edit', '--file', 'a.js', '--hunks', JSON.stringify([{ old: NF_QUOTE, replace: '// nope' }])])
1322
+ const retryRun = (scene, handle) => scene.run(['basetools', 'edit', '--file', 'a.js', '--handle', handle, '--lines', `${NF_REGION.start},${NF_REGION.end}`, '--replace', NF_NEW.join('\n')])
1323
+
1324
+ // ── ① CLI:一次注定找不到的编辑 ──
1325
+ const cliScene = mtScene('m-cli')
1326
+ cliScene.write('a.js', NF_SRC)
1327
+ const cliBefore = fs.readFileSync(path.join(cliScene.dir, 'a.js'))
1328
+ const cliFail = notFoundRun(cliScene)
1329
+ const cliEnv = json(cliFail)
1330
+ // 正文在哪个键上由各面自己定:CLI 把消息放在 `error`,MCP / 引擎放在 `message`。
1331
+ // 判据只问「这段文本在不在回执里」,不问键名 —— 模型两面都看得见这一整段。
1332
+ const textOf = (env) => String(env?.message ?? env?.error ?? '')
1333
+ const cliRetry = nearHandles(textOf(cliEnv))
1334
+ check(
1335
+ cliEnv.code === 'PATCH_NOT_FOUND' && cliRetry.length === 1,
1336
+ 'M① CLI:引文写错 ⇒ `PATCH_NOT_FOUND`,且回执正文里能抄到一个 `retry: {"handle":…}`',
1337
+ `code=${cliEnv.code} 抄到 ${cliRetry.length} 个:${JSON.stringify(cliRetry)}\n${textOf(cliEnv).slice(0, 300)}`,
1338
+ )
1339
+ check(
1340
+ fs.readFileSync(path.join(cliScene.dir, 'a.js')).equals(cliBefore),
1341
+ 'M① CLI:失败那一发一个字节都没写',
1342
+ )
1343
+
1344
+ // ── ② CLI 抄回地址重试(**新进程**)──
1345
+ const cliHandle = cliRetry[0]?.handle ?? ''
1346
+ const cliRetryRes = retryRun(cliScene, cliHandle)
1347
+ const cliRetryEnv = json(cliRetryRes)
1348
+ const cliExpected = [...NF_LINES.slice(0, NF_REGION.start - 1), ...NF_NEW, ...NF_LINES.slice(NF_REGION.end)].join('\n')
1349
+ const cliDisk = fs.readFileSync(path.join(cliScene.dir, 'a.js')).toString('utf8')
1350
+ check(
1351
+ cliRetryRes.status === 0 && cliRetryEnv.ok === true && cliDisk === cliExpected,
1352
+ 'M② CLI:把那一行地址抄回去再发一次 ⇒ 真的落盘,且只换了回执印的那个区间',
1353
+ `exit=${cliRetryRes.status} ok=${cliRetryEnv.ok} ${byteDiff(cliDisk, cliExpected) ?? ''}`,
1354
+ )
1355
+
1356
+ // ── ③ MCP:同一份夹具、同一条路 ──
1357
+ const MCP_HELLO_M = [
1358
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {} } },
1359
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
1360
+ ]
1361
+ const mcpCallM = (id, name, args) => ({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } })
1362
+ const envOfM = (res, id) => {
1363
+ const line = String(res.stdout).trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)).find((r) => r.id === id)
1364
+ return JSON.parse(line?.result?.content?.[0]?.text ?? '{}')
1365
+ }
1366
+ const mcpScene = mtScene('m-mcp')
1367
+ mcpScene.write('a.js', NF_SRC)
1368
+ const mcpFailRes = mcpScene.mcp([...MCP_HELLO_M, mcpCallM(3, 'aura_basetools_edit', { file_path: 'a.js', hunks: [{ old: NF_QUOTE, replace: '// nope' }] })])
1369
+ const mcpFail = envOfM(mcpFailRes, 3)
1370
+ const mcpRetry = nearHandles(textOf(mcpFail))
1371
+ check(
1372
+ mcpFail.code === 'PATCH_NOT_FOUND' && mcpRetry.length === 1,
1373
+ 'M③ MCP:同一个失败在 MCP 面上同样能抄到地址(工具回执正文)',
1374
+ `code=${mcpFail.code} 抄到 ${mcpRetry.length} 个:${JSON.stringify(mcpRetry)}`,
1375
+ )
1376
+ const mcpRetryRes = mcpScene.mcp([
1377
+ ...MCP_HELLO_M,
1378
+ mcpCallM(3, 'aura_basetools_edit', { file_path: 'a.js', hunks: [{ handle: mcpRetry[0]?.handle, lines: [NF_REGION.start, NF_REGION.end], replace: NF_NEW.join('\n') }] }),
1379
+ ])
1380
+ const mcpRetryEnv = envOfM(mcpRetryRes, 3)
1381
+ const mcpDisk = fs.readFileSync(path.join(mcpScene.dir, 'a.js')).toString('utf8')
1382
+ check(
1383
+ mcpRetryEnv.ok === true && mcpDisk === cliExpected,
1384
+ 'M③ MCP:把地址抄回去 ⇒ 真的落盘,字节与 CLI 那条路完全相同',
1385
+ `ok=${mcpRetryEnv.ok} ${byteDiff(mcpDisk, cliExpected) ?? ''}`,
1386
+ )
1387
+
1388
+ // ── ④ 三面同源:候选行号与句柄逐字相同(与 MT-L⑤ 同一套进程内直调:ops + 真 fs provider + 落盘观测)──
1389
+ const engSceneM = mtScene('m-engine')
1390
+ engSceneM.write('a.js', NF_SRC)
1391
+ const engFileM = path.join(engSceneM.dir, 'a.js')
1392
+ const opsM = await import(pathToFileURL(path.join(here, '..', 'lib', 'ops.mjs')).href)
1393
+ const { createNodeFsProvider: createNodeFsProviderM } = await import(pathToFileURL(path.join(here, '..', 'lib', 'fs-provider.mjs')).href)
1394
+ process.env.AURA_CACHE_DIR = path.join(tmp, 'cache-m-engine')
1395
+ opsM.installDiskObservations()
1396
+ const engProv = createNodeFsProviderM({ resolveRoot: engSceneM.dir })
1397
+ let engErrM = null
1398
+ try {
1399
+ await opsM.applyEditToProvider(engProv, { displayPath: engFileM, hunks: [{ old: NF_QUOTE, replace: '// nope' }] })
1400
+ } catch (caught) { engErrM = caught }
1401
+ const engRetry = nearHandles(String(engErrM?.message ?? ''))
1402
+ /**
1403
+ * 柄的三段:`h` + 4 位**身份标签**(账本键)+ 8 位**区间摘要**(那段字节的函)+ 起-止行号。
1404
+ * 「三面同源」必须拿**内容那两段**(摘要 + 行号)比,不能拿身份标签比:
1405
+ * 三个面各自一份账本(缓存目录不同),身份标签本来就该不同 —— 拿它比等于要求三个账本合成一份,
1406
+ * 那是把设计当成缺陷。而**摘要相同**才是“三面说的是同一段字节”这个断言。
1407
+ */
1408
+ const contentOf = (h) => String(h).replace(/^h[0-9a-f]{4}/, '')
1409
+ const idTagOf = (h) => (String(h).match(/^h([0-9a-f]{4})/) ?? [null, ''])[1]
1410
+ check(
1411
+ engRetry.length === 1 && contentOf(engRetry[0].handle) === contentOf(cliHandle) && contentOf(cliHandle) === contentOf(mcpRetry[0]?.handle),
1412
+ 'M④ 三面同源:CLI / MCP / 引擎交出的柄指向**同一段字节与同一区间**(摘要+行号逐字相同)',
1413
+ `引擎=${engRetry[0]?.handle}/CLI=${cliHandle}/MCP=${mcpRetry[0]?.handle}`,
1414
+ )
1415
+ check(
1416
+ idTagOf(cliHandle) !== idTagOf(mcpRetry[0]?.handle) && idTagOf(cliHandle) !== idTagOf(engRetry[0]?.handle),
1417
+ 'M④ 身份标签各自不同(三个面各有自己的账本——这是设计,不是缺陷)',
1418
+ `CLI=${idTagOf(cliHandle)}/MCP=${idTagOf(mcpRetry[0]?.handle)}/引擎=${idTagOf(engRetry[0]?.handle)}`,
1419
+ )
1420
+ check(
1421
+ String(engErrM?.message ?? '').includes(NF_REGION.start + '-' + NF_REGION.end),
1422
+ 'M④ 三面同源:引擎回执印的最近区间(' + NF_REGION.start + '-' + NF_REGION.end + ')也在正文里',
1423
+ String(engErrM?.message ?? '').slice(0, 200),
1424
+ )
1425
+
1426
+ // ── ⑤ 多候选:三处同文 ⇒ 每个候选各带一行地址,且**每一行都能用** ──
1427
+ const AMB_SRC = ['const head = 0', ...Array.from({ length: 10 }, (_, i) => `const pad${String(i + 1).padStart(2, '0')} = ${i + 1}`), 'const dup = value * 2', ...Array.from({ length: 7 }, (_, i) => `const pad${String(i + 11).padStart(2, '0')} = ${i + 11}`), 'const dup = value * 2', ...Array.from({ length: 2 }, (_, i) => `const pad${String(i + 18).padStart(2, '0')} = ${i + 18}`), 'const dup = value * 2', 'const tail = 0'].join('\n') + '\n'
1428
+ const ambScene = mtScene('m-amb')
1429
+ ambScene.write('a.js', AMB_SRC)
1430
+ const ambFail = json(ambScene.run(['basetools', 'edit', '--file', 'a.js', '--hunks', JSON.stringify([{ old: 'const dup = value * 2', replace: 'const dup = 0' }])]))
1431
+ const ambRetry = nearHandles(textOf(ambFail))
1432
+ // 候选在哪个键上:引擎的错误对象把细节挂在 `error` 下(DSH 壳再把它抬成外套字段)。
1433
+ // CLI 收到的是 `{ok,error,code}`(`error` 是**消息字符串**)⇒ 细节只能从 `result`/外套里找;
1434
+ // 所以候选数改从**消息正文**数(每个候选项占一行 `retry:`),与地址行数同源同量。
1435
+ const ambCands = ambFail.error?.candidates ?? ambFail.result?.error?.candidates ?? []
1436
+ const ambCandCount = ambCands.length > 0 ? ambCands.length : ambRetry.length
1437
+ const ambLineNumbers = [...String(textOf(ambFail)).matchAll(/candidates: ([^.;)]+)/g)].map((m) => m[1])
1438
+ check(
1439
+ ambRetry.length === ambCandCount && ambRetry.length >= 3 && ambLineNumbers.length === 1,
1440
+ 'M⑤ 多候选:`PATCH_AMBIGUOUS` 的每个候选各带一行 `retry:`(从挑一个变成抄一个)',
1441
+ `地址 ${ambRetry.length} 行 / 正文点名的候选行=${ambLineNumbers.join('|')} / 结构化候选 ${ambCands.length} 个`,
1442
+ )
1443
+ // 逐行都真能用:拿第三个候选的地址改一次(刻意不取第一个 "" 就近挑会蒙对)
1444
+ const ambPick = ambRetry[2]
1445
+ const ambBeforeDisk = fs.readFileSync(path.join(ambScene.dir, 'a.js')).toString('utf8')
1446
+ const ambUse = json(ambScene.run(['basetools', 'edit', '--file', 'a.js', '--handle', ambPick.handle, '--replace', 'const dup = 99']))
1447
+ const ambAfterDisk = fs.readFileSync(path.join(ambScene.dir, 'a.js')).toString('utf8')
1448
+ check(
1449
+ ambUse.ok === true && ambAfterDisk !== ambBeforeDisk && ambAfterDisk.split('const dup = 99').length === 2,
1450
+ 'M⑤ 第三个候选的地址真能用(只换那一行,另外两处同文行不动)',
1451
+ `ok=${ambUse.ok} tier=${ambUse.applied?.[0]?.tier}`,
1452
+ )
1453
+ }
1454
+
1455
+ say('N provider:mode / symlink / hardlink / range / 上限 / cache')
1456
+ {
1457
+ const provider = createNodeFsProvider({ resolveRoot: tmp, maxTextBytes: 1024 })
1458
+
1459
+ // N1 mode 保留(POSIX);Windows 的 mode 不承载执行位,明确跳过而不是假装通过。
1460
+ const modeFile = path.join(tmp, 'provider-mode.js')
1461
+ fs.writeFileSync(modeFile, '#!/usr/bin/env node\nconsole.log(0)\n')
1462
+ try { fs.chmodSync(modeFile, 0o755) } catch { /* Windows / 受限 FS */ }
1463
+ const modeStat = provider.stat(modeFile)
1464
+ await provider.writeText(modeFile, '#!/usr/bin/env node\nconsole.log(1)\n', { kind: 'replaceIfVersion', version: modeStat.version })
1465
+ if (path.sep === '\\') {
1466
+ console.log('⊘ N1 mode:Windows 不承载 POSIX 执行位,跳过')
1467
+ } else {
1468
+ check((fs.statSync(modeFile).mode & 0o777) === 0o755, 'N1 原子写保留 0755', (fs.statSync(modeFile).mode & 0o777).toString(8))
1469
+ }
1470
+
1471
+ // N2 符号链接写穿:链接保留,目标内容更新。
1472
+ const target = path.join(tmp, 'provider-target.txt')
1473
+ const link = path.join(tmp, 'provider-link.txt')
1474
+ fs.writeFileSync(target, 'ORIGINAL\n')
1475
+ let symlinkOk = false
1476
+ try { fs.symlinkSync(target, link, 'file'); symlinkOk = true } catch { /* 权限不允许 */ }
1477
+ if (!symlinkOk) {
1478
+ console.log('⊘ N2 symlink:本机不允许创建文件符号链接,跳过')
1479
+ } else {
1480
+ const linkStat = provider.stat(link)
1481
+ await provider.writeText(link, 'VIA LINK\n', { kind: 'replaceIfVersion', version: linkStat.version })
1482
+ check(fs.lstatSync(link).isSymbolicLink(), 'N2 写后 link 仍是符号链接')
1483
+ check(fs.readFileSync(target, 'utf8') === 'VIA LINK\n', 'N2 目标文件收到新内容')
1484
+ }
1485
+
1486
+ // N3 硬链接拒绝:rename 会打断另一个名字,必须显式拒绝而不是静默分叉。
1487
+ const hardA = path.join(tmp, 'provider-hard-a.txt')
1488
+ const hardB = path.join(tmp, 'provider-hard-b.txt')
1489
+ fs.writeFileSync(hardA, 'HARD\n')
1490
+ let hardOk = false
1491
+ try { fs.linkSync(hardA, hardB); hardOk = true } catch { /* 受限 FS */ }
1492
+ if (!hardOk) {
1493
+ console.log('⊘ N3 hardlink:本机不允许创建硬链接,跳过')
1494
+ } else {
1495
+ const hardStat = provider.stat(hardA)
1496
+ let hardCode = null
1497
+ try { await provider.writeText(hardA, 'CHANGED\n', { kind: 'replaceIfVersion', version: hardStat.version }) } catch (error) { hardCode = error?.code }
1498
+ check(hardCode === 'FS_HARDLINK_UNSUPPORTED', 'N3 硬链接写被拒绝', String(hardCode))
1499
+ check(fs.readFileSync(hardA, 'utf8') === 'HARD\n', 'N3 拒绝后两个名字内容都未变')
1500
+ }
1501
+
1502
+ // N4 range 只取请求区间,不走整份读。
1503
+ const rangeFile = path.join(tmp, 'provider-range.txt')
1504
+ fs.writeFileSync(rangeFile, 'A'.repeat(5000) + 'B'.repeat(10) + 'C'.repeat(5000))
1505
+ const range = await provider.readByteRange(rangeFile, { offset: 5000, length: 10 })
1506
+ check(Buffer.from(range).toString() === 'B'.repeat(10), 'N4 readByteRange 返回请求区间')
1507
+
1508
+ // N5 文本上限可配置;range 不受文本上限阻断。
1509
+ const capped = path.join(tmp, 'provider-capped.txt')
1510
+ fs.writeFileSync(capped, 'x'.repeat(2048))
1511
+ let capCode = null
1512
+ try { await provider.readText(capped) } catch (error) { capCode = error?.code }
1513
+ check(capCode === 'FS_TOO_LARGE', 'N5 超过 maxTextBytes 的 readText 被拒绝', String(capCode))
1514
+ const one = await provider.readByteRange(capped, { offset: 0, length: 1 })
1515
+ check(one.length === 1, 'N5 range 读取不受文本上限阻断')
1516
+
1517
+ // N7 版本指纹含 ctime:改中间内容再恢复 mtime,仍必须换版本。
1518
+ const ctimeFile = path.join(tmp, 'provider-ctime.txt')
1519
+ const ctimeText = 'a'.repeat(50000) + 'X'.repeat(1000) + 'a'.repeat(50000)
1520
+ fs.writeFileSync(ctimeFile, ctimeText)
1521
+ const ctimeBefore = provider.stat(ctimeFile)
1522
+ const ctimeStat = fs.statSync(ctimeFile)
1523
+ fs.writeFileSync(ctimeFile, ctimeText.replace('X'.repeat(1000), 'Y'.repeat(1000)))
1524
+ fs.utimesSync(ctimeFile, ctimeStat.atime, ctimeStat.mtime)
1525
+ const ctimeAfter = provider.stat(ctimeFile)
1526
+ check(ctimeBefore.version !== ctimeAfter.version, 'N7 ctime 使「改中间 + 恢复 mtime」换版本', `${ctimeBefore.version} -> ${ctimeAfter.version}`)
1527
+
1528
+ // N6 观测缓存权限(POSIX):源码片段不能落成 0644。
1529
+ const obs = path.join(tmp, 'provider-observations.json')
1530
+ const store = createDiskStore(obs)
1531
+ store.remember('source.js', 'v1', 'const secret = 1\n', false)
1532
+ check(fs.existsSync(obs), 'N6 观测缓存落盘')
1533
+ if (path.sep === '\\') {
1534
+ console.log('⊘ N6 cache mode:Windows 不承载 POSIX mode,跳过')
1535
+ } else {
1536
+ check((fs.statSync(obs).mode & 0o777) === 0o600, 'N6 观测缓存为 0600', (fs.statSync(obs).mode & 0o777).toString(8))
1537
+ }
1538
+ }
1539
+
1540
+ say('O 写事务:journal / undo / 多目标回滚')
1541
+ {
1542
+ const txFile = path.join(tmp, 'tx-single.txt')
1543
+ const txOriginal = 'one\ntwo\n'
1544
+ fs.writeFileSync(txFile, txOriginal)
1545
+ const txEdit = json(run(['basetools', 'edit', '--file', txFile, '--lines', '1', '--replace', 'ONE', '--transaction']))
1546
+ check(txEdit.ok === true && typeof txEdit.result?.transaction?.id === 'string', 'O1 单目标编辑返回 transaction id', JSON.stringify(txEdit.result?.transaction ?? txEdit.code))
1547
+ check(fs.readFileSync(txFile, 'utf8') === 'ONE\ntwo\n', 'O1 编辑已落盘')
1548
+ const txId = txEdit.result?.transaction?.id
1549
+ const txUndo = json(run(['basetools', 'undo', '--id', String(txId)]))
1550
+ check(txUndo.ok === true && txUndo.status === 'undone', 'O2 undo 成功', JSON.stringify(txUndo))
1551
+ check(fs.readFileSync(txFile, 'utf8') === txOriginal, 'O2 undo 恢复原始字节')
1552
+
1553
+ const a = path.join(tmp, 'tx-a.txt')
1554
+ const b = path.join(tmp, 'tx-b.txt')
1555
+ fs.writeFileSync(a, 'alpha\n')
1556
+ fs.writeFileSync(b, 'beta\n')
1557
+ const targets = [
1558
+ { file_path: a, hunks: [{ lines: [1, 1], replace: 'ALPHA' }] },
1559
+ { file_path: b, hunks: [{ lines: [1, 1], replace: 'BETA' }] },
1560
+ ]
1561
+ const multi = json(run(['basetools', 'edit', '--targets', JSON.stringify(targets), '--transaction']))
1562
+ check(multi.ok === true && typeof multi.transaction?.id === 'string', 'O3 多目标编辑返回 transaction id', JSON.stringify(multi.transaction ?? multi.code))
1563
+ check(fs.readFileSync(a, 'utf8') === 'ALPHA\n' && fs.readFileSync(b, 'utf8') === 'BETA\n', 'O3 两个文件都已落盘')
1564
+ const multiUndo = json(run(['basetools', 'undo', '--id', String(multi.transaction?.id)]))
1565
+ check(multiUndo.ok === true && multiUndo.restored === 2, 'O4 多目标 undo 恢复两个文件', JSON.stringify(multiUndo))
1566
+ check(fs.readFileSync(a, 'utf8') === 'alpha\n' && fs.readFileSync(b, 'utf8') === 'beta\n', 'O4 两个文件都回到原始字节')
1567
+
1568
+ const list = json(run(['basetools', 'transactions']))
1569
+ check(list.ok === true && Array.isArray(list.transactions) && list.transactions.length > 0, 'O5 transactions 列出当前工作区事务')
1570
+
1571
+ // 事务后又被改过 ⇒ 默认拒绝 undo,force 才覆盖
1572
+ const guardFile = path.join(tmp, 'tx-guard.txt')
1573
+ fs.writeFileSync(guardFile, 'v0\n')
1574
+ const guardEdit = json(run(['basetools', 'edit', '--file', guardFile, '--lines', '1', '--replace', 'v1', '--transaction']))
1575
+ const guardId = guardEdit.result?.transaction?.id
1576
+ fs.writeFileSync(guardFile, 'external\n')
1577
+ const blocked = json(run(['basetools', 'undo', '--id', String(guardId)]))
1578
+ check(blocked.ok === false && blocked.code === 'TRANSACTION_STALE', 'O6 事务后文件被外部改过 ⇒ undo 默认拒绝', JSON.stringify({ code: blocked.code, error: blocked.error }))
1579
+ const forced = json(run(['basetools', 'undo', '--id', String(guardId), '--force']))
1580
+ check(forced.ok === true && fs.readFileSync(guardFile, 'utf8') === 'v0\n', 'O6 force 后仍可恢复')
1581
+
1582
+ // O7 多目标:一个成功、一个写失败 ⇒ 成功那个也必须回滚
1583
+ const okFile = path.join(tmp, 'tx-rollback-ok.txt')
1584
+ const hardBase = path.join(tmp, 'tx-rollback-hard-base.txt')
1585
+ const hardFile = path.join(tmp, 'tx-rollback-hard.txt')
1586
+ fs.writeFileSync(okFile, 'alpha\n')
1587
+ fs.writeFileSync(hardBase, 'beta\n')
1588
+ let hardlinkOk = false
1589
+ try { fs.linkSync(hardBase, hardFile); hardlinkOk = true } catch { /* 受限 FS */ }
1590
+ if (!hardlinkOk) {
1591
+ console.log('⊘ O7 hardlink:本机不允许创建硬链接,跳过')
1592
+ } else {
1593
+ const rollbackTargets = [
1594
+ { file_path: okFile, hunks: [{ lines: [1, 1], replace: 'ALPHA' }] },
1595
+ { file_path: hardFile, hunks: [{ lines: [1, 1], replace: 'BETA' }] },
1596
+ ]
1597
+ const rollback = json(run(['basetools', 'edit', '--targets', JSON.stringify(rollbackTargets), '--transaction']))
1598
+ check(rollback.ok === false && rollback.transaction?.status === 'rolled_back', 'O7 多目标部分失败 ⇒ transaction rolled_back', JSON.stringify({ ok: rollback.ok, tx: rollback.transaction, rollback: rollback.rollback }))
1599
+ check(rollback.rollback?.restored === 1 && fs.readFileSync(okFile, 'utf8') === 'alpha\n', 'O7 已落地的那个文件被恢复')
1600
+ check(fs.readFileSync(hardBase, 'utf8') === 'beta\n' && fs.readFileSync(hardFile, 'utf8') === 'beta\n', 'O7 硬链接目标未被改动')
1601
+ // 回执必须等于**回滚后的最终磁盘状态**,不能继续报 landed/applied。
1602
+ check(rollback.applied === 0 && rollback.landed_paths?.length === 0 && rollback.unchanged === true, 'O7 回滚后 applied=0 / landed_paths=[] / unchanged=true', JSON.stringify({ applied: rollback.applied, landed_paths: rollback.landed_paths, unchanged: rollback.unchanged }))
1603
+ check(rollback.rolled_back === true && rollback.edits?.filter((e) => e.outcome === 'rolled_back').every((e) => e.landed === false), 'O7 逐项 outcome=rolled_back 且 landed=false', JSON.stringify(rollback.edits?.map((e) => ({ outcome: e.outcome, landed: e.landed }))))
1604
+ check(!/applied [1-9]|landed [1-9]/.test(rollback.summary ?? ''), 'O7 汇总行不再出现 applied N / landed N', rollback.summary)
1605
+
1606
+ // 不带 --transaction 也必须能看出“已回滚”:回执事实不依赖是否索要事务 id。
1607
+ // 用一组**新文件**:上一轮回滚后观测账本里还留着“写下去的那版”,同一文件立刻重试会先被
1608
+ // 区域闸拦下(那是另一条保护,别把它混进回执断言)。
1609
+ const okFile2 = path.join(tmp, 'tx-rollback-ok2.txt')
1610
+ const hardBase2 = path.join(tmp, 'tx-rollback-hard-base2.txt')
1611
+ const hardFile2 = path.join(tmp, 'tx-rollback-hard2.txt')
1612
+ fs.writeFileSync(okFile2, 'alpha\n')
1613
+ fs.writeFileSync(hardBase2, 'beta\n')
1614
+ fs.linkSync(hardBase2, hardFile2)
1615
+ const rollbackTargets2 = [
1616
+ { file_path: okFile2, hunks: [{ lines: [1, 1], replace: 'ALPHA' }] },
1617
+ { file_path: hardFile2, hunks: [{ lines: [1, 1], replace: 'BETA' }] },
1618
+ ]
1619
+ const rollbackNoTx = json(run(['basetools', 'edit', '--targets', JSON.stringify(rollbackTargets2)]))
1620
+ check(rollbackNoTx.ok === false && rollbackNoTx.rollback?.status === 'rolled_back' && rollbackNoTx.rolled_back === true, 'O7 include_transaction=false 仍返回 rollback/rolled_back', JSON.stringify({ rollback: rollbackNoTx.rollback, rolled_back: rollbackNoTx.rolled_back }))
1621
+ check(rollbackNoTx.applied === 0 && rollbackNoTx.landed_paths?.length === 0 && fs.readFileSync(okFile2, 'utf8') === 'alpha\n', 'O7 不索要事务 id 时回执仍与最终磁盘一致', JSON.stringify({ applied: rollbackNoTx.applied, landed_paths: rollbackNoTx.landed_paths }))
1622
+ }
1623
+
1624
+ // O8 锁:同一工作区已有写事务时,第二个编辑必须超时拒绝
1625
+ const lockFile = path.join(tmp, 'tx-lock.txt')
1626
+ fs.writeFileSync(lockFile, 'lock\n')
1627
+ const previousCache = process.env.AURA_CACHE_DIR
1628
+ process.env.AURA_CACHE_DIR = cache
1629
+ try {
1630
+ await withWorkspaceLock(tmp, async () => {
1631
+ const res = spawnSync(process.execPath, [bin, 'basetools', 'edit', '--file', lockFile, '--lines', '1', '--replace', 'LOCK'], {
1632
+ cwd: tmp,
1633
+ encoding: 'utf8',
1634
+ env: { ...env, AURA_LOCK_TIMEOUT_MS: '200' },
1635
+ })
1636
+ const locked = json(res)
1637
+ check(locked.ok === false && locked.code === 'WORKSPACE_LOCKED', 'O8 工作区锁冲突返回 WORKSPACE_LOCKED', JSON.stringify({ code: locked.code, error: locked.error }))
1638
+ check(fs.readFileSync(lockFile, 'utf8') === 'lock\n', 'O8 锁冲突时磁盘未变')
1639
+ })
1640
+ } finally {
1641
+ if (previousCache === undefined) delete process.env.AURA_CACHE_DIR
1642
+ else process.env.AURA_CACHE_DIR = previousCache
1643
+ }
1644
+ }
1645
+
1646
+ console.log(`\n--- 独立面:${pass} 通过 / ${fail} 失败 ---`)
1647
+ fs.rmSync(tmp, { recursive: true, force: true })
1648
+ process.exit(fail === 0 ? 0 : 1)
1649
+