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