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:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+908
View File
@@ -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();
}