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 budget;
17pub mod chapter_size_cap;
18pub mod comparison_dated_tables;
19pub mod comparison_escaped_pipes;
20pub mod comparison_legend;
21pub mod comparison_one_reference_per_cell;
22pub mod comparison_verdict_word;
23pub mod gate_message_cites_a_rule;
24pub mod instance_manifest;
25pub mod ki_bugzilla_report_width;
26pub mod ki_checked_date;
27pub mod ki_filename_shape;
28pub mod ki_filing;
29pub mod ki_mechanism_walkthrough;
30pub mod ki_record;
31pub mod ki_report_body;
32pub mod ki_retire_when;
33pub mod ki_state;
34pub mod markdown_prose;
35pub mod no_personal_path;
36pub mod no_self_narration;
37pub mod prose_stays_unwrapped;
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::path_filter::PathFilter;
53use crate::domain::rule_id::RuleId;
54
55/// Where a gate runs: the repository root pre-commit invoked it from, and
56/// the subject filter that bounds what it judges there.
57///
58/// # Subject paths and support paths
59///
60/// A *subject* path is one whose content the gate judges and which can
61/// appear in a finding. A *support* path is one the gate reads to know what
62/// to judge: the canon manifest, the known-issue records, the docs-root
63/// resolution, the tracking registry. The filter governs subject paths.
64/// [`Self::path`] and [`read_text`] stay open, because a filter that reached
65/// support paths would let a project disable a gate by excluding the file
66/// that configures it.
67///
68/// # Every route a subject path takes
69///
70/// There are two, and each passes through [`Self::subjects`], so a gate
71/// author cannot reach an unfiltered subject list:
72///
73/// 1. The `&[String]` a gate is handed, filtered in `commands::gate`.
74/// 2. [`walk_files`], which filters before it returns.
75///
76/// `canon::every_subject_producer_is_filter_aware` holds that list.
77#[derive(Debug)]
78pub struct GateCtx {
79    /// The repository root; every path a gate reads or reports is relative to it.
80    pub repo_root: Utf8PathBuf,
81    /// What this gate may judge. Private, so the only way to a subject list
82    /// is [`Self::subjects`].
83    filter: PathFilter,
84}
85
86impl GateCtx {
87    /// A context rooted at the given repository, judging everything.
88    ///
89    /// This is the shape every test and every internal caller wants. The
90    /// command path uses [`Self::with_filter`].
91    #[must_use]
92    pub fn new(repo_root: impl Into<Utf8PathBuf>) -> Self {
93        Self {
94            repo_root: repo_root.into(),
95            filter: PathFilter::permissive(),
96        }
97    }
98
99    /// A context whose gate judges only what the filter admits.
100    #[must_use]
101    pub fn with_filter(repo_root: impl Into<Utf8PathBuf>, filter: PathFilter) -> Self {
102        Self {
103            repo_root: repo_root.into(),
104            filter,
105        }
106    }
107
108    /// Resolve a repository-relative path for reading.
109    ///
110    /// Deliberately unfiltered: a gate reads its support files through here.
111    #[must_use]
112    pub fn path(&self, relative: impl AsRef<Utf8Path>) -> Utf8PathBuf {
113        self.repo_root.join(relative)
114    }
115
116    /// The form a pattern speaks, for one candidate.
117    ///
118    /// See [`crate::domain::path_filter::project`], which both this and
119    /// `--explain` use, so the two never disagree about which file a path
120    /// names.
121    fn relative(&self, path: &Utf8Path) -> Utf8PathBuf {
122        crate::domain::path_filter::project(path, &self.repo_root)
123    }
124
125    /// The subset of `candidates` this gate judges.
126    ///
127    /// Every subject path pre-commit or an operator hands a gate comes
128    /// through here, and the registry whitelist binds.
129    #[must_use]
130    pub fn subjects<P: AsRef<Utf8Path>>(&self, candidates: impl IntoIterator<Item = P>) -> Vec<P> {
131        candidates
132            .into_iter()
133            .filter(|path| self.filter.judges(&self.relative(path.as_ref())))
134            .collect()
135    }
136
137    /// The subset of `candidates` this gate's exclusions leave.
138    ///
139    /// For a subject set the gate discovered itself. See
140    /// [`PathFilter::retains`].
141    #[must_use]
142    pub fn retained<P: AsRef<Utf8Path>>(&self, candidates: impl IntoIterator<Item = P>) -> Vec<P> {
143        candidates
144            .into_iter()
145            .filter(|path| self.filter.retains(&self.relative(path.as_ref())))
146            .collect()
147    }
148
149    /// The filter itself, for `--explain` and for the renderer.
150    #[must_use]
151    pub const fn filter(&self) -> &PathFilter {
152        &self.filter
153    }
154}
155
156/// One line a failing gate prints.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum Violation {
159    /// A rule violation, rendered as its `FAIL <domain>:<rule> ...` line.
160    Finding(Finding),
161    /// The repository does not have the shape the gate needs; rendered as
162    /// `FAIL <reason>` with no rule to cite.
163    Layout(String),
164    /// A continuation line under a preceding violation, rendered verbatim.
165    Note(String),
166}
167
168impl fmt::Display for Violation {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            Self::Finding(finding) => finding.fmt(f),
172            Self::Layout(reason) => write!(f, "FAIL {reason}"),
173            Self::Note(text) => f.write_str(text),
174        }
175    }
176}
177
178/// A gate that could not run at all — distinct from one that found violations.
179#[derive(Debug, Error)]
180pub enum GateError {
181    /// A file the gate needed could not be read.
182    #[error("{path}: {source}")]
183    Io {
184        /// The path that failed.
185        path: Utf8PathBuf,
186        /// The underlying failure.
187        source: std::io::Error,
188    },
189    /// The instance's debt file cannot be trusted, so no budget gate can
190    /// judge against it.
191    #[error("{0}")]
192    Debt(crate::domain::debt::DebtError),
193}
194
195impl GateError {
196    pub(crate) fn io(path: impl Into<Utf8PathBuf>, source: std::io::Error) -> Self {
197        Self::Io {
198            path: path.into(),
199            source,
200        }
201    }
202}
203
204impl From<GateError> for crate::error::AppError {
205    fn from(error: GateError) -> Self {
206        match error {
207            GateError::Io { path, source } => {
208                let kind = source.kind();
209                Self::Io(std::io::Error::new(kind, format!("{path}: {source}")))
210            }
211            GateError::Debt(error) => Self::Debt(error),
212        }
213    }
214}
215
216/// What every gate returns: the violations it found, or why it could not run.
217pub type GateResult = Result<Vec<Violation>, GateError>;
218
219/// The implementation shape shared by every gate.
220pub type GateFn = fn(&GateCtx, &[String]) -> GateResult;
221
222/// One registry row: everything the deliveries need to know about a gate.
223#[derive(Debug)]
224pub struct GateSpec {
225    /// The gate's identity.
226    pub id: GateId,
227    /// The display name pre-commit shows.
228    pub name: &'static str,
229    /// The subject paths this gate judges, as include globs with
230    /// `{docs_root}` left templated.
231    ///
232    /// Every row states them, under
233    /// `release:a-delivered-gate-reads-what-the-convention-owns`. An empty
234    /// list judges everything the excludes leave, and a row that states one
235    /// carries a comment saying why.
236    pub include: &'static [&'static str],
237    /// The `types:` scope, when the gate takes one.
238    ///
239    /// Pre-commit applies it in addition to the rendered patterns. `sdd
240    /// gate` does not, which is why `--explain` prints it rather than
241    /// folding it into the answer.
242    pub types: Option<&'static str>,
243    /// The subject paths this gate never judges, as exclude globs with
244    /// `{docs_root}` left templated.
245    pub exclude: &'static [&'static str],
246    /// Whether the gate runs regardless of which files changed.
247    pub always_run: bool,
248    /// Whether the gate resolves its own subject set rather than judging
249    /// the paths it is handed.
250    ///
251    /// For such a gate the discovery is the include, so
252    /// [`GateCtx::retained`] applies and the registry whitelist does not.
253    /// `always_run` is not this: `agents-digest-size` runs always and still
254    /// judges what [`walk_files`] hands it, which the registry include
255    /// narrows. `--explain` reads this field, so a wrong value makes the
256    /// diagnostic contradict the gate.
257    pub discovers: bool,
258    /// Every rule the gate can cite in a finding.
259    pub cites: &'static [RuleId],
260    /// The implementation.
261    pub run: GateFn,
262}
263
264/// Look up one gate's registry row.
265#[must_use]
266pub fn spec(id: GateId) -> &'static GateSpec {
267    let index = GateId::ALL.iter().position(|g| *g == id).unwrap_or(0);
268    &GATES[index]
269}
270
271/// The delivered gate set, in [`GateId::ALL`] order.
272pub static GATES: &[GateSpec] = &[
273    GateSpec {
274        id: GateId::AdrCitesALiveRule,
275        name: "decision record citations resolve",
276        include: &[r"{docs_root}/decisions/*.md"],
277        types: None,
278        exclude: &[],
279        always_run: true,
280        discovers: true,
281        cites: adr_cites_a_live_rule::CITES,
282        run: adr_cites_a_live_rule::run,
283    },
284    GateSpec {
285        id: GateId::AdrFilenameShape,
286        name: "decision record filename shape",
287        include: &[r"{docs_root}/decisions/*.md"],
288        types: None,
289        exclude: &[],
290        always_run: false,
291        discovers: false,
292        cites: adr_filename_shape::CITES,
293        run: adr_filename_shape::run,
294    },
295    GateSpec {
296        id: GateId::AdrWordCap,
297        name: "decision record word cap",
298        include: &[r"{docs_root}/decisions/*.md"],
299        types: None,
300        exclude: &[],
301        always_run: true,
302        discovers: true,
303        cites: adr_word_cap::CITES,
304        run: adr_word_cap::run,
305    },
306    GateSpec {
307        id: GateId::AgentsDigestSize,
308        name: "agent digest size",
309        include: &[r"**/AGENTS.md"],
310        types: None,
311        exclude: &[],
312        always_run: true,
313        discovers: false,
314        cites: agents_digest_size::CITES,
315        run: agents_digest_size::run,
316    },
317    GateSpec {
318        id: GateId::ChapterSizeCap,
319        name: "chapter and catalog size",
320        include: &[r"**/*.md"],
321        types: None,
322        exclude: &[],
323        always_run: true,
324        discovers: false,
325        cites: chapter_size_cap::CITES,
326        run: chapter_size_cap::run,
327    },
328    GateSpec {
329        id: GateId::ComparisonDatedTables,
330        name: "comparison tables are dated",
331        include: &[r"**/COMPARISON-*.md"],
332        types: None,
333        exclude: &[],
334        always_run: false,
335        discovers: false,
336        cites: comparison_dated_tables::CITES,
337        run: comparison_dated_tables::run,
338    },
339    GateSpec {
340        id: GateId::ComparisonEscapedPipes,
341        name: "comparison table pipes are escaped",
342        include: &[r"**/COMPARISON-*.md"],
343        types: None,
344        exclude: &[],
345        always_run: false,
346        discovers: false,
347        cites: comparison_escaped_pipes::CITES,
348        run: comparison_escaped_pipes::run,
349    },
350    GateSpec {
351        id: GateId::ComparisonLegend,
352        name: "comparison legend",
353        include: &[r"**/COMPARISON-*.md"],
354        types: None,
355        exclude: &[],
356        always_run: false,
357        discovers: false,
358        cites: comparison_legend::CITES,
359        run: comparison_legend::run,
360    },
361    GateSpec {
362        id: GateId::ComparisonOneReferencePerCell,
363        name: "one reference per comparison cell",
364        include: &[r"**/COMPARISON-*.md"],
365        types: None,
366        exclude: &[],
367        always_run: false,
368        discovers: false,
369        cites: comparison_one_reference_per_cell::CITES,
370        run: comparison_one_reference_per_cell::run,
371    },
372    GateSpec {
373        id: GateId::ComparisonVerdictWord,
374        name: "comparison verdict word",
375        include: &[r"**/COMPARISON-*.md"],
376        types: None,
377        exclude: &[],
378        always_run: false,
379        discovers: false,
380        cites: comparison_verdict_word::CITES,
381        run: comparison_verdict_word::run,
382    },
383    GateSpec {
384        id: GateId::GateMessageCitesARule,
385        name: "gate messages cite a rule",
386        // Judges the registry itself, not a path in the tree: the
387        // subject is every gate row, and the specs it resolves them
388        // against are support.
389        include: &[],
390        types: None,
391        exclude: &[],
392        always_run: true,
393        discovers: false,
394        cites: gate_message_cites_a_rule::CITES,
395        run: gate_message_cites_a_rule::run,
396    },
397    GateSpec {
398        id: GateId::InstanceManifest,
399        name: "instance manifest",
400        include: &[crate::domain::paths::MANIFEST_PATH],
401        types: None,
402        exclude: &[],
403        always_run: true,
404        discovers: true,
405        cites: instance_manifest::CITES,
406        run: instance_manifest::run,
407    },
408    GateSpec {
409        id: GateId::KiBugzillaReportWidth,
410        name: "Bugzilla report width",
411        include: &[r"{docs_root}/reference/known-issues/*.md"],
412        types: None,
413        exclude: &[],
414        always_run: true,
415        discovers: true,
416        cites: ki_bugzilla_report_width::CITES,
417        run: ki_bugzilla_report_width::run,
418    },
419    GateSpec {
420        id: GateId::KiCheckedDate,
421        name: "known issue last-check date",
422        include: &[r"{docs_root}/reference/known-issues/*.md"],
423        types: None,
424        exclude: &[],
425        always_run: true,
426        discovers: true,
427        cites: ki_checked_date::CITES,
428        run: ki_checked_date::run,
429    },
430    GateSpec {
431        id: GateId::KiFilenameShape,
432        name: "known issue filename shape",
433        include: &[r"{docs_root}/reference/known-issues/*.md"],
434        types: None,
435        exclude: &[],
436        always_run: false,
437        discovers: false,
438        cites: ki_filename_shape::CITES,
439        run: ki_filename_shape::run,
440    },
441    GateSpec {
442        id: GateId::KiFiling,
443        name: "known issue filing state",
444        include: &[r"{docs_root}/reference/known-issues/*.md"],
445        types: None,
446        exclude: &[],
447        always_run: true,
448        discovers: true,
449        cites: ki_filing::CITES,
450        run: ki_filing::run,
451    },
452    GateSpec {
453        id: GateId::KiMechanismWalkthrough,
454        name: "known issue mechanism walkthrough",
455        include: &[r"{docs_root}/reference/known-issues/*.md"],
456        types: None,
457        exclude: &[],
458        always_run: true,
459        discovers: true,
460        cites: ki_mechanism_walkthrough::CITES,
461        run: ki_mechanism_walkthrough::run,
462    },
463    GateSpec {
464        id: GateId::KiReportBody,
465        name: "known issue report body",
466        include: &[r"{docs_root}/reference/known-issues/*.md"],
467        types: None,
468        exclude: &[],
469        always_run: true,
470        discovers: true,
471        cites: ki_report_body::CITES,
472        run: ki_report_body::run,
473    },
474    GateSpec {
475        id: GateId::KiRetireWhen,
476        name: "known issue retirement condition",
477        include: &[r"{docs_root}/reference/known-issues/*.md"],
478        types: None,
479        exclude: &[],
480        always_run: true,
481        discovers: true,
482        cites: ki_retire_when::CITES,
483        run: ki_retire_when::run,
484    },
485    GateSpec {
486        id: GateId::KiState,
487        name: "known issue state",
488        include: &[r"{docs_root}/reference/known-issues/*.md"],
489        types: None,
490        exclude: &[],
491        always_run: true,
492        discovers: true,
493        cites: ki_state::CITES,
494        run: ki_state::run,
495    },
496    GateSpec {
497        id: GateId::NoPersonalPath,
498        name: "no personal path",
499        // Judges the whole project. Whether a string is a real person's
500        // home directory does not depend on which conventions a project
501        // follows, so a false positive is nearly impossible and the value
502        // is entirely in breadth. v0.6.5 anchored this to the documentation
503        // root over two register collisions, which a leak check does not
504        // have: a rendered release block carries no home directory. A
505        // project that needs a path exempt reserves it
506        // under `instance:the-project-declares-what-its-gates-judge`.
507        include: &[],
508        types: Some("text"),
509        exclude: &[],
510        always_run: false,
511        discovers: false,
512        cites: no_personal_path::CITES,
513        run: no_personal_path::run,
514    },
515    GateSpec {
516        id: GateId::NoSelfNarration,
517        name: "documents state the present",
518        include: &[r"{docs_root}/{specs,decisions,guides,reference,explanation}/**/*.md"],
519        types: Some("markdown"),
520        exclude: &[r"{docs_root}/decisions/**"],
521        always_run: false,
522        discovers: false,
523        cites: no_self_narration::CITES,
524        run: no_self_narration::run,
525    },
526    GateSpec {
527        id: GateId::ProseStaysUnwrapped,
528        name: "prose lines stay unwrapped",
529        include: &[r"{docs_root}/{specs,decisions,guides,reference,explanation}/**/*.md"],
530        types: Some("markdown"),
531        exclude: &[r"**/CHANGELOG.md"],
532        always_run: false,
533        discovers: false,
534        cites: prose_stays_unwrapped::CITES,
535        run: prose_stays_unwrapped::run,
536    },
537    GateSpec {
538        id: GateId::SpecRequirementParts,
539        name: "spec requirement parts",
540        include: &[r"{docs_root}/specs/SPEC-*.md"],
541        types: None,
542        exclude: &[],
543        always_run: false,
544        discovers: false,
545        cites: spec_requirement_parts::CITES,
546        run: spec_requirement_parts::run,
547    },
548    GateSpec {
549        id: GateId::SpecRuleIdUnique,
550        name: "spec rule IDs are unique",
551        include: &[r"{docs_root}/specs/SPEC-*.md"],
552        types: None,
553        exclude: &[],
554        always_run: true,
555        discovers: true,
556        cites: spec_rule_id_unique::CITES,
557        run: spec_rule_id_unique::run,
558    },
559    GateSpec {
560        id: GateId::SpecSizeCap,
561        name: "spec size cap",
562        include: &[r"{docs_root}/specs/SPEC-*.md"],
563        types: None,
564        exclude: &[],
565        always_run: true,
566        discovers: true,
567        cites: spec_size_cap::CITES,
568        run: spec_size_cap::run,
569    },
570    GateSpec {
571        id: GateId::SpecVerifyHooksExist,
572        name: "spec hook references exist",
573        include: &[r"{docs_root}/specs/SPEC-*.md"],
574        types: None,
575        exclude: &[],
576        always_run: true,
577        discovers: true,
578        cites: spec_verify_hooks_exist::CITES,
579        run: spec_verify_hooks_exist::run,
580    },
581    GateSpec {
582        id: GateId::SuppressionNamesItsCase,
583        name: "suppressions name a known issue",
584        // Judges every file in the project, because a `KI-` citation can be
585        // written in any of them and this gate reads no language. The
586        // documentation root is out: a specification, a chapter, and a
587        // record each write the token while teaching it. The known-issue
588        // records it resolves a case against are support.
589        include: &[],
590        types: None,
591        exclude: &[r"{docs_root}/**"],
592        always_run: true,
593        discovers: false,
594        cites: suppression_names_its_case::CITES,
595        run: suppression_names_its_case::run,
596    },
597    GateSpec {
598        id: GateId::TrackingRegistry,
599        name: "tracking registry is valid and current",
600        include: &[r"{docs_root}/reference/tracking.yaml"],
601        types: None,
602        exclude: &[],
603        always_run: true,
604        discovers: true,
605        cites: tracking_registry::CITES,
606        run: tracking_registry::run,
607    },
608];
609
610/// The directories every repository walk prunes: vendored or generated trees
611/// a consumer cannot be asked to author.
612pub const PRUNED_DIRS: &[&str] = &[
613    ".git",
614    "node_modules",
615    ".venv",
616    "vendor",
617    "third-party",
618    "target",
619    "dist",
620];
621
622/// Count the newline-terminated lines of a text, as `wc -l` does.
623#[must_use]
624pub fn line_count(text: &str) -> usize {
625    text.matches('\n').count()
626}
627
628/// Read a repository-relative text file for a gate.
629///
630/// # Errors
631///
632/// [`GateError::Io`] naming the path when the file cannot be read.
633pub fn read_text(ctx: &GateCtx, relative: impl AsRef<Utf8Path>) -> Result<String, GateError> {
634    let relative = relative.as_ref();
635    std::fs::read_to_string(ctx.path(relative)).map_err(|source| GateError::io(relative, source))
636}
637
638/// Every value a front-matter key carries, in the order the keys appear.
639///
640/// The scan is the leading `---` block alone, so a `state:` line in the
641/// prose below it is text about the record rather than the record's own
642/// field. A key stated twice yields two entries, which is what makes
643/// "exactly one" decidable.
644#[must_use]
645pub fn front_matter_values(text: &str, key: &str) -> Vec<String> {
646    let mut lines = text.lines();
647    if lines.next() != Some("---") {
648        return Vec::new();
649    }
650    lines
651        .take_while(|line| *line != "---")
652        .filter_map(|line| {
653            line.strip_prefix(key)
654                .and_then(|rest| rest.strip_prefix(':'))
655        })
656        .map(|value| value.trim().to_string())
657        .collect()
658}
659
660/// The traversal pruner: [`PRUNED_DIRS`] as an `ignore` override.
661///
662/// `Override` is the right tool here and the wrong one in
663/// [`crate::domain::path_filter`]. Pruning wants one boolean per directory
664/// and no provenance, which is exactly what it gives.
665fn pruner(root: &Utf8Path) -> ignore::overrides::Override {
666    let mut builder = ignore::overrides::OverrideBuilder::new(root.as_std_path());
667    for dir in PRUNED_DIRS {
668        // `!` marks an exclude in `Override`'s own grammar, which is not
669        // the restricted grammar `PathFilter` carries.
670        let _ = builder.add(&format!("!{dir}/**"));
671        let _ = builder.add(&format!("!{dir}"));
672    }
673    builder
674        .build()
675        .unwrap_or_else(|_| ignore::overrides::Override::empty())
676}
677
678/// Walk the repository and yield every file as a `./`-prefixed
679/// repository-relative path in sorted order.
680///
681/// The walk prunes [`PRUNED_DIRS`] and honours the repository's committed
682/// `.gitignore`. It honours no machine-local ignore source: `.git/info/exclude`,
683/// the user's global excludes file, and ignore files above the repository
684/// root are all disabled, because a gate whose answer depends on whose
685/// checkout it runs in is not a gate.
686#[must_use]
687pub fn walk_files(ctx: &GateCtx) -> Vec<Utf8PathBuf> {
688    let root = ctx.repo_root.as_std_path();
689    let mut files: Vec<Utf8PathBuf> = ignore::WalkBuilder::new(root)
690        .standard_filters(false)
691        .git_ignore(true)
692        .git_exclude(false)
693        .git_global(false)
694        .ignore(false)
695        .parents(false)
696        .require_git(false)
697        .hidden(false)
698        .overrides(pruner(&ctx.repo_root))
699        .build()
700        .filter_map(Result::ok)
701        .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
702        .filter_map(|entry| {
703            let relative = entry.path().strip_prefix(root).ok()?.to_str()?;
704            Some(Utf8PathBuf::from(format!("./{relative}")))
705        })
706        .collect();
707    files.sort();
708    ctx.subjects(files)
709}
710
711#[cfg(test)]
712pub(crate) mod tests_support {
713    /// A repository holding one known-issue record with the given `state:`
714    /// value and `retire_when:` line.
715    pub fn ki_fixture_state(state: &str, retire_line: &str) -> tempfile::TempDir {
716        ki_record(&format!(
717            "---\nupstream: https://example.invalid/issues\nstate: {state}\nfiling: gathering\n{retire_line}---\n# Vendor issue\n## How it works\nRun.\n"
718        ))
719    }
720
721    /// A repository holding one known-issue record with the given `state:`
722    /// value and `checked:` line.
723    pub fn ki_fixture_checked(state: &str, checked_line: &str) -> tempfile::TempDir {
724        ki_record(&format!(
725            "---\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"
726        ))
727    }
728
729    /// A repository holding one known-issue record with a conforming
730    /// frontmatter and the given body.
731    pub fn ki_fixture_body(body: &str) -> tempfile::TempDir {
732        ki_record(&format!(
733            "---\nupstream: https://example.invalid/issues\nstate: masked\nfiling: gathering\nretire_when: release >= 2.0\n---\n{body}"
734        ))
735    }
736
737    /// A repository holding one filed known-issue record with the given
738    /// `upstream:` value and body.
739    pub fn ki_fixture_upstream(upstream: &str, body: &str) -> tempfile::TempDir {
740        ki_fixture_filing("filed", upstream, body)
741    }
742
743    /// A repository holding one known-issue record with the given `filing:`
744    /// value, `upstream:` value and body.
745    pub fn ki_fixture_filing(filing: &str, upstream: &str, body: &str) -> tempfile::TempDir {
746        ki_record(&format!(
747            "---\nupstream: {upstream}\nstate: masked\nfiling: {filing}\nretire_when: release >= 2.0\n---\n{body}"
748        ))
749    }
750
751    fn ki_record(text: &str) -> tempfile::TempDir {
752        let dir = tempfile::tempdir().unwrap();
753        let records = dir.path().join("_docs/reference/known-issues");
754        std::fs::create_dir_all(&records).unwrap();
755        std::fs::write(records.join("KI-vendor.md"), text).unwrap();
756        dir
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    /// A repository holding one file at each named path.
765    fn tree(paths: &[(&str, &str)]) -> tempfile::TempDir {
766        let dir = tempfile::tempdir().expect("a scratch directory");
767        for (path, body) in paths {
768            let full = dir.path().join(path);
769            if let Some(parent) = full.parent() {
770                std::fs::create_dir_all(parent).expect("the parent exists");
771            }
772            std::fs::write(&full, body).expect("the file is written");
773        }
774        dir
775    }
776
777    fn walked(dir: &tempfile::TempDir) -> Vec<String> {
778        let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
779            .expect("the scratch path is UTF-8");
780        walk_files(&GateCtx::new(root))
781            .into_iter()
782            .map(|p| p.to_string())
783            .collect()
784    }
785
786    #[test]
787    fn walk_files_skips_a_gitignored_file() {
788        let dir = tree(&[
789            (".gitignore", "generated.md\n"),
790            ("generated.md", "x\n"),
791            ("kept.md", "x\n"),
792        ]);
793        let files = walked(&dir);
794        assert!(files.contains(&"./kept.md".to_string()));
795        assert!(
796            !files.contains(&"./generated.md".to_string()),
797            "a git-ignored file still reached a walking gate: {files:?}"
798        );
799    }
800
801    #[test]
802    fn walk_files_ignores_a_machine_local_exclude_file() {
803        // The hostile case. A machine-local exclude must not hide a governed
804        // file, or one operator's checkout reports a violation another's
805        // does not.
806        let dir = tree(&[
807            (".git/info/exclude", "governed.md\n"),
808            ("governed.md", "x\n"),
809        ]);
810        assert!(
811            walked(&dir).contains(&"./governed.md".to_string()),
812            "a machine-local exclude hid a governed file"
813        );
814    }
815
816    #[test]
817    fn walk_files_still_prunes_the_pruned_dirs() {
818        let dir = tree(&[
819            ("target/debug/artifact", "x\n"),
820            ("node_modules/pkg/index.js", "x\n"),
821            ("src/main.rs", "x\n"),
822        ]);
823        let files = walked(&dir);
824        assert_eq!(files, vec!["./src/main.rs".to_string()]);
825    }
826
827    #[test]
828    fn walk_files_yields_dotted_paths() {
829        let dir = tree(&[(".markdownlint/base.yaml", "x\n")]);
830        assert!(walked(&dir).contains(&"./.markdownlint/base.yaml".to_string()));
831    }
832
833    #[test]
834    fn registry_covers_every_gate_exactly_once_in_order() {
835        assert_eq!(GATES.len(), GateId::ALL.len());
836        for (row, id) in GATES.iter().zip(GateId::ALL) {
837            assert_eq!(row.id, *id);
838            assert_eq!(spec(*id).id, *id);
839        }
840    }
841
842    #[test]
843    fn every_gate_declares_the_rules_it_cites() {
844        for row in GATES {
845            assert!(!row.cites.is_empty(), "{} cites nothing", row.id);
846        }
847    }
848
849    #[test]
850    fn cited_rules_resolve_in_the_embedded_specs() {
851        let defined = crate::embedded::spec_rule_ids();
852        for row in GATES {
853            for rule in row.cites {
854                assert!(
855                    defined.contains(rule.as_str()),
856                    "{}: {rule} is undefined",
857                    row.id
858                );
859            }
860        }
861    }
862}