// @vitest-environment node /* RFC-1086: Unit tests for --fast flag incremental validation in mission.validate. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-1086: initial unit tests for --fast flag — skip passed phases, cascade re-run, fallback on missing/corrupt/mismatched report. RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared */ import { test, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import os from "node:os"; const hoisted = vi.hoisted(() => ({ mockExecuteKernelPipeline: vi.fn(), mockExecuteKernelCommand: vi.fn(), mockReadMissionManifest: vi.fn(), mockResolveMissionDir: vi.fn(), mockRunOperation: vi.fn(), mockCheckDifferentKindOperation: vi.fn(), mockCommitBordbuchProjections: vi.fn(), mockComputeBuildInputHash: vi.fn(), })); function makeInterceptedIo(): ReturnType["io"] { const baseIo = createDefaultIO().io; return { ...baseIo, exec: vi.fn( async ( command: string, args: string[], opts?: { cwd?: string; timeoutMs?: number; env?: Record }, ) => { if (command === "pnpm" && args.join(" ") === "exec astro build") { return { exitCode: 0, stdout: "10 page(s)", stderr: "" }; } return baseIo.exec(command, args, opts); }, ), }; } vi.mock("@warpgogol/werkstatt-engine/fingerprint", () => ({ byteHashFile: vi.fn().mockResolvedValue("sha256:mocked"), })); vi.mock("@warpgogol/werkstatt-engine/kernel", async (importOriginal) => { const original = await importOriginal(); return { ...original, executeKernelPipeline: hoisted.mockExecuteKernelPipeline, executeKernelCommand: hoisted.mockExecuteKernelCommand, }; }); vi.mock("../../kernel/cache/cache-layer.ts", () => ({ createCacheLayer: vi.fn().mockResolvedValue({ clear: vi.fn(), close: vi.fn(), status: vi.fn().mockResolvedValue({ entries: 0 }), }), })); vi.mock("../../kernel/runtime/execute-pipeline.ts", async (importOriginal) => { const original = await importOriginal(); return { ...original, clearPipelineCacheHits: vi.fn(), }; }); vi.mock("../mission-io.ts", () => ({ readMissionManifest: hoisted.mockReadMissionManifest, writeMissionManifest: vi.fn(), resolveMissionDir: hoisted.mockResolveMissionDir, })); vi.mock("../mission-git-commit.ts", () => ({ isWorkpieceDirty: vi.fn().mockReturnValue({ dirty: false, fileCount: 0 }), investigateUntrackedFiles: vi.fn().mockReturnValue([]), commitWorkpieceIfDirty: vi.fn().mockResolvedValue(false), commitCacheCloneIfDirty: vi.fn().mockResolvedValue(false), cacheCloneCommit: vi.fn(), })); vi.mock("../../journal/runner.ts", () => ({ runOperation: hoisted.mockRunOperation, })); vi.mock("../../journal/index.ts", () => ({ checkDifferentKindOperation: hoisted.mockCheckDifferentKindOperation, })); vi.mock("../../bordbuch/bordbuch-commit.ts", () => ({ commitBordbuchProjections: hoisted.mockCommitBordbuchProjections, })); vi.mock("../../handoff/build-pipeline-helpers.ts", () => ({ runPipelinePhase: vi.fn(), computeBuildInputHash: hoisted.mockComputeBuildInputHash, writePreliminaryBuildIdentity: vi.fn(), cleanupPreliminaryBuildIdentity: vi.fn(), })); vi.mock("../snapshot-auto-regen.ts", () => ({ orchestrateSnap01Recovery: vi.fn(), bootstrapCommittedSnapshot: vi.fn().mockResolvedValue({ committed: true }), SNAPSHOT_FILENAME: "behavior.snapshot.generated.yaml", })); vi.mock("../actor-identity.ts", () => ({ resolveActor: vi.fn().mockReturnValue({ kind: "human", id: "test-operator" }), })); vi.mock("../../sternsystem/registry-io.ts", () => ({ resolveCacheClonePath: vi.fn().mockReturnValue("/tmp/mock-cache-clone"), readSystemConfig: vi.fn(), })); vi.mock("../cache-clone-gitignore.ts", () => ({ restoreCacheCloneGitignore: vi.fn(), untrackForbiddenGeneratedFiles: vi.fn(), CACHE_CLONE_GENERATED_PATTERNS: [], })); vi.mock("../workpiece-config-presence-check.ts", () => ({ checkWorkpieceConfigPresence: vi.fn().mockResolvedValue({ allPresent: true, missing: [] }), })); import { runMissionValidate } from "../mission-materialization-commands.ts"; import { createDefaultIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; const baseManifest = { id: "test-mission", systemId: "test-system", state: "open", materializedAt: "2026-01-01T00:00:00Z", brief: "test", createdAt: "2026-01-01T00:00:00Z", }; function makeContext(tmpDir: string) { return { io: makeInterceptedIo(), workspaceRoot: tmpDir, logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, }, } as any; } let tmpDir: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "test-rfc1086-")); const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); const workpieceDir = path.join(missionDir, "workpiece"); await fs.mkdir(evidenceDir, { recursive: true }); await fs.mkdir(workpieceDir, { recursive: true }); hoisted.mockExecuteKernelPipeline.mockReset(); hoisted.mockExecuteKernelCommand.mockReset(); hoisted.mockReadMissionManifest.mockReset(); hoisted.mockResolveMissionDir.mockReset(); hoisted.mockRunOperation.mockReset(); hoisted.mockCheckDifferentKindOperation.mockReset(); hoisted.mockCommitBordbuchProjections.mockReset(); hoisted.mockComputeBuildInputHash.mockReset(); hoisted.mockReadMissionManifest.mockResolvedValue(baseManifest); hoisted.mockResolveMissionDir.mockResolvedValue(missionDir); hoisted.mockCheckDifferentKindOperation.mockResolvedValue({ blocked: false }); hoisted.mockCommitBordbuchProjections.mockResolvedValue({ committed: false, filesCommitted: [] }); hoisted.mockRunOperation.mockResolvedValue({ completed: true }); hoisted.mockExecuteKernelPipeline.mockResolvedValue({ ok: true, steps: [], timing: { failedStep: null }, }); hoisted.mockComputeBuildInputHash.mockResolvedValue({ buildInputHash: "sha256:mismatch" }); }); afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); function makePreviousReport( overrides: Partial<{ missionId: string; contractFullPassed: boolean; buildSucceeded: boolean; failedSteps: Array<{ name: string; exitCode: number }>; routeCount: number; sitemapHash: string; }> = {}, ): string { const report = { schemaVersion: "1.0.0", missionId: overrides.missionId ?? "test-mission", contractFull: { passed: overrides.contractFullPassed ?? true, validators: [], }, build: { succeeded: overrides.buildSucceeded ?? true, routeCount: overrides.routeCount ?? 10, sitemapHash: overrides.sitemapHash ?? "sha256:abc123", failedSteps: overrides.failedSteps ?? [], }, distributionReused: false, fullBuildRan: true, validatedAt: "2026-09-14T00:00:00Z", }; return JSON.stringify(report, null, 2) + "\n"; } const allPassedReport = makePreviousReport(); const buildCheckFailedReport = makePreviousReport({ contractFullPassed: false, buildSucceeded: false, failedSteps: [{ name: "build.check", exitCode: 1 }], }); const buildPrepareFailedReport = makePreviousReport({ contractFullPassed: false, buildSucceeded: false, failedSteps: [{ name: "build.prepare", exitCode: 1 }], }); test("AC-4: --fast and --force together returns exit code 1 with error message", async () => { const result = await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true, force: true } } as any, makeContext(tmpDir), ); expect(result.exitCode, "exit code must be 1 for mutually exclusive flags").toBe(1); expect(result.summary, "summary must mention mutual exclusivity").toContain("mutually exclusive"); }); test("AC-5: --fast with all-passed previous report returns exit 0 and does not run steps", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), allPassedReport); const result = await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, makeContext(tmpDir), ); expect(result.exitCode ?? 0, "exit code must be 0 when all phases passed").toBe(0); expect(result.summary, "summary must mention skip").toContain("skipped"); expect( hoisted.mockRunOperation, "runOperation must NOT be called when all phases passed", ).not.toHaveBeenCalled(); }); test("AC-5: --fast with all-passed previous report does NOT overwrite validation-report.json", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), allPassedReport); await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, makeContext(tmpDir), ); const content = await fs.readFile(path.join(evidenceDir, "validation-report.json"), "utf8"); const report = JSON.parse(content); expect(report.validatedAt, "validatedAt must be unchanged — report must NOT be overwritten").toBe( "2026-09-14T00:00:00Z", ); }); test("AC-2 + AC-3: --fast with no previous report falls back to full run and warns", async () => { const warnCalls: string[] = []; const ctx = { io: makeInterceptedIo(), workspaceRoot: tmpDir, logger: { info: () => {}, warn: (msg: string) => { warnCalls.push(msg); }, error: () => {}, debug: () => {}, }, } as any; await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, ctx, ); expect( warnCalls.some((m) => m.includes("No previous validation report")), "must warn about missing previous report", ).toBe(true); expect( hoisted.mockRunOperation, "runOperation must be called for full run when no previous report", ).toHaveBeenCalledTimes(1); }); test("AC-8 + AC-9: --fast with corrupt previous report falls back to full run and warns", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), "{corrupt json!!!"); const warnCalls: string[] = []; const ctx = { io: makeInterceptedIo(), workspaceRoot: tmpDir, logger: { info: () => {}, warn: (msg: string) => { warnCalls.push(msg); }, error: () => {}, debug: () => {}, }, } as any; await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, ctx, ); expect( warnCalls.some((m) => m.includes("No previous validation report")), "must warn about corrupt (unreadable) previous report", ).toBe(true); expect( hoisted.mockRunOperation, "runOperation must be called for full run when previous report is corrupt", ).toHaveBeenCalledTimes(1); }); test("AC-10 + AC-11: --fast with mismatched missionId falls back to full run and warns", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile( path.join(evidenceDir, "validation-report.json"), makePreviousReport({ missionId: "different-mission" }), ); const warnCalls: string[] = []; const ctx = { io: makeInterceptedIo(), workspaceRoot: tmpDir, logger: { info: () => {}, warn: (msg: string) => { warnCalls.push(msg); }, error: () => {}, debug: () => {}, }, } as any; await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, ctx, ); expect( warnCalls.some((m) => m.includes("different mission")), "must warn about missionId mismatch", ).toBe(true); expect( hoisted.mockRunOperation, "runOperation must be called for full run when missionId mismatches", ).toHaveBeenCalledTimes(1); }); test("AC-6: validateCtx.fast is true when --fast is passed and previous report exists", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildCheckFailedReport); await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, makeContext(tmpDir), ); expect(hoisted.mockRunOperation, "runOperation must be called").toHaveBeenCalledTimes(1); const validateCtx = hoisted.mockRunOperation.mock.calls[0][2]; expect(validateCtx.fast, "validateCtx.fast must be true when --fast is set").toBe(true); }); test("AC-7: validateCtx.previousReport is populated from the previous report", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildCheckFailedReport); await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, makeContext(tmpDir), ); const validateCtx = hoisted.mockRunOperation.mock.calls[0][2]; expect( validateCtx.previousReport, "validateCtx.previousReport must be populated from the previous report", ).not.toBeNull(); expect( validateCtx.previousReport.missionId, "previousReport.missionId must match the report content", ).toBe("test-mission"); expect( validateCtx.previousReport.build.failedSteps.length, "previousReport must contain the failed steps from the report", ).toBe(1); }); test("AC-1: --fast with build-check failed skips build-prepare but runs build-check and downstream (cascade)", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildCheckFailedReport); // Capture the steps built by buildValidateSteps let builtSteps: any[] = []; hoisted.mockRunOperation.mockImplementation(async (_journalPath: string, op: any, ctx: any) => { builtSteps = op.steps; // Simulate running all steps to check cascade behavior for (const step of builtSteps) { if (step.run) await step.run(ctx); } return { completed: true }; }); await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, makeContext(tmpDir), ); const validateCtx = hoisted.mockRunOperation.mock.calls[0][2]; // build-prepare should be skipped (contractFull.passed was false in buildCheckFailedReport? No — contractFullPassed: false means prepare failed) // Actually in buildCheckFailedReport, contractFullPassed is false — so build-prepare will NOT be skipped // Let me fix: buildCheckFailedReport has contractFullPassed: false, which means build.prepare failed // So build-prepare will re-run, and cascade will propagate to all downstream expect(validateCtx.cascadeRerun, "cascadeRerun must be true after build-prepare re-runs").toBe( true, ); }); test("AC-1: --fast with only build-check failed skips build-prepare, re-runs build-check, cascades to astro-build and build-post", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); // build.prepare passed (contractFullPassed: true), build.check failed (failedSteps non-empty) await fs.writeFile( path.join(evidenceDir, "validation-report.json"), makePreviousReport({ contractFullPassed: true, buildSucceeded: false, failedSteps: [{ name: "build.check", exitCode: 1 }], }), ); const pipelineCallNames: string[] = []; hoisted.mockExecuteKernelPipeline.mockImplementation(async (args: any) => { pipelineCallNames.push(args.pipelineName); return { ok: true, steps: [], timing: { failedStep: null } }; }); hoisted.mockRunOperation.mockImplementation(async (_journalPath: string, op: any, ctx: any) => { for (const step of op.steps) { if (step.run) await step.run(ctx); } return { completed: true }; }); await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, makeContext(tmpDir), ); // build.prepare should be skipped (contractFullPassed: true) // build.check should re-run (failedSteps non-empty) // astro-build should re-run (cascade from build-check) // build-post should re-run (cascade from astro-build) expect( pipelineCallNames, "build.prepare must be skipped, build.check + build.post must re-run (cascade)", ).toEqual(["build.check", "build.post"]); }); test("AC-1: --fast with build-prepare failed re-runs all phases (cascade from prepare)", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildPrepareFailedReport); const pipelineCallNames: string[] = []; hoisted.mockExecuteKernelPipeline.mockImplementation(async (args: any) => { pipelineCallNames.push(args.pipelineName); return { ok: true, steps: [], timing: { failedStep: null } }; }); hoisted.mockRunOperation.mockImplementation(async (_journalPath: string, op: any, ctx: any) => { for (const step of op.steps) { if (step.run) await step.run(ctx); } return { completed: true }; }); await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, makeContext(tmpDir), ); // build.prepare re-runs (contractFullPassed: false), cascade to all expect( pipelineCallNames, "all phases must re-run when build-prepare failed (cascade from prepare)", ).toEqual(["build.prepare", "build.check", "build.post"]); }); test("RFC-1086: --fast does NOT delete validation-report.json before reading it", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), buildCheckFailedReport); await runMissionValidate( { argv: [], flags: { mission: "test-mission", fast: true } } as any, makeContext(tmpDir), ); // The previous report must still exist when --fast is active (deletion is guarded) // It will be overwritten by the new run, but it must not have been deleted BEFORE the run expect( existsSync(path.join(evidenceDir, "validation-report.json")), "validation-report.json must not be deleted before --fast reads it", ).toBe(true); }); test("RFC-1086: without --fast, validation-report.json IS deleted (RFC-1020 behavior preserved)", async () => { const missionDir = path.join(tmpDir, "missions", "test-mission"); const evidenceDir = path.join(missionDir, "evidence"); await fs.writeFile(path.join(evidenceDir, "validation-report.json"), '{"stale":true}'); await runMissionValidate( { argv: [], flags: { mission: "test-mission" } } as any, makeContext(tmpDir), ); // Without --fast, the stale report is deleted (RFC-1020 behavior) // runOperation mock returns { completed: true } without writing a new report expect( existsSync(path.join(evidenceDir, "validation-report.json")), "validation-report.json must be deleted without --fast (RFC-1020 preserved)", ).toBe(false); });