interface LintParsedGsap { animations: Array<{ targetSelector: string; targetIdentity?: string; method: string; position: number | string; properties: Record; // fromTo() exposes its first ("from") vars object separately; a layout/reflow prop // that appears only here still animates and must be checked. fromProperties?: Record; duration?: number; ease?: string; extras?: Record; resolvedStart?: number; /** True for an off-timeline `gsap.set(...)` (applied once at load). */ global?: boolean; }>; timelineVar: string; } // Use the acorn read parser: it resolves computed timelines (helpers, bounded // loops) so lint findings like overlapping_gsap_tweens reflect true positions // instead of all-collapsed-at-0. It's also browser-safe, so this keeps recast // out of the lint graph entirely. Dynamic import preserves the lazy load. async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap> { const mod = await import("@hyperframes/parsers/gsap-parser-acorn"); return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap; } async function loadGsapScriptMotionPathFirstUseIndex(): Promise<(script: string) => number | null> { const mod = await import("@hyperframes/parsers/gsap-parser-acorn"); return mod.gsapScriptMotionPathFirstUseIndex; } import type { LintContext } from "../context"; import type { HyperframeLintFinding, LintRule } from "../types"; import type { OpenTag } from "../utils"; import { readAttr, readDecodedAttr, truncateSnippet, stripJsComments, hasCaptionStyles, WINDOW_TIMELINE_ASSIGN_PATTERN, TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN, } from "../utils"; // ── GSAP-specific types ──────────────────────────────────────────────────── type GsapWindow = { targetSelector: string; targetIdentity?: string; position: number; end: number; properties: string[]; propertyValues: Record; fromPropertyValues?: Record; overwriteAuto: boolean; immediateRender: boolean; method: string; /** True for an off-timeline `gsap.set(...)` (applied once at load). */ global?: boolean; raw: string; }; type CompositionRange = { id: string; start: number; end: number; }; const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05; // Sentinel the GSAP parser assigns to a tween whose target it cannot statically // resolve to a concrete element (a computed variable, a helper call, etc.). It is // NOT an identity: two distinct unresolved selectors are not the same element, so // 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 { const counts = new Map(); for (const tag of tags) { const classAttr = readAttr(tag.raw, "class"); if (!classAttr) continue; for (const className of classAttr.split(/\s+/).filter(Boolean)) { counts.set(className, (counts.get(className) || 0) + 1); } } return counts; } function readRegisteredTimelineCompositionId(script: string): string | null { const match = script.match(WINDOW_TIMELINE_ASSIGN_PATTERN); return match?.[1] || match?.[2] || null; } /** Strip a `__raw:` prefix the parser adds to unresolvable values. */ function unwrapRaw(value: unknown): string | number | undefined { if (typeof value === "number") return value; if (typeof value !== "string") return undefined; const code = value.startsWith("__raw:") ? value.slice(6) : value; return code.replace(/^\s*["']|["']\s*$/g, ""); } function extrasNumber(value: unknown): number { const unwrapped = unwrapRaw(value); const numeric = typeof unwrapped === "number" ? unwrapped : Number(unwrapped); return Number.isFinite(numeric) ? numeric : 0; } /** A readable single-line snippet of a tween for finding messages. */ function synthesizeWindowRaw( timelineVar: string, anim: LintParsedGsap["animations"][number], ): string { const entries = Object.entries(anim.properties).map(([k, v]) => { if (typeof v === "string" && v.startsWith("__raw:")) return `${k}: ${v.slice(6)}`; return `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`; }); if (anim.duration !== undefined) entries.push(`duration: ${anim.duration}`); if (anim.ease) entries.push(`ease: ${JSON.stringify(anim.ease)}`); const pos = typeof anim.position === "number" ? anim.position : JSON.stringify(anim.position); return `${timelineVar}.${anim.method}("${anim.targetSelector}", { ${entries.join(", ")} }, ${pos})`; } const gsapWindowsCache = new Map(); async function cachedExtractGsapWindows(scriptContent: string): Promise { const cached = gsapWindowsCache.get(scriptContent); if (cached) return cached; const windows = await extractGsapWindows(scriptContent); gsapWindowsCache.set(scriptContent, windows); return windows; } // fallow-ignore-next-line complexity async function extractGsapWindows(script: string): Promise { if (!/gsap\.timeline/.test(script)) return []; const parseGsapScript = await loadParseGsapScript(); const parsed = parseGsapScript(script); if (parsed.animations.length === 0) return []; const windows: GsapWindow[] = []; for (const animation of parsed.animations) { const start = animation.resolvedStart ?? (typeof animation.position === "number" ? animation.position : null); if (start === null) continue; const repeat = extrasNumber(animation.extras?.repeat); const infiniteRepeat = repeat < 0; const cycleCount = infiniteRepeat ? 1 : repeat > 0 ? repeat + 1 : 1; const effectiveDuration = animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount; windows.push({ targetSelector: animation.targetSelector, targetIdentity: animation.targetIdentity, position: start, end: infiniteRepeat && animation.method !== "set" ? Number.POSITIVE_INFINITY : start + effectiveDuration, properties: Object.keys(animation.properties), propertyValues: animation.properties, fromPropertyValues: animation.fromProperties, overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto", immediateRender: unwrapRaw(animation.extras?.immediateRender) === "true", method: animation.method, global: animation.global, raw: synthesizeWindowRaw(parsed.timelineVar, animation), }); } return windows; } function numberValue(value: string | number | undefined): number | null { if (typeof value === "number") return value; if (typeof value === "string" && value.trim()) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; } return null; } function stringValue(value: string | number | undefined): string | null { if (typeof value === "string") return value; if (typeof value === "number") return String(value); return null; } function zeroValue(value: string | number | undefined): boolean { if (typeof value === "number") return value === 0; if (typeof value !== "string") return false; return Number(value.trim()) === 0; } function isHiddenGsapState(values: Record): boolean { const visibility = stringValue(values.visibility)?.toLowerCase(); const display = stringValue(values.display)?.toLowerCase(); return ( zeroValue(values.opacity) || zeroValue(values.autoAlpha) || visibility === "hidden" || display === "none" ); } function extractStandaloneHiddenSelectors(script: string): Set { const selectors = new Set(); const source = stripJsComments(script); const functionRanges = collectFunctionBodyRanges(source); const aliases = new Map(); for (const match of source.matchAll( /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])([^"'`]+)\2\s*;/g, )) { aliases.set(match[1] ?? "", match[3] ?? ""); } const pattern = /gsap\.set\s*\(\s*([^,]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g; let match: RegExpExecArray | null; while ((match = pattern.exec(source)) !== null) { // Skip callback/handler bodies; keep IIFEs (they run at parse time). if (indexInsideNonIifeRange(match.index, source, functionRanges)) continue; const target = (match[1] ?? "").trim(); const selector = /^(["'`])([^"'`]+)\1$/.exec(target)?.[2] ?? aliases.get(target); if (!selector) continue; const body = match[2] ?? ""; if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) { selectors.add(selector); } } return selectors; } function oneValue( values: Record, keys: string[], ): string | number | undefined { for (const key of keys) { const value = values[key]; if (value !== undefined) return value; } return undefined; } function isVisibleGsapState(values: Record): boolean { const opacity = oneValue(values, ["opacity", "autoAlpha"]); if (typeof opacity === "number") return opacity > 0; if (typeof opacity === "string" && opacity.trim()) { const numeric = Number(opacity); if (Number.isFinite(numeric)) return numeric > 0; } const visibility = stringValue(values.visibility)?.toLowerCase(); if (visibility === "visible" || visibility === "inherit") return true; const display = stringValue(values.display)?.toLowerCase(); if (display && display !== "none") return true; return false; } function makesOverlayVisible(win: GsapWindow): boolean { if (win.method === "from" && isHiddenGsapState(win.propertyValues)) return true; return isVisibleGsapState(win.propertyValues); } function isSceneBoundaryExit(win: GsapWindow): boolean { if (win.end <= win.position) return false; if (win.method !== "to" && win.method !== "fromTo") return false; return isHiddenGsapState(win.propertyValues); } function isHardKillSet(win: GsapWindow, selector: string, boundary: number): boolean { return ( win.method === "set" && win.targetSelector === selector && Math.abs(win.position - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues) ); } function hiddenStateLiteral(values: Record): string { if (zeroValue(values.autoAlpha)) return "{ autoAlpha: 0 }"; if (zeroValue(values.opacity)) return "{ opacity: 0 }"; if (stringValue(values.visibility)?.toLowerCase() === "hidden") return '{ visibility: "hidden" }'; if (stringValue(values.display)?.toLowerCase() === "none") return '{ display: "none" }'; return "{ opacity: 0 }"; } function findTagEnd(source: string, tag: OpenTag): number { const escapedTagName = tag.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const pattern = new RegExp(`<\\/?${escapedTagName}\\b[^>]*>`, "gi"); pattern.lastIndex = tag.index; let depth = 0; let match: RegExpExecArray | null; while ((match = pattern.exec(source)) !== null) { const raw = match[0]; const isClosing = /^<\s*\//.test(raw); const isSelfClosing = /\/\s*>$/.test(raw); if (!isClosing && !isSelfClosing) depth += 1; if (isClosing) depth -= 1; if (depth === 0) return pattern.lastIndex; } return source.length; } function collectCompositionRanges(source: string, tags: OpenTag[]): CompositionRange[] { return tags .map((tag) => { const id = readDecodedAttr(tag.raw, "data-composition-id"); if (!id) return null; return { id, start: tag.index, end: findTagEnd(source, tag), }; }) .filter((range) => range !== null); } function findContainingCompositionId(tag: OpenTag, ranges: CompositionRange[]): string | null { let match: CompositionRange | null = null; for (const range of ranges) { if (tag.index < range.start || tag.index >= range.end) continue; if (!match || range.start >= match.start) match = range; } return match?.id || null; } // A tag's `class` attribute, split into tokens, but only when it carries the // `clip` marker class — the common "is this a clip element?" filter used by // several rules that walk every tag looking for clips. type ClipTagClasses = { classAttr: string; classes: string[] }; function getClipTagClasses(tag: OpenTag): ClipTagClasses | null { const classAttr = readAttr(tag.raw, "class") || ""; const classes = classAttr.split(/\s+/).filter(Boolean); return classes.includes("clip") ? { classAttr, classes } : null; } function collectClipStartBoundariesByComposition( source: string, tags: OpenTag[], ): Map { const ranges = collectCompositionRanges(source, tags); const boundaries = new Map>(); for (const tag of tags) { if (!getClipTagClasses(tag)) continue; const compositionId = findContainingCompositionId(tag, ranges); if (!compositionId) continue; const start = numberValue(readAttr(tag.raw, "data-start") ?? undefined); if (start == null || start <= 0) continue; const compositionBoundaries = boundaries.get(compositionId) ?? new Set(); compositionBoundaries.add(start); boundaries.set(compositionId, compositionBoundaries); } return new Map( [...boundaries.entries()].map(([compositionId, values]) => [ compositionId, [...values].sort((a, b) => a - b), ]), ); } function findMatchingSceneBoundary(time: number, boundaries: number[]): number | null { for (const boundary of boundaries) { if (Math.abs(time - boundary) <= SCENE_BOUNDARY_EPSILON_SECONDS) return boundary; } return null; } function isSuspiciousGlobalSelector(selector: string): boolean { if (!selector) return false; if (selector.includes("[data-composition-id=")) return false; if (selector.startsWith("#")) return false; return selector.startsWith(".") || /^[a-z]/i.test(selector); } function getSingleClassSelector(selector: string): string | null { const match = selector.trim().match(/^\.(?[A-Za-z0-9_-]+)$/); return match?.groups?.name || null; } function readStyleProperty(style: string, property: string): string | null { const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = style.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, "i")); return match?.[1]?.trim() || null; } function cssZero(value: string | null): boolean { if (!value) return false; return /^0(?:\.0+)?(?:px|%|vw|vh|rem|em)?$/i.test(value.trim()); } function styleHasHiddenInitialState(style: string): boolean { const opacity = readStyleProperty(style, "opacity"); if (opacity && Number(opacity) === 0) return true; if (readStyleProperty(style, "visibility")?.toLowerCase() === "hidden") return true; if (readStyleProperty(style, "display")?.toLowerCase() === "none") return true; return false; } function styleHasOpaqueBackground(style: string): boolean { const background = readStyleProperty(style, "background") || readStyleProperty(style, "background-color"); if (!background) return false; const normalized = background.toLowerCase().replace(/\s+/g, ""); if (normalized === "transparent" || normalized === "none") return false; if (/rgba?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false; if (/hsla?\([^)]*,0(?:\.0+)?\)$/.test(normalized)) return false; return true; } function styleLooksFullFrameOverlay(style: string): boolean { const position = readStyleProperty(style, "position")?.toLowerCase(); if (position !== "fixed" && position !== "absolute") return false; const coversFrame = cssZero(readStyleProperty(style, "inset")) || (cssZero(readStyleProperty(style, "top")) && cssZero(readStyleProperty(style, "right")) && cssZero(readStyleProperty(style, "bottom")) && cssZero(readStyleProperty(style, "left"))); return coversFrame && styleHasOpaqueBackground(style); } function collectSimpleStyleRules(styles: LintContext["styles"]): Map { const rules = new Map(); for (const style of styles) { for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) { if (!selectorList || !body) continue; for (const selector of selectorList.split(",")) { const token = selector.trim(); if (!/^[#.][A-Za-z0-9_-]+$/.test(token)) continue; rules.set(token, `${rules.get(token) || ""};${body}`); } } } return rules; } function tagSimpleSelectors(tag: OpenTag): string[] { const selectors: string[] = []; const id = readAttr(tag.raw, "id"); if (id) selectors.push(`#${id}`); const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []; for (const className of classes) selectors.push(`.${className}`); return selectors; } function combinedTagStyle(tag: OpenTag, styleRules: Map): string { const styles = [readAttr(tag.raw, "style") || ""]; for (const selector of tagSimpleSelectors(tag)) { const ruleStyle = styleRules.get(selector); if (ruleStyle) styles.push(ruleStyle); } return styles.filter(Boolean).join(";"); } // fallow-ignore-next-line complexity function cssTransformToGsapProps(cssTransform: string): string | null { const parts: string[] = []; // translate(-50%, -50%) or translate(X, Y) const translateMatch = cssTransform.match( /translate\(\s*(-?[\d.]+)(%|px)?\s*,\s*(-?[\d.]+)(%|px)?\s*\)/, ); if (translateMatch) { const [, xVal, xUnit, yVal, yUnit] = translateMatch; if (xUnit === "%") parts.push(`xPercent: ${xVal}`); else parts.push(`x: ${xVal}`); if (yUnit === "%") parts.push(`yPercent: ${yVal}`); else parts.push(`y: ${yVal}`); } // translateX(-50%) or translateX(px) const txMatch = cssTransform.match(/translateX\(\s*(-?[\d.]+)(%|px)?\s*\)/); if (txMatch) { const [, val, unit] = txMatch; parts.push(unit === "%" ? `xPercent: ${val}` : `x: ${val}`); } // translateY(-50%) or translateY(px) const tyMatch = cssTransform.match(/translateY\(\s*(-?[\d.]+)(%|px)?\s*\)/); if (tyMatch) { const [, val, unit] = tyMatch; parts.push(unit === "%" ? `yPercent: ${val}` : `y: ${val}`); } // scale(N) const scaleMatch = cssTransform.match(/scale\(\s*([\d.]+)\s*\)/); if (scaleMatch) { parts.push(`scale: ${scaleMatch[1]}`); } return parts.length > 0 ? parts.join(", ") : null; } // ── CSS-transform ↔ GSAP-transform conflict matching ───────────────────────── // Transform components that COMBINE with a CSS translate/scale on the same // element. GSAP bakes the element's existing CSS transform in when it seeks, so // these stack rather than override in the capture path (e.g. CSS translateX(-50%) // + xPercent:-50 renders as -100% — off-centre). `rotation` is excluded: it maps // to CSS rotate(), which this rule treats separately (no false positive on spin). const CONFLICTING_TRANSLATE_PROPS = ["x", "y", "xPercent", "yPercent"]; const CONFLICTING_SCALE_PROPS = ["scale", "scaleX", "scaleY"]; type GsapTransformCall = { method: string; selector: string; properties: string[]; raw: string; }; // Decompose a (possibly grouped / descendant / compound) GSAP target selector // into the simple `#id` / `.class` tokens of the elements it actually targets — // the RIGHTMOST compound of each comma group is the targeted element. This lets a // CSS rule keyed by a simple selector (`.m04-label`) match a scoped GSAP selector // (`"#root .m04-label, #root .m04-sub"`), which the prior exact-string lookup // missed — so every scoped/grouped selector slipped past the rule entirely. function targetedSelectorTokens(selector: string): Set { const tokens = new Set(); for (const group of selector.split(",")) { const compounds = group .trim() .split(/[\s>+~]+/) .filter(Boolean); const last = compounds[compounds.length - 1]; if (!last) continue; const simple = last.match(/[#.][A-Za-z0-9_-]+/g); if (simple) for (const token of simple) tokens.add(token); } return tokens; } // Find a CSS transform conflicting with a GSAP target selector: exact-string // match first (fast path + back-compat with the original behaviour), then a // token match so scoped/grouped/descendant selectors resolve to their class/id. function matchCssTransform(gsapSelector: string, cssMap: Map): string | undefined { if (cssMap.size === 0) return undefined; const direct = cssMap.get(gsapSelector); if (direct) return direct; const tokens = targetedSelectorTokens(gsapSelector); for (const [cssSelector, value] of cssMap) { if (tokens.has(cssSelector)) return value; } return undefined; } // Scan for STANDALONE `gsap.set/to/from/fromTo("selector", { ...props })` calls. // The acorn timeline parser only captures calls rooted on the timeline var // (`tl.to`, `tl.set`, …); a top-level `gsap.set("#root .label", { xPercent: -50 })` // — a common way to seat shared base transforms before the timeline runs — is // invisible to it, so the conflict rule never saw it. Variable selectors // (`gsap.set(kicker, …)`) can't be resolved statically and are skipped. function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[] { const calls: GsapTransformCall[] = []; const pattern = /gsap\.(set|to|from|fromTo)\s*\(\s*(["'])([^"']+)\2\s*,\s*\{([^{}]*)\}/g; let match: RegExpExecArray | null; while ((match = pattern.exec(script)) !== null) { const method = match[1] ?? "set"; const selector = match[3] ?? ""; const propsBody = match[4] ?? ""; const properties = [...propsBody.matchAll(/([A-Za-z_$][\w$]*)\s*:/g)].map((m) => m[1] ?? ""); calls.push({ method, selector, properties, raw: truncateSnippet(match[0]) ?? match[0] }); } return calls; } // Run a global regex over every script's content, yielding each match plus a // context-padded snippet around it. Shared by the repeat-count and // group-selector-keyframes rules below, which differ only in the pattern, // whether comments are stripped first, and the context window size. function scanScriptsForRegexMatches( scripts: LintContext["scripts"], pattern: RegExp, options: { stripComments: boolean; contextBefore: number; contextAfter: number }, ): Array<{ match: RegExpExecArray; snippet: string }> { const hits: Array<{ match: RegExpExecArray; snippet: string }> = []; for (const script of scripts) { const content = options.stripComments ? stripJsComments(script.content) : script.content; const regex = new RegExp(pattern.source, pattern.flags); let match: RegExpExecArray | null; while ((match = regex.exec(content)) !== null) { const contextStart = Math.max(0, match.index - options.contextBefore); const contextEnd = Math.min( content.length, match.index + match[0].length + options.contextAfter, ); hits.push({ match, snippet: content.slice(contextStart, contextEnd) }); } } return hits; } // ── Seek-order safety helpers ─────────────────────────────────────────────── // // The renderer distributes frames across workers; cold render workers seek // non-linearly straight into their range instead of playing sequentially from 0. // Any state that depends on seek ORDER — relative tween bases, callback-measured // geometry, per-init random values — renders differently per worker, visible as // position jumps or dead animation at chunk boundaries. const RELATIVE_TWEEN_VALUE = /^[+-]=/; function isRelativeTweenValue(value: string | number | undefined): boolean { return typeof value === "string" && RELATIVE_TWEEN_VALUE.test(value.trim()); } // DOM reads split by transform sensitivity. Transform-sensitive reads report // live animated geometry, so their result depends on the worker's own seek // order. Transform-invariant layout reads (intrinsic size, path geometry) give // the same answer on every worker as long as layout itself is not animated. const TRANSFORM_SENSITIVE_READ = /\.getBoundingClientRect\s*\(|\bgetComputedStyle\s*\(|\bgsap\.getProperty\s*\(/; const TRANSFORM_INVARIANT_READ = /\.(?:getTotalLength|getBBox)\s*\(|\.(?:offsetWidth|offsetHeight|clientWidth|clientHeight)\b/; // Measurement set for CALLBACK analysis: gsap.getProperty is deliberately // excluded — callbacks that read animated values to drive derived output // (scramble text, typewriter cursors) are per-frame deterministic and // seek-idempotent, so they render the same on every worker. const CALLBACK_MEASUREMENT_PATTERN = /\.(?:getBoundingClientRect|getTotalLength|getBBox)\s*\(|\bgetComputedStyle\s*\(|\.(?:offsetWidth|offsetHeight|clientWidth|clientHeight)\b/; function indexTagsByToken(tags: OpenTag[]): Map { const tagsByToken = new Map(); const addToken = (token: string, tag: OpenTag): void => { const list = tagsByToken.get(token); if (list) list.push(tag); else tagsByToken.set(token, [tag]); }; for (const tag of tags) { const id = readAttr(tag.raw, "id"); if (id) addToken(`#${id}`, tag); for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []) addToken(`.${cls}`, tag); } return tagsByToken; } function resolveSelectorTagIndexes( selector: string, tagsByToken: Map, ): Set { const indexes = new Set(); for (const token of targetedSelectorTokens(selector)) { for (const tag of tagsByToken.get(token) ?? []) indexes.add(tag.index); } return indexes; } // A selector whose comma groups are each a single simple compound (no // combinators, no attribute selectors) — the only shape that resolves // faithfully through simple #id/.class tokens. Descendant selectors // (".card-a .icon") and composition-scoped selectors // ('[data-composition-id="a"] .dot') would mis-join across elements or // compositions, so token-based matching must bail on them. function selectorResolvesFaithfully(selector: string): boolean { return selector.split(",").every((group) => { const token = group.trim(); if (!token || token.includes("[")) return false; return !/[\s>+~]/.test(token); }); } // Two GSAP targets provably hit the same element when their stable identities // are equal, or when their (faithfully resolvable) selectors resolve to // intersecting element sets — an id selector and a class selector can name the // same node. Selectors with combinators or attribute parts are skipped rather // than guessed at. function targetsShareElement( a: { selector: string; identity?: string }, b: { selector: string; identity?: string }, tagsByToken: Map, ): boolean { if ( !targetHasNoStableIdentity(a.selector, a.identity) && !targetHasNoStableIdentity(b.selector, b.identity) && (a.identity ?? a.selector) === (b.identity ?? b.selector) ) { return true; } if (!selectorResolvesFaithfully(a.selector) || !selectorResolvesFaithfully(b.selector)) { return false; } const aTags = resolveSelectorTagIndexes(a.selector, tagsByToken); if (aTags.size === 0) return false; const bTags = resolveSelectorTagIndexes(b.selector, tagsByToken); for (const index of bTags) if (aTags.has(index)) return true; return false; } /** Source from the delimiter at `openIndex` to its matching closer, inclusive. */ function matchBalanced( source: string, openIndex: number, open: string, close: string, ): string | null { let depth = 0; for (let i = openIndex; i < source.length; i++) { const ch = source[i]; if (ch === open) depth++; else if (ch === close) { depth--; if (depth === 0) return source.slice(openIndex, i + 1); } } return null; } /** The nearest object literal `{...}` enclosing `index` (comment-stripped source). */ function enclosingObjectLiteral(source: string, index: number): string | null { let depth = 0; for (let i = index; i >= 0; i--) { const ch = source[i]; if (ch === "}") depth++; else if (ch === "{") { if (depth === 0) return matchBalanced(source, i, "{", "}"); depth--; } } return null; } function objectLiteralHasTopLevelRelativeValue(objectLiteral: string): boolean { let depth = 0; let inString: '"' | "'" | "`" | null = null; for (let i = 0; i < objectLiteral.length; i++) { const ch = objectLiteral[i] ?? ""; const prev = objectLiteral[i - 1] ?? ""; if (inString) { if (ch === inString && prev !== "\\") inString = null; continue; } if (ch === '"' || ch === "'" || ch === "`") { inString = ch; if (depth === 1 && /^[+-]=/.test(objectLiteral.slice(i + 1))) return true; continue; } if (ch === "{" || ch === "(" || ch === "[") depth++; else if (ch === "}" || ch === ")" || ch === "]") depth--; } return false; } function isInsideGsapTweenVars(source: string, index: number, timelineVars: string[]): boolean { let depth = 0; for (let i = index; i >= 0; i--) { const ch = source[i]; if (ch === "}") depth++; else if (ch === "{") { if (depth === 0) { const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " "); const receivers = ["gsap", ...timelineVars].map(escapeRegExp).join("|"); return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test( before, ); } depth--; } } return false; } /** An expression starting at `start`, ending at the first `,` / closer at depth 0. */ function sliceExpression(source: string, start: number): string { let depth = 0; for (let i = start; i < source.length; i++) { const ch = source[i] ?? ""; if ("({[".includes(ch)) depth++; else if (")}]".includes(ch)) { if (depth === 0) return source.slice(start, i); depth--; } else if (ch === "," && depth === 0) return source.slice(start, i); } return source.slice(start); } type ParsedFunctionValue = { firstParam: string | null; body: string }; function normalizeFirstParam(raw: string): string | null { let param = raw.trim().replace(/=.*$/, "").trim(); param = param.replace(/\s*:\s*[\w$|<>,\s[\].]+$/, "").trim(); if (!param || /^[[{]/.test(param)) return null; if (!/^[A-Za-z_$][\w$]*$/.test(param)) return null; return param; } /** Parse a function-shaped source string into its first parameter and body. */ function parseFunctionValueSource(code: string): ParsedFunctionValue | null { const src = code.trim(); const match = src.match(/^(?:async\s+)?function\s*[\w$]*\s*\(([^)]*)\)/) ?? src.match(/^(?:async\s*)?\(([^)]*)\)\s*=>/) ?? src.match(/^(?:async\s*)?([A-Za-z_$][\w$]*)\s*=>/); if (!match) return null; const firstParam = normalizeFirstParam((match[1] ?? "").split(",")[0] ?? ""); return { firstParam, body: src.slice(match[0].length) }; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // Methods that exist on numbers: calling them on the (index) first parameter of // a GSAP function value is valid and must not be flagged. const NUMBER_METHODS = new Set([ "toFixed", "toString", "toPrecision", "toExponential", "toLocaleString", "valueOf", ]); // Index is a NUMBER — non-number member access on the first param throws at init. function firstParamMemberAccessHazard(fn: ParsedFunctionValue): string | null { if (!fn.firstParam) return null; const pattern = new RegExp( `\\b${escapeRegExp(fn.firstParam)}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`, "g", ); let match: RegExpExecArray | null; while ((match = pattern.exec(fn.body)) !== null) { const member = match[1] ?? ""; const after = fn.body.slice(match.index + match[0].length); const isCall = /^\s*\(/.test(after); if (isCall && NUMBER_METHODS.has(member)) continue; return member; } return null; } /** Names of timeline variables (`const tl = gsap.timeline(...)`) in a script. */ function collectTimelineVarNames(source: string): string[] { return [...source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*gsap\.timeline\b/g)] .map((m) => m[1] ?? "") .filter(Boolean); } // Named function bodies in a script (declarations plus `const f = ...` function // expressions and arrows). Expression-bodied arrows keep their single line. function collectNamedFunctionBodies(source: string): Map { const bodies = new Map(); const declPattern = /(?:^|[^.\w$])function\s+([A-Za-z_$][\w$]*)\s*\(/g; let match: RegExpExecArray | null; while ((match = declPattern.exec(source)) !== null) { const braceIndex = source.indexOf("{", declPattern.lastIndex); if (braceIndex < 0) continue; const body = matchBalanced(source, braceIndex, "{", "}"); if (body) bodies.set(match[1] ?? "", body); } const assignPattern = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b[^{]*|\([^)]*\)\s*=>\s*|[A-Za-z_$][\w$]*\s*=>\s*)/g; while ((match = assignPattern.exec(source)) !== null) { const bodyStart = assignPattern.lastIndex; const body = source[bodyStart] === "{" ? matchBalanced(source, bodyStart, "{", "}") : sliceExpression(source, bodyStart); if (body) bodies.set(match[1] ?? "", body); } return bodies; } // Two-hop closure: functions whose body measures the DOM directly, plus // functions that call one of those (bounded fixpoint — no deep recursion). function collectMeasuringFunctionNames(bodies: Map): Set { const measuring = new Set(); for (const [name, body] of bodies) { if (CALLBACK_MEASUREMENT_PATTERN.test(body)) measuring.add(name); } for (let pass = 0; pass < 3; pass++) { let grew = false; for (const [name, body] of bodies) { if (measuring.has(name)) continue; for (const measured of measuring) { if (new RegExp(`\\b${escapeRegExp(measured)}\\s*\\(`).test(body)) { measuring.add(name); grew = true; break; } } } if (!grew) break; } return measuring; } function expressionReachesMeasurement(expression: string, measuring: Set): boolean { if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true; for (const name of measuring) { if (new RegExp(`\\b${escapeRegExp(name)}\\b`).test(expression)) return true; } return false; } // Resolve script-level element variables to the simple selector tokens they can // denote: literal getElementById/querySelector lookups, template-literal ids // matched against the document's actual ids, and script-assigned class names // (createElementNS + setAttribute("class", ...)). Anything else stays unresolved. function resolveScriptElementTokens(source: string, tags: OpenTag[]): Map> { const documentIds = tags.map((tag) => readAttr(tag.raw, "id")).filter((id) => id !== null); const tokensByVar = new Map>(); const add = (name: string, token: string): void => { const tokens = tokensByVar.get(name) ?? new Set(); tokens.add(token); tokensByVar.set(name, tokens); }; for (const match of source.matchAll( /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.getElementById\(\s*(["'])([^"'`]+)\2/g, )) { add(match[1] ?? "", `#${match[3] ?? ""}`); } for (const match of source.matchAll( /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.getElementById\(\s*`([^`]*)`/g, )) { const template = match[2] ?? ""; const staticParts = template.split(/\$\{[^}]*\}/); // A template with no literal segments (`getElementById(\`${name}\`)`) would // match EVERY id in the document — treat it as unresolved instead. if (staticParts.every((part) => part === "")) continue; const idPattern = new RegExp(`^${staticParts.map(escapeRegExp).join(".*")}$`); for (const id of documentIds) { if (idPattern.test(id)) add(match[1] ?? "", `#${id}`); } } for (const match of source.matchAll( /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.querySelector\(\s*(["'])([^"'`]+)\2/g, )) { for (const token of targetedSelectorTokens(match[3] ?? "")) add(match[1] ?? "", token); } for (const match of source.matchAll( /\b([A-Za-z_$][\w$]*)\.setAttribute\(\s*(["'])class\2\s*,\s*(["'])([^"'`]*)\3/g, )) { for (const cls of (match[4] ?? "").split(/\s+/).filter(Boolean)) add(match[1] ?? "", `.${cls}`); } for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\.className\s*=\s*(["'])([^"'`]*)\2/g)) { for (const cls of (match[3] ?? "").split(/\s+/).filter(Boolean)) add(match[1] ?? "", `.${cls}`); } return tokensByVar; } /** Expand selector tokens to the FULL token sets of the elements they resolve to. */ function elementLevelTokens( tokens: Iterable, tagsByToken: Map, ): Set { const expanded = new Set(tokens); for (const token of [...expanded]) { for (const tag of tagsByToken.get(token) ?? []) { for (const own of tagSimpleSelectors(tag)) expanded.add(own); } } return expanded; } function isMultiComponentDasharray(value: string): boolean { const normalized = value.replace(/!important\s*$/i, "").trim(); if (!normalized || /^none$/i.test(normalized)) return false; return normalized.split(/[\s,]+/).filter(Boolean).length >= 2; } // A GSAP strokeDasharray value that is a static string/template with >= 2 // components is the explicit "L L" fix form — safe. Variables and numbers are // the common single-component draw-on form (the pathLength trick). function gsapDasharrayValueLooksMultiComponent(valueSource: string): boolean { const literal = valueSource.trim().match(/^(["'`])([\s\S]*)\1$/)?.[2]; if (literal === undefined) return false; return isMultiComponentDasharray(literal.replace(/\$\{[^}]*\}/g, "0")); } /** Byte ranges of every function body (declarations, expressions, block arrows). */ function collectFunctionBodyRanges(source: string): Array<{ start: number; end: number }> { const ranges: Array<{ start: number; end: number }> = []; const openerPatterns = [/\bfunction\b[^{;()]*\([^)]*\)\s*\{/g, /=>\s*\{/g]; for (const pattern of openerPatterns) { let match: RegExpExecArray | null; while ((match = pattern.exec(source)) !== null) { const braceIndex = match.index + match[0].length - 1; const body = matchBalanced(source, braceIndex, "{", "}"); if (body) ranges.push({ start: braceIndex, end: braceIndex + body.length }); } } return ranges; } function indexInsideAnyRange( index: number, ranges: Array<{ start: number; end: number }>, ): boolean { return ranges.some((range) => index > range.start && index < range.end); } function isIifeBody(source: string, range: { start: number; end: number }): boolean { let j = range.end; while (j < source.length && /\s/.test(source[j]!)) j++; if (source[j] !== ")") return false; j++; while (j < source.length && /\s/.test(source[j]!)) j++; return source[j] === "(" || source.startsWith(".call", j) || source.startsWith(".apply", j); } function indexInsideNonIifeRange( index: number, source: string, ranges: Array<{ start: number; end: number }>, ): boolean { return ranges.some( (range) => index > range.start && index < range.end && !isIifeBody(source, range), ); } // Simple selectors whose authored CSS (style blocks or inline styles) sets // opacity to EXACTLY zero. The declaration regex is boundary-anchored so // `opacity: 0.98` never matches; it ends at `;` or end of input, which also // catches a final declaration without a trailing semicolon. function collectCssOpacityZeroSelectors( styles: LintContext["styles"], tags: OpenTag[], ): Set { const selectors = new Set(); const opacityExactlyZero = /opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/; for (const style of styles) { for (const [, selector, body] of style.content.matchAll( /([#.][a-zA-Z0-9_-]+)\s*\{([^}]+)\}/g, )) { if (body && opacityExactlyZero.test(body)) { selectors.add((selector ?? "").trim()); } } } for (const tag of tags) { const inlineStyle = readAttr(tag.raw, "style"); if (!inlineStyle || !opacityExactlyZero.test(inlineStyle)) continue; const id = readAttr(tag.raw, "id"); if (id) selectors.add(`#${id}`); for (const cls of readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []) { selectors.add(`.${cls}`); } } return selectors; } // ── GSAP rules ───────────────────────────────────────────────────────────── // fallow-ignore-next-line complexity export const gsapRules: LintRule[] = [ // overlapping_gsap_tweens + gsap_animates_clip_element + unscoped_gsap_selector // fallow-ignore-next-line complexity async ({ source, tags, scripts, styles, rootCompositionId }) => { const findings: HyperframeLintFinding[] = []; // Build clip element selector map type ClipInfo = { tag: string; id: string; classes: string }; const clipIds = new Map(); const clipClasses = new Map(); for (const tag of tags) { const clipTag = getClipTagClasses(tag); if (!clipTag) continue; const id = readAttr(tag.raw, "id"); const info: ClipInfo = { tag: tag.name, id: id || "", classes: clipTag.classAttr, }; if (id) clipIds.set(`#${id}`, info); for (const cls of clipTag.classes) { if (cls !== "clip") clipClasses.set(`.${cls}`, info); } } const classUsage = countClassUsage(tags); const clipStartBoundariesByComposition = collectClipStartBoundariesByComposition(source, tags); const styleRules = collectSimpleStyleRules(styles); const reportedVisibleOverlayKeys = new Set(); for (const script of scripts) { const localTimelineCompId = readRegisteredTimelineCompositionId(script.content); const gsapWindows = await cachedExtractGsapWindows(script.content); const clipStartBoundaries = clipStartBoundariesByComposition.get(localTimelineCompId || rootCompositionId || "") ?? []; // overlapping_gsap_tweens for (let i = 0; i < gsapWindows.length; i++) { const left = gsapWindows[i]; if (!left) continue; 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 (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; 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; if (left.overwriteAuto || right.overwriteAuto) continue; const sharedProperties = left.properties.filter((prop) => right.properties.includes(prop), ); if (sharedProperties.length === 0) continue; findings.push({ code: "overlapping_gsap_tweens", severity: "warning", message: `GSAP tweens overlap on "${left.targetSelector}" for ${sharedProperties.join(", ")} between ${overlapStart.toFixed(2)}s and ${overlapEnd.toFixed(2)}s.`, selector: left.targetSelector, fixHint: 'Shorten the earlier tween, move the later tween, or add `overwrite: "auto"`.', snippet: truncateSnippet(`${left.raw}\n${right.raw}`), }); } } // gsap_exit_missing_hard_kill if (clipStartBoundaries.length > 0) { for (const win of gsapWindows) { // Unresolved targets are unknown elements: you cannot assert a missing // hard kill on one, and a `tl.set("__unresolved__", ...)` hint is meaningless. if (win.targetSelector === UNRESOLVED_TARGET) continue; if (!isSceneBoundaryExit(win)) continue; const boundary = findMatchingSceneBoundary(win.end, clipStartBoundaries); if (boundary == null) continue; const hasHardKill = gsapWindows.some((candidate) => isHardKillSet(candidate, win.targetSelector, boundary), ); if (hasHardKill) continue; // A tl.set hard kill on the exiting selector itself is the fix — unless // that selector IS a clip element, in which case gsap_animates_clip_element // (below) errors on that exact tl.set: the framework already owns // visibility/display on clip elements. Point at the inner-wrapper // pattern instead so the two rules' advice doesn't contradict. const exitClipInfo = clipIds.get(win.targetSelector) || clipClasses.get(win.targetSelector); const fixHint = exitClipInfo ? `"${win.targetSelector}" is a clip element — the framework already manages its visibility. ` + "Wrap the scene's content in an inner non-clip
, move the exit tween and the hard kill " + `(\`tl.set("", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\`) onto that wrapper instead.` : `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` + "after the exit tween."; findings.push({ code: "gsap_exit_missing_hard_kill", severity: "error", message: `GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary ` + "without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.", selector: win.targetSelector, fixHint, snippet: truncateSnippet(win.raw), }); } } // gsap_fullscreen_overlay_starts_visible for (const tag of tags) { const selectors = tagSimpleSelectors(tag); if (selectors.length === 0) continue; const overlayKey = readAttr(tag.raw, "id") || String(tag.index); if (reportedVisibleOverlayKeys.has(overlayKey)) continue; const authoredStyle = combinedTagStyle(tag, styleRules); if (!authoredStyle || !styleLooksFullFrameOverlay(authoredStyle)) continue; if (styleHasHiddenInitialState(authoredStyle)) continue; const visibilityWindows = gsapWindows .filter((win) => { const tokens = targetedSelectorTokens(win.targetSelector); if (!selectors.some((selector) => tokens.has(selector))) return false; return win.properties.some((prop) => ["opacity", "autoAlpha", "visibility", "display"].includes(prop), ); }) .sort((a, b) => a.position - b.position); const startsHiddenAtZero = visibilityWindows.some( (win) => win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues), ); if (startsHiddenAtZero) continue; const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win)); if (!firstVisible) continue; const selector = selectors.find((candidate) => targetedSelectorTokens(firstVisible.targetSelector).has(candidate), ) || selectors[0] || tag.name; const laterHidden = visibilityWindows.some( (win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues), ); if (firstVisible.method !== "from" && !laterHidden) continue; reportedVisibleOverlayKeys.add(overlayKey); findings.push({ code: "gsap_fullscreen_overlay_starts_visible", severity: "error", message: `Full-frame overlay "${selector}" starts visible before its first GSAP opacity tween at ` + `${firstVisible.position.toFixed(2)}s. It will cover earlier render frames, often as a blank/white video.`, selector, elementId: readAttr(tag.raw, "id") || undefined, // gsap_timeline_set_initial_hide warns on `tl.set(..., 0)` initial hides // (a zero-duration set at 0 does not render at exactly t=0), so this hint // must not recommend that pattern — advise authored CSS or an immediate // gsap.set() instead, keeping the two rules' advice consistent. fixHint: `Add \`opacity: 0\` to "${selector}" in CSS/inline styles, or add an immediate ` + `\`gsap.set("${selector}", { opacity: 0 })\` (outside the timeline) before the reveal tween.`, snippet: truncateSnippet(firstVisible.raw), }); } // gsap_animates_clip_element — only error when GSAP animates visibility/display for (const win of gsapWindows) { const sel = win.targetSelector; const clipInfo = clipIds.get(sel) || clipClasses.get(sel); if (!clipInfo) continue; const conflictingProps = win.properties.filter( (p) => p === "visibility" || p === "display", ); if (conflictingProps.length === 0) continue; const elDesc = `<${clipInfo.tag}${clipInfo.id ? ` id="${clipInfo.id}"` : ""} class="${clipInfo.classes}">`; findings.push({ code: "gsap_animates_clip_element", severity: "error", message: `GSAP animation sets ${conflictingProps.join(", ")} on a clip element. Selector "${sel}" resolves to element ${elDesc}. The framework manages clip visibility via ${conflictingProps.join("/")} — do not animate these properties on clip elements.`, selector: sel, elementId: clipInfo.id || undefined, fixHint: "Remove the visibility/display tween, or move the content into a child
and target that instead.", snippet: truncateSnippet(win.raw), }); } // unscoped_gsap_selector if (!localTimelineCompId || localTimelineCompId === rootCompositionId) continue; for (const win of gsapWindows) { if (!isSuspiciousGlobalSelector(win.targetSelector)) continue; const className = getSingleClassSelector(win.targetSelector); if (className && (classUsage.get(className) || 0) < 2) continue; findings.push({ code: "unscoped_gsap_selector", severity: "error", message: `Timeline "${localTimelineCompId}" uses unscoped selector "${win.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`, selector: win.targetSelector, fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${win.targetSelector}\` or use a unique id.`, snippet: truncateSnippet(win.raw), }); } } return findings; }, // gsap_css_transform_conflict // fallow-ignore-next-line complexity async ({ styles, scripts, tags }) => { const findings: HyperframeLintFinding[] = []; const cssTranslateSelectors = new Map(); const cssScaleSelectors = new Map(); // Check