@dtlabs/ai-toolkit 0.10.0 → 0.10.1
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/CHANGELOG.md +8 -0
- package/bin/cli.js +16 -0
- package/package.json +7 -2
- package/.claude/.ai-toolkit-version +0 -1
- package/.claude/scripts/tests/fixtures/ac-table-format.md +0 -48
- package/.claude/scripts/tests/fixtures/test-feature/TEST-001-Work-Breakdown.json +0 -234
- package/.claude/scripts/tests/fixtures/test-feature/TEST-001-semantic-output.json +0 -4
- package/.claude/scripts/tests/fixtures/test-feature/feature.md +0 -45
- package/.claude/scripts/tests/fixtures/wb-invalid-bad-domain.json +0 -39
- package/.claude/scripts/tests/fixtures/wb-invalid-cycle.json +0 -61
- package/.claude/scripts/tests/fixtures/wb-invalid-duplicate-id.json +0 -72
- package/.claude/scripts/tests/fixtures/wb-invalid-missing-field.json +0 -38
- package/.claude/scripts/tests/fixtures/wb-valid.json +0 -189
- package/.claude/scripts/tests/helpers/buildWaves.js +0 -21
- package/.claude/scripts/tests/helpers/pm-phase2-flow.js +0 -29
- package/.claude/scripts/tests/helpers/pm-phase2-gate2.js +0 -55
- package/.claude/scripts/tests/helpers/pm-phase2-ledger.js +0 -108
- package/.claude/scripts/tests/installer.scripts-distribution.test.js +0 -131
- package/.claude/scripts/tests/pm-phase2.gate2-payload.test.js +0 -353
- package/.claude/scripts/tests/pm-phase2.ledger-transitions.test.js +0 -346
- package/.claude/scripts/tests/pm-phase2.orchestration-flow.test.js +0 -276
- package/.claude/scripts/tests/verify-us-01-t03.js +0 -288
- package/.claude/scripts/tests/verify-us-03-t04.js +0 -262
- package/.claude/scripts/tests/wb-render.checks-md-csv-deps.test.js +0 -337
- package/.claude/scripts/tests/wb-render.checks-sanitize-commit-paths.test.js +0 -352
- package/.claude/scripts/tests/wb-render.regression-pm-phase3-csv.test.js +0 -219
- package/.claude/scripts/tests/wb-validate.checks-1-7.test.js +0 -330
- package/.claude/scripts/tests/wb-validate.checks-11.test.js +0 -166
- package/.claude/scripts/tests/wb-validate.checks-12-14.test.js +0 -222
- package/.claude/scripts/tests/wb-validate.checks-15-18.test.js +0 -359
- package/.claude/scripts/tests/wb-validate.checks-19-21.test.js +0 -362
- package/.claude/scripts/tests/wb-validate.checks-22-23-report.test.js +0 -297
- package/.claude/scripts/tests/wb-validate.checks-8-10.test.js +0 -243
- package/.claude/settings.json +0 -5
|
@@ -1,362 +0,0 @@
|
|
|
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
|
-
const AC_FIXTURE = path.join(FIXTURES, 'ac-table-format.md');
|
|
11
|
-
|
|
12
|
-
function runValidator(wbPath, reqPath) {
|
|
13
|
-
const args = [VALIDATE, wbPath];
|
|
14
|
-
if (reqPath) args.push(reqPath);
|
|
15
|
-
const result = spawnSync('node', args, { encoding: 'utf8' });
|
|
16
|
-
let report = null;
|
|
17
|
-
try { report = JSON.parse(result.stdout); } catch (_) {}
|
|
18
|
-
return { exitCode: result.status, report, stderr: result.stderr };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// ── Shared temp-file lifecycle ─────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
let tmpFile, tmpReqFile;
|
|
24
|
-
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
tmpFile = path.join(os.tmpdir(), `wb-t20-${Date.now()}.json`);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
try { fs.unlinkSync(tmpFile); } catch (_) {}
|
|
31
|
-
if (tmpReqFile) { try { fs.unlinkSync(tmpReqFile); } catch (_) {} tmpReqFile = null; }
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
// ── Domain builders ────────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
function makeTask(id, acIds = [], dependsOn = []) {
|
|
37
|
-
return {
|
|
38
|
-
id,
|
|
39
|
-
title: `Task ${id}`,
|
|
40
|
-
outcome: 'Done',
|
|
41
|
-
domain: 'BE',
|
|
42
|
-
agentType: 'developer-backend',
|
|
43
|
-
dependsOn,
|
|
44
|
-
acceptanceCriteria: acIds,
|
|
45
|
-
verification: { commands: ['node --version'] },
|
|
46
|
-
estimate: { agentMinutes: 10, tokens: 5000 },
|
|
47
|
-
outputCount: 1,
|
|
48
|
-
groupingRationale: 'Single',
|
|
49
|
-
commit: { subject: `feat: ${id}` },
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function makePhase(id, tasks) {
|
|
54
|
-
return { id, type: 'user-story', title: `Phase ${id}`, commit: `feat(${id}): done`, tasks };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function makeWB(phases) { return { schemaVersion: 2, phases }; }
|
|
58
|
-
|
|
59
|
-
function writeWB(obj) { fs.writeFileSync(tmpFile, JSON.stringify(obj, null, 2)); return tmpFile; }
|
|
60
|
-
|
|
61
|
-
// ── Requirements fixture builder ───────────────────────────────────────────────
|
|
62
|
-
// Generates a minimal requirements Markdown file the AC parser can consume.
|
|
63
|
-
//
|
|
64
|
-
// ucPriorities — { 'UC-01': 'Must', 'UC-02': 'Should', ... }
|
|
65
|
-
// acRows — [['AC-01', 'UC-01'], ['AC-02', 'All UCs'], ...]
|
|
66
|
-
//
|
|
67
|
-
// The resulting string matches the parser's expectations:
|
|
68
|
-
// • ### UC-NN: … heading followed by a | Priority | <value> | row
|
|
69
|
-
// • ## 7. Acceptance Criteria section with a | ID | Criterion | Related UC | table
|
|
70
|
-
|
|
71
|
-
function makeReqMd(ucPriorities, acRows) {
|
|
72
|
-
const ucSections = Object.entries(ucPriorities)
|
|
73
|
-
.map(([ucId, pri]) =>
|
|
74
|
-
`### ${ucId}: Description\n| Field | Value |\n|-------|-------|\n| Priority | ${pri} |\n`
|
|
75
|
-
)
|
|
76
|
-
.join('\n');
|
|
77
|
-
const acTable =
|
|
78
|
-
'| ID | Criterion | Related UC |\n|----|-----------|------------|\n' +
|
|
79
|
-
acRows.map(([id, relUc]) => `| ${id} | A criterion | ${relUc} |`).join('\n');
|
|
80
|
-
return `## 5. Use Cases\n\n${ucSections}\n## 7. Acceptance Criteria\n\n${acTable}\n`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
-
// The AC fixture (ac-table-format.md) defines:
|
|
85
|
-
// UC-01 (Must), UC-02 (Should)
|
|
86
|
-
// AC-01: UC-01 → priority Must, allowedUserStories: ['US-01']
|
|
87
|
-
// AC-02: UC-01 → priority Must, allowedUserStories: ['US-01']
|
|
88
|
-
// AC-03: UC-01, UC-02 → priority Must (strongest), allowedUserStories: ['US-01','US-02']
|
|
89
|
-
// AC-04: UC-02 → priority Should, allowedUserStories: ['US-02']
|
|
90
|
-
// AC-05: All UCs → priority Must (strongest overall), unscoped: true
|
|
91
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
describe('wb-validate.js — check 19 — AC existence (ac_not_found)', () => {
|
|
94
|
-
|
|
95
|
-
// ── Test A: all referenced AC IDs exist in the fixture ───────────────────────
|
|
96
|
-
|
|
97
|
-
test('exits 0 and report contains no ac_not_found errors when all referenced ACs exist in the requirements table', () => {
|
|
98
|
-
// Arrange — cover all four Must-priority ACs from the fixture so check 21 also passes:
|
|
99
|
-
// AC-01 (Must, US-01), AC-02 (Must, US-01), AC-03 (Must, US-01+US-02), AC-05 (Must, unscoped)
|
|
100
|
-
const wb = makeWB([
|
|
101
|
-
makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['AC-01', 'AC-02', 'AC-03', 'AC-05'])]),
|
|
102
|
-
]);
|
|
103
|
-
writeWB(wb);
|
|
104
|
-
|
|
105
|
-
// Act
|
|
106
|
-
const { exitCode, report } = runValidator(tmpFile, AC_FIXTURE);
|
|
107
|
-
|
|
108
|
-
// Assert
|
|
109
|
-
expect(exitCode).toBe(0);
|
|
110
|
-
expect(report.errors.filter(e => e.category === 'ac_not_found')).toHaveLength(0);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ── Test B: referenced AC ID is absent from the requirements table ─────────────
|
|
114
|
-
|
|
115
|
-
test('exits 1 and report.errors contains ac_not_found with details.acId "AC-99" when task references an AC that does not exist in the requirements table', () => {
|
|
116
|
-
// Arrange — AC-99 does not appear in the fixture
|
|
117
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['AC-99'])])]);
|
|
118
|
-
writeWB(wb);
|
|
119
|
-
|
|
120
|
-
// Act
|
|
121
|
-
const { exitCode, report } = runValidator(tmpFile, AC_FIXTURE);
|
|
122
|
-
|
|
123
|
-
// Assert
|
|
124
|
-
expect(exitCode).toBe(1);
|
|
125
|
-
const error = report.errors.find(
|
|
126
|
-
e => e.category === 'ac_not_found' && e.details.acId === 'AC-99'
|
|
127
|
-
);
|
|
128
|
-
expect(error).toBeDefined();
|
|
129
|
-
expect(error.details.acId).toBe('AC-99');
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// ── Test C: empty acceptanceCriteria — no ac_not_found is raised ──────────────
|
|
133
|
-
|
|
134
|
-
test('exits 0 and report contains no ac_not_found errors when acceptanceCriteria is an empty array and requirements has no Must-priority ACs', () => {
|
|
135
|
-
// Arrange — custom req with a single Should-priority AC so check 21 does not block
|
|
136
|
-
tmpReqFile = path.join(os.tmpdir(), `req-t20-${Date.now()}.md`);
|
|
137
|
-
fs.writeFileSync(tmpReqFile, makeReqMd({ 'UC-01': 'Should' }, [['AC-01', 'UC-01']]));
|
|
138
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', [])])]);
|
|
139
|
-
writeWB(wb);
|
|
140
|
-
|
|
141
|
-
// Act
|
|
142
|
-
const { exitCode, report } = runValidator(tmpFile, tmpReqFile);
|
|
143
|
-
|
|
144
|
-
// Assert
|
|
145
|
-
expect(exitCode).toBe(0);
|
|
146
|
-
expect(report.errors.filter(e => e.category === 'ac_not_found')).toHaveLength(0);
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
describe('wb-validate.js — check 20 — AC scope validation (ac_wrong_us)', () => {
|
|
153
|
-
|
|
154
|
-
// ── Test A: AC referenced by the correct phase ────────────────────────────────
|
|
155
|
-
|
|
156
|
-
test('exits 0 and report contains no ac_wrong_us errors when AC scoped to UC-01 is referenced by a task in the US-01 phase', () => {
|
|
157
|
-
// Arrange — custom req: one Must AC scoped to UC-01; task is in the matching US-01 phase
|
|
158
|
-
tmpReqFile = path.join(os.tmpdir(), `req-t20-${Date.now()}.md`);
|
|
159
|
-
fs.writeFileSync(tmpReqFile, makeReqMd({ 'UC-01': 'Must' }, [['AC-01', 'UC-01']]));
|
|
160
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['AC-01'])])]);
|
|
161
|
-
writeWB(wb);
|
|
162
|
-
|
|
163
|
-
// Act
|
|
164
|
-
const { exitCode, report } = runValidator(tmpFile, tmpReqFile);
|
|
165
|
-
|
|
166
|
-
// Assert
|
|
167
|
-
expect(exitCode).toBe(0);
|
|
168
|
-
expect(report.errors.filter(e => e.category === 'ac_wrong_us')).toHaveLength(0);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// ── Test B: AC referenced by the wrong phase ──────────────────────────────────
|
|
172
|
-
|
|
173
|
-
test('exits 1 and report.errors contains ac_wrong_us when AC scoped to UC-01 is referenced by a task in the US-02 phase', () => {
|
|
174
|
-
// Arrange — custom req: UC-01 (Must), UC-02 (Should), AC-01 scoped to UC-01 → allowedUserStories: ['US-01']
|
|
175
|
-
// The task is in US-02; AC-01 IS referenced so check 21 (must_ac_uncovered) does not fire,
|
|
176
|
-
// but the scope check (check 20) reports ac_wrong_us
|
|
177
|
-
tmpReqFile = path.join(os.tmpdir(), `req-t20-${Date.now()}.md`);
|
|
178
|
-
fs.writeFileSync(tmpReqFile, makeReqMd({ 'UC-01': 'Must', 'UC-02': 'Should' }, [['AC-01', 'UC-01']]));
|
|
179
|
-
const wb = makeWB([makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['AC-01'])])]);
|
|
180
|
-
writeWB(wb);
|
|
181
|
-
|
|
182
|
-
// Act
|
|
183
|
-
const { exitCode, report } = runValidator(tmpFile, tmpReqFile);
|
|
184
|
-
|
|
185
|
-
// Assert
|
|
186
|
-
expect(exitCode).toBe(1);
|
|
187
|
-
const error = report.errors.find(e => e.category === 'ac_wrong_us');
|
|
188
|
-
expect(error).toBeDefined();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// ── Test C: unscoped AC (All UCs) — no ac_wrong_us regardless of phase ────────
|
|
192
|
-
|
|
193
|
-
test('exits 0 and report contains no ac_wrong_us errors when an unscoped AC (All UCs) is referenced by a task in any phase', () => {
|
|
194
|
-
// Arrange — custom req: UC-01 (Must), UC-02 (Should), AC-01 scoped to "All UCs"
|
|
195
|
-
// (unscoped = true, priority Must as strongest of Must+Should).
|
|
196
|
-
// Task is in US-02; unscoped ACs are never flagged for wrong phase.
|
|
197
|
-
// AC-01 is referenced → check 21 passes.
|
|
198
|
-
tmpReqFile = path.join(os.tmpdir(), `req-t20-${Date.now()}.md`);
|
|
199
|
-
fs.writeFileSync(tmpReqFile, makeReqMd({ 'UC-01': 'Must', 'UC-02': 'Should' }, [['AC-01', 'All UCs']]));
|
|
200
|
-
const wb = makeWB([makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['AC-01'])])]);
|
|
201
|
-
writeWB(wb);
|
|
202
|
-
|
|
203
|
-
// Act
|
|
204
|
-
const { exitCode, report } = runValidator(tmpFile, tmpReqFile);
|
|
205
|
-
|
|
206
|
-
// Assert
|
|
207
|
-
expect(exitCode).toBe(0);
|
|
208
|
-
expect(report.errors.filter(e => e.category === 'ac_wrong_us')).toHaveLength(0);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
// ── Test D: multi-UC AC referenced by one of its allowed phases ───────────────
|
|
212
|
-
|
|
213
|
-
test('exits 0 and report contains no ac_wrong_us errors when a multi-UC AC scoped to UC-01 and UC-02 is referenced by a task in the US-02 phase', () => {
|
|
214
|
-
// Arrange — custom req: UC-01 (Must), UC-02 (Should), AC-01 scoped to "UC-01, UC-02"
|
|
215
|
-
// → allowedUserStories: ['US-01', 'US-02'], priority Must (strongest of Must+Should).
|
|
216
|
-
// Task is in US-02, which is in allowedUserStories → scope is valid.
|
|
217
|
-
// AC-01 is referenced → check 21 passes.
|
|
218
|
-
tmpReqFile = path.join(os.tmpdir(), `req-t20-${Date.now()}.md`);
|
|
219
|
-
fs.writeFileSync(tmpReqFile, makeReqMd({ 'UC-01': 'Must', 'UC-02': 'Should' }, [['AC-01', 'UC-01, UC-02']]));
|
|
220
|
-
const wb = makeWB([makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['AC-01'])])]);
|
|
221
|
-
writeWB(wb);
|
|
222
|
-
|
|
223
|
-
// Act
|
|
224
|
-
const { exitCode, report } = runValidator(tmpFile, tmpReqFile);
|
|
225
|
-
|
|
226
|
-
// Assert
|
|
227
|
-
expect(exitCode).toBe(0);
|
|
228
|
-
expect(report.errors.filter(e => e.category === 'ac_wrong_us')).toHaveLength(0);
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
233
|
-
|
|
234
|
-
describe('wb-validate.js — check 21 — Must AC coverage (must_ac_uncovered)', () => {
|
|
235
|
-
|
|
236
|
-
// ── Test A: Must AC is covered by a task ──────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
test('exits 0 and report contains no must_ac_uncovered errors when every Must-priority AC is referenced by at least one task', () => {
|
|
239
|
-
// Arrange — custom req: UC-01 (Must), AC-01 (Must). Task references AC-01.
|
|
240
|
-
tmpReqFile = path.join(os.tmpdir(), `req-t20-${Date.now()}.md`);
|
|
241
|
-
fs.writeFileSync(tmpReqFile, makeReqMd({ 'UC-01': 'Must' }, [['AC-01', 'UC-01']]));
|
|
242
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['AC-01'])])]);
|
|
243
|
-
writeWB(wb);
|
|
244
|
-
|
|
245
|
-
// Act
|
|
246
|
-
const { exitCode, report } = runValidator(tmpFile, tmpReqFile);
|
|
247
|
-
|
|
248
|
-
// Assert
|
|
249
|
-
expect(exitCode).toBe(0);
|
|
250
|
-
expect(report.errors.filter(e => e.category === 'must_ac_uncovered')).toHaveLength(0);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
// ── Test B: Must AC is not covered by any task ────────────────────────────────
|
|
254
|
-
|
|
255
|
-
test('exits 1 and report.errors contains must_ac_uncovered with details.acId "AC-01" when a Must-priority AC is not referenced by any task', () => {
|
|
256
|
-
// Arrange — custom req: UC-01 (Must), AC-01 (Must). Task has empty acceptanceCriteria.
|
|
257
|
-
tmpReqFile = path.join(os.tmpdir(), `req-t20-${Date.now()}.md`);
|
|
258
|
-
fs.writeFileSync(tmpReqFile, makeReqMd({ 'UC-01': 'Must' }, [['AC-01', 'UC-01']]));
|
|
259
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', [])])]);
|
|
260
|
-
writeWB(wb);
|
|
261
|
-
|
|
262
|
-
// Act
|
|
263
|
-
const { exitCode, report } = runValidator(tmpFile, tmpReqFile);
|
|
264
|
-
|
|
265
|
-
// Assert
|
|
266
|
-
expect(exitCode).toBe(1);
|
|
267
|
-
const error = report.errors.find(e => e.category === 'must_ac_uncovered');
|
|
268
|
-
expect(error).toBeDefined();
|
|
269
|
-
expect(error.details.acId).toBe('AC-01');
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
// ── Test C: AC checks skipped entirely when no requirements path is given ──────
|
|
273
|
-
|
|
274
|
-
test('exits 0 and report contains no AC-related errors when validator is invoked without a requirements path argument', () => {
|
|
275
|
-
// Arrange — WB with a task referencing a fake AC ID (AC-99).
|
|
276
|
-
// Without a requirements path, all three AC checks (19, 20, 21) are skipped.
|
|
277
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['AC-99'])])]);
|
|
278
|
-
writeWB(wb);
|
|
279
|
-
|
|
280
|
-
// Act — no second argument; requirementsPath is undefined → acMap is null → checks skipped
|
|
281
|
-
const { exitCode, report } = runValidator(tmpFile);
|
|
282
|
-
|
|
283
|
-
// Assert
|
|
284
|
-
expect(exitCode).toBe(0);
|
|
285
|
-
const acErrors = report.errors.filter(e =>
|
|
286
|
-
e.category === 'ac_not_found' ||
|
|
287
|
-
e.category === 'ac_wrong_us' ||
|
|
288
|
-
e.category === 'must_ac_uncovered'
|
|
289
|
-
);
|
|
290
|
-
expect(acErrors).toHaveLength(0);
|
|
291
|
-
});
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
295
|
-
// I5 contract-pinning — parseAcTable fatal errors must use exit 2 (not exit 1)
|
|
296
|
-
//
|
|
297
|
-
// Exit 1 means "validation completed, JSON report on stdout".
|
|
298
|
-
// Exit 2 means "fatal/parse error, no JSON on stdout (stderr only)".
|
|
299
|
-
//
|
|
300
|
-
// Before fix: two parseAcTable paths used exit(1) without emitting a JSON
|
|
301
|
-
// report — pm-phase2 would see empty stdout and throw "wb-validate returned
|
|
302
|
-
// empty stdout" masking the real cause. Fixed to exit(2).
|
|
303
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
304
|
-
|
|
305
|
-
describe('wb-validate.js — parseAcTable fatal errors exit with code 2 and no JSON on stdout (I5)', () => {
|
|
306
|
-
|
|
307
|
-
function writeTmpReq(content) {
|
|
308
|
-
tmpReqFile = path.join(os.tmpdir(), `req-i5-${Date.now()}.md`);
|
|
309
|
-
fs.writeFileSync(tmpReqFile, content);
|
|
310
|
-
return tmpReqFile;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
test('exits 2 and emits only stderr (no JSON on stdout) when AC row references a UC that does not exist in the requirements', () => {
|
|
314
|
-
// Arrange: requirements declares UC-01 but AC-01 references UC-02 (missing)
|
|
315
|
-
const reqContent = makeReqMd(
|
|
316
|
-
{ 'UC-01': 'Must' },
|
|
317
|
-
[['AC-01', 'UC-02']], // UC-02 not declared → parseAcTable exit(1) before fix, exit(2) after
|
|
318
|
-
);
|
|
319
|
-
writeTmpReq(reqContent);
|
|
320
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['AC-01'])])]);
|
|
321
|
-
writeWB(wb);
|
|
322
|
-
|
|
323
|
-
// Act
|
|
324
|
-
const result = spawnSync('node', [VALIDATE, tmpFile, tmpReqFile], { encoding: 'utf8' });
|
|
325
|
-
|
|
326
|
-
// Assert: exit 2 = fatal/parse error; stdout must NOT be parseable JSON
|
|
327
|
-
expect(result.status).toBe(2);
|
|
328
|
-
expect(result.stderr).toMatch(/does not exist/i);
|
|
329
|
-
let parsed = null;
|
|
330
|
-
try { parsed = JSON.parse(result.stdout); } catch (_) {}
|
|
331
|
-
expect(parsed).toBeNull();
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
test('exits 2 and emits only stderr (no JSON on stdout) when a UC heading exists but has no Priority row (parser never adds it to the map, so the "does not exist" guard fires)', () => {
|
|
335
|
-
// Arrange: UC-01 heading exists but no "| Priority | ... |" metadata row.
|
|
336
|
-
// ucPriorityMap never adds UC-01 → !has(ucRef) → exit 2, "does not exist".
|
|
337
|
-
// Note: the !get(ucRef) ("no declared priority") guard is structurally unreachable
|
|
338
|
-
// by construction — the parser only adds a UC to the map when it finds a non-empty
|
|
339
|
-
// priority value via \S+ regex, so has(ucRef) being true implies get(ucRef) is truthy.
|
|
340
|
-
// Both exit(1)→exit(2) fixes are correctness guards; this test verifies that the
|
|
341
|
-
// "missing priority" scenario always resolves through exit 2 in practice.
|
|
342
|
-
const reqContent =
|
|
343
|
-
'## 5. Use Cases\n\n' +
|
|
344
|
-
'### UC-01: Description\n| Field | Value |\n|-------|-------|\n| Other | x |\n\n' +
|
|
345
|
-
'## 7. Acceptance Criteria\n\n' +
|
|
346
|
-
'| ID | Criterion | Related UC |\n|----|-----------|------------|\n' +
|
|
347
|
-
'| AC-01 | A criterion | UC-01 |\n';
|
|
348
|
-
writeTmpReq(reqContent);
|
|
349
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['AC-01'])])]);
|
|
350
|
-
writeWB(wb);
|
|
351
|
-
|
|
352
|
-
// Act
|
|
353
|
-
const result = spawnSync('node', [VALIDATE, tmpFile, tmpReqFile], { encoding: 'utf8' });
|
|
354
|
-
|
|
355
|
-
// Assert: exit 2 = fatal/parse error; stdout must NOT be parseable JSON
|
|
356
|
-
expect(result.status).toBe(2);
|
|
357
|
-
expect(result.stderr).toMatch(/does not exist/i);
|
|
358
|
-
let parsed = null;
|
|
359
|
-
try { parsed = JSON.parse(result.stdout); } catch (_) {}
|
|
360
|
-
expect(parsed).toBeNull();
|
|
361
|
-
});
|
|
362
|
-
});
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
const { spawnSync } = require('child_process')
|
|
3
|
-
const path = require('path')
|
|
4
|
-
const fs = require('fs')
|
|
5
|
-
const os = require('os')
|
|
6
|
-
|
|
7
|
-
const VALIDATE = path.resolve(__dirname, '../wb-validate.js')
|
|
8
|
-
const FIXTURES = path.resolve(__dirname, 'fixtures')
|
|
9
|
-
|
|
10
|
-
function runValidator(wbPath, reqPath) {
|
|
11
|
-
const args = [VALIDATE, wbPath]
|
|
12
|
-
if (reqPath) args.push(reqPath)
|
|
13
|
-
const result = spawnSync('node', args, { encoding: 'utf8' })
|
|
14
|
-
let report = null
|
|
15
|
-
try { report = JSON.parse(result.stdout) } catch (_) {}
|
|
16
|
-
return { exitCode: result.status, report, stderr: result.stderr, rawStdout: result.stdout }
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
let tmpFile
|
|
20
|
-
beforeEach(() => { tmpFile = path.join(os.tmpdir(), `wb-t21-${Date.now()}.json`) })
|
|
21
|
-
afterEach(() => { try { fs.unlinkSync(tmpFile) } catch (_) {} })
|
|
22
|
-
function writeFixture(obj) { fs.writeFileSync(tmpFile, JSON.stringify(obj, null, 2)); return tmpFile }
|
|
23
|
-
|
|
24
|
-
function makeTask(id, overrides = {}) {
|
|
25
|
-
return {
|
|
26
|
-
id, title: `Task ${id}`, outcome: 'Done', domain: 'BE',
|
|
27
|
-
agentType: 'developer-backend', dependsOn: [], acceptanceCriteria: [],
|
|
28
|
-
verification: { commands: ['node --version'] },
|
|
29
|
-
estimate: { agentMinutes: 10, tokens: 5000 },
|
|
30
|
-
outputCount: 1, groupingRationale: 'Single', commit: { subject: `feat: ${id}` },
|
|
31
|
-
...overrides,
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
function makePhase(id, tasks, overrides = {}) {
|
|
35
|
-
return { id, type: 'user-story', title: `Phase ${id}`, commit: `feat(${id}): done`, tasks, ...overrides }
|
|
36
|
-
}
|
|
37
|
-
function makeWB(phases) { return { schemaVersion: 2, phases } }
|
|
38
|
-
|
|
39
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
-
// W1 regression guard — checks 22 and 23 must not crash on missing/non-array phases
|
|
41
|
-
// Before fix: both checks iterated wb.phases directly → TypeError: not iterable.
|
|
42
|
-
// After fix: both use the guarded `phases` constant (same as checks 3-21).
|
|
43
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
-
|
|
45
|
-
describe('wb-validate.js — checks 22 and 23 — no crash when wb.phases is absent or non-array', () => {
|
|
46
|
-
|
|
47
|
-
test('exits 0 and emits valid JSON when wb.phases is absent (missing field)', () => {
|
|
48
|
-
// Arrange: valid schemaVersion but no phases field at all
|
|
49
|
-
const wb = { schemaVersion: 2 }
|
|
50
|
-
writeFixture(wb)
|
|
51
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
52
|
-
// Assert: no TypeError — exits cleanly and produces a structured report
|
|
53
|
-
expect(exitCode).toBe(0)
|
|
54
|
-
expect(report).not.toBeNull()
|
|
55
|
-
expect(Array.isArray(report.errors)).toBe(true)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('exits 0 and emits valid JSON when wb.phases is null', () => {
|
|
59
|
-
// Arrange
|
|
60
|
-
const wb = { schemaVersion: 2, phases: null }
|
|
61
|
-
writeFixture(wb)
|
|
62
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
63
|
-
expect(exitCode).toBe(0)
|
|
64
|
-
expect(report).not.toBeNull()
|
|
65
|
-
expect(Array.isArray(report.errors)).toBe(true)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test('exits 0 and emits valid JSON when wb.phases is a non-array object', () => {
|
|
69
|
-
// Arrange
|
|
70
|
-
const wb = { schemaVersion: 2, phases: { id: 'US-01' } }
|
|
71
|
-
writeFixture(wb)
|
|
72
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
73
|
-
expect(exitCode).toBe(0)
|
|
74
|
-
expect(report).not.toBeNull()
|
|
75
|
-
expect(Array.isArray(report.errors)).toBe(true)
|
|
76
|
-
})
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
-
// Check 22 — invalid text field characters (invalid_text_field_chars)
|
|
81
|
-
// Forbidden characters: | (pipe), \r (CR), \n (LF)
|
|
82
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
-
|
|
84
|
-
describe('wb-validate.js — check 22 — invalid text field characters (invalid_text_field_chars)', () => {
|
|
85
|
-
|
|
86
|
-
// ── Test A: pipe in task title ────────────────────────────────────────────────
|
|
87
|
-
|
|
88
|
-
test('exits 1 and report.errors contains invalid_text_field_chars with field "title" when task title contains a pipe character', () => {
|
|
89
|
-
// Arrange
|
|
90
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', { title: 'Task with | pipe' })])])
|
|
91
|
-
writeFixture(wb)
|
|
92
|
-
|
|
93
|
-
// Act
|
|
94
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
95
|
-
|
|
96
|
-
// Assert
|
|
97
|
-
expect(exitCode).toBe(1)
|
|
98
|
-
const error = report.errors.find(e => e.category === 'invalid_text_field_chars')
|
|
99
|
-
expect(error).toBeDefined()
|
|
100
|
-
expect(error.field).toBe('title')
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
// ── Test B: LF in commit.subject ──────────────────────────────────────────────
|
|
104
|
-
|
|
105
|
-
test('exits 1 and report.errors contains invalid_text_field_chars with field "commit.subject" when task commit subject contains a newline character', () => {
|
|
106
|
-
// Arrange
|
|
107
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01', { commit: { subject: 'feat: line1\nline2' } })])])
|
|
108
|
-
writeFixture(wb)
|
|
109
|
-
|
|
110
|
-
// Act
|
|
111
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
112
|
-
|
|
113
|
-
// Assert
|
|
114
|
-
expect(exitCode).toBe(1)
|
|
115
|
-
const error = report.errors.find(e => e.category === 'invalid_text_field_chars')
|
|
116
|
-
expect(error).toBeDefined()
|
|
117
|
-
expect(error.field).toBe('commit.subject')
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
// ── Test C: CR in phase title ─────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
test('exits 1 and report.errors contains invalid_text_field_chars with field "title" and phaseId set when phase title contains a carriage return character', () => {
|
|
123
|
-
// Arrange
|
|
124
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01')], { title: 'Phase\rTitle' })])
|
|
125
|
-
writeFixture(wb)
|
|
126
|
-
|
|
127
|
-
// Act
|
|
128
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
129
|
-
|
|
130
|
-
// Assert
|
|
131
|
-
expect(exitCode).toBe(1)
|
|
132
|
-
const error = report.errors.find(e => e.category === 'invalid_text_field_chars')
|
|
133
|
-
expect(error).toBeDefined()
|
|
134
|
-
expect(error.field).toBe('title')
|
|
135
|
-
expect(error.phaseId).toBe('US-01')
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
// ── Test D: clean fields — no invalid_text_field_chars error ──────────────────
|
|
139
|
-
|
|
140
|
-
test('exits 0 and report contains no invalid_text_field_chars errors when all text fields contain only valid characters', () => {
|
|
141
|
-
// Arrange — default makeTask and makePhase produce clean strings with no pipe, CR, or LF
|
|
142
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01')])])
|
|
143
|
-
writeFixture(wb)
|
|
144
|
-
|
|
145
|
-
// Act
|
|
146
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
147
|
-
|
|
148
|
-
// Assert
|
|
149
|
-
expect(exitCode).toBe(0)
|
|
150
|
-
expect(report.errors.filter(e => e.category === 'invalid_text_field_chars')).toHaveLength(0)
|
|
151
|
-
})
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
155
|
-
// Check 23 — empty phase (empty_phase)
|
|
156
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
describe('wb-validate.js — check 23 — empty phase (empty_phase)', () => {
|
|
159
|
-
|
|
160
|
-
// ── Test A: phase with no tasks ───────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
test('exits 1 and report.errors contains empty_phase when a phase has an empty tasks array', () => {
|
|
163
|
-
// Arrange
|
|
164
|
-
const wb = makeWB([makePhase('US-01', [])])
|
|
165
|
-
writeFixture(wb)
|
|
166
|
-
|
|
167
|
-
// Act
|
|
168
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
169
|
-
|
|
170
|
-
// Assert
|
|
171
|
-
expect(exitCode).toBe(1)
|
|
172
|
-
const error = report.errors.find(e => e.category === 'empty_phase')
|
|
173
|
-
expect(error).toBeDefined()
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
// ── Test B: phase with at least one task ──────────────────────────────────────
|
|
177
|
-
|
|
178
|
-
test('exits 0 and report contains no empty_phase errors when a phase has at least one task', () => {
|
|
179
|
-
// Arrange
|
|
180
|
-
const wb = makeWB([makePhase('US-01', [makeTask('US-01-TASK-BE-01')])])
|
|
181
|
-
writeFixture(wb)
|
|
182
|
-
|
|
183
|
-
// Act
|
|
184
|
-
const { exitCode, report } = runValidator(tmpFile)
|
|
185
|
-
|
|
186
|
-
// Assert
|
|
187
|
-
expect(exitCode).toBe(0)
|
|
188
|
-
expect(report.errors.filter(e => e.category === 'empty_phase')).toHaveLength(0)
|
|
189
|
-
})
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
-
// Report output contract — structure and exit codes
|
|
194
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
195
|
-
|
|
196
|
-
describe('wb-validate.js — report output contract', () => {
|
|
197
|
-
|
|
198
|
-
// ── Test A: valid WB — full report shape ──────────────────────────────────────
|
|
199
|
-
|
|
200
|
-
test('exits 0 and emits a report with all required top-level keys and correct types when the work breakdown is valid', () => {
|
|
201
|
-
// Arrange — use the canonical valid fixture; no requirements path so AC checks are skipped
|
|
202
|
-
// Act
|
|
203
|
-
const { exitCode, report } = runValidator(path.join(FIXTURES, 'wb-valid.json'))
|
|
204
|
-
|
|
205
|
-
// Assert
|
|
206
|
-
expect(exitCode).toBe(0)
|
|
207
|
-
expect(report).toMatchObject({
|
|
208
|
-
valid: true,
|
|
209
|
-
schemaVersion: 2,
|
|
210
|
-
taskCount: expect.any(Number),
|
|
211
|
-
errors: [],
|
|
212
|
-
warnings: expect.any(Array),
|
|
213
|
-
durationBands: expect.objectContaining({
|
|
214
|
-
target: expect.any(Number),
|
|
215
|
-
above_target: expect.any(Number),
|
|
216
|
-
warning: expect.any(Number),
|
|
217
|
-
split_required: expect.any(Number),
|
|
218
|
-
}),
|
|
219
|
-
domainDistribution: expect.objectContaining({
|
|
220
|
-
BE: expect.any(Number),
|
|
221
|
-
FE: expect.any(Number),
|
|
222
|
-
DB: expect.any(Number),
|
|
223
|
-
DevOps: expect.any(Number),
|
|
224
|
-
INFRA: expect.any(Number),
|
|
225
|
-
TEST: expect.any(Number),
|
|
226
|
-
}),
|
|
227
|
-
dependencies: expect.objectContaining({
|
|
228
|
-
taskGraph: expect.any(Object),
|
|
229
|
-
taskCycles: expect.any(Array),
|
|
230
|
-
phaseGraph: expect.any(Object),
|
|
231
|
-
phaseCycles: expect.any(Array),
|
|
232
|
-
phaseWaves: expect.any(Array),
|
|
233
|
-
phaseUnschedulable: expect.any(Array),
|
|
234
|
-
}),
|
|
235
|
-
})
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
// ── Test B: invalid WB — valid = false and errors non-empty ──────────────────
|
|
239
|
-
|
|
240
|
-
test('exits 1 and emits a report with valid=false and at least one error when the work breakdown has a duplicate task ID', () => {
|
|
241
|
-
// Arrange — the duplicate-ID fixture contains INFRA-TASK-INFRA-01 in two phases
|
|
242
|
-
// Act
|
|
243
|
-
const { exitCode, report } = runValidator(path.join(FIXTURES, 'wb-invalid-duplicate-id.json'))
|
|
244
|
-
|
|
245
|
-
// Assert
|
|
246
|
-
expect(exitCode).toBe(1)
|
|
247
|
-
expect(report.valid).toBe(false)
|
|
248
|
-
expect(report.errors.length).toBeGreaterThan(0)
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
// ── Test C: exit code 2 on file not found ─────────────────────────────────────
|
|
252
|
-
|
|
253
|
-
test('exits with code 2 when the specified work breakdown file does not exist', () => {
|
|
254
|
-
// Arrange — path points to a non-existent file
|
|
255
|
-
// Act
|
|
256
|
-
const result = spawnSync('node', [VALIDATE, '/nonexistent/path/wb.json'], { encoding: 'utf8' })
|
|
257
|
-
|
|
258
|
-
// Assert
|
|
259
|
-
expect(result.status).toBe(2)
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
// ── Test D: exit code 2 on invalid JSON ───────────────────────────────────────
|
|
263
|
-
|
|
264
|
-
test('exits with code 2 when the work breakdown file contains invalid JSON', () => {
|
|
265
|
-
// Arrange — write a file with malformed JSON content
|
|
266
|
-
fs.writeFileSync(tmpFile, '{ invalid json')
|
|
267
|
-
|
|
268
|
-
// Act
|
|
269
|
-
const result = spawnSync('node', [VALIDATE, tmpFile], { encoding: 'utf8' })
|
|
270
|
-
|
|
271
|
-
// Assert
|
|
272
|
-
expect(result.status).toBe(2)
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
// ── Test E: exit code 2 on missing path argument ──────────────────────────────
|
|
276
|
-
|
|
277
|
-
test('exits with code 2 when no work breakdown path argument is provided', () => {
|
|
278
|
-
// Arrange: invoke with no arguments
|
|
279
|
-
// Act
|
|
280
|
-
const result = spawnSync('node', [VALIDATE], { encoding: 'utf8' })
|
|
281
|
-
|
|
282
|
-
// Assert
|
|
283
|
-
expect(result.status).toBe(2)
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
// ── Test F: stdout is always valid JSON on exit 1 ─────────────────────────────
|
|
287
|
-
|
|
288
|
-
test('emits valid JSON to stdout even when validation fails and exits with code 1', () => {
|
|
289
|
-
// Arrange — the duplicate-ID fixture produces exit 1 but must still emit valid JSON
|
|
290
|
-
// Act
|
|
291
|
-
const { exitCode, report } = runValidator(path.join(FIXTURES, 'wb-invalid-duplicate-id.json'))
|
|
292
|
-
|
|
293
|
-
// Assert — if report is non-null, JSON.parse succeeded without throwing
|
|
294
|
-
expect(exitCode).toBe(1)
|
|
295
|
-
expect(report).not.toBeNull()
|
|
296
|
-
})
|
|
297
|
-
})
|