| 1 | #!/usr/bin/env python3 |
| 2 | """Hermetic tests for scripts/check-dead-code-budget.py. |
| 3 | |
| 4 | The property under test is the one #6241 reported missing: the gate must |
| 5 | ratchet on dead-code *suppression*, not on one spelling of it. Rewriting |
| 6 | `#[allow(dead_code)]` as `#[expect(dead_code)]` removes no dead code, so it |
| 7 | must not lower the headline number. |
| 8 | """ |
| 9 | |
| 10 | from __future__ import annotations |
| 11 | |
| 12 | import importlib.util |
| 13 | import io |
| 14 | import json |
| 15 | import sys |
| 16 | import tempfile |
| 17 | import unittest |
| 18 | from contextlib import redirect_stderr, redirect_stdout |
| 19 | from pathlib import Path |
| 20 | |
| 21 | ROOT = Path(__file__).resolve().parents[1] |
| 22 | SCRIPT = ROOT / "scripts" / "check-dead-code-budget.py" |
| 23 | |
| 24 | SPEC = importlib.util.spec_from_file_location("check_dead_code_budget", SCRIPT) |
| 25 | assert SPEC and SPEC.loader |
| 26 | mod = importlib.util.module_from_spec(SPEC) |
| 27 | sys.modules[SPEC.name] = mod |
| 28 | SPEC.loader.exec_module(mod) |
| 29 | |
| 30 | |
| 31 | class DeadCodeBudgetTest(unittest.TestCase): |
| 32 | def setUp(self) -> None: |
| 33 | self._tmp = tempfile.TemporaryDirectory() |
| 34 | root = Path(self._tmp.name) |
| 35 | self.crates = root / "crates" |
| 36 | (self.crates / "demo" / "src").mkdir(parents=True) |
| 37 | self.budget = root / "scripts" / "dead-code-budget.json" |
| 38 | self.budget.parent.mkdir(parents=True) |
| 39 | self._saved = (mod.REPO_ROOT, mod.CRATES_DIR, mod.BUDGET_PATH) |
| 40 | mod.REPO_ROOT, mod.CRATES_DIR, mod.BUDGET_PATH = root, self.crates, self.budget |
| 41 | |
| 42 | def tearDown(self) -> None: |
| 43 | mod.REPO_ROOT, mod.CRATES_DIR, mod.BUDGET_PATH = self._saved |
| 44 | self._tmp.cleanup() |
| 45 | |
| 46 | def write_source(self, body: str) -> None: |
| 47 | (self.crates / "demo" / "src" / "lib.rs").write_text(body, encoding="utf-8") |
| 48 | |
| 49 | def write_budget(self, total: int) -> None: |
| 50 | self.budget.write_text(json.dumps({"total": total}) + "\n", encoding="utf-8") |
| 51 | |
| 52 | def run_main(self, *argv: str) -> tuple[int, str, str]: |
| 53 | out, err = io.StringIO(), io.StringIO() |
| 54 | saved = sys.argv |
| 55 | sys.argv = ["check-dead-code-budget.py", *argv] |
| 56 | try: |
| 57 | with redirect_stdout(out), redirect_stderr(err): |
| 58 | code = mod.main() |
| 59 | finally: |
| 60 | sys.argv = saved |
| 61 | return code, out.getvalue(), err.getvalue() |
| 62 | |
| 63 | def test_expect_spelling_is_counted(self) -> None: |
| 64 | """The blind spot itself: `expect(dead_code)` is a suppression too.""" |
| 65 | self.write_source("#[expect(dead_code)]\nfn a() {}\n") |
| 66 | allow_total, expect_total, _ = mod.measure() |
| 67 | self.assertEqual(allow_total, 0) |
| 68 | self.assertEqual(expect_total, 1) |
| 69 | |
| 70 | def test_rewriting_allow_as_expect_does_not_lower_the_total(self) -> None: |
| 71 | """#6241's exact regression: a spelling change is not progress.""" |
| 72 | self.write_source("#[allow(dead_code)]\nfn a() {}\n#[allow(dead_code)]\nfn b() {}\n") |
| 73 | before = sum(mod.measure()[:2]) |
| 74 | self.write_source("#[expect(dead_code)]\nfn a() {}\n#[expect(dead_code)]\nfn b() {}\n") |
| 75 | after = sum(mod.measure()[:2]) |
| 76 | self.assertEqual( |
| 77 | before, |
| 78 | after, |
| 79 | "converting allow->expect removes no dead code and must not move the ratchet", |
| 80 | ) |
| 81 | |
| 82 | def test_gate_fails_when_expect_pushes_past_the_ceiling(self) -> None: |
| 83 | self.write_source("#[allow(dead_code)]\nfn a() {}\n#[expect(dead_code)]\nfn b() {}\n") |
| 84 | self.write_budget(1) |
| 85 | code, _, err = self.run_main() |
| 86 | self.assertEqual(code, 1) |
| 87 | self.assertIn("allow=1", err) |
| 88 | self.assertIn("expect=1", err) |
| 89 | |
| 90 | def test_gate_passes_at_the_combined_ceiling(self) -> None: |
| 91 | self.write_source("#[allow(dead_code)]\nfn a() {}\n#[expect(dead_code)]\nfn b() {}\n") |
| 92 | self.write_budget(2) |
| 93 | code, out, _ = self.run_main() |
| 94 | self.assertEqual(code, 0) |
| 95 | self.assertIn("PASS", out) |
| 96 | |
| 97 | def test_update_records_both_spellings_separately(self) -> None: |
| 98 | self.write_source("#[allow(dead_code)]\nfn a() {}\n#[expect(dead_code)]\nfn b() {}\n") |
| 99 | self.write_budget(0) |
| 100 | code, _, _ = self.run_main("--update") |
| 101 | self.assertEqual(code, 0) |
| 102 | payload = json.loads(self.budget.read_text(encoding="utf-8")) |
| 103 | self.assertEqual(payload["total"], 2) |
| 104 | self.assertEqual(payload["allow_total"], 1) |
| 105 | self.assertEqual(payload["expect_total"], 1) |
| 106 | self.assertEqual(payload["per_crate"]["demo"], {"allow": 1, "expect": 1}) |
| 107 | |
| 108 | def test_legacy_per_crate_shape_still_renders(self) -> None: |
| 109 | """A budget written before #6241 mapped each crate to a bare int.""" |
| 110 | self.write_source("#[allow(dead_code)]\nfn a() {}\n#[expect(dead_code)]\nfn b() {}\n") |
| 111 | self.budget.write_text( |
| 112 | json.dumps({"total": 1, "per_crate": {"demo": 1}}) + "\n", encoding="utf-8" |
| 113 | ) |
| 114 | code, _, err = self.run_main() |
| 115 | self.assertEqual(code, 1) |
| 116 | self.assertIn("budget 1", err) |
| 117 | |
| 118 | |
| 119 | if __name__ == "__main__": |
| 120 | unittest.main() |
| 121 |