agentcss 0.26.0 → 0.27.0

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.
Files changed (44) hide show
  1. package/01-tokens/README.md +1 -1
  2. package/01-tokens/responsive-demo.html +1 -1
  3. package/01-tokens/tokens.core.css +1 -1
  4. package/01-tokens/tokens.css +2 -2
  5. package/02-primitives/date-range/date-range.js +1 -1
  6. package/02-primitives/timeline/demo.html +1 -1
  7. package/03-patterns/drawer/demo.html +1 -1
  8. package/03-patterns/overlay/overlay.css +1 -1
  9. package/03-patterns/states/states.css +1 -1
  10. package/04-recipes/app-shell/README.md +1 -1
  11. package/04-recipes/app-shell/demo.html +1 -1
  12. package/04-recipes/longform/content.html +6 -6
  13. package/04-recipes/report/demo.html +2 -2
  14. package/04-recipes/table/README.md +1 -1
  15. package/04-recipes/table/demo.html +1 -1
  16. package/09-assets/bar/bar.css +1 -1
  17. package/09-assets/model-viewer/model-viewer.js +1 -1
  18. package/AGENTS.md +3 -1
  19. package/CHANGELOG.md +103 -5
  20. package/CONTRIBUTING.md +44 -3
  21. package/README.md +2 -0
  22. package/README.zh-CN.md +2 -0
  23. package/START-HERE.md +12 -5
  24. package/ai/START-HERE.md +40 -6
  25. package/ai/check-html.js +658 -0
  26. package/ai/cli.js +557 -67
  27. package/ai/components.json +1 -1
  28. package/ai/pdf.js +2 -2
  29. package/ai/recipes.json +86 -0
  30. package/ai/scaffold.js +195 -0
  31. package/ai/shot.js +345 -0
  32. package/ai/validate.js +65 -6
  33. package/dist/COSTS.md +10 -10
  34. package/docs/ACCESSIBILITY.md +2 -2
  35. package/docs/ARCHITECTURE.md +116 -0
  36. package/docs/LAYER-BASELINE.md +2 -2
  37. package/docs/PRINT.md +3 -3
  38. package/docs/RELEASE-PROCESS.md +52 -2
  39. package/docs/STABILITY.md +1 -1
  40. package/docs/VERIFY-RELEASE.md +17 -0
  41. package/index.html +3 -3
  42. package/llms-full.txt +17 -3
  43. package/llms.txt +26 -5
  44. package/package.json +2 -1
package/ai/cli.js CHANGED
@@ -16,13 +16,16 @@
16
16
  * 用法
17
17
  * ----------------------------------------------------------------------------
18
18
  * agentcss ai check <doc.json> [--profile=strict]
19
- * agentcss ai validate <file.json> --schema=contract|components|tokens|tokensAudience|tokensTree|deprecations|patch
19
+ * agentcss ai check <page.html> [--recipe=report/finance] [--strict]
20
+ * agentcss ai validate <file.json> --schema=<名字>(见 SCHEMAS,当前 8 份)
20
21
  * agentcss ai diff <a.json> <b.json>
21
22
  * agentcss ai patch <doc.json> <patch.json> [-o out.json]
22
23
  * agentcss ai components [--maturity=stable] [--json]
23
24
  * agentcss ai tokens [--set=light|dark] [--json]
24
25
  * agentcss ai profile 列出三档及其差异
25
26
  * agentcss ai capabilities
27
+ * agentcss ai scaffold <版式> --out <page.html>
28
+ * agentcss ai shot <page.html> --out <png> [--width=390,1440] [--full]
26
29
  * ============================================================================
27
30
  */
28
31
  'use strict';
@@ -47,38 +50,100 @@ function pad(s, n) {
47
50
  return out;
48
51
  }
49
52
 
