Skip to main content

amont_runtime/
manifest.rs

1//! `amont.conf` — checks a repository declares for itself.
2//!
3//! A third party cannot add a Rust module without rebuilding this binary, so
4//! extension means declared commands.
5//!
6//! The manifest is **committed at the repository root**, and that is the point.
7//! `.git/hooks` is not committed, so under the old filename-prefix mechanism a
8//! team could never actually share a custom hook — every member had to install
9//! it by hand, and nothing told them when it changed. That flaw mattered more
10//! than the lexicographic ordering usually cited against prefixes.
11//!
12//! ```text
13//! # stage       name        scope   severity  command
14//! pre-commit    lint-shell  *.sh    block     scripts/lint-shell.sh
15//! pre-push      smoke       *       warn      make smoke
16//! ```
17//!
18//! Whitespace-delimited, in file order. TOML would be nicer to write and costs a
19//! dependency tree that would then run on every commit in ninety-six
20//! repositories; for four fields and a command, the twenty lines of parsing win.
21//! See `scripts/check-no-deps.sh` for why that trade is the default here.
22//!
23//! ## No shell
24//!
25//! The command is split on whitespace and executed directly. There is no shell,
26//! so no pipes, no redirection, no globbing and no quoting. Two reasons, and the
27//! second is the one that decided it: Windows has no `sh`, and every emulation
28//! of one this project has tried has been a source of bugs; and a manifest line
29//! that silently gained shell semantics would be a much larger thing to have
30//! introduced than it looks. A pipeline belongs in a script the line invokes.
31//!
32//! ## A line that cannot be understood is not skipped
33//!
34//! A malformed line means a check the repository asked for is not running, which
35//! is precisely the "looks verified, enforced nothing" failure `Outcome` exists
36//! to name. So a broken line still produces a check — one that runs to
37//! `Unavailable` and says why. It appears in the dispatcher's "could not run"
38//! roll-up like any other gap, rather than needing a mechanism of its own.
39
40use std::path::Path;
41use std::process::{Command, Stdio};
42
43use crate::check::{Check, Fix, Outcome, Scope, Severity, Stage};
44use crate::hooks::common::Restaged;
45use crate::registry::{Ctx, CHECKS, ENTRYPOINTS};
46
47pub const MANIFEST: &str = "amont.conf";
48
49/// Why a line could not be used.
50///
51/// A type rather than a `String`: the prose belongs in `Display`, and a caller
52/// that wants to ask "was this a duplicate?" should not have to grep for the
53/// word. The tests used to assert on substrings, which coupled them to wording
54/// and would have kept passing if the wording stayed while the meaning changed.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ParseError {
57    MissingFields,
58    MissingName,
59    /// Names a check compiled into the binary.
60    NameTaken(String),
61    /// The name is a trigger, or carries one as a prefix.
62    ///
63    /// `pre-commit  pre-commit-clippy  …` would declare a check whose SHORT
64    /// name is another check's full id, so `hook.skip pre-commit-clippy` would
65    /// mean two things at once. The stage column supplies the trigger; writing
66    /// it again in the name is the one way to make an id ambiguous.
67    TriggerInName(String),
68    /// A second USABLE line claiming a name already claimed ON THE SAME
69    /// TRIGGER. The same name on both triggers is two checks, not a clash.
70    Duplicate(String),
71    BadStage(String),
72    BadScope(String),
73    BadSeverity(String),
74    /// A `tool` line with the wrong shape.
75    BadTool,
76    /// A `severity`/`skip`/`set` line with the wrong shape; carries the usage.
77    BadPolicyLine(&'static str),
78    /// A `set` line naming a key policy may not reach.
79    UnsettableKey(String),
80    /// A `pre-push` line asked to rewrite files.
81    ///
82    /// Refused HERE, beside `NameTaken` and `Duplicate`, rather than as a
83    /// runtime "contract violation" at push time: same fact, discovered
84    /// earlier, by more people, at the moment it is cheapest to fix. A pre-push
85    /// hook must not modify the worktree or index — the pushed commit would
86    /// then differ from the tree the developer is looking at.
87    FixOnPrePush,
88}
89
90impl std::fmt::Display for ParseError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            ParseError::MissingFields => {
94                write!(f, "expected 5 fields: stage name scope severity command")
95            }
96            ParseError::MissingName => write!(f, "missing name"),
97            ParseError::NameTaken(n) => write!(f, "{n:?} already names a check"),
98            ParseError::TriggerInName(n) => write!(
99                f,
100                "{n:?} must not be a trigger or start with one — the stage column says which"
101            ),
102            ParseError::Duplicate(n) => write!(f, "{n:?} is declared twice on one trigger"),
103            ParseError::BadStage(t) => {
104                write!(f, "stage {t:?} must be `pre-commit` or `pre-push`")
105            }
106            ParseError::BadScope(t) => write!(
107                f,
108                "scope {t:?} must be `*`, `*.<ext>`, or a bare filename (no `/`), \
109                 optionally followed by `+<file>` the repository must carry"
110            ),
111            ParseError::BadTool => write!(
112                f,
113                "a tool pin is exactly `tool <program> <version-substring>`"
114            ),
115            ParseError::BadPolicyLine(usage) => write!(f, "a policy line is `{usage}`"),
116            ParseError::UnsettableKey(k) => write!(
117                f,
118                "set {k:?} is not a policy-settable key — see docs/custom-checks.md"
119            ),
120            ParseError::FixOnPrePush => write!(
121                f,
122                "`fix` is only for pre-commit — a pre-push hook must not rewrite files"
123            ),
124            ParseError::BadSeverity(t) => {
125                write!(f, "severity {t:?} must be `block` or `warn`")
126            }
127        }
128    }
129}
130
131/// A line that parsed. Every field means something.
132///
133/// `program` and `args` rather than one `argv`: a runnable check must have a
134/// command, and splitting the head off makes that structural instead of a
135/// `split_first` guard that can only ever be dead code.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Declared {
138    /// `Fix::Rewrite` when the command column began `fix `.
139    pub fix: Fix,
140    /// The command column carried the `files` marker.
141    pub files: bool,
142    pub name: String,
143    pub stage: Stage,
144    pub severity: Severity,
145    /// Extensions that gate it. Empty means any change — the `*` scope.
146    pub exts: Vec<String>,
147    /// Exact filenames that gate it — the scope column's bare tokens.
148    pub names: Vec<String>,
149    /// Files the REPOSITORY must carry for this check to apply at all — the
150    /// scope column's `+` half. Empty means always applicable, which is what
151    /// every declaration meant before `amont add` made a line something you
152    /// could acquire rather than write.
153    pub opt_in: Vec<String>,
154    pub program: String,
155    pub args: Vec<String>,
156}
157
158impl Declared {
159    /// `<trigger>-<name>`, the same shape a built-in has.
160    ///
161    /// This is what `hook.skip` and `amont.severity.<key>` resolve against,
162    /// so a declared check answers to its trigger and its short name exactly as
163    /// a compiled-in one does. Before it had an id, `hook.skip pre-commit`
164    /// silenced fifteen built-ins and left every declared check running.
165    pub fn id(&self) -> String {
166        format!("{}-{}", self.stage.as_str(), self.name)
167    }
168
169    /// The command as written, for display.
170    pub fn command(&self) -> String {
171        std::iter::once(self.program.as_str())
172            .chain(self.args.iter().map(String::as_str))
173            .collect::<Vec<_>>()
174            .join(" ")
175    }
176}
177
178/// One manifest line: usable, or not.
179///
180/// A SUM, not a struct with an `Option<why>` beside the fields. The struct
181/// form let a broken line carry a severity, a scope and an argv that meant
182/// nothing — and it produced a wrong diagnosis: because broken and usable
183/// entries shared one list, a valid line was rejected as "declared twice" for
184/// colliding with a line that could not run. Dedup now sees only `Usable`.
185///
186/// Separate from `External` because the fleet reads ninety-six manifests and may
187/// re-read them on every refresh, while `External` holds a `Scope` whose
188/// `&'static` slices are LEAKED.
189/// `tool <program> <version-substring>` — a version this repository expects
190/// of a tool its checks drive, so cross-machine skew is a printed fact
191/// instead of "the hook is flaky here". Verified once per hook run, warn-only.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct ToolPin {
194    pub program: String,
195    /// A substring `<program> --version`'s first line must contain — `0.6.`
196    /// pins a minor, `0.6.3` pins a patch. Substring, not semver: the point
197    /// is agreement between machines, not range arithmetic.
198    pub want: String,
199}
200
201/// A committed policy statement about a BUILT-IN (or declared) check — the
202/// team's decision, shipped with the repository, trust-gated like everything
203/// else the manifest says. See `policy`.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum PolicyLine {
206    /// `severity <check|short-name|trigger> warn|block`
207    Severity { target: String, severity: Severity },
208    /// `skip <check|short-name|trigger>`
209    Skip { target: String },
210    /// `set <key> <value>` — a committed default for an allowlisted
211    /// `amont.*` config key. `key` is the FULL git key (`amont.timeout`),
212    /// canonicalised from whatever case the file used, because git keys are
213    /// case-insensitive and a policy file stricter than git is a trap.
214    Set { key: String, value: String },
215}
216
217/// The keys `set` may reach, in canonical spelling. Deliberately absent:
218/// `fix` (a committed file must not change what already-trusted commands may
219/// DO to your working tree — a different consent than "I read these
220/// commands"), `trusted`, `conventions`, `recordBypasses`, `progress`,
221/// `knownIdentity`, and the `severity.*` family (its own line kind).
222pub const SETTABLE: &[&str] = &[
223    "largeFileWarn",
224    "largeFileBlock",
225    "commit.gitmoji",
226    "commit.subjectMax",
227    "commit.descriptionMax",
228    "commit.bodyWrap",
229    "autoRebase",
230    "timeout",
231    "testPushedTree",
232    "minVersion",
233    // A snapshot is not a workspace. `testPushedTree` is settable here, so a
234    // repository can say "run the gate on a checkout of the commit" — and
235    // before this it could not say how to make that checkout runnable, which
236    // for a pnpm or npm workspace means an install. The repository is what
237    // knows; requiring every clone to discover `git config
238    // amont.snapshotPrepare` by hand made the declarable half useless.
239    //
240    // Safe on the same terms as a declared check: policy is populated ONLY
241    // when the manifest is trusted, `PolicyLine::describe` puts the command
242    // in front of the person consenting, and any edit revokes that consent.
243    // A local `git config` still outranks it (see `policy`'s ladder), so a
244    // developer can override without touching the file.
245    "snapshotPrepare",
246    // The two halves of making a snapshot runnable that amont does itself.
247    // `snapshotDeps` picks how dependencies arrive (`install`, `reuse`,
248    // `off`); `snapshotCarry` names untracked files copied in from the
249    // working tree — never over tracked content, which is refused. Both are
250    // properties of the project, and trust-gated like `snapshotPrepare`.
251    "snapshotDeps",
252    "snapshotCarry",
253    // Ordering only. `amont.order evidence` permutes the push gates a
254    // repository already runs, using that machine's own record of them; it
255    // adds nothing, removes nothing and skips nothing, so it grants a
256    // committed file no power over what is enforced — which is the line
257    // `fix` and the `severity.*` family are kept on the other side of. Which
258    // ORDER a team's suites are attempted in is a property of the project
259    // (the four-minute suite, the five-second audit), so the project is the
260    // right place to be able to say it; a local `git config` still wins.
261    "order",
262];
263
264impl PolicyLine {
265    /// The one-line rendering the trust prompt shows — consent must see the
266    /// policy it is granting.
267    pub fn describe(&self) -> String {
268        match self {
269            PolicyLine::Severity { target, severity } => {
270                format!("severity  {target}  {}", severity.as_str())
271            }
272            PolicyLine::Skip { target } => format!("skip      {target}"),
273            PolicyLine::Set { key, value } => format!("set       {key}  {value}"),
274        }
275    }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub enum Line {
280    Usable(Declared),
281    /// A tool version pin — carries no check.
282    Tool(ToolPin),
283    /// A policy statement — carries no check either. The line number rides
284    /// along because a "names no check here" note must point somewhere.
285    Policy {
286        what: PolicyLine,
287        lineno: usize,
288    },
289    Broken {
290        /// The declared name, or `<file>:<lineno>` when the line has none — a
291        /// gap has to be nameable to be reportable.
292        name: String,
293        /// Broken lines land on pre-commit unless the stage token parsed: seen
294        /// on every commit beats seen on every push.
295        stage: Stage,
296        lineno: usize,
297        why: ParseError,
298    },
299}
300
301impl Line {
302    pub fn name(&self) -> &str {
303        match self {
304            Line::Usable(d) => &d.name,
305            Line::Tool(pin) => &pin.program,
306            Line::Policy {
307                what: PolicyLine::Severity { target, .. } | PolicyLine::Skip { target },
308                ..
309            } => target,
310            Line::Policy {
311                what: PolicyLine::Set { key, .. },
312                ..
313            } => key,
314            Line::Broken { name, .. } => name,
315        }
316    }
317
318    /// Does this line declare a CHECK? `Tool` and `Policy` lines do not, and
319    /// every consumer that projects lines into checks — `parse`, the trust
320    /// prompt, the fleet's declared column — must filter on this rather than
321    /// pattern-match variants it will forget to extend. A `Broken` line IS a
322    /// check (one that runs to Unavailable and says why).
323    pub fn is_check(&self) -> bool {
324        matches!(self, Line::Usable(_) | Line::Broken { .. })
325    }
326    pub fn stage(&self) -> Stage {
327        match self {
328            Line::Usable(d) => d.stage,
329            // A pin has no stage; it is verified at both. The value only
330            // feeds displays that will not ask a pin for one. Policy lines
331            // likewise — `is_check()` keeps both out of anything that would.
332            Line::Tool(_) | Line::Policy { .. } => Stage::PreCommit,
333            Line::Broken { stage, .. } => *stage,
334        }
335    }
336    /// `Some(reason)` when this line declares a check that cannot run.
337    pub fn broken(&self) -> Option<String> {
338        match self {
339            Line::Usable(_) | Line::Tool(_) | Line::Policy { .. } => None,
340            Line::Broken { lineno, why, .. } => Some(format!("line {lineno}: {why}")),
341        }
342    }
343
344    /// `<trigger>-<name>`, matching `Declared::id` and `External::id`. A broken
345    /// line has one too: `hook.skip pre-commit` should silence its nag exactly
346    /// as it silences the checks that do run.
347    pub fn id(&self) -> String {
348        format!("{}-{}", self.stage().as_str(), self.name())
349    }
350
351    /// Consume into the identity every line has, and either the declaration or
352    /// the reason there is none.
353    ///
354    /// Both consumers — `External::from` and the fleet's projection — used to
355    /// destructure this by hand, and both carried an arm for a combination the
356    /// type forbids, because they computed the reason BEFORE matching. Written
357    /// once, that arm has nowhere to appear.
358    pub fn into_parts(self) -> (String, Stage, Result<Declared, String>) {
359        let name = self.name().to_string();
360        let stage = self.stage();
361        let parsed = match self {
362            Line::Usable(d) => Ok(d),
363            // Reachable only through a consumer that skipped `is_check()` —
364            // kept total so the type stays honest, worded so a leak is
365            // recognisable in whatever display it lands in.
366            Line::Tool(pin) => Err(format!("tool pin: {} {}", pin.program, pin.want)),
367            Line::Policy { what, .. } => Err(format!("policy: {}", what.describe())),
368            Line::Broken { lineno, why, .. } => Err(format!("line {lineno}: {why}")),
369        };
370        (name, stage, parsed)
371    }
372}
373
374/// A check a repository declares, rather than one compiled in.
375pub struct External {
376    /// `<trigger>-<name>` — what `hook.skip` and `amont.severity.<key>`
377    /// resolve against, and what `Check::name` returns. Built-ins have had this
378    /// shape all along; declared checks answering to a bare name were invisible
379    /// to `hook.skip pre-commit`.
380    pub id: String,
381    /// The name as written in the manifest — the "short name" of the vocabulary
382    /// — used for messages. A line too malformed to name itself falls back to
383    /// its position, and reading `pre-commit-amont.conf:3` back to somebody
384    /// helps nobody.
385    pub short_name: String,
386    pub stage: Stage,
387    pub kind: Kind,
388}
389
390/// The two things an external can be. `Scope` and `Severity` live only on the
391/// runnable side, so a broken external cannot carry a severity nobody applies.
392pub enum Kind {
393    Runnable {
394        scope: Scope,
395        severity: Severity,
396        program: String,
397        args: Vec<String>,
398        fix: Fix,
399        /// The command column began `files ` — append the matched paths to
400        /// the argv, the way a builtin hands its tool the staged list.
401        files: bool,
402    },
403    Unusable {
404        why: String,
405    },
406}
407
408/// The scripts already vouched for against the staged tree, read once per
409/// hook process — a stage's externals run concurrently, and each asking git
410/// itself would be two spawns per check for one answer.
411fn vouched_for_staged_tree() -> &'static std::collections::HashSet<String> {
412    static VOUCHED: std::sync::OnceLock<std::collections::HashSet<String>> =
413        std::sync::OnceLock::new();
414    VOUCHED.get_or_init(crate::gate_stamp::vouched_for_staged_tree)
415}
416
417impl Check for External {
418    fn name(&self) -> &str {
419        &self.id
420    }
421    fn stage(&self) -> Stage {
422        self.stage
423    }
424    /// Derived for an unusable check rather than stored: it never runs, so its
425    /// scope is a question with no answer, and computing one here keeps the
426    /// DATA from carrying a value that means nothing.
427    fn scope(&self) -> Scope {
428        match &self.kind {
429            Kind::Runnable { scope, .. } => *scope,
430            Kind::Unusable { .. } => Scope::ALWAYS,
431        }
432    }
433    fn severity(&self) -> Severity {
434        match &self.kind {
435            Kind::Runnable { severity, .. } => *severity,
436            // Never consulted: an unusable check reports `Unavailable`, which
437            // no severity can turn into a block.
438            Kind::Unusable { .. } => Severity::Warn,
439        }
440    }
441
442    fn run(&self, ctx: &Ctx) -> Outcome {
443        let settings = ctx.settings;
444        let (scope, program, args, fix, files) = match &self.kind {
445            Kind::Runnable {
446                scope,
447                program,
448                args,
449                fix,
450                files,
451                ..
452            } => (scope, program, args, *fix, *files),
453            Kind::Unusable { why } => {
454                crate::hooks::common::warn(&format!(
455                    "{MANIFEST}: {} — {}",
456                    crate::ui::highlight(&self.short_name),
457                    // Carries repo tokens: `BadStage("…")` quotes the manifest.
458                    crate::ui::sanitize(why)
459                ));
460                return Outcome::Unavailable;
461            }
462        };
463
464        // The scope gate lives HERE, unlike a built-in's, which enforces its own
465        // in its first three lines. A declared command has no way to know what
466        // was staged, so if this did not gate it, `*.sh` would run on every
467        // commit and the column would be decoration.
468        //
469        // Which files to test against depends on the stage: what is staged for
470        // a commit, what is being pushed for a push. `*` short-circuits before
471        // either is computed, which is the common case.
472        // A check whose whole job is to rewrite has nothing to say when nobody
473        // asked for rewriting — so it does not RUN, rather than running and
474        // having its result discarded. Gating only the re-staging let the
475        // command edit files with `amont.fix` off, which is precisely the
476        // surprise the gate exists to prevent.
477        //
478        // `Unavailable`, not `Passed`. `check.rs` defines `Unavailable` as
479        // "COULD NOT RUN — a tool is missing, or the opt-in config is absent",
480        // which is exactly this; `Passed` is the one verdict it must not
481        // report, because the dispatcher's roll-up and the fleet dashboard
482        // then show a check that never executed as clean. With a message,
483        // because every other `Unavailable` in this codebase says what was
484        // missing and an unexplained count on every commit is worse than none.
485        if fix == Fix::Rewrite && !crate::hooks::common::fixing_enabled(settings) {
486            crate::hooks::common::warn(&format!(
487                "{}: declares fix, and {} is off — not run",
488                crate::ui::highlight(&self.short_name),
489                crate::ui::highlight("amont.fix")
490            ));
491            return Outcome::Unavailable;
492        }
493
494        // Two questions, two path lists. `touches` asks what the CHANGE
495        // contains; `opted_in` asks what the REPOSITORY carries, and answering
496        // the second from the change set made a `+marker` row fire only when
497        // the marker itself was staged — so a pack that followed our own
498        // advice to gate every row was a pack that never ran. `tracked_files`
499        // returning `None` means git would not say, and an unverified answer
500        // must not silently switch a check off: run it, and let the command
501        // judge.
502        let opted_in =
503            crate::hooks::common::tracked_files().map_or(true, |tracked| scope.opted_in(&tracked));
504        if !opted_in {
505            return Outcome::Passed;
506        }
507        let root = crate::hooks::common::repo_root();
508        match self.stage {
509            Stage::PreCommit => {
510                let staged = crate::hooks::common::staged_files(&[]);
511                let Some(matched) = files_to_judge(scope, files, &staged) else {
512                    return Outcome::Passed;
513                };
514                // A blocking gate that already ran clean against exactly this
515                // staged tree is not run again: the attempt that ran it never
516                // reached post-commit (commit-msg refused the subject, the
517                // editor was closed on the message) or the same tree is
518                // being re-committed. Said out loud, in the push gate's
519                // words, and answered as `Passed` so pre-commit records the
520                // marker again and post-commit binds it as usual. What can
521                // and cannot be vouched for: `gate_stamp::vouched_for_staged_tree`.
522                if self.severity() == Severity::Block
523                    && crate::gate_stamp::commit_stamps_enabled(settings)
524                    && vouched_for_staged_tree().contains(&self.short_name)
525                {
526                    crate::say!(
527                        "{} {} passed on this exact tree earlier — not repeating it here",
528                        crate::ui::valid_sign(),
529                        crate::ui::highlight(&self.short_name),
530                    );
531                    return Outcome::Passed;
532                }
533                self.execute(
534                    settings,
535                    program,
536                    args,
537                    fix,
538                    files,
539                    &matched,
540                    Path::new(&root),
541                )
542            }
543            // PER REF, and — when `amont.testPushedTree` asks for it — in a
544            // checkout of that ref's tip, exactly as the built-in suites do
545            // and for the same reason: a declared `test *.rs block cargo
546            // test` line used to run once, in the working tree, whatever the
547            // flag said. It passed on an uncommitted fix, and `stamp_tips`
548            // then read the flag and stamped it onto the tip as though it had
549            // run there, so the next push of that tip skipped it. The flag is
550            // a promise about what a push gate tests; a declared gate is a
551            // push gate.
552            Stage::PrePush => {
553                let zero = crate::git::stdout(&["hash-object", "--stdin"])
554                    .map(|h| "0".repeat(h.len()))
555                    .unwrap_or_else(|| "0".repeat(40));
556                for r in ctx.push.get() {
557                    if r.local_oid == zero {
558                        continue; // deleting a ref pushes no code
559                    }
560                    let changed = crate::pushrefs::changed_files_for(r, &zero);
561                    let Some(matched) = files_to_judge(scope, files, &changed) else {
562                        continue;
563                    };
564                    // `_guard` owns the checkout for the length of this ref's
565                    // run; dropping it removes the worktree before the next
566                    // ref's begins. `self.id`, because that is what a stamp
567                    // names and what `stamp_tips` asks `ran_on_tip` about.
568                    let (run_in, _guard) =
569                        crate::pushed_tree::where_to_run(settings, &r.local_oid, &root, &self.id);
570                    match self.execute(settings, program, args, fix, files, &matched, &run_in) {
571                        Outcome::Passed => {}
572                        other => return other,
573                    }
574                }
575                Outcome::Passed
576            }
577        }
578    }
579}
580
581/// The paths a declared check is handed for `in_scope`, or `None` when it
582/// has nothing to judge and must not run.
583///
584/// No `is_unscoped()` guard: `touches` already answers true for an unscoped
585/// trigger, so the gate reduces to the opt-in the caller has already
586/// settled — which is what a row with nothing on the left (`+Gemfile`: "any
587/// change, in a repository that carries a Gemfile") means. Guarding on it
588/// here skipped the opt-in for exactly those rows.
589///
590/// The paths are what the declaration's scope actually matched — what a
591/// builtin would be handed. A files-taking command with no files has
592/// nothing to judge — running it bare would make most linters error on an
593/// empty argv, blocking a commit over nothing. The builtin convention,
594/// applied here.
595fn files_to_judge(scope: &Scope, files: bool, in_scope: &[String]) -> Option<Vec<String>> {
596    if !scope.touches(in_scope) {
597        return None;
598    }
599    let matched = scoped(scope, in_scope);
600    if files && matched.is_empty() {
601        return None;
602    }
603    Some(matched)
604}
605
606impl External {
607    /// Spawn the declared command over `matched`, in `run_in`, and judge it.
608    ///
609    /// `matched` reaches the command two ways: `$AMONT_FILES` always
610    /// (newline-separated, so a wrapper script never re-derives `git diff
611    /// --cached` and diverges from the set this gate judged — `amont run
612    /// --all-files` overrides the set in-process, invisibly to any child that
613    /// asks git itself), and appended to the argv when the declaration
614    /// carries the `files` marker.
615    #[allow(clippy::too_many_arguments)]
616    fn execute(
617        &self,
618        settings: &crate::config::Settings,
619        program: &str,
620        args: &[String],
621        fix: Fix,
622        files: bool,
623        matched: &[String],
624        run_in: &Path,
625    ) -> Outcome {
626        // Through `program()`, exactly like every builtin: on Windows,
627        // `Command::new("npx")` cannot start `npx.cmd`, and an external that
628        // fails to SPAWN reports Unavailable — warn, never block — so the
629        // check would silently never run. The guard test that enforces this
630        // for builtins scans only `src/hooks/`; this call is the manifest's
631        // half of the same rule.
632        let mut cmd = Command::new(crate::hooks::common::program(program));
633        cmd.args(args).current_dir(run_in).stdin(Stdio::null());
634        // Env, not only argv: newline-separated so ordinary shell loops can
635        // read it. A path with a newline in its name would split wrong — the
636        // list is repo-controlled and such a path is already hostile input —
637        // and a change set too large for an environment variable (rare, but
638        // E2BIG kills the spawn outright) travels as an empty variable
639        // instead, which a wrapper treats exactly like "derive it yourself".
640        let joined = matched.join("\n");
641        cmd.env(
642            "AMONT_FILES",
643            if joined.len() <= 100_000 {
644                joined.as_str()
645            } else {
646                ""
647            },
648        );
649        if files {
650            cmd.args(matched);
651        }
652        crate::hooks::common::strip_git_env(&mut cmd);
653        // Under the deadline: repo-authored code that outlives the budget is
654        // killed and FAILS — "hung" must not read as "passed", and pre-push
655        // runs these serially where one hang stalls the entire push.
656        let status = match crate::hooks::common::status_streamed(settings, &mut cmd) {
657            Ok(crate::hooks::common::Ran::Status(s)) => Ok(s),
658            Ok(crate::hooks::common::Ran::TimedOut(budget)) => {
659                crate::hooks::common::say_timed_out(&self.short_name, budget);
660                return Outcome::Failed;
661            }
662            Err(e) => Err(e),
663        };
664        match status {
665            // A command that could not be STARTED has not judged anything. This
666            // is the distinction `Outcome` was added for: reporting a missing
667            // `shellcheck` as a lint failure sends someone hunting for a lint
668            // error that does not exist.
669            Err(e) => {
670                crate::hooks::common::warn(&format!(
671                    "{MANIFEST}: {} could not run {} — {}",
672                    crate::ui::highlight(&self.short_name),
673                    crate::ui::highlight(program),
674                    // The io error's text embeds the program name it tried.
675                    crate::ui::sanitize(&e.to_string())
676                ));
677                Outcome::Unavailable
678            }
679            Ok(s) if s.success() => {
680                // A declared fixer that ran clean may still have rewritten
681                // something; re-stage exactly what moved. Only its own scope,
682                // so it cannot stage a file it never looked at.
683                if fix == Fix::Rewrite && crate::hooks::common::fixing_enabled(settings) {
684                    match crate::hooks::common::restage(matched) {
685                        Restaged::Staged => {
686                            crate::hooks::common::ok(
687                                settings,
688                                &format!(
689                                    "{} fixed and re-staged",
690                                    crate::ui::highlight(&self.short_name)
691                                ),
692                            );
693                            return Outcome::Fixed;
694                        }
695                        // `git add` failed, so the index still holds whatever
696                        // the command has already replaced on disk. This used
697                        // to be indistinguishable from "nothing moved" and was
698                        // reported as a pass.
699                        Restaged::Failed(stuck) => {
700                            crate::hooks::common::fail(&format!(
701                                "{} rewrote files but {} failed — the index still holds the \
702                                 OLD content: {}",
703                                crate::ui::highlight(&self.short_name),
704                                crate::ui::highlight("git add"),
705                                crate::ui::sanitize(&stuck.join(", "))
706                            ));
707                            return Outcome::Failed;
708                        }
709                        Restaged::Nothing => {}
710                    }
711                }
712                Outcome::Passed
713            }
714            Ok(_) => {
715                crate::hooks::common::fail(&format!(
716                    "{} failed (output above)",
717                    crate::ui::highlight(&self.short_name)
718                ));
719                Outcome::Failed
720            }
721        }
722    }
723}
724
725/// The paths this check's scope actually covers.
726fn scoped(scope: &Scope, paths: &[String]) -> Vec<String> {
727    if scope.is_unscoped() {
728        return paths.to_vec();
729    }
730    paths.iter().filter(|p| scope.covers(p)).cloned().collect()
731}
732
733/// `Scope` holds `&'static` slices so a built-in can be a `const`. A parsed
734/// manifest has neither, so its extension list is leaked.
735///
736/// This is bounded and deliberate: the manifest is read at most once per
737/// process, holds a handful of short strings, and the process is a git hook that
738/// exits in milliseconds. The alternative — a lifetime on `Scope` — would
739/// propagate through the trait, both dispatchers and the fleet crate to buy back
740/// a few hundred bytes that the kernel reclaims moments later.
741fn leak(exts: Vec<String>) -> &'static [&'static str] {
742    let refs: Vec<&'static str> = exts
743        .into_iter()
744        .map(|s| &*Box::leak(s.into_boxed_str()))
745        .collect();
746    Box::leak(refs.into_boxed_slice())
747}
748
749/// `*` means any change; `*.sh` or `*.sh,*.bash` gate on extensions; and
750/// `<triggers>+<opt-ins>` adds the repository condition a builtin has.
751///
752/// # `+`, and why there is now an opt-in at all
753///
754/// This used to read: *"No `opt_in` counterpart, because the manifest IS the
755/// opt-in: a repository that does not want the check deletes the line."* That
756/// was true while every line was one somebody typed. `amont add` ended it — a
757/// vendored line is in your manifest because you took a whole PACK, not
758/// because you chose that tool, so a packaged `rubocop` fired on every `.rb`
759/// in a repository with no rubocop config and simply errored. That is the
760/// "ninety-five nags" `hooks::yamllint` documents avoiding, which is a
761/// courtesy every builtin has had and no declaration could express.
762///
763/// The separator is `+` because that is what `amont list` already PRINTS
764/// between the two halves — `inert here — needs .rs + Cargo.toml`. What you
765/// read in the listing is what you write in the file. No spaces: the column
766/// grammar splits on whitespace.
767///
768/// Both sides are comma-separated, and each side is an OR — any trigger, and
769/// any one opt-in. Between the sides it is an AND, matching `Scope`'s own
770/// semantics.
771///
772/// Splitting on the FIRST `+` means a filename containing one is not
773/// expressible. That is the same class of limit as directories being
774/// inexpressible, and it is stated in `docs/custom-checks.md` rather than
775/// worked around with quoting this grammar deliberately does not have.
776///
777/// Returns owned strings rather than a `Scope`, so validating a manifest
778/// costs nothing permanent. Only `External::from` turns these into the
779/// `&'static` form `Scope` requires.
780type ParsedScope = (Vec<String>, Vec<String>, Vec<String>);
781
782fn parse_scope(token: &str) -> Result<ParsedScope, ParseError> {
783    let (triggers, opt_in) = match token.split_once('+') {
784        None => (token, Vec::new()),
785        Some((left, right)) => {
786            // `*.rb+` promises a condition and states none. Refused rather
787            // than read as "no condition", which is the opposite of what the
788            // author was reaching for.
789            let opts: Vec<String> = right
790                .split(',')
791                .filter(|p| !p.is_empty())
792                .map(str::to_string)
793                .collect();
794            if opts.is_empty() || right.contains('+') {
795                return Err(ParseError::BadScope(token.to_string()));
796            }
797            for o in &opts {
798                if o.contains(['*', '?', '[', '/']) {
799                    return Err(ParseError::BadScope(o.clone()));
800                }
801            }
802            // `+cfg` with nothing on the left is "any change, if this repo has
803            // cfg" — a real shape, and `*` is how the trigger half spells
804            // "anything".
805            (if left.is_empty() { "*" } else { left }, opts)
806        }
807    };
808    if triggers == "*" {
809        return Ok((Vec::new(), Vec::new(), opt_in));
810    }
811    let mut exts = Vec::new();
812    let mut names = Vec::new();
813    for part in triggers.split(',') {
814        if let Some(ext) = part.strip_prefix('*').filter(|ext| ext.starts_with('.')) {
815            exts.push(ext.to_string());
816            continue;
817        }
818        // A bare token is an exact FILENAME — `package.json`, `Dockerfile`,
819        // `.prettierrc` — matched against the basename. Directories are not
820        // expressible (a `/` is refused), and anything that LOOKS like a glob
821        // (`*`, `?`, `[`) is refused as the typo it almost certainly is —
822        // this grammar deliberately has no globs to mis-guess.
823        if !part.is_empty() && !part.contains(['*', '?', '[', '/']) {
824            names.push(part.to_string());
825            continue;
826        }
827        return Err(ParseError::BadScope(part.to_string()));
828    }
829    Ok((exts, names, opt_in))
830}
831
832fn parse_stage(token: &str) -> Option<Stage> {
833    match token {
834        "pre-commit" => Some(Stage::PreCommit),
835        "pre-push" => Some(Stage::PrePush),
836        _ => None,
837    }
838}
839
840/// An identity already spoken for. An external must not be able to shadow
841/// `pre-push-branch-protect` — nor silently lose to it, which is what a
842/// first-match lookup would do without this.
843///
844/// Judged on the ID, which is why `clippy` on `pre-push` is now legal: it is
845/// `pre-push-clippy`, a different check from `pre-commit-clippy`. Judged on the
846/// bare name, as it was, the two collided and the second was refused.
847///
848fn name_is_taken(id: &str) -> bool {
849    CHECKS.iter().any(|c| c.name == id) || ENTRYPOINTS.iter().any(|(n, _)| *n == id)
850}
851
852/// A short name that says its own trigger — either by being one, or by starting
853/// with one.
854///
855/// Both make an id ambiguous rather than merely ugly. `pre-commit` as a name
856/// gives `hook.skip pre-commit` two readings; `pre-commit-clippy` as a name
857/// gives a check whose SHORT name is the built-in's FULL id, so one skip
858/// silences both. The stage column already says which trigger this is.
859fn name_says_its_trigger(name: &str) -> bool {
860    crate::TRIGGERS
861        .iter()
862        .any(|t| name == *t || name.starts_with(&format!("{t}-")))
863}
864
865/// The four leading tokens and the untouched remainder, or `None` when the line
866/// does not have them.
867///
868/// The arity is in the TYPE. Returning a `Vec` made "four or it is malformed"
869/// a rule every caller had to remember and none could be checked against.
870///
871/// NOT `splitn(5, char::is_whitespace)`: that splits at the FIRST whitespace
872/// character every time, so a file aligned into columns — which is how the
873/// format invites you to write it — yields empty fields for every run of
874/// spaces after the first.
875fn tokenise(line: &str) -> Option<([&str; 4], &str)> {
876    let mut fields: [&str; 4] = [""; 4];
877    let mut rest = line;
878    for slot in fields.iter_mut() {
879        rest = rest.trim_start();
880        let i = rest.find(char::is_whitespace)?;
881        *slot = &rest[..i];
882        rest = &rest[i..];
883    }
884    let command = rest.trim();
885    (!command.is_empty()).then_some((fields, command))
886}
887
888pub fn parse_lines(text: &str) -> Vec<Line> {
889    let mut out: Vec<Line> = Vec::new();
890    for (i, raw) in text.lines().enumerate() {
891        let line = raw.trim();
892        if line.is_empty() || line.starts_with('#') {
893            continue;
894        }
895        let lineno = i + 1;
896        out.push(parse_line(lineno, line, &out));
897    }
898    out
899}
900
901/// One line, given the lines already accepted.
902///
903/// `earlier` is read ONLY for the duplicate check, and only its `Usable`
904/// entries — a line that cannot run does not reserve its name. Before this, a
905/// valid declaration was rejected as "declared twice" for colliding with a
906/// broken one, which pointed the reader at the wrong line entirely.
907fn parse_line(lineno: usize, line: &str, earlier: &[Line]) -> Line {
908    // `tool` was never a valid stage, so claiming it as a keyword breaks no
909    // manifest that ever parsed. Handled before `tokenise`, whose five-column
910    // shape a three-token pin does not have.
911    if line == "tool" || line.starts_with("tool ") || line.starts_with("tool\t") {
912        let mut it = line.split_whitespace().skip(1);
913        return match (it.next(), it.next(), it.next()) {
914            (Some(program), Some(want), None) => Line::Tool(ToolPin {
915                program: program.to_string(),
916                want: want.to_string(),
917            }),
918            _ => broken_at(
919                lineno,
920                format!("{MANIFEST}:{lineno}"),
921                None,
922                ParseError::BadTool,
923            ),
924        };
925    }
926    // `severity` and `skip` were never valid stages either — the same
927    // keyword claim `tool` made. Fixed arity; anything else is a positional
928    // gap, never a phantom check named after its target (a broken
929    // `severity clippy loud` must not mint `pre-commit-clippy`).
930    if line == "severity" || line.starts_with("severity ") || line.starts_with("severity\t") {
931        let mut it = line.split_whitespace().skip(1);
932        return match (it.next(), it.next(), it.next()) {
933            (Some(target), Some(word), None) => match Severity::parse(word) {
934                Some(severity) => Line::Policy {
935                    what: PolicyLine::Severity {
936                        target: target.to_string(),
937                        severity,
938                    },
939                    lineno,
940                },
941                None => broken_at(
942                    lineno,
943                    format!("{MANIFEST}:{lineno}"),
944                    None,
945                    ParseError::BadSeverity(word.to_string()),
946                ),
947            },
948            _ => broken_at(
949                lineno,
950                format!("{MANIFEST}:{lineno}"),
951                None,
952                ParseError::BadPolicyLine("severity <check> warn|block"),
953            ),
954        };
955    }
956    if line == "skip" || line.starts_with("skip ") || line.starts_with("skip\t") {
957        let mut it = line.split_whitespace().skip(1);
958        return match (it.next(), it.next()) {
959            (Some(target), None) => Line::Policy {
960                what: PolicyLine::Skip {
961                    target: target.to_string(),
962                },
963                lineno,
964            },
965            _ => broken_at(
966                lineno,
967                format!("{MANIFEST}:{lineno}"),
968                None,
969                ParseError::BadPolicyLine("skip <check>"),
970            ),
971        };
972    }
973    if line == "set" || line.starts_with("set ") || line.starts_with("set\t") {
974        // The value is the REST of the line, not a third token. Every key
975        // settable before this took a single word — `true`, `72`, a version —
976        // so splitting on whitespace was indistinguishable from splitting
977        // once. `snapshotPrepare` is a shell command, and a rule that only
978        // ever admits one-word values cannot express it.
979        //
980        // A trailing `#` stays part of the value, exactly as it does in a
981        // check's command: comments here are whole-line only (see the caller).
982        let rest = line[3..].trim();
983        let split = rest
984            .split_once(char::is_whitespace)
985            .map(|(k, v)| (k, v.trim()))
986            .filter(|(_, v)| !v.is_empty());
987        return match split {
988            Some((key, value)) => {
989                // Case-insensitive against the allowlist, canonical spelling
990                // stored — `set commit.subjectmax 72` must work, because git
991                // would have accepted the key in any case.
992                match SETTABLE.iter().find(|k| k.eq_ignore_ascii_case(key)) {
993                    Some(canonical) => Line::Policy {
994                        what: PolicyLine::Set {
995                            key: format!("amont.{canonical}"),
996                            value: value.to_string(),
997                        },
998                        lineno,
999                    },
1000                    None => broken_at(
1001                        lineno,
1002                        format!("{MANIFEST}:{lineno}"),
1003                        None,
1004                        ParseError::UnsettableKey(key.to_string()),
1005                    ),
1006                }
1007            }
1008            None => broken_at(
1009                lineno,
1010                format!("{MANIFEST}:{lineno}"),
1011                None,
1012                ParseError::BadPolicyLine("set <key> <value>"),
1013            ),
1014        };
1015    }
1016    let (fields, command) = match tokenise(line) {
1017        Some(t) => t,
1018        // No name to report: fall back to the position, which is the only
1019        // handle a reader has on a line this malformed.
1020        None => {
1021            return broken_at(
1022                lineno,
1023                name_or_position(tokenise(line).map(|(f, _)| f[1]).unwrap_or(""), lineno),
1024                None,
1025                ParseError::MissingFields,
1026            )
1027        }
1028    };
1029    let [stage_tok, declared, scope_tok, severity_tok] = fields;
1030    let stage = parse_stage(stage_tok);
1031    let name = name_or_position(declared, lineno);
1032    let fail = |why| broken_at(lineno, name.clone(), stage, why);
1033
1034    if declared.is_empty() {
1035        return fail(ParseError::MissingName);
1036    }
1037    // The stage is settled BEFORE the name is judged, because the identity
1038    // being judged is `<trigger>-<name>` and there is no such thing without a
1039    // trigger. Ordered the other way, a line with an unusable stage was refused
1040    // for a name clash that could not be assessed yet.
1041    let Some(stage) = stage else {
1042        return fail(ParseError::BadStage(stage_tok.to_string()));
1043    };
1044    if name_says_its_trigger(declared) {
1045        return fail(ParseError::TriggerInName(declared.to_string()));
1046    }
1047    let id = format!("{}-{}", stage.as_str(), declared);
1048    if name_is_taken(&id) {
1049        return fail(ParseError::NameTaken(declared.to_string()));
1050    }
1051    if earlier
1052        .iter()
1053        .any(|l| matches!(l, Line::Usable(d) if d.id() == id))
1054    {
1055        return fail(ParseError::Duplicate(declared.to_string()));
1056    }
1057    let (exts, names, opt_in) = match parse_scope(scope_tok) {
1058        Ok(e) => e,
1059        Err(why) => return fail(why),
1060    };
1061    let Some(severity) = Severity::parse(severity_tok) else {
1062        return fail(ParseError::BadSeverity(severity_tok.to_string()));
1063    };
1064    // `fix` and `files` are leading markers on the command column rather
1065    // than extra fields, so every manifest written before them still parses.
1066    // A loop, so `fix files cmd` and `files fix cmd` both work — two markers
1067    // whose order carries no meaning must not be order-sensitive.
1068    let mut command = command;
1069    let mut wants_fix = false;
1070    let mut wants_files = false;
1071    loop {
1072        if let Some(rest) = command.strip_prefix("fix ") {
1073            command = rest.trim_start();
1074            wants_fix = true;
1075            continue;
1076        }
1077        if let Some(rest) = command.strip_prefix("files ") {
1078            command = rest.trim_start();
1079            wants_files = true;
1080            continue;
1081        }
1082        break;
1083    }
1084    if wants_fix && stage == Stage::PrePush {
1085        return fail(ParseError::FixOnPrePush);
1086    }
1087    // `tokenise` guarantees a non-empty command, so the split cannot fail.
1088    let mut argv = command.split_whitespace().map(str::to_owned);
1089    let Some(program) = argv.next() else {
1090        return fail(ParseError::MissingFields);
1091    };
1092    Line::Usable(Declared {
1093        fix: if wants_fix { Fix::Rewrite } else { Fix::None },
1094        files: wants_files,
1095        name: declared.to_string(),
1096        stage,
1097        severity,
1098        exts,
1099        names,
1100        opt_in,
1101        program,
1102        args: argv.collect(),
1103    })
1104}
1105
1106fn name_or_position(declared: &str, lineno: usize) -> String {
1107    if declared.is_empty() {
1108        format!("{MANIFEST}:{lineno}")
1109    } else {
1110        declared.to_string()
1111    }
1112}
1113
1114fn broken_at(lineno: usize, name: String, stage: Option<Stage>, why: ParseError) -> Line {
1115    Line::Broken {
1116        name,
1117        stage: stage.unwrap_or(Stage::PreCommit),
1118        lineno,
1119        why,
1120    }
1121}
1122
1123impl From<Line> for External {
1124    fn from(l: Line) -> External {
1125        let (name, stage, parsed) = l.into_parts();
1126        let kind = match parsed {
1127            Ok(d) => Kind::Runnable {
1128                // `ALWAYS` only when NOTHING narrows it. An opt-in alone is a
1129                // narrowing — `+Gemfile` means "any change, in a Ruby
1130                // repository" — and collapsing that to `ALWAYS` would run the
1131                // check everywhere, which is the exact bug this field exists
1132                // to prevent.
1133                scope: if d.exts.is_empty() && d.names.is_empty() && d.opt_in.is_empty() {
1134                    Scope::ALWAYS
1135                } else {
1136                    Scope {
1137                        files: leak(d.exts),
1138                        names: leak(d.names),
1139                        opt_in: leak(d.opt_in),
1140                        not_during: &[],
1141                    }
1142                },
1143                severity: d.severity,
1144                program: d.program,
1145                args: d.args,
1146                fix: d.fix,
1147                files: d.files,
1148            },
1149            Err(why) => Kind::Unusable { why },
1150        };
1151        let id = format!("{}-{}", stage.as_str(), name);
1152        External {
1153            id,
1154            short_name: name,
1155            stage,
1156            kind,
1157        }
1158    }
1159}
1160
1161pub fn parse(text: &str) -> Vec<External> {
1162    parse_lines(text)
1163        .into_iter()
1164        .filter(Line::is_check)
1165        .map(External::from)
1166        .collect()
1167}
1168
1169/// The manifest for `root`, or an empty list. Read once per process.
1170pub fn read(root: &Path) -> Vec<External> {
1171    std::fs::read_to_string(root.join(MANIFEST))
1172        .map(|t| parse(&t))
1173        .unwrap_or_default()
1174}
1175
1176/// The same file, without building the `Scope`s — for a reader that inspects
1177/// many repositories and must not leak once per manifest per refresh.
1178pub fn read_lines(root: &Path) -> Vec<Line> {
1179    std::fs::read_to_string(root.join(MANIFEST))
1180        .map(|t| parse_lines(&t))
1181        .unwrap_or_default()
1182}
1183
1184/// Everything one repository's manifest declares, parsed and trust-gated
1185/// ONCE, owned by the entrypoint that loaded it and lent down through `Ctx`.
1186///
1187/// This replaced two process-global `OnceLock`s keyed on the working
1188/// directory at first call — safe in a hook, which handles one repository
1189/// and exits, and a trap for anything that walks many. Owned data has no
1190/// first-call: the caller says which repository it means, every time.
1191#[derive(Default)]
1192pub struct Manifest {
1193    /// Declared checks — untrusted ones present but `Unusable`, so the
1194    /// decision waiting on the reader stays visible. See [`gate`].
1195    pub externals: Vec<External>,
1196    /// Tool version pins — DROPPED entirely when untrusted, because verifying
1197    /// one executes `<program> --version` for a name the repository chose,
1198    /// which is exactly the consent the trust model exists to collect.
1199    pub pins: Vec<ToolPin>,
1200    /// Whether an `amont.conf` EXISTS in this repository — the committed
1201    /// declaration that this project subscribes to amont's conventions.
1202    /// Presence, not content: an empty file declares, and declaring executes
1203    /// nothing, so this is safe to read before any trust decision. What the
1204    /// file SAYS stays trust-gated above.
1205    pub declared: bool,
1206    /// Committed `severity`/`skip` policy — POPULATED ONLY WHEN TRUSTED,
1207    /// like the pins: applying a repository's opinion about your safety net
1208    /// is exactly the consent the trust model collects. See `policy`.
1209    pub policy: crate::policy::Policy,
1210    /// `trust::why(state)` when policy lines exist but the manifest is not
1211    /// trusted — the dispatchers say it once per stage, because policy that
1212    /// silently does not apply is a silent behaviour change.
1213    pub policy_withheld: Option<&'static str>,
1214    /// `severity`/`skip` targets that name no check here, with positions —
1215    /// a typo in committed policy must be loud, not a phantom check.
1216    pub policy_notes: Vec<String>,
1217}
1218
1219/// Read and trust-gate `root`'s manifest.
1220///
1221/// ONE read. The bytes that get PARSED and the bytes that get HASHED are the
1222/// same bytes: an earlier shape `read(root)`-ed and then let `trust::state`
1223/// open the file a second time, so anything that changed it in between — a
1224/// `git checkout`, a watcher, a `make` target already running — produced a
1225/// trust decision about content that is not the content about to be
1226/// executed.
1227///
1228/// Each call leaks the `Scope` slices it builds (see [`leak`]) — pennies for
1229/// the hook path, which loads once per process, and the reason the fleet
1230/// keeps reading [`read_lines`] instead: a scanner must not leak once per
1231/// repository per refresh.
1232pub fn load(root: &Path) -> Manifest {
1233    // Non-UTF-8 yields nothing, as it always has: `parse` takes a `&str`,
1234    // and a manifest we cannot read as text is one we cannot act on. Not
1235    // lossy — that would invent a manifest nobody wrote.
1236    let declared = root.join(MANIFEST).exists();
1237    let Ok(bytes) = std::fs::read(root.join(MANIFEST)) else {
1238        return Manifest {
1239            declared,
1240            ..Manifest::default()
1241        };
1242    };
1243    let Ok(text) = String::from_utf8(bytes.clone()) else {
1244        return Manifest {
1245            declared,
1246            ..Manifest::default()
1247        };
1248    };
1249    let state = crate::trust::state_of(root, &bytes);
1250    let lines = parse_lines(&text);
1251    let externals = gate(
1252        lines
1253            .iter()
1254            .filter(|l| l.is_check())
1255            .cloned()
1256            .map(External::from)
1257            .collect(),
1258        state,
1259    );
1260    let trusted = state == crate::trust::State::Trusted;
1261    let pins = if trusted {
1262        lines
1263            .iter()
1264            .filter_map(|l| match l {
1265                Line::Tool(pin) => Some(pin.clone()),
1266                _ => None,
1267            })
1268            .collect()
1269    } else {
1270        Vec::new()
1271    };
1272    let has_policy = lines.iter().any(|l| matches!(l, Line::Policy { .. }));
1273    let (policy, policy_notes) = if trusted {
1274        crate::policy::Policy::from_lines(&lines)
1275    } else {
1276        (crate::policy::Policy::default(), Vec::new())
1277    };
1278    // NOTE: load() computes but never INSTALLS the policy — the entrypoints
1279    // do, immediately after this returns and before any config read. A
1280    // multi-repo walker (the fleet) reads `read_lines` and must never seed
1281    // the process-global store.
1282    let policy_withheld = if has_policy && !trusted {
1283        crate::trust::why(state)
1284    } else {
1285        None
1286    };
1287    Manifest {
1288        externals,
1289        pins,
1290        declared,
1291        policy,
1292        policy_withheld,
1293        policy_notes,
1294    }
1295}
1296
1297/// Check every trusted pin against the tool actually on this machine, and say
1298/// what disagrees. Warn-only, once per hook run, at BOTH stages: skew never
1299/// blocks a commit — its cost is a check disagreeing with CI, and the fix is
1300/// a human decision — but it stops being invisible, which is the whole point.
1301pub fn verify_tool_pins(pins: &[ToolPin]) {
1302    for pin in pins {
1303        match version_of(&pin.program) {
1304            None => crate::hooks::common::warn(&format!(
1305                "{} is pinned to {} in {MANIFEST}, but `{} --version` would not run",
1306                crate::ui::highlight(&pin.program),
1307                crate::ui::sanitize(&pin.want),
1308                crate::ui::sanitize(&pin.program),
1309            )),
1310            Some(v) if !v.contains(&pin.want) => crate::hooks::common::warn(&format!(
1311                "{} reports {} — {MANIFEST} pins {}; this machine may disagree with CI",
1312                crate::ui::highlight(&pin.program),
1313                crate::ui::sanitize(&v),
1314                crate::ui::sanitize(&pin.want),
1315            )),
1316            _ => {}
1317        }
1318    }
1319}
1320
1321/// First line of `<program> --version`, resolved the way every check resolves
1322/// a tool. A version probe answers in milliseconds or not at all, so it is
1323/// deliberately not under the check deadline.
1324fn version_of(program: &str) -> Option<String> {
1325    let out = Command::new(crate::hooks::common::program(program))
1326        .arg("--version")
1327        .stdin(Stdio::null())
1328        .output()
1329        .ok()?;
1330    if !out.status.success() {
1331        return None;
1332    }
1333    let text = String::from_utf8_lossy(&out.stdout);
1334    let line = text.lines().next().unwrap_or("").trim();
1335    if line.is_empty() {
1336        return None;
1337    }
1338    Some(line.to_string())
1339}
1340
1341/// Apply a trust verdict to what the manifest declared.
1342///
1343/// Untrusted declarations are kept and DISABLED, not dropped. The names stay
1344/// visible in `amont list`, in the dashboard and in the "could not run"
1345/// roll-up, because a repository quietly declaring checks that never run is the
1346/// failure this project is arranged against — and the reader needs to know
1347/// there is a decision waiting for them.
1348///
1349/// Split out of [`load`] so the rule can be asserted without a repository on
1350/// disk — and kept split even now that `load` takes an explicit root, because
1351/// a trust verdict is one input among several there and this is the part with
1352/// the rule in it.
1353pub(crate) fn gate(declared: Vec<External>, state: crate::trust::State) -> Vec<External> {
1354    match crate::trust::why(state) {
1355        None => declared,
1356        Some(reason) => declared
1357            .into_iter()
1358            .map(|external| External {
1359                kind: Kind::Unusable {
1360                    why: reason.to_string(),
1361                },
1362                ..external
1363            })
1364            .collect(),
1365    }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371
1372    fn one(text: &str) -> Line {
1373        let mut v = parse_lines(text);
1374        assert_eq!(v.len(), 1, "expected one entry from {text:?}");
1375        v.pop().expect("one")
1376    }
1377
1378    /// The error a line produced, as a VALUE. Tests used to match on the prose,
1379    /// which coupled them to wording and would have kept passing if the wording
1380    /// stayed while the meaning changed.
1381    fn why(l: &Line) -> ParseError {
1382        match l {
1383            Line::Broken { why, .. } => why.clone(),
1384            Line::Usable(d) => panic!("{} parsed when it should not have", d.name),
1385            Line::Tool(pin) => panic!("{} parsed as a pin, not a broken line", pin.program),
1386            Line::Policy { what, .. } => {
1387                panic!("{} parsed as policy, not a broken line", what.describe())
1388            }
1389        }
1390    }
1391
1392    fn usable(l: &Line) -> &Declared {
1393        match l {
1394            Line::Usable(d) => d,
1395            Line::Broken { name, why, .. } => panic!("{name} failed to parse: {why}"),
1396            Line::Tool(pin) => panic!("{} is a tool pin, not a declaration", pin.program),
1397            Line::Policy { what, .. } => {
1398                panic!("{} is policy, not a declaration", what.describe())
1399            }
1400        }
1401    }
1402
1403    /// The scope column's bare tokens are exact filenames — basename-matched,
1404    /// so `package.json` cannot be counterfeited by `not-package.json` — and
1405    /// they mix freely with extensions. Directories and free `*` stay refused.
1406    #[test]
1407    fn a_bare_scope_token_is_an_exact_filename() {
1408        let line = one("pre-commit  lockcheck  package.json  block  ./check.sh\n");
1409        let d = usable(&line);
1410        assert!(d.exts.is_empty());
1411        assert_eq!(d.names, ["package.json"]);
1412
1413        let line = one("pre-commit  x  *.ts,package.json,.prettierrc  block  ./x\n");
1414        let d = usable(&line);
1415        assert_eq!(d.exts, [".ts"]);
1416        assert_eq!(d.names, ["package.json", ".prettierrc"]);
1417
1418        assert_eq!(
1419            why(&one("pre-commit  x  src/package.json  block  ./x\n")),
1420            ParseError::BadScope("src/package.json".into())
1421        );
1422        assert_eq!(
1423            why(&one("pre-commit  x  pkg*  block  ./x\n")),
1424            ParseError::BadScope("pkg*".into())
1425        );
1426    }
1427
1428    /// `+` adds the repository condition a builtin has always had.
1429    ///
1430    /// The separator is the one `amont list` already prints between the two
1431    /// halves (`needs .rs + Cargo.toml`), so what a reader sees in the listing
1432    /// is what they write in the file.
1433    #[test]
1434    fn a_scope_can_name_what_the_repository_must_carry() {
1435        let line = one("pre-commit  rubocop  *.rb+.rubocop.yml  block  rubocop\n");
1436        let d = usable(&line);
1437        assert_eq!(d.exts, [".rb"]);
1438        assert!(d.names.is_empty());
1439        assert_eq!(d.opt_in, [".rubocop.yml"]);
1440
1441        // Both sides comma-separated: any trigger, and any ONE opt-in.
1442        let line = one("pre-commit  x  *.rb,Rakefile+.rubocop.yml,.rubocop_todo.yml  block  ./x\n");
1443        let d = usable(&line);
1444        assert_eq!(d.exts, [".rb"]);
1445        assert_eq!(d.names, ["Rakefile"]);
1446        assert_eq!(d.opt_in, [".rubocop.yml", ".rubocop_todo.yml"]);
1447
1448        // No trigger half: "any change, in a repository that carries this".
1449        let line = one("pre-commit  x  +Gemfile  block  ./x\n");
1450        let d = usable(&line);
1451        assert!(d.exts.is_empty() && d.names.is_empty());
1452        assert_eq!(d.opt_in, ["Gemfile"]);
1453    }
1454
1455    /// Every declaration written before `+` existed keeps its meaning: no
1456    /// opt-in is the old behaviour, which is "applies wherever it triggers".
1457    #[test]
1458    fn a_scope_without_a_plus_is_unchanged() {
1459        let line = one("pre-commit  x  *.ts,package.json  block  ./x\n");
1460        let d = usable(&line);
1461        assert!(d.opt_in.is_empty());
1462        let line = one("pre-commit  x  *  block  ./x\n");
1463        let d = usable(&line);
1464        assert!(d.opt_in.is_empty() && d.exts.is_empty() && d.names.is_empty());
1465    }
1466
1467    /// A `+` that promises a condition and names none is refused, rather than
1468    /// read as "no condition" — which is the opposite of what was reached for.
1469    #[test]
1470    fn a_malformed_opt_in_half_is_refused() {
1471        for bad in [
1472            "*.rb+",            // promised, not given
1473            "*.rb+,",           // still nothing
1474            "*.rb+a+b",         // a second separator is not a filename
1475            "*.rb+sub/dir.yml", // directories are inexpressible, as on the left
1476            "*.rb+*.yml",       // this grammar has no globs to mis-guess
1477        ] {
1478            let line = one(&format!("pre-commit  x  {bad}  block  ./x\n"));
1479            assert!(
1480                matches!(line, Line::Broken { .. }),
1481                "{bad} should be refused, got {line:?}"
1482            );
1483        }
1484    }
1485
1486    /// The pin grammar: exactly three tokens, claimed from a first token that
1487    /// was never a valid stage — no manifest that ever parsed changes meaning.
1488    #[test]
1489    fn a_tool_pin_parses_and_a_malformed_one_is_broken() {
1490        let line = one("tool  ruff  0.6.\n");
1491        assert_eq!(
1492            line,
1493            Line::Tool(ToolPin {
1494                program: "ruff".into(),
1495                want: "0.6.".into()
1496            })
1497        );
1498        assert_eq!(why(&one("tool ruff\n")), ParseError::BadTool);
1499        assert_eq!(why(&one("tool ruff 0.6. extra\n")), ParseError::BadTool);
1500        // `tool` is only a keyword in column one — a check NAMED tool still
1501        // parses as the check it always was.
1502        let line = one("pre-commit  tool  *  block  make tool\n");
1503        assert_eq!(usable(&line).name, "tool");
1504    }
1505
1506    #[test]
1507    fn parses_the_documented_example() {
1508        let v = parse_lines(
1509            "# stage       name        scope   severity  command\n\
1510             pre-commit    lint-shell  *.sh    block     scripts/lint-shell.sh\n\
1511             pre-push      smoke       *       warn      make smoke\n",
1512        );
1513        assert_eq!(v.len(), 2);
1514
1515        let a = usable(&v[0]);
1516        assert_eq!(a.name, "lint-shell");
1517        assert_eq!(a.stage, Stage::PreCommit);
1518        assert_eq!(a.severity, Severity::Block);
1519        assert_eq!(a.program, "scripts/lint-shell.sh");
1520        assert!(a.args.is_empty());
1521        assert_eq!(a.exts, [".sh"]);
1522
1523        let b = usable(&v[1]);
1524        assert_eq!(b.stage, Stage::PrePush);
1525        assert_eq!(b.severity, Severity::Warn);
1526        // A command with arguments is split, not handed to a shell.
1527        assert_eq!(b.program, "make");
1528        assert_eq!(b.args, ["smoke"]);
1529        assert!(b.exts.is_empty(), "`*` gates on nothing");
1530    }
1531
1532    /// Blank lines and comments are not entries, and must not become broken
1533    /// ones — a file that is mostly documentation would otherwise report a
1534    /// dozen gaps.
1535    #[test]
1536    fn comments_and_blank_lines_produce_nothing() {
1537        assert!(parse_lines("\n  \n# just a comment\n\t# indented\n").is_empty());
1538    }
1539
1540    /// The rule the module commits to: a line that cannot be understood still
1541    /// yields a check, so its absence is visible. Matched by VARIANT.
1542    #[test]
1543    fn a_malformed_line_becomes_a_visible_gap() {
1544        let cases: [(&str, ParseError); 4] = [
1545            (
1546                "pre-commit lint-shell *.sh block\n",
1547                ParseError::MissingFields,
1548            ),
1549            (
1550                "nonsense lint-shell *.sh block x\n",
1551                ParseError::BadStage("nonsense".into()),
1552            ),
1553            (
1554                "pre-commit lint-shell ?.sh block x\n",
1555                ParseError::BadScope("?.sh".into()),
1556            ),
1557            (
1558                "pre-commit lint-shell *.sh loud x\n",
1559                ParseError::BadSeverity("loud".into()),
1560            ),
1561        ];
1562        for (text, expected) in cases {
1563            assert_eq!(why(&one(text)), expected, "for {text:?}");
1564        }
1565    }
1566
1567    /// The prose still has to locate the line, even though the tests no longer
1568    /// depend on its wording.
1569    #[test]
1570    fn a_gap_reports_where_it_is() {
1571        let l = one("pre-commit lint-shell *.sh loud x\n");
1572        let said = l.broken().expect("broken");
1573        assert!(said.contains("line 1"), "{said}");
1574        assert!(said.contains("severity"), "{said}");
1575    }
1576
1577    /// `fix` on a pre-push line is refused where every other bad declaration is
1578    /// refused — on every commit, named and located — rather than as a runtime
1579    /// "contract violation" discovered later at push time by fewer people.
1580    #[test]
1581    fn fix_is_refused_on_a_pre_push_line() {
1582        assert_eq!(
1583            why(&one("pre-push  smoke  *  block  fix make smoke\n")),
1584            ParseError::FixOnPrePush
1585        );
1586        // …and accepted on pre-commit.
1587        let line = one("pre-commit  fmt  *  block  fix make format\n");
1588        let declared = usable(&line);
1589        assert_eq!(declared.fix, Fix::Rewrite);
1590        assert_eq!(declared.program, "make");
1591        assert_eq!(declared.args, ["format"]);
1592    }
1593
1594    /// Every manifest written before `fix` existed must still parse the same.
1595    #[test]
1596    fn a_command_that_merely_starts_with_fix_is_not_a_marker() {
1597        let line = one("pre-commit  x  *  block  fixup-tool --check\n");
1598        let declared = usable(&line);
1599        assert_eq!(declared.fix, Fix::None);
1600        assert_eq!(declared.program, "fixup-tool");
1601    }
1602
1603    /// The `files` marker, alone and stacked with `fix` in either order —
1604    /// two markers whose order carries no meaning must not be
1605    /// order-sensitive.
1606    #[test]
1607    fn the_files_marker_parses_alone_and_in_either_order_with_fix() {
1608        let line = one("pre-commit  sc  *.sh  block  files shellcheck\n");
1609        let declared = usable(&line);
1610        assert!(declared.files);
1611        assert_eq!(declared.fix, Fix::None);
1612        assert_eq!(declared.program, "shellcheck");
1613
1614        for text in [
1615            "pre-commit  fmt  *  block  fix files prettier --write\n",
1616            "pre-commit  fmt  *  block  files fix prettier --write\n",
1617        ] {
1618            let line = one(text);
1619            let declared = usable(&line);
1620            assert!(declared.files, "for {text:?}");
1621            assert_eq!(declared.fix, Fix::Rewrite, "for {text:?}");
1622            assert_eq!(declared.program, "prettier", "for {text:?}");
1623            assert_eq!(declared.args, ["--write"], "for {text:?}");
1624        }
1625    }
1626
1627    /// A command that merely starts with `files` keeps its name, exactly as
1628    /// `fixup-tool` keeps its own.
1629    #[test]
1630    fn a_command_that_merely_starts_with_files_is_not_a_marker() {
1631        let line = one("pre-commit  x  *  block  files-checker --strict\n");
1632        let declared = usable(&line);
1633        assert!(!declared.files);
1634        assert_eq!(declared.program, "files-checker");
1635    }
1636
1637    /// A gap with no name cannot be reported, and a line this broken has none.
1638    #[test]
1639    fn a_nameless_line_is_named_after_its_position() {
1640        let l = one("pre-commit\n");
1641        assert_eq!(l.name(), "amont.conf:1");
1642        assert_eq!(why(&l), ParseError::MissingFields);
1643    }
1644
1645    /// An external must not be able to take a built-in's id — it would either
1646    /// shadow `pre-push-branch-protect` or silently lose to it, and neither is
1647    /// something a repository should be able to do by editing a text file.
1648    ///
1649    /// Judged on the id, so the same declaration is refused on one trigger and
1650    /// accepted on the other. That is not a loophole: `pre-push-clippy` is a
1651    /// different check from `pre-commit-clippy`, and nothing is shadowed.
1652    #[test]
1653    fn a_built_in_id_is_refused() {
1654        assert_eq!(
1655            why(&one("pre-commit clippy *.rs block x\n")),
1656            ParseError::NameTaken("clippy".into())
1657        );
1658        assert!(matches!(
1659            one("pre-push clippy *.rs block x\n"),
1660            Line::Usable(_)
1661        ));
1662        // And a pre-push built-in is protected on pre-push, not on pre-commit,
1663        // for the same reason.
1664        assert_eq!(
1665            why(&one("pre-push pull-rebase * block x\n")),
1666            ParseError::NameTaken("pull-rebase".into())
1667        );
1668        assert!(matches!(
1669            one("pre-commit pull-rebase * block x\n"),
1670            Line::Usable(_)
1671        ));
1672    }
1673
1674    /// The stage column says which trigger a line is for. Saying it again in
1675    /// the name is the one way to make an id ambiguous: `pre-commit-clippy` as
1676    /// a NAME is a check whose short name is the built-in's full id, so one
1677    /// `hook.skip` would silence both.
1678    #[test]
1679    fn a_name_that_says_its_own_trigger_is_refused() {
1680        for name in ["pre-commit", "pre-push", "pre-commit-clippy", "pre-push-x"] {
1681            assert_eq!(
1682                why(&one(&format!("pre-commit {name} * block x\n"))),
1683                ParseError::TriggerInName(name.into()),
1684                "{name}"
1685            );
1686        }
1687        // A name that merely begins with the same letters is fine — the trigger
1688        // has to be followed by the separator to count.
1689        assert!(matches!(
1690            one("pre-commit pre-commitish * block x\n"),
1691            Line::Usable(_)
1692        ));
1693    }
1694
1695    /// Two USABLE lines with one ID: the second cannot be addressed by
1696    /// `hook.skip` or by a severity override, so it is refused.
1697    #[test]
1698    fn a_duplicate_id_is_refused() {
1699        let v = parse_lines(
1700            "pre-commit smoke * block a\n\
1701             pre-commit smoke * block b\n",
1702        );
1703        assert_eq!(v.len(), 2);
1704        assert_eq!(usable(&v[0]).id(), "pre-commit-smoke");
1705        assert_eq!(why(&v[1]), ParseError::Duplicate("smoke".into()));
1706    }
1707
1708    /// The same name on both triggers is TWO checks, and this used to refuse
1709    /// the second. Somebody wanting a `show-unicorn` on commit and on push had
1710    /// no way to write it, and no way to skip or downgrade one without the
1711    /// other — the bare name could not tell them apart.
1712    #[test]
1713    fn the_same_name_on_two_triggers_is_allowed() {
1714        let v = parse_lines(
1715            "pre-commit show-unicorn * block a\n\
1716             pre-push   show-unicorn * block b\n",
1717        );
1718        assert_eq!(v.len(), 2);
1719        assert_eq!(usable(&v[0]).id(), "pre-commit-show-unicorn");
1720        assert_eq!(usable(&v[1]).id(), "pre-push-show-unicorn");
1721
1722        // And each is separately addressable, while the short name takes both —
1723        // which is the whole vocabulary, applied to declared checks.
1724        for (id, only) in [
1725            ("pre-commit-show-unicorn", "pre-push-show-unicorn"),
1726            ("pre-push-show-unicorn", "pre-commit-show-unicorn"),
1727        ] {
1728            assert!(crate::skip_suppresses(id, id));
1729            assert!(!crate::skip_suppresses(only, id));
1730        }
1731        assert!(crate::skip_suppresses(
1732            "pre-commit-show-unicorn",
1733            "show-unicorn"
1734        ));
1735        assert!(crate::skip_suppresses(
1736            "pre-push-show-unicorn",
1737            "show-unicorn"
1738        ));
1739        assert!(crate::skip_suppresses(
1740            "pre-commit-show-unicorn",
1741            "pre-commit"
1742        ));
1743        assert!(!crate::skip_suppresses(
1744            "pre-push-show-unicorn",
1745            "pre-commit"
1746        ));
1747    }
1748
1749    /// A line that cannot run does not RESERVE its name.
1750    ///
1751    /// It used to: broken and usable entries shared one list, so a valid
1752    /// declaration was rejected as "declared twice" for colliding with a line
1753    /// that could never execute — pointing the reader at the wrong line, and
1754    /// forcing them to fix the first before the second would work at all.
1755    #[test]
1756    fn a_broken_line_does_not_reserve_its_name() {
1757        let v = parse_lines(
1758            "pre-commit smoke * LOUD   make a\n\
1759             pre-commit smoke * block  make b\n",
1760        );
1761        assert_eq!(v.len(), 2);
1762        assert_eq!(why(&v[0]), ParseError::BadSeverity("LOUD".into()));
1763        let good = usable(&v[1]);
1764        assert_eq!(good.name, "smoke");
1765        assert_eq!(good.program, "make");
1766    }
1767
1768    /// Alignment is cosmetic. A file someone has lined up with tabs, or not
1769    /// lined up at all, must parse identically.
1770    #[test]
1771    fn field_alignment_does_not_matter() {
1772        let spaced = one("pre-commit      lint-shell    *.sh      block     make lint\n");
1773        let tabbed = one("pre-commit\tlint-shell\t*.sh\tblock\tmake lint\n");
1774        assert_eq!(usable(&spaced), usable(&tabbed));
1775        assert_eq!(usable(&spaced).args, ["lint"]);
1776    }
1777
1778    #[test]
1779    fn several_extensions_can_gate_one_check() {
1780        let e = External::from(one("pre-commit shell *.sh,*.bash block make lint\n"));
1781        assert!(e.scope().matches(&["a.bash".into()]));
1782        assert!(e.scope().matches(&["a.sh".into()]));
1783        assert!(!e.scope().matches(&["a.zsh".into()]));
1784    }
1785
1786    /// `tokenise` states its arity in the type, so "four tokens then a command"
1787    /// is checked rather than remembered.
1788    #[test]
1789    fn tokenise_wants_four_fields_and_a_command() {
1790        assert!(tokenise("a b c").is_none(), "too few fields");
1791        assert!(tokenise("a b c d").is_none(), "four fields, no command");
1792        // Trailing whitespace reaches the four fields but still leaves nothing
1793        // to run — the case the `?` on the last field cannot catch.
1794        assert!(
1795            tokenise("a b c d   ").is_none(),
1796            "command is all whitespace"
1797        );
1798        assert!(tokenise("a b c d\t").is_none(), "command is a tab");
1799        let (fields, cmd) = tokenise("a  b\tc   d   run it").expect("four and a command");
1800        assert_eq!(fields, ["a", "b", "c", "d"]);
1801        assert_eq!(cmd, "run it");
1802    }
1803
1804    /// An unusable line carries no command at all — the type has nowhere to put
1805    /// one, which is the point of the split.
1806    #[test]
1807    fn an_unusable_external_holds_no_command() {
1808        let e = External::from(one("pre-commit lint-shell *.sh loud echo hi\n"));
1809        assert!(matches!(e.kind, Kind::Unusable { .. }));
1810        // And it can never block, whatever severity anyone configures.
1811        assert_eq!(e.severity(), Severity::Warn);
1812    }
1813
1814    /// A missing manifest is the normal case and must not be an error.
1815    #[test]
1816    fn a_repository_with_no_manifest_declares_nothing() {
1817        assert!(read(Path::new("/nonexistent-c8f2")).is_empty());
1818        assert!(read_lines(Path::new("/nonexistent-c8f2")).is_empty());
1819    }
1820
1821    /// `Line` exists to spare the dashboard a leak, not to become a second
1822    /// opinion about what a manifest says.
1823    #[test]
1824    fn the_leaking_and_non_leaking_parsers_agree() {
1825        let text = "pre-commit  lint-shell  *.sh,*.bash  block  make lint\n\
1826                    pre-push    smoke       *            warn   make smoke\n\
1827                    pre-commit  broken      ?            block  x\n";
1828        let lines = parse_lines(text);
1829        let externals = parse(text);
1830        assert_eq!(lines.len(), externals.len());
1831        for (l, e) in lines.iter().zip(&externals) {
1832            assert_eq!(l.id(), e.name(), "the id is what a check answers to");
1833            assert_eq!(
1834                l.name(),
1835                e.short_name,
1836                "and the short name is what it is called"
1837            );
1838            assert_eq!(l.stage(), e.stage());
1839            assert_eq!(
1840                l.broken().is_some(),
1841                matches!(e.kind, Kind::Unusable { .. })
1842            );
1843            if let Line::Usable(d) = l {
1844                assert_eq!(d.severity, e.severity());
1845                // The scope the dashboard would DESCRIBE is the scope the
1846                // dispatcher would ENFORCE.
1847                assert_eq!(d.exts, e.scope().files);
1848            }
1849        }
1850    }
1851}