initial import: @mikefreno/omp-ralpi (omp port)

This commit is contained in:
2026-08-10 09:46:09 -04:00
commit a9757c6fce
36 changed files with 14616 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
/**
* Tests for the tri-state commit-range diff (src/utils.ts getCommitRangeDiff):
* a FAILED range computation (invalid/stale base ref, git error) must be a
* distinct `error` signal, never collapsed into a clean `no-changes` — a
* broken base ref must never be silently treated as a verified task.
*
* Uses a real throwaway git repo so the shell-out behavior is exercised.
*/
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { execSync } from "node:child_process";
import { getCommitRangeDiff } from "../src/utils";
let repoDir: string;
function sh(cmd: string, cwd: string) {
execSync(cmd, { cwd, stdio: "pipe" });
}
beforeAll(() => {
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-crd-"));
sh("git init -q", repoDir);
sh("git config user.email test@example.com", repoDir);
sh("git config user.name test", repoDir);
fs.writeFileSync(path.join(repoDir, "a.ts"), "one\n", "utf-8");
sh("git add -A", repoDir);
sh("git commit -q -m init", repoDir);
});
afterAll(() => {
fs.rmSync(repoDir, { recursive: true, force: true });
});
describe("getCommitRangeDiff tri-state", () => {
test("ok: a resolvable base with committed changes yields the diff", () => {
fs.writeFileSync(path.join(repoDir, "a.ts"), "one\ntwo\n", "utf-8");
sh("git add -A", repoDir);
sh("git commit -q -m change", repoDir);
const base = execSync("git rev-parse HEAD~1", {
cwd: repoDir,
encoding: "utf-8",
}).trim();
const result = getCommitRangeDiff(repoDir, base);
expect(result.kind).toBe("ok");
if (result.kind === "ok") {
expect(result.diff).toContain("a.ts");
expect(result.hash.length).toBeGreaterThan(0);
}
});
test("error: a fake/unresolvable base ref yields the failure signal, not no-changes", () => {
// 40 hex chars that never existed in this repo.
const fake = "ffffffffffffffffffffffffffffffffffffffff";
const result = getCommitRangeDiff(repoDir, fake);
expect(result.kind).toBe("error");
if (result.kind === "error") {
expect(result.error).toContain("cannot be resolved");
}
});
test("error: a non-hex base ref is rejected before reaching the shell", () => {
const result = getCommitRangeDiff(repoDir, "HEAD~1; rm -rf /");
expect(result.kind).toBe("error");
if (result.kind === "error") {
expect(result.error).toContain("invalid or stale base ref");
}
});
test("no-changes: an empty range (base == HEAD) yields the no-changes signal", () => {
const head = execSync("git rev-parse HEAD", {
cwd: repoDir,
encoding: "utf-8",
}).trim();
const result = getCommitRangeDiff(repoDir, head);
expect(result.kind).toBe("no-changes");
});
});

View File

