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 配置收敛。
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* `scripts/utils/check-motion-constraints.ts` 的回归测试。
|
||||
*
|
||||
* 这个门禁的存在理由(N-29 + N-22/N-24/A-6「永不可失败的门禁」),决定了测试的形状:
|
||||
* 每条规则都必须**两头都被证明** ——
|
||||
* ① 合规夹具必须判 0(否则门禁是「常红」,会把人训练成忽略它);
|
||||
* ② 违规夹具必须判非 0,且**是它对应的那条规则**判红(否则门禁是「常绿」)。
|
||||
* 再加一条元测试:断言四条规则的并集 == 声明的规则全集,任何一条规则变得不可触发即红。
|
||||
*
|
||||
* 跑法(jest 的 roots 是 src,故本目录需显式指定):npm run check:motion:test
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
import {
|
||||
MOTION_RULES,
|
||||
EXIT_CLEAN,
|
||||
EXIT_VIOLATION,
|
||||
EXIT_UNMEASURABLE,
|
||||
markTransitionLines,
|
||||
parseDurationMs,
|
||||
parseTailwindDuration,
|
||||
runScan,
|
||||
type RuleId,
|
||||
} from '../check-motion-constraints';
|
||||
|
||||
const SCRIPT_PATH = path.resolve(__dirname, '..', 'check-motion-constraints.ts');
|
||||
|
||||
/** 合规令牌层:曲线允许集合与 --transition-* 档位都齐活(脚本从这里读允许集合,不硬编码) */
|
||||
const CLEAN_CSS = `
|
||||
:root {
|
||||
--transition-instant: 100ms;
|
||||
--transition-fast: 180ms;
|
||||
--transition-normal: 280ms;
|
||||
--transition-slow: 450ms;
|
||||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--card-transition: transform var(--transition-normal) var(--ease-ink);
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: color var(--transition-fast) var(--ease-ink);
|
||||
}
|
||||
|
||||
.glow {
|
||||
animation: pulse-soft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CLEAN_TSX = `
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
export function Card() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.28, ease: EASE_OUT }}
|
||||
className="transition-colors duration-150"
|
||||
/>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const CONFIG_JS = `
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'fade-in-up': 'fadeInUp 0.7s var(--ease-ink) forwards',
|
||||
'pulse-soft': 'pulse-soft 2s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
`;
|
||||
|
||||
let workspace: string;
|
||||
|
||||
beforeAll(() => {
|
||||
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-gate-'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* 写一棵最小夹具树并按门禁口径跑一遍。
|
||||
* 每次调用用独立子目录,避免用例间互相污染。
|
||||
*/
|
||||
function scanFixture(fixture: {
|
||||
css?: string;
|
||||
source?: string;
|
||||
config?: string;
|
||||
omitSrc?: boolean;
|
||||
omitCss?: boolean;
|
||||
omitConfig?: boolean;
|
||||
}) {
|
||||
const root = fs.mkdtempSync(path.join(workspace, 'case-'));
|
||||
fs.mkdirSync(path.join(root, 'src', 'app'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true });
|
||||
|
||||
if (!fixture.omitCss) {
|
||||
fs.writeFileSync(path.join(root, 'src', 'app', 'globals.css'), fixture.css ?? CLEAN_CSS, 'utf8');
|
||||
}
|
||||
if (!fixture.omitSrc) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'src', 'components', 'card.tsx'),
|
||||
fixture.source ?? CLEAN_TSX,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
if (!fixture.omitConfig) {
|
||||
fs.writeFileSync(path.join(root, 'tailwind.config.js'), fixture.config ?? CONFIG_JS, 'utf8');
|
||||
}
|
||||
|
||||
const result = runScan({
|
||||
root,
|
||||
srcDir: 'src',
|
||||
cssFiles: ['src/app/globals.css'],
|
||||
configFiles: ['tailwind.config.js'],
|
||||
});
|
||||
return {
|
||||
root,
|
||||
...result,
|
||||
rules: new Set<RuleId>(result.violations.map((violation) => violation.rule)),
|
||||
countOf: (rule: RuleId) => result.violations.filter((violation) => violation.rule === rule).length,
|
||||
byRule: (rule: RuleId) => result.violations.filter((violation) => violation.rule === rule),
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseDurationMs —— 时长单位口径', () => {
|
||||
it('ms 与 s 都能解析,非时长形态返回 null', () => {
|
||||
expect(parseDurationMs('180ms')).toBe(180);
|
||||
expect(parseDurationMs(' 0.6s')).toBe(600);
|
||||
expect(parseDurationMs('.28s')).toBeNull(); // 前导点写法不被承认,故不会静默算成 0
|
||||
expect(parseDurationMs('24px')).toBeNull();
|
||||
expect(parseDurationMs('var(--transition-fast)')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTailwindDuration —— Tailwind 时长类的单位口径', () => {
|
||||
it('裸数字按 ms 解释,带单位则换算(duration-900 必须是 900ms,不能被静默忽略)', () => {
|
||||
expect(parseTailwindDuration('150')).toBe(150);
|
||||
expect(parseTailwindDuration('900')).toBe(900);
|
||||
expect(parseTailwindDuration('0.6s')).toBe(600);
|
||||
expect(parseTailwindDuration('1200ms')).toBe(1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markTransitionLines —— 区分 framer transition 对象与其他 duration', () => {
|
||||
it('只在 transition 对象内部为 true', () => {
|
||||
const lines = [
|
||||
'const variants = {',
|
||||
' visible: {',
|
||||
' transition: {',
|
||||
' duration: 0.28,',
|
||||
' },',
|
||||
' },',
|
||||
'};',
|
||||
"toast('已保存', { duration: 3000 });",
|
||||
];
|
||||
// 只有 transition 对象自身(声明行 + 其内部行)为 true;外层对象与末尾的 sonner toast 都不算
|
||||
expect(markTransitionLines(lines)).toEqual([false, false, true, true, false, false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('合规夹具:门禁必须判 0(防「常红」)', () => {
|
||||
it('干净令牌层 + 干净源码 → EXIT_CLEAN,且确实看到了文件', () => {
|
||||
const result = scanFixture({});
|
||||
expect(result.violations).toEqual([]);
|
||||
expect(result.exitCode).toBe(EXIT_CLEAN);
|
||||
expect(result.counts.sourceFiles).toBe(1);
|
||||
expect(result.counts.durationLiterals).toBeGreaterThan(0);
|
||||
expect(result.allowedCurves.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('infinite 循环动画与 !important reduced-motion 硬开关按契约豁免', () => {
|
||||
const result = scanFixture({});
|
||||
expect(result.countOf('entrance-cap')).toBe(0);
|
||||
expect(result.countOf('css-transition-token')).toBe(0);
|
||||
});
|
||||
|
||||
it('*-delay 是延迟/错落而非时长:800ms 的 animation-delay 不判 entrance-cap', () => {
|
||||
// 回归:首轮实现把 .animation-delay-800 误判成超上限(stagger 属 CONTEXT.md:78 的另一条口径)
|
||||
const result = scanFixture({
|
||||
css: `${CLEAN_CSS}\n.animation-delay-800 { animation-delay: 800ms; }\n.slow-in { transition-delay: 900ms; }\n`,
|
||||
});
|
||||
expect(result.countOf('entrance-cap')).toBe(0);
|
||||
expect(result.countOf('css-transition-token')).toBe(0);
|
||||
});
|
||||
|
||||
it('允许集合来自 CSS 令牌层而非硬编码:自定义曲线声明后被使用即合规', () => {
|
||||
const result = scanFixture({
|
||||
css: `
|
||||
:root {
|
||||
--transition-fast: 200ms;
|
||||
--transition-normal: 280ms;
|
||||
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-bespoke: cubic-bezier(0.1, 0.2, 0.3, 0.4);
|
||||
}
|
||||
`,
|
||||
source: `const EASE_CUSTOM = [0.1, 0.2, 0.3, 0.4] as const;
|
||||
export const C = () => <motion.div transition={{ duration: 0.2, ease: [0.1, 0.2, 0.3, 0.4] }} />;
|
||||
`,
|
||||
});
|
||||
expect(result.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('违规夹具:每条规则都必须能判红(防「常绿」)', () => {
|
||||
it('R1 token-band —— fast 超入场档 / instant 非反馈档', () => {
|
||||
const result = scanFixture({
|
||||
css: `
|
||||
:root {
|
||||
--transition-instant: 250ms;
|
||||
--transition-fast: 400ms;
|
||||
--transition-normal: 280ms;
|
||||
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(result.exitCode).toBe(EXIT_VIOLATION);
|
||||
expect(result.countOf('token-band')).toBe(2);
|
||||
expect(result.byRule('token-band').map((v) => v.token)).toEqual(['250ms', '400ms']);
|
||||
});
|
||||
|
||||
it('R2 entrance-cap —— 令牌 1200ms / framer 1.4s / duration-900 类 / 配置串 1.1s', () => {
|
||||
const result = scanFixture({
|
||||
css: `
|
||||
:root {
|
||||
--transition-instant: 100ms;
|
||||
--transition-fast: 180ms;
|
||||
--transition-normal: 280ms;
|
||||
--transition-gentle: 1200ms;
|
||||
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.banner { transition: opacity var(--transition-gentle) var(--ease-ink); }
|
||||
`,
|
||||
source: `export const Bad = () => (
|
||||
<motion.div
|
||||
transition={{ duration: 1.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="transition-all duration-900"
|
||||
style={{ transition: 'opacity 0.9s var(--ease-ink)' }}
|
||||
/>
|
||||
);
|
||||
`,
|
||||
config: `module.exports = { theme: { extend: { animation: { 'slow-in': 'fadeIn 1.1s var(--ease-ink) forwards' } } } };`,
|
||||
});
|
||||
expect(result.exitCode).toBe(EXIT_VIOLATION);
|
||||
// 令牌自身 + 经 var() 解析到该令牌的声明 + framer 1.4s + duration-900 类 + 内联 0.9s + 配置串 1.1s
|
||||
expect(result.countOf('entrance-cap')).toBe(6);
|
||||
expect(result.byRule('entrance-cap').map((violation) => violation.file)).toEqual([
|
||||
'src/app/globals.css',
|
||||
'src/app/globals.css',
|
||||
'src/components/card.tsx',
|
||||
'src/components/card.tsx',
|
||||
'src/components/card.tsx',
|
||||
'tailwind.config.js',
|
||||
]);
|
||||
});
|
||||
|
||||
it('R3 css-transition-token —— 裸 ms/s 字面量与悬空 var() 引用', () => {
|
||||
const result = scanFixture({
|
||||
css: `
|
||||
:root {
|
||||
--transition-instant: 100ms;
|
||||
--transition-fast: 180ms;
|
||||
--transition-normal: 280ms;
|
||||
--ease-ink: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.glow-border::before {
|
||||
transition: opacity 0.6s var(--ease-ink);
|
||||
}
|
||||
.ghost { transition: transform var(--transition-ghost) var(--ease-ink); }
|
||||
`,
|
||||
});
|
||||
expect(result.exitCode).toBe(EXIT_VIOLATION);
|
||||
expect(result.countOf('css-transition-token')).toBe(2);
|
||||
expect(result.byRule('css-transition-token').map((v) => v.token)).toEqual(['0.6s', 'var(--transition-ghost)']);
|
||||
});
|
||||
|
||||
it('R4 easing-palette —— 非令牌曲线(framer 数组 / 具名常量 / CSS / Tailwind 任意值)与 --ease-ink 基准漂移', () => {
|
||||
const result = scanFixture({
|
||||
css: `
|
||||
:root {
|
||||
--transition-instant: 100ms;
|
||||
--transition-fast: 180ms;
|
||||
--transition-normal: 280ms;
|
||||
--ease-ink: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.bad { transition: opacity var(--transition-fast) cubic-bezier(0.9, 0, 0.1, 0.9); }
|
||||
`,
|
||||
source: `const EASE = [0.16, 1, 0.3, 1] as const;
|
||||
export const Bad = () => (
|
||||
<motion.div
|
||||
transition={{ duration: 0.28, ease: [0.25, 1, 0.5, 1] }}
|
||||
className="ease-[cubic-bezier(0.5, 0.5, 0.5, 0.5)]"
|
||||
/>
|
||||
);
|
||||
`,
|
||||
});
|
||||
expect(result.exitCode).toBe(EXIT_VIOLATION);
|
||||
// --ease-ink 基准 + CSS 写死曲线 + const EASE + ease: [] + Tailwind 任意值
|
||||
expect(result.countOf('easing-palette')).toBe(5);
|
||||
const files = result.byRule('easing-palette').map((violation) => violation.file);
|
||||
expect(new Set(files)).toEqual(new Set(['src/app/globals.css', 'src/components/card.tsx']));
|
||||
});
|
||||
|
||||
it('测试文件与被排除目录不参与判定(e2e 断言里出现违规是合规的)', () => {
|
||||
const root = fs.mkdtempSync(path.join(workspace, 'excluded-'));
|
||||
fs.mkdirSync(path.join(root, 'src', 'app'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'src', 'components'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'src', 'app', 'globals.css'), CLEAN_CSS, 'utf8');
|
||||
fs.writeFileSync(path.join(root, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'src', 'components', 'card.test.tsx'),
|
||||
'const EASE = [0.99, 0.98, 0.97, 0.96] as const;\n',
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(path.join(root, 'tailwind.config.js'), CONFIG_JS, 'utf8');
|
||||
const result = runScan({
|
||||
root,
|
||||
srcDir: 'src',
|
||||
cssFiles: ['src/app/globals.css'],
|
||||
configFiles: ['tailwind.config.js'],
|
||||
});
|
||||
expect(result.exitCode).toBe(EXIT_CLEAN);
|
||||
expect(result.counts.sourceFiles).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('元测试:规则可达性(N-22 / N-24 / A-6 的直接防线)', () => {
|
||||
it('四条规则各自都有至少一个能触发它的夹具', () => {
|
||||
const fixtures = [
|
||||
scanFixture({ css: CLEAN_CSS.replace('--transition-fast: 180ms', '--transition-fast: 999ms') }),
|
||||
scanFixture({ source: CLEAN_TSX.replace('duration: 0.28', 'duration: 2.8') }),
|
||||
scanFixture({ css: `${CLEAN_CSS}\n.x { transition: opacity 0.6s var(--ease-ink); }` }),
|
||||
scanFixture({ source: CLEAN_TSX.replace('[0.22, 1, 0.36, 1]', '[0.7, 0.7, 0.7, 0.7]') }),
|
||||
];
|
||||
const covered = new Set<RuleId>();
|
||||
for (const fixture of fixtures) {
|
||||
expect(fixture.exitCode).toBe(EXIT_VIOLATION);
|
||||
for (const violation of fixture.violations) covered.add(violation.rule);
|
||||
}
|
||||
expect([...covered].sort()).toEqual([...MOTION_RULES.map((rule) => rule.id)].sort());
|
||||
});
|
||||
|
||||
it('MOTION_RULES 声明的四条规则 id 唯一且与退出码语义一致', () => {
|
||||
const ids = MOTION_RULES.map((rule) => rule.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect([EXIT_CLEAN, EXIT_VIOLATION, EXIT_UNMEASURABLE]).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('「空扫描 ≠ 干净」:无法度量必须判 2', () => {
|
||||
it('源码目录不存在 → 2', () => {
|
||||
const result = scanFixture({ omitSrc: true });
|
||||
// 目录仍在(夹具建了 src/app),故这里用空目录场景另证
|
||||
expect(result.counts.sourceFiles).toBe(0);
|
||||
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
|
||||
});
|
||||
|
||||
it('CSS 令牌文件缺失 → 2', () => {
|
||||
const result = scanFixture({ omitCss: true });
|
||||
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
|
||||
expect(result.blockers.join(' ')).toContain('CSS 令牌文件不存在');
|
||||
});
|
||||
|
||||
it('令牌层解析不到任何 --ease-* / --transition-* → 2(不允许静默放行)', () => {
|
||||
const result = scanFixture({ css: '.a { transition: opacity 200ms linear; }' });
|
||||
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
|
||||
expect(result.blockers.join(' ')).toContain('--ease-*');
|
||||
});
|
||||
|
||||
it('tailwind.config.js 缺失 → 2', () => {
|
||||
const result = scanFixture({ omitConfig: true });
|
||||
expect(result.exitCode).toBe(EXIT_UNMEASURABLE);
|
||||
expect(result.blockers.join(' ')).toContain('tailwind.config.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI 退出码 plumbing(process.exit 真的接上了判定)', () => {
|
||||
function runCli(root: string, extra: string[] = []) {
|
||||
return spawnSync('npx', ['tsx', SCRIPT_PATH, '--root', root, ...extra], {
|
||||
encoding: 'utf8',
|
||||
cwd: path.resolve(__dirname, '..', '..', '..'),
|
||||
});
|
||||
}
|
||||
|
||||
it('合规夹具 0 / 违规夹具 1 / 目标缺失 2', () => {
|
||||
const clean = fs.mkdtempSync(path.join(workspace, 'cli-clean-'));
|
||||
fs.mkdirSync(path.join(clean, 'src', 'app'), { recursive: true });
|
||||
fs.mkdirSync(path.join(clean, 'src', 'components'), { recursive: true });
|
||||
fs.writeFileSync(path.join(clean, 'src', 'app', 'globals.css'), CLEAN_CSS, 'utf8');
|
||||
fs.writeFileSync(path.join(clean, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
|
||||
fs.writeFileSync(path.join(clean, 'tailwind.config.js'), CONFIG_JS, 'utf8');
|
||||
expect(runCli(clean).status).toBe(EXIT_CLEAN);
|
||||
|
||||
const dirty = fs.mkdtempSync(path.join(workspace, 'cli-dirty-'));
|
||||
fs.mkdirSync(path.join(dirty, 'src', 'app'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dirty, 'src', 'components'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dirty, 'src', 'app', 'globals.css'),
|
||||
`${CLEAN_CSS}\n.bad { transition: opacity 1.2s cubic-bezier(0.9, 0, 0.1, 0.9); }\n`,
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(path.join(dirty, 'src', 'components', 'card.tsx'), CLEAN_TSX, 'utf8');
|
||||
fs.writeFileSync(path.join(dirty, 'tailwind.config.js'), CONFIG_JS, 'utf8');
|
||||
const dirtyRun = runCli(dirty);
|
||||
expect(dirtyRun.status).toBe(EXIT_VIOLATION);
|
||||
expect(dirtyRun.stdout).toContain('css-transition-token');
|
||||
|
||||
const missing = fs.mkdtempSync(path.join(workspace, 'cli-missing-'));
|
||||
expect(runCli(missing).status).toBe(EXIT_UNMEASURABLE);
|
||||
}, 120000);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/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();
|
||||
@@ -1,62 +1,235 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { meetsWCAGStandard } from '../../src/lib/color-contrast';
|
||||
|
||||
interface ColorPair {
|
||||
name: string;
|
||||
/**
|
||||
* 对比度门禁(验收 A-11-1 重写)。
|
||||
*
|
||||
* 旧实现是 7 组硬编码十六进制(全部 #FFFFFF 底),既不读令牌表也不含暗色配对 ——
|
||||
* 主题令牌改了它仍对陈旧色值报绿,因此全站 62 页的暗色违规能从这道门禁底下漏过。
|
||||
* 现在:色值一律从 globals.css 的两个主题根解析,缺令牌即失败(不允许静默跳过)。
|
||||
*/
|
||||
|
||||
const THEME_BLOCKS = [
|
||||
{ theme: 'light', selector: ':root' },
|
||||
{ theme: 'dark', selector: "html[data-theme='dark']" },
|
||||
] as const;
|
||||
|
||||
interface Pair {
|
||||
foreground: string;
|
||||
background: string;
|
||||
/** 前景 alpha(0-1),用于检验带 alpha 修饰类的正文(如 text-text-secondary 80%) */
|
||||
alpha?: number;
|
||||
textSize?: 'normal' | 'large';
|
||||
}
|
||||
|
||||
const criticalColorPairs: ColorPair[] = [
|
||||
{ name: 'Primary text on primary background', foreground: '#1C1C1C', background: '#FFFFFF' },
|
||||
{ name: 'Secondary text on primary background', foreground: '#3D3D3D', background: '#FFFFFF' },
|
||||
{ name: 'Tertiary text on primary background', foreground: '#4A4A4A', background: '#FFFFFF' },
|
||||
{ name: 'Muted text on primary background', foreground: '#6B6B6B', background: '#FFFFFF' },
|
||||
{ name: 'Brand primary on white', foreground: '#C41E3A', background: '#FFFFFF' },
|
||||
{ name: 'Brand primary on brand bg', foreground: '#C41E3A', background: '#FEF2F4' },
|
||||
{ name: 'Link on hover', foreground: '#C41E3A', background: '#FFFFFF' },
|
||||
/** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token。 */
|
||||
/** 契约要求的配对:来自 DESIGN.md 的文字/底色通道与深色区块 token,两主题各解一次。 */
|
||||
const REQUIRED_GROUPS: Array<{ label: string; pairs: Pair[] }> = [
|
||||
{
|
||||
label: '正文层级 × 页面底色',
|
||||
pairs: [
|
||||
{ foreground: '--color-text-primary', background: '--color-bg-primary' },
|
||||
{ foreground: '--color-text-primary', background: '--color-bg-secondary' },
|
||||
{ foreground: '--color-text-primary', background: '--color-bg-tertiary' },
|
||||
{ foreground: '--color-text-secondary', background: '--color-bg-primary' },
|
||||
{ foreground: '--color-text-secondary', background: '--color-bg-secondary' },
|
||||
{ foreground: '--color-text-tertiary', background: '--color-bg-primary' },
|
||||
{ foreground: '--color-text-muted', background: '--color-bg-primary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '品牌红文字通道(text-brand-ink 及 hover 档)',
|
||||
pairs: [
|
||||
{ foreground: '--color-brand-ink', background: '--color-bg-primary' },
|
||||
{ foreground: '--color-brand-ink', background: '--color-bg-secondary' },
|
||||
{ foreground: '--color-brand-ink', background: '--color-brand-bg' },
|
||||
{ foreground: '--color-brand-ink-hover', background: '--color-bg-primary' },
|
||||
{ foreground: '--color-brand-ink-hover', background: '--color-brand-bg' },
|
||||
// 红底按钮的真实组合是 bg-brand + text-white(button.tsx:14,text-sm 属 normal 字号);
|
||||
// --color-text-inverse 全仓 0 引用且暗色翻成墨色,不构成 UI 组合,故此处用字面量白。
|
||||
{ foreground: '#FFFFFF', background: '--color-brand' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'alpha 修饰正文(带 /80 等修饰类的正文)',
|
||||
pairs: [{ foreground: '--color-text-secondary', background: '--color-bg-primary', alpha: 0.8 }],
|
||||
},
|
||||
{
|
||||
// 验收 A-12 残留:装饰序号/占位字(aria-hidden + ≥24px)。axe 的 color-contrast 不认 aria-hidden
|
||||
// (实测 4.11.4 仍按可见文字判定),所以这类文字必须自己过 AA 大字 3:1,不能再靠低 alpha 淡化。
|
||||
label: '大号装饰文本(text-text-hint)× 各级卡片底色',
|
||||
pairs: [
|
||||
{ foreground: '--color-text-hint', background: '--color-bg-primary', textSize: 'large' },
|
||||
{ foreground: '--color-text-hint', background: '--color-bg-secondary', textSize: 'large' },
|
||||
{ foreground: '--color-text-hint', background: '--color-bg-tertiary', textSize: 'large' },
|
||||
{ foreground: '--color-text-hint', background: '--color-bg-elevated', textSize: 'large' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '深色区块(不随主题翻转的独立 token)',
|
||||
pairs: [
|
||||
{ foreground: '--color-dark-text-primary', background: '--color-dark-bg' },
|
||||
{ foreground: '--color-dark-text-secondary', background: '--color-dark-bg' },
|
||||
{ foreground: '--color-dark-text-muted', background: '--color-dark-bg' },
|
||||
{ foreground: '--color-brand-section-text', background: '--color-brand-section' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function checkContrast() {
|
||||
console.log('🎨 Color Contrast Check\n');
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
|
||||
criticalColorPairs.forEach(pair => {
|
||||
const result = meetsWCAGStandard(
|
||||
pair.foreground,
|
||||
pair.background,
|
||||
'AA',
|
||||
pair.textSize || 'normal'
|
||||
);
|
||||
|
||||
const status = result.passes ? '✅ PASS' : '❌ FAIL';
|
||||
const ratio = result.ratio.toFixed(2);
|
||||
|
||||
console.log(`${status} ${pair.name}`);
|
||||
console.log(` Ratio: ${ratio}:1 (Required: ${result.requiredRatio}:1)`);
|
||||
console.log(` FG: ${pair.foreground} | BG: ${pair.background}\n`);
|
||||
|
||||
if (result.passes) {
|
||||
passes++;
|
||||
} else {
|
||||
failures++;
|
||||
function extractBlock(css: string, selector: string): string {
|
||||
const start = css.indexOf(`${selector} {`);
|
||||
if (start < 0) throw new Error(`globals.css 中找不到主题根:${selector}`);
|
||||
let depth = 0;
|
||||
let cursor = css.indexOf('{', start);
|
||||
const bodyStart = cursor + 1;
|
||||
while (cursor < css.length) {
|
||||
const ch = css[cursor];
|
||||
if (ch === '{') depth += 1;
|
||||
else if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n📊 Summary:`);
|
||||
console.log(` Total: ${criticalColorPairs.length}`);
|
||||
console.log(` ✅ Passes: ${passes}`);
|
||||
console.log(` ❌ Failures: ${failures}`);
|
||||
|
||||
if (failures > 0) {
|
||||
console.log('\n⚠️ Some color pairs do not meet WCAG AA standards!');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ All color pairs meet WCAG AA standards!');
|
||||
process.exit(0);
|
||||
cursor += 1;
|
||||
}
|
||||
return css.slice(bodyStart, cursor);
|
||||
}
|
||||
|
||||
checkContrast();
|
||||
function parseTokens(block: string): Map<string, string> {
|
||||
const tokens = new Map<string, string>();
|
||||
for (const match of block.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
|
||||
const name = match[1] as string;
|
||||
const value = (match[2] as string).trim();
|
||||
tokens.set(name, tokens.get(name) ?? value);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
interface Rgba {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
function parseColor(raw: string, tokens: Map<string, string>, depth = 0): Rgba {
|
||||
if (depth > 5) throw new Error(`var() 递归超过 5 层:${raw}`);
|
||||
const value = raw.startsWith('var(') ? resolveVar(raw, tokens) : raw;
|
||||
if (value !== raw && value.startsWith('var(')) return parseColor(value, tokens, depth + 1);
|
||||
|
||||
const hex = value.match(/^#([\da-f]{3}|[\da-f]{6})$/i)?.[1];
|
||||
if (hex) {
|
||||
const full = hex.length === 3 ? hex.split('').map((c) => c + c).join('') : hex;
|
||||
return {
|
||||
r: parseInt(full.slice(0, 2), 16),
|
||||
g: parseInt(full.slice(2, 4), 16),
|
||||
b: parseInt(full.slice(4, 6), 16),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const rgb = value.match(/^rgba?\(([^)]+)\)$/);
|
||||
if (rgb) {
|
||||
const parts = (rgb[1] as string).split(/[\s,]+/).map((p) => Number.parseFloat(p));
|
||||
const [r, g, b, a] = [parts[0], parts[1], parts[2], parts[3]];
|
||||
if ([r, g, b].some((n) => n === undefined || Number.isNaN(n))) throw new Error(`无法解析颜色:${value}`);
|
||||
return { r: r ?? 0, g: g ?? 0, b: b ?? 0, a: a === undefined || Number.isNaN(a) ? 1 : a };
|
||||
}
|
||||
|
||||
throw new Error(`无法解析颜色:${raw}`);
|
||||
}
|
||||
|
||||
function resolveVar(raw: string, tokens: Map<string, string>): string {
|
||||
const inner = raw.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]*))?\)$/);
|
||||
if (!inner) return raw;
|
||||
const name = inner[1] as string;
|
||||
const fallback = inner[2];
|
||||
const value = tokens.get(name) ?? fallback;
|
||||
if (value === undefined) throw new Error(`令牌未定义:${name}`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function composite(fg: Rgba, bg: Rgba): Rgba {
|
||||
const a = fg.a;
|
||||
return {
|
||||
r: Math.round(fg.r * a + bg.r * (1 - a)),
|
||||
g: Math.round(fg.g * a + bg.g * (1 - a)),
|
||||
b: Math.round(fg.b * a + bg.b * (1 - a)),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function toHex({ r, g, b }: Rgba): string {
|
||||
const hex = (n: number) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
|
||||
return `#${hex(r)}${hex(g)}${hex(b)}`.toUpperCase();
|
||||
}
|
||||
|
||||
function resolve(tokenName: string, tokens: Map<string, string>): string {
|
||||
if (tokenName.startsWith('#')) return tokenName;
|
||||
const raw = tokens.get(tokenName);
|
||||
if (raw === undefined) throw new Error(`主题令牌缺失:${tokenName}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function check() {
|
||||
const cssPath = join(process.cwd(), 'src', 'app', 'globals.css');
|
||||
const css = readFileSync(cssPath, 'utf8');
|
||||
|
||||
let passes = 0;
|
||||
const failures: string[] = [];
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const { theme, selector } of THEME_BLOCKS) {
|
||||
const own = parseTokens(extractBlock(css, selector));
|
||||
// 按 CSS 层叠语义取色:暗色块只声明需要翻转的令牌,其余沿用 :root。
|
||||
// 不做层叠就会把「刻意不翻」的底色通道误报成令牌缺失(DESIGN.md 双通道红规则)。
|
||||
const tokens = theme === 'dark' ? new Map([...parseTokens(extractBlock(css, ':root')), ...own]) : own;
|
||||
console.log(`\n🎨 ${theme.toUpperCase()} 主题(${selector},${tokens.size} 个令牌)`);
|
||||
|
||||
for (const group of REQUIRED_GROUPS) {
|
||||
console.log(` ${group.label}`);
|
||||
for (const pair of group.pairs) {
|
||||
let label = `${pair.foreground} on ${pair.background}${pair.alpha !== undefined ? ` @${pair.alpha * 100}%` : ''}`;
|
||||
try {
|
||||
const fgToken = parseColor(resolve(pair.foreground, tokens), tokens);
|
||||
const bgToken = parseColor(resolve(pair.background, tokens), tokens);
|
||||
const fg = pair.alpha === undefined ? fgToken : { ...fgToken, a: pair.alpha };
|
||||
const composited = composite(fg, bgToken);
|
||||
const bgHex = toHex(bgToken);
|
||||
const fgHex = toHex(composited);
|
||||
const result = meetsWCAGStandard(fgHex, bgHex, 'AA', pair.textSize ?? 'normal');
|
||||
const ratio = result.ratio.toFixed(2);
|
||||
if (result.passes) {
|
||||
passes += 1;
|
||||
console.log(` ✅ ${label} — ${ratio}:1`);
|
||||
} else {
|
||||
failures.push(`${theme} · ${label} = ${ratio}:1(需 ${result.requiredRatio}:1;${fgHex} on ${bgHex})`);
|
||||
console.log(` ❌ ${label} — ${ratio}:1(需 ${result.requiredRatio}:1)`);
|
||||
}
|
||||
} catch (error) {
|
||||
missing.push(`${theme} · ${label} — ${(error as Error).message}`);
|
||||
console.log(` ❌ ${label} — ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 合计 ${passes + failures.length + missing.length} 组:✅ ${passes} / ❌ ${failures.length} 不达标 / ⚠️ ${missing.length} 令牌缺失`);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n不达标:');
|
||||
failures.forEach((f) => console.log(` · ${f}`));
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.log('\n令牌缺失(门禁视为失败,避免对着陈旧色值报绿):');
|
||||
missing.forEach((m) => console.log(` · ${m}`));
|
||||
}
|
||||
|
||||
if (failures.length > 0 || missing.length > 0) {
|
||||
console.log('\n⚠️ 存在不满足 WCAG 2.1 AA 的令牌配对');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✅ 全部令牌配对满足 WCAG 2.1 AA');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
check();
|
||||
|
||||
@@ -9,27 +9,32 @@
|
||||
* 3. 标题不为空
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { chromium } from "playwright";
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
|
||||
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
|
||||
const BASE_URL = process.env.E2E_BASE_URL || "http://localhost:3000";
|
||||
|
||||
const PAGES = [
|
||||
{ path: '/', name: '首页' },
|
||||
{ path: '/about', name: '关于我们' },
|
||||
{ path: '/products', name: '产品中心' },
|
||||
{ path: '/products/erp', name: '产品详情-ERP' },
|
||||
{ path: '/solutions', name: '解决方案' },
|
||||
{ path: '/solutions/manufacturing', name: '解决方案详情-制造' },
|
||||
{ path: '/services', name: '服务列表' },
|
||||
{ path: '/services/software', name: '服务详情-软件开发' },
|
||||
{ path: '/news', name: '新闻列表' },
|
||||
{ path: '/contact', name: '联系我们' },
|
||||
{ path: "/", name: "首页" },
|
||||
{ path: "/about", name: "关于我们" },
|
||||
{ path: "/products", name: "产品中心" },
|
||||
{ path: "/products/erp", name: "产品详情-ERP" },
|
||||
{ path: "/solutions", name: "解决方案" },
|
||||
{ path: "/solutions/manufacturing", name: "解决方案详情-制造" },
|
||||
{ path: "/services", name: "服务列表" },
|
||||
{ path: "/services/software", name: "服务详情-软件开发" },
|
||||
{ path: "/news", name: "新闻列表" },
|
||||
{ path: "/contact", name: "联系我们" },
|
||||
];
|
||||
|
||||
interface HeadingIssue {
|
||||
type: 'missing_h1' | 'multiple_h1' | 'empty_heading' | 'skipped_level';
|
||||
type:
|
||||
| "missing_h1"
|
||||
| "multiple_h1"
|
||||
| "empty_heading"
|
||||
| "skipped_level"
|
||||
| "http_error";
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
@@ -44,22 +49,48 @@ interface PageResult {
|
||||
|
||||
function startServer(): Promise<ReturnType<typeof spawn>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = spawn('npm', ['run', 'preview'], {
|
||||
cwd: path.resolve(__dirname, '../..'),
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
});
|
||||
// N-24:`npm run preview` = `next start -p 3000`,而本项目是 output:'standalone',
|
||||
// Next 16 明确告警 `"next start" does not work with "output: standalone"` ⇒ 此前该门禁跑在不受支持的降级路径上。
|
||||
// 有产物就直起 standalone server(与 check:axe / Jenkinsfile / lighthouserc 同法),否则回退并大声告警。
|
||||
const standalone = path.resolve(
|
||||
__dirname,
|
||||
"../../dist/standalone/server.js",
|
||||
);
|
||||
const useStandalone = require("fs").existsSync(standalone);
|
||||
if (!useStandalone) {
|
||||
console.warn(
|
||||
"⚠ 未找到 dist/standalone/server.js,回退到 `npm run preview`(该路径在 standalone 下不受支持,结论仅参考)",
|
||||
);
|
||||
}
|
||||
const server = spawn(
|
||||
useStandalone ? "node" : "npm",
|
||||
useStandalone ? [standalone] : ["run", "preview"],
|
||||
{
|
||||
env: useStandalone
|
||||
? {
|
||||
...process.env,
|
||||
HOSTNAME: "localhost",
|
||||
PORT: "3000",
|
||||
NODE_ENV: "production",
|
||||
}
|
||||
: process.env,
|
||||
cwd: path.resolve(__dirname, "../.."),
|
||||
stdio: "pipe",
|
||||
shell: true,
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
|
||||
server.stdout?.on('data', (data: Buffer) => {
|
||||
server.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
if (output.includes('Ready') || output.includes('3000')) {
|
||||
if (output.includes("Ready") || output.includes("3000")) {
|
||||
resolve(server);
|
||||
}
|
||||
});
|
||||
|
||||
server.stderr?.on('data', (data: Buffer) => {
|
||||
server.stderr?.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
if (output.includes('EADDRINUSE') || output.includes('Failed')) {
|
||||
if (output.includes("EADDRINUSE") || output.includes("Failed")) {
|
||||
reject(new Error(output));
|
||||
}
|
||||
});
|
||||
@@ -68,29 +99,50 @@ function startServer(): Promise<ReturnType<typeof spawn>> {
|
||||
});
|
||||
}
|
||||
|
||||
// 负 PID = 杀整个进程组。spawn 的 `npm run preview` 只是包装进程,真正 LISTEN :3000 的是它的
|
||||
// next-server 孙进程;只 kill 包装进程会让孙进程被 init 收养继续占端口(验收报告 §9 实测残留 39 分钟),
|
||||
// 叠加 Playwright 的 reuseExistingServer: true 后,下一轮 E2E / Lighthouse 会静默复用它。
|
||||
let serverStopped = false;
|
||||
function stopServer(server: ReturnType<typeof spawn> | null | undefined): void {
|
||||
if (!server?.pid || serverStopped) return;
|
||||
serverStopped = true;
|
||||
try {
|
||||
process.kill(-server.pid, "SIGTERM");
|
||||
} catch {
|
||||
/* 进程组已自行退出 */
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, retries = 30): Promise<void> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD' });
|
||||
if (res.ok || res.status === 404) return;
|
||||
const res = await fetch(url, { method: "HEAD" });
|
||||
// N-24:原本 `|| res.status === 404` 把「已响应但站点是错的」当成就绪;
|
||||
// 而 :3000 上任何别的服务都会立刻满足它,于是整轮量的是错对象。
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
// 服务未起:连接被拒
|
||||
}
|
||||
// 就绪等待必须每轮都退避:启动期返回 503/500 等非 ok 状态时,
|
||||
// 若只在 catch 里 sleep,30 次重试会在毫秒内跑完并误报「服务未就绪」。
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
throw new Error(`Server at ${url} did not become ready in time`);
|
||||
}
|
||||
|
||||
function checkHeadings(headings: Array<{ level: number; text: string }>): HeadingIssue[] {
|
||||
function checkHeadings(
|
||||
headings: Array<{ level: number; text: string }>,
|
||||
): HeadingIssue[] {
|
||||
const issues: HeadingIssue[] = [];
|
||||
|
||||
const h1Headings = headings.filter((h) => h.level === 1);
|
||||
if (h1Headings.length === 0) {
|
||||
issues.push({ type: 'missing_h1', message: '页面缺少 h1 标题' });
|
||||
issues.push({ type: "missing_h1", message: "页面缺少 h1 标题" });
|
||||
} else if (h1Headings.length > 1) {
|
||||
issues.push({
|
||||
type: 'multiple_h1',
|
||||
type: "multiple_h1",
|
||||
message: `页面存在 ${h1Headings.length} 个 h1 标题`,
|
||||
detail: h1Headings.map((h) => h.text).join(' | '),
|
||||
detail: h1Headings.map((h) => h.text).join(" | "),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,7 +150,7 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
|
||||
const current = headings[i]!;
|
||||
if (!current.text.trim()) {
|
||||
issues.push({
|
||||
type: 'empty_heading',
|
||||
type: "empty_heading",
|
||||
message: `h${current.level} 标题内容为空`,
|
||||
});
|
||||
continue;
|
||||
@@ -108,7 +160,7 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
|
||||
const previous = headings[i - 1]!;
|
||||
if (current.level > previous.level + 1) {
|
||||
issues.push({
|
||||
type: 'skipped_level',
|
||||
type: "skipped_level",
|
||||
message: `标题层级跳跃: h${previous.level} → h${current.level}`,
|
||||
detail: `"${previous.text}" → "${current.text}"`,
|
||||
});
|
||||
@@ -121,13 +173,15 @@ function checkHeadings(headings: Array<{ level: number; text: string }>): Headin
|
||||
async function runCheck() {
|
||||
let server;
|
||||
try {
|
||||
console.log('🚀 启动预览服务...');
|
||||
console.log("🚀 启动预览服务...");
|
||||
server = await startServer();
|
||||
await waitForServer(BASE_URL);
|
||||
|
||||
console.log('🔍 检查页面标题层级...\n');
|
||||
console.log("🔍 检查页面标题层级...\n");
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 800 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const results: PageResult[] = [];
|
||||
@@ -136,14 +190,40 @@ async function runCheck() {
|
||||
for (const { path: pagePath, name } of PAGES) {
|
||||
const url = `${BASE_URL}${pagePath}`;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const response = await page.goto(url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
// N-24:page.goto 不对 404/500 抛错,而 Next 的 not-found 页只有一个 h1、无层级跳跃
|
||||
// ⇒ 路由消失时本门禁会安静地判「层级正常」。必须显式断言状态码。
|
||||
const httpStatus = response?.status() ?? 0;
|
||||
if (httpStatus >= 400) {
|
||||
totalIssues++;
|
||||
results.push({
|
||||
name,
|
||||
url,
|
||||
passed: false,
|
||||
headings: [],
|
||||
issues: [
|
||||
{
|
||||
type: "http_error",
|
||||
message: `HTTP ${httpStatus} —— 页面未正常渲染(路由缺失/服务端错误)`,
|
||||
detail: "标题层级检查未执行",
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log(` ❌ ${name} (${pagePath}): HTTP ${httpStatus}`);
|
||||
continue;
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const headings = await page.evaluate(() => {
|
||||
const elements = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
return elements.map((el) => ({
|
||||
level: parseInt(el.tagName[1]!, 10),
|
||||
text: el.textContent?.trim() ?? '',
|
||||
text: el.textContent?.trim() ?? "",
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -159,10 +239,12 @@ async function runCheck() {
|
||||
};
|
||||
results.push(result);
|
||||
|
||||
const status = issues.length === 0 ? '✅' : '❌';
|
||||
const status = issues.length === 0 ? "✅" : "❌";
|
||||
console.log(`${status} ${name} (${issues.length} 个问题)`);
|
||||
for (const issue of issues) {
|
||||
console.log(` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ''}`);
|
||||
console.log(
|
||||
` • ${issue.message}${issue.detail ? ` (${issue.detail})` : ""}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -172,31 +254,35 @@ async function runCheck() {
|
||||
url,
|
||||
passed: false,
|
||||
headings: [],
|
||||
issues: [{ type: 'missing_h1', message: `页面加载失败: ${message}` }],
|
||||
issues: [{ type: "missing_h1", message: `页面加载失败: ${message}` }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n📊 检查摘要');
|
||||
console.log("\n📊 检查摘要");
|
||||
console.log(` 扫描页面: ${results.length}`);
|
||||
console.log(` 通过页面: ${results.filter((r) => r.passed).length}`);
|
||||
console.log(` 失败页面: ${results.filter((r) => !r.passed).length}`);
|
||||
console.log(` 问题总数: ${totalIssues}`);
|
||||
|
||||
const reportPath = path.resolve(__dirname, '../../heading-hierarchy-report.json');
|
||||
require('fs').writeFileSync(reportPath, JSON.stringify(results, null, 2));
|
||||
const reportPath = path.resolve(
|
||||
__dirname,
|
||||
"../../heading-hierarchy-report.json",
|
||||
);
|
||||
require("fs").writeFileSync(reportPath, JSON.stringify(results, null, 2));
|
||||
console.log(`\n📝 详细报告已保存: ${reportPath}`);
|
||||
|
||||
// process.exit() 不会执行 finally,所以两处出口都得自己收服服务再退出。
|
||||
stopServer(server);
|
||||
process.exit(totalIssues > 0 ? 1 : 0);
|
||||
} catch (error) {
|
||||
console.error('检查执行失败:', error);
|
||||
console.error("检查执行失败:", error);
|
||||
stopServer(server);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (server) {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
stopServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,908 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* 动效契约检查脚本(时长档位 + 缓动曲线)
|
||||
*
|
||||
* 背景:验收报告 `docs/acceptance/2026-09-23-gates/final-verdict.md` §3 的 **N-29** 记录:
|
||||
* `check:a11y` 只等于 `check:contrast` + `check:headings` + `check:brand-token`,
|
||||
* 「没有任何脚本约束动效时长与缓动曲线」。`CONTEXT.md`「动效设计四原则」是**有权威出处的硬约束**,
|
||||
* 却长期只是文档 → 本脚本把它变成可判定的门禁。
|
||||
*
|
||||
* 唯一真源:`CONTEXT.md:73-85`(✅ 2026-06-29 确认)
|
||||
* - `CONTEXT.md:76` 入场 **180–280ms**(原文明确点名 `--transition-fast` … `--transition-normal`)、
|
||||
* hover **150ms**、反馈 **100ms**
|
||||
* - `CONTEXT.md:77` 统一以 **`ease-ink` = cubic-bezier(0.22, 1, 0.36, 1)** 为默认缓动曲线
|
||||
* - `CONTEXT.md:82` **禁止超过 700ms 的入场动效**(硬上限)
|
||||
* (`CONTEXT.md:81` 禁止循环动画、`:83` 禁止 spring 做内容入场 **不在本脚本射程内** ——
|
||||
* 「是否装饰性循环 / 是否内容入场」无法从字面机械判定,留给后续独立门禁,见下方「本门禁测不到的面」。)
|
||||
*
|
||||
* ── 四条规则(每条都可被触发,无 warn 分支)────────────────────────────────
|
||||
* R1 `token-band` 令牌时长带:`--transition-instant` 必须 == 100ms(反馈档)、
|
||||
* `--transition-fast` / `--transition-normal` 必须落在 180–280ms(入场档)。
|
||||
* R2 `entrance-cap` 700ms 硬上限:CSS `transition*:` / `animation*:` 声明值、
|
||||
* framer-motion `transition` 对象里的 `duration: <秒>`、
|
||||
* Tailwind `duration-NNN` / `duration-[...]` 类、
|
||||
* `tailwind.config.js` 的 `animation` 简写串,一律不得超过 700ms。
|
||||
* 例外一:带 `infinite` 的循环动画不计入(循环不属「入场」,另立门禁)。
|
||||
* 例外二:`*-delay` 声明判的是**延迟/错落**而非时长,属 `CONTEXT.md:78`
|
||||
* 的 stagger 口径(子元素 30–60ms、Section 间 100–150ms),本脚本不判。
|
||||
* `--transition-*` 家族里非 fast/normal/instant 的成员(slow / slower / gentle)
|
||||
* 同样受此上限约束 —— `gentle: 1000ms` 就是这么被抓出来的。
|
||||
* R3 `css-transition-token` CSS/内联 transition 声明的时长必须走 `var(--transition-*)` 令牌,
|
||||
* 不得写裸 ms/s 字面量(令牌层是唯一事实来源)。
|
||||
* 例外:含 `!important` 的 `prefers-reduced-motion` 硬开关(globals.css:767-775)。
|
||||
* 引用了不存在的 `--transition-*` 令牌同样判红(悬空引用=死样式)。
|
||||
* R4 `easing-palette` 写死的贝塞尔曲线必须等于令牌层声明的某条曲线。
|
||||
* 允许集合**从 `--ease-*` 令牌声明里解析出来**(不在本脚本里硬编码),
|
||||
* 并钉住契约基准:`--ease-ink` 必须存在且 == [0.22, 1, 0.36, 1]。
|
||||
* 射程:CSS 里的 `cubic-bezier(...)`(`--ease-*` 声明自身除外)、
|
||||
* framer `ease: [a,b,c,d]`、`const EASE* = [a,b,c,d]`、
|
||||
* Tailwind `ease-[cubic-bezier(...)]` 任意值。
|
||||
*
|
||||
* ── 退出码纪律(N-22 / N-24 / A-6 的直接回应)──────────────────────────────
|
||||
* 0 = 已扫描且无违规;1 = 存在违规;**2 = 根本没能度量**
|
||||
* (目标目录/CSS 令牌文件缺失、扫描到的源码文件数为 0、`--ease-*` 或 `--transition-*` 令牌一个都没解析到)。
|
||||
* 「空扫描」绝不等于「干净」:摘要行会打印扫描到的文件数与声明数,分母为 0 即判 2。
|
||||
* 与 `scripts/accessibility/axe-node-count.mjs:52` 的 `fatal(msg, 2)` 同一口径。
|
||||
*
|
||||
* ── 本门禁测不到的面(明确交代,别把「绿」读成「动效全对」)────────────────
|
||||
* 1. hover 150ms / 入场 180–280ms 的**分类判定**:一个裸时长字面量属于 hover、反馈还是入场,
|
||||
* 无法从 class 串可靠推出 ⇒ R1 只在**被 CONTEXT.md:76 点名的三个令牌**上执行档位,
|
||||
* R2 只对**全体**时长执行 700ms 上限。故 `duration-200` 的 hover 过渡、
|
||||
* `tailwind.config.js` 里 `animate-fade-in-up: 0.7s` 这类「上限内但超入场档」的写法**不会判红**。
|
||||
* 2. 具名缓动关键字(`ease-out` / `ease-in-out` / `linear`)与 framer 字符串别名(`ease: 'easeOut'`)
|
||||
* 不做约束 —— CONTEXT.md 的令牌层本身还留着 5 条非 ink 曲线,一刀切会与令牌层冲突。
|
||||
* 3. `duration={N}` 这类 JSX prop 不解析(同一 prop 名在 `ScrollReveal` 是秒、
|
||||
* 在 `AnimatedCounter` 是毫秒,机械判定必假阳),只看对象字面量 `duration:` 且处于 transition 对象内。
|
||||
* 4. `CONTEXT.md:81` 循环动画、`:83` spring 入场、`:78` stagger(子元素 30–60ms、
|
||||
* Section 间 100–150ms)与一切 `*-delay` 延迟值都不在射程内 —— 本脚本只判**时长**。
|
||||
*
|
||||
* 用法:
|
||||
* npx tsx scripts/utils/check-motion-constraints.ts
|
||||
* npx tsx scripts/utils/check-motion-constraints.ts --root /tmp/fixture # 自证伪:对夹具跑
|
||||
* npx tsx scripts/utils/check-motion-constraints.ts --src src --css src/app/globals.css
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 契约常量(出处见文件头;数值与 CONTEXT.md:76/77/82 逐条对齐)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export const MOTION_SPEC = {
|
||||
/** CONTEXT.md:76 入场下界 */
|
||||
entranceMinMs: 180,
|
||||
/** CONTEXT.md:76 入场上界 */
|
||||
entranceMaxMs: 280,
|
||||
/** CONTEXT.md:76 反馈档(`--transition-instant`) */
|
||||
feedbackMs: 100,
|
||||
/** CONTEXT.md:82 入场硬上限(「禁止超过 700ms」,故 700ms 本身合法) */
|
||||
entranceCapMs: 700,
|
||||
/** CONTEXT.md:77 默认缓动曲线 ease-ink */
|
||||
inkCurve: [0.22, 1, 0.36, 1] as readonly number[],
|
||||
} as const;
|
||||
|
||||
export const EXIT_CLEAN = 0;
|
||||
export const EXIT_VIOLATION = 1;
|
||||
export const EXIT_UNMEASURABLE = 2;
|
||||
|
||||
export type RuleId =
|
||||
| 'token-band'
|
||||
| 'entrance-cap'
|
||||
| 'css-transition-token'
|
||||
| 'easing-palette';
|
||||
|
||||
export interface RuleSpec {
|
||||
id: RuleId;
|
||||
title: string;
|
||||
/** 权威出处 */
|
||||
source: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export const MOTION_RULES: readonly RuleSpec[] = [
|
||||
{
|
||||
id: 'token-band',
|
||||
title: 'R1 令牌时长带:instant==100ms、fast/normal ∈ 180–280ms',
|
||||
source: 'CONTEXT.md:76',
|
||||
suggestion: '把令牌值改回 CONTEXT.md 档位(反馈 100ms;入场 180–280ms),或换用已合规的令牌',
|
||||
},
|
||||
{
|
||||
id: 'entrance-cap',
|
||||
title: 'R2 时长硬上限:任何动效时长不得超过 700ms(infinite 循环动画除外)',
|
||||
source: 'CONTEXT.md:82',
|
||||
suggestion: '降到 ≤ 700ms,入场动画应落到 var(--transition-fast)…var(--transition-normal)',
|
||||
},
|
||||
{
|
||||
id: 'css-transition-token',
|
||||
title: 'R3 令牌间接层:transition 声明的时长必须走 var(--transition-*)',
|
||||
source: 'CONTEXT.md:76(令牌层是时长的唯一事实来源)',
|
||||
suggestion: '裸 ms/s 字面量 → var(--transition-instant | --transition-fast | --transition-normal)',
|
||||
},
|
||||
{
|
||||
id: 'easing-palette',
|
||||
title: 'R4 缓动曲线:写死的 cubic-bezier 必须等于 --ease-* 令牌集,且 --ease-ink 必须是 [0.22,1,0.36,1]',
|
||||
source: 'CONTEXT.md:77',
|
||||
suggestion: '改用 var(--ease-*) / ease-* 类,或把曲线值改回令牌层已声明的那条',
|
||||
},
|
||||
];
|
||||
|
||||
export interface MotionViolation {
|
||||
rule: RuleId;
|
||||
file: string;
|
||||
line: number;
|
||||
token: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ScanTargets {
|
||||
/** 仓库根(相对路径都相对它解析);夹具自证伪时指向临时目录 */
|
||||
root: string;
|
||||
srcDir: string;
|
||||
cssFiles: string[];
|
||||
configFiles: string[];
|
||||
}
|
||||
|
||||
export interface ScanCounts {
|
||||
sourceFiles: number;
|
||||
cssFiles: number;
|
||||
configFiles: number;
|
||||
declarations: number;
|
||||
transitionTokens: number;
|
||||
easeTokens: number;
|
||||
bezierLiterals: number;
|
||||
durationLiterals: number;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
exitCode: number;
|
||||
violations: MotionViolation[];
|
||||
counts: ScanCounts;
|
||||
/** 允许集合:从 CSS 令牌层 `--ease-*` 解析出的贝塞尔曲线(人类可读键) */
|
||||
allowedCurves: string[];
|
||||
/** 无法度量的原因(非空即 exitCode = EXIT_UNMEASURABLE) */
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 通用工具
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 输出走 process.stdout:与其余 checker 的排版一致,但不新增 `no-console` 告警 */
|
||||
function emit(line: string): void {
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
|
||||
function emitError(line: string): void {
|
||||
process.stderr.write(`${line}\n`);
|
||||
}
|
||||
|
||||
/** `180ms` / `.6s` / `0.01ms` → 毫秒;解析不出来返回 null */
|
||||
export function parseDurationMs(raw: string): number | null {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(raw.trim());
|
||||
const value = match?.[1];
|
||||
const unit = match?.[2];
|
||||
if (!value || !unit) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return unit === 's' ? parsed * 1000 : parsed;
|
||||
}
|
||||
|
||||
/** 值里出现的裸时长字面量(`2s` / `0.6s` / `150ms`) */
|
||||
const TIME_LITERAL = /\b\d+(?:\.\d+)?(?:ms|s)\b/g;
|
||||
|
||||
/** 贝塞尔曲线:`cubic-bezier(a, b, c, d)` / `[a, b, c, d]` 共用 */
|
||||
export function normalizeCurve(numbers: readonly number[]): string | null {
|
||||
if (numbers.length !== 4) return null;
|
||||
if (!numbers.every((n) => Number.isFinite(n))) return null;
|
||||
return numbers.map((n) => n.toFixed(3)).join(', ');
|
||||
}
|
||||
|
||||
function curveOfBezierArgs(args: string): string | null {
|
||||
const numbers = args
|
||||
.split(',')
|
||||
.map((part) => Number(part.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
return normalizeCurve(numbers);
|
||||
}
|
||||
|
||||
const CUBIC_BEZIER = /cubic-bezier\(\s*([^)]*)\)/gi;
|
||||
|
||||
/** 逐行文本的行号解析器(一次建表,二分查找) */
|
||||
function makeLineResolver(text: string): (index: number) => number {
|
||||
const newlines: number[] = [];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === '\n') newlines.push(i);
|
||||
}
|
||||
return (index: number) => {
|
||||
let low = 0;
|
||||
let high = newlines.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if ((newlines[mid] as number) < index) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low + 1;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CssDeclaration {
|
||||
/** `transition` / `transition-duration` / `animation` / `--transition-fast` / `--ease-ink` … */
|
||||
prop: string;
|
||||
value: string;
|
||||
line: number;
|
||||
isCustomProperty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 CSS 文本里抽出「属性: 值」声明。
|
||||
* `[^;{}]*` 天然跨行,故 globals.css:314 那种多行值也能拿到完整串。
|
||||
* 自定义属性(`--transition-*` / `--ease-*` / `--card-transition`)与普通声明都会拿到,
|
||||
* 由调用方按 prop 名分流;`--card-transition` 这类「组件级过渡令牌」也在射程内,
|
||||
* 因为它同样是编译进产物的 CSS 值。
|
||||
*/
|
||||
export function extractCssDeclarations(text: string): CssDeclaration[] {
|
||||
const declarations: CssDeclaration[] = [];
|
||||
const lineOf = makeLineResolver(text);
|
||||
const pattern = /(--[\w-]+|[a-z][\w-]*)\s*:\s*([^;{}]*)/gi;
|
||||
let match: RegExpExecArray | null = pattern.exec(text);
|
||||
while (match) {
|
||||
const prop = match[1] as string;
|
||||
const value = match[2] as string;
|
||||
// 只看动效相关属性:transition* / animation* / --transition-* / --ease-* / --card-transition
|
||||
const baseName = prop.replace(/^--/, '').toLowerCase();
|
||||
const isMotionProperty =
|
||||
/transition|animation/.test(baseName) || /^ease(-|$)/.test(baseName);
|
||||
if (isMotionProperty) {
|
||||
declarations.push({
|
||||
prop,
|
||||
value,
|
||||
line: lineOf(match.index),
|
||||
isCustomProperty: prop.startsWith('--'),
|
||||
});
|
||||
}
|
||||
match = pattern.exec(text);
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
/** 收集 cubic-bezier 字面量(曲线键 + 原文 + 行号,行号相对传入的文本) */
|
||||
function collectBezierLiterals(
|
||||
text: string,
|
||||
): Array<{ curve: string; token: string; line: number; index: number }> {
|
||||
const lineOf = makeLineResolver(text);
|
||||
const found: Array<{ curve: string; token: string; line: number; index: number }> = [];
|
||||
CUBIC_BEZIER.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = CUBIC_BEZIER.exec(text);
|
||||
while (match) {
|
||||
const index = match.index;
|
||||
const curve = curveOfBezierArgs(match[1] as string);
|
||||
if (curve) {
|
||||
found.push({ curve, token: match[0], line: lineOf(index), index });
|
||||
}
|
||||
match = CUBIC_BEZIER.exec(text);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** 收集 `ease: [a,b,c,d]` / `const EASE = [a,b,c,d]` 形态的贝塞尔数组 */
|
||||
function collectEaseArrays(text: string): Array<{ curve: string; token: string; line: number }> {
|
||||
const lineOf = makeLineResolver(text);
|
||||
const found: Array<{ curve: string; token: string; line: number }> = [];
|
||||
|
||||
const record = (token: string, args: string, index: number): void => {
|
||||
const curve = curveOfBezierArgs(args);
|
||||
if (curve) found.push({ curve, token: token.trim(), line: lineOf(index) });
|
||||
};
|
||||
|
||||
// framer-motion `transition={{ ease: [0.22, 1, 0.36, 1] }}`
|
||||
const easeKey = /\bease\s*:\s*\[([^\]]*)\]/gi;
|
||||
let match: RegExpExecArray | null = easeKey.exec(text);
|
||||
while (match) {
|
||||
record(match[0], match[1] as string, match.index);
|
||||
match = easeKey.exec(text);
|
||||
}
|
||||
|
||||
// 模块级曲线常量 `const EASE = [...] as const` / `export const EASE_OUT = [...]`。
|
||||
// 变量名单独用 /ease|curve/i 判定:`EASE` 全大写无法被大小写敏感的字面匹配抓住(首轮实测漏检过)。
|
||||
const easeConst = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*\[([^\]]*)\]/g;
|
||||
match = easeConst.exec(text);
|
||||
while (match) {
|
||||
if (/ease|curve/i.test(match[1] as string)) record(match[0], match[2] as string, match.index);
|
||||
match = easeConst.exec(text);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定源码文本中「哪些行处于 framer-motion transition 对象内部」。
|
||||
* 逐字符维护花括号深度:遇到 `transition`(`transition:` / `transition={{`)后的第一个 `{`
|
||||
* 记为该对象的目标深度,深度回落到目标值以下即出栈。
|
||||
* 这样能区分 `transition: { duration: 0.28 }`(动效)与 `toast(x, { duration: 3000 })`(毫秒显示时长)。
|
||||
*/
|
||||
export function markTransitionLines(lines: readonly string[]): boolean[] {
|
||||
const flags: boolean[] = lines.map(() => false);
|
||||
let depth = 0;
|
||||
const stack: number[] = [];
|
||||
lines.forEach((line, index) => {
|
||||
const keyword = /\btransition\w*\s*[:=]/.exec(line);
|
||||
let openedAt: number | null = null;
|
||||
if (keyword) flags[index] = true;
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const char = line[i];
|
||||
if (char === '{') {
|
||||
depth += 1;
|
||||
if (openedAt === null && keyword && i > keyword.index) openedAt = depth;
|
||||
} else if (char === '}') {
|
||||
if (stack.length > 0 && depth === (stack[stack.length - 1] as number)) stack.pop();
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
if (openedAt !== null) stack.push(openedAt);
|
||||
if (stack.length > 0) flags[index] = true;
|
||||
});
|
||||
return flags;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 扫描器
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
interface TokenLayer {
|
||||
/** `--transition-<name>` → 毫秒 */
|
||||
transitionTokens: Map<string, number>;
|
||||
/** 允许集合:`--ease-*` 令牌解析出的曲线键 */
|
||||
allowedCurves: Set<string>;
|
||||
/** `--ease-<name>` → 曲线键 */
|
||||
easeTokens: Map<string, string>;
|
||||
}
|
||||
|
||||
const TRANSITION_CUSTOM_PROP = /^--transition-([\w-]+)$/i;
|
||||
const EASE_CUSTOM_PROP = /^--ease-([\w-]+)$/i;
|
||||
|
||||
/** 第一遍:从 CSS 令牌层读出 `--transition-*` 时长与 `--ease-*` 曲线(允许集合不硬编码,见 R4 说明) */
|
||||
function readTokenLayer(cssDeclarations: CssDeclaration[], counts: ScanCounts): TokenLayer {
|
||||
const transitionTokens = new Map<string, number>();
|
||||
const easeTokens = new Map<string, string>();
|
||||
const allowedCurves = new Set<string>();
|
||||
|
||||
for (const declaration of cssDeclarations) {
|
||||
if (!declaration.isCustomProperty) continue;
|
||||
const transitionName = declaration.prop.match(TRANSITION_CUSTOM_PROP)?.[1];
|
||||
if (transitionName) {
|
||||
const ms = parseDurationMs(declaration.value);
|
||||
if (ms !== null) {
|
||||
transitionTokens.set(transitionName.toLowerCase(), ms);
|
||||
counts.transitionTokens += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const easeName = declaration.prop.match(EASE_CUSTOM_PROP)?.[1];
|
||||
if (easeName) {
|
||||
counts.easeTokens += 1;
|
||||
const curve = firstBezierCurve(declaration.value);
|
||||
if (curve) {
|
||||
easeTokens.set(easeName.toLowerCase(), curve);
|
||||
allowedCurves.add(curve);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { transitionTokens, easeTokens, allowedCurves };
|
||||
}
|
||||
|
||||
/** 取串里第一条 cubic-bezier 的归一化曲线键(令牌定义用,单值语义) */
|
||||
function firstBezierCurve(value: string): string | null {
|
||||
const literal = collectBezierLiterals(value)[0];
|
||||
return literal ? literal.curve : null;
|
||||
}
|
||||
|
||||
/** CSS 令牌文件:R1 / R2 / R3 / R4 */
|
||||
export function scanCssText(
|
||||
text: string,
|
||||
relativeFile: string,
|
||||
layer: TokenLayer,
|
||||
counts: ScanCounts,
|
||||
): MotionViolation[] {
|
||||
const violations: MotionViolation[] = [];
|
||||
const declarations = extractCssDeclarations(text);
|
||||
const push = (
|
||||
rule: RuleId,
|
||||
line: number,
|
||||
token: string,
|
||||
message: string,
|
||||
): void => {
|
||||
violations.push({ rule, file: relativeFile, line, token, message });
|
||||
};
|
||||
|
||||
for (const declaration of declarations) {
|
||||
counts.declarations += 1;
|
||||
const transitionTokenName = declaration.prop.match(TRANSITION_CUSTOM_PROP)?.[1];
|
||||
const easeTokenName = declaration.prop.match(EASE_CUSTOM_PROP)?.[1];
|
||||
|
||||
/* --- 令牌声明自身:R1 档位 + R2 上限 --- */
|
||||
if (transitionTokenName) {
|
||||
const ms = parseDurationMs(declaration.value);
|
||||
if (ms === null) continue; // 非时长形态(注释/别名)由 readTokenLayer 已忽略
|
||||
counts.durationLiterals += 1;
|
||||
const name = transitionTokenName.toLowerCase();
|
||||
if (name === 'instant' && ms !== MOTION_SPEC.feedbackMs) {
|
||||
push(
|
||||
'token-band',
|
||||
declaration.line,
|
||||
declaration.value.trim(),
|
||||
`--transition-${name} 是反馈档,必须为 ${MOTION_SPEC.feedbackMs}ms(实测 ${ms}ms)`,
|
||||
);
|
||||
}
|
||||
if ((name === 'fast' || name === 'normal') &&
|
||||
(ms < MOTION_SPEC.entranceMinMs || ms > MOTION_SPEC.entranceMaxMs)) {
|
||||
push(
|
||||
'token-band',
|
||||
declaration.line,
|
||||
declaration.value.trim(),
|
||||
`--transition-${name} 是入场档,必须落在 ${MOTION_SPEC.entranceMinMs}–${MOTION_SPEC.entranceMaxMs}ms(实测 ${ms}ms)`,
|
||||
);
|
||||
}
|
||||
if (ms > MOTION_SPEC.entranceCapMs) {
|
||||
push(
|
||||
'entrance-cap',
|
||||
declaration.line,
|
||||
declaration.value.trim(),
|
||||
`--transition-${name} = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 入场硬上限`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* --- ease-* 令牌定义自身:R4 的 --ease-ink 基准钉(曲线本身不进「写死曲线」判定) --- */
|
||||
if (easeTokenName) {
|
||||
if (easeTokenName.toLowerCase() === 'ink') {
|
||||
const curve = firstBezierCurve(declaration.value);
|
||||
const expected = normalizeCurve(MOTION_SPEC.inkCurve);
|
||||
if (curve !== expected) {
|
||||
push(
|
||||
'easing-palette',
|
||||
declaration.line,
|
||||
declaration.value.trim(),
|
||||
`--ease-ink 必须等于 cubic-bezier(${MOTION_SPEC.inkCurve.join(', ')})(实测 ${curve ?? '无法解析'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// `--card-transition` 这类组件级过渡令牌同样算 transition 声明(它会被原样编译进 CSS 值)
|
||||
const isTransition = /transition/i.test(declaration.prop);
|
||||
const isAnimation = /animation/i.test(declaration.prop);
|
||||
if (!isTransition && !isAnimation) continue;
|
||||
|
||||
// `*-delay` 是**延迟/错落**,不是时长:CONTEXT.md:78 的 stagger(子元素 30–60ms、Section 间 100–150ms)
|
||||
// 是另一条口径,本门禁不判(globals.css:1285-1291 的 .animation-delay-200…800 工具类即属此类)。
|
||||
if (/-delay$/i.test(declaration.prop.replace(/^--/, ''))) continue;
|
||||
|
||||
const value = declaration.value.trim();
|
||||
if (!value) continue;
|
||||
|
||||
/* --- R4:CSS 里写死的 cubic-bezier 必须在令牌允许集合里 --- */
|
||||
for (const literal of collectBezierLiterals(value)) {
|
||||
counts.bezierLiterals += 1;
|
||||
if (!layer.allowedCurves.has(literal.curve)) {
|
||||
push(
|
||||
'easing-palette',
|
||||
declaration.line,
|
||||
`cubic-bezier(${literal.curve})`,
|
||||
`${declaration.prop} 用了未在 --ease-* 中声明的曲线`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- R3:transition 声明的时长必须走令牌 --- */
|
||||
const reducedMotionKillSwitch = isTransition && /!important/i.test(value);
|
||||
if (isTransition && !reducedMotionKillSwitch) {
|
||||
for (const raw of value.match(TIME_LITERAL) ?? []) {
|
||||
counts.durationLiterals += 1;
|
||||
push(
|
||||
'css-transition-token',
|
||||
declaration.line,
|
||||
raw,
|
||||
`${declaration.prop} 写了裸时长字面量,应改用 var(--transition-*)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- R2:700ms 硬上限(infinite 循环动画豁免) --- */
|
||||
const loopsInfinitely = /\binfinite\b/i.test(value);
|
||||
const rawDurations = value.match(TIME_LITERAL) ?? [];
|
||||
if (!loopsInfinitely) {
|
||||
for (const raw of rawDurations) {
|
||||
const ms = parseDurationMs(raw);
|
||||
if (ms === null) continue;
|
||||
if (ms > MOTION_SPEC.entranceCapMs) {
|
||||
push(
|
||||
'entrance-cap',
|
||||
declaration.line,
|
||||
raw,
|
||||
`${declaration.prop} 的 ${raw} = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// var(--transition-x) 引用:解析后同样受上限约束;悬空引用即死样式
|
||||
for (const reference of value.match(/var\(\s*--transition-[\w-]+\s*\)/g) ?? []) {
|
||||
const name = /--transition-([\w-]+)/.exec(reference)?.[1]?.toLowerCase();
|
||||
if (!name) continue;
|
||||
const ms = layer.transitionTokens.get(name);
|
||||
if (ms === undefined) {
|
||||
push(
|
||||
'css-transition-token',
|
||||
declaration.line,
|
||||
`var(--transition-${name})`,
|
||||
`引用了未定义的 --transition-${name} 令牌(编译后不生效)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
counts.durationLiterals += 1;
|
||||
if (ms > MOTION_SPEC.entranceCapMs) {
|
||||
push(
|
||||
'entrance-cap',
|
||||
declaration.line,
|
||||
`var(--transition-${name})`,
|
||||
`${declaration.prop} 解析为 ${ms}ms,超过 ${MOTION_SPEC.entranceCapMs}ms 上限`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
const TAILWIND_DURATION_CLASS = /\bduration-\[?(\d+(?:\.\d+)?(?:ms|s)?)\]?/g;
|
||||
const TAILWIND_EASE_ARBITRARY = /\bease-\[([^\]]*)\]/g;
|
||||
|
||||
/** Tailwind 时长类:`duration-150` 的裸数字单位是 ms,`duration-[0.6s]` 才带单位 */
|
||||
export function parseTailwindDuration(raw: string): number | null {
|
||||
const withUnit = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(raw);
|
||||
if (withUnit) return parseDurationMs(withUnit[0]);
|
||||
const bare = /^\d+(?:\.\d+)?$/.exec(raw);
|
||||
if (!bare) return null;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/** 源码文件(.ts / .tsx):R2(framer duration、Tailwind duration-*)+ R4(ease 数组 / 任意值)+ R3(内联 transition 串) */
|
||||
export function scanSourceText(
|
||||
text: string,
|
||||
relativeFile: string,
|
||||
layer: TokenLayer,
|
||||
counts: ScanCounts,
|
||||
): MotionViolation[] {
|
||||
const violations: MotionViolation[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
const inTransition = markTransitionLines(lines);
|
||||
|
||||
const push = (rule: RuleId, line: number, token: string, message: string): void => {
|
||||
violations.push({ rule, file: relativeFile, line, token, message });
|
||||
};
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
|
||||
/* R2 —— framer-motion transition 对象里的 `duration: <秒>` */
|
||||
if (inTransition[index]) {
|
||||
const durationMatch = /(?<![\w-])duration\s*:\s*(\d+(?:\.\d+)?)(?![\w.])/.exec(line);
|
||||
const seconds = durationMatch?.[1];
|
||||
if (seconds) {
|
||||
const ms = Number(seconds) * 1000;
|
||||
counts.durationLiterals += 1;
|
||||
if (ms > MOTION_SPEC.entranceCapMs) {
|
||||
push(
|
||||
'entrance-cap',
|
||||
lineNumber,
|
||||
`duration: ${seconds}`,
|
||||
`framer-motion transition 的 duration ${seconds}s = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* R3 —— 内联/常量里的 CSS transition 串(`transition: 'all 0.3s cubic-bezier(...)'`) */
|
||||
const inlineTransition = /\btransition\s*:\s*['"`]([^'"`]*)['"`]/.exec(line);
|
||||
const inlineValue = inlineTransition?.[1];
|
||||
if (inlineValue) {
|
||||
for (const raw of inlineValue.match(TIME_LITERAL) ?? []) {
|
||||
counts.durationLiterals += 1;
|
||||
push(
|
||||
'css-transition-token',
|
||||
lineNumber,
|
||||
raw,
|
||||
'内联 transition 串写了裸时长字面量,CSS 变量在 style 串里同样可用:var(--transition-*)',
|
||||
);
|
||||
const ms = parseDurationMs(raw);
|
||||
if (ms !== null && ms > MOTION_SPEC.entranceCapMs) {
|
||||
push('entrance-cap', lineNumber, raw, `${raw} = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`);
|
||||
}
|
||||
}
|
||||
for (const literal of collectBezierLiterals(inlineValue)) {
|
||||
counts.bezierLiterals += 1;
|
||||
if (!layer.allowedCurves.has(literal.curve)) {
|
||||
push('easing-palette', lineNumber, `cubic-bezier(${literal.curve})`, '内联 transition 串的曲线未在 --ease-* 中声明');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* R2 —— Tailwind duration-NNN / duration-[...] 类 */
|
||||
TAILWIND_DURATION_CLASS.lastIndex = 0;
|
||||
let classMatch: RegExpExecArray | null = TAILWIND_DURATION_CLASS.exec(line);
|
||||
while (classMatch) {
|
||||
const raw = classMatch[1] as string;
|
||||
const ms = parseTailwindDuration(raw);
|
||||
counts.durationLiterals += 1;
|
||||
if (ms !== null && ms > MOTION_SPEC.entranceCapMs) {
|
||||
push('entrance-cap', lineNumber, `duration-${raw}`, `Tailwind 时长类 = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`);
|
||||
}
|
||||
classMatch = TAILWIND_DURATION_CLASS.exec(line);
|
||||
}
|
||||
|
||||
/* R4 —— Tailwind ease-[cubic-bezier(...)] 任意值 */
|
||||
TAILWIND_EASE_ARBITRARY.lastIndex = 0;
|
||||
let easeClass: RegExpExecArray | null = TAILWIND_EASE_ARBITRARY.exec(line);
|
||||
while (easeClass) {
|
||||
const inner = easeClass[1] as string;
|
||||
for (const literal of collectBezierLiterals(inner)) {
|
||||
counts.bezierLiterals += 1;
|
||||
if (!layer.allowedCurves.has(literal.curve)) {
|
||||
push('easing-palette', lineNumber, `ease-[cubic-bezier(${literal.curve})]`, 'Tailwind 任意值曲线未在 --ease-* 中声明');
|
||||
}
|
||||
}
|
||||
easeClass = TAILWIND_EASE_ARBITRARY.exec(line);
|
||||
}
|
||||
});
|
||||
|
||||
/* R4 —— `ease: [a,b,c,d]` 与 `const EASE* = [a,b,c,d]`(跨行也能抓到,故整文件扫) */
|
||||
for (const item of collectEaseArrays(text)) {
|
||||
counts.bezierLiterals += 1;
|
||||
if (!layer.allowedCurves.has(item.curve)) {
|
||||
push(
|
||||
'easing-palette',
|
||||
item.line,
|
||||
item.token,
|
||||
`曲线 [${item.curve}] 不在令牌层 --ease-* 允许集合内(默认应为 ease-ink [${normalizeCurve(MOTION_SPEC.inkCurve)}])`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** tailwind.config.js 的 `animation` 简写串(animate-* 类的真实时长来源):R2 + R4 */
|
||||
export function scanConfigText(
|
||||
text: string,
|
||||
relativeFile: string,
|
||||
layer: TokenLayer,
|
||||
counts: ScanCounts,
|
||||
): MotionViolation[] {
|
||||
const violations: MotionViolation[] = [];
|
||||
const lineOf = makeLineResolver(text);
|
||||
const stringPair = /['"]([^'"]+)['"]\s*:\s*['"]([^'"]*)['"]/g;
|
||||
let match: RegExpExecArray | null = stringPair.exec(text);
|
||||
while (match) {
|
||||
const value = match[2] as string;
|
||||
const line = lineOf(match.index);
|
||||
const loopsInfinitely = /\binfinite\b/i.test(value);
|
||||
for (const raw of value.match(TIME_LITERAL) ?? []) {
|
||||
counts.durationLiterals += 1;
|
||||
const ms = parseDurationMs(raw);
|
||||
if (!loopsInfinitely && ms !== null && ms > MOTION_SPEC.entranceCapMs) {
|
||||
violations.push({
|
||||
rule: 'entrance-cap',
|
||||
file: relativeFile,
|
||||
line,
|
||||
token: raw,
|
||||
message: `animate-${match[1]} 的 ${raw} = ${ms}ms 超过 ${MOTION_SPEC.entranceCapMs}ms 上限`,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const literal of collectBezierLiterals(value)) {
|
||||
counts.bezierLiterals += 1;
|
||||
if (!layer.allowedCurves.has(literal.curve)) {
|
||||
violations.push({
|
||||
rule: 'easing-palette',
|
||||
file: relativeFile,
|
||||
line,
|
||||
token: `cubic-bezier(${literal.curve})`,
|
||||
message: `animate-${match[1]} 的曲线未在 --ease-* 中声明`,
|
||||
});
|
||||
}
|
||||
}
|
||||
match = stringPair.exec(text);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
const SOURCE_EXTENSIONS = ['.ts', '.tsx'];
|
||||
|
||||
function isTestPath(relativeFile: string): boolean {
|
||||
return (
|
||||
/\.test\.[jt]sx?$/.test(relativeFile) ||
|
||||
relativeFile.includes('__tests__') ||
|
||||
relativeFile.includes(`${path.sep}_archive`) ||
|
||||
relativeFile.includes('/_archive/')
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
} else if (SOURCE_EXTENSIONS.includes(path.extname(entry.name))) {
|
||||
accumulator.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function relative(root: string, target: string): string {
|
||||
return path.relative(root, target) || '.';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 编排
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function runScan(targets: ScanTargets): ScanResult {
|
||||
const counts: ScanCounts = {
|
||||
sourceFiles: 0,
|
||||
cssFiles: 0,
|
||||
configFiles: 0,
|
||||
declarations: 0,
|
||||
transitionTokens: 0,
|
||||
easeTokens: 0,
|
||||
bezierLiterals: 0,
|
||||
durationLiterals: 0,
|
||||
};
|
||||
const blockers: string[] = [];
|
||||
const violations: MotionViolation[] = [];
|
||||
const cssTexts: Array<{ file: string; text: string }> = [];
|
||||
|
||||
for (const cssFile of targets.cssFiles) {
|
||||
const absolute = path.resolve(targets.root, cssFile);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
blockers.push(`CSS 令牌文件不存在:${relative(targets.root, absolute)}`);
|
||||
continue;
|
||||
}
|
||||
cssTexts.push({ file: relative(targets.root, absolute), text: fs.readFileSync(absolute, 'utf8') });
|
||||
counts.cssFiles += 1;
|
||||
}
|
||||
|
||||
// 允许集合来自令牌层,故必须先跑一遍
|
||||
const allCssDeclarations = cssTexts.flatMap((item) => extractCssDeclarations(item.text));
|
||||
const layer = readTokenLayer(allCssDeclarations, counts);
|
||||
|
||||
if (counts.easeTokens === 0) {
|
||||
blockers.push('未从 CSS 令牌层解析到任何 --ease-* 声明(允许集合为空,R4 无法判定)');
|
||||
}
|
||||
if (counts.transitionTokens === 0) {
|
||||
blockers.push('未从 CSS 令牌层解析到任何 --transition-* 时长(R1/R2 无法判定)');
|
||||
}
|
||||
|
||||
for (const item of cssTexts) {
|
||||
violations.push(...scanCssText(item.text, item.file, layer, counts));
|
||||
}
|
||||
|
||||
const absoluteSrc = path.resolve(targets.root, targets.srcDir);
|
||||
if (!fs.existsSync(absoluteSrc)) {
|
||||
blockers.push(`扫描目录不存在:${relative(targets.root, absoluteSrc)}`);
|
||||
} else {
|
||||
const files: string[] = [];
|
||||
collectSourceFiles(absoluteSrc, files);
|
||||
const candidates = files
|
||||
.map((file) => relative(targets.root, file))
|
||||
.filter((file) => !isTestPath(file))
|
||||
.sort();
|
||||
for (const file of candidates) {
|
||||
const text = fs.readFileSync(path.resolve(targets.root, file), 'utf8');
|
||||
counts.sourceFiles += 1;
|
||||
violations.push(...scanSourceText(text, file, layer, counts));
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
blockers.push(`扫描目录 ${relative(targets.root, absoluteSrc)} 下没有可判定的 .ts/.tsx 源码文件`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const configFile of targets.configFiles) {
|
||||
const absolute = path.resolve(targets.root, configFile);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
blockers.push(`动效配置文件不存在:${relative(targets.root, absolute)}`);
|
||||
continue;
|
||||
}
|
||||
counts.configFiles += 1;
|
||||
violations.push(...scanConfigText(fs.readFileSync(absolute, 'utf8'), relative(targets.root, absolute), layer, counts));
|
||||
}
|
||||
|
||||
// 排序按「文件 → 行号(数值)→ 规则」,别让 `:10` 排在 `:8` 前面
|
||||
violations.sort((a, b) =>
|
||||
a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule),
|
||||
);
|
||||
|
||||
const exitCode = blockers.length > 0 ? EXIT_UNMEASURABLE : violations.length > 0 ? EXIT_VIOLATION : EXIT_CLEAN;
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
violations,
|
||||
counts,
|
||||
allowedCurves: [...layer.allowedCurves].sort(),
|
||||
blockers,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ScanTargets {
|
||||
const readFlag = (flag: string): string | undefined => {
|
||||
const index = argv.indexOf(flag);
|
||||
return index !== -1 ? argv[index + 1] : undefined;
|
||||
};
|
||||
const repoRoot = path.resolve(__dirname, '../..');
|
||||
const root = path.resolve(repoRoot, readFlag('--root') ?? '.');
|
||||
const css = readFlag('--css');
|
||||
const config = readFlag('--config');
|
||||
return {
|
||||
root,
|
||||
srcDir: readFlag('--src') ?? 'src',
|
||||
cssFiles: css ? css.split(',').map((item) => item.trim()).filter(Boolean) : ['src/app/globals.css'],
|
||||
configFiles: config ? config.split(',').map((item) => item.trim()).filter(Boolean) : ['tailwind.config.js'],
|
||||
};
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const targets = parseArgs(process.argv.slice(2));
|
||||
emit('\n🎬 动效契约检查(时长档位 + 缓动曲线)\n');
|
||||
emit(` 契约:入场 ${MOTION_SPEC.entranceMinMs}–${MOTION_SPEC.entranceMaxMs}ms · 反馈 ${MOTION_SPEC.feedbackMs}ms · 上限 ${MOTION_SPEC.entranceCapMs}ms · ease-ink [${MOTION_SPEC.inkCurve.join(', ')}]`);
|
||||
emit(' 出处:CONTEXT.md:76 / :77 / :82(N-29:此前无任何脚本约束)\n');
|
||||
|
||||
const result = runScan(targets);
|
||||
|
||||
emit(` 令牌层允许曲线:${result.allowedCurves.length ? result.allowedCurves.map((c) => `[${c}]`).join(' ') : '(无)'}`);
|
||||
|
||||
for (const violation of result.violations) {
|
||||
emit(`❌ [${violation.rule}] ${violation.file}:${violation.line} ${violation.token}`);
|
||||
emit(` ${violation.message}`);
|
||||
}
|
||||
|
||||
emit('\n📊 检查摘要');
|
||||
emit(` 扫描源码文件: ${result.counts.sourceFiles}(.ts/.tsx,已排除测试与 _archive)`);
|
||||
emit(` 扫描令牌/配置: CSS ${result.counts.cssFiles} · config ${result.counts.configFiles}`);
|
||||
emit(` 判定分母: CSS 声明 ${result.counts.declarations} · 时长字面量 ${result.counts.durationLiterals} · 贝塞尔字面量 ${result.counts.bezierLiterals} · --transition-* 令牌 ${result.counts.transitionTokens} · --ease-* 令牌 ${result.counts.easeTokens}`);
|
||||
emit(` 违规处数: ${result.violations.length}`);
|
||||
|
||||
if (result.violations.length > 0) {
|
||||
const byRule = new Map<RuleId, number>();
|
||||
for (const violation of result.violations) {
|
||||
byRule.set(violation.rule, (byRule.get(violation.rule) ?? 0) + 1);
|
||||
}
|
||||
emit('\n📋 分规则计数');
|
||||
for (const rule of MOTION_RULES) {
|
||||
emit(` ${rule.id}: ${byRule.get(rule.id) ?? 0} —— ${rule.title}(${rule.source})`);
|
||||
}
|
||||
const firstRule = MOTION_RULES.find((rule) => rule.id === result.violations[0]?.rule);
|
||||
if (firstRule) emit(`\n 修复方向示例(${firstRule.id}):${firstRule.suggestion}`);
|
||||
}
|
||||
|
||||
if (result.blockers.length > 0) {
|
||||
emitError('\n🚫 本轮**未能度量**(空扫描 ≠ 干净,判 2):');
|
||||
for (const blocker of result.blockers) emitError(` • ${blocker}`);
|
||||
process.exit(EXIT_UNMEASURABLE);
|
||||
}
|
||||
|
||||
if (result.violations.length > 0) {
|
||||
emitError('\n❌ 存在动效契约违反!');
|
||||
process.exit(EXIT_VIOLATION);
|
||||
}
|
||||
|
||||
emit('\n✅ 动效时长与缓动曲线全部落在 CONTEXT.md 契约内!');
|
||||
process.exit(EXIT_CLEAN);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -58,13 +58,24 @@ function parseCookies(setCookieHeader: string | null): Array<{ name: string; fla
|
||||
}
|
||||
|
||||
function checkCookies(cookies: Array<{ name: string; flags: string[] }>): CookieCheck[] {
|
||||
return cookies.map((cookie) => ({
|
||||
name: cookie.name,
|
||||
hasHttpOnly: cookie.flags.some((f) => f === 'httponly'),
|
||||
hasSecure: cookie.flags.some((f) => f === 'secure'),
|
||||
hasSameSite: cookie.flags.some((f) => f.startsWith('samesite')),
|
||||
status: 'pass' as const,
|
||||
}));
|
||||
// N-22:此前 status 恒为字面量 'pass',上面三个 flag 全部白算、cookieFailed 永远为 0,
|
||||
// 而汇总的 hasFailures 只看 headerChecks ⇒ 任何 cookie 属性错误都无法让本门禁变红。
|
||||
return cookies.map((cookie) => {
|
||||
const hasHttpOnly = cookie.flags.some((f) => f === 'httponly');
|
||||
const hasSecure = cookie.flags.some((f) => f === 'secure');
|
||||
const hasSameSite = cookie.flags.some((f) => f.startsWith('samesite'));
|
||||
const missing = [!hasHttpOnly, !hasSameSite, !hasSecure];
|
||||
const isSessionLike = /token|session|auth|jwt/i.test(cookie.name);
|
||||
let status: CookieCheck['status'];
|
||||
if (isSessionLike && (!hasHttpOnly || !hasSameSite)) {
|
||||
status = 'fail';
|
||||
} else if (missing.some(Boolean)) {
|
||||
status = 'warn';
|
||||
} else {
|
||||
status = 'pass';
|
||||
}
|
||||
return { name: cookie.name, hasHttpOnly, hasSecure, hasSameSite, status };
|
||||
});
|
||||
}
|
||||
|
||||
async function checkSecurityHeaders(url: string): Promise<{
|
||||
@@ -85,7 +96,9 @@ async function checkSecurityHeaders(url: string): Promise<{
|
||||
header: 'Content-Security-Policy',
|
||||
expected: '存在(需包含合理策略)',
|
||||
actual: headers.get('content-security-policy'),
|
||||
status: headers.get('content-security-policy') ? 'pass' : 'warn',
|
||||
// N-22:缺 CSP 原本只 warn ⇒ 删掉整条 CSP 也无法让本门禁变红,而同族的
|
||||
// X-Content-Type-Options 同样缺失却判 fail。站点确实发 CSP(E2E 已在产物上断言),故判红不会误伤。
|
||||
status: headers.get('content-security-policy') ? ('pass' as const) : ('fail' as const),
|
||||
description: '防止 XSS 和数据注入攻击的核心策略',
|
||||
},
|
||||
{
|
||||
@@ -110,7 +123,13 @@ async function checkSecurityHeaders(url: string): Promise<{
|
||||
header: 'Strict-Transport-Security',
|
||||
expected: '存在(需包含 max-age)',
|
||||
actual: headers.get('strict-transport-security'),
|
||||
status: headers.get('strict-transport-security') ? 'pass' : 'fail',
|
||||
// N-22:HSTS 由边缘(Nginx/CDN)注入,本地 standalone 产物上没有。无条件判 fail 会让本门禁
|
||||
// 永远无法在分支产物上通过(只能打线上),故仅在 https 目标上缺失才判 fail。
|
||||
status: headers.get('strict-transport-security')
|
||||
? ('pass' as const)
|
||||
: url.startsWith('https:')
|
||||
? ('fail' as const)
|
||||
: ('warn' as const),
|
||||
description: '强制 HTTPS 连接,防止 SSL Strip 攻击',
|
||||
},
|
||||
{
|
||||
@@ -134,7 +153,7 @@ async function checkSecurityHeaders(url: string): Promise<{
|
||||
status: (() => {
|
||||
const val = headers.get('x-xss-protection');
|
||||
if (val === '0' || val === '1; mode=block') return 'pass' as const;
|
||||
return val ? 'warn' as const : 'warn' as const;
|
||||
return val ? ('warn' as const) : ('pass' as const);
|
||||
})(),
|
||||
description: '已废弃的 XSS 过滤器(现代浏览器不再需要)',
|
||||
},
|
||||
@@ -281,7 +300,8 @@ async function main() {
|
||||
printSummary(headerChecks, cookieChecks);
|
||||
|
||||
// Determine exit code
|
||||
const hasFailures = headerChecks.some((c) => c.status === 'fail');
|
||||
const hasFailures =
|
||||
headerChecks.some((c) => c.status === 'fail') || cookieChecks.some((c) => c.status === 'fail');
|
||||
if (hasFailures) {
|
||||
console.log('\n❌ 存在失败的安全头检查项!');
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user