import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { isAbsolute, resolve } from 'node:path'; import { z } from 'zod'; import { stableStringify } from '../../core/utils/stableStringify'; const tddEventSchema = z.object({ status: z.enum(['passed', 'failed']), timestamp: z.string().datetime({ offset: true }).optional(), run_id: z.string().min(1).optional(), test_ref: z.string().min(1).optional(), }); const tddSliceSchema = z.object({ id: z.string().min(1), scenario_ref: z.string().min(1), red: tddEventSchema, green: tddEventSchema, refactor: tddEventSchema, verify: tddEventSchema.optional(), }); const tddIntegritySchema = z.object({ algorithm: z.literal('sha256'), payload_hash: z.string().min(1), }); const tddBddEvidenceSchema = z.object({ version: z.literal('1'), generated_at: z.string().datetime({ offset: true }), slices: z.array(tddSliceSchema), metadata: z .object({ stack: z.string().min(1).optional(), source: z.string().min(1).optional(), }) .optional(), integrity: tddIntegritySchema.optional(), }); export type TddEvent = z.infer; export type TddSlice = z.infer; export type TddBddEvidenceV1 = z.infer; export type TddBddEvidenceReadResult = | { kind: 'missing'; path: string; } | { kind: 'invalid'; path: string; reason: string; version?: string; } | { kind: 'valid'; path: string; evidence: TddBddEvidenceV1; integrity: { present: boolean; valid: boolean; }; }; const DEFAULT_EVIDENCE_PATH = '.pumuki/artifacts/pumuki-evidence-v1.json'; export const resolveTddBddEvidencePath = (repoRoot: string): string => { const candidate = process.env.PUMUKI_TDD_BDD_EVIDENCE_PATH?.trim(); if (!candidate) { return resolve(repoRoot, DEFAULT_EVIDENCE_PATH); } if (isAbsolute(candidate)) { return candidate; } return resolve(repoRoot, candidate); }; const stablePayloadForIntegrity = (evidence: TddBddEvidenceV1): string => { return stableStringify({ version: evidence.version, generated_at: evidence.generated_at, slices: evidence.slices, metadata: evidence.metadata ?? null, }); }; const computeIntegrityHash = (evidence: TddBddEvidenceV1): string => { return createHash('sha256').update(stablePayloadForIntegrity(evidence), 'utf8').digest('hex'); }; const resolveIntegrityState = ( evidence: TddBddEvidenceV1 ): { present: boolean; valid: boolean; } => { if (!evidence.integrity) { return { present: false, valid: true, }; } const computed = computeIntegrityHash(evidence); return { present: true, valid: computed === evidence.integrity.payload_hash, }; }; const extractVersionCandidate = (parsed: unknown): string | undefined => { if (typeof parsed !== 'object' || parsed === null || !('version' in parsed)) { return undefined; } const candidate = (parsed as { version?: unknown }).version; return typeof candidate === 'string' ? candidate : undefined; }; export const readTddBddEvidence = (repoRoot: string): TddBddEvidenceReadResult => { const path = resolveTddBddEvidencePath(repoRoot); if (!existsSync(path)) { return { kind: 'missing', path, }; } try { const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown; const validation = tddBddEvidenceSchema.safeParse(parsed); if (!validation.success) { return { kind: 'invalid', path, reason: validation.error.issues[0]?.message ?? 'invalid_schema', version: extractVersionCandidate(parsed), }; } const integrity = resolveIntegrityState(validation.data); if (!integrity.valid) { return { kind: 'invalid', path, reason: 'integrity_hash_mismatch', version: validation.data.version, }; } return { kind: 'valid', path, evidence: validation.data, integrity, }; } catch (error) { return { kind: 'invalid', path, reason: error instanceof Error ? error.message : String(error), }; } };