/** * tasks/check.ts — Validate the tasks file for schema issues, broken deps, and orphans. * * Usage: * woco tasks check * * Checks: * - Required fields present on every task * - No duplicate IDs * - All depends_on references point to existing tasks * - No circular dependencies * - No orphaned subtasks (subtask IDs unique) * - Priority/difficulty values are valid enums * - Effort strings are parseable ISO 8601 durations */ import type { WomboConfig } from "../../config"; import { loadFeatures, allFeatureIds, parseDurationMinutes, type Feature, type Subtask, type FeaturesFile, } from "../../lib/tasks"; import { VALID_STATUSES, VALID_PRIORITIES, VALID_DIFFICULTIES, } from "../../lib/task-schema"; import { output, type OutputFormat } from "../../lib/output"; import { renderTasksCheck } from "../../lib/toon"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface TasksCheckOptions { projectRoot: string; config: WomboConfig; outputFmt?: OutputFormat; } interface CheckResult { errors: string[]; warnings: string[]; } // --------------------------------------------------------------------------- // Validation (enums imported from task-schema.ts — single source of truth) // --------------------------------------------------------------------------- function collectAllItems(data: FeaturesFile): (Feature | Subtask)[] { const items: (Feature | Subtask)[] = []; const collect = (list: (Feature | Subtask)[]) => { for (const item of list) { items.push(item); if (item.subtasks?.length) collect(item.subtasks); } }; collect(data.tasks ?? []); collect(data.archive ?? []); return items; } function checkTasks(data: FeaturesFile): CheckResult { const errors: string[] = []; const warnings: string[] = []; const allItems = collectAllItems(data); const allIds = allItems.map((i) => i.id); const idSet = new Set(); // Check for duplicates for (const id of allIds) { if (idSet.has(id)) { errors.push(`Duplicate ID: "${id}"`); } idSet.add(id); } // Check each item for (const item of allItems) { const prefix = `[${item.id}]`; // Required fields if (!item.id) errors.push(`${prefix} Missing required field: id`); if (!item.title) errors.push(`${prefix} Missing required field: title`); if (!item.status) errors.push(`${prefix} Missing required field: status`); // Validate enum values if (item.status && !(VALID_STATUSES as readonly string[]).includes(item.status)) { errors.push(`${prefix} Invalid status: "${item.status}"`); } if (item.priority && !(VALID_PRIORITIES as readonly string[]).includes(item.priority)) { errors.push(`${prefix} Invalid priority: "${item.priority}"`); } if (item.difficulty && !(VALID_DIFFICULTIES as readonly string[]).includes(item.difficulty)) { errors.push(`${prefix} Invalid difficulty: "${item.difficulty}"`); } // Validate effort if (item.effort) { const minutes = parseDurationMinutes(item.effort); if (minutes === Infinity) { warnings.push(`${prefix} Unparseable effort duration: "${item.effort}"`); } } // Check depends_on references if (item.depends_on?.length) { for (const dep of item.depends_on) { if (!idSet.has(dep)) { errors.push(`${prefix} Broken dependency: "${dep}" does not exist`); } } } } // Check for circular dependencies (simple DFS) const visited = new Set(); const visiting = new Set(); const depMap = new Map(); for (const item of allItems) { depMap.set(item.id, item.depends_on ?? []); } function hasCycle(id: string): boolean { if (visiting.has(id)) return true; if (visited.has(id)) return false; visiting.add(id); for (const dep of depMap.get(id) ?? []) { if (hasCycle(dep)) { errors.push(`Circular dependency detected involving: "${id}" → "${dep}"`); return true; } } visiting.delete(id); visited.add(id); return false; } for (const id of idSet) { hasCycle(id); } // Warn about features with no effort estimate for (const item of allItems) { if (!item.effort || item.effort === "PT0S") { warnings.push(`[${item.id}] No effort estimate`); } } return { errors, warnings }; } // --------------------------------------------------------------------------- // Command // --------------------------------------------------------------------------- export async function cmdTasksCheck(opts: TasksCheckOptions): Promise { const { projectRoot, config } = opts; const fmt = opts.outputFmt ?? "text"; const data = loadFeatures(projectRoot, config); const result = checkTasks(data); const allItems = collectAllItems(data); const issues = [ ...result.errors.map((msg) => ({ level: "error" as const, message: msg })), ...result.warnings.map((msg) => ({ level: "warning" as const, message: msg })), ]; const ok = result.errors.length === 0; const checkData = { ok, issues, items_checked: allItems.length, }; output( fmt, checkData, () => { console.log(`\nChecking ${config.tasksDir}/ (${allItems.length} items)...\n`); if (result.errors.length === 0 && result.warnings.length === 0) { console.log(" All checks passed. No issues found."); return; } if (result.errors.length > 0) { console.log(` ERRORS (${result.errors.length}):`); for (const e of result.errors) { console.log(` \x1b[31m✗\x1b[0m ${e}`); } console.log(""); } if (result.warnings.length > 0) { console.log(` WARNINGS (${result.warnings.length}):`); for (const w of result.warnings) { console.log(` \x1b[33m⚠\x1b[0m ${w}`); } console.log(""); } }, () => { // TOON renderer console.log(renderTasksCheck(checkData)); } ); if (result.errors.length > 0) { process.exit(1); } }