fix(reference): retain dynamic import relationships

This commit is contained in:
Kaoru0822 2026-10-05 22:32:36 -07:00
parent 19f8cddfd6
commit b89de8feab
3 changed files with 166 additions and 9 deletions

View File

@ -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: "<dynamic-import>",
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",
},
]);
});
});

View File

@ -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<CallExpression | ImportExpression>,
): 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<CallExpression | ImportExpression> = [];
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 ?? "<dynamic-import>",
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);

View File

@ -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: "<dynamic-import>",
kind: "imports",
resolution: "unknown",
parse_state: "partial",
});
});