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        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
58/// Measure every chapter and catalog: one `lines` count per file.
59///
60/// # Errors
61///
62/// [`GateError::Io`] when a matched file cannot be read.
63pub 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
81/// The legacy list's entries, as `./`-prefixed paths, with the findings its
82/// own expiry rules produce.
83fn 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
110/// Judge every chapter and catalog against its cap or its recorded ceiling.
111///
112/// # Errors
113///
114/// [`GateError::Io`] when a matched file cannot be read, and
115/// [`GateError::Debt`] when the debt file cannot be trusted.
116pub 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        // The ratchet is per path: a ceiling on one chapter exempts no other.
289        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}