Skip to main content

spec_driven_docs/gates/
simple_english.rs

1//! Gate: prose follows the objective `SimpleEnglish` checks.
2//!
3//! This is a compatibility port of the deterministic checks the vendored
4//! `SimpleEnglish` linter expresses, adapted to `Markdown`, to structural passage
5//! resolution, and to this project's citable-failure contract. It reports
6//! only the objective subset: sentence length, contractions, perfect tense,
7//! `-ing` verbs, banned modals, semicolons, and logic dashes. Judgment rules
8//! — active voice, term familiarity, one-item-one-name — are review-held in
9//! `SPEC-simple-english.md`, never inferred here.
10//!
11//! The passage mode comes from structure. Every sentence carries the 25-word
12//! descriptive limit, except the command sentence of a numbered step in a
13//! guide, which carries 20. The gate never reads a verb or a heading to guess
14//! the mode. Protected spans stay exact: a code span counts as one word, and
15//! an uppercase RFC 2119 keyword is left alone.
16
17use 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
26/// The rules this gate can cite.
27pub const CITES: &[RuleId] = &[
28    RuleId::ObjectiveCheckMatchesItsUpstreamRule,
29    RuleId::ExceptionNamesItsReason,
30];
31
32/// Repository-relative files held under a shrinking adoption exemption for `SimpleEnglish`.
33///
34/// The gate still scans each one and suppresses its findings, but fails if a
35/// listed file becomes clean or disappears, so the list cannot outlive the
36/// corpus it covers (the `ESLint` unused-suppression model, and the method's
37/// shrinking-exemption shape). Empty is the goal.
38pub const ADOPTION_EXEMPT: &[&str] = &[];
39
40const DESCRIPTIVE_LIMIT: usize = 25;
41const PROCEDURAL_LIMIT: usize = 20;
42
43// sdd: permanent a static pattern that fails to compile is a build defect
44#[allow(clippy::expect_used)]
45fn re(cell: &'static OnceLock<Regex>, pattern: &str) -> &'static Regex {
46    // The patterns are compile-time constants in this module; a compile
47    // failure is a bug, not a runtime condition.
48    cell.get_or_init(|| Regex::new(pattern).expect("static pattern compiles"))
49}
50
51/// Collapse every protected span to one token, so a word count and the
52/// text checks see one word where a reader sees one unit.
53fn 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
69/// Split a collapsed text into sentences, dropping fragments under two words.
70fn 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
93/// One deterministic finding on a line: its upstream rule and its category.
94struct Hit {
95    upstream: &'static str,
96    category: &'static str,
97    detail: String,
98}
99
100/// Count logic dashes: an em dash, a spaced double hyphen, or a spaced single
101/// hyphen between two non-digits. A range or a flag is not a logic dash.
102fn 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            // A spaced ` -- ` or ` - ` between two non-digit characters.
109            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        // An uppercase RFC 2119 keyword is protected, not a banned modal.
185        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
227/// Whether the file is governed as a guide, where a numbered step command
228/// carries the procedural limit.
229fn 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
239/// A directive control marker, distinguished from an ordinary comment.
240enum Marker {
241    Open { reason_ok: bool },
242    Close,
243    Unknown,
244}
245
246// sdd: permanent the if-let chain states the marker cases in order, and a closure hides them
247#[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// sdd: permanent one pass keeps the line kinds and the findings together
264#[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    // First pass: the exception directives, which are comment lines.
273    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, // an unterminated region is already a failure
338        });
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    // Second pass: the prose checks.
353    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 &regions {
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
404/// Judge every markdown file pre-commit passed.
405///
406/// # Errors
407///
408/// [`crate::gates::GateError::Io`] when a file cannot be read.
409pub 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        // 21 words, ordered item in a guide: procedural, over 20.
478        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}