@geoly-ai/skills-hub 0.3.5 → 0.3.6
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/package.json +1 -1
- package/src/artifact.mjs +19 -1
- package/src/cli.mjs +9 -2
- package/src/commands/context.mjs +45 -0
- package/src/commands/install.mjs +13 -3
- package/src/commands/output.mjs +7 -1
- package/src/commands/remove.mjs +10 -3
- package/src/commands/update.mjs +8 -3
- package/src/target.mjs +267 -25
package/package.json
CHANGED
package/src/artifact.mjs
CHANGED
|
@@ -181,7 +181,25 @@ export function verifyAndExtract({ bytes, record, parent = tmpdir() }) {
|
|
|
181
181
|
|
|
182
182
|
const SKILL_MANIFEST_KEYS = {
|
|
183
183
|
required: ['schema', 'kind', 'namespace', 'name', 'version', 'description', 'license',
|
|
184
|
-
'clients', 'capabilities', 'replaces', 'conflicts'
|
|
184
|
+
'clients', 'capabilities', 'replaces', 'conflicts'],
|
|
185
|
+
// 🔴 **`provenance` 从必填改成可选**(2026-09-05 用户拍板)。
|
|
186
|
+
//
|
|
187
|
+
// 原因是它构成一个**时间循环**:`submitted_by_pr` 必须等于真实 PR 号,
|
|
188
|
+
// 而 PR 号只有开了 PR 才知道 —— 于是投稿者被要求在开 PR **之前**
|
|
189
|
+
// 写进一个开 PR 之后才存在的值。实际做法是「先开 PR → 回填 → 强推同一分支」,
|
|
190
|
+
// 而漏回填的投稿会**先进 main、再在 promote 卡死**。
|
|
191
|
+
//
|
|
192
|
+
// ⚠️ 更能说明问题的是它与 `PROMOTION.json` **自相矛盾**:那边明确
|
|
193
|
+
// **拒绝**投稿者声明 `author_github_id` / `submitted_by_pr`
|
|
194
|
+
// (理由:投稿者只能声明只有他知道的事),而这边却要求他自己写同样两个字段。
|
|
195
|
+
// 同一个仓库里两套相反的规则 —— 我自己在两天里踩了它两次。
|
|
196
|
+
//
|
|
197
|
+
// 现在:缺省即由 promote 填(与 pack 一致);**若投稿者写了,仍然逐字核对**
|
|
198
|
+
// (`assertProvenanceMatchesPr`)—— 不静默改写,写错了要有人看见。
|
|
199
|
+
//
|
|
200
|
+
// 🔴 改成 optional 而不是删掉:已发布的 5 张快照里的制品**都带着这个字段**,
|
|
201
|
+
// 删掉会让它们验不过。可选是唯一向后兼容的形状。
|
|
202
|
+
optional: ['provenance'],
|
|
185
203
|
};
|
|
186
204
|
|
|
187
205
|
const PACK_MANIFEST_KEYS = {
|
package/src/cli.mjs
CHANGED
|
@@ -46,9 +46,16 @@ const HELP = `skills-hub —— geoly skill 分发(M1 + M2 的命令面)
|
|
|
46
46
|
--no-bundled / --pre / --json / --yes
|
|
47
47
|
--yes-i-really-want-everything 仅 --all 在非交互下使用(--yes 不够)
|
|
48
48
|
--keep-generations <N> attic 保留代数,默认 3
|
|
49
|
+
--scan-max-depth <N> 嵌套 target 预检的扫描深度预算,默认 64(硬顶 1024)
|
|
50
|
+
--scan-max-dirs <N> 同上,目录数预算,默认 100000(硬顶 5000000)
|
|
51
|
+
报 target.nested-scan-incomplete 时,错误里会点名撞的是**哪一个**上限;
|
|
52
|
+
只有那一条说「深度上限到顶」/「目录数上限用尽」时抬这里才有用。
|
|
53
|
+
说「读不进去」的那种是权限问题,抬预算没有用。
|
|
49
54
|
|
|
50
|
-
🔴 没有 --no-verify、--insecure、--force、--force-unlock、--assume-idle
|
|
51
|
-
|
|
55
|
+
🔴 没有 --no-verify、--insecure、--force、--force-unlock、--assume-idle、
|
|
56
|
+
--skip-nested-scan。验签与摘要校验不可关闭;替换必须点名;
|
|
57
|
+
陈旧/yank/全量各有独立开关;扫描预算只能**抬高**,不能关掉 ——
|
|
58
|
+
关掉它就是把「无法证明没有嵌套 target」改写成「假设没有」(04-install.md §3.5)。
|
|
52
59
|
|
|
53
60
|
埋点(docs/telemetry/00-spec.md):
|
|
54
61
|
🔴 上报**默认开**(有内置默认端点)。**install 成功收尾后**会静默发一次
|
package/src/commands/context.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { homedir, platform } from 'node:os';
|
|
|
16
16
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
17
|
import { isAbsolute, resolve as resolvePath } from 'node:path';
|
|
18
18
|
import { UsageError, UnsupportedError } from '../exit-codes.mjs';
|
|
19
|
+
import { SCAN_CEILINGS } from '../target.mjs';
|
|
19
20
|
|
|
20
21
|
/** §2 那张表。`arg: true` = 后面跟一个值。 */
|
|
21
22
|
const GLOBAL_FLAGS = Object.freeze({
|
|
@@ -31,6 +32,8 @@ const GLOBAL_FLAGS = Object.freeze({
|
|
|
31
32
|
'--no-bundled': { arg: false },
|
|
32
33
|
'--freeze-attic': { arg: true },
|
|
33
34
|
'--keep-generations': { arg: true },
|
|
35
|
+
'--scan-max-depth': { arg: true },
|
|
36
|
+
'--scan-max-dirs': { arg: true },
|
|
34
37
|
'--json': { arg: false },
|
|
35
38
|
'--yes': { arg: false },
|
|
36
39
|
'--yes-i-really-want-everything': { arg: false },
|
|
@@ -51,6 +54,12 @@ const REMOVED_FLAGS = Object.freeze({
|
|
|
51
54
|
'--clear-lock': '同 --force-unlock:协议里没有任何 unlink,也就没有可清的东西(§5.1)。',
|
|
52
55
|
'--assume-idle':
|
|
53
56
|
'本工具**不检测也不阻断**正在运行的 agent(D5,04-install.md §9),所以没有可假设的东西。',
|
|
57
|
+
'--skip-nested-scan':
|
|
58
|
+
'嵌套 target 扫描**不能关闭**(04-install.md §3.5)。关掉它是把「无法证明没有嵌套」'
|
|
59
|
+
+ '改写成「假设没有嵌套」,而外层替换会连内层的 .geoly/ 一起搬走、两把锁互不相识。\n'
|
|
60
|
+
+ '扫描超预算时报错里会点名撞的是哪个上限:深度/目录数用 `--scan-max-depth <N>` / '
|
|
61
|
+
+ '`--scan-max-dirs <N>` 抬高(仍然是真扫描、仍然 fail-closed);'
|
|
62
|
+
+ '说「读不进去」的那种是权限问题,抬预算没有用。',
|
|
54
63
|
'--allow-pending':
|
|
55
64
|
'Q12 是阻塞门,不是建议。要开某一格就补那一格的实测证据(docs/m1/01-residual-risks.md R-4)。',
|
|
56
65
|
});
|
|
@@ -152,6 +161,18 @@ function applyGlobal(g, name, val) {
|
|
|
152
161
|
case '--no-bundled': g.noBundled = true; break;
|
|
153
162
|
case '--freeze-attic': g.freezeAttic = val; break;
|
|
154
163
|
case '--keep-generations': g.keepGenerations = uintArg(name, val); break;
|
|
164
|
+
// 🔴 预检的**预算旋钮**,不是关闭开关。抬高它仍然是一次真扫描、仍然 fail-closed;
|
|
165
|
+
// 抬到硬顶还不够就照旧拒绝。故意**不提供** `--skip-nested-scan`:
|
|
166
|
+
// 那会把「无法证明没有嵌套」改成「假设没有嵌套」,正是 §3.5 要防的事
|
|
167
|
+
// (同 REMOVED_FLAGS 里 --force / --no-verify 的理由)。
|
|
168
|
+
// 上界由 target.mjs 的 SCAN_CEILINGS 兜底,超了报错而不是静默截断。
|
|
169
|
+
// 🔴 硬顶要在**解析期**就拒,不能等到预检里才抛:那时 install 已经
|
|
170
|
+
// 建过 target/.geoly、取过锁,而 target.mjs 抛的是普通 Error ——
|
|
171
|
+
// 会被 classify 判成 unclassified(退出码 2「完整性失败」),
|
|
172
|
+
// 把一个「参数超上限」说成「制品坏了」。解析期拒 = 零磁盘副作用 + 退出码 1。
|
|
173
|
+
// (target.mjs 里那道保留为纵深防御:库调用方不经过本文件。)
|
|
174
|
+
case '--scan-max-depth': g.scanMaxDepth = scanArg(name, val, 'maxDepth'); break;
|
|
175
|
+
case '--scan-max-dirs': g.scanMaxDirs = scanArg(name, val, 'maxDirs'); break;
|
|
155
176
|
case '--json': g.json = true; break;
|
|
156
177
|
case '--yes': g.yes = true; break;
|
|
157
178
|
case '--yes-i-really-want-everything': g.yesEverything = true; break;
|
|
@@ -160,6 +181,22 @@ function applyGlobal(g, name, val) {
|
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
183
|
|
|
184
|
+
/**
|
|
185
|
+
* 扫描预算旋钮:非负整数 **且** 不超过 `target.mjs` 的硬顶。
|
|
186
|
+
* 🔴 硬顶的**唯一出处**是 `SCAN_CEILINGS` —— 这里不复制一份数字,
|
|
187
|
+
* 复制的那份迟早会跟本体漂。
|
|
188
|
+
*/
|
|
189
|
+
function scanArg(name, val, key) {
|
|
190
|
+
const n = uintArg(name, val);
|
|
191
|
+
if (n > SCAN_CEILINGS[key]) {
|
|
192
|
+
throw new UsageError(
|
|
193
|
+
`${name} 超过硬顶 ${SCAN_CEILINGS[key]}(收到 ${n})。`
|
|
194
|
+
+ '预算旋钮不是关闭开关:无上限的遍历等于没有资源闸(04-install.md §3.5.1)。',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return n;
|
|
198
|
+
}
|
|
199
|
+
|
|
163
200
|
/** 11-wire-contract.md §2:数字只允许非负整数,不允许前导零、浮点、指数、`-0`。 */
|
|
164
201
|
export function uintArg(name, val) {
|
|
165
202
|
if (!/^(0|[1-9]\d*)$/.test(val)) {
|
|
@@ -263,6 +300,14 @@ export function makeContext(globals, deps = {}) {
|
|
|
263
300
|
* 照样可读,但那不是「用户正坐在终端前逐条看名单」。
|
|
264
301
|
*/
|
|
265
302
|
stdin: deps.stdin ?? process.stdin,
|
|
303
|
+
/**
|
|
304
|
+
* 预检的扫描预算,直接喂给 `precheckTarget({ scan })`。
|
|
305
|
+
* 🔴 没给旋钮时这两项是 `undefined` —— `normalizeScan` 把 `undefined` 读成
|
|
306
|
+
* 「用默认值」。**不要**在这里填上默认值:填了以后默认值就有了两个出处
|
|
307
|
+
* (这里一份、target.mjs 一份),改一处漏一处的那种漂移正好发生在
|
|
308
|
+
* 「上限是多少」这个用户会照着报错去调的数上。
|
|
309
|
+
*/
|
|
310
|
+
scan: Object.freeze({ maxDepth: globals.scanMaxDepth, maxDirs: globals.scanMaxDirs }),
|
|
266
311
|
});
|
|
267
312
|
return ctx;
|
|
268
313
|
}
|
package/src/commands/install.mjs
CHANGED
|
@@ -684,7 +684,13 @@ export async function cmdInstall(ctx, argv, out) {
|
|
|
684
684
|
const t = byPath.get(o.path);
|
|
685
685
|
const started = Date.now();
|
|
686
686
|
try {
|
|
687
|
-
const r = installOneTarget(ctx, t, perClient.get(t.client), {
|
|
687
|
+
const r = installOneTarget(ctx, t, perClient.get(t.client), {
|
|
688
|
+
snap, floor, pinned, out, verifier,
|
|
689
|
+
// 🔴 §3.5 识别范围 ① 是「本次命令的**全部** target」,不是「自己」。
|
|
690
|
+
// 只传自己会漏掉「两个 target 互相嵌套、但内层还没有 .geoly 状态」——
|
|
691
|
+
// 那正是首次安装时的形状(内层的状态目录还没建出来)。
|
|
692
|
+
targetSet: selected.map((s) => s.target),
|
|
693
|
+
});
|
|
688
694
|
results.push({
|
|
689
695
|
...r, client: t.client, scope: t.scope, target: t.target, ok: true, ms: Date.now() - started,
|
|
690
696
|
});
|
|
@@ -754,7 +760,7 @@ export async function cmdInstall(ctx, argv, out) {
|
|
|
754
760
|
}
|
|
755
761
|
|
|
756
762
|
/** 单个 target 的第 2–10 步。 */
|
|
757
|
-
function installOneTarget(ctx, t, { units, rootSpecs, packInfos }, { snap, floor, out, verifier }) {
|
|
763
|
+
function installOneTarget(ctx, t, { units, rootSpecs, packInfos }, { snap, floor, out, verifier, targetSet }) {
|
|
758
764
|
const target = t.target;
|
|
759
765
|
const P0 = layout(target);
|
|
760
766
|
const onLedgerChanged = makeLockfileHook(ctx, { snap, verifier });
|
|
@@ -764,7 +770,11 @@ function installOneTarget(ctx, t, { units, rootSpecs, packInfos }, { snap, floor
|
|
|
764
770
|
if (rec.outcome !== 'nothing') out.note(`${t.client}:入口分流 —— ${rec.outcome}`);
|
|
765
771
|
|
|
766
772
|
// ── 第 3 步:预检(🔴 assertPrecheckOk 必须调)─────────────────────────
|
|
767
|
-
const pre = precheckTarget(target, {
|
|
773
|
+
const pre = precheckTarget(target, {
|
|
774
|
+
base: t.base,
|
|
775
|
+
targetSet: targetSet ?? [target],
|
|
776
|
+
scan: ctx.scan,
|
|
777
|
+
});
|
|
768
778
|
assertPrecheckOk(pre);
|
|
769
779
|
|
|
770
780
|
// ── 第 4 步:取字节 → 验签/验资产/解包/manifest 绑定 ────────────────────
|
package/src/commands/output.mjs
CHANGED
|
@@ -116,8 +116,14 @@ export class Output {
|
|
|
116
116
|
message,
|
|
117
117
|
unclassified: cls.unclassified,
|
|
118
118
|
// 预检聚合错带全部违规项 —— 🔴 JSON 里**始终保留全部**,不只报优先级最高那条
|
|
119
|
+
// 🔴 `detail` 必须一起出:它是**给机器读的那一半**(嵌套 target 的 relation/via、
|
|
120
|
+
// 扫描没跑完时撞的是哪个上限、实际值、样例路径)。只留 message 等于逼
|
|
121
|
+
// 调用方去正则解析中文文案 —— 那正是 `detail` 存在的理由。
|
|
122
|
+
// ⚠️ 白名单式挑字段是对的,别改成整个 `...v` 透传。
|
|
119
123
|
violations: Array.isArray(err?.violations)
|
|
120
|
-
? err.violations.map((v) => ({
|
|
124
|
+
? err.violations.map((v) => pruneUndefined({
|
|
125
|
+
code: v.code, message: v.message, path: v.path, detail: v.detail,
|
|
126
|
+
}))
|
|
121
127
|
: undefined,
|
|
122
128
|
candidates: Array.isArray(err?.candidates) ? err.candidates : undefined,
|
|
123
129
|
}),
|
package/src/commands/remove.mjs
CHANGED
|
@@ -204,7 +204,10 @@ export async function cmdRemove(ctx, argv, out) {
|
|
|
204
204
|
const pv = byPath.get(o.path);
|
|
205
205
|
const started = Date.now();
|
|
206
206
|
try {
|
|
207
|
-
|
|
207
|
+
// 🔴 §3.5 识别范围 ① 要「本次命令的**全部** target」(见 install.mjs 同处)
|
|
208
|
+
const r = removeOneTarget(ctx, pv, {
|
|
209
|
+
name, hook, out, targetSet: previews.map((x) => x.t.target),
|
|
210
|
+
});
|
|
208
211
|
results.push({ ...r, client: pv.t.client, ok: true, ms: Date.now() - started, scope: pv.t.scope, target: pv.t.target });
|
|
209
212
|
} catch (err) {
|
|
210
213
|
const cls = classify(err);
|
|
@@ -270,7 +273,7 @@ function fingerprintOf(p) {
|
|
|
270
273
|
}
|
|
271
274
|
|
|
272
275
|
/** 单个 target 的第 2–10 步。🔴 全同步 —— 它在锁与事务里面,不能 await。 */
|
|
273
|
-
function removeOneTarget(ctx, pv, { name, hook, out }) {
|
|
276
|
+
function removeOneTarget(ctx, pv, { name, hook, out, targetSet }) {
|
|
274
277
|
const target = pv.t.target;
|
|
275
278
|
const P0 = layout(target);
|
|
276
279
|
|
|
@@ -279,7 +282,11 @@ function removeOneTarget(ctx, pv, { name, hook, out }) {
|
|
|
279
282
|
if (rec.outcome !== 'nothing') out.note(`${pv.t.client}:入口分流 —— ${rec.outcome}`);
|
|
280
283
|
|
|
281
284
|
// ── 第 3 步:预检 ───────────────────────────────────────────────────────
|
|
282
|
-
const pre = precheckTarget(target, {
|
|
285
|
+
const pre = precheckTarget(target, {
|
|
286
|
+
base: pv.t.base,
|
|
287
|
+
targetSet: targetSet ?? [target],
|
|
288
|
+
scan: ctx.scan,
|
|
289
|
+
});
|
|
283
290
|
assertPrecheckOk(pre);
|
|
284
291
|
|
|
285
292
|
// 🔴 **锁内重读并重算**:预览是在没有任何锁的情况下读的,从那时到现在
|
package/src/commands/update.mjs
CHANGED
|
@@ -553,7 +553,10 @@ export async function cmdUpdate(ctx, argv, out) {
|
|
|
553
553
|
const p = byPath.get(o.path);
|
|
554
554
|
const started = Date.now();
|
|
555
555
|
try {
|
|
556
|
-
|
|
556
|
+
// 🔴 §3.5 识别范围 ① 要「本次命令的**全部** target」,不是「自己」(见 install.mjs 同处)
|
|
557
|
+
const r = updateOneTarget(ctx, p, {
|
|
558
|
+
at, floor, hook, out, snap, targetSet: previews.map((q) => q.t.target),
|
|
559
|
+
});
|
|
557
560
|
results.push({ ...r, client: p.t.client, ms: Date.now() - started, ok: true, scope: p.t.scope, target: p.t.target });
|
|
558
561
|
} catch (err) {
|
|
559
562
|
const cls = classify(err);
|
|
@@ -645,7 +648,7 @@ export function namesNeedingBytes(target, g) {
|
|
|
645
648
|
}
|
|
646
649
|
|
|
647
650
|
/** 单个 target 的第 2–10 步。🔴 全同步 —— 它在锁与事务里面,不能 await。 */
|
|
648
|
-
function updateOneTarget(ctx, p, { at, floor, hook, out, snap }) {
|
|
651
|
+
function updateOneTarget(ctx, p, { at, floor, hook, out, snap, targetSet }) {
|
|
649
652
|
const target = p.t.target;
|
|
650
653
|
const P0 = layout(target);
|
|
651
654
|
|
|
@@ -654,7 +657,9 @@ function updateOneTarget(ctx, p, { at, floor, hook, out, snap }) {
|
|
|
654
657
|
if (rec.outcome !== 'nothing') out.note(`${p.t.client}:入口分流 —— ${rec.outcome}`);
|
|
655
658
|
|
|
656
659
|
// ── 第 3 步:预检 ───────────────────────────────────────────────────────
|
|
657
|
-
assertPrecheckOk(
|
|
660
|
+
assertPrecheckOk(
|
|
661
|
+
precheckTarget(target, { base: p.t.base, targetSet: targetSet ?? [target], scan: ctx.scan }),
|
|
662
|
+
);
|
|
658
663
|
|
|
659
664
|
// 🔴 **锁内重读重算,比语义指纹**。预览是在没有任何锁的时候读的;
|
|
660
665
|
// 从那时到现在,另一个进程(乃至上面那次 recover)完全可以改掉这张图。
|
package/src/target.mjs
CHANGED
|
@@ -111,8 +111,42 @@ const STATE_MARKER_FILES = [
|
|
|
111
111
|
];
|
|
112
112
|
const STATE_MARKER_DIRS = ['journal', 'attic', 'quarantine', 'audit-archive'];
|
|
113
113
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
114
|
+
/**
|
|
115
|
+
* 扫描预算。
|
|
116
|
+
*
|
|
117
|
+
* 🔴 **这三个数在规格里没有出处**(§3.4 / §3.5 只说「拒绝嵌套 target」、
|
|
118
|
+
* 「状态路径逐个 lstat」,一个数字都没写)。所以它们是**实现的资源闸**,
|
|
119
|
+
* 不是规则本身 —— 定得太紧不会更安全,只会把「证明不了」当成常态。
|
|
120
|
+
*
|
|
121
|
+
* 旧值 `maxDepth: 8` 就是这么栽的:一个真实的 `~/.claude/skills`(657 个目录)
|
|
122
|
+
* 里只要有**一个** skill vendored 了一个仓库,深度就到 12,
|
|
123
|
+
* 于是每一次 install 都报 `target.nested-scan-incomplete` 而退出 —— 装不上任何东西。
|
|
124
|
+
* 实测:那棵树扫完只要 17ms、573/657 个目录,`maxDirs: 5000` 连一半都没碰到。
|
|
125
|
+
*
|
|
126
|
+
* 🔴 **成本是 O(目录数),与深度无关。** 所以:
|
|
127
|
+
* · `maxDirs` / `maxEntries` 才是真正的**预算闸**(它们限制的是工作量);
|
|
128
|
+
* · `maxDepth` 只是一个**防病态路径的 sanity guard**(PATH_MAX 量级),
|
|
129
|
+
* 不该拿来当预算 —— 拿它当预算就是按一个与成本无关的量收费。
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ `maxEntries` 补的是 `maxDirs` 的漏:一个目录**下** 500 万个条目
|
|
132
|
+
* 只算 1 个 visited,`maxDirs` 完全拦不住。(它仍然拦不住**单次** `readdirSync`
|
|
133
|
+
* 的内存尖峰 —— 那要 `opendirSync` 流式读才行,见文件末尾的「明确没做」。)
|
|
134
|
+
*/
|
|
135
|
+
const SCAN_DEFAULTS = Object.freeze({ maxDepth: 64, maxDirs: 100_000, maxEntries: 1_000_000 });
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 🔴 预算旋钮必须有**硬顶**。否则 `--scan-max-dirs 9007199254740991` 就等于
|
|
139
|
+
* 把有界遍历改回无界 —— 那是把资源闸删掉,而不是「用户自己负责」。
|
|
140
|
+
* 撞到硬顶要**报错**,不能静默截断成硬顶:静默截断会让用户以为他给的预算生效了。
|
|
141
|
+
*/
|
|
142
|
+
const SCAN_CEILINGS = Object.freeze({
|
|
143
|
+
maxDepth: 1024, // 比任何文件系统的 PATH_MAX 能容下的层数都宽
|
|
144
|
+
maxDirs: 5_000_000,
|
|
145
|
+
maxEntries: 50_000_000,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** 每一类「没扫完」最多留几条样例路径。样例只用来指路,不是清单。 */
|
|
149
|
+
const SAMPLE_MAX = 5;
|
|
116
150
|
|
|
117
151
|
/**
|
|
118
152
|
* 🔴 扫描上限必须校验。`NaN` 参与 `>=` 永远是 false ——
|
|
@@ -126,14 +160,24 @@ function normalizeScan(scan = {}) {
|
|
|
126
160
|
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
|
|
127
161
|
throw new Error(`scan.${name} 必须是有限的非负数,收到 ${JSON.stringify(v)}`);
|
|
128
162
|
}
|
|
129
|
-
|
|
163
|
+
const n = Math.floor(v);
|
|
164
|
+
if (n > SCAN_CEILINGS[name]) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`scan.${name} 超过硬顶 ${SCAN_CEILINGS[name]}(收到 ${n})。` +
|
|
167
|
+
'预算旋钮不是关闭开关:无上限的遍历等于没有资源闸。',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return n;
|
|
130
171
|
};
|
|
131
172
|
return {
|
|
132
173
|
maxDepth: pick(scan.maxDepth, SCAN_DEFAULTS.maxDepth, 'maxDepth'),
|
|
133
174
|
maxDirs: pick(scan.maxDirs, SCAN_DEFAULTS.maxDirs, 'maxDirs'),
|
|
175
|
+
maxEntries: pick(scan.maxEntries, SCAN_DEFAULTS.maxEntries, 'maxEntries'),
|
|
134
176
|
};
|
|
135
177
|
}
|
|
136
178
|
|
|
179
|
+
export { SCAN_DEFAULTS, SCAN_CEILINGS };
|
|
180
|
+
|
|
137
181
|
// ── 有效状态判定 ─────────────────────────────────────────────────────────────
|
|
138
182
|
|
|
139
183
|
/**
|
|
@@ -186,7 +230,7 @@ const isUnder = (child, parent) => {
|
|
|
186
230
|
* 绝不按名字扫任意后代的 `.claude/skills` —— 那会误伤普通目录。
|
|
187
231
|
*/
|
|
188
232
|
export function findNestedTargets(targetPath, { targetSet = [], scan = {} } = {}) {
|
|
189
|
-
const { maxDepth, maxDirs } = normalizeScan(scan);
|
|
233
|
+
const { maxDepth, maxDirs, maxEntries } = normalizeScan(scan);
|
|
190
234
|
const self = tryRealpath(targetPath);
|
|
191
235
|
const hits = [];
|
|
192
236
|
const seen = new Set();
|
|
@@ -217,35 +261,105 @@ export function findNestedTargets(targetPath, { targetSet = [], scan = {} } = {}
|
|
|
217
261
|
}
|
|
218
262
|
|
|
219
263
|
// ②b 向下:有界遍历,找带有效状态的后代
|
|
220
|
-
const scanResult = walkBounded(self, { maxDepth, maxDirs }, (dir) => {
|
|
264
|
+
const scanResult = walkBounded(self, { maxDepth, maxDirs, maxEntries }, (dir) => {
|
|
221
265
|
if (dir !== self && hasGeolyState(dir)) add(dir, 'descendant', 'geoly-state');
|
|
222
266
|
});
|
|
223
267
|
|
|
224
|
-
return { nested: hits,
|
|
268
|
+
return { nested: hits, ...scanResult };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── 「没扫完」的原因记账 ─────────────────────────────────────────────────────
|
|
272
|
+
//
|
|
273
|
+
// 🔴 一条**不告诉你撞的是哪个上限**的报错,指导不了任何行动。
|
|
274
|
+
// 旧版把「深度到顶」「目录数超限」「读不进去」三种原因塞进同一句
|
|
275
|
+
// 「深度上限 8 / 目录数上限 5000」里,用户读完既不知道该提哪个旋钮、
|
|
276
|
+
// 也不知道是不是权限问题。所以原因必须是**结构化的事实**,不是一句话。
|
|
277
|
+
//
|
|
278
|
+
// 🔴 不变式:`complete === false` ⇒ `stops` 里至少有一项非空。
|
|
279
|
+
// 一个说不出原因的 incomplete 等于「我拒绝了但我不知道为什么」,
|
|
280
|
+
// 那种拒绝没法被修,也没法被审计。`assertAttributable` 在返回前兜住它。
|
|
281
|
+
|
|
282
|
+
function newStops() {
|
|
283
|
+
return {
|
|
284
|
+
depth: { count: 0, samples: [] }, // 深度到顶、但下面还有目录
|
|
285
|
+
unreadable: { count: 0, samples: [] }, // EACCES/EPERM 等:有东西但看不了
|
|
286
|
+
dirs: null, // 目录数预算耗尽时正要处理的那个路径
|
|
287
|
+
entries: null, // 目录项预算耗尽时正要处理的那个路径
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 样例只用来指路,不是清单:留前 `SAMPLE_MAX` 条,但**总数照记**。 */
|
|
292
|
+
function sample(bucket, value) {
|
|
293
|
+
bucket.count += 1;
|
|
294
|
+
if (bucket.samples.length < SAMPLE_MAX) bucket.samples.push(value);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 🔴 样例要**确定性**:遍历用的是 LIFO 栈,同一棵树在不同 Node 版本/不同
|
|
299
|
+
* `readdir` 返回序下拿到的前 5 条可以不一样。不排序的话,报错文案与
|
|
300
|
+
* `--json` 输出就成了不可复现的东西 —— 用户贴给我们的两次输出对不上。
|
|
301
|
+
*/
|
|
302
|
+
function finalizeStops(stops) {
|
|
303
|
+
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
304
|
+
return {
|
|
305
|
+
depth: { count: stops.depth.count, samples: [...stops.depth.samples].sort() },
|
|
306
|
+
unreadable: {
|
|
307
|
+
count: stops.unreadable.count,
|
|
308
|
+
samples: [...stops.unreadable.samples].sort(byPath),
|
|
309
|
+
},
|
|
310
|
+
dirs: stops.dirs,
|
|
311
|
+
entries: stops.entries,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** `complete:false` 却一个原因都说不出 → 那是本模块自己的 bug,不该悄悄发出去。 */
|
|
316
|
+
function assertAttributable(complete, stops, where) {
|
|
317
|
+
if (complete) return;
|
|
318
|
+
if (stops.depth.count || stops.unreadable.count || stops.dirs || stops.entries) return;
|
|
319
|
+
throw new Error(`内部错误:${where} 报了 complete:false 却没有可归因的原因(记账漏了一条)`);
|
|
225
320
|
}
|
|
226
321
|
|
|
227
322
|
/**
|
|
228
323
|
* 有界遍历。不跟随 symlink(`readdirSync` 的 Dirent 判类型,不 stat)、
|
|
229
324
|
* 跳过 `.geoly` 自身(它的内容由状态路径检查负责,不是嵌套候选)。
|
|
230
325
|
*
|
|
326
|
+
* ✅ 「跳过 `.geoly`」这一条**实测生效**:`<target>/.geoly/tx-1/stage/1/a/…/h`
|
|
327
|
+
* 这样 12 层的事务目录,扫描结果是 `visited:1, complete:true` ——
|
|
328
|
+
* 事务目录不吃深度预算。(回归测试见 target.test.mjs。)
|
|
329
|
+
*
|
|
231
330
|
* 🔴 撞到上限**不静默放过**:调用方会因此记一条 `target.nested-scan-incomplete`。
|
|
232
331
|
* 「扫不完」与「扫完了没有」是两件事,把前者说成后者就是在假装证明了一个否定命题。
|
|
233
332
|
*/
|
|
234
|
-
function walkBounded(root, { maxDepth, maxDirs }, visit) {
|
|
333
|
+
function walkBounded(root, { maxDepth, maxDirs, maxEntries }, visit) {
|
|
235
334
|
let visited = 0;
|
|
335
|
+
let entries = 0;
|
|
336
|
+
let maxDepthSeen = 0;
|
|
236
337
|
let complete = true;
|
|
338
|
+
const stops = newStops();
|
|
237
339
|
const stack = [[root, 0]];
|
|
238
340
|
while (stack.length) {
|
|
239
341
|
const [dir, depth] = stack.pop();
|
|
240
342
|
if (visited >= maxDirs) {
|
|
241
343
|
complete = false;
|
|
344
|
+
stops.dirs = dir;
|
|
242
345
|
break;
|
|
243
346
|
}
|
|
244
347
|
visited += 1;
|
|
348
|
+
if (depth > maxDepthSeen) maxDepthSeen = depth;
|
|
245
349
|
visit(dir);
|
|
246
350
|
if (depth >= maxDepth) {
|
|
247
351
|
// 深度到顶但下面还有目录 → 同样是「没扫完」
|
|
248
|
-
|
|
352
|
+
// 🔴 EACCES 与「确认下面还有目录」**互斥归因**:读不了的时候我们并没有
|
|
353
|
+
// 确认过下面有目录,把它同时记进 depth 会让文案既劝你提高深度、
|
|
354
|
+
// 又劝你修权限 —— 其中一半是编的。只记 unreadable。
|
|
355
|
+
const more = hasSubdir(dir);
|
|
356
|
+
if (more.unreadable) {
|
|
357
|
+
complete = false;
|
|
358
|
+
sample(stops.unreadable, { path: dir, code: more.code });
|
|
359
|
+
} else if (more.yes) {
|
|
360
|
+
complete = false;
|
|
361
|
+
sample(stops.depth, dir);
|
|
362
|
+
}
|
|
249
363
|
continue;
|
|
250
364
|
}
|
|
251
365
|
let ents;
|
|
@@ -256,25 +370,104 @@ function walkBounded(root, { maxDepth, maxDirs }, visit) {
|
|
|
256
370
|
// 静默跳过等于宣称「这里没有」,而我们并没有看过。
|
|
257
371
|
// ⚠️ 但 ENOENT/ENOTDIR 是「本来就没有东西」——目录还没建、或扫描途中被删 ——
|
|
258
372
|
// 那不是盲区,不能因此把每一次「target 尚不存在」的预检都判成扫不完。
|
|
259
|
-
if (!isAbsent(err))
|
|
373
|
+
if (!isAbsent(err)) {
|
|
374
|
+
complete = false;
|
|
375
|
+
sample(stops.unreadable, { path: dir, code: err.code ?? 'EUNKNOWN' });
|
|
376
|
+
}
|
|
260
377
|
continue;
|
|
261
378
|
}
|
|
379
|
+
// 🔴 预算检查必须**紧跟在这次 readdir 之后**,不能等到下一轮循环顶部。
|
|
380
|
+
// 等下一轮是 fail-open:`/usr/bin` 这种「924 个条目、一个子目录都没有」
|
|
381
|
+
// 的目录会把预算撑爆之后**直接把栈跑空**,于是返回 complete:true ——
|
|
382
|
+
// 预算超了却宣称扫完了。(实测:maxEntries:1 下 entries=924 而 complete:true。)
|
|
383
|
+
// 顺带把 stop 记在**真正超预算的那个目录**上,而不是下一个待处理目录。
|
|
384
|
+
entries += ents.length;
|
|
385
|
+
if (entries > maxEntries) {
|
|
386
|
+
complete = false;
|
|
387
|
+
stops.entries = dir;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
262
390
|
for (const e of ents) {
|
|
263
391
|
if (!e.isDirectory()) continue; // Dirent 的 isDirectory 对 symlink 返回 false
|
|
264
392
|
if (e.name === STATE_DIR) continue;
|
|
265
393
|
stack.push([join(dir, e.name), depth + 1]);
|
|
266
394
|
}
|
|
267
395
|
}
|
|
268
|
-
|
|
396
|
+
assertAttributable(complete, stops, 'walkBounded');
|
|
397
|
+
return { complete, visited, entries, maxDepthSeen, stops: finalizeStops(stops) };
|
|
269
398
|
}
|
|
270
399
|
|
|
400
|
+
/**
|
|
401
|
+
* 深度到顶那一层:下面**还有没有**目录。
|
|
402
|
+
* 🔴 「读不了」与「有」要分开报:两者都让 `complete` 变 false,但用户的下一步不同
|
|
403
|
+
* (一个是提高 `--scan-max-depth`,一个是去修权限)。旧版把两者合成一个布尔,
|
|
404
|
+
* 于是权限问题会被文案说成「深度不够」,把人指向错误的方向。
|
|
405
|
+
*/
|
|
271
406
|
function hasSubdir(dir) {
|
|
272
407
|
try {
|
|
273
|
-
|
|
408
|
+
const ents = readdirSync(dir, { withFileTypes: true });
|
|
409
|
+
return { yes: ents.some((e) => e.isDirectory() && e.name !== STATE_DIR), unreadable: false };
|
|
274
410
|
} catch (err) {
|
|
275
411
|
// 🔴 读不了 → 无法证明下面没有目录,按「还有」算;不存在则确实没有
|
|
276
|
-
|
|
412
|
+
if (isAbsent(err)) return { yes: false, unreadable: false };
|
|
413
|
+
return { yes: true, unreadable: true, code: err.code ?? 'EUNKNOWN' };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 把「没扫完」的记账翻成一句**能指导下一步**的话。
|
|
419
|
+
*
|
|
420
|
+
* 🔴 三件事缺一不可:撞的是**哪个**上限、它的**实际值**、以及**怎么办**。
|
|
421
|
+
* 旧文案(「深度上限 8 / 目录数上限 5000」)三件事只占了半件 ——
|
|
422
|
+
* 它把两个上限并列念了一遍,既没说是哪个,也没给任何出路。
|
|
423
|
+
*/
|
|
424
|
+
function describeIncomplete(stops, bounds, walk) {
|
|
425
|
+
const parts = [];
|
|
426
|
+
if (stops.depth.count) {
|
|
427
|
+
parts.push(
|
|
428
|
+
`深度上限 ${bounds.maxDepth} 到顶,仍有 ${stops.depth.count} 处目录没往下看` +
|
|
429
|
+
`(例如 ${stops.depth.samples.join('、')})` +
|
|
430
|
+
`;提高它:--scan-max-depth <N>(硬顶 ${SCAN_CEILINGS.maxDepth})`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (stops.dirs) {
|
|
434
|
+
parts.push(
|
|
435
|
+
`目录数上限 ${bounds.maxDirs} 用尽(已访问 ${walk.visited} 个,停在 ${stops.dirs})` +
|
|
436
|
+
`;提高它:--scan-max-dirs <N>(硬顶 ${SCAN_CEILINGS.maxDirs})`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
if (stops.entries) {
|
|
440
|
+
parts.push(
|
|
441
|
+
`目录项上限 ${bounds.maxEntries} 用尽(已读 ${walk.entries} 条,停在 ${stops.entries})` +
|
|
442
|
+
';这通常意味着 target 下有超大目录,先确认那里该不该有这些文件',
|
|
443
|
+
);
|
|
277
444
|
}
|
|
445
|
+
if (stops.unreadable.count) {
|
|
446
|
+
parts.push(
|
|
447
|
+
`有 ${stops.unreadable.count} 处目录读不进去(例如 ` +
|
|
448
|
+
stops.unreadable.samples.map((s) => `${s.path}[${s.code}]`).join('、') +
|
|
449
|
+
');这是权限问题,提高扫描上限没有用,请修好权限或换一个 target',
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return parts.join(';');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 给机器读的那一半(`violation.detail`)。
|
|
457
|
+
*
|
|
458
|
+
* 🔴 文案与 detail 必须来自**同一份**记账,不能各算各的 ——
|
|
459
|
+
* 两边分头拼字符串正是「报错说 A、JSON 说 B」的来源。
|
|
460
|
+
* detail 里带 `limits`,是因为默认值会随版本变:用户贴给我们一份 JSON 时,
|
|
461
|
+
* 我们要能看出他当时**实际**跑的是哪一组预算,而不是去猜他装的哪个版本。
|
|
462
|
+
*/
|
|
463
|
+
function scanDetail(bounds, walk) {
|
|
464
|
+
return {
|
|
465
|
+
limits: { ...bounds },
|
|
466
|
+
visited: walk.visited,
|
|
467
|
+
entries: walk.entries,
|
|
468
|
+
maxDepthSeen: walk.maxDepthSeen,
|
|
469
|
+
stops: walk.stops,
|
|
470
|
+
};
|
|
278
471
|
}
|
|
279
472
|
|
|
280
473
|
/** 「这里本来就没东西」而不是「有东西但我看不了」。两者的 fail-closed 处置相反。 */
|
|
@@ -306,36 +499,70 @@ function tryRealpath(p) {
|
|
|
306
499
|
* 动作点仍然必须复验并 fail-closed(见文件顶部)。
|
|
307
500
|
*/
|
|
308
501
|
export function scanStatePaths(stateDir, scanOpts = {}) {
|
|
309
|
-
const
|
|
502
|
+
const bounds = normalizeScan(scanOpts);
|
|
503
|
+
const { maxDepth, maxDirs, maxEntries } = bounds;
|
|
504
|
+
// 🔴 提前返回的三条路径也要给出**同形状**的结果:调用方读 `stops` 前不该先判
|
|
505
|
+
// 「这次是不是走了短路分支」。少一个字段就多一处 `?.`,而 `?.` 正是把
|
|
506
|
+
// 「没扫完」悄悄读成「没问题」的那种写法。
|
|
507
|
+
const empty = (over) => ({
|
|
508
|
+
symlinks: [],
|
|
509
|
+
notPlain: [],
|
|
510
|
+
complete: true,
|
|
511
|
+
visited: 0,
|
|
512
|
+
entries: 0,
|
|
513
|
+
maxDepthSeen: 0,
|
|
514
|
+
stops: finalizeStops(newStops()),
|
|
515
|
+
...over,
|
|
516
|
+
});
|
|
310
517
|
const bad = [];
|
|
311
518
|
let st;
|
|
312
519
|
try {
|
|
313
520
|
st = lstatSync(stateDir);
|
|
314
521
|
} catch (err) {
|
|
315
|
-
if (isAbsent(err)) return
|
|
316
|
-
|
|
522
|
+
if (isAbsent(err)) return empty(); // 还不存在,后面才创建
|
|
523
|
+
// 🔴 看不了 ≠ 没问题。而且要说清是**哪一种**看不了 —— 这条以前只回一个
|
|
524
|
+
// `complete:false`,报错文案便只能泛泛地说「深度/目录数/读不进去」三选一。
|
|
525
|
+
const stops = newStops();
|
|
526
|
+
sample(stops.unreadable, { path: stateDir, code: err.code ?? 'EUNKNOWN' });
|
|
527
|
+
return empty({ complete: false, stops: finalizeStops(stops) });
|
|
317
528
|
}
|
|
318
|
-
if (st.isSymbolicLink()) return { symlinks: [stateDir]
|
|
319
|
-
if (!st.isDirectory()) return {
|
|
529
|
+
if (st.isSymbolicLink()) return empty({ symlinks: [stateDir] });
|
|
530
|
+
if (!st.isDirectory()) return empty({ notPlain: [stateDir] });
|
|
320
531
|
|
|
321
532
|
const symlinks = [];
|
|
322
533
|
let visited = 0;
|
|
534
|
+
let entries = 0;
|
|
535
|
+
let maxDepthSeen = 0;
|
|
323
536
|
let complete = true;
|
|
537
|
+
const stops = newStops();
|
|
324
538
|
const stack = [[stateDir, 0]];
|
|
325
539
|
while (stack.length) {
|
|
326
540
|
const [dir, depth] = stack.pop();
|
|
327
541
|
if (visited >= maxDirs) {
|
|
328
542
|
complete = false;
|
|
543
|
+
stops.dirs = dir;
|
|
329
544
|
break;
|
|
330
545
|
}
|
|
331
546
|
visited += 1;
|
|
547
|
+
if (depth > maxDepthSeen) maxDepthSeen = depth;
|
|
332
548
|
let ents;
|
|
333
549
|
try {
|
|
334
550
|
ents = readdirSync(dir, { withFileTypes: true });
|
|
335
551
|
} catch (err) {
|
|
336
|
-
if (!isAbsent(err))
|
|
552
|
+
if (!isAbsent(err)) {
|
|
553
|
+
complete = false; // 🔴 看不了就不能宣称这下面没有 symlink
|
|
554
|
+
sample(stops.unreadable, { path: dir, code: err.code ?? 'EUNKNOWN' });
|
|
555
|
+
}
|
|
337
556
|
continue;
|
|
338
557
|
}
|
|
558
|
+
// 🔴 同 walkBounded:检查必须紧跟 readdir,等下一轮循环顶部是 fail-open
|
|
559
|
+
// (目录项撑爆预算、但没有子目录可推 → 栈跑空 → 宣称 complete:true)。
|
|
560
|
+
entries += ents.length;
|
|
561
|
+
if (entries > maxEntries) {
|
|
562
|
+
complete = false;
|
|
563
|
+
stops.entries = dir;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
339
566
|
for (const e of ents) {
|
|
340
567
|
const p = join(dir, e.name);
|
|
341
568
|
if (e.isSymbolicLink()) {
|
|
@@ -344,13 +571,25 @@ export function scanStatePaths(stateDir, scanOpts = {}) {
|
|
|
344
571
|
}
|
|
345
572
|
if (e.isDirectory()) {
|
|
346
573
|
if (depth < maxDepth) stack.push([p, depth + 1]);
|
|
347
|
-
else
|
|
574
|
+
else {
|
|
575
|
+
complete = false;
|
|
576
|
+
sample(stops.depth, p);
|
|
577
|
+
}
|
|
348
578
|
continue;
|
|
349
579
|
}
|
|
350
580
|
if (!e.isFile()) bad.push(p); // FIFO / socket / 设备节点
|
|
351
581
|
}
|
|
352
582
|
}
|
|
353
|
-
|
|
583
|
+
assertAttributable(complete, stops, 'scanStatePaths');
|
|
584
|
+
return {
|
|
585
|
+
symlinks,
|
|
586
|
+
notPlain: bad,
|
|
587
|
+
complete,
|
|
588
|
+
visited,
|
|
589
|
+
entries,
|
|
590
|
+
maxDepthSeen,
|
|
591
|
+
stops: finalizeStops(stops),
|
|
592
|
+
};
|
|
354
593
|
}
|
|
355
594
|
|
|
356
595
|
// ── 挂载点(§3.4) ───────────────────────────────────────────────────────────
|
|
@@ -556,13 +795,15 @@ export function precheckTarget(targetPath, opts = {}) {
|
|
|
556
795
|
add(
|
|
557
796
|
V.STATE_SCAN_INCOMPLETE,
|
|
558
797
|
stateDir,
|
|
559
|
-
`${stateDir}
|
|
560
|
-
|
|
798
|
+
`${stateDir} 没扫完,无法证明状态路径里没有符号链接 —— ` +
|
|
799
|
+
describeIncomplete(stateScan.stops, bounds, stateScan),
|
|
800
|
+
scanDetail(bounds, stateScan),
|
|
561
801
|
);
|
|
562
802
|
}
|
|
563
803
|
|
|
564
804
|
// ── §3.5 嵌套 target
|
|
565
|
-
const
|
|
805
|
+
const nestedScan = findNestedTargets(targetPath, { targetSet, scan: bounds });
|
|
806
|
+
const { nested, complete } = nestedScan;
|
|
566
807
|
for (const n of nested) {
|
|
567
808
|
add(
|
|
568
809
|
V.NESTED_TARGET,
|
|
@@ -577,8 +818,9 @@ export function precheckTarget(targetPath, opts = {}) {
|
|
|
577
818
|
add(
|
|
578
819
|
V.SCAN_INCOMPLETE,
|
|
579
820
|
targetPath,
|
|
580
|
-
`嵌套 target
|
|
581
|
-
|
|
821
|
+
`嵌套 target 扫描未跑完,无法证明 ${targetPath} 之下没有嵌套 target —— ` +
|
|
822
|
+
describeIncomplete(nestedScan.stops, bounds, nestedScan),
|
|
823
|
+
scanDetail(bounds, nestedScan),
|
|
582
824
|
);
|
|
583
825
|
}
|
|
584
826
|
|