Skip to main content

amont_runtime/
registry.rs

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