mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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.
This commit is contained in:
@@ -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<string>,
|
||||
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<ReferenceSourceEntry, { status: "read"; kind: "file" }>,
|
||||
filePaths: ReadonlySet<string>,
|
||||
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<ReferenceSourceEntry, { status: "read" }>,
|
||||
filePaths: ReadonlySet<string>,
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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<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,
|
||||
);
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -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<string>,
|
||||
): ReferenceSourcePathLookup => {
|
||||
const exact = new Set(paths);
|
||||
const canonical = new Map<string, string | null>();
|
||||
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);
|
||||
@@ -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<Dirent>;
|
||||
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 (
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user