|
| 1 | +"""Offline slice for `pdca revalidate` — re-gate a frozen bundle (stdlib unittest). |
| 2 | +
|
| 3 | +Proves the issue-#11 contract: revalidate re-runs the single-sourced gates against the |
| 4 | +current engine, writes an additive dated stamp, NEVER mutates the frozen |
| 5 | +check-gates.json / check-gates.md / §9, refuses a non-COMPLETE bundle, reports a changed |
| 6 | +row in either direction, and surfaces deltas where Act looks. Deterministic real gates |
| 7 | +(`true` / `false`) flip a gate's result between freeze and revalidate with no Claude / |
| 8 | +Docker. Run from the project root: PYTHONPATH=src python -m unittest discover -s tests |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +import json |
| 14 | +import shutil |
| 15 | +import tempfile |
| 16 | +import unittest |
| 17 | +from pathlib import Path |
| 18 | +from types import SimpleNamespace |
| 19 | + |
| 20 | +from pdca_harness import act, cli, gates, revalidate, state |
| 21 | +from pdca_harness.config import Config, LeafConfig |
| 22 | + |
| 23 | +# A real bundle-scoped gate keyed on a stable (element, rule_id, check) so a frozen row |
| 24 | +# and a fresh row line up; only the cmd's exit code (true/false) decides pass/fail. |
| 25 | +_GATE = {"id": "C4", "tier": "C4", "label": "verify", "scope": "bundle", "gating": True} |
| 26 | +_PASS = {**_GATE, "cmd": "true"} |
| 27 | +_FAIL = {**_GATE, "cmd": "false"} |
| 28 | + |
| 29 | + |
| 30 | +def _stub_config(root: Path) -> Config: |
| 31 | + return Config( |
| 32 | + root=root, |
| 33 | + bundle_root=root / "results", |
| 34 | + process_dir=root / "process", |
| 35 | + templates_dir=root / "templates", |
| 36 | + default_branch="main", |
| 37 | + tracker_system="github", |
| 38 | + tracker_url="", |
| 39 | + issue_id_example="#1", |
| 40 | + builder=LeafConfig(mode="stub", family="claude"), |
| 41 | + reviewer=LeafConfig(mode="stub", family="codex"), |
| 42 | + planner=LeafConfig(mode="stub", family="claude", interactive=True), |
| 43 | + signoff=LeafConfig(mode="stub", family="claude", interactive=True), |
| 44 | + publisher=LeafConfig(mode="stub", family="claude", interactive=True), |
| 45 | + act=LeafConfig(mode="stub", family="claude", interactive=True), |
| 46 | + ) |
| 47 | + |
| 48 | + |
| 49 | +class Revalidate(unittest.TestCase): |
| 50 | + def setUp(self) -> None: |
| 51 | + self.tmp = Path(tempfile.mkdtemp()) |
| 52 | + self.cfg = _stub_config(self.tmp) |
| 53 | + |
| 54 | + def tearDown(self) -> None: |
| 55 | + shutil.rmtree(self.tmp, ignore_errors=True) |
| 56 | + |
| 57 | + def _complete_bundle(self, iid: str, *, frozen_gate: dict) -> Path: |
| 58 | + """A COMPLETE (frozen) bundle whose check-gates.json was written by `frozen_gate`.""" |
| 59 | + d = self.cfg.bundle(iid) |
| 60 | + d.mkdir(parents=True) |
| 61 | + (d / "brief.md").write_text("- **Slug:** reval\n", encoding="utf-8") |
| 62 | + (d / "patch.diff").write_text("--- a\n+++ b\n", encoding="utf-8") |
| 63 | + self.cfg.gates_checks = [frozen_gate] |
| 64 | + gates.run_gates(d, self.cfg) # writes the frozen check-gates.json / .md |
| 65 | + (d / "SUMMARY.md").write_text( |
| 66 | + "## 9. Check sign-off\n- Outcome: accepted\n- By / date: t / 2026-06-04\n", |
| 67 | + encoding="utf-8") |
| 68 | + self.assertEqual(state.state(d), state.COMPLETE) |
| 69 | + return d |
| 70 | + |
| 71 | + def test_stamp_written_and_frozen_files_untouched(self) -> None: |
| 72 | + # Frozen FAIL (old engine); the current engine PASSes. Revalidate records the |
| 73 | + # delta but leaves the frozen record byte-for-byte intact. |
| 74 | + d = self._complete_bundle("REVAL", frozen_gate=_FAIL) |
| 75 | + before = {name: (d / name).read_bytes() |
| 76 | + for name in ("check-gates.json", "check-gates.md", "SUMMARY.md")} |
| 77 | + self.cfg.gates_checks = [_PASS] # engine since fixed |
| 78 | + result = revalidate.revalidate(self.cfg, d, "2026-06-12") |
| 79 | + |
| 80 | + self.assertTrue((d / "revalidation-2026-06-12.json").exists()) |
| 81 | + for name, blob in before.items(): |
| 82 | + self.assertEqual((d / name).read_bytes(), blob, |
| 83 | + f"revalidate must not touch the frozen {name}") |
| 84 | + self.assertTrue(result["changed"]) |
| 85 | + self.assertFalse(result["regression"]) # FAIL→PASS is a stale artifact, not a regression |
| 86 | + c4 = next(r for r in result["rows"] if r["element"] == "C4") |
| 87 | + self.assertEqual((c4["old"], c4["new"]), ("fail", "pass")) |
| 88 | + |
| 89 | + def test_regression_when_frozen_pass_now_fails(self) -> None: |
| 90 | + # Frozen PASS; the current engine FAILs the same gate — the load-bearing signal. |
| 91 | + d = self._complete_bundle("REG", frozen_gate=_PASS) |
| 92 | + self.cfg.gates_checks = [_FAIL] |
| 93 | + result = revalidate.revalidate(self.cfg, d, "2026-06-12") |
| 94 | + self.assertTrue(result["changed"]) |
| 95 | + self.assertTrue(result["regression"]) |
| 96 | + c4 = next(r for r in result["rows"] if r["element"] == "C4") |
| 97 | + self.assertEqual((c4["old"], c4["new"]), ("pass", "fail")) |
| 98 | + |
| 99 | + def test_unchanged_is_a_quiet_confirmation(self) -> None: |
| 100 | + # Same gate result at freeze and now → no delta; the CLI exits 0. |
| 101 | + d = self._complete_bundle("SAME", frozen_gate=_PASS) |
| 102 | + self.cfg.gates_checks = [_PASS] |
| 103 | + rc = cli._revalidate(self.cfg, SimpleNamespace(issue_id="SAME", date="2026-06-12")) |
| 104 | + self.assertEqual(rc, 0) |
| 105 | + stamp = json.loads((d / "revalidation-2026-06-12.json").read_text(encoding="utf-8")) |
| 106 | + self.assertFalse(stamp["changed"]) |
| 107 | + |
| 108 | + def test_cli_exit_nonzero_on_delta(self) -> None: |
| 109 | + d = self._complete_bundle("DELTA", frozen_gate=_FAIL) |
| 110 | + self.cfg.gates_checks = [_PASS] |
| 111 | + rc = cli._revalidate(self.cfg, SimpleNamespace(issue_id="DELTA", date="2026-06-12")) |
| 112 | + self.assertEqual(rc, 1) # a changed row is surfaced to the caller |
| 113 | + self.assertTrue((d / "revalidation-2026-06-12.json").exists()) |
| 114 | + |
| 115 | + def test_refuses_non_complete_bundle(self) -> None: |
| 116 | + # A bundle that is only PLANNED (brief, no patch) must be refused, no stamp. |
| 117 | + d = self.cfg.bundle("PARTIAL") |
| 118 | + d.mkdir(parents=True) |
| 119 | + (d / "brief.md").write_text("- **Slug:** x\n", encoding="utf-8") |
| 120 | + self.assertNotEqual(state.state(d), state.COMPLETE) |
| 121 | + rc = cli._revalidate(self.cfg, SimpleNamespace(issue_id="PARTIAL", date="2026-06-12")) |
| 122 | + self.assertEqual(rc, 2) |
| 123 | + self.assertEqual(list(d.glob("revalidation-*.json")), []) |
| 124 | + |
| 125 | + def test_missing_bundle_returns_one(self) -> None: |
| 126 | + rc = cli._revalidate(self.cfg, SimpleNamespace(issue_id="GHOST", date=None)) |
| 127 | + self.assertEqual(rc, 1) |
| 128 | + |
| 129 | + def test_act_index_surfaces_revalidation_delta(self) -> None: |
| 130 | + # A COMPLETE bundle carrying a revalidation delta shows it in the Act index, |
| 131 | + # so Act can tell a stale frozen FAIL from a real accepted failure. |
| 132 | + d = self._complete_bundle("ACTREVAL", frozen_gate=_FAIL) |
| 133 | + self.cfg.gates_checks = [_PASS] |
| 134 | + revalidate.revalidate(self.cfg, d, "2026-06-12") |
| 135 | + entries = act.index(self.cfg) |
| 136 | + entry = next(e for e in entries if e.bundle.name == "issue_ACTREVAL") |
| 137 | + self.assertTrue(entry.reval_deltas) |
| 138 | + rendered = act.render_index(entries, act.patterns(entries)) |
| 139 | + self.assertIn("revalidation deltas", rendered) |
| 140 | + self.assertIn("fail→pass", rendered) |
| 141 | + |
| 142 | + |
| 143 | +if __name__ == "__main__": |
| 144 | + unittest.main() |
0 commit comments