From 86fab11fbe000a04c0810699419e99e50476e424 Mon Sep 17 00:00:00 2001 From: hhang <17309584214@163.com> Date: Tue, 13 Oct 2026 05:04:21 +0800 Subject: [PATCH] fix(javascript): preserve import-equals declaration and export evidence (#2062) * fix(javascript): retain TypeScript import-equals dependencies * test(mcp): align completion checks with warm provider state * fix(javascript): propagate require origins through exported aliases * fix(javascript): normalize equivalent CTS binding origins --------- Co-authored-by: hhang <169027046+xu91102@users.noreply.github.com> --- .../javascript/JavaScriptArtifactAnalysis.ts | 7 +- .../JavaScriptArtifactPathResolution.ts | 12 +- .../JavaScriptModuleRelationships.ts | 3 + .../javascriptAnalysisWorkerProcess.ts | 6 +- ...riptSemanticAnalysis.require_links.test.ts | 35 +- ...vascriptSemanticAnalysis.structure.test.ts | 3 + .../javascript/javascriptSemanticAnalysis.ts | 30 +- src/domain/javascript/javascriptSemanticIr.ts | 3 + .../javascriptSemanticProjection.ts | 33 +- .../javascriptSemanticProvenance.ts | 6 +- .../javascriptSemanticRequireOrigin.ts | 6 +- .../javascript/javascriptSemanticReturns.ts | 18 +- .../javascript/javascriptSemanticState.ts | 2 + .../javascript/javascriptStaticAnalysis.ts | 7 +- .../JavaScriptAnalysisWorkerSchemas.ts | 42 ++- .../applications/unboundRequires.test.ts | 333 +++++++++++++++++- 16 files changed, 498 insertions(+), 48 deletions(-) diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 58bbce3f98..e52ba04ddc 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -1,4 +1,6 @@ import { createHash } from "node:crypto"; + +import { moduleKindForJavaScriptReference } from "./JavaScriptArtifactPathResolution.js"; import { setImmediate } from "node:timers/promises"; import { resolveJavaScriptSourceMapReference } from "../../domain/javascript/javascriptSourceMapPaths.js"; @@ -380,7 +382,10 @@ function* analyzeArtifactFileSteps< yield; const semantics = analysis.parse_status === "complete" || analysis.parse_status === "partial" - ? yield* analyzeParsedJavaScriptSemanticsSteps(parsed) + ? yield* analyzeParsedJavaScriptSemanticsSteps( + parsed, + moduleKindForJavaScriptReference(file.path, "static-import"), + ) : null; const projected = semantics === null diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 2f1539eb22..f8530f7f09 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -68,12 +68,20 @@ export interface ResolveArtifactPathInput { export const moduleKindForJavaScriptReference = ( sourcePath: string, referenceKind: "static-import" | "dynamic-import" | "require", + resolutionMode?: "import" | "require", ): "import" | "require" => - referenceKind === "require" || + resolutionMode ?? + (referenceKind === "require" || (referenceKind === "static-import" && posix.extname(sourcePath).toLowerCase() === ".cts") ? "require" - : "import"; + : "import"); + +/** Preserve an explicitly retained loader mode on graph relationships. */ +export const moduleResolutionModeProperties = ( + mode: "import" | "require" | undefined, +): Readonly> => + mode === undefined ? {} : { resolution_mode: mode }; const EXTENSIONS = [ ".js", diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index 3468762f4f..8743778105 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -31,6 +31,7 @@ import { } from "./JavaScriptArtifactGraphEvidence.js"; import { moduleKindForJavaScriptReference, + moduleResolutionModeProperties, resolveArtifactPathByContext, type ArtifactPathResolution, } from "./JavaScriptArtifactPathResolution.js"; @@ -281,6 +282,7 @@ const resolveModuleTarget = ( input.link.kind === "require" || input.link.kind === "commonjs-export" ? "require" : "static-import", + input.link.resolutionMode, ), }); if (resolution.resolution_status !== "resolved") @@ -373,6 +375,7 @@ const relationshipProperties = ( origin?: JavaScriptModuleOrigin, ) => ({ module_link_kind: link.kind, + ...moduleResolutionModeProperties(link.resolutionMode), specifier: origin?.specifier ?? link.specifier, imported_name: origin?.importedPath.at(-1) ?? link.importedName, imported_path: origin?.importedPath ?? [], diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 5c66f4afa9..09a9c9bc33 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -1,3 +1,4 @@ +import { moduleKindForJavaScriptReference } from "../application/javascript/JavaScriptArtifactPathResolution.js"; import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js"; import { analyzeParsedJavaScriptStaticSourceSteps } from "../domain/javascript/javascriptStaticAnalysis.js"; import { @@ -53,7 +54,10 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( ); await callbacks.phase("semantic"); const ir = await completeJavaScriptAnalysisSteps( - analyzeParsedJavaScriptSemanticsSteps(parsed), + analyzeParsedJavaScriptSemanticsSteps( + parsed, + moduleKindForJavaScriptReference(file.path, "static-import"), + ), ); parsed = null; const module = projectJavaScriptModuleSemantics(ir); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts index 19e7b2fd2a..333ad4f9cc 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.require_links.test.ts @@ -52,6 +52,36 @@ describe("TypeScript import-equals links", () => { }); describe("CommonJS dependency links", () => { + it("retains an import-equals binding and its module origin", () => { + const ir = analyzeJavaScriptSemantics(` + import typed = require("dependency"); + import type TypeOnly = require("type-only"); + namespace Local { export class Marker {} } + import Alias = Local.Marker; + void typed; + `); + expect(topLevelBinding(ir, "typed")).toMatchObject({ + kind: "import", + mutable: false, + provenance: { + origins: [{ specifier: "dependency", importedPath: [] }], + }, + }); + expect( + ir.moduleLinks.filter(({ specifier }) => specifier !== null), + ).toEqual([ + expect.objectContaining({ + kind: "require", + specifier: "dependency", + importedName: null, + localName: "typed", + }), + ]); + expect(ir.references.filter(({ name }) => name === "typed")).toHaveLength( + 1, + ); + }); + it.each([ 'require("dependency");', 'void require("dependency");', @@ -148,7 +178,9 @@ describe("CommonJS dependency links", () => { ]); expect( ir.bindings.find(({ name }) => name === "bound")?.provenance.origins, - ).toEqual([{ specifier: "bound", importedPath: [] }]); + ).toEqual([ + { specifier: "bound", importedPath: [], resolutionMode: "require" }, + ]); }); it.each([ @@ -179,6 +211,7 @@ it("preserves unshadowed require origins through aliases and assignments", () => expect(origin(topLevelBinding(ir, name))).toEqual({ specifier: "electron", importedPath: ["ipcMain"], + resolutionMode: "require", }); expect(topLevelBinding(ir, "assigned").provenance).toMatchObject({ status: "ambiguous", diff --git a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts index 7405bf60e2..2cd9fba802 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.structure.test.ts @@ -38,10 +38,12 @@ describe("JavaScript semantic analysis: structure 1", () => { expect(origin(topLevelBinding(ir, "bus"))).toEqual({ specifier: "electron", importedPath: ["ipcMain"], + resolutionMode: "require", }); expect(origin(topLevelBinding(ir, "forwarded"))).toEqual({ specifier: "electron", importedPath: ["ipcMain"], + resolutionMode: "require", }); expect(topLevelBinding(ir, "assigned").provenance).toMatchObject({ status: "ambiguous", @@ -66,6 +68,7 @@ describe("JavaScript semantic analysis: structure 1", () => { expect(origin(innerBus)).toEqual({ specifier: "./local-bus.js", importedPath: [], + resolutionMode: "require", }); const shadow = onlyBinding(ir, "ipcRenderer"); expect(shadow.provenance).toEqual({ diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index cff8d04582..66776180a0 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -134,8 +134,11 @@ export function* indexJavaScriptBindingsSteps( /** Recover semantics from an already parsed JavaScript artifact. */ export const analyzeParsedJavaScriptSemantics = ( file: ParsedJavaScriptSource, + staticImportResolutionMode: "import" | "require" = "import", ): JavaScriptSemanticIr => - completeSemanticSteps(analyzeParsedJavaScriptSemanticsSteps(file)); + completeSemanticSteps( + analyzeParsedJavaScriptSemanticsSteps(file, staticImportResolutionMode), + ); /** * Recover semantics from a parsed artifact with a yield between analysis @@ -145,8 +148,9 @@ export const analyzeParsedJavaScriptSemantics = ( */ export function* analyzeParsedJavaScriptSemanticsSteps( file: ParsedJavaScriptSource, + staticImportResolutionMode: "import" | "require" = "import", ): Generator { - const state = createState(file.program); + const state = createState(file.program, staticImportResolutionMode); yield* collectDefinitionsSteps(file.program, state); yield* collectSemanticMemberMutationsSteps(file.program, state); yield* traverseJavaScriptAstSteps(file.program, { @@ -159,7 +163,11 @@ export function* analyzeParsedJavaScriptSemanticsSteps( yield; const callables = collectSemanticReturns(state, parserPartial); yield; - const moduleLinks = resolveSemanticModuleCallables(state, callables); + const moduleLinks = resolveSemanticModuleCallables( + state, + callables, + bindings, + ); const derived = yield* collectJavaScriptDerivedSemanticsSteps( file.program, state, @@ -517,7 +525,10 @@ const bindingOrigin = ( : { ...origin, importedPath: [...origin.importedPath, ...projection] }; }; -const createState = (program: t.Program): JavaScriptSemanticAnalysisState => { +const createState = ( + program: t.Program, + staticImportResolutionMode: "import" | "require" = "import", +): JavaScriptSemanticAnalysisState => { const root: JavaScriptSemanticScopeState = { scopeId: semanticScopeId("program", program), parentScopeId: null, @@ -527,6 +538,7 @@ const createState = (program: t.Program): JavaScriptSemanticAnalysisState => { bindings: new Map(), }; return { + staticImportResolutionMode, parentsByNode: new WeakMap(), scopes: [root], scopesById: new Map([[root.scopeId, root]]), @@ -803,7 +815,7 @@ const bindImportEquals = ( mutable: false, definitionNode: node.id, initializer: null, - directOrigin: { specifier, importedPath: [] }, + directOrigin: { specifier, importedPath: [], resolutionMode: "require" }, }); }; @@ -1181,7 +1193,13 @@ const addBinding = (input: AddBindingInput): void => { binding.definitions.push({ kind, location: range(definitionNode) }); if (initializer !== null) binding.initializers.push({ node: initializer, projection }); - if (directOrigin !== undefined) binding.directOrigins.push(directOrigin); + if (directOrigin !== undefined) + binding.directOrigins.push( + directOrigin.resolutionMode === undefined && + state.staticImportResolutionMode === "require" + ? { ...directOrigin, resolutionMode: "require" } + : directOrigin, + ); }; const createBinding = ( diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index 0e547f155d..18f1eb4854 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -7,6 +7,7 @@ import type { /** One exact module origin followed through imports, requires, or aliases. */ export interface JavaScriptModuleOrigin { + readonly resolutionMode?: "import" | "require"; readonly specifier: string; readonly importedPath: readonly string[]; } @@ -392,8 +393,10 @@ export interface JavaScriptSemanticFrontier { /** Static import/export relationship retained for cross-file composition. */ export interface JavaScriptSemanticModuleLink { + readonly resolutionMode?: "import" | "require"; readonly kind: | "import" + | "type-import" | "require" | "export" | "re-export" diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 42601888a1..7e4663efea 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -669,19 +669,26 @@ export const semanticReferenceRole = ( return "read"; }; -const isDeclarationIdentifier = (node: t.Identifier, parent: t.Node): boolean => - (t.isVariableDeclarator(parent) && patternContains(parent.id, node)) || - ((t.isFunctionDeclaration(parent) || t.isFunctionExpression(parent)) && - parent.id === node) || - (t.isFunction(parent) && - parent.params.some((value) => patternContains(value, node))) || - ((t.isClassDeclaration(parent) || t.isClassExpression(parent)) && - parent.id === node) || - (t.isImportSpecifier(parent) && - (parent.local === node || parent.imported === node)) || - (t.isImportDefaultSpecifier(parent) && parent.local === node) || - (t.isImportNamespaceSpecifier(parent) && parent.local === node) || - (t.isCatchClause(parent) && parent.param === node); +const isDeclarationIdentifier = ( + node: t.Identifier, + parent: t.Node, +): boolean => { + if (t.isTSImportEqualsDeclaration(parent)) return parent.id === node; + return ( + (t.isVariableDeclarator(parent) && patternContains(parent.id, node)) || + ((t.isFunctionDeclaration(parent) || t.isFunctionExpression(parent)) && + parent.id === node) || + (t.isFunction(parent) && + parent.params.some((value) => patternContains(value, node))) || + ((t.isClassDeclaration(parent) || t.isClassExpression(parent)) && + parent.id === node) || + (t.isImportSpecifier(parent) && + (parent.local === node || parent.imported === node)) || + (t.isImportDefaultSpecifier(parent) && parent.local === node) || + (t.isImportNamespaceSpecifier(parent) && parent.local === node) || + (t.isCatchClause(parent) && parent.param === node) + ); +}; const isNonReferenceKey = (node: t.Identifier, parent: t.Node): boolean => ((t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) && diff --git a/src/domain/javascript/javascriptSemanticProvenance.ts b/src/domain/javascript/javascriptSemanticProvenance.ts index ab56a1488f..3272b2ab99 100644 --- a/src/domain/javascript/javascriptSemanticProvenance.ts +++ b/src/domain/javascript/javascriptSemanticProvenance.ts @@ -48,7 +48,11 @@ export const uniqueSemanticOrigins = ( ); const semanticOriginKey = (origin: JavaScriptModuleOrigin): string => - JSON.stringify([origin.specifier, origin.importedPath]); + JSON.stringify([ + origin.specifier, + origin.importedPath, + origin.resolutionMode ?? "import", + ]); /** Canonically distinguish primitive values across type boundaries. */ export const semanticPrimitiveKey = ( diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 00f0805ba1..63fde3a959 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -54,7 +54,11 @@ export const semanticRequireOrigin = ( return specifier === undefined ? undefined : { - origin: { specifier, importedPath: members.reverse() }, + origin: { + specifier, + importedPath: members.reverse(), + resolutionMode: "require", + }, call: current, }; }; diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index b6ce1540bc..189a1385ec 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -6,6 +6,7 @@ import { } from "./javascriptSemanticSlots.js"; import type { + JavaScriptSemanticBinding, JavaScriptSemanticCallable, JavaScriptSemanticModuleLink, JavaScriptSemanticReturnSite, @@ -89,12 +90,16 @@ const semanticReturnSiteId = ( String(location.end.column), ].join(":"); -/** Link exports to callables only when lexical resolution is unique and exact. */ +/** Finalize exported bindings only when lexical resolution is unique and exact. */ export const resolveSemanticModuleCallables = ( state: JavaScriptSemanticAnalysisState, callables: readonly JavaScriptSemanticCallable[], + bindings: readonly JavaScriptSemanticBinding[], ): JavaScriptSemanticModuleLink[] => { const callableIds = new Set(callables.map(({ callableId }) => callableId)); + const provenanceByBinding = new Map( + bindings.map(({ bindingId, provenance }) => [bindingId, provenance]), + ); return state.moduleLinks.map((link) => { // Named exports follow their lexical binding, including every assignment. // A declaration's ID or a callable's display name cannot prove that link. @@ -107,8 +112,19 @@ export const resolveSemanticModuleCallables = ( binding === undefined ? [] : callableIdsForBinding(binding, state, callableIds, new Set()); + const provenance = + binding === undefined || link.specifier !== null + ? undefined + : provenanceByBinding.get(binding.bindingId); + const resolutionMode = + provenance?.status === "module" + ? provenance.origins[0].resolutionMode + : undefined; return { ...link, + ...(resolutionMode === "import" || resolutionMode === "require" + ? { resolutionMode } + : {}), callableId: resolved.length === 1 ? (resolved[0] ?? null) : null, }; }); diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index e07caad996..29f798f361 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -57,6 +57,8 @@ export interface JavaScriptSemanticScopeState { /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { + /** Effective loader for static imports in this source artifact. */ + readonly staticImportResolutionMode: "import" | "require"; readonly parentsByNode: WeakMap; readonly scopes: JavaScriptSemanticScopeState[]; readonly scopesById: Map; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index ea712f6584..cbbd57b77f 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -52,6 +52,7 @@ import { } from "./javascriptSourceParser.js"; import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; +import { semanticImportEqualsSpecifier } from "./javascriptSemanticProjection.js"; /** Parse one JavaScript artifact and recover static structure only. */ export const analyzeJavaScriptStaticSource = ( @@ -238,7 +239,11 @@ const inspectNode = ( } else if (t.isOptionalCallExpression(node)) { inspectCall(source, node, findings); } else if (t.isNewExpression(node)) inspectCall(source, node, findings); - if ( + if (t.isTSImportEqualsDeclaration(node)) { + const specifier = semanticImportEqualsSpecifier(node); + if (specifier !== null) + addReference(findings, { node, kind: "require", specifier }); + } else if ( (t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) && node.source !== undefined ) diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 196c8b7b96..b666cfa9b9 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -471,7 +471,16 @@ const semanticValue: z.ZodType = z.lazy(() => }; }), ); -const origin = z.strictObject({ specifier: z.string(), importedPath: strings }); +const origin = z + .strictObject({ + specifier: z.string(), + importedPath: strings, + resolutionMode: z.enum(["import", "require"]).optional(), + }) + .transform(({ resolutionMode, ...rest }) => ({ + ...rest, + ...(resolutionMode === undefined ? {} : { resolutionMode }), + })); const provenance = z.discriminatedUnion("status", [ z.strictObject({ status: z.literal("module"), @@ -519,15 +528,28 @@ const coverage = z ...(resourceLimits === undefined ? {} : { resourceLimits }), }; }); -const moduleLink = z.strictObject({ - kind: z.enum(["import", "require", "export", "re-export", "commonjs-export"]), - specifier: z.string().nullable(), - importedName: z.string().nullable(), - localName: z.string().nullable(), - exportedName: z.string().nullable(), - callableId: z.string().nullable(), - location: range, -}); +const moduleLink = z + .strictObject({ + resolutionMode: z.enum(["import", "require"]).optional(), + kind: z.enum([ + "import", + "type-import", + "require", + "export", + "re-export", + "commonjs-export", + ]), + specifier: z.string().nullable(), + importedName: z.string().nullable(), + localName: z.string().nullable(), + exportedName: z.string().nullable(), + callableId: z.string().nullable(), + location: range, + }) + .transform(({ resolutionMode, ...rest }) => ({ + ...rest, + ...(resolutionMode === undefined ? {} : { resolutionMode }), + })); const definitionKind = z.enum([ "import", "variable", diff --git a/tests/acceptance/applications/unboundRequires.test.ts b/tests/acceptance/applications/unboundRequires.test.ts index a9f66e1b19..0e85f0a9a7 100644 --- a/tests/acceptance/applications/unboundRequires.test.ts +++ b/tests/acceptance/applications/unboundRequires.test.ts @@ -1,7 +1,9 @@ +import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { join, relative } from "node:path"; +import { dirname, join, relative } from "node:path"; +import { promisify } from "node:util"; import { expect } from "vitest"; @@ -10,6 +12,318 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const execute = promisify(execFile); +const compiler = join( + dirname(createRequire(import.meta.url).resolve("typescript/package.json")), + "bin", + "tsc", +); + +const typedLines = [ + 'export import typed = require("typed-package");', + 'import type TypeOnly = require("./type-only.cjs");', +] as const; +const typedSource = typedLines.join("\n"); +const aliasSource = [ + 'import raw = require("typed-package");', + "const alias = raw;", + "export { alias };", +].join("\n"); +const mixedSource = [ + 'import { value as imported } from "mixed-package";', + 'import required = require("mixed-package");', + "const mixed = Date.now() > 0 ? imported : required.value;", + "export { imported as staticChoice, mixed };", + "export const requireChoice = required.value;", +].join("\n"); +const typescriptSourceFiles = { + "typed.cts": typedSource, + "typed.mts": typedSource, + "alias.cts": aliasSource, + "alias.mts": aliasSource, + "mixed.cts": mixedSource, + "mixed.mts": mixedSource, +}; +const typedPackageFiles = { + "node_modules/typed-package/package.json": JSON.stringify({ + exports: { require: "./actual.cjs", import: "./decoy.mjs" }, + }), + "node_modules/typed-package/actual.cjs": "module.exports = 42;", + "node_modules/typed-package/actual.d.cts": + "declare const value: number; export = value;", + "node_modules/typed-package/decoy.mjs": "export default 99;", + "node_modules/typed-package/decoy.d.mts": + "declare const value: number; export default value;", + "node_modules/mixed-package/package.json": JSON.stringify({ + exports: { require: "./actual.cjs", import: "./decoy.mjs" }, + }), + "node_modules/mixed-package/actual.cjs": "exports.value = 42;", + "node_modules/mixed-package/actual.d.cts": + "export declare const value: number;", + "node_modules/mixed-package/decoy.mjs": "export const value = 99;", + "node_modules/mixed-package/decoy.d.mts": + "export declare const value: number;", +}; + +const expectNativeModuleControls = async (root: string): Promise => { + const emitted = await createTestTempDirectory("rea-import-equals-output-"); + await writeFixtureFiles(emitted, typedPackageFiles); + await execute( + process.execPath, + [ + compiler, + "--module", + "NodeNext", + "--moduleResolution", + "NodeNext", + "--target", + "ES2022", + "--skipLibCheck", + "--allowJs", + "--outDir", + emitted, + join(root, "typed.cts"), + join(root, "typed.mts"), + join(root, "alias.cts"), + join(root, "alias.mts"), + join(root, "mixed.cts"), + join(root, "mixed.mts"), + ], + { cwd: root, timeout: 10_000 }, + ); + for (const [filename, script] of [ + [ + "typed.cjs", + 'console.log(require("./typed.cjs").typed, require("./alias.cjs").alias);', + ], + [ + "typed.mjs", + 'Promise.all([import("./typed.mjs"), import("./alias.mjs")]).then(([first, second]) => console.log(first.typed, second.alias));', + ], + ] as const) { + const typedControl = await execute(process.execPath, ["-e", script], { + cwd: emitted, + timeout: 5_000, + }); + expect(typedControl.stdout.trim()).toBe("42 42"); + expect(await readFile(join(emitted, filename), "utf8")).not.toContain( + "./type-only.cjs", + ); + } + for (const [filename, expected] of [ + ["mixed.cjs", "42 42 42"], + ["mixed.mjs", "99 42 99"], + ] as const) { + const control = await execute( + process.execPath, + [ + "-e", + `import("./${filename}").then(({staticChoice, requireChoice, mixed}) => console.log(staticChoice, requireChoice, mixed));`, + ], + { cwd: emitted, timeout: 5_000 }, + ); + expect(control.stdout.trim()).toBe(expected); + } + const nativeRequire = createRequire(join(root, "main.cjs")); + for (const name of ["dependency", "nested"]) + expect( + relative(root, nativeRequire.resolve(`./${name}.cjs`)).replaceAll( + "\\", + "/", + ), + ).toBe(`${name}.cjs`); +}; + +const expectAliasRelationships = ( + graph: ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse + >["graph"], +): void => { + for (const source of ["alias.cts", "alias.mts"]) { + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + module_link_kind: "export", + local_name: "alias", + exported_name: "alias", + resolution_mode: "require", + resolved_path: "node_modules/typed-package/actual.cjs", + resolution_status: "resolved", + }), + evidence: expect.objectContaining({ + artifact: expect.objectContaining({ + sha256: createHash("sha256").update(aliasSource).digest("hex"), + }), + location: expect.objectContaining({ + value: expect.objectContaining({ source }), + }), + }), + }), + ); + } +}; + +const expectMixedRelationships = ( + graph: ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse + >["graph"], +): void => { + const exported = (source: string, name: string) => + graph.edges.filter( + ({ relation, properties, evidence }) => + relation === "imports" && + properties.module_link_kind === "export" && + properties.exported_name === name && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ); + for (const source of ["mixed.cts", "mixed.mts"]) { + for (const [name, target] of [ + ["staticChoice", source.endsWith(".cts") ? "actual.cjs" : "decoy.mjs"], + ["requireChoice", "actual.cjs"], + ] as const) + expect(exported(source, name)).toMatchObject([ + { + properties: { + resolved_path: `node_modules/mixed-package/${target}`, + resolution_status: "resolved", + }, + evidence: { + artifact: { + sha256: createHash("sha256").update(mixedSource).digest("hex"), + }, + }, + }, + ]); + } + expect(exported("mixed.cts", "mixed")).toMatchObject([ + { + properties: { + resolved_path: "node_modules/mixed-package/actual.cjs", + resolution_status: "resolved", + }, + evidence: { + artifact: { + sha256: createHash("sha256").update(mixedSource).digest("hex"), + }, + }, + }, + ]); + expect(exported("mixed.mts", "mixed")).toEqual([]); +}; + +const expectImportEqualsRelationships = ( + graph: ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse + >["graph"], +): void => { + for (const [sourcePath, format] of [ + ["typed.cts", "commonjs"], + ["typed.mts", "esm"], + ] as const) { + const typedLinks = graph.edges.filter( + ({ relation, properties, evidence: edgeEvidence }) => + relation === "imports" && + properties.module_link_kind === "require" && + edgeEvidence.location.available && + edgeEvidence.location.value.kind === "source-range" && + edgeEvidence.location.value.source === sourcePath, + ); + expect(typedLinks).toHaveLength(1); + expect(typedLinks[0]).toMatchObject({ + properties: { + specifier: "typed-package", + imported_name: null, + local_name: "typed", + resolved_path: "node_modules/typed-package/actual.cjs", + resolution_status: "resolved", + }, + evidence: { + artifact: { + available: true, + sha256: createHash("sha256").update(typedSource).digest("hex"), + }, + location: { + available: true, + value: { + source: sourcePath, + start: { line: 1, column: 7 }, + end: { line: 1, column: typedLines[0].length }, + }, + }, + }, + }); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + module_link_kind: "export", + local_name: "typed", + resolution_mode: "require", + resolved_path: "node_modules/typed-package/actual.cjs", + }), + evidence: expect.objectContaining({ + location: expect.objectContaining({ + value: expect.objectContaining({ source: sourcePath }), + }), + }), + }), + ); + expect( + graph.nodes.flatMap(({ observations }) => observations), + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + semantic_role: "source-module", + path: sourcePath, + export_names: ["typed"], + export_relationships: 1, + }), + }), + ); + expect( + graph.edges.filter( + ({ relation, evidence: edgeEvidence }) => + relation === "exposes" && + edgeEvidence.location.available && + edgeEvidence.location.value.kind === "source-range" && + edgeEvidence.location.value.source === sourcePath, + ), + ).toHaveLength(1); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + kind: "require", + specifier: "typed-package", + resolved_path: "node_modules/typed-package/actual.cjs", + }), + }), + ); + expect( + graph.nodes.flatMap(({ observations }) => observations), + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + semantic_role: "source-module", + path: sourcePath, + module_format: format, + import_relationships: 1, + }), + }), + ); + } + expect( + graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "./type-only.cjs", + ), + ).toHaveLength(0); +}; cliTest.for(["CLI", "stdio MCP"] as const)( "retains unbound CommonJS semantic relationships through %s", @@ -34,20 +348,16 @@ cliTest.for(["CLI", "stdio MCP"] as const)( "imports.mjs": 'import "./dependency.cjs";', "dependency.cjs": 'throw new Error("fixture must not execute");', "nested.cjs": 'throw new Error("fixture must not execute");', + ...typescriptSourceFiles, + "type-only.d.cts": "declare const value: unknown; export = value;", }; await Promise.all( Object.entries(files).map(([path, text]) => writeFile(join(root, path), text), ), ); - const nativeRequire = createRequire(join(root, "main.cjs")); - for (const name of ["dependency", "nested"]) - expect( - relative(root, nativeRequire.resolve(`./${name}.cjs`)).replaceAll( - "\\", - "/", - ), - ).toBe(`${name}.cjs`); + await writeFixtureFiles(root, typedPackageFiles); + await expectNativeModuleControls(root); let document: unknown; if (surface === "CLI") { @@ -70,6 +380,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)( const { graph } = javascriptApplicationAnalysisResultSchema.parse( evidence.normalized_result, ); + expectImportEqualsRelationships(graph); + expectAliasRelationships(graph); + expectMixedRelationships(graph); const links = graph.edges.filter( ({ relation, properties, evidence: edgeEvidence }) => relation === "imports" &&