diff --git a/docs/cli.md b/docs/cli.md index 864ddecfbb..f6e936d0a6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 1426a9f178..60be947dfc 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -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, }; }; diff --git a/src/domain/referenceSourceGraph.test.ts b/src/domain/referenceSourceGraph.test.ts index 0d571774fb..7f0576763f 100644 --- a/src/domain/referenceSourceGraph.test.ts +++ b/src/domain/referenceSourceGraph.test.ts @@ -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"; diff --git a/src/domain/referenceSourceGraph.ts b/src/domain/referenceSourceGraph.ts index 61aa2b4b66..9ac3163ff3 100644 --- a/src/domain/referenceSourceGraph.ts +++ b/src/domain/referenceSourceGraph.ts @@ -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 = ( diff --git a/src/domain/referenceSourceSummary.ts b/src/domain/referenceSourceSummary.ts index 55d24025d2..57d97466bf 100644 --- a/src/domain/referenceSourceSummary.ts +++ b/src/domain/referenceSourceSummary.ts @@ -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 }), }; }; diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index adecd9b18a..40bdd668e4 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -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, + ); } }; diff --git a/src/reference/ReferenceSourceReaderErrors.ts b/src/reference/ReferenceSourceReaderErrors.ts index 85e02be759..a8b68399c4 100644 --- a/src/reference/ReferenceSourceReaderErrors.ts +++ b/src/reference/ReferenceSourceReaderErrors.ts @@ -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["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 }), }); diff --git a/src/reference/ReferenceSourceReaderFile.ts b/src/reference/ReferenceSourceReaderFile.ts index edef7a128b..14ad08cddf 100644 --- a/src/reference/ReferenceSourceReaderFile.ts +++ b/src/reference/ReferenceSourceReaderFile.ts @@ -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, + ); } }; diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts index 2f5532106d..8038a5c8f3 100644 --- a/src/reference/ReferenceSourceReaderTypes.ts +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -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; }; diff --git a/src/reference/ReferenceSourceReaderValidate.ts b/src/reference/ReferenceSourceReaderValidate.ts index 33cfbf6685..e3eca29b15 100644 --- a/src/reference/ReferenceSourceReaderValidate.ts +++ b/src/reference/ReferenceSourceReaderValidate.ts @@ -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, }; } }; diff --git a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts index 56b66f16a1..947bd02ffd 100644 --- a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts +++ b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts @@ -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); } diff --git a/tests/boundary/filesystem/referenceSourceImport.test.ts b/tests/boundary/filesystem/referenceSourceImport.test.ts index f9f31b3d7b..cb34185470 100644 --- a/tests/boundary/filesystem/referenceSourceImport.test.ts +++ b/tests/boundary/filesystem/referenceSourceImport.test.ts @@ -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 {