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