Require a caller-owned artifact resource scope for source traversal and Git metadata reads. Preserve cancellation, filesystem reasons, partial entries and bytes, and cleanup observations through import and the CLI. Transfer exact failed file owners at regular-file admission and close boundaries, including multiple failures encountered by Git reference resolution. Validation: 169 focused tests passed across 26 files; two Windows-only cases skipped. Build, typecheck, lint, formatting, knip, architecture guards, discovery and docs checks passed. BREAKING CHANGE: Reference source readers and imports require an ArtifactResourceScope. VCS metadata reads return Result, and CLI import failures include observed details and partial evidence.
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
import type { Stats } from "node:fs";
|
|
import { lstat, stat, type FileHandle } from "node:fs/promises";
|
|
|
|
import {
|
|
RegularFileAdmissionFailure,
|
|
openRegularFile,
|
|
sameRegularFileState,
|
|
RegularFileChangedError,
|
|
} from "../filesystem/RegularFile.js";
|
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
|
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js";
|
|
|
|
/** Filesystem admission policy for one selected regular input. */
|
|
export interface RegularFileReadOptions {
|
|
readonly signal?: AbortSignal | undefined;
|
|
readonly symlinks?: "follow" | "reject";
|
|
}
|
|
|
|
export type RegularFileReadOutcome<Value> =
|
|
| { readonly kind: "completed"; readonly value: Value }
|
|
| { readonly kind: "failed"; readonly cause: unknown };
|
|
|
|
/** A close failed after reading; this error transfers the exact handle owner. */
|
|
export class RegularFileCleanupFailure<Value = unknown> extends Error {
|
|
constructor(
|
|
readonly path: string,
|
|
readonly owner: OwnedFileHandle,
|
|
readonly outcome: RegularFileReadOutcome<Value>,
|
|
readonly cleanupCause: unknown,
|
|
) {
|
|
super(`Regular file cleanup failed: ${path}`, {
|
|
cause: outcome.kind === "failed" ? outcome.cause : cleanupCause,
|
|
});
|
|
this.name = "RegularFileCleanupFailure";
|
|
}
|
|
}
|
|
|
|
/** Retry a failed close through its retained owner and report any remaining cleanup. */
|
|
export const retryRegularFileCleanup = async <Value>(
|
|
failure: RegularFileCleanupFailure<Value>,
|
|
resources: ArtifactResourceScope,
|
|
): Promise<{
|
|
readonly outcome: RegularFileReadOutcome<Value>;
|
|
readonly cleanup?: AnalysisCleanupObservation;
|
|
}> => {
|
|
const released = await resources.release({
|
|
kind: "file-handle",
|
|
handle: failure.owner,
|
|
resource: failure.path,
|
|
});
|
|
if (released.kind === "released") return { outcome: failure.outcome };
|
|
return {
|
|
outcome: failure.outcome,
|
|
cleanup: {
|
|
reason: `${failureMessage(failure.cleanupCause)}; retry: ${failureMessage(released.cause)}`,
|
|
resources: [failure.path],
|
|
},
|
|
};
|
|
};
|
|
|
|
const failureMessage = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|
|
|
|
/** Admit one regular-file handle, retain it through the consumer, and always close it. */
|
|
export const withRegularFile = async <Value>(
|
|
path: string,
|
|
read: (handle: FileHandle, stats: Stats) => Promise<Value>,
|
|
options: RegularFileReadOptions = {},
|
|
): Promise<Value> => {
|
|
const { signal } = options;
|
|
let handle: FileHandle;
|
|
try {
|
|
handle = await openRegularFile(path, {
|
|
symlinks: options.symlinks ?? "follow",
|
|
signal,
|
|
});
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof RegularFileAdmissionFailure)) throw cause;
|
|
throw new RegularFileCleanupFailure(
|
|
path,
|
|
cause.owner,
|
|
{ kind: "failed", cause: cause.cause },
|
|
cause.cleanupCause,
|
|
);
|
|
}
|
|
const owner = new OwnedFileHandle(handle);
|
|
let outcome: RegularFileReadOutcome<Value>;
|
|
try {
|
|
const stats = await handle.stat();
|
|
signal?.throwIfAborted();
|
|
const value = await read(handle, stats);
|
|
signal?.throwIfAborted();
|
|
const [opened, currentPath] = await Promise.all([
|
|
handle.stat(),
|
|
options.symlinks === "reject" ? lstat(path) : stat(path),
|
|
]);
|
|
if (
|
|
!sameRegularFileState(stats, opened) ||
|
|
!sameRegularFileState(stats, currentPath)
|
|
)
|
|
throw new RegularFileChangedError(path);
|
|
outcome = { kind: "completed", value };
|
|
} catch (cause: unknown) {
|
|
outcome = { kind: "failed", cause };
|
|
}
|
|
try {
|
|
await owner.close();
|
|
} catch (cleanupCause: unknown) {
|
|
throw new RegularFileCleanupFailure(path, owner, outcome, cleanupCause);
|
|
}
|
|
if (outcome.kind === "failed") throw outcome.cause;
|
|
return outcome.value;
|
|
};
|
|
|
|
/** Admit a regular file without waiting for a pipe, then read its verified handle. */
|
|
export const readRegularFile = (
|
|
path: string,
|
|
options: RegularFileReadOptions = {},
|
|
): Promise<Buffer> =>
|
|
withRegularFile(
|
|
path,
|
|
async (handle, stats) => {
|
|
const bytes = await readBoundedFileBytes(
|
|
handle,
|
|
stats.size,
|
|
options.signal,
|
|
);
|
|
if (bytes === undefined || bytes.length !== stats.size)
|
|
throw new RegularFileChangedError(path);
|
|
return bytes;
|
|
},
|
|
options,
|
|
);
|
|
|
|
/** Read one admitted regular file as UTF-8 without waiting on a pipe. */
|
|
export const readRegularFileText = async (
|
|
path: string,
|
|
options: RegularFileReadOptions = {},
|
|
): Promise<string> => {
|
|
try {
|
|
return (await readRegularFile(path, options)).toString("utf8");
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof RegularFileCleanupFailure &&
|
|
cause.outcome.kind === "completed" &&
|
|
Buffer.isBuffer(cause.outcome.value)
|
|
)
|
|
throw new RegularFileCleanupFailure(
|
|
cause.path,
|
|
cause.owner,
|
|
{ kind: "completed", value: cause.outcome.value.toString("utf8") },
|
|
cause.cleanupCause,
|
|
);
|
|
throw cause;
|
|
}
|
|
};
|