Files
rea/src/application/javascript/JavaScriptArtifactPathResolution.ts
T
Jay HemnaniandCursor Agent 45fcd28b40 fix(javascript): resolve bare source-map and worker URLs next to the script (#890)
* fix(javascript): resolve bare source-map and worker URLs next to the script

A sourceMappingURL like `main.js.map` (the default of webpack, esbuild,
rollup, Vite and rolldown) and `new Worker("worker.js")` were resolved as
module specifiers, so the bare name was looked up as an npm package and the
map or worker script was never linked. Resolve them as URLs relative to the
declaring script.

* fix(javascript): tighten url-reference resolution edge cases

Use looksExternal for non-module contexts so protocol-relative URLs
cannot be misread as root-absolute paths, cover url-reference unit
cases, and assert bare service-worker script resolution.

---------

Co-authored-by: Cursor Agent <[email protected]>
2026-10-07 19:04:15 +08:00

578 lines
19 KiB
TypeScript

import { builtinModules } from "node:module";
import { posix } from "node:path";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
admitsCanonicalPathSyntax,
hasScheme,
looksExternal,
stripQueryAndFragment,
} from "../../domain/artifactPathSyntax.js";
type ArtifactPathResolutionContext =
| "package-entrypoint"
| "filesystem-expression"
| "module-specifier"
| "html-reference"
| "url-reference";
type UnresolvedArtifactPathStatus =
| "not-found"
| "unavailable"
| "external"
| "rejected";
interface ArtifactPathResolutionBase {
readonly declared_path: string;
readonly resolution_context: ArtifactPathResolutionContext;
readonly limitations: readonly string[];
}
/** Explicit outcome of artifact-confined path resolution. */
export type ArtifactPathResolution = ArtifactPathResolutionBase &
(
| {
readonly resolved_path: string;
readonly resolution_status: "resolved";
}
| {
readonly resolved_path: null;
readonly resolution_status: UnresolvedArtifactPathStatus;
}
);
/** Values needed to resolve a declaration without filesystem access. */
export interface ResolveArtifactPathInput {
readonly declaredPath: string;
readonly sourcePath: string;
readonly context: ArtifactPathResolutionContext;
readonly files: ReadonlyMap<string, JavaScriptArtifactFile>;
readonly htmlBaseHref?: string | null;
readonly moduleKind?: "import" | "require";
}
const EXTENSIONS = [
".js",
".cjs",
".mjs",
".ts",
".tsx",
".json",
".html",
".node",
];
const NODE_BUILTINS = new Set(
builtinModules.map((name) => name.replace(/^node:/u, "")),
);
type CandidateResolution =
| {
readonly resolvedPath: string;
readonly status: "resolved";
readonly limitations: readonly string[];
}
| {
readonly resolvedPath: null;
readonly status: UnresolvedArtifactPathStatus;
readonly limitations: readonly string[];
};
/** Resolve one declaration under its exact syntax/metadata context. */
export const resolveArtifactPathByContext = (
input: ResolveArtifactPathInput,
): ArtifactPathResolution => resolvePackagePath(input, new Set());
const resolvePackagePath = (
input: ResolveArtifactPathInput,
packageChain: ReadonlySet<string>,
mode: "full" | "files-and-index" = "full",
): ArtifactPathResolution => {
const rejected = rejectDeclaration(input);
if (rejected !== null) return rejected;
const candidate = contextualCandidate(input);
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(input, candidate);
if (typeof confined !== "string") return confined;
const resolved =
mode === "files-and-index"
? (resolveFileCandidates(input, [
...fileCandidates(confined),
...indexCandidates(confined),
]) ?? notFoundCandidate())
: resolveCandidate(input, confined, packageChain);
return outcome(input, resolved);
};
const rejectDeclaration = (
input: ResolveArtifactPathInput,
): ArtifactPathResolution | null => {
const declared = input.declaredPath;
if (declared.length === 0)
return unresolvedOutcome(input, "rejected", [
"The declared path is empty.",
]);
if (declared.includes("\0") || declared.includes("\\"))
return unresolvedOutcome(input, "rejected", [
"NUL and backslash path syntax are not admitted for canonical artifact paths.",
]);
if (!admitsCanonicalPathSyntax(declared))
return unresolvedOutcome(input, "rejected", [
"Encoded dot or separator bytes are rejected before artifact path resolution.",
]);
return null;
};
const contextualCandidate = (
input: ResolveArtifactPathInput,
): string | ArtifactPathResolution => {
const { context } = input;
const declared =
context === "html-reference" ||
context === "url-reference" ||
(context === "module-specifier" && input.moduleKind !== "require")
? stripQueryAndFragment(input.declaredPath)
: input.declaredPath;
if (context === "html-reference") return htmlCandidate(input);
if (context === "module-specifier") {
const fileUrl = fileUrlPath(declared);
if (fileUrl !== undefined) return fileUrl;
if (hasScheme(declared))
return unresolvedOutcome(input, "external", [
"URL and Node builtin schemes are outside static artifact module resolution.",
]);
if (!declared.startsWith(".") && !declared.startsWith("/"))
return bareModuleCandidate(input, declared);
} else if (looksExternal(declared))
return unresolvedOutcome(input, "external", [
"URL schemes and protocol-relative URLs are outside this local artifact path context.",
]);
const relative = declared.startsWith("/") ? declared.slice(1) : declared;
return declared.startsWith("/")
? relative
: posix.join(posix.dirname(input.sourcePath), relative);
};
const bareModuleCandidate = (
input: ResolveArtifactPathInput,
declared: string,
): string | ArtifactPathResolution => {
const packageName = barePackageName(declared);
if (
packageName === null ||
NODE_BUILTINS.has(packageName) ||
declared.startsWith("#")
)
return unresolvedOutcome(input, "external", [
"The bare specifier is a Node builtin, package import map, or invalid package name.",
]);
if (declared !== packageName)
return unresolvedOutcome(input, "external", [
"Bare package subpaths remain unresolved unless an exact package-exports subpath model is available.",
]);
const source = input.files.get(input.sourcePath);
let directory = posix.dirname(input.sourcePath);
while (true) {
const candidate = posix.join(directory, "node_modules", declared);
if (hasContainerCandidate(input.files, candidate, source?.container_sha256))
return candidate;
if (directory === "." || directory === "") break;
directory = posix.dirname(directory);
}
return unresolvedOutcome(input, "external", [
"No matching bare package was inventoried in an enclosing node_modules directory.",
]);
};
const barePackageName = (specifier: string): string | null => {
const segments = specifier.split("/");
if (specifier.startsWith("@"))
return segments.length >= 2 && segments[0] !== "" && segments[1] !== ""
? `${segments[0]}/${segments[1]}`
: null;
return segments[0] === "" ? null : (segments[0] ?? null);
};
const hasContainerCandidate = (
files: ReadonlyMap<string, JavaScriptArtifactFile>,
candidate: string,
containerSha256: string | undefined,
): boolean =>
[
...fileCandidates(candidate),
...indexCandidates(candidate),
posix.join(candidate, "package.json"),
].some((path) => {
const file = files.get(path);
return (
file !== undefined &&
(containerSha256 === undefined ||
file.container_sha256 === containerSha256)
);
});
const htmlCandidate = (
input: ResolveArtifactPathInput,
): string | ArtifactPathResolution => {
const declared = stripQueryAndFragment(input.declaredPath);
if (looksExternal(declared))
return unresolvedOutcome(input, "external", [
"External HTML references are not mapped to local artifact assets.",
]);
const rawBase = input.htmlBaseHref;
const base =
rawBase === undefined || rawBase === null
? rawBase
: stripQueryAndFragment(rawBase);
if (base !== undefined && base !== null && looksExternal(base))
return unresolvedOutcome(input, "external", [
"The document base href is external, so its script reference is not a local artifact path.",
]);
if (declared.startsWith("/")) return declared.slice(1);
if (base === undefined || base === null || base === "")
return posix.join(posix.dirname(input.sourcePath), declared);
// A local base href is a second untrusted path input; apply the same
// admission rules a declared path gets so it cannot smuggle traversal or
// separator syntax past canonicalization.
if (!admitsCanonicalPathSyntax(base))
return unresolvedOutcome(input, "rejected", [
"The document base href uses NUL, backslash, or encoded dot and separator bytes that are not admitted for canonical artifact paths.",
]);
const basePath = base.startsWith("/")
? base.slice(1)
: posix.join(posix.dirname(input.sourcePath), base);
return posix.join(htmlBaseDirectory(base, basePath), declared);
};
/**
* The directory a relative HTML reference resolves against. A base path
* ending in "/" or in a "." or ".." segment is already a directory, because a
* relative reference resolves against the base URL's directory and those
* segments are dropped rather than stepped through.
*/
const htmlBaseDirectory = (base: string, basePath: string): string =>
base.endsWith("/") ||
base.endsWith("/.") ||
base.endsWith("/..") ||
base === "." ||
base === ".."
? basePath
: posix.dirname(basePath);
const confineCandidate = (
input: ResolveArtifactPathInput,
candidate: string,
): string | ArtifactPathResolution => {
const normalized = posix.normalize(candidate);
if (
normalized === ".." ||
normalized.startsWith("../") ||
normalized.startsWith("/")
)
return unresolvedOutcome(input, "rejected", [
"The resolved candidate escapes the canonical artifact root.",
]);
return normalized === "." ? "" : normalized;
};
const resolveCandidate = (
input: ResolveArtifactPathInput,
candidate: string,
packageChain: ReadonlySet<string>,
): CandidateResolution => {
const direct = resolveFileCandidates(input, fileCandidates(candidate));
if (direct !== null) return direct;
const source = input.files.get(input.sourcePath);
const packagePath = posix.join(candidate, "package.json");
const packageFile = input.files.get(packagePath);
if (
packageFile === undefined ||
(source !== undefined &&
packageFile.container_sha256 !== source.container_sha256)
)
return (
resolveFileCandidates(input, indexCandidates(candidate)) ??
notFoundCandidate()
);
if (!packageFile.text.included)
return {
resolvedPath: null,
status: "unavailable",
limitations: [
`Directory package metadata ${packagePath} was inventoried but its text is unavailable: ${packageFile.text.reason}.`,
],
};
const main = packageEntry(
packageFile.text.value,
input.moduleKind,
input.context === "module-specifier" &&
!input.declaredPath.startsWith(".") &&
!input.declaredPath.startsWith("/") &&
!hasScheme(input.declaredPath),
);
if (main.status === "invalid")
return {
resolvedPath: null,
status: "unavailable",
limitations: [
`Directory package metadata ${packagePath} is not valid package JSON.`,
],
};
if (main.status === "unmatched")
return {
resolvedPath: null,
status: "external",
limitations: [
`Directory package metadata ${packagePath} declares no exports target for the active conditions; it declares ${main.declared.join(", ")}.`,
],
};
if (packageChain.has(packagePath)) {
return {
resolvedPath: null,
status: "unavailable",
limitations: [
`Directory package entrypoint cycle includes ${packagePath}.`,
],
};
}
if (main.status === "missing")
return (
resolveFileCandidates(input, indexCandidates(candidate)) ??
notFoundCandidate()
);
const entryInput: ResolveArtifactPathInput = {
declaredPath: main.value,
sourcePath: packagePath,
context: "package-entrypoint",
files: input.files,
};
const chain = new Set([...packageChain, packagePath]);
if (main.source === "legacy") {
const entry = resolvePackagePath(entryInput, chain, "files-and-index");
if (entry.resolution_status !== "not-found") return candidateOutcome(entry);
const index = resolveFileCandidates(input, indexCandidates(candidate));
if (index !== null) return index;
}
// Preserve recursive artifact package lookup only after legacy file/index fallbacks.
return candidateOutcome(resolvePackagePath(entryInput, chain));
};
const candidateOutcome = (
nested: ArtifactPathResolution,
): CandidateResolution =>
nested.resolution_status === "resolved"
? {
resolvedPath: nested.resolved_path,
status: nested.resolution_status,
limitations: nested.limitations,
}
: {
resolvedPath: null,
status: nested.resolution_status,
limitations: nested.limitations,
};
const resolveFileCandidates = (
input: ResolveArtifactPathInput,
candidates: readonly string[],
): CandidateResolution | null => {
const source = input.files.get(input.sourcePath);
for (const path of candidates) {
const target = input.files.get(path);
if (
target !== undefined &&
(source === undefined ||
target.container_sha256 === source.container_sha256)
)
return { resolvedPath: path, status: "resolved", limitations: [] };
}
return null;
};
const fileCandidates = (candidate: string): readonly string[] => [
candidate,
...EXTENSIONS.map((extension) => `${candidate}${extension}`),
];
const indexCandidates = (candidate: string): readonly string[] =>
EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`));
const packageEntry = (
text: string,
moduleKind: ResolveArtifactPathInput["moduleKind"],
useExports: boolean,
):
| {
readonly status: "value";
readonly value: string;
readonly source: "legacy" | "exports";
}
| { readonly status: "missing" }
| { readonly status: "invalid" }
| { readonly status: "unmatched"; readonly declared: readonly string[] } => {
try {
const value: unknown = JSON.parse(text);
if (typeof value !== "object" || value === null)
return { status: "invalid" };
const rawExports = Reflect.get(value, "exports");
if (useExports && rawExports !== undefined && rawExports !== null) {
const exported = packageExport(rawExports, moduleKind);
return exported.status === "value"
? { ...exported, source: "exports" }
: exported;
}
const preferred =
moduleKind === "import"
? [Reflect.get(value, "module"), Reflect.get(value, "main")]
: [Reflect.get(value, "main"), Reflect.get(value, "module")];
const entry = preferred.find((candidate) => candidate !== undefined);
if (entry === undefined) return { status: "missing" };
const legacy = packagePathValue(entry);
return legacy.status === "value" ? { ...legacy, source: "legacy" } : legacy;
} catch (cause: unknown) {
// Reflect-based manifest reads fail closed as invalid.
void cause;
return { status: "invalid" };
}
};
const packageExport = (
value: unknown,
moduleKind: ResolveArtifactPathInput["moduleKind"],
): PackageExportOutcome => {
if (typeof value === "string") return packagePathValue(value);
if (typeof value !== "object" || value === null || Array.isArray(value))
return { status: "invalid" };
const root = Reflect.get(value, ".") ?? value;
if (typeof root === "string") return packagePathValue(root);
if (typeof root !== "object" || root === null || Array.isArray(root))
return { status: "invalid" };
const flattened = exportTargets(root, packageExportConditions(moduleKind));
if (flattened.kind === "invalid") return { status: "invalid" };
const first =
flattened.kind === "unmatched" ? undefined : flattened.values[0];
return first === undefined
? { status: "unmatched", declared: Object.keys(root) }
: { status: "value", value: first };
};
type ExportTargets =
| { readonly kind: "targets"; readonly values: readonly string[] }
| { readonly kind: "unmatched" }
| { readonly kind: "invalid" };
/**
* Resolve a target in Node's declared order. Explicit null blocks an active
* condition, whereas an unmatched nested object permits the next condition.
* An array skips unmatched and invalid entries, stops at its first string, and
* preserves the final invalid-versus-null refusal if no string is selected.
*/
const exportTargets = (
value: unknown,
conditions: ReadonlySet<string>,
): ExportTargets => {
if (typeof value === "string")
return value.startsWith("./")
? { kind: "targets", values: [value] }
: { kind: "invalid" };
if (value === null) return { kind: "targets", values: [] };
if (Array.isArray(value)) {
let invalid = false;
for (const entry of value) {
const nested = exportTargets(entry, conditions);
if (nested.kind === "invalid") {
invalid = true;
continue;
}
if (nested.kind === "unmatched") continue;
invalid = false;
if (nested.values.length > 0) return nested;
}
return invalid ? { kind: "invalid" } : { kind: "targets", values: [] };
}
if (typeof value !== "object") return { kind: "invalid" };
for (const [condition, target] of Object.entries(value)) {
if (!conditions.has(condition)) continue;
const nested = exportTargets(target, conditions);
if (nested.kind !== "unmatched") return nested;
}
return { kind: "unmatched" };
};
/**
* Node selects an exports target by walking the declared keys in order and
* taking the first whose condition is active for the calling resolver.
* "default" is always active, and "node" plus "node-addons" are active for the
* built-in resolver that owns installed-package imports and requires.
*/
const packageExportConditions = (
moduleKind: ResolveArtifactPathInput["moduleKind"],
): ReadonlySet<string> =>
new Set([
"node",
"node-addons",
...(moduleKind === undefined ? [] : [moduleKind]),
"default",
]);
type PackageExportOutcome =
| { readonly status: "value"; readonly value: string }
| { readonly status: "invalid" }
| { readonly status: "unmatched"; readonly declared: readonly string[] };
const packagePathValue = (
value: unknown,
):
| { readonly status: "value"; readonly value: string }
| { readonly status: "invalid" } =>
typeof value === "string" && value.length > 0
? { status: "value", value }
: { status: "invalid" };
const notFoundCandidate = (): CandidateResolution => ({
resolvedPath: null,
status: "not-found",
limitations: [
"No extension, directory package, or index candidate exists in the inventoried artifact container.",
],
});
const fileUrlPath = (value: string): string | undefined => {
if (!value.startsWith("file://")) return undefined;
try {
const url = new URL(value);
if (url.protocol !== "file:" || url.hostname !== "") return undefined;
return decodeURIComponent(url.pathname).replace(/^\/+/, "");
} catch (cause: unknown) {
// Unparseable file URLs have no path to resolve.
void cause;
return undefined;
}
};
const outcome = (
input: ResolveArtifactPathInput,
resolution: CandidateResolution,
): ArtifactPathResolution =>
resolution.status === "resolved"
? {
declared_path: input.declaredPath,
resolution_context: input.context,
resolved_path: resolution.resolvedPath,
resolution_status: resolution.status,
limitations: resolution.limitations,
}
: {
declared_path: input.declaredPath,
resolution_context: input.context,
resolved_path: null,
resolution_status: resolution.status,
limitations: resolution.limitations,
};
const unresolvedOutcome = (
input: ResolveArtifactPathInput,
status: UnresolvedArtifactPathStatus,
limitations: readonly string[],
): ArtifactPathResolution =>
outcome(input, { resolvedPath: null, status, limitations });