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:
parent
c6166d4b56
commit
bcb60c179e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue