fix(parsers): resolve media source writes in their lexical scopes (#5255)

This commit is contained in:
Abdullah M. Nazif
2026-10-26 18:59:36 +00:00
committed by GitHub
parent 020e6c99f3
commit 5279b6f476
4 changed files with 367 additions and 27 deletions
+25
View File
@@ -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 = `<html><body><div data-composition-id="c1" data-width="1920" data-height="1080">
<video id="clip" src="a.mp4"></video><img id="poster" src="a.png">
<script>${script}</script></div></body></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 = `
<html><body>
@@ -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([]);
});
});
+190 -27
View File
@@ -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<string, Binding>;
}
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, string>): 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<acorn.AnyNode, Scope>([[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<Binding>(),
): 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<string, string>): strin
export function extractMediaSrcMutations(script: string): MediaSrcMutation[] {
try {
const ast = parseProgram(script);
const bindings = new Map<string, string>();
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,
@@ -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 = `<video id="clip" src="a.mp4"></video><img id="poster" src="a.png">
<script>const target = document.getElementById("clip");
{ const target = document.getElementById("poster"); target.src = "b.png"; }
target.src = "b.mp4";</script>`;
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;