@geoly-ai/skills-hub 0.3.5 → 0.3.7

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.
@@ -0,0 +1,244 @@
1
+ // `publish` 的 token 来源解析 —— 06-submission.md §9 的**偏离版**
2
+ // (10-open-questions.md Q4,2026-09-05 用户拍板)。
3
+ //
4
+ // ── 🔴 这个模块的**唯一**产品承诺:CLI 不持有 token ──────────────────────
5
+ //
6
+ // 我们不注册 OAuth App / GitHub App,不做 device flow,不做 login / logout,
7
+ // **也不存任何东西**。token 只在一次 `publish` 的进程生命周期里存在于内存,
8
+ // 用完即随进程消失。`src/auth.mjs` 的 keychain 存储在这个模型下**不再被使用** ——
9
+ // 它没有被删掉(别的东西可能还引用它),但 publish 这条路径一个字节都不写。
10
+ //
11
+ // ⚠️ **不要把它描述成「token 权限很小」。** 恰恰相反:用户已有的 token
12
+ // 很可能是 classic `repo` PAT,权限面比 `public_repo` 还大,而**我们无法收窄它**。
13
+ // 「CLI 不持有」与「权限很小」是两件完全不同的事,只有前者是我们能保证的。
14
+ // 权限面必须在任何写操作之前如实披露 —— 那是 `commands/publish.mjs` 的事。
15
+ //
16
+ // ── 🔴 token 的六条禁令(每一条都有测试) ────────────────────────────────
17
+ // ① 不落盘 ② 不进 argv ③ 不进子进程环境
18
+ // ④ 不进日志 ⑤ 不进错误文案 ⑥ 不进 telemetry
19
+ // 前三条靠**控制流**保证(本模块从不 write、从不把 token 放进 args/env);
20
+ // 后三条靠 `scrub()` 兜底 —— 它是最后一道,不是第一道。
21
+
22
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
23
+
24
+ /**
25
+ * 来源顺序。**顺序本身是判据的一部分**,不是实现细节:
26
+ * 用户看到的身份必须与实际投稿身份一致,所以「用了哪一个」要能说得出来。
27
+ *
28
+ * ⚠️ `GITHUB_TOKEN` 在 GitHub Actions 里**会自动存在**(每个 job 都注入)。
29
+ * 它排在最后,但仍然排在 `gh` 前面 —— 于是在 CI 里,一个以为自己在用
30
+ * `gh` 身份的用户实际会用上 Actions 的 job token。这不是可以静默处理的事:
31
+ * 披露里必须把「用的是哪一个变量」写在脸上(见 `describeSource`)。
32
+ */
33
+ export const TOKEN_ENV_ORDER = Object.freeze([
34
+ 'GEOLY_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN',
35
+ ]);
36
+
37
+ /**
38
+ * 跑 `gh` 之前要从继承环境里**删掉**的变量。
39
+ *
40
+ * 🔴 `GH_HOST` 必须删 + 显式覆盖成 `github.com`:留着继承值的话,
41
+ * 一个配了 GHE 的用户会拿到**另一台主机**的 token,而我们接下来把它发去
42
+ * `api.github.com` —— 那是一次真正的凭据泄漏,不是配置错误。
43
+ * 🔴 token 类变量也要删:`gh auth token` 在 `GH_TOKEN` 存在时会**原样回显它**。
44
+ * 我们只有在三个变量都不存在时才走到这里,所以这一条在今天是冗余的 ——
45
+ * 留着是因为它防的是「将来有人改了上面的顺序」,而那种改动不会让任何测试变红。
46
+ */
47
+ const GH_ENV_STRIP = Object.freeze([
48
+ 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN',
49
+ 'GH_HOST', 'GH_REPO',
50
+ ]);
51
+
52
+ export class TokenError extends Error {
53
+ /** 认证/权限类一律 EXIT.AUTH(7) —— 见 09-cli.md §6。 */
54
+ constructor(message) { super(message); this.name = 'TokenError'; this.exitCode = 7; }
55
+ }
56
+
57
+ /**
58
+ * token 的形状门。**不是**在验证它有效(那要出网),是在拒绝明显不是 token 的东西。
59
+ *
60
+ * 🔴 存在的理由很具体:`gh auth token` 失败时也可能往 stdout 写东西,
61
+ * 多行输出 / 带空白的输出被当成 token 塞进 `Authorization` header,
62
+ * 会造成一次**带着垃圾内容的请求**,而且那段垃圾可能被服务端记进日志。
63
+ * 宁可在这里失败。
64
+ */
65
+ export function assertTokenShape(raw, where) {
66
+ if (typeof raw !== 'string') throw new TokenError(`${where} 给出的不是字符串`);
67
+ // 🔴 这里**不能**把值放进错误文案 —— 哪怕它"看起来不像 token"。
68
+ if (raw.length < 8 || raw.length > 512) {
69
+ throw new TokenError(`${where} 给出的 token 长度不合理(应在 8–512 之间)—— 已忽略,不予使用。`);
70
+ }
71
+ if (!/^[\x21-\x7e]+$/.test(raw)) {
72
+ throw new TokenError(
73
+ `${where} 给出的 token 含空白或不可见字符 —— 拒绝使用。\n`
74
+ + ' 🔴 常见成因:把整段命令输出(含换行/提示语)塞进了环境变量。',
75
+ );
76
+ }
77
+ return raw;
78
+ }
79
+
80
+ /**
81
+ * 遮蔽器 —— **最后一道,不是第一道**。
82
+ *
83
+ * 🔴 两条规则缺一不可:
84
+ * ① 替换掉**这次实际用的** token 原文(最准,但只覆盖我们知道的那一个);
85
+ * ② 按**形态**替换任何看起来像 GitHub 凭据的串 —— 覆盖「响应体里回显了另一个
86
+ * token」「用户把 token 写进了 PR body」这类我们不知道的情况。
87
+ * 只做 ① 的话,一次 `422` 响应体里回显的**别的**凭据会被原样打出来。
88
+ *
89
+ * ⚠️ **诚实边界**:`②` 认的是今天 GitHub 在用的前缀(`ghp_`/`gho_`/`ghu_`/`ghs_`/
90
+ * `ghr_`/`github_pat_`)。老式的 40 位 hex PAT **不在其中** —— 那个形态与
91
+ * commit sha 完全一样,按形态替换会把每一个 sha 都打成 `***`,而 sha 正是
92
+ * 本命令要给用户看的关键事实。老式 PAT 只由 ① 兜住。
93
+ */
94
+ export function scrub(text, token = null) {
95
+ let s = String(text);
96
+ if (typeof token === 'string' && token.length >= 8) {
97
+ s = s.split(token).join('***');
98
+ }
99
+ return s
100
+ .replace(/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '***')
101
+ .replace(/\bgithub_pat_[A-Za-z0-9_]{16,}/g, '***');
102
+ }
103
+
104
+ /**
105
+ * 环境变量这一层。返回**存在**的那些(trim 后非空才算存在)。
106
+ * 🔴 空串视为"不存在"而不是"存在但为空":CI 里把一个变量置空是常见的
107
+ * "关掉它"写法,把它当成一个候选会让后面的冲突检测报出无意义的红。
108
+ */
109
+ export function collectEnvTokens(env) {
110
+ const found = [];
111
+ for (const name of TOKEN_ENV_ORDER) {
112
+ const raw = env?.[name];
113
+ if (typeof raw !== 'string') continue;
114
+ const v = raw.trim();
115
+ if (v === '') continue;
116
+ found.push({ name, value: v });
117
+ }
118
+ return found;
119
+ }
120
+
121
+ /**
122
+ * 🔴 多个来源同时存在**且值不同** → 直接拒,不静默挑一个。
123
+ *
124
+ * 为什么不"按顺序挑第一个就好":用户看到的身份与实际投稿身份必须一致。
125
+ * 一台机器上同时有 `GH_TOKEN`(个人账号)与 `GITHUB_TOKEN`(CI job token)
126
+ * 是**常态**,两者是不同的身份 —— 静默挑一个的话,投稿会以他没预期的身份提交,
127
+ * 而他要到 PR 页面上才看得出来。
128
+ *
129
+ * ⚠️ 值**相同**时不拒:那只是同一个 token 被写进了多个变量,身份没有歧义。
130
+ * 但披露里仍然要把其余变量名列出来(`others`)。
131
+ *
132
+ * 🔴 错误文案里**只有变量名**。不列值、不列长度、不列前缀 ——
133
+ * 长度和前缀足以区分 classic PAT / fine-grained / Actions token,
134
+ * 那已经是在泄漏「这台机器上有什么凭据」了。
135
+ */
136
+ export function resolveEnvToken(env) {
137
+ const found = collectEnvTokens(env);
138
+ if (found.length === 0) return null;
139
+ const distinct = new Set(found.map((f) => f.value));
140
+ if (distinct.size > 1) {
141
+ throw new TokenError(
142
+ `检测到多个 GitHub token 环境变量,且它们的值**不同**:${found.map((f) => f.name).join('、')}。\n`
143
+ + ' 🔴 拒绝替你挑一个 —— 它们是不同的身份,挑错了你会以没预期的账号投稿,\n'
144
+ + ' 而要到 PR 页面上才看得出来。\n'
145
+ + ` ⚠️ 如果你在 GitHub Actions 里:\`GITHUB_TOKEN\` 是 runner 自动注入的 job token,\n`
146
+ + ' 它多半不是你想用的那个身份。\n'
147
+ + ' 怎么办:只留一个,或显式设 `GEOLY_GITHUB_TOKEN` 指定你要用的那一个\n'
148
+ + ' (它的优先级最高,但**值不同时本命令仍然拒绝** —— 请把其余的 unset)。',
149
+ );
150
+ }
151
+ const chosen = found[0];
152
+ assertTokenShape(chosen.value, `环境变量 ${chosen.name}`);
153
+ return {
154
+ token: chosen.value,
155
+ source: `env:${chosen.name}`,
156
+ sourceLabel: `环境变量 ${chosen.name}`,
157
+ others: found.slice(1).map((f) => f.name),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * `gh auth token` 这一层。
163
+ *
164
+ * 🔴 `shell: false`(`spawnSync` 的默认,这里显式写出来):绝不经过 shell。
165
+ * 🔴 `--hostname github.com` 固定,并且**同时**把继承来的 `GH_HOST` 删掉再显式设回
166
+ * —— 两条都要:`--hostname` 决定它去哪个 host 取凭据,删 `GH_HOST` 防的是
167
+ * 将来有人去掉那个参数时静默退回继承值。
168
+ * 🔴 **stdout / stderr 一律不原样输出。** gh 的输出里可能带 token
169
+ * (`gh auth token` 的正常输出**就是** token),把它拼进错误文案是最容易犯的错。
170
+ * 这里按 exit code 分档给固定文案。
171
+ */
172
+ export function resolveGhToken(env, spawnSync = nodeSpawnSync) {
173
+ const childEnv = { ...env };
174
+ for (const k of GH_ENV_STRIP) delete childEnv[k];
175
+ childEnv.GH_HOST = 'github.com';
176
+ // 🔴 `GH_PROMPT_DISABLED` / `NO_COLOR`:不让它试图交互,也不要 ANSI 转义进 stdout。
177
+ childEnv.GH_PROMPT_DISABLED = '1';
178
+ childEnv.NO_COLOR = '1';
179
+
180
+ let r;
181
+ try {
182
+ r = spawnSync('gh', ['auth', 'token', '--hostname', 'github.com'], {
183
+ shell: false,
184
+ encoding: 'utf8',
185
+ env: childEnv,
186
+ timeout: 15000,
187
+ // 🔴 很小的 maxBuffer:`gh auth token` 的正常输出是**一行**。
188
+ // 留一个大缓冲区,等于愿意把一大坨我们不打算读的东西收进内存。
189
+ maxBuffer: 64 * 1024,
190
+ windowsHide: true,
191
+ });
192
+ } catch (e) {
193
+ // spawn 本身抛(极少见)——**不带 e.message**,它可能含路径等环境信息。
194
+ throw new TokenError(`调用 \`gh\` 失败(${e?.code ?? 'spawn 错误'})。${GH_HINT}`);
195
+ }
196
+ if (r.error) {
197
+ const code = r.error.code;
198
+ if (code === 'ENOENT') {
199
+ throw new TokenError(`找不到 \`gh\`(GitHub CLI 未安装或不在 PATH 上)。${GH_HINT}`);
200
+ }
201
+ throw new TokenError(`调用 \`gh\` 失败(${code ?? 'spawn 错误'})。${GH_HINT}`);
202
+ }
203
+ // 🔴 超时被 kill 时 `status` 是 null、`signal` 有值 —— 单看 exit code 会漏。
204
+ if (r.signal !== null && r.signal !== undefined) {
205
+ throw new TokenError(`\`gh auth token\` 被信号 ${r.signal} 中止(多半是 15 秒超时)。${GH_HINT}`);
206
+ }
207
+ if (r.status !== 0) {
208
+ // 🔴 **不透传 gh 的 stderr。** gh 未登录时 exit 1、stderr 是人类文案,
209
+ // 看着无害 —— 但「看着无害」不是判据:没有任何机制保证它下一个版本不会
210
+ // 把 token 打进 stderr(比如某个 debug 开关从环境里被继承进去)。
211
+ // ⚠️ 也**不靠 exit code 分辨"没装"与"没登录"** —— 它分辨不了。
212
+ // 「没装」由上面的 `ENOENT` 分支认(那个判据是可靠的);
213
+ // 到这里就统一给"两条出路"的固定文案。
214
+ throw new TokenError(
215
+ `\`gh auth token --hostname github.com\` 以 exit ${r.status} 失败`
216
+ + '(最常见的原因是**没有登录 github.com**)。' + GH_HINT,
217
+ );
218
+ }
219
+ const out = typeof r.stdout === 'string' ? r.stdout.trim() : '';
220
+ if (out === '') throw new TokenError(`\`gh auth token\` 没有输出任何 token。${GH_HINT}`);
221
+ assertTokenShape(out, '`gh auth token`');
222
+ return { token: out, source: 'gh', sourceLabel: '`gh auth token`(GitHub CLI 的已登录身份)', others: [] };
223
+ }
224
+
225
+ const GH_HINT =
226
+ '\n 怎么办(二选一):\n'
227
+ + ' · `gh auth login --hostname github.com`(推荐:本命令不持有、也不存储任何 token)\n'
228
+ + ' · 或设 `GEOLY_GITHUB_TOKEN=<你的 token>` 后重跑\n'
229
+ + ' 🔴 本命令**不做** login,也**不会**把 token 存到任何地方 —— 撤销请去\n'
230
+ + ' GitHub 的 Settings → Developer settings,或 `gh auth logout`。';
231
+
232
+ /**
233
+ * 完整的来源解析:环境变量 → `gh`。
234
+ *
235
+ * @param {object} a
236
+ * @param {Record<string,string>} a.env
237
+ * @param {function} [a.spawnSync] 注入点(测试用)。生产走 node:child_process。
238
+ * @returns {{token:string, source:string, sourceLabel:string, others:string[]}}
239
+ */
240
+ export function resolveToken({ env, spawnSync = nodeSpawnSync }) {
241
+ const fromEnv = resolveEnvToken(env);
242
+ if (fromEnv !== null) return fromEnv;
243
+ return resolveGhToken(env, spawnSync);
244
+ }
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