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