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