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    /// `set planExempt` with a value other than `on`/`off`, or a second one.
81    /// attest's verifiers read this one line from the committed bytes with a
82    /// tolerant reading that matches only exactly `set planExempt off`, so any
83    /// other shape would silently mean "on" to them: it is loud here instead.
84    BadPlanExempt(String),
85    /// A `pre-push` line asked to rewrite files.
86    ///
87    /// Refused HERE, beside `NameTaken` and `Duplicate`, rather than as a
88    /// runtime "contract violation" at push time: same fact, discovered
89    /// earlier, by more people, at the moment it is cheapest to fix. A pre-push
90    /// hook must not modify the worktree or index — the pushed commit would
91    /// then differ from the tree the developer is looking at.
92    FixOnPrePush,
93    /// `docs-skip` on a pre-push line: a push has no staged change to judge as docs.
94    DocsSkipOnPrePush,
95    /// A `tree` line with the wrong shape; carries what was wrong.
96    BadTreeLine(&'static str),
97    /// A `tree` line naming a tool amont does not know how to attest.
98    BadTreeTool(String),
99    /// A `tree` gate name that cannot become a note's gate token.
100    BadTreeName(String),
101    /// A `tree` option amont does not know, or one whose value is unsafe.
102    BadTreeOption(String),
103    /// `{cache}` on a tool that has no cache, or more than once.
104    BadTreeCache,
105}
106
107impl std::fmt::Display for ParseError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            ParseError::MissingFields => {
111                write!(f, "expected 5 fields: stage name scope severity command")
112            }
113            ParseError::MissingName => write!(f, "missing name"),
114            ParseError::NameTaken(n) => write!(f, "{n:?} already names a check"),
115            ParseError::TriggerInName(n) => write!(
116                f,
117                "{n:?} must not be a trigger or start with one — the stage column says which"
118            ),
119            ParseError::Duplicate(n) => write!(f, "{n:?} is declared twice on one trigger"),
120            ParseError::BadStage(t) => {
121                write!(f, "stage {t:?} must be `pre-commit` or `pre-push`")
122            }
123            ParseError::BadScope(t) => write!(
124                f,
125                "scope {t:?} must be `*`, `*.<ext>`, or a bare filename (no `/`), \
126                 optionally followed by `+<file>` the repository must carry"
127            ),
128            ParseError::BadTool => write!(
129                f,
130                "a tool pin is exactly `tool <program> <version-substring>`"
131            ),
132            ParseError::BadPolicyLine(usage) => write!(f, "a policy line is `{usage}`"),
133            ParseError::BadPlanExempt(why) => write!(f, "set planExempt: {why}"),
134            ParseError::UnsettableKey(k) => write!(
135                f,
136                "set {k:?} is not a policy-settable key — see docs/custom-checks.md"
137            ),
138            ParseError::BadTreeLine(what) => write!(
139                f,
140                "a tree gate is `tree <name> <tool> <scope> attest [cwd=<dir>] \
141                 [inputs=<path>,...] <command>` — {what}"
142            ),
143            ParseError::BadTreeTool(t) => write!(
144                f,
145                "tree tool {t:?} must be one of: {}",
146                TreeTool::ALL
147                    .iter()
148                    .map(|t| t.as_str())
149                    .collect::<Vec<_>>()
150                    .join(", ")
151            ),
152            ParseError::BadTreeName(n) => write!(
153                f,
154                "tree gate name {n:?} must match [A-Za-z0-9][A-Za-z0-9._-]{{0,58}}"
155            ),
156            ParseError::BadTreeOption(o) => write!(
157                f,
158                "tree option {o:?}: only `cwd=<relative dir>` and \
159                 `inputs=<path>,...` (literal paths, no globs, no `..`)"
160            ),
161            ParseError::BadTreeCache => write!(
162                f,
163                "`{{cache}}` may appear once, and only for eslint or prettier"
164            ),
165            ParseError::FixOnPrePush => write!(
166                f,
167                "`fix` is only for pre-commit — a pre-push hook must not rewrite files"
168            ),
169            ParseError::DocsSkipOnPrePush => write!(
170                f,
171                "`docs-skip` is only for pre-commit — a push has no staged change to judge"
172            ),
173            ParseError::BadSeverity(t) => {
174                write!(f, "severity {t:?} must be `block` or `warn`")
175            }
176        }
177    }
178}
179
180/// A line that parsed. Every field means something.
181///
182/// `program` and `args` rather than one `argv`: a runnable check must have a
183/// command, and splitting the head off makes that structural instead of a
184/// `split_first` guard that can only ever be dead code.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Declared {
187    /// `Fix::Rewrite` when the command column began `fix `.
188    pub fix: Fix,
189    /// The command column carried the `files` marker.
190    pub files: bool,
191    /// The command column began `docs-skip ` — do not run on a commit whose
192    /// staged change only edits existing documentation (`staged_docs_only`).
193    pub docs_skip: bool,
194    pub name: String,
195    pub stage: Stage,
196    pub severity: Severity,
197    /// Extensions that gate it. Empty means any change — the `*` scope.
198    pub exts: Vec<String>,
199    /// Exact filenames that gate it — the scope column's bare tokens.
200    pub names: Vec<String>,
201    /// Directory triggers, each `dir/**/*.ext` — see `parse_scope`.
202    pub dirs: Vec<String>,
203    /// Files the REPOSITORY must carry for this check to apply at all — the
204    /// scope column's `+` half. Empty means always applicable, which is what
205    /// every declaration meant before `amont add` made a line something you
206    /// could acquire rather than write.
207    pub opt_in: Vec<String>,
208    pub program: String,
209    pub args: Vec<String>,
210}
211
212impl Declared {
213    /// `<trigger>-<name>`, the same shape a built-in has.
214    ///
215    /// This is what `hook.skip` and `amont.severity.<key>` resolve against,
216    /// so a declared check answers to its trigger and its short name exactly as
217    /// a compiled-in one does. Before it had an id, `hook.skip pre-commit`
218    /// silenced fifteen built-ins and left every declared check running.
219    pub fn id(&self) -> String {
220        format!("{}-{}", self.stage.as_str(), self.name)
221    }
222
223    /// The command as written, for display.
224    pub fn command(&self) -> String {
225        std::iter::once(self.program.as_str())
226            .chain(self.args.iter().map(String::as_str))
227            .collect::<Vec<_>>()
228            .join(" ")
229    }
230}
231
232/// One manifest line: usable, or not.
233///
234/// A SUM, not a struct with an `Option<why>` beside the fields. The struct
235/// form let a broken line carry a severity, a scope and an argv that meant
236/// nothing — and it produced a wrong diagnosis: because broken and usable
237/// entries shared one list, a valid line was rejected as "declared twice" for
238/// colliding with a line that could not run. Dedup now sees only `Usable`.
239///
240/// Separate from `External` because the fleet reads ninety-six manifests and may
241/// re-read them on every refresh, while `External` holds a `Scope` whose
242/// `&'static` slices are LEAKED.
243/// `tool <program> <version-substring>` — a version this repository expects
244/// of a tool its checks drive, so cross-machine skew is a printed fact
245/// instead of "the hook is flaky here". Verified once per hook run, warn-only.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct ToolPin {
248    pub program: String,
249    /// A substring `<program> --version`'s first line must contain — `0.6.`
250    /// pins a minor, `0.6.3` pins a patch. Substring, not semver: the point
251    /// is agreement between machines, not range arithmetic.
252    pub want: String,
253}
254
255/// The tools a `tree` gate may name. The tool is declared, never guessed from
256/// the command: `npm run lint` says nothing about what it runs, and amont needs
257/// to know what `{cache}` expands to and how to read the version the gate
258/// actually executes (ADR-0024, `ci.skip-needs-the-same-command`).
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum TreeTool {
261    Eslint,
262    Prettier,
263    Ruff,
264    Pyright,
265    Gofmt,
266}
267
268impl TreeTool {
269    pub const ALL: &'static [TreeTool] = &[
270        TreeTool::Eslint,
271        TreeTool::Prettier,
272        TreeTool::Ruff,
273        TreeTool::Pyright,
274        TreeTool::Gofmt,
275    ];
276
277    pub fn parse(token: &str) -> Option<TreeTool> {
278        TreeTool::ALL.iter().copied().find(|t| t.as_str() == token)
279    }
280
281    pub fn as_str(self) -> &'static str {
282        match self {
283            TreeTool::Eslint => "eslint",
284            TreeTool::Prettier => "prettier",
285            TreeTool::Ruff => "ruff",
286            TreeTool::Pyright => "pyright",
287            TreeTool::Gofmt => "gofmt",
288        }
289    }
290
291    /// Whether the tool has an on-disk cache `{cache}` can point at. Ruff has
292    /// one too, but it manages it itself and a whole-tree run is already
293    /// sub-second; pyright and gofmt have none.
294    pub fn has_cache(self) -> bool {
295        matches!(self, TreeTool::Eslint | TreeTool::Prettier)
296    }
297}
298
299/// `tree <name> <tool> <scope> attest [cwd=<dir>] [inputs=<path>,...] <command>`
300/// — a whole-tree check whose pass may be ATTESTED so CI skips the step that
301/// runs the same command (ADR-0024).
302///
303/// The declaration IS the CI command. `normalized()` is what the workflow
304/// step's `run:` must equal, character for character; `{cache}` is the only
305/// thing amont may add, and it never changes a verdict. A tree gate never
306/// decides a commit: it is not a pre-commit or pre-push check, so it has no
307/// severity column — `attest` stands where the severity would be, saying what
308/// the line is for.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct TreeGate {
311    pub name: String,
312    pub tool: TreeTool,
313    pub exts: Vec<String>,
314    pub names: Vec<String>,
315    pub dirs: Vec<String>,
316    pub opt_in: Vec<String>,
317    /// The directory the command runs in, relative to the repository root.
318    pub cwd: Option<String>,
319    /// Extra paths that belong to the gate's cache namespace.
320    pub inputs: Vec<String>,
321    /// The command as declared, `{cache}` and all.
322    pub command: String,
323    pub lineno: usize,
324}
325
326/// The placeholder a `tree` command may carry for the tool's cache flags.
327pub const CACHE_PLACEHOLDER: &str = "{cache}";
328
329impl TreeGate {
330    /// The gate token in the attestation note — what CI's `if:` names.
331    pub fn id(&self) -> String {
332        format!("tree-{}", self.name)
333    }
334
335    /// The key a tree proof is stamped under. Distinct from every commit-stamp
336    /// token (short names like `cargo-test`) by its `:`, so no per-commit
337    /// reader can mistake a staged-only pass for a whole-tree one.
338    pub fn stamp_key(&self) -> String {
339        format!("tree:{}", self.name)
340    }
341
342    /// The command CI must run: `{cache}` removed, then a dangling trailing
343    /// `--` (left behind by `npm run lint -- {cache}`), then whitespace
344    /// collapsed.
345    pub fn normalized(&self) -> String {
346        normalize_command(&self.command)
347    }
348}
349
350/// See [`TreeGate::normalized`]. Free so `tree-parity` can normalize a
351/// workflow's `run:` the same way.
352pub fn normalize_command(command: &str) -> String {
353    let mut words: Vec<&str> = command
354        .split_whitespace()
355        .filter(|w| *w != CACHE_PLACEHOLDER)
356        .collect();
357    if words.last() == Some(&"--") {
358        words.pop();
359    }
360    words.join(" ")
361}
362
363/// A committed policy statement about a BUILT-IN (or declared) check — the
364/// team's decision, shipped with the repository, trust-gated like everything
365/// else the manifest says. See `policy`.
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub enum PolicyLine {
368    /// `severity <check|short-name|trigger> warn|block`
369    Severity { target: String, severity: Severity },
370    /// `skip <check|short-name|trigger>`
371    Skip { target: String },
372    /// `set <key> <value>` — a committed default for an allowlisted
373    /// `amont.*` config key. `key` is the FULL git key (`amont.timeout`),
374    /// canonicalised from whatever case the file used, because git keys are
375    /// case-insensitive and a policy file stricter than git is a trap.
376    Set { key: String, value: String },
377}
378
379/// The keys `set` may reach, in canonical spelling. Deliberately absent:
380/// `fix` (a committed file must not change what already-trusted commands may
381/// DO to your working tree — a different consent than "I read these
382/// commands"), `trusted`, `conventions`, `recordBypasses`, `progress`,
383/// `knownIdentity`, and the `severity.*` family (its own line kind).
384pub const SETTABLE: &[&str] = &[
385    "largeFileWarn",
386    "largeFileBlock",
387    "commit.gitmoji",
388    "commit.subjectMax",
389    "commit.descriptionMax",
390    "commit.bodyWrap",
391    "autoRebase",
392    "timeout",
393    // How long a tool may sit in a declared lock wait (ADR-0009). A property
394    // of the project's build — a workspace whose cold check holds the cargo
395    // lock for ten minutes is the project's to know — and, like `timeout`,
396    // only ever lengthens or shortens a wait; it enforces nothing.
397    "lockWait",
398    "testPushedTree",
399    "minVersion",
400    // A snapshot is not a workspace. `testPushedTree` is settable here, so a
401    // repository can say "run the gate on a checkout of the commit" — and
402    // before this it could not say how to make that checkout runnable, which
403    // for a pnpm or npm workspace means an install. The repository is what
404    // knows; requiring every clone to discover `git config
405    // amont.snapshotPrepare` by hand made the declarable half useless.
406    //
407    // Safe on the same terms as a declared check: policy is populated ONLY
408    // when the manifest is trusted, `PolicyLine::describe` puts the command
409    // in front of the person consenting, and any edit revokes that consent.
410    // A local `git config` still outranks it (see `policy`'s ladder), so a
411    // developer can override without touching the file.
412    "snapshotPrepare",
413    // The two halves of making a snapshot runnable that amont does itself.
414    // `snapshotDeps` picks how dependencies arrive (`install`, `reuse`,
415    // `off`); `snapshotCarry` names untracked files copied in from the
416    // working tree — never over tracked content, which is refused. Both are
417    // properties of the project, and trust-gated like `snapshotPrepare`.
418    "snapshotDeps",
419    "snapshotCarry",
420    // Ordering only. `amont.order evidence` permutes the push gates a
421    // repository already runs, using that machine's own record of them; it
422    // adds nothing, removes nothing and skips nothing, so it grants a
423    // committed file no power over what is enforced — which is the line
424    // `fix` and the `severity.*` family are kept on the other side of. Which
425    // ORDER a team's suites are attempted in is a property of the project
426    // (the four-minute suite, the five-second audit), so the project is the
427    // right place to be able to say it; a local `git config` still wins.
428    "order",
429    // Tree gates (ADR-0024). Whether they run, how long they may add to a
430    // commit after its checks, and which ignored paths are the repository's
431    // reproducible outputs rather than inputs a linter could import. None of
432    // them decides a commit; they only decide whether a stamp is earned.
433    "treeLint",
434    "treeLintSlack",
435    "treeLintWait",
436    "treeLintRehearsalTimeout",
437    "snapshotPrepareOutputs",
438    // Whether a closing plan commit may reuse a gate's result (attest 1.5.0,
439    // "Canonical tree"). `off` for a repository whose gate reads the contents
440    // of a plan. It only ever narrows, so attest's verifiers and every producer
441    // read the committed line itself, trusted or not, and the line is strict:
442    // exactly `on` or `off`, once.
443    "planExempt",
444];
445
446impl PolicyLine {
447    /// The one-line rendering the trust prompt shows — consent must see the
448    /// policy it is granting.
449    pub fn describe(&self) -> String {
450        match self {
451            PolicyLine::Severity { target, severity } => {
452                format!("severity  {target}  {}", severity.as_str())
453            }
454            PolicyLine::Skip { target } => format!("skip      {target}"),
455            PolicyLine::Set { key, value } => format!("set       {key}  {value}"),
456        }
457    }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub enum Line {
462    Usable(Declared),
463    /// A tool version pin — carries no check.
464    Tool(ToolPin),
465    /// A policy statement — carries no check either. The line number rides
466    /// along because a "names no check here" note must point somewhere.
467    Policy {
468        what: PolicyLine,
469        lineno: usize,
470    },
471    /// A whole-tree gate that may be attested — carries no check: it never
472    /// decides a commit or a push (ADR-0024).
473    Tree(TreeGate),
474    Broken {
475        /// The declared name, or `<file>:<lineno>` when the line has none — a
476        /// gap has to be nameable to be reportable.
477        name: String,
478        /// Broken lines land on pre-commit unless the stage token parsed: seen
479        /// on every commit beats seen on every push.
480        stage: Stage,
481        lineno: usize,
482        why: ParseError,
483    },
484}
485
486impl Line {
487    pub fn name(&self) -> &str {
488        match self {
489            Line::Usable(d) => &d.name,
490            Line::Tool(pin) => &pin.program,
491            Line::Policy {
492                what: PolicyLine::Severity { target, .. } | PolicyLine::Skip { target },
493                ..
494            } => target,
495            Line::Policy {
496                what: PolicyLine::Set { key, .. },
497                ..
498            } => key,
499            Line::Tree(gate) => &gate.name,
500            Line::Broken { name, .. } => name,
501        }
502    }
503
504    /// Does this line declare a CHECK? `Tool` and `Policy` lines do not, and
505    /// every consumer that projects lines into checks — `parse`, the trust
506    /// prompt, the fleet's declared column — must filter on this rather than
507    /// pattern-match variants it will forget to extend. A `Broken` line IS a
508    /// check (one that runs to Unavailable and says why).
509    pub fn is_check(&self) -> bool {
510        matches!(self, Line::Usable(_) | Line::Broken { .. })
511    }
512    pub fn stage(&self) -> Stage {
513        match self {
514            Line::Usable(d) => d.stage,
515            // A pin has no stage; it is verified at both. The value only
516            // feeds displays that will not ask a pin for one. Policy lines
517            // likewise — `is_check()` keeps both out of anything that would.
518            Line::Tool(_) | Line::Policy { .. } | Line::Tree(_) => Stage::PreCommit,
519            Line::Broken { stage, .. } => *stage,
520        }
521    }
522    /// `Some(reason)` when this line declares a check that cannot run.
523    pub fn broken(&self) -> Option<String> {
524        match self {
525            Line::Usable(_) | Line::Tool(_) | Line::Policy { .. } | Line::Tree(_) => None,
526            Line::Broken { lineno, why, .. } => Some(format!("line {lineno}: {why}")),
527        }
528    }
529
530    /// `<trigger>-<name>`, matching `Declared::id` and `External::id`. A broken
531    /// line has one too: `hook.skip pre-commit` should silence its nag exactly
532    /// as it silences the checks that do run.
533    pub fn id(&self) -> String {
534        format!("{}-{}", self.stage().as_str(), self.name())
535    }
536
537    /// Consume into the identity every line has, and either the declaration or
538    /// the reason there is none.
539    ///
540    /// Both consumers — `External::from` and the fleet's projection — used to
541    /// destructure this by hand, and both carried an arm for a combination the
542    /// type forbids, because they computed the reason BEFORE matching. Written
543    /// once, that arm has nowhere to appear.
544    pub fn into_parts(self) -> (String, Stage, Result<Declared, String>) {
545        let name = self.name().to_string();
546        let stage = self.stage();
547        let parsed = match self {
548            Line::Usable(d) => Ok(d),
549            // Reachable only through a consumer that skipped `is_check()` —
550            // kept total so the type stays honest, worded so a leak is
551            // recognisable in whatever display it lands in.
552            Line::Tool(pin) => Err(format!("tool pin: {} {}", pin.program, pin.want)),
553            Line::Policy { what, .. } => Err(format!("policy: {}", what.describe())),
554            Line::Tree(gate) => Err(format!("tree gate: {} {}", gate.name, gate.command)),
555            Line::Broken { lineno, why, .. } => Err(format!("line {lineno}: {why}")),
556        };
557        (name, stage, parsed)
558    }
559}
560
561/// A check a repository declares, rather than one compiled in.
562pub struct External {
563    /// `<trigger>-<name>` — what `hook.skip` and `amont.severity.<key>`
564    /// resolve against, and what `Check::name` returns. Built-ins have had this
565    /// shape all along; declared checks answering to a bare name were invisible
566    /// to `hook.skip pre-commit`.
567    pub id: String,
568    /// The name as written in the manifest — the "short name" of the vocabulary
569    /// — used for messages. A line too malformed to name itself falls back to
570    /// its position, and reading `pre-commit-amont.conf:3` back to somebody
571    /// helps nobody.
572    pub short_name: String,
573    pub stage: Stage,
574    pub kind: Kind,
575}
576
577/// The two things an external can be. `Scope` and `Severity` live only on the
578/// runnable side, so a broken external cannot carry a severity nobody applies.
579pub enum Kind {
580    Runnable {
581        scope: Scope,
582        severity: Severity,
583        program: String,
584        args: Vec<String>,
585        fix: Fix,
586        /// The command column began `files ` — append the matched paths to
587        /// the argv, the way a builtin hands its tool the staged list.
588        files: bool,
589        /// The command column began `docs-skip ` — see `Declared::docs_skip`.
590        docs_skip: bool,
591    },
592    Unusable {
593        why: String,
594        /// The severity the line declares, when it parsed and only trust is
595        /// withheld: kept for `amont list` to report, never applied. `None`
596        /// for a line that did not parse.
597        declared: Option<Severity>,
598    },
599}
600
601/// The scripts already vouched for against the staged tree, read once per
602/// hook process — a stage's externals run concurrently, and each asking git
603/// itself would be two spawns per check for one answer.
604fn vouched_for_staged_tree() -> &'static std::collections::HashSet<String> {
605    static VOUCHED: std::sync::OnceLock<std::collections::HashSet<String>> =
606        std::sync::OnceLock::new();
607    VOUCHED.get_or_init(crate::gate_stamp::vouched_for_staged_tree)
608}
609
610impl External {
611    /// What the `amont.conf` line declares, trusted or not: reading the file
612    /// applies nothing, so a reader may be told what it says. `None` for a
613    /// line that did not parse.
614    pub fn manifest_severity(&self) -> Option<Severity> {
615        match &self.kind {
616            Kind::Runnable { severity, .. } => Some(*severity),
617            Kind::Unusable { declared, .. } => *declared,
618        }
619    }
620}
621
622impl Check for External {
623    fn name(&self) -> &str {
624        &self.id
625    }
626    fn stage(&self) -> Stage {
627        self.stage
628    }
629    /// Derived for an unusable check rather than stored: it never runs, so its
630    /// scope is a question with no answer, and computing one here keeps the
631    /// DATA from carrying a value that means nothing.
632    fn scope(&self) -> Scope {
633        match &self.kind {
634            Kind::Runnable { scope, .. } => *scope,
635            Kind::Unusable { .. } => Scope::ALWAYS,
636        }
637    }
638    fn severity(&self) -> Severity {
639        match &self.kind {
640            Kind::Runnable { severity, .. } => *severity,
641            // Never consulted: an unusable check reports `Unavailable`, which
642            // no severity can turn into a block.
643            Kind::Unusable { .. } => Severity::Warn,
644        }
645    }
646
647    fn run(&self, ctx: &Ctx) -> Outcome {
648        let settings = ctx.settings;
649        let (scope, program, args, fix, files, docs_skip) = match &self.kind {
650            Kind::Runnable {
651                scope,
652                program,
653                args,
654                fix,
655                files,
656                docs_skip,
657                ..
658            } => (scope, program, args, *fix, *files, *docs_skip),
659            Kind::Unusable { why, .. } => {
660                crate::hooks::common::warn(&format!(
661                    "{MANIFEST}: {} — {}",
662                    crate::ui::highlight(&self.short_name),
663                    // Carries repo tokens: `BadStage("…")` quotes the manifest.
664                    crate::ui::sanitize(why)
665                ));
666                return Outcome::Unavailable;
667            }
668        };
669
670        // The scope gate lives HERE, unlike a built-in's, which enforces its own
671        // in its first three lines. A declared command has no way to know what
672        // was staged, so if this did not gate it, `*.sh` would run on every
673        // commit and the column would be decoration.
674        //
675        // Which files to test against depends on the stage: what is staged for
676        // a commit, what is being pushed for a push. `*` short-circuits before
677        // either is computed, which is the common case.
678        // A check whose whole job is to rewrite has nothing to say when nobody
679        // asked for rewriting — so it does not RUN, rather than running and
680        // having its result discarded. Gating only the re-staging let the
681        // command edit files with `amont.fix` off, which is precisely the
682        // surprise the gate exists to prevent.
683        //
684        // `Unavailable`, not `Passed`. `check.rs` defines `Unavailable` as
685        // "COULD NOT RUN — a tool is missing, or the opt-in config is absent",
686        // which is exactly this; `Passed` is the one verdict it must not
687        // report, because the dispatcher's roll-up and the fleet dashboard
688        // then show a check that never executed as clean. With a message,
689        // because every other `Unavailable` in this codebase says what was
690        // missing and an unexplained count on every commit is worse than none.
691        if fix == Fix::Rewrite && !crate::hooks::common::fixing_enabled(settings) {
692            crate::hooks::common::warn(&format!(
693                "{}: declares fix, and {} is off — not run",
694                crate::ui::highlight(&self.short_name),
695                crate::ui::highlight("amont.fix")
696            ));
697            return Outcome::Unavailable;
698        }
699
700        // Two questions, two path lists. `touches` asks what the CHANGE
701        // contains; `opted_in` asks what the REPOSITORY carries, and answering
702        // the second from the change set made a `+marker` row fire only when
703        // the marker itself was staged — so a pack that followed our own
704        // advice to gate every row was a pack that never ran. `tracked_files`
705        // returning `None` means git would not say, and an unverified answer
706        // must not silently switch a check off: run it, and let the command
707        // judge.
708        let opted_in =
709            crate::hooks::common::tracked_files().map_or(true, |tracked| scope.opted_in(&tracked));
710        if !opted_in {
711            return Outcome::Passed;
712        }
713        let root = crate::hooks::common::repo_root();
714        match self.stage {
715            Stage::PreCommit => {
716                let staged = crate::hooks::common::staged_files(&[]);
717                // The declaration's own bet is that an edit to existing
718                // documentation does not change what this gate judges. Said
719                // out loud, like every other skip that is a decision and not a
720                // missing tool.
721                //
722                // `Inert`, not `Passed`: a bet is not a verdict. `Passed` is
723                // what the stamp and the push-side pairing read as "this gate
724                // ran clean on this tree", and a stamp for a run that never
725                // happened would let a same-named `pre-push` declaration defer
726                // to it, or an attestation tell CI the gate is covered. Left
727                // unstamped, the push gate runs it as it would have.
728                if docs_skip && crate::hooks::common::staged_docs_only() {
729                    crate::say!(
730                        "{} {} skipped — the commit only edits existing documentation",
731                        crate::ui::valid_sign(),
732                        crate::ui::highlight(&self.short_name),
733                    );
734                    return Outcome::Inert;
735                }
736                let Some(matched) = files_to_judge(scope, files, &staged) else {
737                    return Outcome::Passed;
738                };
739                // A blocking gate that already ran clean against exactly this
740                // staged tree is not run again: the attempt that ran it never
741                // reached post-commit (commit-msg refused the subject, the
742                // editor was closed on the message) or the same tree is
743                // being re-committed. Said out loud, in the push gate's
744                // words, and answered as `Passed` so pre-commit records the
745                // marker again and post-commit binds it as usual. What can
746                // and cannot be vouched for: `gate_stamp::vouched_for_staged_tree`.
747                if self.severity() == Severity::Block
748                    && crate::gate_stamp::commit_stamps_enabled(settings)
749                    && crate::gate_identity::external_token(self, &self.short_name)
750                        .is_some_and(|t| vouched_for_staged_tree().contains(&t))
751                {
752                    crate::say!(
753                        "{} {} passed on this exact tree earlier — not repeating it here",
754                        crate::ui::valid_sign(),
755                        crate::ui::highlight(&self.short_name),
756                    );
757                    return Outcome::Passed;
758                }
759                self.execute(
760                    settings,
761                    program,
762                    args,
763                    fix,
764                    files,
765                    &matched,
766                    Path::new(&root),
767                )
768            }
769            // PER REF, and — when `amont.testPushedTree` asks for it — in a
770            // checkout of that ref's tip, exactly as the built-in suites do
771            // and for the same reason: a declared `test *.rs block cargo
772            // test` line used to run once, in the working tree, whatever the
773            // flag said. It passed on an uncommitted fix, and `stamp_tips`
774            // then read the flag and stamped it onto the tip as though it had
775            // run there, so the next push of that tip skipped it. The flag is
776            // a promise about what a push gate tests; a declared gate is a
777            // push gate.
778            Stage::PrePush => {
779                let zero = crate::git::stdout(&["hash-object", "--stdin"])
780                    .map(|h| "0".repeat(h.len()))
781                    .unwrap_or_else(|| "0".repeat(40));
782                for r in ctx.push.get() {
783                    if r.local_oid == zero {
784                        continue; // deleting a ref pushes no code
785                    }
786                    let changed = crate::pushrefs::changed_files_for(r, &zero);
787                    let Some(matched) = files_to_judge(scope, files, &changed) else {
788                        continue;
789                    };
790                    // `_guard` owns the checkout for the length of this ref's
791                    // run; dropping it removes the worktree before the next
792                    // ref's begins. `self.id`, because that is what a stamp
793                    // names and what `stamp_tips` asks `ran_on_tip` about.
794                    let (run_in, _guard) =
795                        crate::pushed_tree::where_to_run(settings, &r.local_oid, &root, &self.id);
796                    match self.execute(settings, program, args, fix, files, &matched, &run_in) {
797                        Outcome::Passed => {}
798                        other => return other,
799                    }
800                }
801                Outcome::Passed
802            }
803        }
804    }
805}
806
807/// The paths a declared check is handed for `in_scope`, or `None` when it
808/// has nothing to judge and must not run.
809///
810/// No `is_unscoped()` guard: `touches` already answers true for an unscoped
811/// trigger, so the gate reduces to the opt-in the caller has already
812/// settled — which is what a row with nothing on the left (`+Gemfile`: "any
813/// change, in a repository that carries a Gemfile") means. Guarding on it
814/// here skipped the opt-in for exactly those rows.
815///
816/// The paths are what the declaration's scope actually matched — what a
817/// builtin would be handed. A files-taking command with no files has
818/// nothing to judge — running it bare would make most linters error on an
819/// empty argv, blocking a commit over nothing. The builtin convention,
820/// applied here.
821fn files_to_judge(scope: &Scope, files: bool, in_scope: &[String]) -> Option<Vec<String>> {
822    if !scope.touches(in_scope) {
823        return None;
824    }
825    let matched = scoped(scope, in_scope);
826    if files && matched.is_empty() {
827        return None;
828    }
829    Some(matched)
830}
831
832impl External {
833    /// Spawn the declared command over `matched`, in `run_in`, and judge it.
834    ///
835    /// `matched` reaches the command two ways: `$AMONT_FILES` always
836    /// (newline-separated, so a wrapper script never re-derives `git diff
837    /// --cached` and diverges from the set this gate judged — `amont run
838    /// --all-files` overrides the set in-process, invisibly to any child that
839    /// asks git itself), and appended to the argv when the declaration
840    /// carries the `files` marker.
841    #[allow(clippy::too_many_arguments)]
842    fn execute(
843        &self,
844        settings: &crate::config::Settings,
845        program: &str,
846        args: &[String],
847        fix: Fix,
848        files: bool,
849        matched: &[String],
850        run_in: &Path,
851    ) -> Outcome {
852        // Through `program()`, exactly like every builtin: on Windows,
853        // `Command::new("npx")` cannot start `npx.cmd`, and an external that
854        // fails to SPAWN reports Unavailable — warn, never block — so the
855        // check would silently never run. The guard test that enforces this
856        // for builtins scans only `src/hooks/`; this call is the manifest's
857        // half of the same rule.
858        let mut cmd = Command::new(crate::hooks::common::program(program));
859        cmd.args(args).current_dir(run_in).stdin(Stdio::null());
860        // Env, not only argv: newline-separated so ordinary shell loops can
861        // read it. A path with a newline in its name would split wrong — the
862        // list is repo-controlled and such a path is already hostile input —
863        // and a change set too large for an environment variable (rare, but
864        // E2BIG kills the spawn outright) travels as an empty variable
865        // instead, which a wrapper treats exactly like "derive it yourself".
866        let joined = matched.join("\n");
867        cmd.env(
868            "AMONT_FILES",
869            if joined.len() <= 100_000 {
870                joined.as_str()
871            } else {
872                ""
873            },
874        );
875        if files {
876            cmd.args(matched);
877        }
878        crate::hooks::common::strip_git_env(&mut cmd);
879        // Under the deadline: repo-authored code that outlives the budget is
880        // killed and FAILS — "hung" must not read as "passed", and pre-push
881        // runs these serially where one hang stalls the entire push.
882        let status = match crate::hooks::common::status_streamed(
883            settings,
884            &mut cmd,
885            crate::hooks::common::Retry::Never,
886        ) {
887            Ok(crate::hooks::common::Ran::Status(s)) => Ok(s),
888            Ok(crate::hooks::common::Ran::TimedOut(budget)) => {
889                crate::hooks::common::say_timed_out(&self.short_name, budget);
890                return Outcome::Failed;
891            }
892            Err(e) => Err(e),
893        };
894        match status {
895            // A command that could not be STARTED has not judged anything. This
896            // is the distinction `Outcome` was added for: reporting a missing
897            // `shellcheck` as a lint failure sends someone hunting for a lint
898            // error that does not exist.
899            Err(e) => {
900                crate::hooks::common::warn(&format!(
901                    "{MANIFEST}: {} could not run {} — {}",
902                    crate::ui::highlight(&self.short_name),
903                    crate::ui::highlight(program),
904                    // The io error's text embeds the program name it tried.
905                    crate::ui::sanitize(&e.to_string())
906                ));
907                Outcome::Unavailable
908            }
909            Ok(s) if s.success() => {
910                // A declared fixer that ran clean may still have rewritten
911                // something; re-stage exactly what moved. Only its own scope,
912                // so it cannot stage a file it never looked at.
913                if fix == Fix::Rewrite && crate::hooks::common::fixing_enabled(settings) {
914                    match crate::hooks::common::restage(matched) {
915                        Restaged::Staged => {
916                            crate::hooks::common::ok(
917                                settings,
918                                &format!(
919                                    "{} fixed and re-staged",
920                                    crate::ui::highlight(&self.short_name)
921                                ),
922                            );
923                            return Outcome::Fixed;
924                        }
925                        // `git add` failed, so the index still holds whatever
926                        // the command has already replaced on disk. This used
927                        // to be indistinguishable from "nothing moved" and was
928                        // reported as a pass.
929                        Restaged::Failed(stuck) => {
930                            crate::hooks::common::fail(&format!(
931                                "{} rewrote files but {} failed — the index still holds the \
932                                 OLD content: {}",
933                                crate::ui::highlight(&self.short_name),
934                                crate::ui::highlight("git add"),
935                                crate::ui::sanitize(&stuck.join(", "))
936                            ));
937                            return Outcome::Failed;
938                        }
939                        Restaged::Nothing => {}
940                    }
941                }
942                Outcome::Passed
943            }
944            Ok(_) => {
945                crate::hooks::common::fail(&format!(
946                    "{} failed (output above)",
947                    crate::ui::highlight(&self.short_name)
948                ));
949                Outcome::Failed
950            }
951        }
952    }
953}
954
955/// The paths this check's scope actually covers.
956fn scoped(scope: &Scope, paths: &[String]) -> Vec<String> {
957    if scope.is_unscoped() {
958        return paths.to_vec();
959    }
960    paths.iter().filter(|p| scope.covers(p)).cloned().collect()
961}
962
963/// `Scope` holds `&'static` slices so a built-in can be a `const`. A parsed
964/// manifest has neither, so its extension list is leaked.
965///
966/// This is bounded and deliberate: the manifest is read at most once per
967/// process, holds a handful of short strings, and the process is a git hook that
968/// exits in milliseconds. The alternative — a lifetime on `Scope` — would
969/// propagate through the trait, both dispatchers and the fleet crate to buy back
970/// a few hundred bytes that the kernel reclaims moments later.
971fn leak(exts: Vec<String>) -> &'static [&'static str] {
972    let refs: Vec<&'static str> = exts
973        .into_iter()
974        .map(|s| &*Box::leak(s.into_boxed_str()))
975        .collect();
976    Box::leak(refs.into_boxed_slice())
977}
978
979/// `*` means any change; `*.sh` or `*.sh,*.bash` gate on extensions; and
980/// `<triggers>+<opt-ins>` adds the repository condition a builtin has.
981///
982/// # `+`, and why there is now an opt-in at all
983///
984/// This used to read: *"No `opt_in` counterpart, because the manifest IS the
985/// opt-in: a repository that does not want the check deletes the line."* That
986/// was true while every line was one somebody typed. `amont add` ended it — a
987/// vendored line is in your manifest because you took a whole PACK, not
988/// because you chose that tool, so a packaged `rubocop` fired on every `.rb`
989/// in a repository with no rubocop config and simply errored. That is the
990/// "ninety-five nags" `hooks::yamllint` documents avoiding, which is a
991/// courtesy every builtin has had and no declaration could express.
992///
993/// The separator is `+` because that is what `amont list` already PRINTS
994/// between the two halves — `inert here — needs .rs + Cargo.toml`. What you
995/// read in the listing is what you write in the file. No spaces: the column
996/// grammar splits on whitespace.
997///
998/// Both sides are comma-separated, and each side is an OR — any trigger, and
999/// any one opt-in. Between the sides it is an AND, matching `Scope`'s own
1000/// semantics.
1001///
1002/// Splitting on the FIRST `+` means a filename containing one is not
1003/// expressible. It is stated in `docs/custom-checks.md` rather than worked
1004/// around with quoting this grammar deliberately does not have.
1005///
1006/// Returns owned strings rather than a `Scope`, so validating a manifest
1007/// costs nothing permanent. Only `External::from` turns these into the
1008/// `&'static` form `Scope` requires.
1009#[derive(Debug, Default, PartialEq, Eq)]
1010struct ParsedScope {
1011    exts: Vec<String>,
1012    names: Vec<String>,
1013    dirs: Vec<String>,
1014    opt_in: Vec<String>,
1015}
1016
1017/// A directory trigger: `dir/**/*.ext`. The directory is whole segments with no
1018/// glob characters, so `claude-plugin/**/*.md` is in and `claude-*/**/*.md`,
1019/// `/abs/**/*.md`, `a//b/**/*.md` and `../x/**/*.md` are out. The extension is
1020/// a plain suffix. Nothing else in the grammar gets a `/` or a glob.
1021fn is_dir_token(part: &str) -> bool {
1022    let Some((dir, ext)) = part.split_once("/**/*") else {
1023        return false;
1024    };
1025    !dir.is_empty()
1026        && dir.split('/').all(|seg| {
1027            !seg.is_empty() && seg != "." && seg != ".." && !seg.contains(['*', '?', '['])
1028        })
1029        && ext.len() > 1
1030        && ext.starts_with('.')
1031        && !ext.contains(['*', '?', '[', '/'])
1032}
1033
1034fn parse_scope(token: &str) -> Result<ParsedScope, ParseError> {
1035    let (triggers, opt_in) = match token.split_once('+') {
1036        None => (token, Vec::new()),
1037        Some((left, right)) => {
1038            // `*.rb+` promises a condition and states none. Refused rather
1039            // than read as "no condition", which is the opposite of what the
1040            // author was reaching for.
1041            let opts: Vec<String> = right
1042                .split(',')
1043                .filter(|p| !p.is_empty())
1044                .map(str::to_string)
1045                .collect();
1046            if opts.is_empty() || right.contains('+') {
1047                return Err(ParseError::BadScope(token.to_string()));
1048            }
1049            for o in &opts {
1050                if o.contains(['*', '?', '[', '/']) {
1051                    return Err(ParseError::BadScope(o.clone()));
1052                }
1053            }
1054            // `+cfg` with nothing on the left is "any change, if this repo has
1055            // cfg" — a real shape, and `*` is how the trigger half spells
1056            // "anything".
1057            (if left.is_empty() { "*" } else { left }, opts)
1058        }
1059    };
1060    if triggers == "*" {
1061        return Ok(ParsedScope {
1062            opt_in,
1063            ..ParsedScope::default()
1064        });
1065    }
1066    let mut exts = Vec::new();
1067    let mut names = Vec::new();
1068    let mut dirs = Vec::new();
1069    for part in triggers.split(',') {
1070        if let Some(ext) = part.strip_prefix('*').filter(|ext| ext.starts_with('.')) {
1071            exts.push(ext.to_string());
1072            continue;
1073        }
1074        if part.contains("/**/*") {
1075            if is_dir_token(part) {
1076                dirs.push(part.to_string());
1077                continue;
1078            }
1079            return Err(ParseError::BadScope(part.to_string()));
1080        }
1081        // A bare token is an exact FILENAME — `package.json`, `Dockerfile`,
1082        // `.prettierrc` — matched against the basename. A directory is only
1083        // ever `dir/**/*.ext`, handled above; any other `/`, and anything that
1084        // LOOKS like a glob (`*`, `?`, `[`), is refused as the typo it almost
1085        // certainly is — this grammar has no globs to mis-guess.
1086        if !part.is_empty() && !part.contains(['*', '?', '[', '/']) {
1087            names.push(part.to_string());
1088            continue;
1089        }
1090        return Err(ParseError::BadScope(part.to_string()));
1091    }
1092    Ok(ParsedScope {
1093        exts,
1094        names,
1095        dirs,
1096        opt_in,
1097    })
1098}
1099
1100fn parse_stage(token: &str) -> Option<Stage> {
1101    match token {
1102        "pre-commit" => Some(Stage::PreCommit),
1103        "pre-push" => Some(Stage::PrePush),
1104        _ => None,
1105    }
1106}
1107
1108/// An identity already spoken for. An external must not be able to shadow
1109/// `pre-push-branch-protect` — nor silently lose to it, which is what a
1110/// first-match lookup would do without this.
1111///
1112/// Judged on the ID, which is why `clippy` on `pre-push` is now legal: it is
1113/// `pre-push-clippy`, a different check from `pre-commit-clippy`. Judged on the
1114/// bare name, as it was, the two collided and the second was refused.
1115///
1116fn name_is_taken(id: &str) -> bool {
1117    CHECKS.iter().any(|c| c.name == id) || ENTRYPOINTS.iter().any(|(n, _)| *n == id)
1118}
1119
1120/// A short name that says its own trigger — either by being one, or by starting
1121/// with one.
1122///
1123/// Both make an id ambiguous rather than merely ugly. `pre-commit` as a name
1124/// gives `hook.skip pre-commit` two readings; `pre-commit-clippy` as a name
1125/// gives a check whose SHORT name is the built-in's FULL id, so one skip
1126/// silences both. The stage column already says which trigger this is.
1127fn name_says_its_trigger(name: &str) -> bool {
1128    crate::TRIGGERS
1129        .iter()
1130        .any(|t| name == *t || name.starts_with(&format!("{t}-")))
1131}
1132
1133/// The four leading tokens and the untouched remainder, or `None` when the line
1134/// does not have them.
1135///
1136/// The arity is in the TYPE. Returning a `Vec` made "four or it is malformed"
1137/// a rule every caller had to remember and none could be checked against.
1138///
1139/// NOT `splitn(5, char::is_whitespace)`: that splits at the FIRST whitespace
1140/// character every time, so a file aligned into columns — which is how the
1141/// format invites you to write it — yields empty fields for every run of
1142/// spaces after the first.
1143fn tokenise(line: &str) -> Option<([&str; 4], &str)> {
1144    let mut fields: [&str; 4] = [""; 4];
1145    let mut rest = line;
1146    for slot in fields.iter_mut() {
1147        rest = rest.trim_start();
1148        let i = rest.find(char::is_whitespace)?;
1149        *slot = &rest[..i];
1150        rest = &rest[i..];
1151    }
1152    let command = rest.trim();
1153    (!command.is_empty()).then_some((fields, command))
1154}
1155
1156pub fn parse_lines(text: &str) -> Vec<Line> {
1157    let mut out: Vec<Line> = Vec::new();
1158    for (i, raw) in text.lines().enumerate() {
1159        let line = raw.trim();
1160        if line.is_empty() || line.starts_with('#') {
1161            continue;
1162        }
1163        let lineno = i + 1;
1164        let mut parsed = parse_line(lineno, line, &out);
1165        // `trim` and `is_whitespace` are Unicode; attest's verifiers read the
1166        // raw line with ASCII blanks only. A line this parser takes for
1167        // `planExempt off` that they would read as "on" (a form feed, a
1168        // non-breaking space) is loud here, never a silent opt-out that is not.
1169        if matches!(&parsed, Line::Policy { what: PolicyLine::Set { key, value }, .. }
1170            if key == "amont.planExempt" && value.eq_ignore_ascii_case("off"))
1171            && !crate::attest_inputs::conf_says_plan_exempt_off(raw.as_bytes())
1172        {
1173            parsed = broken_at(
1174                lineno,
1175                format!("{MANIFEST}:{lineno}"),
1176                None,
1177                ParseError::BadPlanExempt("write it with plain spaces or tabs".to_string()),
1178            );
1179        }
1180        out.push(parsed);
1181    }
1182    out
1183}
1184
1185/// One line, given the lines already accepted.
1186///
1187/// `earlier` is read ONLY for the duplicate check, and only its `Usable`
1188/// entries — a line that cannot run does not reserve its name. Before this, a
1189/// valid declaration was rejected as "declared twice" for colliding with a
1190/// broken one, which pointed the reader at the wrong line entirely.
1191fn parse_line(lineno: usize, line: &str, earlier: &[Line]) -> Line {
1192    // `tool` was never a valid stage, so claiming it as a keyword breaks no
1193    // manifest that ever parsed. Handled before `tokenise`, whose five-column
1194    // shape a three-token pin does not have.
1195    if line == "tool" || line.starts_with("tool ") || line.starts_with("tool\t") {
1196        let mut it = line.split_whitespace().skip(1);
1197        return match (it.next(), it.next(), it.next()) {
1198            (Some(program), Some(want), None) => Line::Tool(ToolPin {
1199                program: program.to_string(),
1200                want: want.to_string(),
1201            }),
1202            _ => broken_at(
1203                lineno,
1204                format!("{MANIFEST}:{lineno}"),
1205                None,
1206                ParseError::BadTool,
1207            ),
1208        };
1209    }
1210    // `severity` and `skip` were never valid stages either — the same
1211    // keyword claim `tool` made. Fixed arity; anything else is a positional
1212    // gap, never a phantom check named after its target (a broken
1213    // `severity clippy loud` must not mint `pre-commit-clippy`).
1214    if line == "severity" || line.starts_with("severity ") || line.starts_with("severity\t") {
1215        let mut it = line.split_whitespace().skip(1);
1216        return match (it.next(), it.next(), it.next()) {
1217            (Some(target), Some(word), None) => match Severity::parse(word) {
1218                Some(severity) => Line::Policy {
1219                    what: PolicyLine::Severity {
1220                        target: target.to_string(),
1221                        severity,
1222                    },
1223                    lineno,
1224                },
1225                None => broken_at(
1226                    lineno,
1227                    format!("{MANIFEST}:{lineno}"),
1228                    None,
1229                    ParseError::BadSeverity(word.to_string()),
1230                ),
1231            },
1232            _ => broken_at(
1233                lineno,
1234                format!("{MANIFEST}:{lineno}"),
1235                None,
1236                ParseError::BadPolicyLine("severity <check> warn|block"),
1237            ),
1238        };
1239    }
1240    if line == "skip" || line.starts_with("skip ") || line.starts_with("skip\t") {
1241        let mut it = line.split_whitespace().skip(1);
1242        return match (it.next(), it.next()) {
1243            (Some(target), None) => Line::Policy {
1244                what: PolicyLine::Skip {
1245                    target: target.to_string(),
1246                },
1247                lineno,
1248            },
1249            _ => broken_at(
1250                lineno,
1251                format!("{MANIFEST}:{lineno}"),
1252                None,
1253                ParseError::BadPolicyLine("skip <check>"),
1254            ),
1255        };
1256    }
1257    // `tree` was never a valid stage either. Its own shape: it carries a
1258    // command but decides nothing, so it gets no severity column.
1259    if line == "tree" || line.starts_with("tree ") || line.starts_with("tree\t") {
1260        return parse_tree_line(lineno, line, earlier);
1261    }
1262    if line == "set" || line.starts_with("set ") || line.starts_with("set\t") {
1263        // The value is the REST of the line, not a third token. Every key
1264        // settable before this took a single word — `true`, `72`, a version —
1265        // so splitting on whitespace was indistinguishable from splitting
1266        // once. `snapshotPrepare` is a shell command, and a rule that only
1267        // ever admits one-word values cannot express it.
1268        //
1269        // A trailing `#` stays part of the value, exactly as it does in a
1270        // check's command: comments here are whole-line only (see the caller).
1271        let rest = line[3..].trim();
1272        let split = rest
1273            .split_once(char::is_whitespace)
1274            .map(|(k, v)| (k, v.trim()))
1275            .filter(|(_, v)| !v.is_empty());
1276        return match split {
1277            Some((key, value)) => {
1278                // Case-insensitive against the allowlist, canonical spelling
1279                // stored — `set commit.subjectmax 72` must work, because git
1280                // would have accepted the key in any case.
1281                match SETTABLE.iter().find(|k| k.eq_ignore_ascii_case(key)) {
1282                    Some(canonical) if *canonical == "planExempt" => {
1283                        if !(value.eq_ignore_ascii_case("on") || value.eq_ignore_ascii_case("off"))
1284                        {
1285                            broken_at(
1286                                lineno,
1287                                format!("{MANIFEST}:{lineno}"),
1288                                None,
1289                                ParseError::BadPlanExempt(format!(
1290                                    "the value is `on` or `off`, not {value:?}"
1291                                )),
1292                            )
1293                        } else if earlier.iter().any(|l| {
1294                            matches!(l, Line::Policy { what: PolicyLine::Set { key, .. }, .. }
1295                                if key == "amont.planExempt")
1296                        }) {
1297                            broken_at(
1298                                lineno,
1299                                format!("{MANIFEST}:{lineno}"),
1300                                None,
1301                                ParseError::BadPlanExempt("it is set twice".to_string()),
1302                            )
1303                        } else {
1304                            Line::Policy {
1305                                what: PolicyLine::Set {
1306                                    key: format!("amont.{canonical}"),
1307                                    value: value.to_string(),
1308                                },
1309                                lineno,
1310                            }
1311                        }
1312                    }
1313                    Some(canonical) => Line::Policy {
1314                        what: PolicyLine::Set {
1315                            key: format!("amont.{canonical}"),
1316                            value: value.to_string(),
1317                        },
1318                        lineno,
1319                    },
1320                    None => broken_at(
1321                        lineno,
1322                        format!("{MANIFEST}:{lineno}"),
1323                        None,
1324                        ParseError::UnsettableKey(key.to_string()),
1325                    ),
1326                }
1327            }
1328            None => broken_at(
1329                lineno,
1330                format!("{MANIFEST}:{lineno}"),
1331                None,
1332                ParseError::BadPolicyLine("set <key> <value>"),
1333            ),
1334        };
1335    }
1336    let (fields, command) = match tokenise(line) {
1337        Some(t) => t,
1338        // No name to report: fall back to the position, which is the only
1339        // handle a reader has on a line this malformed.
1340        None => {
1341            return broken_at(
1342                lineno,
1343                name_or_position(tokenise(line).map(|(f, _)| f[1]).unwrap_or(""), lineno),
1344                None,
1345                ParseError::MissingFields,
1346            )
1347        }
1348    };
1349    let [stage_tok, declared, scope_tok, severity_tok] = fields;
1350    let stage = parse_stage(stage_tok);
1351    let name = name_or_position(declared, lineno);
1352    let fail = |why| broken_at(lineno, name.clone(), stage, why);
1353
1354    if declared.is_empty() {
1355        return fail(ParseError::MissingName);
1356    }
1357    // The stage is settled BEFORE the name is judged, because the identity
1358    // being judged is `<trigger>-<name>` and there is no such thing without a
1359    // trigger. Ordered the other way, a line with an unusable stage was refused
1360    // for a name clash that could not be assessed yet.
1361    let Some(stage) = stage else {
1362        return fail(ParseError::BadStage(stage_tok.to_string()));
1363    };
1364    if name_says_its_trigger(declared) {
1365        return fail(ParseError::TriggerInName(declared.to_string()));
1366    }
1367    let id = format!("{}-{}", stage.as_str(), declared);
1368    if name_is_taken(&id) {
1369        return fail(ParseError::NameTaken(declared.to_string()));
1370    }
1371    if earlier
1372        .iter()
1373        .any(|l| matches!(l, Line::Usable(d) if d.id() == id))
1374    {
1375        return fail(ParseError::Duplicate(declared.to_string()));
1376    }
1377    let ParsedScope {
1378        exts,
1379        names,
1380        dirs,
1381        opt_in,
1382    } = match parse_scope(scope_tok) {
1383        Ok(e) => e,
1384        Err(why) => return fail(why),
1385    };
1386    let Some(severity) = Severity::parse(severity_tok) else {
1387        return fail(ParseError::BadSeverity(severity_tok.to_string()));
1388    };
1389    // `fix` and `files` are leading markers on the command column rather
1390    // than extra fields, so every manifest written before them still parses.
1391    // A loop, so `fix files cmd` and `files fix cmd` both work — two markers
1392    // whose order carries no meaning must not be order-sensitive.
1393    let mut command = command;
1394    let mut wants_fix = false;
1395    let mut wants_files = false;
1396    let mut wants_docs_skip = false;
1397    loop {
1398        if let Some(rest) = command.strip_prefix("fix ") {
1399            command = rest.trim_start();
1400            wants_fix = true;
1401            continue;
1402        }
1403        if let Some(rest) = command.strip_prefix("files ") {
1404            command = rest.trim_start();
1405            wants_files = true;
1406            continue;
1407        }
1408        if let Some(rest) = command.strip_prefix("docs-skip ") {
1409            command = rest.trim_start();
1410            wants_docs_skip = true;
1411            continue;
1412        }
1413        break;
1414    }
1415    if wants_fix && stage == Stage::PrePush {
1416        return fail(ParseError::FixOnPrePush);
1417    }
1418    if wants_docs_skip && stage == Stage::PrePush {
1419        return fail(ParseError::DocsSkipOnPrePush);
1420    }
1421    // `tokenise` guarantees a non-empty command, so the split cannot fail.
1422    let mut argv = command.split_whitespace().map(str::to_owned);
1423    let Some(program) = argv.next() else {
1424        return fail(ParseError::MissingFields);
1425    };
1426    Line::Usable(Declared {
1427        fix: if wants_fix { Fix::Rewrite } else { Fix::None },
1428        files: wants_files,
1429        docs_skip: wants_docs_skip,
1430        name: declared.to_string(),
1431        stage,
1432        severity,
1433        exts,
1434        names,
1435        dirs,
1436        opt_in,
1437        program,
1438        args: argv.collect(),
1439    })
1440}
1441
1442/// The first whitespace-separated token of `s`, and what follows it untouched.
1443fn split_token(s: &str) -> Option<(&str, &str)> {
1444    let s = s.trim_start();
1445    if s.is_empty() {
1446        return None;
1447    }
1448    let end = s.find(char::is_whitespace).unwrap_or(s.len());
1449    Some((&s[..end], &s[end..]))
1450}
1451
1452/// A gate name the attestation note can carry: the note's token grammar is
1453/// `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` and `tree-` spends five of it.
1454fn tree_name_valid(name: &str) -> bool {
1455    let mut chars = name.chars();
1456    matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric())
1457        && name.len() <= 59
1458        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1459}
1460
1461/// A path a `cwd=` or `inputs=` may name: relative, literal, inside the tree.
1462fn tree_path_safe(path: &str) -> bool {
1463    !path.is_empty()
1464        && !path.starts_with('/')
1465        && !path.contains(['*', '?', '[', '\\'])
1466        && !path.split('/').any(|seg| seg == "..")
1467}
1468
1469fn parse_tree_line(lineno: usize, line: &str, earlier: &[Line]) -> Line {
1470    let position = format!("{MANIFEST}:{lineno}");
1471    let fail = |name: &str, why| {
1472        broken_at(
1473            lineno,
1474            if name.is_empty() {
1475                position.clone()
1476            } else {
1477                name.to_string()
1478            },
1479            None,
1480            why,
1481        )
1482    };
1483    let rest = &line[4..];
1484    let Some((name, rest)) = split_token(rest) else {
1485        return fail("", ParseError::BadTreeLine("missing name"));
1486    };
1487    if !tree_name_valid(name) {
1488        return fail(name, ParseError::BadTreeName(name.to_string()));
1489    }
1490    if name_says_its_trigger(name) {
1491        return fail(name, ParseError::TriggerInName(name.to_string()));
1492    }
1493    if earlier
1494        .iter()
1495        .any(|l| matches!(l, Line::Tree(g) if g.name == name))
1496    {
1497        return fail(name, ParseError::Duplicate(name.to_string()));
1498    }
1499    let Some((tool_tok, rest)) = split_token(rest) else {
1500        return fail(name, ParseError::BadTreeLine("missing tool"));
1501    };
1502    let Some(tool) = TreeTool::parse(tool_tok) else {
1503        return fail(name, ParseError::BadTreeTool(tool_tok.to_string()));
1504    };
1505    let Some((scope_tok, rest)) = split_token(rest) else {
1506        return fail(name, ParseError::BadTreeLine("missing scope"));
1507    };
1508    let ParsedScope {
1509        exts,
1510        names,
1511        dirs,
1512        opt_in,
1513    } = match parse_scope(scope_tok) {
1514        Ok(s) => s,
1515        Err(why) => return fail(name, why),
1516    };
1517    match split_token(rest) {
1518        Some(("attest", _)) => {}
1519        _ => {
1520            return fail(
1521                name,
1522                ParseError::BadTreeLine("the fifth column must be `attest`"),
1523            )
1524        }
1525    }
1526    let mut rest = split_token(rest).map(|(_, r)| r).unwrap_or("");
1527    let mut cwd = None;
1528    let mut inputs = Vec::new();
1529    // Options are LOWERCASE `key=value` tokens. An uppercase one
1530    // (`NODE_OPTIONS=… npm run lint`) is an environment assignment that begins
1531    // the command, so an env-prefixed command stays expressible.
1532    while let Some((tok, after)) = split_token(rest) {
1533        let Some((key, value)) = tok.split_once('=') else {
1534            break;
1535        };
1536        if key.is_empty() || !key.chars().all(|c| c.is_ascii_lowercase()) {
1537            break;
1538        }
1539        match key {
1540            "cwd" if cwd.is_none() && tree_path_safe(value) => {
1541                cwd = Some(value.trim_end_matches('/').to_string());
1542            }
1543            "inputs" if inputs.is_empty() => {
1544                let paths: Vec<String> = value.split(',').map(str::to_string).collect();
1545                if !paths.iter().all(|p| tree_path_safe(p)) {
1546                    return fail(name, ParseError::BadTreeOption(tok.to_string()));
1547                }
1548                inputs = paths;
1549            }
1550            _ => return fail(name, ParseError::BadTreeOption(tok.to_string())),
1551        }
1552        rest = after;
1553    }
1554    let command = rest.trim();
1555    if command.is_empty() {
1556        return fail(name, ParseError::BadTreeLine("missing command"));
1557    }
1558    let placeholders = command
1559        .split_whitespace()
1560        .filter(|w| *w == CACHE_PLACEHOLDER)
1561        .count();
1562    if placeholders > 1 || (placeholders == 1 && !tool.has_cache()) {
1563        return fail(name, ParseError::BadTreeCache);
1564    }
1565    if normalize_command(command).is_empty() {
1566        return fail(name, ParseError::BadTreeLine("missing command"));
1567    }
1568    Line::Tree(TreeGate {
1569        name: name.to_string(),
1570        tool,
1571        exts,
1572        names,
1573        dirs,
1574        opt_in,
1575        cwd,
1576        inputs,
1577        command: command.to_string(),
1578        lineno,
1579    })
1580}
1581
1582/// The usable tree gates a manifest text declares — untrusted, for readers
1583/// that execute nothing (`tree-parity` compares text with text). Anything that
1584/// RUNS a gate reads [`Manifest::tree`], which is trust-gated.
1585pub fn tree_gates(text: &str) -> Vec<TreeGate> {
1586    parse_lines(text)
1587        .into_iter()
1588        .filter_map(|l| match l {
1589            Line::Tree(g) => Some(g),
1590            _ => None,
1591        })
1592        .collect()
1593}
1594
1595fn name_or_position(declared: &str, lineno: usize) -> String {
1596    if declared.is_empty() {
1597        format!("{MANIFEST}:{lineno}")
1598    } else {
1599        declared.to_string()
1600    }
1601}
1602
1603fn broken_at(lineno: usize, name: String, stage: Option<Stage>, why: ParseError) -> Line {
1604    Line::Broken {
1605        name,
1606        stage: stage.unwrap_or(Stage::PreCommit),
1607        lineno,
1608        why,
1609    }
1610}
1611
1612impl From<Line> for External {
1613    fn from(l: Line) -> External {
1614        let (name, stage, parsed) = l.into_parts();
1615        let kind = match parsed {
1616            Ok(d) => Kind::Runnable {
1617                // `ALWAYS` only when NOTHING narrows it. An opt-in alone is a
1618                // narrowing — `+Gemfile` means "any change, in a Ruby
1619                // repository" — and collapsing that to `ALWAYS` would run the
1620                // check everywhere, which is the exact bug this field exists
1621                // to prevent.
1622                scope: if d.exts.is_empty()
1623                    && d.names.is_empty()
1624                    && d.dirs.is_empty()
1625                    && d.opt_in.is_empty()
1626                {
1627                    Scope::ALWAYS
1628                } else {
1629                    Scope {
1630                        files: leak(d.exts),
1631                        names: leak(d.names),
1632                        dirs: leak(d.dirs),
1633                        opt_in: leak(d.opt_in),
1634                        not_during: &[],
1635                    }
1636                },
1637                severity: d.severity,
1638                program: d.program,
1639                args: d.args,
1640                fix: d.fix,
1641                files: d.files,
1642                docs_skip: d.docs_skip,
1643            },
1644            Err(why) => Kind::Unusable {
1645                why,
1646                declared: None,
1647            },
1648        };
1649        let id = format!("{}-{}", stage.as_str(), name);
1650        External {
1651            id,
1652            short_name: name,
1653            stage,
1654            kind,
1655        }
1656    }
1657}
1658
1659pub fn parse(text: &str) -> Vec<External> {
1660    parse_lines(text)
1661        .into_iter()
1662        .filter(Line::is_check)
1663        .map(External::from)
1664        .collect()
1665}
1666
1667/// The manifest for `root`, or an empty list. Read once per process.
1668pub fn read(root: &Path) -> Vec<External> {
1669    std::fs::read_to_string(root.join(MANIFEST))
1670        .map(|t| parse(&t))
1671        .unwrap_or_default()
1672}
1673
1674/// The same file, without building the `Scope`s — for a reader that inspects
1675/// many repositories and must not leak once per manifest per refresh.
1676pub fn read_lines(root: &Path) -> Vec<Line> {
1677    std::fs::read_to_string(root.join(MANIFEST))
1678        .map(|t| parse_lines(&t))
1679        .unwrap_or_default()
1680}
1681
1682/// Everything one repository's manifest declares, parsed and trust-gated
1683/// ONCE, owned by the entrypoint that loaded it and lent down through `Ctx`.
1684///
1685/// This replaced two process-global `OnceLock`s keyed on the working
1686/// directory at first call — safe in a hook, which handles one repository
1687/// and exits, and a trap for anything that walks many. Owned data has no
1688/// first-call: the caller says which repository it means, every time.
1689#[derive(Default)]
1690pub struct Manifest {
1691    /// Declared checks — untrusted ones present but `Unusable`, so the
1692    /// decision waiting on the reader stays visible. See [`gate`].
1693    pub externals: Vec<External>,
1694    /// Tool version pins — DROPPED entirely when untrusted, because verifying
1695    /// one executes `<program> --version` for a name the repository chose,
1696    /// which is exactly the consent the trust model exists to collect.
1697    pub pins: Vec<ToolPin>,
1698    /// Tree gates — DROPPED entirely when untrusted, like the pins: running
1699    /// one executes a command the repository chose.
1700    pub tree: Vec<TreeGate>,
1701    /// Whether an `amont.conf` EXISTS in this repository — the committed
1702    /// declaration that this project subscribes to amont's conventions.
1703    /// Presence, not content: an empty file declares, and declaring executes
1704    /// nothing, so this is safe to read before any trust decision. What the
1705    /// file SAYS stays trust-gated above.
1706    pub declared: bool,
1707    /// Committed `severity`/`skip` policy — POPULATED ONLY WHEN TRUSTED,
1708    /// like the pins: applying a repository's opinion about your safety net
1709    /// is exactly the consent the trust model collects. See `policy`.
1710    pub policy: crate::policy::Policy,
1711    /// `trust::why(state)` when policy lines exist but the manifest is not
1712    /// trusted — the dispatchers say it once per stage, because policy that
1713    /// silently does not apply is a silent behaviour change.
1714    pub policy_withheld: Option<&'static str>,
1715    /// `severity`/`skip` targets that name no check here, with positions —
1716    /// a typo in committed policy must be loud, not a phantom check.
1717    pub policy_notes: Vec<String>,
1718}
1719
1720/// Read and trust-gate `root`'s manifest.
1721///
1722/// ONE read. The bytes that get PARSED and the bytes that get HASHED are the
1723/// same bytes: an earlier shape `read(root)`-ed and then let `trust::state`
1724/// open the file a second time, so anything that changed it in between — a
1725/// `git checkout`, a watcher, a `make` target already running — produced a
1726/// trust decision about content that is not the content about to be
1727/// executed.
1728///
1729/// Each call leaks the `Scope` slices it builds (see [`leak`]) — pennies for
1730/// the hook path, which loads once per process, and the reason the fleet
1731/// keeps reading [`read_lines`] instead: a scanner must not leak once per
1732/// repository per refresh.
1733pub fn load(root: &Path) -> Manifest {
1734    // Non-UTF-8 yields nothing, as it always has: `parse` takes a `&str`,
1735    // and a manifest we cannot read as text is one we cannot act on. Not
1736    // lossy — that would invent a manifest nobody wrote.
1737    let declared = root.join(MANIFEST).exists();
1738    let Ok(bytes) = std::fs::read(root.join(MANIFEST)) else {
1739        return Manifest {
1740            declared,
1741            ..Manifest::default()
1742        };
1743    };
1744    let Ok(text) = String::from_utf8(bytes.clone()) else {
1745        return Manifest {
1746            declared,
1747            ..Manifest::default()
1748        };
1749    };
1750    let state = crate::trust::state_of(root, &bytes);
1751    let lines = parse_lines(&text);
1752    let externals = gate(
1753        lines
1754            .iter()
1755            .filter(|l| l.is_check())
1756            .cloned()
1757            .map(External::from)
1758            .collect(),
1759        state,
1760    );
1761    let trusted = state == crate::trust::State::Trusted;
1762    let pins = if trusted {
1763        lines
1764            .iter()
1765            .filter_map(|l| match l {
1766                Line::Tool(pin) => Some(pin.clone()),
1767                _ => None,
1768            })
1769            .collect()
1770    } else {
1771        Vec::new()
1772    };
1773    let tree = if trusted {
1774        lines
1775            .iter()
1776            .filter_map(|l| match l {
1777                Line::Tree(gate) => Some(gate.clone()),
1778                _ => None,
1779            })
1780            .collect()
1781    } else {
1782        Vec::new()
1783    };
1784    let has_policy = lines.iter().any(|l| matches!(l, Line::Policy { .. }));
1785    let (policy, policy_notes) = if trusted {
1786        crate::policy::Policy::from_lines(&lines)
1787    } else {
1788        (crate::policy::Policy::default(), Vec::new())
1789    };
1790    // NOTE: load() computes but never INSTALLS the policy — the entrypoints
1791    // do, immediately after this returns and before any config read. A
1792    // multi-repo walker (the fleet) reads `read_lines` and must never seed
1793    // the process-global store.
1794    let policy_withheld = if has_policy && !trusted {
1795        crate::trust::why(state)
1796    } else {
1797        None
1798    };
1799    Manifest {
1800        externals,
1801        pins,
1802        tree,
1803        declared,
1804        policy,
1805        policy_withheld,
1806        policy_notes,
1807    }
1808}
1809
1810/// Check every trusted pin against the tool actually on this machine, and say
1811/// what disagrees. Warn-only, once per hook run, at BOTH stages: skew never
1812/// blocks a commit — its cost is a check disagreeing with CI, and the fix is
1813/// a human decision — but it stops being invisible, which is the whole point.
1814pub fn verify_tool_pins(pins: &[ToolPin]) {
1815    for pin in pins {
1816        match version_of(&pin.program) {
1817            None => crate::hooks::common::warn(&format!(
1818                "{} is pinned to {} in {MANIFEST}, but `{} --version` would not run",
1819                crate::ui::highlight(&pin.program),
1820                crate::ui::sanitize(&pin.want),
1821                crate::ui::sanitize(&pin.program),
1822            )),
1823            Some(v) if !v.contains(&pin.want) => crate::hooks::common::warn(&format!(
1824                "{} reports {} — {MANIFEST} pins {}; this machine may disagree with CI",
1825                crate::ui::highlight(&pin.program),
1826                crate::ui::sanitize(&v),
1827                crate::ui::sanitize(&pin.want),
1828            )),
1829            _ => {}
1830        }
1831    }
1832}
1833
1834/// First line of `<program> --version`, resolved the way every check resolves
1835/// a tool. A version probe answers in milliseconds or not at all, so it is
1836/// deliberately not under the check deadline.
1837fn version_of(program: &str) -> Option<String> {
1838    let out = Command::new(crate::hooks::common::program(program))
1839        .arg("--version")
1840        .stdin(Stdio::null())
1841        .output()
1842        .ok()?;
1843    if !out.status.success() {
1844        return None;
1845    }
1846    let text = String::from_utf8_lossy(&out.stdout);
1847    let line = text.lines().next().unwrap_or("").trim();
1848    if line.is_empty() {
1849        return None;
1850    }
1851    Some(line.to_string())
1852}
1853
1854/// Apply a trust verdict to what the manifest declared.
1855///
1856/// Untrusted declarations are kept and DISABLED, not dropped. The names stay
1857/// visible in `amont list`, in the dashboard and in the "could not run"
1858/// roll-up, because a repository quietly declaring checks that never run is the
1859/// failure this project is arranged against — and the reader needs to know
1860/// there is a decision waiting for them.
1861///
1862/// Split out of [`load`] so the rule can be asserted without a repository on
1863/// disk — and kept split even now that `load` takes an explicit root, because
1864/// a trust verdict is one input among several there and this is the part with
1865/// the rule in it.
1866pub(crate) fn gate(declared: Vec<External>, state: crate::trust::State) -> Vec<External> {
1867    match crate::trust::why(state) {
1868        None => declared,
1869        Some(reason) => declared
1870            .into_iter()
1871            .map(|external| External {
1872                kind: Kind::Unusable {
1873                    why: reason.to_string(),
1874                    declared: external.manifest_severity(),
1875                },
1876                ..external
1877            })
1878            .collect(),
1879    }
1880}
1881
1882#[cfg(test)]
1883mod tests {
1884    use super::*;
1885
1886    fn one(text: &str) -> Line {
1887        let mut v = parse_lines(text);
1888        assert_eq!(v.len(), 1, "expected one entry from {text:?}");
1889        v.pop().expect("one")
1890    }
1891
1892    /// The error a line produced, as a VALUE. Tests used to match on the prose,
1893    /// which coupled them to wording and would have kept passing if the wording
1894    /// stayed while the meaning changed.
1895    fn why(l: &Line) -> ParseError {
1896        match l {
1897            Line::Broken { why, .. } => why.clone(),
1898            Line::Usable(d) => panic!("{} parsed when it should not have", d.name),
1899            Line::Tool(pin) => panic!("{} parsed as a pin, not a broken line", pin.program),
1900            Line::Policy { what, .. } => {
1901                panic!("{} parsed as policy, not a broken line", what.describe())
1902            }
1903            Line::Tree(g) => panic!("{} parsed as a tree gate, not a broken line", g.name),
1904        }
1905    }
1906
1907    fn usable(l: &Line) -> &Declared {
1908        match l {
1909            Line::Usable(d) => d,
1910            Line::Broken { name, why, .. } => panic!("{name} failed to parse: {why}"),
1911            Line::Tool(pin) => panic!("{} is a tool pin, not a declaration", pin.program),
1912            Line::Policy { what, .. } => {
1913                panic!("{} is policy, not a declaration", what.describe())
1914            }
1915            Line::Tree(g) => panic!("{} is a tree gate, not a declaration", g.name),
1916        }
1917    }
1918
1919    fn tree(l: &Line) -> &TreeGate {
1920        match l {
1921            Line::Tree(g) => g,
1922            Line::Broken { name, why, .. } => panic!("{name} failed to parse: {why}"),
1923            other => panic!("{} is not a tree gate", other.name()),
1924        }
1925    }
1926
1927    #[test]
1928    fn tree_full_line_parses_with_options() {
1929        let l = one("tree  eslint  eslint  *.ts,*.tsx  attest  cwd=web/ inputs=tsconfig.json,.eslintignore  npm run lint -- {cache}");
1930        let g = tree(&l);
1931        assert_eq!(g.name, "eslint");
1932        assert_eq!(g.tool, TreeTool::Eslint);
1933        assert_eq!(g.exts, vec![".ts".to_string(), ".tsx".to_string()]);
1934        assert_eq!(g.cwd.as_deref(), Some("web"));
1935        assert_eq!(
1936            g.inputs,
1937            vec!["tsconfig.json".to_string(), ".eslintignore".to_string()]
1938        );
1939        assert_eq!(g.command, "npm run lint -- {cache}");
1940        assert_eq!(g.id(), "tree-eslint");
1941        assert_eq!(g.stamp_key(), "tree:eslint");
1942        assert!(!l.is_check());
1943    }
1944
1945    #[test]
1946    fn tree_normalizes_cache_dangling_dashes_and_whitespace() {
1947        assert_eq!(normalize_command("npm run lint -- {cache}"), "npm run lint");
1948        assert_eq!(normalize_command("npx eslint . {cache}"), "npx eslint .");
1949        assert_eq!(
1950            normalize_command("uvx ruff@0.16.0   check packages"),
1951            "uvx ruff@0.16.0 check packages"
1952        );
1953        // A `--` that is NOT trailing is the command's own and stays.
1954        assert_eq!(
1955            normalize_command("npm run lint -- --quiet"),
1956            "npm run lint -- --quiet"
1957        );
1958    }
1959
1960    #[test]
1961    fn tree_env_assignment_begins_the_command() {
1962        let l =
1963            one("tree eslint eslint * attest NODE_OPTIONS=--max-old-space-size=4096 npm run lint");
1964        let g = tree(&l);
1965        assert_eq!(g.cwd, None);
1966        assert_eq!(
1967            g.command,
1968            "NODE_OPTIONS=--max-old-space-size=4096 npm run lint"
1969        );
1970    }
1971
1972    /// `dir/**/*.ext` is the one trigger with a `/` in it: a whole directory
1973    /// prefix and a plain suffix. Everything else with a slash, a glob or an
1974    /// empty segment is refused, so the grammar cannot grow a glob by accident.
1975    #[test]
1976    fn a_directory_token_is_a_trigger_and_nothing_else_with_a_slash_is() {
1977        let Line::Usable(d) = one("pre-commit  plugin  claude-plugin/**/*.md  block  true\n")
1978        else {
1979            panic!("a directory trigger should parse");
1980        };
1981        assert_eq!(d.dirs, ["claude-plugin/**/*.md"]);
1982        assert!(d.exts.is_empty() && d.names.is_empty());
1983
1984        let Line::Usable(d) = one("pre-commit  plugin  docs/x/**/*.md+Cargo.toml  block  true\n")
1985        else {
1986            panic!("a directory trigger with an opt-in should parse");
1987        };
1988        assert_eq!(d.dirs, ["docs/x/**/*.md"]);
1989        assert_eq!(d.opt_in, ["Cargo.toml"]);
1990
1991        for bad in [
1992            "claude-*/**/*.md",
1993            "/abs/**/*.md",
1994            "a//b/**/*.md",
1995            "../x/**/*.md",
1996            "a/./**/*.md",
1997            "x/**/*",
1998            "claude-plugin/*.md",
1999            "claude-plugin/**/*.md/x",
2000        ] {
2001            let l = one(&format!("pre-commit  plugin  {bad}  block  true\n"));
2002            assert!(
2003                matches!(why(&l), ParseError::BadScope(_)),
2004                "{bad} must be refused"
2005            );
2006        }
2007    }
2008
2009    /// `docs-skip` is a leading marker like `fix` and `files`, and it means
2010    /// something only where there is a staged change to judge.
2011    #[test]
2012    fn docs_skip_is_a_marker_and_refused_on_pre_push() {
2013        let Line::Usable(d) = one("pre-commit  adr  *+.adr.yaml  block  docs-skip aval check\n")
2014        else {
2015            panic!("docs-skip should parse on pre-commit");
2016        };
2017        assert!(d.docs_skip);
2018        assert_eq!(d.program, "aval");
2019        assert_eq!(
2020            why(&one("pre-push  adr  *  block  docs-skip aval check\n")),
2021            ParseError::DocsSkipOnPrePush
2022        );
2023    }
2024
2025    #[test]
2026    fn tree_cache_refused_for_a_tool_without_one() {
2027        let l = one("tree ruff-check ruff * attest uvx ruff@0.16.0 check packages {cache}");
2028        assert_eq!(why(&l), ParseError::BadTreeCache);
2029    }
2030
2031    #[test]
2032    fn tree_cache_twice_refused() {
2033        let l = one("tree eslint eslint * attest npx eslint {cache} . {cache}");
2034        assert_eq!(why(&l), ParseError::BadTreeCache);
2035    }
2036
2037    #[test]
2038    fn tree_bad_tool_refused() {
2039        let l = one("tree lint biome * attest npx biome check .");
2040        assert_eq!(why(&l), ParseError::BadTreeTool("biome".into()));
2041    }
2042
2043    #[test]
2044    fn tree_missing_attest_refused() {
2045        let l = one("tree eslint eslint * block npx eslint .");
2046        assert!(matches!(why(&l), ParseError::BadTreeLine(_)));
2047    }
2048
2049    #[test]
2050    fn tree_absolute_cwd_refused() {
2051        let l = one("tree eslint eslint * attest cwd=/etc npx eslint .");
2052        assert_eq!(why(&l), ParseError::BadTreeOption("cwd=/etc".into()));
2053    }
2054
2055    #[test]
2056    fn tree_parent_dir_in_cwd_refused() {
2057        let l = one("tree eslint eslint * attest cwd=web/../.. npx eslint .");
2058        assert_eq!(why(&l), ParseError::BadTreeOption("cwd=web/../..".into()));
2059    }
2060
2061    #[test]
2062    fn tree_glob_in_inputs_refused() {
2063        let l = one("tree eslint eslint * attest inputs=src/*.json npx eslint .");
2064        assert_eq!(
2065            why(&l),
2066            ParseError::BadTreeOption("inputs=src/*.json".into())
2067        );
2068    }
2069
2070    #[test]
2071    fn tree_unknown_option_refused() {
2072        let l = one("tree eslint eslint * attest shell=bash npx eslint .");
2073        assert_eq!(why(&l), ParseError::BadTreeOption("shell=bash".into()));
2074    }
2075
2076    #[test]
2077    fn tree_duplicate_name_refused() {
2078        let v = parse_lines(
2079            "tree eslint eslint * attest npx eslint .\ntree eslint eslint * attest npx eslint src",
2080        );
2081        assert_eq!(v.len(), 2);
2082        tree(&v[0]);
2083        assert_eq!(why(&v[1]), ParseError::Duplicate("eslint".into()));
2084    }
2085
2086    #[test]
2087    fn tree_name_saying_its_trigger_refused() {
2088        let l = one("tree pre-commit-lint eslint * attest npx eslint .");
2089        assert_eq!(why(&l), ParseError::TriggerInName("pre-commit-lint".into()));
2090    }
2091
2092    #[test]
2093    fn tree_bad_name_refused() {
2094        let l = one("tree -lint eslint * attest npx eslint .");
2095        assert_eq!(why(&l), ParseError::BadTreeName("-lint".into()));
2096    }
2097
2098    #[test]
2099    fn tree_gates_are_not_runnable_checks() {
2100        let text = "tree eslint eslint * attest npx eslint .\npre-commit smoke * block true";
2101        let checks = parse(text);
2102        assert_eq!(checks.len(), 1);
2103        assert_eq!(checks[0].id, "pre-commit-smoke");
2104        let gates = tree_gates(text);
2105        assert_eq!(gates.len(), 1);
2106        assert_eq!(gates[0].normalized(), "npx eslint .");
2107    }
2108
2109    /// The scope column's bare tokens are exact filenames — basename-matched,
2110    /// so `package.json` cannot be counterfeited by `not-package.json` — and
2111    /// they mix freely with extensions. Directories and free `*` stay refused.
2112    #[test]
2113    fn a_bare_scope_token_is_an_exact_filename() {
2114        let line = one("pre-commit  lockcheck  package.json  block  ./check.sh\n");
2115        let d = usable(&line);
2116        assert!(d.exts.is_empty());
2117        assert_eq!(d.names, ["package.json"]);
2118
2119        let line = one("pre-commit  x  *.ts,package.json,.prettierrc  block  ./x\n");
2120        let d = usable(&line);
2121        assert_eq!(d.exts, [".ts"]);
2122        assert_eq!(d.names, ["package.json", ".prettierrc"]);
2123
2124        assert_eq!(
2125            why(&one("pre-commit  x  src/package.json  block  ./x\n")),
2126            ParseError::BadScope("src/package.json".into())
2127        );
2128        assert_eq!(
2129            why(&one("pre-commit  x  pkg*  block  ./x\n")),
2130            ParseError::BadScope("pkg*".into())
2131        );
2132    }
2133
2134    /// `+` adds the repository condition a builtin has always had.
2135    ///
2136    /// The separator is the one `amont list` already prints between the two
2137    /// halves (`needs .rs + Cargo.toml`), so what a reader sees in the listing
2138    /// is what they write in the file.
2139    #[test]
2140    fn a_scope_can_name_what_the_repository_must_carry() {
2141        let line = one("pre-commit  rubocop  *.rb+.rubocop.yml  block  rubocop\n");
2142        let d = usable(&line);
2143        assert_eq!(d.exts, [".rb"]);
2144        assert!(d.names.is_empty());
2145        assert_eq!(d.opt_in, [".rubocop.yml"]);
2146
2147        // Both sides comma-separated: any trigger, and any ONE opt-in.
2148        let line = one("pre-commit  x  *.rb,Rakefile+.rubocop.yml,.rubocop_todo.yml  block  ./x\n");
2149        let d = usable(&line);
2150        assert_eq!(d.exts, [".rb"]);
2151        assert_eq!(d.names, ["Rakefile"]);
2152        assert_eq!(d.opt_in, [".rubocop.yml", ".rubocop_todo.yml"]);
2153
2154        // No trigger half: "any change, in a repository that carries this".
2155        let line = one("pre-commit  x  +Gemfile  block  ./x\n");
2156        let d = usable(&line);
2157        assert!(d.exts.is_empty() && d.names.is_empty());
2158        assert_eq!(d.opt_in, ["Gemfile"]);
2159    }
2160
2161    /// Every declaration written before `+` existed keeps its meaning: no
2162    /// opt-in is the old behaviour, which is "applies wherever it triggers".
2163    #[test]
2164    fn a_scope_without_a_plus_is_unchanged() {
2165        let line = one("pre-commit  x  *.ts,package.json  block  ./x\n");
2166        let d = usable(&line);
2167        assert!(d.opt_in.is_empty());
2168        let line = one("pre-commit  x  *  block  ./x\n");
2169        let d = usable(&line);
2170        assert!(d.opt_in.is_empty() && d.exts.is_empty() && d.names.is_empty());
2171    }
2172
2173    /// A `+` that promises a condition and names none is refused, rather than
2174    /// read as "no condition" — which is the opposite of what was reached for.
2175    #[test]
2176    fn a_malformed_opt_in_half_is_refused() {
2177        for bad in [
2178            "*.rb+",            // promised, not given
2179            "*.rb+,",           // still nothing
2180            "*.rb+a+b",         // a second separator is not a filename
2181            "*.rb+sub/dir.yml", // directories are inexpressible, as on the left
2182            "*.rb+*.yml",       // this grammar has no globs to mis-guess
2183        ] {
2184            let line = one(&format!("pre-commit  x  {bad}  block  ./x\n"));
2185            assert!(
2186                matches!(line, Line::Broken { .. }),
2187                "{bad} should be refused, got {line:?}"
2188            );
2189        }
2190    }
2191
2192    /// The pin grammar: exactly three tokens, claimed from a first token that
2193    /// was never a valid stage — no manifest that ever parsed changes meaning.
2194    #[test]
2195    fn a_tool_pin_parses_and_a_malformed_one_is_broken() {
2196        let line = one("tool  ruff  0.6.\n");
2197        assert_eq!(
2198            line,
2199            Line::Tool(ToolPin {
2200                program: "ruff".into(),
2201                want: "0.6.".into()
2202            })
2203        );
2204        assert_eq!(why(&one("tool ruff\n")), ParseError::BadTool);
2205        assert_eq!(why(&one("tool ruff 0.6. extra\n")), ParseError::BadTool);
2206        // `tool` is only a keyword in column one — a check NAMED tool still
2207        // parses as the check it always was.
2208        let line = one("pre-commit  tool  *  block  make tool\n");
2209        assert_eq!(usable(&line).name, "tool");
2210    }
2211
2212    #[test]
2213    fn parses_the_documented_example() {
2214        let v = parse_lines(
2215            "# stage       name        scope   severity  command\n\
2216             pre-commit    lint-shell  *.sh    block     scripts/lint-shell.sh\n\
2217             pre-push      smoke       *       warn      make smoke\n",
2218        );
2219        assert_eq!(v.len(), 2);
2220
2221        let a = usable(&v[0]);
2222        assert_eq!(a.name, "lint-shell");
2223        assert_eq!(a.stage, Stage::PreCommit);
2224        assert_eq!(a.severity, Severity::Block);
2225        assert_eq!(a.program, "scripts/lint-shell.sh");
2226        assert!(a.args.is_empty());
2227        assert_eq!(a.exts, [".sh"]);
2228
2229        let b = usable(&v[1]);
2230        assert_eq!(b.stage, Stage::PrePush);
2231        assert_eq!(b.severity, Severity::Warn);
2232        // A command with arguments is split, not handed to a shell.
2233        assert_eq!(b.program, "make");
2234        assert_eq!(b.args, ["smoke"]);
2235        assert!(b.exts.is_empty(), "`*` gates on nothing");
2236    }
2237
2238    /// Blank lines and comments are not entries, and must not become broken
2239    /// ones — a file that is mostly documentation would otherwise report a
2240    /// dozen gaps.
2241    #[test]
2242    fn comments_and_blank_lines_produce_nothing() {
2243        assert!(parse_lines("\n  \n# just a comment\n\t# indented\n").is_empty());
2244    }
2245
2246    /// The rule the module commits to: a line that cannot be understood still
2247    /// yields a check, so its absence is visible. Matched by VARIANT.
2248    #[test]
2249    fn a_malformed_line_becomes_a_visible_gap() {
2250        let cases: [(&str, ParseError); 4] = [
2251            (
2252                "pre-commit lint-shell *.sh block\n",
2253                ParseError::MissingFields,
2254            ),
2255            (
2256                "nonsense lint-shell *.sh block x\n",
2257                ParseError::BadStage("nonsense".into()),
2258            ),
2259            (
2260                "pre-commit lint-shell ?.sh block x\n",
2261                ParseError::BadScope("?.sh".into()),
2262            ),
2263            (
2264                "pre-commit lint-shell *.sh loud x\n",
2265                ParseError::BadSeverity("loud".into()),
2266            ),
2267        ];
2268        for (text, expected) in cases {
2269            assert_eq!(why(&one(text)), expected, "for {text:?}");
2270        }
2271    }
2272
2273    /// The prose still has to locate the line, even though the tests no longer
2274    /// depend on its wording.
2275    #[test]
2276    fn a_gap_reports_where_it_is() {
2277        let l = one("pre-commit lint-shell *.sh loud x\n");
2278        let said = l.broken().expect("broken");
2279        assert!(said.contains("line 1"), "{said}");
2280        assert!(said.contains("severity"), "{said}");
2281    }
2282
2283    /// `fix` on a pre-push line is refused where every other bad declaration is
2284    /// refused — on every commit, named and located — rather than as a runtime
2285    /// "contract violation" discovered later at push time by fewer people.
2286    #[test]
2287    fn fix_is_refused_on_a_pre_push_line() {
2288        assert_eq!(
2289            why(&one("pre-push  smoke  *  block  fix make smoke\n")),
2290            ParseError::FixOnPrePush
2291        );
2292        // …and accepted on pre-commit.
2293        let line = one("pre-commit  fmt  *  block  fix make format\n");
2294        let declared = usable(&line);
2295        assert_eq!(declared.fix, Fix::Rewrite);
2296        assert_eq!(declared.program, "make");
2297        assert_eq!(declared.args, ["format"]);
2298    }
2299
2300    /// Every manifest written before `fix` existed must still parse the same.
2301    #[test]
2302    fn a_command_that_merely_starts_with_fix_is_not_a_marker() {
2303        let line = one("pre-commit  x  *  block  fixup-tool --check\n");
2304        let declared = usable(&line);
2305        assert_eq!(declared.fix, Fix::None);
2306        assert_eq!(declared.program, "fixup-tool");
2307    }
2308
2309    /// The `files` marker, alone and stacked with `fix` in either order —
2310    /// two markers whose order carries no meaning must not be
2311    /// order-sensitive.
2312    #[test]
2313    fn the_files_marker_parses_alone_and_in_either_order_with_fix() {
2314        let line = one("pre-commit  sc  *.sh  block  files shellcheck\n");
2315        let declared = usable(&line);
2316        assert!(declared.files);
2317        assert_eq!(declared.fix, Fix::None);
2318        assert_eq!(declared.program, "shellcheck");
2319
2320        for text in [
2321            "pre-commit  fmt  *  block  fix files prettier --write\n",
2322            "pre-commit  fmt  *  block  files fix prettier --write\n",
2323        ] {
2324            let line = one(text);
2325            let declared = usable(&line);
2326            assert!(declared.files, "for {text:?}");
2327            assert_eq!(declared.fix, Fix::Rewrite, "for {text:?}");
2328            assert_eq!(declared.program, "prettier", "for {text:?}");
2329            assert_eq!(declared.args, ["--write"], "for {text:?}");
2330        }
2331    }
2332
2333    /// A command that merely starts with `files` keeps its name, exactly as
2334    /// `fixup-tool` keeps its own.
2335    #[test]
2336    fn a_command_that_merely_starts_with_files_is_not_a_marker() {
2337        let line = one("pre-commit  x  *  block  files-checker --strict\n");
2338        let declared = usable(&line);
2339        assert!(!declared.files);
2340        assert_eq!(declared.program, "files-checker");
2341    }
2342
2343    /// A gap with no name cannot be reported, and a line this broken has none.
2344    #[test]
2345    fn a_nameless_line_is_named_after_its_position() {
2346        let l = one("pre-commit\n");
2347        assert_eq!(l.name(), "amont.conf:1");
2348        assert_eq!(why(&l), ParseError::MissingFields);
2349    }
2350
2351    /// An external must not be able to take a built-in's id — it would either
2352    /// shadow `pre-push-branch-protect` or silently lose to it, and neither is
2353    /// something a repository should be able to do by editing a text file.
2354    ///
2355    /// Judged on the id, so the same declaration is refused on one trigger and
2356    /// accepted on the other. That is not a loophole: `pre-push-clippy` is a
2357    /// different check from `pre-commit-clippy`, and nothing is shadowed.
2358    #[test]
2359    fn a_built_in_id_is_refused() {
2360        assert_eq!(
2361            why(&one("pre-commit clippy *.rs block x\n")),
2362            ParseError::NameTaken("clippy".into())
2363        );
2364        assert!(matches!(
2365            one("pre-push clippy *.rs block x\n"),
2366            Line::Usable(_)
2367        ));
2368        // And a pre-push built-in is protected on pre-push, not on pre-commit,
2369        // for the same reason.
2370        assert_eq!(
2371            why(&one("pre-push pull-rebase * block x\n")),
2372            ParseError::NameTaken("pull-rebase".into())
2373        );
2374        assert!(matches!(
2375            one("pre-commit pull-rebase * block x\n"),
2376            Line::Usable(_)
2377        ));
2378    }
2379
2380    /// The stage column says which trigger a line is for. Saying it again in
2381    /// the name is the one way to make an id ambiguous: `pre-commit-clippy` as
2382    /// a NAME is a check whose short name is the built-in's full id, so one
2383    /// `hook.skip` would silence both.
2384    #[test]
2385    fn a_name_that_says_its_own_trigger_is_refused() {
2386        for name in ["pre-commit", "pre-push", "pre-commit-clippy", "pre-push-x"] {
2387            assert_eq!(
2388                why(&one(&format!("pre-commit {name} * block x\n"))),
2389                ParseError::TriggerInName(name.into()),
2390                "{name}"
2391            );
2392        }
2393        // A name that merely begins with the same letters is fine — the trigger
2394        // has to be followed by the separator to count.
2395        assert!(matches!(
2396            one("pre-commit pre-commitish * block x\n"),
2397            Line::Usable(_)
2398        ));
2399    }
2400
2401    /// Two USABLE lines with one ID: the second cannot be addressed by
2402    /// `hook.skip` or by a severity override, so it is refused.
2403    #[test]
2404    fn a_duplicate_id_is_refused() {
2405        let v = parse_lines(
2406            "pre-commit smoke * block a\n\
2407             pre-commit smoke * block b\n",
2408        );
2409        assert_eq!(v.len(), 2);
2410        assert_eq!(usable(&v[0]).id(), "pre-commit-smoke");
2411        assert_eq!(why(&v[1]), ParseError::Duplicate("smoke".into()));
2412    }
2413
2414    /// The same name on both triggers is TWO checks, and this used to refuse
2415    /// the second. Somebody wanting a `show-unicorn` on commit and on push had
2416    /// no way to write it, and no way to skip or downgrade one without the
2417    /// other — the bare name could not tell them apart.
2418    #[test]
2419    fn the_same_name_on_two_triggers_is_allowed() {
2420        let v = parse_lines(
2421            "pre-commit show-unicorn * block a\n\
2422             pre-push   show-unicorn * block b\n",
2423        );
2424        assert_eq!(v.len(), 2);
2425        assert_eq!(usable(&v[0]).id(), "pre-commit-show-unicorn");
2426        assert_eq!(usable(&v[1]).id(), "pre-push-show-unicorn");
2427
2428        // And each is separately addressable, while the short name takes both —
2429        // which is the whole vocabulary, applied to declared checks.
2430        for (id, only) in [
2431            ("pre-commit-show-unicorn", "pre-push-show-unicorn"),
2432            ("pre-push-show-unicorn", "pre-commit-show-unicorn"),
2433        ] {
2434            assert!(crate::skip_suppresses(id, id));
2435            assert!(!crate::skip_suppresses(only, id));
2436        }
2437        assert!(crate::skip_suppresses(
2438            "pre-commit-show-unicorn",
2439            "show-unicorn"
2440        ));
2441        assert!(crate::skip_suppresses(
2442            "pre-push-show-unicorn",
2443            "show-unicorn"
2444        ));
2445        assert!(crate::skip_suppresses(
2446            "pre-commit-show-unicorn",
2447            "pre-commit"
2448        ));
2449        assert!(!crate::skip_suppresses(
2450            "pre-push-show-unicorn",
2451            "pre-commit"
2452        ));
2453    }
2454
2455    /// A line that cannot run does not RESERVE its name.
2456    ///
2457    /// It used to: broken and usable entries shared one list, so a valid
2458    /// declaration was rejected as "declared twice" for colliding with a line
2459    /// that could never execute — pointing the reader at the wrong line, and
2460    /// forcing them to fix the first before the second would work at all.
2461    #[test]
2462    fn a_broken_line_does_not_reserve_its_name() {
2463        let v = parse_lines(
2464            "pre-commit smoke * LOUD   make a\n\
2465             pre-commit smoke * block  make b\n",
2466        );
2467        assert_eq!(v.len(), 2);
2468        assert_eq!(why(&v[0]), ParseError::BadSeverity("LOUD".into()));
2469        let good = usable(&v[1]);
2470        assert_eq!(good.name, "smoke");
2471        assert_eq!(good.program, "make");
2472    }
2473
2474    /// Alignment is cosmetic. A file someone has lined up with tabs, or not
2475    /// lined up at all, must parse identically.
2476    #[test]
2477    fn field_alignment_does_not_matter() {
2478        let spaced = one("pre-commit      lint-shell    *.sh      block     make lint\n");
2479        let tabbed = one("pre-commit\tlint-shell\t*.sh\tblock\tmake lint\n");
2480        assert_eq!(usable(&spaced), usable(&tabbed));
2481        assert_eq!(usable(&spaced).args, ["lint"]);
2482    }
2483
2484    #[test]
2485    fn several_extensions_can_gate_one_check() {
2486        let e = External::from(one("pre-commit shell *.sh,*.bash block make lint\n"));
2487        assert!(e.scope().matches(&["a.bash".into()]));
2488        assert!(e.scope().matches(&["a.sh".into()]));
2489        assert!(!e.scope().matches(&["a.zsh".into()]));
2490    }
2491
2492    /// `tokenise` states its arity in the type, so "four tokens then a command"
2493    /// is checked rather than remembered.
2494    #[test]
2495    fn tokenise_wants_four_fields_and_a_command() {
2496        assert!(tokenise("a b c").is_none(), "too few fields");
2497        assert!(tokenise("a b c d").is_none(), "four fields, no command");
2498        // Trailing whitespace reaches the four fields but still leaves nothing
2499        // to run — the case the `?` on the last field cannot catch.
2500        assert!(
2501            tokenise("a b c d   ").is_none(),
2502            "command is all whitespace"
2503        );
2504        assert!(tokenise("a b c d\t").is_none(), "command is a tab");
2505        let (fields, cmd) = tokenise("a  b\tc   d   run it").expect("four and a command");
2506        assert_eq!(fields, ["a", "b", "c", "d"]);
2507        assert_eq!(cmd, "run it");
2508    }
2509
2510    /// An unusable line carries no command at all — the type has nowhere to put
2511    /// one, which is the point of the split.
2512    #[test]
2513    fn an_unusable_external_holds_no_command() {
2514        let e = External::from(one("pre-commit lint-shell *.sh loud echo hi\n"));
2515        assert!(matches!(e.kind, Kind::Unusable { .. }));
2516        // And it can never block, whatever severity anyone configures.
2517        assert_eq!(e.severity(), Severity::Warn);
2518    }
2519
2520    /// A missing manifest is the normal case and must not be an error.
2521    #[test]
2522    fn a_repository_with_no_manifest_declares_nothing() {
2523        assert!(read(Path::new("/nonexistent-c8f2")).is_empty());
2524        assert!(read_lines(Path::new("/nonexistent-c8f2")).is_empty());
2525    }
2526
2527    /// `Line` exists to spare the dashboard a leak, not to become a second
2528    /// opinion about what a manifest says.
2529    #[test]
2530    fn the_leaking_and_non_leaking_parsers_agree() {
2531        let text = "pre-commit  lint-shell  *.sh,*.bash  block  make lint\n\
2532                    pre-push    smoke       *            warn   make smoke\n\
2533                    pre-commit  broken      ?            block  x\n";
2534        let lines = parse_lines(text);
2535        let externals = parse(text);
2536        assert_eq!(lines.len(), externals.len());
2537        for (l, e) in lines.iter().zip(&externals) {
2538            assert_eq!(l.id(), e.name(), "the id is what a check answers to");
2539            assert_eq!(
2540                l.name(),
2541                e.short_name,
2542                "and the short name is what it is called"
2543            );
2544            assert_eq!(l.stage(), e.stage());
2545            assert_eq!(
2546                l.broken().is_some(),
2547                matches!(e.kind, Kind::Unusable { .. })
2548            );
2549            if let Line::Usable(d) = l {
2550                assert_eq!(d.severity, e.severity());
2551                // The scope the dashboard would DESCRIBE is the scope the
2552                // dispatcher would ENFORCE.
2553                assert_eq!(d.exts, e.scope().files);
2554            }
2555        }
2556    }
2557
2558    #[test]
2559    fn plan_exempt_is_exactly_on_or_off_and_set_once() {
2560        let policy = |l: &Line| match l {
2561            Line::Policy {
2562                what: PolicyLine::Set { key, value },
2563                ..
2564            } => Some((key.clone(), value.clone())),
2565            _ => None,
2566        };
2567        for ok in [
2568            "set planExempt off",
2569            "set PLANEXEMPT On",
2570            "  set\tplanExempt\toff  ",
2571        ] {
2572            let v = parse_lines(ok);
2573            assert!(policy(&v[0]).is_some(), "{ok:?} should parse");
2574            assert_eq!(policy(&v[0]).unwrap().0, "amont.planExempt");
2575        }
2576        // attest's verifiers match only exactly `set planExempt off`, so any
2577        // other shape would silently mean "on" to them: loud here instead.
2578        for bad in [
2579            "set planExempt off # because",
2580            "set planExempt false",
2581            "set planExempt off off",
2582            "set planExempt",
2583        ] {
2584            let v = parse_lines(bad);
2585            assert!(policy(&v[0]).is_none(), "{bad:?} must not be a policy");
2586        }
2587        let v = parse_lines("set planExempt off # because");
2588        assert!(matches!(why(&v[0]), ParseError::BadPlanExempt(_)));
2589        let v = parse_lines("set planExempt off\nset planExempt on\n");
2590        assert!(policy(&v[0]).is_some());
2591        assert!(matches!(why(&v[1]), ParseError::BadPlanExempt(_)));
2592        let v = parse_lines("set planExempt on\nset planExempt off\n");
2593        assert!(matches!(why(&v[1]), ParseError::BadPlanExempt(_)));
2594        for odd in [
2595            "set planExempt off\u{c}",
2596            "\u{b}set planExempt off",
2597            "set planExempt\u{a0}off",
2598        ] {
2599            let v = parse_lines(odd);
2600            assert!(
2601                matches!(why(&v[0]), ParseError::BadPlanExempt(_)),
2602                "{odd:?} must be loud"
2603            );
2604        }
2605    }
2606}