ariadne/tests/test_hermes_code_defects.py
codex 54de192c8b
All checks were successful
Tests / Declarative: Post Actions passed: 1237
feat(hermes): make the fix categories an operator setting
The three defect categories were implicit in code: an operator could neither
see which were active nor switch one off. ARIADNE_HERMES_FIX_CATEGORIES now
names them, an unrecognised entry is ignored rather than trusted, and an empty
setting means all three so the default stays obvious.

Kept separate from ARIADNE_HERMES_ALLOWED_ACTIONS on purpose. That allowlist
gates what Ariadne executes on its own authority, with nothing between the
decision and the change. Everything here becomes a pull request a person
reads. Sharing one list would let a patch clear the same gate as an autonomous
mutation, and that distinction is what makes the autonomous half defensible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:46:27 -03:00

310 lines
12 KiB
Python

"""Tests for recognising mechanically-fixable defects in console evidence."""
from __future__ import annotations
from ariadne.services import hermes_code_defects as module
CFG = {"allowed_path_prefixes": ["ariadne/", "src/"], "allowed_suffixes": [".py", ".go", ".ts"]}
def _bundle(*lines: str) -> dict:
return {"jenkins": {"console_failures": [{"text": "\n".join(lines)}], "console_tail": ""}}
def test_ruff_diagnostic_is_extracted_whole() -> None:
found = module.extract_lint_defects(
_bundle("ariadne/app.py:12:5: E501 Line too long (95 > 88)"), CFG
)
assert found == [
{
"category": module.LINT_VIOLATION,
"tool": "ruff",
"path": "ariadne/app.py",
"line": 12,
"rule": "E501",
"message": "Line too long (95 > 88)",
}
]
def test_golangci_diagnostic_is_extracted() -> None:
found = module.extract_lint_defects(_bundle("src/main.go:9:2: unused variable x (govet)"), CFG)
assert found[0]["tool"] == "golangci"
assert (found[0]["path"], found[0]["line"], found[0]["rule"]) == ("src/main.go", 9, "govet")
def test_eslint_diagnostics_inherit_the_preceding_file_header() -> None:
"""eslint prints the path once, then indented diagnostics beneath it."""
found = module.extract_lint_defects(
_bundle("src/panel.ts", " 12:5 error Unexpected console statement no-console"), CFG
)
assert (found[0]["path"], found[0]["line"], found[0]["rule"]) == ("src/panel.ts", 12, "no-console")
def test_a_file_outside_the_write_allowlist_is_dropped() -> None:
"""Reporting a defect the patcher could never write is worse than silence."""
assert module.extract_lint_defects(_bundle("vendor/x.py:1:1: E501 Line too long"), CFG) == []
assert module.extract_lint_defects(_bundle("ariadne/x.rb:1:1: E501 Line too long"), CFG) == []
def test_duplicate_diagnostics_are_collapsed() -> None:
line = "ariadne/app.py:12:5: E501 Line too long (95 > 88)"
assert len(module.extract_lint_defects(_bundle(line, line, line), CFG)) == 1
def test_extraction_is_bounded() -> None:
lines = [f"ariadne/f{i}.py:{i}:1: E501 Line too long" for i in range(50)]
assert len(module.extract_lint_defects(_bundle(*lines), CFG)) == module._MAX_DEFECTS
def test_ordinary_console_noise_matches_nothing() -> None:
noise = _bundle("Running tests...", "OK", "+ ruff check ariadne", "All checks passed!")
assert module.extract_lint_defects(noise, CFG) == []
def test_never_raises_on_hostile_input() -> None:
for bad in (None, {}, {"jenkins": None}, {"jenkins": {"console_failures": "no"}}, 7):
assert module.extract_lint_defects(bad, CFG) == []
assert module.extract_lint_defects(_bundle("ariadne/a.py:1:1: E1 x"), None) == []
def test_instruction_names_rule_and_line() -> None:
defects = module.extract_lint_defects(_bundle("ariadne/app.py:12:5: E501 Line too long"), CFG)
text = module.defect_instruction(defects)
assert "ariadne/app.py line 12: E501" in text
assert "do not suppress the rule" in text
def test_no_defects_yields_no_instruction() -> None:
"""The caller must fall back to the open-ended request, not an empty header."""
assert module.defect_instruction([]) == ""
def test_the_patch_prompt_carries_the_linter_diagnosis(monkeypatch) -> None:
"""The instruction must reach Hermes, not just be computed."""
from ariadne.services import hermes_code_prompt
bundle = {
"jenkins": {
"console_failures": [{"text": "ariadne/app.py:12:5: E501 Line too long (95 > 88)"}],
"console_tail": "",
}
}
cfg = {
"owner": "bstein",
"repo": "ariadne",
"base_branch": "master",
"allowed_path_prefixes": ["ariadne/"],
"allowed_suffixes": [".py"],
}
prompt = hermes_code_prompt.build_patch_prompt("ariadne/1", bundle, cfg, {"ariadne/app.py": "x"})
assert "ariadne/app.py line 12: E501" in prompt
assert "do not suppress the rule" in prompt
def test_the_patch_prompt_is_unchanged_without_diagnostics() -> None:
"""A build with no linter output must fall back to the open request."""
from ariadne.services import hermes_code_prompt
bundle = {"jenkins": {"console_failures": [{"text": "boom"}], "console_tail": ""}}
cfg = {"owner": "b", "repo": "r", "base_branch": "main",
"allowed_path_prefixes": ["ariadne/"], "allowed_suffixes": [".py"]}
prompt = hermes_code_prompt.build_patch_prompt("a/1", bundle, cfg, {"ariadne/a.py": "x"})
assert "A linter reported these violations" not in prompt
assert "MINIMAL source fix" in prompt
# --- category 2: undefined or misspelled names ---------------------------------
def test_python_nameerror_takes_its_file_from_the_traceback_frame() -> None:
"""The error line names the symbol; the frame above names the file."""
found = module.extract_name_defects(
_bundle(' File "ariadne/app.py", line 42, in build', "NameError: name 'setttings' is not defined"),
CFG,
)
assert found[0]["symbol"] == "setttings"
assert (found[0]["path"], found[0]["line"]) == ("ariadne/app.py", 42)
assert found[0]["category"] == module.UNDEFINED_NAME
def test_import_errors_are_recognised() -> None:
for text in (
"ModuleNotFoundError: No module named 'httpx'",
"ImportError: cannot import name 'safe_error' from 'ariadne.utils.errors'",
):
found = module.extract_name_defects(
_bundle(' File "ariadne/app.py", line 3, in <module>', text), CFG
)
assert found, text
assert found[0]["category"] == module.UNDEFINED_NAME
def test_go_undefined_names_carry_their_own_location() -> None:
found = module.extract_name_defects(_bundle("src/main.go:12:5: undefined: doThing"), CFG)
assert (found[0]["symbol"], found[0]["path"], found[0]["line"]) == ("doThing", "src/main.go", 12)
def test_a_name_defect_without_a_known_file_is_dropped() -> None:
"""With no frame there is no file, so the patcher could not act on it."""
assert module.extract_name_defects(_bundle("NameError: name 'x' is not defined"), CFG) == []
def test_name_defects_outside_the_write_allowlist_are_dropped() -> None:
found = module.extract_name_defects(
_bundle(' File "vendor/thing.py", line 1, in x', "NameError: name 'y' is not defined"), CFG
)
assert found == []
# --- category 3: failing assertions --------------------------------------------
def test_failing_tests_come_from_structured_results() -> None:
bundle = {
"jenkins": {
"console_failures": [],
"console_tail": "",
"failed_tests": [
{
"name": "test_discount",
"className": "tests.test_discount",
"errorDetails": "AssertionError: assert 0.0 == 90.0",
}
],
}
}
found = module.extract_assertion_defects(bundle, CFG)
assert found[0]["test"] == "test_discount"
assert found[0]["class"] == "tests.test_discount"
assert "assert 0.0 == 90.0" in found[0]["message"]
def test_an_assertion_defect_carries_no_path() -> None:
"""Naming the test file would invite weakening the test instead of fixing the cause."""
bundle = {"jenkins": {"failed_tests": [{"name": "t", "className": "c", "errorDetails": "boom"}]}}
assert "path" not in module.extract_assertion_defects(bundle, CFG)[0]
def test_assertion_instruction_forbids_weakening_the_test() -> None:
bundle = {"jenkins": {"failed_tests": [{"name": "t", "className": "c", "errorDetails": "boom"}]}}
text = module.defect_instruction(module.extract_assertion_defects(bundle, CFG))
assert "Fix the code under test" in text
assert "Do not weaken, skip or delete" in text
def test_malformed_failed_tests_are_skipped() -> None:
bundle = {"jenkins": {"failed_tests": ["nope", {}, {"name": " "}, {"name": "ok"}]}}
found = module.extract_assertion_defects(bundle, CFG)
assert [f["test"] for f in found] == ["ok"]
def test_extract_defects_combines_every_category() -> None:
bundle = {
"jenkins": {
"console_failures": [
{"text": "ariadne/app.py:12:5: E501 Line too long\n"
' File "ariadne/app.py", line 42, in build\n'
"NameError: name 'setttings' is not defined"}
],
"console_tail": "",
"failed_tests": [{"name": "t", "className": "c", "errorDetails": "boom"}],
}
}
cats = {d["category"] for d in module.extract_defects(bundle, CFG)}
assert cats == {module.LINT_VIOLATION, module.UNDEFINED_NAME, module.FAILING_ASSERTION}
def test_name_and_assertion_extraction_never_raise_on_hostile_input() -> None:
for bad in (None, 7, {"jenkins": {"console_failures": "no"}}, {"jenkins": {"failed_tests": 5}}):
assert module.extract_name_defects(bad, CFG) == []
assert module.extract_assertion_defects(bad, CFG) == []
assert module.extract_name_defects(_bundle("NameError: name 'x' is not defined"), None) == []
def test_name_extraction_is_bounded() -> None:
lines = []
for i in range(40):
lines.append(f' File "ariadne/f{i}.py", line {i + 1}, in fn')
lines.append(f"NameError: name 'sym{i}' is not defined")
assert len(module.extract_name_defects(_bundle(*lines), CFG)) == module._MAX_DEFECTS
def test_duplicate_name_defects_are_collapsed() -> None:
lines = [' File "ariadne/app.py", line 4, in fn', "NameError: name 'zz' is not defined"] * 3
assert len(module.extract_name_defects(_bundle(*lines), CFG)) == 1
def test_a_name_defect_with_no_prefixes_configured_is_dropped() -> None:
"""An empty allowlist must deny, never permit everything."""
empty = {"allowed_path_prefixes": [], "allowed_suffixes": [".py"]}
found = module.extract_name_defects(
_bundle(' File "ariadne/app.py", line 1, in fn', "NameError: name 'q' is not defined"), empty
)
assert found == []
def test_undefined_name_instruction_names_the_symbol() -> None:
found = module.extract_name_defects(
_bundle(' File "ariadne/app.py", line 42, in build', "NameError: name 'setttings' is not defined"),
CFG,
)
text = module.defect_instruction(found)
assert "'setttings' is not defined or not importable" in text
assert "ariadne/app.py line 42" in text
def test_a_lint_defect_with_a_disallowed_suffix_is_dropped() -> None:
"""Suffix and prefix are separate gates; both must hold."""
cfg = {"allowed_path_prefixes": ["ariadne/"], "allowed_suffixes": [".go"]}
assert module.extract_lint_defects(_bundle("ariadne/app.py:1:1: E501 Long"), cfg) == []
# --- the operator's control over what Hermes may be asked to fix ---------------
def test_no_setting_means_every_category() -> None:
"""The default must be obvious rather than silently empty."""
assert module.enabled_categories({}) == module.ALL_CATEGORIES
assert module.enabled_categories({"fix_categories": []}) == module.ALL_CATEGORIES
assert module.enabled_categories(None) == module.ALL_CATEGORIES
def test_a_category_can_be_switched_off() -> None:
cfg = {**CFG, "fix_categories": [module.LINT_VIOLATION]}
assert module.enabled_categories(cfg) == (module.LINT_VIOLATION,)
bundle = {
"jenkins": {
"console_failures": [{"text": "ariadne/app.py:12:5: E501 Line too long"}],
"console_tail": "",
"failed_tests": [{"name": "t", "className": "c", "errorDetails": "boom"}],
}
}
cats = {d["category"] for d in module.extract_defects(bundle, cfg)}
assert cats == {module.LINT_VIOLATION}
def test_an_unrecognised_category_is_ignored_not_trusted() -> None:
cfg = {"fix_categories": ["lint_violation", "rewrite_everything"]}
assert module.enabled_categories(cfg) == (module.LINT_VIOLATION,)
def test_categories_keep_their_declared_order() -> None:
cfg = {"fix_categories": [module.FAILING_ASSERTION, module.LINT_VIOLATION]}
assert module.enabled_categories(cfg) == (module.LINT_VIOLATION, module.FAILING_ASSERTION)