spec_driven_docs/gates/
spec_verify_hooks_exist.rs1use std::collections::BTreeSet;
11
12use crate::domain::finding::Finding;
13use crate::domain::paths::HOOKS_CONFIG_PATH;
14use crate::domain::rule_id::RuleId;
15
16use crate::gates::{GateCtx, GateResult, Violation, read_text};
17
18pub const CITES: &[RuleId] = &[RuleId::VerificationNamesALiveHook];
20
21fn hook_names_in(text: &str) -> impl Iterator<Item = String> + '_ {
22 text.match_indices("pre-commit run ")
23 .filter_map(|(index, needle)| {
24 let after = &text[index + needle.len()..];
25 let name: String = after
26 .chars()
27 .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
28 .collect();
29 (!name.is_empty()).then_some(name)
30 })
31}
32
33fn defines_hook(config: &str, hook: &str) -> bool {
34 config.lines().any(|line| {
35 let trimmed = line.trim_start();
36 trimmed
37 .strip_prefix("- id: ")
38 .or_else(|| trimmed.strip_prefix("alias: "))
39 .is_some_and(|value| value == hook)
40 })
41}
42
43pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
50 let Some(all_specs) = crate::gates::spec_rule_id_unique::spec_files(ctx) else {
55 return Ok(vec![Violation::Layout(
56 "no specs matched; the layout moved".to_string(),
57 )]);
58 };
59 let mut declared: BTreeSet<String> = BTreeSet::new();
60 for file in &all_specs {
61 declared.extend(hook_names_in(&read_text(ctx, file)?));
62 }
63 if declared.is_empty() {
64 return Ok(vec![Violation::Layout(
65 "no spec names a hook; the Verify shape moved".to_string(),
66 )]);
67 }
68
69 let mut hooks: BTreeSet<String> = BTreeSet::new();
70 for file in ctx.retained(all_specs) {
71 hooks.extend(hook_names_in(&read_text(ctx, &file)?));
72 }
73 let config = read_text(ctx, HOOKS_CONFIG_PATH)?;
74 Ok(hooks
75 .into_iter()
76 .filter(|hook| !defines_hook(&config, hook))
77 .map(|hook| Violation::Finding(Finding::global(RuleId::VerificationNamesALiveHook, hook)))
78 .collect())
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 fn fixture(hook_id: &str) -> tempfile::TempDir {
86 let dir = tempfile::tempdir().unwrap();
87 let specs = dir.path().join("_docs/specs");
88 std::fs::create_dir_all(&specs).unwrap();
89 std::fs::write(
90 specs.join("SPEC-sample.md"),
91 "### `sample:works` — Works\n\nVerify: `pre-commit run sample-hook --all-files`\n",
92 )
93 .unwrap();
94 std::fs::write(
95 dir.path().join(".pre-commit-config.yaml"),
96 format!(
97 "repos:\n - repo: local\n hooks:\n - id: {hook_id}\n entry: true\n"
98 ),
99 )
100 .unwrap();
101 dir
102 }
103
104 fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
105 let ctx = GateCtx::new(dir.path().to_str().unwrap());
106 run(&ctx, &[])
107 .unwrap()
108 .iter()
109 .map(ToString::to_string)
110 .collect()
111 }
112
113 #[test]
114 fn accepts_a_cited_hook_that_exists() {
115 assert!(run_in(&fixture("sample-hook")).is_empty());
116 }
117
118 #[test]
119 fn rejects_a_cited_hook_that_was_renamed() {
120 let out = run_in(&fixture("renamed-hook"));
121 assert_eq!(
122 out,
123 vec!["FAIL docs-specs:verification-names-a-live-hook: sample-hook".to_string()]
124 );
125 }
126
127 #[test]
128 fn a_suffixed_rename_does_not_satisfy_the_citation() {
129 assert_eq!(run_in(&fixture("sample-hook-v2")).len(), 1);
130 }
131
132 #[test]
133 fn an_alias_satisfies_the_citation() {
134 let dir = fixture("other");
135 let config = dir.path().join(".pre-commit-config.yaml");
136 let text = std::fs::read_to_string(&config).unwrap() + " alias: sample-hook\n";
137 std::fs::write(&config, text).unwrap();
138 assert!(run_in(&dir).is_empty());
139 }
140}