@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,352 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process')
|
|
4
|
+
const os = require('os')
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const RENDER = path.resolve(__dirname, '../wb-render.js')
|
|
9
|
+
const TMPDIR = os.tmpdir()
|
|
10
|
+
|
|
11
|
+
// ── Fixture helpers ───────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function makeWb(overrides) {
|
|
14
|
+
return Object.assign({
|
|
15
|
+
schemaVersion: 2,
|
|
16
|
+
feature: 'TEST',
|
|
17
|
+
title: 'Test WB',
|
|
18
|
+
phases: []
|
|
19
|
+
}, overrides)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makePhase(id, title, tasks, commitOverride) {
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
title,
|
|
26
|
+
commit: commitOverride !== undefined ? commitOverride : { type: 'feat', scope: id, subject: `implement ${id}` },
|
|
27
|
+
tasks: tasks != null ? tasks : []
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeTask(id, domain, overrides) {
|
|
32
|
+
return Object.assign({
|
|
33
|
+
id,
|
|
34
|
+
title: `Task ${id}`,
|
|
35
|
+
domain,
|
|
36
|
+
agentType: 'developer-backend',
|
|
37
|
+
agentMinutes: 10,
|
|
38
|
+
outcome: `outcome of ${id}`,
|
|
39
|
+
dependsOn: [],
|
|
40
|
+
verificationCommands: ['echo ok'],
|
|
41
|
+
commit: { type: 'feat', scope: 'wb', subject: `implement ${id}` }
|
|
42
|
+
}, overrides)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeFixture(wb) {
|
|
46
|
+
const p = path.join(TMPDIR, `wb-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`)
|
|
47
|
+
fs.writeFileSync(p, JSON.stringify(wb), 'utf8')
|
|
48
|
+
return p
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function runRender(jsonPath, prefix, destDir) {
|
|
52
|
+
const args = [jsonPath, prefix]
|
|
53
|
+
if (destDir) args.push(destDir)
|
|
54
|
+
return spawnSync(process.execPath, [RENDER, ...args], { encoding: 'utf8' })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
// Group 1: sanitizeField — via CSV and Markdown output
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe('wb-render.js — Group 1: sanitizeField behaviour via output files', () => {
|
|
62
|
+
|
|
63
|
+
// ── Test 1: pipe in task title → CSV row has exactly 8 columns ───────────────
|
|
64
|
+
|
|
65
|
+
test('pipe in task title is replaced so CSV data row has exactly 8 pipe-separated columns', () => {
|
|
66
|
+
// Arrange
|
|
67
|
+
const task = makeTask('T-01', 'BE', { title: 'foo|bar' })
|
|
68
|
+
const phase = makePhase('US-01', 'Phase One', [task])
|
|
69
|
+
const wb = makeWb({ phases: [phase] })
|
|
70
|
+
const jsonPath = writeFixture(wb)
|
|
71
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-sanitize-t1-'))
|
|
72
|
+
try {
|
|
73
|
+
// Act
|
|
74
|
+
runRender(jsonPath, 'TEST', tmpDir)
|
|
75
|
+
const csv = fs.readFileSync(path.join(tmpDir, 'TEST-Work-Breakdown.csv'), 'utf8')
|
|
76
|
+
const dataRows = csv.split('\n').filter((l, i) => i > 0 && l.trim() !== '')
|
|
77
|
+
|
|
78
|
+
// Assert: each data row has exactly 8 pipe-separated fields (pipe in title is replaced with space)
|
|
79
|
+
expect(dataRows.length).toBeGreaterThan(0)
|
|
80
|
+
for (const row of dataRows) {
|
|
81
|
+
expect(row.split('|')).toHaveLength(8)
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
85
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ── Test 2: LF in phase title → no embedded newline in CSV data rows ─────────
|
|
90
|
+
|
|
91
|
+
test('LF in phase title is replaced with space so CSV has no extra lines in data section', () => {
|
|
92
|
+
// Arrange
|
|
93
|
+
const task = makeTask('T-01', 'BE')
|
|
94
|
+
const phase = makePhase('US-01', 'Phase\nOne', [task])
|
|
95
|
+
const wb = makeWb({ phases: [phase] })
|
|
96
|
+
const jsonPath = writeFixture(wb)
|
|
97
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-sanitize-t2-'))
|
|
98
|
+
try {
|
|
99
|
+
// Act
|
|
100
|
+
runRender(jsonPath, 'TEST', tmpDir)
|
|
101
|
+
const csv = fs.readFileSync(path.join(tmpDir, 'TEST-Work-Breakdown.csv'), 'utf8')
|
|
102
|
+
const nonEmptyLines = csv.split('\n').filter(l => l.trim() !== '')
|
|
103
|
+
|
|
104
|
+
// Assert: exactly 2 non-empty lines — 1 header + 1 data row (no extra LF-split lines)
|
|
105
|
+
expect(nonEmptyLines).toHaveLength(2)
|
|
106
|
+
} finally {
|
|
107
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
108
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ── Test 3: CR in phase title → no carriage-return in CSV output ─────────────
|
|
113
|
+
|
|
114
|
+
test('CR in phase title is stripped so CSV content contains no carriage-return characters', () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const task = makeTask('T-01', 'BE')
|
|
117
|
+
const phase = makePhase('US-01', 'Phase\rOne', [task])
|
|
118
|
+
const wb = makeWb({ phases: [phase] })
|
|
119
|
+
const jsonPath = writeFixture(wb)
|
|
120
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-sanitize-t3-'))
|
|
121
|
+
try {
|
|
122
|
+
// Act
|
|
123
|
+
runRender(jsonPath, 'TEST', tmpDir)
|
|
124
|
+
const csv = fs.readFileSync(path.join(tmpDir, 'TEST-Work-Breakdown.csv'), 'utf8')
|
|
125
|
+
|
|
126
|
+
// Assert: no raw CR bytes anywhere in the CSV
|
|
127
|
+
expect(csv).not.toContain('\r')
|
|
128
|
+
} finally {
|
|
129
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
130
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// ── Test 4: pipe in task title → MD table row contains sanitized form ─────────
|
|
135
|
+
|
|
136
|
+
test('pipe in task title is replaced with space in Markdown task table so raw pipe is absent', () => {
|
|
137
|
+
// Arrange
|
|
138
|
+
const task = makeTask('T-01', 'BE', { title: 'foo|bar' })
|
|
139
|
+
const phase = makePhase('US-01', 'Phase One', [task])
|
|
140
|
+
const wb = makeWb({ phases: [phase] })
|
|
141
|
+
const jsonPath = writeFixture(wb)
|
|
142
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-sanitize-t4-'))
|
|
143
|
+
try {
|
|
144
|
+
// Act
|
|
145
|
+
runRender(jsonPath, 'TEST', tmpDir)
|
|
146
|
+
const md = fs.readFileSync(path.join(tmpDir, 'TEST-Work-Breakdown.md'), 'utf8')
|
|
147
|
+
|
|
148
|
+
// Assert: sanitized value present; raw piped title absent
|
|
149
|
+
expect(md).toContain('foo bar')
|
|
150
|
+
expect(md).not.toContain('foo|bar')
|
|
151
|
+
} finally {
|
|
152
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
153
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// ── Test 5: null task title → no crash, exit 0 ───────────────────────────────
|
|
158
|
+
|
|
159
|
+
test('null task title does not crash the renderer and process exits with code 0', () => {
|
|
160
|
+
// Arrange
|
|
161
|
+
const task = makeTask('T-01', 'BE', { title: null })
|
|
162
|
+
const phase = makePhase('US-01', 'Phase One', [task])
|
|
163
|
+
const wb = makeWb({ phases: [phase] })
|
|
164
|
+
const jsonPath = writeFixture(wb)
|
|
165
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-sanitize-t5-'))
|
|
166
|
+
try {
|
|
167
|
+
// Act
|
|
168
|
+
const result = runRender(jsonPath, 'TEST', tmpDir)
|
|
169
|
+
|
|
170
|
+
// Assert
|
|
171
|
+
expect(result.status).toBe(0)
|
|
172
|
+
} finally {
|
|
173
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
174
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
// Group 2: buildCommitSubject / phase commit formatting — via CSV and MD output
|
|
182
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('wb-render.js — Group 2: commit message formatting in output files', () => {
|
|
185
|
+
|
|
186
|
+
// ── Test 6: CSV commit_message uses phase commit type and prefix arg ──────────
|
|
187
|
+
|
|
188
|
+
test('CSV commit_message starts with type(PREFIX): when phase.commit is present', () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
const task = makeTask('T-01', 'BE')
|
|
191
|
+
const phase = makePhase('US-01', 'Phase One', [task], { type: 'feat', subject: 'implement us-01' })
|
|
192
|
+
const wb = makeWb({ phases: [phase] })
|
|
193
|
+
const jsonPath = writeFixture(wb)
|
|
194
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-commit-t6-'))
|
|
195
|
+
try {
|
|
196
|
+
// Act
|
|
197
|
+
runRender(jsonPath, 'TEST-PREFIX', tmpDir)
|
|
198
|
+
const csv = fs.readFileSync(path.join(tmpDir, 'TEST-PREFIX-Work-Breakdown.csv'), 'utf8')
|
|
199
|
+
const dataRows = csv.split('\n').filter((l, i) => i > 0 && l.trim() !== '')
|
|
200
|
+
|
|
201
|
+
// Assert: commit_message column (index 2) starts with feat(TEST-PREFIX):
|
|
202
|
+
expect(dataRows.length).toBeGreaterThan(0)
|
|
203
|
+
for (const row of dataRows) {
|
|
204
|
+
const cols = row.split('|')
|
|
205
|
+
expect(cols[2]).toMatch(/^feat\(TEST-PREFIX\):/)
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
209
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ── Test 7: missing phase.commit → CSV falls back to chore(PREFIX): ──────────
|
|
214
|
+
|
|
215
|
+
test('CSV commit_message falls back to chore(PREFIX): when phase has no commit field', () => {
|
|
216
|
+
// Arrange: phase object without a commit field
|
|
217
|
+
const task = makeTask('T-01', 'BE')
|
|
218
|
+
const phase = { id: 'US-01', title: 'Phase One', tasks: [task] }
|
|
219
|
+
const wb = makeWb({ phases: [phase] })
|
|
220
|
+
const jsonPath = writeFixture(wb)
|
|
221
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-commit-t7-'))
|
|
222
|
+
try {
|
|
223
|
+
// Act
|
|
224
|
+
runRender(jsonPath, 'TEST-PREFIX', tmpDir)
|
|
225
|
+
const csv = fs.readFileSync(path.join(tmpDir, 'TEST-PREFIX-Work-Breakdown.csv'), 'utf8')
|
|
226
|
+
const dataRows = csv.split('\n').filter((l, i) => i > 0 && l.trim() !== '')
|
|
227
|
+
|
|
228
|
+
// Assert: commit_message column (index 2) starts with chore(TEST-PREFIX):
|
|
229
|
+
expect(dataRows.length).toBeGreaterThan(0)
|
|
230
|
+
for (const row of dataRows) {
|
|
231
|
+
const cols = row.split('|')
|
|
232
|
+
expect(cols[2]).toMatch(/^chore\(TEST-PREFIX\):/)
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
236
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// ── Test 8: long phase commit subject → Markdown commit section renders correctly
|
|
241
|
+
|
|
242
|
+
test('long phase commit subject is rendered verbatim in Markdown commit section', () => {
|
|
243
|
+
// Arrange: subject long enough to produce a commit line exceeding 72 chars
|
|
244
|
+
const longSubject = 'a'.repeat(85) // "feat(PREFIX): " (14 chars) + 85 = 99 chars total
|
|
245
|
+
const task = makeTask('T-01', 'BE')
|
|
246
|
+
const phase = makePhase('US-01', 'Phase One', [task], { type: 'feat', subject: longSubject })
|
|
247
|
+
const wb = makeWb({ phases: [phase] })
|
|
248
|
+
const jsonPath = writeFixture(wb)
|
|
249
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-commit-t8-'))
|
|
250
|
+
try {
|
|
251
|
+
// Act
|
|
252
|
+
runRender(jsonPath, 'PREFIX', tmpDir)
|
|
253
|
+
const md = fs.readFileSync(path.join(tmpDir, 'PREFIX-Work-Breakdown.md'), 'utf8')
|
|
254
|
+
|
|
255
|
+
// Assert: the full commit line appears in the MD (phaseCommit renders untruncated)
|
|
256
|
+
const expectedLine = `feat(PREFIX): ${longSubject}`
|
|
257
|
+
expect(md).toContain(expectedLine)
|
|
258
|
+
} finally {
|
|
259
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
260
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
267
|
+
// Group 3: Output file paths
|
|
268
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
describe('wb-render.js — Group 3: output file path resolution', () => {
|
|
271
|
+
|
|
272
|
+
// ── Test 9: default destDir is same directory as JSON file ───────────────────
|
|
273
|
+
|
|
274
|
+
test('output files are written to the JSON directory when no destDir argument is provided', () => {
|
|
275
|
+
// Arrange
|
|
276
|
+
const task = makeTask('T-01', 'BE')
|
|
277
|
+
const phase = makePhase('US-01', 'Phase One', [task])
|
|
278
|
+
const wb = makeWb({ phases: [phase] })
|
|
279
|
+
const jsonPath = writeFixture(wb)
|
|
280
|
+
const prefix = 'TEST-P9'
|
|
281
|
+
const jsonDir = path.dirname(jsonPath)
|
|
282
|
+
const expectedMd = path.join(jsonDir, `${prefix}-Work-Breakdown.md`)
|
|
283
|
+
const expectedCsv = path.join(jsonDir, `${prefix}-Work-Breakdown.csv`)
|
|
284
|
+
try {
|
|
285
|
+
// Act: run without destDir — output defaults to JSON's directory
|
|
286
|
+
runRender(jsonPath, prefix)
|
|
287
|
+
|
|
288
|
+
// Assert: both output files exist in the JSON's directory
|
|
289
|
+
expect(fs.existsSync(expectedMd)).toBe(true)
|
|
290
|
+
expect(fs.existsSync(expectedCsv)).toBe(true)
|
|
291
|
+
} finally {
|
|
292
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
293
|
+
try { fs.unlinkSync(expectedMd) } catch (_) {}
|
|
294
|
+
try { fs.unlinkSync(expectedCsv) } catch (_) {}
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// ── Test 10: explicit destDir overrides output location ──────────────────────
|
|
299
|
+
|
|
300
|
+
test('output files appear in explicit destDir and not in the JSON file directory', () => {
|
|
301
|
+
// Arrange
|
|
302
|
+
const task = makeTask('T-01', 'BE')
|
|
303
|
+
const phase = makePhase('US-01', 'Phase One', [task])
|
|
304
|
+
const wb = makeWb({ phases: [phase] })
|
|
305
|
+
const jsonPath = writeFixture(wb)
|
|
306
|
+
const prefix = 'TEST-P10'
|
|
307
|
+
const customDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-paths-t10-'))
|
|
308
|
+
const jsonDir = path.dirname(jsonPath)
|
|
309
|
+
try {
|
|
310
|
+
// Act
|
|
311
|
+
runRender(jsonPath, prefix, customDir)
|
|
312
|
+
|
|
313
|
+
// Assert: output files are in customDir
|
|
314
|
+
expect(fs.existsSync(path.join(customDir, `${prefix}-Work-Breakdown.md`))).toBe(true)
|
|
315
|
+
expect(fs.existsSync(path.join(customDir, `${prefix}-Work-Breakdown.csv`))).toBe(true)
|
|
316
|
+
|
|
317
|
+
// Assert: output files are NOT in the JSON's directory (unique prefix ensures no collision)
|
|
318
|
+
expect(fs.existsSync(path.join(jsonDir, `${prefix}-Work-Breakdown.md`))).toBe(false)
|
|
319
|
+
expect(fs.existsSync(path.join(jsonDir, `${prefix}-Work-Breakdown.csv`))).toBe(false)
|
|
320
|
+
} finally {
|
|
321
|
+
fs.rmSync(customDir, { recursive: true, force: true })
|
|
322
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// ── Test 11: stdout JSON contains markdownPath and csvPath keys ───────────────
|
|
327
|
+
|
|
328
|
+
test('stdout from a successful run is valid JSON with correct markdownPath and csvPath values', () => {
|
|
329
|
+
// Arrange
|
|
330
|
+
const task = makeTask('T-01', 'BE')
|
|
331
|
+
const phase = makePhase('US-01', 'Phase One', [task])
|
|
332
|
+
const wb = makeWb({ phases: [phase] })
|
|
333
|
+
const jsonPath = writeFixture(wb)
|
|
334
|
+
const tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-paths-t11-'))
|
|
335
|
+
const prefix = 'TEST-P11'
|
|
336
|
+
try {
|
|
337
|
+
// Act
|
|
338
|
+
const result = runRender(jsonPath, prefix, tmpDir)
|
|
339
|
+
const parsed = JSON.parse(result.stdout.trim())
|
|
340
|
+
|
|
341
|
+
// Assert: stdout JSON has the expected path keys with correct values
|
|
342
|
+
expect(parsed).toHaveProperty('markdownPath')
|
|
343
|
+
expect(parsed).toHaveProperty('csvPath')
|
|
344
|
+
expect(parsed.markdownPath).toBe(path.join(tmpDir, `${prefix}-Work-Breakdown.md`))
|
|
345
|
+
expect(parsed.csvPath).toBe(path.join(tmpDir, `${prefix}-Work-Breakdown.csv`))
|
|
346
|
+
} finally {
|
|
347
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
348
|
+
try { fs.unlinkSync(jsonPath) } catch (_) {}
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process')
|
|
4
|
+
const os = require('os')
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const RENDER = path.resolve(__dirname, '../wb-render.js')
|
|
9
|
+
const FIXTURE = path.resolve(__dirname, 'fixtures/wb-valid.json')
|
|
10
|
+
const TMPDIR = os.tmpdir()
|
|
11
|
+
const PREFIX = 'REG-TEST'
|
|
12
|
+
|
|
13
|
+
// Load the fixture JSON to derive expected values
|
|
14
|
+
const fixtureWb = JSON.parse(fs.readFileSync(FIXTURE, 'utf8'))
|
|
15
|
+
const allFixtureTasks = fixtureWb.phases.flatMap(p => p.tasks ?? [])
|
|
16
|
+
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
// Regression: CSV produced by wb-render.js is correctly parsed by pm-phase3
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe('wb-render.js → pm-phase3 CSV parsing regression', () => {
|
|
22
|
+
let tmpDir
|
|
23
|
+
let rawDataLines // trimmed, non-header, non-empty lines from the CSV (raw strings)
|
|
24
|
+
let rows // output of pm-phase3 row mapping + filter
|
|
25
|
+
let phaseMap // output of pm-phase3 phase grouping
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
// Arrange: render the standard fixture into a unique temp directory
|
|
29
|
+
tmpDir = fs.mkdtempSync(path.join(TMPDIR, 'wb-reg-pm3-'))
|
|
30
|
+
spawnSync(process.execPath, [RENDER, FIXTURE, PREFIX, tmpDir], { encoding: 'utf8' })
|
|
31
|
+
|
|
32
|
+
const csvPath = path.join(tmpDir, `${PREFIX}-Work-Breakdown.csv`)
|
|
33
|
+
const csvContent = fs.readFileSync(csvPath, 'utf8')
|
|
34
|
+
|
|
35
|
+
// Save raw data lines for structural assertions (before pm-phase3 field mapping)
|
|
36
|
+
rawDataLines = csvContent
|
|
37
|
+
.split('\n')
|
|
38
|
+
.map(l => l.trim())
|
|
39
|
+
.filter(l => l && !l.startsWith('phase_id'))
|
|
40
|
+
|
|
41
|
+
// ── pm-phase3 parsing logic — inlined verbatim from pm-phase3.js lines 77-105 ─
|
|
42
|
+
rows = csvContent
|
|
43
|
+
.split('\n')
|
|
44
|
+
.map(l => l.trim())
|
|
45
|
+
.filter(l => l && !l.startsWith('phase_id')) // skip header and empty lines
|
|
46
|
+
.map(l => {
|
|
47
|
+
const [phase_id, phase_title, commit_message, depends_on, task_id, task_title, domain, agent_type] = l.split('|')
|
|
48
|
+
return { phase_id, phase_title, commit_message, depends_on: depends_on || '', task_id, task_title, domain, agent_type }
|
|
49
|
+
})
|
|
50
|
+
.filter(r => r.phase_id && r.task_id)
|
|
51
|
+
|
|
52
|
+
// Group rows into phases preserving order
|
|
53
|
+
phaseMap = new Map()
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
if (!phaseMap.has(row.phase_id)) {
|
|
56
|
+
phaseMap.set(row.phase_id, {
|
|
57
|
+
phase_id: row.phase_id,
|
|
58
|
+
title: row.phase_title,
|
|
59
|
+
commit_message: row.commit_message,
|
|
60
|
+
depends_on: row.depends_on.split(' ').filter(Boolean), // [] for empty
|
|
61
|
+
impl_tasks: [],
|
|
62
|
+
test_tasks: [],
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
const p = phaseMap.get(row.phase_id)
|
|
66
|
+
if (row.domain === 'TEST') {
|
|
67
|
+
p.test_tasks.push({ task_id: row.task_id, agent_type: row.agent_type })
|
|
68
|
+
} else {
|
|
69
|
+
p.impl_tasks.push({ task_id: row.task_id, agent_type: row.agent_type })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterAll(() => {
|
|
75
|
+
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ── Test 1: row count matches fixture task count ──────────────────────────────
|
|
79
|
+
|
|
80
|
+
test('rows.length equals the total number of tasks in the fixture after pm-phase3 parsing', () => {
|
|
81
|
+
// Arrange: allFixtureTasks derived from wb-valid.json (6 tasks across 2 phases)
|
|
82
|
+
|
|
83
|
+
// Act: rows computed in beforeAll via inlined pm-phase3 logic
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
86
|
+
expect(rows.length).toBe(allFixtureTasks.length)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ── Test 2: every raw data line has exactly 8 pipe-separated fields ───────────
|
|
90
|
+
|
|
91
|
+
test('every non-header data line in the CSV has exactly 8 pipe-separated fields', () => {
|
|
92
|
+
// Arrange: rawDataLines are the trimmed, non-empty, non-header lines
|
|
93
|
+
expect(rawDataLines.length).toBeGreaterThan(0)
|
|
94
|
+
|
|
95
|
+
// Act & Assert
|
|
96
|
+
for (const line of rawDataLines) {
|
|
97
|
+
expect(line.split('|')).toHaveLength(8)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ── Test 3: every parsed row has non-empty phase_id and task_id ───────────────
|
|
102
|
+
|
|
103
|
+
test('every parsed row has a non-empty phase_id and task_id after pm-phase3 mapping', () => {
|
|
104
|
+
// Arrange: rows computed in beforeAll
|
|
105
|
+
expect(rows.length).toBeGreaterThan(0)
|
|
106
|
+
|
|
107
|
+
// Act & Assert
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
expect(row.phase_id).toBeTruthy()
|
|
110
|
+
expect(row.task_id).toBeTruthy()
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// ── Test 4: phaseMap size matches fixture phase count ─────────────────────────
|
|
115
|
+
|
|
116
|
+
test('phaseMap.size equals the number of phases in the fixture', () => {
|
|
117
|
+
// Arrange: fixtureWb.phases has 2 phases (INFRA and US-01)
|
|
118
|
+
|
|
119
|
+
// Act: phaseMap computed in beforeAll
|
|
120
|
+
|
|
121
|
+
// Assert
|
|
122
|
+
expect(phaseMap.size).toBe(fixtureWb.phases.length)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ── Test 5: INFRA phase has the correct impl_tasks count ─────────────────────
|
|
126
|
+
|
|
127
|
+
test('INFRA phase in phaseMap has 2 impl_tasks (the 2 non-TEST domain tasks)', () => {
|
|
128
|
+
// Arrange: fixture INFRA phase has INFRA-TASK-INFRA-01 and INFRA-TASK-INFRA-02 (domain INFRA)
|
|
129
|
+
const infraPhase = phaseMap.get('INFRA')
|
|
130
|
+
|
|
131
|
+
// Assert
|
|
132
|
+
expect(infraPhase).toBeDefined()
|
|
133
|
+
expect(infraPhase.impl_tasks).toHaveLength(2)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// ── Test 6: INFRA phase has the correct test_tasks count ─────────────────────
|
|
137
|
+
|
|
138
|
+
test('INFRA phase in phaseMap has 1 test_task (the TEST domain task)', () => {
|
|
139
|
+
// Arrange: fixture INFRA phase has INFRA-TASK-TEST-01 (domain TEST)
|
|
140
|
+
const infraPhase = phaseMap.get('INFRA')
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
expect(infraPhase).toBeDefined()
|
|
144
|
+
expect(infraPhase.test_tasks).toHaveLength(1)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ── Test 7: US-01 phase has the correct impl_tasks count ─────────────────────
|
|
148
|
+
|
|
149
|
+
test('US-01 phase in phaseMap has 2 impl_tasks (the 2 BE domain tasks)', () => {
|
|
150
|
+
// Arrange: fixture US-01 phase has US-01-TASK-BE-01 and US-01-TASK-BE-02 (domain BE)
|
|
151
|
+
const us01Phase = phaseMap.get('US-01')
|
|
152
|
+
|
|
153
|
+
// Assert
|
|
154
|
+
expect(us01Phase).toBeDefined()
|
|
155
|
+
expect(us01Phase.impl_tasks).toHaveLength(2)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ── Test 8: US-01 phase has the correct test_tasks count ─────────────────────
|
|
159
|
+
|
|
160
|
+
test('US-01 phase in phaseMap has 1 test_task (the TEST domain task)', () => {
|
|
161
|
+
// Arrange: fixture US-01 phase has US-01-TASK-TEST-01 (domain TEST)
|
|
162
|
+
const us01Phase = phaseMap.get('US-01')
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
expect(us01Phase).toBeDefined()
|
|
166
|
+
expect(us01Phase.test_tasks).toHaveLength(1)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ── Test 9: INFRA phase depends_on is an empty array ─────────────────────────
|
|
170
|
+
|
|
171
|
+
test('INFRA phase depends_on is an empty array after split+filter because it has no external dependencies', () => {
|
|
172
|
+
// Arrange: INFRA phase tasks have no cross-phase dependsOn references
|
|
173
|
+
const infraPhase = phaseMap.get('INFRA')
|
|
174
|
+
|
|
175
|
+
// Assert
|
|
176
|
+
expect(infraPhase).toBeDefined()
|
|
177
|
+
expect(infraPhase.depends_on).toEqual([])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// ── Test 10: US-01 phase depends_on contains INFRA ───────────────────────────
|
|
181
|
+
|
|
182
|
+
test('US-01 phase depends_on contains "INFRA" because US-01-TASK-BE-01 references an INFRA-phase task', () => {
|
|
183
|
+
// Arrange: US-01-TASK-BE-01 has dependsOn: ['INFRA-TASK-INFRA-02'] (cross-phase)
|
|
184
|
+
const us01Phase = phaseMap.get('US-01')
|
|
185
|
+
|
|
186
|
+
// Assert
|
|
187
|
+
expect(us01Phase).toBeDefined()
|
|
188
|
+
expect(us01Phase.depends_on).toContain('INFRA')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ── Test 11: no row has undefined as any field value ─────────────────────────
|
|
192
|
+
|
|
193
|
+
test('no parsed row has undefined as any of its 8 destructured field values', () => {
|
|
194
|
+
// Arrange: if the CSV had fewer than 8 pipe-delimited columns, destructuring would
|
|
195
|
+
// yield undefined for the missing positions — this test guards against that regression
|
|
196
|
+
const FIELDS = ['phase_id', 'phase_title', 'commit_message', 'depends_on', 'task_id', 'task_title', 'domain', 'agent_type']
|
|
197
|
+
expect(rows.length).toBeGreaterThan(0)
|
|
198
|
+
|
|
199
|
+
// Act & Assert
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
for (const field of FIELDS) {
|
|
202
|
+
expect(row[field]).not.toBeUndefined()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// ── Test 12: agent_type has no surrounding whitespace ────────────────────────
|
|
208
|
+
|
|
209
|
+
test('agent_type on every parsed row is a clean string with no surrounding whitespace', () => {
|
|
210
|
+
// Arrange: CSV rows end with a newline; l.trim() removes it, but this test confirms
|
|
211
|
+
// the agent_type value (last column) arrives clean after pm-phase3 parsing
|
|
212
|
+
expect(rows.length).toBeGreaterThan(0)
|
|
213
|
+
|
|
214
|
+
// Act & Assert
|
|
215
|
+
for (const row of rows) {
|
|
216
|
+
expect(row.agent_type).toBe(row.agent_type.trim())
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
})
|