#!/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) / )` 注册, * 然后写 token class:`bg-bg-section/60`、`border-border-primary/50`、`bg-brand-bg/30`。 * 注:不带 alpha 修饰符的 `bg-[var(--color-x)]` 是可正常编译的(本脚本不报), * 规则 B 只锁定 `]/` 这一形态。 * * 违规判定见 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>/'; 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:先补 -rgb 三元组(:root 与 html[data-theme=\'dark\'] 两版),' + '再在 tailwind.config.js 注册 rgb(var(--color--rgb) / ),最后写 -/。', }, ]; /** * 已知死样式、但由并发清理任务负责下线的文件 —— 降级为 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(); 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();