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