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 {
357 name: "pre-commit-tree-parity",
358 stage: Stage::PreCommit,
359 scope: Scope {
360 files: hooks::k8s::EXTS,
361 names: &[crate::manifest::MANIFEST],
362 dirs: &[],
363 opt_in: &[crate::manifest::MANIFEST],
364 not_during: MID_OPERATION,
365 },
366 severity: Severity::Block,
367 fix: Fix::None,
368 reach: Reach::Convention,
369 run: |ctx| hooks::tree_parity::run(ctx.settings),
370 },
371 Builtin {
372 name: "pre-commit-usual-name",
373 stage: Stage::PreCommit,
374 scope: Scope::ALWAYS,
375 severity: Severity::Block,
376 fix: Fix::None,
377 reach: Reach::Convention,
378 run: |ctx| hooks::usual_name::run(ctx.args),
379 },
380 Builtin {
381 name: "pre-commit-shellcheck",
382 stage: Stage::PreCommit,
383 scope: Scope::files(hooks::shellcheck::EXTS).not_during(MID_OPERATION),
386 severity: Severity::Block,
387 fix: Fix::None,
388 reach: Reach::Convention,
389 run: |ctx| hooks::shellcheck::run(ctx.settings, ctx.args),
390 },
391 Builtin {
392 name: "pre-commit-hadolint",
393 stage: Stage::PreCommit,
394 scope: Scope::named(hooks::hadolint::NAMES).not_during(MID_OPERATION),
398 severity: Severity::Block,
399 fix: Fix::None,
400 reach: Reach::Convention,
401 run: |ctx| hooks::hadolint::run(ctx.settings, ctx.args),
402 },
403 Builtin {
404 name: "pre-commit-helm-lint",
405 stage: Stage::PreCommit,
406 scope: Scope::new(hooks::helm::EXTS, hooks::helm::MARKERS).not_during(MID_OPERATION),
409 severity: Severity::Block,
410 fix: Fix::None,
411 reach: Reach::Convention,
412 run: |ctx| hooks::helm::run(ctx.settings, ctx.args),
413 },
414 Builtin {
415 name: "pre-commit-yamllint",
416 stage: Stage::PreCommit,
417 scope: Scope::new(
418 hooks::yamllint::EXTS,
419 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
420 )
421 .not_during(MID_OPERATION),
422 severity: Severity::Block,
423 fix: Fix::None,
424 reach: Reach::Convention,
425 run: |ctx| hooks::yamllint::run(ctx.settings, ctx.args),
426 },
427 Builtin {
429 name: "pre-push-branch-protect",
430 stage: Stage::PrePush,
431 scope: Scope::ALWAYS,
432 severity: Severity::Block,
433 fix: Fix::None,
434 reach: Reach::Convention,
435 run: |ctx| hooks::branch_protect::run(ctx.settings, ctx.push.get()),
436 },
437 Builtin {
438 name: "pre-push-branch-pattern",
439 stage: Stage::PrePush,
440 scope: Scope::ALWAYS,
441 severity: Severity::Block,
442 fix: Fix::None,
443 reach: Reach::Convention,
444 run: |ctx| hooks::branch_pattern::run(ctx.settings, ctx.push.get(), ctx.args),
445 },
446 Builtin {
447 name: "pre-push-secrets",
448 stage: Stage::PrePush,
449 scope: Scope::ALWAYS,
450 severity: Severity::Block,
451 fix: Fix::None,
452 reach: Reach::Safety,
453 run: |ctx| hooks::secrets::pushed(ctx.settings, ctx.push.get()),
454 },
455 Builtin {
456 name: "pre-push-pull-rebase",
457 stage: Stage::PrePush,
458 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
459 severity: Severity::Block,
460 fix: Fix::None,
461 reach: Reach::Convention,
462 run: |ctx| hooks::pull_rebase::run(ctx.settings, ctx.args),
463 },
464 Builtin {
471 name: "pre-push-audit-go",
472 stage: Stage::PrePush,
473 scope: Scope::new(&[], &["go.sum"]),
474 severity: Severity::Block,
475 fix: Fix::None,
476 reach: Reach::Convention,
477 run: |ctx| hooks::audit::go(ctx.settings, ctx.push.get()),
478 },
479 Builtin {
483 name: "pre-push-audit-js",
484 stage: Stage::PrePush,
485 scope: Scope::new(&[], &["package-lock.json", "pnpm-lock.yaml"]),
486 severity: Severity::Block,
487 fix: Fix::None,
488 reach: Reach::Convention,
489 run: |ctx| hooks::audit::js(ctx.settings, ctx.push.get()),
490 },
491 Builtin {
496 name: "pre-push-audit-python",
497 stage: Stage::PrePush,
498 scope: Scope::new(&[], &["requirements.txt", "pyproject.toml"]),
499 severity: Severity::Block,
500 fix: Fix::None,
501 reach: Reach::Convention,
502 run: |ctx| hooks::audit::python(ctx.settings, ctx.push.get()),
503 },
504 Builtin {
505 name: "pre-push-audit-rust",
506 stage: Stage::PrePush,
507 scope: Scope::new(&[], &["Cargo.lock"]),
508 severity: Severity::Block,
509 fix: Fix::None,
510 reach: Reach::Convention,
511 run: |ctx| hooks::audit::rust(ctx.settings, ctx.push.get()),
512 },
513 Builtin {
514 name: "pre-push-run-tests-js",
515 stage: Stage::PrePush,
516 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
517 .not_during(&[GitState::Bisect, GitState::Rebase]),
518 severity: Severity::Block,
519 fix: Fix::None,
520 reach: Reach::Convention,
521 run: |ctx| {
522 hooks::run_tests::run(
523 ctx.settings,
524 ctx.push.get(),
525 &ctx.manifest.externals,
526 ctx.name,
527 )
528 },
529 },
530 Builtin {
531 name: "pre-push-cargo-test",
532 stage: Stage::PrePush,
533 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
534 .not_during(&[GitState::Bisect, GitState::Rebase]),
535 severity: Severity::Block,
536 fix: Fix::None,
537 reach: Reach::Convention,
538 run: |ctx| hooks::rust_tools::test(ctx.settings, ctx.push.get(), ctx.name),
539 },
540 Builtin {
541 name: "pre-push-go-test",
542 stage: Stage::PrePush,
543 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
544 .not_during(&[GitState::Bisect, GitState::Rebase]),
545 severity: Severity::Block,
546 fix: Fix::None,
547 reach: Reach::Convention,
548 run: |ctx| hooks::go_tools::test(ctx.settings, ctx.push.get(), ctx.name),
549 },
550 Builtin {
551 name: "pre-push-pytest",
552 stage: Stage::PrePush,
553 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
554 .not_during(&[GitState::Bisect, GitState::Rebase]),
555 severity: Severity::Block,
556 fix: Fix::None,
557 reach: Reach::Convention,
558 run: |ctx| hooks::python_tools::pytest(ctx.settings, ctx.push.get(), ctx.name),
559 },
560];
561
562pub fn severity_of(settings: &crate::config::Settings, check: &dyn Check) -> Severity {
568 effective_override(settings, None, check.name()).unwrap_or_else(|| check.severity())
569}
570
571pub fn severity_key(check: &str) -> String {
573 format!("amont.severity.{check}")
574}
575
576#[derive(Debug, Default, Clone)]
587pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593pub enum Source {
594 Config,
595 Policy,
596}
597
598impl Source {
599 pub fn as_str(self) -> &'static str {
600 match self {
601 Source::Config => "config",
602 Source::Policy => "policy",
603 }
604 }
605}
606
607impl Overrides {
608 pub fn read(settings: &crate::config::Settings) -> Overrides {
615 let policy = settings.policy();
616 match crate::git::stdout(&[
617 "config",
618 "--show-scope",
619 "--get-regexp",
620 r"^amont\.severity\.",
621 ]) {
622 Some(scoped) => Overrides::from_scoped(&scoped, policy),
623 None => Overrides::from_plain_with_policy_below(
627 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
628 policy,
629 ),
630 }
631 }
632
633 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
640 let mut below = String::new();
641 let mut above = String::new();
642 for line in scoped.lines() {
643 let Some((scope, rest)) = line.split_once('\t') else {
644 continue;
645 };
646 match scope {
647 "system" | "global" => {
648 below.push_str(rest);
649 below.push('\n');
650 }
651 _ => {
652 above.push_str(rest);
653 above.push('\n');
654 }
655 }
656 }
657 let mut o = Overrides::default();
658 o.fold_plain(&below, Source::Config);
659 o.fold_policy(policy);
660 o.fold_plain(&above, Source::Config);
661 o
662 }
663
664 pub fn from_plain_with_policy_below(
669 plain: Option<String>,
670 policy: &crate::policy::Policy,
671 ) -> Overrides {
672 let mut o = Overrides::default();
673 o.fold_policy(policy);
674 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
675 o
676 }
677
678 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
681 for (target, severity) in &policy.severities {
682 self.0.insert(target.clone(), (*severity, Source::Policy));
683 }
684 }
685
686 fn fold_plain(&mut self, text: &str, source: Source) {
690 for line in text.lines() {
691 let Some((key, value)) = line.split_once(' ') else {
692 continue;
693 };
694 let Some(check) = key.strip_prefix("amont.severity.") else {
695 continue;
696 };
697 match Severity::parse(value.trim()) {
698 Some(sev) => {
699 self.0.insert(check.to_string(), (sev, source));
700 }
701 None => {
702 self.0.remove(check);
703 }
704 }
705 }
706 }
707
708 pub fn from_config(out: Option<String>) -> Overrides {
716 let mut o = Overrides::default();
717 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
718 o
719 }
720
721 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
728 self.applied_with_source(check)
729 .map(|(pattern, severity, _)| (pattern, severity))
730 }
731
732 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
736 self.0
737 .iter()
738 .filter_map(|(pattern, (severity, source))| {
739 crate::names_check(check, pattern)
740 .map(|m| (m, pattern.as_str(), *severity, *source))
741 })
742 .max_by_key(|(m, _, _, _)| *m)
743 .map(|(_, pattern, severity, source)| (pattern, severity, source))
744 }
745
746 pub fn of(&self, check: &dyn Check) -> Severity {
748 self.applied_to(check.name())
749 .map(|(_, severity)| severity)
750 .unwrap_or_else(|| check.severity())
751 }
752}
753
754#[cfg(test)]
755mod precedence {
756 use super::{Overrides, Severity};
757
758 fn overrides(lines: &[&str]) -> Overrides {
759 let text = lines
760 .iter()
761 .map(|l| format!("amont.severity.{l}\n"))
762 .collect::<String>();
763 Overrides::from_config(Some(text))
764 }
765
766 #[test]
770 fn the_more_specific_key_wins() {
771 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
772 assert_eq!(
773 both.applied_to("pre-commit-clippy"),
774 Some(("pre-commit-clippy", Severity::Block)),
775 "a full id beats its trigger"
776 );
777 assert_eq!(
778 both.applied_to("pre-commit-shellcheck"),
779 Some(("pre-commit", Severity::Warn)),
780 "and the trigger still governs every check it did not exempt"
781 );
782 }
783
784 #[test]
787 fn the_three_ways_to_name_a_check_are_ranked() {
788 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
789 assert_eq!(
790 all.applied_to("pre-commit-clippy"),
791 Some(("pre-commit-clippy", Severity::Warn))
792 );
793
794 let no_full = overrides(&["pre-commit warn", "clippy block"]);
795 assert_eq!(
796 no_full.applied_to("pre-commit-clippy"),
797 Some(("clippy", Severity::Block)),
798 "a short name beats a trigger"
799 );
800 }
801
802 #[test]
805 fn a_key_that_names_no_check_applies_to_nothing() {
806 let typo = overrides(&["clipy warn", "e warn", " warn"]);
807 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
808 }
809}
810
811pub fn effective_override(
821 settings: &crate::config::Settings,
822 repo: Option<&Path>,
823 check: &str,
824) -> Option<Severity> {
825 overrides_in(settings, repo)
826 .applied_to(check)
827 .map(|(_, s)| s)
828}
829
830pub fn effective_key(
836 settings: &crate::config::Settings,
837 repo: Option<&Path>,
838 check: &str,
839) -> Option<String> {
840 overrides_in(settings, repo)
841 .applied_to(check)
842 .map(|(pattern, _)| pattern.to_string())
843}
844
845fn overrides_in(settings: &crate::config::Settings, repo: Option<&Path>) -> Overrides {
846 match repo {
847 None => Overrides::read(settings),
851 Some(dir) => Overrides::from_config(crate::git::stdout_in(
855 dir,
856 &["config", "--get-regexp", r"^amont\.severity\."],
857 )),
858 }
859}
860
861pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
863 CHECKS.iter().filter(move |check| check.stage == stage)
864}
865
866pub fn all_stage_checks<'a>(
873 stage: Stage,
874 manifest: &'a crate::manifest::Manifest,
875) -> Vec<&'a dyn Check> {
876 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
877 .map(|check| check as &dyn Check)
878 .collect();
879 out.extend(
880 manifest
881 .externals
882 .iter()
883 .filter(|external| external.stage == stage)
884 .map(|external| external as &dyn Check),
885 );
886 out
887}
888
889pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
890 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
891 return Some(*f);
892 }
893 if CHECKS.iter().any(|check| check.name == name)
900 || manifest
901 .externals
902 .iter()
903 .any(|external| external.id == name)
904 {
905 return Some(|ctx: &Ctx| {
906 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
907 let _slot = crate::host_slots::enter_check(check.name());
910 Verdict::blocking(matches!(
911 (check.run(ctx), severity_of(ctx.settings, check)),
912 (Outcome::Failed, Severity::Block)
913 ))
914 });
915 }
916 None
917}
918
919pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
923 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
924 return Some(builtin);
925 }
926 manifest
927 .externals
928 .iter()
929 .find(|external| external.id == name)
930 .map(|external| external as &dyn Check)
931}
932
933#[cfg(test)]
934mod tests {
935 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
936 use std::collections::BTreeSet;
937
938 #[test]
945 fn the_batch_agrees_with_the_authority() {
946 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
947 let _ = std::fs::remove_dir_all(&d);
948 std::fs::create_dir_all(&d).unwrap();
949 let git = |args: &[&str]| {
950 std::process::Command::new("git")
951 .args(args)
952 .current_dir(&d)
953 .output()
954 .expect("git");
955 };
956 git(&["init", "-q", "--template=", "."]);
957 let key = "amont.severity.pre-commit-merge-conflict";
958 git(&["config", "--add", key, "warn"]);
959 git(&["config", "--add", key, "block"]);
960
961 let raw = std::process::Command::new("git")
962 .args(["config", "--get-regexp", r"^amont\.severity\."])
963 .current_dir(&d)
964 .output()
965 .expect("git");
966 let batch = Overrides::from_config(Some(
967 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
968 ));
969 let authority =
970 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
971 let _ = std::fs::remove_dir_all(&d);
972
973 assert_eq!(
974 authority,
975 Some(Severity::Block),
976 "git applies the last entry"
977 );
978 assert_eq!(
979 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
980 authority,
981 "the batch reader disagreed with `--get`"
982 );
983 }
984
985 #[test]
989 fn an_unrecognised_value_clears_rather_than_overrides() {
990 let o = Overrides::from_config(Some(
991 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
992 ));
993 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
994 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
995 }
996
997 #[test]
998 fn names_are_unique_across_entrypoints_and_checks() {
999 let mut seen = BTreeSet::new();
1000 for n in ENTRYPOINTS
1001 .iter()
1002 .map(|(n, _)| *n)
1003 .chain(CHECKS.iter().map(|check| check.name))
1004 {
1005 assert!(seen.insert(n), "duplicate registration: {n}");
1006 }
1007 }
1008
1009 #[test]
1011 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
1012 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
1013 let mut shipped: Vec<String> = std::fs::read_dir(dir)
1014 .expect("templates/hooks")
1015 .flatten()
1016 .map(|entry| entry.file_name().to_string_lossy().into_owned())
1017 .collect();
1018 shipped.sort();
1019 assert_eq!(
1020 shipped,
1021 vec![
1022 "commit-msg",
1023 "post-commit",
1024 "post-rewrite",
1025 "pre-commit",
1026 "pre-push",
1027 "prepare-commit-msg"
1028 ]
1029 );
1030 let none = crate::manifest::Manifest::default();
1031 for name in &shipped {
1032 assert!(
1033 lookup(name, &none).is_some(),
1034 "shipped shim {name:?} has no handler"
1035 );
1036 }
1037 }
1038
1039 #[test]
1042 fn every_check_is_reachable_by_name() {
1043 let none = crate::manifest::Manifest::default();
1044 for check in CHECKS {
1045 assert!(
1046 lookup(check.name, &none).is_some(),
1047 "{} not reachable",
1048 check.name
1049 );
1050 }
1051 assert!(lookup("pre-commit-not-a-check", &none).is_none());
1052 }
1053
1054 #[test]
1056 fn pre_push_runs_cheapest_first() {
1057 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
1058 .map(|check| check.name)
1059 .collect();
1060 assert_eq!(
1061 order,
1062 vec![
1063 "pre-push-branch-protect",
1064 "pre-push-branch-pattern",
1065 "pre-push-secrets",
1066 "pre-push-pull-rebase",
1067 "pre-push-audit-go",
1068 "pre-push-audit-js",
1069 "pre-push-audit-python",
1070 "pre-push-audit-rust",
1071 "pre-push-run-tests-js",
1072 "pre-push-cargo-test",
1073 "pre-push-go-test",
1074 "pre-push-pytest",
1075 ]
1076 );
1077 }
1078
1079 enum Consumes {
1085 All,
1086 Exts(&'static [&'static str]),
1087 }
1088
1089 const CONSUMED: &[(&str, Consumes)] = &[
1103 (
1104 "pre-commit-argo-lint",
1105 Consumes::Exts(crate::hooks::k8s::EXTS),
1106 ),
1107 (
1111 "pre-commit-ban-terms",
1112 Consumes::Exts(crate::hooks::ban_terms::EXTS),
1113 ),
1114 (
1115 "pre-commit-cargo-fmt",
1116 Consumes::Exts(crate::hooks::rust_tools::EXTS),
1117 ),
1118 (
1119 "pre-commit-clippy",
1120 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1121 ),
1122 (
1123 "pre-commit-go-vet",
1124 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1125 ),
1126 (
1127 "pre-commit-gofmt",
1128 Consumes::Exts(crate::hooks::go_tools::EXTS),
1129 ),
1130 (
1131 "pre-commit-kube-linter",
1132 Consumes::Exts(crate::hooks::k8s::EXTS),
1133 ),
1134 (
1135 "pre-commit-kubeconform",
1136 Consumes::Exts(crate::hooks::k8s::EXTS),
1137 ),
1138 (
1139 "pre-commit-lint-js",
1140 Consumes::Exts(crate::hooks::lint_js::EXTS),
1141 ),
1142 (
1143 "pre-commit-lint-json-yaml",
1144 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1145 ),
1146 ("pre-commit-merge-conflict", Consumes::All),
1147 ("pre-commit-package-lock", Consumes::All),
1148 (
1152 "pre-commit-prettier",
1153 Consumes::Exts(crate::hooks::prettier::EXTS),
1154 ),
1155 (
1156 "pre-commit-pyright",
1157 Consumes::Exts(crate::hooks::python_tools::EXTS),
1158 ),
1159 (
1160 "pre-commit-ruff",
1161 Consumes::Exts(crate::hooks::python_tools::EXTS),
1162 ),
1163 ("pre-commit-usual-name", Consumes::All),
1164 (
1165 "pre-commit-yamllint",
1166 Consumes::Exts(crate::hooks::yamllint::EXTS),
1167 ),
1168 (
1169 "pre-commit-shellcheck",
1170 Consumes::Exts(crate::hooks::shellcheck::EXTS),
1171 ),
1172 (
1173 "pre-commit-hadolint",
1174 Consumes::Exts(crate::hooks::hadolint::NAMES),
1175 ),
1176 (
1177 "pre-commit-helm-lint",
1178 Consumes::Exts(crate::hooks::helm::EXTS),
1179 ),
1180 ("pre-commit-large-files", Consumes::All),
1181 ("pre-commit-secrets", Consumes::All),
1182 ("pre-push-secrets", Consumes::All),
1183 ("pre-push-branch-protect", Consumes::All),
1184 ("pre-push-branch-pattern", Consumes::All),
1185 ("pre-push-pull-rebase", Consumes::All),
1186 (
1187 "pre-push-run-tests-js",
1188 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1189 ),
1190 (
1191 "pre-push-pytest",
1192 Consumes::Exts(crate::hooks::python_tools::EXTS),
1193 ),
1194 (
1195 "pre-push-cargo-test",
1196 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1197 ),
1198 (
1199 "pre-push-go-test",
1200 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1201 ),
1202 (
1204 "pre-commit-tree-parity",
1205 Consumes::Exts(crate::hooks::k8s::EXTS),
1206 ),
1207 ];
1208
1209 #[test]
1220 fn no_check_declares_a_file_type_it_does_not_consume() {
1221 for (name, _) in CONSUMED {
1222 assert!(
1223 CHECKS.iter().any(|check| check.name == *name),
1224 "CONSUMED names {name:?}, which is not a check"
1225 );
1226 }
1227 for check in CHECKS {
1228 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1229 if check.scope.files.is_empty() && entry.is_none() {
1230 continue;
1231 }
1232 let Some((_, consumes)) = entry else {
1233 panic!(
1234 "{} declares scope.files {:?} but is missing from CONSUMED — \
1235 say what it actually reads",
1236 check.name, check.scope.files
1237 );
1238 };
1239 let Consumes::Exts(consumed) = consumes else {
1240 continue; };
1242 for ext in check.scope.files {
1243 assert!(
1244 consumed.contains(ext),
1245 "{} declares {ext:?} in its scope but never asks for it — \
1246 `amont list` would report a coverage the check does not have",
1247 check.name
1248 );
1249 }
1250 }
1251 }
1252
1253 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1262 ("pre-commit-agents-md", true),
1263 ("pre-commit-argo-lint", false),
1264 ("pre-commit-ban-terms", false),
1265 ("pre-commit-branch-pattern", false),
1266 ("pre-commit-branch-protect", false),
1267 ("pre-commit-cargo-fmt", true),
1268 ("pre-commit-clippy", false),
1269 ("pre-commit-go-vet", false),
1270 ("pre-commit-gofmt", true),
1271 ("pre-commit-kube-linter", false),
1272 ("pre-commit-kubeconform", false),
1273 ("pre-commit-lint-js", false),
1274 ("pre-commit-lint-json-yaml", false),
1275 ("pre-commit-manifest-trust", false),
1276 ("pre-commit-merge-conflict", false),
1277 ("pre-commit-package-lock", false),
1278 ("pre-commit-prettier", true),
1279 ("pre-commit-pyright", false),
1280 ("pre-commit-ruff", true),
1281 ("pre-commit-tree-parity", false),
1282 ("pre-commit-usual-name", false),
1283 ("pre-commit-yamllint", false),
1284 ("pre-commit-shellcheck", false),
1285 ("pre-commit-hadolint", false),
1286 ("pre-commit-helm-lint", false),
1287 ("pre-commit-large-files", false),
1288 ("pre-commit-secrets", false),
1289 ("pre-push-secrets", false),
1290 ("pre-push-branch-protect", false),
1291 ("pre-push-branch-pattern", false),
1292 ("pre-push-pull-rebase", false),
1293 ("pre-push-audit-go", false),
1294 ("pre-push-audit-js", false),
1295 ("pre-push-audit-python", false),
1296 ("pre-push-audit-rust", false),
1297 ("pre-push-run-tests-js", false),
1298 ("pre-push-cargo-test", false),
1299 ("pre-push-go-test", false),
1300 ("pre-push-pytest", false),
1301 ];
1302
1303 #[test]
1306 fn every_rewrite_declaration_has_a_fixer() {
1307 let declared: BTreeSet<&str> = CHECKS
1308 .iter()
1309 .filter(|check| check.fix == super::Fix::Rewrite)
1310 .map(|check| check.name)
1311 .collect();
1312 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1313 .iter()
1314 .filter(|(_, has)| *has)
1315 .map(|(name, _)| *name)
1316 .collect();
1317 assert_eq!(
1318 declared, implemented,
1319 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1320 and a check with a fixer that does not declare it can never be reached"
1321 );
1322
1323 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1326 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1327 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1328 }
1329
1330 #[test]
1341 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1342 let safety: Vec<&str> = CHECKS
1343 .iter()
1344 .filter(|c| c.reach == Reach::Safety)
1345 .map(|c| c.name)
1346 .collect();
1347 assert_eq!(
1348 safety,
1349 vec![
1350 "pre-commit-ban-terms",
1351 "pre-commit-large-files",
1352 "pre-commit-manifest-trust",
1353 "pre-commit-merge-conflict",
1354 "pre-commit-secrets",
1355 "pre-push-secrets",
1356 ]
1357 );
1358 }
1359
1360 #[test]
1361 fn every_check_declares_a_stage_and_a_scope() {
1362 assert_eq!(CHECKS.len(), 39);
1363 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1364 let pre_push = super::stage_checks(Stage::PrePush).count();
1365 assert_eq!(
1366 pre_commit + pre_push,
1367 CHECKS.len(),
1368 "every check has a stage"
1369 );
1370 }
1371}