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:
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { visualAudit } from '../lib/visual-audit.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -1437,10 +1438,13 @@ switch (cmd) {
|
||||
case 'lint-iterations':
|
||||
cmdLintIterations();
|
||||
break;
|
||||
case 'audit:visual':
|
||||
visualAudit(args, ROOT);
|
||||
break;
|
||||
case 'mcp':
|
||||
cmdMcp();
|
||||
break;
|
||||
default:
|
||||
console.log('usage: ua <index|list|search <kw>|info <name>|docs|tokens [--json]|style|sync-counts|verify|commit-lint|lint-iterations|changelog|mcp>');
|
||||
console.log('usage: ua <index|list|search <kw>|info <name>|docs|tokens [--json]|style|sync-counts|verify|commit-lint|lint-iterations|audit:visual [--json|--baseline]|changelog|mcp>');
|
||||
process.exit(cmd ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*** @ui-arch 视觉规则表:硬编码视觉字面量 → 设计令牌的唯一映射来源
|
||||
*
|
||||
* 为什么单独成模块:迁移脚本(scripts/visual-migrate.mjs)与审计门禁
|
||||
* (packages/tools/cli/lib/visual-audit.mjs)必须用同一张表,否则「迁移完还报漂移」
|
||||
* 或「门禁放过迁移漏掉的写法」这类派生语义不一致必然出现(CODING.md §5)。
|
||||
* 改规则只改这里;新增一类硬编码 = 加一条 KIND_META + 一个 detect 分支 + migrateDeclaration 分支。
|
||||
*/
|
||||
|
||||
/*** 皮肤包样式表相对仓库根的路径(迁移与审计共同的目标文件) ***/
|
||||
export const UI_CSS_REL = 'packages/skin/ui/ui.css';
|
||||
|
||||
/*** 作用:五类硬编码的键名、中文名与判定口径,审计输出与报告都按这个顺序 ***/
|
||||
export const KIND_META = [
|
||||
{
|
||||
key: 'font-size-literal',
|
||||
label: '字号字面值',
|
||||
hint: 'font-size 写死 px/em,应改用 --ua-font-size-*',
|
||||
},
|
||||
{
|
||||
key: 'duration-literal',
|
||||
label: '动效时长字面值',
|
||||
hint: 'transition/animation 写死时长,应改用 --ua-duration-*',
|
||||
},
|
||||
{
|
||||
key: 'shadow-literal',
|
||||
label: '阴影字面值',
|
||||
hint: 'box-shadow 里出现 px 长度,应整条改用 --ua-shadow-1..4',
|
||||
},
|
||||
{
|
||||
key: 'radius-literal',
|
||||
label: '圆角字面值',
|
||||
hint: 'border-radius 写死 px,应改用 --ua-radius-*',
|
||||
},
|
||||
{
|
||||
key: 'focus-ring-variant',
|
||||
label: '焦点环变体',
|
||||
hint: '自绘 0 0 0 Npx 色环,应改用 --ua-focus-ring / --ua-focus-ring-inset',
|
||||
},
|
||||
];
|
||||
|
||||
/*** 作用:需要扫描的 CSS 属性白名单(其余属性本轮不动;用白名单顺带避开伪类选择器误判) ***/
|
||||
const SCAN_PROPS = new Set([
|
||||
'font-size',
|
||||
'transition',
|
||||
'animation',
|
||||
'transition-duration',
|
||||
'animation-duration',
|
||||
'box-shadow',
|
||||
'border-radius',
|
||||
'outline',
|
||||
]);
|
||||
|
||||
/*** 作用:字号字面值 → 令牌(10/11 归并到 xs,避免 11px 单独成档) ***/
|
||||
const FONT_SIZE_TOKENS = new Map([
|
||||
['10px', 'var(--ua-font-size-xs)'],
|
||||
['11px', 'var(--ua-font-size-xs)'],
|
||||
['12px', 'var(--ua-font-size-sm)'],
|
||||
['13px', 'var(--ua-font-size-md)'],
|
||||
['14px', 'var(--ua-font-size-lg)'],
|
||||
['16px', 'var(--ua-font-size-xl)'],
|
||||
['18px', 'var(--ua-font-size-2xl)'],
|
||||
]);
|
||||
|
||||
/*** 作用:圆角字面值 → 令牌(1px 细线、50% 圆形、999px 胶囊是语义形状,不属漂移,保留) ***/
|
||||
const RADIUS_TOKENS = new Map([
|
||||
['4px', 'var(--ua-radius-sm)'],
|
||||
['10px', 'var(--ua-radius-md)'],
|
||||
]);
|
||||
|
||||
/*** 作用:圆角里视为语义化、不参与漂移统计的取值 ***/
|
||||
const RADIUS_LEGIT = new Set(['0', '1px', '50%', '999px', 'inherit']);
|
||||
|
||||
/*** 作用:纯黑投影的(位移:模糊:透明度)→ elevation 档位;表外的主题色投影留待后续按语义人工归并 ***/
|
||||
const ELEVATION_BY_SHAPE = new Map([
|
||||
['8:24:0.12', '2'],
|
||||
['8:24:0.14', '2'],
|
||||
['10:28:0.16', '3'],
|
||||
['12:28:0.18', '4'],
|
||||
['16:32:0.24', '4'],
|
||||
['10:40:0.2', '4'],
|
||||
['0:24:0.18', '2'],
|
||||
['0:40:0.22', '4'],
|
||||
]);
|
||||
|
||||
/*** 作用:动效时长三档的秒数上限(fast 取 0.15s,因为既有 111 处都是 0.15s,把离群值收进来不改变观感) ***/
|
||||
const DURATION_STEPS = [
|
||||
{ max: 0.17, token: 'var(--ua-duration-fast)' },
|
||||
{ max: 0.23, token: 'var(--ua-duration-base)' },
|
||||
{ max: 0.2501, token: 'var(--ua-duration-slow)' },
|
||||
];
|
||||
|
||||
/*** 作用:压缩空白,便于用稳定字符串比对(不改语义) ***/
|
||||
export function squash(value) {
|
||||
return String(value).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/*** 作用:把整段 CSS 的注释替换成等长空格,保持偏移与行号可对齐(迁移时按偏移回写原文) ***/
|
||||
export function blankComments(src) {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
if (src[i] === '/' && src[i + 1] === '*') {
|
||||
const close = src.indexOf('*/', i + 2);
|
||||
const stop = close === -1 ? src.length : close + 2; // 未闭合时一路吞到文件尾
|
||||
for (let k = i; k < stop; k++) out += src[k] === '\n' ? '\n' : ' ';
|
||||
i = stop;
|
||||
} else {
|
||||
out += src[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/*** 作用:逐条产出「属性 + 值」及其在原文中的偏移;只认白名单属性且必须以分号收尾(避开伪类选择器) ***/
|
||||
export function eachDeclaration(code) {
|
||||
const re = /(transition-duration|animation-duration|transition|animation|font-size|box-shadow|border-radius|outline)\s*:\s*([^;{}]*);/gi;
|
||||
const list = [];
|
||||
for (const m of code.matchAll(re)) {
|
||||
const prop = m[1].toLowerCase();
|
||||
if (!SCAN_PROPS.has(prop)) continue; // 白名单外的属性不进入迁移与统计
|
||||
const valueEnd = m.index + m[0].length - 1; // 指向结尾分号
|
||||
list.push({ prop, value: m[2], valueStart: valueEnd - m[2].length, valueEnd });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/*** 作用:拆出尾部的 !important,迁移只看核心值、尾部原样保留 ***/
|
||||
function splitImportant(raw) {
|
||||
const m = String(raw).match(/^(.*?)(\s*!important\s*)$/i);
|
||||
return m ? { core: m[1], tail: m[2] } : { core: raw, tail: '' };
|
||||
}
|
||||
|
||||
/*** 作用:按顶层逗号切分(跳过括号内的逗号,cubic-bezier 的入参不会被切开) ***/
|
||||
function splitTopLevel(value) {
|
||||
const parts = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const ch = value[i];
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(value.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(value.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
/*** 作用:单个时长字面值 → 档位令牌;超出三档上限的返回 null(如 0.3s / 1.6s 的加载动画节奏,保留原值) ***/
|
||||
function durationToken(text) {
|
||||
const m = String(text).match(/^(\d*\.?\d+)(ms|s)$/i);
|
||||
if (!m) return null;
|
||||
const sec = m[2].toLowerCase() === 'ms' ? Number(m[1]) / 1000 : Number(m[1]);
|
||||
for (const step of DURATION_STEPS) if (sec <= step.max) return step.token;
|
||||
return null;
|
||||
}
|
||||
|
||||
/*** 作用:判断一段动效值里是否已写了缓动(含令牌形式;写了就保留,不覆盖作者意图) ***/
|
||||
function hasTiming(value) {
|
||||
if (/var\(--ua-ease/.test(value)) return true;
|
||||
if (/(cubic-bezier|steps|linear)\(/.test(value)) return true; // 函数式缓动:参数里的逗号不是分段符
|
||||
return /(^|[\s,])(step-start|step-end|ease-in-out|ease-in|ease-out|ease|linear)(?=$|[\s,])/.test(value);
|
||||
}
|
||||
|
||||
/*** 作用:迁移单个动效声明值(逐段替换时长,缺缓动时补 --ua-ease);返回 null 表示无需改动 ***/
|
||||
function migrateMotion(prop, core) {
|
||||
const shorthand = prop === 'transition' || prop === 'animation'; // 只有简写能补缓动,-duration 属性补了是非法的
|
||||
const parts = splitTopLevel(core);
|
||||
let hits = 0;
|
||||
const next = parts.map((segment) => {
|
||||
let replaced = 0;
|
||||
let out = segment.replace(/(\d*\.?\d+)(ms|s)\b/gi, (token) => {
|
||||
const mapped = durationToken(token);
|
||||
if (!mapped) return token; // 表外时长保留,交给后续视觉条目单独评估
|
||||
replaced++;
|
||||
hits++;
|
||||
return mapped;
|
||||
});
|
||||
if (shorthand && replaced && !hasTiming(out)) out = `${out} var(--ua-ease)`; // 只补缺失的缓动,写过的保
|
||||
return out;
|
||||
});
|
||||
if (!hits) return null;
|
||||
return { kind: 'duration-literal', value: next.join(',') };
|
||||
}
|
||||
|
||||
/*** 作用:迁移单条 box-shadow:焦点环收敛成两个令牌,纯黑投影按 位移/模糊/透明度 归入 elevation 四档 ***/
|
||||
function migrateShadow(core) {
|
||||
const value = squash(core);
|
||||
// 焦点环:2/3px 的强调色描边环;5px 4% 这类是呼吸光晕,动效依赖两个不同状态,不并入令牌
|
||||
const ring = value.match(/^(inset )?0 0 0 ([23])px color-mix\(in srgb, var\(--ua-color-accent\) (\d+)%, transparent\)$/);
|
||||
if (ring && Number(ring[3]) >= 10) {
|
||||
return { kind: 'focus-ring-variant', value: ring[1] ? 'var(--ua-focus-ring-inset)' : 'var(--ua-focus-ring)' };
|
||||
}
|
||||
const offset = value.match(/^0 (\d+)px (\d+)px rgba\(0, 0, 0, ([\d.]+)\)$/); // 带位移的投影
|
||||
const glow = value.match(/^0 0 (\d+)px rgba\(0, 0, 0, ([\d.]+)\)$/); // 无位移的发光投影
|
||||
const hit = offset
|
||||
? ELEVATION_BY_SHAPE.get(`${offset[1]}:${offset[2]}:${offset[3]}`)
|
||||
: glow
|
||||
? ELEVATION_BY_SHAPE.get(`0:${glow[1]}:${glow[2]}`)
|
||||
: undefined;
|
||||
if (!hit) return null; // 主题色投影 / 状态色环 / 遮罩色块保留,由视觉条目逐条人工归并
|
||||
return { kind: 'shadow-literal', value: `var(--ua-shadow-${hit})` };
|
||||
}
|
||||
|
||||
/*** 作用:判定单条声明是否需要迁移,返回 { kind, value } 或 null(幂等:已是令牌就返回 null) ***/
|
||||
export function migrateDeclaration(prop, rawValue) {
|
||||
const { core, tail } = splitImportant(rawValue);
|
||||
let result = null;
|
||||
if (prop === 'font-size') {
|
||||
const token = FONT_SIZE_TOKENS.get(squash(core));
|
||||
if (token) result = { kind: 'font-size-literal', value: token };
|
||||
} else if (prop === 'border-radius') {
|
||||
const token = RADIUS_TOKENS.get(squash(core));
|
||||
if (token) result = { kind: 'radius-literal', value: token };
|
||||
} else if (prop === 'transition' || prop === 'animation' || prop === 'transition-duration' || prop === 'animation-duration') {
|
||||
result = migrateMotion(prop, core);
|
||||
} else if (prop === 'box-shadow') {
|
||||
result = migrateShadow(core);
|
||||
}
|
||||
if (!result) return null;
|
||||
return { kind: result.kind, value: result.value + tail };
|
||||
}
|
||||
|
||||
/*** 作用:统计单条声明在一个类别下的命中数(0 表示该类别下不算漂移);口径与迁移规则严格对应 ***/
|
||||
function detect(prop, rawValue, key) {
|
||||
const { core } = splitImportant(rawValue);
|
||||
const value = squash(core);
|
||||
if (key === 'font-size-literal') {
|
||||
if (prop !== 'font-size') return 0;
|
||||
if (/var\(/.test(value)) return 0; // 已走令牌(含 calc(var(...) * 系数))不算漂移
|
||||
return /\d+(px|em|rem)\b/.test(value) ? 1 : 0;
|
||||
}
|
||||
if (key === 'duration-literal') {
|
||||
if (!['transition', 'animation', 'transition-duration', 'animation-duration'].includes(prop)) return 0;
|
||||
const stripped = value.replace(/var\(--ua-duration-[\w-]+\)/g, ' '); // 已是令牌的时长先剔除
|
||||
return (stripped.match(/\b\d*\.?\d+(ms|s)\b/gi) ?? []).length;
|
||||
}
|
||||
if (key === 'shadow-literal') {
|
||||
if (prop !== 'box-shadow') return 0;
|
||||
if (value === 'none') return 0;
|
||||
if (/^0 0 0 (1[0-9]{2,}|[2-9][0-9]{2,})px /.test(value)) return 0; // 巨型扩散是遮罩色块(0 0 0 9999px),不是投影
|
||||
return /\b\d+(\.\d+)?px\b/.test(value) ? 1 : 0;
|
||||
}
|
||||
if (key === 'radius-literal') {
|
||||
if (prop !== 'border-radius') return 0;
|
||||
if (/^var\(/.test(value)) return 0;
|
||||
if (RADIUS_LEGIT.has(value)) return 0;
|
||||
return /\b\d+(\.\d+)?px\b/.test(value) ? 1 : 0;
|
||||
}
|
||||
if (key === 'focus-ring-variant') {
|
||||
// 口径:只统计 2-8px 的 box-shadow 自绘色环。outline 焦点描边是仓库既有的正统兜底写法
|
||||
// (:focus-visible 全局规则),算进去会让新组件照抄现有写法就被门禁拦住;
|
||||
// 1px 内框是边框语义(backlog 里另有边框条目),与页面底色断开的环是几何技巧,都不入此类别。
|
||||
if (prop !== 'box-shadow') return 0;
|
||||
if (value === 'var(--ua-focus-ring)' || value === 'var(--ua-focus-ring-inset)') return 0; // 已统一,不算变体
|
||||
if (!/^(inset )?0 0 0 [2-8]px \S/.test(value)) return 0;
|
||||
return /var\(--ua-color-(accent|warning|danger)/.test(value) ? 1 : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*** 作用:扫描整段 CSS,返回五类硬编码的计数与命中行号(审计命令的唯一统计口径) ***/
|
||||
export function scanSource(src) {
|
||||
const code = blankComments(src);
|
||||
const lineAt = (offset) => {
|
||||
let line = 1;
|
||||
for (let i = 0; i < offset && i < code.length; i++) if (code[i] === '\n') line++;
|
||||
return line;
|
||||
};
|
||||
const counts = {};
|
||||
const hits = {};
|
||||
for (const meta of KIND_META) {
|
||||
counts[meta.key] = 0;
|
||||
hits[meta.key] = [];
|
||||
}
|
||||
for (const decl of eachDeclaration(code)) {
|
||||
for (const meta of KIND_META) {
|
||||
const n = detect(decl.prop, decl.value, meta.key);
|
||||
if (!n) continue;
|
||||
counts[meta.key] += n;
|
||||
hits[meta.key].push({ line: lineAt(decl.valueStart), prop: decl.prop, value: squash(decl.value) });
|
||||
}
|
||||
}
|
||||
return { counts, hits };
|
||||
}
|
||||
|
||||
/*** 作用:迁移整段 CSS,返回改写后的文本与每类替换处数(幂等:无字面量时 text 与入参完全一致) ***/
|
||||
export function migrateSource(src) {
|
||||
const code = blankComments(src);
|
||||
const counts = {};
|
||||
for (const meta of KIND_META) counts[meta.key] = 0;
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
for (const decl of eachDeclaration(code)) {
|
||||
const replaced = migrateDeclaration(decl.prop, src.slice(decl.valueStart, decl.valueEnd));
|
||||
if (!replaced) continue;
|
||||
out += src.slice(cursor, decl.valueStart) + replaced.value;
|
||||
cursor = decl.valueEnd;
|
||||
counts[replaced.kind] += 1;
|
||||
}
|
||||
out += src.slice(cursor);
|
||||
return { text: out, counts };
|
||||
}
|
||||
|
||||
/*** 作用:把五类计数拍平成「类别: 数量」数组,供审计报告与基线文件共用同一顺序 ***/
|
||||
export function countRows(counts) {
|
||||
return KIND_META.map((meta) => ({ ...meta, count: counts[meta.key] ?? 0 }));
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"ua style:注释规范门禁(导出必须 /*** ***/、接口字段行尾注释、行注释中文),违规退出码 1",
|
||||
"ua verify:清单一致性门禁(id 唯一 / exports 对应 / props 必带 n t d / steps≥3 / demo 锚点存在)",
|
||||
"ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1",
|
||||
"ua audit:visual [--json|--baseline]:视觉漂移门禁,统计皮肤包里五类硬编码视觉字面量(字号/动效时长/阴影/圆角/焦点环)并与 .ua/visual-baseline.json 棘轮比对,超基线退出码 1;--baseline 重写基线,--json 出机器可读计数",
|
||||
"ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe"
|
||||
],
|
||||
"keyTypes": [
|
||||
@@ -26,7 +27,7 @@
|
||||
"MCP tool 入参 schema"
|
||||
]
|
||||
},
|
||||
"usage": "node packages/tools/cli/bin/ua.js <index|list|info <包名|组件id>|docs|tokens|style|verify|commit-lint|mcp>;仓库内等价于 npm run index / docs / style / verify",
|
||||
"usage": "node packages/tools/cli/bin/ua.js <index|list|info <包名|组件id>|docs|tokens|style|verify|commit-lint|audit:visual|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual",
|
||||
"conventions": [
|
||||
"零依赖:只用 node 标准库,Node >= 18",
|
||||
"清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生",
|
||||
@@ -35,11 +36,12 @@
|
||||
"docs": {
|
||||
"intro": "工程层工具包(private,不发布)。所有 Agent 与人都通过它读取组件库:清单驱动索引、文档、MCP 三件套,外加 style / verify 两道门禁。",
|
||||
"steps": [
|
||||
"装好依赖后先跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify)",
|
||||
"装好依赖后先跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual)",
|
||||
"查组件:ua list 看全量,ua search <关键词> 按 id/标题/参数名/参数说明/步骤模糊找(「处理中」这类说明里的词也能搜到,命中非 id 标题时会标出命中字段;拼错会回相近 id),ua info <组件id> 看 props/步骤/示例(ua info <包名> 看单包 api 与约定,加 --json 出原始清单)",
|
||||
"查配方:ua recipes 列全量(编号 / 标题 / 用到的组件),ua recipes <编号> 看骨架代码与要点(--json 给脚本用;MCP 里对应 list_recipes / get_recipe)",
|
||||
"查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码",
|
||||
"查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码。令牌分十组:颜色 / 圆角 / 间距 / 字号 / 字重行高 / 边框 / 阴影 / 动效 / 焦点环 / 控件高度 / 层级,弹层阴影用 --ua-shadow-1..4,过渡用 --ua-duration-* + --ua-ease,焦点环用 --ua-focus-ring(_inset)",
|
||||
"改完清单跑 npm run docs:同时刷新人类文档站与 llms-full.txt(Agent 首选喂料)",
|
||||
"改完样式跑 npm run audit:visual:五类硬编码计数不得高于 .ua/visual-baseline.json(棘轮只减不增);字面量可用 npm run visual:migrate 幂等改写成令牌",
|
||||
"在 ~/.workbuddy/mcp.json 注册 ua mcp 后,Agent 可直接调 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe"
|
||||
],
|
||||
"components": []
|
||||
|
||||
Reference in New Issue
Block a user