Coverage for src/ai_jury/policy.py: 100%
92 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +0000
1"""Optional repository review policy.
3The review *policy* is distinct from the agent-runtime ``jury.toml`` handled
4by :mod:`ai_jury.config`. A policy file is authored and committed
5by the maintainers of the repository being reviewed and expresses
6project-specific review expectations (high-risk paths, focus areas, forbidden
7output behaviour, severity overrides, a free-form checklist, and links to docs
8reviewers should consider).
10Because the policy is maintainer-authored it is treated as **trusted** content
11and is rendered into the review prompt in a clearly separated section, distinct
12from the untrusted diff/context fences.
14Policy files are entirely optional: when none is found, loaders return ``None``
15and the pipeline proceeds with an empty policy section. Only a *malformed*
16policy file raises an error, so a typo is surfaced loudly rather than silently
17ignored.
18"""
20from __future__ import annotations
22import tomllib
23from dataclasses import dataclass, field
24from pathlib import Path
26# Standard discovery locations, searched in order when no explicit path is given.
27DEFAULT_POLICY_NAMES = (".jury/policy.toml", "jury-policy.toml")
30class PolicyError(Exception):
31 """Raised when a policy file exists but cannot be parsed or is malformed."""
34@dataclass
35class SeverityOverride:
36 """Override the severity for findings touching paths matching ``glob``."""
38 glob: str
39 severity: str
42@dataclass
43class ReviewPolicy:
44 """A repository's review policy.
46 Every field is optional; an empty policy is a valid (if pointless) policy.
47 """
49 high_risk_paths: list[str] = field(default_factory=list)
50 focus_areas: list[str] = field(default_factory=list)
51 forbidden_output: list[str] = field(default_factory=list)
52 severity_overrides: list[SeverityOverride] = field(default_factory=list)
53 checklist: str = ""
54 doc_links: list[str] = field(default_factory=list)
56 def is_empty(self) -> bool:
57 """Return True when the policy carries no actionable content."""
58 return not (
59 self.high_risk_paths
60 or self.focus_areas
61 or self.forbidden_output
62 or self.severity_overrides
63 or self.checklist.strip()
64 or self.doc_links
65 )
68SENTINEL = "_(no repository policy configured)_"
71def load_policy(path: Path | None = None) -> ReviewPolicy | None:
72 """Load a repository review policy from a TOML file.
74 When ``path`` is given it must exist; a missing explicit path is treated as
75 a malformed configuration and raises :class:`PolicyError`. When ``path`` is
76 ``None`` the standard discovery locations in :data:`DEFAULT_POLICY_NAMES`
77 are searched in the current working directory.
79 Returns ``None`` when no policy file is present (the common case). Raises
80 :class:`PolicyError` when a file is found but cannot be parsed or has the
81 wrong shape.
82 """
83 policy_path = _resolve_path(path)
84 if policy_path is None:
85 return None
87 try:
88 with policy_path.open("rb") as handle:
89 data = tomllib.load(handle)
90 except OSError as exc:
91 raise PolicyError(f"could not read policy file {policy_path}: {exc}") from exc
92 except tomllib.TOMLDecodeError as exc:
93 raise PolicyError(f"invalid TOML in policy file {policy_path}: {exc}") from exc
95 return _from_dict(data, source=policy_path)
98def _resolve_path(path: Path | None) -> Path | None:
99 """Return an explicit policy path, or discover one, or ``None``."""
100 if path is not None:
101 path = Path(path)
102 if not path.exists():
103 raise PolicyError(f"policy file not found: {path}")
104 return path
105 for name in DEFAULT_POLICY_NAMES:
106 candidate = Path(name)
107 if candidate.exists():
108 return candidate
109 return None
112def _from_dict(data: dict, *, source: Path | None = None) -> ReviewPolicy:
113 """Build a :class:`ReviewPolicy` from a parsed TOML mapping."""
114 where = f" in {source}" if source is not None else ""
116 def str_list(key: str) -> list[str]:
117 value = data.get(key, [])
118 if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
119 raise PolicyError(f"'{key}' must be a list of strings{where}")
120 return list(value)
122 checklist = data.get("checklist", "")
123 if not isinstance(checklist, str):
124 raise PolicyError(f"'checklist' must be a string{where}")
126 overrides_raw = data.get("severity_overrides", [])
127 if not isinstance(overrides_raw, list):
128 raise PolicyError(f"'severity_overrides' must be a list of tables{where}")
129 overrides: list[SeverityOverride] = []
130 for entry in overrides_raw:
131 if not isinstance(entry, dict):
132 raise PolicyError(f"each severity override must be a table{where}")
133 glob = entry.get("glob")
134 severity = entry.get("severity")
135 if not isinstance(glob, str) or not isinstance(severity, str):
136 raise PolicyError(
137 f"each severity override needs string 'glob' and 'severity'{where}"
138 )
139 overrides.append(SeverityOverride(glob=glob, severity=severity))
141 return ReviewPolicy(
142 high_risk_paths=str_list("high_risk_paths"),
143 focus_areas=str_list("focus_areas"),
144 forbidden_output=str_list("forbidden_output"),
145 severity_overrides=overrides,
146 checklist=checklist,
147 doc_links=str_list("doc_links"),
148 )
151def render_policy_section(policy: ReviewPolicy | None) -> str:
152 """Render a policy as a trusted, human/agent-readable Markdown block.
154 Returns :data:`SENTINEL` when there is no (effective) policy so the review
155 prompt remains stable and self-explanatory.
156 """
157 if policy is None or policy.is_empty():
158 return SENTINEL
160 lines: list[str] = []
162 def bullet_list(title: str, items: list[str]) -> None:
163 if not items:
164 return
165 lines.append(f"**{title}:**")
166 for item in items:
167 lines.append(f"- {item}")
168 lines.append("")
170 bullet_list("High-risk paths (review with extra care)", policy.high_risk_paths)
171 bullet_list("Required review focus areas", policy.focus_areas)
172 bullet_list("Forbidden output behaviour", policy.forbidden_output)
174 if policy.severity_overrides:
175 lines.append("**Severity overrides by path pattern:**")
176 for override in policy.severity_overrides:
177 lines.append(f"- `{override.glob}` -> {override.severity}")
178 lines.append("")
180 if policy.checklist.strip():
181 lines.append("**Project review checklist:**")
182 lines.append(policy.checklist.strip())
183 lines.append("")
185 bullet_list("Reference docs to consider", policy.doc_links)
187 return "\n".join(lines).rstrip()