aura-basetools 0.13.0 → 0.13.2

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 CHANGED
@@ -1,4 +1,4 @@
1
- # aura-basetools 0.13.0
1
+ # aura-basetools 0.13.2
2
2
 
3
3
  Byte-exact file tools for coding agents: read by intent, edit by intent, and write whole files without corrupting bytes. One engine shared by a standalone CLI and an MCP server.
4
4
 
@@ -21,7 +21,7 @@ The engine is intentionally not a general file manager: it reads and edits UTF-8
21
21
  ### Install
22
22
 
23
23
  ```bash
24
- npm i -g aura-basetools@0.13.0
24
+ npm i -g aura-basetools@0.13.2
25
25
  aura --version
26
26
  ```
27
27
 
@@ -137,7 +137,7 @@ MIT.
137
137
  ### 安装
138
138
 
139
139
  ```bash
140
- npm i -g aura-basetools@0.13.0
140
+ npm i -g aura-basetools@0.13.2
141
141
  aura --version
142
142
  ```
143
143
 
package/bin/aura-mcp.mjs CHANGED
@@ -13,9 +13,9 @@
13
13
  * aura_basetools_transactions 列出当前工作区写事务
14
14
  *
15
15
  * 接法(写进各家的 MCP 配置即可):
16
- * codex: codex mcp add aura -- npx -y --package aura-basetools aura mcp
17
- * claude code: claude mcp add aura -- npx -y --package aura-basetools aura mcp
18
- * cursor/zed: command = "npx", args = ["-y", "--package", "aura-basetools", "aura", "mcp"]
16
+ * codex: codex mcp add aura -- npx -y --package aura-basetools aura basetools mcp
17
+ * claude code: claude mcp add aura -- npx -y --package aura-basetools aura basetools mcp
18
+ * cursor/zed: command = "npx", args = ["-y", "--package", "aura-basetools", "aura", "basetools", "mcp"]
19
19
  *
20
20
  * 为什么必须带 `--package`:本包有两个 bin(`aura` / `aura-mcp`),**没有**一个叫
21
21
  * `aura-basetools`;npx 在"多个 bin 且没有与包名同名的"时候会拒绝猜,直接报
@@ -559,7 +559,7 @@ export async function serve() {
559
559
  await chain
560
560
  }
561
561
 
562
- // 直接运行本文件(`aura-mcp`,package.json 里声明的第二个 bin)也要起服务,与 `aura mcp` 等价。
562
+ // 直接运行本文件(`aura-mcp`,package.json 里声明的第二个 bin)也要起服务,与 `aura basetools mcp` 等价。
563
563
  // 用 pathToFileURL 比手拼 file:// 靠得住(Windows 盘符就是那种手拼会踩的地方)。
564
564
  if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
565
565
  await serve()
@@ -745,6 +745,22 @@ export function locate(source, request = {}) {
745
745
  }
746
746
  }
747
747
 
748
+ /**
749
+ * 符号地址落在“整行区间比声明更宽”的行上 ⇒ 拒绝,不能把行内别的代码一起删掉。
750
+ *
751
+ * 这是 `LOCATOR_LINE_SHARED` 的另一半:同族判据(同一行还有别人),但“别人”
752
+ * 不一定是符号表里的兄弟——`import` / 指令 / 调用语句根本不是符号。
753
+ */
754
+ const sharedLineRefusal = (hit, shared, byWhat) => {
755
+ const label = clipReceiptLabel(filePath === null ? 'this file' : filePath, RECEIPT_LABEL_CHARS)
756
+ const advice = 'use old= with a unique fragment, handle=, or rewrite the whole file explicitly — nothing was written'
757
+ const message =
758
+ shared.side === 'header'
759
+ ? `cannot address "${hit.name}" by ${byWhat} in ${label}: the declaration starts on line ${shared.line} (${JSON.stringify(shared.text)}), so the addressed span would begin after its header and leave it behind; use lines= covering line ${shared.line}, or ${advice}`
760
+ : `cannot address "${hit.name}" by ${byWhat} in ${label}: line ${shared.line} also holds ${JSON.stringify(shared.text)} ${shared.side === 'before' ? 'before' : 'after'} the declaration, so replacing the symbol's whole-line span would delete it; ${advice}`
761
+ return { ok: false, code: 'LOCATOR_LINE_SHARED', message, shared }
762
+ }
763
+
748
764
  // ── 第 0 层:意图地址(T16)。调用方给一段模糊的意图串,引擎确定性地落成精确区间。
