Skip to main content

amont_runtime/
registry.rs

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