260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
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,
|
|
},
|
|
};
|
|
}
|