1use std::ffi::OsString;
15use std::path::Path;
16
17use crate::check::{
18 Builtin, Check, Fix, GitState, Outcome, Reach, Scope, Severity, Stage, Verdict,
19};
20use crate::pushrefs::PushRefs;
21use crate::{dispatch, hooks};
22
23pub struct Ctx<'a> {
27 pub name: &'a str,
29 pub args: &'a [OsString],
31 pub hooks_dir: &'a Path,
34 pub push: &'a PushRefs,
37 pub manifest: &'a crate::manifest::Manifest,
41 pub settings: &'a crate::config::Settings,
46}
47
48pub type HookFn = fn(&Ctx) -> Verdict;
49
50pub const ENTRYPOINTS: &[(&str, HookFn)] = &[
53 ("pre-commit", dispatch::pre_commit),
54 ("pre-push", dispatch::pre_push),
55 ("commit-msg", |ctx| {
62 if !dispatch::conventions_apply(ctx.settings, ctx.manifest) {
63 return Verdict::Proceed;
64 }
65 hooks::commit_msg::run(ctx.settings, ctx.args)
66 }),
67 ("prepare-commit-msg", |ctx| {
68 if !dispatch::conventions_apply(ctx.settings, ctx.manifest) {
69 return Verdict::Proceed;
70 }
71 hooks::prepare_commit_msg::run(ctx.args)
72 }),
73 ("post-commit", |ctx| {
74 hooks::post_commit::run(ctx.settings, ctx)
75 }),
76];
77
78const MID_OPERATION: &[GitState] = &[
97 GitState::Merge,
98 GitState::Rebase,
99 GitState::CherryPick,
100 GitState::Revert,
101];
102
103pub const CHECKS: &[Builtin] = &[
104 Builtin {
109 name: "pre-commit-agents-md",
110 stage: Stage::PreCommit,
111 scope: Scope::ALWAYS.not_during(MID_OPERATION),
112 severity: Severity::Warn,
113 fix: Fix::Rewrite,
114 reach: Reach::Convention,
115 run: |ctx| hooks::agents_md_drift::run(ctx.settings),
116 },
117 Builtin {
118 name: "pre-commit-argo-lint",
119 stage: Stage::PreCommit,
120 scope: Scope::new(
121 hooks::k8s::EXTS,
122 &["kustomization.yaml", "kustomization.yml"],
123 )
124 .not_during(MID_OPERATION),
125 severity: Severity::Block,
126 fix: Fix::None,
127 reach: Reach::Convention,
128 run: |ctx| hooks::k8s::argo_lint(ctx.settings, ctx.args),
129 },
130 Builtin {
131 name: "pre-commit-ban-terms",
132 stage: Stage::PreCommit,
133 scope: Scope::files(hooks::ban_terms::EXTS),
134 severity: Severity::Block,
135 fix: Fix::None,
136 reach: Reach::Safety,
137 run: |ctx| hooks::ban_terms::run(ctx.settings, ctx.name, ctx.args),
138 },
139 Builtin {
144 name: "pre-commit-branch-pattern",
145 stage: Stage::PreCommit,
146 scope: Scope::ALWAYS,
147 severity: Severity::Warn,
148 fix: Fix::None,
149 reach: Reach::Convention,
150 run: |ctx| hooks::branch_pattern::early(ctx.settings),
151 },
152 Builtin {
157 name: "pre-commit-branch-protect",
158 stage: Stage::PreCommit,
159 scope: Scope::ALWAYS,
160 severity: Severity::Warn,
161 fix: Fix::None,
162 reach: Reach::Convention,
163 run: |ctx| hooks::branch_protect::early(ctx.settings),
164 },
165 Builtin {
166 name: "pre-commit-cargo-fmt",
167 stage: Stage::PreCommit,
168 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
169 severity: Severity::Block,
170 fix: Fix::Rewrite,
171 reach: Reach::Convention,
172 run: |ctx| hooks::rust_tools::fmt(ctx.settings, ctx.args),
173 },
174 Builtin {
175 name: "pre-commit-clippy",
176 stage: Stage::PreCommit,
177 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
178 severity: Severity::Block,
179 fix: Fix::None,
180 reach: Reach::Convention,
181 run: |ctx| hooks::rust_tools::clippy(ctx.settings, ctx.args),
182 },
183 Builtin {
184 name: "pre-commit-go-vet",
185 stage: Stage::PreCommit,
186 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
187 severity: Severity::Block,
188 fix: Fix::None,
189 reach: Reach::Convention,
190 run: |ctx| hooks::go_tools::vet(ctx.settings, ctx.args),
191 },
192 Builtin {
193 name: "pre-commit-gofmt",
194 stage: Stage::PreCommit,
195 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
196 severity: Severity::Block,
197 fix: Fix::Rewrite,
198 reach: Reach::Convention,
199 run: |ctx| hooks::go_tools::fmt(ctx.settings, ctx.args),
200 },
201 Builtin {
202 name: "pre-commit-kube-linter",
203 stage: Stage::PreCommit,
204 scope: Scope::new(
205 hooks::k8s::EXTS,
206 &[".kube-linter*.yaml", ".kube-linter*.yml"],
207 )
208 .not_during(MID_OPERATION),
209 severity: Severity::Block,
210 fix: Fix::None,
211 reach: Reach::Convention,
212 run: |ctx| hooks::k8s::kube_linter(ctx.settings, ctx.args),
213 },
214 Builtin {
215 name: "pre-commit-kubeconform",
216 stage: Stage::PreCommit,
217 scope: Scope::new(
218 hooks::k8s::EXTS,
219 &["kustomization.yaml", "kustomization.yml"],
220 )
221 .not_during(MID_OPERATION),
222 severity: Severity::Block,
223 fix: Fix::None,
224 reach: Reach::Convention,
225 run: |ctx| hooks::k8s::kubeconform(ctx.settings, ctx.args),
226 },
227 Builtin {
228 name: "pre-commit-large-files",
229 stage: Stage::PreCommit,
230 scope: Scope::ALWAYS,
231 severity: Severity::Block,
232 fix: Fix::None,
233 reach: Reach::Safety,
234 run: |ctx| hooks::large_files::staged(ctx.settings),
235 },
236 Builtin {
237 name: "pre-commit-lint-js",
238 stage: Stage::PreCommit,
239 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
240 severity: Severity::Block,
241 fix: Fix::None,
242 reach: Reach::Convention,
243 run: |ctx| hooks::lint_js::run(ctx.settings, ctx.args),
244 },
245 Builtin {
246 name: "pre-commit-lint-json-yaml",
247 stage: Stage::PreCommit,
248 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
249 severity: Severity::Block,
250 fix: Fix::None,
251 reach: Reach::Convention,
252 run: |ctx| hooks::lint_json_yaml::run(ctx.settings, ctx.args),
253 },
254 Builtin {
255 name: "pre-commit-merge-conflict",
256 stage: Stage::PreCommit,
257 scope: Scope::ALWAYS,
258 severity: Severity::Block,
259 fix: Fix::None,
260 reach: Reach::Safety,
261 run: |ctx| hooks::merge_conflict::run(ctx.settings, ctx.name, ctx.args),
262 },
263 Builtin {
264 name: "pre-commit-package-lock",
265 stage: Stage::PreCommit,
266 scope: Scope::new(&[], &["package.json"]),
267 severity: Severity::Block,
268 fix: Fix::None,
269 reach: Reach::Convention,
270 run: |ctx| hooks::package_lock::run(ctx.settings, ctx.args),
271 },
272 Builtin {
273 name: "pre-commit-prettier",
274 stage: Stage::PreCommit,
275 scope: Scope::new(
276 &[],
277 &[
278 ".prettierrc",
279 ".prettierrc.json",
280 ".prettierrc.yml",
281 ".prettierrc.yaml",
282 ".prettierrc.js",
283 "prettier.config.js",
284 ],
285 )
286 .not_during(MID_OPERATION),
287 severity: Severity::Block,
288 fix: Fix::Rewrite,
289 reach: Reach::Convention,
290 run: |ctx| hooks::prettier::run(ctx.settings, ctx.args),
291 },
292 Builtin {
293 name: "pre-commit-pyright",
294 stage: Stage::PreCommit,
295 scope: Scope::new(
296 hooks::python_tools::EXTS,
297 &[
298 "pyrightconfig.json",
299 "pyrightconfig.jsonc",
300 "pyproject.toml",
301 ],
302 )
303 .not_during(MID_OPERATION),
304 severity: Severity::Block,
305 fix: Fix::None,
306 reach: Reach::Convention,
307 run: |ctx| hooks::python_tools::pyright(ctx.settings, ctx.args),
308 },
309 Builtin {
310 name: "pre-commit-ruff",
311 stage: Stage::PreCommit,
312 scope: Scope::new(
313 hooks::python_tools::EXTS,
314 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
315 )
316 .not_during(MID_OPERATION),
317 severity: Severity::Block,
318 fix: Fix::Rewrite,
319 reach: Reach::Convention,
320 run: |ctx| hooks::python_tools::ruff(ctx.settings, ctx.args),
321 },
322 Builtin {
326 name: "pre-commit-secrets",
327 stage: Stage::PreCommit,
328 scope: Scope::ALWAYS,
329 severity: Severity::Block,
330 fix: Fix::None,
331 reach: Reach::Safety,
332 run: |ctx| hooks::secrets::staged(ctx.settings),
333 },
334 Builtin {
335 name: "pre-commit-usual-name",
336 stage: Stage::PreCommit,
337 scope: Scope::ALWAYS,
338 severity: Severity::Block,
339 fix: Fix::None,
340 reach: Reach::Convention,
341 run: |ctx| hooks::usual_name::run(ctx.args),
342 },
343 Builtin {
344 name: "pre-commit-shellcheck",
345 stage: Stage::PreCommit,
346 scope: Scope::files(hooks::shellcheck::EXTS).not_during(MID_OPERATION),
349 severity: Severity::Block,
350 fix: Fix::None,
351 reach: Reach::Convention,
352 run: |ctx| hooks::shellcheck::run(ctx.settings, ctx.args),
353 },
354 Builtin {
355 name: "pre-commit-hadolint",
356 stage: Stage::PreCommit,
357 scope: Scope::named(hooks::hadolint::NAMES).not_during(MID_OPERATION),
361 severity: Severity::Block,
362 fix: Fix::None,
363 reach: Reach::Convention,
364 run: |ctx| hooks::hadolint::run(ctx.settings, ctx.args),
365 },
366 Builtin {
367 name: "pre-commit-helm-lint",
368 stage: Stage::PreCommit,
369 scope: Scope::new(hooks::helm::EXTS, hooks::helm::MARKERS).not_during(MID_OPERATION),
372 severity: Severity::Block,
373 fix: Fix::None,
374 reach: Reach::Convention,
375 run: |ctx| hooks::helm::run(ctx.settings, ctx.args),
376 },
377 Builtin {
378 name: "pre-commit-yamllint",
379 stage: Stage::PreCommit,
380 scope: Scope::new(
381 hooks::yamllint::EXTS,
382 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
383 )
384 .not_during(MID_OPERATION),
385 severity: Severity::Block,
386 fix: Fix::None,
387 reach: Reach::Convention,
388 run: |ctx| hooks::yamllint::run(ctx.settings, ctx.args),
389 },
390 Builtin {
392 name: "pre-push-branch-protect",
393 stage: Stage::PrePush,
394 scope: Scope::ALWAYS,
395 severity: Severity::Block,
396 fix: Fix::None,
397 reach: Reach::Convention,
398 run: |ctx| hooks::branch_protect::run(ctx.settings, ctx.push.get()),
399 },
400 Builtin {
401 name: "pre-push-branch-pattern",
402 stage: Stage::PrePush,
403 scope: Scope::ALWAYS,
404 severity: Severity::Block,
405 fix: Fix::None,
406 reach: Reach::Convention,
407 run: |ctx| hooks::branch_pattern::run(ctx.settings, ctx.push.get(), ctx.args),
408 },
409 Builtin {
410 name: "pre-push-secrets",
411 stage: Stage::PrePush,
412 scope: Scope::ALWAYS,
413 severity: Severity::Block,
414 fix: Fix::None,
415 reach: Reach::Safety,
416 run: |ctx| hooks::secrets::pushed(ctx.settings, ctx.push.get()),
417 },
418 Builtin {
419 name: "pre-push-pull-rebase",
420 stage: Stage::PrePush,
421 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
422 severity: Severity::Block,
423 fix: Fix::None,
424 reach: Reach::Convention,
425 run: |ctx| hooks::pull_rebase::run(ctx.settings, ctx.args),
426 },
427 Builtin {
434 name: "pre-push-audit-go",
435 stage: Stage::PrePush,
436 scope: Scope::new(&[], &["go.sum"]),
437 severity: Severity::Block,
438 fix: Fix::None,
439 reach: Reach::Convention,
440 run: |ctx| hooks::audit::go(ctx.settings, ctx.push.get()),
441 },
442 Builtin {
443 name: "pre-push-audit-js",
444 stage: Stage::PrePush,
445 scope: Scope::new(&[], &["package-lock.json"]),
446 severity: Severity::Block,
447 fix: Fix::None,
448 reach: Reach::Convention,
449 run: |ctx| hooks::audit::js(ctx.settings, ctx.push.get()),
450 },
451 Builtin {
452 name: "pre-push-audit-python",
453 stage: Stage::PrePush,
454 scope: Scope::new(&[], &["requirements.txt"]),
455 severity: Severity::Block,
456 fix: Fix::None,
457 reach: Reach::Convention,
458 run: |ctx| hooks::audit::python(ctx.settings, ctx.push.get()),
459 },
460 Builtin {
461 name: "pre-push-audit-rust",
462 stage: Stage::PrePush,
463 scope: Scope::new(&[], &["Cargo.lock"]),
464 severity: Severity::Block,
465 fix: Fix::None,
466 reach: Reach::Convention,
467 run: |ctx| hooks::audit::rust(ctx.settings, ctx.push.get()),
468 },
469 Builtin {
470 name: "pre-push-run-tests-js",
471 stage: Stage::PrePush,
472 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
473 .not_during(&[GitState::Bisect, GitState::Rebase]),
474 severity: Severity::Block,
475 fix: Fix::None,
476 reach: Reach::Convention,
477 run: |ctx| hooks::run_tests::run(ctx.settings, ctx.push.get(), &ctx.manifest.externals),
478 },
479 Builtin {
480 name: "pre-push-cargo-test",
481 stage: Stage::PrePush,
482 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
483 .not_during(&[GitState::Bisect, GitState::Rebase]),
484 severity: Severity::Block,
485 fix: Fix::None,
486 reach: Reach::Convention,
487 run: |ctx| hooks::rust_tools::test(ctx.settings, ctx.push.get()),
488 },
489 Builtin {
490 name: "pre-push-go-test",
491 stage: Stage::PrePush,
492 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
493 .not_during(&[GitState::Bisect, GitState::Rebase]),
494 severity: Severity::Block,
495 fix: Fix::None,
496 reach: Reach::Convention,
497 run: |ctx| hooks::go_tools::test(ctx.settings, ctx.push.get()),
498 },
499 Builtin {
500 name: "pre-push-pytest",
501 stage: Stage::PrePush,
502 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
503 .not_during(&[GitState::Bisect, GitState::Rebase]),
504 severity: Severity::Block,
505 fix: Fix::None,
506 reach: Reach::Convention,
507 run: |ctx| hooks::python_tools::pytest(ctx.settings, ctx.push.get()),
508 },
509];
510
511pub fn severity_of(settings: &crate::config::Settings, check: &dyn Check) -> Severity {
517 effective_override(settings, None, check.name()).unwrap_or_else(|| check.severity())
518}
519
520pub fn severity_key(check: &str) -> String {
522 format!("amont.severity.{check}")
523}
524
525#[derive(Debug, Default, Clone)]
536pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
542pub enum Source {
543 Config,
544 Policy,
545}
546
547impl Source {
548 pub fn as_str(self) -> &'static str {
549 match self {
550 Source::Config => "config",
551 Source::Policy => "policy",
552 }
553 }
554}
555
556impl Overrides {
557 pub fn read(settings: &crate::config::Settings) -> Overrides {
564 let policy = settings.policy();
565 match crate::git::stdout(&[
566 "config",
567 "--show-scope",
568 "--get-regexp",
569 r"^amont\.severity\.",
570 ]) {
571 Some(scoped) => Overrides::from_scoped(&scoped, policy),
572 None => Overrides::from_plain_with_policy_below(
576 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
577 policy,
578 ),
579 }
580 }
581
582 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
589 let mut below = String::new();
590 let mut above = String::new();
591 for line in scoped.lines() {
592 let Some((scope, rest)) = line.split_once('\t') else {
593 continue;
594 };
595 match scope {
596 "system" | "global" => {
597 below.push_str(rest);
598 below.push('\n');
599 }
600 _ => {
601 above.push_str(rest);
602 above.push('\n');
603 }
604 }
605 }
606 let mut o = Overrides::default();
607 o.fold_plain(&below, Source::Config);
608 o.fold_policy(policy);
609 o.fold_plain(&above, Source::Config);
610 o
611 }
612
613 pub fn from_plain_with_policy_below(
618 plain: Option<String>,
619 policy: &crate::policy::Policy,
620 ) -> Overrides {
621 let mut o = Overrides::default();
622 o.fold_policy(policy);
623 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
624 o
625 }
626
627 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
630 for (target, severity) in &policy.severities {
631 self.0.insert(target.clone(), (*severity, Source::Policy));
632 }
633 }
634
635 fn fold_plain(&mut self, text: &str, source: Source) {
639 for line in text.lines() {
640 let Some((key, value)) = line.split_once(' ') else {
641 continue;
642 };
643 let Some(check) = key.strip_prefix("amont.severity.") else {
644 continue;
645 };
646 match Severity::parse(value.trim()) {
647 Some(sev) => {
648 self.0.insert(check.to_string(), (sev, source));
649 }
650 None => {
651 self.0.remove(check);
652 }
653 }
654 }
655 }
656
657 pub fn from_config(out: Option<String>) -> Overrides {
665 let mut o = Overrides::default();
666 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
667 o
668 }
669
670 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
677 self.applied_with_source(check)
678 .map(|(pattern, severity, _)| (pattern, severity))
679 }
680
681 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
685 self.0
686 .iter()
687 .filter_map(|(pattern, (severity, source))| {
688 crate::names_check(check, pattern)
689 .map(|m| (m, pattern.as_str(), *severity, *source))
690 })
691 .max_by_key(|(m, _, _, _)| *m)
692 .map(|(_, pattern, severity, source)| (pattern, severity, source))
693 }
694
695 pub fn of(&self, check: &dyn Check) -> Severity {
697 self.applied_to(check.name())
698 .map(|(_, severity)| severity)
699 .unwrap_or_else(|| check.severity())
700 }
701}
702
703#[cfg(test)]
704mod precedence {
705 use super::{Overrides, Severity};
706
707 fn overrides(lines: &[&str]) -> Overrides {
708 let text = lines
709 .iter()
710 .map(|l| format!("amont.severity.{l}\n"))
711 .collect::<String>();
712 Overrides::from_config(Some(text))
713 }
714
715 #[test]
719 fn the_more_specific_key_wins() {
720 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
721 assert_eq!(
722 both.applied_to("pre-commit-clippy"),
723 Some(("pre-commit-clippy", Severity::Block)),
724 "a full id beats its trigger"
725 );
726 assert_eq!(
727 both.applied_to("pre-commit-shellcheck"),
728 Some(("pre-commit", Severity::Warn)),
729 "and the trigger still governs every check it did not exempt"
730 );
731 }
732
733 #[test]
736 fn the_three_ways_to_name_a_check_are_ranked() {
737 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
738 assert_eq!(
739 all.applied_to("pre-commit-clippy"),
740 Some(("pre-commit-clippy", Severity::Warn))
741 );
742
743 let no_full = overrides(&["pre-commit warn", "clippy block"]);
744 assert_eq!(
745 no_full.applied_to("pre-commit-clippy"),
746 Some(("clippy", Severity::Block)),
747 "a short name beats a trigger"
748 );
749 }
750
751 #[test]
754 fn a_key_that_names_no_check_applies_to_nothing() {
755 let typo = overrides(&["clipy warn", "e warn", " warn"]);
756 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
757 }
758}
759
760pub fn effective_override(
770 settings: &crate::config::Settings,
771 repo: Option<&Path>,
772 check: &str,
773) -> Option<Severity> {
774 overrides_in(settings, repo)
775 .applied_to(check)
776 .map(|(_, s)| s)
777}
778
779pub fn effective_key(
785 settings: &crate::config::Settings,
786 repo: Option<&Path>,
787 check: &str,
788) -> Option<String> {
789 overrides_in(settings, repo)
790 .applied_to(check)
791 .map(|(pattern, _)| pattern.to_string())
792}
793
794fn overrides_in(settings: &crate::config::Settings, repo: Option<&Path>) -> Overrides {
795 match repo {
796 None => Overrides::read(settings),
800 Some(dir) => Overrides::from_config(crate::git::stdout_in(
804 dir,
805 &["config", "--get-regexp", r"^amont\.severity\."],
806 )),
807 }
808}
809
810pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
812 CHECKS.iter().filter(move |check| check.stage == stage)
813}
814
815pub fn all_stage_checks<'a>(
822 stage: Stage,
823 manifest: &'a crate::manifest::Manifest,
824) -> Vec<&'a dyn Check> {
825 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
826 .map(|check| check as &dyn Check)
827 .collect();
828 out.extend(
829 manifest
830 .externals
831 .iter()
832 .filter(|external| external.stage == stage)
833 .map(|external| external as &dyn Check),
834 );
835 out
836}
837
838pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
839 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
840 return Some(*f);
841 }
842 if CHECKS.iter().any(|check| check.name == name)
849 || manifest
850 .externals
851 .iter()
852 .any(|external| external.id == name)
853 {
854 return Some(|ctx: &Ctx| {
855 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
856 Verdict::blocking(matches!(
857 (check.run(ctx), severity_of(ctx.settings, check)),
858 (Outcome::Failed, Severity::Block)
859 ))
860 });
861 }
862 None
863}
864
865pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
869 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
870 return Some(builtin);
871 }
872 manifest
873 .externals
874 .iter()
875 .find(|external| external.id == name)
876 .map(|external| external as &dyn Check)
877}
878
879#[cfg(test)]
880mod tests {
881 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
882 use std::collections::BTreeSet;
883
884 #[test]
891 fn the_batch_agrees_with_the_authority() {
892 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
893 let _ = std::fs::remove_dir_all(&d);
894 std::fs::create_dir_all(&d).unwrap();
895 let git = |args: &[&str]| {
896 std::process::Command::new("git")
897 .args(args)
898 .current_dir(&d)
899 .output()
900 .expect("git");
901 };
902 git(&["init", "-q", "--template=", "."]);
903 let key = "amont.severity.pre-commit-merge-conflict";
904 git(&["config", "--add", key, "warn"]);
905 git(&["config", "--add", key, "block"]);
906
907 let raw = std::process::Command::new("git")
908 .args(["config", "--get-regexp", r"^amont\.severity\."])
909 .current_dir(&d)
910 .output()
911 .expect("git");
912 let batch = Overrides::from_config(Some(
913 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
914 ));
915 let authority =
916 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
917 let _ = std::fs::remove_dir_all(&d);
918
919 assert_eq!(
920 authority,
921 Some(Severity::Block),
922 "git applies the last entry"
923 );
924 assert_eq!(
925 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
926 authority,
927 "the batch reader disagreed with `--get`"
928 );
929 }
930
931 #[test]
935 fn an_unrecognised_value_clears_rather_than_overrides() {
936 let o = Overrides::from_config(Some(
937 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
938 ));
939 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
940 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
941 }
942
943 #[test]
944 fn names_are_unique_across_entrypoints_and_checks() {
945 let mut seen = BTreeSet::new();
946 for n in ENTRYPOINTS
947 .iter()
948 .map(|(n, _)| *n)
949 .chain(CHECKS.iter().map(|check| check.name))
950 {
951 assert!(seen.insert(n), "duplicate registration: {n}");
952 }
953 }
954
955 #[test]
957 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
958 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
959 let mut shipped: Vec<String> = std::fs::read_dir(dir)
960 .expect("templates/hooks")
961 .flatten()
962 .map(|entry| entry.file_name().to_string_lossy().into_owned())
963 .collect();
964 shipped.sort();
965 assert_eq!(
966 shipped,
967 vec![
968 "commit-msg",
969 "post-commit",
970 "pre-commit",
971 "pre-push",
972 "prepare-commit-msg"
973 ]
974 );
975 let none = crate::manifest::Manifest::default();
976 for name in &shipped {
977 assert!(
978 lookup(name, &none).is_some(),
979 "shipped shim {name:?} has no handler"
980 );
981 }
982 }
983
984 #[test]
987 fn every_check_is_reachable_by_name() {
988 let none = crate::manifest::Manifest::default();
989 for check in CHECKS {
990 assert!(
991 lookup(check.name, &none).is_some(),
992 "{} not reachable",
993 check.name
994 );
995 }
996 assert!(lookup("pre-commit-not-a-check", &none).is_none());
997 }
998
999 #[test]
1001 fn pre_push_runs_cheapest_first() {
1002 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
1003 .map(|check| check.name)
1004 .collect();
1005 assert_eq!(
1006 order,
1007 vec![
1008 "pre-push-branch-protect",
1009 "pre-push-branch-pattern",
1010 "pre-push-secrets",
1011 "pre-push-pull-rebase",
1012 "pre-push-audit-go",
1013 "pre-push-audit-js",
1014 "pre-push-audit-python",
1015 "pre-push-audit-rust",
1016 "pre-push-run-tests-js",
1017 "pre-push-cargo-test",
1018 "pre-push-go-test",
1019 "pre-push-pytest",
1020 ]
1021 );
1022 }
1023
1024 enum Consumes {
1030 All,
1031 Exts(&'static [&'static str]),
1032 }
1033
1034 const CONSUMED: &[(&str, Consumes)] = &[
1048 (
1049 "pre-commit-argo-lint",
1050 Consumes::Exts(crate::hooks::k8s::EXTS),
1051 ),
1052 (
1056 "pre-commit-ban-terms",
1057 Consumes::Exts(crate::hooks::ban_terms::EXTS),
1058 ),
1059 (
1060 "pre-commit-cargo-fmt",
1061 Consumes::Exts(crate::hooks::rust_tools::EXTS),
1062 ),
1063 (
1064 "pre-commit-clippy",
1065 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1066 ),
1067 (
1068 "pre-commit-go-vet",
1069 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1070 ),
1071 (
1072 "pre-commit-gofmt",
1073 Consumes::Exts(crate::hooks::go_tools::EXTS),
1074 ),
1075 (
1076 "pre-commit-kube-linter",
1077 Consumes::Exts(crate::hooks::k8s::EXTS),
1078 ),
1079 (
1080 "pre-commit-kubeconform",
1081 Consumes::Exts(crate::hooks::k8s::EXTS),
1082 ),
1083 (
1084 "pre-commit-lint-js",
1085 Consumes::Exts(crate::hooks::lint_js::EXTS),
1086 ),
1087 (
1088 "pre-commit-lint-json-yaml",
1089 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1090 ),
1091 ("pre-commit-merge-conflict", Consumes::All),
1092 ("pre-commit-package-lock", Consumes::All),
1093 (
1097 "pre-commit-prettier",
1098 Consumes::Exts(crate::hooks::prettier::EXTS),
1099 ),
1100 (
1101 "pre-commit-pyright",
1102 Consumes::Exts(crate::hooks::python_tools::EXTS),
1103 ),
1104 (
1105 "pre-commit-ruff",
1106 Consumes::Exts(crate::hooks::python_tools::EXTS),
1107 ),
1108 ("pre-commit-usual-name", Consumes::All),
1109 (
1110 "pre-commit-yamllint",
1111 Consumes::Exts(crate::hooks::yamllint::EXTS),
1112 ),
1113 (
1114 "pre-commit-shellcheck",
1115 Consumes::Exts(crate::hooks::shellcheck::EXTS),
1116 ),
1117 (
1118 "pre-commit-hadolint",
1119 Consumes::Exts(crate::hooks::hadolint::NAMES),
1120 ),
1121 (
1122 "pre-commit-helm-lint",
1123 Consumes::Exts(crate::hooks::helm::EXTS),
1124 ),
1125 ("pre-commit-large-files", Consumes::All),
1126 ("pre-commit-secrets", Consumes::All),
1127 ("pre-push-secrets", Consumes::All),
1128 ("pre-push-branch-protect", Consumes::All),
1129 ("pre-push-branch-pattern", Consumes::All),
1130 ("pre-push-pull-rebase", Consumes::All),
1131 (
1132 "pre-push-run-tests-js",
1133 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1134 ),
1135 (
1136 "pre-push-pytest",
1137 Consumes::Exts(crate::hooks::python_tools::EXTS),
1138 ),
1139 (
1140 "pre-push-cargo-test",
1141 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1142 ),
1143 (
1144 "pre-push-go-test",
1145 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1146 ),
1147 ];
1148
1149 #[test]
1160 fn no_check_declares_a_file_type_it_does_not_consume() {
1161 for (name, _) in CONSUMED {
1162 assert!(
1163 CHECKS.iter().any(|check| check.name == *name),
1164 "CONSUMED names {name:?}, which is not a check"
1165 );
1166 }
1167 for check in CHECKS {
1168 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1169 if check.scope.files.is_empty() && entry.is_none() {
1170 continue;
1171 }
1172 let Some((_, consumes)) = entry else {
1173 panic!(
1174 "{} declares scope.files {:?} but is missing from CONSUMED — \
1175 say what it actually reads",
1176 check.name, check.scope.files
1177 );
1178 };
1179 let Consumes::Exts(consumed) = consumes else {
1180 continue; };
1182 for ext in check.scope.files {
1183 assert!(
1184 consumed.contains(ext),
1185 "{} declares {ext:?} in its scope but never asks for it — \
1186 `amont list` would report a coverage the check does not have",
1187 check.name
1188 );
1189 }
1190 }
1191 }
1192
1193 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1202 ("pre-commit-agents-md", true),
1203 ("pre-commit-argo-lint", false),
1204 ("pre-commit-ban-terms", false),
1205 ("pre-commit-branch-pattern", false),
1206 ("pre-commit-branch-protect", false),
1207 ("pre-commit-cargo-fmt", true),
1208 ("pre-commit-clippy", false),
1209 ("pre-commit-go-vet", false),
1210 ("pre-commit-gofmt", true),
1211 ("pre-commit-kube-linter", false),
1212 ("pre-commit-kubeconform", false),
1213 ("pre-commit-lint-js", false),
1214 ("pre-commit-lint-json-yaml", false),
1215 ("pre-commit-merge-conflict", false),
1216 ("pre-commit-package-lock", false),
1217 ("pre-commit-prettier", true),
1218 ("pre-commit-pyright", false),
1219 ("pre-commit-ruff", true),
1220 ("pre-commit-usual-name", false),
1221 ("pre-commit-yamllint", false),
1222 ("pre-commit-shellcheck", false),
1223 ("pre-commit-hadolint", false),
1224 ("pre-commit-helm-lint", false),
1225 ("pre-commit-large-files", false),
1226 ("pre-commit-secrets", false),
1227 ("pre-push-secrets", false),
1228 ("pre-push-branch-protect", false),
1229 ("pre-push-branch-pattern", false),
1230 ("pre-push-pull-rebase", false),
1231 ("pre-push-audit-go", false),
1232 ("pre-push-audit-js", false),
1233 ("pre-push-audit-python", false),
1234 ("pre-push-audit-rust", false),
1235 ("pre-push-run-tests-js", false),
1236 ("pre-push-cargo-test", false),
1237 ("pre-push-go-test", false),
1238 ("pre-push-pytest", false),
1239 ];
1240
1241 #[test]
1244 fn every_rewrite_declaration_has_a_fixer() {
1245 let declared: BTreeSet<&str> = CHECKS
1246 .iter()
1247 .filter(|check| check.fix == super::Fix::Rewrite)
1248 .map(|check| check.name)
1249 .collect();
1250 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1251 .iter()
1252 .filter(|(_, has)| *has)
1253 .map(|(name, _)| *name)
1254 .collect();
1255 assert_eq!(
1256 declared, implemented,
1257 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1258 and a check with a fixer that does not declare it can never be reached"
1259 );
1260
1261 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1264 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1265 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1266 }
1267
1268 #[test]
1279 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1280 let safety: Vec<&str> = CHECKS
1281 .iter()
1282 .filter(|c| c.reach == Reach::Safety)
1283 .map(|c| c.name)
1284 .collect();
1285 assert_eq!(
1286 safety,
1287 vec![
1288 "pre-commit-ban-terms",
1289 "pre-commit-large-files",
1290 "pre-commit-merge-conflict",
1291 "pre-commit-secrets",
1292 "pre-push-secrets",
1293 ]
1294 );
1295 }
1296
1297 #[test]
1298 fn every_check_declares_a_stage_and_a_scope() {
1299 assert_eq!(CHECKS.len(), 37);
1300 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1301 let pre_push = super::stage_checks(Stage::PrePush).count();
1302 assert_eq!(
1303 pre_commit + pre_push,
1304 CHECKS.len(),
1305 "every check has a stage"
1306 );
1307 }
1308}