Files
novalon-website/scripts/utils/check-motion-constraints.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

909 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env tsx
/**
* 动效契约检查脚本(时长档位 + 缓动曲线)
*
* 背景:验收报告 `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();
}