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),
183 severity: Severity::Block,
184 fix: Fix::None,
185 reach: Reach::Convention,
186 run: |ctx| hooks::rust_tools::clippy(ctx.settings, ctx.args),
187 },
188 Builtin {
189 name: "pre-commit-go-vet",
190 stage: Stage::PreCommit,
191 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
192 severity: Severity::Block,
193 fix: Fix::None,
194 reach: Reach::Convention,
195 run: |ctx| hooks::go_tools::vet(ctx.settings, ctx.args),
196 },
197 Builtin {
198 name: "pre-commit-gofmt",
199 stage: Stage::PreCommit,
200 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
201 severity: Severity::Block,
202 fix: Fix::Rewrite,
203 reach: Reach::Convention,
204 run: |ctx| hooks::go_tools::fmt(ctx.settings, ctx.args),
205 },
206 Builtin {
207 name: "pre-commit-kube-linter",
208 stage: Stage::PreCommit,
209 scope: Scope::new(
210 hooks::k8s::EXTS,
211 &[".kube-linter*.yaml", ".kube-linter*.yml"],
212 )
213 .not_during(MID_OPERATION),
214 severity: Severity::Block,
215 fix: Fix::None,
216 reach: Reach::Convention,
217 run: |ctx| hooks::k8s::kube_linter(ctx.settings, ctx.args),
218 },
219 Builtin {
220 name: "pre-commit-kubeconform",
221 stage: Stage::PreCommit,
222 scope: Scope::new(
223 hooks::k8s::EXTS,
224 &["kustomization.yaml", "kustomization.yml"],
225 )
226 .not_during(MID_OPERATION),
227 severity: Severity::Block,
228 fix: Fix::None,
229 reach: Reach::Convention,
230 run: |ctx| hooks::k8s::kubeconform(ctx.settings, ctx.args),
231 },
232 Builtin {
233 name: "pre-commit-large-files",
234 stage: Stage::PreCommit,
235 scope: Scope::ALWAYS,
236 severity: Severity::Block,
237 fix: Fix::None,
238 reach: Reach::Safety,
239 run: |ctx| hooks::large_files::staged(ctx.settings),
240 },
241 Builtin {
242 name: "pre-commit-lint-js",
243 stage: Stage::PreCommit,
244 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
245 severity: Severity::Block,
246 fix: Fix::None,
247 reach: Reach::Convention,
248 run: |ctx| hooks::lint_js::run(ctx.settings, ctx.args),
249 },
250 Builtin {
251 name: "pre-commit-lint-json-yaml",
252 stage: Stage::PreCommit,
253 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
254 severity: Severity::Block,
255 fix: Fix::None,
256 reach: Reach::Convention,
257 run: |ctx| hooks::lint_json_yaml::run(ctx.settings, ctx.args),
258 },
259 Builtin {
265 name: "pre-commit-manifest-trust",
266 stage: Stage::PreCommit,
267 scope: Scope::named(&[crate::manifest::MANIFEST]).not_during(MID_OPERATION),
268 severity: Severity::Block,
269 fix: Fix::None,
270 reach: Reach::Safety,
271 run: |ctx| hooks::manifest_trust::run(ctx.settings, ctx.manifest),
272 },
273 Builtin {
274 name: "pre-commit-merge-conflict",
275 stage: Stage::PreCommit,
276 scope: Scope::ALWAYS,
277 severity: Severity::Block,
278 fix: Fix::None,
279 reach: Reach::Safety,
280 run: |ctx| hooks::merge_conflict::run(ctx.settings, ctx.name, ctx.args),
281 },
282 Builtin {
283 name: "pre-commit-package-lock",
284 stage: Stage::PreCommit,
285 scope: Scope::new(&[], &["package.json"]),
286 severity: Severity::Block,
287 fix: Fix::None,
288 reach: Reach::Convention,
289 run: |ctx| hooks::package_lock::run(ctx.settings, ctx.args),
290 },
291 Builtin {
292 name: "pre-commit-prettier",
293 stage: Stage::PreCommit,
294 scope: Scope::new(
295 &[],
296 &[
297 ".prettierrc",
298 ".prettierrc.json",
299 ".prettierrc.yml",
300 ".prettierrc.yaml",
301 ".prettierrc.js",
302 "prettier.config.js",
303 ],
304 )
305 .not_during(MID_OPERATION),
306 severity: Severity::Block,
307 fix: Fix::Rewrite,
308 reach: Reach::Convention,
309 run: |ctx| hooks::prettier::run(ctx.settings, ctx.args),
310 },
311 Builtin {
312 name: "pre-commit-pyright",
313 stage: Stage::PreCommit,
314 scope: Scope::new(
315 hooks::python_tools::EXTS,
316 &[
317 "pyrightconfig.json",
318 "pyrightconfig.jsonc",
319 "pyproject.toml",
320 ],
321 )
322 .not_during(MID_OPERATION),
323 severity: Severity::Block,
324 fix: Fix::None,
325 reach: Reach::Convention,
326 run: |ctx| hooks::python_tools::pyright(ctx.settings, ctx.args),
327 },
328 Builtin {
329 name: "pre-commit-ruff",
330 stage: Stage::PreCommit,
331 scope: Scope::new(
332 hooks::python_tools::EXTS,
333 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
334 )
335 .not_during(MID_OPERATION),
336 severity: Severity::Block,
337 fix: Fix::Rewrite,
338 reach: Reach::Convention,
339 run: |ctx| hooks::python_tools::ruff(ctx.settings, ctx.args),
340 },
341 Builtin {
345 name: "pre-commit-secrets",
346 stage: Stage::PreCommit,
347 scope: Scope::ALWAYS,
348 severity: Severity::Block,
349 fix: Fix::None,
350 reach: Reach::Safety,
351 run: |ctx| hooks::secrets::staged(ctx.settings),
352 },
353 Builtin {
354 name: "pre-commit-usual-name",
355 stage: Stage::PreCommit,
356 scope: Scope::ALWAYS,
357 severity: Severity::Block,
358 fix: Fix::None,
359 reach: Reach::Convention,
360 run: |ctx| hooks::usual_name::run(ctx.args),
361 },
362 Builtin {
363 name: "pre-commit-shellcheck",
364 stage: Stage::PreCommit,
365 scope: Scope::files(hooks::shellcheck::EXTS).not_during(MID_OPERATION),
368 severity: Severity::Block,
369 fix: Fix::None,
370 reach: Reach::Convention,
371 run: |ctx| hooks::shellcheck::run(ctx.settings, ctx.args),
372 },
373 Builtin {
374 name: "pre-commit-hadolint",
375 stage: Stage::PreCommit,
376 scope: Scope::named(hooks::hadolint::NAMES).not_during(MID_OPERATION),
380 severity: Severity::Block,
381 fix: Fix::None,
382 reach: Reach::Convention,
383 run: |ctx| hooks::hadolint::run(ctx.settings, ctx.args),
384 },
385 Builtin {
386 name: "pre-commit-helm-lint",
387 stage: Stage::PreCommit,
388 scope: Scope::new(hooks::helm::EXTS, hooks::helm::MARKERS).not_during(MID_OPERATION),
391 severity: Severity::Block,
392 fix: Fix::None,
393 reach: Reach::Convention,
394 run: |ctx| hooks::helm::run(ctx.settings, ctx.args),
395 },
396 Builtin {
397 name: "pre-commit-yamllint",
398 stage: Stage::PreCommit,
399 scope: Scope::new(
400 hooks::yamllint::EXTS,
401 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
402 )
403 .not_during(MID_OPERATION),
404 severity: Severity::Block,
405 fix: Fix::None,
406 reach: Reach::Convention,
407 run: |ctx| hooks::yamllint::run(ctx.settings, ctx.args),
408 },
409 Builtin {
411 name: "pre-push-branch-protect",
412 stage: Stage::PrePush,
413 scope: Scope::ALWAYS,
414 severity: Severity::Block,
415 fix: Fix::None,
416 reach: Reach::Convention,
417 run: |ctx| hooks::branch_protect::run(ctx.settings, ctx.push.get()),
418 },
419 Builtin {
420 name: "pre-push-branch-pattern",
421 stage: Stage::PrePush,
422 scope: Scope::ALWAYS,
423 severity: Severity::Block,
424 fix: Fix::None,
425 reach: Reach::Convention,
426 run: |ctx| hooks::branch_pattern::run(ctx.settings, ctx.push.get(), ctx.args),
427 },
428 Builtin {
429 name: "pre-push-secrets",
430 stage: Stage::PrePush,
431 scope: Scope::ALWAYS,
432 severity: Severity::Block,
433 fix: Fix::None,
434 reach: Reach::Safety,
435 run: |ctx| hooks::secrets::pushed(ctx.settings, ctx.push.get()),
436 },
437 Builtin {
438 name: "pre-push-pull-rebase",
439 stage: Stage::PrePush,
440 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
441 severity: Severity::Block,
442 fix: Fix::None,
443 reach: Reach::Convention,
444 run: |ctx| hooks::pull_rebase::run(ctx.settings, ctx.args),
445 },
446 Builtin {
453 name: "pre-push-audit-go",
454 stage: Stage::PrePush,
455 scope: Scope::new(&[], &["go.sum"]),
456 severity: Severity::Block,
457 fix: Fix::None,
458 reach: Reach::Convention,
459 run: |ctx| hooks::audit::go(ctx.settings, ctx.push.get()),
460 },
461 Builtin {
465 name: "pre-push-audit-js",
466 stage: Stage::PrePush,
467 scope: Scope::new(&[], &["package-lock.json", "pnpm-lock.yaml"]),
468 severity: Severity::Block,
469 fix: Fix::None,
470 reach: Reach::Convention,
471 run: |ctx| hooks::audit::js(ctx.settings, ctx.push.get()),
472 },
473 Builtin {
478 name: "pre-push-audit-python",
479 stage: Stage::PrePush,
480 scope: Scope::new(&[], &["requirements.txt", "pyproject.toml"]),
481 severity: Severity::Block,
482 fix: Fix::None,
483 reach: Reach::Convention,
484 run: |ctx| hooks::audit::python(ctx.settings, ctx.push.get()),
485 },
486 Builtin {
487 name: "pre-push-audit-rust",
488 stage: Stage::PrePush,
489 scope: Scope::new(&[], &["Cargo.lock"]),
490 severity: Severity::Block,
491 fix: Fix::None,
492 reach: Reach::Convention,
493 run: |ctx| hooks::audit::rust(ctx.settings, ctx.push.get()),
494 },
495 Builtin {
496 name: "pre-push-run-tests-js",
497 stage: Stage::PrePush,
498 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
499 .not_during(&[GitState::Bisect, GitState::Rebase]),
500 severity: Severity::Block,
501 fix: Fix::None,
502 reach: Reach::Convention,
503 run: |ctx| {
504 hooks::run_tests::run(
505 ctx.settings,
506 ctx.push.get(),
507 &ctx.manifest.externals,
508 ctx.name,
509 )
510 },
511 },
512 Builtin {
513 name: "pre-push-cargo-test",
514 stage: Stage::PrePush,
515 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
516 .not_during(&[GitState::Bisect, GitState::Rebase]),
517 severity: Severity::Block,
518 fix: Fix::None,
519 reach: Reach::Convention,
520 run: |ctx| hooks::rust_tools::test(ctx.settings, ctx.push.get(), ctx.name),
521 },
522 Builtin {
523 name: "pre-push-go-test",
524 stage: Stage::PrePush,
525 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
526 .not_during(&[GitState::Bisect, GitState::Rebase]),
527 severity: Severity::Block,
528 fix: Fix::None,
529 reach: Reach::Convention,
530 run: |ctx| hooks::go_tools::test(ctx.settings, ctx.push.get(), ctx.name),
531 },
532 Builtin {
533 name: "pre-push-pytest",
534 stage: Stage::PrePush,
535 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
536 .not_during(&[GitState::Bisect, GitState::Rebase]),
537 severity: Severity::Block,
538 fix: Fix::None,
539 reach: Reach::Convention,
540 run: |ctx| hooks::python_tools::pytest(ctx.settings, ctx.push.get(), ctx.name),
541 },
542];
543
544pub fn severity_of(settings: &crate::config::Settings, check: &dyn Check) -> Severity {
550 effective_override(settings, None, check.name()).unwrap_or_else(|| check.severity())
551}
552
553pub fn severity_key(check: &str) -> String {
555 format!("amont.severity.{check}")
556}
557
558#[derive(Debug, Default, Clone)]
569pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
570
571#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575pub enum Source {
576 Config,
577 Policy,
578}
579
580impl Source {
581 pub fn as_str(self) -> &'static str {
582 match self {
583 Source::Config => "config",
584 Source::Policy => "policy",
585 }
586 }
587}
588
589impl Overrides {
590 pub fn read(settings: &crate::config::Settings) -> Overrides {
597 let policy = settings.policy();
598 match crate::git::stdout(&[
599 "config",
600 "--show-scope",
601 "--get-regexp",
602 r"^amont\.severity\.",
603 ]) {
604 Some(scoped) => Overrides::from_scoped(&scoped, policy),
605 None => Overrides::from_plain_with_policy_below(
609 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
610 policy,
611 ),
612 }
613 }
614
615 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
622 let mut below = String::new();
623 let mut above = String::new();
624 for line in scoped.lines() {
625 let Some((scope, rest)) = line.split_once('\t') else {
626 continue;
627 };
628 match scope {
629 "system" | "global" => {
630 below.push_str(rest);
631 below.push('\n');
632 }
633 _ => {
634 above.push_str(rest);
635 above.push('\n');
636 }
637 }
638 }
639 let mut o = Overrides::default();
640 o.fold_plain(&below, Source::Config);
641 o.fold_policy(policy);
642 o.fold_plain(&above, Source::Config);
643 o
644 }
645
646 pub fn from_plain_with_policy_below(
651 plain: Option<String>,
652 policy: &crate::policy::Policy,
653 ) -> Overrides {
654 let mut o = Overrides::default();
655 o.fold_policy(policy);
656 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
657 o
658 }
659
660 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
663 for (target, severity) in &policy.severities {
664 self.0.insert(target.clone(), (*severity, Source::Policy));
665 }
666 }
667
668 fn fold_plain(&mut self, text: &str, source: Source) {
672 for line in text.lines() {
673 let Some((key, value)) = line.split_once(' ') else {
674 continue;
675 };
676 let Some(check) = key.strip_prefix("amont.severity.") else {
677 continue;
678 };
679 match Severity::parse(value.trim()) {
680 Some(sev) => {
681 self.0.insert(check.to_string(), (sev, source));
682 }
683 None => {
684 self.0.remove(check);
685 }
686 }
687 }
688 }
689
690 pub fn from_config(out: Option<String>) -> Overrides {
698 let mut o = Overrides::default();
699 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
700 o
701 }
702
703 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
710 self.applied_with_source(check)
711 .map(|(pattern, severity, _)| (pattern, severity))
712 }
713
714 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
718 self.0
719 .iter()
720 .filter_map(|(pattern, (severity, source))| {
721 crate::names_check(check, pattern)
722 .map(|m| (m, pattern.as_str(), *severity, *source))
723 })
724 .max_by_key(|(m, _, _, _)| *m)
725 .map(|(_, pattern, severity, source)| (pattern, severity, source))
726 }
727
728 pub fn of(&self, check: &dyn Check) -> Severity {
730 self.applied_to(check.name())
731 .map(|(_, severity)| severity)
732 .unwrap_or_else(|| check.severity())
733 }
734}
735
736#[cfg(test)]
737mod precedence {
738 use super::{Overrides, Severity};
739
740 fn overrides(lines: &[&str]) -> Overrides {
741 let text = lines
742 .iter()
743 .map(|l| format!("amont.severity.{l}\n"))
744 .collect::<String>();
745 Overrides::from_config(Some(text))
746 }
747
748 #[test]
752 fn the_more_specific_key_wins() {
753 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
754 assert_eq!(
755 both.applied_to("pre-commit-clippy"),
756 Some(("pre-commit-clippy", Severity::Block)),
757 "a full id beats its trigger"
758 );
759 assert_eq!(
760 both.applied_to("pre-commit-shellcheck"),
761 Some(("pre-commit", Severity::Warn)),
762 "and the trigger still governs every check it did not exempt"
763 );
764 }
765
766 #[test]
769 fn the_three_ways_to_name_a_check_are_ranked() {
770 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
771 assert_eq!(
772 all.applied_to("pre-commit-clippy"),
773 Some(("pre-commit-clippy", Severity::Warn))
774 );
775
776 let no_full = overrides(&["pre-commit warn", "clippy block"]);
777 assert_eq!(
778 no_full.applied_to("pre-commit-clippy"),
779 Some(("clippy", Severity::Block)),
780 "a short name beats a trigger"
781 );
782 }
783
784 #[test]
787 fn a_key_that_names_no_check_applies_to_nothing() {
788 let typo = overrides(&["clipy warn", "e warn", " warn"]);
789 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
790 }
791}
792
793pub fn effective_override(
803 settings: &crate::config::Settings,
804 repo: Option<&Path>,
805 check: &str,
806) -> Option<Severity> {
807 overrides_in(settings, repo)
808 .applied_to(check)
809 .map(|(_, s)| s)
810}
811
812pub fn effective_key(
818 settings: &crate::config::Settings,
819 repo: Option<&Path>,
820 check: &str,
821) -> Option<String> {
822 overrides_in(settings, repo)
823 .applied_to(check)
824 .map(|(pattern, _)| pattern.to_string())
825}
826
827fn overrides_in(settings: &crate::config::Settings, repo: Option<&Path>) -> Overrides {
828 match repo {
829 None => Overrides::read(settings),
833 Some(dir) => Overrides::from_config(crate::git::stdout_in(
837 dir,
838 &["config", "--get-regexp", r"^amont\.severity\."],
839 )),
840 }
841}
842
843pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
845 CHECKS.iter().filter(move |check| check.stage == stage)
846}
847
848pub fn all_stage_checks<'a>(
855 stage: Stage,
856 manifest: &'a crate::manifest::Manifest,
857) -> Vec<&'a dyn Check> {
858 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
859 .map(|check| check as &dyn Check)
860 .collect();
861 out.extend(
862 manifest
863 .externals
864 .iter()
865 .filter(|external| external.stage == stage)
866 .map(|external| external as &dyn Check),
867 );
868 out
869}
870
871pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
872 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
873 return Some(*f);
874 }
875 if CHECKS.iter().any(|check| check.name == name)
882 || manifest
883 .externals
884 .iter()
885 .any(|external| external.id == name)
886 {
887 return Some(|ctx: &Ctx| {
888 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
889 Verdict::blocking(matches!(
890 (check.run(ctx), severity_of(ctx.settings, check)),
891 (Outcome::Failed, Severity::Block)
892 ))
893 });
894 }
895 None
896}
897
898pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
902 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
903 return Some(builtin);
904 }
905 manifest
906 .externals
907 .iter()
908 .find(|external| external.id == name)
909 .map(|external| external as &dyn Check)
910}
911
912#[cfg(test)]
913mod tests {
914 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
915 use std::collections::BTreeSet;
916
917 #[test]
924 fn the_batch_agrees_with_the_authority() {
925 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
926 let _ = std::fs::remove_dir_all(&d);
927 std::fs::create_dir_all(&d).unwrap();
928 let git = |args: &[&str]| {
929 std::process::Command::new("git")
930 .args(args)
931 .current_dir(&d)
932 .output()
933 .expect("git");
934 };
935 git(&["init", "-q", "--template=", "."]);
936 let key = "amont.severity.pre-commit-merge-conflict";
937 git(&["config", "--add", key, "warn"]);
938 git(&["config", "--add", key, "block"]);
939
940 let raw = std::process::Command::new("git")
941 .args(["config", "--get-regexp", r"^amont\.severity\."])
942 .current_dir(&d)
943 .output()
944 .expect("git");
945 let batch = Overrides::from_config(Some(
946 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
947 ));
948 let authority =
949 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
950 let _ = std::fs::remove_dir_all(&d);
951
952 assert_eq!(
953 authority,
954 Some(Severity::Block),
955 "git applies the last entry"
956 );
957 assert_eq!(
958 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
959 authority,
960 "the batch reader disagreed with `--get`"
961 );
962 }
963
964 #[test]
968 fn an_unrecognised_value_clears_rather_than_overrides() {
969 let o = Overrides::from_config(Some(
970 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
971 ));
972 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
973 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
974 }
975
976 #[test]
977 fn names_are_unique_across_entrypoints_and_checks() {
978 let mut seen = BTreeSet::new();
979 for n in ENTRYPOINTS
980 .iter()
981 .map(|(n, _)| *n)
982 .chain(CHECKS.iter().map(|check| check.name))
983 {
984 assert!(seen.insert(n), "duplicate registration: {n}");
985 }
986 }
987
988 #[test]
990 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
991 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
992 let mut shipped: Vec<String> = std::fs::read_dir(dir)
993 .expect("templates/hooks")
994 .flatten()
995 .map(|entry| entry.file_name().to_string_lossy().into_owned())
996 .collect();
997 shipped.sort();
998 assert_eq!(
999 shipped,
1000 vec![
1001 "commit-msg",
1002 "post-commit",
1003 "post-rewrite",
1004 "pre-commit",
1005 "pre-push",
1006 "prepare-commit-msg"
1007 ]
1008 );
1009 let none = crate::manifest::Manifest::default();
1010 for name in &shipped {
1011 assert!(
1012 lookup(name, &none).is_some(),
1013 "shipped shim {name:?} has no handler"
1014 );
1015 }
1016 }
1017
1018 #[test]
1021 fn every_check_is_reachable_by_name() {
1022 let none = crate::manifest::Manifest::default();
1023 for check in CHECKS {
1024 assert!(
1025 lookup(check.name, &none).is_some(),
1026 "{} not reachable",
1027 check.name
1028 );
1029 }
1030 assert!(lookup("pre-commit-not-a-check", &none).is_none());
1031 }
1032
1033 #[test]
1035 fn pre_push_runs_cheapest_first() {
1036 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
1037 .map(|check| check.name)
1038 .collect();
1039 assert_eq!(
1040 order,
1041 vec![
1042 "pre-push-branch-protect",
1043 "pre-push-branch-pattern",
1044 "pre-push-secrets",
1045 "pre-push-pull-rebase",
1046 "pre-push-audit-go",
1047 "pre-push-audit-js",
1048 "pre-push-audit-python",
1049 "pre-push-audit-rust",
1050 "pre-push-run-tests-js",
1051 "pre-push-cargo-test",
1052 "pre-push-go-test",
1053 "pre-push-pytest",
1054 ]
1055 );
1056 }
1057
1058 enum Consumes {
1064 All,
1065 Exts(&'static [&'static str]),
1066 }
1067
1068 const CONSUMED: &[(&str, Consumes)] = &[
1082 (
1083 "pre-commit-argo-lint",
1084 Consumes::Exts(crate::hooks::k8s::EXTS),
1085 ),
1086 (
1090 "pre-commit-ban-terms",
1091 Consumes::Exts(crate::hooks::ban_terms::EXTS),
1092 ),
1093 (
1094 "pre-commit-cargo-fmt",
1095 Consumes::Exts(crate::hooks::rust_tools::EXTS),
1096 ),
1097 (
1098 "pre-commit-clippy",
1099 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1100 ),
1101 (
1102 "pre-commit-go-vet",
1103 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1104 ),
1105 (
1106 "pre-commit-gofmt",
1107 Consumes::Exts(crate::hooks::go_tools::EXTS),
1108 ),
1109 (
1110 "pre-commit-kube-linter",
1111 Consumes::Exts(crate::hooks::k8s::EXTS),
1112 ),
1113 (
1114 "pre-commit-kubeconform",
1115 Consumes::Exts(crate::hooks::k8s::EXTS),
1116 ),
1117 (
1118 "pre-commit-lint-js",
1119 Consumes::Exts(crate::hooks::lint_js::EXTS),
1120 ),
1121 (
1122 "pre-commit-lint-json-yaml",
1123 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1124 ),
1125 ("pre-commit-merge-conflict", Consumes::All),
1126 ("pre-commit-package-lock", Consumes::All),
1127 (
1131 "pre-commit-prettier",
1132 Consumes::Exts(crate::hooks::prettier::EXTS),
1133 ),
1134 (
1135 "pre-commit-pyright",
1136 Consumes::Exts(crate::hooks::python_tools::EXTS),
1137 ),
1138 (
1139 "pre-commit-ruff",
1140 Consumes::Exts(crate::hooks::python_tools::EXTS),
1141 ),
1142 ("pre-commit-usual-name", Consumes::All),
1143 (
1144 "pre-commit-yamllint",
1145 Consumes::Exts(crate::hooks::yamllint::EXTS),
1146 ),
1147 (
1148 "pre-commit-shellcheck",
1149 Consumes::Exts(crate::hooks::shellcheck::EXTS),
1150 ),
1151 (
1152 "pre-commit-hadolint",
1153 Consumes::Exts(crate::hooks::hadolint::NAMES),
1154 ),
1155 (
1156 "pre-commit-helm-lint",
1157 Consumes::Exts(crate::hooks::helm::EXTS),
1158 ),
1159 ("pre-commit-large-files", Consumes::All),
1160 ("pre-commit-secrets", Consumes::All),
1161 ("pre-push-secrets", Consumes::All),
1162 ("pre-push-branch-protect", Consumes::All),
1163 ("pre-push-branch-pattern", Consumes::All),
1164 ("pre-push-pull-rebase", Consumes::All),
1165 (
1166 "pre-push-run-tests-js",
1167 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1168 ),
1169 (
1170 "pre-push-pytest",
1171 Consumes::Exts(crate::hooks::python_tools::EXTS),
1172 ),
1173 (
1174 "pre-push-cargo-test",
1175 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1176 ),
1177 (
1178 "pre-push-go-test",
1179 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1180 ),
1181 ];
1182
1183 #[test]
1194 fn no_check_declares_a_file_type_it_does_not_consume() {
1195 for (name, _) in CONSUMED {
1196 assert!(
1197 CHECKS.iter().any(|check| check.name == *name),
1198 "CONSUMED names {name:?}, which is not a check"
1199 );
1200 }
1201 for check in CHECKS {
1202 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1203 if check.scope.files.is_empty() && entry.is_none() {
1204 continue;
1205 }
1206 let Some((_, consumes)) = entry else {
1207 panic!(
1208 "{} declares scope.files {:?} but is missing from CONSUMED — \
1209 say what it actually reads",
1210 check.name, check.scope.files
1211 );
1212 };
1213 let Consumes::Exts(consumed) = consumes else {
1214 continue; };
1216 for ext in check.scope.files {
1217 assert!(
1218 consumed.contains(ext),
1219 "{} declares {ext:?} in its scope but never asks for it — \
1220 `amont list` would report a coverage the check does not have",
1221 check.name
1222 );
1223 }
1224 }
1225 }
1226
1227 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1236 ("pre-commit-agents-md", true),
1237 ("pre-commit-argo-lint", false),
1238 ("pre-commit-ban-terms", false),
1239 ("pre-commit-branch-pattern", false),
1240 ("pre-commit-branch-protect", false),
1241 ("pre-commit-cargo-fmt", true),
1242 ("pre-commit-clippy", false),
1243 ("pre-commit-go-vet", false),
1244 ("pre-commit-gofmt", true),
1245 ("pre-commit-kube-linter", false),
1246 ("pre-commit-kubeconform", false),
1247 ("pre-commit-lint-js", false),
1248 ("pre-commit-lint-json-yaml", false),
1249 ("pre-commit-manifest-trust", false),
1250 ("pre-commit-merge-conflict", false),
1251 ("pre-commit-package-lock", false),
1252 ("pre-commit-prettier", true),
1253 ("pre-commit-pyright", false),
1254 ("pre-commit-ruff", true),
1255 ("pre-commit-usual-name", false),
1256 ("pre-commit-yamllint", false),
1257 ("pre-commit-shellcheck", false),
1258 ("pre-commit-hadolint", false),
1259 ("pre-commit-helm-lint", false),
1260 ("pre-commit-large-files", false),
1261 ("pre-commit-secrets", false),
1262 ("pre-push-secrets", false),
1263 ("pre-push-branch-protect", false),
1264 ("pre-push-branch-pattern", false),
1265 ("pre-push-pull-rebase", false),
1266 ("pre-push-audit-go", false),
1267 ("pre-push-audit-js", false),
1268 ("pre-push-audit-python", false),
1269 ("pre-push-audit-rust", false),
1270 ("pre-push-run-tests-js", false),
1271 ("pre-push-cargo-test", false),
1272 ("pre-push-go-test", false),
1273 ("pre-push-pytest", false),
1274 ];
1275
1276 #[test]
1279 fn every_rewrite_declaration_has_a_fixer() {
1280 let declared: BTreeSet<&str> = CHECKS
1281 .iter()
1282 .filter(|check| check.fix == super::Fix::Rewrite)
1283 .map(|check| check.name)
1284 .collect();
1285 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1286 .iter()
1287 .filter(|(_, has)| *has)
1288 .map(|(name, _)| *name)
1289 .collect();
1290 assert_eq!(
1291 declared, implemented,
1292 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1293 and a check with a fixer that does not declare it can never be reached"
1294 );
1295
1296 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1299 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1300 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1301 }
1302
1303 #[test]
1314 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1315 let safety: Vec<&str> = CHECKS
1316 .iter()
1317 .filter(|c| c.reach == Reach::Safety)
1318 .map(|c| c.name)
1319 .collect();
1320 assert_eq!(
1321 safety,
1322 vec![
1323 "pre-commit-ban-terms",
1324 "pre-commit-large-files",
1325 "pre-commit-manifest-trust",
1326 "pre-commit-merge-conflict",
1327 "pre-commit-secrets",
1328 "pre-push-secrets",
1329 ]
1330 );
1331 }
1332
1333 #[test]
1334 fn every_check_declares_a_stage_and_a_scope() {
1335 assert_eq!(CHECKS.len(), 38);
1336 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1337 let pre_push = super::stage_checks(Stage::PrePush).count();
1338 assert_eq!(
1339 pre_commit + pre_push,
1340 CHECKS.len(),
1341 "every check has a stage"
1342 );
1343 }
1344}