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