fix(browser): read expression-free template literals as module specifiers (#2842)

Captured-bundle chunk edges and source-map original module edges only
accepted quoted string literals as import()/require() specifiers. Bundlers
such as rolldown emit plain constants as expression-free template literals
(import(`./chunk.js`)), so statically known dependencies were dropped while
completeness stayed complete. Read both sites through the shared exact-literal
reader (stringValue); template literals with substitutions stay unrecognized.

On OWASP Juice Shop v20.3.0 this recovers 8 lazy chunk edges (previously 0).

Fixes #2841
Fixes #2599

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <17176810+morluto@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Shubham Ahuja 2026-10-14 04:10:25 +05:30 committed by GitHub
parent c6166d4b56
commit bcb60c179e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 82 additions and 18 deletions

View File

@ -11,6 +11,7 @@ import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js";
import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js";
import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
import { stringValue } from "../domain/javascript/javascriptStaticAnalysisHelpers.js";
import type {
AnalyzeWebBundleInput,
WebSourceMapItem,
@ -1049,18 +1050,21 @@ const originalDependency = (
return node.source === null || node.source === undefined
? null
: { kind: "static_import", specifier: node.source.value };
if (t.isImportExpression(node) && t.isStringLiteral(node.source))
return { kind: "dynamic_import", specifier: node.source.value };
if (t.isImportExpression(node)) {
const specifier = stringValue(node.source);
return specifier === undefined
? null
: { kind: "dynamic_import", specifier };
}
if (
(t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
t.isIdentifier(node.callee, { name: "require" }) &&
t.isStringLiteral(node.arguments[0])
)
return {
kind: "require",
specifier: node.arguments[0].value,
requireCallee: node.callee,
};
t.isIdentifier(node.callee, { name: "require" })
) {
const specifier = stringValue(node.arguments[0]);
return specifier === undefined
? null
: { kind: "require", specifier, requireCallee: node.callee };
}
// `import x = require("m")` is only legal at module top level and always
// refers to the host loader, so it needs no unbound-`require` check. The
// `moduleReference` is an entity name for a local alias instead, which

View File

@ -662,6 +662,28 @@ describe("web metadata key semantics", () => {
});
});
it("reads expression-free template dynamic imports as chunk edges", () => {
const result = analyzeCapturedWebBundle(
inspection(`
import(\`./lazy.js\`);
import("./quoted.js");
import(\`./\${name}.js\`);
`),
);
expect(result.observations.chunks.edges).toEqual([
expect.objectContaining({
kind: "dynamic_import",
specifier: "./lazy.js",
resolved_url: `${origin}/assets/lazy.js`,
}),
expect.objectContaining({
kind: "dynamic_import",
specifier: "./quoted.js",
}),
]);
});
it("does not infer call names from dynamic computed identifiers", () => {
const result = analyzeCapturedWebBundle(
inspection(`

View File

@ -123,14 +123,17 @@ const inspectNode = (
node,
accumulator,
});
else if (t.isImportExpression(node) && t.isStringLiteral(node.source))
addEdge({
script,
specifier: node.source.value,
kind: "dynamic_import",
node,
accumulator,
});
else if (t.isImportExpression(node)) {
const specifier = stringValue(node.source);
if (specifier !== undefined)
addEdge({
script,
specifier,
kind: "dynamic_import",
node,
accumulator,
});
}
if (t.isObjectProperty(node)) inspectRouteProperty(script, node, accumulator);
if (
t.isCallExpression(node) ||

View File

@ -988,6 +988,41 @@ describe("source-map original dependency syntax", () => {
result.items[0]?.original_module_edges.map(({ specifier }) => specifier),
).toEqual(["./real.js", "./dynamic.js", "./common.cjs"]);
});
it("reads expression-free template module specifiers as literals", async () => {
const text = [
"import(`./dep.js`);",
"import(`./dep.${name}.js`);",
"require(`./common.cjs`);",
"require(`./${name}.cjs`);",
].join("\n");
const result = await fetchWebSourceMaps([request], input(), undefined, {
fetch: () =>
Promise.resolve(
new Response(
JSON.stringify({
version: 3,
names: [],
sources: ["main.ts"],
sourcesContent: [text],
mappings: "AAAA",
}),
),
),
});
const item = result.items[0];
if (item?.status !== "included") throw new Error("map not included");
expect(item.original_module_edges).toEqual([
expect.objectContaining({
kind: "dynamic_import",
specifier: "./dep.js",
resolved_source: `${origin}/assets/dep.js`,
}),
expect.objectContaining({ kind: "require", specifier: "./common.cjs" }),
]);
expect(item.original_sources[0]?.artifact?.text).toBe(text);
expect(item.mappings).toHaveLength(1);
});
});
describe("source-map original syntax boundaries", () => {