@@ -0,0 +1,674 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test";
import type { Project, Task } from "../src/types";
import {
buildExecutionPlan,
buildSequentialPlan,
getBlockedTasks,
detectCycles,
getCriticalPath,
formatDependencyChain,
formatExecutionPlan,
} from "../src/dag";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeProject(overrides?: Partial<Project>): Project {
return {
tasks: [],
dependencies: {},
sourcePath: "/tmp/test.md",
sourceDir: "/tmp",
...overrides,
};
}
function task(
id: string,
dependencies: string[] = [],
status: Task["status"] = "pending",
parallelGroup?: number,
): Task {
return { id, title: `Task ${id}`, status, dependencies, parallelGroup };
}
function tasksFrom(...args: Task[]): Task[] {
return args;
}
// ─── Basic DAG Construction ──────────────────────────────────────────────────
describe("buildExecutionPlan (Kahn's algorithm)", () => {
it("handles empty task list", () => {
const project = makeProject({ tasks: [] });
const plan = buildExecutionPlan(project, new Set());
expect(plan.batches).toEqual([]);
expect(plan.totalTasks).toBe(0);
});
it("puts all root tasks in batch 0", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02"), task("03")),
});
const plan = buildExecutionPlan(project, new Set());
expect(plan.batches).toHaveLength(1);
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual([
"01",
"02",
"03",
]);
});
it("builds correct linear dependency chain", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["02"]),
task("04", ["03"]),
),
});
const plan = buildExecutionPlan(project, new Set());
expect(plan.batches).toHaveLength(4);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["02"]);
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["04"]);
});
it("groups parallelizable tasks in the same batch", () => {
// Diamond: 01 -> 02, 03 -> 04
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04", ["02", "03"]),
),
});
const plan = buildExecutionPlan(project, new Set());
// Batch 0: [01], Batch 1: [02, 03], Batch 2: [04]
expect(plan.batches).toHaveLength(3);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
});
it("assigns correct batchIndex values", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04", ["02", "03"]),
),
});
const plan = buildExecutionPlan(project, new Set());
expect(plan.batches[0].batchIndex).toBe(0);
expect(plan.batches[1].batchIndex).toBe(1);
expect(plan.batches[2].batchIndex).toBe(2);
});
it("skips completed tasks and includes them in skippedTasks", () => {
const project = makeProject({
tasks: tasksFrom(
task("01", [], "completed"),
task("02", ["01"]),
task("03", ["02"]),
),
});
const plan = buildExecutionPlan(project, new Set(["01"]));
expect(plan.totalTasks).toBe(2);
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches).toHaveLength(2);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]);
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]);
});
it("throws on dependency cycle", () => {
const project = makeProject({
tasks: tasksFrom(
task("01", ["03"]),
task("02", ["01"]),
task("03", ["02"]),
),
});
expect(() => buildExecutionPlan(project, new Set())).toThrow(
/dependency cycle/i,
);
});
it("blocks tasks that depend on failed tasks", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["02"]),
task("04", ["03"]),
),
});
const plan = buildExecutionPlan(
project,
new Set(),
undefined,
new Set(["01"]),
);
// 01 is excluded from pending (failed). 02, 03, 04 are pending but
// transitively blocked — they don't appear in batches.
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.totalTasks).toBe(3); // 02, 03, 04 are pending but blocked
expect(plan.batches).toHaveLength(0);
});
it("blocks immediate dependents when task fails", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04"), // independent
),
});
const plan = buildExecutionPlan(
project,
new Set(),
undefined,
new Set(["01"]),
);
// 01 is excluded from pending (failed). 02, 03 are pending but blocked
// (depend on 01). 04 is independent and ready.
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]);
});
});
// ─── Complex DAGs ───────────────────────────────────────────────────────────
describe("Complex DAG batching", () => {
it("builds the OAuth PRD example correctly", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["02"]),
task("04", ["01"]),
task("05", ["03", "04"]),
task("06", ["03", "04"]),
task("07", ["03"]),
task("08", ["05", "06", "07"]),
),
});
const plan = buildExecutionPlan(project, new Set());
// Expected batches: [01], [02,04], [03], [05,06,07], [08]
expect(plan.batches).toHaveLength(5);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "04"]);
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
expect(plan.batches[3].tasks.map((t) => t.id).sort()).toEqual([
"05",
"06",
"07",
]);
expect(plan.batches[4].tasks.map((t) => t.id)).toEqual(["08"]);
});
it("builds the Design Token PRD example correctly", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04", ["02", "03"]),
task("05", ["04", "01"]),
),
});
const plan = buildExecutionPlan(project, new Set());
// Expected batches: [01], [02,03], [04], [05]
expect(plan.batches).toHaveLength(4);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["05"]);
});
it("handles a 3-tier diamond", () => {
// 01
// / \
// 02 03
// / \ / \
// 04 05 06
// \ | /
// 07
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04", ["02"]),
task("05", ["02", "03"]),
task("06", ["03"]),
task("07", ["04", "05", "06"]),
),
});
const plan = buildExecutionPlan(project, new Set());
expect(plan.batches).toHaveLength(4);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([
"04",
"05",
"06",
]);
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["07"]);
});
it("handles a wide fan-out with delayed convergence", () => {
// 01 -> 02,03,04,05,06
// 02,03 -> 07
// 04,05 -> 08
// 06 -> 09
// 07,08,09 -> 10
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04", ["01"]),
task("05", ["01"]),
task("06", ["01"]),
task("07", ["02", "03"]),
task("08", ["04", "05"]),
task("09", ["06"]),
task("10", ["07", "08", "09"]),
),
});
const plan = buildExecutionPlan(project, new Set());
expect(plan.batches).toHaveLength(4);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual([
"02",
"03",
"04",
"05",
"06",
]);
expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([
"07",
"08",
"09",
]);
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["10"]);
});
it("handles multiple independent subgraphs", () => {
// Two completely independent chains:
// Chain A: 01 -> 02 -> 03
// Chain B: 04 -> 05
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["02"]),
task("04"),
task("05", ["04"]),
),
});
const plan = buildExecutionPlan(project, new Set());
// Batch 0: [01, 04] (both roots)
// Batch 1: [02, 05]
// Batch 2: [03]
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "04"]);
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "05"]);
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
});
it("batches tasks respecting fan-in convergence", () => {
// 01 -> 03, 02 -> 03 (03 depends on both 01 AND 02)
const project = makeProject({
tasks: tasksFrom(task("01"), task("02"), task("03", ["01", "02"])),
});
const plan = buildExecutionPlan(project, new Set());
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "02"]);
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]);
});
});
// ─── Sequential Plan ─────────────────────────────────────────────────────────
describe("buildSequentialPlan", () => {
it("puts each task in its own batch", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
});
const plan = buildSequentialPlan(project, new Set());
expect(plan.batches).toHaveLength(3);
plan.batches.forEach((b, i) => {
expect(b.tasks).toHaveLength(1);
expect(b.batchIndex).toBe(i);
});
});
it("skips completed tasks and blocks transitively failed tasks", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["02"]),
task("04"),
),
});
const plan = buildSequentialPlan(project, new Set(["01"]), new Set(["01"]));
// 01 failed => 02, 03 blocked. 04 independent, runs.
expect(plan.skippedTasks.map((t) => t.id).sort()).toEqual([
"01",
"02",
"03",
]);
expect(plan.totalTasks).toBe(3);
});
it("maintains task order in sequential batches", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
});
const plan = buildSequentialPlan(project, new Set());
expect(plan.batches.map((b) => b.tasks[0].id)).toEqual(["01", "02", "03"]);
});
});
// ─── getBlockedTasks ─────────────────────────────────────────────────────────
describe("getBlockedTasks", () => {
it("returns direct dependents of failed tasks", () => {
const pending = tasksFrom(task("01"), task("02", ["01"]), task("03"));
const blocked = getBlockedTasks(pending, new Set(["01"]));
expect([...blocked]).toEqual(["02"]);
});
it("returns transitive dependents (chain reaction)", () => {
const pending = tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["02"]),
task("04", ["03"]),
);
const blocked = getBlockedTasks(pending, new Set(["01"]));
expect([...blocked].sort()).toEqual(["02", "03", "04"]);
});
it("does not affect tasks in separate subgraphs", () => {
const pending = tasksFrom(
task("01"),
task("02", ["01"]),
task("10"),
task("11", ["10"]),
);
const blocked = getBlockedTasks(pending, new Set(["01"]));
expect([...blocked].sort()).toEqual(["02"]);
});
it("returns empty set when no tasks depend on failed tasks", () => {
const pending = tasksFrom(task("01"), task("02"), task("03"));
const blocked = getBlockedTasks(pending, new Set(["99"]));
expect(blocked.size).toBe(0);
});
});
// ─── detectCycles ────────────────────────────────────────────────────────────
describe("detectCycles", () => {
it("returns empty for acyclic graph", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["02"])),
});
expect(detectCycles(project)).toEqual([]);
});
it("detects a 3-node cycle", () => {
const project = makeProject({
tasks: tasksFrom(
task("01", ["03"]),
task("02", ["01"]),
task("03", ["02"]),
),
});
const cycles = detectCycles(project);
expect(cycles.length).toBeGreaterThan(0);
});
it("detects a self-loop", () => {
const project = makeProject({
tasks: tasksFrom(task("01", ["01"])),
});
const cycles = detectCycles(project);
expect(cycles.length).toBeGreaterThan(0);
});
it("detects cycle in disconnected subgraph", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"), // isolated
task("02", ["03"]),
task("03", ["02"]), // cycle
),
});
const cycles = detectCycles(project);
expect(cycles.length).toBeGreaterThan(0);
});
it("returns empty for graph with only diamond patterns", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04", ["02", "03"]),
),
});
expect(detectCycles(project)).toEqual([]);
});
});
// ─── getCriticalPath ─────────────────────────────────────────────────────────
describe("getCriticalPath", () => {
it("returns the longest path through the DAG", () => {
// 01 -> 02 -> 03 -> 04 (long = 4)
// 01 -> 05 -> 04 (short = 3)
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["02"]),
task("04", ["03", "05"]),
task("05", ["01"]),
),
});
const path = getCriticalPath(project);
expect(path.length).toBe(4);
expect(path[0].id).toBe("01");
expect(path[path.length - 1].id).toBe("04");
});
it("returns single-node path for roots", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02"), task("03")),
});
const path = getCriticalPath(project);
expect(path.length).toBe(1);
});
it("handles complex branching by picking the longest chain", () => {
// 01 -> 02 -> 03 -> 04 -> 05 (long = 5)
// 01 -> 06 -> 05 (short = 3)
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["02"]),
task("04", ["03"]),
task("05", ["04", "06"]),
task("06", ["01"]),
),
});
const path = getCriticalPath(project);
// Should pick 01 -> 02 -> 03 -> 04 -> 05 (length 5)
expect(path.length).toBe(5);
expect(path.map((t) => t.id)).toEqual(["01", "02", "03", "04", "05"]);
});
});
// ─── formatDependencyChain ───────────────────────────────────────────────────
describe("formatDependencyChain", () => {
it("renders a simple tree", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02", ["01"])),
});
const formatted = formatDependencyChain(project);
expect(formatted).toContain("01");
expect(formatted).toContain("02");
});
it("mentions root tasks", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02")),
});
const formatted = formatDependencyChain(project);
expect(formatted).toMatch(/01.*root|root.*01/i);
});
it("handles empty task list", () => {
const project = makeProject({ tasks: [] });
const formatted = formatDependencyChain(project);
expect(formatted).toContain("no tasks");
});
it("shows orphan tasks when dependencies reference non-existent IDs", () => {
const project = makeProject({
tasks: tasksFrom(task("01", ["99"])),
});
const formatted = formatDependencyChain(project);
expect(formatted).toMatch(/orphan|unreached/i);
});
});
// ─── formatExecutionPlan ─────────────────────────────────────────────────────
describe("formatExecutionPlan", () => {
it("displays task counts and batches", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02", ["01"])),
});
const plan = buildExecutionPlan(project, new Set());
const formatted = formatExecutionPlan(plan);
expect(formatted).toContain("Total tasks");
expect(formatted).toContain("Batches");
expect(formatted).toContain("01");
expect(formatted).toContain("02");
});
it("shows skipped tasks", () => {
const project = makeProject({
tasks: tasksFrom(task("01", [], "completed"), task("02", ["01"])),
});
const plan = buildExecutionPlan(project, new Set(["01"]));
const formatted = formatExecutionPlan(plan);
expect(formatted).toContain("completed");
});
it("shows parallel group annotations when provided", () => {
const project = makeProject({
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
parallelGroups: [{ index: 0, label: "UI sprint", taskIds: ["02", "03"] }],
});
const plan = buildExecutionPlan(project, new Set());
const formatted = formatExecutionPlan(plan, project.parallelGroups);
expect(formatted).toContain("UI sprint");
});
});
// ─── Group-Aware Batching ────────────────────────────────────────────────────
describe("Parallel group batching", () => {
it("builds batches when parallel groups are defined", () => {
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04", ["02", "03"]),
),
parallelGroups: [
{ index: 0, label: "Frontend", taskIds: ["01", "02", "03", "04"] },
],
});
// Should route through buildGroupAwareBatches
const plan = buildExecutionPlan(project, new Set());
expect(plan.batches.length).toBeGreaterThan(0);
});
it("respects intra-group dependencies in parallel groups", () => {
// Tasks: 01 -> 02, 01 -> 03, 02 -> 04, 03 -> 04
// With parallel groups, there are no cross-group dependencies by definition.
// Intra-group deps are respected by Kahn's algorithm.
const project = makeProject({
tasks: tasksFrom(
task("01"),
task("02", ["01"]),
task("03", ["01"]),
task("04", ["02", "03"]),
),
parallelGroups: [
{ index: 0, label: "All", taskIds: ["01", "02", "03", "04"] },
],
});
const plan = buildExecutionPlan(project, new Set());
// Batch 0: [01], Batch 1: [02, 03], Batch 2: [04]
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
});
});
// ─── Real-World Scenario: Resume with completed tasks ───────────────────────
describe("Real-world resume scenarios", () => {
it("buildExecutionPlan correctly excludes file-based [x] completions", () => {
// Design Token PRD resume: 01,02,03 [x] in file, 04 [~], 05 [ ]
const project = makeProject({
tasks: tasksFrom(
task("01", [], "completed"),
task("02", ["01"], "completed"),
task("03", ["01"], "completed"),
task("04", ["02", "03"], "in_progress"),
task("05", ["04", "01"], "pending"),
),
});
// buildCompletedSet in index.ts produces {01, 02, 03} from file + progress
// This simulates what happens after buildCompletedSet is called
const completedFromFile = new Set(
project.tasks.filter((t) => t.status === "completed").map((t) => t.id),
);
const plan = buildExecutionPlan(project, completedFromFile);
// Only 04 and 05 should be pending
expect(plan.totalTasks).toBe(2);
expect(plan.batches).toHaveLength(2);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]);
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["05"]);
});
it("skipsTasks includes both progress-completed and file-completed tasks", () => {
const project = makeProject({
tasks: tasksFrom(
task("01", [], "completed"),
task("02", ["01"], "pending"),
),
});
// Simulate: 01 completed in file AND in progress
const plan = buildExecutionPlan(project, new Set(["01"]));
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]);
});
});

