1use camino::Utf8Path;
13
14use crate::domain::debt::{LEGACY_DEBT_PATH, Measurement, Presence};
15use crate::domain::finding::Finding;
16use crate::domain::gate_id::GateId;
17use crate::domain::rule_id::RuleId;
18use crate::gates::budget;
19use crate::gates::{GateCtx, GateError, GateResult, Violation, line_count, read_text, walk_files};
20
21pub const CITES: &[RuleId] = &[RuleId::ChapterStaysWithinLineCap];
23
24const RULE: RuleId = RuleId::ChapterStaysWithinLineCap;
25const CHAPTER_ZONES: &[&str] = &["./method", "./comparison-docs"];
26
27fn cap_for(file: &Utf8Path) -> usize {
28 if file.file_name().is_some_and(|name| {
29 name.ends_with("-gates.md")
30 || name.ends_with("-checklist.md")
31 || matches!(
32 name,
33 "gates.md" | "checklist.md" | "glossary.md" | "README.md" | "SOURCES.md"
34 )
35 }) {
36 300
37 } else {
38 200
39 }
40}
41
42fn is_chapter(file: &Utf8Path) -> bool {
43 let Some(name) = file.file_name() else {
44 return false;
45 };
46 if name == "AGENTS.md" {
47 return false;
48 }
49 if name == "glossary.md" || name == "README.md" {
50 return true;
51 }
52 file.extension() == Some("md")
53 && file
54 .parent()
55 .is_some_and(|parent| CHAPTER_ZONES.contains(&parent.as_str()))
56}
57
58pub fn measure(ctx: &GateCtx) -> Result<Vec<Measurement>, GateError> {
64 let mut measurements = Vec::new();
65 for file in walk_files(ctx) {
66 if !is_chapter(&file) {
67 continue;
68 }
69 let lines = line_count(&read_text(ctx, &file)?);
70 measurements.push(Measurement::count(
71 GateId::ChapterSizeCap,
72 file.as_str(),
73 "lines",
74 lines,
75 cap_for(&file),
76 ));
77 }
78 Ok(measurements)
79}
80
81fn legacy_entries(
84 ctx: &GateCtx,
85 violations: &mut Vec<Violation>,
86) -> Result<Vec<String>, GateError> {
87 let mut entries = Vec::new();
88 for entry in crate::domain::debt::legacy_list(&read_text(ctx, LEGACY_DEBT_PATH)?) {
89 let file = format!("./{entry}");
90 if !ctx.path(&file).is_file() {
91 violations.push(Violation::Finding(Finding::on_file(
92 RULE,
93 format!("delist {file}"),
94 "deleted",
95 )));
96 continue;
97 }
98 if line_count(&read_text(ctx, Utf8Path::new(&file))?) <= cap_for(Utf8Path::new(&file)) {
99 violations.push(Violation::Finding(Finding::on_file(
100 RULE,
101 format!("delist {file}"),
102 "now fits",
103 )));
104 }
105 entries.push(file);
106 }
107 Ok(entries)
108}
109
110pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
117 let mut violations = Vec::new();
118 let debt = budget::read_debt(ctx)?;
119 let legacy = if Presence::at(&ctx.repo_root).legacy {
120 legacy_entries(ctx, &mut violations)?
121 } else {
122 Vec::new()
123 };
124 let measurements: Vec<Measurement> = measure(ctx)?
125 .into_iter()
126 .filter(|m| {
127 let bare = m.path.trim_start_matches("./");
128 !legacy
129 .iter()
130 .any(|entry| entry == &m.path || entry.trim_start_matches("./") == bare)
131 })
132 .collect();
133 violations.extend(budget::judge(
134 &debt,
135 GateId::ChapterSizeCap,
136 RULE,
137 &measurements,
138 |m| Violation::Finding(Finding::on_file(RULE, m.path.as_str(), "")),
139 ));
140 Ok(violations)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn write(dir: &tempfile::TempDir, path: &str, content: &str) {
148 let path = dir.path().join(path);
149 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
150 std::fs::write(path, content).unwrap();
151 }
152
153 fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
154 let ctx = GateCtx::new(dir.path().to_str().unwrap());
155 run(&ctx, &[])
156 .unwrap()
157 .iter()
158 .map(ToString::to_string)
159 .collect()
160 }
161
162 #[test]
163 fn accepts_chapters_within_cap_and_ignores_vendored_trees() {
164 let dir = tempfile::tempdir().unwrap();
165 write(&dir, "method/chapter.md", "# Chapter\n");
166 write(&dir, "node_modules/pkg/README.md", &"line\n".repeat(400));
167 assert!(run_in(&dir).is_empty());
168 }
169
170 #[test]
171 fn rejects_a_chapter_over_cap() {
172 let dir = tempfile::tempdir().unwrap();
173 write(&dir, "method/chapter.md", &"line\n".repeat(201));
174 assert_eq!(
175 run_in(&dir),
176 vec!["FAIL docs-format:chapter-stays-within-200-lines ./method/chapter.md".to_string()]
177 );
178 }
179
180 #[test]
181 fn catalogs_get_the_larger_cap() {
182 let dir = tempfile::tempdir().unwrap();
183 write(&dir, "README.md", &"line\n".repeat(300));
184 write(&dir, "instance/README.md", &"line\n".repeat(300));
185 write(&dir, "method/README.md", &"line\n".repeat(300));
186 write(&dir, "method/gates.md", &"line\n".repeat(300));
187 write(&dir, "method/checklist.md", &"line\n".repeat(300));
188 write(&dir, "method/glossary.md", &"line\n".repeat(300));
189 write(&dir, "method/08-gates.md", &"line\n".repeat(300));
190 write(&dir, "method/08-checklist.md", &"line\n".repeat(300));
191 write(&dir, "comparison-docs/SOURCES.md", &"line\n".repeat(300));
192 assert!(run_in(&dir).is_empty());
193
194 for path in [
195 "README.md",
196 "comparison-docs/SOURCES.md",
197 "instance/README.md",
198 "method/08-checklist.md",
199 "method/08-gates.md",
200 "method/README.md",
201 "method/checklist.md",
202 "method/gates.md",
203 "method/glossary.md",
204 ] {
205 write(&dir, path, &"line\n".repeat(301));
206 }
207 assert_eq!(run_in(&dir).len(), 9);
208 }
209
210 #[test]
211 fn legacy_debt_exempts_an_oversize_chapter() {
212 let dir = tempfile::tempdir().unwrap();
213 write(&dir, "method/debt-chapter.md", &"line\n".repeat(201));
214 write(
215 &dir,
216 ".spec-driven-docs/chapter-size-debt.txt",
217 "method/debt-chapter.md\n",
218 );
219 assert!(run_in(&dir).is_empty());
220 }
221
222 #[test]
223 fn legacy_debt_expires_when_the_chapter_fits_even_unterminated() {
224 let dir = tempfile::tempdir().unwrap();
225 write(&dir, "method/debt-chapter.md", "# fits\n");
226 write(
227 &dir,
228 ".spec-driven-docs/chapter-size-debt.txt",
229 "method/debt-chapter.md\n",
230 );
231 assert!(run_in(&dir)[0].contains("delist ./method/debt-chapter.md: now fits"));
232
233 write(
234 &dir,
235 ".spec-driven-docs/chapter-size-debt.txt",
236 "method/debt-chapter.md",
237 );
238 assert!(run_in(&dir)[0].contains("now fits"));
239 }
240
241 #[test]
242 fn legacy_debt_expires_when_the_chapter_is_deleted_even_unterminated() {
243 let dir = tempfile::tempdir().unwrap();
244 write(
245 &dir,
246 ".spec-driven-docs/chapter-size-debt.txt",
247 "method/missing-chapter.md\n",
248 );
249 assert!(run_in(&dir)[0].contains("delist ./method/missing-chapter.md: deleted"));
250
251 write(
252 &dir,
253 ".spec-driven-docs/chapter-size-debt.txt",
254 "method/missing-chapter.md",
255 );
256 assert!(run_in(&dir)[0].contains("deleted"));
257 }
258
259 #[test]
260 fn a_recorded_ceiling_is_judged_instead_of_the_cap() {
261 let dir = tempfile::tempdir().unwrap();
262 write(&dir, "method/legacy.md", &"line\n".repeat(250));
263 write(
264 &dir,
265 ".spec-driven-docs/debt.yaml",
266 "schema_version: 1\nchapter-size-cap:\n method/legacy.md:\n lines:\n ceiling: 250\n",
267 );
268 assert!(run_in(&dir).is_empty());
269
270 write(&dir, "method/legacy.md", &"line\n".repeat(251));
271 let out = run_in(&dir);
272 assert_eq!(
273 out,
274 vec![
275 "FAIL docs-format:chapter-stays-within-200-lines ./method/legacy.md: 251 lines, recorded ceiling is 250"
276 .to_string()
277 ]
278 );
279
280 write(&dir, "method/legacy.md", &"line\n".repeat(240));
281 let out = run_in(&dir);
282 assert_eq!(out.len(), 1);
283 assert!(out[0].contains("sdd debt tighten --apply"), "{}", out[0]);
284 }
285
286 #[test]
287 fn a_ceiling_never_lets_a_second_chapter_grow() {
288 let dir = tempfile::tempdir().unwrap();
290 write(&dir, "method/legacy.md", &"line\n".repeat(250));
291 write(&dir, "method/fresh.md", &"line\n".repeat(201));
292 write(
293 &dir,
294 ".spec-driven-docs/debt.yaml",
295 "schema_version: 1\nchapter-size-cap:\n method/legacy.md:\n lines:\n ceiling: 250\n",
296 );
297 assert_eq!(
298 run_in(&dir),
299 vec!["FAIL docs-format:chapter-stays-within-200-lines ./method/fresh.md".to_string()]
300 );
301 }
302
303 #[test]
304 fn both_debt_formats_present_is_an_error_naming_migrate() {
305 let dir = tempfile::tempdir().unwrap();
306 write(&dir, "method/legacy.md", &"line\n".repeat(250));
307 write(&dir, ".spec-driven-docs/debt.yaml", "schema_version: 1\n");
308 write(
309 &dir,
310 ".spec-driven-docs/chapter-size-debt.txt",
311 "method/legacy.md\n",
312 );
313 let ctx = GateCtx::new(dir.path().to_str().unwrap());
314 let error = run(&ctx, &[]).unwrap_err();
315 assert!(
316 error.to_string().contains("sdd debt migrate --apply"),
317 "{error}"
318 );
319 }
320
321 #[test]
322 fn a_malformed_debt_file_stops_the_gate_rather_than_passing_it() {
323 let dir = tempfile::tempdir().unwrap();
324 write(&dir, "method/legacy.md", &"line\n".repeat(250));
325 write(
326 &dir,
327 ".spec-driven-docs/debt.yaml",
328 "schema_version: 1\nchapter-size-cap:\n method/legacy.md:\n lines: 250\n",
329 );
330 let ctx = GateCtx::new(dir.path().to_str().unwrap());
331 let error = run(&ctx, &[]).unwrap_err();
332 assert!(error.to_string().contains("method/legacy.md"), "{error}");
333 }
334
335 #[test]
336 fn rejects_a_slug_named_chapter_in_a_zone() {
337 let dir = tempfile::tempdir().unwrap();
338 write(
339 &dir,
340 "comparison-docs/slug-chapter.md",
341 &"line\n".repeat(201),
342 );
343 assert_eq!(
344 run_in(&dir),
345 vec![
346 "FAIL docs-format:chapter-stays-within-200-lines ./comparison-docs/slug-chapter.md"
347 .to_string()
348 ]
349 );
350 }
351
352 #[test]
353 fn ignores_a_slug_named_markdown_file_outside_every_zone() {
354 let dir = tempfile::tempdir().unwrap();
355 write(&dir, "reference/slug-document.md", &"line\n".repeat(201));
356 assert!(run_in(&dir).is_empty());
357 }
358
359 #[test]
360 fn ignores_a_markdown_file_nested_below_a_chapter_zone() {
361 let dir = tempfile::tempdir().unwrap();
362 write(&dir, "method/nested/slug-chapter.md", &"line\n".repeat(201));
363 assert!(run_in(&dir).is_empty());
364 }
365
366 #[test]
367 fn judges_a_glossary_outside_every_zone() {
368 let dir = tempfile::tempdir().unwrap();
369 write(&dir, "reference/glossary.md", &"line\n".repeat(301));
370 assert_eq!(
371 run_in(&dir),
372 vec![
373 "FAIL docs-format:chapter-stays-within-200-lines ./reference/glossary.md"
374 .to_string()
375 ]
376 );
377 }
378
379 #[test]
380 fn ignores_agents_md_in_a_chapter_zone() {
381 let dir = tempfile::tempdir().unwrap();
382 write(&dir, "method/AGENTS.md", &"line\n".repeat(301));
383 write(&dir, "comparison-docs/AGENTS.md", &"line\n".repeat(301));
384 assert!(run_in(&dir).is_empty());
385 }
386}