Files
UI/packages/tools/cli/lib/visual-rules.mjs
T

638 lines
26 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 视觉规则表:硬编码视觉字面量 → 设计令牌的唯一映射来源
*
* 为什么单独成模块:迁移脚本(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',
},
{
key: 'space-literal',
label: '内距字面值',
hint: 'padding/gap/margin 写死 px,应改用 --ua-space-*',
},
{
key: 'control-h-literal',
label: '控件高度字面值',
hint: 'height 写死控件档位值(28/34/40px),应改用 --ua-control-h-*',
},
{
key: 'weight-literal',
label: '字重字面值',
hint: 'font-weight 写死数字,应改用 --ua-weight-*',
},
{
key: 'leading-literal',
label: '行高字面值',
hint: 'line-height 写死数值,应改用 --ua-leading-*(px 行高是图形对齐技巧,不入此口径)',
},
{
key: 'z-literal',
label: '层级字面值',
hint: 'z-index 写死数字,应改用 --ua-z-* 五档(组件内 1/2 微排序豁免)',
},
{
key: 'border-literal',
label: '边框色字面值',
hint: 'border/border-color 写死色值(rgba()/#hex),组件轮廓用 --ua-color-border、分割线用 --ua-border-subtle',
},
{
key: 'mix-literal',
label: '混色浓度字面值',
hint: 'color-mix 的混入浓度写死百分比,应改用 --ua-alpha-* 六档(soft/tint/line/mid/strong/heavy)',
},
];
/*** 作用:需要扫描的 CSS 属性白名单(其余属性本轮不动;用白名单顺带避开伪类选择器误判) ***/
const SCAN_PROPS = new Set([
'background-image',
'background',
'color',
'fill',
'stroke',
'font-size',
'transition',
'animation',
'transition-duration',
'animation-duration',
'box-shadow',
'border-radius',
'outline',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'gap',
'row-gap',
'column-gap',
'min-height',
'max-height',
'height',
'font-weight',
'line-height',
'z-index',
'border',
'border-top',
'border-right',
'border-bottom',
'border-left',
'border-color',
'border-top-color',
'border-right-color',
'border-bottom-color',
'border-left-color',
]);
/*** 作用:边框类属性集合(轮廓与分割线共用一套色值口径,禁用字面色) ***/
const BORDER_PROPS = new Set([
'border',
'border-top',
'border-right',
'border-bottom',
'border-left',
'border-color',
'border-top-color',
'border-right-color',
'border-bottom-color',
'border-left-color',
]);
/*** 作用:内距类属性集合(迁移与统计共用同一判定) ***/
const SPACE_PROPS = new Set([
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'gap',
'row-gap',
'column-gap',
]);
/*** 作用:内距 px 字面值 → 令牌(半步档位 05/15/25/35 与既有 1..4 组成完整梯子;1px 是边框语义不入表) ***/
const SPACE_TOKENS = new Map([
['2px', 'var(--ua-space-05)'],
['4px', 'var(--ua-space-1)'],
['6px', 'var(--ua-space-15)'],
['8px', 'var(--ua-space-2)'],
['10px', 'var(--ua-space-25)'],
['12px', 'var(--ua-space-3)'],
['14px', 'var(--ua-space-35)'],
['16px', 'var(--ua-space-4)'],
]);
/*** 作用:字号字面值 → 令牌(10/11 归并到 xs;22/26/34 是展示档;0.92em/0.85em 归相对缩放档) ***/
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)'],
['22px', 'var(--ua-font-size-3xl)'],
['26px', 'var(--ua-font-size-4xl)'],
['34px', 'var(--ua-font-size-5xl)'],
['0.92em', 'var(--ua-font-size-scale-md)'],
['0.85em', 'var(--ua-font-size-scale-sm)'],
]);
/*** 作用:圆角字面值 → 令牌(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)' },
{ max: 0.36, token: 'var(--ua-duration-slower)' },
];
/*** 作用:循环动效(infinite)的时长 → 循环四档;0.7/0.8 收敛到中档、1.2/1.4 收敛到慢档,表外保留 ***/
const LOOP_DURATIONS = new Map([
['0.6s', 'var(--ua-duration-loop-fast)'],
['0.7s', 'var(--ua-duration-loop-mid)'],
['0.8s', 'var(--ua-duration-loop-mid)'],
['1.2s', 'var(--ua-duration-loop-slow)'],
['1.4s', 'var(--ua-duration-loop-slow)'],
['1.6s', 'var(--ua-duration-loop-long)'],
]);
/*** 作用:控件高度三档的字面值 → 令牌(28/34/40px 三个值保留给控件高度档位,其余尺寸不入此口径) ***/
const CONTROL_H_TOKENS = new Map([
['28px', 'var(--ua-control-h-sm)'],
['34px', 'var(--ua-control-h-md)'],
['40px', 'var(--ua-control-h-lg)'],
]);
/*** 作用:字重字面值 → 令牌(normal/medium/semibold/bold 四档覆盖常见取值) ***/
const WEIGHT_TOKENS = new Map([
['400', 'var(--ua-weight-normal)'],
['500', 'var(--ua-weight-medium)'],
['600', 'var(--ua-weight-semibold)'],
['700', 'var(--ua-weight-bold)'],
]);
/*** 作用:行高字面值 → 令牌(unitless 归 none/tight/normal 三档,就近收敛;px 行高是图形对齐不入表) ***/
const LEADING_TOKENS = new Map([
['1', 'var(--ua-leading-none)'],
['1.2', 'var(--ua-leading-none)'],
['1.35', 'var(--ua-leading-tight)'],
['1.4', 'var(--ua-leading-tight)'],
['1.5', 'var(--ua-leading-normal)'],
['1.55', 'var(--ua-leading-normal)'],
['1.6', 'var(--ua-leading-normal)'],
['1.65', 'var(--ua-leading-normal)'],
]);
/*** 作用:下拉类 z-index 字面值集合(历史上下拉/气泡弹层用的 20/30/40 档,统一归 --ua-z-dropdown) ***/
const Z_DROPDOWN_LIKE = new Set(['20', '30', '40']);
/*** 作用:混色浓度字面值 → --ua-alpha-* 档位(近档就近收敛:12→10、16/20/24→18、40→45、65/80→70,Δ≤6) ***/
const MIX_TOKENS = new Map([
['10', 'var(--ua-alpha-soft)'],
['12', 'var(--ua-alpha-soft)'],
['16', 'var(--ua-alpha-tint)'],
['18', 'var(--ua-alpha-tint)'],
['20', 'var(--ua-alpha-tint)'],
['24', 'var(--ua-alpha-tint)'],
['30', 'var(--ua-alpha-line)'],
['40', 'var(--ua-alpha-mid)'],
['45', 'var(--ua-alpha-mid)'],
['55', 'var(--ua-alpha-strong)'],
['65', 'var(--ua-alpha-heavy)'],
['70', 'var(--ua-alpha-heavy)'],
['80', 'var(--ua-alpha-heavy)'],
]);
/*** 作用:提取值里所有 color-mix(...) 块(配平括号,嵌套 color-mix 逐层都算一条);返回全块文本与首色参(in srgb 之后的第一个顶层参数) ***/
function extractMixes(value) {
const out = [];
const src = String(value);
for (let i = src.indexOf('color-mix('); i !== -1; i = src.indexOf('color-mix(', i + 1)) {
let depth = 0;
let end = -1;
for (let k = i + 'color-mix'.length; k < src.length; k++) { // 从函数名后的 ( 起配平
if (src[k] === '(') depth++;
else if (src[k] === ')') { depth--; if (!depth) { end = k; break; } }
}
if (end === -1) break; // 未闭合:整值残缺,交给浏览器报错
const block = src.slice(i, end + 1);
const inner = block.slice('color-mix('.length, -1); // 去掉外壳与收尾括号
const args = splitTopLevel(inner).map((s) => s.trim());
out.push({ block, start: i, arg1: args[1] ?? '' }); // args[0] 是插值方法(in srgb),首色参跟在其后
}
return out;
}
/*** 作用:首色参末尾的混入浓度:已是 --ua-alpha-* 令牌返回 null;写死百分比返回数字字符串(渐变停靠位不在 color-mix 首参末尾,天然豁免) ***/
function mixAmount(arg1) {
const s = String(arg1).trim();
if (/var\(--ua-alpha-[\w-]+\)\s*$/.test(s)) return null; // 已令牌化
const lit = s.match(/(\d+(?:\.\d+)?)%\s*$/);
return lit ? lit[1] : null;
}
/*** 作用:迁移单条声明里的所有 color-mix 浓度字面量:逐块把首色参尾部的 NN% 换成 --ua-alpha-* 档位(表外浓度保留交人工归档) ***/
function migrateMix(core) {
const src = String(core);
const mixes = extractMixes(src);
if (!mixes.length) return null;
let hits = 0;
let out = '';
let cursor = 0;
for (const { block, start, arg1 } of mixes) {
const amt = mixAmount(arg1);
const token = amt !== null ? MIX_TOKENS.get(amt) : null;
if (!token) continue;
const nextArg1 = arg1 ? arg1.replace(/(\d+(?:\.\d+)?)%\s*$/, token) : arg1; // 复用原有的「色 浓度」间隔空格
const nextBlock = nextArg1 === arg1 || !arg1 ? block : block.replace(arg1, nextArg1); // arg1 是 block 的精确子串,整串替换安全
hits++;
out += src.slice(cursor, start) + nextBlock;
cursor = start + block.length;
}
if (!hits) return null;
out += src.slice(cursor);
return { kind: 'mix-literal', value: out };
}
/*** 作用:压缩空白,便于用稳定字符串比对(不改语义) ***/
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 = /(border-top-color|border-right-color|border-bottom-color|border-left-color|border-color|border-top|border-right|border-bottom|border-left|border|padding-top|padding-right|padding-bottom|padding-left|padding|margin-top|margin-right|margin-bottom|margin-left|margin|row-gap|column-gap|gap|font-weight|line-height|z-index|min-height|max-height|height|transition-duration|animation-duration|transition|animation|font-size|box-shadow|border-radius|outline|background-image|background|color|fill|stroke)\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 loop = /infinite/.test(core) && (prop === 'animation' || 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 = loop ? LOOP_DURATIONS.get(token.toLowerCase()) ?? null : 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})` };
}
/*** 作用:迁移单条内距声明:逐个 px 记号查 SPACE_TOKENS 替换(负值与 calc 内不算,返回 null 表示无需改动) ***/
function migrateSpace(core) {
let hits = 0;
const next = String(core).replace(/(^|[\s(,])(-?\d+(?:\.\d+)?)px\b/gi, (full, lead, num) => {
if (String(num).startsWith('-')) return full; // 负外距是偏移语义,不入令牌
const mapped = SPACE_TOKENS.get(`${num}px`);
if (!mapped) return full; // 表外取值(3px / 5px / 22px 等)留给后续条目评估
hits++;
return `${lead}${mapped}`;
});
if (!hits) return null;
return { kind: 'space-literal', value: next };
}
/*** 作用:迁移单条控件高度:整值命中三档即换令牌(其余 height 值不动),返回 null 表示无需改动 ***/
function migrateControlH(core) {
const token = CONTROL_H_TOKENS.get(squash(core));
if (!token) return null;
return { kind: 'control-h-literal', value: token };
}
/*** 作用:迁移单条字重:整值命中四档即换令牌 ***/
function migrateWeight(core) {
const token = WEIGHT_TOKENS.get(squash(core));
if (!token) return null;
return { kind: 'weight-literal', value: token };
}
/*** 作用:迁移单条行高:unitless 数值命中映射表即换令牌(px 与表外值不动) ***/
function migrateLeading(core) {
const token = LEADING_TOKENS.get(squash(core));
if (!token) return null;
return { kind: 'leading-literal', value: token };
}
/*** 作用:迁移单条层级:下拉类常见字面值(20/30/40)归 dropdown 档;其余取值层级语义需按选择器人工归档,留给门禁拦截 ***/
function migrateZ(core) {
const token = Z_DROPDOWN_LIKE.has(squash(core)) ? 'var(--ua-z-dropdown)' : null;
if (!token) return null;
return { kind: 'z-literal', value: token };
}
/*** 作用:迁移单条边框:纯黑 rgba 边(历史上手写的「深一层黑边」)归到基础边框色;其余留在门禁里由人按轮廓/分割线语义定位 ***/
function migrateBorder(prop, core) {
if (!BORDER_PROPS.has(prop)) return null;
const value = squash(core);
const next = value.replace(/rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*[\d.]+\s*\)/g, 'var(--ua-color-border)'); // 空格宽容,避免只认一种写法
if (next === value) return null;
return { kind: 'border-literal', value: next };
}
/*** 作用:判定单条声明是否需要迁移,返回 { 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);
} else if (SPACE_PROPS.has(prop)) {
result = migrateSpace(core);
} else if (prop === 'height') {
result = migrateControlH(core);
} else if (prop === 'font-weight') {
result = migrateWeight(core);
} else if (prop === 'line-height') {
result = migrateLeading(core);
} else if (prop === 'z-index') {
result = migrateZ(core);
} else {
result = migrateMix(core) ?? migrateBorder(prop, core); // 其余属性:先收敛 color-mix 浓度,再收敛纯黑 rgba 边
}
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;
}
if (key === 'space-literal') {
if (!SPACE_PROPS.has(prop)) return 0;
// 口径:数非负、非 1px 的 px 记号(1px 是边框/分隔线语义,负外距是偏移语义,都不算漂移)
const tokens = String(value).match(/(^|[\s(,])(\d+(?:\.\d+)?)px\b/gi) ?? [];
return tokens.filter((t) => !/(^|[\s(,])1px$/i.test(t)).length;
}
if (key === 'control-h-literal') {
if (prop !== 'height') return 0; // min/max-height 是约束不是控件档位,不入此口径
return CONTROL_H_TOKENS.has(squash(core)) ? 1 : 0;
}
if (key === 'weight-literal') {
if (prop !== 'font-weight') return 0;
if (/^var\(/.test(value)) return 0;
return /\d+/.test(value) ? 1 : 0; // normal/bold 关键字与数值都算漂移,一律走令牌
}
if (key === 'leading-literal') {
if (prop !== 'line-height') return 0;
if (/^var\(/.test(value)) return 0;
if (/px|em|rem|%|calc/.test(value)) return 0; // px 行高是图形对齐技巧(图标 / 角标),不入此口径
return /^\d+(\.\d+)?$/.test(value) ? 1 : 0; // unitless 数值归三档令牌
}
if (key === 'z-literal') {
if (prop !== 'z-index') return 0;
if (/^var\(/.test(value)) return 0;
if (value === '1' || value === '2') return 0; // 组件内微排序(表头压行、水印叠层、头像悬停),不是全局层级
return /^\d+$/.test(value) ? 1 : 0;
}
if (key === 'border-literal') {
if (!BORDER_PROPS.has(prop)) return 0;
if (value === 'none' || value === '0' || value === 'inherit') return 0; // 无边框与继承不算色值漂移
const stripped = value.replace(/var\(--ua-[\w-]+\)/g, ' '); // 已是令牌的色先剔除
return /(#[0-9a-f]{3,8}\b|rgba?\(|hsla?\()/i.test(stripped) ? 1 : 0;
}
if (key === 'mix-literal') {
return extractMixes(value).filter(({ arg1 }) => mixAmount(arg1) !== null).length; // 只认 color-mix 块内首色参的写死浓度;渐变停靠位与几何百分比天然不在口径内
}
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 }));
}