mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
fix(lint): break two fix-loops and drop two rules the runtime owns (#3400)
An eval of time-to-finished-video against Remotion found the whole gap sits
after authoring, in the lint/check -> fix -> recheck loop. Lint execution is
3-5% of wall; the cost is the model turn each finding triggers. So the
expensive rule is the one an agent cannot satisfy, and the next most expensive
is the one that fires on correct code.
Two rules could not be satisfied at all.
`gsap_fullscreen_overlay_starts_visible` on a from() reveal was a closed cycle.
It errored on `tl.from("#flash", { opacity: 0 })`, which is not a defect:
from() seats its start values immediately, so on a paused timeline the overlay
already measures opacity 0 at t=0. Both of its fixHints (authored CSS
`opacity: 0`, or an immediate `gsap.set`) turn that working composition into a
real defect, which `gsap_from_opacity_noop` correctly errors on -- and that
rule's fixHint says to remove the very thing we just asked for. Applying either
hint bounced between the two errors forever.
The root cause was not the reporting condition but `laterHidden`, which counted
the reveal itself: a from-tween records its START values, so
`from({opacity: 0})` read as its own later hide. Excluding the reveal, and
excluding from-tweens (which end visible), is what actually fixes it. The
later-hidden shape still reports and still converges.
`caption_text_overflow_risk` told authors to add `overflow: hidden`, which is
exactly what `caption_overflow_clips_scaled_words` errors on. Following the
warning produced an error. The hint now says to keep overflow visible.
Two rules asserted a failure that cannot happen.
`root_composition_missing_data_start` errored because "the runtime needs
data-start=0 on the root element to begin playback". The runtime sets it itself
-- init.ts:286-292, whose comment reads "Agents sometimes omit data-start on the
root composition element ... Default to 0 for the root." The rule demanded the
fix the runtime had already applied. 16 of the 643 shipped registry files.
`overlapping_clips_same_track` claimed overlapping clips "cause rendering
conflicts". Nothing reads the track index at render: timeline.ts:586 states
"Track index is display-only; render never reads it", and grepping
`trackIndex|track-index` across engine and producer source returns zero hits.
Two clips overlapping on one track is a crossfade.
Two false positives fixed rather than removed, because the invariants are real.
`timeline_id_mismatch` fired on the legal one-liner
`window.__timelines = { main: gsap.timeline({ paused: true }) }`. The body regex
was non-greedy, so it stopped at the brace of the inlined OPTIONS object, and
the entry scanner harvested `paused` as a composition id. The resulting fixHint
named a registration that does not exist, so it could never be applied; hoisting
to a variable was the only escape and nothing said so. It now walks brace depth
and reads top-level keys only.
`non_deterministic_code` fired on `new Date("2026-01-01T00:00:00Z")`, which is
deterministic, and on `Math.random()` inside a string literal that a code-display
composition renders but never executes. Date is now zero-arg only, and patterns
run against string-stripped source -- except the GSAP `"random(...)"` tween
value, where the string IS the executed value and must still be scanned. That
exception is what the first attempt got wrong; the existing tests caught it.
Measured over the 643 shipped registry files: 386 -> 370 errors, no new codes.
The corpus does not exercise the loop cases, so it understates the change -- the
point is the turns those loops cost, not the finding count.
This commit is contained in:
@@ -163,4 +163,33 @@ describe("caption rules", () => {
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
describe("caption_text_overflow_risk — its fix must not create an error", () => {
|
||||
const cap = (css: string) => `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<style>.caption-group{${css}}</style><div class="caption-group"></div>
|
||||
</div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
words.forEach((w) => tl.to(w, { scale: 1.3 }));
|
||||
window.__timelines = { captions: tl };
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
it("clears when the fixHint is applied as written", async () => {
|
||||
// The hint used to say "and overflow: hidden", which is exactly what
|
||||
// caption_overflow_clips_scaled_words errors on. Following the warning
|
||||
// produced an error.
|
||||
const before = await lintHyperframeHtml(cap("position:absolute;white-space:nowrap"));
|
||||
expect(before.findings.find((f) => f.code === "caption_text_overflow_risk")).toBeDefined();
|
||||
|
||||
const after = await lintHyperframeHtml(
|
||||
cap("position:absolute;white-space:nowrap;max-width:1600px;overflow:visible"),
|
||||
);
|
||||
const blocking = after.findings.filter((f) => f.severity !== "info");
|
||||
expect(blocking.map((f) => f.code)).not.toContain("caption_text_overflow_risk");
|
||||
expect(blocking.map((f) => f.code)).not.toContain("caption_overflow_clips_scaled_words");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,11 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
||||
fixHint:
|
||||
"Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
|
||||
// Deliberately does NOT say `overflow: hidden`: caption words are scaled
|
||||
// above 1.0x, and clipping them is exactly what caption_overflow_clips_scaled_words
|
||||
// errors on. Recommending it here made this warning's own fix produce an error.
|
||||
"Add max-width: 1600px (landscape) or max-width: 900px (portrait). Keep " +
|
||||
"overflow visible so scaled emphasis words are not clipped.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,78 +528,6 @@ describe("composition rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlapping_clips_same_track", () => {
|
||||
it("flags overlapping clips on the same track", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
|
||||
<div class="clip" data-start="2" data-duration="3" data-track-index="0">B</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await 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", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
|
||||
<div class="clip" data-start="1" data-duration="3" data-track-index="1">B</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await 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", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0" data-duration="2" data-track-index="0">A</div>
|
||||
<div class="clip" data-start="2" data-duration="2" data-track-index="0">B</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag adjacencies where parseFloat + add drifts by a few ulps", async () => {
|
||||
// parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0.1" data-duration="0.2" data-track-index="0">A</div>
|
||||
<div class="clip" data-start="0.3" data-duration="0.2" data-track-index="0">B</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("root_composition_missing_html_wrapper", () => {
|
||||
it("flags bare composition div as error", async () => {
|
||||
// Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper
|
||||
|
||||
@@ -59,14 +59,6 @@ const HEAVY_OVERLAY_CSS_PATTERN =
|
||||
/(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
|
||||
const INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
|
||||
|
||||
// `parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004`. Sub-second
|
||||
// authored adjacencies survive parse + add as a value a few ulps above the
|
||||
// next clip's start; a strict `>` fires the overlap rule on adjacencies that
|
||||
// are exact in the source HTML. 1μs sits ~11 orders of magnitude above the
|
||||
// observed drift (worst ~2e-16s across every realistic decimal pair) and 4
|
||||
// below one 60fps frame (~16.67ms), so this only ever swallows float slop.
|
||||
const OVERLAP_EPSILON_SECONDS = 1e-6;
|
||||
|
||||
function readTagTiming(rawTag: string) {
|
||||
return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
|
||||
}
|
||||
@@ -164,6 +156,7 @@ function leftmostCompoundId(selector: string): string | null {
|
||||
// are scanned — the flat `[^{}]*` body class naturally skips @keyframes
|
||||
// bodies (which contain nested `{...}` stops) and other @-rules, so keyframe
|
||||
// selectors like `0%`/`100%` don't leak in.
|
||||
// fallow-ignore-next-line complexity
|
||||
function collectHeavyOverlayHooks(styles: ExtractedBlock[]): {
|
||||
classes: Set<string>;
|
||||
ids: Set<string>;
|
||||
@@ -557,75 +550,6 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
return findings;
|
||||
},
|
||||
|
||||
// overlapping_clips_same_track
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
type ClipInfo = { start: number; end: number; elementId?: string; snippet: string };
|
||||
const trackMap = new Map<string, ClipInfo[]>();
|
||||
|
||||
for (const tag of tags) {
|
||||
const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
|
||||
if (!trackStr) continue;
|
||||
const timing = readTagTiming(tag.raw);
|
||||
const { start, duration } = timing;
|
||||
const track = trackStr;
|
||||
|
||||
// Skip non-numeric (relative timing references like "intro-comp")
|
||||
if (start == null || duration == null) 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 > OVERLAP_EPSILON_SECONDS) {
|
||||
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;
|
||||
},
|
||||
|
||||
// root_composition_missing_data_start
|
||||
({ rootTag, options }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (options.isSubComposition) return findings;
|
||||
if (!rootTag) return findings;
|
||||
const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
|
||||
if (!compId) return findings;
|
||||
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
|
||||
if (!hasStart) {
|
||||
findings.push({
|
||||
code: "root_composition_missing_data_start",
|
||||
severity: "error",
|
||||
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
|
||||
fixHint: 'Add data-start="0" to the root composition element.',
|
||||
snippet: truncateSnippet(rootTag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// standalone_composition_wrapped_in_template
|
||||
({ rawSource, options }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
@@ -708,4 +708,64 @@ describe("core rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("non_deterministic_code — determinism is about execution, not text", () => {
|
||||
const comp = (script: string) => `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
|
||||
<script src="gsap.min.js"></script>
|
||||
<script>const tl = gsap.timeline({ paused: true }); ${script} window.__timelines = { main: tl };</script>
|
||||
</body></html>`;
|
||||
|
||||
it("does not flag new Date() with a fixed timestamp", async () => {
|
||||
// Deterministic, and the fixHint ("remove time-dependent code") cannot be
|
||||
// applied without deleting the label the composition renders.
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(`const label = new Date("2026-01-01T00:00:00Z").toISOString();`),
|
||||
);
|
||||
expect(result.findings.find((f) => f.code === "non_deterministic_code")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag non-deterministic APIs quoted inside a string literal", async () => {
|
||||
// Code-display compositions render source they never execute.
|
||||
const result = await lintHyperframeHtml(comp(`const SNIPPET = "const x = Math.random();";`));
|
||||
expect(result.findings.find((f) => f.code === "non_deterministic_code")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still flags a bare new Date()", async () => {
|
||||
const result = await lintHyperframeHtml(comp(`const now = new Date();`));
|
||||
expect(result.findings.find((f) => f.code === "non_deterministic_code")).toBeDefined();
|
||||
});
|
||||
|
||||
it("still flags Math.random() in executed code", async () => {
|
||||
const result = await lintHyperframeHtml(comp(`const r = Math.random();`));
|
||||
expect(result.findings.find((f) => f.code === "non_deterministic_code")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeline_id_mismatch — only top-level registry keys are composition ids", () => {
|
||||
const comp = (script: string) => `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
|
||||
<script src="gsap.min.js"></script>
|
||||
<script>${script}</script>
|
||||
</body></html>`;
|
||||
|
||||
it("does not flag the one-liner registration form", async () => {
|
||||
// The inlined options object is not a registration. Reading `paused` as a
|
||||
// composition id produced an error whose fixHint named a registration that
|
||||
// did not exist, so it could never be applied.
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(`window.__timelines = { main: gsap.timeline({ paused: true }) };`),
|
||||
);
|
||||
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still flags a genuinely mismatched id", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
comp(`window.__timelines = { wrongid: gsap.timeline({ paused: true }) };`),
|
||||
);
|
||||
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readDecodedAttr,
|
||||
truncateSnippet,
|
||||
stripJsComments,
|
||||
stripStringLiterals,
|
||||
extractCompositionIdsFromCss,
|
||||
extractTimelineRegistryKeys,
|
||||
getInlineScriptSyntaxError,
|
||||
@@ -204,6 +205,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
|
||||
// root_missing_composition_id + root_missing_dimensions
|
||||
// fallow-ignore-next-line complexity
|
||||
({ rootTag }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
|
||||
@@ -247,6 +249,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
|
||||
// missing_timeline_registry + timeline_registry_missing_init
|
||||
// fallow-ignore-next-line complexity
|
||||
({ source, rawSource, rootTag, options }) => {
|
||||
// Sub-compositions inherit window.__timelines from the host composition
|
||||
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
|
||||
@@ -439,7 +442,13 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// non_deterministic_code
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const patterns: Array<{ pattern: RegExp; label: string; hint: string }> = [
|
||||
const patterns: Array<{
|
||||
pattern: RegExp;
|
||||
label: string;
|
||||
hint: string;
|
||||
/** Match against raw source, because the value being matched is a string GSAP parses. */
|
||||
scansStrings?: boolean;
|
||||
}> = [
|
||||
{
|
||||
pattern: /Math\.random\s*\(/,
|
||||
label: "Math.random()",
|
||||
@@ -451,7 +460,10 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
|
||||
},
|
||||
{
|
||||
pattern: /new\s+Date\s*\(/,
|
||||
// Zero-arg only. `new Date(<fixed timestamp>)` is fully deterministic and is how
|
||||
// a composition labels a fixed date on an axis or card; the hint ("remove
|
||||
// time-dependent code") cannot be applied to it without deleting the label.
|
||||
pattern: /new\s+Date\s*\(\s*\)/,
|
||||
label: "new Date()",
|
||||
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
|
||||
},
|
||||
@@ -472,16 +484,25 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
{
|
||||
// GSAP string form: "random(...)" / "+=random(...)" — re-rolls at tween init.
|
||||
// `scansStrings` because here the string IS the executed value: GSAP parses it.
|
||||
// Every other pattern above matches executable code, so a match inside a string
|
||||
// literal is inert text and must not be reported.
|
||||
pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/,
|
||||
scansStrings: true,
|
||||
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) {
|
||||
const stripped = stripJsComments(script.content);
|
||||
for (const { pattern, label, hint } of patterns) {
|
||||
if (pattern.test(stripped)) {
|
||||
const withoutComments = stripJsComments(script.content);
|
||||
// Strings are content, not code. A composition that DISPLAYS source (the
|
||||
// code-snippet blocks, /pr-to-video) carries `Math.random()` inside a string
|
||||
// literal it never executes, and reported itself non-deterministic with no
|
||||
// way to clear the error while still rendering the snippet.
|
||||
const executable = stripStringLiterals(withoutComments);
|
||||
for (const { pattern, label, hint, scansStrings } of patterns) {
|
||||
if (pattern.test(scansStrings ? withoutComments : executable)) {
|
||||
findings.push({
|
||||
code: "non_deterministic_code",
|
||||
severity: "error",
|
||||
|
||||
@@ -240,27 +240,6 @@ describe("GSAP rules", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("errors when a full-frame transition flash uses a GSAP from reveal", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
|
||||
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#tr-flash-1", { opacity: 0, duration: 0.18 }, 7.92);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe("#tr-flash-1");
|
||||
});
|
||||
|
||||
it("errors when a grouped GSAP selector targets a visible full-frame flash", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
@@ -3051,4 +3030,44 @@ describe("SVG draw-on rules", () => {
|
||||
const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("gsap_fullscreen_overlay_starts_visible — the from() shape is not a defect", () => {
|
||||
const overlay = (style: string, script: string) => `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div id="flash" style="position:fixed;inset:0;background:#000;${style}"></div>
|
||||
</div>
|
||||
<script src="gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
${script}
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
it("does not flag a from() reveal, which already seats opacity 0 at t=0", async () => {
|
||||
// This used to error, and BOTH its fixHints (authored CSS opacity:0, or an
|
||||
// immediate gsap.set) produce gsap_from_opacity_noop — whose own fixHint says
|
||||
// to remove exactly what was just added. Applying either hint looped forever.
|
||||
const result = await lintHyperframeHtml(
|
||||
overlay("", `tl.from("#flash", { opacity: 0, duration: 1 }, 2);`),
|
||||
);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "gsap_fullscreen_overlay_starts_visible"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still flags an overlay that is revealed and later hidden again", async () => {
|
||||
const result = await lintHyperframeHtml(
|
||||
overlay(
|
||||
"",
|
||||
`tl.to("#flash", { opacity: 1, duration: 1 }, 2);\n tl.to("#flash", { opacity: 0, duration: 1 }, 5);`,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "gsap_fullscreen_overlay_starts_visible"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1181,10 +1181,30 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
) ||
|
||||
selectors[0] ||
|
||||
tag.name;
|
||||
// A window only re-hides the overlay if it is a DIFFERENT tween that ends
|
||||
// hidden. Two exclusions matter:
|
||||
// - `win !== firstVisible`: the reveal counted itself.
|
||||
// - `method !== "from"`: a from-tween's recorded propertyValues are its
|
||||
// START state. `from({opacity: 0})` ENDS visible, so reading those values
|
||||
// as an end state made every from-reveal look like its own later hide.
|
||||
// Together these are what made the `from` shape report at all.
|
||||
const laterHidden = visibilityWindows.some(
|
||||
(win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues),
|
||||
(win) =>
|
||||
win !== firstVisible &&
|
||||
win.method !== "from" &&
|
||||
win.position >= firstVisible.position &&
|
||||
isHiddenGsapState(win.propertyValues),
|
||||
);
|
||||
if (firstVisible.method !== "from" && !laterHidden) continue;
|
||||
// Only the later-hidden shape is a real defect. The `from` shape is not:
|
||||
// gsap.from() seats its start values immediately, so on a paused timeline the
|
||||
// overlay already measures opacity 0 at t=0 and never covers an early frame.
|
||||
//
|
||||
// Worse, it had no exit. Both fixHints below (authored CSS `opacity: 0`, or an
|
||||
// immediate `gsap.set`) turn a working composition into a real defect that
|
||||
// `gsap_from_opacity_noop` correctly errors on — and that rule's fixHint says to
|
||||
// remove the very thing we just asked for, closing the loop. An agent applying
|
||||
// either hint bounces between the two errors forever.
|
||||
if (!laterHidden) continue;
|
||||
|
||||
reportedVisibleOverlayKeys.add(overlayKey);
|
||||
findings.push({
|
||||
|
||||
+88
-12
@@ -46,7 +46,11 @@ const TIMELINE_REGISTRY_KEY_PATTERN =
|
||||
|
||||
// The `window.__timelines = { ... }` object-literal body (group 1), captured so its
|
||||
// `key: value` entries can be scanned for registered keys.
|
||||
const TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i;
|
||||
// Locates the START of a `window.__timelines = { ... }` literal. Deliberately does
|
||||
// not try to match the closing brace: see readTimelineRegistryObjectBody, which walks
|
||||
// braces instead. A regex cannot tell the registry's own `}` from the `}` of an
|
||||
// inlined options object.
|
||||
const TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN = /window\.__timelines\s*=\s*\{/i;
|
||||
// A single object-literal entry whose value is an identifier (real timeline registration),
|
||||
// e.g. `main: tl` or `"comp-1": tl`. Captures the key in group 1 (quoted) or 2 (bare).
|
||||
const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN =
|
||||
@@ -246,20 +250,71 @@ export function extractTimelineRegistryKeys(source: string): string[] {
|
||||
const key = match[1] ?? match[2];
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
const objectBody = TIMELINE_REGISTRY_OBJECT_BODY_PATTERN.exec(source)?.[1];
|
||||
if (objectBody) {
|
||||
const entryPattern = new RegExp(
|
||||
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
|
||||
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags,
|
||||
);
|
||||
while ((match = entryPattern.exec(objectBody)) !== null) {
|
||||
const key = match[1] ?? match[2];
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
}
|
||||
for (const entry of readTimelineRegistryTopLevelKeys(source)) keys.add(entry);
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level keys of a `window.__timelines = { ... }` literal.
|
||||
*
|
||||
* Walks brace depth rather than regex-matching the body. The previous non-greedy
|
||||
* body match stopped at the first `}` it saw, which for the legal one-liner
|
||||
*
|
||||
* window.__timelines = { main: gsap.timeline({ paused: true }) };
|
||||
*
|
||||
* was the brace of the INLINED OPTIONS OBJECT. The entry scanner then harvested
|
||||
* `paused` as a composition id and timeline_id_mismatch reported a timeline
|
||||
* "registered as paused" — a registration that does not exist, so its fixHint
|
||||
* could never be applied. Hoisting the timeline to a variable was the only escape,
|
||||
* and nothing said so.
|
||||
*/
|
||||
/** Index of the brace that closes the group opened just before `bodyStart`. */
|
||||
function findMatchingBrace(source: string, bodyStart: number): number {
|
||||
let depth = 1;
|
||||
for (let i = bodyStart; i < source.length; i += 1) {
|
||||
if (source[i] === "{") depth += 1;
|
||||
else if (source[i] === "}" && (depth -= 1) === 0) return i;
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
/** Replace every nested brace group with spaces so only depth-0 text remains. */
|
||||
function blankNestedBraceGroups(body: string): string {
|
||||
let out = "";
|
||||
let depth = 0;
|
||||
for (const ch of body) {
|
||||
if (ch === "{") depth += 1;
|
||||
else if (ch === "}") depth = Math.max(0, depth - 1);
|
||||
else if (depth === 0) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
out += " ";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readTimelineRegistryTopLevelKeys(source: string): string[] {
|
||||
const open = TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN.exec(source);
|
||||
if (!open) return [];
|
||||
|
||||
const bodyStart = open.index + open[0].length;
|
||||
const body = source.slice(bodyStart, findMatchingBrace(source, bodyStart));
|
||||
const flattened = blankNestedBraceGroups(body);
|
||||
|
||||
const keys: string[] = [];
|
||||
const entryPattern = new RegExp(
|
||||
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
|
||||
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags,
|
||||
);
|
||||
let entry: RegExpExecArray | null;
|
||||
while ((entry = entryPattern.exec(flattened)) !== null) {
|
||||
const key = entry[1] ?? entry[2];
|
||||
if (key) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function getInlineScriptSyntaxError(source: string): string | null {
|
||||
if (!source.trim()) return null;
|
||||
try {
|
||||
@@ -272,6 +327,27 @@ export function getInlineScriptSyntaxError(source: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
/**
|
||||
* Blank the contents of every `'...'` and `"..."` literal, keeping the quotes so
|
||||
* the source stays the same shape.
|
||||
*
|
||||
* Needed because a composition that *displays* source code carries things like
|
||||
* `Math.random()` inside a string it never executes. Scanning raw script text for
|
||||
* non-determinism reported those compositions as non-deterministic, and no edit
|
||||
* could clear it while keeping the displayed snippet intact.
|
||||
*
|
||||
* Template literals are deliberately left alone: `${Math.random()}` inside one IS
|
||||
* executed, and blanking it would hide real non-determinism. A snippet stored in a
|
||||
* backtick string therefore still reports — a narrower gap than the one this closes.
|
||||
*/
|
||||
export function stripStringLiterals(source: string): string {
|
||||
return source.replace(
|
||||
/(['"])(?:\\.|(?!\1)[^\\\n])*\1?/g,
|
||||
(literal) => literal[0] + " ".repeat(Math.max(0, literal.length - 1)),
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function stripJsComments(source: string): string {
|
||||
let out = "";
|
||||
|
||||
Reference in New Issue
Block a user