fix(reference): separate local filesystem diagnostics from graph commitments

This commit is contained in:
morluto
2026-10-24 03:10:06 +08:00
parent b165b0e830
commit cae03361cf
12 changed files with 220 additions and 38 deletions
+9 -1
View File
@@ -483,12 +483,20 @@ rea import-reference-source /absolute/path/to/source --detail summary --outline-
The default (`--detail full`) returns the complete `HistoricalSourceGraph`.
Choose `--detail summary` for a `HistoricalSourceSummary` of that same committed
inventory: its `HistoricalSourceManifest` contains the full graph and root
inventory: its `HistoricalSourceManifest` contains the semantic graph and root
digests, with provenance, VCS facts, languages, manifest paths, entry and
relationship counts, parse-failure counts, file/directory states, exclusions
and limitations. The summary is a presentation view, not a graph accepted by
source-to-bundle comparison; keep the full output for that workflow.
Known filesystem failures commit the operation, parsed errno, entry-relative
location and recovery guidance. Entry `local_diagnostics`, when present,
retains the exact host message, including local paths, outside the semantic
graph commitment. Equivalent permission failures therefore retain their manifest
identity after relocation while preserving original source diagnostics in full
output and visible summary outline entries. Other substantive limitations still
participate in the graph commitment.
`--outline-depth` requires `--detail summary` and accepts a nonnegative safe
integer. Depth 1 shows root children, depth 2 (the default) also shows their
children, and depth 0 omits all outline entries. Paths retain canonical Unicode
@@ -61,6 +61,10 @@ const failedEntry = (
): HistoricalSourceGraphInput["entries"][number] => {
const classifications = classifyReferenceSourcePath(entry.path);
const limitation = projectReferenceSourceEntryFailure(entry);
const local =
entry.localDiagnostic === undefined
? {}
: { local_diagnostics: [entry.localDiagnostic] };
if (entry.kind === "directory")
return {
path: entry.path,
@@ -68,6 +72,7 @@ const failedEntry = (
classifications,
tree_state: "unreadable",
limitations: [limitation],
...local,
};
if (entry.kind === "symlink")
return {
@@ -77,6 +82,7 @@ const failedEntry = (
target_state: "unreadable",
classifications,
limitations: [limitation],
...local,
};
if (entry.kind === "unknown" || entry.kind === "other")
return {
@@ -84,6 +90,7 @@ const failedEntry = (
kind: "unknown",
classifications: ["unknown"],
limitations: [limitation],
...local,
};
return {
path: entry.path,
@@ -94,6 +101,7 @@ const failedEntry = (
classifications: entry.kind === "file" ? classifications : ["unknown"],
content_state: "unreadable",
limitations: [limitation],
...local,
};
};
+31
View File
@@ -283,6 +283,37 @@ describe("partial historical source graphs", () => {
);
});
it("retains local diagnostics outside semantic commitments while committing operation failures", () => {
const base = graphInput();
base.inventory_state = "partial";
base.entries[1] = unavailableFileEntry({
limitations: [
"File could not be read safely: EACCES. Check permissions.",
],
local_diagnostics: [
"EACCES: permission denied, open '/first/src/main.ts'",
],
});
const graph = createHistoricalSourceGraph(base);
const moved = structuredClone(base);
moved.entries[1]!.local_diagnostics = [
"EACCES: permission denied, open '/second/src/main.ts'",
];
const relocated = createHistoricalSourceGraph(moved);
expect(createHistoricalSourceManifest(relocated)).toEqual(
createHistoricalSourceManifest(graph),
);
expect(relocated.entries[1]!.local_diagnostics).toEqual(
moved.entries[1]!.local_diagnostics,
);
moved.entries[1]!.limitations = [
"File could not be read safely: EIO. Check permissions.",
];
expect(
computeHistoricalSourceGraphSha256(createHistoricalSourceGraph(moved)),
).not.toBe(computeHistoricalSourceGraphSha256(graph));
});
it("keeps an unreadable symlink target unknown and out of root identity", () => {
const base = graphInput();
base.inventory_state = "partial";
+13 -4
View File
@@ -40,6 +40,8 @@ const entryBaseShape = {
path: relativePathSchema,
classifications: z.array(classificationSchema).min(1),
limitations: z.array(boundedTextSchema),
// Host presentation is retained evidence, separate from relocatable semantics.
local_diagnostics: z.array(boundedTextSchema).optional(),
};
const sourceFileBaseShape = {
@@ -570,12 +572,19 @@ export const parseHistoricalSourceGraph = (
input: unknown,
): HistoricalSourceGraph => historicalSourceGraphSchema.parse(input);
/** Compute a deterministic graph commitment containing no absolute root path. */
export const computeHistoricalSourceGraphSha256 = (input: unknown): string =>
digestCanonicalValue(
parseHistoricalSourceGraph(input),
/** Commit semantic graph facts while retaining host-local diagnostic metadata separately. */
export const computeHistoricalSourceGraphSha256 = (input: unknown): string => {
const graph = parseHistoricalSourceGraph(input);
return digestCanonicalValue(
{
...graph,
entries: graph.entries.map(
({ local_diagnostics: _local, ...entry }) => entry,
),
},
"Reference source graph",
);
};
/** Build a deterministic, relocation-independent manifest for a source graph. */
export const createHistoricalSourceManifest = (
+3
View File
@@ -15,6 +15,9 @@ const outlineEntry = (entry: Entry) => {
content_state: entry.content_state,
classifications: entry.classifications,
limitations: entry.limitations,
...(entry.local_diagnostics === undefined
? {}
: { local_diagnostics: entry.local_diagnostics }),
};
};
+62 -19
View File
@@ -12,6 +12,7 @@ import {
failure,
filesystemFailureDetail,
safeSize,
type FilesystemFailureDetail,
} from "./ReferenceSourceReaderErrors.js";
import { isPathWithinRoot } from "../domain/localPath.js";
import { pathFromRoot } from "./ReferenceSourceReaderPaths.js";
@@ -37,8 +38,10 @@ const recordDirectoryFailure = (
absolute: string,
code: ReferenceSourceFailureCode,
message: string,
localDiagnostic?: string,
): void => {
if (absolute === state.root) state.rootFailure = { code, message };
if (absolute === state.root)
state.rootFailure = { code, message: localDiagnostic ?? message };
else
state.entries.push(
entryFailure(
@@ -46,6 +49,8 @@ const recordDirectoryFailure = (
"directory",
code,
message,
undefined,
localDiagnostic,
),
);
};
@@ -62,7 +67,13 @@ export const traverseDirectory = async (
);
if (isAborted(state.signal)) return { ok: false, error: cancelled() };
if (!before.ok) {
recordDirectoryFailure(state, current.path, before.code, before.message);
recordDirectoryFailure(
state,
current.path,
before.code,
before.message,
before.localDiagnostic,
);
return { ok: true, value: undefined };
}
const policy = await state.beforeDirectory?.(
@@ -71,7 +82,7 @@ export const traverseDirectory = async (
if (policy !== undefined && !policy.ok) return policy;
if (isAborted(state.signal)) return err(cancelled());
const directories: PendingDirectory[] = [];
let readFailure: string | undefined;
let readFailure: FilesystemFailureDetail | undefined;
let unrepresentableNameCount = 0;
let firstUnrepresentableNameHex: string | undefined;
let handle: Dir | undefined;
@@ -100,7 +111,9 @@ export const traverseDirectory = async (
}
if (child === null) break;
if (!Buffer.isBuffer(child.name)) {
readFailure = "Directory entry name was not returned as raw bytes";
readFailure = {
message: "Directory entry name was not returned as raw bytes",
};
break;
}
const decoded = decodeDirectoryEntryName(child.name);
@@ -121,7 +134,13 @@ export const traverseDirectory = async (
}
if (cleanup.kind === "failed") {
if (result.ok && !isAborted(state.signal) && readFailure !== undefined)
recordDirectoryFailure(state, current.path, "io", readFailure);
recordDirectoryFailure(
state,
current.path,
"io",
readFailure.message,
readFailure.localDiagnostic,
);
const observation = ArtifactReaderFailure.cleanupObservation(
cleanup.cause,
current.path,
@@ -132,7 +151,8 @@ export const traverseDirectory = async (
? cancelled()
: failure(
"io",
readFailure ??
readFailure?.localDiagnostic ??
readFailure?.message ??
`Reference directory cleanup failed: ${current.path}`,
)
: result.error;
@@ -154,7 +174,13 @@ export const traverseDirectory = async (
}
if (isAborted(state.signal)) return { ok: false, error: cancelled() };
if (readFailure !== undefined) {
recordDirectoryFailure(state, current.path, "io", readFailure);
recordDirectoryFailure(
state,
current.path,
"io",
readFailure.message,
readFailure.localDiagnostic,
);
scheduleObservedDirectories(state, directories);
return { ok: true, value: undefined };
}
@@ -171,6 +197,7 @@ export const traverseDirectory = async (
current.path,
after.ok ? "changed" : after.code,
after.ok ? "Directory changed while it was read" : after.message,
after.ok ? undefined : after.localDiagnostic,
);
scheduleObservedDirectories(state, directories);
return { ok: true, value: undefined };
@@ -200,27 +227,27 @@ const scheduleObservedDirectories = (
state.pending.push(...directories);
};
const directoryReadFailure = (cause: unknown): string => {
const message = filesystemFailureDetail(cause, "Directory could not be read");
if (message === undefined) throw cause;
return message;
const directoryReadFailure = (cause: unknown): FilesystemFailureDetail => {
const detail = filesystemFailureDetail(cause, "Directory could not be read");
if (detail === undefined) throw cause;
return detail;
};
const readMetadata = async (
path: string,
): Promise<
| { readonly ok: true; readonly value: BigIntStats }
| { readonly ok: false; readonly message: string }
| ({ readonly ok: false } & FilesystemFailureDetail)
> => {
try {
return { ok: true, value: await bigLstat(path) };
} catch (cause: unknown) {
const message = filesystemFailureDetail(
const detail = filesystemFailureDetail(
cause,
"Entry metadata could not be read",
);
if (message === undefined) throw cause;
return { ok: false, message };
if (detail === undefined) throw cause;
return { ok: false, ...detail };
}
};
@@ -246,7 +273,16 @@ const processEntry = async (
state.exclusions.push({ path, kind: "unknown" });
return { ok: true, value: undefined };
}
state.entries.push(entryFailure(path, "unknown", "io", metadata.message));
state.entries.push(
entryFailure(
path,
"unknown",
"io",
metadata.message,
undefined,
metadata.localDiagnostic,
),
);
return { ok: true, value: undefined };
}
const kind = metadata.value.isSymbolicLink()
@@ -357,12 +393,19 @@ export const readStableSymlinkTarget = async (
const code =
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
if (code === "ENOENT" || code === "ENOTDIR") return changedSymlink(path);
const message = filesystemFailureDetail(
const detail = filesystemFailureDetail(
cause,
"Symbolic link target could not be read safely",
);
if (message === undefined) throw cause;
return entryFailure(path, "symlink", "io", message);
if (detail === undefined) throw cause;
return entryFailure(
path,
"symlink",
"io",
detail.message,
undefined,
detail.localDiagnostic,
);
}
};
+23 -8
View File
@@ -52,18 +52,31 @@ const FILESYSTEM_ERROR_CODES = new Set([
"EROFS",
]);
/** Preserve actionable operating-system detail while leaving invariants visible. */
export interface FilesystemFailureDetail {
/** Relocation-independent operation and parsed errno; location lives in entry.path. */
readonly message: string;
/** Exact host diagnostic, retained separately from semantic graph commitments. */
readonly localDiagnostic?: string;
}
/** Parse known filesystem failures once, separating semantics from host presentation. */
export const filesystemFailureDetail = (
cause: unknown,
operation: string,
): string | undefined => {
): FilesystemFailureDetail | undefined => {
if (!(cause instanceof Error)) return undefined;
const code: unknown = Reflect.get(cause, "code");
if (code === "ENAMETOOLONG")
return `${operation}: ENAMETOOLONG (entry path exceeds the operating system path limit)`;
return typeof code === "string" && FILESYSTEM_ERROR_CODES.has(code)
? `${operation}: ${cause.message}`
: undefined;
if (typeof code !== "string" || !FILESYSTEM_ERROR_CODES.has(code))
return undefined;
const message =
code === "ENAMETOOLONG"
? `${operation}: ENAMETOOLONG (entry path exceeds the operating system path limit)`
: `${operation}: ${code}`;
const localDiagnostic = `${operation}: ${cause.message}`;
return {
message,
...(localDiagnostic === message ? {} : { localDiagnostic }),
};
};
/** Preserve root I/O detail while separating bad selections from access failures. */
@@ -87,12 +100,13 @@ export const rootFilesystemFailure = (
};
export const entryFailure = (
...[path, kind, code, message, size]: readonly [
...[path, kind, code, message, size, localDiagnostic]: readonly [
string,
Extract<ReferenceSourceEntry, { status: "failed" }>["kind"],
ReferenceSourceFailureCode,
string,
size?: number,
localDiagnostic?: string,
]
): ReferenceSourceEntry => ({
status: "failed",
@@ -101,4 +115,5 @@ export const entryFailure = (
code,
message,
...(size === undefined ? {} : { size }),
...(localDiagnostic === undefined ? {} : { localDiagnostic }),
});
+10 -3
View File
@@ -295,11 +295,18 @@ const readStableFileEntry = async (
"File is no longer a regular file",
safeSize(expected.size),
);
const message = filesystemFailureDetail(
const detail = filesystemFailureDetail(
cause,
"File could not be read safely",
);
if (message === undefined) throw cause;
return entryFailure(path, "file", "io", message, safeSize(expected.size));
if (detail === undefined) throw cause;
return entryFailure(
path,
"file",
"io",
detail.message,
safeSize(expected.size),
detail.localDiagnostic,
);
}
};
@@ -62,6 +62,8 @@ export type ReferenceSourceEntry =
readonly path: string;
readonly code: ReferenceSourceFailureCode;
readonly message: string;
/** Exact filesystem presentation; may contain the selected local root path. */
readonly localDiagnostic?: string;
readonly size?: number;
};
@@ -42,6 +42,7 @@ export const validateDirectory = async (
readonly ok: false;
readonly code: ReferenceSourceFailureCode;
readonly message: string;
readonly localDiagnostic?: string;
}
> => {
try {
@@ -76,15 +77,15 @@ export const validateDirectory = async (
code: "cancelled",
message: "Reference source traversal cancelled",
};
const message = filesystemFailureDetail(
const detail = filesystemFailureDetail(
cause,
"Directory identity could not be verified",
);
if (message === undefined) throw cause;
if (detail === undefined) throw cause;
return {
ok: false,
code: "io",
message,
...detail,
};
}
};
@@ -66,12 +66,14 @@ describe("reference import native entry failure details", () => {
"Owned permission fixture did not produce a read failure",
);
expect(failure.message).toContain("EACCES");
expect(failure.localDiagnostic).toContain(path);
const imported = await importTree(root);
if (!imported.ok) throw new Error(imported.error.message);
const entry = imported.value.entries.find(
(item) => item.path === failure.path,
);
expect(entry?.limitations.join(" ")).toContain(failure.message);
expect(entry?.local_diagnostics).toEqual([failure.localDiagnostic]);
} finally {
await chmod(path, 0o600);
}
@@ -1,3 +1,4 @@
import { createHistoricalSourceSummary } from "../../../src/domain/referenceSourceSummary.js";
import fs from "node:fs";
import {
chmod,
@@ -730,6 +731,58 @@ describe("reference source import behavior", () => {
}
});
it
.skipIf(process.platform === "win32" || process.getuid?.() === 0)
.each(["file", "directory"] as const)(
"commits equivalent unreadable %s inventories independently of relocation and retains exact local diagnostics",
async (kind) => {
const parent = await createTestTempDirectory(
"rea-reference-failure-relocation-",
);
const roots = [join(parent, "left"), join(parent, "right")];
const denied: string[] = [];
try {
for (const root of roots) {
await mkdir(root);
const path = join(root, kind === "file" ? "blocked.ts" : "blocked");
if (kind === "file")
await writeFile(path, "export const answer = 42;\n");
else await mkdir(path);
denied.push(path);
await chmod(path, 0);
}
const [left, right] = await Promise.all(
roots.map((root) => importTree(root)),
);
if (left === undefined || right === undefined || !left.ok || !right.ok)
throw new Error("Expected partial imports");
expect(left.value.inventory_state).toBe("partial");
expect(createHistoricalSourceManifest(left.value)).toEqual(
createHistoricalSourceManifest(right.value),
);
for (const [index, graph] of [left.value, right.value].entries()) {
const entry = graph.entries[0];
const root = roots[index];
if (entry === undefined || root === undefined)
throw new Error("Expected failed entry");
expect(entry.limitations.join(" ")).toContain("EACCES");
expect(entry.limitations.join(" ")).toMatch(/Check.*permissions/u);
expect(entry.limitations.join(" ")).not.toContain(root);
expect(entry.local_diagnostics).toEqual([
expect.stringContaining(await realpath(root)),
]);
expect(
createHistoricalSourceSummary(graph, 2).outline.entries[0],
).toMatchObject({ local_diagnostics: entry.local_diagnostics });
}
} finally {
for (const path of denied)
await chmod(path, kind === "file" ? 0o600 : 0o700);
await rm(parent, { recursive: true, force: true });
}
},
);
it("imports the caller-selected directory and honors cancellation", async () => {
const outside = await createTestTempDirectory("rea-reference-outside-");
try {