import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderStateMd } from "../../src/application/sync/generate-state.js"; import { SQLiteStateAdapter } from "../../src/infrastructure/adapters/sqlite/sqlite-state.adapter.js"; /** * Invariant property tests for Phase D hardening. * * 1. DB <-> STATE.md consistency at writer exit. * 2. No held lock after any command exits (tested via back-to-back calls). * 3. Reconcile renderer failure on a read path never surfaces PARTIAL_SUCCESS * in the reader's response and leaves STATE.md unchanged on disk. */ const { mockClosableStateStores, mockCreateBranch } = vi.hoisted(() => { return { mockClosableStateStores: {} as { db: unknown; sliceStore: unknown; milestoneStore: unknown; taskStore: unknown; projectStore: unknown; dependencyStore: unknown; sliceDependencyStore: unknown; sessionStore: unknown; reviewStore: unknown; journalRepository: unknown; close: () => void; checkpoint: () => void; }, mockCreateBranch: vi.fn<(name: string, start: string) => Promise>(), }; }); vi.mock("../../src/infrastructure/adapters/sqlite/create-state-stores.js", () => ({ createClosableStateStoresUnchecked: vi.fn(() => mockClosableStateStores), })); vi.mock("../../src/infrastructure/adapters/git/git-cli.adapter.js", () => ({ GitCliAdapter: class { createBranch(name: string, start: string): Promise { return mockCreateBranch(name, start); } }, })); // Silence warn logs so test output stays clean. vi.mock("../../src/infrastructure/adapters/logging/warn.js", () => ({ tffWarn: vi.fn(), })); const sha256 = (s: string): string => createHash("sha256").update(s).digest("hex"); function installStores(adapter: SQLiteStateAdapter): void { mockClosableStateStores.db = adapter; mockClosableStateStores.sliceStore = adapter; mockClosableStateStores.milestoneStore = adapter; mockClosableStateStores.taskStore = adapter; mockClosableStateStores.projectStore = adapter; mockClosableStateStores.dependencyStore = adapter; mockClosableStateStores.sliceDependencyStore = adapter; mockClosableStateStores.sessionStore = adapter; mockClosableStateStores.reviewStore = adapter; mockClosableStateStores.journalRepository = adapter; mockClosableStateStores.close = () => {}; mockClosableStateStores.checkpoint = () => adapter.checkpoint(); } function seedProject(): { adapter: SQLiteStateAdapter; milestoneId: string; sliceId: string } { const adapter = SQLiteStateAdapter.createInMemory(); adapter.init(); adapter.saveProject({ name: "P" }); const ms = adapter.createMilestone({ number: 1, name: "M" }); if (!ms.ok) throw new Error("milestone create failed"); const sl = adapter.createSlice({ milestoneId: ms.data.id, number: 1, title: "Test Slice" }); if (!sl.ok) throw new Error("slice create failed"); return { adapter, milestoneId: ms.data.id, sliceId: sl.data.id }; } interface WarningLike { code?: string; context?: { pendingEffect?: unknown }; } function hasStateMdPartialSuccess(warnings: unknown): boolean { if (!Array.isArray(warnings)) return false; return warnings.some((w: WarningLike) => { if (w?.code !== "PARTIAL_SUCCESS") return false; const pending = w?.context?.pendingEffect; return typeof pending === "string" && pending.includes("STATE.md"); }); } function activeMilestoneId(adapter: SQLiteStateAdapter): string { const all = adapter.listMilestones(); if (!all.ok) throw new Error("listMilestones failed"); const active = all.data.find((m) => m.status !== "closed") ?? all.data[0]; if (!active) throw new Error("no milestone"); return active.id; } function assertStateConsistentOrWarned( adapter: SQLiteStateAdapter, repoRoot: string, warnings: unknown, ): void { const mid = activeMilestoneId(adapter); const rendered = renderStateMd( { milestoneId: mid }, { milestoneStore: adapter, sliceStore: adapter, taskStore: adapter }, ); if (!rendered.ok) throw new Error(`render failed: ${rendered.error.message}`); const stateMdPath = join(repoRoot, ".tff", "STATE.md"); const fileExists = existsSync(stateMdPath); if (hasStateMdPartialSuccess(warnings)) { // Writer flagged STATE.md as a pending effect; invariant satisfied. return; } // No warning → file must match rendered checksum. expect(fileExists).toBe(true); const onDisk = readFileSync(stateMdPath, "utf8"); expect(sha256(onDisk)).toBe(sha256(rendered.data)); } let repo: string; let prevCwd: string; beforeEach(() => { prevCwd = process.cwd(); repo = mkdtempSync(join(tmpdir(), "tff-invariants-")); mkdirSync(join(repo, ".tff"), { recursive: true }); process.chdir(repo); mockCreateBranch.mockResolvedValue(); }); afterEach(() => { process.chdir(prevCwd); rmSync(repo, { recursive: true, force: true }); vi.resetModules(); vi.clearAllMocks(); }); // --------------------------------------------------------------------------- // Invariant 1: DB <-> STATE.md consistency at writer exit. // --------------------------------------------------------------------------- describe("invariant 1: DB<->STATE.md consistency after writer exits", () => { it("slice:transition — STATE.md checksum equals renderStateMd OR warning flags STATE.md", async () => { const { adapter } = seedProject(); installStores(adapter); const { sliceTransitionCmd } = await import("../../src/cli/commands/slice-transition.cmd.js"); const raw = await sliceTransitionCmd(["--slice-id", "M01-S01", "--status", "discussing"]); const result = JSON.parse(raw); expect(result.ok).toBe(true); assertStateConsistentOrWarned(adapter, repo, result.warnings); }); // slice:create and milestone:create now stage STATE.md atomically inside // their transaction alongside PLAN.md / REQUIREMENTS.md. The strict // writer-exit invariant (DB↔STATE.md checksum match OR PARTIAL_SUCCESS // warning naming STATE.md) holds for these writers. it("slice:create — STATE.md checksum equals renderStateMd OR warning flags STATE.md", async () => { const { adapter } = seedProject(); installStores(adapter); const { sliceCreateCmd } = await import("../../src/cli/commands/slice-create.cmd.js"); const raw = await sliceCreateCmd(["--title", "Another slice", "--milestone-id", "M01"]); const result = JSON.parse(raw); expect(result.ok).toBe(true); assertStateConsistentOrWarned(adapter, repo, result.warnings); }); it("milestone:create — STATE.md checksum equals renderStateMd OR warning flags STATE.md", async () => { const adapter = SQLiteStateAdapter.createInMemory(); adapter.init(); adapter.saveProject({ name: "P" }); installStores(adapter); const { milestoneCreateCmd } = await import("../../src/cli/commands/milestone-create.cmd.js"); const raw = await milestoneCreateCmd(["--name", "First milestone"]); const result = JSON.parse(raw); expect(result.ok).toBe(true); assertStateConsistentOrWarned(adapter, repo, result.warnings); }); // Weaker but real: after a reader runs, STATE.md reconciles with DB. // This demonstrates the invariant holds at the system level when reads // and writes are composed, which is the actual operational contract. it("slice:create followed by a reader — STATE.md matches renderStateMd", async () => { const { adapter } = seedProject(); installStores(adapter); const { sliceCreateCmd } = await import("../../src/cli/commands/slice-create.cmd.js"); const createRaw = await sliceCreateCmd(["--title", "Another slice", "--milestone-id", "M01"]); expect(JSON.parse(createRaw).ok).toBe(true); // Reader reconciles STATE.md. const { sliceListCmd } = await import("../../src/cli/commands/slice-list.cmd.js"); const listRaw = await sliceListCmd([]); const listResult = JSON.parse(listRaw); expect(listResult.ok).toBe(true); assertStateConsistentOrWarned(adapter, repo, listResult.warnings); }); it("milestone:create followed by a reader — STATE.md matches renderStateMd", async () => { const adapter = SQLiteStateAdapter.createInMemory(); adapter.init(); adapter.saveProject({ name: "P" }); installStores(adapter); const { milestoneCreateCmd } = await import("../../src/cli/commands/milestone-create.cmd.js"); const createRaw = await milestoneCreateCmd(["--name", "First milestone"]); expect(JSON.parse(createRaw).ok).toBe(true); const { milestoneListCmd } = await import("../../src/cli/commands/milestone-list.cmd.js"); const listRaw = await milestoneListCmd([]); const listResult = JSON.parse(listRaw); expect(listResult.ok).toBe(true); assertStateConsistentOrWarned(adapter, repo, listResult.warnings); }); }); // --------------------------------------------------------------------------- // Invariant 2: No held lock after exit. // // We cannot inspect the sync-lock path directly from outside withSyncLock // without new production code (lockPath is an internal helper argument). The // production writers tested here are structured on withTransaction, not // withSyncLock, so lockfile.check has no applicable target at the cmd layer. // // Weaker but testable signal: run two back-to-back invocations of the same // writer. If the first left a lock or otherwise poisoned global state, the // second would fail. Both returning ok:true proves no held resource blocked // the second call. // --------------------------------------------------------------------------- describe("invariant 2: no held lock (back-to-back writer invocations succeed)", () => { it("slice:transition followed by slice:transition both succeed", async () => { const { adapter } = seedProject(); // Add a second slice so we can transition it after the first. const sl2 = adapter.createSlice({ milestoneId: activeMilestoneId(adapter), number: 2, title: "Second", }); if (!sl2.ok) throw new Error("second slice create failed"); installStores(adapter); const { sliceTransitionCmd } = await import("../../src/cli/commands/slice-transition.cmd.js"); const r1 = JSON.parse( await sliceTransitionCmd(["--slice-id", "M01-S01", "--status", "discussing"]), ); expect(r1.ok).toBe(true); const r2 = JSON.parse( await sliceTransitionCmd(["--slice-id", "M01-S02", "--status", "discussing"]), ); expect(r2.ok).toBe(true); }); it("slice:create followed by slice:create both succeed", async () => { const { adapter } = seedProject(); installStores(adapter); const { sliceCreateCmd } = await import("../../src/cli/commands/slice-create.cmd.js"); const r1 = JSON.parse(await sliceCreateCmd(["--title", "S2", "--milestone-id", "M01"])); expect(r1.ok).toBe(true); const r2 = JSON.parse(await sliceCreateCmd(["--title", "S3", "--milestone-id", "M01"])); expect(r2.ok).toBe(true); }); it("milestone:create followed by milestone:create both succeed", async () => { const adapter = SQLiteStateAdapter.createInMemory(); adapter.init(); adapter.saveProject({ name: "P" }); installStores(adapter); const { milestoneCreateCmd } = await import("../../src/cli/commands/milestone-create.cmd.js"); const r1 = JSON.parse(await milestoneCreateCmd(["--name", "First"])); expect(r1.ok).toBe(true); const r2 = JSON.parse(await milestoneCreateCmd(["--name", "Second"])); expect(r2.ok).toBe(true); }); // The truly strong invariant — lockfile.check(sync-lock-path) === false — // is not testable from the cmd layer without new production code exposing // the lock path. See task scene-setting for rationale. it.skip( "STRONG form: proper-lockfile.check(lockPath) is false after exit — skipped, no accessor", ); }); // --------------------------------------------------------------------------- // Invariant 3: Reconcile renderer failure on a read path leaves no warning. // --------------------------------------------------------------------------- describe("invariant 3: reconcile render failure on read has no PARTIAL_SUCCESS warning", () => { it("slice:list — renderStateMd throws during reconcile; response ok, no warning, file unchanged", async () => { const { adapter } = seedProject(); installStores(adapter); const stateMdPath = join(repo, ".tff", "STATE.md"); const stashed = "STASHED STATE (should remain untouched on render failure)"; writeFileSync(stateMdPath, stashed); const before = sha256(readFileSync(stateMdPath, "utf8")); // Monkey-patch the generate-state module so renderStateMd throws. const genMod = await import("../../src/application/sync/generate-state.js"); const spy = vi.spyOn(genMod, "renderStateMd").mockImplementation(() => { throw new Error("injected renderer failure"); }); const { sliceListCmd } = await import("../../src/cli/commands/slice-list.cmd.js"); const raw = await sliceListCmd([]); const result = JSON.parse(raw); spy.mockRestore(); expect(result.ok).toBe(true); // Reader must NOT leak a PARTIAL_SUCCESS on its response. const warnings = result.warnings; if (Array.isArray(warnings)) { const anyPartial = warnings.some((w: WarningLike) => w?.code === "PARTIAL_SUCCESS"); expect(anyPartial).toBe(false); } else { expect(warnings).toBeUndefined(); } // STATE.md on disk unchanged. const after = sha256(readFileSync(stateMdPath, "utf8")); expect(after).toBe(before); }); });