aura-basetools 0.13.2 → 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.
@@ -1,17 +1,16 @@
1
1
  /**
2
- * transaction-store.mjs —— 写事务日志、回滚与 undo。
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(写成功,可 undo)
9
+ * pending → committed(写成功;日志仅用于内部回滚,不再提供 undo)
10
10
  * → rolled_back(写失败,已恢复原字节)
11
11
  * → recovery_required(写失败且恢复也失败;meta 里留着可手工恢复的路径)
12
- * committed → undone(undo 成功)
13
12
  *
14
- * 回滚/undo 前会比一次 `afterVersion`:文件在事务之后又被别人改过就拒绝覆盖(`--force` 才强制)。
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
- }
@@ -126,12 +126,12 @@ export function normalizeReadRequest(args, totalLines = undefined) {
126
126
  * 导出它是为了不再出现第二份:守卫要拿它算“合法键集合”,而如果有两份名单,
127
127
  * 新增一种地址写法时必有一处漏改——那正是本项目反复出现的形状。
128
128
  */
129
- export const FLAT_ADDRESS_KEYS = ['target', 'intent', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'handle', 'old']
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` 本身、批量级的 `include_transaction` 都不是元素参数;
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', 'intent', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'old', 'context',
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. `intent` —— 一段模糊意图(符号名片段 / 短语 / 近似原文)。引擎按固定次序收敛:
30
- * 精确符号 → 符号子串 → 文本锚点;收敛不了就报歧义并给候选,绝不猜。
31
- * 2. `target` —— 符号名。不读文本,对内容记错免疫。适合改整个函数/类/类型。
32
- * 3. `lines` —— 行号区间。模型刚用 `read` 看过文件,输出就是 `<行号>: <文本>`,
29
+ * 1. `target` —— 符号名。不读文本,对内容记错免疫。适合改整个函数/类/类型。
30
+ * 2. `lines` —— 行号区间。模型刚用 `read` 看过文件,输出就是 `<行号>: <文本>`,
33
31
  * `grep` 也给行号——**位置是它本来就知道的信息**,我却一直只让它给内容。
34
32
  * 把行号纳入地址空间后,符号体内的任意一行都变得可精确寻址。
35
- * 4. `lineCue` —— 一个词。当模型记不住行号时,说"就在有 resolvePolicy 的那一行"。
33
+ * 3. `lineCue` —— 一个词。当模型记不住行号时,说"就在有 resolvePolicy 的那一行"。
36
34
  * 只接受**全文件唯一**的线索;不唯一则报歧义并给出候选行号,绝不猜。
37
- * 5. `anchor` —— 记得的原文。五级降级匹配,容错性最好但要求模型复现内容。
35
+ * 4. `anchor` —— 记得的原文。五级降级匹配,容错性最好但要求模型复现内容。
38
36
  *
39
- * `intent` 与其余四层**互斥**(由 `planHunks` 判):同时给两个地址时谁说了算没有正确答案。
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
- * 多个子串命中里,`near` 能否**严格**收敛到一个(唯一最小值)。
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 - `intent` / `target` / `lines` / `lineCue` / `anchor` 五选一,外加 `near` 与 `assert`
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: intent, target, lines, lineCue, anchor' }
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
- * 没有正确答案 —— 参数面直接拒绝,与 `intent` 的互斥同一条理由。
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
- if (typeof hunk.intent === 'string' && hunk.intent.trim().length > 0) clashes.push('intent')
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 && !hasIntent) {
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, intent, target, lines, line_cue, anchor',
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
- * 意图地址(T16)单独走一条请求:只带 `intent` / `near` / `expect` / `filePath`。
2781
- * 冲突字段在上面已经拒了,这里刻意**不再把其余地址字段递下去**——即便哪天冲突判据放宽,
2782
- * 引擎也不该从第二个地址里"顺手"拿到位置(那正是静默换语义的来路)。
2783
- */
2784
- const request = hasIntent
2785
- ? {
2786
- intent: hunk.intent,
2787
- ...(hunk.near === undefined ? {} : { near: hunk.near }),
2788
- ...(hunk.expect === undefined || hunk.expect === null ? {} : { assert: hunk.expect }),
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.13.2",
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",
@@ -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('intent') && editTool.addressKinds.includes('target') && editTool.addressKinds.includes('handle'), '编辑地址档含 intent/target/handle', JSON.stringify(editTool?.addressKinds))
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 === 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(',')}`)
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 写事务:journal / undo / 多目标回滚')
1540
+ say('O 写失败回滚与锁(0.14.0 已移除公开 undo/transactions)')
1541
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 多目标:一个成功、一个写失败 ⇒ 成功那个也必须回滚
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), '--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 }))
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 锁:同一工作区已有写事务时,第二个编辑必须超时拒绝