fix(javascript): reconstruct HTML script loads accurately (#1042)

* fix: parse HTML script references with HTML semantics

Signed-off-by: Rudy Celekli <[email protected]>

* test(javascript): cover exact HTML script resource identity

---------

Signed-off-by: Rudy Celekli <[email protected]>
Co-authored-by: N0zoM1z0 <[email protected]>
This commit is contained in:
Rudy Mizrahi Celekli
2026-10-08 13:24:24 +08:00
committed by GitHub
co-authored by N0zoM1z0
parent 53409ab196
commit 1154f2f03c
11 changed files with 475 additions and 22 deletions
@@ -230,3 +230,9 @@ such as `./web3:app.js` still names a local artifact.
Package `exports` fallback arrays are supported both at the top level and under
the root `"."` entry. The resolver selects targets in declared order using the
same conditional and invalid-entry handling as nested exports arrays.
HTML script references resolve to exact inventoried files after applying the
document base and query/fragment rules. CommonJS module lookups retain extension
and directory resolution.
Unresolved HTML references retain their declaration, source range, and resolution
reason in the renderer observations.
+25
View File
@@ -31,6 +31,7 @@
"isomorphic-git": "1.38.7",
"jsonc-parser": "3.3.1",
"lossless-json": "4.3.1",
"parse5": "8.0.1",
"pino": "10.4.0",
"playwright-core": "1.58.2",
"plist": "5.0.0",
@@ -6339,6 +6340,30 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+1
View File
@@ -225,6 +225,7 @@
"isomorphic-git": "1.38.7",
"jsonc-parser": "3.3.1",
"lossless-json": "4.3.1",
"parse5": "8.0.1",
"pino": "10.4.0",
"playwright-core": "1.58.2",
"plist": "5.0.0",
@@ -25,6 +25,7 @@ import type {
JavaScriptSourceMapOriginal,
} from "./JavaScriptArtifactAnalysisTypes.js";
import { analyzeJavaScriptJsonModule } from "./JavaScriptJsonModules.js";
import { htmlArtifactReferences } from "../../domain/javascript/htmlArtifactReferences.js";
interface MutableArtifactAnalysis {
readonly files: AnalyzedJavaScriptArtifactFile[];
@@ -205,26 +206,13 @@ const parseHtmlScripts = (
path: string,
text: string,
): JavaScriptHtmlScriptObservation[] => {
const scripts: JavaScriptHtmlScriptObservation[] = [];
const baseHref = htmlBaseHref(text);
const pattern = /<script\b[^>]*\bsrc\s*=\s*(["'])([^"']+)\1[^>]*>/giu;
for (const match of text.matchAll(pattern)) {
const script = match[2];
if (script === undefined) continue;
const start = match.index;
scripts.push({
html_path: path,
script_path: script,
base_href: baseHref,
location: rangeForOffsets(text, start, start + match[0].length),
});
}
return scripts;
};
const htmlBaseHref = (text: string): string | null => {
const match = /<base\b[^>]*\bhref\s*=\s*(["'])([^"']+)\1[^>]*>/iu.exec(text);
return match?.[2] ?? null;
const { scripts, baseHref } = htmlArtifactReferences(text);
return scripts.map(({ scriptPath, startOffset, endOffset }) => ({
html_path: path,
script_path: scriptPath,
base_href: baseHref,
location: rangeForOffsets(text, startOffset, endOffset),
}));
};
const parseSourceMap = (
@@ -5,7 +5,10 @@ import {
linkElectronRoleToAsset,
type JavaScriptArtifactGraphContext,
} from "./JavaScriptArtifactGraphContext.js";
import { artifactObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
artifactObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { completeApplicationCoverage } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
@@ -57,7 +60,32 @@ export const addJavaScriptHtmlRoles = (
htmlBaseHref: script.base_href,
});
const resolvedPath = resolution.resolved_path;
if (resolvedPath === null) continue;
if (resolvedPath === null) {
context.accumulator.addNode({
kind: role.kind,
identity: role.identity,
observations: [
{
label: script.script_path,
properties: {
mechanism: "html-script-reference",
script_path: script.script_path,
base_href: script.base_href,
...resolution,
},
evidence: staticInferenceEvidence({
sha256: html.sha256,
path: html.path,
range: script.location,
operation: "resolve-html-script-reference",
coverage: completeApplicationCoverage(),
limitations: resolution.limitations,
}),
},
],
});
continue;
}
const resolved = context.fileNodes.get(resolvedPath);
if (resolved === undefined) continue;
addStaticInferenceEdge(context, {
@@ -676,3 +676,52 @@ const file = (
? { included: false, reason: "invalid-utf8" }
: { included: true, value: text },
});
describe("exact HTML resource resolution", () => {
it.each([
["extension", ["app.js"]],
["directory index", ["app/index.js"]],
["directory package", ["app/package.json", "app/entry.js"]],
] as const)("does not infer a file from %s fallback", (_name, paths) => {
const files = fileMap([
file("index.html", "root"),
...paths.map((path) =>
file(
path,
"root",
path.endsWith("package.json") ? '{"main":"entry.js"}' : "",
),
),
]);
expect(
resolve({
declaredPath: "./app",
sourcePath: "index.html",
context: "html-reference",
files,
}),
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
});
it("prefers an exact extensionless resource and retains URL suffix handling", () => {
const files = fileMap([
file("renderer/index.html", "root"),
file("assets/app", "root"),
file("assets/app.js", "root"),
file("assets/app/index.js", "root"),
]);
expect(
resolve({
declaredPath: "./app?cache=1#v2",
sourcePath: "renderer/index.html",
context: "html-reference",
htmlBaseHref: "/assets/",
files,
}),
).toMatchObject({
declared_path: "./app?cache=1#v2",
resolution_status: "resolved",
resolved_path: "assets/app",
});
});
});
@@ -93,6 +93,17 @@ const resolvePackagePath = (
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(input, candidate);
if (typeof confined !== "string") return confined;
if (input.context === "html-reference")
return outcome(
input,
resolveFileCandidates(input, [confined]) ?? {
resolvedPath: null,
status: "not-found",
limitations: [
`The exact HTML resource ${confined} was not found among the selected application files.`,
],
},
);
const resolved =
mode === "files-and-index"
? (resolveFileCandidates(input, [
@@ -0,0 +1,70 @@
import { expect, it } from "vitest";
import { htmlArtifactReferences } from "./htmlArtifactReferences.js";
it.each([
['<script data-src="decoy.js"></script>', []],
['<script x:src="decoy.js"></script>', []],
[`<script title='src="decoy.js"'></script>`, []],
["<script src=actual.js></script>", ["actual.js"]],
['<script title=">" src="actual.js"></script>', ["actual.js"]],
['<SCRIPT SRC="actual.js"></SCRIPT>', ["actual.js"]],
["<script src='actual.js'></script>", ["actual.js"]],
['<script data-src="decoy.js" src="actual.js"></script>', ["actual.js"]],
['<script src="actual.js" src="decoy.js"></script>', ["actual.js"]],
['<script src="" src="decoy.js"></script>', []],
['<script src="actual&#46;js"></script>', ["actual.js"]],
['<!-- <script src="decoy.js"></script> -->', []],
['<textarea><script src="decoy.js"></script></textarea>', []],
['<style><script src="decoy.js"></script></style>', []],
[`<script>const text = '<script src="decoy.js">';</script>`, []],
[
'<template><base href="decoy/"><script src="decoy.js"></script></template>',
[],
],
['<svg><script src="decoy.js"></script></svg>', []],
[
'<svg><foreignObject><script src="actual.js"></script></foreignObject></svg>',
["actual.js"],
],
// The existing extraction contract does not classify script MIME types.
['<script type="application/json" src="actual.js"></script>', ["actual.js"]],
])("extracts document references from %s", (text, expected) => {
expect(
htmlArtifactReferences(text).scripts.map(({ scriptPath }) => scriptPath),
).toEqual(expected);
});
it("retains exact opening-tag offsets rather than the script body", () => {
const opening = '<script\n title=">" src=actual.js>';
const prefix = "<!doctype html>\n\n";
const text = `${prefix}${opening}ignored body</script>`;
expect(htmlArtifactReferences(text)).toEqual({
baseHref: null,
scripts: [
{
scriptPath: "actual.js",
startOffset: prefix.length,
endOffset: prefix.length + opening.length,
},
],
});
});
it.each([
['<base data-href="decoy/"><base href=actual/>', "actual/"],
['<base href="actual&#47;" href="decoy/"><base href="later/">', "actual/"],
['<base href=""><base href="decoy/">', ""],
['<template><base href="decoy/"></template><base href="actual/">', "actual/"],
['<!-- <base href="decoy/"> --><base title=">" href="actual/">', "actual/"],
])("selects the first real base href from %s", (text, expected) => {
expect(htmlArtifactReferences(text).baseHref).toBe(expected);
});
it("traverses deep valid document content without recursive stack growth", () => {
const depth = 12_000;
const text = `${"<div>".repeat(depth)}<script src=actual.js></script>${"</div>".repeat(depth)}`;
expect(
htmlArtifactReferences(text).scripts.map(({ scriptPath }) => scriptPath),
).toEqual(["actual.js"]);
});
@@ -0,0 +1,55 @@
import {
defaultTreeAdapter,
parse,
type DefaultTreeAdapterTypes,
} from "parse5";
interface HtmlScriptReference {
readonly scriptPath: string;
readonly startOffset: number;
readonly endOffset: number;
}
/** Extract inert HTML script references and the first document base href. */
export const htmlArtifactReferences = (
text: string,
): {
readonly baseHref: string | null;
readonly scripts: readonly HtmlScriptReference[];
} => {
const pending: DefaultTreeAdapterTypes.Node[] = [
parse(text, { sourceCodeLocationInfo: true }),
];
const scripts: HtmlScriptReference[] = [];
let baseHref: string | null = null;
while (pending.length > 0) {
const node = pending.pop();
if (node === undefined) continue;
if (
defaultTreeAdapter.isElementNode(node) &&
node.namespaceURI === "http://www.w3.org/1999/xhtml"
) {
if (node.tagName === "base" && baseHref === null) {
const href = node.attrs.find(({ name }) => name === "href");
if (href !== undefined) baseHref = href.value;
}
if (node.tagName === "script") {
const src = node.attrs.find(({ name }) => name === "src");
const location = node.sourceCodeLocation?.startTag;
if (src !== undefined && src.value !== "" && location !== undefined) {
scripts.push({
scriptPath: src.value,
startOffset: location.startOffset,
endOffset: location.endOffset,
});
}
}
}
// Template content belongs to a separate inert document fragment. Only
// traverse the actual document children, without recursion on deep input.
if ("childNodes" in node) {
for (const child of node.childNodes.toReversed()) pending.push(child);
}
}
return { baseHref, scripts };
};
@@ -0,0 +1,87 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const cases = [
['<script data-src="decoy.js"></script>', []],
['<script x:src="decoy.js"></script>', []],
[`<script title='src="decoy.js"'></script>`, []],
["<script src=actual.js></script>", ["actual.js"]],
['<script title=">" src="actual.js"></script>', ["actual.js"]],
['<script src="actual.js"></script>', ["actual.js"]],
["<script src='actual.js'></script>", ["actual.js"]],
['<script data-src="decoy.js" src="actual.js"></script>', ["actual.js"]],
['<!-- <script src="decoy.js"></script> -->', []],
['<textarea><script src="decoy.js"></script></textarea>', []],
['<template><script src="decoy.js"></script></template>', []],
['<script src="actual&#46;js" src="decoy.js"></script>', ["actual.js"]],
] as const;
it.each(cases)(
"projects local loads from actual HTML attributes in %s",
async (html, expected) => {
const root = await createTestTempDirectory("rea-html-entrypoints-");
await Promise.all([
writeFile(join(root, "index.html"), html),
writeFile(join(root, "actual.js"), "globalThis.ownedActual = true;"),
writeFile(join(root, "decoy.js"), "globalThis.ownedDecoy = true;"),
]);
const result = await reconstructJavaScriptArtifact({ input_path: root });
const loads = result.graph.edges.filter(
({ relation, properties }) =>
relation === "loads" && properties.script_path !== undefined,
);
expect(loads.map(({ properties }) => properties.resolved_path)).toEqual(
expected,
);
},
);
it("preserves source digest, opening-tag range and decoded local base resolution", async () => {
const root = await createTestTempDirectory("rea-html-entrypoint-evidence-");
const opening = '<script title=">" src="actual&#46;js">';
const prefix = '<base data-href="decoy/"><base href=assets/>\n';
const html = `${prefix}${opening}</script>`;
await mkdir(join(root, "assets"));
await Promise.all([
writeFile(join(root, "index.html"), html),
writeFile(
join(root, "assets", "actual.js"),
"globalThis.ownedActual = true;",
),
]);
const result = await reconstructJavaScriptArtifact({ input_path: root });
const loads = result.graph.edges.filter(
({ relation, properties }) =>
relation === "loads" && properties.script_path !== undefined,
);
expect(loads).toHaveLength(1);
expect(loads[0]).toMatchObject({
properties: {
script_path: "actual.js",
base_href: "assets/",
resolved_path: "assets/actual.js",
},
evidence: {
authority: "static-relationship-inference",
artifact: {
available: true,
sha256: createHash("sha256").update(html).digest("hex"),
},
location: {
available: true,
value: {
kind: "source-range",
source: "index.html",
start: { line: 2, column: 0 },
end: { line: 2, column: opening.length },
},
},
},
});
});
@@ -0,0 +1,133 @@
import { mkdir, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, relative, sep } from "node:path";
import { expect, it } from "vitest";
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const cases: readonly {
name: string;
html: string;
files: Readonly<Record<string, string>>;
expected: readonly string[];
}[] = [
{
name: "extension fallback",
html: '<script src="./app"></script>',
files: { "app.js": "globalThis.owned = true;" },
expected: [],
},
{
name: "directory index",
html: '<script src="./app"></script>',
files: { "app/index.js": "globalThis.owned = true;" },
expected: [],
},
{
name: "directory package",
html: '<script src="./app"></script>',
files: {
"app/package.json": '{"main":"entry.js"}',
"app/entry.js": "globalThis.owned = true;",
},
expected: [],
},
{
name: "base directory",
html: '<base href="assets/"><script src="./app"></script>',
files: { "assets/app.js": "globalThis.owned = true;" },
expected: [],
},
{
name: "exact base resource",
html: '<base href="assets/"><script src="./app.js?cache=1#v2"></script>',
files: {
"assets/app.js": "globalThis.owned = true;",
"app.js": "globalThis.decoy = true;",
},
expected: ["assets/app.js"],
},
{
name: "exact script resource",
html: '<script src="./app.js?cache=1#v2"></script>',
files: { "app.js": "globalThis.owned = true;" },
expected: ["app.js"],
},
];
it.each(cases)(
"uses exact HTML resource identity for $name",
async ({ html, files, expected }) => {
const root = await createTestTempDirectory("rea-html-resources-");
await Promise.all(
Object.entries({ ...files, "index.html": html }).map(
async ([path, text]) => {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), text);
},
),
);
const result = await reconstructJavaScriptArtifact({ input_path: root });
const loads = result.graph.edges.filter(
({ relation, properties }) =>
relation === "loads" && properties.script_path !== undefined,
);
expect(loads.map(({ properties }) => properties.resolved_path)).toEqual(
expected,
);
if (expected.length === 0) {
const reference = result.graph.nodes
.flatMap(({ observations }) => observations)
.find(
({ properties }) => properties.mechanism === "html-script-reference",
);
expect(reference).toMatchObject({
properties: {
script_path: "./app",
resolution_context: "html-reference",
resolution_status: "not-found",
resolved_path: null,
},
evidence: {
authority: "static-relationship-inference",
location: {
available: true,
value: { kind: "source-range", source: "index.html" },
},
limitations: expect.arrayContaining([
expect.stringContaining("exact HTML resource"),
]),
},
});
}
},
);
it("retains CommonJS extension resolution against Node", async () => {
const root = await createTestTempDirectory("rea-html-node-control-");
await Promise.all([
writeFile(
join(root, "main.cjs"),
'const value = require("./app"); module.exports = value;',
),
writeFile(join(root, "app.js"), 'module.exports = "actual";'),
]);
const expected = relative(
root,
createRequire(join(root, "main.cjs")).resolve("./app"),
)
.split(sep)
.join("/");
const result = await reconstructJavaScriptArtifact({ input_path: root });
const edge = result.graph.edges.find(
({ properties }) =>
properties.module_link_kind === "require" &&
properties.specifier === "./app",
);
expect(edge?.properties).toMatchObject({
resolution_status: "resolved",
resolved_path: expected,
});
});