Skip to main content

amont_runtime/
manifest.rs

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