Skip to main content

spec_driven_docs/commands/
gate.rs

1//! `gate` subcommand: runtime-shape.
2//!
3//! Runs one delivered gate against the invoking repository, printing every
4//! violation to stdout, lists the registry, or explains which gates judge a
5//! path. Gate semantics live in `gates`; this handler resolves the subject
6//! filter, holds the containment boundary, and reports.
7
8use camino::Utf8Path;
9
10use crate::cli::gate::GateArgs;
11use crate::context::AppContext;
12use crate::domain::gate_id::GateId;
13use crate::domain::instance_config::{self, InstanceConfig};
14use crate::domain::path_filter::{Decision, PathFilter};
15use crate::error::AppError;
16use crate::gates::{GATES, GateCtx, spec};
17use crate::output;
18
19/// The documentation root patterns are templated against.
20///
21/// It comes from the instance's own record, because the two profiles use
22/// different roots. Templating against a fixed `_docs` would build patterns
23/// no path in a `codebase` instance matches, and every filename-selected
24/// gate there would silently judge nothing.
25fn docs_root() -> String {
26    crate::services::verifier::read_manifest(Utf8Path::new("."))
27        .map_or_else(|_| "_docs".to_string(), |m| m.docs_root.to_string())
28}
29
30#[allow(
31    clippy::literal_string_with_formatting_args,
32    reason = "the braces are the wiring template's placeholder, not a formatting argument"
33)]
34fn substitute_root(pattern: &str, docs_root: &str) -> String {
35    pattern.replace("{docs_root}", docs_root)
36}
37
38/// Build one gate's subject filter from every layer.
39///
40/// The registry is the first layer, the project's declaration the second and
41/// the fourth, and the command-line flags the third.
42/// [`instance_config::resolve`] owns the algorithm, so the command line and
43/// the renderer cannot disagree about it.
44fn filter_for(
45    id: GateId,
46    declaration: &InstanceConfig,
47    docs_root: &str,
48    include: &[String],
49    exclude: &[String],
50) -> Result<PathFilter, AppError> {
51    let row = spec(id);
52    let registry_include: Vec<String> = row
53        .include
54        .iter()
55        .map(|g| substitute_root(g, docs_root))
56        .collect();
57    let registry_exclude: Vec<String> = row
58        .exclude
59        .iter()
60        .map(|g| substitute_root(g, docs_root))
61        .collect();
62    instance_config::resolve(
63        &registry_include,
64        &registry_exclude,
65        declaration.for_gate(id),
66        include,
67        exclude,
68        &declaration.reserved,
69    )
70    .map_err(|error| AppError::Usage(error.to_string()))
71}
72
73/// The declaration this invocation resolves against.
74fn declaration() -> Result<InstanceConfig, AppError> {
75    InstanceConfig::read(Utf8Path::new(".")).map_err(|error| AppError::Usage(error.to_string()))
76}
77
78/// Refuse a path this command must not follow.
79///
80/// A relative path that climbs out of the repository is refused, and so is
81/// one reached through a link that leaves it. An absolute path is not:
82/// pre-commit hands the message file at the `commit-msg` stage by absolute
83/// path, and it sits outside the working tree by design, so refusing
84/// absolutes would break a delivered wiring. What a gate reads stays bounded
85/// by what pre-commit or the operator names, which was already true.
86fn contained(path: &str) -> Result<(), AppError> {
87    let candidate = Utf8Path::new(path);
88    if candidate.components().any(|part| part.as_str() == "..") {
89        return Err(AppError::Usage(format!(
90            "{path} climbs out of the repository"
91        )));
92    }
93    if candidate.is_absolute() {
94        return Ok(());
95    }
96    // A relative path that resolves outside the repository got there through
97    // a link, which is the case the `..` check cannot see.
98    if let (Ok(resolved), Ok(root)) = (std::fs::canonicalize(path), std::fs::canonicalize(".")) {
99        if !resolved.starts_with(&root) {
100            return Err(AppError::Usage(format!(
101                "{path} is reached through a link that leaves the repository"
102            )));
103        }
104    }
105    Ok(())
106}
107
108/// Every include pattern a filter carries, for the explain output.
109fn declared_globs(filter: &PathFilter) -> String {
110    filter
111        .includes()
112        .iter()
113        .map(|pattern| pattern.glob.clone())
114        .collect::<Vec<_>>()
115        .join(", ")
116}
117
118/// Report which gates judge one path, and what decided each answer.
119///
120/// This answers path-declaration eligibility. It is not a prediction of what
121/// pre-commit will run: pre-commit applies `types:` in addition to the
122/// rendered patterns and this command does not, so a row's `types:` is
123/// printed rather than folded into the answer.
124fn explain(path: &str) -> Result<(), AppError> {
125    contained(path)?;
126    let declaration = declaration()?;
127    let docs_root = docs_root();
128    // Decide on the form a pattern speaks. `GateCtx` does this for the run
129    // path; this one answers without a context.
130    let relative = crate::domain::path_filter::project(Utf8Path::new(path), Utf8Path::new("."));
131    let subject = relative.as_path();
132    for gate in GATES {
133        let filter = filter_for(gate.id, &declaration, &docs_root, &[], &[])?;
134        let types = gate
135            .types
136            .map_or_else(String::new, |types| format!("  types: [{types}]"));
137        // Three answers for a discovering row, because two would lie. The
138        // gate judges what its discovery produces, and it also judges a
139        // path an operator points it at through a support root. So a path
140        // outside its registry include is not "not included" — that is the
141        // whitelist answer and this gate does not apply one — and it is not
142        // "judges" either, because the discovery will not produce it on its
143        // own.
144        let line = if gate.discovers {
145            match filter.decide(subject) {
146                Decision::Skipped(pattern) => format!(
147                    "skipped      {}  exclude {}  ({}){types}",
148                    gate.id, pattern.glob, pattern.layer
149                ),
150                _ if !filter.retains(subject) => format!(
151                    "not included {}  include {}{types}",
152                    gate.id,
153                    declared_globs(&filter)
154                ),
155                Decision::Read => format!("judges       {}{types}", gate.id),
156                Decision::NotIncluded => format!(
157                    "not discovered {}  its set is {}{types}",
158                    gate.id,
159                    declared_globs(&filter)
160                ),
161            }
162        } else {
163            match filter.decide(subject) {
164                Decision::Read => format!("judges       {}{types}", gate.id),
165                Decision::Skipped(pattern) => format!(
166                    "skipped      {}  exclude {}  ({}){types}",
167                    gate.id, pattern.glob, pattern.layer
168                ),
169                Decision::NotIncluded => format!(
170                    "not included {}  include {}{types}",
171                    gate.id,
172                    declared_globs(&filter)
173                ),
174            }
175        };
176        output::line(line);
177    }
178    output::line("note: pre-commit also applies each row's types:, which this answer does not.");
179    output::line(
180        "note: `not discovered` means outside the gate's own set. Some of these take an explicit record root and would then judge it; others read one fixed location and never will.",
181    );
182    Ok(())
183}
184
185/// Run, list, or explain.
186///
187/// # Errors
188///
189/// [`AppError::Violations`] when the gate found any; [`AppError::Usage`] for
190/// a refused path or a malformed pattern; I/O errors when it could not run.
191pub fn run(_ctx: &AppContext, args: GateArgs) -> Result<(), AppError> {
192    let GateArgs {
193        id,
194        files,
195        list,
196        explain: explain_path,
197        include,
198        exclude,
199    } = args;
200    if list {
201        for gate in GATES {
202            output::line(format!("{}: {}", gate.id, gate.name));
203        }
204        return Ok(());
205    }
206    if let Some(path) = explain_path {
207        return explain(&path);
208    }
209    let Some(id) = id else {
210        return Err(AppError::Usage(
211            "a gate id, --list, or --explain is required".to_string(),
212        ));
213    };
214
215    for path in &files {
216        contained(path)?;
217    }
218    let filter = filter_for(id, &declaration()?, &docs_root(), &include, &exclude)?;
219    // Filter the passed paths always. Ruff carries `--force-exclude`
220    // because the opposite default surprised people under pre-commit, which
221    // passes changed files explicitly, and pre-commit is this tool's only
222    // caller.
223    let gate_ctx = GateCtx::with_filter(".", filter);
224    // A positional value is a file to judge or a record root to resolve,
225    // and `src/cli/gate.rs` says so. Filter the files; pass a directory
226    // through untouched, because it is a support root and the records
227    // discovered beneath it are filtered where the gate resolves them.
228    // Filtering the root itself would silently drop it and let the gate
229    // fall back to the default location, reporting nothing.
230    let (roots, subjects): (Vec<String>, Vec<String>) = files
231        .into_iter()
232        .partition(|path| gate_ctx.path(path).is_dir());
233    let judged: Vec<String> = roots
234        .into_iter()
235        .chain(
236            gate_ctx
237                .subjects(subjects.iter().map(Utf8Path::new))
238                .into_iter()
239                .map(ToString::to_string),
240        )
241        .collect();
242
243    let violations = (spec(id).run)(&gate_ctx, &judged)?;
244    if violations.is_empty() {
245        return Ok(());
246    }
247    for violation in &violations {
248        output::line(violation);
249    }
250    Err(AppError::Violations {
251        count: violations.len(),
252    })
253}