aura-basetools 0.7.0 → 0.13.1

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
@@ -76,6 +76,54 @@ const readCore = await import(engineUrl('read-core.mjs'))
76
76
  const repoMap = await import(engineUrl('repo-map.mjs'))
77
77
  const engineArgs = await import(engineUrl('args.mjs'))
78
78
  const writePath = await import(engineUrl('write-path.mjs'))
79
+ const structCore = await import(engineUrl('struct-core.mjs'))
80
+ const handleCore = await import(engineUrl('handle-core.mjs'))
81
+
82
+ /**
83
+ * 0.9.9:运行时可查询的能力清单(与 build/capabilities.mjs 同一份源码常量)。
84
+ * 不发货额外 JSON:包内已有的引擎模块就是唯一来源。
85
+ */
86
+ export function capabilitiesEnvelope() {
87
+ const langs = Object.keys(structCore.LANGS).sort()
88
+ return {
89
+ tools: [
90
+ { name: 'aura_basetools_read', modes: ['auto', ...readCore.MODES] },
91
+ {
92
+ name: 'aura_basetools_edit',
93
+ addressKinds: [...new Set([...engineArgs.FLAT_ADDRESS_KEYS, 'old', 'context', 'position'])],
94
+ options: ['replace_all', 'require_observation'],
95
+ writeSafety: {
96
+ cli: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'byte-verify'],
97
+ mcp: ['transaction', 'undo', 'workspace-lock', 'stale-region-gate', 'byte-verify'],
98
+ dsh: ['stale-region-gate', 'byte-verify'],
99
+ },
100
+ },
101
+ {
102
+ name: 'aura_basetools_write',
103
+ operations: ['create', 'overwrite'],
104
+ options: ['eol'],
105
+ 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/工作区锁;它经过引擎的区域闸、严格新鲜度与回读校验。
109
+ dsh: ['stale-region-gate', 'observation-gate', 'byte-verify'],
110
+ },
111
+ },
112
+ ],
113
+ commands: ['basetools read', 'basetools edit', 'basetools write', 'basetools undo', 'basetools transactions', 'basetools mcp', 'basetools doctor', 'basetools capabilities', '--version', '--help'],
114
+ langs,
115
+ langsCount: langs.length,
116
+ limits: {
117
+ multiTargetsMax: engineArgs.MT_MAX_TARGETS,
118
+ multiTargetPool: engineArgs.MT_POOL,
119
+ defaultPruneBudget: readCore.DEFAULT_PRUNE_BUDGET,
120
+ envelopeAllowance: readCore.DEFAULT_ENVELOPE_ALLOWANCE,
121
+ contentBudget: readCore.CONTENT_BUDGET,
122
+ handlesPerReceipt: handleCore.HANDLE_MAX_PER_RECEIPT,
123
+ },
124
+ packaging: { corePackage: 'aura-basetools', adapterPackage: 'aura-basetools-dsh' },
125
+ }
126
+ }
79
127
 
80
128
  /**
81
129
  * 引擎公开面的转发:绑定转发,不是「把命名空间塞给调用方」——
@@ -94,6 +142,7 @@ export const asLinePair = engineArgs.asLinePair
94
142
  export const gaveLineAddress = engineArgs.gaveLineAddress
95
143
  export const inferReadMode = engineArgs.inferReadMode
96
144
  export const lineRangeOf = engineArgs.lineRangeOf
145
+ export const normalizeReadRequest = engineArgs.normalizeReadRequest
97
146
  export const flatHunk = engineArgs.flatHunk
98
147
  export const applyEditToProvider = writePath.applyEditToProvider
99
148
 
@@ -117,6 +166,8 @@ export const assertReadTargets = engineArgs.assertReadTargets
117
166
  export const assertEditTargets = engineArgs.assertEditTargets
118
167
  export const assertTargetShape = engineArgs.assertTargetShape
119
168
  export const assertKnownArgs = engineArgs.assertKnownArgs
169
+ export const MT_READ_ELEMENT_KEYS = engineArgs.MT_READ_ELEMENT_KEYS
170
+ export const MT_EDIT_ELEMENT_KEYS = engineArgs.MT_EDIT_ELEMENT_KEYS
120
171
 
121
172
  // ── C55:非 UTF-8 文件 ⇒ 可执行的拒绝 ──────────────────────────────────────
122
173
 
@@ -262,7 +313,7 @@ export function readFileBytes(displayPath) {
262
313
  return {
263
314
  error: {
264
315
  ok: false,
265
- error: nonUtf8RefusalMessage('read', displayPath, 'aura_read'),
316
+ error: nonUtf8RefusalMessage('read', displayPath, 'aura_basetools_read'),
266
317
  code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
267
318
  },
268
319
  }
@@ -277,18 +328,22 @@ export function readFileBytes(displayPath) {
277
328
  * 两个调用方(CLI `bin/aura.mjs`、MCP `bin/aura-mcp.mjs`)本来就 `await` 它,写法不变。
278
329
  */
