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>);
471
472impl Overrides {
473    pub fn read() -> Overrides {
474        Overrides::from_config(crate::git::stdout(&[
475            "config",
476            "--get-regexp",
477            r"^amont\.severity\.",
478        ]))
479    }
480
481    /// Built from `--get-regexp`-shaped text (`amont.severity.<check>
482    /// <value>` per line, in git's own precedence order — system, then
483    /// global, then local, then includes). `pub` so a reader that has
484    /// already fetched those lines for its own reasons (the fleet dashboard
485    /// needs the origin of each, which `Overrides` does not track) can still
486    /// ask this — the one place that must not get precedence wrong — rather
487    /// than re-deriving it from the lines by hand.
488    pub fn from_config(out: Option<String>) -> Overrides {
489        let mut map = std::collections::BTreeMap::new();
490        for line in out.as_deref().unwrap_or_default().lines() {
491            let Some((key, value)) = line.split_once(' ') else {
492                continue;
493            };
494            let Some(check) = key.strip_prefix("amont.severity.") else {
495                continue;
496            };
497            // Later entries overwrite earlier ones — git's own precedence,
498            // observed rather than reimplemented.
499            match Severity::parse(value.trim()) {
500                Some(s) => {
501                    map.insert(check.to_string(), s);
502                }
503                // An unrecognised value is not an override at all, and must not
504                // shadow a valid earlier one either.
505                None => {
506                    map.remove(check);
507                }
508            }
509        }
510        Overrides(map)
511    }
512
513    /// The configured key that applies to `check`, and what it says.
514    ///
515    /// Several keys can name one check — `pre-commit` and `clippy` and
516    /// `pre-commit-clippy` all reach `pre-commit-clippy`. The most specific
517    /// wins, which is the rule anybody would guess and the only one that lets
518    /// you downgrade a whole trigger and then exempt one check from it.
519    pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
520        self.0
521            .iter()
522            .filter_map(|(pattern, severity)| {
523                crate::names_check(check, pattern).map(|m| (m, pattern.as_str(), *severity))
524            })
525            .max_by_key(|(m, _, _)| *m)
526            .map(|(_, pattern, severity)| (pattern, severity))
527    }
528
529    /// The severity to apply to `check`, override or declared.
530    pub fn of(&self, check: &dyn Check) -> Severity {
531        self.applied_to(check.name())
532            .map(|(_, severity)| severity)
533            .unwrap_or_else(|| check.severity())
534    }
535}
536
537#[cfg(test)]
538mod precedence {
539    use super::{Overrides, Severity};
540
541    fn overrides(lines: &[&str]) -> Overrides {
542        let text = lines
543            .iter()
544            .map(|l| format!("amont.severity.{l}\n"))
545            .collect::<String>();
546        Overrides::from_config(Some(text))
547    }
548
549    /// The whole reason triggers and short names are allowed as keys: downgrade
550    /// a trigger wholesale, then say something different about one check. If the
551    /// broader key won instead, the exemption would be unwritable.
552    #[test]
553    fn the_more_specific_key_wins() {
554        let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
555        assert_eq!(
556            both.applied_to("pre-commit-clippy"),
557            Some(("pre-commit-clippy", Severity::Block)),
558            "a full id beats its trigger"
559        );
560        assert_eq!(
561            both.applied_to("pre-commit-shellcheck"),
562            Some(("pre-commit", Severity::Warn)),
563            "and the trigger still governs every check it did not exempt"
564        );
565    }
566
567    /// Full id > short name > trigger, all three at once, so the ordering is
568    /// pinned end to end rather than one pair at a time.
569    #[test]
570    fn the_three_ways_to_name_a_check_are_ranked() {
571        let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
572        assert_eq!(
573            all.applied_to("pre-commit-clippy"),
574            Some(("pre-commit-clippy", Severity::Warn))
575        );
576
577        let no_full = overrides(&["pre-commit warn", "clippy block"]);
578        assert_eq!(
579            no_full.applied_to("pre-commit-clippy"),
580            Some(("clippy", Severity::Block)),
581            "a short name beats a trigger"
582        );
583    }
584
585    /// A key naming nothing must not become the answer by being the only one
586    /// there — that is how a repo believes it downgraded a check it never named.
587    #[test]
588    fn a_key_that_names_no_check_applies_to_nothing() {
589        let typo = overrides(&["clipy warn", "e warn", " warn"]);
590        assert_eq!(typo.applied_to("pre-commit-clippy"), None);
591    }
592}
593
594/// The override git would actually apply for `check`, or `None` if there is
595/// none (or the value is not one this understands).
596///
597/// `--get`, NOT `--get-regexp`: git returns the LAST value, so a local `block`
598/// beats a global `warn`. A reader that listed every entry instead and treated
599/// each as authoritative would report a downgrade that the dispatcher does not
600/// apply — which is exactly what the dashboard used to do.
601///
602/// `repo` is `None` for the current directory, which is where a hook runs.
603pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
604    overrides_in(repo).applied_to(check).map(|(_, s)| s)
605}
606
607/// Which configured KEY applies to `check` here, if any.
608///
609/// The dashboard needs the key rather than the value: with three ways to name a
610/// check, "which of these lines is the one doing something" is the question a
611/// reader actually has.
612pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
613    overrides_in(repo)
614        .applied_to(check)
615        .map(|(pattern, _)| pattern.to_string())
616}
617
618fn overrides_in(repo: Option<&Path>) -> Overrides {
619    let args = ["config", "--get-regexp", r"^amont\.severity\."];
620    Overrides::from_config(match repo {
621        None => crate::git::stdout(&args),
622        Some(dir) => crate::git::stdout_in(dir, &args),
623    })
624}
625
626/// Built-in checks for one stage, in declared order.
627pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
628    CHECKS.iter().filter(move |check| check.stage == stage)
629}
630
631/// Every check for one stage — built-ins first, then whatever this repository
632/// declares in `amont.conf`.
633///
634/// The order is not negotiable and not configurable. A third-party command must
635/// not be able to delay `pre-push-branch-protect`, and appending is the only
636/// arrangement in which it cannot.
637pub fn all_stage_checks<'a>(
638    stage: Stage,
639    manifest: &'a crate::manifest::Manifest,
640) -> Vec<&'a dyn Check> {
641    let mut out: Vec<&'a dyn Check> = stage_checks(stage)
642        .map(|check| check as &dyn Check)
643        .collect();
644    out.extend(
645        manifest
646            .externals
647            .iter()
648            .filter(|external| external.stage == stage)
649            .map(|external| external as &dyn Check),
650    );
651    out
652}
653
654pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
655    if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
656        return Some(*f);
657    }
658    // A check invoked directly by name — how the tests drive individual checks,
659    // and how `amont <check>` works from a shell. Its Outcome collapses to an
660    // exit code here, honouring severity, so a `warn` check invoked directly
661    // reports without failing exactly as it does inside a dispatcher. The
662    // closure re-resolves through the Ctx it is handed — a plain fn pointer
663    // captures nothing, and the manifest travels ON the Ctx.
664    if CHECKS.iter().any(|check| check.name == name)
665        || manifest
666            .externals
667            .iter()
668            .any(|external| external.id == name)
669    {
670        return Some(|ctx: &Ctx| {
671            let check = one_named(ctx.name, ctx.manifest).expect("checked above");
672            Verdict::blocking(matches!(
673                (check.run(ctx), severity_of(check)),
674                (Outcome::Failed, Severity::Block)
675            ))
676        });
677    }
678    None
679}
680
681/// One check by name, whichever kind it is. Built-ins are searched first, which
682/// costs nothing because `manifest::parse` refuses a name a built-in already
683/// holds — the two guards together mean neither kind can shadow the other.
684pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
685    if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
686        return Some(builtin);
687    }
688    manifest
689        .externals
690        .iter()
691        .find(|external| external.id == name)
692        .map(|external| external as &dyn Check)
693}
694
695#[cfg(test)]
696mod tests {
697    use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
698    use std::collections::BTreeSet;
699
700    /// The batch reader must land on the same answer as the authority.
701    ///
702    /// `Overrides` folds `--get-regexp` output with overwrite; `effective_override`
703    /// asks `--get`. They agree only because git emits entries in precedence
704    /// order — an assumption, so it is asserted against real git rather than
705    /// trusted.
706    #[test]
707    fn the_batch_agrees_with_the_authority() {
708        let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
709        let _ = std::fs::remove_dir_all(&d);
710        std::fs::create_dir_all(&d).unwrap();
711        let git = |args: &[&str]| {
712            std::process::Command::new("git")
713                .args(args)
714                .current_dir(&d)
715                .output()
716                .expect("git");
717        };
718        git(&["init", "-q", "--template=", "."]);
719        let key = "amont.severity.pre-commit-merge-conflict";
720        git(&["config", "--add", key, "warn"]);
721        git(&["config", "--add", key, "block"]);
722
723        let raw = std::process::Command::new("git")
724            .args(["config", "--get-regexp", r"^amont\.severity\."])
725            .current_dir(&d)
726            .output()
727            .expect("git");
728        let batch = Overrides::from_config(Some(
729            String::from_utf8_lossy(&raw.stdout).trim().to_string(),
730        ));
731        let authority =
732            crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
733        let _ = std::fs::remove_dir_all(&d);
734
735        assert_eq!(
736            authority,
737            Some(Severity::Block),
738            "git applies the last entry"
739        );
740        assert_eq!(
741            batch.0.get("pre-commit-merge-conflict").copied(),
742            authority,
743            "the batch reader disagreed with `--get`"
744        );
745    }
746
747    /// An unrecognised value is not an override, and must not shadow a valid
748    /// earlier one either — a typo would otherwise silently restore the
749    /// declared severity in a way nobody could see.
750    #[test]
751    fn an_unrecognised_value_clears_rather_than_overrides() {
752        let o = Overrides::from_config(Some(
753            "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
754        ));
755        assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
756        assert_eq!(o.0.get("b").copied(), Some(Severity::Warn));
757    }
758
759    #[test]
760    fn names_are_unique_across_entrypoints_and_checks() {
761        let mut seen = BTreeSet::new();
762        for n in ENTRYPOINTS
763            .iter()
764            .map(|(n, _)| *n)
765            .chain(CHECKS.iter().map(|check| check.name))
766        {
767            assert!(seen.insert(n), "duplicate registration: {n}");
768        }
769    }
770
771    /// Only FIVE files ship, and they are exactly the hook names git invokes.
772    #[test]
773    fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
774        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
775        let mut shipped: Vec<String> = std::fs::read_dir(dir)
776            .expect("templates/hooks")
777            .flatten()
778            .map(|entry| entry.file_name().to_string_lossy().into_owned())
779            .collect();
780        shipped.sort();
781        assert_eq!(
782            shipped,
783            vec![
784                "commit-msg",
785                "post-commit",
786                "pre-commit",
787                "pre-push",
788                "prepare-commit-msg"
789            ]
790        );
791        let none = crate::manifest::Manifest::default();
792        for name in &shipped {
793            assert!(
794                lookup(name, &none).is_some(),
795                "shipped shim {name:?} has no handler"
796            );
797        }
798    }
799
800    /// Every check is reachable by name, which is how a shell — and the tests —
801    /// invoke one directly.
802    #[test]
803    fn every_check_is_reachable_by_name() {
804        let none = crate::manifest::Manifest::default();
805        for check in CHECKS {
806            assert!(
807                lookup(check.name, &none).is_some(),
808                "{} not reachable",
809                check.name
810            );
811        }
812        assert!(lookup("pre-commit-not-a-check", &none).is_none());
813    }
814
815    /// pre-push is serial and fail-fast, so declaration order IS cost order.
816    #[test]
817    fn pre_push_runs_cheapest_first() {
818        let order: Vec<&str> = super::stage_checks(Stage::PrePush)
819            .map(|check| check.name)
820            .collect();
821        assert_eq!(
822            order,
823            vec![
824                "pre-push-branch-protect",
825                "pre-push-branch-pattern",
826                "pre-push-secrets",
827                "pre-push-pull-rebase",
828                "pre-push-audit-go",
829                "pre-push-audit-js",
830                "pre-push-audit-python",
831                "pre-push-audit-rust",
832                "pre-push-run-tests-js",
833                "pre-push-cargo-test",
834                "pre-push-go-test",
835                "pre-push-pytest",
836            ]
837        );
838    }
839
840    /// What a check actually looks at, as opposed to what it DECLARES.
841    ///
842    /// `All` is a check that reads every staged path regardless of the
843    /// extensions in its scope (ban-terms greps them all); `Exts(e)` is a check
844    /// that filters by suffix, and `e` is the list it filters WITH.
845    enum Consumes {
846        All,
847        Exts(&'static [&'static str]),
848    }
849
850    /// Every check, and the file set it consumes. The table exists so a NEW
851    /// check cannot be added without somebody stating the answer.
852    ///
853    /// The incident: `pre-commit-lint-json-yaml` declared
854    /// `[".json", ".yaml", ".yml"]` in the registry while `lint_json_yaml::run`
855    /// asked `staged_files` for `[".yaml"]`. `amont list` reported the check
856    /// as covering `.yml`, the fleet dashboard agreed, and a staged, broken
857    /// `x.yml` returned `Outcome::Passed` with no output whatsoever. Nothing
858    /// connected the two lists, so nothing could notice.
859    ///
860    /// Each entry now names the module constant the check itself filters with,
861    /// which is the same constant the registry declares its scope from — so
862    /// this table cannot silently agree with a stale copy.
863    const CONSUMED: &[(&str, Consumes)] = &[
864        (
865            "pre-commit-argo-lint",
866            Consumes::Exts(crate::hooks::k8s::EXTS),
867        ),
868        // Each term filters candidates against its own language's extensions,
869        // and `EXTS` is pinned to be exactly their union — so this names the
870        // module constant the check itself filters with.
871        (
872            "pre-commit-ban-terms",
873            Consumes::Exts(crate::hooks::ban_terms::EXTS),
874        ),
875        (
876            "pre-commit-cargo-fmt",
877            Consumes::Exts(crate::hooks::rust_tools::EXTS),
878        ),
879        (
880            "pre-commit-clippy",
881            Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
882        ),
883        (
884            "pre-commit-go-vet",
885            Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
886        ),
887        (
888            "pre-commit-gofmt",
889            Consumes::Exts(crate::hooks::go_tools::EXTS),
890        ),
891        (
892            "pre-commit-kube-linter",
893            Consumes::Exts(crate::hooks::k8s::EXTS),
894        ),
895        (
896            "pre-commit-kubeconform",
897            Consumes::Exts(crate::hooks::k8s::EXTS),
898        ),
899        (
900            "pre-commit-lint-js",
901            Consumes::Exts(crate::hooks::lint_js::EXTS),
902        ),
903        (
904            "pre-commit-lint-json-yaml",
905            Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
906        ),
907        ("pre-commit-merge-conflict", Consumes::All),
908        ("pre-commit-package-lock", Consumes::All),
909        // Declares `files: &[]` — it is opt-in by CONFIG, not by file type —
910        // while consuming seventeen extensions. The other reason the assertion
911        // is a subset check rather than an equality.
912        (
913            "pre-commit-prettier",
914            Consumes::Exts(crate::hooks::prettier::EXTS),
915        ),
916        (
917            "pre-commit-pyright",
918            Consumes::Exts(crate::hooks::python_tools::EXTS),
919        ),
920        (
921            "pre-commit-ruff",
922            Consumes::Exts(crate::hooks::python_tools::EXTS),
923        ),
924        ("pre-commit-usual-name", Consumes::All),
925        (
926            "pre-commit-yamllint",
927            Consumes::Exts(crate::hooks::yamllint::EXTS),
928        ),
929        ("pre-commit-large-files", Consumes::All),
930        ("pre-commit-secrets", Consumes::All),
931        ("pre-push-secrets", Consumes::All),
932        ("pre-push-branch-protect", Consumes::All),
933        ("pre-push-branch-pattern", Consumes::All),
934        ("pre-push-pull-rebase", Consumes::All),
935        (
936            "pre-push-run-tests-js",
937            Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
938        ),
939        (
940            "pre-push-pytest",
941            Consumes::Exts(crate::hooks::python_tools::EXTS),
942        ),
943        (
944            "pre-push-cargo-test",
945            Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
946        ),
947        (
948            "pre-push-go-test",
949            Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
950        ),
951    ];
952
953    /// A declared scope must never promise more than the check consumes.
954    ///
955    /// Two halves, and the first is the one that earns its keep: a check with a
956    /// non-empty `scope.files` that nobody has entered in `CONSUMED` fails the
957    /// build, so adding a check forces somebody to state what it actually
958    /// reads. The second half then pins that `scope.files ⊆ consumed`.
959    ///
960    /// SUBSET, not equality, deliberately: `prettier` declares `files: &[]` —
961    /// it is opt-in by CONFIG, not by file type — while consuming seventeen
962    /// extensions. Equality would forbid it.
963    #[test]
964    fn no_check_declares_a_file_type_it_does_not_consume() {
965        for (name, _) in CONSUMED {
966            assert!(
967                CHECKS.iter().any(|check| check.name == *name),
968                "CONSUMED names {name:?}, which is not a check"
969            );
970        }
971        for check in CHECKS {
972            let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
973            if check.scope.files.is_empty() && entry.is_none() {
974                continue;
975            }
976            let Some((_, consumes)) = entry else {
977                panic!(
978                    "{} declares scope.files {:?} but is missing from CONSUMED — \
979                     say what it actually reads",
980                    check.name, check.scope.files
981                );
982            };
983            let Consumes::Exts(consumed) = consumes else {
984                continue; // `All` consumes everything, so any declaration fits
985            };
986            for ext in check.scope.files {
987                assert!(
988                    consumed.contains(ext),
989                    "{} declares {ext:?} in its scope but never asks for it — \
990                     `amont list` would report a coverage the check does not have",
991                    check.name
992                );
993            }
994        }
995    }
996
997    /// Which checks contain code that repairs and re-stages, stated by hand.
998    ///
999    /// `pre-commit-cargo-fmt` and `pre-commit-ruff` both declared
1000    /// `Fix::Rewrite` with NO fixing code anywhere — only `prettier.rs` and
1001    /// `manifest.rs` ever called `restage`/`fixing_enabled`. `amont list
1002    /// --json` reported `"fix":"rewrite"` for them regardless, and `agents_md`
1003    /// explicitly directs agents to trust that JSON, so an agent would set
1004    /// `amont.fix true` and wait for a repair that could never arrive.
1005    const HAS_FIXING_CODE: &[(&str, bool)] = &[
1006        ("pre-commit-argo-lint", false),
1007        ("pre-commit-ban-terms", false),
1008        ("pre-commit-branch-pattern", false),
1009        ("pre-commit-cargo-fmt", true),
1010        ("pre-commit-clippy", false),
1011        ("pre-commit-go-vet", false),
1012        ("pre-commit-gofmt", true),
1013        ("pre-commit-kube-linter", false),
1014        ("pre-commit-kubeconform", false),
1015        ("pre-commit-lint-js", false),
1016        ("pre-commit-lint-json-yaml", false),
1017        ("pre-commit-merge-conflict", false),
1018        ("pre-commit-package-lock", false),
1019        ("pre-commit-prettier", true),
1020        ("pre-commit-pyright", false),
1021        ("pre-commit-ruff", true),
1022        ("pre-commit-usual-name", false),
1023        ("pre-commit-yamllint", false),
1024        ("pre-commit-large-files", false),
1025        ("pre-commit-secrets", false),
1026        ("pre-push-secrets", false),
1027        ("pre-push-branch-protect", false),
1028        ("pre-push-branch-pattern", false),
1029        ("pre-push-pull-rebase", false),
1030        ("pre-push-audit-go", false),
1031        ("pre-push-audit-js", false),
1032        ("pre-push-audit-python", false),
1033        ("pre-push-audit-rust", false),
1034        ("pre-push-run-tests-js", false),
1035        ("pre-push-cargo-test", false),
1036        ("pre-push-go-test", false),
1037        ("pre-push-pytest", false),
1038    ];
1039
1040    /// A `Fix::Rewrite` declaration is a PROMISE, and the set of checks that
1041    /// keep it must equal the set that make it.
1042    #[test]
1043    fn every_rewrite_declaration_has_a_fixer() {
1044        let declared: BTreeSet<&str> = CHECKS
1045            .iter()
1046            .filter(|check| check.fix == super::Fix::Rewrite)
1047            .map(|check| check.name)
1048            .collect();
1049        let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1050            .iter()
1051            .filter(|(_, has)| *has)
1052            .map(|(name, _)| *name)
1053            .collect();
1054        assert_eq!(
1055            declared, implemented,
1056            "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1057             and a check with a fixer that does not declare it can never be reached"
1058        );
1059
1060        // …and the table must cover every check, so a new one cannot be added
1061        // without somebody answering the question.
1062        let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1063        let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1064        assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1065    }
1066
1067    /// The reconciliation tests that used to live here are gone, and that is
1068    /// the point of the refactor: there is no second table to disagree with.
1069    /// The safety net is exactly the checks whose findings are mistakes in
1070    /// ANY codebase, with near-zero false positives — a conflict marker, an
1071    /// oversized blob, a leaked credential, a debug leftover in YOUR diff.
1072    /// Everything else is a house rule, and a house rule must not fire in a
1073    /// repository that never subscribed. Growing this list is a decision,
1074    /// not a default: lint-json-yaml stays OUT because Helm templates are
1075    /// invalid YAML and blocking a contribution to somebody else's chart
1076    /// repo is exactly the false positive this split exists to prevent.
1077    #[test]
1078    fn the_safety_net_is_exactly_the_low_false_positive_set() {
1079        let safety: Vec<&str> = CHECKS
1080            .iter()
1081            .filter(|c| c.reach == Reach::Safety)
1082            .map(|c| c.name)
1083            .collect();
1084        assert_eq!(
1085            safety,
1086            vec![
1087                "pre-commit-ban-terms",
1088                "pre-commit-large-files",
1089                "pre-commit-merge-conflict",
1090                "pre-commit-secrets",
1091                "pre-push-secrets",
1092            ]
1093        );
1094    }
1095
1096    #[test]
1097    fn every_check_declares_a_stage_and_a_scope() {
1098        assert_eq!(CHECKS.len(), 32);
1099        let pre_commit = super::stage_checks(Stage::PreCommit).count();
1100        let pre_push = super::stage_checks(Stage::PrePush).count();
1101        assert_eq!(
1102            pre_commit + pre_push,
1103            CHECKS.len(),
1104            "every check has a stage"
1105        );
1106    }
1107}