@qilitt-mickey/vue3-temp-skill 1.1.62 → 1.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,878 @@
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
+ */
31
+
32
+ import fs from "fs";
33
+ import path from "path";
34
+ import { fileURLToPath } from "url";
35
+
36
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
+ const SKILL_ROOT = path.resolve(__dirname, "..");
38
+
39
+ const argv = process.argv.slice(2);
40
+ const argValue = (name, def) => {
41
+ const i = argv.indexOf(name);
42
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
43
+ };
44
+ const PROJECT = path.resolve(argValue("--project", process.cwd()));
45
+ const AS_JSON = argv.includes("--json");
46
+ const CONTAINER_EXPLICIT = argv.includes("--container-width");
47
+ const CONTAINER_WIDTH = Number(argValue("--container-width", "1200")) || 1200;
48
+
49
+ const DESIGN_LAYER_GLOBS = [
50
+ "src/styles/design-shell.scss",
51
+ "src/styles/design-tokens.scss",
52
+ "src/styles/dark.scss",
53
+ ];
54
+ const SRC_DIRS = ["src"];
55
+ const IGNORES = new Set(["node_modules", ".git", "dist", "build", ".vite", "public"]);
56
+ const SRC_EXT = new Set([".vue", ".ts", ".tsx", ".js", ".mjs", ".scss", ".css", ".html"]);
57
+ const JSON_EXT = new Set([".json"]);
58
+
59
+ /** 内层盒属性族:必须落 EP 内层 .cell,落宿主 th/td 会被 .cell 覆盖 */
60
+ const INNER_ONLY_PROPS = new Set([
61
+ "white-space",
62
+ "text-overflow",
63
+ "overflow",
64
+ "word-break",
65
+ "text-align",
66
+ "line-height",
67
+ "vertical-align",
68
+ "text-align-last",
69
+ ]);
70
+
71
+ /** 项目自有变量前缀(--el-* 为出厂,不参与悬空判定) */
72
+ const OWNED_VAR_PREFIXES = ["--ds-", "--vts-", "--re-", "--brand-", "--table-", "--pagination-", "--filter-", "--page-", "--action-bar-", "--empty-", "--menu-", "--sidebar-"];
73
+
74
+ /** 项目自有类名前缀(用于选择器挂空判定) */
75
+ const OWNED_CLASS_PREFIXES = ["vts-", "re-", "col-", "navbar-", "header-", "sider-", "sidebar-", "menu-", "card-tab", "tab-", "language-", "app-", "list-"];
76
+
77
+ const errors = [];
78
+ const warnings = [];
79
+ const infos = [];
80
+
81
+ const err = (id, msg) => errors.push({ id, msg });
82
+ const warn = (id, msg) => warnings.push({ id, msg });
83
+ const info = (id, msg) => infos.push({ id, msg });
84
+
85
+ // ── 文件工具 ────────────────────────────────────────────────
86
+ function walk(dir, out = [], exts = SRC_EXT) {
87
+ if (!fs.existsSync(dir)) return out;
88
+ for (const name of fs.readdirSync(dir)) {
89
+ if (IGNORES.has(name)) continue;
90
+ const full = path.join(dir, name);
91
+ let st;
92
+ try {
93
+ st = fs.statSync(full);
94
+ } catch {
95
+ continue;
96
+ }
97
+ if (st.isDirectory()) walk(full, out, exts);
98
+ else if (exts.has(path.extname(name))) out.push(full);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ const readIf = (p) => (fs.existsSync(p) ? fs.readFileSync(p, "utf-8") : null);
104
+
105
+ // ── 迷你 CSS/SCSS 展平器 ────────────────────────────────────
106
+ function stripComments(css) {
107
+ return css
108
+ .replace(/\/\*[\s\S]*?\*\//g, "")
109
+ .replace(/(^|[\s;}])[/]{2}[^\n]*/g, "$1");
110
+ }
111
+
112
+ function addDecls(decls, chunk) {
113
+ const text = String(chunk);
114
+ if (!text.trim()) return;
115
+ for (const piece of text.split(";")) {
116
+ const t = piece.trim();
117
+ if (!t || t.startsWith("$") || t.startsWith("@")) continue;
118
+ const idx = t.indexOf(":");
119
+ if (idx <= 0) continue;
120
+ const prop = t.slice(0, idx).trim().toLowerCase();
121
+ const value = t.slice(idx + 1).trim();
122
+ if (!prop || /[{}]/.test(prop)) continue;
123
+ decls[prop] = value;
124
+ }
125
+ }
126
+
127
+ const splitList = (s) => String(s).split(",").map((x) => x.trim()).filter(Boolean);
128
+
129
+ function joinSelector(parent, child) {
130
+ const parts = splitList(child);
131
+ if (!parent) return parts.join(", ");
132
+ const parents = splitList(parent);
133
+ const out = [];
134
+ for (const part of parts) {
135
+ if (part.includes("&")) {
136
+ for (const p of parents) out.push(part.replace(/&/g, p));
137
+ } else {
138
+ for (const p of parents) out.push(`${p} ${part}`);
139
+ }
140
+ }
141
+ return out.join(", ");
142
+ }
143
+
144
+ /**
145
+ * 把一段 SCSS 正文拆成「本层声明」与「子块」。
146
+ * 关键:声明与子块是交错的,必须逐字符按 `;` / `{}` 切分,
147
+ * 否则「先声明后嵌套」的规则会把声明粘到下一个子选择器上(错位出幽灵规则)。
148
+ */
149
+ function parseRule(body) {
150
+ const blocks = [];
151
+ let declText = "";
152
+ let buf = "";
153
+ let i = 0;
154
+ while (i < body.length) {
155
+ const ch = body[i];
156
+ if (ch === '"' || ch === "'") {
157
+ let j = i + 1;
158
+ while (j < body.length && body[j] !== ch) j += body[j] === "\\" ? 2 : 1;
159
+ buf += body.slice(i, j + 1);
160
+ i = j + 1;
161
+ } else if (ch === "{" && buf.endsWith("#")) {
162
+ buf += ch; // SCSS 插值 #{},不是块
163
+ i++;
164
+ } else if (ch === "{") {
165
+ const sel = buf.trim();
166
+ buf = "";
167
+ let depth = 1;
168
+ let j = i + 1;
169
+ let inner = "";
170
+ while (j < body.length && depth > 0) {
171
+ const c = body[j];
172
+ if (c === "{") depth++;
173
+ else if (c === "}") {
174
+ depth--;
175
+ if (depth === 0) break;
176
+ }
177
+ inner += c;
178
+ j++;
179
+ }
180
+ if (sel) blocks.push({ sel, body: inner });
181
+ i = j + 1;
182
+ } else if (ch === ";") {
183
+ declText += buf + ";";
184
+ buf = "";
185
+ i++;
186
+ } else if (ch === "}") {
187
+ i++;
188
+ } else {
189
+ buf += ch;
190
+ i++;
191
+ }
192
+ }
193
+ declText += buf;
194
+ return { declText, blocks };
195
+ }
196
+
197
+ const AT_SKIP = /^@(keyframes|-webkit-keyframes|-moz-keyframes|-o-keyframes|font-face|import|use|forward|charset|tailwind|apply|extend)/i;
198
+
199
+ function flattenCss(css, parentSel = "", acc = []) {
200
+ const { declText, blocks } = parseRule(stripComments(css));
201
+ if (parentSel && declText.trim()) {
202
+ const decls = {};
203
+ addDecls(decls, declText);
204
+ if (Object.keys(decls).length) acc.push({ selector: parentSel, decls });
205
+ }
206
+ for (const b of blocks) {
207
+ const sel = b.sel.trim();
208
+ if (!sel || AT_SKIP.test(sel)) continue;
209
+ if (sel.startsWith("@")) {
210
+ flattenCss(b.body, parentSel, acc);
211
+ continue;
212
+ }
213
+ flattenCss(b.body, joinSelector(parentSel, sel), acc);
214
+ }
215
+ return acc;
216
+ }
217
+
218
+ const classTokens = (selector) => [...selector.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((m) => m[1]);
219
+ /** 去掉伪元素后的「末位复合选择器」——它才是这条规则真正作用的那一层元素 */
220
+ const lastCompound = (sel) => {
221
+ const parts = String(sel)
222
+ .replace(/::[a-z-]+(\([^)]*\))?/gi, "")
223
+ .split(/[\s>+~]+/)
224
+ .filter(Boolean);
225
+ return parts[parts.length - 1] || "";
226
+ };
227
+ const allVars = (text) => [...text.matchAll(/var\(\s*(--[\w-]+)\s*(,)?/g)].map((m) => ({ name: m[1], hasFallback: !!m[2] }));
228
+
229
+ // ── 采集:设计层 / 项目源 / 变更集 / 词典 ────────────────────
230
+ const designFiles = DESIGN_LAYER_GLOBS.map((g) => path.join(PROJECT, g)).filter((p) => fs.existsSync(p));
231
+ const extraDesign = walk(path.join(PROJECT, "src", "styles")).filter((p) => /design-.*\.(scss|css)$/i.test(path.basename(p)));
232
+ const DESIGN_FILES = [...new Set([...designFiles, ...extraDesign])];
233
+
234
+ const srcFiles = SRC_DIRS.flatMap((d) => walk(path.join(PROJECT, d)));
235
+ const srcTextByFile = new Map();
236
+ for (const f of srcFiles) srcTextByFile.set(f, readIf(f) ?? "");
237
+
238
+ const designSet = new Set(DESIGN_FILES.map((p) => path.resolve(p)));
239
+ const projectText = [...srcTextByFile.entries()]
240
+ .filter(([f]) => !designSet.has(path.resolve(f)))
241
+ .map(([, t]) => t)
242
+ .join("\n");
243
+
244
+ const designCss = DESIGN_FILES.map((f) => srcTextByFile.get(f) ?? readIf(f) ?? "").join("\n");
245
+ // 每条规则带上来源文件:用于区分「本次落地的设计层」与「历史遗留层」的告警级别
246
+ const designRules = DESIGN_FILES.flatMap((f) =>
247
+ flattenCss(srcTextByFile.get(f) ?? readIf(f) ?? "").map((r) => ({ ...r, file: f })),
248
+ );
249
+ const PRIMARY_LAYER = path.resolve(PROJECT, argValue("--design-layer", "src/styles/design-shell.scss"));
250
+ const isPrimary = (file) => path.resolve(file) === PRIMARY_LAYER;
251
+
252
+ // 变更集(取 .codebuddy/design 下第一个 changeset-*.json)
253
+ const designDir = path.join(PROJECT, ".codebuddy", "design");
254
+ let changeset = null;
255
+ let changesetFile = null;
256
+ if (fs.existsSync(designDir)) {
257
+ const cand = fs.readdirSync(designDir).filter((f) => /^changeset.*\.json$/i.test(f)).sort();
258
+ if (cand.length) {
259
+ changesetFile = path.join(designDir, cand[0]);
260
+ try {
261
+ changeset = JSON.parse(fs.readFileSync(changesetFile, "utf-8"));
262
+ } catch (e) {
263
+ err("E0", `变更集解析失败:${changesetFile} — ${e.message}`);
264
+ }
265
+ }
266
+ }
267
+
268
+ // 词典(技能包自带 mapping/)
269
+ const dictSidMap = new Map();
270
+ const dictEntries = [];
271
+ (function loadMapping() {
272
+ const roots = ["mapping/scenes", "mapping/component", "mapping/layout", "mapping/basic"].map((r) => path.join(SKILL_ROOT, r));
273
+ const files = roots.flatMap((r) => walk(r, [], JSON_EXT));
274
+ for (const f of files) {
275
+ try {
276
+ const json = JSON.parse(fs.readFileSync(f, "utf-8"));
277
+ for (const [sid, entry] of Object.entries(json.mappings || {})) {
278
+ if (!entry || typeof entry !== "object") continue;
279
+ dictSidMap.set(sid, { ...entry, __file: f });
280
+ dictEntries.push({ sid, entry });
281
+ }
282
+ } catch {
283
+ /* 忽略无法解析的映射文件 */
284
+ }
285
+ }
286
+ })();
287
+
288
+ // ── E1 变量悬空 ─────────────────────────────────────────────
289
+ (function checkE1() {
290
+ if (!designCss) return;
291
+ const defined = new Set();
292
+ for (const t of [designCss, projectText].filter(Boolean)) {
293
+ for (const m of t.matchAll(/(--[\w-]+)\s*:/g)) defined.add(m[1]);
294
+ }
295
+ if (changeset) {
296
+ // 令牌名可能只在变更集里出现(由项目侧再映射),一并视为已声明
297
+ for (const m of JSON.stringify(changeset).matchAll(/--[\w-]+/g)) defined.add(m[0]);
298
+ }
299
+ const missing = new Map();
300
+ for (const { name, hasFallback } of allVars(designCss)) {
301
+ if (hasFallback || defined.has(name)) continue;
302
+ if (!OWNED_VAR_PREFIXES.some((p) => name.startsWith(p))) continue;
303
+ missing.set(name, (missing.get(name) || 0) + 1);
304
+ }
305
+ for (const [name, count] of missing) {
306
+ err("E1", `变量悬空:设计层使用了 ${name}(${count} 处),但项目内无定义、也无 var() 回退 —— 该声明整条失效。`);
307
+ }
308
+ if (!missing.size) info("E1", `变量悬空:0 处(受检前缀 ${OWNED_VAR_PREFIXES.join(" ")})`);
309
+ })();
310
+
311
+ // ── E2 层错位 ───────────────────────────────────────────────
312
+ (function checkE2() {
313
+ const hits = [];
314
+ for (const rule of designRules) {
315
+ const sel = rule.selector;
316
+ if (!/el-table|(^|[\s,>])(th|td)(\.|[\s,:{]|$)/.test(sel)) continue;
317
+ if (/\.cell\b/.test(sel)) continue; // 已锚定内层文本盒
318
+ const offenders = Object.keys(rule.decls).filter((p) => INNER_ONLY_PROPS.has(p));
319
+ if (offenders.length) hits.push({ sel, offenders });
320
+ }
321
+ for (const h of hits.slice(0, 12)) {
322
+ err("E2", `层错位:${h.sel} 声明了内层盒属性族 [${h.offenders.join(", ")}],但未锚定 > .cell —— 会被 Element Plus 内层 .cell 覆盖,等于没写。`);
323
+ }
324
+ if (hits.length > 12) err("E2", `层错位:另有 ${hits.length - 12} 处未列出。`);
325
+ if (!hits.length) info("E2", "层错位:0 处");
326
+ })();
327
+
328
+ // ── E3 层叠胜负 / E10 scoped 挂空 ───────────────────────────
329
+ // 组件 scoped 规则编译后末位选择器会追加 [data-v-xxx] 属性选择器,等价 +1 个类级特指度。
330
+ // 因此「设计层声明是否真的生效」必须算特指度,不能只看有没有同名类。
331
+ const SCOPED_ATTR = 1000;
332
+ const STATE_PSEUDO = /:(hover|focus|active|visited|disabled|checked|focus-within|focus-visible|first-child|last-child|nth-[\w-]+)/i;
333
+
334
+ const pseudoElOf = (sel) => (sel.match(/::([a-z-]+)/i) || [])[1] || "";
335
+
336
+ function specOf(selector) {
337
+ const s = selector
338
+ .replace(/::[a-z-]+(\([^)]*\))?/gi, " ")
339
+ .replace(/:not\(([^)]*)\)/g, ":not");
340
+ const ids = (s.match(/#[\w-]+/g) || []).length;
341
+ const classes =
342
+ (s.match(/\.[\w-]+/g) || []).length +
343
+ (s.match(/\[[^\]]*\]/g) || []).length +
344
+ (s.match(/:(?!:)[\w-]+(\([^)]*\))?/g) || []).length;
345
+ const elems = (s.match(/(?:^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
346
+ return ids * 1e6 + classes * 1e3 + elems;
347
+ }
348
+
349
+ /** 状态类 / 第三方类不属于「项目自有挂载点」,挂空判定不参与 */
350
+ const isOwnedClass = (c) =>
351
+ OWNED_CLASS_PREFIXES.some((p) => c.startsWith(p)) && !/^(is|has|van|el|n)-/.test(c);
352
+ const IGNORE_DEAD = new Set(["language-text", "language-text__item"]);
353
+
354
+ // 模板/脚本源码(剔掉 style 块,避免样式里的类名自证「已挂载」)
355
+ const markupText = [...srcTextByFile.entries()]
356
+ .filter(([f]) => f.endsWith(".vue"))
357
+ .map(([, t]) => t.replace(/<style[\s\S]*?<\/style>/g, ""))
358
+ .join("\n");
359
+
360
+ // 收集组件 scoped 规则,并标记「挂空」的(选择器链条上存在运行时不存在的类)
361
+ const scopedRules = [];
362
+ const scopedDead = [];
363
+ for (const [f, t] of srcTextByFile) {
364
+ if (!f.endsWith(".vue")) continue;
365
+ for (const m of t.matchAll(/<style[^>]*scoped[^>]*>([\s\S]*?)<\/style>/g)) {
366
+ for (const r of flattenCss(m[1])) {
367
+ const cls = [...new Set(classTokens(r.selector))];
368
+ if (!cls.length) continue;
369
+ if (/:deep\(|::v-deep|:global\(/.test(r.selector)) continue;
370
+ const owned = cls.filter(isOwnedClass);
371
+ const missing = owned.filter((c) => !markupText.includes(c));
372
+ const dead = missing.length > 0;
373
+ scopedRules.push({ ...r, file: f, dead });
374
+ if (dead) scopedDead.push({ sel: r.selector, file: f, missing });
375
+ }
376
+ }
377
+ }
378
+
379
+ // 竞争者索引:只登记「与目标同一元素(同末位复合选择器)」且非挂空的规则
380
+ const scopedIdx = new Map();
381
+ for (const r of scopedRules) {
382
+ if (r.dead) continue; // 挂空的规则永不命中,不能算作竞争者
383
+ if (STATE_PSEUDO.test(r.selector)) continue; // 状态态不参与基态竞争
384
+ const pe = pseudoElOf(r.selector);
385
+ const spec = specOf(r.selector) + SCOPED_ATTR;
386
+ for (const cls of new Set(classTokens(lastCompound(r.selector)))) {
387
+ for (const prop of Object.keys(r.decls)) {
388
+ const key = `${cls}|${prop}|${pe}`;
389
+ const cur = scopedIdx.get(key);
390
+ if (!cur || spec > cur.spec) scopedIdx.set(key, { spec, selector: r.selector, file: r.file });
391
+ }
392
+ }
393
+ }
394
+
395
+ (function checkE3() {
396
+ if (!scopedIdx.size) {
397
+ info("E3", "层叠胜负:无同属性竞争的组件 scoped 规则");
398
+ return;
399
+ }
400
+ const hits = [];
401
+ const seen = new Set();
402
+ for (const r of designRules) {
403
+ const pe = pseudoElOf(r.selector);
404
+ for (const part of splitList(r.selector)) {
405
+ const last = lastCompound(part);
406
+ const clsList = [...new Set(classTokens(last))];
407
+ if (!clsList.length) continue;
408
+ const mine = specOf(part);
409
+ for (const prop of Object.keys(r.decls)) {
410
+ for (const cls of clsList) {
411
+ const comp = scopedIdx.get(`${cls}|${prop}|${pe}`);
412
+ if (!comp || mine > comp.spec) continue;
413
+ const key = `${part}|${prop}`;
414
+ if (seen.has(key)) continue;
415
+ seen.add(key);
416
+ hits.push({ sel: part, prop, cls, mine, comp });
417
+ }
418
+ }
419
+ }
420
+ }
421
+ const shown = hits.slice(0, 12);
422
+ for (const h of shown) {
423
+ const rel = path.relative(PROJECT, h.comp.file).replace(/\\/g, "/");
424
+ err(
425
+ "E3",
426
+ `层叠败北:${h.sel} 的 ${h.prop} 特指度 ${h.mine} ≤ 组件 scoped 竞争者 ${h.comp.selector}(${rel})的 ${h.comp.spec}(scoped 追加 [data-v] 属性选择器,等价 +1 类级)—— 该声明不会生效:收口类提权加 !important,形态类须提高锚定层级(补上 .is-active / .card-item 等对方使用的限定类)。`,
427
+ );
428
+ }
429
+ if (hits.length > shown.length) {
430
+ const byFile = new Map();
431
+ for (const h of hits.slice(shown.length)) {
432
+ const rel = path.relative(PROJECT, h.comp.file).replace(/\\/g, "/");
433
+ byFile.set(rel, (byFile.get(rel) || 0) + 1);
434
+ }
435
+ const detail = [...byFile.entries()].sort((a, b) => b[1] - a[1]).map(([f, n]) => `${f}(${n})`).join("、");
436
+ err("E3", `层叠败北:另有 ${hits.length - shown.length} 处未列出,集中在 ${detail} —— 同一文件成片败北说明该区域的锚定层级整体偏低,应按文件批量提权。`);
437
+ }
438
+ if (!hits.length) info("E3", `层叠胜负:0 处败北(竞争者 ${scopedIdx.size} 组)`);
439
+ })();
440
+
441
+ // ── E10 组件 scoped 规则挂空(重构残留) ────────────────────
442
+ (function checkE10() {
443
+ const dead = scopedDead.filter((d) => d.missing.some((c) => !IGNORE_DEAD.has(c)));
444
+ if (!dead.length) {
445
+ info("E10", "组件 scoped 挂空:0 处");
446
+ return;
447
+ }
448
+ for (const d of dead.slice(0, 15)) {
449
+ const rel = path.relative(PROJECT, d.file).replace(/\\/g, "/");
450
+ warn(
451
+ "E10",
452
+ `scoped 规则挂空:${rel} 的 ${d.sel} 依赖运行时并不存在的类 [${d.missing.join(", ")}] —— 整条规则永不命中(多在结构重构后残留),既不会生效也会误导后续收口判断。`,
453
+ );
454
+ }
455
+ if (dead.length > 15) warn("E10", `scoped 规则挂空:另有 ${dead.length - 15} 处未列出。`);
456
+ })();
457
+
458
+ // ── E4 选择器挂空 ───────────────────────────────────────────
459
+ (function checkE4() {
460
+ const used = new Map(); // 类名 → 声明它的设计层文件集合
461
+ for (const rule of designRules) {
462
+ for (const c of classTokens(rule.selector)) {
463
+ if (!OWNED_CLASS_PREFIXES.some((p) => c.startsWith(p))) continue;
464
+ if (!used.has(c)) used.set(c, new Set());
465
+ used.get(c).add(rule.file);
466
+ }
467
+ }
468
+ const phantom = [...used.keys()].filter((c) => !projectText.includes(c));
469
+ const hard = phantom.filter((c) => [...used.get(c)].some(isPrimary));
470
+ const soft = phantom.filter((c) => !hard.includes(c));
471
+ for (const c of hard.slice(0, 15)) {
472
+ err("E4", `选择器挂空:设计层选择器使用了 .${c},但 src/ 全量源码里没有该类的挂载点 —— 规则命中不到任何元素(锚点必须锚「实际在」,不能锚「应该在」)。`);
473
+ }
474
+ if (hard.length > 15) err("E4", `选择器挂空:另有 ${hard.length - 15} 个未列出。`);
475
+ for (const c of soft.slice(0, 10)) {
476
+ const files = [...used.get(c)].map((f) => path.basename(f)).join(", ");
477
+ warn("E4", `选择器挂空(历史层):.${c} 只出现在 ${files},项目内无挂载点 —— 静态遗留,非本次落地引入,但同样永不命中。`);
478
+ }
479
+ if (soft.length > 10) warn("E4", `选择器挂空(历史层):另有 ${soft.length - 10} 个未列出。`);
480
+ if (!phantom.length) info("E4", `选择器挂空:0 个(受检类名 ${used.size} 个)`);
481
+ })();
482
+
483
+ // ── E5 列宽预算 ─────────────────────────────────────────────
484
+ function extractObjectLiterals(arrSrc) {
485
+ const out = [];
486
+ let depth = 0;
487
+ let start = -1;
488
+ for (let i = 0; i < arrSrc.length; i++) {
489
+ const ch = arrSrc[i];
490
+ if (ch === "{") {
491
+ if (depth === 0) start = i;
492
+ depth++;
493
+ } else if (ch === "}") {
494
+ depth--;
495
+ if (depth === 0 && start >= 0) {
496
+ out.push(arrSrc.slice(start, i + 1));
497
+ start = -1;
498
+ }
499
+ }
500
+ }
501
+ return out;
502
+ }
503
+
504
+ function matchArrayLiteral(text, anchorRe) {
505
+ const m = anchorRe.exec(text);
506
+ if (!m) return null;
507
+ const open = text.indexOf("[", m.index);
508
+ if (open < 0) return null;
509
+ let depth = 0;
510
+ for (let i = open; i < text.length; i++) {
511
+ if (text[i] === "[") depth++;
512
+ else if (text[i] === "]") {
513
+ depth--;
514
+ if (depth === 0) return text.slice(open + 1, i);
515
+ }
516
+ }
517
+ return null;
518
+ }
519
+
520
+ (function checkE5() {
521
+ const tableFiles = [...srcTextByFile.entries()].filter(([, t]) => t.includes("el-table-column"));
522
+ if (!tableFiles.length) {
523
+ info("E5", "列宽预算:未在 src 中发现 el-table-column");
524
+ return;
525
+ }
526
+ const pxOf = (s) => {
527
+ const m = String(s).match(/(\d+(?:\.\d+)?)\s*(px)?/);
528
+ return m ? Number(m[1]) : null;
529
+ };
530
+ // 表头文案最小占宽:CJK ≈ 1em,ASCII ≈ 0.6em;两侧各留 16px 宿主内边距
531
+ const HEADER_FS = 14;
532
+ const CELL_PAD = 16;
533
+ const textWidth = (label) => {
534
+ let w = 0;
535
+ for (const ch of String(label)) w += /[\u2e80-\u9fff\uff00-\uffef]/.test(ch) ? HEADER_FS : HEADER_FS * 0.6;
536
+ return Math.ceil(w);
537
+ };
538
+ const minFit = (label) => textWidth(label) + CELL_PAD * 2;
539
+ // 列名可能是 $t('类型') 这类 i18n 调用,需取出引号内的真实文案
540
+ const cleanLabel = (raw) => {
541
+ const s = String(raw).trim();
542
+ const q = s.match(/[`'"]([^`'"]+)[`'"]/);
543
+ return ((q ? q[1] : s).trim() || "?");
544
+ };
545
+ const labelOf = (src) => {
546
+ const m =
547
+ src.match(/label:\s*([^,\n}]+)/) || src.match(/\blabel="([^"]*)"/) || src.match(/\blabel='([^']*)'/);
548
+ return m ? cleanLabel(m[1]) : "?";
549
+ };
550
+ const OPS = ["operate", "operation", "actions"];
551
+
552
+ for (const [file, text] of tableFiles) {
553
+ const rel = path.relative(PROJECT, file).replace(/\\/g, "/");
554
+ const declared = [];
555
+ const noWidth = [];
556
+
557
+ // 1) 列定义数组(模板常用动态列)
558
+ const arr = matchArrayLiteral(text, /(?:const|let|var)\s+\w*[Cc]olumn\w*\s*(?::[^=]+)?=\s*\[/);
559
+ if (arr) {
560
+ for (const objSrc of extractObjectLiterals(arr)) {
561
+ const label = labelOf(objSrc);
562
+ const prop = (objSrc.match(/prop:\s*[`'"]([^`'"]+)/) || [])[1] || "?";
563
+ const w = objSrc.match(/\bwidth:\s*[`'"]?([\d.]+)/);
564
+ const mw = objSrc.match(/\bminWidth:\s*[`'"]?([\d.]+)/);
565
+ if (w) declared.push({ label, prop, w: pxOf(w[1]) });
566
+ else if (mw) declared.push({ label, prop, mw: pxOf(mw[1]) });
567
+ else noWidth.push({ label, prop });
568
+ }
569
+ }
570
+
571
+ // 2) 内联列(selection / index / 手工列)
572
+ for (const tag of text.matchAll(/<el-table-column\b[\s\S]*?(?:\/>|>)/g)) {
573
+ const src = tag[0];
574
+ const type = (src.match(/:?type="['"]([^'"]+)/) || [])[1] || "";
575
+ const label = (src.match(/:?label="([^"]*)"/) || src.match(/:?label='([^']*)'/) || [])[1];
576
+ const labelText = label ? cleanLabel(label) : type || "?";
577
+ const hasW = /(^|\s):?width=/.test(src) || /\bwidth=/.test(src);
578
+ const hasMw = /:?min-width=/.test(src) || /:?minWidth=/.test(src);
579
+ if (hasW || hasMw) continue;
580
+ if (type === "selection" || type === "index") {
581
+ warn("E5", `${rel}:${type === "selection" ? "选择列" : "序号列"}(${labelText})未声明 width / min-width,只能按出厂兜底宽度参与布局(§11.132 要求逐列声明意图)。`);
582
+ } else if (type) {
583
+ noWidth.push({ label: labelText, prop: type });
584
+ }
585
+ }
586
+
587
+ const realNoWidth = noWidth.filter((c) => !OPS.includes(c.prop));
588
+ for (const c of realNoWidth) {
589
+ err(
590
+ "E5",
591
+ `${rel}:列「${c.label}」(prop=${c.prop}) 既无 width 也无 min-width —— 无宽列由出厂兜底宽度参与布局,会被相邻固定宽列挤压;本列表头文案需 ≥ ${minFit(c.label)}px(文案 ${textWidth(c.label)}px + 内边距 ${CELL_PAD * 2}px),请按此反推 width / min-width(换行与省略号截断都源于此)。`,
592
+ );
593
+ }
594
+
595
+ // 3) 已声明 min-width 的列:min-width 必须容得下自己的表头文案
596
+ for (const c of declared) {
597
+ if (c.mw == null) continue;
598
+ const need = minFit(c.label);
599
+ if (c.mw < need) {
600
+ err(
601
+ "E5",
602
+ `${rel}:列「${c.label}」min-width=${c.mw}px < 表头文案最小占宽 ${need}px —— 布局算法压缩到 min-width 时表头必然换行或截断(「${c.label}」→「${c.label.slice(0, 2)}…」)。`,
603
+ );
604
+ }
605
+ }
606
+
607
+ const total = declared.reduce((s, c) => s + (c.w ?? 0), 0);
608
+ const minTotal = declared.reduce((s, c) => s + (c.mw ?? 0), 0);
609
+ const fixedCount = declared.filter((c) => c.w).length;
610
+ const flexCount = declared.filter((c) => c.mw).length;
611
+ const budget = total + minTotal;
612
+ const noWidthNeed = realNoWidth.reduce((s, c) => s + minFit(c.label), 0);
613
+ const line = `${rel}:固定宽 ${fixedCount} 列(${total}px) / 伸缩列 ${flexCount} 列(${minTotal}px) / 无宽列 ${realNoWidth.length} 列(需 ${noWidthNeed}px)`;
614
+ if (CONTAINER_EXPLICIT && budget + noWidthNeed > CONTAINER_WIDTH) {
615
+ err(
616
+ "E5",
617
+ `${line} —— 合计 ${budget + noWidthNeed}px > 容器可用宽 ${CONTAINER_WIDTH}px:列宽预算溢出,必须在设计侧收敛(减少列 / 降 min-width / 显式声明横向滚动),不允许靠静默压缩消化。`,
618
+ );
619
+ } else if (!CONTAINER_EXPLICIT && budget + noWidthNeed > CONTAINER_WIDTH) {
620
+ warn(
621
+ "E5",
622
+ `${line} —— 合计 ${budget + noWidthNeed}px 超出假定容器宽 ${CONTAINER_WIDTH}px。容器宽为估值,请用 --container-width <px> 传入实际内容区宽度后复跑,据以判定是否溢出。`,
623
+ );
624
+ } else {
625
+ info("E5", `${line},合计 ${budget + noWidthNeed}px ≤ ${CONTAINER_WIDTH}px`);
626
+ }
627
+ }
628
+ })();
629
+
630
+ // ── E6 visible:false 收口断言 ──────────────────────────────
631
+ (function checkE6() {
632
+ if (!changeset) {
633
+ info("E6", "未发现变更集(.codebuddy/design/changeset*.json),跳过收口断言");
634
+ return;
635
+ }
636
+ const changes = changeset.changes || [];
637
+ const closable = changes.filter((c) => c.visible === false || c.style?.visible === false);
638
+ if (!closable.length) {
639
+ info("E6", "收口断言:变更集无 visible:false 项");
640
+ return;
641
+ }
642
+ for (const change of closable) {
643
+ const entry = dictSidMap.get(change.id);
644
+ const selector = entry?.selector;
645
+ if (!selector) {
646
+ warn("E6", `${change.id} 声明 visible:false,但词典无 selector,无法断言收口落点。`);
647
+ continue;
648
+ }
649
+ const target = classTokens(selector).slice(-1)[0];
650
+ if (!target) {
651
+ warn("E6", `${change.id} 的词典 selector「${selector}」不含类名,无法断言收口落点。`);
652
+ continue;
653
+ }
654
+ const rule = designRules.find(
655
+ (r) => r.selector.includes(`.${target}`) && /none/i.test(r.decls.display || ""),
656
+ );
657
+ if (!rule) {
658
+ err("E6", `${change.id} 声明 visible:false,但设计层没有任何「命中 .${target} 且 display:none」的收口规则 —— 元素会照旧显示。`);
659
+ } else if (!/!important/i.test(rule.decls.display)) {
660
+ warn("E6", `${change.id} 的收口规则 ${rule.selector} 的 display:none 未加 !important,存在被组件 scoped 同权重反压的风险。`);
661
+ } else {
662
+ info("E6", `${change.id} → ${rule.selector} display:none !important ✓`);
663
+ }
664
+ }
665
+ })();
666
+
667
+ // ── E7 激活态越层 ───────────────────────────────────────────
668
+ (function checkE7() {
669
+ const hits = designRules.filter((r) => {
670
+ const sel = r.selector;
671
+ const onGroup = /el-sub-menu|__title/.test(sel);
672
+ const active = /is-active|\.active\b|\[aria-current|\.is-opened/.test(sel);
673
+ if (!onGroup || !active) return false; // 必须同时命中「分组标题」与「激活态」才是越层
674
+ const setsSurface = Object.keys(r.decls).some(
675
+ (p) => p === "background" || p.startsWith("background") || p === "box-shadow" || p === "border-inline-start",
676
+ );
677
+ const pseudoBar = /::?(before|after)/.test(sel);
678
+ return setsSurface || pseudoBar;
679
+ });
680
+ for (const r of hits.slice(0, 10)) {
681
+ err("E7", `激活态越层:${r.selector} 把激活态(胶囊/指示条)写到了可展开分组的标题上 —— 分组标题会同时长出胶囊与竖向指示条,并与右侧展开箭头叠压(§6.15/§6.16 要求激活态只作用叶子项,分组标题须以 :not(.el-sub-menu__title) 排除)。`);
682
+ }
683
+ if (!hits.length) info("E7", "激活态越层:0 处");
684
+ })();
685
+
686
+ // ── E8 浮层残留 ─────────────────────────────────────────────
687
+ (function checkE8() {
688
+ if (!changeset) {
689
+ info("E8", "未发现变更集,跳过浮层残留检查");
690
+ return;
691
+ }
692
+ for (const change of changeset.changes || []) {
693
+ const form = change.form;
694
+ if (!form) continue;
695
+ const entry = dictSidMap.get(change.id);
696
+ if (!entry) continue;
697
+ const required = entry.suppress_rules?.[form] || [];
698
+ const structural = [...required, ...(entry.structure_ops_required?.[form] || [])].join(" ");
699
+ if (!/popper|dropdown|浮层/i.test(structural)) continue;
700
+ const compFile = entry.file && typeof entry.file === "string" ? entry.file.match(/(src\/[\w./-]+\.vue)/) : null;
701
+ if (!compFile) continue;
702
+ const p = path.join(PROJECT, compFile[1]);
703
+ const t = readIf(p);
704
+ if (!t) continue;
705
+ if (/#dropdown|ElDropdown|el-dropdown/.test(t)) {
706
+ err("E8", `${change.id} 的 form=${form} 要求收口下拉浮层,但 ${compFile[1]} 仍保留 ElDropdown / #dropdown 消费 —— 点击触发器仍会弹出浮层(浮层属 teleport popper,样式层收不掉)。`);
707
+ } else {
708
+ info("E8", `${change.id} 浮层收口 ✓`);
709
+ }
710
+ }
711
+ })();
712
+
713
+ // ── E9 形态收口缺件 ─────────────────────────────────────────
714
+ // 形态类 form(如 icon_only)落地必须「成对」:显图标 + 隐文案,且收口要落回该语义元素
715
+ // 自己的作用域内。只按「全局有没有 .tag-title{display:none}」判断会被兄弟变体规则骗过。
716
+ const normSel = (s) => String(s).replace(/\s+/g, " ").trim();
717
+ const lastClassOutsideNot = (sel) => {
718
+ const cleaned = String(sel).replace(/:not\([^)]*\)/g, "");
719
+ const parts = splitList(cleaned);
720
+ const last = parts.length ? lastCompound(parts[parts.length - 1]) : "";
721
+ const cls = classTokens(last);
722
+ if (cls.length) return cls[cls.length - 1];
723
+ const all = classTokens(cleaned);
724
+ return all.length ? all[all.length - 1] : null;
725
+ };
726
+
727
+ (function checkE9() {
728
+ if (!changeset) {
729
+ info("E9", "未发现变更集,跳过形态收口检查");
730
+ return;
731
+ }
732
+ for (const change of changeset.changes || []) {
733
+ if (!/^(icon_only|icon)$/.test(change.form || "")) continue;
734
+ const entry = dictSidMap.get(change.id);
735
+ if (!entry) {
736
+ warn("E9", `${change.id} 的 form=${change.form} 在词典里找不到条目,无法断言形态收口。`);
737
+ continue;
738
+ }
739
+ const scope = normSel(entry.selector || "");
740
+ const subs = entry.sub_selectors || {};
741
+ const textSub = subs.title || subs.text;
742
+ const iconSub = subs.icon || subs.leading_icon;
743
+ if (!scope || !textSub) {
744
+ warn("E9", `${change.id} 的词典缺少 selector 或 sub_selectors.title,无法断言「显图标 + 隐文案」是否成对。`);
745
+ continue;
746
+ }
747
+ const textTarget = lastClassOutsideNot(textSub);
748
+ const iconTarget = iconSub ? lastClassOutsideNot(iconSub) : null;
749
+ const inScope = (r) => {
750
+ const s = normSel(r.selector);
751
+ return s.includes(scope);
752
+ };
753
+ const closed = designRules.filter(
754
+ (r) => inScope(r) && /none/i.test(r.decls.display || "") && (!textTarget || normSel(r.selector).includes(`.${textTarget}`)),
755
+ );
756
+ const shown =
757
+ (iconTarget &&
758
+ designRules.find((r) => inScope(r) && normSel(r.selector).includes(`.${iconTarget}`) && /(block|inline-flex|flex|inline-block)/i.test(r.decls.display || ""))) ||
759
+ null;
760
+ const closedIcon = designRules.find((r) => inScope(r) && /\.card-tab-icon|\.tag-icon|icon/i.test(normSel(r.selector)) && /none/i.test(r.decls.display || ""));
761
+ // 图标与文案至少要有一边在本作用域内被显式声明,否则说明该形态未在本层落地
762
+ const landed = shown || closedIcon || closed.length;
763
+ if (!landed) continue;
764
+ if (!closed.length) {
765
+ err(
766
+ "E9",
767
+ `${change.id} 的 form=${change.form} 只落了「显图标」,没有在同一作用域 ${scope} 内收口文案(需要 .${textTarget} 的 display:none,锚定 ${scope})—— 图标与文案并存会把固定窄宽挤爆,文案被截断(如「工作台」→「工…」)。注意:作用域外的兄弟变体收口(如 :not(:nth-child(1)))不算成对。`,
768
+ );
769
+ continue;
770
+ }
771
+ if (!/!important/i.test(closed[0].decls.display || "")) {
772
+ warn("E9", `${change.id} 的文案收口 ${closed[0].selector} 未加 !important,存在被组件 scoped 同权反压的风险。`);
773
+ } else {
774
+ info("E9", `${change.id} 图标/文案收口成对 ✓(${closed[0].selector})`);
775
+ }
776
+ }
777
+ })();
778
+
779
+ // ── @sid 标记覆盖率(可选增强) ─────────────────────────────
780
+ (function checkSidCoverage() {
781
+ const marked = new Set([...designCss.matchAll(/@sid\s*:\s*([\w.$-]+)/g)].map((m) => m[1]));
782
+ if (!marked.size) {
783
+ info("SID", "设计层未使用 @sid 标记;建议每条设计层规则上方标注 /* @sid: <语义ID> */,即可启用「变更集 ↔ 设计层」逐条覆盖对账。");
784
+ return;
785
+ }
786
+ const declared = new Set((changeset?.changes || []).map((c) => c.id));
787
+ const missing = [...declared].filter((sid) => !marked.has(sid));
788
+ for (const sid of missing) {
789
+ warn("SID", `变更集声明了 ${sid},但设计层没有对应 @sid 标记 —— 无法证明该条已落到样式/结构。`);
790
+ }
791
+ info("SID", `@sid 覆盖率:${declared.size - missing.length}/${declared.size}`);
792
+ })();
793
+
794
+ // ── 输出 ────────────────────────────────────────────────────
795
+ const result = { project: PROJECT, errors, warnings, infos };
796
+
797
+ const CHECK_TITLES = {
798
+ E1: "变量悬空",
799
+ E2: "层错位",
800
+ E3: "层叠败北",
801
+ E4: "选择器挂空",
802
+ E5: "列宽预算",
803
+ E6: "visible:false 收口断言",
804
+ E7: "激活态越层",
805
+ E8: "浮层残留",
806
+ E9: "形态收口缺件",
807
+ E10: "scoped 规则挂空",
808
+ SID: "@sid 覆盖率",
809
+ };
810
+
811
+ const REPORT_PATH = argValue("--report", null);
812
+ if (REPORT_PATH) {
813
+ const rel = (p) => path.relative(PROJECT, p).replace(/\\/g, "/");
814
+ const rows = Object.keys(CHECK_TITLES)
815
+ .map((id) => {
816
+ const e = errors.filter((x) => x.id === id).length;
817
+ const w = warnings.filter((x) => x.id === id).length;
818
+ return { id, title: CHECK_TITLES[id], e, w };
819
+ })
820
+ .filter((r) => r.e || r.w);
821
+
822
+ const lines = [
823
+ "# 设计层闭环审计报告",
824
+ "",
825
+ `- 项目:\`${PROJECT}\``,
826
+ `- 设计层文件:${DESIGN_FILES.map(rel).join("、") || "(未发现)"}`,
827
+ `- 变更集:${changesetFile ? rel(changesetFile) : "(未发现)"}`,
828
+ `- 词典条目:${dictEntries.length} | 设计层规则:${designRules.length} | 容器可用宽:${CONTAINER_WIDTH}px${CONTAINER_EXPLICIT ? "" : "(估值,用 --container-width 校准)"}`,
829
+ `- 结论:**ERROR ${errors.length} | WARN ${warnings.length}**`,
830
+ "",
831
+ "## 命中分布",
832
+ "",
833
+ "| 检查项 | 含义 | ERROR | WARN |",
834
+ "|---|---|---|---|",
835
+ ...rows.map((r) => `| ${r.id} | ${r.title} | ${r.e} | ${r.w} |`),
836
+ "",
837
+ "## 待修项(ERROR)",
838
+ "",
839
+ ...(errors.length ? errors.map((e, i) => `${i + 1}. **[${e.id}] ${CHECK_TITLES[e.id] || ""}** — ${e.msg}`) : ["(无)"]),
840
+ "",
841
+ "## 提示项(WARN)",
842
+ "",
843
+ ...(warnings.length ? warnings.map((w, i) => `${i + 1}. **[${w.id}] ${CHECK_TITLES[w.id] || ""}** — ${w.msg}`) : ["(无)"]),
844
+ "",
845
+ "## 已通过项",
846
+ "",
847
+ ...(infos.length ? infos.map((i) => `- [${i.id}] ${i.msg}`) : ["(无)"]),
848
+ "",
849
+ `> 复跑:\`node scripts/design-audit.mjs --project "${PROJECT}" --container-width ${CONTAINER_WIDTH}\``,
850
+ "",
851
+ ];
852
+ const out = path.resolve(REPORT_PATH);
853
+ fs.mkdirSync(path.dirname(out), { recursive: true });
854
+ fs.writeFileSync(out, lines.join("\n"), "utf-8");
855
+ console.log(`\n审计报告已写入:${out}`);
856
+ }
857
+
858
+ if (AS_JSON) {
859
+ console.log(JSON.stringify(result, null, 2));
860
+ } else {
861
+ console.log("\n设计层闭环审计\n");
862
+ console.log(`项目: ${PROJECT}`);
863
+ console.log(`设计层文件: ${DESIGN_FILES.map((f) => path.relative(PROJECT, f).replace(/\\/g, "/")).join(", ") || "(未发现)"}`);
864
+ console.log(`变更集: ${changesetFile ? path.relative(PROJECT, changesetFile) : "(未发现)"}`);
865
+ console.log(`词典条目: ${dictEntries.length} | 设计层规则: ${designRules.length}\n`);
866
+ for (const i of infos) console.log(` · [${i.id}] ${i.msg}`);
867
+ for (const w of warnings) console.log(` ! [${w.id}] ${w.msg}`);
868
+ for (const e of errors) console.log(` \u2717 [${e.id}] ${e.msg}`);
869
+ console.log(`\n${"=".repeat(50)}`);
870
+ console.log(`闭环审计:ERROR ${errors.length} | WARN ${warnings.length}`);
871
+ if (errors.length) {
872
+ console.log("\n\u2717 设计层存在「声明了却不会生效 / 形态未落地」的问题,修复后重跑。\n");
873
+ } else {
874
+ console.log("\n\u2713 设计层闭环审计通过(样式声明均已具备生效条件)。\n");
875
+ }
876
+ }
877
+
878
+ process.exit(errors.length ? 1 : 0);