- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注 skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets 等复用桩。 - 集成层:config/test/jest.integration.config.js + tests-integration/ 真库 一次性 SQLite 用例,teardown 守卫开发库指纹。 - 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、 check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse 配置收敛。
240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
#!/usr/bin/env tsx
|
||
|
||
/**
|
||
* 品牌红「文字通道」契约 + 「透明度修饰符打在裸 var() 任意值上」死样式检查
|
||
*
|
||
* 本脚本守护两条机械可判定的规则(npm run check:brand-token,已并入 check:a11y):
|
||
*
|
||
* ── 规则 A:DESIGN.md 的 The Two-Channel Red Rule(DESIGN.md:154 / DESIGN.md:232)
|
||
* 底色用 `--color-brand`(暗黑不翻转),文字用 `--color-brand-ink`(暗黑翻至 #F87171)。
|
||
* 把 `--color-brand` 当文字色用是契约违反:亮色下 #FFFFFF 上 5.84:1(侥幸过 AA),
|
||
* 暗色下 --color-bg-primary (#0A0E14) 仅 3.31:1、--color-brand-bg (#2A1418) 仅 2.97:1,
|
||
* 均低于小字 WCAG AA 的 4.5:1 —— 该失败已由 axe(Playwright 移动 + Lighthouse 桌面)
|
||
* 双引擎在全站 62 页实测复现(ACCEPTANCE_REVIEW_2026-09-21 A-12 / R-3)。
|
||
*
|
||
* ── 规则 B:Tailwind 无法对「任意值形式的裸 var()」套用 /NN 透明度修饰符
|
||
* `bg-[var(--color-bg-section)]` 再加 `/60`、`border-[var(--color-border-primary)]` 加 `/50`、
|
||
* `group-hover:bg-[var(--color-brand-bg)]` 加 `/30` 这类写法 **编译后不生成任何 CSS 规则**
|
||
* (已用 `npx tailwindcss -i ./src/app/globals.css -o /tmp/x.css` 实测:0 条选择器;
|
||
* 对照 `.bg-bg-section\/60`、`.border-border-primary\/50` 各 1 条,正常生成)。
|
||
* 它同时满足三个特征:不报错、不进入产物、Code Review 里看起来完全合法 —— 即「静默死样式」。
|
||
* 正确写法:为该色建 `-rgb` 三元组 token(:root 与 html[data-theme='dark'] 都声明),
|
||
* 并在 tailwind.config.js 用 `rgb(var(--color-x-rgb) / <alpha-value>)` 注册,
|
||
* 然后写 token class:`bg-bg-section/60`、`border-border-primary/50`、`bg-brand-bg/30`。
|
||
* 注:不带 alpha 修饰符的 `bg-[var(--color-x)]` 是可正常编译的(本脚本不报),
|
||
* 规则 B 只锁定 `]/<alpha>` 这一形态。
|
||
*
|
||
* 违规判定见 FORBIDDEN_RULES;合法写法示例:`text-brand-ink`、`bg-brand/10`、`bg-bg-primary/80`。
|
||
*/
|
||
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
|
||
/** 扫描根目录(相对仓库根)与纳入扫描的源码扩展名 */
|
||
const SCAN_ROOT = 'src';
|
||
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.mdx'];
|
||
|
||
interface ForbiddenRule {
|
||
id: 'A' | 'B';
|
||
/** 人类可读的规则名 */
|
||
title: string;
|
||
pattern: RegExp;
|
||
/** 由命中 token 生成修复建议 */
|
||
suggestion: (token: string) => string;
|
||
/** 命中时的补充说明 */
|
||
footnote?: string;
|
||
}
|
||
|
||
/**
|
||
* 规则 A —— 只锁定「文字通道误用」:`--color-brand` 后必须紧跟 `)]` 才算命中,
|
||
* 因此 `--color-brand-ink` / `--color-brand-bg` / `--color-brand-hover` 不会误报;
|
||
* `bg-` / `border-` / `fill-` / `stroke-` / `ring-` 等底色通道也不在匹配范围内。
|
||
*
|
||
* 规则 B —— 只锁定「颜色类工具 + 裸 var() 任意值 + 透明度修饰符」。
|
||
* 关键约束(避免误伤可正常编译的写法):
|
||
* 1. `\[var\(` 紧跟工具名前缀 —— 排除 `bg-[rgba(var(--color-brand-rgb),0.2)]`(合法,有实际色值);
|
||
* 2. 必须命中 `/\d` 或 `/[…]` —— 排除 `bg-[var(--color-brand-bg)]`(合法,无修饰符);
|
||
* 3. token 名限 `--color-*` 族 —— 排除 `w-[var(--spacing-md)]/2` 之类非颜色任意值;
|
||
* 4. 允许多级 variant 前缀(`hover:` / `group-hover:` / `sm:` / `dark:` …)。
|
||
* fill / stroke 与已列十个工具同属 Tailwind 颜色工具族、同样会静默失效,故一并纳入。
|
||
*/
|
||
const ALPHA_SUFFIX = String.raw`(?:\/(?:\[[^\]]*\]|[0-9]+(?:\.[0-9]+)?))`;
|
||
const VARIANT_PREFIX = String.raw`(?:[a-zA-Z0-9_-]+:)*`;
|
||
|
||
const COLOR_UTILITY = 'bg|border|from|via|to|shadow|text|ring|divide|placeholder|fill|stroke';
|
||
|
||
const FORBIDDEN_RULES: ForbiddenRule[] = [
|
||
{
|
||
id: 'A',
|
||
title: 'Two-Channel Red Rule:文字通道禁用 --color-brand',
|
||
pattern: new RegExp(
|
||
String.raw`(?:[a-zA-Z0-9:_-]+:)?text-\[var\(--color-brand\)\](?:\/(?:\[[^\]]*\]|[0-9.]+))?`,
|
||
'g',
|
||
),
|
||
suggestion: (token) => {
|
||
const prefix = token.slice(0, token.indexOf('text-'));
|
||
return token.includes(']/')
|
||
? `${prefix}text-brand-ink/${token.split(']/')[1]}`
|
||
: `${prefix}text-brand-ink`;
|
||
},
|
||
footnote: '规则出处: DESIGN.md:154 / DESIGN.md:232。带 alpha 的 text-[var(--color-brand)]/NN 同时是死样式(编译后 0 条规则)。',
|
||
},
|
||
{
|
||
id: 'B',
|
||
title: '死样式:Tailwind 不能对裸 var() 任意值套用透明度修饰符',
|
||
pattern: new RegExp(
|
||
String.raw`(${VARIANT_PREFIX})(?:${COLOR_UTILITY})-\[var\(--color-[^\]\s]*\)\]${ALPHA_SUFFIX}`,
|
||
'g',
|
||
),
|
||
suggestion: (token) => {
|
||
const [, utility] = token.match(
|
||
new RegExp(String.raw`(?:${VARIANT_PREFIX})((?:${COLOR_UTILITY})-)`),
|
||
) ?? [];
|
||
const varName = token.match(/\[var\(--color-([^\]]+)\)\]/)?.[1];
|
||
const alpha = token.split(']/')[1];
|
||
if (!utility || !varName) return '<改用对应 token class>/<alpha>';
|
||
const prefix = token.slice(0, token.indexOf(utility));
|
||
// --color-bg-section -> bg-bg-section;--color-brand-bg -> bg-brand-bg;--color-ink -> text-ink
|
||
const colorKey = varName.replace(/^color-/, '');
|
||
return `${prefix}${utility}${colorKey}${alpha ? `/${alpha}` : ''}`;
|
||
},
|
||
footnote:
|
||
'改为 token class:先补 <name>-rgb 三元组(:root 与 html[data-theme=\'dark\'] 两版),' +
|
||
'再在 tailwind.config.js 注册 rgb(var(--color-<name>-rgb) / <alpha-value>),最后写 <util>-<color>/<alpha>。',
|
||
},
|
||
];
|
||
|
||
/**
|
||
* 已知死样式、但由并发清理任务负责下线的文件 —— 降级为 warning 而非 failure。
|
||
* 目的不是掩盖,而是让门禁在「他人正在删除」的窗口期仍可退出 0;
|
||
* 一旦文件仍存在,下方会打印待办修复;一旦文件消失,会提示从本清单移除,避免清单腐烂。
|
||
*/
|
||
const PENDING_CLEANUP_ALLOWLIST: Array<{ file: string; reason: string; fix: string }> = [
|
||
{
|
||
file: 'src/components/ui/loading-state.tsx',
|
||
reason: '无页面引用(仅自身测试导入),且 loading 组件族正由另一并发任务删除(loading-skeleton.tsx 已删除)',
|
||
fix: 'L137 `bg-[var(--color-bg-primary)]/80` → `bg-bg-primary/80`(token 已存在,无需新增)',
|
||
},
|
||
];
|
||
|
||
interface Violation {
|
||
file: string;
|
||
line: number;
|
||
token: string;
|
||
rule: ForbiddenRule;
|
||
suppressed: boolean;
|
||
}
|
||
|
||
function parseArgs(): string {
|
||
const args = process.argv.slice(2);
|
||
const dirIndex = args.indexOf('--dir');
|
||
if (dirIndex !== -1 && args[dirIndex + 1]) {
|
||
return args[dirIndex + 1]!;
|
||
}
|
||
return SCAN_ROOT;
|
||
}
|
||
|
||
function collectSourceFiles(absoluteDir: string, accumulator: string[]): void {
|
||
for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) {
|
||
const entryPath = path.join(absoluteDir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
collectSourceFiles(entryPath, accumulator);
|
||
continue;
|
||
}
|
||
if (SOURCE_EXTENSIONS.includes(path.extname(entry.name))) {
|
||
accumulator.push(entryPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
function isAllowlisted(relativeFile: string): boolean {
|
||
return PENDING_CLEANUP_ALLOWLIST.some((entry) => entry.file === relativeFile);
|
||
}
|
||
|
||
function scanFile(absoluteFile: string, repoRoot: string): Violation[] {
|
||
const violations: Violation[] = [];
|
||
const relativeFile = path.relative(repoRoot, absoluteFile);
|
||
const suppressed = isAllowlisted(relativeFile);
|
||
const lines = fs.readFileSync(absoluteFile, 'utf8').split(/\r?\n/);
|
||
|
||
lines.forEach((line, index) => {
|
||
const seen = new Set<string>();
|
||
for (const rule of FORBIDDEN_RULES) {
|
||
rule.pattern.lastIndex = 0;
|
||
for (const match of line.matchAll(rule.pattern)) {
|
||
const token = match[0]!;
|
||
// 规则 A 已覆盖的 token 不再由规则 B 重复计数(两者命中同一处写法)
|
||
const coveredByEarlierRule = [...seen].some((previous) => previous.includes(token));
|
||
if (coveredByEarlierRule) continue;
|
||
seen.add(token);
|
||
violations.push({ file: relativeFile, line: index + 1, token, rule, suppressed });
|
||
}
|
||
}
|
||
});
|
||
|
||
return violations;
|
||
}
|
||
|
||
function main(): void {
|
||
const repoRoot = path.resolve(__dirname, '../..');
|
||
// resolve(而非 join)以同时支持 --dir 传入相对路径与绝对路径(如临时对照夹具目录)
|
||
const scanRoot = path.resolve(repoRoot, parseArgs());
|
||
|
||
console.log('\n🔴 品牌红双通道契约 + 裸 var() 透明度死样式 检查\n');
|
||
|
||
if (!fs.existsSync(scanRoot)) {
|
||
console.error(`❌ 扫描目录不存在: ${path.relative(repoRoot, scanRoot)}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const files: string[] = [];
|
||
collectSourceFiles(scanRoot, files);
|
||
files.sort();
|
||
|
||
const violations: Violation[] = [];
|
||
for (const file of files) {
|
||
violations.push(...scanFile(file, repoRoot));
|
||
}
|
||
|
||
const blocking = violations.filter((v) => !v.suppressed);
|
||
const suppressed = violations.filter((v) => v.suppressed);
|
||
|
||
for (const violation of blocking) {
|
||
console.log(`❌ [规则 ${violation.rule.id}] ${violation.file}:${violation.line} ${violation.token}`);
|
||
console.log(` 改为 ${violation.rule.suggestion(violation.token)}`);
|
||
}
|
||
|
||
for (const entry of PENDING_CLEANUP_ALLOWLIST) {
|
||
const absolute = path.join(repoRoot, entry.file);
|
||
if (!fs.existsSync(absolute)) {
|
||
console.log(`ℹ️ 白名单条目已失效(文件不存在,请从本脚本移除): ${entry.file}`);
|
||
continue;
|
||
}
|
||
if (suppressed.some((v) => v.file === entry.file)) {
|
||
console.log(`\n⚠️ [已降级为 warning] ${entry.file} —— ${entry.reason}`);
|
||
console.log(` 待办修复: ${entry.fix}`);
|
||
}
|
||
}
|
||
|
||
console.log('\n📊 检查摘要');
|
||
console.log(` 扫描目录: ${path.relative(repoRoot, scanRoot)}`);
|
||
console.log(` 扫描文件: ${files.length}`);
|
||
console.log(` 违规处数: ${blocking.length}(另有 ${suppressed.length} 处在清理白名单内,已降级)`);
|
||
|
||
if (blocking.length > 0) {
|
||
const ruleIds = [...new Set(blocking.map((v) => v.rule.id))].sort();
|
||
for (const id of ruleIds) {
|
||
const rule = FORBIDDEN_RULES.find((r) => r.id === id)!;
|
||
console.log(`\n⚠️ 规则 ${id}: ${rule.title}`);
|
||
console.log(` ${rule.footnote ?? ''}`);
|
||
}
|
||
console.log('\n❌ 存在契约违反或静默死样式!');
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log('\n✅ 文字通道使用 --color-brand-ink,且无「裸 var() + 透明度」死样式!');
|
||
process.exit(0);
|
||
}
|
||
|
||
main();
|