522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
|
import { parseJavaScriptApplicationGraph } from "../../../../src/domain/javascript/javascriptApplicationGraph.js";
|
|
|
|
import {
|
|
findExportNode,
|
|
findRelationshipEdge,
|
|
findSourceModule,
|
|
reconstructJavaScriptFixture,
|
|
writeFixtureFiles,
|
|
} from "../../../support/javascriptApplicationFixture.js";
|
|
|
|
describe("TypeScript source module loader semantics", () => {
|
|
it("keeps whole type-only declarations out of runtime module relationships", async () => {
|
|
const root = await createTestTempDirectory("rea-type-module-links-");
|
|
await writeFixtureFiles(root, {
|
|
"main.ts": `
|
|
import type { Whole } from "./whole";
|
|
export type { Remote } from "./remote";
|
|
import { type Inline } from "./inline";
|
|
export type LocalType = string;
|
|
export interface LocalInterface { value: string }
|
|
`,
|
|
"whole.ts": "export interface Whole { value: string }",
|
|
"remote.ts": "export interface Remote { value: string }",
|
|
"inline.ts": "export interface Inline { value: string }",
|
|
});
|
|
|
|
const { graph } = await reconstructJavaScriptFixture(root);
|
|
const source = findSourceModule(graph, "main.ts");
|
|
const sourceProperties = source?.observations.find(
|
|
({ properties }) => properties.semantic_role === "source-module",
|
|
)?.properties;
|
|
|
|
expect(sourceProperties).toMatchObject({
|
|
import_relationships: 0,
|
|
type_relationships: 5,
|
|
export_names: [],
|
|
type_export_names: ["LocalInterface", "LocalType", "Remote"],
|
|
});
|
|
expect(
|
|
graph.edges.filter(
|
|
({ relation, properties }) =>
|
|
relation === "imports" &&
|
|
["./whole", "./remote"].includes(String(properties.specifier)),
|
|
),
|
|
).toEqual([]);
|
|
expect(
|
|
graph.edges.find(
|
|
({ source_node_id, relation, properties }) =>
|
|
source_node_id === source?.node_id &&
|
|
relation === "imports" &&
|
|
properties.kind === "static-import" &&
|
|
properties.specifier === "./inline",
|
|
),
|
|
).toBeDefined();
|
|
expect(
|
|
source?.observations
|
|
.flatMap(({ evidence }) => evidence.limitations)
|
|
.join(" "),
|
|
).toContain(
|
|
"inline type-only imports or re-exports may preserve a module side-effect dependency",
|
|
);
|
|
expect(findExportNode(graph, "main.ts", "LocalType")).toBeUndefined();
|
|
expect(findExportNode(graph, "main.ts", "LocalInterface")).toBeUndefined();
|
|
});
|
|
|
|
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("JavaScript module relationships", () => {
|
|
it("composes bindings, re-exports, dynamic imports, and JSON modules", async () => {
|
|
const root = await moduleFixture();
|
|
const first = await reconstructJavaScriptFixture(root);
|
|
const second = await reconstructJavaScriptFixture(root);
|
|
const graph = first.graph;
|
|
|
|
expect(second.graph).toEqual(graph);
|
|
expect(findSourceModule(graph, "main.cjs")).toBeDefined();
|
|
expect(findSourceModule(graph, "consumer.mjs")).toBeDefined();
|
|
expect(findSourceModule(graph, "dependency.mjs")).toBeDefined();
|
|
|
|
expect(
|
|
findRelationshipEdge(graph, {
|
|
kind: "require",
|
|
specifier: "./dependency.mjs",
|
|
resolvedPath: "dependency.mjs",
|
|
importedName: "value",
|
|
localName: "importedValue",
|
|
}),
|
|
).toBeDefined();
|
|
expect(
|
|
graph.edges.filter(
|
|
({ relation, properties }) =>
|
|
relation === "imports" && properties.specifier === "fixture-package",
|
|
),
|
|
).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
properties: expect.objectContaining({ resolved_path: null }),
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
findRelationshipEdge(graph, {
|
|
kind: "require",
|
|
specifier: "fixture-package",
|
|
resolvedPath: "node_modules/fixture-package/cjs.cjs",
|
|
localName: "packageValue",
|
|
}),
|
|
).toBeDefined();
|
|
expect(
|
|
findRelationshipEdge(graph, {
|
|
kind: "import",
|
|
specifier: "fixture-package",
|
|
resolvedPath: "node_modules/fixture-package/esm.mjs",
|
|
importedName: "default",
|
|
localName: "packageDefault",
|
|
}),
|
|
).toBeDefined();
|
|
expect(
|
|
findRelationshipEdge(graph, {
|
|
kind: "import",
|
|
specifier: "./values.js",
|
|
resolvedPath: "values.js",
|
|
importedName: "value",
|
|
localName: "alias",
|
|
}),
|
|
).toBeDefined();
|
|
expect(
|
|
findRelationshipEdge(graph, {
|
|
kind: "re-export",
|
|
specifier: "./star.js",
|
|
resolvedPath: "star.js",
|
|
importedName: "*",
|
|
exportedName: "*",
|
|
}),
|
|
).toBeDefined();
|
|
|
|
const forwarded = findExportNode(graph, "main.cjs", "forwarded");
|
|
expect(forwarded).toBeDefined();
|
|
expect(
|
|
graph.edges.some(
|
|
({ source_node_id, relation, properties }) =>
|
|
source_node_id === forwarded?.node_id &&
|
|
relation === "imports" &&
|
|
properties.resolved_path === "dependency.mjs" &&
|
|
properties.imported_name === "value",
|
|
),
|
|
).toBe(true);
|
|
|
|
expect(
|
|
graph.edges.find(
|
|
({ relation, properties }) =>
|
|
relation === "imports" &&
|
|
properties.kind === "dynamic-import" &&
|
|
properties.resolved_path === "lazy.js",
|
|
),
|
|
).toMatchObject({
|
|
source_node_id: findSourceModule(graph, "consumer.mjs")?.node_id,
|
|
});
|
|
|
|
expect(
|
|
findRelationshipEdge(graph, {
|
|
kind: "require",
|
|
specifier: "./data.json",
|
|
resolvedPath: "data.json",
|
|
})?.properties,
|
|
).toMatchObject({
|
|
target_file_kind: "json",
|
|
target_json_status: "included",
|
|
});
|
|
expect(
|
|
findRelationshipEdge(graph, {
|
|
kind: "require",
|
|
specifier: "./broken.json",
|
|
resolvedPath: "broken.json",
|
|
})?.properties,
|
|
).toMatchObject({
|
|
target_file_kind: "json",
|
|
target_json_status: "invalid",
|
|
});
|
|
expect(
|
|
findRelationshipEdge(graph, {
|
|
kind: "require",
|
|
specifier: "electron",
|
|
})?.properties,
|
|
).toMatchObject({ resolution_status: "external", resolved_path: null });
|
|
expect(
|
|
graph.edges.find(
|
|
({ relation, properties }) =>
|
|
relation === "imports" &&
|
|
properties.kind === "require" &&
|
|
properties.specifier === null,
|
|
)?.properties,
|
|
).toMatchObject({ resolved_path: null });
|
|
|
|
expect(first.statistics.parse_failures).toBe(1);
|
|
expect(graph.coverage).toMatchObject({
|
|
status: "partial",
|
|
truncated: false,
|
|
});
|
|
expect(graph.limitations.join(" ")).toMatch(/incomplete/iu);
|
|
});
|
|
});
|
|
|
|
describe("Compiler-dependent TypeScript resolution", () => {
|
|
it("retains only compiler-independent targets for .ts static imports", async () => {
|
|
const root = await createTestTempDirectory("rea-ts-conditional-loader-");
|
|
await writeFixtureFiles(root, {
|
|
"package.json": '{"type":"commonjs"}',
|
|
"main.ts": `import { value as different } from "different";
|
|
import { value as same } from "same";
|
|
void different; void same;`,
|
|
"node_modules/different/package.json": JSON.stringify({
|
|
exports: {
|
|
".": {
|
|
import: "./import.mjs",
|
|
require: "./require.cjs",
|
|
},
|
|
},
|
|
}),
|
|
"node_modules/different/import.mjs": "export const value = 'import';",
|
|
"node_modules/different/require.cjs": "exports.value = 'require';",
|
|
"node_modules/same/package.json": JSON.stringify({
|
|
exports: { ".": { import: "./shared.js", require: "./shared.js" } },
|
|
}),
|
|
"node_modules/same/shared.js": "export const value = 'shared';",
|
|
});
|
|
|
|
const { graph } = await reconstructJavaScriptFixture(root);
|
|
const different = findRelationshipEdge(graph, {
|
|
kind: "import",
|
|
specifier: "different",
|
|
localName: "different",
|
|
});
|
|
const same = findRelationshipEdge(graph, {
|
|
kind: "import",
|
|
specifier: "same",
|
|
localName: "same",
|
|
resolvedPath: "node_modules/same/shared.js",
|
|
});
|
|
const staticFinding = (specifier: string) =>
|
|
graph.edges.find(
|
|
({ relation, properties }) =>
|
|
relation === "imports" &&
|
|
properties.kind === "static-import" &&
|
|
properties.specifier === specifier,
|
|
);
|
|
|
|
expect(
|
|
findSourceModule(graph, "main.ts")?.observations[0]?.properties,
|
|
).toMatchObject({ module_format: "unknown" });
|
|
expect(different).toMatchObject({
|
|
properties: {
|
|
resolution_mode: "compiler-dependent",
|
|
resolution_status: "unavailable",
|
|
resolved_path: null,
|
|
conditional_resolutions: [
|
|
{
|
|
mode: "import",
|
|
resolution_status: "resolved",
|
|
resolved_path: "node_modules/different/import.mjs",
|
|
},
|
|
{
|
|
mode: "require",
|
|
resolution_status: "resolved",
|
|
resolved_path: "node_modules/different/require.cjs",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(same).toMatchObject({
|
|
properties: {
|
|
resolution_mode: "compiler-dependent",
|
|
resolved_path: "node_modules/same/shared.js",
|
|
resolution_status: "resolved",
|
|
},
|
|
evidence: {
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining(
|
|
"compiler-dependent without retained compiler options",
|
|
),
|
|
]),
|
|
},
|
|
});
|
|
expect(staticFinding("different")).toMatchObject({
|
|
properties: {
|
|
resolution_mode: "compiler-dependent",
|
|
resolution_status: "unavailable",
|
|
conditional_resolutions: [
|
|
{
|
|
mode: "import",
|
|
resolved_path: "node_modules/different/import.mjs",
|
|
},
|
|
{
|
|
mode: "require",
|
|
resolved_path: "node_modules/different/require.cjs",
|
|
},
|
|
],
|
|
},
|
|
evidence: {
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining(
|
|
"compiler-dependent without retained compiler options",
|
|
),
|
|
]),
|
|
},
|
|
});
|
|
expect(staticFinding("same")).toMatchObject({
|
|
properties: { resolved_path: "node_modules/same/shared.js" },
|
|
evidence: {
|
|
limitations: expect.arrayContaining([
|
|
expect.stringContaining(
|
|
"compiler-dependent without retained compiler options",
|
|
),
|
|
]),
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not assign one module origin to an export with colliding path encodings", async () => {
|
|
const root = await createTestTempDirectory("rea-ambiguous-module-origin-");
|
|
await writeFixtureFiles(root, {
|
|
"index.js": String.raw`
|
|
import * as ns from "./fixture.js";
|
|
const selected = choice ? ns["a\0b"] : ns.a.b;
|
|
export { selected };
|
|
`,
|
|
"fixture.js": "export const fixture = true;\n",
|
|
});
|
|
const { graph } = await reconstructJavaScriptFixture(root);
|
|
const applicationGraph = parseJavaScriptApplicationGraph(
|
|
JSON.parse(JSON.stringify(graph)),
|
|
);
|
|
const selectedExport = findExportNode(
|
|
applicationGraph,
|
|
"index.js",
|
|
"selected",
|
|
);
|
|
if (selectedExport === undefined)
|
|
throw new Error("Expected the selected export node");
|
|
|
|
expect(
|
|
applicationGraph.edges.filter(
|
|
({ relation, source_node_id, properties }) =>
|
|
relation === "imports" &&
|
|
source_node_id === selectedExport.node_id &&
|
|
properties.local_name === "selected",
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
describe("complete static application projections", () => {
|
|
it("retains exports and JSON keys beyond the former projection prefix", async () => {
|
|
const root = await moduleFixture();
|
|
const exportNames = Array.from(
|
|
{ length: 140 },
|
|
(_, index) =>
|
|
`export const retained_${String(index).padStart(3, "0")} = ${index};`,
|
|
);
|
|
const jsonKeys = Object.fromEntries(
|
|
Array.from({ length: 140 }, (_, index) => [`key_${index}`, index]),
|
|
);
|
|
await Promise.all([
|
|
writeFile(join(root, "many-exports.mjs"), exportNames.join("\n")),
|
|
writeFile(join(root, "many.json"), JSON.stringify(jsonKeys)),
|
|
]);
|
|
|
|
const { graph } = await reconstructJavaScriptFixture(root);
|
|
const module = findSourceModule(graph, "many-exports.mjs");
|
|
const exportObservation = module?.observations.find(
|
|
({ properties }) => properties.semantic_role === "source-module",
|
|
);
|
|
const json = graph.nodes.find((node) =>
|
|
node.observations.some(
|
|
({ properties }) => properties.path === "many.json",
|
|
),
|
|
);
|
|
const jsonObservation = json?.observations.find(
|
|
({ properties }) => properties.path === "many.json",
|
|
);
|
|
|
|
expect(exportObservation?.properties.export_names).toHaveLength(140);
|
|
expect(exportObservation?.properties.omitted_export_names).toBe(0);
|
|
expect(jsonObservation?.properties.json_top_level_keys).toHaveLength(140);
|
|
expect(jsonObservation?.properties.omitted_json_top_level_keys).toBe(0);
|
|
});
|
|
});
|
|
|
|
const moduleFixture = async (): Promise<string> => {
|
|
const root = await createTestTempDirectory("rea-module-relationships-");
|
|
await writeFixtureFiles(root, {
|
|
"package.json": '{"main":"main.cjs"}',
|
|
"main.cjs": `
|
|
const { value: importedValue } = require("./dependency.mjs");
|
|
const data = require("./data.json");
|
|
const broken = require("./broken.json");
|
|
const electron = require("electron");
|
|
const packageValue = require("fixture-package");
|
|
module.exports.forwarded = importedValue;
|
|
exports.data = data;
|
|
exports.broken = broken;
|
|
exports.electron = electron;
|
|
exports.packageValue = packageValue;
|
|
require(dynamicName);
|
|
`,
|
|
"dependency.mjs": `
|
|
export { value } from "./values.js";
|
|
export * from "./star.js";
|
|
`,
|
|
"consumer.mjs": `
|
|
import { value as alias } from "./values.js";
|
|
import packageDefault from "fixture-package";
|
|
export { alias as relayed };
|
|
void import("./lazy.js");
|
|
void packageDefault;
|
|
`,
|
|
"values.js": "export const value = 42;",
|
|
"star.js": "export const extra = true;",
|
|
"lazy.js": "export default 'lazy';",
|
|
"data.json": '{"name":"fixture","value":42}',
|
|
"broken.json": '{"name":',
|
|
"node_modules/fixture-package/package.json": JSON.stringify({
|
|
exports: {
|
|
".": {
|
|
import: "./esm.mjs",
|
|
require: "./cjs.cjs",
|
|
},
|
|
},
|
|
}),
|
|
"node_modules/fixture-package/esm.mjs": "export default 'esm';",
|
|
"node_modules/fixture-package/cjs.cjs": "module.exports = 'cjs';",
|
|
});
|
|
return root;
|
|
};
|