Files
novalon-website/scripts/utils/check-brand-text-token.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- 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
  配置收敛。
2026-09-28 10:48:08 +08:00

240 lines
10 KiB
TypeScript
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.
#!/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();