feat(core): acorn GSAP read path with T6b differential corpus tests (#1368)

## Summary

Replaces the regex-based GSAP script parser with an acorn AST parser for the read path. This is the first of three parser PRs (T6b → T6c → T6d) that together migrate hyperframes off fragile regex parsing onto a proper AST.

## Why

The existing `gsapParser.ts` regex-based parser silently misparses edge cases: chained `.to()` calls, template literal targets, `gsap.utils.toArray(...)` expansions, lexically scoped variables, and percent-keyframe arrays. These misparses produce wrong `animationId` values that downstream SDK write ops use as keys — write ops targeting the wrong node corrupt the script. The fix is to parse with a real JS AST.

## What changed

**`packages/core/src/parsers/gsapParserAcorn.ts`** (new, ~1100 lines)
- `parseGsapScriptAcorn(script)` — full-featured read-path parser. Walks an acorn AST to extract:
  - Timeline variable detection (`gsap.timeline()` assignment)
  - `resolvedStart` computation: handles absolute positions, label references, relative `+=`/`-=`, chained calls
  - Property group classification (`transform`, `opacity`, `color`, etc.)
  - GSAP keyframes: percentage-object, object-array, simple-array with three-level easing
  - Variable target resolution: `querySelector`, `getElementById`, `querySelectorAll`, `gsap.utils.toArray`, array literals, forEach/map callbacks
  - Timeline `defaults` inheritance
  - Stagger / repeat / yoyo extraction
- All `animationId` values are content-addressed (`target-method-startMs-group`) for deterministic round-trips
- Note: `parseGsapScriptAcornForWrite` (the write-path slice used by T6c) lives in T6c (#1369), not this PR

**`packages/core/src/parsers/gsapParser.acorn.test.ts`** (new, ~220 lines)
- Differential corpus tests: same input run through both the old regex parser and the new acorn parser, asserting outputs are equal on the scenarios the old parser handled correctly
- Catches regressions during the transition without requiring tests to be rewritten
- `onComplete`/`onStart`/`onUpdate`/`onRepeat` dropped-key assertions added in Phase 3b commit (#1379) where `DROPPED_VAR_KEYS` is defined — the test file is in T6b but the extended assertions live one commit up-stack

**`packages/core/package.json`**
- Added `acorn` and `acorn-walk` dependencies

## Test plan

- `bun run test packages/core` → all tests pass (35 passing in the T6b suite alone)
- Stacked on: `main`
- Stack above: T6c (write path), T6d (parity suite)
This commit is contained in:
Vance Ingalls
2026-06-15 00:33:08 -07:00
committed by GitHub
parent a9f7d9096d
commit be4a28ae72
4 changed files with 1337 additions and 10 deletions
+16 -10
View File
@@ -22,7 +22,7 @@
},
"packages/aws-lambda": {
"name": "@hyperframes/aws-lambda",
"version": "0.6.95",
"version": "0.6.98",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
@@ -54,7 +54,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
"version": "0.6.95",
"version": "0.6.98",
"bin": {
"hyperframes": "./dist/cli.js",
},
@@ -101,10 +101,12 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.6.95",
"version": "0.6.98",
"dependencies": {
"@babel/parser": "^7.27.0",
"@chenglou/pretext": "^0.0.5",
"acorn": "^8.17.0",
"acorn-walk": "^8.3.5",
"bpm-detective": "^2.0.5",
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.2",
@@ -132,7 +134,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.6.95",
"version": "0.6.98",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
@@ -150,7 +152,7 @@
},
"packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run",
"version": "0.6.95",
"version": "0.6.98",
"dependencies": {
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
@@ -170,7 +172,7 @@
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.6.95",
"version": "0.6.98",
"devDependencies": {
"@types/bun": "^1.1.0",
"gsap": "^3.12.5",
@@ -182,7 +184,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.6.95",
"version": "0.6.98",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -236,7 +238,7 @@
},
"packages/shader-transitions": {
"name": "@hyperframes/shader-transitions",
"version": "0.6.95",
"version": "0.6.98",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -248,7 +250,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.6.95",
"version": "0.6.98",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -1086,7 +1088,9 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
"adm-zip": ["adm-zip@0.5.17", "", {}, "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ=="],
@@ -2140,6 +2144,8 @@
"minizlib/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"mlly/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"pac-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+2
View File
@@ -213,6 +213,8 @@
"dependencies": {
"@babel/parser": "^7.27.0",
"@chenglou/pretext": "^0.0.5",
"acorn": "^8.17.0",
"acorn-walk": "^8.3.5",
"bpm-detective": "^2.0.5",
"postcss": "^8.5.8",
"postcss-selector-parser": "^7.1.2",
@@ -0,0 +1,225 @@
// fallow-ignore-file duplication
/**
* T6b — acorn vs golden differential harness.
*
* Each corpus script runs through `parseGsapScriptAcorn` and must produce
* output identical to the T6a golden files (captured from the recast/babel
* baseline). Any mismatch = fidelity bug in the acorn port to fix before
* recast is removed.
*
* Also includes the targeted preservation test (comments, custom JS, postamble)
* and a coverage check against the fromTo / chained-call patterns.
*/
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
const __goldens__ = join(fileURLToPath(import.meta.url), "..", "__goldens__");
const g = (name: string) => join(__goldens__, name);
// ---------------------------------------------------------------------------
// Corpus scripts — identical to gsapParser.golden.test.ts so goldens are shared
// ---------------------------------------------------------------------------
const MINIMAL_SCRIPT = `\
var tl = gsap.timeline({ paused: true });
var notification = document.getElementById("notification");
gsap.set(notification, { x: 420, opacity: 0 });
tl.to(notification, { x: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2);
tl.to(notification, { x: 420, opacity: 0, duration: 0.3, ease: "power3.in" }, 4.2);
window.__timelines["macos-notification"] = tl;`;
const MODERATE_SCRIPT = `\
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var card = document.getElementById("card");
var btn = document.getElementById("subscribe-btn");
var textSub = document.getElementById("btn-subscribe");
var textSubd = document.getElementById("btn-subscribed");
gsap.set(card, { y: 300, opacity: 0 });
tl.to(card, { y: 0, opacity: 1, duration: 0.5, ease: "power3.out" }, 0.1);
tl.to(btn, { scale: 0.92, duration: 0.15, ease: "power2.out" }, 1.0);
tl.to(btn, { scale: 1, duration: 0.4, ease: "elastic.out(1, 0.4)" }, 1.15);
tl.to(textSub, { opacity: 0, duration: 0.08, ease: "none" }, 1.15);
tl.to(textSubd, { opacity: 1, duration: 0.08, ease: "none" }, 1.18);
tl.to(card, { y: 300, opacity: 0, duration: 0.25, ease: "power3.in" }, 3.8);
window.__timelines["yt-lower-third"] = tl;`;
const COMPLEX_SCRIPT = `\
window.__timelines = window.__timelines || {};
gsap.defaults({ force3D: true });
const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } });
tl.from(".headline span", { y: 46, opacity: 0, stagger: 0.055, duration: 0.38, ease: "back.out(1.35)" }, 0.05)
.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2)
.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08)
.from(".ambient-line", { scaleX: 0, opacity: 0, stagger: 0.08, duration: 0.42 }, 0.16);
window.__timelines["vpn-youtube-spot"] = tl;`;
const FROMTO_SCRIPT = `\
var tl = gsap.timeline({ paused: true });
var hero = document.getElementById("hero");
var caption = document.getElementById("caption");
tl.fromTo(hero, { x: -200, opacity: 0 }, { x: 0, opacity: 1, duration: 0.6, ease: "power3.out" }, 0.1);
tl.fromTo(caption, { y: -30, opacity: 0 }, { y: 0, opacity: 1, duration: 0.45 }, 0.5);
window.__timelines["hero-reveal"] = tl;`;
// ---------------------------------------------------------------------------
// T6b differential: acorn output must match T6a golden files
// ---------------------------------------------------------------------------
describe("T6b — acorn vs recast golden differential", () => {
it("minimal — matches golden (macos-notification)", async () => {
const result = parseGsapScriptAcorn(MINIMAL_SCRIPT);
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("minimal.parsed.json"));
});
it("moderate — matches golden (yt-lower-third)", async () => {
const result = parseGsapScriptAcorn(MODERATE_SCRIPT);
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("moderate.parsed.json"));
});
it("complex — matches golden (vpn-youtube-spot, chained .from() calls)", async () => {
const result = parseGsapScriptAcorn(COMPLEX_SCRIPT);
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("complex.parsed.json"));
});
it("fromTo — matches golden (hero-reveal, negative positions)", async () => {
const result = parseGsapScriptAcorn(FROMTO_SCRIPT);
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(g("fromto.parsed.json"));
});
});
// ---------------------------------------------------------------------------
// T6b preservation test — the acorn claim: untouched code survives verbatim
// ---------------------------------------------------------------------------
describe("T6b — preservation (comments, custom JS, postamble)", () => {
it("preserves preamble and postamble around tween calls", () => {
const script = `
// author comment preserved
const tl = gsap.timeline({ paused: true });
tl.to('#hero', { opacity: 1, duration: 0.5, ease: 'power2.out' });
window.__timelines['scene'] = tl;
`.trim();
const result = parseGsapScriptAcorn(script);
expect(result.preamble).toContain("// author comment preserved");
expect(result.preamble).toContain("gsap.timeline");
expect(result.postamble).toContain("window.__timelines");
expect(result.postamble).toContain("scene");
});
it("extracts correct animation from script with custom JS around tweens", () => {
const script = `
var tl = gsap.timeline({ paused: true });
var el = document.querySelector('.box');
console.log('before tween');
tl.to(el, { x: 100, duration: 0.5 }, 0);
console.log('after tween');
window.__timelines['custom'] = tl;
`.trim();
const result = parseGsapScriptAcorn(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0]?.targetSelector).toBe(".box");
expect(result.animations[0]?.properties.x).toBe(100);
expect(result.postamble).toContain("window.__timelines");
});
});
// ---------------------------------------------------------------------------
// T6b structural coverage — patterns exercised by existing corpus
// ---------------------------------------------------------------------------
describe("T6b — structural coverage", () => {
it("resolves getElementById targets", () => {
const script = `
var tl = gsap.timeline({ paused: true });
var hero = document.getElementById("hero");
tl.to(hero, { opacity: 1, duration: 0.5 }, 0);
window.__timelines['t'] = tl;
`.trim();
const result = parseGsapScriptAcorn(script);
expect(result.animations[0]?.targetSelector).toBe("#hero");
});
it("resolves querySelector targets", () => {
const script = `
var tl = gsap.timeline({ paused: true });
var el = document.querySelector(".box");
tl.to(el, { x: 50, duration: 0.3 }, 0);
window.__timelines['t'] = tl;
`.trim();
const result = parseGsapScriptAcorn(script);
expect(result.animations[0]?.targetSelector).toBe(".box");
});
it("handles stagger as __raw: extra", () => {
const script = `
var tl = gsap.timeline({ paused: true });
tl.from(".item", { y: 20, opacity: 0, stagger: 0.1, duration: 0.4 }, 0);
window.__timelines['t'] = tl;
`.trim();
const result = parseGsapScriptAcorn(script);
const anim = result.animations[0];
expect(anim?.extras?.stagger).toBe("__raw:0.1");
expect(anim?.properties).not.toHaveProperty("stagger");
});
it("handles stagger as __raw: when expressed as object", () => {
const script = `
var tl = gsap.timeline({ paused: true });
tl.from(".item", { y: 20, stagger: { each: 0.1, from: "start" }, duration: 0.4 }, 0);
window.__timelines['t'] = tl;
`.trim();
const result = parseGsapScriptAcorn(script);
const extras = result.animations[0]?.extras;
const stagger = extras?.stagger;
expect(typeof stagger).toBe("string");
expect(typeof stagger === "string" && stagger.startsWith("__raw:")).toBe(true);
expect(stagger).toContain("each");
});
it("drops dropped keys (onComplete, onStart, onUpdate, onRepeat)", () => {
const script = `
var tl = gsap.timeline({ paused: true });
tl.to(".box", { x: 100, duration: 0.5, onComplete: function() {}, onStart: function() {}, onUpdate: function() {}, onRepeat: function() {} }, 0);
window.__timelines['t'] = tl;
`.trim();
const result = parseGsapScriptAcorn(script);
const anim = result.animations[0];
expect(anim?.properties).not.toHaveProperty("onComplete");
expect(anim?.properties).not.toHaveProperty("onStart");
expect(anim?.properties).not.toHaveProperty("onUpdate");
expect(anim?.properties).not.toHaveProperty("onRepeat");
expect(anim?.extras).toBeUndefined();
});
it("assigns stable IDs based on selector + method + position", () => {
const script = `
var tl = gsap.timeline({ paused: true });
tl.to(".a", { x: 1, duration: 0.5 }, 0);
tl.to(".a", { x: 2, duration: 0.5 }, 0);
window.__timelines['t'] = tl;
`.trim();
const result = parseGsapScriptAcorn(script);
expect(result.animations[0]?.id).toBe(".a-to-0-position");
expect(result.animations[1]?.id).toBe(".a-to-0-position-2");
});
it("returns empty result on syntax error (graceful fail)", () => {
const result = parseGsapScriptAcorn("this is not valid js {{{{");
expect(result.animations).toHaveLength(0);
expect(result.timelineVar).toBe("tl");
});
it("detects multipleTimelines when script has >1 timeline", () => {
const script = `
var tl1 = gsap.timeline({ paused: true });
var tl2 = gsap.timeline({ paused: true });
tl1.to(".a", { x: 1, duration: 0.5 }, 0);
window.__timelines['t'] = tl1;
`.trim();
const result = parseGsapScriptAcorn(script);
expect(result.multipleTimelines).toBe(true);
});
});
File diff suppressed because it is too large Load Diff