From 9bb4b4ce60b6d41f2aa5fc4c1bcfc3e4efa73b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 21 Aug 2026 15:15:12 -0400 Subject: [PATCH] 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. --- packages/lint/src/rules/captions.test.ts | 29 ++++++ packages/lint/src/rules/captions.ts | 6 +- packages/lint/src/rules/composition.test.ts | 72 -------------- packages/lint/src/rules/composition.ts | 78 +-------------- packages/lint/src/rules/core.test.ts | 60 ++++++++++++ packages/lint/src/rules/core.ts | 31 +++++- packages/lint/src/rules/gsap.test.ts | 61 ++++++++---- packages/lint/src/rules/gsap.ts | 24 ++++- packages/lint/src/utils.ts | 100 +++++++++++++++++--- 9 files changed, 271 insertions(+), 190 deletions(-) diff --git a/packages/lint/src/rules/captions.test.ts b/packages/lint/src/rules/captions.test.ts index 6977acb6e..57fedcda9 100644 --- a/packages/lint/src/rules/captions.test.ts +++ b/packages/lint/src/rules/captions.test.ts @@ -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) => ` + +
+
+
+ +`; + + 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"); + }); + }); }); diff --git a/packages/lint/src/rules/captions.ts b/packages/lint/src/rules/captions.ts index 410024257..17faf97dd 100644 --- a/packages/lint/src/rules/captions.ts +++ b/packages/lint/src/rules/captions.ts @@ -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.", }); } } diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index a508e7e7c..68881f4d0 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -528,78 +528,6 @@ describe("composition rules", () => { }); }); - describe("overlapping_clips_same_track", () => { - it("flags overlapping clips on the same track", async () => { - const html = ` - -
-
A
-
B
-
- -`; - 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 = ` - -
-
A
-
B
-
- -`; - 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 = ` - -
-
A
-
B
-
- -`; - 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 = ` - -
-
A
-
B
-
- -`; - 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 diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 2b4ea76f9..021826864 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -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; ids: Set; @@ -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(); - - 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[] = []; diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index e4d7fc281..4015cf96d 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -708,4 +708,64 @@ describe("core rules", () => { expect(finding).toBeUndefined(); }); }); + + describe("non_deterministic_code — determinism is about execution, not text", () => { + const comp = (script: string) => ` + +
+ + +`; + + 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) => ` + +
+ + +`; + + 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(); + }); + }); }); diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index de31a2cfc..4ef3245f0 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -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(" 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()` 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", diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index b39ca5d2f..33f3083c6 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -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 = ` - -
-

Scene 1

- - -`; - 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 = ` @@ -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) => ` + +
+
+
+ + +`; + + 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(); + }); + }); }); diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 05a56934a..c87d75e13 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1181,10 +1181,30 @@ export const gsapRules: LintRule[] = [ ) || 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({ diff --git a/packages/lint/src/utils.ts b/packages/lint/src/utils.ts index 39b231538..4b665dcbe 100644 --- a/packages/lint/src/utils.ts +++ b/packages/lint/src/utils.ts @@ -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 = "";