deepflow 0.1.91 → 0.1.92
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/bin/ratchet.js +67 -2
- package/bin/ratchet.test.js +308 -4
- package/bin/wave-runner.js +259 -0
- package/bin/wave-runner.test.js +556 -0
- package/package.json +1 -1
- package/src/commands/df/execute.md +83 -8
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for bin/wave-runner.js — DAG-based wave grouping of PLAN.md tasks.
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* 1. PLAN.md parsing: task extraction, dependency parsing, completed-task skipping
|
|
6
|
+
* 2. Wave grouping: correct topological ordering via Kahn's algorithm
|
|
7
|
+
* 3. --recalc --failed: stuck tasks and transitive dependents excluded
|
|
8
|
+
* 4. Edge cases: no tasks, circular deps, all completed, single task
|
|
9
|
+
* 5. CLI arg parsing
|
|
10
|
+
* 6. Output formatting
|
|
11
|
+
*
|
|
12
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { test, describe } = require('node:test');
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const os = require('node:os');
|
|
22
|
+
const { execFileSync } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const WAVE_RUNNER_PATH = path.resolve(__dirname, 'wave-runner.js');
|
|
29
|
+
const WAVE_RUNNER_SRC = fs.readFileSync(WAVE_RUNNER_PATH, 'utf8');
|
|
30
|
+
|
|
31
|
+
function makeTmpDir() {
|
|
32
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'df-wave-runner-test-'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rmrf(dir) {
|
|
36
|
+
if (fs.existsSync(dir)) {
|
|
37
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Extract pure functions from wave-runner.js source for unit testing.
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const extractedFns = (() => {
|
|
46
|
+
const modifiedSrc = WAVE_RUNNER_SRC
|
|
47
|
+
.replace(/^main\(\);?\s*$/m, '')
|
|
48
|
+
.replace(/^#!.*$/m, '');
|
|
49
|
+
|
|
50
|
+
const wrapped = `
|
|
51
|
+
${modifiedSrc}
|
|
52
|
+
return { parseArgs, parsePlan, buildWaves, formatWaves };
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const factory = new Function('require', 'process', '__dirname', '__filename', 'module', 'exports', wrapped);
|
|
56
|
+
return factory(require, process, __dirname, __filename, module, exports);
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
const { parseArgs, parsePlan, buildWaves, formatWaves } = extractedFns;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// CLI runner helper
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function runWaveRunner(args = [], { cwd } = {}) {
|
|
66
|
+
try {
|
|
67
|
+
const stdout = execFileSync(
|
|
68
|
+
process.execPath,
|
|
69
|
+
[WAVE_RUNNER_PATH, ...args],
|
|
70
|
+
{
|
|
71
|
+
cwd: cwd || os.tmpdir(),
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
return { stdout, stderr: '', code: 0 };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return {
|
|
78
|
+
stdout: err.stdout || '',
|
|
79
|
+
stderr: err.stderr || '',
|
|
80
|
+
code: err.status ?? 1,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// 1. parseArgs
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe('parseArgs — CLI argument parsing', () => {
|
|
90
|
+
test('defaults to PLAN.md with no recalc', () => {
|
|
91
|
+
const args = parseArgs(['node', 'wave-runner.js']);
|
|
92
|
+
assert.equal(args.plan, 'PLAN.md');
|
|
93
|
+
assert.equal(args.recalc, false);
|
|
94
|
+
assert.deepEqual(args.failed, []);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('--plan overrides default path', () => {
|
|
98
|
+
const args = parseArgs(['node', 'wave-runner.js', '--plan', 'custom/PLAN.md']);
|
|
99
|
+
assert.equal(args.plan, 'custom/PLAN.md');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('--recalc flag enables recalc mode', () => {
|
|
103
|
+
const args = parseArgs(['node', 'wave-runner.js', '--recalc']);
|
|
104
|
+
assert.equal(args.recalc, true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('--failed accepts comma-separated task IDs', () => {
|
|
108
|
+
const args = parseArgs(['node', 'wave-runner.js', '--recalc', '--failed', 'T3,T5,T7']);
|
|
109
|
+
assert.deepEqual(args.failed, ['T3', 'T5', 'T7']);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('--failed accepts multiple --failed flags', () => {
|
|
113
|
+
const args = parseArgs(['node', 'wave-runner.js', '--recalc', '--failed', 'T3', '--failed', 'T5']);
|
|
114
|
+
assert.deepEqual(args.failed, ['T3', 'T5']);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// 2. parsePlan — PLAN.md task extraction
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe('parsePlan — PLAN.md parsing', () => {
|
|
123
|
+
test('extracts pending tasks with IDs and descriptions', () => {
|
|
124
|
+
const text = `
|
|
125
|
+
## Tasks
|
|
126
|
+
- [ ] **T1**: Build the parser
|
|
127
|
+
- [ ] **T2**: Wire up CLI
|
|
128
|
+
`;
|
|
129
|
+
const tasks = parsePlan(text);
|
|
130
|
+
assert.equal(tasks.length, 2);
|
|
131
|
+
assert.equal(tasks[0].id, 'T1');
|
|
132
|
+
assert.equal(tasks[0].description, 'Build the parser');
|
|
133
|
+
assert.equal(tasks[1].id, 'T2');
|
|
134
|
+
assert.equal(tasks[1].description, 'Wire up CLI');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('extracts blocked-by dependencies', () => {
|
|
138
|
+
const text = `
|
|
139
|
+
- [ ] **T1**: First task
|
|
140
|
+
- [ ] **T2**: Second task
|
|
141
|
+
- Blocked by: T1
|
|
142
|
+
- [ ] **T3**: Third task
|
|
143
|
+
- Blocked by: T1, T2
|
|
144
|
+
`;
|
|
145
|
+
const tasks = parsePlan(text);
|
|
146
|
+
assert.equal(tasks.length, 3);
|
|
147
|
+
assert.deepEqual(tasks[0].blockedBy, []);
|
|
148
|
+
assert.deepEqual(tasks[1].blockedBy, ['T1']);
|
|
149
|
+
assert.deepEqual(tasks[2].blockedBy, ['T1', 'T2']);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('skips completed tasks (checked checkbox)', () => {
|
|
153
|
+
const text = `
|
|
154
|
+
- [x] **T1**: Done task
|
|
155
|
+
- [ ] **T2**: Pending task
|
|
156
|
+
`;
|
|
157
|
+
const tasks = parsePlan(text);
|
|
158
|
+
assert.equal(tasks.length, 1);
|
|
159
|
+
assert.equal(tasks[0].id, 'T2');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('handles tasks with tag brackets', () => {
|
|
163
|
+
const text = `
|
|
164
|
+
- [ ] **T5** [SPIKE]: Investigate API design
|
|
165
|
+
`;
|
|
166
|
+
const tasks = parsePlan(text);
|
|
167
|
+
assert.equal(tasks.length, 1);
|
|
168
|
+
assert.equal(tasks[0].id, 'T5');
|
|
169
|
+
assert.equal(tasks[0].description, 'Investigate API design');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('returns empty array for empty input', () => {
|
|
173
|
+
const tasks = parsePlan('');
|
|
174
|
+
assert.equal(tasks.length, 0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('returns empty array when all tasks are completed', () => {
|
|
178
|
+
const text = `
|
|
179
|
+
- [x] **T1**: Done
|
|
180
|
+
- [x] **T2**: Also done
|
|
181
|
+
`;
|
|
182
|
+
const tasks = parsePlan(text);
|
|
183
|
+
assert.equal(tasks.length, 0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('does not attach blocked-by annotations from completed tasks to next pending task', () => {
|
|
187
|
+
const text = `
|
|
188
|
+
- [x] **T1**: Completed
|
|
189
|
+
- Blocked by: T0
|
|
190
|
+
- [ ] **T2**: Pending with no deps
|
|
191
|
+
`;
|
|
192
|
+
const tasks = parsePlan(text);
|
|
193
|
+
assert.equal(tasks.length, 1);
|
|
194
|
+
assert.equal(tasks[0].id, 'T2');
|
|
195
|
+
assert.deepEqual(tasks[0].blockedBy, []);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('handles input with no task lines', () => {
|
|
199
|
+
const text = `
|
|
200
|
+
# My Plan
|
|
201
|
+
Some descriptive text without any task checkboxes.
|
|
202
|
+
`;
|
|
203
|
+
const tasks = parsePlan(text);
|
|
204
|
+
assert.equal(tasks.length, 0);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// 3. buildWaves — DAG to wave grouping (AC-1, AC-2)
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
describe('buildWaves — topological wave grouping', () => {
|
|
213
|
+
test('independent tasks all go in wave 1', () => {
|
|
214
|
+
const tasks = [
|
|
215
|
+
{ id: 'T1', num: 1, description: 'A', blockedBy: [] },
|
|
216
|
+
{ id: 'T2', num: 2, description: 'B', blockedBy: [] },
|
|
217
|
+
{ id: 'T3', num: 3, description: 'C', blockedBy: [] },
|
|
218
|
+
];
|
|
219
|
+
const waves = buildWaves(tasks, new Set());
|
|
220
|
+
assert.equal(waves.length, 1);
|
|
221
|
+
assert.equal(waves[0].length, 3);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('linear chain produces one task per wave', () => {
|
|
225
|
+
// T1 → T2 → T3
|
|
226
|
+
const tasks = [
|
|
227
|
+
{ id: 'T1', num: 1, description: 'First', blockedBy: [] },
|
|
228
|
+
{ id: 'T2', num: 2, description: 'Second', blockedBy: ['T1'] },
|
|
229
|
+
{ id: 'T3', num: 3, description: 'Third', blockedBy: ['T2'] },
|
|
230
|
+
];
|
|
231
|
+
const waves = buildWaves(tasks, new Set());
|
|
232
|
+
assert.equal(waves.length, 3);
|
|
233
|
+
assert.equal(waves[0][0].id, 'T1');
|
|
234
|
+
assert.equal(waves[1][0].id, 'T2');
|
|
235
|
+
assert.equal(waves[2][0].id, 'T3');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('diamond DAG groups correctly', () => {
|
|
239
|
+
// T1
|
|
240
|
+
// / \
|
|
241
|
+
// T2 T3
|
|
242
|
+
// \ /
|
|
243
|
+
// T4
|
|
244
|
+
const tasks = [
|
|
245
|
+
{ id: 'T1', num: 1, description: 'Root', blockedBy: [] },
|
|
246
|
+
{ id: 'T2', num: 2, description: 'Left', blockedBy: ['T1'] },
|
|
247
|
+
{ id: 'T3', num: 3, description: 'Right', blockedBy: ['T1'] },
|
|
248
|
+
{ id: 'T4', num: 4, description: 'Join', blockedBy: ['T2', 'T3'] },
|
|
249
|
+
];
|
|
250
|
+
const waves = buildWaves(tasks, new Set());
|
|
251
|
+
assert.equal(waves.length, 3);
|
|
252
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T1']);
|
|
253
|
+
assert.deepEqual(waves[1].map(t => t.id), ['T2', 'T3']);
|
|
254
|
+
assert.deepEqual(waves[2].map(t => t.id), ['T4']);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('tasks appear after all their blocking dependencies waves', () => {
|
|
258
|
+
// T1 (wave 1), T2 blocked by T1 (wave 2), T3 blocked by T1 (wave 2),
|
|
259
|
+
// T4 blocked by T2 and T3 (wave 3)
|
|
260
|
+
const tasks = [
|
|
261
|
+
{ id: 'T1', num: 1, description: '', blockedBy: [] },
|
|
262
|
+
{ id: 'T2', num: 2, description: '', blockedBy: ['T1'] },
|
|
263
|
+
{ id: 'T3', num: 3, description: '', blockedBy: ['T1'] },
|
|
264
|
+
{ id: 'T4', num: 4, description: '', blockedBy: ['T2', 'T3'] },
|
|
265
|
+
];
|
|
266
|
+
const waves = buildWaves(tasks, new Set());
|
|
267
|
+
|
|
268
|
+
// Build a map of task → wave index
|
|
269
|
+
const taskWave = new Map();
|
|
270
|
+
waves.forEach((wave, wi) => {
|
|
271
|
+
for (const t of wave) taskWave.set(t.id, wi);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// T4 must be in a wave strictly after T2 and T3
|
|
275
|
+
assert.ok(taskWave.get('T4') > taskWave.get('T2'));
|
|
276
|
+
assert.ok(taskWave.get('T4') > taskWave.get('T3'));
|
|
277
|
+
// T2 and T3 must be after T1
|
|
278
|
+
assert.ok(taskWave.get('T2') > taskWave.get('T1'));
|
|
279
|
+
assert.ok(taskWave.get('T3') > taskWave.get('T1'));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('deps on completed (non-pending) tasks are treated as satisfied', () => {
|
|
283
|
+
// T2 depends on T1, but T1 is not in the pending list (already completed)
|
|
284
|
+
const tasks = [
|
|
285
|
+
{ id: 'T2', num: 2, description: 'Depends on completed T1', blockedBy: ['T1'] },
|
|
286
|
+
{ id: 'T3', num: 3, description: 'Independent', blockedBy: [] },
|
|
287
|
+
];
|
|
288
|
+
const waves = buildWaves(tasks, new Set());
|
|
289
|
+
// Both should be in wave 1 since T1 is not pending
|
|
290
|
+
assert.equal(waves.length, 1);
|
|
291
|
+
assert.equal(waves[0].length, 2);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('tasks are sorted by task number within each wave', () => {
|
|
295
|
+
const tasks = [
|
|
296
|
+
{ id: 'T10', num: 10, description: '', blockedBy: [] },
|
|
297
|
+
{ id: 'T3', num: 3, description: '', blockedBy: [] },
|
|
298
|
+
{ id: 'T7', num: 7, description: '', blockedBy: [] },
|
|
299
|
+
{ id: 'T1', num: 1, description: '', blockedBy: [] },
|
|
300
|
+
];
|
|
301
|
+
const waves = buildWaves(tasks, new Set());
|
|
302
|
+
assert.equal(waves.length, 1);
|
|
303
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T1', 'T3', 'T7', 'T10']);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('empty task list produces no waves', () => {
|
|
307
|
+
const waves = buildWaves([], new Set());
|
|
308
|
+
assert.equal(waves.length, 0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('circular dependency results in tasks not appearing in any wave', () => {
|
|
312
|
+
// T1 blocked by T2, T2 blocked by T1 — neither can ever be resolved
|
|
313
|
+
const tasks = [
|
|
314
|
+
{ id: 'T1', num: 1, description: 'A', blockedBy: ['T2'] },
|
|
315
|
+
{ id: 'T2', num: 2, description: 'B', blockedBy: ['T1'] },
|
|
316
|
+
];
|
|
317
|
+
const waves = buildWaves(tasks, new Set());
|
|
318
|
+
// Kahn's algorithm: tasks in a cycle have in-degree > 0 forever, so no waves
|
|
319
|
+
assert.equal(waves.length, 0);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('partial circular dependency: non-cyclic tasks still appear', () => {
|
|
323
|
+
// T1 and T2 form a cycle, but T3 is independent
|
|
324
|
+
const tasks = [
|
|
325
|
+
{ id: 'T1', num: 1, description: '', blockedBy: ['T2'] },
|
|
326
|
+
{ id: 'T2', num: 2, description: '', blockedBy: ['T1'] },
|
|
327
|
+
{ id: 'T3', num: 3, description: '', blockedBy: [] },
|
|
328
|
+
];
|
|
329
|
+
const waves = buildWaves(tasks, new Set());
|
|
330
|
+
assert.equal(waves.length, 1);
|
|
331
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T3']);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// 4. buildWaves with stuckIds — --recalc --failed (AC-3)
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe('buildWaves with stuckIds — recalc/failed mode', () => {
|
|
340
|
+
test('stuck task is excluded from all waves', () => {
|
|
341
|
+
const tasks = [
|
|
342
|
+
{ id: 'T1', num: 1, description: '', blockedBy: [] },
|
|
343
|
+
{ id: 'T2', num: 2, description: '', blockedBy: [] },
|
|
344
|
+
];
|
|
345
|
+
const waves = buildWaves(tasks, new Set(['T1']));
|
|
346
|
+
assert.equal(waves.length, 1);
|
|
347
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T2']);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('transitive dependents of stuck task are also excluded', () => {
|
|
351
|
+
// T1 → T2 → T3, T4 independent
|
|
352
|
+
const tasks = [
|
|
353
|
+
{ id: 'T1', num: 1, description: '', blockedBy: [] },
|
|
354
|
+
{ id: 'T2', num: 2, description: '', blockedBy: ['T1'] },
|
|
355
|
+
{ id: 'T3', num: 3, description: '', blockedBy: ['T2'] },
|
|
356
|
+
{ id: 'T4', num: 4, description: '', blockedBy: [] },
|
|
357
|
+
];
|
|
358
|
+
// T1 failed → T2 and T3 (transitive dependents) should also be excluded
|
|
359
|
+
const waves = buildWaves(tasks, new Set(['T1']));
|
|
360
|
+
assert.equal(waves.length, 1);
|
|
361
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T4']);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('multiple stuck tasks exclude all their dependents', () => {
|
|
365
|
+
const tasks = [
|
|
366
|
+
{ id: 'T1', num: 1, description: '', blockedBy: [] },
|
|
367
|
+
{ id: 'T2', num: 2, description: '', blockedBy: [] },
|
|
368
|
+
{ id: 'T3', num: 3, description: '', blockedBy: ['T1'] },
|
|
369
|
+
{ id: 'T4', num: 4, description: '', blockedBy: ['T2'] },
|
|
370
|
+
{ id: 'T5', num: 5, description: '', blockedBy: [] },
|
|
371
|
+
];
|
|
372
|
+
const waves = buildWaves(tasks, new Set(['T1', 'T2']));
|
|
373
|
+
// T1, T2 stuck → T3, T4 excluded → only T5 remains
|
|
374
|
+
assert.equal(waves.length, 1);
|
|
375
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T5']);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('stuck task that does not exist in task list is ignored', () => {
|
|
379
|
+
const tasks = [
|
|
380
|
+
{ id: 'T1', num: 1, description: '', blockedBy: [] },
|
|
381
|
+
];
|
|
382
|
+
const waves = buildWaves(tasks, new Set(['T99']));
|
|
383
|
+
assert.equal(waves.length, 1);
|
|
384
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T1']);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('all tasks stuck results in no waves', () => {
|
|
388
|
+
const tasks = [
|
|
389
|
+
{ id: 'T1', num: 1, description: '', blockedBy: [] },
|
|
390
|
+
{ id: 'T2', num: 2, description: '', blockedBy: [] },
|
|
391
|
+
];
|
|
392
|
+
const waves = buildWaves(tasks, new Set(['T1', 'T2']));
|
|
393
|
+
assert.equal(waves.length, 0);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// 5. formatWaves — output formatting
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
describe('formatWaves — output formatting', () => {
|
|
402
|
+
test('formats waves with task IDs and descriptions', () => {
|
|
403
|
+
const waves = [
|
|
404
|
+
[{ id: 'T1', description: 'Build parser' }, { id: 'T4', description: 'Setup CI' }],
|
|
405
|
+
[{ id: 'T2', description: 'Wire CLI' }],
|
|
406
|
+
];
|
|
407
|
+
const output = formatWaves(waves);
|
|
408
|
+
assert.equal(output, 'Wave 1: T1 — Build parser, T4 — Setup CI\nWave 2: T2 — Wire CLI');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('handles tasks with empty descriptions', () => {
|
|
412
|
+
const waves = [
|
|
413
|
+
[{ id: 'T1', description: '' }],
|
|
414
|
+
];
|
|
415
|
+
const output = formatWaves(waves);
|
|
416
|
+
assert.equal(output, 'Wave 1: T1');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test('returns "(no pending tasks)" for empty waves', () => {
|
|
420
|
+
const output = formatWaves([]);
|
|
421
|
+
assert.equal(output, '(no pending tasks)');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// 6. Integration: parsePlan → buildWaves end-to-end
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
describe('Integration — parsePlan → buildWaves', () => {
|
|
430
|
+
test('realistic PLAN.md produces correct waves', () => {
|
|
431
|
+
const planText = `
|
|
432
|
+
# PLAN
|
|
433
|
+
|
|
434
|
+
## Tasks
|
|
435
|
+
|
|
436
|
+
- [x] **T1**: Setup project structure
|
|
437
|
+
- [ ] **T2**: Implement parser
|
|
438
|
+
- Blocked by: T1
|
|
439
|
+
- [ ] **T3**: Add CLI interface
|
|
440
|
+
- Blocked by: T1
|
|
441
|
+
- [ ] **T4**: Integration tests
|
|
442
|
+
- Blocked by: T2, T3
|
|
443
|
+
- [ ] **T5**: Documentation
|
|
444
|
+
`;
|
|
445
|
+
|
|
446
|
+
const tasks = parsePlan(planText);
|
|
447
|
+
// T1 is completed, so only T2, T3, T4, T5 are pending
|
|
448
|
+
assert.equal(tasks.length, 4);
|
|
449
|
+
|
|
450
|
+
const waves = buildWaves(tasks, new Set());
|
|
451
|
+
// T2, T3, T5 have no pending deps (T1 is completed) → wave 1
|
|
452
|
+
// T4 depends on T2 and T3 → wave 2
|
|
453
|
+
assert.equal(waves.length, 2);
|
|
454
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T2', 'T3', 'T5']);
|
|
455
|
+
assert.deepEqual(waves[1].map(t => t.id), ['T4']);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('recalc with failed task excludes dependents from waves', () => {
|
|
459
|
+
const planText = `
|
|
460
|
+
- [ ] **T1**: Base implementation
|
|
461
|
+
- [ ] **T2**: Feature A
|
|
462
|
+
- Blocked by: T1
|
|
463
|
+
- [ ] **T3**: Feature B
|
|
464
|
+
- Blocked by: T1
|
|
465
|
+
- [ ] **T4**: Feature C
|
|
466
|
+
`;
|
|
467
|
+
|
|
468
|
+
const tasks = parsePlan(planText);
|
|
469
|
+
const waves = buildWaves(tasks, new Set(['T1']));
|
|
470
|
+
// T1 stuck → T2, T3 excluded → only T4
|
|
471
|
+
assert.equal(waves.length, 1);
|
|
472
|
+
assert.deepEqual(waves[0].map(t => t.id), ['T4']);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// 7. CLI subprocess tests
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
describe('CLI — wave-runner.js subprocess', () => {
|
|
481
|
+
test('exits 1 when PLAN.md is not found', () => {
|
|
482
|
+
const tmpDir = makeTmpDir();
|
|
483
|
+
try {
|
|
484
|
+
const { code, stderr } = runWaveRunner([], { cwd: tmpDir });
|
|
485
|
+
assert.equal(code, 1);
|
|
486
|
+
assert.ok(stderr.includes('not found'), `Expected "not found" in stderr: ${stderr}`);
|
|
487
|
+
} finally {
|
|
488
|
+
rmrf(tmpDir);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('exits 0 and prints waves for valid PLAN.md', () => {
|
|
493
|
+
const tmpDir = makeTmpDir();
|
|
494
|
+
try {
|
|
495
|
+
fs.writeFileSync(
|
|
496
|
+
path.join(tmpDir, 'PLAN.md'),
|
|
497
|
+
'- [ ] **T1**: First\n- [ ] **T2**: Second\n - Blocked by: T1\n'
|
|
498
|
+
);
|
|
499
|
+
const { code, stdout } = runWaveRunner([], { cwd: tmpDir });
|
|
500
|
+
assert.equal(code, 0);
|
|
501
|
+
assert.ok(stdout.includes('Wave 1:'));
|
|
502
|
+
assert.ok(stdout.includes('T1'));
|
|
503
|
+
assert.ok(stdout.includes('Wave 2:'));
|
|
504
|
+
assert.ok(stdout.includes('T2'));
|
|
505
|
+
} finally {
|
|
506
|
+
rmrf(tmpDir);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('--plan flag reads from custom path', () => {
|
|
511
|
+
const tmpDir = makeTmpDir();
|
|
512
|
+
try {
|
|
513
|
+
fs.writeFileSync(
|
|
514
|
+
path.join(tmpDir, 'custom.md'),
|
|
515
|
+
'- [ ] **T1**: Only task\n'
|
|
516
|
+
);
|
|
517
|
+
const { code, stdout } = runWaveRunner(['--plan', 'custom.md'], { cwd: tmpDir });
|
|
518
|
+
assert.equal(code, 0);
|
|
519
|
+
assert.ok(stdout.includes('T1'));
|
|
520
|
+
} finally {
|
|
521
|
+
rmrf(tmpDir);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('--recalc --failed excludes failed task and dependents', () => {
|
|
526
|
+
const tmpDir = makeTmpDir();
|
|
527
|
+
try {
|
|
528
|
+
fs.writeFileSync(
|
|
529
|
+
path.join(tmpDir, 'PLAN.md'),
|
|
530
|
+
'- [ ] **T1**: Base\n- [ ] **T2**: Depends on T1\n - Blocked by: T1\n- [ ] **T3**: Independent\n'
|
|
531
|
+
);
|
|
532
|
+
const { code, stdout } = runWaveRunner(['--recalc', '--failed', 'T1'], { cwd: tmpDir });
|
|
533
|
+
assert.equal(code, 0);
|
|
534
|
+
assert.ok(stdout.includes('T3'), 'Independent task should appear');
|
|
535
|
+
assert.ok(!stdout.includes('T1'), 'Failed task should not appear');
|
|
536
|
+
assert.ok(!stdout.includes('T2'), 'Dependent of failed task should not appear');
|
|
537
|
+
} finally {
|
|
538
|
+
rmrf(tmpDir);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test('prints "(no pending tasks)" when all tasks are completed', () => {
|
|
543
|
+
const tmpDir = makeTmpDir();
|
|
544
|
+
try {
|
|
545
|
+
fs.writeFileSync(
|
|
546
|
+
path.join(tmpDir, 'PLAN.md'),
|
|
547
|
+
'- [x] **T1**: Done\n- [x] **T2**: Also done\n'
|
|
548
|
+
);
|
|
549
|
+
const { code, stdout } = runWaveRunner([], { cwd: tmpDir });
|
|
550
|
+
assert.equal(code, 0);
|
|
551
|
+
assert.ok(stdout.includes('(no pending tasks)'));
|
|
552
|
+
} finally {
|
|
553
|
+
rmrf(tmpDir);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
});
|