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`.
91 ///
92 /// The manifest's scope column fills this, and so does
93 /// `pre-commit-hadolint`, the first builtin to need it: a Dockerfile has
94 /// no extension to gate on.
95 pub names: &'static [&'static str],
96 /// Config paths that opt a repository in. Empty means always on.
97 pub opt_in: &'static [&'static str],
98 /// Git operations during which this check does not run.
99 ///
100 /// The other half of "when does this apply". `files` and `opt_in` say which
101 /// REPOSITORIES and which CHANGES; this says which repository STATES — a
102 /// question that used to be answered by one hard-coded `CHERRY_PICK_HEAD`
103 /// test in one dispatcher, with the other carrying a comment admitting it
104 /// had none because the shell version had none.
105 pub not_during: &'static [GitState],
106}
107
108impl Scope {
109 pub const ALWAYS: Scope = Scope {
110 files: &[],
111 names: &[],
112 opt_in: &[],
113 not_during: &[],
114 };
115
116 pub const fn files(files: &'static [&'static str]) -> Scope {
117 Scope {
118 files,
119 names: &[],
120 opt_in: &[],
121 not_during: &[],
122 }
123 }
124
125 /// Gated on exact basenames rather than extensions — what a `Dockerfile`
126 /// needs, having none.
127 pub const fn named(names: &'static [&'static str]) -> Scope {
128 Scope {
129 files: &[],
130 names,
131 opt_in: &[],
132 not_during: &[],
133 }
134 }
135
136 pub const fn new(files: &'static [&'static str], opt_in: &'static [&'static str]) -> Scope {
137 Scope {
138 files,
139 names: &[],
140 opt_in,
141 not_during: &[],
142 }
143 }
144
145 /// The same scope, silent during these operations.
146 pub const fn not_during(self, states: &'static [GitState]) -> Scope {
147 Scope {
148 files: self.files,
149 names: self.names,
150 opt_in: self.opt_in,
151 not_during: states,
152 }
153 }
154
155 /// No file gate at all — every change is in scope.
156 pub fn is_unscoped(&self) -> bool {
157 self.files.is_empty() && self.names.is_empty()
158 }
159
160 /// Does ONE path fall inside the file gate?
161 pub fn covers(&self, path: &str) -> bool {
162 self.files.iter().any(|ext| path.ends_with(ext)) || {
163 let base = path.rsplit('/').next().unwrap_or(path);
164 self.names.contains(&base)
165 }
166 }
167
168 /// Does this gate have work to do, given the files a PUSH changed?
169 ///
170 /// Distinct from [`matches`], and the distinction is the bug this was
171 /// written for. `matches` answers "would this check ever fire in a
172 /// repository containing these paths", so it also consults `opt_in` —
173 /// the marker that says a repository is a Rust one at all. Feeding a
174 /// push DIFF to that question conflates two things: whether the
175 /// repository is Rust (settled long before, when the dispatcher decided
176 /// this check runs here) and whether this push changed any Rust.
177 ///
178 /// `pre-push-cargo-test` opts in on `Cargo.toml`. A `.rs`-only push, in
179 /// a repository with a `Cargo.toml` sitting right there, therefore
180 /// answered NO to "is this a Rust repository" — because that one file
181 /// was not in the diff — and was judged to have nothing worth attesting.
182 /// Which is to say: the most ordinary Rust push there is.
183 ///
184 /// Opt-in is a fact about the repository. This asks only about the
185 /// change, which is what a caller holding a diff means.
186 pub fn touches(&self, paths: &[String]) -> bool {
187 self.is_unscoped() || paths.iter().any(|p| self.covers(p))
188 }
189
190 /// Did the gate see EVERY one of `paths`? All-match, where [`matches`]
191 /// is any-match: the caller asking this is deciding whether a commit-time
192 /// run COVERED a push, and under-approximating is the safe direction.
193 pub fn covers_all(&self, paths: &[String]) -> bool {
194 self.is_unscoped() || paths.iter().all(|p| self.covers(p))
195 }
196
197 /// Would this check ever fire, given the paths a repository contains?
198 ///
199 /// Deliberately coarse for checks that resolve an ancestor at run time —
200 /// `cargo-fmt` declares `Cargo.toml` meaning "somewhere here" while
201 /// enforcing "nearest above the staged file". The dispatcher asks the
202 /// precise question by running the check; this answers the dashboard's
203 /// question, "would it ever fire", where over-approximating is the safe
204 /// direction.
205 pub fn matches(&self, paths: &[String]) -> bool {
206 let by_ext = self.is_unscoped() || paths.iter().any(|path| self.covers(path));
207 let opted_in = self.opt_in.is_empty()
208 || paths.iter().any(|p| {
209 let name = p.rsplit('/').next().unwrap_or(p);
210 self.opt_in.iter().any(|c| {
211 // A trailing `*` is a prefix match: `.kube-linter*.yaml`.
212 match c.split_once('*') {
213 Some((pre, suf)) => name.starts_with(pre) && name.ends_with(suf),
214 None => name == *c,
215 }
216 })
217 });
218 by_ext && opted_in
219 }
220}
221
222/// What a check meant, as opposed to what it printed.
223///
224/// The fourth variant is the point. Fifteen sites used to warn and return 0,
225/// collapsing two different situations: "I ran and found something you should
226/// know" and "I could not run at all". `ruff config found but no ruff binary`
227/// was indistinguishable from ruff running clean — to the dispatcher, and to
228/// the dashboard. A repository where a check has silently never executed read
229/// as one where it passes.
230///
231/// That is the same invisibility `hook.skip` had before skipped checks were
232/// announced, and it took three PRs to notice there.
233///
234/// The check still prints its own message; this only classifies the result.
235///
236/// Deliberately NO `Default`. It used to be `Failed`, to fill the slot of a
237/// check whose thread died — a real rule, but `Default` means "the neutral
238/// value" to every reader and to every `#[derive(Default)]` that might later
239/// contain one. The rule is now written where it applies, in the runner.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub enum Outcome {
242 Passed,
243 /// Ran, found a problem. Whether that blocks is `Severity`, not this.
244 Failed,
245 /// Ran, found something worth saying, which does not block.
246 Warned,
247 /// Ran, found a problem, and REPAIRED it. The commit proceeds with the
248 /// repair staged, which is neither `Passed` (something happened, and the
249 /// author should know their files changed) nor `Failed`.
250 Fixed,
251 /// COULD NOT RUN — a tool is missing, or the opt-in config is absent.
252 Unavailable,
253}
254
255/// What a HOOK concluded — the only thing git actually reads.
256///
257/// Distinct from `Outcome`, which is what one CHECK concluded. Git has exactly
258/// two questions to ask a hook, so this has exactly two answers, and the `i32`
259/// that expresses them lives at the process boundary rather than being threaded
260/// through every hook, dispatcher and handler as it used to be.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum Verdict {
263 Proceed,
264 Block,
265}
266
267impl Verdict {
268 /// The exit code git reads. The ONLY place a hook result becomes a number.
269 pub fn exit_code(self) -> i32 {
270 match self {
271 Verdict::Proceed => 0,
272 Verdict::Block => 1,
273 }
274 }
275
276 pub fn blocking(blocked: bool) -> Verdict {
277 if blocked {
278 Verdict::Block
279 } else {
280 Verdict::Proceed
281 }
282 }
283}
284
285/// Whether a failing check stops the commit or merely reports.
286///
287/// Declared per check and overridable per repository with
288/// `git config amont.severity.<check> warn`. That is a better escape hatch
289/// than `hook.skip`, which is all-or-nothing and invisible enough that
290/// `hook.skip = e` disables all twenty checks: a downgrade keeps the signal and
291/// removes only the block.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum Severity {
294 Block,
295 Warn,
296}
297
298impl Severity {
299 /// The ONE mapping from configured text to severity.
300 ///
301 /// There were four: this key's reader, the manifest's severity column, the
302 /// dashboard's copy, and the dashboard's reverse mapping for `--json`. They
303 /// agreed, but nothing made them — and the dashboard's copy is its
304 /// prediction of what the dispatcher will do, which is the one thing it must
305 /// never get wrong.
306 ///
307 /// `None` for anything else, deliberately: git validates nothing here, so an
308 /// unrecognised value must fall back to the declared severity rather than
309 /// silently disable a check.
310 pub fn parse(value: &str) -> Option<Severity> {
311 match value {
312 "warn" => Some(Severity::Warn),
313 "block" => Some(Severity::Block),
314 _ => None,
315 }
316 }
317
318 /// How it is written in config and in `--json`.
319 pub fn as_str(self) -> &'static str {
320 match self {
321 Severity::Block => "block",
322 Severity::Warn => "warn",
323 }
324 }
325}
326
327/// Whether a check can rewrite the files it inspects.
328///
329/// Off by default and per check, never global: a hook that edits your files
330/// without being asked is a larger surprise than one that complains.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum Fix {
333 /// Reports only. Every check, until somebody declares otherwise.
334 None,
335 /// Runs a command that rewrites files, and stages what it changed.
336 ///
337 /// Only reachable from a `Stage::PreCommit` declaration. A pre-push hook
338 /// must not modify the worktree or index: the pushed commit would then
339 /// differ from the tree the developer is looking at.
340 Rewrite,
341}
342
343impl Fix {
344 /// How it is written in `--json`. No `parse()`: nothing reads a `Fix`
345 /// back out of text — the manifest's `fix` marker has its own, unrelated
346 /// parsing path in `manifest.rs`.
347 pub fn as_str(self) -> &'static str {
348 match self {
349 Fix::None => "none",
350 Fix::Rewrite => "rewrite",
351 }
352 }
353}
354
355/// One check, whether compiled in or declared by the repository.
356///
357/// `Sync` because `pre-commit` hands every check to its own thread. Both
358/// implementations satisfy it for free, and requiring it here is what lets the
359/// dispatcher hold `&'static dyn Check` without caring which kind it has.
360pub trait Check: Sync {
361 fn name(&self) -> &str;
362 fn stage(&self) -> Stage;
363 fn scope(&self) -> Scope;
364 /// The name a commit-time declaration must use to pair with this gate.
365 ///
366 /// Deliberately the SHORT name, and deliberately not the id: pairing is
367 /// cross-stage by definition — `test` declared at `pre-commit` pairs
368 /// with `test` at `pre-push` — so the id, which encodes the stage, is
369 /// the one key that cannot work. `pre-push-cargo-test` would be compared
370 /// against a `GateDecl.script` of `cargo-test` and never match, and the
371 /// failure would be silence rather than an error: the gate simply never
372 /// pairs and nobody is told why.
373 ///
374 /// This is the single place where comparing short names is correct,
375 /// which is why it is a named method rather than a `short_name()` call
376 /// at the comparison site — that function's own documentation says never
377 /// to compare against it, and it is right everywhere else.
378 fn pairing_name(&self) -> &str {
379 crate::short_name(self.name())
380 }
381 fn severity(&self) -> Severity;
382 /// Whether this check can repair what it finds. `None` for almost all.
383 fn fix(&self) -> Fix {
384 Fix::None
385 }
386 /// How far the check reaches when `amont.conventions` is `declared` —
387 /// see [`Reach`]. Externals default to `Convention`, which costs them
388 /// nothing: a declared check only exists where an `amont.conf` does,
389 /// and that is exactly the declaration the mode asks for.
390 fn reach(&self) -> Reach {
391 Reach::Convention
392 }
393 fn run(&self, ctx: &Ctx) -> Outcome;
394}
395
396/// How far a check reaches into repositories that never asked for it.
397///
398/// With `git config --global amont.conventions declared`, hooks installed by
399/// a standing grant (`init.templateDir`) split in two: `Safety` checks run in
400/// EVERY repository, because their findings are mistakes in any codebase — a
401/// conflict marker, a leaked credential, a hundred-megabyte blob, a
402/// `debugger;` left in the diff. `Convention` checks run only where the
403/// repository has committed an `amont.conf` — they are one team's house
404/// rules (commit shapes, branch names, lint severities, test gates), and a
405/// clone of somebody else's project did not agree to them.
406///
407/// The default mode is `everywhere`, where this distinction is inert.
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409pub enum Reach {
410 /// A mistake anywhere: runs regardless of declaration.
411 Safety,
412 /// A house rule: runs only where the repository declares amont.
413 Convention,
414}
415
416/// A check compiled into the binary.
417pub struct Builtin {
418 pub name: &'static str,
419 pub stage: Stage,
420 pub scope: Scope,
421 pub severity: Severity,
422 pub run: fn(&Ctx) -> Outcome,
423 /// Almost always `Fix::None`; see `CHECKS`.
424 pub fix: Fix,
425 /// Almost always `Convention`; the exceptions are pinned in the registry.
426 pub reach: Reach,
427}
428
429impl Check for Builtin {
430 fn name(&self) -> &str {
431 self.name
432 }
433 fn stage(&self) -> Stage {
434 self.stage
435 }
436 fn scope(&self) -> Scope {
437 self.scope
438 }
439 fn severity(&self) -> Severity {
440 self.severity
441 }
442 fn fix(&self) -> Fix {
443 self.fix
444 }
445 fn reach(&self) -> Reach {
446 self.reach
447 }
448 fn run(&self, ctx: &Ctx) -> Outcome {
449 (self.run)(ctx)
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 /// Round-trips, and refuses everything else. A `Some` for an unknown value
458 /// would turn a typo into a silent disable.
459 #[test]
460 fn severity_parses_exactly_the_two_words_it_documents() {
461 for s in [Severity::Block, Severity::Warn] {
462 assert_eq!(Severity::parse(s.as_str()), Some(s));
463 }
464 for bad in ["", "Warn", "WARN", "advisory", "true", "1", " warn"] {
465 assert_eq!(Severity::parse(bad), None, "{bad:?} must not parse");
466 }
467 }
468
469 #[test]
470 fn fix_says_how_it_is_written_in_json() {
471 assert_eq!(Fix::None.as_str(), "none");
472 assert_eq!(Fix::Rewrite.as_str(), "rewrite");
473 }
474
475 #[test]
476 fn always_matches_anything() {
477 assert!(Scope::ALWAYS.matches(&[]));
478 assert!(Scope::ALWAYS.matches(&["README.md".into()]));
479 }
480
481 #[test]
482 fn extensions_gate_on_the_file_type() {
483 let s = Scope::files(&[".rs"]);
484 assert!(s.matches(&["src/main.rs".into()]));
485 assert!(!s.matches(&["README.md".into()]));
486 }
487
488 /// The case the enum could not express: BOTH conditions must hold.
489 #[test]
490 fn files_and_opt_in_are_a_conjunction() {
491 let ruff = Scope::new(&[".py"], &["ruff.toml", "pyproject.toml"]);
492 assert!(
493 !ruff.matches(&["a.py".into()]),
494 "python alone is not enough — the repo must opt in"
495 );
496 assert!(
497 !ruff.matches(&["pyproject.toml".into()]),
498 "and a config alone is not enough without python"
499 );
500 assert!(ruff.matches(&["a.py".into(), "pyproject.toml".into()]));
501 }
502
503 /// `.kube-linter*.yaml` is a real config name in this repo's own hooks.
504 #[test]
505 fn a_trailing_star_is_a_prefix_match() {
506 let s = Scope::new(&[".yaml"], &[".kube-linter*.yaml"]);
507 assert!(s.matches(&["k8s/x.yaml".into(), ".kube-linter-prod.yaml".into()]));
508 assert!(!s.matches(&["k8s/x.yaml".into(), ".kube-lint.yaml".into()]));
509 }
510
511 /// Opt-in matches a BASENAME anywhere, which is what makes the coarse
512 /// answer right for a check that resolves an ancestor when it runs.
513 #[test]
514 fn opt_in_matches_a_nested_manifest() {
515 let cargo = Scope::new(&[".rs"], &["Cargo.toml"]);
516 assert!(cargo.matches(&["crates/a/src/lib.rs".into(), "crates/a/Cargo.toml".into()]));
517 }
518}