aura-basetools 0.20.0 → 0.21.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 +3 -3
- package/bin/aura-mcp.mjs +49 -14
- package/bin/aura.mjs +28 -4
- package/lib/encoding-memory.mjs +87 -0
- package/lib/encoding.mjs +410 -0
- package/lib/fs-provider.mjs +60 -6
- package/lib/ops.mjs +50 -14
- package/locator-lab/doc-structure.mjs +382 -0
- package/locator-lab/read-core.mjs +136 -12
- package/locator-lab/write-path.mjs +22 -3
- package/package.json +1 -1
- package/test/doctor-test.mjs +6 -0
- package/test/write-test.mjs +7 -2
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-structure.mjs —— 文档 / 配置的结构目录(`map` 用)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* `map` 现在只认代码扩展名;`.md` / `.json` / `.yaml` / `.toml` 一律回一句
|
|
7
|
+
* “unsupported extension — no symbol index”。而实测最近 62 个会话里 **1,836 次 read**
|
|
8
|
+
* 有 330 次 `.md`(18%)、55 次 `.json`、13 次 `.yml`——约 22% 的目标是文档/配置,
|
|
9
|
+
* 它们拿到的地图是一句拒绝。
|
|
10
|
+
*
|
|
11
|
+
* ## 为什么不是“再写一个解析器”
|
|
12
|
+
*
|
|
13
|
+
* 这层的目标不是完整语法,而是**目录**:给出行号、层级与名字,让人能挑一段用
|
|
14
|
+
* `mode=lines` 取回。所以:
|
|
15
|
+
* · Markdown:ATX 标题(跳过围栏代码块里的 `#`);setext 标题不认(降级为正文)。
|
|
16
|
+
* · JSON:字符串/注释感知的轻量扫描,给对象键与数组元素;解析不完整时**降级**而不是抛。
|
|
17
|
+
* · YAML:缩进 + `key:` 行;锚点/多行块不展开(目录仍然可用)。
|
|
18
|
+
* · TOML:`[table]` 与 `key =` 行。
|
|
19
|
+
*
|
|
20
|
+
* 每条 entry 都有 `startLine` / `endLine`:目录本身要可执行(下一步是 `mode=lines`)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 扩展名 → 文档种类。故意不含 `.txt`:纯文本没有结构,给了目录也是假的。 */
|
|
24
|
+
export const DOC_KINDS = {
|
|
25
|
+
md: 'markdown',
|
|
26
|
+
markdown: 'markdown',
|
|
27
|
+
mdx: 'markdown',
|
|
28
|
+
json: 'json',
|
|
29
|
+
jsonc: 'json',
|
|
30
|
+
json5: 'json',
|
|
31
|
+
yaml: 'yaml',
|
|
32
|
+
yml: 'yaml',
|
|
33
|
+
toml: 'toml',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `filePath` 的扩展名决定用哪套结构索引;不是文档类型返回 null。 */
|
|
37
|
+
export function docKindOf(filePath) {
|
|
38
|
+
if (typeof filePath !== 'string') return null
|
|
39
|
+
const dot = filePath.lastIndexOf('.')
|
|
40
|
+
if (dot < 0) return null
|
|
41
|
+
const ext = filePath.slice(dot + 1).toLowerCase()
|
|
42
|
+
return DOC_KINDS[ext] ?? null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 索引一篇文档。
|
|
47
|
+
*
|
|
48
|
+
* @returns {{entries: Array<{name:string, kind:string, depth:number, startLine:number, endLine:number}>, unit:string, note?:string}}
|
|
49
|
+
*/
|
|
50
|
+
export function indexDocument(source, docKind) {
|
|
51
|
+
const lines = String(source).split(/\r?\n/)
|
|
52
|
+
if (docKind === 'markdown') return indexMarkdown(lines)
|
|
53
|
+
if (docKind === 'json') return indexJson(String(source), lines)
|
|
54
|
+
if (docKind === 'yaml') return indexYaml(lines)
|
|
55
|
+
if (docKind === 'toml') return indexToml(lines)
|
|
56
|
+
return { entries: [], unit: 'entries', note: `no structure index for ${docKind}` }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ── Markdown ─────────────────────────────────────────────────────────────── */
|
|
60
|
+
|
|
61
|
+
function indexMarkdown(lines) {
|
|
62
|
+
const entries = []
|
|
63
|
+
let fence = null
|
|
64
|
+
/** YAML front matter:只在第 1 行 `---` 起算,到下一个 `---` / `...` 结束。 */
|
|
65
|
+
let frontMatter = false
|
|
66
|
+
/**
|
|
67
|
+
* 上一行是否是一个“可以升级为 setext 标题的段落行”。
|
|
68
|
+
*
|
|
69
|
+
* 为什么不能只看“下一行是不是 `===`/`---`”:`---` 同时是**分隔线**、也是 YAML
|
|
70
|
+
* front matter 的边界。CommonMark 的消歧规则是:setext 下划线只能接在**段落**后面
|
|
71
|
+
* (列表项、引用、缩进代码、标题、分隔线都不行),而分隔线前面又必须是空行。
|
|
72
|
+
* 所以这里维护一个“上一行是段落行”的状态,而不是做简单的向前看。
|
|
73
|
+
*/
|
|
74
|
+
let paragraph = -1
|
|
75
|
+
/** 当前段落的起始行:setext 标题取的是**整段**,不是最后一行(CommonMark)。 */
|
|
76
|
+
let paragraphStart = -1
|
|
77
|
+
const isParagraph = (line) => {
|
|
78
|
+
if (line.trim() === '') return false
|
|
79
|
+
if (/^\s{4,}/.test(line)) return false // 缩进代码块
|
|
80
|
+
if (/^\s*>/.test(line)) return false // 引用
|
|
81
|
+
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) return false // 列表项
|
|
82
|
+
if (/^\s*(```+|~~~+)/.test(line)) return false
|
|
83
|
+
if (/^ {0,3}#{1,6}(?:\s|$)/.test(line)) return false // ATX 标题
|
|
84
|
+
if (/^\s*(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/.test(line)) return false // 分隔线
|
|
85
|
+
if (/^\s*<[^>]+>/.test(line)) return false // HTML 块(粗略)
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
89
|
+
const line = lines[i]
|
|
90
|
+
if (i === 0 && /^---\s*$/.test(line)) {
|
|
91
|
+
frontMatter = true
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (frontMatter) {
|
|
95
|
+
if (/^(?:---|\.\.\.)\s*$/.test(line)) frontMatter = false
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
const fenceHit = /^\s*(```+|~~~+)/.exec(line)
|
|
99
|
+
if (fenceHit !== null) {
|
|
100
|
+
const mark = fenceHit[1][0]
|
|
101
|
+
if (fence === null) fence = mark
|
|
102
|
+
else if (fence === mark) fence = null
|
|
103
|
+
paragraph = -1
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (fence !== null) continue
|
|
107
|
+
// setext:上一行是段落 ⇒ 本行是它的下划线(`=` 一级,`-` 二级)
|
|
108
|
+
const setext = /^ {0,3}(=+|-+)\s*$/.exec(line)
|
|
109
|
+
if (setext !== null && paragraph === i - 1) {
|
|
110
|
+
// 多行段落 → 标题文本是整段(行间用空格接起来),区间从段首算起。
|
|
111
|
+
const name = lines.slice(paragraphStart, paragraph + 1).map((row) => row.trim()).filter(Boolean).join(' ')
|
|
112
|
+
entries.push({
|
|
113
|
+
kind: 'heading',
|
|
114
|
+
name: name || '(untitled)',
|
|
115
|
+
depth: setext[1][0] === '=' ? 1 : 2,
|
|
116
|
+
startLine: paragraphStart + 1,
|
|
117
|
+
endLine: i + 1,
|
|
118
|
+
})
|
|
119
|
+
paragraph = -1
|
|
120
|
+
paragraphStart = -1
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
|
|
124
|
+
if (heading !== null) {
|
|
125
|
+
entries.push({
|
|
126
|
+
kind: 'heading',
|
|
127
|
+
name: heading[2].length === 0 ? '(untitled)' : heading[2],
|
|
128
|
+
depth: heading[1].length,
|
|
129
|
+
startLine: i + 1,
|
|
130
|
+
endLine: i + 1,
|
|
131
|
+
})
|
|
132
|
+
paragraph = -1
|
|
133
|
+
paragraphStart = -1
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (isParagraph(line)) {
|
|
137
|
+
// 连续段落行:保持 paragraphStart,直到段落被中断。
|
|
138
|
+
// 注意 `paragraph === -1` 与“上一行正好是 -1”会撞车(第 1 行时 i-1 也是 -1),
|
|
139
|
+
// 所以用 paragraphStart 的哨兵值把“段落还没开始”区分开。
|
|
140
|
+
if (paragraphStart === -1 || paragraph !== i - 1) paragraphStart = i
|
|
141
|
+
paragraph = i
|
|
142
|
+
} else {
|
|
143
|
+
paragraph = -1
|
|
144
|
+
paragraphStart = -1
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// 每节的范围 = 到下一个标题(任意层级)之前;最后一节到文件尾。
|
|
148
|
+
for (let k = 0; k < entries.length; k += 1) entries[k].endLine = (entries[k + 1]?.startLine ?? lines.length + 1) - 1
|
|
149
|
+
return { entries, unit: 'sections' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ── JSON / JSONC ─────────────────────────────────────────────────────────── */
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 轻量扫描:不建 AST,只记录“键/元素在哪几行”。
|
|
156
|
+
*
|
|
157
|
+
* 关键取舍:**不抛**。配置里出现尾逗号、注释、甚至半份文件时,已经扫到的目录仍然有用,
|
|
158
|
+
* 比一句“解析失败”有用得多;扫不动的部分在 `note` 里说。
|
|
159
|
+
*/
|
|
160
|
+
function indexJson(source, lines) {
|
|
161
|
+
const entries = []
|
|
162
|
+
/** 每层容器记自己的目录项(由父层创建);闭合时把它的 endLine 收到闭合行。 */
|
|
163
|
+
const stack = [] // { type:'object'|'array', path:string, entry:entry|null, index:number }
|
|
164
|
+
let i = 0
|
|
165
|
+
let line = 1
|
|
166
|
+
let note = null
|
|
167
|
+
let expectKey = false
|
|
168
|
+
let pendingKey = null
|
|
169
|
+
let pendingKeyLine = 0
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 键里有 `.` / 空格 / `[` 时不能拼成 `a.b`(`exports."."` 会读成 `exports..`——实测就撞上了):
|
|
173
|
+
* 改用 `parent["key"]`,与 JS/JSONPath 的习惯一致。
|
|
174
|
+
*/
|
|
175
|
+
const seg = (key) => (/^[A-Za-z0-9_$-]+$/.test(key) ? key : `[${JSON.stringify(key)}]`)
|
|
176
|
+
const pathOf = (parent, key) => (parent === '' ? seg(key) : `${parent}${seg(key).startsWith('[') ? seg(key) : `.${seg(key)}`}`)
|
|
177
|
+
const add = (entry) => {
|
|
178
|
+
entries.push(entry)
|
|
179
|
+
return entry
|
|
180
|
+
}
|
|
181
|
+
const skipTrivia = () => {
|
|
182
|
+
while (i < source.length) {
|
|
183
|
+
const c = source[i]
|
|
184
|
+
if (c === '\n') {
|
|
185
|
+
line += 1
|
|
186
|
+
i += 1
|
|
187
|
+
} else if (c === ' ' || c === '\t' || c === '\r') {
|
|
188
|
+
i += 1
|
|
189
|
+
} else if (c === '/' && source[i + 1] === '/') {
|
|
190
|
+
while (i < source.length && source[i] !== '\n') i += 1
|
|
191
|
+
} else if (c === '/' && source[i + 1] === '*') {
|
|
192
|
+
i += 2
|
|
193
|
+
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
|
|
194
|
+
if (source[i] === '\n') line += 1
|
|
195
|
+
i += 1
|
|
196
|
+
}
|
|
197
|
+
i += 2
|
|
198
|
+
} else break
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const readString = (quote = '"') => {
|
|
202
|
+
// 调用方已在引号上
|
|
203
|
+
const startLine = line
|
|
204
|
+
i += 1
|
|
205
|
+
let value = ''
|
|
206
|
+
while (i < source.length) {
|
|
207
|
+
const c = source[i]
|
|
208
|
+
if (c === '\\') {
|
|
209
|
+
value += source[i + 1] ?? ''
|
|
210
|
+
i += 2
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (c === quote) {
|
|
214
|
+
i += 1
|
|
215
|
+
return { value, line: startLine }
|
|
216
|
+
}
|
|
217
|
+
if (c === '\n') line += 1
|
|
218
|
+
value += c
|
|
219
|
+
i += 1
|
|
220
|
+
}
|
|
221
|
+
note = note ?? 'unterminated string — the directory stops where the text does'
|
|
222
|
+
return { value, line: startLine }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
while (i < source.length) {
|
|
226
|
+
skipTrivia()
|
|
227
|
+
if (i >= source.length) break
|
|
228
|
+
const c = source[i]
|
|
229
|
+
const top = stack[stack.length - 1]
|
|
230
|
+
if (c === '{' || c === '[') {
|
|
231
|
+
const isObject = c === '{'
|
|
232
|
+
const name = top === undefined
|
|
233
|
+
? ''
|
|
234
|
+
: top.type === 'array'
|
|
235
|
+
? `${top.path}[${top.index}]`
|
|
236
|
+
: pathOf(top.path, pendingKey ?? '?')
|
|
237
|
+
const ownEntry = top === undefined
|
|
238
|
+
? null
|
|
239
|
+
: add({ kind: isObject ? 'object' : 'array', name, depth: stack.length + 1, startLine: line, endLine: line })
|
|
240
|
+
stack.push({ type: isObject ? 'object' : 'array', path: name, entry: ownEntry, index: 0 })
|
|
241
|
+
expectKey = isObject
|
|
242
|
+
pendingKey = null
|
|
243
|
+
i += 1
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (c === '}' || c === ']') {
|
|
247
|
+
const closed = stack.pop()
|
|
248
|
+
if (closed !== undefined && closed.entry !== null && closed.entry !== undefined) closed.entry.endLine = line
|
|
249
|
+
expectKey = stack.length > 0 && stack[stack.length - 1].type === 'object'
|
|
250
|
+
i += 1
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if (c === ',') {
|
|
254
|
+
expectKey = top !== undefined && top.type === 'object'
|
|
255
|
+
pendingKey = null
|
|
256
|
+
if (top !== undefined && top.type === 'array') top.index += 1
|
|
257
|
+
i += 1
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if (c === ':') {
|
|
261
|
+
i += 1
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* JSON5:无引号键(`{name: 1}`)。必须在“标量跳过”之前认,否则整个 `name: 1`
|
|
266
|
+
* 会被当成标量吞到下一个逗号,目录里就少了这个键。
|
|
267
|
+
*/
|
|
268
|
+
if (expectKey && /[A-Za-z_$]/.test(c)) {
|
|
269
|
+
let key = ''
|
|
270
|
+
while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) {
|
|
271
|
+
key += source[i]
|
|
272
|
+
i += 1
|
|
273
|
+
}
|
|
274
|
+
pendingKey = key
|
|
275
|
+
pendingKeyLine = line
|
|
276
|
+
expectKey = false
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
if (c === '"' || c === "'") {
|
|
280
|
+
const str = readString(c)
|
|
281
|
+
if (expectKey) {
|
|
282
|
+
pendingKey = str.value
|
|
283
|
+
pendingKeyLine = str.line
|
|
284
|
+
expectKey = false
|
|
285
|
+
} else if (top !== undefined && top.type === 'object' && pendingKey !== null) {
|
|
286
|
+
// 对象键的值是字符串:目录行就是键所在的那一行(与标量值同一条规则)。
|
|
287
|
+
add({ kind: 'key', name: pathOf(top.path, pendingKey), depth: stack.length, startLine: pendingKeyLine, endLine: str.line })
|
|
288
|
+
pendingKey = null
|
|
289
|
+
} else if (top !== undefined && top.type === 'array') {
|
|
290
|
+
// 数组里的字符串元素:只有对象/数组元素才值得进目录;标量元素跳过。
|
|
291
|
+
top.index += 1
|
|
292
|
+
}
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
// 标量:跳过到下一个分隔符
|
|
296
|
+
while (i < source.length && !',}]'.includes(source[i])) {
|
|
297
|
+
if (source[i] === '\n') line += 1
|
|
298
|
+
i += 1
|
|
299
|
+
}
|
|
300
|
+
if (top !== undefined && top.type === 'object' && pendingKey !== null) {
|
|
301
|
+
// 对象键的值是标量:目录行就是键所在行
|
|
302
|
+
add({ kind: 'key', name: pathOf(top.path, pendingKey), depth: stack.length, startLine: pendingKeyLine, endLine: line })
|
|
303
|
+
pendingKey = null
|
|
304
|
+
} else if (top !== undefined && top.type === 'array') {
|
|
305
|
+
top.index += 1
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (stack.length > 0) note = note ?? 'the document ends inside an open container — the directory is partial'
|
|
309
|
+
return { entries, unit: 'keys', ...(note === null ? {} : { note }) }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ── YAML ─────────────────────────────────────────────────────────────────── */
|
|
313
|
+
|
|
314
|
+
function indexYaml(lines) {
|
|
315
|
+
const entries = []
|
|
316
|
+
const stack = [] // { indent, name }
|
|
317
|
+
let blockScalarIndent = null
|
|
318
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
319
|
+
const raw = lines[i]
|
|
320
|
+
if (raw.trim() === '' || /^\s*#/.test(raw)) continue
|
|
321
|
+
const indent = raw.length - raw.trimStart().length
|
|
322
|
+
if (blockScalarIndent !== null && indent > blockScalarIndent) continue
|
|
323
|
+
blockScalarIndent = null
|
|
324
|
+
if (/^---(\s|$)/.test(raw) || /^\.\.\.(\s|$)/.test(raw)) {
|
|
325
|
+
stack.length = 0
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
const body = raw.trimStart()
|
|
329
|
+
const match = /^(-\s+)?([^#\s][^:]*?):(\s|$)/.exec(body)
|
|
330
|
+
if (match === null) continue
|
|
331
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
|
|
332
|
+
const key = match[2]
|
|
333
|
+
const path = stack.length === 0 ? key : `${stack[stack.length - 1].name}.${key}`
|
|
334
|
+
const entry = { kind: 'key', name: path, depth: stack.length + 1, startLine: i + 1, endLine: i + 1 }
|
|
335
|
+
entries.push(entry)
|
|
336
|
+
// 值是否是多行块标量:`key: |` / `key: >`
|
|
337
|
+
if (/\s[|>][-+]?\s*$/.test(body)) blockScalarIndent = indent
|
|
338
|
+
stack.push({ indent, name: path })
|
|
339
|
+
}
|
|
340
|
+
// 一节的范围 = 到**下一个同级或更高级**的键之前(中间的深一级键属于它)。
|
|
341
|
+
for (let k = 0; k < entries.length; k += 1) {
|
|
342
|
+
let j = k + 1
|
|
343
|
+
while (j < entries.length && entries[j].depth > entries[k].depth) j += 1
|
|
344
|
+
entries[k].endLine = j < entries.length ? Math.max(entries[k].startLine, entries[j].startLine - 1) : lines.length
|
|
345
|
+
}
|
|
346
|
+
return { entries, unit: 'keys' }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* ── TOML ─────────────────────────────────────────────────────────────────── */
|
|
350
|
+
|
|
351
|
+
function indexToml(lines) {
|
|
352
|
+
const entries = []
|
|
353
|
+
let table = ''
|
|
354
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
355
|
+
const line = lines[i].trim()
|
|
356
|
+
if (line === '' || line.startsWith('#')) continue
|
|
357
|
+
const tableHit = /^\[\[?([^\]]+)\]\]?$/.exec(line)
|
|
358
|
+
if (tableHit !== null) {
|
|
359
|
+
table = tableHit[1].trim()
|
|
360
|
+
entries.push({ kind: 'table', name: table, depth: table.split('.').length, startLine: i + 1, endLine: i + 1 })
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
const keyHit = /^([A-Za-z0-9_.-]+)\s*=/.exec(line)
|
|
364
|
+
if (keyHit !== null) {
|
|
365
|
+
const name = table === '' ? keyHit[1] : `${table}.${keyHit[1]}`
|
|
366
|
+
entries.push({ kind: 'key', name, depth: name.split('.').length, startLine: i + 1, endLine: i + 1 })
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* TOML 的范围规则与 YAML 不同:表(`[a.b]`)是扁平段,深度按点名数算,
|
|
371
|
+
* 但 `[db.pool]` 与 `[server]` 是**同级**——它们之间没有父子关系。
|
|
372
|
+
* 所以:表到下一张表之前;键只占自己那一行(多行值不进目录,mode=lines 拿)。
|
|
373
|
+
*/
|
|
374
|
+
for (let k = 0; k < entries.length; k += 1) {
|
|
375
|
+
if (entries[k].kind !== 'table') continue
|
|
376
|
+
let j = k + 1
|
|
377
|
+
while (j < entries.length && entries[j].kind !== 'table') j += 1
|
|
378
|
+
entries[k].endLine = j < entries.length ? Math.max(entries[k].startLine, entries[j].startLine - 1) : lines.length
|
|
379
|
+
}
|
|
380
|
+
return { entries, unit: 'keys' }
|
|
381
|
+
}
|
|
382
|
+
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { indexStructure, langOf, listSymbols, rankSymbols, resolveTarget } from './struct-core.mjs'
|
|
25
|
+
import { docKindOf, indexDocument } from './doc-structure.mjs'
|
|
25
26
|
/**
|
|
26
27
|
* 区间句柄(HD1):本文件只**发**句柄,编辑侧怎么解析是另一张单的事。
|
|
27
28
|
*
|
|
@@ -185,7 +186,7 @@ function serializedSize(envelope) {
|
|
|
185
186
|
* 模式。刻意做成显式枚举而不是"有参数就切换"——后者会让"我到底要什么"变得含糊,
|
|
186
187
|
* 而含糊正是信息浪费的源头。
|
|
187
188
|
*/
|
|
188
|
-
export const MODES = ['map', 'digest', 'full', 'around', 'lines', 'search', 'changed']
|
|
189
|
+
export const MODES = ['map', 'digest', 'full', 'around', 'lines', 'search', 'changed', 'probe']
|
|
189
190
|
|
|
190
191
|
/**
|
|
191
192
|
* 摘要:**看懂一个文件**所需的最小信息集。
|
|
@@ -198,10 +199,26 @@ export const MODES = ['map', 'digest', 'full', 'around', 'lines', 'search', 'cha
|
|
|
198
199
|
*/
|
|
199
200
|
function renderDigest(base, source, lines, lang, request, symbolsIndex = null) {
|
|
200
201
|
if (lang === null) {
|
|
202
|
+
/**
|
|
203
|
+
* 文档没有签名,但有**结构**。`digest` 的承诺是“无正文的结构”,
|
|
204
|
+
* 所以文档的 digest = 结构目录(与 `map` 同源),而不是一句“没有结构”。
|
|
205
|
+
*/
|
|
206
|
+
const docKind = docKindOf(request.filePath)
|
|
207
|
+
if (docKind !== null) {
|
|
208
|
+
return {
|
|
209
|
+
...renderDocMap(base, lines, docKind, request),
|
|
210
|
+
mode: 'digest',
|
|
211
|
+
note: 'documents have no signatures; this is their structure directory (same as mode=map)',
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* 正文放 `text`(不是 `note`):`metrics.chars` 量的是 `text` 的长度(read-test 钉着这条等式),
|
|
216
|
+
* 只给 `note` 时 `chars` 会报 0,与 note 里的实际信息矛盾(反馈 D8)。
|
|
217
|
+
*/
|
|
201
218
|
return {
|
|
202
219
|
...base,
|
|
203
220
|
mode: 'digest',
|
|
204
|
-
|
|
221
|
+
text: `unsupported extension — no structure available; ${base.totalLines} lines`,
|
|
205
222
|
next: 'mode=lines lines=[a,b]',
|
|
206
223
|
}
|
|
207
224
|
}
|
|
@@ -1524,7 +1541,23 @@ function planIncremental(request, lines, version) {
|
|
|
1524
1541
|
: { kind: 'skip' }
|
|
1525
1542
|
}
|
|
1526
1543
|
if (entry.version === version) {
|
|
1527
|
-
|
|
1544
|
+
/**
|
|
1545
|
+
* 同一版重读:**只有账本里那份是完整交付时**,“不发正文”才成立。
|
|
1546
|
+
*
|
|
1547
|
+
* 上一次被预算截断(`covered = false`)时,调用方手里只有前半份。旧实现仍走
|
|
1548
|
+
* `no-change`,只回一句“去用 lines 拿剩下的”,于是**显式提高 budget 的请求被吞掉**:
|
|
1549
|
+
* 实测 401 行文件第二次 `full` + `budget: 60000` 只拿到 155 字符,且 `truncated: false`——
|
|
1550
|
+
* 程序化调用方会把它当成“已拿全”。(反馈 D4)
|
|
1551
|
+
*
|
|
1552
|
+
* 现在:没被显式要求增量 ⇒ `skip`(普通 full 渲染,按本次 budget 出正文);
|
|
1553
|
+
* 被显式要求增量 ⇒ `degrade`(全文 + 为何不能给差量)。两者都不会再谎报“没变化”。
|
|
1554
|
+
*/
|
|
1555
|
+
if (entry.covered !== true) {
|
|
1556
|
+
return asked
|
|
1557
|
+
? { kind: 'degrade', asked: true, reason: 'your earlier read of this file was cut at the content budget, so the ledger copy is not a complete baseline; the full text follows' }
|
|
1558
|
+
: { kind: 'skip' }
|
|
1559
|
+
}
|
|
1560
|
+
return { kind: 'no-change', baseline: entry.version, asked, bomChanged: false }
|
|
1528
1561
|
}
|
|
1529
1562
|
if (entry.covered !== true) {
|
|
1530
1563
|
return asked
|
|
@@ -1563,15 +1596,9 @@ function planIncremental(request, lines, version) {
|
|
|
1563
1596
|
|
|
1564
1597
|
/** 文件没变时的回执:**明写 no change、明写基准令牌,不给正文**。 */
|
|
1565
1598
|
function noChangeEnvelope(base, plan, total) {
|
|
1566
|
-
const partial = plan.partial === true
|
|
1567
1599
|
const bom = plan.bomChanged === true ? ' (the byte-order mark changed; the text itself is unchanged)' : ''
|
|
1568
|
-
const note =
|
|
1569
|
-
|
|
1570
|
-
: `no change since baseline ${plan.baseline} — the file is byte-identical to the copy you already have${bom}`
|
|
1571
|
-
const text = partial
|
|
1572
|
-
? `no change since baseline ${plan.baseline}; your earlier copy was truncated at the content budget — ask for the missing lines with mode=lines if you need them`
|
|
1573
|
-
: `no change since baseline ${plan.baseline} — nothing to send${bom}`
|
|
1574
|
-
return { ...base, mode: 'full', note, text, next: `mode=lines lines=[1,${total}] to get the whole text again` }
|
|
1600
|
+
const note = `no change since baseline ${plan.baseline} — the file is byte-identical to the copy you already have${bom}`
|
|
1601
|
+
return { ...base, mode: 'full', note, text: `no change since baseline ${plan.baseline} — nothing to send${bom}`, next: `mode=lines lines=[1,${total}] to get the whole text again` }
|
|
1575
1602
|
}
|
|
1576
1603
|
|
|
1577
1604
|
/**
|
|
@@ -2262,6 +2289,7 @@ export function readWith(source, request = {}) {
|
|
|
2262
2289
|
// 符号索引只建一次:`map`/`digest`/`around` 本来就要用,而 `lines`/`search` 现在也要
|
|
2263
2290
|
// (给裸行号附上可重新定位的引用)。没语言配置(未索引扩展名)时为 null,各处自行降级。
|
|
2264
2291
|
const symbolsIndex = lang === null ? null : indexStructure(source, lang)
|
|
2292
|
+
if (mode === 'probe') return renderProbe(base, source, lang, docKindOf(request.filePath), request)
|
|
2265
2293
|
if (mode === 'map') return renderMap(base, source, lines, lang, request, symbolsIndex)
|
|
2266
2294
|
if (mode === 'digest') return renderDigest(base, source, lines, lang, request, symbolsIndex)
|
|
2267
2295
|
/**
|
|
@@ -2427,6 +2455,94 @@ export function readWith(source, request = {}) {
|
|
|
2427
2455
|
return result
|
|
2428
2456
|
}
|
|
2429
2457
|
|
|
2458
|
+
/**
|
|
2459
|
+
* probe —— 只回答“这是什么文件”,不重复正文。
|
|
2460
|
+
*
|
|
2461
|
+
* 用途:调用方先判断“值不值得读”。行数、字节数、行尾风格、编码、语言/文档类型、
|
|
2462
|
+
* 结构规模(符号数或节/键数)都在这一行里;正文一律不发。
|
|
2463
|
+
*
|
|
2464
|
+
* 它不是 `map` 的替代:`map` 给的是**可追问的目录**,`probe` 给的是一句**元信息摘要**。
|
|
2465
|
+
* 代价也只有本地 IO——但对于“先看一眼再决定”这种意图,它省的是整份回执的 token。
|
|
2466
|
+
*/
|
|
2467
|
+
/** 1 symbols / 1 sections 这种复数噪声一律收掉(回执是给人读的)。 */
|
|
2468
|
+
const unitWord = (n, unit) => (n === 1 && unit.endsWith('s') ? unit.slice(0, -1) : unit)
|
|
2469
|
+
|
|
2470
|
+
function renderProbe(base, source, lang, docKind, request) {
|
|
2471
|
+
const bytes = Number.isSafeInteger(request.fileSize) ? request.fileSize : new TextEncoder().encode(source).length
|
|
2472
|
+
const hasCrlf = source.includes('\r\n')
|
|
2473
|
+
const hasLf = source.replaceAll('\r\n', '').includes('\n')
|
|
2474
|
+
const lineEndings = hasCrlf && hasLf ? 'mixed' : hasCrlf ? 'crlf' : 'lf'
|
|
2475
|
+
const encoding = typeof request.sourceEncoding === 'string' ? request.sourceEncoding : null
|
|
2476
|
+
/**
|
|
2477
|
+
* `lang` 是**语法配置对象**(`langOf` 的返回值),不是名字;直接拼进摘要会得到
|
|
2478
|
+
* `[object Object]`(实测)。名字取扩展名,与 `langOf` 判定的是同一个东西。
|
|
2479
|
+
*/
|
|
2480
|
+
const dot = typeof request.filePath === 'string' ? request.filePath.lastIndexOf('.') : -1
|
|
2481
|
+
const language = lang !== null && dot >= 0 ? request.filePath.slice(dot + 1).toLowerCase() : null
|
|
2482
|
+
let structure = null
|
|
2483
|
+
if (lang !== null) {
|
|
2484
|
+
const n = listSymbols(indexStructure(source, lang)).length
|
|
2485
|
+
structure = `${n} ${unitWord(n, 'symbols')}`
|
|
2486
|
+
} else if (docKind !== null) {
|
|
2487
|
+
const index = indexDocument(source, docKind)
|
|
2488
|
+
structure = `${index.entries.length} ${unitWord(index.entries.length, index.unit)}`
|
|
2489
|
+
}
|
|
2490
|
+
const parts = [`${base.totalLines} lines`, `${bytes} bytes`, lineEndings === 'mixed' ? 'mixed line endings' : lineEndings.toUpperCase()]
|
|
2491
|
+
if (encoding !== null) parts.push(encoding)
|
|
2492
|
+
if (language !== null) parts.push(language)
|
|
2493
|
+
else if (docKind !== null) parts.push(docKind)
|
|
2494
|
+
if (structure !== null) parts.push(structure)
|
|
2495
|
+
return {
|
|
2496
|
+
...base,
|
|
2497
|
+
mode: 'probe',
|
|
2498
|
+
bytes,
|
|
2499
|
+
lineEndings,
|
|
2500
|
+
...(encoding === null ? {} : { encoding }),
|
|
2501
|
+
...(language === null ? {} : { language }),
|
|
2502
|
+
...(docKind === null ? {} : { docKind }),
|
|
2503
|
+
...(structure === null ? {} : { structure }),
|
|
2504
|
+
text: parts.join(' · '),
|
|
2505
|
+
next: 'mode=map for a directory; mode=lines lines=[a,b] for a section; mode=full for every line',
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
/**
|
|
2510
|
+
* 文档 / 配置的目录:行号 + 层级 + 名字,不复制正文。
|
|
2511
|
+
*
|
|
2512
|
+
* 与代码地图同一套预算与续读语义:超出预算时 `omitted` 点名区间,下一步是 `mode=lines`。
|
|
2513
|
+
*/
|
|
2514
|
+
function renderDocMap(base, lines, docKind, request) {
|
|
2515
|
+
const index = indexDocument(lines.join('\n'), docKind)
|
|
2516
|
+
const kindFilter = typeof request.kind === 'string' ? request.kind : null
|
|
2517
|
+
const entries = kindFilter === null ? index.entries : index.entries.filter((entry) => entry.kind === kindFilter)
|
|
2518
|
+
if (entries.length === 0) {
|
|
2519
|
+
return {
|
|
2520
|
+
...base,
|
|
2521
|
+
mode: 'map',
|
|
2522
|
+
text: `${base.totalLines} lines · no ${index.unit} found in this ${docKind} file`,
|
|
2523
|
+
...(index.note === undefined ? {} : { note: index.note }),
|
|
2524
|
+
next: 'use mode=full or mode=lines to read content',
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
const byKind = {}
|
|
2528
|
+
for (const entry of entries) byKind[entry.kind] = (byKind[entry.kind] ?? 0) + 1
|
|
2529
|
+
const header = `${base.totalLines} lines · ${index.entries.length} ${unitWord(index.entries.length, index.unit)} (${Object.entries(byKind).map(([kind, n]) => `${kind}:${n}`).join(' ')})`
|
|
2530
|
+
const rows = entries.map((entry) => `${' '.repeat(Math.max(0, entry.depth - 1))}${entry.name} ${entry.startLine}-${entry.endLine}`)
|
|
2531
|
+
const kept = fitWithin([header, ...rows].join('\n'), base.contentBudget)
|
|
2532
|
+
const shown = Math.max(0, kept.kept - 1)
|
|
2533
|
+
return {
|
|
2534
|
+
...base,
|
|
2535
|
+
mode: 'map',
|
|
2536
|
+
symbols: index.entries.length,
|
|
2537
|
+
shown,
|
|
2538
|
+
byKind,
|
|
2539
|
+
text: kept.text,
|
|
2540
|
+
...(index.note === undefined ? {} : { note: index.note }),
|
|
2541
|
+
...(shown < rows.length ? { omitted: `${index.unit} ${shown + 1}..${rows.length} of ${rows.length} beyond the content budget; read them with mode=lines over their line range` } : {}),
|
|
2542
|
+
next: 'mode=lines lines=[a,b] to read a section; mode=map kind=<kind> to filter',
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2430
2546
|
/**
|
|
2431
2547
|
* map —— 先给地图。
|
|
2432
2548
|
*
|
|
@@ -2437,10 +2553,18 @@ export function readWith(source, request = {}) {
|
|
|
2437
2553
|
*/
|
|
2438
2554
|
function renderMap(base, source, lines, lang, request, symbolsIndex = null) {
|
|
2439
2555
|
if (lang === null) {
|
|
2556
|
+
/**
|
|
2557
|
+
* 文档 / 配置(.md/.json/.yaml/.toml)也应有目录:实测 22% 的读取目标是这几类扩展名
|
|
2558
|
+
* (最近 62 个会话 / 1,836 次 read),而旧实现只回一句“unsupported extension”。
|
|
2559
|
+
* 结构索引在 `doc-structure.mjs`;这里只负责渲染与续读。
|
|
2560
|
+
*/
|
|
2561
|
+
const docKind = docKindOf(request.filePath)
|
|
2562
|
+
if (docKind !== null) return renderDocMap(base, lines, docKind, request)
|
|
2563
|
+
// 同上:正文进 `text`,否则 `metrics.chars` 为 0 而同一条回执的说明里写着实际字符数。
|
|
2440
2564
|
return {
|
|
2441
2565
|
...base,
|
|
2442
2566
|
mode: 'map',
|
|
2443
|
-
|
|
2567
|
+
text: `unsupported extension — no symbol index; ${base.totalLines} lines, ${source.length} chars`,
|
|
2444
2568
|
next: 'use mode=full or mode=lines to read content',
|
|
2445
2569
|
}
|
|
2446
2570
|
}
|
|
@@ -3099,7 +3099,13 @@ async function verifyWriteBack(service, target, written, displayPath, signal) {
|
|
|
3099
3099
|
let actual
|
|
3100
3100
|
try {
|
|
3101
3101
|
if (level === 'bytes') {
|
|
3102
|
-
|
|
3102
|
+
/**
|
|
3103
|
+
* 期望字节由**提供方**回答(可选 `encodeFor`):非 UTF-8 存储下,引擎不能假设
|
|
3104
|
+
* `TextEncoder` 就是落盘字节。没有这个方法的提供方(DSH 壳)仍走 UTF-8,行为不变。
|
|
3105
|
+
*/
|
|
3106
|
+
expected = typeof service.encodeFor === 'function'
|
|
3107
|
+
? Buffer.from(await service.encodeFor(target, written))
|
|
3108
|
+
: new TextEncoder().encode(written)
|
|
3103
3109
|
actual = await service.readByteRange(
|
|
3104
3110
|
target,
|
|
3105
3111
|
{ offset: 0, length: expected.length + READBACK_PROBE_EXTRA_BYTES },
|
|
@@ -3190,14 +3196,25 @@ function errorCodeOf(error) {
|
|
|
3190
3196
|
*
|
|
3191
3197
|
* @throws PatchError('PATCH_UNOBSERVED')
|
|
3192
3198
|
*/
|
|
3193
|
-
export function requireObservedRevision({ displayPath, identityKey, content, byteOrderMark = false }) {
|
|
3199
|
+
export function requireObservedRevision({ displayPath, identityKey, content, byteOrderMark = false, allowBlindHint = false }) {
|
|
3194
3200
|
const ledgerKey = ledgerIdentityKey(displayPath, identityKey)
|
|
3195
3201
|
const observation = latestObservation(ledgerKey, { source: content })
|
|
3196
3202
|
const currentToken = contentToken(content, byteOrderMark)
|
|
3197
3203
|
if (observation !== null && observation.version === currentToken) return
|
|
3198
3204
|
const changed = observation !== null
|
|
3205
|
+
/**
|
|
3206
|
+
* 回执里直接给**下一次调用**,而不是只说“先读”:实测 5 天内 22 次 PATCH_UNOBSERVED,
|
|
3207
|
+
* 每次都要模型自己挑读法(多半选 full,5k+ 字符)。这里按当前内容的体量挑一个装得下预算
|
|
3208
|
+
* 的模式;整份覆写还可以走已有的显式逃生口 `allow_unobserved`,把“读一遍再写”折成
|
|
3209
|
+
* “一次带标记的写”——代价(unverified_region)写在回执里,而不是藏在 schema 描述里。
|
|
3210
|
+
*/
|
|
3211
|
+
const readHint = content.length <= 6000 ? 'full' : 'map'
|
|
3199
3212
|
throw new PatchError(
|
|
3200
|
-
`cannot patch "${displayPath}": ${changed ? 'the file changed since your last read' : 'this file has not been read in this session'} — read it first (aura_basetools_read), then retry${changed ? ' against the current revision' : ''} — nothing was written
|
|
3213
|
+
`cannot patch "${displayPath}": ${changed ? 'the file changed since your last read' : 'this file has not been read in this session'} — read it first (aura_basetools_read), then retry${changed ? ' against the current revision' : ''} — nothing was written` +
|
|
3214
|
+
`\nnext: aura_basetools_read { file_path: "${displayPath}", mode: "${readHint}" } — then retry` +
|
|
3215
|
+
(allowBlindHint
|
|
3216
|
+
? `\n or pass allow_unobserved: true if this is a deliberate whole-file overwrite; the receipt will be marked unverified_region`
|
|
3217
|
+
: ''),
|
|
3201
3218
|
'PATCH_UNOBSERVED',
|
|
3202
3219
|
{
|
|
3203
3220
|
reason: changed ? 'changed-since-read' : 'never-read',
|
|
@@ -3273,6 +3290,8 @@ async function planEditToProvider(provider, request) {
|
|
|
3273
3290
|
...(request.identityKey === undefined ? {} : { identityKey: request.identityKey }),
|
|
3274
3291
|
content,
|
|
3275
3292
|
byteOrderMark: byteOrderMarks > 0,
|
|
3293
|
+
// 只有整份覆写才有显式逃生口(`allow_unobserved`);edit 的严格模式不提供它。
|
|
3294
|
+
allowBlindHint: typeof request.wholeFile === 'string',
|
|
3276
3295
|
})
|
|
3277
3296
|
}
|
|
3278
3297
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aura-basetools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Byte-exact read, edit and write tools for coding agents. Standalone CLI and MCP server; no DSH required. 面向 coding agent 的逐字节读写工具;独立 CLI / MCP,可选 DSH 适配器。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/test/doctor-test.mjs
CHANGED
|
@@ -49,6 +49,12 @@ say('=== A. 正常环境 ===')
|
|
|
49
49
|
check('A4 所有检查项通过', (report?.checks ?? []).every((c) => c.ok === true), JSON.stringify((report?.checks ?? []).filter((c) => !c.ok)))
|
|
50
50
|
check('A5 MCP 握手列出两个工具', /aura_basetools_read/.test(JSON.stringify(report?.checks)) && /aura_basetools_edit/.test(JSON.stringify(report?.checks)))
|
|
51
51
|
check('A6 缓存目录真的被创建', fs.existsSync(cache), cache)
|
|
52
|
+
// strict 是“提示”而不是“检查项”:工具侧开不了它(那是 provider 配置),但装完就应该看得到怎么开。
|
|
53
|
+
check(
|
|
54
|
+
'A7 doctor 给出 strict 启用提示',
|
|
55
|
+
report?.strict?.compatible === true && typeof report?.strict?.deepseek === 'string' && typeof report?.strict?.convert === 'string',
|
|
56
|
+
JSON.stringify(report?.strict).slice(0, 140),
|
|
57
|
+
)
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
say('')
|