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