From ada878fdcde967a90da6cf49b05f05e67fabce9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 14 Jul 2026 20:13:01 -0400 Subject: [PATCH] fix(lint): consolidate lint and audit correctness (#2413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(lint): stop CSS comments in + + + + +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined(); + expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined(); + expect(result.findings.find((f) => f.code === "head_leaked_text")).toBeUndefined(); + }); + it("reports error when timeline registry is missing", async () => { const html = ` @@ -778,6 +801,20 @@ body { expect(finding).toBeUndefined(); }); + it("matches timeline keys against browser-decoded composition ids", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined(); + }); + it("accepts object-literal timeline registration and extracts its keys", async () => { const html = ` diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index f4b6b8a30..16ae48896 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -2,6 +2,7 @@ import type { LintContext, HyperframeLintFinding } from "../context"; import postcss from "postcss"; import { readAttr, + readDecodedAttr, truncateSnippet, stripJsComments, extractCompositionIdsFromCss, @@ -40,7 +41,7 @@ function isStudioTimelineElement(tag: { raw: string; name: string }): boolean { function describeStudioElement(tag: { raw: string; name: string }): string { const parts = [`<${tag.name}`]; const className = readAttr(tag.raw, "class"); - const compositionId = readAttr(tag.raw, "data-composition-id"); + const compositionId = readDecodedAttr(tag.raw, "data-composition-id"); const dataStart = readAttr(tag.raw, "data-start"); const dataTrack = readAttr(tag.raw, "data-track-index") ?? readAttr(tag.raw, "data-track"); @@ -203,7 +204,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // root_missing_composition_id + root_missing_dimensions ({ rootTag }) => { const findings: HyperframeLintFinding[] = []; - if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) { + if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) { findings.push({ code: "root_missing_composition_id", severity: "error", @@ -300,15 +301,10 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ }, // timeline_id_mismatch - ({ source }) => { + ({ source, compositionIds }) => { const findings: HyperframeLintFinding[] = []; - const htmlCompIds = new Set(); + const htmlCompIds = new Set(compositionIds); const timelineRegKeys = new Set(); - const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi; - let m: RegExpExecArray | null; - while ((m = compIdRe.exec(source)) !== null) { - if (m[1]) htmlCompIds.add(m[1]); - } for (const key of extractTimelineRegistryKeys(source)) { timelineRegKeys.add(key); } @@ -369,7 +365,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ for (const tag of tags) { const src = readAttr(tag.raw, "data-composition-src"); if (!src) continue; - if (readAttr(tag.raw, "data-composition-id")) continue; + if (readDecodedAttr(tag.raw, "data-composition-id")) continue; findings.push({ code: "host_missing_composition_id", severity: "error", @@ -452,8 +448,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ code: "studio_missing_editable_id", severity: "warning", message: `${descriptor} has no id, so Studio cannot use a stable edit target for its timeline and canvas controls.`, - selector: readAttr(tag.raw, "data-composition-id") - ? `[data-composition-id="${readAttr(tag.raw, "data-composition-id")}"]` + selector: readDecodedAttr(tag.raw, "data-composition-id") + ? `[data-composition-id="${readDecodedAttr(tag.raw, "data-composition-id")}"]` : undefined, fixHint: 'Add a stable, human-readable id such as id="hero-title" or id="scene-1-card" to every timeline-visible element you want agents or Studio to edit.', diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 5f9c93fcc..3bbc59bc8 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -998,6 +998,92 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("does NOT report overlapping_gsap_tweens for distinct loop-built DOM targets", async () => { + const html = ` + +
+
+
+
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens"); + expect(finding).toBeUndefined(); + }); + + it("does NOT report overlapping_gsap_tweens for distinct object proxy drivers", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens"); + expect(finding).toBeUndefined(); + }); + + it("reports overlapping_gsap_tweens for the same object proxy driver", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens"); + expect(finding).toBeDefined(); + }); + + it("does not conflate same-named object proxies from sibling scopes", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens"); + expect(finding).toBeUndefined(); + }); + it("warns when an opacity exit ends at a clip start boundary without a hard kill", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index ec2e4c9b8..df5f516c6 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1,6 +1,7 @@ interface LintParsedGsap { animations: Array<{ targetSelector: string; + targetIdentity?: string; method: string; position: number | string; properties: Record; @@ -27,6 +28,7 @@ import type { HyperframeLintFinding, LintRule } from "../types"; import type { OpenTag } from "../utils"; import { readAttr, + readDecodedAttr, truncateSnippet, stripJsComments, hasCaptionStyles, @@ -38,6 +40,7 @@ import { type GsapWindow = { targetSelector: string; + targetIdentity?: string; position: number; end: number; properties: string[]; @@ -61,6 +64,16 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05; // overlap analysis must never treat them as one. const UNRESOLVED_TARGET = "__unresolved__"; +// Parser labels for object-proxy tweens describe their role, not target +// identity. Two independent proxies can both be labelled `dwell/hold` (or the +// same driven DOM channel), so equality cannot prove they conflict. +function targetHasNoStableIdentity(selector: string, identity?: string): boolean { + if (identity) return false; + return ( + selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy → ") + ); +} + // ── GSAP parsing utilities ───────────────────────────────────────────────── function countClassUsage(tags: OpenTag[]): Map { @@ -137,6 +150,7 @@ async function extractGsapWindows(script: string): Promise { animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount; windows.push({ targetSelector: animation.targetSelector, + targetIdentity: animation.targetIdentity, position: animation.position, end: animation.position + effectiveDuration, properties: Object.keys(animation.properties), @@ -259,7 +273,7 @@ function findTagEnd(source: string, tag: OpenTag): number { function collectCompositionRanges(source: string, tags: OpenTag[]): CompositionRange[] { return tags .map((tag) => { - const id = readAttr(tag.raw, "data-composition-id"); + const id = readDecodedAttr(tag.raw, "data-composition-id"); if (!id) return null; return { id, @@ -593,12 +607,14 @@ export const gsapRules: LintRule[] = [ if (left.end <= left.position) continue; // Unresolved targets are unknown elements: two of them are not provably // the same element, so an overlap between them cannot be asserted. - if (left.targetSelector === UNRESOLVED_TARGET) continue; + if (targetHasNoStableIdentity(left.targetSelector, left.targetIdentity)) continue; for (let j = i + 1; j < gsapWindows.length; j++) { const right = gsapWindows[j]; if (!right) continue; if (right.end <= right.position) continue; - if (left.targetSelector !== right.targetSelector) continue; + const leftIdentity = left.targetIdentity ?? left.targetSelector; + const rightIdentity = right.targetIdentity ?? right.targetSelector; + if (leftIdentity !== rightIdentity) continue; const overlapStart = Math.max(left.position, right.position); const overlapEnd = Math.min(left.end, right.end); if (overlapEnd <= overlapStart) continue; diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts index 1fa14d249..8e14fc4df 100644 --- a/packages/lint/src/rules/media.ts +++ b/packages/lint/src/rules/media.ts @@ -1,5 +1,5 @@ import type { LintContext, HyperframeLintFinding } from "../context"; -import { readAttr, truncateSnippet, isMediaTag } from "../utils"; +import { readAttr, readDecodedAttr, truncateSnippet, isMediaTag } from "../utils"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -308,7 +308,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = if (tag.name === "video" || tag.name === "audio") continue; if (voidElements.has(tag.name)) continue; // Skip the composition root — it uses data-start as a playback anchor, not as a clip timer - if (readAttr(tag.raw, "data-composition-id")) continue; + if (readDecodedAttr(tag.raw, "data-composition-id")) continue; if (readAttr(tag.raw, "data-start")) { timedTagPositions.push({ name: tag.name, diff --git a/packages/lint/src/rules/slideshow.ts b/packages/lint/src/rules/slideshow.ts index f435be33b..41f2e86bc 100644 --- a/packages/lint/src/rules/slideshow.ts +++ b/packages/lint/src/rules/slideshow.ts @@ -1,6 +1,6 @@ import type { LintContext, HyperframeLintFinding } from "../context"; import type { LintRule } from "../types"; -import { readAttr } from "../utils"; +import { readAttr, readDecodedAttr } from "../utils"; import { parseSlideshowManifest, resolveSlideshow, @@ -30,7 +30,7 @@ function parseTiming(raw: string): { start: number; duration: number } | null { function collectCompositionIdScenes(ctx: LintContext, seen: Set, out: Scene[]): void { for (const tag of ctx.tags) { - const compositionId = readAttr(tag.raw, "data-composition-id"); + const compositionId = readDecodedAttr(tag.raw, "data-composition-id"); if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId)) continue; const timing = parseTiming(tag.raw); diff --git a/packages/lint/src/utils.ts b/packages/lint/src/utils.ts index edb4b17e3..39b231538 100644 --- a/packages/lint/src/utils.ts +++ b/packages/lint/src/utils.ts @@ -121,7 +121,7 @@ export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): Op const bodyTag = tags.find((tag) => tag.name === "body"); if ( bodyTag && - (readAttr(bodyTag.raw, "data-composition-id") || + (readDecodedAttr(bodyTag.raw, "data-composition-id") || readAttr(bodyTag.raw, "data-width") || readAttr(bodyTag.raw, "data-height")) ) { @@ -148,7 +148,7 @@ export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): Op // still eligible as the root. if ( tag.name === "svg" && - !readAttr(tag.raw, "data-composition-id") && + !readDecodedAttr(tag.raw, "data-composition-id") && !readAttr(tag.raw, "data-width") && !readAttr(tag.raw, "data-height") ) { @@ -173,6 +173,22 @@ export function readAttr(tagSource: string, attr: string): string | null { return match?.[1] || null; } +/** Read an HTML attribute using browser-equivalent character-reference decoding. */ +export function readDecodedAttr(tagSource: string, attr: string): string | null { + if (!tagSource) return null; + let value: string | null = null; + const parser = new Parser( + { + onattribute(name, decodedValue) { + if (value === null && name.toLowerCase() === attr.toLowerCase()) value = decodedValue; + }, + }, + { decodeEntities: true, lowerCaseAttributeNames: false, lowerCaseTags: true }, + ); + parser.end(tagSource); + return value; +} + /** * Read an attribute that may legitimately contain the opposite quote * character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'` @@ -200,7 +216,7 @@ export function readJsonAttr(tagSource: string, attr: string): string | null { export function collectCompositionIds(tags: OpenTag[]): Set { const ids = new Set(); for (const tag of tags) { - const compId = readAttr(tag.raw, "data-composition-id"); + const compId = readDecodedAttr(tag.raw, "data-composition-id"); if (compId) ids.add(compId); } return ids; diff --git a/packages/parsers/src/gsapInline.ts b/packages/parsers/src/gsapInline.ts index fa382ffa3..5989fc35c 100644 --- a/packages/parsers/src/gsapInline.ts +++ b/packages/parsers/src/gsapInline.ts @@ -73,14 +73,20 @@ function collectPatternNames(pattern: Node, out: Set): void { else if (pattern?.type === "RestElement") collectPatternNames(pattern.argument, out); } +function boundPatterns(node: Node): Node[] { + if (isFunctionNode(node)) return node.params ?? []; + if (node.type === "VariableDeclarator") return [node.id]; + if (node.type === "CatchClause") return [node.param]; + if (node.type === "AssignmentExpression" && node.left?.type === "Identifier") return [node.left]; + return []; +} + /** Every identifier name bound anywhere inside the subtree (fn params, declared vars, catch params). */ function collectBoundNames(root: Node): Set { const names = new Set(); const visit = (node: Node): Node => { if (!isNode(node)) return node; - if (isFunctionNode(node)) for (const p of node.params ?? []) collectPatternNames(p, names); - else if (node.type === "VariableDeclarator") collectPatternNames(node.id, names); - else if (node.type === "CatchClause") collectPatternNames(node.param, names); + for (const pattern of boundPatterns(node)) collectPatternNames(pattern, names); transformChildren(node, visit); return node; }; @@ -339,8 +345,13 @@ function expandBody( ctx: ExpandCtx, ): Node[] { const block = substituteParams(cloneNode({ type: "BlockStatement", body: bodyStmts }), bindings); + tagProvenance(block, prov); tagTimelineCalls(block.body, prov, ctx); - return expandStatements(block.body, { ...ctx, depth: ctx.depth + 1 }); + block.body = expandStatements(block.body, { ...ctx, depth: ctx.depth + 1 }); + // Keep each synthetic expansion in its own lexical scope. Flattening repeated + // loop/helper bodies into Program scope makes same-named local DOM bindings + // overwrite one another during selector analysis. + return [block]; } function inlineHelper(call: Node, ctx: ExpandCtx): Node[] { diff --git a/packages/parsers/src/gsapParserAcorn.computed.test.ts b/packages/parsers/src/gsapParserAcorn.computed.test.ts index eb20ea757..9666c4a2f 100644 --- a/packages/parsers/src/gsapParserAcorn.computed.test.ts +++ b/packages/parsers/src/gsapParserAcorn.computed.test.ts @@ -20,6 +20,13 @@ describe("editabilityForProvenance", () => { const start = (a: { resolvedStart?: number }): number | undefined => a.resolvedStart; +function expectDistinctProxyIdentities(script: string): void { + const { animations } = parseGsapScriptAcorn(script); + expect(animations[0]?.targetIdentity).toBeDefined(); + expect(animations[1]?.targetIdentity).toBeDefined(); + expect(animations[0]?.targetIdentity).not.toBe(animations[1]?.targetIdentity); +} + describe("parseGsapScriptAcorn — computed timelines", () => { it("resolves an add-to-basket helper called twice (the reported case)", () => { const script = ` @@ -65,6 +72,140 @@ describe("parseGsapScriptAcorn — computed timelines", () => { expect(animations.map((a) => a.provenance?.kind)).toEqual(["loop", "loop", "loop"]); }); + it("keeps DOM target bindings distinct across bounded loop iterations", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + for (let i = 0; i < 2; i++) { + const card = document.getElementById("caption-card-" + i); + tl.to(card, { opacity: 1, duration: 1 }, i * 0.5); + } + `); + + expect(animations.map((animation) => animation.targetSelector)).toEqual([ + "#caption-card-0", + "#caption-card-1", + ]); + }); + + it("keeps var DOM target bindings visible outside ordinary blocks", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + if (true) { + var card = document.getElementById("caption-card"); + } + tl.to(card, { opacity: 1, duration: 1 }, 0); + `); + + expect(animations.map((animation) => animation.targetSelector)).toEqual(["#caption-card"]); + }); + + it("keeps an outer let DOM target binding after assignment inside a block", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + let card; + if (true) { + card = document.getElementById("caption-card"); + } + tl.to(card, { opacity: 1, duration: 1 }, 0); + `); + + expect(animations.map((animation) => animation.targetSelector)).toEqual(["#caption-card"]); + }); + + it("uses declaration-scoped identities for object proxy targets", () => { + expectDistinctProxyIdentities(` + const tl = gsap.timeline(); + (() => { + const driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, 0); + })(); + (() => { + const driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, 0); + })(); + `); + }); + + it("withholds object proxy identity when the binding is reassigned", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + let driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, 0); + driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, 0); + `); + + expect(animations.map((animation) => animation.targetIdentity)).toEqual([undefined, undefined]); + }); + + it("keeps helper-created object proxy instances distinct", () => { + expectDistinctProxyIdentities(` + const tl = gsap.timeline(); + function addDriver(at) { + const driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, at); + } + addDriver(0); + addDriver(0.5); + `); + }); + + it("keeps helper-created object proxy instances distinct across nested blocks", () => { + expectDistinctProxyIdentities(` + const tl = gsap.timeline(); + function addDriver(at) { + if (at >= 0) { + const driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, at); + } + } + addDriver(0); + addDriver(0.5); + `); + }); + + it("keeps helper-created var proxy instances distinct", () => { + expectDistinctProxyIdentities(` + const tl = gsap.timeline(); + function addDriver(at) { + var driver = { value: 0 }; + tl.to(driver, { value: 1, duration: 1 }, at); + } + addDriver(0); + addDriver(0.5); + `); + }); + + it("keeps one outer object proxy stable across helper calls", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + const driver = { value: 0 }; + function animateDriver(at) { + tl.to(driver, { value: 1, duration: 1 }, at); + } + animateDriver(0); + animateDriver(0.5); + `); + + expect(animations[0]?.targetIdentity).toBeDefined(); + expect(animations[1]?.targetIdentity).toBe(animations[0]?.targetIdentity); + }); + + it("keeps parameter DOM target assignments visible outside nested blocks", () => { + const { animations } = parseGsapScriptAcorn(` + const tl = gsap.timeline(); + function configure(card) { + if (true) { + card = document.getElementById("caption-card"); + } + tl.to(card, { opacity: 1, duration: 1 }, 0); + } + configure(null); + `); + + expect(animations.map((animation) => animation.targetSelector)).toEqual(["#caption-card"]); + }); + it("leaves a literal-position composition unchanged (regression)", () => { const { animations } = parseGsapScriptAcorn(` const tl = gsap.timeline(); diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index 048e0befd..5e9b472bc 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -39,6 +39,7 @@ const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]); const ITERATION_METHODS = new Set(["forEach", "map"]); const SCOPE_NODE_TYPES = new Set([ "Program", + "BlockStatement", "FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", @@ -50,6 +51,19 @@ type ScopeBindings = ReadonlyMap; /** Per-scope element bindings: scopeNode → (variable name → selector). */ type TargetBindings = Map>; +type IdentifierDeclaration = { + node: any; + scopeNode: any; + expandedScopeNode?: any; + name: string; + kind: "const" | "let" | "var" | "param"; +}; + +type IdentifierBindingIndex = { + declarationsByName: Map; + reassignedDeclarations: Set; +}; + /** * Side-table of top-level const/let ARRAY and OBJECT literals (of literals), * captured by `collectScopeBindings` and stashed on the scope Map so that @@ -226,10 +240,16 @@ function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null { * Return the nearest ancestor node whose type is in SCOPE_NODE_TYPES. * `ancestors` is the acorn-walk ancestor array (root→current, current is last). */ -function enclosingScopeNodeFromAncestors(ancestors: any[]): any { +function enclosingScopeNodeFromAncestors(ancestors: any[], includeBlocks = true): any { for (let i = ancestors.length - 2; i >= 0; i--) { const node = ancestors[i]; - if (node && SCOPE_NODE_TYPES.has(node.type)) return node; + if ( + node && + SCOPE_NODE_TYPES.has(node.type) && + (includeBlocks || node.type !== "BlockStatement") + ) { + return node; + } } return null; } @@ -244,6 +264,82 @@ function scopeChainFromAncestors(ancestors: any[]): any[] { return chain; } +function nearestExpandedScopeFromAncestors(ancestors: any[]): any | undefined { + for (let index = ancestors.length - 2; index >= 0; index--) { + const candidate = ancestors[index]; + if (candidate?.type === "BlockStatement" && readProvenance(candidate)) return candidate; + } + return undefined; +} + +function findVisibleIdentifierDeclaration( + name: string, + ancestors: any[], + index: IdentifierBindingIndex, + usageStart = Number.POSITIVE_INFINITY, +): IdentifierDeclaration | undefined { + const declarations = index.declarationsByName.get(name) ?? []; + const expandedScopeNode = nearestExpandedScopeFromAncestors(ancestors); + for (const scopeNode of scopeChainFromAncestors(ancestors)) { + const candidates = declarations + .filter( + (declaration) => + declaration.scopeNode === scopeNode && + (!declaration.expandedScopeNode || declaration.expandedScopeNode === expandedScopeNode) && + (declaration.kind === "var" || + declaration.kind === "param" || + declaration.node.start < usageStart), + ) + .sort((left, right) => right.node.start - left.node.start); + if (candidates[0]) return candidates[0]; + } + return undefined; +} + +function collectIdentifierBindingIndex(ast: any): IdentifierBindingIndex { + const declarationsByName = new Map(); + const reassignedDeclarations = new Set(); + + acornWalk.ancestor(ast, { + VariableDeclarator(node: any, _: unknown, ancestors: any[]) { + const name = node.id?.name; + if (!name) return; + const declaration = ancestors.at(-2); + const kind = declaration?.kind as "const" | "let" | "var" | undefined; + if (!kind) return; + const includeBlocks = declaration?.type !== "VariableDeclaration" || kind !== "var"; + const scopeNode = enclosingScopeNodeFromAncestors(ancestors, includeBlocks); + const expandedScopeNode = nearestExpandedScopeFromAncestors(ancestors); + const entries = declarationsByName.get(name) ?? []; + entries.push({ node, scopeNode, expandedScopeNode, name, kind }); + declarationsByName.set(name, entries); + }, + FunctionDeclaration: indexFunctionParameters, + FunctionExpression: indexFunctionParameters, + ArrowFunctionExpression: indexFunctionParameters, + } as any); + + const index = { declarationsByName, reassignedDeclarations }; + acornWalk.ancestor(ast, { + AssignmentExpression(node: any, _: unknown, ancestors: any[]) { + const name = node.left?.type === "Identifier" ? node.left.name : undefined; + if (!name) return; + const declaration = findVisibleIdentifierDeclaration(name, ancestors, index, node.start); + if (declaration) reassignedDeclarations.add(declaration.node); + }, + } as any); + return index; + + function indexFunctionParameters(node: any): void { + for (const parameter of node.params ?? []) { + if (parameter?.type !== "Identifier") continue; + const entries = declarationsByName.get(parameter.name) ?? []; + entries.push({ node: parameter, scopeNode: node, name: parameter.name, kind: "param" }); + declarationsByName.set(parameter.name, entries); + } + } +} + // ── Target bindings ─────────────────────────────────────────────────────────── function addBinding( @@ -334,7 +430,11 @@ function collectScopeBindings(ast: any): ScopeBindings { * Pass 1: direct DOM-lookup assignments. * Pass 2: forEach/map callback params whose collection's selector is known. */ -function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings { +function collectTargetBindings( + ast: any, + scope: ScopeBindings, + identifierBindings: IdentifierBindingIndex, +): TargetBindings { const bindings: TargetBindings = new Map(); acornWalk.ancestor(ast, { @@ -342,14 +442,35 @@ function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings { const name = node.id?.name; const selector = selectorFromQueryCall(node.init, scope); if (name && selector !== null) { - addBinding(bindings, enclosingScopeNodeFromAncestors(ancestors), name, selector); + const declaration = ancestors.at(-2); + const includeBlocks = + declaration?.type !== "VariableDeclaration" || declaration.kind !== "var"; + addBinding( + bindings, + enclosingScopeNodeFromAncestors(ancestors, includeBlocks), + name, + selector, + ); } }, AssignmentExpression(node: any, _: unknown, ancestors: any[]) { const left = node.left; const selector = selectorFromQueryCall(node.right, scope); if (left?.type === "Identifier" && selector !== null) { - addBinding(bindings, enclosingScopeNodeFromAncestors(ancestors), left.name, selector); + const declaration = findVisibleIdentifierDeclaration( + left.name, + ancestors, + identifierBindings, + node.start, + ); + addBinding( + bindings, + declaration?.scopeNode ?? + nearestExpandedScopeFromAncestors(ancestors) ?? + enclosingScopeNodeFromAncestors(ancestors), + left.name, + selector, + ); } }, } as any); @@ -1101,7 +1222,9 @@ function tweenCallToAnimation( call: TweenCallInfo, scope: ScopeBindings, source: string, + identifierBindings: IdentifierBindingIndex, ): Omit { + const provenance = readProvenance(call.node); const vars = objectExpressionToRecord(call.varsArg, scope, source); const properties: Record = {}; const extras: Record = {}; @@ -1201,9 +1324,34 @@ function tweenCallToAnimation( // Relabel object-proxy / empty-target tweens so they don't read as bare // __unresolved__: a dwell/hold spacer or an onUpdate-driven DOM channel (#5/#11). let selector = call.selector; + let targetIdentity: string | undefined; if (selector === "__unresolved__") { - const proxyLabel = describeProxyTarget(call.node.arguments?.[0], call.varsArg, scope); - if (proxyLabel) selector = proxyLabel; + const targetNode = call.node.arguments?.[0]; + const proxyLabel = describeProxyTarget(targetNode, call.varsArg, scope); + if (proxyLabel) { + selector = proxyLabel; + if (targetNode?.type === "Identifier") { + const declaration = findVisibleIdentifierDeclaration( + targetNode.name, + call.ancestors, + identifierBindings, + call.node.start, + ); + if ( + declaration?.node.init?.type === "ObjectExpression" && + !identifierBindings.reassignedDeclarations.has(declaration.node) + ) { + const declarationProvenance = + readProvenance(declaration.scopeNode) ?? readProvenance(declaration.expandedScopeNode); + const instanceIdentity = + declarationProvenance && + (declarationProvenance.kind === "helper" || declarationProvenance.kind === "loop") + ? `:${declarationProvenance.kind}:${declarationProvenance.callSite ?? ""}:${declarationProvenance.iteration ?? ""}` + : ""; + targetIdentity = `proxy:${targetNode.name}@${declaration.node.start}${instanceIdentity}`; + } + } + } } const anim: Omit = { @@ -1215,6 +1363,7 @@ function tweenCallToAnimation( duration, ease, }; + if (targetIdentity) anim.targetIdentity = targetIdentity; if (!hasPositionArg) anim.implicitPosition = true; let group = classifyTweenPropertyGroup(properties); if (!group && keyframesData) { @@ -1231,7 +1380,6 @@ function tweenCallToAnimation( if (motionPathResult) anim.arcPath = motionPathResult.arcPath; if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true; if (selector === "__unresolved__") anim.hasUnresolvedSelector = true; - const provenance = readProvenance(call.node); if (provenance) anim.provenance = provenance; return anim; } @@ -1670,13 +1818,16 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor locations: true, }); const scope = collectScopeBindings(ast); - const targetBindings = collectTargetBindings(ast, scope); + const identifierBindings = collectIdentifierBindingIndex(ast); + const targetBindings = collectTargetBindings(ast, scope, identifierBindings); const detection = findTimelineVar(ast, scope); const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; const timelineVar = timelineRootSource(ref, script); const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); - const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); + const rawAnims = calls.map((call) => + tweenCallToAnimation(call, scope, script, identifierBindings), + ); applyTimelineDefaults(rawAnims, detection.defaults); resolveTimelinePositions(rawAnims); const animations = assignStableIds(rawAnims); @@ -1720,10 +1871,13 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap { /* fall back to current behavior */ } } - const targetBindings = collectTargetBindings(ast, scope); + const identifierBindings = collectIdentifierBindingIndex(ast); + const targetBindings = collectTargetBindings(ast, scope, identifierBindings); const calls = findAllTweenCalls(ast, ref, scope, targetBindings); sortBySourcePosition(calls); - const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script)); + const rawAnims = calls.map((call) => + tweenCallToAnimation(call, scope, script, identifierBindings), + ); applyTimelineDefaults(rawAnims, detection.defaults); // Seed tween start-keyframes from gsap.set()/tl.set() pre-states (read-only // enrichment; the write path keeps source untouched for round-trip parity). diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts index 0595961bd..d4080a533 100644 --- a/packages/parsers/src/gsapSerialize.ts +++ b/packages/parsers/src/gsapSerialize.ts @@ -50,6 +50,8 @@ export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeE export interface GsapAnimation { id: string; targetSelector: string; + /** Stable parser-only identity for non-DOM targets whose display label is not unique. */ + targetIdentity?: string; method: GsapMethod; position: number | string; properties: Record;