Skip to content

Readability

FieldValue
TypeSkill Resource
Source~/.copilot/skills/technical-writing/scripts/readability.py
DescriptionNot specified

Source Content

#!/usr/bin/env python3
"""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.
Usage:
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.
"""
import argparse
import re
import sys
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())
if not w:
return 0
if len(w) <= 3:
return 1
# silent trailing e ("table" keeps the le, "make" drops the e)
if w.endswith("e") and not w.endswith(("le", "ee", "ye")):
w = w[:-1]
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
return text
def split_sentences(prose: str) -> list:
sentences = []
# 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):
p = p.strip()
if p and re.search(r"[a-zA-Z]{2,}", p):
sentences.append(p)
return sentences
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 = {}
for w in all_words:
freq[w.lower()] = freq.get(w.lower(), 0) + 1
raw_syll = 0
adj_syll = 0
domain_terms = set()
for w in all_words:
s = count_syllables(w)
raw_syll += s
lower = w.lower()
is_domain = s >= 3 and (freq[lower] >= REPEAT_THRESHOLD or lower in allow)
if is_domain:
domain_terms.add(lower)
adj_syll += 2 # cap domain terms at 2 syllables
else:
adj_syll += s
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)
return {
"sentences": len(sentences),
"words": len(all_words),
"avg_sentence_len": n_words / n_sent,
"ease": ease,
"grade_raw": fk(raw_syll),
"grade_adjusted": fk(adj_syll),
"domain_terms": sorted(domain_terms),
}
def is_prose_line(line: str) -> bool:
s = line.strip()
if not s:
return False
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."""
violations = []
text = re.sub(r"\A---\n.*?\n---\n", lambda m: "\n" * m.group(0).count("\n"), text, flags=re.S)
lines = text.split("\n")
in_code = False
block_start, block_lines = None, []
def flush():
if block_start is None:
return
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("```"):
in_code = not in_code
flush()
block_start, block_lines = None, []
continue
if in_code:
continue
if is_prose_line(line):
if block_start is None:
block_start = i
block_lines.append(line)
else:
flush()
block_start, block_lines = None, []
flush()
return violations
def check_file(path: str, max_grade: float, allow: set) -> bool:
try:
with open(path, encoding="utf-8") as f:
text = f.read()
except OSError as e:
print(f"ERROR: cannot read {path}: {e}")
sys.exit(2)
prose = strip_to_prose(text)
sentences = split_sentences(prose)
if not sentences:
print(f"{path}: no prose found — nothing to score.")
return True
m = grade_levels(sentences, allow)
ok = m["grade_adjusted"] <= max_grade
print(f"\n{path}")
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'}")
if m["domain_terms"]:
print(f" Domain terms down-weighted: {', '.join(m['domain_terms'][:15])}"
+ ("" if len(m["domain_terms"]) > 15 else ""))
if not ok:
longest = sorted(sentences, key=lambda s: len(words_of(s)), reverse=True)[:3]
print(" Longest sentences to split or simplify:")
for s in longest:
preview = s if len(s) <= 100 else s[:97] + ""
print(f" - ({len(words_of(s))} words) {preview}")
para_ok = True
for line_no, count in paragraph_violations(text):
para_ok = False
print(f" ERROR line {line_no}: paragraph has {count} sentences "
f"(max {MAX_SENTENCES_PER_PARAGRAPH}) — split it.")
return ok and para_ok
def main() -> None:
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")
args = ap.parse_args()
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__":
main()