@geoly-ai/skills-hub 0.3.4 → 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.
@@ -21,11 +21,22 @@ process.emit = function (name, data, ...rest) {
21
21
  for (const k of Object.keys(process.env)) {
22
22
  if (k.startsWith('GEOLY_FAULT')) delete process.env[k];
23
23
  }
24
- // 🔴 认 HTTP_PROXY / HTTPS_PROXY / NO_PROXY —— 只能靠**启动前**就带上变量。
24
+ // 🔴 认 HTTP_PROXY / HTTPS_PROXY / NO_PROXY —— **只认这个,不去猜别的**。
25
25
  //
26
- // Node 的内建 fetch(undici)默认**不认**代理环境变量,而 npm / git / curl 都认。
27
- // 后果不是「慢一点」:在企业代理后面 `install` 报 `UND_ERR_CONNECT_TIMEOUT`,
26
+ // Node 的内建 fetch(undici)默认不认代理环境变量,而 npm / git / curl 都认。
27
+ // 后果不是「慢一点」:在代理后面 `install` 报 `UND_ERR_CONNECT_TIMEOUT`,
28
28
  // 而同一台机器上 `curl` 同一个地址是通的 —— 看起来像我们的 registry 挂了。
29
+ // 我们做的只是**把用户已经表达过的意图变成生效的**。
30
+ //
31
+ // 🔴 **不读系统代理,不替用户决定他的网络怎么走。**(2026-09-04 用户拍板。)
32
+ // 我一度加了「没有环境变量就去读 macOS 的 scutil --proxy」——
33
+ // 它确实能让「什么都不设也能装」,但那是**替用户选了一条他没选的路**:
34
+ // 设了环境变量 = 他说「这次走这儿」;系统设置只是「这台机器平时怎样」,
35
+ // 不等于他要让这个工具走那儿。
36
+ // ⚠️ 更实际的一层:静默把请求送进一个代理,是**改变了流量的去向**,
37
+ // 而用户没有要求过。要用代理就设变量,不要就不设 —— 他说了算。
38
+ // 连不上的时候**明确告诉他怎么设**(见 `src/download.mjs` 的错误提示),
39
+ // 这是帮忙;替他设上,是越界。
29
40
  //
30
41
  // ⚠️ **`process.env.NODE_USE_ENV_PROXY = '1'` 在进程内设置是无效的**(实测)。
31
42
  // Node 在**启动时**读它,之后再改不算数。
@@ -35,16 +46,27 @@ for (const k of Object.keys(process.env)) {
35
46
  // ② 进程内设置 → UND_ERR_CONNECT_TIMEOUT ← 无效
36
47
  // ③ 启动前外部设置 → 302
37
48
  // **在一个本来就会过的窗口里验证一道闸,等于没验。**
38
- //
39
- // 所以:检测到代理配置但变量没带上时,**带着变量把自己重启一次**。
40
- // 🔴 三个前提缺一不可,否则不重启:
41
- // · 用户没有显式表态(`NODE_USE_ENV_PROXY` 未设 —— 设成 '0' 也是表态)
42
- // · 环境里确实配了代理(没配就重启纯属白费一个进程)
43
- // · 不是已经重启过的那一次(防无限自我重启)
49
+ // 所以只能**带着变量把自己重启一次**。
50
+
51
+ // 🔴 **只有会出网的命令才值得为代理重启一次进程。**
52
+ // `list` / `why` / `stats` 这些是纯本地的 —— 给它们重启等于每次多起一个进程。
53
+ // 出网的只有两处:`install`(preheat + 收尾的自动上报)与 `telemetry flush`。
54
+ // ⚠️ 判据取**第一个非 flag 参数**,不是 `argv[2]` —— 全局 flag 可以写在命令前面。
55
+ // ⚠️ 这里故意**不做真正的参数解析**:解析是 `cli.mjs` 的事,
56
+ // 在两个地方各写一套 argv 语义,迟早会分叉。这里只要一个保守的近似:
57
+ // 多重启一次不算错,漏了才算 —— 所以拿不准(比如 `--help`)就当作要出网。
58
+ const firstArg = process.argv.slice(2).find((a) => !a.startsWith('-'));
59
+ const mayGoOnline = firstArg === undefined || firstArg === 'install' || firstArg === 'telemetry';
60
+
61
+ // 三个前提缺一不可,否则不重启:
62
+ // · 用户没有显式表态(`NODE_USE_ENV_PROXY` 已设 —— 哪怕设成 '0' —— 一律尊重)
63
+ // · 环境里确实配了代理(没配就重启纯属白费一个进程)
64
+ // · 不是已经重启过的那一次(防无限自我重启)
44
65
  if (
45
- process.env.NODE_USE_ENV_PROXY === undefined &&
46
- process.env.GEOLY_PROXY_REEXEC === undefined &&
47
- ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((k) => process.env[k])
66
+ mayGoOnline
67
+ && process.env.NODE_USE_ENV_PROXY === undefined
68
+ && process.env.GEOLY_PROXY_REEXEC === undefined
69
+ && ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((k) => process.env[k])
48
70
  ) {
49
71
  const { spawnSync } = await import('node:child_process');
50
72
  const r = spawnSync(process.execPath, [...process.execArgv, ...process.argv.slice(1)], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoly-ai/skills-hub",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "geoly-ai 的 skill 分发中心 —— 安装、校验、审计",
5
5
  "type": "module",
6
6
  "bin": {
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', 'provenance'],
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
- 验签与摘要校验不可关闭;替换必须点名;陈旧/yank/全量各有独立开关。
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 成功收尾后**会静默发一次
@@ -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
  }
@@ -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), { snap, floor, pinned, out, verifier });
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, { base: t.base, targetSet: [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 绑定 ────────────────────
@@ -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) => ({ code: v.code, message: v.message, path: v.path }))
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
  }),
