feat(lint): seek-order safety and SVG draw-on rules for GSAP timelines (#2611)

## 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 `<path>` 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.
This commit is contained in:
Xuanru Li
2026-07-16 23:22:18 -07:00
committed by GitHub
parent 0561adc11c
commit f3d2100663
4 changed files with 1347 additions and 45 deletions
+66
View File
@@ -922,6 +922,72 @@ body {
// Date.now() is not used here
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".chip", { x: gsap.utils.random(-100, 100), duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".chip", { x: "random(-100, 100)", duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".chip", { x: "+=random(-10, 10)", duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const note = "avoid random(seed) helpers in render code";
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
+11
View File
@@ -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) {
+508
View File
@@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to('#a', { x: "+=100", duration: 1, repeat: 4, repeatRefresh: true }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="el"></div><div id="other"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to('#el', {
x: 100,
repeatRefresh: true,
onComplete: () => gsap.to('#other', { y: "-=15" }),
}, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to('#a', { x: 100, duration: 1, repeat: 4, repeatRefresh: true }, 0);
tl.to('#a', { y: "+=10", duration: 1 }, 6);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div class="chip"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to('.chip', { x: (i, target) => target.getBoundingClientRect().width / 2, duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div class="chip"></div></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to('.chip', { x: (el) => el.getAttribute("data-x"), duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="track"></div></div>
<script>
window.__timelines = window.__timelines || {};
const track = document.getElementById('track');
const tl = gsap.timeline({ paused: true });
tl.to('#track', { x: () => -track.offsetWidth, duration: 8, ease: "none" }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div class="chip"></div></div>
<script>
window.__timelines = window.__timelines || {};
const baseOffset = 40;
const tl = gsap.timeline({ paused: true });
tl.to('.chip', { x: (i) => i * 20, duration: 1 }, 0);
tl.to('.chip', { y: (i) => (i % 2 === 0 ? -50 : 50), duration: 1 }, 0);
tl.to('.chip', { rotation: gsap.utils.wrap([-10, 10]), duration: 1 }, 1);
tl.to('.chip', { scale: (i) => baseOffset + i * 0.1, duration: 1 }, 2);
tl.to('.chip', { opacity: (i) => Number(i.toFixed(2)), duration: 1 }, 3);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><svg><path id="p1"/></svg></div>
<script>
window.__timelines = window.__timelines || {};
function getCenter(el) {
const rect = el.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function setupConnectors() {
const orb = document.querySelector('#p1');
const center = getCenter(orb);
const length = orb.getTotalLength();
gsap.set(orb, { strokeDashoffset: length });
}
const tl = gsap.timeline({ paused: true });
tl.add(() => setupConnectors(), 2.5);
tl.to('#p1', { strokeDashoffset: 0, duration: 1 }, 3);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="counter"></div></div>
<script>
window.__timelines = window.__timelines || {};
const state = { n: 0 };
const el = document.getElementById('counter');
const tl = gsap.timeline({ paused: true });
tl.to(state, { n: 100, duration: 2, onUpdate: () => { el.textContent = String(Math.round(gsap.getProperty(state, "n"))); } }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><div id="a"></div></div>
<script>
window.__timelines = window.__timelines || {};
const measureNow = () => document.getElementById('a').offsetWidth;
const tl = gsap.timeline({ paused: true });
tl.to('#a', { x: 100, duration: 1, onUpdate: measureNow }, 0);
tl.eventCallback("onComplete", () => { const h = document.getElementById('a').clientHeight; });
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><svg><path id="p1" d="M 0 0 L 100 100"/></svg></div>
<script>
window.__timelines = window.__timelines || {};
const path = document.getElementById('p1');
const length = path.getTotalLength();
const tl = gsap.timeline({ paused: true });
tl.set('#p1', { strokeDasharray: length + " " + length, strokeDashoffset: length }, 0);
tl.add("chapter-two", 2);
tl.to('#p1', { strokeDashoffset: 0, duration: 1, onComplete: () => console.log("done") }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<style>
.sync-line { stroke: rgba(0, 163, 255, 0.3); stroke-width: 2; fill: none; stroke-dasharray: 10 10; }
</style>
<div data-composition-id="c1" data-width="1080" data-height="1920"><svg id="svg-overlay"></svg></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const svg = document.getElementById('svg-overlay');
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("class", "sync-line");
path.setAttribute("d", "M 210 410 L 540 960");
svg.appendChild(path);
const pathLength = path.getTotalLength();
tl.set(path, { strokeDasharray: pathLength, strokeDashoffset: pathLength }, 0);
tl.to(path, { strokeDashoffset: 0, duration: 1.2, ease: "power1.inOut" }, 1);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<svg><path id="wire" style="stroke-dasharray: 8 8" d="M 0 0 L 100 100"/></svg>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set('#wire', { strokeDasharray: 141.4, strokeDashoffset: 141.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<style>.decorative-frame .wire { stroke-dasharray: 10 10; }</style>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<svg><path id="line" class="wire" d="M 0 0 L 100 0"/></svg>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set('#line', { strokeDasharray: 100 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<style>.wire { stroke-dasharray: 12; }</style>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<svg><path id="wire" class="wire" d="M 0 0 L 100 100"/></svg>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set('#wire', { strokeDasharray: 141.4, strokeDashoffset: 141.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<style>.wire { stroke-dasharray: 10 10; }</style>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<svg><path id="wire" class="wire" d="M 0 0 L 100 100"/></svg>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const len = 141.4;
tl.set('#wire', { strokeDasharray: \`\${len} \${len}\`, strokeDashoffset: len }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<style>.wire-b { stroke-dasharray: 10 10; }</style>
<div data-composition-id="a" data-width="1920" data-height="1080">
<svg><path id="line-a" d="M 0 0 L 100 100"/></svg>
</div>
<div data-composition-id="b" data-width="1920" data-height="1080">
<svg><path id="line-b" class="wire-b" d="M 0 0 L 100 100"/></svg>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const name = "line-a";
const target = document.getElementById(\`\${name}\`);
tl.set(target, { strokeDasharray: 141.4, strokeDashoffset: 141.4 }, 0);
window.__timelines["a"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><svg><path id="wave" class="line"/></svg></div>
<script>
window.__timelines = window.__timelines || {};
const wave = document.getElementById('wave');
const length = wave.getTotalLength();
const tl = gsap.timeline({ paused: true });
tl.set('#wave', { strokeDashoffset: length }, 1);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<svg><path id="alpha"/><path id="beta"/></svg>
</div>
<script>
window.__timelines = window.__timelines || {};
const alpha = document.getElementById('alpha');
const beta = document.getElementById('beta');
beta.setAttribute('d', 'M0 0 L10 10');
const length = alpha.getTotalLength();
const tl = gsap.timeline({ paused: true });
tl.set('#alpha', { strokeDashoffset: length }, 1);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><svg><path id="wave"/></svg></div>
<script>
window.__timelines = window.__timelines || {};
const wave = document.getElementById('wave');
function setupPath() {
wave.setAttribute('d', 'M 0 0 L 500 500');
}
const tl = gsap.timeline({ paused: true });
tl.add(setupPath, 2);
const length = wave.getTotalLength();
tl.to('#wave', { strokeDashoffset: 0, duration: 1 }, 3);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><svg><path id="wave" d="M 0 0 L 500 500"/></svg></div>
<script>
window.__timelines = window.__timelines || {};
const wave = document.getElementById('wave');
const length = wave.getTotalLength();
const tl = gsap.timeline({ paused: true });
tl.set('#wave', { strokeDashoffset: length }, 1);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><svg><path id="wave"/></svg></div>
<script>
window.__timelines = window.__timelines || {};
const wave = document.getElementById('wave');
wave.setAttribute('d', 'M 0 0 L 500 500');
const length = wave.getTotalLength();
const tl = gsap.timeline({ paused: true });
tl.set('#wave', { strokeDashoffset: length }, 1);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><svg><path id="wave"/></svg></div>
<script>
window.__timelines = window.__timelines || {};
const wave = document.getElementById('wave');
gsap.set(wave, { attr: { d: "M 0 0 L 100 100" } });
const length = wave.getTotalLength();
const tl = gsap.timeline({ paused: true });
tl.set('#wave', { strokeDashoffset: length }, 1);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><svg id="overlay"></svg></div>
<script>
window.__timelines = window.__timelines || {};
const overlay = document.getElementById('overlay');
const line = document.createElementNS("http://www.w3.org/2000/svg", "path");
line.setAttribute("d", "M 0 0 L 100 100");
overlay.appendChild(line);
const length = line.getTotalLength();
const tl = gsap.timeline({ paused: true });
tl.set(line, { strokeDashoffset: length }, 1);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d");
expect(finding).toBeUndefined();
});
});
+762 -45
View File
@@ -11,6 +11,9 @@ interface LintParsedGsap {
duration?: number;
ease?: string;
extras?: Record<string, unknown>;
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<string, string | number>;
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<GsapWindow[]> {
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<string, OpenTag[]> {
const tagsByToken = new Map<string, OpenTag[]>();
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<string, string> {
const bodies = new Map<string, string>();
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<string, string>): Set<string> {
const measuring = new Set<string>();
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<string>): 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<string, Set<string>> {
const documentIds = tags.map((tag) => readAttr(tag.raw, "id")).filter((id) => id !== null);
const tokensByVar = new Map<string, Set<string>>();
const add = (name: string, token: string): void => {
const tokens = tokensByVar.get(name) ?? new Set<string>();
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<string>,
tagsByToken: Map<string, OpenTag[]>,
): Set<string> {
const expanded = new Set<string>(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<string> {
const selectors = new Set<string>();
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<LintContext>[] = [
// fallow-ignore-next-line complexity
async ({ styles, scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
const cssOpacityZeroSelectors = new Set<string>();
// 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<LintContext>[] = [
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<string, OpenTag[]>();
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<LintContext>[] = [
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<LintContext>[] = [
}
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<string>();
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<string>();
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<string>());
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 <path> 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<string>();
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 <path> 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;
},
];