1120 lines
34 KiB
TypeScript
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"]]);
|
|
});
|
|
});
|