diff --git a/src/domain/referenceSourceImportParsing.test.ts b/src/domain/referenceSourceImportParsing.test.ts new file mode 100644 index 000000000..27595155b --- /dev/null +++ b/src/domain/referenceSourceImportParsing.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { parseReferenceSourceImports } from "./referenceSourceImportParsing.js"; + +const parse = (source: string) => + parseReferenceSourceImports( + "main.ts", + new TextEncoder().encode(source), + "TypeScript", + ); + +describe("historical source dynamic imports", () => { + it.each([ + 'import("./lazy.js");', + 'const module = import("./lazy.js");', + 'async function load() { return await import("./lazy.js"); }', + 'import("./lazy.js", { with: { type: "json" } });', + ])("retains the literal dependency in %s", (source) => { + expect(parse(source)).toEqual({ + relationships: [ + { + from_path: "main.ts", + to: "./lazy.js", + kind: "imports", + resolution: "internal", + parse_state: "parsed", + }, + ], + parse_failures: [], + }); + }); + + it.each([ + "import(moduleName);", + "async function load(name: string) { return await import(name); }", + "import(`./${moduleName}.js`);", + ])("keeps a computed dependency unknown in %s", (source) => { + expect(parse(source)).toEqual({ + relationships: [ + { + from_path: "main.ts", + to: "", + kind: "imports", + resolution: "unknown", + parse_state: "partial", + }, + ], + parse_failures: [], + }); + }); + + it("retains static import and require relationships alongside dynamic imports", () => { + const result = parse( + 'import "./static.js"; require("./common.cjs"); import("node:fs");', + ); + expect(result.parse_failures).toEqual([]); + expect(result.relationships).toEqual([ + { + from_path: "main.ts", + to: "./static.js", + kind: "imports", + resolution: "internal", + parse_state: "parsed", + }, + { + from_path: "main.ts", + to: "./common.cjs", + kind: "requires", + resolution: "internal", + parse_state: "parsed", + }, + { + from_path: "main.ts", + to: "node:fs", + kind: "imports", + resolution: "external", + parse_state: "parsed", + }, + ]); + }); +}); diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index bb858eba7..2b40efcb4 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -1,5 +1,11 @@ import { parse, type ParserPlugin } from "@babel/parser"; -import type { CallExpression, File, Node, StringLiteral } from "@babel/types"; +import type { + CallExpression, + File, + ImportExpression, + Node, + StringLiteral, +} from "@babel/types"; import { isCallExpression, isExportAllDeclaration, @@ -7,6 +13,7 @@ import { isIdentifier, isImport, isImportDeclaration, + isImportExpression, isMemberExpression, isNode, isStringLiteral, @@ -196,18 +203,18 @@ const isRequireCallee = (callee: Node | null | undefined): boolean => { const isImportCallee = (callee: Node | null | undefined): boolean => isImport(callee); -const collectCallExpressions = ( +const collectModuleExpressions = ( node: unknown, - targets: CallExpression[], + targets: Array, ): void => { if (node === null || node === undefined) return; if (typeof node !== "object") return; if (Array.isArray(node)) { - for (const item of node) collectCallExpressions(item, targets); + for (const item of node) collectModuleExpressions(item, targets); return; } if (!isNode(node)) return; - if (isCallExpression(node)) { + if (isCallExpression(node) || isImportExpression(node)) { targets.push(node); } for (const value of Object.values(node)) { @@ -217,7 +224,7 @@ const collectCallExpressions = ( typeof value === "object" && !isSourceLocation(value) ) { - collectCallExpressions(value, targets); + collectModuleExpressions(value, targets); } } }; @@ -234,10 +241,22 @@ const extractRequireAndDynamicImports = ( from_path: string, relationships: ReferenceSourceImportRelationship[], ): void => { - const calls: CallExpression[] = []; - for (const statement of body) collectCallExpressions(statement, calls); + const expressions: Array = []; + for (const statement of body) + collectModuleExpressions(statement, expressions); - for (const call of calls) { + for (const call of expressions) { + if (isImportExpression(call)) { + const specifier = safeModuleName(call.source); + appendRelationship(relationships, { + fromPath: from_path, + to: specifier ?? "", + kind: "imports", + parseState: specifier === undefined ? "partial" : "parsed", + ...(specifier === undefined ? { resolution: "unknown" } : {}), + }); + continue; + } const first = call.arguments[0]; if (isRequireCallee(call.callee) && isModuleExpression(first)) { const result = moduleSpecifierFromExpression(first); diff --git a/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts b/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts new file mode 100644 index 000000000..dd5b85e37 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts @@ -0,0 +1,57 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("resolves dynamic source imports while retaining computed targets as unknown", async () => { + const root = await createTestTempDirectory("rea-reference-dynamic-import-"); + await Promise.all([ + writeFile( + join(root, "main.ts"), + [ + 'import "./static";', + 'require("./common");', + 'const lazy = import("./lazy");', + 'async function load() { return await import("./data.json", { with: { type: "json" } }); }', + 'const name = "./not-guessed.js"; import(name);', + ].join("\n"), + ), + ...["static.ts", "common.js", "lazy.ts"].map((path) => + writeFile(join(root, path), "export {};\n"), + ), + writeFile(join(root, "data.json"), '{"value":1}\n'), + ]); + + const result = await importReferenceSource({ + root, + caller: "reference-dynamic-import-test", + policy: { secretPatterns: [] }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.parse_failures).toEqual([]); + expect(result.value.relationships).toHaveLength(5); + for (const [to, kind] of [ + ["static.ts", "imports"], + ["common.js", "requires"], + ["lazy.ts", "imports"], + ["data.json", "imports"], + ]) + expect(result.value.relationships).toContainEqual({ + from_path: "main.ts", + to, + kind, + resolution: "internal", + parse_state: "parsed", + }); + expect(result.value.relationships).toContainEqual({ + from_path: "main.ts", + to: "", + kind: "imports", + resolution: "unknown", + parse_state: "partial", + }); +});