From e18c36969984962662463310c7ad3a398dcc7873 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 31 Dec 2025 18:14:19 +0000 Subject: [PATCH 01/12] chore(codex): bootstrap PR for issue #405 --- agents/codex-405.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 agents/codex-405.md diff --git a/agents/codex-405.md b/agents/codex-405.md new file mode 100644 index 000000000..005f3eee3 --- /dev/null +++ b/agents/codex-405.md @@ -0,0 +1 @@ + From d17e77b828d4b5f89fd5005d354bf5454ff90fc7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 31 Dec 2025 18:20:35 +0000 Subject: [PATCH 02/12] Add coverage for workflow health check --- tests/scripts/test_workflow_health_check.py | 86 +++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/scripts/test_workflow_health_check.py diff --git a/tests/scripts/test_workflow_health_check.py b/tests/scripts/test_workflow_health_check.py new file mode 100644 index 000000000..e556b4355 --- /dev/null +++ b/tests/scripts/test_workflow_health_check.py @@ -0,0 +1,86 @@ +"""Tests for workflow_health_check module (scripts coverage).""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from scripts import workflow_health_check + + +def test_format_duration_branches() -> None: + """Exercise short, minute, and hour duration formats.""" + assert workflow_health_check.format_duration(45) == "45s" + assert workflow_health_check.format_duration(75) == "1m 15s" + assert workflow_health_check.format_duration(3725) == "1h 2m" + + +def test_get_recent_runs_skips_invalid_timestamp() -> None: + """Invalid timestamps should be ignored during recency filtering.""" + recent_time = datetime.now(UTC).isoformat() + runs = [ + {"verdict": "pass", "recorded_at": recent_time}, + {"verdict": "fail", "recorded_at": "not-a-timestamp"}, + {"verdict": "pass", "recorded_at": ""}, + ] + + recent = workflow_health_check.get_recent_runs(runs, days=7) + + assert recent == [{"verdict": "pass", "recorded_at": recent_time}] + + +def test_generate_report_writes_output(tmp_path: Path) -> None: + """Output file should be written when output_path is provided.""" + metrics_file = tmp_path / "metrics.ndjson" + metrics_file.write_text( + "\n".join( + [ + json.dumps( + { + "verdict": "pass", + "recorded_at": datetime.now(UTC).isoformat(), + } + ), + json.dumps( + { + "verdict": "fail", + "recorded_at": datetime.now(UTC).isoformat(), + "error": "timeout", + } + ), + ] + ) + + "\n" + ) + output_file = tmp_path / "report.json" + + report = workflow_health_check.generate_report(str(metrics_file), str(output_file)) + + assert output_file.exists() + saved = json.loads(output_file.read_text()) + assert saved["total_runs"] == 2 + assert saved == report + + +def test_main_exits_below_threshold(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Main should exit non-zero when recent success rate is below threshold.""" + metrics_file = tmp_path / "metrics.ndjson" + metrics_file.write_text( + json.dumps( + { + "verdict": "fail", + "recorded_at": datetime.now(UTC).isoformat(), + } + ) + + "\n" + ) + monkeypatch.setenv("METRICS_PATH", str(metrics_file)) + monkeypatch.setenv("SUCCESS_THRESHOLD", "50") + + with pytest.raises(SystemExit) as excinfo: + workflow_health_check.main() + + assert excinfo.value.code == 1 From 6afe0619266e6466efff79bf6a7a1030b400935b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:20:44 +0000 Subject: [PATCH 03/12] chore(codex-keepalive): apply updates (PR #407) --- .workflows-lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.workflows-lib b/.workflows-lib index 477e9de95..abc3d0164 160000 --- a/.workflows-lib +++ b/.workflows-lib @@ -1 +1 @@ -Subproject commit 477e9de95572844bc1ddcb393c5e176a494ea088 +Subproject commit abc3d01649acd81a5fef92dd877c28384ffcc56c From 325dd9d44f7ed2e2d54273a6107b353bc72aa6fa Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 31 Dec 2025 18:32:42 +0000 Subject: [PATCH 04/12] test: cover classify_test_failures --- tests/scripts/test_classify_test_failures.py | 181 +++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 tests/scripts/test_classify_test_failures.py diff --git a/tests/scripts/test_classify_test_failures.py b/tests/scripts/test_classify_test_failures.py new file mode 100644 index 000000000..9ade71614 --- /dev/null +++ b/tests/scripts/test_classify_test_failures.py @@ -0,0 +1,181 @@ +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +from scripts import classify_test_failures + + +def _write_junit(tmp_path: Path, name: str, body: str) -> Path: + path = tmp_path / name + path.write_text( + '\n\n' + f"{body}\n\n", + encoding="utf-8", + ) + return path + + +def test_classify_reports_ignores_missing_file() -> None: + summary = classify_test_failures.classify_reports(["missing-report.xml"]) + + assert summary["total_failures"] == 0 + assert summary["has_failures"] is False + assert summary["only_cosmetic"] is False + assert summary["cosmetic"] == [] + assert summary["runtime"] == [] + assert summary["unknown"] == [] + + +def test_classify_reports_handles_parse_error(tmp_path: Path) -> None: + report = tmp_path / "bad-report.xml" + report.write_text("", encoding="utf-8") + + summary = classify_test_failures.classify_reports([report]) + + assert summary["total_failures"] == 1 + assert summary["has_failures"] is True + assert summary["unknown"][0]["failure_type"] == "error" + assert "Unable to parse JUnit XML" in summary["unknown"][0]["message"] + assert summary["unknown"][0]["id"].startswith(":") + + +def test_classify_reports_classifies_markers_and_dedupes(tmp_path: Path) -> None: + body = """ + + + + + trace + + + + + + trace + + + + + + stack + + + + + + + + + + + """ + report = _write_junit(tmp_path, "report.xml", body) + + summary = classify_test_failures.classify_reports([report]) + + assert summary["total_failures"] == 4 + assert summary["has_failures"] is True + assert len(summary["runtime"]) == 2 + assert len(summary["cosmetic"]) == 1 + assert len(summary["unknown"]) == 1 + assert summary["cosmetic"][0]["failure_type"] == "error" + assert summary["runtime"][0]["message"].startswith("boom:") + + +def test_failure_message_and_test_id_helpers() -> None: + failure_case = ET.fromstring( + '' + 'trace' + "" + ) + message, failure_type = classify_test_failures._failure_message(failure_case) + + assert failure_type == "failure" + assert message == "boom: trace" + assert classify_test_failures._test_id(failure_case, Path("report.xml")) == "pkg.mod::test_it" + + error_case = ET.fromstring("stack") + message, failure_type = classify_test_failures._failure_message(error_case) + + assert failure_type == "error" + assert message == "stack" + assert classify_test_failures._test_id(error_case, Path("report.xml")) == "test_err" + + unnamed_case = ET.fromstring("") + assert ( + classify_test_failures._test_id(unnamed_case, Path("report.xml")) + == "report.xml::testcase" + ) + empty_case = ET.fromstring("") + assert classify_test_failures._failure_message(empty_case) == ("", "failure") + + +def test_extract_markers_skips_blank_values() -> None: + testcase = ET.fromstring( + "" + "" + "" + "" + "" + "" + "" + ) + + markers = classify_test_failures._extract_markers(testcase) + + assert markers == {"runtime"} + + +def test_classify_reports_skips_non_failures(tmp_path: Path) -> None: + report = _write_junit( + tmp_path, + "report.xml", + """ + + + stack + + """, + ) + + summary = classify_test_failures.classify_reports([report]) + + assert summary["total_failures"] == 1 + + +def test_main_writes_output_file(tmp_path: Path, capsys, monkeypatch) -> None: + report = _write_junit( + tmp_path, + "report.xml", + """ + + + + """, + ) + output = tmp_path / "summary.json" + + monkeypatch.chdir(tmp_path) + status = classify_test_failures.main([report.name, "--output", str(output)]) + + assert status == 0 + assert output.exists() + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["has_failures"] is True + assert payload["total_failures"] == 1 + assert "cosmetic" in payload + + captured = capsys.readouterr() + assert '"total_failures": 1' in captured.out + + +def test_main_with_missing_report_prints_empty_summary( + tmp_path: Path, capsys, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + + status = classify_test_failures.main(["missing-report.xml"]) + + assert status == 0 + captured = capsys.readouterr() + assert '"total_failures": 0' in captured.out From e514d40f1a072fb82f7602b30844b182987c42ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 31 Dec 2025 18:33:34 +0000 Subject: [PATCH 05/12] chore(autofix): formatting/lint --- autofix_report_enriched.json | 2 +- tests/scripts/test_classify_test_failures.py | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/autofix_report_enriched.json b/autofix_report_enriched.json index 4d3d5ad3b..611ee1b37 100644 --- a/autofix_report_enriched.json +++ b/autofix_report_enriched.json @@ -1 +1 @@ -{"changed": true, "classification": {"total": 0, "new": 0, "allowed": 0}, "timestamp": "2025-12-31T17:57:55Z", "files": ["tests/scripts/test_keepalive_metrics_dashboard.py"]} \ No newline at end of file +{"changed": true, "classification": {"total": 0, "new": 0, "allowed": 0}, "timestamp": "2025-12-31T18:33:34Z", "files": ["tests/scripts/test_classify_test_failures.py"]} \ No newline at end of file diff --git a/tests/scripts/test_classify_test_failures.py b/tests/scripts/test_classify_test_failures.py index 9ade71614..828add7d2 100644 --- a/tests/scripts/test_classify_test_failures.py +++ b/tests/scripts/test_classify_test_failures.py @@ -8,8 +8,7 @@ def _write_junit(tmp_path: Path, name: str, body: str) -> Path: path = tmp_path / name path.write_text( - '\n\n' - f"{body}\n\n", + '\n\n' f"{body}\n\n", encoding="utf-8", ) return path @@ -94,7 +93,7 @@ def test_failure_message_and_test_id_helpers() -> None: assert message == "boom: trace" assert classify_test_failures._test_id(failure_case, Path("report.xml")) == "pkg.mod::test_it" - error_case = ET.fromstring("stack") + error_case = ET.fromstring('stack') message, failure_type = classify_test_failures._failure_message(error_case) assert failure_type == "error" @@ -103,8 +102,7 @@ def test_failure_message_and_test_id_helpers() -> None: unnamed_case = ET.fromstring("") assert ( - classify_test_failures._test_id(unnamed_case, Path("report.xml")) - == "report.xml::testcase" + classify_test_failures._test_id(unnamed_case, Path("report.xml")) == "report.xml::testcase" ) empty_case = ET.fromstring("") assert classify_test_failures._failure_message(empty_case) == ("", "failure") @@ -114,9 +112,9 @@ def test_extract_markers_skips_blank_values() -> None: testcase = ET.fromstring( "" "" - "" - "" - "" + '' + '' + '' "" "" ) @@ -169,9 +167,7 @@ def test_main_writes_output_file(tmp_path: Path, capsys, monkeypatch) -> None: assert '"total_failures": 1' in captured.out -def test_main_with_missing_report_prints_empty_summary( - tmp_path: Path, capsys, monkeypatch -) -> None: +def test_main_with_missing_report_prints_empty_summary(tmp_path: Path, capsys, monkeypatch) -> None: monkeypatch.chdir(tmp_path) status = classify_test_failures.main(["missing-report.xml"]) From 6b7a40bb670fe20f829c5d20d2c932c336831422 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:42:26 +0000 Subject: [PATCH 06/12] chore(codex-autofix): apply updates (PR #407) --- scripts/ci_cosmetic_repair.py | 2 +- tests/workflows/test_ci_cosmetic_repair.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ci_cosmetic_repair.py b/scripts/ci_cosmetic_repair.py index 800153ce6..6c496a272 100644 --- a/scripts/ci_cosmetic_repair.py +++ b/scripts/ci_cosmetic_repair.py @@ -307,7 +307,7 @@ def stage_and_commit( summary: str, branch_suffix: str | None, ) -> str: - branch_suffix = branch_suffix or datetime.utcnow().strftime("%Y%m%d%H%M%S") + branch_suffix = branch_suffix or datetime.now(UTC).strftime("%Y%m%d%H%M%S") branch = f"{BRANCH_PREFIX}-{branch_suffix}" _run(["git", "checkout", "-B", branch], cwd=root) _run(["git", "add", *{str(p.relative_to(root)) for p in paths}], cwd=root) diff --git a/tests/workflows/test_ci_cosmetic_repair.py b/tests/workflows/test_ci_cosmetic_repair.py index ed0fec4b0..3eae53c18 100644 --- a/tests/workflows/test_ci_cosmetic_repair.py +++ b/tests/workflows/test_ci_cosmetic_repair.py @@ -397,7 +397,7 @@ def fake_run(cmd, *, cwd=None): class FakeDateTime: @staticmethod - def utcnow() -> dt_datetime: + def now(tz=None) -> dt_datetime: return dt_datetime(2024, 1, 2, 3, 4, 5) monkeypatch.setattr(ci_cosmetic_repair, "_run", fake_run) From 00d27584af8e61ba0e8fe6f2370244595dc6f6f4 Mon Sep 17 00:00:00 2001 From: stranske Date: Wed, 31 Dec 2025 19:05:48 +0000 Subject: [PATCH 07/12] Fix Python 3.12 DeprecationWarning in XML element testing Replace 'or' chaining of XML find() calls with explicit 'is not None' checks to avoid truthiness testing of XML elements, which raises DeprecationWarning in Python 3.12 and will become an error in future versions. Fixes test failures: - test_classify_reports_classifies_markers_and_dedupes - test_main_writes_output_file --- scripts/classify_test_failures.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/classify_test_failures.py b/scripts/classify_test_failures.py index e25eebdba..9a603a347 100644 --- a/scripts/classify_test_failures.py +++ b/scripts/classify_test_failures.py @@ -139,7 +139,9 @@ def classify_reports(paths: Iterable[str | Path]) -> dict[str, object]: root = tree.getroot() testcases = list(root.iter("testcase")) for testcase in testcases: - failure_node = testcase.find("failure") or testcase.find("error") + failure_node = testcase.find("failure") + if failure_node is None: + failure_node = testcase.find("error") if failure_node is None: continue marker_set = _extract_markers(testcase) From ddcdb04d60c4f4928d5a64302f4c68b48d7ccb8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:17:05 +0000 Subject: [PATCH 08/12] chore(codex-keepalive): apply updates (PR #407) --- tests/workflows/test_autofix_pipeline_diverse.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/workflows/test_autofix_pipeline_diverse.py b/tests/workflows/test_autofix_pipeline_diverse.py index c71233399..53a2daad3 100644 --- a/tests/workflows/test_autofix_pipeline_diverse.py +++ b/tests/workflows/test_autofix_pipeline_diverse.py @@ -125,6 +125,8 @@ def compute_expected_report_count() -> int: encoding="utf-8", ) + black_targets = (sample_module, automation_module, numpy_test, expectations_module) + commands = [ ( [ @@ -140,7 +142,6 @@ def compute_expected_report_count() -> int: ), ([sys.executable, "-m", "isort", str(sample_module)], (0,)), ([sys.executable, "-m", "docformatter", "-i", str(sample_module)], (0, 3)), - ([sys.executable, "-m", "black", str(repo_root)], (0,)), ( [ sys.executable, @@ -158,6 +159,9 @@ def compute_expected_report_count() -> int: for command, ok_codes in commands: _run(command, cwd=repo_root, ok_exit_codes=ok_codes) + for target in black_targets: + _run([sys.executable, "-m", "black", str(target)], cwd=repo_root) + monkeypatch.setattr(auto_type_hygiene, "ROOT", repo_root, raising=False) monkeypatch.setattr(auto_type_hygiene, "SRC_DIRS", [src_dir, tests_dir], raising=False) monkeypatch.setattr(auto_type_hygiene, "DRY_RUN", False, raising=False) @@ -223,7 +227,8 @@ def compute_expected_report_count() -> int: mypy_return_autofix.main() _run([sys.executable, "-m", "isort", str(sample_module)], cwd=repo_root) - _run([sys.executable, "-m", "black", str(repo_root)], cwd=repo_root) + for target in black_targets: + _run([sys.executable, "-m", "black", str(target)], cwd=repo_root) _run( [ sys.executable, @@ -238,7 +243,8 @@ def compute_expected_report_count() -> int: ) _run([sys.executable, "-m", "ruff", "check", str(repo_root)], cwd=repo_root) - _run([sys.executable, "-m", "black", "--check", str(repo_root)], cwd=repo_root) + for target in black_targets: + _run([sys.executable, "-m", "black", "--check", str(target)], cwd=repo_root) _run( [ sys.executable, From 5ac8c313f45e63943fe06e0565b1aa01d44f61e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:29:46 +0000 Subject: [PATCH 09/12] chore(codex-keepalive): apply updates (PR #407) --- .workflows-lib | 2 +- tests/scripts/test_coverage_history_append.py | 64 +++ tests/scripts/test_fix_cosmetic_aggregate.py | 10 + tests/scripts/test_ledger_migrate_base.py | 84 ++++ tests/scripts/test_ledger_validate.py | 383 ++++++++++++++++++ tests/scripts/test_mypy_return_autofix.py | 125 ++++++ .../test_update_autofix_expectations.py | 63 +++ tests/test_workflow_validator.py | 15 + 8 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 tests/scripts/test_fix_cosmetic_aggregate.py create mode 100644 tests/scripts/test_mypy_return_autofix.py create mode 100644 tests/scripts/test_update_autofix_expectations.py diff --git a/.workflows-lib b/.workflows-lib index abc3d0164..48f12e5a9 160000 --- a/.workflows-lib +++ b/.workflows-lib @@ -1 +1 @@ -Subproject commit abc3d01649acd81a5fef92dd877c28384ffcc56c +Subproject commit 48f12e5a9f482dfd1f43079ef4b18cdfee6211b8 diff --git a/tests/scripts/test_coverage_history_append.py b/tests/scripts/test_coverage_history_append.py index d3e0fb255..9f5c4880b 100644 --- a/tests/scripts/test_coverage_history_append.py +++ b/tests/scripts/test_coverage_history_append.py @@ -26,6 +26,14 @@ def test_load_existing_skips_invalid_lines(tmp_path: Path) -> None: assert records == [{"run_id": 1}, {"run_id": 2}] +def test_load_existing_returns_empty_when_missing(tmp_path: Path) -> None: + history_path = tmp_path / "missing.ndjson" + + records = coverage_history_append.load_existing(history_path) + + assert records == [] + + def test_main_replaces_matching_run_id_and_sorts( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -55,6 +63,62 @@ def test_main_replaces_matching_run_id_and_sorts( assert records[1]["coverage"] == 75.0 +def test_main_sorts_by_run_id_when_no_run_number( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + history_path = tmp_path / "history.ndjson" + record_path = tmp_path / "record.json" + + _write_ndjson( + history_path, + [ + {"run_id": 3, "coverage": 50.0}, + {"run_id": 1, "coverage": 45.0}, + ], + ) + record_path.write_text( + json.dumps({"run_id": 2, "coverage": 60.0}), + encoding="utf-8", + ) + + monkeypatch.setenv("HISTORY_PATH", str(history_path)) + monkeypatch.setenv("RECORD_PATH", str(record_path)) + + exit_code = coverage_history_append.main() + + assert exit_code == 0 + records = _read_ndjson(history_path) + assert [record["run_id"] for record in records] == [1, 2, 3] + + +def test_main_appends_record_without_run_id( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + history_path = tmp_path / "history.ndjson" + record_path = tmp_path / "record.json" + + _write_ndjson( + history_path, + [ + {"run_number": 1, "coverage": 50.0}, + {"run_number": 2, "coverage": 55.0}, + ], + ) + record_path.write_text( + json.dumps({"run_number": 3, "coverage": 60.0}), + encoding="utf-8", + ) + + monkeypatch.setenv("HISTORY_PATH", str(history_path)) + monkeypatch.setenv("RECORD_PATH", str(record_path)) + + exit_code = coverage_history_append.main() + + assert exit_code == 0 + records = _read_ndjson(history_path) + assert len(records) == 3 + + def test_main_skips_missing_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: history_path = tmp_path / "history.ndjson" record_path = tmp_path / "missing.json" diff --git a/tests/scripts/test_fix_cosmetic_aggregate.py b/tests/scripts/test_fix_cosmetic_aggregate.py new file mode 100644 index 000000000..c5250bfb6 --- /dev/null +++ b/tests/scripts/test_fix_cosmetic_aggregate.py @@ -0,0 +1,10 @@ +from pathlib import Path + +from scripts import fix_cosmetic_aggregate + + +def test_main_returns_when_target_missing(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(fix_cosmetic_aggregate, "ROOT", Path(tmp_path)) + monkeypatch.setattr(fix_cosmetic_aggregate, "TARGET", Path("missing.py")) + + assert fix_cosmetic_aggregate.main() == 0 diff --git a/tests/scripts/test_ledger_migrate_base.py b/tests/scripts/test_ledger_migrate_base.py index daeef218b..ddbd3a113 100644 --- a/tests/scripts/test_ledger_migrate_base.py +++ b/tests/scripts/test_ledger_migrate_base.py @@ -1,4 +1,5 @@ import textwrap +from pathlib import Path import pytest import yaml @@ -37,6 +38,46 @@ def fake_run(args): assert ledger_migrate_base.detect_default_branch() == "main" +def test_detect_default_branch_ignores_blank_head_branch(monkeypatch) -> None: + def fake_run(args): + if args == ["remote", "show", "origin"]: + return " HEAD branch:\n" + if args == ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]: + return "refs/heads/dev\n" + raise AssertionError(f"unexpected args: {args}") + + monkeypatch.setattr(ledger_migrate_base, "_run_git", fake_run) + assert ledger_migrate_base.detect_default_branch() == "dev" + + +def test_detect_default_branch_returns_raw_ref(monkeypatch) -> None: + def fake_run(args): + if args == ["remote", "show", "origin"]: + raise ledger_migrate_base.MigrationError("no remote") + if args == ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]: + return "feature\n" + raise AssertionError(f"unexpected args: {args}") + + monkeypatch.setattr(ledger_migrate_base, "_run_git", fake_run) + assert ledger_migrate_base.detect_default_branch() == "feature" + + +def test_detect_default_branch_falls_back_to_current_branch(monkeypatch) -> None: + def fake_run(args): + if args == ["remote", "show", "origin"]: + raise ledger_migrate_base.MigrationError("no remote") + if args == ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]: + raise ledger_migrate_base.MigrationError("no origin head") + if args == ["rev-parse", "--abbrev-ref", "origin/HEAD"]: + return "origin/HEAD\n" + if args == ["symbolic-ref", "--quiet", "HEAD"]: + return "release\n" + raise AssertionError(f"unexpected args: {args}") + + monkeypatch.setattr(ledger_migrate_base, "_run_git", fake_run) + assert ledger_migrate_base.detect_default_branch() == "release" + + def test_detect_default_branch_falls_back_to_head(monkeypatch) -> None: def fake_run(args): if args == ["remote", "show", "origin"]: @@ -76,6 +117,16 @@ def fake_run(args): ledger_migrate_base.detect_default_branch() +def test_run_git_returns_stdout(monkeypatch) -> None: + def fake_run(args, check, capture_output, text): + assert args[0] == "git" + return type("Result", (), {"stdout": "ok\n"})() + + monkeypatch.setattr(ledger_migrate_base.subprocess, "run", fake_run) + + assert ledger_migrate_base._run_git(["status"]) == "ok\n" + + def test_load_ledger_requires_mapping(tmp_path) -> None: ledger_path = tmp_path / "issue-1-ledger.yml" ledger_path.write_text("- item\n", encoding="utf-8") @@ -145,6 +196,12 @@ def fake_run(args): ledger_migrate_base.find_repo_root() +def test_find_repo_root_returns_path(monkeypatch) -> None: + monkeypatch.setattr(ledger_migrate_base, "_run_git", lambda args: "/tmp/repo\n") + + assert ledger_migrate_base.find_repo_root() == Path("/tmp/repo") + + def test_discover_ledgers_lists_agents(tmp_path) -> None: agents_dir = tmp_path / ".agents" agents_dir.mkdir() @@ -156,6 +213,10 @@ def test_discover_ledgers_lists_agents(tmp_path) -> None: assert ledger_migrate_base.discover_ledgers(tmp_path) == [first, second] +def test_discover_ledgers_returns_empty_when_missing(tmp_path) -> None: + assert ledger_migrate_base.discover_ledgers(tmp_path) == [] + + def test_main_reports_no_ledgers(monkeypatch, capsys, tmp_path) -> None: monkeypatch.setattr(ledger_migrate_base, "find_repo_root", lambda: tmp_path) monkeypatch.setattr(ledger_migrate_base, "detect_default_branch", lambda _=None: "main") @@ -237,3 +298,26 @@ def fail_root(): assert exit_code == 2 err = capsys.readouterr().err assert "::error::boom" in err + + +def test_main_reports_no_updates(monkeypatch, capsys, tmp_path) -> None: + agents_dir = tmp_path / ".agents" + agents_dir.mkdir() + ledger_path = agents_dir / "issue-33-ledger.yml" + ledger_path.write_text("base: main\n", encoding="utf-8") + + monkeypatch.setattr(ledger_migrate_base, "find_repo_root", lambda: tmp_path) + monkeypatch.setattr(ledger_migrate_base, "detect_default_branch", lambda _=None: "main") + monkeypatch.setattr( + ledger_migrate_base, + "migrate_ledger", + lambda path, default_branch, check: ledger_migrate_base.LedgerResult( + path=path, previous="main", updated="main", changed=False + ), + ) + + exit_code = ledger_migrate_base.main([]) + + assert exit_code == 0 + out = capsys.readouterr().out + assert "Ledgers already matched the default branch; no updates written." in out diff --git a/tests/scripts/test_ledger_validate.py b/tests/scripts/test_ledger_validate.py index 34909553d..a440f2d7b 100644 --- a/tests/scripts/test_ledger_validate.py +++ b/tests/scripts/test_ledger_validate.py @@ -161,6 +161,27 @@ def test_validate_timestamp_formats_are_checked(tmp_path: Path, monkeypatch) -> assert "tasks[1].finished_at is not a valid timestamp" in errors[0] +def test_validate_timestamp_rejects_non_iso_format(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + + errors = ledger_validate._validate_timestamp( + "2024-01-01", + field="started_at", + path="tasks[0]", + ) + + assert errors == [ + "tasks[0].started_at must be an ISO-8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ)" + ] + + +def test_ensure_type_allows_none(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + + assert ledger_validate._ensure_type(None, str, allow_none=True) is True + assert ledger_validate._ensure_type(123, str) is False + + def test_commit_files_raises_for_unknown_commit(tmp_path: Path, monkeypatch) -> None: ledger_validate = _load_module(monkeypatch, tmp_path) @@ -174,6 +195,368 @@ def raise_called_process_error(*args, **kwargs): ledger_validate._commit_files("deadbeef") +def test_fetch_commit_succeeds_without_retry(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + calls: list[list[str]] = [] + + def fake_check_call(args, stdout=None, stderr=None): + calls.append(args) + return 0 + + monkeypatch.setattr(ledger_validate.subprocess, "check_call", fake_check_call) + + assert ledger_validate._fetch_commit("abc1234") is True + assert calls == [ + [ + "git", + "fetch", + "--no-tags", + "--filter=blob:none", + "origin", + "abc1234", + ] + ] + + +def test_fetch_commit_retries_after_deepen(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + calls: list[list[str]] = [] + + def fake_check_call(args, stdout=None, stderr=None): + calls.append(args) + if args[-1] == "abc1234" and len(calls) == 1: + raise subprocess.CalledProcessError(1, args) + return 0 + + monkeypatch.setattr(ledger_validate.subprocess, "check_call", fake_check_call) + + assert ledger_validate._fetch_commit("abc1234") is True + assert calls[0][-1] == "abc1234" + assert calls[-1][-1] == "abc1234" + + +def test_fetch_commit_continues_after_failed_retry(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + calls: list[list[str]] = [] + + def fake_check_call(args, stdout=None, stderr=None): + calls.append(args) + if len(calls) == 1: + raise subprocess.CalledProcessError(1, args) + if args[-1] == "abc1234" and len(calls) == 3: + raise subprocess.CalledProcessError(1, args) + return 0 + + monkeypatch.setattr(ledger_validate.subprocess, "check_call", fake_check_call) + + assert ledger_validate._fetch_commit("abc1234") is True + assert calls[-1][-1] == "abc1234" + + +def test_commit_files_fetches_history(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + calls = {"count": 0} + + def fake_check_output(args, text=True): + calls["count"] += 1 + if calls["count"] == 1: + raise subprocess.CalledProcessError(1, args) + return "first.txt\nsecond.txt\n" + + monkeypatch.setattr(ledger_validate.subprocess, "check_output", fake_check_output) + monkeypatch.setattr(ledger_validate, "_fetch_commit", lambda commit: True) + + assert ledger_validate._commit_files("abc1234") == ["first.txt", "second.txt"] + + +def test_commit_subject_fetches_history(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + calls = {"count": 0} + + def fake_check_output(args, text=True): + calls["count"] += 1 + if calls["count"] == 1: + raise subprocess.CalledProcessError(1, args) + return "fix: subject" + + monkeypatch.setattr(ledger_validate.subprocess, "check_output", fake_check_output) + monkeypatch.setattr(ledger_validate, "_fetch_commit", lambda commit: True) + + assert ledger_validate._commit_subject("abc1234") == "fix: subject" + + +def test_commit_subject_raises_for_unknown_commit(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + + def raise_called_process_error(*args, **kwargs): + raise subprocess.CalledProcessError(1, args[0]) + + monkeypatch.setattr(ledger_validate.subprocess, "check_output", raise_called_process_error) + monkeypatch.setattr(ledger_validate, "_fetch_commit", lambda commit: False) + + with pytest.raises(ledger_validate.LedgerError, match="unknown commit deadbeef"): + ledger_validate._commit_subject("deadbeef") + + +def test_validate_ledger_rejects_non_mapping_task(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + payload = { + "version": 1, + "issue": 1, + "base": "main", + "branch": "feature", + "tasks": ["not-a-mapping"], + } + ledger_path.write_text(yaml.safe_dump(payload), encoding="utf-8") + + errors = ledger_validate.validate_ledger(ledger_path) + + assert f"{ledger_path}: tasks[0] must be a mapping" in errors + + +def test_validate_task_commit_rules(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + + task = { + "id": "task-1", + "title": "Work", + "status": "done", + "notes": None, + "commit": None, + } + errors = ledger_validate._validate_task(task, index=0, seen_ids=set(), ledger_path=ledger_path) + + assert "tasks[0].commit is required when status is done" in errors + assert not any("notes must be a list" in error for error in errors) + + +def test_validate_task_invalid_fields(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + + task = { + "id": " ", + "title": "", + "status": "unknown", + "notes": ["ok", 1], + "commit": "bad", + } + + errors = ledger_validate._validate_task(task, index=0, seen_ids=set(), ledger_path=ledger_path) + + assert "tasks[0].id must be a non-empty string" in errors + assert "tasks[0].title must be a non-empty string" in errors + assert "tasks[0].status must be one of" in errors[2] + assert "tasks[0].notes must be a list of strings" in errors + assert "tasks[0].commit must be empty or a Git SHA" in errors + + +def test_validate_task_done_requires_valid_commit(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + + task = { + "id": "task-1", + "title": "Done", + "status": "done", + "commit": "nope", + } + + errors = ledger_validate._validate_task(task, index=0, seen_ids=set(), ledger_path=ledger_path) + + assert "tasks[0].commit must be a Git SHA (7-40 hex characters)" in errors + + +def test_validate_task_commit_has_no_files(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + + monkeypatch.setattr(ledger_validate, "_commit_files", lambda commit: []) + + task = { + "id": "task-1", + "title": "Ship it", + "status": "done", + "commit": "abcdef1", + } + + errors = ledger_validate._validate_task(task, index=0, seen_ids=set(), ledger_path=ledger_path) + + assert f"{ledger_path}: tasks[0].commit abcdef1 has no changed files" in errors + + +def test_validate_task_commit_type_and_duplicate_ids(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + seen_ids: set[str] = set() + + first = { + "id": "task-1", + "title": "First", + "status": "todo", + "commit": 123, + } + second = { + "id": "task-1", + "title": "Second", + "status": "todo", + } + + errors = ledger_validate._validate_task(first, index=0, seen_ids=seen_ids, ledger_path=ledger_path) + errors += ledger_validate._validate_task(second, index=1, seen_ids=seen_ids, ledger_path=ledger_path) + + assert "tasks[0].commit must be a string" in errors + assert "duplicate task id: task-1" in errors + + +def test_validate_task_handles_commit_errors(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_validate.REPO_ROOT = tmp_path + ledger_path = tmp_path / "ledger.yml" + + def raise_commit_files(_commit): + raise ledger_validate.LedgerError("missing") + + monkeypatch.setattr(ledger_validate, "_commit_files", raise_commit_files) + + task = { + "id": "task-1", + "title": "Title", + "status": "done", + "commit": "abcdef1", + } + + errors = ledger_validate._validate_task(task, index=0, seen_ids=set(), ledger_path=ledger_path) + + assert f"{ledger_path}: tasks[0].commit abcdef1 not found in repository" in errors[0] + + +def test_validate_task_commit_subject_failure(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_validate.REPO_ROOT = tmp_path + ledger_dir = tmp_path / ".agents" + ledger_dir.mkdir() + ledger_path = ledger_dir / "issue-1-ledger.yml" + + monkeypatch.setattr( + ledger_validate, "_commit_files", lambda commit: [".agents/issue-1-ledger.yml"] + ) + + def raise_subject(_commit): + raise ledger_validate.LedgerError("no subject") + + monkeypatch.setattr(ledger_validate, "_commit_subject", raise_subject) + + task = { + "id": "task-1", + "title": "Ship it", + "status": "done", + "commit": "abcdef1", + } + + errors = ledger_validate._validate_task(task, index=0, seen_ids=set(), ledger_path=ledger_path) + + assert any("not found in repository" in error for error in errors) + assert any("must include non-ledger changes" in error for error in errors) + + +def test_validate_task_allows_non_agents_files(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + + monkeypatch.setattr(ledger_validate, "_commit_files", lambda commit: ["src/app.py"]) + monkeypatch.setattr(ledger_validate, "_commit_subject", lambda commit: "feat: update") + + task = { + "id": "task-1", + "title": "Ship it", + "status": "done", + "commit": "abcdef1", + } + + errors = ledger_validate._validate_task(task, index=0, seen_ids=set(), ledger_path=ledger_path) + + assert errors == [] + + +def test_find_ledgers_respects_explicit_paths(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + + ledgers = ledger_validate.find_ledgers([str(tmp_path / "one.yml"), str(tmp_path / "two.yml")]) + + assert ledgers == [tmp_path / "one.yml", tmp_path / "two.yml"] + + +def test_find_ledgers_returns_empty_when_missing(tmp_path: Path, monkeypatch) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + + assert ledger_validate.find_ledgers([]) == [] + + +def test_main_reports_validated_ledgers(tmp_path: Path, monkeypatch, capsys) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + ledger_path.write_text( + yaml.safe_dump( + { + "version": 1, + "issue": 1, + "base": "main", + "branch": "feature", + "tasks": [ + {"id": "task-1", "title": "Ok", "status": "todo"}, + ], + } + ), + encoding="utf-8", + ) + + monkeypatch.setattr(ledger_validate, "find_ledgers", lambda paths: [ledger_path]) + + exit_code = ledger_validate.main([]) + + assert exit_code == 0 + assert f"Validated {ledger_path}" in capsys.readouterr().out + + +def test_main_reports_no_ledgers(tmp_path: Path, monkeypatch, capsys) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + monkeypatch.setattr(ledger_validate, "find_ledgers", lambda paths: []) + + exit_code = ledger_validate.main([]) + + assert exit_code == 0 + assert "No ledger files found." in capsys.readouterr().out + + +def test_main_prints_errors_to_stderr(tmp_path: Path, monkeypatch, capsys) -> None: + ledger_validate = _load_module(monkeypatch, tmp_path) + ledger_path = tmp_path / "ledger.yml" + ledger_path.write_text( + yaml.safe_dump( + { + "version": 1, + "issue": "nope", + "base": "main", + "branch": "feature", + "tasks": [ + {"id": "task-1", "title": "Ok", "status": "todo"}, + ], + } + ), + encoding="utf-8", + ) + + monkeypatch.setattr(ledger_validate, "find_ledgers", lambda paths: [ledger_path]) + + exit_code = ledger_validate.main([]) + + assert exit_code == 1 + assert "issue must be an integer" in capsys.readouterr().err + + def test_main_json_output_includes_errors(tmp_path: Path, monkeypatch, capsys) -> None: ledger_validate = _load_module(monkeypatch, tmp_path) ledger_path = tmp_path / "ledger.yml" diff --git a/tests/scripts/test_mypy_return_autofix.py b/tests/scripts/test_mypy_return_autofix.py new file mode 100644 index 000000000..d0c037f82 --- /dev/null +++ b/tests/scripts/test_mypy_return_autofix.py @@ -0,0 +1,125 @@ +import ast +import textwrap +from pathlib import Path + +from scripts import mypy_return_autofix + + +def _expr(source: str) -> ast.AST: + return ast.parse(source).body[0].value + + +def test_is_str_like_variants() -> None: + assert mypy_return_autofix._is_str_like(_expr("'hello'"), set()) is True + assert mypy_return_autofix._is_str_like(_expr('f"{value}"'), set()) is True + assert mypy_return_autofix._is_str_like(_expr("name"), {"name"}) is True + assert mypy_return_autofix._is_str_like(_expr("str(123)"), set()) is True + assert mypy_return_autofix._is_str_like(_expr("'a'.upper()"), set()) is True + assert mypy_return_autofix._is_str_like(_expr("'-'.join(['a'])"), set()) is True + assert mypy_return_autofix._is_str_like(_expr("'hi {}'.format('x')"), set()) is True + assert mypy_return_autofix._is_str_like(_expr("missing"), set()) is False + + +def test_is_list_of_str_variants() -> None: + assert mypy_return_autofix._is_list_of_str(_expr("['a', 'b']"), set()) is True + assert mypy_return_autofix._is_list_of_str(_expr("[1, 2]"), set()) is False + assert mypy_return_autofix._is_list_of_str(_expr("names"), {"names"}) is True + + +def test_collect_string_vars_tracks_assignments() -> None: + module = ast.parse( + textwrap.dedent( + """\ + def sample(): + greeting = "hi" + alias = greeting + names = ["a", f"{greeting}"] + nums = [1] + first, second = ["x", "y"] + """ + ) + ) + func = module.body[0] + string_vars, list_vars = mypy_return_autofix._collect_string_vars(func.body) + + assert string_vars == {"greeting", "alias"} + assert list_vars == {"names", "nums"} + + +def test_process_function_updates_list_annotation() -> None: + source = textwrap.dedent( + """\ + def names() -> list[int]: + items = ["a"] + return items + """ + ) + module = ast.parse(source) + lines = source.splitlines() + func = module.body[0] + + changed = mypy_return_autofix._process_function(func, lines, set()) + + assert changed is True + assert "list[str]" in lines[0] + + +def test_process_function_no_annotation_no_change() -> None: + source = textwrap.dedent( + """\ + def value(): + return "hi" + """ + ) + module = ast.parse(source) + lines = source.splitlines() + func = module.body[0] + + changed = mypy_return_autofix._process_function(func, lines, set()) + + assert changed is False + + +def test_process_function_skips_bare_return() -> None: + source = textwrap.dedent( + """\ + def value() -> int: + return + """ + ) + module = ast.parse(source) + lines = source.splitlines() + func = module.body[0] + + changed = mypy_return_autofix._process_function(func, lines, set()) + + assert changed is False + + +def test_process_file_rewrites_annotation(tmp_path: Path) -> None: + path = tmp_path / "sample.py" + path.write_text( + textwrap.dedent( + """\ + def value() -> int: + return "hello" + """ + ), + encoding="utf-8", + ) + + assert mypy_return_autofix._process_file(path) is True + assert "-> str:" in path.read_text(encoding="utf-8") + + +def test_annotation_to_str_without_unparse(monkeypatch) -> None: + monkeypatch.delattr(ast, "unparse", raising=False) + + assert mypy_return_autofix._annotation_to_str(ast.Name(id="value")) == "" + + +def test_main_skips_missing_project_dirs(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(mypy_return_autofix, "ROOT", tmp_path) + monkeypatch.setattr(mypy_return_autofix, "PROJECT_DIRS", [Path("missing")]) + + assert mypy_return_autofix.main([]) == 0 diff --git a/tests/scripts/test_update_autofix_expectations.py b/tests/scripts/test_update_autofix_expectations.py new file mode 100644 index 000000000..b6a2c658e --- /dev/null +++ b/tests/scripts/test_update_autofix_expectations.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import types +from pathlib import Path + +from scripts import update_autofix_expectations + + +def test_update_constant_skips_missing_module_file(tmp_path: Path) -> None: + module = types.ModuleType("sample") + module.__file__ = str(tmp_path / "missing.py") + + def build() -> str: + return "value" + + module.build = build + target = update_autofix_expectations.AutofixTarget( + module="sample", + callable_name="build", + constant_name="EXPECTED", + ) + + assert update_autofix_expectations._update_constant(module, target) is False + + +def test_update_constant_no_matching_constant(tmp_path: Path) -> None: + module = types.ModuleType("sample") + module_path = tmp_path / "sample.py" + module_path.write_text("OTHER = 'value'\n", encoding="utf-8") + module.__file__ = str(module_path) + + def build() -> str: + return "new" + + module.build = build + target = update_autofix_expectations.AutofixTarget( + module="sample", + callable_name="build", + constant_name="EXPECTED", + ) + + assert update_autofix_expectations._update_constant(module, target) is False + assert module_path.read_text(encoding="utf-8") == "OTHER = 'value'\n" + + +def test_update_constant_rewrites_matching_constant(tmp_path: Path) -> None: + module = types.ModuleType("sample") + module_path = tmp_path / "sample.py" + module_path.write_text("EXPECTED = 'old'\nOTHER = 1\n", encoding="utf-8") + module.__file__ = str(module_path) + + def build() -> str: + return "new" + + module.build = build + target = update_autofix_expectations.AutofixTarget( + module="sample", + callable_name="build", + constant_name="EXPECTED", + ) + + assert update_autofix_expectations._update_constant(module, target) is True + assert "EXPECTED = 'new'" in module_path.read_text(encoding="utf-8") diff --git a/tests/test_workflow_validator.py b/tests/test_workflow_validator.py index 2f60baa41..00317a0ad 100644 --- a/tests/test_workflow_validator.py +++ b/tests/test_workflow_validator.py @@ -154,6 +154,13 @@ def test_detect_write_all(self) -> None: issues = check_permissions(workflow) assert len(issues) >= 1 + def test_detect_job_write_all(self) -> None: + """Test detection of job-level write-all permissions.""" + workflow = {"jobs": {"build": {"permissions": "write-all"}}} + + issues = check_permissions(workflow) + assert issues == ["Job build has write-all permissions"] + class TestValidateWorkflow: """Tests for validate_workflow function.""" @@ -202,6 +209,14 @@ def test_validate_bad_workflow(self, tmp_path: Path) -> None: assert len(results["missing_timeout"]) >= 1 assert len(results["permission_issues"]) >= 1 + def test_validate_invalid_yaml(self, tmp_path: Path) -> None: + """Test validation reports errors for invalid YAML.""" + workflow_file = tmp_path / "invalid.yml" + workflow_file.write_text("{{invalid yaml") + + results = validate_workflow(str(workflow_file)) + assert results["errors"] == [f"Failed to load workflow: {workflow_file}"] + class TestValidateAllWorkflows: """Tests for validate_all_workflows function.""" From 0201053424da3251ddbd0223569e782cbc375fa2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 31 Dec 2025 19:30:16 +0000 Subject: [PATCH 10/12] chore(autofix): formatting/lint --- autofix_report_enriched.json | 2 +- tests/scripts/test_ledger_validate.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/autofix_report_enriched.json b/autofix_report_enriched.json index 611ee1b37..51b615184 100644 --- a/autofix_report_enriched.json +++ b/autofix_report_enriched.json @@ -1 +1 @@ -{"changed": true, "classification": {"total": 0, "new": 0, "allowed": 0}, "timestamp": "2025-12-31T18:33:34Z", "files": ["tests/scripts/test_classify_test_failures.py"]} \ No newline at end of file +{"changed": true, "classification": {"total": 0, "new": 0, "allowed": 0}, "timestamp": "2025-12-31T19:30:16Z", "files": ["tests/scripts/test_ledger_validate.py"]} \ No newline at end of file diff --git a/tests/scripts/test_ledger_validate.py b/tests/scripts/test_ledger_validate.py index a440f2d7b..cdac52454 100644 --- a/tests/scripts/test_ledger_validate.py +++ b/tests/scripts/test_ledger_validate.py @@ -404,8 +404,12 @@ def test_validate_task_commit_type_and_duplicate_ids(tmp_path: Path, monkeypatch "status": "todo", } - errors = ledger_validate._validate_task(first, index=0, seen_ids=seen_ids, ledger_path=ledger_path) - errors += ledger_validate._validate_task(second, index=1, seen_ids=seen_ids, ledger_path=ledger_path) + errors = ledger_validate._validate_task( + first, index=0, seen_ids=seen_ids, ledger_path=ledger_path + ) + errors += ledger_validate._validate_task( + second, index=1, seen_ids=seen_ids, ledger_path=ledger_path + ) assert "tasks[0].commit must be a string" in errors assert "duplicate task id: task-1" in errors From 6d81f201adf0b5384db6dba19d48ceb4af9034b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:33:10 +0000 Subject: [PATCH 11/12] chore(codex-keepalive): apply updates (PR #407) --- tests/scripts/test_workflow_health_check.py | 87 +++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/scripts/test_workflow_health_check.py b/tests/scripts/test_workflow_health_check.py index e556b4355..4f513a724 100644 --- a/tests/scripts/test_workflow_health_check.py +++ b/tests/scripts/test_workflow_health_check.py @@ -65,6 +65,93 @@ def test_generate_report_writes_output(tmp_path: Path) -> None: assert saved == report +def test_load_workflow_runs_skips_missing_file(tmp_path: Path) -> None: + missing = tmp_path / "missing.ndjson" + + runs = workflow_health_check.load_workflow_runs(str(missing)) + + assert runs == [] + + +def test_load_workflow_runs_reads_nonempty_lines(tmp_path: Path) -> None: + metrics_file = tmp_path / "metrics.ndjson" + metrics_file.write_text( + "\n".join( + [ + json.dumps({"verdict": "pass"}), + "", + json.dumps({"status": "failure"}), + ] + ) + + "\n" + ) + + runs = workflow_health_check.load_workflow_runs(str(metrics_file)) + + assert runs == [{"verdict": "pass"}, {"status": "failure"}] + + +def test_calculate_success_rate_uses_verdict_or_status() -> None: + runs = [ + {"verdict": "pass"}, + {"status": "success"}, + {"verdict": "fail"}, + {"status": "failure"}, + ] + + assert workflow_health_check.calculate_success_rate(runs) == 50.0 + + +def test_calculate_success_rate_empty() -> None: + assert workflow_health_check.calculate_success_rate([]) == 0.0 + + +def test_analyze_failure_patterns_counts_reasons() -> None: + runs = [ + {"verdict": "pass"}, + {"verdict": "fail", "skip_reason": "flaky"}, + {"status": "failure", "error": "timeout"}, + {"status": "failure"}, + {"verdict": "fail", "skip_reason": "flaky"}, + ] + + patterns = workflow_health_check.analyze_failure_patterns(runs) + + assert patterns == {"flaky": 2, "timeout": 1, "unknown": 1} + + +def test_get_recent_runs_skips_older_runs() -> None: + old_time = datetime(2000, 1, 1, tzinfo=UTC).isoformat() + runs = [{"verdict": "pass", "recorded_at": old_time}] + + recent = workflow_health_check.get_recent_runs(runs, days=7) + + assert recent == [] + + +def test_main_successful_run_prints_summary( + tmp_path: Path, capsys, monkeypatch: pytest.MonkeyPatch +) -> None: + metrics_file = tmp_path / "metrics.ndjson" + metrics_file.write_text( + json.dumps( + { + "verdict": "pass", + "recorded_at": datetime.now(UTC).isoformat(), + } + ) + + "\n" + ) + monkeypatch.setenv("METRICS_PATH", str(metrics_file)) + monkeypatch.setenv("SUCCESS_THRESHOLD", "80") + + workflow_health_check.main() + + captured = capsys.readouterr() + assert "Workflow Health Report" in captured.out + assert "Overall success rate: 100.0%" in captured.out + + def test_main_exits_below_threshold(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Main should exit non-zero when recent success rate is below threshold.""" metrics_file = tmp_path / "metrics.ndjson" From 699eaa29b3fe2685ea6924b620d353eaaa3596c3 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 31 Dec 2025 19:38:15 +0000 Subject: [PATCH 12/12] test: cover contents write permissions --- tests/test_workflow_validator.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_workflow_validator.py b/tests/test_workflow_validator.py index 00317a0ad..2bf94999d 100644 --- a/tests/test_workflow_validator.py +++ b/tests/test_workflow_validator.py @@ -161,6 +161,13 @@ def test_detect_job_write_all(self) -> None: issues = check_permissions(workflow) assert issues == ["Job build has write-all permissions"] + def test_allows_contents_write(self) -> None: + """Test that contents: write does not raise a permission issue.""" + workflow = {"permissions": {"contents": "write"}, "jobs": {}} + + issues = check_permissions(workflow) + assert issues == [] + class TestValidateWorkflow: """Tests for validate_workflow function."""