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