@dtlabs/ai-toolkit 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude/agents/generate-work-breakdown.md +212 -192
  2. package/.claude/agents/install-toolkit.md +6 -1
  3. package/.claude/agents/validate-work-breakdown-semantic.md +96 -0
  4. package/.claude/scripts/.gitkeep +0 -0
  5. package/.claude/scripts/tests/fixtures/ac-table-format.md +48 -0
  6. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-Work-Breakdown.json +234 -0
  7. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-semantic-output.json +4 -0
  8. package/.claude/scripts/tests/fixtures/test-feature/feature.md +45 -0
  9. package/.claude/scripts/tests/fixtures/wb-invalid-bad-domain.json +39 -0
  10. package/.claude/scripts/tests/fixtures/wb-invalid-cycle.json +61 -0
  11. package/.claude/scripts/tests/fixtures/wb-invalid-duplicate-id.json +72 -0
  12. package/.claude/scripts/tests/fixtures/wb-invalid-missing-field.json +38 -0
  13. package/.claude/scripts/tests/fixtures/wb-valid.json +189 -0
  14. package/.claude/scripts/tests/helpers/buildWaves.js +21 -0
  15. package/.claude/scripts/tests/helpers/pm-phase2-flow.js +29 -0
  16. package/.claude/scripts/tests/helpers/pm-phase2-gate2.js +55 -0
  17. package/.claude/scripts/tests/helpers/pm-phase2-ledger.js +108 -0
  18. package/.claude/scripts/tests/installer.scripts-distribution.test.js +131 -0
  19. package/.claude/scripts/tests/pm-phase2.gate2-payload.test.js +353 -0
  20. package/.claude/scripts/tests/pm-phase2.ledger-transitions.test.js +346 -0
  21. package/.claude/scripts/tests/pm-phase2.orchestration-flow.test.js +276 -0
  22. package/.claude/scripts/tests/verify-us-01-t03.js +288 -0
  23. package/.claude/scripts/tests/verify-us-03-t04.js +262 -0
  24. package/.claude/scripts/tests/wb-render.checks-md-csv-deps.test.js +337 -0
  25. package/.claude/scripts/tests/wb-render.checks-sanitize-commit-paths.test.js +352 -0
  26. package/.claude/scripts/tests/wb-render.regression-pm-phase3-csv.test.js +219 -0
  27. package/.claude/scripts/tests/wb-validate.checks-1-7.test.js +330 -0
  28. package/.claude/scripts/tests/wb-validate.checks-11.test.js +166 -0
  29. package/.claude/scripts/tests/wb-validate.checks-12-14.test.js +222 -0
  30. package/.claude/scripts/tests/wb-validate.checks-15-18.test.js +359 -0
  31. package/.claude/scripts/tests/wb-validate.checks-19-21.test.js +362 -0
  32. package/.claude/scripts/tests/wb-validate.checks-22-23-report.test.js +297 -0
  33. package/.claude/scripts/tests/wb-validate.checks-8-10.test.js +243 -0
  34. package/.claude/scripts/wb-render.js +309 -0
  35. package/.claude/scripts/wb-validate.js +1007 -0
  36. package/.claude/workflows/pm-phase2.js +289 -28
  37. package/CHANGELOG.md +67 -0
  38. package/bin/cli.js +1 -0
  39. package/package.json +1 -1
