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 {
102 name: "pre-commit-agents-md",
103 stage: Stage::PreCommit,
104 scope: Scope::ALWAYS.not_during(MID_OPERATION),
105 severity: Severity::Warn,
106 fix: Fix::Rewrite,
107 reach: Reach::Convention,
108 run: |_ctx| hooks::agents_md_drift::run(),
109 },
110 Builtin {
111 name: "pre-commit-argo-lint",
112 stage: Stage::PreCommit,
113 scope: Scope::new(
114 hooks::k8s::EXTS,
115 &["kustomization.yaml", "kustomization.yml"],
116 )
117 .not_during(MID_OPERATION),
118 severity: Severity::Block,
119 fix: Fix::None,
120 reach: Reach::Convention,
121 run: |ctx| hooks::k8s::argo_lint(ctx.args),
122 },
123 Builtin {
124 name: "pre-commit-ban-terms",
125 stage: Stage::PreCommit,
126 scope: Scope::files(hooks::ban_terms::EXTS),
127 severity: Severity::Block,
128 fix: Fix::None,
129 reach: Reach::Safety,
130 run: |ctx| hooks::ban_terms::run(ctx.name, ctx.args),
131 },
132 Builtin {
137 name: "pre-commit-branch-pattern",
138 stage: Stage::PreCommit,
139 scope: Scope::ALWAYS,
140 severity: Severity::Warn,
141 fix: Fix::None,
142 reach: Reach::Convention,
143 run: |_ctx| hooks::branch_pattern::early(),
144 },
145 Builtin {
150 name: "pre-commit-branch-protect",
151 stage: Stage::PreCommit,
152 scope: Scope::ALWAYS,
153 severity: Severity::Warn,
154 fix: Fix::None,
155 reach: Reach::Convention,
156 run: |_ctx| hooks::branch_protect::early(),
157 },
158 Builtin {
159 name: "pre-commit-cargo-fmt",
160 stage: Stage::PreCommit,
161 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
162 severity: Severity::Block,
163 fix: Fix::Rewrite,
164 reach: Reach::Convention,
165 run: |ctx| hooks::rust_tools::fmt(ctx.args),
166 },
167 Builtin {
168 name: "pre-commit-clippy",
169 stage: Stage::PreCommit,
170 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
171 severity: Severity::Block,
172 fix: Fix::None,
173 reach: Reach::Convention,
174 run: |ctx| hooks::rust_tools::clippy(ctx.args),
175 },
176 Builtin {
177 name: "pre-commit-go-vet",
178 stage: Stage::PreCommit,
179 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
180 severity: Severity::Block,
181 fix: Fix::None,
182 reach: Reach::Convention,
183 run: |ctx| hooks::go_tools::vet(ctx.args),
184 },
185 Builtin {
186 name: "pre-commit-gofmt",
187 stage: Stage::PreCommit,
188 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"]).not_during(MID_OPERATION),
189 severity: Severity::Block,
190 fix: Fix::Rewrite,
191 reach: Reach::Convention,
192 run: |ctx| hooks::go_tools::fmt(ctx.args),
193 },
194 Builtin {
195 name: "pre-commit-kube-linter",
196 stage: Stage::PreCommit,
197 scope: Scope::new(
198 hooks::k8s::EXTS,
199 &[".kube-linter*.yaml", ".kube-linter*.yml"],
200 )
201 .not_during(MID_OPERATION),
202 severity: Severity::Block,
203 fix: Fix::None,
204 reach: Reach::Convention,
205 run: |ctx| hooks::k8s::kube_linter(ctx.args),
206 },
207 Builtin {
208 name: "pre-commit-kubeconform",
209 stage: Stage::PreCommit,
210 scope: Scope::new(
211 hooks::k8s::EXTS,
212 &["kustomization.yaml", "kustomization.yml"],
213 )
214 .not_during(MID_OPERATION),
215 severity: Severity::Block,
216 fix: Fix::None,
217 reach: Reach::Convention,
218 run: |ctx| hooks::k8s::kubeconform(ctx.args),
219 },
220 Builtin {
221 name: "pre-commit-large-files",
222 stage: Stage::PreCommit,
223 scope: Scope::ALWAYS,
224 severity: Severity::Block,
225 fix: Fix::None,
226 reach: Reach::Safety,
227 run: |_ctx| hooks::large_files::staged(),
228 },
229 Builtin {
230 name: "pre-commit-lint-js",
231 stage: Stage::PreCommit,
232 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
233 severity: Severity::Block,
234 fix: Fix::None,
235 reach: Reach::Convention,
236 run: |ctx| hooks::lint_js::run(ctx.args),
237 },
238 Builtin {
239 name: "pre-commit-lint-json-yaml",
240 stage: Stage::PreCommit,
241 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
242 severity: Severity::Block,
243 fix: Fix::None,
244 reach: Reach::Convention,
245 run: |ctx| hooks::lint_json_yaml::run(ctx.args),
246 },
247 Builtin {
248 name: "pre-commit-merge-conflict",
249 stage: Stage::PreCommit,
250 scope: Scope::ALWAYS,
251 severity: Severity::Block,
252 fix: Fix::None,
253 reach: Reach::Safety,
254 run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
255 },
256 Builtin {
257 name: "pre-commit-package-lock",
258 stage: Stage::PreCommit,
259 scope: Scope::new(&[], &["package.json"]),
260 severity: Severity::Block,
261 fix: Fix::None,
262 reach: Reach::Convention,
263 run: |ctx| hooks::package_lock::run(ctx.args),
264 },
265 Builtin {
266 name: "pre-commit-prettier",
267 stage: Stage::PreCommit,
268 scope: Scope::new(
269 &[],
270 &[
271 ".prettierrc",
272 ".prettierrc.json",
273 ".prettierrc.yml",
274 ".prettierrc.yaml",
275 ".prettierrc.js",
276 "prettier.config.js",
277 ],
278 )
279 .not_during(MID_OPERATION),
280 severity: Severity::Block,
281 fix: Fix::Rewrite,
282 reach: Reach::Convention,
283 run: |ctx| hooks::prettier::run(ctx.args),
284 },
285 Builtin {
286 name: "pre-commit-pyright",
287 stage: Stage::PreCommit,
288 scope: Scope::new(
289 hooks::python_tools::EXTS,
290 &[
291 "pyrightconfig.json",
292 "pyrightconfig.jsonc",
293 "pyproject.toml",
294 ],
295 )
296 .not_during(MID_OPERATION),
297 severity: Severity::Block,
298 fix: Fix::None,
299 reach: Reach::Convention,
300 run: |ctx| hooks::python_tools::pyright(ctx.args),
301 },
302 Builtin {
303 name: "pre-commit-ruff",
304 stage: Stage::PreCommit,
305 scope: Scope::new(
306 hooks::python_tools::EXTS,
307 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
308 )
309 .not_during(MID_OPERATION),
310 severity: Severity::Block,
311 fix: Fix::Rewrite,
312 reach: Reach::Convention,
313 run: |ctx| hooks::python_tools::ruff(ctx.args),
314 },
315 Builtin {
319 name: "pre-commit-secrets",
320 stage: Stage::PreCommit,
321 scope: Scope::ALWAYS,
322 severity: Severity::Block,
323 fix: Fix::None,
324 reach: Reach::Safety,
325 run: |_ctx| hooks::secrets::staged(),
326 },
327 Builtin {
328 name: "pre-commit-usual-name",
329 stage: Stage::PreCommit,
330 scope: Scope::ALWAYS,
331 severity: Severity::Block,
332 fix: Fix::None,
333 reach: Reach::Convention,
334 run: |ctx| hooks::usual_name::run(ctx.args),
335 },
336 Builtin {
337 name: "pre-commit-yamllint",
338 stage: Stage::PreCommit,
339 scope: Scope::new(
340 hooks::yamllint::EXTS,
341 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
342 )
343 .not_during(MID_OPERATION),
344 severity: Severity::Block,
345 fix: Fix::None,
346 reach: Reach::Convention,
347 run: |ctx| hooks::yamllint::run(ctx.args),
348 },
349 Builtin {
351 name: "pre-push-branch-protect",
352 stage: Stage::PrePush,
353 scope: Scope::ALWAYS,
354 severity: Severity::Block,
355 fix: Fix::None,
356 reach: Reach::Convention,
357 run: |ctx| hooks::branch_protect::run(ctx.push.get()),
358 },
359 Builtin {
360 name: "pre-push-branch-pattern",
361 stage: Stage::PrePush,
362 scope: Scope::ALWAYS,
363 severity: Severity::Block,
364 fix: Fix::None,
365 reach: Reach::Convention,
366 run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
367 },
368 Builtin {
369 name: "pre-push-secrets",
370 stage: Stage::PrePush,
371 scope: Scope::ALWAYS,
372 severity: Severity::Block,
373 fix: Fix::None,
374 reach: Reach::Safety,
375 run: |ctx| hooks::secrets::pushed(ctx.push.get()),
376 },
377 Builtin {
378 name: "pre-push-pull-rebase",
379 stage: Stage::PrePush,
380 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
381 severity: Severity::Block,
382 fix: Fix::None,
383 reach: Reach::Convention,
384 run: |ctx| hooks::pull_rebase::run(ctx.args),
385 },
386 Builtin {
393 name: "pre-push-audit-go",
394 stage: Stage::PrePush,
395 scope: Scope::new(&[], &["go.sum"]),
396 severity: Severity::Block,
397 fix: Fix::None,
398 reach: Reach::Convention,
399 run: |ctx| hooks::audit::go(ctx.push.get()),
400 },
401 Builtin {
402 name: "pre-push-audit-js",
403 stage: Stage::PrePush,
404 scope: Scope::new(&[], &["package-lock.json"]),
405 severity: Severity::Block,
406 fix: Fix::None,
407 reach: Reach::Convention,
408 run: |ctx| hooks::audit::js(ctx.push.get()),
409 },
410 Builtin {
411 name: "pre-push-audit-python",
412 stage: Stage::PrePush,
413 scope: Scope::new(&[], &["requirements.txt"]),
414 severity: Severity::Block,
415 fix: Fix::None,
416 reach: Reach::Convention,
417 run: |ctx| hooks::audit::python(ctx.push.get()),
418 },
419 Builtin {
420 name: "pre-push-audit-rust",
421 stage: Stage::PrePush,
422 scope: Scope::new(&[], &["Cargo.lock"]),
423 severity: Severity::Block,
424 fix: Fix::None,
425 reach: Reach::Convention,
426 run: |ctx| hooks::audit::rust(ctx.push.get()),
427 },
428 Builtin {
429 name: "pre-push-run-tests-js",
430 stage: Stage::PrePush,
431 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
432 .not_during(&[GitState::Bisect, GitState::Rebase]),
433 severity: Severity::Block,
434 fix: Fix::None,
435 reach: Reach::Convention,
436 run: |ctx| hooks::run_tests::run(ctx.push.get(), &ctx.manifest.externals),
437 },
438 Builtin {
439 name: "pre-push-cargo-test",
440 stage: Stage::PrePush,
441 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
442 .not_during(&[GitState::Bisect, GitState::Rebase]),
443 severity: Severity::Block,
444 fix: Fix::None,
445 reach: Reach::Convention,
446 run: |ctx| hooks::rust_tools::test(ctx.push.get()),
447 },
448 Builtin {
449 name: "pre-push-go-test",
450 stage: Stage::PrePush,
451 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
452 .not_during(&[GitState::Bisect, GitState::Rebase]),
453 severity: Severity::Block,
454 fix: Fix::None,
455 reach: Reach::Convention,
456 run: |ctx| hooks::go_tools::test(ctx.push.get()),
457 },
458 Builtin {
459 name: "pre-push-pytest",
460 stage: Stage::PrePush,
461 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
462 .not_during(&[GitState::Bisect, GitState::Rebase]),
463 severity: Severity::Block,
464 fix: Fix::None,
465 reach: Reach::Convention,
466 run: |ctx| hooks::python_tools::pytest(ctx.push.get()),
467 },
468];
469
470pub fn severity_of(check: &dyn Check) -> Severity {
476 effective_override(None, check.name()).unwrap_or_else(|| check.severity())
477}
478
479pub fn severity_key(check: &str) -> String {
481 format!("amont.severity.{check}")
482}
483
484#[derive(Debug, Default, Clone)]
495pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
501pub enum Source {
502 Config,
503 Policy,
504}
505
506impl Source {
507 pub fn as_str(self) -> &'static str {
508 match self {
509 Source::Config => "config",
510 Source::Policy => "policy",
511 }
512 }
513}
514
515impl Overrides {
516 pub fn read() -> Overrides {
523 let policy = crate::policy::current();
524 match crate::git::stdout(&[
525 "config",
526 "--show-scope",
527 "--get-regexp",
528 r"^amont\.severity\.",
529 ]) {
530 Some(scoped) => Overrides::from_scoped(&scoped, policy),
531 None => Overrides::from_plain_with_policy_below(
535 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
536 policy,
537 ),
538 }
539 }
540
541 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
548 let mut below = String::new();
549 let mut above = String::new();
550 for line in scoped.lines() {
551 let Some((scope, rest)) = line.split_once('\t') else {
552 continue;
553 };
554 match scope {
555 "system" | "global" => {
556 below.push_str(rest);
557 below.push('\n');
558 }
559 _ => {
560 above.push_str(rest);
561 above.push('\n');
562 }
563 }
564 }
565 let mut o = Overrides::default();
566 o.fold_plain(&below, Source::Config);
567 o.fold_policy(policy);
568 o.fold_plain(&above, Source::Config);
569 o
570 }
571
572 pub fn from_plain_with_policy_below(
577 plain: Option<String>,
578 policy: &crate::policy::Policy,
579 ) -> Overrides {
580 let mut o = Overrides::default();
581 o.fold_policy(policy);
582 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
583 o
584 }
585
586 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
589 for (target, severity) in &policy.severities {
590 self.0.insert(target.clone(), (*severity, Source::Policy));
591 }
592 }
593
594 fn fold_plain(&mut self, text: &str, source: Source) {
598 for line in text.lines() {
599 let Some((key, value)) = line.split_once(' ') else {
600 continue;
601 };
602 let Some(check) = key.strip_prefix("amont.severity.") else {
603 continue;
604 };
605 match Severity::parse(value.trim()) {
606 Some(sev) => {
607 self.0.insert(check.to_string(), (sev, source));
608 }
609 None => {
610 self.0.remove(check);
611 }
612 }
613 }
614 }
615
616 pub fn from_config(out: Option<String>) -> Overrides {
624 let mut o = Overrides::default();
625 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
626 o
627 }
628
629 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
636 self.applied_with_source(check)
637 .map(|(pattern, severity, _)| (pattern, severity))
638 }
639
640 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
644 self.0
645 .iter()
646 .filter_map(|(pattern, (severity, source))| {
647 crate::names_check(check, pattern)
648 .map(|m| (m, pattern.as_str(), *severity, *source))
649 })
650 .max_by_key(|(m, _, _, _)| *m)
651 .map(|(_, pattern, severity, source)| (pattern, severity, source))
652 }
653
654 pub fn of(&self, check: &dyn Check) -> Severity {
656 self.applied_to(check.name())
657 .map(|(_, severity)| severity)
658 .unwrap_or_else(|| check.severity())
659 }
660}
661
662#[cfg(test)]
663mod precedence {
664 use super::{Overrides, Severity};
665
666 fn overrides(lines: &[&str]) -> Overrides {
667 let text = lines
668 .iter()
669 .map(|l| format!("amont.severity.{l}\n"))
670 .collect::<String>();
671 Overrides::from_config(Some(text))
672 }
673
674 #[test]
678 fn the_more_specific_key_wins() {
679 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
680 assert_eq!(
681 both.applied_to("pre-commit-clippy"),
682 Some(("pre-commit-clippy", Severity::Block)),
683 "a full id beats its trigger"
684 );
685 assert_eq!(
686 both.applied_to("pre-commit-shellcheck"),
687 Some(("pre-commit", Severity::Warn)),
688 "and the trigger still governs every check it did not exempt"
689 );
690 }
691
692 #[test]
695 fn the_three_ways_to_name_a_check_are_ranked() {
696 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
697 assert_eq!(
698 all.applied_to("pre-commit-clippy"),
699 Some(("pre-commit-clippy", Severity::Warn))
700 );
701
702 let no_full = overrides(&["pre-commit warn", "clippy block"]);
703 assert_eq!(
704 no_full.applied_to("pre-commit-clippy"),
705 Some(("clippy", Severity::Block)),
706 "a short name beats a trigger"
707 );
708 }
709
710 #[test]
713 fn a_key_that_names_no_check_applies_to_nothing() {
714 let typo = overrides(&["clipy warn", "e warn", " warn"]);
715 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
716 }
717}
718
719pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
729 overrides_in(repo).applied_to(check).map(|(_, s)| s)
730}
731
732pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
738 overrides_in(repo)
739 .applied_to(check)
740 .map(|(pattern, _)| pattern.to_string())
741}
742
743fn overrides_in(repo: Option<&Path>) -> Overrides {
744 match repo {
745 None => Overrides::read(),
749 Some(dir) => Overrides::from_config(crate::git::stdout_in(
753 dir,
754 &["config", "--get-regexp", r"^amont\.severity\."],
755 )),
756 }
757}
758
759pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
761 CHECKS.iter().filter(move |check| check.stage == stage)
762}
763
764pub fn all_stage_checks<'a>(
771 stage: Stage,
772 manifest: &'a crate::manifest::Manifest,
773) -> Vec<&'a dyn Check> {
774 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
775 .map(|check| check as &dyn Check)
776 .collect();
777 out.extend(
778 manifest
779 .externals
780 .iter()
781 .filter(|external| external.stage == stage)
782 .map(|external| external as &dyn Check),
783 );
784 out
785}
786
787pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
788 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
789 return Some(*f);
790 }
791 if CHECKS.iter().any(|check| check.name == name)
798 || manifest
799 .externals
800 .iter()
801 .any(|external| external.id == name)
802 {
803 return Some(|ctx: &Ctx| {
804 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
805 Verdict::blocking(matches!(
806 (check.run(ctx), severity_of(check)),
807 (Outcome::Failed, Severity::Block)
808 ))
809 });
810 }
811 None
812}
813
814pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
818 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
819 return Some(builtin);
820 }
821 manifest
822 .externals
823 .iter()
824 .find(|external| external.id == name)
825 .map(|external| external as &dyn Check)
826}
827
828#[cfg(test)]
829mod tests {
830 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
831 use std::collections::BTreeSet;
832
833 #[test]
840 fn the_batch_agrees_with_the_authority() {
841 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
842 let _ = std::fs::remove_dir_all(&d);
843 std::fs::create_dir_all(&d).unwrap();
844 let git = |args: &[&str]| {
845 std::process::Command::new("git")
846 .args(args)
847 .current_dir(&d)
848 .output()
849 .expect("git");
850 };
851 git(&["init", "-q", "--template=", "."]);
852 let key = "amont.severity.pre-commit-merge-conflict";
853 git(&["config", "--add", key, "warn"]);
854 git(&["config", "--add", key, "block"]);
855
856 let raw = std::process::Command::new("git")
857 .args(["config", "--get-regexp", r"^amont\.severity\."])
858 .current_dir(&d)
859 .output()
860 .expect("git");
861 let batch = Overrides::from_config(Some(
862 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
863 ));
864 let authority =
865 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
866 let _ = std::fs::remove_dir_all(&d);
867
868 assert_eq!(
869 authority,
870 Some(Severity::Block),
871 "git applies the last entry"
872 );
873 assert_eq!(
874 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
875 authority,
876 "the batch reader disagreed with `--get`"
877 );
878 }
879
880 #[test]
884 fn an_unrecognised_value_clears_rather_than_overrides() {
885 let o = Overrides::from_config(Some(
886 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
887 ));
888 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
889 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
890 }
891
892 #[test]
893 fn names_are_unique_across_entrypoints_and_checks() {
894 let mut seen = BTreeSet::new();
895 for n in ENTRYPOINTS
896 .iter()
897 .map(|(n, _)| *n)
898 .chain(CHECKS.iter().map(|check| check.name))
899 {
900 assert!(seen.insert(n), "duplicate registration: {n}");
901 }
902 }
903
904 #[test]
906 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
907 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
908 let mut shipped: Vec<String> = std::fs::read_dir(dir)
909 .expect("templates/hooks")
910 .flatten()
911 .map(|entry| entry.file_name().to_string_lossy().into_owned())
912 .collect();
913 shipped.sort();
914 assert_eq!(
915 shipped,
916 vec![
917 "commit-msg",
918 "post-commit",
919 "pre-commit",
920 "pre-push",
921 "prepare-commit-msg"
922 ]
923 );
924 let none = crate::manifest::Manifest::default();
925 for name in &shipped {
926 assert!(
927 lookup(name, &none).is_some(),
928 "shipped shim {name:?} has no handler"
929 );
930 }
931 }
932
933 #[test]
936 fn every_check_is_reachable_by_name() {
937 let none = crate::manifest::Manifest::default();
938 for check in CHECKS {
939 assert!(
940 lookup(check.name, &none).is_some(),
941 "{} not reachable",
942 check.name
943 );
944 }
945 assert!(lookup("pre-commit-not-a-check", &none).is_none());
946 }
947
948 #[test]
950 fn pre_push_runs_cheapest_first() {
951 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
952 .map(|check| check.name)
953 .collect();
954 assert_eq!(
955 order,
956 vec![
957 "pre-push-branch-protect",
958 "pre-push-branch-pattern",
959 "pre-push-secrets",
960 "pre-push-pull-rebase",
961 "pre-push-audit-go",
962 "pre-push-audit-js",
963 "pre-push-audit-python",
964 "pre-push-audit-rust",
965 "pre-push-run-tests-js",
966 "pre-push-cargo-test",
967 "pre-push-go-test",
968 "pre-push-pytest",
969 ]
970 );
971 }
972
973 enum Consumes {
979 All,
980 Exts(&'static [&'static str]),
981 }
982
983 const CONSUMED: &[(&str, Consumes)] = &[
997 (
998 "pre-commit-argo-lint",
999 Consumes::Exts(crate::hooks::k8s::EXTS),
1000 ),
1001 (
1005 "pre-commit-ban-terms",
1006 Consumes::Exts(crate::hooks::ban_terms::EXTS),
1007 ),
1008 (
1009 "pre-commit-cargo-fmt",
1010 Consumes::Exts(crate::hooks::rust_tools::EXTS),
1011 ),
1012 (
1013 "pre-commit-clippy",
1014 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1015 ),
1016 (
1017 "pre-commit-go-vet",
1018 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1019 ),
1020 (
1021 "pre-commit-gofmt",
1022 Consumes::Exts(crate::hooks::go_tools::EXTS),
1023 ),
1024 (
1025 "pre-commit-kube-linter",
1026 Consumes::Exts(crate::hooks::k8s::EXTS),
1027 ),
1028 (
1029 "pre-commit-kubeconform",
1030 Consumes::Exts(crate::hooks::k8s::EXTS),
1031 ),
1032 (
1033 "pre-commit-lint-js",
1034 Consumes::Exts(crate::hooks::lint_js::EXTS),
1035 ),
1036 (
1037 "pre-commit-lint-json-yaml",
1038 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1039 ),
1040 ("pre-commit-merge-conflict", Consumes::All),
1041 ("pre-commit-package-lock", Consumes::All),
1042 (
1046 "pre-commit-prettier",
1047 Consumes::Exts(crate::hooks::prettier::EXTS),
1048 ),
1049 (
1050 "pre-commit-pyright",
1051 Consumes::Exts(crate::hooks::python_tools::EXTS),
1052 ),
1053 (
1054 "pre-commit-ruff",
1055 Consumes::Exts(crate::hooks::python_tools::EXTS),
1056 ),
1057 ("pre-commit-usual-name", Consumes::All),
1058 (
1059 "pre-commit-yamllint",
1060 Consumes::Exts(crate::hooks::yamllint::EXTS),
1061 ),
1062 ("pre-commit-large-files", Consumes::All),
1063 ("pre-commit-secrets", Consumes::All),
1064 ("pre-push-secrets", Consumes::All),
1065 ("pre-push-branch-protect", Consumes::All),
1066 ("pre-push-branch-pattern", Consumes::All),
1067 ("pre-push-pull-rebase", Consumes::All),
1068 (
1069 "pre-push-run-tests-js",
1070 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1071 ),
1072 (
1073 "pre-push-pytest",
1074 Consumes::Exts(crate::hooks::python_tools::EXTS),
1075 ),
1076 (
1077 "pre-push-cargo-test",
1078 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1079 ),
1080 (
1081 "pre-push-go-test",
1082 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1083 ),
1084 ];
1085
1086 #[test]
1097 fn no_check_declares_a_file_type_it_does_not_consume() {
1098 for (name, _) in CONSUMED {
1099 assert!(
1100 CHECKS.iter().any(|check| check.name == *name),
1101 "CONSUMED names {name:?}, which is not a check"
1102 );
1103 }
1104 for check in CHECKS {
1105 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1106 if check.scope.files.is_empty() && entry.is_none() {
1107 continue;
1108 }
1109 let Some((_, consumes)) = entry else {
1110 panic!(
1111 "{} declares scope.files {:?} but is missing from CONSUMED — \
1112 say what it actually reads",
1113 check.name, check.scope.files
1114 );
1115 };
1116 let Consumes::Exts(consumed) = consumes else {
1117 continue; };
1119 for ext in check.scope.files {
1120 assert!(
1121 consumed.contains(ext),
1122 "{} declares {ext:?} in its scope but never asks for it — \
1123 `amont list` would report a coverage the check does not have",
1124 check.name
1125 );
1126 }
1127 }
1128 }
1129
1130 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1139 ("pre-commit-agents-md", true),
1140 ("pre-commit-argo-lint", false),
1141 ("pre-commit-ban-terms", false),
1142 ("pre-commit-branch-pattern", false),
1143 ("pre-commit-branch-protect", false),
1144 ("pre-commit-cargo-fmt", true),
1145 ("pre-commit-clippy", false),
1146 ("pre-commit-go-vet", false),
1147 ("pre-commit-gofmt", true),
1148 ("pre-commit-kube-linter", false),
1149 ("pre-commit-kubeconform", false),
1150 ("pre-commit-lint-js", false),
1151 ("pre-commit-lint-json-yaml", false),
1152 ("pre-commit-merge-conflict", false),
1153 ("pre-commit-package-lock", false),
1154 ("pre-commit-prettier", true),
1155 ("pre-commit-pyright", false),
1156 ("pre-commit-ruff", true),
1157 ("pre-commit-usual-name", false),
1158 ("pre-commit-yamllint", false),
1159 ("pre-commit-large-files", false),
1160 ("pre-commit-secrets", false),
1161 ("pre-push-secrets", false),
1162 ("pre-push-branch-protect", false),
1163 ("pre-push-branch-pattern", false),
1164 ("pre-push-pull-rebase", false),
1165 ("pre-push-audit-go", false),
1166 ("pre-push-audit-js", false),
1167 ("pre-push-audit-python", false),
1168 ("pre-push-audit-rust", false),
1169 ("pre-push-run-tests-js", false),
1170 ("pre-push-cargo-test", false),
1171 ("pre-push-go-test", false),
1172 ("pre-push-pytest", false),
1173 ];
1174
1175 #[test]
1178 fn every_rewrite_declaration_has_a_fixer() {
1179 let declared: BTreeSet<&str> = CHECKS
1180 .iter()
1181 .filter(|check| check.fix == super::Fix::Rewrite)
1182 .map(|check| check.name)
1183 .collect();
1184 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1185 .iter()
1186 .filter(|(_, has)| *has)
1187 .map(|(name, _)| *name)
1188 .collect();
1189 assert_eq!(
1190 declared, implemented,
1191 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1192 and a check with a fixer that does not declare it can never be reached"
1193 );
1194
1195 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1198 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1199 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1200 }
1201
1202 #[test]
1213 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1214 let safety: Vec<&str> = CHECKS
1215 .iter()
1216 .filter(|c| c.reach == Reach::Safety)
1217 .map(|c| c.name)
1218 .collect();
1219 assert_eq!(
1220 safety,
1221 vec![
1222 "pre-commit-ban-terms",
1223 "pre-commit-large-files",
1224 "pre-commit-merge-conflict",
1225 "pre-commit-secrets",
1226 "pre-push-secrets",
1227 ]
1228 );
1229 }
1230
1231 #[test]
1232 fn every_check_declares_a_stage_and_a_scope() {
1233 assert_eq!(CHECKS.len(), 34);
1234 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1235 let pre_push = super::stage_checks(Stage::PrePush).count();
1236 assert_eq!(
1237 pre_commit + pre_push,
1238 CHECKS.len(),
1239 "every check has a stage"
1240 );
1241 }
1242}