Files
claude-code c260eab803
werkator build succeeded after 00:14
werkator/werkrevisor-statistics build succeeded after 00:35
werkator/release build succeeded after 00:10
werkrevisor 0.49.0: Jev checks each changed file against the PR document (#117)
See [docs/prs/2026-10-17-PR#117-doccheck-variant-b.md](docs/prs/2026-10-17-PR%23117-doccheck-variant-b.md).

Replaces the economy-model summary of the doc check with one Jev call per changed file, and adds a data-outflow check.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #117
Co-authored-by: claude-code <claude-code@javagil.de>
2026-10-17 17:10:28 +02:00

283 lines
14 KiB
Python

import json
import unittest
from tests.fakes import FakeGitea, FakeJev, FakeModel, config, deps, diff_for, gitea_file, pr_ref
from werkrevisor import doccheck
from werkrevisor.cli import execute
from werkrevisor.config import ConfigError
from werkrevisor.redact import Redactor
from werkrevisor.verdict import Verdict
APP = "src/main/java/App.java"
APPTEST = "src/test/java/AppTest.java"
DOC = "doc/PR/2026-10-08-PR#7-feature.md"
DOC_TEXT = """# PR#7: a feature
## The Problem
Something is missing.
## The Solution
The PR adds the endpoint and its tests.
"""
# The first changed line of APP's diff, in the new version.
APP_LINE = 1
def doc_gitea(app_lines=("class App {}",), extra=()):
files = [gitea_file(APP), gitea_file(DOC, status="added")] + [gitea_file(path) for path, _ in extra]
diff = diff_for(APP, added=list(app_lines)) + diff_for(DOC, added=DOC_TEXT.splitlines(), new=True)
for path, lines in extra:
diff += diff_for(path, added=list(lines))
gitea = FakeGitea(files=files, diff=diff)
gitea.raw_files[DOC] = DOC_TEXT.encode()
return gitea
def check_config(**doc_check):
# Jev judges every changed file, so a GREEN needs no model review.
return config(engine={"mode": "classifier"}, gate={"yellow_gate": "block"},
hard_yellow={"paths": [], "judged_paths": []}, path_judge={"judge": "jev", "scope": "all"},
doc_check=doc_check)
def check_run(jev=None, gitea=None, cfg=None):
gitea = gitea or doc_gitea()
d = deps(gitea, FakeModel(prompt_files=[APP, DOC]), None, jev or FakeJev())
outcome = execute(cfg or check_config(), pr_ref(), d, Redactor())
return outcome, d, gitea
def reason(outcome, code):
return next(r for r in outcome.result.reasons if r.code == code)
def doc_requests(jev):
"""The Jev calls of the document check: their state holds a changed file and the PR document."""
return [(s, q) for s, q in jev.requests if isinstance(s, dict) and "changed_file" in s]
def resolve_all(gitea):
for comments in gitea.line_comments.values():
for c in comments:
c["resolver"] = {"login": "claude-code"}
def second_round(gitea, jev):
"""A new commit: the earlier result comment belongs to the old head, the line comments stay."""
gitea.comments = []
return check_run(jev, gitea=gitea)
class DocCheckTest(unittest.TestCase):
def test_a_conflicting_test_claim_gets_orange_at_the_file(self):
"""Scenario#117.01"""
jev = FakeJev(problems={(APP, "doc_test_claim_conflict"): 0.9})
outcome, _, gitea = check_run(jev)
self.assertIs(outcome.result.verdict, Verdict.ORANGE)
self.assertIs(reason(outcome, "doc_contradiction").verdict, Verdict.ORANGE)
[comment] = gitea.review_comments[0]
self.assertEqual((comment["path"], comment["new_position"]), (APP, APP_LINE))
self.assertIn("Test claim contradicts the file", comment["body"])
self.assertIn("File fingerprint", comment["body"])
def test_an_unrelated_file_gets_a_hint(self):
"""Scenario#117.02"""
jev = FakeJev(problems={(APP, "doc_change_unrelated"): 0.8})
outcome, _, gitea = check_run(jev)
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertEqual(reason(outcome, "doc_change_unrelated").text,
f"the PR document may not require the changes of these files according to Jev: {APP}")
[comment] = gitea.review_comments[0]
self.assertEqual((comment["path"], comment["new_position"]), (APP, APP_LINE))
self.assertIn("Change not required by the PR document", comment["body"])
def test_a_data_outflow_gets_a_hint(self):
"""Scenario#117.03"""
jev = FakeJev(problems={(APP, "doc_data_outflow"): 0.8})
outcome, _, gitea = check_run(jev)
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertEqual(reason(outcome, "doc_data_outflow").text,
f"these files may send personal or customer data outside according to Jev: {APP}")
[comment] = gitea.review_comments[0]
self.assertEqual((comment["path"], comment["new_position"]), (APP, APP_LINE))
self.assertIn("Possible data outflow", comment["body"])
self.assertIn("possible data outflow 0.80", comment["body"])
def test_jev_gets_each_file_with_the_pr_document_and_all_questions(self):
"""Scenario#117.04"""
jev = FakeJev()
check_run(jev, gitea=doc_gitea(extra=[(APPTEST, ["assertTrue(true);"])]))
states = [s for s, _ in doc_requests(jev)]
self.assertEqual(sorted(s["changed_file"] for s in states), [APP, APPTEST])
for state in states:
self.assertEqual(state["pr_document"], DOC_TEXT)
self.assertEqual(state["conventions"], doccheck.CONVENTIONS)
self.assertIn("class App {}" if state["changed_file"] == APP else "assertTrue(true);", state["diff"])
[(_, questions)] = doc_requests(jev)[:1]
self.assertEqual(sorted(questions), ["doc_change_unrelated", "doc_claimed_not_done", "doc_data_outflow",
"doc_requirement_conflict", "doc_test_claim_conflict",
"doc_wrong_description"])
def test_a_hint_on_a_markdown_file_holds_the_merge(self):
"""A doc-check hint on a documentation file is a line comment, not a free hint, so it holds the merge."""
md = "docs/guide.md"
gitea = doc_gitea()
gitea.files.append(gitea_file(md))
gitea.diff += diff_for(md, added=["new guidance"])
jev = FakeJev(problems={(md, "doc_change_unrelated"): 0.9})
outcome, _, gitea = check_run(jev, gitea=gitea)
md_comments = [c for cs in gitea.review_comments for c in cs if c["path"] == md]
self.assertEqual(len(md_comments), 1)
self.assertIn("Change not required by the PR document", md_comments[0]["body"])
self.assertIn("doc_change_unrelated", [r.code for r in outcome.result.reasons])
def test_a_mechanical_file_is_not_checked(self):
"""Scenario#117.05"""
jev = FakeJev()
imports = "src/main/java/Imports.java"
check_run(jev, gitea=doc_gitea(extra=[(imports, ["import java.util.List;"])]))
self.assertEqual([s["changed_file"] for s, _ in doc_requests(jev)], [APP])
def test_a_low_value_gives_no_comment(self):
jev = FakeJev(problems={(APP, "doc_test_claim_conflict"): 0.5})
outcome, _, gitea = check_run(jev)
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertEqual(reason(outcome, "doc_check_passed").text,
"1 changed file(s) agree with the PR document according to Jev")
self.assertFalse(any(gitea.review_comments))
self.assertEqual(len(doc_requests(jev)), 1)
def test_a_contradiction_below_hint_min_is_no_hint(self):
jev = FakeJev(problems={(APP, "doc_wrong_description"): 0.65})
outcome, _, _ = check_run(jev)
self.assertNotIn("doc_contradiction_hint", [r.code for r in outcome.result.reasons])
def test_another_contradiction_is_a_hint(self):
jev = FakeJev(problems={(APP, "doc_wrong_description"): 0.75})
outcome, _, gitea = check_run(jev)
self.assertIs(outcome.result.verdict, Verdict.GREEN)
self.assertIs(reason(outcome, "doc_contradiction_hint").verdict, Verdict.GREEN)
[comment] = gitea.review_comments[0]
self.assertIn("Description contradicts the file", comment["body"])
def test_a_secret_keeps_the_diff_from_jev(self):
"""Scenario#47.06"""
jev = FakeJev()
outcome, _, _ = check_run(jev, gitea=doc_gitea(["-----BEGIN " + "PRIVATE KEY-----"]))
self.assertEqual(doc_requests(jev), [])
self.assertIn("possible secrets", reason(outcome, "doc_check_skipped").text)
def test_the_usage_record_logs_the_values_per_file(self):
"""Scenario#117.07"""
jev = FakeJev(problems={(APP, "doc_wrong_description"): 0.3})
_, d, _ = check_run(jev)
record = json.loads(d.out.getvalue().strip())
self.assertEqual((record["doc_check_model"], record["doc_check_calls"]), ("jev-test", 1))
self.assertEqual(record["doc_check_input_tokens"], 1000)
[row] = record["doc_check_judgments"]
self.assertEqual((row["file"], row["line"]), (APP, APP_LINE))
self.assertAlmostEqual(row["values"]["doc_wrong_description"], 0.3)
self.assertIn("fingerprint", row)
class DeletionTest(unittest.TestCase):
def test_a_deleted_file_is_checked_and_anchored_at_the_pr_document(self):
"""A deleted file has no new-version line, so its hint joins one comment at the PR document."""
gitea = doc_gitea()
gitea.files.append(gitea_file("src/main/java/Gone.java", status="removed", additions=0, deletions=1))
gitea.diff += diff_for("src/main/java/Gone.java", removed=["class Gone {}"], deleted=True)
jev = FakeJev(problems={("src/main/java/Gone.java", "doc_change_unrelated"): 0.8})
outcome, _, gitea = check_run(jev, gitea=gitea)
[comment] = gitea.review_comments[0]
self.assertEqual((comment["path"], comment["new_position"]), (DOC, 1))
self.assertIn("Deleted or deletion-only file", comment["body"])
self.assertIn("src/main/java/Gone.java", comment["body"])
self.assertEqual(reason(outcome, "doc_change_unrelated").text,
"the PR document may not require the changes of these files according to Jev: "
"src/main/java/Gone.java")
def test_a_deletion_only_file_is_anchored_at_the_pr_document(self):
"""A modified file whose only change removes lines gets no comment beyond the end of the file."""
gitea = doc_gitea()
gitea.files.append(gitea_file("src/main/java/Trim.java", status="modified", additions=0, deletions=2))
gitea.diff += diff_for("src/main/java/Trim.java", removed=["int a;", "int b;"])
jev = FakeJev(problems={("src/main/java/Trim.java", "doc_data_outflow"): 0.8})
_, _, gitea = check_run(jev, gitea=gitea)
[comment] = gitea.review_comments[0]
self.assertEqual((comment["path"], comment["new_position"]), (DOC, 1))
self.assertIn("src/main/java/Trim.java", comment["body"])
class DocCheckScopeTest(unittest.TestCase):
def test_a_pr_without_a_changed_pr_document_is_not_compared(self):
jev = FakeJev()
gitea = FakeGitea(files=[gitea_file(APP)], diff=diff_for(APP, added=["x"]))
outcome, _, _ = check_run(jev, gitea=gitea)
self.assertEqual(doc_requests(jev), [])
self.assertFalse(any(r.code.startswith("doc_check") for r in outcome.result.reasons))
def test_a_pr_without_a_non_doc_file_is_not_compared(self):
jev = FakeJev()
gitea = FakeGitea(files=[gitea_file(DOC, status="added")],
diff=diff_for(DOC, added=DOC_TEXT.splitlines(), new=True))
gitea.raw_files[DOC] = DOC_TEXT.encode()
outcome, _, _ = check_run(jev, gitea=gitea)
self.assertEqual(doc_requests(jev), [])
self.assertFalse(any(r.code.startswith("doc_check") for r in outcome.result.reasons))
class DocCheckConfigTest(unittest.TestCase):
def test_values_are_validated(self):
for values, message in (({"orange_min": 1.5}, r"\[doc_check\] orange_min must be a number between 0 and 1"),
({"hint_min": 0.8}, r"\[doc_check\] hint_min must not be above orange_min"),
({"unrelated_min": 1.5},
r"\[doc_check\] unrelated_min must be a number between 0 and 1"),
({"exfil_min": 1.5}, r"\[doc_check\] exfil_min must be a number between 0 and 1")):
with self.subTest(values=values):
with self.assertRaisesRegex(ConfigError, message):
check_config(**values)
def test_the_check_needs_jev(self):
with self.assertRaisesRegex(ConfigError, r"\[doc_check\] needs \[engine\] mode 'classifier' or 'combined'"):
config(engine={"mode": "llm"}, doc_check={})
class AnsweredHintTest(unittest.TestCase):
def setUp(self):
self.gitea = doc_gitea()
self.jev = FakeJev(problems={(APP, "doc_wrong_description"): 0.75})
check_run(self.jev, gitea=self.gitea)
[comment] = self.gitea.review_comments[0]
self.assertIn("File fingerprint", comment["body"])
resolve_all(self.gitea)
def test_a_resolved_hint_is_not_repeated(self):
"""Scenario#117.06"""
outcome, _, _ = second_round(self.gitea, self.jev)
self.assertFalse(any(self.gitea.review_comments[1:]))
self.assertEqual(reason(outcome, "doc_check_answered").text,
"1 hint(s) of the PR document check not repeated: "
"the author resolved them, and the files and the PR document are unchanged")
self.assertNotIn("doc_contradiction_hint", [r.code for r in outcome.result.reasons])
self.assertIs(outcome.result.verdict, Verdict.GREEN)
def test_a_changed_file_is_checked_again(self):
gitea = doc_gitea(["class App { int x; }"])
outcome, _, _ = check_run(self.jev, gitea=gitea)
self.assertIn("doc_contradiction_hint", [r.code for r in outcome.result.reasons])
def test_an_orange_contradiction_comes_back(self):
jev = FakeJev(problems={(APP, "doc_test_claim_conflict"): 0.9})
gitea = doc_gitea()
check_run(jev, gitea=gitea)
resolve_all(gitea)
outcome, _, _ = second_round(gitea, jev)
self.assertIs(outcome.result.verdict, Verdict.ORANGE)
self.assertEqual(len(gitea.review_comments[-1]), 1)
if __name__ == "__main__":
unittest.main()