From ffe0574e17b9d973860371083cb1a351be912131 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:41:49 +0800 Subject: [PATCH] fix(artifacts)!: retain cleanup owners through client shutdown Keep failed reader, root descriptor, and output-tree cleanup with the caller. Drain admitted work before close, retry the same retained owners, and block new resource work while cleanup is incomplete. Preserve primary failures and complete inventory observations. Thread ownership through JavaScript reconstruction, the CLI's one-shot scope, MCP server shutdown, verifier scripts, and test fixtures. Remove redundant ownership flags and the unused validated test wrapper. Validate with 850 passing focused tests across 90 suites (two skipped), targeted classification and cleanup retries, static and unused-code checks, and real macOS CLI/MCP directory, ZIP, and DMG workflows. Native Windows and deep-analysis providers were not verified. BREAKING CHANGE: Direct artifact inventory/extraction and JavaScript analysis APIs require an explicit caller-owned ArtifactResourceScope. CLI and MCP input contracts are unchanged. --- .../lib/javascript-application-analysis.mjs | 28 ++ .../javascript/application-workflows.mjs | 8 +- .../verify/javascript/runtime-observation.mjs | 7 +- .../JavaScriptApplicationCancellation.test.ts | 2 +- ...JavaScriptApplicationEmptyLiterals.test.ts | 2 +- ...aScriptApplicationModuleProvenance.test.ts | 2 +- .../JavaScriptApplicationService.test.ts | 42 ++- .../JavaScriptApplicationService.ts | 32 ++- .../JavaScriptApplicationSignedZero.test.ts | 2 +- ...aScriptApplicationTemplateLiterals.test.ts | 2 +- .../JavaScriptApplicationTypeScript.test.ts | 2 +- .../JavaScriptArtifactReconstruction.ts | 260 ++++++++++-------- .../javascript/JavaScriptFileUrlPaths.test.ts | 2 +- .../JavaScriptModuleSpecifierPaths.test.ts | 2 +- .../JavaScriptOpenClassification.test.ts | 2 +- src/artifacts/ArtifactProvider.ts | 26 +- src/artifacts/ArtifactResourceScope.test.ts | 68 +++++ src/artifacts/ArtifactResourceScope.ts | 140 ++++++++++ .../extraction/ArtifactExtraction.ts | 206 +++++++++----- src/artifacts/inventory/ArtifactInventory.ts | 16 +- .../ArtifactOccurrenceIdentity.test.ts | 2 +- src/artifacts/inventory/classify.ts | 86 ++++-- src/artifacts/inventory/scanCanonical.ts | 116 +++++--- src/artifacts/inventory/types.ts | 3 + src/cli/javascriptApplicationAnalysis.ts | 31 ++- src/server/createServer.ts | 7 + src/server/registerElectronTools.ts | 3 + .../applicationWorkflowCli.test.ts | 2 +- .../javascriptApplicationJsonOutput.test.ts | 2 +- .../cli/javascriptApplicationProgress.test.ts | 2 +- .../artifactComparisonSummary.test.ts | 2 +- .../artifactDirectoryIdentity.test.ts | 2 +- .../artifactInventoryEntryOrder.test.ts | 2 +- .../artifactInventoryRegularRoot.test.ts | 6 +- .../artifactInventorySnapshot.test.ts | 18 +- .../filesystem/artifactMzInventory.test.ts | 2 +- .../asarInventoryContainment.test.ts | 2 +- .../filesystem/asarInventoryFreshness.test.ts | 71 +++-- .../asarStreamingAllocation.test.ts | 2 +- ...avascriptApplicationInputSelection.test.ts | 2 +- .../javascriptApplicationWorkflows.test.ts | 2 +- .../javascriptArtifactBomRanges.test.ts | 2 +- .../javascriptArtifactContentIdentity.test.ts | 2 +- .../javascriptArtifactReconstruction.test.ts | 4 +- ...avascriptArtifactTypeScriptDialect.test.ts | 2 +- .../javascriptExportContainerCoverage.test.ts | 2 +- .../javascriptExportShapeComparison.test.ts | 2 +- .../javascriptHtmlEntrypoints.test.ts | 2 +- .../javascriptHtmlResourcePaths.test.ts | 2 +- .../javascriptIntegrityPolicy.test.ts | 4 +- .../javascriptPackageExportTargets.test.ts | 2 +- .../javascriptPackagePrecedence.test.ts | 2 +- .../javascriptRequireLiteralPaths.test.ts | 2 +- .../javascriptSourceMapComments.test.ts | 2 +- .../javascriptSourceMapLocations.test.ts | 2 +- .../filesystem/sourceToBundlePaths.test.ts | 2 +- .../filesystem/webScriptExport.test.ts | 2 +- .../boundary/filesystem/zipDetection.test.ts | 6 +- .../javascriptRuntimeUnresolvedTarget.test.ts | 2 +- .../mcp/applicationExportShapeMcp.test.ts | 2 +- .../applicationMutationPresenceMcp.test.ts | 2 +- tests/boundary/mcp/electronMcp.test.ts | 57 +++- ...riptRuntimeReconciliationAmbiguity.test.ts | 2 +- .../artifactProvider.cleanupRetry.test.ts | 227 +++++++++++++++ .../artifactProvider.extraction.test.ts | 20 ++ ...actProvider.extractionCompleteness.test.ts | 6 +- .../native/nativeDmgArtifactReader.test.ts | 8 +- .../artifactComparison.test.ts | 2 +- .../electronApplicationAnalysis.test.ts | 4 +- .../electronOptionKeys.test.ts | 2 +- .../javascriptArtifactReconstruction.test.ts | 8 +- .../javascriptHtmlReferenceResolution.test.ts | 2 +- .../javascriptRuntimeReconciliation.test.ts | 2 +- tests/fixtures/artifactInventory.ts | 108 ++++++++ tests/support/javascriptApplicationFixture.ts | 2 +- tests/support/javascriptApplicationScope.ts | 21 ++ 76 files changed, 1361 insertions(+), 374 deletions(-) create mode 100644 scripts/lib/javascript-application-analysis.mjs create mode 100644 src/artifacts/ArtifactResourceScope.test.ts create mode 100644 src/artifacts/ArtifactResourceScope.ts create mode 100644 tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts create mode 100644 tests/fixtures/artifactInventory.ts create mode 100644 tests/support/javascriptApplicationScope.ts diff --git a/scripts/lib/javascript-application-analysis.mjs b/scripts/lib/javascript-application-analysis.mjs new file mode 100644 index 0000000000..e9b4c88226 --- /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 2c46808718..cfd0c8f119 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 a0c71f3c7c..b9af11516c 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 5c2b2f0059..f4424de5b9 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 33ebd22201..444616d78a 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 ba8e168ebf..c8768b262a 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 02c26096e2..272856988d 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 9953566985..917c29dfff 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 a36c1fdab4..47143bb915 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 2028147bfc..378d733534 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 6d59578e73..531168bcc0 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 25919923c9..7b3972c363 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 151b9b7562..0a136cff46 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 74a69d1f9a..5ff96504e6 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 0a7369ea22..436ea4a703 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 db3b21daa2..acf4eeedb0 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 0000000000..9bf8fe87bb --- /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 0000000000..b7da3fd835 --- /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 03f4750700..79f384eef7 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 1cea07af8b..582c970f1f 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 10351f9d49..f44d79aeb7 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 fbb8215a16..7932c519eb 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 929760002f..2d36bcd74e 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 4d0daa8f30..e9d03196ca 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 26fad1e3e3..30796dfef2 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 0523006a6c..3b55a49272 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 2d128943cd..167d41eac7 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 a163783178..34719f173d 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 36843cfa01..af41425eda 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 a4d855592f..9c79a309bf 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 0873584a42..bbb721bf7a 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 5d54cc1543..3d1d252100 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 04cb7b7079..ee56bacf41 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 00e823ee7d..9ea26cfdae 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 337bb7c70f..a72c5de187 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 1073534f69..613b5feb82 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 5251a30e9a..42e470c54a 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 3b7cedcfac..1ad207dffa 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 0af58d0df6..f982f0ea95 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 04d5de8dfb..390ea6664c 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 8da2a46a53..17c3f00487 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 dfa6a3c20c..dabf98548f 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 2acfb89119..47e1b5ce48 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 5651d5390b..5ecbb0fdad 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 b266dde878..bef55982e5 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 bea6adbac1..3fa7163071 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 ed429c35e9..365cbcdf82 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 a251469b01..29a9847df7 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 979951b3cb..7310629fa4 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 9fd482ec27..61e120e182 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 aa8596cb6d..1c73bd8944 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 8bc2511e1b..acade00ead 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 77d383cbef..65349b8e4f 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 aaaae82f4b..aa20dd9b75 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 278324b544..69a5af6621 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 7dcd910ffe..50e39000ee 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 3a6ce47fdc..87f05c545b 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 3464afa59a..08c3182571 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 cfc47a7355..a5a91239f8 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 e336e3f749..1794513905 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 bece1f9bde..a2e8ad098c 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 293a34a99e..39bdc3ef78 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 3d709e3c70..58b12ea3e7 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 0000000000..fd8bd052e8 --- /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 9a538e2b12..6488af65fc 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 7444a220eb..d427626011 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 11a63a976f..7ec3edc604 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 a3850df20b..4633a3fb27 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 dfc74255ac..90d7e2504a 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 b05162fa95..16c1ce2cd4 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 4bbc2eb367..846eee6c3c 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 00265773bc..86faea2bfe 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 9e90a1da4d..e69e1f486e 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 0000000000..776d55fbea --- /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 c8522aeb61..45afb3a82f 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 0000000000..830f803681 --- /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), + );