aura-basetools 0.13.3 → 0.15.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 +14 -16
- package/bin/aura-mcp.mjs +38 -41
- package/bin/aura.mjs +16 -37
- package/lib/ops.mjs +106 -37
- 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/lib/ops.mjs
CHANGED
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
* (`flatHunk` 也从这里出去),于是「引擎在哪」这个问题在整棵树里只有一个答案。
|
|
11
11
|
*/
|
|
12
12
|
import fs from 'node:fs'
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
13
14
|
import { fileURLToPath } from 'node:url'
|
|
14
15
|
import { createNodeFsProvider, DEFAULT_MAX_TEXT_BYTES } from './fs-provider.mjs'
|
|
15
16
|
import { withWorkspaceLock } from './workspace-lock.mjs'
|
|
16
|
-
import { createTransaction
|
|
17
|
+
import { createTransaction } from './transaction-store.mjs'
|
|
17
18
|
import { createDiskStore } from './observation-store.mjs'
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -91,26 +92,26 @@ export function capabilitiesEnvelope() {
|
|
|
91
92
|
{
|
|
92
93
|
name: 'aura_basetools_edit',
|
|
93
94
|
addressKinds: [...new Set([...engineArgs.FLAT_ADDRESS_KEYS, 'old', 'context', 'position'])],
|
|
94
|
-
options: ['replace_all', 'require_observation'],
|
|
95
|
+
options: ['replace_all', 'require_observation', 'verify'],
|
|
95
96
|
writeSafety: {
|
|
96
|
-
cli: ['
|
|
97
|
-
mcp: ['
|
|
97
|
+
cli: ['workspace-lock', 'stale-region-gate', 'byte-verify'],
|
|
98
|
+
mcp: ['workspace-lock', 'stale-region-gate', 'byte-verify'],
|
|
98
99
|
dsh: ['stale-region-gate', 'byte-verify'],
|
|
99
100
|
},
|
|
100
101
|
},
|
|
101
102
|
{
|
|
102
103
|
name: 'aura_basetools_write',
|
|
103
104
|
operations: ['create', 'overwrite'],
|
|
104
|
-
options: ['eol'],
|
|
105
|
+
options: ['eol', 'verify'],
|
|
105
106
|
writeSafety: {
|
|
106
|
-
cli: ['
|
|
107
|
-
mcp: ['
|
|
108
|
-
// DSH
|
|
107
|
+
cli: ['workspace-lock', 'stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
108
|
+
mcp: ['workspace-lock', 'stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
109
|
+
// DSH 壳没有工作区锁;它经过引擎的区域闸、严格新鲜度与回读校验。
|
|
109
110
|
dsh: ['stale-region-gate', 'observation-gate', 'byte-verify'],
|
|
110
111
|
},
|
|
111
112
|
},
|
|
112
113
|
],
|
|
113
|
-
commands: ['basetools read', 'basetools edit', 'basetools write', 'basetools
|
|
114
|
+
commands: ['basetools read', 'basetools edit', 'basetools write', 'basetools mcp', 'basetools doctor', 'basetools capabilities', '--version', '--help'],
|
|
114
115
|
langs,
|
|
115
116
|
langsCount: langs.length,
|
|
116
117
|
limits: {
|
|
@@ -479,6 +480,45 @@ function transactionError(error) {
|
|
|
479
480
|
}
|
|
480
481
|
}
|
|
481
482
|
|
|
483
|
+
const VERIFY_OUTPUT_LIMIT = 4000
|
|
484
|
+
const tailText = (value) => {
|
|
485
|
+
const text = String(value ?? '')
|
|
486
|
+
return text.length <= VERIFY_OUTPUT_LIMIT ? text : `…${text.slice(-VERIFY_OUTPUT_LIMIT)}`
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 写后验证:**只在调用方显式给出命令时**执行。
|
|
491
|
+
*
|
|
492
|
+
* 不是自动发现仓库脚本,也不绕过沙箱/权限;命令在 workspace 根下跑,有超时与输出上限。
|
|
493
|
+
* 失败由外层事务回滚,调用方拿到的是可判定的 `PATCH_VERIFY_FAILED`,不是“写完再说”。
|
|
494
|
+
*/
|
|
495
|
+
function runVerification(command, cwd) {
|
|
496
|
+
const timeout = Math.min(600000, Math.max(1000, Number(process.env.AURA_VERIFY_TIMEOUT_MS) || 120000))
|
|
497
|
+
const started = Date.now()
|
|
498
|
+
const res = spawnSync(command, {
|
|
499
|
+
cwd,
|
|
500
|
+
shell: true,
|
|
501
|
+
encoding: 'utf8',
|
|
502
|
+
timeout,
|
|
503
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
504
|
+
env: process.env,
|
|
505
|
+
windowsHide: true,
|
|
506
|
+
})
|
|
507
|
+
const timedOut = res.error?.code === 'ETIMEDOUT'
|
|
508
|
+
return {
|
|
509
|
+
command,
|
|
510
|
+
cwd,
|
|
511
|
+
ok: res.status === 0 && res.error === undefined,
|
|
512
|
+
exitCode: res.status,
|
|
513
|
+
signal: res.signal ?? null,
|
|
514
|
+
timedOut,
|
|
515
|
+
durationMs: Date.now() - started,
|
|
516
|
+
stdout: tailText(res.stdout),
|
|
517
|
+
stderr: tailText(res.stderr),
|
|
518
|
+
...(res.error === undefined ? {} : { error: String(res.error.message ?? res.error) }),
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
482
522
|
/**
|
|
483
523
|
* 写事务外壳:workspace 锁 + 事务日志 + 失败回滚。
|
|
484
524
|
*
|
|
@@ -489,17 +529,37 @@ export async function editEnvelope(provider, request) {
|
|
|
489
529
|
// 事务需要工作区根与真实文件系统;测试/嵌入式 provider 没有 root 时保持原来的引擎行为。
|
|
490
530
|
if (provider.root === undefined) return editEnvelopeInner(provider, request)
|
|
491
531
|
/**
|
|
492
|
-
*
|
|
493
|
-
* 把 `aura undo`(最近一次 committed)顶掉——用户以为撤销了真实编辑,实际 restored:0。
|
|
494
|
-
* 预演也不写盘,不需要工作区锁。
|
|
532
|
+
* 预演不建事务、不写盘,也不需要工作区锁;0.14.0 起事务日志只用于失败回滚。
|
|
495
533
|
*/
|
|
496
534
|
if (request.dryRun === true) return editEnvelopeInner(provider, request)
|
|
497
|
-
const
|
|
535
|
+
const verifyCommand = typeof request.verify === 'string' && request.verify.trim().length > 0 ? request.verify.trim() : null
|
|
498
536
|
try {
|
|
499
537
|
return await withWorkspaceLock(provider.root, async () => {
|
|
500
538
|
const tx = createTransaction(provider.root, 'aura_basetools_edit')
|
|
501
539
|
const env = await editEnvelopeInner(tx.proxy(provider), request)
|
|
502
540
|
if (env.ok) {
|
|
541
|
+
if (verifyCommand !== null) {
|
|
542
|
+
const verification = runVerification(verifyCommand, provider.root)
|
|
543
|
+
if (!verification.ok) {
|
|
544
|
+
const rollback = await tx.rollback(provider)
|
|
545
|
+
return {
|
|
546
|
+
ok: false,
|
|
547
|
+
error: `edit verification failed (exit ${verification.exitCode ?? 'no-status'}${verification.timedOut ? ', timed out' : ''}): ${verifyCommand}`,
|
|
548
|
+
code: 'PATCH_VERIFY_FAILED',
|
|
549
|
+
verification,
|
|
550
|
+
rollback,
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
tx.commit()
|
|
555
|
+
} catch (error) {
|
|
556
|
+
const rollback = await tx.rollback(provider)
|
|
557
|
+
error.transaction = tx.summary()
|
|
558
|
+
error.rollback = rollback
|
|
559
|
+
throw error
|
|
560
|
+
}
|
|
561
|
+
return { ...env, verification }
|
|
562
|
+
}
|
|
503
563
|
try {
|
|
504
564
|
tx.commit()
|
|
505
565
|
} catch (error) {
|
|
@@ -508,10 +568,10 @@ export async function editEnvelope(provider, request) {
|
|
|
508
568
|
error.rollback = rollback
|
|
509
569
|
throw error
|
|
510
570
|
}
|
|
511
|
-
return
|
|
571
|
+
return env
|
|
512
572
|
}
|
|
513
|
-
|
|
514
|
-
return
|
|
573
|
+
await tx.rollback(provider)
|
|
574
|
+
return env
|
|
515
575
|
})
|
|
516
576
|
} catch (error) {
|
|
517
577
|
return transactionError(error)
|
|
@@ -523,13 +583,37 @@ export async function editManyEnvelope(provider, request) {
|
|
|
523
583
|
if (provider.root === undefined) return editManyEnvelopeInner(provider, request)
|
|
524
584
|
// 与单目标同一条:预演不建事务、不写盘,不能顶掉真正的最近 committed 事务。
|
|
525
585
|
if (request.dryRun === true) return editManyEnvelopeInner(provider, request)
|
|
526
|
-
const
|
|
586
|
+
const verifyCommand = typeof request.verify === 'string' && request.verify.trim().length > 0 ? request.verify.trim() : null
|
|
527
587
|
try {
|
|
528
588
|
return await withWorkspaceLock(provider.root, async () => {
|
|
529
589
|
const tx = createTransaction(provider.root, 'aura_basetools_edit')
|
|
530
590
|
try {
|
|
531
591
|
const batch = await editManyEnvelopeInner(tx.proxy(provider), request)
|
|
532
592
|
if (batch.ok) {
|
|
593
|
+
if (verifyCommand !== null) {
|
|
594
|
+
const verification = runVerification(verifyCommand, provider.root)
|
|
595
|
+
if (!verification.ok) {
|
|
596
|
+
const rollback = await tx.rollback(provider)
|
|
597
|
+
const finalBatch = writePath.rebuildAfterRollback(batch, rollback)
|
|
598
|
+
return {
|
|
599
|
+
...finalBatch,
|
|
600
|
+
ok: false,
|
|
601
|
+
code: 'PATCH_VERIFY_FAILED',
|
|
602
|
+
error: `edit verification failed (exit ${verification.exitCode ?? 'no-status'}${verification.timedOut ? ', timed out' : ''}): ${verifyCommand}`,
|
|
603
|
+
verification,
|
|
604
|
+
rollback,
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
tx.commit()
|
|
609
|
+
} catch (error) {
|
|
610
|
+
const rollback = await tx.rollback(provider)
|
|
611
|
+
error.transaction = tx.summary()
|
|
612
|
+
error.rollback = rollback
|
|
613
|
+
throw error
|
|
614
|
+
}
|
|
615
|
+
return { ...batch, verification }
|
|
616
|
+
}
|
|
533
617
|
try {
|
|
534
618
|
tx.commit()
|
|
535
619
|
} catch (error) {
|
|
@@ -538,14 +622,13 @@ export async function editManyEnvelope(provider, request) {
|
|
|
538
622
|
error.rollback = rollback
|
|
539
623
|
throw error
|
|
540
624
|
}
|
|
541
|
-
return
|
|
625
|
+
return batch
|
|
542
626
|
}
|
|
543
627
|
const rollback = await tx.rollback(provider)
|
|
544
628
|
const finalBatch = writePath.rebuildAfterRollback(batch, rollback)
|
|
545
629
|
return {
|
|
546
630
|
...finalBatch,
|
|
547
631
|
ok: false,
|
|
548
|
-
...(includeTransaction ? { transaction: tx.summary() } : {}),
|
|
549
632
|
}
|
|
550
633
|
} catch (error) {
|
|
551
634
|
const rollback = await tx.rollback(provider)
|
|
@@ -572,6 +655,7 @@ function compactWriteReceipt(env, content) {
|
|
|
572
655
|
const bytes = result.write_verify?.bytes ?? (dryRun && result.operation === 'create' ? Buffer.byteLength(content, 'utf8') : undefined)
|
|
573
656
|
return {
|
|
574
657
|
ok: true,
|
|
658
|
+
...(env.verification === undefined ? {} : { verification: env.verification }),
|
|
575
659
|
result: {
|
|
576
660
|
path: result.path,
|
|
577
661
|
operation: result.operation ?? 'write',
|
|
@@ -579,7 +663,6 @@ function compactWriteReceipt(env, content) {
|
|
|
579
663
|
dryRun,
|
|
580
664
|
...(result.write_verify === undefined ? {} : { write_verify: result.write_verify }),
|
|
581
665
|
...(result.unverified_region === true ? { unverified_region: true } : {}),
|
|
582
|
-
...(result.transaction === undefined ? {} : { transaction: result.transaction }),
|
|
583
666
|
},
|
|
584
667
|
}
|
|
585
668
|
}
|
|
@@ -600,7 +683,7 @@ function compactWriteReceipt(env, content) {
|
|
|
600
683
|
* `preserve` 按 content 原样写(与内置 Write 的“给什么写什么”对齐),`lf` / `crlf` 强制统一。
|
|
601
684
|
* 新建文件永远按 content 原样写;显式传非 `auto` 的 eol 会被拒绝(不做静默忽略)。
|
|
602
685
|
*/
|
|
603
|
-
export async function writeEnvelope(provider, { file, content, dryRun = false, noOverwrite = false,
|
|
686
|
+
export async function writeEnvelope(provider, { file, content, dryRun = false, noOverwrite = false, allowUnobserved = false, eol = 'auto', verify } = {}) {
|
|
604
687
|
if (typeof file !== 'string' || file.length === 0) {
|
|
605
688
|
return { ok: false, error: 'write needs a file path', code: 'WRITE_BAD_ARGS' }
|
|
606
689
|
}
|
|
@@ -627,7 +710,7 @@ export async function writeEnvelope(provider, { file, content, dryRun = false, n
|
|
|
627
710
|
if (dryRun) {
|
|
628
711
|
return { ok: true, result: { path: displayPath, operation: 'create', bytes: Buffer.byteLength(content, 'utf8'), dryRun: true } }
|
|
629
712
|
}
|
|
630
|
-
return compactWriteReceipt(await editEnvelope(provider, { file, create: content,
|
|
713
|
+
return compactWriteReceipt(await editEnvelope(provider, { file, create: content, verify }), content)
|
|
631
714
|
}
|
|
632
715
|
return compactWriteReceipt(
|
|
633
716
|
await editEnvelope(provider, {
|
|
@@ -635,10 +718,10 @@ export async function writeEnvelope(provider, { file, content, dryRun = false, n
|
|
|
635
718
|
wholeFile: content,
|
|
636
719
|
eol,
|
|
637
720
|
dryRun,
|
|
638
|
-
includeTransaction,
|
|
639
721
|
requireObservation: allowUnobserved !== true,
|
|
640
722
|
skipStaleGate: allowUnobserved === true,
|
|
641
723
|
toolName: 'aura_basetools_write',
|
|
724
|
+
verify,
|
|
642
725
|
}),
|
|
643
726
|
content,
|
|
644
727
|
)
|
|
@@ -646,17 +729,3 @@ export async function writeEnvelope(provider, { file, content, dryRun = false, n
|
|
|
646
729
|
return transactionError(error)
|
|
647
730
|
}
|
|
648
731
|
}
|
|
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
|
/**
|