"""Structured gate error formatting for categorized output."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from scripts.helpers.ci_gate.models import GateError
_CATEGORY_LABEL: dict[str, str] = {
"new_source": "[A]",
"modified_source": "[M]",
"deleted_test": "[D]",
"deleted_source": "[DS]",
"exemption_drift": "[ED]",
}
_CATEGORY_HEADER: dict[str, str] = {
"new_source": "new source file(s) have no coverage mapping entry and are not exempt",
"modified_source": "modified symbol(s) have no coverage mapping entry and are not exempt",
"deleted_test": "deleted test(s) are sole coverage for source symbols",
"deleted_source": "deleted source file(s) still have sole-coverage test mapping entries",
"exemption_drift": "gate_policy exemption(s) reference deleted or renamed paths",
}
_CATEGORY_SUGGESTION: dict[str, str] = {
"new_source": (
"Add test cases or register an exemption in tests/.ci/gate_policy.yaml.\n"
" → If already exempted, ensure symbols matches path::symbol above exactly."
),
"modified_source": (
"Add test cases or register an exemption in tests/.ci/gate_policy.yaml.\n"
" → If already exempted, ensure symbols matches path::symbol above exactly."
),
"deleted_test": "Add replacement test cases or delete the corresponding source symbols.",
"deleted_source": ("Delete the sole-coverage test node(s) in the same PR, or refresh test_map after nightly/sync."),
"exemption_drift": (
"Update or remove stale entries in tests/.ci/gate_policy.yaml to match the renamed or deleted paths in this PR."
),
}
def format_blocking_errors(errors: tuple[GateError, ...], *, pytest_ran: bool = False) -> str:
by_category: dict[str, list[GateError]] = {}
for err in errors:
by_category.setdefault(err.category, []).append(err)
lines: list[str] = []
if pytest_ran:
lines.append(
f"CI gate failed: coverage mapping policy not satisfied after pytest.\nBlocking items: {len(errors)}."
)
else:
lines.append(f"CI gate failed: policy violation — pytest was not run.\nBlocking items: {len(errors)}.")
for category in (
"exemption_drift",
"new_source",
"modified_source",
"deleted_test",
"deleted_source",
):
group = by_category.get(category)
if not group:
continue
tag = _CATEGORY_LABEL.get(category, "")
header = _CATEGORY_HEADER.get(category, category)
suggestion = _CATEGORY_SUGGESTION.get(category, "")
lines.append(f"The following {len(group)} {header}:")
for err in group:
label = f"{err.path}::{err.symbol}" if err.symbol else err.path
lines.append(f" - {label} {tag}")
if err.detail:
lines.extend(f" {dline}" for dline in err.detail.splitlines())
lines.append(f" → {suggestion}")
lines.append("")
return "\n".join(lines)
def format_pytest_failure_hint(node_ids: tuple[str, ...]) -> str:
lines = [
"CI gate failed: selected test(s) failed. Fix test failures before gate check.",
"",
"Executed node(s):",
*[f" - {node_id}" for node_id in sorted(node_ids)],
"",
"To exempt failing test(s), add entries under exemptions.tests",
"in tests/.ci/gate_policy.yaml:",
" exemptions:",
" tests:",
" - symbols:",
" - tests/path/to/test_file.py::test_name",
' reason: "<why this test is exempt from PR gate>"',
" applicant: <your-id>",
" approver: <approver-from-tests/.ci/approvers.yaml>",
" deadline: YYYY-MM-DD",
]
return "\n".join(lines)