@qilitt-mickey/vue3-temp-skill 1.1.76 → 1.2.1

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 (85) hide show
  1. package/README.md +85 -84
  2. package/SKILL.md +55 -27
  3. package/bin/cli.js +5 -6
  4. package/mapping/admin.json +60 -0
  5. package/package.json +4 -13
  6. package/references/adapters/_new-adapter.md +88 -0
  7. package/references/adapters/vue-antdv.md +70 -0
  8. package/references/adapters/vue-element-plus.md +90 -0
  9. package/references/design-apply.md +238 -122
  10. package/scripts/modules.json +2 -2
  11. package/dist/mapping-type.d.ts +0 -33
  12. package/dist/theme-vars.js +0 -62
  13. package/dist/theme-vars.ts +0 -113
  14. package/dist/theme.css +0 -73
  15. package/mapping/basic/color.json +0 -35
  16. package/mapping/basic/radius.json +0 -17
  17. package/mapping/basic/semantic-values.json +0 -72
  18. package/mapping/basic/spacing.json +0 -21
  19. package/mapping/basic/typography.json +0 -21
  20. package/mapping/component/button.json +0 -90
  21. package/mapping/component/card.json +0 -31
  22. package/mapping/component/input.json +0 -42
  23. package/mapping/component/overlay.json +0 -79
  24. package/mapping/component/pagination.json +0 -34
  25. package/mapping/component/table.json +0 -42
  26. package/mapping/component/tag.json +0 -48
  27. package/mapping/layout/breakpoint.json +0 -20
  28. package/mapping/layout/shell-geometry.json +0 -26
  29. package/mapping/scenes/admin/content.json +0 -497
  30. package/mapping/scenes/admin/layout.json +0 -577
  31. package/parser/fixtures/changeset-fullpage-restore.json +0 -259
  32. package/parser/fixtures/changeset-gap-block.json +0 -24
  33. package/parser/fixtures/changeset-legacy.json +0 -11
  34. package/parser/fixtures/changeset-neg1.json +0 -8
  35. package/parser/fixtures/changeset-neg2.json +0 -10
  36. package/parser/fixtures/changeset-neg3.json +0 -22
  37. package/parser/fixtures/changeset-neg4-g8.json +0 -15
  38. package/parser/fixtures/changeset-neg5-g8-stub.json +0 -26
  39. package/parser/fixtures/changeset-neg6-g9-identity.json +0 -39
  40. package/parser/fixtures/changeset-neg7-g10-dims.json +0 -57
  41. package/parser/fixtures/changeset-neg8-g11-oracle.json +0 -57
  42. package/parser/fixtures/changeset-neg9-g6-token-chain.json +0 -34
  43. package/parser/fixtures/changeset-pos-g6-token-chain.json +0 -35
  44. package/parser/fixtures/changeset-pos-theme-neutral.json +0 -264
  45. package/parser/fixtures/changeset-pos-theme-nometa.json +0 -260
  46. package/parser/fixtures/changeset-pos.json +0 -50
  47. package/parser/fixtures/changeset-registry-gap.json +0 -29
  48. package/parser/fixtures/changeset-v32-fields.json +0 -71
  49. package/parser/fixtures/fake-project/src/styles/app.scss +0 -5
  50. package/parser/fixtures/fake-project/src/views/list.vue +0 -13
  51. package/parser/fixtures/registry-bad.json +0 -11
  52. package/parser/fixtures/registry-g6-token-chain.json +0 -10
  53. package/parser/fixtures/registry-sample.json +0 -15
  54. package/parser/fixtures/verify/actual-clean.json +0 -35
  55. package/parser/fixtures/verify/actual.json +0 -33
  56. package/parser/fixtures/verify/mockup-baseline-admin-list.html +0 -27
  57. package/parser/fixtures/verify/mockup-g6-content.html +0 -12
  58. package/parser/fixtures/verify/mockup-neutral.html +0 -26
  59. package/parser/fixtures/verify/mockup-nometa.html +0 -26
  60. package/parser/fixtures/verify/oracle.json +0 -37
  61. package/parser/fixtures/verify/snapshot-latest.json +0 -103
  62. package/parser/generator.js +0 -178
  63. package/parser/index.js +0 -2706
  64. package/parser/loader.js +0 -66
  65. package/parser/override.js +0 -119
  66. package/parser/regression.js +0 -301
  67. package/parser/validator.js +0 -206
  68. package/parser/watch.js +0 -103
  69. package/references/steps/maintenance.md +0 -31
  70. package/references/steps/regression.md +0 -49
  71. package/references/steps/step0-preflight.md +0 -58
  72. package/references/steps/step1-match.md +0 -67
  73. package/references/steps/step1b-audit.md +0 -23
  74. package/references/steps/step1c-registry.md +0 -29
  75. package/references/steps/step2-structure.md +0 -73
  76. package/references/steps/step3-style.md +0 -38
  77. package/references/steps/step3b-shell.md +0 -63
  78. package/references/steps/step3c-priority.md +0 -26
  79. package/references/steps/step4-l3.md +0 -14
  80. package/references/steps/step5-gates.md +0 -65
  81. package/references/steps/step6-verify.md +0 -77
  82. package/scripts/apply-final-gate.mjs +0 -170
  83. package/scripts/check.mjs +0 -207
  84. package/scripts/design-audit.mjs +0 -1707
  85. package/scripts/validate.mjs +0 -106
