"""Optional prose gate: Vale, Proselint, a term-consistency check, and a
This layers on top of scripts/readability.py (readability + paragraph
length) and scripts/markdown_lint.py (mechanical Markdown/MDX
rules, including alt-text). Internal (relative) link validation lives in
the repo root's scripts/validate.py, which already runs in CI — this
script does not duplicate it and only checks external http(s) links.
Vale and Proselint are silently skipped if not installed, exactly like
markdownlint-cli2 in scripts/markdown_lint.py. The term-consistency check and
the link checker have no dependency — they are plain Python and always run.
Install once per machine, not per project — see requirements.txt / Brewfile
in this skill's root, or just run scripts/install-tools.sh:
brew install vale # style/voice linter
uv tool install proselint # grammar, cliches, redundancy
`uv tool install` puts the binary in ~/.local/bin, which may not be on
PATH in every shell that runs this script (e.g. a fresh non-interactive
shell before `uv tool update-shell` takes effect) — this script checks
that path directly as a fallback, so it finds proselint either way.
python3 prose_lint.py FILE [FILE ...] [--no-links] [--only-links] [--timeout 5]
Exit 0 = clean (or nothing installed to run). Exit 1 = a link is dead.
Vale, Proselint, and term-consistency findings are advisory and never
fail the exit code — only a dead link does.
sys.path.insert(0, str(Path(__file__).parent))
from readability import strip_to_prose # noqa: E402
SKILL_ROOT = Path(__file__).parent.parent
VALE_CONFIG = SKILL_ROOT / ".vale.ini"
PROSELINT_CONFIG = SKILL_ROOT / ".proselintrc.json"
UV_TOOL_BIN = Path(os.path.expanduser("~/.local/bin"))
def find_tool(name: str) -> str | None:
"""Resolve a CLI tool via PATH, falling back to ~/.local/bin (where
`uv tool install` places binaries — it may not be on PATH yet in a
found = shutil.which(name)
candidate = UV_TOOL_BIN / name
MD_LINK_RE = re.compile(r"\[[^\]]*\]\((https?://[^)\s]+)\)")
BARE_URL_RE = re.compile(r"(?<!\()(?<!\]\()https?://[^\s\)\]>,\"']+")
def run_vale(path: Path) -> tuple[list[str], bool]:
"""Return (messages, had_error). Silently skips if vale isn't installed.
had_error is only True for a Vale rule explicitly set to `level: error`
in references/vale-styles/ — none are today, since regex-based prose
rules are false-positive-prone by nature and this gate treats all of
them as advisory. The mechanism still reads real Severity from Vale's
JSON output (not a text-match on the word "error"), so it stays correct
if a future rule is deliberately given error level.
vale_bin = find_tool("vale")
if not VALE_CONFIG.exists():
return [f"WARNING (vale): no config at {VALE_CONFIG} — skipping."], False
[vale_bin, "--config", str(VALE_CONFIG), "--output", "JSON", str(path)],
capture_output=True, text=True, timeout=30,
except subprocess.TimeoutExpired:
return ["WARNING (vale): timed out — skipping."], False
report = json.loads(result.stdout or "{}")
except json.JSONDecodeError:
stderr = result.stderr.strip()
return [f"WARNING (vale): could not parse output.{' ' + stderr if stderr else ''}"], False
findings = next(iter(report.values()), [])
lines = ["── vale findings ──"]
severity = f.get("Severity", "suggestion")
lines.append(f" {path}:{f.get('Line')}:{f.get('Check')} ({severity}): {f.get('Message')}")
def run_proselint(path: Path) -> tuple[list[str], bool]:
"""Return (messages, had_error). Silently skips if proselint isn't installed.
had_error is always False: proselint's JSON output (0.16.0) carries no
severity field per finding, and its process exit code goes nonzero for
any finding at all, real or stylistic — neither signal is reliable
enough to fail the gate on, so every proselint finding here is advisory.
config_args = ["--config", str(PROSELINT_CONFIG)] if PROSELINT_CONFIG.exists() else []
proselint_bin = find_tool("proselint")
cmd = [proselint_bin, "check", "--output-format", "json", *config_args, str(path)]
[sys.executable, "-c", "import proselint"],
capture_output=True, timeout=10, check=True,
cmd = [sys.executable, "-m", "proselint", "check", "--output-format", "json", *config_args, str(path)]
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except subprocess.TimeoutExpired:
return ["WARNING (proselint): timed out — skipping."], False
report = json.loads(result.stdout or "{}")
except json.JSONDecodeError:
stderr = result.stderr.strip()
return [f"WARNING (proselint): could not parse output.{' ' + stderr if stderr else ''}"], False
diagnostics = next(iter(report.get("result", {}).values()), {}).get("diagnostics", [])
lines = ["── proselint findings ──"]
line_no = d.get("pos", [None])[0]
lines.append(f" {path}:{line_no}:{d.get('check_path')}: {d.get('message')}")
def extract_links(text: str) -> list[str]:
text = re.sub(r"^```.*?^```\s*$", "", text, flags=re.S | re.M)
text = re.sub(r"`[^`\n]+`", "", text) # skip inline code — e.g. a doc showing `[text](url)` as example syntax
links = set(MD_LINK_RE.findall(text))
stripped = MD_LINK_RE.sub("", text)
links.update(BARE_URL_RE.findall(stripped))
# Compound modifiers where English legitimately varies the hyphen by
# position — "a well-known fact" (before the noun) vs. "the fact is well
# known" (after it). Flagging these as inconsistent would punish correct
# grammar, so they are excluded rather than caught and warned about.
HYPHEN_DRIFT_EXCEPTIONS = frozenset({
"well-known", "well-defined", "well-formed", "well-established",
"well-documented", "well-tested", "up-to-date", "high-quality",
"first-class", "long-term", "short-term", "real-time", "one-time",
"state-of-the-art", "self-service", "open-source", "user-friendly",
"built-in", "third-party", "best-in-class", "ready-to-use",
"easy-to-use", "cutting-edge",
HYPHEN_PAIR_RE = re.compile(r"\b([a-zA-Z]+)-([a-zA-Z]+)\b")
def check_term_consistency(path: Path) -> tuple[list[str], bool]:
"""Flag a term that appears both hyphenated and as two separate words
in the same document — a likely house-style drift on a project- or
doc-specific term, distinct from Vale's TermConsistency.yml (which
only covers a fixed list of universal English spelling pairs).
Always advisory: this is a heuristic, not a grammar model, so a term
on HYPHEN_DRIFT_EXCEPTIONS is skipped rather than flagged, since for
those the hyphen is meaningful (position in the sentence), not drift.
text = path.read_text(encoding="utf-8")
prose = strip_to_prose(text)
for m in HYPHEN_PAIR_RE.finditer(prose):
pair = f"{m.group(1)}-{m.group(2)}"
if key in HYPHEN_DRIFT_EXCEPTIONS:
# "per-" is a preposition as often as a compound prefix: "per-user
# limit" (adjective) and "$5 per user" (rate) share no relationship
# despite sharing words, so it collides with this heuristic more
# than any other prefix does. Exclude it generically rather than
# one exception at a time.
if key.startswith("per-"):
seen_forms.setdefault(key, pair)
for key, original in seen_forms.items():
spaced_re = re.compile(rf"\b{re.escape(w1)}\s+{re.escape(w2)}\b", re.IGNORECASE)
if spaced_re.search(prose):
f" SUGGESTION: '{original}' appears both hyphenated and as two words "
f"('{w1} {w2}') — pick one form and use it throughout this doc."
return ["── term consistency ──"] + findings, False
def check_link(url: str, timeout: float) -> str | None:
"""Return an error string if the link is dead, else None."""
req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "curl/8.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return f"HTTP {resp.status}"
except urllib.error.HTTPError as e:
if e.code in (405, 403): # HEAD not allowed / blocked — retry with GET
req_get = urllib.request.Request(url, headers={"User-Agent": "curl/8.0"})
with urllib.request.urlopen(req_get, timeout=timeout) as resp:
return f"HTTP {resp.status}"
def run_link_check(path: Path, timeout: float) -> tuple[list[str], bool]:
text = path.read_text(encoding="utf-8")
links = extract_links(text)
return [" link check: no URLs found."], False
lines = [f"── link check ({len(links)} URL(s)) ──"]
err = check_link(url, timeout)
lines.append(f" ERROR: {url} — {err}")
lines.append(" All links reachable.")
def _proselint_available() -> bool:
if find_tool("proselint"):
[sys.executable, "-c", "import proselint"],
capture_output=True, timeout=10, check=True,
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
def check_file(path: Path, check_links: bool, check_prose: bool, timeout: float) -> bool:
for name, available, runner in (
("vale", bool(find_tool("vale")), run_vale),
("proselint", _proselint_available(), run_proselint),
print(f" {name}: not installed — skipping.")
messages, had_error = runner(path)
print(f" {name}: clean.")
any_error = any_error or had_error
messages, had_error = check_term_consistency(path)
print(" term consistency: clean.")
any_error = any_error or had_error
messages, had_error = run_link_check(path, timeout)
any_error = any_error or had_error
print(" link check: skipped.")
ap = argparse.ArgumentParser(description="Optional prose gate: Vale, Proselint, term consistency, link check.")
ap.add_argument("files", nargs="+", help=".md / .mdx files to check")
ap.add_argument("--no-links", action="store_true", help="skip the link checker")
ap.add_argument("--only-links", action="store_true",
help="run only the link checker — skips vale/proselint/term-consistency "
"and doesn't require them installed (used by the scheduled CI link-check job)")
ap.add_argument("--timeout", type=float, default=5.0, help="per-link timeout in seconds")
check_links = args.only_links or not args.no_links
check_prose = not args.only_links
print(f"ERROR: file not found: {path}")
all_ok = check_file(path, check_links, check_prose, args.timeout) and all_ok
sys.exit(0 if all_ok else 1)
if __name__ == "__main__":