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
This commit is contained in:
HIsheR
2026-10-26 16:49:40 +08:00
parent 5a1d5ba6d7
commit f125369744
10 changed files with 833 additions and 174 deletions
+130
View File
@@ -0,0 +1,130 @@
/*** @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;
}