Files
rea/tests/composition/directAnalysisCleanup.test.ts
morluto 835904e4ee refactor(evidence): carry producer-owned source records
Carry the final artifact inspection's exact nested source Evidence through
AnalysisExecution. Derive links in the shared Evidence builder instead of
reparsing the result in CLI and MCP adapters. Retain validation for unknown
and historical Evidence inputs.

Move the shared source recorder into application ownership and migrate its
consumers. Record sources before parents; keep source recording failures
from publishing summaries. Decline snapshot entries whose source closure the
current entry format cannot represent, while retaining investigation Evidence.

Validation: actual ASAR regression failed before the change with missing
links and passes afterward; exact nested identity, source recorder refusal,
final-close cancellation and cleanup failure, historical authentication,
snapshot controls and public CLI/MCP workflows. Fresh runtime/catalog build,
full static/docs, discovery and duplication checks pass. Installed native
providers and platform-specific skipped cases remain unverified.
2026-10-25 04:37:11 +08:00

730 lines
25 KiB
TypeScript

import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createPackageWithOptions } from "@electron/asar";
import { createArtifactBinarySession } from "../../src/composition/binary.js";
import {
runDirectAnalysis,
runManagedProviderExecution,
runProviderAnalysis,
} from "../../src/application/DirectAnalysis.js";
import type { DirectAnalysisDependencies } from "../../src/application/DirectAnalysisDependencies.js";
import { parseConfig } from "../../src/config/parseConfig.js";
import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js";
import type { AnalysisError } from "../../src/domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisTimeoutError,
} from "../../src/domain/analysisErrorCore.js";
import {
projectAnalysisError,
projectAnalysisErrorWithoutCapturedOutput,
} from "../../src/domain/analysisErrorProjection.js";
import { parseEvidence, type Evidence } from "../../src/domain/evidence.js";
import { jsonObjectSchema } from "../../src/domain/jsonValue.js";
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
import { ghidraFunctionDossier } from "../../src/domain/ghidraValues.fixture.js";
import {
createAnalysisProfile,
type AnalysisProfileCommitment,
} from "../../src/domain/analysisProfile.js";
import { OwnedStagedOutput } from "../../src/filesystem/OwnedStagedOutput.js";
import { readAnalysisSnapshot } from "../../src/application/binary/AnalysisSnapshotFiles.js";
import { EvidenceFileError } from "../../src/domain/evidenceErrors.js";
import { err, ok } from "../../src/domain/result.js";
import { isCliOperationFailure } from "../../src/cliLogging.js";
import { observed } from "../fixtures/analysisExecution.js";
import {
createBinarySessionTargets,
createTestBinarySession,
} from "../fixtures/binarySession.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const cleanupError = new ProviderCleanupError(
"fixture",
["fixture-provider-process"],
{ reason: "process exit could not be confirmed" },
{ remediationAction: "Close the retained fixture process before retrying." },
);
const primaryError = new AnalysisTimeoutError("read_bytes", 100, {
capturedOutput: {
stdout: "retained observation",
stderr: "",
truncated: false,
},
});
const factories = (
options: {
readonly profile?: AnalysisProfileCommitment;
readonly executionError?: AnalysisError;
readonly thrownExecution?: Error;
readonly cleanupError?: AnalysisError;
readonly thrownCleanup?: Error;
readonly onClose?: () => void;
} = {},
) => {
const profile = options.profile;
const session = createTestBinarySession(
() => ({
execute: async (operation) => {
if (operation === "health") return observed(null);
if (options.thrownExecution !== undefined)
throw options.thrownExecution;
return options.executionError === undefined
? observed(
operation === "analyze_function"
? ghidraFunctionDossier()
: { operation, observation: "retained result" },
)
: err(options.executionError);
},
close: async () => {
if (options.thrownCleanup !== undefined) throw options.thrownCleanup;
options.onClose?.();
return options.cleanupError === undefined
? ok(null)
: err(options.cleanupError);
},
}),
profile === undefined
? {}
: { resolveAnalysisProfile: async () => ok({ profile }) },
);
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
createArtifactBinarySession: () => session,
createNativeDispatchBinarySession: () => session,
createNativeBinarySession: () => session,
createManagedBinarySession: () => session,
};
return { dependencies, session };
};
it.each([false, true])(
"retains actual artifact sources and caller path after final-close cancellation (cleanup fails: %s)",
async (closeFails) => {
const root = await createTestTempDirectory("rea-artifact-final-close-");
const source = join(root, "source");
const archive = join(root, "fixture.asar");
await mkdir(source);
await writeFile(join(source, "main.js"), "console.log('retained');\n");
await createPackageWithOptions(source, archive, {});
const session = await createArtifactBinarySession({});
const nativeClose = session.close.bind(session);
const controller = new AbortController();
const reason = {
request: "cancel during actual artifact session final close",
};
let completed: Evidence | undefined;
const close = vi
.spyOn(session, "close")
.mockImplementationOnce(async () => {
completed = session
.exportEvidenceBundle()
.records.find(
({ operation, caller_input_path }) =>
operation === "inspect_artifact" && caller_input_path === archive,
);
controller.abort(reason);
// A controlled refusal retains the actual static session for finally
// cleanup; successful cleanup runs its native owner here.
return closeFails ? err(cleanupError) : nativeClose();
});
const unexpectedSession = () => {
throw new Error("Artifact one-shot must select its static session");
};
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createArtifactBinarySession: () => session,
createBinarySession: unexpectedSession,
createNativeDispatchBinarySession: unexpectedSession,
createNativeBinarySession: unexpectedSession,
createManagedBinarySession: unexpectedSession,
};
try {
const result = analysisCliErrorEnvelopeSchema.parse(
await runProviderAnalysis(
dependencies,
archive,
"inspect_artifact",
{},
undefined,
controller.signal,
),
);
expect(result.code).toBe(closeFails ? "cleanup_incomplete" : "cancelled");
expect(result.category).toBe("cancelled");
expect(completed).toBeDefined();
const details = jsonObjectSchema.parse(result.details);
const partial = parseEvidence(details.partial_observation);
expect(partial).toEqual(completed);
expect(partial.operation).toBe("inspect_artifact");
expect(partial.caller_input_path).toBe(archive);
expect(partial.subject?.local_path).toBe(archive);
const inspection = artifactInspectionResultSchema.parse(
partial.normalized_result,
);
expect(inspection.subject.root_format).toBe("asar");
expect(partial.evidence_links).toEqual(inspection.evidence_links);
expect(partial.evidence_links).toEqual(
inspection.substeps.map(({ evidence }) => evidence.evidence_id),
);
expect(inspection.substeps[0]?.evidence.normalized_result).toMatchObject({
occurrences: expect.arrayContaining([
expect.objectContaining({ logical_path: "main.js" }),
]),
});
if (closeFails)
expect(details.cleanup_error).toEqual(
projectAnalysisError(cleanupError),
);
else expect(result.details).not.toHaveProperty("cleanup_error");
expect((await readdir(root)).sort()).toEqual(["fixture.asar", "source"]);
} finally {
close.mockRestore();
const closed = await nativeClose();
if (!closed.ok) throw closed.error;
}
},
);
describe("one-shot finalization cancellation", () => {
it.each(["read_bytes", "analyze_function"] as const)(
"retains completed %s Evidence when cancellation arrives during final close",
async (tool) => {
const [path] = await createBinarySessionTargets();
const parameters =
tool === "analyze_function" ? { procedure: "fixture" } : {};
const original = parseEvidence(
await runDirectAnalysis(
factories().dependencies,
path,
tool,
parameters,
),
);
const controller = new AbortController();
const reason = { request: "cancel during native close" };
const { dependencies, session } = factories({
onClose: () => controller.abort(reason),
});
const result = await runDirectAnalysis(
dependencies,
path,
tool,
parameters,
{ signal: controller.signal },
);
expect(result).toEqual({
error: "Analysis failed",
...projectAnalysisError(
new AnalysisCancelledError(tool, {
cause: reason,
partialObservation: original,
}),
),
});
expect(session.activeTarget()).toBeUndefined();
},
);
it.each([false, true])(
"preserves the execution failure during close cancellation (cleanup fails: %s)",
async (closeFails) => {
const [path] = await createBinarySessionTargets();
const controller = new AbortController();
const { dependencies } = factories({
executionError: primaryError,
onClose: () => controller.abort({ later: true }),
...(closeFails ? { cleanupError } : {}),
});
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ signal: controller.signal },
);
expect(result).toMatchObject({
code: closeFails ? "cleanup_incomplete" : "provider_timeout",
message: projectAnalysisError(primaryError).message,
details: {
captured_output: primaryError.capturedOutput,
...(closeFails
? { cleanup_error: projectAnalysisError(cleanupError) }
: {}),
},
});
},
);
it("retains completed Evidence and cancellation alongside unsuccessful cleanup", async () => {
const [path] = await createBinarySessionTargets();
const controller = new AbortController();
const { dependencies } = factories({
cleanupError,
onClose: () => controller.abort({ during: "close" }),
});
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ signal: controller.signal },
);
expect(result).toMatchObject({
code: "cleanup_incomplete",
category: "cancelled",
details: {
execution_failure: "cancelled",
resources: ["fixture-provider-process"],
partial_observation: {
operation: "read_bytes",
normalized_result: { observation: "retained result" },
},
cleanup_error: projectAnalysisError(cleanupError),
},
});
});
});
describe("snapshot publication finalization", () => {
it.each([
"success",
"cancel",
"recovered-write-cancel",
"close-failure",
"cancel-close-failure",
] as const)(
"retains the actual snapshot publication through finalization: %s",
async (mode) => {
const [path] = await createBinarySessionTargets();
const snapshotPath = join(dirname(path), "snapshot.json");
const controller = new AbortController();
const profile = createAnalysisProfile(
{ id: "fixture", name: "Fixture analysis provider", version: "1" },
{ fixture: true },
);
const cancelled = mode.includes("cancel");
const { dependencies, session } = factories({
profile,
onClose: () => {
if (cancelled) controller.abort({ during: "snapshot finalization" });
},
...(mode.includes("close-failure") ? { cleanupError } : {}),
});
const stagingCleanup =
mode === "recovered-write-cancel"
? vi
.spyOn(OwnedStagedOutput.prototype, "close")
.mockRejectedValueOnce(
new Error("transient staging cleanup failure"),
)
: undefined;
try {
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ signal: controller.signal, snapshotPath },
);
const loaded = await readAnalysisSnapshot(snapshotPath);
if (!loaded.ok) throw loaded.error;
const evidence = loaded.value.evidence_bundle.records.find(
(record) => record.operation === "read_bytes",
);
expect(evidence).toBeDefined();
const receipt = {
path: snapshotPath,
bytes: (await stat(snapshotPath)).size,
};
expect((await readFile(snapshotPath)).byteLength).toBe(receipt.bytes);
if (mode === "success") expect(result).toEqual(evidence);
else {
const failure = analysisCliErrorEnvelopeSchema.parse(result);
expect(failure.code).toBe(
mode.includes("close-failure") ? "cleanup_incomplete" : "cancelled",
);
expect(failure.details).toMatchObject({
partial_observation: evidence,
diagnostics: { analysis_snapshot: receipt },
});
}
expect(session.activeTarget()).toBeUndefined();
expect((await readdir(dirname(path))).toSorted()).toEqual([
"first.hop",
"second.hop",
"snapshot.json",
]);
if (stagingCleanup !== undefined)
expect(stagingCleanup).toHaveBeenCalledTimes(2);
} finally {
stagingCleanup?.mockRestore();
}
},
);
it("preserves a snapshot write failure over later cancellation without a publication receipt", async () => {
const [path] = await createBinarySessionTargets();
const controller = new AbortController();
const profile = createAnalysisProfile(
{ id: "fixture", name: "Fixture analysis provider", version: "1" },
{ fixture: true },
);
const { dependencies, session } = factories({
profile,
onClose: () => controller.abort({ later: true }),
});
const failure = new EvidenceFileError("write", "io", {
path: join(dirname(path), "failed.json"),
});
const write = vi
.spyOn(session, "writeAnalysisSnapshot")
.mockResolvedValue(err(failure));
try {
const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
{ signal: controller.signal, snapshotPath: failure.path },
),
);
expect(result.code).toBe(projectAnalysisError(failure).code);
expect(result.details).toMatchObject({
partial_observation: { operation: "read_bytes" },
});
expect(result.details).not.toHaveProperty(
"diagnostics.analysis_snapshot",
);
expect(await readdir(dirname(path))).not.toContain("failed.json");
} finally {
write.mockRestore();
}
});
});
describe("one-shot analysis cleanup outcomes", () => {
it("retains successful evidence and reports CLI failure when close fails", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories({ cleanupError });
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
);
expect(result).toMatchObject({
error: "Analysis failed",
code: "cleanup_incomplete",
details: {
resources: ["fixture-provider-process"],
partial_observation: {
operation: "read_bytes",
subject: { local_path: path },
normalized_result: { observation: "retained result" },
raw_result: null,
},
},
});
expect(isCliOperationFailure(result)).toBe(true);
expect(session.activeTarget()).toBeUndefined();
});
it("preserves primary failure diagnostics alongside an unsuccessful close", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({
executionError: primaryError,
cleanupError,
});
const primary = projectAnalysisError(primaryError);
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
);
const callerError = analysisCliErrorEnvelopeSchema.parse(result);
expect(callerError).toMatchObject({
code: "cleanup_incomplete",
category: primary.category,
message: primary.message,
remediation: {
action: "Close the retained fixture process before retrying.",
},
details: {
execution_failure: primary.code,
captured_output: primary.details?.captured_output,
cleanup_error: projectAnalysisError(cleanupError),
},
});
expect(callerError.details).not.toHaveProperty("primary_error");
expect(
JSON.stringify(callerError).match(/retained observation/g),
).toHaveLength(1);
expect(isCliOperationFailure(result)).toBe(true);
});
it("reports provider close rejection with the successful observation", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({
thrownCleanup: new Error("unexpected close rejection"),
});
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
);
expect(result).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: ["provider-client"],
diagnostics: { reason: "provider client close rejected unexpectedly" },
partial_observation: {
normalized_result: { observation: "retained result" },
},
},
});
});
it.each([false, true])(
"retains original cleanup diagnostics when final close fails: %s",
async (closeFails) => {
const [path] = await createBinarySessionTargets();
const executionError = new AnalysisTimeoutError("read_bytes", 100, {
cleanup: {
reason: "snapshot removal failed during execution",
resources: ["primary-snapshot"],
},
capturedOutput: primaryError.capturedOutput,
});
const primary = projectAnalysisError(executionError);
const { dependencies } = factories({
executionError,
...(closeFails ? { cleanupError } : {}),
});
const result = analysisCliErrorEnvelopeSchema.parse(
await runDirectAnalysis(dependencies, path, "read_bytes", {}),
);
expect(result).toMatchObject({
code: "cleanup_incomplete",
details: {
...primary.details,
...(closeFails
? { cleanup_error: projectAnalysisError(cleanupError) }
: {}),
},
});
expect(
JSON.stringify(result).match(/retained observation/g),
).toHaveLength(1);
if (!closeFails)
expect(result.details).not.toHaveProperty("cleanup_error");
},
);
it("retains both unexpected execution failure and cleanup failure", async () => {
const [path] = await createBinarySessionTargets();
const thrownExecution = new Error("unexpected execution rejection");
const { dependencies } = factories({ thrownExecution, cleanupError });
await expect(
runDirectAnalysis(dependencies, path, "read_bytes", {}),
).rejects.toMatchObject({
cause: thrownExecution,
errors: [thrownExecution, cleanupError],
});
});
it("keeps successful evidence unchanged after confirmed cleanup", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies, session } = factories();
const result = await runDirectAnalysis(
dependencies,
path,
"read_bytes",
{},
);
expect(parseEvidence(result)).toMatchObject({
operation: "read_bytes",
normalized_result: { observation: "retained result" },
});
expect(isCliOperationFailure(result)).toBe(false);
expect(session.activeTarget()).toBeUndefined();
});
});
it.each(["complete", "summary"] as const)(
"retains completed Evidence when cancellation arrives during managed close before %s delivery",
async (detail) => {
const [path] = await createBinarySessionTargets();
const files = await readdir(dirname(path));
const original = parseEvidence(
await runProviderAnalysis(
factories().dependencies,
path,
"inspect_managed_members",
{},
),
);
const controller = new AbortController();
const reason = new Error("cancel during managed close");
const { dependencies, session } = factories({
onClose: () => controller.abort(reason),
});
const result =
detail === "summary"
? await runProviderAnalysis(
dependencies,
path,
"inspect_managed_members",
{},
undefined,
controller.signal,
{
detail,
evidenceBundlePath: join(dirname(path), "bundle.json"),
},
)
: await runProviderAnalysis(
dependencies,
path,
"inspect_managed_members",
{},
undefined,
controller.signal,
);
expect(result).toEqual({
error: "Analysis failed",
...projectAnalysisError(
new AnalysisCancelledError("inspect_managed_members", {
cause: reason,
partialObservation: original,
}),
),
});
expect(session.activeTarget()).toBeUndefined();
expect(await readdir(dirname(path))).toEqual(files);
},
);
it("preserves a managed provider failure when cancellation arrives during close", async () => {
const [path] = await createBinarySessionTargets();
const controller = new AbortController();
const failure = new AnalysisInputError("inspect_managed_members", undefined, [
{ path: ["path"], reason: "invalid_format" },
]);
const { dependencies, session } = factories({
executionError: failure,
onClose: () => controller.abort(new Error("later cancellation")),
});
const result = await runProviderAnalysis(
dependencies,
path,
"inspect_managed_members",
{},
undefined,
controller.signal,
);
expect(result).toEqual({
error: "Analysis failed",
...projectAnalysisError(failure),
});
expect(session.activeTarget()).toBeUndefined();
});
describe("managed one-shot cleanup outcomes", () => {
it("returns a managed cleanup error retaining the complete successful execution", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({ cleanupError });
const result = await runManagedProviderExecution(
dependencies,
path,
"inspect_managed_artifact",
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cleanup_incomplete",
remediation: {
action: "Close the retained fixture process before retrying.",
},
details: {
resources: ["fixture-provider-process"],
diagnostics: {
cleanup_error: projectAnalysisError(cleanupError),
partial_observation: {
result: {
operation: "inspect_managed_artifact",
observation: "retained result",
},
rawResult: null,
provider: { id: "fixture" },
},
},
},
});
});
it("retains the typed managed primary error when close also fails", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({
executionError: primaryError,
cleanupError,
});
const result = await runManagedProviderExecution(
dependencies,
path,
"inspect_managed_artifact",
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.cause).toBeInstanceOf(AggregateError);
expect(result.error.cause).toMatchObject({
cause: primaryError,
errors: [primaryError, cleanupError],
});
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "cleanup_incomplete",
details: {
captured_output: primaryError.capturedOutput,
diagnostics: {
primary_error:
projectAnalysisErrorWithoutCapturedOutput(primaryError),
cleanup_error: projectAnalysisError(cleanupError),
},
},
});
expect(projected).not.toHaveProperty(
"details.diagnostics.primary_error.details.captured_output",
);
expect(
JSON.stringify(projected).split("retained observation"),
).toHaveLength(2);
});
it("exposes managed cleanup failure to the CLI error classifier", async () => {
const [path] = await createBinarySessionTargets();
const { dependencies } = factories({ cleanupError });
const result = await runProviderAnalysis(
dependencies,
path,
"inspect_managed_artifact",
{},
);
expect(isCliOperationFailure(result)).toBe(true);
expect(result).toMatchObject({
error: "Analysis failed",
code: "cleanup_incomplete",
});
});
});