singularity-forge/src/resources/extensions/sf/tests/integration/state-machine-edge-cases.test.ts

1213 lines
48 KiB
TypeScript

/**
* state-machine-edge-cases.test.ts — Gap-filling tests for the SF state
* machine covering failure modes, boundary conditions, and edge cases NOT
* covered by the existing state-machine-live-validation.test.ts suite.
*
* Coverage gaps filled:
* 1. State derivation failures (file deletion races, partial DB, cache staleness,
* corrupt files, 0-slice ROADMAP)
* 2. Transition boundary failures (mid-transition mutation, cascading blockers,
* multi-level milestone deps, blocked→unblocked recovery)
* 3. Dispatch failures (null activeSlice, evaluating-gates without config,
* unhandled phase, missing task plan recovery)
* 4. Completion & verification failures (unparseable verdict, needs-remediation
* blocks completion, missing SUMMARY blocks validation, UAT verdict gate,
* replan loop cap)
*/
// SF State Machine Edge Case Tests
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
rmSync,
existsSync,
unlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// ── DB layer ──────────────────────────────────────────────────────────────
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
getTask,
getSlice,
getMilestone,
getSliceTasks,
getMilestoneSlices,
updateTaskStatus,
updateSliceStatus,
updateMilestoneStatus,
insertReplanHistory,
getReplanHistory,
insertGateRow,
getPendingGates,
} from "../../sf-db.ts";
// ── State derivation ──────────────────────────────────────────────────────
import {
deriveState,
deriveStateFromDb,
invalidateStateCache,
isGhostMilestone,
isValidationTerminal,
} from "../../state.ts";
// ── Status guards ─────────────────────────────────────────────────────────
import { isClosedStatus } from "../../status-guards.ts";
// ── Cache invalidation ───────────────────────────────────────────────────
import { invalidateAllCaches } from "../../cache.ts";
// ── Dispatch ─────────────────────────────────────────────────────────────
import {
resolveDispatch,
DISPATCH_RULES,
getDispatchRuleNames,
} from "../../auto-dispatch.ts";
import type { DispatchContext, DispatchAction } from "../../auto-dispatch.ts";
// ── Verdict parser ──────────────────────────────────────────────────────
import {
extractVerdict,
isAcceptableUatVerdict,
isValidMilestoneVerdict,
} from "../../verdict-parser.ts";
// ── Path helpers ─────────────────────────────────────────────────────────
import { clearPathCache } from "../../paths.ts";
// ═══════════════════════════════════════════════════════════════════════════
// Fixture Helpers
// ═══════════════════════════════════════════════════════════════════════════
function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), "sf-edge-cases-"));
}
/**
* Create a standard .sf/ fixture with M001 containing S01 (2 tasks) and S02 (1 task).
* Same structure as state-machine-live-validation.test.ts for consistency.
*/
function createFullFixture(): string {
const base = makeTempDir();
const sfDir = join(base, ".sf");
const m001Dir = join(sfDir, "milestones", "M001");
const s01Dir = join(m001Dir, "slices", "S01");
const s01Tasks = join(s01Dir, "tasks");
const s02Dir = join(m001Dir, "slices", "S02");
const s02Tasks = join(s02Dir, "tasks");
mkdirSync(s01Tasks, { recursive: true });
mkdirSync(s02Tasks, { recursive: true });
writeFileSync(
join(m001Dir, "M001-CONTEXT.md"),
[
"# M001: Edge Case Milestone",
"",
"## Purpose",
"Test state machine edge cases.",
].join("\n"),
);
writeFileSync(
join(m001Dir, "M001-ROADMAP.md"),
[
"# M001: Edge Case Milestone",
"",
"## Vision",
"Prove edge case correctness.",
"",
"## Success Criteria",
"- All edge cases handled",
"",
"## Slices",
"",
"- [ ] **S01: First Feature** `risk:low` `depends:[]`",
" - After this: First feature proven.",
"",
"- [ ] **S02: Second Feature** `risk:low` `depends:[]`",
" - After this: Second feature proven.",
"",
"## Boundary Map",
"",
"| From | To | Produces | Consumes |",
"|------|----|----------|----------|",
"| S01 | terminal | feature-a | nothing |",
"| S02 | terminal | feature-b | nothing |",
].join("\n"),
);
writeFileSync(
join(s01Dir, "S01-PLAN.md"),
[
"# S01: First Feature",
"",
"**Goal:** Implement first feature.",
"",
"## Tasks",
"",
"- [ ] **T01: Implementation** `est:30m`",
" - Do: Build it",
" - Verify: Run tests",
"",
"- [ ] **T02: Testing** `est:30m`",
" - Do: Write tests",
" - Verify: Run tests",
].join("\n"),
);
writeFileSync(join(s01Tasks, "T01-PLAN.md"), "# T01 Plan\nImplement.\n");
writeFileSync(join(s01Tasks, "T02-PLAN.md"), "# T02 Plan\nTest.\n");
writeFileSync(
join(s02Dir, "S02-PLAN.md"),
[
"# S02: Second Feature",
"",
"**Goal:** Implement second feature.",
"",
"## Tasks",
"",
"- [ ] **T01: Implementation** `est:30m`",
" - Do: Build it",
" - Verify: Run tests",
].join("\n"),
);
writeFileSync(join(s02Tasks, "T01-PLAN.md"), "# T01 Plan\nBuild.\n");
return base;
}
/**
* Create a multi-milestone fixture with M001 → M002 → M003 dependency chain.
*/
function createMultiMilestoneFixture(): string {
const base = makeTempDir();
const sfDir = join(base, ".sf");
for (const mid of ["M001", "M002", "M003"]) {
const mDir = join(sfDir, "milestones", mid);
const sDir = join(mDir, "slices", "S01", "tasks");
mkdirSync(sDir, { recursive: true });
writeFileSync(
join(mDir, `${mid}-CONTEXT.md`),
`# ${mid}: Milestone ${mid.slice(-1)}\n\n## Purpose\nTest deps.\n`,
);
writeFileSync(
join(mDir, `${mid}-ROADMAP.md`),
[
`# ${mid}: Milestone ${mid.slice(-1)}`,
"",
"## Vision",
"Test dependency chains.",
"",
"## Success Criteria",
"- Works",
"",
"## Slices",
"",
"- [ ] **S01: Only Slice** `risk:low` `depends:[]`",
" - After this: Done.",
"",
"## Boundary Map",
"",
"| From | To | Produces | Consumes |",
"|------|----|----------|----------|",
"| S01 | terminal | output | nothing |",
].join("\n"),
);
writeFileSync(
join(mDir, "slices", "S01", "S01-PLAN.md"),
[
"# S01: Only Slice",
"",
"**Goal:** Do the thing.",
"",
"## Tasks",
"",
"- [ ] **T01: Task** `est:30m`",
" - Do: Implement",
" - Verify: Run tests",
].join("\n"),
);
writeFileSync(join(sDir, "T01-PLAN.md"), "# T01 Plan\nDo it.\n");
}
return base;
}
function buildDispatchCtx(
base: string,
mid: string,
stateOverrides: Partial<import("../../types.ts").SFState> = {},
): DispatchContext {
return {
basePath: base,
mid,
midTitle: `${mid} Test`,
state: {
activeMilestone: { id: mid, title: `${mid} Test` },
activeSlice: null,
activeTask: null,
phase: "executing",
recentDecisions: [],
blockers: [],
nextAction: "",
registry: [],
requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 },
progress: { milestones: { done: 0, total: 1 } },
...stateOverrides,
},
prefs: undefined,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Test Suite
// ═══════════════════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────────────
// SECTION 1: State Derivation Failure Modes
// ─────────────────────────────────────────────────────────────────────────
describe("state derivation failures", () => {
let base: string;
afterEach(() => {
try { closeDatabase(); } catch { /* may not be open */ }
if (base) rmSync(base, { recursive: true, force: true });
});
test("file deleted between deriveState calls produces consistent result", async () => {
// Simulates race condition: PLAN file exists on first derive, deleted before second
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
invalidateAllCaches();
const stateBefore = await deriveStateFromDb(base);
assert.equal(stateBefore.phase, "executing");
// Delete the task plan file mid-flow
const planPath = join(base, ".sf", "milestones", "M001", "slices", "S01", "tasks", "T01-PLAN.md");
if (existsSync(planPath)) unlinkSync(planPath);
invalidateAllCaches();
const stateAfter = await deriveStateFromDb(base);
// State machine should still function — either executing (DB says task exists)
// or planning (missing plan file triggers replan). Should NOT throw.
assert.ok(
["executing", "planning"].includes(stateAfter.phase),
`expected executing or planning after plan deletion, got: ${stateAfter.phase}`,
);
});
test("partial DB write: milestone inserted but no slices → pre-planning", async () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Test\n\n## Purpose\nTest.\n");
openDatabase(join(base, ".sf", "sf.db"));
// Only insert milestone — no slices, no roadmap
insertMilestone({ id: "M001", title: "Partial", status: "active" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
// No roadmap → pre-planning (milestone exists but no structure yet)
assert.equal(state.phase, "pre-planning");
assert.equal(state.activeMilestone?.id, "M001");
});
test("cache staleness: derive within TTL returns same result after DB mutation", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
// First call populates cache
invalidateStateCache();
const state1 = await deriveState(base);
assert.equal(state1.phase, "executing");
// Mutate DB WITHOUT invalidating cache
updateTaskStatus("M001", "S01", "T01", "complete", new Date().toISOString());
// Second call within 100ms TTL should return cached (stale) result
const state2 = await deriveState(base);
assert.equal(state2.phase, "executing", "cached result should still show executing");
// After explicit invalidation, should reflect the DB mutation
invalidateStateCache();
const state3 = await deriveState(base);
assert.equal(state3.phase, "summarizing", "after cache invalidation should show summarizing");
});
test("corrupt ROADMAP: binary content does not crash deriveState", async () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Corrupt\n\n## Purpose\nTest.\n");
// Write binary garbage as ROADMAP
writeFileSync(join(mDir, "M001-ROADMAP.md"), Buffer.from([0x00, 0xFF, 0xFE, 0x89, 0x50, 0x4E, 0x47]));
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Corrupt", status: "active" });
invalidateAllCaches();
// Should NOT throw — should degrade gracefully
const state = await deriveStateFromDb(base);
assert.ok(state.phase, "should produce a valid phase even with corrupt ROADMAP");
});
test("0-byte ROADMAP file is treated as no roadmap (pre-planning)", async () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Empty\n\n## Purpose\nTest.\n");
writeFileSync(join(mDir, "M001-ROADMAP.md"), "");
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Empty", status: "active" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, "pre-planning", "empty ROADMAP should result in pre-planning");
});
test("ROADMAP with no ## Slices section derives pre-planning", async () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: No Slices\n\n## Purpose\nTest.\n");
writeFileSync(
join(mDir, "M001-ROADMAP.md"),
[
"# M001: No Slices",
"",
"## Vision",
"Test zero slices.",
"",
"## Success Criteria",
"- Works",
"",
"## Slices",
"",
"## Boundary Map",
"",
"| From | To | Produces | Consumes |",
"|------|----|----------|----------|",
].join("\n"),
);
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "No Slices", status: "active" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
// 0-slice ROADMAP guard: should NOT derive validating-milestone (#2667)
assert.notEqual(
state.phase,
"validating-milestone",
"0-slice ROADMAP must NOT produce validating-milestone",
);
});
test("corrupt VALIDATION frontmatter: extractVerdict returns undefined", () => {
// Test the verdict parser directly with malformed content
assert.equal(extractVerdict(""), undefined, "empty string → undefined");
assert.equal(extractVerdict("---\n\n---\n# No verdict"), undefined, "empty frontmatter → undefined");
assert.equal(extractVerdict("---\nverdict:\n---"), undefined, "verdict with no value → undefined");
assert.equal(
extractVerdict("random text without frontmatter"),
undefined,
"no frontmatter → undefined",
);
});
test("VALIDATION with binary/garbage content: isValidationTerminal returns false", () => {
assert.equal(isValidationTerminal(""), false, "empty → not terminal");
assert.equal(isValidationTerminal("\x00\xFF\xFE"), false, "binary → not terminal");
assert.equal(
isValidationTerminal("---\ngarbage: yes\n---\nNo verdict here."),
false,
"no verdict field → not terminal",
);
});
});
// ─────────────────────────────────────────────────────────────────────────
// SECTION 2: Transition Boundary Failures
// ─────────────────────────────────────────────────────────────────────────
describe("transition boundary failures", () => {
let base: string;
afterEach(() => {
try { closeDatabase(); } catch { /* may not be open */ }
if (base) rmSync(base, { recursive: true, force: true });
});
test("mid-transition: CONTEXT.md created between derives transitions needs-discussion → pre-planning correctly", async () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
// Start with only CONTEXT-DRAFT → needs-discussion
writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\nSome draft.\n");
openDatabase(join(base, ".sf", "sf.db"));
invalidateAllCaches();
const state1 = await deriveState(base);
assert.equal(state1.phase, "needs-discussion");
// Now write the full CONTEXT (simulates discussion completion)
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Resolved\n\n## Purpose\nDone.\n");
invalidateAllCaches();
const state2 = await deriveState(base);
// Should advance to pre-planning (has context but no roadmap yet)
assert.equal(state2.phase, "pre-planning");
});
test("cascading slice dependencies: S02 depends S01, S03 depends S02 — only S01 eligible", async () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
// Create 3 slices with chain deps
for (const sid of ["S01", "S02", "S03"]) {
const sDir = join(mDir, "slices", sid, "tasks");
mkdirSync(sDir, { recursive: true });
writeFileSync(
join(mDir, "slices", sid, `${sid}-PLAN.md`),
[
`# ${sid}: Feature`,
"",
"**Goal:** Do the thing.",
"",
"## Tasks",
"",
"- [ ] **T01: Task** `est:30m`",
" - Do: Implement",
" - Verify: Run tests",
].join("\n"),
);
writeFileSync(join(sDir, "T01-PLAN.md"), "# T01 Plan\nDo it.\n");
}
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Chain\n\n## Purpose\nTest deps.\n");
writeFileSync(
join(mDir, "M001-ROADMAP.md"),
[
"# M001: Chain Deps",
"",
"## Vision",
"Test cascading.",
"",
"## Success Criteria",
"- Works",
"",
"## Slices",
"",
"- [ ] **S01: Base** `risk:low` `depends:[]`",
" - After this: Base done.",
"",
"- [ ] **S02: Middle** `risk:low` `depends:[S01]`",
" - After this: Middle done.",
"",
"- [ ] **S03: Top** `risk:low` `depends:[S02]`",
" - After this: Top done.",
"",
"## Boundary Map",
"",
"| From | To | Produces | Consumes |",
"|------|----|----------|----------|",
"| S01 | S02 | base | nothing |",
"| S02 | S03 | middle | base |",
"| S03 | terminal | top | middle |",
].join("\n"),
);
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Chain", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Base", status: "pending", depends: [] });
insertSlice({ id: "S02", milestoneId: "M001", title: "Middle", status: "pending", depends: ["S01"] });
insertSlice({ id: "S03", milestoneId: "M001", title: "Top", status: "pending", depends: ["S02"] });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
insertTask({ id: "T01", sliceId: "S03", milestoneId: "M001", status: "pending" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
// Only S01 should be active — S02 and S03 are dep-blocked
assert.equal(state.activeSlice?.id, "S01", "S01 should be the active slice (no deps)");
assert.equal(state.phase, "executing", "should be executing S01");
});
test("cascading deps: completing S01 unblocks S02 (not S03)", async () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
for (const sid of ["S01", "S02", "S03"]) {
const sDir = join(mDir, "slices", sid, "tasks");
mkdirSync(sDir, { recursive: true });
writeFileSync(
join(mDir, "slices", sid, `${sid}-PLAN.md`),
`# ${sid}\n\n**Goal:** Do.\n\n## Tasks\n\n- [ ] **T01: Task** \`est:30m\`\n - Do: Impl\n - Verify: Test\n`,
);
writeFileSync(join(sDir, "T01-PLAN.md"), `# T01 Plan\nDo it.\n`);
}
// Write slice SUMMARY for S01
writeFileSync(
join(mDir, "slices", "S01", "S01-SUMMARY.md"),
"---\n---\n# S01 Summary\nDone.\n",
);
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Chain\n\n## Purpose\nTest.\n");
writeFileSync(
join(mDir, "M001-ROADMAP.md"),
[
"# M001: Chain",
"",
"## Vision",
"Test.",
"",
"## Success Criteria",
"- Works",
"",
"## Slices",
"",
"- [x] **S01: Base** `risk:low` `depends:[]`",
" - After this: Done.",
"",
"- [ ] **S02: Middle** `risk:low` `depends:[S01]`",
" - After this: Done.",
"",
"- [ ] **S03: Top** `risk:low` `depends:[S02]`",
" - After this: Done.",
"",
"## Boundary Map",
"",
"| From | To | Produces | Consumes |",
"|------|----|----------|----------|",
"| S01 | S02 | x | nothing |",
"| S02 | S03 | y | x |",
"| S03 | terminal | z | y |",
].join("\n"),
);
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Chain", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Base", status: "complete", depends: [] });
insertSlice({ id: "S02", milestoneId: "M001", title: "Middle", status: "pending", depends: ["S01"] });
insertSlice({ id: "S03", milestoneId: "M001", title: "Top", status: "pending", depends: ["S02"] });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
insertTask({ id: "T01", sliceId: "S03", milestoneId: "M001", status: "pending" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
// S01 complete → S02 unblocked → S02 should be active
assert.equal(state.activeSlice?.id, "S02", "S02 should be active after S01 completes");
assert.equal(state.phase, "executing");
});
test("multi-milestone deps: M002 depends M001, M003 depends M002 — blocked correctly", async () => {
base = createMultiMilestoneFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "First", status: "active" });
insertMilestone({ id: "M002", title: "Second", status: "active", depends_on: ["M001"] });
insertMilestone({ id: "M003", title: "Third", status: "active", depends_on: ["M002"] });
insertSlice({ id: "S01", milestoneId: "M001", title: "S01", status: "pending" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
insertSlice({ id: "S01", milestoneId: "M002", title: "S01", status: "pending" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M002", status: "pending" });
insertSlice({ id: "S01", milestoneId: "M003", title: "S01", status: "pending" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M003", status: "pending" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
// Only M001 should be active — M002 and M003 are blocked
assert.equal(state.activeMilestone?.id, "M001", "M001 should be active (no deps)");
});
test("blocker_discovered in task transitions to replanning-slice", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", blockerDiscovered: true });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
assert.equal(state.phase, "replanning-slice", "blocker_discovered should trigger replanning");
assert.ok(state.blockers.length > 0, "should report blocker");
});
test("replan loop protection: replan already done skips replanning-slice", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", blockerDiscovered: true });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending" });
// Record that a replan was already done for this slice
insertReplanHistory({
milestoneId: "M001",
sliceId: "S01",
summary: "Already replanned once",
});
invalidateAllCaches();
const state = await deriveStateFromDb(base);
// With replan history, should NOT re-enter replanning-slice
assert.notEqual(
state.phase,
"replanning-slice",
"replan loop protection: should not re-enter replanning after replan was done",
);
});
test("blocked state: all slices have unmet deps → fallback picks slice", async () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(join(mDir, "slices", "S01", "tasks"), { recursive: true });
mkdirSync(join(mDir, "slices", "S02", "tasks"), { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\n## Purpose\nTest.\n");
writeFileSync(
join(mDir, "M001-ROADMAP.md"),
[
"# M001: Blocked",
"",
"## Vision",
"Test blocked.",
"",
"## Success Criteria",
"- Works",
"",
"## Slices",
"",
"- [ ] **S01: A** `risk:low` `depends:[S02]`",
" - After this: Done.",
"",
"- [ ] **S02: B** `risk:low` `depends:[S01]`",
" - After this: Done.",
"",
"## Boundary Map",
"",
"| From | To | Produces | Consumes |",
"|------|----|----------|----------|",
"| S01 | S02 | a | b |",
"| S02 | S01 | b | a |",
].join("\n"),
);
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Blocked", status: "active" });
// Circular deps: S01→S02 and S02→S01 — both blocked
insertSlice({ id: "S01", milestoneId: "M001", title: "A", status: "pending", depends: ["S02"] });
insertSlice({ id: "S02", milestoneId: "M001", title: "B", status: "pending", depends: ["S01"] });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "pending" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
// With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
assert.equal(state.phase, "planning", "circular deps: fallback picks a slice instead of blocking");
assert.ok(state.activeSlice !== null, "activeSlice set via fallback");
});
});
// ─────────────────────────────────────────────────────────────────────────
// SECTION 3: Dispatch Failure Modes
// ─────────────────────────────────────────────────────────────────────────
describe("dispatch failure modes", () => {
let base: string;
afterEach(() => {
try { closeDatabase(); } catch { /* may not be open */ }
if (base) rmSync(base, { recursive: true, force: true });
});
test("dispatch with null activeSlice in executing phase → stop (error)", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
const ctx = buildDispatchCtx(base, "M001", {
phase: "executing",
activeSlice: null,
activeTask: { id: "T01", title: "Task" },
});
// The "executing → execute-task (recover missing task plan)" rule checks activeSlice
// and returns missingSliceStop when null
const result = await resolveDispatch(ctx);
assert.equal(result.action, "stop", "null activeSlice in executing should stop");
});
test("dispatch for unhandled phase → stop with diagnostic", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
const ctx = buildDispatchCtx(base, "M001", {
phase: "paused" as any,
activeSlice: null,
activeTask: null,
});
const result = await resolveDispatch(ctx);
assert.equal(result.action, "stop", "unhandled phase should produce stop action");
});
test("dispatch: summarizing with null activeSlice → stop (error)", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
const ctx = buildDispatchCtx(base, "M001", {
phase: "summarizing",
activeSlice: null,
activeTask: null,
});
const result = await resolveDispatch(ctx);
assert.equal(result.action, "stop", "summarizing without activeSlice should stop");
assert.ok(
(result as any).reason?.includes("no active slice"),
"stop reason should mention missing slice",
);
});
test("dispatch: evaluating-gates without gate config → skip (gates omitted)", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "in_progress" });
const ctx = buildDispatchCtx(base, "M001", {
phase: "evaluating-gates",
activeSlice: { id: "S01", title: "First" },
activeTask: null,
});
ctx.prefs = undefined; // No prefs → gate_evaluation not enabled
const result = await resolveDispatch(ctx);
// Without gate config, the rule should skip (gates omitted)
assert.ok(
result.action === "skip" || result.action === "stop",
`evaluating-gates without config should skip or stop, got: ${result.action}`,
);
});
test("dispatch: needs-discussion → discuss-milestone dispatch", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
const ctx = buildDispatchCtx(base, "M001", {
phase: "needs-discussion",
activeSlice: null,
activeTask: null,
});
const result = await resolveDispatch(ctx);
assert.equal(result.action, "dispatch");
assert.equal((result as any).unitType, "discuss-milestone");
});
test("dispatch: incomplete milestone roadmap re-runs plan-milestone instead of missing-slice stop", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
const ctx = buildDispatchCtx(base, "M001", {
phase: "planning",
activeSlice: null,
activeTask: null,
nextAction: "Milestone M001 roadmap is incomplete (missing vision alignment meeting). Re-run plan-milestone with a weighted vision alignment meeting before execution.",
});
const result = await resolveDispatch(ctx);
assert.equal(result.action, "dispatch");
assert.equal((result as any).unitType, "plan-milestone");
assert.equal((result as any).unitId, "M001");
assert.equal((result as any).matchedRule, "planning (roadmap incomplete) → plan-milestone");
});
test("dispatch: complete phase → stop with info level", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
const ctx = buildDispatchCtx(base, "M001", {
phase: "complete",
activeSlice: null,
activeTask: null,
});
const result = await resolveDispatch(ctx);
assert.equal(result.action, "stop");
assert.equal((result as any).level, "info");
assert.ok((result as any).reason?.includes("complete"), "reason should mention completion");
});
test("dispatch rule order: first match wins for overlapping rules", () => {
const ruleNames = getDispatchRuleNames();
// Verify critical ordering constraints
const summarizeIdx = ruleNames.indexOf("summarizing → complete-slice");
const runUatIdx = ruleNames.indexOf("run-uat (post-completion)");
const uatGateIdx = ruleNames.indexOf("uat-verdict-gate (non-PASS blocks progression)");
const executeIdx = ruleNames.indexOf("executing → execute-task");
const repairIdx = ruleNames.indexOf("planning (roadmap incomplete) → plan-milestone");
const planSliceIdx = ruleNames.indexOf("planning → plan-slice");
// summarizing should come before execute-task
assert.ok(summarizeIdx < executeIdx, "summarizing rule should precede execute-task");
// run-uat should come before uat-verdict-gate
assert.ok(runUatIdx < uatGateIdx, "run-uat should precede uat-verdict-gate");
assert.ok(repairIdx < planSliceIdx, "milestone-plan repair should precede slice planning");
});
});
// ─────────────────────────────────────────────────────────────────────────
// SECTION 4: Completion & Verification Failures
// ─────────────────────────────────────────────────────────────────────────
describe("completion and verification failures", () => {
let base: string;
afterEach(() => {
try { closeDatabase(); } catch { /* may not be open */ }
if (base) rmSync(base, { recursive: true, force: true });
});
test("needs-remediation VALIDATION blocks milestone completion dispatch", async () => {
base = createFullFixture();
const mDir = join(base, ".sf", "milestones", "M001");
writeFileSync(
join(mDir, "M001-VALIDATION.md"),
[
"---",
"verdict: needs-remediation",
"remediation_round: 1",
"---",
"",
"# Validation",
"",
"Needs remediation work.",
].join("\n"),
);
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
const ctx = buildDispatchCtx(base, "M001", {
phase: "completing-milestone",
activeSlice: null,
activeTask: null,
});
const result = await resolveDispatch(ctx);
assert.equal(result.action, "stop", "needs-remediation should block completion");
assert.ok(
(result as any).reason?.includes("needs-remediation"),
"stop reason should mention needs-remediation",
);
});
test("missing slice SUMMARY blocks milestone validation dispatch", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
// Use "pending" status — closed slices (complete/done/skipped) are
// excluded from SUMMARY checks per #3620.
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "pending" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending" });
// No S01-SUMMARY.md or S02-SUMMARY.md on disk
const ctx = buildDispatchCtx(base, "M001", {
phase: "validating-milestone",
activeSlice: null,
activeTask: null,
});
const result = await resolveDispatch(ctx);
assert.equal(result.action, "stop", "missing SUMMARY files should block validation");
assert.ok(
(result as any).reason?.includes("missing SUMMARY"),
"stop reason should mention missing SUMMARY",
);
});
test("VALIDATION with pass verdict: isValidationTerminal returns true", () => {
const content = "---\nverdict: pass\nremediation_round: 0\n---\n# Pass\n";
assert.equal(isValidationTerminal(content), true);
});
test("VALIDATION with needs-attention: isValidationTerminal returns true", () => {
const content = "---\nverdict: needs-attention\n---\n# Attention\n";
assert.equal(isValidationTerminal(content), true);
});
test("VALIDATION with needs-remediation: isValidationTerminal returns true (terminal for loop prevention)", () => {
// Per #832: needs-remediation IS terminal to prevent validate-milestone loops
const content = "---\nverdict: needs-remediation\nremediation_round: 1\n---\n# Remediate\n";
assert.equal(isValidationTerminal(content), true);
});
test("UAT verdict gate: non-PASS verdict blocks progression", () => {
assert.equal(isAcceptableUatVerdict("pass", undefined), true);
assert.equal(isAcceptableUatVerdict("passed", undefined), true);
assert.equal(isAcceptableUatVerdict("fail", undefined), false);
assert.equal(isAcceptableUatVerdict("needs-remediation", undefined), false);
assert.equal(isAcceptableUatVerdict("partial", undefined), false, "partial without eligible type → not acceptable");
assert.equal(isAcceptableUatVerdict("partial", "mixed"), true, "partial with mixed type → acceptable");
assert.equal(isAcceptableUatVerdict("partial", "human-experience"), true, "partial with human-experience → acceptable");
assert.equal(isAcceptableUatVerdict("partial", "artifact-driven"), false, "partial with artifact-driven → not acceptable");
});
test("milestone validation verdict schema validation", () => {
assert.equal(isValidMilestoneVerdict("pass"), true);
assert.equal(isValidMilestoneVerdict("needs-attention"), true);
assert.equal(isValidMilestoneVerdict("needs-remediation"), true);
assert.equal(isValidMilestoneVerdict("fail"), false, "fail is not a valid milestone verdict");
assert.equal(isValidMilestoneVerdict(""), false);
assert.equal(isValidMilestoneVerdict("unknown"), false);
});
test("all slices done + no VALIDATION → validating-milestone (not completing)", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
assert.equal(
state.phase,
"validating-milestone",
"all slices done without VALIDATION should be validating-milestone",
);
});
test("all slices done + terminal VALIDATION + no SUMMARY → completing-milestone", async () => {
base = createFullFixture();
writeFileSync(
join(base, ".sf", "milestones", "M001", "M001-VALIDATION.md"),
"---\nverdict: pass\n---\n# Validation\nPassed.\n",
);
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete" });
insertTask({ id: "T01", sliceId: "S02", milestoneId: "M001", status: "complete" });
invalidateAllCaches();
const state = await deriveStateFromDb(base);
assert.equal(
state.phase,
"completing-milestone",
"terminal VALIDATION without SUMMARY should be completing-milestone",
);
});
test("extractVerdict: markdown body fallback works", () => {
// When LLM writes verdict in body instead of frontmatter (#2960)
assert.equal(extractVerdict("# Validation\n\n**Verdict:** PASS"), "pass");
assert.equal(extractVerdict("# Validation\n\n**Verdict:** ✅ PASS"), "pass");
assert.equal(extractVerdict("# Validation\n\n**Verdict** needs-remediation"), "needs-remediation");
});
test("extractVerdict: normalizes 'passed' to 'pass'", () => {
assert.equal(extractVerdict("---\nverdict: passed\n---"), "pass");
assert.equal(extractVerdict("**Verdict:** passed"), "pass");
});
test("isClosedStatus: boundary values", () => {
assert.equal(isClosedStatus("complete"), true);
assert.equal(isClosedStatus("done"), true);
assert.equal(isClosedStatus("skipped"), true);
assert.equal(isClosedStatus("active"), false);
assert.equal(isClosedStatus("pending"), false);
assert.equal(isClosedStatus("in_progress"), false);
assert.equal(isClosedStatus(""), false);
assert.equal(isClosedStatus("COMPLETE"), false, "case-sensitive: uppercase should be false");
});
});
// ─────────────────────────────────────────────────────────────────────────
// SECTION 5: Ghost Milestone Edge Cases
// ─────────────────────────────────────────────────────────────────────────
describe("ghost milestone edge cases", () => {
let base: string;
afterEach(() => {
try { closeDatabase(); } catch { /* may not be open */ }
if (base) rmSync(base, { recursive: true, force: true });
});
test("empty directory with DB row is NOT a ghost (#2921)", () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Queued", status: "active" });
assert.equal(isGhostMilestone(base, "M001"), false, "DB row means not a ghost");
});
test("empty directory with worktree is NOT a ghost (#2921)", () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
// Simulate worktree existence
mkdirSync(join(base, ".sf", "worktrees", "M001"), { recursive: true });
assert.equal(isGhostMilestone(base, "M001"), false, "worktree means not a ghost");
});
test("empty directory without DB or worktree IS a ghost", () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
assert.equal(isGhostMilestone(base, "M001"), true, "no DB, no worktree, no files → ghost");
});
test("directory with only META.json is still a ghost", () => {
base = makeTempDir();
const mDir = join(base, ".sf", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "META.json"), '{"created":"2026-01-01"}');
assert.equal(isGhostMilestone(base, "M001"), true, "META.json alone → ghost");
});
test("ghost milestones are skipped in state derivation", async () => {
base = makeTempDir();
const sfDir = join(base, ".sf", "milestones");
// M001 is ghost — empty dir
mkdirSync(join(sfDir, "M001"), { recursive: true });
// M002 is real — has CONTEXT-DRAFT
mkdirSync(join(sfDir, "M002"), { recursive: true });
writeFileSync(join(sfDir, "M002", "M002-CONTEXT-DRAFT.md"), "# Draft\nContent.\n");
invalidateAllCaches();
const state = await deriveState(base);
assert.equal(state.activeMilestone?.id, "M002", "ghost M001 skipped, M002 is active");
});
});
// ─────────────────────────────────────────────────────────────────────────
// SECTION 6: Dispatch Guard Integration
// ─────────────────────────────────────────────────────────────────────────
describe("dispatch guard integration", () => {
let base: string;
afterEach(() => {
try { closeDatabase(); } catch { /* may not be open */ }
if (base) rmSync(base, { recursive: true, force: true });
});
test("skip_milestone_validation preference writes pass-through VALIDATION", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "complete" });
// Write slice SUMMARYs so the missing SUMMARY guard doesn't fire
writeFileSync(
join(base, ".sf", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"),
"# S01 Summary\nDone.\n",
);
writeFileSync(
join(base, ".sf", "milestones", "M001", "slices", "S02", "S02-SUMMARY.md"),
"# S02 Summary\nDone.\n",
);
const ctx = buildDispatchCtx(base, "M001", {
phase: "validating-milestone",
activeSlice: null,
activeTask: null,
});
ctx.prefs = { phases: { skip_milestone_validation: true } } as any;
const result = await resolveDispatch(ctx);
assert.equal(result.action, "skip", "skip_milestone_validation should produce skip action");
// Should have written a pass-through VALIDATION file
const validationPath = join(base, ".sf", "milestones", "M001", "M001-VALIDATION.md");
assert.ok(existsSync(validationPath), "VALIDATION file should be written");
const content = readFileSync(validationPath, "utf-8");
assert.ok(content.includes("verdict: pass"), "should contain pass verdict");
assert.ok(content.includes("skipped by preference"), "should note it was skipped");
});
test("rewrite-docs circuit breaker: exceeding MAX attempts resolves all overrides", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
insertMilestone({ id: "M001", title: "Active", status: "active" });
// Write a rewrite count at the max
const runtimeDir = join(base, ".sf", "runtime");
mkdirSync(runtimeDir, { recursive: true });
writeFileSync(
join(runtimeDir, "rewrite-count.json"),
JSON.stringify({ count: 3, updatedAt: new Date().toISOString() }),
);
// Import and check
const { getRewriteCount } = await import("../../auto-dispatch.ts");
assert.equal(getRewriteCount(base), 3, "rewrite count should be 3");
});
test("replanning-slice with null activeSlice → stop (error)", async () => {
base = createFullFixture();
openDatabase(join(base, ".sf", "sf.db"));
const ctx = buildDispatchCtx(base, "M001", {
phase: "replanning-slice",
activeSlice: null,
activeTask: null,
});
const result = await resolveDispatch(ctx);
assert.equal(result.action, "stop", "replanning without activeSlice should stop");
});
});