Files
reverse-engineering-anything/src/domain/process/processComparison.ts
T
morluto 599a8ee11d fix(process): preserve cleanup ownership and normalize captures once
Retain unresolved process identity and launch evidence through retryable cleanup. Share capture path normalization and preserve failed filesystem observations. Bound child output and drain Windows pipes without one-chunk throttling.

Reviewed fixes include #1628, #2632, #2644, #2784, #2928, #2931, #2965, #2992, and #3011.

Validation: 92 focused source tests passed; four platform-specific skips. Real Windows and Hopper lifecycle coverage was not rerun.
2026-10-14 08:19:07 +08:00

532 lines
17 KiB
TypeScript

import canonicalize from "canonicalize";
import { z } from "zod";
import { processSourceTruncated } from "./processCaptureCoverage.js";
import type {
ProcessObservationLocation,
ProcessObservationSource,
} from "./processObservation.js";
import type { ProcessCapture } from "./processCaptureParsing.js";
import { comparableTerminalFrame } from "./processObservation.js";
import { AnalysisInputError } from "../analysisErrorCore.js";
import { jsonValueSchema } from "../jsonValue.js";
import type { ProcessTraceSpecification } from "./processTraceSpecification.js";
import { processTraceComparisonResultSchema } from "./processTraceEvaluation.js";
import {
dimensionsForTraceSources,
traceCoversObservedDimension,
} from "./processTraceDimensionProjection.js";
import {
compareProcessTraces,
processTraceOutcomesDiffer,
} from "./processTraceComparison.js";
const OPERATION = "compare_process_captures";
/** Comparison classification that never equates incomplete evidence. */
export const comparisonStatusSchema = z.enum([
"unchanged",
"added",
"removed",
"changed",
"truncated",
"unknown",
"contradiction",
]);
type ComparisonStatus = z.infer<typeof comparisonStatusSchema>;
/** Canonical ordered dimensions that contribute to an overall process status. */
export const PROCESS_COMPARISON_DIMENSIONS = [
"terminal",
"interaction",
"exit",
"filesystem",
"process",
] as const;
/** Derive the overall comparison status from every process dimension. */
export const deriveProcessComparisonStatus = (
dimensions: readonly ComparisonStatus[],
): ComparisonStatus => {
if (dimensions.includes("truncated")) return "truncated";
if (
dimensions.some((status) =>
["changed", "added", "removed"].includes(status),
)
)
return "changed";
return dimensions.includes("unknown") ? "unknown" : "unchanged";
};
/** Pure normalized comparison between two captures. */
export const processCaptureComparisonSchema = z
.object({
status: comparisonStatusSchema,
terminal: comparisonStatusSchema,
interaction: comparisonStatusSchema,
exit: comparisonStatusSchema,
filesystem: comparisonStatusSchema,
process: comparisonStatusSchema,
first_divergence: z.discriminatedUnion("status", [
z.object({ status: z.literal("none") }),
z.object({ status: z.literal("unknown"), reason: z.string() }),
z.object({
status: z.literal("found"),
dimension: z.enum([
"terminal",
"interaction",
"exit",
"filesystem",
"process",
]),
index: z.number().int().nonnegative(),
left_at_ms: z.number().int().nonnegative().nullable(),
right_at_ms: z.number().int().nonnegative().nullable(),
left: jsonValueSchema.nullable(),
right: jsonValueSchema.nullable(),
}),
]),
trace: processTraceComparisonResultSchema.optional(),
limitations: z.array(z.string()),
})
.superRefine((comparison, context) => {
const expected = deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => comparison[dimension]),
);
if (comparison.status !== expected)
context.addIssue({
code: "custom",
message: "Process comparison status contradicts its dimensions",
path: ["status"],
});
});
export type ProcessCaptureComparison = z.infer<
typeof processCaptureComparisonSchema
>;
const terminalObservations = (capture: ProcessCapture): readonly unknown[] =>
[
...capture.frames.map((frame) => ({
kind: "raw" as const,
...comparableTerminalFrame(frame),
})),
...capture.rendered_frames.map((frame) => ({
kind: "rendered" as const,
...frame,
})),
].sort((left, right) => {
const time = left.at_ms - right.at_ms;
if (time !== 0) return time;
if (left.kind !== right.kind) return left.kind === "raw" ? -1 : 1;
return left.sequence - right.sequence;
});
const filesystemObservations = (capture: ProcessCapture): readonly unknown[] =>
capture.filesystem_checkpoints;
const sameJsonValue = (left: unknown, right: unknown): boolean => {
const encodedLeft = canonicalize(left);
return encodedLeft !== undefined && encodedLeft === canonicalize(right);
};
/** Canonical observations for one process-comparison dimension. */
export const processDimensionObservations = (
capture: ProcessCapture,
dimension: (typeof PROCESS_COMPARISON_DIMENSIONS)[number],
): readonly unknown[] => {
switch (dimension) {
case "terminal":
return terminalObservations(capture);
case "interaction":
return capture.interaction_events;
case "exit":
return [{ ...capture.exit, settlement: capture.settlement }];
case "filesystem":
return filesystemObservations(capture);
case "process":
return capture.process_samples;
}
};
const classifyCollection = (
left: readonly unknown[],
right: readonly unknown[],
): ComparisonStatus => {
if (sameJsonValue(left, right)) return "unchanged";
if (left.length === 0) return "added";
if (right.length === 0) return "removed";
return "changed";
};
const hasUnknown = (
capture: ProcessCapture,
scope: ProcessCapture["residual_unknowns"][number]["scope"],
): boolean => capture.residual_unknowns.some((item) => item.scope === scope);
type DivergenceDimension = Exclude<
ProcessCaptureComparison["first_divergence"],
{ readonly status: "none" | "unknown" }
>["dimension"];
// Interactions record when their outcome was observed as the dispatch time;
// every other timed observation uses at_ms.
const EVENT_TIME_FIELD: Readonly<Record<DivergenceDimension, string>> = {
terminal: "at_ms",
interaction: "dispatched_at_ms",
exit: "at_ms",
filesystem: "at_ms",
process: "at_ms",
};
const eventTime = (
dimension: DivergenceDimension,
value: unknown,
): number | null => {
if (typeof value !== "object" || value === null) return null;
const time: unknown = Reflect.get(value, EVENT_TIME_FIELD[dimension]);
return typeof time === "number" && Number.isSafeInteger(time) && time >= 0
? time
: null;
};
const firstCollectionDivergence = (
dimension: DivergenceDimension,
left: readonly unknown[],
right: readonly unknown[],
): Extract<
ProcessCaptureComparison["first_divergence"],
{ readonly status: "found" }
> | null => {
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const leftValue = left[index] ?? null;
const rightValue = right[index] ?? null;
if (sameJsonValue(leftValue, rightValue)) continue;
return {
status: "found",
dimension,
index,
left_at_ms: eventTime(dimension, leftValue),
right_at_ms: eventTime(dimension, rightValue),
left: jsonValueSchema.parse(leftValue),
right: jsonValueSchema.parse(rightValue),
};
}
return null;
};
const chooseFirstDivergence = (
candidates: ReadonlyArray<Extract<
ProcessCaptureComparison["first_divergence"],
{ readonly status: "found" }
> | null>,
): ProcessCaptureComparison["first_divergence"] => {
const found = candidates.filter(
(candidate): candidate is NonNullable<typeof candidate> =>
candidate !== null,
);
return (
found.sort((left, right) => {
const leftTime = Math.min(
left.left_at_ms ?? Number.MAX_SAFE_INTEGER,
left.right_at_ms ?? Number.MAX_SAFE_INTEGER,
);
const rightTime = Math.min(
right.left_at_ms ?? Number.MAX_SAFE_INTEGER,
right.right_at_ms ?? Number.MAX_SAFE_INTEGER,
);
return leftTime - rightTime;
})[0] ?? { status: "none" }
);
};
const assertComparable = (
left: ProcessCapture,
right: ProcessCapture,
options: { readonly maxCaptureAgeMs?: number; readonly now?: () => number },
): void => {
if (
left.manifest.comparison_contract_sha256 !==
right.manifest.comparison_contract_sha256
) {
const fields = differingContractFields(
left.manifest.comparison_contract,
right.manifest.comparison_contract,
);
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["right"],
reason: "invalid_value",
message: `Process captures have incompatible comparison contracts${fields.length === 0 ? "" : `; these scenario fields differ: ${fields.join(", ")}`}. Capture both scenarios with the same values for these fields, then compare them.`,
expected: fields,
},
]);
}
if (options.maxCaptureAgeMs === undefined) return;
const maxCaptureAgeMs = options.maxCaptureAgeMs;
const now = (options.now ?? Date.now)();
const stale = (
[
["left", left],
["right", right],
] as const
).flatMap(([side, { manifest }]) => {
const age = now - Date.parse(manifest.completed_at);
return age > maxCaptureAgeMs
? [
`${side} completed at ${manifest.completed_at} (${String(age)} ms ago)`,
]
: [];
});
if (stale.length > 0)
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["max_capture_age_ms"],
reason: "out_of_range",
message: `Process capture exceeds max_capture_age_ms ${String(maxCaptureAgeMs)}: ${stale.join("; ")}. Recapture the stale scenario or raise max_capture_age_ms.`,
},
]);
};
/** Name top-level contract fields whose canonical values differ. */
const differingContractFields = (
left: Readonly<Record<string, unknown>>,
right: Readonly<Record<string, unknown>>,
): string[] =>
[...new Set([...Object.keys(left), ...Object.keys(right)])]
.filter(
(field) =>
!Object.hasOwn(left, field) ||
!Object.hasOwn(right, field) ||
canonicalize(left[field]) !== canonicalize(right[field]),
)
.sort();
const LOCATION_SOURCES = {
frames: "terminal_raw",
rendered_frames: "terminal_rendered",
interaction_events: "interaction",
lifecycle: "lifecycle",
process_samples: "process",
filesystem_checkpoints: "filesystem",
} as const satisfies Record<
ProcessObservationLocation["collection"],
ProcessObservationSource
>;
const applyTraceVerdict = (
dimensions: ComparisonDimensions,
specification: ProcessTraceSpecification,
trace: ReturnType<typeof compareProcessTraces>,
captures: readonly [left: ProcessCapture, right: ProcessCapture],
): ComparisonDimensions => {
const traceShowsDifference = processTraceOutcomesDiffer(trace);
const traceStatus =
trace.verdict === "unknown"
? "unknown"
: trace.verdict === "equivalent" || !traceShowsDifference
? "unchanged"
: "changed";
const sources = new Set(specification.events.map(({ source }) => source));
const covered = dimensionsForTraceSources(sources);
// A different verdict names the failing events and observations; attribute
// it only to their dimensions so equal observations keep their exact status.
const diagnosticSources = new Set<ProcessObservationSource>([
...specification.events
.filter(({ id }) => trace.diagnostic?.event_ids.includes(id) === true)
.map(({ source }) => source),
...(trace.diagnostic?.locations ?? []).map(
({ collection }) => LOCATION_SOURCES[collection],
),
]);
const attributed =
diagnosticSources.size > 0
? dimensionsForTraceSources(diagnosticSources)
: covered;
const statusFor = (
dimension: (typeof PROCESS_COMPARISON_DIMENSIONS)[number],
): ComparisonStatus => {
const exact = dimensions[dimension];
if (!covered.has(dimension)) return exact;
if (trace.verdict === "nonconforming") return exact;
if (trace.verdict === "different")
return traceShowsDifference &&
(exact !== "unchanged" || attributed.has(dimension))
? "changed"
: exact;
if (
trace.verdict === "unknown" &&
["changed", "added", "removed"].includes(exact)
)
return exact;
return traceCoversObservedDimension(
dimension,
sources,
captures[0],
captures[1],
)
? traceStatus
: exact;
};
return {
terminal: statusFor("terminal"),
interaction: statusFor("interaction"),
exit: statusFor("exit"),
filesystem: statusFor("filesystem"),
process: statusFor("process"),
};
};
type ComparisonDimensions = Pick<
ProcessCaptureComparison,
"terminal" | "interaction" | "exit" | "filesystem" | "process"
>;
const compareDimensions = (
left: ProcessCapture,
right: ProcessCapture,
sameNormalization: boolean,
): ComparisonDimensions => {
const classify = (
scope: ProcessCapture["residual_unknowns"][number]["scope"],
leftValues: readonly unknown[],
rightValues: readonly unknown[],
source: ProcessObservationSource,
): ComparisonStatus =>
!sameNormalization ||
hasUnknown(left, scope) ||
hasUnknown(right, scope) ||
processSourceTruncated(left, source) ||
processSourceTruncated(right, source)
? "unknown"
: classifyCollection(leftValues, rightValues);
return {
terminal: classify(
"terminal",
terminalObservations(left),
terminalObservations(right),
"terminal_rendered",
),
interaction: classify(
"interaction",
left.interaction_events,
right.interaction_events,
"interaction",
),
exit:
hasUnknown(left, "exit") || hasUnknown(right, "exit")
? "unknown"
: sameJsonValue(
{ exit: left.exit, settlement: left.settlement },
{ exit: right.exit, settlement: right.settlement },
)
? "unchanged"
: "changed",
filesystem: classify(
"filesystem",
filesystemObservations(left),
filesystemObservations(right),
"filesystem",
),
process: classify(
"process",
left.process_samples,
right.process_samples,
"process",
),
};
};
/**
* Compare two process capture observations without treating absence as proof.
*
* Observed differences outrank residual unknowns. A capture may therefore be
* definitively changed in one dimension while completeness remains unknown.
*/
export const compareProcessCaptures = (
left: ProcessCapture,
right: ProcessCapture,
options: {
readonly maxCaptureAgeMs?: number;
readonly now?: () => number;
readonly traceSpecification?: ProcessTraceSpecification;
} = {},
): ProcessCaptureComparison => {
assertComparable(left, right, options);
const sameNormalization = sameJsonValue(
left.normalization,
right.normalization,
);
const exactDimensions = compareDimensions(left, right, sameNormalization);
const trace =
options.traceSpecification === undefined
? undefined
: compareProcessTraces(left, right, options.traceSpecification);
const dimensions =
trace === undefined || options.traceSpecification === undefined
? exactDimensions
: applyTraceVerdict(exactDimensions, options.traceSpecification, trace, [
left,
right,
]);
const divergenceCandidates = PROCESS_COMPARISON_DIMENSIONS.map((dimension) =>
dimensions[dimension] === "unknown"
? null
: firstCollectionDivergence(
dimension,
processDimensionObservations(left, dimension),
processDimensionObservations(right, dimension),
),
);
const observedFirstDivergence = chooseFirstDivergence(
divergenceCandidates.map((candidate) =>
candidate !== null &&
trace?.verdict === "equivalent" &&
dimensions[candidate.dimension] === "unchanged"
? null
: candidate,
),
);
const firstDivergence =
left.truncated || right.truncated
? ({
status: "unknown",
reason:
"Incomplete observations prevent locating the first divergence; unaffected dimensions retain their comparisons.",
} as const)
: trace?.verdict === "equivalent" &&
deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map(
(dimension) => dimensions[dimension],
),
) === "unchanged"
? ({ status: "none" } as const)
: observedFirstDivergence.status === "found"
? observedFirstDivergence
: !sameNormalization ||
left.residual_unknowns.length > 0 ||
right.residual_unknowns.length > 0
? ({
status: "unknown",
reason:
"Residual unknowns prevent proving that no divergence occurred.",
} as const)
: observedFirstDivergence;
const observedStatus = deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]),
);
return {
status: observedStatus,
...dimensions,
first_divergence: firstDivergence,
...(trace === undefined ? {} : { trace }),
limitations: [
...left.limitations,
...right.limitations,
...left.residual_unknowns.map(({ reason }) => reason),
...right.residual_unknowns.map(({ reason }) => reason),
...(sameNormalization ? [] : ["Capture normalization rules differ."]),
],
};
};