@dtlabs/ai-toolkit 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude/agents/generate-work-breakdown.md +212 -192
  2. package/.claude/agents/install-toolkit.md +6 -1
  3. package/.claude/agents/validate-work-breakdown-semantic.md +96 -0
  4. package/.claude/scripts/.gitkeep +0 -0
  5. package/.claude/scripts/tests/fixtures/ac-table-format.md +48 -0
  6. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-Work-Breakdown.json +234 -0
  7. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-semantic-output.json +4 -0
  8. package/.claude/scripts/tests/fixtures/test-feature/feature.md +45 -0
  9. package/.claude/scripts/tests/fixtures/wb-invalid-bad-domain.json +39 -0
  10. package/.claude/scripts/tests/fixtures/wb-invalid-cycle.json +61 -0
  11. package/.claude/scripts/tests/fixtures/wb-invalid-duplicate-id.json +72 -0
  12. package/.claude/scripts/tests/fixtures/wb-invalid-missing-field.json +38 -0
  13. package/.claude/scripts/tests/fixtures/wb-valid.json +189 -0
  14. package/.claude/scripts/tests/helpers/buildWaves.js +21 -0
  15. package/.claude/scripts/tests/helpers/pm-phase2-flow.js +29 -0
  16. package/.claude/scripts/tests/helpers/pm-phase2-gate2.js +55 -0
  17. package/.claude/scripts/tests/helpers/pm-phase2-ledger.js +108 -0
  18. package/.claude/scripts/tests/installer.scripts-distribution.test.js +131 -0
  19. package/.claude/scripts/tests/pm-phase2.gate2-payload.test.js +353 -0
  20. package/.claude/scripts/tests/pm-phase2.ledger-transitions.test.js +346 -0
  21. package/.claude/scripts/tests/pm-phase2.orchestration-flow.test.js +276 -0
  22. package/.claude/scripts/tests/verify-us-01-t03.js +288 -0
  23. package/.claude/scripts/tests/verify-us-03-t04.js +262 -0
  24. package/.claude/scripts/tests/wb-render.checks-md-csv-deps.test.js +337 -0
  25. package/.claude/scripts/tests/wb-render.checks-sanitize-commit-paths.test.js +352 -0
  26. package/.claude/scripts/tests/wb-render.regression-pm-phase3-csv.test.js +219 -0
  27. package/.claude/scripts/tests/wb-validate.checks-1-7.test.js +330 -0
  28. package/.claude/scripts/tests/wb-validate.checks-11.test.js +166 -0
  29. package/.claude/scripts/tests/wb-validate.checks-12-14.test.js +222 -0
  30. package/.claude/scripts/tests/wb-validate.checks-15-18.test.js +359 -0
  31. package/.claude/scripts/tests/wb-validate.checks-19-21.test.js +362 -0
  32. package/.claude/scripts/tests/wb-validate.checks-22-23-report.test.js +297 -0
  33. package/.claude/scripts/tests/wb-validate.checks-8-10.test.js +243 -0
  34. package/.claude/scripts/wb-render.js +309 -0
  35. package/.claude/scripts/wb-validate.js +1007 -0
  36. package/.claude/workflows/pm-phase2.js +289 -28
  37. package/CHANGELOG.md +67 -0
  38. package/bin/cli.js +1 -0
  39. package/package.json +1 -1
@@ -0,0 +1,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
+ })