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