From 0d3323838121a3845465c8e372017467156dd6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 7 Apr 2026 16:53:50 +0200 Subject: [PATCH] fix(core): add lint rule for infinite GSAP repeat (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a new lint rule `gsap_infinite_repeat` that flags `repeat: -1` in GSAP timelines as an error. This is a hard enforcement of the skill guardrail added in PR #217. ## What it fixes The deterministic capture engine (`HeadlessExperimental.beginFrame`) seeks to exact frame times on a paused GSAP timeline. When a timeline contains `repeat: -1`, the timeline duration is infinite, which causes the capture engine to produce incorrect/blurry output. **Eval prompt #20** (loading-spinner, scored 2.0/5) used `repeat: -1` on a dots animation cycle, producing "a highly compressed and blurry loading animation lacking visual clarity and professional polish." ## Changes - `packages/core/src/lint/rules/gsap.ts` — new `gsap_infinite_repeat` rule (regex scan for `repeat: -1`) - `packages/core/src/lint/rules/gsap.test.ts` — 2 new tests (detects infinite repeat, allows finite repeat) ## Test plan - [x] `pnpm --filter @hyperframes/core test` — all 429 tests pass - [x] Rule catches `repeat: -1` and reports as error with fix hint - [x] Rule does not flag `repeat: 4` (finite repeats) --- packages/core/src/lint/rules/gsap.test.ts | 53 +++++++++++++++++++++++ packages/core/src/lint/rules/gsap.ts | 28 ++++++++++++ 2 files changed, 81 insertions(+) diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts index acce70fb7..f7680b026 100644 --- a/packages/core/src/lint/rules/gsap.test.ts +++ b/packages/core/src/lint/rules/gsap.test.ts @@ -411,4 +411,57 @@ describe("GSAP rules", () => { expect(finding).toBeDefined(); expect(finding?.severity).toBe("error"); }); + + it("errors on repeat: -1 (infinite repeat breaks capture engine)", () => { + const html = ` + +
+ + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + expect(finding?.message).toContain("repeat: -1"); + }); + + it("does not error on finite repeat values", () => { + const html = ` + +
+ + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat"); + expect(finding).toBeUndefined(); + }); + + it("does not false-positive on repeat: -10 (invalid GSAP but not infinite)", () => { + const html = ` + +
+ + +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "gsap_infinite_repeat"); + expect(finding).toBeUndefined(); + }); }); diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts index a4dff2829..db9f3b671 100644 --- a/packages/core/src/lint/rules/gsap.ts +++ b/packages/core/src/lint/rules/gsap.ts @@ -482,6 +482,34 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ return findings; }, + // gsap_infinite_repeat + ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + for (const script of scripts) { + const content = script.content; + // Match repeat: -1 in GSAP tweens or timeline configs + const pattern = /repeat\s*:\s*-1(?!\d)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(content)) !== null) { + const contextStart = Math.max(0, match.index - 60); + const contextEnd = Math.min(content.length, match.index + match[0].length + 60); + const snippet = content.slice(contextStart, contextEnd).trim(); + findings.push({ + code: "gsap_infinite_repeat", + severity: "error", + message: + "GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " + + "capture engine which seeks to exact frame times. Use a finite repeat count calculated " + + "from the composition duration: `repeat: Math.ceil(duration / cycleDuration) - 1`.", + fixHint: + "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.ceil(totalDuration / singleCycleDuration) - 1`.", + snippet: truncateSnippet(snippet), + }); + } + } + return findings; + }, + // scene_layer_missing_visibility_kill ({ scripts, tags }) => { const findings: HyperframeLintFinding[] = [];