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