From 0363e5a8e1be47f534c2fb7dfa26c579aa31ddd0 Mon Sep 17 00:00:00 2001 From: Anuj Singh <122965317+anujsingh-cse@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:32:10 +0530 Subject: [PATCH] fix(reference): join internal symlink targets with the inventory NFC normalizer (#1535) * fix(reference): join internal symlink targets with the inventory NFC normalizer checkSymlinks resolved an internal symlink target with bare posix.normalize and looked the result up in the inventoried path set. A target that a filesystem reports in NFD (macOS readlink commonly does) joined to an NFD spelling while the inventory holds NFC, so validation reported "Internal symlink target must resolve to an inventoried entry" for a target the inventory actually contains. Route the join through normalizeJoinedLogicalPath, the same inventory identity normalizer #1513 applied to the runtime, HTML/module, and import joins from #1488. The feature-seed NFKC query fold stays a query. Refs #1488 * fix(reference): preserve source spelling during symlink lookup --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/domain/referenceSourceGraph.test.ts | 53 +++++++++++++++++++++++++ src/domain/referenceSourceGraph.ts | 16 +++++++- 2 files changed, 68 insertions(+), 1 deletion(-) 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",