1use camino::Utf8Path;
11
12use crate::domain::finding::Finding;
13use crate::domain::rule_id::RuleId;
14use crate::gates::{GateCtx, GateResult, Violation, line_count, read_text, walk_files};
15
16pub const CITES: &[RuleId] = &[RuleId::ChapterStaysWithinLineCap];
18
19const RULE: RuleId = RuleId::ChapterStaysWithinLineCap;
20const DEBT: &str = ".spec-driven-docs/chapter-size-debt.txt";
21const CHAPTER_ZONES: &[&str] = &["./method", "./comparison-docs"];
22
23fn cap_for(file: &Utf8Path) -> usize {
24 if file.file_name().is_some_and(|name| {
25 name.ends_with("-gates.md")
26 || name.ends_with("-checklist.md")
27 || matches!(
28 name,
29 "gates.md" | "checklist.md" | "glossary.md" | "README.md" | "SOURCES.md"
30 )
31 }) {
32 300
33 } else {
34 200
35 }
36}
37
38fn is_chapter(file: &Utf8Path) -> bool {
39 let Some(name) = file.file_name() else {
40 return false;
41 };
42 if name == "AGENTS.md" {
43 return false;
44 }
45 if name == "glossary.md" || name == "README.md" {
46 return true;
47 }
48 file.extension() == Some("md")
49 && file
50 .parent()
51 .is_some_and(|parent| CHAPTER_ZONES.contains(&parent.as_str()))
52}
53
54pub fn run(ctx: &GateCtx, _files: &[String]) -> GateResult {
60 let mut violations = Vec::new();
61 let mut debt_entries: Vec<String> = Vec::new();
62
63 if ctx.path(DEBT).is_file() {
64 for entry in read_text(ctx, DEBT)?.lines() {
65 if entry.is_empty() || entry.starts_with('#') {
66 continue;
67 }
68 let file = if entry.starts_with("./") {
69 entry.to_string()
70 } else {
71 format!("./{entry}")
72 };
73 if !ctx.path(&file).is_file() {
74 violations.push(Violation::Finding(Finding::on_file(
75 RULE,
76 format!("delist {file}"),
77 "deleted",
78 )));
79 continue;
80 }
81 if line_count(&read_text(ctx, Utf8Path::new(&file))?) <= cap_for(Utf8Path::new(&file)) {
82 violations.push(Violation::Finding(Finding::on_file(
83 RULE,
84 format!("delist {file}"),
85 "now fits",
86 )));
87 }
88 debt_entries.push(file);
89 }
90 }
91
92 for file in walk_files(ctx) {
93 if !is_chapter(&file) {
94 continue;
95 }
96 let as_listed = file.as_str();
97 let bare = as_listed.trim_start_matches("./");
98 if debt_entries
99 .iter()
100 .any(|entry| entry == as_listed || entry.trim_start_matches("./") == bare)
101 {
102 continue;
103 }
104 if line_count(&read_text(ctx, &file)?) > cap_for(&file) {
105 violations.push(Violation::Finding(Finding::on_file(RULE, file, "")));
106 }
107 }
108 Ok(violations)
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 fn write(dir: &tempfile::TempDir, path: &str, content: &str) {
116 let path = dir.path().join(path);
117 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
118 std::fs::write(path, content).unwrap();
119 }
120
121 fn run_in(dir: &tempfile::TempDir) -> Vec<String> {
122 let ctx = GateCtx::new(dir.path().to_str().unwrap());
123 run(&ctx, &[])
124 .unwrap()
125 .iter()
126 .map(ToString::to_string)
127 .collect()
128 }
129
130 #[test]
131 fn accepts_chapters_within_cap_and_ignores_vendored_trees() {
132 let dir = tempfile::tempdir().unwrap();
133 write(&dir, "method/chapter.md", "# Chapter\n");
134 write(&dir, "node_modules/pkg/README.md", &"line\n".repeat(400));
135 assert!(run_in(&dir).is_empty());
136 }
137
138 #[test]
139 fn rejects_a_chapter_over_cap() {
140 let dir = tempfile::tempdir().unwrap();
141 write(&dir, "method/chapter.md", &"line\n".repeat(201));
142 assert_eq!(
143 run_in(&dir),
144 vec!["FAIL docs-format:chapter-stays-within-200-lines ./method/chapter.md".to_string()]
145 );
146 }
147
148 #[test]
149 fn catalogs_get_the_larger_cap() {
150 let dir = tempfile::tempdir().unwrap();
151 write(&dir, "README.md", &"line\n".repeat(300));
152 write(&dir, "instance/README.md", &"line\n".repeat(300));
153 write(&dir, "method/README.md", &"line\n".repeat(300));
154 write(&dir, "method/gates.md", &"line\n".repeat(300));
155 write(&dir, "method/checklist.md", &"line\n".repeat(300));
156 write(&dir, "method/glossary.md", &"line\n".repeat(300));
157 write(&dir, "method/08-gates.md", &"line\n".repeat(300));
158 write(&dir, "method/08-checklist.md", &"line\n".repeat(300));
159 write(&dir, "comparison-docs/SOURCES.md", &"line\n".repeat(300));
160 assert!(run_in(&dir).is_empty());
161
162 for path in [
163 "README.md",
164 "comparison-docs/SOURCES.md",
165 "instance/README.md",
166 "method/08-checklist.md",
167 "method/08-gates.md",
168 "method/README.md",
169 "method/checklist.md",
170 "method/gates.md",
171 "method/glossary.md",
172 ] {
173 write(&dir, path, &"line\n".repeat(301));
174 }
175 assert_eq!(run_in(&dir).len(), 9);
176 }
177
178 #[test]
179 fn debt_exempts_an_oversize_chapter() {
180 let dir = tempfile::tempdir().unwrap();
181 write(&dir, "method/debt-chapter.md", &"line\n".repeat(201));
182 write(
183 &dir,
184 ".spec-driven-docs/chapter-size-debt.txt",
185 "method/debt-chapter.md\n",
186 );
187 assert!(run_in(&dir).is_empty());
188 }
189
190 #[test]
191 fn debt_expires_when_the_chapter_fits_even_unterminated() {
192 let dir = tempfile::tempdir().unwrap();
193 write(&dir, "method/debt-chapter.md", "# fits\n");
194 write(
195 &dir,
196 ".spec-driven-docs/chapter-size-debt.txt",
197 "method/debt-chapter.md\n",
198 );
199 assert!(run_in(&dir)[0].contains("delist ./method/debt-chapter.md: now fits"));
200
201 write(
202 &dir,
203 ".spec-driven-docs/chapter-size-debt.txt",
204 "method/debt-chapter.md",
205 );
206 assert!(run_in(&dir)[0].contains("now fits"));
207 }
208
209 #[test]
210 fn debt_expires_when_the_chapter_is_deleted_even_unterminated() {
211 let dir = tempfile::tempdir().unwrap();
212 write(
213 &dir,
214 ".spec-driven-docs/chapter-size-debt.txt",
215 "method/missing-chapter.md\n",
216 );
217 assert!(run_in(&dir)[0].contains("delist ./method/missing-chapter.md: deleted"));
218
219 write(
220 &dir,
221 ".spec-driven-docs/chapter-size-debt.txt",
222 "method/missing-chapter.md",
223 );
224 assert!(run_in(&dir)[0].contains("deleted"));
225 }
226
227 #[test]
228 fn rejects_a_slug_named_chapter_in_a_zone() {
229 let dir = tempfile::tempdir().unwrap();
230 write(
231 &dir,
232 "comparison-docs/slug-chapter.md",
233 &"line\n".repeat(201),
234 );
235 assert_eq!(
236 run_in(&dir),
237 vec![
238 "FAIL docs-format:chapter-stays-within-200-lines ./comparison-docs/slug-chapter.md"
239 .to_string()
240 ]
241 );
242 }
243
244 #[test]
245 fn ignores_a_slug_named_markdown_file_outside_every_zone() {
246 let dir = tempfile::tempdir().unwrap();
247 write(&dir, "reference/slug-document.md", &"line\n".repeat(201));
248 assert!(run_in(&dir).is_empty());
249 }
250
251 #[test]
252 fn ignores_a_markdown_file_nested_below_a_chapter_zone() {
253 let dir = tempfile::tempdir().unwrap();
254 write(&dir, "method/nested/slug-chapter.md", &"line\n".repeat(201));
255 assert!(run_in(&dir).is_empty());
256 }
257
258 #[test]
259 fn judges_a_glossary_outside_every_zone() {
260 let dir = tempfile::tempdir().unwrap();
261 write(&dir, "reference/glossary.md", &"line\n".repeat(301));
262 assert_eq!(
263 run_in(&dir),
264 vec![
265 "FAIL docs-format:chapter-stays-within-200-lines ./reference/glossary.md"
266 .to_string()
267 ]
268 );
269 }
270
271 #[test]
272 fn ignores_agents_md_in_a_chapter_zone() {
273 let dir = tempfile::tempdir().unwrap();
274 write(&dir, "method/AGENTS.md", &"line\n".repeat(301));
275 write(&dir, "comparison-docs/AGENTS.md", &"line\n".repeat(301));
276 assert!(run_in(&dir).is_empty());
277 }
278}