mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(reference): separate local filesystem diagnostics from graph commitments
This commit is contained in:
1 parent
b165b0e830
commit
cae03361cf
12 files changed
+220
-38
No files matched your search
+9
-1
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user