#!/usr/bin/env node /** * ui-token-audit.mjs — 视觉秩序(token 收敛度)审计 * * 以 src/styles/base.css 的 :root 自定义属性与设计系统内置类为唯一 palette 来源, * 扫描 src 下全部 .vue/.css/.scss(base.css 自身除外),统计: * 1. 颜色:var() 引用 / palette 内字面值 / palette 外字面值(违规) * 2. 字号:分布 + 不在 base.css 派生字阶内的"孤儿"值 * 3. 间距:margin/padding/gap 分布 + 非 2px 网格值 * 4. 圆角:分布 + 不在 token 半径集合内的值(0/百分比/胶囊值除外) * * 输出控制台摘要 + output/ui-ux/token-audit-latest.{json,md}。 * 默认只报告不失败;--strict 时存在 palette 外颜色则以 1 退出。 */ import { readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import { mobileRoot, tokenFile, normalizeHex, normalizeRgba, NEAR_MISS_MAX_DIST, loadTokens, } from './lib/ui-tokens.mjs'; const root = mobileRoot; const srcDir = join(root, 'src'); const strict = process.argv.includes('--strict'); // ---------- 1. 从 base.css 提取 palette 与派生字阶(共享 lib,与渲染审计同源) ---------- const { palette, fontScale, radiusTokens, nearestPalette } = loadTokens(); // ---------- 2. 收集源文件 ---------- const exts = new Set(['.vue', '.css', '.scss']); function walk(dir) { const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, entry.name); if (entry.isDirectory()) out.push(...walk(p)); else if (exts.has(entry.name.slice(entry.name.lastIndexOf('.')))) out.push(p); } return out; } const files = walk(srcDir).filter((f) => f !== tokenFile); // ---------- 3. 扫描 ---------- const colors = { varRefs: 0, paletteLiteral: new Map(), offPalette: new Map() }; const fontSizes = new Map(); const fontSizesOffScale = new Map(); const spacings = new Map(); const spacingsOffGrid = new Map(); const radii = new Map(); const radiiOffToken = new Map(); function bump(map, key, loc) { const cur = map.get(key) || { count: 0, locs: [] }; cur.count += 1; if (cur.locs.length < 5) cur.locs.push(loc); map.set(key, cur); } for (const file of files) { const rel = relative(root, file).replaceAll('\\', '/'); const text = readFileSync(file, 'utf8'); const lineOf = (idx) => rel + ':' + (text.slice(0, idx).split('\n').length); for (const m of text.matchAll(/var\(--[\w-]+\)/g)) colors.varRefs += 1; for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) { const v = normalizeHex(m[0]); if (palette.has(v)) bump(colors.paletteLiteral, v, lineOf(m.index)); else bump(colors.offPalette, v, lineOf(m.index)); } for (const m of text.matchAll(/rgba?\([^)]*\)/g)) { const v = normalizeRgba(m[0]); if (palette.has(v)) bump(colors.paletteLiteral, v, lineOf(m.index)); else bump(colors.offPalette, v, lineOf(m.index)); } for (const m of text.matchAll(/font-size:\s*([\d.]+)px/g)) { const v = Number(m[1]); bump(fontSizes, v, lineOf(m.index)); if (!fontScale.has(v)) bump(fontSizesOffScale, v, lineOf(m.index)); } for (const m of text.matchAll(/(?:margin|padding|gap)(?:-[\w]+)?:\s*([^;}]+)/g)) { for (const n of m[1].matchAll(/(-?[\d.]+)px/g)) { const v = Number(n[1]); if (v === 0) continue; bump(spacings, v, lineOf(m.index)); if (v % 2 !== 0) bump(spacingsOffGrid, v, lineOf(m.index)); } } for (const m of text.matchAll(/border-radius:\s*([^;}]+)/g)) { const raw = m[1].trim(); if (raw.includes('var(') || raw.includes('%')) continue; for (const n of raw.matchAll(/([\d.]+)px/g)) { const v = Number(n[1]); if (v === 0 || v >= 999) continue; // 直角与胶囊/圆形属合法手段 bump(radii, v, lineOf(m.index)); if (!radiusTokens.has(v)) bump(radiiOffToken, v, lineOf(m.index)); } } } // ---------- 4. 汇总 ---------- const sum = (map) => [...map.values()].reduce((a, b) => a + b.count, 0); const top = (map, n = 12) => [...map.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, n); const offPaletteDetailed = {}; let nearMissCount = 0; for (const [v, d] of top(colors.offPalette, colors.offPalette.size)) { const nearest = v.startsWith('#') && v.length === 7 ? nearestPalette(v) : null; if (nearest && nearest.dist > 0 && nearest.dist <= NEAR_MISS_MAX_DIST) nearMissCount += d.count; offPaletteDetailed[v] = { ...d, nearest }; } const result = { generatedAt: new Date().toISOString(), scope: 'mobile-uni/src (excluding styles/base.css as token source)', filesScanned: files.length, tokenSource: { paletteSize: palette.size, fontScale: [...fontScale].sort((a, b) => a - b), radiusTokens: [...radiusTokens].sort((a, b) => a - b), }, colors: { varRefs: colors.varRefs, paletteLiteralCount: sum(colors.paletteLiteral), offPaletteCount: sum(colors.offPalette), offPaletteDistinct: colors.offPalette.size, nearMissCount, offPalette: Object.fromEntries(Object.entries(offPaletteDetailed).slice(0, 30)), }, fontSizes: { distinct: fontSizes.size, total: sum(fontSizes), offScaleDistinct: fontSizesOffScale.size, offScaleCount: sum(fontSizesOffScale), distribution: Object.fromEntries(top(fontSizes, 20)), offScale: Object.fromEntries(top(fontSizesOffScale, 20)), }, spacings: { distinct: spacings.size, total: sum(spacings), offGridDistinct: spacingsOffGrid.size, offGridCount: sum(spacingsOffGrid), distribution: Object.fromEntries(top(spacings, 20)), offGrid: Object.fromEntries(top(spacingsOffGrid, 20)), }, radii: { distribution: Object.fromEntries(top(radii, 20)), offToken: Object.fromEntries(top(radiiOffToken, 20)), }, }; // ---------- 5. 输出 ---------- const literalTotal = result.colors.paletteLiteralCount + result.colors.offPaletteCount; const colorTotal = colors.varRefs + literalTotal; const varRatio = colorTotal ? ((colors.varRefs / colorTotal) * 100).toFixed(1) : '0.0'; const lines = []; lines.push('# mobile-uni UI token 审计'); lines.push(''); lines.push(`- 生成时间:${result.generatedAt}`); lines.push(`- 扫描文件:${files.length} 个(token 源:src/styles/base.css)`); lines.push(`- palette:${palette.size} 个颜色;字阶:${result.tokenSource.fontScale.join('/')}px;半径 token:${result.tokenSource.radiusTokens.join('/')}px`); lines.push(''); lines.push('## 颜色'); lines.push(`- var() 引用 ${colors.varRefs} 次;字面值 ${literalTotal} 次(palette 内 ${result.colors.paletteLiteralCount},**palette 外 ${result.colors.offPaletteCount} 次 / ${colors.offPalette.size} 种**,其中近色漂移 ${nearMissCount} 次)`); lines.push(`- token 引用占比(var / 全部颜色引用):${varRatio}%`); if (colors.offPalette.size) { lines.push(''); lines.push('| palette 外颜色 | 次数 | 最近 palette 色 (Δ) | 示例位置 |'); lines.push('|---|---:|---|---|'); for (const [v, d] of Object.entries(offPaletteDetailed).slice(0, 30)) { const hint = d.nearest ? `${d.nearest.value} (Δ${d.nearest.dist})` : '—'; lines.push(`| ${v} | ${d.count} | ${hint} | ${d.locs.join('、')} |`); } } lines.push(''); lines.push('## 字号'); lines.push(`- 共 ${result.fontSizes.total} 处、${fontSizes.size} 种;**字阶外 ${result.fontSizes.offScaleCount} 处 / ${fontSizesOffScale.size} 种**`); lines.push(`- 分布:${[...top(fontSizes, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',')}`); if (fontSizesOffScale.size) { lines.push(''); lines.push('| 字阶外字号 | 次数 | 示例位置 |'); lines.push('|---|---:|---|'); for (const [v, d] of top(fontSizesOffScale, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`); } lines.push(''); lines.push('## 间距(margin/padding/gap)'); lines.push(`- 共 ${result.spacings.total} 处、${spacings.size} 种;**非 2px 网格 ${result.spacings.offGridCount} 处 / ${spacingsOffGrid.size} 种**`); lines.push(`- 分布:${[...top(spacings, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',')}`); if (spacingsOffGrid.size) { lines.push(''); lines.push('| 非网格间距 | 次数 | 示例位置 |'); lines.push('|---|---:|---|'); for (const [v, d] of top(spacingsOffGrid, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`); } lines.push(''); lines.push('## 圆角'); lines.push(`- 分布:${[...top(radii, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',') || '(无字面值)'}`); if (radiiOffToken.size) { lines.push(''); lines.push('| token 外圆角 | 次数 | 示例位置 |'); lines.push('|---|---:|---|'); for (const [v, d] of top(radiiOffToken, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`); } const outDir = join(root, '..', 'output', 'ui-ux'); mkdirSync(outDir, { recursive: true }); writeFileSync(join(outDir, 'token-audit-latest.json'), JSON.stringify(result, null, 2)); writeFileSync(join(outDir, 'token-audit-latest.md'), lines.join('\n') + '\n'); console.log(lines.join('\n')); console.log(''); console.log(`报告已写入 output/ui-ux/token-audit-latest.{md,json}`); if (strict && result.colors.offPaletteCount > 0) { console.error(`--strict:存在 ${result.colors.offPaletteCount} 处 palette 外颜色`); process.exit(1); }