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