* 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>
478 lines
15 KiB
TypeScript
478 lines
15 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join, relative } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import { expect } from "vitest";
|
|
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
|
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<void> => {
|
|
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",
|
|
async (surface, { cli }) => {
|
|
const root = await createTestTempDirectory("rea-unbound-requires-");
|
|
const lines = [
|
|
'require("./dependency.cjs");',
|
|
'void require("./dependency.cjs");',
|
|
'consume(require("./dependency.cjs"));',
|
|
'require("./dependency.cjs").member;',
|
|
'const {} = require("./dependency.cjs");',
|
|
'const bound = require("./dependency.cjs");',
|
|
'const { left, right } = require("./dependency.cjs");',
|
|
'module.exports = require("./dependency.cjs", require("./nested.cjs"));',
|
|
'function local(require) { require("./shadow.cjs"); }',
|
|
"require(dynamicSpecifier);",
|
|
'let assigned; assigned = require("./dependency.cjs");',
|
|
] as const;
|
|
const source = lines.join("\n");
|
|
const files = {
|
|
"main.cjs": source,
|
|
"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),
|
|
),
|
|
);
|
|
await writeFixtureFiles(root, typedPackageFiles);
|
|
await expectNativeModuleControls(root);
|
|
|
|
let document: unknown;
|
|
if (surface === "CLI") {
|
|
const response = await cli.run({
|
|
arguments: ["analyze-javascript-application", root, "--format", "json"],
|
|
environment: { REA_LOG_LEVEL: "silent" },
|
|
});
|
|
expect(response.exitCode, response.stderr).toBe(0);
|
|
document = response.json;
|
|
} else {
|
|
const { call } = await connectLocalToolsMcp();
|
|
const response = await call("analyze_javascript_application", {
|
|
input_path: root,
|
|
format: "directory",
|
|
});
|
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
|
document = response.structuredContent;
|
|
}
|
|
const evidence = parseEvidence(document);
|
|
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
|
|
evidence.normalized_result,
|
|
);
|
|
expectImportEqualsRelationships(graph);
|
|
expectAliasRelationships(graph);
|
|
expectMixedRelationships(graph);
|
|
const links = 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 === "main.cjs",
|
|
);
|
|
expect(links).toHaveLength(10);
|
|
for (const [line, count] of [
|
|
[1, 1],
|
|
[2, 1],
|
|
[3, 1],
|
|
[4, 1],
|
|
[5, 1],
|
|
[6, 1],
|
|
[7, 2],
|
|
[8, 1],
|
|
[11, 1],
|
|
] as const)
|
|
expect(
|
|
links.filter(
|
|
({ evidence: edgeEvidence }) =>
|
|
edgeEvidence.location.available &&
|
|
edgeEvidence.location.value.kind === "source-range" &&
|
|
edgeEvidence.location.value.start.line === line,
|
|
),
|
|
`semantic links on line ${line}`,
|
|
).toHaveLength(count);
|
|
const digest = createHash("sha256").update(source).digest("hex");
|
|
for (const edge of links) {
|
|
expect(edge.evidence.artifact).toMatchObject({
|
|
available: true,
|
|
sha256: digest,
|
|
});
|
|
expect(edge.properties.resolution_status).toBe("resolved");
|
|
expect(edge.properties.resolved_path).toBe(
|
|
edge.properties.specifier === "./nested.cjs"
|
|
? "nested.cjs"
|
|
: "dependency.cjs",
|
|
);
|
|
}
|
|
const bare = links.find(
|
|
({ evidence: edgeEvidence }) =>
|
|
edgeEvidence.location.available &&
|
|
edgeEvidence.location.value.kind === "source-range" &&
|
|
edgeEvidence.location.value.start.line === 1,
|
|
);
|
|
expect(bare).toMatchObject({
|
|
properties: {
|
|
imported_name: null,
|
|
local_name: null,
|
|
exported_name: null,
|
|
},
|
|
evidence: {
|
|
location: {
|
|
available: true,
|
|
value: {
|
|
source: "main.cjs",
|
|
start: { line: 1, column: 0 },
|
|
end: { line: 1, column: lines[0].length - 1 },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
graph.nodes
|
|
.flatMap(({ observations }) => observations)
|
|
.find(
|
|
({ properties }) =>
|
|
properties.semantic_role === "source-module" &&
|
|
properties.path === "main.cjs",
|
|
)?.properties.import_relationships,
|
|
).toBe(10);
|
|
expect(
|
|
graph.edges.some(
|
|
({ relation, properties }) =>
|
|
relation === "imports" &&
|
|
properties.kind === "require" &&
|
|
properties.specifier === "./dependency.cjs",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
graph.edges.filter(
|
|
({ relation, properties }) =>
|
|
relation === "imports" &&
|
|
properties.module_link_kind === "commonjs-export" &&
|
|
properties.specifier === "./dependency.cjs",
|
|
),
|
|
).toHaveLength(1);
|
|
},
|
|
);
|