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