Skip to main content

amont_runtime/
registry.rs

1//! The hook registry — one table, one signature.
2//!
3//! Before this, dispatch was a 20-arm `match` in main.rs and handlers had four
4//! different signatures (`run(&args)`, `run(&hook, &args)`, `argo_lint(&args)`,
5//! …). Two costs, one of them real:
6//!
7//!   - adding a hook meant touching a match arm, a module, and remembering
8//!     which signature that one used;
9//!   - the hook NAME was written twice — in the arm and as the shim's filename
10//!     — with nothing checking they agree. A shim the binary does not recognise
11//!     exits 2 and blocks the commit; a handler with no shim is dead code. The
12//!     consistency test below turns that pairing into something enforced.
13
14use std::ffi::OsString;
15use std::path::Path;
16
17use crate::check::{Builtin, Check, Fix, GitState, Outcome, Scope, Severity, Stage, Verdict};
18use crate::pushrefs::PushRefs;
19use crate::{dispatch, hooks};
20
21/// Everything a hook is given. One shape for all of them, so a handler that
22/// needs the invoked name (ban-terms excludes its own source by it) or the
23/// hooks directory (the dispatchers glob it) does not need its own signature.
24pub struct Ctx<'a> {
25    /// The hook name as invoked.
26    pub name: &'a str,
27    /// Arguments git passed the hook.
28    pub args: &'a [OsString],
29    /// Directory the shim lives in. Only foreign sub-hooks are found here now;
30    /// our own checks are functions in this binary.
31    pub hooks_dir: &'a Path,
32    /// The pre-push ref list, read from stdin at most once and lent to every
33    /// check that asks. See `pushrefs`.
34    pub push: &'a PushRefs,
35    /// What this repository's manifest declares — parsed and trust-gated once
36    /// by the entrypoint, lent to everything downstream. The same shape as
37    /// `push`: read once, owned high, borrowed everywhere.
38    pub manifest: &'a crate::manifest::Manifest,
39}
40
41pub type HookFn = fn(&Ctx) -> Verdict;
42
43/// name → handler. The single place a hook is registered.
44/// The five hook names git itself invokes. Everything else is a `Check`.
45pub const ENTRYPOINTS: &[(&str, HookFn)] = &[
46    ("pre-commit", dispatch::pre_commit),
47    ("pre-push", dispatch::pre_push),
48    ("commit-msg", |ctx| hooks::commit_msg::run(ctx.args)),
49    ("prepare-commit-msg", |ctx| {
50        hooks::prepare_commit_msg::run(ctx.args)
51    }),
52    ("post-commit", |ctx| hooks::post_commit::run(ctx)),
53];
54
55/// Every check, in the order its stage runs them.
56///
57/// ONE declaration each: name, stage, scope and function together. This
58/// replaced `REGISTRY` plus `PRE_COMMIT_CHECKS` plus `PRE_PUSH_CHECKS` plus the
59/// fleet crate's `LANGUAGES` — four tables keyed by the same string, kept in
60/// step by reconciliation tests that are now unnecessary rather than passing.
61///
62/// pre-push order is the cost order: refuse a forbidden push before validating
63/// a name, and validate everything structural before paying for a test suite.
64/// Operations during which a content check cannot say anything useful: half the
65/// tree is somebody else's work, and you cannot fix it from inside the
66/// operation anyway.
67///
68/// NOT applied to `merge-conflict` or `ban-terms`. Those are exactly the checks
69/// you want during a resolution commit — leaving a conflict marker in the
70/// commit that RESOLVES a merge is the bug, and importing a banned term from
71/// the other branch is the other one. The old behaviour skipped the whole
72/// pre-commit stage during a cherry-pick, which silenced both.
73const MID_OPERATION: &[GitState] = &[
74    GitState::Merge,
75    GitState::Rebase,
76    GitState::CherryPick,
77    GitState::Revert,
78];
79
80pub const CHECKS: &[Builtin] = &[
81    // ---- pre-commit ----
82    Builtin {
83        name: "pre-commit-argo-lint",
84        stage: Stage::PreCommit,
85        scope: Scope::new(
86            hooks::k8s::EXTS,
87            &["kustomization.yaml", "kustomization.yml"],
88        )
89        .not_during(MID_OPERATION),
90        severity: Severity::Block,
91        fix: Fix::None,
92        run: |ctx| hooks::k8s::argo_lint(ctx.args),
93    },
94    Builtin {
95        name: "pre-commit-ban-terms",
96        stage: Stage::PreCommit,
97        scope: Scope::files(hooks::ban_terms::EXTS),
98        severity: Severity::Block,
99        fix: Fix::None,
100        run: |ctx| hooks::ban_terms::run(ctx.name, ctx.args),
101    },
102    // The push-time contract, said at the first commit — when renaming the
103    // branch costs one command and zero rework. Same short name as the
104    // pre-push check on purpose: `hook.skip branch-pattern` silences the
105    // rule, not one of its two voices.
106    Builtin {
107        name: "pre-commit-branch-pattern",
108        stage: Stage::PreCommit,
109        scope: Scope::ALWAYS,
110        severity: Severity::Warn,
111        fix: Fix::None,
112        run: |_ctx| hooks::branch_pattern::early(),
113    },
114    Builtin {
115        name: "pre-commit-cargo-fmt",
116        stage: Stage::PreCommit,
117        scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
118        severity: Severity::Block,
119        fix: Fix::Rewrite,
120        run: |ctx| hooks::rust_tools::fmt(ctx.args),
121    },
122    Builtin {
123        name: "pre-commit-clippy",
124        stage: Stage::PreCommit,
125        scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
126        severity: Severity::Block,
127        fix: Fix::None,
128        run: |ctx| hooks::rust_tools::clippy(ctx.args),
129    },
130    Builtin {
131        name: "pre-commit-go-vet",
132        stage: Stage::PreCommit,
133        scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
134        severity: Severity::Block,
135        fix: Fix::None,
136        run: |ctx| hooks::go_tools::vet(ctx.args),
137    },
138    Builtin {
139        name: "pre-commit-gofmt",
140        stage: Stage::PreCommit,
141        scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
142        severity: Severity::Block,
143        fix: Fix::Rewrite,
144        run: |ctx| hooks::go_tools::fmt(ctx.args),
145    },
146    Builtin {
147        name: "pre-commit-kube-linter",
148        stage: Stage::PreCommit,
149        scope: Scope::new(
150            hooks::k8s::EXTS,
151            &[".kube-linter*.yaml", ".kube-linter*.yml"],
152        )
153        .not_during(MID_OPERATION),
154        severity: Severity::Block,
155        fix: Fix::None,
156        run: |ctx| hooks::k8s::kube_linter(ctx.args),
157    },
158    Builtin {
159        name: "pre-commit-kubeconform",
160        stage: Stage::PreCommit,
161        scope: Scope::new(
162            hooks::k8s::EXTS,
163            &["kustomization.yaml", "kustomization.yml"],
164        )
165        .not_during(MID_OPERATION),
166        severity: Severity::Block,
167        fix: Fix::None,
168        run: |ctx| hooks::k8s::kubeconform(ctx.args),
169    },
170    Builtin {
171        name: "pre-commit-large-files",
172        stage: Stage::PreCommit,
173        scope: Scope::ALWAYS,
174        severity: Severity::Block,
175        fix: Fix::None,
176        run: |_ctx| hooks::large_files::staged(),
177    },
178    Builtin {
179        name: "pre-commit-lint-js",
180        stage: Stage::PreCommit,
181        scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
182        severity: Severity::Block,
183        fix: Fix::None,
184        run: |ctx| hooks::lint_js::run(ctx.args),
185    },
186    Builtin {
187        name: "pre-commit-lint-json-yaml",
188        stage: Stage::PreCommit,
189        scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
190        severity: Severity::Block,
191        fix: Fix::None,
192        run: |ctx| hooks::lint_json_yaml::run(ctx.args),
193    },
194    Builtin {
195        name: "pre-commit-merge-conflict",
196        stage: Stage::PreCommit,
197        scope: Scope::ALWAYS,
198        severity: Severity::Block,
199        fix: Fix::None,
200        run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
201    },
202    Builtin {
203        name: "pre-commit-package-lock",
204        stage: Stage::PreCommit,
205        scope: Scope::new(&[], &["package.json"]),
206        severity: Severity::Block,
207        fix: Fix::None,
208        run: |ctx| hooks::package_lock::run(ctx.args),
209    },
210    Builtin {
211        name: "pre-commit-prettier",
212        stage: Stage::PreCommit,
213        scope: Scope::new(
214            &[],
215            &[
216                ".prettierrc",
217                ".prettierrc.json",
218                ".prettierrc.yml",
219                ".prettierrc.yaml",
220                ".prettierrc.js",
221                "prettier.config.js",
222            ],
223        )
224        .not_during(MID_OPERATION),
225        severity: Severity::Block,
226        fix: Fix::Rewrite,
227        run: |ctx| hooks::prettier::run(ctx.args),
228    },
229    Builtin {
230        name: "pre-commit-pyright",
231        stage: Stage::PreCommit,
232        scope: Scope::new(
233            hooks::python_tools::EXTS,
234            &[
235                "pyrightconfig.json",
236                "pyrightconfig.jsonc",
237                "pyproject.toml",
238            ],
239        )
240        .not_during(MID_OPERATION),
241        severity: Severity::Block,
242        fix: Fix::None,
243        run: |ctx| hooks::python_tools::pyright(ctx.args),
244    },
245    Builtin {
246        name: "pre-commit-ruff",
247        stage: Stage::PreCommit,
248        scope: Scope::new(
249            hooks::python_tools::EXTS,
250            &["ruff.toml", ".ruff.toml", "pyproject.toml"],
251        )
252        .not_during(MID_OPERATION),
253        severity: Severity::Block,
254        fix: Fix::Rewrite,
255        run: |ctx| hooks::python_tools::ruff(ctx.args),
256    },
257    // A staged credential is a ten-second fix; a pushed one is an
258    // incident. Both halves of that sentence are checks — see
259    // `hooks::secrets` for why the push half exists at all.
260    Builtin {
261        name: "pre-commit-secrets",
262        stage: Stage::PreCommit,
263        scope: Scope::ALWAYS,
264        severity: Severity::Block,
265        fix: Fix::None,
266        run: |_ctx| hooks::secrets::staged(),
267    },
268    Builtin {
269        name: "pre-commit-usual-name",
270        stage: Stage::PreCommit,
271        scope: Scope::ALWAYS,
272        severity: Severity::Block,
273        fix: Fix::None,
274        run: |ctx| hooks::usual_name::run(ctx.args),
275    },
276    Builtin {
277        name: "pre-commit-yamllint",
278        stage: Stage::PreCommit,
279        scope: Scope::new(
280            hooks::yamllint::EXTS,
281            &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
282        )
283        .not_during(MID_OPERATION),
284        severity: Severity::Block,
285        fix: Fix::None,
286        run: |ctx| hooks::yamllint::run(ctx.args),
287    },
288    // ---- pre-push, cheapest and most decisive first ----
289    Builtin {
290        name: "pre-push-branch-protect",
291        stage: Stage::PrePush,
292        scope: Scope::ALWAYS,
293        severity: Severity::Block,
294        fix: Fix::None,
295        run: |ctx| hooks::branch_protect::run(ctx.push.get()),
296    },
297    Builtin {
298        name: "pre-push-branch-pattern",
299        stage: Stage::PrePush,
300        scope: Scope::ALWAYS,
301        severity: Severity::Block,
302        fix: Fix::None,
303        run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
304    },
305    Builtin {
306        name: "pre-push-secrets",
307        stage: Stage::PrePush,
308        scope: Scope::ALWAYS,
309        severity: Severity::Block,
310        fix: Fix::None,
311        run: |ctx| hooks::secrets::pushed(ctx.push.get()),
312    },
313    Builtin {
314        name: "pre-push-pull-rebase",
315        stage: Stage::PrePush,
316        scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
317        severity: Severity::Block,
318        fix: Fix::None,
319        run: |ctx| hooks::pull_rebase::run(ctx.args),
320    },
321    // The four dependency audits sit between the structural checks and the
322    // test suites: network-bound but seconds, where a suite is minutes —
323    // and when a v* tag is in the push, failing here saves the suite's
324    // whole cost. Each opts in by the LOCKFILE its tool audits: an audit
325    // without a resolved tree audits a guess. On a branch push they only
326    // warn; the blocking case is the release tag — see `hooks::audit`.
327    Builtin {
328        name: "pre-push-audit-go",
329        stage: Stage::PrePush,
330        scope: Scope::new(&[], &["go.sum"]),
331        severity: Severity::Block,
332        fix: Fix::None,
333        run: |ctx| hooks::audit::go(ctx.push.get()),
334    },
335    Builtin {
336        name: "pre-push-audit-js",
337        stage: Stage::PrePush,
338        scope: Scope::new(&[], &["package-lock.json"]),
339        severity: Severity::Block,
340        fix: Fix::None,
341        run: |ctx| hooks::audit::js(ctx.push.get()),
342    },
343    Builtin {
344        name: "pre-push-audit-python",
345        stage: Stage::PrePush,
346        scope: Scope::new(&[], &["requirements.txt"]),
347        severity: Severity::Block,
348        fix: Fix::None,
349        run: |ctx| hooks::audit::python(ctx.push.get()),
350    },
351    Builtin {
352        name: "pre-push-audit-rust",
353        stage: Stage::PrePush,
354        scope: Scope::new(&[], &["Cargo.lock"]),
355        severity: Severity::Block,
356        fix: Fix::None,
357        run: |ctx| hooks::audit::rust(ctx.push.get()),
358    },
359    Builtin {
360        name: "pre-push-run-tests-js",
361        stage: Stage::PrePush,
362        scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
363            .not_during(&[GitState::Bisect, GitState::Rebase]),
364        severity: Severity::Block,
365        fix: Fix::None,
366        run: |ctx| hooks::run_tests::run(ctx.push.get(), &ctx.manifest.externals),
367    },
368    Builtin {
369        name: "pre-push-cargo-test",
370        stage: Stage::PrePush,
371        scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
372            .not_during(&[GitState::Bisect, GitState::Rebase]),
373        severity: Severity::Block,
374        fix: Fix::None,
375        run: |ctx| hooks::rust_tools::test(ctx.push.get()),
376    },
377    Builtin {
378        name: "pre-push-go-test",
379        stage: Stage::PrePush,
380        scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
381            .not_during(&[GitState::Bisect, GitState::Rebase]),
382        severity: Severity::Block,
383        fix: Fix::None,
384        run: |ctx| hooks::go_tools::test(ctx.push.get()),
385    },
386    Builtin {
387        name: "pre-push-pytest",
388        stage: Stage::PrePush,
389        scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
390            .not_during(&[GitState::Bisect, GitState::Rebase]),
391        severity: Severity::Block,
392        fix: Fix::None,
393        run: |ctx| hooks::python_tools::pytest(ctx.push.get()),
394    },
395];
396
397/// A check's severity, after any per-repository override.
398///
399/// `git config amont.severity.<check> warn` downgrades a blocking check to a
400/// warning. Unlike `hook.skip` it keeps the signal: the check still runs and
401/// still reports, it just stops failing the commit.
402pub fn severity_of(check: &dyn Check) -> Severity {
403    effective_override(None, check.name()).unwrap_or_else(|| check.severity())
404}
405
406/// The config key a severity override lives under.
407pub fn severity_key(check: &str) -> String {
408    format!("amont.severity.{check}")
409}
410
411/// Every severity override visible here, resolved the way `--get` resolves it.
412///
413/// ONE subprocess for the whole stage, and — more importantly — a VALUE. The
414/// dispatcher used to call `severity_of` inside the loop that classifies
415/// outcomes, which put a `git` spawn in the middle of a fold and made the fold
416/// impossible to test without a repository.
417///
418/// `--get-regexp` emits entries in precedence order, so folding with overwrite
419/// lands on the same answer `--get` gives. That equivalence is not assumed:
420/// `the_batch_agrees_with_the_authority` pins it against `effective_override`.
421#[derive(Debug, Default, Clone)]
422pub struct Overrides(std::collections::BTreeMap<String, Severity>);
423
424impl Overrides {
425    pub fn read() -> Overrides {
426        Overrides::from_config(crate::git::stdout(&[
427            "config",
428            "--get-regexp",
429            r"^amont\.severity\.",
430        ]))
431    }
432
433    /// Built from `--get-regexp`-shaped text (`amont.severity.<check>
434    /// <value>` per line, in git's own precedence order — system, then
435    /// global, then local, then includes). `pub` so a reader that has
436    /// already fetched those lines for its own reasons (the fleet dashboard
437    /// needs the origin of each, which `Overrides` does not track) can still
438    /// ask this — the one place that must not get precedence wrong — rather
439    /// than re-deriving it from the lines by hand.
440    pub fn from_config(out: Option<String>) -> Overrides {
441        let mut map = std::collections::BTreeMap::new();
442        for line in out.as_deref().unwrap_or_default().lines() {
443            let Some((key, value)) = line.split_once(' ') else {
444                continue;
445            };
446            let Some(check) = key.strip_prefix("amont.severity.") else {
447                continue;
448            };
449            // Later entries overwrite earlier ones — git's own precedence,
450            // observed rather than reimplemented.
451            match Severity::parse(value.trim()) {
452                Some(s) => {
453                    map.insert(check.to_string(), s);
454                }
455                // An unrecognised value is not an override at all, and must not
456                // shadow a valid earlier one either.
457                None => {
458                    map.remove(check);
459                }
460            }
461        }
462        Overrides(map)
463    }
464
465    /// The configured key that applies to `check`, and what it says.
466    ///
467    /// Several keys can name one check — `pre-commit` and `clippy` and
468    /// `pre-commit-clippy` all reach `pre-commit-clippy`. The most specific
469    /// wins, which is the rule anybody would guess and the only one that lets
470    /// you downgrade a whole trigger and then exempt one check from it.
471    pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
472        self.0
473            .iter()
474            .filter_map(|(pattern, severity)| {
475                crate::names_check(check, pattern).map(|m| (m, pattern.as_str(), *severity))
476            })
477            .max_by_key(|(m, _, _)| *m)
478            .map(|(_, pattern, severity)| (pattern, severity))
479    }
480
481    /// The severity to apply to `check`, override or declared.
482    pub fn of(&self, check: &dyn Check) -> Severity {
483        self.applied_to(check.name())
484            .map(|(_, severity)| severity)
485            .unwrap_or_else(|| check.severity())
486    }
487}
488
489#[cfg(test)]
490mod precedence {
491    use super::{Overrides, Severity};
492
493    fn overrides(lines: &[&str]) -> Overrides {
494        let text = lines
495            .iter()
496            .map(|l| format!("amont.severity.{l}\n"))
497            .collect::<String>();
498        Overrides::from_config(Some(text))
499    }
500
501    /// The whole reason triggers and short names are allowed as keys: downgrade
502    /// a trigger wholesale, then say something different about one check. If the
503    /// broader key won instead, the exemption would be unwritable.
504    #[test]
505    fn the_more_specific_key_wins() {
506        let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
507        assert_eq!(
508            both.applied_to("pre-commit-clippy"),
509            Some(("pre-commit-clippy", Severity::Block)),
510            "a full id beats its trigger"
511        );
512        assert_eq!(
513            both.applied_to("pre-commit-shellcheck"),
514            Some(("pre-commit", Severity::Warn)),
515            "and the trigger still governs every check it did not exempt"
516        );
517    }
518
519    /// Full id > short name > trigger, all three at once, so the ordering is
520    /// pinned end to end rather than one pair at a time.
521    #[test]
522    fn the_three_ways_to_name_a_check_are_ranked() {
523        let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
524        assert_eq!(
525            all.applied_to("pre-commit-clippy"),
526            Some(("pre-commit-clippy", Severity::Warn))
527        );
528
529        let no_full = overrides(&["pre-commit warn", "clippy block"]);
530        assert_eq!(
531            no_full.applied_to("pre-commit-clippy"),
532            Some(("clippy", Severity::Block)),
533            "a short name beats a trigger"
534        );
535    }
536
537    /// A key naming nothing must not become the answer by being the only one
538    /// there — that is how a repo believes it downgraded a check it never named.
539    #[test]
540    fn a_key_that_names_no_check_applies_to_nothing() {
541        let typo = overrides(&["clipy warn", "e warn", " warn"]);
542        assert_eq!(typo.applied_to("pre-commit-clippy"), None);
543    }
544}
545
546/// The override git would actually apply for `check`, or `None` if there is
547/// none (or the value is not one this understands).
548///
549/// `--get`, NOT `--get-regexp`: git returns the LAST value, so a local `block`
550/// beats a global `warn`. A reader that listed every entry instead and treated
551/// each as authoritative would report a downgrade that the dispatcher does not
552/// apply — which is exactly what the dashboard used to do.
553///
554/// `repo` is `None` for the current directory, which is where a hook runs.
555pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
556    overrides_in(repo).applied_to(check).map(|(_, s)| s)
557}
558
559/// Which configured KEY applies to `check` here, if any.
560///
561/// The dashboard needs the key rather than the value: with three ways to name a
562/// check, "which of these lines is the one doing something" is the question a
563/// reader actually has.
564pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
565    overrides_in(repo)
566        .applied_to(check)
567        .map(|(pattern, _)| pattern.to_string())
568}
569
570fn overrides_in(repo: Option<&Path>) -> Overrides {
571    let args = ["config", "--get-regexp", r"^amont\.severity\."];
572    Overrides::from_config(match repo {
573        None => crate::git::stdout(&args),
574        Some(dir) => crate::git::stdout_in(dir, &args),
575    })
576}
577
578/// Built-in checks for one stage, in declared order.
579pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
580    CHECKS.iter().filter(move |check| check.stage == stage)
581}
582
583/// Every check for one stage — built-ins first, then whatever this repository
584/// declares in `amont.conf`.
585///
586/// The order is not negotiable and not configurable. A third-party command must
587/// not be able to delay `pre-push-branch-protect`, and appending is the only
588/// arrangement in which it cannot.
589pub fn all_stage_checks<'a>(
590    stage: Stage,
591    manifest: &'a crate::manifest::Manifest,
592) -> Vec<&'a dyn Check> {
593    let mut out: Vec<&'a dyn Check> = stage_checks(stage)
594        .map(|check| check as &dyn Check)
595        .collect();
596    out.extend(
597        manifest
598            .externals
599            .iter()
600            .filter(|external| external.stage == stage)
601            .map(|external| external as &dyn Check),
602    );
603    out
604}
605
606pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
607    if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
608        return Some(*f);
609    }
610    // A check invoked directly by name — how the tests drive individual checks,
611    // and how `amont <check>` works from a shell. Its Outcome collapses to an
612    // exit code here, honouring severity, so a `warn` check invoked directly
613    // reports without failing exactly as it does inside a dispatcher. The
614    // closure re-resolves through the Ctx it is handed — a plain fn pointer
615    // captures nothing, and the manifest travels ON the Ctx.
616    if CHECKS.iter().any(|check| check.name == name)
617        || manifest
618            .externals
619            .iter()
620            .any(|external| external.id == name)
621    {
622        return Some(|ctx: &Ctx| {
623            let check = one_named(ctx.name, ctx.manifest).expect("checked above");
624            Verdict::blocking(matches!(
625                (check.run(ctx), severity_of(check)),
626                (Outcome::Failed, Severity::Block)
627            ))
628        });
629    }
630    None
631}
632
633/// One check by name, whichever kind it is. Built-ins are searched first, which
634/// costs nothing because `manifest::parse` refuses a name a built-in already
635/// holds — the two guards together mean neither kind can shadow the other.
636pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
637    if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
638        return Some(builtin);
639    }
640    manifest
641        .externals
642        .iter()
643        .find(|external| external.id == name)
644        .map(|external| external as &dyn Check)
645}
646
647#[cfg(test)]
648mod tests {
649    use super::{lookup, Overrides, Severity, Stage, CHECKS, ENTRYPOINTS};
650    use std::collections::BTreeSet;
651
652    /// The batch reader must land on the same answer as the authority.
653    ///
654    /// `Overrides` folds `--get-regexp` output with overwrite; `effective_override`
655    /// asks `--get`. They agree only because git emits entries in precedence
656    /// order — an assumption, so it is asserted against real git rather than
657    /// trusted.
658    #[test]
659    fn the_batch_agrees_with_the_authority() {
660        let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
661        let _ = std::fs::remove_dir_all(&d);
662        std::fs::create_dir_all(&d).unwrap();
663        let git = |args: &[&str]| {
664            std::process::Command::new("git")
665                .args(args)
666                .current_dir(&d)
667                .output()
668                .expect("git");
669        };
670        git(&["init", "-q", "--template=", "."]);
671        let key = "amont.severity.pre-commit-merge-conflict";
672        git(&["config", "--add", key, "warn"]);
673        git(&["config", "--add", key, "block"]);
674
675        let raw = std::process::Command::new("git")
676            .args(["config", "--get-regexp", r"^amont\.severity\."])
677            .current_dir(&d)
678            .output()
679            .expect("git");
680        let batch = Overrides::from_config(Some(
681            String::from_utf8_lossy(&raw.stdout).trim().to_string(),
682        ));
683        let authority =
684            crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
685        let _ = std::fs::remove_dir_all(&d);
686
687        assert_eq!(
688            authority,
689            Some(Severity::Block),
690            "git applies the last entry"
691        );
692        assert_eq!(
693            batch.0.get("pre-commit-merge-conflict").copied(),
694            authority,
695            "the batch reader disagreed with `--get`"
696        );
697    }
698
699    /// An unrecognised value is not an override, and must not shadow a valid
700    /// earlier one either — a typo would otherwise silently restore the
701    /// declared severity in a way nobody could see.
702    #[test]
703    fn an_unrecognised_value_clears_rather_than_overrides() {
704        let o = Overrides::from_config(Some(
705            "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
706        ));
707        assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
708        assert_eq!(o.0.get("b").copied(), Some(Severity::Warn));
709    }
710
711    #[test]
712    fn names_are_unique_across_entrypoints_and_checks() {
713        let mut seen = BTreeSet::new();
714        for n in ENTRYPOINTS
715            .iter()
716            .map(|(n, _)| *n)
717            .chain(CHECKS.iter().map(|check| check.name))
718        {
719            assert!(seen.insert(n), "duplicate registration: {n}");
720        }
721    }
722
723    /// Only FIVE files ship, and they are exactly the hook names git invokes.
724    #[test]
725    fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
726        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
727        let mut shipped: Vec<String> = std::fs::read_dir(dir)
728            .expect("templates/hooks")
729            .flatten()
730            .map(|entry| entry.file_name().to_string_lossy().into_owned())
731            .collect();
732        shipped.sort();
733        assert_eq!(
734            shipped,
735            vec![
736                "commit-msg",
737                "post-commit",
738                "pre-commit",
739                "pre-push",
740                "prepare-commit-msg"
741            ]
742        );
743        let none = crate::manifest::Manifest::default();
744        for name in &shipped {
745            assert!(
746                lookup(name, &none).is_some(),
747                "shipped shim {name:?} has no handler"
748            );
749        }
750    }
751
752    /// Every check is reachable by name, which is how a shell — and the tests —
753    /// invoke one directly.
754    #[test]
755    fn every_check_is_reachable_by_name() {
756        let none = crate::manifest::Manifest::default();
757        for check in CHECKS {
758            assert!(
759                lookup(check.name, &none).is_some(),
760                "{} not reachable",
761                check.name
762            );
763        }
764        assert!(lookup("pre-commit-not-a-check", &none).is_none());
765    }
766
767    /// pre-push is serial and fail-fast, so declaration order IS cost order.
768    #[test]
769    fn pre_push_runs_cheapest_first() {
770        let order: Vec<&str> = super::stage_checks(Stage::PrePush)
771            .map(|check| check.name)
772            .collect();
773        assert_eq!(
774            order,
775            vec![
776                "pre-push-branch-protect",
777                "pre-push-branch-pattern",
778                "pre-push-secrets",
779                "pre-push-pull-rebase",
780                "pre-push-audit-go",
781                "pre-push-audit-js",
782                "pre-push-audit-python",
783                "pre-push-audit-rust",
784                "pre-push-run-tests-js",
785                "pre-push-cargo-test",
786                "pre-push-go-test",
787                "pre-push-pytest",
788            ]
789        );
790    }
791
792    /// What a check actually looks at, as opposed to what it DECLARES.
793    ///
794    /// `All` is a check that reads every staged path regardless of the
795    /// extensions in its scope (ban-terms greps them all); `Exts(e)` is a check
796    /// that filters by suffix, and `e` is the list it filters WITH.
797    enum Consumes {
798        All,
799        Exts(&'static [&'static str]),
800    }
801
802    /// Every check, and the file set it consumes. The table exists so a NEW
803    /// check cannot be added without somebody stating the answer.
804    ///
805    /// The incident: `pre-commit-lint-json-yaml` declared
806    /// `[".json", ".yaml", ".yml"]` in the registry while `lint_json_yaml::run`
807    /// asked `staged_files` for `[".yaml"]`. `amont list` reported the check
808    /// as covering `.yml`, the fleet dashboard agreed, and a staged, broken
809    /// `x.yml` returned `Outcome::Passed` with no output whatsoever. Nothing
810    /// connected the two lists, so nothing could notice.
811    ///
812    /// Each entry now names the module constant the check itself filters with,
813    /// which is the same constant the registry declares its scope from — so
814    /// this table cannot silently agree with a stale copy.
815    const CONSUMED: &[(&str, Consumes)] = &[
816        (
817            "pre-commit-argo-lint",
818            Consumes::Exts(crate::hooks::k8s::EXTS),
819        ),
820        // Each term filters candidates against its own language's extensions,
821        // and `EXTS` is pinned to be exactly their union — so this names the
822        // module constant the check itself filters with.
823        (
824            "pre-commit-ban-terms",
825            Consumes::Exts(crate::hooks::ban_terms::EXTS),
826        ),
827        (
828            "pre-commit-cargo-fmt",
829            Consumes::Exts(crate::hooks::rust_tools::EXTS),
830        ),
831        (
832            "pre-commit-clippy",
833            Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
834        ),
835        (
836            "pre-commit-go-vet",
837            Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
838        ),
839        (
840            "pre-commit-gofmt",
841            Consumes::Exts(crate::hooks::go_tools::EXTS),
842        ),
843        (
844            "pre-commit-kube-linter",
845            Consumes::Exts(crate::hooks::k8s::EXTS),
846        ),
847        (
848            "pre-commit-kubeconform",
849            Consumes::Exts(crate::hooks::k8s::EXTS),
850        ),
851        (
852            "pre-commit-lint-js",
853            Consumes::Exts(crate::hooks::lint_js::EXTS),
854        ),
855        (
856            "pre-commit-lint-json-yaml",
857            Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
858        ),
859        ("pre-commit-merge-conflict", Consumes::All),
860        ("pre-commit-package-lock", Consumes::All),
861        // Declares `files: &[]` — it is opt-in by CONFIG, not by file type —
862        // while consuming seventeen extensions. The other reason the assertion
863        // is a subset check rather than an equality.
864        (
865            "pre-commit-prettier",
866            Consumes::Exts(crate::hooks::prettier::EXTS),
867        ),
868        (
869            "pre-commit-pyright",
870            Consumes::Exts(crate::hooks::python_tools::EXTS),
871        ),
872        (
873            "pre-commit-ruff",
874            Consumes::Exts(crate::hooks::python_tools::EXTS),
875        ),
876        ("pre-commit-usual-name", Consumes::All),
877        (
878            "pre-commit-yamllint",
879            Consumes::Exts(crate::hooks::yamllint::EXTS),
880        ),
881        ("pre-commit-large-files", Consumes::All),
882        ("pre-commit-secrets", Consumes::All),
883        ("pre-push-secrets", Consumes::All),
884        ("pre-push-branch-protect", Consumes::All),
885        ("pre-push-branch-pattern", Consumes::All),
886        ("pre-push-pull-rebase", Consumes::All),
887        (
888            "pre-push-run-tests-js",
889            Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
890        ),
891        (
892            "pre-push-pytest",
893            Consumes::Exts(crate::hooks::python_tools::EXTS),
894        ),
895        (
896            "pre-push-cargo-test",
897            Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
898        ),
899        (
900            "pre-push-go-test",
901            Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
902        ),
903    ];
904
905    /// A declared scope must never promise more than the check consumes.
906    ///
907    /// Two halves, and the first is the one that earns its keep: a check with a
908    /// non-empty `scope.files` that nobody has entered in `CONSUMED` fails the
909    /// build, so adding a check forces somebody to state what it actually
910    /// reads. The second half then pins that `scope.files ⊆ consumed`.
911    ///
912    /// SUBSET, not equality, deliberately: `prettier` declares `files: &[]` —
913    /// it is opt-in by CONFIG, not by file type — while consuming seventeen
914    /// extensions. Equality would forbid it.
915    #[test]
916    fn no_check_declares_a_file_type_it_does_not_consume() {
917        for (name, _) in CONSUMED {
918            assert!(
919                CHECKS.iter().any(|check| check.name == *name),
920                "CONSUMED names {name:?}, which is not a check"
921            );
922        }
923        for check in CHECKS {
924            let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
925            if check.scope.files.is_empty() && entry.is_none() {
926                continue;
927            }
928            let Some((_, consumes)) = entry else {
929                panic!(
930                    "{} declares scope.files {:?} but is missing from CONSUMED — \
931                     say what it actually reads",
932                    check.name, check.scope.files
933                );
934            };
935            let Consumes::Exts(consumed) = consumes else {
936                continue; // `All` consumes everything, so any declaration fits
937            };
938            for ext in check.scope.files {
939                assert!(
940                    consumed.contains(ext),
941                    "{} declares {ext:?} in its scope but never asks for it — \
942                     `amont list` would report a coverage the check does not have",
943                    check.name
944                );
945            }
946        }
947    }
948
949    /// Which checks contain code that repairs and re-stages, stated by hand.
950    ///
951    /// `pre-commit-cargo-fmt` and `pre-commit-ruff` both declared
952    /// `Fix::Rewrite` with NO fixing code anywhere — only `prettier.rs` and
953    /// `manifest.rs` ever called `restage`/`fixing_enabled`. `amont list
954    /// --json` reported `"fix":"rewrite"` for them regardless, and `agents_md`
955    /// explicitly directs agents to trust that JSON, so an agent would set
956    /// `amont.fix true` and wait for a repair that could never arrive.
957    const HAS_FIXING_CODE: &[(&str, bool)] = &[
958        ("pre-commit-argo-lint", false),
959        ("pre-commit-ban-terms", false),
960        ("pre-commit-branch-pattern", false),
961        ("pre-commit-cargo-fmt", true),
962        ("pre-commit-clippy", false),
963        ("pre-commit-go-vet", false),
964        ("pre-commit-gofmt", true),
965        ("pre-commit-kube-linter", false),
966        ("pre-commit-kubeconform", false),
967        ("pre-commit-lint-js", false),
968        ("pre-commit-lint-json-yaml", false),
969        ("pre-commit-merge-conflict", false),
970        ("pre-commit-package-lock", false),
971        ("pre-commit-prettier", true),
972        ("pre-commit-pyright", false),
973        ("pre-commit-ruff", true),
974        ("pre-commit-usual-name", false),
975        ("pre-commit-yamllint", false),
976        ("pre-commit-large-files", false),
977        ("pre-commit-secrets", false),
978        ("pre-push-secrets", false),
979        ("pre-push-branch-protect", false),
980        ("pre-push-branch-pattern", false),
981        ("pre-push-pull-rebase", false),
982        ("pre-push-audit-go", false),
983        ("pre-push-audit-js", false),
984        ("pre-push-audit-python", false),
985        ("pre-push-audit-rust", false),
986        ("pre-push-run-tests-js", false),
987        ("pre-push-cargo-test", false),
988        ("pre-push-go-test", false),
989        ("pre-push-pytest", false),
990    ];
991
992    /// A `Fix::Rewrite` declaration is a PROMISE, and the set of checks that
993    /// keep it must equal the set that make it.
994    #[test]
995    fn every_rewrite_declaration_has_a_fixer() {
996        let declared: BTreeSet<&str> = CHECKS
997            .iter()
998            .filter(|check| check.fix == super::Fix::Rewrite)
999            .map(|check| check.name)
1000            .collect();
1001        let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1002            .iter()
1003            .filter(|(_, has)| *has)
1004            .map(|(name, _)| *name)
1005            .collect();
1006        assert_eq!(
1007            declared, implemented,
1008            "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1009             and a check with a fixer that does not declare it can never be reached"
1010        );
1011
1012        // …and the table must cover every check, so a new one cannot be added
1013        // without somebody answering the question.
1014        let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1015        let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1016        assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1017    }
1018
1019    /// The reconciliation tests that used to live here are gone, and that is
1020    /// the point of the refactor: there is no second table to disagree with.
1021    #[test]
1022    fn every_check_declares_a_stage_and_a_scope() {
1023        assert_eq!(CHECKS.len(), 32);
1024        let pre_commit = super::stage_checks(Stage::PreCommit).count();
1025        let pre_push = super::stage_checks(Stage::PrePush).count();
1026        assert_eq!(
1027            pre_commit + pre_push,
1028            CHECKS.len(),
1029            "every check has a stage"
1030        );
1031    }
1032}