import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const expectedCounts = { acceptanceCriteria: 52, errorCategories: 9, featureModules: 13, parentFamilies: 89, productContracts: 19, requirements: 109, tasks: 52, testCases: 117, uiPages: 22, }; function readText(root, path) { return readFileSync(resolve(root, path), "utf8"); } function sha256(root, path) { return createHash("sha256").update(readFileSync(resolve(root, path))).digest("hex").toUpperCase(); } function duplicates(values) { const counts = new Map(); for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); return [...counts.entries()].filter(([, count]) => count > 1).map(([value]) => value); } function markdownTable(markdown, headingPattern) { const lines = markdown.split(/\r?\n/); const headingIndex = lines.findIndex((line) => headingPattern.test(line)); if (headingIndex < 0) return []; const tableIndex = lines.findIndex((line, index) => index > headingIndex && line.trim().startsWith("|")); if (tableIndex < 0) return []; const rows = []; for (let index = tableIndex + 2; index < lines.length; index += 1) { const line = lines[index].trim(); if (!line.startsWith("|")) break; rows.push(line.split("|").slice(1, -1).map((cell) => cell.trim())); } return rows; } function collectPenIds(node, ids, duplicateIds) { if (node.id) { if (ids.has(node.id)) duplicateIds.push(node.id); ids.add(node.id); } for (const child of node.children ?? []) collectPenIds(child, ids, duplicateIds); } function uiFrameIds(scope) { if (!scope.frames) return []; const frames = Array.isArray(scope.frames) ? scope.frames : [scope.frames]; return frames.map((frame) => frame.frame_id).filter((id) => id && id !== "UIDesign-only"); } export function validateTddTrace({ root = process.cwd(), manifestOverride } = {}) { const errors = []; const manifest = manifestOverride ?? JSON.parse(readText(root, "tasks.manifest.json")); const prd = readText(root, "PRD.md"); const featureSummary = readText(root, "FeatureSummary.md"); const tdd = readText(root, "tdd.md"); const tasksMarkdown = readText(root, "tasks.md"); const pen = JSON.parse(readText(root, "Dada-P0A-LowFi.pen")); for (const [path, expectedHash] of Object.entries(manifest.source_hashes ?? {})) { const actualHash = sha256(root, path); if (actualHash !== expectedHash.toUpperCase()) { errors.push(`${path} SHA-256 mismatch: ${actualHash}`); } } if (manifest.tasks.length !== expectedCounts.tasks || manifest.task_count !== expectedCounts.tasks) { errors.push(`task count must be ${expectedCounts.tasks}`); } if ( manifest.case_catalog.length !== expectedCounts.testCases || manifest.normative_case_count !== expectedCounts.testCases ) { errors.push(`normative case count must be ${expectedCounts.testCases}`); } const taskIds = manifest.tasks.map((task) => task.task_id); const familyAssignments = manifest.tasks.flatMap((task) => task.parent_families); const caseAssignments = manifest.tasks.flatMap((task) => task.case_ids); const catalogCaseIds = manifest.case_catalog.map((testCase) => testCase.case_id); const catalogFamilies = [...new Set(manifest.case_catalog.map((testCase) => testCase.parent_family))]; for (const [label, values] of [ ["task", taskIds], ["family assignment", familyAssignments], ["case assignment", caseAssignments], ["case catalog", catalogCaseIds], ]) { const repeated = duplicates(values); if (repeated.length > 0) errors.push(`duplicate ${label} IDs: ${repeated.join(", ")}`); } if (familyAssignments.length !== expectedCounts.parentFamilies || new Set(familyAssignments).size !== expectedCounts.parentFamilies) { errors.push(`parent family assignments must be unique and total ${expectedCounts.parentFamilies}`); } if (manifest.parent_family_count !== expectedCounts.parentFamilies || catalogFamilies.length !== expectedCounts.parentFamilies) { errors.push(`parent family catalog count must be ${expectedCounts.parentFamilies}`); } if (caseAssignments.length !== expectedCounts.testCases || new Set(caseAssignments).size !== expectedCounts.testCases) { errors.push(`case assignments must be unique and total ${expectedCounts.testCases}`); } const assignedFamilySet = new Set(familyAssignments); const assignedCaseSet = new Set(caseAssignments); for (const family of catalogFamilies) { if (!assignedFamilySet.has(family)) errors.push(`unassigned parent family: ${family}`); if (!tdd.includes(family)) errors.push(`parent family missing from tdd.md: ${family}`); } for (const caseId of catalogCaseIds) { if (!assignedCaseSet.has(caseId)) errors.push(`unassigned normative case: ${caseId}`); if (!tdd.includes(caseId)) errors.push(`normative case missing from tdd.md: ${caseId}`); } for (const assignedCase of assignedCaseSet) { if (!catalogCaseIds.includes(assignedCase)) errors.push(`assigned case missing from catalog: ${assignedCase}`); } const taskIndex = new Map(taskIds.map((id, index) => [id, index])); const knownWorkPackages = new Set(manifest.tasks.map((task) => task.work_package)); manifest.tasks.forEach((task, index) => { if (!/^TASK-(?:WP\d+|FINAL)-\d{2}$/.test(task.task_id)) { errors.push(`invalid task ID: ${task.task_id}`); } if (!tasksMarkdown.includes(task.task_id)) errors.push(`task missing from tasks.md: ${task.task_id}`); for (const dependency of task.dependencies.task_ids) { const dependencyIndex = taskIndex.get(dependency); if (dependencyIndex === undefined) errors.push(`${task.task_id} has unknown dependency ${dependency}`); else if (dependencyIndex >= index) errors.push(`${task.task_id} dependency is not earlier: ${dependency}`); } for (const dependency of task.dependencies.work_packages) { if (!knownWorkPackages.has(dependency)) errors.push(`${task.task_id} has unknown work-package dependency ${dependency}`); } if (task.validation_commands.length === 0) errors.push(`${task.task_id} has no validation command`); if (task.evidence_directories.length === 0) errors.push(`${task.task_id} has no evidence directory`); if (task.automation.length === 0) errors.push(`${task.task_id} has no automation classification`); if (task.release_gate.length === 0) errors.push(`${task.task_id} has no release gate`); }); for (const testCase of manifest.case_catalog) { if (!/^TDD-[A-Z0-9-]+-[a-z0-9-]+$/.test(testCase.case_id)) { errors.push(`invalid case ID: ${testCase.case_id}`); } if (!testCase.case_id.startsWith(`${testCase.parent_family}-`)) { errors.push(`${testCase.case_id} does not belong to ${testCase.parent_family}`); } for (const field of [ "requirement_ac_source", "fixture_and_preconditions", "numbered_steps", "expected_response_ui", "expected_db_files", "forbidden_side_effects", "evidence_files", ]) { if (!testCase[field]?.trim()) errors.push(`${testCase.case_id} has an empty ${field}`); } } const requirementIds = [...new Set(manifest.tasks.flatMap((task) => task.requirements))]; if (requirementIds.length !== expectedCounts.requirements) { errors.push(`requirement coverage must total ${expectedCounts.requirements}`); } for (const id of requirementIds) { if (!prd.includes(id)) errors.push(`requirement missing from PRD.md: ${id}`); } const acceptanceCriteria = [...new Set(manifest.tasks.flatMap((task) => task.acceptance_criteria))]; const expectedAcceptanceCriteria = Array.from({ length: 56 }, (_, index) => index + 1) .filter((number) => ![8, 26, 37, 54].includes(number)) .map((number) => `AC-${String(number).padStart(2, "0")}`); if ( acceptanceCriteria.length !== expectedCounts.acceptanceCriteria || expectedAcceptanceCriteria.some((id) => !acceptanceCriteria.includes(id)) ) { errors.push(`current P0-A AC coverage must be the frozen ${expectedCounts.acceptanceCriteria}-item set`); } const featureModules = markdownTable(featureSummary, /^## 5\./); const errorCategories = markdownTable(featureSummary, /^### 6\.1\b/); const productContracts = markdownTable(featureSummary, /^## 7\./); for (const [label, rows, count] of [ ["feature modules", featureModules, expectedCounts.featureModules], ["error categories", errorCategories, expectedCounts.errorCategories], ["product contracts", productContracts, expectedCounts.productContracts], ]) { if (rows.length !== count) errors.push(`${label} table must contain ${count} rows`); if (duplicates(rows.map((row) => row[0])).length > 0) errors.push(`${label} table has duplicate keys`); } const uiScopes = manifest.tasks.flatMap((task) => task.ui_scope); const pageIds = [...new Set(uiScopes.map((scope) => scope.page_id))]; if (pageIds.length !== expectedCounts.uiPages) errors.push(`UI page coverage must total ${expectedCounts.uiPages}`); const uiRoleKeys = uiScopes.map((scope) => `${scope.page_id}:${scope.role}`); for (const pageId of pageIds) { for (const role of ["implementation", "final_evidence"]) { if (!uiRoleKeys.includes(`${pageId}:${role}`)) errors.push(`${pageId} is missing UI role ${role}`); } } const penIds = new Set(); const duplicatePenIds = []; collectPenIds(pen, penIds, duplicatePenIds); if (duplicatePenIds.length > 0) errors.push(`duplicate .pen IDs: ${duplicatePenIds.join(", ")}`); if (pen.children.length !== 19 || pen.children.filter((frame) => frame.id !== "Z8p3I").length !== 18) { errors.push("Dada-P0A-LowFi.pen must contain one index and 18 product frames"); } for (const frameId of new Set(uiScopes.flatMap(uiFrameIds))) { if (!penIds.has(frameId)) errors.push(`UI scope references an unknown .pen frame: ${frameId}`); } const requiredScripts = [ "test:unit", "test:integration", "test:api", "test:worker", "test:e2e", "test:visual", "test:performance", "test:security", "test:package", "validate:tdd-trace", "test:all", "validate:external", ]; const packageJson = JSON.parse(readText(root, "package.json")); for (const script of requiredScripts) { if (!packageJson.scripts?.[script]) errors.push(`package.json is missing script ${script}`); } return { errors, status: errors.length === 0 ? "passed" : "failed", summary: { acceptanceCriteria: acceptanceCriteria.length, errorCategories: errorCategories.length, featureModules: featureModules.length, parentFamilies: catalogFamilies.length, penProductFrames: pen.children.filter((frame) => frame.id !== "Z8p3I").length, productContracts: productContracts.length, requirements: requirementIds.length, tasks: manifest.tasks.length, testCases: manifest.case_catalog.length, uiPages: pageIds.length, }, }; }