@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoly-ai/skills-hub",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "geoly-ai 的 skill 分发中心 —— 安装、校验、审计",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,11 @@
24
24
  },
25
25
  "files": [
26
26
  "bin",
27
- "src"
27
+ "src",
28
+ "scripts/submission/structural-gates.mjs",
29
+ "scripts/submission/run-gates.mjs",
30
+ "scripts/submission/promotion-file.mjs",
31
+ "scripts/submission/scan-text.mjs"
28
32
  ],
29
33
  "scripts": {
30
34
  "test": "node --test test/*.test.mjs",
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ // `submissions/<ns>/<name>@<ver>/PROMOTION.json` —— 06-submission.md §6,
3
+ // 以及 2026-08-31 的决策 ②(R-19 的闭合方式)。
4
+ //
5
+ // ── 🔴 这个文件解决什么 ────────────────────────────────────────────────────
6
+ // `build-inputs.mjs` 要两份**只有 PR 侧才有**的事实:
7
+ // · `--claim-owner` —— 首次注册 namespace 时的 owner(不在 manifest 的键集里);
8
+ // · `--provenance-of` —— pack 的 provenance(`pack.json` 的键集里没有这个字段)。
9
+ // 在它们有来处之前,registry 只收得了「已注册 namespace 下的 skill 续版本」。
10
+ //
11
+ // ── 🔴🔴 分工:投稿者声明什么,promote 填什么 ─────────────────────────────
12
+ // **投稿者只能声明「只有他知道」的事**,凡是「只有 promote 能证明」的字段
13
+ // 一律由 promote 自己填 —— 让投稿者写它们,等于让他自称是谁。
14
+ //
15
+ // | 字段 | 谁给 | 为什么 |
16
+ // |---|---|---|
17
+ // | `owner.kind`(user/org) | 投稿者 | 意图,只有他知道 |
18
+ // | `owner.login` | 投稿者 | `user` 时必须等于 PR 作者,promote 会核 |
19
+ // | `owner.id` | **promote** | 不可变 node id 是身份本身;投稿者写它 = 冒名 |
20
+ // | `provenance.kind` | 投稿者 | 原创还是搬运,只有他知道 |
21
+ // | `author_github_id` | **promote** | 同上 |
22
+ // | `submitted_by_pr` / `imported_by_pr` | **promote** | 开 PR 之前根本不存在 |
23
+ // | `imported_at` | **promote** | 用**投稿 PR 的 created_at** —— 见下面的说明 |
24
+ // | `origin_*` / `license_evidence` | 投稿者 | 关于上游的事实,promote 无从得知 |
25
+ //
26
+ // 🔴 **投稿者写了 promote 该填的字段 → 直接拒**,不是「忽略它」。
27
+ // 忽略的话,一个想冒名的投稿看起来会像被接受了;而拒掉是一次沟通。
28
+ //
29
+ // ⚠️ 这份文件**不是**信任来源,是**待审材料**:`origin_*` 与 `license_evidence`
30
+ // 没有任何自动门能证实,它们归 §8 人工门第 8 条(license 与 provenance 是否可信)。
31
+
32
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { join } from 'node:path';
35
+
36
+ import { parseStrict } from '../../src/canonical-json.mjs';
37
+
38
+ export const PROMOTION_SCHEMA = 'geoly.skills.promotion-file/1';
39
+ export const PROMOTION_FILE = 'PROMOTION.json';
40
+
41
+ class PromotionFileError extends Error {
42
+ constructor(code, msg) { super(msg); this.name = 'PromotionFileError'; this.code = code; }
43
+ }
44
+ const bad = (code, msg) => { throw new PromotionFileError(code, msg); };
45
+
46
+ /** promote 自己填的字段 —— 投稿者写了任何一个都拒。 */
47
+ const FILLED_BY_PROMOTE = [
48
+ 'id', 'author_github_id', 'submitted_by_pr', 'imported_by_pr', 'imported_at',
49
+ ];
50
+
51
+ const OWNER_KEYS = ['kind', 'login'];
52
+ const PROV_ORIGINAL_KEYS = ['kind'];
53
+ const PROV_VENDORED_KEYS = [
54
+ 'kind', 'origin_repo', 'origin_ref', 'origin_commit', 'origin_subpath',
55
+ 'origin_tree_digest', 'license_evidence', 'added_files',
56
+ ];
57
+
58
+ const RE_COMMIT = /^[0-9a-f]{40}$/;
59
+ const RE_TREE = /^geoly-tree-v1:sha256:[0-9a-f]{64}$/;
60
+
61
+ function assertExact(obj, keys, where) {
62
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
63
+ bad('E_PROMO_SHAPE', `${where} 必须是对象`);
64
+ }
65
+ const got = Object.keys(obj).sort();
66
+ for (const k of got) {
67
+ if (FILLED_BY_PROMOTE.includes(k)) {
68
+ bad('E_PROMO_FORBIDDEN',
69
+ `${where}.${k} 由 promote 自己填,投稿里不能写它。\n`
70
+ + ' 🔴 这一类字段是**身份与时间**(node id、PR 号、导入时刻)——\n'
71
+ + ' 让投稿声明它们,等于让投稿者自称是谁、自称什么时候来的。');
72
+ }
73
+ }
74
+ const want = [...keys].sort();
75
+ if (got.join(',') !== want.join(',')) {
76
+ bad('E_PROMO_SHAPE', `${where} 的键集必须正好是 {${want.join(', ')}},得到 {${got.join(', ')}}`);
77
+ }
78
+ }
79
+
80
+ const str = (v, where) => {
81
+ if (typeof v !== 'string' || v === '') bad('E_PROMO_SHAPE', `${where} 必须是非空字符串`);
82
+ return v;
83
+ };
84
+
85
+ /**
86
+ * 读并校验一个投稿目录里的 `PROMOTION.json`。
87
+ * @returns {{owner:object|null, provenance:object|null}|null} 没有这个文件时返回 null
88
+ */
89
+ export function readPromotionFile(submissionDir) {
90
+ const p = join(submissionDir, PROMOTION_FILE);
91
+ if (!existsSync(p)) return null;
92
+
93
+ let doc;
94
+ try {
95
+ doc = parseStrict(readFileSync(p, 'utf8'));
96
+ } catch (e) {
97
+ bad('E_PROMO_PARSE', `${PROMOTION_FILE} 读不出来:${e.message.split('\n')[0]}`);
98
+ }
99
+ if (doc.schema !== PROMOTION_SCHEMA) {
100
+ bad('E_PROMO_SHAPE', `${PROMOTION_FILE}.schema 应为 ${PROMOTION_SCHEMA},得到 ${JSON.stringify(doc.schema)}`);
101
+ }
102
+ const keys = Object.keys(doc).sort().filter((k) => k !== 'schema');
103
+ for (const k of keys) {
104
+ if (!['claim_owner', 'provenance'].includes(k)) {
105
+ bad('E_PROMO_SHAPE', `${PROMOTION_FILE} 里不认识的键:${k}(只能有 schema / claim_owner / provenance)`);
106
+ }
107
+ }
108
+
109
+ let owner = null;
110
+ if (doc.claim_owner !== undefined) {
111
+ assertExact(doc.claim_owner, OWNER_KEYS, 'claim_owner');
112
+ const kind = str(doc.claim_owner.kind, 'claim_owner.kind');
113
+ if (kind !== 'user' && kind !== 'org') {
114
+ bad('E_PROMO_SHAPE', `claim_owner.kind 只能是 user / org,得到 ${JSON.stringify(kind)}`);
115
+ }
116
+ str(doc.claim_owner.login, 'claim_owner.login');
117
+ owner = { kind, login: doc.claim_owner.login };
118
+ }
119
+
120
+ let provenance = null;
121
+ if (doc.provenance !== undefined) {
122
+ const kind = doc.provenance?.kind;
123
+ if (kind === 'original') {
124
+ assertExact(doc.provenance, PROV_ORIGINAL_KEYS, 'provenance');
125
+ provenance = { kind: 'original' };
126
+ } else if (kind === 'vendored') {
127
+ assertExact(doc.provenance, PROV_VENDORED_KEYS, 'provenance');
128
+ const v = doc.provenance;
129
+ for (const k of ['origin_repo', 'origin_ref', 'origin_subpath', 'license_evidence']) {
130
+ str(v[k], `provenance.${k}`);
131
+ }
132
+ // 🔴 只记 tag 不行 —— tag 可以被移动,那正是「审核后换内容」的路径
133
+ // (05-lifecycle.md §6)。与 snapshot.mjs 的 E_PROV_COMMIT 同一条判据。
134
+ if (!RE_COMMIT.test(v.origin_commit ?? '')) {
135
+ bad('E_PROMO_SHAPE', `provenance.origin_commit 必须是 40 位小写 hex,得到 ${JSON.stringify(v.origin_commit)}`);
136
+ }
137
+ // 🔴 是**树**摘要,要带 geoly-tree-v1: 前缀(ERRATA E-8)——
138
+ // 系统里有两种树算法,裸 sha256 分不出是哪一种。
139
+ if (!RE_TREE.test(v.origin_tree_digest ?? '')) {
140
+ bad('E_PROMO_SHAPE',
141
+ `provenance.origin_tree_digest 必须形如 geoly-tree-v1:sha256:<64hex>,`
142
+ + `得到 ${JSON.stringify(v.origin_tree_digest)}`);
143
+ }
144
+ if (!Array.isArray(v.added_files) || v.added_files.some((x) => typeof x !== 'string' || x === '')) {
145
+ bad('E_PROMO_SHAPE', 'provenance.added_files 必须是非空字符串数组(没有新增就写 [])');
146
+ }
147
+ // 🔴 重复项要拒:它进快照、且是「相对上游多了哪些文件」的权威列表 ——
148
+ // 列两遍会让读它的人以为有两个文件。
149
+ const dup = v.added_files.filter((x, i) => v.added_files.indexOf(x) !== i);
150
+ if (dup.length) bad('E_PROMO_SHAPE', `provenance.added_files 有重复项:${[...new Set(dup)].join('、')}`);
151
+ provenance = { ...v };
152
+ } else {
153
+ bad('E_PROMO_SHAPE', `provenance.kind 只能是 original / vendored,得到 ${JSON.stringify(kind)}`);
154
+ }
155
+ }
156
+ return { owner, provenance };
157
+ }
158
+
159
+ /**
160
+ * 把投稿声明的那一半,与 promote 才知道的那一半合起来。
161
+ *
162
+ * @param {object} a
163
+ * @param {object|null} a.declared `readPromotionFile` 的结果
164
+ * @param {string} a.kind skill / pack
165
+ * @param {object} a.pr `{number, authorId, authorLogin, createdAt}`
166
+ * @param {object} [a.orgIds] `login → node id`,**由 promote 从 API 解析**
167
+ * @returns {{owner:object|undefined, provenance:object|undefined}}
168
+ */
169
+ export function fillFromPr({ declared, kind, pr, orgIds = {} }) {
170
+ const out = {};
171
+ if (declared?.owner) {
172
+ // 🔴 `user` 的 owner 必须**就是** PR 作者。允许写别人的 login,
173
+ // 等于让 A 用一次投稿把 namespace 注册到 B 名下。
174
+ // `org` 走不了这条自动判据 —— 归 §8 人工门(维护者要确认这个 org 真是他的)。
175
+ if (declared.owner.kind === 'user' && declared.owner.login !== pr.authorLogin) {
176
+ bad('E_PROMO_OWNER',
177
+ `claim_owner.login 是 ${declared.owner.login},而 PR 作者是 ${pr.authorLogin}。\n`
178
+ + ' 🔴 kind: user 的首次注册只能注册到**投稿者自己**名下。\n'
179
+ + ' 要注册到别人 / 组织名下,用 kind: org,并由维护者人工确认。');
180
+ }
181
+ // 🔴 **`org` 的 id 必须是那个 org 的 node id,不是投稿者的。**
182
+ // 早先这里两种 kind 都写 `pr.authorId` —— 于是记录里是
183
+ // 「login: 某组织 + id: 某个人」,而 05-lifecycle §1 说 id 才是身份本身:
184
+ // 那条绑定实际上把 namespace 给了这个人,却显示成组织的(Codex 2026-08-31)。
185
+ // ⚠️ 「这个人是不是该组织的成员 / 有没有权代表它」**没有自动判据**,
186
+ // 归 §8 人工门 —— 维护者必须确认。
187
+ let id;
188
+ if (declared.owner.kind === 'org') {
189
+ id = orgIds[declared.owner.login];
190
+ if (typeof id !== 'string' || id === '') {
191
+ bad('E_PROMO_ORG_ID',
192
+ `claim_owner 是 org ${declared.owner.login},但没有拿到它的 node id。\n`
193
+ + ' 🔴 org 的绑定必须记**组织自己**的不可变 id —— 记成投稿者的 id,\n'
194
+ + ' 等于把 namespace 给了这个人却显示成组织的。\n'
195
+ + ' promote 应当用 `gh api orgs/<login> --jq .node_id` 解析后传进来。');
196
+ }
197
+ } else {
198
+ id = pr.authorId;
199
+ }
200
+ out.owner = { kind: declared.owner.kind, login: declared.owner.login, id };
201
+ }
202
+ if (declared?.provenance) {
203
+ const p = declared.provenance;
204
+ out.provenance = p.kind === 'original'
205
+ ? { kind: 'original', author_github_id: pr.authorId, submitted_by_pr: pr.number }
206
+ : { ...p, imported_at: pr.createdAt, imported_by_pr: pr.number };
207
+ }
208
+ // skill 的 provenance 在 manifest 里,不该由这个文件提供
209
+ if (kind === 'skill' && declared?.provenance) {
210
+ bad('E_PROMO_SHAPE',
211
+ 'skill 的 provenance 在 skill.json 里(它的键集有这个字段),'
212
+ + `${PROMOTION_FILE} 不要重复声明 —— 两个来源就会有两个真值。`);
213
+ }
214
+ return out;
215
+ }
216
+
217
+ export { PromotionFileError };
218
+
219
+ // ── CLI(只校验,给结构门用)────────────────────────────────────────────────
220
+
221
+ export function main(argv) {
222
+ if (argv.length !== 1) bad('E_PROMO_INPUT', '用法:promotion-file.mjs <投稿目录>');
223
+ const r = readPromotionFile(argv[0]);
224
+ process.stderr.write(r === null
225
+ ? `${argv[0]} 没有 ${PROMOTION_FILE}(已注册 namespace 下的 skill 续版本不需要它)。\n`
226
+ : `✔ ${PROMOTION_FILE} 形状合规(owner=${r.owner ? r.owner.kind : '无'},`
227
+ + `provenance=${r.provenance ? r.provenance.kind : '无'})。\n`
228
+ + '⚠️ 形状合规 ≠ 内容可信:origin_* 与 license_evidence 没有自动门能证实,\n'
229
+ + ' 它们归 §8 人工门第 8 条。\n');
230
+ return 0;
231
+ }
232
+
233
+ // 入口守卫比 realpath —— 见 scripts/release/build-timestamp.mjs 里的说明。
234
+ function invokedDirectly() {
235
+ if (process.argv[1] === undefined) return false;
236
+ try {
237
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
238
+ } catch { return true; }
239
+ }
240
+
241
+ if (invokedDirectly()) {
242
+ try { process.exit(main(process.argv.slice(2))); } catch (e) {
243
+ process.stderr.write(`${e.code ? `[${e.code}] ` : ''}${e.message}\n`);
244
+ process.exit(1);
245
+ }
246
+ }
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ // 把 §6 的结构门跑在 `submissions/**` 上 —— 06-submission.md §6。
3
+ //
4
+ // 🔴 **不执行载荷**(§5):这里只读文件、算摘要、比字符串。
5
+ // 没有任何一步会跑投稿带来的脚本。
6
+ //
7
+ // 布局(01-artifacts.md §2):`submissions/<namespace>/<name>@<version>/`
8
+ //
9
+ // 🔴 **一次跑完全部投稿、把所有失败一起报出来**,不是遇到第一个就退出。
10
+ // 投稿者要能一次看到所有问题 —— 让人来回改三轮的门,人会绕着走。
11
+
12
+ import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { join } from 'node:path';
15
+
16
+ import { parseStrict } from '../../src/canonical-json.mjs';
17
+ import { collectTree } from '../../src/packer.mjs';
18
+ import {
19
+ readReserved, assertReservedNamespaceAllowed, assertNoNormalizedCollision,
20
+ assertVersionUnused, assertCapabilityConsistency,
21
+ } from './structural-gates.mjs';
22
+ import { readPromotionFile } from './promotion-file.mjs';
23
+ import { assertSkillFrontmatter } from '../../src/artifact.mjs';
24
+
25
+ const RE_SUBMISSION_DIR = /^([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)@(.+)$/;
26
+
27
+ class RunError extends Error {
28
+ constructor(msg) { super(msg); this.name = 'RunError'; }
29
+ }
30
+
31
+ /** 枚举 `submissions/<ns>/<name>@<ver>/`。 */
32
+ export function scanSubmissions(root) {
33
+ if (!existsSync(root)) return [];
34
+ const out = [];
35
+ for (const ns of readdirSync(root).sort()) {
36
+ for (const entry of readdirSync(join(root, ns)).sort()) {
37
+ const m = RE_SUBMISSION_DIR.exec(entry);
38
+ if (m === null) {
39
+ throw new RunError(
40
+ `submissions/${ns}/${entry} 不合布局 —— 必须是 <name>@<version>/`
41
+ + '(01-artifacts.md §2)。目录名本身就是投稿在声明它是什么,不该靠猜。',
42
+ );
43
+ }
44
+ out.push({ namespace: ns, name: m[1], version: m[2], dir: join(root, ns, entry) });
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * @param {object} a
52
+ * @param {string} a.submissionsRoot
53
+ * @param {object} a.reserved
54
+ * @param {string[]} a.existingIds 已有的全部 ArtifactId(**含已 yank**)
55
+ * @param {Map<string,string[]>} a.existingNamesByNs 同 namespace 内已有的 name
56
+ * @param {boolean} a.byMaintainer
57
+ * @returns {{checked:number, problems:string[]}}
58
+ */
59
+ export function runGates({
60
+ submissionsRoot, reserved, existingIds = [], existingNamesByNs = new Map(),
61
+ byMaintainer = false, registeredNamespaces = new Set(),
62
+ }) {
63
+ const problems = [];
64
+ const subs = scanSubmissions(submissionsRoot);
65
+
66
+ for (const s of subs) {
67
+ const where = `submissions/${s.namespace}/${s.name}@${s.version}`;
68
+ const push = (e) => problems.push(`${where}:${e.message.split('\n')[0]}`);
69
+
70
+ // ① 保留 namespace
71
+ try {
72
+ assertReservedNamespaceAllowed({ namespace: s.namespace, reserved, byMaintainer });
73
+ } catch (e) { push(e); }
74
+
75
+ // ② 归一化重名(同 namespace 内)
76
+ try {
77
+ assertNoNormalizedCollision({
78
+ name: s.name, existing: existingNamesByNs.get(s.namespace) ?? [],
79
+ });
80
+ } catch (e) { push(e); }
81
+
82
+ // ③ 版本未占用(含已 yank)
83
+ // kind 由载荷里有 skill.json 还是 pack.json 决定 —— 目录名不带 kind。
84
+ const isPack = existsSync(join(s.dir, 'pack.json'));
85
+ const isSkill = existsSync(join(s.dir, 'skill.json'));
86
+ if (isPack === isSkill) {
87
+ problems.push(
88
+ `${where}:载荷根必须**恰好**有 skill.json 或 pack.json 其中一个`
89
+ + `(现在 skill.json=${isSkill}、pack.json=${isPack})——`
90
+ + '两个都在时「这是什么制品」有两个答案。',
91
+ );
92
+ continue; // kind 都定不下来,后面的门无从谈起
93
+ }
94
+ const kind = isPack ? 'pack' : 'skill';
95
+ try {
96
+ assertVersionUnused({ id: `${kind}:${s.namespace}/${s.name}@${s.version}`, existingIds });
97
+ } catch (e) { push(e); }
98
+
99
+ // ④ PROMOTION.json:形状 + **必需性**
100
+ // 🔴 在**投稿 PR** 上就检,而不是等到 promote —— 那时投稿已经合并进 main,
101
+ // 再报「你的 PROMOTION.json 写错了」,改起来要走一整轮。
102
+ // 🔴 「有就检」不够:一个没有 provenance 的 pack、或一个未注册 namespace
103
+ // 下没有 claim_owner 的首投,会**通过投稿 CI、合并进 main、然后卡住
104
+ // promote** —— 那时它已经在 main 上了(Codex 2026-08-31)。
105
+ let declared = null;
106
+ try {
107
+ declared = readPromotionFile(s.dir);
108
+ } catch (e) { push(e); }
109
+ if (declared === null && !existsSync(join(s.dir, 'PROMOTION.json'))) {
110
+ if (isPack) {
111
+ problems.push(`${where}:pack 必须有 PROMOTION.json —— `
112
+ + 'pack.json 的键集里没有 provenance(03-packs §2),它只能由投稿声明。');
113
+ }
114
+ if (!registeredNamespaces.has(s.namespace)) {
115
+ problems.push(`${where}:namespace ${s.namespace} 尚未注册,`
116
+ + '必须在 PROMOTION.json 里给出 claim_owner(首次注册)。');
117
+ }
118
+ } else if (declared !== null) {
119
+ if (isPack && declared.provenance === null) {
120
+ problems.push(`${where}:pack 的 PROMOTION.json 必须声明 provenance`);
121
+ }
122
+ if (!registeredNamespaces.has(s.namespace) && declared.owner === null) {
123
+ problems.push(`${where}:namespace ${s.namespace} 尚未注册,`
124
+ + 'PROMOTION.json 必须声明 claim_owner');
125
+ }
126
+ }
127
+
128
+ // ⑤ˢ SKILL.md frontmatter —— 调**建快照将来要调的同一个函数**
129
+ // 🔴 2026-09-02 补上:这道门以前根本不解析 frontmatter,于是 11 个投稿
130
+ // 全绿合并进 main,promote 建快照时才红在 E_FRONTMATTER(10 个用了 YAML
131
+ // 折叠标量 `>`,而解析器是刻意最小化的、只认单行 key: value)。
132
+ // 那正是本文件④处注释里写的那件事:等到 promote 才报,投稿已经在 main 上了。
133
+ // ⚠️ 共用 `assertSkillFrontmatter`,不在这里另写一份 —— 另写就是又一处会分叉的实现。
134
+ if (kind === 'skill') {
135
+ try {
136
+ assertSkillFrontmatter({
137
+ payloadDir: s.dir,
138
+ name: s.name,
139
+ viol: (code, msg) => { throw new Error(`[${code}] ${msg}`); },
140
+ });
141
+ } catch (e) { push(e); }
142
+ }
143
+
144
+ // ⑤ capability 一致性(只对 skill —— pack.json 里没有 capabilities)
145
+ if (kind === 'skill') {
146
+ try {
147
+ const manifest = parseStrict(readFileSync(join(s.dir, 'skill.json'), 'utf8'));
148
+ assertCapabilityConsistency({
149
+ capabilities: manifest.capabilities, entries: collectTree(s.dir),
150
+ });
151
+ } catch (e) { push(e); }
152
+ }
153
+ }
154
+
155
+ return { checked: subs.length, problems };
156
+ }
157
+
158
+ // ── CLI ────────────────────────────────────────────────────────────────────
159
+
160
+ function parseArgs(argv) {
161
+ const o = {};
162
+ for (let i = 0; i < argv.length; i++) {
163
+ const a = argv[i];
164
+ if (!a.startsWith('--')) throw new RunError(`不认得参数 ${a}`);
165
+ const eq = a.indexOf('=');
166
+ const name = eq === -1 ? a : a.slice(0, eq);
167
+ const val = eq === -1 ? argv[++i] : a.slice(eq + 1);
168
+ if (val === undefined) throw new RunError(`${name} 需要一个值`);
169
+ o[name.slice(2)] = val;
170
+ }
171
+ return o;
172
+ }
173
+
174
+ /** `owners.json` 里已注册的 namespace 集合。文件不存在 = 一个都还没注册。 */
175
+ function readRegistered(path) {
176
+ if (!existsSync(path)) return new Set();
177
+ try {
178
+ return new Set(Object.keys(parseStrict(readFileSync(path, 'utf8')).namespaces ?? {}));
179
+ } catch {
180
+ // 🔴 读不出来时**当成空的**是危险的:那会让每个 namespace 都被判成
181
+ // 「首次注册」,于是任何人都能声明 claim_owner。宁可整个门失败。
182
+ throw new RunError(`${path} 读不出来 —— 拒绝在「不知道谁注册过」的情况下判首次注册`);
183
+ }
184
+ }
185
+
186
+ export function main(argv) {
187
+ const o = parseArgs(argv);
188
+ const submissionsRoot = o.submissions ?? 'submissions';
189
+
190
+ // 已有制品:从 artifacts/ 树里数(快照可能还不存在)
191
+ const existingIds = [];
192
+ const existingNamesByNs = new Map();
193
+ const artifactsRoot = o.artifacts ?? 'artifacts';
194
+ if (existsSync(artifactsRoot)) {
195
+ for (const kindDir of readdirSync(artifactsRoot)) {
196
+ const kind = kindDir === 'skills' ? 'skill' : kindDir === 'packs' ? 'pack' : null;
197
+ if (kind === null) continue;
198
+ for (const ns of readdirSync(join(artifactsRoot, kindDir))) {
199
+ for (const name of readdirSync(join(artifactsRoot, kindDir, ns))) {
200
+ if (!existingNamesByNs.has(ns)) existingNamesByNs.set(ns, []);
201
+ existingNamesByNs.get(ns).push(name);
202
+ for (const v of readdirSync(join(artifactsRoot, kindDir, ns, name))) {
203
+ existingIds.push(`${kind}:${ns}/${name}@${v}`);
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ const { checked, problems } = runGates({
211
+ submissionsRoot,
212
+ reserved: readReserved(o.reserved ?? 'registry/reserved.json'),
213
+ existingIds,
214
+ existingNamesByNs,
215
+ // 🔴 「是不是维护者」是 PR 侧的事实,默认 **false**(fail-closed):
216
+ // 没传就按「不是维护者」处理,保留 namespace 因此会被拒。
217
+ byMaintainer: o['by-maintainer'] === 'true',
218
+ // 已注册的 namespace —— 用来判「这次是不是首次注册」
219
+ registeredNamespaces: readRegistered(o.owners ?? 'registry/owners.json'),
220
+ });
221
+
222
+ if (checked === 0) {
223
+ process.stderr.write('没有待检的投稿(submissions/ 为空)。\n');
224
+ return 0;
225
+ }
226
+ if (problems.length) {
227
+ process.stderr.write(`🔴 ${checked} 个投稿里有 ${problems.length} 处不合规:\n`);
228
+ for (const p of problems) process.stderr.write(` · ${p}\n`);
229
+ return 1;
230
+ }
231
+ process.stderr.write(`✔ ${checked} 个投稿全部通过结构门。\n`);
232
+ return 0;
233
+ }
234
+
235
+ export { RunError };
236
+
237
+ // 入口守卫比 realpath —— 见 scripts/release/build-timestamp.mjs 里的说明。
238
+ function invokedDirectly() {
239
+ if (process.argv[1] === undefined) return false;
240
+ try {
241
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
242
+ } catch { return true; }
243
+ }
244
+
245
+ if (invokedDirectly()) {
246
+ try { process.exit(main(process.argv.slice(2))); } catch (e) {
247
+ process.stderr.write(`${e.message}\n`);
248
+ process.exit(1);
249
+ }
250
+ }