1use std::ffi::OsString;
15use std::path::Path;
16
17use crate::check::{Builtin, Check, Fix, GitState, Outcome, Scope, Severity, Stage, Verdict};
18use crate::pushrefs::PushRefs;
19use crate::{dispatch, hooks};
20
21pub struct Ctx<'a> {
25 pub name: &'a str,
27 pub args: &'a [OsString],
29 pub hooks_dir: &'a Path,
32 pub push: &'a PushRefs,
35 pub manifest: &'a crate::manifest::Manifest,
39}
40
41pub type HookFn = fn(&Ctx) -> Verdict;
42
43pub const ENTRYPOINTS: &[(&str, HookFn)] = &[
46 ("pre-commit", dispatch::pre_commit),
47 ("pre-push", dispatch::pre_push),
48 ("commit-msg", |ctx| hooks::commit_msg::run(ctx.args)),
49 ("prepare-commit-msg", |ctx| {
50 hooks::prepare_commit_msg::run(ctx.args)
51 }),
52 ("post-commit", |ctx| hooks::post_commit::run(ctx)),
53];
54
55const MID_OPERATION: &[GitState] = &[
74 GitState::Merge,
75 GitState::Rebase,
76 GitState::CherryPick,
77 GitState::Revert,
78];
79
80pub const CHECKS: &[Builtin] = &[
81 Builtin {
83 name: "pre-commit-argo-lint",
84 stage: Stage::PreCommit,
85 scope: Scope::new(
86 hooks::k8s::EXTS,
87 &["kustomization.yaml", "kustomization.yml"],
88 )
89 .not_during(MID_OPERATION),
90 severity: Severity::Block,
91 fix: Fix::None,
92 run: |ctx| hooks::k8s::argo_lint(ctx.args),
93 },
94 Builtin {
95 name: "pre-commit-ban-terms",
96 stage: Stage::PreCommit,
97 scope: Scope::files(hooks::ban_terms::EXTS),
98 severity: Severity::Block,
99 fix: Fix::None,
100 run: |ctx| hooks::ban_terms::run(ctx.name, ctx.args),
101 },
102 Builtin {
107 name: "pre-commit-branch-pattern",
108 stage: Stage::PreCommit,
109 scope: Scope::ALWAYS,
110 severity: Severity::Warn,
111 fix: Fix::None,
112 run: |_ctx| hooks::branch_pattern::early(),
113 },
114 Builtin {
115 name: "pre-commit-cargo-fmt",
116 stage: Stage::PreCommit,
117 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
118 severity: Severity::Block,
119 fix: Fix::Rewrite,
120 run: |ctx| hooks::rust_tools::fmt(ctx.args),
121 },
122 Builtin {
123 name: "pre-commit-clippy",
124 stage: Stage::PreCommit,
125 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"]).not_during(MID_OPERATION),
126 severity: Severity::Block,
127 fix: Fix::None,
128 run: |ctx| hooks::rust_tools::clippy(ctx.args),
129 },
130 Builtin {
131 name: "pre-commit-kube-linter",
132 stage: Stage::PreCommit,
133 scope: Scope::new(
134 hooks::k8s::EXTS,
135 &[".kube-linter*.yaml", ".kube-linter*.yml"],
136 )
137 .not_during(MID_OPERATION),
138 severity: Severity::Block,
139 fix: Fix::None,
140 run: |ctx| hooks::k8s::kube_linter(ctx.args),
141 },
142 Builtin {
143 name: "pre-commit-kubeconform",
144 stage: Stage::PreCommit,
145 scope: Scope::new(
146 hooks::k8s::EXTS,
147 &["kustomization.yaml", "kustomization.yml"],
148 )
149 .not_during(MID_OPERATION),
150 severity: Severity::Block,
151 fix: Fix::None,
152 run: |ctx| hooks::k8s::kubeconform(ctx.args),
153 },
154 Builtin {
155 name: "pre-commit-large-files",
156 stage: Stage::PreCommit,
157 scope: Scope::ALWAYS,
158 severity: Severity::Block,
159 fix: Fix::None,
160 run: |_ctx| hooks::large_files::staged(),
161 },
162 Builtin {
163 name: "pre-commit-lint-js",
164 stage: Stage::PreCommit,
165 scope: Scope::new(hooks::lint_js::EXTS, &["package.json"]).not_during(MID_OPERATION),
166 severity: Severity::Block,
167 fix: Fix::None,
168 run: |ctx| hooks::lint_js::run(ctx.args),
169 },
170 Builtin {
171 name: "pre-commit-lint-json-yaml",
172 stage: Stage::PreCommit,
173 scope: Scope::files(hooks::lint_json_yaml::EXTS).not_during(MID_OPERATION),
174 severity: Severity::Block,
175 fix: Fix::None,
176 run: |ctx| hooks::lint_json_yaml::run(ctx.args),
177 },
178 Builtin {
179 name: "pre-commit-merge-conflict",
180 stage: Stage::PreCommit,
181 scope: Scope::ALWAYS,
182 severity: Severity::Block,
183 fix: Fix::None,
184 run: |ctx| hooks::merge_conflict::run(ctx.name, ctx.args),
185 },
186 Builtin {
187 name: "pre-commit-package-lock",
188 stage: Stage::PreCommit,
189 scope: Scope::new(&[], &["package.json"]),
190 severity: Severity::Block,
191 fix: Fix::None,
192 run: |ctx| hooks::package_lock::run(ctx.args),
193 },
194 Builtin {
195 name: "pre-commit-prettier",
196 stage: Stage::PreCommit,
197 scope: Scope::new(
198 &[],
199 &[
200 ".prettierrc",
201 ".prettierrc.json",
202 ".prettierrc.yml",
203 ".prettierrc.yaml",
204 ".prettierrc.js",
205 "prettier.config.js",
206 ],
207 )
208 .not_during(MID_OPERATION),
209 severity: Severity::Block,
210 fix: Fix::Rewrite,
211 run: |ctx| hooks::prettier::run(ctx.args),
212 },
213 Builtin {
214 name: "pre-commit-pyright",
215 stage: Stage::PreCommit,
216 scope: Scope::new(
217 hooks::python_tools::EXTS,
218 &[
219 "pyrightconfig.json",
220 "pyrightconfig.jsonc",
221 "pyproject.toml",
222 ],
223 )
224 .not_during(MID_OPERATION),
225 severity: Severity::Block,
226 fix: Fix::None,
227 run: |ctx| hooks::python_tools::pyright(ctx.args),
228 },
229 Builtin {
230 name: "pre-commit-ruff",
231 stage: Stage::PreCommit,
232 scope: Scope::new(
233 hooks::python_tools::EXTS,
234 &["ruff.toml", ".ruff.toml", "pyproject.toml"],
235 )
236 .not_during(MID_OPERATION),
237 severity: Severity::Block,
238 fix: Fix::Rewrite,
239 run: |ctx| hooks::python_tools::ruff(ctx.args),
240 },
241 Builtin {
245 name: "pre-commit-secrets",
246 stage: Stage::PreCommit,
247 scope: Scope::ALWAYS,
248 severity: Severity::Block,
249 fix: Fix::None,
250 run: |_ctx| hooks::secrets::staged(),
251 },
252 Builtin {
253 name: "pre-commit-usual-name",
254 stage: Stage::PreCommit,
255 scope: Scope::ALWAYS,
256 severity: Severity::Block,
257 fix: Fix::None,
258 run: |ctx| hooks::usual_name::run(ctx.args),
259 },
260 Builtin {
261 name: "pre-commit-yamllint",
262 stage: Stage::PreCommit,
263 scope: Scope::new(
264 hooks::yamllint::EXTS,
265 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
266 )
267 .not_during(MID_OPERATION),
268 severity: Severity::Block,
269 fix: Fix::None,
270 run: |ctx| hooks::yamllint::run(ctx.args),
271 },
272 Builtin {
274 name: "pre-push-branch-protect",
275 stage: Stage::PrePush,
276 scope: Scope::ALWAYS,
277 severity: Severity::Block,
278 fix: Fix::None,
279 run: |ctx| hooks::branch_protect::run(ctx.push.get()),
280 },
281 Builtin {
282 name: "pre-push-branch-pattern",
283 stage: Stage::PrePush,
284 scope: Scope::ALWAYS,
285 severity: Severity::Block,
286 fix: Fix::None,
287 run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
288 },
289 Builtin {
290 name: "pre-push-secrets",
291 stage: Stage::PrePush,
292 scope: Scope::ALWAYS,
293 severity: Severity::Block,
294 fix: Fix::None,
295 run: |ctx| hooks::secrets::pushed(ctx.push.get()),
296 },
297 Builtin {
298 name: "pre-push-pull-rebase",
299 stage: Stage::PrePush,
300 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
301 severity: Severity::Block,
302 fix: Fix::None,
303 run: |ctx| hooks::pull_rebase::run(ctx.args),
304 },
305 Builtin {
312 name: "pre-push-audit-js",
313 stage: Stage::PrePush,
314 scope: Scope::new(&[], &["package-lock.json"]),
315 severity: Severity::Block,
316 fix: Fix::None,
317 run: |ctx| hooks::audit::js(ctx.push.get()),
318 },
319 Builtin {
320 name: "pre-push-audit-python",
321 stage: Stage::PrePush,
322 scope: Scope::new(&[], &["requirements.txt"]),
323 severity: Severity::Block,
324 fix: Fix::None,
325 run: |ctx| hooks::audit::python(ctx.push.get()),
326 },
327 Builtin {
328 name: "pre-push-audit-rust",
329 stage: Stage::PrePush,
330 scope: Scope::new(&[], &["Cargo.lock"]),
331 severity: Severity::Block,
332 fix: Fix::None,
333 run: |ctx| hooks::audit::rust(ctx.push.get()),
334 },
335 Builtin {
336 name: "pre-push-run-tests-js",
337 stage: Stage::PrePush,
338 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
339 .not_during(&[GitState::Bisect, GitState::Rebase]),
340 severity: Severity::Block,
341 fix: Fix::None,
342 run: |ctx| hooks::run_tests::run(ctx.push.get(), &ctx.manifest.externals),
343 },
344 Builtin {
345 name: "pre-push-cargo-test",
346 stage: Stage::PrePush,
347 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
348 .not_during(&[GitState::Bisect, GitState::Rebase]),
349 severity: Severity::Block,
350 fix: Fix::None,
351 run: |ctx| hooks::rust_tools::test(ctx.push.get()),
352 },
353 Builtin {
354 name: "pre-push-pytest",
355 stage: Stage::PrePush,
356 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
357 .not_during(&[GitState::Bisect, GitState::Rebase]),
358 severity: Severity::Block,
359 fix: Fix::None,
360 run: |ctx| hooks::python_tools::pytest(ctx.push.get()),
361 },
362];
363
364pub fn severity_of(check: &dyn Check) -> Severity {
370 effective_override(None, check.name()).unwrap_or_else(|| check.severity())
371}
372
373pub fn severity_key(check: &str) -> String {
375 format!("amont.severity.{check}")
376}
377
378#[derive(Debug, Default, Clone)]
389pub struct Overrides(std::collections::BTreeMap<String, Severity>);
390
391impl Overrides {
392 pub fn read() -> Overrides {
393 Overrides::from_config(crate::git::stdout(&[
394 "config",
395 "--get-regexp",
396 r"^amont\.severity\.",
397 ]))
398 }
399
400 pub fn from_config(out: Option<String>) -> Overrides {
408 let mut map = std::collections::BTreeMap::new();
409 for line in out.as_deref().unwrap_or_default().lines() {
410 let Some((key, value)) = line.split_once(' ') else {
411 continue;
412 };
413 let Some(check) = key.strip_prefix("amont.severity.") else {
414 continue;
415 };
416 match Severity::parse(value.trim()) {
419 Some(s) => {
420 map.insert(check.to_string(), s);
421 }
422 None => {
425 map.remove(check);
426 }
427 }
428 }
429 Overrides(map)
430 }
431
432 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
439 self.0
440 .iter()
441 .filter_map(|(pattern, severity)| {
442 crate::names_check(check, pattern).map(|m| (m, pattern.as_str(), *severity))
443 })
444 .max_by_key(|(m, _, _)| *m)
445 .map(|(_, pattern, severity)| (pattern, severity))
446 }
447
448 pub fn of(&self, check: &dyn Check) -> Severity {
450 self.applied_to(check.name())
451 .map(|(_, severity)| severity)
452 .unwrap_or_else(|| check.severity())
453 }
454}
455
456#[cfg(test)]
457mod precedence {
458 use super::{Overrides, Severity};
459
460 fn overrides(lines: &[&str]) -> Overrides {
461 let text = lines
462 .iter()
463 .map(|l| format!("amont.severity.{l}\n"))
464 .collect::<String>();
465 Overrides::from_config(Some(text))
466 }
467
468 #[test]
472 fn the_more_specific_key_wins() {
473 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
474 assert_eq!(
475 both.applied_to("pre-commit-clippy"),
476 Some(("pre-commit-clippy", Severity::Block)),
477 "a full id beats its trigger"
478 );
479 assert_eq!(
480 both.applied_to("pre-commit-shellcheck"),
481 Some(("pre-commit", Severity::Warn)),
482 "and the trigger still governs every check it did not exempt"
483 );
484 }
485
486 #[test]
489 fn the_three_ways_to_name_a_check_are_ranked() {
490 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
491 assert_eq!(
492 all.applied_to("pre-commit-clippy"),
493 Some(("pre-commit-clippy", Severity::Warn))
494 );
495
496 let no_full = overrides(&["pre-commit warn", "clippy block"]);
497 assert_eq!(
498 no_full.applied_to("pre-commit-clippy"),
499 Some(("clippy", Severity::Block)),
500 "a short name beats a trigger"
501 );
502 }
503
504 #[test]
507 fn a_key_that_names_no_check_applies_to_nothing() {
508 let typo = overrides(&["clipy warn", "e warn", " warn"]);
509 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
510 }
511}
512
513pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
523 overrides_in(repo).applied_to(check).map(|(_, s)| s)
524}
525
526pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
532 overrides_in(repo)
533 .applied_to(check)
534 .map(|(pattern, _)| pattern.to_string())
535}
536
537fn overrides_in(repo: Option<&Path>) -> Overrides {
538 let args = ["config", "--get-regexp", r"^amont\.severity\."];
539 Overrides::from_config(match repo {
540 None => crate::git::stdout(&args),
541 Some(dir) => crate::git::stdout_in(dir, &args),
542 })
543}
544
545pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
547 CHECKS.iter().filter(move |check| check.stage == stage)
548}
549
550pub fn all_stage_checks<'a>(
557 stage: Stage,
558 manifest: &'a crate::manifest::Manifest,
559) -> Vec<&'a dyn Check> {
560 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
561 .map(|check| check as &dyn Check)
562 .collect();
563 out.extend(
564 manifest
565 .externals
566 .iter()
567 .filter(|external| external.stage == stage)
568 .map(|external| external as &dyn Check),
569 );
570 out
571}
572
573pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
574 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
575 return Some(*f);
576 }
577 if CHECKS.iter().any(|check| check.name == name)
584 || manifest
585 .externals
586 .iter()
587 .any(|external| external.id == name)
588 {
589 return Some(|ctx: &Ctx| {
590 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
591 Verdict::blocking(matches!(
592 (check.run(ctx), severity_of(check)),
593 (Outcome::Failed, Severity::Block)
594 ))
595 });
596 }
597 None
598}
599
600pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
604 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
605 return Some(builtin);
606 }
607 manifest
608 .externals
609 .iter()
610 .find(|external| external.id == name)
611 .map(|external| external as &dyn Check)
612}
613
614#[cfg(test)]
615mod tests {
616 use super::{lookup, Overrides, Severity, Stage, CHECKS, ENTRYPOINTS};
617 use std::collections::BTreeSet;
618
619 #[test]
626 fn the_batch_agrees_with_the_authority() {
627 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
628 let _ = std::fs::remove_dir_all(&d);
629 std::fs::create_dir_all(&d).unwrap();
630 let git = |args: &[&str]| {
631 std::process::Command::new("git")
632 .args(args)
633 .current_dir(&d)
634 .output()
635 .expect("git");
636 };
637 git(&["init", "-q", "--template=", "."]);
638 let key = "amont.severity.pre-commit-merge-conflict";
639 git(&["config", "--add", key, "warn"]);
640 git(&["config", "--add", key, "block"]);
641
642 let raw = std::process::Command::new("git")
643 .args(["config", "--get-regexp", r"^amont\.severity\."])
644 .current_dir(&d)
645 .output()
646 .expect("git");
647 let batch = Overrides::from_config(Some(
648 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
649 ));
650 let authority =
651 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
652 let _ = std::fs::remove_dir_all(&d);
653
654 assert_eq!(
655 authority,
656 Some(Severity::Block),
657 "git applies the last entry"
658 );
659 assert_eq!(
660 batch.0.get("pre-commit-merge-conflict").copied(),
661 authority,
662 "the batch reader disagreed with `--get`"
663 );
664 }
665
666 #[test]
670 fn an_unrecognised_value_clears_rather_than_overrides() {
671 let o = Overrides::from_config(Some(
672 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
673 ));
674 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
675 assert_eq!(o.0.get("b").copied(), Some(Severity::Warn));
676 }
677
678 #[test]
679 fn names_are_unique_across_entrypoints_and_checks() {
680 let mut seen = BTreeSet::new();
681 for n in ENTRYPOINTS
682 .iter()
683 .map(|(n, _)| *n)
684 .chain(CHECKS.iter().map(|check| check.name))
685 {
686 assert!(seen.insert(n), "duplicate registration: {n}");
687 }
688 }
689
690 #[test]
692 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
693 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
694 let mut shipped: Vec<String> = std::fs::read_dir(dir)
695 .expect("templates/hooks")
696 .flatten()
697 .map(|entry| entry.file_name().to_string_lossy().into_owned())
698 .collect();
699 shipped.sort();
700 assert_eq!(
701 shipped,
702 vec![
703 "commit-msg",
704 "post-commit",
705 "pre-commit",
706 "pre-push",
707 "prepare-commit-msg"
708 ]
709 );
710 let none = crate::manifest::Manifest::default();
711 for name in &shipped {
712 assert!(
713 lookup(name, &none).is_some(),
714 "shipped shim {name:?} has no handler"
715 );
716 }
717 }
718
719 #[test]
722 fn every_check_is_reachable_by_name() {
723 let none = crate::manifest::Manifest::default();
724 for check in CHECKS {
725 assert!(
726 lookup(check.name, &none).is_some(),
727 "{} not reachable",
728 check.name
729 );
730 }
731 assert!(lookup("pre-commit-not-a-check", &none).is_none());
732 }
733
734 #[test]
736 fn pre_push_runs_cheapest_first() {
737 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
738 .map(|check| check.name)
739 .collect();
740 assert_eq!(
741 order,
742 vec![
743 "pre-push-branch-protect",
744 "pre-push-branch-pattern",
745 "pre-push-secrets",
746 "pre-push-pull-rebase",
747 "pre-push-audit-js",
748 "pre-push-audit-python",
749 "pre-push-audit-rust",
750 "pre-push-run-tests-js",
751 "pre-push-cargo-test",
752 "pre-push-pytest",
753 ]
754 );
755 }
756
757 enum Consumes {
763 All,
764 Exts(&'static [&'static str]),
765 }
766
767 const CONSUMED: &[(&str, Consumes)] = &[
781 (
782 "pre-commit-argo-lint",
783 Consumes::Exts(crate::hooks::k8s::EXTS),
784 ),
785 (
789 "pre-commit-ban-terms",
790 Consumes::Exts(crate::hooks::ban_terms::EXTS),
791 ),
792 (
793 "pre-commit-cargo-fmt",
794 Consumes::Exts(crate::hooks::rust_tools::EXTS),
795 ),
796 (
797 "pre-commit-clippy",
798 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
799 ),
800 (
801 "pre-commit-kube-linter",
802 Consumes::Exts(crate::hooks::k8s::EXTS),
803 ),
804 (
805 "pre-commit-kubeconform",
806 Consumes::Exts(crate::hooks::k8s::EXTS),
807 ),
808 (
809 "pre-commit-lint-js",
810 Consumes::Exts(crate::hooks::lint_js::EXTS),
811 ),
812 (
813 "pre-commit-lint-json-yaml",
814 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
815 ),
816 ("pre-commit-merge-conflict", Consumes::All),
817 ("pre-commit-package-lock", Consumes::All),
818 (
822 "pre-commit-prettier",
823 Consumes::Exts(crate::hooks::prettier::EXTS),
824 ),
825 (
826 "pre-commit-pyright",
827 Consumes::Exts(crate::hooks::python_tools::EXTS),
828 ),
829 (
830 "pre-commit-ruff",
831 Consumes::Exts(crate::hooks::python_tools::EXTS),
832 ),
833 ("pre-commit-usual-name", Consumes::All),
834 (
835 "pre-commit-yamllint",
836 Consumes::Exts(crate::hooks::yamllint::EXTS),
837 ),
838 ("pre-commit-large-files", Consumes::All),
839 ("pre-commit-secrets", Consumes::All),
840 ("pre-push-secrets", Consumes::All),
841 ("pre-push-branch-protect", Consumes::All),
842 ("pre-push-branch-pattern", Consumes::All),
843 ("pre-push-pull-rebase", Consumes::All),
844 (
845 "pre-push-run-tests-js",
846 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
847 ),
848 (
849 "pre-push-pytest",
850 Consumes::Exts(crate::hooks::python_tools::EXTS),
851 ),
852 (
853 "pre-push-cargo-test",
854 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
855 ),
856 ];
857
858 #[test]
869 fn no_check_declares_a_file_type_it_does_not_consume() {
870 for (name, _) in CONSUMED {
871 assert!(
872 CHECKS.iter().any(|check| check.name == *name),
873 "CONSUMED names {name:?}, which is not a check"
874 );
875 }
876 for check in CHECKS {
877 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
878 if check.scope.files.is_empty() && entry.is_none() {
879 continue;
880 }
881 let Some((_, consumes)) = entry else {
882 panic!(
883 "{} declares scope.files {:?} but is missing from CONSUMED — \
884 say what it actually reads",
885 check.name, check.scope.files
886 );
887 };
888 let Consumes::Exts(consumed) = consumes else {
889 continue; };
891 for ext in check.scope.files {
892 assert!(
893 consumed.contains(ext),
894 "{} declares {ext:?} in its scope but never asks for it — \
895 `amont list` would report a coverage the check does not have",
896 check.name
897 );
898 }
899 }
900 }
901
902 const HAS_FIXING_CODE: &[(&str, bool)] = &[
911 ("pre-commit-argo-lint", false),
912 ("pre-commit-ban-terms", false),
913 ("pre-commit-branch-pattern", false),
914 ("pre-commit-cargo-fmt", true),
915 ("pre-commit-clippy", false),
916 ("pre-commit-kube-linter", false),
917 ("pre-commit-kubeconform", false),
918 ("pre-commit-lint-js", false),
919 ("pre-commit-lint-json-yaml", false),
920 ("pre-commit-merge-conflict", false),
921 ("pre-commit-package-lock", false),
922 ("pre-commit-prettier", true),
923 ("pre-commit-pyright", false),
924 ("pre-commit-ruff", true),
925 ("pre-commit-usual-name", false),
926 ("pre-commit-yamllint", false),
927 ("pre-commit-large-files", false),
928 ("pre-commit-secrets", false),
929 ("pre-push-secrets", false),
930 ("pre-push-branch-protect", false),
931 ("pre-push-branch-pattern", false),
932 ("pre-push-pull-rebase", false),
933 ("pre-push-audit-js", false),
934 ("pre-push-audit-python", false),
935 ("pre-push-audit-rust", false),
936 ("pre-push-run-tests-js", false),
937 ("pre-push-cargo-test", false),
938 ("pre-push-pytest", false),
939 ];
940
941 #[test]
944 fn every_rewrite_declaration_has_a_fixer() {
945 let declared: BTreeSet<&str> = CHECKS
946 .iter()
947 .filter(|check| check.fix == super::Fix::Rewrite)
948 .map(|check| check.name)
949 .collect();
950 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
951 .iter()
952 .filter(|(_, has)| *has)
953 .map(|(name, _)| *name)
954 .collect();
955 assert_eq!(
956 declared, implemented,
957 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
958 and a check with a fixer that does not declare it can never be reached"
959 );
960
961 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
964 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
965 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
966 }
967
968 #[test]
971 fn every_check_declares_a_stage_and_a_scope() {
972 assert_eq!(CHECKS.len(), 28);
973 let pre_commit = super::stage_checks(Stage::PreCommit).count();
974 let pre_push = super::stage_checks(Stage::PrePush).count();
975 assert_eq!(
976 pre_commit + pre_push,
977 CHECKS.len(),
978 "every check has a stage"
979 );
980 }
981}