Files
UI/packages/tools/cli/lib/visual-audit.mjs
T
HIsheR f125369744 feat(cli): 新增视觉漂移审计门禁与幂等迁移脚本
- 新增 lib/visual-rules.mjs 作为规则表单一来源:字号阶梯、时长三档、
  阴影四档、焦点环收敛、圆角档位的判定与改写集中在一处,迁移与审计共用
- 新增 lib/visual-audit.mjs + ua audit:visual:按五类统计硬编码视觉字面量,
  支持 --json 与 --baseline,默认与 .ua/visual-baseline.json 棘轮比对,
  超基线退出码 1 并按 git 历史倒推疑似引入提交
- 新增 scripts/visual-migrate.mjs:幂等迁移 ui.css(重复运行零 diff),
  另带 --seed 灌视觉欠账、--backfill-domain 回填域字段
- 实跑迁移:283 处降到 35 处(字号 27→5、时长 170→9、阴影 56→17、
  圆角 2→0、焦点环 28→4),159 行逐行等价替换,括号/分号/行数不变
- package.json 把 audit:visual 追加到 check 链末尾,并补 visual:migrate
2026-10-26 16:49:40 +08:00

131 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*** @ui-arch 视觉漂移审计:统计皮肤包里残留的硬编码视觉字面量,并按基线做棘轮门禁(只减不增)
*
* 为什么放在 lib 而不是塞进 bin/ua.js:ua.js 已经承载包索引、文档、MCP、六道门禁,
* 再加一套统计逻辑会让入口文件继续膨胀;这里只留「读取 → 统计 → 比对 → 报错」四步,
* 规则表本身在 visual-rules.mjs(与迁移脚本共用同一张表,保证判定口径一致)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { UI_CSS_REL, KIND_META, countRows, scanSource } from './visual-rules.mjs';
/*** 作用:基线文件相对仓库根的路径(入库,供 CI 与本地共用) ***/
const BASELINE_REL = '.ua/visual-baseline.json';
/*** 作用:读仓库文件,统一换行便于行号稳定 ***/
function readText(file) {
return fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
}
/*** 作用:取当前提交短哈希,写入基线便于回溯「哪次提交把数字定在这」 ***/
function shortCommit(root) {
const res = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' });
return res.status === 0 ? String(res.stdout).trim() : 'unknown';
}
/*** 作用:统计单行文本里某个类别的命中数(diff 归因时逐行套同一套规则,口径与整文件扫描一致) ***/
function countInLine(line, key) {
return scanSource(line).counts[key] ?? 0;
}
/*** 作用:按 git 历史倒序找最可能引入该类硬编码的提交(只查样式表最近 40 次改动) ***/
function suspectCommit(root, key) {
const log = spawnSync('git', ['log', '-n', '40', '--pretty=%h', '--', UI_CSS_REL], { cwd: root, encoding: 'utf8' });
if (log.status !== 0) return null; // 非 git 环境(打包分发)跳过归因
for (const hash of String(log.stdout).split('\n').map((s) => s.trim()).filter(Boolean)) {
const show = spawnSync('git', ['show', '--format=', '--unified=0', hash, '--', UI_CSS_REL], { cwd: root, encoding: 'utf8' });
if (show.status !== 0) continue;
const added = String(show.stdout).split('\n').filter((line) => line.startsWith('+') && !line.startsWith('+++'));
const hit = added.reduce((sum, line) => sum + countInLine(line.slice(1), key), 0);
if (hit) return { hash, hit };
}
return null;
}
/*** 作用:打印每类的计数与前 5 个行号,返回总计数(人读模式) ***/
function printReport(root, result) {
for (const row of countRows(result.counts)) {
const lines = (result.hits[row.key] ?? []).slice(0, 5).map((hit) => hit.line);
const where = lines.length ? ` 例如行 ${lines.join('/')}` : '';
console.log(`${row.label}(${row.key}):${row.count} 处${where}`);
}
const total = countRows(result.counts).reduce((sum, row) => sum + row.count, 0);
console.log(`硬编码视觉字面量合计 ${total} 处(${UI_CSS_REL})`);
return total;
}
/*** 作用:把当前计数写入基线文件(含提交与时间,入库),供后续棘轮比对 ***/
function writeBaseline(root, result) {
const file = path.join(root, BASELINE_REL);
const total = KIND_META.reduce((sum, meta) => sum + (result.counts[meta.key] ?? 0), 0);
const data = {
$schema: '视觉漂移基线:五类硬编码视觉字面量的允许上限,任一类超出即 ua audit:visual 退出码 1;迁移后用 --baseline 重写',
file: UI_CSS_REL,
commit: shortCommit(root),
generatedAt: new Date().toISOString(),
lines: readText(path.join(root, UI_CSS_REL)).split('\n').length,
total,
counts: Object.fromEntries(KIND_META.map((meta) => [meta.key, result.counts[meta.key] ?? 0])),
};
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
console.log(`已写入基线 ${BASELINE_REL}(合计 ${total} 处,commit ${data.commit})`);
}
/*** 作用:读基线;缺失或损坏时返回 null(门禁降级为「只报告不拦截」并提示生成基线) ***/
function readBaseline(root) {
const file = path.join(root, BASELINE_REL);
if (!fs.existsSync(file)) return null;
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return data && typeof data.counts === 'object' ? data : null;
} catch {
return null;
}
}
/*** 作用:入口:ua audit:visual [--json] [--baseline];超基线退出码 1,否则打印通过 ***/
export function visualAudit(args, root) {
const cssFile = path.join(root, UI_CSS_REL);
if (!fs.existsSync(cssFile)) {
console.error(`audit:visual: 找不到 ${UI_CSS_REL}`);
process.exit(1);
}
const result = scanSource(readText(cssFile));
const total = KIND_META.reduce((sum, meta) => sum + (result.counts[meta.key] ?? 0), 0);
if (args.includes('--baseline')) {
writeBaseline(root, result);
printReport(root, result);
return 0;
}
if (args.includes('--json')) {
const baseline = readBaseline(root);
console.log(JSON.stringify({ file: UI_CSS_REL, total, counts: result.counts, baseline: baseline?.counts ?? null }, null, 2));
return 0;
}
const baseline = readBaseline(root);
printReport(root, result);
if (!baseline) {
console.log(`audit:visual: 尚无基线(${BASELINE_REL}),先跑 npm run audit:visual -- --baseline 定基线`);
return 0;
}
const grown = KIND_META.filter((meta) => (result.counts[meta.key] ?? 0) > (baseline.counts[meta.key] ?? 0));
const baseTotal = baseline.total ?? KIND_META.reduce((sum, meta) => sum + (baseline.counts[meta.key] ?? 0), 0);
if (!grown.length) {
console.log(`视觉基线通过(当前 ${total} 处,基线 ${baseTotal} 处)`);
return 0;
}
for (const meta of grown) {
const now = result.counts[meta.key] ?? 0;
const was = baseline.counts[meta.key] ?? 0;
const sus = suspectCommit(root, meta.key);
const who = sus ? `疑似由提交 ${sus.hash} 引入(该提交新增 ${sus.hit} 处)` : '未能定位引入提交(可能来自未提交改动)';
console.error(`audit:visual: ${meta.label}(${meta.key})涨了 ${now - was} 处(基线 ${was} → 当前 ${now}),${meta.hint};${who}`);
}
console.error(`视觉基线失败:${grown.length} 类超过基线。禁止新增硬编码视觉字面量,改用 --ua-* 令牌`);
process.exit(1);
return 1;
}