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#[allow(clippy::expect_used)]
44fn re(cell: &'static OnceLock<Regex>, pattern: &str) -> &'static Regex {
45    // The patterns are compile-time constants in this module; a compile
46    // failure is a bug, not a runtime condition.
47    cell.get_or_init(|| Regex::new(pattern).expect("static pattern compiles"))
48}
49
50/// Collapse every protected span to one token, so a word count and the
51/// text checks see one word where a reader sees one unit.
52fn 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
68/// Split a collapsed text into sentences, dropping fragments under two words.
69fn 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
92/// One deterministic finding on a line: its upstream rule and its category.
93struct Hit {
94    upstream: &'static str,
95    category: &'static str,
96    detail: String,
97}
98
99/// Count logic dashes: an em dash, a spaced double hyphen, or a spaced single
100/// hyphen between two non-digits. A range or a flag is not a logic dash.
101fn 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            // A spaced ` -- ` or ` - ` between two non-digit characters.
108            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        // An uppercase RFC 2119 keyword is protected, not a banned modal.
184        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
226/// Whether the file is governed as a guide, where a numbered step command
227/// carries the procedural limit.
228fn 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
238/// A directive control marker, distinguished from an ordinary comment.
239enum 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    // First pass: the exception directives, which are comment lines.
270    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, // an unterminated region is already a failure
335        });
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    // Second pass: the prose checks.
350    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 &regions {
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
401/// Judge every markdown file pre-commit passed.
402///
403/// # Errors
404///
405/// [`crate::gates::GateError::Io`] when a file cannot be read.
406pub 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        // 21 words, ordered item in a guide: procedural, over 20.
475        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}