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