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}
42
43pub type HookFn = fn(&Ctx) -> Verdict;
44
45pub const ENTRYPOINTS: &[(&str, HookFn)] = &[
48 ("pre-commit", dispatch::pre_commit),
49 ("pre-push", dispatch::pre_push),
50 ("commit-msg", |ctx| {
57 if !dispatch::conventions_apply(ctx.manifest) {
58 return Verdict::Proceed;
59 }
60 hooks::commit_msg::run(ctx.args)
61 }),
62 ("prepare-commit-msg", |ctx| {
63 if !dispatch::conventions_apply(ctx.manifest) {
64 return Verdict::Proceed;
65 }
66 hooks::prepare_commit_msg::run(ctx.args)
67 }),
68 ("post-commit", |ctx| hooks::post_commit::run(ctx)),
69];
70
71const MID_OPERATION: &[GitState] = &[
90 GitState::Merge,
91 GitState::Rebase,
92 GitState::CherryPick,
93 GitState::Revert,
94];
95
96pub const CHECKS: &[Builtin] = &[
97 Builtin {
99 name: "pre-commit-argo-lint",
100 stage: Stage::PreCommit,
101 scope: Scope::new(
102 hooks::k8s::EXTS,
103 &["kustomization.yaml", "kustomization.yml"],
104 )
105 .not_during(MID_OPERATION),
106 severity: Severity::Block,
107 fix: Fix::None,
108 reach: Reach::Convention,
109 run: |ctx| hooks::k8s::argo_lint(ctx.args),
110 },
111 Builtin {
112 name: "pre-commit-ban-terms",
113 stage: Stage::PreCommit,
114 scope: Scope::files(hooks::ban_terms::EXTS),
115 severity: Severity::Block,
116 fix: Fix::None,
117 reach: Reach::Safety,
118 run: |ctx| hooks::ban_terms::run(ctx.name, ctx.args),
119 },
120 Builtin {
125 name: "pre-commit-branch-pattern",
126 stage: Stage::PreCommit,
127 scope: Scope::ALWAYS,
128 severity: Severity::Warn,
129 fix: Fix::None,
130 reach: Reach::Convention,
131 run: |_ctx| hooks::branch_pattern::early(),
132 },
133 Builtin {
134 name: "pre-commit-cargo-fmt",
135 stage: Stage::PreCommit,
136 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
137 severity: Severity::Block,
138 fix: Fix::Rewrite,
139 reach: Reach::Convention,
140 run: |ctx| hooks::rust_tools::fmt(ctx.args),
141 },
142 Builtin {
143 name: "pre-commit-clippy",
144 stage: Stage::PreCommit,
145 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
146 severity: Severity::Block,
147 fix: Fix::None,
148 reach: Reach::Convention,
149 run: |ctx| hooks::rust_tools::clippy(ctx.args),
150 },
151 Builtin {
152 name: "pre-commit-go-vet",
153 stage: Stage::PreCommit,
154 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
155 severity: Severity::Block,
156 fix: Fix::None,
157 reach: Reach::Convention,
158 run: |ctx| hooks::go_tools::vet(ctx.args),
159 },
160 Builtin {
161 name: "pre-commit-gofmt",
162 stage: Stage::PreCommit,
163 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
164 severity: Severity::Block,
165 fix: Fix::Rewrite,
166 reach: Reach::Convention,
167 run: |ctx| hooks::go_tools::fmt(ctx.args),
168 },
169 Builtin {
170 name: "pre-commit-kube-linter",
171 stage: Stage::PreCommit,
172 scope: Scope::new(
173 hooks::k8s::EXTS,
174 &[".kube-linter*.yaml", ".kube-linter*.yml"],
175 )
176 .not_during(MID_OPERATION),
177 severity: Severity::Block,
178 fix: Fix::None,
179 reach: Reach::Convention,
180 run: |ctx| hooks::k8s::kube_linter(ctx.args),
181 },
182 Builtin {
183 name: "pre-commit-kubeconform",
184 stage: Stage::PreCommit,
185 scope: Scope::new(
186 hooks::k8s::EXTS,
187 &["kustomization.yaml", "kustomization.yml"],
188 )
189 .not_during(MID_OPERATION),
190 severity: Severity::Block,
191 fix: Fix::None,
192 reach: Reach::Convention,
193 run: |ctx| hooks::k8s::kubeconform(ctx.args),
194 },
195 Builtin {
196 name: "pre-commit-large-files",
197 stage: Stage::PreCommit,
198 scope: Scope::ALWAYS,
199 severity: Severity::Block,
200 fix: Fix::None,
201 reach: Reach::Safety,
202 run: |_ctx| hooks::large_files::staged(),
203 },
204 Builtin {
205 name: "pre-commit-lint-js",
206 stage: Stage::PreCommit,
207 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
208 severity: Severity::Block,
209 fix: Fix::None,
210 reach: Reach::Convention,
211 run: |ctx| hooks::lint_js::run(ctx.args),
212 },
213 Builtin {
214 name: "pre-commit-lint-json-yaml",
215 stage: Stage::PreCommit,
216 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
217 severity: Severity::Block,
218 fix: Fix::None,
219 reach: Reach::Convention,
220 run: |ctx| hooks::lint_json_yaml::run(ctx.args),
221 },
222 Builtin {
223 name: "pre-commit-merge-conflict",
224 stage: Stage::PreCommit,
225 scope: Scope::ALWAYS,
226 severity: Severity::Block,
227 fix: Fix::None,
228 reach: Reach::Safety,
229 run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
230 },
231 Builtin {
232 name: "pre-commit-package-lock",
233 stage: Stage::PreCommit,
234 scope: Scope::new(&[], &["package.json"]),
235 severity: Severity::Block,
236 fix: Fix::None,
237 reach: Reach::Convention,
238 run: |ctx| hooks::package_lock::run(ctx.args),
239 },
240 Builtin {
241 name: "pre-commit-prettier",
242 stage: Stage::PreCommit,
243 scope: Scope::new(
244 &[],
245 &[
246 ".prettierrc",
247 ".prettierrc.json",
248 ".prettierrc.yml",
249 ".prettierrc.yaml",
250 ".prettierrc.js",
251 "prettier.config.js",
252 ],
253 )
254 .not_during(MID_OPERATION),
255 severity: Severity::Block,
256 fix: Fix::Rewrite,
257 reach: Reach::Convention,
258 run: |ctx| hooks::prettier::run(ctx.args),
259 },
260 Builtin {
261 name: "pre-commit-pyright",
262 stage: Stage::PreCommit,
263 scope: Scope::new(
264 hooks::python_tools::EXTS,
265 &[
266 "pyrightconfig.json",
267 "pyrightconfig.jsonc",
268 "pyproject.toml",
269 ],
270 )
271 .not_during(MID_OPERATION),
272 severity: Severity::Block,
273 fix: Fix::None,
274 reach: Reach::Convention,
275 run: |ctx| hooks::python_tools::pyright(ctx.args),
276 },
277 Builtin {
278 name: "pre-commit-ruff",
279 stage: Stage::PreCommit,
280 scope: Scope::new(
281 hooks::python_tools::EXTS,
282 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
283 )
284 .not_during(MID_OPERATION),
285 severity: Severity::Block,
286 fix: Fix::Rewrite,
287 reach: Reach::Convention,
288 run: |ctx| hooks::python_tools::ruff(ctx.args),
289 },
290 Builtin {
294 name: "pre-commit-secrets",
295 stage: Stage::PreCommit,
296 scope: Scope::ALWAYS,
297 severity: Severity::Block,
298 fix: Fix::None,
299 reach: Reach::Safety,
300 run: |_ctx| hooks::secrets::staged(),
301 },
302 Builtin {
303 name: "pre-commit-usual-name",
304 stage: Stage::PreCommit,
305 scope: Scope::ALWAYS,
306 severity: Severity::Block,
307 fix: Fix::None,
308 reach: Reach::Convention,
309 run: |ctx| hooks::usual_name::run(ctx.args),
310 },
311 Builtin {
312 name: "pre-commit-yamllint",
313 stage: Stage::PreCommit,
314 scope: Scope::new(
315 hooks::yamllint::EXTS,
316 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
317 )
318 .not_during(MID_OPERATION),
319 severity: Severity::Block,
320 fix: Fix::None,
321 reach: Reach::Convention,
322 run: |ctx| hooks::yamllint::run(ctx.args),
323 },
324 Builtin {
326 name: "pre-push-branch-protect",
327 stage: Stage::PrePush,
328 scope: Scope::ALWAYS,
329 severity: Severity::Block,
330 fix: Fix::None,
331 reach: Reach::Convention,
332 run: |ctx| hooks::branch_protect::run(ctx.push.get()),
333 },
334 Builtin {
335 name: "pre-push-branch-pattern",
336 stage: Stage::PrePush,
337 scope: Scope::ALWAYS,
338 severity: Severity::Block,
339 fix: Fix::None,
340 reach: Reach::Convention,
341 run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
342 },
343 Builtin {
344 name: "pre-push-secrets",
345 stage: Stage::PrePush,
346 scope: Scope::ALWAYS,
347 severity: Severity::Block,
348 fix: Fix::None,
349 reach: Reach::Safety,
350 run: |ctx| hooks::secrets::pushed(ctx.push.get()),
351 },
352 Builtin {
353 name: "pre-push-pull-rebase",
354 stage: Stage::PrePush,
355 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
356 severity: Severity::Block,
357 fix: Fix::None,
358 reach: Reach::Convention,
359 run: |ctx| hooks::pull_rebase::run(ctx.args),
360 },
361 Builtin {
368 name: "pre-push-audit-go",
369 stage: Stage::PrePush,
370 scope: Scope::new(&[], &["go.sum"]),
371 severity: Severity::Block,
372 fix: Fix::None,
373 reach: Reach::Convention,
374 run: |ctx| hooks::audit::go(ctx.push.get()),
375 },
376 Builtin {
377 name: "pre-push-audit-js",
378 stage: Stage::PrePush,
379 scope: Scope::new(&[], &["package-lock.json"]),
380 severity: Severity::Block,
381 fix: Fix::None,
382 reach: Reach::Convention,
383 run: |ctx| hooks::audit::js(ctx.push.get()),
384 },
385 Builtin {
386 name: "pre-push-audit-python",
387 stage: Stage::PrePush,
388 scope: Scope::new(&[], &["requirements.txt"]),
389 severity: Severity::Block,
390 fix: Fix::None,
391 reach: Reach::Convention,
392 run: |ctx| hooks::audit::python(ctx.push.get()),
393 },
394 Builtin {
395 name: "pre-push-audit-rust",
396 stage: Stage::PrePush,
397 scope: Scope::new(&[], &["Cargo.lock"]),
398 severity: Severity::Block,
399 fix: Fix::None,
400 reach: Reach::Convention,
401 run: |ctx| hooks::audit::rust(ctx.push.get()),
402 },
403 Builtin {
404 name: "pre-push-run-tests-js",
405 stage: Stage::PrePush,
406 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
407 .not_during(&[GitState::Bisect, GitState::Rebase]),
408 severity: Severity::Block,
409 fix: Fix::None,
410 reach: Reach::Convention,
411 run: |ctx| hooks::run_tests::run(ctx.push.get(), &ctx.manifest.externals),
412 },
413 Builtin {
414 name: "pre-push-cargo-test",
415 stage: Stage::PrePush,
416 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
417 .not_during(&[GitState::Bisect, GitState::Rebase]),
418 severity: Severity::Block,
419 fix: Fix::None,
420 reach: Reach::Convention,
421 run: |ctx| hooks::rust_tools::test(ctx.push.get()),
422 },
423 Builtin {
424 name: "pre-push-go-test",
425 stage: Stage::PrePush,
426 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
427 .not_during(&[GitState::Bisect, GitState::Rebase]),
428 severity: Severity::Block,
429 fix: Fix::None,
430 reach: Reach::Convention,
431 run: |ctx| hooks::go_tools::test(ctx.push.get()),
432 },
433 Builtin {
434 name: "pre-push-pytest",
435 stage: Stage::PrePush,
436 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
437 .not_during(&[GitState::Bisect, GitState::Rebase]),
438 severity: Severity::Block,
439 fix: Fix::None,
440 reach: Reach::Convention,
441 run: |ctx| hooks::python_tools::pytest(ctx.push.get()),
442 },
443];
444
445pub fn severity_of(check: &dyn Check) -> Severity {
451 effective_override(None, check.name()).unwrap_or_else(|| check.severity())
452}
453
454pub fn severity_key(check: &str) -> String {
456 format!("amont.severity.{check}")
457}
458
459#[derive(Debug, Default, Clone)]
470pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476pub enum Source {
477 Config,
478 Policy,
479}
480
481impl Source {
482 pub fn as_str(self) -> &'static str {
483 match self {
484 Source::Config => "config",
485 Source::Policy => "policy",
486 }
487 }
488}
489
490impl Overrides {
491 pub fn read() -> Overrides {
498 let policy = crate::policy::current();
499 match crate::git::stdout(&[
500 "config",
501 "--show-scope",
502 "--get-regexp",
503 r"^amont\.severity\.",
504 ]) {
505 Some(scoped) => Overrides::from_scoped(&scoped, policy),
506 None => Overrides::from_plain_with_policy_below(
510 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
511 policy,
512 ),
513 }
514 }
515
516 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
523 let mut below = String::new();
524 let mut above = String::new();
525 for line in scoped.lines() {
526 let Some((scope, rest)) = line.split_once('\t') else {
527 continue;
528 };
529 match scope {
530 "system" | "global" => {
531 below.push_str(rest);
532 below.push('\n');
533 }
534 _ => {
535 above.push_str(rest);
536 above.push('\n');
537 }
538 }
539 }
540 let mut o = Overrides::default();
541 o.fold_plain(&below, Source::Config);
542 o.fold_policy(policy);
543 o.fold_plain(&above, Source::Config);
544 o
545 }
546
547 pub fn from_plain_with_policy_below(
552 plain: Option<String>,
553 policy: &crate::policy::Policy,
554 ) -> Overrides {
555 let mut o = Overrides::default();
556 o.fold_policy(policy);
557 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
558 o
559 }
560
561 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
564 for (target, severity) in &policy.severities {
565 self.0.insert(target.clone(), (*severity, Source::Policy));
566 }
567 }
568
569 fn fold_plain(&mut self, text: &str, source: Source) {
573 for line in text.lines() {
574 let Some((key, value)) = line.split_once(' ') else {
575 continue;
576 };
577 let Some(check) = key.strip_prefix("amont.severity.") else {
578 continue;
579 };
580 match Severity::parse(value.trim()) {
581 Some(sev) => {
582 self.0.insert(check.to_string(), (sev, source));
583 }
584 None => {
585 self.0.remove(check);
586 }
587 }
588 }
589 }
590
591 pub fn from_config(out: Option<String>) -> Overrides {
599 let mut o = Overrides::default();
600 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
601 o
602 }
603
604 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
611 self.applied_with_source(check)
612 .map(|(pattern, severity, _)| (pattern, severity))
613 }
614
615 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
619 self.0
620 .iter()
621 .filter_map(|(pattern, (severity, source))| {
622 crate::names_check(check, pattern)
623 .map(|m| (m, pattern.as_str(), *severity, *source))
624 })
625 .max_by_key(|(m, _, _, _)| *m)
626 .map(|(_, pattern, severity, source)| (pattern, severity, source))
627 }
628
629 pub fn of(&self, check: &dyn Check) -> Severity {
631 self.applied_to(check.name())
632 .map(|(_, severity)| severity)
633 .unwrap_or_else(|| check.severity())
634 }
635}
636
637#[cfg(test)]
638mod precedence {
639 use super::{Overrides, Severity};
640
641 fn overrides(lines: &[&str]) -> Overrides {
642 let text = lines
643 .iter()
644 .map(|l| format!("amont.severity.{l}\n"))
645 .collect::<String>();
646 Overrides::from_config(Some(text))
647 }
648
649 #[test]
653 fn the_more_specific_key_wins() {
654 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
655 assert_eq!(
656 both.applied_to("pre-commit-clippy"),
657 Some(("pre-commit-clippy", Severity::Block)),
658 "a full id beats its trigger"
659 );
660 assert_eq!(
661 both.applied_to("pre-commit-shellcheck"),
662 Some(("pre-commit", Severity::Warn)),
663 "and the trigger still governs every check it did not exempt"
664 );
665 }
666
667 #[test]
670 fn the_three_ways_to_name_a_check_are_ranked() {
671 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
672 assert_eq!(
673 all.applied_to("pre-commit-clippy"),
674 Some(("pre-commit-clippy", Severity::Warn))
675 );
676
677 let no_full = overrides(&["pre-commit warn", "clippy block"]);
678 assert_eq!(
679 no_full.applied_to("pre-commit-clippy"),
680 Some(("clippy", Severity::Block)),
681 "a short name beats a trigger"
682 );
683 }
684
685 #[test]
688 fn a_key_that_names_no_check_applies_to_nothing() {
689 let typo = overrides(&["clipy warn", "e warn", " warn"]);
690 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
691 }
692}
693
694pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
704 overrides_in(repo).applied_to(check).map(|(_, s)| s)
705}
706
707pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
713 overrides_in(repo)
714 .applied_to(check)
715 .map(|(pattern, _)| pattern.to_string())
716}
717
718fn overrides_in(repo: Option<&Path>) -> Overrides {
719 match repo {
720 None => Overrides::read(),
724 Some(dir) => Overrides::from_config(crate::git::stdout_in(
728 dir,
729 &["config", "--get-regexp", r"^amont\.severity\."],
730 )),
731 }
732}
733
734pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
736 CHECKS.iter().filter(move |check| check.stage == stage)
737}
738
739pub fn all_stage_checks<'a>(
746 stage: Stage,
747 manifest: &'a crate::manifest::Manifest,
748) -> Vec<&'a dyn Check> {
749 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
750 .map(|check| check as &dyn Check)
751 .collect();
752 out.extend(
753 manifest
754 .externals
755 .iter()
756 .filter(|external| external.stage == stage)
757 .map(|external| external as &dyn Check),
758 );
759 out
760}
761
762pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
763 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
764 return Some(*f);
765 }
766 if CHECKS.iter().any(|check| check.name == name)
773 || manifest
774 .externals
775 .iter()
776 .any(|external| external.id == name)
777 {
778 return Some(|ctx: &Ctx| {
779 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
780 Verdict::blocking(matches!(
781 (check.run(ctx), severity_of(check)),
782 (Outcome::Failed, Severity::Block)
783 ))
784 });
785 }
786 None
787}
788
789pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
793 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
794 return Some(builtin);
795 }
796 manifest
797 .externals
798 .iter()
799 .find(|external| external.id == name)
800 .map(|external| external as &dyn Check)
801}
802
803#[cfg(test)]
804mod tests {
805 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
806 use std::collections::BTreeSet;
807
808 #[test]
815 fn the_batch_agrees_with_the_authority() {
816 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
817 let _ = std::fs::remove_dir_all(&d);
818 std::fs::create_dir_all(&d).unwrap();
819 let git = |args: &[&str]| {
820 std::process::Command::new("git")
821 .args(args)
822 .current_dir(&d)
823 .output()
824 .expect("git");
825 };
826 git(&["init", "-q", "--template=", "."]);
827 let key = "amont.severity.pre-commit-merge-conflict";
828 git(&["config", "--add", key, "warn"]);
829 git(&["config", "--add", key, "block"]);
830
831 let raw = std::process::Command::new("git")
832 .args(["config", "--get-regexp", r"^amont\.severity\."])
833 .current_dir(&d)
834 .output()
835 .expect("git");
836 let batch = Overrides::from_config(Some(
837 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
838 ));
839 let authority =
840 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
841 let _ = std::fs::remove_dir_all(&d);
842
843 assert_eq!(
844 authority,
845 Some(Severity::Block),
846 "git applies the last entry"
847 );
848 assert_eq!(
849 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
850 authority,
851 "the batch reader disagreed with `--get`"
852 );
853 }
854
855 #[test]
859 fn an_unrecognised_value_clears_rather_than_overrides() {
860 let o = Overrides::from_config(Some(
861 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
862 ));
863 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
864 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
865 }
866
867 #[test]
868 fn names_are_unique_across_entrypoints_and_checks() {
869 let mut seen = BTreeSet::new();
870 for n in ENTRYPOINTS
871 .iter()
872 .map(|(n, _)| *n)
873 .chain(CHECKS.iter().map(|check| check.name))
874 {
875 assert!(seen.insert(n), "duplicate registration: {n}");
876 }
877 }
878
879 #[test]
881 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
882 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
883 let mut shipped: Vec<String> = std::fs::read_dir(dir)
884 .expect("templates/hooks")
885 .flatten()
886 .map(|entry| entry.file_name().to_string_lossy().into_owned())
887 .collect();
888 shipped.sort();
889 assert_eq!(
890 shipped,
891 vec![
892 "commit-msg",
893 "post-commit",
894 "pre-commit",
895 "pre-push",
896 "prepare-commit-msg"
897 ]
898 );
899 let none = crate::manifest::Manifest::default();
900 for name in &shipped {
901 assert!(
902 lookup(name, &none).is_some(),
903 "shipped shim {name:?} has no handler"
904 );
905 }
906 }
907
908 #[test]
911 fn every_check_is_reachable_by_name() {
912 let none = crate::manifest::Manifest::default();
913 for check in CHECKS {
914 assert!(
915 lookup(check.name, &none).is_some(),
916 "{} not reachable",
917 check.name
918 );
919 }
920 assert!(lookup("pre-commit-not-a-check", &none).is_none());
921 }
922
923 #[test]
925 fn pre_push_runs_cheapest_first() {
926 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
927 .map(|check| check.name)
928 .collect();
929 assert_eq!(
930 order,
931 vec![
932 "pre-push-branch-protect",
933 "pre-push-branch-pattern",
934 "pre-push-secrets",
935 "pre-push-pull-rebase",
936 "pre-push-audit-go",
937 "pre-push-audit-js",
938 "pre-push-audit-python",
939 "pre-push-audit-rust",
940 "pre-push-run-tests-js",
941 "pre-push-cargo-test",
942 "pre-push-go-test",
943 "pre-push-pytest",
944 ]
945 );
946 }
947
948 enum Consumes {
954 All,
955 Exts(&'static [&'static str]),
956 }
957
958 const CONSUMED: &[(&str, Consumes)] = &[
972 (
973 "pre-commit-argo-lint",
974 Consumes::Exts(crate::hooks::k8s::EXTS),
975 ),
976 (
980 "pre-commit-ban-terms",
981 Consumes::Exts(crate::hooks::ban_terms::EXTS),
982 ),
983 (
984 "pre-commit-cargo-fmt",
985 Consumes::Exts(crate::hooks::rust_tools::EXTS),
986 ),
987 (
988 "pre-commit-clippy",
989 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
990 ),
991 (
992 "pre-commit-go-vet",
993 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
994 ),
995 (
996 "pre-commit-gofmt",
997 Consumes::Exts(crate::hooks::go_tools::EXTS),
998 ),
999 (
1000 "pre-commit-kube-linter",
1001 Consumes::Exts(crate::hooks::k8s::EXTS),
1002 ),
1003 (
1004 "pre-commit-kubeconform",
1005 Consumes::Exts(crate::hooks::k8s::EXTS),
1006 ),
1007 (
1008 "pre-commit-lint-js",
1009 Consumes::Exts(crate::hooks::lint_js::EXTS),
1010 ),
1011 (
1012 "pre-commit-lint-json-yaml",
1013 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1014 ),
1015 ("pre-commit-merge-conflict", Consumes::All),
1016 ("pre-commit-package-lock", Consumes::All),
1017 (
1021 "pre-commit-prettier",
1022 Consumes::Exts(crate::hooks::prettier::EXTS),
1023 ),
1024 (
1025 "pre-commit-pyright",
1026 Consumes::Exts(crate::hooks::python_tools::EXTS),
1027 ),
1028 (
1029 "pre-commit-ruff",
1030 Consumes::Exts(crate::hooks::python_tools::EXTS),
1031 ),
1032 ("pre-commit-usual-name", Consumes::All),
1033 (
1034 "pre-commit-yamllint",
1035 Consumes::Exts(crate::hooks::yamllint::EXTS),
1036 ),
1037 ("pre-commit-large-files", Consumes::All),
1038 ("pre-commit-secrets", Consumes::All),
1039 ("pre-push-secrets", Consumes::All),
1040 ("pre-push-branch-protect", Consumes::All),
1041 ("pre-push-branch-pattern", Consumes::All),
1042 ("pre-push-pull-rebase", Consumes::All),
1043 (
1044 "pre-push-run-tests-js",
1045 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1046 ),
1047 (
1048 "pre-push-pytest",
1049 Consumes::Exts(crate::hooks::python_tools::EXTS),
1050 ),
1051 (
1052 "pre-push-cargo-test",
1053 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1054 ),
1055 (
1056 "pre-push-go-test",
1057 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1058 ),
1059 ];
1060
1061 #[test]
1072 fn no_check_declares_a_file_type_it_does_not_consume() {
1073 for (name, _) in CONSUMED {
1074 assert!(
1075 CHECKS.iter().any(|check| check.name == *name),
1076 "CONSUMED names {name:?}, which is not a check"
1077 );
1078 }
1079 for check in CHECKS {
1080 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1081 if check.scope.files.is_empty() && entry.is_none() {
1082 continue;
1083 }
1084 let Some((_, consumes)) = entry else {
1085 panic!(
1086 "{} declares scope.files {:?} but is missing from CONSUMED — \
1087 say what it actually reads",
1088 check.name, check.scope.files
1089 );
1090 };
1091 let Consumes::Exts(consumed) = consumes else {
1092 continue; };
1094 for ext in check.scope.files {
1095 assert!(
1096 consumed.contains(ext),
1097 "{} declares {ext:?} in its scope but never asks for it — \
1098 `amont list` would report a coverage the check does not have",
1099 check.name
1100 );
1101 }
1102 }
1103 }
1104
1105 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1114 ("pre-commit-argo-lint", false),
1115 ("pre-commit-ban-terms", false),
1116 ("pre-commit-branch-pattern", false),
1117 ("pre-commit-cargo-fmt", true),
1118 ("pre-commit-clippy", false),
1119 ("pre-commit-go-vet", false),
1120 ("pre-commit-gofmt", true),
1121 ("pre-commit-kube-linter", false),
1122 ("pre-commit-kubeconform", false),
1123 ("pre-commit-lint-js", false),
1124 ("pre-commit-lint-json-yaml", false),
1125 ("pre-commit-merge-conflict", false),
1126 ("pre-commit-package-lock", false),
1127 ("pre-commit-prettier", true),
1128 ("pre-commit-pyright", false),
1129 ("pre-commit-ruff", true),
1130 ("pre-commit-usual-name", false),
1131 ("pre-commit-yamllint", false),
1132 ("pre-commit-large-files", false),
1133 ("pre-commit-secrets", false),
1134 ("pre-push-secrets", false),
1135 ("pre-push-branch-protect", false),
1136 ("pre-push-branch-pattern", false),
1137 ("pre-push-pull-rebase", false),
1138 ("pre-push-audit-go", false),
1139 ("pre-push-audit-js", false),
1140 ("pre-push-audit-python", false),
1141 ("pre-push-audit-rust", false),
1142 ("pre-push-run-tests-js", false),
1143 ("pre-push-cargo-test", false),
1144 ("pre-push-go-test", false),
1145 ("pre-push-pytest", false),
1146 ];
1147
1148 #[test]
1151 fn every_rewrite_declaration_has_a_fixer() {
1152 let declared: BTreeSet<&str> = CHECKS
1153 .iter()
1154 .filter(|check| check.fix == super::Fix::Rewrite)
1155 .map(|check| check.name)
1156 .collect();
1157 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1158 .iter()
1159 .filter(|(_, has)| *has)
1160 .map(|(name, _)| *name)
1161 .collect();
1162 assert_eq!(
1163 declared, implemented,
1164 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1165 and a check with a fixer that does not declare it can never be reached"
1166 );
1167
1168 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1171 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1172 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1173 }
1174
1175 #[test]
1186 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1187 let safety: Vec<&str> = CHECKS
1188 .iter()
1189 .filter(|c| c.reach == Reach::Safety)
1190 .map(|c| c.name)
1191 .collect();
1192 assert_eq!(
1193 safety,
1194 vec![
1195 "pre-commit-ban-terms",
1196 "pre-commit-large-files",
1197 "pre-commit-merge-conflict",
1198 "pre-commit-secrets",
1199 "pre-push-secrets",
1200 ]
1201 );
1202 }
1203
1204 #[test]
1205 fn every_check_declares_a_stage_and_a_scope() {
1206 assert_eq!(CHECKS.len(), 32);
1207 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1208 let pre_push = super::stage_checks(Stage::PrePush).count();
1209 assert_eq!(
1210 pre_commit + pre_push,
1211 CHECKS.len(),
1212 "every check has a stage"
1213 );
1214 }
1215}