aura-basetools 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +26 -2
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/bin/aura-mcp.mjs +424 -0
- package/bin/aura.mjs +396 -0
- package/lib/fs-provider.mjs +355 -0
- package/lib/observation-store.mjs +236 -0
- package/lib/ops.mjs +498 -0
- package/lib/transaction-store.mjs +350 -0
- package/lib/workspace-lock.mjs +130 -0
- package/locator-lab/anchor-core.mjs +586 -0
- package/locator-lab/args.mjs +491 -0
- package/locator-lab/handle-core.mjs +207 -0
- package/locator-lab/locator-api.mjs +921 -0
- package/locator-lab/read-core.mjs +3013 -0
- package/locator-lab/repo-map.mjs +768 -0
- package/locator-lab/struct-core.mjs +3026 -0
- package/locator-lab/write-path.mjs +3446 -0
- package/package.json +50 -4
- package/test/standalone-test.mjs +1575 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* locator-api —— 框架无关的定位服务。
|
|
3
|
+
*
|
|
4
|
+
* 这一层的存在本身就是"不要打补丁"的兑现:DSH 的 `patch` 工具是它的**适配器之一**,
|
|
5
|
+
* 不是它的宿主。定位逻辑不认识 Cordis、不认识 ctx、不认识 schema,因此换成别的 agent
|
|
6
|
+
* 框架时不需要动它一行。
|
|
7
|
+
*
|
|
8
|
+
* 设计约束:
|
|
9
|
+
* 1. **零依赖**:只 import node 内建与本目录的纯函数。任何框架都能直接 import。
|
|
10
|
+
* 2. **无状态、无 I/O**:输入源码字符串,输出行区间。文件读写归调用方。
|
|
11
|
+
* ——这一条是关键。工具层各自有各自的沙箱/观察策略/审批模型,定位层不该知道这些。
|
|
12
|
+
* 3. **同步**:索引是 CPU 活,没有理由异步。调用方要异步就自己包一层。
|
|
13
|
+
*
|
|
14
|
+
* 对任何 agent 框架,接进来只需要三件事:
|
|
15
|
+
* a. 把文件内容读成字符串(用你框架自己的 fs 能力,带它自己的沙箱/权限)
|
|
16
|
+
* b. 调 `locate(source, request)` 拿到区间或候选
|
|
17
|
+
* c. 用你框架自己的写回能力落盘(带它自己的原子性/审批)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { indexStructure, langOf, listSymbols, findReferences, regionConfidence, resolveTarget, rankSymbols, LANGS } from './struct-core.mjs'
|
|
21
|
+
import { indexFile, locateWith, normSpace } from './anchor-core.mjs'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 定位一份源码里的改动点。
|
|
25
|
+
*
|
|
26
|
+
* 四层寻址,按"模型要提供什么"从强到弱排列。每层覆盖上一层够不到的场景,
|
|
27
|
+
* 但**都不要求模型复现字节**——这是它们与 `edit` 的根本差别:
|
|
28
|
+
*
|
|
29
|
+
* 1. `target` —— 符号名。不读文本,对内容记错免疫。适合改整个函数/类/类型。
|
|
30
|
+
* 2. `lines` —— 行号区间。模型刚用 `read` 看过文件,输出就是 `<行号>: <文本>`,
|
|
31
|
+
* `grep` 也给行号——**位置是它本来就知道的信息**,我却一直只让它给内容。
|
|
32
|
+
* 把行号纳入地址空间后,符号体内的任意一行都变得可精确寻址。
|
|
33
|
+
* 3. `lineCue` —— 一个词。当模型记不住行号时,说"就在有 resolvePolicy 的那一行"。
|
|
34
|
+
* 只接受**全文件唯一**的线索;不唯一则报歧义并给出候选行号,绝不猜。
|
|
35
|
+
* 4. `anchor` —— 记得的原文。五级降级匹配,容错性最好但要求模型复现内容。
|
|
36
|
+
*
|
|
37
|
+
* 前两层是**精确**的(要么命中要么报错),后两层是**容错**的。
|
|
38
|
+
* 调用方可以传 `assert` 做内容校验,四层都适用——那才是防止"指到错的行"的那道闸。
|
|
39
|
+
* 判据是**包含且唯一**:这段文字必须在整个文件里只出现一处,且该处落在受寻址区间内
|
|
40
|
+
* (短引文例外,仍要求与区间整段相等;推导见 {@link ASSERT_RELAX_MIN_CHARS})。
|
|
41
|
+
*
|
|
42
|
+
* @param {string} source - 源码全文
|
|
43
|
+
* @param {object} request - 定位请求
|
|
44
|
+
* @param {string} [request.target] - 符号名(`name` / `kind:name` / `Outer/Inner` / `name#n`)
|
|
45
|
+
* @param {{start:number,end:number}} [request.lines] - 1-based 闭区间行号
|
|
46
|
+
* @param {string} [request.lineCue] - 一个词的线索,必须全文件唯一
|
|
47
|
+
* @param {string} [request.anchor] - 记得的原文,走五级文本匹配
|
|
48
|
+
* @param {number} [request.near] - 行号提示
|
|
49
|
+
* @param {string} [request.assert] - 内容校验(空白归一化后比较):这段文字必须在**整个文件**里唯一出现,
|
|
50
|
+
* 且该唯一出现处落在受寻址区间内(区间含有它即通过);不唯一时拒绝并点出每一处的行号与该行原文;
|
|
51
|
+
* 短于 {@link ASSERT_RELAX_MIN_CHARS} 字符时仍要求与区间整段相等
|
|
52
|
+
* @param {string} [request.filePath] - 用于判定语言;给了才能用 target / lineCue
|
|
53
|
+
* @returns {{ok: true, startLine, endLine, tier, actual} | {ok: false, code, message, candidates?, hint?}}
|
|
54
|
+
* `startLine`/`endLine` 是 1-based 闭区间;`actual` 是该区间的原文,供调用方核对或展示。
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* 引用一段原文给人/模型看 —— 见 {@link quoteForMessage}。
|
|
58
|
+
*
|
|
59
|
+
* 那一份是本仓库**唯一**的引文渲染实现(本文件与写路径 `write-path.mjs` 共用):
|
|
60
|
+
* 从前写路径照抄过一个同形的 `quoteOne`,于是"同一次渲染改动要落两处",
|
|
61
|
+
* 两处一旦不同步,同一件事就有了两份判定。
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* 把失败回执的候选行**写进 message**,并返回自含的回执对象。
|
|
65
|
+
*
|
|
66
|
+
* 为什么不能只挂 `candidates` 字段:DSH 框架对抛出的错误只取 `error.message`
|
|
67
|
+
* (`dsh-tools/lib/index.js:2495-2503`),其余字段一概不读。所以"给候选行"这件事
|
|
68
|
+
* 挂成结构化字段等于没给——**模型看不到它**。
|
|
69
|
+
*
|
|
70
|
+
* 实测:86 次 `appears on N lines` 歧义失败,回执里只有"出现在 2 行上",
|
|
71
|
+
* 没有任何行号;模型只能猜或重试。而候选行在这里**已经算出来了**。
|
|
72
|
+
*
|
|
73
|
+
* 统一走这一个助手而不是在四处各拼一遍:本项目吃过"同一决策写两处、必有一处漏改"
|
|
74
|
+
* 的亏(`rankSymbols` 的挑选逻辑、手写透传清单),而下面四条失败路径都要做同一件事。
|
|
75
|
+
*
|
|
76
|
+
* @param code - 失败码
|
|
77
|
+
* @param message - 原始说明(候选行会追加在它后面)
|
|
78
|
+
* @param candidates - 两种形状:**行候选** `{line, text?}` 与**符号候选** `{name, kind, enclosing, startLine, endLine}`
|
|
79
|
+
* @returns 自含的回执对象
|
|
80
|
+
*/
|
|
81
|
+
function failure(code, message, candidates) {
|
|
82
|
+
const raw = Array.isArray(candidates) ? candidates : []
|
|
83
|
+
/**
|
|
84
|
+
* 候选有**两种形状**,而它们走同一个格式化器。
|
|
85
|
+
*
|
|
86
|
+
* 这是个真踩过的坑(T29 顺带发现):符号候选带的是 `startLine` / `enclosing`,
|
|
87
|
+
* 而这里只读 `line` / `text`,于是 `target=` 歧义的回执印成
|
|
88
|
+
* **`candidates: line undefined, line undefined`**——
|
|
89
|
+
* 候选明明算出来了,却在回执里变成两个 `undefined`。
|
|
90
|
+
* 覆盖面不小:真实语料里 659 个符号有 **165 个(25%)** 按名寻址是歧义的,
|
|
91
|
+
* 也就是说“四分之一次的按名编辑里,模型拿到的是两个 undefined”。
|
|
92
|
+
*
|
|
93
|
+
* 统一在这里归一,不在调用方各拼一遍(上游已经因为同一个决策写两处而漏改过)。
|
|
94
|
+
*/
|
|
95
|
+
const rows = raw.map((c, i) => {
|
|
96
|
+
/**
|
|
97
|
+
* 数字候选(**行号数组**)与符号候选一样,是"算出来了却印成 undefined"的另一条来路。
|
|
98
|
+
*
|
|
99
|
+
* `anchor-core` 的四条歧义路径(verbatim / norm-space / similarity / aligned)抛的都是
|
|
100
|
+
* `candidates: found.map((i) => i + 1)` 这种 **`number[]`**——它们既不是 `null` 也不是对象,
|
|
101
|
+
* 于是下面那行原样放过,而 `renderCandidates` 只会做 `` `line ${c.line}` ``,
|
|
102
|
+
* 回执当场印成 **`candidates: line undefined, line undefined, line undefined`**。
|
|
103
|
+
* 真实语料 F0059 就是这一例:32 行引文对齐到 3 个窗口,行号三个都算出来了,
|
|
104
|
+
* 模型看到的却是三个 undefined。
|
|
105
|
+
*
|
|
106
|
+
* 判据刻意写成 `typeof c === 'number'`(而不是 `Number.isInteger`):引擎眼下只发整数,
|
|
107
|
+
* 但只要写成整数判据,任何非整数数字候选又会掉回 undefined 那一支——本单要治的正是这个。
|
|
108
|
+
* 与壳层 `aura-edit/index.js` 的 `normalizeCandidates` 同一个去向(数字 ⇒ `{line}`),
|
|
109
|
+
* 于是 API 面的 `candidates` 从此**只有一种形状**。
|
|
110
|
+
*/
|
|
111
|
+
if (typeof c === 'number') return { line: c, ordinal: i + 1 }
|
|
112
|
+
if (c === null || typeof c !== 'object') return c
|
|
113
|
+
if (typeof c.line === 'number') return { ...c, ordinal: i + 1 }
|
|
114
|
+
return {
|
|
115
|
+
...c,
|
|
116
|
+
line: typeof c.startLine === 'number' ? c.startLine : null,
|
|
117
|
+
...(c.kind === undefined ? {} : { text: `${c.kind}${c.enclosing === undefined ? '' : ` in ${c.enclosing}`}` }),
|
|
118
|
+
ordinal: i + 1,
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
const listed = renderCandidates(rows)
|
|
122
|
+
// 两种消歧写法都说:`#N` 精确、`near=<行号>` 省事。不说的话它只能盲重试。
|
|
123
|
+
const howto =
|
|
124
|
+
code === 'STRUCT_AMBIGUOUS' && rows.some((c) => c.name !== undefined)
|
|
125
|
+
? ' — retry with one of those addresses, or add near=<line> to pick the closest'
|
|
126
|
+
: ''
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
code,
|
|
130
|
+
message: `${message}${listed}${howto}`,
|
|
131
|
+
...(rows.length === 0 ? {} : { candidates: rows }),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 候选清单的渲染:`line N = <引文>` 用 `, ` 连成一行,**合计**体量封在 {@link CANDIDATE_LIST_CHARS}。
|
|
137
|
+
*
|
|
138
|
+
* 两条上限各管一件事,缺一不可:{@link CANDIDATE_LIMIT} 管**条数**,本条管**体量**。
|
|
139
|
+
* 只有条数上限时,20 条 × 每条一整段忠实原文能到五位数 —— 比宿主阈值 8192 还大,
|
|
140
|
+
* 于是"回执不许顶穿宿主阈值"这条约束在候选这一路上当场落空。
|
|
141
|
+
* 到顶就停,并把没展示的条数写在原地(`+N more`),不静默丢。
|
|
142
|
+
*
|
|
143
|
+
* 引文本身走 {@link quoteForMessage}(与其它引用点同一份渲染):
|
|
144
|
+
* 候选也是"引用原文给调用方看",在这里另写一套等于同一件事又有了两份判定。
|
|
145
|
+
*
|
|
146
|
+
* @param rows - 已归一的候选行(形状见 {@link failure})
|
|
147
|
+
* @returns 形如 `; candidates: line 12 = “…” (+3 more)`;无候选时为空串
|
|
148
|
+
*/
|
|
149
|
+
function renderCandidates(rows) {
|
|
150
|
+
const parts = []
|
|
151
|
+
let spent = 0
|
|
152
|
+
for (const c of rows) {
|
|
153
|
+
if (parts.length >= CANDIDATE_LIMIT) break
|
|
154
|
+
const at = typeof c.line === 'number' ? c.line : null
|
|
155
|
+
const entry =
|
|
156
|
+
`line ${c.line}${c.text === undefined ? '' : ` = ${quoteForMessage(c.text, at)}`}` +
|
|
157
|
+
// 符号候选额外给出**可直接用的地址**:`#N` 就是 hits 里的序号(`resolveTarget` 的语义)。
|
|
158
|
+
// 不给的话,模型只能看见“matched 2 symbols”,而不知道下一步该写什么。
|
|
159
|
+
(c.name === undefined ? '' : ` (target=${c.name}#${c.ordinal})`)
|
|
160
|
+
/**
|
|
161
|
+
* 第一条**无论多长都要放**:一条候选都不印的清单等于没给,"有候选"当场变成一句空话。
|
|
162
|
+
* 预算只从第二条起生效。
|
|
163
|
+
*/
|
|
164
|
+
if (parts.length > 0 && spent + entry.length > CANDIDATE_LIST_CHARS) break
|
|
165
|
+
parts.push(entry)
|
|
166
|
+
spent += entry.length
|
|
167
|
+
}
|
|
168
|
+
if (parts.length === 0) return ''
|
|
169
|
+
const hidden = rows.length - parts.length
|
|
170
|
+
/**
|
|
171
|
+
* 分隔符看内容:全是单行就给一行 `, ` 连到底(与改前逐字相同),
|
|
172
|
+
* 只要有一条自带忠实块(真换行),就**一条一行** —— 否则上一条的原文块会把下一条的
|
|
173
|
+
* `line N = ` 前缀吞进自己的最后一行,回抄时两条候选粘成一段(这不是格式洁癖:
|
|
174
|
+
* 段A 的 `A8` 就是按"边距可机械剥掉"去取候选原文的,粘在一起就取不出来了)。
|
|
175
|
+
*/
|
|
176
|
+
const sep = parts.some((p) => p.includes('\n')) ? '' : ', '
|
|
177
|
+
return `; candidates: ${parts.join(sep)}${hidden > 0 ? ` (+${hidden} more)` : ''}`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 失败回执里最多列几行候选。
|
|
182
|
+
*
|
|
183
|
+
* 同样是**框架剪枝阀**的派生值,不是"够用就好"的估计:错误回执也会被剪(该项 prune 不区分 `isError`),
|
|
184
|
+
* 而候选行是恢复率最高的信息(实测:锚点失败里可被符号名救回的比例很高)。
|
|
185
|
+
* 原先写 6,理由是"错误回执也占预算"——那句话对,但 6 与预算之间**没有任何换算关系**,
|
|
186
|
+
* 实测空出的预算能容下几十行(最大的目录 5490 字节,与 8192 差 2702)。
|
|
187
|
+
* 所以改成由 {@link DEFAULT_HINT_CHARS} 派生,并**如实报出截了多少**(`+N more`)。
|
|
188
|
+
*
|
|
189
|
+
* 它是**条数**上限,封不住体量:清单的体量上限是 {@link CANDIDATE_LIST_CHARS},两条各管一头。
|
|
190
|
+
*/
|
|
191
|
+
const CANDIDATE_LIMIT = 20
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 候选清单的**合计**字符预算(与 {@link CANDIDATE_LIMIT} 一起把清单夹在宿主阈值以内)。
|
|
195
|
+
*
|
|
196
|
+
* 为什么条数封不住体量(C38 实测):改前每条引文被夹在 120 字符,20 条 ≈ 2400,看着安全;
|
|
197
|
+
* 而这一单给每条配了**忠实块**(真换行 + 行号前缀,单条可到 ~1200),
|
|
198
|
+
* 20 条就是两万四 —— 宿主 `toolResultPruner.thresholdChars` 是 8192,
|
|
199
|
+
* 超了就把中间换成一行 `[... tool result middle pruned ...]`,而候选恰恰在中间。
|
|
200
|
+
* 所以条数与体量必须各有一条上限。
|
|
201
|
+
*/
|
|
202
|
+
const CANDIDATE_LIST_CHARS = 2400
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 符号目录的**缺省**字符预算。
|
|
206
|
+
*
|
|
207
|
+
* 它**只有一个依据:框架的剪枝阀**——`dsh-compaction-tool-result-pruner` 对任何超过 8192 字符的
|
|
208
|
+
* 工具结果保留 前 4096 + `[marker]` + 后 1024,中间的文本**被静默移除**(实测确认:该 prune 不区分
|
|
209
|
+
* `isError`,错误回执同样被剪)。所以上限存在的意义是:**自己先截、自己先说**,而不是让下游默默抽掉。
|
|
210
|
+
*
|
|
211
|
+
* 取 7600 = 8192 减信封余量(路径/指令/omitted 那一圈),而不是一个"够用就好"的拍值。
|
|
212
|
+
*
|
|
213
|
+
* 曾经这里是 `const HINT_LIMIT = 24`(条数),注释自称"能放下的 24 个"而**没有任何东西在量**;
|
|
214
|
+
* 后来我又改成 754(旧 24 条的字节量)。两者都是同一个错:
|
|
215
|
+
* **用输出预算去替代"有多少是有用的"这个判断**。而实测说明这个限制从未防住任何溢出:
|
|
216
|
+
* 本仓库语料里最大的目录是 `build/upgrade-test.mjs`(159 符号 / 5490 字节),离 8192 尚有 2702 字节。
|
|
217
|
+
* 也就是说:那条 24 条的限制一直在**默默少给本来装得下的信息**。
|
|
218
|
+
*
|
|
219
|
+
* 仍然保留 `budget` 入参(可选):调用方知道自己那条回执还剩多少空间时可以传更小的值。
|
|
220
|
+
*/
|
|
221
|
+
const DEFAULT_HINT_CHARS = 7600
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 引用一段原文给人/模型看 —— **两种形态,各自标明身份**:
|
|
225
|
+
*
|
|
226
|
+
* · **紧凑摘要** `“…”`(换行写成 `⏎`):一眼看出这段的形状,**明写不可回抄**;
|
|
227
|
+
* · **忠实块**(真换行 + 行号前缀):**明写这就是原文、可整段回抄进 expect**。
|
|
228
|
+
*
|
|
229
|
+
* ## 为什么非要两份(C38;实测 F0275)
|
|
230
|
+
*
|
|
231
|
+
* 改前只有摘要这一种形态,而它与原文**逐字不同**:4 行 import 被渲染成一行、用 `⏎` 连接。
|
|
232
|
+
* 调用方把这段引文整块拓进下一次调用的 `expect`,拿到的是**单行字符串**,而原文里永远没有
|
|
233
|
+
* 这样一行。实测:把 `⏎` 还原成真换行(去污)前 fail 174,去污后 173 ——
|
|
234
|
+
* **洗活的唯一一条就是它**。也就是说,错误回执里"原文是什么"那一段,
|
|
235
|
+
* 回抄回去命中不了原文,而它的全部用途恰恰就是让调用方回抄。
|
|
236
|
+
*
|
|
237
|
+
* 当时用 `⏎` 不是没理由(换行位置本身就是信息,"你这几行其实是一行"是常见误判)。
|
|
238
|
+
* 所以这一单不是删掉 `⏎`,而是**让可回抄的那一份是忠实的**:摘要原样留着(它确实更直读),
|
|
239
|
+
* 忠实块另起一份(它确实能抄)。
|
|
240
|
+
*
|
|
241
|
+
* ## 行号前缀为什么是 `N| ` 而不是读回执的 `N: `
|
|
242
|
+
*
|
|
243
|
+
* 行号用的是**读回执同一套编号**(1-based,与 `aura_read` 打印的行号、与回执开头那句
|
|
244
|
+
* `lines A-B` 同一个尺子),调用方因此能把引文与读回执逐行对上。
|
|
245
|
+
* 字形却刻意**不同形**:`N: ` 正是调用方会整块抄走的那种形状(`probes/quote-hygiene.mjs`
|
|
246
|
+
* 把"行首 `数字: `"列为一类引文污染,且"整块抄走必然失配"),同形等于引诱它连前缀一起抄。
|
|
247
|
+
* 所以换成 `|` 分隔,并在忠实块的身份句里明写"这个前缀不是文件内容"。
|
|
248
|
+
*
|
|
249
|
+
* ## 单行原文不走这条路
|
|
250
|
+
*
|
|
251
|
+
* 一份单行 `“…”` 本身就是逐字的,再套一个块只会把回执撑大,
|
|
252
|
+
* 所以单行与改前**逐字相同**(`layers-test` 有断言钉着单行回执的形状与截断说明)。
|
|
253
|
+
*
|
|
254
|
+
* 限额存在的理由与其它披露点一致:错误回执也占输出预算,不能把一整段原文无条件倒进去。
|
|
255
|
+
*
|
|
256
|
+
* @param text - 原始文本(可能多行、可能很长)
|
|
257
|
+
* @param firstLine - 这段原文首行的 1-based 行号;缺省则忠实块不印行号前缀
|
|
258
|
+
* @param limit - 一份引文的正文上限(字符);超了就截断并明写截了多少
|
|
259
|
+
* @returns 单行时形如 `“…”`;多行时 `“…” (one-line preview, NOT copyable). <身份句>\n12| 原文行`
|
|
260
|
+
*/
|
|
261
|
+
export function quoteForMessage(text, firstLine = null, limit = QUOTE_LIMIT) {
|
|
262
|
+
/**
|
|
263
|
+
* 紧凑摘要:与改前的渲染**逐字相同**(含 `(N more chars withheld)`)。
|
|
264
|
+
* 为什么要逐字保留:失败重放器按 `the content there is: “…”` 取实证原文,
|
|
265
|
+
* 形态一改,测量面就换了尺子 —— 而这条线(fail 174/173)正是本单的判据本身。
|
|
266
|
+
*/
|
|
267
|
+
const flat = text.replace(/\r\n/g, '\u23ce').replace(/[\r\n]/g, '\u23ce')
|
|
268
|
+
const preview =
|
|
269
|
+
flat.length <= limit
|
|
270
|
+
? `\u201c${flat}\u201d`
|
|
271
|
+
: `\u201c${flat.slice(0, limit)}\u2026\u201d (${flat.length - limit} more chars withheld)`
|
|
272
|
+
|
|
273
|
+
const all = text.split(/\r\n|\r|\n/)
|
|
274
|
+
if (all.length === 1) return preview
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 忠实块:真换行 + 行号前缀。两层上限都必须有 ——
|
|
278
|
+
* 只封字符时,一串空行能在 400 字符里塞进几百行;只封行数时,一行能塞进整份文件。
|
|
279
|
+
*/
|
|
280
|
+
const rows = []
|
|
281
|
+
let used = 0
|
|
282
|
+
let cut = false
|
|
283
|
+
for (const row of all) {
|
|
284
|
+
if (rows.length >= QUOTE_MAX_LINES || (rows.length > 0 && used + row.length > limit)) {
|
|
285
|
+
cut = true
|
|
286
|
+
break
|
|
287
|
+
}
|
|
288
|
+
if (row.length > limit) {
|
|
289
|
+
rows.push(`${row.slice(0, limit)}\u2026(${row.length - limit} more chars)`)
|
|
290
|
+
used = limit
|
|
291
|
+
cut = true
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
rows.push(row)
|
|
295
|
+
used += row.length + 1
|
|
296
|
+
}
|
|
297
|
+
if (rows.length < all.length) cut = true
|
|
298
|
+
|
|
299
|
+
const body = rows.map((row, i) => `${firstLine === null ? '' : `${firstLine + i}| `}${row}`).join('\n')
|
|
300
|
+
const identity = firstLine === null ? QUOTE_VERBATIM_NOTE : QUOTE_VERBATIM_NOTE_GUTTERED
|
|
301
|
+
/**
|
|
302
|
+
* 块尾**自带换行**:回执里这一块后面往往还有别的句子(`(expect is shorter than …)`、
|
|
303
|
+
* `, line 9-10 = …`、`. Add the surrounding lines as \`context\``…)。
|
|
304
|
+
* 不留这个换行,那句话就粘在最后一行原文的尾巴上,于是"块到哪一行为止"变成**猜**出来的 ——
|
|
305
|
+
* 而回抄恰恰要求边界是确定的(段A 的判据就是按 `N| ` 边距逐行取)。
|
|
306
|
+
*/
|
|
307
|
+
return (
|
|
308
|
+
`${preview} (${QUOTE_PREVIEW_NOTE}). ${identity}\n${body}\n` +
|
|
309
|
+
(cut
|
|
310
|
+
? `(only ${rows.length} of ${all.length} line(s) shown \u2014 the rest was cut to stay inside this receipt's budget, so this block cannot be copied whole)\n`
|
|
311
|
+
: '')
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 错误回执里引用的原文上限(字符)。超过就截断并明写截了多少。 */
|
|
316
|
+
const QUOTE_LIMIT = 400
|
|
317
|
+
/** 忠实块最多印几行:行数也要封顶(见 {@link quoteForMessage} 里那两层上限的理由)。 */
|
|
318
|
+
const QUOTE_MAX_LINES = 20
|
|
319
|
+
/**
|
|
320
|
+
* 紧凑摘要的身份声明:**明确写出它不可回抄**。
|
|
321
|
+
* 摘要长得像引文,不写这句,调用方就会照抄它 —— 而它恰恰是那份与原文不逐字的形态。
|
|
322
|
+
*/
|
|
323
|
+
const QUOTE_PREVIEW_NOTE = 'one-line preview, NOT copyable'
|
|
324
|
+
/**
|
|
325
|
+
* 忠实块的身份声明:**明确写出这就是原文、可整段回抄**。
|
|
326
|
+
* 行号前缀的身份也必须在这里写清:它是回执加的边距,不是文件内容(否则整块抄走必然失配)。
|
|
327
|
+
*/
|
|
328
|
+
const QUOTE_VERBATIM_NOTE_GUTTERED =
|
|
329
|
+
'What follows is that same region verbatim, byte-for-byte \u2014 copy it as-is into expect. ' +
|
|
330
|
+
'The `N| ` at the start of each line is a line number (the same numbering as the read receipt), not part of the file:'
|
|
331
|
+
const QUOTE_VERBATIM_NOTE =
|
|
332
|
+
'What follows is that same region verbatim, byte-for-byte \u2014 copy it as-is into expect:'
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 回执里 `label`(通常是文件路径)的夹子 —— 它和回执正文**抢同一份体量预算**。
|
|
336
|
+
*
|
|
337
|
+
* 为什么必须有:`label` 的长度**不由本工具决定**(调用方给什么就是什么),而它与正文写在
|
|
338
|
+
* 同一条消息里。不夹的话,一个病态长路径既能把同一份回执里的原文整段挤出去,也能把整条
|
|
339
|
+
* 回执顶穿宿主阈值(`toolResultPruner.thresholdChars` = 8192)——超了中间会被静默剪掉,
|
|
340
|
+
* 而回执里最有用的那一段恰在中间。实测:`old` 歧义那一支在 label 两万字符时交出 22479 字符。
|
|
341
|
+
*
|
|
342
|
+
* 夹过就在原地写明夹了多少字符,口径与 {@link quoteForMessage} 里那些截断说明一致:
|
|
343
|
+
* 剪了什么、剪了多少,一律自己说出来,不静默丢。
|
|
344
|
+
*
|
|
345
|
+
* 这个函数**导出**,理由与 {@link quoteForMessage} 相同:`write-path.mjs` 的几处回执
|
|
346
|
+
* (找不到 / `old` 歧义)也要夹同一个东西,各写一份等于同一件事有两份判定。
|
|
347
|
+
*
|
|
348
|
+
* @param label - 指代这份文件的说法
|
|
349
|
+
* @param room - 这一处最多能给 `label` 多少字符(**含**披露句本身)
|
|
350
|
+
* @returns 长度不超过 `max(room, 披露句长度)` 的 label
|
|
351
|
+
*/
|
|
352
|
+
export function clipReceiptLabel(label, room) {
|
|
353
|
+
if (label.length <= room) return label
|
|
354
|
+
const note = (n) => `\u2026(${n} more chars)`
|
|
355
|
+
/** 披露句自己也占地方:先按它留出位置再夹,夹完的长度加披露句**不超过** `room`。 */
|
|
356
|
+
const keep = Math.max(0, room - note(label.length).length)
|
|
357
|
+
return `${label.slice(0, keep)}${note(label.length - keep)}`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* `label` 只有这一个落点(符号跨度拒信)时不与别的正文抢预算,直接给它一份上限。
|
|
362
|
+
*
|
|
363
|
+
* 取 6000 与 `write-path.mjs` 的 `NOTFOUND_RECEIPT_CHARS` 同值、同一条依据:
|
|
364
|
+
* 6000 < 8192(宿主阈值),也 < 4096 + 1024 + 标记长度 —— 连“只留头窗”那种剪法也装得下。
|
|
365
|
+
*/
|
|
366
|
+
const RECEIPT_LABEL_CHARS = 6000
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* `assert` 走"包含"放松所需的最短引文长度(按 {@link normSpace} 归一化后的字符数)。
|
|
370
|
+
*
|
|
371
|
+
* 为什么必须有这条下限:放松把判据从"受寻址区间**就是**这段文字"降成"受寻址区间**含有**这段文字",
|
|
372
|
+
* 而后者对一段极短引文几乎不构成证据——`}`、`return`、`const` 这类片段能被大量不同的区间包含,
|
|
373
|
+
* "包含即通过"就等于把"防指到错的行"这道闸放空。蓝图 `fs-contract-v2` 的风险项点名的正是这个。
|
|
374
|
+
*
|
|
375
|
+
* 阈值不是拍的,按**风险预算**从语料里量出来:本仓库 67 个源文件、14305 条**全文件唯一**的归一化
|
|
376
|
+
* 行文本里,长度 < 12 的占 4.54%(12 正是这个分布的 5% 分位,p0.05 = 12)。取 5% 作为预算的含义是:
|
|
377
|
+
* **只有最短的那 ~5% 的行文本留在整段相等的严格判据下**,其余长度才允许"包含即通过"。
|
|
378
|
+
* 量法可复现:逐行 `normSpace` → 按内容计数 → 只留全文件唯一的那些 → 取长度分位。
|
|
379
|
+
*
|
|
380
|
+
* 短于阈值的 `assert` **不放松**:退回改前的整段相等判据(更严,不是更松),失败回执里也会明写
|
|
381
|
+
* 这条口径——否则"为什么这次严格"就成了调用方看不见的规则。
|
|
382
|
+
*/
|
|
383
|
+
export const ASSERT_RELAX_MIN_CHARS = 12
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 「调用方自己写出了坐标」的那一层 —— {@link expectVerdict} 唯一性分档的**唯一开关**(C17)。
|
|
387
|
+
*
|
|
388
|
+
* 为什么只有 `lines`:四层降级里,只有行号区间是**调用方断言的地址**,受寻址区间由它直接决定。
|
|
389
|
+
* `symbol`(符号身份)/ `lineCue`(唯一词)/ `anchor`(文本锚点)三层的区间是引擎**按内容算出来**的
|
|
390
|
+
* ——调用方给的是线索,区间是引擎猜的结果。`target` 与 `lines` 同时给时层1 取胜、坐标降为提示
|
|
391
|
+
* (写路径的 `coordinateNote` 已经把这次降级如实披露),那一档因此仍按"没给坐标"判。
|
|
392
|
+
* 判据回答的是"**这次命中的区间是不是调用方给的坐标**",不是"请求里出现过坐标没有"——两个问题不同。
|
|
393
|
+
*/
|
|
394
|
+
const COORDINATE_TIER = 'lines'
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `assert`(工具层的 `expect`)与受寻址区间的关系判定。
|
|
398
|
+
*
|
|
399
|
+
* 判据从"整段相等"改成"**包含且唯一**",两条都不是可选的:
|
|
400
|
+
* 1. **唯一**:`assert` 归一化后在**整个文件**里必须只出现一处。能被许多位置包含的引文说明不了
|
|
401
|
+
* "你指的是哪一处"——那正是改前这道闸要防的"指到错的行",放松后由唯一性接手。
|
|
402
|
+
* 不唯一时拒绝:每一处的**行号 + 那行原文**都要进回执(本函数把它们算好放在 `places`,
|
|
403
|
+
* 由调用方渲染)。DSH 框架只读 `error.message`(`dsh-tools/lib/index.js:2495-2503`),
|
|
404
|
+
* 所以位置既要挂 `candidates` 供壳层渲染,也必须落进 message 才真能被模型看见。
|
|
405
|
+
* 2. **包含**:那处唯一出现必须落在受寻址区间内——即区间**含有**这段文字。
|
|
406
|
+
* 区间比引文窄(引文跨更多行)也落在这一条上:区间没有涵盖它,判不过。
|
|
407
|
+
* 口径写死在这里,而不是"引文比区间宽就放过":那会把判定变成对称的重叠判定,
|
|
408
|
+
* 引文与区间各说各话也算通过,这道闸就空了(判据脚本段B/段F 钉的就是它)。
|
|
409
|
+
*
|
|
410
|
+
* 短于 {@link ASSERT_RELAX_MIN_CHARS} 的引文走**改前的整段相等**判据,不参与放松,
|
|
411
|
+
* 且在拒绝时明写原因。它对区间的要求只会更严,因此不存在"静默放松"。
|
|
412
|
+
*
|
|
413
|
+
* ## 按地址形态分档(C17)
|
|
414
|
+
*
|
|
415
|
+
* 唯一性不是判据的目的,而是**「这个地址是否明确」的代理判据**:C1 把谓词从"整段相等"放松成
|
|
416
|
+
* "包含"之后,由它接手改前这道闸真正防的事——"指到错的行"。既然是代理,就有**代理多余**的场合:
|
|
417
|
+
* 调用方**自己写出了精确坐标**(`tier === 'lines'`)时,受寻址区间就是地址本身,
|
|
418
|
+
* "这段文字在全文件还有没有第二处"不再提供任何关于"你指的是哪儿"的信息,却会实打实地误伤
|
|
419
|
+
* ——坐标给准、引文也确实在区间里的调用被拒。
|
|
420
|
+
*
|
|
421
|
+
* 代价是**量出来的**,不是推的:成功集重放(`probes/success-regression.mjs`;读数见
|
|
422
|
+
* `.polaris/workorders/fs-contract-v2.integrate4.success-regression.txt` 第 60-75 行)里,
|
|
423
|
+
* 旧契约下成功、新契约被拒的 Z=7 条**全部**是 `lines` 寻址 + `LOCATOR_ASSERT_MISMATCH`,
|
|
424
|
+
* 且 7 条的引文都逐字等于受寻址区间(旧契约要求整段相等)。分档后这一支一律通过,
|
|
425
|
+
* 顺带省掉 `normLines`/`join`/`indexOf` 那次全文件扫描——它本来只为"数处数"而做。
|
|
426
|
+
*
|
|
427
|
+
* 分档的**边界**,两条都不许跟着放松:
|
|
428
|
+
* 1. **区间内不含该文本时仍必须拒绝**。这是防陈旧寻址的闸门,也是这道判据存在的理由:
|
|
429
|
+
* 调用方拿旧视图给的坐标,若区间里根本没有他说的那段文字,唯一性放不放松都救不了,
|
|
430
|
+
* 必须拒。`short_mismatch` / `not_unique` / `nowhere` / `spans_wider` / `outside` 五支一字未动。
|
|
431
|
+
* 2. **没给坐标、只给线索时仍必须要求唯一**(`symbol` / `lineCue` / `anchor` 三层,
|
|
432
|
+
* 以及**不传**第 6 参的旧调用)。那里区间是引擎猜的,引文不唯一就判不出"你指的是哪一处"
|
|
433
|
+
* ——正是 C1 当初需要唯一性的场合,分档不动它。
|
|
434
|
+
*
|
|
435
|
+
* 短于 {@link ASSERT_RELAX_MIN_CHARS} 的引文**不分档**:仍判整段相等,给了坐标也一样(更严,不是更松)。
|
|
436
|
+
*
|
|
437
|
+
* 本档与 C1 的终态在同一组形状上结论相反(C1 的判据脚本 `assert-contains-test.mjs` 段C/段F 钉的
|
|
438
|
+
* 正是"坐标已给准、区间也含引文、但引文全文两处 ⇒ 拒绝"),这份冲突与实测翻转清单记在
|
|
439
|
+
* `expect-address-form-test.mjs` 的函数头与 C17 回执里——那份判据脚本不在本单的写范围内,不静默改它。
|
|
440
|
+
*
|
|
441
|
+
* ## 为什么返回「结论」而不是「回执」(C10:判据单源化)
|
|
442
|
+
*
|
|
443
|
+
* 这份判据有**两个**调用方:`locate()` 的 `assert` 闸门({@link assertVerdict})与写路径的
|
|
444
|
+
* `expect` 闸门(`write-path.mjs` 的 hunk 路径)。改前它们各写了一份谓词——一份"包含且唯一"、
|
|
445
|
+
* 一份"整段相等"——于是**同一个 `expect` 在两条路径上判出两种结果**:片段引文在 `locate()` 里
|
|
446
|
+
* 通过,经 `planHunks` 的三种 `lines` 写法全被拒(实测读数见 `expect-single-source-test.mjs`)。
|
|
447
|
+
* 所以本函数只回答"判据的结论是什么",把回执形状(码与文案)留给调用方:**一份判据,两处引用**。
|
|
448
|
+
*
|
|
449
|
+
* @param lines - 已切好的行数组(与 `lines=` 用的是同一份,CRLF 已处理)
|
|
450
|
+
* @param startLine - 受寻址区间起点(1-based,用于重叠判定与"引文比区间宽"的披露)
|
|
451
|
+
* @param endLine - 受寻址区间终点(1-based)
|
|
452
|
+
* @param actual - 受寻址区间原文
|
|
453
|
+
* @param expectText - 调用方给的内容断言
|
|
454
|
+
* @param tier - 可选:这次命中是哪一层给的(`locate()` 的 `tier`)。`'lines'` = 调用方给的精确坐标
|
|
455
|
+
* 直接决定了区间 ⇒ 唯一性免掉;其余取值与**不传**同义 ⇒ 唯一性照旧(旧调用不必改一个字)。
|
|
456
|
+
* @returns {null} 通过;否则 `{kind, want, have, places, spans, regionLines, at?, overlaps?}`——
|
|
457
|
+
* `kind` ∈ `short_mismatch`(短引文不放松,且与区间不整段相等)/ `not_unique`(全文多处)/
|
|
458
|
+
* `nowhere`(全文没有)/ `spans_wider`(引文比区间宽)/ `outside`(唯一出现处不在区间内);
|
|
459
|
+
* `places` 是候选清单的**唯一形状** `[{line, endLine, text}]`(`text` 为该处首行原文,不 trim)。
|
|
460
|
+
*/
|
|
461
|
+
export function expectVerdict(lines, startLine, endLine, actual, expectText, tier = null) {
|
|
462
|
+
/**
|
|
463
|
+
* 引文首尾的空行是**构造伪影**,不是内容:受寻址区间的 `actual` 是 `lines.slice(a-1,b).join('\n')`,
|
|
464
|
+
* 按构造**永远不带**首尾换行。于是调用方写 `expect: "<整行>\n<整行>\n"`(把行尾一并引上)时,
|
|
465
|
+
* 无论它多正确都不可能被包含——一个系统性的假阴性(实测:CRLF 文件里引两整行必被拒)。
|
|
466
|
+
* 这里把首尾换行去掉再判,两边就同一尺度了。只剪换行、不剪空格:行内与行首空格由
|
|
467
|
+
* {@link normSpace} 折叠,那部分语义不变。
|
|
468
|
+
* 剪完可能为空串(引文只有空行),此时长度必小于阈值,会走下面「整段相等」那一支,
|
|
469
|
+
* 空针不会进全文扫描。
|
|
470
|
+
*/
|
|
471
|
+
const want = normSpace(expectText).replace(/^\n+/, '').replace(/\n+$/, '')
|
|
472
|
+
const have = normSpace(actual)
|
|
473
|
+
const spans = want.split('\n').length
|
|
474
|
+
const regionLines = endLine - startLine + 1
|
|
475
|
+
|
|
476
|
+
// ── 短引文:不放松,仍旧判"整段相等"(改前的谓词原样保留;有坐标也一样)
|
|
477
|
+
if (want.length < ASSERT_RELAX_MIN_CHARS) {
|
|
478
|
+
return have === want ? null : { kind: 'short_mismatch', want, have, places: [], spans, regionLines }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* ── 有坐标:区间本身就是地址 ⇒ 唯一性这一档免掉(C17;理由与边界见函数头)
|
|
483
|
+
*
|
|
484
|
+
* 这一支放在全文件扫描**之前**:通过就是通过,没有回执要渲染,`places` 用不上,
|
|
485
|
+
* 于是连那次全文件扫描也一并省了(带 expect 的语料里凡是坐标已给准的都走这一支)。
|
|
486
|
+
* 让掉的只有唯一性这一条 —— `have.includes(want)`(区间确实含有这段文字)一字不让:
|
|
487
|
+
* 少了它,这道闸就只剩"文件里随便哪儿有这段字",防陈旧寻址的职责当场落空。
|
|
488
|
+
*/
|
|
489
|
+
if (tier === COORDINATE_TIER && have.includes(want)) return null
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* 全文件出现位置:与判据**同一套归一化**({@link normSpace} 逐行折叠、保留换行),
|
|
493
|
+
* 而且用的是 `lines` 那一份(不是重新 split 全文)——CRLF 文件下两处若各自按自己的方式断行,
|
|
494
|
+
* 同一个"第 7 行"会指向不同字节,这正是本项目已经栽过一次的那类不一致。
|
|
495
|
+
* 归一化保留行结构,因此命中偏移能一一映射回行号。
|
|
496
|
+
*/
|
|
497
|
+
const normLines = lines.map((line) => normSpace(line))
|
|
498
|
+
const hay = normLines.join('\n')
|
|
499
|
+
const offsets = []
|
|
500
|
+
for (let i = 0, off = 0; i < normLines.length; i += 1) {
|
|
501
|
+
offsets.push(off)
|
|
502
|
+
off += normLines[i].length + 1
|
|
503
|
+
}
|
|
504
|
+
const lineAt = (offset) => {
|
|
505
|
+
let i = offsets.length - 1
|
|
506
|
+
while (i > 0 && offsets[i] > offset) i -= 1
|
|
507
|
+
return i + 1
|
|
508
|
+
}
|
|
509
|
+
const places = []
|
|
510
|
+
const seen = new Set()
|
|
511
|
+
for (let p = hay.indexOf(want); p !== -1; p = hay.indexOf(want, p + 1)) {
|
|
512
|
+
const first = lineAt(p)
|
|
513
|
+
const last = lineAt(p + want.length - 1)
|
|
514
|
+
const key = `${first}-${last}`
|
|
515
|
+
if (seen.has(key)) continue
|
|
516
|
+
seen.add(key)
|
|
517
|
+
// 原文(不 trim):缩进本身就是模型用来对照回执的信息
|
|
518
|
+
places.push({ line: first, endLine: last, text: lines[first - 1] })
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── 不唯一:多处都能对上 ⇒ 这段引文说明不了"你指的是哪一处"(唯一性是放松的前提)
|
|
522
|
+
if (places.length > 1) return { kind: 'not_unique', want, have, places, spans, regionLines }
|
|
523
|
+
|
|
524
|
+
// ── 全文没有:比"对不上"更可执行的说法
|
|
525
|
+
if (places.length === 0) return { kind: 'nowhere', want, have, places: [], spans, regionLines }
|
|
526
|
+
|
|
527
|
+
// ── 唯一且被区间包含:通过
|
|
528
|
+
if (have.includes(want)) return null
|
|
529
|
+
|
|
530
|
+
const at = places[0]
|
|
531
|
+
/**
|
|
532
|
+
* 相对位置必须说准:真的不重叠才能说"在区间外"。引文与区间**部分重叠**时(区间 4-5、引文 5-6),
|
|
533
|
+
* 说"在区间外"会与调用方看到的行号自相矛盾——第 5 行明明就在区间里。
|
|
534
|
+
*/
|
|
535
|
+
const overlaps = at.line <= endLine && at.endLine >= startLine
|
|
536
|
+
return {
|
|
537
|
+
kind: spans > regionLines ? 'spans_wider' : 'outside',
|
|
538
|
+
want,
|
|
539
|
+
have,
|
|
540
|
+
places,
|
|
541
|
+
spans,
|
|
542
|
+
regionLines,
|
|
543
|
+
at,
|
|
544
|
+
overlaps,
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* `locate()` 的失败回执:读 {@link expectVerdict} 的结论,渲染成既有的回执形状。
|
|
550
|
+
*
|
|
551
|
+
* 消息在 C10 前后**逐字不变**(C1 的判据脚本按子串钉着它们:`spans 3 lines`、`widen lines=`、
|
|
552
|
+
* `line 12 =`、`nowhere in this file`、`shorter than 12 chars`、`only partly overlaps` …),
|
|
553
|
+
* 变的只有判据的来源:从"这里现算一份"改成"读那一份结论"。
|
|
554
|
+
*
|
|
555
|
+
* 走到最后那一支时恰好一处(0 处与多处都在 {@link expectVerdict} 里分了支),所以正文可以直接说
|
|
556
|
+
* "唯一出现处"——不写 `places.length === 1 ? ... : ...` 那种永远取同一支的三元
|
|
557
|
+
* (那正是本项目的"死字段"形态)。
|
|
558
|
+
*
|
|
559
|
+
* @returns {null} 通过;否则 `{ok:false, code, message, actual, candidates?}`
|
|
560
|
+
*/
|
|
561
|
+
function assertVerdict(lines, startLine, endLine, tier, actual, assertText) {
|
|
562
|
+
const bad = expectVerdict(lines, startLine, endLine, actual, assertText, tier)
|
|
563
|
+
if (bad === null) return null
|
|
564
|
+
const where = `lines ${startLine}-${endLine} via ${tier}`
|
|
565
|
+
const there = `the content there is: ${quoteForMessage(actual, startLine)}`
|
|
566
|
+
|
|
567
|
+
// ── 短引文不放松,且与区间不整段相等
|
|
568
|
+
if (bad.kind === 'short_mismatch') {
|
|
569
|
+
return {
|
|
570
|
+
ok: false,
|
|
571
|
+
code: 'LOCATOR_ASSERT_MISMATCH',
|
|
572
|
+
message:
|
|
573
|
+
`${where} do not match assert — ${there} ` +
|
|
574
|
+
`(expect is shorter than ${ASSERT_RELAX_MIN_CHARS} chars, so it must equal the addressed region exactly)`,
|
|
575
|
+
actual,
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── 不唯一:拒绝,并点出每一处(行号 + 那行原文),要求补上下文
|
|
580
|
+
if (bad.kind === 'not_unique') {
|
|
581
|
+
return {
|
|
582
|
+
...failure(
|
|
583
|
+
'LOCATOR_ASSERT_MISMATCH',
|
|
584
|
+
`${where}: expect is not unique in this file — it appears on ${bad.places.length} places, so it cannot ` +
|
|
585
|
+
`say WHICH one you mean; quote more of the surrounding text (or address it with target= / line_cue=) ` +
|
|
586
|
+
`so that it is unique — ${there}`,
|
|
587
|
+
bad.places,
|
|
588
|
+
),
|
|
589
|
+
actual,
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ── 全文没有
|
|
594
|
+
if (bad.kind === 'nowhere') {
|
|
595
|
+
return {
|
|
596
|
+
ok: false,
|
|
597
|
+
code: 'LOCATOR_ASSERT_MISMATCH',
|
|
598
|
+
message: `${where} do not contain expect — it appears nowhere in this file (whole-file scan, whitespace-normalized) — ${there}`,
|
|
599
|
+
actual,
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── 引文比区间宽:明写这条口径,并给出两条可执行的修正方向(不猜、也不"大概齐"放过)
|
|
604
|
+
if (bad.kind === 'spans_wider') {
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
code: 'LOCATOR_ASSERT_MISMATCH',
|
|
608
|
+
message:
|
|
609
|
+
`${where} do not contain expect — expect spans ${bad.spans} lines but the addressed region is ` +
|
|
610
|
+
`${bad.regionLines} line(s) (lines ${bad.at.line}-${bad.at.endLine} hold it); widen lines= to cover the whole ` +
|
|
611
|
+
`quote, or trim expect down to the addressed text — ${there}`,
|
|
612
|
+
actual,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── 唯一出现处不落在区间内(outside;部分重叠时也不能说"在区间外")
|
|
617
|
+
const at = bad.at
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
code: 'LOCATOR_ASSERT_MISMATCH',
|
|
621
|
+
message:
|
|
622
|
+
`${where} do not contain expect — its only occurrence in this file is ` +
|
|
623
|
+
`${at.line === at.endLine ? `line ${at.line}` : `lines ${at.line}-${at.endLine}`}, which ` +
|
|
624
|
+
(bad.overlaps
|
|
625
|
+
? `only partly overlaps the addressed region (the quote has to fit entirely inside it)`
|
|
626
|
+
: `is outside the addressed region`) +
|
|
627
|
+
` — ${there}`,
|
|
628
|
+
actual,
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* 定位一个区间。四层降级:符号身份 → 行号 → 唯一词 → 文本锚点。
|
|
634
|
+
*
|
|
635
|
+
* @param source - 文件全文
|
|
636
|
+
* @param request - `target` / `lines` / `lineCue` / `anchor` 四选一,外加 `near` 与 `assert`
|
|
637
|
+
* @returns 命中时 `{ok: true, startLine, endLine, tier, actual}`;未命中时 `{ok: false, code, message, ...}`。
|
|
638
|
+
* `actual` 是该区间的原文,供调用方核对或展示。
|
|
639
|
+
*/
|
|
640
|
+
export function locate(source, request = {}) {
|
|
641
|
+
const lines = source.split(/\r?\n/)
|
|
642
|
+
const slice = (start, end) => lines.slice(start - 1, end).join('\n')
|
|
643
|
+
const filePath = request.filePath ?? null
|
|
644
|
+
const lang = filePath === null ? null : langOf(filePath)
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* @param byNear - 这次命中是否靠 `near` 从歧义里收敛出来的(见 anchor-core 的四个产出点)。
|
|
648
|
+
* 必须透传:工具回执的 `by near` 标记、以及由此给出的"下次直接用确定地址"建议都靠它。
|
|
649
|
+
* 此前本函数只回 `{startLine, endLine, tier, actual}`,把引擎算好的 `byNear` 丢在中间——
|
|
650
|
+
* 于是 `aura_edit` 渲染的 `by near` **恒为 false**(字段被声明必填、被渲染、却永远不出现),
|
|
651
|
+
* 降级命中对模型完全不可见。这是本项目最典型的"双义字段/死字段"形态。
|
|
652
|
+
*/
|
|
653
|
+
const done = (startLine, endLine, tier, byNear = false) => {
|
|
654
|
+
if (request.assert !== undefined && request.assert !== null) {
|
|
655
|
+
const actual = slice(startLine, endLine)
|
|
656
|
+
/**
|
|
657
|
+
* 把**实际内容**写进 message,而不是只报"不符"。
|
|
658
|
+
*
|
|
659
|
+
* 实测依据(11k 条真实调用):`expect` 不符 388 条,而**回执里贴出实际内容的 0 条**,
|
|
660
|
+
* 于是模型只能盲重试——161 组"参数完全相同的重复失败",最高一组撞 17 次,
|
|
661
|
+
* 其中 202 次多余重试属于这一类。它拿到"不符"三个字,却拿不到可用于修正的信息。
|
|
662
|
+
*
|
|
663
|
+
* 为什么必须写进 **message** 而不是挂成结构化字段:DSH 框架对抛出的错误
|
|
664
|
+
* 只取 `error.message`(`dsh-tools/lib/index.js:2495-2503` 的 `errorMessage`),
|
|
665
|
+
* 其余字段(`candidates` / `minimal_next` 等)**框架一律不读**。
|
|
666
|
+
* 所以对"让模型看见"而言,message 是唯一有效的通道。
|
|
667
|
+
*
|
|
668
|
+
* 引用方式见 {@link quoteForMessage}:紧凑摘要一行(`⏎` 标出换行)**明写不可回抄**,
|
|
669
|
+
* 忠实块(真换行 + 行号前缀)**明写可整段回抄进 expect**;两份都截断到 {@link QUOTE_LIMIT}
|
|
670
|
+
* 并明写省略了多少字符(改前只有摘要那一种形态,而它与原文不逐字 —— 实测 F0275)。
|
|
671
|
+
*
|
|
672
|
+
* 判据本体在 {@link assertVerdict}:**包含且唯一**(短引文例外,仍要求整段相等)。
|
|
673
|
+
* 这里曾经是 `normSpace(actual) !== normSpace(request.assert)`——"整段相等",
|
|
674
|
+
* 于是模型只要把 `expect` 写成区间里的一段(真实调用里最常见的写法:它只记得住那一行),
|
|
675
|
+
* 就必被拒。改后包含即通过,而"不唯一时拒绝"把改前真正防的那件事(指到错的行)接了下来。
|
|
676
|
+
* 上面这五条对新回执同样成立:每一条失败消息都仍然自带区间原文。
|
|
677
|
+
*/
|
|
678
|
+
const bad = assertVerdict(lines, startLine, endLine, tier, actual, request.assert)
|
|
679
|
+
if (bad !== null) return bad
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
ok: true,
|
|
683
|
+
startLine,
|
|
684
|
+
endLine,
|
|
685
|
+
tier,
|
|
686
|
+
...(byNear ? { byNear: true } : {}),
|
|
687
|
+
actual: slice(startLine, endLine),
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── 第 1 层:符号身份。不读文本,因此对内容记错免疫。
|
|
692
|
+
if (typeof request.target === 'string' && request.target.trim().length > 0) {
|
|
693
|
+
if (lang === null) {
|
|
694
|
+
return { ok: false, code: 'LOCATOR_NO_LANGUAGE', message: 'target= needs a recognized file extension' }
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
const hit = resolveTarget(indexStructure(source, lang), request.target, request.near ?? null)
|
|
698
|
+
/**
|
|
699
|
+
* 跨度覆盖不了符号体时必须**拒绝**,不能退化成"只有签名行"。
|
|
700
|
+
*
|
|
701
|
+
* 这是本工具最贵的一个静默失效:`target=<函数名>` 只换掉签名行、旧函数体原样留下,
|
|
702
|
+
* 而 `python -m py_compile` 一样返回 0——没有任何错误信号。拒绝会让调用方立刻看见
|
|
703
|
+
* 失败,并把恢复路径(`lines=` / `line_cue=`)写进消息里。
|
|
704
|
+
*/
|
|
705
|
+
if (hit.spanComplete === false) {
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
code: 'LOCATOR_SPAN_INCOMPLETE',
|
|
709
|
+
message: `cannot address "${hit.name}" by target in ${clipReceiptLabel(filePath, RECEIPT_LABEL_CHARS)}: the span does not cover the body — ${hit.spanNote}; address it with lines= or line_cue= instead`,
|
|
710
|
+
hint: directoryHint(source, lang),
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return done(hit.startLine, hit.endLine, 'symbol')
|
|
714
|
+
} catch (error) {
|
|
715
|
+
return {
|
|
716
|
+
...failure(error.code ?? 'LOCATOR_FAILED', error.message, error.candidates),
|
|
717
|
+
hint: directoryHint(source, lang),
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ── 第 2 层:行号区间。模型从 read / grep 的输出里直接抄,不需要回忆内容。
|
|
723
|
+
if (request.lines !== undefined && request.lines !== null) {
|
|
724
|
+
const start = request.lines.start
|
|
725
|
+
const end = request.lines.end ?? request.lines.start
|
|
726
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
|
727
|
+
return { ok: false, code: 'LOCATOR_BAD_LINES', message: 'lines.start / lines.end must be integers' }
|
|
728
|
+
}
|
|
729
|
+
if (start < 1 || end < start || end > lines.length) {
|
|
730
|
+
return {
|
|
731
|
+
ok: false,
|
|
732
|
+
code: 'LOCATOR_OUT_OF_RANGE',
|
|
733
|
+
message: `lines ${start}-${end} is outside 1..${lines.length}${loneCarriageReturnNote(source)}`,
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return done(start, end, 'lines')
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ── 第 3 层:单词线索。要求全文件唯一;不唯一就报歧义,绝不猜。
|
|
740
|
+
if (typeof request.lineCue === 'string' && request.lineCue.trim().length > 0) {
|
|
741
|
+
const cue = request.lineCue.trim()
|
|
742
|
+
const hits = []
|
|
743
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
744
|
+
if (lines[i].includes(cue)) hits.push(i + 1)
|
|
745
|
+
}
|
|
746
|
+
if (hits.length === 0) {
|
|
747
|
+
return { ok: false, code: 'LOCATOR_CUE_NOT_FOUND', message: `no line contains ${JSON.stringify(cue)}` }
|
|
748
|
+
}
|
|
749
|
+
if (hits.length > 1) {
|
|
750
|
+
if (request.near !== undefined && request.near !== null) {
|
|
751
|
+
const near = request.near
|
|
752
|
+
let best = hits[0]
|
|
753
|
+
let bestD = Infinity
|
|
754
|
+
let ties = 0
|
|
755
|
+
for (const h of hits) {
|
|
756
|
+
const d = Math.abs(h - near)
|
|
757
|
+
if (d < bestD) {
|
|
758
|
+
bestD = d
|
|
759
|
+
ties = 1
|
|
760
|
+
best = h
|
|
761
|
+
} else if (d === bestD) ties += 1
|
|
762
|
+
}
|
|
763
|
+
// 靠 `near` 从多个候选里收敛到一个:这也是"按提示命中",与锚点层同义
|
|
764
|
+
if (ties === 1) return done(best, best, 'lineCue', true)
|
|
765
|
+
}
|
|
766
|
+
return failure(
|
|
767
|
+
'LOCATOR_CUE_AMBIGUOUS',
|
|
768
|
+
`${JSON.stringify(cue)} appears on ${hits.length} lines`,
|
|
769
|
+
hits.map((line) => ({ line, endLine: line, text: lines[line - 1].trim().slice(0, 80) })),
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
// 唯一命中:这正是 lineCue 设计上最常见的情形。
|
|
773
|
+
// 这一行曾在一次按行号区间的编辑里被误删,后果是**每个唯一线索都报
|
|
774
|
+
// LOCATOR_NO_REQUEST**("provide one of: target, lines, lineCue, anchor")——
|
|
775
|
+
// 提示信息与真正的原因完全无关,且看起来像调用方没给参数。
|
|
776
|
+
return done(hits[0], hits[0], 'lineCue')
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ── 第 4 层:文本锚点。容错性最好,但要求模型复现内容。
|
|
780
|
+
if (typeof request.anchor === 'string' && request.anchor.length > 0) {
|
|
781
|
+
try {
|
|
782
|
+
const loc = locateWith(indexFile(source), request.anchor, request.near ?? null)
|
|
783
|
+
return done(loc.start + 1, loc.end, loc.tier, loc.byNear === true)
|
|
784
|
+
} catch (error) {
|
|
785
|
+
return {
|
|
786
|
+
...failure(error.code ?? 'LOCATOR_FAILED', error.message, error.candidates),
|
|
787
|
+
// 锚点失败时给出符号目录——这是实测支撑的恢复路径。
|
|
788
|
+
// 注:`hint` 走的是结构化字段,只有把它拼进 message 的调用方(如 aura_edit
|
|
789
|
+
// 的 symbolHint)才真能让模型看到;aura_read 直接透传 message,所以真正
|
|
790
|
+
// 抵达模型的是上面那条 message。
|
|
791
|
+
...(lang === null ? {} : { hint: directoryHint(source, lang) }),
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return { ok: false, code: 'LOCATOR_NO_REQUEST', message: 'provide one of: target, lines, lineCue, anchor' }
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* 单 `\r`(老 Mac 行尾)文件的拒绝说明:这类文件在本工具里**整份算一行**。
|
|
801
|
+
*
|
|
802
|
+
* 为什么不干脆按 `\r` 也断行:行号是**跨层共享**的判据——符号表那一层(`struct-core` 的
|
|
803
|
+
* `lineIndexFor`)只按 `\n` 数行,而它不在本组件的改动范围内。若只有这里按 `\r` 断行,
|
|
804
|
+
* 同一个文件的“第 1 行”在两个层里会指向不同字节,`target=` 算出来的区间就会**静默**指错地方
|
|
805
|
+
* (比现在“拒绝”坏得多)。因此结论是:不支持单 `\r` 行尾,但拒绝信息必须点名原因,
|
|
806
|
+
* 并给出真能用的替代:整份替换 `lines=[1,1]`,或 `line_cue` / `anchor`。
|
|
807
|
+
*
|
|
808
|
+
* @param source - 文件全文
|
|
809
|
+
* @returns 追加到错误消息后的说明;不含单 `\r` 时为空串
|
|
810
|
+
*/
|
|
811
|
+
function loneCarriageReturnNote(source) {
|
|
812
|
+
if (!/\r(?!\n)/.test(source)) return ''
|
|
813
|
+
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]'
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* 符号目录:失败时给调用方一条可执行的恢复路径。
|
|
818
|
+
*
|
|
819
|
+
* **排序依据与地图共用 `rankSymbols`**,这是必须的:先前这里用的是 `listSymbols().slice(0, 24)`,
|
|
820
|
+
* 即**声明顺序**——而实测声明顺序与重要性零相关(前 24 个里 49% 是函数内局部量)。
|
|
821
|
+
* 于是"恢复提示"给模型的恰恰是最没用的那些名字。我修了地图却漏了这里,
|
|
822
|
+
* 原因就是同一个选择逻辑存在两份实现。现在只有一份。
|
|
823
|
+
*
|
|
824
|
+
* 深度上按预算收:目录也可能很长,超出内容预算的部分不入内,并如实报告总数。
|
|
825
|
+
*
|
|
826
|
+
* 收录条数**按字符预算填充**,不是一个拍死的条数上限。
|
|
827
|
+
*
|
|
828
|
+
* 先前这里写的是 `const HINT_LIMIT = 24`,注释自称"能放下的 24 个"——而**没有任何东西在量**:
|
|
829
|
+
* 它既不是算出来的,也不随调用方的信封预算伸缩。后果是预算宽的宿主(错误消息很短、
|
|
830
|
+
* 下游剪枝阈值很高)本来能容下 60 个候选、却只拿到 24 个,而穷尽的候选**正是恢复率最高的手段**
|
|
831
|
+
* (实测:锚点失败里可被符号名救回的比例很高)。
|
|
832
|
+
*
|
|
833
|
+
* 现在:`budget` 是可选入参(字符),按序填充到装不下为止,并把"被截了多少"如实报出。
|
|
834
|
+
* 缺省值保留 `DEFAULT_HINT_CHARS`,因此**所有现有调用方行为不变**——这是"可选但更强"。
|
|
835
|
+
*
|
|
836
|
+
* @param source - 文件全文
|
|
837
|
+
* @param lang - 语言配置
|
|
838
|
+
* @param budget - 可选的字符预算(缺省 `DEFAULT_HINT_CHARS`)
|
|
839
|
+
* @returns {{symbols, total, shown, truncated, instruction}|null} 无符号时返回 null
|
|
840
|
+
*/
|
|
841
|
+
export function directoryHint(source, lang, budget = DEFAULT_HINT_CHARS) {
|
|
842
|
+
const lines = source.split(/\r?\n/)
|
|
843
|
+
const ranked = rankSymbols(indexStructure(source, lang), lines).filter((x) => x.imp > 0)
|
|
844
|
+
if (ranked.length === 0) return null
|
|
845
|
+
// 按预算填充:每行形如 `function name (lines a-b) ★`,按**实际渲染长度**累加,
|
|
846
|
+
// 不估算字符宽度(估算会系统性偏差,本项目已经在信封预算上栽过一次)。
|
|
847
|
+
const chars = Number.isFinite(budget) && budget > 0 ? budget : DEFAULT_HINT_CHARS
|
|
848
|
+
/** 渲染一行(与消费方看到的形状一致,含缩进与换行)——预算就按它的真实长度算。 */
|
|
849
|
+
const renderRow = (s, exported) => ` ${s.kind} ${s.name} (lines ${s.startLine}-${s.endLine})${exported ? ' ★' : ''}`
|
|
850
|
+
const picked = []
|
|
851
|
+
let used = 0
|
|
852
|
+
for (const { s, exported } of ranked) {
|
|
853
|
+
// 不再估算行宽:估算值是拍的,而拍错的方向很贵——
|
|
854
|
+
// 拍大 → 默认回执胀(增加使用代价);拍小 → 比旧的写死 24 条还少(能力回退)。
|
|
855
|
+
// 直接建行、量它的真实长度,两个方向都不会错。
|
|
856
|
+
const cost = renderRow(s, exported).length + 1
|
|
857
|
+
if (used + cost > chars && picked.length > 0) break
|
|
858
|
+
used += cost
|
|
859
|
+
picked.push({ s, exported })
|
|
860
|
+
}
|
|
861
|
+
const symbols = picked.map(({ s, exported }) => ({
|
|
862
|
+
name: s.name,
|
|
863
|
+
kind: s.kind,
|
|
864
|
+
startLine: s.startLine,
|
|
865
|
+
endLine: s.endLine,
|
|
866
|
+
exported,
|
|
867
|
+
// 建议里必须带上"这个符号能不能用 target="——否则"重试时改用 target=<name>"
|
|
868
|
+
// 会把调用方推回同一个结构化拒绝,形成死循环式的重试。
|
|
869
|
+
spanComplete: s.spanComplete !== false,
|
|
870
|
+
}))
|
|
871
|
+
const incomplete = symbols.filter((s) => !s.spanComplete).length
|
|
872
|
+
/**
|
|
873
|
+
* 被预算截掉多少,必须如实报出。
|
|
874
|
+
*
|
|
875
|
+
* 先前 `total` 算了却**无人消费**(`aura-edit` 的 `symbolHint` 自己重渲染行、丢掉 `total`),
|
|
876
|
+
* 于是模型看到 24 条就以为"这文件只有 24 个符号"——把"被截"伪装成了"就这些"。
|
|
877
|
+
* 现在调用方可以把它写进回执,也可以拿个更大的 `budget` 再要一次。
|
|
878
|
+
*/
|
|
879
|
+
return {
|
|
880
|
+
symbols,
|
|
881
|
+
total: ranked.length,
|
|
882
|
+
shown: symbols.length,
|
|
883
|
+
truncated: ranked.length - symbols.length,
|
|
884
|
+
instruction:
|
|
885
|
+
'locate by name instead of guessing text: retry with target=<name> (also accepts kind:name, Outer/Inner) and no anchor' +
|
|
886
|
+
(incomplete === 0
|
|
887
|
+
? ''
|
|
888
|
+
: `; ${incomplete} of the symbols listed have no determinable span and reject target= — use lines= for those regions`),
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** 列出源码里的符号,供 agent 在不知道确切名字时先看目录。 */
|
|
893
|
+
export function outline(source, filePath) {
|
|
894
|
+
const lang = langOf(filePath)
|
|
895
|
+
if (lang === null) return null
|
|
896
|
+
return listSymbols(indexStructure(source, lang))
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** 某符号体内的引用位置:重命名这类需要"所有出现处"的操作。 */
|
|
900
|
+
export function references(source, filePath, name) {
|
|
901
|
+
const lang = langOf(filePath)
|
|
902
|
+
if (lang === null) return null
|
|
903
|
+
return findReferences(indexStructure(source, lang), name)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** 一个区间是否恰好是完整的语法单位。可用于给候选排序,或判断"这次改动是否自洽"。 */
|
|
907
|
+
export function spanQuality(source, filePath, startLine, endLine) {
|
|
908
|
+
const lang = langOf(filePath)
|
|
909
|
+
if (lang === null) return null
|
|
910
|
+
return regionConfidence(indexStructure(source, lang), startLine, endLine)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* 支持的语言扩展名。别的框架据此判断能不能挂结构路径。
|
|
915
|
+
*
|
|
916
|
+
* **从配置表派生,不手写清单。** 第一版这里写死了一个九项数组
|
|
917
|
+
* (`js ts jsx tsx mjs cjs py go rs`),而配置表早已长到 44 项——
|
|
918
|
+
* 于是这个导出对调用方撒了谎:它说"只支持这九种",实际远不止。
|
|
919
|
+
* 手写清单必然与配置表脱节,且脱节是静默的;派生就没有这个问题。
|
|
920
|
+
*/
|
|
921
|
+
export const SUPPORTED_EXTENSIONS = Object.keys(LANGS).sort()
|