| 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 |