237
tests/diff.test.ts Normal file
View File

@@ -0,0 +1,237 @@
/**
* Tests for the noise-filtered diff engine (src/diff.ts).
* Covers: per-file +/ parsing, excluded-file split, totals excluding noise,
* malformed-chunk guard, isExcluded reasons, and configurable overrides.
*/
import { describe, test, expect } from "bun:test";
import {
parseDiff,
filterNoise,
isExcluded,
compileIgnorePatterns,
EXCLUDED_PATTERNS,
} from "../src/diff";
/** A synthetic unified diff mixing code, a lockfile, a minified file, and a binary. */
const SYNTH_DIFF = [
"diff --git a/src/index.ts b/src/index.ts",
"index 111..222 100644",
"--- a/src/index.ts",
"+++ b/src/index.ts",
"@@ -1,2 +1,4 @@",
' import { foo } from "./foo";',
"+export const baz = 1;",
"+export const qux = 2;",
"-foo();",
"+bar();",
"",
"diff --git a/package-lock.json b/package-lock.json",
"index 000..111 100644",
"--- a/package-lock.json",
"+++ b/package-lock.json",
"@@ -0,0 +1,3 @@",
"+{",
'+ "name": "x"',
"+}",
"",
"diff --git a/dist/foo.min.js b/dist/foo.min.js",
"index 111..222 100644",
"--- a/dist/foo.min.js",
"+++ b/dist/foo.min.js",
"@@ -1 +1 @@",
"-var a=1;",
"+var a=2;",
"",
"diff --git a/assets/logo.png b/assets/logo.png",
"index 111..222 100644",
"Binary files differ",
].join("\n");
describe("parseDiff", () => {
test("splits included vs excluded files and totals only included", () => {
const summary = parseDiff(SYNTH_DIFF);
// Included: only src/index.ts (code). Lockfile, minified, binary excluded.
expect(summary.files).toHaveLength(1);
expect(summary.files[0]).toEqual({
path: "src/index.ts",
linesAdded: 3,
linesRemoved: 1,
ext: "ts",
});
expect(summary.excluded).toHaveLength(3);
const byPath = new Map(
summary.excluded.map((f) => [f.path, f]),
);
expect(byPath.get("package-lock.json")).toMatchObject({
linesAdded: 3,
linesRemoved: 0,
reason: "lockfile",
});
expect(byPath.get("dist/foo.min.js")).toMatchObject({
linesAdded: 1,
linesRemoved: 1,
reason: "minified asset",
});
expect(byPath.get("assets/logo.png")).toMatchObject({
linesAdded: 0,
linesRemoved: 0,
reason: "binary/media asset",
});
// Totals exclude the noise files.
expect(summary.totalAdded).toBe(3);
expect(summary.totalRemoved).toBe(1);
});
test("returns empty summary for an empty diff", () => {
const summary = parseDiff("");
expect(summary.files).toHaveLength(0);
expect(summary.excluded).toHaveLength(0);
expect(summary.totalAdded).toBe(0);
expect(summary.totalRemoved).toBe(0);
});
test("skips malformed chunks without a/… b/ header without crashing", () => {
const malformed =
"diff --git weird-line\nindex 111..222\n--- a/x\n+++ b/x\n+x\n" +
"\n" +
"diff --git a/src/ok.ts b/src/ok.ts\n--- a/src/ok.ts\n+++ b/src/ok.ts\n+ok\n";
const summary = parseDiff(malformed);
// Only the well-formed chunk is counted.
expect(summary.files).toHaveLength(1);
expect(summary.files[0].path).toBe("src/ok.ts");
expect(summary.totalAdded).toBe(1);
});
test("does not count +++/--- header lines as additions/removals", () => {
const diff = [
"diff --git a/src/a.ts b/src/a.ts",
"--- a/src/a.ts",
"+++ b/src/a.ts",
"@@ -0,0 +1,2 @@",
"+one",
"+two",
].join("\n");
const summary = parseDiff(diff);
expect(summary.files[0].linesAdded).toBe(2);
expect(summary.files[0].linesRemoved).toBe(0);
});
});
describe("isExcluded", () => {
test("returns the right reason per pattern", () => {
expect(isExcluded("package-lock.json")).toBe("lockfile");
expect(isExcluded("yarn.lock")).toBe("lockfile");
expect(isExcluded("src/app.min.js")).toBe("minified asset");
expect(isExcluded("src/styles.min.css")).toBe("minified asset");
expect(isExcluded("build/out.js")).toBe("build output");
expect(isExcluded("node_modules/foo/index.js")).toBe("dependency");
expect(isExcluded("vendor/lib.bundle.js")).toBe("vendored dependency");
expect(isExcluded("assets/icon.svg")).toBe("binary/media asset");
expect(isExcluded("src/api.generated.ts")).toBe("generated file");
expect(isExcluded("test/__snapshots__/x.snap")).toBe("snapshot");
expect(isExcluded("dist/x.js.map")).toBe("source map");
});
test("returns undefined for review-relevant files", () => {
expect(isExcluded("src/foo.ts")).toBeUndefined();
expect(isExcluded("src/index.ts")).toBeUndefined();
});
test("merges caller-supplied extra patterns", () => {
expect(isExcluded("src/data.foo", [/\.foo$/])).toBe("extra ignore pattern");
expect(isExcluded("src/data.foo")).toBeUndefined();
});
test("EXCLUDED_PATTERNS covers lockfiles, min, generated, snap, map, build, vendor, binaries", () => {
for (const pat of [
"package-lock.json",
"src/app.min.js",
"src/thing.generated.ts",
"x.snap",
"x.js.map",
"dist/bundle.js",
"node_modules/a/b.js",
"vendor/x",
"a.png",
"f.woff2",
]) {
const hit = EXCLUDED_PATTERNS.some((r) => r.pattern.test(pat));
expect(hit, `${pat} should be covered by a default rule`).toBe(true);
}
});
});
describe("filterNoise", () => {
test("re-emits only included-file chunks", () => {
const filtered = filterNoise(SYNTH_DIFF);
expect(filtered).toContain("diff --git a/src/index.ts");
expect(filtered).not.toContain("package-lock.json");
expect(filtered).not.toContain("foo.min.js");
expect(filtered).not.toContain("logo.png");
});
test("returns empty when every file is noise", () => {
const onlyNoise = [
"diff --git a/package-lock.json b/package-lock.json",
"--- a/package-lock.json",
"+++ b/package-lock.json",
"+x",
].join("\n");
expect(filterNoise(onlyNoise)).toBe("");
});
});
describe("configurable noise rules", () => {
test("extraPatterns excludes a matching file from the review diff", () => {
const diff = [
"diff --git a/src/foo.ts b/src/foo.ts",
"--- a/src/foo.ts",
"+++ b/src/foo.ts",
"+keep",
"diff --git a/src/data.foo b/src/data.foo",
"--- a/src/data.foo",
"+++ b/src/data.foo",
"+drop",
].join("\n");
const opts = { extraPatterns: compileIgnorePatterns(["\\.foo$"]) };
const summary = parseDiff(diff, opts);
expect(summary.files.map((f) => f.path)).toEqual(["src/foo.ts"]);
expect(summary.excluded.map((f) => [f.path, f.reason])).toEqual([
["src/data.foo", "extra ignore pattern"],
]);
expect(filterNoise(diff, opts)).not.toContain("data.foo");
});
test("ignorePaths keeps an excluded-by-default file in scope", () => {
const diff = [
"diff --git a/package-lock.json b/package-lock.json",
"--- a/package-lock.json",
"+++ b/package-lock.json",
"+a",
"+b",
"+c",
].join("\n");
const opts = { ignorePaths: ["package-lock.json"] };
const summary = parseDiff(diff, opts);
expect(summary.files).toHaveLength(1);
expect(summary.files[0].path).toBe("package-lock.json");
expect(summary.excluded).toHaveLength(0);
expect(summary.totalAdded).toBe(3);
expect(filterNoise(diff, opts)).toContain("package-lock.json");
});
test("default behavior unchanged when overrides are unset", () => {
const summary = parseDiff(SYNTH_DIFF);
expect(summary.files[0].path).toBe("src/index.ts");
expect(summary.totalAdded).toBe(3);
});
test("compileIgnorePatterns skips invalid regexes", () => {
const compiled = compileIgnorePatterns(["\\.foo$", "(", "ok$"]);
expect(compiled.length).toBe(2);
});
});

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { tempDir } from "./helpers";
import { ensureRalpiIgnored } from "../src/utils";
// ─── Gitignore hygiene: ensureRalpiIgnored ──────────────────────────────────
describe("ensureRalpiIgnored", () => {
it("creates .gitignore with .ralpi/ when absent in a git work tree", () => {
const { dir, cleanup } = tempDir();
try {
fs.mkdirSync(path.join(dir, ".git"));
expect(ensureRalpiIgnored(dir)).toBe(true);
const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
expect(content).toContain(".ralpi/");
} finally {
cleanup();
}
});
it("appends .ralpi/ to an existing .gitignore without the marker", () => {
const { dir, cleanup } = tempDir();
try {
fs.mkdirSync(path.join(dir, ".git"));
fs.writeFileSync(
path.join(dir, ".gitignore"),
"node_modules/\n*.log\n",
"utf8",
);
expect(ensureRalpiIgnored(dir)).toBe(true);
const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
expect(content).toContain("node_modules/");
expect(content).toContain(".ralpi/");
} finally {
cleanup();
}
});
it("leaves a .gitignore with the marker untouched", () => {
const { dir, cleanup } = tempDir();
try {
fs.mkdirSync(path.join(dir, ".git"));
fs.writeFileSync(path.join(dir, ".gitignore"), ".ralpi/\n", "utf8");
expect(ensureRalpiIgnored(dir)).toBe(false);
expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe(
".ralpi/\n",
);
} finally {
cleanup();
}
});
it("is a no-op outside a git work tree", () => {
const { dir, cleanup } = tempDir();
try {
expect(ensureRalpiIgnored(dir)).toBe(false);
expect(fs.existsSync(path.join(dir, ".gitignore"))).toBe(false);
} finally {
cleanup();
}
});
it("is memoized per project dir", () => {
const { dir, cleanup } = tempDir();
try {
fs.mkdirSync(path.join(dir, ".git"));
expect(ensureRalpiIgnored(dir)).toBe(true);
// Second call: same dir already handled → no further work.
expect(ensureRalpiIgnored(dir)).toBe(false);
fs.writeFileSync(path.join(dir, ".gitignore"), "old\n", "utf8");
expect(ensureRalpiIgnored(dir)).toBe(false);
expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe(
"old\n",
);
} finally {
cleanup();
}
});
it("works when .git is a file (linked git worktree)", () => {
const { dir, cleanup } = tempDir();
try {
fs.writeFileSync(
path.join(dir, ".git"),
"gitdir: /some/shared/repo\n",
"utf8",
);
expect(ensureRalpiIgnored(dir)).toBe(true);
const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
expect(content).toContain(".ralpi/");
} finally {
cleanup();
}
});
});

