1use std::collections::BTreeMap;
4
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use repoctl_core::{
7 Diagnostic, EdgeKind, PolicyContext, PolicyRule, ProjectKind, ProjectManifest,
8 RepoRelativePath, RepoSnapshot, RepoctlError,
9};
10
11pub struct PolicyEngine {
13 rules: Vec<Box<dyn PolicyRule>>,
14}
15
16impl std::fmt::Debug for PolicyEngine {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 f.debug_struct("PolicyEngine")
19 .field("rules", &self.rules.len())
20 .finish()
21 }
22}
23
24impl Default for PolicyEngine {
25 fn default() -> Self {
26 Self {
27 rules: vec![
28 Box::new(CrossAppDependencyRule),
29 Box::new(ProjectKindDependencyRule),
30 Box::new(FrameworkFacadeOnlyRule),
31 Box::new(FoundationPublicClientOnlyRule),
32 Box::new(GeneratedCodeReadonlyRule),
33 Box::new(HighRiskIacRule),
34 ],
35 }
36 }
37}
38
39impl Clone for PolicyEngine {
40 fn clone(&self) -> Self {
41 Self::default()
42 }
43}
44
45impl PolicyEngine {
46 pub fn evaluate(
48 &self,
49 snapshot: &RepoSnapshot,
50 changed_files: &[RepoRelativePath],
51 ) -> Result<Vec<Diagnostic>, RepoctlError> {
52 let context = PolicyContext {
53 snapshot,
54 changed_files,
55 };
56 let mut diagnostics = Vec::new();
57 for rule in &self.rules {
58 diagnostics.extend(rule.evaluate(&context)?);
59 }
60 diagnostics.sort_by(|left, right| {
61 (
62 &left.code,
63 &left.message,
64 &left.project,
65 &left.source.as_ref().map(|s| &s.path),
66 )
67 .cmp(&(
68 &right.code,
69 &right.message,
70 &right.project,
71 &right.source.as_ref().map(|s| &s.path),
72 ))
73 });
74 Ok(diagnostics)
75 }
76}
77
78#[derive(Clone, Debug, Default)]
80pub struct CrossAppDependencyRule;
81
82impl PolicyRule for CrossAppDependencyRule {
83 fn name(&self) -> &'static str {
84 "no-cross-app-dependency"
85 }
86
87 fn evaluate(&self, context: &PolicyContext<'_>) -> Result<Vec<Diagnostic>, RepoctlError> {
88 let projects = projects_by_node(context.snapshot);
89 let mut diagnostics = Vec::new();
90 for edge in &context.snapshot.graph.edges {
91 if edge.kind != EdgeKind::DependsOnProject {
92 continue;
93 }
94 let Some(source) = projects.get(&edge.from) else {
95 continue;
96 };
97 let Some(target) = projects.get(&edge.to) else {
98 continue;
99 };
100 if source.kind == ProjectKind::App && target.kind == ProjectKind::App {
101 diagnostics.push(policy_error(
102 "policy.cross_app_dependency",
103 format!(
104 "app `{}` must not depend on app `{}`",
105 source.name, target.name
106 ),
107 source,
108 edge.evidence.clone(),
109 ));
110 }
111 }
112 Ok(diagnostics)
113 }
114}
115
116#[derive(Clone, Debug, Default)]
118pub struct ProjectKindDependencyRule;
119
120impl PolicyRule for ProjectKindDependencyRule {
121 fn name(&self) -> &'static str {
122 "project-kind-dependency"
123 }
124
125 fn evaluate(&self, context: &PolicyContext<'_>) -> Result<Vec<Diagnostic>, RepoctlError> {
126 let projects = projects_by_node(context.snapshot);
127 let mut diagnostics = Vec::new();
128 for edge in &context.snapshot.graph.edges {
129 if edge.kind != EdgeKind::DependsOnProject {
130 continue;
131 }
132 let Some(source) = projects.get(&edge.from) else {
133 continue;
134 };
135 let Some(target) = projects.get(&edge.to) else {
136 continue;
137 };
138 if target.kind == ProjectKind::App
139 && !matches!(source.kind, ProjectKind::App | ProjectKind::Tool)
140 {
141 diagnostics.push(policy_error(
142 "policy.project_kind_dependency",
143 format!(
144 "{:?} `{}` must not depend on app `{}`",
145 source.kind, source.name, target.name
146 ),
147 source,
148 edge.evidence.clone(),
149 ));
150 }
151 if target.kind == ProjectKind::Tool && source.kind == ProjectKind::App {
152 diagnostics.push(policy_error(
153 "policy.project_kind_dependency",
154 format!(
155 "app `{}` must not depend on tool internals `{}`",
156 source.name, target.name
157 ),
158 source,
159 edge.evidence.clone(),
160 ));
161 }
162 }
163 Ok(diagnostics)
164 }
165}
166
167#[derive(Clone, Debug, Default)]
169pub struct FrameworkFacadeOnlyRule;
170
171impl PolicyRule for FrameworkFacadeOnlyRule {
172 fn name(&self) -> &'static str {
173 "app-must-use-framework-facade"
174 }
175
176 fn evaluate(&self, context: &PolicyContext<'_>) -> Result<Vec<Diagnostic>, RepoctlError> {
177 let projects = projects_by_node(context.snapshot);
178 let mut diagnostics = Vec::new();
179 for edge in &context.snapshot.graph.edges {
180 if edge.kind != EdgeKind::UsesFrameworkInternal {
181 continue;
182 }
183 let Some(source) = projects.get(&edge.from) else {
184 continue;
185 };
186 if source.kind != ProjectKind::App {
187 continue;
188 }
189 diagnostics.push(policy_error(
190 "policy.framework_internal_dependency",
191 format!(
192 "app `{}` must depend on framework facades only",
193 source.name
194 ),
195 source,
196 edge.evidence.clone(),
197 ));
198 }
199 Ok(diagnostics)
200 }
201}
202
203#[derive(Clone, Debug, Default)]
205pub struct FoundationPublicClientOnlyRule;
206
207impl PolicyRule for FoundationPublicClientOnlyRule {
208 fn name(&self) -> &'static str {
209 "app-must-use-foundation-client"
210 }
211
212 fn evaluate(&self, context: &PolicyContext<'_>) -> Result<Vec<Diagnostic>, RepoctlError> {
213 let projects = projects_by_node(context.snapshot);
214 let mut diagnostics = Vec::new();
215 for edge in &context.snapshot.graph.edges {
216 if edge.kind != EdgeKind::UsesFoundationInternal {
217 continue;
218 }
219 let Some(source) = projects.get(&edge.from) else {
220 continue;
221 };
222 if source.kind != ProjectKind::App {
223 continue;
224 }
225 diagnostics.push(
226 policy_error(
227 "policy.foundation_internal_dependency",
228 format!(
229 "app `{}` must use foundation public clients instead of internals",
230 source.name
231 ),
232 source,
233 edge.evidence.clone(),
234 )
235 .with_help("depend on foundations.<service>.client"),
236 );
237 }
238 Ok(diagnostics)
239 }
240}
241
242#[derive(Clone, Debug, Default)]
244pub struct GeneratedCodeReadonlyRule;
245
246impl PolicyRule for GeneratedCodeReadonlyRule {
247 fn name(&self) -> &'static str {
248 "generated-code-readonly"
249 }
250
251 fn evaluate(&self, context: &PolicyContext<'_>) -> Result<Vec<Diagnostic>, RepoctlError> {
252 let generated = compile_globs(["**/generated/**"])?;
253 let mut diagnostics = Vec::new();
254 for changed_file in context.changed_files {
255 let global_match = generated.is_match(changed_file.as_str());
256 let project_match = context
257 .snapshot
258 .projects
259 .iter()
260 .any(|project| project_generated_match(project, changed_file));
261 if global_match || project_match {
262 diagnostics.push(
263 Diagnostic::error(
264 "policy.generated_code_readonly",
265 format!("generated file `{changed_file}` must not be edited directly"),
266 )
267 .with_path(changed_file.as_str()),
268 );
269 }
270 }
271 Ok(diagnostics)
272 }
273}
274
275#[derive(Clone, Debug, Default)]
277pub struct HighRiskIacRule;
278
279impl PolicyRule for HighRiskIacRule {
280 fn name(&self) -> &'static str {
281 "high-risk-iac"
282 }
283
284 fn evaluate(&self, context: &PolicyContext<'_>) -> Result<Vec<Diagnostic>, RepoctlError> {
285 let high_risk = compile_globs([
286 "core-infra/**",
287 "core-infra/**/prod/**",
288 "core-infra/**/Pulumi.prod.yaml",
289 "apps/*/iac/stacks/prod/**",
290 "apps/*/iac/stacks/prod*",
291 "foundations/*/iac/stacks/prod/**",
292 "foundations/*/iac/stacks/prod*",
293 ])?;
294 let mut diagnostics = Vec::new();
295 for changed_file in context.changed_files {
296 if !high_risk.is_match(changed_file.as_str()) {
297 continue;
298 }
299 let reviewers = required_reviewers(context.snapshot, changed_file);
300 diagnostics.push(
301 Diagnostic::warning(
302 "policy.high_risk_iac",
303 format!("high-risk infrastructure change `{changed_file}` requires review"),
304 )
305 .with_path(changed_file.as_str())
306 .with_help(format!("required reviewers: {}", reviewers.join(", "))),
307 );
308 }
309 Ok(diagnostics)
310 }
311}
312
313fn projects_by_node(snapshot: &RepoSnapshot) -> BTreeMap<String, &ProjectManifest> {
314 snapshot
315 .projects
316 .iter()
317 .map(|project| (project.node_id(), project))
318 .collect()
319}
320
321fn policy_error(
322 code: &'static str,
323 message: String,
324 project: &ProjectManifest,
325 evidence: Option<String>,
326) -> Diagnostic {
327 let mut diagnostic = Diagnostic::error(code, message)
328 .with_path(project.source.as_str())
329 .with_project(project.name.as_str());
330 if let Some(evidence) = evidence {
331 diagnostic = diagnostic.with_help(format!("offending dependency: {evidence}"));
332 }
333 diagnostic
334}
335
336fn compile_globs<const N: usize>(patterns: [&str; N]) -> Result<GlobSet, RepoctlError> {
337 let mut builder = GlobSetBuilder::new();
338 for pattern in patterns {
339 let glob = Glob::new(pattern).map_err(|error| {
340 RepoctlError::Internal(format!("invalid built-in glob `{pattern}`: {error}"))
341 })?;
342 builder.add(glob);
343 }
344 builder
345 .build()
346 .map_err(|error| RepoctlError::Internal(format!("failed to build glob set: {error}")))
347}
348
349fn project_generated_match(project: &ProjectManifest, changed_file: &RepoRelativePath) -> bool {
350 if !changed_file.starts_with(&project.path) {
351 return false;
352 }
353 project.ai.do_not_edit.iter().any(|pattern| {
354 let full_pattern = if pattern.as_str().starts_with("**/") {
355 pattern.as_str().to_string()
356 } else {
357 format!("{}/{}", project.path, pattern.as_str())
358 };
359 Glob::new(&full_pattern)
360 .map(|glob| glob.compile_matcher())
361 .is_ok_and(|matcher| matcher.is_match(changed_file.as_str()))
362 })
363}
364
365fn required_reviewers(snapshot: &RepoSnapshot, changed_file: &RepoRelativePath) -> Vec<String> {
366 let mut reviewers = snapshot
367 .repo_manifest
368 .policies
369 .prod_change_required_owners
370 .iter()
371 .map(ToString::to_string)
372 .collect::<Vec<_>>();
373 for project in &snapshot.projects {
374 if project.contains_path(changed_file) {
375 reviewers.extend(project.owners.iter().map(ToString::to_string));
376 }
377 }
378 reviewers.sort();
379 reviewers.dedup();
380 if reviewers.is_empty() {
381 reviewers.push("@platform".to_string());
382 }
383 reviewers
384}