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