A flat-YAML task tracker that is just files. No daemon, no database, no git hooks. diarie.dev
issue-tracker cli nodejs agent-friendly
0

Configure Feed

Select the types of activity you want to include in your feed.

diarie / lib / validate.js
14 kB 281 lines
1/** 2 * validate.js — the integrity gate. 3 * 4 * PURE: `lintTasks()` takes already-parsed files and returns errors/warnings. No 5 * filesystem, no process, no exit codes. `commands/validate.js` does the IO and 6 * owns the exit codes; `store.js` owns finding the store. 7 * 8 * Four passes: 9 * 1. per-file structural — required fields, enum values, unique ids 10 * 2. dep-graph integrity — dangling deps, orphan parents, no cycles (Kahn) 11 * 3. status-transition sanity — claimed-before-blockers, ghost claims 12 * 4. test-ratchet — completed work units must state their acceptance criteria 13 * 14 * Honest scope: this catches dep-graph rot at check time — the same guarantee 15 * `bd graph check` gave (a snapshot, not a structural invariant). It cannot 16 * make an agent write its plan-updates back; it makes the rot visible within 17 * one `npm run check`. 18 * 19 * NOT substrate-optional any more. It used to exit 0 with `{clean:true, 20 * skipped:true}` when no store existed — which made an ABSENT store and a CLEAN 21 * one indistinguishable, and forced `beads-probe` to test `skipped === false` just 22 * to know whether the gate had been real. A missing store is now ENOSTORE and a 23 * non-zero exit; the `skipped` flag is gone with the defect it papered over. An 24 * EMPTY store is still perfectly clean — see store.js. 25 */ 26 27import { 28 isObject, isStringArray, isType, isUnknownArray, 29} from '@voxpelli/typed-utils'; 30 31import { 32 ID_RE, isNil, isPriority, isStatus, isTaskType, nsId, RATCHET_TYPES, REQUIRED_FIELDS, 33} from './schema.js'; 34 35/** 36 * A task entry exactly as YAML handed it over: known keys, UNKNOWN values. 37 * 38 * `Record<string, unknown>`, not `any`. The values genuinely are unknown — that is this 39 * file's entire subject — but the KEYS are not, and `any` was throwing both away. Under 40 * `any`, renaming a schema field would make every check on it silently read `undefined` 41 * and stop checking, while `npm run check` stayed green: the same silent-no-op class that 42 * produced every other bug in this tracker, sitting inside the gate meant to catch them. 43 * 44 * @typedef {Record<string, unknown>} RawTask 45 */ 46 47/** 48 * If a dangling BARE dep matches a task id in a different slug, suggest it — 49 * the most common copy-paste error (a task moved between files keeps bare deps). 50 * 51 * @param {unknown} rawDep the dep exactly as written (bare or `slug/id`) 52 * @param {string} resolved the globalized id that failed to resolve 53 * @param {Map<string, { t: RawTask, slug: string }>} all 54 * @returns {string} 55 */ 56function crossSlugHint (rawDep, resolved, all) { 57 if (String(rawDep).includes('/')) return ''; 58 const match = [...all.keys()].find(k => k !== resolved && k.endsWith(`/${rawDep}`)); 59 return match ? ` (did you mean ${match}?)` : ''; 60} 61 62/** 63 * Pure linter over loaded task files. 64 * 65 * @param {Array<{ name: string, tasks: unknown }>} files `name` is the slug 66 * @returns {{ errors: string[], warnings: string[] }} 67 */ 68export function lintTasks (files) { 69 /** @type {string[]} */ const errors = []; 70 /** @type {string[]} */ const warnings = []; 71 const err = (/** @type {string} */ f, /** @type {string} */ m) => errors.push(`${f}: ${m}`); 72 const warn = (/** @type {string} */ f, /** @type {string} */ m) => warnings.push(`${f}: ${m}`); 73 74 /** @type {Map<string, { t: RawTask, slug: string }>} */ 75 const all = new Map(); 76 /** 77 * Files whose top-level shape is broken — excluded from the value passes. 78 * 79 * @type {Set<string>} 80 */ 81 const badFiles = new Set(); 82 83 // --- Pass 0: shape guard (types, not values) — a wrong YAML shape would 84 // otherwise char-split a scalar into nonsense or throw on a non-iterable. --- 85 for (const { name, tasks } of files) { 86 if (!Array.isArray(tasks)) { 87 err(name, `top-level "tasks" must be a list (got ${tasks === null ? 'null' : typeof tasks})`); 88 badFiles.add(name); 89 continue; 90 } 91 for (const [i, t] of tasks.entries()) { 92 if (!isObject(t)) { err(name, `task at index ${i} is not a mapping`); continue; } 93 const label = t['id'] ?? `index ${i}`; 94 if (!isNil(t['deps']) && !isUnknownArray(t['deps'])) err(name, `task ${label}: "deps" must be a list (got ${typeof t['deps']})`); 95 if (!isNil(t['acceptance_criteria'])) { 96 if (!isUnknownArray(t['acceptance_criteria'])) err(name, `task ${label}: "acceptance_criteria" must be a list (got ${typeof t['acceptance_criteria']})`); 97 // The ELEMENTS, not just the container. `labels` was checked this way and 98 // `acceptance_criteria` was not — and the gap bit immediately: an unquoted `priority: 2` 99 // inside a criterion made YAML parse that element as a MAP, the list stayed an Array, 100 // and validate waved it through. Only the loader's reject-warn caught it, on stderr. 101 else if (!isStringArray(t['acceptance_criteria'])) err(name, `task ${label}: "acceptance_criteria" entries must all be strings (an unquoted \`key: value\` becomes a map — quote it)`); 102 } 103 if (!isNil(t['labels'])) { 104 if (!isUnknownArray(t['labels'])) err(name, `task ${label}: "labels" must be a list (got ${typeof t['labels']})`); 105 else if (!isStringArray(t['labels'])) err(name, `task ${label}: "labels" entries must all be strings`); 106 } 107 } 108 } 109 110 // --- Pass 1: per-file structural (field values) --- 111 for (const { name, tasks } of files) { 112 if (badFiles.has(name)) continue; 113 /** @type {Set<unknown>} */ const seen = new Set(); 114 for (const t of isUnknownArray(tasks) ? tasks : []) { 115 if (!isObject(t)) continue; // shape error already reported in Pass 0 116 const label = t['id'] ?? '(no id)'; 117 for (const field of REQUIRED_FIELDS) { 118 if (isNil(t[field]) || t[field] === '') err(name, `task ${label} missing required field: ${field}`); 119 } 120 if (!isNil(t['id'])) { 121 if (!ID_RE.test(String(t['id']))) err(name, `task ${label}: invalid id (expected ${ID_RE.source})`); 122 if (seen.has(t['id'])) err(name, `duplicate id "${t['id']}" within file`); 123 seen.add(t['id']); 124 } 125 if (!isNil(t['status']) && !isStatus(t['status'])) err(name, `task ${label}: invalid status "${t['status']}"`); 126 if (!isNil(t['type']) && !isTaskType(t['type'])) err(name, `task ${label}: invalid type "${t['type']}"`); 127 if (!isNil(t['priority']) && !isPriority(t['priority'])) err(name, `task ${label}: invalid priority "${t['priority']}"`); 128 if (!isNil(t['updated']) && (!isType(t['updated'], 'string') || !Number.isFinite(Date.parse(t['updated'])))) { 129 err(name, `task ${label}: invalid updated "${t['updated']}" (expected an ISO date string)`); 130 } 131 if (!isNil(t['id'])) all.set(nsId(t['id'], name), { t, slug: name }); 132 } 133 } 134 135 // --- Pass 2: dep graph (dangling, orphan parent, cycles) --- 136 /** @type {Map<string, string[]>} */ const deps = new Map(); 137 /** @type {Map<string, string>} */ const parents = new Map(); 138 for (const [gid, { slug, t }] of all) { 139 const resolved = []; 140 for (const d of isUnknownArray(t['deps']) ? t['deps'] : []) { 141 const gd = nsId(d, slug); 142 if (all.has(gd)) resolved.push(gd); 143 else err(slug, `task ${t['id']}: dep "${gd}" does not exist${crossSlugHint(d, gd, all)}`); 144 } 145 deps.set(gid, resolved); 146 if (!isNil(t['parent'])) { 147 const gp = nsId(t['parent'], slug); 148 if (gp === gid) err(slug, `task ${t['id']}: parent "${gp}" is itself`); 149 else if (!all.has(gp)) err(slug, `task ${t['id']}: parent "${gp}" does not exist`); 150 else parents.set(gid, gp); 151 } 152 } 153 // --- The BLOCKING graph. One check, over the union — not two, over the projections. --- 154 // 155 // `computeReady` blocks a task on TWO kinds of edge, and they point opposite ways: 156 // 157 // a dep blocks the DEPENDENT → edge task → dep (finish the dep first) 158 // a child blocks the PARENT → edge parent → child (the work is inside it) 159 // 160 // Checking `deps` and `parents` as SEPARATE graphs was wrong, and wrong in the worst 161 // available way: a ring that ALTERNATES edge kinds is acyclic in both projections and 162 // cyclic in neither, so every check passed over a backlog that could never be worked. 163 // The minimal case is a task that depends on its own epic — an entirely natural thing to 164 // write: 165 // 166 // EPIC-1 (epic) EPIC-1 is blocked by its child T-1 167 // T-1 parent: EPIC-1 T-1 is blocked by its dep EPIC-1 168 // deps: [EPIC-1] → neither can ever start 169 // 170 // `diarie ready` → "0 ready, 1 blocked — run `diarie validate` to check for a cycle" 171 // `diarie validate` → "Task validation passed." ...and the human loops, forever. 172 // 173 // So build the graph the ready-walk ACTUALLY walks, and check that. Note the parent edge 174 // is REVERSED relative to how `parents` stores it (child→parent): it is the parent that 175 // gets blocked. 176 /** @type {Map<string, string[]>} */ 177 const blocking = new Map(); 178 /** @type {Map<string, 'dep' | 'child'>} */ 179 const edgeKind = new Map(); 180 const addEdge = (/** @type {string} */ from, /** @type {string} */ to, /** @type {'dep'|'child'} */ kind) => { 181 const list = blocking.get(from); 182 if (list) list.push(to); 183 else blocking.set(from, [to]); 184 edgeKind.set(`${from}\u0000${to}`, kind); 185 }; 186 for (const [gid, targets] of deps) for (const d of targets) addEdge(gid, d, 'dep'); 187 for (const [child, parent] of parents) addEdge(parent, child, 'child'); 188 for (const gid of all.keys()) if (!blocking.has(gid)) blocking.set(gid, []); 189 190 for (const cycle of findCycles(blocking)) { 191 const head = cycle[0]; 192 const { slug } = (head === undefined ? undefined : all.get(head)) ?? { slug: '(graph)' }; 193 // Name the edge kinds in the path, so the reader can see WHICH relationship to break. 194 const path = cycle.map((node, i) => { 195 const next = cycle[i + 1]; 196 if (next === undefined) return node; 197 return `${node} ${edgeKind.get(`${node}\u0000${next}`) === 'child' ? '⊃' : '→'} `; 198 }).join(''); 199 const kinds = new Set(cycle.map((n, i) => edgeKind.get(`${n}\u0000${cycle[i + 1]}`)).filter(Boolean)); 200 const label = kinds.has('child') ? (kinds.has('dep') ? 'blocking cycle (deps ⨯ containment)' : 'parent cycle') : 'dependency cycle'; 201 err(slug, `${label}: ${path} (→ depends on, ⊃ contains)`); 202 } 203 204 // --- Pass 3: status-transition sanity --- 205 for (const [, { slug, t }] of all) { 206 if (t['status'] === 'in_progress') { 207 for (const d of isUnknownArray(t['deps']) ? t['deps'] : []) { 208 const dep = all.get(nsId(d, slug))?.t; 209 const depStatus = dep?.['status']; 210 if (depStatus === 'pending' || depStatus === 'in_progress') { 211 warn(slug, `task ${t['id']}: in_progress but dep ${nsId(d, slug)} is ${depStatus} (claimed before blockers resolved)`); 212 } 213 } 214 } 215 if (!isNil(t['agent']) && t['status'] === 'pending') { 216 warn(slug, `task ${t['id']}: agent "${t['agent']}" set but status is pending (ghost claim — clear agent or claim it)`); 217 } 218 } 219 220 // --- Pass 4: test-ratchet --- 221 for (const [, { slug, t }] of all) { 222 if (t['status'] === 'completed' && isTaskType(t['type']) && RATCHET_TYPES.has(t['type']) && 223 !(isUnknownArray(t['acceptance_criteria']) && t['acceptance_criteria'].length)) { 224 warn(slug, `task ${t['id']}: completed ${t['type']} with no acceptance_criteria (state done-ness before marking done)`); 225 } 226 } 227 228 return { errors, warnings }; 229} 230 231/** 232 * Find EVERY disjoint dependency cycle via Kahn's algorithm. `deps` maps each 233 * task → its prerequisites (edges point task → prerequisite); in-degree is 234 * counted on the prerequisite side, so a node nothing depends on starts at 0 and 235 * is removed first. Nodes that never reach in-degree 0 are in a cycle; one 236 * representative path is recovered per disjoint cycle component. The recovery 237 * order is sorted, so the output is deterministic across runs. 238 * 239 * @param {Map<string, string[]>} deps task → its prerequisites 240 * @returns {string[][]} one representative path per disjoint cycle (`[]` if acyclic) 241 */ 242function findCycles (deps) { 243 const indeg = new Map([...deps.keys()].map(k => [k, 0])); 244 for (const ds of deps.values()) for (const d of ds) indeg.set(d, (indeg.get(d) ?? 0) + 1); 245 const queue = [...indeg].filter(([, n]) => n === 0).map(([k]) => k); 246 /** @type {Set<string>} */ 247 const removed = new Set(); 248 while (queue.length) { 249 const n = /** @type {string} */ (queue.shift()); 250 removed.add(n); 251 for (const d of deps.get(n) ?? []) { 252 indeg.set(d, /** @type {number} */ (indeg.get(d)) - 1); 253 if (indeg.get(d) === 0) queue.push(d); 254 } 255 } 256 const stuck = [...deps.keys()].filter(k => !removed.has(k)); 257 stuck.sort(); // sort the fresh array in place for deterministic recovery order 258 if (!stuck.length) return []; 259 260 const inCycle = new Set(stuck); 261 /** @type {Set<string>} */ 262 const covered = new Set(); 263 /** @type {string[][]} */ 264 const cycles = []; 265 for (const start of stuck) { 266 if (covered.has(start)) continue; 267 /** @type {string[]} */ const path = []; 268 /** @type {string | undefined} */ let cur = start; 269 /** @type {Set<string>} */ 270 const visited = new Set(); 271 while (cur && !visited.has(cur)) { 272 visited.add(cur); 273 path.push(cur); 274 cur = (deps.get(cur) ?? []).find(d => inCycle.has(d)); 275 } 276 if (cur) path.push(cur); // close the loop 277 for (const node of path) covered.add(node); 278 cycles.push(path); 279 } 280 return cycles; 281}