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/README.md +223 -13
- package/bin/aura-mcp.mjs +169 -27
- package/bin/aura.mjs +257 -37
- package/lib/observation-store.mjs +150 -17
- package/lib/ops.mjs +183 -19
- package/lib/transaction-store.mjs +1 -1
- package/lib/workspace-lock.mjs +25 -4
- package/locator-lab/anchor-core.mjs +1 -1
- package/locator-lab/args.mjs +103 -6
- package/locator-lab/locator-api.mjs +401 -22
- package/locator-lab/read-core.mjs +634 -563
- package/locator-lab/repo-map.mjs +1 -1
- package/locator-lab/struct-core.mjs +119 -38
- package/locator-lab/write-path.mjs +663 -112
- package/package.json +8 -8
- package/test/argument-surface-test.mjs +119 -0
- package/test/doctor-test.mjs +73 -0
- package/test/observation-concurrency-test.mjs +90 -0
- package/test/standalone-test.mjs +202 -128
- package/test/write-test.mjs +294 -0
|
@@ -28,28 +28,28 @@ import { indexStructure, langOf, listSymbols, rankSymbols, resolveTarget } from
|
|
|
28
28
|
* `handle-core.mjs` 是叶子(导入表为空),因此这条 import **不构成环**——
|
|
29
29
|
* 否则 `write-path → read-core → handle-core → read-core` 会让模块求值顺序依赖入口。
|
|
30
30
|
*/
|
|
31
|
-
import { HANDLE_MAX_PER_RECEIPT, fnv1a32, handleCharsFor, mintHandle } from './handle-core.mjs'
|
|
32
|
-
/**
|
|
33
|
-
* 参数收敛层(叶子模块,导入表为空 ⇒ 这条 import 也不构成环)。
|
|
34
|
-
*
|
|
35
|
-
* 多目标读({@link readManyWith})**必须**用与 `readEnvelope` 同一对判定器
|
|
36
|
-
* (`gaveLineAddress` + `lineRangeOf` + `inferReadMode`)把"今天那套单目标参数"解出来,
|
|
37
|
-
* 而不是在引擎里另写一份。两份判定一旦分叉,症状是"同一个目标,单独调用读得出来、
|
|
38
|
-
* 放进 targets 里就报参数错"——而两边各自的测试都会绿(契约 §2.1 那句"同一个校验器")。
|
|
39
|
-
*/
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* 多目标形状的**契约上限**(fs-contract-v2 §2.1)不是本地判据,而是从参数面 `args.mjs` **转出**:
|
|
44
|
-
* 那个数在参数面只有一份(`assertEditTargets` / `assertReadTargets` 与两个壳共用一个上限),
|
|
45
|
-
* 读侧再写一遍就等于把同一份不变式抄成两份独立真值——将来改一处必分叉,而分叉的方向是
|
|
46
|
-
* “参数面放行了、读引擎却拒收”这类没人愿意调的对不上。
|
|
47
|
-
*
|
|
48
|
-
* 接法:上面的 `import` 建一个本地绑定供本文件自己判上限,再把这个**同一绑定**导出
|
|
49
|
-
* (不是 `export … from`:那种写法不建本地绑定,本文件自己的 `if (list.length > MT_MAX_TARGETS)`
|
|
50
|
-
* 会当场 `ReferenceError`——这一点是实跑出来的,不是推的)。取值只有一个来源:`args.mjs` 那个字面量;
|
|
51
|
-
* `const MT_MAX_TARGETS = 32` 这种第二份字面量在这里已无必要。对外导出名与取值逐字未变,`multi-read-test.mjs` 照旧。
|
|
52
|
-
*/
|
|
31
|
+
import { HANDLE_MAX_PER_RECEIPT, fnv1a32, handleCharsFor, mintHandle } from './handle-core.mjs'
|
|
32
|
+
/**
|
|
33
|
+
* 参数收敛层(叶子模块,导入表为空 ⇒ 这条 import 也不构成环)。
|
|
34
|
+
*
|
|
35
|
+
* 多目标读({@link readManyWith})**必须**用与 `readEnvelope` 同一对判定器
|
|
36
|
+
* (`gaveLineAddress` + `lineRangeOf` + `inferReadMode`)把"今天那套单目标参数"解出来,
|
|
37
|
+
* 而不是在引擎里另写一份。两份判定一旦分叉,症状是"同一个目标,单独调用读得出来、
|
|
38
|
+
* 放进 targets 里就报参数错"——而两边各自的测试都会绿(契约 §2.1 那句"同一个校验器")。
|
|
39
|
+
*/
|
|
40
|
+
import { MT_MAX_TARGETS, MT_POOL, asLinePair, normalizeReadRequest } from './args.mjs'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 多目标形状的**契约上限**(fs-contract-v2 §2.1)不是本地判据,而是从参数面 `args.mjs` **转出**:
|
|
44
|
+
* 那个数在参数面只有一份(`assertEditTargets` / `assertReadTargets` 与两个壳共用一个上限),
|
|
45
|
+
* 读侧再写一遍就等于把同一份不变式抄成两份独立真值——将来改一处必分叉,而分叉的方向是
|
|
46
|
+
* “参数面放行了、读引擎却拒收”这类没人愿意调的对不上。
|
|
47
|
+
*
|
|
48
|
+
* 接法:上面的 `import` 建一个本地绑定供本文件自己判上限,再把这个**同一绑定**导出
|
|
49
|
+
* (不是 `export … from`:那种写法不建本地绑定,本文件自己的 `if (list.length > MT_MAX_TARGETS)`
|
|
50
|
+
* 会当场 `ReferenceError`——这一点是实跑出来的,不是推的)。取值只有一个来源:`args.mjs` 那个字面量;
|
|
51
|
+
* `const MT_MAX_TARGETS = 32` 这种第二份字面量在这里已无必要。对外导出名与取值逐字未变,`multi-read-test.mjs` 照旧。
|
|
52
|
+
*/
|
|
53
53
|
export { MT_MAX_TARGETS }
|
|
54
54
|
|
|
55
55
|
/**
|
|
@@ -119,31 +119,31 @@ function handleFor(ledgerKey, lines, byteOrderMark, startLine, endLine) {
|
|
|
119
119
|
return mintHandle({ ledgerKey, regionText, byteOrderMark, startLine, endLine: to })
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
* 句柄落在 text 里时的分隔串(也是它唯一的**机器可识别标记**)。
|
|
124
|
-
*
|
|
125
|
-
* 为什么必须有个标记而不是直接 "行内容 + 句柄":`text` 是行的拼接,句柄得能**被抓出来**
|
|
126
|
-
* (`HANDLE_RE` 还需要一个左边界),而且人读时能一眼看出这不是文件里的字节。
|
|
127
|
-
* 选 ` <` … `>` 是因为:它不会出现在正常行尾,长度只有 3 字符,
|
|
128
|
-
* 而且与既有 ` ⋯ [truncated …]` 的"行尾附加说明"风格一致。
|
|
129
|
-
*/
|
|
130
|
-
const HANDLE_MARK_OPEN = ' <'
|
|
131
|
-
const HANDLE_MARK_CLOSE = '>'
|
|
132
|
-
|
|
133
|
-
/** 行尾附加说明的**全部字符数**(含两侧标记):21 字符句柄 ⇒ 24。 */
|
|
134
|
-
export function handleInlineChars(startLine, endLine) {
|
|
135
|
-
return HANDLE_MARK_OPEN.length + handleCharsFor(startLine, endLine) + HANDLE_MARK_CLOSE.length
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* 把句柄接到一行文本末尾;`handle` 为 null 时原样返回。
|
|
140
|
-
*
|
|
141
|
-
* `lines` 与 `search` 共用这一处,免得两个模式的"行尾长什么样"漂成两种。
|
|
142
|
-
*/
|
|
143
|
-
function withHandleOnLine(line, handle) {
|
|
144
|
-
return handle === null ? line : `${line}${HANDLE_MARK_OPEN}${handle}${HANDLE_MARK_CLOSE}`
|
|
145
|
-
}
|
|
146
|
-
|
|
122
|
+
/**
|
|
123
|
+
* 句柄落在 text 里时的分隔串(也是它唯一的**机器可识别标记**)。
|
|
124
|
+
*
|
|
125
|
+
* 为什么必须有个标记而不是直接 "行内容 + 句柄":`text` 是行的拼接,句柄得能**被抓出来**
|
|
126
|
+
* (`HANDLE_RE` 还需要一个左边界),而且人读时能一眼看出这不是文件里的字节。
|
|
127
|
+
* 选 ` <` … `>` 是因为:它不会出现在正常行尾,长度只有 3 字符,
|
|
128
|
+
* 而且与既有 ` ⋯ [truncated …]` 的"行尾附加说明"风格一致。
|
|
129
|
+
*/
|
|
130
|
+
const HANDLE_MARK_OPEN = ' <'
|
|
131
|
+
const HANDLE_MARK_CLOSE = '>'
|
|
132
|
+
|
|
133
|
+
/** 行尾附加说明的**全部字符数**(含两侧标记):21 字符句柄 ⇒ 24。 */
|
|
134
|
+
export function handleInlineChars(startLine, endLine) {
|
|
135
|
+
return HANDLE_MARK_OPEN.length + handleCharsFor(startLine, endLine) + HANDLE_MARK_CLOSE.length
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 把句柄接到一行文本末尾;`handle` 为 null 时原样返回。
|
|
140
|
+
*
|
|
141
|
+
* `lines` 与 `search` 共用这一处,免得两个模式的"行尾长什么样"漂成两种。
|
|
142
|
+
*/
|
|
143
|
+
function withHandleOnLine(line, handle) {
|
|
144
|
+
return handle === null ? line : `${line}${HANDLE_MARK_OPEN}${handle}${HANDLE_MARK_CLOSE}`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
147
|
/** 句柄的字符长度(契约 §3)——测试与体积核算共用这一个来源,不各写一个 21。 */
|
|
148
148
|
// 句柄长度常量与上面那个上限同源,都在 `handle-core.mjs`(见文件顶部那条 re-export)。
|
|
149
149
|
|
|
@@ -939,7 +939,7 @@ function renderChanged(base, source, lines, request, symbolsIndex = null, ledger
|
|
|
939
939
|
* 先前这里把非字符串的 `since` 当成"首读哨兵"并返回 `version: null`。那个 null 传不下去:
|
|
940
940
|
* 工具声明的 schema 里 version 是 string,框架对字符串字段做类型校验
|
|
941
941
|
* (dsh-tools/lib/index.js:495 `typeof value === "string"`),于是 null 会被判违规:
|
|
942
|
-
* tool "
|
|
942
|
+
* tool "aura_basetools_read" returned invalid output: "value.version" must be a string
|
|
943
943
|
* 更糟的是它**没意义**:回执的 next 让模型"把 version 传回来",而 null 传回来又落回同一分支——
|
|
944
944
|
* 一个永远解不开的环。现在首读也给真令牌,环就断了。
|
|
945
945
|
*/
|
|
@@ -981,14 +981,22 @@ function renderChanged(base, source, lines, request, symbolsIndex = null, ledger
|
|
|
981
981
|
}
|
|
982
982
|
}
|
|
983
983
|
if (prev === null) {
|
|
984
|
-
|
|
984
|
+
/**
|
|
985
|
+
* 缓存未命中:换进程、被淘汰,或**大文件只记了令牌、没保留全文**(`source_omitted`)。
|
|
986
|
+
* 最后一种不是“过期”,说成“no longer cached”会让调用方以为重读一次就能修好——
|
|
987
|
+
* 它即使重读,>256 KiB 的全文仍然不会进缓存。把两种事实分开说。
|
|
988
|
+
*/
|
|
989
|
+
const omitted = prevEntry?.source_omitted === true
|
|
990
|
+
const note = omitted
|
|
991
|
+
? 'that revision is in the ledger, but the file exceeds 256 KiB so its full text was not retained — cannot diff; read the regions you need'
|
|
992
|
+
: 'that version is no longer cached — cannot diff; read the regions you need'
|
|
985
993
|
return {
|
|
986
994
|
...base,
|
|
987
995
|
mode: 'changed',
|
|
988
996
|
version,
|
|
989
|
-
note
|
|
997
|
+
note,
|
|
990
998
|
next: 'mode=map then mode=lines',
|
|
991
|
-
text:
|
|
999
|
+
text: `${note} (next: mode=map then mode=lines)`,
|
|
992
1000
|
}
|
|
993
1001
|
}
|
|
994
1002
|
|
|
@@ -1098,28 +1106,28 @@ export function leadingByteOrderMarks(text) {
|
|
|
1098
1106
|
/**
|
|
1099
1107
|
* 内容指纹:用于 changed 模式的比对。变化敏感、且不依赖 mtime。
|
|
1100
1108
|
*
|
|
1101
|
-
* 带 BOM 的文件按**含 U+FEFF 的内容**算——否则只差一个 BOM 的两个文件会拿到同一个令牌,
|
|
1102
|
-
* `changed` 会说"没变"。令牌因此与落盘字节、与写路径带回的内容给出一致答案。
|
|
1103
|
-
*
|
|
1104
|
-
* **哈希本体不在这里**:它是 `handle-core.mjs` 的 {@link fnv1a32}(全仓唯一一份 FNV-1a)。
|
|
1105
|
-
* 句柄的区间摘要(`regionFingerprint`)取的是同一个哈希的前 8 位,所以"整文件令牌"与
|
|
1106
|
-
* "区间摘要"在 BOM 口径上永远一致——两边各自实现一份就可能漂开,而漂开是静默的。
|
|
1107
|
-
* 搬家的边界很窄:**只有哈希那几行**搬了,下面这个返回串的形状(`指纹:长度`)
|
|
1108
|
-
* 与 `effective` 的口径都逐字未改(否则 `changed` 与既有判据全要重算)。
|
|
1109
|
-
*
|
|
1110
|
-
* @param source - 解码后的全文
|
|
1111
|
-
* @param byteOrderMark - 这份文件是否带 UTF-8 BOM(判据只能是字节,见 {@link startsWithByteOrderMark})
|
|
1112
|
-
*/
|
|
1113
|
-
export function contentToken(source, byteOrderMark = false) {
|
|
1114
|
-
const effective = byteOrderMark ? BYTE_ORDER_MARK + source : source
|
|
1115
|
-
return `${fnv1a32(source, byteOrderMark)}:${effective.length}`
|
|
1109
|
+
* 带 BOM 的文件按**含 U+FEFF 的内容**算——否则只差一个 BOM 的两个文件会拿到同一个令牌,
|
|
1110
|
+
* `changed` 会说"没变"。令牌因此与落盘字节、与写路径带回的内容给出一致答案。
|
|
1111
|
+
*
|
|
1112
|
+
* **哈希本体不在这里**:它是 `handle-core.mjs` 的 {@link fnv1a32}(全仓唯一一份 FNV-1a)。
|
|
1113
|
+
* 句柄的区间摘要(`regionFingerprint`)取的是同一个哈希的前 8 位,所以"整文件令牌"与
|
|
1114
|
+
* "区间摘要"在 BOM 口径上永远一致——两边各自实现一份就可能漂开,而漂开是静默的。
|
|
1115
|
+
* 搬家的边界很窄:**只有哈希那几行**搬了,下面这个返回串的形状(`指纹:长度`)
|
|
1116
|
+
* 与 `effective` 的口径都逐字未改(否则 `changed` 与既有判据全要重算)。
|
|
1117
|
+
*
|
|
1118
|
+
* @param source - 解码后的全文
|
|
1119
|
+
* @param byteOrderMark - 这份文件是否带 UTF-8 BOM(判据只能是字节,见 {@link startsWithByteOrderMark})
|
|
1120
|
+
*/
|
|
1121
|
+
export function contentToken(source, byteOrderMark = false) {
|
|
1122
|
+
const effective = byteOrderMark ? BYTE_ORDER_MARK + source : source
|
|
1123
|
+
return `${fnv1a32(source, byteOrderMark)}:${effective.length}`
|
|
1116
1124
|
}
|
|
1117
1125
|
|
|
1118
1126
|
/* ── 同会话增量读 ──────────────────────────────────────────────────────────
|
|
1119
1127
|
*
|
|
1120
1128
|
* ## 它治的是什么
|
|
1121
1129
|
*
|
|
1122
|
-
* 实测:15,935 次
|
|
1130
|
+
* 实测:15,935 次 aura_basetools_read 里 **63.8%** 是同一会话内对**已读文件**的重复读,
|
|
1123
1131
|
* 那些回执合计 53.8M 字符。重复读里绝大多数行根本没变——再发一遍全文,
|
|
1124
1132
|
* 是把已经给过的东西再收一次钱。
|
|
1125
1133
|
*
|
|
@@ -1524,8 +1532,17 @@ function planIncremental(request, lines, version) {
|
|
|
1524
1532
|
: { kind: 'skip' }
|
|
1525
1533
|
}
|
|
1526
1534
|
// 身份键在手就递下去(宿主事实优先):观测账本本来就按身份键记,这里少一次"写法 vs 盘上"的对不上。
|
|
1527
|
-
const
|
|
1535
|
+
const beforeEntry = seenEntry(filePath, entry.version, request.identityKey)
|
|
1536
|
+
const before = beforeEntry === null ? null : beforeEntry.source
|
|
1528
1537
|
if (before === null) {
|
|
1538
|
+
/**
|
|
1539
|
+
* 账本说“你读过”,但没有可 diff 的全文。两种原因要分开:
|
|
1540
|
+
* · `source_omitted`:文件超过 256 KiB,**按设计**不保留全文(重读也不会改变);
|
|
1541
|
+
* · 否则:有界缓存淘汰 / 换进程(重读可以恢复)。
|
|
1542
|
+
*/
|
|
1543
|
+
if (beforeEntry?.source_omitted === true) {
|
|
1544
|
+
return { kind: 'degrade', asked, reason: `the baseline you read earlier (${entry.version}) is in the ledger, but this file exceeds 256 KiB so its full text was not retained; cannot diff — the full text follows` }
|
|
1545
|
+
}
|
|
1529
1546
|
// 账本说"你读过",缓存却说"我没有那份内容"——有界缓存淘汰或换进程都会走到这里
|
|
1530
1547
|
return { kind: 'degrade', asked, reason: `the baseline you read earlier (${entry.version}) is no longer in the bounded observation cache, so it cannot be diffed; the full text follows` }
|
|
1531
1548
|
}
|
|
@@ -1575,8 +1592,8 @@ function noChangeEnvelope(base, plan, total) {
|
|
|
1575
1592
|
* 那时调用方要的明确是增量,"为什么还是全文"必须有答案;自动路径下省字节优先,
|
|
1576
1593
|
* 沉默就是答案(全文本身已经回答了那个问题)。
|
|
1577
1594
|
*/
|
|
1578
|
-
function renderIncremental(base, lines, symbolsIndex, plan, budget, total, handleContext = null) {
|
|
1579
|
-
// `handleContext` 在这里**不被消费**:`full` 不发句柄(见 `readWith` 里的长注释)。
|
|
1595
|
+
function renderIncremental(base, lines, symbolsIndex, plan, budget, total, handleContext = null) {
|
|
1596
|
+
// `handleContext` 在这里**不被消费**:`full` 不发句柄(见 `readWith` 里的长注释)。
|
|
1580
1597
|
// 参数留着是为了让调用点不必按模式分叉——去掉它就得在调用处再判一次模式。
|
|
1581
1598
|
const full = () => renderFull(base, lines, symbolsIndex)
|
|
1582
1599
|
if (plan.kind === 'skip') return { env: full(), record: null }
|
|
@@ -1643,9 +1660,9 @@ function recordDelivery(filePath, result, version, total, bom, record) {
|
|
|
1643
1660
|
* (1 个函数时全文 156 字符、map 123、digest 180;再小一点全文就反超)。
|
|
1644
1661
|
* 把这种情况硬塞给 lines 模式是错的——lines 需要调用方给区间,而这里调用方什么都没给。
|
|
1645
1662
|
*/
|
|
1646
|
-
function renderFull(base, lines, symbolsIndex = null) {
|
|
1647
|
-
// 不发句柄(理由见 `readWith` 里 `handleContext` 那段):全文读不是编辑地址。
|
|
1648
|
-
return windowEnvelope(base, lines, 1, lines.length, 'full file', 1, null, symbolsIndex)
|
|
1663
|
+
function renderFull(base, lines, symbolsIndex = null) {
|
|
1664
|
+
// 不发句柄(理由见 `readWith` 里 `handleContext` 那段):全文读不是编辑地址。
|
|
1665
|
+
return windowEnvelope(base, lines, 1, lines.length, 'full file', 1, null, symbolsIndex)
|
|
1649
1666
|
}
|
|
1650
1667
|
|
|
1651
1668
|
/**
|
|
@@ -1659,15 +1676,29 @@ function renderFull(base, lines, symbolsIndex = null) {
|
|
|
1659
1676
|
*
|
|
1660
1677
|
* @returns {{mode: string, reason: string, sizes: object}}
|
|
1661
1678
|
*/
|
|
1662
|
-
export function suggestMode(source, filePath) {
|
|
1663
|
-
|
|
1679
|
+
export function suggestMode(source, filePath, budget = null) {
|
|
1680
|
+
/**
|
|
1681
|
+
* 宿主预算(`request.budget`)优先:它是调用方**实际会执行**的剪枝阈值,缺省才是 8192。
|
|
1682
|
+
* 内容上限必须减去两层信封开销({@link DEFAULT_ENVELOPE_ALLOWANCE}),与 `readWith` 的
|
|
1683
|
+
* `contentCeiling` 同一口径——否则 auto 会以为“装得下”,实际渲染时又被信封挤出去。
|
|
1684
|
+
*/
|
|
1685
|
+
const hostBudget = Number.isInteger(budget) && budget > 0 ? budget : DEFAULT_PRUNE_BUDGET
|
|
1686
|
+
const contentBudget = Math.max(0, hostBudget - DEFAULT_ENVELOPE_ALLOWANCE)
|
|
1687
|
+
/**
|
|
1688
|
+
* `full` 的渲染长度**不是** `source.length`:每行有 `N: ` 行号前缀,736 行的文件会多出
|
|
1689
|
+
* 约 1.6KB。旧判据拿原始字符数比预算,于是 5902 字符 / 736 行被判成“全文装得下”,
|
|
1690
|
+
* 实际渲染时剪掉 193 行,`auto` 理由还写着 “complete answer”。这里直接用 full 的真实
|
|
1691
|
+
* 渲染结果(`omitted === undefined` 才算完整)作判据。
|
|
1692
|
+
*/
|
|
1693
|
+
const fullProbe = readWith(source, { filePath, mode: 'full', budget: hostBudget, probe: true })
|
|
1694
|
+
const sizes = { full: fullProbe.text?.length ?? source.length }
|
|
1664
1695
|
const lang = filePath === null || filePath === undefined ? null : langOf(filePath)
|
|
1665
1696
|
let symbols = null
|
|
1666
1697
|
if (lang !== null) {
|
|
1667
1698
|
symbols = listSymbols(indexStructure(source, lang)).length
|
|
1668
|
-
sizes.map = readWith(source, { filePath, mode: 'map' }).text?.length ?? 0
|
|
1699
|
+
sizes.map = readWith(source, { filePath, mode: 'map', budget: hostBudget, probe: true }).text?.length ?? 0
|
|
1669
1700
|
if (sizes.map <= sizes.full) {
|
|
1670
|
-
sizes.digest = readWith(source, { filePath, mode: 'digest' }).text?.length ?? 0
|
|
1701
|
+
sizes.digest = readWith(source, { filePath, mode: 'digest', budget: hostBudget, probe: true }).text?.length ?? 0
|
|
1671
1702
|
}
|
|
1672
1703
|
}
|
|
1673
1704
|
|
|
@@ -1698,11 +1729,11 @@ export function suggestMode(source, filePath) {
|
|
|
1698
1729
|
* 为什么用预算而不是固定字符数:预算是算出来的(内容预算随信封收紧),
|
|
1699
1730
|
* 而固定字符数会随着预算变化而变成另一个隐含的错值。
|
|
1700
1731
|
*/
|
|
1701
|
-
const FULL_TEXT_FITS =
|
|
1732
|
+
const FULL_TEXT_FITS = fullProbe.ok === true && fullProbe.omitted === undefined
|
|
1702
1733
|
if (FULL_TEXT_FITS) {
|
|
1703
1734
|
return {
|
|
1704
1735
|
mode: 'full',
|
|
1705
|
-
reason: `the whole text fits the content budget (${
|
|
1736
|
+
reason: `the whole text fits the content budget (${sizes.full} ≤ ${contentBudget} chars rendered; host budget ${hostBudget}, envelope allowance ${DEFAULT_ENVELOPE_ALLOWANCE}), so it is the complete answer`,
|
|
1706
1737
|
sizes,
|
|
1707
1738
|
}
|
|
1708
1739
|
}
|
|
@@ -1730,381 +1761,391 @@ export function suggestMode(source, filePath) {
|
|
|
1730
1761
|
return n > 0 && n >= source.length * 0.01
|
|
1731
1762
|
}
|
|
1732
1763
|
const top = Object.entries(sizes).sort((a, b) => a[1] - b[1])
|
|
1733
|
-
|
|
1764
|
+
// `full` 通常不受“1% 交付”门槛限制(它的少是预算裁剪,有解释);
|
|
1765
|
+
// 但渲染长度是 0 的 full 是**空壳**,不能因为“叫 full”就被选中。
|
|
1766
|
+
const ranked = top.filter(([name]) => (name === 'full' ? sizes.full > 0 : delivered(name)))
|
|
1734
1767
|
const candidates = ranked.length > 0 ? ranked : [['full', sizes.full]]
|
|
1735
1768
|
const best = candidates[0]
|
|
1736
1769
|
const reason =
|
|
1737
|
-
best[
|
|
1738
|
-
? `
|
|
1739
|
-
:
|
|
1770
|
+
best[1] === 0
|
|
1771
|
+
? `no mode can render text at host budget ${hostBudget} (below the minimum envelope); raise the budget or pass maxChars`
|
|
1772
|
+
: best[0] === 'full'
|
|
1773
|
+
? `full text is the smallest option (${sizes.full} chars rendered)`
|
|
1774
|
+
: `${best[0]} is ${(sizes.full / best[1]).toFixed(1)}x smaller than the full text`
|
|
1740
1775
|
return { mode: best[0], reason, sizes, ...(symbols === null ? {} : { symbols }) }
|
|
1741
1776
|
}
|
|
1742
1777
|
|
|
1743
|
-
/**
|
|
1744
|
-
* 多目标读(契约 §2.1 / §4)——**与单目标并列的新增形状**,既有形状一字不改。
|
|
1745
|
-
*
|
|
1746
|
-
* 这不是 `readWith` 的变体:`readWith` 吃**已经读进来的全文**,本入口吃**目标描述**,
|
|
1747
|
-
* 读盘那一步由 provider 完成。两者共用的只有"一个目标怎么读"这一处逻辑
|
|
1748
|
-
* ({@link readManyTarget}),因此不存在"单目标一套、多目标另一套"两份判定。
|
|
1749
|
-
*
|
|
1750
|
-
* ## 为什么新入口要读盘、而单目标那条路不读(这一条是可复核的历史,不是取舍)
|
|
1751
|
-
*
|
|
1752
|
-
* 读侧的真编排入口是 `ops.mjs` 的 `readEnvelope`,它在文件分支用 `readFileBytes`(直接 fs 读)
|
|
1753
|
-
* ——**不经 `provider.readText`**。上一单把这条现状钉成了红线(`provider-calls` 那三类逐条比对
|
|
1754
|
-
* 调用序列)。所以本入口**绝不**改 `readEnvelope`:多目标走 provider 是**新路径**,单目标
|
|
1755
|
-
* 继续走它原来那条。谁要动单目标那条路,先绿的是红线套件,不是这份代码。
|
|
1756
|
-
*
|
|
1757
|
-
* 本文件**零 `node:` 依赖**(契约 §1.5):这里只做纯计算与 provider 调用,
|
|
1758
|
-
* 计时用 `Date.now()`(全局,不是内置模块)。
|
|
1759
|
-
*/
|
|
1760
|
-
|
|
1761
|
-
/**
|
|
1762
|
-
* 并发池大小(契约 §4:`MT_POOL = 8`)—— **不是本地真值**,而是参数面 `args.mjs` 那一份的转发。
|
|
1763
|
-
*
|
|
1764
|
-
* 这里原来写着 `export const MT_POOL = 8`,与写侧 `write-path.mjs` 里同一句构成**两份活真值**;
|
|
1765
|
-
* 两边恰好都是 8,于是两个套件同时绿,把缝盖住了。现在取值只有一个来源,与上面 `MT_MAX_TARGETS`
|
|
1766
|
-
* 同一条纪律、同一种接法:上面的 `import` 建本地绑定供本文件自己用(`options.pool ?? MT_POOL`),
|
|
1767
|
-
* 再用 `export { MT_POOL }` 把这个**同一绑定**转出去。**不是** `export … from`:那种写法不建本地绑定,
|
|
1768
|
-
* 本文件自己那两处会当场 `ReferenceError`——`node --check` 抓不到,只有实跑套件才炸。
|
|
1769
|
-
*
|
|
1770
|
-
* 导出名与取值逐字未变(`multi-read-test.mjs` 从这里取 `MT_POOL` 并断言在飞峰值)。
|
|
1771
|
-
*/
|
|
1772
|
-
export { MT_POOL }
|
|
1773
|
-
/** 多目标的"参数形状没成形"错误码(与 `ARGS_UNKNOWN_KEY` 同族:**抛错**,不是结果通道)。 */
|
|
1774
|
-
export const MT_BAD_TARGETS = 'MT_BAD_TARGETS'
|
|
1775
|
-
export const MT_TOO_MANY_TARGETS = 'MT_TOO_MANY_TARGETS'
|
|
1776
|
-
|
|
1777
|
-
/** 一个目标读完之后账面上算哪一类(汇总行只按这一列计数)。 */
|
|
1778
|
-
const MT_OK = 'ok'
|
|
1779
|
-
|
|
1780
|
-
/**
|
|
1781
|
-
* 汇总行:形如 `targets: 7 | ok 6 | not_found 1`。
|
|
1782
|
-
*
|
|
1783
|
-
* 计数**按结果现算**,不另维护一套账:两套账迟早会分叉,而分叉的方向必然是
|
|
1784
|
-
* "汇总说 6 条成了、逐项里只有 5 条 ok"这种谁都不信的中间态。
|
|
1785
|
-
* 没成的按 `code` 逐个列出(`not_found` 用短名,其余用原码),因此
|
|
1786
|
-
* "哪几条没成"在一行里就能读出来。
|
|
1787
|
-
*/
|
|
1788
|
-
function mtSummaryLine(entries) {
|
|
1789
|
-
const counts = new Map()
|
|
1790
|
-
for (const entry of entries) {
|
|
1791
|
-
const label = entry.outcome === MT_OK ? MT_OK : mtShortCode(entry.code)
|
|
1792
|
-
counts.set(label, (counts.get(label) ?? 0) + 1)
|
|
1793
|
-
}
|
|
1794
|
-
// `ok` 永远排第一(它是这条行的主语),其余按名字排序 ⇒ 同一份输入恒定给同一行。
|
|
1795
|
-
const names = [...counts.keys()].sort((a, b) => (a === MT_OK ? -1 : b === MT_OK ? 1 : a < b ? -1 : a > b ? 1 : 0))
|
|
1796
|
-
return [`targets: ${entries.length}`, ...names.map((name) => `${name} ${counts.get(name)}`)].join(' | ')
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
/** 汇总行里的短码:只给 `not_found` 起短名,其余原样——短名多了就成了第二种语言。 */
|
|
1800
|
-
const mtShortCode = (code) => (code === 'FS_NOT_FOUND' ? 'not_found' : String(code ?? 'error'))
|
|
1801
|
-
|
|
1802
|
-
/**
|
|
1803
|
-
* 读一个 target:**唯一的**"一个目标 = 一次读"实现。
|
|
1804
|
-
*
|
|
1805
|
-
* N===1 直调它、N>1 池里调它——同一条代码路径,所以"多目标那条路会不会比单目标多跑一次"
|
|
1806
|
-
* 这个问题在结构上就只有一种答案。
|
|
1807
|
-
*
|
|
1808
|
-
* 顺序(也就是 provider 调用序列)是刻意钉死的:`resolve` → `stat` → `readText` →(可选)`readByteRange`。
|
|
1809
|
-
* 它刻意与 `readEnvelope` 文件分支的 `resolve` → `stat` → 读 同形:
|
|
1810
|
-
* 这样 N===1 的调用**序列**与直接读一个目标逐字相同,红线那条断言才有东西可比。
|
|
1811
|
-
*
|
|
1812
|
-
* @param provider - 四方法契约:`resolve` / `stat` / `readText` / `readByteRange`
|
|
1813
|
-
* @param rawTarget - 调用方给的单个目标(**就是今天那套单目标参数**)
|
|
1814
|
-
* @param index - 在输入数组里的序号(进回执,用于"哪一条没成")
|
|
1815
|
-
* @param probeBom - 是否用 `readByteRange` 探一次 BOM。理由见 {@link readManyWith}
|
|
1816
|
-
* @returns 逐项回执行(与单目标回执同一套渲染:成功时就是 `readWith` 的原样输出)
|
|
1817
|
-
*/
|
|
1818
|
-
async function readManyTarget(provider, rawTarget, index, probeBom) {
|
|
1819
|
-
const request = rawTarget === null || typeof rawTarget !== 'object' ? null : rawTarget
|
|
1820
|
-
if (request === null) throw Object.assign(new Error(`targets[${index}] must be an object`), { code: MT_BAD_TARGETS })
|
|
1821
|
-
/**
|
|
1822
|
-
* 目标里的 `file_path` 留**原样**(回执原样回抄调用方给的路径,与单目标同一条纪律);
|
|
1823
|
-
* `readWith` 要的是 `filePath`,转换只在这一处发生。
|
|
1824
|
-
*/
|
|
1825
|
-
const filePath = request.file_path ?? request.filePath ?? request.path
|
|
1826
|
-
if (typeof filePath !== 'string' || filePath === '') {
|
|
1827
|
-
throw Object.assign(new Error(`targets[${index}] needs a file_path string`), { code: MT_BAD_TARGETS })
|
|
1828
|
-
}
|
|
1829
|
-
/**
|
|
1830
|
-
*
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
1846
|
-
*
|
|
1847
|
-
*
|
|
1848
|
-
* `
|
|
1849
|
-
* `
|
|
1850
|
-
* `
|
|
1851
|
-
*
|
|
1852
|
-
*
|
|
1853
|
-
*
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
const
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
* `
|
|
1871
|
-
* ·
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
*
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
1929
|
-
*
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
*
|
|
1980
|
-
*
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
1983
|
-
*
|
|
1984
|
-
*
|
|
1985
|
-
*
|
|
1986
|
-
*
|
|
1987
|
-
*
|
|
1988
|
-
*
|
|
1989
|
-
*
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
*
|
|
1993
|
-
*
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
2000
|
-
*
|
|
2001
|
-
*
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2009
|
-
*
|
|
2010
|
-
*
|
|
2011
|
-
*
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
2014
|
-
*
|
|
2015
|
-
*
|
|
2016
|
-
*
|
|
2017
|
-
*
|
|
2018
|
-
* @
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
if (list
|
|
2033
|
-
throw Object.assign(new Error(`targets
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
counts
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
*
|
|
2073
|
-
*
|
|
2074
|
-
* "
|
|
2075
|
-
*
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
1778
|
+
/**
|
|
1779
|
+
* 多目标读(契约 §2.1 / §4)——**与单目标并列的新增形状**,既有形状一字不改。
|
|
1780
|
+
*
|
|
1781
|
+
* 这不是 `readWith` 的变体:`readWith` 吃**已经读进来的全文**,本入口吃**目标描述**,
|
|
1782
|
+
* 读盘那一步由 provider 完成。两者共用的只有"一个目标怎么读"这一处逻辑
|
|
1783
|
+
* ({@link readManyTarget}),因此不存在"单目标一套、多目标另一套"两份判定。
|
|
1784
|
+
*
|
|
1785
|
+
* ## 为什么新入口要读盘、而单目标那条路不读(这一条是可复核的历史,不是取舍)
|
|
1786
|
+
*
|
|
1787
|
+
* 读侧的真编排入口是 `ops.mjs` 的 `readEnvelope`,它在文件分支用 `readFileBytes`(直接 fs 读)
|
|
1788
|
+
* ——**不经 `provider.readText`**。上一单把这条现状钉成了红线(`provider-calls` 那三类逐条比对
|
|
1789
|
+
* 调用序列)。所以本入口**绝不**改 `readEnvelope`:多目标走 provider 是**新路径**,单目标
|
|
1790
|
+
* 继续走它原来那条。谁要动单目标那条路,先绿的是红线套件,不是这份代码。
|
|
1791
|
+
*
|
|
1792
|
+
* 本文件**零 `node:` 依赖**(契约 §1.5):这里只做纯计算与 provider 调用,
|
|
1793
|
+
* 计时用 `Date.now()`(全局,不是内置模块)。
|
|
1794
|
+
*/
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* 并发池大小(契约 §4:`MT_POOL = 8`)—— **不是本地真值**,而是参数面 `args.mjs` 那一份的转发。
|
|
1798
|
+
*
|
|
1799
|
+
* 这里原来写着 `export const MT_POOL = 8`,与写侧 `write-path.mjs` 里同一句构成**两份活真值**;
|
|
1800
|
+
* 两边恰好都是 8,于是两个套件同时绿,把缝盖住了。现在取值只有一个来源,与上面 `MT_MAX_TARGETS`
|
|
1801
|
+
* 同一条纪律、同一种接法:上面的 `import` 建本地绑定供本文件自己用(`options.pool ?? MT_POOL`),
|
|
1802
|
+
* 再用 `export { MT_POOL }` 把这个**同一绑定**转出去。**不是** `export … from`:那种写法不建本地绑定,
|
|
1803
|
+
* 本文件自己那两处会当场 `ReferenceError`——`node --check` 抓不到,只有实跑套件才炸。
|
|
1804
|
+
*
|
|
1805
|
+
* 导出名与取值逐字未变(`multi-read-test.mjs` 从这里取 `MT_POOL` 并断言在飞峰值)。
|
|
1806
|
+
*/
|
|
1807
|
+
export { MT_POOL }
|
|
1808
|
+
/** 多目标的"参数形状没成形"错误码(与 `ARGS_UNKNOWN_KEY` 同族:**抛错**,不是结果通道)。 */
|
|
1809
|
+
export const MT_BAD_TARGETS = 'MT_BAD_TARGETS'
|
|
1810
|
+
export const MT_TOO_MANY_TARGETS = 'MT_TOO_MANY_TARGETS'
|
|
1811
|
+
|
|
1812
|
+
/** 一个目标读完之后账面上算哪一类(汇总行只按这一列计数)。 */
|
|
1813
|
+
const MT_OK = 'ok'
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* 汇总行:形如 `targets: 7 | ok 6 | not_found 1`。
|
|
1817
|
+
*
|
|
1818
|
+
* 计数**按结果现算**,不另维护一套账:两套账迟早会分叉,而分叉的方向必然是
|
|
1819
|
+
* "汇总说 6 条成了、逐项里只有 5 条 ok"这种谁都不信的中间态。
|
|
1820
|
+
* 没成的按 `code` 逐个列出(`not_found` 用短名,其余用原码),因此
|
|
1821
|
+
* "哪几条没成"在一行里就能读出来。
|
|
1822
|
+
*/
|
|
1823
|
+
function mtSummaryLine(entries) {
|
|
1824
|
+
const counts = new Map()
|
|
1825
|
+
for (const entry of entries) {
|
|
1826
|
+
const label = entry.outcome === MT_OK ? MT_OK : mtShortCode(entry.code)
|
|
1827
|
+
counts.set(label, (counts.get(label) ?? 0) + 1)
|
|
1828
|
+
}
|
|
1829
|
+
// `ok` 永远排第一(它是这条行的主语),其余按名字排序 ⇒ 同一份输入恒定给同一行。
|
|
1830
|
+
const names = [...counts.keys()].sort((a, b) => (a === MT_OK ? -1 : b === MT_OK ? 1 : a < b ? -1 : a > b ? 1 : 0))
|
|
1831
|
+
return [`targets: ${entries.length}`, ...names.map((name) => `${name} ${counts.get(name)}`)].join(' | ')
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/** 汇总行里的短码:只给 `not_found` 起短名,其余原样——短名多了就成了第二种语言。 */
|
|
1835
|
+
const mtShortCode = (code) => (code === 'FS_NOT_FOUND' ? 'not_found' : String(code ?? 'error'))
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* 读一个 target:**唯一的**"一个目标 = 一次读"实现。
|
|
1839
|
+
*
|
|
1840
|
+
* N===1 直调它、N>1 池里调它——同一条代码路径,所以"多目标那条路会不会比单目标多跑一次"
|
|
1841
|
+
* 这个问题在结构上就只有一种答案。
|
|
1842
|
+
*
|
|
1843
|
+
* 顺序(也就是 provider 调用序列)是刻意钉死的:`resolve` → `stat` → `readText` →(可选)`readByteRange`。
|
|
1844
|
+
* 它刻意与 `readEnvelope` 文件分支的 `resolve` → `stat` → 读 同形:
|
|
1845
|
+
* 这样 N===1 的调用**序列**与直接读一个目标逐字相同,红线那条断言才有东西可比。
|
|
1846
|
+
*
|
|
1847
|
+
* @param provider - 四方法契约:`resolve` / `stat` / `readText` / `readByteRange`
|
|
1848
|
+
* @param rawTarget - 调用方给的单个目标(**就是今天那套单目标参数**)
|
|
1849
|
+
* @param index - 在输入数组里的序号(进回执,用于"哪一条没成")
|
|
1850
|
+
* @param probeBom - 是否用 `readByteRange` 探一次 BOM。理由见 {@link readManyWith}
|
|
1851
|
+
* @returns 逐项回执行(与单目标回执同一套渲染:成功时就是 `readWith` 的原样输出)
|
|
1852
|
+
*/
|
|
1853
|
+
async function readManyTarget(provider, rawTarget, index, probeBom) {
|
|
1854
|
+
const request = rawTarget === null || typeof rawTarget !== 'object' ? null : rawTarget
|
|
1855
|
+
if (request === null) throw Object.assign(new Error(`targets[${index}] must be an object`), { code: MT_BAD_TARGETS })
|
|
1856
|
+
/**
|
|
1857
|
+
* 目标里的 `file_path` 留**原样**(回执原样回抄调用方给的路径,与单目标同一条纪律);
|
|
1858
|
+
* `readWith` 要的是 `filePath`,转换只在这一处发生。
|
|
1859
|
+
*/
|
|
1860
|
+
const filePath = request.file_path ?? request.filePath ?? request.path
|
|
1861
|
+
if (typeof filePath !== 'string' || filePath === '') {
|
|
1862
|
+
throw Object.assign(new Error(`targets[${index}] needs a file_path string`), { code: MT_BAD_TARGETS })
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* 行区间 / 分页 / 模式推断:与单目标**同一个** `normalizeReadRequest`。
|
|
1866
|
+
* 旧版只转发 `around/lines/search/since/maxChars`,于是 `offset/limit/context/kind/budget`
|
|
1867
|
+
* 在多目标里被静默丢掉(同一个元素单独读与放进 targets 读会得到两种结果)。
|
|
1868
|
+
* 这里先只判形状;读到内容后再用它做 offset 越界与尾行截断。
|
|
1869
|
+
*/
|
|
1870
|
+
const shape = normalizeReadRequest(request)
|
|
1871
|
+
if (shape.ok !== true) {
|
|
1872
|
+
return mtFailure(index, [filePath, filePath], 'READ_BAD_ARGS', shape.error)
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* `provider.resolve` 的两种返回形状**都认**(`await` 对同步值等价,所以旧提供方一行不用改):
|
|
1876
|
+
* · **字符串** —— 旧形状(CLI/MCP 的 `createNodeFsProvider`、绝大多数测试):它就是显示路径,
|
|
1877
|
+
* 账本键也由它算(`identityKey` 缺席)⇒ 与本参数存在之前**逐字节相同**;
|
|
1878
|
+
* · **宿主解析结果** `{displayPath, targetKey}` —— DSH 壳的 `ctx.fs.resolve()` 就是这个形状:
|
|
1879
|
+
* 显示路径用 `displayPath`,**身份键**用 `targetKey`。
|
|
1880
|
+
*
|
|
1881
|
+
* 为什么必须认第二种(实测:同一文件同一区间,单目标读发 `h4745015d4840:…`、多目标读发
|
|
1882
|
+
* `he6dd015d4840:…` —— 指纹段相同、**身份标签段不同**):句柄的身份标签按
|
|
1883
|
+
* `ledgerIdentityKey(filePath, identityKey)` 算,而单目标那条路(`readEnvelope` 前的壳)递的正是
|
|
1884
|
+
* `ctx.fs.resolve()` 的 `targetKey`。多目标若只把路径串当身份,就会对**同一份盘上文件**发出两个
|
|
1885
|
+
* 不同的句柄,后果具体:拿批量读来的句柄去 `aura_basetools_edit` ⇒ `PATCH_HANDLE_IDENTITY_MISMATCH`。
|
|
1886
|
+
*
|
|
1887
|
+
* 身份口径**不新造第三份**:宿主给的 `targetKey` 就是既有那一份(@see ledgerIdentityKey / IDENTITY_KEYS);
|
|
1888
|
+
* 元素自带的 `identityKey`(与写侧 `editTargetRequests` 同名的那个键)只作为提供方给不出事实时的回落。
|
|
1889
|
+
*/
|
|
1890
|
+
const hostTarget = await provider.resolve(filePath)
|
|
1891
|
+
const target =
|
|
1892
|
+
hostTarget !== null && typeof hostTarget === 'object' ? (hostTarget.displayPath ?? filePath) : hostTarget
|
|
1893
|
+
const identityKey =
|
|
1894
|
+
hostTarget !== null && typeof hostTarget === 'object' && typeof hostTarget.targetKey === 'string' && hostTarget.targetKey.length > 0
|
|
1895
|
+
? hostTarget.targetKey
|
|
1896
|
+
: request.identityKey
|
|
1897
|
+
let info
|
|
1898
|
+
try {
|
|
1899
|
+
info = await provider.stat(target)
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
return mtFailure(index, [target, filePath], error?.code ?? 'IO_ERROR', String(error?.message ?? error))
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* `stat` 的两套约定都要认:
|
|
1905
|
+
* · `createNodeFsProvider` 与 DSH 的 `probe()` 给 `{type: 'file'|'directory'|'other', …}`;
|
|
1906
|
+
* · 有些提供方给 `undefined` 表示不存在。
|
|
1907
|
+
* 存在但拿不到类型时**按文件试读**(读不动自有 provider 抛错兜底),
|
|
1908
|
+
* 不在这里替提供方发明第三种类型判定。
|
|
1909
|
+
*/
|
|
1910
|
+
if (info === undefined || info === null) {
|
|
1911
|
+
return mtFailure(index, [target, filePath], 'FS_NOT_FOUND', `cannot read "${filePath}": not found`)
|
|
1912
|
+
}
|
|
1913
|
+
if (info.type === 'directory') {
|
|
1914
|
+
// 目录级地图(T26)是**单目标**的既有能力,多目标这一波不扩它:只给可执行的拒绝。
|
|
1915
|
+
return mtFailure(index, [target, filePath], 'MT_TARGET_IS_DIRECTORY', `"${filePath}" is a directory: read it on its own (a repo map is not a window)`, `read "${filePath}" by itself for a repo map`)
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* `readText` 可能同步抛(非 UTF-8 / 二进制 / 竞争删除)——`await` 包在 try 里,
|
|
1919
|
+
* 于是同步抛与 reject 走同一条路。逐项拒绝,整批不受影响(契约 §1.3)。
|
|
1920
|
+
*/
|
|
1921
|
+
let source
|
|
1922
|
+
try {
|
|
1923
|
+
source = await provider.readText(target)
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
return mtFailure(index, [target, filePath], error?.code ?? 'IO_ERROR', String(error?.message ?? error))
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* BOM:`provider.readText` 把第一个 BOM 吃掉了(默认 `ignoreBOM: false`),
|
|
1929
|
+
* 而 BOM **进内容指纹**、也进句柄(契约 §4 的令牌语义),因此它必须由**字节**得出。
|
|
1930
|
+
*
|
|
1931
|
+
* 这一笔是"多目标路径上多出来的一次读",代价与理由都摆在这里:只读 BOM 那几个字节,
|
|
1932
|
+
* 且只在提供方有 `readByteRange` 时;`probeBom: false` 可整体关掉(那时按"无 BOM"读)。
|
|
1933
|
+
* **单目标那条路一点没碰**:它仍旧走 `readEnvelope` 的 `readFileBytes`(一次 fs 读,BOM 一次拿到)。
|
|
1934
|
+
*/
|
|
1935
|
+
let byteOrderMark = false
|
|
1936
|
+
if (probeBom && typeof provider.readByteRange === 'function') {
|
|
1937
|
+
try {
|
|
1938
|
+
const head = await provider.readByteRange(target, { offset: 0, length: BYTE_ORDER_MARK_BYTES.length })
|
|
1939
|
+
byteOrderMark = startsWithByteOrderMark(head)
|
|
1940
|
+
} catch {
|
|
1941
|
+
// 读头失败不回滚整条读:BOM 是**元数据**,缺它就按没有处理(下面那次 readText 已经成功了)。
|
|
1942
|
+
byteOrderMark = false
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
const requestShape = normalizeReadRequest(request, source.split(/\r?\n/).length)
|
|
1946
|
+
if (requestShape.ok !== true) {
|
|
1947
|
+
return mtFailure(index, [target, filePath], 'READ_BAD_ARGS', requestShape.error)
|
|
1948
|
+
}
|
|
1949
|
+
const env = readWith(source, {
|
|
1950
|
+
mode: requestShape.mode,
|
|
1951
|
+
around: request.around,
|
|
1952
|
+
lines: asLinePair(requestShape.lines),
|
|
1953
|
+
search: request.search,
|
|
1954
|
+
since: request.since,
|
|
1955
|
+
// 读侧两层叫法不同:宿主面 `max_chars`,引擎面 `maxChars`(与 `readEnvelope` 同源)。
|
|
1956
|
+
maxChars: request.max_chars ?? request.maxChars,
|
|
1957
|
+
...(request.context === undefined ? {} : { context: request.context }),
|
|
1958
|
+
...(request.kind === undefined ? {} : { kind: request.kind }),
|
|
1959
|
+
...(request.budget === undefined ? {} : { budget: request.budget }),
|
|
1960
|
+
byteOrderMark,
|
|
1961
|
+
/**
|
|
1962
|
+
* `filePath` 给的是 **provider.resolve 之后的那个串**,不是调用方原样给的路径。
|
|
1963
|
+
*
|
|
1964
|
+
* 为什么必须是它(这条是实测出来的,不是风格):`readWith` 用它算**账本键**,
|
|
1965
|
+
* 而账本键进句柄的身份标签。单目标那条路(`readEnvelope`)给的正是 `provider.resolve(file)`,
|
|
1966
|
+
* 于是这里若给原样的 `file_path`,同一个文件在两条路上会得到**两个不同的句柄**
|
|
1967
|
+
* ——`multi-read-test` 段A 的"逐项回执与单独跑一次逐字节相同"当场变红,
|
|
1968
|
+
* 而且更坏的是:拿批内句柄去 `aura_basetools_edit` 会查不到那一条观测。
|
|
1969
|
+
*
|
|
1970
|
+
* 回执里显示哪一份由 `file_path` 字段负责(下面那条)——两件事分开,各归各的字段。
|
|
1971
|
+
*/
|
|
1972
|
+
filePath: target,
|
|
1973
|
+
/**
|
|
1974
|
+
* 身份键与 `filePath` **分开递**,与单目标那条路(`readEnvelope` 的 `identityKey: target.targetKey`)
|
|
1975
|
+
* 同源。缺席时(提供方只给字符串、元素也没带)走旧行为:账本键 = `filePath`。
|
|
1976
|
+
*/
|
|
1977
|
+
...(typeof identityKey === 'string' && identityKey.length > 0 ? { identityKey } : {}),
|
|
1978
|
+
})
|
|
1979
|
+
/**
|
|
1980
|
+
* 成了 ⇒ **这一条就是单目标回执本身**(同一个对象引用,不是另渲染一遍)。
|
|
1981
|
+
* 另渲染一遍是这一单最容易犯的错:两份渲染一开始一样,改一处就只改一份。
|
|
1982
|
+
*/
|
|
1983
|
+
return {
|
|
1984
|
+
index,
|
|
1985
|
+
file_path: filePath,
|
|
1986
|
+
ok: true,
|
|
1987
|
+
outcome: MT_OK,
|
|
1988
|
+
mode: env.mode,
|
|
1989
|
+
envelope: env,
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/**
|
|
1994
|
+
* 一条没成的逐项回执。
|
|
1995
|
+
*
|
|
1996
|
+
* 形状刻意与成了的那条**逐字段对齐**(`index` / `file_path` / `ok` / `outcome` 都在),
|
|
1997
|
+
* 只有 `envelope` 换成 `code` + `message`:调用方按同一个字段集读两种结果,不用分叉。
|
|
1998
|
+
* `next` 只在能给**可执行**的下一步时才出现(与单目标的 `next` 同一条纪律:不给空话)。
|
|
1999
|
+
*/
|
|
2000
|
+
function mtFailure(index, [target, displayPath], code, message, next) {
|
|
2001
|
+
return {
|
|
2002
|
+
index,
|
|
2003
|
+
file_path: displayPath,
|
|
2004
|
+
ok: false,
|
|
2005
|
+
outcome: 'failed',
|
|
2006
|
+
...(code === 'FS_NOT_FOUND' ? { target } : {}),
|
|
2007
|
+
code,
|
|
2008
|
+
message,
|
|
2009
|
+
...(next === undefined ? {} : { next }),
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* **一次调用读多个目标**(契约 §2.1 的读侧形状)。
|
|
2015
|
+
*
|
|
2016
|
+
* ## 形状
|
|
2017
|
+
*
|
|
2018
|
+
* 两种调用方式,**互斥**:
|
|
2019
|
+
* · `readManyWith(provider, [{file_path, mode, …}, …], opts)` —— 多目标(推荐,显式);
|
|
2020
|
+
* · `readManyWith(provider, {file_path, mode, …}, opts)` —— 单个目标,与上面**同一条代码路径**。
|
|
2021
|
+
* 单个对象被归一成"长度 1 的数组",所以不存在第二条实现。
|
|
2022
|
+
*
|
|
2023
|
+
* ## N===1 ⇒ 直调(结构性保证,不是"我们测了没变慢")
|
|
2024
|
+
*
|
|
2025
|
+
* 长度 1 时**不建池、不建队列、不包一层**:直接 `await` 那个唯一的目标,
|
|
2026
|
+
* 一行 `if` 就出函数。因此"N=1 时多跑一次 / 多包一层 / 多一个字段"这件事
|
|
2027
|
+
* 在本入口里没有可发生的语法位置——它是控制流性质,不是性能观测。
|
|
2028
|
+
* 测试(`multi-read-test.mjs` 段C)用计数 provider 把这条钉成"调用序列逐项相同"。
|
|
2029
|
+
*
|
|
2030
|
+
* ## N>1 ⇒ 有界并发池,只并发 I/O 与纯计算(契约 §4)
|
|
2031
|
+
*
|
|
2032
|
+
* 池子就是 `min(MT_POOL, N)` 个**常驻 worker** 各拉一条队列:`maxInFlight` 因此由结构决定,
|
|
2033
|
+
* 不用"数一数同时在飞几个"去经验地保证。
|
|
2034
|
+
* **结果按输入顺序装配**:`results[i] = …` 按下标写,完成顺序只影响"谁先写"、不影响任何一项的位置。
|
|
2035
|
+
* 这一条是硬要求(契约 §1.4)——并发完成顺序一旦泄漏到输出,同一份输入两次调用可能给两种排列。
|
|
2036
|
+
*
|
|
2037
|
+
* ## 失败语义(契约 §1.3)
|
|
2038
|
+
*
|
|
2039
|
+
* 逐项失败(不存在 / 找不到 / 需选择 / 不是文本 / 目录)一律**走结果通道**:那一条带
|
|
2040
|
+
* `ok:false` + `code` + `message`(能给下一步时还有 `next`),**其余目标照常**。
|
|
2041
|
+
* 只有"参数形状没成形"才抛错:
|
|
2042
|
+
* · `targets` 不是数组/不是对象 ⇒ `MT_BAD_TARGETS`;
|
|
2043
|
+
* · 目标数超过 {@link MT_MAX_TARGETS} ⇒ `MT_TOO_MANY_TARGETS`,**message 里报出实得数**;
|
|
2044
|
+
* · `targets` 为空 ⇒ `MT_BAD_TARGETS`(空批不是"成功读了 0 个",那是静默换语义)。
|
|
2045
|
+
* 抛错**不会**吞掉已经读成的那些:抛发生在**读之前**(形状校验是第一件事),所以没有"半批已读"这种状态。
|
|
2046
|
+
*
|
|
2047
|
+
* ## 回执
|
|
2048
|
+
*
|
|
2049
|
+
* 逐项(同一套渲染)+ **一行汇总**(`targets: N | ok K | not_found M`)。
|
|
2050
|
+
* 汇总只在 `summary` 字段里、不进任何 `envelope.text`:正文是宿主剪枝器量的东西(契约 §5),
|
|
2051
|
+
* 往里塞一行就是让每一份多目标回执的正文回涨。
|
|
2052
|
+
*
|
|
2053
|
+
* @param provider - 四方法契约:`resolve` / `stat` / `readText` / `readByteRange`
|
|
2054
|
+
* @param targets - 目标数组(每个元素就是今天那套单目标参数),或**单个**这样的对象
|
|
2055
|
+
* @param {number} [options.pool] - 覆盖池大小(默认 {@link MT_POOL})。测试要它把"有界"钉成结构观测
|
|
2056
|
+
* @param {boolean} [options.probeBom] - 是否探 BOM,默认 `true`
|
|
2057
|
+
* @param {(event: {index: number, maxInFlight: number}) => void} [options.trace] - 纯观测钩子(测试用)
|
|
2058
|
+
* @returns 逐项 + 汇总的整批回执(**不是** Promise 之外的任何包装)
|
|
2059
|
+
* @throws {Error} `code` ∈ {`MT_BAD_TARGETS`, `MT_TOO_MANY_TARGETS`}
|
|
2060
|
+
*/
|
|
2061
|
+
export async function readManyWith(provider, targets, options = {}) {
|
|
2062
|
+
if (provider === null || typeof provider !== 'object' || typeof provider.readText !== 'function') {
|
|
2063
|
+
throw Object.assign(new Error('readManyWith needs a provider with resolve/stat/readText'), { code: MT_BAD_TARGETS })
|
|
2064
|
+
}
|
|
2065
|
+
/** 单个对象 = 长度 1 的数组:**归一只有这一处**,下面再没有"单个/多个"的分叉。 */
|
|
2066
|
+
const list = Array.isArray(targets) ? targets : targets !== null && typeof targets === 'object' ? [targets] : null
|
|
2067
|
+
if (list === null) {
|
|
2068
|
+
throw Object.assign(new Error(`targets must be an array or a single target object, got ${targets === null ? 'null' : typeof targets}`), { code: MT_BAD_TARGETS })
|
|
2069
|
+
}
|
|
2070
|
+
if (list.length === 0) {
|
|
2071
|
+
throw Object.assign(new Error('targets must not be empty (an empty batch is not "read 0 files")'), { code: MT_BAD_TARGETS })
|
|
2072
|
+
}
|
|
2073
|
+
if (list.length > MT_MAX_TARGETS) {
|
|
2074
|
+
throw Object.assign(new Error(`targets has ${list.length} entries, but the limit is ${MT_MAX_TARGETS} — split the call`), {
|
|
2075
|
+
code: MT_TOO_MANY_TARGETS,
|
|
2076
|
+
got: list.length,
|
|
2077
|
+
max: MT_MAX_TARGETS,
|
|
2078
|
+
})
|
|
2079
|
+
}
|
|
2080
|
+
const probeBom = options.probeBom !== false
|
|
2081
|
+
const started = Date.now()
|
|
2082
|
+
const entries = new Array(list.length)
|
|
2083
|
+
if (list.length === 1) {
|
|
2084
|
+
// ── N===1:直调。没有池、没有队列、没有 trace、没有包装 —— 这里少写一行代码就是保证。 ──
|
|
2085
|
+
entries[0] = await readManyTarget(provider, list[0], 0, probeBom)
|
|
2086
|
+
} else {
|
|
2087
|
+
await mtPool(list, options.pool ?? MT_POOL, options.trace, async (item, index) => {
|
|
2088
|
+
entries[index] = await readManyTarget(provider, item, index, probeBom)
|
|
2089
|
+
})
|
|
2090
|
+
}
|
|
2091
|
+
const counts = { ok: 0, failed: 0 }
|
|
2092
|
+
for (const entry of entries) {
|
|
2093
|
+
if (entry.ok === true) counts.ok += 1
|
|
2094
|
+
else counts.failed += 1
|
|
2095
|
+
}
|
|
2096
|
+
return {
|
|
2097
|
+
ok: counts.failed === 0,
|
|
2098
|
+
targets: entries.length,
|
|
2099
|
+
counts,
|
|
2100
|
+
summary: mtSummaryLine(entries),
|
|
2101
|
+
entries,
|
|
2102
|
+
elapsedMs: Date.now() - started,
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* 有界并发池:`min(pool, N)` 个常驻 worker 各拉一条队列。
|
|
2108
|
+
*
|
|
2109
|
+
* 为什么是"常驻 worker + 共享游标"而不是"分批 `Promise.all`":分批发的话,
|
|
2110
|
+
* 一格慢就整批等(尾部空转),而"同时在飞几个"这件事会随每批的完成快慢漂移。
|
|
2111
|
+
* 常驻 worker 下**在飞数恒 ≤ pool**——这是结构,不靠经验观测。
|
|
2112
|
+
*
|
|
2113
|
+
* 为什么 worker 自己 `await`:`maxInFlight` 只在 worker 真正进入/离开时增减,
|
|
2114
|
+
* 而它进出之间**只**做一件事:跑 `run`(I/O 与纯计算)。`trace` 因此能给出
|
|
2115
|
+
* "同时在飞的最大值"这个**确定性**读数(慢 provider 下它必然等于 pool),
|
|
2116
|
+
* 而不是一次墙钟计时(机器上有并行单元,计时不作判据)。
|
|
2117
|
+
*/
|
|
2118
|
+
async function mtPool(items, pool, trace, run) {
|
|
2119
|
+
const width = Math.max(1, Math.min(Math.trunc(pool) || MT_POOL, items.length))
|
|
2120
|
+
let next = 0
|
|
2121
|
+
let inFlight = 0
|
|
2122
|
+
let maxInFlight = 0
|
|
2123
|
+
const worker = async () => {
|
|
2124
|
+
while (true) {
|
|
2125
|
+
const index = next
|
|
2126
|
+
next += 1
|
|
2127
|
+
if (index >= items.length) return
|
|
2128
|
+
inFlight += 1
|
|
2129
|
+
if (inFlight > maxInFlight) maxInFlight = inFlight
|
|
2130
|
+
if (typeof trace === 'function') trace({ index, maxInFlight })
|
|
2131
|
+
try {
|
|
2132
|
+
await run(items[index], index)
|
|
2133
|
+
} finally {
|
|
2134
|
+
inFlight -= 1
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
const workers = []
|
|
2139
|
+
for (let w = 0; w < width; w += 1) workers.push(worker())
|
|
2140
|
+
await Promise.all(workers)
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* 读一个文件,按模式返回**带预算与省略说明**的结构化结果。
|
|
2145
|
+
*
|
|
2146
|
+
* 多目标(多文件 / 多区间一次调用)走 {@link readManyWith}:它**逐个**调用本函数,
|
|
2147
|
+
* 因此"一个目标怎么读"只有一处实现。本函数自身一行未改——单目标零回归由红线套件钉住。
|
|
2148
|
+
*
|
|
2108
2149
|
* @param {string} source - 文件全文
|
|
2109
2150
|
* @param {object} request
|
|
2110
2151
|
* @param {'map'|'digest'|'around'|'lines'|'search'|'changed'|'auto'} [request.mode] - 默认 auto
|
|
@@ -2143,7 +2184,7 @@ export function readWith(source, request = {}) {
|
|
|
2143
2184
|
// 实测后果:changed 在真实会话里只被用过 1 次、且那一次没带 since——工具递出了钥匙,引擎不接。
|
|
2144
2185
|
else if (request.since !== undefined || request.token !== undefined || request.previous !== undefined) mode = 'changed'
|
|
2145
2186
|
else {
|
|
2146
|
-
auto = suggestMode(source, request.filePath ?? null)
|
|
2187
|
+
auto = suggestMode(source, request.filePath ?? null, request.budget)
|
|
2147
2188
|
mode = auto.mode
|
|
2148
2189
|
}
|
|
2149
2190
|
}
|
|
@@ -2152,14 +2193,14 @@ export function readWith(source, request = {}) {
|
|
|
2152
2193
|
}
|
|
2153
2194
|
const lines = source.split(/\r?\n/)
|
|
2154
2195
|
const total = lines.length
|
|
2155
|
-
const filePath = request.filePath ?? null
|
|
2156
|
-
const lang = filePath === null ? null : langOf(filePath)
|
|
2157
|
-
/**
|
|
2158
|
-
* 账本的**身份键**:算一次,下面每一处账本操作都用它({@link ledgerIdentityKey} 是
|
|
2159
|
-
* 全引擎唯一那道归一)。`filePath` 从这一行往下只管显示与语言判定。
|
|
2160
|
-
*/
|
|
2161
|
-
const ledgerKey = ledgerIdentityKey(filePath, request.identityKey)
|
|
2162
|
-
/**
|
|
2196
|
+
const filePath = request.filePath ?? null
|
|
2197
|
+
const lang = filePath === null ? null : langOf(filePath)
|
|
2198
|
+
/**
|
|
2199
|
+
* 账本的**身份键**:算一次,下面每一处账本操作都用它({@link ledgerIdentityKey} 是
|
|
2200
|
+
* 全引擎唯一那道归一)。`filePath` 从这一行往下只管显示与语言判定。
|
|
2201
|
+
*/
|
|
2202
|
+
const ledgerKey = ledgerIdentityKey(filePath, request.identityKey)
|
|
2203
|
+
/**
|
|
2163
2204
|
* 版本令牌与"这份内容带不带 BOM"
|
|
2164
2205
|
* (它们原先在 `render` 里算——每轮重算一遍纯属白做工,而下面还要用它们做一次增量判定。)
|
|
2165
2206
|
*/
|
|
@@ -2170,7 +2211,7 @@ export function readWith(source, request = {}) {
|
|
|
2170
2211
|
* 增量层的判定也放在循环外:它只看账本、缓存与内容,不看预算。
|
|
2171
2212
|
* 放进 `render` 会让收敛的每一轮把差分重做一遍(大文件上那就是百毫秒级 × 8 轮)。
|
|
2172
2213
|
*/
|
|
2173
|
-
const incremental = mode === 'full' ? planIncremental(request, lines, version) : { kind: 'skip' }
|
|
2214
|
+
const incremental = mode === 'full' && request.probe !== true ? planIncremental(request, lines, version) : { kind: 'skip' }
|
|
2174
2215
|
/**
|
|
2175
2216
|
* 顺扫判定同样放在循环外:它只看请求与会话账本(不看预算),而且
|
|
2176
2217
|
* 「这一次是不是同一段顺扫的下一段」不应该随收敛轮次变。
|
|
@@ -2189,7 +2230,7 @@ export function readWith(source, request = {}) {
|
|
|
2189
2230
|
*
|
|
2190
2231
|
* 这个字段曾经被 `mode === 'changed' || lang === null` 这个条件剥掉,后果是一整类
|
|
2191
2232
|
* 调用被框架直接拒绝(schema 声明它必填):
|
|
2192
|
-
* tool "
|
|
2233
|
+
* tool "aura_basetools_read" returned invalid output: missing required property "value.version"
|
|
2193
2234
|
* 实测命中的组合:`around`/`lines`/`search` 三种模式(它们经 windowEnvelope 再铺 base,
|
|
2194
2235
|
* 而 base 里已经被剥掉了),以及**所有未索引扩展名**上的内容读取——
|
|
2195
2236
|
* `.json`/`.yml`/`.md` 一个都读不了。
|
|
@@ -2204,12 +2245,12 @@ export function readWith(source, request = {}) {
|
|
|
2204
2245
|
* 版本令牌与**观测记录**:每一模式都要。
|
|
2205
2246
|
*
|
|
2206
2247
|
* `changed` 自己也会记一次(同一键重复记无害),而其余模式此前不记——
|
|
2207
|
-
* 实测后果:普通 `
|
|
2248
|
+
* 实测后果:普通 `aura_basetools_read` 之后拿那个令牌去问 `changed`,得到的是
|
|
2208
2249
|
* “that version is no longer cached — cannot diff”,把“上次观测”当判据的路直接断掉。
|
|
2209
2250
|
*/
|
|
2210
2251
|
delivery = null
|
|
2211
2252
|
// 账本键换成**身份键**(有的话):`filePath` 只用于显示与语言判定
|
|
2212
|
-
if (filePath !== null) rememberRead(ledgerKey, version, source, bom, request.identityKey)
|
|
2253
|
+
if (filePath !== null && request.probe !== true) rememberRead(ledgerKey, version, source, bom, request.identityKey)
|
|
2213
2254
|
const base = {
|
|
2214
2255
|
ok: true,
|
|
2215
2256
|
mode,
|
|
@@ -2226,25 +2267,25 @@ export function readWith(source, request = {}) {
|
|
|
2226
2267
|
/**
|
|
2227
2268
|
* 句柄上下文:`lines` / `search` / `full` 用它铸柄,其余模式为 null。
|
|
2228
2269
|
*
|
|
2229
|
-
* 为什么按模式逐个点名、而不是"凡有窗口就发":契约 §4 的表把发法逐模式钉死了。
|
|
2230
|
-
* `around` / `changed` 不在表里,给它们附加柄就是**超出契约的发法**——
|
|
2231
|
-
* 而回执体积那条不变式(§2.5)只管得住被量过的模式。
|
|
2232
|
-
*
|
|
2233
|
-
* `search` 必须在这一行里点名:这个函数**按模式逐个分支**,少一个名字就是静默不发
|
|
2234
|
-
* (而"没发句柄"与"发了空句柄"在回执上长得一样,只有掐住字段才看得出来)。
|
|
2235
|
-
*
|
|
2236
|
-
* **`full` 不在名单里(本单的裁决,理由是可复核的)**:
|
|
2237
|
-
* 契约 §4 给 `full` 写的是"发一条,仅在调用方显式要时"——**条件句、非强制**(同表的
|
|
2238
|
-
* `map`/`digest` 写的是硬"不发")。而代价是实测的:`full` 的句柄带**身份标签**,
|
|
2239
|
-
* 于是**两条不同路径上的同一份内容**不再给出逐字节相同的回执。
|
|
2240
|
-
* 既有套件 `incremental-read-test` 的 K1("首次读的回执不受本层影响(两条新路径逐字节相同)")
|
|
2241
|
-
* 正是拿这件事当判据的,实测**只因这一个字段变红**(把 `full` 从这行名单里去掉 ⇒ 45/45 绿)。
|
|
2242
|
-
* 而 §7 对 HD1 的硬验收要求那三个既有套件判据**逐字不变**,
|
|
2243
|
-
* 因此这里选择**不发 `full` 句柄**:一个"把整个文件给我"的读本来就不是编辑地址
|
|
2244
|
-
* (它没有相对定位可依附),而"同一份内容两份回执逐字节相同"是既有契约。
|
|
2245
|
-
* `lines`(区间读)与 `search`(命中行)不受影响——那才是编辑要用的两种读法。
|
|
2246
|
-
*/
|
|
2247
|
-
const handleContext =
|
|
2270
|
+
* 为什么按模式逐个点名、而不是"凡有窗口就发":契约 §4 的表把发法逐模式钉死了。
|
|
2271
|
+
* `around` / `changed` 不在表里,给它们附加柄就是**超出契约的发法**——
|
|
2272
|
+
* 而回执体积那条不变式(§2.5)只管得住被量过的模式。
|
|
2273
|
+
*
|
|
2274
|
+
* `search` 必须在这一行里点名:这个函数**按模式逐个分支**,少一个名字就是静默不发
|
|
2275
|
+
* (而"没发句柄"与"发了空句柄"在回执上长得一样,只有掐住字段才看得出来)。
|
|
2276
|
+
*
|
|
2277
|
+
* **`full` 不在名单里(本单的裁决,理由是可复核的)**:
|
|
2278
|
+
* 契约 §4 给 `full` 写的是"发一条,仅在调用方显式要时"——**条件句、非强制**(同表的
|
|
2279
|
+
* `map`/`digest` 写的是硬"不发")。而代价是实测的:`full` 的句柄带**身份标签**,
|
|
2280
|
+
* 于是**两条不同路径上的同一份内容**不再给出逐字节相同的回执。
|
|
2281
|
+
* 既有套件 `incremental-read-test` 的 K1("首次读的回执不受本层影响(两条新路径逐字节相同)")
|
|
2282
|
+
* 正是拿这件事当判据的,实测**只因这一个字段变红**(把 `full` 从这行名单里去掉 ⇒ 45/45 绿)。
|
|
2283
|
+
* 而 §7 对 HD1 的硬验收要求那三个既有套件判据**逐字不变**,
|
|
2284
|
+
* 因此这里选择**不发 `full` 句柄**:一个"把整个文件给我"的读本来就不是编辑地址
|
|
2285
|
+
* (它没有相对定位可依附),而"同一份内容两份回执逐字节相同"是既有契约。
|
|
2286
|
+
* `lines`(区间读)与 `search`(命中行)不受影响——那才是编辑要用的两种读法。
|
|
2287
|
+
*/
|
|
2288
|
+
const handleContext =
|
|
2248
2289
|
mode === 'lines' || mode === 'search' ? { ledgerKey, byteOrderMark: bom } : null
|
|
2249
2290
|
if (mode === 'full') {
|
|
2250
2291
|
// 整文件内容请求:先让增量层说话(差量 / no change / 全量+为什么),
|
|
@@ -2312,7 +2353,28 @@ export function readWith(source, request = {}) {
|
|
|
2312
2353
|
Number.isInteger(request.maxChars) && request.maxChars > 0
|
|
2313
2354
|
? Math.min(request.maxChars, contentCeiling)
|
|
2314
2355
|
: contentCeiling
|
|
2315
|
-
|
|
2356
|
+
/**
|
|
2357
|
+
* 0.9.8:确定性的回执 metrics。`mode` / `totalLines` / `text` / `symbols` 本来就在信封里,
|
|
2358
|
+
* 但调用方要自己数字符、自己判断有没有裁剪;这里统一附一份,三面同形。
|
|
2359
|
+
* 不加 `elapsedMs`(墙钟有噪声),只放**可由内容复算**的事实。
|
|
2360
|
+
*
|
|
2361
|
+
* **必须进收敛循环**:metrics 本身也占字节;事后贴上去会让 `sizeBy(result)` 少算几十字节,
|
|
2362
|
+
* 实测把 8192 档顶到 8250。所以每次 render 后立刻附 metrics,再量尺寸。
|
|
2363
|
+
*/
|
|
2364
|
+
const withMetrics = (env) => env.ok === false ? env : ({
|
|
2365
|
+
...env,
|
|
2366
|
+
metrics: {
|
|
2367
|
+
mode: env.mode,
|
|
2368
|
+
lines: env.totalLines,
|
|
2369
|
+
chars: (env.text ?? '').length,
|
|
2370
|
+
...(env.symbols === undefined ? {} : { symbols: env.symbols }),
|
|
2371
|
+
// `truncated` = **被预算裁过**,不是“有任何 omitted”。map 的 “nested locals omitted”
|
|
2372
|
+
// 是刻意不列(不是内容被裁),旧判据会把它误报成 truncated。
|
|
2373
|
+
truncated: typeof env.omitted === 'string' && /content budget|minimum envelope|host result limit|withheld|truncated/i.test(env.omitted),
|
|
2374
|
+
},
|
|
2375
|
+
})
|
|
2376
|
+
const renderMeasured = (chars) => withMetrics(render(chars))
|
|
2377
|
+
let result = renderMeasured(budget)
|
|
2316
2378
|
/** 每轮额外多收的余量:只按"超出多少"收窄会恰好贴着上限,信封随内容缩短而重算时容易再涨回去。 */
|
|
2317
2379
|
const SHRINK_MARGIN = 64
|
|
2318
2380
|
/** 轮数上限:收敛是单调的,几轮就够;给足是为了让下面那道硬断言根本不触发。 */
|
|
@@ -2323,7 +2385,7 @@ export function readWith(source, request = {}) {
|
|
|
2323
2385
|
const next = Math.max(0, budget - (size - ceiling) - SHRINK_MARGIN)
|
|
2324
2386
|
if (next >= budget) break // 收不动了,交给下面那道硬断言
|
|
2325
2387
|
budget = next
|
|
2326
|
-
result =
|
|
2388
|
+
result = renderMeasured(budget)
|
|
2327
2389
|
}
|
|
2328
2390
|
/**
|
|
2329
2391
|
* 收不到上限时,缩到最小再试一次。
|
|
@@ -2340,19 +2402,28 @@ export function readWith(source, request = {}) {
|
|
|
2340
2402
|
*/
|
|
2341
2403
|
const finalSize = sizeBy(result)
|
|
2342
2404
|
if (finalSize > ceiling) {
|
|
2343
|
-
const minimal =
|
|
2405
|
+
const minimal = renderMeasured(0)
|
|
2344
2406
|
const minimalSize = sizeBy(minimal)
|
|
2345
2407
|
// 记账必须发生在**确定要发哪一份之后**:账本决定下一次还能不能做差量。
|
|
2346
|
-
|
|
2347
|
-
|
|
2408
|
+
// probe(`suggestMode` 的选型探针)只量渲染长度,不改账本、也不触发增量。
|
|
2409
|
+
if (request.probe !== true) {
|
|
2410
|
+
recordDelivery(ledgerKey, minimal, version, total, bom, delivery)
|
|
2411
|
+
recordScan(mode, ledgerKey, version, minimal, total)
|
|
2412
|
+
}
|
|
2348
2413
|
if (minimalSize <= ceiling) return minimal
|
|
2349
2414
|
return {
|
|
2350
2415
|
...minimal,
|
|
2416
|
+
metrics: {
|
|
2417
|
+
...minimal.metrics,
|
|
2418
|
+
truncated: true,
|
|
2419
|
+
},
|
|
2351
2420
|
omitted: `${minimal.omitted === undefined ? '' : `${minimal.omitted}; `}output is already at its minimum envelope (${minimalSize} chars) and still exceeds the host result limit (${ceiling}); the limit is below what a read result can physically be`,
|
|
2352
2421
|
}
|
|
2353
2422
|
}
|
|
2354
|
-
|
|
2355
|
-
|
|
2423
|
+
if (request.probe !== true) {
|
|
2424
|
+
recordDelivery(ledgerKey, result, version, total, bom, delivery)
|
|
2425
|
+
recordScan(mode, ledgerKey, version, result, total)
|
|
2426
|
+
}
|
|
2356
2427
|
return result
|
|
2357
2428
|
}
|
|
2358
2429
|
|
|
@@ -2682,20 +2753,20 @@ function renderSearch(base, lines, request, symbolsIndex = null, handleContext =
|
|
|
2682
2753
|
const limit = Math.min(request.limit ?? 40, HANDLE_MAX_PER_RECEIPT)
|
|
2683
2754
|
const shown = hits.slice(0, limit)
|
|
2684
2755
|
/**
|
|
2685
|
-
* 每条命中一个句柄(契约 §4)。两个落点**都要**:
|
|
2686
|
-
* · `handles: [{line, handle}]`(结构化,与 `relocatable` 同形)——宿主 / UI / schema 面用;
|
|
2687
|
-
* · **每个命中行的行尾**(` <句柄>`)——模型面用。
|
|
2688
|
-
*
|
|
2689
|
-
* 为什么非要有第二个落点(HD3 集成时实测出来的):壳层只把 `value.text` 交给模型
|
|
2690
|
-
* (`aura-read/index.js` 的 `render: (_args, value) => [{type:'text', text: value.text}]`),
|
|
2691
|
-
* 框架再把 `result.content` 装进 tool 消息——**结构化字段一个都到不了模型**。
|
|
2692
|
-
* 只发结构化字段 = 句柄存在但没人看得见,而本波的全部价值就是让模型手里有一个
|
|
2693
|
-
* 工具发的、逐字节正确的区间地址。
|
|
2694
|
-
*
|
|
2695
|
-
* 行首的 `N: ` 前缀一个字不许动:既有解析器(`^\d+: `)靠它取行号,动了就会把
|
|
2696
|
-
* "格式演进"变成"行为退化"。所以句柄一律接在**行尾**。
|
|
2697
|
-
*
|
|
2698
|
-
* 区间取**单行**(`n-n`):`search` 的命中就是一行,句柄指多行反而让"改哪里"变模糊。
|
|
2756
|
+
* 每条命中一个句柄(契约 §4)。两个落点**都要**:
|
|
2757
|
+
* · `handles: [{line, handle}]`(结构化,与 `relocatable` 同形)——宿主 / UI / schema 面用;
|
|
2758
|
+
* · **每个命中行的行尾**(` <句柄>`)——模型面用。
|
|
2759
|
+
*
|
|
2760
|
+
* 为什么非要有第二个落点(HD3 集成时实测出来的):壳层只把 `value.text` 交给模型
|
|
2761
|
+
* (`aura-read/index.js` 的 `render: (_args, value) => [{type:'text', text: value.text}]`),
|
|
2762
|
+
* 框架再把 `result.content` 装进 tool 消息——**结构化字段一个都到不了模型**。
|
|
2763
|
+
* 只发结构化字段 = 句柄存在但没人看得见,而本波的全部价值就是让模型手里有一个
|
|
2764
|
+
* 工具发的、逐字节正确的区间地址。
|
|
2765
|
+
*
|
|
2766
|
+
* 行首的 `N: ` 前缀一个字不许动:既有解析器(`^\d+: `)靠它取行号,动了就会把
|
|
2767
|
+
* "格式演进"变成"行为退化"。所以句柄一律接在**行尾**。
|
|
2768
|
+
*
|
|
2769
|
+
* 区间取**单行**(`n-n`):`search` 的命中就是一行,句柄指多行反而让"改哪里"变模糊。
|
|
2699
2770
|
*/
|
|
2700
2771
|
const handles = (handleContext === null ? [] : shown)
|
|
2701
2772
|
.map((n) => ({ line: n, handle: handleFor(handleContext.ledgerKey, lines, handleContext.byteOrderMark, n, n) }))
|
|
@@ -2709,14 +2780,14 @@ function renderSearch(base, lines, request, symbolsIndex = null, handleContext =
|
|
|
2709
2780
|
* 不披露就等于告诉模型“这一行就到那里”。
|
|
2710
2781
|
* `windowEnvelope` 早就有这套机制(`truncated` + `omitted`),这里只是把同一件事补上。
|
|
2711
2782
|
*/
|
|
2712
|
-
/** 行号 → 句柄。末尾没柄的行拿不到(不会指到没渲染的文本)。 */
|
|
2713
|
-
const handleByLine = new Map(handles.map((h) => [h.line, h.handle]))
|
|
2714
|
-
const clipped = []
|
|
2715
|
-
const rows = shown.map((n) => {
|
|
2716
|
-
const raw = lines[n - 1].trim()
|
|
2717
|
-
// 句柄接在**行尾**:行首的 `N: ` 前缀是既有解析器(`^\d+: `)的锚点,一个字符都不能动。
|
|
2718
|
-
const handle = handleByLine.get(n) ?? null
|
|
2719
|
-
if (raw.length <= MATCH_LINE_CHARS) return withHandleOnLine(`${n}: ${raw}`, handle)
|
|
2783
|
+
/** 行号 → 句柄。末尾没柄的行拿不到(不会指到没渲染的文本)。 */
|
|
2784
|
+
const handleByLine = new Map(handles.map((h) => [h.line, h.handle]))
|
|
2785
|
+
const clipped = []
|
|
2786
|
+
const rows = shown.map((n) => {
|
|
2787
|
+
const raw = lines[n - 1].trim()
|
|
2788
|
+
// 句柄接在**行尾**:行首的 `N: ` 前缀是既有解析器(`^\d+: `)的锚点,一个字符都不能动。
|
|
2789
|
+
const handle = handleByLine.get(n) ?? null
|
|
2790
|
+
if (raw.length <= MATCH_LINE_CHARS) return withHandleOnLine(`${n}: ${raw}`, handle)
|
|
2720
2791
|
const cut = raw.length - MATCH_LINE_CHARS
|
|
2721
2792
|
// 形状与 `windowEnvelope` 的 `truncated` **完全一致**({line, shown, omittedChars}):
|
|
2722
2793
|
// 第一版这里给的是 `{line, omittedChars}` 的数组,而 `windowEnvelope` 给的是对象——
|
|
@@ -2852,59 +2923,59 @@ function windowEnvelope(
|
|
|
2852
2923
|
* (见 {@link SCAN} / {@link planScan})。它只往下、不越过文件末尾、永远包含请求区间,
|
|
2853
2924
|
* 因此 `lines` 的既有语义(请求的那几行一定在里面)没有被改。
|
|
2854
2925
|
*/
|
|
2855
|
-
const limit = scan !== null && scan.extend === true ? Math.max(to, total) : to
|
|
2856
|
-
const rows = []
|
|
2857
|
-
let chars = 0
|
|
2858
|
-
let lostFrom = null
|
|
2859
|
-
let truncated = null
|
|
2860
|
-
/**
|
|
2861
|
-
* 句柄要落在**窗口的头行**上(契约 §4),而头行是哪一行由预算决定——
|
|
2862
|
-
* 所以预留不能拍死:先按 `from` 算一份,等 `deliveredTo` 定下来再换真的那一份。
|
|
2863
|
-
*
|
|
2864
|
-
* 预留必须**在循环里**算:句柄是行的一部分,把它排除在预算外,
|
|
2865
|
-
* 就会出现"行放下了、句柄被拆掉"的静默半答案。
|
|
2866
|
-
*/
|
|
2867
|
-
const reserve = handleContext === null ? 0 : handleInlineChars(from, from)
|
|
2868
|
-
for (let n = from; n <= limit; n += 1) {
|
|
2869
|
-
const raw = lines[n - 1] ?? ''
|
|
2870
|
-
const rendered = `${n}: ${raw}`
|
|
2871
|
-
const cost = rendered.length + 1 + (rows.length === 0 ? reserve : 0)
|
|
2872
|
-
if (chars + cost <= base.contentBudget) {
|
|
2873
|
-
chars += cost
|
|
2874
|
-
rows.push(rendered)
|
|
2875
|
-
continue
|
|
2876
|
-
}
|
|
2877
|
-
// 放不下。若这是窗口的第一行,就不能空手而归——给一段并标注截断。
|
|
2878
|
-
if (rows.length === 0) {
|
|
2879
|
-
// `suffix` 是行号之外还要留的说明空间:截断标记本身也要算进预算,
|
|
2880
|
-
// 否则"标注"会把内容顶出预算,重演同一个问题。
|
|
2881
|
-
const suffix = ' ⋯ [truncated]'
|
|
2882
|
-
const budget = base.contentBudget - reserve
|
|
2883
|
-
const take = Math.max(0, budget - suffix.length - `${n}: `.length)
|
|
2884
|
-
if (take > 0) {
|
|
2885
|
-
rows.push(`${n}: ${raw.slice(0, take)}${suffix}`)
|
|
2886
|
-
truncated = { line: n, shown: take, omittedChars: raw.length - take }
|
|
2887
|
-
}
|
|
2888
|
-
}
|
|
2889
|
-
lostFrom = n
|
|
2890
|
-
break
|
|
2926
|
+
const limit = scan !== null && scan.extend === true ? Math.max(to, total) : to
|
|
2927
|
+
const rows = []
|
|
2928
|
+
let chars = 0
|
|
2929
|
+
let lostFrom = null
|
|
2930
|
+
let truncated = null
|
|
2931
|
+
/**
|
|
2932
|
+
* 句柄要落在**窗口的头行**上(契约 §4),而头行是哪一行由预算决定——
|
|
2933
|
+
* 所以预留不能拍死:先按 `from` 算一份,等 `deliveredTo` 定下来再换真的那一份。
|
|
2934
|
+
*
|
|
2935
|
+
* 预留必须**在循环里**算:句柄是行的一部分,把它排除在预算外,
|
|
2936
|
+
* 就会出现"行放下了、句柄被拆掉"的静默半答案。
|
|
2937
|
+
*/
|
|
2938
|
+
const reserve = handleContext === null ? 0 : handleInlineChars(from, from)
|
|
2939
|
+
for (let n = from; n <= limit; n += 1) {
|
|
2940
|
+
const raw = lines[n - 1] ?? ''
|
|
2941
|
+
const rendered = `${n}: ${raw}`
|
|
2942
|
+
const cost = rendered.length + 1 + (rows.length === 0 ? reserve : 0)
|
|
2943
|
+
if (chars + cost <= base.contentBudget) {
|
|
2944
|
+
chars += cost
|
|
2945
|
+
rows.push(rendered)
|
|
2946
|
+
continue
|
|
2947
|
+
}
|
|
2948
|
+
// 放不下。若这是窗口的第一行,就不能空手而归——给一段并标注截断。
|
|
2949
|
+
if (rows.length === 0) {
|
|
2950
|
+
// `suffix` 是行号之外还要留的说明空间:截断标记本身也要算进预算,
|
|
2951
|
+
// 否则"标注"会把内容顶出预算,重演同一个问题。
|
|
2952
|
+
const suffix = ' ⋯ [truncated]'
|
|
2953
|
+
const budget = base.contentBudget - reserve
|
|
2954
|
+
const take = Math.max(0, budget - suffix.length - `${n}: `.length)
|
|
2955
|
+
if (take > 0) {
|
|
2956
|
+
rows.push(`${n}: ${raw.slice(0, take)}${suffix}`)
|
|
2957
|
+
truncated = { line: n, shown: take, omittedChars: raw.length - take }
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
lostFrom = n
|
|
2961
|
+
break
|
|
2891
2962
|
}
|
|
2892
2963
|
const kept = rows.length
|
|
2893
2964
|
/** 实际交付到的最后一行;“已经读到哪”这句话的唯一来源。 */
|
|
2894
2965
|
const deliveredTo = kept === 0 ? from - 1 : from + kept - 1
|
|
2895
|
-
const budgetCut = lostFrom !== null && lostFrom <= limit
|
|
2896
|
-
/**
|
|
2897
|
-
* 头行现在是确定的,句柄就在这里真的接上去(第二轮)。
|
|
2898
|
-
*
|
|
2899
|
-
* 为什么不在循环里直接接:头行上那个句柄的**结束行**就是 `deliveredTo`,而它要到
|
|
2900
|
-
* 循环跑完才知道(预算可能砍在中间、顺扫可能把窗口拉长)。用 `from` 硬编出来的是
|
|
2901
|
-
* 一个**指错区间**的地址——那比不发还坏。
|
|
2902
|
-
*/
|
|
2903
|
-
const headHandle =
|
|
2904
|
-
handleContext === null || kept === 0
|
|
2905
|
-
? null
|
|
2906
|
-
: handleFor(handleContext.ledgerKey, lines, handleContext.byteOrderMark, from, deliveredTo)
|
|
2907
|
-
const rowsWithHandle = headHandle === null ? rows : [withHandleOnLine(rows[0], headHandle), ...rows.slice(1)]
|
|
2966
|
+
const budgetCut = lostFrom !== null && lostFrom <= limit
|
|
2967
|
+
/**
|
|
2968
|
+
* 头行现在是确定的,句柄就在这里真的接上去(第二轮)。
|
|
2969
|
+
*
|
|
2970
|
+
* 为什么不在循环里直接接:头行上那个句柄的**结束行**就是 `deliveredTo`,而它要到
|
|
2971
|
+
* 循环跑完才知道(预算可能砍在中间、顺扫可能把窗口拉长)。用 `from` 硬编出来的是
|
|
2972
|
+
* 一个**指错区间**的地址——那比不发还坏。
|
|
2973
|
+
*/
|
|
2974
|
+
const headHandle =
|
|
2975
|
+
handleContext === null || kept === 0
|
|
2976
|
+
? null
|
|
2977
|
+
: handleFor(handleContext.ledgerKey, lines, handleContext.byteOrderMark, from, deliveredTo)
|
|
2978
|
+
const rowsWithHandle = headHandle === null ? rows : [withHandleOnLine(rows[0], headHandle), ...rows.slice(1)]
|
|
2908
2979
|
const out = {
|
|
2909
2980
|
...base,
|
|
2910
2981
|
mode: base.mode,
|
|
@@ -2920,11 +2991,11 @@ function windowEnvelope(
|
|
|
2920
2991
|
//
|
|
2921
2992
|
// 覆盖的区间是**实际交付的**那一段(预算截断与顺扫延伸都算进去),而不是请求里那个 N:
|
|
2922
2993
|
// 调用方拿到柄之后要编辑的是"它手里那段字节",不是"它当初点名要的那几行"。
|
|
2923
|
-
// 两者在预算截断时会分叉,而分叉后一个指得到、一个指不到——指不到的那个是错的。
|
|
2924
|
-
// 与接在头行上的那一个**是同一个值**(`headHandle` 只算一次):两处各算一次的话,
|
|
2925
|
-
// 以后任何一处改了口径,结构化字段与模型看到的文本就会静默地对不上。
|
|
2926
|
-
...(headHandle === null ? {} : { handle: headHandle }),
|
|
2927
|
-
text: rowsWithHandle.join('\n'),
|
|
2994
|
+
// 两者在预算截断时会分叉,而分叉后一个指得到、一个指不到——指不到的那个是错的。
|
|
2995
|
+
// 与接在头行上的那一个**是同一个值**(`headHandle` 只算一次):两处各算一次的话,
|
|
2996
|
+
// 以后任何一处改了口径,结构化字段与模型看到的文本就会静默地对不上。
|
|
2997
|
+
...(headHandle === null ? {} : { handle: headHandle }),
|
|
2998
|
+
text: rowsWithHandle.join('\n'),
|
|
2928
2999
|
}
|
|
2929
3000
|
const omissions = []
|
|
2930
3001
|
if (truncated !== null) {
|