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