Skip to main content

repoctl_engine/
policy.rs

1//! Boundary policy rules.
2
3use 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
11/// Evaluates the default v0.2 policy rule set.
12pub 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    /// Evaluates all rules against a snapshot.
47    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/// Denies app-to-app dependencies.
79#[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/// Denies framework and foundation dependencies on app projects.
117#[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/// Denies app dependencies on framework internal areas.
168#[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/// Denies app dependencies on foundation internals.
204#[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/// Denies direct edits to generated code paths.
243#[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/// Reports high-risk infrastructure changes.
276#[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}