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

1322 lines
35 KiB
TypeScript

/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test";
import { parseTaskFile } from "../src/parser";
import { tempDir, writeTaskFile } from "./helpers";
// ─── Helper ──────────────────────────────────────────────────────────────────
/** Parse a task file from an inline template literal. */
function parse(content: string) {
const { dir, cleanup } = tempDir();
try {
const filePath = writeTaskFile(dir, "README.md", content);
return { project: parseTaskFile(filePath), cleanup };
} catch (e) {
cleanup();
throw e;
}
}
/** Assert that task with `id` has the exact set of dependency IDs. */
function expectDeps(content: string, id: string, expectedDeps: string[]) {
const { project, cleanup } = parse(content);
try {
const task = project.tasks.find((t) => t.id === id);
if (!task) throw new Error(`Task ${id} not found`);
expect(task.dependencies.sort()).toEqual([...expectedDeps].sort());
} finally {
cleanup();
}
}
// ─── Helpers for constructing header + task table ────────────────────────────
const FIO_HEADER = `# Test Project
## Tasks`;
const FIO_FOOTER = `## Dependencies`;
// ─── Arrow Notation Tests ────────────────────────────────────────────────────
describe("Arrow notation (`->`)", () => {
it("parses basic single dependency", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Task one
- [ ] 02 — Task two
${FIO_FOOTER}
- 01 -> 02
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(2);
const t1 = project.tasks.find((t) => t.id === "01")!;
const t2 = project.tasks.find((t) => t.id === "02")!;
expect(t1.dependencies).toEqual([]);
expect(t2.dependencies).toEqual(["01"]);
} finally {
cleanup();
}
});
it("parses multi-target arrows (one source, many targets)", () => {
expectDeps(
`${FIO_HEADER}
- [ ] 01 — Task one
- [ ] 02 — Task two
- [ ] 03 — Task three
${FIO_FOOTER}
- 01 -> 02, 03
`,
"02",
["01"],
);
});
it("parses chained arrows (A -> B -> C)", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Task one
- [ ] 02 — Task two
- [ ] 03 — Task three
${FIO_FOOTER}
- 01 -> 02 -> 03
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"02",
]);
} finally {
cleanup();
}
});
it("parses chained arrows with multi-target forks", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Task one
- [ ] 02 — Task two
- [ ] 03 — Task three
- [ ] 04 — Task four
${FIO_FOOTER}
- 01 -> 02, 03 -> 04
`;
const { project, cleanup } = parse(content);
try {
// 01 -> 02,03: 02 and 03 depend on 01
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"01",
]);
// 02, 03 -> 04: 04 depends on BOTH 02 and 03 (chained multi-target fork)
expect(
project.tasks.find((t) => t.id === "04")!.dependencies.sort(),
).toEqual(["02", "03"]);
} finally {
cleanup();
}
});
it("parses unicode arrow (→)", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Task one
- [ ] 02 — Task two
${FIO_FOOTER}
- 01 → 02
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
} finally {
cleanup();
}
});
it("parses arrows with parenthetical descriptions", () => {
expectDeps(
`${FIO_HEADER}
- [ ] 01 — Domain colors
- [ ] 02 — Corner radius
- [ ] 03 — Color leakage
- [ ] 04 — Raw spacing
${FIO_FOOTER}
- 01 -> 02 (SemanticColors tokens must exist before views consume them)
- 01 -> 03 (Color tokens needed for system color replacement)
- 02 -> 04 (independent — sequential for clean git history)
- 03 -> 04 (independent — sequential for clean git history)
`,
"02",
["01"],
);
});
it("parses multi-source, multi-target arrows", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Research
- [ ] 02 — API design
- [ ] 03 — Implementation
- [ ] 04 — Review
- [ ] 05 — Merge
${FIO_FOOTER}
- 01, 02, 03 -> 04 -> 05
`;
const { project, cleanup } = parse(content);
try {
// 01,02,03 -> 04: 04 depends on 01,02,03
expect(
project.tasks.find((t) => t.id === "04")!.dependencies.sort(),
).toEqual(["01", "02", "03"]);
// 04 -> 05: 05 depends on 04
expect(project.tasks.find((t) => t.id === "05")!.dependencies).toEqual([
"04",
]);
} finally {
cleanup();
}
});
});
// ─── "depends on" Format Tests ───────────────────────────────────────────────
describe('"depends on" format', () => {
it("parses basic 'X depends on Y'", () => {
expectDeps(
`${FIO_HEADER}
- [ ] 01 — OAuth research
- [ ] 02 — Clerk API
${FIO_FOOTER}
- 02 depends on 01
`,
"02",
["01"],
);
});
it("parses 'X depends on Y, Z' (multi-dependency)", () => {
expectDeps(
`${FIO_HEADER}
- [ ] 01 — Sign-in design
- [ ] 02 — Sign-up design
- [ ] 03 — OAuth buttons
- [ ] 04 — Reuse buttons
${FIO_FOOTER}
- 04 depends on 01, 02, 03
`,
"04",
["01", "02", "03"],
);
});
it("parses 'X also depends on Y'", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Foundation
- [ ] 02 — Feature A
- [ ] 03 — Feature B
- [ ] 04 — Integration
${FIO_FOOTER}
- 04 depends on 02, 03
- 04 also depends on 01
`;
const { project, cleanup } = parse(content);
try {
expect(
project.tasks.find((t) => t.id === "04")!.dependencies.sort(),
).toEqual(["01", "02", "03"]);
} finally {
cleanup();
}
});
it("parses many depends-on lines forming a full DAG", () => {
const content = `${FIO_HEADER}
- [ ] 01 — OAuth research
- [ ] 02 — Clerk API methods
- [ ] 03 — AuthService methods
- [ ] 04 — OAuth button
- [ ] 05 — Update sign-in
- [ ] 06 — Update sign-up
- [ ] 07 — Callback handler
- [ ] 08 — Integration tests
${FIO_FOOTER}
- 02 depends on 01
- 03 depends on 02
- 04 depends on 01
- 05 depends on 03, 04
- 06 depends on 03, 04
- 07 depends on 03
- 08 depends on 05, 06, 07
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "01")!.dependencies).toEqual(
[],
);
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"02",
]);
expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([
"01",
]);
expect(
project.tasks.find((t) => t.id === "05")!.dependencies.sort(),
).toEqual(["03", "04"]);
expect(
project.tasks.find((t) => t.id === "06")!.dependencies.sort(),
).toEqual(["03", "04"]);
expect(project.tasks.find((t) => t.id === "07")!.dependencies).toEqual([
"03",
]);
expect(
project.tasks.find((t) => t.id === "08")!.dependencies.sort(),
).toEqual(["05", "06", "07"]);
} finally {
cleanup();
}
});
});
// ─── "depend on" (Plural) Format Tests ───────────────────────────────────────
describe('"depend on" (plural) format', () => {
it("parses 'X, Y depend on Z'", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Foundation
- [ ] 02 — Feature A
- [ ] 03 — Feature B
${FIO_FOOTER}
- 02, 03 depend on 01
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"01",
]);
} finally {
cleanup();
}
});
it("parses 'X, Y depend on Z, W' (multi-source, multi-dependency)", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Foundation
- [ ] 02 — API
- [ ] 03 — Feature A
- [ ] 04 — Feature B
- [ ] 05 — Integration
${FIO_FOOTER}
- 03, 04, 05 depend on 01, 02
`;
const { project, cleanup } = parse(content);
try {
expect(
project.tasks.find((t) => t.id === "03")!.dependencies.sort(),
).toEqual(["01", "02"]);
expect(
project.tasks.find((t) => t.id === "04")!.dependencies.sort(),
).toEqual(["01", "02"]);
expect(
project.tasks.find((t) => t.id === "05")!.dependencies.sort(),
).toEqual(["01", "02"]);
} finally {
cleanup();
}
});
it("handles mixed singular/plural across different lines", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Research
- [ ] 02 — API design
- [ ] 03 — Implementation
- [ ] 04 — Tests
${FIO_FOOTER}
- 02 depends on 01
- 03, 04 depend on 02
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"02",
]);
expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([
"02",
]);
} finally {
cleanup();
}
});
});
// ─── "must be done before" Format Tests ─────────────────────────────────────
describe('"must be done before" format', () => {
it("parses 'X must be done before Y'", () => {
expectDeps(
`${FIO_HEADER}
- [ ] 01 — Setup
- [ ] 02 — Build
${FIO_FOOTER}
- 01 must be done before 02
`,
"02",
["01"],
);
});
it("parses 'X must be done before Y, Z' (multi-target)", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Foundation
- [ ] 02 — Feature A
- [ ] 03 — Feature B
${FIO_FOOTER}
- 01 must be done before 02, 03
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"01",
]);
} finally {
cleanup();
}
});
it("parses 'X, Y must be done before Z' (multi-source)", () => {
expectDeps(
`${FIO_HEADER}
- [ ] 01 — Auth
- [ ] 02 — Billing
- [ ] 03 — Dashboard
${FIO_FOOTER}
- 01, 02 must be done before 03
`,
"03",
["01", "02"],
);
});
it("parses 'must be done before' with parenthetical labels", () => {
expectDeps(
`${FIO_HEADER}
- [ ] 21 — Backend integration
- [ ] 22 — API routes
- [ ] 23 — Database schema
- [ ] 24 — Frontend components
${FIO_FOOTER}
- 21 must be done before 22, 23, 24 (backend integration foundation)
`,
"22",
["21"],
);
});
});
// ─── Parallel Groups Format Tests ────────────────────────────────────────────
describe("Parallel groups format", () => {
it("parses 'X, Y can be done in parallel'", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Research
- [ ] 02 — API
- [ ] 03 — UI
- [ ] 04 — Tests
${FIO_FOOTER}
- 01, 02, 03, 04 can be done in parallel
`;
const { project, cleanup } = parse(content);
try {
expect(project.parallelGroups).toBeDefined();
expect(project.parallelGroups!).toHaveLength(1);
expect(project.parallelGroups![0].taskIds.sort()).toEqual([
"01",
"02",
"03",
"04",
]);
} finally {
cleanup();
}
});
it("parses parallel groups with labels", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Play Store listing
- [ ] 02 — Screenshots
- [ ] 03 — Privacy policy
- [ ] 04 — Rating prompts
${FIO_FOOTER}
- 01, 02, 03, 04 can be done in parallel (Play Store prep)
`;
const { project, cleanup } = parse(content);
try {
expect(project.parallelGroups).toBeDefined();
expect(project.parallelGroups![0].label).toBe("Play Store prep");
} finally {
cleanup();
}
});
it("assigns parallelGroup index to tasks", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Research
- [ ] 02 — API
- [ ] 03 — UI
${FIO_FOOTER}
- 01, 02, 03 can be done in parallel
`;
const { project, cleanup } = parse(content);
try {
for (const t of project.tasks) {
expect(t.parallelGroup).toBe(0);
}
} finally {
cleanup();
}
});
});
// ─── YAML Format Tests ───────────────────────────────────────────────────────
describe("YAML task file format", () => {
function parseYaml(content: string) {
const { dir, cleanup } = tempDir();
const filePath = writeTaskFile(dir, "tasks.yaml", content);
return { project: parseTaskFile(filePath), cleanup };
}
it("parses basic YAML tasks", () => {
const content = `tasks:
- id: "01"
title: Research OAuth flows
status: pending
- id: "02"
title: Implement Clerk API methods
status: pending
depends_on: ["01"]
`;
const { project, cleanup } = parseYaml(content);
try {
expect(project.tasks).toHaveLength(2);
const t2 = project.tasks.find((t) => t.id === "02")!;
expect(t2.dependencies).toEqual(["01"]);
} finally {
cleanup();
}
});
it("parses YAML with dependencies (dependencies key)", () => {
const content = `tasks:
- id: "01"
title: Foundation
- id: "02"
title: Feature A
dependencies: ["01"]
- id: "03"
title: Feature B
dependencies: ["01"]
- id: "04"
title: Integration
dependencies: ["02", "03"]
`;
const { project, cleanup } = parseYaml(content);
try {
expect(
project.tasks.find((t) => t.id === "04")!.dependencies.sort(),
).toEqual(["02", "03"]);
} finally {
cleanup();
}
});
it("parses YAML with exit criteria and objective", () => {
const content = `objective: Complete OAuth integration
exit_criteria:
- Users can sign in with Google
- Users can sign in with Apple
tasks:
- id: "01"
title: Research
`;
const { project, cleanup } = parseYaml(content);
try {
expect(project.objective).toBe("Complete OAuth integration");
expect(project.exitCriteria).toEqual([
"Users can sign in with Google",
"Users can sign in with Apple",
]);
} finally {
cleanup();
}
});
});
// ─── Mixed Format Tests ──────────────────────────────────────────────────────
describe("Mixed format files", () => {
it("handles arrow + depends-on arrows mixed in same file", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Research
- [ ] 02 — Design
- [ ] 03 — Implement
- [ ] 04 — Test
- [ ] 05 — Deploy
${FIO_FOOTER}
- 02 depends on 01
- 03 -> 04
- 04 -> 05
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual(
[],
);
expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([
"03",
]);
expect(project.tasks.find((t) => t.id === "05")!.dependencies).toEqual([
"04",
]);
} finally {
cleanup();
}
});
it("handles must-be-done-before + depends-on mixed", () => {
const content = `${FIO_HEADER}
- [ ] 10 — Scaffold
- [ ] 11 — Backend
- [ ] 12 — Frontend
- [ ] 13 — Auth
- [ ] 14 — Deploy
${FIO_FOOTER}
- 10 must be done before 11, 12
- 13 depends on 11, 12
- 14 depends on 13
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "11")!.dependencies).toEqual([
"10",
]);
expect(project.tasks.find((t) => t.id === "12")!.dependencies).toEqual([
"10",
]);
expect(
project.tasks.find((t) => t.id === "13")!.dependencies.sort(),
).toEqual(["11", "12"]);
expect(project.tasks.find((t) => t.id === "14")!.dependencies).toEqual([
"13",
]);
} finally {
cleanup();
}
});
});
// ─── Simple Checkbox Format (Fallback) Tests ─────────────────────────────────
describe("Simple checkbox format (fallback)", () => {
it("parses simple checkboxes when no ## Dependencies section", () => {
const content = `# Todo
- [ ] Buy groceries
- [x] Walk the dog
- [~] Do laundry
- [!] Fix bug
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(4);
expect(project.tasks[0].status).toBe("pending");
expect(project.tasks[1].status).toBe("completed");
expect(project.tasks[2].status).toBe("in_progress");
expect(project.tasks[3].status).toBe("failed");
expect(project.dependencies).toEqual({});
} finally {
cleanup();
}
});
});
// ─── Edge Cases ──────────────────────────────────────────────────────────────
describe("Edge cases", () => {
it("parses a file with no dependencies section", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Solo task
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(1);
expect(project.tasks[0].dependencies).toEqual([]);
} finally {
cleanup();
}
});
it("parses a file with mixed task status characters", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Pending
- [~] 02 — In progress
- [x] 03 — Completed
- [!] 04 — Failed
- [-] 05 — Skipped
${FIO_FOOTER}
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "01")!.status).toBe("pending");
expect(project.tasks.find((t) => t.id === "02")!.status).toBe(
"in_progress",
);
expect(project.tasks.find((t) => t.id === "03")!.status).toBe(
"completed",
);
expect(project.tasks.find((t) => t.id === "04")!.status).toBe("failed");
expect(project.tasks.find((t) => t.id === "05")!.status).toBe("skipped");
} finally {
cleanup();
}
});
it("preserves exit criteria content", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Task
${FIO_FOOTER}
## Exit Criteria
- Users can sign in with Google
- All tests pass
- No regressions
`;
const { project, cleanup } = parse(content);
try {
expect(project.exitCriteria).toBeDefined();
expect(project.exitCriteria).toHaveLength(3);
expect(project.exitCriteria![0]).toBe("Users can sign in with Google");
} finally {
cleanup();
}
});
it("extracts the objective from the H1 heading", () => {
const content = `# iOS OAuth Sign-In
Objective: Add Google and Apple OAuth
## Tasks
- [ ] 01 — Research
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
expect(project.objective).toBe("iOS OAuth Sign-In");
} finally {
cleanup();
}
});
it("does not confuse 'depends on' inside a parenthetical comment", () => {
expectDeps(
`${FIO_HEADER}
- [ ] 01 — Setup
- [ ] 02 — Feature
${FIO_FOOTER}
- 01 -> 02 (this depends on the setup being complete)
`,
"02",
["01"],
);
});
});
// ─── Complex / Large DAG Tests ───────────────────────────────────────────────
describe("Complex dependency scenarios", () => {
it("parses a 20-task diamond with multiple layers", () => {
// Diamond: 01 feeds two middle layers which converge
const lines: string[] = [`${FIO_HEADER}`];
for (let i = 1; i <= 20; i++) {
lines.push(`- [ ] ${String(i).padStart(2, "0")} — Task ${i}`);
}
lines.push("", `${FIO_FOOTER}`);
// 01 -> 02..10 (left chain) and 01 -> 11..19 (right chain)
// 10 -> 20, 19 -> 20
const leftIds = Array.from({ length: 9 }, (_, i) =>
String(i + 2).padStart(2, "0"),
); // 02-10
const rightIds = Array.from({ length: 9 }, (_, i) =>
String(i + 11).padStart(2, "0"),
); // 11-19
lines.push(`- 01 -> ${leftIds.join(", ")}`);
lines.push(`- 01 -> ${rightIds.join(", ")}`);
lines.push(`- 10, 19 -> 20`);
const content = lines.join("\n");
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(20);
// All left-branch tasks depend on 01
for (const id of leftIds) {
expect(project.tasks.find((t) => t.id === id)!.dependencies).toEqual([
"01",
]);
}
// All right-branch tasks depend on 01
for (const id of rightIds) {
expect(project.tasks.find((t) => t.id === id)!.dependencies).toEqual([
"01",
]);
}
// Task 20 depends on 10 and 19
expect(
project.tasks.find((t) => t.id === "20")!.dependencies.sort(),
).toEqual(["10", "19"]);
} finally {
cleanup();
}
});
it("parses a multi-level fan-out/fan-in DAG", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Foundation
- [ ] 02 — Module A
- [ ] 03 — Module B
- [ ] 04 — Module C
- [ ] 05 — Component A1
- [ ] 06 — Component A2
- [ ] 07 — Component B1
- [ ] 08 — Component B2
- [ ] 09 — Component C1
- [ ] 10 — Integration A
- [ ] 11 — Integration B
- [ ] 12 — Integration C
- [ ] 13 — System test
- [ ] 14 — Deploy
${FIO_FOOTER}
- 01 -> 02, 03, 04
- 02 -> 05, 06
- 03 -> 07, 08
- 04 -> 09
- 05, 06 -> 10
- 07, 08 -> 11
- 09 -> 12
- 10, 11, 12 -> 13
- 13 -> 14
`;
const { project, cleanup } = parse(content);
try {
expect(
project.tasks.find((t) => t.id === "10")!.dependencies.sort(),
).toEqual(["05", "06"]);
expect(
project.tasks.find((t) => t.id === "11")!.dependencies.sort(),
).toEqual(["07", "08"]);
expect(project.tasks.find((t) => t.id === "12")!.dependencies).toEqual([
"09",
]);
expect(
project.tasks.find((t) => t.id === "13")!.dependencies.sort(),
).toEqual(["10", "11", "12"]);
expect(project.tasks.find((t) => t.id === "14")!.dependencies).toEqual([
"13",
]);
} finally {
cleanup();
}
});
it("parses all formats mixed into one complex file", () => {
const content = `${FIO_HEADER}
- [ ] 01 — Config setup
- [ ] 02 — Database schema
- [ ] 03 — API routes
- [ ] 04 — Auth middleware
- [ ] 05 — Frontend shell
- [ ] 06 — User model
- [ ] 07 — Login page
- [ ] 08 — Dashboard
- [ ] 09 — Tests
- [ ] 10 — Deploy
${FIO_FOOTER}
- 01 -> 02, 03, 04 (foundational layers)
- 05, 06 depend on 02, 03
- 07 depends on 04, 06
- 08 must be done before 09
- 06, 07, 08 can be done in parallel (UI sprint)
- 09 -> 10 (quality gate before deploy)
`;
const { project, cleanup } = parse(content);
try {
// Arrow
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([
"01",
]);
// depends on (plural)
expect(
project.tasks.find((t) => t.id === "05")!.dependencies.sort(),
).toEqual(["02", "03"]);
expect(
project.tasks.find((t) => t.id === "06")!.dependencies.sort(),
).toEqual(["02", "03"]);
// depends on (singular)
expect(
project.tasks.find((t) => t.id === "07")!.dependencies.sort(),
).toEqual(["04", "06"]);
// must be done before
expect(project.tasks.find((t) => t.id === "09")!.dependencies).toEqual([
"08",
]);
// arrow again
expect(project.tasks.find((t) => t.id === "10")!.dependencies).toEqual([
"09",
]);
// parallel groups
expect(project.parallelGroups).toBeDefined();
const uiSprint = project.parallelGroups!.find(
(g) => g.label === "UI sprint",
);
expect(uiSprint).toBeDefined();
expect(uiSprint!.taskIds.sort()).toEqual(["06", "07", "08"]);
} finally {
cleanup();
}
});
});
// ─── Plain Section Headings (without ##) ──────────────────────────────────
describe("Plain section headings (no ##)", () => {
it("parses plain 'Tasks' and 'Dependencies' headings (the OAuth PRD format)", () => {
const content = `# iOS OAuth Sign-In\n\nObjective: Add Google and Apple OAuth sign-in options\n\nStatus legend: [ ] todo, [~] in-progress, [x] done\n\nTasks\n- [~] 01 — oauth-flow-research\n- [~] 02 — clerkapi-oauth-methods\n- [ ] 03 — authservice-oauth-methods\n- [ ] 04 — oauth-button-component\n- [ ] 05 — update-signin-view\n- [ ] 06 — update-signup-view\n- [ ] 07 — oauth-callback-handler\n- [ ] 08 — oauth-integration-tests\n\nDependencies\n- 02 depends on 01\n- 03 depends on 02\n- 04 depends on 01\n- 05 depends on 03, 04\n- 06 depends on 03, 04\n- 07 depends on 03\n- 08 depends on 05, 06, 07\n\nExit criteria\n- Users can sign in with Google account\n- Users can sign in with Apple account\n`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(8);
expect(project.tasks.find((t) => t.id === "01")!.dependencies).toEqual(
[],
);
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"02",
]);
expect(project.tasks.find((t) => t.id === "04")!.dependencies).toEqual([
"01",
]);
expect(
project.tasks.find((t) => t.id === "05")!.dependencies.sort(),
).toEqual(["03", "04"]);
expect(
project.tasks.find((t) => t.id === "06")!.dependencies.sort(),
).toEqual(["03", "04"]);
expect(project.tasks.find((t) => t.id === "07")!.dependencies).toEqual([
"03",
]);
expect(
project.tasks.find((t) => t.id === "08")!.dependencies.sort(),
).toEqual(["05", "06", "07"]);
expect(project.exitCriteria).toBeDefined();
expect(project.exitCriteria).toHaveLength(2);
expect(project.objective).toBe("iOS OAuth Sign-In");
} finally {
cleanup();
}
});
it("parses plain headings with arrow notation deps", () => {
const content = `# Design Token Integration\n\nTasks\n- [x] 01 — Migrate domain colors\n- [x] 02 — Replace corner radius\n- [x] 03 — Replace color leakage\n- [~] 04 — Replace raw spacing\n- [ ] 05 — Increase component adoption\n\nDependencies\n- 01 -> 02 (SemanticColors tokens must exist before views consume them)\n- 01 -> 03 (SemanticColors tokens must exist before views consume them)\n- 02 -> 04 (independent for clean git history)\n- 03 -> 04 (independent for clean git history)\n- 04 -> 05 (spacing consistency before component adoption)\n- 01 -> 05 (SemanticColors tokens before component-level adoption)\n\nExit criteria\n- Zero Color.systemGroupedBackground remain\n`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(5);
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"01",
]);
expect(
project.tasks.find((t) => t.id === "04")!.dependencies.sort(),
).toEqual(["02", "03"]);
expect(
project.tasks.find((t) => t.id === "05")!.dependencies.sort(),
).toEqual(["01", "04"]);
} finally {
cleanup();
}
});
it("ignores 'Status legend' line (has colon — not a section break)", () => {
const content = `# Test\n\nStatus legend: [ ] todo, [~] in-progress, [x] done\n\nTasks\n- [ ] 01 — First\n- [~] 02 — Second\n\nDependencies\n- 02 depends on 01\n`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(2);
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
} finally {
cleanup();
}
});
it("ignores 'Objective:' line (has colon — not a section break)", () => {
const content = `# Test\n\nObjective: Add Google and Apple OAuth\n\nTasks\n- [ ] 01 — Research\n- [ ] 02 — Implement\n\nDependencies\n- 02 depends on 01\n`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(2);
expect(project.tasks.find((t) => t.id === "02")!.dependencies).toEqual([
"01",
]);
expect(project.objective).toBe("Test");
} finally {
cleanup();
}
});
});
// ─── Lettered Step IDs (e.g. 02b, 02c) ────────────────────────────────────
describe("Lettered step IDs (e.g. 02b, 02c)", () => {
it("parses task lines with a single lowercase letter suffix", () => {
const content = `${FIO_HEADER}
- [~] 01 — Revert terminology
- [ ] 02 — Fix lesson generation
- [ ] 02b — Create lesson sequence adapter
- [ ] 02c — Add tap to show translation
- [ ] 03 — Restore web curriculum overview
${FIO_FOOTER}
- 02 -> 02b
- 02 -> 02c
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(5);
expect(project.tasks.map((t) => t.id)).toEqual([
"01",
"02",
"02b",
"02c",
"03",
]);
} finally {
cleanup();
}
});
it("normalizes unpadded lettered IDs (2b -> 02b)", () => {
const content = `${FIO_HEADER}
- [ ] 2 — First
- [ ] 2b — Sub-step
- [ ] 2c — Another sub-step
- [ ] 3 — Third
${FIO_FOOTER}
`;
const { project, cleanup } = parse(content);
try {
const ids = project.tasks.map((t) => t.id);
expect(ids).toEqual(["02", "02b", "02c", "03"]);
} finally {
cleanup();
}
});
it("preserves lettered IDs in natural-language depends-on", () => {
const content = `${FIO_HEADER}
- [ ] 02 — Fix bugs
- [ ] 02b — Adapter
- [ ] 02c — Translation
- [ ] 04 — Restore unit detail
${FIO_FOOTER}
- 02b depends on 02
- 02c depends on 02
- 04 depends on 01, 02b, 03
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([
"02",
]);
expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([
"02",
]);
expect(
project.tasks.find((t) => t.id === "04")!.dependencies.sort(),
).toEqual(["01", "02b", "03"]);
} finally {
cleanup();
}
});
it("handles 'also depends on' with lettered IDs", () => {
const content = `${FIO_HEADER}
- [ ] 02 — First
- [ ] 02b — Sub
${FIO_FOOTER}
- 02b also depends on 02
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([
"02",
]);
} finally {
cleanup();
}
});
it("handles arrow notation with lettered targets", () => {
const content = `${FIO_HEADER}
- [ ] 02 — Source
- [ ] 02b — Target b
- [ ] 02c — Target c
- [ ] 03 — End
${FIO_FOOTER}
- 02 -> 02b, 02c
- 02b, 02c -> 03
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([
"02",
]);
expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([
"02",
]);
expect(
project.tasks.find((t) => t.id === "03")!.dependencies.sort(),
).toEqual(["02b", "02c"]);
} finally {
cleanup();
}
});
it("handles 'must be done before' with lettered IDs", () => {
const content = `${FIO_HEADER}
- [ ] 02 — Before
- [ ] 02b — Sub b
- [ ] 02c — Sub c
- [ ] 03 — After
${FIO_FOOTER}
- 02 must be done before 02b, 02c
- 02b must be done before 03
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks.find((t) => t.id === "02b")!.dependencies).toEqual([
"02",
]);
expect(project.tasks.find((t) => t.id === "02c")!.dependencies).toEqual([
"02",
]);
expect(project.tasks.find((t) => t.id === "03")!.dependencies).toEqual([
"02b",
]);
} finally {
cleanup();
}
});
it("handles 'depend on' with lettered IDs", () => {
const content = `${FIO_HEADER}
- [ ] 02 — Foundation
- [ ] 03 — Foundation
- [ ] 02b — Needs both
${FIO_FOOTER}
- 02b depends on 02, 03
`;
const { project, cleanup } = parse(content);
try {
expect(
project.tasks.find((t) => t.id === "02b")!.dependencies.sort(),
).toEqual(["02", "03"]);
} finally {
cleanup();
}
});
it("handles 'can be done in parallel' with lettered IDs", () => {
const content = `${FIO_HEADER}
- [ ] 02b — Adapter
- [ ] 02c — Translation
- [ ] 05 — Final
${FIO_FOOTER}
- 02b, 02c can be done in parallel (post-fix polish)
`;
const { project, cleanup } = parse(content);
try {
expect(project.parallelGroups).toBeDefined();
const group = project.parallelGroups![0];
expect(group.taskIds.sort()).toEqual(["02b", "02c"]);
expect(group.label).toBe("post-fix polish");
} finally {
cleanup();
}
});
it("parses timeout meta block for a lettered ID", () => {
// Timeout meta blocks live in the Dependencies section, not inline
// with the task. Format: "02b [timeout] = 15m" (no list prefix).
const content = `${FIO_HEADER}
- [ ] 02b — Adapter
${FIO_FOOTER}
02b [timeout] = 15m
`;
const { project, cleanup } = parse(content);
try {
const task = project.tasks.find((t) => t.id === "02b")!;
expect(task.timeoutMs).toBe(15 * 60 * 1000);
} finally {
cleanup();
}
});
it("parses the full Linear UI Reintegration example", () => {
const content = `# Linear UI Reintegration
Objective: Reintegrate the original linear unit progression UI while retaining the pool-based v2 backend, hiding all pool internals from the user. Also fix critical lesson generation bugs (word repetition, introduction dedup, learning-pool cap) and add tap-to-show-translation.
Status legend: [ ] todo, [~] in-progress, [x] done
Tasks
- [~] 01 — revert-terminology-and-url-scheme
- [ ] 02 — fix-lesson-generation-bugs
- [ ] 02b — create-lesson-sequence-adapter
- [ ] 02c — add-tap-to-show-translation
- [ ] 03 — restore-web-curriculum-overview
- [ ] 04 — restore-web-linear-path-unit-detail
- [ ] 05 — wire-web-lesson-player-to-v2
- [ ] 06 — remove-pool-exposing-web-ui
- [ ] 07 — restore-ios-curriculum-linear-view
- [ ] 08 — bridge-ios-lesson-flow-to-v2
- [ ] 09 — remove-pool-exposing-ios-ui
- [ ] 10 — e2e-testing-and-validation
Dependencies
- 02b depends on 02
- 04 depends on 01, 02b, 03
- 05 depends on 02b, 02c, 04
- 06 depends on 03, 04, 05
- 08 depends on 02b, 02c, 07
- 09 depends on 07, 08
- 10 depends on 05, 06, 08, 09
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks).toHaveLength(12);
expect(project.objective).toBe("Linear UI Reintegration");
// Lettered IDs land in the right slot
const t2b = project.tasks.find((t) => t.id === "02b")!;
const t2c = project.tasks.find((t) => t.id === "02c")!;
expect(t2b.title).toBe("create-lesson-sequence-adapter");
expect(t2c.title).toBe("add-tap-to-show-translation");
// 02b depends on 02
expect(t2b.dependencies).toEqual(["02"]);
expect(t2c.dependencies).toEqual([]);
// 04 depends on 01, 02b, 03
expect(
project.tasks.find((t) => t.id === "04")!.dependencies.sort(),
).toEqual(["01", "02b", "03"]);
// 05 depends on 02b, 02c, 04
expect(
project.tasks.find((t) => t.id === "05")!.dependencies.sort(),
).toEqual(["02b", "02c", "04"]);
// 10 depends on 05, 06, 08, 09
expect(
project.tasks.find((t) => t.id === "10")!.dependencies.sort(),
).toEqual(["05", "06", "08", "09"]);
} finally {
cleanup();
}
});
it("preserves 02b in task title normalization (id 02b vs 02)", () => {
const content = `${FIO_HEADER}
- [ ] 02 — Step two
- [ ] 02b — Step two-b
- [ ] 02c — Step two-c
${FIO_FOOTER}
`;
const { project, cleanup } = parse(content);
try {
const ids = project.tasks.map((t) => t.id);
expect(ids).toEqual(["02", "02b", "02c"]);
// All three are distinct
expect(new Set(ids).size).toBe(3);
} finally {
cleanup();
}
});
});