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>);
471
472impl Overrides {
473 pub fn read() -> Overrides {
474 Overrides::from_config(crate::git::stdout(&[
475 "config",
476 "--get-regexp",
477 r"^amont\.severity\.",
478 ]))
479 }
480
481 pub fn from_config(out: Option<String>) -> Overrides {
489 let mut map = std::collections::BTreeMap::new();
490 for line in out.as_deref().unwrap_or_default().lines() {
491 let Some((key, value)) = line.split_once(' ') else {
492 continue;
493 };
494 let Some(check) = key.strip_prefix("amont.severity.") else {
495 continue;
496 };
497 match Severity::parse(value.trim()) {
500 Some(s) => {
501 map.insert(check.to_string(), s);
502 }
503 None => {
506 map.remove(check);
507 }
508 }
509 }
510 Overrides(map)
511 }
512
513 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
520 self.0
521 .iter()
522 .filter_map(|(pattern, severity)| {
523 crate::names_check(check, pattern).map(|m| (m, pattern.as_str(), *severity))
524 })
525 .max_by_key(|(m, _, _)| *m)
526 .map(|(_, pattern, severity)| (pattern, severity))
527 }
528
529 pub fn of(&self, check: &dyn Check) -> Severity {
531 self.applied_to(check.name())
532 .map(|(_, severity)| severity)
533 .unwrap_or_else(|| check.severity())
534 }
535}
536
537#[cfg(test)]
538mod precedence {
539 use super::{Overrides, Severity};
540
541 fn overrides(lines: &[&str]) -> Overrides {
542 let text = lines
543 .iter()
544 .map(|l| format!("amont.severity.{l}\n"))
545 .collect::<String>();
546 Overrides::from_config(Some(text))
547 }
548
549 #[test]
553 fn the_more_specific_key_wins() {
554 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
555 assert_eq!(
556 both.applied_to("pre-commit-clippy"),
557 Some(("pre-commit-clippy", Severity::Block)),
558 "a full id beats its trigger"
559 );
560 assert_eq!(
561 both.applied_to("pre-commit-shellcheck"),
562 Some(("pre-commit", Severity::Warn)),
563 "and the trigger still governs every check it did not exempt"
564 );
565 }
566
567 #[test]
570 fn the_three_ways_to_name_a_check_are_ranked() {
571 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
572 assert_eq!(
573 all.applied_to("pre-commit-clippy"),
574 Some(("pre-commit-clippy", Severity::Warn))
575 );
576
577 let no_full = overrides(&["pre-commit warn", "clippy block"]);
578 assert_eq!(
579 no_full.applied_to("pre-commit-clippy"),
580 Some(("clippy", Severity::Block)),
581 "a short name beats a trigger"
582 );
583 }
584
585 #[test]
588 fn a_key_that_names_no_check_applies_to_nothing() {
589 let typo = overrides(&["clipy warn", "e warn", " warn"]);
590 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
591 }
592}
593
594pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
604 overrides_in(repo).applied_to(check).map(|(_, s)| s)
605}
606
607pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
613 overrides_in(repo)
614 .applied_to(check)
615 .map(|(pattern, _)| pattern.to_string())
616}
617
618fn overrides_in(repo: Option<&Path>) -> Overrides {
619 let args = ["config", "--get-regexp", r"^amont\.severity\."];
620 Overrides::from_config(match repo {
621 None => crate::git::stdout(&args),
622 Some(dir) => crate::git::stdout_in(dir, &args),
623 })
624}
625
626pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
628 CHECKS.iter().filter(move |check| check.stage == stage)
629}
630
631pub fn all_stage_checks<'a>(
638 stage: Stage,
639 manifest: &'a crate::manifest::Manifest,
640) -> Vec<&'a dyn Check> {
641 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
642 .map(|check| check as &dyn Check)
643 .collect();
644 out.extend(
645 manifest
646 .externals
647 .iter()
648 .filter(|external| external.stage == stage)
649 .map(|external| external as &dyn Check),
650 );
651 out
652}
653
654pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
655 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
656 return Some(*f);
657 }
658 if CHECKS.iter().any(|check| check.name == name)
665 || manifest
666 .externals
667 .iter()
668 .any(|external| external.id == name)
669 {
670 return Some(|ctx: &Ctx| {
671 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
672 Verdict::blocking(matches!(
673 (check.run(ctx), severity_of(check)),
674 (Outcome::Failed, Severity::Block)
675 ))
676 });
677 }
678 None
679}
680
681pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
685 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
686 return Some(builtin);
687 }
688 manifest
689 .externals
690 .iter()
691 .find(|external| external.id == name)
692 .map(|external| external as &dyn Check)
693}
694
695#[cfg(test)]
696mod tests {
697 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
698 use std::collections::BTreeSet;
699
700 #[test]
707 fn the_batch_agrees_with_the_authority() {
708 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
709 let _ = std::fs::remove_dir_all(&d);
710 std::fs::create_dir_all(&d).unwrap();
711 let git = |args: &[&str]| {
712 std::process::Command::new("git")
713 .args(args)
714 .current_dir(&d)
715 .output()
716 .expect("git");
717 };
718 git(&["init", "-q", "--template=", "."]);
719 let key = "amont.severity.pre-commit-merge-conflict";
720 git(&["config", "--add", key, "warn"]);
721 git(&["config", "--add", key, "block"]);
722
723 let raw = std::process::Command::new("git")
724 .args(["config", "--get-regexp", r"^amont\.severity\."])
725 .current_dir(&d)
726 .output()
727 .expect("git");
728 let batch = Overrides::from_config(Some(
729 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
730 ));
731 let authority =
732 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
733 let _ = std::fs::remove_dir_all(&d);
734
735 assert_eq!(
736 authority,
737 Some(Severity::Block),
738 "git applies the last entry"
739 );
740 assert_eq!(
741 batch.0.get("pre-commit-merge-conflict").copied(),
742 authority,
743 "the batch reader disagreed with `--get`"
744 );
745 }
746
747 #[test]
751 fn an_unrecognised_value_clears_rather_than_overrides() {
752 let o = Overrides::from_config(Some(
753 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
754 ));
755 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
756 assert_eq!(o.0.get("b").copied(), Some(Severity::Warn));
757 }
758
759 #[test]
760 fn names_are_unique_across_entrypoints_and_checks() {
761 let mut seen = BTreeSet::new();
762 for n in ENTRYPOINTS
763 .iter()
764 .map(|(n, _)| *n)
765 .chain(CHECKS.iter().map(|check| check.name))
766 {
767 assert!(seen.insert(n), "duplicate registration: {n}");
768 }
769 }
770
771 #[test]
773 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
774 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
775 let mut shipped: Vec<String> = std::fs::read_dir(dir)
776 .expect("templates/hooks")
777 .flatten()
778 .map(|entry| entry.file_name().to_string_lossy().into_owned())
779 .collect();
780 shipped.sort();
781 assert_eq!(
782 shipped,
783 vec![
784 "commit-msg",
785 "post-commit",
786 "pre-commit",
787 "pre-push",
788 "prepare-commit-msg"
789 ]
790 );
791 let none = crate::manifest::Manifest::default();
792 for name in &shipped {
793 assert!(
794 lookup(name, &none).is_some(),
795 "shipped shim {name:?} has no handler"
796 );
797 }
798 }
799
800 #[test]
803 fn every_check_is_reachable_by_name() {
804 let none = crate::manifest::Manifest::default();
805 for check in CHECKS {
806 assert!(
807 lookup(check.name, &none).is_some(),
808 "{} not reachable",
809 check.name
810 );
811 }
812 assert!(lookup("pre-commit-not-a-check", &none).is_none());
813 }
814
815 #[test]
817 fn pre_push_runs_cheapest_first() {
818 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
819 .map(|check| check.name)
820 .collect();
821 assert_eq!(
822 order,
823 vec![
824 "pre-push-branch-protect",
825 "pre-push-branch-pattern",
826 "pre-push-secrets",
827 "pre-push-pull-rebase",
828 "pre-push-audit-go",
829 "pre-push-audit-js",
830 "pre-push-audit-python",
831 "pre-push-audit-rust",
832 "pre-push-run-tests-js",
833 "pre-push-cargo-test",
834 "pre-push-go-test",
835 "pre-push-pytest",
836 ]
837 );
838 }
839
840 enum Consumes {
846 All,
847 Exts(&'static [&'static str]),
848 }
849
850 const CONSUMED: &[(&str, Consumes)] = &[
864 (
865 "pre-commit-argo-lint",
866 Consumes::Exts(crate::hooks::k8s::EXTS),
867 ),
868 (
872 "pre-commit-ban-terms",
873 Consumes::Exts(crate::hooks::ban_terms::EXTS),
874 ),
875 (
876 "pre-commit-cargo-fmt",
877 Consumes::Exts(crate::hooks::rust_tools::EXTS),
878 ),
879 (
880 "pre-commit-clippy",
881 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
882 ),
883 (
884 "pre-commit-go-vet",
885 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
886 ),
887 (
888 "pre-commit-gofmt",
889 Consumes::Exts(crate::hooks::go_tools::EXTS),
890 ),
891 (
892 "pre-commit-kube-linter",
893 Consumes::Exts(crate::hooks::k8s::EXTS),
894 ),
895 (
896 "pre-commit-kubeconform",
897 Consumes::Exts(crate::hooks::k8s::EXTS),
898 ),
899 (
900 "pre-commit-lint-js",
901 Consumes::Exts(crate::hooks::lint_js::EXTS),
902 ),
903 (
904 "pre-commit-lint-json-yaml",
905 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
906 ),
907 ("pre-commit-merge-conflict", Consumes::All),
908 ("pre-commit-package-lock", Consumes::All),
909 (
913 "pre-commit-prettier",
914 Consumes::Exts(crate::hooks::prettier::EXTS),
915 ),
916 (
917 "pre-commit-pyright",
918 Consumes::Exts(crate::hooks::python_tools::EXTS),
919 ),
920 (
921 "pre-commit-ruff",
922 Consumes::Exts(crate::hooks::python_tools::EXTS),
923 ),
924 ("pre-commit-usual-name", Consumes::All),
925 (
926 "pre-commit-yamllint",
927 Consumes::Exts(crate::hooks::yamllint::EXTS),
928 ),
929 ("pre-commit-large-files", Consumes::All),
930 ("pre-commit-secrets", Consumes::All),
931 ("pre-push-secrets", Consumes::All),
932 ("pre-push-branch-protect", Consumes::All),
933 ("pre-push-branch-pattern", Consumes::All),
934 ("pre-push-pull-rebase", Consumes::All),
935 (
936 "pre-push-run-tests-js",
937 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
938 ),
939 (
940 "pre-push-pytest",
941 Consumes::Exts(crate::hooks::python_tools::EXTS),
942 ),
943 (
944 "pre-push-cargo-test",
945 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
946 ),
947 (
948 "pre-push-go-test",
949 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
950 ),
951 ];
952
953 #[test]
964 fn no_check_declares_a_file_type_it_does_not_consume() {
965 for (name, _) in CONSUMED {
966 assert!(
967 CHECKS.iter().any(|check| check.name == *name),
968 "CONSUMED names {name:?}, which is not a check"
969 );
970 }
971 for check in CHECKS {
972 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
973 if check.scope.files.is_empty() && entry.is_none() {
974 continue;
975 }
976 let Some((_, consumes)) = entry else {
977 panic!(
978 "{} declares scope.files {:?} but is missing from CONSUMED — \
979 say what it actually reads",
980 check.name, check.scope.files
981 );
982 };
983 let Consumes::Exts(consumed) = consumes else {
984 continue; };
986 for ext in check.scope.files {
987 assert!(
988 consumed.contains(ext),
989 "{} declares {ext:?} in its scope but never asks for it — \
990 `amont list` would report a coverage the check does not have",
991 check.name
992 );
993 }
994 }
995 }
996
997 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1006 ("pre-commit-argo-lint", false),
1007 ("pre-commit-ban-terms", false),
1008 ("pre-commit-branch-pattern", false),
1009 ("pre-commit-cargo-fmt", true),
1010 ("pre-commit-clippy", false),
1011 ("pre-commit-go-vet", false),
1012 ("pre-commit-gofmt", true),
1013 ("pre-commit-kube-linter", false),
1014 ("pre-commit-kubeconform", false),
1015 ("pre-commit-lint-js", false),
1016 ("pre-commit-lint-json-yaml", false),
1017 ("pre-commit-merge-conflict", false),
1018 ("pre-commit-package-lock", false),
1019 ("pre-commit-prettier", true),
1020 ("pre-commit-pyright", false),
1021 ("pre-commit-ruff", true),
1022 ("pre-commit-usual-name", false),
1023 ("pre-commit-yamllint", false),
1024 ("pre-commit-large-files", false),
1025 ("pre-commit-secrets", false),
1026 ("pre-push-secrets", false),
1027 ("pre-push-branch-protect", false),
1028 ("pre-push-branch-pattern", false),
1029 ("pre-push-pull-rebase", false),
1030 ("pre-push-audit-go", false),
1031 ("pre-push-audit-js", false),
1032 ("pre-push-audit-python", false),
1033 ("pre-push-audit-rust", false),
1034 ("pre-push-run-tests-js", false),
1035 ("pre-push-cargo-test", false),
1036 ("pre-push-go-test", false),
1037 ("pre-push-pytest", false),
1038 ];
1039
1040 #[test]
1043 fn every_rewrite_declaration_has_a_fixer() {
1044 let declared: BTreeSet<&str> = CHECKS
1045 .iter()
1046 .filter(|check| check.fix == super::Fix::Rewrite)
1047 .map(|check| check.name)
1048 .collect();
1049 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1050 .iter()
1051 .filter(|(_, has)| *has)
1052 .map(|(name, _)| *name)
1053 .collect();
1054 assert_eq!(
1055 declared, implemented,
1056 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1057 and a check with a fixer that does not declare it can never be reached"
1058 );
1059
1060 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1063 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1064 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1065 }
1066
1067 #[test]
1078 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1079 let safety: Vec<&str> = CHECKS
1080 .iter()
1081 .filter(|c| c.reach == Reach::Safety)
1082 .map(|c| c.name)
1083 .collect();
1084 assert_eq!(
1085 safety,
1086 vec![
1087 "pre-commit-ban-terms",
1088 "pre-commit-large-files",
1089 "pre-commit-merge-conflict",
1090 "pre-commit-secrets",
1091 "pre-push-secrets",
1092 ]
1093 );
1094 }
1095
1096 #[test]
1097 fn every_check_declares_a_stage_and_a_scope() {
1098 assert_eq!(CHECKS.len(), 32);
1099 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1100 let pre_push = super::stage_checks(Stage::PrePush).count();
1101 assert_eq!(
1102 pre_commit + pre_push,
1103 CHECKS.len(),
1104 "every check has a stage"
1105 );
1106 }
1107}