more dependancy parsing support

This commit is contained in:
2026-06-01 11:31:33 -04:00
parent 8db135a523
commit 8151d19127
3 changed files with 514 additions and 296 deletions

View File

@@ -1,4 +1,10 @@
import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types"; import type {
Task,
ExecutionBatch,
ExecutionPlan,
Project,
ParallelGroup,
} from "./types";
// ─── Blocked Tasks ─────────────────────────────────────────────────────────── // ─── Blocked Tasks ───────────────────────────────────────────────────────────
@@ -7,25 +13,25 @@ import type { Task, ExecutionBatch, ExecutionPlan, Project } from "./types";
* Returns a Set of blocked task IDs. * Returns a Set of blocked task IDs.
*/ */
export function getBlockedTasks( export function getBlockedTasks(
pendingTasks: Task[], pendingTasks: Task[],
failedTaskIds: Set<string>, failedTaskIds: Set<string>,
): Set<string> { ): Set<string> {
const blocked = new Set<string>(); const blocked = new Set<string>();
let changed = true; let changed = true;
while (changed) { while (changed) {
changed = false; changed = false;
for (const task of pendingTasks) { for (const task of pendingTasks) {
if (blocked.has(task.id)) continue; if (blocked.has(task.id)) continue;
const deps = task.dependencies || []; const deps = task.dependencies || [];
if (deps.some((dep) => failedTaskIds.has(dep))) { if (deps.some((dep) => failedTaskIds.has(dep))) {
blocked.add(task.id); blocked.add(task.id);
changed = true; changed = true;
} }
} }
} }
return blocked; return blocked;
} }
// ─── Main Entry ────────────────────────────────────────────────────────────── // ─── Main Entry ──────────────────────────────────────────────────────────────
@@ -35,29 +41,44 @@ export function getBlockedTasks(
* Returns ordered batches of parallelizable tasks. * Returns ordered batches of parallelizable tasks.
*/ */
export function buildExecutionPlan( export function buildExecutionPlan(
project: Project, project: Project,
completed: Set<string>, completed: Set<string>,
parallelGroup?: number, parallelGroup?: number,
failedTaskIds: Set<string> = new Set(), failedTaskIds: Set<string> = new Set(),
): ExecutionPlan { ): ExecutionPlan {
// Filter out already completed tasks // Filter out already completed tasks
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
const skippedTasks = project.tasks.filter((t) => completed.has(t.id));
// If parallel_group is explicitly set, use group-based batching // With explicitly declared parallel groups, all groups are independent.
if (parallelGroup !== undefined) { // Since there are no cross-group dependencies by definition, standard
return { // Kahn's algorithm produces the correct plan — tasks ready in any group
batches: buildParallelGroupBatches(pendingTasks, failedTaskIds), // appear in the same batch, and intra-group dependencies (e.g. "21 must
totalTasks: pendingTasks.length, // be done before 22, 23, 24") are respected automatically.
skippedTasks: project.tasks.filter((t) => completed.has(t.id)), // The parallel groups are preserved as metadata for display/documentation.
}; if (project.parallelGroups && project.parallelGroups.length > 0) {
} return {
batches: buildGroupAwareBatches(project, pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length,
skippedTasks,
};
}
// Use dependency-based Kahn's algorithm // If parallel_group is explicitly set (legacy config flag), use group-based batching
return { if (parallelGroup !== undefined) {
batches: buildBatches(pendingTasks, failedTaskIds), return {
totalTasks: pendingTasks.length, batches: buildParallelGroupBatchesLegacy(pendingTasks, failedTaskIds),
skippedTasks: project.tasks.filter((t) => completed.has(t.id)), totalTasks: pendingTasks.length,
}; skippedTasks,
};
}
// Use dependency-based Kahn's algorithm
return {
batches: buildBatches(pendingTasks, failedTaskIds),
totalTasks: pendingTasks.length,
skippedTasks,
};
} }
// ─── Sequential Plan ───────────────────────────────────────────────────────── // ─── Sequential Plan ─────────────────────────────────────────────────────────
@@ -66,106 +87,165 @@ export function buildExecutionPlan(
* Build a sequential execution plan (one task per batch) * Build a sequential execution plan (one task per batch)
*/ */
export function buildSequentialPlan( export function buildSequentialPlan(
project: Project, project: Project,
completed: Set<string>, completed: Set<string>,
failedTaskIds: Set<string> = new Set(), failedTaskIds: Set<string> = new Set(),
): ExecutionPlan { ): ExecutionPlan {
const pendingTasks = project.tasks.filter((t) => !completed.has(t.id)); const pendingTasks = project.tasks.filter((t) => !completed.has(t.id));
// Mark tasks with failed dependencies as skipped // Mark tasks with failed dependencies as skipped
const blocked = getBlockedTasks(pendingTasks, failedTaskIds); const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const skippedTasks = project.tasks.filter( const skippedTasks = project.tasks.filter(
(t) => completed.has(t.id) || blocked.has(t.id), (t) => completed.has(t.id) || blocked.has(t.id),
); );
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({ const batches: ExecutionBatch[] = activeTasks.map((task, i) => ({
tasks: [task], tasks: [task],
batchIndex: i, batchIndex: i,
})); }));
return { return {
batches, batches,
totalTasks: pendingTasks.length, totalTasks: pendingTasks.length,
skippedTasks, skippedTasks,
}; };
} }
// ─── Kahn's Algorithm (Dependency-Based Batching) ──────────────────────────── // ─── Kahn's Algorithm (Dependency-Based Batching) ────────────────────────────
function buildBatches( function buildBatches(
pendingTasks: Task[], pendingTasks: Task[],
failedTaskIds: Set<string>, failedTaskIds: Set<string>,
): ExecutionBatch[] { ): ExecutionBatch[] {
const batches: ExecutionBatch[] = []; const batches: ExecutionBatch[] = [];
const done = new Set<string>(); const done = new Set<string>();
const blocked = getBlockedTasks(pendingTasks, failedTaskIds); const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const pendingSet = new Set(pendingTasks.map((t) => t.id)); const pendingSet = new Set(pendingTasks.map((t) => t.id));
const remaining = new Set( const remaining = new Set(
pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id), pendingTasks.filter((t) => !blocked.has(t.id)).map((t) => t.id),
); );
while (remaining.size > 0) { while (remaining.size > 0) {
// Find tasks whose dependencies are all satisfied // Find tasks whose dependencies are all satisfied
const ready: Task[] = []; const ready: Task[] = [];
for (const task of pendingTasks) { for (const task of pendingTasks) {
if (!remaining.has(task.id)) continue; if (!remaining.has(task.id)) continue;
const deps = task.dependencies || []; const deps = task.dependencies || [];
const depsSatisfied = deps.every( const depsSatisfied = deps.every(
(dep) => done.has(dep) || !pendingSet.has(dep), (dep) => done.has(dep) || !pendingSet.has(dep),
); );
if (depsSatisfied) { if (depsSatisfied) {
ready.push(task); ready.push(task);
} }
} }
// Cycle detection: no tasks ready but some remain // Cycle detection: no tasks ready but some remain
if (ready.length === 0) { if (ready.length === 0) {
const cycleTasks = Array.from(remaining); const cycleTasks = Array.from(remaining);
throw new Error( throw new Error(
`Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`, `Dependency cycle detected among tasks: ${cycleTasks.join(", ")}`,
); );
} }
batches.push({ tasks: ready, batchIndex: batches.length }); batches.push({ tasks: ready, batchIndex: batches.length });
for (const task of ready) { for (const task of ready) {
done.add(task.id); done.add(task.id);
remaining.delete(task.id); remaining.delete(task.id);
} }
} }
return batches; return batches;
} }
// ─── Parallel Group Batching ───────────────────────────────────────────────── // ─── Group-Aware Batching ────────────────────────────────────────────────────
/** /**
* Build batches from explicit parallel_group values. * Build batches respecting both explicit parallel groups and intra-group
* Groups execute in ascending order; tasks within a group run concurrently. * dependencies. Since parallel group declarations imply no cross-group
* dependencies, all tasks whose dependencies are satisfied — across any
* group — can run concurrently in the same batch. This means groups
* "proceed independently" as the user specified: tasks from different
* groups can appear in the same batch when ready.
*
* Intra-group dependencies (e.g., "21 must be done before 22, 23, 24")
* are handled by Kahn's algorithm: if 21 has deps satisfied but 22 doesn't,
* only 21 appears in the current batch.
*/ */
function buildParallelGroupBatches( function buildGroupAwareBatches(
pendingTasks: Task[], _project: Project,
failedTaskIds: Set<string>, pendingTasks: Task[],
failedTaskIds: Set<string>,
): ExecutionBatch[] { ): ExecutionBatch[] {
const blocked = getBlockedTasks(pendingTasks, failedTaskIds); const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id)); const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
const groups = new Map<number, Task[]>(); // Standard Kahn's algorithm across ALL tasks — parallel groups are
// metadata for display, not scheduling constraints.
const pendingSet = new Set(pendingTasks.map((t) => t.id));
const done = new Set<string>();
const remaining = new Set(activeTasks.map((t) => t.id));
const batches: ExecutionBatch[] = [];
for (const task of activeTasks) { while (remaining.size > 0) {
const group = task.parallelGroup ?? 0; const ready: Task[] = [];
if (!groups.has(group)) groups.set(group, []); for (const task of activeTasks) {
groups.get(group)!.push(task); if (!remaining.has(task.id)) continue;
} const deps = task.dependencies || [];
const depsSatisfied = deps.every(
(dep) => done.has(dep) || !pendingSet.has(dep),
);
if (depsSatisfied) {
ready.push(task);
}
}
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]); if (ready.length === 0) {
throw new Error(
`Dependency cycle detected: ${Array.from(remaining).join(", ")}`,
);
}
return sortedGroups.map(([_groupNum, tasks], i) => ({ batches.push({ tasks: ready, batchIndex: batches.length });
tasks, for (const task of ready) {
batchIndex: i, done.add(task.id);
})); remaining.delete(task.id);
}
}
return batches;
}
// ─── Legacy Parallel Group Batching ─────────────────────────────────────────
/**
* Legacy: build batches from explicit parallel_group values only.
* Groups execute in ascending order; tasks within a group run concurrently.
* Does NOT respect intra-group dependencies.
*/
function buildParallelGroupBatchesLegacy(
pendingTasks: Task[],
failedTaskIds: Set<string>,
): ExecutionBatch[] {
const blocked = getBlockedTasks(pendingTasks, failedTaskIds);
const activeTasks = pendingTasks.filter((t) => !blocked.has(t.id));
const groups = new Map<number, Task[]>();
for (const task of activeTasks) {
const group = task.parallelGroup ?? 0;
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(task);
}
const sortedGroups = Array.from(groups.entries()).sort((a, b) => a[0] - b[0]);
return sortedGroups.map(([_groupNum, tasks], i) => ({
tasks,
batchIndex: i,
}));
} }
// ─── Cycle Detection ───────────────────────────────────────────────────────── // ─── Cycle Detection ─────────────────────────────────────────────────────────
@@ -174,51 +254,51 @@ function buildParallelGroupBatches(
* Detect cycles in the task dependency graph * Detect cycles in the task dependency graph
*/ */
export function detectCycles(project: Project): string[] { export function detectCycles(project: Project): string[] {
const adj = new Map<string, string[]>(); const adj = new Map<string, string[]>();
for (const task of project.tasks) { for (const task of project.tasks) {
adj.set(task.id, task.dependencies || []); adj.set(task.id, task.dependencies || []);
} }
const WHITE = 0; const WHITE = 0;
const GRAY = 1; const GRAY = 1;
const BLACK = 2; const BLACK = 2;
const color = new Map<string, number>(); const color = new Map<string, number>();
for (const task of project.tasks) { for (const task of project.tasks) {
color.set(task.id, WHITE); color.set(task.id, WHITE);
} }
const cycleNodes: string[] = []; const cycleNodes: string[] = [];
function dfs(node: string): boolean { function dfs(node: string): boolean {
color.set(node, GRAY); color.set(node, GRAY);
const deps = adj.get(node) || []; const deps = adj.get(node) || [];
for (const dep of deps) { for (const dep of deps) {
if (!adj.has(dep)) continue; if (!adj.has(dep)) continue;
const depColor = color.get(dep); const depColor = color.get(dep);
if (depColor === GRAY) { if (depColor === GRAY) {
cycleNodes.push(dep); cycleNodes.push(dep);
return true; return true;
} }
if (depColor === WHITE && dfs(dep)) { if (depColor === WHITE && dfs(dep)) {
cycleNodes.push(node); cycleNodes.push(node);
return true; return true;
} }
} }
color.set(node, BLACK); color.set(node, BLACK);
return false; return false;
} }
for (const task of project.tasks) { for (const task of project.tasks) {
if (color.get(task.id) === WHITE) { if (color.get(task.id) === WHITE) {
dfs(task.id); dfs(task.id);
} }
} }
return [...new Set(cycleNodes)]; return [...new Set(cycleNodes)];
} }
// ─── Ready Tasks ───────────────────────────────────────────────────────────── // ─── Ready Tasks ─────────────────────────────────────────────────────────────
@@ -227,14 +307,14 @@ export function detectCycles(project: Project): string[] {
* Get tasks that are ready to execute (all dependencies completed) * Get tasks that are ready to execute (all dependencies completed)
*/ */
export function getReadyTasks( export function getReadyTasks(
project: Project, project: Project,
completed: Set<string>, completed: Set<string>,
): Task[] { ): Task[] {
return project.tasks.filter((task) => { return project.tasks.filter((task) => {
if (completed.has(task.id)) return false; if (completed.has(task.id)) return false;
const deps = task.dependencies || []; const deps = task.dependencies || [];
return deps.every((dep) => completed.has(dep)); return deps.every((dep) => completed.has(dep));
}); });
} }
// ─── Critical Path ─────────────────────────────────────────────────────────── // ─── Critical Path ───────────────────────────────────────────────────────────
@@ -243,70 +323,70 @@ export function getReadyTasks(
* Calculate the critical path (longest path through the DAG) * Calculate the critical path (longest path through the DAG)
*/ */
export function getCriticalPath(project: Project): Task[] { export function getCriticalPath(project: Project): Task[] {
const taskMap = new Map(project.tasks.map((t) => [t.id, t])); const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
const dist = new Map<string, number>(); const dist = new Map<string, number>();
const prev = new Map<string, string | null>(); const prev = new Map<string, string | null>();
// Initialize // Initialize
for (const task of project.tasks) { for (const task of project.tasks) {
dist.set(task.id, 1); dist.set(task.id, 1);
prev.set(task.id, null); prev.set(task.id, null);
} }
// Topological sort // Topological sort
const sorted: Task[] = []; const sorted: Task[] = [];
const visited = new Set<string>(); const visited = new Set<string>();
function visit(id: string) { function visit(id: string) {
if (visited.has(id)) return; if (visited.has(id)) return;
visited.add(id); visited.add(id);
const task = taskMap.get(id); const task = taskMap.get(id);
if (!task) return; if (!task) return;
for (const dep of task.dependencies || []) { for (const dep of task.dependencies || []) {
visit(dep); visit(dep);
} }
sorted.push(task); sorted.push(task);
} }
for (const task of project.tasks) { for (const task of project.tasks) {
visit(task.id); visit(task.id);
} }
// Relax edges // Relax edges
for (const task of sorted) { for (const task of sorted) {
for (const dep of task.dependencies || []) { for (const dep of task.dependencies || []) {
const depDist = dist.get(dep); const depDist = dist.get(dep);
if (depDist === undefined) continue; if (depDist === undefined) continue;
const newDist = depDist + 1; const newDist = depDist + 1;
const currentDist = dist.get(task.id) ?? 0; const currentDist = dist.get(task.id) ?? 0;
if (newDist > currentDist) { if (newDist > currentDist) {
dist.set(task.id, newDist); dist.set(task.id, newDist);
prev.set(task.id, dep); prev.set(task.id, dep);
} }
} }
} }
// Trace back from the longest path end // Trace back from the longest path end
let maxTask = project.tasks[0]; let maxTask = project.tasks[0];
for (const task of project.tasks) { for (const task of project.tasks) {
const taskDist = dist.get(task.id) ?? 0; const taskDist = dist.get(task.id) ?? 0;
const maxDist = dist.get(maxTask.id) ?? 0; const maxDist = dist.get(maxTask.id) ?? 0;
if (taskDist > maxDist) { if (taskDist > maxDist) {
maxTask = task; maxTask = task;
} }
} }
const path: Task[] = []; const path: Task[] = [];
let current: string | null = maxTask.id; let current: string | null = maxTask.id;
while (current) { while (current) {
const task = taskMap.get(current); const task = taskMap.get(current);
if (task) path.unshift(task); if (task) path.unshift(task);
current = prev.get(current) || null; current = prev.get(current) || null;
} }
return path; return path;
} }
// ─── Format Dependency Chain ───────────────────────────────────────────────── // ─── Format Dependency Chain ─────────────────────────────────────────────────
@@ -316,86 +396,86 @@ export function getCriticalPath(project: Project): Task[] {
* Rooted at tasks with no dependencies, showing what depends on what. * Rooted at tasks with no dependencies, showing what depends on what.
*/ */
export function formatDependencyChain(project: Project): string { export function formatDependencyChain(project: Project): string {
const taskMap = new Map(project.tasks.map((t) => [t.id, t])); const taskMap = new Map(project.tasks.map((t) => [t.id, t]));
const lines: string[] = []; const lines: string[] = [];
lines.push("## Dependency Chain"); lines.push("## Dependency Chain");
lines.push(""); lines.push("");
if (project.tasks.length === 0) { if (project.tasks.length === 0) {
lines.push("(no tasks)"); lines.push("(no tasks)");
return lines.join("\n"); return lines.join("\n");
} }
// Build reverse dependency map: taskId → [dependent taskIds] // Build reverse dependency map: taskId → [dependent taskIds]
const dependents = new Map<string, string[]>(); const dependents = new Map<string, string[]>();
for (const task of project.tasks) { for (const task of project.tasks) {
dependents.set(task.id, []); dependents.set(task.id, []);
} }
for (const task of project.tasks) { for (const task of project.tasks) {
for (const dep of task.dependencies) { for (const dep of task.dependencies) {
if (dependents.has(dep)) { if (dependents.has(dep)) {
dependents.get(dep)!.push(task.id); dependents.get(dep)!.push(task.id);
} }
} }
} }
// Root tasks: those with no dependencies // Root tasks: those with no dependencies
const roots = project.tasks.filter((t) => t.dependencies.length === 0); const roots = project.tasks.filter((t) => t.dependencies.length === 0);
const rendered = new Set<string>(); const rendered = new Set<string>();
function renderNode(taskId: string, prefix: string, isLast: boolean): void { function renderNode(taskId: string, prefix: string, isLast: boolean): void {
const task = taskMap.get(taskId); const task = taskMap.get(taskId);
if (!task) return; if (!task) return;
const alreadyRendered = rendered.has(taskId); const alreadyRendered = rendered.has(taskId);
rendered.add(taskId); rendered.add(taskId);
const connector = prefix ? (isLast ? "└── " : "├── ") : ""; const connector = prefix ? (isLast ? "└── " : "├── ") : "";
if (alreadyRendered) { if (alreadyRendered) {
lines.push(`${prefix}${connector}${task.id} · ${task.title}`); lines.push(`${prefix}${connector}${task.id} · ${task.title}`);
return; return;
} }
const deps = const deps =
task.dependencies.length > 0 task.dependencies.length > 0
? ` ← needs ${task.dependencies.join(", ")}` ? ` ← needs ${task.dependencies.join(", ")}`
: " (root)"; : " (root)";
lines.push( lines.push(
`${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`, `${prefix}${connector}${task.id} · ${task.title}${prefix ? "" : deps}`,
); );
const children = (dependents.get(taskId) || []) const children = (dependents.get(taskId) || [])
.filter((c) => c !== taskId) .filter((c) => c !== taskId)
.sort(); .sort();
for (let i = 0; i < children.length; i++) { for (let i = 0; i < children.length; i++) {
const childPrefix = prefix + (isLast ? " " : "│ "); const childPrefix = prefix + (isLast ? " " : "│ ");
renderNode(children[i], childPrefix, i === children.length - 1); renderNode(children[i], childPrefix, i === children.length - 1);
} }
} }
for (let i = 0; i < roots.length; i++) { for (let i = 0; i < roots.length; i++) {
renderNode(roots[i].id, "", i === roots.length - 1); renderNode(roots[i].id, "", i === roots.length - 1);
} }
// Tasks not reached from any root (have deps but no root-traversable path) // Tasks not reached from any root (have deps but no root-traversable path)
const unreached = project.tasks.filter((t) => !rendered.has(t.id)); const unreached = project.tasks.filter((t) => !rendered.has(t.id));
if (unreached.length > 0) { if (unreached.length > 0) {
lines.push(""); lines.push("");
lines.push("Orphan tasks (dependencies not in task list):"); lines.push("Orphan tasks (dependencies not in task list):");
for (const t of unreached) { for (const t of unreached) {
const deps = const deps =
t.dependencies.length > 0 t.dependencies.length > 0
? ` ← needs ${t.dependencies.join(", ")}` ? ` ← needs ${t.dependencies.join(", ")}`
: ""; : "";
lines.push(` ${t.id} · ${t.title}${deps}`); lines.push(` ${t.id} · ${t.title}${deps}`);
} }
} }
return lines.join("\n"); return lines.join("\n");
} }
// ─── Format Execution Plan ─────────────────────────────────────────────────── // ─── Format Execution Plan ───────────────────────────────────────────────────
@@ -403,27 +483,52 @@ export function formatDependencyChain(project: Project): string {
/** /**
* Format the execution plan for display * Format the execution plan for display
*/ */
export function formatExecutionPlan(plan: ExecutionPlan): string { /**
const lines: string[] = []; * Format the execution plan for display, optionally with parallel group annotations
lines.push("## Execution Plan"); */
lines.push(""); export function formatExecutionPlan(
lines.push(`Total tasks: ${plan.totalTasks}`); plan: ExecutionPlan,
lines.push(`Batches: ${plan.batches.length}`); parallelGroups?: ParallelGroup[],
): string {
const lines: string[] = [];
lines.push("## Execution Plan");
lines.push("");
lines.push(`Total tasks: ${plan.totalTasks}`);
lines.push(`Batches: ${plan.batches.length}`);
if (plan.skippedTasks.length > 0) { // Build a lookup: taskId → group label
lines.push( const groupLabel = new Map<string, string>();
`Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`, if (parallelGroups) {
); for (const g of parallelGroups) {
} for (const id of g.taskIds) {
lines.push(""); if (g.label) {
groupLabel.set(id, g.label);
}
}
}
}
for (const batch of plan.batches) { if (plan.skippedTasks.length > 0) {
lines.push(`### Batch ${batch.batchIndex + 1}`); lines.push(
for (const task of batch.tasks) { `Already completed: ${plan.skippedTasks.map((t) => t.id).join(", ")}`,
lines.push(`- ${task.id}: ${task.title}`); );
} }
lines.push(""); lines.push("");
}
return lines.join("\n"); for (const batch of plan.batches) {
lines.push(`### Batch ${batch.batchIndex + 1}`);
for (const task of batch.tasks) {
const annotation = groupLabel.has(task.id)
? ` _(${groupLabel.get(task.id)})_`
: "";
const deps =
task.dependencies.length > 0
? ` ← needs ${task.dependencies.join(", ")}`
: "";
lines.push(`- ${task.id}: ${task.title}${annotation}${deps}`);
}
lines.push("");
}
return lines.join("\n");
} }

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import type { Task, Project } from "./types"; import type { Task, Project, ParallelGroup } from "./types";
// Lazy-loaded yaml package // Lazy-loaded yaml package
let YAML_module: typeof import("yaml") | undefined; let YAML_module: typeof import("yaml") | undefined;
@@ -56,6 +56,7 @@ function parseFioFormat(
const lines = content.split("\n"); const lines = content.split("\n");
const tasks: Task[] = []; const tasks: Task[] = [];
const dependencies: Record<string, string[]> = {}; const dependencies: Record<string, string[]> = {};
const parallelGroups: ParallelGroup[] = [];
let inTasks = false; let inTasks = false;
let inDeps = false; let inDeps = false;
@@ -151,8 +152,9 @@ function parseFioFormat(
// Format 1: Natural language "X depends on A, B, C" // Format 1: Natural language "X depends on A, B, C"
// Supports optional markdown list prefix: "- 13 depends on 17, 18, 19" // Supports optional markdown list prefix: "- 13 depends on 17, 18, 19"
// Also handles "also depends on": "- 08 also depends on 05, 06"
const dependsMatch = line.match( const dependsMatch = line.match(
/^(?:\s*[-*]\s+)?(\d+)\s+depends\s+on\s+([\d,\s]+)/i, /^(?:\s*[-*]\s+)?(\d+)\s+(?:also\s+)?depends\s+on\s+([\d,\s]+)/i,
); );
if (dependsMatch) { if (dependsMatch) {
const [, taskId, depsList] = dependsMatch; const [, taskId, depsList] = dependsMatch;
@@ -164,7 +166,11 @@ function parseFioFormat(
.map((t) => t.padStart(2, "0")); .map((t) => t.padStart(2, "0"));
if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = []; if (!dependencies[taskIdPadded]) dependencies[taskIdPadded] = [];
dependencies[taskIdPadded].push(...depIds); for (const depId of depIds) {
if (!dependencies[taskIdPadded].includes(depId)) {
dependencies[taskIdPadded].push(depId);
}
}
} }
// Parse meta blocks for task configuration (timeout, etc.) // Parse meta blocks for task configuration (timeout, etc.)
@@ -178,6 +184,91 @@ function parseFioFormat(
task.timeoutMs = parseTimeoutValue(Number(value), unit); task.timeoutMs = parseTimeoutValue(Number(value), unit);
} }
} }
// Format 2: "X, Y, Z can be done in parallel (label)"
// "- 01, 02, 03, 04 can be done in parallel (Play Store prep)"
const parallelMatch = line.match(
/^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+can\s+be\s+done\s+in\s+parallel(?:\s+\(([^)]+)\))?$/i,
);
if (parallelMatch) {
const [, idsStr, label] = parallelMatch;
const taskIds = idsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+$/.test(t))
.map((t) => t.padStart(2, "0"));
if (taskIds.length > 0) {
parallelGroups.push({
index: parallelGroups.length,
label: label ? label.trim() : undefined,
taskIds,
});
}
}
// Format 3: "A must be done before B, C" or "A, B must be done before C"
// "- 21 must be done before 22, 23, 24 (backend integration foundation)"
// "- 02, 03 must be done before 04"
const mustBeforeMatch = line.match(
/^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+must\s+be\s+done\s+before\s+((?:0?\d+\s*,\s*)*0?\d+)(?:\s+\(([^)]+)\))?$/i,
);
if (mustBeforeMatch) {
const [, fromIdsStr, toIdsStr] = mustBeforeMatch;
const fromIds = fromIdsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+$/.test(t))
.map((t) => t.padStart(2, "0"));
const toIds = toIdsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+$/.test(t))
.map((t) => t.padStart(2, "0"));
// Each "to" task depends on ALL "from" tasks
for (const toId of toIds) {
if (!dependencies[toId]) dependencies[toId] = [];
for (const fromId of fromIds) {
if (!dependencies[toId].includes(fromId)) {
dependencies[toId].push(fromId);
}
}
}
}
// Format 4: "X, Y, Z depend on A" or "X depends on A, B, C"
// "- 22, 23, 24 depend on 21"
// "- 05, 06 depend on 02, 03, 04"
// "- 08 also depends on 05, 06" ("also" is ignored)
// Strip optional "also" before matching
const cleanedLine = line.replace(/\balso\b/i, "");
const dependOnMatch = cleanedLine.match(
/^(?:\s*[-*]\s+)?((?:0?\d+\s*,\s*)*0?\d+)\s+depend(?:s)?\s+on\s+((?:0?\d+\s*,\s*)*0?\d+)(?:\s+\(([^)]+)\))?$/i,
);
if (dependOnMatch) {
const [, fromIdsStr, toIdsStr] = dependOnMatch;
const fromIds = fromIdsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+$/.test(t))
.map((t) => t.padStart(2, "0"));
const toIds = toIdsStr
.split(",")
.map((t) => t.trim())
.filter((t) => /^\d+$/.test(t))
.map((t) => t.padStart(2, "0"));
// Each "from" task depends on ALL "to" tasks
for (const fromId of fromIds) {
if (!dependencies[fromId]) dependencies[fromId] = [];
for (const toId of toIds) {
if (!dependencies[fromId].includes(toId)) {
dependencies[fromId].push(toId);
}
}
}
}
} }
} }
@@ -203,9 +294,20 @@ function parseFioFormat(
} }
} }
// Apply parallelGroup to tasks
for (const group of parallelGroups) {
for (const taskId of group.taskIds) {
const task = tasks.find((t) => t.id === taskId);
if (task) {
task.parallelGroup = group.index;
}
}
}
return { return {
tasks, tasks,
dependencies, dependencies,
parallelGroups: parallelGroups.length > 0 ? parallelGroups : undefined,
sourcePath, sourcePath,
sourceDir, sourceDir,
exitCriteria, exitCriteria,

View File

@@ -29,6 +29,15 @@ export interface Task {
index?: number; index?: number;
} }
export interface ParallelGroup {
/** Group index (0-based, determines execution order) */
index: number;
/** Human-readable label for the group (e.g. "Play Store prep") */
label?: string;
/** Task IDs in this group — all can run concurrently */
taskIds: string[];
}
export interface Project { export interface Project {
/** Project-level objective / goal */ /** Project-level objective / goal */
objective?: string; objective?: string;
@@ -36,6 +45,8 @@ export interface Project {
tasks: Task[]; tasks: Task[];
/** Explicit dependency map: taskId → [dependency taskIds] */ /** Explicit dependency map: taskId → [dependency taskIds] */
dependencies: Record<string, string[]>; dependencies: Record<string, string[]>;
/** Explicit parallel groups from "can be done in parallel" declarations */
parallelGroups?: ParallelGroup[];
/** Exit criteria (from README ## Exit Criteria section) */ /** Exit criteria (from README ## Exit Criteria section) */
exitCriteria?: string[]; exitCriteria?: string[];
/** Path to the source task file */ /** Path to the source task file */