397 lines
14 KiB
TypeScript
397 lines
14 KiB
TypeScript
import fs from "node:fs";
|
|
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { add, commit, init } from "isomorphic-git";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
import {
|
|
importReferenceSource,
|
|
normalizeHistoricalSourceParseFailures,
|
|
} from "../../../src/application/ReferenceSourceImport.js";
|
|
import { projectReferenceSourceEntryFailure } from "../../../src/application/ReferenceSourceImportEntries.js";
|
|
import {
|
|
projectReferenceSourceImportError,
|
|
type ReferenceSourceImportError,
|
|
} from "../../../src/application/ReferenceSourceImportTypes.js";
|
|
import { createHistoricalSourceManifest } from "../../../src/domain/referenceSourceGraph.js";
|
|
|
|
const fixture = async (parent: string, name: string): Promise<string> => {
|
|
const root = join(parent, name);
|
|
await mkdir(join(root, "src"), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(join(root, "src", "main.ts"), 'import "./dep";\n'),
|
|
writeFile(join(root, "src", "dep.ts"), "export const value = 1;\n"),
|
|
writeFile(join(root, "src", "broken.ts"), "const = ;\n"),
|
|
writeFile(join(root, ".env"), "SECRET_SENTINEL=do-not-record\n"),
|
|
writeFile(join(root, "package.json"), '{"name":"fixture"}\n'),
|
|
]);
|
|
return root;
|
|
};
|
|
|
|
const importTree = (
|
|
root: string,
|
|
signal?: AbortSignal,
|
|
secretPatterns: readonly string[] = [".env", ".env.*"],
|
|
) =>
|
|
importReferenceSource({
|
|
root,
|
|
caller: "reference-import-test",
|
|
policy: {
|
|
secretPatterns,
|
|
},
|
|
...(signal === undefined ? {} : { signal }),
|
|
});
|
|
|
|
describe("reference source import error projection", () => {
|
|
it("deduplicates exact parse failures without collapsing distinct reasons", () => {
|
|
const malformed = {
|
|
path: "src/main.ts",
|
|
parser: "babel",
|
|
reason: "Malformed input",
|
|
};
|
|
const unexpected = {
|
|
path: "src/main.ts",
|
|
parser: "babel",
|
|
reason: "Unexpected token",
|
|
};
|
|
|
|
expect(
|
|
normalizeHistoricalSourceParseFailures([
|
|
unexpected,
|
|
malformed,
|
|
unexpected,
|
|
]),
|
|
).toEqual([malformed, unexpected]);
|
|
});
|
|
|
|
it("retains entry failure diagnostics alongside recovery guidance", () => {
|
|
for (const [kind, code] of [
|
|
["directory", "io"],
|
|
["symlink", "io"],
|
|
["file", "io"],
|
|
["file", "cancelled"],
|
|
["unknown", "unsupported"],
|
|
] as const) {
|
|
const message = projectReferenceSourceEntryFailure({
|
|
status: "failed",
|
|
path: "safe/path",
|
|
kind,
|
|
code,
|
|
message: "Observed entry failure at /owned/fixture/path",
|
|
});
|
|
expect(message).toContain(
|
|
"Observed entry failure at /owned/fixture/path",
|
|
);
|
|
expect(message).toMatch(/Check|try again|when ready|Exclude/u);
|
|
}
|
|
});
|
|
|
|
it("projects every import failure without raw parser or policy text", () => {
|
|
const expectedCategories = {
|
|
cancelled: "cancelled",
|
|
"invalid-root": "invalid_input",
|
|
unsupported: "unsupported_host",
|
|
io: "execution_failure",
|
|
parse: "execution_failure",
|
|
} as const;
|
|
for (const [code, category] of Object.entries(expectedCategories)) {
|
|
const projected = projectReferenceSourceImportError({
|
|
tag: "reference-source-import",
|
|
code: code as ReferenceSourceImportError["code"],
|
|
message: "SECRET parser stack and /private/path",
|
|
});
|
|
expect(projected.category).toBe(category);
|
|
expect(projected.message).not.toContain("SECRET");
|
|
expect(projected.message).not.toContain("/private/path");
|
|
expect(projected.message).toMatch(
|
|
/try again|when ready|Check that|REA on Linux/u,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("reference source symlink import", () => {
|
|
it("retains external symlink targets as local graph diagnostics", async () => {
|
|
const root = await createTestTempDirectory("rea-reference-links-");
|
|
const outside = await createTestTempDirectory("rea-reference-outside-");
|
|
const target = join(outside, "target.js");
|
|
await writeFile(target, "export {};");
|
|
await symlink(target, join(root, "external.js"));
|
|
|
|
const imported = await importTree(root);
|
|
|
|
if (!imported.ok) throw imported.error;
|
|
expect(imported.value.entries).toContainEqual(
|
|
expect.objectContaining({
|
|
kind: "symlink",
|
|
path: "external.js",
|
|
target,
|
|
target_state: "external",
|
|
limitations: [],
|
|
}),
|
|
);
|
|
expect(imported.value.entries).not.toContainEqual(
|
|
expect.objectContaining({ path: "target.js", kind: "file" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("reference source manifest inventory", () => {
|
|
it("retains CMake build manifests in the imported inventory", async () => {
|
|
const root = await createTestTempDirectory("rea-reference-cmake-");
|
|
await mkdir(join(root, "src"));
|
|
await Promise.all([
|
|
writeFile(join(root, "CMakeLists.txt"), "project(example)\n"),
|
|
writeFile(
|
|
join(root, "src", "CMakeLists.txt"),
|
|
"add_library(example main.cpp)\n",
|
|
),
|
|
writeFile(join(root, "notes.txt"), "Build notes.\n"),
|
|
]);
|
|
|
|
const result = await importTree(root);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.manifests).toEqual([
|
|
"CMakeLists.txt",
|
|
"src/CMakeLists.txt",
|
|
]);
|
|
expect(result.value.entries).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "CMakeLists.txt",
|
|
classifications: ["documentation", "manifest"],
|
|
}),
|
|
);
|
|
expect(result.value.entries).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "src/CMakeLists.txt",
|
|
classifications: ["documentation", "manifest", "source"],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("reference source import behavior", () => {
|
|
// The importer declares no byte ceiling and no entry-count ceiling. Scale
|
|
// independence cannot be asserted as a relationship over a bounded fixture,
|
|
// because a cap above the fixture size would pass unnoticed, so the fixture
|
|
// has to cross any ceiling a future change would plausibly introduce: 5,000
|
|
// entries is well past a round 1,000 or 2,000 cap, and one 4 MiB member is
|
|
// well past a 1 MiB cap.
|
|
//
|
|
// The two boundaries are crossed independently rather than as a cross
|
|
// product. Cycling the sizes over every entry would make a thousand 4 MiB
|
|
// members, which is nearly 4 GiB of fixture and cannot fit a runner's disk.
|
|
// Entry count is crossed by volume, byte size by a single large member.
|
|
it("returns every written entry with complete coverage at any scale", async () => {
|
|
const root = await createTestTempDirectory("rea-reference-scale-");
|
|
const sizes = [0, 1, 4_097, 65_536, 4_194_304];
|
|
const written = Array.from(
|
|
{ length: 5_000 },
|
|
(_, index) => `entry-${String(index).padStart(5, "0")}.txt`,
|
|
);
|
|
// Only the first entry of each size takes that size; the remaining 4,995
|
|
// stay one byte each. The fixture is therefore about 4.2 MiB in total
|
|
// while still containing a member far larger than any byte ceiling.
|
|
const sizeFor = (index: number): number => sizes[index] ?? 1;
|
|
await Promise.all(
|
|
written.map((name, index) =>
|
|
writeFile(join(root, name), "a".repeat(sizeFor(index))),
|
|
),
|
|
);
|
|
|
|
const result = await importReferenceSource({
|
|
root,
|
|
caller: "reference-import-test",
|
|
policy: { secretPatterns: [] },
|
|
});
|
|
|
|
if (!result.ok) throw result.error;
|
|
// The importer always reports one standing advisory about pathname races,
|
|
// so completeness is asserted per entry rather than globally.
|
|
const limited = result.value.entries.filter(
|
|
(entry) => entry.limitations.length > 0,
|
|
);
|
|
expect(limited.map((entry) => entry.path)).toEqual([]);
|
|
expect(result.value.entries).toHaveLength(written.length);
|
|
expect(new Set(result.value.entries.map((entry) => entry.path))).toEqual(
|
|
new Set(written),
|
|
);
|
|
// Members of every size must still be hashed rather than skipped or
|
|
// truncated, including the 4 MiB member, so each entry is checked against
|
|
// the size it was actually written with.
|
|
for (const [index, size] of sizes.entries()) {
|
|
expect(result.value.entries).toContainEqual(
|
|
expect.objectContaining({
|
|
path: `entry-${String(index).padStart(5, "0")}.txt`,
|
|
kind: "file",
|
|
size,
|
|
content_state: "hashed",
|
|
}),
|
|
);
|
|
}
|
|
// The bulk of the fixture must also survive intact, so a cap on either
|
|
// dimension fails even though only five entries carry a distinct size.
|
|
const bulk = result.value.entries.filter(
|
|
(entry) =>
|
|
Number.parseInt(entry.path.replace("entry-", ""), 10) >= sizes.length,
|
|
);
|
|
expect(bulk).toHaveLength(written.length - sizes.length);
|
|
expect(bulk.every((entry) => "size" in entry && entry.size === 1)).toBe(
|
|
true,
|
|
);
|
|
// `inventory_state` is deliberately not asserted to equal "complete": the
|
|
// importer always reports a standing advisory that Node cannot offer
|
|
// descriptor-relative openat traversal, which forces "partial" on every
|
|
// host. Completeness is proven above at the entry level, where it is
|
|
// actually meaningful.
|
|
});
|
|
|
|
it("imports BMP and supplementary filenames in Unicode code point order", async () => {
|
|
const root = await createTestTempDirectory("reference-unicode-");
|
|
try {
|
|
const paths = ["\uE000.ts", "\u{10000}.ts"];
|
|
await Promise.all(
|
|
paths.map((path) =>
|
|
writeFile(join(root, path), "export const value = 1;\n"),
|
|
),
|
|
);
|
|
const result = await importTree(root);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.entries.map(({ path }) => path)).toEqual(paths);
|
|
const repeated = await importTree(root);
|
|
expect(repeated).toEqual(result);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("is relocation-stable, resolves imports, and excludes secrets before capture", async () => {
|
|
const parent = await createTestTempDirectory("rea-reference-import-");
|
|
try {
|
|
const leftRoot = await fixture(parent, "left");
|
|
const rightRoot = await fixture(parent, "right");
|
|
const [left, right] = await Promise.all([
|
|
importTree(leftRoot),
|
|
importTree(rightRoot),
|
|
]);
|
|
expect(left.ok).toBe(true);
|
|
expect(right.ok).toBe(true);
|
|
if (!left.ok || !right.ok) throw new Error("expected imports to pass");
|
|
expect(createHistoricalSourceManifest(left.value)).toEqual(
|
|
createHistoricalSourceManifest(right.value),
|
|
);
|
|
expect(left.value.relationships).toContainEqual(
|
|
expect.objectContaining({
|
|
from_path: "src/main.ts",
|
|
to: "src/dep.ts",
|
|
resolution: "internal",
|
|
}),
|
|
);
|
|
expect(left.value.parse_failures).toHaveLength(1);
|
|
expect(left.value.exclusions).toContainEqual({
|
|
path: ".env",
|
|
reason: "configured-secret",
|
|
});
|
|
expect(JSON.stringify(left.value)).not.toContain("SECRET_SENTINEL");
|
|
expect(JSON.stringify(left.value)).not.toContain(leftRoot);
|
|
} finally {
|
|
await rm(parent, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("imports the caller-selected directory and honors cancellation", async () => {
|
|
const outside = await createTestTempDirectory("rea-reference-outside-");
|
|
try {
|
|
const root = await fixture(outside, "tree");
|
|
expect(await importTree(root)).toMatchObject({ ok: true });
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const cancelled = await importTree(root, controller.signal);
|
|
expect(cancelled).toMatchObject({
|
|
ok: false,
|
|
error: { code: "cancelled" },
|
|
});
|
|
} finally {
|
|
await rm(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("records bounded local Git state without invoking Git", async () => {
|
|
const parent = await createTestTempDirectory("rea-reference-git-");
|
|
try {
|
|
const root = await fixture(parent, "repo");
|
|
await init({ fs, dir: root, defaultBranch: "main" });
|
|
for (const filepath of ["package.json", "src/main.ts", "src/dep.ts"])
|
|
await add({ fs, dir: root, filepath });
|
|
const oid = await commit({
|
|
fs,
|
|
dir: root,
|
|
author: { name: "REA Test", email: "rea@example.invalid" },
|
|
message: "fixture",
|
|
});
|
|
const result = await importTree(root);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.vcs).toEqual({ kind: "git", head: oid, dirty: null });
|
|
} finally {
|
|
await rm(parent, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("reference source path selection", () => {
|
|
it("does not omit selected files by secret-like path or filename", async () => {
|
|
const parent = await createTestTempDirectory("rea-reference-selected-");
|
|
try {
|
|
const root = await fixture(parent, "tree");
|
|
const result = await importTree(root, undefined, []);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.entries).toContainEqual(
|
|
expect.objectContaining({
|
|
path: ".env",
|
|
kind: "file",
|
|
content_state: "hashed",
|
|
}),
|
|
);
|
|
expect(result.value.exclusions).not.toContainEqual(
|
|
expect.objectContaining({ path: ".env" }),
|
|
);
|
|
} finally {
|
|
await rm(parent, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("reference source rooted module specifiers", () => {
|
|
it("does not rebase an absolute import onto a coincidentally matching source member", async () => {
|
|
const root = await createTestTempDirectory("rea-reference-rooted-");
|
|
await mkdir(join(root, "src", "outside"), { recursive: true });
|
|
await writeFile(
|
|
join(root, "src", "main.js"),
|
|
'import "/outside/dep.js"; import "./outside/dep.js";\n',
|
|
);
|
|
await writeFile(
|
|
join(root, "src", "outside", "dep.js"),
|
|
"export const value = 1;\n",
|
|
);
|
|
const result = await importTree(root);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.relationships).toContainEqual({
|
|
from_path: "src/main.js",
|
|
to: "/outside/dep.js",
|
|
kind: "imports",
|
|
resolution: "unresolved",
|
|
parse_state: "parsed",
|
|
});
|
|
expect(result.value.relationships).toContainEqual({
|
|
from_path: "src/main.js",
|
|
to: "src/outside/dep.js",
|
|
kind: "imports",
|
|
resolution: "internal",
|
|
parse_state: "parsed",
|
|
});
|
|
});
|
|
});
|