Skip to main content

amont_runtime/
check.rs

1//! What a check IS, as one value rather than four tables.
2//!
3//! Before this, a check was spread across `REGISTRY` (name → fn), two ordered
4//! name lists, and a language table in the fleet crate — four places keyed by
5//! the same string, held together by reconciliation tests. Those tests were
6//! good, but they policed a shape that should not have been splittable. With
7//! the metadata attached to the check, there is nothing left to reconcile.
8//!
9//! It also gives external checks somewhere to exist. A third party cannot add a
10//! Rust module without rebuilding the binary, so extension means a declared
11//! command implementing this same trait — and the dispatcher not caring which
12//! kind it is holding.
13
14use crate::registry::Ctx;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Stage {
18    PreCommit,
19    PrePush,
20}
21
22impl Stage {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Stage::PreCommit => "pre-commit",
26            Stage::PrePush => "pre-push",
27        }
28    }
29}
30
31/// When a check is relevant, declared rather than reimplemented by every
32/// reader.
33///
34/// A CONJUNCTION, not a choice: ruff is `.py` files AND a ruff config; clippy
35/// is `.rs` AND `Cargo.toml`. An earlier design offered these as alternatives
36/// plus a `Custom` escape hatch, which would have swallowed nearly every check
37/// and left the dashboard knowing nothing.
38/// A git operation that is part-way through.
39///
40/// Detected from the marker files git writes into `$GIT_DIR`, which is how git
41/// itself and every prompt-writer answers the question.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum GitState {
44    Merge,
45    Rebase,
46    CherryPick,
47    Revert,
48    Bisect,
49}
50
51impl GitState {
52    /// The marker git writes. `rebase-merge` and `rebase-apply` are
53    /// DIRECTORIES; the rest are files, and `Path::exists` covers both.
54    pub fn markers(self) -> &'static [&'static str] {
55        match self {
56            GitState::Merge => &["MERGE_HEAD"],
57            GitState::Rebase => &["REBASE_HEAD", "rebase-merge", "rebase-apply"],
58            GitState::CherryPick => &["CHERRY_PICK_HEAD"],
59            GitState::Revert => &["REVERT_HEAD"],
60            GitState::Bisect => &["BISECT_LOG"],
61        }
62    }
63
64    pub fn as_str(self) -> &'static str {
65        match self {
66            GitState::Merge => "a merge",
67            GitState::Rebase => "a rebase",
68            GitState::CherryPick => "a cherry-pick",
69            GitState::Revert => "a revert",
70            GitState::Bisect => "a bisect",
71        }
72    }
73
74    pub const ALL: [GitState; 5] = [
75        GitState::Merge,
76        GitState::Rebase,
77        GitState::CherryPick,
78        GitState::Revert,
79        GitState::Bisect,
80    ];
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct Scope {
85    /// Extensions that trigger it. Empty means any change.
86    pub files: &'static [&'static str],
87    /// Exact FILENAMES that trigger it — `package.json`, `Dockerfile` —
88    /// matched against the path's basename, never as a suffix: an extension
89    /// list cannot say "package.json" without also matching
90    /// `not-package.json`.
91    ///
92    /// The manifest's scope column fills this, and so does
93    /// `pre-commit-hadolint`, the first builtin to need it: a Dockerfile has
94    /// no extension to gate on.
95    pub names: &'static [&'static str],
96    /// Config paths that opt a repository in. Empty means always on.
97    pub opt_in: &'static [&'static str],
98    /// Git operations during which this check does not run.
99    ///
100    /// The other half of "when does this apply". `files` and `opt_in` say which
101    /// REPOSITORIES and which CHANGES; this says which repository STATES — a
102    /// question that used to be answered by one hard-coded `CHERRY_PICK_HEAD`
103    /// test in one dispatcher, with the other carrying a comment admitting it
104    /// had none because the shell version had none.
105    pub not_during: &'static [GitState],
106}
107
108impl Scope {
109    pub const ALWAYS: Scope = Scope {
110        files: &[],
111        names: &[],
112        opt_in: &[],
113        not_during: &[],
114    };
115
116    pub const fn files(files: &'static [&'static str]) -> Scope {
117        Scope {
118            files,
119            names: &[],
120            opt_in: &[],
121            not_during: &[],
122        }
123    }
124
125    /// Gated on exact basenames rather than extensions — what a `Dockerfile`
126    /// needs, having none.
127    pub const fn named(names: &'static [&'static str]) -> Scope {
128        Scope {
129            files: &[],
130            names,
131            opt_in: &[],
132            not_during: &[],
133        }
134    }
135
136    pub const fn new(files: &'static [&'static str], opt_in: &'static [&'static str]) -> Scope {
137        Scope {
138            files,
139            names: &[],
140            opt_in,
141            not_during: &[],
142        }
143    }
144
145    /// The same scope, silent during these operations.
146    pub const fn not_during(self, states: &'static [GitState]) -> Scope {
147        Scope {
148            files: self.files,
149            names: self.names,
150            opt_in: self.opt_in,
151            not_during: states,
152        }
153    }
154
155    /// No file gate at all — every change is in scope.
156    pub fn is_unscoped(&self) -> bool {
157        self.files.is_empty() && self.names.is_empty()
158    }
159
160    /// Does ONE path fall inside the file gate?
161    pub fn covers(&self, path: &str) -> bool {
162        self.files.iter().any(|ext| path.ends_with(ext)) || {
163            let base = path.rsplit('/').next().unwrap_or(path);
164            self.names.contains(&base)
165        }
166    }
167
168    /// Does this gate have work to do, given the files a PUSH changed?
169    ///
170    /// Distinct from [`matches`], and the distinction is the bug this was
171    /// written for. `matches` answers "would this check ever fire in a
172    /// repository containing these paths", so it also consults `opt_in` —
173    /// the marker that says a repository is a Rust one at all. Feeding a
174    /// push DIFF to that question conflates two things: whether the
175    /// repository is Rust (settled long before, when the dispatcher decided
176    /// this check runs here) and whether this push changed any Rust.
177    ///
178    /// `pre-push-cargo-test` opts in on `Cargo.toml`. A `.rs`-only push, in
179    /// a repository with a `Cargo.toml` sitting right there, therefore
180    /// answered NO to "is this a Rust repository" — because that one file
181    /// was not in the diff — and was judged to have nothing worth attesting.
182    /// Which is to say: the most ordinary Rust push there is.
183    ///
184    /// Opt-in is a fact about the repository. This asks only about the
185    /// change, which is what a caller holding a diff means.
186    pub fn touches(&self, paths: &[String]) -> bool {
187        self.is_unscoped() || paths.iter().any(|p| self.covers(p))
188    }
189
190    /// Did the gate see EVERY one of `paths`? All-match, where [`matches`]
191    /// is any-match: the caller asking this is deciding whether a commit-time
192    /// run COVERED a push, and under-approximating is the safe direction.
193    pub fn covers_all(&self, paths: &[String]) -> bool {
194        self.is_unscoped() || paths.iter().all(|p| self.covers(p))
195    }
196
197    /// Would this check ever fire, given the paths a repository contains?
198    ///
199    /// Deliberately coarse for checks that resolve an ancestor at run time —
200    /// `cargo-fmt` declares `Cargo.toml` meaning "somewhere here" while
201    /// enforcing "nearest above the staged file". The dispatcher asks the
202    /// precise question by running the check; this answers the dashboard's
203    /// question, "would it ever fire", where over-approximating is the safe
204    /// direction.
205    pub fn matches(&self, paths: &[String]) -> bool {
206        self.touches(paths) && self.opted_in(paths)
207    }
208
209    /// Does this repository carry the marker that turns the check on?
210    ///
211    /// Split out of [`matches`] because the two halves ask about DIFFERENT
212    /// path lists, and conflating them is a bug this codebase has now made
213    /// twice. `touches` asks about the CHANGE — the staged set, or the push
214    /// diff. This asks about the REPOSITORY, and the only honest source for
215    /// that is the index (`git ls-files`).
216    ///
217    /// Feed a change to this question and it can only answer no unless that
218    /// change happened to touch the marker: a `+pom.xml` row would run when
219    /// you edited `pom.xml` and never when you edited only `.java`, which is
220    /// the ordinary case and the entire point of the gate. See `attestable`
221    /// in `dispatch.rs` for the first occurrence, in the attestation path.
222    pub fn opted_in(&self, paths: &[String]) -> bool {
223        self.opt_in.is_empty()
224            || paths.iter().any(|p| {
225                let name = p.rsplit('/').next().unwrap_or(p);
226                self.opt_in.iter().any(|c| {
227                    // A trailing `*` is a prefix match: `.kube-linter*.yaml`.
228                    match c.split_once('*') {
229                        Some((pre, suf)) => name.starts_with(pre) && name.ends_with(suf),
230                        None => name == *c,
231                    }
232                })
233            })
234    }
235}
236
237/// What a check meant, as opposed to what it printed.
238///
239/// The fourth variant is the point. Fifteen sites used to warn and return 0,
240/// collapsing two different situations: "I ran and found something you should
241/// know" and "I could not run at all". `ruff config found but no ruff binary`
242/// was indistinguishable from ruff running clean — to the dispatcher, and to
243/// the dashboard. A repository where a check has silently never executed read
244/// as one where it passes.
245///
246/// That is the same invisibility `hook.skip` had before skipped checks were
247/// announced, and it took three PRs to notice there.
248///
249/// The check still prints its own message; this only classifies the result.
250///
251/// Deliberately NO `Default`. It used to be `Failed`, to fill the slot of a
252/// check whose thread died — a real rule, but `Default` means "the neutral
253/// value" to every reader and to every `#[derive(Default)]` that might later
254/// contain one. The rule is now written where it applies, in the runner.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum Outcome {
257    Passed,
258    /// Ran, found a problem. Whether that blocks is `Severity`, not this.
259    Failed,
260    /// Ran, found something worth saying, which does not block.
261    Warned,
262    /// Ran, found a problem, and REPAIRED it. The commit proceeds with the
263    /// repair staged, which is neither `Passed` (something happened, and the
264    /// author should know their files changed) nor `Failed`.
265    Fixed,
266    /// COULD NOT RUN — a tool is missing, or the opt-in config is absent.
267    Unavailable,
268}
269
270/// What a HOOK concluded — the only thing git actually reads.
271///
272/// Distinct from `Outcome`, which is what one CHECK concluded. Git has exactly
273/// two questions to ask a hook, so this has exactly two answers, and the `i32`
274/// that expresses them lives at the process boundary rather than being threaded
275/// through every hook, dispatcher and handler as it used to be.
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum Verdict {
278    Proceed,
279    Block,
280}
281
282impl Verdict {
283    /// The exit code git reads. The ONLY place a hook result becomes a number.
284    pub fn exit_code(self) -> i32 {
285        match self {
286            Verdict::Proceed => 0,
287            Verdict::Block => 1,
288        }
289    }
290
291    pub fn blocking(blocked: bool) -> Verdict {
292        if blocked {
293            Verdict::Block
294        } else {
295            Verdict::Proceed
296        }
297    }
298}
299
300/// Whether a failing check stops the commit or merely reports.
301///
302/// Declared per check and overridable per repository with
303/// `git config amont.severity.<check> warn`. That is a better escape hatch
304/// than `hook.skip`, which is all-or-nothing and invisible enough that
305/// `hook.skip = e` disables all twenty checks: a downgrade keeps the signal and
306/// removes only the block.
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum Severity {
309    Block,
310    Warn,
311}
312
313impl Severity {
314    /// The ONE mapping from configured text to severity.
315    ///
316    /// There were four: this key's reader, the manifest's severity column, the
317    /// dashboard's copy, and the dashboard's reverse mapping for `--json`. They
318    /// agreed, but nothing made them — and the dashboard's copy is its
319    /// prediction of what the dispatcher will do, which is the one thing it must
320    /// never get wrong.
321    ///
322    /// `None` for anything else, deliberately: git validates nothing here, so an
323    /// unrecognised value must fall back to the declared severity rather than
324    /// silently disable a check.
325    pub fn parse(value: &str) -> Option<Severity> {
326        match value {
327            "warn" => Some(Severity::Warn),
328            "block" => Some(Severity::Block),
329            _ => None,
330        }
331    }
332
333    /// How it is written in config and in `--json`.
334    pub fn as_str(self) -> &'static str {
335        match self {
336            Severity::Block => "block",
337            Severity::Warn => "warn",
338        }
339    }
340}
341
342/// Whether a check can rewrite the files it inspects.
343///
344/// Off by default and per check, never global: a hook that edits your files
345/// without being asked is a larger surprise than one that complains.
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum Fix {
348    /// Reports only. Every check, until somebody declares otherwise.
349    None,
350    /// Runs a command that rewrites files, and stages what it changed.
351    ///
352    /// Only reachable from a `Stage::PreCommit` declaration. A pre-push hook
353    /// must not modify the worktree or index: the pushed commit would then
354    /// differ from the tree the developer is looking at.
355    Rewrite,
356}
357
358impl Fix {
359    /// How it is written in `--json`. No `parse()`: nothing reads a `Fix`
360    /// back out of text — the manifest's `fix` marker has its own, unrelated
361    /// parsing path in `manifest.rs`.
362    pub fn as_str(self) -> &'static str {
363        match self {
364            Fix::None => "none",
365            Fix::Rewrite => "rewrite",
366        }
367    }
368}
369
370/// One check, whether compiled in or declared by the repository.
371///
372/// `Sync` because `pre-commit` hands every check to its own thread. Both
373/// implementations satisfy it for free, and requiring it here is what lets the
374/// dispatcher hold `&'static dyn Check` without caring which kind it has.
375pub trait Check: Sync {
376    fn name(&self) -> &str;
377    fn stage(&self) -> Stage;
378    fn scope(&self) -> Scope;
379    /// The name a commit-time declaration must use to pair with this gate.
380    ///
381    /// Deliberately the SHORT name, and deliberately not the id: pairing is
382    /// cross-stage by definition — `test` declared at `pre-commit` pairs
383    /// with `test` at `pre-push` — so the id, which encodes the stage, is
384    /// the one key that cannot work. `pre-push-cargo-test` would be compared
385    /// against a `GateDecl.script` of `cargo-test` and never match, and the
386    /// failure would be silence rather than an error: the gate simply never
387    /// pairs and nobody is told why.
388    ///
389    /// This is the single place where comparing short names is correct,
390    /// which is why it is a named method rather than a `short_name()` call
391    /// at the comparison site — that function's own documentation says never
392    /// to compare against it, and it is right everywhere else.
393    fn pairing_name(&self) -> &str {
394        crate::short_name(self.name())
395    }
396    fn severity(&self) -> Severity;
397    /// Whether this check can repair what it finds. `None` for almost all.
398    fn fix(&self) -> Fix {
399        Fix::None
400    }
401    /// How far the check reaches when `amont.conventions` is `declared` —
402    /// see [`Reach`]. Externals default to `Convention`, which costs them
403    /// nothing: a declared check only exists where an `amont.conf` does,
404    /// and that is exactly the declaration the mode asks for.
405    fn reach(&self) -> Reach {
406        Reach::Convention
407    }
408    fn run(&self, ctx: &Ctx) -> Outcome;
409}
410
411/// How far a check reaches into repositories that never asked for it.
412///
413/// With `git config --global amont.conventions declared`, hooks installed by
414/// a standing grant (`init.templateDir`) split in two: `Safety` checks run in
415/// EVERY repository, because their findings are mistakes in any codebase — a
416/// conflict marker, a leaked credential, a hundred-megabyte blob, a
417/// `debugger;` left in the diff. `Convention` checks run only where the
418/// repository has committed an `amont.conf` — they are one team's house
419/// rules (commit shapes, branch names, lint severities, test gates), and a
420/// clone of somebody else's project did not agree to them.
421///
422/// The default mode is `everywhere`, where this distinction is inert.
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424pub enum Reach {
425    /// A mistake anywhere: runs regardless of declaration.
426    Safety,
427    /// A house rule: runs only where the repository declares amont.
428    Convention,
429}
430
431/// A check compiled into the binary.
432pub struct Builtin {
433    pub name: &'static str,
434    pub stage: Stage,
435    pub scope: Scope,
436    pub severity: Severity,
437    pub run: fn(&Ctx) -> Outcome,
438    /// Almost always `Fix::None`; see `CHECKS`.
439    pub fix: Fix,
440    /// Almost always `Convention`; the exceptions are pinned in the registry.
441    pub reach: Reach,
442}
443
444impl Check for Builtin {
445    fn name(&self) -> &str {
446        self.name
447    }
448    fn stage(&self) -> Stage {
449        self.stage
450    }
451    fn scope(&self) -> Scope {
452        self.scope
453    }
454    fn severity(&self) -> Severity {
455        self.severity
456    }
457    fn fix(&self) -> Fix {
458        self.fix
459    }
460    fn reach(&self) -> Reach {
461        self.reach
462    }
463    fn run(&self, ctx: &Ctx) -> Outcome {
464        (self.run)(ctx)
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    /// Round-trips, and refuses everything else. A `Some` for an unknown value
473    /// would turn a typo into a silent disable.
474    #[test]
475    fn severity_parses_exactly_the_two_words_it_documents() {
476        for s in [Severity::Block, Severity::Warn] {
477            assert_eq!(Severity::parse(s.as_str()), Some(s));
478        }
479        for bad in ["", "Warn", "WARN", "advisory", "true", "1", " warn"] {
480            assert_eq!(Severity::parse(bad), None, "{bad:?} must not parse");
481        }
482    }
483
484    #[test]
485    fn fix_says_how_it_is_written_in_json() {
486        assert_eq!(Fix::None.as_str(), "none");
487        assert_eq!(Fix::Rewrite.as_str(), "rewrite");
488    }
489
490    #[test]
491    fn always_matches_anything() {
492        assert!(Scope::ALWAYS.matches(&[]));
493        assert!(Scope::ALWAYS.matches(&["README.md".into()]));
494    }
495
496    #[test]
497    fn extensions_gate_on_the_file_type() {
498        let s = Scope::files(&[".rs"]);
499        assert!(s.matches(&["src/main.rs".into()]));
500        assert!(!s.matches(&["README.md".into()]));
501    }
502
503    /// The case the enum could not express: BOTH conditions must hold.
504    #[test]
505    fn files_and_opt_in_are_a_conjunction() {
506        let ruff = Scope::new(&[".py"], &["ruff.toml", "pyproject.toml"]);
507        assert!(
508            !ruff.matches(&["a.py".into()]),
509            "python alone is not enough — the repo must opt in"
510        );
511        assert!(
512            !ruff.matches(&["pyproject.toml".into()]),
513            "and a config alone is not enough without python"
514        );
515        assert!(ruff.matches(&["a.py".into(), "pyproject.toml".into()]));
516    }
517
518    /// `.kube-linter*.yaml` is a real config name in this repo's own hooks.
519    #[test]
520    fn a_trailing_star_is_a_prefix_match() {
521        let s = Scope::new(&[".yaml"], &[".kube-linter*.yaml"]);
522        assert!(s.matches(&["k8s/x.yaml".into(), ".kube-linter-prod.yaml".into()]));
523        assert!(!s.matches(&["k8s/x.yaml".into(), ".kube-lint.yaml".into()]));
524    }
525
526    /// Opt-in matches a BASENAME anywhere, which is what makes the coarse
527    /// answer right for a check that resolves an ancestor when it runs.
528    #[test]
529    fn opt_in_matches_a_nested_manifest() {
530        let cargo = Scope::new(&[".rs"], &["Cargo.toml"]);
531        assert!(cargo.matches(&["crates/a/src/lib.rs".into(), "crates/a/Cargo.toml".into()]));
532    }
533}