|
| 1 | +// ABOUTME: AC-3 finalize-status gate — --set/--archive refuse the incomplete- |
| 2 | +// ABOUTME: finalize shape (verdict set, status never advanced to terminal). |
| 3 | +package status |
| 4 | + |
| 5 | +import ( |
| 6 | + "path/filepath" |
| 7 | + "strings" |
| 8 | + "testing" |
| 9 | +) |
| 10 | + |
| 11 | +// Spike C (docs/dev fo-opus-behavioral-robustness) proved the residual hole |
| 12 | +// behind the verdict gate: `--set {slug} completed verdict=PASSED worktree=` |
| 13 | +// with no `status={terminal}` in the same call finalizes an entity that never |
| 14 | +// advanced past its non-terminal stage, and a follow-on `--archive` then moves |
| 15 | +// it into `_archive/` still carrying `status: implementation`. The verdict gate |
| 16 | +// (verdict_guard_test.go) only checks that a verdict IS set — it never checks |
| 17 | +// that the resulting status is terminal. This gate closes that gap. |
| 18 | + |
| 19 | +// TestFinalizeStatusGateRefusesSpikeCSequence is the AC-3 red-first case: |
| 20 | +// Spike C's exact command sequence, byte for byte. Before the gate this exits 0 |
| 21 | +// and mutates the entity; the gate must refuse it (exit 1, entity untouched, |
| 22 | +// verbatim remediation text). |
| 23 | +func TestFinalizeStatusGateRefusesSpikeCSequence(t *testing.T) { |
| 24 | + env := pinnedEnv(t) |
| 25 | + root := stageFixture(t, "seq-workflow") |
| 26 | + |
| 27 | + args := append([]string{"--workflow-dir", root}, "--set", "002-vendor-script", "completed", "verdict=PASSED", "worktree=") |
| 28 | + out, errOut, code := runNative(t, root, env, args...) |
| 29 | + |
| 30 | + if code != 1 { |
| 31 | + t.Fatalf("finalize with no status={terminal} in the same call must refuse (exit 1), got %d (stdout=%q stderr=%q)", code, out, errOut) |
| 32 | + } |
| 33 | + if out != "" { |
| 34 | + t.Fatalf("stdout must be empty on rejection, got %q", out) |
| 35 | + } |
| 36 | + wantErr := "Error: entity 002-vendor-script cannot be finalized ('completed') while status 'ideation' is not the terminal stage. " + |
| 37 | + "Set status=done in the same --set (or run 'spacedock merge guard 002-vendor-script'), or use --force." |
| 38 | + if !strings.Contains(errOut, wantErr) { |
| 39 | + t.Fatalf("stderr = %q, want it to contain %q", errOut, wantErr) |
| 40 | + } |
| 41 | + fm := readWhole(t, filepath.Join(root, "002-vendor-script.md")) |
| 42 | + if !strings.Contains(fm, "status: ideation") { |
| 43 | + t.Fatalf("entity must stay unmutated when the gate refuses:\n%s", fm) |
| 44 | + } |
| 45 | + if strings.Contains(fm, "verdict:") { |
| 46 | + t.Fatalf("entity must not have gained a verdict when the gate refuses:\n%s", fm) |
| 47 | + } |
| 48 | + |
| 49 | + // The --set step above never mutated the entity, so the follow-on --archive |
| 50 | + // half of Spike C's sequence has nothing incomplete to archive: it sees the |
| 51 | + // untouched, verdict-less, non-terminal entity and takes the sanctioned |
| 52 | + // verdict-less-non-terminal-archive path (still exit 0 — see |
| 53 | + // TestFinalizeStatusGateArchiveVerdictLessNonTerminalPasses). The dangerous |
| 54 | + // end-state — `status: implementation` + a false `verdict: PASSED` landing in |
| 55 | + // `_archive/` — is exactly what the --set refusal above prevents from ever |
| 56 | + // existing. |
| 57 | +} |
| 58 | + |
| 59 | +// TestFinalizeStatusGateSetWithTerminalStatusAllowed: the contract terminalize |
| 60 | +// shape — status={terminal} in the SAME --set call as completed+verdict — passes. |
| 61 | +func TestFinalizeStatusGateSetWithTerminalStatusAllowed(t *testing.T) { |
| 62 | + env := pinnedEnv(t) |
| 63 | + root := stageFixture(t, "seq-workflow") |
| 64 | + |
| 65 | + args := append([]string{"--workflow-dir", root}, "--set", "002-vendor-script", "status=done", "completed", "verdict=passed", "worktree=") |
| 66 | + _, errOut, code := runNative(t, root, env, args...) |
| 67 | + |
| 68 | + if code != 0 { |
| 69 | + t.Fatalf("finalize WITH status={terminal} in the same call must succeed (exit 0), got %d (stderr=%q)", code, errOut) |
| 70 | + } |
| 71 | + fm := readWhole(t, filepath.Join(root, "002-vendor-script.md")) |
| 72 | + if !strings.Contains(fm, "status: done") || !strings.Contains(fm, "verdict: passed") { |
| 73 | + t.Fatalf("entity should have terminalized:\n%s", fm) |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +// TestFinalizeStatusGateAlreadyTerminalAllowed: an entity already sitting at the |
| 78 | +// terminal stage (no status= in this call) finalizes fine — the gate reads the |
| 79 | +// CURRENT status when this call sets none. |
| 80 | +func TestFinalizeStatusGateAlreadyTerminalAllowed(t *testing.T) { |
| 81 | + env := pinnedEnv(t) |
| 82 | + root := stageFixtureWith(t, "seq-workflow", map[string]string{ |
| 83 | + "already-done.md": "---\nid: \"053\"\ntitle: Already Done\nstatus: done\nscore: \"0.5\"\nsource: x\n---\n# Already Done\n", |
| 84 | + }) |
| 85 | + |
| 86 | + args := append([]string{"--workflow-dir", root}, "--set", "already-done", "completed", "verdict=passed", "worktree=") |
| 87 | + _, errOut, code := runNative(t, root, env, args...) |
| 88 | + |
| 89 | + if code != 0 { |
| 90 | + t.Fatalf("finalize on an already-terminal entity must succeed (exit 0), got %d (stderr=%q)", code, errOut) |
| 91 | + } |
| 92 | + fm := readWhole(t, filepath.Join(root, "already-done.md")) |
| 93 | + if !strings.Contains(fm, "verdict: passed") { |
| 94 | + t.Fatalf("entity should have gained a verdict:\n%s", fm) |
| 95 | + } |
| 96 | +} |
| 97 | + |
| 98 | +// TestFinalizeStatusGateForceBypasses: --force bypasses the gate, same idiom as |
| 99 | +// the mod-block / merge-hook / verdict guards. |
| 100 | +func TestFinalizeStatusGateForceBypasses(t *testing.T) { |
| 101 | + env := pinnedEnv(t) |
| 102 | + root := stageFixture(t, "seq-workflow") |
| 103 | + |
| 104 | + args := append([]string{"--workflow-dir", root}, "--set", "002-vendor-script", "completed", "verdict=PASSED", "worktree=", "--force") |
| 105 | + _, errOut, code := runNative(t, root, env, args...) |
| 106 | + |
| 107 | + if code != 0 { |
| 108 | + t.Fatalf("--force must bypass the finalize-status gate (exit 0), got %d (stderr=%q)", code, errOut) |
| 109 | + } |
| 110 | + fm := readWhole(t, filepath.Join(root, "002-vendor-script.md")) |
| 111 | + if !strings.Contains(fm, "status: ideation") || !strings.Contains(fm, "verdict: PASSED") { |
| 112 | + t.Fatalf("entity should have finalized under --force despite the non-terminal status:\n%s", fm) |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +// TestFinalizeStatusGateDispatchIntoTerminalPasses: a bare dispatch-into-terminal |
| 117 | +// (`status=done started`, no `completed`) is not a finalize at all — the gate |
| 118 | +// must not fire on it, matching the verdict gate's identical carve-out. |
| 119 | +func TestFinalizeStatusGateDispatchIntoTerminalPasses(t *testing.T) { |
| 120 | + env := pinnedEnv(t) |
| 121 | + root := stageFixture(t, "seq-workflow") |
| 122 | + |
| 123 | + args := append([]string{"--workflow-dir", root}, "--set", "002-vendor-script", "status=done", "started") |
| 124 | + _, errOut, code := runNative(t, root, env, args...) |
| 125 | + |
| 126 | + if code != 0 { |
| 127 | + t.Fatalf("dispatch-into-terminal (no `completed`) must PASS (exit 0), got %d (stderr=%q)", code, errOut) |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +// TestFinalizeStatusGateArchiveRefusesNonTerminalVerdict: the --archive mirror. |
| 132 | +// An entity that already carries a non-rejected verdict but sits at a |
| 133 | +// non-terminal status — the shape a stale binary (predating the --set gate |
| 134 | +// above) or a drifting FO can still produce by hand-editing — must be refused |
| 135 | +// at --archive too, so the false-verdict end-state cannot land in `_archive/` |
| 136 | +// via that route either. |
| 137 | +func TestFinalizeStatusGateArchiveRefusesNonTerminalVerdict(t *testing.T) { |
| 138 | + env := pinnedEnv(t) |
| 139 | + root := stageFixtureWith(t, "seq-workflow", map[string]string{ |
| 140 | + "stale-finalized.md": "---\nid: \"054\"\ntitle: Stale Finalized\nstatus: implementation\nverdict: PASSED\nscore: \"0.5\"\nsource: x\n---\n# Stale Finalized\n", |
| 141 | + }) |
| 142 | + |
| 143 | + out, errOut, code := runNative(t, root, env, "--workflow-dir", root, "--archive", "stale-finalized") |
| 144 | + if code != 1 { |
| 145 | + t.Fatalf("--archive of a non-terminal entity carrying a verdict must refuse (exit 1), got %d (stderr=%q)", code, errOut) |
| 146 | + } |
| 147 | + if out != "" { |
| 148 | + t.Fatalf("stdout must be empty on rejection, got %q", out) |
| 149 | + } |
| 150 | + if !strings.Contains(errOut, "cannot be archived") { |
| 151 | + t.Fatalf("stderr must name the archive refusal, got %q", errOut) |
| 152 | + } |
| 153 | + if !strings.Contains(errOut, "PASSED") || !strings.Contains(errOut, "implementation") { |
| 154 | + t.Fatalf("stderr must name the verdict and the non-terminal status, got %q", errOut) |
| 155 | + } |
| 156 | + |
| 157 | + // --force bypasses. |
| 158 | + _, errOut, code = runNative(t, root, env, "--workflow-dir", root, "--archive", "stale-finalized", "--force") |
| 159 | + if code != 0 { |
| 160 | + t.Fatalf("--force must bypass the archive-side gate (exit 0), got %d (stderr=%q)", code, errOut) |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +// TestFinalizeStatusGateArchiveRejectedNonTerminalPasses: the sanctioned |
| 165 | +// reject-then-archive path — verdict: rejected — is exempt from the archive-side |
| 166 | +// gate regardless of status, matching the merge-hook guard's identical |
| 167 | +// exemption (a rejected entity never ran the merge ceremony). |
| 168 | +func TestFinalizeStatusGateArchiveRejectedNonTerminalPasses(t *testing.T) { |
| 169 | + env := pinnedEnv(t) |
| 170 | + root := stageFixtureWith(t, "seq-workflow", map[string]string{ |
| 171 | + "rejected-nonterminal.md": "---\nid: \"055\"\ntitle: Rejected Non-Terminal\nstatus: implementation\nverdict: rejected\nscore: \"0.5\"\nsource: x\n---\n# Rejected Non-Terminal\n", |
| 172 | + }) |
| 173 | + |
| 174 | + _, errOut, code := runNative(t, root, env, "--workflow-dir", root, "--archive", "rejected-nonterminal") |
| 175 | + if code != 0 { |
| 176 | + t.Fatalf("reject-then-archive must pass regardless of terminal status (exit 0), got %d (stderr=%q)", code, errOut) |
| 177 | + } |
| 178 | +} |
| 179 | + |
| 180 | +// TestFinalizeStatusGateArchiveVerdictLessNonTerminalPasses: archiving a |
| 181 | +// non-terminal entity that carries NO verdict at all (an abandoned/cancelled |
| 182 | +// task, never finalized) is unaffected — the gate only fires on a non-empty, |
| 183 | +// non-rejected verdict. |
| 184 | +func TestFinalizeStatusGateArchiveVerdictLessNonTerminalPasses(t *testing.T) { |
| 185 | + env := pinnedEnv(t) |
| 186 | + root := stageFixture(t, "seq-workflow") |
| 187 | + |
| 188 | + _, errOut, code := runNative(t, root, env, "--workflow-dir", root, "--archive", "002-vendor-script") |
| 189 | + if code != 0 { |
| 190 | + t.Fatalf("verdict-less non-terminal archive must pass (exit 0), got %d (stderr=%q)", code, errOut) |
| 191 | + } |
| 192 | +} |
0 commit comments