diff --git a/scripts/lib/javascript-application-analysis.mjs b/scripts/lib/javascript-application-analysis.mjs new file mode 100644 index 000000000..e9b4c8822 --- /dev/null +++ b/scripts/lib/javascript-application-analysis.mjs @@ -0,0 +1,28 @@ +import { analyzeJavaScriptApplication } from "../../dist/application/javascript/JavaScriptApplicationService.js"; +import { ArtifactResourceScope } from "../../dist/artifacts/ArtifactResourceScope.js"; + +/** Analyze one JavaScript application and close its caller-owned resources. */ +export const analyzeJavaScriptApplicationWithOwnedScope = async (input) => { + const scope = new ArtifactResourceScope(); + let outcome; + try { + const result = await analyzeJavaScriptApplication(input, scope); + outcome = result.ok + ? { kind: "completed", value: result.value } + : { kind: "failed", cause: result.error }; + } catch (cause) { + outcome = { kind: "failed", cause }; + } + try { + await scope.close(); + } catch (cleanupCause) { + if (outcome.kind === "failed") + throw new AggregateError( + [outcome.cause, cleanupCause], + "JavaScript analysis and artifact cleanup both failed", + ); + throw cleanupCause; + } + if (outcome.kind === "failed") throw outcome.cause; + return outcome.value; +}; diff --git a/scripts/verify/javascript/application-workflows.mjs b/scripts/verify/javascript/application-workflows.mjs index 2c4680871..cfd0c8f11 100644 --- a/scripts/verify/javascript/application-workflows.mjs +++ b/scripts/verify/javascript/application-workflows.mjs @@ -3,7 +3,7 @@ import { realpath } from "node:fs/promises"; import { parseArgs } from "node:util"; -import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs"; import { compareApplicationVersionsEvidence, compareJavaScriptExportShapesEvidence, @@ -199,9 +199,5 @@ function selectorKey(selector) { } async function analyze(path) { - const result = await analyzeJavaScriptApplication({ - input_path: path, - }); - if (!result.ok) throw result.error; - return result.value; + return analyzeJavaScriptApplicationWithOwnedScope({ input_path: path }); } diff --git a/scripts/verify/javascript/runtime-observation.mjs b/scripts/verify/javascript/runtime-observation.mjs index a0c71f3c7..b9af11516 100644 --- a/scripts/verify/javascript/runtime-observation.mjs +++ b/scripts/verify/javascript/runtime-observation.mjs @@ -3,8 +3,8 @@ import { readFile, realpath } from "node:fs/promises"; import { parseArgs } from "node:util"; -import { analyzeJavaScriptApplication } from "../../../dist/application/javascript/JavaScriptApplicationService.js"; import { reconcileJavaScriptRuntimeEvidence } from "../../../dist/application/javascript/JavaScriptRuntimeReconciliationService.js"; +import { analyzeJavaScriptApplicationWithOwnedScope } from "../../lib/javascript-application-analysis.mjs"; import { completeVerifierRun, createVerifierRun, @@ -44,14 +44,13 @@ const canonicalLayers = await Promise.all( ); const staticLayers = []; for (const layer of canonicalLayers) { - const analyzed = await analyzeJavaScriptApplication({ + const analyzed = await analyzeJavaScriptApplicationWithOwnedScope({ input_path: layer.path, format: "auto", }); - if (!analyzed.ok) throw analyzed.error; staticLayers.push({ role: layer.role, - analysis: analyzed.value, + analysis: analyzed, runtime_mappings: [], }); } diff --git a/src/application/javascript/JavaScriptApplicationCancellation.test.ts b/src/application/javascript/JavaScriptApplicationCancellation.test.ts index 5c2b2f005..f4424de5b 100644 --- a/src/application/javascript/JavaScriptApplicationCancellation.test.ts +++ b/src/application/javascript/JavaScriptApplicationCancellation.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; describe("JavaScript analysis cancellation before publication", () => { it("interrupts semantic graph commitment before result validation", async () => { diff --git a/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts b/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts index 33ebd2220..444616d78 100644 --- a/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts +++ b/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts @@ -10,7 +10,7 @@ import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascri import { matchJavaScriptApplicationVersions } from "../../domain/javascript/javascriptApplicationVersionKeys.js"; import { findApplicationFeatureSeeds } from "../../domain/javascript/javascriptFeatureSeed.js"; import { traceApplicationFeatureInputSchema } from "../../domain/javascript/javascriptFeatureTraceSchemas.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; const analyzeSource = async ( source: string, diff --git a/src/application/javascript/JavaScriptApplicationModuleProvenance.test.ts b/src/application/javascript/JavaScriptApplicationModuleProvenance.test.ts index ba8e168eb..c8768b262 100644 --- a/src/application/javascript/JavaScriptApplicationModuleProvenance.test.ts +++ b/src/application/javascript/JavaScriptApplicationModuleProvenance.test.ts @@ -5,7 +5,7 @@ import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; it.each([ { diff --git a/src/application/javascript/JavaScriptApplicationService.test.ts b/src/application/javascript/JavaScriptApplicationService.test.ts index 02c26096e..272856988 100644 --- a/src/application/javascript/JavaScriptApplicationService.test.ts +++ b/src/application/javascript/JavaScriptApplicationService.test.ts @@ -4,9 +4,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { z } from "zod"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { scanCanonicalArtifactInventory } from "../../../tests/fixtures/artifactInventory.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; describe("JavaScript application failure diagnostics", () => { it("identifies the rejected result field in caller-visible diagnostics", async () => { @@ -153,3 +155,41 @@ describe("JavaScript application failure diagnostics", () => { }, ); }); + +describe("JavaScript application cleanup diagnostics", () => { + it("preserves cleanup and a complete inventory when translating reader failures", async () => { + const inputPath = await createTestTempDirectory("rea-js-cleanup-failure-"); + await writeFile(join(inputPath, "main.js"), "export const value = 1;\n"); + const inventory = await scanCanonicalArtifactInventory(inputPath); + const cleanup = { + reason: "reader remains open", + resources: [`JavaScript artifact reader for ${inputPath}`], + }; + const partialObservation = { + kind: "artifact-inventory" as const, + inventory, + }; + const failure = new ArtifactReaderFailure("io", "Reader cleanup failed", { + cleanup, + partialObservation, + }); + + const result = await analyzeJavaScriptApplication( + { input_path: inputPath, format: "directory" }, + { + progress: { + report: async () => { + throw failure; + }, + }, + }, + ); + + if (result.ok) throw new Error("Expected reader failure"); + expect(result.error).toMatchObject({ + _tag: "ArtifactOperationError", + cleanup, + partialObservation, + }); + }); +}); diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 995356698..917c29dff 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analyzeJavaScriptApplicationInputSchema, parseOwnedJavaScriptApplicationAnalysisSteps, @@ -32,6 +33,7 @@ const OPERATION = "analyze_javascript_application" as const; /** Statically analyze one local JavaScript/Electron application. */ export const analyzeJavaScriptApplication = async ( rawInput: unknown, + scope: ArtifactResourceScope, options: ExecutionOptions = {}, ): Promise> => { const parsed = analyzeJavaScriptApplicationInputSchema.safeParse(rawInput); @@ -39,12 +41,13 @@ export const analyzeJavaScriptApplication = async ( return err( analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput), ); - return analyzeJavaScriptApplicationValidated(parsed.data, options); + return analyzeJavaScriptApplicationValidated(parsed.data, scope, options); }; /** Analyze input already parsed by a trusted adapter boundary. */ export const analyzeJavaScriptApplicationValidated = async ( input: z.output, + scope: ArtifactResourceScope, options: ExecutionOptions = {}, ): Promise> => { try { @@ -62,6 +65,7 @@ export const analyzeJavaScriptApplicationValidated = async ( format: input.format, integrity_policy: input.integrity_policy, }, + scope, options.signal, options.progress, ); @@ -114,14 +118,7 @@ export const analyzeJavaScriptApplicationValidated = async ( ) return err(cause); if (cause instanceof ArtifactReaderFailure) - return err( - new ArtifactOperationError( - OPERATION, - cause.reason, - cause.details, - cause.message, - ), - ); + return err(artifactFailureToOperationError(cause)); if (cause instanceof z.ZodError) return err( new AnalysisOutputError(OPERATION, describeResultSchemaFailure(cause), { @@ -153,6 +150,23 @@ export const analyzeJavaScriptApplicationValidated = async ( } }; +const artifactFailureToOperationError = ( + cause: ArtifactReaderFailure, +): ArtifactOperationError => + new ArtifactOperationError( + OPERATION, + cause.reason, + cause.details, + cause.message, + { + cause, + ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), + ...(cause.partialObservation === undefined + ? {} + : { partialObservation: cause.partialObservation }), + }, + ); + const describeResultSchemaFailure = (cause: z.ZodError): string => { const issue = cause.issues[0]; if (issue === undefined) return "Result schema rejected an unknown issue"; diff --git a/src/application/javascript/JavaScriptApplicationSignedZero.test.ts b/src/application/javascript/JavaScriptApplicationSignedZero.test.ts index a36c1fdab..47143bb91 100644 --- a/src/application/javascript/JavaScriptApplicationSignedZero.test.ts +++ b/src/application/javascript/JavaScriptApplicationSignedZero.test.ts @@ -7,7 +7,7 @@ import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirect import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { javaScriptSemanticQueryResultSchema } from "../../domain/javascript/javascriptSemanticQuerySchemas.js"; import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; it.each([ { diff --git a/src/application/javascript/JavaScriptApplicationTemplateLiterals.test.ts b/src/application/javascript/JavaScriptApplicationTemplateLiterals.test.ts index 2028147bf..378d73353 100644 --- a/src/application/javascript/JavaScriptApplicationTemplateLiterals.test.ts +++ b/src/application/javascript/JavaScriptApplicationTemplateLiterals.test.ts @@ -5,7 +5,7 @@ import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; const graphIdentities = async (source: string) => { const inputPath = await createTestTempDirectory("rea-js-template-literal-"); diff --git a/src/application/javascript/JavaScriptApplicationTypeScript.test.ts b/src/application/javascript/JavaScriptApplicationTypeScript.test.ts index 6d59578e7..531168bcc 100644 --- a/src/application/javascript/JavaScriptApplicationTypeScript.test.ts +++ b/src/application/javascript/JavaScriptApplicationTypeScript.test.ts @@ -7,7 +7,7 @@ import { canonicalJson } from "../../domain/comparisonSemantics.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; type ApplicationAnalysis = ReturnType< typeof javascriptApplicationAnalysisResultSchema.parse diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 25919923c..7b3972c36 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -12,6 +12,7 @@ import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascr import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; +import type { ArtifactInventoryPartialObservation } from "../../domain/artifactPartialObservation.js"; import { analyzeAndProjectJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js"; import { buildImmutableJavaScriptArtifactGraphSteps } from "./JavaScriptArtifactGraphBuilder.js"; @@ -19,7 +20,8 @@ import { javascriptArtifactReconstructionInputSchema, type JavaScriptArtifactReconstructionInput, } from "./JavaScriptArtifactReconstructionInput.js"; -import { scanCanonicalArtifactInventory } from "../../artifacts/inventory/scanCanonical.js"; +import { scanCanonicalArtifactInventoryInScope } from "../../artifacts/inventory/scanCanonical.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js"; import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; import type { ProgressReporter } from "../ProgressReporter.js"; @@ -56,118 +58,156 @@ export interface JavaScriptArtifactReconstructionResult { /** Reconstruct one local ASAR or extracted directory without executing code. */ export const reconstructJavaScriptArtifact = async ( rawInput: unknown, + scope: ArtifactResourceScope, signal?: AbortSignal, progress?: ProgressReporter, -): Promise => { - const reportPhase = async (phase: string, message: string): Promise => { - await progress?.report({ phase, completed: 0, total: 1, message }); - await checkpointJavaScriptAnalysis(signal); - }; - const input = javascriptArtifactReconstructionInputSchema.parse(rawInput); - abortIfNeeded(signal); - const path = await resolveSelectedInput(input.input_path); - const format = await resolveFormat(path, input); - const snapshot = await scanCanonicalArtifactInventory(path, { - signal, - integrity: { mode: input.integrity_policy }, - }); - if (snapshot.manifest.root_format !== format) - throw new ArtifactReaderFailure( - "format", - `Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`, - ); - const reader = createReader(path, format); - try { - await reportPhase( - "read_javascript_artifacts", - "Reading inventoried JavaScript application sources", - ); - const files = await readJavaScriptArtifactFiles(reader, snapshot, signal); - await reportPhase( - "parse_javascript_sources", - `Parsing and projecting ${String(files.files.length)} application source files`, - ); - const semanticProjection = createJavaScriptSemanticGraphProjection(); - const analysis = await analyzeAndProjectJavaScriptArtifactFiles( - files, - semanticProjection.projectFileSteps, - async (file, completed, total) => { - abortIfNeeded(signal); - await progress?.report({ - phase: "parse_javascript_source", - completed: 0, - total: 1, - message: `Parsing and projecting ${file.path} (${String(completed + 1)}/${String(total)})`, - }); - abortIfNeeded(signal); - }, - signal, - ); - await checkpointJavaScriptAnalysis(signal); - abortIfNeeded(signal); - await reportPhase( - "build_javascript_application_graph", - "Constructing application and Electron boundary relationships", - ); - const applicationGraphSteps = buildImmutableJavaScriptArtifactGraphSteps( - snapshot, - files, - analysis, - ); - await reportPhase( - "seal_javascript_application_graph", - "Sealing the validated application graph", - ); - const graph = await completeJavaScriptAnalysisSteps( - applicationGraphSteps, - signal, - ); - await reportPhase( - "build_javascript_semantic_graph", - "Binding and validating static semantic relationships", - ); - const semanticGraphSteps = semanticProjection.finishImmutableSteps( - snapshot.manifest.root_sha256, - graph, - ); - await reportPhase( - "seal_javascript_semantic_graph", - "Validating and sealing the semantic graph", - ); - const semanticGraph = await completeJavaScriptAnalysisSteps( - semanticGraphSteps, - signal, - ); - await checkpointJavaScriptAnalysis(signal); - return { - input_path: path, - format, - root_artifact_sha256: snapshot.manifest.root_sha256, - inventory_manifest_id: snapshot.manifest.manifest_id, - inventory_graph_sha256: snapshot.manifest.graph_sha256, - integrity_contradictions: snapshot.integrity_contradictions, - graph, - semantic_graph: semanticGraph, - electron_summary: summarizeElectronBoundaries(analysis), - statistics: { - relevant_files: files.files.length, - nested_asar_containers: files.containers.length, - text_bytes_read: files.text_bytes_read, - invalid_utf8_files: files.invalid_utf8_files, - parsed_javascript_files: analysis.files.filter( - ({ javascript }) => javascript !== null, - ).length, - visited_ast_nodes: analysis.visited_ast_nodes, - findings: analysis.findings, - modules: analysis.modules, - parse_failures: analysis.parse_failures, - }, - limitations: analysis.limitations, +): Promise => + scope.run(async () => { + const reportPhase = async ( + phase: string, + message: string, + ): Promise => { + await progress?.report({ phase, completed: 0, total: 1, message }); + await checkpointJavaScriptAnalysis(signal); }; - } finally { - await reader.close(); - } -}; + const input = javascriptArtifactReconstructionInputSchema.parse(rawInput); + abortIfNeeded(signal); + const path = await resolveSelectedInput(input.input_path); + const format = await resolveFormat(path, input); + const snapshot = await scanCanonicalArtifactInventoryInScope(path, { + resourceScope: scope, + signal, + integrity: { mode: input.integrity_policy }, + }); + if (snapshot.manifest.root_format !== format) + throw new ArtifactReaderFailure( + "format", + `Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`, + ); + const reader = createReader(path, format); + let outcome: + | { + readonly kind: "completed"; + readonly result: JavaScriptArtifactReconstructionResult; + } + | { readonly kind: "failed"; readonly cause: unknown }; + try { + await reportPhase( + "read_javascript_artifacts", + "Reading inventoried JavaScript application sources", + ); + const files = await readJavaScriptArtifactFiles(reader, snapshot, signal); + await reportPhase( + "parse_javascript_sources", + `Parsing and projecting ${String(files.files.length)} application source files`, + ); + const semanticProjection = createJavaScriptSemanticGraphProjection(); + const analysis = await analyzeAndProjectJavaScriptArtifactFiles( + files, + semanticProjection.projectFileSteps, + async (file, completed, total) => { + abortIfNeeded(signal); + await progress?.report({ + phase: "parse_javascript_source", + completed: 0, + total: 1, + message: `Parsing and projecting ${file.path} (${String(completed + 1)}/${String(total)})`, + }); + abortIfNeeded(signal); + }, + signal, + ); + await checkpointJavaScriptAnalysis(signal); + abortIfNeeded(signal); + await reportPhase( + "build_javascript_application_graph", + "Constructing application and Electron boundary relationships", + ); + const applicationGraphSteps = buildImmutableJavaScriptArtifactGraphSteps( + snapshot, + files, + analysis, + ); + await reportPhase( + "seal_javascript_application_graph", + "Sealing the validated application graph", + ); + const graph = await completeJavaScriptAnalysisSteps( + applicationGraphSteps, + signal, + ); + await reportPhase( + "build_javascript_semantic_graph", + "Binding and validating static semantic relationships", + ); + const semanticGraphSteps = semanticProjection.finishImmutableSteps( + snapshot.manifest.root_sha256, + graph, + ); + await reportPhase( + "seal_javascript_semantic_graph", + "Validating and sealing the semantic graph", + ); + const semanticGraph = await completeJavaScriptAnalysisSteps( + semanticGraphSteps, + signal, + ); + await checkpointJavaScriptAnalysis(signal); + outcome = { + kind: "completed", + result: { + input_path: path, + format, + root_artifact_sha256: snapshot.manifest.root_sha256, + inventory_manifest_id: snapshot.manifest.manifest_id, + inventory_graph_sha256: snapshot.manifest.graph_sha256, + integrity_contradictions: snapshot.integrity_contradictions, + graph, + semantic_graph: semanticGraph, + electron_summary: summarizeElectronBoundaries(analysis), + statistics: { + relevant_files: files.files.length, + nested_asar_containers: files.containers.length, + text_bytes_read: files.text_bytes_read, + invalid_utf8_files: files.invalid_utf8_files, + parsed_javascript_files: analysis.files.filter( + ({ javascript }) => javascript !== null, + ).length, + visited_ast_nodes: analysis.visited_ast_nodes, + findings: analysis.findings, + modules: analysis.modules, + parse_failures: analysis.parse_failures, + }, + limitations: analysis.limitations, + }, + }; + } catch (cause: unknown) { + outcome = { kind: "failed", cause }; + } + const owner = { + kind: "reader" as const, + reader, + resource: `JavaScript artifact reader for ${path}`, + }; + const cleanupAttempt = await scope.release(owner); + if (cleanupAttempt.kind === "failed") { + const cleanup = ArtifactReaderFailure.cleanupObservation( + cleanupAttempt.cause, + owner.resource, + ); + const partialObservation: ArtifactInventoryPartialObservation = { + kind: "artifact-inventory", + inventory: snapshot, + }; + throw ArtifactReaderFailure.withCleanup( + outcome.kind === "failed" ? outcome.cause : cleanupAttempt.cause, + cleanup, + partialObservation, + ); + } + if (outcome.kind === "failed") throw outcome.cause; + return outcome.result; + }); const OPERATION = "analyze_javascript_application"; diff --git a/src/application/javascript/JavaScriptFileUrlPaths.test.ts b/src/application/javascript/JavaScriptFileUrlPaths.test.ts index 151b9b756..0a136cff4 100644 --- a/src/application/javascript/JavaScriptFileUrlPaths.test.ts +++ b/src/application/javascript/JavaScriptFileUrlPaths.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { staticPath } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; const urlCases = [ ["./preload%20name.cjs", "preload name.cjs", "main.mjs"], diff --git a/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts b/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts index 74a69d1f9..5ff96504e 100644 --- a/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts +++ b/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts @@ -7,7 +7,7 @@ import { promisify } from "node:util"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; const filesFor = (paths: readonly string[]) => diff --git a/src/application/javascript/JavaScriptOpenClassification.test.ts b/src/application/javascript/JavaScriptOpenClassification.test.ts index 0a7369ea2..436ea4a70 100644 --- a/src/application/javascript/JavaScriptOpenClassification.test.ts +++ b/src/application/javascript/JavaScriptOpenClassification.test.ts @@ -5,7 +5,7 @@ import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; -import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../../tests/support/javascriptApplicationScope.js"; it("classifies overloaded open calls from lexical receiver facts", async () => { const inputPath = await createTestTempDirectory( diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index db3b21daa..acf4eeedb 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -33,10 +33,12 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { interfaceBuilderLimitsSchema } from "../domain/apple/interfaceBuilderGraph.js"; import { err, ok, type Result } from "../domain/result.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; import { artifactCapabilities } from "./ArtifactProviderMetadata.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js"; import { createEvidence } from "../domain/evidence.js"; import { createArtifactInspection } from "../domain/artifactInspection.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; /** Read-only inventory and exclusively owned extraction provider. */ export class ArtifactProvider implements AnalysisProvider { @@ -67,6 +69,8 @@ export class ArtifactProvider implements AnalysisProvider { } class ArtifactClient implements AnalysisClient { + readonly #resourceScope = new ArtifactResourceScope(); + constructor( private readonly target: BinaryTarget, private readonly environment: Readonly, @@ -184,6 +188,7 @@ class ArtifactClient implements AnalysisClient { outputRoot: parsed.output_root, environment: this.environment, integrityPolicy: parsed.integrity_policy, + resourceScope: this.#resourceScope, }, options?.signal, ); @@ -220,7 +225,25 @@ class ArtifactClient implements AnalysisClient { } close(): Promise> { - return Promise.resolve(ok(null)); + return this.#resourceScope.close().then( + () => ok(null), + (cause: unknown) => { + const failure = + cause instanceof ArtifactReaderFailure ? cause : undefined; + return err( + new ProviderCleanupError( + ARTIFACT_GRAPH_PROVIDER.id, + failure?.cleanup?.resources ?? ["artifact resources"], + { + reason: + failure?.cleanup?.reason ?? + (cause instanceof Error ? cause.message : String(cause)), + }, + { cause, operation: "close_binary" }, + ), + ); + }, + ); } /** The active target's kind is outside this operation's supported targets. */ @@ -353,6 +376,7 @@ class ArtifactClient implements AnalysisClient { options?: ExecutionOptions, ) { return inventoryArtifact(this.target.sourcePath ?? this.target.path, { + resourceScope: this.#resourceScope, environment: this.environment, ...(options?.signal === undefined ? {} : { signal: options.signal }), integrity: { mode: parsed.integrity_policy }, diff --git a/src/artifacts/ArtifactResourceScope.test.ts b/src/artifacts/ArtifactResourceScope.test.ts new file mode 100644 index 000000000..9bf8fe87b --- /dev/null +++ b/src/artifacts/ArtifactResourceScope.test.ts @@ -0,0 +1,68 @@ +import { expect, it } from "vitest"; + +import type { ArtifactReader } from "./ArtifactReader.js"; +import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; + +const deferred = () => { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +it("waits for admitted operations and shares concurrent close", async () => { + const scope = new ArtifactResourceScope(); + const started = deferred(); + const finish = deferred(); + const operation = scope.run(async () => { + started.resolve(); + await finish.promise; + }); + await started.promise; + + const close = scope.close(); + expect(scope.close()).toBe(close); + await expect(scope.run(async () => undefined)).rejects.toMatchObject({ + reason: "unavailable", + message: "Artifact resource scope is closed", + }); + let closed = false; + void close.then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + + finish.resolve(); + await operation; + await close; + expect(closed).toBe(true); +}); + +it("retains the same failed reader owner and retries before admitting work", async () => { + const scope = new ArtifactResourceScope(); + let closeAttempts = 0; + const reader = { + async close() { + closeAttempts += 1; + if (closeAttempts < 4) throw new Error(`close attempt ${closeAttempts}`); + }, + } as unknown as ArtifactReader; + const owner = { kind: "reader" as const, resource: "fixture reader", reader }; + + expect(await scope.release(owner)).toMatchObject({ kind: "failed" }); + expect(closeAttempts).toBe(1); + + await expect(scope.run(async () => "must not run")).rejects.toMatchObject({ + reason: "unavailable", + message: "Artifact cleanup remains incomplete: fixture reader", + }); + expect(closeAttempts).toBe(2); + await expect(scope.close()).rejects.toMatchObject({ + reason: "unavailable", + }); + expect(closeAttempts).toBe(3); + await scope.close(); + expect(closeAttempts).toBe(4); +}); diff --git a/src/artifacts/ArtifactResourceScope.ts b/src/artifacts/ArtifactResourceScope.ts new file mode 100644 index 000000000..b7da3fd83 --- /dev/null +++ b/src/artifacts/ArtifactResourceScope.ts @@ -0,0 +1,140 @@ +import type { FileHandle } from "node:fs/promises"; + +import type { ArtifactReader } from "./ArtifactReader.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import type { SafeOutputTree } from "./SafeOutputTree.js"; + +/** One artifact resource and its format-specific cleanup owner. */ +export type ArtifactResourceOwner = + | { + readonly kind: "reader"; + readonly resource: string; + readonly reader: ArtifactReader; + } + | { + readonly kind: "file-handle"; + readonly resource: string; + readonly handle: FileHandle; + } + | { + readonly kind: "output-tree"; + readonly resource: string; + readonly tree: SafeOutputTree; + }; + +export type ArtifactCleanupAttempt = + | { readonly kind: "released" } + | { readonly kind: "failed"; readonly cause: unknown }; + +/** Caller-owned scope that retains failed artifact cleanup for a later close. */ +export class ArtifactResourceScope { + readonly #pending = new Set(); + readonly #active = new Set>(); + #draining: Promise | undefined; + #closed = false; + + /** Admit a resource operation while the scope remains open. */ + async run(operation: () => Promise): Promise { + while (true) { + if (this.#closed) + throw new ArtifactReaderFailure( + "unavailable", + "Artifact resource scope is closed", + ); + if (this.#draining !== undefined) await this.#draining; + if (this.#closed) + throw new ArtifactReaderFailure( + "unavailable", + "Artifact resource scope is closed", + ); + if (this.#pending.size > 0) { + await this.#retryCleanup(); + continue; + } + const running = Promise.resolve().then(operation); + this.#active.add(running); + try { + return await running; + } finally { + this.#active.delete(running); + } + } + } + + /** Attempt cleanup now and retain the same owner if it fails. */ + async release(owner: ArtifactResourceOwner): Promise { + try { + await cleanup(owner); + this.#pending.delete(owner); + return { kind: "released" }; + } catch (cause: unknown) { + this.#pending.add(owner); + return { kind: "failed", cause }; + } + } + + /** Retry retained owners without closing resource admission. */ + #retryCleanup(): Promise { + if (this.#draining !== undefined) return this.#draining; + this.#draining = this.#drainAndRetry().finally(() => { + this.#draining = undefined; + }); + return this.#draining; + } + + /** Stop admission, wait for active operations, and retry every retained owner. */ + close(): Promise { + this.#closed = true; + return this.#retryCleanup(); + } + + async #drainAndRetry(): Promise { + while (this.#active.size > 0) await Promise.allSettled(this.#active); + const failures: { + readonly owner: ArtifactResourceOwner; + readonly cause: unknown; + }[] = []; + for (const owner of this.#pending) { + try { + await cleanup(owner); + this.#pending.delete(owner); + } catch (cause: unknown) { + failures.push({ owner, cause }); + } + } + if (failures.length === 0) return; + const observations = failures.map(({ owner, cause }) => + ArtifactReaderFailure.cleanupObservation(cause, owner.resource), + ); + const resources = [ + ...new Set(observations.flatMap(({ resources }) => resources)), + ]; + throw new ArtifactReaderFailure( + "unavailable", + `Artifact cleanup remains incomplete: ${resources.join(", ")}`, + { + cause: new AggregateError( + failures.map(({ cause }) => cause), + "One or more artifact resources remain owned", + ), + cleanup: { + reason: observations.map(({ reason }) => reason).join("; "), + resources, + }, + }, + ); + } +} + +const cleanup = async (owner: ArtifactResourceOwner): Promise => { + switch (owner.kind) { + case "reader": + await owner.reader.close(); + return; + case "file-handle": + await owner.handle.close(); + return; + case "output-tree": + await owner.tree.rollback(); + } +}; diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 03f475070..79f384eef 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -26,9 +26,13 @@ import { type IntegrityContradiction, } from "../../domain/artifactGraph.js"; import { AnalysisUnsupportedTargetError } from "../../domain/analysisErrorCore.js"; +import { + type ArtifactResourceOwner, + type ArtifactResourceScope, +} from "../ArtifactResourceScope.js"; import type { BinaryTarget } from "../../domain/binaryTargetTypes.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; -import { scanArtifactInventory } from "../inventory/ArtifactInventory.js"; +import { scanArtifactInventoryInScope } from "../inventory/ArtifactInventory.js"; import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js"; /** Local extraction input with the output root chosen by the adapter. */ @@ -38,23 +42,36 @@ export interface ArtifactExtractionInput { readonly outputRoot: string; readonly environment: Readonly; readonly integrityPolicy: ArtifactIntegrityPolicyName; + readonly resourceScope: ArtifactResourceScope; } /** Extract every regular inventory occurrence into an exclusively owned absent root. */ export const extractArtifact = async ( input: ArtifactExtractionInput, signal?: AbortSignal, +): Promise => + input.resourceScope.run(() => extractArtifactInScope(input, signal)); + +const extractArtifactInScope = async ( + input: ArtifactExtractionInput, + signal?: AbortSignal, ): Promise => { // Cancellation wins over every later refusal, including unsupported formats. abortIfNeeded(signal); const sourcePath = await realpath(input.inputPath); await requireExtractableFormat(sourcePath, input); - const snapshot = await scanArtifactInventory(sourcePath, { + const snapshot = await scanArtifactInventoryInScope(sourcePath, { + resourceScope: input.resourceScope, signal, environment: input.environment, integrity: { mode: input.integrityPolicy }, }); - return materializeArtifactInventory(input, sourcePath, snapshot, signal); + return materializeArtifactInventoryInScope( + input, + sourcePath, + snapshot, + signal, + ); }; /** Materialize a scanned inventory, verifying every current occurrence against it. */ @@ -63,6 +80,17 @@ export const materializeArtifactInventory = async ( sourcePath: string, snapshot: ArtifactInventorySnapshot, signal?: AbortSignal, +): Promise => { + return input.resourceScope.run(() => + materializeArtifactInventoryInScope(input, sourcePath, snapshot, signal), + ); +}; + +const materializeArtifactInventoryInScope = async ( + input: ArtifactExtractionInput, + sourcePath: string, + snapshot: ArtifactInventorySnapshot, + signal?: AbortSignal, ): Promise => { abortIfNeeded(signal); const selectedOccurrences = snapshot.occurrences.filter( @@ -185,53 +213,27 @@ const materializeSelection = async ({ selected.map((item) => [item.occurrence.logical_path, item]), ); const reader = await createReader(sourcePath, input); + const readerOwner: ArtifactResourceOwner = { + kind: "reader" as const, + reader, + resource: `artifact reader for ${sourcePath}`, + }; + let localReaderOwner: ArtifactResourceOwner | undefined = readerOwner; let output: SafeOutputTree | undefined; - let readerCloseAttempted = false; - let readerClosed = false; const extracted: ExtractedOccurrence[] = []; try { output = await SafeOutputTree.create(input.outputRoot); - const registry = new ArtifactPathRegistry(); - for await (const entry of reader.entries(signal)) { - const path = normalizeArtifactPath(entry.path); - registry.add(path, entry.kind); - const selectedItem = byPath.get(path); - if (selectedItem === undefined) { - if (entry.kind === "file" || entry.kind === "slice") - throw new ArtifactReaderFailure( - "integrity", - `Regular artifact entry is missing from inventory: ${path}`, - ); - continue; - } - preflight(entry); - const stream = await reader.open(entry, signal); - const written = await output.write( - path, - stream, - { - sha256: selectedItem.node.sha256, - bytes: selectedItem.node.size, - }, - signal, + await writeSelectedEntries({ reader, output, byPath, extracted, signal }); + localReaderOwner = undefined; + const closeAttempt = await input.resourceScope.release(readerOwner); + if (closeAttempt.kind === "failed") + throw ArtifactReaderFailure.withCleanup( + closeAttempt.cause, + ArtifactReaderFailure.cleanupObservation( + closeAttempt.cause, + readerOwner.resource, + ), ); - extracted.push({ - artifact_id: selectedItem.node.artifact_id, - relative_path: written.relativePath, - sha256: written.sha256, - bytes_written: written.bytesWritten, - created: true, - }); - byPath.delete(path); - } - if (byPath.size > 0) - throw new ArtifactReaderFailure( - "integrity", - `Inventoried regular artifact entries were not materialized: ${[...byPath.keys()].sort(compareUnicodeCodePoints).join(", ")}`, - ); - readerCloseAttempted = true; - await reader.close(); - readerClosed = true; extracted.sort((left, right) => compareUnicodeCodePoints(left.relative_path, right.relative_path), ); @@ -244,36 +246,12 @@ const materializeSelection = async ({ await output.commit(); return result; } catch (cause: unknown) { - const cleanupFailures: { - readonly cause: unknown; - readonly resource: string; - }[] = []; - if (readerCloseAttempted && !readerClosed) - cleanupFailures.push({ - cause, - resource: `artifact reader for ${sourcePath}`, - }); - if (!readerCloseAttempted) { - try { - readerCloseAttempted = true; - await reader.close(); - } catch (cleanupCause: unknown) { - cleanupFailures.push({ - cause: cleanupCause, - resource: `artifact reader for ${sourcePath}`, - }); - } - } - if (output !== undefined) { - try { - await output.rollback(); - } catch (cleanupCause: unknown) { - cleanupFailures.push({ - cause: cleanupCause, - resource: input.outputRoot, - }); - } - } + const cleanupFailures = await releaseExtractionOwners( + input.resourceScope, + localReaderOwner, + output, + input.outputRoot, + ); if (cleanupFailures.length > 0) { const observations = cleanupFailures.map( ({ cause: cleanupCause, resource }) => @@ -290,6 +268,84 @@ const materializeSelection = async ({ } }; +const writeSelectedEntries = async ({ + reader, + output, + byPath, + extracted, + signal, +}: { + readonly reader: ArtifactReader; + readonly output: SafeOutputTree; + readonly byPath: Map; + readonly extracted: ExtractedOccurrence[]; + readonly signal: AbortSignal | undefined; +}): Promise => { + const registry = new ArtifactPathRegistry(); + for await (const entry of reader.entries(signal)) { + const path = normalizeArtifactPath(entry.path); + registry.add(path, entry.kind); + const selectedItem = byPath.get(path); + if (selectedItem === undefined) { + if (entry.kind === "file" || entry.kind === "slice") + throw new ArtifactReaderFailure( + "integrity", + `Regular artifact entry is missing from inventory: ${path}`, + ); + continue; + } + preflight(entry); + const stream = await reader.open(entry, signal); + const written = await output.write( + path, + stream, + { + sha256: selectedItem.node.sha256, + bytes: selectedItem.node.size, + }, + signal, + ); + extracted.push({ + artifact_id: selectedItem.node.artifact_id, + relative_path: written.relativePath, + sha256: written.sha256, + bytes_written: written.bytesWritten, + created: true, + }); + byPath.delete(path); + } + if (byPath.size > 0) + throw new ArtifactReaderFailure( + "integrity", + `Inventoried regular artifact entries were not materialized: ${[...byPath.keys()].sort(compareUnicodeCodePoints).join(", ")}`, + ); +}; + +const releaseExtractionOwners = async ( + resourceScope: ArtifactResourceScope, + readerOwner: ArtifactResourceOwner | undefined, + output: SafeOutputTree | undefined, + outputRoot: string, +): Promise<{ readonly cause: unknown; readonly resource: string }[]> => { + const failures: { readonly cause: unknown; readonly resource: string }[] = []; + if (readerOwner !== undefined) { + const attempt = await resourceScope.release(readerOwner); + if (attempt.kind === "failed") + failures.push({ cause: attempt.cause, resource: readerOwner.resource }); + } + if (output !== undefined) { + const owner: ArtifactResourceOwner = { + kind: "output-tree", + tree: output, + resource: outputRoot, + }; + const attempt = await resourceScope.release(owner); + if (attempt.kind === "failed") + failures.push({ cause: attempt.cause, resource: owner.resource }); + } + return failures; +}; + const createExtractionResult = ( input: ArtifactExtractionInput, inventory: ExtractionInventory, diff --git a/src/artifacts/inventory/ArtifactInventory.ts b/src/artifacts/inventory/ArtifactInventory.ts index 1cea07af8..582c970f1 100644 --- a/src/artifacts/inventory/ArtifactInventory.ts +++ b/src/artifacts/inventory/ArtifactInventory.ts @@ -5,14 +5,14 @@ import { type ArtifactInventoryResult, } from "../../domain/artifactGraph.js"; import { abortIfNeeded } from "../ArtifactHash.js"; -import { scanCanonicalArtifactInventory } from "./scanCanonical.js"; +import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js"; import type { ArtifactInventoryOptions } from "./types.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; /** Inventory one local artifact and return every graph collection inline. */ export const inventoryArtifact = async ( inputPath: string, - options: ArtifactInventoryOptions = {}, + options: ArtifactInventoryOptions, ): Promise => { const snapshot = await scanArtifactInventory(inputPath, options); return artifactInventoryResultSchema.parse(snapshot); @@ -21,9 +21,17 @@ export const inventoryArtifact = async ( /** Scan an artifact once and retain the complete immutable graph for projection. */ export const scanArtifactInventory = async ( inputPath: string, - options: ArtifactInventoryOptions = {}, + options: ArtifactInventoryOptions, +): Promise => + options.resourceScope.run(() => + scanArtifactInventoryInScope(inputPath, options), + ); + +export const scanArtifactInventoryInScope = async ( + inputPath: string, + options: ArtifactInventoryOptions, ): Promise => { abortIfNeeded(options.signal); const path = await realpath(inputPath); - return scanCanonicalArtifactInventory(path, options); + return scanCanonicalArtifactInventoryInScope(path, options); }; diff --git a/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts b/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts index 10351f9d4..f44d79aeb 100644 --- a/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts +++ b/src/artifacts/inventory/ArtifactOccurrenceIdentity.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; import { describe, expect, it } from "vitest"; -import { inventoryArtifact } from "./ArtifactInventory.js"; +import { inventoryArtifact } from "../../../tests/fixtures/artifactInventory.js"; import { compareArtifacts } from "../../domain/artifactComparison.js"; import { projectAppleApplication } from "../../domain/apple/appleApplication.js"; import { createEvidence } from "../../domain/evidence.js"; diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index fbb8215a1..7932c519e 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -15,8 +15,10 @@ import { zipPackageFormatForPath, } from "../../domain/zipPackageFormat.js"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { type ArtifactResourceOwner } from "../ArtifactResourceScope.js"; import type { HashResult } from "../ArtifactHash.js"; import { hashStableRootArtifactHandle } from "./hashStableRootArtifact.js"; +import type { ArtifactInventoryOptions } from "./types.js"; interface RootClassification { readonly format: ArtifactOccurrence["artifact_format"]; @@ -32,19 +34,34 @@ export const classifyAndHashRoot = async ( path: string, directory: boolean, expectedMetadata: Stats, - signal?: AbortSignal, + options: Pick, ): Promise => { - const result = await classifyAndHashRootForInventory( - path, - directory, - expectedMetadata, - signal, - ); - try { + return options.resourceScope.run(async () => { + const result = await classifyAndHashRootForInventory( + path, + directory, + expectedMetadata, + options, + ); + const rootSource = result.rootSource; + if (rootSource !== undefined) { + const owner: ArtifactResourceOwner = { + kind: "file-handle", + handle: rootSource.handle, + resource: `root artifact descriptor for ${path}`, + }; + const cleanupAttempt = await options.resourceScope.release(owner); + if (cleanupAttempt.kind === "failed") + throw ArtifactReaderFailure.withCleanup( + cleanupAttempt.cause, + ArtifactReaderFailure.cleanupObservation( + cleanupAttempt.cause, + owner.resource, + ), + ); + } return { format: result.format, digest: result.digest }; - } finally { - await result.rootSource?.handle.close(); - } + }); }; /** Classify a root and retain its admitted ZIP descriptor for child inventory. */ @@ -52,12 +69,22 @@ export const classifyAndHashRootForInventory = async ( path: string, directory: boolean, expectedMetadata: Stats, - signal?: AbortSignal, + options: Pick, ): Promise => { if (directory) return { format: "directory", digest: null, rootSource: undefined }; - const handle = await openRootFile(path, signal); - let transferHandle = false; + const handle = await openRootFile(path, options.signal); + const owner: ArtifactResourceOwner = { + kind: "file-handle", + handle, + resource: `root artifact descriptor for ${path}`, + }; + let outcome: + | { + readonly kind: "completed"; + readonly value: RootInventoryClassification; + } + | { readonly kind: "failed"; readonly cause: unknown }; try { const initial = await handle.stat(); if (!sameRegularFileState(expectedMetadata, initial)) @@ -70,18 +97,33 @@ export const classifyAndHashRootForInventory = async ( path, handle, initial, - signal, + options.signal, ); const retainSource = isZipFormat(format) || format === "mach-o-universal"; - if (retainSource) transferHandle = true; - return { - format, - digest, - rootSource: retainSource ? { handle, initial } : undefined, + outcome = { + kind: "completed", + value: { + format, + digest, + rootSource: retainSource ? { handle, initial } : undefined, + }, }; - } finally { - if (!transferHandle) await handle.close(); + } catch (cause: unknown) { + outcome = { kind: "failed", cause }; } + if (outcome.kind === "failed" || outcome.value.rootSource === undefined) { + const cleanupAttempt = await options.resourceScope.release(owner); + if (cleanupAttempt.kind === "failed") + throw ArtifactReaderFailure.withCleanup( + outcome.kind === "failed" ? outcome.cause : cleanupAttempt.cause, + ArtifactReaderFailure.cleanupObservation( + cleanupAttempt.cause, + owner.resource, + ), + ); + } + if (outcome.kind === "failed") throw outcome.cause; + return outcome.value; }; const isZipFormat = (format: ArtifactOccurrence["artifact_format"]): boolean => diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 929760002..2d36bcd74 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -11,6 +11,7 @@ import { ArtifactReaderFailure, type ArtifactReader, } from "../ArtifactReader.js"; +import type { ArtifactResourceOwner } from "../ArtifactResourceScope.js"; import { artifactInventoryResultSchema, type ArtifactInventoryResult, @@ -50,7 +51,16 @@ import { export const scanCanonicalArtifactInventory = async ( path: string, - options: ArtifactInventoryOptions = {}, + options: ArtifactInventoryOptions, + readerFactory: typeof createReader = createReader, +): Promise => + options.resourceScope.run(() => + scanCanonicalArtifactInventoryInScope(path, options, readerFactory), + ); + +export const scanCanonicalArtifactInventoryInScope = async ( + path: string, + options: ArtifactInventoryOptions, readerFactory: typeof createReader = createReader, ): Promise => { const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY; @@ -63,7 +73,7 @@ export const scanCanonicalArtifactInventory = async ( path, metadata.isDirectory(), metadata, - options.signal, + options, ); let reader: ArtifactReader | undefined; let readerCreationFailure: { readonly cause: unknown } | undefined; @@ -78,12 +88,7 @@ export const scanCanonicalArtifactInventory = async ( readerCreationFailure = { cause }; } const ownedReaders: ArtifactReader[] = reader === undefined ? [] : [reader]; - let outcome: - | { - readonly kind: "completed"; - readonly snapshot: ArtifactInventorySnapshot; - } - | { readonly kind: "failed"; readonly cause: unknown }; + let outcome: InventoryScanOutcome; try { if (readerCreationFailure !== undefined) throw readerCreationFailure.cause; if (reader instanceof AsarArtifactReader) @@ -113,48 +118,69 @@ export const scanCanonicalArtifactInventory = async ( } catch (cause: unknown) { outcome = { kind: "failed", cause }; } - let cleanupFailure: ArtifactReaderFailure | undefined; - for (const owned of ownedReaders.reverse()) { - try { - await owned.close(); - } catch (cleanupCause: unknown) { - const cleanup = ArtifactReaderFailure.cleanupObservation( - cleanupCause, - `artifact reader for ${path}`, - ); - cleanupFailure = ArtifactReaderFailure.withCleanup( - cleanupFailure ?? - (outcome.kind === "failed" ? outcome.cause : cleanupCause), - cleanup, - outcome.kind === "completed" - ? { kind: "artifact-inventory", inventory: outcome.snapshot } - : undefined, - ); - } - } - if (rootSource !== undefined) { - try { - await rootSource.handle.close(); - } catch (cleanupCause: unknown) { - const cleanup = ArtifactReaderFailure.cleanupObservation( - cleanupCause, - `root artifact descriptor for ${path}`, - ); - cleanupFailure = ArtifactReaderFailure.withCleanup( - cleanupFailure ?? - (outcome.kind === "failed" ? outcome.cause : cleanupCause), - cleanup, - outcome.kind === "completed" - ? { kind: "artifact-inventory", inventory: outcome.snapshot } - : undefined, - ); - } - } + const cleanupFailure = await cleanupInventoryOwners({ + path, + options, + rootSource, + ownedReaders, + outcome, + }); if (cleanupFailure !== undefined) throw cleanupFailure; if (outcome.kind === "failed") throw outcome.cause; return outcome.snapshot; }; +type InventoryScanOutcome = + | { + readonly kind: "completed"; + readonly snapshot: ArtifactInventorySnapshot; + } + | { readonly kind: "failed"; readonly cause: unknown }; + +const cleanupInventoryOwners = async ({ + path, + options, + rootSource, + ownedReaders, + outcome, +}: { + readonly path: string; + readonly options: ArtifactInventoryOptions; + readonly rootSource: StableRegularFileDescriptor | undefined; + readonly ownedReaders: readonly ArtifactReader[]; + readonly outcome: InventoryScanOutcome; +}): Promise => { + let cleanupFailure: ArtifactReaderFailure | undefined; + const owners: ArtifactResourceOwner[] = [...ownedReaders] + .reverse() + .map((reader) => ({ + kind: "reader" as const, + reader, + resource: `artifact reader for ${path}`, + })); + if (rootSource !== undefined) + owners.push({ + kind: "file-handle", + handle: rootSource.handle, + resource: `root artifact descriptor for ${path}`, + }); + for (const owner of owners) { + const attempt = await options.resourceScope.release(owner); + if (attempt.kind === "released") continue; + const primary = + cleanupFailure ?? + (outcome.kind === "failed" ? outcome.cause : attempt.cause); + cleanupFailure = ArtifactReaderFailure.withCleanup( + primary, + ArtifactReaderFailure.cleanupObservation(attempt.cause, owner.resource), + outcome.kind === "completed" + ? { kind: "artifact-inventory", inventory: outcome.snapshot } + : undefined, + ); + } + return cleanupFailure; +}; + interface SnapshotBuildInput { readonly path: string; readonly metadata: Stats; diff --git a/src/artifacts/inventory/types.ts b/src/artifacts/inventory/types.ts index 4d0daa8f3..e9d03196c 100644 --- a/src/artifacts/inventory/types.ts +++ b/src/artifacts/inventory/types.ts @@ -1,3 +1,5 @@ +import type { ArtifactResourceScope } from "../ArtifactResourceScope.js"; + /** Resolved integrity behavior admitted to the artifact scanner. */ export type ArtifactIntegrityPolicy = | { readonly mode: "fail" } @@ -9,6 +11,7 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = { /** Options shared by artifact inventory scans. */ export interface ArtifactInventoryOptions { + readonly resourceScope: ArtifactResourceScope; readonly signal?: AbortSignal | undefined; readonly integrity?: ArtifactIntegrityPolicy | undefined; readonly environment?: Readonly | undefined; diff --git a/src/cli/javascriptApplicationAnalysis.ts b/src/cli/javascriptApplicationAnalysis.ts index 26fad1e3e..30796dfef 100644 --- a/src/cli/javascriptApplicationAnalysis.ts +++ b/src/cli/javascriptApplicationAnalysis.ts @@ -1,7 +1,12 @@ import { analyzeJavaScriptApplication } from "../application/javascript/JavaScriptApplicationService.js"; +import { JAVASCRIPT_APPLICATION_PROVIDER } from "../application/InvestigationProviders.js"; import { createProgressReporter } from "../application/ProgressReporter.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; /** Execute the shared one-shot CLI boundary for static JavaScript analysis. */ export const runCliJavaScriptApplicationAnalysis = async ( @@ -14,10 +19,34 @@ export const runCliJavaScriptApplicationAnalysis = async ( }, { minimumIntervalMs: 0 }, ); - const result = await analyzeJavaScriptApplication(input, { + const resources = new ArtifactResourceScope(); + const result = await analyzeJavaScriptApplication(input, resources, { progress, ...(signal === undefined ? {} : { signal }), }); + try { + await resources.close(); + } catch (cause: unknown) { + const cleanup = ArtifactReaderFailure.cleanupObservation( + cause, + "JavaScript application resources", + ); + const failure = new ProviderCleanupError( + JAVASCRIPT_APPLICATION_PROVIDER.id, + cleanup.resources, + { reason: cleanup.reason }, + { cause, operation: "analyze_javascript_application" }, + ); + return cliError( + result.ok + ? failure + : analysisErrorWithCleanupFailure( + result.error, + failure, + "analyze_javascript_application", + ), + ); + } return result.ok ? result.value : cliError(result.error); }; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 0523006a6..3b55a4927 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -56,6 +56,7 @@ import { JavaScriptRecoveryService } from "../application/javascript/JavaScriptR import type { JavaScriptRecoveryPort } from "../application/javascript/JavaScriptRecoveryPort.js"; import { createJavaScriptRecoveryProvider } from "../composition/javascriptRecovery.js"; import { registerElectronTools } from "./registerElectronTools.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { registerEnhancedTools } from "./registerEnhancedTools.js"; import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js"; import { registerManagedTools } from "./registerManagedTools.js"; @@ -210,6 +211,7 @@ export const createServer = ( options.webSourceLocation ?? createWebSourceLocationService(environment); const javascriptRecovery = options.javascriptRecovery ?? createJavaScriptRecoveryProvider(environment); + const javascriptApplicationResources = new ArtifactResourceScope(); const firmwareAnalysis = new FirmwareAnalysisService( options.firmwareAnalysis ?? createFirmwareAnalysisProvider(environment), ); @@ -246,6 +248,7 @@ export const createServer = ( evmInterface.close(), webSourceLocation.close(), javascriptRecovery.close?.() ?? Promise.resolve(), + javascriptApplicationResources.close(), firmwareAnalysis.close(), options.browserScenarioCapture?.close?.() ?? Promise.resolve(), ]) @@ -286,6 +289,7 @@ export const createServer = ( recordEvidence, recordEvidenceWithUnknown, withAdmittedAnalysis: analysisAdmission, + javascriptApplicationResources, }; registerBinaryAnalysisTools(toolContext); const previousOnclose = server.server.onclose; @@ -497,6 +501,7 @@ const createSessionRecorders = ( }); interface ServerToolContext extends ReturnType { + readonly javascriptApplicationResources: ArtifactResourceScope; readonly server: EvidenceMcpServer; readonly session: BinarySessionPort | undefined; readonly options: CreateServerOptions; @@ -567,6 +572,7 @@ const registerObservationTools = ({ recordEvidence, recordEvidenceWithUnknown, withAdmittedAnalysis, + javascriptApplicationResources, }: ServerToolContext): void => { const common = { logger, recordEvidence, withAdmittedAnalysis }; registerWebScriptTool(server, common); @@ -582,6 +588,7 @@ const registerObservationTools = ({ }); registerElectronTools(server, { ...common, + javascriptApplicationResources, evidenceById, electron: options.electronObservation, electronActive: options.electronActiveObservation, diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts index 2d128943c..167d41eac 100644 --- a/src/server/registerElectronTools.ts +++ b/src/server/registerElectronTools.ts @@ -18,6 +18,7 @@ import { listElectronTargets, } from "../application/javascript/ElectronObservationService.js"; import { analyzeJavaScriptApplicationValidated } from "../application/javascript/JavaScriptApplicationService.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { reconcileJavaScriptRuntimeEvidenceValidated } from "../application/javascript/JavaScriptRuntimeReconciliationService.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import { toolContract } from "../contracts/toolContracts.js"; @@ -34,6 +35,7 @@ import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; interface ElectronToolRegistration { + readonly javascriptApplicationResources: ArtifactResourceScope; readonly logger: Logger; readonly observationLoadFailure?: OptionalProviderLoadFailure | undefined; readonly activeLoadFailure?: OptionalProviderLoadFailure | undefined; @@ -104,6 +106,7 @@ export const registerElectronTools = ( async ({ detail, ...request }, { signal, progress }) => { const analyzed = await analyzeJavaScriptApplicationValidated( request, + options.javascriptApplicationResources, { signal, progress }, ); return detail === "summary" && analyzed.ok diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index a16378317..34719f173 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -15,7 +15,7 @@ import { SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE, } from "../../../src/contracts/javascript/javascriptApplicationWorkflowExamples.js"; import { JAVASCRIPT_EXPORT_SHAPE_COMPARISON_EXAMPLE } from "../../../src/contracts/javascript/javascriptExportShapeComparisonExample.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { javascriptApplicationAnalysisResultSchema, type JavaScriptApplicationAnalysisResult, diff --git a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts index 36843cfa0..af41425ed 100644 --- a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts +++ b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts @@ -5,7 +5,7 @@ import { Filter } from "incur"; import { expect } from "vitest"; import { z } from "zod"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; diff --git a/tests/boundary/cli/javascriptApplicationProgress.test.ts b/tests/boundary/cli/javascriptApplicationProgress.test.ts index a4d855592..9c79a309b 100644 --- a/tests/boundary/cli/javascriptApplicationProgress.test.ts +++ b/tests/boundary/cli/javascriptApplicationProgress.test.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; diff --git a/tests/boundary/filesystem/artifactComparisonSummary.test.ts b/tests/boundary/filesystem/artifactComparisonSummary.test.ts index 0873584a4..bbb721bf7 100644 --- a/tests/boundary/filesystem/artifactComparisonSummary.test.ts +++ b/tests/boundary/filesystem/artifactComparisonSummary.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { compareArtifacts } from "../../../src/domain/artifactComparison.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; diff --git a/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts b/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts index 5d54cc154..3d1d25210 100644 --- a/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts +++ b/tests/boundary/filesystem/artifactDirectoryIdentity.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js"; import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js"; -import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { compareArtifacts } from "../../../src/domain/artifactComparison.js"; import type { ArtifactInventoryResult } from "../../../src/domain/artifactGraph.js"; import { createEvidence } from "../../../src/domain/evidence.js"; diff --git a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts index 04cb7b707..ee56bacf4 100644 --- a/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts +++ b/tests/boundary/filesystem/artifactInventoryEntryOrder.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { createPackage } from "@electron/asar"; import { describe, expect, it } from "vitest"; -import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { artifactOccurrenceAt, artifactParentPaths, diff --git a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts index 00e823ee7..9ea26cfda 100644 --- a/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts +++ b/tests/boundary/filesystem/artifactInventoryRegularRoot.test.ts @@ -5,8 +5,10 @@ import { promisify } from "node:util"; import { expect, it } from "vitest"; -import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js"; -import { classifyAndHashRoot } from "../../../src/artifacts/inventory/classify.js"; +import { + classifyAndHashRoot, + inventoryArtifact, +} from "../../fixtures/artifactInventory.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js"; diff --git a/tests/boundary/filesystem/artifactInventorySnapshot.test.ts b/tests/boundary/filesystem/artifactInventorySnapshot.test.ts index 337bb7c70..a72c5de18 100644 --- a/tests/boundary/filesystem/artifactInventorySnapshot.test.ts +++ b/tests/boundary/filesystem/artifactInventorySnapshot.test.ts @@ -1,7 +1,7 @@ import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -10,9 +10,10 @@ import { ArtifactReaderFailure, type ArtifactReader, } from "../../../src/artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; -import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js"; -import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { scanCanonicalArtifactInventory as scanCanonicalArtifactInventoryOwned } from "../../../src/artifacts/inventory/scanCanonical.js"; +import { scanArtifactInventory } from "../../fixtures/artifactInventory.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; describe("artifact inventory snapshot", () => { @@ -35,6 +36,9 @@ describe("artifact inventory snapshot", () => { const root = await createTestTempDirectory("rea-inventory-cleanup-"); await writeFile(join(root, "observed.txt"), "observed"); const directory = new DirectoryArtifactReader(root); + const resourceScope = new ArtifactResourceScope(); + onTestFinished(() => resourceScope.close()); + let closeAttempts = 0; const reader: ArtifactReader = { format: directory.format, entries: (signal) => directory.entries(signal), @@ -42,6 +46,8 @@ describe("artifact inventory snapshot", () => { provenance: () => directory.provenance(), async close() { await directory.close(); + closeAttempts += 1; + if (closeAttempts > 1) return; throw new ArtifactReaderFailure("unavailable", "mount still attached", { cleanup: { reason: "DMG detach failed", @@ -50,9 +56,9 @@ describe("artifact inventory snapshot", () => { }); }, }; - const failure = await scanCanonicalArtifactInventory( + const failure = await scanCanonicalArtifactInventoryOwned( root, - {}, + { resourceScope }, () => reader, ).catch((cause: unknown) => cause); expect(failure).toBeInstanceOf(ArtifactReaderFailure); @@ -79,5 +85,7 @@ describe("artifact inventory snapshot", () => { }, }, }); + await resourceScope.close(); + expect(closeAttempts).toBe(2); }); }); diff --git a/tests/boundary/filesystem/artifactMzInventory.test.ts b/tests/boundary/filesystem/artifactMzInventory.test.ts index 1073534f6..613b5feb8 100644 --- a/tests/boundary/filesystem/artifactMzInventory.test.ts +++ b/tests/boundary/filesystem/artifactMzInventory.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { classifyArtifactContent } from "../../../src/artifacts/inventory/ArtifactGraphConstruction.js"; import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/artifacts/ArtifactHash.js"; -import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js"; +import { scanCanonicalArtifactInventory } from "../../fixtures/artifactInventory.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/filesystem/asarInventoryContainment.test.ts b/tests/boundary/filesystem/asarInventoryContainment.test.ts index 5251a30e9..42e470c54 100644 --- a/tests/boundary/filesystem/asarInventoryContainment.test.ts +++ b/tests/boundary/filesystem/asarInventoryContainment.test.ts @@ -11,7 +11,7 @@ import { } from "@zip.js/zip.js"; import { describe, expect, it } from "vitest"; -import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { artifactOccurrenceAt, artifactParentPaths, diff --git a/tests/boundary/filesystem/asarInventoryFreshness.test.ts b/tests/boundary/filesystem/asarInventoryFreshness.test.ts index 3b7cedcfa..1ad207dff 100644 --- a/tests/boundary/filesystem/asarInventoryFreshness.test.ts +++ b/tests/boundary/filesystem/asarInventoryFreshness.test.ts @@ -4,12 +4,13 @@ import { Readable } from "node:stream"; import { buffer } from "node:stream/consumers"; import { createPackage, listPackage, uncache } from "@electron/asar"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; -import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; -import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; +import { scanCanonicalArtifactInventory as scanCanonicalArtifactInventoryOwned } from "../../../src/artifacts/inventory/scanCanonical.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const CONTENT = "module.exports = 42;\n"; @@ -175,27 +176,39 @@ describe("ASAR captured-byte identity", () => { const fixture = await archiveFixture(); const originalSize = (await lstat(fixture.archive)).size; const directory = new DirectoryArtifactReader(fixture.bundle); - await expect( - scanCanonicalArtifactInventory(fixture.bundle, {}, () => ({ - format: directory.format, - entries: (signal) => directory.entries(signal), - provenance: () => directory.provenance(), - close: () => directory.close(), - async open(entry, signal) { - const source = await directory.open(entry, signal); - return Readable.from( - (async function* () { - yield* source; - await fixture.replace(); - expect((await lstat(fixture.archive)).size).toBe(originalSize); - })(), - ); - }, - })), - ).rejects.toMatchObject({ - reason: "integrity", - message: expect.stringContaining("changed before interpretation"), - }); + const resourceScope = new ArtifactResourceScope(); + onTestFinished(() => resourceScope.close()); + try { + await expect( + scanCanonicalArtifactInventoryOwned( + fixture.bundle, + { resourceScope }, + () => ({ + format: directory.format, + entries: (signal) => directory.entries(signal), + provenance: () => directory.provenance(), + close: () => directory.close(), + async open(entry, signal) { + const source = await directory.open(entry, signal); + return Readable.from( + (async function* () { + yield* source; + await fixture.replace(); + expect((await lstat(fixture.archive)).size).toBe( + originalSize, + ); + })(), + ); + }, + }), + ), + ).rejects.toMatchObject({ + reason: "integrity", + message: expect.stringContaining("changed before interpretation"), + }); + } finally { + await resourceScope.close(); + } }); it("isolates interleaved readers from replacement headers and packed bytes", async () => { @@ -250,10 +263,12 @@ describe("ASAR captured-byte identity", () => { await rm(path, { recursive: true, force: true }); }, ); + const resourceScope = new ArtifactResourceScope(); + onTestFinished(() => resourceScope.close()); try { - const failure: unknown = await scanCanonicalArtifactInventory( + const failure: unknown = await scanCanonicalArtifactInventoryOwned( fixture.archive, - {}, + { resourceScope }, () => reader, ).catch((cause: unknown) => cause); expect(failure).toMatchObject({ @@ -273,11 +288,11 @@ describe("ASAR captured-byte identity", () => { const root = attempts[0]; if (root === undefined) throw new Error("Expected owned snapshot"); expect((await lstat(root)).isDirectory()).toBe(true); - await reader.close(); + await resourceScope.close(); expect(attempts).toEqual([root, root]); await expect(lstat(root)).rejects.toMatchObject({ code: "ENOENT" }); } finally { - await reader.close(); + await resourceScope.close(); } }); }); diff --git a/tests/boundary/filesystem/asarStreamingAllocation.test.ts b/tests/boundary/filesystem/asarStreamingAllocation.test.ts index 0af58d0df..f982f0ea9 100644 --- a/tests/boundary/filesystem/asarStreamingAllocation.test.ts +++ b/tests/boundary/filesystem/asarStreamingAllocation.test.ts @@ -14,7 +14,7 @@ import { Readable } from "node:stream"; import { buffer } from "node:stream/consumers"; import { createPackageWithOptions } from "@electron/asar"; import { describe, expect, it } from "vitest"; -import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { scanArtifactInventory } from "../../fixtures/artifactInventory.js"; import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; import { closeAsarHandle, diff --git a/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts b/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts index 04d5de8df..390ea6664 100644 --- a/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; const analyzeFailure = async (input: Record) => { diff --git a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts index 8da2a46a5..17c3f0048 100644 --- a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { compareApplicationVersionsRequestSchema } from "../../../src/contracts/javascript/applicationWorkflowInputContracts.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { compareApplicationVersionsEvidence, traceApplicationFeatureEvidence, diff --git a/tests/boundary/filesystem/javascriptArtifactBomRanges.test.ts b/tests/boundary/filesystem/javascriptArtifactBomRanges.test.ts index dfa6a3c20..dabf98548 100644 --- a/tests/boundary/filesystem/javascriptArtifactBomRanges.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactBomRanges.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { bomSourceCases, createBomArtifact, diff --git a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts index 2acfb8911..47e1b5ce4 100644 --- a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { createPackage } from "@electron/asar"; import { describe, expect, it } from "vitest"; -import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { scanArtifactInventory } from "../../fixtures/artifactInventory.js"; import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js"; import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; diff --git a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts index 5651d5390..5ecbb0fda 100644 --- a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts @@ -7,8 +7,8 @@ import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; -import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; +import { scanArtifactInventory } from "../../fixtures/artifactInventory.js"; import { type ArtifactEntry, type ArtifactReader, diff --git a/tests/boundary/filesystem/javascriptArtifactTypeScriptDialect.test.ts b/tests/boundary/filesystem/javascriptArtifactTypeScriptDialect.test.ts index b266dde87..bef55982e 100644 --- a/tests/boundary/filesystem/javascriptArtifactTypeScriptDialect.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactTypeScriptDialect.test.ts @@ -1,7 +1,7 @@ import { parse } from "@babel/parser"; import { expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { typeScriptDialectCases, createTypeScriptDialectArtifact, diff --git a/tests/boundary/filesystem/javascriptExportContainerCoverage.test.ts b/tests/boundary/filesystem/javascriptExportContainerCoverage.test.ts index bea6adbac..3fa716307 100644 --- a/tests/boundary/filesystem/javascriptExportContainerCoverage.test.ts +++ b/tests/boundary/filesystem/javascriptExportContainerCoverage.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { compareJavaScriptExportShapesEvidence } from "../../../src/application/javascript/JavaScriptApplicationWorkflowService.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts index ed429c35e..365cbcdf8 100644 --- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts +++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js"; import { compareJavaScriptExportShapes } from "../../../src/domain/javascript/javascriptExportShapeComparison.js"; import { diff --git a/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts b/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts index a251469b0..29a9847df 100644 --- a/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts +++ b/tests/boundary/filesystem/javascriptHtmlEntrypoints.test.ts @@ -9,7 +9,7 @@ import { } from "parse5"; import { expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const cases = [ diff --git a/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts b/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts index 979951b3c..7310629fa 100644 --- a/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts +++ b/tests/boundary/filesystem/javascriptHtmlResourcePaths.test.ts @@ -4,7 +4,7 @@ import { dirname, join, relative, sep } from "node:path"; import { expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const cases: readonly { diff --git a/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts b/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts index 9fd482ec2..61e120e18 100644 --- a/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts +++ b/tests/boundary/filesystem/javascriptIntegrityPolicy.test.ts @@ -9,12 +9,12 @@ import { join } from "node:path"; import { createPackage, createPackageWithOptions } from "@electron/asar"; import { expect, it } from "vitest"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createJavaScriptArtifactReader } from "../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js"; -import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js"; +import { scanCanonicalArtifactInventory } from "../../fixtures/artifactInventory.js"; import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts b/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts index aa8596cb6..1c73bd894 100644 --- a/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts +++ b/tests/boundary/filesystem/javascriptPackageExportTargets.test.ts @@ -6,7 +6,7 @@ import { promisify } from "node:util"; import { expect, it } from "vitest"; import { z } from "zod"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const cases: readonly { diff --git a/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts b/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts index 8bc2511e1..acade00ea 100644 --- a/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts +++ b/tests/boundary/filesystem/javascriptPackagePrecedence.test.ts @@ -8,7 +8,7 @@ import { promisify } from "node:util"; import { expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const cases: readonly { diff --git a/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts b/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts index 77d383cbe..65349b8e4 100644 --- a/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts +++ b/tests/boundary/filesystem/javascriptRequireLiteralPaths.test.ts @@ -4,7 +4,7 @@ import { join, relative, sep } from "node:path"; import { describe, expect, it } from "vitest"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/filesystem/javascriptSourceMapComments.test.ts b/tests/boundary/filesystem/javascriptSourceMapComments.test.ts index aaaae82f4..aa20dd9b7 100644 --- a/tests/boundary/filesystem/javascriptSourceMapComments.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapComments.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it("creates source-map graph relationships only for real comments", async () => { diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index 278324b54..69a5af662 100644 --- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { parseJavaScriptApplicationGraph, diff --git a/tests/boundary/filesystem/sourceToBundlePaths.test.ts b/tests/boundary/filesystem/sourceToBundlePaths.test.ts index 7dcd910ff..50e39000e 100644 --- a/tests/boundary/filesystem/sourceToBundlePaths.test.ts +++ b/tests/boundary/filesystem/sourceToBundlePaths.test.ts @@ -2,7 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +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"; diff --git a/tests/boundary/filesystem/webScriptExport.test.ts b/tests/boundary/filesystem/webScriptExport.test.ts index 3a6ce47fd..87f05c545 100644 --- a/tests/boundary/filesystem/webScriptExport.test.ts +++ b/tests/boundary/filesystem/webScriptExport.test.ts @@ -14,7 +14,7 @@ import { promisify } from "node:util"; import { describe, expect, it, onTestFinished } from "vitest"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { exportWebScripts } from "../../../src/application/WebScriptExportService.js"; import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js"; import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js"; diff --git a/tests/boundary/filesystem/zipDetection.test.ts b/tests/boundary/filesystem/zipDetection.test.ts index 3464afa59..08c318257 100644 --- a/tests/boundary/filesystem/zipDetection.test.ts +++ b/tests/boundary/filesystem/zipDetection.test.ts @@ -9,8 +9,10 @@ import { } from "@zip.js/zip.js"; import { describe, expect, it } from "vitest"; -import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js"; -import { classifyAndHashRoot } from "../../../src/artifacts/inventory/classify.js"; +import { + classifyAndHashRoot, + inventoryArtifact, +} from "../../fixtures/artifactInventory.js"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; import { artifactOccurrenceAt } from "../../fixtures/artifactEntryOrder.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/inspector/javascriptRuntimeUnresolvedTarget.test.ts b/tests/boundary/inspector/javascriptRuntimeUnresolvedTarget.test.ts index cfc47a735..a5a91239f 100644 --- a/tests/boundary/inspector/javascriptRuntimeUnresolvedTarget.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeUnresolvedTarget.test.ts @@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url"; import { expect, test } from "vitest"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { createJavaScriptRuntimeObservationEvidence } from "../../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js"; import { diff --git a/tests/boundary/mcp/applicationExportShapeMcp.test.ts b/tests/boundary/mcp/applicationExportShapeMcp.test.ts index e336e3f74..179451390 100644 --- a/tests/boundary/mcp/applicationExportShapeMcp.test.ts +++ b/tests/boundary/mcp/applicationExportShapeMcp.test.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { compareJavaScriptExportShapesEvidence } from "../../../src/application/javascript/JavaScriptApplicationWorkflowService.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { javaScriptExportShapeComparisonResultSchema } from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js"; diff --git a/tests/boundary/mcp/applicationMutationPresenceMcp.test.ts b/tests/boundary/mcp/applicationMutationPresenceMcp.test.ts index bece1f9bd..a2e8ad098 100644 --- a/tests/boundary/mcp/applicationMutationPresenceMcp.test.ts +++ b/tests/boundary/mcp/applicationMutationPresenceMcp.test.ts @@ -7,7 +7,7 @@ import { projectedExportReturnShapesSchema, } from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; diff --git a/tests/boundary/mcp/electronMcp.test.ts b/tests/boundary/mcp/electronMcp.test.ts index 293a34a99..39bdc3ef7 100644 --- a/tests/boundary/mcp/electronMcp.test.ts +++ b/tests/boundary/mcp/electronMcp.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { afterEach, expect, it } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import { z } from "zod"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -13,7 +13,7 @@ import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import type { ElectronActiveObservationPort } from "../../../src/application/javascript/ElectronActiveObservationPort.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { CdpElectronProvider } from "../../../src/browser/CdpElectronProvider.js"; import { createServer } from "../../../src/server/createServer.js"; import { observed } from "../../fixtures/analysisExecution.js"; @@ -24,11 +24,64 @@ import { import { writeElectronBoundaryFixture } from "../../fixtures/electronBoundaryApplication.js"; import { createElectronActiveObservationFixtureResult } from "../../../src/domain/javascript/electronActiveObservation.fixture.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; const browsers: FakeCdpBrowser[] = []; const resources: Array<{ close(): Promise }> = []; const temporary: string[] = []; +it("retains failed JavaScript inventory cleanup through server shutdown retries", async () => { + const { archive } = await createStrippedAsarAddon(); + const session = createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(resultOk(null)), + })); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "javascript-cleanup-owner", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const originalClose = AsarArtifactReader.prototype.close; + const owners = new Set(); + let blocked = true; + const close = vi + .spyOn(AsarArtifactReader.prototype, "close") + .mockImplementation(function (this: AsarArtifactReader) { + owners.add(this); + if (blocked) return Promise.reject(new Error("snapshot cleanup blocked")); + return originalClose.call(this); + }); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const response = await client.callTool({ + name: "analyze_javascript_application", + arguments: { + input_path: archive, + integrity_policy: "record-and-continue", + }, + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: "cleanup_incomplete", + details: { + resources: [expect.stringContaining(archive)], + partial_observation: { kind: "artifact-inventory" }, + }, + }, + }); + expect(owners.size).toBe(1); + await expect(server.close()).rejects.toThrow(); + blocked = false; + await server.close(); + expect(owners.size).toBe(1); + expect(close.mock.calls.length).toBeGreaterThanOrEqual(3); + } finally { + blocked = false; + await Promise.all([client.close(), server.close(), session.close()]); + close.mockRestore(); + } +}); + afterEach(async () => { await Promise.all(resources.splice(0).map(async (item) => item.close())); await Promise.all(browsers.splice(0).map(async (browser) => browser.close())); diff --git a/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts b/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts index 3d709e3c7..58b12ea3e 100644 --- a/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts +++ b/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts @@ -8,7 +8,7 @@ import { afterEach, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createElectronEvidence } from "../../../src/application/javascript/ElectronEvidence.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { parseRuntimeReconciliationInput, reconcileJavaScriptRuntime, diff --git a/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts b/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts new file mode 100644 index 000000000..fd8bd052e --- /dev/null +++ b/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts @@ -0,0 +1,227 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { createPackage } from "@electron/asar"; +import { expect, it, onTestFinished, vi } from "vitest"; + +import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; +import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js"; +import { AsarArtifactReader } from "../../../../src/artifacts/AsarArtifactReader.js"; +import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js"; +import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const deferred = () => { + let resolve: () => void = () => {}; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +const createArchive = async () => { + const root = await createTestTempDirectory("rea-artifact-cleanup-retry-"); + const contents = join(root, "contents"); + await mkdir(contents); + await writeFile(join(contents, "payload.txt"), "payload\n"); + const archive = join(root, "fixture.asar"); + await createPackage(contents, archive); + return { root, archive }; +}; + +const createClient = (path: string) => + new ArtifactProvider(process.env).createClient({ + path, + sourcePath: path, + sha256: "0".repeat(64), + kind: "archive", + format: "asar", + }); + +it("retains a failed reader close, blocks new work, and retries the same reader", async () => { + const { root, archive } = await createArchive(); + const output = join(root, "output"); + const client = createClient(archive); + const originalClose = AsarArtifactReader.prototype.close; + const closeOwners: AsarArtifactReader[] = []; + let readerCloseBlocked = true; + onTestFinished(async () => { + readerCloseBlocked = false; + try { + await client.close(); + } finally { + vi.restoreAllMocks(); + } + }); + vi.spyOn(AsarArtifactReader.prototype, "close").mockImplementation( + async function (this: AsarArtifactReader) { + closeOwners.push(this); + if (closeOwners[1] === this && readerCloseBlocked) + throw new ArtifactReaderFailure( + "unavailable", + "injected reader close failure", + ); + await originalClose.call(this); + }, + ); + + const extraction = await client.execute("extract_artifact", { + output_root: output, + }); + expect(extraction).toMatchObject({ + ok: false, + error: { + _tag: "ArtifactOperationError", + reason: "unavailable", + cleanup: { resources: [expect.stringContaining("artifact reader")] }, + }, + }); + await expect(access(output)).rejects.toThrow(); + expect(closeOwners).toHaveLength(2); + + const blocked = await client.execute("inventory_artifact", {}); + expect(blocked).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "unavailable" }, + }); + expect(closeOwners).toHaveLength(3); + expect(closeOwners[2]).toBe(closeOwners[1]); + + const incompleteClose = await client.close(); + expect(incompleteClose.ok).toBe(false); + if (incompleteClose.ok) throw new Error("Expected reader cleanup failure"); + expect(incompleteClose.error).toBeInstanceOf(ProviderCleanupError); + expect(closeOwners).toHaveLength(4); + expect(closeOwners[3]).toBe(closeOwners[1]); + readerCloseBlocked = false; + expect(await client.close()).toMatchObject({ ok: true }); + expect(closeOwners).toHaveLength(5); + expect(closeOwners[4]).toBe(closeOwners[1]); + const closed = await client.execute("inventory_artifact", {}); + expect(closed).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "unavailable" }, + }); +}); + +it("retains a failed rollback owner and preserves the materialization failure", async () => { + const { root, archive } = await createArchive(); + const output = join(root, "output"); + const client = createClient(archive); + const originalWrite = SafeOutputTree.prototype.write; + const originalRollback = SafeOutputTree.prototype.rollback; + const rollbackOwners: SafeOutputTree[] = []; + let failRollback = true; + onTestFinished(async () => { + await client.close(); + vi.restoreAllMocks(); + }); + vi.spyOn(SafeOutputTree.prototype, "write").mockImplementation( + async function ( + this: SafeOutputTree, + ...args: Parameters + ) { + const [relativePath, stream, expected, signal] = args; + const written = await originalWrite.call( + this, + relativePath, + stream, + expected, + signal, + ); + expect(written.bytesWritten).toBeGreaterThan(0); + throw new ArtifactReaderFailure( + "unavailable", + "primary materialization failure", + ); + }, + ); + vi.spyOn(SafeOutputTree.prototype, "rollback").mockImplementation( + async function (this: SafeOutputTree) { + rollbackOwners.push(this); + if (failRollback) { + failRollback = false; + throw new Error("injected rollback failure"); + } + return originalRollback.call(this); + }, + ); + + const extraction = await client.execute("extract_artifact", { + output_root: output, + }); + expect(extraction).toMatchObject({ + ok: false, + error: { + _tag: "ArtifactOperationError", + reason: "unavailable", + detail: expect.stringContaining("primary materialization failure"), + cleanup: { resources: [output] }, + }, + }); + expect(rollbackOwners).toHaveLength(1); + await access(output); + expect(await client.close()).toMatchObject({ ok: true }); + expect(rollbackOwners).toHaveLength(2); + expect(rollbackOwners[1]).toBe(rollbackOwners[0]); + await expect(access(output)).rejects.toThrow(); +}); + +it("waits for admitted extraction before close and rejects later work", async () => { + const { root, archive } = await createArchive(); + const output = join(root, "output"); + const client = createClient(archive); + const originalWrite = SafeOutputTree.prototype.write; + const started = deferred(); + const finish = deferred(); + let writeCalls = 0; + let closeFinished = false; + onTestFinished(async () => { + finish.resolve(); + await client.close(); + vi.restoreAllMocks(); + }); + vi.spyOn(SafeOutputTree.prototype, "write").mockImplementation( + async function ( + this: SafeOutputTree, + ...args: Parameters + ) { + const [relativePath, stream, expected, signal] = args; + writeCalls += 1; + const written = await originalWrite.call( + this, + relativePath, + stream, + expected, + signal, + ); + started.resolve(); + await finish.promise; + return written; + }, + ); + + const extraction = client.execute("extract_artifact", { + output_root: output, + }); + await started.promise; + const closing = client.close().then((result) => { + closeFinished = true; + return result; + }); + const concurrent = await client.execute("extract_artifact", { + output_root: join(root, "rejected-output"), + }); + expect(concurrent).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "unavailable" }, + }); + expect(writeCalls).toBe(1); + await Promise.resolve(); + expect(closeFinished).toBe(false); + finish.resolve(); + const completed = await extraction; + expect(completed.ok).toBe(true); + expect(await closing).toMatchObject({ ok: true }); + expect(await readFile(join(output, "payload.txt"), "utf8")).toBe("payload\n"); +}); diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index 9a538e2b1..6488af65f 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -14,6 +14,26 @@ import { import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; describe("artifact extraction", () => { + it("keeps committed files after the owning client closes", async () => { + const root = await createTestTempDirectory("rea-extract-client-close-"); + const source = join(root, "source"); + await mkdir(source); + await writeFile(join(source, "payload.txt"), "committed\n"); + const output = join(root, "output"); + const client = new ArtifactProvider(process.env).createClient( + target(source, "directory"), + ); + + const result = await client.execute("extract_artifact", { + output_root: output, + }); + if (!result.ok) throw result.error; + await client.close(); + expect(await readFile(join(output, "payload.txt"), "utf8")).toBe( + "committed\n", + ); + }); + it("extracts all regular occurrences through an exclusively owned output tree", async () => { const root = await createTestTempDirectory("rea-extract-"); const source = join(root, "source"); diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index 7444a220e..d42762601 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -6,8 +6,10 @@ import { createPackage, createPackageWithOptions } from "@electron/asar"; import { expect, it } from "vitest"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; -import { materializeArtifactInventory } from "../../../../src/artifacts/extraction/ArtifactExtraction.js"; -import { scanArtifactInventory } from "../../../../src/artifacts/inventory/ArtifactInventory.js"; +import { + materializeArtifactInventory, + scanArtifactInventory, +} from "../../../fixtures/artifactInventory.js"; import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index 11a63a976..7ec3edc60 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { execFileOutput } from "../../../../src/process/ExecFileOutput.js"; import { ArtifactReaderFailure } from "../../../../src/artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; import { NativeDmgArtifactReader, type NativeDmgHost, @@ -209,9 +210,11 @@ describe("DMG inventory reader ownership", () => { let reader: NativeDmgArtifactReader | undefined; let closeCalls = 0; + const resourceScope = new ArtifactResourceScope(); + onTestFinished(() => resourceScope.close()); const failure = await scanCanonicalArtifactInventory( path, - {}, + { resourceScope }, (inputPath, _format, environment) => { reader = new NativeDmgArtifactReader(inputPath, environment, host); const closeReader = reader.close.bind(reader); @@ -240,7 +243,8 @@ describe("DMG inventory reader ownership", () => { expect(await realpath(mountRoot)).toBe(mountRoot); infoAvailable = true; - await reader.close(); + await resourceScope.close(); + expect(closeCalls).toBe(2); expect(attached).toBe(false); await expect(realpath(mountRoot)).rejects.toMatchObject({ code: "ENOENT" }); expect(calls.map(([operation]) => operation)).toEqual([ diff --git a/tests/composition/analysis-sessions/artifactComparison.test.ts b/tests/composition/analysis-sessions/artifactComparison.test.ts index a3850df20..4633a3fb2 100644 --- a/tests/composition/analysis-sessions/artifactComparison.test.ts +++ b/tests/composition/analysis-sessions/artifactComparison.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { inventoryArtifact } from "../../fixtures/artifactInventory.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; import { artifactComparisonResultSchema, diff --git a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts index dfc74255a..90d7e2504 100644 --- a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts @@ -5,8 +5,8 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { analyzeJavaScriptApplicationInputSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; diff --git a/tests/composition/javascript-applications/electronOptionKeys.test.ts b/tests/composition/javascript-applications/electronOptionKeys.test.ts index b05162fa9..16c1ce2cd 100644 --- a/tests/composition/javascript-applications/electronOptionKeys.test.ts +++ b/tests/composition/javascript-applications/electronOptionKeys.test.ts @@ -2,7 +2,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts index 4bbc2eb36..846eee6c3 100644 --- a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -11,7 +11,7 @@ import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { analyzeJavaScriptArtifactFiles, analyzeAndProjectJavaScriptArtifactFiles, @@ -23,8 +23,10 @@ import { } from "../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js"; import { createJavaScriptArtifactReader } from "../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js"; -import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/scanCanonical.js"; -import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js"; +import { + scanCanonicalArtifactInventory, + scanArtifactInventory, +} from "../../fixtures/artifactInventory.js"; import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { createJavaScriptSemanticGraph } from "../../../src/domain/javascript/javascriptSemanticGraph.js"; diff --git a/tests/composition/javascript-applications/javascriptHtmlReferenceResolution.test.ts b/tests/composition/javascript-applications/javascriptHtmlReferenceResolution.test.ts index 00265773b..86faea2bf 100644 --- a/tests/composition/javascript-applications/javascriptHtmlReferenceResolution.test.ts +++ b/tests/composition/javascript-applications/javascriptHtmlReferenceResolution.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { expect, it } from "vitest"; -import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it("resolves empty-path HTML script URLs against their canonical base path", async () => { diff --git a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts index 9e90a1da4..e69e1f486 100644 --- a/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts +++ b/tests/composition/runtime-observation/javascriptRuntimeReconciliation.test.ts @@ -7,7 +7,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createElectronEvidence } from "../../../src/application/javascript/ElectronEvidence.js"; import { createElectronActiveEvidence } from "../../../src/application/javascript/ElectronActiveEvidence.js"; -import { analyzeJavaScriptApplication } from "../../../src/application/javascript/JavaScriptApplicationService.js"; +import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { diff --git a/tests/fixtures/artifactInventory.ts b/tests/fixtures/artifactInventory.ts new file mode 100644 index 000000000..776d55fbe --- /dev/null +++ b/tests/fixtures/artifactInventory.ts @@ -0,0 +1,108 @@ +import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { onTestFinished } from "vitest"; +import { ArtifactReaderFailure } from "../../src/artifacts/ArtifactReader.js"; +import { classifyAndHashRoot as classifyAndHashRootOwned } from "../../src/artifacts/inventory/classify.js"; +import { + inventoryArtifact as inventoryArtifactOwned, + scanArtifactInventory as scanArtifactInventoryOwned, +} from "../../src/artifacts/inventory/ArtifactInventory.js"; +import { scanCanonicalArtifactInventory as scanCanonicalArtifactInventoryOwned } from "../../src/artifacts/inventory/scanCanonical.js"; +import type { createReader } from "../../src/artifacts/inventory/reader.js"; +import { + materializeArtifactInventory as materializeArtifactInventoryOwned, + type ArtifactExtractionInput, +} from "../../src/artifacts/extraction/ArtifactExtraction.js"; +import type { ArtifactInventorySnapshot } from "../../src/domain/artifactInventorySnapshot.js"; +import type { ArtifactInventoryOptions } from "../../src/artifacts/inventory/types.js"; + +type InventoryOptions = Omit; + +export const inventoryArtifact = ( + path: string, + options: InventoryOptions = {}, +) => + withArtifactResourceScope((resourceScope) => + inventoryArtifactOwned(path, { ...options, resourceScope }), + ); + +export const classifyAndHashRoot = ( + path: string, + directory: boolean, + expectedMetadata: import("node:fs").Stats, + signal?: AbortSignal, +) => + withArtifactResourceScope((resourceScope) => + classifyAndHashRootOwned(path, directory, expectedMetadata, { + resourceScope, + ...(signal === undefined ? {} : { signal }), + }), + ); + +export const scanArtifactInventory = ( + path: string, + options: InventoryOptions = {}, +) => + withArtifactResourceScope((resourceScope) => + scanArtifactInventoryOwned(path, { ...options, resourceScope }), + ); + +export const scanCanonicalArtifactInventory = ( + path: string, + options: InventoryOptions = {}, + readerFactory?: typeof createReader, +) => + withArtifactResourceScope((resourceScope) => + scanCanonicalArtifactInventoryOwned( + path, + { ...options, resourceScope }, + readerFactory, + ), + ); + +export const materializeArtifactInventory = ( + input: Omit, + sourcePath: string, + snapshot: ArtifactInventorySnapshot, + signal?: AbortSignal, +) => + withArtifactResourceScope((resourceScope) => + materializeArtifactInventoryOwned( + { ...input, resourceScope }, + sourcePath, + snapshot, + signal, + ), + ); + +export const withArtifactResourceScope = async ( + operation: (resourceScope: ArtifactResourceScope) => Promise, +): Promise => { + const resourceScope = new ArtifactResourceScope(); + onTestFinished(() => resourceScope.close()); + let outcome: + | { readonly kind: "completed"; readonly value: T } + | { readonly kind: "failed"; readonly cause: unknown }; + try { + outcome = { + kind: "completed", + value: await operation(resourceScope), + }; + } catch (cause: unknown) { + outcome = { kind: "failed", cause }; + } + try { + await resourceScope.close(); + } catch (cleanupCause: unknown) { + if (outcome.kind === "failed") + throw ArtifactReaderFailure.withCleanup( + outcome.cause, + ArtifactReaderFailure.cleanupObservation( + cleanupCause, + "artifact resources", + ), + ); + throw cleanupCause; + } + if (outcome.kind === "failed") throw outcome.cause; + return outcome.value; +}; diff --git a/tests/support/javascriptApplicationFixture.ts b/tests/support/javascriptApplicationFixture.ts index c8522aeb6..45afb3a82 100644 --- a/tests/support/javascriptApplicationFixture.ts +++ b/tests/support/javascriptApplicationFixture.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { reconstructJavaScriptArtifact } from "../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { reconstructJavaScriptArtifact } from "./javascriptApplicationScope.js"; import type { ApplicationEdge, ApplicationNode, diff --git a/tests/support/javascriptApplicationScope.ts b/tests/support/javascriptApplicationScope.ts new file mode 100644 index 000000000..830f80368 --- /dev/null +++ b/tests/support/javascriptApplicationScope.ts @@ -0,0 +1,21 @@ +import { withArtifactResourceScope } from "../fixtures/artifactInventory.js"; +import type { ExecutionOptions } from "../../src/application/AnalysisProvider.js"; +import { analyzeJavaScriptApplication as analyzeOwned } from "../../src/application/javascript/JavaScriptApplicationService.js"; +import { reconstructJavaScriptArtifact as reconstructOwned } from "../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import type { ProgressReporter } from "../../src/application/ProgressReporter.js"; + +/** Run a JavaScript analysis with an awaited, test-owned artifact scope. */ +export const analyzeJavaScriptApplication = ( + input: unknown, + options: ExecutionOptions = {}, +) => withArtifactResourceScope((scope) => analyzeOwned(input, scope, options)); + +/** Reconstruct a fixture with an awaited, test-owned artifact scope. */ +export const reconstructJavaScriptArtifact = ( + rawInput: unknown, + signal?: AbortSignal, + progress?: ProgressReporter, +) => + withArtifactResourceScope((scope) => + reconstructOwned(rawInput, scope, signal, progress), + );