From 5279b6f476401292949ff9bc0b0dbef4f6979d08 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Mon, 26 Oct 2026 18:59:36 +0000 Subject: [PATCH] fix(parsers): resolve media source writes in their lexical scopes (#5255) --- packages/lint/src/rules/media.test.ts | 25 ++ packages/parsers/src/mediaSrcMutation.test.ts | 113 +++++++++ packages/parsers/src/mediaSrcMutation.ts | 217 +++++++++++++++--- .../services/render/stages/probeStage.test.ts | 39 ++++ 4 files changed, 367 insertions(+), 27 deletions(-) create mode 100644 packages/parsers/src/mediaSrcMutation.test.ts diff --git a/packages/lint/src/rules/media.test.ts b/packages/lint/src/rules/media.test.ts index e5664c8cf3..fcdf3d0317 100644 --- a/packages/lint/src/rules/media.test.ts +++ b/packages/lint/src/rules/media.test.ts @@ -695,6 +695,31 @@ describe("media rules", () => { expect(findings.every((finding) => finding.fixHint?.includes("data-var-src"))).toBe(true); }); + it.each([ + { + script: `const target = document.getElementById("clip"); { const target = document.getElementById("poster"); target.src = "b.png"; } target.src = "b.mp4";`, + expected: ["clip"], + }, + { + script: `const target = document.getElementById("clip"); function update(target) { target.src = "b.png"; }`, + expected: [], + }, + { + script: `function updateVideo() { const target = document.getElementById("clip"); target.src = "b.mp4"; } function updateImage() { const target = document.getElementById("poster"); target.src = "b.png"; }`, + expected: ["clip"], + }, + ])("checks the media source write in its own scope ($expected)", async ({ script, expected }) => { + const html = `
+ +
`; + const result = await lintHyperframeHtml(html); + expect( + result.findings + .filter((finding) => finding.code === "media_runtime_src_mutation") + .map((finding) => finding.elementId), + ).toEqual(expected); + }); + it("does not flag img or script src mutations", async () => { const html = ` diff --git a/packages/parsers/src/mediaSrcMutation.test.ts b/packages/parsers/src/mediaSrcMutation.test.ts new file mode 100644 index 0000000000..be5db2bc95 --- /dev/null +++ b/packages/parsers/src/mediaSrcMutation.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { extractMediaSrcMutations } from "./mediaSrcMutation.js"; + +describe("extractMediaSrcMutations", () => { + it.each([ + { + label: "block", + script: `const media = document.getElementById("video"); { const media = document.getElementById("image"); media.src = "image.png"; } media.src = "new.mp4";`, + }, + { + label: "sibling functions", + script: `function updateImage() { const media = document.getElementById("image"); media.src = "image.png"; } function updateVideo() { const media = document.getElementById("video"); media.src = "new.mp4"; }`, + }, + { + label: "arrow function", + script: `const media = document.getElementById("video"); const updateImage = () => { const media = document.getElementById("image"); media.src = "image.png"; }; media.src = "new.mp4";`, + }, + ])("keeps $label bindings separate", ({ script }) => { + expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([ + "#image", + "#video", + ]); + }); + + it.each([ + { + label: "parameter", + script: `const media = document.getElementById("video"); function update(media) { media.src = "image.png"; } media.src = "new.mp4";`, + }, + { + label: "destructured parameter", + script: `const media = document.getElementById("video"); function update({ media }) { media.src = "image.png"; } media.src = "new.mp4";`, + }, + { + label: "unknown local", + script: `const media = document.getElementById("video"); { const media = findImage(); media.src = "image.png"; } media.src = "new.mp4";`, + }, + { + label: "destructured local", + script: `const media = document.getElementById("video"); { const { media } = assets; media.src = "image.png"; } media.src = "new.mp4";`, + }, + { + label: "catch parameter", + script: `const media = document.getElementById("video"); try { update(); } catch(media) { media.src = "image.png"; } media.src = "new.mp4";`, + }, + { + label: "loop variable", + script: `const media = document.getElementById("video"); for (const media of images) { media.src = "image.png"; } media.src = "new.mp4";`, + }, + ])("does not inherit an outer target through a $label", ({ script }) => { + expect(extractMediaSrcMutations(script)).toEqual([ + { selector: "#video", operation: "src_assignment", raw: `media.src = "new.mp4"` }, + ]); + }); + + it("resolves a closure and its scoped aliases", () => { + const script = `const media = document.getElementById("video"); { const alias = media; function update() { alias.setAttribute("src", "new.mp4"); } }`; + expect(extractMediaSrcMutations(script)).toEqual([ + { + selector: "#video", + operation: "set_attribute", + raw: `alias.setAttribute("src", "new.mp4")`, + }, + ]); + }); + + it("keeps var declarations in their enclosing function", () => { + const script = `function update() { { var media = document.getElementById("video"); } media.src = "new.mp4"; }`; + expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([ + "#video", + ]); + }); + + it("retains a parameter that is redeclared and initialized with var", () => { + const script = `function update(media) { var media = document.getElementById("video"); media.src = "new.mp4"; }`; + expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([ + "#video", + ]); + }); + + it("does not inherit an outer target through a named class expression", () => { + const script = `const media = document.getElementById("video"); const View = class media { update() { media.src = "not-a-video"; } }; media.src = "new.mp4";`; + expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([ + "#video", + ]); + }); + + it("does not leak block declarations outside their scope", () => { + const script = `{ const media = document.getElementById("video"); } media.src = "new.mp4";`; + expect(extractMediaSrcMutations(script)).toEqual([]); + }); + + it("retains direct and computed DOM lookups", () => { + const script = `document["getElementById"]("video")["src"] = "new.mp4"; document.querySelector("#audio").setAttribute("SRC", "new.wav");`; + expect(extractMediaSrcMutations(script).map((mutation) => mutation.selector)).toEqual([ + "#video", + "#audio", + ]); + }); + + it("keeps module parsing and rejects malformed scripts", () => { + expect( + extractMediaSrcMutations( + `export const media = document.getElementById("video"); media.src = "new.mp4";`, + ), + ).toHaveLength(1); + expect(extractMediaSrcMutations("const media = ;")).toEqual([]); + }); + + it("does not recurse through circular aliases", () => { + expect(extractMediaSrcMutations(`let a = b; let b = a; a.src = "new.mp4";`)).toEqual([]); + }); +}); diff --git a/packages/parsers/src/mediaSrcMutation.ts b/packages/parsers/src/mediaSrcMutation.ts index 23812f8374..942ecfd437 100644 --- a/packages/parsers/src/mediaSrcMutation.ts +++ b/packages/parsers/src/mediaSrcMutation.ts @@ -7,7 +7,17 @@ export interface MediaSrcMutation { raw: string; } -function parseProgram(script: string): any { +interface Binding { + initializer: acorn.Expression | null; + scope: Scope; +} + +interface Scope { + parent?: Scope; + bindings: Map; +} + +function parseProgram(script: string): acorn.Program { try { return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" }); } catch { @@ -15,28 +25,183 @@ function parseProgram(script: string): any { } } -function literalString(node: any): string | undefined { +function literalString(node: acorn.AnyNode | null | undefined): string | undefined { if (node?.type === "Literal" && typeof node.value === "string") return node.value; - if (node?.type === "TemplateLiteral" && node.expressions?.length === 0) { - return node.quasis?.[0]?.value?.cooked; + if (node?.type === "TemplateLiteral" && node.expressions.length === 0) { + return node.quasis[0]?.value.cooked ?? undefined; } return undefined; } -function memberName(node: any): string | undefined { +function memberName(node: acorn.AnyNode | null | undefined): string | undefined { if (node?.type !== "MemberExpression") return undefined; - if (!node.computed && node.property?.type === "Identifier") return node.property.name; + if (!node.computed && node.property.type === "Identifier") return node.property.name; return literalString(node.property); } -function lookupSelector(node: any, bindings: ReadonlyMap): string | undefined { - if (node?.type === "Identifier") return bindings.get(node.name); - if (node?.type !== "CallExpression" || node.callee?.type !== "MemberExpression") { +function isFunctionScope(node: acorn.AnyNode): boolean { + return ( + node.type === "Program" || + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" || + node.type === "StaticBlock" + ); +} + +function isScope(node: acorn.AnyNode): boolean { + return ( + isFunctionScope(node) || + node.type === "BlockStatement" || + node.type === "CatchClause" || + node.type === "ForStatement" || + node.type === "ForInStatement" || + node.type === "ForOfStatement" || + node.type === "SwitchStatement" || + node.type === "ClassDeclaration" || + node.type === "ClassExpression" + ); +} + +function declarePattern( + pattern: acorn.Pattern, + initializer: acorn.Expression | null, + scope: Scope, + initializerScope = scope, +): void { + switch (pattern.type) { + case "Identifier": + if (initializer || !scope.bindings.has(pattern.name)) { + scope.bindings.set(pattern.name, { initializer, scope: initializerScope }); + } + break; + case "RestElement": + declarePattern(pattern.argument, null, scope); + break; + case "AssignmentPattern": + declarePattern(pattern.left, null, scope); + break; + case "ArrayPattern": + case "ObjectPattern": + declarePatterns(destructuredPatterns(pattern), scope); + break; + } +} + +function declarePatterns(patterns: readonly acorn.Pattern[], scope: Scope): void { + for (const pattern of patterns) declarePattern(pattern, null, scope); +} + +function destructuredPatterns(pattern: acorn.ObjectPattern | acorn.ArrayPattern): acorn.Pattern[] { + if (pattern.type === "ObjectPattern") { + return pattern.properties.map((property) => + property.type === "RestElement" ? property.argument : property.value, + ); + } + return pattern.elements.filter((element): element is acorn.Pattern => element !== null); +} + +function declareOptionalPattern(pattern: acorn.Pattern | null | undefined, scope: Scope): void { + if (pattern) declarePattern(pattern, null, scope); +} + +function declareScopedName(node: acorn.AnyNode, scope: Scope): void { + switch (node.type) { + case "FunctionExpression": + case "FunctionDeclaration": + declareOptionalPattern(node.id, scope); + declarePatterns(node.params, scope); + break; + case "ArrowFunctionExpression": + declarePatterns(node.params, scope); + break; + case "ClassDeclaration": + case "ClassExpression": + declareOptionalPattern(node.id, scope); + break; + case "CatchClause": + declareOptionalPattern(node.param, scope); + break; + case "ImportDeclaration": + declarePatterns( + node.specifiers.map((specifier) => specifier.local), + scope, + ); + break; + } +} + +function lookupBinding(name: string, scope: Scope): Binding | undefined { + for (let current: Scope | undefined = scope; current; current = current.parent) { + const binding = current.bindings.get(name); + if (binding) return binding; + } + return undefined; +} + +function collectBindings( + ast: acorn.Program, +): (ancestors: readonly acorn.AnyNode[], functionOnly?: boolean) => Scope { + const root: Scope = { bindings: new Map() }; + const scopes = new WeakMap([[ast, root]]); + const scopeAt = (ancestors: readonly acorn.AnyNode[], functionOnly = false): Scope => { + let parent = root; + let selected = root; + for (const node of ancestors) { + if (!isScope(node)) continue; + let scope = scopes.get(node); + if (!scope) { + scope = { parent, bindings: new Map() }; + scopes.set(node, scope); + } + parent = scope; + if (!functionOnly || isFunctionScope(node)) selected = scope; + } + return selected; + }; + + acornWalk.fullAncestor(ast, (node, _state, ancestors) => { + const scope = scopeAt(ancestors); + if (node.type === "VariableDeclarator") { + const declaration = ancestors.at(-2); + const bindingScope = scopeAt( + ancestors, + declaration?.type === "VariableDeclaration" && declaration.kind === "var", + ); + declarePattern(node.id, node.init ?? null, bindingScope, scope); + return; + } + if ((node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && node.id) { + declarePattern(node.id, null, scopeAt(ancestors.slice(0, -1))); + } + declareScopedName(node, scope); + }); + return scopeAt; +} + +function lookupSelector( + node: acorn.Expression | acorn.Super | null | undefined, + scope: Scope, + seen = new Set(), +): string | undefined { + if (node?.type === "Identifier") { + const binding = lookupBinding(node.name, scope); + if (!binding || seen.has(binding)) return undefined; + seen.add(binding); + return lookupSelector(binding.initializer, binding.scope, seen); + } + return selectorFromDomLookup(node); +} + +function selectorFromDomLookup( + node: acorn.Expression | acorn.Super | null | undefined, +): string | undefined { + if (node?.type !== "CallExpression" || node.callee.type !== "MemberExpression") { return undefined; } const method = memberName(node.callee); if (method !== "getElementById" && method !== "querySelector") return undefined; - const value = literalString(node.arguments?.[0]); + const value = literalString(node.arguments[0]); if (!value) return undefined; return method === "getElementById" ? `#${value}` : value; } @@ -45,20 +210,17 @@ function lookupSelector(node: any, bindings: ReadonlyMap): strin export function extractMediaSrcMutations(script: string): MediaSrcMutation[] { try { const ast = parseProgram(script); - const bindings = new Map(); - acornWalk.simple(ast, { - VariableDeclarator(node: any) { - if (node.id?.type !== "Identifier") return; - const selector = lookupSelector(node.init, bindings); - if (selector) bindings.set(node.id.name, selector); - }, - }); - + const scopeAt = collectBindings(ast); const mutations: MediaSrcMutation[] = []; - acornWalk.simple(ast, { - AssignmentExpression(node: any) { - if (node.operator !== "=" || memberName(node.left) !== "src") return; - const selector = lookupSelector(node.left.object, bindings); + acornWalk.ancestor(ast, { + AssignmentExpression(node, _state, ancestors) { + if ( + node.operator !== "=" || + node.left.type !== "MemberExpression" || + memberName(node.left) !== "src" + ) + return; + const selector = lookupSelector(node.left.object, scopeAt(ancestors)); if (!selector) return; mutations.push({ selector, @@ -66,10 +228,11 @@ export function extractMediaSrcMutations(script: string): MediaSrcMutation[] { raw: script.slice(node.start, node.end), }); }, - CallExpression(node: any) { - if (memberName(node.callee) !== "setAttribute") return; - if (literalString(node.arguments?.[0])?.toLowerCase() !== "src") return; - const selector = lookupSelector(node.callee.object, bindings); + CallExpression(node, _state, ancestors) { + if (node.callee.type !== "MemberExpression" || memberName(node.callee) !== "setAttribute") + return; + if (literalString(node.arguments[0])?.toLowerCase() !== "src") return; + const selector = lookupSelector(node.callee.object, scopeAt(ancestors)); if (!selector) return; mutations.push({ selector, diff --git a/packages/producer/src/services/render/stages/probeStage.test.ts b/packages/producer/src/services/render/stages/probeStage.test.ts index cbd1a105d0..57e791e152 100644 --- a/packages/producer/src/services/render/stages/probeStage.test.ts +++ b/packages/producer/src/services/render/stages/probeStage.test.ts @@ -1034,6 +1034,45 @@ describe("runProbeStage — forceScreenshot threading", () => { expect(mediaPreflightComposition).toBe(input.composition); }); + it("probes a video source write after an inner binding shadows its variable", async () => { + resetRetryMocks(); + capturedCfgs.length = 0; + const { runProbeStage } = await import("./probeStage.js"); + const input = makeProbeInput({}); + input.composition.duration = 5; + input.composition.videos.push({ + id: "clip", + src: "a.mp4", + start: 0, + end: 5, + mediaStart: 0, + loop: false, + hasAudio: false, + }); + input.compiled.html = ` + `; + browserMediaResults = [ + { + id: "clip", + tagName: "video", + src: "b.mp4", + start: 0, + end: 5, + duration: 5, + mediaStart: 0, + loop: false, + hasAudio: false, + volume: 1, + muted: true, + }, + ]; + await runProbeStage(input); + expect(capturedCfgs.length).toBeGreaterThan(0); + expect(input.composition.videos[0]?.src).toBe("b.mp4"); + }); + it("does not probe for img or script src mutations", async () => { resetRetryMocks(); capturedCfgs.length = 0;