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 {
138 name: "pre-commit-branch-protect",
139 stage: Stage::PreCommit,
140 scope: Scope::ALWAYS,
141 severity: Severity::Warn,
142 fix: Fix::None,
143 reach: Reach::Convention,
144 run: |_ctx| hooks::branch_protect::early(),
145 },
146 Builtin {
147 name: "pre-commit-cargo-fmt",
148 stage: Stage::PreCommit,
149 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
150 severity: Severity::Block,
151 fix: Fix::Rewrite,
152 reach: Reach::Convention,
153 run: |ctx| hooks::rust_tools::fmt(ctx.args),
154 },
155 Builtin {
156 name: "pre-commit-clippy",
157 stage: Stage::PreCommit,
158 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
159 severity: Severity::Block,
160 fix: Fix::None,
161 reach: Reach::Convention,
162 run: |ctx| hooks::rust_tools::clippy(ctx.args),
163 },
164 Builtin {
165 name: "pre-commit-go-vet",
166 stage: Stage::PreCommit,
167 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
168 severity: Severity::Block,
169 fix: Fix::None,
170 reach: Reach::Convention,
171 run: |ctx| hooks::go_tools::vet(ctx.args),
172 },
173 Builtin {
174 name: "pre-commit-gofmt",
175 stage: Stage::PreCommit,
176 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
177 severity: Severity::Block,
178 fix: Fix::Rewrite,
179 reach: Reach::Convention,
180 run: |ctx| hooks::go_tools::fmt(ctx.args),
181 },
182 Builtin {
183 name: "pre-commit-kube-linter",
184 stage: Stage::PreCommit,
185 scope: Scope::new(
186 hooks::k8s::EXTS,
187 &[".kube-linter*.yaml", ".kube-linter*.yml"],
188 )
189 .not_during(MID_OPERATION),
190 severity: Severity::Block,
191 fix: Fix::None,
192 reach: Reach::Convention,
193 run: |ctx| hooks::k8s::kube_linter(ctx.args),
194 },
195 Builtin {
196 name: "pre-commit-kubeconform",
197 stage: Stage::PreCommit,
198 scope: Scope::new(
199 hooks::k8s::EXTS,
200 &["kustomization.yaml", "kustomization.yml"],
201 )
202 .not_during(MID_OPERATION),
203 severity: Severity::Block,
204 fix: Fix::None,
205 reach: Reach::Convention,
206 run: |ctx| hooks::k8s::kubeconform(ctx.args),
207 },
208 Builtin {
209 name: "pre-commit-large-files",
210 stage: Stage::PreCommit,
211 scope: Scope::ALWAYS,
212 severity: Severity::Block,
213 fix: Fix::None,
214 reach: Reach::Safety,
215 run: |_ctx| hooks::large_files::staged(),
216 },
217 Builtin {
218 name: "pre-commit-lint-js",
219 stage: Stage::PreCommit,
220 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
221 severity: Severity::Block,
222 fix: Fix::None,
223 reach: Reach::Convention,
224 run: |ctx| hooks::lint_js::run(ctx.args),
225 },
226 Builtin {
227 name: "pre-commit-lint-json-yaml",
228 stage: Stage::PreCommit,
229 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
230 severity: Severity::Block,
231 fix: Fix::None,
232 reach: Reach::Convention,
233 run: |ctx| hooks::lint_json_yaml::run(ctx.args),
234 },
235 Builtin {
236 name: "pre-commit-merge-conflict",
237 stage: Stage::PreCommit,
238 scope: Scope::ALWAYS,
239 severity: Severity::Block,
240 fix: Fix::None,
241 reach: Reach::Safety,
242 run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
243 },
244 Builtin {
245 name: "pre-commit-package-lock",
246 stage: Stage::PreCommit,
247 scope: Scope::new(&[], &["package.json"]),
248 severity: Severity::Block,
249 fix: Fix::None,
250 reach: Reach::Convention,
251 run: |ctx| hooks::package_lock::run(ctx.args),
252 },
253 Builtin {
254 name: "pre-commit-prettier",
255 stage: Stage::PreCommit,
256 scope: Scope::new(
257 &[],
258 &[
259 ".prettierrc",
260 ".prettierrc.json",
261 ".prettierrc.yml",
262 ".prettierrc.yaml",
263 ".prettierrc.js",
264 "prettier.config.js",
265 ],
266 )
267 .not_during(MID_OPERATION),
268 severity: Severity::Block,
269 fix: Fix::Rewrite,
270 reach: Reach::Convention,
271 run: |ctx| hooks::prettier::run(ctx.args),
272 },
273 Builtin {
274 name: "pre-commit-pyright",
275 stage: Stage::PreCommit,
276 scope: Scope::new(
277 hooks::python_tools::EXTS,
278 &[
279 "pyrightconfig.json",
280 "pyrightconfig.jsonc",
281 "pyproject.toml",
282 ],
283 )
284 .not_during(MID_OPERATION),
285 severity: Severity::Block,
286 fix: Fix::None,
287 reach: Reach::Convention,
288 run: |ctx| hooks::python_tools::pyright(ctx.args),
289 },
290 Builtin {
291 name: "pre-commit-ruff",
292 stage: Stage::PreCommit,
293 scope: Scope::new(
294 hooks::python_tools::EXTS,
295 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
296 )
297 .not_during(MID_OPERATION),
298 severity: Severity::Block,
299 fix: Fix::Rewrite,
300 reach: Reach::Convention,
301 run: |ctx| hooks::python_tools::ruff(ctx.args),
302 },
303 Builtin {
307 name: "pre-commit-secrets",
308 stage: Stage::PreCommit,
309 scope: Scope::ALWAYS,
310 severity: Severity::Block,
311 fix: Fix::None,
312 reach: Reach::Safety,
313 run: |_ctx| hooks::secrets::staged(),
314 },
315 Builtin {
316 name: "pre-commit-usual-name",
317 stage: Stage::PreCommit,
318 scope: Scope::ALWAYS,
319 severity: Severity::Block,
320 fix: Fix::None,
321 reach: Reach::Convention,
322 run: |ctx| hooks::usual_name::run(ctx.args),
323 },
324 Builtin {
325 name: "pre-commit-yamllint",
326 stage: Stage::PreCommit,
327 scope: Scope::new(
328 hooks::yamllint::EXTS,
329 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
330 )
331 .not_during(MID_OPERATION),
332 severity: Severity::Block,
333 fix: Fix::None,
334 reach: Reach::Convention,
335 run: |ctx| hooks::yamllint::run(ctx.args),
336 },
337 Builtin {
339 name: "pre-push-branch-protect",
340 stage: Stage::PrePush,
341 scope: Scope::ALWAYS,
342 severity: Severity::Block,
343 fix: Fix::None,
344 reach: Reach::Convention,
345 run: |ctx| hooks::branch_protect::run(ctx.push.get()),
346 },
347 Builtin {
348 name: "pre-push-branch-pattern",
349 stage: Stage::PrePush,
350 scope: Scope::ALWAYS,
351 severity: Severity::Block,
352 fix: Fix::None,
353 reach: Reach::Convention,
354 run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
355 },
356 Builtin {
357 name: "pre-push-secrets",
358 stage: Stage::PrePush,
359 scope: Scope::ALWAYS,
360 severity: Severity::Block,
361 fix: Fix::None,
362 reach: Reach::Safety,
363 run: |ctx| hooks::secrets::pushed(ctx.push.get()),
364 },
365 Builtin {
366 name: "pre-push-pull-rebase",
367 stage: Stage::PrePush,
368 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
369 severity: Severity::Block,
370 fix: Fix::None,
371 reach: Reach::Convention,
372 run: |ctx| hooks::pull_rebase::run(ctx.args),
373 },
374 Builtin {
381 name: "pre-push-audit-go",
382 stage: Stage::PrePush,
383 scope: Scope::new(&[], &["go.sum"]),
384 severity: Severity::Block,
385 fix: Fix::None,
386 reach: Reach::Convention,
387 run: |ctx| hooks::audit::go(ctx.push.get()),
388 },
389 Builtin {
390 name: "pre-push-audit-js",
391 stage: Stage::PrePush,
392 scope: Scope::new(&[], &["package-lock.json"]),
393 severity: Severity::Block,
394 fix: Fix::None,
395 reach: Reach::Convention,
396 run: |ctx| hooks::audit::js(ctx.push.get()),
397 },
398 Builtin {
399 name: "pre-push-audit-python",
400 stage: Stage::PrePush,
401 scope: Scope::new(&[], &["requirements.txt"]),
402 severity: Severity::Block,
403 fix: Fix::None,
404 reach: Reach::Convention,
405 run: |ctx| hooks::audit::python(ctx.push.get()),
406 },
407 Builtin {
408 name: "pre-push-audit-rust",
409 stage: Stage::PrePush,
410 scope: Scope::new(&[], &["Cargo.lock"]),
411 severity: Severity::Block,
412 fix: Fix::None,
413 reach: Reach::Convention,
414 run: |ctx| hooks::audit::rust(ctx.push.get()),
415 },
416 Builtin {
417 name: "pre-push-run-tests-js",
418 stage: Stage::PrePush,
419 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
420 .not_during(&[GitState::Bisect, GitState::Rebase]),
421 severity: Severity::Block,
422 fix: Fix::None,
423 reach: Reach::Convention,
424 run: |ctx| hooks::run_tests::run(ctx.push.get(), &ctx.manifest.externals),
425 },
426 Builtin {
427 name: "pre-push-cargo-test",
428 stage: Stage::PrePush,
429 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
430 .not_during(&[GitState::Bisect, GitState::Rebase]),
431 severity: Severity::Block,
432 fix: Fix::None,
433 reach: Reach::Convention,
434 run: |ctx| hooks::rust_tools::test(ctx.push.get()),
435 },
436 Builtin {
437 name: "pre-push-go-test",
438 stage: Stage::PrePush,
439 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
440 .not_during(&[GitState::Bisect, GitState::Rebase]),
441 severity: Severity::Block,
442 fix: Fix::None,
443 reach: Reach::Convention,
444 run: |ctx| hooks::go_tools::test(ctx.push.get()),
445 },
446 Builtin {
447 name: "pre-push-pytest",
448 stage: Stage::PrePush,
449 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
450 .not_during(&[GitState::Bisect, GitState::Rebase]),
451 severity: Severity::Block,
452 fix: Fix::None,
453 reach: Reach::Convention,
454 run: |ctx| hooks::python_tools::pytest(ctx.push.get()),
455 },
456];
457
458pub fn severity_of(check: &dyn Check) -> Severity {
464 effective_override(None, check.name()).unwrap_or_else(|| check.severity())
465}
466
467pub fn severity_key(check: &str) -> String {
469 format!("amont.severity.{check}")
470}
471
472#[derive(Debug, Default, Clone)]
483pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489pub enum Source {
490 Config,
491 Policy,
492}
493
494impl Source {
495 pub fn as_str(self) -> &'static str {
496 match self {
497 Source::Config => "config",
498 Source::Policy => "policy",
499 }
500 }
501}
502
503impl Overrides {
504 pub fn read() -> Overrides {
511 let policy = crate::policy::current();
512 match crate::git::stdout(&[
513 "config",
514 "--show-scope",
515 "--get-regexp",
516 r"^amont\.severity\.",
517 ]) {
518 Some(scoped) => Overrides::from_scoped(&scoped, policy),
519 None => Overrides::from_plain_with_policy_below(
523 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
524 policy,
525 ),
526 }
527 }
528
529 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
536 let mut below = String::new();
537 let mut above = String::new();
538 for line in scoped.lines() {
539 let Some((scope, rest)) = line.split_once('\t') else {
540 continue;
541 };
542 match scope {
543 "system" | "global" => {
544 below.push_str(rest);
545 below.push('\n');
546 }
547 _ => {
548 above.push_str(rest);
549 above.push('\n');
550 }
551 }
552 }
553 let mut o = Overrides::default();
554 o.fold_plain(&below, Source::Config);
555 o.fold_policy(policy);
556 o.fold_plain(&above, Source::Config);
557 o
558 }
559
560 pub fn from_plain_with_policy_below(
565 plain: Option<String>,
566 policy: &crate::policy::Policy,
567 ) -> Overrides {
568 let mut o = Overrides::default();
569 o.fold_policy(policy);
570 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
571 o
572 }
573
574 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
577 for (target, severity) in &policy.severities {
578 self.0.insert(target.clone(), (*severity, Source::Policy));
579 }
580 }
581
582 fn fold_plain(&mut self, text: &str, source: Source) {
586 for line in text.lines() {
587 let Some((key, value)) = line.split_once(' ') else {
588 continue;
589 };
590 let Some(check) = key.strip_prefix("amont.severity.") else {
591 continue;
592 };
593 match Severity::parse(value.trim()) {
594 Some(sev) => {
595 self.0.insert(check.to_string(), (sev, source));
596 }
597 None => {
598 self.0.remove(check);
599 }
600 }
601 }
602 }
603
604 pub fn from_config(out: Option<String>) -> Overrides {
612 let mut o = Overrides::default();
613 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
614 o
615 }
616
617 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
624 self.applied_with_source(check)
625 .map(|(pattern, severity, _)| (pattern, severity))
626 }
627
628 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
632 self.0
633 .iter()
634 .filter_map(|(pattern, (severity, source))| {
635 crate::names_check(check, pattern)
636 .map(|m| (m, pattern.as_str(), *severity, *source))
637 })
638 .max_by_key(|(m, _, _, _)| *m)
639 .map(|(_, pattern, severity, source)| (pattern, severity, source))
640 }
641
642 pub fn of(&self, check: &dyn Check) -> Severity {
644 self.applied_to(check.name())
645 .map(|(_, severity)| severity)
646 .unwrap_or_else(|| check.severity())
647 }
648}
649
650#[cfg(test)]
651mod precedence {
652 use super::{Overrides, Severity};
653
654 fn overrides(lines: &[&str]) -> Overrides {
655 let text = lines
656 .iter()
657 .map(|l| format!("amont.severity.{l}\n"))
658 .collect::<String>();
659 Overrides::from_config(Some(text))
660 }
661
662 #[test]
666 fn the_more_specific_key_wins() {
667 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
668 assert_eq!(
669 both.applied_to("pre-commit-clippy"),
670 Some(("pre-commit-clippy", Severity::Block)),
671 "a full id beats its trigger"
672 );
673 assert_eq!(
674 both.applied_to("pre-commit-shellcheck"),
675 Some(("pre-commit", Severity::Warn)),
676 "and the trigger still governs every check it did not exempt"
677 );
678 }
679
680 #[test]
683 fn the_three_ways_to_name_a_check_are_ranked() {
684 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
685 assert_eq!(
686 all.applied_to("pre-commit-clippy"),
687 Some(("pre-commit-clippy", Severity::Warn))
688 );
689
690 let no_full = overrides(&["pre-commit warn", "clippy block"]);
691 assert_eq!(
692 no_full.applied_to("pre-commit-clippy"),
693 Some(("clippy", Severity::Block)),
694 "a short name beats a trigger"
695 );
696 }
697
698 #[test]
701 fn a_key_that_names_no_check_applies_to_nothing() {
702 let typo = overrides(&["clipy warn", "e warn", " warn"]);
703 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
704 }
705}
706
707pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
717 overrides_in(repo).applied_to(check).map(|(_, s)| s)
718}
719
720pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
726 overrides_in(repo)
727 .applied_to(check)
728 .map(|(pattern, _)| pattern.to_string())
729}
730
731fn overrides_in(repo: Option<&Path>) -> Overrides {
732 match repo {
733 None => Overrides::read(),
737 Some(dir) => Overrides::from_config(crate::git::stdout_in(
741 dir,
742 &["config", "--get-regexp", r"^amont\.severity\."],
743 )),
744 }
745}
746
747pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
749 CHECKS.iter().filter(move |check| check.stage == stage)
750}
751
752pub fn all_stage_checks<'a>(
759 stage: Stage,
760 manifest: &'a crate::manifest::Manifest,
761) -> Vec<&'a dyn Check> {
762 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
763 .map(|check| check as &dyn Check)
764 .collect();
765 out.extend(
766 manifest
767 .externals
768 .iter()
769 .filter(|external| external.stage == stage)
770 .map(|external| external as &dyn Check),
771 );
772 out
773}
774
775pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
776 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
777 return Some(*f);
778 }
779 if CHECKS.iter().any(|check| check.name == name)
786 || manifest
787 .externals
788 .iter()
789 .any(|external| external.id == name)
790 {
791 return Some(|ctx: &Ctx| {
792 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
793 Verdict::blocking(matches!(
794 (check.run(ctx), severity_of(check)),
795 (Outcome::Failed, Severity::Block)
796 ))
797 });
798 }
799 None
800}
801
802pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
806 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
807 return Some(builtin);
808 }
809 manifest
810 .externals
811 .iter()
812 .find(|external| external.id == name)
813 .map(|external| external as &dyn Check)
814}
815
816#[cfg(test)]
817mod tests {
818 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
819 use std::collections::BTreeSet;
820
821 #[test]
828 fn the_batch_agrees_with_the_authority() {
829 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
830 let _ = std::fs::remove_dir_all(&d);
831 std::fs::create_dir_all(&d).unwrap();
832 let git = |args: &[&str]| {
833 std::process::Command::new("git")
834 .args(args)
835 .current_dir(&d)
836 .output()
837 .expect("git");
838 };
839 git(&["init", "-q", "--template=", "."]);
840 let key = "amont.severity.pre-commit-merge-conflict";
841 git(&["config", "--add", key, "warn"]);
842 git(&["config", "--add", key, "block"]);
843
844 let raw = std::process::Command::new("git")
845 .args(["config", "--get-regexp", r"^amont\.severity\."])
846 .current_dir(&d)
847 .output()
848 .expect("git");
849 let batch = Overrides::from_config(Some(
850 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
851 ));
852 let authority =
853 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
854 let _ = std::fs::remove_dir_all(&d);
855
856 assert_eq!(
857 authority,
858 Some(Severity::Block),
859 "git applies the last entry"
860 );
861 assert_eq!(
862 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
863 authority,
864 "the batch reader disagreed with `--get`"
865 );
866 }
867
868 #[test]
872 fn an_unrecognised_value_clears_rather_than_overrides() {
873 let o = Overrides::from_config(Some(
874 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
875 ));
876 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
877 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
878 }
879
880 #[test]
881 fn names_are_unique_across_entrypoints_and_checks() {
882 let mut seen = BTreeSet::new();
883 for n in ENTRYPOINTS
884 .iter()
885 .map(|(n, _)| *n)
886 .chain(CHECKS.iter().map(|check| check.name))
887 {
888 assert!(seen.insert(n), "duplicate registration: {n}");
889 }
890 }
891
892 #[test]
894 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
895 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
896 let mut shipped: Vec<String> = std::fs::read_dir(dir)
897 .expect("templates/hooks")
898 .flatten()
899 .map(|entry| entry.file_name().to_string_lossy().into_owned())
900 .collect();
901 shipped.sort();
902 assert_eq!(
903 shipped,
904 vec![
905 "commit-msg",
906 "post-commit",
907 "pre-commit",
908 "pre-push",
909 "prepare-commit-msg"
910 ]
911 );
912 let none = crate::manifest::Manifest::default();
913 for name in &shipped {
914 assert!(
915 lookup(name, &none).is_some(),
916 "shipped shim {name:?} has no handler"
917 );
918 }
919 }
920
921 #[test]
924 fn every_check_is_reachable_by_name() {
925 let none = crate::manifest::Manifest::default();
926 for check in CHECKS {
927 assert!(
928 lookup(check.name, &none).is_some(),
929 "{} not reachable",
930 check.name
931 );
932 }
933 assert!(lookup("pre-commit-not-a-check", &none).is_none());
934 }
935
936 #[test]
938 fn pre_push_runs_cheapest_first() {
939 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
940 .map(|check| check.name)
941 .collect();
942 assert_eq!(
943 order,
944 vec![
945 "pre-push-branch-protect",
946 "pre-push-branch-pattern",
947 "pre-push-secrets",
948 "pre-push-pull-rebase",
949 "pre-push-audit-go",
950 "pre-push-audit-js",
951 "pre-push-audit-python",
952 "pre-push-audit-rust",
953 "pre-push-run-tests-js",
954 "pre-push-cargo-test",
955 "pre-push-go-test",
956 "pre-push-pytest",
957 ]
958 );
959 }
960
961 enum Consumes {
967 All,
968 Exts(&'static [&'static str]),
969 }
970
971 const CONSUMED: &[(&str, Consumes)] = &[
985 (
986 "pre-commit-argo-lint",
987 Consumes::Exts(crate::hooks::k8s::EXTS),
988 ),
989 (
993 "pre-commit-ban-terms",
994 Consumes::Exts(crate::hooks::ban_terms::EXTS),
995 ),
996 (
997 "pre-commit-cargo-fmt",
998 Consumes::Exts(crate::hooks::rust_tools::EXTS),
999 ),
1000 (
1001 "pre-commit-clippy",
1002 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1003 ),
1004 (
1005 "pre-commit-go-vet",
1006 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1007 ),
1008 (
1009 "pre-commit-gofmt",
1010 Consumes::Exts(crate::hooks::go_tools::EXTS),
1011 ),
1012 (
1013 "pre-commit-kube-linter",
1014 Consumes::Exts(crate::hooks::k8s::EXTS),
1015 ),
1016 (
1017 "pre-commit-kubeconform",
1018 Consumes::Exts(crate::hooks::k8s::EXTS),
1019 ),
1020 (
1021 "pre-commit-lint-js",
1022 Consumes::Exts(crate::hooks::lint_js::EXTS),
1023 ),
1024 (
1025 "pre-commit-lint-json-yaml",
1026 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1027 ),
1028 ("pre-commit-merge-conflict", Consumes::All),
1029 ("pre-commit-package-lock", Consumes::All),
1030 (
1034 "pre-commit-prettier",
1035 Consumes::Exts(crate::hooks::prettier::EXTS),
1036 ),
1037 (
1038 "pre-commit-pyright",
1039 Consumes::Exts(crate::hooks::python_tools::EXTS),
1040 ),
1041 (
1042 "pre-commit-ruff",
1043 Consumes::Exts(crate::hooks::python_tools::EXTS),
1044 ),
1045 ("pre-commit-usual-name", Consumes::All),
1046 (
1047 "pre-commit-yamllint",
1048 Consumes::Exts(crate::hooks::yamllint::EXTS),
1049 ),
1050 ("pre-commit-large-files", Consumes::All),
1051 ("pre-commit-secrets", Consumes::All),
1052 ("pre-push-secrets", Consumes::All),
1053 ("pre-push-branch-protect", Consumes::All),
1054 ("pre-push-branch-pattern", Consumes::All),
1055 ("pre-push-pull-rebase", Consumes::All),
1056 (
1057 "pre-push-run-tests-js",
1058 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1059 ),
1060 (
1061 "pre-push-pytest",
1062 Consumes::Exts(crate::hooks::python_tools::EXTS),
1063 ),
1064 (
1065 "pre-push-cargo-test",
1066 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1067 ),
1068 (
1069 "pre-push-go-test",
1070 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1071 ),
1072 ];
1073
1074 #[test]
1085 fn no_check_declares_a_file_type_it_does_not_consume() {
1086 for (name, _) in CONSUMED {
1087 assert!(
1088 CHECKS.iter().any(|check| check.name == *name),
1089 "CONSUMED names {name:?}, which is not a check"
1090 );
1091 }
1092 for check in CHECKS {
1093 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1094 if check.scope.files.is_empty() && entry.is_none() {
1095 continue;
1096 }
1097 let Some((_, consumes)) = entry else {
1098 panic!(
1099 "{} declares scope.files {:?} but is missing from CONSUMED — \
1100 say what it actually reads",
1101 check.name, check.scope.files
1102 );
1103 };
1104 let Consumes::Exts(consumed) = consumes else {
1105 continue; };
1107 for ext in check.scope.files {
1108 assert!(
1109 consumed.contains(ext),
1110 "{} declares {ext:?} in its scope but never asks for it — \
1111 `amont list` would report a coverage the check does not have",
1112 check.name
1113 );
1114 }
1115 }
1116 }
1117
1118 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1127 ("pre-commit-argo-lint", false),
1128 ("pre-commit-ban-terms", false),
1129 ("pre-commit-branch-pattern", false),
1130 ("pre-commit-branch-protect", false),
1131 ("pre-commit-cargo-fmt", true),
1132 ("pre-commit-clippy", false),
1133 ("pre-commit-go-vet", false),
1134 ("pre-commit-gofmt", true),
1135 ("pre-commit-kube-linter", false),
1136 ("pre-commit-kubeconform", false),
1137 ("pre-commit-lint-js", false),
1138 ("pre-commit-lint-json-yaml", false),
1139 ("pre-commit-merge-conflict", false),
1140 ("pre-commit-package-lock", false),
1141 ("pre-commit-prettier", true),
1142 ("pre-commit-pyright", false),
1143 ("pre-commit-ruff", true),
1144 ("pre-commit-usual-name", false),
1145 ("pre-commit-yamllint", false),
1146 ("pre-commit-large-files", false),
1147 ("pre-commit-secrets", false),
1148 ("pre-push-secrets", false),
1149 ("pre-push-branch-protect", false),
1150 ("pre-push-branch-pattern", false),
1151 ("pre-push-pull-rebase", false),
1152 ("pre-push-audit-go", false),
1153 ("pre-push-audit-js", false),
1154 ("pre-push-audit-python", false),
1155 ("pre-push-audit-rust", false),
1156 ("pre-push-run-tests-js", false),
1157 ("pre-push-cargo-test", false),
1158 ("pre-push-go-test", false),
1159 ("pre-push-pytest", false),
1160 ];
1161
1162 #[test]
1165 fn every_rewrite_declaration_has_a_fixer() {
1166 let declared: BTreeSet<&str> = CHECKS
1167 .iter()
1168 .filter(|check| check.fix == super::Fix::Rewrite)
1169 .map(|check| check.name)
1170 .collect();
1171 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1172 .iter()
1173 .filter(|(_, has)| *has)
1174 .map(|(name, _)| *name)
1175 .collect();
1176 assert_eq!(
1177 declared, implemented,
1178 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1179 and a check with a fixer that does not declare it can never be reached"
1180 );
1181
1182 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1185 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1186 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1187 }
1188
1189 #[test]
1200 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1201 let safety: Vec<&str> = CHECKS
1202 .iter()
1203 .filter(|c| c.reach == Reach::Safety)
1204 .map(|c| c.name)
1205 .collect();
1206 assert_eq!(
1207 safety,
1208 vec![
1209 "pre-commit-ban-terms",
1210 "pre-commit-large-files",
1211 "pre-commit-merge-conflict",
1212 "pre-commit-secrets",
1213 "pre-push-secrets",
1214 ]
1215 );
1216 }
1217
1218 #[test]
1219 fn every_check_declares_a_stage_and_a_scope() {
1220 assert_eq!(CHECKS.len(), 33);
1221 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1222 let pre_push = super::stage_checks(Stage::PrePush).count();
1223 assert_eq!(
1224 pre_commit + pre_push,
1225 CHECKS.len(),
1226 "every check has a stage"
1227 );
1228 }
1229}