#!/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-` → 毫秒 */ transitionTokens: Map; /** 允许集合:`--ease-*` 令牌解析出的曲线键 */ allowedCurves: Set; /** `--ease-` → 曲线键 */ easeTokens: Map; } 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(); const easeTokens = new Map(); const allowedCurves = new Set(); 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 = /(? 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(); 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(); }