Skip to main content

amont_runtime/
lib.rs

1//! The git-templates hook logic: registry, dispatchers and every check.
2//!
3//! This is a library so that more than one binary can hold the same truth about
4//! what a hook IS. `amont` (the commit path) executes the checks;
5//! `amont-fleet` reports on how they are installed across the fleet. Before
6//! the split there was no lib target at all, which is why `cargo test --lib`
7//! failed outright.
8//!
9//! **This crate must never gain an external dependency.** The hook binary
10//! depends on it, so anything added here reaches every commit transitively —
11//! and the entire Rust migration existed to remove exactly that kind of
12//! requirement. ratatui and friends belong in `amont-fleet`.
13//!
14//! Hooks are invoked through a thin `sh` shim at each hook path, which passes
15//! the hooks directory it lives in:
16//!
17//! ```text
18//! amont --hooks-dir <dir> pre-commit [args…]
19//! ```
20
21pub mod agents_md;
22pub mod check;
23pub mod commit_style;
24pub mod config;
25pub mod dispatch;
26pub mod git;
27pub mod hookfile;
28pub mod hooks;
29pub mod install;
30pub mod json;
31pub mod manifest;
32pub mod pushed_tree;
33pub mod pushrefs;
34pub mod registry;
35pub mod setup;
36pub mod staged_only;
37pub mod trust;
38pub mod ui;
39pub mod vocabulary;
40
41use std::path::Path;
42use std::process::{Command, Stdio};
43
44/// `git config --get-all hook.skip`, or empty when unset/unavailable.
45/// The two triggers a check can be attached to, as they are spelled in config.
46///
47/// Deliberately the same strings as `Stage::as_str`, and
48/// `every_id_agrees_with_its_declared_stage` keeps them that way.
49pub const TRIGGERS: [&str; 2] = ["pre-commit", "pre-push"];
50
51/// How specifically a configured value names a check.
52///
53/// Ordered, so that when several keys match one check the most specific wins —
54/// which only matters for `amont.severity`, since a skip is a boolean.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
56pub enum Match {
57    /// `pre-commit` — every check on that trigger.
58    Trigger,
59    /// `clippy` — that check, whichever trigger it is on.
60    ShortName,
61    /// `pre-commit-clippy` — this check and no other.
62    FullId,
63}
64
65/// A check's id without its trigger — `pre-commit-clippy` → `clippy`.
66///
67/// For DISPLAY only, wherever the trigger is already established by a heading
68/// or a neighbouring column. Under a `pre-commit` heading, printing
69/// `pre-commit-clippy` on every row spends eleven columns restating what the
70/// heading said. Never write this to config or compare against it: two checks
71/// can share a short name, and telling them apart is what the id is for.
72pub fn short_name(check: &str) -> &str {
73    for trigger in TRIGGERS {
74        if let Some(short) = check
75            .strip_prefix(trigger)
76            .and_then(|rest| rest.strip_prefix('-'))
77        {
78            return short;
79        }
80    }
81    check
82}
83
84/// Does `pattern`, as written in `hook.skip` or `amont.severity.<pattern>`,
85/// name `check`?
86///
87/// A check's id is `<trigger>-<name>`, and exactly three things name it:
88///
89/// | written | means |
90/// |---|---|
91/// | `pre-commit-clippy` | that one check |
92/// | `pre-commit`        | every check on that trigger |
93/// | `clippy`            | that check, on any trigger |
94///
95/// Three exact comparisons. **No substring.** The previous rule was
96/// `check.contains(skip)`, which made `hook.skip = clippy` work by accident of
97/// reach — and `hook.skip = e` disable all twenty checks by the same accident,
98/// and `lint-js` silently also suppress `lint-json-yaml`. Naming the three
99/// things a user actually means keeps every useful case and removes every
100/// sharp edge, including the one the old doc comment called "not a bug".
101///
102/// This reads the trigger out of the ID, which is not the same as deriving a
103/// check's stage: `Stage` remains a declared field and is what the dispatcher
104/// obeys. Here we are parsing an identifier a human typed.
105///
106/// Defined ONCE because four callers need it — the dispatcher decides what
107/// runs, the severity resolver decides what blocks, the fleet view reports
108/// where a check applies, and the skip resolver computes reach. A
109/// reimplementation that disagreed would have the dashboard claim a check is
110/// active while the dispatcher skips it.
111pub fn names_check(check: &str, pattern: &str) -> Option<Match> {
112    if check == pattern {
113        return Some(Match::FullId);
114    }
115    for trigger in TRIGGERS {
116        let Some(short) = check
117            .strip_prefix(trigger)
118            .and_then(|rest| rest.strip_prefix('-'))
119        else {
120            continue;
121        };
122        // An id carries one trigger, so the first that matches is the answer.
123        if pattern == trigger {
124            return Some(Match::Trigger);
125        }
126        if pattern == short {
127            return Some(Match::ShortName);
128        }
129        return None;
130    }
131    None
132}
133
134/// Does `skip`, as configured in `hook.skip`, suppress `check`?
135pub fn skip_suppresses(check: &str, skip: &str) -> bool {
136    names_check(check, skip).is_some()
137}
138
139/// One check, as reported by `amont list`.
140///
141/// Deliberately flat — this is what gets rendered as text or serialised to
142/// JSON, and a reader (human or agent) parsing the latter should not have to
143/// chase nested objects for a yes/no question.
144#[derive(Debug, Clone)]
145pub struct CheckListing {
146    /// `<trigger>-<name>` — what `hook.skip` and `amont.severity.<key>`
147    /// resolve against.
148    pub id: String,
149    pub short_name: String,
150    pub stage: check::Stage,
151    pub source: Source,
152    /// What the check (or manifest line) declared.
153    pub declared_severity: check::Severity,
154    /// What `registry::Overrides::of` would actually apply — NOT the same as
155    /// `declared_severity` once `amont.severity.*` is configured. This is
156    /// the one thing the old text-only `list_checks` never reported, and the
157    /// exact "declared vs. effective" gap that caused a real bug in the
158    /// fleet's own severity column (see `amont-fleet/src/severities.rs`).
159    pub effective_severity: check::Severity,
160    pub severity_overridden: bool,
161    pub fix: check::Fix,
162    pub status: Status,
163    /// Empty when `status == Status::Runs`; the same prose the text output
164    /// always showed for the other three states.
165    pub reason: String,
166    pub scope_files: Vec<String>,
167    pub scope_opt_in: Vec<String>,
168    /// `Some` only for a declared, `Runnable` external — a builtin has no
169    /// command to show, and an `Unusable` external never got far enough to
170    /// have one.
171    pub command: Option<String>,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum Source {
176    Builtin,
177    Declared,
178}
179
180/// The four states the text output's glyphs already named. Not called
181/// `Outcome` — that type means what a check concluded when it RAN; this means
182/// whether it would run at all, the same question `Scope::matches` answers.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum Status {
185    Runs,
186    Inert,
187    Skipped,
188    Unusable,
189}
190
191pub struct ListOptions {
192    pub json: bool,
193    pub stage: Option<check::Stage>,
194    pub pushed: bool,
195}
196
197/// Every check that would be considered for `stage_filter` (or both stages,
198/// when `None`), evaluated against `paths`.
199///
200/// Reads `hook.skip` and `amont.severity.*` from the current repo's git
201/// config, same as the original `list_checks` did — this is why it is not
202/// unit-tested in isolation; see `hooks/pull_rebase.rs`'s own split between
203/// pure helpers (unit-tested) and config-dependent behaviour (integration
204/// tested) for the precedent.
205pub fn gather_checks(stage_filter: Option<check::Stage>, paths: &[String]) -> Vec<CheckListing> {
206    use crate::check::Stage;
207    let stages: Vec<Stage> = match stage_filter {
208        Some(s) => vec![s],
209        None => vec![Stage::PreCommit, Stage::PrePush],
210    };
211    let skips = configured_skips();
212    let overrides = registry::Overrides::read();
213    let externals_by_id: std::collections::BTreeMap<&str, &manifest::External> =
214        manifest::externals()
215            .iter()
216            .map(|e| (e.id.as_str(), e))
217            .collect();
218
219    let mut out = Vec::new();
220    for stage in stages {
221        // Externals are listed here too, and marked, because the question
222        // this command answers — "would this run here?" — is asked most
223        // often about the check somebody just added to `amont.conf`.
224        for check in registry::all_stage_checks(stage) {
225            let name = check.name();
226            let external = externals_by_id.get(name).copied();
227            let skipped = skips.iter().any(|s| skip_suppresses(name, s));
228            let applies = check.scope().matches(paths);
229
230            let unusable_why = external.and_then(|e| match &e.kind {
231                manifest::Kind::Unusable { why } => Some(why.as_str()),
232                manifest::Kind::Runnable { .. } => None,
233            });
234            // Four states: a check that is correctly silent must never look
235            // like one that is disabled, and neither must look like one
236            // whose declaration could not be read.
237            let (status, reason) = if let Some(w) = unusable_why {
238                (Status::Unusable, format!("{} {w}", manifest::MANIFEST))
239            } else if skipped {
240                (Status::Skipped, "skipped via hook.skip".to_string())
241            } else if applies {
242                (Status::Runs, String::new())
243            } else {
244                (
245                    Status::Inert,
246                    format!("inert here — needs {}", describe(check.scope())),
247                )
248            };
249
250            let command = external.and_then(|e| match &e.kind {
251                manifest::Kind::Runnable { program, args, .. } => Some(
252                    std::iter::once(program.as_str())
253                        .chain(args.iter().map(String::as_str))
254                        .collect::<Vec<_>>()
255                        .join(" "),
256                ),
257                manifest::Kind::Unusable { .. } => None,
258            });
259
260            let declared_severity = check.severity();
261            let effective_severity = overrides.of(check);
262            out.push(CheckListing {
263                id: name.to_string(),
264                short_name: short_name(name).to_string(),
265                stage,
266                source: if external.is_some() {
267                    Source::Declared
268                } else {
269                    Source::Builtin
270                },
271                declared_severity,
272                effective_severity,
273                severity_overridden: declared_severity != effective_severity,
274                fix: check.fix(),
275                status,
276                reason,
277                scope_files: check.scope().files.iter().map(|s| s.to_string()).collect(),
278                scope_opt_in: check.scope().opt_in.iter().map(|s| s.to_string()).collect(),
279                command,
280            });
281        }
282    }
283    out
284}
285
286/// BYTE-IDENTICAL to what `list_checks` printed before it grew `--json`.
287/// `listings` is already stage-grouped (`gather_checks` iterates stage by
288/// stage), so a heading prints exactly once per stage encountered, in the
289/// same order.
290pub fn print_text(listings: &[CheckListing]) {
291    let mut current: Option<check::Stage> = None;
292    for l in listings {
293        if current != Some(l.stage) {
294            println!("{}", ui::highlight(l.stage.as_str()));
295            current = Some(l.stage);
296        }
297        let glyph = match l.status {
298            Status::Unusable => '✗',
299            Status::Skipped => '⊘',
300            Status::Runs => '●',
301            Status::Inert => '○',
302        };
303        // The SHORT name: this loop is already inside a `pre-commit` /
304        // `pre-push` heading, so printing the trigger on all twenty rows
305        // restates the heading twenty times and pushes the reason — the part
306        // that differs per row — eleven columns to the right.
307        //
308        // Where a check CAME FROM belongs next to its name, not appended
309        // after a reason that is often empty. A reader scanning this list
310        // wants to know which of these their repository added.
311        // A declared check's name and its reason both come from the
312        // repository's manifest, and `amont list` is read at least as often
313        // as the trust prompt. Sanitised before padding, so the column width is
314        // computed on what is printed — see `ui::sanitize`.
315        let short_name = ui::sanitize(&l.short_name);
316        let label = match l.source {
317            Source::Declared => format!("{short_name} (declared)"),
318            Source::Builtin => short_name,
319        };
320        println!("  {glyph} {label:<26} {}", ui::sanitize(&l.reason));
321    }
322    println!();
323    println!("  ● runs here   ○ inert   ⊘ skipped via hook.skip   ✗ declaration unusable");
324}
325
326/// What `commit-msg` will enforce here, and where each answer came from.
327///
328/// Printed **always**, not only when something has been configured. The
329/// defaults are the divisive part — a gitmoji in every subject, a 50-character
330/// description — and somebody who wants them changed has no reason to guess
331/// that four keys exist. `amont list` is where they are already looking, so
332/// it is where the dial belongs.
333///
334/// The source column appears only for a value somebody set: repeating
335/// "default" on four rows spends a column restating the line underneath.
336pub fn print_commit_style(style: &commit_style::Style, rows: &[commit_style::Setting]) {
337    println!();
338    println!("{}", ui::highlight("commit style"));
339    for r in rows {
340        let origin = if r.set_here {
341            format!("{} ({})", r.key, r.scope.as_str())
342        } else {
343            String::new()
344        };
345        println!("  {:<18} {:<10} {origin}", r.label, r.value);
346    }
347    println!();
348    for w in style.warnings() {
349        println!("  {} {w}", ui::warning_sign().trim());
350    }
351    println!("  `amont setup` to change any of these");
352}
353
354/// The commit-style block as JSON: the effective value, the shipped default,
355/// whether they differ and where the answer came from — the same
356/// declared-vs-effective shape `CheckListing` uses for severity.
357fn commit_style_json(style: &commit_style::Style, rows: &[commit_style::Setting]) -> String {
358    let setting = |r: &commit_style::Setting, value: String, default: String| {
359        json::object(&[
360            format!("\"value\":{value}"),
361            format!("\"default\":{default}"),
362            json::bool_field("overridden", r.overridden),
363            json::bool_field("set_here", r.set_here),
364            json::string_field("source", r.scope.as_str()),
365            json::string_field("key", r.key),
366        ])
367    };
368    let d = commit_style::Style::default();
369    // `rows` is built in this order by `commit_style::describe`, and the
370    // numbers are emitted as numbers so a reader can compare them.
371    let quoted = |s: &str| format!("\"{}\"", json::escape(s));
372    let fields: Vec<String> = rows
373        .iter()
374        .map(|r| {
375            let (value, default) = match r.key {
376                commit_style::KEY_GITMOJI => {
377                    (quoted(style.gitmoji.as_str()), quoted(d.gitmoji.as_str()))
378                }
379                commit_style::KEY_SUBJECT_MAX => {
380                    (style.subject_max.to_string(), d.subject_max.to_string())
381                }
382                commit_style::KEY_DESCRIPTION_MAX => (
383                    style.description_max.to_string(),
384                    d.description_max.to_string(),
385                ),
386                _ => (style.body_wrap.to_string(), d.body_wrap.to_string()),
387            };
388            let name = r.key.rsplit('.').next().unwrap_or(r.key);
389            format!("\"{}\":{}", json::escape(name), setting(r, value, default))
390        })
391        .collect();
392
393    let warnings: Vec<String> = style.warnings();
394    let mut all = fields;
395    all.push(json::string_array_field("warnings", &warnings));
396    json::object(&all)
397}
398
399/// `{"stage_filter": ..., "pushed": ..., "checks": [...]}` — an object, not a
400/// bare array, so a field can be added later without changing the top-level
401/// shape.
402pub fn print_json(stage_filter: Option<check::Stage>, pushed: bool, listings: &[CheckListing]) {
403    let checks: Vec<String> = listings
404        .iter()
405        .map(|l| {
406            json::object(&[
407                json::string_field("id", &l.id),
408                json::string_field("short_name", &l.short_name),
409                json::string_field("stage", l.stage.as_str()),
410                json::string_field(
411                    "source",
412                    match l.source {
413                        Source::Builtin => "builtin",
414                        Source::Declared => "declared",
415                    },
416                ),
417                json::string_field("declared_severity", l.declared_severity.as_str()),
418                json::string_field("effective_severity", l.effective_severity.as_str()),
419                json::bool_field("severity_overridden", l.severity_overridden),
420                json::string_field("fix", l.fix.as_str()),
421                json::string_field(
422                    "status",
423                    match l.status {
424                        Status::Runs => "runs",
425                        Status::Inert => "inert",
426                        Status::Skipped => "skipped",
427                        Status::Unusable => "unusable",
428                    },
429                ),
430                json::string_field("reason", &l.reason),
431                json::string_array_field("scope_files", &l.scope_files),
432                json::string_array_field("scope_opt_in", &l.scope_opt_in),
433                json::opt_string_field("command", l.command.as_deref()),
434            ])
435        })
436        .collect();
437
438    let (style, rows) = commit_style::describe();
439    println!(
440        "{}",
441        json::object(&[
442            json::opt_string_field("stage_filter", stage_filter.map(check::Stage::as_str)),
443            json::bool_field("pushed", pushed),
444            format!("\"checks\":{}", json::array(&checks)),
445            format!("\"commit_style\":{}", commit_style_json(&style, &rows)),
446            format!("\"branch_style\":{}", branch_style_json()),
447        ])
448    );
449}
450
451/// The branch contract, in the same document agents are told to consult — so
452/// the pattern is knowable BEFORE a branch is created rather than discovered
453/// at push time. Rendered from `vocabulary::BRANCH_PREFIXES`, the same table
454/// `pre-push-branch-pattern` enforces: there is no second copy to drift.
455fn branch_style_json() -> String {
456    let prefixes: Vec<String> = vocabulary::BRANCH_PREFIXES
457        .iter()
458        .map(|p| p.name.to_string())
459        .collect();
460    json::object(&[
461        json::string_field("shape", "<prefix>/<name>"),
462        json::string_field("pattern", &vocabulary::branch_contract()),
463        json::string_array_field("prefixes", &prefixes),
464    ])
465}
466
467/// `git ls-files` — every check's default scope evaluation, unchanged from
468/// what `list_checks` always did.
469///
470/// Through `git::stdout_paths`, i.e. with `-z`. A raw `ls-files` QUOTES any
471/// path holding an unusual byte: `é.json` comes back as the nine-byte literal
472/// `"\303\251.json"`, which ends with a quote rather than an extension, so
473/// `Scope::matches` reports a check as irrelevant to a repository it plainly
474/// covers. Cosmetic here (this only decides what `list` prints) and not
475/// cosmetic in `dispatch::enter_all_files_mode`, which is the same bug — so
476/// both ask the same way.
477fn tracked_paths() -> Vec<String> {
478    git::stdout_paths(&["ls-files"]).unwrap_or_default()
479}
480
481/// The pushed-range file list, computed standalone rather than from a real
482/// pre-push invocation's stdin.
483///
484/// Reuses `pushrefs::changed_files`, which already handles zero-oid deletes,
485/// merge commits and the `--stdin` trailing-newline edge case — this only
486/// SYNTHESISES the one `PushRef` a standalone invocation has no other way to
487/// obtain.
488fn pushed_paths() -> Result<Vec<String>, String> {
489    let synthetic = pushrefs::synthetic_from_upstream()?;
490    Ok(pushrefs::changed_files(&[synthetic]))
491}
492
493/// `amont list`: what would run here, and why — as prose, or as
494/// `--json` for a reader that wants to parse it.
495pub fn list_checks(opts: ListOptions) -> i32 {
496    let paths = if opts.pushed {
497        match pushed_paths() {
498            Ok(p) => p,
499            Err(msg) => {
500                if opts.json {
501                    println!("{}", json::object(&[json::string_field("error", &msg)]));
502                } else {
503                    eprintln!("amont: {msg}");
504                }
505                return 2;
506            }
507        }
508    } else {
509        tracked_paths()
510    };
511    let listings = gather_checks(opts.stage, &paths);
512    if opts.json {
513        print_json(opts.stage, opts.pushed, &listings);
514    } else {
515        print_text(&listings);
516        // Not filtered by `--stage`: commit style belongs to no stage, and
517        // suppressing it for `--stage pre-push` would only hide it from the
518        // reader who narrowed their question.
519        let (style, rows) = commit_style::describe();
520        print_commit_style(&style, &rows);
521    }
522    0
523}
524
525fn describe(s: crate::check::Scope) -> String {
526    let files = if s.files.is_empty() {
527        String::new()
528    } else {
529        s.files.join(" ")
530    };
531    let opt = s.opt_in.join(" | ");
532    match (files.is_empty(), opt.is_empty()) {
533        (false, false) => format!("{files} + {opt}"),
534        (false, true) => files,
535        (true, false) => opt,
536        (true, true) => "nothing".into(),
537    }
538}
539
540pub fn configured_skips() -> Vec<String> {
541    let Ok(out) = Command::new("git")
542        .args(["config", "--get-all", "hook.skip"])
543        .stderr(Stdio::null())
544        .output()
545    else {
546        return Vec::new();
547    };
548    String::from_utf8_lossy(&out.stdout)
549        .lines()
550        .map(str::trim)
551        .filter(|l| !l.is_empty())
552        .map(str::to_owned)
553        .collect()
554}
555
556/// Which git operations are part-way through, from the markers in `$GIT_DIR`.
557///
558/// Asks git directly rather than deriving a path from `hooks_dir`. That used
559/// to be `hooks_dir.parent()` — correct for the main worktree, where hooks
560/// live in `.git/hooks` and `.git` IS `$GIT_DIR`, but wrong for a LINKED
561/// worktree: hooks dispatch from the COMMON directory's `hooks/`, shared
562/// across every worktree, while `MERGE_HEAD`/`CHERRY_PICK_HEAD`/etc. live in
563/// each worktree's own PRIVATE gitdir under `.git/worktrees/<name>`.
564/// Conflating the two silently disabled this guard for every linked
565/// worktree — the same mistake `staged_only`'s store path made, which lost
566/// unstaged work outright; see its module doc.
567pub fn git_states_in_progress() -> Vec<crate::check::GitState> {
568    let Some(git_dir) = crate::git::stdout(&["rev-parse", "--git-dir"]) else {
569        return Vec::new();
570    };
571    let git_dir = Path::new(&git_dir);
572    crate::check::GitState::ALL
573        .into_iter()
574        .filter(|state| {
575            state
576                .markers()
577                .iter()
578                .any(|marker| git_dir.join(marker).exists())
579        })
580        .collect()
581}
582
583/// True during a cherry-pick, where the zsh `pre-commit` exited 0 immediately.
584/// Superseded internally by [`git_states_in_progress`]; kept for whatever
585/// still calls it directly. See that function for why this asks git rather
586/// than deriving a path from `hooks_dir`.
587pub fn cherry_pick_in_progress() -> bool {
588    crate::git::stdout(&["rev-parse", "--git-dir"])
589        .map(|d| Path::new(&d).join("CHERRY_PICK_HEAD").exists())
590        .unwrap_or(false)
591}
592
593#[cfg(test)]
594mod naming {
595    use super::*;
596
597    /// The three things a user can write, and what each reaches.
598    #[test]
599    fn three_ways_to_name_a_check() {
600        assert_eq!(
601            names_check("pre-commit-clippy", "pre-commit-clippy"),
602            Some(Match::FullId)
603        );
604        assert_eq!(
605            names_check("pre-commit-clippy", "pre-commit"),
606            Some(Match::Trigger)
607        );
608        assert_eq!(
609            names_check("pre-commit-clippy", "clippy"),
610            Some(Match::ShortName)
611        );
612    }
613
614    /// The hazards the old substring rule created, all gone by construction.
615    #[test]
616    fn nothing_matches_by_accident() {
617        // `hook.skip = e` disabled all twenty checks. It now reaches nothing.
618        for pattern in ["e", "t", "i", ""] {
619            assert_eq!(
620                names_check("pre-commit-clippy", pattern),
621                None,
622                "{pattern:?}"
623            );
624        }
625        // A partial word is not a name.
626        assert_eq!(names_check("pre-commit-clippy", "clip"), None);
627        assert_eq!(names_check("pre-commit-clippy", "lint"), None);
628        // The wrong trigger reaches nothing.
629        assert_eq!(names_check("pre-commit-clippy", "pre-push"), None);
630        // And the empty string names nothing, rather than everything — git
631        // stores `hook.skip` with no value as exactly this.
632        assert_eq!(names_check("pre-commit-clippy", ""), None);
633    }
634
635    /// The coupling `docs/hook-skip-management.md` warned about: `lint-js` is a
636    /// substring of `lint-json-yaml`, so skipping one used to skip both.
637    #[test]
638    fn a_short_name_does_not_reach_a_longer_one() {
639        assert!(names_check("pre-commit-lint-json-yaml", "lint-js").is_none());
640        assert_eq!(
641            names_check("pre-commit-lint-js", "lint-js"),
642            Some(Match::ShortName)
643        );
644        assert_eq!(
645            names_check("pre-commit-lint-json-yaml", "lint-json-yaml"),
646            Some(Match::ShortName)
647        );
648    }
649
650    /// The one value that exists in the real fleet.
651    #[test]
652    fn the_fleets_only_skip_still_resolves() {
653        assert_eq!(
654            names_check("pre-push-run-tests-js", "run-tests-js"),
655            Some(Match::ShortName)
656        );
657    }
658
659    /// A trigger reaches every check on it and none on the other.
660    #[test]
661    fn a_trigger_reaches_its_own_stage_only() {
662        let pre_commit = registry::CHECKS
663            .iter()
664            .filter(|c| names_check(c.name, "pre-commit").is_some())
665            .count();
666        let pre_push = registry::CHECKS
667            .iter()
668            .filter(|c| names_check(c.name, "pre-push").is_some())
669            .count();
670        assert_eq!(pre_commit + pre_push, registry::CHECKS.len());
671        assert!(pre_commit > 0 && pre_push > 0);
672    }
673
674    /// Specificity ordering, which decides severity when several keys apply.
675    #[test]
676    fn a_full_id_outranks_a_short_name_outranks_a_trigger() {
677        assert!(Match::FullId > Match::ShortName);
678        assert!(Match::ShortName > Match::Trigger);
679    }
680
681    /// The resolver reads the trigger out of the ID. That is only sound while
682    /// every ID agrees with the stage its check actually declares — so it is
683    /// checked rather than assumed.
684    #[test]
685    fn every_id_agrees_with_its_declared_stage() {
686        for check in registry::CHECKS {
687            assert_eq!(
688                names_check(check.name, check.stage.as_str()),
689                Some(Match::Trigger),
690                "{} declares {:?} but its id says otherwise",
691                check.name,
692                check.stage
693            );
694        }
695    }
696}