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

1"""Optional repository review policy. 

2 

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). 

9 

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. 

13 

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""" 

19 

20from __future__ import annotations 

21 

22import tomllib 

23from dataclasses import dataclass, field 

24from pathlib import Path 

25 

26from .redaction import redact 

27 

28# Standard discovery locations, searched in order when no explicit path is given. 

29DEFAULT_POLICY_NAMES = (".jury/policy.toml", "jury-policy.toml") 

30 

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 

33 

34 

35class PolicyError(Exception): 

36 """Raised when a policy file exists but cannot be parsed or is malformed.""" 

37 

38 

39@dataclass 

40class SeverityOverride: 

41 """Override the severity for findings touching paths matching ``glob``.""" 

42 

43 glob: str 

44 severity: str 

45 

46 

47@dataclass 

48class ReviewPolicy: 

49 """A repository's review policy. 

50 

51 Every field is optional; an empty policy is a valid (if pointless) policy. 

52 """ 

53 

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) 

60 

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 ) 

71 

72 

73SENTINEL = "_(no repository policy configured)_" 

74 

75 

76def load_policy(path: Path | None = None) -> ReviewPolicy | None: 

77 """Load a repository review policy from a TOML file. 

78 

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. 

83 

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 

91 

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 

109 

110 return _from_dict(data, source=policy_path) 

111 

112 

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 

125 

126 

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 "" 

130 

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) 

136 

137 checklist = data.get("checklist", "") 

138 if not isinstance(checklist, str): 

139 raise PolicyError(f"'checklist' must be a string{where}") 

140 

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)) 

153 

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 ) 

162 

163 

164def render_policy_section(policy: ReviewPolicy | None) -> str: 

165 """Render a policy as a trusted, human/agent-readable Markdown block. 

166 

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 

172 

173 lines: list[str] = [] 

174 

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("") 

182 

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) 

186 

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("") 

192 

193 if policy.checklist.strip(): 

194 lines.append("**Project review checklist:**") 

195 lines.append(policy.checklist.strip()) 

196 lines.append("") 

197 

198 bullet_list("Reference docs to consider", policy.doc_links) 

199 

200 return "\n".join(lines).rstrip()