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