1use 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
23pub struct Ctx<'a> {
27 pub name: &'a str,
29 pub args: &'a [OsString],
31 pub hooks_dir: &'a Path,
34 pub push: &'a PushRefs,
37 pub manifest: &'a crate::manifest::Manifest,
41 pub settings: &'a crate::config::Settings,
46}
47
48pub type HookFn = fn(&Ctx) -> Verdict;
49
50pub const ENTRYPOINTS: &[(&str, HookFn)] = &[
53 ("pre-commit", dispatch::pre_commit),
54 ("pre-push", dispatch::pre_push),
55 ("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
78const MID_OPERATION: &[GitState] = &[
97 GitState::Merge,
98 GitState::Rebase,
99 GitState::CherryPick,
100 GitState::Revert,
101];
102
103pub const CHECKS: &[Builtin] = &[
104 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 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 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 {
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 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 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 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 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 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 Builtin {
448 name: "pre-push-audit-go",
449 stage: Stage::PrePush,
450 scope: Scope::new(&[], &["go.sum"]),
451 severity: Severity::Block,
452 fix: Fix::None,
453 reach: Reach::Convention,
454 run: |ctx| hooks::audit::go(ctx.settings, ctx.push.get()),
455 },
456 Builtin {
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 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
539pub 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
548pub fn severity_key(check: &str) -> String {
550 format!("amont.severity.{check}")
551}
552
553#[derive(Debug, Default, Clone)]
564pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
565
566#[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 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 None => Overrides::from_plain_with_policy_below(
604 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
605 policy,
606 ),
607 }
608 }
609
610 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 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 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 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 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 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 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 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 #[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 #[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 #[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
788pub 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
807pub 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 None => Overrides::read(settings),
828 Some(dir) => Overrides::from_config(crate::git::stdout_in(
832 dir,
833 &["config", "--get-regexp", r"^amont\.severity\."],
834 )),
835 }
836}
837
838pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
840 CHECKS.iter().filter(move |check| check.stage == stage)
841}
842
843pub 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 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
893pub 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 #[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 #[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 #[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 #[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 #[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 enum Consumes {
1058 All,
1059 Exts(&'static [&'static str]),
1060 }
1061
1062 const CONSUMED: &[(&str, Consumes)] = &[
1076 (
1077 "pre-commit-argo-lint",
1078 Consumes::Exts(crate::hooks::k8s::EXTS),
1079 ),
1080 (
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 (
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 #[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; };
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 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 #[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 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 #[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}