From f3d21006633014fcb29b7a51571cd50ce832fed3 Mon Sep 17 00:00:00 2001 From: Xuanru Li <157947275+xuanruli@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:22:18 -0700 Subject: [PATCH] feat(lint): seek-order safety and SVG draw-on rules for GSAP timelines (#2611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Five lint rules (plus one extended core pattern) for GSAP defect classes that pass every existing check but break rendered output — the narrow, corpus-clean half of what was originally one PR (split per review; part 2 with the two catalog-touching rules stacks on top as #2612). - `gsap_repeat_refresh_relative_value` (error) — `repeatRefresh: true` + relative value re-captures and accumulates per iteration; a cold seek into iteration N skips the accumulation (verified with gsap 3.15.0: sequential 47.5 vs cold 17.5). - `gsap_function_value_hazard` (error/warning) — function values that call a method on the first parameter (GSAP passes `(index, target, targets)` — the first param is a number, so `(el) => el.getTotalLength()` throws and aborts the seeked frame) or measure the DOM: transform-sensitive reads (`getBoundingClientRect`, `getComputedStyle`, `gsap.getProperty`) are errors; transform-invariant layout reads (`offsetWidth`, `getBBox`, ...) are warnings. Pure-index arithmetic, `gsap.utils.wrap/distribute`, dataset/attribute reads, and closures over build-time constants are exempt. - `gsap_callback_dom_measurement` (warning) — DOM layout measurement reachable from `tl.add()`/`tl.call()`/`eventCallback`/`onStart|onUpdate|...` via a two-hop named-function scan. The capture path seeks with `suppressEvents: false`, so callbacks re-fire on every seek and measured geometry is seek-order-dependent. `gsap.getProperty`-driven derived output (scramble/typewriter patterns) is exempt. - `svg_measure_before_path_d` (error/warning) — `getTotalLength()` on a `` with no static `d`: error when no `d` assignment exists anywhere (returns 0 in Chrome, silently killing dash animations); warning when assignments exist only inside function bodies. Recognizes `setAttribute`, GSAP `attr: { d }`, and CSS `d: path()`. - `svg_drawon_css_dasharray_conflict` (error) — GSAP `strokeDasharray` on an element whose CSS declares a multi-component `stroke-dasharray`. GSAP merges per component, so `strokeDasharray: pathLength` computes to `"641.4px, 10px"` — the gap stays 10px, the hide-then-reveal hides only 10px, and the line stays visible all scene with a crawling notch. One of this repo's own producer fixtures has this exact bug (true positive from the corpus run). - `gsap.utils.random()` and `"random(...)"` string tween values added to `non_deterministic_code` (core) — each worker inits independently, so the same tween resolves different randoms across chunks. Both motivating production bugs (nodes teleporting at chunk boundaries; a draw-on line visible all scene) are minimally reproduced in the tests. ## Review hardening Two independent adversarial reviews ran before submission: a false-positive hunt over all 393 compositions in this repo plus 23 constructed adversarial snippets (with gsap 3.15.0 semantics experiments), and a maintainer-conventions pass. Fixed FP classes are locked in as negative tests: all-interpolation template ids, GSAP attr-plugin `d` writes, getProperty-driven callbacks, transform-invariant marquee reads. Corpus residue for these five rules: **1 error (a genuine dasharray bug in a producer fixture, happy to fix in a follow-up) and 2 warnings** (real layout reads in callbacks). ## Tests `packages/lint` green at this commit in isolation; `tsc`, oxlint, fallow audit clean. ## Notes for reviewers - All rules follow the file's conservative philosophy: anything not statically resolvable is skipped; false negatives over false positives. - Open question: should the cold-seek family gate on `HyperframeLinterOptions.distributed` (error when distributed, warning otherwise), following the `system_font_will_alias` precedent? Happy to wire either way. --- packages/lint/src/rules/core.test.ts | 66 +++ packages/lint/src/rules/core.ts | 11 + packages/lint/src/rules/gsap.test.ts | 508 +++++++++++++++++ packages/lint/src/rules/gsap.ts | 807 +++++++++++++++++++++++++-- 4 files changed, 1347 insertions(+), 45 deletions(-) diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index df2faeec1..057e4b482 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -922,6 +922,72 @@ body { // Date.now() is not used here window.__timelines["c1"] = gsap.timeline({ paused: true }); +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "non_deterministic_code"); + expect(finding).toBeUndefined(); + }); + + it("detects gsap.utils.random() in script content", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "non_deterministic_code"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("gsap.utils.random"); + }); + + it("detects GSAP 'random(...)' string tween values", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "non_deterministic_code"); + expect(finding).toBeDefined(); + expect(finding?.message).toContain('"random(...)"'); + }); + + it("detects prefixed '+=random(...)' string tween values", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "non_deterministic_code"); + expect(finding).toBeDefined(); + }); + + it("does NOT flag prose strings that merely mention random(", async () => { + const html = ` + +
+ `; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "non_deterministic_code"); diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index 16ae48896..1e0c80e34 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -488,6 +488,17 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ label: "crypto.getRandomValues()", hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders.", }, + { + pattern: /gsap\.utils\.random\s*\(/, + label: "gsap.utils.random()", + hint: "Each render worker initializes independently, so random values diverge across chunks. Use a seeded PRNG or fixed values.", + }, + { + // GSAP string form: "random(...)" / "+=random(...)" — re-rolls at tween init. + pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/, + label: '"random(...)" tween value', + hint: "GSAP random string values re-roll at tween init and each render worker initializes independently. Use fixed values or precompute with a seeded PRNG.", + }, ]; for (const script of scripts) { diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 9798776de..a73d0c4e8 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -1905,3 +1905,511 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); }); + +describe("GSAP seek-order safety rules", () => { + // ── gsap_repeat_refresh_relative_value ───────────────────────────────────── + + it("gsap_repeat_refresh_relative_value: flags repeatRefresh with a relative value in the same vars", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_repeat_refresh_relative_value"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("gsap_repeat_refresh_relative_value: does NOT flag relative values nested in callback vars", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_repeat_refresh_relative_value"); + expect(finding).toBeUndefined(); + }); + + it("gsap_repeat_refresh_relative_value: does NOT flag repeatRefresh without relative values", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_repeat_refresh_relative_value"); + expect(finding).toBeUndefined(); + }); + + // ── gsap_function_value_hazard ───────────────────────────────────────────── + + it("gsap_function_value_hazard: flags a function value that measures the DOM", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_function_value_hazard"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("gsap_function_value_hazard: flags a method call on the first (index) parameter", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_function_value_hazard"); + expect(finding).toBeDefined(); + }); + + it("gsap_function_value_hazard: WARNS (not errors) on transform-invariant layout reads", async () => { + // Marquee pattern: x: () => -track.offsetWidth is deterministic across + // workers while the measured layout is static — warning severity. + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_function_value_hazard"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("gsap_function_value_hazard: does NOT flag pure-index arithmetic, ternaries, wrap, or closures", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_function_value_hazard"); + expect(finding).toBeUndefined(); + }); + + // ── gsap_callback_dom_measurement ────────────────────────────────────────── + + it("gsap_callback_dom_measurement: flags a tl.add callback reaching measurement two hops away", async () => { + // Distilled from a production composition: tl.add(() => setupConnectors()) + // where setupConnectors measures via getCenter -> getBoundingClientRect. + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + // The callback site is reported in the structured selector field (the + // linter dedupes on code+selector+message). + expect(finding?.selector).toContain("setupConnectors"); + }); + + it("gsap_callback_dom_measurement: does NOT flag gsap.getProperty-driven derived-output callbacks", async () => { + // Scramble/typewriter pattern: onUpdate reads the animated value to derive + // text output — per-frame deterministic and seek-idempotent. + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement"); + expect(finding).toBeUndefined(); + }); + + it("gsap_callback_dom_measurement: flags onUpdate / eventCallback referencing a measuring function", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const findings = result.findings.filter((f) => f.code === "gsap_callback_dom_measurement"); + expect(findings.length).toBeGreaterThanOrEqual(2); + }); + + it("gsap_callback_dom_measurement: does NOT flag build-time measurement or non-measuring callbacks", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement"); + expect(finding).toBeUndefined(); + }); +}); + +describe("SVG draw-on rules", () => { + // ── svg_drawon_css_dasharray_conflict ────────────────────────────────────── + + it("svg_drawon_css_dasharray_conflict: flags the draw-on trick over CSS 'stroke-dasharray: 10 10'", async () => { + // Distilled from a production composition: script-created path gets the + // sync-line class, whose CSS declares a decorative two-component dash. + const html = ` + + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_drawon_css_dasharray_conflict"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("svg_drawon_css_dasharray_conflict: flags a quoted-selector write over an inline multi-component dash", async () => { + const html = ` + +
+ +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_drawon_css_dasharray_conflict"); + expect(finding).toBeDefined(); + }); + + it("svg_drawon_css_dasharray_conflict: does NOT flag a descendant-scoped CSS dasharray", async () => { + const html = ` + + +
+ +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_drawon_css_dasharray_conflict"); + expect(finding).toBeUndefined(); + }); + + it("svg_drawon_css_dasharray_conflict: does NOT flag a single-component CSS dasharray", async () => { + const html = ` + + +
+ +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_drawon_css_dasharray_conflict"); + expect(finding).toBeUndefined(); + }); + + it("svg_drawon_css_dasharray_conflict: does NOT flag a full two-component GSAP value (the fix form)", async () => { + const html = ` + + +
+ +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_drawon_css_dasharray_conflict"); + expect(finding).toBeUndefined(); + }); + + it("svg_drawon_css_dasharray_conflict: treats an all-interpolation template id as unresolved (multi-composition safe)", async () => { + // getElementById(\`\${name}\`) has no literal segment — it must NOT match + // every id in the document (here it would mis-join to the OTHER + // composition's dashed element). + const html = ` + + +
+ +
+
+ +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_drawon_css_dasharray_conflict"); + expect(finding).toBeUndefined(); + }); + + // ── svg_measure_before_path_d ────────────────────────────────────────────── + + it("svg_measure_before_path_d: ERROR when no d assignment exists anywhere", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("svg_measure_before_path_d: ERROR when only a different path has a d assignment", async () => { + const html = ` + +
+ +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("no d"); + }); + + it("svg_measure_before_path_d: WARNING when d is only assigned inside a function body", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("svg_measure_before_path_d: no finding for a static d attribute in the HTML", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d"); + expect(finding).toBeUndefined(); + }); + + it("svg_measure_before_path_d: no finding for top-level assign-then-measure", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d"); + expect(finding).toBeUndefined(); + }); + + it("svg_measure_before_path_d: no finding for a top-level GSAP attr-plugin d assignment before the measure", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d"); + expect(finding).toBeUndefined(); + }); + + it("svg_measure_before_path_d: no finding for createElementNS-built paths", async () => { + const html = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d"); + expect(finding).toBeUndefined(); + }); +}); diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 08a2d6990..a1b5057f5 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -11,6 +11,9 @@ interface LintParsedGsap { duration?: number; ease?: string; extras?: Record; + resolvedStart?: number; + /** True for an off-timeline `gsap.set(...)` (applied once at load). */ + global?: boolean; }>; timelineVar: string; } @@ -48,6 +51,8 @@ type GsapWindow = { fromPropertyValues?: Record; overwriteAuto: boolean; method: string; + /** True for an off-timeline `gsap.set(...)` (applied once at load). */ + global?: boolean; raw: string; }; @@ -142,23 +147,29 @@ async function extractGsapWindows(script: string): Promise { const windows: GsapWindow[] = []; for (const animation of parsed.animations) { - // Skip animations with string positions (e.g. "+=1", "<") — their absolute - // timing depends on runtime evaluation and can't be statically linted. - if (typeof animation.position !== "number") continue; + const start = + animation.resolvedStart ?? + (typeof animation.position === "number" ? animation.position : null); + if (start === null) continue; const repeat = extrasNumber(animation.extras?.repeat); - const cycleCount = repeat > 0 ? repeat + 1 : 1; + 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: animation.position, - end: animation.position + effectiveDuration, + 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", method: animation.method, + global: animation.global, raw: synthesizeWindowRaw(parsed.timelineVar, animation), }); } @@ -586,6 +597,382 @@ function scanScriptsForRegexMatches( 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. + +// 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; +} + +/** 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); +} + +// 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 @@ -1170,33 +1557,7 @@ export const gsapRules: LintRule[] = [ // fallow-ignore-next-line complexity async ({ styles, scripts, tags }) => { const findings: HyperframeLintFinding[] = []; - const cssOpacityZeroSelectors = new Set(); - - // Single owner of "this declaration list sets opacity to EXACTLY zero" — - // boundary-anchored so `opacity: 0.98` never matches. Works for both a CSS - // block body (brace already stripped by the block regex) and an inline - // style attribute: the declaration ends at `;` or at end of input, which - // also catches a final declaration without a trailing semicolon. - 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)) { - cssOpacityZeroSelectors.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"); - const classes = readAttr(tag.raw, "class")?.split(/\s+/).filter(Boolean) ?? []; - if (id) cssOpacityZeroSelectors.add(`#${id}`); - for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`); - } + const cssOpacityZeroSelectors = collectCssOpacityZeroSelectors(styles, tags); for (const script of scripts) { if (!/gsap\.timeline/.test(script.content)) continue; @@ -1286,18 +1647,7 @@ export const gsapRules: LintRule[] = [ layoutSubtreeRanges.some((r) => tag.index > r.start && tag.index < r.end); // Resolve a simple #id / .class token to the element tag(s) it matches. - 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); - } + const tagsByToken = indexTagsByToken(tags); // True only when the selector resolves to at least one element AND every resolved // element is html-in-canvas. Unresolvable selectors (no match) are NOT exempt — we @@ -1432,6 +1782,183 @@ export const gsapRules: LintRule[] = [ return findings; }, + // gsap_repeat_refresh_relative_value — repeatRefresh re-resolves the tween's values + // on every repeat iteration, so a relative value ACCUMULATES per cycle. A cold render + // worker seeking non-linearly into iteration N skips the accumulation a sequential + // playhead performed, so workers disagree on where the element is. + ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + for (const script of scripts) { + const source = stripJsComments(script.content); + const pattern = /repeatRefresh\s*:\s*true\b/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null) { + const objectLiteral = enclosingObjectLiteral(source, match.index); + if (!objectLiteral || !objectLiteralHasTopLevelRelativeValue(objectLiteral)) continue; + findings.push({ + code: "gsap_repeat_refresh_relative_value", + severity: "error", + message: + '`repeatRefresh: true` combined with a relative value ("+="/"-=") accumulates per repeat iteration. ' + + "A cold render worker seeking non-linearly into iteration N never performed the earlier iterations' " + + "accumulation, so its rendered position diverges from the sequential path.", + fixHint: + "Remove `repeatRefresh: true`, or replace the relative value with absolute endpoints (e.g. a fromTo()) " + + "so each iteration resolves to the same state on every seek path.", + snippet: truncateSnippet(objectLiteral), + }); + } + } + return findings; + }, + + // gsap_function_value_hazard — function-valued tween vars re-run at tween INIT, + // which is seek-order-dependent. A value reading transform-SENSITIVE geometry + // (getBoundingClientRect/getComputedStyle/gsap.getProperty) captures whatever state + // the worker's own seek order produced — error. Transform-INVARIANT layout reads + // (offsetWidth, getTotalLength, ...) are deterministic across cold render workers + // unless the measured layout itself animates — warning. GSAP function values receive + // (index, target, targets) — index is a NUMBER, so a method call on the first + // parameter (assuming it is the element) throws at init — error. Pure-index + // arithmetic, gsap.utils.wrap/distribute, and closures over constants are statically + // opaque or safe and are never flagged. + // + // Uses the raw parser output instead of the windows machinery: windows drop tweens + // with string positions ("+=0.5", labels), and position is irrelevant to whether a + // VALUE is hazardous. + async ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + const parseGsapScript = await loadParseGsapScript(); + for (const script of scripts) { + if (!/gsap\.timeline/.test(script.content)) continue; + const parsed = parseGsapScript(script.content); + for (const anim of parsed.animations) { + const raw = synthesizeWindowRaw(parsed.timelineVar, anim); + const entries = [ + ...Object.entries(anim.properties), + ...Object.entries(anim.fromProperties ?? {}), + ]; + for (const [prop, value] of entries) { + if (typeof value !== "string" || !value.startsWith("__raw:")) continue; + const fn = parseFunctionValueSource(value.slice(6)); + // Non-function raw values (gsap.utils.wrap(...), identifiers, arithmetic) + // are statically opaque — conservatively skipped. + if (!fn) continue; + const readsSensitive = TRANSFORM_SENSITIVE_READ.test(fn.body); + const readsInvariant = TRANSFORM_INVARIANT_READ.test(fn.body); + const badMember = firstParamMemberAccessHazard(fn); + if (!readsSensitive && !readsInvariant && !badMember) continue; + const reason = readsSensitive + ? "reads transform-sensitive geometry, so its result depends on the worker's own seek order" + : badMember + ? `accesses .${badMember} on its first parameter — GSAP function values receive (index, target, targets), ` + + "so the first parameter is a NUMBER and this throws at tween init" + : "measures layout at tween init, which is deterministic across cold render workers only while the measured layout never animates"; + findings.push({ + code: "gsap_function_value_hazard", + severity: readsSensitive || badMember ? "error" : "warning", + message: `Function-valued tween var for ${prop} on "${anim.targetSelector}" ${reason}. Each render worker initializes tweens independently.`, + selector: anim.targetSelector, + fixHint: badMember + ? "Use the SECOND parameter for the element: (index, target) => ... — or index arithmetic like (i) => i * 20." + : "Compute the value once at build time (before the timeline is registered) and pass a constant, or derive it from fixed composition coordinates.", + snippet: truncateSnippet(raw), + }); + } + } + } + return findings; + }, + + // gsap_callback_dom_measurement — DOM measurement reachable from timeline callbacks + // (tl.add(fn) / tl.call(fn) / eventCallback / onStart-style vars). The capture path + // seeks with suppressEvents=false (core/src/adapters/gsap.ts), so callbacks re-fire + // on EVERY seek, including rewinds — and a cold render worker executes them against + // whatever DOM state its own non-linear seek order produced. Geometry measured + // inside a callback is therefore seek-order-dependent, and anything measured before + // the callback ran (e.g. a build-time getTotalLength() on a path whose `d` the + // callback assigns) is stale or zero. Warning, not error: gsap.getProperty-style + // derived-output callbacks were excluded, but the remaining reads can still be + // legitimate when the measured layout is static. + ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + for (const script of scripts) { + const source = stripJsComments(script.content); + if (!/gsap\.timeline/.test(source)) continue; + const bodies = collectNamedFunctionBodies(source); + const measuring = collectMeasuringFunctionNames(bodies); + + // A callback argument is hazardous when it is an inline function whose body + // reaches a measurement, or a bare reference to a measuring function. Call + // expressions (`tl.add(build())`) execute at BUILD time, not as callbacks — + // conservatively skipped. + const callbackExpressionHazard = (expression: string): boolean => { + const trimmed = expression.trim(); + const inline = parseFunctionValueSource(trimmed); + if (inline) return expressionReachesMeasurement(inline.body, measuring); + if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return measuring.has(trimmed); + return false; + }; + // The callback site goes into the structured `selector` field: the linter + // dedupes on code+selector+message, and a constant message would collapse + // distinct callback sites into a single finding. + const report = (site: string, snippet: string): void => { + findings.push({ + code: "gsap_callback_dom_measurement", + severity: "warning", + message: + "Timeline callback reaches DOM measurement (getBoundingClientRect/getTotalLength/getComputedStyle/...). " + + "The renderer seeks with suppressEvents=false, so callbacks re-fire on every seek — and a cold render " + + "worker runs them against whatever DOM state its own non-linear seek order produced. Measured geometry is " + + "seek-order-dependent, and values measured at build time (before the callback ran) are stale or zero.", + selector: truncateSnippet(site, 120), + fixHint: + "Do all measurement and DOM setup synchronously at build time, before registering the timeline — " + + "or derive geometry from fixed composition coordinates instead of measuring.", + snippet: truncateSnippet(snippet), + }); + }; + + const timelineVars = collectTimelineVarNames(source); + for (const timelineVar of timelineVars) { + const callPattern = new RegExp( + `\\b${escapeRegExp(timelineVar)}\\.(?:add|call)\\s*\\(`, + "g", + ); + let match: RegExpExecArray | null; + while ((match = callPattern.exec(source)) !== null) { + const parenIndex = match.index + match[0].length - 1; + const argsWithParens = matchBalanced(source, parenIndex, "(", ")"); + if (!argsWithParens) continue; + const firstArg = sliceExpression(argsWithParens.slice(1, -1), 0); + const site = match[0] + firstArg + ", ...)"; + if (callbackExpressionHazard(firstArg)) report(site, site); + } + + const eventCallbackPattern = new RegExp( + `\\b${escapeRegExp(timelineVar)}\\.eventCallback\\s*\\(\\s*["']on[A-Za-z]+["']\\s*,`, + "g", + ); + while ((match = eventCallbackPattern.exec(source)) !== null) { + const expression = sliceExpression(source, eventCallbackPattern.lastIndex); + const site = match[0] + expression + ")"; + if (callbackExpressionHazard(expression)) report(site, site); + } + } + + const varsCallbackPattern = + /\bon(?:Start|Update|Complete|Repeat|ReverseComplete|Interrupt|Overwrite)\s*:\s*/g; + let match: RegExpExecArray | null; + while ((match = varsCallbackPattern.exec(source)) !== null) { + if (!isInsideGsapTweenVars(source, match.index, timelineVars)) continue; + const expression = sliceExpression(source, varsCallbackPattern.lastIndex); + const site = match[0] + expression; + if (callbackExpressionHazard(expression)) report(site, site); + } + } + return findings; + }, + // gsap_group_selector_keyframes ({ scripts }) => { const findings: HyperframeLintFinding[] = []; @@ -1458,4 +1985,194 @@ export const gsapRules: LintRule[] = [ } return findings; }, + + // svg_drawon_css_dasharray_conflict — GSAP sets/tweens strokeDasharray on an element + // whose CSS declares a MULTI-component stroke-dasharray (e.g. `10 10`). GSAP merges + // dash lists per component, so `strokeDasharray: 641.4` over CSS `10 10` computes to + // "641.4px, 10px" — the gap stays 10px and the hide-then-draw-on trick silently + // fails: the line is visible the whole scene. A static two-component GSAP value is + // the explicit fix form and is not flagged. + // fallow-ignore-next-line complexity + ({ scripts, styles, tags }) => { + const findings: HyperframeLintFinding[] = []; + const tagsByToken = indexTagsByToken(tags); + + const multiDashTokens = new Set(); + for (const style of styles) { + for (const [, selectorList, body] of style.content.matchAll(/([^{}]+)\{([^}]+)\}/g)) { + if (!selectorList || !body) continue; + const value = readStyleProperty(body, "stroke-dasharray"); + if (!value || !isMultiComponentDasharray(value)) continue; + // Skip combinator groups — scope-dependent, unsafe to correlate by leaf token. + for (const group of selectorList.split(",")) { + const trimmed = group.trim(); + if (!trimmed || /[\s>+~]/.test(trimmed)) continue; + for (const token of targetedSelectorTokens(trimmed)) multiDashTokens.add(token); + } + } + } + for (const tag of tags) { + const inlineValue = readStyleProperty(readAttr(tag.raw, "style") ?? "", "stroke-dasharray"); + if (!inlineValue || !isMultiComponentDasharray(inlineValue)) continue; + for (const token of tagSimpleSelectors(tag)) multiDashTokens.add(token); + } + if (multiDashTokens.size === 0) return findings; + + for (const script of scripts) { + const source = stripJsComments(script.content); + const varTokens = resolveScriptElementTokens(source, tags); + const reported = new Set(); + + const writerPattern = + /\b[\w$]+\.(set|to|fromTo)\s*\(\s*(?:(["'])([^"'`]+)\2|([A-Za-z_$][\w$]*))\s*,\s*\{/g; + let match: RegExpExecArray | null; + while ((match = writerPattern.exec(source)) !== null) { + const method = match[1] ?? ""; + const braceIndex = match.index + match[0].length - 1; + const firstVars = matchBalanced(source, braceIndex, "{", "}"); + if (!firstVars) continue; + const varsObjects = [firstVars]; + if (method === "fromTo") { + const afterFirst = source.slice(braceIndex + firstVars.length); + const secondOpen = /^\s*,\s*\{/.exec(afterFirst); + if (secondOpen) { + const secondBrace = braceIndex + firstVars.length + secondOpen[0].length - 1; + const secondVars = matchBalanced(source, secondBrace, "{", "}"); + if (secondVars) varsObjects.push(secondVars); + } + } + + const quotedSelector = match[3]; + const targetTokens = quotedSelector + ? targetedSelectorTokens(quotedSelector) + : (varTokens.get(match[4] ?? "") ?? new Set()); + if (targetTokens.size === 0) continue; + const expanded = elementLevelTokens(targetTokens, tagsByToken); + + for (const varsObject of varsObjects) { + const propMatch = + varsObject.match(/\bstrokeDasharray\s*:\s*/) ?? + varsObject.match(/["']stroke-dasharray["']\s*:\s*/); + if (!propMatch || propMatch.index === undefined) continue; + const valueSource = sliceExpression(varsObject, propMatch.index + propMatch[0].length); + if (gsapDasharrayValueLooksMultiComponent(valueSource)) continue; + const conflictToken = [...expanded].find((token) => multiDashTokens.has(token)); + if (!conflictToken) continue; + const targetLabel = quotedSelector ?? match[4] ?? ""; + if (reported.has(targetLabel + conflictToken)) continue; + reported.add(targetLabel + conflictToken); + findings.push({ + code: "svg_drawon_css_dasharray_conflict", + severity: "error", + message: + `GSAP writes strokeDasharray on "${targetLabel}", but its CSS ("${conflictToken}") declares a multi-component ` + + 'stroke-dasharray. GSAP merges dash lists per component, so the CSS gap survives (e.g. "641.4px, 10px") — ' + + "the draw-on hide only hides one gap's worth and the line stays visible the whole scene.", + selector: quotedSelector ?? undefined, + fixHint: + `Remove the CSS stroke-dasharray from "${conflictToken}" (decorative dashes belong on a separate element), ` + + 'or set the full two-component value in GSAP: strokeDasharray: "${len} ${len}".', + snippet: truncateSnippet(match[0] + firstVars.slice(1)), + }); + } + } + } + return findings; + }, + + // svg_measure_before_path_d — getTotalLength() on a that has no static `d` + // attribute in the HTML. In Chrome getTotalLength() on a d-less path returns 0, + // silently killing dash animations (offset 0 == length 0 == nothing to draw). If a + // d assignment exists but only inside a function body, execution order is statically + // undecidable — WARNING; if NO d assignment exists anywhere — ERROR. Element + // identity is resolved conservatively (literal / template getElementById, + // querySelector); createElementNS-built paths and unresolved variables are skipped. + // fallow-ignore-next-line complexity + ({ scripts, styles, tags }) => { + const findings: HyperframeLintFinding[] = []; + const tagsByToken = indexTagsByToken(tags); + // CSS `d: path(...)` supplies geometry statically — treat like a static attribute. + const cssProvidesD = styles.some((style) => /\bd\s*:\s*path\(/.test(style.content)); + + for (const script of scripts) { + const source = stripJsComments(script.content); + const varTokens = resolveScriptElementTokens(source, tags); + const functionRanges = collectFunctionBodyRanges(source); + const createdVars = new Set( + [...source.matchAll(/([A-Za-z_$][\w$]*)\s*=\s*document\.createElementNS\(/g)].map( + (m) => m[1] ?? "", + ), + ); + // `d` assignments come in two forms: direct setAttribute('d', ...) and the + // GSAP attr plugin (`gsap.set(wire, { attr: { d: "..." } })`). Both count, + // with the same lexical-order semantics. + const dAssignments = [ + ...[...source.matchAll(/\b([A-Za-z_$][\w$]*)\.setAttribute\(\s*["']d["']\s*,/g)].map( + (m) => ({ varName: m[1] ?? "", index: m.index ?? 0 }), + ), + ...[ + ...source.matchAll( + /\.(?:set|to|fromTo)\s*\(\s*([A-Za-z_$][\w$]*)\s*,\s*\{[^{}]*\battr\s*:\s*\{[^{}]*\bd\s*:/g, + ), + ].map((m) => ({ varName: m[1] ?? "", index: m.index ?? 0 })), + ]; + const reported = new Set(); + + const measurePattern = /\b([A-Za-z_$][\w$]*)\.getTotalLength\s*\(/g; + let match: RegExpExecArray | null; + while ((match = measurePattern.exec(source)) !== null) { + const varName = match[1] ?? ""; + if (createdVars.has(varName)) continue; + const tokens = varTokens.get(varName); + if (!tokens || tokens.size === 0) continue; + // Only elements without a static d attribute qualify. + const resolvedTags = [...tokens].flatMap((token) => tagsByToken.get(token) ?? []); + const dLessPaths = resolvedTags.filter( + (tag) => tag.name.toLowerCase() === "path" && readAttr(tag.raw, "d") === null, + ); + if (dLessPaths.length === 0 || dLessPaths.length !== resolvedTags.length) continue; + if (cssProvidesD) continue; + + // A same-variable d assignment lexically before the measure, in scope of the + // measure (top-level, or a function body containing the measure), is the + // legitimate synchronous assign-then-measure pattern. + const measureIndex = match.index; + const assignedBeforeInScope = dAssignments.some( + (assign) => + assign.varName === varName && + assign.index < measureIndex && + (!indexInsideAnyRange(assign.index, functionRanges) || + functionRanges.some( + (range) => + assign.index > range.start && + assign.index < range.end && + measureIndex > range.start && + measureIndex < range.end, + )), + ); + if (assignedBeforeInScope) continue; + + const sameVarAssignmentExists = dAssignments.some((a) => a.varName === varName); + const tokenLabel = [...tokens].join(", "); + if (reported.has(tokenLabel)) continue; + reported.add(tokenLabel); + findings.push({ + code: "svg_measure_before_path_d", + severity: sameVarAssignmentExists ? "warning" : "error", + message: sameVarAssignmentExists + ? `getTotalLength() is called on "${tokenLabel}", whose \`d\` is only assigned inside a function body — ` + + "if the measure runs before that function (e.g. the function is a timeline callback), the length is 0 " + + "and the dash animation is dead." + : `getTotalLength() is called on "${tokenLabel}", but the path has no static \`d\` attribute and no d ` + + "assignment exists anywhere — getTotalLength() returns 0 in Chrome, silently killing dash animations.", + selector: tokenLabel, + fixHint: + "Assign the path's `d` synchronously at build time (top level, before measuring), or author a static " + + "d attribute in the HTML.", + snippet: truncateSnippet(match[0] + ")"), + }); + } + } + return findings; + }, ];