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-shellcheck",
338 stage: Stage::PreCommit,
339 scope: Scope::files(hooks::shellcheck::EXTS).not_during(MID_OPERATION),
342 severity: Severity::Block,
343 fix: Fix::None,
344 reach: Reach::Convention,
345 run: |ctx| hooks::shellcheck::run(ctx.args),
346 },
347 Builtin {
348 name: "pre-commit-hadolint",
349 stage: Stage::PreCommit,
350 scope: Scope::named(hooks::hadolint::NAMES).not_during(MID_OPERATION),
354 severity: Severity::Block,
355 fix: Fix::None,
356 reach: Reach::Convention,
357 run: |ctx| hooks::hadolint::run(ctx.args),
358 },
359 Builtin {
360 name: "pre-commit-helm-lint",
361 stage: Stage::PreCommit,
362 scope: Scope::new(hooks::helm::EXTS, hooks::helm::MARKERS).not_during(MID_OPERATION),
365 severity: Severity::Block,
366 fix: Fix::None,
367 reach: Reach::Convention,
368 run: |ctx| hooks::helm::run(ctx.args),
369 },
370 Builtin {
371 name: "pre-commit-yamllint",
372 stage: Stage::PreCommit,
373 scope: Scope::new(
374 hooks::yamllint::EXTS,
375 &[".yamllint.yaml", ".yamllint.yml", ".yamllint"],
376 )
377 .not_during(MID_OPERATION),
378 severity: Severity::Block,
379 fix: Fix::None,
380 reach: Reach::Convention,
381 run: |ctx| hooks::yamllint::run(ctx.args),
382 },
383 Builtin {
385 name: "pre-push-branch-protect",
386 stage: Stage::PrePush,
387 scope: Scope::ALWAYS,
388 severity: Severity::Block,
389 fix: Fix::None,
390 reach: Reach::Convention,
391 run: |ctx| hooks::branch_protect::run(ctx.push.get()),
392 },
393 Builtin {
394 name: "pre-push-branch-pattern",
395 stage: Stage::PrePush,
396 scope: Scope::ALWAYS,
397 severity: Severity::Block,
398 fix: Fix::None,
399 reach: Reach::Convention,
400 run: |ctx| hooks::branch_pattern::run(ctx.push.get(), ctx.args),
401 },
402 Builtin {
403 name: "pre-push-secrets",
404 stage: Stage::PrePush,
405 scope: Scope::ALWAYS,
406 severity: Severity::Block,
407 fix: Fix::None,
408 reach: Reach::Safety,
409 run: |ctx| hooks::secrets::pushed(ctx.push.get()),
410 },
411 Builtin {
412 name: "pre-push-pull-rebase",
413 stage: Stage::PrePush,
414 scope: Scope::ALWAYS.not_during(&[GitState::Rebase, GitState::Merge]),
415 severity: Severity::Block,
416 fix: Fix::None,
417 reach: Reach::Convention,
418 run: |ctx| hooks::pull_rebase::run(ctx.args),
419 },
420 Builtin {
427 name: "pre-push-audit-go",
428 stage: Stage::PrePush,
429 scope: Scope::new(&[], &["go.sum"]),
430 severity: Severity::Block,
431 fix: Fix::None,
432 reach: Reach::Convention,
433 run: |ctx| hooks::audit::go(ctx.push.get()),
434 },
435 Builtin {
436 name: "pre-push-audit-js",
437 stage: Stage::PrePush,
438 scope: Scope::new(&[], &["package-lock.json"]),
439 severity: Severity::Block,
440 fix: Fix::None,
441 reach: Reach::Convention,
442 run: |ctx| hooks::audit::js(ctx.push.get()),
443 },
444 Builtin {
445 name: "pre-push-audit-python",
446 stage: Stage::PrePush,
447 scope: Scope::new(&[], &["requirements.txt"]),
448 severity: Severity::Block,
449 fix: Fix::None,
450 reach: Reach::Convention,
451 run: |ctx| hooks::audit::python(ctx.push.get()),
452 },
453 Builtin {
454 name: "pre-push-audit-rust",
455 stage: Stage::PrePush,
456 scope: Scope::new(&[], &["Cargo.lock"]),
457 severity: Severity::Block,
458 fix: Fix::None,
459 reach: Reach::Convention,
460 run: |ctx| hooks::audit::rust(ctx.push.get()),
461 },
462 Builtin {
463 name: "pre-push-run-tests-js",
464 stage: Stage::PrePush,
465 scope: Scope::new(hooks::run_tests::JS_EXTS, &["package.json"])
466 .not_during(&[GitState::Bisect, GitState::Rebase]),
467 severity: Severity::Block,
468 fix: Fix::None,
469 reach: Reach::Convention,
470 run: |ctx| hooks::run_tests::run(ctx.push.get(), &ctx.manifest.externals),
471 },
472 Builtin {
473 name: "pre-push-cargo-test",
474 stage: Stage::PrePush,
475 scope: Scope::new(hooks::rust_tools::EXTS, &["Cargo.toml"])
476 .not_during(&[GitState::Bisect, GitState::Rebase]),
477 severity: Severity::Block,
478 fix: Fix::None,
479 reach: Reach::Convention,
480 run: |ctx| hooks::rust_tools::test(ctx.push.get()),
481 },
482 Builtin {
483 name: "pre-push-go-test",
484 stage: Stage::PrePush,
485 scope: Scope::new(hooks::go_tools::EXTS, &["go.mod"])
486 .not_during(&[GitState::Bisect, GitState::Rebase]),
487 severity: Severity::Block,
488 fix: Fix::None,
489 reach: Reach::Convention,
490 run: |ctx| hooks::go_tools::test(ctx.push.get()),
491 },
492 Builtin {
493 name: "pre-push-pytest",
494 stage: Stage::PrePush,
495 scope: Scope::new(hooks::python_tools::EXTS, &["pytest.ini", "conftest.py"])
496 .not_during(&[GitState::Bisect, GitState::Rebase]),
497 severity: Severity::Block,
498 fix: Fix::None,
499 reach: Reach::Convention,
500 run: |ctx| hooks::python_tools::pytest(ctx.push.get()),
501 },
502];
503
504pub fn severity_of(check: &dyn Check) -> Severity {
510 effective_override(None, check.name()).unwrap_or_else(|| check.severity())
511}
512
513pub fn severity_key(check: &str) -> String {
515 format!("amont.severity.{check}")
516}
517
518#[derive(Debug, Default, Clone)]
529pub struct Overrides(std::collections::BTreeMap<String, (Severity, Source)>);
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub enum Source {
536 Config,
537 Policy,
538}
539
540impl Source {
541 pub fn as_str(self) -> &'static str {
542 match self {
543 Source::Config => "config",
544 Source::Policy => "policy",
545 }
546 }
547}
548
549impl Overrides {
550 pub fn read() -> Overrides {
557 let policy = crate::policy::current();
558 match crate::git::stdout(&[
559 "config",
560 "--show-scope",
561 "--get-regexp",
562 r"^amont\.severity\.",
563 ]) {
564 Some(scoped) => Overrides::from_scoped(&scoped, policy),
565 None => Overrides::from_plain_with_policy_below(
569 crate::git::stdout(&["config", "--get-regexp", r"^amont\.severity\."]),
570 policy,
571 ),
572 }
573 }
574
575 pub fn from_scoped(scoped: &str, policy: &crate::policy::Policy) -> Overrides {
582 let mut below = String::new();
583 let mut above = String::new();
584 for line in scoped.lines() {
585 let Some((scope, rest)) = line.split_once('\t') else {
586 continue;
587 };
588 match scope {
589 "system" | "global" => {
590 below.push_str(rest);
591 below.push('\n');
592 }
593 _ => {
594 above.push_str(rest);
595 above.push('\n');
596 }
597 }
598 }
599 let mut o = Overrides::default();
600 o.fold_plain(&below, Source::Config);
601 o.fold_policy(policy);
602 o.fold_plain(&above, Source::Config);
603 o
604 }
605
606 pub fn from_plain_with_policy_below(
611 plain: Option<String>,
612 policy: &crate::policy::Policy,
613 ) -> Overrides {
614 let mut o = Overrides::default();
615 o.fold_policy(policy);
616 o.fold_plain(plain.as_deref().unwrap_or_default(), Source::Config);
617 o
618 }
619
620 fn fold_policy(&mut self, policy: &crate::policy::Policy) {
623 for (target, severity) in &policy.severities {
624 self.0.insert(target.clone(), (*severity, Source::Policy));
625 }
626 }
627
628 fn fold_plain(&mut self, text: &str, source: Source) {
632 for line in text.lines() {
633 let Some((key, value)) = line.split_once(' ') else {
634 continue;
635 };
636 let Some(check) = key.strip_prefix("amont.severity.") else {
637 continue;
638 };
639 match Severity::parse(value.trim()) {
640 Some(sev) => {
641 self.0.insert(check.to_string(), (sev, source));
642 }
643 None => {
644 self.0.remove(check);
645 }
646 }
647 }
648 }
649
650 pub fn from_config(out: Option<String>) -> Overrides {
658 let mut o = Overrides::default();
659 o.fold_plain(out.as_deref().unwrap_or_default(), Source::Config);
660 o
661 }
662
663 pub fn applied_to(&self, check: &str) -> Option<(&str, Severity)> {
670 self.applied_with_source(check)
671 .map(|(pattern, severity, _)| (pattern, severity))
672 }
673
674 pub fn applied_with_source(&self, check: &str) -> Option<(&str, Severity, Source)> {
678 self.0
679 .iter()
680 .filter_map(|(pattern, (severity, source))| {
681 crate::names_check(check, pattern)
682 .map(|m| (m, pattern.as_str(), *severity, *source))
683 })
684 .max_by_key(|(m, _, _, _)| *m)
685 .map(|(_, pattern, severity, source)| (pattern, severity, source))
686 }
687
688 pub fn of(&self, check: &dyn Check) -> Severity {
690 self.applied_to(check.name())
691 .map(|(_, severity)| severity)
692 .unwrap_or_else(|| check.severity())
693 }
694}
695
696#[cfg(test)]
697mod precedence {
698 use super::{Overrides, Severity};
699
700 fn overrides(lines: &[&str]) -> Overrides {
701 let text = lines
702 .iter()
703 .map(|l| format!("amont.severity.{l}\n"))
704 .collect::<String>();
705 Overrides::from_config(Some(text))
706 }
707
708 #[test]
712 fn the_more_specific_key_wins() {
713 let both = overrides(&["pre-commit warn", "pre-commit-clippy block"]);
714 assert_eq!(
715 both.applied_to("pre-commit-clippy"),
716 Some(("pre-commit-clippy", Severity::Block)),
717 "a full id beats its trigger"
718 );
719 assert_eq!(
720 both.applied_to("pre-commit-shellcheck"),
721 Some(("pre-commit", Severity::Warn)),
722 "and the trigger still governs every check it did not exempt"
723 );
724 }
725
726 #[test]
729 fn the_three_ways_to_name_a_check_are_ranked() {
730 let all = overrides(&["pre-commit warn", "clippy block", "pre-commit-clippy warn"]);
731 assert_eq!(
732 all.applied_to("pre-commit-clippy"),
733 Some(("pre-commit-clippy", Severity::Warn))
734 );
735
736 let no_full = overrides(&["pre-commit warn", "clippy block"]);
737 assert_eq!(
738 no_full.applied_to("pre-commit-clippy"),
739 Some(("clippy", Severity::Block)),
740 "a short name beats a trigger"
741 );
742 }
743
744 #[test]
747 fn a_key_that_names_no_check_applies_to_nothing() {
748 let typo = overrides(&["clipy warn", "e warn", " warn"]);
749 assert_eq!(typo.applied_to("pre-commit-clippy"), None);
750 }
751}
752
753pub fn effective_override(repo: Option<&Path>, check: &str) -> Option<Severity> {
763 overrides_in(repo).applied_to(check).map(|(_, s)| s)
764}
765
766pub fn effective_key(repo: Option<&Path>, check: &str) -> Option<String> {
772 overrides_in(repo)
773 .applied_to(check)
774 .map(|(pattern, _)| pattern.to_string())
775}
776
777fn overrides_in(repo: Option<&Path>) -> Overrides {
778 match repo {
779 None => Overrides::read(),
783 Some(dir) => Overrides::from_config(crate::git::stdout_in(
787 dir,
788 &["config", "--get-regexp", r"^amont\.severity\."],
789 )),
790 }
791}
792
793pub fn stage_checks(stage: Stage) -> impl Iterator<Item = &'static Builtin> {
795 CHECKS.iter().filter(move |check| check.stage == stage)
796}
797
798pub fn all_stage_checks<'a>(
805 stage: Stage,
806 manifest: &'a crate::manifest::Manifest,
807) -> Vec<&'a dyn Check> {
808 let mut out: Vec<&'a dyn Check> = stage_checks(stage)
809 .map(|check| check as &dyn Check)
810 .collect();
811 out.extend(
812 manifest
813 .externals
814 .iter()
815 .filter(|external| external.stage == stage)
816 .map(|external| external as &dyn Check),
817 );
818 out
819}
820
821pub fn lookup(name: &str, manifest: &crate::manifest::Manifest) -> Option<HookFn> {
822 if let Some((_, f)) = ENTRYPOINTS.iter().find(|(n, _)| *n == name) {
823 return Some(*f);
824 }
825 if CHECKS.iter().any(|check| check.name == name)
832 || manifest
833 .externals
834 .iter()
835 .any(|external| external.id == name)
836 {
837 return Some(|ctx: &Ctx| {
838 let check = one_named(ctx.name, ctx.manifest).expect("checked above");
839 Verdict::blocking(matches!(
840 (check.run(ctx), severity_of(check)),
841 (Outcome::Failed, Severity::Block)
842 ))
843 });
844 }
845 None
846}
847
848pub fn one_named<'a>(name: &str, manifest: &'a crate::manifest::Manifest) -> Option<&'a dyn Check> {
852 if let Some(builtin) = CHECKS.iter().find(|check| check.name == name) {
853 return Some(builtin);
854 }
855 manifest
856 .externals
857 .iter()
858 .find(|external| external.id == name)
859 .map(|external| external as &dyn Check)
860}
861
862#[cfg(test)]
863mod tests {
864 use super::{lookup, Overrides, Reach, Severity, Stage, CHECKS, ENTRYPOINTS};
865 use std::collections::BTreeSet;
866
867 #[test]
874 fn the_batch_agrees_with_the_authority() {
875 let d = std::env::temp_dir().join(format!("ov-{}", std::process::id()));
876 let _ = std::fs::remove_dir_all(&d);
877 std::fs::create_dir_all(&d).unwrap();
878 let git = |args: &[&str]| {
879 std::process::Command::new("git")
880 .args(args)
881 .current_dir(&d)
882 .output()
883 .expect("git");
884 };
885 git(&["init", "-q", "--template=", "."]);
886 let key = "amont.severity.pre-commit-merge-conflict";
887 git(&["config", "--add", key, "warn"]);
888 git(&["config", "--add", key, "block"]);
889
890 let raw = std::process::Command::new("git")
891 .args(["config", "--get-regexp", r"^amont\.severity\."])
892 .current_dir(&d)
893 .output()
894 .expect("git");
895 let batch = Overrides::from_config(Some(
896 String::from_utf8_lossy(&raw.stdout).trim().to_string(),
897 ));
898 let authority =
899 crate::git::stdout_in(&d, &["config", "--get", key]).and_then(|v| Severity::parse(&v));
900 let _ = std::fs::remove_dir_all(&d);
901
902 assert_eq!(
903 authority,
904 Some(Severity::Block),
905 "git applies the last entry"
906 );
907 assert_eq!(
908 batch.0.get("pre-commit-merge-conflict").map(|(s, _)| *s),
909 authority,
910 "the batch reader disagreed with `--get`"
911 );
912 }
913
914 #[test]
918 fn an_unrecognised_value_clears_rather_than_overrides() {
919 let o = Overrides::from_config(Some(
920 "amont.severity.a warn\namont.severity.a advisory\namont.severity.b warn".to_string(),
921 ));
922 assert_eq!(o.0.get("a"), None, "a typo must not leave `warn` standing");
923 assert_eq!(o.0.get("b").map(|(s, _)| *s), Some(Severity::Warn));
924 }
925
926 #[test]
927 fn names_are_unique_across_entrypoints_and_checks() {
928 let mut seen = BTreeSet::new();
929 for n in ENTRYPOINTS
930 .iter()
931 .map(|(n, _)| *n)
932 .chain(CHECKS.iter().map(|check| check.name))
933 {
934 assert!(seen.insert(n), "duplicate registration: {n}");
935 }
936 }
937
938 #[test]
940 fn the_shipped_shims_are_exactly_the_git_invoked_hooks() {
941 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../templates/hooks");
942 let mut shipped: Vec<String> = std::fs::read_dir(dir)
943 .expect("templates/hooks")
944 .flatten()
945 .map(|entry| entry.file_name().to_string_lossy().into_owned())
946 .collect();
947 shipped.sort();
948 assert_eq!(
949 shipped,
950 vec![
951 "commit-msg",
952 "post-commit",
953 "pre-commit",
954 "pre-push",
955 "prepare-commit-msg"
956 ]
957 );
958 let none = crate::manifest::Manifest::default();
959 for name in &shipped {
960 assert!(
961 lookup(name, &none).is_some(),
962 "shipped shim {name:?} has no handler"
963 );
964 }
965 }
966
967 #[test]
970 fn every_check_is_reachable_by_name() {
971 let none = crate::manifest::Manifest::default();
972 for check in CHECKS {
973 assert!(
974 lookup(check.name, &none).is_some(),
975 "{} not reachable",
976 check.name
977 );
978 }
979 assert!(lookup("pre-commit-not-a-check", &none).is_none());
980 }
981
982 #[test]
984 fn pre_push_runs_cheapest_first() {
985 let order: Vec<&str> = super::stage_checks(Stage::PrePush)
986 .map(|check| check.name)
987 .collect();
988 assert_eq!(
989 order,
990 vec![
991 "pre-push-branch-protect",
992 "pre-push-branch-pattern",
993 "pre-push-secrets",
994 "pre-push-pull-rebase",
995 "pre-push-audit-go",
996 "pre-push-audit-js",
997 "pre-push-audit-python",
998 "pre-push-audit-rust",
999 "pre-push-run-tests-js",
1000 "pre-push-cargo-test",
1001 "pre-push-go-test",
1002 "pre-push-pytest",
1003 ]
1004 );
1005 }
1006
1007 enum Consumes {
1013 All,
1014 Exts(&'static [&'static str]),
1015 }
1016
1017 const CONSUMED: &[(&str, Consumes)] = &[
1031 (
1032 "pre-commit-argo-lint",
1033 Consumes::Exts(crate::hooks::k8s::EXTS),
1034 ),
1035 (
1039 "pre-commit-ban-terms",
1040 Consumes::Exts(crate::hooks::ban_terms::EXTS),
1041 ),
1042 (
1043 "pre-commit-cargo-fmt",
1044 Consumes::Exts(crate::hooks::rust_tools::EXTS),
1045 ),
1046 (
1047 "pre-commit-clippy",
1048 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1049 ),
1050 (
1051 "pre-commit-go-vet",
1052 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1053 ),
1054 (
1055 "pre-commit-gofmt",
1056 Consumes::Exts(crate::hooks::go_tools::EXTS),
1057 ),
1058 (
1059 "pre-commit-kube-linter",
1060 Consumes::Exts(crate::hooks::k8s::EXTS),
1061 ),
1062 (
1063 "pre-commit-kubeconform",
1064 Consumes::Exts(crate::hooks::k8s::EXTS),
1065 ),
1066 (
1067 "pre-commit-lint-js",
1068 Consumes::Exts(crate::hooks::lint_js::EXTS),
1069 ),
1070 (
1071 "pre-commit-lint-json-yaml",
1072 Consumes::Exts(crate::hooks::lint_json_yaml::EXTS),
1073 ),
1074 ("pre-commit-merge-conflict", Consumes::All),
1075 ("pre-commit-package-lock", Consumes::All),
1076 (
1080 "pre-commit-prettier",
1081 Consumes::Exts(crate::hooks::prettier::EXTS),
1082 ),
1083 (
1084 "pre-commit-pyright",
1085 Consumes::Exts(crate::hooks::python_tools::EXTS),
1086 ),
1087 (
1088 "pre-commit-ruff",
1089 Consumes::Exts(crate::hooks::python_tools::EXTS),
1090 ),
1091 ("pre-commit-usual-name", Consumes::All),
1092 (
1093 "pre-commit-yamllint",
1094 Consumes::Exts(crate::hooks::yamllint::EXTS),
1095 ),
1096 (
1097 "pre-commit-shellcheck",
1098 Consumes::Exts(crate::hooks::shellcheck::EXTS),
1099 ),
1100 (
1101 "pre-commit-hadolint",
1102 Consumes::Exts(crate::hooks::hadolint::NAMES),
1103 ),
1104 (
1105 "pre-commit-helm-lint",
1106 Consumes::Exts(crate::hooks::helm::EXTS),
1107 ),
1108 ("pre-commit-large-files", Consumes::All),
1109 ("pre-commit-secrets", Consumes::All),
1110 ("pre-push-secrets", Consumes::All),
1111 ("pre-push-branch-protect", Consumes::All),
1112 ("pre-push-branch-pattern", Consumes::All),
1113 ("pre-push-pull-rebase", Consumes::All),
1114 (
1115 "pre-push-run-tests-js",
1116 Consumes::Exts(crate::hooks::run_tests::JS_EXTS),
1117 ),
1118 (
1119 "pre-push-pytest",
1120 Consumes::Exts(crate::hooks::python_tools::EXTS),
1121 ),
1122 (
1123 "pre-push-cargo-test",
1124 Consumes::Exts(crate::hooks::rust_tools::RUST_PATHS),
1125 ),
1126 (
1127 "pre-push-go-test",
1128 Consumes::Exts(crate::hooks::go_tools::GO_PATHS),
1129 ),
1130 ];
1131
1132 #[test]
1143 fn no_check_declares_a_file_type_it_does_not_consume() {
1144 for (name, _) in CONSUMED {
1145 assert!(
1146 CHECKS.iter().any(|check| check.name == *name),
1147 "CONSUMED names {name:?}, which is not a check"
1148 );
1149 }
1150 for check in CHECKS {
1151 let entry = CONSUMED.iter().find(|(name, _)| *name == check.name);
1152 if check.scope.files.is_empty() && entry.is_none() {
1153 continue;
1154 }
1155 let Some((_, consumes)) = entry else {
1156 panic!(
1157 "{} declares scope.files {:?} but is missing from CONSUMED — \
1158 say what it actually reads",
1159 check.name, check.scope.files
1160 );
1161 };
1162 let Consumes::Exts(consumed) = consumes else {
1163 continue; };
1165 for ext in check.scope.files {
1166 assert!(
1167 consumed.contains(ext),
1168 "{} declares {ext:?} in its scope but never asks for it — \
1169 `amont list` would report a coverage the check does not have",
1170 check.name
1171 );
1172 }
1173 }
1174 }
1175
1176 const HAS_FIXING_CODE: &[(&str, bool)] = &[
1185 ("pre-commit-agents-md", true),
1186 ("pre-commit-argo-lint", false),
1187 ("pre-commit-ban-terms", false),
1188 ("pre-commit-branch-pattern", false),
1189 ("pre-commit-branch-protect", false),
1190 ("pre-commit-cargo-fmt", true),
1191 ("pre-commit-clippy", false),
1192 ("pre-commit-go-vet", false),
1193 ("pre-commit-gofmt", true),
1194 ("pre-commit-kube-linter", false),
1195 ("pre-commit-kubeconform", false),
1196 ("pre-commit-lint-js", false),
1197 ("pre-commit-lint-json-yaml", false),
1198 ("pre-commit-merge-conflict", false),
1199 ("pre-commit-package-lock", false),
1200 ("pre-commit-prettier", true),
1201 ("pre-commit-pyright", false),
1202 ("pre-commit-ruff", true),
1203 ("pre-commit-usual-name", false),
1204 ("pre-commit-yamllint", false),
1205 ("pre-commit-shellcheck", false),
1206 ("pre-commit-hadolint", false),
1207 ("pre-commit-helm-lint", false),
1208 ("pre-commit-large-files", false),
1209 ("pre-commit-secrets", false),
1210 ("pre-push-secrets", false),
1211 ("pre-push-branch-protect", false),
1212 ("pre-push-branch-pattern", false),
1213 ("pre-push-pull-rebase", false),
1214 ("pre-push-audit-go", false),
1215 ("pre-push-audit-js", false),
1216 ("pre-push-audit-python", false),
1217 ("pre-push-audit-rust", false),
1218 ("pre-push-run-tests-js", false),
1219 ("pre-push-cargo-test", false),
1220 ("pre-push-go-test", false),
1221 ("pre-push-pytest", false),
1222 ];
1223
1224 #[test]
1227 fn every_rewrite_declaration_has_a_fixer() {
1228 let declared: BTreeSet<&str> = CHECKS
1229 .iter()
1230 .filter(|check| check.fix == super::Fix::Rewrite)
1231 .map(|check| check.name)
1232 .collect();
1233 let implemented: BTreeSet<&str> = HAS_FIXING_CODE
1234 .iter()
1235 .filter(|(_, has)| *has)
1236 .map(|(name, _)| *name)
1237 .collect();
1238 assert_eq!(
1239 declared, implemented,
1240 "a check declaring Fix::Rewrite with no fixer lies to `amont list --json`, \
1241 and a check with a fixer that does not declare it can never be reached"
1242 );
1243
1244 let listed: BTreeSet<&str> = HAS_FIXING_CODE.iter().map(|(name, _)| *name).collect();
1247 let all: BTreeSet<&str> = CHECKS.iter().map(|check| check.name).collect();
1248 assert_eq!(listed, all, "HAS_FIXING_CODE does not cover CHECKS");
1249 }
1250
1251 #[test]
1262 fn the_safety_net_is_exactly_the_low_false_positive_set() {
1263 let safety: Vec<&str> = CHECKS
1264 .iter()
1265 .filter(|c| c.reach == Reach::Safety)
1266 .map(|c| c.name)
1267 .collect();
1268 assert_eq!(
1269 safety,
1270 vec![
1271 "pre-commit-ban-terms",
1272 "pre-commit-large-files",
1273 "pre-commit-merge-conflict",
1274 "pre-commit-secrets",
1275 "pre-push-secrets",
1276 ]
1277 );
1278 }
1279
1280 #[test]
1281 fn every_check_declares_a_stage_and_a_scope() {
1282 assert_eq!(CHECKS.len(), 37);
1283 let pre_commit = super::stage_checks(Stage::PreCommit).count();
1284 let pre_push = super::stage_checks(Stage::PrePush).count();
1285 assert_eq!(
1286 pre_commit + pre_push,
1287 CHECKS.len(),
1288 "every check has a stage"
1289 );
1290 }
1291}