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