fix(javascript): preserve loader classification and exact source targets

This commit is contained in:
morluto
2026-10-24 02:30:47 +08:00
parent baf16784af
commit 75daf0c7df
4 changed files with 145 additions and 6 deletions
@@ -844,3 +844,12 @@ truncated under the `html_inline_script_locations_per_file` limit, and the
scope's evidence counts the omitted locations. The graph names the
file and up to 10 starting lines in its limitations, and coverage is partial. Import maps, speculation rules, data blocks, and blank
bodies are not JavaScript and are not reported.
For static source relationships, an explicitly requested declaration filename
(`.d.ts`, `.d.mts`, or `.d.cts`) retains its inventoried identity through both
`import` and `require`. A declaration found only as an emitted-extension fallback
remains unavailable as a runtime implementation. Package URL targets decode
filename escapes once, including dots within filenames; forbidden whole segments
and encoded separators remain rejected. ESM local-path classification uses the
authored request before URL whitespace preprocessing, so leading whitespace
cannot turn a bare request into a relative file dependency.
@@ -648,8 +648,8 @@ const contextualCandidate = (
"URL and Node builtin schemes are outside static artifact module resolution.",
]);
if (
!isRelativeJavaScriptModuleSpecifier(declared) &&
!declared.startsWith("/")
!isRelativeJavaScriptModuleSpecifier(input.declaredPath) &&
!input.declaredPath.startsWith("/")
)
return bareModuleCandidate(input);
if (input.moduleKind !== "require") {
@@ -1088,7 +1088,7 @@ const resolveLegacyEsmSubpath = (
subpath: string,
): CandidateResolution => {
const subpathPath = stripQueryAndFragment(htmlUrlText(subpath));
const declarationRejection = rejectDeclaration(subpathPath);
const declarationRejection = rejectDeclaration(subpathPath, true);
if (declarationRejection !== null) return declarationRejection;
let depth = directory === "." ? 0 : directory.split("/").length;
for (const segment of subpathPath.split("/")) {
@@ -1115,7 +1115,7 @@ const resolveLegacyEsmSubpath = (
const encodedPath = url.pathname.slice(
PACKAGE_ARTIFACT_ROOT_URL.pathname.length,
);
const rejected = rejectDeclaration(encodedPath);
const rejected = rejectDeclaration(encodedPath, true);
if (rejected !== null) return rejected;
const decoded = decodeModuleUrlPath(encodedPath);
if (typeof decoded !== "string") return decoded;
@@ -1201,7 +1201,7 @@ const resolveExportTarget = (
PACKAGE_ARTIFACT_ROOT_URL.pathname.length,
);
// Package targets are URLs for both loaders, so their percent bytes encode.
const rejected = rejectDeclaration(encodedPath, false);
const rejected = rejectDeclaration(encodedPath, true);
if (rejected !== null) return withTargetSource(rejected);
const decoded = decodeModuleUrlPath(encodedPath);
if (typeof decoded !== "string") return withTargetSource(decoded);
@@ -1483,7 +1483,15 @@ const resolveCandidate = (
emittedSourceCandidates: readonly TypeScriptEmittedSourceCandidate[] = [],
): CandidateResolution => {
if (!commonJsDirectoryRequest(input)) {
// An explicitly selected source path owns its identity independently of
// whether its declaration bytes can implement a runtime module.
const exactSource =
input.context === "module-specifier" &&
input.runtimePathKind === undefined
? resolveRuntimeSourceCandidates(input, candidate, [])
: null;
const file =
exactSource ??
resolveFileCandidates(
input,
fileCandidates(candidate, input.moduleKind),
@@ -0,0 +1,123 @@
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import {
analyzeJavaScriptDirectory,
runNodeJsonControl,
} from "../../support/cli/javascriptApplication.js";
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
cliTest.each(["CLI", "stdio MCP"] as const)(
"preserves module classification and explicit source identities through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-module-owner-semantics-");
const valid = {
"legacy/x%2Ey.js": "node_modules/legacy/x.y.js",
"mapped/encoded": "node_modules/mapped/x.y.js",
"mapped/double": "node_modules/mapped/x%2Ey.js",
"#encoded": "local.file.mjs",
"./dep.mjs ": "dep.mjs",
"./de\np.mjs": "dep.mjs",
};
const invalid = [" ./dep.mjs", "\n./dep.mjs", "\t./dep.mjs", ".\n/dep.mjs"];
await writeFixtureFiles(root, {
"package.json": JSON.stringify({
type: "module",
imports: { "#encoded": "./local%2Efile.mjs" },
}),
"main.mjs": [
...Object.keys(valid),
...invalid,
"mapped/missing",
"mapped/invalid",
"./dep.d.ts",
"./cfg.d.mts",
"./legacy.d.cts",
]
.map((specifier) => `import ${JSON.stringify(specifier)};`)
.join("\n"),
"main.cjs": [
"./dep.d.ts",
"./cfg.d.mts",
"./legacy.d.cts",
"mapped/encoded",
"mapped/double",
]
.map((specifier) => `require(${JSON.stringify(specifier)});`)
.join("\n"),
"fallback.cts": 'require("./only.js");',
"dep.d.ts": "export declare const value: number;",
"cfg.d.mts": "export declare const value: number;",
"legacy.d.cts": "export declare const value: number;",
"only.d.ts": "export declare const value: number;",
"dep.mjs": 'throw new Error("must never load");',
"local.file.mjs": 'throw new Error("must never load");',
"node_modules/legacy/package.json": '{"type":"module"}',
"node_modules/legacy/x.y.js": 'throw new Error("must never load");',
"node_modules/mapped/package.json": JSON.stringify({
type: "module",
exports: {
"./encoded": "./x%2Ey.js",
"./double": "./x%252Ey.js",
"./missing": ["./absent%2Efile.js", "./x.y.js"],
"./invalid": "./%2e%2e/escape.js",
},
}),
"node_modules/mapped/x.y.js": 'throw new Error("must never load");',
"node_modules/mapped/x%2Ey.js": 'throw new Error("must never load");',
"oracle.mjs": `import {fileURLToPath} from "node:url"; import {relative} from "node:path"; const valid = ${JSON.stringify(Object.keys(valid))}; const invalid = ${JSON.stringify(invalid)}; console.log(JSON.stringify({ valid: valid.map(request => relative(process.cwd(), fileURLToPath(import.meta.resolve(request)))), invalid: invalid.map(request => { try { import.meta.resolve(request); return false; } catch { return true; } }) }));`,
});
expect(await runNodeJsonControl(root, join(root, "oracle.mjs"))).toEqual({
valid: Object.values(valid),
invalid: invalid.map(() => true),
});
const result = await analyzeJavaScriptDirectory(surface, cli, root);
expect(result.statistics.parse_failures).toBe(0);
const edges = (specifier: string) =>
result.graph.edges.filter(
(edge) =>
edge.relation === "imports" &&
edge.properties.specifier === specifier,
);
for (const [specifier, target] of Object.entries(valid)) {
const matches = edges(specifier);
expect(matches.length, specifier).toBeGreaterThan(0);
for (const edge of matches)
expect(edge.properties).toMatchObject({
resolution_status: "resolved",
resolved_path: target,
});
}
for (const specifier of invalid) {
const matches = edges(specifier);
expect(matches.length, specifier).toBeGreaterThan(0);
for (const edge of matches)
expect(edge.properties.resolved_path).toBeNull();
}
for (const suffix of ["dep.d.ts", "cfg.d.mts", "legacy.d.cts"]) {
const matches = edges(`./${suffix}`);
expect(matches.length, suffix).toBeGreaterThan(0);
for (const edge of matches)
expect(edge.properties).toMatchObject({
resolution_status: "resolved",
resolved_path: suffix,
});
}
for (const [specifier, status] of [
["./only.js", "unavailable"],
["mapped/missing", "not-found"],
["mapped/invalid", "rejected"],
] as const) {
const matches = edges(specifier);
expect(matches.length, specifier).toBeGreaterThan(0);
for (const edge of matches)
expect(edge.properties).toMatchObject({
resolution_status: status,
resolved_path: null,
});
}
},
);
@@ -342,7 +342,6 @@ describe("package exports URL path rejections", () => {
"./actual%00file.cjs",
"./actual%2Ffile.cjs",
"./actual%5Cfile.cjs",
"./actual%2Efile.cjs",
"./../../../../escape.cjs",
].map((declaredPath) => ({ moduleKind, declaredPath })),
),