From e4532b537663026c3e5b784380eecd009c959a36 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:08:09 +0800 Subject: [PATCH] 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. --- .../JavaScriptArtifactGraphFindings.ts | 16 ++-- .../JavaScriptArtifactPathResolution.test.ts | 21 +++++ .../JavaScriptArtifactPathResolution.ts | 18 +++- .../JavaScriptModuleRelationships.ts | 11 ++- ...riptSemanticAnalysis.require_links.test.ts | 45 ++++++++++ .../javascript/javascriptSemanticAnalysis.ts | 22 +++++ .../javascriptSemanticProjection.ts | 38 ++++++++- .../javascriptModuleRelationships.test.ts | 84 +++++++++++++++++++ 8 files changed, 242 insertions(+), 13 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index bbe2adead6..95dbab3196 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -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); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index e44d2666ac..7b4a15f50d 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -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([ diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 9845e8c033..2f1539eb22 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -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.", diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index fcbd5b3d33..3468762f4f 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -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), ); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts index 830bc08ba8..19e7b2fd2a 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts @@ -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");', diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index b8e2a24ae8..cff8d04582 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -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, diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 4488f584f8..42601888a1 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -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 []; }; diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts index 756a350ddd..be51b9bb4d 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts @@ -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();