diff --git a/packages/lint/src/rules/media.test.ts b/packages/lint/src/rules/media.test.ts index f2574aa178..8554bad69d 100644 --- a/packages/lint/src/rules/media.test.ts +++ b/packages/lint/src/rules/media.test.ts @@ -457,6 +457,27 @@ describe("audio_volume_double_automation", () => { } }); + it("does not blame the wrong element in a chained timeline", async () => { + // A chain has no semicolon until its very end, so a run that could cross `)` + // reached the `volume` in a LATER call and reported the element from an + // earlier one. Acting on the fixHint would have deleted #bgm's only real + // automation to fix a tween that is on #vo. + const res = await lintHyperframeHtml( + withScript( + LANE, + `gsap.timeline().to("#bgm", { duration: 0.6, x: 10 }).to("#vo", { volume: 1 });`, + ), + ); + expect(res.findings.some((f) => f.code === "audio_volume_double_automation")).toBe(false); + }); + + it("still catches a real tween further down the same call", async () => { + const res = await lintHyperframeHtml( + withScript(LANE, `gsap.timeline().to("#bgm", { duration: 0.6, ease: "none", volume: 0 });`), + ); + expect(res.findings.some((f) => f.code === "audio_volume_double_automation")).toBe(true); + }); + it("ignores a lane that automates something other than volume", async () => { const res = await lintHyperframeHtml( withScript( diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts index 9efca4f6fe..32c10d8620 100644 --- a/packages/lint/src/rules/media.ts +++ b/packages/lint/src/rules/media.ts @@ -625,7 +625,14 @@ function findVolumeDoubleAutomationFindings(ctx: LintContext): HyperframeLintFin // the same call the runtime's own probe would pick up, and the rule only // warns, so a miss costs nothing. const escaped = escapeRegExp(id); - const tweened = new RegExp(`#${escaped}(?![\\w-])[^;]{0,200}?\\bvolume\\s*:`, "s").test(script); + // `[^;)]`, not `[^;]`: a chained timeline has no semicolon until the end of + // the whole chain, so a run that could cross `)` matched `volume` in a LATER + // `.to()` call and named the wrong element — and this rule's fixHint tells + // the author to delete their lane. Refusing to cross the closing paren keeps + // the match inside the call the selector belongs to. It costs a false + // negative when some other value in the same object is a call result, which + // is the safe direction for a warning that already only guesses. + const tweened = new RegExp(`#${escaped}(?![\\w-])[^;)]{0,200}?\\bvolume\\s*:`).test(script); if (!tweened) continue; findings.push({ code: "audio_volume_double_automation",