refactor(core): swap studio-api read path from recast to acorn parser (T6e) (#1392)

* refactor(core): swap studio-api read path from recast to acorn parser (T6e)

* fix(core,sdk): code-review findings — 5 correctness bugs + 2 cleanup

- gsapParserAcorn: top-level variable targets now resolved via program-scope
  null-key fallback in lookupBindingFromAncestors (const el = querySelector...)
- gsapParserAcorn: fromTo guard requires args.length >= 3, preventing undefined
  args[2]/args[3] access when fewer args supplied
- gsapWriterAcorn: remove fuzzing fallback in removeAnimationFromScript that
  silently deleted the wrong animation (from→to ID conversion)
- gsapWriterAcorn: valueToCode guards NaN → "0" to avoid broken tween props;
  safeKey regex aligned to ASCII-only (matching gsapSerialize)
- mutate: handleSetGsapTween now includes stagger in extras (was in addGsapTween
  but missing from setGsapTween)
- apply-patches: script case now mirrors stylesheet — op=remove calls
  setGsapScript("") instead of silently ignoring the patch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(core): add trust-model header to T6d parity suite

Documents the recast-baseline trust relationship and clarifies that
motionPath parity tests live in the Phase 3b commit (PR #1379) since
the acorn motionPath parser is also added there.

Addresses #1370 R1-N1 (Rames).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-15 00:49:27 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 6dcbb5530e
commit b8fa4b5dd2
6 changed files with 54 additions and 41 deletions
@@ -3,6 +3,13 @@
* T6d: parse-parity suite — runs the full gsapParser.test.ts parse scenarios
* against parseGsapScriptAcorn. Write-path tests are it.skip'd; those live
* in gsapWriter.acorn.test.ts.
*
* Trust model: assertions here trust the recast-baseline outputs from
* gsapParser.test.ts as ground truth. T6b (gsapParser.acorn.test.ts) carries
* the real behavioral parity contract; this file widens coverage to the full
* corpus without duplicating the contract commentary.
* motionPath parity tests live in the Phase 3b commit (PR #1379) because that
* commit adds the acorn motionPath parser itself.
*/
import { describe, it, expect } from "vitest";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
+27 -25
View File
@@ -153,7 +153,9 @@ function lookupBindingFromAncestors(
const selector = bindings.get(scopeNode)?.get(name);
if (selector !== undefined) return selector;
}
return null;
// Program-scope bindings are stored under null (enclosingScopeNodeFromAncestors
// returns null when no function wrapper exists — the common case in HF scripts).
return bindings.get(null)?.get(name) ?? null;
}
function isFunctionNode(node: any): boolean {
@@ -470,31 +472,31 @@ function findAllTweenCalls(
) {
const method = callee.property.name;
const args = node.arguments;
if (args.length >= 2) {
const selectorValue =
resolveTargetSelector(args[0], nodeAncestors, scope, targetBindings) ??
"__unresolved__";
const selectorValue =
args.length >= 1
? (resolveTargetSelector(args[0], nodeAncestors, scope, targetBindings) ??
"__unresolved__")
: "__unresolved__";
if (method === "fromTo") {
results.push({
node,
ancestors: nodeAncestors,
method: "fromTo",
selector: selectorValue,
fromArg: args[1],
varsArg: args[2],
positionArg: args[3],
});
} else {
results.push({
node,
ancestors: nodeAncestors,
method: method as GsapMethod,
selector: selectorValue,
varsArg: args[1],
positionArg: args[2],
});
}
if (method === "fromTo" && args.length >= 3) {
results.push({
node,
ancestors: nodeAncestors,
method: "fromTo",
selector: selectorValue,
fromArg: args[1],
varsArg: args[2],
positionArg: args[3],
});
} else if (method !== "fromTo" && args.length >= 2) {
results.push({
node,
ancestors: nodeAncestors,
method: method as GsapMethod,
selector: selectorValue,
varsArg: args[1],
positionArg: args[2],
});
}
}
}
+4 -7
View File
@@ -20,12 +20,13 @@ import * as acornWalk from "acorn-walk";
function valueToCode(value: unknown): string {
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (typeof value === "number") return Number.isNaN(value) ? "0" : String(value);
if (typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function safeKey(key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
}
// fallow-ignore-next-line complexity
@@ -241,11 +242,7 @@ export function addAnimationToScript(
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
let target = parsed.located.find((l) => l.id === animationId);
if (!target) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
target = parsed.located.find((l) => l.id === convertedId);
}
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const ms = new MagicString(script);
+12 -8
View File
@@ -24,6 +24,7 @@ import {
type UnsafeMutationValue,
} from "../helpers/finiteMutation.js";
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js";
import {
removeElementFromHtml,
patchElementInHtml,
@@ -316,7 +317,13 @@ function bakeVisibilityOnDelete(document: Document, anim: GsapAnimation): void {
}
}
/** Lazy-load gsapParser to avoid pulling recast into every file-route import. */
/**
* Lazy-load gsapParser for write ops (recast-backed) that are not yet ported to
* the acorn writer. The read path (`parseGsapScript`) has been replaced by the
* browser-safe `parseGsapScriptAcorn` this loader is only needed for the write
* ops that remain: convertToKeyframesInScript, removeAllKeyframesFromScript,
* materializeKeyframesInScript, unrollDynamicAnimations, setArcPathInScript, etc.
*/
async function loadGsapParser() {
return import("../../parsers/gsapParser.js");
}
@@ -492,7 +499,6 @@ async function executeGsapMutation(
): Promise<GsapMutationResult | Response> {
const parser = await loadGsapParser();
const {
parseGsapScript,
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
@@ -515,7 +521,7 @@ async function executeGsapMutation(
scriptText: string,
animationId: string,
): { anim: GsapAnimation } | { err: Response } {
const parsed = parseGsapScript(scriptText);
const parsed = parseGsapScriptAcorn(scriptText);
const anim = parsed.animations.find((a) => a.id === animationId);
if (!anim) return { err: respond({ error: "animation not found" }, 404) };
return { anim };
@@ -578,7 +584,7 @@ async function executeGsapMutation(
return removeAnimationFromScript(block.scriptText, body.animationId);
}
case "delete-all-for-selector": {
const parsed = parseGsapScript(block.scriptText);
const parsed = parseGsapScriptAcorn(block.scriptText);
const matching = parsed.animations.filter((a) => a.targetSelector === body.targetSelector);
if (matching.length === 0) return block.scriptText;
stripStudioEditsFromTarget(block.document, body.targetSelector);
@@ -1162,8 +1168,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
}
const { parseGsapScript } = await loadGsapParser();
const parsed = parseGsapScript(block.scriptText);
const parsed = parseGsapScriptAcorn(block.scriptText);
return c.json(parsed);
});
@@ -1228,8 +1233,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
writeFileSync(res.absPath, newHtml, "utf-8");
}
const { parseGsapScript } = await loadGsapParser();
const freshParsed = parseGsapScript(newScript);
const freshParsed = parseGsapScriptAcorn(newScript);
const responsePayload: Record<string, unknown> = {
ok: true,
changed,
+3 -1
View File
@@ -207,7 +207,9 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
}
case "script": {
if (patch.op !== "remove") {
if (patch.op === "remove") {
setGsapScript(parsed.document, "");
} else {
setGsapScript(parsed.document, String(patch.value ?? ""));
}
break;
+1
View File
@@ -546,6 +546,7 @@ function handleSetGsapTween(
const extras: Record<string, unknown> = {};
if (properties.repeat !== undefined) extras.repeat = properties.repeat;
if (properties.yoyo !== undefined) extras.yoyo = properties.yoyo;
if (properties.stagger !== undefined) extras.stagger = properties.stagger;
if (Object.keys(extras).length > 0) updates.extras = extras;
const newScript = updateAnimationInScript(script, animationId, updates);