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 {
456 name: "pre-push-audit-python",
457 stage: Stage::PrePush,
458 scope: Scope::new(&[], &["requirements.txt", "pyproject.toml"]),
459 severity: Severity::Block,
460 fix: Fix::None,
461 reach: Reach::Convention,
462 run: |ctx| hooks::audit::python(ctx.settings, ctx.push.get()),
463 },
464 Builtin {
465 name: "pre-push-audit-rust",
466 stage: Stage::PrePush,
467 scope: Scope::new(&[], &["Cargo.lock"]),
468 severity: Severity::Block,
469 fix: Fix::None,
470 reach: Reach::Convention,
471 run: |ctx| hooks::audit::rust(ctx.settings, ctx.push.get()),
472 },
473 Builtin {
474 name: "pre-push-run-tests-js",
475 stage: Stage::PrePush,
476 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
477 .not_during(&[GitState::Bisect, GitState::Rebase]),
478 severity: Severity::Block,
479 fix: Fix::None,
480 reach: Reach::Convention,
481 run: |ctx| {
482 hooks::run_tests::run(
483 ctx.settings,
484 ctx.push.get(),
485 &ctx.manifest.externals,
486 ctx.name,
487 )
488 },
489 },
490 Builtin {
491 name: "pre-push-cargo-test",
492 stage: Stage::PrePush,
493 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
494 .not_during(&[GitState::Bisect, GitState::Rebase]),
495 severity: Severity::Block,
496 fix: Fix::None,
497 reach: Reach::Convention,
498 run: |ctx| hooks::rust_tools::test(ctx.settings, ctx.push.get(), ctx.name),
499 },
500 Builtin {
501 name: "pre-push-go-test",
502 stage: Stage::PrePush,
503 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
504 .not_during(&[GitState::Bisect, GitState::Rebase]),
505 severity: Severity::Block,
506 fix: Fix::None,
507 reach: Reach::Convention,
508 run: |ctx| hooks::go_tools::test(ctx.settings, ctx.push.get(), ctx.name),
509 },
510 Builtin {
511 name: "pre-push-pytest",
512 stage: Stage::PrePush,
513 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
514 .not_during(&[GitState::Bisect, GitState::Rebase]),
515 severity: Severity::Block,
516 fix: Fix::None,
517 reach: Reach::Convention,
518 run: |ctx| hooks::python_tools::pytest(ctx.settings, ctx.push.get(), ctx.name),
519 },
520];
521
522pub fn severity_of(settings: &crate::config::Settings, check: &dyn Check) -> Severity {
528 effective_override(settings, None, check.name()).unwrap_or_else(|| check.severity())
529}
530
531pub fn severity_key(check: &str) -> String {
533 format!("amont.severity.{check}")
534}
535
536#[derive(Debug, Default, Clone)]
547pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub enum Source {
554 Config,
555 Policy,
556}
557
558impl Source {
559 pub fn as_str(self) -> &'static str {
560 match self {
561 Source::Config => "config",
562 Source::Policy => "policy",
563 }
564 }
565}
566
567impl Overrides {
568 pub fn read(settings: &crate::config::Settings) -> Overrides {
575 let policy = settings.policy();
576 match crate::git::stdout(&[
577 "config",
578 "--show-scope",
579 "--get-regexp",
580 r"^amont\.severity\.",
581 ]) {
582 Some(scoped) => Overrides::from_scoped(&scoped, policy),
583 None => Overrides::from_plain_with_policy_below(
587 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
588 policy,
589 ),
590 }
591 }
592
593 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
600 let mut below = String::new();
601 let mut above = String::new();
602 for line in scoped.lines() {
603 let Some((scope, rest)) = line.split_once('\t') else {
604 continue;
605 };
606 match scope {
607 "system" | "global" => {
608 below.push_str(rest);
609 below.push('\n');
610 }
611 _ => {
612 above.push_str(rest);
613 above.push('\n');
614 }
615 }
616 }
617 let mut o = Overrides::default();
618 o.fold_plain(&below, Source::Config);
619 o.fold_policy(policy);
620 o.fold_plain(&above, Source::Config);
621 o
622 }
623
624 pub fn from_plain_with_policy_below(
629 plain: Option<String>,
630 policy: &crate::policy::Policy,
631 ) -> Overrides {
632 let mut o = Overrides::default();
633 o.fold_policy(policy);
634 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
635 o
636 }
637
638 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
641 for (target, severity) in &policy.severities {
642 self.0.insert(target.clone(), (*severity, Source::Policy));
643 }
644 }
645
646 fn fold_plain(&mut self, text: &str, source: Source) {
650 for line in text.lines() {
651 let Some((key, value)) = line.split_once(' ') else {
652 continue;
653 };
654 let Some(check) = key.strip_prefix("amont.severity.") else {
655 continue;
656 };
657 match Severity::parse(value.trim()) {
658 Some(sev) => {
659 self.0.insert(check.to_string(), (sev, source));
660 }
661 None => {
662 self.0.remove(check);
663 }
664 }
665 }
666 }
667
668 pub fn from_config(out: Option<String>) -> Overrides {
676 let mut o = Overrides::default();
677 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
678 o
679 }
680
681 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
688 self.applied_with_source(check)
689 .map(|(pattern, severity, _)| (pattern, severity))
690 }
691
692 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
696 self.0
697 .iter()
698 .filter_map(|(pattern, (severity, source))| {
699 crate::names_check(check, pattern)
700 .map(|m| (m, pattern.as_str(), *severity, *source))
701 })
702 .max_by_key(|(m, _, _, _)| *m)
703 .map(|(_, pattern, severity, source)| (pattern, severity, source))
704 }
705
706 pub fn of(&self, check: &dyn Check) -> Severity {
708 self.applied_to(check.name())
709 .map(|(_, severity)| severity)
710 .unwrap_or_else(|| check.severity())
711 }
712}
713
714#[cfg(test)]
715mod precedence {
716 use super::{Overrides, Severity};
717
718 fn overrides(lines: &[&str]) -> Overrides {
719 let text = lines
720 .iter()
721 .map(|l| format!("amont.severity.{l}\n"))
722 .collect::<String>();
723 Overrides::from_config(Some(text))
724 }
725
726 #[test]
730 fn the_more_specific_key_wins() {
731 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
732 assert_eq!(
733 both.applied_to("pre-commit-clippy"),
734 Some(("pre-commit-clippy", Severity::Block)),
735 "a full id beats its trigger"
736 );
737 assert_eq!(
738 both.applied_to("pre-commit-shellcheck"),
739 Some(("pre-commit", Severity::Warn)),
740 "and the trigger still governs every check it did not exempt"
741 );
742 }
743
744 #[test]
747 fn the_three_ways_to_name_a_check_are_ranked() {
748 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
749 assert_eq!(
750 all.applied_to("pre-commit-clippy"),
751 Some(("pre-commit-clippy", Severity::Warn))
752 );
753
754 let no_full = overrides(&["pre-commit warn", "clippy block"]);
755 assert_eq!(
756 no_full.applied_to("pre-commit-clippy"),
757 Some(("clippy", Severity::Block)),
758 "a short name beats a trigger"
759 );
760 }
761
762 #[test]
765 fn a_key_that_names_no_check_applies_to_nothing() {
766 let typo = overrides(&["clipy warn", "e warn", " warn"]);
767 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
768 }
769}
770
771pub fn effective_override(
781 settings: &crate::config::Settings,
782 repo: Option<&Path>,
783 check: &str,
784) -> Option<Severity> {
785 overrides_in(settings, repo)
786 .applied_to(check)
787 .map(|(_, s)| s)
788}
789
790pub fn effective_key(
796 settings: &crate::config::Settings,
797 repo: Option<&Path>,
798 check: &str,
799) -> Option<String> {
800 overrides_in(settings, repo)
801 .applied_to(check)
802 .map(|(pattern, _)| pattern.to_string())
803}
804
805fn overrides_in(settings: &crate::config::Settings, repo: Option<&Path>) -> Overrides {
806 match repo {
807 None => Overrides::read(settings),
811 Some(dir) => Overrides::from_config(crate::git::stdout_in(
815 dir,
816 &["config", "--get-regexp", r"^amont\.severity\."],
817 )),
818 }
819}
820
821pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
823 CHECKS.iter().filter(move |check| check.stage == stage)
824}
825
826pub fn all_stage_checks<'a>(
833 stage: Stage,
834 manifest: &'a crate::manifest::Manifest,
835) -> Vec<&'a dyn Check> {
836 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
837 .map(|check| check as &dyn Check)
838 .collect();
839 out.extend(
840 manifest
841 .externals
842 .iter()
843 .filter(|external| external.stage == stage)
844 .map(|external| external as &dyn Check),
845 );
846 out
847}
848
849pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
850 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
851 return Some(*f);
852 }
853 if CHECKS.iter().any(|check| check.name == name)
860 || manifest
861 .externals
862 .iter()
863 .any(|external| external.id == name)
864 {
865 return Some(|ctx: &Ctx| {
866 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
867 Verdict::blocking(matches!(
868 (check.run(ctx), severity_of(ctx.settings, check)),
869 (Outcome::Failed, Severity::Block)
870 ))
871 });
872 }
873 None
874}
875
876pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
880 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
881 return Some(builtin);
882 }
883 manifest
884 .externals
885 .iter()
886 .find(|external| external.id == name)
887 .map(|external| external as &dyn Check)
888}
889
890#[cfg(test)]
891mod tests {
892 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
893 use std::collections::BTreeSet;
894
895 #[test]
902 fn the_batch_agrees_with_the_authority() {
903 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
904 let _ = std::fs::remove_dir_all(&d);
905 std::fs::create_dir_all(&d).unwrap();
906 let git = |args: &[&str]| {
907 std::process::Command::new("git")
908 .args(args)
909 .current_dir(&d)
910 .output()
911 .expect("git");
912 };
913 git(&["init", "-q", "--template=", "."]);
914 let key = "amont.severity.pre-commit-merge-conflict";
915 git(&["config", "--add", key, "warn"]);
916 git(&["config", "--add", key, "block"]);
917
918 let raw = std::process::Command::new("git")
919 .args(["config", "--get-regexp", r"^amont\.severity\."])
920 .current_dir(&d)
921 .output()
922 .expect("git");
923 let batch = Overrides::from_config(Some(
924 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
925 ));
926 let authority =
927 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
928 let _ = std::fs::remove_dir_all(&d);
929
930 assert_eq!(
931 authority,
932 Some(Severity::Block),
933 "git applies the last entry"
934 );
935 assert_eq!(
936 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
937 authority,
938 "the batch reader disagreed with `--get`"
939 );
940 }
941
942 #[test]
946 fn an_unrecognised_value_clears_rather_than_overrides() {
947 let o = Overrides::from_config(Some(
948 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
949 ));
950 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
951 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
952 }
953
954 #[test]
955 fn names_are_unique_across_entrypoints_and_checks() {
956 let mut seen = BTreeSet::new();
957 for n in ENTRYPOINTS
958 .iter()
959 .map(|(n, _)| *n)
960 .chain(CHECKS.iter().map(|check| check.name))
961 {
962 assert!(seen.insert(n), "duplicate registration: {n}");
963 }
964 }
965
966 #[test]
968 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
969 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
970 let mut shipped: Vec<String> = std::fs::read_dir(dir)
971 .expect("templates/hooks")
972 .flatten()
973 .map(|entry| entry.file_name().to_string_lossy().into_owned())
974 .collect();
975 shipped.sort();
976 assert_eq!(
977 shipped,
978 vec![
979 "commit-msg",
980 "post-commit",
981 "pre-commit",
982 "pre-push",
983 "prepare-commit-msg"
984 ]
985 );
986 let none = crate::manifest::Manifest::default();
987 for name in &shipped {
988 assert!(
989 lookup(name, &none).is_some(),
990 "shipped shim {name:?} has no handler"
991 );
992 }
993 }
994
995 #[test]
998 fn every_check_is_reachable_by_name() {
999 let none = crate::manifest::Manifest::default();
1000 for check in CHECKS {
1001 assert!(
1002 lookup(check.name, &none).is_some(),
1003 "{} not reachable",
1004 check.name
1005 );
1006 }
1007 assert!(lookup("pre-commit-not-a-check", &none).is_none());
1008 }
1009
1010 #[test]
1012 fn pre_push_runs_cheapest_first() {
1013 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
1014 .map(|check| check.name)
1015 .collect();
1016 assert_eq!(
1017 order,
1018 vec![
1019 "pre-push-branch-protect",
1020 "pre-push-branch-pattern",
1021 "pre-push-secrets",
1022 "pre-push-pull-rebase",
1023 "pre-push-audit-go",
1024 "pre-push-audit-js",
1025 "pre-push-audit-python",
1026 "pre-push-audit-rust",
1027 "pre-push-run-tests-js",
1028 "pre-push-cargo-test",
1029 "pre-push-go-test",
1030 "pre-push-pytest",
1031 ]
1032 );
1033 }
1034
1035 enum Consumes {
1041 All,
1042 Exts(&'static [&'static str]),
1043 }
1044
1045 const CONSUMED: &[(&str, Consumes)] = &[
1059 (
1060 "pre-commit-argo-lint",
1061 Consumes::Exts(crate::hooks::k8s::EXTS),
1062 ),
1063 (
1067 "pre-commit-ban-terms",
1068 Consumes::Exts(crate::hooks::ban_terms::EXTS),
1069 ),
1070 (
1071 "pre-commit-cargo-fmt",
1072 Consumes::Exts(crate::hooks::rust_tools::EXTS),
1073 ),
1074 (
1075 "pre-commit-clippy",
1076 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1077 ),
1078 (
1079 "pre-commit-go-vet",
1080 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1081 ),
1082 (
1083 "pre-commit-gofmt",
1084 Consumes::Exts(crate::hooks::go_tools::EXTS),
1085 ),
1086 (
1087 "pre-commit-kube-linter",
1088 Consumes::Exts(crate::hooks::k8s::EXTS),
1089 ),
1090 (
1091 "pre-commit-kubeconform",
1092 Consumes::Exts(crate::hooks::k8s::EXTS),
1093 ),
1094 (
1095 "pre-commit-lint-js",
1096 Consumes::Exts(crate::hooks::lint_js::EXTS),
1097 ),
1098 (
1099 "pre-commit-lint-json-yaml",
1100 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1101 ),
1102 ("pre-commit-merge-conflict", Consumes::All),
1103 ("pre-commit-package-lock", Consumes::All),
1104 (
1108 "pre-commit-prettier",
1109 Consumes::Exts(crate::hooks::prettier::EXTS),
1110 ),
1111 (
1112 "pre-commit-pyright",
1113 Consumes::Exts(crate::hooks::python_tools::EXTS),
1114 ),
1115 (
1116 "pre-commit-ruff",
1117 Consumes::Exts(crate::hooks::python_tools::EXTS),
1118 ),
1119 ("pre-commit-usual-name", Consumes::All),
1120 (
1121 "pre-commit-yamllint",
1122 Consumes::Exts(crate::hooks::yamllint::EXTS),
1123 ),
1124 (
1125 "pre-commit-shellcheck",
1126 Consumes::Exts(crate::hooks::shellcheck::EXTS),
1127 ),
1128 (
1129 "pre-commit-hadolint",
1130 Consumes::Exts(crate::hooks::hadolint::NAMES),
1131 ),
1132 (
1133 "pre-commit-helm-lint",
1134 Consumes::Exts(crate::hooks::helm::EXTS),
1135 ),
1136 ("pre-commit-large-files", Consumes::All),
1137 ("pre-commit-secrets", Consumes::All),
1138 ("pre-push-secrets", Consumes::All),
1139 ("pre-push-branch-protect", Consumes::All),
1140 ("pre-push-branch-pattern", Consumes::All),
1141 ("pre-push-pull-rebase", Consumes::All),
1142 (
1143 "pre-push-run-tests-js",
1144 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1145 ),
1146 (
1147 "pre-push-pytest",
1148 Consumes::Exts(crate::hooks::python_tools::EXTS),
1149 ),
1150 (
1151 "pre-push-cargo-test",
1152 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1153 ),
1154 (
1155 "pre-push-go-test",
1156 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1157 ),
1158 ];
1159
1160 #[test]
1171 fn no_check_declares_a_file_type_it_does_not_consume() {
1172 for (name, _) in CONSUMED {
1173 assert!(
1174 CHECKS.iter().any(|check| check.name == *name),
1175 "CONSUMED names {name:?}, which is not a check"
1176 );
1177 }
1178 for check in CHECKS {
1179 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1180 if check.scope.files.is_empty() && entry.is_none() {
1181 continue;
1182 }
1183 let Some((_, consumes)) = entry else {
1184 panic!(
1185 "{} declares scope.files {:?} but is missing from CONSUMED — \
1186 say what it actually reads",
1187 check.name, check.scope.files
1188 );
1189 };
1190 let Consumes::Exts(consumed) = consumes else {
1191 continue; };
1193 for ext in check.scope.files {
1194 assert!(
1195 consumed.contains(ext),
1196 "{} declares {ext:?} in its scope but never asks for it — \
1197 `amont list` would report a coverage the check does not have",
1198 check.name
1199 );
1200 }
1201 }
1202 }
1203
1204 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1213 ("pre-commit-agents-md", true),
1214 ("pre-commit-argo-lint", false),
1215 ("pre-commit-ban-terms", false),
1216 ("pre-commit-branch-pattern", false),
1217 ("pre-commit-branch-protect", false),
1218 ("pre-commit-cargo-fmt", true),
1219 ("pre-commit-clippy", false),
1220 ("pre-commit-go-vet", false),
1221 ("pre-commit-gofmt", true),
1222 ("pre-commit-kube-linter", false),
1223 ("pre-commit-kubeconform", false),
1224 ("pre-commit-lint-js", false),
1225 ("pre-commit-lint-json-yaml", false),
1226 ("pre-commit-merge-conflict", false),
1227 ("pre-commit-package-lock", false),
1228 ("pre-commit-prettier", true),
1229 ("pre-commit-pyright", false),
1230 ("pre-commit-ruff", true),
1231 ("pre-commit-usual-name", false),
1232 ("pre-commit-yamllint", false),
1233 ("pre-commit-shellcheck", false),
1234 ("pre-commit-hadolint", false),
1235 ("pre-commit-helm-lint", false),
1236 ("pre-commit-large-files", false),
1237 ("pre-commit-secrets", false),
1238 ("pre-push-secrets", false),
1239 ("pre-push-branch-protect", false),
1240 ("pre-push-branch-pattern", false),
1241 ("pre-push-pull-rebase", false),
1242 ("pre-push-audit-go", false),
1243 ("pre-push-audit-js", false),
1244 ("pre-push-audit-python", false),
1245 ("pre-push-audit-rust", false),
1246 ("pre-push-run-tests-js", false),
1247 ("pre-push-cargo-test", false),
1248 ("pre-push-go-test", false),
1249 ("pre-push-pytest", false),
1250 ];
1251
1252 #[test]
1255 fn every_rewrite_declaration_has_a_fixer() {
1256 let declared: BTreeSet<&str> = CHECKS
1257 .iter()
1258 .filter(|check| check.fix == super::Fix::Rewrite)
1259 .map(|check| check.name)
1260 .collect();
1261 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1262 .iter()
1263 .filter(|(_, has)| *has)
1264 .map(|(name, _)| *name)
1265 .collect();
1266 assert_eq!(
1267 declared, implemented,
1268 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1269 and a check with a fixer that does not declare it can never be reached"
1270 );
1271
1272 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1275 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1276 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1277 }
1278
1279 #[test]
1290 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1291 let safety: Vec<&str> = CHECKS
1292 .iter()
1293 .filter(|c| c.reach == Reach::Safety)
1294 .map(|c| c.name)
1295 .collect();
1296 assert_eq!(
1297 safety,
1298 vec![
1299 "pre-commit-ban-terms",
1300 "pre-commit-large-files",
1301 "pre-commit-merge-conflict",
1302 "pre-commit-secrets",
1303 "pre-push-secrets",
1304 ]
1305 );
1306 }
1307
1308 #[test]
1309 fn every_check_declares_a_stage_and_a_scope() {
1310 assert_eq!(CHECKS.len(), 37);
1311 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1312 let pre_push = super::stage_checks(Stage::PrePush).count();
1313 assert_eq!(
1314 pre_commit + pre_push,
1315 CHECKS.len(),
1316 "every check has a stage"
1317 );
1318 }
1319}