diff --git a/src/application/ReferenceSourceImport.ts b/src/application/ReferenceSourceImport.ts index ddd85f4bb..3b8ace1e7 100644 --- a/src/application/ReferenceSourceImport.ts +++ b/src/application/ReferenceSourceImport.ts @@ -8,8 +8,14 @@ import { type HistoricalSourceGraphInput, } from "../domain/referenceSourceGraph.js"; import { err, ok, type Result } from "../domain/result.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import { readReferenceSource } from "../reference/ReferenceSourceReader.js"; -import type { ReferenceSourceEntryKind } from "../reference/ReferenceSourceReaderTypes.js"; +import type { + ReferenceSourceEntryKind, + ReferenceSourceRead, + ReferenceSourceReaderError, +} from "../reference/ReferenceSourceReaderTypes.js"; import { parseReferenceSourceEntries } from "./ReferenceSourceImportEntries.js"; import { readReferenceSourceVcs } from "./ReferenceSourceVcsAdapter.js"; import { @@ -30,8 +36,12 @@ const failure = ( message, }); -const cancelled = (): ReferenceSourceImportError => - failure("cancelled", "Reference source import cancelled"); +const cancelled = ( + partial?: ReferenceSourceRead, +): ReferenceSourceImportError => ({ + ...failure("cancelled", "Reference source import cancelled"), + ...(partial === undefined ? {} : { partial }), +}); const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; @@ -170,9 +180,10 @@ const createShouldExclude = */ export const importReferenceSource = async ( options: ReferenceSourceImportOptions, + resources: ArtifactResourceScope, ): Promise> => { if (isAborted(options.signal)) return err(cancelled()); - const prepared = await prepareReferenceSourceImport(options); + const prepared = await prepareReferenceSourceImport(options, resources); if (!prepared.ok) return prepared; const { ignored, root, secrets } = prepared.value; if (isAborted(options.signal)) return err(cancelled()); @@ -180,23 +191,30 @@ export const importReferenceSource = async ( const exclusions: HistoricalSourceGraphInput["exclusions"] = []; const shouldExclude = createShouldExclude(exclusions, secrets, ignored); - const [readResult, vcs] = await Promise.all([ - readReferenceSource(root, { + const [readResult, vcsResult] = await Promise.all([ + readReferenceSource(root, resources, { ...(options.signal === undefined ? {} : { signal: options.signal }), shouldExclude, }), - readReferenceSourceVcs(root, options.signal), + readReferenceSourceVcs(root, resources, options.signal), ]); - if (!readResult.ok) { - const error = readResult.error; - if (error.code === "cancelled") return err(cancelled()); - if (error.code === "unsupported") - return err(failure("unsupported", error.message)); - return err(failure("io", error.message)); - } + if (!readResult.ok) + return err( + combineReadFailures( + readResult.error, + vcsResult.ok ? undefined : vcsResult.error, + readResult.error.partial, + ), + ); + if (!vcsResult.ok) + return err( + combineReadFailures(vcsResult.error, undefined, readResult.value), + ); - if (isAborted(options.signal)) return err(cancelled()); + const vcs = vcsResult.value; + + if (isAborted(options.signal)) return err(cancelled(readResult.value)); const read = readResult.value; const filePaths = new Set( @@ -208,7 +226,7 @@ export const importReferenceSource = async ( const { entries, relationships, parseFailures, limitations } = parseReferenceSourceEntries(read, filePaths, options.signal); - if (isAborted(options.signal)) return err(cancelled()); + if (isAborted(options.signal)) return err(cancelled(read)); const uniqueRelationships = deduplicateRelationships(relationships); const uniqueFailures = normalizeHistoricalSourceParseFailures(parseFailures); @@ -254,3 +272,45 @@ export const importReferenceSource = async ( ); } }; + +const combineReadFailures = ( + primary: ReferenceSourceReaderError, + other: ReferenceSourceReaderError | undefined, + partial: ReferenceSourceRead | undefined, +): ReferenceSourceImportError => { + const code = + primary.code === "cancelled" || other?.code === "cancelled" + ? "cancelled" + : primary.code; + const cleanup = mergeCleanup(primary.cleanup, other?.cleanup); + const message = + other === undefined + ? primary.message + : `${primary.message}; additional reference metadata failure: ${other.message}`; + const cause = + other === undefined + ? primary.cause + : new AggregateError( + [primary, other], + "Reference source inventory and metadata both failed", + { cause: primary }, + ); + return { + ...failure(code, message), + ...(cleanup === undefined ? {} : { cleanup }), + ...(partial === undefined ? {} : { partial }), + ...(cause === undefined ? {} : { cause }), + }; +}; + +const mergeCleanup = ( + first: AnalysisCleanupObservation | undefined, + second: AnalysisCleanupObservation | undefined, +): AnalysisCleanupObservation | undefined => { + if (first === undefined) return second; + if (second === undefined) return first; + return { + reason: `${first.reason}; ${second.reason}`, + resources: [...new Set([...first.resources, ...second.resources])], + }; +}; diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index b67773f7a..547e57cd3 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -2,9 +2,15 @@ import { lstat, realpath, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; import ignore from "ignore"; -import { readRegularFileText } from "./RegularFileRead.js"; +import { + readRegularFileText, + RegularFileCleanupFailure, + retryRegularFileCleanup, +} from "./RegularFileRead.js"; import { err, ok, type Result } from "../domain/result.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS, type ReferenceSourceImportError, @@ -52,7 +58,10 @@ const resolveRoot = async ( return err(failure("cancelled", "Reference source import cancelled")); if (!metadata.isDirectory()) return err( - failure("invalid-root", "Reference source root is not a directory"), + failure( + "invalid-root", + `Reference source root is not a directory: ${requestedRoot}`, + ), ); const canonicalRoot = await realpath(resolve(requestedRoot)); if (isAborted(signal)) @@ -66,19 +75,16 @@ const resolveRoot = async ( "Reference source root could not be resolved", ); if (rootFailure === undefined) throw cause; - const message = - rootFailure.code === "invalid-root" - ? "Reference source root could not be resolved" - : rootFailure.message; - return err(failure(rootFailure.code, message)); + return err({ ...failure(rootFailure.code, rootFailure.message), cause }); } }; const buildIgnored = async ( root: string, excludePaths: readonly string[], + resources: ArtifactResourceScope, signal?: AbortSignal, -): Promise> => { +): Promise, ReferenceSourceImportError>> => { const ignored = ignore(); const policyPath = join(root, ".gitignore"); let present = true; @@ -98,7 +104,29 @@ const buildIgnored = async ( } signal?.throwIfAborted(); if (present) { - const text = await readRegularFileText(policyPath, { signal }); + let text: string; + try { + text = await readRegularFileText(policyPath, { signal }); + } catch (cause: unknown) { + if (!(cause instanceof RegularFileCleanupFailure)) throw cause; + const retry = await retryRegularFileCleanup(cause, resources); + if (retry.cleanup !== undefined) { + const primary = + cause.outcome.kind === "failed" + ? cause.outcome.cause + : cause.cleanupCause; + return err({ + ...failure( + signal?.aborted === true ? "cancelled" : "io", + `Reference source ignore policy could not be read at ${policyPath}: ${errorMessage(primary)}`, + ), + cleanup: retry.cleanup, + cause: primary, + }); + } + if (retry.outcome.kind === "failed") throw retry.outcome.cause; + text = retry.outcome.value; + } addMarkedPatterns(ignored, [text], "project-ignored"); } addMarkedPatterns( @@ -109,33 +137,55 @@ const buildIgnored = async ( for (const path of excludePaths) { addMarkedPatterns(ignored, [path, `${path}/`], "caller-excluded"); } - return ignored; + return ok(ignored); }; /** Resolve the caller-selected directory and build path filters. */ export const prepareReferenceSourceImport = async ( options: ReferenceSourceImportOptions, + resources: ArtifactResourceScope, ): Promise< Result > => { - const root = await resolveRoot(options.root, options.signal); - if (!root.ok) return root; try { - return ok({ - root: root.value, - ignored: await buildIgnored( - root.value, - options.excludePaths ?? [], - options.signal, - ), - secrets: ignore().add([...options.policy.secretPatterns]), + return await resources.run(async () => { + const root = await resolveRoot(options.root, options.signal); + if (!root.ok) return root; + try { + const ignored = await buildIgnored( + root.value, + options.excludePaths ?? [], + resources, + options.signal, + ); + if (!ignored.ok) return ignored; + return ok({ + root: root.value, + ignored: ignored.value, + secrets: ignore().add([...options.policy.secretPatterns]), + }); + } catch (cause: unknown) { + return err({ + ...failure( + options.signal?.aborted === true ? "cancelled" : "io", + `Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${errorMessage(cause)}`, + ), + cause, + }); + } }); } catch (cause: unknown) { - return err( - failure( - options.signal?.aborted === true ? "cancelled" : "io", - `Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${cause instanceof Error ? cause.message : String(cause)}`, + if (!(cause instanceof ArtifactReaderFailure)) throw cause; + return err({ + ...failure( + cause.reason === "cancelled" ? "cancelled" : "io", + cause.message, ), - ); + ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), + cause, + }); } }; + +const errorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); diff --git a/src/application/ReferenceSourceImportTypes.test.ts b/src/application/ReferenceSourceImportTypes.test.ts new file mode 100644 index 000000000..1bc4b25dd --- /dev/null +++ b/src/application/ReferenceSourceImportTypes.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + projectReferenceSourceImportError, + type ReferenceSourceImportError, +} from "./ReferenceSourceImportTypes.js"; +import type { ReferenceSourceRead } from "../reference/ReferenceSourceReaderTypes.js"; + +describe("reference-source import error projection", () => { + it("preserves primary evidence with category-specific recovery guidance", () => { + const expectedCategories = { + cancelled: "cancelled", + "invalid-root": "invalid_input", + unsupported: "unsupported_host", + io: "execution_failure", + parse: "execution_failure", + } as const; + for (const [code, category] of Object.entries(expectedCategories)) { + const projected = projectReferenceSourceImportError({ + tag: "reference-source-import", + code: code as ReferenceSourceImportError["code"], + message: "EIO while reading /selected/source/src/main.ts", + }); + expect(projected.category).toBe(category); + expect(projected.message).toContain( + "EIO while reading /selected/source/src/main.ts", + ); + expect(projected.message).toMatch( + /try again|when ready|Check that|REA on Linux/u, + ); + } + }); + + it("preserves cancellation, partial facts, and cleanup diagnostics", () => { + const bytes = Buffer.from("export const observed = true;\n"); + const partial: ReferenceSourceRead = { + root: "/selected/source", + entries: [ + { + status: "read", + kind: "file", + path: "src/main.ts", + bytes, + size: bytes.length, + }, + ], + bytesRead: bytes.length, + limitations: [], + }; + const projected = projectReferenceSourceImportError({ + tag: "reference-source-import", + code: "cancelled", + message: "Traversal cancelled after reading src/main.ts", + cleanup: { + reason: "directory close rejected", + resources: ["/selected/source"], + }, + partial, + }); + + expect(projected).toMatchObject({ + category: "cancelled", + message: expect.stringContaining( + "Traversal cancelled after reading src/main.ts", + ), + cleanup: { + reason: "directory close rejected", + resources: ["/selected/source"], + }, + partial: { + root: "/selected/source", + entries: [{ path: "src/main.ts", bytes }], + }, + }); + expect(projected.message).toContain("Cleanup failed during import"); + expect(projected.message).toContain("directory close rejected"); + expect(projected.message).toContain("/selected/source"); + expect(projected.message).not.toContain("remains unconfirmed"); + }); +}); diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index 81f79eaaa..9395dc5c0 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -1,44 +1,67 @@ import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; +import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; +import type { ReferenceSourceRead } from "../reference/ReferenceSourceReaderTypes.js"; /** Typed expected failure returned by historical-source imports. */ export interface ReferenceSourceImportError { readonly tag: "reference-source-import"; readonly code: "cancelled" | "invalid-root" | "unsupported" | "io" | "parse"; readonly message: string; + readonly cleanup?: AnalysisCleanupObservation; + readonly partial?: ReferenceSourceRead; + readonly cause?: unknown; } +const IMPORT_FAILURE_GUIDANCE = { + cancelled: { + category: "cancelled", + message: + "Reference source import was cancelled. Start it again when ready.", + }, + "invalid-root": { + category: "invalid_input", + message: + "Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.", + }, + unsupported: { + category: "unsupported_host", + message: + "Safe no-follow file opens are unavailable on this host. Import the source tree with REA on Linux (including WSL) or macOS.", + }, + io: { + category: "execution_failure", + message: + "Reference source files could not be read. Check directory permissions and try again.", + }, + parse: { + category: "execution_failure", + message: + "Reference source could not be indexed. Check that the source tree is readable, then try again.", + }, +} satisfies Record< + ReferenceSourceImportError["code"], + { readonly category: string; readonly message: string } +>; + /** Safe CLI projection for a historical-source import failure. */ export const projectReferenceSourceImportError = ( error: ReferenceSourceImportError, -): Readonly<{ category: string; message: string }> => { - if (error.code === "cancelled") - return { - category: "cancelled", - message: - "Reference source import was cancelled. Start it again when ready.", - }; - if (error.code === "invalid-root") - return { - category: "invalid_input", - message: - "Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.", - }; - if (error.code === "unsupported") - return { - category: "unsupported_host", - message: - "Safe no-follow file opens are unavailable on this host. Import the source tree with REA on Linux (including WSL) or macOS.", - }; - if (error.code === "io") - return { - category: "execution_failure", - message: - "Reference source files could not be read. Check directory permissions and try again.", - }; +): Readonly<{ + category: string; + message: string; + cleanup?: AnalysisCleanupObservation; + partial?: ReferenceSourceRead; +}> => { + const primary = IMPORT_FAILURE_GUIDANCE[error.code]; + const cleanupMessage = + error.cleanup === undefined + ? "" + : ` Cleanup failed during import for ${error.cleanup.resources.join(", ")}: ${error.cleanup.reason}.`; return { - category: "execution_failure", - message: - "Reference source could not be indexed. Check that the source tree is readable, then try again.", + category: primary.category, + message: `${error.message} ${primary.message}${cleanupMessage}`, + ...(error.cleanup === undefined ? {} : { cleanup: error.cleanup }), + ...(error.partial === undefined ? {} : { partial: error.partial }), }; }; diff --git a/src/application/ReferenceSourceVcsAdapter.ts b/src/application/ReferenceSourceVcsAdapter.ts index f70ace901..5bb6d0e35 100644 --- a/src/application/ReferenceSourceVcsAdapter.ts +++ b/src/application/ReferenceSourceVcsAdapter.ts @@ -1,7 +1,17 @@ import fs from "node:fs"; import { lstat } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { readRegularFile, readRegularFileText } from "./RegularFileRead.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; +import { + readRegularFile, + readRegularFileText, + RegularFileCleanupFailure, + retryRegularFileCleanup, +} from "./RegularFileRead.js"; +import type { ReferenceSourceReaderError } from "../reference/ReferenceSourceReaderTypes.js"; export type ReferenceSourceVcsInfo = | { @@ -19,40 +29,122 @@ export type ReferenceSourceVcsInfo = */ export const readReferenceSourceVcs = async ( root: string, + resources: ArtifactResourceScope, signal?: AbortSignal, -): Promise => { - if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null }; +): Promise> => { try { - await lstat(join(root, ".git")); + return await resources.run(async () => { + if (isAborted(signal)) return ok(unknownVcs()); + try { + await lstat(join(root, ".git")); + } catch (cause: unknown) { + return ok( + errorCode(cause) === "ENOENT" + ? { kind: "none", head: null, dirty: null } + : unknownVcs(), + ); + } + try { + const head = await resolveSourceHead(root, resources, signal); + if (isAborted(signal)) return ok(unknownVcs()); + return ok({ kind: "git", head, dirty: null }); + } catch (cause: unknown) { + if (cause instanceof ArtifactReaderFailure) throw cause; + if (cause instanceof RegularFileCleanupFailure) { + const retry = await retryRegularFileCleanup(cause, resources); + if (retry.cleanup !== undefined) { + const primary = + cause.outcome.kind === "failed" + ? cause.outcome.cause + : cause.cleanupCause; + const code = isAborted(signal) ? "cancelled" : "io"; + return err({ + tag: "reference-source-reader", + code, + message: `Git metadata could not be read safely: ${errorMessage(primary)}`, + cleanup: retry.cleanup, + cause: primary, + }); + } + if (retry.outcome.kind === "completed") + return err({ + tag: "reference-source-reader", + code: isAborted(signal) ? "cancelled" : "io", + message: isAborted(signal) + ? "Git metadata read cancelled" + : `Git metadata close failed: ${errorMessage(cause.cleanupCause)}`, + cause: cause.cleanupCause, + }); + if (retry.outcome.kind === "failed") { + return isAborted(signal) + ? err({ + tag: "reference-source-reader", + code: "cancelled", + message: "Git metadata read cancelled", + cause: retry.outcome.cause, + }) + : ok(unknownVcs()); + } + } + return ok(unknownVcs()); + } + }); } catch (cause: unknown) { - return errorCode(cause) === "ENOENT" - ? { kind: "none", head: null, dirty: null } - : { kind: "unknown", head: null, dirty: null }; - } - try { - const head = await resolveSourceHead(root, signal); - if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null }; - return { kind: "git", head, dirty: null }; - } catch (cause: unknown) { - // Unresolvable refs mean VCS state is unknown, not absent. - void cause; - return { kind: "unknown", head: null, dirty: null }; + if (!(cause instanceof ArtifactReaderFailure)) throw cause; + return err({ + tag: "reference-source-reader", + code: cause.reason === "cancelled" ? "cancelled" : "io", + message: cause.message, + ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), + cause, + }); } }; +const unknownVcs = (): ReferenceSourceVcsInfo => ({ + kind: "unknown", + head: null, + dirty: null, +}); + const resolveSourceHead = async ( root: string, + resources: ArtifactResourceScope, signal?: AbortSignal, ): Promise => { // isomorphic-git is loaded on first use so CLI and MCP startup skip it. const { resolveRef } = await import("isomorphic-git"); let failedRead: { readonly cause: unknown } | undefined; + const cleanupFailures: { + readonly failure: RegularFileCleanupFailure; + readonly cleanup?: NonNullable; + }[] = []; + const throwCleanupFailures = (primary: unknown): never => { + const cleanup = mergeCleanup( + cleanupFailures.flatMap((failure) => + failure.cleanup === undefined ? [] : [failure.cleanup], + ), + ); + throw new ArtifactReaderFailure( + isAborted(signal) ? "cancelled" : "io", + `Git metadata could not be read safely: ${errorMessage(primary)}`, + { + cause: new AggregateError( + [primary, ...cleanupFailures.map((item) => item.failure)], + "Git metadata reads and cleanup failed", + { cause: primary }, + ), + ...(cleanup === undefined ? {} : { cleanup }), + }, + ); + }; const referenceFs = { ...fs, promises: { ...fs.promises, - // isomorphic-git treats every read failure as absence. Preserve real - // failures so a readable packed ref cannot hide an unreadable loose ref. + // Let isomorphic-git continue from a failed loose ref to packed-refs, + // then report the original read failure after resolution. This preserves + // both observations when packed metadata also fails cleanup. readFile: async ( path: string | undefined, options: @@ -72,7 +164,20 @@ const resolveSourceHead = async ( ? bytes : bytes.toString(encoding); } catch (cause: unknown) { - if (errorCode(cause) !== "ENOENT") failedRead ??= { cause }; + if (cause instanceof RegularFileCleanupFailure) { + const retry = await retryRegularFileCleanup(cause, resources); + cleanupFailures.push({ + failure: cause, + ...(retry.cleanup === undefined + ? {} + : { cleanup: retry.cleanup }), + }); + if ( + retry.outcome.kind === "failed" && + errorCode(retry.outcome.cause) !== "ENOENT" + ) + failedRead ??= { cause: retry.outcome.cause }; + } else if (errorCode(cause) !== "ENOENT") failedRead ??= { cause }; throw cause; } }, @@ -82,7 +187,19 @@ const resolveSourceHead = async ( readonly gitdir?: string; readonly ref: string; }) => { - const head = await resolveRef({ fs: referenceFs, dir: root, ...options }); + let head: string; + try { + head = await resolveRef({ fs: referenceFs, dir: root, ...options }); + } catch (cause: unknown) { + if (cleanupFailures.length > 0) + throwCleanupFailures(failedRead?.cause ?? cause); + if (failedRead !== undefined) throw failedRead.cause; + throw cause; + } + if (cleanupFailures.length > 0) + throwCleanupFailures( + failedRead?.cause ?? cleanupFailurePrimary(cleanupFailures[0]), + ); signal?.throwIfAborted(); if (failedRead !== undefined) throw failedRead.cause; return head; @@ -122,9 +239,38 @@ const resolveSourceHead = async ( }); }; +const cleanupFailurePrimary = ( + failure: + | { + readonly failure: RegularFileCleanupFailure; + } + | undefined, +): unknown => + failure?.failure.outcome.kind === "failed" + ? failure.failure.outcome.cause + : failure?.failure.cleanupCause; + +const mergeCleanup = ( + observations: readonly AnalysisCleanupObservation[], +): AnalysisCleanupObservation | undefined => { + if (observations.length === 0) return undefined; + const first = observations[0]; + if (first === undefined) return undefined; + return observations.slice(1).reduce( + (merged, next) => ({ + reason: `${merged.reason}; ${next.reason}`, + resources: [...new Set([...merged.resources, ...next.resources])], + }), + first, + ); +}; + const errorCode = (cause: unknown): string | undefined => typeof cause === "object" && cause !== null && "code" in cause ? String(cause.code) : undefined; +const errorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; diff --git a/src/application/RegularFileRead.ts b/src/application/RegularFileRead.ts index f584f1f6e..1c3852326 100644 --- a/src/application/RegularFileRead.ts +++ b/src/application/RegularFileRead.ts @@ -2,11 +2,15 @@ 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 { @@ -14,6 +18,51 @@ export interface RegularFileReadOptions { readonly symlinks?: "follow" | "reject"; } +export type RegularFileReadOutcome = + | { 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 extends Error { + constructor( + readonly path: string, + readonly owner: OwnedFileHandle, + readonly outcome: RegularFileReadOutcome, + 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 ( + failure: RegularFileCleanupFailure, + resources: ArtifactResourceScope, +): Promise<{ + readonly outcome: RegularFileReadOutcome; + 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 ( path: string, @@ -21,10 +70,23 @@ export const withRegularFile = async ( options: RegularFileReadOptions = {}, ): Promise => { const { signal } = options; - const handle = await openRegularFile(path, { - symlinks: options.symlinks ?? "follow", - signal, - }); + 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; try { const stats = await handle.stat(); signal?.throwIfAborted(); @@ -39,10 +101,17 @@ export const withRegularFile = async ( !sameRegularFileState(stats, currentPath) ) throw new RegularFileChangedError(path); - return value; - } finally { - await handle.close(); + 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. */ @@ -69,4 +138,21 @@ export const readRegularFile = ( export const readRegularFileText = async ( path: string, options: RegularFileReadOptions = {}, -): Promise => (await readRegularFile(path, options)).toString("utf8"); +): Promise => { + 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; + } +}; diff --git a/src/cli/referenceSourceImportStatus.ts b/src/cli/referenceSourceImportStatus.ts index 2cfe3246d..c9f8e7087 100644 --- a/src/cli/referenceSourceImportStatus.ts +++ b/src/cli/referenceSourceImportStatus.ts @@ -11,6 +11,13 @@ const referenceSourceImportFailureSchema = z.strictObject({ "execution_failure", ]), message: z.string().min(1), + cleanup: z + .strictObject({ + reason: z.string().min(1), + resources: z.array(z.string().min(1)), + }) + .optional(), + partial: z.unknown().optional(), }); /** Identify the historical-source import command's failed operation outputs. */ diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index 0c4f79c5a..47cb9e5bc 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -1,8 +1,12 @@ import { z } from "incur"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; +import type { ReferenceSourcePolicy } from "../domain/referenceSourcePolicy.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { importReferenceSource } from "../application/ReferenceSourceImport.js"; import { projectReferenceSourceImportError } from "../application/ReferenceSourceImportTypes.js"; +import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import { parseConfig } from "../config/parseConfig.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; @@ -221,21 +225,110 @@ const registerReferenceSourceCommand = ( error: "Import failed", ...projectAnalysisError(config.error), }; - const imported = await importReferenceSource({ - root: args.root, - caller: "rea-cli", - policy: config.value.referenceSourcePolicy, - importer: PRODUCT_IDENTITY.packageName, - importerVersion: null, - }); - return imported.ok - ? imported.value - : { - error: "Import failed", - ...projectReferenceSourceImportError(imported.error), - }; + return runReferenceSourceImport( + args.root, + config.value.referenceSourcePolicy, + ); }, isReferenceSourceImportCliFailure, ), }); }; + +const runReferenceSourceImport = async ( + root: string, + policy: ReferenceSourcePolicy, +): Promise => { + const resources = new ArtifactResourceScope(); + let outcome: + | { + readonly kind: "result"; + readonly imported: Awaited>; + } + | { readonly kind: "thrown"; readonly cause: unknown }; + try { + outcome = { + kind: "result", + imported: await importReferenceSource( + { + root, + caller: "rea-cli", + policy, + importer: PRODUCT_IDENTITY.packageName, + importerVersion: null, + }, + resources, + ), + }; + } catch (cause: unknown) { + outcome = { kind: "thrown", cause }; + } + + let closeOutcome: + | { readonly kind: "closed" } + | { readonly kind: "failed"; readonly cause: unknown }; + try { + await resources.close(); + closeOutcome = { kind: "closed" }; + } catch (cause: unknown) { + closeOutcome = { kind: "failed", cause }; + } + + if (outcome.kind === "thrown") { + if (closeOutcome.kind === "failed") + throw new AggregateError( + [outcome.cause, closeOutcome.cause], + "Reference source import and cleanup both failed", + { cause: outcome.cause }, + ); + throw outcome.cause; + } + + const imported = outcome.imported; + if (!imported.ok) { + const cleanup = + closeOutcome.kind === "closed" + ? imported.error.cleanup + : mergeReferenceSourceCleanup( + imported.error.cleanup, + ArtifactReaderFailure.cleanupObservation( + closeOutcome.cause, + "reference source", + ), + ); + return { + error: "Import failed", + ...projectReferenceSourceImportError({ + ...imported.error, + ...(cleanup === undefined ? {} : { cleanup }), + }), + }; + } + + if (closeOutcome.kind === "failed") + return { + error: "Import failed", + ...projectReferenceSourceImportError({ + tag: "reference-source-import", + code: "io", + message: "Reference source cleanup failed after import", + cleanup: ArtifactReaderFailure.cleanupObservation( + closeOutcome.cause, + "reference source", + ), + cause: closeOutcome.cause, + }), + }; + return imported.value; +}; + +const mergeReferenceSourceCleanup = ( + first: AnalysisCleanupObservation | undefined, + second: AnalysisCleanupObservation, +): AnalysisCleanupObservation => + first === undefined + ? second + : { + reason: `${first.reason}; ${second.reason}`, + resources: [...new Set([...first.resources, ...second.resources])], + }; diff --git a/src/reference/ReferenceSourceReader.ts b/src/reference/ReferenceSourceReader.ts index 2821dd3d4..aa1e3b1d4 100644 --- a/src/reference/ReferenceSourceReader.ts +++ b/src/reference/ReferenceSourceReader.ts @@ -1,5 +1,7 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { err, ok } from "../domain/result.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { traverseDirectory } from "./ReferenceSourceReaderEntries.js"; import { type ReferenceSourceReaderOptions, @@ -19,7 +21,27 @@ const PATH_RACE_LIMITATION = /** Read a source tree without intentionally following symbolic links. */ export const readReferenceSource = async ( root: string, + resources: ArtifactResourceScope, options: ReferenceSourceReaderOptions = {}, +): Promise> => { + try { + return await resources.run(() => readSource(root, resources, options)); + } catch (cause: unknown) { + if (!(cause instanceof ArtifactReaderFailure)) throw cause; + return err({ + tag: "reference-source-reader", + code: cause.reason === "cancelled" ? "cancelled" : "io", + message: cause.message, + ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), + cause, + }); + } +}; + +const readSource = async ( + root: string, + resources: ArtifactResourceScope, + options: ReferenceSourceReaderOptions, ): Promise> => { if (!noFollowOpenSupported()) return err({ @@ -31,6 +53,7 @@ export const readReferenceSource = async ( if (!prepared.ok) return prepared; const { canonicalRoot, rootIdentity } = prepared.value; const state: TraversalState = { + resources, root: canonicalRoot, rootIdentity, ...(options.signal === undefined ? {} : { signal: options.signal }), @@ -42,16 +65,16 @@ export const readReferenceSource = async ( bytesRead: 0, }; const traversal = await traverse(state); - if (!traversal.ok) return traversal; state.entries.sort((left, right) => compareUnicodeCodePoints(left.path, right.path), ); - return ok({ + const read: ReferenceSourceRead = { root: canonicalRoot, entries: state.entries, bytesRead: state.bytesRead, limitations: [PATH_RACE_LIMITATION], - }); + }; + return traversal.ok ? ok(read) : err({ ...traversal.error, partial: read }); }; const traverse = async ( diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index 162c96811..cad5b7864 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -1,10 +1,15 @@ import type { BigIntStats, Dir, Dirent } from "node:fs"; import { opendir, readlink, realpath } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { ArtifactCleanupAttempt } from "../artifacts/ArtifactResourceScope.js"; +import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; +import { err, ok } from "../domain/result.js"; import { cancelled, entryFailure, + failure, filesystemFailureDetail, safeSize, } from "./ReferenceSourceReaderErrors.js"; @@ -56,28 +61,68 @@ export const traverseDirectory = async ( readFailure = directoryReadFailure(cause); } if (handle !== undefined) { - const iterator = handle[Symbol.asyncIterator](); + const owner = new OwnedDirectoryHandle(handle); + let result: ReferenceSourceResult = ok(undefined); + let cleanup: ArtifactCleanupAttempt; try { for (;;) { - let child: IteratorResult; + let child: Dirent | null; try { - child = await iterator.next(); + child = await handle.read(); } 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; + if (child === null) break; + result = await processEntry(state, current, child.name, directories); + if (!result.ok) break; } } finally { - await handle.close().catch(() => undefined); + cleanup = await state.resources.release({ + kind: "directory-handle", + handle: owner, + resource: current.path, + }); } + if (cleanup.kind === "failed") { + if (result.ok && !isAborted(state.signal) && readFailure !== undefined) + state.entries.push( + entryFailure( + pathFromRoot(state.root, current.path), + "directory", + "io", + readFailure, + ), + ); + const observation = ArtifactReaderFailure.cleanupObservation( + cleanup.cause, + current.path, + ); + const previous = result.ok ? undefined : result.error.cleanup; + const primary = result.ok + ? isAborted(state.signal) + ? cancelled() + : failure( + "io", + readFailure ?? + `Reference directory cleanup failed: ${current.path}`, + ) + : result.error; + return err({ + ...primary, + cleanup: + previous === undefined + ? observation + : { + reason: `${previous.reason}; ${observation.reason}`, + resources: [ + ...new Set([...previous.resources, ...observation.resources]), + ], + }, + cause: primary.cause ?? cleanup.cause, + }); + } + if (!result.ok) return result; } if (isAborted(state.signal)) return { ok: false, error: cancelled() }; if (readFailure !== undefined) { @@ -194,7 +239,7 @@ const processEntry = async ( safeSize(metadata.value.size), ), ); - else await processFileEntry(state, absolute, path, metadata.value); + else return processFileEntry(state, absolute, path, metadata.value); return { ok: true, value: undefined }; }; @@ -203,8 +248,9 @@ const processFileEntry = async ( absolute: string, path: string, metadata: BigIntStats, -): Promise => { +): Promise> => { const result = await readStableFile({ + resources: state.resources, root: state.root, rootIdentity: state.rootIdentity, absolute, @@ -212,9 +258,24 @@ const processFileEntry = async ( expected: metadata, ...(state.signal === undefined ? {} : { signal: state.signal }), }); - if (result.status === "read" && result.kind === "file") - state.bytesRead += result.bytes.byteLength; - state.entries.push(result); + const { entry } = result; + if (entry.status === "read" && entry.kind === "file") + state.bytesRead += entry.bytes.byteLength; + state.entries.push(entry); + if (result.cleanup !== undefined) + return err({ + tag: "reference-source-reader", + code: + entry.status === "failed" && entry.code === "cancelled" + ? "cancelled" + : "io", + message: + entry.status === "failed" + ? entry.message + : `Reference file cleanup failed: ${path}`, + cleanup: result.cleanup, + }); + return ok(undefined); }; const describeSymlink = async ( diff --git a/src/reference/ReferenceSourceReaderFile.ts b/src/reference/ReferenceSourceReaderFile.ts index 6f61c0253..27b5c37ef 100644 --- a/src/reference/ReferenceSourceReaderFile.ts +++ b/src/reference/ReferenceSourceReaderFile.ts @@ -4,8 +4,12 @@ import { dirname } from "node:path"; import { NonRegularFileReadError, + RegularFileAdmissionFailure, openRegularFile, } from "../filesystem/RegularFile.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import { entryFailure, @@ -195,9 +199,39 @@ const finalizeFileRead = async ( export const readStableFile = async ( request: StableFileRequest, +): Promise<{ + readonly entry: ReferenceSourceEntry; + readonly cleanup?: AnalysisCleanupObservation; +}> => { + let owner: OwnedFileHandle | undefined; + let cleanup: AnalysisCleanupObservation | undefined; + let entry: ReferenceSourceEntry; + try { + entry = await readStableFileEntry(request, (admitted) => { + owner = admitted; + }); + } finally { + if (owner !== undefined) { + const result = await request.resources.release({ + kind: "file-handle", + handle: owner, + resource: request.absolute, + }); + if (result.kind === "failed") + cleanup = ArtifactReaderFailure.cleanupObservation( + result.cause, + request.absolute, + ); + } + } + return { entry, ...(cleanup === undefined ? {} : { cleanup }) }; +}; + +const readStableFileEntry = async ( + request: StableFileRequest, + retain: (owner: OwnedFileHandle) => void, ): Promise => { const { root, rootIdentity, absolute, path, expected, signal } = request; - let handle: FileHandle | undefined; try { const parentBefore = await validateDirectory( root, @@ -213,7 +247,11 @@ export const readStableFile = async ( parentBefore.message, safeSize(expected.size), ); - handle = await openRegularFile(absolute, { symlinks: "reject", signal }); + const handle = await openRegularFile(absolute, { + symlinks: "reject", + signal, + }); + retain(new OwnedFileHandle(handle)); const prepared = await prepareFileRead(request, handle); if (prepared.status === "failed") return prepared.entry; const contents = await readFileContents({ @@ -236,6 +274,10 @@ export const readStableFile = async ( ...(signal === undefined ? {} : { signal }), }); } catch (cause: unknown) { + if (cause instanceof RegularFileAdmissionFailure) { + retain(cause.owner); + cause = cause.cause; + } if (isAborted(signal)) return entryFailure( path, @@ -258,8 +300,5 @@ export const readStableFile = async ( ); if (message === undefined) throw cause; return entryFailure(path, "file", "io", message, safeSize(expected.size)); - } finally { - // best-effort cleanup: file-handle close must not mask the read result. - await handle?.close().catch(() => undefined); } }; diff --git a/src/reference/ReferenceSourceReaderTypes.ts b/src/reference/ReferenceSourceReaderTypes.ts index 08ed9d78b..76096ef71 100644 --- a/src/reference/ReferenceSourceReaderTypes.ts +++ b/src/reference/ReferenceSourceReaderTypes.ts @@ -1,6 +1,8 @@ import { type BigIntStats } from "node:fs"; import { type Result } from "../domain/result.js"; +import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; export type ReferenceSourceEntryKind = | "file" @@ -65,6 +67,10 @@ export interface ReferenceSourceReaderError { readonly tag: "reference-source-reader"; readonly code: "cancelled" | "invalid-root" | "io" | "unsupported"; readonly message: string; + readonly cleanup?: AnalysisCleanupObservation; + /** Captured entries before failure; this does not establish complete tree coverage. */ + readonly partial?: ReferenceSourceRead; + readonly cause?: unknown; } export type PendingDirectory = { @@ -72,6 +78,7 @@ export type PendingDirectory = { }; export type TraversalState = { + readonly resources: ArtifactResourceScope; readonly root: string; readonly rootIdentity: BigIntStats; readonly signal?: AbortSignal; @@ -85,6 +92,7 @@ export type TraversalState = { }; export type StableFileRequest = { + readonly resources: ArtifactResourceScope; readonly root: string; readonly rootIdentity: BigIntStats; readonly absolute: string; diff --git a/tests/boundary/cli/cliStartupImports.test.ts b/tests/boundary/cli/cliStartupImports.test.ts index c084b6212..9181bf7e3 100644 --- a/tests/boundary/cli/cliStartupImports.test.ts +++ b/tests/boundary/cli/cliStartupImports.test.ts @@ -62,7 +62,7 @@ describe("CLI startup imports", () => { await expect( resolvedPackages( evaluate( - `const { readReferenceSourceVcs } = await import("./dist/application/ReferenceSourceVcsAdapter.js"); await readReferenceSourceVcs(${JSON.stringify(source)});`, + `const { readReferenceSourceVcs } = await import("./dist/application/ReferenceSourceVcsAdapter.js"); const { ArtifactResourceScope } = await import("./dist/artifacts/ArtifactResourceScope.js"); const resources = new ArtifactResourceScope(); try { await readReferenceSourceVcs(${JSON.stringify(source)}, resources); } finally { await resources.close(); }`, ), ), ).resolves.toContain("isomorphic-git"); diff --git a/tests/boundary/cli/referenceSourceImportStatus.test.ts b/tests/boundary/cli/referenceSourceImportStatus.test.ts index c15a1fe63..4c971899c 100644 --- a/tests/boundary/cli/referenceSourceImportStatus.test.ts +++ b/tests/boundary/cli/referenceSourceImportStatus.test.ts @@ -6,18 +6,12 @@ import { describe, expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; -const invalidRootOutput = { - error: "Import failed", - category: "invalid_input", - message: - "Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.", -}; - const unsupportedHostOutput = { error: "Import failed", category: "unsupported_host", - message: - "Safe no-follow file opens are unavailable on this host. Import the source tree with REA on Linux (including WSL) or macOS.", + message: expect.stringContaining( + "Safe no-follow file opens are unavailable on this host.", + ), }; const permissionChecksUnavailable = @@ -105,6 +99,15 @@ describe("compiled reference-source import preflight failures", () => { ...(scenario.logging ? { REA_LOG_LEVEL: "info" } : {}), }, }); + const rootReason = + scenario.rootKind === "missing" + ? "Reference source root could not be resolved" + : "Reference source root is not a directory"; + const invalidRootOutput = { + error: "Import failed", + category: "invalid_input", + message: expect.stringContaining(rootReason), + }; if (scenario.fullOutput) { // Incur's legacy wrapper remains unchanged; operation status is // conveyed by the exit code and command log, not this `ok` field. @@ -113,8 +116,12 @@ describe("compiled reference-source import preflight failures", () => { data: invalidRootOutput, }); } else { - expect(result.json).toEqual(invalidRootOutput); + expect(result.json).toMatchObject(invalidRootOutput); } + expect(JSON.stringify(result.json)).toContain( + "Check that the path exists, is readable, and points to a directory.", + ); + expect(JSON.stringify(result.json)).toContain(root); expect.soft(result.exitCode).toBe(1); if (scenario.logging) { const records: unknown[] = result.stderr @@ -157,12 +164,15 @@ describe("compiled reference-source import preflight failures", () => { XDG_CACHE_HOME: directory, }, }); - expect(result.json).toEqual({ + expect(result.json).toMatchObject({ error: "Import failed", category: "execution_failure", - message: - "Reference source files could not be read. Check directory permissions and try again.", + message: expect.stringContaining("EACCES: permission denied"), }); + expect(JSON.stringify(result.json)).toContain(root); + expect(JSON.stringify(result.json)).toContain( + "Check directory permissions and try again.", + ); expect(result.exitCode).toBe(1); } finally { await chmod(parent, 0o700); diff --git a/tests/boundary/filesystem/referenceSourceCleanupOwnership.test.ts b/tests/boundary/filesystem/referenceSourceCleanupOwnership.test.ts new file mode 100644 index 000000000..815402ad5 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceCleanupOwnership.test.ts @@ -0,0 +1,304 @@ +import { + mkdir, + open as fsOpen, + opendir as fsOpendir, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest"; + +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; +import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: vi.fn(actual.open), + opendir: vi.fn(actual.opendir), + }; +}); + +const openMock = vi.mocked(fsOpen); +const opendirMock = vi.mocked(fsOpendir); +let actual: typeof import("node:fs/promises"); + +beforeEach(async () => { + actual = + await vi.importActual( + "node:fs/promises", + ); + openMock.mockImplementation((...args) => actual.open(...args)); + opendirMock.mockImplementation((...args) => actual.opendir(...args)); +}); + +afterEach(() => { + openMock.mockReset(); + opendirMock.mockReset(); +}); + +it("preserves admission EIO and retains an invalidated failed-close owner", async () => { + const root = await createTestTempDirectory("rea-reference-admission-owner-"); + const file = join(root, "source.ts"); + await writeFile(file, "export const answer = 42;\n"); + const resources = new ArtifactResourceScope(); + onTestFinished(() => resources.close().catch(() => undefined)); + const statFailure = Object.assign(new Error("injected admission EIO"), { + code: "EIO", + }); + const closeFailure = new Error("native close rejected after invalidation"); + let closeCalls = 0; + + openMock.mockImplementation(async (...args) => { + const handle = await actual.open(...args); + vi.spyOn(handle, "stat").mockRejectedValueOnce(statFailure); + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closeCalls += 1; + await close(); + throw closeFailure; + }); + return handle; + }); + + const result = await readReferenceSource(root, resources); + expect(result).toMatchObject({ + ok: false, + error: { + tag: "reference-source-reader", + code: "io", + message: expect.stringContaining("EIO"), + cleanup: { + resources: [file], + reason: expect.stringContaining(closeFailure.message), + }, + partial: { + entries: [ + { + status: "failed", + kind: "file", + path: "source.ts", + code: "io", + message: expect.stringContaining("EIO"), + }, + ], + }, + }, + }); + expect(closeCalls).toBe(1); + + await expect(resources.close()).rejects.toMatchObject({ + cleanup: { + resources: [file], + reason: expect.stringContaining(closeFailure.message), + }, + }); + expect(closeCalls).toBe(1); +}); + +it("retains partial file bytes and retries the same live owner before new work", async () => { + const root = await createTestTempDirectory("rea-reference-file-owner-"); + const file = join(root, "source.ts"); + const bytes = "export const answer = 'retained';\n"; + await writeFile(file, bytes); + const resources = new ArtifactResourceScope(); + const closeFailure = new Error("injected close before native close"); + let allowClose = false; + onTestFinished(async () => { + allowClose = true; + await resources.close().catch(() => undefined); + }); + let closeCalls = 0; + let openCalls = 0; + + openMock.mockImplementation(async (...args) => { + openCalls += 1; + const handle = await actual.open(...args); + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closeCalls += 1; + if (!allowClose) throw closeFailure; + await close(); + }); + return handle; + }); + + const first = await importReferenceSource( + { + root, + caller: "reference-source-cleanup-owner-test", + policy: { secretPatterns: [] }, + }, + resources, + ); + expect(first).toMatchObject({ + ok: false, + error: { + code: "io", + cleanup: { + resources: [file], + reason: expect.stringContaining(closeFailure.message), + }, + partial: { + entries: [ + { + status: "read", + kind: "file", + path: "source.ts", + bytes: Buffer.from(bytes), + }, + ], + bytesRead: Buffer.byteLength(bytes), + }, + }, + }); + expect(openCalls).toBe(1); + expect(closeCalls).toBe(1); + + const blocked = await readReferenceSource(root, resources); + expect(blocked).toMatchObject({ + ok: false, + error: { + code: "io", + cleanup: { resources: [file] }, + }, + }); + expect(openCalls).toBe(1); + expect(closeCalls).toBe(2); + + allowClose = true; + await resources.close(); + expect(openCalls).toBe(1); + expect(closeCalls).toBe(3); +}); + +it("preserves a directory read failure when closing that directory also fails", async () => { + const root = await createTestTempDirectory( + "rea-reference-directory-read-owner-", + ); + const file = join(root, "source.ts"); + const bytes = "observed before directory EIO\n"; + await writeFile(file, bytes); + const resources = new ArtifactResourceScope(); + onTestFinished(() => resources.close().catch(() => undefined)); + const readFailure = Object.assign(new Error("injected directory EIO"), { + code: "EIO", + }); + const closeFailure = new Error("directory close rejected after invalidation"); + let readCalls = 0; + let closeCalls = 0; + + opendirMock.mockImplementation(async (...args) => { + const directory = await actual.opendir(...args); + const read = directory.read.bind(directory); + vi.spyOn(directory, "read").mockImplementation(async () => { + if (readCalls++ > 0) throw readFailure; + return read(); + }); + const close = directory.close.bind(directory); + vi.spyOn(directory, "close").mockImplementation(async () => { + closeCalls += 1; + await close(); + throw closeFailure; + }); + return directory; + }); + + const result = await readReferenceSource(root, resources); + if (result.ok) throw new Error("Directory and cleanup failures were ignored"); + expect(result).toMatchObject({ + ok: false, + error: { + code: "io", + message: expect.stringContaining(readFailure.message), + cleanup: { + resources: [root], + reason: expect.stringContaining(closeFailure.message), + }, + partial: { + bytesRead: Buffer.byteLength(bytes), + }, + }, + }); + expect(result.error.partial?.entries).toContainEqual( + expect.objectContaining({ + status: "read", + kind: "file", + path: "source.ts", + bytes: Buffer.from(bytes), + }), + ); + expect(result.error.partial?.entries).toContainEqual({ + status: "failed", + kind: "directory", + path: ".", + code: "io", + message: expect.stringContaining(readFailure.message), + }); + expect(closeCalls).toBe(1); + + await expect(resources.close()).rejects.toMatchObject({ + cleanup: { + resources: [root], + reason: expect.stringContaining(closeFailure.message), + }, + }); + expect(closeCalls).toBe(1); +}); + +it("keeps cancelled traversal typed when native directory close rejects after invalidation", async () => { + const root = await createTestTempDirectory("rea-reference-directory-owner-"); + await mkdir(join(root, "child")); + const resources = new ArtifactResourceScope(); + onTestFinished(() => resources.close().catch(() => undefined)); + const controller = new AbortController(); + const closeFailure = new Error("native directory close rejected"); + let closeCalls = 0; + let openedPath: string | undefined; + + opendirMock.mockImplementation(async (...args) => { + const directory = await actual.opendir(...args); + openedPath = String(args[0]); + const read = directory.read.bind(directory); + vi.spyOn(directory, "read").mockImplementation(async () => { + const child = await read(); + if (child !== null) controller.abort(); + return child; + }); + const close = directory.close.bind(directory); + vi.spyOn(directory, "close").mockImplementation(async () => { + closeCalls += 1; + await close(); + throw closeFailure; + }); + return directory; + }); + + const result = await readReferenceSource(root, resources, { + signal: controller.signal, + }); + expect(result).toMatchObject({ + ok: false, + error: { + code: "cancelled", + cleanup: { + resources: [root], + reason: expect.stringContaining(closeFailure.message), + }, + partial: { entries: [] }, + }, + }); + expect(openedPath).toBe(root); + expect(closeCalls).toBe(1); + + await expect(resources.close()).rejects.toMatchObject({ + cleanup: { + resources: [root], + reason: expect.stringContaining(closeFailure.message), + }, + }); + expect(closeCalls).toBe(1); +}); diff --git a/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts b/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts index c478e6391..ff95c1951 100644 --- a/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts @@ -6,8 +6,8 @@ import { promisify } from "node:util"; import { expect, it } from "vitest"; -import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; it("distinguishes computed require member values from literal members in source evidence", async () => { const root = await createTestTempDirectory("rea-reference-require-members-"); diff --git a/tests/boundary/filesystem/referenceSourceDockerfileLanguage.test.ts b/tests/boundary/filesystem/referenceSourceDockerfileLanguage.test.ts index 56fe1ff86..ea8d1ac59 100644 --- a/tests/boundary/filesystem/referenceSourceDockerfileLanguage.test.ts +++ b/tests/boundary/filesystem/referenceSourceDockerfileLanguage.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import { expect, it } from "vitest"; -import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; it("imports Dockerfile-prefixed TypeScript with ordinary source evidence and uncertainty", async () => { const root = await createTestTempDirectory("rea-dockerfile-language-"); diff --git a/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts b/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts index 432b7bf30..91eab7ec6 100644 --- a/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts +++ b/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts @@ -3,8 +3,8 @@ import { join } from "node:path"; import { expect, it } from "vitest"; -import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; it("resolves dynamic source imports while retaining computed targets as unknown", async () => { const root = await createTestTempDirectory("rea-reference-dynamic-import-"); diff --git a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts index a17525e9e..dd87ea92a 100644 --- a/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts +++ b/tests/boundary/filesystem/referenceSourceFailureDetails.test.ts @@ -5,9 +5,11 @@ import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; -import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + importReferenceSource, + readReferenceSource, +} from "../../support/referenceSourceResourceScope.js"; const importTree = (root: string) => importReferenceSource({ diff --git a/tests/boundary/filesystem/referenceSourceImport.test.ts b/tests/boundary/filesystem/referenceSourceImport.test.ts index 3cebcf5b9..75eab7acb 100644 --- a/tests/boundary/filesystem/referenceSourceImport.test.ts +++ b/tests/boundary/filesystem/referenceSourceImport.test.ts @@ -7,19 +7,13 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { - importReferenceSource, - normalizeHistoricalSourceParseFailures, -} from "../../../src/application/ReferenceSourceImport.js"; +import { normalizeHistoricalSourceParseFailures } from "../../../src/application/ReferenceSourceImport.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; import { parseReferenceSourceEntries, projectReferenceSourceEntryFailure, } from "../../../src/application/ReferenceSourceImportEntries.js"; import type { ReferenceSourceRead } from "../../../src/reference/ReferenceSourceReaderTypes.js"; -import { - projectReferenceSourceImportError, - type ReferenceSourceImportError, -} from "../../../src/application/ReferenceSourceImportTypes.js"; import { createHistoricalSourceManifest } from "../../../src/domain/referenceSourceGraph.js"; const fixture = async (parent: string, name: string): Promise => { @@ -49,7 +43,7 @@ const importTree = ( ...(signal === undefined ? {} : { signal }), }); -describe("reference source import error projection", () => { +describe("reference source import projections", () => { it("deduplicates exact parse failures without collapsing distinct reasons", () => { const malformed = { path: "src/main.ts", @@ -92,29 +86,6 @@ describe("reference source import error projection", () => { expect(message).toMatch(/Check|try again|when ready|Exclude/u); } }); - - it("projects every import failure without raw parser or policy text", () => { - const expectedCategories = { - cancelled: "cancelled", - "invalid-root": "invalid_input", - unsupported: "unsupported_host", - io: "execution_failure", - parse: "execution_failure", - } as const; - for (const [code, category] of Object.entries(expectedCategories)) { - const projected = projectReferenceSourceImportError({ - tag: "reference-source-import", - code: code as ReferenceSourceImportError["code"], - message: "SECRET parser stack and /private/path", - }); - expect(projected.category).toBe(category); - expect(projected.message).not.toContain("SECRET"); - expect(projected.message).not.toContain("/private/path"); - expect(projected.message).toMatch( - /try again|when ready|Check that|REA on Linux/u, - ); - } - }); }); describe("reference source symlink import", () => { diff --git a/tests/boundary/filesystem/referenceSourceMetadataCleanup.test.ts b/tests/boundary/filesystem/referenceSourceMetadataCleanup.test.ts new file mode 100644 index 000000000..615dbf617 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceMetadataCleanup.test.ts @@ -0,0 +1,226 @@ +import { mkdir, open as fsOpen, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest"; + +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; +import { readReferenceSourceVcs } from "../../../src/application/ReferenceSourceVcsAdapter.js"; +import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, open: vi.fn(actual.open) }; +}); + +const openMock = vi.mocked(fsOpen); +let actual: typeof import("node:fs/promises"); + +beforeEach(async () => { + actual = + await vi.importActual( + "node:fs/promises", + ); + openMock.mockImplementation((...args) => actual.open(...args)); +}); + +afterEach(() => openMock.mockReset()); + +const failCloseUntilAllowed = ( + target: string, +): { readonly allow: () => void; readonly calls: () => number } => { + const closeFailure = new Error("injected metadata close failure"); + let allowClose = false; + let closeCalls = 0; + openMock.mockImplementation(async (...args) => { + const handle = await actual.open(...args); + if (resolve(String(args[0])) !== resolve(target)) return handle; + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + closeCalls += 1; + if (!allowClose) throw closeFailure; + await close(); + }); + return handle; + }); + return { + allow: () => { + allowClose = true; + }, + calls: () => closeCalls, + }; +}; + +it("retains failed .gitignore cleanup under the caller's scope", async () => { + const root = await createTestTempDirectory("rea-reference-ignore-owner-"); + const path = join(root, ".gitignore"); + await writeFile(path, "generated/\n"); + const resources = new ArtifactResourceScope(); + const close = failCloseUntilAllowed(path); + onTestFinished(async () => { + close.allow(); + await resources.close().catch(() => undefined); + }); + + const result = await importReferenceSource( + { + root, + caller: "reference-source-metadata-cleanup-test", + policy: { secretPatterns: [] }, + }, + resources, + ); + expect(result).toMatchObject({ + ok: false, + error: { + code: "io", + cleanup: { + resources: [path], + reason: expect.stringContaining("injected metadata close failure"), + }, + }, + }); + expect(close.calls()).toBe(2); + + close.allow(); + await resources.close(); + expect(close.calls()).toBe(3); +}); + +it("preserves Git metadata cleanup failure and the completed source read", async () => { + const root = await createTestTempDirectory("rea-reference-head-owner-"); + const repository = join(root, "repository"); + const sourceRoot = join(root, "source"); + await Promise.all([mkdir(repository), mkdir(sourceRoot)]); + await execFileOutput("git", ["init", "--initial-branch=main"], { + cwd: repository, + }); + const source = join(sourceRoot, "main.ts"); + const bytes = "export const value = 1;\n"; + await writeFile(source, bytes); + await writeFile(join(sourceRoot, ".git"), "gitdir: ../repository/.git\n"); + const head = join(sourceRoot, ".git"); + const resources = new ArtifactResourceScope(); + const close = failCloseUntilAllowed(head); + onTestFinished(async () => { + close.allow(); + await resources.close().catch(() => undefined); + }); + + const result = await importReferenceSource( + { + root: sourceRoot, + excludePaths: [".git"], + caller: "reference-source-metadata-cleanup-test", + policy: { secretPatterns: [] }, + }, + resources, + ); + expect(result).toMatchObject({ + ok: false, + error: { + code: "io", + cleanup: { + resources: [head], + reason: expect.stringContaining("injected metadata close failure"), + }, + partial: { + entries: [ + { + kind: "file", + path: "main.ts", + bytes: Buffer.from(bytes), + }, + ], + }, + }, + }); + expect(close.calls()).toBe(2); + + close.allow(); + await resources.close(); + expect(close.calls()).toBe(3); +}); + +it("retains later packed-ref cleanup after an earlier loose-ref read failure", async () => { + const root = await createTestTempDirectory("rea-reference-packed-owner-"); + await execFileOutput("git", ["init", "--initial-branch=main"], { cwd: root }); + await writeFile(join(root, "main.ts"), "export const value = 1;\n"); + await execFileOutput( + "git", + [ + "-c", + "user.name=REA fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + { cwd: root }, + ); + const head = ( + await execFileOutput("git", ["rev-parse", "HEAD"], { cwd: root }) + ).stdout.trim(); + await execFileOutput("git", ["pack-refs", "--all", "--prune"], { cwd: root }); + const looseRef = join(root, ".git", "refs", "heads", "main"); + const packedRefs = join(root, ".git", "packed-refs"); + await actual.mkdir(join(root, ".git", "refs", "heads"), { recursive: true }); + await writeFile(looseRef, `${head}\n`); + + const resources = new ArtifactResourceScope(); + let allowClose = false; + let looseReadFailed = false; + const packedCloseCalls = new Map(); + openMock.mockImplementation(async (...args) => { + const handle = await actual.open(...args); + const path = resolve(String(args[0])); + if (path === resolve(looseRef)) { + vi.spyOn(handle, "read").mockImplementation(async () => { + looseReadFailed = true; + throw Object.assign(new Error("injected loose ref EIO"), { + code: "EIO", + }); + }); + } + if (path === resolve(packedRefs)) { + const close = handle.close.bind(handle); + vi.spyOn(handle, "close").mockImplementation(async () => { + packedCloseCalls.set(handle, (packedCloseCalls.get(handle) ?? 0) + 1); + if (!allowClose) throw new Error("injected packed-refs close failure"); + await close(); + }); + } + return handle; + }); + onTestFinished(async () => { + allowClose = true; + await resources.close().catch(() => undefined); + }); + + const result = await readReferenceSourceVcs(root, resources); + expect(looseReadFailed).toBe(true); + expect(result).toMatchObject({ + ok: false, + error: { + code: "io", + cleanup: { + resources: [packedRefs], + reason: expect.stringContaining("injected packed-refs close failure"), + }, + }, + }); + expect([...packedCloseCalls.values()]).toEqual( + Array.from({ length: packedCloseCalls.size }, () => 2), + ); + + allowClose = true; + await resources.close(); + expect([...packedCloseCalls.values()]).toEqual( + Array.from({ length: packedCloseCalls.size }, () => 3), + ); +}); diff --git a/tests/boundary/filesystem/referenceSourceReader.test.ts b/tests/boundary/filesystem/referenceSourceReader.test.ts index 59be7f8f0..606d2fef9 100644 --- a/tests/boundary/filesystem/referenceSourceReader.test.ts +++ b/tests/boundary/filesystem/referenceSourceReader.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js"; +import { readReferenceSource } from "../../support/referenceSourceResourceScope.js"; describe("readReferenceSource entries", () => { it("returns explicit entries in canonical code-point path order", async () => { @@ -158,12 +158,17 @@ describe("readReferenceSource failures and exclusions", () => { }, }); - expect(result).toEqual({ + expect(result).toMatchObject({ ok: false, error: { tag: "reference-source-reader", code: "io", message: "Reference source exclusion check failed", + partial: { + root, + entries: [], + bytesRead: 0, + }, }, }); }); diff --git a/tests/boundary/filesystem/referenceSourceRecovery.test.ts b/tests/boundary/filesystem/referenceSourceRecovery.test.ts index 5b9f6ad45..3eea3ecaa 100644 --- a/tests/boundary/filesystem/referenceSourceRecovery.test.ts +++ b/tests/boundary/filesystem/referenceSourceRecovery.test.ts @@ -4,12 +4,10 @@ import { join } from "node:path"; import { expect, it } from "vitest"; -import { - importReferenceSource, - normalizeHistoricalSourceParseFailures, -} from "../../../src/application/ReferenceSourceImport.js"; +import { normalizeHistoricalSourceParseFailures } from "../../../src/application/ReferenceSourceImport.js"; import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; const sources = { "fatal.js": 'import "./valid";\nconst = ;', diff --git a/tests/boundary/filesystem/referenceSourceTestFilenames.test.ts b/tests/boundary/filesystem/referenceSourceTestFilenames.test.ts index 9bc174d9c..54a4955e3 100644 --- a/tests/boundary/filesystem/referenceSourceTestFilenames.test.ts +++ b/tests/boundary/filesystem/referenceSourceTestFilenames.test.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import { expect, it } from "vitest"; -import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; it("imports test-suffixed filenames with unchanged paths, hashes, and languages", async () => { const root = await createTestTempDirectory("rea-reference-test-filenames-"); diff --git a/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts b/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts index c6ca51b16..d537a1dca 100644 --- a/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts +++ b/tests/boundary/filesystem/referenceSourceTypeScriptExtensions.test.ts @@ -3,8 +3,8 @@ import { join } from "node:path"; import { expect, it } from "vitest"; -import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; it.each([ ["main.ts", "dep.js", "dep.ts"], diff --git a/tests/boundary/filesystem/referenceSourceUnreadableDirectories.test.ts b/tests/boundary/filesystem/referenceSourceUnreadableDirectories.test.ts index 545025f57..ec868052b 100644 --- a/tests/boundary/filesystem/referenceSourceUnreadableDirectories.test.ts +++ b/tests/boundary/filesystem/referenceSourceUnreadableDirectories.test.ts @@ -3,9 +3,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; -import { readReferenceSource } from "../../../src/reference/ReferenceSourceReader.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + importReferenceSource, + readReferenceSource, +} from "../../support/referenceSourceResourceScope.js"; const importTree = (root: string) => importReferenceSource({ diff --git a/tests/boundary/filesystem/referenceSourceVcs.test.ts b/tests/boundary/filesystem/referenceSourceVcs.test.ts index 3ce919f8e..dbfa9ff2b 100644 --- a/tests/boundary/filesystem/referenceSourceVcs.test.ts +++ b/tests/boundary/filesystem/referenceSourceVcs.test.ts @@ -2,9 +2,17 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { withArtifactResourceScope } from "../../fixtures/artifactInventory.js"; import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; import { readReferenceSourceVcs } from "../../../src/application/ReferenceSourceVcsAdapter.js"; +const readVcs = (root: string, signal?: AbortSignal) => + withArtifactResourceScope(async (resources) => { + const result = await readReferenceSourceVcs(root, resources, signal); + if (!result.ok) throw result.error; + return result.value; + }); + it("reads a linked worktree's own branch and detached HEAD through shared Git refs", async () => { const root = await createTestTempDirectory("rea-reference-worktree-"); const repository = join(root, "repository"); @@ -49,18 +57,18 @@ it("reads a linked worktree's own branch and detached HEAD through shared Git re await execFileOutput("git", ["rev-parse", "HEAD"], { cwd: linked }) ).stdout.trim(); expect(linkedHead).not.toBe(mainHead); - expect(await readReferenceSourceVcs(repository)).toEqual({ + expect(await readVcs(repository)).toEqual({ kind: "git", head: mainHead, dirty: null, }); - expect(await readReferenceSourceVcs(linked)).toEqual({ + expect(await readVcs(linked)).toEqual({ kind: "git", head: linkedHead, dirty: null, }); await git("pack-refs", "--all", "--prune"); - expect(await readReferenceSourceVcs(linked)).toEqual({ + expect(await readVcs(linked)).toEqual({ kind: "git", head: linkedHead, dirty: null, @@ -68,7 +76,7 @@ it("reads a linked worktree's own branch and detached HEAD through shared Git re await execFileOutput("git", ["checkout", "--detach", mainHead], { cwd: linked, }); - expect(await readReferenceSourceVcs(linked)).toEqual({ + expect(await readVcs(linked)).toEqual({ kind: "git", head: mainHead, dirty: null, @@ -79,7 +87,7 @@ it("reads a linked worktree's own branch and detached HEAD through shared Git re join(relativePointer, ".git"), "gitdir: ../repository/.git\r\n", ); - expect(await readReferenceSourceVcs(relativePointer)).toEqual({ + expect(await readVcs(relativePointer)).toEqual({ kind: "git", head: mainHead, dirty: null, @@ -87,14 +95,14 @@ it("reads a linked worktree's own branch and detached HEAD through shared Git re // A non-regular loose ref must remain unknown even when packed-refs has // a readable value. The library's promise-API probe is not a source failure. await mkdir(join(repository, ".git", "refs", "heads", "main")); - expect(await readReferenceSourceVcs(repository)).toEqual({ + expect(await readVcs(repository)).toEqual({ kind: "unknown", head: null, dirty: null, }); const controller = new AbortController(); controller.abort(); - expect(await readReferenceSourceVcs(linked, controller.signal)).toEqual({ + expect(await readVcs(linked, controller.signal)).toEqual({ kind: "unknown", head: null, dirty: null, diff --git a/tests/boundary/filesystem/sourceToBundlePaths.test.ts b/tests/boundary/filesystem/sourceToBundlePaths.test.ts index 50e39000e..19aa07a1a 100644 --- a/tests/boundary/filesystem/sourceToBundlePaths.test.ts +++ b/tests/boundary/filesystem/sourceToBundlePaths.test.ts @@ -4,11 +4,11 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js"; -import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; import { findApplicationFeatureSeeds } from "../../../src/domain/javascript/javascriptFeatureSeed.js"; import { compareSourceToBundle } from "../../../src/domain/javascript/sourceToBundleComparison.js"; import { resolveSourceMapSource } from "../../../src/javascript/sourceMaps/DecodedSourceMap.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; it("keeps literal hash characters in historical/current filesystem paths", async () => { const root = await createTestTempDirectory("rea-source-path-"); diff --git a/tests/fixtures/referenceReaderFifoProbe.mjs b/tests/fixtures/referenceReaderFifoProbe.mjs index a8f785648..026661bb7 100644 --- a/tests/fixtures/referenceReaderFifoProbe.mjs +++ b/tests/fixtures/referenceReaderFifoProbe.mjs @@ -9,23 +9,33 @@ if (modulePath === undefined || root === undefined) throw new Error("Reference reader FIFO probe requires module and root paths"); const { readStableFile } = await import(pathToFileURL(modulePath)); -const absolute = join(root, "source.txt"); -await writeFile(absolute, "observed regular file\n"); -const expected = await lstat(absolute, { bigint: true }); -await unlink(absolute); -execFileSync("mkfifo", [absolute]); +const { ArtifactResourceScope } = await import( + new URL("../artifacts/ArtifactResourceScope.js", pathToFileURL(modulePath)) +); +const resources = new ArtifactResourceScope(); +try { + const absolute = join(root, "source.txt"); + await writeFile(absolute, "observed regular file\n"); + const expected = await lstat(absolute, { bigint: true }); + await unlink(absolute); + execFileSync("mkfifo", [absolute]); -// Capture the directory after replacement to reach the file-open boundary. -// The owning test bounds this child if an O_RDONLY FIFO open blocks again. -const rootIdentity = await lstat(root, { bigint: true }); -const result = await readStableFile({ - root, - rootIdentity, - absolute, - path: "source.txt", - expected, -}); -assert.equal(result.status, "failed"); -assert.equal(result.kind, "file"); -assert.equal(result.code, "changed"); + // Capture the directory after replacement to reach the file-open boundary. + // The owning test bounds this child if an O_RDONLY FIFO open blocks again. + const rootIdentity = await lstat(root, { bigint: true }); + const result = await readStableFile({ + resources, + root, + rootIdentity, + absolute, + path: "source.txt", + expected, + }); + assert.equal(result.cleanup, undefined); + assert.equal(result.entry.status, "failed"); + assert.equal(result.entry.kind, "file"); + assert.equal(result.entry.code, "changed"); +} finally { + await resources.close(); +} process.stdout.write("changed\n"); diff --git a/tests/support/referenceSourceResourceScope.ts b/tests/support/referenceSourceResourceScope.ts new file mode 100644 index 000000000..83557b647 --- /dev/null +++ b/tests/support/referenceSourceResourceScope.ts @@ -0,0 +1,16 @@ +import { importReferenceSource as importOwned } from "../../src/application/ReferenceSourceImport.js"; +import type { ReferenceSourceImportOptions } from "../../src/application/ReferenceSourceImportTypes.js"; +import { readReferenceSource as readOwned } from "../../src/reference/ReferenceSourceReader.js"; +import type { ReferenceSourceReaderOptions } from "../../src/reference/ReferenceSourceReaderTypes.js"; +import { withArtifactResourceScope } from "../fixtures/artifactInventory.js"; + +/** Run one reference-source test operation with an awaited resource owner. */ +export const importReferenceSource = (options: ReferenceSourceImportOptions) => + withArtifactResourceScope((resources) => importOwned(options, resources)); + +/** Read one reference-source test tree with an awaited resource owner. */ +export const readReferenceSource = ( + root: string, + options: ReferenceSourceReaderOptions = {}, +) => + withArtifactResourceScope((resources) => readOwned(root, resources, options));