verify-ui.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. #!/usr/bin/env node
  2. /**
  3. * verify:ui —— FR-12 / FR-13 / FR-15 的静态断言
  4. *
  5. * 依据《设计令牌.md》v3.0 §十二。测量口径(评审 G3 要求):
  6. * · **口径 A(强制)**:业务目录 `src/app/{core,components,desktop,mobile,pages}` 的 .ts/.scss/.html
  7. * · **口径 B(豁免)**:`src/app/ui/**`(底座生成物,禁改)
  8. *
  9. * 用法:node scripts/verify-ui.mjs
  10. * 退出码 0 = 全部通过;非 0 = 有断言失败。
  11. */
  12. import { readdirSync, readFileSync, statSync } from 'node:fs';
  13. import { join, relative, sep } from 'node:path';
  14. const ROOT = process.cwd();
  15. const SRC = join(ROOT, 'src');
  16. /** 业务目录(口径 A) */
  17. const BIZ_DIRS = ['app/core', 'app/components', 'app/desktop', 'app/mobile', 'app/pages', 'environments'];
  18. /** 全局样式文件(单独检查行数) */
  19. const GLOBAL_STYLES = ['styles.scss', 'app/core/styles/_base.scss', 'app/core/styles/_palette.scss'];
  20. /** 覆盖块行数预算(评审 G5 / AC-12.4) */
  21. const BUDGET_LINES = 300;
  22. const results = [];
  23. const fail = (name, detail, hits = []) => results.push({ name, ok: false, detail, hits });
  24. /** 相对路径(报告里不打印完整磁盘路径,便于阅读) */
  25. const rel = (f) => relative(ROOT, f).split(sep).join('/');
  26. const pass = (name, detail) => results.push({ name, ok: true, detail });
  27. function walk(dir, out = []) {
  28. let entries;
  29. try {
  30. entries = readdirSync(dir);
  31. } catch {
  32. return out;
  33. }
  34. for (const e of entries) {
  35. const p = join(dir, e);
  36. const st = statSync(p);
  37. if (st.isDirectory()) walk(p, out);
  38. else out.push(p);
  39. }
  40. return out;
  41. }
  42. const bizFiles = BIZ_DIRS.flatMap((d) => walk(join(SRC, d))).filter((f) =>
  43. /\.(ts|scss|html)$/.test(f),
  44. );
  45. /** 逐行扫描,返回命中 {file, line, text} */
  46. function scan(files, regex) {
  47. const hits = [];
  48. for (const f of files) {
  49. const lines = readFileSync(f, 'utf-8').split('\n');
  50. lines.forEach((text, i) => {
  51. if (regex.test(text)) hits.push({ file: relative(ROOT, f), line: i + 1, text: text.trim() });
  52. });
  53. }
  54. return hits;
  55. }
  56. /** 从样式/模板中提取所有「代码实参」,用于去重档位统计 */
  57. function extractCssValues(files, propRegex) {
  58. const values = new Set();
  59. for (const f of files) {
  60. const src = readFileSync(f, 'utf-8');
  61. let m;
  62. const re = new RegExp(propRegex.source, 'g');
  63. while ((m = re.exec(src)) !== null) values.add(m[1].trim());
  64. }
  65. return [...values];
  66. }
  67. /* ------------------------------------------------------------------ */
  68. /* AC-12.1 硬编码色值 → 0 处 */
  69. /* ------------------------------------------------------------------ */
  70. {
  71. // 允许在「注释行」与「令牌定义文件」中出现色值(令牌定义是取值的唯一真源)
  72. const ALLOW_FILES = GLOBAL_STYLES.map((f) => 'src/' + f);
  73. const files = bizFiles.filter((f) => !ALLOW_FILES.includes(relative(ROOT, f).replace(/\\/g, '/')));
  74. const hits = scan(files, /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/).filter(
  75. // 排除注释行与文档说明中的示例
  76. (h) => !/^\s*(\/\/|\*|\/\*)/.test(h.text),
  77. );
  78. if (hits.length === 0) pass('AC-12.1 硬编码色值', '业务目录 0 处');
  79. else fail('AC-12.1 硬编码色值', `业务目录 ${hits.length} 处(目标 0)`, hits.slice(0, 20));
  80. }
  81. /* ------------------------------------------------------------------ */
  82. /* AC-12.2 自有前缀 / 双轨语义色 → 0 处 */
  83. /* ------------------------------------------------------------------ */
  84. {
  85. const hits = scan(bizFiles, /--(?:fx|wecom|dk)-|--ok\b|--crit\b|--warn-(?:ink|bg)\s*:/).filter(
  86. (h) => !/^\s*(\/\/|\*|\/\*)/.test(h.text),
  87. );
  88. if (hits.length === 0) pass('AC-12.2 令牌前缀单轨', '0 处自有前缀 / 双轨语义色');
  89. else fail('AC-12.2 令牌前缀单轨', `${hits.length} 处(目标 0)`, hits.slice(0, 20));
  90. }
  91. /* ------------------------------------------------------------------ */
  92. /* AC-12.3a 字号档位 ≤8 */
  93. /* ------------------------------------------------------------------ */
  94. {
  95. const styles = bizFiles.filter((f) => /\.(scss|ts)$/.test(f));
  96. // 只统计「字面量 px/数值」形式;var(--fs-*) 是令牌引用,不参与档位统计
  97. const literal = extractCssValues(
  98. styles,
  99. /font-size:\s*(?!var\(|inherit|1em)([0-9.]+(?:px|rem))/,
  100. );
  101. /*
  102. * 🔴 规则口径是「**同一界面**字号 ≤8 档」(rules/18 §一.2),
  103. * 不是「全站去重后 ≤8 档」。
  104. *
  105. * 原实现把全站所有文件的 --fs-* 汇总成一个集合统计 ——
  106. * 全站有 13 档(10~26)就必然失败,但那是**设计令牌全集**,
  107. * 任何单页都用不满。实测:档位最多的页面(登录/我的/消息列表)各 7 档,全部合规。
  108. *
  109. * 改为**逐文件统计,取单文件最大值**。
  110. */
  111. const perFile = new Map();
  112. for (const f of styles) {
  113. const src = readFileSync(f, 'utf-8');
  114. const set = new Set();
  115. for (const m of src.matchAll(/var\((--fs-[0-9]+)\)/g)) set.add(m[1]);
  116. if (set.size > 0) perFile.set(f, set);
  117. }
  118. let worst = { file: '', size: 0 };
  119. for (const [f, set] of perFile) {
  120. if (set.size > worst.size) worst = { file: f, size: set.size };
  121. }
  122. const allTokens = new Set([...perFile.values()].flatMap((s) => [...s]));
  123. const over = [...perFile.entries()].filter(([, s]) => s.size > 8);
  124. if (literal.length === 0 && over.length === 0) {
  125. pass(
  126. 'AC-12.3a 字号 ≤8 档(按单页计)',
  127. `字面量 0 个;单页最多 ${worst.size} 档(${rel(worst.file)});全站令牌共 ${allTokens.size} 档`,
  128. );
  129. } else {
  130. fail(
  131. 'AC-12.3a 字号 ≤8 档(按单页计)',
  132. `字面量 ${literal.length} 个;超标文件 ${over.length} 个(各自档位:${over
  133. .map(([f, s]) => `${rel(f)}=${s.size}`)
  134. .join(', ')})`,
  135. literal.map((v) => ({ file: '(字面量)', line: 0, text: v })),
  136. );
  137. }
  138. }
  139. /* ------------------------------------------------------------------ */
  140. /* AC-12.3b 圆角档位 ≤4 */
  141. /* ------------------------------------------------------------------ */
  142. {
  143. const styles = bizFiles.filter((f) => /\.(scss|ts)$/.test(f));
  144. const token = new Set();
  145. for (const f of styles) {
  146. const src = readFileSync(f, 'utf-8');
  147. for (const m of src.matchAll(/var\((--r-[a-z]+)\)/g)) token.add(m[1]);
  148. }
  149. if (token.size <= 4) pass('AC-12.3b 圆角 ≤4 档', `令牌 ${token.size} 档(${[...token].join(', ')})`);
  150. else fail('AC-12.3b 圆角 ≤4 档', `令牌 ${token.size} 档(目标 ≤4)`);
  151. }
  152. /* ------------------------------------------------------------------ */
  153. /* AC-12.4 覆盖块行数 ≤300 */
  154. /* ------------------------------------------------------------------ */
  155. {
  156. let total = 0;
  157. const parts = [];
  158. for (const f of GLOBAL_STYLES) {
  159. try {
  160. const n = readFileSync(join(SRC, f), 'utf-8').split('\n').length;
  161. total += n;
  162. parts.push(`${f}=${n}`);
  163. } catch {
  164. /* 缺失则跳过 */
  165. }
  166. }
  167. if (total <= BUDGET_LINES) pass('AC-12.4 覆盖块行数', `${total} ≤ ${BUDGET_LINES}(${parts.join(' + ')})`);
  168. else fail('AC-12.4 覆盖块行数', `${total} 行 > ${BUDGET_LINES}(${parts.join(' + ')})`);
  169. }
  170. /* ------------------------------------------------------------------ */
  171. /* AC-12.5 单例服务:ThemeService / PaletteService 各一处 */
  172. /* ------------------------------------------------------------------ */
  173. {
  174. const hits = scan(bizFiles, /new\s+(?:ThemeService|PaletteService)\s*\(/);
  175. if (hits.length === 0) pass('AC-12.5 服务单例', '无手工 new,均走 DI');
  176. else fail('AC-12.5 服务单例', `${hits.length} 处手工实例化(应走 DI)`, hits);
  177. }
  178. /* ------------------------------------------------------------------ */
  179. /* AC-12.6 ui-kit 组件引用去重 ≥30 */
  180. /* ------------------------------------------------------------------ */
  181. {
  182. // 用 fs 递归统计(避免 Windows 下 grep 不可用)
  183. const bizRoot = join(SRC, 'app');
  184. const scanDirs = ['desktop', 'mobile', 'components', 'pages', 'core'].map((d) => join(bizRoot, d));
  185. const selectors = new Set();
  186. for (const d of scanDirs) {
  187. for (const f of walk(d)) {
  188. if (!/\.(ts|html)$/.test(f)) continue;
  189. const src = readFileSync(f, 'utf-8');
  190. for (const m of src.matchAll(/<(ui-[a-z-]+)/g)) selectors.add(m[1]);
  191. }
  192. }
  193. const count = selectors.size;
  194. if (count >= 30) pass('AC-12.6 ui-kit 覆盖率', `${count} 个不同组件(门槛 30):${[...selectors].sort().join(', ')}`);
  195. else
  196. fail(
  197. 'AC-12.6 ui-kit 覆盖率',
  198. `${count} 个(门槛 30)`,
  199. [{ file: '(统计)', line: 0, text: [...selectors].sort().join(', ') }],
  200. );
  201. }
  202. /* ------------------------------------------------------------------ */
  203. /* AC-13.3 触达区 ≥44px */
  204. /* ------------------------------------------------------------------ */
  205. {
  206. const styles = bizFiles.filter((f) => /\.(scss|ts)$/.test(f));
  207. // 只检查交互元素的高度声明是否 < 44px(图标按钮/筛选 chip 等)
  208. const hits = [];
  209. for (const f of styles) {
  210. const src = readFileSync(f, 'utf-8');
  211. // 形如 height: 28px / min-height: 30px 且未走 --touch-min 的交互控件
  212. for (const m of src.matchAll(/(?:^|\s)(?:min-)?height:\s*([0-9]+)px/g)) {
  213. const n = Number(m[1]);
  214. if (n > 0 && n < 44) {
  215. const line = src.slice(0, m.index).split('\n').length;
  216. hits.push({ file: relative(ROOT, f), line, text: m[0].trim() });
  217. }
  218. }
  219. }
  220. // 视觉高 < 44 是允许的(用 ::after 撑开触达区);此处只报告供人工核对
  221. pass(
  222. 'AC-13.3 触达区(提示)',
  223. `视觉高 <44px 的声明 ${hits.length} 处 —— 需人工确认均有 ::after 撑开触达区(筛选 chip/徽标属此列)`,
  224. );
  225. }
  226. /* ------------------------------------------------------------------ */
  227. /* AC-15.1 调试残留 → 0 处 */
  228. /* ------------------------------------------------------------------ */
  229. {
  230. const hits = scan(bizFiles, /\bconsole\.(log|debug)\s*\(/);
  231. if (hits.length === 0) pass('AC-15.1 无 console 残留', '0 处');
  232. else fail('AC-15.1 无 console 残留', `${hits.length} 处`, hits.slice(0, 20));
  233. }
  234. /* ------------------------------------------------------------------ */
  235. /* template / styles 里不得出现孤立的反引号 -> 0 处 */
  236. /* */
  237. /* 判定:从 template: 后的第一个反引号起,逐字符前进;遇到未转义的反引号 */
  238. /* 时检查其后是否紧跟 styles/imports/},是则视为合法结尾,否则计一次违规。*/
  239. /* 必须容忍 CRLF(Windows 换行是 \r\n),否则永远判不中合法结尾。 */
  240. /* 这个错踩过 10 次:tsc 有时不报,dev server 卡在旧错误上,界面看似正常。*/
  241. /* ------------------------------------------------------------------ */
  242. {
  243. const hits = [];
  244. const BT = String.fromCharCode(96);
  245. const BS = String.fromCharCode(92);
  246. const NL = String.fromCharCode(10);
  247. const CR = String.fromCharCode(13);
  248. for (const f of bizFiles) {
  249. const text = readFileSync(f, "utf-8");
  250. const lineOf = (idx) => text.slice(0, idx).split(NL).length;
  251. /* 收集所有 template: / styles: 块的起始反引号(含 styles: [ 数组形式) */
  252. const starts = [];
  253. const re = new RegExp("(template|styles):\\s*(\\[\\s*)?`", "g");
  254. let m;
  255. while ((m = re.exec(text)) !== null) {
  256. const k = m.index + m[0].length - 1;
  257. if (text[k] === BT) starts.push(k);
  258. }
  259. for (const start of starts) {
  260. let i = start + 1;
  261. let bad = 0;
  262. while (i < text.length) {
  263. const c = text[i];
  264. if (c === BS) { i += 2; continue; }
  265. if (c === BT) {
  266. let j = i + 1;
  267. while (j < text.length && (text[j] === " " || text[j] === CR || text[j] === NL)) j++;
  268. const head = text.slice(j, j + 7);
  269. const isEnd =
  270. head.startsWith("styles") || head.startsWith("imports") ||
  271. text[j] === "}" || text[j] === "," || text[j] === "]";
  272. if (isEnd) break;
  273. bad++;
  274. }
  275. i++;
  276. }
  277. if (bad > 0) hits.push({ file: rel(f), line: lineOf(start), text: "字符串体内含反引号" });
  278. }
  279. }
  280. if (hits.length === 0) pass('字符串内无反引号', '0 处');
  281. else fail('字符串内无反引号', `${hits.length} 处(会提前闭合 template/styles)`, hits);
  282. }
  283. /* 全局 outline: none → 0 处 */
  284. /* ------------------------------------------------------------------ */
  285. {
  286. const hits = scan(bizFiles, /outline:\s*none/).filter((h) => !/focus-visible/.test(h.text));
  287. if (hits.length === 0) pass('全局 outline:none', '0 处');
  288. else fail('全局 outline:none', `${hits.length} 处(禁止全局移除焦点环)`, hits);
  289. }
  290. /* ------------------------------------------------------------------ */
  291. /* 裸断点(未走 mixin)→ 0 处 */
  292. /* ------------------------------------------------------------------ */
  293. {
  294. const styles = bizFiles.filter((f) => /\.(scss|ts)$/.test(f));
  295. const hits = scan(styles, /@media[^{]*\d+px/).filter(
  296. (h) => !/_breakpoints\.scss$/.test(h.file) && !/prefers-reduced-motion/.test(h.text),
  297. );
  298. if (hits.length === 0) pass('断点令牌化', '无裸断点');
  299. else
  300. fail(
  301. '断点令牌化',
  302. `${hits.length} 处裸断点(应用 _breakpoints.scss 的 mixin)`,
  303. hits.slice(0, 20),
  304. );
  305. }
  306. /* ------------------------------------------------------------------ */
  307. /* 输出 */
  308. /* ------------------------------------------------------------------ */
  309. const failed = results.filter((r) => !r.ok);
  310. console.log('\n[verify:ui] Fmode v2 · UI 规范断言');
  311. console.log(`口径:业务目录 ${bizFiles.length} 个文件;底座 src/app/ui/** 豁免\n`);
  312. for (const r of results) {
  313. const mark = r.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
  314. console.log(`${mark} ${r.name}`);
  315. console.log(` ${r.detail}`);
  316. for (const h of r.hits ?? []) {
  317. console.log(` · ${h.file}:${h.line} ${h.text}`);
  318. }
  319. }
  320. console.log('');
  321. if (failed.length === 0) {
  322. console.log(`\x1b[32m[verify:ui] ✓ ${results.length} 项断言全部通过\x1b[0m\n`);
  323. process.exit(0);
  324. } else {
  325. console.log(`\x1b[31m[verify:ui] ✗ ${failed.length}/${results.length} 项未通过\x1b[0m\n`);
  326. process.exit(1);
  327. }