@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.
Files changed (81) hide show
  1. package/dist/algorithms/critical-path.d.ts +46 -0
  2. package/dist/algorithms/critical-path.d.ts.map +1 -0
  3. package/dist/algorithms/critical-path.js +308 -0
  4. package/dist/algorithms/critical-path.js.map +1 -0
  5. package/dist/algorithms/critical-path.test.d.ts +2 -0
  6. package/dist/algorithms/critical-path.test.d.ts.map +1 -0
  7. package/dist/algorithms/critical-path.test.js +194 -0
  8. package/dist/algorithms/critical-path.test.js.map +1 -0
  9. package/dist/algorithms/index.d.ts +3 -0
  10. package/dist/algorithms/index.d.ts.map +1 -0
  11. package/dist/algorithms/index.js +3 -0
  12. package/dist/algorithms/index.js.map +1 -0
  13. package/dist/algorithms/topological-sort.d.ts +41 -0
  14. package/dist/algorithms/topological-sort.d.ts.map +1 -0
  15. package/dist/algorithms/topological-sort.js +168 -0
  16. package/dist/algorithms/topological-sort.js.map +1 -0
  17. package/dist/algorithms/topological-sort.test.d.ts +2 -0
  18. package/dist/algorithms/topological-sort.test.d.ts.map +1 -0
  19. package/dist/algorithms/topological-sort.test.js +162 -0
  20. package/dist/algorithms/topological-sort.test.js.map +1 -0
  21. package/dist/index.d.ts +4 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +7 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/schemas/index.d.ts +4 -0
  26. package/dist/schemas/index.d.ts.map +1 -0
  27. package/dist/schemas/index.js +7 -0
  28. package/dist/schemas/index.js.map +1 -0
  29. package/dist/schemas/project.d.ts +55 -0
  30. package/dist/schemas/project.d.ts.map +1 -0
  31. package/dist/schemas/project.js +48 -0
  32. package/dist/schemas/project.js.map +1 -0
  33. package/dist/schemas/task.d.ts +124 -0
  34. package/dist/schemas/task.d.ts.map +1 -0
  35. package/dist/schemas/task.js +89 -0
  36. package/dist/schemas/task.js.map +1 -0
  37. package/dist/schemas/view.d.ts +44 -0
  38. package/dist/schemas/view.d.ts.map +1 -0
  39. package/dist/schemas/view.js +33 -0
  40. package/dist/schemas/view.js.map +1 -0
  41. package/dist/utils/date.d.ts +25 -0
  42. package/dist/utils/date.d.ts.map +1 -0
  43. package/dist/utils/date.js +103 -0
  44. package/dist/utils/date.js.map +1 -0
  45. package/dist/utils/date.test.d.ts +2 -0
  46. package/dist/utils/date.test.d.ts.map +1 -0
  47. package/dist/utils/date.test.js +138 -0
  48. package/dist/utils/date.test.js.map +1 -0
  49. package/dist/utils/id.d.ts +27 -0
  50. package/dist/utils/id.d.ts.map +1 -0
  51. package/dist/utils/id.js +41 -0
  52. package/dist/utils/id.js.map +1 -0
  53. package/dist/utils/index.d.ts +4 -0
  54. package/dist/utils/index.d.ts.map +1 -0
  55. package/dist/utils/index.js +4 -0
  56. package/dist/utils/index.js.map +1 -0
  57. package/dist/utils/natural-language.d.ts +12 -0
  58. package/dist/utils/natural-language.d.ts.map +1 -0
  59. package/dist/utils/natural-language.js +112 -0
  60. package/dist/utils/natural-language.js.map +1 -0
  61. package/dist/utils/natural-language.test.d.ts +2 -0
  62. package/dist/utils/natural-language.test.d.ts.map +1 -0
  63. package/dist/utils/natural-language.test.js +132 -0
  64. package/dist/utils/natural-language.test.js.map +1 -0
  65. package/package.json +46 -0
  66. package/src/algorithms/critical-path.test.ts +241 -0
  67. package/src/algorithms/critical-path.ts +413 -0
  68. package/src/algorithms/index.ts +17 -0
  69. package/src/algorithms/topological-sort.test.ts +190 -0
  70. package/src/algorithms/topological-sort.ts +204 -0
  71. package/src/index.ts +8 -0
  72. package/src/schemas/index.ts +30 -0
  73. package/src/schemas/project.ts +62 -0
  74. package/src/schemas/task.ts +116 -0
  75. package/src/schemas/view.ts +46 -0
  76. package/src/utils/date.test.ts +160 -0
  77. package/src/utils/date.ts +119 -0
  78. package/src/utils/id.ts +45 -0
  79. package/src/utils/index.ts +3 -0
  80. package/src/utils/natural-language.test.ts +154 -0
  81. 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
+ });