diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 75d1079415..21ae157f6e 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -435,9 +435,10 @@ const parseHtmlScripts = ( const { scripts, inlineScripts, inlineScriptCount, baseHref } = htmlArtifactReferences(text); return { - scripts: scripts.map(({ scriptPath, location }) => ({ + scripts: scripts.map(({ scriptPath, scriptType, location }) => ({ html_path: path, script_path: scriptPath, + script_type: scriptType, base_href: baseHref, location, })), diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index 840ca83111..144e4af58f 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -53,6 +53,8 @@ export type JavaScriptPackageObservation = JavaScriptStructuredObservation & export interface JavaScriptHtmlScriptObservation { readonly html_path: string; readonly script_path: string; + /** Parsed JavaScript type; null for data blocks or other non-JavaScript types. */ + readonly script_type: "classic" | "module" | null; readonly base_href: string | null; readonly location: JavaScriptSourceRange; } diff --git a/src/application/javascript/JavaScriptArtifactGraphDocuments.ts b/src/application/javascript/JavaScriptArtifactGraphDocuments.ts index 7f8e9e7edf..ad3a3792f3 100644 --- a/src/application/javascript/JavaScriptArtifactGraphDocuments.ts +++ b/src/application/javascript/JavaScriptArtifactGraphDocuments.ts @@ -19,6 +19,8 @@ export const addJavaScriptHtmlRoles = ( ): void => { const byHtml = new Map(); for (const script of context.analysis.html_scripts) { + if (script.script_type !== "classic" && script.script_type !== "module") + continue; const existing = byHtml.get(script.html_path); byHtml.set( script.html_path, diff --git a/src/domain/javascript/htmlArtifactReferences.test.ts b/src/domain/javascript/htmlArtifactReferences.test.ts index c9855feb10..7dbf2f363f 100644 --- a/src/domain/javascript/htmlArtifactReferences.test.ts +++ b/src/domain/javascript/htmlArtifactReferences.test.ts @@ -30,8 +30,13 @@ it.each([ '', ["actual.js"], ], - // The existing extraction contract does not classify script MIME types. ['', ["actual.js"]], + ['', ["actual.js"]], + ['', ["actual.js"]], + ['', ["actual.js"]], + ['', ["actual.js"]], + ['', ["actual.js"]], + ['', ["actual.js"]], ])("extracts document references from %s", (text, expected) => { expect( htmlArtifactReferences(text).scripts.map(({ scriptPath }) => scriptPath), @@ -49,6 +54,7 @@ it("retains exact opening-tag coordinates rather than the script body", () => { scripts: [ { scriptPath: "actual.js", + scriptType: "classic", location: { start: { line: 3, column: 0 }, end: { line: 4, column: ' title=">" src=actual.js>'.length }, @@ -143,3 +149,21 @@ it("counts every inline body but retains bounded locations", () => { }, }); }); + +it.each([ + ['type=" Text/JavaScript "', "classic"], + ['type="MODULE"', "module"], + ['type="application/json"', null], + ['type="importmap"', null], + ['type="speculationrules"', null], + ['language="vbscript"', null], + ['type=" module "', null], +])( + "retains external src with parsed JavaScript type for %s", + (attributes, scriptType) => { + expect( + htmlArtifactReferences(``) + .scripts, + ).toMatchObject([{ scriptPath: "source.js", scriptType }]); + }, +); diff --git a/src/domain/javascript/htmlArtifactReferences.ts b/src/domain/javascript/htmlArtifactReferences.ts index 10a52f21a9..0edb22fb69 100644 --- a/src/domain/javascript/htmlArtifactReferences.ts +++ b/src/domain/javascript/htmlArtifactReferences.ts @@ -11,6 +11,8 @@ import type { interface HtmlScriptReference { readonly scriptPath: string; + /** Null for data blocks and other non-JavaScript script types. */ + readonly scriptType: HtmlInlineScript["scriptType"] | null; /** The opening tag's range in parse5 coordinates. */ readonly location: JavaScriptSourceRange; } @@ -54,7 +56,7 @@ const JAVASCRIPT_MIME_TYPES = new Set([ * MIME check; `module` must match the type string exactly, ignoring ASCII * case. Import maps, speculation rules and data blocks are not JavaScript. */ -const inlineScriptType = ( +const javaScriptType = ( attrs: readonly { readonly name: string; readonly value: string }[], ): HtmlInlineScript["scriptType"] | null => { const type = attrs.find(({ name }) => name === "type"); @@ -110,11 +112,13 @@ export const htmlArtifactReferences = ( if (href !== undefined) baseHref = href.value; } if (node.tagName === "script") { + const scriptType = javaScriptType(node.attrs); 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, + scriptType, location: { start: sourcePoint(location.startLine, location.startCol), end: sourcePoint(location.endLine, location.endCol), @@ -122,8 +126,6 @@ export const htmlArtifactReferences = ( }); } const inline = src === undefined ? inlineScriptBody(node) : undefined; - const scriptType = - inline === undefined ? null : inlineScriptType(node.attrs); if (inline !== undefined && scriptType !== null) { inlineScriptCount += 1; if (inlineScripts.length < HTML_INLINE_SCRIPT_LOCATION_LIMIT) diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationHtmlScriptTypes.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationHtmlScriptTypes.test.ts new file mode 100644 index 0000000000..3e94c1049e --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationHtmlScriptTypes.test.ts @@ -0,0 +1,78 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; +import { analyzeArtifactFiles } from "../../../fixtures/javascriptArtifactAnalysis.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; + +it("does not infer renderer loads from external data blocks or import maps", async () => { + const root = await createTestTempDirectory("rea-html-script-types-"); + const declarations = [ + ["classic.js", 'type=" Text/JavaScript "'], + ["module.js", 'type="module"'], + ["data.js", 'type="application/json"'], + ["imports.js", 'type="importmap"'], + ["rules.js", 'type="speculationrules"'], + ["unsupported.js", 'language="vbscript"'], + ] as const; + const html = declarations + .map( + ([path, attributes]) => ``, + ) + .join("\n"); + await Promise.all([ + writeFile(join(root, "index.html"), html), + ...declarations.map(([path]) => writeFile(join(root, path), "export {};")), + ]); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + const analysis = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + expect( + analysis.graph.edges + .filter( + ({ relation, properties }) => + relation === "loads" && typeof properties.script_path === "string", + ) + .map(({ properties }) => properties.script_path) + .sort(), + ).toEqual(["classic.js", "module.js"]); + const sources = await analyzeArtifactFiles({ + files: [ + { + path: "index.html", + container_sha256: "a".repeat(64), + sha256: "b".repeat(64), + bytes: Buffer.byteLength(html), + inventory_artifact_id: "html_fixture", + kind: "html", + unpacked: false, + text: { included: true, value: html }, + }, + ], + unavailable_relevant_files: [], + containers: [], + text_bytes_read: Buffer.byteLength(html), + invalid_utf8_files: 0, + }); + expect( + sources.analysis.html_scripts.map(({ script_path, script_type }) => [ + script_path, + script_type, + ]), + ).toEqual([ + ["classic.js", "classic"], + ["module.js", "module"], + ["data.js", null], + ["imports.js", null], + ["rules.js", null], + ["unsupported.js", null], + ]); +});