30
tests/helpers.ts Normal file
View File

@@ -0,0 +1,30 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
// ─── Helpers ────────────────────────────────────────────────────────────────
/**
* Create a temporary directory for test files.
* Returns the path and a cleanup function.
*/
export function tempDir(): { dir: string; cleanup: () => void } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-test-"));
return {
dir,
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
};
}
/**
* Write content to a temp markdown file and return its path.
*/
export function writeTaskFile(
dir: string,
name: string,
content: string,
): string {
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, content, "utf-8");
return filePath;
}

1119
tests/parser-dag.test.ts Normal file

File diff suppressed because it is too large Load Diff

1321
tests/parser-formats.test.ts Normal file

File diff suppressed because it is too large Load Diff

521
tests/parser-phased.test.ts Normal file
View File

@@ -0,0 +1,521 @@
/**
* Tests for phased task format parsing
* Covers: phase detection, task parsing, phase boundaries, implicit dependencies
*/
import { describe, test, expect } from "bun:test";
import { parseTaskFile } from "../src/parser";
import type { Task } from "../src/types";
import { tempDir, writeTaskFile } from "./helpers";
/** 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;
}
}
describe("Phased task format", () => {
describe("Phase detection", () => {
test("detects phased format with markdown headings", () => {
const content = `# Voice Conversation
## Phase 1 - MVP
- [ ] 01 - Build voice pipeline
- [ ] 02 - Add audio playback
## Phase 2 - Streaming
- [ ] 03 - WebSocket channel
- [ ] 04 - Streaming STT
## Dependencies
- 02 depends on 01
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases).toBeDefined();
expect(project.phases?.length).toBe(2);
expect(project.phases?.[0].number).toBe(1);
expect(project.phases?.[0].title).toBe("MVP");
expect(project.phases?.[1].number).toBe(2);
expect(project.phases?.[1].title).toBe("Streaming");
} finally {
cleanup();
}
});
test("detects phased format with plain headings", () => {
const content = `# Voice Conversation
Phase 1 - MVP
- [ ] 01 - Build voice pipeline
- [ ] 02 - Add audio playback
Phase 2 - Streaming
- [ ] 03 - WebSocket channel
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases).toBeDefined();
expect(project.phases?.length).toBe(2);
} finally {
cleanup();
}
});
test("supports various separators in phase headings", () => {
const variants = [
"## Phase 1 - MVP",
"## Phase 1 - MVP",
"## Phase 1 - MVP",
"## Phase 1: MVP",
"## Phase 1 - MVP", // multiple spaces
];
for (const heading of variants) {
const content = `# Test
${heading}
- [ ] 01 - Task
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases).toBeDefined();
expect(project.phases?.length).toBe(1);
} finally {
cleanup();
}
}
});
test("handles phase headings with extra whitespace", () => {
const content = `# Test
## Phase 1 - MVP
- [ ] 01 - Task
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases).toBeDefined();
expect(project.phases?.length).toBe(1);
} finally {
cleanup();
}
});
});
describe("Task parsing within phases", () => {
test("assigns phase number to tasks", () => {
const content = `# Test
## Phase 1 - MVP
- [ ] 01 - Task A
- [ ] 02 - Task B
## Phase 2 - Enhancement
- [ ] 03 - Task C
- [ ] 04 - Task D
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks[0].id).toBe("01");
expect(project.tasks[0].phase).toBe(1);
expect(project.tasks[1].id).toBe("02");
expect(project.tasks[1].phase).toBe(1);
expect(project.tasks[2].id).toBe("03");
expect(project.tasks[2].phase).toBe(2);
expect(project.tasks[3].id).toBe("04");
expect(project.tasks[3].phase).toBe(2);
} finally {
cleanup();
}
});
test("tracks task IDs in each phase", () => {
const content = `# Test
## Phase 1 - Foundation
- [ ] 01 - Setup
- [ ] 02 - Config
## Phase 2 - Implementation
- [ ] 03 - Feature A
- [ ] 04 - Feature B
- [ ] 05 - Feature C
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases?.[0].taskIds).toEqual(["01", "02"]);
expect(project.phases?.[1].taskIds).toEqual(["03", "04", "05"]);
} finally {
cleanup();
}
});
test("handles tasks with different statuses in phases", () => {
const content = `# Test
## Phase 1 - MVP
- [x] 01 - Done task
- [ ] 02 - Pending task
- [~] 03 - In progress
## Phase 2 - Next
- [ ] 04 - Future task
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
expect(project.tasks[0].status).toBe("completed");
expect(project.tasks[1].status).toBe("pending");
expect(project.tasks[2].status).toBe("in_progress");
expect(project.tasks[3].status).toBe("pending");
expect(project.phases?.[0].taskIds).toEqual(["01", "02", "03"]);
expect(project.phases?.[1].taskIds).toEqual(["04"]);
} finally {
cleanup();
}
});
test("handles empty phases", () => {
const content = `# Test
## Phase 1 - Empty
## Phase 2 - Has tasks
- [ ] 01 - Task
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases?.length).toBe(1);
expect(project.phases?.[0].number).toBe(2);
expect(project.phases?.[0].taskIds).toEqual(["01"]);
} finally {
cleanup();
}
});
});
describe("Implicit phase-boundary dependencies", () => {
test("adds dependency from first task of phase 2 to last task of phase 1", () => {
const content = `# Test
## Phase 1 - MVP
- [ ] 01 - Setup
- [ ] 02 - Build
## Phase 2 - Enhancement
- [ ] 03 - Feature
- [ ] 04 - Test
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
// Task 03 should depend on task 02 (implicit phase boundary)
expect(
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
).toContain("02");
} finally {
cleanup();
}
});
test("adds dependencies across multiple phases", () => {
const content = `# Test
## Phase 1 - Foundation
- [ ] 01 - Setup
## Phase 2 - Core
- [ ] 02 - Build
- [ ] 03 - Test
## Phase 3 — Polish
- [ ] 04 — Refine
- [ ] 05 — Release
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
// Task 02 depends on task 01 (phase 1 → 2 boundary)
expect(
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
).toContain("01");
// Task 04 depends on task 03 (phase 2 → 3 boundary)
expect(
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
).toContain("03");
} finally {
cleanup();
}
});
test("does not duplicate explicit dependencies", () => {
const content = `# Test
## Phase 1 - MVP
- [ ] 01 - Setup
- [ ] 02 - Build
## Phase 2 — Enhancement
- [ ] 03 — Feature
## Dependencies
- 03 depends on 02
`;
const { project, cleanup } = parse(content);
try {
const task03 = project.tasks.find((t: Task) => t.id === "03");
const depCount = task03?.dependencies.filter(
(d: string) => d === "02",
).length;
expect(depCount).toBe(1); // Should not duplicate
} finally {
cleanup();
}
});
test("handles single phase (no boundaries)", () => {
const content = `# Test
## Phase 1 - All tasks
- [ ] 01 - Task A
- [ ] 02 - Task B
## Dependencies
`;
const { project, cleanup } = parse(content);
try {
// No implicit dependencies should be added
expect(project.tasks[0].dependencies).toEqual([]);
expect(project.tasks[1].dependencies).toEqual([]);
} finally {
cleanup();
}
});
test("works alongside explicit dependencies", () => {
const content = `# Test
## Phase 1 - MVP
- [ ] 01 - Setup
- [ ] 02 - Build
## Phase 2 - Enhancement
- [ ] 03 - Feature A
- [ ] 04 - Feature B
## Dependencies
- 04 depends on 03
`;
const { project, cleanup } = parse(content);
try {
// Task 03 has implicit dependency on task 02
expect(
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
).toContain("02");
// Task 04 has explicit dependency on task 03
expect(
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
).toContain("03");
// Task 04 should NOT have implicit dependency on task 02
expect(
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
).not.toContain("02");
} finally {
cleanup();
}
});
});
describe("Mixed formats", () => {
test("phased format with arrow dependencies", () => {
const content = `# Test
## Phase 1 - Setup
- [ ] 01 - Initialize
- [ ] 02 - Configure
## Phase 2 - Build
- [ ] 03 - Compile
- [ ] 04 - Bundle
## Dependencies
- 01 → 02
- 03 → 04
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases?.length).toBe(2);
expect(
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
).toContain("01");
expect(
project.tasks.find((t: Task) => t.id === "04")?.dependencies,
).toContain("03");
expect(
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
).toContain("02");
} finally {
cleanup();
}
});
test("phased format with parallel groups", () => {
const content = `# Test
## Phase 1 - MVP
- [ ] 01 - Setup
- [ ] 02 - Build
## Phase 2 - Enhancement
- [ ] 03 - Feature
- [ ] 04 - Test
## Dependencies
- 01, 02 can be done in parallel
- 03, 04 can be done in parallel
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases?.length).toBe(2);
expect(project.parallelGroups?.length).toBe(2);
expect(
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
).toContain("02");
} finally {
cleanup();
}
});
test("phased format with exit criteria", () => {
const content = `# Test
## Phase 1 - MVP
- [ ] 01 - Build
- [ ] 02 - Test
## Phase 2 - Release
- [ ] 03 - Deploy
## Dependencies
## Exit Criteria
- All tests pass
- Deployment successful
`;
const { project, cleanup } = parse(content);
try {
expect(project.phases?.length).toBe(2);
expect(project.exitCriteria?.length).toBe(2);
} finally {
cleanup();
}
});
});
describe("Real-world example", () => {
test("parses voice conversation PRD correctly", () => {
const content = `# Voice Conversation
Objective: Add full voice conversation capability
## Phase 1 - Push-to-Talk MVP
- [ ] 01 - Build voice pipeline orchestrator → \`01-voice-pipeline-orchestrator.md\`
- [ ] 02 - Build auto-playback audio module → \`02-auto-playback-audio-module.md\`
- [ ] 03 - Wire voice mode toggle into chat UI → \`03-voice-mode-toggle-ui.md\`
- [ ] 04 - End-to-end push-to-talk integration test → \`04-push-to-talk-integration-test.md\`
## Phase 2 - Streaming & Real-Time
- [ ] 05 - Build WebSocket voice channel → \`05-websocket-voice-channel.md\`
- [ ] 06 - Implement streaming STT pipeline → \`06-streaming-stt-pipeline.md\`
- [ ] 07 - Implement streaming TTS pipeline → \`07-streaming-tts-pipeline.md\`
## Phase 3 - Optimization & Hardening
- [ ] 08 - Model quantization and VRAM budget manager → \`08-model-quantization.md\`
- [ ] 09 - Latency profiling and pipeline optimization → \`09-latency-profiling.md\`
## Dependencies
- 02 depends on 01
- 03 depends on 01, 02
- 04 depends on 03
- 06 depends on 05
- 07 depends on 05
- 09 depends on 08
## Exit Criteria
- Users can hold multi-turn voice conversations
- Total round-trip latency under 3s
`;
const { project, cleanup } = parse(content);
try {
// Verify phases
expect(project.phases?.length).toBe(3);
expect(project.phases?.[0].title).toBe("Push-to-Talk MVP");
expect(project.phases?.[1].title).toBe("Streaming & Real-Time");
expect(project.phases?.[2].title).toBe("Optimization & Hardening");
// Verify task phases
expect(project.tasks[0].phase).toBe(1);
expect(project.tasks[4].phase).toBe(2);
expect(project.tasks[7].phase).toBe(3);
// Verify phase boundaries
// Task 05 (first in phase 2) depends on task 04 (last in phase 1)
expect(
project.tasks.find((t: Task) => t.id === "05")?.dependencies,
).toContain("04");
// Task 08 (first in phase 3) depends on task 07 (last in phase 2)
expect(
project.tasks.find((t: Task) => t.id === "08")?.dependencies,
).toContain("07");
// Verify explicit dependencies still work
expect(
project.tasks.find((t: Task) => t.id === "02")?.dependencies,
).toContain("01");
expect(
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
).toContain("01");
expect(
project.tasks.find((t: Task) => t.id === "03")?.dependencies,
).toContain("02");
// Verify task files
expect(project.tasks[0].file).toBe("01-voice-pipeline-orchestrator.md");
expect(project.tasks[1].file).toBe("02-auto-playback-audio-module.md");
// Verify exit criteria
expect(project.exitCriteria?.length).toBe(2);
expect(project.objective).toBe("Voice Conversation");
} finally {
cleanup();
}
});
});
});

View File

@@ -0,0 +1,82 @@
/// <reference types="bun-types" />
import { describe, it, expect, beforeEach } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { ProgressTracker } from "../src/progress";
/**
* Regression test: two concurrent loops (different PRDs) each run their own
* ProgressTracker. Each instance snapshots the whole state at construction;
* a save() that writes that stale snapshot verbatim would revert the OTHER
* loop's task status changes — tasks wrongly back to "pending" while their
* worktrees carry real work, stranding it on the next resume.
*/
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-prog-test-"));
});
function prdA(projectDir: string): ProgressTracker {
return new ProgressTracker(
projectDir,
path.join(projectDir, "tasks/a/README.md"),
);
}
function prdB(projectDir: string): ProgressTracker {
return new ProgressTracker(
projectDir,
path.join(projectDir, "tasks/b/README.md"),
);
}
/** Read the on-disk progress state; the file is written by the tracker, so
* a parse failure is a test bug worth surfacing. */
function readState(): Record<string, any> {
const raw = fs.readFileSync(
path.join(root, ".ralpi", "progress.json"),
"utf-8",
);
try {
return JSON.parse(raw) as Record<string, any>;
} catch {
throw new Error(`malformed progress.json:\n${raw.slice(0, 200)}`);
}
}
describe("ProgressTracker multi-PRD save isolation", () => {
it("does not clobber another PRD's task status on save", () => {
const a = prdA(root);
const b = prdB(root);
expect(a.getKey()).not.toBe(b.getKey());
// Loop A marks its task in_progress.
a.markInProgress("01");
expect(a.getTaskStatus("01")).toBe("in_progress");
// Loop B (stale snapshot from before A's update) marks ITS task.
b.markInProgress("02");
// The on-disk state must show BOTH updates.
const raw = readState();
expect(raw.prds[a.getKey()].tasks["01"].status).toBe("in_progress");
expect(raw.prds[b.getKey()].tasks["02"].status).toBe("in_progress");
});
it("preserves other PRD completions when this PRD saves", () => {
const a = prdA(root);
const b = prdB(root);
a.markCompleted("01", 1000);
b.markInProgress("02");
// A completes another task later — A's save must not revert B.
a.markCompleted("03", 500);
const raw = readState();
expect(raw.prds[a.getKey()].tasks["01"].status).toBe("completed");
expect(raw.prds[a.getKey()].tasks["03"].status).toBe("completed");
expect(raw.prds[b.getKey()].tasks["02"].status).toBe("in_progress");
});
});

