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