initial import: @mikefreno/omp-ralpi (omp port)
This commit is contained in:
82
tests/commit-range-diff.test.ts
Normal file
82
tests/commit-range-diff.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
674
tests/dag-construction.test.ts
Normal file
674
tests/dag-construction.test.ts
Normal 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
237
tests/diff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
96
tests/gitignore-hygiene.test.ts
Normal file
96
tests/gitignore-hygiene.test.ts
Normal 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
30
tests/helpers.ts
Normal 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
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
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
521
tests/parser-phased.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
82
tests/progress-multiprd.test.ts
Normal file
82
tests/progress-multiprd.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
87
tests/resume-stats.test.ts
Normal file
87
tests/resume-stats.test.ts
Normal 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
303
tests/review-prompt.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
53
tests/review-severity.test.ts
Normal file
53
tests/review-severity.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
265
tests/worktree-resume.test.ts
Normal file
265
tests/worktree-resume.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user