/*** @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); // hits 是「哪一行踩了哪条规则」的机器可读明细:此前排查只能手写 node -e 调 scanSource, // Agent 拿到计数却定位不到行,等于每轮都要重写一遍探针。 const hits = Object.fromEntries(KIND_META.map((meta) => [ meta.key, (result.hits[meta.key] ?? []).map((hit) => ({ line: hit.line, prop: hit.prop, value: hit.value, hint: meta.hint })), ])); console.log(JSON.stringify({ file: UI_CSS_REL, total, counts: result.counts, baseline: baseline?.counts ?? null, hits }, 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; }