ariadne/tests/test_hermes_code_candidates.py

315 lines
11 KiB
Python
Raw Normal View History

from __future__ import annotations
import pytest
from ariadne.services import hermes_code_candidates as module
def _cfg(**overrides) -> dict: # type: ignore[no-untyped-def]
base = {
"allowed_path_prefixes": ["src/", "tests/"],
"allowed_suffixes": [".py", ".rs", ".ts", ".tsx"],
"max_candidates": 3,
}
base.update(overrides)
return base
def _bundle(*regions: str, tail: str = "") -> dict:
return {
"jenkins": {
"console_failures": [
{"marker": "FAILED ", "line_number": index + 1, "text": text}
for index, text in enumerate(regions)
],
"console_tail": tail,
}
}
def _paths(text: str, **cfg_overrides) -> list[str]: # type: ignore[no-untyped-def]
return module.extract_candidate_paths(_bundle(text), _cfg(**cfg_overrides))
@pytest.mark.parametrize(
("line", "expected"),
[
(' File "src/ledger.py", line 12, in balance', "src/ledger.py"),
("src/ledger.py:14: AssertionError", "src/ledger.py"),
("FAILED tests/test_ledger.py::test_balance - assert 2 == 3", "tests/test_ledger.py"),
("ERROR tests/test_ledger.py", "tests/test_ledger.py"),
("thread 'main' panicked at src/lib.rs:12:34:", "src/lib.rs"),
(" --> src/lib.rs:20:5", "src/lib.rs"),
(" at src/client.ts:10:5", "src/client.ts"),
(" at Object.<anonymous> (src/panel.tsx:10:5)", "src/panel.tsx"),
("compiling src/build.rs:1:1 failed", "src/build.rs"),
],
)
def test_each_language_reference_pattern_is_matched(line: str, expected: str) -> None:
assert _paths(line) == [expected]
def test_named_patterns_are_reviewable() -> None:
names = [name for name, _pattern in module.FILE_REFERENCE_PATTERNS]
assert names == [
"python_traceback",
"pytest_summary",
"rust_diagnostic",
"js_stack_frame",
"path_with_line",
]
def test_workspace_prefix_is_stripped() -> None:
line = "/home/jenkins/agent/workspace/titan-api/src/ledger.py:14: AssertionError"
assert _paths(line) == ["src/ledger.py"]
def test_leading_dot_slash_and_duplicate_slashes_are_normalized() -> None:
assert _paths(".//src//ledger.py:14: boom") == ["src/ledger.py"]
def test_query_and_anchor_noise_is_dropped() -> None:
assert _paths("src/ledger.py?raw=1:14: boom") == ["src/ledger.py"]
assert _paths("src/ledger.py#L14:14: boom") == ["src/ledger.py"]
def test_suffix_outside_the_allowlist_is_rejected() -> None:
assert _paths("src/ledger.rb:14: boom") == []
assert _paths("src/ledger.rs:14: boom", allowed_suffixes=[".py"]) == []
def test_prefix_outside_the_allowlist_is_rejected() -> None:
assert _paths("vendor/ledger.py:14: boom") == []
assert _paths("vendor/helper.py:2: boom", allowed_path_prefixes=["src/"]) == []
def test_test_files_are_readable_outside_the_write_allowlist() -> None:
"""The failing test explains the defect and is often the only path named.
Reading it is what lets its imports lead back to the module under test.
Writing to it stays barred by the patch validator, which gates on
allowed_path_prefixes alone.
"""
assert _paths("tests/test_ledger.py:2: boom", allowed_path_prefixes=["src/"]) == [
"tests/test_ledger.py"
]
def test_empty_allowlists_accept_nothing() -> None:
assert _paths("src/ledger.py:14: boom", allowed_suffixes=[]) == []
assert _paths("src/ledger.py:14: boom", allowed_path_prefixes=[]) == []
def test_traversal_paths_are_rejected() -> None:
assert _paths("src/../../etc/shadow.py:1: boom") == []
def test_absolute_paths_are_rejected() -> None:
assert _paths("/etc/hosts.py:1: boom", allowed_path_prefixes=["/etc/", "src/"]) == []
def test_backslash_paths_never_become_candidates() -> None:
found = _paths("src\\ledger.py:12: boom", allowed_path_prefixes=["src/", "ledger.py"])
assert "src\\ledger.py" not in found
def test_nul_bearing_paths_are_rejected() -> None:
assert _paths("src/led\x00ger.py:1: boom") == []
def test_earliest_region_outranks_a_busier_later_region() -> None:
bundle = _bundle(
"src/first.py:3: AssertionError",
"src/second.py:4: boom\nsrc/second.py:5: boom\nsrc/second.py:6: boom",
)
assert module.extract_candidate_paths(bundle, _cfg()) == ["src/first.py", "src/second.py"]
def test_reference_count_breaks_ties_inside_one_region() -> None:
text = "src/one.py:1: boom\nsrc/many.py:2: boom\nsrc/many.py:3: boom"
assert _paths(text) == ["src/many.py", "src/one.py"]
def test_non_test_paths_rank_before_test_paths_at_equal_score() -> None:
text = "FAILED tests/test_ledger.py::test_balance\nsrc/ledger.py:2: AssertionError"
assert _paths(text) == ["src/ledger.py", "tests/test_ledger.py"]
def test_console_tail_is_scanned_after_the_regions() -> None:
bundle = _bundle("src/region.py:1: boom", tail="src/tail.py:9: boom")
assert module.extract_candidate_paths(bundle, _cfg()) == ["src/region.py", "src/tail.py"]
def test_tail_only_bundle_still_yields_candidates() -> None:
bundle = {"jenkins": {"console_failures": [], "console_tail": "src/tail.py:9: boom"}}
assert module.extract_candidate_paths(bundle, _cfg()) == ["src/tail.py"]
def test_repeated_references_are_deduped() -> None:
text = 'src/ledger.py:1: boom\nFile "src/ledger.py", line 1, in balance\nsrc/ledger.py:1: boom'
assert _paths(text) == ["src/ledger.py"]
def test_multiple_patterns_on_one_line_count_once() -> None:
text = " --> src/lib.rs:20:5\nsrc/other.rs:1: boom\nsrc/other.rs:2: boom"
assert _paths(text) == ["src/other.rs", "src/lib.rs"]
def test_max_candidates_caps_the_result() -> None:
text = "\n".join(f"src/file{index}.py:{index}: boom" for index in range(6))
assert len(_paths(text)) == 3
assert len(_paths(text, max_candidates=2)) == 2
def test_max_candidates_defaults_when_missing_or_unusable() -> None:
text = "\n".join(f"src/file{index}.py:{index}: boom" for index in range(6))
cfg = {"allowed_path_prefixes": ["src/"], "allowed_suffixes": [".py"]}
assert len(module.extract_candidate_paths(_bundle(text), cfg)) == module.DEFAULT_MAX_CANDIDATES
assert len(_paths(text, max_candidates=0)) == module.DEFAULT_MAX_CANDIDATES
assert len(_paths(text, max_candidates="nope")) == module.DEFAULT_MAX_CANDIDATES
@pytest.mark.parametrize(
"bundle",
[
{},
{"jenkins": None},
{"jenkins": {}},
{"jenkins": {"console_failures": None, "console_tail": None}},
{"jenkins": {"console_failures": "not-a-list", "console_tail": 7}},
{"jenkins": {"console_failures": ["not-a-dict", {"text": None}, {}]}},
{"jenkins": {"console_failures": [{"text": "nothing to see"}]}},
],
)
def test_empty_or_malformed_bundles_yield_no_candidates(bundle) -> None: # type: ignore[no-untyped-def]
assert module.extract_candidate_paths(bundle, _cfg()) == []
@pytest.mark.parametrize("bundle", [None, "text", 7, [], {"jenkins": 5}])
def test_never_raises_on_hostile_input(bundle) -> None: # type: ignore[no-untyped-def]
assert module.extract_candidate_paths(bundle, _cfg()) == []
def test_never_raises_on_hostile_config() -> None:
bundle = _bundle("src/ledger.py:1: boom")
assert module.extract_candidate_paths(bundle, None) == []
assert module.extract_candidate_paths(bundle, {"allowed_suffixes": 5}) == []
@pytest.mark.parametrize(
("path", "expected"),
[
("tests/test_ledger.py", True),
("src/test_helpers.py", True),
("src/ledger_test.py", True),
("tests/conftest.py", True),
("src/ledger.py", False),
("src/latest_run.py", False),
("src/contest.py", False),
],
)
def test_is_test_path_uses_default_markers(path: str, expected: bool) -> None:
assert module.is_test_path(path, {}) is expected
def test_is_test_path_honours_configured_markers() -> None:
cfg = {"test_path_markers": ("spec/",)}
assert module.is_test_path("spec/ledger_spec.py", cfg) is True
assert module.is_test_path("tests/test_ledger.py", cfg) is False
def test_is_test_path_never_raises() -> None:
assert module.is_test_path(None, {}) is False
assert module.is_test_path("src/a.py", {"test_path_markers": [None]}) is False
_CFG = {"allowed_suffixes": [".py"], "allowed_path_prefixes": ["ariadne/"], "max_candidates": 3}
def test_imports_lead_from_a_failing_test_to_the_module_under_test() -> None:
"""The defective source is often absent from the console entirely."""
source = "\n".join(
[
"from __future__ import annotations",
"import httpx",
"from ariadne.utils.errors import safe_error_detail",
"from ariadne.services import mailu",
]
)
paths = module.imported_source_paths(source, _CFG)
assert "ariadne/utils/errors.py" in paths
assert "ariadne/services/mailu.py" in paths
# Third-party and stdlib imports are outside the allowlist.
assert not any(path.startswith("httpx") for path in paths)
def test_imported_paths_stay_inside_the_write_allowlist() -> None:
"""Reading a test must not make test files patchable."""
source = "from tests.helpers import build_fixture\nfrom ariadne.app import create_app"
paths = module.imported_source_paths(source, _CFG)
assert "ariadne/app.py" in paths
assert not any(path.startswith("tests/") for path in paths)
def test_relative_imports_are_not_guessed() -> None:
"""A relative import gives no repository-root path to resolve."""
assert module.imported_source_paths("from . import errors", _CFG) == []
assert module.imported_source_paths("from .errors import safe", _CFG) == []
def test_queue_imported_sources_only_follows_tests() -> None:
"""A source file's imports are not chased; only the failing test's are."""
source = "from ariadne.utils.errors import safe_error_detail"
queue, queued = ["ariadne/app.py"], {"ariadne/app.py"}
module.queue_imported_sources(_CFG, "ariadne/app.py", source, queue, queued)
assert queue == ["ariadne/app.py"]
queue, queued = ["tests/test_utils.py"], {"tests/test_utils.py"}
module.queue_imported_sources(_CFG, "tests/test_utils.py", source, queue, queued)
assert "ariadne/utils/errors.py" in queue
feat(hermes): propose fixes for SonarQube findings on a schedule Triage has only ever entered on a failure. Static analysis is the opposite shape - a standing backlog that never fails a build and so never asks anyone for attention. On this instance that backlog is 139 open findings on Ariadne alone, each already naming its file, its line, its rule and what is wrong. That is better-located evidence than the console text the code-repair flow normally mines, and it was being thrown away. This is a second way into the same flow, not a second flow. A scheduled sweep picks one finding and hands it to the existing proposal path, which is unchanged: Hermes returns a patch as data, Ariadne validates it against the file it names, pushes a branch, opens a pull request nobody merges. A finding arriving from outside the build is not a reason to relax the gates that make a proposal worth reading, so it does not. Three deliberate limits. Security hotspots are never fetched: SonarQube models them as needing human review, the quality gate here fails on exactly that condition, and an automation that resolved them would be marking them reviewed without review - defeating the control rather than satisfying it. Findings already marked won't-fix carry a judgement someone made, and reopening it produces pull requests that argue with a person. And the sweep proposes one fix per run by default, because 139 pull requests nobody reads would make the review gate theatre. Selection is by SonarQube's own effort estimate rather than severity: effort is the closest available proxy for the one-anchor change the patch validator can actually check, so a trivial CRITICAL beats an involved MINOR. An unparseable estimate is treated as ineligible, not as free. Off by default. Triage reacts to a failure someone already cares about; this opens pull requests nobody asked for, and that is a decision an operator makes deliberately rather than inherits on upgrade. Branch naming now sanitizes its token, since it arrives from a finding key as well as a build number and a ref is one of the few places where an unexpected character stops being cosmetic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:08:42 -03:00
def test_a_file_named_by_a_sonarqube_finding_leads_the_candidates() -> None:
"""A finding states which file is wrong; console text only implies it."""
cfg = {"allowed_path_prefixes": ["ariadne/"], "allowed_suffixes": [".py"], "max_candidates": 3}
bundle = {
"jenkins": {"console_tail": "File \"ariadne/other.py\", line 3"},
"sonarqube": {"issues": [{"path": "ariadne/named.py"}]},
}
assert module.extract_candidate_paths(bundle, cfg)[0] == "ariadne/named.py"
def test_sonarqube_paths_are_deduplicated_and_allowlisted() -> None:
cfg = {"allowed_path_prefixes": ["ariadne/"], "allowed_suffixes": [".py"]}
bundle = {
"sonarqube": {
"issues": [
{"path": "ariadne/a.py"},
{"path": "ariadne/a.py"},
{"path": "docs/readme.md"},
{"path": "../escape.py"},
{"path": ""},
"not-a-dict",
]
}
}
assert module.sonar_paths(bundle, cfg) == ["ariadne/a.py"]
def test_a_bundle_without_sonarqube_findings_names_no_paths() -> None:
assert module.sonar_paths({}, {}) == []
assert module.sonar_paths({"sonarqube": "nope"}, {}) == []
assert module.sonar_paths({"sonarqube": {"issues": None}}, {}) == []