View File

@@ -0,0 +1,87 @@
/// <reference types="bun-types" />
import { describe, it, expect, beforeEach } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { ProgressTracker } from "../src/progress";
import { countPRDResumeStats } from "../src/utils";
/**
* Regression test: the resume-selection prompt under-reported task totals
* when multiple loop histories existed. The progress tracker only records
* TOUCHED tasks (started/completed/failed) — never-started tasks are absent
* from prd.tasks, so a naive Object.keys() count missed them entirely, and
* file-checked completions were ignored unless markCompleted had run.
* countPRDResumeStats derives the true total from the parsed PRD file and
* counts checkbox completions too.
*/
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-stats-test-"));
});
const PRD_CONTENT = `# Test PRD
## Tasks
- [ ] Task one
- [x] Task two
- [ ] Task three
- [ ] Task four
`;
function writePRD(rel: string): string {
const p = path.join(root, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, PRD_CONTENT, "utf-8");
return p;
}
describe("countPRDResumeStats", () => {
it("reports the full task total from the PRD file, not just touched tasks", () => {
const sourcePath = writePRD("tasks/a/README.md");
const progress = new ProgressTracker(root, sourcePath);
// Simple-checkbox format assigns sequential ids 00-03. Only 00
// (completed) and 02 (failed) were touched by the loop; 01 is checked
// off in the file; 03 was never started.
progress.markCompleted("00", 1000);
progress.markFailed("02", "boom");
const stats = countPRDResumeStats(progress.getState(), sourcePath);
expect(stats.total).toBe(4); // old code reported 2
expect(stats.completed).toBe(2); // 00 via progress + 01 via checkbox
expect(stats.failed).toBe(1);
});
it("does not double-count a task that is both progress-completed and file-checked", () => {
const sourcePath = writePRD("tasks/b/README.md");
const progress = new ProgressTracker(root, sourcePath);
progress.markCompleted("01", 500); // 01 already [x] in the file
const stats = countPRDResumeStats(progress.getState(), sourcePath);
expect(stats.completed).toBe(1);
});
it("falls back to touched-task counts when the PRD file is missing", () => {
const missing = path.join(root, "tasks/gone/README.md");
const progress = new ProgressTracker(root, missing);
progress.markCompleted("01", 1000);
progress.markFailed("02", "nope");
const stats = countPRDResumeStats(progress.getState(), missing);
expect(stats.total).toBe(2);
expect(stats.completed).toBe(1);
expect(stats.failed).toBe(1);
});
it("reports zero for a never-touched PRD with no file", () => {
const missing = path.join(root, "tasks/none/README.md");
const progress = new ProgressTracker(root, missing);
const stats = countPRDResumeStats(progress.getState(), missing);
expect(stats.total).toBe(0);
expect(stats.completed).toBe(0);
expect(stats.failed).toBe(0);
});
});