@@ -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
- const r = removeOneTarget(ctx, pv, { name, hook, out });
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, { base: pv.t.base, targetSet: [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
  // 🔴 **锁内重读并重算**:预览是在没有任何锁的情况下读的,从那时到现在
@@ -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
- const r = updateOneTarget(ctx, p, { at, floor, hook, out, snap });
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(precheckTarget(target, { base: p.t.base, targetSet: [target] }));
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/download.mjs CHANGED
@@ -182,8 +182,13 @@ export async function download(url, {
182
182
  // 我第一版无论如何都说「需要 Node ≥ 24」,而在 Node 25 上读者会
183
183
  // 合理地认为「我满足了,那问题在别处」—— 一句正确但不适用的话,
184
184
  // 比不说更能把人带偏。
185
+ // 🔴 **不按错误码枚举**。2026-09-04 实测:真实现场返回的是 `UND_ERR_SOCKET`,
186
+ // 而我当时枚举的是 TIMEOUT/ECONNREFUSED/ENOTFOUND/EAI_AGAIN —— 提示一个字没打。
187
+ // undici 的错误码谱系又长又会变(UND_ERR_SOCKET / UND_ERR_CONNECT_TIMEOUT /
188
+ // ECONNRESET / EPIPE / 证书类…),**枚举注定漏**。
189
+ // 判据换成:**走到这里就是取不到字节**,那时告诉用户「怎么走网络」总是有用的。
185
190
  let hint = '';
186
- if (/TIMEOUT|ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EAI_AGAIN/.test(String(why))) {
191
+ {
187
192
  const major = Number(process.versions.node.split('.')[0]);
188
193
  const hasProxyEnv = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']
189
194
  .some((k) => process.env[k]);
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
- /** `.geoly` 之下会出现的状态路径,全部要以 lstat 无跟随方式检查(§3.4)。 */
115
- const SCAN_DEFAULTS = Object.freeze({ maxDepth: 8, maxDirs: 5000 });
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
- return Math.floor(v);
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, complete: scanResult.complete, visited: scanResult.visited };
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
- if (hasSubdir(dir)) complete = false;
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)) complete = false;
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
- return { complete, visited };
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
- return readdirSync(dir, { withFileTypes: true }).some((e) => e.isDirectory() && e.name !== STATE_DIR);
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
- return !isAbsent(err);
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 { maxDepth, maxDirs } = normalizeScan(scanOpts);
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 { symlinks: [], notPlain: [], complete: true }; // 还不存在,后面才创建
316
- return { symlinks: [], notPlain: [], complete: false }; // 🔴 看不了 ≠ 没问题
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], notPlain: [], complete: true };
319
- if (!st.isDirectory()) return { symlinks: [], notPlain: [stateDir], complete: true };
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)) complete = false; // 🔴 看不了就不能宣称这下面没有 symlink
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 complete = false;
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
- return { symlinks, notPlain: bad, complete };
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 { nested, complete } = findNestedTargets(targetPath, { targetSet, scan: bounds });
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 扫描未跑完(深度上限 ${bounds.maxDepth} / ` +
581
- `目录数上限 ${bounds.maxDirs}),无法证明其下没有嵌套 target`,
821
+ `嵌套 target 扫描未跑完,无法证明 ${targetPath} 之下没有嵌套 target —— ` +
822
+ describeIncomplete(nestedScan.stops, bounds, nestedScan),
823
+ scanDetail(bounds, nestedScan),
582
824
  );
583
825
  }
584
826