gsd-pi 2.33.0-dev.69bff0f → 2.33.0-dev.bafba33

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 (26) hide show
  1. package/README.md +18 -13
  2. package/dist/resources/extensions/gsd/auto-supervisor.ts +5 -10
  3. package/dist/resources/extensions/gsd/auto-worktree.ts +1 -135
  4. package/dist/resources/extensions/gsd/commands.ts +2 -14
  5. package/dist/resources/extensions/gsd/session-lock.ts +16 -80
  6. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +1 -39
  7. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +0 -119
  8. package/dist/resources/extensions/mcporter/extension-manifest.json +12 -0
  9. package/package.json +1 -1
  10. package/src/resources/extensions/gsd/auto-supervisor.ts +5 -10
  11. package/src/resources/extensions/gsd/auto-worktree.ts +1 -135
  12. package/src/resources/extensions/gsd/commands.ts +2 -14
  13. package/src/resources/extensions/gsd/session-lock.ts +16 -80
  14. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +1 -39
  15. package/src/resources/extensions/gsd/tests/session-lock.test.ts +0 -119
  16. package/src/resources/extensions/mcporter/extension-manifest.json +12 -0
  17. package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
  18. package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +0 -317
  19. package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +0 -358
  20. package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +0 -216
  21. package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +0 -206
  22. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +0 -691
  23. package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +0 -317
  24. package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +0 -358
  25. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +0 -216
  26. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +0 -206
