"""Readability gate for Markdown/MDX prose.
Scores the prose (not the code) of a .md/.mdx file against the house
target: an 8th-grade reading level (Flesch-Kincaid). Technical vocabulary
gets a fair shake — long words that repeat 3+ times are treated as domain
terms and down-weighted in the adjusted score, which is the one gated on.
Also enforces the house paragraph rule: no paragraph over 4 sentences.
python3 readability.py FILE [FILE ...] [--max-grade 8.9] [--allow term1,term2]
Exit 0 = all files pass. Exit 1 = grade too high or a paragraph over 4
sentences. Exit 2 = usage/file error.
MAX_GRADE_DEFAULT = 8.9 # "8th grade" band tops out just under 9
MAX_SENTENCES_PER_PARAGRAPH = 4
REPEAT_THRESHOLD = 3 # a long word seen this often counts as a domain term
VOWEL_GROUPS = re.compile(r"[aeiouy]+")
SENTENCE_END = re.compile(r"[.!?]+(?=\s|$)")
def count_syllables(word: str) -> int:
w = re.sub(r"[^a-z]", "", word.lower())
# silent trailing e ("table" keeps the le, "make" drops the e)
if w.endswith("e") and not w.endswith(("le", "ee", "ye")):
return max(1, len(VOWEL_GROUPS.findall(w)))
def strip_to_prose(text: str) -> str:
"""Remove everything that isn't sentence prose: frontmatter, code,
tables, headings, JSX, imports, URLs, markdown syntax."""
text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S)
text = re.sub(r"^```.*?^```\s*$", "", text, flags=re.S | re.M)
text = re.sub(r"^(import|export)\s.*$", "", text, flags=re.M)
text = re.sub(r"^\s*<.*$", "", text, flags=re.M) # JSX-only lines
text = re.sub(r"<[^>\n]+>", "", text) # inline tags
text = re.sub(r"^\s*\|.*$", "", text, flags=re.M) # table rows
text = re.sub(r"^#{1,6}\s.*$", "", text, flags=re.M) # headings
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images
text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) # links -> text
text = re.sub(r"`[^`\n]+`", "", text) # inline code
text = re.sub(r"https?://\S+", "", text)
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.M) # list markers
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.M)
text = re.sub(r"^\s*>\s?", "", text, flags=re.M) # blockquote markers
text = re.sub(r"[*_~]{1,3}", "", text) # emphasis markers
def split_sentences(prose: str) -> list:
# a line break is a boundary too — list items rarely end with periods
for chunk in re.split(r"\n\s*\n|\n", prose):
for p in SENTENCE_END.split(chunk):
if p and re.search(r"[a-zA-Z]{2,}", p):
def words_of(sentence: str) -> list:
return re.findall(r"[A-Za-z][A-Za-z'’-]*", sentence)
def grade_levels(sentences: list, allow: set) -> dict:
all_words = [w for s in sentences for w in words_of(s)]
n_sent = max(1, len(sentences))
n_words = max(1, len(all_words))
freq[w.lower()] = freq.get(w.lower(), 0) + 1
is_domain = s >= 3 and (freq[lower] >= REPEAT_THRESHOLD or lower in allow)
adj_syll += 2 # cap domain terms at 2 syllables
def fk(syllables: int) -> float:
return 0.39 * (n_words / n_sent) + 11.8 * (syllables / n_words) - 15.59
ease = 206.835 - 1.015 * (n_words / n_sent) - 84.6 * (raw_syll / n_words)
"sentences": len(sentences),
"avg_sentence_len": n_words / n_sent,
"grade_raw": fk(raw_syll),
"grade_adjusted": fk(adj_syll),
"domain_terms": sorted(domain_terms),
def is_prose_line(line: str) -> bool:
return not re.match(r"^(#{1,6}\s|\||[-*+]\s|\d+\.\s|>|```|<|import\s|export\s|---\s*$|:::)", s)
def paragraph_violations(text: str) -> list:
"""Return (line_number, sentence_count) for prose paragraphs over the max."""
text = re.sub(r"\A---\n.*?\n---\n", lambda m: "\n" * m.group(0).count("\n"), text, flags=re.S)
block_start, block_lines = None, []
prose = strip_to_prose("\n".join(block_lines))
n = len(split_sentences(prose))
if n > MAX_SENTENCES_PER_PARAGRAPH:
violations.append((block_start, n))
for i, line in enumerate(lines, 1):
if line.strip().startswith("```"):
block_start, block_lines = None, []
block_start, block_lines = None, []
def check_file(path: str, max_grade: float, allow: set) -> bool:
with open(path, encoding="utf-8") as f:
print(f"ERROR: cannot read {path}: {e}")
prose = strip_to_prose(text)
sentences = split_sentences(prose)
print(f"{path}: no prose found — nothing to score.")
m = grade_levels(sentences, allow)
ok = m["grade_adjusted"] <= max_grade
print(f" Sentences: {m['sentences']} Words: {m['words']} "
f"Avg sentence length: {m['avg_sentence_len']:.1f}")
print(f" Flesch Reading Ease: {m['ease']:.1f}")
print(f" Grade level (raw): {m['grade_raw']:.1f}")
print(f" Grade level (adjusted for domain terms): {m['grade_adjusted']:.1f} "
f"target ≤ {max_grade} {'PASS' if ok else 'FAIL'}")
print(f" Domain terms down-weighted: {', '.join(m['domain_terms'][:15])}"
+ (" …" if len(m["domain_terms"]) > 15 else ""))
longest = sorted(sentences, key=lambda s: len(words_of(s)), reverse=True)[:3]
print(" Longest sentences to split or simplify:")
preview = s if len(s) <= 100 else s[:97] + "…"
print(f" - ({len(words_of(s))} words) {preview}")
for line_no, count in paragraph_violations(text):
print(f" ERROR line {line_no}: paragraph has {count} sentences "
f"(max {MAX_SENTENCES_PER_PARAGRAPH}) — split it.")
ap = argparse.ArgumentParser(description="Readability gate for Markdown/MDX prose.")
ap.add_argument("files", nargs="+", help=".md / .mdx files to score")
ap.add_argument("--max-grade", type=float, default=MAX_GRADE_DEFAULT,
help=f"max adjusted FK grade (default {MAX_GRADE_DEFAULT})")
ap.add_argument("--allow", default="",
help="comma-separated technical terms to down-weight regardless of frequency")
allow = {t.strip().lower() for t in args.allow.split(",") if t.strip()}
all_ok = all([check_file(p, args.max_grade, allow) for p in args.files])
sys.exit(0 if all_ok else 1)
if __name__ == "__main__":