Files
rea/src/domain/processComparison.ts
T

161 lines
4.6 KiB
TypeScript

import { z } from "zod";
import type { ProcessCapture } from "./processCapture.js";
/** Comparison classification that never equates incomplete evidence. */
export const comparisonStatusSchema = z.enum([
"unchanged",
"added",
"removed",
"changed",
"truncated",
"unknown",
]);
type ComparisonStatus = z.infer<typeof comparisonStatusSchema>;
/** Pure normalized comparison between two captures. */
export const processCaptureComparisonSchema = z
.object({
status: comparisonStatusSchema,
terminal: comparisonStatusSchema,
exit: comparisonStatusSchema,
filesystem: comparisonStatusSchema,
protocol: comparisonStatusSchema,
process: comparisonStatusSchema,
limitations: z.array(z.string()),
})
.superRefine((comparison, context) => {
const dimensions = [
comparison.terminal,
comparison.exit,
comparison.filesystem,
comparison.protocol,
comparison.process,
];
const expected = dimensions.includes("truncated")
? "truncated"
: dimensions.some((status) =>
["added", "removed", "changed"].includes(status),
)
? "changed"
: dimensions.includes("unknown")
? "unknown"
: "unchanged";
if (comparison.status !== expected)
context.addIssue({
code: "custom",
message: "Process comparison status contradicts its dimensions",
path: ["status"],
});
});
type ProcessCaptureComparison = z.infer<typeof processCaptureComparisonSchema>;
const stableFiles = (capture: ProcessCapture): string =>
JSON.stringify(
[...capture.files_after].sort((left, right) =>
left.path.localeCompare(right.path),
),
);
const classifyCollection = (
left: readonly unknown[],
right: readonly unknown[],
): ComparisonStatus => {
if (JSON.stringify(left) === JSON.stringify(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);
const comparisonStatus = (
dimensions: readonly ComparisonStatus[],
): ComparisonStatus => {
if (
dimensions.some((status) =>
["changed", "added", "removed"].includes(status),
)
)
return "changed";
return dimensions.includes("unknown") ? "unknown" : "unchanged";
};
/** Compare bounded captures without equating missing or incompatible evidence. */
export const compareProcessCaptures = (
left: ProcessCapture,
right: ProcessCapture,
): ProcessCaptureComparison => {
if (left.truncated || right.truncated) {
return {
status: "truncated",
terminal: "truncated",
exit: "truncated",
filesystem: "truncated",
protocol: "truncated",
process: "truncated",
limitations: ["At least one capture is truncated."],
};
}
const sameNormalization =
JSON.stringify(left.normalization) === JSON.stringify(right.normalization);
const classify = (
scope: ProcessCapture["residual_unknowns"][number]["scope"],
leftValues: readonly unknown[],
rightValues: readonly unknown[],
): ComparisonStatus =>
!sameNormalization || hasUnknown(left, scope) || hasUnknown(right, scope)
? "unknown"
: classifyCollection(leftValues, rightValues);
const terminal = classify(
"terminal",
left.frames.map(({ data }) => data),
right.frames.map(({ data }) => data),
);
const exit =
hasUnknown(left, "exit") || hasUnknown(right, "exit")
? "unknown"
: JSON.stringify(left.exit) === JSON.stringify(right.exit)
? "unchanged"
: "changed";
const filesystem =
!hasUnknown(left, "filesystem") &&
!hasUnknown(right, "filesystem") &&
sameNormalization &&
stableFiles(left) === stableFiles(right)
? "unchanged"
: classify(
"filesystem",
left.filesystem_effects,
right.filesystem_effects,
);
const protocol = classify(
"protocol",
left.protocol_events,
right.protocol_events,
);
const process = classify(
"process",
left.process_samples,
right.process_samples,
);
return {
status: comparisonStatus([terminal, exit, filesystem, protocol, process]),
terminal,
exit,
filesystem,
protocol,
process,
limitations: [
...left.limitations,
...right.limitations,
...left.residual_unknowns.map(({ reason }) => reason),
...right.residual_unknowns.map(({ reason }) => reason),
...(sameNormalization ? [] : ["Capture normalization rules differ."]),
],
};
};