303
tests/review-prompt.test.ts Normal file
View File

@@ -0,0 +1,303 @@
/**
* Tests for the review prompt builders (src/prompts.ts).
* Covers: per-file summary table, excluded-files section, oversized-diff
* read-instruction (never byte-truncates), custom review focus, and the
* configurable noise-filter overrides surfacing in the prompt.
*/
import { describe, test, expect } from "bun:test";
import {
buildReviewPrompt,
buildReviewPromptUncommitted,
} from "../src/prompts";
import { compileIgnorePatterns } from "../src/diff";
import type { Task, Project } from "../src/types";
const task: Task = {
id: "01",
title: "Implement auth",
description: "Add a login flow",
status: "completed",
dependencies: [],
};
const project: Project = {
objective: "Build the app",
sourcePath: "README.md",
sourceDir: "/tmp",
tasks: [task],
dependencies: {},
};
/** A diff mixing one code file plus lockfile/minified/binary noise. */
const MIXED_DIFF = [
"diff --git a/src/auth.ts b/src/auth.ts",
"index 111..222 100644",
"--- a/src/auth.ts",
"+++ b/src/auth.ts",
"@@ -1,3 +1,5 @@",
' import { hash } from "./hash";',
"+export function login() {",
"+ return hash(secret);",
"- return legacy();",
"+}",
"",
"diff --git a/package-lock.json b/package-lock.json",
"index 000..111 100644",
"--- a/package-lock.json",
"+++ b/package-lock.json",
"@@ -0,0 +1,3 @@",
"+{",
'+ "name": "x"',
"+}",
"",
"diff --git a/assets/logo.png b/assets/logo.png",
"index 111..222 100644",
"Binary files differ",
"",
"diff --git a/dist/app.min.js b/dist/app.min.js",
"index 111..222 100644",
"--- a/dist/app.min.js",
"+++ b/dist/app.min.js",
"@@ -1 +1 @@",
"-var a=1;",
"+var a=2;",
].join("\n");
function manyFileDiff(n: number): string {
const chunks: string[] = [];
for (let i = 0; i < n; i++) {
chunks.push(
`diff --git a/src/f${String(i).padStart(2, "0")}.ts b/src/f${String(i).padStart(2, "0")}.ts`,
"--- a/src/f.ts",
"+++ b/src/f.ts",
`+line ${i}`,
);
}
return chunks.join("\n");
}
describe("buildReviewPrompt", () => {
test("emits a per-file +/ summary table with totals, excluding noise", () => {
const prompt = buildReviewPrompt(
task,
project,
"abc1234",
"feat: auth",
MIXED_DIFF,
);
expect(prompt).toContain("### Changed Files");
expect(prompt).toContain("| `src/auth.ts` | +3/-1 | ts |");
expect(prompt).toContain("| **Total** | **+3/-1** | |");
});
test("surfaces an excluded-files section with path, counts, and reason", () => {
const prompt = buildReviewPrompt(
task,
project,
"abc1234",
"feat: auth",
MIXED_DIFF,
);
expect(prompt).toContain("### Excluded Files (3)");
expect(prompt).toContain("- `package-lock.json` (+3/-0) — lockfile");
expect(prompt).toContain(
"- `assets/logo.png` (+0/-0) — binary/media asset",
);
expect(prompt).toContain("- `dist/app.min.js` (+1/-1) — minified asset");
});
test("never inlines excluded (noise) chunks into the diff block", () => {
const prompt = buildReviewPrompt(
task,
project,
"abc1234",
"feat: auth",
MIXED_DIFF,
);
// The noise chunks themselves are never inlined — only the excluded-files
// section names them (as `- path (+x/-y) — reason`, no `diff --git` header).
expect(prompt).not.toContain("diff --git a/package-lock.json");
expect(prompt).not.toContain("diff --git a/assets/logo.png");
expect(prompt).not.toContain("diff --git a/dist/app.min.js");
// The cleaned diff block is present with the code file.
expect(prompt).toContain("```diff");
expect(prompt).toContain("diff --git a/src/auth.ts");
});
test("omits the excluded section entirely when nothing is excluded", () => {
const clean = [
"diff --git a/src/auth.ts b/src/auth.ts",
"--- a/src/auth.ts",
"+++ b/src/auth.ts",
"+export const x = 1;",
].join("\n");
const prompt = buildReviewPrompt(
task,
project,
"abc1234",
"feat: auth",
clean,
);
expect(prompt).not.toContain("### Excluded Files");
expect(prompt).toContain("| `src/auth.ts` | +1/-0 | ts |");
});
test("switches to a file-list + read instruction for >20 files, no truncation", () => {
const prompt = buildReviewPrompt(
task,
project,
"abc1234",
"feat: many",
manyFileDiff(21),
);
expect(prompt).toContain("Diff too large");
expect(prompt).toContain("Use `read` to inspect the changed files");
// No byte-truncated inline diff for oversized inputs.
expect(prompt).not.toContain("```diff");
});
test("switches to a file-list + read instruction for a >50KB diff, no truncation", () => {
// One file but a huge cleaned diff — crosses MAX_DIFF_BYTES (50_000).
const huge = [
"diff --git a/src/auth.ts b/src/auth.ts",
"--- a/src/auth.ts",
"+++ b/src/auth.ts",
...Array.from(
{ length: 26000 },
() => "+padding line to blow past the size threshold",
),
].join("\n");
const prompt = buildReviewPrompt(
task,
project,
"abc1234",
"feat: auth",
huge,
);
expect(prompt).toContain("Diff too large");
expect(prompt).toContain("Use `read` to inspect the changed files");
expect(prompt).toContain("src/auth.ts");
// No byte-truncated inline diff for the oversized input.
expect(prompt).not.toContain("```diff");
});
test("a small diff over the file-count branch still inlines under size threshold", () => {
// 5 files, small diff — under MAX_REVIEW_FILES and MAX_DIFF_BYTES → inlined.
const prompt = buildReviewPrompt(
task,
project,
"abc1234",
"feat: small",
manyFileDiff(5),
);
expect(prompt).toContain("```diff");
expect(prompt).not.toContain("Diff too large");
});
test("inlines a small diff normally (no read-instruction)", () => {
const prompt = buildReviewPrompt(
task,
project,
"abc1234",
"feat: auth",
MIXED_DIFF,
);
expect(prompt).not.toContain("Diff too large");
});
test("emits a Custom Review Focus section only when focus is set", () => {
const withFocus = buildReviewPrompt(
task,
project,
"abc1234",
"feat: auth",
MIXED_DIFF,
{ focus: "check security only" },
);
expect(withFocus).toContain("## Custom Review Focus");
expect(withFocus).toContain("check security only");
const withoutFocus = buildReviewPrompt(
task,
project,
"abc1234",
"feat: auth",
MIXED_DIFF,
);
expect(withoutFocus).not.toContain("## Custom Review Focus");
});
test("surfaces extra ignore patterns and ignorePaths overrides in the prompt", () => {
const diff = [
"diff --git a/src/keep.ts b/src/keep.ts",
"--- a/src/keep.ts",
"+++ b/src/keep.ts",
"+keep",
"diff --git a/package-lock.json b/package-lock.json",
"--- a/package-lock.json",
"+++ b/package-lock.json",
"+a",
"+b",
"+c",
"+d",
].join("\n");
// ignorePaths keeps the lockfile in scope → it shows in the table,
// and no excluded section is emitted.
const kept = buildReviewPrompt(task, project, "abc1234", "x", diff, {
diffOptions: { ignorePaths: ["package-lock.json"] },
});
expect(kept).toContain("| `package-lock.json` | +4/-0 | json |");
expect(kept).not.toContain("### Excluded Files");
// Without ignorePaths, the lockfile is excluded.
const excluded = buildReviewPrompt(task, project, "abc1234", "x", diff);
expect(excluded).not.toContain("| `package-lock.json` |");
expect(excluded).toContain("### Excluded Files (1)");
// extraPatterns drops a matching file from scope.
const dropped = buildReviewPrompt(task, project, "abc1234", "x", diff, {
diffOptions: {
extraPatterns: compileIgnorePatterns(["\\.ts$"]),
ignorePaths: [],
},
});
expect(dropped).not.toContain("| `src/keep.ts` |");
expect(dropped).toContain("### Excluded Files (2)");
});
});
describe("buildReviewPromptUncommitted", () => {
test("emits summary table, excluded section, and cleaned diff", () => {
const prompt = buildReviewPromptUncommitted(
task,
project,
"M src/auth.ts",
MIXED_DIFF,
);
expect(prompt).toContain("### Changed Files");
expect(prompt).toContain("| `src/auth.ts` | +3/-1 | ts |");
expect(prompt).toContain("### Excluded Files (3)");
expect(prompt).not.toContain("diff --git a/package-lock.json");
expect(prompt).toContain("### Current Tracked Diff (git diff)");
});
test("supports custom focus", () => {
const prompt = buildReviewPromptUncommitted(
task,
project,
"M src/auth.ts",
MIXED_DIFF,
{ focus: "review performance" },
);
expect(prompt).toContain("## Custom Review Focus");
expect(prompt).toContain("review performance");
});
});