279
330
  export async function readEnvelope(provider, args) {
280
- const { file, around, search, since, maxChars } = args
331
+ const { file, around, search, since, context, kind, budget } = args
332
+ // 宿主面用 `max_chars`,引擎面用 `maxChars`;单目标与 targets 元素都必须认两种写法。
333
+ const maxChars = args.maxChars ?? args.max_chars
281
334
  try {
282
- // 行区间先收敛:`[412,500]`、`"412,500"`、`412`、`start_line/end_line` 都接受;
283
- // 给了但解不出来就明说接受哪些形状(静默退回 auto 会让人以为是别的问题)。
284
- const lines = lineRangeOf(args)
285
- if (lines === undefined && gaveLineAddress(args)) {
286
- return { ok: false, error: LINE_ADDRESS_HINT, code: 'READ_BAD_ARGS' }
287
- }
288
- const mode = inferReadMode(args, lines)
335
+ /**
336
+ * 行区间 / 分页 / 模式推断收成一份(`normalizeReadRequest`):单目标与 `targets` 元素
337
+ * 走同一个判定器。第一遍只判形状(文件还没读),第二遍拿到总行数后做 offset 越界与截断。
338
+ */
339
+ const requestShape = normalizeReadRequest(args)
340
+ if (requestShape.ok !== true) return requestShape
289
341
  const displayPath = provider.resolve(file)
290
342
  const info = provider.stat(displayPath)
291
343
  if (info === undefined) return { ok: false, error: `cannot read "${file}": not found`, code: 'FS_NOT_FOUND' }
344
+ if (info.type === 'directory' && requestShape.paginate) {
345
+ return { ok: false, error: 'offset/limit applies to files; a directory is returned as a repo map — drop pagination', code: 'READ_BAD_ARGS' }
346
+ }
292
347
  /**
293
348
  * 目录:给一张仓库地图(T26)。判据只有“它是目录”这一条,不需要调用方多填参数。
294
349
  *
@@ -311,15 +366,20 @@ export async function readEnvelope(provider, args) {
311
366
  }
312
367
  const got = readFileBytes(displayPath)
313
368
  if (got.error !== undefined) return got.error
369
+ const request = normalizeReadRequest(args, got.source.split(/\r?\n/).length)
370
+ if (request.ok !== true) return request
314
371
  const env = readWith(got.source, {
315
- mode,
372
+ mode: request.mode,
316
373
  around,
317
374
  // 读侧只认数组:这里从收敛层的 {start,end} 转一次(之前 CLI/MCP 直接传 {start,end},
318
375
  // 于是 `aura read --lines 1,2` 会报 READ_NO_RANGE —— 顺手修掉的既有 bug)。
319
- lines: asLinePair(lines),
376
+ lines: asLinePair(request.lines),
320
377
  search,
321
378
  since,
322
379
  maxChars,
380
+ ...(context === undefined ? {} : { context }),
381
+ ...(kind === undefined ? {} : { kind }),
382
+ ...(budget === undefined ? {} : { budget }),
323
383
  byteOrderMark: got.byteOrderMark,
324
384
  filePath: displayPath,
325
385
  })
@@ -332,7 +392,7 @@ export async function readEnvelope(provider, args) {
332
392
  if (isNonUtf8TextFailure(error)) {
333
393
  return {
334
394
  ok: false,
335
- error: nonUtf8RefusalMessage('read', resolvedOrRaw(provider, file), 'aura_read'),
395
+ error: nonUtf8RefusalMessage('read', resolvedOrRaw(provider, file), 'aura_basetools_read'),
336
396
  code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
337
397
  ...errorEvidence(error),
338
398
  }
@@ -347,12 +407,16 @@ export async function readEnvelope(provider, args) {
347
407
  }
348
408
 
349
409
  /** 改一次(规划 → 陈旧闸门 → 逐字节写 + CAS → 回读校验 → 刷新观测)。 */
350
- async function editEnvelopeInner(provider, { file, hunks, dryRun, create }) {
410
+ async function editEnvelopeInner(provider, { file, hunks, dryRun, create, requireObservation, wholeFile, eol, skipStaleGate, toolName = 'aura_basetools_edit' }) {
351
411
  try {
352
412
  const result = await applyEditToProvider(provider, {
353
413
  displayPath: provider.resolve(file),
354
- hunks,
414
+ hunks: hunks ?? [],
355
415
  dryRun: dryRun === true,
416
+ ...(requireObservation === true ? { requireObservation: true } : {}),
417
+ ...(typeof wholeFile === 'string' ? { wholeFile } : {}),
418
+ ...(typeof eol === 'string' ? { eol } : {}),
419
+ ...(skipStaleGate === true ? { skipStaleGate: true } : {}),
356
420
  ...(typeof create === 'string' ? { create } : {}),
357
421
  })
358
422
  return { ok: true, result }
@@ -365,7 +429,7 @@ async function editEnvelopeInner(provider, { file, hunks, dryRun, create }) {
365
429
  if (isNonUtf8TextFailure(error)) {
366
430
  return {
367
431
  ok: false,
368
- error: nonUtf8RefusalMessage('patch', resolvedOrRaw(provider, file), 'aura_edit'),
432
+ error: nonUtf8RefusalMessage('patch', resolvedOrRaw(provider, file), toolName),
369
433
  code: typeof error?.code === 'string' && error.code.length > 0 ? error.code : 'FS_NOT_TEXT',
370
434
  ...errorEvidence(error),
371
435
  }
@@ -424,10 +488,16 @@ function transactionError(error) {
424
488
  export async function editEnvelope(provider, request) {
425
489
  // 事务需要工作区根与真实文件系统;测试/嵌入式 provider 没有 root 时保持原来的引擎行为。
426
490
  if (provider.root === undefined) return editEnvelopeInner(provider, request)
491
+ /**
492
+ * 预演不建事务:否则 `--dry-run --transaction` 会提交一条 0 文件的 committed 事务,
493
+ * 把 `aura undo`(最近一次 committed)顶掉——用户以为撤销了真实编辑,实际 restored:0。
494
+ * 预演也不写盘,不需要工作区锁。
495
+ */
496
+ if (request.dryRun === true) return editEnvelopeInner(provider, request)
427
497
  const includeTransaction = request.includeTransaction === true
428
498
  try {
429
499
  return await withWorkspaceLock(provider.root, async () => {
430
- const tx = createTransaction(provider.root, 'aura_edit')
500
+ const tx = createTransaction(provider.root, 'aura_basetools_edit')
431
501
  const env = await editEnvelopeInner(tx.proxy(provider), request)
432
502
  if (env.ok) {
433
503
  try {
@@ -451,10 +521,12 @@ export async function editEnvelope(provider, request) {
451
521
  /** 多目标写事务:与单目标共用锁/日志/回滚;批次返回 ok=false 时也回滚。 */
452
522
  export async function editManyEnvelope(provider, request) {
453
523
  if (provider.root === undefined) return editManyEnvelopeInner(provider, request)
524
+ // 与单目标同一条:预演不建事务、不写盘,不能顶掉真正的最近 committed 事务。
525
+ if (request.dryRun === true) return editManyEnvelopeInner(provider, request)
454
526
  const includeTransaction = request.includeTransaction === true
455
527
  try {
456
528
  return await withWorkspaceLock(provider.root, async () => {
457
- const tx = createTransaction(provider.root, 'aura_edit')
529
+ const tx = createTransaction(provider.root, 'aura_basetools_edit')
458
530
  try {
459
531
  const batch = await editManyEnvelopeInner(tx.proxy(provider), request)
460
532
  if (batch.ok) {
@@ -469,7 +541,12 @@ export async function editManyEnvelope(provider, request) {
469
541
  return includeTransaction ? { ...batch, transaction: tx.summary() } : batch
470
542
  }
471
543
  const rollback = await tx.rollback(provider)
472
- return includeTransaction ? { ...batch, transaction: tx.summary(), rollback } : batch
544
+ const finalBatch = writePath.rebuildAfterRollback(batch, rollback)
545
+ return {
546
+ ...finalBatch,
547
+ ok: false,
548
+ ...(includeTransaction ? { transaction: tx.summary() } : {}),
549
+ }
473
550
  } catch (error) {
474
551
  const rollback = await tx.rollback(provider)
475
552
  error.transaction = tx.summary()
@@ -482,8 +559,95 @@ export async function editManyEnvelope(provider, request) {
482
559
  }
483
560
  }
484
561
 
485
- export async function undoEnvelope(provider, { id, force } = {}) {
562
+ /** 整文件写的紧凑回执:不把整份 old/new 文本塞进 applied(那是 edit 的形状,不是 write 的)。 */
563
+ function compactWriteReceipt(env, content) {
564
+ if (env === null || typeof env !== 'object' || env.ok !== true) return env
565
+ const result = env.result ?? {}
566
+ const dryRun = result.dryRun === true
567
+ /**
568
+ * 覆写预演没有落盘,也没有回读字节数;此时 `content` 的长度可能不等于最终字节数
569
+ * (整份写会把 content 的换行适配成文件已有风格,CRLF 每行多一个字节)。
570
+ * 宁可**不报** bytes,也不报一个预演后必然对不上的数。新建预演不需要适配,照报。
571
+ */
572
+ const bytes = result.write_verify?.bytes ?? (dryRun && result.operation === 'create' ? Buffer.byteLength(content, 'utf8') : undefined)
573
+ return {
574
+ ok: true,
575
+ result: {
576
+ path: result.path,
577
+ operation: result.operation ?? 'write',
578
+ ...(bytes === undefined ? {} : { bytes }),
579
+ dryRun,
580
+ ...(result.write_verify === undefined ? {} : { write_verify: result.write_verify }),
581
+ ...(result.unverified_region === true ? { unverified_region: true } : {}),
582
+ ...(result.transaction === undefined ? {} : { transaction: result.transaction }),
583
+ },
584
+ }
585
+ }
586
+
587
+ /**
588
+ * 0.10.1:整文件写(内置 `Write` 的替代)。
589
+ *
590
+ * 新建与覆写都走 `editEnvelope`:同一把工作区锁、同一份事务日志、同一套回读校验。
591
+ * 覆写用“整文件行区间(含末尾那个空行)+ `eol: auto`”表达:行区间覆盖到文件末尾,
592
+ * 于是 content 自己的**有无尾换行**被精确保留,而 content 内的换行统一成文件已有的风格
593
+ * (与内置 Edit 对多行替换的处理一致)。已有的 BOM 也照旧保留。
594
+ *
595
+ * **覆写默认要求先读过这版内容**(`PATCH_UNOBSERVED`),与内置 `Write` 的 read-before-write
596
+ * 同一条;`allowUnobserved` 是显式逃生口(回执照实标 `unverified_region`)。
597
+ * 新建不受这条约束。
598
+ *
599
+ * `eol` 只对覆写有意义:`auto`(默认)把 content 的新行适配成文件已有风格,
600
+ * `preserve` 按 content 原样写(与内置 Write 的“给什么写什么”对齐),`lf` / `crlf` 强制统一。
601
+ * 新建文件永远按 content 原样写;显式传非 `auto` 的 eol 会被拒绝(不做静默忽略)。
602
+ */
603
+ export async function writeEnvelope(provider, { file, content, dryRun = false, noOverwrite = false, includeTransaction = false, allowUnobserved = false, eol = 'auto' } = {}) {
604
+ if (typeof file !== 'string' || file.length === 0) {
605
+ return { ok: false, error: 'write needs a file path', code: 'WRITE_BAD_ARGS' }
606
+ }
607
+ if (typeof content !== 'string') {
608
+ return { ok: false, error: 'write needs `content` as a string', code: 'WRITE_BAD_ARGS' }
609
+ }
610
+ if (!['auto', 'preserve', 'lf', 'crlf'].includes(eol)) {
611
+ return { ok: false, error: 'write `eol` must be auto, preserve, lf, or crlf', code: 'WRITE_BAD_ARGS' }
612
+ }
486
613
  try {
614
+ const displayPath = provider.resolve(file)
615
+ const info = provider.stat(displayPath)
616
+ if (info !== undefined && noOverwrite) {
617
+ return { ok: false, error: `cannot write "${displayPath}": it already exists (no-overwrite)`, code: 'WRITE_EXISTS' }
618
+ }
619
+ if (info === undefined) {
620
+ if (eol !== 'auto') {
621
+ return {
622
+ ok: false,
623
+ error: 'write `eol` applies when overwriting an existing file; a new file is written exactly as given — pass the endings you want in `content`',
624
+ code: 'WRITE_BAD_ARGS',
625
+ }
626
+ }
627
+ if (dryRun) {
628
+ return { ok: true, result: { path: displayPath, operation: 'create', bytes: Buffer.byteLength(content, 'utf8'), dryRun: true } }
629
+ }
630
+ return compactWriteReceipt(await editEnvelope(provider, { file, create: content, includeTransaction }), content)
631
+ }
632
+ return compactWriteReceipt(
633
+ await editEnvelope(provider, {
634
+ file,
635
+ wholeFile: content,
636
+ eol,
637
+ dryRun,
638
+ includeTransaction,
639
+ requireObservation: allowUnobserved !== true,
640
+ skipStaleGate: allowUnobserved === true,
641
+ toolName: 'aura_basetools_write',
642
+ }),
643
+ content,
644
+ )
645
+ } catch (error) {
646
+ return transactionError(error)
647
+ }
648
+ }
649
+
650
+ export async function undoEnvelope(provider, { id, force } = {}) { try {
487
651
  return {
488
652
  ok: true,
489
653
  ...(await withWorkspaceLock(provider.root, () => undoTransaction(provider.root, provider, { id, force: force === true }))),
@@ -118,7 +118,7 @@ function fileSummary(entry) {
118
118
  * @param root - 工作区根
119
119
  * @param tool - 产生它的工具名(仅记录)
120
120
  */
121
- export function createTransaction(root, tool = 'aura_edit') {
121
+ export function createTransaction(root, tool = 'aura_basetools_edit') {
122
122
  const id = transactionId()
123
123
  const dir = path.join(transactionsRoot(root), id)
124
124
  ensureDir(dir)
@@ -20,6 +20,8 @@ import crypto from 'node:crypto'
20
20
 
21
21
  const DEFAULT_TIMEOUT_MS = 5000
22
22
  const DEFAULT_STALE_MS = 10 * 60 * 1000
23
+ /** 空锁(已创建、PID 还没写进去)的宽限:短于这个年龄不判陈旧,避免抢走刚持有的锁。 */
24
+ const EMPTY_LOCK_GRACE_MS = 2000
23
25
  const RETRY_MS = 50
24
26
 
25
27
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -99,16 +101,35 @@ export async function withWorkspaceLock(root, fn, options = {}) {
99
101
  } catch (error) {
100
102
  if (error?.code !== 'EEXIST') throw error
101
103
  const holder = readLock(file)
102
- const age = Date.now() - Number(holder?.createdAt ?? 0)
103
- const stale = holder === null || age > staleMs || !processAlive(holder.pid)
104
+ /**
105
+ * **空锁不是陈旧锁**:`open(..., 'wx')` 与写入 PID 之间有窗口,另一个进程此时读到的
106
+ * 是空文件(`readLock` 返回 null)。旧判据把 `holder === null` 直接当陈旧 ⇒ 会删掉
107
+ * 一个刚被持有、还没写完元数据的锁,两个进程同时进入临界区。实测:4 个进程并发改
108
+ * 同一文件的不同行,偶发 1 个进程的陈旧闸门报错、两行修改丢失。
109
+ * 现在空锁给 2 秒宽限:只有 mtime 超过宽限(或超过 staleMs)才回收。
110
+ */
111
+ let stale
112
+ if (holder === null) {
113
+ let mtime = 0
114
+ try {
115
+ mtime = fs.statSync(file).mtimeMs
116
+ } catch {
117
+ continue // 锁刚被释放,下一轮重试
118
+ }
119
+ stale = Date.now() - mtime > Math.min(staleMs, EMPTY_LOCK_GRACE_MS)
120
+ } else {
121
+ const age = Date.now() - Number(holder.createdAt ?? 0)
122
+ stale = age > staleMs || !processAlive(holder.pid)
123
+ }
104
124
  if (stale) {
105
125
  try { fs.rmSync(file, { force: true }) } catch { /* 下一轮再试 */ }
106
126
  continue
107
127
  }
108
128
  if (Date.now() - started > timeoutMs) {
109
- throw Object.assign(new Error(`workspace is locked by pid ${holder.pid}`), {
129
+ const holderLabel = holder === null ? 'an empty lock (pid not written yet)' : `pid ${holder.pid}`
130
+ throw Object.assign(new Error(`workspace is locked by ${holderLabel}`), {
110
131
  code: 'WORKSPACE_LOCKED',
111
- holder,
132
+ holder: holder ?? null,
112
133
  })
113
134
  }
114
135
  await sleep(RETRY_MS)
@@ -12,7 +12,7 @@
12
12
  * 唯一的内部状态是 `indexFile` 手里那层**内容派生的记忆化缓存**(见该函数上方注释):
13
13
  * 同一份内容不再被重复建索引,返回值不变。
14
14
  * 文件读写、沙箱、观察策略、审批都归调用方——那是各框架自己的事,引擎不该知道。
15
- * DSH 的 `aura_edit` 工具是本引擎的一个适配器,见 `../aura-edit/index.js`。
15
+ * DSH 的 `aura_basetools_edit` 工具是本引擎的一个适配器,见 `../aura-edit/index.js`。
16
16
  *
17
17
  * @module anchor-core
18
18
  */
@@ -63,23 +63,86 @@ export function flatHunk(source) {
63
63
  const ADDRESS_KEYS = FLAT_ADDRESS_KEYS
64
64
  if (!ADDRESS_KEYS.some((k) => source[k] !== undefined)) return null
65
65
  const hunk = {}
66
- for (const key of [...ADDRESS_KEYS, ...HANDLE_SHAPE_KEYS, 'replace', 'expect', 'near']) {
66
+ for (const key of [...ADDRESS_KEYS, ...HANDLE_SHAPE_KEYS, 'context', 'replace', 'expect', 'near', 'eol', 'replace_all']) {
67
67
 
68
68
  const value = source[key]
69
69
  if (value === undefined || value === null) continue
70
- if (typeof value === 'string' && value.length === 0) continue
70
+ // `replace: ''` 是**删除**,是合法动作;其余空串仍然不是地址/断言。
71
+ if (typeof value === 'string' && value.length === 0 && key !== 'replace') continue
71
72
  hunk[key] = value
72
73
  }
73
74
  return typeof hunk.replace === 'string' ? hunk : null
74
75
  }
75
76
 
77
+ /**
78
+ * 单次读请求的**统一收敛**(单目标与 `targets` 元素共用)。
79
+ *
80
+ * 旧版 `readManyTarget` 手写字段转发,`offset` / `limit` / `context` / `kind` / `budget`
81
+ * 在多目标里被静默丢掉——同一个元素单独读与放进 `targets` 读会得到两种结果。
82
+ * 这里把“行区间 / 分页 / 模式推断 / 非法组合”收成一份,两个入口都调它。
83
+ *
84
+ * `totalLines` 只有在内容已读到时才给得出;第一遍(读文件前)传 undefined,用来尽早拒绝
85
+ * 形状错误;第二遍传真实行数,用来做 offset 越界与尾行截断。
86
+ *
87
+ * @returns `{ok:true, mode, lines, paginate, offset, limit}` 或
88
+ * `{ok:false, code:'READ_BAD_ARGS', error}`。
89
+ */
90
+ export function normalizeReadRequest(args, totalLines = undefined) {
91
+ const lines = lineRangeOf(args)
92
+ if (lines === undefined && gaveLineAddress(args)) {
93
+ return { ok: false, code: 'READ_BAD_ARGS', error: LINE_ADDRESS_HINT }
94
+ }
95
+ const paginate = args.offset !== undefined || args.limit !== undefined
96
+ if (paginate && lines !== undefined) {
97
+ return { ok: false, code: 'READ_BAD_ARGS', error: 'use either lines/start_line+end_line or offset/limit, not both' }
98
+ }
99
+ if (paginate && args.mode !== undefined && args.mode !== null && args.mode !== 'auto') {
100
+ return { ok: false, code: 'READ_BAD_ARGS', error: `mode=${JSON.stringify(args.mode)} cannot be combined with offset/limit; drop the mode or use lines/start_line+end_line` }
101
+ }
102
+ const offset = paginate ? Number(args.offset ?? 0) : null
103
+ const limit = paginate ? Number(args.limit ?? 2000) : null
104
+ if (paginate && (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit <= 0)) {
105
+ return { ok: false, code: 'READ_BAD_ARGS', error: 'offset must be an integer >= 0 and limit an integer > 0' }
106
+ }
107
+ let resolvedLines = lines
108
+ if (paginate && totalLines !== undefined) {
109
+ if (!Number.isInteger(totalLines) || totalLines < 1 || offset >= totalLines) {
110
+ return { ok: false, code: 'READ_BAD_ARGS', error: `offset ${offset} is outside 0..${Math.max(0, totalLines - 1)}` }
111
+ }
112
+ resolvedLines = { start: offset + 1, end: Math.min(offset + limit, totalLines) }
113
+ }
114
+ return {
115
+ ok: true,
116
+ mode: paginate ? 'lines' : inferReadMode(args, lines),
117
+ lines: resolvedLines,
118
+ paginate,
119
+ offset,
120
+ limit,
121
+ }
122
+ }
76
123
  /**
77
124
  * 平铺形状的**地址键**({@link flatHunk} 与未知参数守卫共用同一份名单)。
78
125
  *
79
126
  * 导出它是为了不再出现第二份:守卫要拿它算“合法键集合”,而如果有两份名单,
80
127
  * 新增一种地址写法时必有一处漏改——那正是本项目反复出现的形状。
81
128
  */
82
- export const FLAT_ADDRESS_KEYS = ['target', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'handle']
129
+ export const FLAT_ADDRESS_KEYS = ['target', 'intent', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'handle', 'old']
130
+
131
+ /**
132
+ * `targets` 元素(单目标参数)的合法键。
133
+ *
134
+ * 与顶层工具键**分开**:`targets` 本身、批量级的 `include_transaction` 都不是元素参数;
135
+ * 放进元素里只会被静默丢掉。三面(CLI / MCP / DSH)共用这一份,未知键一律
136
+ * `ARGS_UNKNOWN_KEY`,不再只校验顶层。
137
+ */
138
+ export const MT_READ_ELEMENT_KEYS = [
139
+ 'file_path', 'mode', 'around', 'lines', 'start_line', 'end_line', 'search', 'context', 'since', 'kind', 'budget', 'max_chars', 'offset', 'limit',
140
+ ]
141
+ export const MT_EDIT_ELEMENT_KEYS = [
142
+ 'file_path', 'hunks', 'create', 'dry_run', 'require_observation',
143
+ 'target', 'intent', 'lines', 'start_line', 'end_line', 'line_cue', 'anchor', 'old', 'context',
144
+ 'replace', 'replace_all', 'eol', 'expect', 'near', 'handle', 'line', 'position',
145
+ ]
83
146
 
84
147
  /**
85
148
  * 句柄形状的**从属键**(fs-contract-v2 / HD2):它们只在 `handle` 在场时有意义,
@@ -121,7 +184,7 @@ export function handleShapeVerdict(value) {
121
184
  *
122
185
  * ## 为什么必须有这道守卫(实测出来的)
123
186
  *
124
- * 真框架**不会**剥掉未声明的参数:实测 `aura_read` 收到 `{file_path, mod: 'map'}` 时
187
+ * 真框架**不会**剥掉未声明的参数:实测 `aura_basetools_read` 收到 `{file_path, mod: 'map'}` 时
125
188
  * `args.mod` 原样到达 `execute`,而工具只读 `args.mode` ⇒ 回一个**默认模式的成功回执**,零反馈。
126
189
  * 模型把 `mode` 写成 `mod`、`lines` 写成 `line`、`maxChars` 写成 `max_chars` 时,
127
190
  * 它看到的是成功——比报错坏得多(本项目原则:宁可大声失败,不可静默换语义)。
@@ -134,7 +197,7 @@ export function handleShapeVerdict(value) {
134
197
  * ## 实测代价(16757 次真实调用)
135
198
  *
136
199
  * 会被这道守卫拒的:`limit×13`、`offset×10`、`line_cue×2`、`start_mode×1`、`sandbox_permissions×1`
137
- * (全在 `aura_read` 侧;`aura_edit` 侧**一条都没有**)。
200
+ * (全在 `aura_basetools_read` 侧;`aura_basetools_edit` 侧**一条都没有**)。
138
201
  * 其中 `limit`与`offset` 本意是分页——而本工具的分页参数叫 `start_line`/`end_line`,
139
202
  * 静默忽略的结果是“模型以为自己翻页了、实际拿到的是同一段”。报错是**修好**,不是收紧。
140
203
  *
@@ -394,6 +457,23 @@ export function assertEditTargets(source, options = {}) {
394
457
  { code: 'ARGS_TARGETS_SHAPE', index, got: 'an object without a path' },
395
458
  )
396
459
  }
460
+ /**
461
+ * 元素级布尔参数在**参数面**就判类型:否则 `require_observation:"yes"` 会在
462
+ * `editTargetRequests` 里抛到编排层之外(dry-run 分支没有 try/catch,CLI 直接吐 Node 栈;
463
+ * 非 dry-run 还会先建一个事务再回滚)。参数没成形属于 `ARGS_*` 面,与 `USAGE/1` 同档。
464
+ */
465
+ for (const key of ['require_observation', 'requireObservation']) {
466
+ if (element[key] !== undefined && typeof element[key] !== 'boolean') {
467
+ throw Object.assign(
468
+ new Error(`${toolName}: \`${targetKey}[${index}].${key}\` must be a boolean — got ${typeWordOf(element[key])}`),
469
+ { code: 'ARGS_BAD_REQUIRE_OBSERVATION', index, key, got: typeWordOf(element[key]) },
470
+ )
471
+ }
472
+ }
473
+ // 元素级的未知键:与顶层同一个守卫、同一套报错(合法键由调用方按读/写给)。
474
+ if (Array.isArray(options.elementKeys)) {
475
+ assertKnownArgs(element, options.elementKeys, `${toolName} ${targetKey}[${index}]`)
476
+ }
397
477
  })
398
478
  return raw
399
479
  }
@@ -413,7 +493,7 @@ export function assertEditTargets(source, options = {}) {
413
493
  *
414
494
  * 为什么不是另一份实现:`assertEditTargets` 里那条互斥判据(`targets` 与单目标形状同时给 ⇒
415
495
  * 参数面拒绝)是本仓的**唯一一份**。读侧再写一遍就会漂开 —— 而漂开之后,同一个载荷
416
- * 在 `aura_read` 被放行、在 `aura_edit` 被拒(或反过来),且两边都不会报错。
496
+ * 在 `aura_basetools_read` 被放行、在 `aura_basetools_edit` 被拒(或反过来),且两边都不会报错。
417
497
  *
418
498
  * 读侧元素内部(mode / lines / search / since / kind)本函数**一个字都不解读**:
419
499
  * 它们由引擎 `readManyWith` 逐项走今天那套单目标判定器(契约 §2.1「同一个校验器」)。
@@ -486,6 +566,23 @@ export function editTargetRequests(targets) {
486
566
  for (const key of ['create', 'identityKey', 'dryRun']) {
487
567
  if (element[key] !== undefined) request[key] = element[key]
488
568
  }
569
+ /**
570
+ * 单目标参数里的 `require_observation` 在这里翻成引擎的驼峰键。
571
+ *
572
+ * 旧版只转发 `create/identityKey/dryRun`,于是元素上写 `require_observation: true`
573
+ * 在参数面被收下、到引擎前被静默丢掉——与“顶层默认值”相反的行为,而且回执照样成功。
574
+ * 元素显式给了就压过顶层默认(与 `dryRun` 同一口径)。
575
+ */
576
+ const requireObservation =
577
+ element.require_observation !== undefined ? element.require_observation : element.requireObservation
578
+ if (requireObservation !== undefined) {
579
+ if (typeof requireObservation !== 'boolean') {
580
+ throw Object.assign(new Error(`targets[${index}].require_observation must be a boolean`), {
581
+ code: 'ARGS_BAD_REQUIRE_OBSERVATION',
582
+ })
583
+ }
584
+ request.requireObservation = requireObservation
585
+ }
489
586
  return request
490
587
  })
491
588
  }