From 2ca3ad238608adfca80a7ff1aaadea6e59a58225 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:03:20 +0800 Subject: [PATCH] fix(reference): preserve exact paths and unify symlink lookup Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run. --- .../ReferenceSourceImportEntries.ts | 34 +++++---- src/domain/referenceSourceGraph.ts | 27 +++---- src/domain/referenceSourcePathIdentity.ts | 33 +++++++++ src/reference/ReferenceSourceReaderEntries.ts | 71 +++++++++++-------- src/reference/ReferenceSourceReaderTypes.ts | 1 + 5 files changed, 105 insertions(+), 61 deletions(-) create mode 100644 src/domain/referenceSourcePathIdentity.ts diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 4b3f2689f8..efde620679 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -1,10 +1,12 @@ import { createHash } from "node:crypto"; import { posix } from "node:path"; +import { logicalPathEscapesRoot } from "../domain/artifactIdentity.js"; import { - logicalPathEscapesRoot, - normalizeJoinedLogicalPath, -} from "../domain/artifactIdentity.js"; + createReferenceSourcePathLookup, + isPortableAbsoluteReferenceTarget, + type ReferenceSourcePathLookup, +} from "../domain/referenceSourcePathIdentity.js"; import { classifyReferenceSourcePath, detectReferenceSourceLanguage, @@ -83,11 +85,11 @@ const failedEntry = ( const resolveInternalSpecifier = ( fromPath: string, specifier: string, - filePaths: ReadonlySet, + lookupPath: ReferenceSourcePathLookup, ): { to: string; resolution: "internal" | "unresolved" } => { if (posix.isAbsolute(specifier)) return { to: specifier, resolution: "unresolved" }; - const normalized = normalizeJoinedLogicalPath( + const normalized = posix.normalize( posix.join(posix.dirname(fromPath), specifier), ); if (logicalPathEscapesRoot(normalized)) @@ -113,7 +115,7 @@ const resolveInternalSpecifier = ( const stem = normalized.slice(0, -posix.extname(normalized).length); candidates.push(...sourceSuffixes.map((suffix) => `${stem}${suffix}`)); } - const match = candidates.find((candidate) => filePaths.has(candidate)); + const match = lookupPath.resolveCandidates(candidates); return match === undefined ? { to: normalized, resolution: "unresolved" } : { to: match, resolution: "internal" }; @@ -121,7 +123,7 @@ const resolveInternalSpecifier = ( const appendFile = ( entry: Extract, - filePaths: ReadonlySet, + lookupPath: ReferenceSourcePathLookup, output: ParsedReferenceSourceEntries, ): void => { const language = detectReferenceSourceLanguage(entry.path); @@ -146,7 +148,7 @@ const appendFile = ( const resolved = resolveInternalSpecifier( relationship.from_path, relationship.to, - filePaths, + lookupPath, ); output.relationships.push({ ...relationship, @@ -159,11 +161,11 @@ const appendFile = ( const appendReadEntry = ( entry: Extract, - filePaths: ReadonlySet, + lookupPath: ReferenceSourcePathLookup, output: ParsedReferenceSourceEntries, ): void => { if (entry.kind === "file") { - appendFile(entry, filePaths, output); + appendFile(entry, lookupPath, output); return; } const classifications = classifyReferenceSourcePath(entry.path); @@ -180,7 +182,14 @@ const appendReadEntry = ( output.entries.push({ path: entry.path, kind: "symlink", - target: entry.target, + // The reader resolves inventory identities from its root; the persisted + // graph represents relative symlink targets from the link's directory. + target: + entry.targetState === "internal" || + (entry.targetState === "missing" && + !isPortableAbsoluteReferenceTarget(entry.target)) + ? posix.relative(posix.dirname(entry.path), entry.target) || "." + : entry.target, target_state: entry.targetState, classifications, limitations: [], @@ -199,10 +208,11 @@ export const parseReferenceSourceEntries = ( parseFailures: [], limitations: [...read.limitations], }; + const lookupPath = createReferenceSourcePathLookup(filePaths); for (const entry of read.entries) { if (signal?.aborted === true) break; if (entry.status === "failed") output.entries.push(failedEntry(entry)); - else appendReadEntry(entry, filePaths, output); + else appendReadEntry(entry, lookupPath, output); } return output; }; diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 69d603bcd8..4c3a06fa7a 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -4,7 +4,10 @@ import { z } from "zod"; import { canonicalJson } from "./comparisonSemantics.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; -import { normalizeJoinedLogicalPath } from "./artifactIdentity.js"; +import { + createReferenceSourcePathLookup, + isPortableAbsoluteReferenceTarget, +} from "./referenceSourcePathIdentity.js"; import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; @@ -82,18 +85,13 @@ const symlinkTargetSchema = z .min(1) .refine( (target) => - isPortableAbsoluteSymlinkTarget(target) || + isPortableAbsoluteReferenceTarget(target) || (!target.startsWith("/") && !target.includes("\\") && posix.normalize(target) === target), "Expected a normalized POSIX symlink target", ); -const isPortableAbsoluteSymlinkTarget = (target: string): boolean => - target.startsWith("/") || - /^[A-Za-z]:[\\/]/u.test(target) || - /^\\\\[^\\/]+[\\/][^\\/]+/u.test(target); - const sourceSymlinkSchema = z.union([ z.strictObject({ ...entryBaseShape, @@ -314,18 +312,11 @@ 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, - ); - } + const lookupPath = createReferenceSourcePathLookup(paths); for (const [index, entry] of graph.entries.entries()) { if (entry.kind !== "symlink") continue; if (entry.target === null) continue; - const absolute = isPortableAbsoluteSymlinkTarget(entry.target); + const absolute = isPortableAbsoluteReferenceTarget(entry.target); if ( (entry.target_state === "external" && !absolute) || (entry.target_state === "internal" && absolute) @@ -339,11 +330,11 @@ const checkSymlinks = ( const resolved = posix.normalize( posix.join(posix.dirname(entry.path), entry.target), ); - const normalized = normalizeJoinedLogicalPath(resolved); if ( resolved.startsWith("../") || resolved === ".." || - (!paths.has(resolved) && normalizedPathCounts.get(normalized) !== 1) + // The inventory root is implicit rather than an entry with path ".". + (resolved !== "." && lookupPath.resolve(resolved) === undefined) ) context.addIssue({ code: "custom", diff --git a/src/domain/referenceSourcePathIdentity.ts b/src/domain/referenceSourcePathIdentity.ts new file mode 100644 index 0000000000..cde64c71a3 --- /dev/null +++ b/src/domain/referenceSourcePathIdentity.ts @@ -0,0 +1,33 @@ +export interface ReferenceSourcePathLookup { + readonly resolve: (path: string) => string | undefined; + readonly resolveCandidates: (paths: readonly string[]) => string | undefined; +} + +/** + * Reference inventories retain filesystem spelling. Prefer that exact identity; + * canonical Unicode equivalence is a fallback only when it identifies one entry. + */ +export const createReferenceSourcePathLookup = ( + paths: Iterable, +): ReferenceSourcePathLookup => { + const exact = new Set(paths); + const canonical = new Map(); + for (const path of exact) { + const key = path.normalize("NFC"); + canonical.set(key, canonical.has(key) ? null : path); + } + const equivalent = (path: string): string | undefined => + canonical.get(path.normalize("NFC")) ?? undefined; + return { + resolve: (path) => (exact.has(path) ? path : equivalent(path)), + resolveCandidates: (candidates) => + candidates.find((path) => exact.has(path)) ?? + candidates.map(equivalent).find((path) => path !== undefined), + }; +}; + +/** Symlink evidence can originate on a different OS from the graph reader. */ +export const isPortableAbsoluteReferenceTarget = (target: string): boolean => + target.startsWith("/") || + /^[A-Za-z]:[\\/]/u.test(target) || + /^\\\\[^\\/]+[\\/][^\\/]+/u.test(target); diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index 36ba8952bc..be88afd147 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -1,8 +1,7 @@ -import type { BigIntStats } from "node:fs"; -import { readdir, readlink, realpath } from "node:fs/promises"; +import type { BigIntStats, Dir, Dirent } from "node:fs"; +import { opendir, readlink, realpath } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; -import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { cancelled, entryFailure, @@ -47,20 +46,50 @@ export const traverseDirectory = async ( ); return { ok: true, value: undefined }; } - const names = await readDirectoryNames(current.path); + const directories: PendingDirectory[] = []; + let readFailure: string | undefined; + let handle: Dir | undefined; + try { + handle = await opendir(current.path); + } catch (cause: unknown) { + readFailure = directoryReadFailure(cause); + } + if (handle !== undefined) { + const iterator = handle[Symbol.asyncIterator](); + try { + for (;;) { + let child: IteratorResult; + try { + child = await iterator.next(); + } catch (cause: unknown) { + readFailure = directoryReadFailure(cause); + break; + } + if (child.done) break; + const result = await processEntry( + state, + current, + child.value.name, + directories, + ); + if (!result.ok) return result; + } + } finally { + await handle.close().catch(() => undefined); + } + } if (isAborted(state.signal)) return { ok: false, error: cancelled() }; - if (!names.ok) { + if (readFailure !== undefined) { state.entries.push( entryFailure( pathFromRoot(state.root, current.path), "directory", "io", - names.message, + readFailure, ), ); return { ok: true, value: undefined }; } - if (isAborted(state.signal)) return { ok: false, error: cancelled() }; const after = await validateDirectory( state.root, state.rootIdentity, @@ -85,35 +114,15 @@ export const traverseDirectory = async ( kind: "directory", path: pathFromRoot(state.root, current.path), }); - const directories: PendingDirectory[] = []; - for (const name of names.value) { - const result = await processEntry(state, current, name, directories); - if (!result.ok) return result; - } directories.reverse(); state.pending.push(...directories); return { ok: true, value: undefined }; }; -const readDirectoryNames = async ( - path: string, -): Promise< - | { readonly ok: true; readonly value: string[] } - | { readonly ok: false; readonly message: string } -> => { - try { - return { - ok: true, - value: (await readdir(path)).sort(compareUnicodeCodePoints), - }; - } catch (cause: unknown) { - const message = filesystemFailureDetail( - cause, - "Directory could not be read", - ); - if (message === undefined) throw cause; - return { ok: false, message }; - } +const directoryReadFailure = (cause: unknown): string => { + const message = filesystemFailureDetail(cause, "Directory could not be read"); + if (message === undefined) throw cause; + return message; }; const readMetadata = async ( diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts index 9ea1a0c5ef..a686bdca78 100644 --- a/src/reference/ReferenceSourceReaderTypes.ts +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -31,6 +31,7 @@ export type ReferenceSourceEntry = readonly status: "read"; readonly kind: "symlink"; readonly path: string; + /** Internal and relative missing targets are resolved from the inventory root. */ readonly target: string; readonly targetState: "internal" | "external" | "missing"; }