Skip to main content

amont_runtime/
policy.rs

1//! Committed repo policy — the team's decisions, shipped with the repository.
2//!
3//! `amont.conf` could always ADD checks; it could never say anything about
4//! the built-ins, so "clippy is warn-only here" meant every teammate running
5//! the same `git config` incantation, unverified. `severity` and `skip`
6//! lines close that: committed, reviewed like code, and trust-gated exactly
7//! like declared checks — a repository you cloned to read cannot weaken your
8//! safety net until you consent.
9//!
10//! Precedence is a specificity ladder decided per KEY: built-in default <
11//! system config < global config < POLICY < local config < worktree <
12//! command-line. Between different keys naming the same check, specificity
13//! (full id > short name > trigger) decides, whatever the source — a local
14//! `amont.severity.pre-commit warn` must never be unbeatable by policy, and
15//! a policy full-id beats a local trigger. Skips are a UNION of all sources:
16//! there is no unskip mechanism anywhere, so ordering has nothing to decide.
17//!
18//! There is no store. A `Policy` is owned by the [`crate::config::Settings`]
19//! each entrypoint builds after `manifest::load`, and borrowed from there —
20//! the shape `Ctx` already used for `manifest` and `push`.
21//!
22//! It WAS a process-global `OnceLock`, which was correct for the hook path
23//! (one process, one repository) and wrong as a contract: a multi-repo walker
24//! had to KNOW not to seed it, `amont-fleet` carried that knowledge as a
25//! comment, and no test could assert the rule because asserting it would have
26//! meant seeding it. Now the rule needs no remembering — a scanner cannot
27//! corrupt a store that does not exist. Every RULE here stays a pure function
28//! over `&Policy`.
29
30use crate::check::Severity;
31use crate::manifest::{Line, PolicyLine};
32
33/// What a trusted manifest's policy lines add up to.
34#[derive(Debug, Default, Clone, PartialEq, Eq)]
35pub struct Policy {
36    /// `(target, severity)` in file order — later lines overwrite earlier
37    /// ones at fold time, matching git config's own precedence rule.
38    pub severities: Vec<(String, Severity)>,
39    /// Skip targets, resolved by the same three-way naming `hook.skip` uses.
40    pub skips: Vec<String>,
41    /// Committed defaults for allowlisted config keys, by FULL git key
42    /// (`amont.timeout`). Values are raw strings — GIT parses them at read
43    /// time (`config::typed_literal`), so no second config dialect exists.
44    pub settings: std::collections::BTreeMap<String, String>,
45}
46
47impl Policy {
48    pub fn is_empty(&self) -> bool {
49        self.severities.is_empty() && self.skips.is_empty() && self.settings.is_empty()
50    }
51
52    /// Collect the policy from parsed lines, and say which targets name
53    /// nothing — validated here, over the WHOLE file, because `parse_line`
54    /// sees only earlier lines and a `severity smoke warn` written above its
55    /// own `pre-commit smoke …` declaration would be wrongly refused.
56    ///
57    /// The naming universe is built-ins plus the file's CHECK lines only
58    /// (`Line::is_check`) — a `tool ruff …` pin must not make
59    /// `severity ruff warn` look valid, and `Broken` lines count because a
60    /// broken line still produces a check id that `hook.skip` can reach.
61    ///
62    /// An unmatched target is a NOTE, not a `Line::Broken` — a broken line
63    /// manufactures a check named after itself, and a phantom
64    /// `pre-commit-clipy` helps nobody.
65    pub fn from_lines(lines: &[Line]) -> (Policy, Vec<String>) {
66        let mut policy = Policy::default();
67        let mut notes = Vec::new();
68        let names_something = |target: &str| {
69            crate::registry::CHECKS
70                .iter()
71                .any(|c| crate::names_check(c.name, target).is_some())
72                || lines
73                    .iter()
74                    .filter(|l| l.is_check())
75                    .any(|l| crate::names_check(&l.id(), target).is_some())
76        };
77        for line in lines {
78            let Line::Policy { what, lineno } = line else {
79                continue;
80            };
81            let target = match what {
82                PolicyLine::Severity { target, .. } | PolicyLine::Skip { target } => target,
83                PolicyLine::Set { key, value } => {
84                    // Allowlisted at parse; later lines overwrite earlier
85                    // ones, the same rule config itself applies.
86                    policy.settings.insert(key.clone(), value.clone());
87                    continue;
88                }
89            };
90            if !names_something(target) {
91                let kind = match what {
92                    PolicyLine::Severity { .. } => "severity",
93                    PolicyLine::Skip { .. } => "skip",
94                    // Set lines took the `continue` above; unreachable here.
95                    PolicyLine::Set { .. } => unreachable!("set lines have no target"),
96                };
97                notes.push(format!(
98                    "{}:{lineno}: {kind} {target:?} names no check here",
99                    crate::manifest::MANIFEST
100                ));
101                continue;
102            }
103            match what {
104                PolicyLine::Severity { target, severity } => {
105                    policy.severities.push((target.clone(), *severity));
106                }
107                PolicyLine::Skip { target } => policy.skips.push(target.clone()),
108                PolicyLine::Set { .. } => unreachable!("set lines have no target"),
109            }
110        }
111        (policy, notes)
112    }
113}
114
115/// The union `hook.skip` resolution sees: machine skips plus policy skips.
116/// A union and not a ladder — nothing anywhere can UN-skip, so there is no
117/// conflict for ordering to settle.
118pub fn union_skips(config_skips: Vec<String>, policy: &Policy) -> Vec<String> {
119    let mut all = config_skips;
120    for s in &policy.skips {
121        if !all.contains(s) {
122            all.push(s.clone());
123        }
124    }
125    all
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::manifest::parse_lines;
132
133    #[test]
134    fn collects_severities_and_skips_in_file_order() {
135        let lines = parse_lines(
136            "severity clippy warn\nskip yamllint\nseverity pre-push-cargo-test block\n",
137        );
138        let (p, notes) = Policy::from_lines(&lines);
139        assert!(notes.is_empty(), "{notes:?}");
140        assert_eq!(
141            p.severities,
142            vec![
143                ("clippy".to_string(), Severity::Warn),
144                ("pre-push-cargo-test".to_string(), Severity::Block),
145            ]
146        );
147        assert_eq!(p.skips, vec!["yamllint".to_string()]);
148    }
149
150    /// A target can be a declared check — including one written BELOW the
151    /// policy line, which is why validation is a whole-file pass.
152    #[test]
153    fn a_declared_check_below_the_policy_line_still_validates() {
154        let lines =
155            parse_lines("severity smoke warn\npre-commit    smoke   *   block   ./smoke.sh\n");
156        let (p, notes) = Policy::from_lines(&lines);
157        assert!(notes.is_empty(), "{notes:?}");
158        assert_eq!(p.severities.len(), 1);
159    }
160
161    /// A tool pin must not lend its name to the validation universe.
162    #[test]
163    fn a_tool_pin_does_not_validate_a_policy_target() {
164        let lines = parse_lines("tool ruffian 0.4\nseverity ruffian warn\n");
165        let (p, notes) = Policy::from_lines(&lines);
166        assert!(p.severities.is_empty());
167        assert_eq!(notes.len(), 1, "{notes:?}");
168        assert!(notes[0].contains("names no check here"), "{notes:?}");
169        assert!(notes[0].contains("amont.conf:2"), "{notes:?}");
170    }
171
172    /// A typo is a note with a position, never a phantom check.
173    #[test]
174    fn an_unmatched_target_is_a_note_not_a_check() {
175        let lines = parse_lines("severity clipy warn\n");
176        let (p, notes) = Policy::from_lines(&lines);
177        assert!(p.is_empty());
178        assert_eq!(
179            notes,
180            vec!["amont.conf:1: severity \"clipy\" names no check here"]
181        );
182    }
183
184    /// Triggers and short names resolve exactly as `hook.skip` resolves them.
185    #[test]
186    fn triggers_and_short_names_are_valid_targets() {
187        let lines = parse_lines("skip pre-commit\nseverity ban-terms warn\n");
188        let (p, notes) = Policy::from_lines(&lines);
189        assert!(notes.is_empty(), "{notes:?}");
190        assert_eq!(p.skips, vec!["pre-commit".to_string()]);
191        assert_eq!(p.severities.len(), 1);
192    }
193
194    #[test]
195    fn union_adds_policy_skips_without_duplicating() {
196        let p = Policy {
197            severities: Vec::new(),
198            skips: vec!["yamllint".into(), "clippy".into()],
199            settings: std::collections::BTreeMap::new(),
200        };
201        let got = union_skips(vec!["clippy".into()], &p);
202        assert_eq!(got, vec!["clippy".to_string(), "yamllint".to_string()]);
203    }
204}