@@ -1,317 +0,0 @@
1
- /**
2
- * cache-staleness-regression.test.ts — Regression tests for stale cache bugs.
3
- *
4
- * The GSD parser caches are critical for performance but have caused multiple
5
- * production bugs when not invalidated at the right time.
6
- *
7
- * Regression coverage for:
8
- * #1249 Stale caches in discuss loop → slice appears "not discussed"
9
- * #1240 Stale caches after milestone creation → "No roadmap yet"
10
- * #1236 Same root cause as #1240
11
- *
12
- * Pattern: derive state → write file → invalidate cache → derive again → verify update
13
- */
14
-
15
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
16
- import { join } from 'node:path';
17
- import { tmpdir } from 'node:os';
18
-
19
- import { deriveState, invalidateStateCache } from '../state.ts';
20
- import { invalidateAllCaches } from '../cache.ts';
21
- import { createTestContext } from './test-helpers.ts';
22
-
23
- const { assertEq, assertTrue, report } = createTestContext();
24
-
25
- function createBase(): string {
26
- const base = mkdtempSync(join(tmpdir(), 'gsd-cache-stale-'));
27
- mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
28
- return base;
29
- }
30
-
31
- function cleanup(base: string): void {
32
- rmSync(base, { recursive: true, force: true });
33
- }
34
-
35
- function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void {
36
- const dir = join(base, '.gsd', 'milestones', mid);
37
- mkdirSync(dir, { recursive: true });
38
- writeFileSync(join(dir, `${mid}-${suffix}.md`), content);
39
- }
40
-
41
- function writeSliceFile(base: string, mid: string, sid: string, suffix: string, content: string): void {
42
- const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
43
- mkdirSync(dir, { recursive: true });
44
- writeFileSync(join(dir, `${sid}-${suffix}.md`), content);
45
- }
46
-
47
- async function main(): Promise<void> {
48
-
49
- // ─── 1. Regression #1240: New roadmap detected after cache invalidation ─
50
- console.log('\n=== 1. #1240: roadmap written after first derive → detected after invalidation ===');
51
- {
52
- const base = createBase();
53
- try {
54
- // Step 1: Create milestone with just context (no roadmap)
55
- writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nBuild a thing.\n');
56
-
57
- invalidateAllCaches();
58
- invalidateStateCache();
59
- const state1 = await deriveState(base);
60
- assertEq(state1.phase, 'pre-planning', 'initial: pre-planning (no roadmap)');
61
-
62
- // Step 2: Write roadmap (simulating what the LLM does during planning)
63
- const roadmap = [
64
- '# M001: Test',
65
- '',
66
- '## Slices',
67
- '',
68
- '- [ ] **S01: First Slice** `risk:low` `depends:[]`',
69
- '',
70
- '## Boundary Map',
71
- '',
72
- ].join('\n');
73
- writeMilestoneFile(base, 'M001', 'ROADMAP', roadmap);
74
-
75
- // Step 3: WITHOUT invalidation, the old state might be cached
76
- // The state cache has a 100ms TTL, so wait just past it
77
- await new Promise(r => setTimeout(r, 150));
78
-
79
- // Step 4: Invalidate and re-derive — should see the new roadmap
80
- invalidateAllCaches();
81
- invalidateStateCache();
82
- const state2 = await deriveState(base);
83
- assertEq(state2.phase, 'planning', '#1240: after roadmap write + invalidation → planning phase');
84
- assertEq(state2.activeSlice?.id, 'S01', '#1240: S01 is now the active slice');
85
- } finally {
86
- cleanup(base);
87
- }
88
- }
89
-
90
- // ─── 2. Regression #1249: Slice context detected after cache invalidation ─
91
- console.log('\n=== 2. #1249: slice context written mid-loop → detected after invalidation ===');
92
- {
93
- const base = createBase();
94
- try {
95
- // Create a milestone in needs-discussion phase (CONTEXT-DRAFT, no CONTEXT)
96
- const mDir = join(base, '.gsd', 'milestones', 'M001');
97
- mkdirSync(mDir, { recursive: true });
98
- writeFileSync(join(mDir, 'M001-CONTEXT-DRAFT.md'), '# Draft\n\nSome ideas.\n');
99
-
100
- invalidateAllCaches();
101
- invalidateStateCache();
102
- const state1 = await deriveState(base);
103
- assertEq(state1.phase, 'needs-discussion', 'initial: needs-discussion');
104
-
105
- // Simulate: discussion completes, CONTEXT.md is written
106
- writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nFull context after discussion.\n');
107
-
108
- // Wait past TTL
109
- await new Promise(r => setTimeout(r, 150));
110
-
111
- // Without invalidation, we'd still see 'needs-discussion'
112
- invalidateAllCaches();
113
- invalidateStateCache();
114
- const state2 = await deriveState(base);
115
- // Should now be pre-planning (has context, but no roadmap yet)
116
- // Actually needs-discussion won't trigger because now CONTEXT exists
117
- // The state should advance past needs-discussion
118
- assertTrue(
119
- state2.phase !== 'needs-discussion',
120
- '#1249: after context write + invalidation → not stuck in needs-discussion',
121
- );
122
- } finally {
123
- cleanup(base);
124
- }
125
- }
126
-
127
- // ─── 3. State cache TTL expires naturally ─────────────────────────────
128
- console.log('\n=== 3. state cache TTL: fresh reads after 100ms ===');
129
- {
130
- const base = createBase();
131
- try {
132
- writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
133
-
134
- invalidateAllCaches();
135
- invalidateStateCache();
136
- const state1 = await deriveState(base);
137
- assertEq(state1.phase, 'pre-planning', 'initial: pre-planning');
138
-
139
- // Write roadmap immediately
140
- writeMilestoneFile(base, 'M001', 'ROADMAP', [
141
- '# M001: Test',
142
- '',
143
- '## Slices',
144
- '',
145
- '- [ ] **S01: Slice** `risk:low` `depends:[]`',
146
- '',
147
- ].join('\n'));
148
-
149
- // Immediately after writing (within 100ms TTL), the cache might be stale
150
- const state2 = await deriveState(base);
151
- // This MAY still show pre-planning if within TTL — that's expected behavior
152
-
153
- // Wait past TTL
154
- await new Promise(r => setTimeout(r, 150));
155
-
156
- // ALSO invalidate parse cache (not just state cache)
157
- invalidateAllCaches();
158
- invalidateStateCache();
159
- const state3 = await deriveState(base);
160
- assertEq(state3.phase, 'planning', 'after TTL expiry + invalidation → planning');
161
- } finally {
162
- cleanup(base);
163
- }
164
- }
165
-
166
- // ─── 4. Task completion detection after file write ────────────────────
167
- console.log('\n=== 4. task marked done in plan → state advances ===');
168
- {
169
- const base = createBase();
170
- try {
171
- writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
172
- writeMilestoneFile(base, 'M001', 'ROADMAP', [
173
- '# M001: Test',
174
- '',
175
- '## Slices',
176
- '',
177
- '- [ ] **S01: Slice** `risk:low` `depends:[]`',
178
- '',
179
- ].join('\n'));
180
- writeSliceFile(base, 'M001', 'S01', 'PLAN', [
181
- '# S01: Slice',
182
- '',
183
- '## Tasks',
184
- '',
185
- '- [ ] **T01: First Task** `est:1h`',
186
- '- [ ] **T02: Second Task** `est:1h`',
187
- ].join('\n'));
188
- // Write task plan files
189
- const tasksDir = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks');
190
- mkdirSync(tasksDir, { recursive: true });
191
- writeFileSync(join(tasksDir, 'T01-PLAN.md'), '# T01\nDo thing.');
192
- writeFileSync(join(tasksDir, 'T02-PLAN.md'), '# T02\nDo other thing.');
193
-
194
- invalidateAllCaches();
195
- invalidateStateCache();
196
- const state1 = await deriveState(base);
197
- assertEq(state1.activeTask?.id, 'T01', 'initial: T01 is active task');
198
-
199
- // Mark T01 as done by rewriting the plan
200
- writeSliceFile(base, 'M001', 'S01', 'PLAN', [
201
- '# S01: Slice',
202
- '',
203
- '## Tasks',
204
- '',
205
- '- [x] **T01: First Task** `est:1h`',
206
- '- [ ] **T02: Second Task** `est:1h`',
207
- ].join('\n'));
208
-
209
- await new Promise(r => setTimeout(r, 150));
210
- invalidateAllCaches();
211
- invalidateStateCache();
212
- const state2 = await deriveState(base);
213
- assertEq(state2.activeTask?.id, 'T02', 'after T01 done → T02 is active task');
214
- } finally {
215
- cleanup(base);
216
- }
217
- }
218
-
219
- // ─── 5. Slice completion detection ────────────────────────────────────
220
- console.log('\n=== 5. all tasks done → summarizing phase ===');
221
- {
222
- const base = createBase();
223
- try {
224
- writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
225
- writeMilestoneFile(base, 'M001', 'ROADMAP', [
226
- '# M001: Test',
227
- '',
228
- '## Slices',
229
- '',
230
- '- [ ] **S01: First** `risk:low` `depends:[]`',
231
- '- [ ] **S02: Second** `risk:low` `depends:[S01]`',
232
- '',
233
- ].join('\n'));
234
- writeSliceFile(base, 'M001', 'S01', 'PLAN', [
235
- '# S01',
236
- '',
237
- '## Tasks',
238
- '',
239
- '- [ ] **T01: Task** `est:1h`',
240
- ].join('\n'));
241
- const tasksDir = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks');
242
- mkdirSync(tasksDir, { recursive: true });
243
- writeFileSync(join(tasksDir, 'T01-PLAN.md'), '# T01\nDo it.');
244
-
245
- invalidateAllCaches();
246
- invalidateStateCache();
247
- const state1 = await deriveState(base);
248
- assertEq(state1.phase, 'executing', 'initial: executing');
249
-
250
- // Mark task done
251
- writeSliceFile(base, 'M001', 'S01', 'PLAN', [
252
- '# S01',
253
- '',
254
- '## Tasks',
255
- '',
256
- '- [x] **T01: Task** `est:1h`',
257
- ].join('\n'));
258
-
259
- await new Promise(r => setTimeout(r, 150));
260
- invalidateAllCaches();
261
- invalidateStateCache();
262
- const state2 = await deriveState(base);
263
- assertEq(state2.phase, 'summarizing', 'after all tasks done → summarizing');
264
- } finally {
265
- cleanup(base);
266
- }
267
- }
268
-
269
- // ─── 6. Roadmap slice marked done → advance to next slice ─────────────
270
- console.log('\n=== 6. roadmap slice marked [x] → next slice active ===');
271
- {
272
- const base = createBase();
273
- try {
274
- writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
275
- writeMilestoneFile(base, 'M001', 'ROADMAP', [
276
- '# M001: Test',
277
- '',
278
- '## Slices',
279
- '',
280
- '- [ ] **S01: First** `risk:low` `depends:[]`',
281
- '- [ ] **S02: Second** `risk:low` `depends:[S01]`',
282
- '',
283
- ].join('\n'));
284
-
285
- invalidateAllCaches();
286
- invalidateStateCache();
287
- const state1 = await deriveState(base);
288
- assertEq(state1.activeSlice?.id, 'S01', 'initial: S01 active');
289
-
290
- // Mark S01 as done in roadmap
291
- writeMilestoneFile(base, 'M001', 'ROADMAP', [
292
- '# M001: Test',
293
- '',
294
- '## Slices',
295
- '',
296
- '- [x] **S01: First** `risk:low` `depends:[]`',
297
- '- [ ] **S02: Second** `risk:low` `depends:[S01]`',
298
- '',
299
- ].join('\n'));
300
-
301
- await new Promise(r => setTimeout(r, 150));
302
- invalidateAllCaches();
303
- invalidateStateCache();
304
- const state2 = await deriveState(base);
305
- assertEq(state2.activeSlice?.id, 'S02', 'after S01 done → S02 active');
306
- } finally {
307
- cleanup(base);
308
- }
309
- }
310
-
311
- report();
312
- }
313
-
314
- main().catch((error) => {
315
- console.error(error);
316
- process.exit(1);
317
- });
@@ -1,358 +0,0 @@
1
- /**
2
- * roadmap-parse-regression.test.ts — Regression tests for roadmap parsing.
3
- *
4
- * Exercises parseRoadmapSlices() and the prose fallback parser against
5
- * every known LLM-generated roadmap variant that has caused production bugs.
6
- *
7
- * Regression coverage for:
8
- * #807 Prose slice headers not parsed → "No slice eligible" block
9
- * #1248 Prose header regex only matched H2 with colon separator
10
- * #1243 Same root cause as #1248
11
- *
12
- * Also covers dependency expansion (range syntax) and edge cases.
13
- */
14
-
15
- import { parseRoadmapSlices, expandDependencies } from '../roadmap-slices.ts';
16
- import { createTestContext } from './test-helpers.ts';
17
-
18
- const { assertEq, assertTrue, report } = createTestContext();
19
-
20
- async function main(): Promise<void> {
21
-
22
- // ═══════════════════════════════════════════════════════════════════════
23
- // A. Standard machine-readable format (should always work)
24
- // ═══════════════════════════════════════════════════════════════════════
25
-
26
- console.log('\n=== A. Standard checkbox format ===');
27
-
28
- {
29
- const content = [
30
- '# M001: Test Project',
31
- '',
32
- '## Slices',
33
- '',
34
- '- [ ] **S01: First Slice** `risk:low` `depends:[]`',
35
- '- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`',
36
- '- [x] **S03: Third Slice** `risk:high` `depends:[S01,S02]`',
37
- '',
38
- '## Boundary Map',
39
- '',
40
- ].join('\n');
41
-
42
- const slices = parseRoadmapSlices(content);
43
- assertEq(slices.length, 3, 'standard format: 3 slices');
44
- assertEq(slices[0].id, 'S01', 'S01 id');
45
- assertEq(slices[0].title, 'First Slice', 'S01 title');
46
- assertEq(slices[0].done, false, 'S01 not done');
47
- assertEq(slices[0].risk, 'low', 'S01 risk');
48
- assertEq(slices[0].depends.length, 0, 'S01 no deps');
49
-
50
- assertEq(slices[1].id, 'S02', 'S02 id');
51
- assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
52
- assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
53
-
54
- assertEq(slices[2].id, 'S03', 'S03 id');
55
- assertEq(slices[2].done, true, 'S03 is done');
56
- assertEq(slices[2].risk, 'high', 'S03 risk');
57
- assertEq(slices[2].depends.length, 2, 'S03 has 2 deps');
58
- }
59
-
60
- // ═══════════════════════════════════════════════════════════════════════
61
- // B. Prose fallback: H2 with colon (the only format the old regex matched)
62
- // ═══════════════════════════════════════════════════════════════════════
63
-
64
- console.log('\n=== B. Prose fallback: H2 with colon ===');
65
-
66
- {
67
- const content = [
68
- '# M001: Test',
69
- '',
70
- '## S01: Setup Foundation',
71
- '',
72
- 'Do the setup work.',
73
- '',
74
- '## S02: Core Features',
75
- '',
76
- 'Build the features.',
77
- '',
78
- ].join('\n');
79
-
80
- const slices = parseRoadmapSlices(content);
81
- assertEq(slices.length, 2, 'prose H2 colon: 2 slices');
82
- assertEq(slices[0].id, 'S01', 'S01 id');
83
- assertEq(slices[0].title, 'Setup Foundation', 'S01 title');
84
- assertEq(slices[1].id, 'S02', 'S02 id');
85
- assertEq(slices[1].title, 'Core Features', 'S02 title');
86
- }
87
-
88
- // ═══════════════════════════════════════════════════════════════════════
89
- // C. Regression #1248: H3 headers (the old regex only matched ##)
90
- // ═══════════════════════════════════════════════════════════════════════
91
-
92
- console.log('\n=== C. #1248: H3 headers ===');
93
-
94
- {
95
- const content = [
96
- '# M001: Test',
97
- '',
98
- '### S01: Setup Foundation',
99
- '',
100
- 'Do the setup work.',
101
- '',
102
- '### S02: Core Features',
103
- '',
104
- 'Build the features.',
105
- '',
106
- ].join('\n');
107
-
108
- const slices = parseRoadmapSlices(content);
109
- assertEq(slices.length, 2, '#1248 H3: 2 slices parsed');
110
- assertEq(slices[0].id, 'S01', 'S01 from H3');
111
- assertEq(slices[1].id, 'S02', 'S02 from H3');
112
- }
113
-
114
- // ═══════════════════════════════════════════════════════════════════════
115
- // D. Regression #1248: H4 headers
116
- // ═══════════════════════════════════════════════════════════════════════
117
-
118
- console.log('\n=== D. #1248: H4 headers ===');
119
-
120
- {
121
- const content = [
122
- '# M001: Test',
123
- '',
124
- '#### S01: Setup Foundation',
125
- '',
126
- '#### S02: Core Features',
127
- '',
128
- ].join('\n');
129
-
130
- const slices = parseRoadmapSlices(content);
131
- assertEq(slices.length, 2, '#1248 H4: 2 slices parsed');
132
- }
133
-
134
- // ═══════════════════════════════════════════════════════════════════════
135
- // E. Regression #1248: H1 header (unusual but LLMs produce it)
136
- // ═══════════════════════════════════════════════════════════════════════
137
-
138
- console.log('\n=== E. #1248: H1 headers ===');
139
-
140
- {
141
- const content = [
142
- '# S01: Setup Foundation',
143
- '',
144
- 'Setup stuff.',
145
- '',
146
- '# S02: Core Features',
147
- '',
148
- 'Build stuff.',
149
- '',
150
- ].join('\n');
151
-
152
- const slices = parseRoadmapSlices(content);
153
- assertEq(slices.length, 2, '#1248 H1: 2 slices parsed');
154
- }
155
-
156
- // ═══════════════════════════════════════════════════════════════════════
157
- // F. Regression #1248: Bold-wrapped IDs
158
- // ═══════════════════════════════════════════════════════════════════════
159
-
160
- console.log('\n=== F. #1248: Bold-wrapped ===');
161
-
162
- {
163
- const content1 = '## **S01: Setup Foundation**\n\nDo stuff.\n\n## **S02: Features**\n\nMore stuff.\n';
164
- const slices1 = parseRoadmapSlices(content1);
165
- assertEq(slices1.length, 2, 'bold-wrapped: 2 slices');
166
- assertEq(slices1[0].title, 'Setup Foundation', 'bold-wrapped: title extracted without bold');
167
-
168
- const content2 = '## **S01**: Setup Foundation\n\n## **S02**: Features\n';
169
- const slices2 = parseRoadmapSlices(content2);
170
- assertEq(slices2.length, 2, 'bold ID only: 2 slices');
171
- }
172
-
173
- // ═══════════════════════════════════════════════════════════════════════
174
- // G. Regression #1248: Dot separator
175
- // ═══════════════════════════════════════════════════════════════════════
176
-
177
- console.log('\n=== G. #1248: Dot separator ===');
178
-
179
- {
180
- const content = '## S01. Setup Foundation\n\n## S02. Core Features\n';
181
- const slices = parseRoadmapSlices(content);
182
- assertEq(slices.length, 2, 'dot separator: 2 slices');
183
- assertEq(slices[0].title, 'Setup Foundation', 'dot separator: title');
184
- }
185
-
186
- // ═══════════════════════════════════════════════════════════════════════
187
- // H. Regression #1248: Em dash separator
188
- // ═══════════════════════════════════════════════════════════════════════
189
-
190
- console.log('\n=== H. #1248: Em/en dash separators ===');
191
-
192
- {
193
- const content = '## S01 — Setup Foundation\n\n## S02 – Core Features\n';
194
- const slices = parseRoadmapSlices(content);
195
- assertEq(slices.length, 2, 'em/en dash: 2 slices');
196
- }
197
-
198
- // ═══════════════════════════════════════════════════════════════════════
199
- // I. Regression #1248: Space-only separator (no punctuation)
200
- // ═══════════════════════════════════════════════════════════════════════
201
-
202
- console.log('\n=== I. #1248: Space-only separator ===');
203
-
204
- {
205
- const content = '## S01 Setup Foundation\n\n## S02 Core Features\n';
206
- const slices = parseRoadmapSlices(content);
207
- assertEq(slices.length, 2, 'space-only: 2 slices');
208
- assertEq(slices[0].title, 'Setup Foundation', 'space-only: title');
209
- }
210
-
211
- // ═══════════════════════════════════════════════════════════════════════
212
- // J. Regression #1248: Non-zero-padded IDs
213
- // ═══════════════════════════════════════════════════════════════════════
214
-
215
- console.log('\n=== J. #1248: Non-zero-padded IDs ===');
216
-
217
- {
218
- const content = '## S1: Setup\n\n## S2: Features\n';
219
- const slices = parseRoadmapSlices(content);
220
- assertEq(slices.length, 2, 'non-padded: 2 slices');
221
- assertEq(slices[0].id, 'S1', 'non-padded: S1');
222
- }
223
-
224
- // ═══════════════════════════════════════════════════════════════════════
225
- // K. Regression #1248: "Slice" prefix
226
- // ═══════════════════════════════════════════════════════════════════════
227
-
228
- console.log('\n=== K. #1248: "Slice" prefix ===');
229
-
230
- {
231
- const content = '## Slice S01: Setup Foundation\n\n## Slice S02: Core Features\n';
232
- const slices = parseRoadmapSlices(content);
233
- assertEq(slices.length, 2, 'Slice prefix: 2 slices');
234
- assertEq(slices[0].id, 'S01', 'Slice prefix: S01');
235
- }
236
-
237
- // ═══════════════════════════════════════════════════════════════════════
238
- // L. Prose with "Depends on:" line
239
- // ═══════════════════════════════════════════════════════════════════════
240
-
241
- console.log('\n=== L. Prose with Depends on: ===');
242
-
243
- {
244
- const content = [
245
- '## S01: Foundation',
246
- '',
247
- 'Build the base.',
248
- '',
249
- '## S02: Features',
250
- '',
251
- '**Depends on:** S01',
252
- '',
253
- 'Build features.',
254
- ].join('\n');
255
-
256
- const slices = parseRoadmapSlices(content);
257
- assertEq(slices.length, 2, 'prose deps: 2 slices');
258
- assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
259
- assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
260
- }
261
-
262
- // ═══════════════════════════════════════════════════════════════════════
263
- // M. Empty / edge cases
264
- // ═══════════════════════════════════════════════════════════════════════
265
-
266
- console.log('\n=== M. Edge cases ===');
267
-
268
- {
269
- assertEq(parseRoadmapSlices('').length, 0, 'empty content → 0 slices');
270
- assertEq(parseRoadmapSlices('# Just a title\n\nSome text.').length, 0, 'no slices at all → 0');
271
-
272
- // Mixed format: ## Slices section with one checkbox + prose below
273
- const mixed = [
274
- '## Slices',
275
- '',
276
- '- [ ] **S01: Foundation** `risk:low` `depends:[]`',
277
- '',
278
- '## S02: Features',
279
- '',
280
- 'Prose content.',
281
- ].join('\n');
282
- const mixedSlices = parseRoadmapSlices(mixed);
283
- // The ## Slices section takes priority — prose headers outside it aren't picked up
284
- assertEq(mixedSlices.length, 1, 'mixed: only 1 slice from ## Slices section');
285
- assertEq(mixedSlices[0].id, 'S01', 'mixed: S01 from checkbox');
286
- }
287
-
288
- // ═══════════════════════════════════════════════════════════════════════
289
- // N. Dependency range expansion
290
- // ═══════════════════════════════════════════════════════════════════════
291
-
292
- console.log('\n=== N. Dependency range expansion ===');
293
-
294
- {
295
- assertEq(
296
- expandDependencies(['S01-S04']),
297
- ['S01', 'S02', 'S03', 'S04'],
298
- 'S01-S04 → 4 individual deps',
299
- );
300
-
301
- assertEq(
302
- expandDependencies(['S01..S03']),
303
- ['S01', 'S02', 'S03'],
304
- 'S01..S03 → 3 individual deps',
305
- );
306
-
307
- assertEq(
308
- expandDependencies(['S01']),
309
- ['S01'],
310
- 'single dep passes through',
311
- );
312
-
313
- assertEq(
314
- expandDependencies(['S01', 'S03-S05']),
315
- ['S01', 'S03', 'S04', 'S05'],
316
- 'mixed single + range',
317
- );
318
-
319
- assertEq(
320
- expandDependencies(['']),
321
- [],
322
- 'empty string filtered out',
323
- );
324
- }
325
-
326
- // ═══════════════════════════════════════════════════════════════════════
327
- // O. No-separator colon-less: "S01:Title" (no space after colon)
328
- // ═══════════════════════════════════════════════════════════════════════
329
-
330
- console.log('\n=== O. No space after colon ===');
331
-
332
- {
333
- const content = '## S01:Foundation\n\n## S02:Features\n';
334
- const slices = parseRoadmapSlices(content);
335
- // The regex uses [:\s.—–-]* which allows colon with no space
336
- assertEq(slices.length, 2, 'no-space-colon: 2 slices');
337
- }
338
-
339
- // ═══════════════════════════════════════════════════════════════════════
340
- // P. Three-digit padded IDs
341
- // ═══════════════════════════════════════════════════════════════════════
342
-
343
- console.log('\n=== P. Three-digit padded IDs ===');
344
-
345
- {
346
- const content = '## S001: Foundation\n\n## S002: Features\n';
347
- const slices = parseRoadmapSlices(content);
348
- assertEq(slices.length, 2, 'three-digit: 2 slices');
349
- assertEq(slices[0].id, 'S001', 'three-digit: S001');
350
- }
351
-
352
- report();
353
- }
354
-
355
- main().catch((error) => {
356
- console.error(error);
357
- process.exit(1);
358
- });