From cbd1a0f3a3815342dbb66fcc83f4717b761583d3 Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:16:42 -0700 Subject: [PATCH 1/3] fix(proof): enforce EvidenceRule.test_id/must_pass in proof runner (#729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A requirement is no longer SATISFIED by any green pytest run — each must_pass evidence rule is enforced by a pytest run scoped to its test_id (-k), and a named test that was never written is a FAILED obligation. - EvidenceRule gains an owning gate field, stamped at capture, persisted in the ledger; legacy rows derive it from the test_id prefix convention - gates.run/_run_pytest accept a test_selector; with a selector, "no tests matched" (exit 5) is a failure, not an empty suite - _run_gate enforces pytest-style rules on every runnable gate: SEC runs ruff AND its test_sec_* rules (codex review finding) - UNIT stub now defines test_unit_ to match its rule, and stub slugs share obligations.slugify with rules (was [:50] vs [:60]) - drop unused pytest import in tests/test_new_feature.py (pre-existing ruff failure) --- codeframe/core/gates.py | 20 +- codeframe/core/proof/ledger.py | 17 +- codeframe/core/proof/models.py | 1 + codeframe/core/proof/obligations.py | 49 ++-- codeframe/core/proof/runner.py | 69 ++++- codeframe/core/proof/stubs.py | 13 +- tests/cli/test_proof_commands.py | 2 +- tests/core/test_proof_evidence_rules.py | 314 +++++++++++++++++++++++ tests/core/test_proof_runner_outcomes.py | 4 +- 9 files changed, 447 insertions(+), 42 deletions(-) create mode 100644 tests/core/test_proof_evidence_rules.py diff --git a/codeframe/core/gates.py b/codeframe/core/gates.py index 1081570d..2c3d5a94 100644 --- a/codeframe/core/gates.py +++ b/codeframe/core/gates.py @@ -287,6 +287,7 @@ def run( gates: Optional[list[str]] = None, verbose: bool = False, auto_install_deps: bool = True, + test_selector: Optional[str] = None, ) -> GateResult: """Run verification gates. @@ -295,6 +296,8 @@ def run( gates: Specific gates to run (None = all available) verbose: Whether to capture full output auto_install_deps: Whether to auto-install missing dependencies before test gates (default: True) + test_selector: Optional pytest ``-k`` keyword expression; only applies to + the pytest gate. With a selector, "no tests matched" is a failure. Returns: GateResult with all check results @@ -358,7 +361,7 @@ def run( # Run each gate for gate_name in gates: if gate_name == "pytest": - check = _run_pytest(repo_path, verbose) + check = _run_pytest(repo_path, verbose, test_selector=test_selector) elif gate_name == "ruff": check = _run_ruff(repo_path, verbose) elif gate_name == "mypy": @@ -476,8 +479,10 @@ def _detect_available_gates(repo_path: Path) -> list[str]: return gates -def _run_pytest(repo_path: Path, verbose: bool = False) -> GateCheck: - """Run pytest.""" +def _run_pytest( + repo_path: Path, verbose: bool = False, test_selector: Optional[str] = None +) -> GateCheck: + """Run pytest, optionally scoped to a ``-k`` keyword expression.""" import time start = time.time() @@ -496,6 +501,8 @@ def _run_pytest(repo_path: Path, verbose: bool = False) -> GateCheck: cmd = ["uv", "run", "pytest", "-v", "--tb=short"] else: cmd = ["pytest", "-v", "--tb=short"] + if test_selector: + cmd += ["-k", test_selector] result = subprocess.run( cmd, @@ -535,9 +542,12 @@ def _run_pytest(repo_path: Path, verbose: bool = False) -> GateCheck: status = GateStatus.FAILED elif result.returncode == 5: # Exit code 5: no tests collected - # Check if this is a clean "no tests" or an error during collection + # With an explicit selector, "nothing matched" means the named + # test doesn't exist — that is a failure, not an empty suite. output_lower = output.lower() - if "error" in output_lower or "importerror" in output_lower or "modulenotfounderror" in output_lower: + if test_selector: + status = GateStatus.FAILED + elif "error" in output_lower or "importerror" in output_lower or "modulenotfounderror" in output_lower: # Collection error disguised as "no tests collected" status = GateStatus.FAILED elif "no tests ran" in output_lower or "collected 0 items" in output_lower: diff --git a/codeframe/core/proof/ledger.py b/codeframe/core/proof/ledger.py index 30c15527..37b1e820 100644 --- a/codeframe/core/proof/ledger.py +++ b/codeframe/core/proof/ledger.py @@ -190,12 +190,25 @@ def _obligations_from_json(raw: str) -> list[Obligation]: def _evidence_rules_to_json(rules: list[EvidenceRule]) -> str: - return json.dumps([{"test_id": r.test_id, "must_pass": r.must_pass} for r in rules]) + return json.dumps([ + {"test_id": r.test_id, "must_pass": r.must_pass, "gate": r.gate.value if r.gate else None} + for r in rules + ]) def _evidence_rules_from_json(raw: str) -> list[EvidenceRule]: + from codeframe.core.proof.obligations import gate_from_test_id + data = json.loads(raw) - return [EvidenceRule(test_id=d["test_id"], must_pass=d.get("must_pass", True)) for d in data] + return [ + EvidenceRule( + test_id=d["test_id"], + must_pass=d.get("must_pass", True), + # Legacy rows lack "gate" — derive from the test_id prefix convention + gate=Gate(d["gate"]) if d.get("gate") else gate_from_test_id(d["test_id"]), + ) + for d in data + ] def _waiver_to_json(waiver: Optional[Waiver]) -> Optional[str]: diff --git a/codeframe/core/proof/models.py b/codeframe/core/proof/models.py index e42402f8..cfadc5b8 100644 --- a/codeframe/core/proof/models.py +++ b/codeframe/core/proof/models.py @@ -110,6 +110,7 @@ class EvidenceRule: test_id: str must_pass: bool = True + gate: Optional[Gate] = None # owning gate; None on legacy rows without a derivable prefix @dataclass diff --git a/codeframe/core/proof/obligations.py b/codeframe/core/proof/obligations.py index 2ee473aa..fa597bd5 100644 --- a/codeframe/core/proof/obligations.py +++ b/codeframe/core/proof/obligations.py @@ -83,21 +83,38 @@ def get_obligations(glitch_type: GlitchType) -> list[Obligation]: return [Obligation(gate=g) for g in gates] +TEST_ID_PREFIXES: dict[Gate, str] = { + Gate.UNIT: "test_unit_", + Gate.CONTRACT: "test_contract_", + Gate.E2E: "test_e2e_", + Gate.VISUAL: "test_visual_", + Gate.A11Y: "test_a11y_", + Gate.PERF: "test_perf_", + Gate.SEC: "test_sec_", + Gate.DEMO: "test_demo_", + Gate.MANUAL: "manual_check_", +} + + +def gate_from_test_id(test_id: str) -> Gate | None: + """Derive the owning gate from a test_id prefix (for legacy persisted rules).""" + for gate, prefix in TEST_ID_PREFIXES.items(): + if test_id.startswith(prefix): + return gate + return None + + +def slugify(text: str) -> str: + """Create a safe identifier from text. + + Shared by evidence rules and stub generation so a generated stub's + function name matches the rule's test_id exactly. + """ + slug = re.sub(r"[^a-z0-9]+", "_", text.lower().strip())[:60].strip("_") + return slug or "unnamed" + + def suggest_evidence_rules(gate: Gate, description: str) -> list[EvidenceRule]: """Generate starter evidence rules for an obligation gate.""" - # Create a sensible test ID from the description - slug = re.sub(r"[^a-z0-9]+", "_", description.lower().strip())[:60].strip("_") - - prefix_map = { - Gate.UNIT: "test_unit_", - Gate.CONTRACT: "test_contract_", - Gate.E2E: "test_e2e_", - Gate.VISUAL: "test_visual_", - Gate.A11Y: "test_a11y_", - Gate.PERF: "test_perf_", - Gate.SEC: "test_sec_", - Gate.DEMO: "test_demo_", - Gate.MANUAL: "manual_check_", - } - prefix = prefix_map.get(gate, "test_") - return [EvidenceRule(test_id=f"{prefix}{slug}", must_pass=True)] + prefix = TEST_ID_PREFIXES.get(gate, "test_") + return [EvidenceRule(test_id=f"{prefix}{slugify(description)}", must_pass=True, gate=gate)] diff --git a/codeframe/core/proof/runner.py b/codeframe/core/proof/runner.py index 72f5d868..bfa12eaa 100644 --- a/codeframe/core/proof/runner.py +++ b/codeframe/core/proof/runner.py @@ -9,12 +9,13 @@ import logging import uuid from datetime import datetime, timezone -from typing import Optional +from typing import Optional, Sequence from codeframe.core.proof import ledger from codeframe.core.proof.evidence import attach_evidence from codeframe.core.proof.models import ( PROOF_CONFIG_FILENAME, + EvidenceRule, Gate, GateOutcome, ProofRun, @@ -80,12 +81,22 @@ def _load_proof_config(workspace: Workspace) -> tuple[Optional[set[Gate]], str]: } -def _run_gate(workspace: Workspace, gate: Gate) -> tuple[GateOutcome, str]: +def _run_gate( + workspace: Workspace, + gate: Gate, + rules: Sequence[EvidenceRule] = (), +) -> tuple[GateOutcome, str]: """Execute a single gate and return (outcome, output). Uses core/gates.py for gates that have direct tool support. Gates without an automated runner return UNVERIFIABLE — the obligation could not be checked, which is distinct from running and failing. + + For pytest-backed gates, each ``must_pass`` evidence rule is enforced + individually: pytest runs scoped to the rule's ``test_id`` (via ``-k``), + and a named test that doesn't exist is a FAILED obligation — a green + whole-suite run proves nothing about a test that was never written. + Rules with ``must_pass=False`` are informational only. """ core_gate_name = _GATE_TO_CORE.get(gate) if not core_gate_name: @@ -95,13 +106,48 @@ def _run_gate(workspace: Workspace, gate: Gate) -> tuple[GateOutcome, str]: ) try: - from codeframe.core.gates import run as run_gates - result = run_gates(workspace, gates=[core_gate_name], verbose=False) - output_parts = [ - f"{check.name}: {check.status.value}" for check in result.checks - ] - outcome = GateOutcome.PASSED if result.passed else GateOutcome.FAILED - return outcome, "\n".join(output_parts) + from codeframe.core import gates as core_gates + + # Only pytest-style test_ids can be enforced by a scoped pytest run; + # e.g. SEC's test_sec_* rules are pytest tests even though the SEC + # gate's own runner is ruff. + enforced = [r for r in rules if r.must_pass and r.test_id.startswith("test_")] + + lines: list[str] = [] + all_passed = True + + for rule in enforced: + result = core_gates.run( + workspace, + gates=["pytest"], + verbose=False, + test_selector=rule.test_id, + ) + check = result.checks[0] if result.checks else None + if check is not None and check.exit_code == 5: + lines.append(f"{rule.test_id}: FAILED — named test missing (not collected)") + all_passed = False + elif result.passed: + lines.append(f"{rule.test_id}: passed") + else: + lines.append(f"{rule.test_id}: FAILED") + all_passed = False + + # Run the gate's own runner unless it is pytest and the enforced rules + # already covered it (scoped runs replace the whole-suite run). + if core_gate_name != "pytest" or not enforced: + result = core_gates.run(workspace, gates=[core_gate_name], verbose=False) + lines.extend( + f"{check.name}: {check.status.value}" for check in result.checks + ) + all_passed = all_passed and result.passed + + for rule in rules: + if not rule.must_pass: + lines.append(f"{rule.test_id}: informational (must_pass=False, not enforced)") + + outcome = GateOutcome.PASSED if all_passed else GateOutcome.FAILED + return outcome, "\n".join(lines) except Exception as exc: logger.warning("Gate %s failed to run: %s", gate.value, exc) return GateOutcome.FAILED, str(exc) @@ -192,8 +238,9 @@ def run_proof( if enabled_gates is not None and obl.gate not in enabled_gates: continue - # Run the gate - outcome, output = _run_gate(workspace, obl.gate) + # Run the gate, enforcing this requirement's evidence rules for it + gate_rules = [r for r in req.evidence_rules if r.gate == obl.gate] + outcome, output = _run_gate(workspace, obl.gate, gate_rules) # Write artifact artifact_path = artifact_dir / f"{req.id}_{obl.gate.value}_{run_id}.txt" diff --git a/codeframe/core/proof/stubs.py b/codeframe/core/proof/stubs.py index 0d2b70ca..2e554d40 100644 --- a/codeframe/core/proof/stubs.py +++ b/codeframe/core/proof/stubs.py @@ -12,7 +12,7 @@ import pytest -def test_{slug}(): +def test_unit_{slug}(): """Proves: {description}""" # Arrange # TODO: Set up test data @@ -128,10 +128,13 @@ def test_sec_{slug}(): def _slugify(text: str) -> str: - """Create a safe identifier from text.""" - import re - slug = re.sub(r"[^a-z0-9]+", "_", text.lower().strip())[:50].strip("_") - return slug or "unnamed" + """Create a safe identifier from text. + + Delegates to obligations.slugify so stub function names always match the + evidence-rule test_ids that enforce them (issue #729). + """ + from codeframe.core.proof.obligations import slugify + return slugify(text) def generate_stubs(req: Requirement) -> dict[Gate, str]: diff --git a/tests/cli/test_proof_commands.py b/tests/cli/test_proof_commands.py index 67357bea..a03babf5 100644 --- a/tests/cli/test_proof_commands.py +++ b/tests/cli/test_proof_commands.py @@ -212,7 +212,7 @@ def test_run_mixed_fail_and_unverifiable_exits_one(self, mock_run_gate, ws): ), ) - def _outcome(_ws, gate): + def _outcome(_ws, gate, _rules=()): if gate == Gate.UNIT: return (GateOutcome.FAILED, "assertion failed") return (GateOutcome.UNVERIFIABLE, "cannot verify") diff --git a/tests/core/test_proof_evidence_rules.py b/tests/core/test_proof_evidence_rules.py new file mode 100644 index 00000000..a1a7e364 --- /dev/null +++ b/tests/core/test_proof_evidence_rules.py @@ -0,0 +1,314 @@ +"""Tests for EvidenceRule.test_id/must_pass enforcement (issue #729). + +A requirement's obligation is only satisfied when its named proving tests +actually run and pass — a missing named test is a failed obligation, even +when the broader suite is green. +""" + +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +import pytest + +from codeframe.core.gates import GateCheck, GateResult, GateStatus +from codeframe.core.proof.models import ( + EvidenceRule, + Gate, + GateOutcome, + Obligation, + Requirement, + RequirementScope, + ReqStatus, + Severity, + Source, +) +from codeframe.core.workspace import Workspace, create_or_load_workspace + +pytestmark = pytest.mark.v2 + + +@pytest.fixture +def workspace(tmp_path: Path) -> Workspace: + return create_or_load_workspace(tmp_path) + + +def _gate_result(status: GateStatus, exit_code: int, output: str = "") -> GateResult: + return GateResult( + passed=status in (GateStatus.PASSED, GateStatus.SKIPPED), + checks=[GateCheck(name="pytest", status=status, exit_code=exit_code, output=output)], + ) + + +# --- Model / capture / ledger plumbing --- + + +class TestEvidenceRuleGateField: + def test_evidence_rule_defaults_gate_none(self): + rule = EvidenceRule(test_id="test_unit_foo") + assert rule.gate is None + assert rule.must_pass is True + + def test_suggest_evidence_rules_stamps_gate(self): + from codeframe.core.proof.obligations import suggest_evidence_rules + + rules = suggest_evidence_rules(Gate.UNIT, "Login rejects empty password") + assert rules[0].gate == Gate.UNIT + rules = suggest_evidence_rules(Gate.CONTRACT, "API returns 404") + assert rules[0].gate == Gate.CONTRACT + + def test_ledger_round_trips_gate(self, workspace): + from codeframe.core.proof import ledger + + req = Requirement( + id="REQ-0001", + title="t", + description="d", + severity=Severity.LOW, + source=Source.QA, + scope=RequirementScope(), + obligations=[Obligation(gate=Gate.UNIT)], + evidence_rules=[EvidenceRule(test_id="test_unit_foo", gate=Gate.UNIT)], + created_at=datetime.now(timezone.utc), + ) + ledger.save_requirement(workspace, req) + loaded = ledger.get_requirement(workspace, "REQ-0001") + assert loaded.evidence_rules[0].gate == Gate.UNIT + + def test_legacy_rules_derive_gate_from_prefix(self): + from codeframe.core.proof.ledger import _evidence_rules_from_json + + raw = json.dumps( + [ + {"test_id": "test_unit_foo", "must_pass": True}, + {"test_id": "test_contract_bar", "must_pass": True}, + {"test_id": "weird_name", "must_pass": True}, + ] + ) + rules = _evidence_rules_from_json(raw) + assert rules[0].gate == Gate.UNIT + assert rules[1].gate == Gate.CONTRACT + assert rules[2].gate is None + + +# --- gates.py selector threading --- + + +class TestPytestSelector: + @patch("codeframe.core.gates.subprocess.run") + @patch("codeframe.core.gates.shutil.which", return_value="/usr/bin/uv") + def test_selector_appended_to_command(self, _which, mock_run): + from codeframe.core.gates import _run_pytest + + mock_run.return_value.returncode = 0 + mock_run.return_value.stdout = "1 passed" + mock_run.return_value.stderr = "" + check = _run_pytest(Path("/tmp"), test_selector="test_unit_foo") + cmd = mock_run.call_args[0][0] + assert "-k" in cmd + assert cmd[cmd.index("-k") + 1] == "test_unit_foo" + assert check.status == GateStatus.PASSED + + @patch("codeframe.core.gates.subprocess.run") + @patch("codeframe.core.gates.shutil.which", return_value="/usr/bin/uv") + def test_exit_5_with_selector_is_failed(self, _which, mock_run): + from codeframe.core.gates import _run_pytest + + mock_run.return_value.returncode = 5 + mock_run.return_value.stdout = "no tests ran" + mock_run.return_value.stderr = "" + check = _run_pytest(Path("/tmp"), test_selector="test_unit_missing") + assert check.status == GateStatus.FAILED + assert check.exit_code == 5 + + @patch("codeframe.core.gates.subprocess.run") + @patch("codeframe.core.gates.shutil.which", return_value="/usr/bin/uv") + def test_exit_5_without_selector_still_passes(self, _which, mock_run): + from codeframe.core.gates import _run_pytest + + mock_run.return_value.returncode = 5 + mock_run.return_value.stdout = "no tests ran" + mock_run.return_value.stderr = "" + check = _run_pytest(Path("/tmp")) + assert check.status == GateStatus.PASSED + + @patch("codeframe.core.gates._ensure_dependencies_installed", return_value=(True, "ok")) + @patch("codeframe.core.gates._run_pytest") + def test_run_dispatcher_forwards_selector(self, mock_pytest, _deps, workspace): + from codeframe.core.gates import run + + mock_pytest.return_value = GateCheck(name="pytest", status=GateStatus.PASSED) + run(workspace, gates=["pytest"], test_selector="test_unit_foo") + assert mock_pytest.call_args.kwargs.get("test_selector") == "test_unit_foo" + + +# --- runner enforcement --- + + +class TestRunGateEnforcement: + def _rule(self, test_id="test_unit_foo", must_pass=True, gate=Gate.UNIT): + return EvidenceRule(test_id=test_id, must_pass=must_pass, gate=gate) + + @patch("codeframe.core.gates.run") + def test_missing_named_test_fails(self, mock_run, workspace): + from codeframe.core.proof.runner import _run_gate + + mock_run.return_value = _gate_result(GateStatus.FAILED, 5, "no tests ran") + outcome, output = _run_gate(workspace, Gate.UNIT, [self._rule()]) + assert outcome == GateOutcome.FAILED + assert "missing" in output.lower() + assert "test_unit_foo" in output + assert mock_run.call_args.kwargs.get("test_selector") == "test_unit_foo" + + @patch("codeframe.core.gates.run") + def test_passing_named_test_passes(self, mock_run, workspace): + from codeframe.core.proof.runner import _run_gate + + mock_run.return_value = _gate_result(GateStatus.PASSED, 0, "1 passed") + outcome, output = _run_gate(workspace, Gate.UNIT, [self._rule()]) + assert outcome == GateOutcome.PASSED + assert "test_unit_foo" in output + + @patch("codeframe.core.gates.run") + def test_failing_named_test_fails(self, mock_run, workspace): + from codeframe.core.proof.runner import _run_gate + + mock_run.return_value = _gate_result(GateStatus.FAILED, 1, "1 failed") + outcome, output = _run_gate(workspace, Gate.UNIT, [self._rule()]) + assert outcome == GateOutcome.FAILED + assert "test_unit_foo" in output + + @patch("codeframe.core.gates.run") + def test_must_pass_false_does_not_gate(self, mock_run, workspace): + from codeframe.core.proof.runner import _run_gate + + # Only informational rules → whole-suite behavior, rule never enforced + mock_run.return_value = _gate_result(GateStatus.PASSED, 0) + outcome, _ = _run_gate( + workspace, Gate.UNIT, [self._rule(must_pass=False)] + ) + assert outcome == GateOutcome.PASSED + # No scoped invocation for informational rules + assert mock_run.call_args.kwargs.get("test_selector") is None + + @patch("codeframe.core.gates.run") + def test_no_rules_runs_whole_suite(self, mock_run, workspace): + from codeframe.core.proof.runner import _run_gate + + mock_run.return_value = _gate_result(GateStatus.PASSED, 0) + outcome, _ = _run_gate(workspace, Gate.UNIT, []) + assert outcome == GateOutcome.PASSED + assert mock_run.call_args.kwargs.get("test_selector") is None + + @patch("codeframe.core.gates.run") + def test_one_missing_one_passing_fails(self, mock_run, workspace): + from codeframe.core.proof.runner import _run_gate + + mock_run.side_effect = [ + _gate_result(GateStatus.PASSED, 0), + _gate_result(GateStatus.FAILED, 5), + ] + outcome, output = _run_gate( + workspace, + Gate.UNIT, + [self._rule("test_unit_a"), self._rule("test_unit_b")], + ) + assert outcome == GateOutcome.FAILED + assert "test_unit_a" in output + assert "test_unit_b" in output + + @patch("codeframe.core.gates.run") + def test_sec_gate_enforces_pytest_rule_alongside_ruff(self, mock_run, workspace): + """A SEC requirement is NOT satisfied by a green ruff run alone when + its named test_sec_* regression test is missing.""" + from codeframe.core.proof.runner import _run_gate + + def fake_run(ws, gates=None, verbose=False, test_selector=None, **kw): + if test_selector: # scoped pytest run for the rule → missing + return _gate_result(GateStatus.FAILED, 5, "no tests ran") + return _gate_result(GateStatus.PASSED, 0, "ruff clean") + + mock_run.side_effect = fake_run + outcome, output = _run_gate( + workspace, Gate.SEC, [self._rule("test_sec_xss", gate=Gate.SEC)] + ) + assert outcome == GateOutcome.FAILED + assert "test_sec_xss" in output + # Both the scoped pytest run and the ruff run happened + called_gates = [c.kwargs.get("gates") or c.args[1] for c in mock_run.call_args_list] + assert ["pytest"] in called_gates + assert ["ruff"] in called_gates + + def test_unit_stub_name_matches_evidence_rule(self): + """The generated UNIT stub must define the exact function the + evidence rule enforces (issue #729 review finding).""" + from codeframe.core.proof.obligations import suggest_evidence_rules + from codeframe.core.proof.stubs import generate_stubs + + title = "Login rejects empty password when the user profile is incomplete" + req = Requirement( + id="REQ-0001", + title=title, + description="d", + severity=Severity.LOW, + source=Source.QA, + scope=RequirementScope(), + obligations=[Obligation(gate=Gate.UNIT), Obligation(gate=Gate.SEC)], + evidence_rules=[], + ) + stubs = generate_stubs(req) + for gate in (Gate.UNIT, Gate.SEC): + rule = suggest_evidence_rules(gate, title)[0] + assert f"def {rule.test_id}(" in stubs[gate] + + def test_unmapped_gate_still_unverifiable(self, workspace): + from codeframe.core.proof.runner import _run_gate + + outcome, _ = _run_gate(workspace, Gate.E2E, [self._rule(gate=Gate.E2E)]) + assert outcome == GateOutcome.UNVERIFIABLE + + +class TestRunProofEnforcement: + @patch("codeframe.core.gates.run") + def test_green_suite_but_missing_named_test_not_satisfied(self, mock_run, workspace): + """The core of #729: a green whole-suite run must NOT satisfy a + requirement whose named regression test was never written.""" + from codeframe.core.proof.capture import capture_requirement + from codeframe.core.proof.runner import run_proof + + capture_requirement( + workspace, title="Bug", description="Logic error in calculation", + where="src/calc.py", severity=Severity.MEDIUM, source=Source.QA, + ) + # Every scoped run reports "no tests matched" (the named tests don't exist) + mock_run.return_value = _gate_result(GateStatus.FAILED, 5, "no tests ran") + + results = run_proof(workspace, full=True) + req_id = list(results.keys())[0] + assert any(o == GateOutcome.FAILED for _, o in results[req_id]) + + from codeframe.core.proof import ledger + + req = ledger.get_requirement(workspace, req_id) + assert req.status == ReqStatus.OPEN + + @patch("codeframe.core.gates.run") + def test_named_tests_passing_satisfies(self, mock_run, workspace): + from codeframe.core.proof.capture import capture_requirement + from codeframe.core.proof.runner import run_proof + + capture_requirement( + workspace, title="Bug", description="Logic error in calculation", + where="src/calc.py", severity=Severity.MEDIUM, source=Source.QA, + ) + mock_run.return_value = _gate_result(GateStatus.PASSED, 0, "1 passed") + + results = run_proof(workspace, full=True) + req_id = list(results.keys())[0] + assert all(o == GateOutcome.PASSED for _, o in results[req_id]) + + from codeframe.core.proof import ledger + + req = ledger.get_requirement(workspace, req_id) + assert req.status == ReqStatus.SATISFIED diff --git a/tests/core/test_proof_runner_outcomes.py b/tests/core/test_proof_runner_outcomes.py index 3ed57508..c70cb96c 100644 --- a/tests/core/test_proof_runner_outcomes.py +++ b/tests/core/test_proof_runner_outcomes.py @@ -152,7 +152,7 @@ def test_mixed_pass_and_unverifiable_stays_open(self, workspace): with patch( "codeframe.core.proof.runner._run_gate", - side_effect=lambda ws, gate: ( + side_effect=lambda ws, gate, rules=(): ( (GateOutcome.PASSED, "ok") if gate == Gate.UNIT else (GateOutcome.UNVERIFIABLE, "cannot verify") @@ -203,7 +203,7 @@ def test_failure_plus_unverifiable_fails_strict(self, workspace): save_requirement(workspace, _make_req("REQ-F3", [Gate.UNIT, Gate.E2E])) with patch( "codeframe.core.proof.runner._run_gate", - side_effect=lambda ws, gate: ( + side_effect=lambda ws, gate, rules=(): ( (GateOutcome.FAILED, "boom") if gate == Gate.UNIT else (GateOutcome.UNVERIFIABLE, "cannot verify") From 2e4c07cc3a26fbbe5eb703be9c48ab2804c168ce Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:33:10 -0700 Subject: [PATCH 2/3] review: guard empty gate checks, warn on unenforceable rules, pin gate=None no-op Addresses claude-review findings on #802: explicit FAILED when gates.run returns no checks, per-requirement warning for rules with no resolvable gate, docstring note on per-rule subprocesses, and a test pinning that gate=None rules fall back to whole-suite behavior. --- codeframe/core/proof/runner.py | 16 ++++++++++++++- tests/core/test_proof_evidence_rules.py | 26 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/codeframe/core/proof/runner.py b/codeframe/core/proof/runner.py index bfa12eaa..d120e75b 100644 --- a/codeframe/core/proof/runner.py +++ b/codeframe/core/proof/runner.py @@ -97,6 +97,10 @@ def _run_gate( and a named test that doesn't exist is a FAILED obligation — a green whole-suite run proves nothing about a test that was never written. Rules with ``must_pass=False`` are informational only. + + Each enforced rule deliberately gets its own pytest subprocess — do not + collapse them into one ``-k "a or b"`` run; per-rule exit codes are what + distinguish "named test missing" from "collected but failing". """ core_gate_name = _GATE_TO_CORE.get(gate) if not core_gate_name: @@ -124,7 +128,10 @@ def _run_gate( test_selector=rule.test_id, ) check = result.checks[0] if result.checks else None - if check is not None and check.exit_code == 5: + if check is None: + lines.append(f"{rule.test_id}: FAILED — no gate check returned") + all_passed = False + elif check.exit_code == 5: lines.append(f"{rule.test_id}: FAILED — named test missing (not collected)") all_passed = False elif result.passed: @@ -229,6 +236,13 @@ def run_proof( req_results: list[tuple[Gate, GateOutcome]] = [] + unresolved = [r.test_id for r in req.evidence_rules if r.gate is None] + if unresolved: + logger.warning( + "REQ %s: %d evidence rule(s) with no resolvable gate are not enforced: %s", + req.id, len(unresolved), unresolved, + ) + for obl in req.obligations: # Apply gate filter if gate_filter and obl.gate != gate_filter: diff --git a/tests/core/test_proof_evidence_rules.py b/tests/core/test_proof_evidence_rules.py index a1a7e364..be5a16f9 100644 --- a/tests/core/test_proof_evidence_rules.py +++ b/tests/core/test_proof_evidence_rules.py @@ -293,6 +293,32 @@ def test_green_suite_but_missing_named_test_not_satisfied(self, mock_run, worksp req = ledger.get_requirement(workspace, req_id) assert req.status == ReqStatus.OPEN + @patch("codeframe.core.gates.run") + def test_gate_none_rules_are_ignored_not_errored(self, mock_run, workspace): + """A rule whose gate could not be resolved (legacy, unknown prefix) is + skipped: whole-suite behavior, no enforcement, no crash.""" + from codeframe.core.proof import ledger + from codeframe.core.proof.capture import capture_requirement + from codeframe.core.proof.runner import run_proof + + req, _ = capture_requirement( + workspace, title="Bug", description="Logic error in calculation", + where="src/calc.py", severity=Severity.MEDIUM, source=Source.QA, + ) + # Unknown prefix → prefix-derivation on load also yields None + for i, rule in enumerate(req.evidence_rules): + rule.gate = None + rule.test_id = f"regression_check_{i}" + ledger.save_requirement(workspace, req) + + mock_run.return_value = _gate_result(GateStatus.PASSED, 0, "suite green") + results = run_proof(workspace, full=True) + assert all(o == GateOutcome.PASSED for _, o in results[req.id]) + # No scoped runs happened — every call was a whole-suite invocation + assert all( + c.kwargs.get("test_selector") is None for c in mock_run.call_args_list + ) + @patch("codeframe.core.gates.run") def test_named_tests_passing_satisfies(self, mock_run, workspace): from codeframe.core.proof.capture import capture_requirement From aa40ea82924bd03800b2c5fc156c83ce97c6ec29 Mon Sep 17 00:00:00 2001 From: Frank Bria <136862992+frankbria@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:44:43 -0700 Subject: [PATCH 3/3] review: harden enforcement per CodeRabbit findings - invalid persisted gate values fall back to test_id prefix derivation instead of crashing requirement load - SKIPPED/ERROR pytest checks fail enforcement (pytest unavailable is not proof) - must_pass rules without a pytest-style test_id fail loudly instead of vanishing silently --- codeframe/core/proof/ledger.py | 11 ++++++++-- codeframe/core/proof/runner.py | 14 ++++++++++-- tests/core/test_proof_evidence_rules.py | 29 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/codeframe/core/proof/ledger.py b/codeframe/core/proof/ledger.py index 37b1e820..c2e1f949 100644 --- a/codeframe/core/proof/ledger.py +++ b/codeframe/core/proof/ledger.py @@ -199,13 +199,20 @@ def _evidence_rules_to_json(rules: list[EvidenceRule]) -> str: def _evidence_rules_from_json(raw: str) -> list[EvidenceRule]: from codeframe.core.proof.obligations import gate_from_test_id + def _resolve_gate(d: dict) -> Optional[Gate]: + # Legacy rows lack "gate"; invalid persisted values (schema drift, + # manual edits) also fall back to test_id prefix derivation. + try: + return Gate(d["gate"]) + except (KeyError, ValueError): + return gate_from_test_id(d["test_id"]) + data = json.loads(raw) return [ EvidenceRule( test_id=d["test_id"], must_pass=d.get("must_pass", True), - # Legacy rows lack "gate" — derive from the test_id prefix convention - gate=Gate(d["gate"]) if d.get("gate") else gate_from_test_id(d["test_id"]), + gate=_resolve_gate(d), ) for d in data ] diff --git a/codeframe/core/proof/runner.py b/codeframe/core/proof/runner.py index d120e75b..800c8054 100644 --- a/codeframe/core/proof/runner.py +++ b/codeframe/core/proof/runner.py @@ -116,6 +116,7 @@ def _run_gate( # e.g. SEC's test_sec_* rules are pytest tests even though the SEC # gate's own runner is ruff. enforced = [r for r in rules if r.must_pass and r.test_id.startswith("test_")] + unenforceable = [r for r in rules if r.must_pass and not r.test_id.startswith("test_")] lines: list[str] = [] all_passed = True @@ -134,12 +135,21 @@ def _run_gate( elif check.exit_code == 5: lines.append(f"{rule.test_id}: FAILED — named test missing (not collected)") all_passed = False - elif result.passed: + elif check.status == core_gates.GateStatus.PASSED: lines.append(f"{rule.test_id}: passed") else: - lines.append(f"{rule.test_id}: FAILED") + # SKIPPED (pytest unavailable) and ERROR (timeout) are not + # proof — enforcement needs a positive pass, unlike the + # whole-suite path where SKIPPED counts as passing. + lines.append(f"{rule.test_id}: FAILED ({check.status.value})") all_passed = False + # A must_pass rule we cannot enforce must not silently count as + # satisfied — that is the exact bug this module exists to prevent. + for rule in unenforceable: + lines.append(f"{rule.test_id}: FAILED — must_pass rule has no pytest-style test_id") + all_passed = False + # Run the gate's own runner unless it is pytest and the enforced rules # already covered it (scoped runs replace the whole-suite run). if core_gate_name != "pytest" or not enforced: diff --git a/tests/core/test_proof_evidence_rules.py b/tests/core/test_proof_evidence_rules.py index be5a16f9..93faca7b 100644 --- a/tests/core/test_proof_evidence_rules.py +++ b/tests/core/test_proof_evidence_rules.py @@ -91,6 +91,12 @@ def test_legacy_rules_derive_gate_from_prefix(self): assert rules[1].gate == Gate.CONTRACT assert rules[2].gate is None + def test_invalid_persisted_gate_falls_back_to_prefix(self): + from codeframe.core.proof.ledger import _evidence_rules_from_json + + raw = json.dumps([{"test_id": "test_unit_foo", "must_pass": True, "gate": "bogus"}]) + assert _evidence_rules_from_json(raw)[0].gate == Gate.UNIT + # --- gates.py selector threading --- @@ -262,6 +268,29 @@ def test_unit_stub_name_matches_evidence_rule(self): rule = suggest_evidence_rules(gate, title)[0] assert f"def {rule.test_id}(" in stubs[gate] + @patch("codeframe.core.gates.run") + def test_skipped_pytest_check_fails_enforcement(self, mock_run, workspace): + """pytest unavailable (SKIPPED) is not proof — the rule fails.""" + from codeframe.core.proof.runner import _run_gate + + mock_run.return_value = _gate_result(GateStatus.SKIPPED, None, "pytest not found") + outcome, output = _run_gate(workspace, Gate.UNIT, [self._rule()]) + assert outcome == GateOutcome.FAILED + assert "SKIPPED" in output + + @patch("codeframe.core.gates.run") + def test_non_pytest_style_must_pass_rule_fails(self, mock_run, workspace): + """A must_pass rule that cannot be enforced must not silently pass.""" + from codeframe.core.proof.runner import _run_gate + + mock_run.return_value = _gate_result(GateStatus.PASSED, 0) + outcome, output = _run_gate( + workspace, Gate.UNIT, [self._rule("custom_check_foo")] + ) + assert outcome == GateOutcome.FAILED + assert "custom_check_foo" in output + assert "no pytest-style test_id" in output + def test_unmapped_gate_still_unverifiable(self, workspace): from codeframe.core.proof.runner import _run_gate