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