235 lines
9.0 KiB
TypeScript
235 lines
9.0 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join, relative, sep } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
|
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
|
|
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const analyze = async (root: string) => {
|
|
const result = await analyzeJavaScriptApplication({
|
|
input_path: root,
|
|
format: "directory",
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
const parsed = parseApplicationGraphEvidence(result.value);
|
|
if (!parsed.ok) throw new Error("Analysis Evidence must parse");
|
|
const authenticated = parsed.value;
|
|
return {
|
|
graph: authenticated.graph,
|
|
result: javascriptApplicationAnalysisResultSchema.parse(
|
|
authenticated.evidence.normalized_result,
|
|
),
|
|
};
|
|
};
|
|
|
|
describe("literal CommonJS paths in application graphs", () => {
|
|
it("agrees with Node for both require projections while preserving ESM suffixes", async () => {
|
|
const root = await createTestTempDirectory("rea-literal-require-");
|
|
const specifier = "./dep.cjs#literal.cjs";
|
|
await Promise.all([
|
|
writeFile(
|
|
join(root, "main.cjs"),
|
|
`const value = require(${JSON.stringify(specifier)}); module.exports = value;`,
|
|
),
|
|
writeFile(
|
|
join(root, "imports.mjs"),
|
|
`import value from ${JSON.stringify(specifier)}; export { value };`,
|
|
),
|
|
writeFile(join(root, "dep.cjs"), "module.exports = 'stripped';"),
|
|
writeFile(
|
|
join(root, "dep.cjs#literal.cjs"),
|
|
"module.exports = 'literal';",
|
|
),
|
|
]);
|
|
const expected = relative(
|
|
root,
|
|
createRequire(join(root, "main.cjs")).resolve(specifier),
|
|
)
|
|
.split(sep)
|
|
.join("/");
|
|
expect(expected).toBe("dep.cjs#literal.cjs");
|
|
const { graph } = await analyze(root);
|
|
const properties = graph.edges
|
|
.filter((edge) => edge.properties.specifier === specifier)
|
|
.map((edge) => edge.properties);
|
|
expect(properties).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
module_link_kind: "require",
|
|
resolved_path: expected,
|
|
}),
|
|
expect.objectContaining({ kind: "require", resolved_path: expected }),
|
|
expect.objectContaining({
|
|
module_link_kind: "import",
|
|
resolved_path: "dep.cjs",
|
|
}),
|
|
expect.objectContaining({
|
|
kind: "static-import",
|
|
resolved_path: "dep.cjs",
|
|
}),
|
|
]),
|
|
);
|
|
expect(graph.coverage.status).toBe("complete");
|
|
});
|
|
|
|
it.each(["literal", "stripped", "both"] as const)(
|
|
"keeps native graph/count projections aligned with %s files",
|
|
async (layout) => {
|
|
const root = await createTestTempDirectory("rea-literal-native-");
|
|
const specifier = "./addon.node#literal.node";
|
|
await Promise.all([
|
|
writeFile(
|
|
join(root, "main.cjs"),
|
|
`const addon = require(${JSON.stringify(specifier)}); module.exports = require(${JSON.stringify(specifier)}); throw new Error('must not execute');`,
|
|
),
|
|
writeFile(
|
|
join(root, "imports.mjs"),
|
|
`import addon from ${JSON.stringify(specifier)}; export { default } from ${JSON.stringify(specifier)}; throw new Error('must not execute');`,
|
|
),
|
|
...(layout !== "stripped"
|
|
? [
|
|
writeFile(
|
|
join(root, "addon.node#literal.node"),
|
|
Buffer.from([0, 1, 2, 3]),
|
|
),
|
|
]
|
|
: []),
|
|
...(layout !== "literal"
|
|
? [writeFile(join(root, "addon.node"), Buffer.from([0, 4, 5, 6]))]
|
|
: []),
|
|
]);
|
|
const { graph, result } = await analyze(root);
|
|
const bindings = graph.nodes
|
|
.filter(({ kind }) => kind === "native-export")
|
|
.flatMap(({ observations }) => observations)
|
|
.map((observation) => ({
|
|
source:
|
|
observation.evidence.location.available &&
|
|
observation.evidence.location.value.kind === "source-range"
|
|
? observation.evidence.location.value.source
|
|
: null,
|
|
binding: observation.properties.binding_kind,
|
|
path: observation.properties.resolved_path,
|
|
}));
|
|
const required = layout !== "stripped" ? "addon.node#literal.node" : null;
|
|
const imported = layout !== "literal" ? "addon.node" : null;
|
|
expect(bindings).toHaveLength(4);
|
|
expect(bindings).toEqual(
|
|
expect.arrayContaining([
|
|
{ source: "main.cjs", binding: "require", path: required },
|
|
{ source: "main.cjs", binding: "re-export", path: required },
|
|
{ source: "imports.mjs", binding: "import", path: imported },
|
|
{ source: "imports.mjs", binding: "re-export", path: imported },
|
|
]),
|
|
);
|
|
expect(result.summary).toMatchObject({
|
|
native_addon_bindings: 4,
|
|
resolved_native_addon_bindings: layout === "both" ? 4 : 2,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("literal CommonJS percent bytes in application graphs", () => {
|
|
it("agrees with Node for requests whose literal names contain %2e or %2F", async () => {
|
|
const root = await createTestTempDirectory("rea-literal-percent-require-");
|
|
const files = {
|
|
"app/lit%2eb.js": "module.exports = 'lit%2eb.js';",
|
|
"app/x%2Fy.js": "module.exports = 'x%2Fy.js';",
|
|
"app/sub/%2e%2e/x.js": "module.exports = 'sub/%2e%2e/x.js';",
|
|
"app/x.js": "module.exports = 'decoy for a decoded parent';",
|
|
"app/pkg/package.json": JSON.stringify({ main: "lit%2eb" }),
|
|
"app/pkg/lit%2eb.js": "module.exports = 'pkg/lit%2eb.js';",
|
|
"app/node_modules/dep/package.json": JSON.stringify({ name: "dep" }),
|
|
"app/node_modules/dep/lit%2eb.js": "module.exports = 'dep/lit%2eb.js';",
|
|
"escape.js": "module.exports = 'decoy outside the request directory';",
|
|
};
|
|
const specifiers = [
|
|
"./lit%2eb",
|
|
"./x%2Fy",
|
|
"./sub/%2e%2e/x",
|
|
"./pkg",
|
|
"dep/lit%2eb",
|
|
];
|
|
// CommonJS reads %2e%2e as a directory name, never as a parent segment.
|
|
const literalSegment = "./%2e%2e/escape";
|
|
for (const [path, text] of Object.entries(files)) {
|
|
await mkdir(dirname(join(root, path)), { recursive: true });
|
|
await writeFile(join(root, path), text);
|
|
}
|
|
const main = join(root, "app", "main.cjs");
|
|
await writeFile(
|
|
main,
|
|
[...specifiers, literalSegment]
|
|
.map((specifier) => `require(${JSON.stringify(specifier)});`)
|
|
.join("\n"),
|
|
);
|
|
const nodeRequire = createRequire(main);
|
|
const nodePath = (specifier: string) =>
|
|
relative(root, nodeRequire.resolve(specifier)).split(sep).join("/");
|
|
expect(() => nodeRequire.resolve(literalSegment)).toThrow(
|
|
/Cannot find module/u,
|
|
);
|
|
|
|
const { graph } = await analyze(root);
|
|
const requests = (specifier: string) =>
|
|
graph.edges.filter(
|
|
(edge) =>
|
|
edge.relation === "imports" &&
|
|
edge.properties.specifier === specifier,
|
|
);
|
|
for (const specifier of specifiers) {
|
|
const edges = requests(specifier);
|
|
expect(edges.length, specifier).toBeGreaterThan(0);
|
|
for (const edge of edges)
|
|
expect(edge.properties.resolved_path, specifier).toBe(
|
|
nodePath(specifier),
|
|
);
|
|
}
|
|
const unresolved = requests(literalSegment);
|
|
expect(unresolved.length).toBeGreaterThan(0);
|
|
for (const edge of unresolved)
|
|
expect(edge.properties.resolved_path).toBeNull();
|
|
});
|
|
});
|
|
|
|
it("reports a present non-JavaScript require target as unavailable with inventory identity", async () => {
|
|
const present = await createTestTempDirectory("rea-require-opaque-present-");
|
|
const absent = await createTestTempDirectory("rea-require-opaque-absent-");
|
|
const bytes = Buffer.from([0xde, 0xc0, 0x17, 0x0b, 0x00, 0x01, 0x02]);
|
|
const main = 'require("./core.jsc");';
|
|
await Promise.all([
|
|
writeFile(join(present, "main.cjs"), main),
|
|
writeFile(join(present, "core.jsc"), bytes),
|
|
writeFile(join(absent, "main.cjs"), main),
|
|
]);
|
|
const edge = async (root: string) => {
|
|
const { graph } = await analyze(root);
|
|
return graph.edges.find(
|
|
({ properties }) =>
|
|
properties.specifier === "./core.jsc" &&
|
|
properties.module_link_kind === "require",
|
|
);
|
|
};
|
|
const presentEdge = await edge(present);
|
|
expect(presentEdge?.properties).toMatchObject({
|
|
resolution_status: "unavailable",
|
|
candidate_path: "core.jsc",
|
|
resolved_path: null,
|
|
});
|
|
expect(presentEdge?.evidence.limitations).toContainEqual(
|
|
expect.stringContaining(
|
|
`core.jsc exists in the inventoried artifact container (sha256 ${createHash("sha256").update(bytes).digest("hex")}, ${String(bytes.length)} bytes)`,
|
|
),
|
|
);
|
|
expect((await edge(absent))?.properties).toMatchObject({
|
|
resolution_status: "not-found",
|
|
resolved_path: null,
|
|
});
|
|
});
|