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,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();
|
||||
}
|
||||
Reference in New Issue
Block a user