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