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