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 {
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 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 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 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 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 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 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
518pub 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
527pub fn severity_key(check: &str) -> String {
529 format!("amont.severity.{check}")
530}
531
532#[derive(Debug, Default, Clone)]
543pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
544
545#[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 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 None => Overrides::from_plain_with_policy_below(
583 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
584 policy,
585 ),
586 }
587 }
588
589 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 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 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 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 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 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 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 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 #[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 #[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 #[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
767pub 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
786pub 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 None => Overrides::read(settings),
807 Some(dir) => Overrides::from_config(crate::git::stdout_in(
811 dir,
812 &["config", "--get-regexp", r"^amont\.severity\."],
813 )),
814 }
815}
816
817pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
819 CHECKS.iter().filter(move |check| check.stage == stage)
820}
821
822pub 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 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
872pub 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 #[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 #[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 #[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 #[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 #[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 enum Consumes {
1037 All,
1038 Exts(&'static [&'static str]),
1039 }
1040
1041 const CONSUMED: &[(&str, Consumes)] = &[
1055 (
1056 "pre-commit-argo-lint",
1057 Consumes::Exts(crate::hooks::k8s::EXTS),
1058 ),
1059 (
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 (
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 #[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; };
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 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 #[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 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 #[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}