View File

@@ -0,0 +1,53 @@
/**
* Tests for the severity taxonomy alignment in review verdict parsing
* (src/review.ts): the `critical` token is accepted and normalized to
* ralpi's `blocker` severity, mirroring @piex-dev/review's grading.
*/
import { describe, test, expect } from "bun:test";
import { extractReview } from "../src/review";
/** Build a full review-agent output ending in a REVIEW VERDICT block. */
function reviewOutput(findings: string[]): string {
return [
"Prose: looks mostly fine, a few issues to fix.",
"## REVIEW VERDICT",
"VERDICT: fail",
"SUMMARY: Needs fixes.",
"FINDINGS:",
...findings,
].join("\n");
}
describe("extractReview severity normalization", () => {
test("maps critical → blocker, keeps warning/nit/info", () => {
const out = reviewOutput([
"- [critical] src/auth.ts:12 hardcoded secret",
"- [warning] src/auth.ts:30 unused import",
"- [nit] src/auth.ts:5 style",
"- [info] src/auth.ts:1 note",
]);
const review = extractReview(out, "01", "abc1234");
expect(review).not.toBeNull();
const severities = review!.findings.map((f) => f.severity);
expect(severities).toEqual(["blocker", "warning", "nit", "info"]);
});
test("normalizes the warn synonym to warning", () => {
const out = reviewOutput(["- [warn] src/a.ts:2 thing"]);
const review = extractReview(out, "01", "abc1234");
expect(review!.findings[0].severity).toBe("warning");
});
test("uppercase CRITICAL token also maps to blocker", () => {
const out = reviewOutput(["- [CRITICAL] src/a.ts:2 thing"]);
const review = extractReview(out, "01", "abc1234");
expect(review!.findings[0].severity).toBe("blocker");
});
test("findings without a severity are still parsed", () => {
const out = reviewOutput(["- src/a.ts:2 plain line"]);
const review = extractReview(out, "01", "abc1234");
expect(review!.findings[0].severity).toBe("info");
});
});

