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