138 lines
6.7 KiB
JavaScript
138 lines
6.7 KiB
JavaScript
/*** @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 dirty = spawnSync('git', ['status', '--porcelain', '--', UI_CSS_REL], { cwd: root, encoding: 'utf8' });
|
||
if (dirty.status === 0 && String(dirty.stdout).trim()) return { hash: '(工作区未提交的改动)', hit: null };
|
||
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.hit === null
|
||
? `来自${sus.hash},提交前应先改回令牌`
|
||
: 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;
|
||
}
|