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