export function validateProductBacklog(backlog, plan) { const errors = []; if (backlog?.schemaVersion !== 2) errors.push("Product backlog schemaVersion must be 2."); if (!Array.isArray(backlog?.statusValues)) errors.push("Product backlog statusValues must be an array."); if (!Array.isArray(backlog?.items)) { errors.push("Product backlog items must be an array."); return errors; } const milestoneById = new Map((plan?.milestones ?? []).map((milestone) => [milestone.id, milestone])); const itemIds = new Set(); const statuses = new Set(backlog.statusValues ?? []); for (const item of backlog.items) { if (!item || typeof item !== "object") { errors.push("Product backlog contains an invalid item."); continue; } if (typeof item.id !== "string" || !item.id || itemIds.has(item.id)) { errors.push(`Product backlog item has a missing or duplicate ID: ${item.id ?? "(missing)"}.`); } itemIds.add(item.id); if (!statuses.has(item.status)) errors.push(`${item.id} has an invalid status: ${item.status}.`); const milestone = milestoneById.get(item.milestone); if (!milestone) { errors.push(`${item.id} references an unknown milestone: ${item.milestone}.`); continue; } if (item.status === "complete" && (!Array.isArray(item.evidence) || item.evidence.length === 0)) { errors.push(`${item.id} is complete without evidence references.`); } if (item.status === "todo" && milestone.status === "complete" && item.postMilestone !== true) { errors.push(`${item.id} is todo in a completed milestone without an explicit postMilestone marker.`); } if (item.status === "partial" && (!item.notes || !Array.isArray(item.evidence) || item.evidence.length === 0)) { errors.push(`${item.id} is partial without notes and evidence references.`); } if (item.status === "blocked_external") { const acceptance = milestone.acceptance?.find((criterion) => criterion.id === item.acceptance); if (!item.externalOwner || !acceptance?.external || acceptance.met !== false) { errors.push(`${item.id} must reference an unmet external acceptance criterion and owner.`); } } if (item.status === "complete" && milestone.status !== "complete") { const acceptance = milestone.acceptance?.find((criterion) => criterion.id === item.acceptance); if (!acceptance || acceptance.met !== true) { errors.push(`${item.id} completes an unfinished milestone without a met acceptance criterion.`); } } } return errors; }