From 1f122c02b71dae7f4109f35ed834f82b96fbf247 Mon Sep 17 00:00:00 2001
From: Yann Roberto <165009858+shoyann@users.noreply.github.com>
Date: Wed, 14 Oct 2026 06:41:22 +0800
Subject: [PATCH] fix(javascript): exclude HTML URL suffixes from path
admission (#2833)
---
.../JavaScriptArtifactPathResolution.ts | 19 +--
.../javascriptHtmlUrlSuffixesCli.test.ts | 136 ++++++++++++++++++
2 files changed, 146 insertions(+), 9 deletions(-)
create mode 100644 tests/acceptance/applications/javascriptHtmlUrlSuffixesCli.test.ts
diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts
index 04172cf65..f56623f2e 100644
--- a/src/application/javascript/JavaScriptArtifactPathResolution.ts
+++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts
@@ -376,8 +376,11 @@ const resolveRuntimeSourceCandidates = (
return null;
};
-const rejectDeclaration = (declared: string): CandidateResolution | null => {
- if (declared.length === 0)
+const rejectDeclaration = (
+ declared: string,
+ allowEmptyPath = false,
+): CandidateResolution | null => {
+ if (declared.length === 0 && !allowEmptyPath)
return unresolvedCandidate("rejected", ["The declared path is empty."]);
if (declared.includes("\0") || declared.includes("\\"))
return unresolvedCandidate("rejected", [
@@ -394,6 +397,7 @@ const contextualCandidate = (
input: ResolveArtifactPathInput,
): string | CandidateResolution => {
const { context } = input;
+ if (context === "html-reference") return htmlCandidate(input);
// URL suffixes are evidence, not pathname bytes. Admit only the path while
// retaining the complete declaration on the resolution result.
let declared =
@@ -426,7 +430,6 @@ const contextualCandidate = (
}
const rejected = rejectDeclaration(declared);
if (rejected !== null) return rejected;
- if (context === "html-reference") return htmlCandidate(input);
if (context === "module-specifier") {
if (hasScheme(declared))
return unresolvedCandidate("external", [
@@ -907,6 +910,10 @@ const htmlCandidate = (
input: ResolveArtifactPathInput,
): string | CandidateResolution => {
const declared = stripQueryAndFragment(htmlUrlText(input.declaredPath));
+ // HTML owns URL-text normalization and pathname admission. A nonempty URL
+ // reference may have no pathname; suffix bytes remain declaration evidence.
+ const rejected = rejectDeclaration(declared, input.declaredPath !== "");
+ if (rejected !== null) return rejected;
if (looksExternal(declared))
return unresolvedCandidate("external", [
"External HTML references are not mapped to local artifact assets.",
@@ -920,12 +927,6 @@ const htmlCandidate = (
return unresolvedCandidate("external", [
"The document base href is external, so its script reference is not a local artifact path.",
]);
- // Removing URL tabs and newlines can join an encoded dot or separator that
- // the raw declaration split, so admit the parsed URL text again.
- if (!admitsCanonicalPathSyntax(declared))
- return unresolvedCandidate("rejected", [
- "Encoded dot or separator bytes are rejected before artifact path resolution.",
- ]);
const declaredPath = percentDecodeUrlPath(declared);
if (declaredPath === null)
return unresolvedCandidate("rejected", [
diff --git a/tests/acceptance/applications/javascriptHtmlUrlSuffixesCli.test.ts b/tests/acceptance/applications/javascriptHtmlUrlSuffixesCli.test.ts
new file mode 100644
index 000000000..88e94144f
--- /dev/null
+++ b/tests/acceptance/applications/javascriptHtmlUrlSuffixesCli.test.ts
@@ -0,0 +1,136 @@
+import { expect } from "vitest";
+
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import { cliTest } from "../../support/cli/cliFixture.js";
+import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
+import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
+
+cliTest.for(["CLI", "stdio MCP"] as const)(
+ "admits HTML script pathnames without treating URL suffixes as paths through %s",
+ async (surface, { cli }) => {
+ const root = await createTestTempDirectory("rea-html-url-suffixes-");
+ const cases = [
+ {
+ source: "query.html",
+ script: "./app.js?redirect=%2Ffoo",
+ resolved: "app.js",
+ },
+ {
+ source: "fragment.html",
+ script: "./app.js#route=%2Ffoo",
+ resolved: "app.js",
+ },
+ {
+ source: "suffix-bytes.html",
+ script: "./app.js?value=%2e%5c%00%ff\\tail",
+ resolved: "app.js",
+ },
+ {
+ source: "url-text.html",
+ script: " \n./a\tpp.js?redirect=%2\nfassets ",
+ resolved: "app.js",
+ },
+ {
+ source: "self.html",
+ script: "?redirect=%2Ffoo",
+ resolved: "self.html",
+ },
+ {
+ source: "self-fragment.html",
+ script: "#route=%5Cfoo",
+ resolved: "self-fragment.html",
+ },
+ {
+ source: "base.html",
+ base: "assets/?redirect=%2Froot",
+ script: "app.js#route=%2Ffoo",
+ resolved: "assets/app.js",
+ },
+ {
+ source: "base-self.html",
+ base: "assets/app.js?redirect=%5Croot",
+ script: "?redirect=%2Ffoo",
+ resolved: "assets/app.js",
+ },
+ {
+ source: "path-separator.html",
+ script: "./assets%2Fapp.js?valid=1",
+ resolved: null,
+ },
+ { source: "path-dot.html", script: "./%2e/app.js#valid", resolved: null },
+ {
+ source: "path-backslash.html",
+ script: "./assets\\app.js?valid=1",
+ resolved: null,
+ },
+ {
+ source: "path-nul.html",
+ script: "./app%00.js?valid=1",
+ resolved: null,
+ },
+ {
+ source: "split-separator.html",
+ script: "./assets%2\nfapp.js?valid=1",
+ resolved: null,
+ },
+ {
+ source: "base-separator.html",
+ base: "assets%2F?valid=1",
+ script: "app.js?redirect=%2Ffoo",
+ resolved: null,
+ },
+ ];
+ await writeFixtureFiles(root, {
+ ...Object.fromEntries(
+ cases.map(({ source, base, script }) => [
+ source,
+ `${base === undefined ? "" : ``}`,
+ ]),
+ ),
+ "app.js": "globalThis.rootApp = true;",
+ "assets/app.js": "globalThis.nestedApp = true;",
+ });
+
+ const result = await analyzeJavaScriptDirectory(surface, cli, root);
+ const loads = result.graph.edges.filter(
+ ({ relation }) => relation === "loads",
+ );
+ const unresolved = result.graph.nodes
+ .flatMap(({ observations }) => observations)
+ .filter(
+ ({ properties }) => properties.mechanism === "html-script-reference",
+ );
+ for (const { source, base, script, resolved } of cases) {
+ const load = loads.find(
+ ({ evidence }) =>
+ evidence.location.available &&
+ evidence.location.value.kind === "source-range" &&
+ evidence.location.value.source === source,
+ );
+ if (resolved === null) {
+ expect(
+ unresolved.find(
+ ({ properties }) => properties.script_path === script,
+ ),
+ ).toMatchObject({
+ properties: {
+ script_path: script,
+ resolved_path: null,
+ resolution_status: "rejected",
+ },
+ });
+ expect(load).toBeUndefined();
+ continue;
+ }
+ expect(load).toMatchObject({
+ properties: {
+ script_path: script,
+ base_href: base ?? null,
+ resolved_path: resolved,
+ resolution_context: "html-reference",
+ resolution_status: "resolved",
+ },
+ });
+ }
+ },
+);