@task-mcp/shared 0.1.0
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/algorithms/critical-path.d.ts +46 -0
- package/dist/algorithms/critical-path.d.ts.map +1 -0
- package/dist/algorithms/critical-path.js +308 -0
- package/dist/algorithms/critical-path.js.map +1 -0
- package/dist/algorithms/critical-path.test.d.ts +2 -0
- package/dist/algorithms/critical-path.test.d.ts.map +1 -0
- package/dist/algorithms/critical-path.test.js +194 -0
- package/dist/algorithms/critical-path.test.js.map +1 -0
- package/dist/algorithms/index.d.ts +3 -0
- package/dist/algorithms/index.d.ts.map +1 -0
- package/dist/algorithms/index.js +3 -0
- package/dist/algorithms/index.js.map +1 -0
- package/dist/algorithms/topological-sort.d.ts +41 -0
- package/dist/algorithms/topological-sort.d.ts.map +1 -0
- package/dist/algorithms/topological-sort.js +168 -0
- package/dist/algorithms/topological-sort.js.map +1 -0
- package/dist/algorithms/topological-sort.test.d.ts +2 -0
- package/dist/algorithms/topological-sort.test.d.ts.map +1 -0
- package/dist/algorithms/topological-sort.test.js +162 -0
- package/dist/algorithms/topological-sort.test.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas/index.d.ts +4 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +7 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/project.d.ts +55 -0
- package/dist/schemas/project.d.ts.map +1 -0
- package/dist/schemas/project.js +48 -0
- package/dist/schemas/project.js.map +1 -0
- package/dist/schemas/task.d.ts +124 -0
- package/dist/schemas/task.d.ts.map +1 -0
- package/dist/schemas/task.js +89 -0
- package/dist/schemas/task.js.map +1 -0
- package/dist/schemas/view.d.ts +44 -0
- package/dist/schemas/view.d.ts.map +1 -0
- package/dist/schemas/view.js +33 -0
- package/dist/schemas/view.js.map +1 -0
- package/dist/utils/date.d.ts +25 -0
- package/dist/utils/date.d.ts.map +1 -0
- package/dist/utils/date.js +103 -0
- package/dist/utils/date.js.map +1 -0
- package/dist/utils/date.test.d.ts +2 -0
- package/dist/utils/date.test.d.ts.map +1 -0
- package/dist/utils/date.test.js +138 -0
- package/dist/utils/date.test.js.map +1 -0
- package/dist/utils/id.d.ts +27 -0
- package/dist/utils/id.d.ts.map +1 -0
- package/dist/utils/id.js +41 -0
- package/dist/utils/id.js.map +1 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/natural-language.d.ts +12 -0
- package/dist/utils/natural-language.d.ts.map +1 -0
- package/dist/utils/natural-language.js +112 -0
- package/dist/utils/natural-language.js.map +1 -0
- package/dist/utils/natural-language.test.d.ts +2 -0
- package/dist/utils/natural-language.test.d.ts.map +1 -0
- package/dist/utils/natural-language.test.js +132 -0
- package/dist/utils/natural-language.test.js.map +1 -0
- package/package.json +46 -0
- package/src/algorithms/critical-path.test.ts +241 -0
- package/src/algorithms/critical-path.ts +413 -0
- package/src/algorithms/index.ts +17 -0
- package/src/algorithms/topological-sort.test.ts +190 -0
- package/src/algorithms/topological-sort.ts +204 -0
- package/src/index.ts +8 -0
- package/src/schemas/index.ts +30 -0
- package/src/schemas/project.ts +62 -0
- package/src/schemas/task.ts +116 -0
- package/src/schemas/view.ts +46 -0
- package/src/utils/date.test.ts +160 -0
- package/src/utils/date.ts +119 -0
- package/src/utils/id.ts +45 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/natural-language.test.ts +154 -0
- package/src/utils/natural-language.ts +125 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import type { Task } from "../schemas/task.js";
|
|
2
|
+
import { topologicalSort, findDependents } from "./topological-sort.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Task with computed CPM (Critical Path Method) values
|
|
6
|
+
*/
|
|
7
|
+
export interface CPMTask extends Task {
|
|
8
|
+
// Computed values
|
|
9
|
+
earliestStart: number; // Minutes from project start
|
|
10
|
+
earliestFinish: number;
|
|
11
|
+
latestStart: number;
|
|
12
|
+
latestFinish: number;
|
|
13
|
+
slack: number; // Float time
|
|
14
|
+
isCritical: boolean; // On critical path if slack === 0
|
|
15
|
+
dependentCount: number; // Number of tasks blocked by this
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Result of critical path analysis
|
|
20
|
+
*/
|
|
21
|
+
export interface CPMResult {
|
|
22
|
+
tasks: CPMTask[];
|
|
23
|
+
criticalPath: CPMTask[]; // Tasks on the critical path, in order
|
|
24
|
+
projectDuration: number; // Total project duration in minutes
|
|
25
|
+
bottlenecks: CPMTask[]; // Critical tasks that block the most downstream work
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Default task duration if no estimate provided */
|
|
29
|
+
const DEFAULT_DURATION = 30;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get task duration with fallback to default
|
|
33
|
+
*/
|
|
34
|
+
function getTaskDuration(task: Task): number {
|
|
35
|
+
return task.estimate?.expected ?? DEFAULT_DURATION;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get blocked_by dependencies for a task
|
|
40
|
+
*/
|
|
41
|
+
function getBlockedByDeps(task: Task): string[] {
|
|
42
|
+
return (task.dependencies ?? [])
|
|
43
|
+
.filter((d) => d.type === "blocked_by")
|
|
44
|
+
.map((d) => d.taskId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Initialize CPM tasks with default values
|
|
49
|
+
*/
|
|
50
|
+
function initializeCPMTasks(sortedTasks: Task[]): Map<string, CPMTask> {
|
|
51
|
+
const taskMap = new Map<string, CPMTask>();
|
|
52
|
+
|
|
53
|
+
for (const task of sortedTasks) {
|
|
54
|
+
const duration = getTaskDuration(task);
|
|
55
|
+
taskMap.set(task.id, {
|
|
56
|
+
...task,
|
|
57
|
+
earliestStart: 0,
|
|
58
|
+
earliestFinish: duration,
|
|
59
|
+
latestStart: Infinity,
|
|
60
|
+
latestFinish: Infinity,
|
|
61
|
+
slack: 0,
|
|
62
|
+
isCritical: false,
|
|
63
|
+
dependentCount: 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return taskMap;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Forward pass: Calculate earliest start/finish times
|
|
72
|
+
* ES = max(EF of all predecessors)
|
|
73
|
+
* EF = ES + duration
|
|
74
|
+
*/
|
|
75
|
+
function forwardPass(sortedTasks: Task[], taskMap: Map<string, CPMTask>): void {
|
|
76
|
+
for (const task of sortedTasks) {
|
|
77
|
+
const cpmTask = taskMap.get(task.id);
|
|
78
|
+
if (!cpmTask) continue;
|
|
79
|
+
|
|
80
|
+
const duration = getTaskDuration(task);
|
|
81
|
+
const deps = getBlockedByDeps(task);
|
|
82
|
+
|
|
83
|
+
// Find maximum EF among predecessors
|
|
84
|
+
let maxPredecessorEF = 0;
|
|
85
|
+
for (const depId of deps) {
|
|
86
|
+
const dep = taskMap.get(depId);
|
|
87
|
+
if (dep) {
|
|
88
|
+
maxPredecessorEF = Math.max(maxPredecessorEF, dep.earliestFinish);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
cpmTask.earliestStart = maxPredecessorEF;
|
|
93
|
+
cpmTask.earliestFinish = cpmTask.earliestStart + duration;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Calculate project duration (maximum earliest finish)
|
|
99
|
+
*/
|
|
100
|
+
function calculateProjectDuration(taskMap: Map<string, CPMTask>): number {
|
|
101
|
+
return Math.max(...Array.from(taskMap.values()).map((t) => t.earliestFinish));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find tasks that have successors (are dependencies of other tasks)
|
|
106
|
+
*/
|
|
107
|
+
function findTasksWithSuccessors(sortedTasks: Task[]): Set<string> {
|
|
108
|
+
const tasksWithSuccessors = new Set<string>();
|
|
109
|
+
|
|
110
|
+
for (const task of sortedTasks) {
|
|
111
|
+
const deps = getBlockedByDeps(task);
|
|
112
|
+
for (const depId of deps) {
|
|
113
|
+
tasksWithSuccessors.add(depId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return tasksWithSuccessors;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Find successor tasks (tasks that depend on a given task)
|
|
122
|
+
*/
|
|
123
|
+
function findSuccessors(taskId: string, taskMap: Map<string, CPMTask>): CPMTask[] {
|
|
124
|
+
const successors: CPMTask[] = [];
|
|
125
|
+
|
|
126
|
+
for (const other of taskMap.values()) {
|
|
127
|
+
const otherDeps = getBlockedByDeps(other);
|
|
128
|
+
if (otherDeps.includes(taskId)) {
|
|
129
|
+
successors.push(other);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return successors;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Backward pass: Calculate latest start/finish times
|
|
138
|
+
* LF = min(LS of all successors) or projectDuration for end tasks
|
|
139
|
+
* LS = LF - duration
|
|
140
|
+
*/
|
|
141
|
+
function backwardPass(
|
|
142
|
+
sortedTasks: Task[],
|
|
143
|
+
taskMap: Map<string, CPMTask>,
|
|
144
|
+
projectDuration: number
|
|
145
|
+
): void {
|
|
146
|
+
const tasksWithSuccessors = findTasksWithSuccessors(sortedTasks);
|
|
147
|
+
|
|
148
|
+
// Initialize end tasks (tasks with no successors)
|
|
149
|
+
for (const task of taskMap.values()) {
|
|
150
|
+
if (!tasksWithSuccessors.has(task.id)) {
|
|
151
|
+
const duration = getTaskDuration(task);
|
|
152
|
+
task.latestFinish = projectDuration;
|
|
153
|
+
task.latestStart = task.latestFinish - duration;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Process in reverse topological order
|
|
158
|
+
const reverseSorted = [...sortedTasks].reverse();
|
|
159
|
+
|
|
160
|
+
for (const task of reverseSorted) {
|
|
161
|
+
const cpmTask = taskMap.get(task.id);
|
|
162
|
+
if (!cpmTask) continue;
|
|
163
|
+
|
|
164
|
+
const duration = getTaskDuration(task);
|
|
165
|
+
const successors = findSuccessors(task.id, taskMap);
|
|
166
|
+
|
|
167
|
+
if (successors.length > 0) {
|
|
168
|
+
// LF = min(LS of all successors)
|
|
169
|
+
cpmTask.latestFinish = Math.min(...successors.map((s) => s.latestStart));
|
|
170
|
+
cpmTask.latestStart = cpmTask.latestFinish - duration;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Calculate slack and mark critical tasks
|
|
177
|
+
* Slack = LS - ES (or LF - EF)
|
|
178
|
+
* Critical = slack ≈ 0
|
|
179
|
+
*/
|
|
180
|
+
function calculateSlackAndCritical(taskMap: Map<string, CPMTask>): void {
|
|
181
|
+
const FLOAT_TOLERANCE = 0.001;
|
|
182
|
+
|
|
183
|
+
for (const task of taskMap.values()) {
|
|
184
|
+
task.slack = task.latestStart - task.earliestStart;
|
|
185
|
+
task.isCritical = Math.abs(task.slack) < FLOAT_TOLERANCE;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Count dependents for bottleneck detection
|
|
191
|
+
*/
|
|
192
|
+
function countDependents(sortedTasks: Task[], activeTasks: Task[], taskMap: Map<string, CPMTask>): void {
|
|
193
|
+
for (const task of sortedTasks) {
|
|
194
|
+
const cpmTask = taskMap.get(task.id);
|
|
195
|
+
if (cpmTask) {
|
|
196
|
+
cpmTask.dependentCount = findDependents(activeTasks, task.id).length;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extract critical path tasks in topological order
|
|
203
|
+
*/
|
|
204
|
+
function extractCriticalPath(sortedTasks: Task[], taskMap: Map<string, CPMTask>): CPMTask[] {
|
|
205
|
+
return sortedTasks
|
|
206
|
+
.map((t) => taskMap.get(t.id))
|
|
207
|
+
.filter((t): t is CPMTask => t !== undefined && t.isCritical);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Find top bottlenecks (critical tasks blocking the most downstream work)
|
|
212
|
+
*/
|
|
213
|
+
function findBottlenecks(criticalPath: CPMTask[], limit = 5): CPMTask[] {
|
|
214
|
+
return [...criticalPath]
|
|
215
|
+
.sort((a, b) => b.dependentCount - a.dependentCount)
|
|
216
|
+
.slice(0, limit);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Perform Critical Path Method analysis
|
|
221
|
+
*
|
|
222
|
+
* CPM calculates:
|
|
223
|
+
* - Earliest Start (ES): Earliest a task can start
|
|
224
|
+
* - Earliest Finish (EF): ES + duration
|
|
225
|
+
* - Latest Finish (LF): Latest a task can finish without delaying project
|
|
226
|
+
* - Latest Start (LS): LF - duration
|
|
227
|
+
* - Slack: LS - ES (or LF - EF)
|
|
228
|
+
* - Critical Path: Tasks with slack = 0
|
|
229
|
+
*/
|
|
230
|
+
export function criticalPathAnalysis(tasks: Task[]): CPMResult {
|
|
231
|
+
// Filter to only pending/in_progress tasks
|
|
232
|
+
const activeTasks = tasks.filter(
|
|
233
|
+
(t) => t.status === "pending" || t.status === "in_progress"
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
if (activeTasks.length === 0) {
|
|
237
|
+
return {
|
|
238
|
+
tasks: [],
|
|
239
|
+
criticalPath: [],
|
|
240
|
+
projectDuration: 0,
|
|
241
|
+
bottlenecks: [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Sort topologically
|
|
246
|
+
const sortedTasks = topologicalSort(activeTasks);
|
|
247
|
+
|
|
248
|
+
// Initialize CPM tasks
|
|
249
|
+
const taskMap = initializeCPMTasks(sortedTasks);
|
|
250
|
+
|
|
251
|
+
// Forward pass: Calculate earliest start/finish
|
|
252
|
+
forwardPass(sortedTasks, taskMap);
|
|
253
|
+
|
|
254
|
+
// Calculate project duration
|
|
255
|
+
const projectDuration = calculateProjectDuration(taskMap);
|
|
256
|
+
|
|
257
|
+
// Backward pass: Calculate latest start/finish
|
|
258
|
+
backwardPass(sortedTasks, taskMap, projectDuration);
|
|
259
|
+
|
|
260
|
+
// Calculate slack and mark critical tasks
|
|
261
|
+
calculateSlackAndCritical(taskMap);
|
|
262
|
+
|
|
263
|
+
// Count dependents for bottleneck detection
|
|
264
|
+
countDependents(sortedTasks, activeTasks, taskMap);
|
|
265
|
+
|
|
266
|
+
// Extract critical path
|
|
267
|
+
const criticalPath = extractCriticalPath(sortedTasks, taskMap);
|
|
268
|
+
|
|
269
|
+
// Find bottlenecks
|
|
270
|
+
const bottlenecks = findBottlenecks(criticalPath);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
tasks: sortedTasks.map((t) => taskMap.get(t.id)!),
|
|
274
|
+
criticalPath,
|
|
275
|
+
projectDuration,
|
|
276
|
+
bottlenecks,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Find tasks that can be executed in parallel (no dependencies between them)
|
|
282
|
+
*/
|
|
283
|
+
export function findParallelTasks(tasks: Task[]): Task[][] {
|
|
284
|
+
const activeTasks = tasks.filter(
|
|
285
|
+
(t) => t.status === "pending" || t.status === "in_progress"
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (activeTasks.length === 0) return [];
|
|
289
|
+
|
|
290
|
+
// Find tasks with no uncompleted dependencies
|
|
291
|
+
const completedIds = new Set(
|
|
292
|
+
tasks.filter((t) => t.status === "completed").map((t) => t.id)
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const available = activeTasks.filter((task) => {
|
|
296
|
+
const deps = getBlockedByDeps(task);
|
|
297
|
+
return deps.every((depId) => completedIds.has(depId));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (available.length <= 1) return [available];
|
|
301
|
+
|
|
302
|
+
// Group tasks that don't depend on each other
|
|
303
|
+
const groups: Task[][] = [];
|
|
304
|
+
const processed = new Set<string>();
|
|
305
|
+
|
|
306
|
+
for (const task of available) {
|
|
307
|
+
if (processed.has(task.id)) continue;
|
|
308
|
+
|
|
309
|
+
const group: Task[] = [task];
|
|
310
|
+
processed.add(task.id);
|
|
311
|
+
|
|
312
|
+
for (const other of available) {
|
|
313
|
+
if (processed.has(other.id)) continue;
|
|
314
|
+
|
|
315
|
+
// Check if these tasks are independent
|
|
316
|
+
const taskDeps = (task.dependencies ?? []).map((d) => d.taskId);
|
|
317
|
+
const otherDeps = (other.dependencies ?? []).map((d) => d.taskId);
|
|
318
|
+
|
|
319
|
+
const independent =
|
|
320
|
+
!taskDeps.includes(other.id) && !otherDeps.includes(task.id);
|
|
321
|
+
|
|
322
|
+
if (independent) {
|
|
323
|
+
group.push(other);
|
|
324
|
+
processed.add(other.id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
groups.push(group);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return groups;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Suggest the next best task to work on
|
|
336
|
+
*/
|
|
337
|
+
export function suggestNextTask(
|
|
338
|
+
tasks: Task[],
|
|
339
|
+
options: {
|
|
340
|
+
contexts?: string[];
|
|
341
|
+
maxMinutes?: number;
|
|
342
|
+
} = {}
|
|
343
|
+
): Task | null {
|
|
344
|
+
const activeTasks = tasks.filter(
|
|
345
|
+
(t) => t.status === "pending" || t.status === "in_progress"
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (activeTasks.length === 0) return null;
|
|
349
|
+
|
|
350
|
+
// Get CPM analysis
|
|
351
|
+
const cpm = criticalPathAnalysis(tasks);
|
|
352
|
+
|
|
353
|
+
// Filter by availability (all dependencies completed)
|
|
354
|
+
const completedIds = new Set(
|
|
355
|
+
tasks.filter((t) => t.status === "completed").map((t) => t.id)
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
let candidates = cpm.tasks.filter((task) => {
|
|
359
|
+
const deps = getBlockedByDeps(task);
|
|
360
|
+
return deps.every((depId) => completedIds.has(depId));
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Filter by context if specified
|
|
364
|
+
if (options.contexts?.length) {
|
|
365
|
+
const contextSet = new Set(options.contexts);
|
|
366
|
+
const contextFiltered = candidates.filter((t) =>
|
|
367
|
+
(t.contexts ?? []).some((c) => contextSet.has(c))
|
|
368
|
+
);
|
|
369
|
+
if (contextFiltered.length > 0) {
|
|
370
|
+
candidates = contextFiltered;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Filter by time if specified
|
|
375
|
+
if (options.maxMinutes) {
|
|
376
|
+
const timeFiltered = candidates.filter(
|
|
377
|
+
(t) => getTaskDuration(t) <= options.maxMinutes!
|
|
378
|
+
);
|
|
379
|
+
if (timeFiltered.length > 0) {
|
|
380
|
+
candidates = timeFiltered;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (candidates.length === 0) return null;
|
|
385
|
+
|
|
386
|
+
// Score and rank candidates
|
|
387
|
+
// Priority: Critical path > High priority > Most dependents > Shortest duration
|
|
388
|
+
const scored = candidates.map((task) => {
|
|
389
|
+
let score = 0;
|
|
390
|
+
if (task.isCritical) score += 1000;
|
|
391
|
+
score += task.dependentCount * 100;
|
|
392
|
+
score += priorityScore(task.priority) * 10;
|
|
393
|
+
// Prefer shorter tasks (quick wins)
|
|
394
|
+
score += Math.max(0, 100 - getTaskDuration(task));
|
|
395
|
+
return { task, score };
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
scored.sort((a, b) => b.score - a.score);
|
|
399
|
+
return scored[0]?.task ?? null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Convert priority to numeric score
|
|
404
|
+
*/
|
|
405
|
+
function priorityScore(priority: string): number {
|
|
406
|
+
const scores: Record<string, number> = {
|
|
407
|
+
critical: 4,
|
|
408
|
+
high: 3,
|
|
409
|
+
medium: 2,
|
|
410
|
+
low: 1,
|
|
411
|
+
};
|
|
412
|
+
return scores[priority] ?? 2;
|
|
413
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
topologicalSort,
|
|
3
|
+
wouldCreateCycle,
|
|
4
|
+
findDependents,
|
|
5
|
+
findDependencies,
|
|
6
|
+
priorityToNumber,
|
|
7
|
+
taskToNode,
|
|
8
|
+
type TaskNode,
|
|
9
|
+
} from "./topological-sort.js";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
criticalPathAnalysis,
|
|
13
|
+
findParallelTasks,
|
|
14
|
+
suggestNextTask,
|
|
15
|
+
type CPMTask,
|
|
16
|
+
type CPMResult,
|
|
17
|
+
} from "./critical-path.js";
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
priorityToNumber,
|
|
4
|
+
topologicalSort,
|
|
5
|
+
wouldCreateCycle,
|
|
6
|
+
findDependents,
|
|
7
|
+
findDependencies,
|
|
8
|
+
} from "./topological-sort.js";
|
|
9
|
+
import type { Task } from "../schemas/task.js";
|
|
10
|
+
|
|
11
|
+
// Helper to create mock tasks
|
|
12
|
+
function createTask(id: string, priority: string = "medium", deps: string[] = []): Task {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
title: `Task ${id}`,
|
|
16
|
+
status: "pending",
|
|
17
|
+
priority: priority as Task["priority"],
|
|
18
|
+
projectId: "test-project",
|
|
19
|
+
createdAt: new Date().toISOString(),
|
|
20
|
+
updatedAt: new Date().toISOString(),
|
|
21
|
+
dependencies: deps.map((depId) => ({ taskId: depId, type: "blocked_by" as const })),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("priorityToNumber", () => {
|
|
26
|
+
test("converts critical to 4", () => {
|
|
27
|
+
expect(priorityToNumber("critical")).toBe(4);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("converts high to 3", () => {
|
|
31
|
+
expect(priorityToNumber("high")).toBe(3);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("converts medium to 2", () => {
|
|
35
|
+
expect(priorityToNumber("medium")).toBe(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("converts low to 1", () => {
|
|
39
|
+
expect(priorityToNumber("low")).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("defaults to 2 for unknown priority", () => {
|
|
43
|
+
expect(priorityToNumber("unknown")).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("topologicalSort", () => {
|
|
48
|
+
test("returns empty array for empty input", () => {
|
|
49
|
+
const result = topologicalSort([]);
|
|
50
|
+
expect(result).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("returns single task unchanged", () => {
|
|
54
|
+
const tasks = [createTask("A")];
|
|
55
|
+
const result = topologicalSort(tasks);
|
|
56
|
+
expect(result.length).toBe(1);
|
|
57
|
+
expect(result[0]!.id).toBe("A");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("sorts by dependency order", () => {
|
|
61
|
+
// B depends on A, so A should come first
|
|
62
|
+
const tasks = [createTask("B", "high", ["A"]), createTask("A", "low")];
|
|
63
|
+
const result = topologicalSort(tasks);
|
|
64
|
+
expect(result[0]!.id).toBe("A");
|
|
65
|
+
expect(result[1]!.id).toBe("B");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("respects priority when no dependencies", () => {
|
|
69
|
+
const tasks = [
|
|
70
|
+
createTask("A", "low"),
|
|
71
|
+
createTask("B", "critical"),
|
|
72
|
+
createTask("C", "high"),
|
|
73
|
+
];
|
|
74
|
+
const result = topologicalSort(tasks);
|
|
75
|
+
expect(result[0]!.id).toBe("B"); // critical first
|
|
76
|
+
expect(result[1]!.id).toBe("C"); // then high
|
|
77
|
+
expect(result[2]!.id).toBe("A"); // then low
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("handles chain of dependencies", () => {
|
|
81
|
+
// C -> B -> A (C depends on B, B depends on A)
|
|
82
|
+
const tasks = [
|
|
83
|
+
createTask("C", "critical", ["B"]),
|
|
84
|
+
createTask("B", "high", ["A"]),
|
|
85
|
+
createTask("A", "low"),
|
|
86
|
+
];
|
|
87
|
+
const result = topologicalSort(tasks);
|
|
88
|
+
expect(result.map((t) => t.id)).toEqual(["A", "B", "C"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("throws on circular dependency", () => {
|
|
92
|
+
// A -> B -> A
|
|
93
|
+
const tasks = [createTask("A", "medium", ["B"]), createTask("B", "medium", ["A"])];
|
|
94
|
+
expect(() => topologicalSort(tasks)).toThrow(/Circular dependency/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("handles diamond dependency", () => {
|
|
98
|
+
// D depends on B and C, both depend on A
|
|
99
|
+
const tasks = [
|
|
100
|
+
createTask("D", "medium", ["B", "C"]),
|
|
101
|
+
createTask("B", "high", ["A"]),
|
|
102
|
+
createTask("C", "low", ["A"]),
|
|
103
|
+
createTask("A", "medium"),
|
|
104
|
+
];
|
|
105
|
+
const result = topologicalSort(tasks);
|
|
106
|
+
// A must come first, D must come last
|
|
107
|
+
expect(result[0]!.id).toBe("A");
|
|
108
|
+
expect(result[3]!.id).toBe("D");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("wouldCreateCycle", () => {
|
|
113
|
+
test("returns false for valid dependency", () => {
|
|
114
|
+
const tasks = [createTask("A"), createTask("B")];
|
|
115
|
+
// Adding B blocked_by A should not create cycle
|
|
116
|
+
expect(wouldCreateCycle(tasks, "B", "A")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("returns true for direct cycle", () => {
|
|
120
|
+
const tasks = [createTask("A", "medium", ["B"]), createTask("B")];
|
|
121
|
+
// B is already blocking A, so A blocking B would create cycle
|
|
122
|
+
expect(wouldCreateCycle(tasks, "B", "A")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("returns true for indirect cycle", () => {
|
|
126
|
+
const tasks = [
|
|
127
|
+
createTask("A", "medium", ["B"]),
|
|
128
|
+
createTask("B", "medium", ["C"]),
|
|
129
|
+
createTask("C"),
|
|
130
|
+
];
|
|
131
|
+
// A <- B <- C, adding C <- A would create cycle
|
|
132
|
+
expect(wouldCreateCycle(tasks, "C", "A")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("findDependents", () => {
|
|
137
|
+
test("returns empty for task with no dependents", () => {
|
|
138
|
+
const tasks = [createTask("A"), createTask("B")];
|
|
139
|
+
const result = findDependents(tasks, "A");
|
|
140
|
+
expect(result).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("finds direct dependents", () => {
|
|
144
|
+
const tasks = [
|
|
145
|
+
createTask("A"),
|
|
146
|
+
createTask("B", "medium", ["A"]),
|
|
147
|
+
createTask("C", "medium", ["A"]),
|
|
148
|
+
];
|
|
149
|
+
const result = findDependents(tasks, "A");
|
|
150
|
+
expect(result.map((t) => t.id).sort()).toEqual(["B", "C"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("finds transitive dependents", () => {
|
|
154
|
+
const tasks = [
|
|
155
|
+
createTask("A"),
|
|
156
|
+
createTask("B", "medium", ["A"]),
|
|
157
|
+
createTask("C", "medium", ["B"]),
|
|
158
|
+
];
|
|
159
|
+
const result = findDependents(tasks, "A");
|
|
160
|
+
expect(result.map((t) => t.id).sort()).toEqual(["B", "C"]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("findDependencies", () => {
|
|
165
|
+
test("returns empty for task with no dependencies", () => {
|
|
166
|
+
const tasks = [createTask("A"), createTask("B")];
|
|
167
|
+
const result = findDependencies(tasks, "A");
|
|
168
|
+
expect(result).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("finds direct dependencies", () => {
|
|
172
|
+
const tasks = [
|
|
173
|
+
createTask("A"),
|
|
174
|
+
createTask("B"),
|
|
175
|
+
createTask("C", "medium", ["A", "B"]),
|
|
176
|
+
];
|
|
177
|
+
const result = findDependencies(tasks, "C");
|
|
178
|
+
expect(result.map((t) => t.id).sort()).toEqual(["A", "B"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("finds transitive dependencies", () => {
|
|
182
|
+
const tasks = [
|
|
183
|
+
createTask("A"),
|
|
184
|
+
createTask("B", "medium", ["A"]),
|
|
185
|
+
createTask("C", "medium", ["B"]),
|
|
186
|
+
];
|
|
187
|
+
const result = findDependencies(tasks, "C");
|
|
188
|
+
expect(result.map((t) => t.id).sort()).toEqual(["A", "B"]);
|
|
189
|
+
});
|
|
190
|
+
});
|