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

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 

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

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

28 

29 

30class PolicyError(Exception): 

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

32 

33 

34@dataclass 

35class SeverityOverride: 

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

37 

38 glob: str 

39 severity: str 

40 

41 

42@dataclass 

43class ReviewPolicy: 

44 """A repository's review policy. 

45 

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

47 """ 

48 

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) 

55 

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 ) 

66 

67 

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

69 

70 

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

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

73 

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. 

78 

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 

86 

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 

94 

95 return _from_dict(data, source=policy_path) 

96 

97 

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 

110 

111 

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

115 

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) 

121 

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

123 if not isinstance(checklist, str): 

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

125 

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

140 

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 ) 

149 

150 

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

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

153 

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 

159 

160 lines: list[str] = [] 

161 

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

169 

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) 

173 

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

179 

180 if policy.checklist.strip(): 

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

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

183 lines.append("") 

184 

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

186 

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