Skip to main content

spec_driven_docs/gates/
chapter_size_cap.rs

1//! Gate: a chapter stays within its line cap, or within the ceiling its
2//! project recorded for it.
3//!
4//! Chapters get 200 lines; catalogs — gates, checklists, glossaries, READMEs
5//! — get 300. A recorded ceiling in `.spec-driven-docs/debt.yaml` is judged
6//! instead of the cap and only comes down. The older flat list at
7//! `.spec-driven-docs/chapter-size-debt.txt` is still honoured with its skip
8//! semantics until `sdd debt migrate --apply` converts it; both files
9//! present is a failure rather than a precedence. Vendored trees are pruned.
10//! Which caps exist is the format spec's business; this gate only counts.
11
12use 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
22/// The rules this gate can cite.
23pub 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
57/// Measure every chapter and catalog: one `lines` count per file.
58///
59/// # Errors
60///
61/// [`GateError::Io`] when a matched file cannot be read.
62pub 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
80/// The legacy list's entries, as `./`-prefixed paths, with the findings its
81/// own expiry rules produce.
82fn 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
109/// Judge every chapter and catalog against its cap or its recorded ceiling.
110///
111/// # Errors
112///
113/// [`GateError::Io`] when a matched file cannot be read, and
114/// [`GateError::Debt`] when the debt file cannot be trusted.
115pub 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        // The ratchet is per path: a ceiling on one chapter exempts no other.
284        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}