deepflow 0.1.96 → 0.1.98

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.
@@ -0,0 +1,882 @@
1
+ /**
2
+ * Tests for bin/plan-consolidator.js — mechanical consolidation of mini-plans.
3
+ *
4
+ * Tests cover:
5
+ * 1. Mini-plan parsing: task extraction, tags, files, blocked-by
6
+ * 2. T-id renumbering from local to global (sequential, no gaps)
7
+ * 3. Blocked-by reference remapping to global T-ids
8
+ * 4. Cross-spec file-conflict detection with [file-conflict: {filename}] annotations
9
+ * 5. Input mini-plan files are never modified (read-only)
10
+ * 6. Edge cases: single spec, empty plans, duplicate files, no tasks
11
+ * 7. CLI: --plans-dir argument parsing and stdout output
12
+ * 8. Output formatting: wave-runner-compatible markdown
13
+ *
14
+ * Uses Node.js built-in node:test to avoid adding dependencies.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const { test, describe } = require('node:test');
20
+ const assert = require('node:assert/strict');
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+ const os = require('node:os');
24
+ const { execFileSync, spawnSync } = require('node:child_process');
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const CONSOLIDATOR_PATH = path.resolve(__dirname, 'plan-consolidator.js');
31
+ const CONSOLIDATOR_SRC = fs.readFileSync(CONSOLIDATOR_PATH, 'utf8');
32
+
33
+ function makeTmpDir() {
34
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'df-plan-consolidator-test-'));
35
+ }
36
+
37
+ function rmrf(dir) {
38
+ if (fs.existsSync(dir)) {
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Extract pure functions from plan-consolidator.js source for unit testing.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const extractedFns = (() => {
48
+ const modifiedSrc = CONSOLIDATOR_SRC
49
+ .replace(/^main\(\);?\s*$/m, '')
50
+ .replace(/^#!.*$/m, '');
51
+
52
+ const wrapped = `
53
+ ${modifiedSrc}
54
+ return { parseArgs, parseMiniPlan, detectFileConflicts, consolidate, formatConsolidated };
55
+ `;
56
+
57
+ const factory = new Function('require', 'process', '__dirname', '__filename', 'module', 'exports', wrapped);
58
+ return factory(require, process, __dirname, __filename, module, exports);
59
+ })();
60
+
61
+ const { parseArgs, parseMiniPlan, detectFileConflicts, consolidate, formatConsolidated } = extractedFns;
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // CLI runner helper
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function runConsolidator(args = [], { cwd } = {}) {
68
+ const result = spawnSync(
69
+ process.execPath,
70
+ [CONSOLIDATOR_PATH, ...args],
71
+ {
72
+ cwd: cwd || os.tmpdir(),
73
+ encoding: 'utf8',
74
+ }
75
+ );
76
+ return {
77
+ stdout: result.stdout || '',
78
+ stderr: result.stderr || '',
79
+ code: result.status ?? 1,
80
+ };
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // 1. parseArgs
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe('parseArgs', () => {
88
+ test('extracts --plans-dir value', () => {
89
+ const args = parseArgs(['node', 'plan-consolidator.js', '--plans-dir', '/tmp/plans']);
90
+ assert.equal(args.plansDir, '/tmp/plans');
91
+ });
92
+
93
+ test('returns null when --plans-dir missing', () => {
94
+ const args = parseArgs(['node', 'plan-consolidator.js']);
95
+ assert.equal(args.plansDir, null);
96
+ });
97
+
98
+ test('returns null when --plans-dir has no following value', () => {
99
+ const args = parseArgs(['node', 'plan-consolidator.js', '--plans-dir']);
100
+ assert.equal(args.plansDir, null);
101
+ });
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // 2. parseMiniPlan
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe('parseMiniPlan', () => {
109
+ test('parses basic pending tasks', () => {
110
+ const text = `## Tasks
111
+
112
+ - [ ] **T1**: Set up project structure
113
+ - [ ] **T2**: Add routing layer
114
+ `;
115
+ const tasks = parseMiniPlan(text);
116
+ assert.equal(tasks.length, 2);
117
+ assert.equal(tasks[0].localId, 'T1');
118
+ assert.equal(tasks[0].num, 1);
119
+ assert.equal(tasks[0].description, 'Set up project structure');
120
+ assert.equal(tasks[1].localId, 'T2');
121
+ assert.equal(tasks[1].description, 'Add routing layer');
122
+ });
123
+
124
+ test('parses tasks with tags', () => {
125
+ const text = '- [ ] **T1** [spike]: Explore API design\n';
126
+ const tasks = parseMiniPlan(text);
127
+ assert.equal(tasks.length, 1);
128
+ assert.equal(tasks[0].tags, '[spike]');
129
+ assert.equal(tasks[0].description, 'Explore API design');
130
+ });
131
+
132
+ test('parses Files annotation', () => {
133
+ const text = `- [ ] **T1**: Do something
134
+ - Files: src/a.js, src/b.js
135
+ `;
136
+ const tasks = parseMiniPlan(text);
137
+ assert.equal(tasks.length, 1);
138
+ assert.deepEqual(tasks[0].files, ['src/a.js', 'src/b.js']);
139
+ });
140
+
141
+ test('parses Blocked by annotation', () => {
142
+ const text = `- [ ] **T1**: First task
143
+ - [ ] **T2**: Second task
144
+ - Blocked by: T1
145
+ `;
146
+ const tasks = parseMiniPlan(text);
147
+ assert.equal(tasks.length, 2);
148
+ assert.deepEqual(tasks[1].blockedBy, ['T1']);
149
+ });
150
+
151
+ test('parses multiple blocked-by refs', () => {
152
+ const text = `- [ ] **T1**: A
153
+ - [ ] **T2**: B
154
+ - [ ] **T3**: C
155
+ - Blocked by: T1, T2
156
+ `;
157
+ const tasks = parseMiniPlan(text);
158
+ assert.deepEqual(tasks[2].blockedBy, ['T1', 'T2']);
159
+ });
160
+
161
+ test('skips completed tasks', () => {
162
+ const text = `- [x] **T1**: Done task
163
+ - [ ] **T2**: Pending task
164
+ `;
165
+ const tasks = parseMiniPlan(text);
166
+ assert.equal(tasks.length, 1);
167
+ assert.equal(tasks[0].localId, 'T2');
168
+ });
169
+
170
+ test('does not attach annotations from after a completed task to a previous pending task', () => {
171
+ const text = `- [ ] **T1**: First
172
+ - [x] **T2**: Completed
173
+ - Files: should-not-attach.js
174
+ - [ ] **T3**: Third
175
+ `;
176
+ const tasks = parseMiniPlan(text);
177
+ assert.equal(tasks.length, 2);
178
+ // Files line after completed T2 should not attach to T1
179
+ assert.deepEqual(tasks[0].files, []);
180
+ });
181
+
182
+ test('returns empty array for text with no tasks', () => {
183
+ const tasks = parseMiniPlan('# Just a heading\n\nSome prose.\n');
184
+ assert.equal(tasks.length, 0);
185
+ });
186
+
187
+ test('returns empty array for empty string', () => {
188
+ const tasks = parseMiniPlan('');
189
+ assert.equal(tasks.length, 0);
190
+ });
191
+
192
+ test('handles task with no description', () => {
193
+ const text = '- [ ] **T1**:\n';
194
+ const tasks = parseMiniPlan(text);
195
+ assert.equal(tasks.length, 1);
196
+ assert.equal(tasks[0].description, '');
197
+ });
198
+ });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // 3. detectFileConflicts
202
+ // ---------------------------------------------------------------------------
203
+
204
+ describe('detectFileConflicts', () => {
205
+ test('detects files touched by multiple specs', () => {
206
+ const specEntries = [
207
+ { specName: 'auth', tasks: [{ files: ['src/user.js', 'src/db.js'] }] },
208
+ { specName: 'billing', tasks: [{ files: ['src/db.js', 'src/payment.js'] }] },
209
+ ];
210
+ const conflicts = detectFileConflicts(specEntries);
211
+ assert.equal(conflicts.size, 1);
212
+ assert.ok(conflicts.has('src/db.js'));
213
+ const specs = conflicts.get('src/db.js');
214
+ assert.ok(specs.includes('auth'));
215
+ assert.ok(specs.includes('billing'));
216
+ });
217
+
218
+ test('returns empty map when no conflicts', () => {
219
+ const specEntries = [
220
+ { specName: 'auth', tasks: [{ files: ['src/auth.js'] }] },
221
+ { specName: 'billing', tasks: [{ files: ['src/billing.js'] }] },
222
+ ];
223
+ const conflicts = detectFileConflicts(specEntries);
224
+ assert.equal(conflicts.size, 0);
225
+ });
226
+
227
+ test('returns empty map for single spec', () => {
228
+ const specEntries = [
229
+ { specName: 'auth', tasks: [{ files: ['src/a.js'] }, { files: ['src/a.js'] }] },
230
+ ];
231
+ const conflicts = detectFileConflicts(specEntries);
232
+ assert.equal(conflicts.size, 0);
233
+ });
234
+
235
+ test('returns empty map when no files declared', () => {
236
+ const specEntries = [
237
+ { specName: 'auth', tasks: [{ files: [] }] },
238
+ { specName: 'billing', tasks: [{ files: [] }] },
239
+ ];
240
+ const conflicts = detectFileConflicts(specEntries);
241
+ assert.equal(conflicts.size, 0);
242
+ });
243
+
244
+ test('detects multiple conflicted files', () => {
245
+ const specEntries = [
246
+ { specName: 'a', tasks: [{ files: ['shared.js', 'utils.js'] }] },
247
+ { specName: 'b', tasks: [{ files: ['shared.js', 'utils.js'] }] },
248
+ { specName: 'c', tasks: [{ files: ['shared.js'] }] },
249
+ ];
250
+ const conflicts = detectFileConflicts(specEntries);
251
+ assert.equal(conflicts.size, 2);
252
+ assert.equal(conflicts.get('shared.js').length, 3);
253
+ assert.equal(conflicts.get('utils.js').length, 2);
254
+ });
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // 4. consolidate — T-id renumbering and blocked-by remapping
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe('consolidate', () => {
262
+ test('renumbers T-ids globally in sequential order', () => {
263
+ const specEntries = [
264
+ {
265
+ specName: 'alpha',
266
+ tasks: [
267
+ { localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: [], files: [] },
268
+ { localId: 'T2', num: 2, description: 'A2', tags: '', blockedBy: [], files: [] },
269
+ ],
270
+ },
271
+ {
272
+ specName: 'beta',
273
+ tasks: [
274
+ { localId: 'T1', num: 1, description: 'B1', tags: '', blockedBy: [], files: [] },
275
+ { localId: 'T2', num: 2, description: 'B2', tags: '', blockedBy: [], files: [] },
276
+ ],
277
+ },
278
+ ];
279
+ const result = consolidate(specEntries, new Map());
280
+ assert.equal(result.length, 4);
281
+ assert.equal(result[0].globalId, 'T1');
282
+ assert.equal(result[1].globalId, 'T2');
283
+ assert.equal(result[2].globalId, 'T3');
284
+ assert.equal(result[3].globalId, 'T4');
285
+ });
286
+
287
+ test('remaps blocked-by references to global T-ids', () => {
288
+ const specEntries = [
289
+ {
290
+ specName: 'alpha',
291
+ tasks: [
292
+ { localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: [], files: [] },
293
+ { localId: 'T2', num: 2, description: 'A2', tags: '', blockedBy: ['T1'], files: [] },
294
+ ],
295
+ },
296
+ {
297
+ specName: 'beta',
298
+ tasks: [
299
+ { localId: 'T1', num: 1, description: 'B1', tags: '', blockedBy: [], files: [] },
300
+ { localId: 'T2', num: 2, description: 'B2', tags: '', blockedBy: ['T1'], files: [] },
301
+ ],
302
+ },
303
+ ];
304
+ const result = consolidate(specEntries, new Map());
305
+ // alpha T2 blocked by alpha T1 → global T2 blocked by T1
306
+ assert.deepEqual(result[1].blockedBy, ['T1']);
307
+ // beta T2 blocked by beta T1 → global T4 blocked by T3
308
+ assert.deepEqual(result[3].blockedBy, ['T3']);
309
+ });
310
+
311
+ test('drops cross-spec blocked-by references that do not exist in local map', () => {
312
+ const specEntries = [
313
+ {
314
+ specName: 'alpha',
315
+ tasks: [
316
+ { localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: ['T99'], files: [] },
317
+ ],
318
+ },
319
+ ];
320
+ const result = consolidate(specEntries, new Map());
321
+ assert.deepEqual(result[0].blockedBy, []);
322
+ });
323
+
324
+ test('adds file-conflict annotations to affected tasks', () => {
325
+ const specEntries = [
326
+ {
327
+ specName: 'alpha',
328
+ tasks: [
329
+ { localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: [], files: ['shared.js'] },
330
+ ],
331
+ },
332
+ ];
333
+ const fileConflicts = new Map([['shared.js', ['alpha', 'beta']]]);
334
+ const result = consolidate(specEntries, fileConflicts);
335
+ assert.deepEqual(result[0].conflictAnnotations, ['[file-conflict: shared.js]']);
336
+ });
337
+
338
+ test('no conflict annotations when file is not conflicted', () => {
339
+ const specEntries = [
340
+ {
341
+ specName: 'alpha',
342
+ tasks: [
343
+ { localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: [], files: ['only-mine.js'] },
344
+ ],
345
+ },
346
+ ];
347
+ const result = consolidate(specEntries, new Map());
348
+ assert.deepEqual(result[0].conflictAnnotations, []);
349
+ });
350
+
351
+ test('handles empty spec entries', () => {
352
+ const result = consolidate([], new Map());
353
+ assert.equal(result.length, 0);
354
+ });
355
+
356
+ test('handles spec with no tasks', () => {
357
+ const specEntries = [{ specName: 'empty', tasks: [] }];
358
+ const result = consolidate(specEntries, new Map());
359
+ assert.equal(result.length, 0);
360
+ });
361
+
362
+ test('preserves tags and specName on consolidated tasks', () => {
363
+ const specEntries = [
364
+ {
365
+ specName: 'auth',
366
+ tasks: [
367
+ { localId: 'T1', num: 1, description: 'spike it', tags: '[spike]', blockedBy: [], files: [] },
368
+ ],
369
+ },
370
+ ];
371
+ const result = consolidate(specEntries, new Map());
372
+ assert.equal(result[0].tags, '[spike]');
373
+ assert.equal(result[0].specName, 'auth');
374
+ });
375
+ });
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // 5. formatConsolidated
379
+ // ---------------------------------------------------------------------------
380
+
381
+ describe('formatConsolidated', () => {
382
+ test('outputs empty state for no tasks', () => {
383
+ const output = formatConsolidated([]);
384
+ assert.ok(output.includes('(no tasks found)'));
385
+ assert.ok(output.startsWith('## Tasks'));
386
+ });
387
+
388
+ test('groups tasks under spec headings', () => {
389
+ const tasks = [
390
+ { globalId: 'T1', specName: 'auth', description: 'Login', tags: '', blockedBy: [], files: [], conflictAnnotations: [] },
391
+ { globalId: 'T2', specName: 'auth', description: 'Logout', tags: '', blockedBy: ['T1'], files: [], conflictAnnotations: [] },
392
+ { globalId: 'T3', specName: 'billing', description: 'Charge', tags: '', blockedBy: [], files: ['billing.js'], conflictAnnotations: [] },
393
+ ];
394
+ const output = formatConsolidated(tasks);
395
+ assert.ok(output.includes('### auth'));
396
+ assert.ok(output.includes('### billing'));
397
+ assert.ok(output.includes('- [ ] **T1**: Login'));
398
+ assert.ok(output.includes('- [ ] **T2**: Logout'));
399
+ assert.ok(output.includes('Blocked by: T1'));
400
+ assert.ok(output.includes('Blocked by: none'));
401
+ assert.ok(output.includes('Files: billing.js'));
402
+ });
403
+
404
+ test('appends file-conflict annotations to description', () => {
405
+ const tasks = [
406
+ {
407
+ globalId: 'T1', specName: 'x', description: 'Do stuff', tags: '',
408
+ blockedBy: [], files: ['shared.js'], conflictAnnotations: ['[file-conflict: shared.js]'],
409
+ },
410
+ ];
411
+ const output = formatConsolidated(tasks);
412
+ assert.ok(output.includes('[file-conflict: shared.js]'));
413
+ });
414
+
415
+ test('includes tags in task header', () => {
416
+ const tasks = [
417
+ { globalId: 'T1', specName: 'x', description: 'Spike it', tags: '[spike]', blockedBy: [], files: [], conflictAnnotations: [] },
418
+ ];
419
+ const output = formatConsolidated(tasks);
420
+ assert.ok(output.includes('**T1** [spike]: Spike it'));
421
+ });
422
+ });
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // 6. CLI integration tests
426
+ // ---------------------------------------------------------------------------
427
+
428
+ describe('CLI integration', () => {
429
+ test('exits 1 when --plans-dir is missing', () => {
430
+ const result = runConsolidator([]);
431
+ assert.equal(result.code, 1);
432
+ assert.ok(result.stderr.includes('--plans-dir'));
433
+ });
434
+
435
+ test('exits 1 when plans directory does not exist', () => {
436
+ const result = runConsolidator(['--plans-dir', '/tmp/nonexistent-df-test-dir-' + Date.now()]);
437
+ assert.equal(result.code, 1);
438
+ assert.ok(result.stderr.includes('not found'));
439
+ });
440
+
441
+ test('outputs empty message for directory with no doing- files', () => {
442
+ const tmpDir = makeTmpDir();
443
+ try {
444
+ const result = runConsolidator(['--plans-dir', tmpDir]);
445
+ assert.equal(result.code, 0);
446
+ assert.ok(result.stdout.includes('no mini-plan files'));
447
+ } finally {
448
+ rmrf(tmpDir);
449
+ }
450
+ });
451
+
452
+ test('consolidates a single mini-plan file', () => {
453
+ const tmpDir = makeTmpDir();
454
+ try {
455
+ fs.writeFileSync(path.join(tmpDir, 'doing-auth.md'), `## Tasks
456
+
457
+ - [ ] **T1**: Set up auth module
458
+ - Files: src/auth.js
459
+ - [ ] **T2**: Add login endpoint
460
+ - Files: src/auth.js, src/routes.js
461
+ - Blocked by: T1
462
+ `);
463
+ const result = runConsolidator(['--plans-dir', tmpDir]);
464
+ assert.equal(result.code, 0);
465
+ assert.ok(result.stdout.includes('**T1**'));
466
+ assert.ok(result.stdout.includes('**T2**'));
467
+ assert.ok(result.stdout.includes('Blocked by: T1'));
468
+ assert.ok(result.stdout.includes('### auth'));
469
+ } finally {
470
+ rmrf(tmpDir);
471
+ }
472
+ });
473
+
474
+ test('consolidates multiple mini-plans with global renumbering', () => {
475
+ const tmpDir = makeTmpDir();
476
+ try {
477
+ fs.writeFileSync(path.join(tmpDir, 'doing-auth.md'), `## Tasks
478
+ - [ ] **T1**: Auth task 1
479
+ - [ ] **T2**: Auth task 2
480
+ - Blocked by: T1
481
+ `);
482
+ fs.writeFileSync(path.join(tmpDir, 'doing-billing.md'), `## Tasks
483
+ - [ ] **T1**: Billing task 1
484
+ - [ ] **T2**: Billing task 2
485
+ - Blocked by: T1
486
+ `);
487
+ const result = runConsolidator(['--plans-dir', tmpDir]);
488
+ assert.equal(result.code, 0);
489
+
490
+ // auth T1→T1, auth T2→T2, billing T1→T3, billing T2→T4
491
+ assert.ok(result.stdout.includes('**T1**: Auth task 1'));
492
+ assert.ok(result.stdout.includes('**T2**: Auth task 2'));
493
+ assert.ok(result.stdout.includes('**T3**: Billing task 1'));
494
+ assert.ok(result.stdout.includes('**T4**: Billing task 2'));
495
+
496
+ // Blocked-by remapping: billing T2 blocked by billing T1 → T4 blocked by T3
497
+ assert.ok(result.stdout.includes('Blocked by: T3'));
498
+ } finally {
499
+ rmrf(tmpDir);
500
+ }
501
+ });
502
+
503
+ test('detects cross-spec file conflicts and annotates tasks', () => {
504
+ const tmpDir = makeTmpDir();
505
+ try {
506
+ fs.writeFileSync(path.join(tmpDir, 'doing-auth.md'), `## Tasks
507
+ - [ ] **T1**: Touch shared file
508
+ - Files: src/shared.js
509
+ `);
510
+ fs.writeFileSync(path.join(tmpDir, 'doing-billing.md'), `## Tasks
511
+ - [ ] **T1**: Also touch shared file
512
+ - Files: src/shared.js
513
+ `);
514
+ const result = runConsolidator(['--plans-dir', tmpDir]);
515
+ assert.equal(result.code, 0);
516
+ assert.ok(result.stdout.includes('[file-conflict: src/shared.js]'));
517
+ // Conflict warning on stderr
518
+ assert.ok(result.stderr.includes('conflict'), 'stderr should mention conflict');
519
+ } finally {
520
+ rmrf(tmpDir);
521
+ }
522
+ });
523
+
524
+ test('input mini-plan files are never modified', () => {
525
+ const tmpDir = makeTmpDir();
526
+ try {
527
+ const content = `## Tasks
528
+ - [ ] **T1**: Original task
529
+ - Files: src/a.js
530
+ - Blocked by: T99
531
+ `;
532
+ const filePath = path.join(tmpDir, 'doing-readonly.md');
533
+ fs.writeFileSync(filePath, content);
534
+ const mtimeBefore = fs.statSync(filePath).mtimeMs;
535
+
536
+ runConsolidator(['--plans-dir', tmpDir]);
537
+
538
+ const contentAfter = fs.readFileSync(filePath, 'utf8');
539
+ const mtimeAfter = fs.statSync(filePath).mtimeMs;
540
+ assert.equal(contentAfter, content, 'File content must not change');
541
+ assert.equal(mtimeAfter, mtimeBefore, 'File mtime must not change');
542
+ } finally {
543
+ rmrf(tmpDir);
544
+ }
545
+ });
546
+
547
+ test('ignores non-doing files in plans directory', () => {
548
+ const tmpDir = makeTmpDir();
549
+ try {
550
+ fs.writeFileSync(path.join(tmpDir, 'done-old.md'), `- [ ] **T1**: Should be ignored\n`);
551
+ fs.writeFileSync(path.join(tmpDir, 'some-notes.md'), `- [ ] **T1**: Also ignored\n`);
552
+ fs.writeFileSync(path.join(tmpDir, 'doing-active.md'), `- [ ] **T1**: Included\n`);
553
+
554
+ const result = runConsolidator(['--plans-dir', tmpDir]);
555
+ assert.equal(result.code, 0);
556
+ assert.ok(result.stdout.includes('**T1**: Included'));
557
+ assert.ok(!result.stdout.includes('Should be ignored'));
558
+ assert.ok(!result.stdout.includes('Also ignored'));
559
+ } finally {
560
+ rmrf(tmpDir);
561
+ }
562
+ });
563
+
564
+ test('processes files in alphabetical order for determinism', () => {
565
+ const tmpDir = makeTmpDir();
566
+ try {
567
+ // Write in reverse alphabetical order
568
+ fs.writeFileSync(path.join(tmpDir, 'doing-zebra.md'), `- [ ] **T1**: Zebra task\n`);
569
+ fs.writeFileSync(path.join(tmpDir, 'doing-alpha.md'), `- [ ] **T1**: Alpha task\n`);
570
+
571
+ const result = runConsolidator(['--plans-dir', tmpDir]);
572
+ assert.equal(result.code, 0);
573
+ // alpha sorts before zebra, so alpha T1 → T1, zebra T1 → T2
574
+ const t1Pos = result.stdout.indexOf('**T1**: Alpha task');
575
+ const t2Pos = result.stdout.indexOf('**T2**: Zebra task');
576
+ assert.ok(t1Pos >= 0, 'Alpha task should be T1');
577
+ assert.ok(t2Pos >= 0, 'Zebra task should be T2');
578
+ assert.ok(t1Pos < t2Pos, 'Alpha should appear before Zebra');
579
+ } finally {
580
+ rmrf(tmpDir);
581
+ }
582
+ });
583
+
584
+ test('derives spec name correctly from hyphenated filename', () => {
585
+ const tmpDir = makeTmpDir();
586
+ try {
587
+ fs.writeFileSync(path.join(tmpDir, 'doing-my-feature-name.md'), `- [ ] **T1**: Some task\n`);
588
+ const result = runConsolidator(['--plans-dir', tmpDir]);
589
+ assert.equal(result.code, 0);
590
+ assert.ok(result.stdout.includes('### my-feature-name'), 'spec name should preserve hyphens');
591
+ } finally {
592
+ rmrf(tmpDir);
593
+ }
594
+ });
595
+
596
+ test('handles mini-plan with mixed completed and pending tasks', () => {
597
+ const tmpDir = makeTmpDir();
598
+ try {
599
+ fs.writeFileSync(path.join(tmpDir, 'doing-mixed.md'), `## Tasks
600
+
601
+ - [x] **T1**: Already done
602
+ - Files: src/done.js
603
+ - [ ] **T2**: Still pending
604
+ - Files: src/pending.js
605
+ - Blocked by: T1
606
+ - [x] **T3**: Also done
607
+ - [ ] **T4**: Last pending
608
+ - Blocked by: T2
609
+ `);
610
+ const result = runConsolidator(['--plans-dir', tmpDir]);
611
+ assert.equal(result.code, 0);
612
+ // Only pending tasks included; T2 and T4 renumbered to T1 and T2
613
+ assert.ok(result.stdout.includes('**T1**: Still pending'), 'T2 becomes global T1');
614
+ assert.ok(result.stdout.includes('**T2**: Last pending'), 'T4 becomes global T2');
615
+ assert.ok(!result.stdout.includes('Already done'), 'completed tasks excluded');
616
+ assert.ok(!result.stdout.includes('Also done'), 'completed tasks excluded');
617
+ } finally {
618
+ rmrf(tmpDir);
619
+ }
620
+ });
621
+
622
+ test('blocked-by remapping skips completed task ids correctly', () => {
623
+ // T4 is blocked by T2 in the local plan; T1 and T3 are completed.
624
+ // After parsing, only T2→globalT1, T4→globalT2 remain. T4's dep on T2 → globalT1.
625
+ const tmpDir = makeTmpDir();
626
+ try {
627
+ fs.writeFileSync(path.join(tmpDir, 'doing-partial.md'), `## Tasks
628
+
629
+ - [x] **T1**: Completed first
630
+ - [ ] **T2**: Pending second
631
+ - [x] **T3**: Completed third
632
+ - [ ] **T4**: Pending fourth
633
+ - Blocked by: T2
634
+ `);
635
+ const result = runConsolidator(['--plans-dir', tmpDir]);
636
+ assert.equal(result.code, 0);
637
+ assert.ok(result.stdout.includes('**T1**: Pending second'));
638
+ assert.ok(result.stdout.includes('**T2**: Pending fourth'));
639
+ assert.ok(result.stdout.includes('Blocked by: T1'), 'T4 dep on T2 remapped to global T1');
640
+ } finally {
641
+ rmrf(tmpDir);
642
+ }
643
+ });
644
+ });
645
+
646
+ // ---------------------------------------------------------------------------
647
+ // 7. parseMiniPlan — additional edge cases
648
+ // ---------------------------------------------------------------------------
649
+
650
+ describe('parseMiniPlan — edge cases', () => {
651
+ test('parses task with large T-number (T100)', () => {
652
+ const text = '- [ ] **T100**: Big numbered task\n';
653
+ const tasks = parseMiniPlan(text);
654
+ assert.equal(tasks.length, 1);
655
+ assert.equal(tasks[0].localId, 'T100');
656
+ assert.equal(tasks[0].num, 100);
657
+ assert.equal(tasks[0].description, 'Big numbered task');
658
+ });
659
+
660
+ test('parses task with both tags and files+blocked-by annotations', () => {
661
+ const text = `- [ ] **T1** [spike]: Explore caching
662
+ - Files: src/cache.js, src/utils.js
663
+ - Blocked by: T0
664
+ `;
665
+ const tasks = parseMiniPlan(text);
666
+ assert.equal(tasks.length, 1);
667
+ assert.equal(tasks[0].tags, '[spike]');
668
+ assert.equal(tasks[0].description, 'Explore caching');
669
+ assert.deepEqual(tasks[0].files, ['src/cache.js', 'src/utils.js']);
670
+ assert.deepEqual(tasks[0].blockedBy, ['T0']);
671
+ });
672
+
673
+ test('File: singular form is also parsed', () => {
674
+ const text = `- [ ] **T1**: Single file task
675
+ - File: src/only.js
676
+ `;
677
+ const tasks = parseMiniPlan(text);
678
+ assert.equal(tasks.length, 1);
679
+ assert.deepEqual(tasks[0].files, ['src/only.js']);
680
+ });
681
+
682
+ test('ignores whitespace-only file entries in files list', () => {
683
+ // If someone writes "Files: src/a.js, , src/b.js" — empty segments filtered out
684
+ const text = `- [ ] **T1**: Task
685
+ - Files: src/a.js, , src/b.js
686
+ `;
687
+ const tasks = parseMiniPlan(text);
688
+ assert.equal(tasks.length, 1);
689
+ // filter(Boolean) removes empty strings after trim
690
+ assert.ok(!tasks[0].files.includes(''), 'no empty string in files');
691
+ assert.ok(tasks[0].files.includes('src/a.js'));
692
+ assert.ok(tasks[0].files.includes('src/b.js'));
693
+ });
694
+
695
+ test('multiple pending tasks with non-contiguous numbers', () => {
696
+ // Mini-plan might have T1, T3, T5 if someone manually edited it
697
+ const text = `- [ ] **T1**: First
698
+ - [ ] **T3**: Third (T2 was removed)
699
+ - [ ] **T5**: Fifth
700
+ `;
701
+ const tasks = parseMiniPlan(text);
702
+ assert.equal(tasks.length, 3);
703
+ assert.equal(tasks[0].localId, 'T1');
704
+ assert.equal(tasks[1].localId, 'T3');
705
+ assert.equal(tasks[2].localId, 'T5');
706
+ });
707
+
708
+ test('malformed blocked-by line with no valid T-refs is parsed as empty', () => {
709
+ const text = `- [ ] **T1**: Task
710
+ - Blocked by: none
711
+ `;
712
+ const tasks = parseMiniPlan(text);
713
+ assert.equal(tasks.length, 1);
714
+ // "none" does not match /^T\d+$/ so blockedBy stays empty
715
+ assert.deepEqual(tasks[0].blockedBy, []);
716
+ });
717
+
718
+ test('case-insensitive blocked by', () => {
719
+ const text = `- [ ] **T1**: A
720
+ - [ ] **T2**: B
721
+ - blocked by: T1
722
+ `;
723
+ const tasks = parseMiniPlan(text);
724
+ assert.deepEqual(tasks[1].blockedBy, ['T1']);
725
+ });
726
+
727
+ test('case-insensitive completed task marker [X]', () => {
728
+ const text = `- [X] **T1**: Done uppercase X
729
+ - [ ] **T2**: Pending
730
+ `;
731
+ const tasks = parseMiniPlan(text);
732
+ assert.equal(tasks.length, 1);
733
+ assert.equal(tasks[0].localId, 'T2');
734
+ });
735
+ });
736
+
737
+ // ---------------------------------------------------------------------------
738
+ // 8. consolidate — additional edge cases
739
+ // ---------------------------------------------------------------------------
740
+
741
+ describe('consolidate — additional edge cases', () => {
742
+ test('renumbers large task set with correct global offset', () => {
743
+ // First spec has T1..T10, second spec has T1..T5
744
+ const firstSpec = {
745
+ specName: 'big',
746
+ tasks: Array.from({ length: 10 }, (_, i) => ({
747
+ localId: `T${i + 1}`, num: i + 1,
748
+ description: `Task ${i + 1}`, tags: '', blockedBy: [], files: [],
749
+ })),
750
+ };
751
+ const secondSpec = {
752
+ specName: 'small',
753
+ tasks: Array.from({ length: 5 }, (_, i) => ({
754
+ localId: `T${i + 1}`, num: i + 1,
755
+ description: `Small ${i + 1}`, tags: '', blockedBy: [], files: [],
756
+ })),
757
+ };
758
+ const result = consolidate([firstSpec, secondSpec], new Map());
759
+ assert.equal(result.length, 15);
760
+ assert.equal(result[9].globalId, 'T10'); // last of first spec
761
+ assert.equal(result[10].globalId, 'T11'); // first of second spec
762
+ assert.equal(result[14].globalId, 'T15'); // last of second spec
763
+ });
764
+
765
+ test('task with multiple files where only some conflict gets all conflict annotations', () => {
766
+ const specEntries = [
767
+ {
768
+ specName: 'alpha',
769
+ tasks: [{
770
+ localId: 'T1', num: 1, description: 'Mixed files', tags: '',
771
+ blockedBy: [], files: ['shared.js', 'mine-only.js', 'also-shared.js'],
772
+ }],
773
+ },
774
+ ];
775
+ const fileConflicts = new Map([
776
+ ['shared.js', ['alpha', 'beta']],
777
+ ['also-shared.js', ['alpha', 'gamma']],
778
+ ]);
779
+ const result = consolidate(specEntries, fileConflicts);
780
+ assert.equal(result[0].conflictAnnotations.length, 2);
781
+ assert.ok(result[0].conflictAnnotations.includes('[file-conflict: shared.js]'));
782
+ assert.ok(result[0].conflictAnnotations.includes('[file-conflict: also-shared.js]'));
783
+ });
784
+
785
+ test('chain-only: blocked-by ref to a non-local T-id is dropped silently', () => {
786
+ // T2 tries to block on T5 which doesn't exist in this spec
787
+ const specEntries = [
788
+ {
789
+ specName: 'solo',
790
+ tasks: [
791
+ { localId: 'T1', num: 1, description: 'A', tags: '', blockedBy: [], files: [] },
792
+ { localId: 'T2', num: 2, description: 'B', tags: '', blockedBy: ['T1', 'T5'], files: [] },
793
+ ],
794
+ },
795
+ ];
796
+ const result = consolidate(specEntries, new Map());
797
+ // T5 doesn't exist in this spec — should be silently dropped
798
+ assert.deepEqual(result[1].blockedBy, ['T1']);
799
+ });
800
+
801
+ test('spec with all completed tasks contributes nothing to consolidated output', () => {
802
+ // parseMiniPlan skips completed tasks; simulate a spec that had all tasks done
803
+ const specEntries = [
804
+ { specName: 'done-spec', tasks: [] },
805
+ {
806
+ specName: 'active-spec',
807
+ tasks: [{ localId: 'T1', num: 1, description: 'Active', tags: '', blockedBy: [], files: [] }],
808
+ },
809
+ ];
810
+ const result = consolidate(specEntries, new Map());
811
+ assert.equal(result.length, 1);
812
+ assert.equal(result[0].globalId, 'T1');
813
+ assert.equal(result[0].specName, 'active-spec');
814
+ });
815
+ });
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // 9. formatConsolidated — additional edge cases
819
+ // ---------------------------------------------------------------------------
820
+
821
+ describe('formatConsolidated — additional edge cases', () => {
822
+ test('task with both tags and conflict annotations renders correctly', () => {
823
+ const tasks = [
824
+ {
825
+ globalId: 'T1', specName: 'x', description: 'Risky spike',
826
+ tags: '[spike]', blockedBy: [], files: ['shared.js'],
827
+ conflictAnnotations: ['[file-conflict: shared.js]'],
828
+ },
829
+ ];
830
+ const output = formatConsolidated(tasks);
831
+ assert.ok(output.includes('**T1** [spike]: Risky spike [file-conflict: shared.js]'));
832
+ });
833
+
834
+ test('task with no description but has conflict annotation', () => {
835
+ const tasks = [
836
+ {
837
+ globalId: 'T1', specName: 'x', description: '',
838
+ tags: '', blockedBy: [], files: ['shared.js'],
839
+ conflictAnnotations: ['[file-conflict: shared.js]'],
840
+ },
841
+ ];
842
+ const output = formatConsolidated(tasks);
843
+ // descPart = ('' + ' [file-conflict: shared.js]').trim() = '[file-conflict: shared.js]'
844
+ assert.ok(output.includes(': [file-conflict: shared.js]'));
845
+ });
846
+
847
+ test('multiple specs produce separate section headings each time spec changes', () => {
848
+ const tasks = [
849
+ { globalId: 'T1', specName: 'alpha', description: 'A1', tags: '', blockedBy: [], files: [], conflictAnnotations: [] },
850
+ { globalId: 'T2', specName: 'beta', description: 'B1', tags: '', blockedBy: [], files: [], conflictAnnotations: [] },
851
+ { globalId: 'T3', specName: 'alpha', description: 'A2', tags: '', blockedBy: [], files: [], conflictAnnotations: [] },
852
+ ];
853
+ const output = formatConsolidated(tasks);
854
+ // alpha appears twice (tasks interleaved) — two ### alpha headings
855
+ const headingMatches = output.match(/### alpha/g);
856
+ assert.equal(headingMatches.length, 2, 'alpha heading appears twice when tasks interleave');
857
+ });
858
+
859
+ test('multiple files are joined with comma-space in output', () => {
860
+ const tasks = [
861
+ {
862
+ globalId: 'T1', specName: 'x', description: 'Multi-file',
863
+ tags: '', blockedBy: [], files: ['a.js', 'b.js', 'c.js'],
864
+ conflictAnnotations: [],
865
+ },
866
+ ];
867
+ const output = formatConsolidated(tasks);
868
+ assert.ok(output.includes('Files: a.js, b.js, c.js'));
869
+ });
870
+
871
+ test('multiple blocked-by refs are joined with comma-space', () => {
872
+ const tasks = [
873
+ {
874
+ globalId: 'T3', specName: 'x', description: 'Multi-dep',
875
+ tags: '', blockedBy: ['T1', 'T2'], files: [],
876
+ conflictAnnotations: [],
877
+ },
878
+ ];
879
+ const output = formatConsolidated(tasks);
880
+ assert.ok(output.includes('Blocked by: T1, T2'));
881
+ });
882
+ });