返回 DeepSeek-Reasonix
check-waapi-contract.mjs
根目录 / desktop / frontend / scripts / check-waapi-contract.mjs
1 #!/usr/bin/env node
2
3 import { readdirSync, readFileSync } from "node:fs";
4 import { join, relative } from "node:path";
5 import ts from "typescript";
6
7 const SOURCE_ROOT = "src";
8 const LEGACY_EASING_RE = /\b(?:power[0-4]|back|bounce|circ|elastic|expo|sine|strong)\.(?:in|out|inOut)\b/;
9 const CSS_EASING_RE = /^(?:linear|ease|ease-in|ease-out|ease-in-out|step-start|step-end|cubic-bezier\([^()]+\)|steps\([^()]+\)|linear\([^()]+\))$/;
10
11 function sourceFiles(root) {
12 const files = [];
13 const visit = (dir) => {
14 for (const entry of readdirSync(dir, { withFileTypes: true })) {
15 const path = join(dir, entry.name);
16 if (entry.isDirectory()) {
17 if (entry.name !== "__tests__") visit(path);
18 } else if (/\.(?:ts|tsx)$/.test(entry.name) && !/\.test\.(?:ts|tsx)$/.test(entry.name)) {
19 files.push(path);
20 }
21 }
22 };
23 visit(root);
24 return files.sort();
25 }
26
27 function propertyName(node) {
28 if (ts.isIdentifier(node) || ts.isStringLiteral(node)) return node.text;
29 return "";
30 }
31
32 function validateSource(text, filename) {
33 const portableFilename = filename.replaceAll("\\", "/");
34 const source = ts.createSourceFile(
35 filename,
36 text,
37 ts.ScriptTarget.Latest,
38 true,
39 filename.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
40 );
41 const issues = [];
42 const motionEasingImports = new Set();
43 const isSharedMotionModule = /(?:^|\/)src\/lib\/motion\.ts$/.test(portableFilename);
44
45 for (const statement of source.statements) {
46 if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
47 const moduleName = statement.moduleSpecifier.text;
48 if (moduleName === "gsap" || moduleName === "@gsap/react") {
49 issues.push(`${filename}:${source.getLineAndCharacterOfPosition(statement.getStart()).line + 1}: GSAP imports are forbidden in the native motion layer`);
50 }
51 if (!/(?:^|\/)motion$/.test(moduleName)) continue;
52 const bindings = statement.importClause?.namedBindings;
53 if (!bindings || !ts.isNamedImports(bindings)) continue;
54 for (const element of bindings.elements) {
55 const imported = element.propertyName?.text ?? element.name.text;
56 if (imported.startsWith("CSS_EASE_")) motionEasingImports.add(element.name.text);
57 }
58 }
59 if (isSharedMotionModule) {
60 for (const statement of source.statements) {
61 if (!ts.isVariableStatement(statement)) continue;
62 for (const declaration of statement.declarationList.declarations) {
63 if (!ts.isIdentifier(declaration.name) || !declaration.name.text.startsWith("CSS_EASE_")) continue;
64 const value = declaration.initializer;
65 const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0;
66 if (!isConst || !value || !ts.isStringLiteral(value) || !CSS_EASING_RE.test(value.text)) {
67 issues.push(`${filename}:${source.getLineAndCharacterOfPosition(declaration.getStart()).line + 1}: shared ${declaration.name.text} must be a const CSS easing string`);
68 continue;
69 }
70 motionEasingImports.add(declaration.name.text);
71 }
72 }
73 }
74
75 const line = (node) => source.getLineAndCharacterOfPosition(node.getStart()).line + 1;
76 const fail = (node, message) => issues.push(`${filename}:${line(node)}: ${message}`);
77 const visit = (node) => {
78 if (ts.isStringLiteralLike(node) && LEGACY_EASING_RE.test(node.text)) {
79 fail(node, `legacy GSAP easing token ${JSON.stringify(node.text.match(LEGACY_EASING_RE)?.[0])} cannot enter production source`);
80 }
81 if (
82 ts.isCallExpression(node) &&
83 ts.isPropertyAccessExpression(node.expression) &&
84 node.expression.name.text === "animate"
85 ) {
86 const options = node.arguments[1];
87 if (options && !ts.isNumericLiteral(options)) {
88 if (!ts.isObjectLiteralExpression(options)) {
89 fail(options, "Element.animate options must be an inline object so easing cannot be forwarded opaquely");
90 } else {
91 for (const property of options.properties) {
92 if (ts.isSpreadAssignment(property)) {
93 fail(property, "Element.animate options cannot spread opaque values");
94 } else if (ts.isShorthandPropertyAssignment(property) && property.name.text === "easing") {
95 fail(property, "Element.animate easing must be explicit, not shorthand");
96 }
97 }
98 const easing = options.properties.find((property) =>
99 ts.isPropertyAssignment(property) && propertyName(property.name) === "easing",
100 );
101 if (easing && ts.isPropertyAssignment(easing)) {
102 const value = easing.initializer;
103 if (ts.isStringLiteral(value)) {
104 if (!CSS_EASING_RE.test(value.text)) fail(value, `${JSON.stringify(value.text)} is not an allowed CSS easing`);
105 } else if (ts.isIdentifier(value)) {
106 if (!motionEasingImports.has(value.text)) {
107 fail(value, `easing identifier ${value.text} must be a CSS_EASE_* token imported from the shared motion module`);
108 }
109 } else {
110 fail(value, "Element.animate easing must be a CSS literal or a shared CSS_EASE_* token");
111 }
112 }
113 }
114 }
115 }
116 ts.forEachChild(node, visit);
117 };
118 visit(source);
119 return issues;
120 }
121
122 function selfTest() {
123 const cases = [
124 ["valid shared token", 'import { CSS_EASE_OUT } from "./motion"; el.animate([], { duration: 1, easing: CSS_EASE_OUT });', 0],
125 ["valid Windows shared declaration", 'export const CSS_EASE_IN = "ease-in"; el.animate([], { easing: CSS_EASE_IN });', 0, "src\\lib\\motion.ts"],
126 ["valid CSS literal", 'el.animate([], { easing: "cubic-bezier(0.2, 0.72, 0.2, 1)" });', 0],
127 ["legacy token", 'el.animate([], { easing: "power2.in" });', 2],
128 ["opaque options", "el.animate([], options);", 1],
129 ["spread options", "el.animate([], { duration: 1, ...legacyOptions });", 1],
130 ["shorthand easing", "el.animate([], { easing });", 1],
131 ["unowned identifier", "el.animate([], { easing: externalEase });", 1],
132 ];
133 for (const [name, source, expected, filename = `${name}.ts`] of cases) {
134 const actual = validateSource(source, filename).length;
135 if (actual !== expected) throw new Error(`${name}: expected ${expected} contract findings, got ${actual}`);
136 }
137 console.log(`waapi-contract: ${cases.length} self-tests passed`);
138 }
139
140 if (process.argv.includes("--self-test")) selfTest();
141
142 const issues = sourceFiles(SOURCE_ROOT).flatMap((path) => validateSource(readFileSync(path, "utf8"), relative(".", path)));
143 if (issues.length > 0) {
144 console.error(`waapi-contract: ${issues.length} violation(s)`);
145 for (const issue of issues) console.error(` ${issue}`);
146 process.exit(1);
147 }
148 console.log("waapi-contract: production Web Animations options use explicit CSS-compatible easing");
149
149 lines Plain Text