Files
werkrevisor/tests/test_rules.py
mhoennigandClaude Opus 5.5 7f951ef008 Add werkrevisor rule engine, PR-Agent review and Gitea gate flow
Deterministic rules, verdict mapping, budget checks, idempotent Gitea
publishing and the PR-Agent 0.47.0 integration, with unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:32:57 +02:00

167 lines
8.0 KiB
Python

import unittest
from tests.fakes import config, diff_for, gitea_file
from werkrevisor.diff import parse_unified_diff
from werkrevisor.globs import matches
from werkrevisor.rules import categorize, evaluate_rules, files_from_gitea, scan_secrets
from werkrevisor.verdict import Verdict
def evaluate(files, diff="", **overrides):
return evaluate_rules(files_from_gitea(files), parse_unified_diff(diff), config(**overrides))
class GlobTest(unittest.TestCase):
def test_double_star_matches_any_depth(self):
self.assertTrue(matches("README.md", "**/*.md"))
self.assertTrue(matches("doc/PR/302.md", "**/*.md"))
self.assertTrue(matches("src/test/java/A.java", "src/test/**"))
self.assertFalse(matches("src/main/java/A.java", "src/test/**"))
def test_pattern_without_slash_matches_file_name_anywhere(self):
self.assertTrue(matches("build.gradle.kts", "build.gradle*"))
self.assertTrue(matches("sub/build.gradle", "build.gradle*"))
self.assertFalse(matches("src/a.java", "*.md"))
def test_single_star_stays_in_one_directory(self):
self.assertFalse(matches("doc/a/b.md", "doc/*.md"))
self.assertTrue(matches("doc/b.md", "doc/*.md"))
class DiffParserTest(unittest.TestCase):
def test_line_numbers_and_files(self):
text = (diff_for("a.txt", added=["x", "y"], removed=["old"])
+ diff_for("gone.txt", removed=["z"], deleted=True)
+ diff_for("new.txt", added=["n"], new=True))
files = parse_unified_diff(text)
self.assertEqual(set(files), {"a.txt", "gone.txt", "new.txt"})
self.assertEqual(files["a.txt"].added, [(1, "x"), (2, "y")])
self.assertEqual(files["a.txt"].removed, [(1, "old")])
self.assertTrue(files["gone.txt"].deleted)
self.assertTrue(files["new.txt"].new)
def test_added_line_that_looks_like_a_header_stays_content(self):
text = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1,0 +1,1 @@\n++++ not a header\n"
self.assertEqual(parse_unified_diff(text)["f"].added, [(1, "+++ not a header")])
class CategoryTest(unittest.TestCase):
def test_categories(self):
c = config().categories
self.assertEqual(categorize("doc/PR/1.md", c), "docs")
self.assertEqual(categorize("src/test/java/ATest.java", c), "tests")
self.assertEqual(categorize("src/main/java/A.java", c), "main")
self.assertEqual(categorize("src/main/resources/api-definition/x.yaml", c), "api_spec")
self.assertEqual(categorize("Makefile", c), "other")
def test_docs_only_is_approvable(self):
outcome = evaluate([gitea_file("README.md")], diff_for("README.md", added=["text"]))
self.assertEqual(outcome.change_kind, "docs_only")
self.assertTrue(outcome.auto_approvable)
self.assertEqual(outcome.verdict, Verdict.GREEN)
def test_added_tests_with_docs(self):
diff = diff_for("src/test/java/ATest.java", added=["@Test void x() { assertThat(1).isOne(); }"])
outcome = evaluate([gitea_file("src/test/java/ATest.java"), gitea_file("doc/x.md")], diff)
self.assertEqual(outcome.change_kind, "docs_and_tests")
self.assertEqual(outcome.verdict, Verdict.GREEN)
def test_removed_assertion_is_not_tests_only_added(self):
diff = diff_for("src/test/java/ATest.java", removed=[" assertThat(x).isEqualTo(1);"])
outcome = evaluate([gitea_file("src/test/java/ATest.java", deletions=1)], diff)
self.assertEqual(outcome.change_kind, "tests_modified")
self.assertEqual(outcome.verdict, Verdict.YELLOW)
self.assertIn("tests_weakened", [r.code for r in outcome.reasons])
def test_disabled_test_is_not_tests_only_added(self):
diff = diff_for("src/test/java/ATest.java", added=[" @Disabled"])
outcome = evaluate([gitea_file("src/test/java/ATest.java")], diff)
self.assertEqual(outcome.change_kind, "tests_modified")
def test_deleted_test_file_is_not_tests_only_added(self):
outcome = evaluate([gitea_file("src/test/java/ATest.java", status="deleted", deletions=3)],
diff_for("src/test/java/ATest.java", removed=["class ATest {}"], deleted=True))
self.assertEqual(outcome.change_kind, "tests_modified")
def test_refactoring_allows_pr_docs(self):
outcome = evaluate([gitea_file("src/main/java/A.java"), gitea_file("doc/PR/1.md")],
diff_for("src/main/java/A.java", added=["int x;"]))
self.assertEqual(outcome.change_kind, "refactoring")
self.assertTrue(outcome.auto_approvable)
def test_main_with_tests_is_mixed(self):
outcome = evaluate([gitea_file("src/main/java/A.java"), gitea_file("src/test/java/ATest.java")])
self.assertEqual(outcome.change_kind, "mixed")
self.assertEqual(outcome.verdict, Verdict.YELLOW)
def test_api_spec_change_is_not_refactoring(self):
outcome = evaluate([gitea_file("src/main/java/A.java"),
gitea_file("src/main/resources/api-definition/api.yaml")])
self.assertEqual(outcome.change_kind, "mixed")
def test_kind_outside_auto_approvable_is_yellow(self):
outcome = evaluate([gitea_file("src/main/java/A.java")],
categories={"auto_approvable": ["docs_only"]})
self.assertFalse(outcome.auto_approvable)
self.assertIn("category", [r.code for r in outcome.reasons])
def test_no_files_is_error(self):
self.assertEqual(evaluate([]).verdict, Verdict.ERROR)
class HardYellowTest(unittest.TestCase):
def test_risky_path_is_yellow_even_for_docs(self):
outcome = evaluate([gitea_file("README.md"), gitea_file("build.gradle.kts")])
self.assertEqual(outcome.verdict, Verdict.YELLOW)
self.assertIn("risky_paths", [r.code for r in outcome.reasons])
def test_renamed_from_risky_path_counts(self):
f = gitea_file("doc/x.md", status="renamed")
f["previous_filename"] = ".gitea/workflows/ci.yaml"
self.assertIn("risky_paths", [r.code for r in evaluate([f]).reasons])
def test_size_limits_skip_the_model(self):
outcome = evaluate([gitea_file("README.md", additions=301)])
self.assertIn("size_lines", [r.code for r in outcome.reasons])
self.assertEqual(outcome.skip_llm_reason, "PR too large")
outcome = evaluate([gitea_file(f"doc/{i}.md") for i in range(16)])
self.assertIn("size_files", [r.code for r in outcome.reasons])
class SecretScanTest(unittest.TestCase):
def scan(self, line):
return scan_secrets(parse_unified_diff(diff_for("a.txt", added=[line])))
def test_detects_secrets(self):
for line in ("-----BEGIN RSA PRIVATE KEY-----",
"-----BEGIN CERTIFICATE-----",
"aws = AKIAABCDEFGHIJKLMNOP",
'password = "correct-horse-battery"',
'api_key: "0123456789abcdef"',
"key hsak_admin." + "0" * 64,
"OPENROUTER=sk-or-v1-" + "f" * 40):
with self.subTest(line=line):
self.assertEqual(len(self.scan(line)), 1)
def test_ignores_harmless_lines(self):
for line in ("format `hsak_<subject-name>.<64 hex>`", 'password = ""', "token = getToken()",
"the sk-or- prefix marks OpenRouter keys"):
with self.subTest(line=line):
self.assertEqual(self.scan(line), [])
def test_hit_names_location_but_not_the_value(self):
hits = self.scan('secret = "supersecretvalue123"')
self.assertEqual(hits, ["a.txt:1 (generic_secret)"])
def test_secret_skips_the_model(self):
outcome = evaluate([gitea_file("README.md")], diff_for("README.md", added=["-----BEGIN PRIVATE KEY-----"]))
self.assertEqual(outcome.skip_llm_reason, "possible secrets in the diff")
self.assertEqual(outcome.verdict, Verdict.YELLOW)
def test_removed_lines_are_not_scanned(self):
self.assertEqual(scan_secrets(parse_unified_diff(diff_for("a", removed=["-----BEGIN X"]))), [])
if __name__ == "__main__":
unittest.main()