108 lines
3.5 KiB
TypeScript
108 lines
3.5 KiB
TypeScript
import { safeJsonParse, stableStringify } from "@add-ideas/toolbox-helpers";
|
||
|
||
export const EVIDENCE_SCHEMA =
|
||
"https://git.add-ideas.de/lotobo/format-lab/schema/evidence-v1" as const;
|
||
|
||
export type EvidenceStatus = "pass" | "change" | "not-tested" | "informational";
|
||
|
||
export interface EvidenceObservation {
|
||
id: string;
|
||
status: EvidenceStatus;
|
||
expectation?: string;
|
||
detail: string;
|
||
}
|
||
|
||
export interface EvidenceReport {
|
||
schema: typeof EVIDENCE_SCHEMA;
|
||
contractVersion: 1;
|
||
generator: { id: string; version: string };
|
||
subject: { kind: string; name: string; mediaType?: string };
|
||
operation: { id: string; parameters: Record<string, unknown> };
|
||
limits: Record<string, number | string | boolean>;
|
||
observations: EvidenceObservation[];
|
||
provenance: {
|
||
execution: "local-browser";
|
||
networkRequired: false;
|
||
recordedAt: string | null;
|
||
};
|
||
}
|
||
|
||
export function createEvidenceReport(
|
||
input: Omit<EvidenceReport, "schema" | "contractVersion" | "provenance"> & {
|
||
recordedAt?: string | null;
|
||
},
|
||
): EvidenceReport {
|
||
if (!input.generator.id || !input.generator.version)
|
||
throw new TypeError("Evidence generator identity is required.");
|
||
if (!input.observations.length || input.observations.length > 1_000)
|
||
throw new RangeError("Evidence reports need 1–1,000 observations.");
|
||
const ids = new Set<string>();
|
||
for (const observation of input.observations) {
|
||
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(observation.id))
|
||
throw new TypeError(`Invalid evidence observation id ${observation.id}.`);
|
||
if (ids.has(observation.id))
|
||
throw new TypeError(
|
||
`Duplicate evidence observation id ${observation.id}.`,
|
||
);
|
||
ids.add(observation.id);
|
||
}
|
||
return {
|
||
schema: EVIDENCE_SCHEMA,
|
||
contractVersion: 1,
|
||
generator: { ...input.generator },
|
||
subject: { ...input.subject },
|
||
operation: {
|
||
id: input.operation.id,
|
||
parameters: structuredClone(input.operation.parameters),
|
||
},
|
||
limits: { ...input.limits },
|
||
observations: input.observations.map((item) => ({ ...item })),
|
||
provenance: {
|
||
execution: "local-browser",
|
||
networkRequired: false,
|
||
recordedAt: input.recordedAt ?? null,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function serializeEvidence(report: EvidenceReport): string {
|
||
return `${stableStringify(report, 2, {
|
||
maxTextChars: 2 * 1024 * 1024,
|
||
maxDepth: 24,
|
||
maxNodes: 20_000,
|
||
})}\n`;
|
||
}
|
||
|
||
export function parseEvidence(source: string): EvidenceReport {
|
||
const value = safeJsonParse(source, {
|
||
maxTextChars: 2 * 1024 * 1024,
|
||
maxDepth: 24,
|
||
maxNodes: 20_000,
|
||
rejectDangerousKeys: true,
|
||
}) as unknown;
|
||
if (!value || Array.isArray(value) || typeof value !== "object")
|
||
throw new TypeError("Evidence must be a JSON object.");
|
||
const candidate = value as Partial<EvidenceReport>;
|
||
if (
|
||
candidate.schema !== EVIDENCE_SCHEMA ||
|
||
candidate.contractVersion !== 1 ||
|
||
!candidate.generator ||
|
||
!candidate.subject ||
|
||
!candidate.operation ||
|
||
!candidate.limits ||
|
||
!Array.isArray(candidate.observations) ||
|
||
!candidate.provenance ||
|
||
candidate.provenance.execution !== "local-browser" ||
|
||
candidate.provenance.networkRequired !== false
|
||
)
|
||
throw new TypeError("Unsupported or incomplete format evidence contract.");
|
||
return createEvidenceReport({
|
||
generator: candidate.generator,
|
||
subject: candidate.subject,
|
||
operation: candidate.operation,
|
||
limits: candidate.limits,
|
||
observations: candidate.observations,
|
||
recordedAt: candidate.provenance.recordedAt,
|
||
});
|
||
}
|