| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- #!/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 } 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 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))/,
- );
- const token = new Set();
- for (const f of styles) {
- const src = readFileSync(f, 'utf-8');
- for (const m of src.matchAll(/var\((--fs-[0-9]+)\)/g)) token.add(m[1]);
- }
- if (literal.length === 0 && token.size <= 8) {
- pass('AC-12.3a 字号 ≤8 档', `字面量 0 个;令牌 ${token.size} 档(${[...token].join(', ')})`);
- } else {
- fail(
- 'AC-12.3a 字号 ≤8 档',
- `字面量 ${literal.length} 个 / 令牌 ${token.size} 档(目标 ≤8)`,
- 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));
- }
- /* ------------------------------------------------------------------ */
- /* 全局 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);
- }
|