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/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, listTransactions, undoTransaction } from './transaction-store.mjs'
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: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'byte-verify'],
97
- mcp: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'byte-verify'],
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: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'observation-gate', 'byte-verify'],
107
- mcp: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'observation-gate', 'byte-verify'],
108
- // DSH 壳没有事务/undo/工作区锁;它经过引擎的区域闸、严格新鲜度与回读校验。
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 undo', 'basetools transactions', 'basetools mcp', 'basetools doctor', 'basetools capabilities', '--version', '--help'],
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
- * 预演不建事务:否则 `--dry-run --transaction` 会提交一条 0 文件的 committed 事务,
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 includeTransaction = request.includeTransaction === true
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 includeTransaction ? { ...env, result: { ...env.result, transaction: tx.summary() } } : env
571
+ return env
512
572
  }
513
- const rollback = await tx.rollback(provider)
514
- return includeTransaction ? { ...env, transaction: tx.summary(), rollback } : env
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 includeTransaction = request.includeTransaction === true
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 includeTransaction ? { ...batch, transaction: tx.summary() } : batch
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, includeTransaction = false, allowUnobserved = false, eol = 'auto' } = {}) {
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, includeTransaction }), 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 —— 写事务日志、回滚与 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
  /**