aura-basetools 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -2
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/bin/aura-mcp.mjs +424 -0
- package/bin/aura.mjs +396 -0
- package/lib/fs-provider.mjs +355 -0
- package/lib/observation-store.mjs +236 -0
- package/lib/ops.mjs +498 -0
- package/lib/transaction-store.mjs +350 -0
- package/lib/workspace-lock.mjs +130 -0
- package/locator-lab/anchor-core.mjs +586 -0
- package/locator-lab/args.mjs +491 -0
- package/locator-lab/handle-core.mjs +207 -0
- package/locator-lab/locator-api.mjs +921 -0
- package/locator-lab/read-core.mjs +3013 -0
- package/locator-lab/repo-map.mjs +768 -0
- package/locator-lab/struct-core.mjs +3026 -0
- package/locator-lab/write-path.mjs +3446 -0
- package/package.json +50 -4
- package/test/standalone-test.mjs +1575 -0
package/lib/ops.mjs
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ops.mjs —— CLI 与 MCP 两个入口共用的**一次读 / 一次改**。
|
|
3
|
+
*
|
|
4
|
+
* 两个入口只差参数怎么来(argv / JSON-RPC),执行完全一致;这样"服务化"不会变成两套行为。
|
|
5
|
+
* 引擎(`locator-lab/`)不认框架也不认 OS,本文件只做两件宿主该做的事:
|
|
6
|
+
* 1. 把观测缓存换成**落盘**实现(短进程也要有"上次观测"这个基准);
|
|
7
|
+
* 2. 提供 `node:fs` 版的 provider(逐字节写 + 本机 CAS + 原子 rename)。
|
|
8
|
+
*
|
|
9
|
+
* 本文件同时是**引擎位置的唯一解析点**(见下方 `engineBase`):CLI 与 MCP 都从这里拿引擎绑定
|
|
10
|
+
* (`flatHunk` 也从这里出去),于是「引擎在哪」这个问题在整棵树里只有一个答案。
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { createNodeFsProvider, DEFAULT_MAX_TEXT_BYTES } from './fs-provider.mjs'
|
|
15
|
+
import { withWorkspaceLock } from './workspace-lock.mjs'
|
|
16
|
+
import { createTransaction, listTransactions, undoTransaction } from './transaction-store.mjs'
|
|
17
|
+
import { createDiskStore } from './observation-store.mjs'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ── 引擎位置:两种布局各一个候选,选一次之后就冻住 ─────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* 为什么不能只写一个相对路径:引擎相对本文件的位置在两种布局里不同。
|
|
23
|
+
* src 布局:本文件 = `src/aura-standalone/lib/ops.mjs`
|
|
24
|
+
* 引擎 = `src/locator-lab/` ⇒ 候选 `../../locator-lab/`
|
|
25
|
+
* 摊平布局:本文件 = `dist/aura-basetools/lib/ops.mjs`
|
|
26
|
+
* 引擎 = `dist/aura-basetools/locator-lab/`(`lib/` 的同层)⇒ 候选 `../locator-lab/`
|
|
27
|
+
*(相对 URL 是相对**本文件所在目录**解析的:`'./x'` 与 `'../x'` 差的就是这一层。)
|
|
28
|
+
*
|
|
29
|
+
* 这不是「少写了一个 `..`」那种笔误:两个布局要求的层数**本来就不同**。从前两处写的是
|
|
30
|
+
* `'../locator-lab/read-core.mjs'`,只在摊平布局下成立;src 布局下它解析成
|
|
31
|
+
* `src/aura-standalone/locator-lab/read-core.mjs`(不存在),CLI 子进程一启动就是
|
|
32
|
+
* ERR_MODULE_NOT_FOUND(`src/aura-standalone/test/standalone-test.mjs` 23 条全红就是这么来的)。
|
|
33
|
+
*
|
|
34
|
+
* 为什么另一个布局也成立(两条要一起看,否则等于换个布局照样红):
|
|
35
|
+
* · 引擎在 src 布局下位于**包根外面**、在摊平布局下位于**包根里面**,是同一个相对路径
|
|
36
|
+
* 永远够不着的两处;所以判据不能是「路径写得多长」,只能是「这里到底有没有引擎」。
|
|
37
|
+
* · 打包器 `build/make-bundle.mjs` 的摊平规则是 `aura-standalone/{bin,lib,test}` → `{bin,lib,test}`,
|
|
38
|
+
* 只压掉 `aura-standalone/` 这一层,`bin/`、`lib/`、`locator-lab/` 三者同时上移一层;
|
|
39
|
+
* 它们彼此的**相对位置一个都没动**,所以 `bin/aura.mjs` → `../lib/ops.mjs` 这类同层引用
|
|
40
|
+
* 天然两边都成立,需要重新定位的只有「引擎」这一个目标(也就是本文件这一段)。
|
|
41
|
+
*
|
|
42
|
+
* 为什么不是别的三条路(都想过,都不如这条):
|
|
43
|
+
* · 只修相对路径:做不到。同一个字面量在两种布局里指向两个不同目录,几何上无解。
|
|
44
|
+
* 除非改摊平规则去迁就——那要动 `build/make-bundle.mjs`(本单元写范围之外),且会把
|
|
45
|
+
* 「包内 bin/、lib/ 与引擎同层」这个既有事实反过来变成「引擎必须多一层」。
|
|
46
|
+
* · `#engine/*` subpath imports:要包内在位、且带 `imports` 映射的 `package.json`。
|
|
47
|
+
* src 布局那份即便放在 `aura-standalone/package.json`,打包器也只复制 `{bin,lib,test}` 里的 .mjs,
|
|
48
|
+
* 产物里不会有它 ⇒ 摊平布局反而解析不到(把一个绿的布局换红)。要让它成立就得改打包器。
|
|
49
|
+
* · 按 `package.json` 的归属分岔:同样依赖那份清单在位,同样绕不开改打包器。
|
|
50
|
+
*
|
|
51
|
+
* 剩下的选择只在「怎么判」上,而判据只有一条:候选里有没有引擎本体 `read-core.mjs`。
|
|
52
|
+
* ① 判据是引擎自带的事实,不是环境变量、cwd、盘符或进程参数 —— 同一份代码在任意机器、任意
|
|
53
|
+
* cwd、Windows/macOS/Linux 上得到同一个答案(本文件不按操作系统分岔,打包器的 host 档守卫也在守这条);
|
|
54
|
+
* ② 候选只有两个,是两种布局的定义值,不是搜索:选中即冻结成常量 `engineBase`,
|
|
55
|
+
* 运行期不再有任何按调用分岔的路径(每次 read/edit 走的都是同一套 import 绑定);
|
|
56
|
+
* ③ 两个都不中时不静默兜底:报错把两个候选连同判据一起列出来,谁都能一眼看出布局坏了。
|
|
57
|
+
*/
|
|
58
|
+
const ENGINE_MARKER = 'read-core.mjs'
|
|
59
|
+
// 顺序无关:两个候选互不包含。摊平那份排前面只是为了让"包里那一份"优先。
|
|
60
|
+
const ENGINE_CANDIDATES = ['../locator-lab/', '../../locator-lab/']
|
|
61
|
+
const engineBase = (() => {
|
|
62
|
+
for (const candidate of ENGINE_CANDIDATES) {
|
|
63
|
+
const base = new URL(candidate, import.meta.url)
|
|
64
|
+
if (fs.existsSync(fileURLToPath(new URL(ENGINE_MARKER, base)))) return base
|
|
65
|
+
}
|
|
66
|
+
throw new Error(
|
|
67
|
+
`aura: 找不到引擎(判据:候选目录里有 ${ENGINE_MARKER})。` +
|
|
68
|
+
`从 ${fileURLToPath(import.meta.url)} 试过:${ENGINE_CANDIDATES.map((c) => fileURLToPath(new URL(c, import.meta.url))).join('、')}。` +
|
|
69
|
+
`这通常意味着打包摊平规则改了、或 aura-standalone/ 被整体挪出了原位。`,
|
|
70
|
+
)
|
|
71
|
+
})()
|
|
72
|
+
const engineUrl = (name) => new URL(name, engineBase).href
|
|
73
|
+
|
|
74
|
+
// 引擎(`locator-lab/`)不认框架也不认 OS;这里按内容取模块,不按写死的相对深度取。
|
|
75
|
+
const readCore = await import(engineUrl('read-core.mjs'))
|
|
76
|
+
const repoMap = await import(engineUrl('repo-map.mjs'))
|
|
77
|
+
const engineArgs = await import(engineUrl('args.mjs'))
|
|
78
|
+
const writePath = await import(engineUrl('write-path.mjs'))
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 引擎公开面的转发:绑定转发,不是「把命名空间塞给调用方」——
|
|
82
|
+
* 静态 import 与动态 import 在 ESM 缓存里同键,所以这里转出去的 `readWith`、`flatHunk`
|
|
83
|
+
* 与 DSH 壳静态引到的是同一个值、同一份模块实例。
|
|
84
|
+
*
|
|
85
|
+
* `flatHunk` 一并转发:CLI 需要的容错规则是引擎里那一份,从这里出去就少一个
|
|
86
|
+
* 「谁去定位引擎」的地方(少一处 = 少一处会在另一种布局下红的引用)。
|
|
87
|
+
*/
|
|
88
|
+
export const readWith = readCore.readWith
|
|
89
|
+
export const setObservationStore = readCore.setObservationStore
|
|
90
|
+
export const BYTE_ORDER_MARK_BYTES = readCore.BYTE_ORDER_MARK_BYTES
|
|
91
|
+
export const collectRepoMap = repoMap.collectRepoMap
|
|
92
|
+
export const LINE_ADDRESS_HINT = engineArgs.LINE_ADDRESS_HINT
|
|
93
|
+
export const asLinePair = engineArgs.asLinePair
|
|
94
|
+
export const gaveLineAddress = engineArgs.gaveLineAddress
|
|
95
|
+
export const inferReadMode = engineArgs.inferReadMode
|
|
96
|
+
export const lineRangeOf = engineArgs.lineRangeOf
|
|
97
|
+
export const flatHunk = engineArgs.flatHunk
|
|
98
|
+
export const applyEditToProvider = writePath.applyEditToProvider
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* ── 多目标(fs-contract-v2)在独立面上的接法:CLI 与 MCP 都从这里拿引擎绑定 ──────
|
|
102
|
+
*
|
|
103
|
+
* 与上面的 `flatHunk` 同一条纪律:**“引擎在哪”只有一个答案**。两个 bin 里各写一句
|
|
104
|
+
* `import('../locator-lab/…')` 就等于又长出两个只在摊平布局下成立的引用(见 `engineBase`)。
|
|
105
|
+
*
|
|
106
|
+
* 形状判据(数组 / 上限 32 / 空批 / 逐项路径 / 与单目标形状互斥)**一条都不在这里**:
|
|
107
|
+
* 它就是 `locator-lab/args.mjs` 里那两份(`assertReadTargets` ↔ `assertEditTargets`,
|
|
108
|
+
* 与两个 DSH 壳共用同一份)。调用面只负责把参数递对。
|
|
109
|
+
*
|
|
110
|
+
* `assertTargetShape`(两个都没给)与 `assertKnownArgs`(顶层不认识的键)同样从引擎转出:
|
|
111
|
+
* 前者是“必居其一”的完整判据(壳里原来那句 `file_path must be a non-empty string` 是它的半个身子),
|
|
112
|
+
* 后者防的是“拼错的键被静默忽略 ⇒ 调用方以为成功”。
|
|
113
|
+
*/
|
|
114
|
+
export const readManyWith = readCore.readManyWith
|
|
115
|
+
export const applyEditsToProvider = writePath.applyEditsToProvider
|
|
116
|
+
export const assertReadTargets = engineArgs.assertReadTargets
|
|
117
|
+
export const assertEditTargets = engineArgs.assertEditTargets
|
|
118
|
+
export const assertTargetShape = engineArgs.assertTargetShape
|
|
119
|
+
export const assertKnownArgs = engineArgs.assertKnownArgs
|
|
120
|
+
|
|
121
|
+
// ── C55:非 UTF-8 文件 ⇒ 可执行的拒绝 ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 这次失败是不是「这份文件不是 UTF-8 文本」?
|
|
125
|
+
*
|
|
126
|
+
* 两种后端形状都要认(都是实测出来的):
|
|
127
|
+
* · `ERR_ENCODING_INVALID_ENCODED_DATA` —— `new TextDecoder('utf-8',{fatal:true}).decode()`
|
|
128
|
+
* 在裸 Node 侧的原始错误,消息是 `The encoded data was not valid for encoding utf-8`,
|
|
129
|
+
* **不带文件名**(BASE 段的实测读数就是它:CLI 与 MCP 两面都只回这一句);
|
|
130
|
+
* · `FS_NOT_TEXT` + 消息里有 `utf-8` —— `dsh-fs-local` 的 `decodeUtf8` 把上面那个 TypeError
|
|
131
|
+
* 翻成了 `cannot read "<路径>": invalid UTF-8 text`。
|
|
132
|
+
*
|
|
133
|
+
* 为什么不能只看码:`FS_NOT_TEXT` 同时也用于「含 NUL 的二进制文件」(消息是 `binary file`,
|
|
134
|
+
* 见 dsh-fs-local:355 与本包 fs-provider:97)。那是另一件事,它的说法本来就够
|
|
135
|
+
* (有路径、有原因),不该被这一段的文案盖住。
|
|
136
|
+
*
|
|
137
|
+
* 反过来,判据也**不能**用「解码失败」去反推:本函数只在**已经捕获到**错误时被调用,
|
|
138
|
+
* 带 BOM 的 UTF-8(`TextDecoder` 默认 `ignoreBOM:false`,只吃掉第一个 BOM)根本走不到这里,
|
|
139
|
+
* 因此不会被误判成非 UTF-8。
|
|
140
|
+
*/
|
|
141
|
+
function isNonUtf8TextFailure(error) {
|
|
142
|
+
if (error === null || typeof error !== 'object') return false
|
|
143
|
+
if (error.code === 'ERR_ENCODING_INVALID_ENCODED_DATA') return true
|
|
144
|
+
const message = typeof error.message === 'string' ? error.message : ''
|
|
145
|
+
return /invalid utf-?8|not valid for encoding utf-?8/i.test(message)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 「这份文件不是 UTF-8 文本」的拒绝回执:**三件事必须同一次说清**。
|
|
150
|
+
*
|
|
151
|
+
* ① 是哪个文件——路径原样带引号,可以直接复制去查;
|
|
152
|
+
* ② 为什么读不了——它的字节不是 UTF-8 文本,因此这里根本没有可读/可寻址的文本;并点明
|
|
153
|
+
* 这是**环境级拒绝**,换参数重发不可能成功(否则调用方会去改地址、改模式,
|
|
154
|
+
* 而它真正要做的只是把文件转成 UTF-8);
|
|
155
|
+
* ③ 下一步怎么办——先确认编码、再转成 UTF-8、再调一次;并明说**这次一个字节都没改**。
|
|
156
|
+
*
|
|
157
|
+
* 独立面(CLI / MCP)的口径与 DSH 壳一致:留在**失败通道**里(`ok:false` + 非零退出),
|
|
158
|
+
* 不伪装成结果通道的「判定不出你的意图」——那是两件事。
|
|
159
|
+
*
|
|
160
|
+
* 同一段判据与同一句话在三个面上各有一份(本波写范围不共享模块):
|
|
161
|
+
* aura-read/index.js · aura-edit/index.js · aura-standalone/lib/ops.mjs。
|
|
162
|
+
* 抄漏一处由 `locator-lab/encoding-refusal-test.mjs` 段C 逐处抽出来比对 ⇒ 会红。
|
|
163
|
+
*/
|
|
164
|
+
function nonUtf8RefusalMessage(verb, displayPath, tool) {
|
|
165
|
+
return (
|
|
166
|
+
`cannot ${verb} "${displayPath}": this file is not UTF-8 text.\n` +
|
|
167
|
+
`why: its bytes are not a UTF-8 encoding of text, so there is no text here to ${verb === 'read' ? 'read' : 'address'}. ` +
|
|
168
|
+
`This is an environment-level refusal, not a problem with the arguments you passed — re-sending the same call cannot succeed.\n` +
|
|
169
|
+
`next: (1) find out which encoding the file really uses — look at its bytes (a hex view) or at your editor's encoding indicator (\`file -i\` where available); ` +
|
|
170
|
+
`(2) convert it to UTF-8 (for example \`iconv -f GBK -t UTF-8\`, or the editor's "save with encoding: UTF-8"); ` +
|
|
171
|
+
`(3) then call ${tool} again on the converted file.\n` +
|
|
172
|
+
`nothing was written: this call did not touch the file — its bytes on disk are exactly as they were.`
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 解析成绝对路径;解析本身失败就退回调用方写的那一份。
|
|
178
|
+
* 拒绝里的路径要是**能直接拿去用**的那一个(相对路径要先知道相对于谁),
|
|
179
|
+
* 但也不能拿一个路径解析失败去掩盖原始的编码错误。
|
|
180
|
+
*/
|
|
181
|
+
function resolvedOrRaw(provider, file) {
|
|
182
|
+
try {
|
|
183
|
+
return provider.resolve(file)
|
|
184
|
+
} catch {
|
|
185
|
+
return String(file)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 把错误对象上的**结构化字段**转进回执。
|
|
191
|
+
*
|
|
192
|
+
* 为什么必须有这一层:`PatchError` 用 `Object.assign(this, extra)` 把证据挂在错误对象自身
|
|
193
|
+
* (`candidates`、`minimal_next`、`others`、`write_verify`、`actual`…),而 CLI/MCP 的回执是
|
|
194
|
+
* 调用方**唯一**的证据来源。原先只转发一个全仓没人设过的 `detail`,于是这些字段在 CLI/MCP 上全丢:
|
|
195
|
+
* 人类可读的 message 还在,机器可用的部分没了(字节靶场报的 F5)。
|
|
196
|
+
*
|
|
197
|
+
* 用**排除法**而不是白名单:以后有人往 `extra` 里加字段,这里自动带上,不会再漏一次。
|
|
198
|
+
*/
|
|
199
|
+
const ERROR_NOISE = new Set(['code', 'name', 'message', 'stack'])
|
|
200
|
+
export function errorEvidence(error) {
|
|
201
|
+
if (error === null || typeof error !== 'object') return {}
|
|
202
|
+
const out = {}
|
|
203
|
+
for (const key of Object.keys(error)) {
|
|
204
|
+
if (ERROR_NOISE.has(key)) continue
|
|
205
|
+
out[key] = error[key]
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let installed = false
|
|
211
|
+
|
|
212
|
+
/** 装一次即可:把引擎的观测存储换成落盘实现。 */
|
|
213
|
+
export function installDiskObservations() {
|
|
214
|
+
if (installed) return
|
|
215
|
+
if (process.env.AURA_NO_OBSERVATION_CACHE === '1') {
|
|
216
|
+
installed = true
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
setObservationStore(createDiskStore())
|
|
220
|
+
installed = true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function openProvider(resolveRoot) {
|
|
224
|
+
installDiskObservations()
|
|
225
|
+
return createNodeFsProvider({ resolveRoot })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 与提供方同形地读一份文件:fatal 解码 + 第一个 BOM 不进字符串;二进制拒绝。 */
|
|
229
|
+
export function readFileBytes(displayPath) {
|
|
230
|
+
let stat
|
|
231
|
+
try {
|
|
232
|
+
stat = fs.statSync(displayPath)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const code = typeof error?.code === 'string' ? error.code : 'FS_IO_ERROR'
|
|
235
|
+
return { error: { ok: false, error: `cannot read "${displayPath}": ${error?.message ?? code}`, code } }
|
|
236
|
+
}
|
|
237
|
+
const configuredMax = Number(process.env.AURA_MAX_FILE_BYTES)
|
|
238
|
+
const maxTextBytes = Number.isSafeInteger(configuredMax) && configuredMax > 0 ? configuredMax : DEFAULT_MAX_TEXT_BYTES
|
|
239
|
+
if (stat.isFile() && stat.size > maxTextBytes) {
|
|
240
|
+
return {
|
|
241
|
+
error: {
|
|
242
|
+
ok: false,
|
|
243
|
+
error: `cannot read "${displayPath}": file is ${stat.size} bytes, over the ${maxTextBytes}-byte text limit`,
|
|
244
|
+
code: 'FS_TOO_LARGE',
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const bytes = fs.readFileSync(displayPath)
|
|
249
|
+
if (bytes.includes(0)) {
|
|
250
|
+
return { error: { ok: false, error: `cannot read "${displayPath}": binary file`, code: 'FS_NOT_TEXT' } }
|
|
251
|
+
}
|
|
252
|
+
const byteOrderMark = bytes.length >= BYTE_ORDER_MARK_BYTES.length && BYTE_ORDER_MARK_BYTES.every((b, i) => bytes[i] === b)
|
|
253
|
+
/**
|
|
254
|
+
* C55:解码失败原来直接抛裸 `TypeError`(`The encoded data was not valid for encoding utf-8`),
|
|
255
|
+
* 一路浮到 CLI/MCP 就只剩这一句——不说哪个文件、不说下一步。现在在这里换成可执行的拒绝。
|
|
256
|
+
* 码**原样保留**(`ERR_ENCODING_INVALID_ENCODED_DATA`):调用方与既有判据靠它区分
|
|
257
|
+
* 「不是 UTF-8」与别的工具级故障(`range-bytes-test` 段D 就钉着它)。
|
|
258
|
+
*/
|
|
259
|
+
try {
|
|
260
|
+
return { source: new TextDecoder('utf-8', { fatal: true }).decode(bytes), byteOrderMark }
|
|
261
|
+
} catch (error) {
|
|
262
|
+
return {
|
|
263
|
+
error: {
|
|
264
|
+
ok: false,
|
|
265
|
+
error: nonUtf8RefusalMessage('read', displayPath, 'aura_read'),
|
|
266
|
+
code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 读一次(含令牌与观测记账)。 */
|
|
273
|
+
/** 读一次(含令牌与观测记账)。抛出与 `editEnvelope` 对称地变成**回包**:
|
|
274
|
+
* 少这一层时,MCP 客户端会因为“没有回包”永久等待(实测:参数名写成 file,只留一行 stderr)。 */
|
|
275
|
+
/**
|
|
276
|
+
* `async`:目录路径要走 `collectRepoMap`(它要 await 列目录与读头部)。
|
|
277
|
+
* 两个调用方(CLI `bin/aura.mjs`、MCP `bin/aura-mcp.mjs`)本来就 `await` 它,写法不变。
|
|
278
|
+
*/
|
|
279
|
+
export async function readEnvelope(provider, args) {
|
|
280
|
+
const { file, around, search, since, maxChars } = args
|
|
281
|
+
try {
|
|
282
|
+
// 行区间先收敛:`[412,500]`、`"412,500"`、`412`、`start_line/end_line` 都接受;
|
|
283
|
+
// 给了但解不出来就明说接受哪些形状(静默退回 auto 会让人以为是别的问题)。
|
|
284
|
+
const lines = lineRangeOf(args)
|
|
285
|
+
if (lines === undefined && gaveLineAddress(args)) {
|
|
286
|
+
return { ok: false, error: LINE_ADDRESS_HINT, code: 'READ_BAD_ARGS' }
|
|
287
|
+
}
|
|
288
|
+
const mode = inferReadMode(args, lines)
|
|
289
|
+
const displayPath = provider.resolve(file)
|
|
290
|
+
const info = provider.stat(displayPath)
|
|
291
|
+
if (info === undefined) return { ok: false, error: `cannot read "${file}": not found`, code: 'FS_NOT_FOUND' }
|
|
292
|
+
/**
|
|
293
|
+
* 目录:给一张仓库地图(T26)。判据只有“它是目录”这一条,不需要调用方多填参数。
|
|
294
|
+
*
|
|
295
|
+
* 引擎驱动遍历(`collectRepoMap`),本函数只把 node:fs 侧的两个原始能力接上去——
|
|
296
|
+
* 与 DSH 壳共用同一份判据(忽略规则、候选排序、预算都在引擎里)。
|
|
297
|
+
*/
|
|
298
|
+
if (info.type === 'directory') {
|
|
299
|
+
const env = await collectRepoMap(
|
|
300
|
+
{
|
|
301
|
+
listChildren: (dir) => provider.listDir(dir),
|
|
302
|
+
readHead: async (childTarget, bytes) => {
|
|
303
|
+
const head = await provider.readByteRange(childTarget, { offset: 0, length: bytes })
|
|
304
|
+
return new TextDecoder('utf-8').decode(head)
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
displayPath,
|
|
308
|
+
{ rootPath: displayPath },
|
|
309
|
+
)
|
|
310
|
+
return { ok: true, ...env }
|
|
311
|
+
}
|
|
312
|
+
const got = readFileBytes(displayPath)
|
|
313
|
+
if (got.error !== undefined) return got.error
|
|
314
|
+
const env = readWith(got.source, {
|
|
315
|
+
mode,
|
|
316
|
+
around,
|
|
317
|
+
// 读侧只认数组:这里从收敛层的 {start,end} 转一次(之前 CLI/MCP 直接传 {start,end},
|
|
318
|
+
// 于是 `aura read --lines 1,2` 会报 READ_NO_RANGE —— 顺手修掉的既有 bug)。
|
|
319
|
+
lines: asLinePair(lines),
|
|
320
|
+
search,
|
|
321
|
+
since,
|
|
322
|
+
maxChars,
|
|
323
|
+
byteOrderMark: got.byteOrderMark,
|
|
324
|
+
filePath: displayPath,
|
|
325
|
+
})
|
|
326
|
+
return { ok: true, ...env }
|
|
327
|
+
} catch (error) {
|
|
328
|
+
/**
|
|
329
|
+
* C55:非 UTF-8 也走这条 catch(解码确实发生在这里的上游)。单独分一支是必要的:
|
|
330
|
+
* 通用的那一支只转发原始 message,而这里要的是**换一句话**。
|
|
331
|
+
*/
|
|
332
|
+
if (isNonUtf8TextFailure(error)) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: nonUtf8RefusalMessage('read', resolvedOrRaw(provider, file), 'aura_read'),
|
|
336
|
+
code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
|
|
337
|
+
...errorEvidence(error),
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
error: String(error?.message ?? error),
|
|
343
|
+
code: error?.code ?? 'IO_ERROR',
|
|
344
|
+
...errorEvidence(error),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 改一次(规划 → 陈旧闸门 → 逐字节写 + CAS → 回读校验 → 刷新观测)。 */
|
|
350
|
+
async function editEnvelopeInner(provider, { file, hunks, dryRun, create }) {
|
|
351
|
+
try {
|
|
352
|
+
const result = await applyEditToProvider(provider, {
|
|
353
|
+
displayPath: provider.resolve(file),
|
|
354
|
+
hunks,
|
|
355
|
+
dryRun: dryRun === true,
|
|
356
|
+
...(typeof create === 'string' ? { create } : {}),
|
|
357
|
+
})
|
|
358
|
+
return { ok: true, result }
|
|
359
|
+
} catch (error) {
|
|
360
|
+
/**
|
|
361
|
+
* C55:写路径上的非 UTF-8 同样要可执行。它抛在引擎 `applyEditToProvider` 的第一步
|
|
362
|
+
* (读不到文本就无法规划 hunk),因此**磁盘上什么都没发生过**——回执里那句
|
|
363
|
+
* 「nothing was written」在写侧不是承诺,而是这条路上不可能有写。
|
|
364
|
+
*/
|
|
365
|
+
if (isNonUtf8TextFailure(error)) {
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
error: nonUtf8RefusalMessage('patch', resolvedOrRaw(provider, file), 'aura_edit'),
|
|
369
|
+
code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
|
|
370
|
+
...errorEvidence(error),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
error: String(error?.message ?? error),
|
|
376
|
+
code: error?.code ?? 'IO_ERROR',
|
|
377
|
+
...errorEvidence(error),
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* **多目标写在独立面上的信封**:转发引擎入口,并补一个“整批成没成”的位。
|
|
384
|
+
*
|
|
385
|
+
* 为什么这个位必须在**这里**补(而不是在 `bin/` 里各算一份):引擎的写侧批回执
|
|
386
|
+
* (`mtEnvelope`)刻意没有 `ok` —— DSH 壳那边由框架自己的结果形状表达,而 CLI/MCP 要的是
|
|
387
|
+
* 一个能接退出码与 `isError` 的布尔位。两个 bin 各写一遍“哪些 outcome 算成”必然漂开,
|
|
388
|
+
* 而漂开的方向恰好是“CLI 说成了、MCP 说没成”这种没人会发现的分叉。
|
|
389
|
+
*
|
|
390
|
+
* “成”的判据用的是引擎自己的 outcome 词表(`write-path.mjs` 的 `receiptKindOf` / `failureItem`):
|
|
391
|
+
* · `applied` / `created` / `already_applied` / `dry_run` —— 成了(`dry_run` 是“按你说的没写”,
|
|
392
|
+
* 不是失败);
|
|
393
|
+
* · `failed` / `not_applied` —— 没成(`not_applied` 只在同一批里别的目标规划失败时出现,
|
|
394
|
+
* 契约 §3.4:“要么全改、要么一个字节不动”)。
|
|
395
|
+
*
|
|
396
|
+
* 逐项与汇总行**一个字都不动**(`ok` 加在信封最前面,与读侧引擎自己的字段顺序对齐):
|
|
397
|
+
* 引擎怎么算的就怎么回,本函数只补那一个位。
|
|
398
|
+
*
|
|
399
|
+
* @param provider - 与单目标同一条 provider 契约(`stat/readText/readByteRange/writeText`)
|
|
400
|
+
* @param request - `{ targets: [...], dryRun?, identityKey?, signal? }`
|
|
401
|
+
* @returns 引擎的批回执 + `ok`
|
|
402
|
+
*/
|
|
403
|
+
async function editManyEnvelopeInner(provider, request) {
|
|
404
|
+
const batch = await applyEditsToProvider(provider, request)
|
|
405
|
+
const ok = batch.edits.every((edit) => edit.outcome !== 'failed' && edit.outcome !== 'not_applied')
|
|
406
|
+
return { ok, ...batch }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function transactionError(error) {
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
error: String(error?.message ?? error),
|
|
413
|
+
code: error?.code ?? 'IO_ERROR',
|
|
414
|
+
...errorEvidence(error),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 写事务外壳:workspace 锁 + 事务日志 + 失败回滚。
|
|
420
|
+
*
|
|
421
|
+
* 锁只约束遵守本协议的 Aura 进程;事务日志在第一次 writeText 前记原始字节,
|
|
422
|
+
* 失败时按 afterVersion 检查后恢复。不改变引擎的规划/寻址语义。
|
|
423
|
+
*/
|
|
424
|
+
export async function editEnvelope(provider, request) {
|
|
425
|
+
// 事务需要工作区根与真实文件系统;测试/嵌入式 provider 没有 root 时保持原来的引擎行为。
|
|
426
|
+
if (provider.root === undefined) return editEnvelopeInner(provider, request)
|
|
427
|
+
const includeTransaction = request.includeTransaction === true
|
|
428
|
+
try {
|
|
429
|
+
return await withWorkspaceLock(provider.root, async () => {
|
|
430
|
+
const tx = createTransaction(provider.root, 'aura_edit')
|
|
431
|
+
const env = await editEnvelopeInner(tx.proxy(provider), request)
|
|
432
|
+
if (env.ok) {
|
|
433
|
+
try {
|
|
434
|
+
tx.commit()
|
|
435
|
+
} catch (error) {
|
|
436
|
+
const rollback = await tx.rollback(provider)
|
|
437
|
+
error.transaction = tx.summary()
|
|
438
|
+
error.rollback = rollback
|
|
439
|
+
throw error
|
|
440
|
+
}
|
|
441
|
+
return includeTransaction ? { ...env, result: { ...env.result, transaction: tx.summary() } } : env
|
|
442
|
+
}
|
|
443
|
+
const rollback = await tx.rollback(provider)
|
|
444
|
+
return includeTransaction ? { ...env, transaction: tx.summary(), rollback } : env
|
|
445
|
+
})
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return transactionError(error)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 多目标写事务:与单目标共用锁/日志/回滚;批次返回 ok=false 时也回滚。 */
|
|
452
|
+
export async function editManyEnvelope(provider, request) {
|
|
453
|
+
if (provider.root === undefined) return editManyEnvelopeInner(provider, request)
|
|
454
|
+
const includeTransaction = request.includeTransaction === true
|
|
455
|
+
try {
|
|
456
|
+
return await withWorkspaceLock(provider.root, async () => {
|
|
457
|
+
const tx = createTransaction(provider.root, 'aura_edit')
|
|
458
|
+
try {
|
|
459
|
+
const batch = await editManyEnvelopeInner(tx.proxy(provider), request)
|
|
460
|
+
if (batch.ok) {
|
|
461
|
+
try {
|
|
462
|
+
tx.commit()
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const rollback = await tx.rollback(provider)
|
|
465
|
+
error.transaction = tx.summary()
|
|
466
|
+
error.rollback = rollback
|
|
467
|
+
throw error
|
|
468
|
+
}
|
|
469
|
+
return includeTransaction ? { ...batch, transaction: tx.summary() } : batch
|
|
470
|
+
}
|
|
471
|
+
const rollback = await tx.rollback(provider)
|
|
472
|
+
return includeTransaction ? { ...batch, transaction: tx.summary(), rollback } : batch
|
|
473
|
+
} catch (error) {
|
|
474
|
+
const rollback = await tx.rollback(provider)
|
|
475
|
+
error.transaction = tx.summary()
|
|
476
|
+
error.rollback = rollback
|
|
477
|
+
throw error
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
} catch (error) {
|
|
481
|
+
return transactionError(error)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function undoEnvelope(provider, { id, force } = {}) {
|
|
486
|
+
try {
|
|
487
|
+
return {
|
|
488
|
+
ok: true,
|
|
489
|
+
...(await withWorkspaceLock(provider.root, () => undoTransaction(provider.root, provider, { id, force: force === true }))),
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
return transactionError(error)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function transactionsEnvelope(provider) {
|
|
497
|
+
return { ok: true, transactions: listTransactions(provider.root) }
|
|
498
|
+
}
|