From 981fcf2d3ae71b21f7511f39cea2403febca59ed Mon Sep 17 00:00:00 2001 From: Kris Wong Date: Thu, 22 Oct 2026 09:46:12 -0500 Subject: [PATCH] fix(edit): make the test-weakening guard opt-in (LEMONCROW_TEST_CONTRACT_GUARD=1) Co-Authored-By: lemoncrow <302591943+lemoncrow-agent[bot]@users.noreply.github.com> LemonCrow-Session: 92cb5b4c-3e6f-4a9d-943c-7ce1c04bdb28 LemonCrow-Model: claude-opus-5-5 --- src/lemoncrow/gateway/adapters/mcp_server.py | 9 ++--- tests/gateway/cli/test_runtime.py | 1 + tests/gateway/test_mcp_tool_handlers.py | 36 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/lemoncrow/gateway/adapters/mcp_server.py b/src/lemoncrow/gateway/adapters/mcp_server.py index 0ce1c3f62..5e0dc86aa 100644 --- a/src/lemoncrow/gateway/adapters/mcp_server.py +++ b/src/lemoncrow/gateway/adapters/mcp_server.py @@ -7023,8 +7023,9 @@ def _looks_like_test_path(path: str) -> bool: # fixing the code. We trip ONLY on that signal -- additive edits (new tests or # assertions) and in-place assertion modifications pass freely. Heuristic and # language-fuzzy by design: it errs toward allowing, surfacing a counterexample + -# rollback only on a clear net assertion-removal or skip/xfail addition. Operator -# off-switch: LEMONCROW_TEST_CONTRACT_GUARD=0. +# rollback only on a clear net assertion-removal or skip/xfail addition. Off by +# default (it blocked legitimate test-only cleanup); opt in with +# LEMONCROW_TEST_CONTRACT_GUARD=1. _ASSERTION_RE = re.compile(r"\bassert|\bexpect\s*\(|\bEXPECT_|\bASSERT_|\.should\b") _SKIP_XFAIL_RE = re.compile( r"@(?:pytest\.mark\.)?(?:skip|skipif|xfail)\b|@unittest\.skip|\bt\.Skip[a-zA-Z]*\(" @@ -7033,8 +7034,8 @@ def _looks_like_test_path(path: str) -> bool: def _test_contract_guard_enabled() -> bool: - """Whether the test-weakening guard runs (operator off-switch, default on).""" - return os.environ.get("LEMONCROW_TEST_CONTRACT_GUARD", "").strip().lower() not in ("0", "false", "no", "off") + """Whether the test-weakening guard runs (operator opt-in, default off).""" + return os.environ.get("LEMONCROW_TEST_CONTRACT_GUARD", "").strip().lower() in ("1", "true", "yes", "on") def _classify_test_weakening(old_content: str, new_content: str) -> str | None: diff --git a/tests/gateway/cli/test_runtime.py b/tests/gateway/cli/test_runtime.py index 5c43fdffa..9a1b21fb2 100644 --- a/tests/gateway/cli/test_runtime.py +++ b/tests/gateway/cli/test_runtime.py @@ -168,6 +168,7 @@ def test_owned_edit_blocks_test_weakening(tmp_path, monkeypatch) -> None: # The owned runtime no longer publishes a self-authorize bypass; an edit that # WEAKENS a test (removes an assertion) is rolled back by the detector. monkeypatch.setenv("CLAUDE_WORKSPACE_ROOT", str(tmp_path)) + monkeypatch.setenv("LEMONCROW_TEST_CONTRACT_GUARD", "1") target = tmp_path / "tests" / "test_contract.py" target.parent.mkdir() target.write_text("def test_x():\n assert value == 'old'\n assert other == 1\n", encoding="utf-8") diff --git a/tests/gateway/test_mcp_tool_handlers.py b/tests/gateway/test_mcp_tool_handlers.py index 79c3a49c2..f5ce18f13 100644 --- a/tests/gateway/test_mcp_tool_handlers.py +++ b/tests/gateway/test_mcp_tool_handlers.py @@ -1367,6 +1367,7 @@ def test_smart_edit_blocks_test_assertion_removal( # roll back and surface a counterexample (the detector trips only on weakening). _ = store_root monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LEMONCROW_TEST_CONTRACT_GUARD", "1") target = Path("tests/test_parser.py") target.parent.mkdir() target.write_text("def test_x():\n assert compute() == 5\n assert other() == 9\n", encoding="utf-8") @@ -1396,11 +1397,43 @@ def test_smart_edit_blocks_test_assertion_removal( assert "assert compute() == 5" in target.read_text(encoding="utf-8") +def test_smart_edit_test_contract_guard_off_by_default( + store_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _ = store_root + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("LEMONCROW_TEST_CONTRACT_GUARD", raising=False) + target = Path("tests/test_parser.py") + target.parent.mkdir() + target.write_text("def test_x():\n assert compute() == 5\n assert other() == 9\n", encoding="utf-8") + + payload = _result( + _call( + "edit", + { + "edits": [ + { + "file_path": str(target), + "old_string": " assert compute() == 5\n assert other() == 9\n", + "new_string": " assert other() == 9\n", + } + ], + "post_edit_hooks": False, + }, + ) + ) + + assert payload.get("rolled_back") is not True + assert "test_weakening" not in payload + assert "assert compute() == 5" not in target.read_text(encoding="utf-8") + + def test_smart_edit_blocks_test_skip_addition( store_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _ = store_root monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LEMONCROW_TEST_CONTRACT_GUARD", "1") target = Path("tests/test_parser.py") target.parent.mkdir() target.write_text("def test_x():\n assert compute() == 5\n", encoding="utf-8") @@ -1432,6 +1465,7 @@ def test_smart_edit_allows_additive_test_edit( # Adding a new assertion to an existing test passes freely -- no friction. _ = store_root monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LEMONCROW_TEST_CONTRACT_GUARD", "1") target = Path("tests/test_parser.py") target.parent.mkdir() target.write_text("def test_x():\n assert compute() == 5\n", encoding="utf-8") @@ -1464,6 +1498,7 @@ def test_smart_edit_allows_assertion_value_change( # assertions and passes -- the detector guards removal/skip, not value edits. _ = store_root monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LEMONCROW_TEST_CONTRACT_GUARD", "1") target = Path("tests/test_parser.py") target.parent.mkdir() target.write_text("def test_x():\n assert compute() == 5\n", encoding="utf-8") @@ -1496,6 +1531,7 @@ def test_smart_edit_allows_test_weakening_paired_with_production_change( # as a genuine refactor (contract moved with the code), not a reward-hack. _ = store_root monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LEMONCROW_TEST_CONTRACT_GUARD", "1") test_file = Path("tests/test_parser.py") test_file.parent.mkdir() test_file.write_text("def test_x():\n assert compute() == 5\n assert other() == 9\n", encoding="utf-8")