@@ -0,0 +1,330 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const VALIDATE = path.resolve(__dirname, '../wb-validate.js');
9
+ const FIXTURES = path.resolve(__dirname, 'fixtures');
10
+
11
+ /**
12
+ * Run wb-validate.js as a subprocess and return exit code, stdout, stderr,
13
+ * and the parsed JSON report (or null if stdout is not valid JSON).
14
+ */
15
+ function runValidator(wbPath, reqPath) {
16
+ const args = [VALIDATE, wbPath];
17
+ if (reqPath) args.push(reqPath);
18
+ const result = spawnSync('node', args, { encoding: 'utf8' });
19
+ let report = null;
20
+ try { report = JSON.parse(result.stdout); } catch (_) {}
21
+ return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr, report };
22
+ }
23
+
24
+ /**
25
+ * Minimal valid work breakdown JSON that passes all checks 1–23 when no
26
+ * requirements file is provided. Used as a base for inline fixtures.
27
+ */
28
+ const MINIMAL_VALID_WB = {
29
+ schemaVersion: 2,
30
+ phases: [
31
+ {
32
+ id: 'US-01',
33
+ type: 'user-story',
34
+ title: 'Test phase',
35
+ commit: 'feat(US-01): test',
36
+ tasks: [
37
+ {
38
+ id: 'US-01-TASK-BE-01',
39
+ title: 'A task',
40
+ outcome: 'Something is done',
41
+ domain: 'BE',
42
+ agentType: 'developer-backend',
43
+ dependsOn: [],
44
+ acceptanceCriteria: [],
45
+ verification: { commands: ['node --version'] },
46
+ estimate: { agentMinutes: 10, tokens: 5000 },
47
+ outputCount: 1,
48
+ groupingRationale: 'Single responsibility',
49
+ commit: { subject: 'feat(US-01): implement task' },
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ };
55
+
56
+ // ── helper — write a WB object to a temp file and return the path ─────────────
57
+
58
+ function writeTmp(tmpFile, wb) {
59
+ fs.writeFileSync(tmpFile, JSON.stringify(wb));
60
+ }
61
+
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ describe('wb-validate.js — checks 1–7', () => {
65
+
66
+ // ── Check 1: missing file argument ──────────────────────────────────────────
67
+
68
+ describe('check 1 — missing file argument', () => {
69
+ test('exits with code 2 and reports "missing required argument" to stderr when no path argument is given', () => {
70
+ // Arrange: invoke the validator with no arguments at all
71
+ // Act
72
+ const result = spawnSync('node', [VALIDATE], { encoding: 'utf8' });
73
+ // Assert
74
+ expect(result.status).toBe(2);
75
+ expect(result.stderr).toContain('missing required argument');
76
+ });
77
+ });
78
+
79
+ // ── Check 2: schema version ──────────────────────────────────────────────────
80
+
81
+ describe('check 2 — schema version', () => {
82
+ let tmpFile;
83
+
84
+ beforeEach(() => {
85
+ tmpFile = path.join(os.tmpdir(), `wb-test-${Date.now()}.json`);
86
+ });
87
+
88
+ afterEach(() => {
89
+ try { fs.unlinkSync(tmpFile); } catch (_) {}
90
+ });
91
+
92
+ test('exits 0 and report.valid is true when schemaVersion is exactly 2', () => {
93
+ // Arrange
94
+ writeTmp(tmpFile, MINIMAL_VALID_WB);
95
+ // Act
96
+ const { exitCode, report } = runValidator(tmpFile);
97
+ // Assert
98
+ expect(exitCode).toBe(0);
99
+ expect(report.valid).toBe(true);
100
+ });
101
+
102
+ test('exits 1, report.valid is false, and schema_version_invalid is reported when schemaVersion is 1', () => {
103
+ // Arrange
104
+ writeTmp(tmpFile, { ...MINIMAL_VALID_WB, schemaVersion: 1 });
105
+ // Act
106
+ const { exitCode, report } = runValidator(tmpFile);
107
+ // Assert
108
+ expect(exitCode).toBe(1);
109
+ expect(report.valid).toBe(false);
110
+ expect(report.errors.some(e => e.category === 'schema_version_invalid')).toBe(true);
111
+ });
112
+
113
+ test('exits 1, report.valid is false, and schema_version_invalid is reported when schemaVersion is null', () => {
114
+ // Arrange
115
+ writeTmp(tmpFile, { ...MINIMAL_VALID_WB, schemaVersion: null });
116
+ // Act
117
+ const { exitCode, report } = runValidator(tmpFile);
118
+ // Assert
119
+ expect(exitCode).toBe(1);
120
+ expect(report.valid).toBe(false);
121
+ expect(report.errors.some(e => e.category === 'schema_version_invalid')).toBe(true);
122
+ });
123
+ });
124
+
125
+ // ── Check 3: unique task IDs ─────────────────────────────────────────────────
126
+
127
+ describe('check 3 — unique task IDs', () => {
128
+ test('exits 1, report.valid is false, and unique_id_violation is reported when the same task ID appears in multiple phases', () => {
129
+ // Arrange: fixture has INFRA-TASK-INFRA-01 in both the INFRA and US-01 phases
130
+ const wbPath = path.join(FIXTURES, 'wb-invalid-duplicate-id.json');
131
+ // Act
132
+ const { exitCode, report } = runValidator(wbPath);
133
+ // Assert
134
+ expect(exitCode).toBe(1);
135
+ expect(report.valid).toBe(false);
136
+ expect(report.errors.some(e => e.category === 'unique_id_violation')).toBe(true);
137
+ });
138
+
139
+ test('exits 0, report.valid is true, and no unique_id_violation errors are present for a valid work breakdown', () => {
140
+ // Arrange
141
+ const wbPath = path.join(FIXTURES, 'wb-valid.json');
142
+ // Act
143
+ const { exitCode, report } = runValidator(wbPath);
144
+ // Assert
145
+ expect(exitCode).toBe(0);
146
+ expect(report.valid).toBe(true);
147
+ expect(report.errors.filter(e => e.category === 'unique_id_violation')).toHaveLength(0);
148
+ });
149
+ });
150
+
151
+ // ── Check 4: required fields ─────────────────────────────────────────────────
152
+
153
+ describe('check 4 — required fields', () => {
154
+ test('exits 1, report.valid is false, and missing_field with field "outcome" is reported when outcome is absent from a task', () => {
155
+ // Arrange: fixture has INFRA-TASK-INFRA-01 with the "outcome" field omitted
156
+ const wbPath = path.join(FIXTURES, 'wb-invalid-missing-field.json');
157
+ // Act
158
+ const { exitCode, report } = runValidator(wbPath);
159
+ // Assert
160
+ expect(exitCode).toBe(1);
161
+ expect(report.valid).toBe(false);
162
+ const outcomeError = report.errors.find(
163
+ e => e.category === 'missing_field' && e.field === 'outcome',
164
+ );
165
+ expect(outcomeError).toBeDefined();
166
+ });
167
+
168
+ test('exits 0, report.valid is true, and no missing_field errors are present for a valid work breakdown', () => {
169
+ // Arrange
170
+ const wbPath = path.join(FIXTURES, 'wb-valid.json');
171
+ // Act
172
+ const { exitCode, report } = runValidator(wbPath);
173
+ // Assert
174
+ expect(exitCode).toBe(0);
175
+ expect(report.valid).toBe(true);
176
+ expect(report.errors.filter(e => e.category === 'missing_field')).toHaveLength(0);
177
+ });
178
+
179
+ // W3 regression guard — groupingRationale must be enforced by check 4
180
+ // Before fix: groupingRationale was in REQUIRED_TASK_FIELDS but absent from
181
+ // topLevelFields, so a task without the field passed check 4 with exit 0.
182
+
183
+ test('W3: exits 1 and missing_field with field "groupingRationale" is reported when groupingRationale is absent from a task', () => {
184
+ const { groupingRationale: _drop, ...taskWithoutRationale } = MINIMAL_VALID_WB.phases[0].tasks[0];
185
+ const wb = {
186
+ ...MINIMAL_VALID_WB,
187
+ phases: [{ ...MINIMAL_VALID_WB.phases[0], tasks: [taskWithoutRationale] }],
188
+ };
189
+ const tmpPath = path.join(os.tmpdir(), `wb-w3-${Date.now()}.json`);
190
+ writeTmp(tmpPath, wb);
191
+ try {
192
+ const { exitCode, report } = runValidator(tmpPath);
193
+ expect(exitCode).toBe(1);
194
+ expect(report.valid).toBe(false);
195
+ const err = report.errors.find(e => e.category === 'missing_field' && e.field === 'groupingRationale');
196
+ expect(err).toBeDefined();
197
+ } finally {
198
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
199
+ }
200
+ });
201
+
202
+ test('W3: exits 0 and no missing_field for groupingRationale when the field is present and non-empty', () => {
203
+ // MINIMAL_VALID_WB already includes groupingRationale — use it directly
204
+ const wbPath = path.join(FIXTURES, 'wb-valid.json');
205
+ const { exitCode, report } = runValidator(wbPath);
206
+ expect(exitCode).toBe(0);
207
+ expect(report.errors.filter(e => e.category === 'missing_field' && e.field === 'groupingRationale')).toHaveLength(0);
208
+ });
209
+ });
210
+
211
+ // ── Check 5: task ID format ──────────────────────────────────────────────────
212
+
213
+ describe('check 5 — task ID format', () => {
214
+ let tmpFile;
215
+
216
+ beforeEach(() => {
217
+ tmpFile = path.join(os.tmpdir(), `wb-test-${Date.now()}.json`);
218
+ });
219
+
220
+ afterEach(() => {
221
+ try { fs.unlinkSync(tmpFile); } catch (_) {}
222
+ });
223
+
224
+ test('exits 1, report.valid is false, and invalid_id_format is reported when a non-INFRA task ID does not match the expected pattern', () => {
225
+ // Arrange: replace the task id with a string that matches no expected pattern
226
+ const wb = {
227
+ ...MINIMAL_VALID_WB,
228
+ phases: [
229
+ {
230
+ ...MINIMAL_VALID_WB.phases[0],
231
+ tasks: [
232
+ { ...MINIMAL_VALID_WB.phases[0].tasks[0], id: 'BAD-FORMAT' },
233
+ ],
234
+ },
235
+ ],
236
+ };
237
+ writeTmp(tmpFile, wb);
238
+ // Act
239
+ const { exitCode, report } = runValidator(tmpFile);
240
+ // Assert
241
+ expect(exitCode).toBe(1);
242
+ expect(report.valid).toBe(false);
243
+ expect(report.errors.some(e => e.category === 'invalid_id_format')).toBe(true);
244
+ });
245
+
246
+ test('exits 0, report.valid is true, and no invalid_id_format errors are present for a valid work breakdown', () => {
247
+ // Arrange
248
+ const wbPath = path.join(FIXTURES, 'wb-valid.json');
249
+ // Act
250
+ const { exitCode, report } = runValidator(wbPath);
251
+ // Assert
252
+ expect(exitCode).toBe(0);
253
+ expect(report.valid).toBe(true);
254
+ expect(report.errors.filter(e => e.category === 'invalid_id_format')).toHaveLength(0);
255
+ });
256
+ });
257
+
258
+ // ── Check 6: domain whitelist ────────────────────────────────────────────────
259
+
260
+ describe('check 6 — domain whitelist', () => {
261
+ test('exits 1, report.valid is false, and invalid_domain is reported when a task has an unrecognised domain value', () => {
262
+ // Arrange: fixture has domain "BACKEND" which is not in the VALID_DOMAINS list
263
+ const wbPath = path.join(FIXTURES, 'wb-invalid-bad-domain.json');
264
+ // Act
265
+ const { exitCode, report } = runValidator(wbPath);
266
+ // Assert
267
+ expect(exitCode).toBe(1);
268
+ expect(report.valid).toBe(false);
269
+ expect(report.errors.some(e => e.category === 'invalid_domain')).toBe(true);
270
+ });
271
+
272
+ test('exits 0, report.valid is true, and no invalid_domain errors are present for a valid work breakdown', () => {
273
+ // Arrange
274
+ const wbPath = path.join(FIXTURES, 'wb-valid.json');
275
+ // Act
276
+ const { exitCode, report } = runValidator(wbPath);
277
+ // Assert
278
+ expect(exitCode).toBe(0);
279
+ expect(report.valid).toBe(true);
280
+ expect(report.errors.filter(e => e.category === 'invalid_domain')).toHaveLength(0);
281
+ });
282
+ });
283
+
284
+ // ── Check 7: agentType whitelist ─────────────────────────────────────────────
285
+
286
+ describe('check 7 — agentType whitelist', () => {
287
+ let tmpFile;
288
+
289
+ beforeEach(() => {
290
+ tmpFile = path.join(os.tmpdir(), `wb-test-${Date.now()}.json`);
291
+ });
292
+
293
+ afterEach(() => {
294
+ try { fs.unlinkSync(tmpFile); } catch (_) {}
295
+ });
296
+
297
+ test('exits 1, report.valid is false, and invalid_agent_type is reported when a task has an unrecognised agentType', () => {
298
+ // Arrange: replace agentType with a value that is not in the VALID_AGENT_TYPES list
299
+ const wb = {
300
+ ...MINIMAL_VALID_WB,
301
+ phases: [
302
+ {
303
+ ...MINIMAL_VALID_WB.phases[0],
304
+ tasks: [
305
+ { ...MINIMAL_VALID_WB.phases[0].tasks[0], agentType: 'unknown-agent' },
306
+ ],
307
+ },
308
+ ],
309
+ };
310
+ writeTmp(tmpFile, wb);
311
+ // Act
312
+ const { exitCode, report } = runValidator(tmpFile);
313
+ // Assert
314
+ expect(exitCode).toBe(1);
315
+ expect(report.valid).toBe(false);
316
+ expect(report.errors.some(e => e.category === 'invalid_agent_type')).toBe(true);
317
+ });
318
+
319
+ test('exits 0, report.valid is true, and no invalid_agent_type errors are present for a valid work breakdown', () => {
320
+ // Arrange
321
+ const wbPath = path.join(FIXTURES, 'wb-valid.json');
322
+ // Act
323
+ const { exitCode, report } = runValidator(wbPath);
324
+ // Assert
325
+ expect(exitCode).toBe(0);
326
+ expect(report.valid).toBe(true);
327
+ expect(report.errors.filter(e => e.category === 'invalid_agent_type')).toHaveLength(0);
328
+ });
329
+ });
330
+ });
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const VALIDATE = path.resolve(__dirname, '../wb-validate.js');
9
+ const FIXTURES = path.resolve(__dirname, 'fixtures');
10
+
11
+ function runValidator(wbPath) {
12
+ const result = spawnSync('node', [VALIDATE, wbPath], { encoding: 'utf8' });
13
+ let report = null;
14
+ try { report = JSON.parse(result.stdout); } catch (_) {}
15
+ return { exitCode: result.status, report, stderr: result.stderr };
16
+ }
17
+
18
+ // ── Shared temp-file lifecycle ────────────────────────────────────────────────
19
+
20
+ let tmpFile;
21
+
22
+ beforeEach(() => { tmpFile = path.join(os.tmpdir(), `wb-t17-${Date.now()}.json`); });
23
+ afterEach(() => { try { fs.unlinkSync(tmpFile); } catch (_) {} });
24
+
25
+ function writeFixture(obj) { fs.writeFileSync(tmpFile, JSON.stringify(obj, null, 2)); return tmpFile; }
26
+
27
+ // ── Domain builders ───────────────────────────────────────────────────────────
28
+
29
+ function makeTask(id, dependsOn = []) {
30
+ return {
31
+ id,
32
+ title: `Task ${id}`,
33
+ outcome: 'Done',
34
+ domain: 'BE',
35
+ agentType: 'developer-backend',
36
+ dependsOn,
37
+ acceptanceCriteria: [],
38
+ verification: { commands: ['node --version'] },
39
+ estimate: { agentMinutes: 10, tokens: 5000 },
40
+ outputCount: 1,
41
+ groupingRationale: 'Single',
42
+ commit: { subject: `feat: ${id}` },
43
+ };
44
+ }
45
+
46
+ function makePhase(id, tasks) {
47
+ return { id, type: 'user-story', title: `Phase ${id}`, commit: `feat(${id}): done`, tasks };
48
+ }
49
+
50
+ function makeWB(phases) { return { schemaVersion: 2, phases }; }
51
+
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ describe('wb-validate.js — check 11 — task cycle detection (DFS gray/black coloring)', () => {
55
+
56
+ // ── Test A: existing cycle fixture ───────────────────────────────────────────
57
+
58
+ test('exits 1 and reports at least one task_cycle_detected error when the existing wb-invalid-cycle.json fixture is validated', () => {
59
+ // Arrange: use the pre-existing cycle fixture (INFRA-TASK-INFRA-01 ↔ INFRA-TASK-INFRA-02)
60
+ const wbPath = path.join(FIXTURES, 'wb-invalid-cycle.json');
61
+
62
+ // Act
63
+ const { exitCode, report } = runValidator(wbPath);
64
+
65
+ // Assert
66
+ expect(exitCode).toBe(1);
67
+ expect(report.errors.some(e => e.category === 'task_cycle_detected')).toBe(true);
68
+ });
69
+
70
+ // ── Test B: two-node mutual cycle A→B, B→A ───────────────────────────────────
71
+
72
+ test('exits 1, reports task_cycle_detected, and details.cycleMembers contains both task IDs for a two-node cycle (A→B, B→A)', () => {
73
+ // Arrange
74
+ const wb = makeWB([makePhase('US-01', [
75
+ makeTask('US-01-TASK-BE-01', ['US-01-TASK-BE-02']),
76
+ makeTask('US-01-TASK-BE-02', ['US-01-TASK-BE-01']),
77
+ ])]);
78
+ writeFixture(wb);
79
+
80
+ // Act
81
+ const { exitCode, report } = runValidator(tmpFile);
82
+
83
+ // Assert
84
+ expect(exitCode).toBe(1);
85
+ const cycleErr = report.errors.find(e => e.category === 'task_cycle_detected');
86
+ expect(cycleErr).toBeDefined();
87
+ expect(Array.isArray(cycleErr.details.cycleMembers)).toBe(true);
88
+ expect(cycleErr.details.cycleMembers).toContain('US-01-TASK-BE-01');
89
+ expect(cycleErr.details.cycleMembers).toContain('US-01-TASK-BE-02');
90
+ });
91
+
92
+ // ── Test C: three-node cycle A→B→C→A ────────────────────────────────────────
93
+
94
+ test('exits 1, reports task_cycle_detected, and details.cycleMembers has at least 3 elements for a three-node cycle (A→B→C→A)', () => {
95
+ // Arrange
96
+ const wb = makeWB([makePhase('US-01', [
97
+ makeTask('US-01-TASK-BE-01', ['US-01-TASK-BE-02']),
98
+ makeTask('US-01-TASK-BE-02', ['US-01-TASK-BE-03']),
99
+ makeTask('US-01-TASK-BE-03', ['US-01-TASK-BE-01']),
100
+ ])]);
101
+ writeFixture(wb);
102
+
103
+ // Act
104
+ const { exitCode, report } = runValidator(tmpFile);
105
+
106
+ // Assert
107
+ expect(exitCode).toBe(1);
108
+ const cycleErr = report.errors.find(e => e.category === 'task_cycle_detected');
109
+ expect(cycleErr).toBeDefined();
110
+ expect(Array.isArray(cycleErr.details.cycleMembers)).toBe(true);
111
+ expect(cycleErr.details.cycleMembers.length).toBeGreaterThanOrEqual(3);
112
+ });
113
+
114
+ // ── Test D: acyclic diamond graph — no cycles ────────────────────────────────
115
+
116
+ test('exits 0, reports no task_cycle_detected errors, and report.dependencies.taskCycles is an empty array for an acyclic diamond graph (A→B, A→C, B→D, C→D)', () => {
117
+ // Arrange
118
+ const wb = makeWB([makePhase('US-01', [
119
+ makeTask('US-01-TASK-BE-01', []),
120
+ makeTask('US-01-TASK-BE-02', ['US-01-TASK-BE-01']),
121
+ makeTask('US-01-TASK-BE-03', ['US-01-TASK-BE-01']),
122
+ makeTask('US-01-TASK-BE-04', ['US-01-TASK-BE-02', 'US-01-TASK-BE-03']),
123
+ ])]);
124
+ writeFixture(wb);
125
+
126
+ // Act
127
+ const { exitCode, report } = runValidator(tmpFile);
128
+
129
+ // Assert
130
+ expect(exitCode).toBe(0);
131
+ expect(report.errors.filter(e => e.category === 'task_cycle_detected')).toHaveLength(0);
132
+ expect(report.dependencies.taskCycles).toEqual([]);
133
+ });
134
+
135
+ // ── Test E: cross-phase cycle ────────────────────────────────────────────────
136
+
137
+ test('exits 1 and reports task_cycle_detected when a mutual dependency cycle spans two phases (US-01-A→US-02-B, US-02-B→US-01-A)', () => {
138
+ // Arrange: task in US-01 depends on task in US-02, which depends back on US-01 task
139
+ const wb = makeWB([
140
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['US-02-TASK-BE-01'])]),
141
+ makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
142
+ ]);
143
+ writeFixture(wb);
144
+
145
+ // Act
146
+ const { exitCode, report } = runValidator(tmpFile);
147
+
148
+ // Assert
149
+ expect(exitCode).toBe(1);
150
+ expect(report.errors.some(e => e.category === 'task_cycle_detected')).toBe(true);
151
+ });
152
+
153
+ // ── Test F: taskCycles always present in report ──────────────────────────────
154
+
155
+ test('report.dependencies.taskCycles is always an array even when there are no cycles (valid fixture exits 0)', () => {
156
+ // Arrange: use the pre-existing valid fixture which has no dependency cycles
157
+ const wbPath = path.join(FIXTURES, 'wb-valid.json');
158
+
159
+ // Act
160
+ const { exitCode, report } = runValidator(wbPath);
161
+
162
+ // Assert
163
+ expect(exitCode).toBe(0);
164
+ expect(Array.isArray(report.dependencies.taskCycles)).toBe(true);
165
+ });
166
+ });