@@ -0,0 +1,107 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user