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>
This commit is contained in:
Anuj Singhandmorluto authored and GitHub committed 2026-10-10 23:02:10 +08:00
1 parent 905e4866c4
commit 0363e5a8e1
2 files changed
+68 -1

No files matched your search

+53
View File
@@ -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,
);
});
});
+15 -1
View File
@@ -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<string>,
context: z.RefinementCtx,
): void => {
const normalizedPathCounts = new Map<string, number>();
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",