fix(javascript): preserve TypeScript loader and dependency semantics

Share loader selection across static findings and module relationships: CTS static imports and re-exports use CommonJS lookup while dynamic imports retain ESM lookup. Retain runtime import-equals dependencies, bindings, and exported names; do not resolve require file-URL strings as local artifacts. Restore import-map descriptions in CLI help.

Validated resolver and semantic tests, application reconstruction boundaries, and CLI help. Installed TypeScript NodeNext emission and Node require behavior were checked independently. Refs #2056, #2057, #2058, #2053.
This commit is contained in:
morluto committed 2026-10-12 14:12:19 +08:00
1 parent 966f7cc84b
commit e4532b5376
8 files changed
+242 -13

No files matched your search

@@ -22,7 +22,10 @@ import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import {
moduleKindForJavaScriptReference,
resolveArtifactPathByContext,
} from "./JavaScriptArtifactPathResolution.js";
type StaticReference = JavaScriptStaticAnalysis["references"][number];
type StaticEndpoint = JavaScriptStaticAnalysis["endpoints"][number];
@@ -400,12 +403,11 @@ const resolveReference = (
specifier,
sourcePath,
context.filesByPath,
reference.kind === "require"
? "require"
: reference.kind === "static-import" ||
reference.kind === "dynamic-import"
? "import"
: undefined,
reference.kind === "require" ||
reference.kind === "static-import" ||
reference.kind === "dynamic-import"
? moduleKindForJavaScriptReference(sourcePath, reference.kind)
: undefined,
);
if (path === null) return null;
const file = context.filesByPath.get(path);
@@ -576,6 +576,27 @@ describe("contextual JavaScript artifact path resolution", () => {
});
});
describe("CommonJS file URL resolution", () => {
it("keeps file URL resolution specific to the import loader", () => {
const files = fileMap([file("entry.cjs", "root"), file("dep.cjs", "root")]);
const input = {
declaredPath: "file:///dep.cjs",
sourcePath: "entry.cjs",
context: "module-specifier" as const,
files,
};
expect(resolve({ ...input, moduleKind: "require" })).toMatchObject({
resolution_status: "external",
resolved_path: null,
});
expect(resolve({ ...input, moduleKind: "import" })).toMatchObject({
resolution_status: "resolved",
resolved_path: "dep.cjs",
});
});
});
describe("contextual JavaScript package path resolution", () => {
it("follows package entrypoints beyond the former recursion ceiling", () => {
const files = fileMap([
@@ -64,6 +64,17 @@ export interface ResolveArtifactPathInput {
readonly moduleKind?: "import" | "require";
}
/** Loader selected by a source-level JavaScript module reference. */
export const moduleKindForJavaScriptReference = (
sourcePath: string,
referenceKind: "static-import" | "dynamic-import" | "require",
): "import" | "require" =>
referenceKind === "require" ||
(referenceKind === "static-import" &&
posix.extname(sourcePath).toLowerCase() === ".cts")
? "require"
: "import";
const EXTENSIONS = [
".js",
".cjs",
@@ -189,7 +200,12 @@ const contextualCandidate = (
if (context === "html-reference") return htmlCandidate(input);
if (context === "module-specifier") {
const fileUrl = fileUrlPath(declared);
if (fileUrl !== undefined) return fileUrl;
if (fileUrl !== undefined)
return input.moduleKind === "require"
? unresolvedCandidate("external", [
"Node CommonJS require specifiers do not resolve file URLs as local artifacts.",
])
: fileUrl;
if (hasScheme(declared))
return unresolvedCandidate("external", [
"URL and Node builtin schemes are outside static artifact module resolution.",
@@ -30,6 +30,7 @@ import {
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
moduleKindForJavaScriptReference,
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
@@ -275,10 +276,12 @@ const resolveModuleTarget = (
sourcePath: input.file.path,
context: "module-specifier",
files: input.context.filesByPath,
moduleKind:
moduleKind: moduleKindForJavaScriptReference(
input.file.path,
input.link.kind === "require" || input.link.kind === "commonjs-export"
? "require"
: "import",
: "static-import",
),
});
if (resolution.resolution_status !== "resolved")
return {
@@ -423,8 +426,8 @@ const moduleFormat = (
ir: JavaScriptModuleSemanticIr,
): "commonjs" | "esm" | "mixed" | "unknown" => {
const extension = posix.extname(path).toLowerCase();
if (extension === ".mjs") return "esm";
if (extension === ".cjs") return "commonjs";
if (extension === ".mjs" || extension === ".mts") return "esm";
if (extension === ".cjs" || extension === ".cts") return "commonjs";
const esm = ir.moduleLinks.some(({ kind }) =>
["import", "export", "re-export"].includes(kind),
);
@@ -6,6 +6,51 @@ import {
topLevelBinding,
} from "./javascriptSemanticAnalysis.fixture.js";
describe("TypeScript import-equals links", () => {
it("retains runtime external import-equals as a require dependency", () => {
const links = analyzeJavaScriptSemantics(`
import dependency = require("./dependency.cjs");
import type types = require("./types");
import alias = Namespace.Dependency;
`).moduleLinks;
expect(links).toEqual([
expect.objectContaining({
kind: "require",
specifier: "./dependency.cjs",
importedName: null,
localName: "dependency",
exportedName: null,
}),
]);
});
it("keeps the exported binding for exported import-equals", () => {
const source = 'export import dependency = require("./dependency");';
const links = analyzeJavaScriptSemantics(source).moduleLinks;
expect(links).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "require",
specifier: "./dependency",
localName: "dependency",
exportedName: null,
}),
expect.objectContaining({
kind: "export",
specifier: null,
localName: "dependency",
exportedName: "dependency",
}),
]),
);
expect(
links.find(({ kind }) => kind === "require")?.location.start,
).toEqual({ line: 1, column: 7 });
});
});
describe("CommonJS dependency links", () => {
it.each([
'require("dependency");',
@@ -10,6 +10,7 @@ import {
import {
collectSemanticModuleLink,
collectSemanticCallable,
semanticImportEqualsSpecifier,
collectSemanticReferences,
collectSemanticReferencesSteps,
immutableSemanticBindings,
@@ -752,6 +753,8 @@ const bindInnerDeclaration = (
state: JavaScriptSemanticAnalysisState,
): void => {
if (t.isImportDeclaration(node)) bindImports(node, scope, state);
else if (t.isTSImportEqualsDeclaration(node))
bindImportEquals(node, scope, state);
else if (t.isVariableDeclarator(node))
bindPattern({
pattern: node.id,
@@ -785,6 +788,25 @@ const bindInnerDeclaration = (
});
};
const bindImportEquals = (
node: t.TSImportEqualsDeclaration,
scope: JavaScriptSemanticScopeState,
state: JavaScriptSemanticAnalysisState,
): void => {
const specifier = semanticImportEqualsSpecifier(node);
if (specifier === null) return;
addBinding({
state,
scope,
name: node.id.name,
kind: "import",
mutable: false,
definitionNode: node.id,
initializer: null,
directOrigin: { specifier, importedPath: [] },
});
};
const bindAssignment = (
node: t.Node,
scope: JavaScriptSemanticScopeState,
@@ -69,7 +69,8 @@ export const collectSemanticModuleLink = (
node: t.Node,
state: JavaScriptSemanticAnalysisState,
): void => {
if (t.isImportDeclaration(node)) collectImports(node, state);
if (t.isTSImportEqualsDeclaration(node)) collectImportEquals(node, state);
else if (t.isImportDeclaration(node)) collectImports(node, state);
else if (t.isExportAllDeclaration(node))
addModuleLink(
state,
@@ -103,6 +104,36 @@ export const collectSemanticModuleLink = (
else if (t.isCallExpression(node)) collectUnboundRequireLink(node, state);
};
const collectImportEquals = (
node: t.TSImportEqualsDeclaration,
state: JavaScriptSemanticAnalysisState,
): void => {
const specifier = semanticImportEqualsSpecifier(node);
if (specifier === null) return;
addModuleLink(
state,
{
kind: "require",
specifier,
importedName: null,
localName: node.id.name,
exportedName: null,
location: range(node),
},
node,
);
};
/** Runtime external-module target for a value import-equals declaration. */
export const semanticImportEqualsSpecifier = (
node: t.TSImportEqualsDeclaration,
): string | null =>
node.importKind !== "type" &&
t.isTSExternalModuleReference(node.moduleReference) &&
t.isStringLiteral(node.moduleReference.expression)
? node.moduleReference.expression.value
: null;
/** Collect lexical references after definitions exist, optionally for one name. */
export const collectSemanticReferences = (
program: t.Program,
@@ -707,6 +738,11 @@ const declarationNames = (declaration: t.Declaration): string[] => {
t.isIdentifier(declaration.id)
)
return [declaration.id.name];
if (
t.isTSImportEqualsDeclaration(declaration) &&
semanticImportEqualsSpecifier(declaration) !== null
)
return [declaration.id.name];
return [];
};
@@ -14,6 +14,90 @@ import {
writeFixtureFiles,
} from "../../../support/javascriptApplicationFixture.js";
describe("TypeScript source module loader semantics", () => {
it("uses emitted TypeScript loader semantics without changing dynamic imports", async () => {
const root = await createTestTempDirectory("rea-cts-module-loader-");
await writeFixtureFiles(root, {
"package.json": '{"type":"module"}',
"main.cts": `
import local = require("./local");
import selected from "conditional";
export { retained } from "conditional";
void import("conditional");
void local;
void selected;
`,
"consumer.mts": `
import local = require("./local");
export { local };
`,
"local.js": "module.exports = 42;",
"node_modules/conditional/package.json": JSON.stringify({
exports: {
".": {
import: "./esm.mjs",
require: "./cjs.cjs",
},
},
}),
"node_modules/conditional/esm.mjs": "export const retained = 'esm';",
"node_modules/conditional/cjs.cjs": "exports.retained = 'cjs';",
});
const { graph } = await reconstructJavaScriptFixture(root);
const localRequireEdge = (sourcePath: string) => {
const source = findSourceModule(graph, sourcePath);
return graph.edges.find(
({ source_node_id, relation, properties }) =>
source_node_id === source?.node_id &&
relation === "imports" &&
properties.module_link_kind === "require" &&
properties.specifier === "./local",
);
};
expect(
findSourceModule(graph, "main.cts")?.observations[0]?.properties,
).toMatchObject({ module_format: "commonjs" });
expect(localRequireEdge("main.cts")).toMatchObject({
properties: { resolved_path: "local.js", local_name: "local" },
});
expect(
findRelationshipEdge(graph, {
kind: "import",
specifier: "conditional",
resolvedPath: "node_modules/conditional/cjs.cjs",
localName: "selected",
}),
).toBeDefined();
expect(
findRelationshipEdge(graph, {
kind: "re-export",
specifier: "conditional",
resolvedPath: "node_modules/conditional/cjs.cjs",
}),
).toBeDefined();
expect(
graph.edges.find(
({ relation, properties }) =>
relation === "imports" &&
properties.kind === "dynamic-import" &&
properties.specifier === "conditional",
),
).toMatchObject({
properties: {
resolved_path: "node_modules/conditional/esm.mjs",
},
});
expect(
findSourceModule(graph, "consumer.mts")?.observations[0]?.properties,
).toMatchObject({ module_format: "esm" });
expect(localRequireEdge("consumer.mts")).toMatchObject({
properties: { resolved_path: "local.js", local_name: "local" },
});
});
});
describe("CommonJS and ESM module relationships", () => {
it("composes bindings, re-exports, dynamic imports, and JSON modules", async () => {
const root = await moduleFixture();