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