From 53e7fadbd078422ba3d55b5499a70d237c5f4908 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:06:28 +0800 Subject: [PATCH] fix(reference)!: retain source and metadata cleanup owners 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. --- src/application/ReferenceSourceImport.ts | 92 +++++- .../ReferenceSourceImportPolicy.ts | 100 ++++-- .../ReferenceSourceImportTypes.test.ts | 80 +++++ src/application/ReferenceSourceImportTypes.ts | 79 +++-- src/application/ReferenceSourceVcsAdapter.ts | 186 +++++++++-- src/application/RegularFileRead.ts | 102 +++++- src/cli/referenceSourceImportStatus.ts | 7 + src/cli/utilityCommands.ts | 119 ++++++- src/reference/ReferenceSourceReader.ts | 29 +- src/reference/ReferenceSourceReaderEntries.ts | 95 +++++- src/reference/ReferenceSourceReaderFile.ts | 49 ++- src/reference/ReferenceSourceReaderTypes.ts | 8 + tests/boundary/cli/cliStartupImports.test.ts | 2 +- .../cli/referenceSourceImportStatus.test.ts | 36 ++- .../referenceSourceCleanupOwnership.test.ts | 304 ++++++++++++++++++ .../referenceSourceComputedRequire.test.ts | 2 +- .../referenceSourceDockerfileLanguage.test.ts | 2 +- .../referenceSourceDynamicImports.test.ts | 2 +- .../referenceSourceFailureDetails.test.ts | 6 +- .../filesystem/referenceSourceImport.test.ts | 35 +- .../referenceSourceMetadataCleanup.test.ts | 226 +++++++++++++ .../filesystem/referenceSourceReader.test.ts | 9 +- .../referenceSourceRecovery.test.ts | 6 +- .../referenceSourceTestFilenames.test.ts | 2 +- ...eferenceSourceTypeScriptExtensions.test.ts | 2 +- ...ferenceSourceUnreadableDirectories.test.ts | 6 +- .../filesystem/referenceSourceVcs.test.ts | 22 +- .../filesystem/sourceToBundlePaths.test.ts | 2 +- tests/fixtures/referenceReaderFifoProbe.mjs | 46 +-- tests/support/referenceSourceResourceScope.ts | 16 + 30 files changed, 1450 insertions(+), 222 deletions(-) create mode 100644 src/application/ReferenceSourceImportTypes.test.ts create mode 100644 tests/boundary/filesystem/referenceSourceCleanupOwnership.test.ts create mode 100644 tests/boundary/filesystem/referenceSourceMetadataCleanup.test.ts create mode 100644 tests/support/referenceSourceResourceScope.ts 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));