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