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