50
- function readJSON(file) {
51
- var txt = fs.readFileSync(file, 'utf8');
53
+ /* 读 JSON。`fromUser` 区分「调用方给我的文件」与「包内自带的文件」——
54
+ 这两类读失败的性质完全不同,退出码也不同:
55
+ · 用户文件读不到/不是 JSON ⇒ usage(2):改调用参数就能解决
56
+ · 包内文件读不到 ⇒ internal(6):包坏了,是工具/打包的问题
57
+ 不带这个区分时,两者都被顶层兜底成 internal(6),
58
+ 于是"你指错了文件"被报成"工具内部错误"—— 调用方会去查工具的 bug。 */
59
+ function readJSON(file, fromUser) {
60
+ var txt;
61
+ try {
62
+ txt = fs.readFileSync(file, 'utf8');
63
+ } catch (e) {
64
+ var miss = new Error((fromUser ? '读不到 ' : '包内文件读不到 ') + file
65
+ + '(' + (e.code || e.message) + ')');
66
+ miss.kind = fromUser ? 'usage' : 'internal';
67
+ throw miss;
68
+ }
52
69
  try {
53
70
  return JSON.parse(txt);
54
71
  } catch (e) {
55
- throw new Error(file + ' 不是合法 JSON:' + e.message);
72
+ var malformed = new Error(file + ' 不是合法 JSON:' + e.message);
73
+ malformed.kind = fromUser ? 'usage' : 'internal';
74
+ throw malformed;
56
75
  }
57
76
  }
58
77
 
59
78
  function out(obj) {
60
- process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
79
+ /* 🔴 `JSON.stringify(undefined)` 返回的是 **undefined**(不是字符串),
80
+ 拼上 '\n' 之后 stdout 得到字面量 `undefined`,退出码还是 0。
81
+ 0.26.0 的 `ai tokens --set=<拼错的>` 就是这么"成功"的:
82
+ 调用方的 JSON.parse 拿到 `undefined` 三个字,报一个跟真实原因无关的解析错。
83
+ 枚举校验已经把已知那条路堵死,这里再加一道兜底 —— 任何**未来**的
84
+ "忘了判断就 out()" 都会当场变成 internal(退出码 6),而不是一个假成功。 */
85
+ var txt = JSON.stringify(obj, null, 2);
86
+ if (txt === undefined) {
87
+ fail('internal', '要输出的对象是 undefined —— 这是工具的问题(没有结果却走到了输出)');
88
+ }
89
+ process.stdout.write(txt + '\n');
61
90
  }
62
91
 
63
92
  var CURRENT_CMD = '';
64
93
 
65
- /* 🔴 失败也必须能被机器读。
66
- 以前 die() 只往 stderr 写一行中文,`--json` 的调用方拿到的是
67
- 退出码 1 + 空 stdout —— 它分不清「参数写错了」和「进程崩了」。
68
- 现在:带 --json 时,失败输出一个**单一 JSON 文档**,与成功路径一样可解析。 */
69
- function die(msg) {
94
+ /* 🔴 退出码表只有这一份 —— 调用方要能**按码分支**,不必按子命令写 switch。
95
+ *
96
+ * 0 通过
97
+ * 1 判定不通过(页面/文档确实有错,跑完了,结论是不合格)
98
+ * 2 用法错误(参数名、取值、组合不对)
99
+ * 3 环境不具备(没有浏览器、node 不在 PATH)
100
+ * 4 渲染失败(超时、读不出、视口没设成)
101
+ * 5 超出预算(产出了完整文件,只是超了页数)
102
+ * 6 工具内部错误(是工具的 bug,不是你的输入错)
103
+ *
104
+ * 为什么 2/3/4/5 是这个数:`pdf` 早就这么发(CHANGELOG 里公开过
105
+ * 「2 用法 / 3 没浏览器 / 4 渲染失败 / 5 超预算」)。把它们收敛成全 CLI 唯一一套,
106
+ * 而不是新增一套再共存 —— 两套码必然漂移。
107
+ *
108
+ * ⚠️ **1 只表示"判定不通过"**,不表示工具坏了。以前 `die()` 用 1,
109
+ * 于是"参数写错"和"页面有 3 个错"拿到同一个码:调用方要么去改页面(改错方向),
110
+ * 要么去改参数(也改错方向)。这是本轮审计点名的第一个问题。
111
+ */
112
+ var EXIT = {
113
+ findings: 1,
114
+ usage: 2,
115
+ 'no-chrome': 3, env: 3,
116
+ render: 4, timeout: 4, unreadable: 4, viewport: 4,
117
+ 'over-budget': 5,
118
+ internal: 6
119
+ };
120
+
121
+ /* 🔴 失败也必须能被机器读,且**只有一种形状**:
122
+ { ok:false, command, error:{ kind, message }, ...上下文 }
123
+ 以前 `pdf` 发 `{ok:false, code, message}`、其它子命令发 `{ok:false, error:{...}}`
124
+ —— 同一个 CLI 的失败有两种形状,调用方要么写两个分支,要么只看 ok。
125
+ 现在 kind 的取值就是上面那张退出码表的键,`kind` → 码是确定的。 */
126
+ function fail(kind, msg, extra) {
127
+ var k = EXIT[kind] === undefined ? 'internal' : kind;
70
128
  if (isJson()) {
71
- out({
72
- ok: false,
73
- command: CURRENT_CMD || null,
74
- error: { kind: 'usage', message: String(msg) }
75
- });
76
- process.exit(1);
129
+ var doc = { ok: false, command: CURRENT_CMD || null,
130
+ error: { kind: k, message: String(msg) } };
131
+ if (extra) for (var ek in extra) {
132
+ if (Object.prototype.hasOwnProperty.call(extra, ek)) doc[ek] = extra[ek];
133
+ }
134
+ out(doc);
135
+ } else {
136
+ process.stderr.write('[FAIL] ' + msg + '\n');
137
+ if (k === 'internal') {
138
+ process.stderr.write(' 这是工具内部错误,不是你参数写错了'
139
+ + '(退出码 ' + EXIT.internal + ')\n');
140
+ }
77
141
  }
78
- process.stderr.write('[FAIL] ' + msg + '\n');
79
- process.exit(1);
142
+ process.exit(EXIT[k]);
80
143
  }
81
144
 
145
+ function die(msg) { fail('usage', msg); }
146
+
82
147
  /** 是否要求 JSON 输出。`--json` / `--json=true` 都认,`--json=false` 不认。 */
83
148
  function isJson() {
84
149
  var v = flag('json', false);
@@ -107,16 +172,114 @@ function flag(name, def) {
107
172
  return v;
108
173
  }
109
174
 
175
+ /* ---------------------------------------------------------------- 枚举参数
176
+ 🔴 非法取值**必须报错**,不许退化成"空结果"。
177
+ 0.26.0 的实测(本轮审计 F1):
178
+ ai tokens --set=not-a-theme --json → stdout 是字面量 `undefined`,退出码 0
179
+ ai tree --set=not-a-theme --json → 同上
180
+ ai components --maturity=not-a-level → `[]`,退出码 0
181
+ 对人是"没输出",对 Agent 是**错误的控制流**:它会把拼错的参数读成
182
+ "这个库没有 beta 组件",再据此写出错误的结论,而且全程没有报错。
183
+
184
+ 取值**不在本文件里重抄**:
185
+ · maturity 的词表来自 profiles.json 的 allowedMaturity(三档取并集)
186
+ · set / cat 来自 tokens.json / tokens.tree.json 的键与 meta.categories
187
+ 抄一份就会漂 —— 组件升到 stable 后,这里还留着 beta,判据就恒真。 */
188
+ var ENUMS = {
189
+ set: function () {
190
+ return Object.keys(readJSON(path.join(AI, 'tokens.json')).sets);
191
+ },
192
+ cat: function () {
193
+ return readJSON(path.join(AI, 'tokens.tree.json')).meta.categories;
194
+ },
195
+ maturity: function () {
196
+ var p = readJSON(path.join(AI, 'profiles.json')).profiles;
197
+ var seen = [];
198
+ Object.keys(p).forEach(function (k) {
199
+ (p[k].allowedMaturity || []).forEach(function (m) {
200
+ if (seen.indexOf(m) < 0) seen.push(m);
201
+ });
202
+ });
203
+ return seen;
204
+ },
205
+ profile: ['creative', 'standard', 'strict'],
206
+ medium: ['screen', 'paper'],
207
+ /* 版式清单来自 `ai/recipes.json`(由 `check-html.js` 读取并校验形状),
208
+ 不在这里重抄 —— 加一个版式只改一个地方。 */
209
+ recipe: function () {
210
+ var r = require('./check-html.js').readRecipes();
211
+ return r ? Object.keys(r.recipes) : [];
212
+ }
213
+ };
214
+
215
+ function enumValues(name) {
216
+ var src = ENUMS[name];
217
+ if (!src) return null;
218
+ return typeof src === 'function' ? src() : src;
219
+ }
220
+
221
+ /* 同一个枚举开关出现两次 ⇒ 报错。`--set=light --set=dark` 以前**静默取第一个**:
222
+ 调用方以为切到了 dark,拿到的却是 light,而且没有任何迹象。 */
223
+ function flagCount(name) {
224
+ var eq = '--' + name + '=', bare = '--' + name, n = 0;
225
+ for (var i = 0; i < process.argv.length; i++) {
226
+ var a = process.argv[i];
227
+ if (a === bare || a.indexOf(eq) === 0) n++;
228
+ }
229
+ return n;
230
+ }
231
+
232
+ /* 读一个枚举开关;没给返回 null。给了就一定是合法值,否则当场退出(非 0)。 */
233
+ function enumFlag(name) {
234
+ var v = flag(name, null);
235
+ if (v === null) return null;
236
+ var allowed = enumValues(name);
237
+ if (!allowed) fail('internal', '枚举 ' + name + ' 没有登记在 ENUMS 里');
238
+ var hint = '(可用:' + allowed.join(' | ') + ')';
239
+ var n = flagCount(name);
240
+ if (n > 1) die('--' + name + ' 给了 ' + n + ' 次。只认一个值 —— 想对比就分两次调用' + hint);
241
+ if (v === true) die('--' + name + ' 后面要跟取值' + hint);
242
+ if (String(v) === '') die('--' + name + '= 是空值' + hint);
243
+ if (allowed.indexOf(String(v)) < 0) {
244
+ die('--' + name + '=' + v + ' 不是合法值' + hint);
245
+ }
246
+ return String(v);
247
+ }
248
+
110
249
  // ---------------------------------------------------------------- check
111
250
  function cmdCheck(args) {
112
251
  var file = args[0];
113
- if (!file) die('用法:ai check <doc.json> [--profile=creative|standard|strict] [--medium=screen|paper]');
114
- var doc = readJSON(file);
115
- var profile = flag('profile', doc.profile || 'standard');
252
+ /* 这两个开关**自己就能构成一次调用**(`ai check --list-recipes`),
253
+ 所以必须在"必须有文件"之前处理 —— 否则它会被当成文件名去读。 */
254
+ var lr = flag('list-recipes', false);
255
+ if (lr === true || lr === 'true') return cmdCheckHtml(null);
256
+ if (!file) {
257
+ die('用法:ai check <doc.json> [--profile=creative|standard|strict] [--medium=screen|paper]\n'
258
+ + ' ai check <page.html> [--recipe=<版式>] [--strict] [--list-recipes]');
259
+ }
260
+ /* 🔴 产物与计划是两种东西,判据也不重叠:
261
+ · `.json` → 文档模型(组件在不在档位里、节点类型对不对)
262
+ · `.html` → 写出来的那一页(类名有没有定义、结构缺不缺件)
263
+ 用扩展名分派,是因为调用方最容易做对的一件事就是"把文件名给对"。
264
+ 显式 `--html` 留给没有扩展名/扩展名不是 html 的临时文件;
265
+ `--recipe` 同理(版式判据只对页面有意义)。 */
266
+ var wantHtml = flag('html', false);
267
+ var hasRecipe = flag('recipe', null) !== null;
268
+ if (wantHtml === true || wantHtml === 'true' || hasRecipe || /\.html?$/i.test(file)) {
269
+ return cmdCheckHtml(file);
270
+ }
271
+ var doc = readJSON(file, true);
272
+ /* 档位优先取命令行,其次取文档自己声明的,最后 standard。
273
+ ⚠️ 枚举校验必须在**取值之后**做:「文档里写了什么」也要是合法档位。 */
274
+ var profile = enumFlag('profile') || doc.profile || 'standard';
275
+ if (enumValues('profile').indexOf(String(profile)) < 0) {
276
+ die('文档的 profile 字段是 ' + JSON.stringify(profile)
277
+ + ',不是合法档位(可用:' + enumValues('profile').join(' | ') + ')');
278
+ }
116
279
  /* 介质是**与档位正交**的第二根轴:
117
280
  strict+paper 与 creative+paper 都必须合法,做成第四个 profile 会把两维压成一维。
118
281
  默认 screen ⇒ 今天所有调用零成本;paper 才追加打印义务。 */
119
- var medium = flag('medium', 'screen');
282
+ var medium = enumFlag('medium') || 'screen';
120
283
  var r = validate.validateDoc(doc, { profile: profile, medium: medium });
121
284
  var json = isJson();
122
285
 
@@ -143,6 +306,91 @@ function cmdCheck(args) {
143
306
  process.exit(r.ok ? 0 : 1);
144
307
  }
145
308
 
309
+ // ------------------------------------------------------- check(产物 / HTML)
310
+ /* ⚠️ 判据全在 `ai/check-html.js` 里,这里只负责"把结果说人话"和退出码。
311
+ 分开的理由:门禁(05-audit/html-check-gate.js)要能直接 require 判据,
312
+ 不必为了拿一份结论去 fork 一个进程、再去解析中文输出。 */
313
+ function cmdCheckHtml(file) {
314
+ var checkHtml = require('./check-html.js');
315
+ /* 🔴 `--profile` / `--medium` 是**文档模型**的轴,对一页 HTML 没有任何意义。
316
+ 以前它们被静默忽略 ⇒ `check page.html --profile=strict` 退出码 0,
317
+ 调用方以为"按 strict 档查过了"。**善意的忽略就是假绿。** */
318
+ if (flag('profile', null) !== null) {
319
+ die('--profile 只对 doc.json 有效(HTML 用 --recipe 指定版式)');
320
+ }
321
+ if (flag('medium', null) !== null) {
322
+ die('--medium 只对 doc.json 有效(HTML 用 --recipe 指定版式)');
323
+ }
324
+ /* `enumFlag` 已经把"没给值 / 空值 / 非法值 / 给两次"都挡在外面了,
325
+ 这里拿到的要么是 null,要么是合法版式名。 */
326
+ var recipe = enumFlag('recipe');
327
+ var listRecipes = flag('list-recipes', false);
328
+ if (listRecipes === true || listRecipes === 'true') {
329
+ var all = checkHtml.readRecipes();
330
+ if (!all) die('ai/recipes.json 读不到 —— 这份包不完整');
331
+ if (isJson()) { out(all); process.exit(0); }
332
+ process.stdout.write(' === 可校验的版式 ===\n\n');
333
+ Object.keys(all.recipes).forEach(function (k) {
334
+ var r = all.recipes[k];
335
+ process.stdout.write(' ' + pad(k, 20) + r.label + '\n');
336
+ process.stdout.write(' ' + r.root + ' 必有件 ' + r.requires.length + ' 条'
337
+ + (r.unchecked && r.unchecked.length
338
+ ? ' · 另有 ' + r.unchecked.length + ' 条只能人工判' : '') + '\n');
339
+ });
340
+ process.exit(0);
341
+ }
342
+ /* `--strict` 把警告当失败。默认不这么做:警告里有一类是"页面自己的类名",
343
+ 那在单文件页面上完全合法,不该把一页正常的报告判死。 */
344
+ var strictFlag = flag('strict', false);
345
+ var r = checkHtml.checkPage(file, {
346
+ recipe: recipe || null,
347
+ strict: strictFlag === true || strictFlag === 'true'
348
+ });
349
+
350
+ if (r.code === 'unreadable') die(r.message);
351
+ if (r.code === 'unknown-recipe') die(r.message);
352
+
353
+ if (isJson()) {
354
+ out(r);
355
+ process.exit(r.ok ? 0 : 1);
356
+ }
357
+
358
+ process.stdout.write(' === 页面检查(' + r.file + ')===\n');
359
+ process.stdout.write(' ' + r.rules.length + ' 条判据 · 本地样式表 ' + r.sources.localSheets
360
+ + ' 个 · 库内类名 ' + r.sources.libraryClasses
361
+ + '(' + r.sources.libraryCssFiles + ' 个 CSS)'
362
+ + (r.recipe ? ' · 版式 ' + r.recipe : '') + '\n\n');
363
+
364
+ r.findings.forEach(function (f) {
365
+ process.stdout.write(' [' + (f.level === 'error' ? 'FAIL' : 'WARN') + '] '
366
+ + pad(f.line + ' 行', 8) + pad(f.rule, 18) + f.msg + '\n');
367
+ process.stdout.write(' ' + checkHtml.RULES[f.rule].hint + '\n');
368
+ });
369
+
370
+ if (r.skipped.length) {
371
+ process.stdout.write('\n ⚠️ 未判定(**不计入通过**):\n');
372
+ r.skipped.forEach(function (s) {
373
+ process.stdout.write(' · ' + s.rule + ' —— ' + s.why + '\n');
374
+ });
375
+ }
376
+ if (r.notes.length) {
377
+ process.stdout.write('\n ⚠️ 这一版还有判不了的部分(只提示,不判过):\n');
378
+ r.notes.forEach(function (n) {
379
+ process.stdout.write(' · ' + n.label + ' —— ' + n.why + '\n');
380
+ });
381
+ }
382
+
383
+ process.stdout.write('\n');
384
+ if (r.ok) {
385
+ process.stdout.write(' OK ' + r.rules.length + ' 条判据没有发现问题('
386
+ + r.counts.error + ' 错误 / ' + r.counts.warn + ' 警告)\n');
387
+ } else {
388
+ process.stdout.write(' FAIL ' + r.counts.error + ' 个错误 / '
389
+ + r.counts.warn + ' 个警告\n');
390
+ }
391
+ process.exit(r.ok ? 0 : 1);
392
+ }
393
+
146
394
  // ---------------------------------------------------------------- validate
147
395
  var SCHEMAS = {
148
396
  contract: 'contract.schema.json',
@@ -154,7 +402,7 @@ var SCHEMAS = {
154
402
  patch: 'patch.schema.json',
155
403
  /* behaviors 是稳定性承诺里的核心契约(微行为矩阵),
156
404
  它的 schema 文件一直都在,但这里漏登记 ⇒ `--schema=behaviors` 报
157
- "未知 schema",等于这份契约**没有任何工具能校验**(E298)。 */
405
+ "未知 schema",等于这份契约**没有任何工具能校验**。 */
158
406
  behaviors: 'behaviors.schema.json'
159
407
  };
160
408
 
@@ -165,7 +413,7 @@ function cmdValidate(args) {
165
413
  if (!SCHEMAS[which]) die('未知 schema:' + which);
166
414
  validate.assertSchemasUseOnlySupported();
167
415
  var schema = readJSON(path.join(AI, SCHEMAS[which]));
168
- var data = readJSON(file);
416
+ var data = readJSON(file, true);
169
417
  var r = validate.validate(schema, data);
170
418
  if (isJson()) {
171
419
  out({ ok: r.ok, schema: which, file: file, errors: r.errors || [] });
@@ -191,7 +439,7 @@ function cmdValidate(args) {
191
439
  function cmdDiff(args) {
192
440
  var fa = args[0], fb = args[1];
193
441
  if (!fa || !fb) die('用法:ai diff <改动前.json> <改动后.json>');
194
- var a = readJSON(fa), b = readJSON(fb);
442
+ var a = readJSON(fa, true), b = readJSON(fb, true);
195
443
  var na = a.nodes || {}, nb = b.nodes || {};
196
444
  var added = [], removed = [], changed = [], moved = [];
197
445
 
@@ -242,8 +490,8 @@ function cmdDiff(args) {
242
490
  function cmdPatch(args) {
243
491
  var fdoc = args[0], fpatch = args[1];
244
492
  if (!fdoc || !fpatch) die('用法:ai patch <doc.json> <patch.json> [-o out.json]');
245
- var doc = readJSON(fdoc);
246
- var p = readJSON(fpatch);
493
+ var doc = readJSON(fdoc, true);
494
+ var p = readJSON(fpatch, true);
247
495
  var r = patch.apply(doc, p);
248
496
 
249
497
  if (!r.ok) {
@@ -384,7 +632,7 @@ function cmdPitfalls(args) {
384
632
  // ---------------------------------------------------------------- 列表类
385
633
  function cmdComponents(args) {
386
634
  var doc = readJSON(path.join(AI, 'components.json'));
387
- var m = flag('maturity', null);
635
+ var m = enumFlag('maturity');
388
636
  var list = doc.components.filter(function (c) {
389
637
  return !m || c.maturity === m;
390
638
  });
@@ -570,7 +818,7 @@ function cmdAssets(args) {
570
818
 
571
819
  function cmdTokens(args) {
572
820
  var doc = readJSON(path.join(AI, 'tokens.json'));
573
- var set = flag('set', null);
821
+ var set = enumFlag('set');
574
822
  if (isJson()) {
575
823
  out(set ? doc.sets[set] : doc);
576
824
  process.exit(0);
@@ -592,8 +840,8 @@ function cmdTokens(args) {
592
840
 
593
841
  function cmdTree(args) {
594
842
  var doc = readJSON(path.join(AI, 'tokens.tree.json'));
595
- var set = flag('set', null);
596
- var cat = flag('cat', null);
843
+ var set = enumFlag('set');
844
+ var cat = enumFlag('cat');
597
845
  if (isJson()) {
598
846
  out(cat && set ? doc.sets[set][cat] : (set ? doc.sets[set] : doc));
599
847
  process.exit(0);
@@ -621,7 +869,14 @@ function cmdTree(args) {
621
869
  process.exit(0);
622
870
  }
623
871
 
624
- function cmdProfile() {
872
+ function cmdProfile(args) {
873
+ /* 多余的位置参数以前被**静默忽略**:`ai profile zzz` 打印三档、退出码 0,
874
+ 调用方会以为"zzz 这一档存在"。只拒绝位置参数,开关照收(`--json`)。 */
875
+ var extra = (args || []).filter(function (a) { return String(a).charAt(0) !== '-'; });
876
+ if (extra.length) {
877
+ die('ai profile 不接受位置参数(收到 ' + extra.join(' ')
878
+ + ')。想查某一档的行为,用 ai check <doc.json> --profile=<档位>');
879
+ }
625
880
  var doc = readJSON(path.join(AI, 'profiles.json'));
626
881
  if (isJson()) { out(doc); process.exit(0); }
627
882
  process.stdout.write(' === 三档 Profile ===\n\n');
@@ -642,22 +897,12 @@ function cmdProfile() {
642
897
  }
643
898
 
644
899
  // ---------------------------------------------------------------- pdf
645
- /* 退出码与其它子命令分开:调用方(CI / 编辑器)要能分清
646
- ① 参数写错 ② 这台机器上没有浏览器 ③ 渲染失败 ④ 超预算。
647
- 合成一个 1 的话,"没有 Chrome"会被当成"页面有错"去查,方向就错了。 */
648
- var PDF_EXIT = {
649
- usage: 2, 'no-chrome': 3, render: 4, timeout: 4, unreadable: 4, 'over-budget': 5
650
- };
651
-
900
+ /* 退出码用全局那一套(顶部 `EXIT`):2 用法 / 3 没浏览器 / 4 渲染失败 / 5 超预算。
901
+ 这几条**先于其它子命令**定下来、也早在 CHANGELOG 里公开过 ——
902
+ 所以收敛方向是「其它子命令向它看齐」,不是再定义一套。 */
652
903
  function cmdPdf(args) {
653
- /* ⚠️ pdf 的参数错误一律退出 2(与 PDF_EXIT.usage 一致),**不能走 die()** ——
654
- die() 退出 1,而 1 在这套约定里表示"进程/未分类失败"。
655
- 两种用法错误给两个退出码,调用方就没法按码分支了。 */
656
- function bad(msg) {
657
- if (isJson()) out({ ok: false, command: 'pdf', code: 'usage', message: msg });
658
- else process.stderr.write(' [FAIL] ' + msg + '\n');
659
- process.exit(PDF_EXIT.usage);
660
- }
904
+ /* 用法错误一律 usage(退出码 2),与全 CLI 其它子命令同一个出口。 */
905
+ function bad(msg) { die(msg); }
661
906
  var src = args[0];
662
907
  if (!src) {
663
908
  bad('用法:agentcss pdf <页面.html|URL> --out <file.pdf> '
@@ -674,7 +919,7 @@ function cmdPdf(args) {
674
919
  var budget = flag('max-pages', null);
675
920
  if (budget === true) bad('--max-pages 后面要跟数字(不设 = 无界)');
676
921
  /* 先按字符串校验再转数字:让报错里出现**使用者打进去的那串字符**。
677
- 直接 Number() 再报错时,`abc` 已经变成 NaN,报「收到 NaN/null」是在念实现细节(E264)。 */
922
+ 直接 Number() 再报错时,`abc` 已经变成 NaN,报「收到 NaN/null」是在念实现细节。 */
678
923
  if (budget !== null && !/^\d+$/.test(String(budget))) {
679
924
  bad('--max-pages 必须是 ≥1 的整数,收到 ' + JSON.stringify(String(budget)));
680
925
  }
@@ -708,17 +953,21 @@ function cmdPdf(args) {
708
953
  chrome: r.chrome || null,
709
954
  ms: r.ms || null
710
955
  };
711
- if (isJson()) {
712
- out(report);
713
- process.exit(r.ok ? 0 : (PDF_EXIT[r.code] || 4));
714
- }
715
956
  if (!r.ok) {
716
- process.stderr.write(' [FAIL] ' + r.message + '\n');
717
- if (r.code === 'over-budget') {
718
- process.stderr.write(' (退出码 ' + (PDF_EXIT[r.code] || 4)
719
- + ':预算超了不是渲染出错,产物是完整的)\n');
957
+ /* 失败时**保留上下文**(产物路径 / 页数 / 预算):
958
+ 超预算这一类的产物是完整的,把这些字段丢掉等于让调用方再猜一次。 */
959
+ if (r.code === 'over-budget' && !isJson()) {
960
+ process.stderr.write(' (预算超了不是渲染出错,产物是完整的)\n');
720
961
  }
721
- process.exit(PDF_EXIT[r.code] || 4);
962
+ fail(r.code || 'render', r.message, {
963
+ out: r.out || null,
964
+ pages: r.pages === undefined ? null : r.pages,
965
+ budget: r.budget === undefined ? null : r.budget
966
+ });
967
+ }
968
+ if (isJson()) {
969
+ out(report);
970
+ process.exit(0);
722
971
  }
723
972
  process.stdout.write(' OK ' + r.out + '\n');
724
973
  process.stdout.write(' ' + r.pages + ' 页 · ' + report.paperSize + ' · '
@@ -747,7 +996,204 @@ function cmdCapabilities() {
747
996
  process.exit(0);
748
997
  }
749
998
 
999
+ // ---------------------------------------------------------------- scaffold
1000
+ function cmdScaffold(args) {
1001
+ var scaffold = require('./scaffold.js');
1002
+ var id = args[0];
1003
+
1004
+ if (!id || id === 'list') {
1005
+ if (isJson()) { out({ ok: true, command: 'scaffold', recipes: scaffold.list() }); process.exit(0); }
1006
+ process.stdout.write(' === 可生成的壳 ===\n\n');
1007
+ scaffold.list().forEach(function (r) {
1008
+ process.stdout.write(' ' + pad(r.id, 20) + r.label + '\n');
1009
+ });
1010
+ process.stdout.write('\n 用法:agentcss ai scaffold <版式> --out <page.html>\n');
1011
+ process.exit(0);
1012
+ }
1013
+
1014
+ var outFile = flag('out', null);
1015
+ if (!outFile || outFile === true) {
1016
+ die('用法:ai scaffold ' + id + ' --out <page.html> [--css-prefix=<样式表前缀>]');
1017
+ }
1018
+ var prefix = flag('css-prefix', null);
1019
+ if (prefix === true) die('--css-prefix 后面要跟路径(例:node_modules/agentcss)');
1020
+
1021
+ var r = scaffold.render(id, { cssPrefix: prefix === null ? undefined : prefix });
1022
+ if (!r.ok) die(r.message);
1023
+
1024
+ var abs = path.resolve(outFile);
1025
+ if (fs.existsSync(abs)) {
1026
+ /* 不覆盖:壳是用来改的,覆盖掉一份正在写的页面比报错更贵。
1027
+ 真要覆盖就自己删 —— 这一步不该由工具代做。 */
1028
+ die('目标文件已存在:' + abs + '\n 壳不会覆盖已有文件(它可能正在被改)。'
1029
+ + '换一个 --out,或先删掉它。');
1030
+ }
1031
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1032
+ fs.writeFileSync(abs, r.html, 'utf8');
1033
+
1034
+ if (isJson()) {
1035
+ out({
1036
+ ok: true, command: 'scaffold', recipe: r.id, out: abs,
1037
+ bytes: Buffer.byteLength(r.html), requires: r.requires
1038
+ });
1039
+ process.exit(0);
1040
+ }
1041
+ process.stdout.write(' OK 已写出 ' + abs + '(' + Buffer.byteLength(r.html) + ' 字节)\n');
1042
+ process.stdout.write(' 版式 ' + r.id + ' · 必有件 ' + r.requires.length + ' 条'
1043
+ + (r.requires.length ? ':' + r.requires.join(' ') : '') + '\n');
1044
+ process.stdout.write(' 下一步:agentcss ai check ' + outFile + ' --recipe=' + r.id + '\n');
1045
+ process.stdout.write(' 再下一步:agentcss ai shot ' + outFile + ' --out shot-{width}.png --width=390,1440 --full\n');
1046
+ process.exit(0);
1047
+ }
1048
+
1049
+ // ---------------------------------------------------------------- shot
1050
+ /* shot 的 code 与 pdf 同一批语义(usage / no-chrome / render / timeout),
1051
+ 多一点 viewport(视口没设成 = 渲染失败)。都走顶部那张全局 `EXIT` ——
1052
+ 调用方按码分支时,不该因为换了个子命令就得记第二张表。 */
1053
+
1054
+ function cmdShot(args) {
1055
+ var shot = require('./shot.js');
1056
+ var src = args[0];
1057
+
1058
+ function bad(msg) { die(msg); }
1059
+
1060
+ if (!src) {
1061
+ bad('用法:agentcss shot <页面.html|URL> --out <file.png> [--width=390,1440] [--height=900] '
1062
+ + '[--full] [--scale=2] [--wait=800] [--chrome=<path>] [--json]');
1063
+ }
1064
+ var outFile = flag('out', null);
1065
+ if (!outFile || outFile === true) bad('缺 --out(多宽度时用 {width} 占位,例:shot-{width}.png)');
1066
+
1067
+ var wRaw = flag('width', null);
1068
+ var widths = [1440];
1069
+ if (wRaw === true) bad('--width 后面要跟数字(多个用逗号分隔,例:390,1440)');
1070
+ if (typeof wRaw === 'string') {
1071
+ widths = wRaw.split(',').map(function (s) { return s.trim(); })
1072
+ .filter(Boolean).map(function (s) { return /^\d+$/.test(s) ? Number(s) : NaN; });
1073
+ if (!widths.length || widths.some(function (n) { return !isFinite(n); })) {
1074
+ bad('--width 只接受整数,收到 ' + JSON.stringify(wRaw));
1075
+ }
1076
+ }
1077
+
1078
+ var hRaw = flag('height', null);
1079
+ if (hRaw === true) bad('--height 后面要跟数字(例:900)');
1080
+ if (typeof hRaw === 'string' && !/^\d+$/.test(hRaw)) {
1081
+ bad('--height 只接受整数,收到 ' + JSON.stringify(hRaw));
1082
+ }
1083
+
1084
+ var scaleRaw = flag('scale', null);
1085
+ if (scaleRaw === true) bad('--scale 后面要跟数字(1 / 2)');
1086
+ if (typeof scaleRaw === 'string' && !/^\d+(\.\d+)?$/.test(scaleRaw)) {
1087
+ bad('--scale 只接受数字,收到 ' + JSON.stringify(scaleRaw));
1088
+ }
1089
+
1090
+ var waitRaw = flag('wait', null);
1091
+ if (waitRaw === true) bad('--wait 后面要跟毫秒数');
1092
+ if (typeof waitRaw === 'string' && !/^\d+$/.test(waitRaw)) {
1093
+ bad('--wait 只接受整数毫秒,收到 ' + JSON.stringify(waitRaw));
1094
+ }
1095
+
1096
+ var chrome = flag('chrome', null);
1097
+ if (chrome === true) bad('--chrome 后面要跟可执行文件路径');
1098
+
1099
+ var fullFlag = flag('full', false);
1100
+ var full = fullFlag === true || fullFlag === 'true';
1101
+
1102
+ shot.shoot({
1103
+ src: src,
1104
+ out: outFile,
1105
+ widths: widths,
1106
+ height: hRaw === null ? undefined : Number(hRaw),
1107
+ full: full,
1108
+ scale: scaleRaw === null ? undefined : Number(scaleRaw),
1109
+ wait: waitRaw === null ? undefined : Number(waitRaw),
1110
+ chrome: chrome === null ? undefined : chrome
1111
+ }).then(function (r) {
1112
+ /* 🔴 视口必须等于请求的宽度。不等就说明"设视口"这一步没生效
1113
+ (正是 `--screenshot --window-size` 那条老路会静默发生的失败)。
1114
+ 这时候不能把图交出去:一张宽度不对的图比没有图更误导。 */
1115
+ if (r.ok) {
1116
+ var wrong = r.shots.filter(function (s) { return s.viewport !== null && s.viewport !== s.width; });
1117
+ if (wrong.length) {
1118
+ r.ok = false;
1119
+ r.code = 'viewport';
1120
+ r.message = '视口没有设成请求的宽度:' + wrong.map(function (s) {
1121
+ return '要 ' + s.width + ' 实际 ' + s.viewport;
1122
+ }).join(';') + '\n 这类图看起来是对的,实际是另一个宽度下的排版。';
1123
+ }
1124
+ }
1125
+
1126
+ if (isJson()) {
1127
+ out(r);
1128
+ process.exitCode = r.ok ? 0 : (EXIT[r.code] || EXIT.render);
1129
+ return;
1130
+ }
1131
+ if (!r.ok) {
1132
+ process.stderr.write(' [FAIL] ' + r.message + '\n');
1133
+ process.exitCode = EXIT[r.code] || EXIT.render;
1134
+ return;
1135
+ }
1136
+ r.shots.forEach(function (s) {
1137
+ process.stdout.write(' OK ' + s.out + '\n');
1138
+ process.stdout.write(' ' + s.png.width + '×' + s.png.height + ' px'
1139
+ + ' · 视口宽 ' + s.width + ' · 内容高 ' + s.scrollHeight
1140
+ + ' · ' + Math.round(s.bytes / 1024) + ' KB · ' + s.ms + ' ms'
1141
+ + (s.truncatedAt ? ' · 🔴 已截断到 ' + s.png.height + '(原 ' + s.truncatedAt + ')' : '') + '\n');
1142
+ if (s.overflowCount) {
1143
+ process.stdout.write(' ⚠️ 这个宽度下横向溢出 ' + s.overflowCount + ' 处'
1144
+ + '(页面宽 ' + s.scrollWidth + ' > 视口 ' + s.viewport + '):' + s.overflow.join('、') + '\n');
1145
+ }
1146
+ });
1147
+ if (r.consoleErrors.length) {
1148
+ process.stdout.write('\n ⚠️ 页面里有 ' + r.consoleErrors.length + ' 条 JS 报错 ——'
1149
+ + '图可能看起来是空的:\n');
1150
+ r.consoleErrors.slice(0, 5).forEach(function (e) {
1151
+ process.stdout.write(' · ' + e + '\n');
1152
+ });
1153
+ }
1154
+ process.exitCode = 0;
1155
+ }).catch(function (e) {
1156
+ fail('render', String(e && e.message || e));
1157
+ });
1158
+ }
1159
+
750
1160
  // ---------------------------------------------------------------- 分发
1161
+ /* 🔴 每个命令认识哪些开关,**只在这一处声明**。
1162
+ 为什么要声明:以前无关开关被静默忽略 ——
1163
+ `ai components --set=dark` 退出码 0、什么都不说,
1164
+ 调用方会以为"按 dark 过滤过了"。**善意的忽略就是假绿**,
1165
+ 而 Agent 不会去核对"这个开关到底生效没有"。
1166
+ `json` / `h` 是所有命令共有的(输出格式与帮助)。 */
1167
+ var GLOBAL_FLAGS = ['json', 'h', 'help'];
1168
+
1169
+ var FLAGS = {
1170
+ check: ['profile', 'medium', 'recipe', 'strict', 'list-recipes', 'html'],
1171
+ validate: ['schema'],
1172
+ diff: [],
1173
+ patch: ['o'],
1174
+ components: ['maturity'],
1175
+ assets: [],
1176
+ tokens: ['set'],
1177
+ tree: ['set', 'cat'],
1178
+ profile: [],
1179
+ capabilities: [],
1180
+ pitfalls: ['component', 'scope', 'tag'],
1181
+ pdf: ['out', 'paper', 'margin', 'max-pages', 'chrome'],
1182
+ scaffold: ['out', 'css-prefix'],
1183
+ shot: ['out', 'width', 'height', 'full', 'scale', 'wait', 'chrome']
1184
+ };
1185
+
1186
+ function rejectUnknownFlags(cmd, allowed) {
1187
+ for (var i = 2; i < process.argv.length; i++) {
1188
+ var a = process.argv[i];
1189
+ if (a.charAt(0) !== '-' || a === '-' || a === '--') continue;
1190
+ var name = a.replace(/^--?/, '').split('=')[0];
1191
+ if (allowed.indexOf(name) >= 0) continue;
1192
+ die('`' + cmd + '` 不认识开关 ' + (a.charAt(1) === '-' ? '--' : '-') + name
1193
+ + '(本命令支持:' + allowed.join(' / ') + ')');
1194
+ }
1195
+ }
1196
+
751
1197
  var CMDS = {
752
1198
  check: cmdCheck,
753
1199
  validate: cmdValidate,
@@ -760,19 +1206,24 @@ var CMDS = {
760
1206
  profile: cmdProfile,
761
1207
  capabilities: cmdCapabilities,
762
1208
  pitfalls: cmdPitfalls,
763
- pdf: cmdPdf
1209
+ pdf: cmdPdf,
1210
+ scaffold: cmdScaffold,
1211
+ shot: cmdShot
764
1212
  };
765
1213
 
766
- function main() {
767
- var argv = process.argv.slice(2);
768
- if (!argv.length || argv[0] === 'help' || argv[0] === '--help') {
769
- process.stdout.write(
1214
+ /* 帮助正文单独成函数:`main()` 里有三处要打印它
1215
+ (无参数 / 显式 help / 任意位置的 --help),复制三份必然漂。 */
1216
+ function usage() {
1217
+ process.stdout.write(
770
1218
  'agentcss ai —— 独立校验入口(零依赖、不联网、不依赖任何模型)\n\n' +
771
1219
  ' ai check <doc.json> [--profile=creative|standard|strict] [--medium=screen|paper] [--json]\n' +
772
- ' ai validate <file.json> --schema=contract|components|tokens|tokensAudience|tokensTree|deprecations|patch [--json]\n' +
1220
+ ' ai check <page.html> [--recipe=<版式>] [--strict] [--list-recipes] [--json]\n' +
1221
+ ' 产物检查:类名有没有定义、引没引到样式表、图/表/标题缺不缺件\n' +
1222
+ ' ai validate <file.json> --schema='
1223
+ + Object.keys(SCHEMAS).join('|') + ' [--json]\n' +
773
1224
  ' ai diff <改动前.json> <改动后.json> [--json] 按语义 id 比对\n' +
774
1225
  ' ai patch <doc.json> <patch.json> [-o out.json] [--json]\n' +
775
- ' ai components [--maturity=stable|beta|alpha] [--json]\n' +
1226
+ ' ai components [--maturity=' + enumValues('maturity').join('|') + '] [--json]\n' +
776
1227
  ' ai assets [--json] 组件之外的资产:布局 / 微行为核 / 示例页 / 适配器\n' +
777
1228
  ' ai tokens [--set=light|dark] [--json]\n' +
778
1229
  ' ai tree [--set=light|dark] [--cat=color] [--json]\n' +
@@ -782,12 +1233,32 @@ function main() {
782
1233
  ' ai pdf <页面.html|URL> --out <file.pdf> [--paper=a4|letter|a5|a3|legal]\n' +
783
1234
  ' [--margin="18mm 15mm"] [--max-pages=N] [--chrome=<path>] [--json]\n' +
784
1235
  ' HTML → PDF。零依赖(用你机器上已装的 Chrome),@page 由导出方给。\n' +
1236
+ ' ai scaffold <版式> --out <page.html> [--css-prefix=<前缀>] [--json]\n' +
1237
+ ' 生成最小壳(正确的骨架 + 必有件位置),不含内容。`list` 看有哪些版式。\n' +
1238
+ ' ai shot <页面.html|URL> --out <shot-{width}.png> [--width=390,1440] [--height=900]\n' +
1239
+ ' [--full] [--scale=2] [--wait=800] [--chrome=<path>] [--json]\n' +
1240
+ ' HTML → PNG(真实视口,不是"按窗口宽度裁一刀")。会报横向溢出与 JS 报错。\n' +
785
1241
  '\n' +
786
1242
  ' 打印:`--medium=paper` 才会校验 printPolicy 与 pageBudget;默认 screen 零成本。\n' +
787
1243
  ' 每个组件的纸面行为:ai/components.json 的 printPolicy。\n' +
788
1244
  '\n' +
789
- ' 每个子命令都支持 --json;失败时输出 {ok:false, command, error} 并以非 0 退出。\n' +
1245
+ ' 每个子命令都支持 --json;失败时输出 {ok:false, command, error:{kind, message}}。\n' +
1246
+ '\n' +
1247
+ ' 退出码(全 CLI 同一套,kind 与码一一对应):\n' +
1248
+ ' 0 通过\n' +
1249
+ ' 1 判定不通过(页面/文档确实有错 —— 是结论,不是工具坏了)\n' +
1250
+ ' 2 用法错误(参数名、取值、组合不对)\n' +
1251
+ ' 3 环境不具备(没有浏览器 / node 不在 PATH)\n' +
1252
+ ' 4 渲染失败(超时、读不出、视口没设成)\n' +
1253
+ ' 5 超出预算(产物是完整的,只是超了页数)\n' +
1254
+ ' 6 工具内部错误(是工具的问题,不是你参数写错)\n' +
790
1255
  ' 前缀 `ai` 可写可不写:`agentcss ai components` 与 `agentcss components` 等价。\n');
1256
+ }
1257
+
1258
+ function main() {
1259
+ var argv = process.argv.slice(2);
1260
+ if (!argv.length || argv[0] === 'help' || argv[0] === '--help' || argv[0] === '-h') {
1261
+ usage();
791
1262
  process.exit(0);
792
1263
  }
793
1264
  var name = argv.shift();
@@ -804,15 +1275,34 @@ function main() {
804
1275
  die('`agentcss ai` 后面要跟子命令(可用:' + Object.keys(CMDS).join(' / ') + ')');
805
1276
  }
806
1277
  }
1278
+ if (argv.indexOf('--help') >= 0 || argv.indexOf('-h') >= 0) {
1279
+ usage();
1280
+ process.exit(0);
1281
+ }
807
1282
  CURRENT_CMD = name;
808
1283
  var fn = CMDS[name];
809
1284
  if (!fn) die('未知子命令:' + name + '(可用:' + Object.keys(CMDS).join(' / ') + ')');
1285
+ /* 未知开关在**调用命令之前**拒掉:命令内部各自判断的话,
1286
+ 遇到不认识的开关就只剩"忽略"和"崩"两种选择。 */
1287
+ rejectUnknownFlags(name, GLOBAL_FLAGS.concat(FLAGS[name] || []));
810
1288
  try {
811
1289
  fn(argv);
812
1290
  } catch (e) {
813
- die(e.message);
1291
+ /* 🔴 这里是**兜底**:走到这儿说明是工具自己的 bug(TypeError / 读不到自带的文件)。
1292
+ 以前一律 `die(e.message)` ⇒ kind 恒为 'usage'、退出码 1。
1293
+ 实测(0.26.0):`ai tree --set=zzz --cat=nope` 报的是
1294
+ {"ok":false,"error":{"kind":"usage","message":"Cannot read properties of undefined (reading 'nope')"}}
1295
+ —— 调用方看到 kind=usage,会去改自己的参数,而真正该改的是工具。
1296
+ 现在:kind=internal、退出码 6、并且带一行为"这是工具的问题"。 */
1297
+ fail((e && e.kind) || 'internal', (e && e.message) || String(e));
814
1298
  }
815
1299
  }
816
1300
 
817
1301
  if (require.main === module) main();
818
- module.exports = { CMDS: CMDS };
1302
+ /* 导出给门禁做**反漂移**用:
1303
+ · `SCHEMAS` —— 判"磁盘上每份 *.schema.json 都已登记",以及"登记的都存在"
1304
+ · `ENUMS` —— 判"每个枚举开关的取值都来自数据,不是手抄"
1305
+ 门禁直接 require 本文件拿真值,**不复制一份清单**(I-7)。 */
1306
+ module.exports = { CMDS: CMDS, SCHEMAS: SCHEMAS, ENUMS: ENUMS, EXIT: EXIT, FLAGS: FLAGS,
1307
+ /* 只给门禁验「输出 undefined 必须当场失败」这条兜底 */
1308
+ _out: out };