Files
ralpi/tests/parser-dag.test.ts

1120 lines
34 KiB
TypeScript

/**
* Comprehensive tests for ralpi's parser (dependency formats) and DAG construction.
*
* Run: bun test tests/parser-dag.test.ts
*
* Covers all supported dependency declaration formats:
* - Arrow notation (->, →) — single, multi-target, multi-source, chained
* - Natural language "depends on" / "depend on" / "also depends on"
* - "must be done before" — single→multi, multi→single, multi→multi
* - "can be done in parallel" — with and without labels
* - Mixed formats in one file
* - DAG construction (Kahn's algorithm) — batching, cycle detection, critical path
* - buildCompletedSet integration
* - Blocked tasks (transitive)
* - Edge cases, negative tests
*/
import { describe, test, expect } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { parseTaskFile } from "../src/parser";
import {
buildExecutionPlan,
buildSequentialPlan,
detectCycles,
getBlockedTasks,
getCriticalPath,
getReadyTasks,
} from "../src/dag";
import type { Task, Project, ExecutionPlan } from "../src/types";
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Parse a markdown string as if it were a task file, returning the Project. */
function parseMD(content: string, name = "test-prd.md"): Project {
const dir = fs.mkdtempSync("/tmp/ralpi-test-");
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, content, "utf-8");
try {
return parseTaskFile(filePath);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
/** Build an execution plan from a Project, marking specified IDs as completed. */
function plan(
project: Project,
completedIds: string[] = [],
failedIds: string[] = [],
): ExecutionPlan {
return buildExecutionPlan(
project,
new Set(completedIds),
undefined,
new Set(failedIds),
);
}
/** Extract batch IDs for easy assertion: [[id1,id2], [id3], ...] */
function batchIds(plan: ExecutionPlan): string[][] {
return plan.batches.map((b) => b.tasks.map((t) => t.id).sort());
}
/** Find a task by ID in a Project. */
function findTask(project: Project, id: string): Task {
const t = project.tasks.find((t) => t.id === id);
if (!t) throw new Error(`Task ${id} not found`);
return t;
}
// ─────────────────────────────────────────────────────────────────────────────
// ARROW NOTATION
// ─────────────────────────────────────────────────────────────────────────────
describe("Arrow notation (->)", () => {
test("single arrow: 01 -> 02", () => {
const md = `# Test
## Tasks
- [ ] 01 — task-a
- [ ] 02 — task-b
## Dependencies
01 -> 02`;
const project = parseMD(md);
expect(findTask(project, "01").dependencies).toEqual([]);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
});
test("multi-target: 01 -> 02,03,06", () => {
const md = `# Test
## Tasks
- [ ] 01 — task-a
- [ ] 02 — task-b
- [ ] 03 — task-c
- [ ] 06 — task-f
## Dependencies
01 -> 02,03,06`;
const project = parseMD(md);
expect(findTask(project, "01").dependencies).toEqual([]);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
expect(findTask(project, "03").dependencies).toEqual(["01"]);
expect(findTask(project, "06").dependencies).toEqual(["01"]);
});
test("multi-source: 05,07,08 -> 13", () => {
const md = `# Test
## Tasks
- [ ] 05 — task-e
- [ ] 07 — task-g
- [ ] 08 — task-h
- [ ] 13 — task-m
## Dependencies
05, 07, 08 -> 13`;
const project = parseMD(md);
expect(findTask(project, "13").dependencies).toEqual(["05", "07", "08"]);
});
test("chained: 03 -> 04 -> 05", () => {
const md = `# Test
## Tasks
- [ ] 03 — task-c
- [ ] 04 — task-d
- [ ] 05 — task-e
## Dependencies
03 -> 04 -> 05`;
const project = parseMD(md);
expect(findTask(project, "04").dependencies).toEqual(["03"]);
expect(findTask(project, "05").dependencies).toEqual(["04"]);
});
test("with markdown list prefix: - 01 -> 02,03", () => {
const md = `# Test
## Tasks
- [ ] 01 — task-a
- [ ] 02 — task-b
- [ ] 03 — task-c
## Dependencies
- 01 -> 02,03`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
expect(findTask(project, "03").dependencies).toEqual(["01"]);
});
test("unicode arrow (→): 01 → 02", () => {
const md = `# Test
## Tasks
- [ ] 01 — task-a
- [ ] 02 — task-b
## Dependencies
01 → 02`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
});
test("chained with unicode arrows: A → B → C", () => {
const md = `# Test
## Tasks
- [ ] 01 — setup
- [ ] 02 — build
- [ ] 03 — deploy
## Dependencies
01 → 02 → 03`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
expect(findTask(project, "03").dependencies).toEqual(["02"]);
});
test("multi-source multi-target: 01,02 -> 03,04", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
- [ ] 04 — d
## Dependencies
01, 02 -> 03, 04`;
const project = parseMD(md);
expect(findTask(project, "03").dependencies).toEqual(["01", "02"]);
expect(findTask(project, "04").dependencies).toEqual(["01", "02"]);
});
test("unpadded task IDs still pad to 2 digits", () => {
const md = `# Test
## Tasks
- [ ] 1 — task-a
- [ ] 2 — task-b
- [ ] 3 — task-c
## Dependencies
1 -> 2, 3`;
const project = parseMD(md);
expect(findTask(project, "01").dependencies).toEqual([]);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
expect(findTask(project, "03").dependencies).toEqual(["01"]);
});
test("arrow with parenthetical comment is stripped", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
- 01 -> 02, 03 (core dependency)`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
expect(findTask(project, "03").dependencies).toEqual(["01"]);
});
test("non-numeric text after arrow is ignored", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
## Dependencies
01 -> some-text-here`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual([]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// NATURAL LANGUAGE "depends on"
// ─────────────────────────────────────────────────────────────────────────────
describe('Natural language "depends on"', () => {
test("single task depends on single", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
## Dependencies
02 depends on 01`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
});
test("task depends on multiple: 13 depends on 17, 18, 19, 20", () => {
const md = `# Test
## Tasks
- [ ] 13 — task-m
- [ ] 17 — task-q
- [ ] 18 — task-r
- [ ] 19 — task-s
- [ ] 20 — task-t
## Dependencies
13 depends on 17, 18, 19, 20`;
const project = parseMD(md);
expect(findTask(project, "13").dependencies).toEqual([
"17",
"18",
"19",
"20",
]);
});
test('multiple tasks depend on one: "depend on" (plural)', () => {
const md = `# Test
## Tasks
- [ ] 02 — b
- [ ] 03 — c
- [ ] 04 — d
- [ ] 05 — e
## Dependencies
04, 05 depend on 02, 03`;
const project = parseMD(md);
expect(findTask(project, "04").dependencies).toEqual(["02", "03"]);
expect(findTask(project, "05").dependencies).toEqual(["02", "03"]);
});
test("also depends on", () => {
const md = `# Test
## Tasks
- [ ] 05 — e
- [ ] 06 — f
- [ ] 08 — h
## Dependencies
08 also depends on 05, 06`;
const project = parseMD(md);
expect(findTask(project, "08").dependencies).toEqual(["05", "06"]);
});
test("with markdown list prefix", () => {
const md = `# Test
## Tasks
- [ ] 13 — m
- [ ] 17 — q
- [ ] 18 — r
- [ ] 19 — s
## Dependencies
- 13 depends on 17, 18, 19`;
const project = parseMD(md);
expect(findTask(project, "13").dependencies).toEqual(["17", "18", "19"]);
});
test("with parenthetical description", () => {
const md = `# Test
## Tasks
- [ ] 21 — setup-db
- [ ] 22 — write-queries
- [ ] 23 — build-api
## Dependencies
- 22 depends on 21 (database schema must exist)
- 23 depends on 22 (API builds on queries)`;
const project = parseMD(md);
expect(findTask(project, "22").dependencies).toEqual(["21"]);
expect(findTask(project, "23").dependencies).toEqual(["22"]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// "must be done before"
// ─────────────────────────────────────────────────────────────────────────────
describe('"must be done before"', () => {
test("single must be done before multiple", () => {
const md = `# Test
## Tasks
- [ ] 21 — backend-foundation
- [ ] 22 — api-endpoints
- [ ] 23 — database-migrations
- [ ] 24 — integration-tests
## Dependencies
21 must be done before 22, 23, 24`;
const project = parseMD(md);
expect(findTask(project, "21").dependencies).toEqual([]);
expect(findTask(project, "22").dependencies).toEqual(["21"]);
expect(findTask(project, "23").dependencies).toEqual(["21"]);
expect(findTask(project, "24").dependencies).toEqual(["21"]);
});
test("multiple must be done before single", () => {
const md = `# Test
## Tasks
- [ ] 02 — design
- [ ] 03 — review
- [ ] 04 — implement
## Dependencies
02, 03 must be done before 04`;
const project = parseMD(md);
expect(findTask(project, "04").dependencies).toEqual(["02", "03"]);
});
test("with markdown list prefix and parenthetical", () => {
const md = `# Test
## Tasks
- [ ] 21 — backend
- [ ] 22 — api
- [ ] 23 — db
- [ ] 24 — tests
## Dependencies
- 21 must be done before 22, 23, 24 (backend integration foundation)`;
const project = parseMD(md);
expect(findTask(project, "22").dependencies).toEqual(["21"]);
expect(findTask(project, "23").dependencies).toEqual(["21"]);
expect(findTask(project, "24").dependencies).toEqual(["21"]);
});
test("multi must be done before multi", () => {
const md = `# Test
## Tasks
- [ ] 01 — env
- [ ] 02 — config
- [ ] 03 — api-v1
- [ ] 04 — api-v2
## Dependencies
01, 02 must be done before 03, 04`;
const project = parseMD(md);
expect(findTask(project, "03").dependencies).toEqual(["01", "02"]);
expect(findTask(project, "04").dependencies).toEqual(["01", "02"]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// "can be done in parallel"
// ─────────────────────────────────────────────────────────────────────────────
describe('"can be done in parallel"', () => {
test("basic parallel group without label", () => {
const md = `# Test
## Tasks
- [ ] 02 — design
- [ ] 03 — auth
- [ ] 04 — storage
## Dependencies
02, 03, 04 can be done in parallel`;
const project = parseMD(md);
expect(project.parallelGroups).toBeDefined();
expect(project.parallelGroups!.length).toBe(1);
expect(project.parallelGroups![0].taskIds.sort()).toEqual([
"02",
"03",
"04",
]);
expect(project.parallelGroups![0].label).toBeUndefined();
});
test("parallel group with label", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
- [ ] 04 — d
## Dependencies
01, 02, 03, 04 can be done in parallel (Play Store prep)`;
const project = parseMD(md);
expect(project.parallelGroups![0].label).toBe("Play Store prep");
expect(project.parallelGroups![0].taskIds.sort()).toEqual([
"01",
"02",
"03",
"04",
]);
});
test("parallel group sets parallelGroup field on tasks", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01, 02, 03 can be done in parallel`;
const project = parseMD(md);
expect(findTask(project, "01").parallelGroup).toBe(0);
expect(findTask(project, "02").parallelGroup).toBe(0);
expect(findTask(project, "03").parallelGroup).toBe(0);
});
test("multiple parallel groups with labels", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
- [ ] 04 — d
- [ ] 05 — e
- [ ] 06 — f
## Dependencies
01, 02 can be done in parallel (frontend)
03, 04, 05 can be done in parallel (backend)
06 depends on 01, 03`;
const project = parseMD(md);
expect(project.parallelGroups!.length).toBe(2);
expect(project.parallelGroups![0].label).toBe("frontend");
expect(project.parallelGroups![1].label).toBe("backend");
});
});
// ─────────────────────────────────────────────────────────────────────────────
// MIXED FORMATS in one file
// ─────────────────────────────────────────────────────────────────────────────
describe("Mixed dependency formats", () => {
test("arrows + depends-on + must-before in one file", () => {
const md = `# iOS OAuth Sign-In
Objective: Add Google and Apple OAuth sign-in options
Status legend: [ ] todo, [~] in-progress, [x] done
## Tasks
- [~] 01 — oauth-flow-research
- [~] 02 — clerkapi-oauth-methods
- [ ] 03 — authservice-oauth-methods
- [ ] 04 — oauth-button-component
- [ ] 05 — update-signin-view
- [ ] 06 — update-signup-view
- [ ] 07 — session-handling
- [ ] 08 — integration-tests
## Dependencies
- 02 depends on 01
- 03 depends on 02
- 01 -> 04
- 05 must be done before 06
- 07 depends on 03
- 08 depends on 05, 06, 07`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
expect(findTask(project, "03").dependencies).toEqual(["02"]);
expect(findTask(project, "04").dependencies).toEqual(["01"]);
expect(findTask(project, "06").dependencies).toEqual(["05"]);
expect(findTask(project, "07").dependencies).toEqual(["03"]);
expect(findTask(project, "08").dependencies).toEqual(["05", "06", "07"]);
});
test("parallel groups mixed with dependencies", () => {
const md = `# Full Project
## Tasks
- [ ] 01 — env-setup
- [ ] 02 — db-schema
- [ ] 03 — api-core
- [ ] 04 — frontend-shell
- [ ] 05 — auth-module
- [ ] 06 — user-dashboard
- [ ] 07 — admin-panel
- [ ] 08 — integration-tests
- [ ] 09 — deploy
## Dependencies
01 -> 02, 03
02 -> 05
03 -> 05
04 -> 06, 07
05 -> 06, 07
06, 07 can be done in parallel (user-facing work)
08 must be done before 09
05 depends on 02, 03
06 depends on 04, 05`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
expect(findTask(project, "03").dependencies).toEqual(["01"]);
expect(findTask(project, "05").dependencies).toEqual(["02", "03"]);
expect(findTask(project, "06").dependencies).toEqual(["04", "05"]);
expect(findTask(project, "07").dependencies.sort()).toEqual(["04", "05"]);
expect(findTask(project, "09").dependencies).toEqual(["08"]);
expect(project.parallelGroups).toBeDefined();
expect(project.parallelGroups!.length).toBe(1);
expect(project.parallelGroups![0].taskIds.sort()).toEqual(["06", "07"]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// DAG CONSTRUCTION (Kahn's Algorithm)
// ─────────────────────────────────────────────────────────────────────────────
describe("DAG construction (Kahn's algorithm)", () => {
test("simple linear chain produces sequential batches", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02 -> 03`;
const p = plan(parseMD(md));
expect(batchIds(p)).toEqual([["01"], ["02"], ["03"]]);
});
test("diamond dependency produces 3 batches", () => {
const md = `# Test
## Tasks
- [ ] 01 — setup
- [ ] 02 — frontend
- [ ] 03 — backend
- [ ] 04 — integration
## Dependencies
01 -> 02, 03
02 -> 04
03 -> 04`;
const p = plan(parseMD(md));
// 01 first, then 02+03 in parallel, then 04
expect(batchIds(p)).toEqual([["01"], ["02", "03"], ["04"]]);
});
test("fan-out: one task gates many", () => {
const md = `# Test
## Tasks
- [ ] 01 — foundation
- [ ] 02 — feature-a
- [ ] 03 — feature-b
- [ ] 04 — feature-c
## Dependencies
01 -> 02, 03, 04`;
const p = plan(parseMD(md));
expect(batchIds(p)).toEqual([["01"], ["02", "03", "04"]]);
});
test("fan-in: many converge on one", () => {
const md = `# Test
## Tasks
- [ ] 01 — data
- [ ] 02 — ui
- [ ] 03 — api
- [ ] 04 — integration
## Dependencies
01, 02, 03 -> 04`;
const p = plan(parseMD(md));
expect(batchIds(p)).toEqual([["01", "02", "03"], ["04"]]);
});
test("complex DAG with multiple dependency chains", () => {
// 01
// / \
// 02 03
// | |
// 04 05
// \ /
// 06
// |
// 07
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
- [ ] 04 — d
- [ ] 05 — e
- [ ] 06 — f
- [ ] 07 — g
## Dependencies
01 -> 02, 03
02 -> 04
03 -> 05
04, 05 -> 06
06 -> 07`;
const p = plan(parseMD(md));
expect(batchIds(p)).toEqual([
["01"],
["02", "03"],
["04", "05"],
["06"],
["07"],
]);
});
test("no dependencies = all in one batch", () => {
// Content without ## Dependencies — uses simple checkbox parsing
// Simple checkbox assigns auto-incrementing IDs starting from "00"
const project = parseMD(`# Test\n- [ ] 01 — a\n- [ ] 02 — b\n- [ ] 03 — c`);
const p = plan(project);
// Simple checkbox: no dependencies, so all pending = all ready = one batch
expect(batchIds(p)).toEqual([["00", "01", "02"]]);
});
test("completed tasks are excluded from batches", () => {
const md = `# Test
## Tasks
- [x] 01 — setup (done)
- [ ] 02 — build
- [ ] 03 — test
## Dependencies
01 -> 02 -> 03`;
const project = parseMD(md);
// 01 is [x] in file, buildCompletedSet would include it
const p = buildExecutionPlan(
project,
new Set(["01"]), // completed
);
expect(batchIds(p)).toEqual([["02"], ["03"]]);
expect(p.totalTasks).toBe(2);
});
test("all completed = empty plan", () => {
const md = `# Test
## Tasks
- [x] 01 — a
- [x] 02 — b
## Dependencies
01 -> 02`;
const project = parseMD(md);
const p = buildExecutionPlan(project, new Set(["01", "02"]));
expect(batchIds(p)).toEqual([]);
expect(p.totalTasks).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SEQUENTIAL PLAN
// ─────────────────────────────────────────────────────────────────────────────
describe("Sequential plan", () => {
test("each batch has exactly one task", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02 -> 03`;
const project = parseMD(md);
const p = buildSequentialPlan(project, new Set());
expect(p.batches.length).toBe(3);
for (const b of p.batches) {
expect(b.tasks.length).toBe(1);
}
});
test("sequential plan respects dependency order", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02 -> 03`;
const project = parseMD(md);
const p = buildSequentialPlan(project, new Set());
expect(p.batches[0].tasks[0].id).toBe("01");
expect(p.batches[1].tasks[0].id).toBe("02");
expect(p.batches[2].tasks[0].id).toBe("03");
});
test("sequential excludes completed tasks", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02 -> 03`;
const project = parseMD(md);
const p = buildSequentialPlan(project, new Set(["01"]));
expect(p.batches.length).toBe(2);
expect(p.batches[0].tasks[0].id).toBe("02");
});
});
// ─────────────────────────────────────────────────────────────────────────────
// CYCLE DETECTION
// ─────────────────────────────────────────────────────────────────────────────
describe("Cycle detection", () => {
test("no cycle = empty array", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
## Dependencies
01 -> 02`;
const project = parseMD(md);
expect(detectCycles(project)).toEqual([]);
});
test("direct cycle: A -> B -> A", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
## Dependencies
01 -> 02
02 -> 01`;
const project = parseMD(md);
const cycles = detectCycles(project);
expect(cycles.length).toBeGreaterThan(0);
});
test("indirect cycle: A -> B -> C -> A", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02
02 -> 03
03 -> 01`;
const project = parseMD(md);
const cycles = detectCycles(project);
expect(cycles.length).toBeGreaterThan(0);
});
test("self-loop: A -> A", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
## Dependencies
01 -> 01`;
const project = parseMD(md);
const cycles = detectCycles(project);
expect(cycles.length).toBeGreaterThan(0);
});
test("buildExecutionPlan throws on cycle", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
## Dependencies
01 -> 02
02 -> 01`;
const project = parseMD(md);
expect(() => buildExecutionPlan(project, new Set())).toThrow(/cycle/i);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// BLOCKED TASKS
// ─────────────────────────────────────────────────────────────────────────────
describe("Blocked tasks", () => {
test("direct dependent blocked", () => {
const tasks: Task[] = [
{ id: "01", title: "a", status: "pending", dependencies: [] },
{ id: "02", title: "b", status: "pending", dependencies: ["01"] },
{ id: "03", title: "c", status: "pending", dependencies: ["02"] },
];
const blocked = getBlockedTasks(tasks, new Set(["01"]));
expect(blocked.has("02")).toBe(true);
});
test("transitive blocking", () => {
const tasks: Task[] = [
{ id: "01", title: "a", status: "pending", dependencies: [] },
{ id: "02", title: "b", status: "pending", dependencies: ["01"] },
{ id: "03", title: "c", status: "pending", dependencies: ["02"] },
{ id: "04", title: "d", status: "pending", dependencies: [] },
];
const blocked = getBlockedTasks(tasks, new Set(["01"]));
expect(blocked.has("02")).toBe(true);
expect(blocked.has("03")).toBe(true);
expect(blocked.has("04")).toBe(false); // independent
});
test("no failed = nothing blocked", () => {
const tasks: Task[] = [
{ id: "01", title: "a", status: "pending", dependencies: [] },
{ id: "02", title: "b", status: "pending", dependencies: ["01"] },
];
const blocked = getBlockedTasks(tasks, new Set());
expect(blocked.size).toBe(0);
});
test("buildExecutionPlan excludes blocked tasks from batches", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02 -> 03`;
const project = parseMD(md);
const p = buildExecutionPlan(
project,
new Set(),
undefined,
new Set(["01"]),
);
// 02 and 03 are blocked because 01 failed. 01 is excluded (failed).
// 02 and 03 remain pending but don't appear in batches.
expect(batchIds(p)).toEqual([]);
expect(p.totalTasks).toBe(2); // 02, 03 are pending (blocked)
});
test("blocked with diamond: failing root blocks everything downstream", () => {
const md = `# Test
## Tasks
- [ ] 01 — setup
- [ ] 02 — frontend
- [ ] 03 — backend
- [ ] 04 — integration
## Dependencies
01 -> 02, 03
02 -> 04
03 -> 04`;
const project = parseMD(md);
const p = buildExecutionPlan(
project,
new Set(),
undefined,
new Set(["01"]),
);
// All tasks are blocked since 01 failed
expect(batchIds(p)).toEqual([]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// CRITICAL PATH
// ─────────────────────────────────────────────────────────────────────────────
describe("Critical path", () => {
test("linear chain critical path is the whole chain", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02 -> 03`;
const project = parseMD(md);
const cp = getCriticalPath(project);
expect(cp.map((t) => t.id)).toEqual(["01", "02", "03"]);
});
test("diamond: critical path is the longer chain", () => {
const md = `# Test
## Tasks
- [ ] 01 — setup
- [ ] 02 — short
- [ ] 03 — long
- [ ] 04 — end
## Dependencies
01 -> 02 -> 04
01 -> 03 -> 04`;
// Both chains are length 3, so either is valid
const project = parseMD(md);
const cp = getCriticalPath(project);
expect(cp.length).toBe(3);
expect(cp[0].id).toBe("01");
});
test("fan-out: critical path covers the longest depth", () => {
const md = `# Test
## Tasks
- [ ] 01 — root
- [ ] 02 — a
- [ ] 03 — b
- [ ] 04 — c
- [ ] 05 — d
## Dependencies
01 -> 02, 03, 04
04 -> 05`;
const project = parseMD(md);
const cp = getCriticalPath(project);
// Critical path: 01 -> 04 -> 05
expect(cp.map((t) => t.id)).toEqual(["01", "04", "05"]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// GET READY TASKS
// ─────────────────────────────────────────────────────────────────────────────
describe("getReadyTasks", () => {
test("root tasks are ready when nothing completed", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02 -> 03`;
const project = parseMD(md);
const ready = getReadyTasks(project, new Set());
expect(ready.map((t) => t.id)).toEqual(["01"]);
});
test("task becomes ready when all deps completed", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01 -> 02 -> 03`;
const project = parseMD(md);
const ready = getReadyTasks(project, new Set(["01"]));
expect(ready.map((t) => t.id)).toEqual(["02"]);
});
test("all independent tasks are ready", () => {
const project = parseMD(`# Test\n- [ ] 01 — a\n- [ ] 02 — b\n- [ ] 03 — c`);
const ready = getReadyTasks(project, new Set());
expect(ready.length).toBe(3);
});
test("completed task is not ready", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
## Dependencies
01 -> 02`;
const project = parseMD(md);
const ready = getReadyTasks(project, new Set(["01"]));
expect(ready.map((t) => t.id)).toEqual(["02"]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// EDGE CASES & NEGATIVE TESTS
// ─────────────────────────────────────────────────────────────────────────────
describe("Edge cases", () => {
test("empty dependencies section produces no deps", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
## Dependencies`;
const project = parseMD(md);
expect(findTask(project, "01").dependencies).toEqual([]);
expect(findTask(project, "02").dependencies).toEqual([]);
});
test("no dependencies section triggers simple checkbox parser", () => {
const md = `# Simple List
- [ ] 01 — task-a
- [ ] 02 — task-b
- [ ] 03 — task-c`;
const project = parseMD(md);
expect(project.tasks.length).toBe(3);
expect(project.dependencies).toEqual({});
});
test("simple checkbox parser assigns sequential zero-padded IDs", () => {
const md = `- [ ] Do something
- [ ] Do something else`;
const project = parseMD(md);
expect(project.tasks[0].id).toBe("00");
expect(project.tasks[1].id).toBe("01");
});
test("line with non-dep content in ## Dependencies is ignored", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
## Dependencies
Some explanatory text that isn't a valid dependency format.
- 01 -> 02`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
});
test("comment after 'can be done in parallel' doesn't break parsing", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
- [ ] 02 — b
- [ ] 03 — c
## Dependencies
01, 02, 03 can be done in parallel
01 -> 02
# some comment`;
const project = parseMD(md);
expect(project.parallelGroups).toBeDefined();
expect(findTask(project, "02").dependencies).toEqual(["01"]);
});
test("exit criteria are extracted", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
## Dependencies
## Exit Criteria
- All tests pass
- Code reviewed
- Deployed to staging`;
const project = parseMD(md);
expect(project.exitCriteria).toEqual([
"All tests pass",
"Code reviewed",
"Deployed to staging",
]);
});
test("objective extracted from top heading", () => {
const md = `# iOS OAuth Sign-In
Objective: Add Google and Apple OAuth sign-in options
## Tasks
- [ ] 01 — research
## Dependencies`;
const project = parseMD(md);
expect(project.objective).toBe("iOS OAuth Sign-In");
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SECTIONS SEPARATION — content between ## sections is ignored
// ─────────────────────────────────────────────────────────────────────────────
describe("Section separation", () => {
test("content between ## sections doesn't leak into tasks/deps", () => {
const md = `# Test
## Tasks
- [ ] 01 — a
Some stray text here that isn't a task
- [ ] 02 — b
## Dependencies
01 -> 02
Extra text in deps should be ignored`;
const project = parseMD(md);
expect(project.tasks.length).toBe(2);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// REAL-WORLD SCENARIO: Full PRD with complex deps
// ─────────────────────────────────────────────────────────────────────────────
describe("Real-world PRD scenario", () => {
test("design token integration with must-before + depends-on + arrows", () => {
const md = `# iOS Design Token Integration
Objective: Replace hardcoded color/spacing values with centralized design tokens
## Tasks
- [x] 01 — migrate-domain-colors
- [x] 02 — replace-corner-radius
- [x] 03 — replace-system-color-leakage
- [~] 04 — replace-raw-spacing-values
- [ ] 05 — replace-border-token-props
- [ ] 06 — replace-font-token-props
- [ ] 07 — fix-component-color-variants
- [ ] 08 — fix-dark-mode-utility-class
## Dependencies
01 -> 02, 03
02 -> 04
03 -> 05
04, 05 -> 06, 07, 08
06 depends on 07`;
const project = parseMD(md);
expect(findTask(project, "02").dependencies).toEqual(["01"]);
expect(findTask(project, "03").dependencies).toEqual(["01"]);
expect(findTask(project, "04").dependencies).toEqual(["02"]);
expect(findTask(project, "05").dependencies).toEqual(["03"]);
expect(findTask(project, "06").dependencies).toEqual(["04", "05", "07"]);
expect(findTask(project, "07").dependencies).toEqual(["04", "05"]);
expect(findTask(project, "08").dependencies).toEqual(["04", "05"]);
// Plan with 01,02,03 completed
const p = buildExecutionPlan(project, new Set(["01", "02", "03"]));
// Batch 1: 04, 05 (both deps satisfied)
// Batch 2: 07 (depends on 04,05)
// Batch 3: 06 (depends on 04,05,07), 08 (depends on 04,05)
expect(batchIds(p)).toEqual([["04", "05"], ["07", "08"], ["06"]]);
});
});