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::rule_id::RuleId;
18use crate::gates::budget;
19use crate::gates::{GateCtx, GateError, GateResult, Violation, line_count, read_text, walk_files};
20
21/// The rules this gate can cite.
22pub 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
56/// Measure every chapter and catalog: one `lines` count per file.
57///
58/// # Errors
59///
60/// [`GateError::Io`] when a matched file cannot be read.
61pub 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
79/// The legacy list's entries, as `./`-prefixed paths, with the findings its
80/// own expiry rules produce.
81fn 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
108/// Judge every chapter and catalog against its cap or its recorded ceiling.
109///
110/// # Errors
111///
112/// [`GateError::Io`] when a matched file cannot be read, and
113/// [`GateError::Debt`] when the debt file cannot be trusted.
114pub 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        // The ratchet is per path: a ceiling on one chapter exempts no other.
283        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}