@cleocode/core 2026.3.43 → 2026.3.45
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/dist/admin/export-tasks.d.ts.map +1 -1
- package/dist/admin/import-tasks.d.ts +10 -2
- package/dist/admin/import-tasks.d.ts.map +1 -1
- package/dist/agents/agent-schema.d.ts +358 -0
- package/dist/agents/agent-schema.d.ts.map +1 -0
- package/dist/agents/capacity.d.ts +57 -0
- package/dist/agents/capacity.d.ts.map +1 -0
- package/dist/agents/index.d.ts +17 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/registry.d.ts +115 -0
- package/dist/agents/registry.d.ts.map +1 -0
- package/dist/agents/retry.d.ts +83 -0
- package/dist/agents/retry.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +4 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/payload-schemas.d.ts +214 -0
- package/dist/hooks/payload-schemas.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16937 -2371
- package/dist/index.js.map +4 -4
- package/dist/inject/index.d.ts.map +1 -1
- package/dist/intelligence/impact.d.ts +51 -0
- package/dist/intelligence/impact.d.ts.map +1 -0
- package/dist/intelligence/index.d.ts +15 -0
- package/dist/intelligence/index.d.ts.map +1 -0
- package/dist/intelligence/patterns.d.ts +66 -0
- package/dist/intelligence/patterns.d.ts.map +1 -0
- package/dist/intelligence/prediction.d.ts +51 -0
- package/dist/intelligence/prediction.d.ts.map +1 -0
- package/dist/intelligence/types.d.ts +221 -0
- package/dist/intelligence/types.d.ts.map +1 -0
- package/dist/internal.d.ts +12 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/issue/template-parser.d.ts +8 -2
- package/dist/issue/template-parser.d.ts.map +1 -1
- package/dist/lifecycle/pipeline.d.ts +2 -2
- package/dist/lifecycle/pipeline.d.ts.map +1 -1
- package/dist/lifecycle/state-machine.d.ts +1 -1
- package/dist/lifecycle/state-machine.d.ts.map +1 -1
- package/dist/memory/brain-lifecycle.d.ts.map +1 -1
- package/dist/memory/brain-retrieval.d.ts.map +1 -1
- package/dist/memory/brain-row-types.d.ts +40 -6
- package/dist/memory/brain-row-types.d.ts.map +1 -1
- package/dist/memory/brain-search.d.ts.map +1 -1
- package/dist/memory/brain-similarity.d.ts.map +1 -1
- package/dist/memory/claude-mem-migration.d.ts.map +1 -1
- package/dist/nexus/discover.d.ts.map +1 -1
- package/dist/nexus/index.d.ts +2 -0
- package/dist/nexus/index.d.ts.map +1 -1
- package/dist/nexus/transfer-types.d.ts +123 -0
- package/dist/nexus/transfer-types.d.ts.map +1 -0
- package/dist/nexus/transfer.d.ts +31 -0
- package/dist/nexus/transfer.d.ts.map +1 -0
- package/dist/orchestration/bootstrap.d.ts.map +1 -1
- package/dist/orchestration/skill-ops.d.ts +4 -4
- package/dist/orchestration/skill-ops.d.ts.map +1 -1
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.d.ts.map +1 -1
- package/dist/sessions/briefing.d.ts.map +1 -1
- package/dist/sessions/handoff.d.ts.map +1 -1
- package/dist/sessions/index.d.ts +1 -1
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/types.d.ts +8 -42
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/signaldock/signaldock-transport.d.ts +1 -1
- package/dist/signaldock/signaldock-transport.d.ts.map +1 -1
- package/dist/skills/injection/subagent.d.ts +3 -3
- package/dist/skills/injection/subagent.d.ts.map +1 -1
- package/dist/skills/manifests/contribution.d.ts +2 -2
- package/dist/skills/manifests/contribution.d.ts.map +1 -1
- package/dist/skills/orchestrator/spawn.d.ts +6 -6
- package/dist/skills/orchestrator/spawn.d.ts.map +1 -1
- package/dist/skills/orchestrator/startup.d.ts +1 -1
- package/dist/skills/orchestrator/startup.d.ts.map +1 -1
- package/dist/skills/orchestrator/validator.d.ts +2 -2
- package/dist/skills/orchestrator/validator.d.ts.map +1 -1
- package/dist/skills/precedence-types.d.ts +24 -1
- package/dist/skills/precedence-types.d.ts.map +1 -1
- package/dist/skills/types.d.ts +70 -4
- package/dist/skills/types.d.ts.map +1 -1
- package/dist/store/brain-sqlite.d.ts +4 -1
- package/dist/store/brain-sqlite.d.ts.map +1 -1
- package/dist/store/export.d.ts +5 -4
- package/dist/store/export.d.ts.map +1 -1
- package/dist/store/nexus-sqlite.d.ts +4 -1
- package/dist/store/nexus-sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.d.ts +4 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/tasks-schema.d.ts +14 -4
- package/dist/store/tasks-schema.d.ts.map +1 -1
- package/dist/store/typed-query.d.ts +12 -0
- package/dist/store/typed-query.d.ts.map +1 -0
- package/dist/store/validation-schemas.d.ts +2423 -50
- package/dist/store/validation-schemas.d.ts.map +1 -1
- package/dist/system/inject-generate.d.ts.map +1 -1
- package/dist/validation/doctor/checks.d.ts +5 -0
- package/dist/validation/doctor/checks.d.ts.map +1 -1
- package/dist/validation/engine.d.ts +10 -10
- package/dist/validation/engine.d.ts.map +1 -1
- package/dist/validation/index.d.ts +6 -2
- package/dist/validation/index.d.ts.map +1 -1
- package/dist/validation/protocol-common.d.ts +10 -2
- package/dist/validation/protocol-common.d.ts.map +1 -1
- package/migrations/drizzle-tasks/20260320013731_wave0-schema-hardening/migration.sql +84 -0
- package/migrations/drizzle-tasks/20260320013731_wave0-schema-hardening/snapshot.json +4060 -0
- package/migrations/drizzle-tasks/20260320020000_agent-dimension/migration.sql +35 -0
- package/migrations/drizzle-tasks/20260320020000_agent-dimension/snapshot.json +4312 -0
- package/package.json +2 -2
- package/src/admin/export-tasks.ts +2 -5
- package/src/admin/import-tasks.ts +53 -29
- package/src/agents/__tests__/capacity.test.ts +219 -0
- package/src/agents/__tests__/registry.test.ts +457 -0
- package/src/agents/__tests__/retry.test.ts +289 -0
- package/src/agents/agent-schema.ts +107 -0
- package/src/agents/capacity.ts +151 -0
- package/src/agents/index.ts +68 -0
- package/src/agents/registry.ts +449 -0
- package/src/agents/retry.ts +255 -0
- package/src/hooks/index.ts +20 -1
- package/src/hooks/payload-schemas.ts +199 -0
- package/src/index.ts +69 -0
- package/src/inject/index.ts +14 -14
- package/src/intelligence/__tests__/impact.test.ts +453 -0
- package/src/intelligence/__tests__/patterns.test.ts +450 -0
- package/src/intelligence/__tests__/prediction.test.ts +418 -0
- package/src/intelligence/impact.ts +638 -0
- package/src/intelligence/index.ts +47 -0
- package/src/intelligence/patterns.ts +621 -0
- package/src/intelligence/prediction.ts +621 -0
- package/src/intelligence/types.ts +273 -0
- package/src/internal.ts +89 -2
- package/src/issue/template-parser.ts +65 -4
- package/src/lifecycle/pipeline.ts +14 -7
- package/src/lifecycle/state-machine.ts +6 -2
- package/src/memory/brain-lifecycle.ts +5 -11
- package/src/memory/brain-retrieval.ts +44 -38
- package/src/memory/brain-row-types.ts +43 -6
- package/src/memory/brain-search.ts +53 -32
- package/src/memory/brain-similarity.ts +9 -8
- package/src/memory/claude-mem-migration.ts +4 -3
- package/src/nexus/__tests__/nexus-e2e.test.ts +1481 -0
- package/src/nexus/__tests__/transfer.test.ts +446 -0
- package/src/nexus/discover.ts +1 -0
- package/src/nexus/index.ts +14 -0
- package/src/nexus/transfer-types.ts +129 -0
- package/src/nexus/transfer.ts +314 -0
- package/src/orchestration/bootstrap.ts +11 -17
- package/src/orchestration/skill-ops.ts +52 -32
- package/src/otel/index.ts +48 -4
- package/src/sessions/__tests__/briefing.test.ts +31 -2
- package/src/sessions/briefing.ts +27 -42
- package/src/sessions/handoff.ts +52 -86
- package/src/sessions/index.ts +5 -1
- package/src/sessions/types.ts +9 -43
- package/src/signaldock/signaldock-transport.ts +5 -2
- package/src/skills/injection/subagent.ts +10 -16
- package/src/skills/manifests/contribution.ts +5 -13
- package/src/skills/orchestrator/__tests__/spawn-tier.test.ts +44 -30
- package/src/skills/orchestrator/spawn.ts +18 -31
- package/src/skills/orchestrator/startup.ts +78 -65
- package/src/skills/orchestrator/validator.ts +26 -31
- package/src/skills/precedence-types.ts +24 -1
- package/src/skills/types.ts +72 -5
- package/src/store/__tests__/test-db-helper.d.ts +4 -4
- package/src/store/__tests__/test-db-helper.js +5 -16
- package/src/store/__tests__/test-db-helper.ts +5 -18
- package/src/store/brain-sqlite.ts +7 -3
- package/src/store/chain-schema.ts +1 -1
- package/src/store/export.ts +22 -12
- package/src/store/nexus-sqlite.ts +7 -3
- package/src/store/sqlite.ts +9 -3
- package/src/store/tasks-schema.ts +65 -8
- package/src/store/typed-query.ts +17 -0
- package/src/store/validation-schemas.ts +347 -23
- package/src/system/inject-generate.ts +9 -23
- package/src/validation/doctor/checks.ts +24 -2
- package/src/validation/engine.ts +11 -11
- package/src/validation/index.ts +131 -3
- package/src/validation/protocol-common.ts +54 -3
- package/dist/tasks/reparent.d.ts +0 -38
- package/dist/tasks/reparent.d.ts.map +0 -1
- package/src/tasks/reparent.ts +0 -134
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for impact analysis module.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Simple dependency chains (A->B->C)
|
|
6
|
+
* - Complex graphs (diamond dependencies)
|
|
7
|
+
* - Each change type (cancel, block, complete, reprioritize)
|
|
8
|
+
* - Blast radius calculation
|
|
9
|
+
* - Critical path detection
|
|
10
|
+
* - Edge cases (orphan tasks, circular refs, no deps)
|
|
11
|
+
*
|
|
12
|
+
* @module intelligence
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DataAccessor, Task } from '@cleocode/contracts';
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import { analyzeChangeImpact, analyzeTaskImpact, calculateBlastRadius } from '../impact.js';
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Test Helpers
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
function makeTask(overrides: Partial<Task> & { id: string }): Task {
|
|
24
|
+
return {
|
|
25
|
+
title: `Task ${overrides.id}`,
|
|
26
|
+
description: `Description for ${overrides.id}`,
|
|
27
|
+
status: 'pending',
|
|
28
|
+
priority: 'medium',
|
|
29
|
+
createdAt: new Date().toISOString(),
|
|
30
|
+
...overrides,
|
|
31
|
+
} as Task;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a mock DataAccessor that returns the given tasks.
|
|
36
|
+
*/
|
|
37
|
+
function mockAccessor(tasks: Task[]): DataAccessor {
|
|
38
|
+
return {
|
|
39
|
+
queryTasks: async () => ({ tasks, total: tasks.length }),
|
|
40
|
+
loadSingleTask: async (id: string) => tasks.find((t) => t.id === id) ?? null,
|
|
41
|
+
taskExists: async (id: string) => tasks.some((t) => t.id === id),
|
|
42
|
+
upsertSingleTask: async () => {},
|
|
43
|
+
deleteSingleTask: async () => true,
|
|
44
|
+
close: async () => {},
|
|
45
|
+
} as unknown as DataAccessor;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// analyzeTaskImpact
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
describe('analyzeTaskImpact', () => {
|
|
53
|
+
it('returns empty assessment for non-existent task', async () => {
|
|
54
|
+
const acc = mockAccessor([]);
|
|
55
|
+
const result = await analyzeTaskImpact('T999', acc);
|
|
56
|
+
|
|
57
|
+
expect(result.taskId).toBe('T999');
|
|
58
|
+
expect(result.directDependents).toEqual([]);
|
|
59
|
+
expect(result.transitiveDependents).toEqual([]);
|
|
60
|
+
expect(result.blockedWorkCount).toBe(0);
|
|
61
|
+
expect(result.isOnCriticalPath).toBe(false);
|
|
62
|
+
expect(result.blastRadius.severity).toBe('isolated');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('analyzes simple linear chain A->B->C', async () => {
|
|
66
|
+
const tasks = [
|
|
67
|
+
makeTask({ id: 'T001' }),
|
|
68
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
69
|
+
makeTask({ id: 'T003', depends: ['T002'] }),
|
|
70
|
+
];
|
|
71
|
+
const acc = mockAccessor(tasks);
|
|
72
|
+
const result = await analyzeTaskImpact('T001', acc);
|
|
73
|
+
|
|
74
|
+
expect(result.taskId).toBe('T001');
|
|
75
|
+
expect(result.directDependents).toEqual(['T002']);
|
|
76
|
+
expect(result.transitiveDependents.sort()).toEqual(['T002', 'T003']);
|
|
77
|
+
expect(result.blockedWorkCount).toBe(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('analyzes diamond dependency graph', async () => {
|
|
81
|
+
// T001 -> T002 -> T004
|
|
82
|
+
// T001 -> T003 -> T004
|
|
83
|
+
const tasks = [
|
|
84
|
+
makeTask({ id: 'T001' }),
|
|
85
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
86
|
+
makeTask({ id: 'T003', depends: ['T001'] }),
|
|
87
|
+
makeTask({ id: 'T004', depends: ['T002', 'T003'] }),
|
|
88
|
+
];
|
|
89
|
+
const acc = mockAccessor(tasks);
|
|
90
|
+
const result = await analyzeTaskImpact('T001', acc);
|
|
91
|
+
|
|
92
|
+
expect(result.directDependents.sort()).toEqual(['T002', 'T003']);
|
|
93
|
+
expect(result.transitiveDependents.sort()).toEqual(['T002', 'T003', 'T004']);
|
|
94
|
+
expect(result.blockedWorkCount).toBe(3);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('excludes completed tasks from blocked work count', async () => {
|
|
98
|
+
const tasks = [
|
|
99
|
+
makeTask({ id: 'T001' }),
|
|
100
|
+
makeTask({ id: 'T002', depends: ['T001'], status: 'done' }),
|
|
101
|
+
makeTask({ id: 'T003', depends: ['T001'] }),
|
|
102
|
+
];
|
|
103
|
+
const acc = mockAccessor(tasks);
|
|
104
|
+
const result = await analyzeTaskImpact('T001', acc);
|
|
105
|
+
|
|
106
|
+
expect(result.directDependents.sort()).toEqual(['T002', 'T003']);
|
|
107
|
+
expect(result.blockedWorkCount).toBe(1); // T002 is done, only T003 counts
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('detects task on critical path', async () => {
|
|
111
|
+
const tasks = [
|
|
112
|
+
makeTask({ id: 'T001' }),
|
|
113
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
114
|
+
makeTask({ id: 'T003', depends: ['T002'] }),
|
|
115
|
+
makeTask({ id: 'T004' }), // isolated, not on critical path
|
|
116
|
+
];
|
|
117
|
+
const acc = mockAccessor(tasks);
|
|
118
|
+
const result = await analyzeTaskImpact('T001', acc);
|
|
119
|
+
|
|
120
|
+
expect(result.isOnCriticalPath).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('detects task NOT on critical path', async () => {
|
|
124
|
+
// T001 -> T002 -> T003 (critical path, length 3)
|
|
125
|
+
// T004 (independent, shorter)
|
|
126
|
+
const tasks = [
|
|
127
|
+
makeTask({ id: 'T001' }),
|
|
128
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
129
|
+
makeTask({ id: 'T003', depends: ['T002'] }),
|
|
130
|
+
makeTask({ id: 'T004' }),
|
|
131
|
+
];
|
|
132
|
+
const acc = mockAccessor(tasks);
|
|
133
|
+
const result = await analyzeTaskImpact('T004', acc);
|
|
134
|
+
|
|
135
|
+
expect(result.isOnCriticalPath).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('finds affected pipelines via parent chain', async () => {
|
|
139
|
+
const tasks = [
|
|
140
|
+
makeTask({ id: 'T001', type: 'epic' }),
|
|
141
|
+
makeTask({ id: 'T002', parentId: 'T001', type: 'task' }),
|
|
142
|
+
makeTask({ id: 'T003', parentId: 'T001', type: 'task', depends: ['T002'] }),
|
|
143
|
+
];
|
|
144
|
+
const acc = mockAccessor(tasks);
|
|
145
|
+
const result = await analyzeTaskImpact('T002', acc);
|
|
146
|
+
|
|
147
|
+
expect(result.affectedPipelines).toContain('T001');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('handles orphan task with no dependents', async () => {
|
|
151
|
+
const tasks = [makeTask({ id: 'T001' }), makeTask({ id: 'T002' })];
|
|
152
|
+
const acc = mockAccessor(tasks);
|
|
153
|
+
const result = await analyzeTaskImpact('T001', acc);
|
|
154
|
+
|
|
155
|
+
expect(result.directDependents).toEqual([]);
|
|
156
|
+
expect(result.transitiveDependents).toEqual([]);
|
|
157
|
+
expect(result.blockedWorkCount).toBe(0);
|
|
158
|
+
expect(result.blastRadius.severity).toBe('isolated');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles circular dependency gracefully', async () => {
|
|
162
|
+
const tasks = [
|
|
163
|
+
makeTask({ id: 'T001', depends: ['T002'] }),
|
|
164
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
165
|
+
];
|
|
166
|
+
const acc = mockAccessor(tasks);
|
|
167
|
+
// Should not throw, BFS handles cycles via visited set
|
|
168
|
+
const result = await analyzeTaskImpact('T001', acc);
|
|
169
|
+
|
|
170
|
+
expect(result.directDependents).toContain('T002');
|
|
171
|
+
expect(result.transitiveDependents).toContain('T002');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// analyzeChangeImpact
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
describe('analyzeChangeImpact', () => {
|
|
180
|
+
it('returns empty for non-existent task', async () => {
|
|
181
|
+
const acc = mockAccessor([]);
|
|
182
|
+
const result = await analyzeChangeImpact('T999', 'cancel', acc);
|
|
183
|
+
|
|
184
|
+
expect(result.affectedTasks).toEqual([]);
|
|
185
|
+
expect(result.cascadeDepth).toBe(0);
|
|
186
|
+
expect(result.recommendation).toContain('not found');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('cancel', () => {
|
|
190
|
+
it('identifies orphaned dependents when sole dependency cancelled', async () => {
|
|
191
|
+
const tasks = [
|
|
192
|
+
makeTask({ id: 'T001' }),
|
|
193
|
+
makeTask({ id: 'T002', depends: ['T001'], status: 'blocked' }),
|
|
194
|
+
];
|
|
195
|
+
const acc = mockAccessor(tasks);
|
|
196
|
+
const result = await analyzeChangeImpact('T001', 'cancel', acc);
|
|
197
|
+
|
|
198
|
+
expect(result.affectedTasks).toHaveLength(1);
|
|
199
|
+
expect(result.affectedTasks[0].id).toBe('T002');
|
|
200
|
+
expect(result.affectedTasks[0].newStatus).toBe('pending');
|
|
201
|
+
expect(result.affectedTasks[0].reason).toContain('orphaned');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('identifies tasks with remaining dependencies', async () => {
|
|
205
|
+
const tasks = [
|
|
206
|
+
makeTask({ id: 'T001' }),
|
|
207
|
+
makeTask({ id: 'T003' }),
|
|
208
|
+
makeTask({ id: 'T002', depends: ['T001', 'T003'] }),
|
|
209
|
+
];
|
|
210
|
+
const acc = mockAccessor(tasks);
|
|
211
|
+
const result = await analyzeChangeImpact('T001', 'cancel', acc);
|
|
212
|
+
|
|
213
|
+
expect(result.affectedTasks).toHaveLength(1);
|
|
214
|
+
expect(result.affectedTasks[0].reason).toContain('other dependencies remain');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('skips already-completed dependents', async () => {
|
|
218
|
+
const tasks = [
|
|
219
|
+
makeTask({ id: 'T001' }),
|
|
220
|
+
makeTask({ id: 'T002', depends: ['T001'], status: 'done' }),
|
|
221
|
+
];
|
|
222
|
+
const acc = mockAccessor(tasks);
|
|
223
|
+
const result = await analyzeChangeImpact('T001', 'cancel', acc);
|
|
224
|
+
|
|
225
|
+
expect(result.affectedTasks).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('block', () => {
|
|
230
|
+
it('cascade-blocks all downstream tasks', async () => {
|
|
231
|
+
const tasks = [
|
|
232
|
+
makeTask({ id: 'T001' }),
|
|
233
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
234
|
+
makeTask({ id: 'T003', depends: ['T002'] }),
|
|
235
|
+
];
|
|
236
|
+
const acc = mockAccessor(tasks);
|
|
237
|
+
const result = await analyzeChangeImpact('T001', 'block', acc);
|
|
238
|
+
|
|
239
|
+
expect(result.affectedTasks).toHaveLength(2);
|
|
240
|
+
expect(result.affectedTasks.every((t) => t.newStatus === 'blocked')).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('includes direct dependency reason', async () => {
|
|
244
|
+
const tasks = [makeTask({ id: 'T001' }), makeTask({ id: 'T002', depends: ['T001'] })];
|
|
245
|
+
const acc = mockAccessor(tasks);
|
|
246
|
+
const result = await analyzeChangeImpact('T001', 'block', acc);
|
|
247
|
+
|
|
248
|
+
expect(result.affectedTasks[0].reason).toContain('Direct dependency blocked');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('complete', () => {
|
|
253
|
+
it('unblocks tasks whose last dependency is completed', async () => {
|
|
254
|
+
const tasks = [
|
|
255
|
+
makeTask({ id: 'T001' }),
|
|
256
|
+
makeTask({ id: 'T002', depends: ['T001'], status: 'blocked' }),
|
|
257
|
+
];
|
|
258
|
+
const acc = mockAccessor(tasks);
|
|
259
|
+
const result = await analyzeChangeImpact('T001', 'complete', acc);
|
|
260
|
+
|
|
261
|
+
expect(result.affectedTasks).toHaveLength(1);
|
|
262
|
+
expect(result.affectedTasks[0].id).toBe('T002');
|
|
263
|
+
expect(result.affectedTasks[0].newStatus).toBe('pending');
|
|
264
|
+
expect(result.affectedTasks[0].reason).toContain('unblocked');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('reports partially unblocked tasks with remaining deps', async () => {
|
|
268
|
+
const tasks = [
|
|
269
|
+
makeTask({ id: 'T001' }),
|
|
270
|
+
makeTask({ id: 'T003' }),
|
|
271
|
+
makeTask({ id: 'T002', depends: ['T001', 'T003'], status: 'blocked' }),
|
|
272
|
+
];
|
|
273
|
+
const acc = mockAccessor(tasks);
|
|
274
|
+
const result = await analyzeChangeImpact('T001', 'complete', acc);
|
|
275
|
+
|
|
276
|
+
expect(result.affectedTasks).toHaveLength(1);
|
|
277
|
+
expect(result.affectedTasks[0].reason).toContain('1 other dependency');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('reprioritize', () => {
|
|
282
|
+
it('flags downstream tasks for reordering', async () => {
|
|
283
|
+
const tasks = [
|
|
284
|
+
makeTask({ id: 'T001' }),
|
|
285
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
286
|
+
makeTask({ id: 'T003', depends: ['T002'] }),
|
|
287
|
+
];
|
|
288
|
+
const acc = mockAccessor(tasks);
|
|
289
|
+
const result = await analyzeChangeImpact('T001', 'reprioritize', acc);
|
|
290
|
+
|
|
291
|
+
expect(result.affectedTasks).toHaveLength(2);
|
|
292
|
+
expect(result.affectedTasks.every((t) => t.reason.includes('reprioritized'))).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('computes cascade depth correctly', async () => {
|
|
297
|
+
const tasks = [
|
|
298
|
+
makeTask({ id: 'T001' }),
|
|
299
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
300
|
+
makeTask({ id: 'T003', depends: ['T002'] }),
|
|
301
|
+
makeTask({ id: 'T004', depends: ['T003'] }),
|
|
302
|
+
];
|
|
303
|
+
const acc = mockAccessor(tasks);
|
|
304
|
+
const result = await analyzeChangeImpact('T001', 'block', acc);
|
|
305
|
+
|
|
306
|
+
expect(result.cascadeDepth).toBe(3);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('generates recommendation text', async () => {
|
|
310
|
+
const tasks = [makeTask({ id: 'T001' }), makeTask({ id: 'T002', depends: ['T001'] })];
|
|
311
|
+
const acc = mockAccessor(tasks);
|
|
312
|
+
const result = await analyzeChangeImpact('T001', 'block', acc);
|
|
313
|
+
|
|
314
|
+
expect(result.recommendation).toContain('blocking');
|
|
315
|
+
expect(result.recommendation).toContain('T001');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ============================================================================
|
|
320
|
+
// calculateBlastRadius
|
|
321
|
+
// ============================================================================
|
|
322
|
+
|
|
323
|
+
describe('calculateBlastRadius', () => {
|
|
324
|
+
it('returns isolated for non-existent task', async () => {
|
|
325
|
+
const acc = mockAccessor([]);
|
|
326
|
+
const result = await calculateBlastRadius('T999', acc);
|
|
327
|
+
|
|
328
|
+
expect(result.directCount).toBe(0);
|
|
329
|
+
expect(result.transitiveCount).toBe(0);
|
|
330
|
+
expect(result.severity).toBe('isolated');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('computes correct counts for linear chain', async () => {
|
|
334
|
+
const tasks = [
|
|
335
|
+
makeTask({ id: 'T001' }),
|
|
336
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
337
|
+
makeTask({ id: 'T003', depends: ['T002'] }),
|
|
338
|
+
];
|
|
339
|
+
const acc = mockAccessor(tasks);
|
|
340
|
+
const result = await calculateBlastRadius('T001', acc);
|
|
341
|
+
|
|
342
|
+
expect(result.directCount).toBe(1);
|
|
343
|
+
expect(result.transitiveCount).toBe(2);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('computes correct counts for diamond dependency', async () => {
|
|
347
|
+
const tasks = [
|
|
348
|
+
makeTask({ id: 'T001' }),
|
|
349
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
350
|
+
makeTask({ id: 'T003', depends: ['T001'] }),
|
|
351
|
+
makeTask({ id: 'T004', depends: ['T002', 'T003'] }),
|
|
352
|
+
];
|
|
353
|
+
const acc = mockAccessor(tasks);
|
|
354
|
+
const result = await calculateBlastRadius('T001', acc);
|
|
355
|
+
|
|
356
|
+
expect(result.directCount).toBe(2);
|
|
357
|
+
expect(result.transitiveCount).toBe(3);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('counts affected epics', async () => {
|
|
361
|
+
const tasks = [
|
|
362
|
+
makeTask({ id: 'T001', type: 'epic' }),
|
|
363
|
+
makeTask({ id: 'T002', parentId: 'T001', type: 'task' }),
|
|
364
|
+
makeTask({ id: 'T003', parentId: 'T001', type: 'task', depends: ['T002'] }),
|
|
365
|
+
];
|
|
366
|
+
const acc = mockAccessor(tasks);
|
|
367
|
+
const result = await calculateBlastRadius('T002', acc);
|
|
368
|
+
|
|
369
|
+
expect(result.epicCount).toBeGreaterThanOrEqual(1);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('calculates project percentage', async () => {
|
|
373
|
+
// 3 out of 10 tasks affected = 30%
|
|
374
|
+
const tasks: Task[] = [];
|
|
375
|
+
for (let i = 1; i <= 10; i++) {
|
|
376
|
+
tasks.push(
|
|
377
|
+
makeTask({
|
|
378
|
+
id: `T${String(i).padStart(3, '0')}`,
|
|
379
|
+
depends: i > 1 && i <= 4 ? [`T${String(i - 1).padStart(3, '0')}`] : undefined,
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const acc = mockAccessor(tasks);
|
|
384
|
+
const result = await calculateBlastRadius('T001', acc);
|
|
385
|
+
|
|
386
|
+
// T001 -> T002 -> T003 -> T004 (transitive: T002, T003, T004 = 3 tasks)
|
|
387
|
+
expect(result.transitiveCount).toBe(3);
|
|
388
|
+
expect(result.projectPercentage).toBe(30);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('classifies severity as isolated for 0%', async () => {
|
|
392
|
+
const tasks = [makeTask({ id: 'T001' }), makeTask({ id: 'T002' })];
|
|
393
|
+
const acc = mockAccessor(tasks);
|
|
394
|
+
const result = await calculateBlastRadius('T001', acc);
|
|
395
|
+
|
|
396
|
+
expect(result.severity).toBe('isolated');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('classifies severity as moderate for 2-10%', async () => {
|
|
400
|
+
// 1 out of 20 = 5%
|
|
401
|
+
const tasks: Task[] = [];
|
|
402
|
+
for (let i = 1; i <= 20; i++) {
|
|
403
|
+
tasks.push(makeTask({ id: `T${String(i).padStart(3, '0')}` }));
|
|
404
|
+
}
|
|
405
|
+
// Make T002 depend on T001
|
|
406
|
+
tasks[1] = makeTask({ id: 'T002', depends: ['T001'] });
|
|
407
|
+
const acc = mockAccessor(tasks);
|
|
408
|
+
const result = await calculateBlastRadius('T001', acc);
|
|
409
|
+
|
|
410
|
+
expect(result.severity).toBe('moderate');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('classifies severity as critical for >30%', async () => {
|
|
414
|
+
// Chain of 4 out of 5 tasks = 60%
|
|
415
|
+
const tasks = [
|
|
416
|
+
makeTask({ id: 'T001' }),
|
|
417
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
418
|
+
makeTask({ id: 'T003', depends: ['T002'] }),
|
|
419
|
+
makeTask({ id: 'T004', depends: ['T003'] }),
|
|
420
|
+
makeTask({ id: 'T005' }),
|
|
421
|
+
];
|
|
422
|
+
const acc = mockAccessor(tasks);
|
|
423
|
+
const result = await calculateBlastRadius('T001', acc);
|
|
424
|
+
|
|
425
|
+
expect(result.severity).toBe('critical');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('handles orphan task with no dependents', async () => {
|
|
429
|
+
const tasks = [makeTask({ id: 'T001' }), makeTask({ id: 'T002' }), makeTask({ id: 'T003' })];
|
|
430
|
+
const acc = mockAccessor(tasks);
|
|
431
|
+
const result = await calculateBlastRadius('T001', acc);
|
|
432
|
+
|
|
433
|
+
expect(result.directCount).toBe(0);
|
|
434
|
+
expect(result.transitiveCount).toBe(0);
|
|
435
|
+
expect(result.severity).toBe('isolated');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('handles circular dependency without infinite loop', async () => {
|
|
439
|
+
const tasks = [
|
|
440
|
+
makeTask({ id: 'T001', depends: ['T002'] }),
|
|
441
|
+
makeTask({ id: 'T002', depends: ['T001'] }),
|
|
442
|
+
];
|
|
443
|
+
const acc = mockAccessor(tasks);
|
|
444
|
+
// Should complete without hanging.
|
|
445
|
+
// In a cycle, both tasks appear in the transitive closure
|
|
446
|
+
// because BFS from T001 finds T002 (direct dep), then from T002
|
|
447
|
+
// finds T001 (since T001 depends on T002), yielding 2.
|
|
448
|
+
const result = await calculateBlastRadius('T001', acc);
|
|
449
|
+
|
|
450
|
+
expect(result.directCount).toBe(1);
|
|
451
|
+
expect(result.transitiveCount).toBe(2);
|
|
452
|
+
});
|
|
453
|
+
});
|