diff --git a/src/domain/referenceSourceGraph.test.ts b/src/domain/referenceSourceGraph.test.ts index 15b8ade4f2..ad4f18bddb 100644 --- a/src/domain/referenceSourceGraph.test.ts +++ b/src/domain/referenceSourceGraph.test.ts @@ -409,3 +409,56 @@ describe("historical source graph validation", () => { ).toThrow(/identifier does not match/u); }); }); + +describe("historical source graph symlink path identity", () => { + it("joins internal symlink targets with the inventory NFC normalizer", () => { + // "café" spelled NFC, as the inventory produces it, and the same + // name spelled NFD, as a macOS readlink can report it. + const nfcPath = "src/café.ts"; + const nfdTarget = "cafe\u0301.ts"; + expect(nfcPath).not.toBe(nfdTarget); + + const input = { ...graphInput(), inventory_state: "partial" as const }; + input.entries[1] = fileEntry({ path: nfcPath }); + input.entries[2] = symlinkEntry({ + path: "src/link.js", + target: nfdTarget, + target_state: "internal", + }); + input.relationships = []; + const graph = createHistoricalSourceGraph(input); + expect(graph.entries[2]).toMatchObject({ + kind: "symlink", + target: nfdTarget, + target_state: "internal", + }); + }); + + it("preserves an exact NFD inventory spelling when resolving its symlink", () => { + const input = { ...graphInput(), inventory_state: "partial" as const }; + input.entries[1] = fileEntry({ path: "src/cafe\u0301.ts" }); + input.entries[2] = symlinkEntry({ + path: "src/link.js", + target: "cafe\u0301.ts", + target_state: "internal", + }); + input.relationships = []; + expect(createHistoricalSourceGraph(input).entries[1]?.path).toBe( + "src/cafe\u0301.ts", + ); + }); + + it("still rejects an internal symlink target that no inventory holds", () => { + const input = { ...graphInput(), inventory_state: "partial" as const }; + input.entries[2] = symlinkEntry({ + path: "src/link.js", + // The NFD spelling normalizes to "src/café.ts", which the + // inventory does not contain, so the join must still miss. + target: "cafe\u0301.ts", + target_state: "internal", + }); + expect(() => createHistoricalSourceGraph(input)).toThrow( + /must resolve to an inventoried entry/u, + ); + }); +}); diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 080f674a26..69d603bcd8 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { canonicalJson } from "./comparisonSemantics.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; +import { normalizeJoinedLogicalPath } from "./artifactIdentity.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; @@ -313,6 +314,14 @@ const checkSymlinks = ( paths: ReadonlySet, context: z.RefinementCtx, ): void => { + const normalizedPathCounts = new Map(); + for (const path of paths) { + const identity = normalizeJoinedLogicalPath(path); + normalizedPathCounts.set( + identity, + (normalizedPathCounts.get(identity) ?? 0) + 1, + ); + } for (const [index, entry] of graph.entries.entries()) { if (entry.kind !== "symlink") continue; if (entry.target === null) continue; @@ -330,7 +339,12 @@ const checkSymlinks = ( const resolved = posix.normalize( posix.join(posix.dirname(entry.path), entry.target), ); - if (resolved.startsWith("../") || resolved === ".." || !paths.has(resolved)) + const normalized = normalizeJoinedLogicalPath(resolved); + if ( + resolved.startsWith("../") || + resolved === ".." || + (!paths.has(resolved) && normalizedPathCounts.get(normalized) !== 1) + ) context.addIssue({ code: "custom", message: "Internal symlink target must resolve to an inventoried entry",