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