import { createHash } from 'node:crypto'; import { stableCinemaJson } from './cinema-shot-compiler.js'; import type { CinemaGenerationLedgerArtifact, CinemaSha256 } from './cinema-types.js'; import type { CinemaReviewDecision, CinemaReviewLedgerArtifact, CinemaReviewValidation, CinemaReviewValidationIssue, } from './cinema-review-types.js'; function add(issues: CinemaReviewValidationIssue[], code: string, path: string, message: string): void { issues.push({ code, path, message }); } function hasText(value: string): boolean { return typeof value === 'string' && value.trim().length > 0; } export function cinemaReviewDecisionHash(decision: Omit): CinemaSha256 { return `sha256:${createHash('sha256').update(stableCinemaJson(decision)).digest('hex')}`; } export function validateCinemaReviewLedger( ledger: CinemaReviewLedgerArtifact, generationLedger?: CinemaGenerationLedgerArtifact, ): CinemaReviewValidation { const issues: CinemaReviewValidationIssue[] = []; if (ledger.schemaVersion !== 1) add(issues, 'schema-version', 'schemaVersion', 'must equal 1'); if (!hasText(ledger.projectSlug)) add(issues, 'project-slug', 'projectSlug', 'is required'); if (!hasText(ledger.generatedAt) || Number.isNaN(Date.parse(ledger.generatedAt))) add(issues, 'generated-at', 'generatedAt', 'must be an ISO timestamp'); const ids = new Set(); const attempts = new Map(generationLedger?.attempts.map((attempt) => [attempt.attemptId, attempt]) ?? []); ledger.decisions.forEach((decision, index) => { const path = `decisions[${index}]`; if (!hasText(decision.decisionId)) add(issues, 'decision-id', `${path}.decisionId`, 'is required'); else if (ids.has(decision.decisionId)) add(issues, 'duplicate-decision', `${path}.decisionId`, 'must be unique'); ids.add(decision.decisionId); if (!hasText(decision.attemptId)) add(issues, 'attempt-id', `${path}.attemptId`, 'is required'); if (!hasText(decision.shotId)) add(issues, 'shot-id', `${path}.shotId`, 'is required'); if (!['note', 'pass', 'reject', 'redesign'].includes(decision.kind)) add(issues, 'decision-kind', `${path}.kind`, 'is invalid'); if (!hasText(decision.reviewerId)) add(issues, 'reviewer-id', `${path}.reviewerId`, 'is required'); if (!hasText(decision.decidedAt) || Number.isNaN(Date.parse(decision.decidedAt))) add(issues, 'decided-at', `${path}.decidedAt`, 'must be an ISO timestamp'); if (!hasText(decision.reason)) add(issues, 'reason', `${path}.reason`, 'is required'); if (decision.timeSeconds !== null && (!Number.isFinite(decision.timeSeconds) || decision.timeSeconds < 0)) add(issues, 'time-seconds', `${path}.timeSeconds`, 'must be null or non-negative'); if (decision.kind === 'redesign' && !hasText(decision.changedVariable ?? '')) add(issues, 'changed-variable', `${path}.changedVariable`, 'redesign must name exactly one controlled variable'); if (decision.kind !== 'redesign' && decision.changedVariable !== null) add(issues, 'unexpected-changed-variable', `${path}.changedVariable`, 'is allowed only for redesign'); if (new Set(decision.evidenceIds).size !== decision.evidenceIds.length) add(issues, 'duplicate-evidence', `${path}.evidenceIds`, 'must be unique'); if (decision.kind === 'pass') { if (!decision.storyPurposeVerified) add(issues, 'story-purpose', `${path}.storyPurposeVerified`, 'pass requires story-purpose verification'); if (decision.evidenceIds.length === 0) add(issues, 'pass-evidence', `${path}.evidenceIds`, 'pass requires evidence'); } else if (decision.storyPurposeVerified) { add(issues, 'unexpected-story-purpose', `${path}.storyPurposeVerified`, 'is true only for pass'); } const { contentHash: _contentHash, ...hashInput } = decision; if (decision.contentHash !== cinemaReviewDecisionHash(hashInput)) add(issues, 'content-hash', `${path}.contentHash`, 'does not match canonical decision content'); if (generationLedger) { const attempt = attempts.get(decision.attemptId); if (!attempt) add(issues, 'unknown-attempt', `${path}.attemptId`, 'must reference an immutable generation attempt'); else if (attempt.shotId !== decision.shotId) add(issues, 'shot-mismatch', `${path}.shotId`, 'must match the referenced attempt'); } }); return { ok: issues.length === 0, issues }; }