aura-basetools 0.13.3 → 0.14.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/README.md +12 -16
- package/bin/aura-mcp.mjs +27 -41
- package/bin/aura.mjs +9 -33
- package/lib/ops.mjs +14 -35
- package/lib/transaction-store.mjs +3 -82
- package/locator-lab/args.mjs +3 -3
- package/locator-lab/locator-api.mjs +10 -172
- package/locator-lab/write-path.mjs +14 -73
- package/package.json +1 -1
- package/test/standalone-test.mjs +6 -64
- package/test/write-test.mjs +3 -18
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# aura-basetools 0.
|
|
1
|
+
# aura-basetools 0.14.0
|
|
2
2
|
|
|
3
|
-
Byte-exact file tools for coding agents: read by intent, edit by
|
|
3
|
+
Byte-exact file tools for coding agents: read by intent, edit by address, and write whole files without corrupting bytes. One engine shared by a standalone CLI and an MCP server.
|
|
4
4
|
|
|
5
|
-
面向 coding agent
|
|
5
|
+
面向 coding agent 的逐字节文件工具:按意图读取、按地址编辑、整文件写入;CLI 与 MCP 共用同一套引擎。
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -21,7 +21,7 @@ The engine is intentionally not a general file manager: it reads and edits UTF-8
|
|
|
21
21
|
### Install
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npm i -g aura-basetools@0.
|
|
24
|
+
npm i -g aura-basetools@0.14.0
|
|
25
25
|
aura --version
|
|
26
26
|
```
|
|
27
27
|
|
|
@@ -31,8 +31,6 @@ aura --version
|
|
|
31
31
|
aura basetools read --file src/app.ts --mode map
|
|
32
32
|
aura basetools edit --file src/app.ts --hunks '[{"target":"main","replace":"..."}]'
|
|
33
33
|
aura basetools write --file generated.ts --content @body.ts
|
|
34
|
-
aura basetools undo
|
|
35
|
-
aura basetools transactions
|
|
36
34
|
aura basetools mcp
|
|
37
35
|
```
|
|
38
36
|
|
|
@@ -77,10 +75,10 @@ The server exposes:
|
|
|
77
75
|
| Tool | Purpose |
|
|
78
76
|
|---|---|
|
|
79
77
|
| `aura_basetools_read` | Intent-based views: `map`, `digest`, `full`, `around`, `lines`, `search`, `changed`, `auto` |
|
|
80
|
-
| `aura_basetools_edit` | Anchored multi-hunk edits; `old` + `replace_all
|
|
78
|
+
| `aura_basetools_edit` | Anchored multi-hunk edits; `old` + `replace_all` |
|
|
81
79
|
| `aura_basetools_write` | Whole-file create/overwrite with read-before-write and read-back verification |
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
|
|
81
|
+
0.14.0 removed the public `undo` / `transactions` tools (0 uses in 642 sessions); failure rollback remains internal and automatic.
|
|
84
82
|
|
|
85
83
|
The older tool names (`aura_read`, `aura_edit`, `aura_write`, ...) are no longer exposed by MCP or DSH.
|
|
86
84
|
|
|
@@ -128,7 +126,7 @@ MIT.
|
|
|
128
126
|
`aura-basetools` 是一个 Node.js(>=18)包,运行时零依赖,提供:
|
|
129
127
|
|
|
130
128
|
- **CLI**:`aura basetools <verb>`
|
|
131
|
-
- **MCP 服务**:stdio
|
|
129
|
+
- **MCP 服务**:stdio 上的三个工具
|
|
132
130
|
- **不依赖 DSH**:DeepSeek Harness 适配器是独立的可选包 `aura-basetools-dsh`
|
|
133
131
|
|
|
134
132
|
它不是通用文件管理器:只处理 UTF-8 文本,保留字节级格式;如果被寻址区域在“授权读取”之后被改动,编辑会被拒绝。
|
|
@@ -136,7 +134,7 @@ MIT.
|
|
|
136
134
|
### 安装
|
|
137
135
|
|
|
138
136
|
```bash
|
|
139
|
-
npm i -g aura-basetools@0.
|
|
137
|
+
npm i -g aura-basetools@0.14.0
|
|
140
138
|
aura --version
|
|
141
139
|
```
|
|
142
140
|
|
|
@@ -146,8 +144,6 @@ aura --version
|
|
|
146
144
|
aura basetools read --file src/app.ts --mode map
|
|
147
145
|
aura basetools edit --file src/app.ts --hunks '[{"target":"main","replace":"..."}]'
|
|
148
146
|
aura basetools write --file generated.ts --content @body.ts
|
|
149
|
-
aura basetools undo
|
|
150
|
-
aura basetools transactions
|
|
151
147
|
aura basetools mcp
|
|
152
148
|
```
|
|
153
149
|
|
|
@@ -192,10 +188,10 @@ npx -y --package aura-basetools aura basetools mcp
|
|
|
192
188
|
| 工具 | 作用 |
|
|
193
189
|
|---|---|
|
|
194
190
|
| `aura_basetools_read` | `map` / `digest` / `full` / `around` / `lines` / `search` / `changed` / `auto` |
|
|
195
|
-
| `aura_basetools_edit` | 多 hunk 锚点编辑;`old` + `replace_all
|
|
191
|
+
| `aura_basetools_edit` | 多 hunk 锚点编辑;`old` + `replace_all` |
|
|
196
192
|
| `aura_basetools_write` | 整文件新建/覆写;覆写默认要求先读,写后回读校验 |
|
|
197
|
-
|
|
198
|
-
|
|
193
|
+
|
|
194
|
+
0.14.0 移除了公开 `undo` / `transactions`(642 个会话中 0 次使用);失败回滚仍是内部自动行为。
|
|
199
195
|
|
|
200
196
|
旧工具名(`aura_read`、`aura_edit`、`aura_write` 等)不再由 MCP/DSH 暴露。
|
|
201
197
|
|
package/bin/aura-mcp.mjs
CHANGED
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
* 为什么是它:Codex / Claude Code / Cursor / Zed 这些 agent 都吃 MCP —— 一次写壳,到处能用,
|
|
6
6
|
* 且**不要求对方装 DSH**(本文件只依赖 node:fs 与本包的引擎)。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* 暴露三个工具:
|
|
9
9
|
* aura_basetools_read 七种取景(map/digest/full/around/lines/search/changed)+ 版本令牌
|
|
10
10
|
* aura_basetools_edit 四层寻址 + expect + dry_run;逐字节写回 + 陈旧区域闸门 + 落盘后回读校验
|
|
11
|
-
* aura_basetools_write 整份写入(对应内置 Write
|
|
12
|
-
* aura_basetools_undo 撤销最近一次/指定写事务(默认拒绝覆盖后续编辑)
|
|
13
|
-
* aura_basetools_transactions 列出当前工作区写事务
|
|
11
|
+
* aura_basetools_write 整份写入(对应内置 Write):新建或整文件替换,同走原子写与回读校验
|
|
14
12
|
*
|
|
15
13
|
* 接法(写进各家的 MCP 配置即可):
|
|
16
14
|
* codex: codex mcp add aura -- npx -y --package aura-basetools aura basetools mcp
|
|
@@ -21,9 +19,9 @@
|
|
|
21
19
|
* `aura-basetools`;npx 在"多个 bin 且没有与包名同名的"时候会拒绝猜,直接报
|
|
22
20
|
* `could not determine executable to run`(实测)。`--package` 明确指定包,`aura` 才是命令。
|
|
23
21
|
*/
|
|
24
|
-
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
25
23
|
import process from 'node:process'
|
|
26
|
-
import { pathToFileURL } from 'node:url'
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
27
25
|
import {
|
|
28
26
|
openProvider,
|
|
29
27
|
readEnvelope,
|
|
@@ -32,8 +30,6 @@ import {
|
|
|
32
30
|
// 多目标(fs-contract-v2)的引擎绑定:与单目标同一处(`lib/ops.mjs`),bin 里不另找引擎。
|
|
33
31
|
readManyWith,
|
|
34
32
|
editManyEnvelope,
|
|
35
|
-
undoEnvelope,
|
|
36
|
-
transactionsEnvelope,
|
|
37
33
|
assertReadTargets,
|
|
38
34
|
assertEditTargets,
|
|
39
35
|
assertTargetShape,
|
|
@@ -81,11 +77,6 @@ const HUNK_SCHEMA = {
|
|
|
81
77
|
type: 'object',
|
|
82
78
|
properties: {
|
|
83
79
|
target: { type: 'string', description: '符号名,如 `toTodoList` 或 `function:apply`;跳过全部文本匹配' },
|
|
84
|
-
intent: {
|
|
85
|
-
type: 'string',
|
|
86
|
-
description:
|
|
87
|
-
'模糊意图地址:符号名片段、短语或近似原文(如 `parseConfig`、`config loader`、`// retry the upload`),由引擎确定性地落成一个精确区间;歧义时报候选并拒绝(不猜)。单独使用(不与 target/lines/line_cue/anchor/old/context/handle 同用);可用 near 与 expect',
|
|
88
|
-
},
|
|
89
80
|
lines: {
|
|
90
81
|
oneOf: [
|
|
91
82
|
{ type: 'array' },
|
|
@@ -137,7 +128,7 @@ const HUNK_SCHEMA = {
|
|
|
137
128
|
replace_all: {
|
|
138
129
|
type: 'boolean',
|
|
139
130
|
description:
|
|
140
|
-
'与 old 同用:替换全部非重叠出现(从左到右),不再要求唯一。不能与 target/
|
|
131
|
+
'与 old 同用:替换全部非重叠出现(从左到右),不再要求唯一。不能与 target/lines/line_cue/anchor/context/handle/near 同用;读取后出现次数变了会整体拒绝。',
|
|
141
132
|
},
|
|
142
133
|
eol: {
|
|
143
134
|
type: 'string',
|
|
@@ -198,14 +189,13 @@ const TOOLS = [
|
|
|
198
189
|
{
|
|
199
190
|
name: 'aura_basetools_edit',
|
|
200
191
|
description:
|
|
201
|
-
'Edit or create a UTF-8 text file with hunks in one call. Address each hunk by
|
|
192
|
+
'Edit or create a UTF-8 text file with hunks in one call. Address each hunk by target (symbol), lines, line_cue, anchor, or old (verbatim content + optional context). With `old`, set `replace_all: true` to replace every non-overlapping occurrence. Writes back byte-for-byte (BOM and per-line endings preserved), refuses a hunk whose addressed region changed since your last read, and reads the file back to verify. All hunks must locate or nothing is written.',
|
|
202
193
|
inputSchema: {
|
|
203
194
|
type: 'object',
|
|
204
195
|
properties: {
|
|
205
196
|
file_path: { type: 'string' },
|
|
206
197
|
hunks: { type: 'array', items: HUNK_SCHEMA },
|
|
207
198
|
dry_run: { type: 'boolean', description: '只定位不落盘' },
|
|
208
|
-
include_transaction: { type: 'boolean', description: '在回执里返回 transaction id(默认不返回,保持输出稳定)' },
|
|
209
199
|
require_observation: {
|
|
210
200
|
type: 'boolean',
|
|
211
201
|
description: '严格新鲜度:要求这一版文件在本会话里被读过(或由本工具写过);否则 PATCH_UNOBSERVED。create 不受影响;dry_run 同样执行这条判决。',
|
|
@@ -230,7 +220,7 @@ const TOOLS = [
|
|
|
230
220
|
{
|
|
231
221
|
name: 'aura_basetools_write',
|
|
232
222
|
description:
|
|
233
|
-
'Write a whole UTF-8 text file at once (create or overwrite), matching a plain Write tool. Preserves an existing file\'s BOM and line endings, goes through the same
|
|
223
|
+
'Write a whole UTF-8 text file at once (create or overwrite), matching a plain Write tool. Preserves an existing file\'s BOM and line endings, goes through the same atomic write path as aura_basetools_edit, and reads the file back to verify bytes. Prefer aura_basetools_edit for partial changes.',
|
|
234
224
|
inputSchema: {
|
|
235
225
|
type: 'object',
|
|
236
226
|
properties: {
|
|
@@ -247,28 +237,10 @@ const TOOLS = [
|
|
|
247
237
|
enum: ['auto', 'preserve', 'lf', 'crlf'],
|
|
248
238
|
description: '覆写时的换行风格:auto(默认)适配文件已有风格;preserve 按 content 原样写;lf/crlf 强制。新建文件始终按 content 原样写(非 auto 会被拒绝)。',
|
|
249
239
|
},
|
|
250
|
-
include_transaction: { type: 'boolean', description: '在回执里返回 transaction id(默认不返回)' },
|
|
251
240
|
},
|
|
252
241
|
required: ['file_path', 'content'],
|
|
253
242
|
},
|
|
254
243
|
},
|
|
255
|
-
{
|
|
256
|
-
name: 'aura_basetools_undo',
|
|
257
|
-
description:
|
|
258
|
-
'Undo the latest committed write transaction, or the transaction with the given id. Refuses if a file changed after the transaction; pass force=true to override. Restores the exact bytes captured before the write.',
|
|
259
|
-
inputSchema: {
|
|
260
|
-
type: 'object',
|
|
261
|
-
properties: {
|
|
262
|
-
id: { type: 'string', description: '事务 id;省略则撤销最近一条 committed 事务' },
|
|
263
|
-
force: { type: 'boolean', description: '即使文件在事务后又被改过也强制恢复' },
|
|
264
|
-
},
|
|
265
|
-
},
|
|
266
|
-
},
|
|
267
|
-
{
|
|
268
|
-
name: 'aura_basetools_transactions',
|
|
269
|
-
description: 'List write transactions for the current workspace (id, status, file count, time).',
|
|
270
|
-
inputSchema: { type: 'object', properties: {} },
|
|
271
|
-
},
|
|
272
244
|
]
|
|
273
245
|
|
|
274
246
|
/**
|
|
@@ -351,7 +323,6 @@ async function editTargetsCall(provider, args) {
|
|
|
351
323
|
return await editManyEnvelope(provider, {
|
|
352
324
|
targets,
|
|
353
325
|
dryRun: args.dry_run === true,
|
|
354
|
-
includeTransaction: args.include_transaction === true,
|
|
355
326
|
...(args.require_observation === true ? { requireObservation: true } : {}),
|
|
356
327
|
})
|
|
357
328
|
}
|
|
@@ -436,7 +407,6 @@ async function callTool(provider, name, args = {}) {
|
|
|
436
407
|
hunks: Array.isArray(args.hunks) ? args.hunks : [],
|
|
437
408
|
dryRun: args.dry_run === true,
|
|
438
409
|
create: creating ? args.create : undefined,
|
|
439
|
-
includeTransaction: args.include_transaction === true,
|
|
440
410
|
...(args.require_observation === true ? { requireObservation: true } : {}),
|
|
441
411
|
})
|
|
442
412
|
}
|
|
@@ -446,13 +416,10 @@ async function callTool(provider, name, args = {}) {
|
|
|
446
416
|
content: args.content,
|
|
447
417
|
dryRun: args.dry_run === true,
|
|
448
418
|
noOverwrite: args.no_overwrite === true,
|
|
449
|
-
includeTransaction: args.include_transaction === true,
|
|
450
419
|
allowUnobserved: args.allow_unobserved === true,
|
|
451
420
|
...(typeof args.eol === 'string' ? { eol: args.eol } : {}),
|
|
452
421
|
})
|
|
453
422
|
}
|
|
454
|
-
if (name === 'aura_basetools_undo') return await undoEnvelope(provider, { id: args.id, force: args.force === true })
|
|
455
|
-
if (name === 'aura_basetools_transactions') return transactionsEnvelope(provider)
|
|
456
423
|
return { ok: false, error: `unknown tool ${JSON.stringify(name)}`, code: 'UNKNOWN_TOOL' }
|
|
457
424
|
}
|
|
458
425
|
|
|
@@ -561,6 +528,25 @@ export async function serve() {
|
|
|
561
528
|
|
|
562
529
|
// 直接运行本文件(`aura-mcp`,package.json 里声明的第二个 bin)也要起服务,与 `aura basetools mcp` 等价。
|
|
563
530
|
// 用 pathToFileURL 比手拼 file:// 靠得住(Windows 盘符就是那种手拼会踩的地方)。
|
|
564
|
-
|
|
531
|
+
/**
|
|
532
|
+
* 直接执行本文件才起 stdio 服务。
|
|
533
|
+
*
|
|
534
|
+
* 不能只比 `import.meta.url === pathToFileURL(argv[1])`:macOS 的临时目录
|
|
535
|
+
* `/var/folders/...` 是 `/private/var/folders/...` 的符号链接,Node 给模块的 URL 与
|
|
536
|
+
* argv 的写法可能一侧已 realpath、另一侧没有 ⇒ 服务静默退出,客户端看到 0 个工具
|
|
537
|
+
* (post-publish / split-package 在 macOS 上正是这条)。两边都过 realpath 再比。
|
|
538
|
+
*/
|
|
539
|
+
function isDirectRun() {
|
|
540
|
+
const invoked = process.argv[1]
|
|
541
|
+
if (invoked === undefined) return false
|
|
542
|
+
if (import.meta.url === pathToFileURL(invoked).href) return true
|
|
543
|
+
try {
|
|
544
|
+
return realpathSync.native(fileURLToPath(import.meta.url)) === realpathSync.native(invoked)
|
|
545
|
+
} catch {
|
|
546
|
+
return false
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (isDirectRun()) {
|
|
565
551
|
await serve()
|
|
566
552
|
}
|
package/bin/aura.mjs
CHANGED
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
* aura basetools edit --file F (--hunks '<json>' | --hunks-file P) [--dry-run] [--create <文本|@文件>]
|
|
7
7
|
* aura basetools read --targets '[...]|@spec.json' 一次读多个目标(与 --file 互斥)
|
|
8
8
|
* aura basetools edit --targets '[...]|@spec.json' 一次改多个文件(与 --file 互斥;任一规划失败 ⇒ 零写入)
|
|
9
|
-
* aura basetools undo [--id <事务 id>] [--force] 撤销最近一次/指定写事务
|
|
10
|
-
* aura basetools transactions 列出当前工作区写事务
|
|
11
9
|
* aura basetools mcp
|
|
12
10
|
* aura --help | --version
|
|
13
11
|
*
|
|
@@ -27,8 +25,6 @@ import {
|
|
|
27
25
|
// 多目标(fs-contract-v2)的引擎绑定:与单目标同一处(`lib/ops.mjs`),bin 里不另找引擎。
|
|
28
26
|
readManyWith,
|
|
29
27
|
editManyEnvelope,
|
|
30
|
-
undoEnvelope,
|
|
31
|
-
transactionsEnvelope,
|
|
32
28
|
capabilitiesEnvelope,
|
|
33
29
|
writeEnvelope,
|
|
34
30
|
assertReadTargets,
|
|
@@ -74,7 +70,7 @@ let command = argv[0]
|
|
|
74
70
|
* 有了这份名单,“下一个是已知布尔旗标”才当没带值,**其它以 `--` 开头的东西都当值**。
|
|
75
71
|
* 代价是名单要维护;收益是那个静默丢值的路径从根上消失,而不是靠叫用户改写法。
|
|
76
72
|
*/
|
|
77
|
-
const BOOLEAN_FLAGS = new Set(['pretty', 'dry-run', 'plan', '
|
|
73
|
+
const BOOLEAN_FLAGS = new Set(['pretty', 'dry-run', 'plan', 'no-overwrite', 'replace-all', 'require-observation', 'allow-unobserved', 'json'])
|
|
78
74
|
const isBooleanFlag = (token) => typeof token === 'string' && BOOLEAN_FLAGS.has(token.replace(/^--/, ''))
|
|
79
75
|
|
|
80
76
|
/**
|
|
@@ -90,15 +86,13 @@ const COMMAND_FLAGS = {
|
|
|
90
86
|
bool: ['pretty'],
|
|
91
87
|
},
|
|
92
88
|
edit: {
|
|
93
|
-
value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', '
|
|
94
|
-
bool: ['pretty', 'dry-run', 'plan', '
|
|
89
|
+
value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol'],
|
|
90
|
+
bool: ['pretty', 'dry-run', 'plan', 'replace-all', 'require-observation'],
|
|
95
91
|
},
|
|
96
92
|
write: {
|
|
97
93
|
value: ['cwd', 'file', 'content', 'eol'],
|
|
98
|
-
bool: ['pretty', 'dry-run', 'plan', '
|
|
94
|
+
bool: ['pretty', 'dry-run', 'plan', 'no-overwrite', 'allow-unobserved'],
|
|
99
95
|
},
|
|
100
|
-
undo: { value: ['cwd', 'id'], bool: ['pretty', 'force'] },
|
|
101
|
-
transactions: { value: ['cwd'], bool: ['pretty'] },
|
|
102
96
|
doctor: { value: ['cwd'], bool: ['pretty', 'json'] },
|
|
103
97
|
capabilities: { value: ['cwd'], bool: ['pretty'] },
|
|
104
98
|
mcp: { value: ['cwd'], bool: ['pretty'] },
|
|
@@ -177,20 +171,20 @@ const USAGE = [
|
|
|
177
171
|
" aura basetools edit --file <路径> (--hunks '<json>' | --hunks-file <路径>) [--dry-run|--plan] [--eol auto|preserve|lf|crlf] [--require-observation]",
|
|
178
172
|
' aura basetools edit --file <路径> --lines <a,b> --replace <文本> [--expect <文本>] [--near <n>]',
|
|
179
173
|
' aura basetools edit --file <路径> --old <原文> --replace <文本> --replace-all # 改全部非重叠出现',
|
|
180
|
-
|
|
174
|
+
" (只改一处时不必包成 JSON:--target / --lines / --start-line+--end-line / --line-cue / --anchor 任选其一)",
|
|
181
175
|
// 句柄(fs-contract-v2 §5):读侧发的柄 + 三种定位写法。下面三行是三种**用法**,不是三种键。
|
|
182
176
|
' aura basetools edit --file <路径> --handle <h…> --replace <文本> # 整段替换:区间由柄定,不靠转述',
|
|
183
177
|
' aura basetools edit --file <路径> --handle <h…> --line <段内行号> --replace <文本> # 只换区间内第 k 行(1-based,从区间首行数起)',
|
|
184
178
|
' aura basetools edit --file <路径> --handle <h…> --position before|after --replace <文本> # 在区间边界插入,区间本身不动',
|
|
185
179
|
" (--line / --position 是从属键:只有与 --handle 同时给才成立,单独给不构成一次编辑)",
|
|
186
180
|
' aura basetools mcp 启动 MCP stdio 服务',
|
|
187
|
-
' aura basetools write --file <路径> --content <文本|@文件> [--no-overwrite] [--dry-run|--plan] [--
|
|
181
|
+
' aura basetools write --file <路径> --content <文本|@文件> [--no-overwrite] [--dry-run|--plan] [--allow-unobserved] [--eol auto|preserve|lf|crlf]',
|
|
188
182
|
' (覆写默认要求先读过这一版;--allow-unobserved 才允许盲写)',
|
|
189
183
|
' aura basetools doctor [--json] 自检:Node/版本/MCP 握手/缓存与锁目录可写',
|
|
190
184
|
' aura basetools capabilities [--pretty] 能力清单:读模式/地址档/语言/限制(JSON)',
|
|
191
185
|
' aura --help | --version',
|
|
192
186
|
'',
|
|
193
|
-
|
|
187
|
+
'hunks 形状:[{"target":"符号名"|"lines":[a,b]|"line_cue":"唯一词"|"anchor":"记得的原文",',
|
|
194
188
|
' "replace":"替换文本","expect":"该处应是什么","near":行号}]',
|
|
195
189
|
'',
|
|
196
190
|
'例:',
|
|
@@ -311,7 +305,7 @@ if (command === '--version' || command === '-v') emit({ ok: true, version: VERSI
|
|
|
311
305
|
* 旧顶层动词只给**迁移提示**,不再作为别名执行——保留别名等于保留未来的撞名。
|
|
312
306
|
* 只提示、不执行:`aura basetools read` 不会偷偷改文件。
|
|
313
307
|
*/
|
|
314
|
-
const MOVED_COMMANDS = new Set(['read', 'outline', 'edit', 'write', '
|
|
308
|
+
const MOVED_COMMANDS = new Set(['read', 'outline', 'edit', 'write', 'mcp', 'doctor', 'capabilities'])
|
|
315
309
|
if (!namespaced && MOVED_COMMANDS.has(command)) {
|
|
316
310
|
emit(
|
|
317
311
|
{
|
|
@@ -356,26 +350,12 @@ if (command === 'write') {
|
|
|
356
350
|
content,
|
|
357
351
|
dryRun: has('dry-run') || has('plan'),
|
|
358
352
|
noOverwrite: has('no-overwrite'),
|
|
359
|
-
includeTransaction: has('transaction'),
|
|
360
353
|
allowUnobserved: has('allow-unobserved'),
|
|
361
354
|
...(typeof eol === 'string' ? { eol } : {}),
|
|
362
355
|
})
|
|
363
356
|
emit(env, env.ok ? 0 : 2)
|
|
364
357
|
}
|
|
365
358
|
|
|
366
|
-
if (command === 'undo') {
|
|
367
|
-
const env = await undoEnvelope(provider, {
|
|
368
|
-
id: typeof flag('id') === 'string' ? flag('id') : undefined,
|
|
369
|
-
force: has('force'),
|
|
370
|
-
})
|
|
371
|
-
emit(env, env.ok ? 0 : 2)
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (command === 'transactions') {
|
|
375
|
-
const env = transactionsEnvelope(provider)
|
|
376
|
-
emit(env, 0)
|
|
377
|
-
}
|
|
378
|
-
|
|
379
359
|
if (command === 'doctor') {
|
|
380
360
|
/**
|
|
381
361
|
* `aura basetools doctor`:把“装完能不能用”的机械检查放在 CLI 自己身上。
|
|
@@ -492,7 +472,6 @@ if (command === 'edit') {
|
|
|
492
472
|
const env = await editManyEnvelope(provider, {
|
|
493
473
|
targets: spec.targets,
|
|
494
474
|
dryRun: has('dry-run') || has('plan') || spec.dryRun,
|
|
495
|
-
includeTransaction: has('transaction'),
|
|
496
475
|
...(has('require-observation') ? { requireObservation: true } : {}),
|
|
497
476
|
})
|
|
498
477
|
emit(env, env.ok ? 0 : 2)
|
|
@@ -509,8 +488,6 @@ if (command === 'edit') {
|
|
|
509
488
|
*/
|
|
510
489
|
const flat = flatHunk({
|
|
511
490
|
target: flag('target'),
|
|
512
|
-
// 意图地址(T16):`--intent 'config loader'` 一句话定位一处改动,不要求符号全名或原文。
|
|
513
|
-
intent: flag('intent'),
|
|
514
491
|
lines: flag('lines'),
|
|
515
492
|
start_line: flag('start-line'),
|
|
516
493
|
end_line: flag('end-line'),
|
|
@@ -593,7 +570,7 @@ if (command === 'edit') {
|
|
|
593
570
|
{
|
|
594
571
|
ok: false,
|
|
595
572
|
error:
|
|
596
|
-
|
|
573
|
+
'edit 需要 --hunks/--hunks-file,或平铺一组地址旗标(--target / --lines / --start-line+--end-line / --line-cue / --anchor / --handle(配 --line 或 --position),外加 --replace),或 --create <整份内容>',
|
|
597
574
|
code: 'USAGE',
|
|
598
575
|
},
|
|
599
576
|
1,
|
|
@@ -607,7 +584,6 @@ if (command === 'edit') {
|
|
|
607
584
|
hunks: hunks ?? [],
|
|
608
585
|
dryRun: has('dry-run') || has('plan'),
|
|
609
586
|
create,
|
|
610
|
-
includeTransaction: has('transaction'),
|
|
611
587
|
...(has('require-observation') ? { requireObservation: true } : {}),
|
|
612
588
|
})
|
|
613
589
|
emit(env, env.ok ? 0 : 2)
|
package/lib/ops.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import fs from 'node:fs'
|
|
|
13
13
|
import { fileURLToPath } from 'node:url'
|
|
14
14
|
import { createNodeFsProvider, DEFAULT_MAX_TEXT_BYTES } from './fs-provider.mjs'
|
|
15
15
|
import { withWorkspaceLock } from './workspace-lock.mjs'
|
|
16
|
-
import { createTransaction
|
|
16
|
+
import { createTransaction } from './transaction-store.mjs'
|
|
17
17
|
import { createDiskStore } from './observation-store.mjs'
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -93,8 +93,8 @@ export function capabilitiesEnvelope() {
|
|
|
93
93
|
addressKinds: [...new Set([...engineArgs.FLAT_ADDRESS_KEYS, 'old', 'context', 'position'])],
|
|
94
94
|
options: ['replace_all', 'require_observation'],
|
|
95
95
|
writeSafety: {
|
|
96
|
-
cli: ['
|
|
97
|
-
mcp: ['
|
|
96
|
+
cli: ['workspace-lock', 'stale-region-gate', 'byte-verify'],
|
|
97
|
+
mcp: ['workspace-lock', 'stale-region-gate', 'byte-verify'],
|
|
98
98
|
dsh: ['stale-region-gate', 'byte-verify'],
|
|
99
99
|
},
|
|
100
100
|
},
|
|
@@ -103,14 +103,14 @@ export function capabilitiesEnvelope() {
|
|
|
103
103
|
operations: ['create', 'overwrite'],
|
|
104
104
|
options: ['eol'],
|
|
105
105
|
writeSafety: {
|
|
106
|
-
cli: ['
|
|
107
|
-
mcp: ['
|
|
108
|
-
// DSH
|
|
106
|
+
cli: ['workspace-lock', 'stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
107
|
+
mcp: ['workspace-lock', 'stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
108
|
+
// DSH 壳没有工作区锁;它经过引擎的区域闸、严格新鲜度与回读校验。
|
|
109
109
|
dsh: ['stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
110
110
|
},
|
|
111
111
|
},
|
|
112
112
|
],
|
|
113
|
-
commands: ['basetools read', 'basetools edit', 'basetools write', 'basetools
|
|
113
|
+
commands: ['basetools read', 'basetools edit', 'basetools write', 'basetools mcp', 'basetools doctor', 'basetools capabilities', '--version', '--help'],
|
|
114
114
|
langs,
|
|
115
115
|
langsCount: langs.length,
|
|
116
116
|
limits: {
|
|
@@ -489,12 +489,9 @@ export async function editEnvelope(provider, request) {
|
|
|
489
489
|
// 事务需要工作区根与真实文件系统;测试/嵌入式 provider 没有 root 时保持原来的引擎行为。
|
|
490
490
|
if (provider.root === undefined) return editEnvelopeInner(provider, request)
|
|
491
491
|
/**
|
|
492
|
-
*
|
|
493
|
-
* 把 `aura undo`(最近一次 committed)顶掉——用户以为撤销了真实编辑,实际 restored:0。
|
|
494
|
-
* 预演也不写盘,不需要工作区锁。
|
|
492
|
+
* 预演不建事务、不写盘,也不需要工作区锁;0.14.0 起事务日志只用于失败回滚。
|
|
495
493
|
*/
|
|
496
494
|
if (request.dryRun === true) return editEnvelopeInner(provider, request)
|
|
497
|
-
const includeTransaction = request.includeTransaction === true
|
|
498
495
|
try {
|
|
499
496
|
return await withWorkspaceLock(provider.root, async () => {
|
|
500
497
|
const tx = createTransaction(provider.root, 'aura_basetools_edit')
|
|
@@ -508,10 +505,10 @@ export async function editEnvelope(provider, request) {
|
|
|
508
505
|
error.rollback = rollback
|
|
509
506
|
throw error
|
|
510
507
|
}
|
|
511
|
-
return
|
|
508
|
+
return env
|
|
512
509
|
}
|
|
513
|
-
|
|
514
|
-
return
|
|
510
|
+
await tx.rollback(provider)
|
|
511
|
+
return env
|
|
515
512
|
})
|
|
516
513
|
} catch (error) {
|
|
517
514
|
return transactionError(error)
|
|
@@ -523,7 +520,6 @@ export async function editManyEnvelope(provider, request) {
|
|
|
523
520
|
if (provider.root === undefined) return editManyEnvelopeInner(provider, request)
|
|
524
521
|
// 与单目标同一条:预演不建事务、不写盘,不能顶掉真正的最近 committed 事务。
|
|
525
522
|
if (request.dryRun === true) return editManyEnvelopeInner(provider, request)
|
|
526
|
-
const includeTransaction = request.includeTransaction === true
|
|
527
523
|
try {
|
|
528
524
|
return await withWorkspaceLock(provider.root, async () => {
|
|
529
525
|
const tx = createTransaction(provider.root, 'aura_basetools_edit')
|
|
@@ -538,14 +534,13 @@ export async function editManyEnvelope(provider, request) {
|
|
|
538
534
|
error.rollback = rollback
|
|
539
535
|
throw error
|
|
540
536
|
}
|
|
541
|
-
return
|
|
537
|
+
return batch
|
|
542
538
|
}
|
|
543
539
|
const rollback = await tx.rollback(provider)
|
|
544
540
|
const finalBatch = writePath.rebuildAfterRollback(batch, rollback)
|
|
545
541
|
return {
|
|
546
542
|
...finalBatch,
|
|
547
543
|
ok: false,
|
|
548
|
-
...(includeTransaction ? { transaction: tx.summary() } : {}),
|
|
549
544
|
}
|
|
550
545
|
} catch (error) {
|
|
551
546
|
const rollback = await tx.rollback(provider)
|
|
@@ -579,7 +574,6 @@ function compactWriteReceipt(env, content) {
|
|
|
579
574
|
dryRun,
|
|
580
575
|
...(result.write_verify === undefined ? {} : { write_verify: result.write_verify }),
|
|
581
576
|
...(result.unverified_region === true ? { unverified_region: true } : {}),
|
|
582
|
-
...(result.transaction === undefined ? {} : { transaction: result.transaction }),
|
|
583
577
|
},
|
|
584
578
|
}
|
|
585
579
|
}
|
|
@@ -600,7 +594,7 @@ function compactWriteReceipt(env, content) {
|
|
|
600
594
|
* `preserve` 按 content 原样写(与内置 Write 的“给什么写什么”对齐),`lf` / `crlf` 强制统一。
|
|
601
595
|
* 新建文件永远按 content 原样写;显式传非 `auto` 的 eol 会被拒绝(不做静默忽略)。
|
|
602
596
|
*/
|
|
603
|
-
export async function writeEnvelope(provider, { file, content, dryRun = false, noOverwrite = false,
|
|
597
|
+
export async function writeEnvelope(provider, { file, content, dryRun = false, noOverwrite = false, allowUnobserved = false, eol = 'auto' } = {}) {
|
|
604
598
|
if (typeof file !== 'string' || file.length === 0) {
|
|
605
599
|
return { ok: false, error: 'write needs a file path', code: 'WRITE_BAD_ARGS' }
|
|
606
600
|
}
|
|
@@ -627,7 +621,7 @@ export async function writeEnvelope(provider, { file, content, dryRun = false, n
|
|
|
627
621
|
if (dryRun) {
|
|
628
622
|
return { ok: true, result: { path: displayPath, operation: 'create', bytes: Buffer.byteLength(content, 'utf8'), dryRun: true } }
|
|
629
623
|
}
|
|
630
|
-
return compactWriteReceipt(await editEnvelope(provider, { file, create: content
|
|
624
|
+
return compactWriteReceipt(await editEnvelope(provider, { file, create: content }), content)
|
|
631
625
|
}
|
|
632
626
|
return compactWriteReceipt(
|
|
633
627
|
await editEnvelope(provider, {
|
|
@@ -635,7 +629,6 @@ export async function writeEnvelope(provider, { file, content, dryRun = false, n
|
|
|
635
629
|
wholeFile: content,
|
|
636
630
|
eol,
|
|
637
631
|
dryRun,
|
|
638
|
-
includeTransaction,
|
|
639
632
|
requireObservation: allowUnobserved !== true,
|
|
640
633
|
skipStaleGate: allowUnobserved === true,
|
|
641
634
|
toolName: 'aura_basetools_write',
|
|
@@ -646,17 +639,3 @@ export async function writeEnvelope(provider, { file, content, dryRun = false, n
|
|
|
646
639
|
return transactionError(error)
|
|
647
640
|
}
|
|
648
641
|
}
|
|
649
|
-
|
|
650
|
-
export async function undoEnvelope(provider, { id, force } = {}) { try {
|
|
651
|
-
return {
|
|
652
|
-
ok: true,
|
|
653
|
-
...(await withWorkspaceLock(provider.root, () => undoTransaction(provider.root, provider, { id, force: force === true }))),
|
|
654
|
-
}
|
|
655
|
-
} catch (error) {
|
|
656
|
-
return transactionError(error)
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
export function transactionsEnvelope(provider) {
|
|
661
|
-
return { ok: true, transactions: listTransactions(provider.root) }
|
|
662
|
-
}
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* transaction-store.mjs ——
|
|
2
|
+
* transaction-store.mjs —— 写事务日志与**失败回滚**(内部实现,不再暴露 undo/transactions 工具)。
|
|
3
3
|
*
|
|
4
4
|
* 一次写事务在 `~/.aura/transactions/<workspace-key>/<id>/` 下留下:
|
|
5
5
|
* · `meta.json` —— 事务状态与每个文件的原始 mode/版本/备份名;
|
|
6
6
|
* · `originals/<n>.bin` —— 写前原始字节(逐字节,含 BOM/行尾)。
|
|
7
7
|
*
|
|
8
8
|
* 事务的生命周期:
|
|
9
|
-
* pending → committed
|
|
9
|
+
* pending → committed(写成功;日志仅用于内部回滚,不再提供 undo)
|
|
10
10
|
* → rolled_back(写失败,已恢复原字节)
|
|
11
11
|
* → recovery_required(写失败且恢复也失败;meta 里留着可手工恢复的路径)
|
|
12
|
-
* committed → undone(undo 成功)
|
|
13
12
|
*
|
|
14
|
-
*
|
|
13
|
+
* 回滚前会比一次 `afterVersion`:文件在事务之后又被别人改过就拒绝覆盖。
|
|
15
14
|
* 这不是安全边界;它只保证遵守本协议的进程不会静默覆盖并发写。
|
|
16
15
|
*/
|
|
17
16
|
import fs from 'node:fs'
|
|
@@ -262,21 +261,6 @@ function readAllMeta(root) {
|
|
|
262
261
|
return out.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
263
262
|
}
|
|
264
263
|
|
|
265
|
-
/** 取最近一条可 undo 的 committed 事务。 */
|
|
266
|
-
export function latestCommitted(root) {
|
|
267
|
-
return readAllMeta(root).find((meta) => meta.status === 'committed') ?? null
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export function listTransactions(root) {
|
|
271
|
-
return readAllMeta(root).map((meta) => ({
|
|
272
|
-
id: meta.id,
|
|
273
|
-
tool: meta.tool,
|
|
274
|
-
createdAt: meta.createdAt,
|
|
275
|
-
status: meta.status,
|
|
276
|
-
files: Array.isArray(meta.files) ? meta.files.length : 0,
|
|
277
|
-
dir: meta.dir,
|
|
278
|
-
}))
|
|
279
|
-
}
|
|
280
264
|
|
|
281
265
|
function prune(root, keep = KEEP) {
|
|
282
266
|
const metas = readAllMeta(root).filter((meta) => meta.status !== 'pending')
|
|
@@ -285,66 +269,3 @@ function prune(root, keep = KEEP) {
|
|
|
285
269
|
}
|
|
286
270
|
}
|
|
287
271
|
|
|
288
|
-
/**
|
|
289
|
-
* 撤销一条 committed 事务。
|
|
290
|
-
*
|
|
291
|
-
* 任何文件在当前时间点的版本与事务提交后的版本不一致 ⇒ 默认拒绝,避免覆盖后续编辑;
|
|
292
|
-
* `force: true` 才强制恢复旧字节。
|
|
293
|
-
*/
|
|
294
|
-
export async function undoTransaction(root, provider, options = {}) {
|
|
295
|
-
const metas = readAllMeta(root)
|
|
296
|
-
const meta = options.id ? metas.find((m) => m.id === options.id) : metas.find((m) => m.status === 'committed')
|
|
297
|
-
if (meta === undefined) {
|
|
298
|
-
throw Object.assign(new Error(options.id ? `transaction not found: ${options.id}` : 'no committed transaction to undo'), {
|
|
299
|
-
code: 'TRANSACTION_NOT_FOUND',
|
|
300
|
-
})
|
|
301
|
-
}
|
|
302
|
-
if (meta.status !== 'committed') {
|
|
303
|
-
throw Object.assign(new Error(`transaction ${meta.id} is ${meta.status}, not committed`), {
|
|
304
|
-
code: 'TRANSACTION_NOT_UNDOABLE',
|
|
305
|
-
status: meta.status,
|
|
306
|
-
})
|
|
307
|
-
}
|
|
308
|
-
const force = options.force === true
|
|
309
|
-
/**
|
|
310
|
-
* 先做一次**只读预检**:任何文件在事务后又被改过 ⇒ 整个 undo 拒绝。
|
|
311
|
-
* 旧顺序会先把还能恢复的文件写回去,再发现某个文件冲突——那就是一次部分 undo;
|
|
312
|
-
* 对一个以“不会静默改错”为卖点的工具,部分恢复比拒绝更坏。
|
|
313
|
-
*/
|
|
314
|
-
const plans = []
|
|
315
|
-
const conflicts = []
|
|
316
|
-
for (const entry of meta.files) {
|
|
317
|
-
if (!entry.written) continue
|
|
318
|
-
let current
|
|
319
|
-
try { current = provider.stat(entry.path) } catch { current = undefined }
|
|
320
|
-
if (!force && entry.afterVersion !== null && current?.version !== entry.afterVersion) {
|
|
321
|
-
conflicts.push({ path: entry.path, reason: 'changed since this transaction', expected: entry.afterVersion, actual: current?.version ?? null })
|
|
322
|
-
}
|
|
323
|
-
plans.push(entry)
|
|
324
|
-
}
|
|
325
|
-
if (conflicts.length > 0) {
|
|
326
|
-
throw Object.assign(new Error(`transaction ${meta.id} cannot be undone: ${conflicts.length} file(s) changed after it`), {
|
|
327
|
-
code: 'TRANSACTION_STALE',
|
|
328
|
-
transaction: meta.id,
|
|
329
|
-
conflicts,
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
let restored = 0
|
|
333
|
-
for (const entry of plans) {
|
|
334
|
-
try {
|
|
335
|
-
if (entry.existed) {
|
|
336
|
-
writeBytesAtomic(entry.realPath, fs.readFileSync(path.join(meta.dir, entry.backup)), entry.mode ?? 0o600)
|
|
337
|
-
} else {
|
|
338
|
-
fs.rmSync(entry.realPath, { force: true })
|
|
339
|
-
}
|
|
340
|
-
restored += 1
|
|
341
|
-
} catch (error) {
|
|
342
|
-
conflicts.push({ path: entry.path, reason: String(error?.message ?? error) })
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
meta.status = conflicts.length === 0 ? 'undone' : 'recovery_required'
|
|
346
|
-
meta.undoneAt = new Date().toISOString()
|
|
347
|
-
meta.undoConflicts = conflicts
|
|
348
|
-
saveMeta(meta)
|
|
349
|
-
return { transaction: meta.id, status: meta.status, restored, conflicts, dir: meta.dir }
|
|
350
|
-
}
|
package/locator-lab/args.mjs
CHANGED
|
@@ -126,12 +126,12 @@ export function normalizeReadRequest(args, totalLines = undefined) {
|
|
|
126
126
|
* 导出它是为了不再出现第二份:守卫要拿它算“合法键集合”,而如果有两份名单,
|
|
127
127
|
* 新增一种地址写法时必有一处漏改——那正是本项目反复出现的形状。
|
|
128
128
|
*/
|
|
129
|
-
export const FLAT_ADDRESS_KEYS = ['target', '
|
|
129
|
+
export const FLAT_ADDRESS_KEYS = ['target', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'handle', 'old']
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* `targets` 元素(单目标参数)的合法键。
|
|
133
133
|
*
|
|
134
|
-
* 与顶层工具键**分开**:`targets`
|
|
134
|
+
* 与顶层工具键**分开**:`targets` 本身不是元素参数;
|
|
135
135
|
* 放进元素里只会被静默丢掉。三面(CLI / MCP / DSH)共用这一份,未知键一律
|
|
136
136
|
* `ARGS_UNKNOWN_KEY`,不再只校验顶层。
|
|
137
137
|
*/
|
|
@@ -140,7 +140,7 @@ export const MT_READ_ELEMENT_KEYS = [
|
|
|
140
140
|
]
|
|
141
141
|
export const MT_EDIT_ELEMENT_KEYS = [
|
|
142
142
|
'file_path', 'hunks', 'create', 'dry_run', 'require_observation',
|
|
143
|
-
'target', '
|
|
143
|
+
'target', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'old', 'context',
|
|
144
144
|
'replace', 'replace_all', 'eol', 'expect', 'near', 'handle', 'line', 'position',
|
|
145
145
|
]
|
|
146
146
|
|
|
@@ -23,28 +23,24 @@ import { indexFile, locateWith, normSpace } from './anchor-core.mjs'
|
|
|
23
23
|
/**
|
|
24
24
|
* 定位一份源码里的改动点。
|
|
25
25
|
*
|
|
26
|
-
*
|
|
26
|
+
* 四层寻址,按"模型要提供什么"从强到弱排列。每层覆盖上一层够不到的场景,
|
|
27
27
|
* 但**都不要求模型复现字节**——这是它们与 `edit` 的根本差别:
|
|
28
28
|
*
|
|
29
|
-
* 1. `
|
|
30
|
-
*
|
|
31
|
-
* 2. `target` —— 符号名。不读文本,对内容记错免疫。适合改整个函数/类/类型。
|
|
32
|
-
* 3. `lines` —— 行号区间。模型刚用 `read` 看过文件,输出就是 `<行号>: <文本>`,
|
|
29
|
+
* 1. `target` —— 符号名。不读文本,对内容记错免疫。适合改整个函数/类/类型。
|
|
30
|
+
* 2. `lines` —— 行号区间。模型刚用 `read` 看过文件,输出就是 `<行号>: <文本>`,
|
|
33
31
|
* `grep` 也给行号——**位置是它本来就知道的信息**,我却一直只让它给内容。
|
|
34
32
|
* 把行号纳入地址空间后,符号体内的任意一行都变得可精确寻址。
|
|
35
|
-
*
|
|
33
|
+
* 3. `lineCue` —— 一个词。当模型记不住行号时,说"就在有 resolvePolicy 的那一行"。
|
|
36
34
|
* 只接受**全文件唯一**的线索;不唯一则报歧义并给出候选行号,绝不猜。
|
|
37
|
-
*
|
|
35
|
+
* 4. `anchor` —— 记得的原文。五级降级匹配,容错性最好但要求模型复现内容。
|
|
38
36
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* 调用方可以传 `assert` 做内容校验,五层都适用——那才是防止"指到错的行"的那道闸。
|
|
37
|
+
* 前两层偏**精确**(要么命中要么报错),后两层偏**容错**。
|
|
38
|
+
* 调用方可以传 `assert` 做内容校验,四层都适用——那才是防止"指到错的行"的那道闸。
|
|
42
39
|
* 判据是**包含且唯一**:这段文字必须在整个文件里只出现一处,且该处落在受寻址区间内
|
|
43
40
|
* (短引文例外,仍要求与区间整段相等;推导见 {@link ASSERT_RELAX_MIN_CHARS})。
|
|
44
41
|
*
|
|
45
42
|
* @param {string} source - 源码全文
|
|
46
43
|
* @param {object} request - 定位请求
|
|
47
|
-
* @param {string} [request.intent] - 模糊意图地址(符号名片段 / 短语 / 近似原文)
|
|
48
44
|
* @param {string} [request.target] - 符号名(`name` / `kind:name` / `Outer/Inner` / `name#n`)
|
|
49
45
|
* @param {{start:number,end:number}} [request.lines] - 1-based 闭区间行号
|
|
50
46
|
* @param {string} [request.lineCue] - 一个词的线索,必须全文件唯一
|
|
@@ -644,53 +640,14 @@ function assertVerdict(lines, startLine, endLine, tier, actual, assertText) {
|
|
|
644
640
|
}
|
|
645
641
|
}
|
|
646
642
|
|
|
647
|
-
/**
|
|
648
|
-
* 意图地址(T16)的符号子串匹配:这个候选符号离 `near` 有多远。
|
|
649
|
-
*
|
|
650
|
-
* 判据只有一条:**`near` 落在符号的跨度里就是 0**,否则取到两端中较近的那一端。
|
|
651
|
-
* 与 `resolveTarget` 的 `|startLine - near|` 刻意不同:`intent` 匹配的是"大概想改哪个符号",
|
|
652
|
-
* 调用方给的 `near` 常常是**符号体内**的一行(它刚从 read 回执里抄来),拿起点算距离会把
|
|
653
|
-
* 一个明显在说的符号判成第二近。跨度包含 `near` ⇒ 距离 0,是这里唯一能自洽的口径。
|
|
654
|
-
*
|
|
655
|
-
* @param - 符号投影(含 startLine / endLine)
|
|
656
|
-
* @param near - 1-based 行号提示
|
|
657
|
-
* @returns 非负整数距离
|
|
658
|
-
*/
|
|
659
|
-
function symbolDistanceFrom(symbol, near) {
|
|
660
|
-
if (near >= symbol.startLine && near <= symbol.endLine) return 0
|
|
661
|
-
return Math.min(Math.abs(near - symbol.startLine), Math.abs(near - symbol.endLine))
|
|
662
|
-
}
|
|
663
643
|
|
|
664
644
|
/**
|
|
665
|
-
*
|
|
645
|
+
* 定位一个区间。四层降级:符号身份 → 行号 → 唯一词 → 文本锚点。
|
|
666
646
|
*
|
|
667
|
-
* 不唯一(并列最小)⇒ null:那正是"该报歧义"的形状,猜测在这里比拒绝贵得多。
|
|
668
|
-
* @returns 命中的符号,或 null(near 没给 / 并列)
|
|
669
|
-
*/
|
|
670
|
-
function nearestSymbolBySpan(symbols, near) {
|
|
671
|
-
let best = null
|
|
672
|
-
let bestDistance = Infinity
|
|
673
|
-
let ties = 0
|
|
674
|
-
for (const symbol of symbols) {
|
|
675
|
-
const distance = symbolDistanceFrom(symbol, near)
|
|
676
|
-
if (distance < bestDistance) {
|
|
677
|
-
bestDistance = distance
|
|
678
|
-
best = symbol
|
|
679
|
-
ties = 1
|
|
680
|
-
} else if (distance === bestDistance) ties += 1
|
|
681
|
-
}
|
|
682
|
-
return ties === 1 ? best : null
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* 定位一个区间。五层降级:意图 → 符号身份 → 行号 → 唯一词 → 文本锚点。
|
|
687
|
-
*
|
|
688
|
-
* `intent`(T16)是**最弱但最自然**的一层:调用方给一个符号名片段、短语或近似原文,
|
|
689
|
-
* 引擎先当符号名解析(精确/限定写法),再按符号名子串匹配,最后才落到文本匹配。
|
|
690
647
|
* 每一档都**确定性地**收敛到一个区间;收敛不了就报歧义并给候选,不猜。
|
|
691
648
|
*
|
|
692
649
|
* @param source - 文件全文
|
|
693
|
-
* @param request - `
|
|
650
|
+
* @param request - `target` / `lines` / `lineCue` / `anchor` 四选一,外加 `near` 与 `assert`
|
|
694
651
|
* @returns 命中时 `{ok: true, startLine, endLine, tier, actual}`;未命中时 `{ok: false, code, message, ...}`。
|
|
695
652
|
* `actual` 是该区间的原文,供调用方核对或展示。
|
|
696
653
|
*/
|
|
@@ -761,125 +718,6 @@ export function locate(source, request = {}) {
|
|
|
761
718
|
return { ok: false, code: 'LOCATOR_LINE_SHARED', message, shared }
|
|
762
719
|
}
|
|
763
720
|
|
|
764
|
-
// ── 第 0 层:意图地址(T16)。调用方给一段模糊的意图串,引擎确定性地落成精确区间。
|
|
765
|
-
//
|
|
766
|
-
// 解析次序(每一档都不猜,落不下来就报歧义并给候选):
|
|
767
|
-
// ① 已知语言时,先当符号名解析(`resolveTarget` 的精确 / 限定写法)——命中就算 symbol 档;
|
|
768
|
-
// ② 符号名没对上(或对上多个)⇒ 按符号名**子串**匹配(先区分大小写,再忽略大小写);
|
|
769
|
-
// ③ 一个符号都没命中(或语言未知)⇒ 落到文本匹配(`locateWith`,与 `anchor` 同一份引擎)。
|
|
770
|
-
//
|
|
771
|
-
// 顺序上刻意在 `target` 之前:`intent` 与其余地址字段**互斥**(`PATCH_INTENT_CONFLICT`,
|
|
772
|
-
// 由 `planHunks` 判),所以两者同时到达这里是不可能的;放在最前只是把"intent 自成一层"写明。
|
|
773
|
-
if (typeof request.intent === 'string' && request.intent.trim().length > 0) {
|
|
774
|
-
const intent = request.intent.trim()
|
|
775
|
-
const near = request.near ?? null
|
|
776
|
-
const label = clipReceiptLabel(filePath === null ? 'this file' : filePath, RECEIPT_LABEL_CHARS)
|
|
777
|
-
/** 跨度覆盖不了符号体时的拒绝——与 `target=` 那一支**同一句话、同一个码**(不是第二套说法)。 */
|
|
778
|
-
const spanIncomplete = (hit) => ({
|
|
779
|
-
ok: false,
|
|
780
|
-
code: 'LOCATOR_SPAN_INCOMPLETE',
|
|
781
|
-
message: `cannot address "${hit.name}" by intent in ${label}: the span does not cover the body — ${hit.spanNote}; address it with lines= or line_cue= instead`,
|
|
782
|
-
hint: directoryHint(source, lang),
|
|
783
|
-
})
|
|
784
|
-
if (lang !== null) {
|
|
785
|
-
const index = indexStructure(source, lang)
|
|
786
|
-
/**
|
|
787
|
-
* 精确/限定符号:先**不带 near** 解析,拿到唯一命中或完整候选。
|
|
788
|
-
* 带 near 交给 `resolveTarget` 会用“起点距离”,与 intent 档的“跨度包含 near 记 0”冲突,
|
|
789
|
-
* 实测会把 near 落在第一个长函数体内的情况选到第二个同名函数上。歧义时这里用
|
|
790
|
-
* `nearestSymbolBySpan` 按跨度收敛;解析不了再落到子串匹配。
|
|
791
|
-
*/
|
|
792
|
-
let exact = null
|
|
793
|
-
try {
|
|
794
|
-
exact = resolveTarget(index, intent, null)
|
|
795
|
-
} catch (error) {
|
|
796
|
-
if (error.code === 'STRUCT_AMBIGUOUS') {
|
|
797
|
-
const candidates = Array.isArray(error.candidates) ? error.candidates : []
|
|
798
|
-
if (near !== null && near !== undefined) {
|
|
799
|
-
const chosen = nearestSymbolBySpan(candidates, near)
|
|
800
|
-
if (chosen !== null) {
|
|
801
|
-
if (chosen.spanComplete === false) return spanIncomplete(chosen)
|
|
802
|
-
return done(chosen.startLine, chosen.endLine, 'intent~symbol', true)
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
return failure('LOCATOR_INTENT_AMBIGUOUS', `intent ${JSON.stringify(intent)}: ${error.message}`, candidates)
|
|
806
|
-
}
|
|
807
|
-
// STRUCT_NOT_FOUND / 其它:符号名没猜中不是终点,落到子串匹配(下面)。
|
|
808
|
-
}
|
|
809
|
-
if (exact !== null) {
|
|
810
|
-
if (exact.spanComplete === false) return spanIncomplete(exact)
|
|
811
|
-
const shared = sharedLineContent(source, index, exact, lang)
|
|
812
|
-
if (shared !== null) return sharedLineRefusal(exact, shared, 'intent')
|
|
813
|
-
return done(exact.startLine, exact.endLine, 'intent~symbol')
|
|
814
|
-
}
|
|
815
|
-
// 用原始符号表而不是 `listSymbols` 投影:整行区间是否越界要看 token,投影里没有。
|
|
816
|
-
const symbols = index.symbols
|
|
817
|
-
let matches = symbols.filter((s) => s.name.includes(intent))
|
|
818
|
-
if (matches.length === 0) {
|
|
819
|
-
const lower = intent.toLowerCase()
|
|
820
|
-
matches = symbols.filter((s) => s.name.toLowerCase().includes(lower))
|
|
821
|
-
}
|
|
822
|
-
if (matches.length === 1) {
|
|
823
|
-
const only = matches[0]
|
|
824
|
-
if (only.spanComplete === false) return spanIncomplete(only)
|
|
825
|
-
const shared = sharedLineContent(source, index, only, lang)
|
|
826
|
-
if (shared !== null) return sharedLineRefusal(only, shared, 'intent')
|
|
827
|
-
return done(only.startLine, only.endLine, 'intent~symbol')
|
|
828
|
-
}
|
|
829
|
-
if (matches.length > 1) {
|
|
830
|
-
if (near !== null && near !== undefined) {
|
|
831
|
-
const chosen = nearestSymbolBySpan(matches, near)
|
|
832
|
-
if (chosen !== null) {
|
|
833
|
-
if (chosen.spanComplete === false) return spanIncomplete(chosen)
|
|
834
|
-
const shared = sharedLineContent(source, index, chosen, lang)
|
|
835
|
-
if (shared !== null) return sharedLineRefusal(chosen, shared, 'intent')
|
|
836
|
-
return done(chosen.startLine, chosen.endLine, 'intent~symbol', true)
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
const why =
|
|
840
|
-
near === null || near === undefined
|
|
841
|
-
? `intent ${JSON.stringify(intent)} matched ${matches.length} symbols — pick one with target=<name>, or add near=<line>`
|
|
842
|
-
: `intent ${JSON.stringify(intent)} matched ${matches.length} symbols and near=${near} did not single one out`
|
|
843
|
-
return failure(
|
|
844
|
-
'LOCATOR_INTENT_AMBIGUOUS',
|
|
845
|
-
why,
|
|
846
|
-
matches.map((s) => ({
|
|
847
|
-
name: s.name,
|
|
848
|
-
kind: s.kind,
|
|
849
|
-
startLine: s.startLine,
|
|
850
|
-
endLine: s.endLine,
|
|
851
|
-
enclosing: s.enclosing,
|
|
852
|
-
})),
|
|
853
|
-
)
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
// ── ③ 文本兜底:与 `anchor` 同一份引擎(`locateWith`);tier 里带上它命中的那一档
|
|
857
|
-
try {
|
|
858
|
-
const loc = locateWith(indexFile(source), intent, near)
|
|
859
|
-
return done(loc.start + 1, loc.end, `intent~text~${loc.tier}`, loc.byNear === true)
|
|
860
|
-
} catch (error) {
|
|
861
|
-
if (error.code === 'PATCH_AMBIGUOUS') {
|
|
862
|
-
/**
|
|
863
|
-
* 行候选统一成 `{line, text}`(`failure` 只认这一形状才会印出行号与原文):
|
|
864
|
-
* `locateWith` 抛的是 `number[]`,直接递下去会印成 `line undefined`。
|
|
865
|
-
*/
|
|
866
|
-
const listed = (Array.isArray(error.candidates) ? error.candidates : [])
|
|
867
|
-
.filter((n) => typeof n === 'number' && n >= 1 && n <= lines.length)
|
|
868
|
-
.map((n) => ({ line: n, text: lines[n - 1].slice(0, 80) }))
|
|
869
|
-
return failure('LOCATOR_INTENT_AMBIGUOUS', `intent ${JSON.stringify(intent)}: ${error.message}`, listed)
|
|
870
|
-
}
|
|
871
|
-
if (error.code === 'PATCH_NOT_FOUND' || error.code === 'PATCH_EMPTY_ANCHOR') {
|
|
872
|
-
return {
|
|
873
|
-
ok: false,
|
|
874
|
-
code: 'LOCATOR_INTENT_NOT_FOUND',
|
|
875
|
-
message: `no symbol or text matches intent ${JSON.stringify(intent)} in ${label}; try target=<name>, lines=, or line_cue=`,
|
|
876
|
-
// 符号目录是实测支撑的恢复路径(与 anchor 失败同一条):只有认识这门语言时才给得出。
|
|
877
|
-
...(lang === null ? {} : { hint: directoryHint(source, lang) }),
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
return failure(error.code ?? 'LOCATOR_FAILED', error.message, error.candidates)
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
721
|
|
|
884
722
|
// ── 第 1 层:符号身份。不读文本,因此对内容记错免疫。
|
|
885
723
|
if (typeof request.target === 'string' && request.target.trim().length > 0) {
|
|
@@ -1028,7 +866,7 @@ export function locate(source, request = {}) {
|
|
|
1028
866
|
}
|
|
1029
867
|
}
|
|
1030
868
|
|
|
1031
|
-
return { ok: false, code: 'LOCATOR_NO_REQUEST', message: 'provide one of:
|
|
869
|
+
return { ok: false, code: 'LOCATOR_NO_REQUEST', message: 'provide one of: target, lines, lineCue, anchor' }
|
|
1032
870
|
}
|
|
1033
871
|
|
|
1034
872
|
/**
|
|
@@ -2577,35 +2577,6 @@ function locateByHandle(lines, hunk, parsed, ctx) {
|
|
|
2577
2577
|
})
|
|
2578
2578
|
}
|
|
2579
2579
|
|
|
2580
|
-
/**
|
|
2581
|
-
* `intent` 与哪些字段**不能同时出现**(T16)。
|
|
2582
|
-
*
|
|
2583
|
-
* `intent` 是**自成一体的一层地址**:它由引擎决定落到哪个精确区间。若同时给出任何别的地址,
|
|
2584
|
-
* 谁说了算就没有正确答案——挑一个执行就是一次静默换语义(调用方写的两个地址指向不同地方时,
|
|
2585
|
-
* 改错的那次不会有任何提示)。所以参数面直接拒绝,而不是挑一个。
|
|
2586
|
-
*
|
|
2587
|
-
* 名单与 {@link planHunks} 的地址判据同源:`target` / `lines`(含别名 `range`)/ `start_line` /
|
|
2588
|
-
* `end_line` / `line_cue` / `anchor` / `old` / `context` / `handle`。
|
|
2589
|
-
* `near` / `expect` / `replace` **不在**名单里:前两个是修饰,第三个是动作。
|
|
2590
|
-
*
|
|
2591
|
-
* 「在场」的判据逐字段照搬它自己那一层的:字符串看非空(`anchor` 看长度、其余看 trim),
|
|
2592
|
-
* 坐标看 `!== undefined && !== null`。判据写宽或写窄都会让某个形状在参数面与引擎里得到两种结论。
|
|
2593
|
-
*
|
|
2594
|
-
* @returns 冲突的字段名(按上面的固定顺序);没有冲突时为空数组
|
|
2595
|
-
*/
|
|
2596
|
-
function intentAddressConflicts(hunk) {
|
|
2597
|
-
const clashes = []
|
|
2598
|
-
if (typeof hunk.target === 'string' && hunk.target.trim().length > 0) clashes.push('target')
|
|
2599
|
-
for (const key of ['lines', 'range', 'start_line', 'end_line']) {
|
|
2600
|
-
if (hunk[key] !== undefined && hunk[key] !== null) clashes.push(key)
|
|
2601
|
-
}
|
|
2602
|
-
if (typeof hunk.line_cue === 'string' && hunk.line_cue.trim().length > 0) clashes.push('line_cue')
|
|
2603
|
-
if (typeof hunk.anchor === 'string' && hunk.anchor.length > 0) clashes.push('anchor')
|
|
2604
|
-
if (oldAddressOf(hunk) !== null) clashes.push('old')
|
|
2605
|
-
if (typeof hunk.context === 'string' && hunk.context.trim().length > 0) clashes.push('context')
|
|
2606
|
-
if (typeof hunk.handle === 'string' && hunk.handle.trim().length > 0) clashes.push('handle')
|
|
2607
|
-
return clashes
|
|
2608
|
-
}
|
|
2609
2580
|
|
|
2610
2581
|
/**
|
|
2611
2582
|
* 规划所有 hunk:逐个定位 → expect 核对 → 重叠检查 → 倒序拼接。
|
|
@@ -2661,7 +2632,7 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
|
|
|
2661
2632
|
*
|
|
2662
2633
|
* 它不是第六种地址,而是 `old` 的一种作用范围;因此只认 `old` + 动作字段(`replace` /
|
|
2663
2634
|
* `expect` / `eol`)。把 `target`/`lines`/`context`/`near` 混进来意味着"既想全改又想收敛到一处",
|
|
2664
|
-
* 没有正确答案 ——
|
|
2635
|
+
* 没有正确答案 —— 参数面直接拒绝,与地址字段互斥同一条理由。
|
|
2665
2636
|
*/
|
|
2666
2637
|
const replaceAll = hunk.replace_all
|
|
2667
2638
|
if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
|
|
@@ -2673,7 +2644,7 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
|
|
|
2673
2644
|
if (replaceAll === true) {
|
|
2674
2645
|
const clashes = []
|
|
2675
2646
|
if (hasTarget) clashes.push('target')
|
|
2676
|
-
|
|
2647
|
+
|
|
2677
2648
|
for (const key of ['lines', 'range', 'start_line', 'end_line']) {
|
|
2678
2649
|
if (hunk[key] !== undefined && hunk[key] !== null) clashes.push(key)
|
|
2679
2650
|
}
|
|
@@ -2708,23 +2679,6 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
|
|
|
2708
2679
|
)
|
|
2709
2680
|
}
|
|
2710
2681
|
}
|
|
2711
|
-
/**
|
|
2712
|
-
* 意图地址(T16):`intent` 是**自成一体**的一层地址,与其余地址字段互斥。
|
|
2713
|
-
*
|
|
2714
|
-
* 冲突在**定位之前**就拒(参数面没成形,与 `PATCH_NO_ADDRESS` 同族),而不是在引擎里挑一个:
|
|
2715
|
-
* 调用方写了两个地址说明它自己也没定,工具替它选一次就是一次看不见的换语义。
|
|
2716
|
-
* `near` / `expect` / `replace` 不在此列——前两个是修饰,第三个是动作。
|
|
2717
|
-
*/
|
|
2718
|
-
const hasIntent = typeof hunk.intent === 'string' && hunk.intent.trim().length > 0
|
|
2719
|
-
if (hasIntent) {
|
|
2720
|
-
const clashed = intentAddressConflicts(hunk)
|
|
2721
|
-
if (clashed.length > 0) {
|
|
2722
|
-
throw new PatchError(
|
|
2723
|
-
`\`intent\` is a complete address by itself — it cannot be combined with ${clashed.join(', ')} (near / expect / replace are fine); drop either intent or those fields — nothing was written`,
|
|
2724
|
-
'PATCH_INTENT_CONFLICT',
|
|
2725
|
-
)
|
|
2726
|
-
}
|
|
2727
|
-
}
|
|
2728
2682
|
/** 句柄的形状在这里判(不对就抛错);`{handle, old}` 那一支的 `old` 仍然要命中区间内部。 */
|
|
2729
2683
|
const parsedHandle = handleAddressOf(hunk)
|
|
2730
2684
|
/**
|
|
@@ -2752,9 +2706,9 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
|
|
|
2752
2706
|
'PATCH_NO_ADDRESS',
|
|
2753
2707
|
)
|
|
2754
2708
|
}
|
|
2755
|
-
if (oldText === null && parsedHandle === null && !hasTarget && range === undefined && !hasCue && !hasAnchor
|
|
2709
|
+
if (oldText === null && parsedHandle === null && !hasTarget && range === undefined && !hasCue && !hasAnchor) {
|
|
2756
2710
|
throw new PatchError(
|
|
2757
|
-
gaveLineAddress(hunk) ? LINE_ADDRESS_HINT : 'each hunk needs one of: old,
|
|
2711
|
+
gaveLineAddress(hunk) ? LINE_ADDRESS_HINT : 'each hunk needs one of: old, target, lines, line_cue, anchor',
|
|
2758
2712
|
'PATCH_NO_ADDRESS',
|
|
2759
2713
|
)
|
|
2760
2714
|
}
|
|
@@ -2776,29 +2730,16 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
|
|
|
2776
2730
|
? locateAllByOld(lines, hunk, oldText, filePath === null ? 'this file' : filePath, handleCtx)
|
|
2777
2731
|
: [locateByOld(lines, hunk, oldText, filePath === null ? 'this file' : filePath, handleCtx)]
|
|
2778
2732
|
} else {
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
? {
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
...(filePath === null ? {} : { filePath }),
|
|
2790
|
-
...(options.allowLoneCr === true ? { allowLoneCr: true } : {}),
|
|
2791
|
-
}
|
|
2792
|
-
: {
|
|
2793
|
-
...(hasTarget ? { target: hunk.target } : {}),
|
|
2794
|
-
...(range === undefined ? {} : { lines: range }),
|
|
2795
|
-
...(hunk.line_cue === undefined ? {} : { lineCue: hunk.line_cue }),
|
|
2796
|
-
...(hasTarget || range !== undefined || hunk.line_cue !== undefined ? {} : { anchor: hunk.anchor }),
|
|
2797
|
-
...(hunk.near === undefined ? {} : { near: hunk.near }),
|
|
2798
|
-
...(hunk.expect === undefined || hunk.expect === null ? {} : { assert: hunk.expect }),
|
|
2799
|
-
...(filePath === null ? {} : { filePath }),
|
|
2800
|
-
...(options.allowLoneCr === true ? { allowLoneCr: true } : {}),
|
|
2801
|
-
}
|
|
2733
|
+
const request = {
|
|
2734
|
+
...(hasTarget ? { target: hunk.target } : {}),
|
|
2735
|
+
...(range === undefined ? {} : { lines: range }),
|
|
2736
|
+
...(hunk.line_cue === undefined ? {} : { lineCue: hunk.line_cue }),
|
|
2737
|
+
...(hasTarget || range !== undefined || hunk.line_cue !== undefined ? {} : { anchor: hunk.anchor }),
|
|
2738
|
+
...(hunk.near === undefined ? {} : { near: hunk.near }),
|
|
2739
|
+
...(hunk.expect === undefined || hunk.expect === null ? {} : { assert: hunk.expect }),
|
|
2740
|
+
...(filePath === null ? {} : { filePath }),
|
|
2741
|
+
...(options.allowLoneCr === true ? { allowLoneCr: true } : {}),
|
|
2742
|
+
}
|
|
2802
2743
|
const found = locate(content, request)
|
|
2803
2744
|
if (!found.ok) {
|
|
2804
2745
|
throw new PatchError(found.message, found.code, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aura-basetools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Byte-exact read, edit and write tools for coding agents. Standalone CLI and MCP server; no DSH required. 面向 coding agent 的逐字节读写工具;独立 CLI / MCP,可选 DSH 适配器。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/test/standalone-test.mjs
CHANGED
|
@@ -179,7 +179,7 @@ say('B2 能力清单:capabilities 从引擎常量派生')
|
|
|
179
179
|
check(caps.ok === true && caps.langsCount === caps.langs?.length, `langsCount=${caps.langsCount}`, JSON.stringify(caps.langs?.length))
|
|
180
180
|
check(caps.langsCount === 65, '语言表 65 种(与 LANGS 同源)', String(caps.langsCount))
|
|
181
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('
|
|
182
|
+
check(editTool?.addressKinds?.includes('target') && editTool.addressKinds.includes('handle'), '编辑地址档含 target/handle', JSON.stringify(editTool?.addressKinds))
|
|
183
183
|
check(editTool?.options?.includes('replace_all'), '编辑选项含 replace_all', JSON.stringify(editTool?.options))
|
|
184
184
|
check(editTool?.options?.includes('require_observation'), '编辑选项含 require_observation', JSON.stringify(editTool?.options))
|
|
185
185
|
check(caps.limits?.multiTargetsMax === 32 && caps.limits?.defaultPruneBudget === 8192 && caps.limits?.contentBudget === 7492, '限制与常量一致', JSON.stringify(caps.limits))
|
|
@@ -385,7 +385,7 @@ say('F MCP:initialize / tools/list / tools/call')
|
|
|
385
385
|
})()
|
|
386
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
387
|
const tools = byId.get(2)?.result?.tools ?? []
|
|
388
|
-
check(tools.length ===
|
|
388
|
+
check(tools.length === 3 && 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/list 三个工具(${tools.map((t) => t.name).join(',')}`)
|
|
389
389
|
const readEnv = JSON.parse(byId.get(3)?.result?.content?.[0]?.text ?? '{}')
|
|
390
390
|
check(readEnv.ok === true && Array.isArray(readEnv.symbols ?? []), 'aura_basetools_read 经 MCP 可用', JSON.stringify(readEnv).slice(0, 120))
|
|
391
391
|
const editEnv = JSON.parse(byId.get(4)?.result?.content?.[0]?.text ?? '{}')
|
|
@@ -1537,49 +1537,9 @@ say('N provider:mode / symlink / hardlink / range / 上限 / cache')
|
|
|
1537
1537
|
}
|
|
1538
1538
|
}
|
|
1539
1539
|
|
|
1540
|
-
say('O
|
|
1540
|
+
say('O 写失败回滚与锁(0.14.0 已移除公开 undo/transactions)')
|
|
1541
1541
|
{
|
|
1542
|
-
|
|
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 多目标:一个成功、一个写失败 ⇒ 成功那个也必须回滚
|
|
1542
|
+
// O7 多目标:一个成功、一个写失败 ⇒ 成功那个也必须回滚(内部事务日志仍保留)
|
|
1583
1543
|
const okFile = path.join(tmp, 'tx-rollback-ok.txt')
|
|
1584
1544
|
const hardBase = path.join(tmp, 'tx-rollback-hard-base.txt')
|
|
1585
1545
|
const hardFile = path.join(tmp, 'tx-rollback-hard.txt')
|
|
@@ -1594,31 +1554,13 @@ say('O 写事务:journal / undo / 多目标回滚')
|
|
|
1594
1554
|
{ file_path: okFile, hunks: [{ lines: [1, 1], replace: 'ALPHA' }] },
|
|
1595
1555
|
{ file_path: hardFile, hunks: [{ lines: [1, 1], replace: 'BETA' }] },
|
|
1596
1556
|
]
|
|
1597
|
-
const rollback = json(run(['basetools', 'edit', '--targets', JSON.stringify(rollbackTargets)
|
|
1598
|
-
check(rollback.ok === false && rollback.
|
|
1557
|
+
const rollback = json(run(['basetools', 'edit', '--targets', JSON.stringify(rollbackTargets)]))
|
|
1558
|
+
check(rollback.ok === false && rollback.rollback?.status === 'rolled_back' && rollback.rolled_back === true, 'O7 多目标部分失败 ⇒ 自动回滚', JSON.stringify({ ok: rollback.ok, rollback: rollback.rollback }))
|
|
1599
1559
|
check(rollback.rollback?.restored === 1 && fs.readFileSync(okFile, 'utf8') === 'alpha\n', 'O7 已落地的那个文件被恢复')
|
|
1600
1560
|
check(fs.readFileSync(hardBase, 'utf8') === 'beta\n' && fs.readFileSync(hardFile, 'utf8') === 'beta\n', 'O7 硬链接目标未被改动')
|
|
1601
|
-
// 回执必须等于**回滚后的最终磁盘状态**,不能继续报 landed/applied。
|
|
1602
1561
|
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
1562
|
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
1563
|
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
1564
|
}
|
|
1623
1565
|
|
|
1624
1566
|
// O8 锁:同一工作区已有写事务时,第二个编辑必须超时拒绝
|
package/test/write-test.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 覆盖:
|
|
5
5
|
* A 新建:真正建盘、回执 operation=create、dry-run 不建盘、事务可撤销(撤销后文件消失)
|
|
6
6
|
* B 覆写:整份替换、BOM 与文件既有行尾保留、有无尾换行精确、no-overwrite、dry-run、
|
|
7
|
-
*
|
|
7
|
+
* `--content @file`(0.14.0 移除公开事务/undo)
|
|
8
8
|
* C MCP:aura_basetools_write 出现在 tools/list,create/overwrite/no_overwrite/dry_run/坏参数都走同一引擎
|
|
9
9
|
* D 能力清单:commands 与 tools 都包含 write
|
|
10
10
|
*
|
|
@@ -55,7 +55,7 @@ process.on('exit', () => {
|
|
|
55
55
|
})
|
|
56
56
|
|
|
57
57
|
// ── A 新建 ────────────────────────────────────────────────────────────────
|
|
58
|
-
say('A 新建:整份建盘,dry-run
|
|
58
|
+
say('A 新建:整份建盘,dry-run 不建盘')
|
|
59
59
|
{
|
|
60
60
|
const created = json(run(['basetools', 'write', '--file', 'created.txt', '--content', 'alpha\nbeta\n']))
|
|
61
61
|
check(created.ok === true && created.result?.operation === 'create', 'A1 新建回执 operation=create', JSON.stringify(created).slice(0, 160))
|
|
@@ -66,13 +66,6 @@ say('A 新建:整份建盘,dry-run 不建盘,事务可撤销')
|
|
|
66
66
|
check(dry.ok === true && dry.result?.operation === 'create' && dry.result?.dryRun === true && dry.result?.bytes === 1, 'A4 新建 dry-run 回执给 create/bytes,但不落盘', JSON.stringify(dry).slice(0, 160))
|
|
67
67
|
check(fs.existsSync(file('dry-new.txt')) === false, 'A5 新建 dry-run 之后文件仍不存在')
|
|
68
68
|
|
|
69
|
-
const tx = json(run(['basetools', 'write', '--file', 'tx-created.txt', '--content', 'gone\n', '--transaction']))
|
|
70
|
-
const txId = tx.result?.transaction?.id
|
|
71
|
-
check(tx.ok === true && typeof txId === 'string' && tx.result.transaction.status === 'committed', 'A6 --transaction 返回 committed 事务', JSON.stringify(tx.result?.transaction))
|
|
72
|
-
const undone = json(run(['basetools', 'undo', '--id', txId]))
|
|
73
|
-
check(undone.ok === true && undone.status === 'undone', 'A7 undo 成功', JSON.stringify(undone).slice(0, 160))
|
|
74
|
-
check(fs.existsSync(file('tx-created.txt')) === false, 'A8 撤销“新建”后文件消失(不留下空文件)')
|
|
75
|
-
|
|
76
69
|
const missingFile = json(run(['basetools', 'write', '--content', 'x']))
|
|
77
70
|
const missingContent = json(run(['basetools', 'write', '--file', 'x.txt']))
|
|
78
71
|
check(missingFile.ok === false && missingFile.code === 'USAGE', 'A9 缺 --file ⇒ USAGE', JSON.stringify(missingFile).slice(0, 140))
|
|
@@ -119,14 +112,6 @@ say('B 覆写:整份替换,BOM / 既有行尾 / 尾换行精确')
|
|
|
119
112
|
const newNoOverwrite = json(run(['basetools', 'write', '--file', 'brand-new.txt', '--content', 'ok\n', '--no-overwrite']))
|
|
120
113
|
check(newNoOverwrite.ok === true, 'B11 no-overwrite 对不存在的文件照常创建')
|
|
121
114
|
|
|
122
|
-
fs.writeFileSync(file('tx-restore.txt'), Buffer.concat([BOM, Buffer.from('orig\r\nbytes\r\n', 'utf8')]))
|
|
123
|
-
prime('tx-restore.txt')
|
|
124
|
-
const original = fs.readFileSync(file('tx-restore.txt'))
|
|
125
|
-
const tx = json(run(['basetools', 'write', '--file', 'tx-restore.txt', '--content', 'changed\n', '--transaction']))
|
|
126
|
-
check(!fs.readFileSync(file('tx-restore.txt')).equals(original), 'B12 覆写真的改了盘')
|
|
127
|
-
const undone = json(run(['basetools', 'undo', '--id', tx.result?.transaction?.id]))
|
|
128
|
-
check(undone.ok === true && fs.readFileSync(file('tx-restore.txt')).equals(original), 'B13 undo 逐字节恢复覆写前的原文(含 BOM/CRLF)')
|
|
129
|
-
|
|
130
115
|
fs.writeFileSync(file('content-source.txt'), 'from @file\nsecond line\n')
|
|
131
116
|
const atFile = json(run(['basetools', 'write', '--file', 'from-at.txt', '--content', '@' + file('content-source.txt')]))
|
|
132
117
|
check(atFile.ok === true && fs.readFileSync(file('from-at.txt'), 'utf8') === fs.readFileSync(file('content-source.txt'), 'utf8'), 'B14 --content @file 读入整份内容')
|
|
@@ -225,7 +210,7 @@ say('C MCP:aura_basetools_write 声明面与调用面')
|
|
|
225
210
|
|
|
226
211
|
const tools = byId.get(2)?.result?.tools ?? []
|
|
227
212
|
const writeTool = tools.find((tool) => tool.name === 'aura_basetools_write')
|
|
228
|
-
check(tools.length ===
|
|
213
|
+
check(tools.length === 3 && writeTool !== undefined, `C1 tools/list 三个工具且含 aura_basetools_write(${tools.map((tool) => tool.name).join(',')})`)
|
|
229
214
|
check(JSON.stringify(writeTool?.inputSchema?.required) === JSON.stringify(['file_path', 'content']), 'C2 aura_basetools_write schema 必填 file_path + content', JSON.stringify(writeTool?.inputSchema?.required))
|
|
230
215
|
check(envOf(3).ok === true && envOf(3).result?.operation === 'create', 'C3 经 MCP 新建成功', JSON.stringify(envOf(3)).slice(0, 160))
|
|
231
216
|
check(fs.readFileSync(file('mcp-created.txt'), 'utf8') === 'mcp\ncreate\n', 'C4 MCP 新建的内容真的落盘')
|