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