fix(javascript): share TypeScript emitted import resolution

This commit is contained in:
morluto
2026-10-12 01:31:32 +08:00
parent 486b56724c
commit 1dfb933e47
5 changed files with 217 additions and 23 deletions
@@ -18,6 +18,7 @@ import type {
ReferenceSourceRead,
} from "../reference/ReferenceSourceReaderTypes.js";
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
export interface ParsedReferenceSourceEntries {
readonly entries: HistoricalSourceGraphInput["entries"];
@@ -99,22 +100,9 @@ const resolveInternalSpecifier = (
`${normalized}${suffix}`,
`${normalized}/index${suffix}`,
]);
// TypeScript source commonly names the extension emitted for NodeNext.
// Retain present runtime files first; substitute only a missing counterpart.
// Counterparts cover explicit filenames, not directory/index resolution.
if (/\.(?:ts|tsx|mts|cts)$/u.test(fromPath)) {
const sourceSuffixes = normalized.endsWith(".js")
? [".ts", ".tsx", ".d.ts"]
: normalized.endsWith(".mjs")
? [".mts", ".d.mts"]
: normalized.endsWith(".cjs")
? [".cts", ".d.cts"]
: normalized.endsWith(".jsx")
? [".tsx", ".d.ts"]
: [];
const stem = normalized.slice(0, -posix.extname(normalized).length);
candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`));
}
candidates.push(...typescriptEmittedSourceCandidates(fromPath, normalized));
const match = lookupPath.resolveCandidates(candidates);
return match === undefined
? { to: normalized, resolution: "unresolved" }
@@ -0,0 +1,29 @@
/** Emitted NodeNext suffixes and the TypeScript source counterparts they name. */
const EMITTED_SUFFIXES: ReadonlyArray<
readonly [suffix: string, sourceSuffixes: readonly string[]]
> = [
[".js", [".ts", ".tsx", ".d.ts"]],
[".mjs", [".mts", ".d.mts"]],
[".cjs", [".cts", ".d.cts"]],
[".jsx", [".tsx", ".d.ts"]],
];
/**
* Return TypeScript source counterparts for an explicit emitted filename.
* Callers retain exact runtime-file precedence and must not use these for
* directory/index, URL, HTML, or package target resolution.
*/
export const typescriptEmittedSourceCandidates = (
sourcePath: string,
candidate: string,
): readonly string[] => {
if (!/\.(?:ts|tsx|mts|cts)$/u.test(sourcePath)) return [];
const emitted = EMITTED_SUFFIXES.find(([suffix]) =>
candidate.endsWith(suffix),
);
if (emitted === undefined) return [];
// Strip the matched suffix by length. posix.extname would miss dotfile
// names such as ".js", whose whole basename is the emitted suffix itself.
const stem = candidate.slice(0, candidate.length - emitted[0].length);
return emitted[1].map((suffix) => `${stem}${suffix}`);
};
@@ -1464,6 +1464,159 @@ describe("contextual JavaScript module identity", () => {
});
});
describe("TypeScript NodeNext emitted-extension resolution", () => {
const container = "container";
it.each([
["./dep.js", "src/dep.ts"],
["./ui/Button.js", "src/ui/Button.tsx"],
["./cfg.mjs", "src/cfg.mts"],
["./legacy.cjs", "src/legacy.cts"],
] as const)(
"resolves %s to its TypeScript source counterpart",
(specifier, target) => {
const files = fileMap([
file("src/main.ts", container),
file(target, container, "export const value = 1;"),
]);
expect(
resolve({
declaredPath: specifier,
sourcePath: "src/main.ts",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({ resolution_status: "resolved", resolved_path: target });
},
);
it.each([
["./dep.js", "src/dep.d.ts"],
["./cfg.mjs", "src/cfg.d.mts"],
["./legacy.cjs", "src/legacy.d.cts"],
] as const)(
"resolves %s to a declaration counterpart",
(specifier, target) => {
const files = fileMap([
file("src/main.ts", container),
file(target, container, "export const value = 1;"),
]);
expect(
resolve({
declaredPath: specifier,
sourcePath: "src/main.ts",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({ resolution_status: "resolved", resolved_path: target });
},
);
it("prefers a present runtime file and keeps the fallback off for JS callers", () => {
const files = fileMap([
file("src/main.ts", container),
file("src/dep.js", container),
file("src/dep.ts", container),
file("src/js-main.js", container),
]);
expect(
resolve({
declaredPath: "./dep.js",
sourcePath: "src/main.ts",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "src/dep.js",
});
expect(
resolve({
declaredPath: "./dep.js",
sourcePath: "src/js-main.js",
context: "module-specifier",
moduleKind: "import",
files: fileMap([
file("src/js-main.js", container),
file("src/dep.ts", container),
]),
}),
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
});
it("does not apply the mapping to URL resources or package export targets", () => {
const urlFiles = fileMap([
file("src/main.ts", container),
file("src/asset.ts", container),
]);
expect(
resolve({
declaredPath: "asset.js",
sourcePath: "src/main.ts",
context: "url-reference",
files: urlFiles,
}),
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
const packageFiles = fileMap([
file("src/main.ts", container),
file(
"src/node_modules/fixture/package.json",
container,
'{"exports":"./dep.js"}',
),
file("src/node_modules/fixture/dep.ts", container),
]);
expect(
resolve({
declaredPath: "fixture",
sourcePath: "src/main.ts",
context: "module-specifier",
moduleKind: "import",
files: packageFiles,
}),
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
});
it("does not substitute source counterparts for directory or index resolution", () => {
const files = fileMap([
file("src/main.ts", container),
file("src/lib/index.ts", container),
]);
expect(
resolve({
declaredPath: "./lib.js",
sourcePath: "src/main.ts",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
});
it("resolves an emitted dotfile name within its own directory", () => {
const files = fileMap([
file("src/main.ts", container),
file("src/.ts", container),
]);
expect(
resolve({
declaredPath: "./.js",
sourcePath: "src/main.ts",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "src/.ts",
});
});
});
describe("url-reference JavaScript artifact path resolution", () => {
it.each([
"app%00.js.map",
@@ -19,6 +19,7 @@ import {
classifyPackageImportTargetString,
lookupExactPackageImport,
} from "../../domain/javascript/javascriptPackageImportAliases.js";
import { typescriptEmittedSourceCandidates } from "../TypeScriptEmittedSourceCandidates.js";
type ArtifactPathResolutionContext =
| "package-entrypoint"
@@ -128,7 +129,15 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
`The exact HTML resource ${confined} was not found among the selected application files.`,
])
);
return resolveCandidate(input, confined, new Set());
return resolveCandidate(
input,
confined,
new Set(),
input.context === "module-specifier" &&
(input.declaredPath.startsWith(".") || input.declaredPath.startsWith("/"))
? typescriptEmittedSourceCandidates(input.sourcePath, confined)
: [],
);
};
const rejectDeclaration = (declared: string): CandidateResolution | null => {
@@ -669,8 +678,12 @@ const resolveCandidate = (
input: ResolveArtifactPathInput,
candidate: string,
packageChain: ReadonlySet<string>,
emittedSourceCandidates: readonly string[] = [],
): CandidateResolution =>
resolveFileCandidates(input, fileCandidates(candidate)) ??
resolveFileCandidates(input, [
...fileCandidates(candidate),
...emittedSourceCandidates,
]) ??
resolveDirectory(
input,
candidate,
@@ -75,7 +75,7 @@ cliTest(
);
cliTest(
"discloses CommonJS and TypeScript self-imports while retaining unresolved extension rewrites",
"discloses CommonJS and TypeScript self-imports including emitted-name rewrites",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-self-require-");
await writeFixtureFiles(root, {
@@ -94,8 +94,10 @@ cliTest(
expect(
graph.edges.filter((edge) => edge.source_node_id === edge.target_node_id),
).toEqual([]);
// ./util.ts is a literal self-import and ./util.js resolves under NodeNext
// emitted-name semantics back to util.ts itself, as ./c.cjs does for CJS.
expect(graph.limitations).toContainEqual(
expect.stringMatching(/^2 import specifiers .*were omitted;/u),
expect.stringMatching(/^3 import specifiers .*were omitted;/u),
);
expect(result.limitations).toEqual(
expect.arrayContaining(graph.limitations),
@@ -128,12 +130,21 @@ cliTest(
).toBe(false);
}
expect(
graph.edges.find(
graph.edges.some(
(edge) =>
edge.source_node_id === findSourceModule(graph, "util.ts")?.node_id &&
edge.properties.specifier === "./util.js" &&
edge.properties.module_link_kind === "import",
)?.properties,
).toMatchObject({ resolved_path: null, resolution_status: "not-found" });
(edge.properties.module_link_kind === "import" ||
edge.properties.resolution_status === "not-found"),
),
).toBe(false);
expect(
graph.nodes.some((node) =>
node.observations.some(
({ properties }) =>
properties.semantic_role === "module-reference" &&
properties.declared_specifier === "./util.js",
),
),
).toBe(false);
},
);