Coverage for src/ai_jury/policy.py: 98%
99 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +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(f"could not read policy file {policy_path}: {redact(str(exc))[0]}") from exc
104 except UnicodeDecodeError as exc:
105 # TOML is UTF-8 by spec (review of #316).
106 raise PolicyError(f"policy file {policy_path} is not valid UTF-8.") from exc
107 except tomllib.TOMLDecodeError as exc:
108 raise PolicyError(f"invalid TOML in policy file {policy_path}: {redact(str(exc))[0]}") from exc
110 return _from_dict(data, source=policy_path)
113def _resolve_path(path: Path | None) -> Path | None:
114 """Return an explicit policy path, or discover one, or ``None``."""
115 if path is not None:
116 path = Path(path)
117 if not path.exists():
118 raise PolicyError(f"policy file not found: {path}")
119 return path
120 for name in DEFAULT_POLICY_NAMES:
121 candidate = Path(name)
122 if candidate.exists():
123 return candidate
124 return None
127def _from_dict(data: dict, *, source: Path | None = None) -> ReviewPolicy:
128 """Build a :class:`ReviewPolicy` from a parsed TOML mapping."""
129 where = f" in {source}" if source is not None else ""
131 def str_list(key: str) -> list[str]:
132 value = data.get(key, [])
133 if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
134 raise PolicyError(f"'{key}' must be a list of strings{where}")
135 return list(value)
137 checklist = data.get("checklist", "")
138 if not isinstance(checklist, str):
139 raise PolicyError(f"'checklist' must be a string{where}")
141 overrides_raw = data.get("severity_overrides", [])
142 if not isinstance(overrides_raw, list):
143 raise PolicyError(f"'severity_overrides' must be a list of tables{where}")
144 overrides: list[SeverityOverride] = []
145 for entry in overrides_raw:
146 if not isinstance(entry, dict):
147 raise PolicyError(f"each severity override must be a table{where}")
148 glob = entry.get("glob")
149 severity = entry.get("severity")
150 if not isinstance(glob, str) or not isinstance(severity, str):
151 raise PolicyError(f"each severity override needs string 'glob' and 'severity'{where}")
152 overrides.append(SeverityOverride(glob=glob, severity=severity))
154 return ReviewPolicy(
155 high_risk_paths=str_list("high_risk_paths"),
156 focus_areas=str_list("focus_areas"),
157 forbidden_output=str_list("forbidden_output"),
158 severity_overrides=overrides,
159 checklist=checklist,
160 doc_links=str_list("doc_links"),
161 )
164def render_policy_section(policy: ReviewPolicy | None) -> str:
165 """Render a policy as a trusted, human/agent-readable Markdown block.
167 Returns :data:`SENTINEL` when there is no (effective) policy so the review
168 prompt remains stable and self-explanatory.
169 """
170 if policy is None or policy.is_empty():
171 return SENTINEL
173 lines: list[str] = []
175 def bullet_list(title: str, items: list[str]) -> None:
176 if not items:
177 return
178 lines.append(f"**{title}:**")
179 for item in items:
180 lines.append(f"- {item}")
181 lines.append("")
183 bullet_list("High-risk paths (review with extra care)", policy.high_risk_paths)
184 bullet_list("Required review focus areas", policy.focus_areas)
185 bullet_list("Forbidden output behaviour", policy.forbidden_output)
187 if policy.severity_overrides:
188 lines.append("**Severity overrides by path pattern:**")
189 for override in policy.severity_overrides:
190 lines.append(f"- `{override.glob}` -> {override.severity}")
191 lines.append("")
193 if policy.checklist.strip():
194 lines.append("**Project review checklist:**")
195 lines.append(policy.checklist.strip())
196 lines.append("")
198 bullet_list("Reference docs to consider", policy.doc_links)
200 return "\n".join(lines).rstrip()