mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user