mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
fix(javascript): share TypeScript emitted import resolution
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user