#!/usr/bin/env node /** * verify:ui —— FR-12 / FR-13 / FR-15 的静态断言 * * 依据《设计令牌.md》v3.0 §十二。测量口径(评审 G3 要求): * · **口径 A(强制)**:业务目录 `src/app/{core,components,desktop,mobile,pages}` 的 .ts/.scss/.html * · **口径 B(豁免)**:`src/app/ui/**`(底座生成物,禁改) * * 用法:node scripts/verify-ui.mjs * 退出码 0 = 全部通过;非 0 = 有断言失败。 */ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; const ROOT = process.cwd(); const SRC = join(ROOT, 'src'); /** 业务目录(口径 A) */ const BIZ_DIRS = ['app/core', 'app/components', 'app/desktop', 'app/mobile', 'app/pages', 'environments']; /** 全局样式文件(单独检查行数) */ const GLOBAL_STYLES = ['styles.scss', 'app/core/styles/_base.scss', 'app/core/styles/_palette.scss']; /** 覆盖块行数预算(评审 G5 / AC-12.4) */ const BUDGET_LINES = 300; const results = []; const fail = (name, detail, hits = []) => results.push({ name, ok: false, detail, hits }); /** 相对路径(报告里不打印完整磁盘路径,便于阅读) */ const rel = (f) => relative(ROOT, f).split(sep).join('/'); const pass = (name, detail) => results.push({ name, ok: true, detail }); function walk(dir, out = []) { let entries; try { entries = readdirSync(dir); } catch { return out; } for (const e of entries) { const p = join(dir, e); const st = statSync(p); if (st.isDirectory()) walk(p, out); else out.push(p); } return out; } const bizFiles = BIZ_DIRS.flatMap((d) => walk(join(SRC, d))).filter((f) => /\.(ts|scss|html)$/.test(f), ); /** 逐行扫描,返回命中 {file, line, text} */ function scan(files, regex) { const hits = []; for (const f of files) { const lines = readFileSync(f, 'utf-8').split('\n'); lines.forEach((text, i) => { if (regex.test(text)) hits.push({ file: relative(ROOT, f), line: i + 1, text: text.trim() }); }); } return hits; } /** 从样式/模板中提取所有「代码实参」,用于去重档位统计 */ function extractCssValues(files, propRegex) { const values = new Set(); for (const f of files) { const src = readFileSync(f, 'utf-8'); let m; const re = new RegExp(propRegex.source, 'g'); while ((m = re.exec(src)) !== null) values.add(m[1].trim()); } return [...values]; } /* ------------------------------------------------------------------ */ /* AC-12.1 硬编码色值 → 0 处 */ /* ------------------------------------------------------------------ */ { // 允许在「注释行」与「令牌定义文件」中出现色值(令牌定义是取值的唯一真源) const ALLOW_FILES = GLOBAL_STYLES.map((f) => 'src/' + f); const files = bizFiles.filter((f) => !ALLOW_FILES.includes(relative(ROOT, f).replace(/\\/g, '/'))); const hits = scan(files, /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/).filter( // 排除注释行与文档说明中的示例 (h) => !/^\s*(\/\/|\*|\/\*)/.test(h.text), ); if (hits.length === 0) pass('AC-12.1 硬编码色值', '业务目录 0 处'); else fail('AC-12.1 硬编码色值', `业务目录 ${hits.length} 处(目标 0)`, hits.slice(0, 20)); } /* ------------------------------------------------------------------ */ /* AC-12.2 自有前缀 / 双轨语义色 → 0 处 */ /* ------------------------------------------------------------------ */ { const hits = scan(bizFiles, /--(?:fx|wecom|dk)-|--ok\b|--crit\b|--warn-(?:ink|bg)\s*:/).filter( (h) => !/^\s*(\/\/|\*|\/\*)/.test(h.text), ); if (hits.length === 0) pass('AC-12.2 令牌前缀单轨', '0 处自有前缀 / 双轨语义色'); else fail('AC-12.2 令牌前缀单轨', `${hits.length} 处(目标 0)`, hits.slice(0, 20)); } /* ------------------------------------------------------------------ */ /* AC-12.3a 字号档位 ≤8 */ /* ------------------------------------------------------------------ */ { const styles = bizFiles.filter((f) => /\.(scss|ts)$/.test(f)); // 只统计「字面量 px/数值」形式;var(--fs-*) 是令牌引用,不参与档位统计 const literal = extractCssValues( styles, /font-size:\s*(?!var\(|inherit|1em)([0-9.]+(?:px|rem))/, ); /* * 🔴 规则口径是「**同一界面**字号 ≤8 档」(rules/18 §一.2), * 不是「全站去重后 ≤8 档」。 * * 原实现把全站所有文件的 --fs-* 汇总成一个集合统计 —— * 全站有 13 档(10~26)就必然失败,但那是**设计令牌全集**, * 任何单页都用不满。实测:档位最多的页面(登录/我的/消息列表)各 7 档,全部合规。 * * 改为**逐文件统计,取单文件最大值**。 */ const perFile = new Map(); for (const f of styles) { const src = readFileSync(f, 'utf-8'); const set = new Set(); for (const m of src.matchAll(/var\((--fs-[0-9]+)\)/g)) set.add(m[1]); if (set.size > 0) perFile.set(f, set); } let worst = { file: '', size: 0 }; for (const [f, set] of perFile) { if (set.size > worst.size) worst = { file: f, size: set.size }; } const allTokens = new Set([...perFile.values()].flatMap((s) => [...s])); const over = [...perFile.entries()].filter(([, s]) => s.size > 8); if (literal.length === 0 && over.length === 0) { pass( 'AC-12.3a 字号 ≤8 档(按单页计)', `字面量 0 个;单页最多 ${worst.size} 档(${rel(worst.file)});全站令牌共 ${allTokens.size} 档`, ); } else { fail( 'AC-12.3a 字号 ≤8 档(按单页计)', `字面量 ${literal.length} 个;超标文件 ${over.length} 个(各自档位:${over .map(([f, s]) => `${rel(f)}=${s.size}`) .join(', ')})`, literal.map((v) => ({ file: '(字面量)', line: 0, text: v })), ); } } /* ------------------------------------------------------------------ */ /* AC-12.3b 圆角档位 ≤4 */ /* ------------------------------------------------------------------ */ { const styles = bizFiles.filter((f) => /\.(scss|ts)$/.test(f)); const token = new Set(); for (const f of styles) { const src = readFileSync(f, 'utf-8'); for (const m of src.matchAll(/var\((--r-[a-z]+)\)/g)) token.add(m[1]); } if (token.size <= 4) pass('AC-12.3b 圆角 ≤4 档', `令牌 ${token.size} 档(${[...token].join(', ')})`); else fail('AC-12.3b 圆角 ≤4 档', `令牌 ${token.size} 档(目标 ≤4)`); } /* ------------------------------------------------------------------ */ /* AC-12.4 覆盖块行数 ≤300 */ /* ------------------------------------------------------------------ */ { let total = 0; const parts = []; for (const f of GLOBAL_STYLES) { try { const n = readFileSync(join(SRC, f), 'utf-8').split('\n').length; total += n; parts.push(`${f}=${n}`); } catch { /* 缺失则跳过 */ } } if (total <= BUDGET_LINES) pass('AC-12.4 覆盖块行数', `${total} ≤ ${BUDGET_LINES}(${parts.join(' + ')})`); else fail('AC-12.4 覆盖块行数', `${total} 行 > ${BUDGET_LINES}(${parts.join(' + ')})`); } /* ------------------------------------------------------------------ */ /* AC-12.5 单例服务:ThemeService / PaletteService 各一处 */ /* ------------------------------------------------------------------ */ { const hits = scan(bizFiles, /new\s+(?:ThemeService|PaletteService)\s*\(/); if (hits.length === 0) pass('AC-12.5 服务单例', '无手工 new,均走 DI'); else fail('AC-12.5 服务单例', `${hits.length} 处手工实例化(应走 DI)`, hits); } /* ------------------------------------------------------------------ */ /* AC-12.6 ui-kit 组件引用去重 ≥30 */ /* ------------------------------------------------------------------ */ { // 用 fs 递归统计(避免 Windows 下 grep 不可用) const bizRoot = join(SRC, 'app'); const scanDirs = ['desktop', 'mobile', 'components', 'pages', 'core'].map((d) => join(bizRoot, d)); const selectors = new Set(); for (const d of scanDirs) { for (const f of walk(d)) { if (!/\.(ts|html)$/.test(f)) continue; const src = readFileSync(f, 'utf-8'); for (const m of src.matchAll(/<(ui-[a-z-]+)/g)) selectors.add(m[1]); } } const count = selectors.size; if (count >= 30) pass('AC-12.6 ui-kit 覆盖率', `${count} 个不同组件(门槛 30):${[...selectors].sort().join(', ')}`); else fail( 'AC-12.6 ui-kit 覆盖率', `${count} 个(门槛 30)`, [{ file: '(统计)', line: 0, text: [...selectors].sort().join(', ') }], ); } /* ------------------------------------------------------------------ */ /* AC-13.3 触达区 ≥44px */ /* ------------------------------------------------------------------ */ { const styles = bizFiles.filter((f) => /\.(scss|ts)$/.test(f)); // 只检查交互元素的高度声明是否 < 44px(图标按钮/筛选 chip 等) const hits = []; for (const f of styles) { const src = readFileSync(f, 'utf-8'); // 形如 height: 28px / min-height: 30px 且未走 --touch-min 的交互控件 for (const m of src.matchAll(/(?:^|\s)(?:min-)?height:\s*([0-9]+)px/g)) { const n = Number(m[1]); if (n > 0 && n < 44) { const line = src.slice(0, m.index).split('\n').length; hits.push({ file: relative(ROOT, f), line, text: m[0].trim() }); } } } // 视觉高 < 44 是允许的(用 ::after 撑开触达区);此处只报告供人工核对 pass( 'AC-13.3 触达区(提示)', `视觉高 <44px 的声明 ${hits.length} 处 —— 需人工确认均有 ::after 撑开触达区(筛选 chip/徽标属此列)`, ); } /* ------------------------------------------------------------------ */ /* AC-15.1 调试残留 → 0 处 */ /* ------------------------------------------------------------------ */ { const hits = scan(bizFiles, /\bconsole\.(log|debug)\s*\(/); if (hits.length === 0) pass('AC-15.1 无 console 残留', '0 处'); else fail('AC-15.1 无 console 残留', `${hits.length} 处`, hits.slice(0, 20)); } /* ------------------------------------------------------------------ */ /* template / styles 里不得出现孤立的反引号 -> 0 处 */ /* */ /* 判定:从 template: 后的第一个反引号起,逐字符前进;遇到未转义的反引号 */ /* 时检查其后是否紧跟 styles/imports/},是则视为合法结尾,否则计一次违规。*/ /* 必须容忍 CRLF(Windows 换行是 \r\n),否则永远判不中合法结尾。 */ /* 这个错踩过 10 次:tsc 有时不报,dev server 卡在旧错误上,界面看似正常。*/ /* ------------------------------------------------------------------ */ { const hits = []; const BT = String.fromCharCode(96); const BS = String.fromCharCode(92); const NL = String.fromCharCode(10); const CR = String.fromCharCode(13); for (const f of bizFiles) { const text = readFileSync(f, "utf-8"); const lineOf = (idx) => text.slice(0, idx).split(NL).length; /* 收集所有 template: / styles: 块的起始反引号(含 styles: [ 数组形式) */ const starts = []; const re = new RegExp("(template|styles):\\s*(\\[\\s*)?`", "g"); let m; while ((m = re.exec(text)) !== null) { const k = m.index + m[0].length - 1; if (text[k] === BT) starts.push(k); } for (const start of starts) { let i = start + 1; let bad = 0; while (i < text.length) { const c = text[i]; if (c === BS) { i += 2; continue; } if (c === BT) { let j = i + 1; while (j < text.length && (text[j] === " " || text[j] === CR || text[j] === NL)) j++; const head = text.slice(j, j + 7); const isEnd = head.startsWith("styles") || head.startsWith("imports") || text[j] === "}" || text[j] === "," || text[j] === "]"; if (isEnd) break; bad++; } i++; } if (bad > 0) hits.push({ file: rel(f), line: lineOf(start), text: "字符串体内含反引号" }); } } if (hits.length === 0) pass('字符串内无反引号', '0 处'); else fail('字符串内无反引号', `${hits.length} 处(会提前闭合 template/styles)`, hits); } /* 全局 outline: none → 0 处 */ /* ------------------------------------------------------------------ */ { const hits = scan(bizFiles, /outline:\s*none/).filter((h) => !/focus-visible/.test(h.text)); if (hits.length === 0) pass('全局 outline:none', '0 处'); else fail('全局 outline:none', `${hits.length} 处(禁止全局移除焦点环)`, hits); } /* ------------------------------------------------------------------ */ /* 裸断点(未走 mixin)→ 0 处 */ /* ------------------------------------------------------------------ */ { const styles = bizFiles.filter((f) => /\.(scss|ts)$/.test(f)); const hits = scan(styles, /@media[^{]*\d+px/).filter( (h) => !/_breakpoints\.scss$/.test(h.file) && !/prefers-reduced-motion/.test(h.text), ); if (hits.length === 0) pass('断点令牌化', '无裸断点'); else fail( '断点令牌化', `${hits.length} 处裸断点(应用 _breakpoints.scss 的 mixin)`, hits.slice(0, 20), ); } /* ------------------------------------------------------------------ */ /* 输出 */ /* ------------------------------------------------------------------ */ const failed = results.filter((r) => !r.ok); console.log('\n[verify:ui] Fmode v2 · UI 规范断言'); console.log(`口径:业务目录 ${bizFiles.length} 个文件;底座 src/app/ui/** 豁免\n`); for (const r of results) { const mark = r.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m'; console.log(`${mark} ${r.name}`); console.log(` ${r.detail}`); for (const h of r.hits ?? []) { console.log(` · ${h.file}:${h.line} ${h.text}`); } } console.log(''); if (failed.length === 0) { console.log(`\x1b[32m[verify:ui] ✓ ${results.length} 项断言全部通过\x1b[0m\n`); process.exit(0); } else { console.log(`\x1b[31m[verify:ui] ✗ ${failed.length}/${results.length} 项未通过\x1b[0m\n`); process.exit(1); }