fix(lint): stop the double-automation rule blaming the wrong element

The pattern allowed `[^;]{0,200}` between an element's selector and `volume:`,
and `[^;]` crosses `)`, `,` and newlines. A chained GSAP timeline has no
semicolon until the end of the whole chain, so

    gsap.timeline().to("#bgm", { x: 10 }).to("#vo", { volume: 1 });

matched for `#bgm` — an element whose only automation is the lane. The warning
then told the author "the lane wins, the tween is ignored" about a tween that
is on a different track, and its fixHint invited them to delete the lane that
was actually working.

Refusing to cross the closing paren keeps the match inside the call the
selector belongs to. It costs a false negative when another value in the same
object is a call result, which is the safe direction for a rule that only
warns and, by its own comment, already only guesses. The `s` flag went with it:
inert, since the character class already matched newlines.
This commit is contained in:
Vance Ingalls
2026-08-08 10:50:36 -07:00
parent 436db25062
commit 59563eae01
2 changed files with 29 additions and 1 deletions
+21
View File
@@ -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(
+8 -1
View File
@@ -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",