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