Skip to main content

spec_driven_docs/services/
hooks_render.rs

1//! Render the gate registry as the managed pre-commit block.
2//!
3//! The registry is the one declaration and this is its one delivery: the
4//! block an instance's configuration carries, rendered at install time and
5//! never committed anywhere in between. A gate reaches an instance because
6//! it is in the registry, so it cannot reach the payload and miss the
7//! wiring. What the registry contains is `gates`' business; where the
8//! output lands is the caller's.
9//!
10//! There is deliberately no second shape. A `.pre-commit-hooks.yaml` would
11//! serve repositories that never adopt this framework, and most gates read
12//! an instance layout those repositories do not have.
13
14use std::fmt::Write as _;
15
16use crate::domain::instance_config::InstanceConfig;
17use crate::domain::marker;
18use crate::gates::GATES;
19
20/// The pre-commit language every delivered gate declares.
21///
22/// An instance runs `sdd` from its own PATH, which is what `system` means;
23/// no other language has a caller.
24const LANGUAGE: &str = "system";
25
26/// The markdown linter the delivered configurations are written for.
27///
28/// The landing writes three configurations under the instance directory,
29/// so it wires the hooks that read them. A configuration whose wiring a
30/// project has to guess is a file that does nothing, and the shapes it
31/// holds are ones no delivered gate reads.
32const MARKDOWNLINT_REPO: &str = "https://github.com/DavidAnson/markdownlint-cli2";
33
34/// The linter revision this release wires.
35const MARKDOWNLINT_REV: &str = "v0.23.2";
36
37/// Where the landing puts the configurations those hooks read.
38const MARKDOWNLINT_DIR: &str = ".spec-driven-docs/markdownlint";
39
40/// Everything a render depends on.
41#[derive(Debug, Clone)]
42pub struct RenderOptions {
43    /// What replaces `{docs_root}` in wiring patterns — the literal root
44    /// the instance's profile selected.
45    pub docs_root: String,
46    /// The command prefix an entry invokes, e.g. `sdd` or `cargo run -q --`.
47    pub entry: String,
48    /// The sequence-item indentation of the consumer's `repos:` entries.
49    pub indent: String,
50    /// What the project declared about what its gates judge.
51    ///
52    /// The block is rendered from this, so the project never edits the
53    /// block and an upgrade never meets its choice.
54    pub declaration: InstanceConfig,
55}
56
57impl Default for RenderOptions {
58    fn default() -> Self {
59        Self {
60            docs_root: "_docs".to_string(),
61            entry: "sdd".to_string(),
62            indent: "  ".to_string(),
63            declaration: InstanceConfig::default(),
64        }
65    }
66}
67
68/// A single-quoted YAML scalar; an apostrophe is escaped by doubling it.
69fn quoted(value: &str) -> String {
70    format!("'{}'", value.replace('\'', "''"))
71}
72
73#[allow(
74    clippy::literal_string_with_formatting_args,
75    reason = "the braces are the wiring template's placeholder, not a formatting argument"
76)]
77fn substitute_root(pattern: &str, docs_root: &str) -> String {
78    pattern.replace("{docs_root}", docs_root)
79}
80
81/// Escape one literal character for a regex.
82fn escape(out: &mut String, ch: char) {
83    if ".^$+()|[]{}\\*?".contains(ch) {
84        out.push('\\');
85    }
86    out.push(ch);
87}
88
89/// Project one glob onto a Python regex for pre-commit's `files:` field.
90///
91/// # The contract is one-directional
92///
93/// The rendered selection MUST match every path the matcher judges, and MAY
94/// match more. `globset` compiles to a Rust byte regex and pre-commit
95/// applies Python `re.search`, and the two languages are not the same, so
96/// promising equivalence would mean writing and maintaining a converter
97/// between them. `PathFilter` stays authoritative at runtime and drops the
98/// excess, which makes a lossy projection cost a wasted invocation and never
99/// a missed violation.
100///
101/// The restricted grammar `crate::domain::path_filter` enforces is what
102/// makes the projection small: a leading `!` or `#`, an absolute path, and a
103/// `..` component are already refused, so this handles `*`, `**`, `?`, a
104/// character class, a brace alternation, and literals.
105fn glob_to_regex(glob: &str) -> String {
106    let mut out = String::from("^");
107    let bytes: Vec<char> = glob.chars().collect();
108    let mut i = 0;
109    while i < bytes.len() {
110        match bytes[i] {
111            '*' if bytes.get(i + 1) == Some(&'*') => {
112                if bytes.get(i + 2) == Some(&'/') {
113                    // `**/` matches zero or more leading directories.
114                    out.push_str("(?:.*/)?");
115                    i += 3;
116                } else {
117                    out.push_str(".*");
118                    i += 2;
119                }
120            }
121            '*' => {
122                out.push_str("[^/]*");
123                i += 1;
124            }
125            '?' => {
126                out.push_str("[^/]");
127                i += 1;
128            }
129            '[' => {
130                let close = bytes[i..].iter().position(|c| *c == ']').map(|p| i + p);
131                if let Some(close) = close {
132                    out.push('[');
133                    let mut j = i + 1;
134                    if bytes.get(j) == Some(&'!') {
135                        out.push('^');
136                        j += 1;
137                    }
138                    for ch in &bytes[j..close] {
139                        out.push(*ch);
140                    }
141                    out.push(']');
142                    i = close + 1;
143                } else {
144                    escape(&mut out, '[');
145                    i += 1;
146                }
147            }
148            '{' => {
149                let close = bytes[i..].iter().position(|c| *c == '}').map(|p| i + p);
150                if let Some(close) = close {
151                    out.push_str("(?:");
152                    let branches: String = bytes[i + 1..close].iter().collect();
153                    let rendered: Vec<String> = branches
154                        .split(',')
155                        .map(|branch| {
156                            let whole = glob_to_regex(branch);
157                            whole
158                                .trim_start_matches('^')
159                                .trim_end_matches('$')
160                                .to_string()
161                        })
162                        .collect();
163                    out.push_str(&rendered.join("|"));
164                    out.push(')');
165                    i = close + 1;
166                } else {
167                    escape(&mut out, '{');
168                    i += 1;
169                }
170            }
171            ch => {
172                escape(&mut out, ch);
173                i += 1;
174            }
175        }
176    }
177    out.push('$');
178    out
179}
180
181/// Project many globs onto one alternation, as pre-commit takes one regex.
182fn render_patterns(globs: &[&str], docs_root: &str) -> Option<String> {
183    let rendered: Vec<String> = globs
184        .iter()
185        .map(|glob| glob_to_regex(&substitute_root(glob, docs_root)))
186        .collect();
187    match rendered.len() {
188        0 => None,
189        1 => Some(rendered[0].clone()),
190        _ => {
191            let inner: Vec<String> = rendered
192                .iter()
193                .map(|r| r.trim_start_matches('^').trim_end_matches('$').to_string())
194                .collect();
195            Some(format!("^(?:{})$", inner.join("|")))
196        }
197    }
198}
199
200/// Render the gate entries alone, without the markers or the verifier.
201fn render_gates(options: &RenderOptions) -> String {
202    let item = format!("{0}    - ", options.indent);
203    let field = format!("{0}      ", options.indent);
204    let mut out = String::new();
205    for gate in GATES {
206        let _ = writeln!(out, "{item}id: {}", gate.id);
207        let _ = writeln!(out, "{field}name: {}", quoted(gate.name));
208        let _ = writeln!(out, "{field}entry: {} gate {}", options.entry, gate.id);
209        let _ = writeln!(out, "{field}language: {LANGUAGE}");
210        // An `always_run` row takes no filenames, so pre-commit ignores
211        // `files:` and `exclude:` there. The registry still declares what
212        // the row judges, and `PathFilter` still applies it; emitting a
213        // selector pre-commit never reads would tell a reader of the block
214        // something untrue.
215        let declared = options.declaration.for_gate(gate.id);
216        // A project `include` replaces the registry's, and every `exclude`
217        // layer extends. `instance_config` states the algorithm; this only
218        // projects the result.
219        let include: Vec<&str> = declared.filter(|d| !d.include.is_empty()).map_or_else(
220            || gate.include.to_vec(),
221            |declared| declared.include.iter().map(String::as_str).collect(),
222        );
223        let exclude: Vec<&str> = gate
224            .exclude
225            .iter()
226            .copied()
227            .chain(
228                declared
229                    .into_iter()
230                    .flat_map(|d| &d.exclude)
231                    .map(String::as_str),
232            )
233            .chain(options.declaration.reserved.iter().map(String::as_str))
234            .collect();
235
236        if !gate.always_run
237            && let Some(files) = render_patterns(&include, &options.docs_root)
238        {
239            let _ = writeln!(out, "{field}files: {}", quoted(&files));
240        }
241        if let Some(types) = gate.types {
242            let _ = writeln!(out, "{field}types: [{types}]");
243        }
244        if !gate.always_run
245            && let Some(exclude) = render_patterns(&exclude, &options.docs_root)
246        {
247            let _ = writeln!(out, "{field}exclude: {}", quoted(&exclude));
248        }
249        if gate.always_run {
250            let _ = writeln!(out, "{field}always_run: true");
251            let _ = writeln!(out, "{field}pass_filenames: false");
252        }
253    }
254    out
255}
256
257/// The `files:` and `exclude:` a hook entry declares, by hook id.
258///
259/// Read from rendered or hand-maintained YAML alike, so the two can be
260/// compared. A region may carry hooks this renderer never emits — this
261/// repository's own does — so the comparison is per gate rather than over
262/// the whole region.
263#[must_use]
264pub fn selectors(
265    block: &str,
266) -> std::collections::BTreeMap<String, (Option<String>, Option<String>)> {
267    let mut found = std::collections::BTreeMap::new();
268    let mut current: Option<String> = None;
269    for line in block.lines() {
270        let trimmed = line.trim_start();
271        if let Some(id) = trimmed.strip_prefix("- id: ") {
272            current = Some(id.trim().to_string());
273            found.insert(id.trim().to_string(), (None, None));
274            continue;
275        }
276        let Some(id) = current.as_ref() else { continue };
277        if let Some(value) = trimmed.strip_prefix("files: ") {
278            if let Some(entry) = found.get_mut(id) {
279                entry.0 = Some(value.trim().to_string());
280            }
281        } else if let Some(value) = trimmed.strip_prefix("exclude: ")
282            && let Some(entry) = found.get_mut(id)
283        {
284            entry.1 = Some(value.trim().to_string());
285        }
286    }
287    found
288}
289
290/// Render the complete managed block an instance's configuration carries:
291/// markers, the verifier hook, and every gate.
292#[must_use]
293pub fn render_block(options: &RenderOptions) -> String {
294    let indent = &options.indent;
295    let mut out = String::new();
296    out.push_str(marker::BEGIN);
297    out.push('\n');
298    let _ = writeln!(out, "{indent}- repo: local");
299    let _ = writeln!(out, "{indent}  hooks:");
300    let _ = writeln!(out, "{indent}    - id: spec-driven-docs-verify");
301    let _ = writeln!(out, "{indent}      name: verify spec-driven docs instance");
302    let _ = writeln!(out, "{indent}      entry: {} verify", options.entry);
303    let _ = writeln!(out, "{indent}      language: {LANGUAGE}");
304    let _ = writeln!(out, "{indent}      always_run: true");
305    let _ = writeln!(out, "{indent}      pass_filenames: false");
306    out.push_str(&render_gates(options));
307    out.push_str(&render_markdownlint(options));
308    out.push_str(marker::END);
309    out.push('\n');
310    out
311}
312
313/// Wire the markdown linter to the configurations the landing writes.
314fn render_markdownlint(options: &RenderOptions) -> String {
315    let indent = &options.indent;
316    let docs_root = &options.docs_root;
317    let mut out = String::new();
318    let _ = writeln!(out, "{indent}- repo: {MARKDOWNLINT_REPO}");
319    let _ = writeln!(out, "{indent}  rev: {MARKDOWNLINT_REV}");
320    let _ = writeln!(out, "{indent}  hooks:");
321
322    let _ = writeln!(out, "{indent}    - id: markdownlint-cli2");
323    let _ = writeln!(out, "{indent}      alias: md-relative-links");
324    let _ = writeln!(out, "{indent}      name: markdownlint relative links");
325    let _ = writeln!(
326        out,
327        "{indent}      additional_dependencies: ['markdownlint-rule-relative-links']"
328    );
329    let _ = writeln!(
330        out,
331        "{indent}      args: ['--config', '{MARKDOWNLINT_DIR}/relative-links.markdownlint-cli2.jsonc']"
332    );
333    let _ = writeln!(
334        out,
335        "{indent}      exclude: {}",
336        quoted(&format!("^{docs_root}/decisions/"))
337    );
338
339    let _ = writeln!(out, "{indent}    - id: markdownlint-cli2");
340    let _ = writeln!(out, "{indent}      alias: md-spec");
341    let _ = writeln!(out, "{indent}      name: markdownlint spec heading shape");
342    let _ = writeln!(
343        out,
344        "{indent}      files: {}",
345        quoted(&format!("^{docs_root}/specs/SPEC-[a-z0-9-]+\\.md$"))
346    );
347    let _ = writeln!(
348        out,
349        "{indent}      args: ['--config', '{MARKDOWNLINT_DIR}/spec.markdownlint-cli2.jsonc']"
350    );
351
352    let _ = writeln!(out, "{indent}    - id: markdownlint-cli2");
353    let _ = writeln!(out, "{indent}      alias: md-adr");
354    let _ = writeln!(
355        out,
356        "{indent}      name: markdownlint decision heading shape"
357    );
358    let _ = writeln!(
359        out,
360        "{indent}      files: {}",
361        quoted(&format!("^{docs_root}/decisions/ADR-[a-z-]+\\.md$"))
362    );
363    let _ = writeln!(
364        out,
365        "{indent}      args: ['--config', '{MARKDOWNLINT_DIR}/adr.markdownlint-cli2.jsonc']"
366    );
367    out
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn the_linter_reads_the_configurations_the_landing_writes() {
376        let out = render_markdownlint(&RenderOptions::default());
377        assert!(out.contains(MARKDOWNLINT_REPO), "{out}");
378        for name in ["relative-links", "spec", "adr"] {
379            assert!(
380                out.contains(&format!(
381                    "{MARKDOWNLINT_DIR}/{name}.markdownlint-cli2.jsonc"
382                )),
383                "{name} is not wired: {out}"
384            );
385        }
386        assert!(
387            out.contains("'^_docs/specs/SPEC-[a-z0-9-]+\\.md$'"),
388            "{out}"
389        );
390    }
391
392    #[test]
393    fn the_documentation_root_reaches_the_linter_wiring() {
394        let out = render_markdownlint(&RenderOptions {
395            docs_root: "docs".to_string(),
396            ..RenderOptions::default()
397        });
398        assert!(out.contains("'^docs/decisions/ADR-[a-z-]+\\.md$'"), "{out}");
399        assert!(!out.contains("_docs/"), "{out}");
400    }
401
402    #[test]
403    fn every_gate_renders_its_wiring_fields() {
404        let out = render_gates(&RenderOptions::default());
405        assert!(out.starts_with("      - id: adr-cites-a-live-rule\n"));
406        assert!(out.contains("        entry: sdd gate adr-filename-shape\n"));
407        assert!(out.contains("        language: system\n"));
408        assert!(out.contains("        types: [markdown]\n"));
409        assert_eq!(out.matches("- id: ").count(), crate::gates::GATES.len());
410    }
411
412    /// The profile picks the root, so a `docs` instance wires `docs` paths.
413    #[test]
414    fn the_docs_root_reaches_every_templated_pattern() {
415        let out = render_gates(&RenderOptions {
416            docs_root: "docs".to_string(),
417            ..RenderOptions::default()
418        });
419        assert!(out.contains("        files: '^docs/decisions/[^/]*\\.md$'\n"));
420        assert!(out.contains("        exclude: '^docs/decisions/.*$'\n"));
421        assert!(!out.contains("{docs_root}"));
422    }
423
424    #[test]
425    fn block_style_carries_the_markers_and_the_verifier() {
426        let out = render_block(&RenderOptions::default());
427        assert!(out.starts_with("# BEGIN spec-driven-docs managed\n"));
428        assert!(out.ends_with("# END spec-driven-docs managed\n"));
429        assert!(out.contains("      - id: spec-driven-docs-verify\n"));
430        assert!(out.contains("        entry: sdd verify\n"));
431        assert!(out.contains("      - id: adr-filename-shape\n"));
432        assert!(out.contains("        files: '^_docs/decisions/[^/]*\\.md$'\n"));
433    }
434
435    #[test]
436    fn a_glob_projects_onto_an_anchored_regex() {
437        assert_eq!(glob_to_regex("README.md"), r"^README\.md$");
438        assert_eq!(glob_to_regex("_docs/*.md"), r"^_docs/[^/]*\.md$");
439        assert_eq!(glob_to_regex("_docs/**/*.md"), r"^_docs/(?:.*/)?[^/]*\.md$");
440        assert_eq!(glob_to_regex("**/AGENTS.md"), r"^(?:.*/)?AGENTS\.md$");
441        assert_eq!(glob_to_regex("vendor/**"), r"^vendor/.*$");
442        assert_eq!(glob_to_regex("a?.md"), r"^a[^/]\.md$");
443        assert_eq!(glob_to_regex("[abc].md"), r"^[abc]\.md$");
444        assert_eq!(glob_to_regex("{SPEC,ADR}-a.md"), r"^(?:SPEC|ADR)-a\.md$");
445    }
446
447    #[test]
448    fn several_includes_render_as_one_alternation() {
449        let rendered = render_patterns(&["a.md", "b/*.md"], "_docs").expect("two patterns render");
450        assert_eq!(rendered, r"^(?:a\.md|b/[^/]*\.md)$");
451        assert_eq!(render_patterns(&[], "_docs"), None);
452    }
453
454    /// The one-directional contract, asserted over every row: pre-commit's
455    /// rendered selection matches everything the matcher judges.
456    ///
457    /// SATISFIES release:a-delivered-gate-reads-what-the-convention-owns
458    #[test]
459    fn the_rendered_pattern_is_a_superset_of_the_matcher() {
460        use crate::domain::path_filter::{Layer, PathFilter, Pattern};
461
462        let corpus = [
463            "README.md",
464            "AGENTS.md",
465            "method/AGENTS.md",
466            "method/gates.md",
467            "CHANGELOG.md",
468            "_docs/specs/SPEC-release.md",
469            "_docs/decisions/ADR-a-choice.md",
470            "_docs/reference/known-issues/KI-a-case.md",
471            "_docs/reference/tracking.yaml",
472            "_docs/guides/release.md",
473            "comparison-docs/COMPARISON-tools.md",
474            ".spec-driven-docs/manifest.json",
475            "src/gates.rs",
476            "vendor/third/lib.rs",
477        ];
478
479        for gate in GATES {
480            let filter = PathFilter::build(
481                gate.include
482                    .iter()
483                    .map(|g| Pattern::new(substitute_root(g, "_docs"), Layer::Registry))
484                    .collect(),
485                gate.exclude
486                    .iter()
487                    .map(|g| Pattern::new(substitute_root(g, "_docs"), Layer::Registry))
488                    .collect(),
489            )
490            .expect("every registry pattern compiles");
491
492            let files = render_patterns(gate.include, "_docs");
493            let excludes = render_patterns(gate.exclude, "_docs");
494
495            for path in corpus {
496                if !filter.judges(camino::Utf8Path::new(path)) {
497                    continue;
498                }
499                if let Some(files) = &files {
500                    let selector = regex::Regex::new(files).expect("the projection compiles");
501                    assert!(
502                        selector.is_match(path),
503                        "{}: the matcher judges {path} and the rendered files: {files} does not select it",
504                        gate.id
505                    );
506                }
507                if let Some(excludes) = &excludes {
508                    let selector = regex::Regex::new(excludes).expect("the projection compiles");
509                    assert!(
510                        !selector.is_match(path),
511                        "{}: the matcher judges {path} and the rendered exclude: {excludes} drops it",
512                        gate.id
513                    );
514                }
515            }
516        }
517    }
518
519    #[test]
520    fn always_run_gates_do_not_take_filenames() {
521        let out = render_gates(&RenderOptions::default());
522        assert_eq!(
523            out.matches("always_run: true").count(),
524            out.matches("pass_filenames: false").count()
525        );
526    }
527
528    #[test]
529    fn an_apostrophe_in_a_name_would_be_doubled() {
530        assert_eq!(quoted("it's"), "'it''s'");
531    }
532
533    #[test]
534    fn the_block_splices_into_a_plain_config() {
535        let block = render_block(&RenderOptions::default());
536        let spliced = crate::domain::marker::splice("repos:\n", &block).unwrap();
537        let (base, found) = crate::domain::marker::split_block(&spliced).unwrap();
538        assert_eq!(base, "repos:\n");
539        assert_eq!(found.as_deref(), Some(block.as_str()));
540    }
541}