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 ("post-rewrite", |ctx| {
79 hooks::post_rewrite::run(ctx.settings, ctx)
80 }),
81];
82
83const MID_OPERATION: &[GitState] = &[
102 GitState::Merge,
103 GitState::Rebase,
104 GitState::CherryPick,
105 GitState::Revert,
106];
107
108pub const CHECKS: &[Builtin] = &[
109 Builtin {
114 name: "pre-commit-agents-md",
115 stage: Stage::PreCommit,
116 scope: Scope::ALWAYS.not_during(MID_OPERATION),
117 severity: Severity::Warn,
118 fix: Fix::Rewrite,
119 reach: Reach::Convention,
120 run: |ctx| hooks::agents_md_drift::run(ctx.settings),
121 },
122 Builtin {
123 name: "pre-commit-argo-lint",
124 stage: Stage::PreCommit,
125 scope: Scope::new(
126 hooks::k8s::EXTS,
127 &["kustomization.yaml", "kustomization.yml"],
128 )
129 .not_during(MID_OPERATION),
130 severity: Severity::Block,
131 fix: Fix::None,
132 reach: Reach::Convention,
133 run: |ctx| hooks::k8s::argo_lint(ctx.settings, ctx.args),
134 },
135 Builtin {
136 name: "pre-commit-ban-terms",
137 stage: Stage::PreCommit,
138 scope: Scope::files(hooks::ban_terms::EXTS),
139 severity: Severity::Block,
140 fix: Fix::None,
141 reach: Reach::Safety,
142 run: |ctx| hooks::ban_terms::run(ctx.settings, ctx.name, ctx.args),
143 },
144 Builtin {
149 name: "pre-commit-branch-pattern",
150 stage: Stage::PreCommit,
151 scope: Scope::ALWAYS,
152 severity: Severity::Warn,
153 fix: Fix::None,
154 reach: Reach::Convention,
155 run: |ctx| hooks::branch_pattern::early(ctx.settings),
156 },
157 Builtin {
162 name: "pre-commit-branch-protect",
163 stage: Stage::PreCommit,
164 scope: Scope::ALWAYS,
165 severity: Severity::Warn,
166 fix: Fix::None,
167 reach: Reach::Convention,
168 run: |ctx| hooks::branch_protect::early(ctx.settings),
169 },
170 Builtin {
171 name: "pre-commit-cargo-fmt",
172 stage: Stage::PreCommit,
173 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
174 severity: Severity::Block,
175 fix: Fix::Rewrite,
176 reach: Reach::Convention,
177 run: |ctx| hooks::rust_tools::fmt(ctx.settings, ctx.args),
178 },
179 Builtin {
180 name: "pre-commit-clippy",
181 stage: Stage::PreCommit,
182 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
187 severity: Severity::Block,
188 fix: Fix::None,
189 reach: Reach::Convention,
190 run: |ctx| hooks::rust_tools::clippy(ctx.settings, ctx.args),
191 },
192 Builtin {
193 name: "pre-commit-go-vet",
194 stage: Stage::PreCommit,
195 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
196 severity: Severity::Block,
197 fix: Fix::None,
198 reach: Reach::Convention,
199 run: |ctx| hooks::go_tools::vet(ctx.settings, ctx.args),
200 },
201 Builtin {
202 name: "pre-commit-gofmt",
203 stage: Stage::PreCommit,
204 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
205 severity: Severity::Block,
206 fix: Fix::Rewrite,
207 reach: Reach::Convention,
208 run: |ctx| hooks::go_tools::fmt(ctx.settings, ctx.args),
209 },
210 Builtin {
211 name: "pre-commit-kube-linter",
212 stage: Stage::PreCommit,
213 scope: Scope::new(
214 hooks::k8s::EXTS,
215 &[".kube-linter*.yaml", ".kube-linter*.yml"],
216 )
217 .not_during(MID_OPERATION),
218 severity: Severity::Block,
219 fix: Fix::None,
220 reach: Reach::Convention,
221 run: |ctx| hooks::k8s::kube_linter(ctx.settings, ctx.args),
222 },
223 Builtin {
224 name: "pre-commit-kubeconform",
225 stage: Stage::PreCommit,
226 scope: Scope::new(
227 hooks::k8s::EXTS,
228 &["kustomization.yaml", "kustomization.yml"],
229 )
230 .not_during(MID_OPERATION),
231 severity: Severity::Block,
232 fix: Fix::None,
233 reach: Reach::Convention,
234 run: |ctx| hooks::k8s::kubeconform(ctx.settings, ctx.args),
235 },
236 Builtin {
237 name: "pre-commit-large-files",
238 stage: Stage::PreCommit,
239 scope: Scope::ALWAYS,
240 severity: Severity::Block,
241 fix: Fix::None,
242 reach: Reach::Safety,
243 run: |ctx| hooks::large_files::staged(ctx.settings),
244 },
245 Builtin {
246 name: "pre-commit-lint-js",
247 stage: Stage::PreCommit,
248 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
249 severity: Severity::Block,
250 fix: Fix::None,
251 reach: Reach::Convention,
252 run: |ctx| hooks::lint_js::run(ctx.settings, ctx.args),
253 },
254 Builtin {
255 name: "pre-commit-lint-json-yaml",
256 stage: Stage::PreCommit,
257 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
258 severity: Severity::Block,
259 fix: Fix::None,
260 reach: Reach::Convention,
261 run: |ctx| hooks::lint_json_yaml::run(ctx.settings, ctx.args),
262 },
263 Builtin {
269 name: "pre-commit-manifest-trust",
270 stage: Stage::PreCommit,
271 scope: Scope::named(&[crate::manifest::MANIFEST]).not_during(MID_OPERATION),
272 severity: Severity::Block,
273 fix: Fix::None,
274 reach: Reach::Safety,
275 run: |ctx| hooks::manifest_trust::run(ctx.settings, ctx.manifest),
276 },
277 Builtin {
278 name: "pre-commit-merge-conflict",
279 stage: Stage::PreCommit,
280 scope: Scope::ALWAYS,
281 severity: Severity::Block,
282 fix: Fix::None,
283 reach: Reach::Safety,
284 run: |ctx| hooks::merge_conflict::run(ctx.settings, ctx.name, ctx.args),
285 },
286 Builtin {
287 name: "pre-commit-package-lock",
288 stage: Stage::PreCommit,
289 scope: Scope::new(&[], &["package.json"]),
290 severity: Severity::Block,
291 fix: Fix::None,
292 reach: Reach::Convention,
293 run: |ctx| hooks::package_lock::run(ctx.settings, ctx.args),
294 },
295 Builtin {
296 name: "pre-commit-prettier",
297 stage: Stage::PreCommit,
298 scope: Scope::new(
299 &[],
300 &[
301 ".prettierrc",
302 ".prettierrc.json",
303 ".prettierrc.yml",
304 ".prettierrc.yaml",
305 ".prettierrc.js",
306 "prettier.config.js",
307 ],
308 )
309 .not_during(MID_OPERATION),
310 severity: Severity::Block,
311 fix: Fix::Rewrite,
312 reach: Reach::Convention,
313 run: |ctx| hooks::prettier::run(ctx.settings, ctx.args),
314 },
315 Builtin {
316 name: "pre-commit-pyright",
317 stage: Stage::PreCommit,
318 scope: Scope::new(
319 hooks::python_tools::EXTS,
320 &[
321 "pyrightconfig.json",
322 "pyrightconfig.jsonc",
323 "pyproject.toml",
324 ],
325 )
326 .not_during(MID_OPERATION),
327 severity: Severity::Block,
328 fix: Fix::None,
329 reach: Reach::Convention,
330 run: |ctx| hooks::python_tools::pyright(ctx.settings, ctx.args),
331 },
332 Builtin {
333 name: "pre-commit-ruff",
334 stage: Stage::PreCommit,
335 scope: Scope::new(
336 hooks::python_tools::EXTS,
337 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
338 )
339 .not_during(MID_OPERATION),
340 severity: Severity::Block,
341 fix: Fix::Rewrite,
342 reach: Reach::Convention,
343 run: |ctx| hooks::python_tools::ruff(ctx.settings, ctx.args),
344 },
345 Builtin {
349 name: "pre-commit-secrets",
350 stage: Stage::PreCommit,
351 scope: Scope::ALWAYS,
352 severity: Severity::Block,
353 fix: Fix::None,
354 reach: Reach::Safety,
355 run: |ctx| hooks::secrets::staged(ctx.settings),
356 },
357 Builtin {
361 name: "pre-commit-tree-parity",
362 stage: Stage::PreCommit,
363 scope: Scope {
364 files: hooks::k8s::EXTS,
365 names: &[crate::manifest::MANIFEST],
366 dirs: &[],
367 opt_in: &[crate::manifest::MANIFEST],
368 not_during: MID_OPERATION,
369 },
370 severity: Severity::Block,
371 fix: Fix::None,
372 reach: Reach::Convention,
373 run: |ctx| hooks::tree_parity::run(ctx.settings),
374 },
375 Builtin {
376 name: "pre-commit-usual-name",
377 stage: Stage::PreCommit,
378 scope: Scope::ALWAYS,
379 severity: Severity::Block,
380 fix: Fix::None,
381 reach: Reach::Convention,
382 run: |ctx| hooks::usual_name::run(ctx.args),
383 },
384 Builtin {
385 name: "pre-commit-shellcheck",
386 stage: Stage::PreCommit,
387 scope: Scope::files(hooks::shellcheck::EXTS).not_during(MID_OPERATION),
390 severity: Severity::Block,
391 fix: Fix::None,
392 reach: Reach::Convention,
393 run: |ctx| hooks::shellcheck::run(ctx.settings, ctx.args),
394 },
395 Builtin {
396 name: "pre-commit-hadolint",
397 stage: Stage::PreCommit,
398 scope: Scope::named(hooks::hadolint::NAMES).not_during(MID_OPERATION),
402 severity: Severity::Block,
403 fix: Fix::None,
404 reach: Reach::Convention,
405 run: |ctx| hooks::hadolint::run(ctx.settings, ctx.args),
406 },
407 Builtin {
408 name: "pre-commit-helm-lint",
409 stage: Stage::PreCommit,
410 scope: Scope::new(hooks::helm::EXTS, hooks::helm::MARKERS).not_during(MID_OPERATION),
413 severity: Severity::Block,
414 fix: Fix::None,
415 reach: Reach::Convention,
416 run: |ctx| hooks::helm::run(ctx.settings, ctx.args),
417 },
418 Builtin {
419 name: "pre-commit-yamllint",
420 stage: Stage::PreCommit,
421 scope: Scope::new(
422 hooks::yamllint::EXTS,
423 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
424 )
425 .not_during(MID_OPERATION),
426 severity: Severity::Block,
427 fix: Fix::None,
428 reach: Reach::Convention,
429 run: |ctx| hooks::yamllint::run(ctx.settings, ctx.args),
430 },
431 Builtin {
433 name: "pre-push-branch-protect",
434 stage: Stage::PrePush,
435 scope: Scope::ALWAYS,
436 severity: Severity::Block,
437 fix: Fix::None,
438 reach: Reach::Convention,
439 run: |ctx| hooks::branch_protect::run(ctx.settings, ctx.push.get()),
440 },
441 Builtin {
442 name: "pre-push-branch-pattern",
443 stage: Stage::PrePush,
444 scope: Scope::ALWAYS,
445 severity: Severity::Block,
446 fix: Fix::None,
447 reach: Reach::Convention,
448 run: |ctx| hooks::branch_pattern::run(ctx.settings, ctx.push.get(), ctx.args),
449 },
450 Builtin {
451 name: "pre-push-secrets",
452 stage: Stage::PrePush,
453 scope: Scope::ALWAYS,
454 severity: Severity::Block,
455 fix: Fix::None,
456 reach: Reach::Safety,
457 run: |ctx| hooks::secrets::pushed(ctx.settings, ctx.push.get()),
458 },
459 Builtin {
460 name: "pre-push-pull-rebase",
461 stage: Stage::PrePush,
462 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
463 severity: Severity::Block,
464 fix: Fix::None,
465 reach: Reach::Convention,
466 run: |ctx| hooks::pull_rebase::run(ctx.settings, ctx.push.get(), ctx.args),
467 },
468 Builtin {
475 name: "pre-push-audit-go",
476 stage: Stage::PrePush,
477 scope: Scope::new(&[], &["go.sum"]),
478 severity: Severity::Block,
479 fix: Fix::None,
480 reach: Reach::Convention,
481 run: |ctx| hooks::audit::go(ctx.settings, ctx.push.get()),
482 },
483 Builtin {
487 name: "pre-push-audit-js",
488 stage: Stage::PrePush,
489 scope: Scope::new(&[], &["package-lock.json", "pnpm-lock.yaml"]),
490 severity: Severity::Block,
491 fix: Fix::None,
492 reach: Reach::Convention,
493 run: |ctx| hooks::audit::js(ctx.settings, ctx.push.get()),
494 },
495 Builtin {
500 name: "pre-push-audit-python",
501 stage: Stage::PrePush,
502 scope: Scope::new(&[], &["requirements.txt", "pyproject.toml"]),
503 severity: Severity::Block,
504 fix: Fix::None,
505 reach: Reach::Convention,
506 run: |ctx| hooks::audit::python(ctx.settings, ctx.push.get()),
507 },
508 Builtin {
509 name: "pre-push-audit-rust",
510 stage: Stage::PrePush,
511 scope: Scope::new(&[], &["Cargo.lock"]),
512 severity: Severity::Block,
513 fix: Fix::None,
514 reach: Reach::Convention,
515 run: |ctx| hooks::audit::rust(ctx.settings, ctx.push.get()),
516 },
517 Builtin {
518 name: "pre-push-run-tests-js",
519 stage: Stage::PrePush,
520 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
521 .not_during(&[GitState::Bisect, GitState::Rebase]),
522 severity: Severity::Block,
523 fix: Fix::None,
524 reach: Reach::Convention,
525 run: |ctx| {
526 hooks::run_tests::run(
527 ctx.settings,
528 ctx.push.get(),
529 &ctx.manifest.externals,
530 ctx.name,
531 )
532 },
533 },
534 Builtin {
535 name: "pre-push-cargo-test",
536 stage: Stage::PrePush,
537 scope: Scope::new(hooks::rust_tools::RUST_PATHS, &["Cargo.toml"])
538 .not_during(&[GitState::Bisect, GitState::Rebase]),
539 severity: Severity::Block,
540 fix: Fix::None,
541 reach: Reach::Convention,
542 run: |ctx| hooks::rust_tools::test(ctx.settings, ctx.push.get(), ctx.name),
543 },
544 Builtin {
545 name: "pre-push-go-test",
546 stage: Stage::PrePush,
547 scope: Scope::new(hooks::go_tools::GO_PATHS, &["go.mod"])
548 .not_during(&[GitState::Bisect, GitState::Rebase]),
549 severity: Severity::Block,
550 fix: Fix::None,
551 reach: Reach::Convention,
552 run: |ctx| hooks::go_tools::test(ctx.settings, ctx.push.get(), ctx.name),
553 },
554 Builtin {
555 name: "pre-push-pytest",
556 stage: Stage::PrePush,
557 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
558 .not_during(&[GitState::Bisect, GitState::Rebase]),
559 severity: Severity::Block,
560 fix: Fix::None,
561 reach: Reach::Convention,
562 run: |ctx| hooks::python_tools::pytest(ctx.settings, ctx.push.get(), ctx.name),
563 },
564];
565
566pub fn severity_of(settings: &crate::config::Settings, check: &dyn Check) -> Severity {
572 effective_override(settings, None, check.name()).unwrap_or_else(|| check.severity())
573}
574
575pub fn severity_key(check: &str) -> String {
577 format!("amont.severity.{check}")
578}
579
580#[derive(Debug, Default, Clone)]
591pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
592
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
597pub enum Source {
598 Config,
599 Policy,
600}
601
602impl Source {
603 pub fn as_str(self) -> &'static str {
604 match self {
605 Source::Config => "config",
606 Source::Policy => "policy",
607 }
608 }
609}
610
611impl Overrides {
612 pub fn read(settings: &crate::config::Settings) -> Overrides {
619 let policy = settings.policy();
620 match crate::git::stdout(&[
621 "config",
622 "--show-scope",
623 "--get-regexp",
624 r"^amont\.severity\.",
625 ]) {
626 Some(scoped) => Overrides::from_scoped(&scoped, policy),
627 None => Overrides::from_plain_with_policy_below(
631 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
632 policy,
633 ),
634 }
635 }
636
637 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
644 let mut below = String::new();
645 let mut above = String::new();
646 for line in scoped.lines() {
647 let Some((scope, rest)) = line.split_once('\t') else {
648 continue;
649 };
650 match scope {
651 "system" | "global" => {
652 below.push_str(rest);
653 below.push('\n');
654 }
655 _ => {
656 above.push_str(rest);
657 above.push('\n');
658 }
659 }
660 }
661 let mut o = Overrides::default();
662 o.fold_plain(&below, Source::Config);
663 o.fold_policy(policy);
664 o.fold_plain(&above, Source::Config);
665 o
666 }
667
668 pub fn from_plain_with_policy_below(
673 plain: Option<String>,
674 policy: &crate::policy::Policy,
675 ) -> Overrides {
676 let mut o = Overrides::default();
677 o.fold_policy(policy);
678 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
679 o
680 }
681
682 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
685 for (target, severity) in &policy.severities {
686 self.0.insert(target.clone(), (*severity, Source::Policy));
687 }
688 }
689
690 fn fold_plain(&mut self, text: &str, source: Source) {
694 for line in text.lines() {
695 let Some((key, value)) = line.split_once(' ') else {
696 continue;
697 };
698 let Some(check) = key.strip_prefix("amont.severity.") else {
699 continue;
700 };
701 match Severity::parse(value.trim()) {
702 Some(sev) => {
703 self.0.insert(check.to_string(), (sev, source));
704 }
705 None => {
706 self.0.remove(check);
707 }
708 }
709 }
710 }
711
712 pub fn from_config(out: Option<String>) -> Overrides {
720 let mut o = Overrides::default();
721 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
722 o
723 }
724
725 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
732 self.applied_with_source(check)
733 .map(|(pattern, severity, _)| (pattern, severity))
734 }
735
736 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
740 self.0
741 .iter()
742 .filter_map(|(pattern, (severity, source))| {
743 crate::names_check(check, pattern)
744 .map(|m| (m, pattern.as_str(), *severity, *source))
745 })
746 .max_by_key(|(m, _, _, _)| *m)
747 .map(|(_, pattern, severity, source)| (pattern, severity, source))
748 }
749
750 pub fn of(&self, check: &dyn Check) -> Severity {
752 self.applied_to(check.name())
753 .map(|(_, severity)| severity)
754 .unwrap_or_else(|| check.severity())
755 }
756}
757
758#[cfg(test)]
759mod precedence {
760 use super::{Overrides, Severity};
761
762 fn overrides(lines: &[&str]) -> Overrides {
763 let text = lines
764 .iter()
765 .map(|l| format!("amont.severity.{l}\n"))
766 .collect::<String>();
767 Overrides::from_config(Some(text))
768 }
769
770 #[test]
774 fn the_more_specific_key_wins() {
775 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
776 assert_eq!(
777 both.applied_to("pre-commit-clippy"),
778 Some(("pre-commit-clippy", Severity::Block)),
779 "a full id beats its trigger"
780 );
781 assert_eq!(
782 both.applied_to("pre-commit-shellcheck"),
783 Some(("pre-commit", Severity::Warn)),
784 "and the trigger still governs every check it did not exempt"
785 );
786 }
787
788 #[test]
791 fn the_three_ways_to_name_a_check_are_ranked() {
792 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
793 assert_eq!(
794 all.applied_to("pre-commit-clippy"),
795 Some(("pre-commit-clippy", Severity::Warn))
796 );
797
798 let no_full = overrides(&["pre-commit warn", "clippy block"]);
799 assert_eq!(
800 no_full.applied_to("pre-commit-clippy"),
801 Some(("clippy", Severity::Block)),
802 "a short name beats a trigger"
803 );
804 }
805
806 #[test]
809 fn a_key_that_names_no_check_applies_to_nothing() {
810 let typo = overrides(&["clipy warn", "e warn", " warn"]);
811 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
812 }
813}
814
815pub fn effective_override(
825 settings: &crate::config::Settings,
826 repo: Option<&Path>,
827 check: &str,
828) -> Option<Severity> {
829 overrides_in(settings, repo)
830 .applied_to(check)
831 .map(|(_, s)| s)
832}
833
834pub fn effective_key(
840 settings: &crate::config::Settings,
841 repo: Option<&Path>,
842 check: &str,
843) -> Option<String> {
844 overrides_in(settings, repo)
845 .applied_to(check)
846 .map(|(pattern, _)| pattern.to_string())
847}
848
849fn overrides_in(settings: &crate::config::Settings, repo: Option<&Path>) -> Overrides {
850 match repo {
851 None => Overrides::read(settings),
855 Some(dir) => Overrides::from_config(crate::git::stdout_in(
859 dir,
860 &["config", "--get-regexp", r"^amont\.severity\."],
861 )),
862 }
863}
864
865pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
867 CHECKS.iter().filter(move |check| check.stage == stage)
868}
869
870pub fn all_stage_checks<'a>(
877 stage: Stage,
878 manifest: &'a crate::manifest::Manifest,
879) -> Vec<&'a dyn Check> {
880 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
881 .map(|check| check as &dyn Check)
882 .collect();
883 out.extend(
884 manifest
885 .externals
886 .iter()
887 .filter(|external| external.stage == stage)
888 .map(|external| external as &dyn Check),
889 );
890 out
891}
892
893pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
894 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
895 return Some(*f);
896 }
897 if CHECKS.iter().any(|check| check.name == name)
904 || manifest
905 .externals
906 .iter()
907 .any(|external| external.id == name)
908 {
909 return Some(|ctx: &Ctx| {
910 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
911 let _slot = crate::host_slots::enter_check(check.name());
914 Verdict::blocking(matches!(
915 (check.run(ctx), severity_of(ctx.settings, check)),
916 (Outcome::Failed, Severity::Block)
917 ))
918 });
919 }
920 None
921}
922
923pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
927 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
928 return Some(builtin);
929 }
930 manifest
931 .externals
932 .iter()
933 .find(|external| external.id == name)
934 .map(|external| external as &dyn Check)
935}
936
937#[cfg(test)]
938mod tests {
939 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
940 use std::collections::BTreeSet;
941
942 #[test]
949 fn the_batch_agrees_with_the_authority() {
950 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
951 let _ = std::fs::remove_dir_all(&d);
952 std::fs::create_dir_all(&d).unwrap();
953 let git = |args: &[&str]| {
954 std::process::Command::new("git")
955 .args(args)
956 .current_dir(&d)
957 .output()
958 .expect("git");
959 };
960 git(&["init", "-q", "--template=", "."]);
961 let key = "amont.severity.pre-commit-merge-conflict";
962 git(&["config", "--add", key, "warn"]);
963 git(&["config", "--add", key, "block"]);
964
965 let raw = std::process::Command::new("git")
966 .args(["config", "--get-regexp", r"^amont\.severity\."])
967 .current_dir(&d)
968 .output()
969 .expect("git");
970 let batch = Overrides::from_config(Some(
971 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
972 ));
973 let authority =
974 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
975 let _ = std::fs::remove_dir_all(&d);
976
977 assert_eq!(
978 authority,
979 Some(Severity::Block),
980 "git applies the last entry"
981 );
982 assert_eq!(
983 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
984 authority,
985 "the batch reader disagreed with `--get`"
986 );
987 }
988
989 #[test]
993 fn an_unrecognised_value_clears_rather_than_overrides() {
994 let o = Overrides::from_config(Some(
995 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
996 ));
997 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
998 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
999 }
1000
1001 #[test]
1002 fn names_are_unique_across_entrypoints_and_checks() {
1003 let mut seen = BTreeSet::new();
1004 for n in ENTRYPOINTS
1005 .iter()
1006 .map(|(n, _)| *n)
1007 .chain(CHECKS.iter().map(|check| check.name))
1008 {
1009 assert!(seen.insert(n), "duplicate registration: {n}");
1010 }
1011 }
1012
1013 #[test]
1015 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
1016 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
1017 let mut shipped: Vec<String> = std::fs::read_dir(dir)
1018 .expect("templates/hooks")
1019 .flatten()
1020 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1021 .collect();
1022 shipped.sort();
1023 assert_eq!(
1024 shipped,
1025 vec![
1026 "commit-msg",
1027 "post-commit",
1028 "post-rewrite",
1029 "pre-commit",
1030 "pre-push",
1031 "prepare-commit-msg"
1032 ]
1033 );
1034 let none = crate::manifest::Manifest::default();
1035 for name in &shipped {
1036 assert!(
1037 lookup(name, &none).is_some(),
1038 "shipped shim {name:?} has no handler"
1039 );
1040 }
1041 }
1042
1043 #[test]
1046 fn every_check_is_reachable_by_name() {
1047 let none = crate::manifest::Manifest::default();
1048 for check in CHECKS {
1049 assert!(
1050 lookup(check.name, &none).is_some(),
1051 "{} not reachable",
1052 check.name
1053 );
1054 }
1055 assert!(lookup("pre-commit-not-a-check", &none).is_none());
1056 }
1057
1058 #[test]
1060 fn pre_push_runs_cheapest_first() {
1061 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
1062 .map(|check| check.name)
1063 .collect();
1064 assert_eq!(
1065 order,
1066 vec![
1067 "pre-push-branch-protect",
1068 "pre-push-branch-pattern",
1069 "pre-push-secrets",
1070 "pre-push-pull-rebase",
1071 "pre-push-audit-go",
1072 "pre-push-audit-js",
1073 "pre-push-audit-python",
1074 "pre-push-audit-rust",
1075 "pre-push-run-tests-js",
1076 "pre-push-cargo-test",
1077 "pre-push-go-test",
1078 "pre-push-pytest",
1079 ]
1080 );
1081 }
1082
1083 enum Consumes {
1089 All,
1090 Exts(&'static [&'static str]),
1091 }
1092
1093 const CONSUMED: &[(&str, Consumes)] = &[
1107 (
1108 "pre-commit-argo-lint",
1109 Consumes::Exts(crate::hooks::k8s::EXTS),
1110 ),
1111 (
1115 "pre-commit-ban-terms",
1116 Consumes::Exts(crate::hooks::ban_terms::EXTS),
1117 ),
1118 (
1119 "pre-commit-cargo-fmt",
1120 Consumes::Exts(crate::hooks::rust_tools::EXTS),
1121 ),
1122 (
1123 "pre-commit-clippy",
1124 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1125 ),
1126 (
1127 "pre-commit-go-vet",
1128 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1129 ),
1130 (
1131 "pre-commit-gofmt",
1132 Consumes::Exts(crate::hooks::go_tools::EXTS),
1133 ),
1134 (
1135 "pre-commit-kube-linter",
1136 Consumes::Exts(crate::hooks::k8s::EXTS),
1137 ),
1138 (
1139 "pre-commit-kubeconform",
1140 Consumes::Exts(crate::hooks::k8s::EXTS),
1141 ),
1142 (
1143 "pre-commit-lint-js",
1144 Consumes::Exts(crate::hooks::lint_js::EXTS),
1145 ),
1146 (
1147 "pre-commit-lint-json-yaml",
1148 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1149 ),
1150 ("pre-commit-merge-conflict", Consumes::All),
1151 ("pre-commit-package-lock", Consumes::All),
1152 (
1156 "pre-commit-prettier",
1157 Consumes::Exts(crate::hooks::prettier::EXTS),
1158 ),
1159 (
1160 "pre-commit-pyright",
1161 Consumes::Exts(crate::hooks::python_tools::EXTS),
1162 ),
1163 (
1164 "pre-commit-ruff",
1165 Consumes::Exts(crate::hooks::python_tools::EXTS),
1166 ),
1167 ("pre-commit-usual-name", Consumes::All),
1168 (
1169 "pre-commit-yamllint",
1170 Consumes::Exts(crate::hooks::yamllint::EXTS),
1171 ),
1172 (
1173 "pre-commit-shellcheck",
1174 Consumes::Exts(crate::hooks::shellcheck::EXTS),
1175 ),
1176 (
1177 "pre-commit-hadolint",
1178 Consumes::Exts(crate::hooks::hadolint::NAMES),
1179 ),
1180 (
1181 "pre-commit-helm-lint",
1182 Consumes::Exts(crate::hooks::helm::EXTS),
1183 ),
1184 ("pre-commit-large-files", Consumes::All),
1185 ("pre-commit-secrets", Consumes::All),
1186 ("pre-push-secrets", Consumes::All),
1187 ("pre-push-branch-protect", Consumes::All),
1188 ("pre-push-branch-pattern", Consumes::All),
1189 ("pre-push-pull-rebase", Consumes::All),
1190 (
1191 "pre-push-run-tests-js",
1192 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1193 ),
1194 (
1195 "pre-push-pytest",
1196 Consumes::Exts(crate::hooks::python_tools::EXTS),
1197 ),
1198 (
1199 "pre-push-cargo-test",
1200 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1201 ),
1202 (
1203 "pre-push-go-test",
1204 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1205 ),
1206 (
1208 "pre-commit-tree-parity",
1209 Consumes::Exts(crate::hooks::k8s::EXTS),
1210 ),
1211 ];
1212
1213 #[test]
1224 fn no_check_declares_a_file_type_it_does_not_consume() {
1225 for (name, _) in CONSUMED {
1226 assert!(
1227 CHECKS.iter().any(|check| check.name == *name),
1228 "CONSUMED names {name:?}, which is not a check"
1229 );
1230 }
1231 for check in CHECKS {
1232 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1233 if check.scope.files.is_empty() && entry.is_none() {
1234 continue;
1235 }
1236 let Some((_, consumes)) = entry else {
1237 panic!(
1238 "{} declares scope.files {:?} but is missing from CONSUMED — \
1239 say what it actually reads",
1240 check.name, check.scope.files
1241 );
1242 };
1243 let Consumes::Exts(consumed) = consumes else {
1244 continue; };
1246 for ext in check.scope.files {
1247 assert!(
1248 consumed.contains(ext),
1249 "{} declares {ext:?} in its scope but never asks for it — \
1250 `amont list` would report a coverage the check does not have",
1251 check.name
1252 );
1253 }
1254 }
1255 }
1256
1257 #[test]
1269 fn a_push_suite_declares_every_path_it_consumes() {
1270 for name in ["pre-push-cargo-test", "pre-push-go-test", "pre-push-pytest"] {
1271 let check = CHECKS.iter().find(|c| c.name == name).expect(name);
1272 let Some((_, Consumes::Exts(consumed))) = CONSUMED.iter().find(|(n, _)| *n == name)
1273 else {
1274 panic!("{name} must consume an explicit file set");
1275 };
1276 for path in *consumed {
1277 assert!(
1278 check.scope.files.contains(path),
1279 "{name} runs for {path:?} but does not declare it: a push \
1280 touching only {path:?} would be rehearsed as nothing to do, \
1281 run at push time, and never stamped"
1282 );
1283 }
1284 }
1285 }
1286
1287 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1296 ("pre-commit-agents-md", true),
1297 ("pre-commit-argo-lint", false),
1298 ("pre-commit-ban-terms", false),
1299 ("pre-commit-branch-pattern", false),
1300 ("pre-commit-branch-protect", false),
1301 ("pre-commit-cargo-fmt", true),
1302 ("pre-commit-clippy", false),
1303 ("pre-commit-go-vet", false),
1304 ("pre-commit-gofmt", true),
1305 ("pre-commit-kube-linter", false),
1306 ("pre-commit-kubeconform", false),
1307 ("pre-commit-lint-js", false),
1308 ("pre-commit-lint-json-yaml", false),
1309 ("pre-commit-manifest-trust", false),
1310 ("pre-commit-merge-conflict", false),
1311 ("pre-commit-package-lock", false),
1312 ("pre-commit-prettier", true),
1313 ("pre-commit-pyright", false),
1314 ("pre-commit-ruff", true),
1315 ("pre-commit-tree-parity", false),
1316 ("pre-commit-usual-name", false),
1317 ("pre-commit-yamllint", false),
1318 ("pre-commit-shellcheck", false),
1319 ("pre-commit-hadolint", false),
1320 ("pre-commit-helm-lint", false),
1321 ("pre-commit-large-files", false),
1322 ("pre-commit-secrets", false),
1323 ("pre-push-secrets", false),
1324 ("pre-push-branch-protect", false),
1325 ("pre-push-branch-pattern", false),
1326 ("pre-push-pull-rebase", false),
1327 ("pre-push-audit-go", false),
1328 ("pre-push-audit-js", false),
1329 ("pre-push-audit-python", false),
1330 ("pre-push-audit-rust", false),
1331 ("pre-push-run-tests-js", false),
1332 ("pre-push-cargo-test", false),
1333 ("pre-push-go-test", false),
1334 ("pre-push-pytest", false),
1335 ];
1336
1337 #[test]
1340 fn every_rewrite_declaration_has_a_fixer() {
1341 let declared: BTreeSet<&str> = CHECKS
1342 .iter()
1343 .filter(|check| check.fix == super::Fix::Rewrite)
1344 .map(|check| check.name)
1345 .collect();
1346 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1347 .iter()
1348 .filter(|(_, has)| *has)
1349 .map(|(name, _)| *name)
1350 .collect();
1351 assert_eq!(
1352 declared, implemented,
1353 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1354 and a check with a fixer that does not declare it can never be reached"
1355 );
1356
1357 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1360 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1361 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1362 }
1363
1364 #[test]
1375 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1376 let safety: Vec<&str> = CHECKS
1377 .iter()
1378 .filter(|c| c.reach == Reach::Safety)
1379 .map(|c| c.name)
1380 .collect();
1381 assert_eq!(
1382 safety,
1383 vec![
1384 "pre-commit-ban-terms",
1385 "pre-commit-large-files",
1386 "pre-commit-manifest-trust",
1387 "pre-commit-merge-conflict",
1388 "pre-commit-secrets",
1389 "pre-push-secrets",
1390 ]
1391 );
1392 }
1393
1394 #[test]
1395 fn every_check_declares_a_stage_and_a_scope() {
1396 assert_eq!(CHECKS.len(), 39);
1397 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1398 let pre_push = super::stage_checks(Stage::PrePush).count();
1399 assert_eq!(
1400 pre_commit + pre_push,
1401 CHECKS.len(),
1402 "every check has a stage"
1403 );
1404 }
1405}