View File

@@ -0,0 +1,265 @@
/// <reference types="bun-types" />
import { describe, it, expect, beforeEach } from "bun:test";
import { execSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
createWorktree,
finalizeCommittedWorktrees,
mergeWorktree,
removeWorktree,
worktreeHasPreservableWork,
} from "../src/worktree";
/**
* Regression tests for worktree resume/finalize behavior:
*
* 1. finalizeCommittedWorktrees merges a committed worktree branch even
* when the worktree carries UNTRACKED files (previously the dirty check
* counted `??` entries, stranding committed code in .ralpi/worktrees/).
* 2. finalize works for tasks that are NOT in_progress (pending) — the
* stranded-work case after an interrupted resume.
* 3. createWorktree reuses an existing worktree under a symlinked project
* path (git porcelain emits realpaths; literal path.join must not be
* compared verbatim).
* 4. worktreeHasPreservableWork keeps failed-task branches alive so a
* timeout doesn't destroy commits the agent already made.
*/
const sh = (cmd: string, cwd: string): string => {
try {
return execSync(cmd, { cwd, encoding: "utf-8" }).trim();
} catch (err) {
throw new Error(
`git cmd failed in ${cwd}: ${cmd}\n${(err as Error).message}`,
);
}
};
const STATE_DIR = ".ralpi";
const PRD_KEY = "prd";
function makeRepo(root: string): void {
sh("git init -q -b master .", root);
sh("git config user.email t@t.co", root);
sh("git config user.name T", root);
sh("echo '# Demo' > README.md", root);
sh("git add -A && git commit -qm init", root);
}
/** Commit work in a worktree and record the commit message. */
function commitInWorktree(wt: { dir: string }, filename: string, msg: string) {
sh(`echo '${filename} content' > ${filename}`, wt.dir);
sh(`git add -A && git commit -qm '${msg}'`, wt.dir);
}
function masterHasFile(root: string, filename: string): boolean {
try {
sh(`git show master:${filename}`, root);
return true;
} catch {
return false;
}
}
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-wt-test-"));
makeRepo(root);
});
describe("finalizeCommittedWorktrees", () => {
it("merges a committed worktree branch even when untracked files exist", () => {
const wt = createWorktree(
root,
STATE_DIR,
"01",
PRD_KEY,
undefined,
"task one",
)!;
commitInWorktree(wt, "work.txt", "task 01 work");
// The task agent left a scratch file untracked (like build artifacts).
sh("mkdir -p scratch && echo junk > scratch/junk.bin", wt.dir);
expect(sh("git status --porcelain", wt.dir)).toContain("??");
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["01"]);
expect(fin.finalized).toEqual(["01"]);
expect(masterHasFile(root, "work.txt")).toBe(true);
});
it("finalizes tasks that are pending (not in_progress) with committed work", () => {
const wt = createWorktree(
root,
STATE_DIR,
"02",
PRD_KEY,
undefined,
"task two",
)!;
commitInWorktree(wt, "b.txt", "task 02 work");
// Simulate a prior interrupted resume: task reset to pending, branch
// never merged.
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["02"]);
expect(fin.finalized).toEqual(["02"]);
expect(masterHasFile(root, "b.txt")).toBe(true);
});
it("leaves a worktree with uncommitted TRACKED edits for re-run", () => {
const wt = createWorktree(
root,
STATE_DIR,
"03",
PRD_KEY,
undefined,
"task three",
)!;
commitInWorktree(wt, "c.txt", "task 03 work");
// Agent was mid-edit when interrupted: a tracked file modified.
sh("echo more >> README.md", wt.dir);
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["03"]);
expect(fin.finalized).toEqual([]);
expect(fin.rerun).toEqual(["03"]);
expect(masterHasFile(root, "c.txt")).toBe(false);
});
it("does not re-merge an already-merged branch", () => {
const wt = createWorktree(
root,
STATE_DIR,
"04",
PRD_KEY,
undefined,
"task four",
)!;
commitInWorktree(wt, "d.txt", "task 04 work");
expect(mergeWorktree(root, wt.branch).success).toBe(true);
removeWorktree(root, wt);
// Re-create a worktree on the same (now-merged) branch tip: nothing
// ahead of main → re-run, no spurious merge.
const wt2 = createWorktree(
root,
STATE_DIR,
"04",
PRD_KEY,
undefined,
"task four",
)!;
commitInWorktree(wt2, "e.txt", "task 04 more work");
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["04"]);
expect(fin.finalized).toEqual(["04"]);
expect(masterHasFile(root, "e.txt")).toBe(true);
});
it("reports conflicts and preserves the worktree", () => {
const wt = createWorktree(
root,
STATE_DIR,
"05",
PRD_KEY,
undefined,
"task five",
)!;
// Both sides edit f.txt: master AFTER the worktree exists, so the
// branches genuinely diverge and the merge must conflict.
sh(
"echo master > f.txt && git add -A && git commit -qm 'master f.txt'",
root,
);
sh("echo worktree > f.txt", wt.dir);
sh("git add -A && git commit -qm 'task 05 work'", wt.dir);
const fin = finalizeCommittedWorktrees(root, STATE_DIR, PRD_KEY, ["05"]);
expect(fin.finalized).toEqual([]);
expect(fin.conflicts["05"]).toBeTruthy();
// worktree preserved for manual resolution
expect(fs.existsSync(wt.dir)).toBe(true);
});
});
describe("createWorktree resume reuse under symlinked paths", () => {
it("reuses an existing worktree when the project path contains a symlink", () => {
// macOS /tmp → /private/tmp style symlink: git porcelain reports the
// REAL path, path.join keeps the literal one. Reuse must still match.
const realBase = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-wt-real-"));
const link = path.join(realBase, "link");
fs.mkdirSync(path.join(realBase, "repo"));
fs.symlinkSync(path.join(realBase, "repo"), link);
const symRoot = link;
makeRepo(symRoot);
// Sanity: this is genuinely a symlink situation.
expect(fs.realpathSync(symRoot)).not.toBe(symRoot);
const wt = createWorktree(
symRoot,
STATE_DIR,
"01",
PRD_KEY,
undefined,
"task one",
)!;
commitInWorktree(wt, "a.txt", "task 01 work");
// Resume: createWorktree again must REUSE the registered worktree
// (same dir), not fail and fall through to the main repo.
const reused = createWorktree(
symRoot,
STATE_DIR,
"01",
PRD_KEY,
undefined,
"task one",
)!;
expect(reused.dir).toBe(fs.realpathSync(wt.dir));
const fin = finalizeCommittedWorktrees(symRoot, STATE_DIR, PRD_KEY, ["01"]);
expect(fin.finalized).toEqual(["01"]);
expect(masterHasFile(fs.realpathSync(symRoot), "a.txt")).toBe(true);
});
});
describe("worktreeHasPreservableWork", () => {
it("returns true for a worktree with committed work ahead of main", () => {
const wt = createWorktree(
root,
STATE_DIR,
"01",
PRD_KEY,
undefined,
"task one",
)!;
commitInWorktree(wt, "a.txt", "task 01 work");
expect(worktreeHasPreservableWork(root, wt)).toBe(true);
});
it("returns true for a worktree with uncommitted changes", () => {
const wt = createWorktree(
root,
STATE_DIR,
"01",
PRD_KEY,
undefined,
"task one",
)!;
sh("echo x > junk.txt", wt.dir);
expect(worktreeHasPreservableWork(root, wt)).toBe(true);
});
it("returns false for an empty fresh worktree", () => {
const wt = createWorktree(
root,
STATE_DIR,
"01",
PRD_KEY,
undefined,
"task one",
)!;
expect(worktreeHasPreservableWork(root, wt)).toBe(false);
});
});