1use std::sync::OnceLock;
18
19use regex::Regex;
20
21use crate::domain::finding::Finding;
22use crate::domain::rule_id::RuleId;
23use crate::gates::markdown_prose::{LineKind, classify};
24use crate::gates::{GateCtx, GateResult, Violation, read_text};
25
26pub const CITES: &[RuleId] = &[
28 RuleId::ObjectiveCheckMatchesItsUpstreamRule,
29 RuleId::ExceptionNamesItsReason,
30];
31
32pub const ADOPTION_EXEMPT: &[&str] = &[];
39
40const DESCRIPTIVE_LIMIT: usize = 25;
41const PROCEDURAL_LIMIT: usize = 20;
42
43#[allow(clippy::expect_used)]
44fn re(cell: &'static OnceLock<Regex>, pattern: &str) -> &'static Regex {
45 cell.get_or_init(|| Regex::new(pattern).expect("static pattern compiles"))
48}
49
50fn collapse(text: &str) -> String {
53 static CODE: OnceLock<Regex> = OnceLock::new();
54 static URL: OnceLock<Regex> = OnceLock::new();
55 static PAREN: OnceLock<Regex> = OnceLock::new();
56 let mut out = re(&CODE, r"`[^`]+`")
57 .replace_all(text, " CODE ")
58 .into_owned();
59 out = re(&URL, r"https?://\S+")
60 .replace_all(&out, " URL ")
61 .into_owned();
62 out = re(&PAREN, r"\([^)]*\)")
63 .replace_all(&out, " PAREN ")
64 .into_owned();
65 out
66}
67
68fn sentences(collapsed: &str) -> Vec<String> {
70 let mut out = Vec::new();
71 let mut current = String::new();
72 let mut chars = collapsed.chars().peekable();
73 while let Some(ch) = chars.next() {
74 current.push(ch);
75 if matches!(ch, '.' | '!' | '?' | ':') && chars.peek().is_none_or(|n| n.is_whitespace()) {
76 out.push(std::mem::take(&mut current));
77 }
78 }
79 if !current.trim().is_empty() {
80 out.push(current);
81 }
82 out.into_iter()
83 .map(|s| s.trim().to_string())
84 .filter(|s| s.split_whitespace().count() >= 2)
85 .collect()
86}
87
88fn word_count(sentence: &str) -> usize {
89 sentence.split_whitespace().count()
90}
91
92struct Hit {
94 upstream: &'static str,
95 category: &'static str,
96 detail: String,
97}
98
99fn logic_dashes(text: &str) -> usize {
102 let mut count = text.matches('—').count();
103 let bytes: Vec<char> = text.chars().collect();
104 let mut i = 0;
105 while i < bytes.len() {
106 if bytes[i] == '-' {
107 let before = if i >= 2 { Some(bytes[i - 2]) } else { None };
109 let is_double = bytes.get(i + 1) == Some(&'-');
110 let (dash_end, after_gap) = if is_double {
111 (i + 1, i + 2)
112 } else {
113 (i, i + 1)
114 };
115 let spaced_left = i >= 1 && bytes[i - 1] == ' ';
116 let spaced_right = bytes.get(after_gap) == Some(&' ');
117 let after = bytes.get(after_gap + 1).copied();
118 if spaced_left && spaced_right {
119 let left_ok = before.is_some_and(|c| !c.is_ascii_digit());
120 let right_ok = after.is_some_and(|c| !c.is_ascii_digit());
121 if left_ok && right_ok {
122 count += 1;
123 }
124 }
125 i = dash_end + 1;
126 } else {
127 i += 1;
128 }
129 }
130 count
131}
132
133fn text_checks(line: &str, hits: &mut Vec<Hit>) {
134 static CONTRACTION: OnceLock<Regex> = OnceLock::new();
135 static PERFECT: OnceLock<Regex> = OnceLock::new();
136 static ING: OnceLock<Regex> = OnceLock::new();
137 static MODAL: OnceLock<Regex> = OnceLock::new();
138 let body = collapse(line);
139
140 let contractions = re(
141 &CONTRACTION,
142 r"(?i)\b\w+(n't|'ll|'re|'ve|'d)\b|\bit's\b|\byou're\b",
143 )
144 .find_iter(&body)
145 .count();
146 for _ in 0..contractions {
147 hits.push(Hit {
148 upstream: "4.2",
149 category: "contraction",
150 detail: "a contraction; keep full grammar".to_string(),
151 });
152 }
153
154 let perfect = re(
155 &PERFECT,
156 r"(?i)\b(has|have|had)\s+been\b|\b(has|have)\s+\w+ed\b",
157 )
158 .find_iter(&body)
159 .count();
160 for _ in 0..perfect {
161 hits.push(Hit {
162 upstream: "3.4",
163 category: "perfect-tense",
164 detail: "a perfect tense; use a simple tense".to_string(),
165 });
166 }
167
168 let ing = re(
169 &ING,
170 r"(?i),\s*(mak|allow|enabl|ensur|highlight|creat|provid|offer|help|reduc|improv|lead|caus|result)ing\b",
171 )
172 .find_iter(&body)
173 .count();
174 for _ in 0..ing {
175 hits.push(Hit {
176 upstream: "3.5",
177 category: "ing-verb",
178 detail: "an '-ing' clause as a verb; start a new sentence".to_string(),
179 });
180 }
181
182 for m in re(&MODAL, r"(?i)\b(should|would|may|might|could|shall)\b").find_iter(&body) {
183 if m.as_str().chars().all(|c| c.is_ascii_uppercase()) {
185 continue;
186 }
187 hits.push(Hit {
188 upstream: "3.2",
189 category: "banned-modal",
190 detail: format!("the modal '{}'; use can, will, or must", m.as_str()),
191 });
192 }
193
194 let semicolons = body.matches(';').count();
195 for _ in 0..semicolons {
196 hits.push(Hit {
197 upstream: "8.1",
198 category: "semicolon",
199 detail: "a semicolon; write two sentences".to_string(),
200 });
201 }
202
203 let dashes = logic_dashes(&body);
204 for _ in 0..dashes {
205 hits.push(Hit {
206 upstream: "8-dash",
207 category: "logic-dash",
208 detail: "a dash splicing two statements; name the relation or write two sentences"
209 .to_string(),
210 });
211 }
212}
213
214fn finding(path: &str, number: usize, hit: &Hit) -> Violation {
215 Violation::Finding(Finding::on_line(
216 RuleId::ObjectiveCheckMatchesItsUpstreamRule,
217 path,
218 number,
219 format!(
220 "upstream {} [{}]: {}",
221 hit.upstream, hit.category, hit.detail
222 ),
223 ))
224}
225
226fn is_guide(path: &str) -> bool {
229 path.contains("/guides/") || path.rsplit('/').next() == Some("TEMPLATE-guide.md")
230}
231
232struct Directive {
233 open_line: usize,
234 close_line: Option<usize>,
235 used: bool,
236}
237
238enum Marker {
240 Open { reason_ok: bool },
241 Close,
242 Unknown,
243}
244
245#[allow(clippy::option_if_let_else)]
246fn directive(content: &str) -> Option<Marker> {
247 let inner = content.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
248 let body = inner.strip_prefix("simple-english-")?;
249 if let Some(rest) = body.strip_prefix("disable") {
250 let reason = rest.trim_start_matches(':').trim();
251 Some(Marker::Open {
252 reason_ok: !reason.is_empty(),
253 })
254 } else if body.trim() == "enable" {
255 Some(Marker::Close)
256 } else {
257 Some(Marker::Unknown)
258 }
259}
260
261#[allow(clippy::too_many_lines)]
262fn judge(path: &str, text: &str) -> Vec<Violation> {
263 let kinds = classify(text);
264 let guide = is_guide(path);
265 let mut directive_violations = Vec::new();
266 let mut regions: Vec<Directive> = Vec::new();
267 let mut open: Option<usize> = None;
268
269 for (index, raw) in text.lines().enumerate() {
271 let number = index + 1;
272 if !matches!(kinds.get(index), Some(LineKind::Comment)) {
273 continue;
274 }
275 let content = raw.trim();
276 match directive(content) {
277 None => {}
278 Some(Marker::Open { reason_ok }) => {
279 if !reason_ok {
280 directive_violations.push(Violation::Finding(Finding::on_line(
281 RuleId::ExceptionNamesItsReason,
282 path,
283 number,
284 "an exception directive carries no reason".to_string(),
285 )));
286 }
287 if open.is_some() {
288 directive_violations.push(Violation::Finding(Finding::on_line(
289 RuleId::ExceptionNamesItsReason,
290 path,
291 number,
292 "a nested exception directive; close the first".to_string(),
293 )));
294 } else {
295 open = Some(number);
296 }
297 }
298 Some(Marker::Close) => {
299 if let Some(start) = open.take() {
300 regions.push(Directive {
301 open_line: start,
302 close_line: Some(number),
303 used: false,
304 });
305 } else {
306 directive_violations.push(Violation::Finding(Finding::on_line(
307 RuleId::ExceptionNamesItsReason,
308 path,
309 number,
310 "an exception close with no open directive".to_string(),
311 )));
312 }
313 }
314 Some(Marker::Unknown) => {
315 directive_violations.push(Violation::Finding(Finding::on_line(
316 RuleId::ExceptionNamesItsReason,
317 path,
318 number,
319 "an unknown simple-english directive".to_string(),
320 )));
321 }
322 }
323 }
324 if let Some(start) = open {
325 directive_violations.push(Violation::Finding(Finding::on_line(
326 RuleId::ExceptionNamesItsReason,
327 path,
328 start,
329 "an exception directive is never closed".to_string(),
330 )));
331 regions.push(Directive {
332 open_line: start,
333 close_line: None,
334 used: true, });
336 }
337
338 let disabled = |number: usize, regions: &mut Vec<Directive>| -> bool {
339 for region in regions.iter_mut() {
340 let end = region.close_line.unwrap_or(usize::MAX);
341 if number > region.open_line && number < end {
342 region.used = true;
343 return true;
344 }
345 }
346 false
347 };
348
349 let mut violations = Vec::new();
351 for kind in &kinds {
352 let LineKind::Prose(prose) = kind else {
353 continue;
354 };
355 let mut hits = Vec::new();
356 text_checks(&prose.content, &mut hits);
357 let collapsed = collapse(&prose.content);
358 for (index, sentence) in sentences(&collapsed).into_iter().enumerate() {
359 let procedural = guide && prose.ordered_item && index == 0;
360 let limit = if procedural {
361 PROCEDURAL_LIMIT
362 } else {
363 DESCRIPTIVE_LIMIT
364 };
365 let count = word_count(&sentence);
366 if count > limit {
367 let mode = if procedural { "5.1" } else { "6.3" };
368 hits.push(Hit {
369 upstream: mode,
370 category: "sentence-over-limit",
371 detail: format!("{count} words, limit {limit}"),
372 });
373 }
374 }
375 if hits.is_empty() {
376 continue;
377 }
378 if disabled(prose.number, &mut regions) {
379 continue;
380 }
381 for hit in &hits {
382 violations.push(finding(path, prose.number, hit));
383 }
384 }
385
386 for region in ®ions {
387 if !region.used {
388 directive_violations.push(Violation::Finding(Finding::on_line(
389 RuleId::ExceptionNamesItsReason,
390 path,
391 region.open_line,
392 "an exception region reports nothing; remove it".to_string(),
393 )));
394 }
395 }
396
397 directive_violations.extend(violations);
398 directive_violations
399}
400
401pub fn run(ctx: &GateCtx, files: &[String]) -> GateResult {
407 let mut violations = Vec::new();
408 for file in files {
409 let relative = file.trim_start_matches("./");
410 let text = read_text(ctx, file)?;
411 let found = judge(relative, &text);
412 if ADOPTION_EXEMPT.contains(&relative) {
413 if found.is_empty() {
414 violations.push(Violation::Finding(Finding::on_file(
415 RuleId::ObjectiveCheckMatchesItsUpstreamRule,
416 relative,
417 "is clean; remove it from the adoption exemption list",
418 )));
419 }
420 continue;
421 }
422 violations.extend(found);
423 }
424 Ok(violations)
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 fn run_on_named(name: &str, text: &str) -> Vec<String> {
432 let dir = tempfile::tempdir().unwrap();
433 let path = dir.path().join(name);
434 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
435 std::fs::write(&path, text).unwrap();
436 let ctx = GateCtx::new(dir.path().to_str().unwrap());
437 run(&ctx, &[name.to_string()])
438 .unwrap()
439 .iter()
440 .map(ToString::to_string)
441 .collect()
442 }
443
444 fn run_on(text: &str) -> Vec<String> {
445 run_on_named("doc.md", text)
446 }
447
448 #[test]
449 fn accepts_plain_prose() {
450 assert!(
451 run_on("# Title\n\nThe gate reads the file. It reports one finding per breach.\n")
452 .is_empty()
453 );
454 }
455
456 #[test]
457 fn a_descriptive_sentence_over_25_words_fails() {
458 let long = "This one sentence runs on and on and on and on and on and on and on and on and on and on and on and on well past the limit here.";
459 let out = run_on(&format!("# T\n\n{long}\n"));
460 assert_eq!(out.len(), 1);
461 assert!(out[0].contains("[sentence-over-limit]"));
462 assert!(out[0].contains("upstream 6.3"));
463 }
464
465 #[test]
466 fn a_25_word_descriptive_sentence_passes() {
467 let s = "one two three four five six seven eight nine ten one two three four five six seven eight nine ten one two three four five.";
468 assert!(run_on(&format!("# T\n\n{s}\n")).is_empty());
469 }
470
471 #[test]
472 fn a_guide_step_command_uses_the_20_word_limit() {
473 let s = "Run the command that installs the tool and configures it and verifies it and prints the version and exits cleanly now.";
474 let out = run_on_named("_docs/guides/x.md", &format!("# T\n\n1. {s}\n"));
476 assert_eq!(out.len(), 1);
477 assert!(out[0].contains("upstream 5.1"));
478 }
479
480 #[test]
481 fn the_same_sentence_passes_as_descriptive_prose() {
482 let s = "Run the command that installs the tool and configures it and verifies it and prints the version and exits cleanly now.";
483 assert!(run_on(&format!("# T\n\n{s}\n")).is_empty());
484 }
485
486 #[test]
487 fn a_code_span_counts_as_one_word() {
488 let s = "Run `a b c d e f g h i j k l m n o p q r s t u v w` once more.";
489 assert!(run_on(&format!("# T\n\n{s}\n")).is_empty());
490 }
491
492 #[test]
493 fn an_uppercase_rfc_keyword_is_not_a_modal() {
494 assert!(run_on("# T\n\nThe author MUST keep it exact.\n").is_empty());
495 assert_eq!(run_on("# T\n\nThe author should keep it exact.\n").len(), 1);
496 }
497
498 #[test]
499 fn a_contraction_and_a_semicolon_each_fail() {
500 let out = run_on("# T\n\nYou're done; the tool exits.\n");
501 assert_eq!(out.len(), 2);
502 }
503
504 #[test]
505 fn a_range_is_not_a_logic_dash_but_an_em_dash_is() {
506 assert!(run_on("# T\n\nThe window is 5 - 10 minutes wide.\n").is_empty());
507 assert_eq!(
508 run_on("# T\n\nThe deploy failed — the disk was full.\n").len(),
509 1
510 );
511 }
512
513 #[test]
514 fn a_reasoned_exception_region_suppresses_a_finding() {
515 let long = "This one sentence runs on and on and on and on and on and on and on and on and on and on and on and on well past the limit here.";
516 let text = format!(
517 "# T\n\n<!-- simple-english-disable: marketing copy -->\n\n{long}\n\n<!-- simple-english-enable -->\n"
518 );
519 assert!(run_on(&text).is_empty());
520 }
521
522 #[test]
523 fn an_exception_without_a_reason_fails() {
524 let text = "# T\n\n<!-- simple-english-disable -->\n\nplain text here.\n\n<!-- simple-english-enable -->\n";
525 let out = run_on(text);
526 assert!(out.iter().any(|v| v.contains("carries no reason")));
527 }
528
529 #[test]
530 fn an_unclosed_exception_fails() {
531 let long = "This runs on and on and on and on and on and on and on and on and on and on and on and on well past the descriptive limit here now.";
532 let text = format!("# T\n\n<!-- simple-english-disable: reason -->\n\n{long}\n");
533 let out = run_on(&text);
534 assert!(out.iter().any(|v| v.contains("never closed")));
535 }
536
537 #[test]
538 fn an_unused_exception_region_fails() {
539 let text = "# T\n\n<!-- simple-english-disable: reason -->\n\nplain short text.\n\n<!-- simple-english-enable -->\n";
540 let out = run_on(text);
541 assert!(out.iter().any(|v| v.contains("reports nothing")));
542 }
543
544 #[test]
545 fn a_directive_inside_a_fence_is_text() {
546 let text = "# T\n\n```text\n<!-- simple-english-disable -->\n```\n\nplain text.\n";
547 assert!(run_on(text).is_empty());
548 }
549}