304 lines
8.6 KiB
TypeScript
304 lines
8.6 KiB
TypeScript
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, it } from "vitest";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { deriveState } from "../state.js";
|
|
|
|
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
|
|
|
function createFixtureBase(): string {
|
|
const base = mkdtempSync(join(tmpdir(), "sf-draft-test-"));
|
|
mkdirSync(join(base, ".sf", "milestones"), { recursive: true });
|
|
return base;
|
|
}
|
|
|
|
function writeContextDraft(base: string, mid: string, content: string): void {
|
|
const dir = join(base, ".sf", "milestones", mid);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, `${mid}-CONTEXT-DRAFT.md`), content);
|
|
}
|
|
|
|
function writeContext(base: string, mid: string, content: string): void {
|
|
const dir = join(base, ".sf", "milestones", mid);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, `${mid}-CONTEXT.md`), content);
|
|
}
|
|
|
|
function writeRoadmap(base: string, mid: string, content: string): void {
|
|
const dir = join(base, ".sf", "milestones", mid);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, `${mid}-ROADMAP.md`), content);
|
|
}
|
|
|
|
function writePlan(
|
|
base: string,
|
|
mid: string,
|
|
sid: string,
|
|
content: string,
|
|
): void {
|
|
const dir = join(base, ".sf", "milestones", mid, "slices", sid);
|
|
mkdirSync(join(dir, "tasks"), { recursive: true });
|
|
writeFileSync(join(dir, "tasks", "T01-PLAN.md"), "# T01 Plan\n");
|
|
writeFileSync(join(dir, `${sid}-PLAN.md`), content);
|
|
}
|
|
|
|
function writeMilestoneSummary(
|
|
base: string,
|
|
mid: string,
|
|
content: string,
|
|
): void {
|
|
const dir = join(base, ".sf", "milestones", mid);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
|
|
}
|
|
|
|
function writeMilestoneValidation(base: string, mid: string): void {
|
|
const dir = join(base, ".sf", "milestones", mid);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(
|
|
join(dir, `${mid}-VALIDATION.md`),
|
|
`---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.`,
|
|
);
|
|
}
|
|
|
|
function cleanup(base: string): void {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
describe("deriveState draft-aware", () => {
|
|
it("CONTEXT-DRAFT.md only → needs-discussion", async () => {
|
|
const base = createFixtureBase();
|
|
try {
|
|
writeContextDraft(
|
|
base,
|
|
"M001",
|
|
"# Draft Context\n\nSeed discussion material.",
|
|
);
|
|
|
|
const state = await deriveState(base);
|
|
|
|
assert.strictEqual(state.phase, "needs-discussion");
|
|
assert.strictEqual(state.activeMilestone?.id, "M001");
|
|
assert.strictEqual(state.activeSlice, null);
|
|
assert.strictEqual(state.activeTask, null);
|
|
assert.strictEqual(state.registry[0]?.status, "active");
|
|
assert.ok(
|
|
state.nextAction.includes("Discuss"),
|
|
"nextAction mentions Discuss",
|
|
);
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
|
|
it("CONTEXT.md only → pre-planning (unchanged)", async () => {
|
|
const base = createFixtureBase();
|
|
try {
|
|
writeContext(
|
|
base,
|
|
"M001",
|
|
"---\ntitle: Full Context\n---\n\n# Full Context\n\nReady for planning.",
|
|
);
|
|
|
|
const state = await deriveState(base);
|
|
|
|
assert.strictEqual(state.phase, "pre-planning");
|
|
assert.strictEqual(state.activeMilestone?.id, "M001");
|
|
assert.strictEqual(state.activeSlice, null);
|
|
assert.strictEqual(state.activeTask, null);
|
|
assert.strictEqual(state.registry[0]?.status, "active");
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
|
|
it("both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins", async () => {
|
|
const base = createFixtureBase();
|
|
try {
|
|
writeContext(
|
|
base,
|
|
"M001",
|
|
"---\ntitle: Full Context\n---\n\n# Full Context\n\nReady.",
|
|
);
|
|
writeContextDraft(base, "M001", "# Draft\n\nThis should be ignored.");
|
|
|
|
const state = await deriveState(base);
|
|
|
|
assert.strictEqual(state.phase, "pre-planning");
|
|
assert.strictEqual(state.activeMilestone?.id, "M001");
|
|
assert.strictEqual(state.registry[0]?.status, "active");
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
|
|
it("M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion", async () => {
|
|
const base = createFixtureBase();
|
|
try {
|
|
writeRoadmap(
|
|
base,
|
|
"M001",
|
|
`# M001: First Milestone
|
|
|
|
**Vision:** Already done.
|
|
|
|
## Slices
|
|
|
|
- [x] **S01: Done** \`risk:low\` \`depends:[]\`
|
|
> After this: Done.
|
|
`,
|
|
);
|
|
writeMilestoneValidation(base, "M001");
|
|
writeMilestoneSummary(
|
|
base,
|
|
"M001",
|
|
"# M001 Summary\n\nFirst milestone complete.",
|
|
);
|
|
|
|
writeContextDraft(base, "M002", "# Draft for M002\n\nSeed material.");
|
|
|
|
const state = await deriveState(base);
|
|
|
|
assert.strictEqual(state.phase, "needs-discussion");
|
|
assert.strictEqual(state.activeMilestone?.id, "M002");
|
|
assert.strictEqual(state.activeSlice, null);
|
|
assert.strictEqual(state.registry.length, 2);
|
|
assert.strictEqual(state.registry[0]?.status, "complete");
|
|
assert.strictEqual(state.registry[1]?.status, "active");
|
|
assert.strictEqual(state.progress?.milestones?.done, 1);
|
|
assert.strictEqual(state.progress?.milestones?.total, 2);
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
|
|
it("multi-milestone: M001 complete, M002 draft, M003 pending", async () => {
|
|
const base = createFixtureBase();
|
|
try {
|
|
writeRoadmap(
|
|
base,
|
|
"M001",
|
|
`# M001: First
|
|
|
|
**Vision:** Done.
|
|
|
|
## Slices
|
|
|
|
- [x] **S01: Done** \`risk:low\` \`depends:[]\`
|
|
> After this: Done.
|
|
`,
|
|
);
|
|
writeMilestoneValidation(base, "M001");
|
|
writeMilestoneSummary(base, "M001", "# M001 Summary\n\nComplete.");
|
|
|
|
writeContextDraft(base, "M002", "# M002 Draft\n\nSeed.");
|
|
|
|
mkdirSync(join(base, ".sf", "milestones", "M003"), { recursive: true });
|
|
writeFileSync(
|
|
join(base, ".sf", "milestones", "M003", "M003-CONTEXT.md"),
|
|
"# M003\n\nPending milestone.",
|
|
);
|
|
|
|
const state = await deriveState(base);
|
|
|
|
assert.strictEqual(state.phase, "needs-discussion");
|
|
assert.strictEqual(state.activeMilestone?.id, "M002");
|
|
assert.strictEqual(state.registry.length, 3);
|
|
assert.strictEqual(state.registry[0]?.status, "complete");
|
|
assert.strictEqual(state.registry[1]?.status, "active");
|
|
assert.strictEqual(state.registry[2]?.status, "pending");
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
|
|
it("milestone with ROADMAP + CONTEXT-DRAFT → ROADMAP takes precedence", async () => {
|
|
const base = createFixtureBase();
|
|
try {
|
|
writeRoadmap(
|
|
base,
|
|
"M001",
|
|
`# M001: Active Milestone
|
|
|
|
**Vision:** In progress.
|
|
|
|
## Slices
|
|
|
|
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
|
|
> After this: First slice done.
|
|
`,
|
|
);
|
|
writeContextDraft(
|
|
base,
|
|
"M001",
|
|
"# Draft\n\nThis should be ignored — roadmap exists.",
|
|
);
|
|
|
|
writePlan(
|
|
base,
|
|
"M001",
|
|
"S01",
|
|
`# S01: First Slice
|
|
|
|
**Goal:** Do something.
|
|
|
|
## Tasks
|
|
|
|
- [ ] **T01: First Task** \`est:30m\`
|
|
`,
|
|
);
|
|
|
|
const state = await deriveState(base);
|
|
|
|
assert.strictEqual(state.phase, "executing");
|
|
assert.strictEqual(state.activeMilestone?.id, "M001");
|
|
assert.strictEqual(state.activeSlice?.id, "S01");
|
|
assert.strictEqual(state.activeTask?.id, "T01");
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
|
|
it("empty milestone dir (ghost) → skipped, pre-planning", async () => {
|
|
const base = createFixtureBase();
|
|
try {
|
|
mkdirSync(join(base, ".sf", "milestones", "M001"), { recursive: true });
|
|
|
|
const state = await deriveState(base);
|
|
|
|
assert.strictEqual(state.phase, "pre-planning");
|
|
assert.strictEqual(state.activeMilestone, null);
|
|
assert.strictEqual(state.registry.length, 0);
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
|
|
it("CONTEXT-DRAFT on non-active milestone → pending", async () => {
|
|
const base = createFixtureBase();
|
|
try {
|
|
mkdirSync(join(base, ".sf", "milestones", "M001"), { recursive: true });
|
|
writeFileSync(
|
|
join(base, ".sf", "milestones", "M001", "M001-CONTEXT.md"),
|
|
"# M001\n\nFirst milestone.",
|
|
);
|
|
|
|
writeContextDraft(base, "M002", "# M002 Draft\n\nSeed.");
|
|
|
|
const state = await deriveState(base);
|
|
|
|
assert.strictEqual(state.phase, "pre-planning");
|
|
assert.strictEqual(state.activeMilestone?.id, "M001");
|
|
assert.strictEqual(state.registry[0]?.status, "active");
|
|
assert.strictEqual(state.registry[1]?.status, "pending");
|
|
} finally {
|
|
cleanup(base);
|
|
}
|
|
});
|
|
});
|