spec_driven_docs/gates/
spec_size_cap.rs1use crate::domain::debt::Measurement;
18use crate::domain::finding::Finding;
19use crate::domain::gate_id::GateId;
20use crate::domain::rule_id::RuleId;
21use crate::gates::budget;
22use crate::gates::spec_rule_id_unique::spec_files_judged;
23use crate::gates::{GateCtx, GateError, GateResult, Violation, read_text};
24
25pub const CITES: &[RuleId] = &[RuleId::SpecStaysWithinLineCap];
27
28const RULE: RuleId = RuleId::SpecStaysWithinLineCap;
29const MARKER: &str = "<!--TOC-->";
30const CAP: usize = 300;
31const TOC_FROM: usize = 100;
32
33struct Counted {
35 path: String,
36 markers: usize,
37 authored: usize,
38}
39
40fn count(ctx: &GateCtx, file: &camino::Utf8Path) -> Result<Counted, GateError> {
41 let text = read_text(ctx, file)?;
42 let markers = text.lines().filter(|line| *line == MARKER).count();
43 let mut inside_toc = false;
44 let authored = text
45 .lines()
46 .filter(|line| {
47 if *line == MARKER {
48 inside_toc = !inside_toc;
49 return false;
50 }
51 !inside_toc
52 })
53 .count();
54 Ok(Counted {
55 path: file.to_string(),
56 markers,
57 authored,
58 })
59}
60
61fn measurements_of(counted: &Counted) -> [Measurement; 2] {
62 [
63 Measurement::count(
64 GateId::SpecSizeCap,
65 counted.path.as_str(),
66 "authored_lines",
67 counted.authored,
68 CAP,
69 ),
70 Measurement::flag(
71 GateId::SpecSizeCap,
72 counted.path.as_str(),
73 "missing_toc",
74 counted.authored > TOC_FROM && counted.markers == 0,
75 ),
76 ]
77}
78
79pub fn measure(ctx: &GateCtx) -> Result<Vec<Measurement>, GateError> {
86 let mut measurements = Vec::new();
87 for file in spec_files_judged(ctx).unwrap_or_default() {
88 let counted = count(ctx, &file)?;
89 if counted.markers == 0 || counted.markers == 2 {
90 measurements.extend(measurements_of(&counted));
91 }
92 }
93 Ok(measurements)
94}
95
96pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
103 let Some(files) = spec_files_judged(ctx) else {
104 return Ok(vec![Violation::Layout(
105 "no specs matched; the layout moved".to_string(),
106 )]);
107 };
108 let debt = budget::read_debt(ctx)?;
109 let mut violations = Vec::new();
110 let mut measurements = Vec::new();
111 for file in files {
112 let counted = count(ctx, &file)?;
113 if counted.markers != 0 && counted.markers != 2 {
114 violations.push(Violation::Finding(Finding::on_file(
115 RULE,
116 &file,
117 format!("{} TOC markers, expected 0 or 2", counted.markers),
118 )));
119 continue;
120 }
121 measurements.extend(measurements_of(&counted));
122 }
123 violations.extend(budget::judge(
124 &debt,
125 GateId::SpecSizeCap,
126 RULE,
127 &measurements,
128 |m| {
129 let detail = match m.value {
130 crate::domain::debt::Measured::Count { value, .. } => {
131 format!("{value} authored lines, cap is {CAP}")
132 }
133 crate::domain::debt::Measured::Flag(_) => {
134 format!("over {TOC_FROM} lines with no TOC")
135 }
136 };
137 Violation::Finding(Finding::on_file(RULE, m.path.as_str(), detail))
138 },
139 ));
140 Ok(violations)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn fixture(text: &str) -> tempfile::TempDir {
148 let dir = tempfile::tempdir().unwrap();
149 let specs = dir.path().join("_docs/specs");
150 std::fs::create_dir_all(&specs).unwrap();
151 std::fs::write(specs.join("SPEC-sample.md"), text).unwrap();
152 dir
153 }
154
155 fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
156 let ctx = GateCtx::new(dir.path().to_str().unwrap());
157 run(&ctx, &[])
158 .unwrap()
159 .iter()
160 .map(ToString::to_string)
161 .collect()
162 }
163
164 fn run_on(text: &str) -> Vec<String> {
165 run_in(&fixture(text))
166 }
167
168 fn with_toc(lines: usize) -> String {
170 format!("<!--TOC-->\n<!--TOC-->\n{}", "line\n".repeat(lines))
171 }
172
173 #[test]
174 fn accepts_a_short_spec_with_no_toc() {
175 assert!(run_on(&"line\n".repeat(100)).is_empty());
176 }
177
178 #[test]
179 fn rejects_a_spec_over_the_authored_cap() {
180 let out = run_on(&with_toc(301));
181 assert_eq!(out.len(), 1);
182 assert!(out[0].contains("docs-specs:spec-stays-within-300-lines"));
183 assert!(out[0].ends_with(": 301 authored lines, cap is 300"));
184 }
185
186 #[test]
187 fn the_toc_region_does_not_spend_the_budget() {
188 let text = format!(
189 "<!--TOC-->\n{}<!--TOC-->\n{}",
190 "toc\n".repeat(250),
191 "line\n".repeat(90)
192 );
193 assert!(run_on(&text).is_empty());
194 }
195
196 #[test]
197 fn a_long_spec_without_a_toc_is_rejected() {
198 let out = run_on(&"line\n".repeat(101));
199 assert_eq!(out.len(), 1);
200 assert!(out[0].ends_with(": over 100 lines with no TOC"));
201 }
202
203 #[test]
204 fn both_dimensions_are_reported_independently() {
205 let out = run_on(&"line\n".repeat(301));
206 assert_eq!(out.len(), 2, "{out:?}");
207 assert!(out[0].ends_with(": 301 authored lines, cap is 300"));
208 assert!(out[1].ends_with(": over 100 lines with no TOC"));
209 }
210
211 #[test]
212 fn a_lone_marker_is_rejected_before_counting() {
213 let text = format!("<!--TOC-->\n{}", "line\n".repeat(400));
214 let out = run_on(&text);
215 assert_eq!(out.len(), 1);
216 assert!(out[0].ends_with(": 1 TOC markers, expected 0 or 2"));
217 }
218
219 #[test]
220 fn a_malformed_marker_stays_strict_under_debt() {
221 let dir = fixture(&format!("<!--TOC-->\n{}", "line\n".repeat(400)));
222 std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
223 std::fs::write(
224 dir.path().join(".spec-driven-docs/debt.yaml"),
225 "schema_version: 1\nspec-size-cap:\n _docs/specs/SPEC-sample.md:\n authored_lines:\n ceiling: 400\n missing_toc: true\n",
226 )
227 .unwrap();
228 let out = run_in(&dir);
229 assert!(
230 out.iter().any(|line| line.contains("1 TOC markers")),
231 "{out:?}"
232 );
233 }
234
235 #[test]
236 fn a_recorded_ceiling_and_exception_carry_an_oversize_spec_without_a_toc() {
237 let dir = fixture(&"line\n".repeat(417));
238 std::fs::create_dir_all(dir.path().join(".spec-driven-docs")).unwrap();
239 std::fs::write(
240 dir.path().join(".spec-driven-docs/debt.yaml"),
241 "schema_version: 1\nspec-size-cap:\n _docs/specs/SPEC-sample.md:\n authored_lines:\n ceiling: 417\n missing_toc: true\n",
242 )
243 .unwrap();
244 assert!(run_in(&dir).is_empty());
245 }
246
247 #[test]
248 fn measure_reports_both_dimensions_of_an_oversize_spec_without_a_toc() {
249 let dir = fixture(&"line\n".repeat(417));
250 let ctx = GateCtx::new(dir.path().to_str().unwrap());
251 let measured = measure(&ctx).unwrap();
252 assert_eq!(measured.len(), 2);
253 assert!(measured.iter().all(Measurement::violates));
254 }
255}