749
765
  //
750
766
  // 解析次序(每一档都不猜,落不下来就报歧义并给候选):
@@ -792,9 +808,12 @@ export function locate(source, request = {}) {
792
808
  }
793
809
  if (exact !== null) {
794
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')
795
813
  return done(exact.startLine, exact.endLine, 'intent~symbol')
796
814
  }
797
- const symbols = listSymbols(index)
815
+ // 用原始符号表而不是 `listSymbols` 投影:整行区间是否越界要看 token,投影里没有。
816
+ const symbols = index.symbols
798
817
  let matches = symbols.filter((s) => s.name.includes(intent))
799
818
  if (matches.length === 0) {
800
819
  const lower = intent.toLowerCase()
@@ -803,6 +822,8 @@ export function locate(source, request = {}) {
803
822
  if (matches.length === 1) {
804
823
  const only = matches[0]
805
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')
806
827
  return done(only.startLine, only.endLine, 'intent~symbol')
807
828
  }
808
829
  if (matches.length > 1) {
@@ -810,6 +831,8 @@ export function locate(source, request = {}) {
810
831
  const chosen = nearestSymbolBySpan(matches, near)
811
832
  if (chosen !== null) {
812
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')
813
836
  return done(chosen.startLine, chosen.endLine, 'intent~symbol', true)
814
837
  }
815
838
  }
@@ -908,6 +931,9 @@ export function locate(source, request = {}) {
908
931
  hint: directoryHint(source, lang),
909
932
  }
910
933
  }
934
+ /** 兄弟符号之外,行内还可能有 `import` / 指令 / 调用语句:同一个判据,看字节。 */
935
+ const shared = sharedLineContent(source, index, hit, lang)
936
+ if (shared !== null) return sharedLineRefusal(hit, shared, 'target')
911
937
  return done(hit.startLine, hit.endLine, 'symbol')
912
938
  } catch (error) {
913
939
  return {
@@ -1022,6 +1048,121 @@ function loneCarriageReturnNote(source) {
1022
1048
  return ' — this file uses lone CR (\\r) line endings, which are not treated as line breaks here (the symbol index counts lines by \\n, so a second line model would point at different bytes); address it with line_cue/anchor, or replace the whole file with lines=[1,1]'
1023
1049
  }
1024
1050
 
1051
+ /** 声明前缀里本来就属于声明的标点(指针/引用/泛型/命名空间/可空修饰)。 */
1052
+ const DECL_PREFIX_PUNCT = new Set(['*', '&', '<', '>', ',', '::', '?', ':', '~', '.'])
1053
+
1054
+ /** 允许“包住声明”的开括号:Lisp 族的 `(defn …)`。`{` 不算(JS/C 的块不是声明本身)。 */
1055
+ const FORM_OPENERS = new Map([['(', ')'], ['[', ']']])
1056
+
1057
+ /**
1058
+ * 整行替换会把声明之外的内容一起删掉的那些行。
1059
+ *
1060
+ * `siblingSymbolsOnSameSpan` 只看得见符号表里的兄弟;`import` / 指令 / 调用语句
1061
+ * 根本不是符号,却同样会被整行替换删掉(实测:`import fs from 'fs'; function alpha()`
1062
+ * 上 `target=alpha` 会把 import 一起删掉)。判据回到字节:
1063
+ * · 声明起点之前:只允许空白与**包住这个声明**的开括号(Lisp 族的 `(defn …)`);
1064
+ * · 声明终点之后:只允许空白、分号、注释与包住这个声明的闭括号。
1065
+ * 其余任何 token(`import` / `return` / 另一条语句)都说明这一个行区间不止这个声明。
1066
+ *
1067
+ * @returns `{line, side:'before'|'after'|'header', text}`,或 null(这一行只属于这个声明)
1068
+ */
1069
+ function sharedLineContent(source, index, hit, lang) {
1070
+ const declStartTok = hit.declStartTok ?? hit.startTok
1071
+ const startToken = index.tokens[declStartTok]
1072
+ const endToken = index.tokens[hit.endTok]
1073
+ if (startToken === undefined || endToken === undefined) return null
1074
+ const startLine = index.lineAt(startToken.start)
1075
+ /**
1076
+ * C 家族:`$ident*` 吃不掉关键字返回类型,声明起点可能落在名字上(见 struct-core 的
1077
+ * `declStartUnknown`)。类型回到上一行时,行区间会把上一行留成悬空前缀——拒绝,不静默改。
1078
+ * 指令行(`#include`)与注解行(`@Override`)本来就不是声明头,不参与这条判据。
1079
+ */
1080
+ if (hit.declStartUnknown === true) {
1081
+ const prev = index.tokens[(hit.declStartTok ?? hit.startTok) - 1]
1082
+ if (prev !== undefined) {
1083
+ const prevLine = index.lineAt(prev.end)
1084
+ if (prevLine === startLine - 1 && ![';', '{', '}'].includes(prev.value)) {
1085
+ const prevLineStart = source.lastIndexOf('\n', prev.start - 1) + 1
1086
+ const prevLineEnd = source.indexOf('\n', prev.end)
1087
+ const prevLineText = source.slice(prevLineStart, prevLineEnd === -1 ? source.length : prevLineEnd)
1088
+ if (!/^\s*[#@]/.test(prevLineText)) {
1089
+ return { line: prevLine, side: 'header', text: prevLineText.trim().slice(0, 80) }
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+ /**
1095
+ * 声明起点之前、同一行里的 token。只有两类可以留:
1096
+ * · **类型前缀**——仅当 `declStartUnknown`(C 家族把返回类型漏在声明起点之前);
1097
+ * · **包住声明的开括号**(Lisp 族):它的闭括号会在后缀里出现。
1098
+ * 其余任何 token(`;` / `{` / `=` / 字符串 / `@` / 别的语句)都说明这一行不止这个声明。
1099
+ */
1100
+ const allowTypePrefix = hit.declStartUnknown === true
1101
+ const enclosing = new Map()
1102
+ for (let k = declStartTok - 1; k >= 0; k -= 1) {
1103
+ const token = index.tokens[k]
1104
+ if (index.lineAt(token.start) !== startLine) break
1105
+ if (allowTypePrefix && token.type === 'ident') continue
1106
+ if (allowTypePrefix && token.type === 'punct' && DECL_PREFIX_PUNCT.has(token.value)) continue
1107
+ if (token.type === 'punct' && FORM_OPENERS.has(token.value)) {
1108
+ // `close > endTok` 或 `close === endTok`:Lisp 的收尾 `)` 本身常是跨度的最后一个 token
1109
+ // (`(defn alpha [] 1)` 的 endTok 就是那个 `)`),开括号却仍在跨度之前。
1110
+ const close = index.pairs.get(k)
1111
+ if (close !== undefined && close >= hit.endTok) {
1112
+ const closer = FORM_OPENERS.get(token.value)
1113
+ enclosing.set(closer, (enclosing.get(closer) ?? 0) + 1)
1114
+ continue
1115
+ }
1116
+ }
1117
+ const lineStart = source.lastIndexOf('\n', startToken.start - 1) + 1
1118
+ return { line: startLine, side: 'before', text: source.slice(lineStart, startToken.start).trim().slice(0, 80) }
1119
+ }
1120
+ const endLine = index.lineAt(endToken.end)
1121
+ const lineEnd = source.indexOf('\n', endToken.end)
1122
+ const after = source.slice(endToken.end, lineEnd === -1 ? source.length : lineEnd)
1123
+ const rest = trailingForeign(after, lang, enclosing)
1124
+ if (rest !== '') return { line: endLine, side: 'after', text: rest.trim().slice(0, 80) }
1125
+ return null
1126
+ }
1127
+
1128
+ /**
1129
+ * 声明终点之后同一行里还剩什么:空白、分号、注释、以及**包住声明的**闭括号不算别的代码。
1130
+ * 闭括号只在 `enclosing` 计数里出现(由前缀里那些开括号登记),所以 `function inner(){} }`
1131
+ * 这种“外层块的 `}` 落在同一行”不会被当成收尾。
1132
+ *
1133
+ * @returns 剩下的非平凡文本(空串 = 这一行到此为止只属于这个声明)
1134
+ */
1135
+ function trailingForeign(text, lang, enclosing) {
1136
+ let rest = text
1137
+ const lineComments = lang?.lineComments ?? []
1138
+ const blockComments = lang?.blockComments ?? []
1139
+ for (;;) {
1140
+ rest = rest.replace(/^\s+/, '')
1141
+ if (rest === '') return ''
1142
+ if (lineComments.some((marker) => rest.startsWith(marker))) return ''
1143
+ const block = blockComments.find(([open]) => rest.startsWith(open))
1144
+ if (block !== undefined) {
1145
+ const close = rest.indexOf(block[1], block[0].length)
1146
+ if (close === -1) return rest
1147
+ rest = rest.slice(close + block[1].length)
1148
+ continue
1149
+ }
1150
+ // 分号单独处理:JS 家族是语句分隔符,Lisp 族已经在 lineComments 里提前返回。
1151
+ if (rest.startsWith(';')) {
1152
+ rest = rest.slice(1)
1153
+ continue
1154
+ }
1155
+ const closer = rest[0]
1156
+ const count = enclosing.get(closer) ?? 0
1157
+ if (count > 0) {
1158
+ enclosing.set(closer, count - 1)
1159
+ rest = rest.slice(1)
1160
+ continue
1161
+ }
1162
+ return rest
1163
+ }
1164
+ }
1165
+
1025
1166
  /** 文件是否含**不跟 LF** 的 CR(老式 Mac 行尾)。只扫一次、不建第二套行模型。 */
1026
1167
  function hasLoneCarriageReturn(source) {
1027
1168
  return /\r(?!\n)/.test(source)
@@ -2278,6 +2278,13 @@ function scanBodyOpen(tokens, pairs, endTok, lang, lineAt, kind) {
2278
2278
  const t = tokens[k]
2279
2279
  const prev = tokens[k - 1]
2280
2280
  if (t.type === 'punct' && (t.value === ';' || t.value === '}')) break
2281
+ /**
2282
+ * Lisp 族:声明本身是列表的第一个元素(`(ns alpha.core)`、`(defn f [a] a)`)。
2283
+ * 名字之后遇到收口 `)` 就是**包住这个声明的那对括号**收尾了——后面的 `(`(下一个 form)
2284
+ * 不是块体。少了这一条,`(ns alpha)` 会把下一行 `(defn …)` 的 `(` 当体,跨度扩到整个文件,
2285
+ * `target=alpha` 会连后面的函数一起删掉(lang-coverage 的 cljs 夹具就是这个形状)。
2286
+ */
2287
+ if (open === '(' && t.type === 'punct' && t.value === ')') break
2281
2288
  /**
2282
2289
  * 块开符判定:`bodyOpen` 本身,加上**配置声明的**其它块开符(`[$ … ]` 字面量体),
2283
2290
  * 再加上 `bodyOpens` 里显式列出的字符。
@@ -2390,7 +2397,7 @@ function headerContinues(tokens, k, prev, clauses) {
2390
2397
  * 用独立的第二条规则去做会与变量规则重复命中同一个位置,靠规则顺序决定谁赢,
2391
2398
  * 那是在用"顺序"掩盖语义,典型补丁。
2392
2399
  */
2393
- function declarationSpan(tokens, pairs, startTok, endTok, lang, lineAt, kind, lines, termScan) {
2400
+ function declarationSpan(tokens, pairs, declStartTok, startTok, endTok, lang, lineAt, kind, lines, termScan) {
2394
2401
  const open = lang.bodyOpen
2395
2402
  let bodyOpen = null
2396
2403
  let binding = kind
@@ -2456,10 +2463,13 @@ function declarationSpan(tokens, pairs, startTok, endTok, lang, lineAt, kind, li
2456
2463
 
2457
2464
  return {
2458
2465
  kind: binding,
2459
- startLine: lineAt(tokens[startTok].start),
2466
+ // 声明起点是**规则起点**(含修饰符/返回类型),不是名字 token:`export function\nalpha()`
2467
+ // 若从名字所在行算起,整行替换会把上一行的 `export function` 留成悬空前缀。
2468
+ startLine: lineAt(tokens[declStartTok].start),
2460
2469
  endLine: lineAt(tokens[last].end),
2461
2470
  startTok,
2462
2471
  endTok: last,
2472
+ declStartTok,
2463
2473
  bodyOpen,
2464
2474
  spanComplete,
2465
2475
  spanNote,
@@ -2666,8 +2676,21 @@ function buildStructure(src, lang) {
2666
2676
  // 会捕到参数列表里的 `a:` 这种半截 token 并报出一个不存在的符号。
2667
2677
  // 它比“只捕标识符”的 `$name` 严——`$name` 不校验**字符串**内容,而 `$label` 会带引号进来。
2668
2678
  if (typeof name !== 'string' || !/^[A-Za-z_$\u0080-\uffff][A-Za-z0-9_$\u0080-\uffff-]*$/.test(name)) continue
2669
- const span = declarationSpan(tokens, pairs, hit.startTok, hit.end - 1, lang, lineAt, rule.kind, lines, termScan)
2670
- symbols.push({ name, ...span })
2679
+ const span = declarationSpan(tokens, pairs, hit.matchStart, hit.startTok, hit.end - 1, lang, lineAt, rule.kind, lines, termScan)
2680
+ /**
2681
+ * 规则在名字之前只有 `$mods*` / `$ident*` 这类“可选前缀段”、没有字面量时,匹配器可能
2682
+ * 一段都没吃到:C 的 `static int alpha`(`$ident*` 只吃非关键字,`int` 是关键字)、
2683
+ * Dart 的 `int alpha(...)`(规则是 `$mods* $name …`,返回类型不在模式里)。此时
2684
+ * `declStartTok` 落在名字上,类型若回到**上一行**,行区间会把上一行留成悬空前缀。
2685
+ *
2686
+ * 这里只做标记,判据放在定位层({@link sharedLineContent}):它能看到行文本与上一行
2687
+ * 是否真的像声明头。`matchStart !== startTok` 说明前缀已经被吃进跨度,没有这个盲区。
2688
+ */
2689
+ const nameAt = rule.pattern.findIndex((part) => part === '$name' || part === '$nameNotKeyword')
2690
+ const prefixParts = nameAt > 0 ? rule.pattern.slice(0, nameAt) : []
2691
+ const declStartUnknown =
2692
+ hit.matchStart === hit.startTok && prefixParts.length > 0 && prefixParts.every((part) => part.startsWith('$'))
2693
+ symbols.push({ name, ...span, ...(declStartUnknown ? { declStartUnknown: true } : {}) })
2671
2694
  break
2672
2695
  }
2673
2696
  }
@@ -2803,6 +2803,8 @@ export function planHunks(content, hunks, filePath = null, options = {}) {
2803
2803
  if (!found.ok) {
2804
2804
  throw new PatchError(found.message, found.code, {
2805
2805
  ...(found.candidates === undefined ? {} : { candidates: found.candidates }),
2806
+ /** `LOCATOR_LINE_SHARED` 的“被谁占了”证据(符号兄弟之外的 import/尾随语句)。 */
2807
+ ...(found.shared === undefined ? {} : { shared: found.shared }),
2806
2808
  })
2807
2809
  }
2808
2810
  hits = [{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aura-basetools",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
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",