fix(reference)!: represent unreadable symlink targets explicitly

BREAKING CHANGE: Historical source symlinks with unreadable or unknown target_state require target: null. Placeholder target strings are rejected.
This commit is contained in:
morluto 2026-10-09 21:01:52 +08:00
parent 0696bde266
commit b7b4333703
4 changed files with 97 additions and 17 deletions

View File

@ -59,7 +59,7 @@ const failedEntry = (
return {
path: entry.path,
kind: "symlink",
target: "<unreadable>",
target: null,
target_state: "unreadable",
classifications,
limitations: [limitation],

View File

@ -58,7 +58,11 @@ const unavailableFileEntry = (
...overrides,
});
const symlinkEntry = (overrides: Partial<SymlinkEntry> = {}): SymlinkEntry => ({
type KnownTargetSymlinkEntry = Extract<SymlinkEntry, { target: string }>;
const symlinkEntry = (
overrides: Partial<KnownTargetSymlinkEntry> = {},
): KnownTargetSymlinkEntry => ({
path: "src/package.json",
kind: "symlink",
target: "main.ts",
@ -237,6 +241,50 @@ describe("historical source graph", () => {
});
describe("partial historical source graphs", () => {
it("keeps an unreadable symlink target unknown and out of root identity", () => {
const base = graphInput();
base.inventory_state = "partial";
base.entries[2] = {
path: "src/package.json",
kind: "symlink",
target: null,
target_state: "unreadable",
classifications: ["manifest"],
limitations: ["Target could not be read."],
};
const graph = createHistoricalSourceGraph(base);
expect(graph.entries[2]).toMatchObject({
kind: "symlink",
target: null,
target_state: "unreadable",
});
const changedMessage = structuredClone(base);
changedMessage.entries[2] = {
path: "src/package.json",
kind: "symlink",
target: null,
target_state: "unreadable",
classifications: ["manifest"],
limitations: ["A different diagnostic."],
};
const revised = createHistoricalSourceGraph(changedMessage);
expect(revised.root_sha256).toBe(graph.root_sha256);
expect(computeHistoricalSourceGraphSha256(revised)).not.toBe(
computeHistoricalSourceGraphSha256(graph),
);
expect(() =>
createHistoricalSourceGraph({
...base,
entries: base.entries.map((entry) =>
entry.kind === "symlink"
? { ...entry, target: "<unreadable>" }
: entry,
),
}),
).toThrow();
});
it("preserves partial observations but rejects every partial complete graph", () => {
const partial = graphInput();
partial.inventory_state = "partial";

View File

@ -92,18 +92,20 @@ const isPortableAbsoluteSymlinkTarget = (target: string): boolean =>
/^[A-Za-z]:[\\/]/u.test(target) ||
/^\\\\[^\\/]+[\\/][^\\/]+/u.test(target);
const sourceSymlinkSchema = z.strictObject({
...entryBaseShape,
kind: z.literal("symlink"),
target: symlinkTargetSchema,
target_state: z.enum([
"internal",
"external",
"missing",
"unreadable",
"unknown",
]),
});
const sourceSymlinkSchema = z.union([
z.strictObject({
...entryBaseShape,
kind: z.literal("symlink"),
target: symlinkTargetSchema,
target_state: z.enum(["internal", "external", "missing"]),
}),
z.strictObject({
...entryBaseShape,
kind: z.literal("symlink"),
target: z.null(),
target_state: z.enum(["unreadable", "unknown"]),
}),
]);
const sourceEntrySchema = z.union([
sourceFileSchema,
@ -314,6 +316,7 @@ const checkSymlinks = (
): void => {
for (const [index, entry] of graph.entries.entries()) {
if (entry.kind !== "symlink") continue;
if (entry.target === null) continue;
const absolute = isPortableAbsoluteSymlinkTarget(entry.target);
if (
(entry.target_state === "external" && !absolute) ||

View File

@ -11,7 +11,11 @@ import {
importReferenceSource,
normalizeHistoricalSourceParseFailures,
} from "../../../src/application/ReferenceSourceImport.js";
import { projectReferenceSourceEntryFailure } from "../../../src/application/ReferenceSourceImportEntries.js";
import {
parseReferenceSourceEntries,
projectReferenceSourceEntryFailure,
} from "../../../src/application/ReferenceSourceImportEntries.js";
import type { ReferenceSourceRead } from "../../../src/reference/ReferenceSourceReader.js";
import {
projectReferenceSourceImportError,
type ReferenceSourceImportError,
@ -114,6 +118,33 @@ describe("reference source import error projection", () => {
});
describe("reference source symlink import", () => {
it("preserves an unreadable target as unknown instead of a synthetic path", () => {
const read: ReferenceSourceRead = {
root: "/reference",
entries: [
{
status: "failed",
kind: "symlink",
path: "src/link.ts",
code: "io",
message: "Symbolic link target could not be read",
},
],
bytesRead: 0,
limitations: [],
};
const parsed = parseReferenceSourceEntries(read, new Set());
expect(parsed.entries).toContainEqual(
expect.objectContaining({
kind: "symlink",
path: "src/link.ts",
target: null,
target_state: "unreadable",
}),
);
});
it("retains external symlink targets as local graph diagnostics", async () => {
const root = await createTestTempDirectory("rea-reference-links-");
const outside = await createTestTempDirectory("rea-reference-outside-");
@ -289,8 +320,6 @@ describe("reference source import behavior", () => {
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),