A flat-YAML task tracker that is just files. No daemon, no database, no git hooks.
diarie.dev
issue-tracker
cli
nodejs
agent-friendly
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}