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