@dtlabs/ai-toolkit 0.8.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.
- package/.claude/agents/generate-work-breakdown.md +212 -192
- package/.claude/agents/install-toolkit.md +6 -1
- package/.claude/agents/validate-work-breakdown-semantic.md +96 -0
- package/.claude/scripts/.gitkeep +0 -0
- package/.claude/scripts/tests/fixtures/ac-table-format.md +48 -0
- package/.claude/scripts/tests/fixtures/test-feature/TEST-001-Work-Breakdown.json +234 -0
- package/.claude/scripts/tests/fixtures/test-feature/TEST-001-semantic-output.json +4 -0
- package/.claude/scripts/tests/fixtures/test-feature/feature.md +45 -0
- package/.claude/scripts/tests/fixtures/wb-invalid-bad-domain.json +39 -0
- package/.claude/scripts/tests/fixtures/wb-invalid-cycle.json +61 -0
- package/.claude/scripts/tests/fixtures/wb-invalid-duplicate-id.json +72 -0
- package/.claude/scripts/tests/fixtures/wb-invalid-missing-field.json +38 -0
- package/.claude/scripts/tests/fixtures/wb-valid.json +189 -0
- package/.claude/scripts/tests/helpers/buildWaves.js +21 -0
- package/.claude/scripts/tests/helpers/pm-phase2-flow.js +29 -0
- package/.claude/scripts/tests/helpers/pm-phase2-gate2.js +55 -0
- package/.claude/scripts/tests/helpers/pm-phase2-ledger.js +108 -0
- package/.claude/scripts/tests/installer.scripts-distribution.test.js +131 -0
- package/.claude/scripts/tests/pm-phase2.gate2-payload.test.js +353 -0
- package/.claude/scripts/tests/pm-phase2.ledger-transitions.test.js +346 -0
- package/.claude/scripts/tests/pm-phase2.orchestration-flow.test.js +276 -0
- package/.claude/scripts/tests/verify-us-01-t03.js +288 -0
- package/.claude/scripts/tests/verify-us-03-t04.js +262 -0
- package/.claude/scripts/tests/wb-render.checks-md-csv-deps.test.js +337 -0
- package/.claude/scripts/tests/wb-render.checks-sanitize-commit-paths.test.js +352 -0
- package/.claude/scripts/tests/wb-render.regression-pm-phase3-csv.test.js +219 -0
- package/.claude/scripts/tests/wb-validate.checks-1-7.test.js +330 -0
- package/.claude/scripts/tests/wb-validate.checks-11.test.js +166 -0
- package/.claude/scripts/tests/wb-validate.checks-12-14.test.js +222 -0
- package/.claude/scripts/tests/wb-validate.checks-15-18.test.js +359 -0
- package/.claude/scripts/tests/wb-validate.checks-19-21.test.js +362 -0
- package/.claude/scripts/tests/wb-validate.checks-22-23-report.test.js +297 -0
- package/.claude/scripts/tests/wb-validate.checks-8-10.test.js +243 -0
- package/.claude/scripts/wb-render.js +309 -0
- package/.claude/scripts/wb-validate.js +1007 -0
- package/.claude/workflows/pm-phase1.js +22 -13
- package/.claude/workflows/pm-phase2.js +304 -32
- package/.claude/workflows/pm-phase3.js +57 -21
- package/CHANGELOG.md +82 -0
- package/bin/cli.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,222 @@
|
|
|
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-t18-${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, type = 'user-story') {
|
|
47
|
+
return { id, type, 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 12 — phase dependency projection (phase_dependency_not_found)', () => {
|
|
55
|
+
|
|
56
|
+
// ── Test A: cross-phase dep projects into phaseGraph ────────────────────────
|
|
57
|
+
|
|
58
|
+
test('exits 0, phaseGraph[US-02] contains US-01, and phaseGraph[US-01] is empty when US-02 task depends on a US-01 task', () => {
|
|
59
|
+
// Arrange: one cross-phase dependency — US-02-TASK-BE-01 depends on US-01-TASK-BE-01
|
|
60
|
+
const wb = makeWB([
|
|
61
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
|
|
62
|
+
makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
|
|
63
|
+
]);
|
|
64
|
+
writeFixture(wb);
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
68
|
+
|
|
69
|
+
// Assert
|
|
70
|
+
expect(exitCode).toBe(0);
|
|
71
|
+
expect(report.dependencies.phaseGraph['US-02']).toContain('US-01');
|
|
72
|
+
expect(report.dependencies.phaseGraph['US-01']).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Test B: intra-phase dep does NOT appear in phaseGraph ───────────────────
|
|
76
|
+
|
|
77
|
+
test('exits 0 and phaseGraph[US-01] is empty when a task depends on another task within the same phase (intra-phase dep is removed)', () => {
|
|
78
|
+
// Arrange: both tasks are in US-01; the dep is intra-phase and must not project
|
|
79
|
+
const wb = makeWB([
|
|
80
|
+
makePhase('US-01', [
|
|
81
|
+
makeTask('US-01-TASK-BE-01'),
|
|
82
|
+
makeTask('US-01-TASK-BE-02', ['US-01-TASK-BE-01']),
|
|
83
|
+
]),
|
|
84
|
+
]);
|
|
85
|
+
writeFixture(wb);
|
|
86
|
+
|
|
87
|
+
// Act
|
|
88
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
expect(exitCode).toBe(0);
|
|
92
|
+
expect(report.dependencies.phaseGraph['US-01']).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe('wb-validate.js — check 13 — phase cycle detection (phase_cycle_detected)', () => {
|
|
99
|
+
|
|
100
|
+
// ── Test A: two-phase mutual cycle ───────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
test('exits 1, reports phase_cycle_detected, and details.cycleMembers contains both phase IDs for a two-phase mutual cycle (US-01 ↔ US-02)', () => {
|
|
103
|
+
// Arrange: US-01 task depends on US-02 task AND US-02 task depends on US-01 task
|
|
104
|
+
const wb = makeWB([
|
|
105
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['US-02-TASK-BE-01'])]),
|
|
106
|
+
makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
|
|
107
|
+
]);
|
|
108
|
+
writeFixture(wb);
|
|
109
|
+
|
|
110
|
+
// Act
|
|
111
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
114
|
+
expect(exitCode).toBe(1);
|
|
115
|
+
const cycleErr = report.errors.find(e => e.category === 'phase_cycle_detected');
|
|
116
|
+
expect(cycleErr).toBeDefined();
|
|
117
|
+
expect(Array.isArray(cycleErr.details.cycleMembers)).toBe(true);
|
|
118
|
+
expect(cycleErr.details.cycleMembers).toContain('US-01');
|
|
119
|
+
expect(cycleErr.details.cycleMembers).toContain('US-02');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── Test B: acyclic WB — phaseCycles is an empty array ──────────────────────
|
|
123
|
+
|
|
124
|
+
test('exits 0 and report.dependencies.phaseCycles is an empty array when there are no phase cycles', () => {
|
|
125
|
+
// Arrange: linear chain US-01 → US-02 — no cycle
|
|
126
|
+
const wb = makeWB([
|
|
127
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
|
|
128
|
+
makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
|
|
129
|
+
]);
|
|
130
|
+
writeFixture(wb);
|
|
131
|
+
|
|
132
|
+
// Act
|
|
133
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
134
|
+
|
|
135
|
+
// Assert
|
|
136
|
+
expect(exitCode).toBe(0);
|
|
137
|
+
expect(report.dependencies.phaseCycles).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe('wb-validate.js — check 14 — phase schedulability / buildWaves (phase_unschedulable)', () => {
|
|
144
|
+
|
|
145
|
+
// ── Test A: linear chain → three sequential waves ────────────────────────────
|
|
146
|
+
|
|
147
|
+
test('exits 0 and phaseWaves is [[US-01], [US-02], [US-03]] for a linear chain US-01 → US-02 → US-03', () => {
|
|
148
|
+
// Arrange: each phase depends on the previous one's task
|
|
149
|
+
const wb = makeWB([
|
|
150
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
|
|
151
|
+
makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
|
|
152
|
+
makePhase('US-03', [makeTask('US-03-TASK-BE-01', ['US-02-TASK-BE-01'])]),
|
|
153
|
+
]);
|
|
154
|
+
writeFixture(wb);
|
|
155
|
+
|
|
156
|
+
// Act
|
|
157
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
158
|
+
|
|
159
|
+
// Assert
|
|
160
|
+
expect(exitCode).toBe(0);
|
|
161
|
+
expect(report.dependencies.phaseWaves).toHaveLength(3);
|
|
162
|
+
expect(report.dependencies.phaseWaves[0]).toEqual(['US-01']);
|
|
163
|
+
expect(report.dependencies.phaseWaves[1]).toEqual(['US-02']);
|
|
164
|
+
expect(report.dependencies.phaseWaves[2]).toEqual(['US-03']);
|
|
165
|
+
expect(report.dependencies.phaseUnschedulable).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── Test B: parallel phases → wave 0 has US-01 + US-02, wave 1 has US-03 ────
|
|
169
|
+
|
|
170
|
+
test('exits 0, wave 0 contains both US-01 and US-02, and wave 1 is [US-03] when US-01 and US-02 are independent and US-03 depends on both', () => {
|
|
171
|
+
// Arrange: US-01 and US-02 have no cross-phase deps; US-03 depends on both
|
|
172
|
+
const wb = makeWB([
|
|
173
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
|
|
174
|
+
makePhase('US-02', [makeTask('US-02-TASK-BE-01')]),
|
|
175
|
+
makePhase('US-03', [makeTask('US-03-TASK-BE-01', ['US-01-TASK-BE-01', 'US-02-TASK-BE-01'])]),
|
|
176
|
+
]);
|
|
177
|
+
writeFixture(wb);
|
|
178
|
+
|
|
179
|
+
// Act
|
|
180
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
181
|
+
|
|
182
|
+
// Assert
|
|
183
|
+
expect(exitCode).toBe(0);
|
|
184
|
+
expect(report.dependencies.phaseWaves).toHaveLength(2);
|
|
185
|
+
expect(report.dependencies.phaseWaves[0]).toContain('US-01');
|
|
186
|
+
expect(report.dependencies.phaseWaves[0]).toContain('US-02');
|
|
187
|
+
expect(report.dependencies.phaseWaves[1]).toEqual(['US-03']);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── Test C: phase cycle creates deadlock → phase_unschedulable ───────────────
|
|
191
|
+
|
|
192
|
+
test('exits 1, reports phase_unschedulable error, and phaseUnschedulable contains at least one phase ID when a mutual cycle creates a scheduling deadlock', () => {
|
|
193
|
+
// Arrange: same two-phase mutual cycle as check 13 test A — neither phase can be scheduled
|
|
194
|
+
const wb = makeWB([
|
|
195
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['US-02-TASK-BE-01'])]),
|
|
196
|
+
makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
|
|
197
|
+
]);
|
|
198
|
+
writeFixture(wb);
|
|
199
|
+
|
|
200
|
+
// Act
|
|
201
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
202
|
+
|
|
203
|
+
// Assert
|
|
204
|
+
expect(exitCode).toBe(1);
|
|
205
|
+
expect(report.dependencies.phaseUnschedulable.length).toBeGreaterThanOrEqual(1);
|
|
206
|
+
expect(report.errors.some(e => e.category === 'phase_unschedulable')).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── Test D: phaseWaves is always present in the report ───────────────────────
|
|
210
|
+
|
|
211
|
+
test('report.dependencies.phaseWaves is an array even when the WB is valid (wb-valid.json exits 0)', () => {
|
|
212
|
+
// Arrange: use the pre-existing valid fixture — no cycles, fully schedulable
|
|
213
|
+
const wbPath = path.join(FIXTURES, 'wb-valid.json');
|
|
214
|
+
|
|
215
|
+
// Act
|
|
216
|
+
const { exitCode, report } = runValidator(wbPath);
|
|
217
|
+
|
|
218
|
+
// Assert
|
|
219
|
+
expect(exitCode).toBe(0);
|
|
220
|
+
expect(Array.isArray(report.dependencies.phaseWaves)).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
|
|
10
|
+
function runValidator(wbPath) {
|
|
11
|
+
const result = spawnSync('node', [VALIDATE, wbPath], { encoding: 'utf8' });
|
|
12
|
+
let report = null;
|
|
13
|
+
try { report = JSON.parse(result.stdout); } catch (_) {}
|
|
14
|
+
return { exitCode: result.status, report, stderr: result.stderr };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Shared temp-file lifecycle ────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
let tmpFile;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => { tmpFile = path.join(os.tmpdir(), `wb-t19-${Date.now()}.json`); });
|
|
22
|
+
afterEach(() => { try { fs.unlinkSync(tmpFile); } catch (_) {} });
|
|
23
|
+
|
|
24
|
+
function writeFixture(obj) { fs.writeFileSync(tmpFile, JSON.stringify(obj, null, 2)); return tmpFile; }
|
|
25
|
+
|
|
26
|
+
// ── Domain builders ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function makeTask(id, overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
title: `Task ${id}`,
|
|
32
|
+
outcome: 'Done',
|
|
33
|
+
domain: 'BE',
|
|
34
|
+
agentType: 'developer-backend',
|
|
35
|
+
dependsOn: [],
|
|
36
|
+
acceptanceCriteria: [],
|
|
37
|
+
verification: { commands: ['node --version'] },
|
|
38
|
+
estimate: { agentMinutes: 10, tokens: 5000 },
|
|
39
|
+
outputCount: 1,
|
|
40
|
+
groupingRationale: 'Single',
|
|
41
|
+
commit: { subject: `feat: ${id}` },
|
|
42
|
+
...overrides,
|
|
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 15 — duration policy (target / above_target / warning / split_required)', () => {
|
|
55
|
+
|
|
56
|
+
// ── Test A: target band (≤15 min) ────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
test('exits 0, no errors or warnings, and durationBands.target >= 1 when agentMinutes is 10 (≤15, target band)', () => {
|
|
59
|
+
// Arrange
|
|
60
|
+
const wb = makeWB([
|
|
61
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 10, tokens: 5000 } })]),
|
|
62
|
+
]);
|
|
63
|
+
writeFixture(wb);
|
|
64
|
+
|
|
65
|
+
// Act
|
|
66
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
67
|
+
|
|
68
|
+
// Assert
|
|
69
|
+
expect(exitCode).toBe(0);
|
|
70
|
+
expect(report.errors).toHaveLength(0);
|
|
71
|
+
expect(report.warnings).toHaveLength(0);
|
|
72
|
+
expect(report.durationBands.target).toBeGreaterThanOrEqual(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Test B: above_target band (16–20 min) ────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
test('exits 0, no errors or warnings, and durationBands.above_target >= 1 when agentMinutes is 18 (16–20, above_target band)', () => {
|
|
78
|
+
// Arrange
|
|
79
|
+
const wb = makeWB([
|
|
80
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 18, tokens: 5000 } })]),
|
|
81
|
+
]);
|
|
82
|
+
writeFixture(wb);
|
|
83
|
+
|
|
84
|
+
// Act
|
|
85
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
86
|
+
|
|
87
|
+
// Assert
|
|
88
|
+
expect(exitCode).toBe(0);
|
|
89
|
+
expect(report.errors).toHaveLength(0);
|
|
90
|
+
expect(report.warnings).toHaveLength(0);
|
|
91
|
+
expect(report.durationBands.above_target).toBeGreaterThanOrEqual(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── Test C: warning band (21–30 min) — non-blocking ─────────────────────────
|
|
95
|
+
|
|
96
|
+
test('exits 0, report.warnings contains a duration_warning entry, and durationBands.warning >= 1 when agentMinutes is 25 (21–30, warning band)', () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
const wb = makeWB([
|
|
99
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 25, tokens: 5000 } })]),
|
|
100
|
+
]);
|
|
101
|
+
writeFixture(wb);
|
|
102
|
+
|
|
103
|
+
// Act
|
|
104
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(exitCode).toBe(0);
|
|
108
|
+
const warningEntry = report.warnings.find(w => w.category === 'duration_warning');
|
|
109
|
+
expect(warningEntry).toBeDefined();
|
|
110
|
+
expect(report.durationBands.warning).toBeGreaterThanOrEqual(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── Test D: split_required band (>30 min) — blocking error ───────────────────
|
|
114
|
+
|
|
115
|
+
test('exits 1, report.errors contains a split_required entry, and durationBands.split_required >= 1 when agentMinutes is 35 (>30, split_required band)', () => {
|
|
116
|
+
// Arrange
|
|
117
|
+
const wb = makeWB([
|
|
118
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 35, tokens: 5000 } })]),
|
|
119
|
+
]);
|
|
120
|
+
writeFixture(wb);
|
|
121
|
+
|
|
122
|
+
// Act
|
|
123
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
expect(exitCode).toBe(1);
|
|
127
|
+
const errorEntry = report.errors.find(e => e.category === 'split_required');
|
|
128
|
+
expect(errorEntry).toBeDefined();
|
|
129
|
+
expect(report.durationBands.split_required).toBeGreaterThanOrEqual(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── Test E: all four bands present in one WB ─────────────────────────────────
|
|
133
|
+
|
|
134
|
+
test('exits 1 and durationBands has all four keys with correct counts when tasks span all four duration bands (10 / 18 / 25 / 35 min)', () => {
|
|
135
|
+
// Arrange: four tasks — one in each duration band
|
|
136
|
+
const wb = makeWB([
|
|
137
|
+
makePhase('US-01', [
|
|
138
|
+
makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 10, tokens: 5000 } }),
|
|
139
|
+
makeTask('US-01-TASK-BE-02', { estimate: { agentMinutes: 18, tokens: 5000 } }),
|
|
140
|
+
makeTask('US-01-TASK-BE-03', { estimate: { agentMinutes: 25, tokens: 5000 } }),
|
|
141
|
+
makeTask('US-01-TASK-BE-04', { estimate: { agentMinutes: 35, tokens: 5000 } }),
|
|
142
|
+
]),
|
|
143
|
+
]);
|
|
144
|
+
writeFixture(wb);
|
|
145
|
+
|
|
146
|
+
// Act
|
|
147
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
148
|
+
|
|
149
|
+
// Assert: split_required causes a blocking error → exit 1
|
|
150
|
+
expect(exitCode).toBe(1);
|
|
151
|
+
expect(report.durationBands.target).toBe(1);
|
|
152
|
+
expect(report.durationBands.above_target).toBe(1);
|
|
153
|
+
expect(report.durationBands.warning).toBe(1);
|
|
154
|
+
expect(report.durationBands.split_required).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe('wb-validate.js — check 16 — empty verification commands (empty_verification_commands)', () => {
|
|
161
|
+
|
|
162
|
+
// ── Test A: empty commands array → blocking error ────────────────────────────
|
|
163
|
+
|
|
164
|
+
test('exits 1 and report.errors contains empty_verification_commands when verification.commands is an empty array', () => {
|
|
165
|
+
// Arrange
|
|
166
|
+
const wb = makeWB([
|
|
167
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { verification: { commands: [] } })]),
|
|
168
|
+
]);
|
|
169
|
+
writeFixture(wb);
|
|
170
|
+
|
|
171
|
+
// Act
|
|
172
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
173
|
+
|
|
174
|
+
// Assert
|
|
175
|
+
expect(exitCode).toBe(1);
|
|
176
|
+
const errorEntry = report.errors.find(e => e.category === 'empty_verification_commands');
|
|
177
|
+
expect(errorEntry).toBeDefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── Test B: non-empty commands array → no error ──────────────────────────────
|
|
181
|
+
|
|
182
|
+
test('exits 0 and no empty_verification_commands error is reported when verification.commands has at least one entry', () => {
|
|
183
|
+
// Arrange
|
|
184
|
+
const wb = makeWB([
|
|
185
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { verification: { commands: ['node --version'] } })]),
|
|
186
|
+
]);
|
|
187
|
+
writeFixture(wb);
|
|
188
|
+
|
|
189
|
+
// Act
|
|
190
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
191
|
+
|
|
192
|
+
// Assert
|
|
193
|
+
expect(exitCode).toBe(0);
|
|
194
|
+
expect(report.errors.find(e => e.category === 'empty_verification_commands')).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
describe('wb-validate.js — check 17 — empty commit subject (empty_commit_subject)', () => {
|
|
201
|
+
|
|
202
|
+
// ── Test A: empty string subject → blocking error ────────────────────────────
|
|
203
|
+
|
|
204
|
+
test('exits 1 and report.errors contains empty_commit_subject when commit.subject is an empty string', () => {
|
|
205
|
+
// Arrange
|
|
206
|
+
const wb = makeWB([
|
|
207
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { commit: { subject: '' } })]),
|
|
208
|
+
]);
|
|
209
|
+
writeFixture(wb);
|
|
210
|
+
|
|
211
|
+
// Act
|
|
212
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
213
|
+
|
|
214
|
+
// Assert
|
|
215
|
+
expect(exitCode).toBe(1);
|
|
216
|
+
const errorEntry = report.errors.find(e => e.category === 'empty_commit_subject');
|
|
217
|
+
expect(errorEntry).toBeDefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── Test B: whitespace-only subject → blocking error ─────────────────────────
|
|
221
|
+
|
|
222
|
+
test('exits 1 and report.errors contains empty_commit_subject when commit.subject is whitespace only', () => {
|
|
223
|
+
// Arrange
|
|
224
|
+
const wb = makeWB([
|
|
225
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { commit: { subject: ' ' } })]),
|
|
226
|
+
]);
|
|
227
|
+
writeFixture(wb);
|
|
228
|
+
|
|
229
|
+
// Act
|
|
230
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
231
|
+
|
|
232
|
+
// Assert
|
|
233
|
+
expect(exitCode).toBe(1);
|
|
234
|
+
const errorEntry = report.errors.find(e => e.category === 'empty_commit_subject');
|
|
235
|
+
expect(errorEntry).toBeDefined();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── Test C: valid non-empty subject → no error ───────────────────────────────
|
|
239
|
+
|
|
240
|
+
test('exits 0 and no empty_commit_subject error is reported when commit.subject is a non-empty string', () => {
|
|
241
|
+
// Arrange
|
|
242
|
+
const wb = makeWB([
|
|
243
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { commit: { subject: 'feat: do something' } })]),
|
|
244
|
+
]);
|
|
245
|
+
writeFixture(wb);
|
|
246
|
+
|
|
247
|
+
// Act
|
|
248
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
249
|
+
|
|
250
|
+
// Assert
|
|
251
|
+
expect(exitCode).toBe(0);
|
|
252
|
+
expect(report.errors.find(e => e.category === 'empty_commit_subject')).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
describe('wb-validate.js — check 18 — missing grouping rationale (missing_grouping_rationale)', () => {
|
|
259
|
+
|
|
260
|
+
// ── Test A: empty string rationale → blocking error ──────────────────────────
|
|
261
|
+
|
|
262
|
+
test('exits 1 and report.errors contains missing_grouping_rationale when groupingRationale is an empty string', () => {
|
|
263
|
+
// Arrange
|
|
264
|
+
const wb = makeWB([
|
|
265
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { groupingRationale: '' })]),
|
|
266
|
+
]);
|
|
267
|
+
writeFixture(wb);
|
|
268
|
+
|
|
269
|
+
// Act
|
|
270
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
271
|
+
|
|
272
|
+
// Assert
|
|
273
|
+
expect(exitCode).toBe(1);
|
|
274
|
+
const errorEntry = report.errors.find(e => e.category === 'missing_grouping_rationale');
|
|
275
|
+
expect(errorEntry).toBeDefined();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ── Test B: whitespace-only rationale → blocking error ───────────────────────
|
|
279
|
+
|
|
280
|
+
test('exits 1 and report.errors contains missing_grouping_rationale when groupingRationale is whitespace only', () => {
|
|
281
|
+
// Arrange
|
|
282
|
+
const wb = makeWB([
|
|
283
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { groupingRationale: ' ' })]),
|
|
284
|
+
]);
|
|
285
|
+
writeFixture(wb);
|
|
286
|
+
|
|
287
|
+
// Act
|
|
288
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
289
|
+
|
|
290
|
+
// Assert
|
|
291
|
+
expect(exitCode).toBe(1);
|
|
292
|
+
const errorEntry = report.errors.find(e => e.category === 'missing_grouping_rationale');
|
|
293
|
+
expect(errorEntry).toBeDefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ── Test C: valid non-empty rationale → no error ─────────────────────────────
|
|
297
|
+
|
|
298
|
+
test('exits 0 and no missing_grouping_rationale error is reported when groupingRationale is a non-empty string', () => {
|
|
299
|
+
// Arrange
|
|
300
|
+
const wb = makeWB([
|
|
301
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { groupingRationale: 'Single responsibility' })]),
|
|
302
|
+
]);
|
|
303
|
+
writeFixture(wb);
|
|
304
|
+
|
|
305
|
+
// Act
|
|
306
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
307
|
+
|
|
308
|
+
// Assert
|
|
309
|
+
expect(exitCode).toBe(0);
|
|
310
|
+
expect(report.errors.find(e => e.category === 'missing_grouping_rationale')).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
315
|
+
// W2 contract-pinning — check 15 details field name must be agentMinutes
|
|
316
|
+
//
|
|
317
|
+
// Pins the field name contract between wb-validate.js (producer) and pm-phase2.js
|
|
318
|
+
// (consumer). If wb-validate.js ever renames the field, this test breaks before
|
|
319
|
+
// the consumer silently returns undefined at runtime.
|
|
320
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
describe('wb-validate.js — check 15 — details field contract: agentMinutes (not estimateMinutes)', () => {
|
|
323
|
+
|
|
324
|
+
test('duration_warning entry details contains agentMinutes (not estimateMinutes) when task exceeds target band', () => {
|
|
325
|
+
// Arrange: task with 25 min estimate → warning band (21-30 min)
|
|
326
|
+
const wb = makeWB([
|
|
327
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 25, tokens: 5000 } })]),
|
|
328
|
+
]);
|
|
329
|
+
writeFixture(wb);
|
|
330
|
+
|
|
331
|
+
// Act
|
|
332
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
333
|
+
|
|
334
|
+
// Assert
|
|
335
|
+
expect(exitCode).toBe(0); // warning band is non-blocking
|
|
336
|
+
const warning = (report.warnings || []).find(w => w.category === 'duration_warning');
|
|
337
|
+
expect(warning).toBeDefined();
|
|
338
|
+
expect(warning.details).toHaveProperty('agentMinutes', 25);
|
|
339
|
+
expect(warning.details).not.toHaveProperty('estimateMinutes');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('split_required entry details contains agentMinutes (not estimateMinutes) when task exceeds split band', () => {
|
|
343
|
+
// Arrange: task with 35 min estimate → split_required band (>30 min)
|
|
344
|
+
const wb = makeWB([
|
|
345
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 35, tokens: 5000 } })]),
|
|
346
|
+
]);
|
|
347
|
+
writeFixture(wb);
|
|
348
|
+
|
|
349
|
+
// Act
|
|
350
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
351
|
+
|
|
352
|
+
// Assert
|
|
353
|
+
expect(exitCode).toBe(1); // split_required is a blocking error
|
|
354
|
+
const error = report.errors.find(e => e.category === 'split_required');
|
|
355
|
+
expect(error).toBeDefined();
|
|
356
|
+
expect(error.details).toHaveProperty('agentMinutes', 35);
|
|
357
|
+
expect(error.details).not.toHaveProperty('estimateMinutes');
|
|
358
|
+
});
|
|
359
|
+
});
|