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