@@ -1,1707 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * 设计层闭环审计 — 「样式逻辑 → 效果」的生效性断言。
5
- *
6
- * 发布闸(scripts/validate.mjs)校验的是技能包自身结构;本脚本校验的是
7
- * **一次落地是否真的把变更集落成了有效样式**:声明了不等于生效,生效了不等于对。
8
- * 它只做静态分析,不依赖浏览器与开发服务器。
9
- *
10
- * 用法:
11
- * node scripts/design-audit.mjs # 审计当前目录(落地项目根)
12
- * node scripts/design-audit.mjs --project <dir> # 指定落地项目
13
- * node scripts/design-audit.mjs --json # 机器可读输出
14
- * node scripts/design-audit.mjs --container-width 1624 # 表格容器可用宽(列宽预算用)
15
- * node scripts/design-audit.mjs --report <file.md> # 另存 Markdown 报告(可选,便于交付留档)
16
- *
17
- * 退出码:0 = 无 ERROR;1 = 存在 ERROR。
18
- *
19
- * 审计项:
20
- * E1 变量悬空 设计层 var(--x) 项目内无定义且无回退
21
- * E2 层错位 内层盒属性族写在表格宿主盒上(会被 EP 内层 .cell 覆盖)
22
- * E3 层叠败北 设计层声明特指度低于组件 scoped 同名类规则(含 [data-v] 加成)→ 不生效
23
- * E4 选择器挂空 设计层使用的项目类名在 src 中不存在(假锚点)
24
- * E5 列宽预算 存在无宽列(既无 width 又无 min-width);Σ列宽超出容器
25
- * E6 收口断言 visible:false 的 sid 必须有 display:none(+提权)落点
26
- * E7 激活态越层 分组标题被写入叶子项的激活态(胶囊/指示条)
27
- * E8 浮层残留 form 要求收口下拉浮层,但组件仍消费 #dropdown
28
- * E9 形态收口缺件 form=icon_only 只显图标、未在同一作用域内收口文案(且不被兄弟变体规则骗过)
29
- * E10 scoped 挂空 组件 scoped 规则依赖运行时并不存在的类(重构残留,永不命中)
30
- * E11 交付完整性 mode=restore 时 covered<applicable / gaps 非空 / 缺口未台账化 / 分母漂移 / notes 与字段矛盾
31
- * E12 设计层累积性 变更集点位或设计层行数相对历史骤降(上一轮成果被覆盖)→ 写交付台账 .design/ledger.json
32
- * E13 固定尺寸内容度量 固定 width 小于内容需求且无收口兜底 → 文字换行/压缩/重叠
33
- * E14 列宽预算链自洽 列宽(含公式)扣掉实际 td 内边距后装不下内容 → 操作列按钮被压
34
- * E16 引用完整性 变更集引用的实物不存在(改名未同步)/ restore 缺顶层 theme
35
- * E17 层间令牌链对账 父子两层盒共享同链间距令牌:绕过令牌通道抢写物理 padding 且同链令牌幸存 → 双层叠加
36
- * E18 激活态消费者成对落地 indicator_* 形态声明后:消费者缺失 / 令牌仍透明 / 伪元素对决权重不足 → 竖条不可见
37
- * E15 结构声明兑现 form 值 / suppress 通道名必须在词典枚举内,且设计层有 @sid / @closed 落点证据(G3 双向对账)
38
- */
39
-
40
- import fs from "fs";
41
- import path from "path";
42
- import { fileURLToPath } from "url";
43
-
44
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
- const SKILL_ROOT = path.resolve(__dirname, "..");
46
-
47
- const argv = process.argv.slice(2);
48
- const argValue = (name, def) => {
49
- const i = argv.indexOf(name);
50
- return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
51
- };
52
- const PROJECT = path.resolve(argValue("--project", process.cwd()));
53
- const AS_JSON = argv.includes("--json");
54
- const CONTAINER_EXPLICIT = argv.includes("--container-width");
55
- const CONTAINER_WIDTH = Number(argValue("--container-width", "1200")) || 1200;
56
-
57
- const DESIGN_LAYER_GLOBS = [
58
- "src/styles/design-shell.scss",
59
- "src/styles/design-tokens.scss",
60
- "src/styles/dark.scss",
61
- ];
62
- const SRC_DIRS = ["src"];
63
- const IGNORES = new Set(["node_modules", ".git", "dist", "build", ".vite", "public"]);
64
- const SRC_EXT = new Set([".vue", ".ts", ".tsx", ".js", ".mjs", ".scss", ".css", ".html"]);
65
- const JSON_EXT = new Set([".json"]);
66
-
67
- /** 内层盒属性族:必须落 EP 内层 .cell,落宿主 th/td 会被 .cell 覆盖 */
68
- const INNER_ONLY_PROPS = new Set([
69
- "white-space",
70
- "text-overflow",
71
- "overflow",
72
- "word-break",
73
- "text-align",
74
- "line-height",
75
- "vertical-align",
76
- "text-align-last",
77
- ]);
78
-
79
- /** 项目自有变量前缀(--el-* 为出厂,不参与悬空判定) */
80
- const OWNED_VAR_PREFIXES = ["--ds-", "--vts-", "--re-", "--brand-", "--table-", "--pagination-", "--filter-", "--page-", "--action-bar-", "--empty-", "--menu-", "--sidebar-"];
81
-
82
- /** 项目自有类名前缀(用于选择器挂空判定) */
83
- const OWNED_CLASS_PREFIXES = ["vts-", "re-", "col-", "navbar-", "header-", "sider-", "sidebar-", "menu-", "card-tab", "tab-", "language-", "app-", "list-"];
84
-
85
- const errors = [];
86
- const warnings = [];
87
- const infos = [];
88
-
89
- const err = (id, msg) => errors.push({ id, msg });
90
- const warn = (id, msg) => warnings.push({ id, msg });
91
- const info = (id, msg) => infos.push({ id, msg });
92
-
93
- // ── 文件工具 ────────────────────────────────────────────────
94
- function walk(dir, out = [], exts = SRC_EXT) {
95
- if (!fs.existsSync(dir)) return out;
96
- for (const name of fs.readdirSync(dir)) {
97
- if (IGNORES.has(name)) continue;
98
- const full = path.join(dir, name);
99
- let st;
100
- try {
101
- st = fs.statSync(full);
102
- } catch {
103
- continue;
104
- }
105
- if (st.isDirectory()) walk(full, out, exts);
106
- else if (exts.has(path.extname(name))) out.push(full);
107
- }
108
- return out;
109
- }
110
-
111
- const readIf = (p) => (fs.existsSync(p) ? fs.readFileSync(p, "utf-8") : null);
112
-
113
- // ── 迷你 CSS/SCSS 展平器 ────────────────────────────────────
114
- function stripComments(css) {
115
- return css
116
- .replace(/\/\*[\s\S]*?\*\//g, "")
117
- .replace(/(^|[\s;}])[/]{2}[^\n]*/g, "$1");
118
- }
119
-
120
- function addDecls(decls, chunk) {
121
- const text = String(chunk);
122
- if (!text.trim()) return;
123
- for (const piece of text.split(";")) {
124
- const t = piece.trim();
125
- if (!t || t.startsWith("$") || t.startsWith("@")) continue;
126
- const idx = t.indexOf(":");
127
- if (idx <= 0) continue;
128
- const prop = t.slice(0, idx).trim().toLowerCase();
129
- const value = t.slice(idx + 1).trim();
130
- if (!prop || /[{}]/.test(prop)) continue;
131
- decls[prop] = value;
132
- }
133
- }
134
-
135
- const splitList = (s) => String(s).split(",").map((x) => x.trim()).filter(Boolean);
136
-
137
- function joinSelector(parent, child) {
138
- const parts = splitList(child);
139
- if (!parent) return parts.join(", ");
140
- const parents = splitList(parent);
141
- const out = [];
142
- for (const part of parts) {
143
- if (part.includes("&")) {
144
- for (const p of parents) out.push(part.replace(/&/g, p));
145
- } else {
146
- for (const p of parents) out.push(`${p} ${part}`);
147
- }
148
- }
149
- return out.join(", ");
150
- }
151
-
152
- /**
153
- * 把一段 SCSS 正文拆成「本层声明」与「子块」。
154
- * 关键:声明与子块是交错的,必须逐字符按 `;` / `{}` 切分,
155
- * 否则「先声明后嵌套」的规则会把声明粘到下一个子选择器上(错位出幽灵规则)。
156
- */
157
- function parseRule(body) {
158
- const blocks = [];
159
- let declText = "";
160
- let buf = "";
161
- let i = 0;
162
- while (i < body.length) {
163
- const ch = body[i];
164
- if (ch === '"' || ch === "'") {
165
- let j = i + 1;
166
- while (j < body.length && body[j] !== ch) j += body[j] === "\\" ? 2 : 1;
167
- buf += body.slice(i, j + 1);
168
- i = j + 1;
169
- } else if (ch === "{" && buf.endsWith("#")) {
170
- buf += ch; // SCSS 插值 #{},不是块
171
- i++;
172
- } else if (ch === "{") {
173
- const sel = buf.trim();
174
- buf = "";
175
- let depth = 1;
176
- let j = i + 1;
177
- let inner = "";
178
- while (j < body.length && depth > 0) {
179
- const c = body[j];
180
- if (c === "{") depth++;
181
- else if (c === "}") {
182
- depth--;
183
- if (depth === 0) break;
184
- }
185
- inner += c;
186
- j++;
187
- }
188
- if (sel) blocks.push({ sel, body: inner });
189
- i = j + 1;
190
- } else if (ch === ";") {
191
- declText += buf + ";";
192
- buf = "";
193
- i++;
194
- } else if (ch === "}") {
195
- i++;
196
- } else {
197
- buf += ch;
198
- i++;
199
- }
200
- }
201
- declText += buf;
202
- return { declText, blocks };
203
- }
204
-
205
- const AT_SKIP = /^@(keyframes|-webkit-keyframes|-moz-keyframes|-o-keyframes|font-face|import|use|forward|charset|tailwind|apply|extend)/i;
206
-
207
- function flattenCss(css, parentSel = "", acc = []) {
208
- const { declText, blocks } = parseRule(stripComments(css));
209
- if (parentSel && declText.trim()) {
210
- const decls = {};
211
- addDecls(decls, declText);
212
- if (Object.keys(decls).length) acc.push({ selector: parentSel, decls });
213
- }
214
- for (const b of blocks) {
215
- const sel = b.sel.trim();
216
- if (!sel || AT_SKIP.test(sel)) continue;
217
- if (sel.startsWith("@")) {
218
- flattenCss(b.body, parentSel, acc);
219
- continue;
220
- }
221
- flattenCss(b.body, joinSelector(parentSel, sel), acc);
222
- }
223
- return acc;
224
- }
225
-
226
- const classTokens = (selector) => [...selector.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((m) => m[1]);
227
- /** 去掉伪元素后的「末位复合选择器」——它才是这条规则真正作用的那一层元素 */
228
- const lastCompound = (sel) => {
229
- const parts = String(sel)
230
- .replace(/::[a-z-]+(\([^)]*\))?/gi, "")
231
- .split(/[\s>+~]+/)
232
- .filter(Boolean);
233
- return parts[parts.length - 1] || "";
234
- };
235
- const allVars = (text) => [...text.matchAll(/var\(\s*(--[\w-]+)\s*(,)?/g)].map((m) => ({ name: m[1], hasFallback: !!m[2] }));
236
-
237
- // ── 采集:设计层 / 项目源 / 变更集 / 词典 ────────────────────
238
- const designFiles = DESIGN_LAYER_GLOBS.map((g) => path.join(PROJECT, g)).filter((p) => fs.existsSync(p));
239
- const extraDesign = walk(path.join(PROJECT, "src", "styles")).filter((p) => /design-.*\.(scss|css)$/i.test(path.basename(p)));
240
- const DESIGN_FILES = [...new Set([...designFiles, ...extraDesign])];
241
-
242
- const srcFiles = SRC_DIRS.flatMap((d) => walk(path.join(PROJECT, d)));
243
- const srcTextByFile = new Map();
244
- for (const f of srcFiles) srcTextByFile.set(f, readIf(f) ?? "");
245
-
246
- const designSet = new Set(DESIGN_FILES.map((p) => path.resolve(p)));
247
- const projectText = [...srcTextByFile.entries()]
248
- .filter(([f]) => !designSet.has(path.resolve(f)))
249
- .map(([, t]) => t)
250
- .join("\n");
251
-
252
- const designCss = DESIGN_FILES.map((f) => srcTextByFile.get(f) ?? readIf(f) ?? "").join("\n");
253
- // 每条规则带上来源文件:用于区分「本轮落地设计层」与「此前存量设计层」的告警级别
254
- const designRules = DESIGN_FILES.flatMap((f) =>
255
- flattenCss(srcTextByFile.get(f) ?? readIf(f) ?? "").map((r) => ({ ...r, file: f })),
256
- );
257
- const PRIMARY_LAYER = path.resolve(PROJECT, argValue("--design-layer", "src/styles/design-shell.scss"));
258
- const isPrimary = (file) => path.resolve(file) === PRIMARY_LAYER;
259
-
260
- // 变更集发现:规范落点 .design/,兼容 design/ 与 .changeset/。
261
- // 硬编码单一目录的代价是「变更集搬家 → 闸门静默跳过」。
262
- const CHANGESET_DIRS = [".design", "design", ".changeset"];
263
- const relp = (p) => path.relative(PROJECT, p).replace(/\\/g, "/");
264
- const changesetCandidates = [];
265
- {
266
- const explicit = argValue("--changeset", "");
267
- if (explicit) {
268
- const p = path.resolve(PROJECT, explicit);
269
- if (fs.existsSync(p)) changesetCandidates.push(p);
270
- else err("E0", `--changeset 指定的变更集不存在:${p}`);
271
- } else {
272
- for (const d of CHANGESET_DIRS) {
273
- const dir = path.join(PROJECT, d);
274
- if (!fs.existsSync(dir)) continue;
275
- for (const f of fs.readdirSync(dir).filter((x) => /changeset.*\.json$/i.test(x)).sort()) {
276
- const full = path.join(dir, f);
277
- if (!changesetCandidates.includes(full)) changesetCandidates.push(full);
278
- }
279
- }
280
- }
281
- }
282
- let changeset = null;
283
- let changesetFile = null;
284
- if (changesetCandidates.length > 1) {
285
- warn(
286
- "E11",
287
- `多份变更集并存:${changesetCandidates.map(relp).join("、")} —— 无法判定哪份是「本轮」交付物,暂取最近修改的一份。落点应唯一为 .design/ 并在交付台账登记。`,
288
- );
289
- }
290
- if (changesetCandidates.length) {
291
- changesetFile = changesetCandidates.map((p) => ({ p, t: fs.statSync(p).mtimeMs })).sort((a, b) => b.t - a.t)[0].p;
292
- try {
293
- changeset = JSON.parse(fs.readFileSync(changesetFile, "utf-8"));
294
- } catch (e) {
295
- err("E0", `变更集解析失败:${changesetFile} — ${e.message}`);
296
- }
297
- }
298
-
299
- // 词典(技能包自带 mapping/)
300
- const dictSidMap = new Map();
301
- const dictEntries = [];
302
- (function loadMapping() {
303
- const roots = ["mapping/scenes", "mapping/component", "mapping/layout", "mapping/basic"].map((r) => path.join(SKILL_ROOT, r));
304
- const files = roots.flatMap((r) => walk(r, [], JSON_EXT));
305
- for (const f of files) {
306
- try {
307
- const json = JSON.parse(fs.readFileSync(f, "utf-8"));
308
- for (const [sid, entry] of Object.entries(json.mappings || {})) {
309
- if (!entry || typeof entry !== "object") continue;
310
- dictSidMap.set(sid, { ...entry, __file: f });
311
- dictEntries.push({ sid, entry });
312
- }
313
- } catch {
314
- /* 忽略无法解析的映射文件 */
315
- }
316
- }
317
- })();
318
-
319
- // ── E1 变量悬空 ─────────────────────────────────────────────
320
- (function checkE1() {
321
- if (!designCss) return;
322
- const defined = new Set();
323
- for (const t of [designCss, projectText].filter(Boolean)) {
324
- for (const m of t.matchAll(/(--[\w-]+)\s*:/g)) defined.add(m[1]);
325
- }
326
- if (changeset) {
327
- // 令牌名可能只在变更集里出现(由项目侧再映射),一并视为已声明
328
- for (const m of JSON.stringify(changeset).matchAll(/--[\w-]+/g)) defined.add(m[0]);
329
- }
330
- const missing = new Map();
331
- for (const { name, hasFallback } of allVars(designCss)) {
332
- if (hasFallback || defined.has(name)) continue;
333
- if (!OWNED_VAR_PREFIXES.some((p) => name.startsWith(p))) continue;
334
- missing.set(name, (missing.get(name) || 0) + 1);
335
- }
336
- for (const [name, count] of missing) {
337
- err("E1", `变量悬空:设计层使用了 ${name}(${count} 处),但项目内无定义、也无 var() 回退 —— 该声明整条失效。`);
338
- }
339
- if (!missing.size) info("E1", `变量悬空:0 处(受检前缀 ${OWNED_VAR_PREFIXES.join(" ")})`);
340
- })();
341
-
342
- // ── E2 层错位 ───────────────────────────────────────────────
343
- (function checkE2() {
344
- const hits = [];
345
- for (const rule of designRules) {
346
- const sel = rule.selector;
347
- if (!/el-table|(^|[\s,>])(th|td)(\.|[\s,:{]|$)/.test(sel)) continue;
348
- if (/\.cell\b/.test(sel)) continue; // 已锚定内层文本盒
349
- const offenders = Object.keys(rule.decls).filter((p) => INNER_ONLY_PROPS.has(p));
350
- if (offenders.length) hits.push({ sel, offenders });
351
- }
352
- for (const h of hits.slice(0, 12)) {
353
- err("E2", `层错位:${h.sel} 声明了内层盒属性族 [${h.offenders.join(", ")}],但未锚定 > .cell —— 会被 Element Plus 内层 .cell 覆盖,等于没写。`);
354
- }
355
- if (hits.length > 12) err("E2", `层错位:另有 ${hits.length - 12} 处未列出。`);
356
- if (!hits.length) info("E2", "层错位:0 处");
357
- })();
358
-
359
- // ── E3 层叠胜负 / E10 scoped 挂空 ───────────────────────────
360
- // 组件 scoped 规则编译后末位选择器会追加 [data-v-xxx] 属性选择器,等价 +1 个类级特指度。
361
- // 因此「设计层声明是否真的生效」必须算特指度,不能只看有没有同名类。
362
- const SCOPED_ATTR = 1000;
363
- const STATE_PSEUDO = /:(hover|focus|active|visited|disabled|checked|focus-within|focus-visible|first-child|last-child|nth-[\w-]+)/i;
364
-
365
- const pseudoElOf = (sel) => (sel.match(/::([a-z-]+)/i) || [])[1] || "";
366
-
367
- function specOf(selector) {
368
- const s = selector
369
- .replace(/::[a-z-]+(\([^)]*\))?/gi, " ")
370
- .replace(/:not\(([^)]*)\)/g, ":not");
371
- const ids = (s.match(/#[\w-]+/g) || []).length;
372
- const classes =
373
- (s.match(/\.[\w-]+/g) || []).length +
374
- (s.match(/\[[^\]]*\]/g) || []).length +
375
- (s.match(/:(?!:)[\w-]+(\([^)]*\))?/g) || []).length;
376
- const elems = (s.match(/(?:^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
377
- return ids * 1e6 + classes * 1e3 + elems;
378
- }
379
-
380
- /** 状态类 / 第三方类不属于「项目自有挂载点」,挂空判定不参与 */
381
- const isOwnedClass = (c) =>
382
- OWNED_CLASS_PREFIXES.some((p) => c.startsWith(p)) && !/^(is|has|van|el|n)-/.test(c);
383
- const IGNORE_DEAD = new Set(["language-text", "language-text__item"]);
384
-
385
- // 模板/脚本源码(剔掉 style 块,避免样式里的类名自证「已挂载」)
386
- const markupText = [...srcTextByFile.entries()]
387
- .filter(([f]) => f.endsWith(".vue"))
388
- .map(([, t]) => t.replace(/<style[\s\S]*?<\/style>/g, ""))
389
- .join("\n");
390
-
391
- // 收集组件 scoped 规则,并标记「挂空」的(选择器链条上存在运行时不存在的类)
392
- const scopedRules = [];
393
- const scopedDead = [];
394
- for (const [f, t] of srcTextByFile) {
395
- if (!f.endsWith(".vue")) continue;
396
- for (const m of t.matchAll(/<style[^>]*scoped[^>]*>([\s\S]*?)<\/style>/g)) {
397
- for (const r of flattenCss(m[1])) {
398
- const cls = [...new Set(classTokens(r.selector))];
399
- if (!cls.length) continue;
400
- if (/:deep\(|::v-deep|:global\(/.test(r.selector)) continue;
401
- const owned = cls.filter(isOwnedClass);
402
- const missing = owned.filter((c) => !markupText.includes(c));
403
- const dead = missing.length > 0;
404
- scopedRules.push({ ...r, file: f, dead });
405
- if (dead) scopedDead.push({ sel: r.selector, file: f, missing });
406
- }
407
- }
408
- }
409
-
410
- // 竞争者索引:只登记「与目标同一元素(同末位复合选择器)」且非挂空的规则
411
- const scopedIdx = new Map();
412
- for (const r of scopedRules) {
413
- if (r.dead) continue; // 挂空的规则永不命中,不能算作竞争者
414
- if (STATE_PSEUDO.test(r.selector)) continue; // 状态态不参与基态竞争
415
- const pe = pseudoElOf(r.selector);
416
- const spec = specOf(r.selector) + SCOPED_ATTR;
417
- for (const cls of new Set(classTokens(lastCompound(r.selector)))) {
418
- for (const prop of Object.keys(r.decls)) {
419
- const key = `${cls}|${prop}|${pe}`;
420
- const cur = scopedIdx.get(key);
421
- if (!cur || spec > cur.spec) scopedIdx.set(key, { spec, selector: r.selector, file: r.file });
422
- }
423
- }
424
- }
425
-
426
- (function checkE3() {
427
- if (!scopedIdx.size) {
428
- info("E3", "层叠胜负:无同属性竞争的组件 scoped 规则");
429
- return;
430
- }
431
- // 出厂暗色主题层(dark.scss)不属于「本次设计落地」:它与组件 scoped 的对决是出厂内部
432
- // 行为(架构红线也禁止改出厂文件来「修复」),按设计层宣判会永久卡死交付闸。
433
- const FACTORY_THEME_LAYERS = new Set(["dark.scss"]);
434
- const isFactoryLayer = (file) => FACTORY_THEME_LAYERS.has(path.basename(String(file || "")));
435
- const hits = [];
436
- const seen = new Set();
437
- for (const r of designRules) {
438
- if (isFactoryLayer(r.file)) continue;
439
- const pe = pseudoElOf(r.selector);
440
- for (const part of splitList(r.selector)) {
441
- const last = lastCompound(part);
442
- const clsList = [...new Set(classTokens(last))];
443
- if (!clsList.length) continue;
444
- const mine = specOf(part);
445
- for (const prop of Object.keys(r.decls)) {
446
- for (const cls of clsList) {
447
- const comp = scopedIdx.get(`${cls}|${prop}|${pe}`);
448
- if (!comp || mine > comp.spec) continue;
449
- const key = `${part}|${prop}`;
450
- if (seen.has(key)) continue;
451
- seen.add(key);
452
- hits.push({ sel: part, prop, cls, mine, comp });
453
- }
454
- }
455
- }
456
- }
457
- const shown = hits.slice(0, 12);
458
- for (const h of shown) {
459
- const rel = path.relative(PROJECT, h.comp.file).replace(/\\/g, "/");
460
- err(
461
- "E3",
462
- `层叠败北:${h.sel} 的 ${h.prop} 特指度 ${h.mine} ≤ 组件 scoped 竞争者 ${h.comp.selector}(${rel})的 ${h.comp.spec}(scoped 追加 [data-v] 属性选择器,等价 +1 类级)—— 该声明不会生效:收口类提权加 !important,形态类须提高锚定层级(补上 .is-active / .card-item 等对方使用的限定类)。`,
463
- );
464
- }
465
- if (hits.length > shown.length) {
466
- const byFile = new Map();
467
- for (const h of hits.slice(shown.length)) {
468
- const rel = path.relative(PROJECT, h.comp.file).replace(/\\/g, "/");
469
- byFile.set(rel, (byFile.get(rel) || 0) + 1);
470
- }
471
- const detail = [...byFile.entries()].sort((a, b) => b[1] - a[1]).map(([f, n]) => `${f}(${n})`).join("、");
472
- err("E3", `层叠败北:另有 ${hits.length - shown.length} 处未列出,集中在 ${detail} —— 同一文件成片败北说明该区域的锚定层级整体偏低,应按文件批量提权。`);
473
- }
474
- if (!hits.length) info("E3", `层叠胜负:0 处败北(竞争者 ${scopedIdx.size} 组)`);
475
- })();
476
-
477
- // ── E10 组件 scoped 规则挂空(重构残留) ────────────────────
478
- (function checkE10() {
479
- const dead = scopedDead.filter((d) => d.missing.some((c) => !IGNORE_DEAD.has(c)));
480
- if (!dead.length) {
481
- info("E10", "组件 scoped 挂空:0 处");
482
- return;
483
- }
484
- for (const d of dead.slice(0, 15)) {
485
- const rel = path.relative(PROJECT, d.file).replace(/\\/g, "/");
486
- warn(
487
- "E10",
488
- `scoped 规则挂空:${rel} 的 ${d.sel} 依赖运行时并不存在的类 [${d.missing.join(", ")}] —— 整条规则永不命中(多在结构重构后残留),既不会生效也会误导后续收口判断。`,
489
- );
490
- }
491
- if (dead.length > 15) warn("E10", `scoped 规则挂空:另有 ${dead.length - 15} 处未列出。`);
492
- })();
493
-
494
- // ── E4 选择器挂空 ───────────────────────────────────────────
495
- (function checkE4() {
496
- const used = new Map(); // 类名 → 声明它的设计层文件集合
497
- for (const rule of designRules) {
498
- for (const c of classTokens(rule.selector)) {
499
- if (!OWNED_CLASS_PREFIXES.some((p) => c.startsWith(p))) continue;
500
- if (!used.has(c)) used.set(c, new Set());
501
- used.get(c).add(rule.file);
502
- }
503
- }
504
- const phantom = [...used.keys()].filter((c) => !projectText.includes(c));
505
- const hard = phantom.filter((c) => [...used.get(c)].some(isPrimary));
506
- const soft = phantom.filter((c) => !hard.includes(c));
507
- for (const c of hard.slice(0, 15)) {
508
- err("E4", `选择器挂空:设计层选择器使用了 .${c},但 src/ 全量源码里没有该类的挂载点 —— 规则命中不到任何元素(锚点必须锚「实际在」,不能锚「应该在」)。`);
509
- }
510
- if (hard.length > 15) err("E4", `选择器挂空:另有 ${hard.length - 15} 个未列出。`);
511
- for (const c of soft.slice(0, 10)) {
512
- const files = [...used.get(c)].map((f) => path.basename(f)).join(", ");
513
- warn("E4", `选择器挂空(历史层):.${c} 只出现在 ${files},项目内无挂载点 —— 静态遗留,非本次落地引入,但同样永不命中。`);
514
- }
515
- if (soft.length > 10) warn("E4", `选择器挂空(历史层):另有 ${soft.length - 10} 个未列出。`);
516
- if (!phantom.length) info("E4", `选择器挂空:0 个(受检类名 ${used.size} 个)`);
517
- })();
518
-
519
- // ── E5 列宽预算 ─────────────────────────────────────────────
520
- function extractObjectLiterals(arrSrc) {
521
- const out = [];
522
- let depth = 0;
523
- let start = -1;
524
- for (let i = 0; i < arrSrc.length; i++) {
525
- const ch = arrSrc[i];
526
- if (ch === "{") {
527
- if (depth === 0) start = i;
528
- depth++;
529
- } else if (ch === "}") {
530
- depth--;
531
- if (depth === 0 && start >= 0) {
532
- out.push(arrSrc.slice(start, i + 1));
533
- start = -1;
534
- }
535
- }
536
- }
537
- return out;
538
- }
539
-
540
- function matchArrayLiteral(text, anchorRe) {
541
- const m = anchorRe.exec(text);
542
- if (!m) return null;
543
- const open = text.indexOf("[", m.index);
544
- if (open < 0) return null;
545
- let depth = 0;
546
- for (let i = open; i < text.length; i++) {
547
- if (text[i] === "[") depth++;
548
- else if (text[i] === "]") {
549
- depth--;
550
- if (depth === 0) return text.slice(open + 1, i);
551
- }
552
- }
553
- return null;
554
- }
555
-
556
- (function checkE5() {
557
- const tableFiles = [...srcTextByFile.entries()].filter(([, t]) => t.includes("el-table-column"));
558
- if (!tableFiles.length) {
559
- info("E5", "列宽预算:未在 src 中发现 el-table-column");
560
- return;
561
- }
562
- const pxOf = (s) => {
563
- const m = String(s).match(/(\d+(?:\.\d+)?)\s*(px)?/);
564
- return m ? Number(m[1]) : null;
565
- };
566
- // 表头文案最小占宽:CJK ≈ 1em,ASCII ≈ 0.6em;两侧各留 16px 宿主内边距
567
- const HEADER_FS = 14;
568
- const CELL_PAD = 16;
569
- const textWidth = (label) => {
570
- let w = 0;
571
- for (const ch of String(label)) w += /[\u2e80-\u9fff\uff00-\uffef]/.test(ch) ? HEADER_FS : HEADER_FS * 0.6;
572
- return Math.ceil(w);
573
- };
574
- const minFit = (label) => textWidth(label) + CELL_PAD * 2;
575
- // 列名可能是 $t('类型') 这类 i18n 调用,需取出引号内的真实文案
576
- const cleanLabel = (raw) => {
577
- const s = String(raw).trim();
578
- const q = s.match(/[`'"]([^`'"]+)[`'"]/);
579
- return ((q ? q[1] : s).trim() || "?");
580
- };
581
- const labelOf = (src) => {
582
- const m =
583
- src.match(/label:\s*([^,\n}]+)/) || src.match(/\blabel="([^"]*)"/) || src.match(/\blabel='([^']*)'/);
584
- return m ? cleanLabel(m[1]) : "?";
585
- };
586
- const OPS = ["operate", "operation", "actions"];
587
-
588
- for (const [file, text] of tableFiles) {
589
- const rel = path.relative(PROJECT, file).replace(/\\/g, "/");
590
- const declared = [];
591
- const noWidth = [];
592
-
593
- // 1) 列定义数组(模板常用动态列)
594
- const arr = matchArrayLiteral(text, /(?:const|let|var)\s+\w*[Cc]olumn\w*\s*(?::[^=]+)?=\s*\[/);
595
- if (arr) {
596
- for (const objSrc of extractObjectLiterals(arr)) {
597
- const label = labelOf(objSrc);
598
- const prop = (objSrc.match(/prop:\s*[`'"]([^`'"]+)/) || [])[1] || "?";
599
- const w = objSrc.match(/\bwidth:\s*[`'"]?([\d.]+)/);
600
- const mw = objSrc.match(/\bminWidth:\s*[`'"]?([\d.]+)/);
601
- if (w) declared.push({ label, prop, w: pxOf(w[1]) });
602
- else if (mw) declared.push({ label, prop, mw: pxOf(mw[1]) });
603
- else noWidth.push({ label, prop });
604
- }
605
- }
606
-
607
- // 2) 内联列(selection / index / 手工列)
608
- for (const tag of text.matchAll(/<el-table-column\b[\s\S]*?(?:\/>|>)/g)) {
609
- const src = tag[0];
610
- const type = (src.match(/:?type="['"]([^'"]+)/) || [])[1] || "";
611
- const label = (src.match(/:?label="([^"]*)"/) || src.match(/:?label='([^']*)'/) || [])[1];
612
- const labelText = label ? cleanLabel(label) : type || "?";
613
- const hasW = /(^|\s):?width=/.test(src) || /\bwidth=/.test(src);
614
- const hasMw = /:?min-width=/.test(src) || /:?minWidth=/.test(src);
615
- if (hasW || hasMw) continue;
616
- if (type === "selection" || type === "index") {
617
- warn("E5", `${rel}:${type === "selection" ? "选择列" : "序号列"}(${labelText})未声明 width / min-width,只能按出厂兜底宽度参与布局(§11.132 要求逐列声明意图)。`);
618
- } else if (type) {
619
- noWidth.push({ label: labelText, prop: type });
620
- }
621
- }
622
-
623
- // 模板里按 prop 条件绑定的列宽(如
624
- // :min-width="column.prop === 'minCategoryName' ? '120' : undefined"
625
- // )会覆盖列定义数组里那次「无宽」——不识别就会把已有下限的列误报成无宽列。
626
- const tplBind = new Map(); // prop → { width?, minWidth? }
627
- for (const attr of ["width", "min-width"]) {
628
- for (const m of text.matchAll(new RegExp(`:${attr}="([^"]+)"`, "g"))) {
629
- for (const c of m[1].matchAll(/[\w.]*prop\s*===\s*['"]([^'"]+)['"]\s*\?\s*['"]?([\d.]+)(?:px)?['"]?/g)) {
630
- const cur = tplBind.get(c[1]) || {};
631
- if (attr === "width") cur.width = Number(c[2]);
632
- else cur.minWidth = Number(c[2]);
633
- tplBind.set(c[1], cur);
634
- }
635
- }
636
- }
637
- for (const [prop, v] of tplBind) {
638
- if (noWidth.some((c) => c.prop === prop)) {
639
- const label = (noWidth.find((c) => c.prop === prop) || {}).label || prop;
640
- if (v.width != null) declared.push({ label, prop, w: v.width });
641
- else if (v.minWidth != null) declared.push({ label, prop, mw: v.minWidth });
642
- }
643
- }
644
-
645
- const realNoWidth = noWidth.filter((c) => !OPS.includes(c.prop) && !tplBind.has(c.prop));
646
- for (const c of realNoWidth) {
647
- err(
648
- "E5",
649
- `${rel}:列「${c.label}」(prop=${c.prop}) 既无 width 也无 min-width —— 无宽列由出厂兜底宽度参与布局,会被相邻固定宽列挤压;本列表头文案需 ≥ ${minFit(c.label)}px(文案 ${textWidth(c.label)}px + 内边距 ${CELL_PAD * 2}px),请按此反推 width / min-width(换行与省略号截断都源于此)。`,
650
- );
651
- }
652
-
653
- // 3) 已声明 min-width 的列:min-width 必须容得下自己的表头文案
654
- for (const c of declared) {
655
- if (c.mw == null) continue;
656
- const need = minFit(c.label);
657
- if (c.mw < need) {
658
- err(
659
- "E5",
660
- `${rel}:列「${c.label}」min-width=${c.mw}px < 表头文案最小占宽 ${need}px —— 布局算法压缩到 min-width 时表头必然换行或截断(「${c.label}」→「${c.label.slice(0, 2)}…」)。`,
661
- );
662
- }
663
- }
664
-
665
- const total = declared.reduce((s, c) => s + (c.w ?? 0), 0);
666
- const minTotal = declared.reduce((s, c) => s + (c.mw ?? 0), 0);
667
- const fixedCount = declared.filter((c) => c.w).length;
668
- const flexCount = declared.filter((c) => c.mw).length;
669
- const budget = total + minTotal;
670
- const noWidthNeed = realNoWidth.reduce((s, c) => s + minFit(c.label), 0);
671
- const line = `${rel}:固定宽 ${fixedCount} 列(${total}px) / 伸缩列 ${flexCount} 列(${minTotal}px) / 无宽列 ${realNoWidth.length} 列(需 ${noWidthNeed}px)`;
672
- if (CONTAINER_EXPLICIT && budget + noWidthNeed > CONTAINER_WIDTH) {
673
- err(
674
- "E5",
675
- `${line} —— 合计 ${budget + noWidthNeed}px > 容器可用宽 ${CONTAINER_WIDTH}px:列宽预算溢出,必须在设计侧收敛(减少列 / 降 min-width / 显式声明横向滚动),不允许靠静默压缩消化。`,
676
- );
677
- } else if (!CONTAINER_EXPLICIT && budget + noWidthNeed > CONTAINER_WIDTH) {
678
- warn(
679
- "E5",
680
- `${line} —— 合计 ${budget + noWidthNeed}px 超出假定容器宽 ${CONTAINER_WIDTH}px。容器宽为估值,请用 --container-width <px> 传入实际内容区宽度后复跑,据以判定是否溢出。`,
681
- );
682
- } else {
683
- info("E5", `${line},合计 ${budget + noWidthNeed}px ≤ ${CONTAINER_WIDTH}px`);
684
- }
685
- }
686
- })();
687
-
688
- // ── E6 visible:false 收口断言 ──────────────────────────────
689
- (function checkE6() {
690
- if (!changeset) {
691
- info("E6", "未发现变更集(.design/changeset*.json),跳过收口断言");
692
- return;
693
- }
694
- const changes = changeset.changes || [];
695
- const closable = changes.filter((c) => c.visible === false || c.style?.visible === false);
696
- if (!closable.length) {
697
- info("E6", "收口断言:变更集无 visible:false 项");
698
- return;
699
- }
700
- for (const change of closable) {
701
- const entry = dictSidMap.get(change.id);
702
- const selector = entry?.selector;
703
- if (!selector) {
704
- warn("E6", `${change.id} 声明 visible:false,但词典无 selector,无法断言收口落点。`);
705
- continue;
706
- }
707
- const target = classTokens(selector).slice(-1)[0];
708
- if (!target) {
709
- warn("E6", `${change.id} 的词典 selector「${selector}」不含类名,无法断言收口落点。`);
710
- continue;
711
- }
712
- const rule = designRules.find(
713
- (r) => r.selector.includes(`.${target}`) && /none/i.test(r.decls.display || ""),
714
- );
715
- if (!rule) {
716
- err("E6", `${change.id} 声明 visible:false,但设计层没有任何「命中 .${target} 且 display:none」的收口规则 —— 元素会照旧显示。`);
717
- } else if (!/!important/i.test(rule.decls.display)) {
718
- warn("E6", `${change.id} 的收口规则 ${rule.selector} 的 display:none 未加 !important,存在被组件 scoped 同权重反压的风险。`);
719
- } else {
720
- info("E6", `${change.id} → ${rule.selector} display:none !important ✓`);
721
- }
722
- }
723
- })();
724
-
725
- // ── E7 激活态越层 ───────────────────────────────────────────
726
- (function checkE7() {
727
- const hits = designRules.filter((r) => {
728
- const sel = r.selector;
729
- const onGroup = /el-sub-menu|__title/.test(sel);
730
- const active = /is-active|\.active\b|\[aria-current|\.is-opened/.test(sel);
731
- if (!onGroup || !active) return false; // 必须同时命中「分组标题」与「激活态」才是越层
732
- const setsSurface = Object.keys(r.decls).some(
733
- (p) => p === "background" || p.startsWith("background") || p === "box-shadow" || p === "border-inline-start",
734
- );
735
- const pseudoBar = /::?(before|after)/.test(sel);
736
- return setsSurface || pseudoBar;
737
- });
738
- for (const r of hits.slice(0, 10)) {
739
- err("E7", `激活态越层:${r.selector} 把激活态(胶囊/指示条)写到了可展开分组的标题上 —— 分组标题会同时长出胶囊与竖向指示条,并与右侧展开箭头叠压(§6.15/§6.16 要求激活态只作用叶子项,分组标题须以 :not(.el-sub-menu__title) 排除)。`);
740
- }
741
- if (!hits.length) info("E7", "激活态越层:0 处");
742
- })();
743
-
744
- // ── E8 浮层残留 ─────────────────────────────────────────────
745
- (function checkE8() {
746
- if (!changeset) {
747
- info("E8", "未发现变更集,跳过浮层残留检查");
748
- return;
749
- }
750
- for (const change of changeset.changes || []) {
751
- const form = change.form;
752
- if (!form) continue;
753
- const entry = dictSidMap.get(change.id);
754
- if (!entry) continue;
755
- const required = entry.suppress_rules?.[form] || [];
756
- const structural = [...required, ...(entry.structure_ops_required?.[form] || [])].join(" ");
757
- if (!/popper|dropdown|浮层/i.test(structural)) continue;
758
- const compFile = entry.file && typeof entry.file === "string" ? entry.file.match(/(src\/[\w./-]+\.vue)/) : null;
759
- if (!compFile) continue;
760
- const p = path.join(PROJECT, compFile[1]);
761
- const t = readIf(p);
762
- if (!t) continue;
763
- if (/#dropdown|ElDropdown|el-dropdown/.test(t)) {
764
- err("E8", `${change.id} 的 form=${form} 要求收口下拉浮层,但 ${compFile[1]} 仍保留 ElDropdown / #dropdown 消费 —— 点击触发器仍会弹出浮层(浮层属 teleport popper,样式层收不掉)。`);
765
- } else {
766
- info("E8", `${change.id} 浮层收口 ✓`);
767
- }
768
- }
769
- })();
770
-
771
- // ── E9 形态收口缺件 ─────────────────────────────────────────
772
- // 形态类 form(如 icon_only)落地必须「成对」:显图标 + 隐文案,且收口要落回该语义元素
773
- // 自己的作用域内。只按「全局有没有 .tag-title{display:none}」判断会被兄弟变体规则骗过。
774
- const normSel = (s) => String(s).replace(/\s+/g, " ").trim();
775
- const lastClassOutsideNot = (sel) => {
776
- const cleaned = String(sel).replace(/:not\([^)]*\)/g, "");
777
- const parts = splitList(cleaned);
778
- const last = parts.length ? lastCompound(parts[parts.length - 1]) : "";
779
- const cls = classTokens(last);
780
- if (cls.length) return cls[cls.length - 1];
781
- const all = classTokens(cleaned);
782
- return all.length ? all[all.length - 1] : null;
783
- };
784
-
785
- (function checkE9() {
786
- if (!changeset) {
787
- info("E9", "未发现变更集,跳过形态收口检查");
788
- return;
789
- }
790
- for (const change of changeset.changes || []) {
791
- if (!/^(icon_only|icon)$/.test(change.form || "")) continue;
792
- const entry = dictSidMap.get(change.id);
793
- if (!entry) {
794
- warn("E9", `${change.id} 的 form=${change.form} 在词典里找不到条目,无法断言形态收口。`);
795
- continue;
796
- }
797
- const scope = normSel(entry.selector || "");
798
- const subs = entry.sub_selectors || {};
799
- const textSub = subs.title || subs.text;
800
- const iconSub = subs.icon || subs.leading_icon;
801
- if (!scope || !textSub) {
802
- warn("E9", `${change.id} 的词典缺少 selector 或 sub_selectors.title,无法断言「显图标 + 隐文案」是否成对。`);
803
- continue;
804
- }
805
- const textTarget = lastClassOutsideNot(textSub);
806
- const iconTarget = iconSub ? lastClassOutsideNot(iconSub) : null;
807
- const inScope = (r) => {
808
- const s = normSel(r.selector);
809
- return s.includes(scope);
810
- };
811
- const closed = designRules.filter(
812
- (r) => inScope(r) && /none/i.test(r.decls.display || "") && (!textTarget || normSel(r.selector).includes(`.${textTarget}`)),
813
- );
814
- const shown =
815
- (iconTarget &&
816
- designRules.find((r) => inScope(r) && normSel(r.selector).includes(`.${iconTarget}`) && /(block|inline-flex|flex|inline-block)/i.test(r.decls.display || ""))) ||
817
- null;
818
- const closedIcon = designRules.find((r) => inScope(r) && /\.card-tab-icon|\.tag-icon|icon/i.test(normSel(r.selector)) && /none/i.test(r.decls.display || ""));
819
- // 图标与文案至少要有一边在本作用域内被显式声明,否则说明该形态未在本层落地
820
- const landed = shown || closedIcon || closed.length;
821
- if (!landed) continue;
822
- if (!closed.length) {
823
- err(
824
- "E9",
825
- `${change.id} 的 form=${change.form} 只落了「显图标」,没有在同一作用域 ${scope} 内收口文案(需要 .${textTarget} 的 display:none,锚定 ${scope})—— 图标与文案并存会把固定窄宽挤爆,文案被截断(如「工作台」→「工…」)。注意:作用域外的兄弟变体收口(如 :not(:nth-child(1)))不算成对。`,
826
- );
827
- continue;
828
- }
829
- if (!/!important/i.test(closed[0].decls.display || "")) {
830
- warn("E9", `${change.id} 的文案收口 ${closed[0].selector} 未加 !important,存在被组件 scoped 同权反压的风险。`);
831
- } else {
832
- info("E9", `${change.id} 图标/文案收口成对 ✓(${closed[0].selector})`);
833
- }
834
- }
835
- })();
836
-
837
- // ── E15 结构声明兑现闸(G3:@sid 强制 + form/suppress 双向对账)──
838
- // 结构性声明(form / suppress / suppress_inner / structure_ops / text_visible:none /
839
- // visible:false)不能停留在台账上——项目侧必须有可指认的落点证据。实证缺口
840
- // 失效模式样例:collapse_trigger.form=header_inline 声明了,但词典 forms
841
- // 只有 bottom_bar / center_edge —— 壳层零规则,落地端静默无效,而 E6/E8/E9 的
842
- // 特判窗口都盖不住它。
843
- // 证据通道(按优先级):
844
- // ① /* @sid: <语义ID> */ —— 逐条落点标记(@sid 强制的机读载体)
845
- // ② /* @closed: <通道名> */ —— suppress 收口证据(与 @sid 同构)
846
- // ③ 通道为 css_vars 键时,设计层对该 var 写入关闭值(transparent / none / hidden / 0)
847
- // ④ visible:false 的 display:none 收口规则(E6 同源)
848
- // 判定:
849
- // · form 值不在词典 forms 枚举内 / suppress 通道名不是 forms∪css_vars∪custom_style_map
850
- // 的键 → ERROR(声明悬空 = 项目侧无规则可挂)
851
- // · suppress_rules[form] 与 suppress[] 要求的通道无任何兑现证据 → ERROR
852
- // · 有落点证据但缺 @sid → WARN(补标记使逐条对账可机读)
853
- // · 出厂默认形态且无其他结构载荷 → WARN(保持现状无需规则,但仍需标记)
854
- // · 反向:设计层 @closed 了变更集未声明的通道 → WARN(台账失真,收口记录进不了 D13 继承链)
855
- const CLOSING_VALUE_RE = /^(transparent|none|hidden|0(?:px)?)(\s|$)/i;
856
- const markedSids = new Set([...designCss.matchAll(/@sid\s*:\s*([\w.$-]+)/g)].map((m) => m[1]));
857
- const closedMarkers = new Set([...designCss.matchAll(/@closed\s*:\s*([\w.-]+)/g)].map((m) => m[1]));
858
-
859
- /** 与 parser extractSuppressRules 同源:结构化 suppress_rules 优先,forms 描述「suppress 必含」兜底 */
860
- function extractSuppressRulesAudit(entry) {
861
- const rules = {};
862
- if (entry && typeof entry.suppress_rules === "object" && entry.suppress_rules !== null) {
863
- for (const [form, channels] of Object.entries(entry.suppress_rules)) {
864
- rules[form] = Array.isArray(channels) ? channels.filter((c) => typeof c === "string") : [];
865
- }
866
- }
867
- const forms = entry?.forms;
868
- if (forms && typeof forms === "object" && !Array.isArray(forms)) {
869
- for (const [form, desc] of Object.entries(forms)) {
870
- if (typeof desc !== "string") continue;
871
- for (const m of desc.matchAll(/suppress\s*必含\s*([a-z][a-z0-9_]*(?:[、,/][a-z][a-z0-9_]*)*)/gi)) {
872
- const channels = m[1].split(/[、,/]/).map((s) => s.trim()).filter(Boolean);
873
- rules[form] = [...new Set([...(rules[form] || []), ...channels])];
874
- }
875
- }
876
- }
877
- return rules;
878
- }
879
-
880
- const channelNamesOf = (entry) => {
881
- const names = new Set([
882
- ...Object.keys(entry?.forms || {}),
883
- ...Object.keys(entry?.css_vars || {}),
884
- ...Object.keys(entry?.custom_style_map || {}),
885
- ]);
886
- // suppress_rules 的「值域通道」也是合法收口目标(与 parser G1 同源):如 dropdown_popper / tool_divider
887
- for (const channels of Object.values(entry?.suppress_rules || {})) {
888
- if (Array.isArray(channels)) for (const ch of channels) if (typeof ch === "string") names.add(ch);
889
- }
890
- // forms 描述里「suppress 必含 x」内联标记的通道同样合法
891
- for (const desc of Object.values(entry?.forms || {})) {
892
- if (typeof desc !== "string") continue;
893
- for (const m of desc.matchAll(/suppress\s*必含\s*([a-z][a-z0-9_]*)/gi)) names.add(m[1]);
894
- }
895
- return names;
896
- };
897
-
898
- /** 通道收口证据:@closed 标记 或(css_vars 键)关闭值覆写 */
899
- function channelEvidence(entry, ch) {
900
- if (closedMarkers.has(ch)) return `@closed:${ch}`;
901
- const varName = entry?.css_vars?.[ch];
902
- if (varName) {
903
- const re = new RegExp(`${varName}\\s*:\\s*([^;\\n}]+)`, "i");
904
- const m = re.exec(designCss);
905
- if (m && CLOSING_VALUE_RE.test(m[1].trim())) return `${varName} 关闭值覆写`;
906
- }
907
- return null;
908
- }
909
-
910
- /** visible:false 落点证据(E6 同源口径) */
911
- function visibleClosureRule(change) {
912
- const entry = dictSidMap.get(change.id);
913
- const target = classTokens(entry?.selector || "").slice(-1)[0];
914
- if (!target) return null;
915
- return (
916
- designRules.find((r) => r.selector.includes(`.${target}`) && /none/i.test(r.decls.display || "")) || null
917
- );
918
- }
919
-
920
- (function checkE15() {
921
- if (!changeset) {
922
- info("E15", "未发现变更集,跳过结构声明兑现对账");
923
- return;
924
- }
925
- const isStructural = (c) =>
926
- typeof c?.form === "string" ||
927
- (Array.isArray(c?.suppress) && c.suppress.length > 0) ||
928
- (typeof c?.suppress_inner === "string" && c.suppress_inner.trim()) ||
929
- (Array.isArray(c?.structure_ops) && c.structure_ops.length > 0) ||
930
- c?.style?.text_visible === "none" ||
931
- c?.visible === false;
932
-
933
- const changes = changeset.changes || [];
934
- for (const change of changes) {
935
- if (!isStructural(change)) continue;
936
- const entry = dictSidMap.get(change.id);
937
- if (!entry) {
938
- warn("E15", `${change.id} 声明结构语义(form/suppress 等),但词典无该条目 —— 项目侧无挂载点,无法兑现(match 会拦截)。`);
939
- continue;
940
- }
941
-
942
- // ① form 值真实性:悬空 form 值 = 壳层零规则(header_inline 实证)
943
- if (typeof change.form === "string" && change.form) {
944
- const forms = entry.forms && typeof entry.forms === "object" ? Object.keys(entry.forms) : [];
945
- const rules = entry.suppress_rules || {};
946
- // modes 型条目(forms 值为数组的枚举字典,如 admin.content.container 的 mode: ["fixed","scroll","auto"]):
947
- // form 合法值取数组并集
948
- const modeValues = [];
949
- for (const v of Object.values(entry.forms || {})) {
950
- if (Array.isArray(v)) for (const x of v) if (typeof x === "string") modeValues.push(x);
951
- }
952
- if (
953
- forms.length &&
954
- !forms.includes(change.form) &&
955
- !(change.form in rules) &&
956
- !modeValues.includes(change.form)
957
- ) {
958
- err(
959
- "E15",
960
- `${change.id} 的 form="${change.form}" 不在词典 forms 枚举 [${forms.join("、")}] 内 —— 项目侧无规则可挂(壳层零规则),声明悬空。形态值只能取词典已支持的枚举;确需新形态先走 pending 补词典。`,
961
- );
962
- }
963
- }
964
-
965
- // ② suppress 通道名真实性(「声明 → 词典」对账)
966
- const declared = Array.isArray(change.suppress) ? change.suppress : [];
967
- const known = channelNamesOf(entry);
968
- const unknown = declared.filter((ch) => !known.has(ch));
969
- if (unknown.length) {
970
- err(
971
- "E15",
972
- `${change.id}.suppress 引用了词典不认识的通道 [${unknown.join("、")}](可用通道:${[...known].join("、")})—— 通道名以该条目 forms/css_vars/custom_style_map 的键为准。`,
973
- );
974
- }
975
-
976
- // ③ suppress_rules[form] 与已声明通道的兑现证据(「声明 → 设计层」对账)
977
- const required = extractSuppressRulesAudit(entry)[change.form] || [];
978
- const demanded = [...new Set([...required, ...declared])];
979
- let allClosed = true;
980
- for (const ch of demanded) {
981
- const ev = channelEvidence(entry, ch);
982
- if (!ev) {
983
- allClosed = false;
984
- const varHint = entry.css_vars?.[ch]
985
- ? `,或(通道为 css_vars 键)在设计层覆写 ${entry.css_vars[ch]} 为关闭值(transparent / none / 0)`
986
- : "";
987
- err(
988
- "E15",
989
- `${change.id} 要求收口通道「${ch}」,但设计层无任何兑现证据 —— 需要 /* @closed: ${ch} */ 标记${varHint}。声明了不收口 = 出厂旧装饰残留(双竖条并存类症状)。`,
990
- );
991
- }
992
- }
993
-
994
- // ④ @sid 强制(零证据 = 壳层零规则;有证据缺标记 = 降级 WARN 促标记)
995
- const hasSid = markedSids.has(change.id);
996
- if (!hasSid) {
997
- const visRule = change.visible === false ? visibleClosureRule(change) : null;
998
- if (change.visible === false && !visRule) {
999
- // E6 已就同一根因报 ERROR,此处不重复
1000
- } else if (demanded.length && allClosed) {
1001
- warn("E15", `${change.id} 的收口已有落点证据,但缺 /* @sid: ${change.id} */ 标记 —— 补标记使「变更集 ↔ 设计层」逐条对账可机读(G3)。`);
1002
- } else if (change.visible === false && visRule) {
1003
- warn("E15", `${change.id} 的 display:none 收口已落(E6 ✓),但缺 /* @sid: ${change.id} */ 标记 —— 补标记使逐条对账可机读。`);
1004
- } else {
1005
- const forms = entry.forms && typeof entry.forms === "object" ? entry.forms : {};
1006
- const isDefaultForm = typeof change.form === "string" && /出厂默认|默认形态|default/i.test(String(forms[change.form] || ""));
1007
- const hasOps = Array.isArray(change.structure_ops) && change.structure_ops.length > 0;
1008
- const hasInner = typeof change.suppress_inner === "string" && change.suppress_inner.trim().length > 0;
1009
- const hasTextNone = change.style?.text_visible === "none";
1010
- const otherPayload = hasOps || hasInner || hasTextNone || demanded.length > 0;
1011
- if (isDefaultForm && !otherPayload) {
1012
- warn("E15", `${change.id} 的 form="${change.form}" 是出厂默认形态(无需新增规则),但缺 /* @sid: ${change.id} */ 标记 —— 建议补标记。`);
1013
- } else if (hasOps && !otherPayload) {
1014
- warn("E15", `${change.id} 的 structure_ops 由 parser G2 施工证据闸取证;设计层建议补 /* @sid: ${change.id} */ 标记。`);
1015
- } else {
1016
- err(
1017
- "E15",
1018
- `${change.id} 的结构性声明在设计层零落点证据(无 @sid 标记、无 @closed 兑现标记、无收口规则)—— 壳层零规则,声明悬空(例:collapse_trigger.form=header_inline 声明后壳层无任何规则)。每条结构性声明必须在设计层留机读标记:/* @sid: <语义ID> */。`,
1019
- );
1020
- }
1021
- }
1022
- }
1023
- }
1024
-
1025
- // ⑤ 反向对账:设计层收口了变更集未声明的通道 → 台账失真
1026
- for (const ch of closedMarkers) {
1027
- const owners = changes
1028
- .map((c) => ({ c, e: dictSidMap.get(c?.id) }))
1029
- .filter(({ e }) => e && channelNamesOf(e).has(ch));
1030
- if (!owners.length) continue; // 不属于本变更集涉及的点位(历史层),不判
1031
- const accounted = owners.some(
1032
- ({ c, e }) =>
1033
- (Array.isArray(c.suppress) ? c.suppress.includes(ch) : false) ||
1034
- (extractSuppressRulesAudit(e)[c.form] || []).includes(ch),
1035
- );
1036
- if (!accounted) {
1037
- warn(
1038
- "E15",
1039
- `设计层 /* @closed: ${ch} */ 收口了通道,但变更集未声明该收口(涉及点位:${owners.map((o) => o.c.id).join("、")})—— 台账失真:收口记录进不了 D13 继承链,下轮漏抄即出厂形态复现。`,
1040
- );
1041
- }
1042
- }
1043
- if (!errors.some((e) => e.id === "E15") && !warnings.some((w) => w.id === "E15")) {
1044
- info("E15", "结构声明兑现:form/suppress 全部落在词典已知通道且设计层有落点证据 ✓");
1045
- }
1046
- })();
1047
-
1048
- // ── @sid 标记覆盖率(可选增强) ─────────────────────────────
1049
- (function checkSidCoverage() {
1050
- const marked = new Set([...designCss.matchAll(/@sid\s*:\s*([\w.$-]+)/g)].map((m) => m[1]));
1051
- if (!marked.size) {
1052
- info("SID", "设计层未使用 @sid 标记;建议每条设计层规则上方标注 /* @sid: <语义ID> */,即可启用「变更集 ↔ 设计层」逐条覆盖对账。");
1053
- return;
1054
- }
1055
- const declared = new Set((changeset?.changes || []).map((c) => c.id));
1056
- const missing = [...declared].filter((sid) => !marked.has(sid));
1057
- for (const sid of missing) {
1058
- warn("SID", `变更集声明了 ${sid},但设计层没有对应 @sid 标记 —— 无法证明该条已落到样式/结构。`);
1059
- }
1060
- info("SID", `@sid 覆盖率:${declared.size - missing.length}/${declared.size}`);
1061
- })();
1062
-
1063
- // ── 输出 ────────────────────────────────────────────────────
1064
- // ── E11 交付完整性 ──────────────────────────────────────────
1065
- // 「落地后只剩主题色」不是落点问题,是上游交付残缺口 + 下游挑拣落地。本项把这两件事都变成 ERROR。
1066
- (function checkE11() {
1067
- if (!changeset) {
1068
- warn(
1069
- "E11",
1070
- `未发现变更集(搜索 ${CHANGESET_DIRS.join(" / ")})—— 本轮交付完整性、收口断言、浮层残留、形态收口均无法判定(闸门静默跳过)。设计还原项目跑不出变更集,本身就是交付缺失。`,
1071
- );
1072
- return;
1073
- }
1074
- const mode = changeset.mode;
1075
- const cov = changeset.coverage;
1076
- const gaps = (cov && cov.gaps) || [];
1077
- const pending = changeset.pending || [];
1078
- const changes = changeset.changes || [];
1079
- const notesText = JSON.stringify(changeset.notes || []);
1080
-
1081
- if (mode !== "restore") {
1082
- err(
1083
- "E11",
1084
- `mode 必须为 "restore"(实为 ${JSON.stringify(changeset.mode ?? null)}):` +
1085
- `还原任务的目标是把设计 100% 落地到项目,环境受限 / 覆盖不足 / 意图不明时唯一正确动作是停下向用户多轮询问确认,不得缩水交付。`,
1086
- );
1087
- return;
1088
- }
1089
- if (!cov) {
1090
- err("E11", "mode=restore 但缺少 coverage 通道 —— 还原交付必须有覆盖率记账;缺失即无法判定登记项是否逐条兑现,落地只会剩下主题色一类全局项。");
1091
- return;
1092
- }
1093
- const R = cov.registrable;
1094
- const A = cov.applicable;
1095
- const C = cov.covered;
1096
- const numeric = typeof A === "number" && typeof C === "number";
1097
- if (numeric && C < A) {
1098
- err(
1099
- "E11",
1100
- `欠声明:coverage.covered=${C} < applicable=${A}(登记项 registrable=${R})—— 有 ${A - C} 条登记项未落进 changes[] / visible / pending。落地端只能兑现已声明的那部分,其余点位零变化,这就是「和设计对不上、只变了主题色」的机制成因。`,
1101
- );
1102
- }
1103
- if (gaps.length) {
1104
- err("E11", `coverage.gaps 非空(${gaps.length} 条)—— 交付契约要求 gaps 为空才可交付(自由文本理由不算闭合资质)。`);
1105
- if (!pending.length) {
1106
- err(
1107
- "E11",
1108
- `缺口未台账化:gaps 有 ${gaps.length} 条理由,但 pending[] 为空 —— 缺口没有变成可执行工作项,所以「继续」时无任何东西可做,复跑也不可能收敛(症状:多轮继续后效果不变)。`,
1109
- );
1110
- } else {
1111
- const declared = new Set(changes.map((c) => c.id));
1112
- const orphan = pending.filter((p) => p && p.proposed_id && !declared.has(p.proposed_id));
1113
- if (orphan.length) {
1114
- err("E11", `pending 中 ${orphan.length} 条未在 changes[] 双写(${orphan.slice(0, 5).map((p) => p.proposed_id).join("、")})—— 只写 pending 的点位不会被施工。`);
1115
- }
1116
- }
1117
- }
1118
- // 分母漂移:自报 registrable 必须与设计侧登记表实物一致
1119
- if (typeof R === "number" && cov.registry) {
1120
- const cands = [
1121
- path.join(SKILL_ROOT, "..", "project-ui-design", "references", "page-specs", String(cov.registry)),
1122
- path.join(SKILL_ROOT, "..", "project-ui-design", String(cov.registry)),
1123
- ].map((p) => path.resolve(p));
1124
- const real = cands.find((p) => fs.existsSync(p));
1125
- if (real) {
1126
- try {
1127
- const reg = JSON.parse(fs.readFileSync(real, "utf-8"));
1128
- const items = reg.items || reg.registry || [];
1129
- if (Array.isArray(items) && items.length && items.length !== R) {
1130
- err("E11", `登记表分母漂移:coverage.registrable=${R},登记表实物为 ${items.length} 条 —— 自报数与实物不一致。分母错则该报的欠声明数也不可信。`);
1131
- } else if (Array.isArray(items) && items.length) {
1132
- info("E11", `登记表分母对账:registrable=${R} = 实物 ${items.length} 条 ✓`);
1133
- }
1134
- } catch {
1135
- /* 解析异常忽略 */
1136
- }
1137
- } else {
1138
- warn("E11", `登记表实物未解析到(coverage.registry="${cov.registry}")—— 项目侧 G9 身份闸会以「登记表实物不存在」拒绝执行;覆盖率分母必须来自「方案 × 场景」的真实登记表。`);
1139
- }
1140
- }
1141
- // 交付说明失真:notes 的成功话术与机器可读字段矛盾
1142
- const claimsComplete = /covered\s*[==]\s*applicable|gaps\s*[==]\s*\[\s*\]|覆盖率\s*100|全量覆盖/.test(notesText);
1143
- if (claimsComplete && ((numeric && C < A) || gaps.length)) {
1144
- err(
1145
- "E11",
1146
- `交付说明失真:notes 含「覆盖率 C=A / gaps=[] / 全量覆盖」类完成结论,而字段实为 covered=${C}/applicable=${A}、gaps=${gaps.length} 条 —— 成功话术与机器可读字段互相矛盾,会直接误导下游判定「已交付、无事可做」。`,
1147
- );
1148
- }
1149
- if (!errors.some((e) => e.id === "E11") && !warnings.some((w) => w.id === "E11")) {
1150
- info("E11", `交付完整性:mode=restore,covered=${C}=applicable=${A},gaps 空,台账闭合 ✓`);
1151
- }
1152
- })();
1153
-
1154
- // ── E12 设计层累积性(跨轮回退检测 + 交付台账) ──────────────
1155
- // 「继续一次处理不完、继续了也没变化」的根治:把每轮落地状态落成机器可读台账,
1156
- // 使「本轮是否把上一轮成果覆盖掉」可被断言,而不是靠人记得。
1157
- const LEDGER_PATH = path.join(PROJECT, ".design", "ledger.json");
1158
- const WRITE_LEDGER = !process.argv.includes("--no-ledger");
1159
- (function checkE12() {
1160
- const primaryText = srcTextByFile.get(PRIMARY_LAYER) ?? readIf(PRIMARY_LAYER) ?? "";
1161
- const primaryLines = primaryText ? primaryText.split("\n").length : 0;
1162
- const appliedSids = changeset ? (changeset.changes || []).map((c) => c.id).sort() : [];
1163
- const cov = changeset && changeset.coverage;
1164
- const cur = {
1165
- at: new Date().toISOString(),
1166
- changeset: changesetFile ? relp(changesetFile) : null,
1167
- mode: changeset ? changeset.mode ?? null : null,
1168
- covered: cov ? cov.covered : null,
1169
- applicable: cov ? cov.applicable : null,
1170
- design_shell_lines: primaryLines,
1171
- applied_sids: appliedSids,
1172
- };
1173
-
1174
- let ledger = null;
1175
- if (fs.existsSync(LEDGER_PATH)) {
1176
- try {
1177
- ledger = JSON.parse(fs.readFileSync(LEDGER_PATH, "utf-8"));
1178
- } catch {
1179
- ledger = null;
1180
- }
1181
- }
1182
- const history = (ledger && ledger.history) || [];
1183
- const priorRounds = ledger ? [ledger, ...history] : [];
1184
-
1185
- if (!ledger) {
1186
- info("E12", `无交付台账(${relp(LEDGER_PATH)})—— 无法检测「本轮是否覆盖掉上一轮设计层」;已写入本轮台账作为基线。`);
1187
- } else {
1188
- // 与历史峰值比较,避免「坏一轮 → 台账被改写 → 下轮检测不出」的掩盖效应
1189
- const peakLines = Math.max(0, ...priorRounds.map((r) => r.design_shell_lines || 0));
1190
- if (peakLines && primaryLines && primaryLines < peakLines * 0.6) {
1191
- err(
1192
- "E12",
1193
- `交付回退:设计层 ${argValue("--design-layer", "src/styles/design-shell.scss")} 行数 ${peakLines} → ${primaryLines}(骤降 >40%)—— 上一轮已落地的规则被覆盖/丢失。设计层是累积产物,禁止按「出厂空文件」重建;重建必须合并既有规则(否则用户侧表现为「布局样式全不对,只剩主题色」)。`,
1194
- );
1195
- }
1196
- const priorSids = new Set(priorRounds.flatMap((r) => r.applied_sids || []));
1197
- const lost = [...priorSids].filter((s) => !appliedSids.includes(s));
1198
- if (priorSids.size && lost.length > Math.max(3, priorSids.size * 0.3)) {
1199
- err("E12", `交付回退:本轮变更集比历史已声明点位少 ${lost.length} 条(${lost.slice(0, 6).join("、")}${lost.length > 6 ? "…" : ""})—— 本轮只覆盖了更小范围,按现状落地会把上一轮成果清掉。`);
1200
- } else if (priorSids.size && lost.length) {
1201
- warn(
1202
- "E12",
1203
- `本轮未覆盖历史已声明点位 ${lost.length} 条(${lost.slice(0, 6).join("、")}${lost.length > 6 ? "…" : ""})—— 若本轮 scope(coverage.page_types)确实不含这些点位可忽略;否则它们会从项目里静默消失。`,
1204
- );
1205
- } else if (priorSids.size) {
1206
- info("E12", `累积性:本轮 ${appliedSids.length} 条 vs 历史已声明 ${priorSids.size} 条,未检出回退`);
1207
- }
1208
- }
1209
-
1210
- if (WRITE_LEDGER) {
1211
- const next = { round: ledger ? (ledger.round || 1) + 1 : 1, ...cur, history: [...history, ...(ledger ? [ledger] : [])].slice(0, 20) };
1212
- for (const r of next.history) delete r.history;
1213
- fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
1214
- fs.writeFileSync(LEDGER_PATH, JSON.stringify(next, null, 2), "utf-8");
1215
- }
1216
- })();
1217
-
1218
- // 列名提取(供 E5/E14 共用):兼容 `label: $t('类型')` / `label="类型"` 两种写法
1219
- const labelOfRaw = (src) => {
1220
- const m =
1221
- src.match(/label:\s*([^,\n}]+)/) || src.match(/\blabel="([^"]*)"/) || src.match(/\blabel='([^']*)'/);
1222
- if (!m) return "?";
1223
- const s = String(m[1]).trim();
1224
- const q = s.match(/[`'"]([^`'"]+)[`'"]/);
1225
- return ((q ? q[1] : s).trim() || "?");
1226
- };
1227
-
1228
- // ── E13 固定尺寸 vs 内容度量 ─────────────────────────────────
1229
- // 「设计说 40px,代码就固定 40px」是本闸治理的对象:像素值不是照抄依据,
1230
- // 而是「该容器要装多少内容」的结论。固定宽容器装不下内容、又没声明收口/截断
1231
- // 兜底时,文字会换行、被压缩、甚至字符重叠渲染成畸形。
1232
- // 判据分两档:① 选择器语义指向文本容器 → 最小需求 = 2 CJK + 左右内边距;
1233
- // ② 其余固定宽 → 低于 24px 连一个字形都放不下。
1234
- // 文本承载语义:只认「这个盒子本身要装字」的选择器;图形载体(图标 / 伪元素 /
1235
- // 分割线 / 开关)不承载文本,不参与内容度量,否则会成片误报。
1236
- const TEXTY_SEL = /(text|title|label|button|tab|span|desc)/i;
1237
- const GRAPHIC_LAST = /(el-icon|icon|svg|img|checkbox|radio|switch|avatar|divider|arrow|star|loading|spinner)|::(before|after|placeholder|marker|selection)/i;
1238
- const MIN_TEXT_BOX = 44;
1239
- const MIN_ANY_BOX = 24;
1240
- (function checkE13() {
1241
- if (!designRules.length) {
1242
- info("E13", "固定尺寸内容度量:无设计层规则");
1243
- return;
1244
- }
1245
- // 项目源码里真实存在的类名(排除设计层自身)——用于剔除「挂空选择器」,
1246
- // 挂空由 E4 单独报,此处只管「锚点存在但装不下内容」。
1247
- const projectClasses = new Set();
1248
- for (const [f, t] of srcTextByFile) {
1249
- if (designSet.has(path.resolve(f))) continue;
1250
- for (const m of t.matchAll(/class\s*[:=]\s*["'`]([^"'`]+)["'`]/g)) {
1251
- for (const c of m[1].split(/[\s{}(),:$'"]+/)) if (/^[A-Za-z][\w-]*$/.test(c)) projectClasses.add(c);
1252
- }
1253
- }
1254
- // 同一选择器上的「兄弟规则」也算保护来源(收口常写成独立规则块)
1255
- const hasProtect = (decls) =>
1256
- String(decls.overflow || "").match(/hidden|clip/) ||
1257
- String(decls["text-overflow"] || "").match(/ellipsis/) ||
1258
- String(decls.display || "").match(/none/);
1259
- const protectBySel = new Set();
1260
- for (const r of designRules) {
1261
- if (hasProtect(r.decls)) protectBySel.add(r.selector);
1262
- }
1263
- const hits = [];
1264
- for (const r of designRules) {
1265
- const raw = r.decls.width;
1266
- if (raw == null) continue;
1267
- if (r.decls["min-width"] != null || r.decls["max-width"] != null) continue;
1268
- const m = String(raw).match(/^(\d+(?:\.\d+)?)\s*px$/i);
1269
- if (!m) continue;
1270
- const n = Number(m[1]);
1271
- const isTexty = TEXTY_SEL.test(r.selector);
1272
- const floor = isTexty ? MIN_TEXT_BOX : MIN_ANY_BOX;
1273
- if (n >= floor) continue;
1274
- if (hasProtect(r.decls)) continue;
1275
- if (protectBySel.has(r.selector)) continue;
1276
- if (GRAPHIC_LAST.test(lastCompound(r.selector)) || GRAPHIC_LAST.test(r.selector)) continue;
1277
- const toks = classTokens(r.selector);
1278
- if (toks.length && !toks.some((c) => projectClasses.has(c))) continue; // 挂空 → 交 E4
1279
- hits.push({ r, n, floor, isTexty });
1280
- }
1281
- for (const h of hits.slice(0, 8)) {
1282
- err(
1283
- "E13",
1284
- `${relp(h.r.file)}:\`${h.r.selector}\` 固定 width:${h.n}px —— ` +
1285
- (h.isTexty
1286
- ? `该选择器指向文本容器,装下 2 个汉字 + 内边距至少需 ${h.floor}px`
1287
- : `低于任何字形盒子的下限 ${h.floor}px`) +
1288
- `;且未声明收口兜底(overflow:hidden / text-overflow:ellipsis / display:none)。` +
1289
- `固定宽小于内容需求时文字会换行、被压缩或重叠渲染 —— ` +
1290
- `请改为内容驱动(min-width / 自适应),或在同一作用域内显式写出收口策略(形态收容 + 文案隐藏成对)。`,
1291
- );
1292
- }
1293
- if (hits.length > 8) err("E13", `固定尺寸内容度量:另有 ${hits.length - 8} 处未列出。`);
1294
- if (!hits.length) info("E13", `固定尺寸内容度量:${designRules.length} 条设计层规则中未检出「固定宽 < 内容下限且无收口」`);
1295
- })();
1296
-
1297
- // ── E14 列宽预算链自洽(列定义宽 vs 实际 td 内边距)──────────
1298
- // 列宽与内容需求必须用同一套「可用宽」口径。实例:列宽公式按单元格留白 24px
1299
- // 推算,而设计层把 td padding 设为 16px(左右共 32px)→ 列宽比真实可用宽多算
1300
- // 8px,内容必然溢出、被内层盒压缩 → 操作按钮文字换行 / 重叠。
1301
- (function checkE14() {
1302
- const tableFiles = [...srcTextByFile.entries()].filter(([, t]) => t.includes("el-table-column"));
1303
- if (!tableFiles.length) {
1304
- info("E14", "列宽预算链:未在 src 中发现 el-table-column");
1305
- return;
1306
- }
1307
- // 设计层对数据格宿主的水平内边距(实际生效值)
1308
- let tdPadX = null;
1309
- for (const r of designRules) {
1310
- if (!/td\.el-table__cell|\.el-table__body\s+td/.test(r.selector)) continue;
1311
- const p = r.decls.padding;
1312
- if (!p) continue;
1313
- const parts = String(p).replace(/!important/g, "").trim().split(/\s+/);
1314
- const px = (v) => {
1315
- const mm = String(v).match(/(\d+(?:\.\d+)?)px/);
1316
- return mm ? Number(mm[1]) : null;
1317
- };
1318
- const vals = parts.map(px).filter((v) => v != null);
1319
- if (!vals.length) continue;
1320
- // padding: A → 四边 A;A B → 上下 A 左右 B;A B C → 上 A 左右 B 下 C
1321
- const x = vals.length === 1 ? vals[0] : vals.length === 2 ? vals[1] : vals.length === 3 ? vals[1] : vals[3];
1322
- tdPadX = x * 2;
1323
- break;
1324
- }
1325
- // 操作列:内容需求 = 平铺槽数 × 槽宽(与组件同源公式)
1326
- const slotW = (() => {
1327
- const src = readIf(path.join(PROJECT, "src", "components", "ReTableOperate", "utils.ts"));
1328
- if (!src) return null;
1329
- const moreChars = Number((src.match(/MORE_LABEL_CHARS\s*=\s*(\d+)/) || [])[1] || 3);
1330
- const fsMatch = src.match(/default:\s*(\d+)/);
1331
- const fs = fsMatch ? Number(fsMatch[1]) : 14;
1332
- const btnSrc = [...srcTextByFile.entries()].find(([, t]) => /operateButtons\s*(?::[^=]+)?=\s*\[/.test(t));
1333
- let maxChars = 2;
1334
- if (btnSrc) {
1335
- const arr = matchArrayLiteral(btnSrc[1], /operateButtons\s*(?::[^=]+)?=\s*\[/);
1336
- if (arr) for (const obj of extractObjectLiterals(arr)) {
1337
- const lm = obj.match(/label\s*:\s*(?:\$t\()?['"`]([^'"`]+)/);
1338
- if (lm) maxChars = Math.max(maxChars, [...lm[1]].length);
1339
- }
1340
- }
1341
- maxChars = Math.max(maxChars, moreChars);
1342
- return Math.ceil(fs + 4 + maxChars * fs);
1343
- })();
1344
- for (const [file, text] of tableFiles) {
1345
- const rel = relp(file);
1346
- const arr = matchArrayLiteral(text, /(?:const|let|var)\s+\w*[Cc]olumn\w*\s*(?::[^=]+)?=\s*\[/);
1347
- if (!arr) continue;
1348
- const limMatch = text.match(/operateLimit\s*=\s*(\d+)/);
1349
- const limit = limMatch ? Number(limMatch[1]) : null;
1350
- for (const objSrc of extractObjectLiterals(arr)) {
1351
- const prop = (objSrc.match(/prop:\s*[`'"]([^`'"]+)/) || [])[1];
1352
- if (!prop) continue;
1353
- const label = labelOfRaw(objSrc);
1354
- const ops = /^(operate|operation|actions)$/.test(prop);
1355
- const wLit = (objSrc.match(/\bwidth\s*:\s*[`'"]([\d.]+)px/) || [])[1];
1356
- const colW = wLit ? Number(wLit) : null;
1357
- if (!ops) continue;
1358
- // 模板是否用公式覆盖了列定义字面量 —— 「同一尺寸两个来源」的第一现场
1359
- const usesFormula = /resolveOperateColumnWidth\s*\(/.test(text);
1360
- if (colW == null && !usesFormula) continue;
1361
- if (slotW == null || limit == null) {
1362
- warn("E14", `${rel}:操作列「${label}」无法解析槽宽/平铺上限(ReTableOperate/utils.ts 或 operateLimit 缺失),跳过列宽预算链核对。`);
1363
- continue;
1364
- }
1365
- const utilSrc = readIf(path.join(PROJECT, "src", "components", "ReTableOperate", "utils.ts")) || "";
1366
- const padInFormula = Number((utilSrc.match(/cellPadding\s*=\s*options\.cellPadding\s*\?\?\s*(\d+)/) || [])[1] || 24);
1367
- const formulaW = limit * slotW + padInFormula;
1368
- const effectiveW = usesFormula ? formulaW : colW != null ? colW : formulaW;
1369
- const contentNeed = limit * slotW;
1370
- const available = tdPadX != null ? effectiveW - tdPadX : effectiveW - padInFormula;
1371
- if (usesFormula && colW != null && colW !== formulaW) {
1372
- err(
1373
- "E14",
1374
- `${rel}:操作列「${label}」列宽有两个来源且不一致 —— 列定义写死 width="${wLit}px",` +
1375
- `但模板用 \`resolveOperateColumnWidth()\` 覆盖为 ${formulaW}px(${limit}×${slotW}+${padInFormula}),实际生效 ${effectiveW}px。` +
1376
- `同一尺寸不得有两个权威来源:请删掉列定义里的死值,只保留公式(或反之),否则改一处不生效、排查时按死值反推必然误判。`,
1377
- );
1378
- }
1379
- if (contentNeed > available) {
1380
- err(
1381
- "E14",
1382
- `${rel}:操作列「${label}」列宽预算链断裂 —— 列宽 ${effectiveW}px(${colW != null ? `列定义 width="${wLit}px"` : `公式 ${limit}×${slotW}+${padInFormula}`})` +
1383
- `扣掉实际单元格水平内边距 ${tdPadX != null ? `${tdPadX}px(设计层 td padding)` : `${padInFormula}px(公式默认)`} 后可用 ${available}px,` +
1384
- `而平铺 ${limit} 个等宽槽需 ${contentNeed}px → 溢出 ${contentNeed - available}px。` +
1385
- `列宽公式的内置留白必须与实际生效的 td padding 同口径;此处请把列宽抬到 ≥ ${contentNeed + (tdPadX ?? padInFormula)}px,或同步下调内边距,` +
1386
- `否则按钮文字会被内层盒压缩、换行甚至字符重叠。`,
1387
- );
1388
- } else if (tdPadX != null && padInFormula !== tdPadX) {
1389
- warn(
1390
- "E14",
1391
- `${rel}:操作列「${label}」列宽公式内置单元格留白 ${padInFormula}px ≠ 设计层实际 td 水平内边距 ${tdPadX}px —— 当前差额 ${Math.abs(padInFormula - tdPadX)}px 尚未导致溢出,但任一参数变动即越界;建议统一为同一来源。`,
1392
- );
1393
- } else {
1394
- info("E14", `${rel}:操作列「${label}」列宽 ${effectiveW}px,可用 ${available}px ≥ 内容 ${contentNeed}px ✓`);
1395
- }
1396
- }
1397
- }
1398
- })();
1399
-
1400
- // ── E16 引用完整性(交付物指向的实物必须存在)────────────────
1401
- // 标识(文件名 / 路径)变更后未同步引用 = 静默断链:闸门找不到基线图、
1402
- // 项目侧 parser 取不到登记表,全链路降级为「凭印象还原」。
1403
- (function checkE16() {
1404
- if (!changeset) {
1405
- info("E16", "引用完整性:未发现变更集");
1406
- return;
1407
- }
1408
- const probes = [
1409
- ["oracle.mockup", changeset.oracle?.mockup],
1410
- ["coverage.registry", changeset.coverage?.registry],
1411
- ["oracle.theme_mapping.mockup", changeset.oracle?.theme_mapping?.mockup],
1412
- ];
1413
- let checked = 0;
1414
- for (const [field, val] of probes) {
1415
- if (!val || typeof val !== "string") continue;
1416
- if (/^https?:\/\//.test(val)) continue;
1417
- checked++;
1418
- const cands = [
1419
- path.resolve(PROJECT, val),
1420
- path.resolve(SKILL_ROOT, val),
1421
- path.resolve(SKILL_ROOT, "..", val),
1422
- path.resolve(PROJECT, "..", val),
1423
- // 设计侧 page-specs 根(效果图 / 登记表以 page-specs 根为相对基座交付)
1424
- path.resolve(SKILL_ROOT, "..", "project-ui-design", "references", "page-specs", val),
1425
- ];
1426
- if (!cands.some((p) => fs.existsSync(p))) {
1427
- err(
1428
- "E16",
1429
- `变更集 \`${field}\` 指向的实物不存在:${val} —— 标识变更(改名 / 换目录)后未同步引用,` +
1430
- `闸门与 parser 都取不到基线(会静默降级为无基线还原)。请同步引用方(变更集字段 / 方案注册表 baseline / 登记表路径)。`,
1431
- );
1432
- }
1433
- }
1434
- // restore 模式的顶层 theme 字段:缺失会让「本次主题」回落到 global.brand_primary 反推,
1435
- // 重新引入「换主题 = 换读取路径」的分叉
1436
- if (changeset.mode === "restore" && !changeset.theme) {
1437
- warn(
1438
- "E16",
1439
- "变更集 mode=restore 但缺顶层 `theme` 字段 —— 本次主题只能回落按 `global.brand_primary` 反推(旧口径)。补 `theme` 可让「本次主题」与「基线图色板」两个身份各自可机读、互不推断。",
1440
- );
1441
- }
1442
- if (checked && !errors.some((e) => e.id === "E16")) info("E16", `引用完整性:${checked} 个路径引用均可解析到实物 ✓`);
1443
- })();
1444
-
1445
- // ── E17 层间令牌链对账(双层内边距叠加断言)──────────────────
1446
- // 失效模式:父子两层盒共享同一条间距令牌链(--layout-content-padding → --vts-margin),
1447
- // 设计层绕过令牌通道在父层抢写物理 padding,子层同链令牌幸存 → 设计值 + 令牌出厂值叠加
1448
- // (24px + 8px = 32px/侧,「边距很大」)。判据(词典「层间令牌链对账」标记驱动):
1449
- // ① 设计层在标记条目的 selector 上抢写物理 padding/margin 属性(非 var 消费);
1450
- // ② 该条目同链令牌在项目内仍存在非零定义(子层消费者会叠加取值)。
1451
- // 修复方向:改走令牌通道(重映射 --layout-content-padding / --vts-margin),或归零另一层。
1452
- (function checkE17() {
1453
- // 从词典收集「层间令牌链对账」条目:selector 主类 ↔ 同链令牌(css_vars 值)与字段名
1454
- const chainEntries = dictEntries
1455
- .filter(({ entry }) =>
1456
- Array.isArray(entry.preconditions) &&
1457
- entry.preconditions.some((p) => typeof p === "string" && p.includes("层间令牌链对账")))
1458
- .map(({ sid, entry }) => ({
1459
- sid,
1460
- classes: (String(entry.selector || "").match(/\.[\w-]+/g) || []).map((c) => c.slice(1)),
1461
- vars: Object.values(entry.css_vars || {}).filter((v) => typeof v === "string"),
1462
- fields: Object.keys(entry.css_vars || {}).filter((k) => /pad|margin/i.test(k)),
1463
- }))
1464
- .filter((e) => e.classes.length && e.vars.length);
1465
-
1466
- if (!chainEntries.length) {
1467
- info("E17", "层间令牌链对账:词典无标记条目,跳过");
1468
- return;
1469
- }
1470
-
1471
- // 同链令牌在项目内(含设计层)的当前定义值(后者覆盖前者,按样式层叠顺序近似取最后定义)
1472
- const varValueOf = (name) => {
1473
- let last = null;
1474
- for (const t of [projectText, designCss].filter(Boolean)) {
1475
- const re = new RegExp(`${name.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&")}\\s*:\\s*([^;]+);`, "g");
1476
- for (const m of t.matchAll(re)) last = m[1].trim();
1477
- }
1478
- return last;
1479
- };
1480
-
1481
- let checked = 0;
1482
- for (const { sid, classes, vars, fields } of chainEntries) {
1483
- for (const rule of designRules) {
1484
- // 命中条目 selector 的主类(末位复合选择器含任一标记类)
1485
- const ruleClasses = new Set((rule.selector.match(/\.[\w-]+/g) || []).map((c) => c.slice(1)));
1486
- if (!classes.some((c) => ruleClasses.has(c))) continue;
1487
- for (const prop of Object.keys(rule.decls)) {
1488
- if (!/^(padding|margin)(-(top|right|bottom|left|inline|block))?$/.test(prop)) continue;
1489
- checked++;
1490
- const val = String(rule.decls[prop]);
1491
- // 写的就是 var() 消费 = 令牌通道,合法
1492
- if (/var\(/.test(val)) continue;
1493
- // 抢写物理值:同链任一令牌在项目内仍有非零定义 → 双层叠加
1494
- const survivors = vars
1495
- .map((v) => ({ v, def: varValueOf(v) }))
1496
- .filter(({ def }) => def && !/^(0|0px|initial|unset)$/.test(def) && !/var\(/.test(def));
1497
- if (survivors.length) {
1498
- err(
1499
- "E17",
1500
- `层间令牌链对账:${rule.selector} 的 ${prop}: ${val} 绕过令牌通道抢写物理间距,但同链令牌 ` +
1501
- `${survivors.map((s) => `${s.v}(当前定义 ${s.def})`).join("、")} 仍非零 —— 父层设计值与子层令牌值叠加,` +
1502
- `实际边距 = 两者之和(如 24px + 8px = 32px/侧)。修复:改走令牌通道(在该点位或消费点作用域重映射 ` +
1503
- `${vars.join(" / ")},两层同变),或把另一层同链间距归零;仅保留一个权威来源。`,
1504
- );
1505
- }
1506
- }
1507
- }
1508
- }
1509
- if (checked) {
1510
- if (!errors.some((e) => e.id === "E17")) info("E17", `层间令牌链对账:${checked} 处几何声明均走令牌通道或邻层已归零 ✓`);
1511
- } else {
1512
- info("E17", "层间令牌链对账:标记条目无物理几何声明,跳过");
1513
- }
1514
- })();
1515
-
1516
- // ── E18 激活态消费者存活断言(成对落地 + 伪元素对决)──────────
1517
- // 失效模式:菜单激活态令牌 --vts-theme-menu-active-before 出厂 transparent 且全项目无消费者。
1518
- // 设计侧声明 indicator_* 形态(或 menu_active_indicator 字段)后:
1519
- // ① 设计层不新建 ::before/::after 消费者 → 令牌仍透明、无消费点,竖条不可见;
1520
- // ② 建了消费者但令牌从未供值(仍 transparent)→ 消费者渲染但不可见;
1521
- // ③ 消费者与出厂 sidebar.scss 的同名 ::before 胶囊(specificity 更高 + !important 背景)
1522
- // 对决输了 → 竖条几何整体不渲染。
1523
- // 判据(词典「激活态消费者成对落地」标记驱动):三者缺一即 ERROR。
1524
- (function checkE18() {
1525
- const marked = dictEntries
1526
- .filter(({ entry }) =>
1527
- Array.isArray(entry.preconditions) &&
1528
- entry.preconditions.some((p) => typeof p === "string" && p.includes("激活态消费者成对落地")))
1529
- .map(({ sid, entry }) => ({ sid, entry }));
1530
- if (!marked.length) {
1531
- info("E18", "激活态消费者成对落地:词典无标记条目,跳过");
1532
- return;
1533
- }
1534
-
1535
- // 令牌最终定义值(projectText → designCss 顺序近似层叠,取最后定义;支持一层 var 链解析)
1536
- const finalVarValue = (name, seen = new Set()) => {
1537
- let last = null;
1538
- for (const t of [projectText, designCss].filter(Boolean)) {
1539
- const re = new RegExp(`${name.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&")}\\s*:\\s*([^;]+);`, "g");
1540
- for (const m of t.matchAll(re)) last = m[1].trim();
1541
- }
1542
- if (last == null) return null;
1543
- const varRef = last.match(/^var\(\s*(--[\w-]+)/);
1544
- if (varRef && !seen.has(name)) {
1545
- seen.add(name);
1546
- const inner = finalVarValue(varRef[1], seen);
1547
- return inner != null ? inner : last;
1548
- }
1549
- return last;
1550
- };
1551
-
1552
- for (const { sid, entry } of marked) {
1553
- const change = (changeset?.changes || []).find((c) => c.id === sid);
1554
- if (!change) continue;
1555
- const form = String(change.form || "");
1556
- const style = change.style && typeof change.style === "object" ? change.style : {};
1557
- const declaresIndicator =
1558
- /indicator/.test(form) ||
1559
- "menu_active_indicator" in style ||
1560
- Object.keys(style).some((k) => /^active_indicator/.test(k));
1561
- if (!declaresIndicator) continue;
1562
-
1563
- const vars = Object.values(entry.css_vars || {})
1564
- .filter((v) => typeof v === "string" && /active/.test(v));
1565
- const indicatorVar = vars.find((v) => /before/.test(v)) || vars[0];
1566
- if (!indicatorVar) continue;
1567
-
1568
- // 断言①:设计层存在该令牌的消费者(var() 引用)
1569
- const consumedInDesign = designRules.some((r) =>
1570
- Object.values(r.decls).some((v) => String(v).includes(`var(${indicatorVar}`)),
1571
- );
1572
- if (!consumedInDesign) {
1573
- err(
1574
- "E18",
1575
- `激活态消费者成对落地:${sid} 声明了指示条形态(form=${form || "—"}),但设计层没有任何规则消费 ` +
1576
- `${indicatorVar} —— 只供值不建消费者 = 未施工(令牌出厂 transparent,全项目无消费点),竖条不可见。` +
1577
- `修复:在 design-shell 新建 ::before/::after 消费者引用 var(${indicatorVar})。`,
1578
- );
1579
- continue;
1580
- }
1581
-
1582
- // 断言②:令牌最终定义非 transparent/none/初始值
1583
- const finalVal = finalVarValue(indicatorVar);
1584
- if (finalVal && /^(transparent|none|initial|unset|inherit)$/i.test(finalVal.replace(/!important\s*$/i, "").trim())) {
1585
- err(
1586
- "E18",
1587
- `激活态消费者成对落地:${indicatorVar} 消费者已建,但最终定义仍为 ${finalVal.trim()}(出厂中性基线)——` +
1588
- `消费者在渲染,色值透明 = 竖条不可见。修复:在条目 style_scope(如 .sidebar-container)内显式供值 ` +
1589
- `(非透明设计色),或在对应 html[data-theme] 主题块确认已有非透明定义。`,
1590
- );
1591
- }
1592
-
1593
- // 断言③(警告级):消费者是伪元素规则且与出厂胶囊对决——无 !important 背景 / 无提权链则提示
1594
- const consumerRules = designRules.filter((r) =>
1595
- Object.values(r.decls).some((v) => String(v).includes(`var(${indicatorVar}`)),
1596
- );
1597
- for (const r of consumerRules) {
1598
- const isPseudo = /::(before|after)/.test(r.selector);
1599
- const bgImportant = /important/i.test(String(r.decls["background"] || r.decls["background-color"] || ""));
1600
- const elevated = /(^|\s)html\b[\s>]*body\b/.test(r.selector) || /body\s+\./.test(r.selector);
1601
- if (isPseudo && !bgImportant && !elevated) {
1602
- warn(
1603
- "E18",
1604
- `激活态消费者成对落地:${r.selector} 消费 ${indicatorVar} 但既无 !important 背景也无 html body 提权链——` +
1605
- `出厂 sidebar.scss 的同名 ::before 胶囊(!important 背景)对决大概率获胜,指示条几何可能整体不渲染。` +
1606
- `修复:背景加 !important 或用 html body 前缀提权(词典 priority.strategy=deep_qualified)。`,
1607
- );
1608
- }
1609
- }
1610
- }
1611
- const checkedAny = marked.some(({ sid }) => (changeset?.changes || []).some((c) => c.id === sid));
1612
- if (checkedAny && !errors.some((e) => e.id === "E18") && !warnings.some((w) => w.id === "E18")) {
1613
- info("E18", "激活态消费者成对落地:消费者存在、令牌非透明、对决权重足够 ✓");
1614
- }
1615
- })();
1616
-
1617
- const result = { project: PROJECT, errors, warnings, infos };
1618
-
1619
- const CHECK_TITLES = {
1620
- E1: "变量悬空",
1621
- E2: "层错位",
1622
- E3: "层叠败北",
1623
- E4: "选择器挂空",
1624
- E5: "列宽预算",
1625
- E6: "visible:false 收口断言",
1626
- E7: "激活态越层",
1627
- E8: "浮层残留",
1628
- E9: "形态收口缺件",
1629
- E10: "scoped 规则挂空",
1630
- E11: "交付完整性",
1631
- E12: "设计层累积性",
1632
- E13: "固定尺寸内容度量",
1633
- E14: "列宽预算链自洽",
1634
- E16: "引用完整性",
1635
- E17: "层间令牌链对账",
1636
- E18: "激活态消费者成对落地",
1637
- SID: "@sid 覆盖率",
1638
- };
1639
-
1640
- const REPORT_PATH = argValue("--report", null);
1641
- if (REPORT_PATH) {
1642
- const rel = (p) => path.relative(PROJECT, p).replace(/\\/g, "/");
1643
- const rows = Object.keys(CHECK_TITLES)
1644
- .map((id) => {
1645
- const e = errors.filter((x) => x.id === id).length;
1646
- const w = warnings.filter((x) => x.id === id).length;
1647
- return { id, title: CHECK_TITLES[id], e, w };
1648
- })
1649
- .filter((r) => r.e || r.w);
1650
-
1651
- const lines = [
1652
- "# 设计层闭环审计报告",
1653
- "",
1654
- `- 项目:\`${PROJECT}\``,
1655
- `- 设计层文件:${DESIGN_FILES.map(rel).join("、") || "(未发现)"}`,
1656
- `- 变更集:${changesetFile ? rel(changesetFile) : "(未发现)"}`,
1657
- `- 词典条目:${dictEntries.length} | 设计层规则:${designRules.length} | 容器可用宽:${CONTAINER_WIDTH}px${CONTAINER_EXPLICIT ? "" : "(估值,用 --container-width 校准)"}`,
1658
- `- 结论:**ERROR ${errors.length} | WARN ${warnings.length}**`,
1659
- "",
1660
- "## 命中分布",
1661
- "",
1662
- "| 检查项 | 含义 | ERROR | WARN |",
1663
- "|---|---|---|---|",
1664
- ...rows.map((r) => `| ${r.id} | ${r.title} | ${r.e} | ${r.w} |`),
1665
- "",
1666
- "## 待修项(ERROR)",
1667
- "",
1668
- ...(errors.length ? errors.map((e, i) => `${i + 1}. **[${e.id}] ${CHECK_TITLES[e.id] || ""}** — ${e.msg}`) : ["(无)"]),
1669
- "",
1670
- "## 提示项(WARN)",
1671
- "",
1672
- ...(warnings.length ? warnings.map((w, i) => `${i + 1}. **[${w.id}] ${CHECK_TITLES[w.id] || ""}** — ${w.msg}`) : ["(无)"]),
1673
- "",
1674
- "## 已通过项",
1675
- "",
1676
- ...(infos.length ? infos.map((i) => `- [${i.id}] ${i.msg}`) : ["(无)"]),
1677
- "",
1678
- `> 复跑:\`node scripts/design-audit.mjs --project "${PROJECT}" --container-width ${CONTAINER_WIDTH}\``,
1679
- "",
1680
- ];
1681
- const out = path.resolve(REPORT_PATH);
1682
- fs.mkdirSync(path.dirname(out), { recursive: true });
1683
- fs.writeFileSync(out, lines.join("\n"), "utf-8");
1684
- console.log(`\n审计报告已写入:${out}`);
1685
- }
1686
-
1687
- if (AS_JSON) {
1688
- console.log(JSON.stringify(result, null, 2));
1689
- } else {
1690
- console.log("\n设计层闭环审计\n");
1691
- console.log(`项目: ${PROJECT}`);
1692
- console.log(`设计层文件: ${DESIGN_FILES.map((f) => path.relative(PROJECT, f).replace(/\\/g, "/")).join(", ") || "(未发现)"}`);
1693
- console.log(`变更集: ${changesetFile ? path.relative(PROJECT, changesetFile) : "(未发现)"}`);
1694
- console.log(`词典条目: ${dictEntries.length} | 设计层规则: ${designRules.length}\n`);
1695
- for (const i of infos) console.log(` · [${i.id}] ${i.msg}`);
1696
- for (const w of warnings) console.log(` ! [${w.id}] ${w.msg}`);
1697
- for (const e of errors) console.log(` \u2717 [${e.id}] ${e.msg}`);
1698
- console.log(`\n${"=".repeat(50)}`);
1699
- console.log(`闭环审计:ERROR ${errors.length} | WARN ${warnings.length}`);
1700
- if (errors.length) {
1701
- console.log("\n\u2717 设计层存在「声明了却不会生效 / 形态未落地」的问题,修复后重跑。\n");
1702
- } else {
1703
- console.log("\n\u2713 设计层闭环审计通过(样式声明均已具备生效条件)。\n");
1704
- }
1705
- }
1706
-
1707
- process.exit(errors.length ? 1 : 0);