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//! The store is a process-global `OnceLock`, installed by each entrypoint
19//! immediately after `manifest::load` — one process means one repository,
20//! structurally (see the counter-precedent note at `manifest::Manifest`).
21//! The FLEET never touches it: a scanner walks many repositories and reads
22//! `manifest::read_lines` per repo instead. Every RULE here is a pure
23//! function over `&Policy`, so the rules are unit-testable without the
24//! global; no amont-runtime unit test may call [`install`].
25
26use crate::check::Severity;
27use crate::manifest::{Line, PolicyLine};
28
29/// What a trusted manifest's policy lines add up to.
30#[derive(Debug, Default, Clone, PartialEq, Eq)]
31pub struct Policy {
32    /// `(target, severity)` in file order — later lines overwrite earlier
33    /// ones at fold time, matching git config's own precedence rule.
34    pub severities: Vec<(String, Severity)>,
35    /// Skip targets, resolved by the same three-way naming `hook.skip` uses.
36    pub skips: Vec<String>,
37    /// Committed defaults for allowlisted config keys, by FULL git key
38    /// (`amont.timeout`). Values are raw strings — GIT parses them at read
39    /// time (`config::typed_literal`), so no second config dialect exists.
40    pub settings: std::collections::BTreeMap<String, String>,
41}
42
43impl Policy {
44    pub fn is_empty(&self) -> bool {
45        self.severities.is_empty() && self.skips.is_empty() && self.settings.is_empty()
46    }
47
48    /// Collect the policy from parsed lines, and say which targets name
49    /// nothing — validated here, over the WHOLE file, because `parse_line`
50    /// sees only earlier lines and a `severity smoke warn` written above its
51    /// own `pre-commit smoke …` declaration would be wrongly refused.
52    ///
53    /// The naming universe is built-ins plus the file's CHECK lines only
54    /// (`Line::is_check`) — a `tool ruff …` pin must not make
55    /// `severity ruff warn` look valid, and `Broken` lines count because a
56    /// broken line still produces a check id that `hook.skip` can reach.
57    ///
58    /// An unmatched target is a NOTE, not a `Line::Broken` — a broken line
59    /// manufactures a check named after itself, and a phantom
60    /// `pre-commit-clipy` helps nobody.
61    pub fn from_lines(lines: &[Line]) -> (Policy, Vec<String>) {
62        let mut policy = Policy::default();
63        let mut notes = Vec::new();
64        let names_something = |target: &str| {
65            crate::registry::CHECKS
66                .iter()
67                .any(|c| crate::names_check(c.name, target).is_some())
68                || lines
69                    .iter()
70                    .filter(|l| l.is_check())
71                    .any(|l| crate::names_check(&l.id(), target).is_some())
72        };
73        for line in lines {
74            let Line::Policy { what, lineno } = line else {
75                continue;
76            };
77            let target = match what {
78                PolicyLine::Severity { target, .. } | PolicyLine::Skip { target } => target,
79                PolicyLine::Set { key, value } => {
80                    // Allowlisted at parse; later lines overwrite earlier
81                    // ones, the same rule config itself applies.
82                    policy.settings.insert(key.clone(), value.clone());
83                    continue;
84                }
85            };
86            if !names_something(target) {
87                let kind = match what {
88                    PolicyLine::Severity { .. } => "severity",
89                    PolicyLine::Skip { .. } => "skip",
90                    // Set lines took the `continue` above; unreachable here.
91                    PolicyLine::Set { .. } => unreachable!("set lines have no target"),
92                };
93                notes.push(format!(
94                    "{}:{lineno}: {kind} {target:?} names no check here",
95                    crate::manifest::MANIFEST
96                ));
97                continue;
98            }
99            match what {
100                PolicyLine::Severity { target, severity } => {
101                    policy.severities.push((target.clone(), *severity));
102                }
103                PolicyLine::Skip { target } => policy.skips.push(target.clone()),
104                PolicyLine::Set { .. } => unreachable!("set lines have no target"),
105            }
106        }
107        (policy, notes)
108    }
109}
110
111static POLICY: std::sync::OnceLock<Policy> = std::sync::OnceLock::new();
112
113/// Install the loaded repository's policy for this process. Idempotent —
114/// first install wins, the `override_file_set` precedent — and called by
115/// every entrypoint immediately after `manifest::load`, BEFORE any config
116/// read. That ordering is the whole contract: `check_timeout` and friends
117/// cache on first read.
118pub fn install(policy: Policy) {
119    let _ = POLICY.set(policy);
120}
121
122/// The installed policy, or an empty one — a process that never loaded a
123/// manifest has no policy, which resolves every rule to today's behaviour.
124pub fn current() -> &'static Policy {
125    static EMPTY: Policy = Policy {
126        severities: Vec::new(),
127        skips: Vec::new(),
128        settings: std::collections::BTreeMap::new(),
129    };
130    POLICY.get().unwrap_or(&EMPTY)
131}
132
133/// The union `hook.skip` resolution sees: machine skips plus policy skips.
134/// A union and not a ladder — nothing anywhere can UN-skip, so there is no
135/// conflict for ordering to settle.
136pub fn union_skips(config_skips: Vec<String>, policy: &Policy) -> Vec<String> {
137    let mut all = config_skips;
138    for s in &policy.skips {
139        if !all.contains(s) {
140            all.push(s.clone());
141        }
142    }
143    all
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::manifest::parse_lines;
150
151    #[test]
152    fn collects_severities_and_skips_in_file_order() {
153        let lines = parse_lines(
154            "severity clippy warn\nskip yamllint\nseverity pre-push-cargo-test block\n",
155        );
156        let (p, notes) = Policy::from_lines(&lines);
157        assert!(notes.is_empty(), "{notes:?}");
158        assert_eq!(
159            p.severities,
160            vec![
161                ("clippy".to_string(), Severity::Warn),
162                ("pre-push-cargo-test".to_string(), Severity::Block),
163            ]
164        );
165        assert_eq!(p.skips, vec!["yamllint".to_string()]);
166    }
167
168    /// A target can be a declared check — including one written BELOW the
169    /// policy line, which is why validation is a whole-file pass.
170    #[test]
171    fn a_declared_check_below_the_policy_line_still_validates() {
172        let lines =
173            parse_lines("severity smoke warn\npre-commit    smoke   *   block   ./smoke.sh\n");
174        let (p, notes) = Policy::from_lines(&lines);
175        assert!(notes.is_empty(), "{notes:?}");
176        assert_eq!(p.severities.len(), 1);
177    }
178
179    /// A tool pin must not lend its name to the validation universe.
180    #[test]
181    fn a_tool_pin_does_not_validate_a_policy_target() {
182        let lines = parse_lines("tool ruffian 0.4\nseverity ruffian warn\n");
183        let (p, notes) = Policy::from_lines(&lines);
184        assert!(p.severities.is_empty());
185        assert_eq!(notes.len(), 1, "{notes:?}");
186        assert!(notes[0].contains("names no check here"), "{notes:?}");
187        assert!(notes[0].contains("amont.conf:2"), "{notes:?}");
188    }
189
190    /// A typo is a note with a position, never a phantom check.
191    #[test]
192    fn an_unmatched_target_is_a_note_not_a_check() {
193        let lines = parse_lines("severity clipy warn\n");
194        let (p, notes) = Policy::from_lines(&lines);
195        assert!(p.is_empty());
196        assert_eq!(
197            notes,
198            vec!["amont.conf:1: severity \"clipy\" names no check here"]
199        );
200    }
201
202    /// Triggers and short names resolve exactly as `hook.skip` resolves them.
203    #[test]
204    fn triggers_and_short_names_are_valid_targets() {
205        let lines = parse_lines("skip pre-commit\nseverity ban-terms warn\n");
206        let (p, notes) = Policy::from_lines(&lines);
207        assert!(notes.is_empty(), "{notes:?}");
208        assert_eq!(p.skips, vec!["pre-commit".to_string()]);
209        assert_eq!(p.severities.len(), 1);
210    }
211
212    #[test]
213    fn union_adds_policy_skips_without_duplicating() {
214        let p = Policy {
215            severities: Vec::new(),
216            skips: vec!["yamllint".into(), "clippy".into()],
217            settings: std::collections::BTreeMap::new(),
218        };
219        let got = union_skips(vec!["clippy".into()], &p);
220        assert_eq!(got, vec!["clippy".to_string(), "yamllint".to_string()]);
221    }
222}