@cleocode/core 2026.4.45 → 2026.4.47

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,331 @@
1
+ /**
2
+ * Tests for epic auto-complete behavior (T585).
3
+ *
4
+ * Verifies that an epic only auto-completes when ALL its direct subtasks
5
+ * are in terminal states (done or cancelled), not when only some are done.
6
+ *
7
+ * @task T585
8
+ */
9
+
10
+ import { writeFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
13
+ import { createTestDb, seedTasks, type TestDbEnv } from '../../store/__tests__/test-db-helper.js';
14
+ import type { DataAccessor } from '../../store/data-accessor.js';
15
+ import { resetDbState } from '../../store/sqlite.js';
16
+ import { completeTask } from '../complete.js';
17
+
18
+ describe('epic auto-complete', () => {
19
+ let env: TestDbEnv;
20
+ let accessor: DataAccessor;
21
+
22
+ const writeConfig = async (config: Record<string, unknown>): Promise<void> => {
23
+ await writeFile(join(env.cleoDir, 'config.json'), JSON.stringify(config));
24
+ };
25
+
26
+ beforeEach(async () => {
27
+ env = await createTestDb();
28
+ accessor = env.accessor;
29
+ process.env['CLEO_DIR'] = env.cleoDir;
30
+ await writeConfig({
31
+ enforcement: {
32
+ session: { requiredForMutate: false },
33
+ acceptance: { mode: 'off' },
34
+ },
35
+ lifecycle: { mode: 'off' },
36
+ verification: { enabled: false },
37
+ });
38
+ });
39
+
40
+ afterEach(async () => {
41
+ delete process.env['CLEO_DIR'];
42
+ resetDbState();
43
+ await env.cleanup();
44
+ });
45
+
46
+ it('does NOT auto-complete epic when only one of two subtasks is completed (bug T585)', async () => {
47
+ await seedTasks(accessor, [
48
+ {
49
+ id: 'T001',
50
+ title: 'Test Epic',
51
+ type: 'epic',
52
+ status: 'active',
53
+ priority: 'medium',
54
+ acceptance: ['AC1'],
55
+ },
56
+ {
57
+ id: 'T002',
58
+ title: 'Sub 1',
59
+ type: 'task',
60
+ status: 'active',
61
+ priority: 'medium',
62
+ parentId: 'T001',
63
+ acceptance: ['AC1'],
64
+ },
65
+ {
66
+ id: 'T003',
67
+ title: 'Sub 2',
68
+ type: 'task',
69
+ status: 'pending',
70
+ priority: 'medium',
71
+ parentId: 'T001',
72
+ acceptance: ['AC1'],
73
+ },
74
+ ]);
75
+
76
+ const result = await completeTask({ taskId: 'T002' }, env.tempDir, accessor);
77
+
78
+ expect(result.task.status).toBe('done');
79
+ expect(result.autoCompleted).toBeUndefined();
80
+
81
+ // Verify the epic is still not done
82
+ const epic = await accessor.loadSingleTask('T001');
83
+ expect(epic?.status).not.toBe('done');
84
+
85
+ // Verify T003 is still pending
86
+ const sub2 = await accessor.loadSingleTask('T003');
87
+ expect(sub2?.status).toBe('pending');
88
+ });
89
+
90
+ it('auto-completes epic when the LAST pending subtask is completed', async () => {
91
+ await seedTasks(accessor, [
92
+ {
93
+ id: 'T001',
94
+ title: 'Test Epic',
95
+ type: 'epic',
96
+ status: 'active',
97
+ priority: 'medium',
98
+ acceptance: ['AC1'],
99
+ },
100
+ {
101
+ id: 'T002',
102
+ title: 'Sub 1',
103
+ type: 'task',
104
+ status: 'done',
105
+ priority: 'medium',
106
+ parentId: 'T001',
107
+ acceptance: ['AC1'],
108
+ completedAt: new Date().toISOString(),
109
+ },
110
+ {
111
+ id: 'T003',
112
+ title: 'Sub 2',
113
+ type: 'task',
114
+ status: 'active',
115
+ priority: 'medium',
116
+ parentId: 'T001',
117
+ acceptance: ['AC1'],
118
+ },
119
+ ]);
120
+
121
+ const result = await completeTask({ taskId: 'T003' }, env.tempDir, accessor);
122
+
123
+ expect(result.task.status).toBe('done');
124
+ expect(result.autoCompleted).toContain('T001');
125
+
126
+ const epic = await accessor.loadSingleTask('T001');
127
+ expect(epic?.status).toBe('done');
128
+ });
129
+
130
+ it('does NOT auto-complete epic when remaining subtask is blocked (not terminal)', async () => {
131
+ await seedTasks(accessor, [
132
+ {
133
+ id: 'T001',
134
+ title: 'Test Epic',
135
+ type: 'epic',
136
+ status: 'active',
137
+ priority: 'medium',
138
+ acceptance: ['AC1'],
139
+ },
140
+ {
141
+ id: 'T002',
142
+ title: 'Sub 1',
143
+ type: 'task',
144
+ status: 'active',
145
+ priority: 'medium',
146
+ parentId: 'T001',
147
+ acceptance: ['AC1'],
148
+ },
149
+ {
150
+ id: 'T003',
151
+ title: 'Sub 2',
152
+ type: 'task',
153
+ status: 'blocked',
154
+ priority: 'medium',
155
+ parentId: 'T001',
156
+ acceptance: ['AC1'],
157
+ },
158
+ ]);
159
+
160
+ const result = await completeTask({ taskId: 'T002' }, env.tempDir, accessor);
161
+
162
+ expect(result.task.status).toBe('done');
163
+ expect(result.autoCompleted).toBeUndefined();
164
+
165
+ const epic = await accessor.loadSingleTask('T001');
166
+ expect(epic?.status).not.toBe('done');
167
+ });
168
+
169
+ it('auto-completes epic when all remaining subtasks are cancelled', async () => {
170
+ await seedTasks(accessor, [
171
+ {
172
+ id: 'T001',
173
+ title: 'Test Epic',
174
+ type: 'epic',
175
+ status: 'active',
176
+ priority: 'medium',
177
+ acceptance: ['AC1'],
178
+ },
179
+ {
180
+ id: 'T002',
181
+ title: 'Sub 1',
182
+ type: 'task',
183
+ status: 'active',
184
+ priority: 'medium',
185
+ parentId: 'T001',
186
+ acceptance: ['AC1'],
187
+ },
188
+ {
189
+ id: 'T003',
190
+ title: 'Sub 2',
191
+ type: 'task',
192
+ status: 'cancelled',
193
+ priority: 'medium',
194
+ parentId: 'T001',
195
+ acceptance: ['AC1'],
196
+ cancelledAt: new Date().toISOString(),
197
+ },
198
+ ]);
199
+
200
+ const result = await completeTask({ taskId: 'T002' }, env.tempDir, accessor);
201
+
202
+ expect(result.task.status).toBe('done');
203
+ expect(result.autoCompleted).toContain('T001');
204
+
205
+ const epic = await accessor.loadSingleTask('T001');
206
+ expect(epic?.status).toBe('done');
207
+ });
208
+
209
+ it('does NOT auto-complete epic when getChildren returns an empty list (vacuous truth guard, T585)', async () => {
210
+ // Defensive guard against [].every() === true (vacuous truth).
211
+ // If getChildren(epic) returns [] — e.g. because all children have
212
+ // parent_id=null due to a data-integrity issue — completing any task
213
+ // with task.parentId set to that epic must NOT auto-complete it.
214
+ //
215
+ // We simulate this by: seeding T001 (epic) and T002 (task with parentId=T001),
216
+ // then manually clearing T002's parent_id in the DB so getChildren(T001) returns [].
217
+ // But loadSingleTask(T002) still returns parentId:'T001' ... wait, no. If parent_id
218
+ // is NULL in DB then rowToTask returns parentId: undefined. So task.parentId is falsy
219
+ // and the auto-complete block is skipped entirely.
220
+ //
221
+ // The vacuous truth guard `siblings.length > 0` is still correct defensive code for
222
+ // the scenario where getChildren returns [] for any reason (e.g., future filter changes).
223
+ // The primary bug fix (partial completion doesn't close epic) is proven by test 1 above.
224
+ //
225
+ // This test creates a mock accessor that returns [] for getChildren to verify the guard.
226
+ await seedTasks(accessor, [
227
+ {
228
+ id: 'T001',
229
+ title: 'Test Epic',
230
+ type: 'epic',
231
+ status: 'active',
232
+ priority: 'medium',
233
+ acceptance: ['AC1'],
234
+ },
235
+ {
236
+ id: 'T002',
237
+ title: 'Sub 1',
238
+ type: 'task',
239
+ status: 'active',
240
+ priority: 'medium',
241
+ parentId: 'T001',
242
+ acceptance: ['AC1'],
243
+ },
244
+ ]);
245
+
246
+ // Create a wrapper accessor that overrides getChildren to return []
247
+ // to simulate the vacuous truth scenario.
248
+ const wrappedAccessor = {
249
+ ...accessor,
250
+ getChildren: async (parentId: string) => {
251
+ if (parentId === 'T001') return []; // Simulate no registered children
252
+ return accessor.getChildren(parentId);
253
+ },
254
+ } as typeof accessor;
255
+
256
+ const result = await completeTask({ taskId: 'T002' }, env.tempDir, wrappedAccessor);
257
+
258
+ expect(result.task.status).toBe('done');
259
+ // With vacuous truth guard: empty siblings → allDone = false → epic does NOT auto-complete
260
+ expect(result.autoCompleted).toBeUndefined();
261
+
262
+ const epic = await accessor.loadSingleTask('T001');
263
+ expect(epic?.status).not.toBe('done');
264
+ });
265
+
266
+ it('does NOT auto-complete epic when it has 5 subtasks and only 1 is being completed', async () => {
267
+ await seedTasks(accessor, [
268
+ {
269
+ id: 'T001',
270
+ title: 'Big Epic',
271
+ type: 'epic',
272
+ status: 'active',
273
+ priority: 'medium',
274
+ acceptance: ['AC1'],
275
+ },
276
+ {
277
+ id: 'T002',
278
+ title: 'Sub 1',
279
+ type: 'task',
280
+ status: 'active',
281
+ priority: 'medium',
282
+ parentId: 'T001',
283
+ acceptance: ['AC1'],
284
+ },
285
+ {
286
+ id: 'T003',
287
+ title: 'Sub 2',
288
+ type: 'task',
289
+ status: 'pending',
290
+ priority: 'medium',
291
+ parentId: 'T001',
292
+ acceptance: ['AC1'],
293
+ },
294
+ {
295
+ id: 'T004',
296
+ title: 'Sub 3',
297
+ type: 'task',
298
+ status: 'pending',
299
+ priority: 'medium',
300
+ parentId: 'T001',
301
+ acceptance: ['AC1'],
302
+ },
303
+ {
304
+ id: 'T005',
305
+ title: 'Sub 4',
306
+ type: 'task',
307
+ status: 'pending',
308
+ priority: 'medium',
309
+ parentId: 'T001',
310
+ acceptance: ['AC1'],
311
+ },
312
+ {
313
+ id: 'T006',
314
+ title: 'Sub 5',
315
+ type: 'task',
316
+ status: 'pending',
317
+ priority: 'medium',
318
+ parentId: 'T001',
319
+ acceptance: ['AC1'],
320
+ },
321
+ ]);
322
+
323
+ const result = await completeTask({ taskId: 'T002' }, env.tempDir, accessor);
324
+
325
+ expect(result.task.status).toBe('done');
326
+ expect(result.autoCompleted).toBeUndefined();
327
+
328
+ const epic = await accessor.loadSingleTask('T001');
329
+ expect(epic?.status).not.toBe('done');
330
+ });
331
+ });
@@ -235,10 +235,13 @@ export async function completeTask(
235
235
  const parent = await acc.loadSingleTask(task.parentId);
236
236
  if (parent && parent.type === 'epic' && !parent.noAutoComplete) {
237
237
  const siblings = await acc.getChildren(parent.id);
238
- // The current task is not yet 'done' in DB, so check by ID
239
- const allDone = siblings.every(
240
- (c) => c.id === task.id || c.status === 'done' || c.status === 'cancelled',
241
- );
238
+ // Guard: only auto-complete if the epic has at least one registered child.
239
+ // An empty siblings list means no children are recorded in the DB, which
240
+ // would vacuously satisfy .every() and incorrectly auto-complete the epic.
241
+ // The current task is not yet 'done' in DB, so match it by ID.
242
+ const allDone =
243
+ siblings.length > 0 &&
244
+ siblings.every((c) => c.id === task.id || c.status === 'done' || c.status === 'cancelled');
242
245
  if (allDone) {
243
246
  parent.status = 'done';
244
247
  parent.completedAt = now;