Skip to main content

spec_driven_docs/
gates.rs

1//! The delivered gates: every check an instance wires as a pre-commit hook.
2//!
3//! This module owns the registry — identity, display name, hook wiring,
4//! citable rules, and implementation for each gate — so a gate cannot exist
5//! unwired: the exhaustive match over [`GateId`] is the declaration. Gate
6//! implementations live one per file below; rendering the registry into
7//! pre-commit YAML and running one gate from the command line live in
8//! `services` and `commands`.
9
10pub mod paths;
11
12pub mod adr_cites_a_live_rule;
13pub mod adr_filename_shape;
14pub mod adr_word_cap;
15pub mod agents_digest_size;
16pub mod chapter_size_cap;
17pub mod comparison_dated_tables;
18pub mod comparison_escaped_pipes;
19pub mod comparison_legend;
20pub mod comparison_one_reference_per_cell;
21pub mod comparison_verdict_word;
22pub mod gate_message_cites_a_rule;
23pub mod instance_manifest;
24pub mod ki_bugzilla_report_width;
25pub mod ki_checked_date;
26pub mod ki_filename_shape;
27pub mod ki_filing;
28pub mod ki_mechanism_walkthrough;
29pub mod ki_record;
30pub mod ki_report_body;
31pub mod ki_retire_when;
32pub mod ki_state;
33pub mod markdown_prose;
34pub mod no_personal_path;
35pub mod no_self_narration;
36pub mod prose_stays_unwrapped;
37pub mod spec_change_is_typed;
38pub mod spec_requirement_parts;
39pub mod spec_rule_id_unique;
40pub mod spec_size_cap;
41pub mod spec_verify_hooks_exist;
42pub mod suppression_names_its_case;
43pub mod tracking_registry;
44
45use std::fmt;
46
47use camino::{Utf8Path, Utf8PathBuf};
48use thiserror::Error;
49
50use crate::domain::finding::Finding;
51use crate::domain::gate_id::GateId;
52use crate::domain::rule_id::RuleId;
53
54/// Where a gate runs: the repository root pre-commit invoked it from.
55#[derive(Debug, Clone)]
56pub struct GateCtx {
57    /// The repository root; every path a gate reads or reports is relative to it.
58    pub repo_root: Utf8PathBuf,
59}
60
61impl GateCtx {
62    /// A context rooted at the given repository.
63    #[must_use]
64    pub fn new(repo_root: impl Into<Utf8PathBuf>) -> Self {
65        Self {
66            repo_root: repo_root.into(),
67        }
68    }
69
70    /// Resolve a repository-relative path for reading.
71    #[must_use]
72    pub fn path(&self, relative: impl AsRef<Utf8Path>) -> Utf8PathBuf {
73        self.repo_root.join(relative)
74    }
75}
76
77/// One line a failing gate prints.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Violation {
80    /// A rule violation, rendered as its `FAIL <domain>:<rule> ...` line.
81    Finding(Finding),
82    /// The repository does not have the shape the gate needs; rendered as
83    /// `FAIL <reason>` with no rule to cite.
84    Layout(String),
85    /// A continuation line under a preceding violation, rendered verbatim.
86    Note(String),
87}
88
89impl fmt::Display for Violation {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            Self::Finding(finding) => finding.fmt(f),
93            Self::Layout(reason) => write!(f, "FAIL {reason}"),
94            Self::Note(text) => f.write_str(text),
95        }
96    }
97}
98
99/// A gate that could not run at all — distinct from one that found violations.
100#[derive(Debug, Error)]
101pub enum GateError {
102    /// A file the gate needed could not be read.
103    #[error("{path}: {source}")]
104    Io {
105        /// The path that failed.
106        path: Utf8PathBuf,
107        /// The underlying failure.
108        source: std::io::Error,
109    },
110}
111
112impl GateError {
113    pub(crate) fn io(path: impl Into<Utf8PathBuf>, source: std::io::Error) -> Self {
114        Self::Io {
115            path: path.into(),
116            source,
117        }
118    }
119}
120
121impl From<GateError> for crate::error::AppError {
122    fn from(error: GateError) -> Self {
123        match error {
124            GateError::Io { path, source } => {
125                let kind = source.kind();
126                Self::Io(std::io::Error::new(kind, format!("{path}: {source}")))
127            }
128        }
129    }
130}
131
132/// What every gate returns: the violations it found, or why it could not run.
133pub type GateResult = Result<Vec<Violation>, GateError>;
134
135/// The implementation shape shared by every gate.
136pub type GateFn = fn(&GateCtx, &[String]) -> GateResult;
137
138/// One registry row: everything the deliveries need to know about a gate.
139#[derive(Debug)]
140pub struct GateSpec {
141    /// The gate's identity.
142    pub id: GateId,
143    /// The display name pre-commit shows.
144    pub name: &'static str,
145    /// The default `files:` pattern, with `{docs_root}` left templated.
146    pub files: Option<&'static str>,
147    /// The `types:` scope, when the gate takes one.
148    pub types: Option<&'static str>,
149    /// The default `exclude:` pattern, with `{docs_root}` left templated.
150    pub exclude: Option<&'static str>,
151    /// Whether the gate runs regardless of which files changed.
152    pub always_run: bool,
153    /// Every rule the gate can cite in a finding.
154    pub cites: &'static [RuleId],
155    /// The implementation.
156    pub run: GateFn,
157}
158
159/// Look up one gate's registry row.
160#[must_use]
161pub fn spec(id: GateId) -> &'static GateSpec {
162    let index = GateId::ALL.iter().position(|g| *g == id).unwrap_or(0);
163    &GATES[index]
164}
165
166/// The delivered gate set, in [`GateId::ALL`] order.
167pub static GATES: &[GateSpec] = &[
168    GateSpec {
169        id: GateId::AdrCitesALiveRule,
170        name: "decision record citations resolve",
171        files: None,
172        types: None,
173        exclude: None,
174        always_run: true,
175        cites: adr_cites_a_live_rule::CITES,
176        run: adr_cites_a_live_rule::run,
177    },
178    GateSpec {
179        id: GateId::AdrFilenameShape,
180        name: "decision record filename shape",
181        files: Some(r"^{docs_root}/decisions/.*\.md$"),
182        types: None,
183        exclude: None,
184        always_run: false,
185        cites: adr_filename_shape::CITES,
186        run: adr_filename_shape::run,
187    },
188    GateSpec {
189        id: GateId::AdrWordCap,
190        name: "decision record word cap",
191        files: None,
192        types: None,
193        exclude: None,
194        always_run: true,
195        cites: adr_word_cap::CITES,
196        run: adr_word_cap::run,
197    },
198    GateSpec {
199        id: GateId::AgentsDigestSize,
200        name: "agent digest size",
201        files: None,
202        types: None,
203        exclude: None,
204        always_run: true,
205        cites: agents_digest_size::CITES,
206        run: agents_digest_size::run,
207    },
208    GateSpec {
209        id: GateId::ChapterSizeCap,
210        name: "chapter and catalog size",
211        files: None,
212        types: None,
213        exclude: None,
214        always_run: true,
215        cites: chapter_size_cap::CITES,
216        run: chapter_size_cap::run,
217    },
218    GateSpec {
219        id: GateId::ComparisonDatedTables,
220        name: "comparison tables are dated",
221        files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
222        types: None,
223        exclude: None,
224        always_run: false,
225        cites: comparison_dated_tables::CITES,
226        run: comparison_dated_tables::run,
227    },
228    GateSpec {
229        id: GateId::ComparisonEscapedPipes,
230        name: "comparison table pipes are escaped",
231        files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
232        types: None,
233        exclude: None,
234        always_run: false,
235        cites: comparison_escaped_pipes::CITES,
236        run: comparison_escaped_pipes::run,
237    },
238    GateSpec {
239        id: GateId::ComparisonLegend,
240        name: "comparison legend",
241        files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
242        types: None,
243        exclude: None,
244        always_run: false,
245        cites: comparison_legend::CITES,
246        run: comparison_legend::run,
247    },
248    GateSpec {
249        id: GateId::ComparisonOneReferencePerCell,
250        name: "one reference per comparison cell",
251        files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
252        types: None,
253        exclude: None,
254        always_run: false,
255        cites: comparison_one_reference_per_cell::CITES,
256        run: comparison_one_reference_per_cell::run,
257    },
258    GateSpec {
259        id: GateId::ComparisonVerdictWord,
260        name: "comparison verdict word",
261        files: Some(r"(^|/)COMPARISON-[a-z0-9-]+\.md$"),
262        types: None,
263        exclude: None,
264        always_run: false,
265        cites: comparison_verdict_word::CITES,
266        run: comparison_verdict_word::run,
267    },
268    GateSpec {
269        id: GateId::GateMessageCitesARule,
270        name: "gate messages cite a rule",
271        files: None,
272        types: None,
273        exclude: None,
274        always_run: true,
275        cites: gate_message_cites_a_rule::CITES,
276        run: gate_message_cites_a_rule::run,
277    },
278    GateSpec {
279        id: GateId::InstanceManifest,
280        name: "instance manifest",
281        files: None,
282        types: None,
283        exclude: None,
284        always_run: true,
285        cites: instance_manifest::CITES,
286        run: instance_manifest::run,
287    },
288    GateSpec {
289        id: GateId::KiBugzillaReportWidth,
290        name: "Bugzilla report width",
291        files: None,
292        types: None,
293        exclude: None,
294        always_run: true,
295        cites: ki_bugzilla_report_width::CITES,
296        run: ki_bugzilla_report_width::run,
297    },
298    GateSpec {
299        id: GateId::KiCheckedDate,
300        name: "known issue last-check date",
301        files: None,
302        types: None,
303        exclude: None,
304        always_run: true,
305        cites: ki_checked_date::CITES,
306        run: ki_checked_date::run,
307    },
308    GateSpec {
309        id: GateId::KiFilenameShape,
310        name: "known issue filename shape",
311        files: Some(r"^{docs_root}/reference/known-issues/.*\.md$"),
312        types: None,
313        exclude: None,
314        always_run: false,
315        cites: ki_filename_shape::CITES,
316        run: ki_filename_shape::run,
317    },
318    GateSpec {
319        id: GateId::KiFiling,
320        name: "known issue filing state",
321        files: None,
322        types: None,
323        exclude: None,
324        always_run: true,
325        cites: ki_filing::CITES,
326        run: ki_filing::run,
327    },
328    GateSpec {
329        id: GateId::KiMechanismWalkthrough,
330        name: "known issue mechanism walkthrough",
331        files: None,
332        types: None,
333        exclude: None,
334        always_run: true,
335        cites: ki_mechanism_walkthrough::CITES,
336        run: ki_mechanism_walkthrough::run,
337    },
338    GateSpec {
339        id: GateId::KiReportBody,
340        name: "known issue report body",
341        files: None,
342        types: None,
343        exclude: None,
344        always_run: true,
345        cites: ki_report_body::CITES,
346        run: ki_report_body::run,
347    },
348    GateSpec {
349        id: GateId::KiRetireWhen,
350        name: "known issue retirement condition",
351        files: None,
352        types: None,
353        exclude: None,
354        always_run: true,
355        cites: ki_retire_when::CITES,
356        run: ki_retire_when::run,
357    },
358    GateSpec {
359        id: GateId::KiState,
360        name: "known issue state",
361        files: None,
362        types: None,
363        exclude: None,
364        always_run: true,
365        cites: ki_state::CITES,
366        run: ki_state::run,
367    },
368    GateSpec {
369        id: GateId::NoPersonalPath,
370        name: "no personal path",
371        files: None,
372        types: Some("text"),
373        exclude: None,
374        always_run: false,
375        cites: no_personal_path::CITES,
376        run: no_personal_path::run,
377    },
378    GateSpec {
379        id: GateId::NoSelfNarration,
380        name: "documents state the present",
381        files: None,
382        types: Some("markdown"),
383        exclude: Some("^{docs_root}/decisions/"),
384        always_run: false,
385        cites: no_self_narration::CITES,
386        run: no_self_narration::run,
387    },
388    GateSpec {
389        id: GateId::ProseStaysUnwrapped,
390        name: "prose lines stay unwrapped",
391        files: None,
392        types: Some("markdown"),
393        exclude: Some(r"(?:^|/)CHANGELOG\.md$"),
394        always_run: false,
395        cites: prose_stays_unwrapped::CITES,
396        run: prose_stays_unwrapped::run,
397    },
398    GateSpec {
399        id: GateId::SpecChangeIsTyped,
400        name: "spec changes are typed",
401        files: None,
402        types: None,
403        exclude: None,
404        always_run: true,
405        cites: spec_change_is_typed::CITES,
406        run: spec_change_is_typed::run,
407    },
408    GateSpec {
409        id: GateId::SpecRequirementParts,
410        name: "spec requirement parts",
411        files: Some(r"^{docs_root}/specs/SPEC-.*\.md$"),
412        types: None,
413        exclude: None,
414        always_run: false,
415        cites: spec_requirement_parts::CITES,
416        run: spec_requirement_parts::run,
417    },
418    GateSpec {
419        id: GateId::SpecRuleIdUnique,
420        name: "spec rule IDs are unique",
421        files: None,
422        types: None,
423        exclude: None,
424        always_run: true,
425        cites: spec_rule_id_unique::CITES,
426        run: spec_rule_id_unique::run,
427    },
428    GateSpec {
429        id: GateId::SpecSizeCap,
430        name: "spec size cap",
431        files: None,
432        types: None,
433        exclude: None,
434        always_run: true,
435        cites: spec_size_cap::CITES,
436        run: spec_size_cap::run,
437    },
438    GateSpec {
439        id: GateId::SpecVerifyHooksExist,
440        name: "spec hook references exist",
441        files: None,
442        types: None,
443        exclude: None,
444        always_run: true,
445        cites: spec_verify_hooks_exist::CITES,
446        run: spec_verify_hooks_exist::run,
447    },
448    GateSpec {
449        id: GateId::SuppressionNamesItsCase,
450        name: "suppressions name a known issue",
451        files: None,
452        types: None,
453        exclude: None,
454        always_run: true,
455        cites: suppression_names_its_case::CITES,
456        run: suppression_names_its_case::run,
457    },
458    GateSpec {
459        id: GateId::TrackingRegistry,
460        name: "tracking registry is valid and current",
461        files: None,
462        types: None,
463        exclude: None,
464        always_run: true,
465        cites: tracking_registry::CITES,
466        run: tracking_registry::run,
467    },
468];
469
470/// The directories every repository walk prunes: vendored or generated trees
471/// a consumer cannot be asked to author.
472pub const PRUNED_DIRS: &[&str] = &[
473    ".git",
474    "node_modules",
475    ".venv",
476    "vendor",
477    "third-party",
478    "target",
479    "dist",
480];
481
482/// Count the newline-terminated lines of a text, as `wc -l` does.
483#[must_use]
484pub fn line_count(text: &str) -> usize {
485    text.matches('\n').count()
486}
487
488/// Read a repository-relative text file for a gate.
489///
490/// # Errors
491///
492/// [`GateError::Io`] naming the path when the file cannot be read.
493pub fn read_text(ctx: &GateCtx, relative: impl AsRef<Utf8Path>) -> Result<String, GateError> {
494    let relative = relative.as_ref();
495    std::fs::read_to_string(ctx.path(relative)).map_err(|source| GateError::io(relative, source))
496}
497
498/// Every value a front-matter key carries, in the order the keys appear.
499///
500/// The scan is the leading `---` block alone, so a `state:` line in the
501/// prose below it is text about the record rather than the record's own
502/// field. A key stated twice yields two entries, which is what makes
503/// "exactly one" decidable.
504#[must_use]
505pub fn front_matter_values(text: &str, key: &str) -> Vec<String> {
506    let mut lines = text.lines();
507    if lines.next() != Some("---") {
508        return Vec::new();
509    }
510    lines
511        .take_while(|line| *line != "---")
512        .filter_map(|line| {
513            line.strip_prefix(key)
514                .and_then(|rest| rest.strip_prefix(':'))
515        })
516        .map(|value| value.trim().to_string())
517        .collect()
518}
519
520/// Walk the repository, pruning [`PRUNED_DIRS`], and yield every file as a
521/// `./`-prefixed repository-relative path in sorted order.
522#[must_use]
523pub fn walk_files(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
524    let root = ctx.repo_root.as_std_path();
525    let mut files: Vec<Utf8PathBuf> = walkdir::WalkDir::new(root)
526        .into_iter()
527        .filter_entry(|entry| {
528            !(entry.file_type().is_dir()
529                && entry.depth() > 0
530                && entry
531                    .file_name()
532                    .to_str()
533                    .is_some_and(|name| PRUNED_DIRS.contains(&name)))
534        })
535        .filter_map(Result::ok)
536        .filter(|entry| entry.file_type().is_file())
537        .filter_map(|entry| {
538            let relative = entry.path().strip_prefix(root).ok()?.to_str()?;
539            Some(Utf8PathBuf::from(format!("./{relative}")))
540        })
541        .collect();
542    files.sort();
543    files
544}
545
546#[cfg(test)]
547pub(crate) mod tests_support {
548    /// A repository holding one known-issue record with the given `state:`
549    /// value and `retire_when:` line.
550    pub fn ki_fixture_state(state: &str, retire_line: &str) -> tempfile::TempDir {
551        ki_record(&format!(
552            "---\nupstream: https://example.invalid/issues\nstate: {state}\nfiling: gathering\n{retire_line}---\n# Vendor issue\n## How it works\nRun.\n"
553        ))
554    }
555
556    /// A repository holding one known-issue record with the given `state:`
557    /// value and `checked:` line.
558    pub fn ki_fixture_checked(state: &str, checked_line: &str) -> tempfile::TempDir {
559        ki_record(&format!(
560            "---\nupstream: https://example.invalid/issues\nstate: {state}\nfiling: gathering\nretire_when: release >= 2.0\n{checked_line}---\n# Vendor issue\n## How it works\nRun.\n"
561        ))
562    }
563
564    /// A repository holding one known-issue record with a conforming
565    /// frontmatter and the given body.
566    pub fn ki_fixture_body(body: &str) -> tempfile::TempDir {
567        ki_record(&format!(
568            "---\nupstream: https://example.invalid/issues\nstate: masked\nfiling: gathering\nretire_when: release >= 2.0\n---\n{body}"
569        ))
570    }
571
572    /// A repository holding one filed known-issue record with the given
573    /// `upstream:` value and body.
574    pub fn ki_fixture_upstream(upstream: &str, body: &str) -> tempfile::TempDir {
575        ki_fixture_filing("filed", upstream, body)
576    }
577
578    /// A repository holding one known-issue record with the given `filing:`
579    /// value, `upstream:` value and body.
580    pub fn ki_fixture_filing(filing: &str, upstream: &str, body: &str) -> tempfile::TempDir {
581        ki_record(&format!(
582            "---\nupstream: {upstream}\nstate: masked\nfiling: {filing}\nretire_when: release >= 2.0\n---\n{body}"
583        ))
584    }
585
586    fn ki_record(text: &str) -> tempfile::TempDir {
587        let dir = tempfile::tempdir().unwrap();
588        let records = dir.path().join("_docs/reference/known-issues");
589        std::fs::create_dir_all(&records).unwrap();
590        std::fs::write(records.join("KI-vendor.md"), text).unwrap();
591        dir
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn registry_covers_every_gate_exactly_once_in_order() {
601        assert_eq!(GATES.len(), GateId::ALL.len());
602        for (row, id) in GATES.iter().zip(GateId::ALL) {
603            assert_eq!(row.id, *id);
604            assert_eq!(spec(*id).id, *id);
605        }
606    }
607
608    #[test]
609    fn every_gate_declares_the_rules_it_cites() {
610        for row in GATES {
611            assert!(!row.cites.is_empty(), "{} cites nothing", row.id);
612        }
613    }
614
615    #[test]
616    fn cited_rules_resolve_in_the_embedded_specs() {
617        let defined = crate::embedded::spec_rule_ids();
618        for row in GATES {
619            for rule in row.cites {
620                assert!(
621                    defined.contains(rule.as_str()),
622                    "{}: {rule} is undefined",
623                    row.id
624                );
625            }
626        }
627    }
628}