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>
This commit is contained in:
Rudy Mizrahi Celekli
2026-10-08 01:24:24 -04:00
committed by GitHub
parent 53409ab196
commit 1154f2f03c
11 changed files with 475 additions and 22 deletions
@@ -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, [