From 4bb01fd1b66fbdb7710eec7c152f15f98abff769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 2 Apr 2026 23:09:50 +0200 Subject: [PATCH] feat(lint): add rules for non-deterministic code, missing clip class, overlapping tracks, and rAF detection (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds 4 new lint rules found missing during comprehensive E2E testing: - **`non_deterministic_code`** (error) — detects `Math.random()`, `Date.now()`, `new Date()`, `performance.now()`, `crypto.getRandomValues()` in scripts. Confirmed: two renders with Math.random() produced different checksums. - **`timed_element_missing_clip_class`** (warning) — flags elements with `data-start`/`data-duration` but no `class="clip"`. Without it, elements are visible forever instead of only during their time window. - **`overlapping_clips_same_track`** (error) — detects clips on the same `data-track-index` with overlapping time ranges. - **`requestanimationframe_in_composition`** (warning) — warns that rAF-based animations don't sync with frame capture. Discovered when Vivus.js (rAF-based) produced incorrect output. 12 new tests, all passing. **Part 1 of 5** in a stacked PR series fixing E2E test findings. ## Test plan - [x] 12 new tests (3 per rule: positive, negative, edge case) - [x] Full suite: 56 pass in rule tests - [x] `npx tsx scripts/lint-skills.ts` — no issues --- .../core/src/lint/rules/composition.test.ts | 143 ++++++++++++++++++ packages/core/src/lint/rules/composition.ts | 105 +++++++++++++ packages/core/src/lint/rules/core.test.ts | 52 +++++++ packages/core/src/lint/rules/core.ts | 49 ++++++ 4 files changed, 349 insertions(+) diff --git a/packages/core/src/lint/rules/composition.test.ts b/packages/core/src/lint/rules/composition.test.ts index b1c09861f..34c0fffcd 100644 --- a/packages/core/src/lint/rules/composition.test.ts +++ b/packages/core/src/lint/rules/composition.test.ts @@ -94,4 +94,147 @@ describe("composition rules", () => { const finding = result.findings.find((f) => f.code === "template_literal_selector"); expect(finding).toBeUndefined(); }); + + describe("timed_element_missing_clip_class", () => { + it("flags element with data-start but no class='clip'", () => { + const html = ` + +
+
Hello
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("does not flag element that has class='clip'", () => { + const html = ` + +
+
Hello
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class"); + expect(finding).toBeUndefined(); + }); + + it("does not flag audio or video elements", () => { + const html = ` + +
+ + +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class"); + expect(finding).toBeUndefined(); + }); + }); + + describe("overlapping_clips_same_track", () => { + it("flags overlapping clips on the same track", () => { + const html = ` + +
+
A
+
B
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("does not flag clips on different tracks", () => { + const html = ` + +
+
A
+
B
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track"); + expect(finding).toBeUndefined(); + }); + + it("does not flag sequential clips on the same track", () => { + const html = ` + +
+
A
+
B
+
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track"); + expect(finding).toBeUndefined(); + }); + }); + + describe("requestanimationframe_in_composition", () => { + it("flags requestAnimationFrame usage in script content", () => { + const html = ` + +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "requestanimationframe_in_composition", + ); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("does not flag requestAnimationFrame in comments", () => { + const html = ` + +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "requestanimationframe_in_composition", + ); + expect(finding).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts index cbfa2b310..702a081a4 100644 --- a/packages/core/src/lint/rules/composition.ts +++ b/packages/core/src/lint/rules/composition.ts @@ -106,4 +106,109 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding } return findings; }, + + // timed_element_missing_clip_class + ({ tags }) => { + const findings: HyperframeLintFinding[] = []; + const skipTags = new Set(["audio", "video", "script", "style", "template"]); + for (const tag of tags) { + if (skipTags.has(tag.name)) continue; + // Skip composition hosts + if (readAttr(tag.raw, "data-composition-id")) continue; + if (readAttr(tag.raw, "data-composition-src")) continue; + + const hasStart = readAttr(tag.raw, "data-start") !== null; + const hasDuration = readAttr(tag.raw, "data-duration") !== null; + const hasTrackIndex = readAttr(tag.raw, "data-track-index") !== null; + if (!hasStart && !hasDuration && !hasTrackIndex) continue; + + const classAttr = readAttr(tag.raw, "class") || ""; + const hasClip = classAttr.split(/\s+/).includes("clip"); + if (hasClip) continue; + + const elementId = readAttr(tag.raw, "id") || undefined; + findings.push({ + code: "timed_element_missing_clip_class", + severity: "warning", + message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`, + elementId, + fixHint: + 'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.', + snippet: truncateSnippet(tag.raw), + }); + } + return findings; + }, + + // overlapping_clips_same_track + ({ tags }) => { + const findings: HyperframeLintFinding[] = []; + + type ClipInfo = { start: number; end: number; elementId?: string; snippet: string }; + const trackMap = new Map(); + + for (const tag of tags) { + const startStr = readAttr(tag.raw, "data-start"); + const durationStr = readAttr(tag.raw, "data-duration"); + const trackStr = readAttr(tag.raw, "data-track-index"); + if (!startStr || !durationStr || !trackStr) continue; + + const start = Number(startStr); + const duration = Number(durationStr); + const track = trackStr; + + // Skip non-numeric (relative timing references like "intro-comp") + if (Number.isNaN(start) || Number.isNaN(duration)) continue; + + const clips = trackMap.get(track) || []; + clips.push({ + start, + end: start + duration, + elementId: readAttr(tag.raw, "id") || undefined, + snippet: truncateSnippet(tag.raw) || "", + }); + trackMap.set(track, clips); + } + + for (const [track, clips] of trackMap) { + clips.sort((a, b) => a.start - b.start); + for (let i = 0; i < clips.length - 1; i++) { + const current = clips[i]; + const next = clips[i + 1]; + if (!current || !next) continue; + if (current.end > next.start) { + findings.push({ + code: "overlapping_clips_same_track", + severity: "error", + message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`, + fixHint: + "Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.", + }); + } + } + } + + return findings; + }, + + // requestanimationframe_in_composition + ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + for (const script of scripts) { + // Strip comments to avoid false positives + const stripped = script.content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); + if (/requestAnimationFrame\s*\(/.test(stripped)) { + findings.push({ + code: "requestanimationframe_in_composition", + severity: "warning", + message: + "`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.", + fixHint: + "Use GSAP tweens or onUpdate callbacks instead of requestAnimationFrame for animation logic.", + snippet: truncateSnippet(script.content), + }); + } + } + return findings; + }, ]; diff --git a/packages/core/src/lint/rules/core.test.ts b/packages/core/src/lint/rules/core.test.ts index 7f0cb9899..6c0d0f26a 100644 --- a/packages/core/src/lint/rules/core.test.ts +++ b/packages/core/src/lint/rules/core.test.ts @@ -91,4 +91,56 @@ describe("core rules", () => { const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init"); expect(finding).toBeUndefined(); }); + + describe("non_deterministic_code", () => { + it("detects Math.random() in script content", () => { + const html = ` + +
+ +`; + const result = 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("Math.random"); + }); + + it("detects Date.now() in script content", () => { + const html = ` + +
+ +`; + const result = 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("Date.now"); + }); + + it("does not flag non-deterministic calls inside single-line comments", () => { + const html = ` + +
+ +`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "non_deterministic_code"); + expect(finding).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index 4c7d9510f..dd5a2300c 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -166,4 +166,53 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ } return findings; }, + + // non_deterministic_code + ({ scripts }) => { + const findings: HyperframeLintFinding[] = []; + const patterns: Array<{ pattern: RegExp; label: string; hint: string }> = [ + { + pattern: /Math\.random\s*\(/, + label: "Math.random()", + hint: "Use a seeded PRNG (e.g. a simple mulberry32) so renders are deterministic across frames.", + }, + { + pattern: /Date\.now\s*\(/, + label: "Date.now()", + hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.", + }, + { + pattern: /new\s+Date\s*\(/, + label: "new Date()", + hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.", + }, + { + pattern: /performance\.now\s*\(/, + label: "performance.now()", + hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.", + }, + { + pattern: /crypto\.getRandomValues\s*\(/, + label: "crypto.getRandomValues()", + hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders.", + }, + ]; + + for (const script of scripts) { + // Strip comments to avoid false positives + const stripped = script.content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); + for (const { pattern, label, hint } of patterns) { + if (pattern.test(stripped)) { + findings.push({ + code: "non_deterministic_code", + severity: "error", + message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`, + fixHint: hint, + snippet: truncateSnippet(script.content), + }); + } + } + } + return findings; + }, ];