1213 lines
48 KiB
TypeScript
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");
|
|
});
|
|
});
|