amont_runtime/check.rs
1//! What a check IS, as one value rather than four tables.
2//!
3//! Before this, a check was spread across `REGISTRY` (name → fn), two ordered
4//! name lists, and a language table in the fleet crate — four places keyed by
5//! the same string, held together by reconciliation tests. Those tests were
6//! good, but they policed a shape that should not have been splittable. With
7//! the metadata attached to the check, there is nothing left to reconcile.
8//!
9//! It also gives external checks somewhere to exist. A third party cannot add a
10//! Rust module without rebuilding the binary, so extension means a declared
11//! command implementing this same trait — and the dispatcher not caring which
12//! kind it is holding.
13
14use crate::registry::Ctx;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Stage {
18 PreCommit,
19 PrePush,
20}
21
22impl Stage {
23 pub fn as_str(self) -> &'static str {
24 match self {
25 Stage::PreCommit => "pre-commit",
26 Stage::PrePush => "pre-push",
27 }
28 }
29}
30
31/// When a check is relevant, declared rather than reimplemented by every
32/// reader.
33///
34/// A CONJUNCTION, not a choice: ruff is `.py` files AND a ruff config; clippy
35/// is `.rs` AND `Cargo.toml`. An earlier design offered these as alternatives
36/// plus a `Custom` escape hatch, which would have swallowed nearly every check
37/// and left the dashboard knowing nothing.
38/// A git operation that is part-way through.
39///
40/// Detected from the marker files git writes into `$GIT_DIR`, which is how git
41/// itself and every prompt-writer answers the question.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum GitState {
44 Merge,
45 Rebase,
46 CherryPick,
47 Revert,
48 Bisect,
49}
50
51impl GitState {
52 /// The marker git writes. `rebase-merge` and `rebase-apply` are
53 /// DIRECTORIES; the rest are files, and `Path::exists` covers both.
54 pub fn markers(self) -> &'static [&'static str] {
55 match self {
56 GitState::Merge => &["MERGE_HEAD"],
57 GitState::Rebase => &["REBASE_HEAD", "rebase-merge", "rebase-apply"],
58 GitState::CherryPick => &["CHERRY_PICK_HEAD"],
59 GitState::Revert => &["REVERT_HEAD"],
60 GitState::Bisect => &["BISECT_LOG"],
61 }
62 }
63
64 pub fn as_str(self) -> &'static str {
65 match self {
66 GitState::Merge => "a merge",
67 GitState::Rebase => "a rebase",
68 GitState::CherryPick => "a cherry-pick",
69 GitState::Revert => "a revert",
70 GitState::Bisect => "a bisect",
71 }
72 }
73
74 pub const ALL: [GitState; 5] = [
75 GitState::Merge,
76 GitState::Rebase,
77 GitState::CherryPick,
78 GitState::Revert,
79 GitState::Bisect,
80 ];
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct Scope {
85 /// Extensions that trigger it. Empty means any change.
86 pub files: &'static [&'static str],
87 /// Exact FILENAMES that trigger it — `package.json`, `Dockerfile` —
88 /// matched against the path's basename, never as a suffix: an extension
89 /// list cannot say "package.json" without also matching
90 /// `not-package.json`. Builtins keep this empty; the manifest's scope
91 /// column fills it.
92 pub names: &'static [&'static str],
93 /// Config paths that opt a repository in. Empty means always on.
94 pub opt_in: &'static [&'static str],
95 /// Git operations during which this check does not run.
96 ///
97 /// The other half of "when does this apply". `files` and `opt_in` say which
98 /// REPOSITORIES and which CHANGES; this says which repository STATES — a
99 /// question that used to be answered by one hard-coded `CHERRY_PICK_HEAD`
100 /// test in one dispatcher, with the other carrying a comment admitting it
101 /// had none because the shell version had none.
102 pub not_during: &'static [GitState],
103}
104
105impl Scope {
106 pub const ALWAYS: Scope = Scope {
107 files: &[],
108 names: &[],
109 opt_in: &[],
110 not_during: &[],
111 };
112
113 pub const fn files(files: &'static [&'static str]) -> Scope {
114 Scope {
115 files,
116 names: &[],
117 opt_in: &[],
118 not_during: &[],
119 }
120 }
121
122 pub const fn new(files: &'static [&'static str], opt_in: &'static [&'static str]) -> Scope {
123 Scope {
124 files,
125 names: &[],
126 opt_in,
127 not_during: &[],
128 }
129 }
130
131 /// The same scope, silent during these operations.
132 pub const fn not_during(self, states: &'static [GitState]) -> Scope {
133 Scope {
134 files: self.files,
135 names: self.names,
136 opt_in: self.opt_in,
137 not_during: states,
138 }
139 }
140
141 /// No file gate at all — every change is in scope.
142 pub fn is_unscoped(&self) -> bool {
143 self.files.is_empty() && self.names.is_empty()
144 }
145
146 /// Does ONE path fall inside the file gate?
147 pub fn covers(&self, path: &str) -> bool {
148 self.files.iter().any(|ext| path.ends_with(ext)) || {
149 let base = path.rsplit('/').next().unwrap_or(path);
150 self.names.contains(&base)
151 }
152 }
153
154 /// Did the gate see EVERY one of `paths`? All-match, where [`matches`]
155 /// is any-match: the caller asking this is deciding whether a commit-time
156 /// run COVERED a push, and under-approximating is the safe direction.
157 pub fn covers_all(&self, paths: &[String]) -> bool {
158 self.is_unscoped() || paths.iter().all(|p| self.covers(p))
159 }
160
161 /// Would this check ever fire, given the paths a repository contains?
162 ///
163 /// Deliberately coarse for checks that resolve an ancestor at run time —
164 /// `cargo-fmt` declares `Cargo.toml` meaning "somewhere here" while
165 /// enforcing "nearest above the staged file". The dispatcher asks the
166 /// precise question by running the check; this answers the dashboard's
167 /// question, "would it ever fire", where over-approximating is the safe
168 /// direction.
169 pub fn matches(&self, paths: &[String]) -> bool {
170 let by_ext = self.is_unscoped() || paths.iter().any(|path| self.covers(path));
171 let opted_in = self.opt_in.is_empty()
172 || paths.iter().any(|p| {
173 let name = p.rsplit('/').next().unwrap_or(p);
174 self.opt_in.iter().any(|c| {
175 // A trailing `*` is a prefix match: `.kube-linter*.yaml`.
176 match c.split_once('*') {
177 Some((pre, suf)) => name.starts_with(pre) && name.ends_with(suf),
178 None => name == *c,
179 }
180 })
181 });
182 by_ext && opted_in
183 }
184}
185
186/// What a check meant, as opposed to what it printed.
187///
188/// The fourth variant is the point. Fifteen sites used to warn and return 0,
189/// collapsing two different situations: "I ran and found something you should
190/// know" and "I could not run at all". `ruff config found but no ruff binary`
191/// was indistinguishable from ruff running clean — to the dispatcher, and to
192/// the dashboard. A repository where a check has silently never executed read
193/// as one where it passes.
194///
195/// That is the same invisibility `hook.skip` had before skipped checks were
196/// announced, and it took three PRs to notice there.
197///
198/// The check still prints its own message; this only classifies the result.
199///
200/// Deliberately NO `Default`. It used to be `Failed`, to fill the slot of a
201/// check whose thread died — a real rule, but `Default` means "the neutral
202/// value" to every reader and to every `#[derive(Default)]` that might later
203/// contain one. The rule is now written where it applies, in the runner.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum Outcome {
206 Passed,
207 /// Ran, found a problem. Whether that blocks is `Severity`, not this.
208 Failed,
209 /// Ran, found something worth saying, which does not block.
210 Warned,
211 /// Ran, found a problem, and REPAIRED it. The commit proceeds with the
212 /// repair staged, which is neither `Passed` (something happened, and the
213 /// author should know their files changed) nor `Failed`.
214 Fixed,
215 /// COULD NOT RUN — a tool is missing, or the opt-in config is absent.
216 Unavailable,
217}
218
219/// What a HOOK concluded — the only thing git actually reads.
220///
221/// Distinct from `Outcome`, which is what one CHECK concluded. Git has exactly
222/// two questions to ask a hook, so this has exactly two answers, and the `i32`
223/// that expresses them lives at the process boundary rather than being threaded
224/// through every hook, dispatcher and handler as it used to be.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum Verdict {
227 Proceed,
228 Block,
229}
230
231impl Verdict {
232 /// The exit code git reads. The ONLY place a hook result becomes a number.
233 pub fn exit_code(self) -> i32 {
234 match self {
235 Verdict::Proceed => 0,
236 Verdict::Block => 1,
237 }
238 }
239
240 pub fn blocking(blocked: bool) -> Verdict {
241 if blocked {
242 Verdict::Block
243 } else {
244 Verdict::Proceed
245 }
246 }
247}
248
249/// Whether a failing check stops the commit or merely reports.
250///
251/// Declared per check and overridable per repository with
252/// `git config amont.severity.<check> warn`. That is a better escape hatch
253/// than `hook.skip`, which is all-or-nothing and invisible enough that
254/// `hook.skip = e` disables all twenty checks: a downgrade keeps the signal and
255/// removes only the block.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum Severity {
258 Block,
259 Warn,
260}
261
262impl Severity {
263 /// The ONE mapping from configured text to severity.
264 ///
265 /// There were four: this key's reader, the manifest's severity column, the
266 /// dashboard's copy, and the dashboard's reverse mapping for `--json`. They
267 /// agreed, but nothing made them — and the dashboard's copy is its
268 /// prediction of what the dispatcher will do, which is the one thing it must
269 /// never get wrong.
270 ///
271 /// `None` for anything else, deliberately: git validates nothing here, so an
272 /// unrecognised value must fall back to the declared severity rather than
273 /// silently disable a check.
274 pub fn parse(value: &str) -> Option<Severity> {
275 match value {
276 "warn" => Some(Severity::Warn),
277 "block" => Some(Severity::Block),
278 _ => None,
279 }
280 }
281
282 /// How it is written in config and in `--json`.
283 pub fn as_str(self) -> &'static str {
284 match self {
285 Severity::Block => "block",
286 Severity::Warn => "warn",
287 }
288 }
289}
290
291/// Whether a check can rewrite the files it inspects.
292///
293/// Off by default and per check, never global: a hook that edits your files
294/// without being asked is a larger surprise than one that complains.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum Fix {
297 /// Reports only. Every check, until somebody declares otherwise.
298 None,
299 /// Runs a command that rewrites files, and stages what it changed.
300 ///
301 /// Only reachable from a `Stage::PreCommit` declaration. A pre-push hook
302 /// must not modify the worktree or index: the pushed commit would then
303 /// differ from the tree the developer is looking at.
304 Rewrite,
305}
306
307impl Fix {
308 /// How it is written in `--json`. No `parse()`: nothing reads a `Fix`
309 /// back out of text — the manifest's `fix` marker has its own, unrelated
310 /// parsing path in `manifest.rs`.
311 pub fn as_str(self) -> &'static str {
312 match self {
313 Fix::None => "none",
314 Fix::Rewrite => "rewrite",
315 }
316 }
317}
318
319/// One check, whether compiled in or declared by the repository.
320///
321/// `Sync` because `pre-commit` hands every check to its own thread. Both
322/// implementations satisfy it for free, and requiring it here is what lets the
323/// dispatcher hold `&'static dyn Check` without caring which kind it has.
324pub trait Check: Sync {
325 fn name(&self) -> &str;
326 fn stage(&self) -> Stage;
327 fn scope(&self) -> Scope;
328 fn severity(&self) -> Severity;
329 /// Whether this check can repair what it finds. `None` for almost all.
330 fn fix(&self) -> Fix {
331 Fix::None
332 }
333 fn run(&self, ctx: &Ctx) -> Outcome;
334}
335
336/// A check compiled into the binary.
337pub struct Builtin {
338 pub name: &'static str,
339 pub stage: Stage,
340 pub scope: Scope,
341 pub severity: Severity,
342 pub run: fn(&Ctx) -> Outcome,
343 /// Almost always `Fix::None`; see `CHECKS`.
344 pub fix: Fix,
345}
346
347impl Check for Builtin {
348 fn name(&self) -> &str {
349 self.name
350 }
351 fn stage(&self) -> Stage {
352 self.stage
353 }
354 fn scope(&self) -> Scope {
355 self.scope
356 }
357 fn severity(&self) -> Severity {
358 self.severity
359 }
360 fn fix(&self) -> Fix {
361 self.fix
362 }
363 fn run(&self, ctx: &Ctx) -> Outcome {
364 (self.run)(ctx)
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 /// Round-trips, and refuses everything else. A `Some` for an unknown value
373 /// would turn a typo into a silent disable.
374 #[test]
375 fn severity_parses_exactly_the_two_words_it_documents() {
376 for s in [Severity::Block, Severity::Warn] {
377 assert_eq!(Severity::parse(s.as_str()), Some(s));
378 }
379 for bad in ["", "Warn", "WARN", "advisory", "true", "1", " warn"] {
380 assert_eq!(Severity::parse(bad), None, "{bad:?} must not parse");
381 }
382 }
383
384 #[test]
385 fn fix_says_how_it_is_written_in_json() {
386 assert_eq!(Fix::None.as_str(), "none");
387 assert_eq!(Fix::Rewrite.as_str(), "rewrite");
388 }
389
390 #[test]
391 fn always_matches_anything() {
392 assert!(Scope::ALWAYS.matches(&[]));
393 assert!(Scope::ALWAYS.matches(&["README.md".into()]));
394 }
395
396 #[test]
397 fn extensions_gate_on_the_file_type() {
398 let s = Scope::files(&[".rs"]);
399 assert!(s.matches(&["src/main.rs".into()]));
400 assert!(!s.matches(&["README.md".into()]));
401 }
402
403 /// The case the enum could not express: BOTH conditions must hold.
404 #[test]
405 fn files_and_opt_in_are_a_conjunction() {
406 let ruff = Scope::new(&[".py"], &["ruff.toml", "pyproject.toml"]);
407 assert!(
408 !ruff.matches(&["a.py".into()]),
409 "python alone is not enough — the repo must opt in"
410 );
411 assert!(
412 !ruff.matches(&["pyproject.toml".into()]),
413 "and a config alone is not enough without python"
414 );
415 assert!(ruff.matches(&["a.py".into(), "pyproject.toml".into()]));
416 }
417
418 /// `.kube-linter*.yaml` is a real config name in this repo's own hooks.
419 #[test]
420 fn a_trailing_star_is_a_prefix_match() {
421 let s = Scope::new(&[".yaml"], &[".kube-linter*.yaml"]);
422 assert!(s.matches(&["k8s/x.yaml".into(), ".kube-linter-prod.yaml".into()]));
423 assert!(!s.matches(&["k8s/x.yaml".into(), ".kube-lint.yaml".into()]));
424 }
425
426 /// Opt-in matches a BASENAME anywhere, which is what makes the coarse
427 /// answer right for a check that resolves an ancestor when it runs.
428 #[test]
429 fn opt_in_matches_a_nested_manifest() {
430 let cargo = Scope::new(&[".rs"], &["Cargo.toml"]);
431 assert!(cargo.matches(&["crates/a/src/lib.rs".into(), "crates/a/Cargo.toml".into()]));
432 }
433}