From b7b433370310c3ffe33d5027d8393151d5e862da Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:01:52 +0800 Subject: [PATCH] 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. --- .../ReferenceSourceImportEntries.ts | 2 +- src/domain/referenceSourceGraph.test.ts | 50 ++++++++++++++++++- src/domain/referenceSourceGraph.ts | 27 +++++----- .../filesystem/referenceSourceImport.test.ts | 35 +++++++++++-- 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 95e80c5f7..233e9cb4e 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -59,7 +59,7 @@ const failedEntry = ( return { path: entry.path, kind: "symlink", - target: "", + target: null, target_state: "unreadable", classifications, limitations: [limitation], diff --git a/src/domain/referenceSourceGraph.test.ts b/src/domain/referenceSourceGraph.test.ts index c4b201813..15b8ade4f 100644 --- a/src/domain/referenceSourceGraph.test.ts +++ b/src/domain/referenceSourceGraph.test.ts @@ -58,7 +58,11 @@ const unavailableFileEntry = ( ...overrides, }); -const symlinkEntry = (overrides: Partial = {}): SymlinkEntry => ({ +type KnownTargetSymlinkEntry = Extract; + +const symlinkEntry = ( + overrides: Partial = {}, +): 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: "" } + : entry, + ), + }), + ).toThrow(); + }); + it("preserves partial observations but rejects every partial complete graph", () => { const partial = graphInput(); partial.inventory_state = "partial"; diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 31a9b566f..436df0729 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -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) || diff --git a/tests/boundary/filesystem/referenceSourceImport.test.ts b/tests/boundary/filesystem/referenceSourceImport.test.ts index 934cfdc5a..d9de535bf 100644 --- a/tests/boundary/filesystem/referenceSourceImport.test.ts +++ b/tests/boundary/filesystem/referenceSourceImport.test.ts @@ -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),