Coverage for src/ai_jury/policy.py: 98%
99 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-30 06:29 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-30 06: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
26from .redaction import redact
28# Standard discovery locations, searched in order when no explicit path is given.
29DEFAULT_POLICY_NAMES = (".jury/policy.toml", "jury-policy.toml")
31# Upper bound on a policy TOML file (issue #316/L-5); a real policy is a few KB.
32_MAX_POLICY_BYTES = 4 * 1024 * 1024
35class PolicyError(Exception):
36 """Raised when a policy file exists but cannot be parsed or is malformed."""
39@dataclass
40class SeverityOverride:
41 """Override the severity for findings touching paths matching ``glob``."""
43 glob: str
44 severity: str
47@dataclass
48class ReviewPolicy:
49 """A repository's review policy.
51 Every field is optional; an empty policy is a valid (if pointless) policy.
52 """
54 high_risk_paths: list[str] = field(default_factory=list)
55 focus_areas: list[str] = field(default_factory=list)
56 forbidden_output: list[str] = field(default_factory=list)
57 severity_overrides: list[SeverityOverride] = field(default_factory=list)
58 checklist: str = ""
59 doc_links: list[str] = field(default_factory=list)
61 def is_empty(self) -> bool:
62 """Return True when the policy carries no actionable content."""
63 return not (
64 self.high_risk_paths
65 or self.focus_areas
66 or self.forbidden_output
67 or self.severity_overrides
68 or self.checklist.strip()
69 or self.doc_links
70 )
73SENTINEL = "_(no repository policy configured)_"
76def load_policy(path: Path | None = None) -> ReviewPolicy | None:
77 """Load a repository review policy from a TOML file.
79 When ``path`` is given it must exist; a missing explicit path is treated as
80 a malformed configuration and raises :class:`PolicyError`. When ``path`` is
81 ``None`` the standard discovery locations in :data:`DEFAULT_POLICY_NAMES`
82 are searched in the current working directory.
84 Returns ``None`` when no policy file is present (the common case). Raises
85 :class:`PolicyError` when a file is found but cannot be parsed or has the
86 wrong shape.
87 """
88 policy_path = _resolve_path(path)
89 if policy_path is None:
90 return None
92 try:
93 # Size-cap the read (issue #316/L-5): a real policy is a few KB; refuse a
94 # multi-MB / pathological file rather than feed it whole to tomllib.
95 with policy_path.open("rb") as handle:
96 raw = handle.read(_MAX_POLICY_BYTES + 1)
97 if len(raw) > _MAX_POLICY_BYTES: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true
98 raise PolicyError(
99 f"policy file {policy_path} exceeds the {_MAX_POLICY_BYTES}-byte limit."
100 )
101 data = tomllib.loads(raw.decode("utf-8"))
102 except OSError as exc:
103 raise PolicyError(
104 f"could not read policy file {policy_path}: {redact(str(exc))[0]}"
105 ) from None
106 except UnicodeDecodeError:
107 # TOML is UTF-8 by spec (review of #316).
108 raise PolicyError(f"policy file {policy_path} is not valid UTF-8.") from None
109 except tomllib.TOMLDecodeError as exc:
110 raise PolicyError(
111 f"invalid TOML in policy file {policy_path}: {redact(str(exc))[0]}"
112 ) from None
114 return _from_dict(data, source=policy_path)
117def _resolve_path(path: Path | None) -> Path | None:
118 """Return an explicit policy path, or discover one, or ``None``."""
119 if path is not None:
120 path = Path(path)
121 if not path.exists():
122 raise PolicyError(f"policy file not found: {path}")
123 return path
124 for name in DEFAULT_POLICY_NAMES:
125 candidate = Path(name)
126 if candidate.exists():
127 return candidate
128 return None
131def _from_dict(data: dict, *, source: Path | None = None) -> ReviewPolicy:
132 """Build a :class:`ReviewPolicy` from a parsed TOML mapping."""
133 where = f" in {source}" if source is not None else ""
135 def str_list(key: str) -> list[str]:
136 value = data.get(key, [])
137 if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
138 raise PolicyError(f"'{key}' must be a list of strings{where}")
139 return list(value)
141 checklist = data.get("checklist", "")
142 if not isinstance(checklist, str):
143 raise PolicyError(f"'checklist' must be a string{where}")
145 overrides_raw = data.get("severity_overrides", [])
146 if not isinstance(overrides_raw, list):
147 raise PolicyError(f"'severity_overrides' must be a list of tables{where}")
148 overrides: list[SeverityOverride] = []
149 for entry in overrides_raw:
150 if not isinstance(entry, dict):
151 raise PolicyError(f"each severity override must be a table{where}")
152 glob = entry.get("glob")
153 severity = entry.get("severity")
154 if not isinstance(glob, str) or not isinstance(severity, str):
155 raise PolicyError(f"each severity override needs string 'glob' and 'severity'{where}")
156 overrides.append(SeverityOverride(glob=glob, severity=severity))
158 return ReviewPolicy(
159 high_risk_paths=str_list("high_risk_paths"),
160 focus_areas=str_list("focus_areas"),
161 forbidden_output=str_list("forbidden_output"),
162 severity_overrides=overrides,
163 checklist=checklist,
164 doc_links=str_list("doc_links"),
165 )
168def render_policy_section(policy: ReviewPolicy | None) -> str:
169 """Render a policy as a trusted, human/agent-readable Markdown block.
171 Returns :data:`SENTINEL` when there is no (effective) policy so the review
172 prompt remains stable and self-explanatory.
173 """
174 if policy is None or policy.is_empty():
175 return SENTINEL
177 lines: list[str] = []
179 def bullet_list(title: str, items: list[str]) -> None:
180 if not items:
181 return
182 lines.append(f"**{title}:**")
183 for item in items:
184 lines.append(f"- {item}")
185 lines.append("")
187 bullet_list("High-risk paths (review with extra care)", policy.high_risk_paths)
188 bullet_list("Required review focus areas", policy.focus_areas)
189 bullet_list("Forbidden output behaviour", policy.forbidden_output)
191 if policy.severity_overrides:
192 lines.append("**Severity overrides by path pattern:**")
193 for override in policy.severity_overrides:
194 lines.append(f"- `{override.glob}` -> {override.severity}")
195 lines.append("")
197 if policy.checklist.strip():
198 lines.append("**Project review checklist:**")
199 lines.append(policy.checklist.strip())
200 lines.append("")
202 bullet_list("Reference docs to consider", policy.doc_links)
204 return "\n".join(lines).rstrip()