From 1154f2f03c0b0bb6cb6c5da09b8d8e1c837febc4 Mon Sep 17 00:00:00 2001
From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com>
Date: Thu, 8 Oct 2026 01:24:24 -0400
Subject: [PATCH] fix(javascript): reconstruct HTML script loads accurately
(#1042)
* fix: parse HTML script references with HTML semantics
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
* test(javascript): cover exact HTML script resource identity
---------
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
Co-authored-by: N0zoM1z0 <1705004398@qq.com>
---
docs/javascript-artifact-reconstruction.md | 6 +
package-lock.json | 25 ++++
package.json | 1 +
.../javascript/JavaScriptArtifactAnalysis.ts | 28 ++--
.../JavaScriptArtifactGraphDocuments.ts | 32 ++++-
.../JavaScriptArtifactPathResolution.test.ts | 49 +++++++
.../JavaScriptArtifactPathResolution.ts | 11 ++
.../javascript/htmlArtifactReferences.test.ts | 70 +++++++++
.../javascript/htmlArtifactReferences.ts | 55 ++++++++
.../javascriptHtmlEntrypoints.test.ts | 87 ++++++++++++
.../javascriptHtmlResourcePaths.test.ts | 133 ++++++++++++++++++
11 files changed, 475 insertions(+), 22 deletions(-)
create mode 100644 src/domain/javascript/htmlArtifactReferences.test.ts
create mode 100644 src/domain/javascript/htmlArtifactReferences.ts
create mode 100644 tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts
create mode 100644 tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts
diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md
index 52038d16d..47eca1e8c 100644
--- a/docs/javascript-artifact-reconstruction.md
+++ b/docs/javascript-artifact-reconstruction.md
@@ -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.
diff --git a/package-lock.json b/package-lock.json
index e47340616..1bb44c60c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 8643ff966..e47a8fbed 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts
index 9422eacff..b390854e0 100644
--- a/src/application/javascript/JavaScriptArtifactAnalysis.ts
+++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts
@@ -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 = /', []],
+ ['', []],
+ [``, []],
+ ["", ["actual.js"]],
+ ['', ["actual.js"]],
+ ['', ["actual.js"]],
+ ["", ["actual.js"]],
+ ['', ["actual.js"]],
+ ['', ["actual.js"]],
+ ['', []],
+ ['', ["actual.js"]],
+ ['', []],
+ ['', []],
+ ['', []],
+ [``, []],
+ [
+ '',
+ [],
+ ],
+ ['', []],
+ [
+ '',
+ ["actual.js"],
+ ],
+ // The existing extraction contract does not classify script MIME types.
+ ['', ["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 = '`;
+ expect(htmlArtifactReferences(text)).toEqual({
+ baseHref: null,
+ scripts: [
+ {
+ scriptPath: "actual.js",
+ startOffset: prefix.length,
+ endOffset: prefix.length + opening.length,
+ },
+ ],
+ });
+});
+
+it.each([
+ ['', "actual/"],
+ ['', "actual/"],
+ ['', ""],
+ ['', "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 = `${"
".repeat(depth)}${"
".repeat(depth)}`;
+ expect(
+ htmlArtifactReferences(text).scripts.map(({ scriptPath }) => scriptPath),
+ ).toEqual(["actual.js"]);
+});
diff --git a/src/domain/javascript/htmlArtifactReferences.ts b/src/domain/javascript/htmlArtifactReferences.ts
new file mode 100644
index 000000000..fa9945e36
--- /dev/null
+++ b/src/domain/javascript/htmlArtifactReferences.ts
@@ -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 };
+};
diff --git a/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts b/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts
new file mode 100644
index 000000000..f25d7fb9e
--- /dev/null
+++ b/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts
@@ -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 = [
+ ['', []],
+ ['', []],
+ [``, []],
+ ["", ["actual.js"]],
+ ['', ["actual.js"]],
+ ['', ["actual.js"]],
+ ["", ["actual.js"]],
+ ['', ["actual.js"]],
+ ['', []],
+ ['', []],
+ ['', []],
+ ['', ["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 = '`;
+ 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 },
+ },
+ },
+ },
+ });
+});
diff --git a/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts b/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts
new file mode 100644
index 000000000..1edd18a86
--- /dev/null
+++ b/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts
@@ -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>;
+ expected: readonly string[];
+}[] = [
+ {
+ name: "extension fallback",
+ html: '',
+ files: { "app.js": "globalThis.owned = true;" },
+ expected: [],
+ },
+ {
+ name: "directory index",
+ html: '',
+ files: { "app/index.js": "globalThis.owned = true;" },
+ expected: [],
+ },
+ {
+ name: "directory package",
+ html: '',
+ files: {
+ "app/package.json": '{"main":"entry.js"}',
+ "app/entry.js": "globalThis.owned = true;",
+ },
+ expected: [],
+ },
+ {
+ name: "base directory",
+ html: '',
+ files: { "assets/app.js": "globalThis.owned = true;" },
+ expected: [],
+ },
+ {
+ name: "exact base resource",
+ html: '',
+ files: {
+ "assets/app.js": "globalThis.owned = true;",
+ "app.js": "globalThis.decoy = true;",
+ },
+ expected: ["assets/app.js"],
+ },
+ {
+ name: "exact script resource",
+ html: '',
+ 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,
+ });
+});