Files

217 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Reflow hard-wrapped markdown prose to one line per paragraph.
Markdown and editors soft-wrap on their own, so manual mid-paragraph line
breaks (the ~75-char "wrap") add nothing. This joins wrapped lines within plain
paragraphs, list items, and (lazily) their continuations, and leaves code
fences, tables, headings, thematic breaks, HTML blocks, indented code,
blockquotes, and front matter byte-identical.
Safety invariant: the non-whitespace token sequence of a file must be unchanged
(a reflow only edits whitespace). Any file that would fail the invariant is left
untouched and reported — the tool never risks losing or scrambling content.
Usage:
python scripts/reflow_md.py # dry run: report what would change
python scripts/reflow_md.py --apply # rewrite files in place
python scripts/reflow_md.py --check # exit 1 if any in-scope file is wrapped
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path()
SKIP_DIRS = {
".OLD",
"node_modules",
".venv",
".uv-cache",
".claude",
".next",
"dist",
".git",
"__pycache__",
".mypy_cache",
".ruff_cache",
".pytest_cache",
".superpowers",
}
# Excluded subtrees / files: archive, generated (regenerated by `make lifecycle`
# — never hand-edit), gitignored internal notes, untracked working specs.
EXCLUDE_PREFIXES = ("docs/internal/", "agents/prompts/_generated/")
EXCLUDE_GLOBS = ("docs/SPEC_",)
FENCE = re.compile(r"^(\s*)(`{3,}|~{3,})")
FENCE_CLOSE = re.compile(r"^\s*(`{3,}|~{3,})\s*$")
HEADING = re.compile(r"^\s*#{1,6}(\s|$)")
LIST_ITEM = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+")
THEMATIC = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
SETEXT = re.compile(r"^\s*(=+|-+)\s*$")
def _is_indented_code(line: str) -> bool:
return line[:4] == " " and not LIST_ITEM.match(line)
def _is_passthrough(line: str) -> bool:
"""A line copied verbatim and never joined into prose.
Headings, code fences, tables/HTML/blockquotes (`|`, `<`, `>`),
thematic/setext rules, and indented code each stay on their own line.
"""
s = line.strip()
return bool(
HEADING.match(line)
or FENCE.match(line)
or s.startswith(("|", "<", ">"))
or THEMATIC.match(line)
or SETEXT.match(line)
or _is_indented_code(line)
)
def _is_block_break(line: str) -> bool:
"""A blank line or any verbatim block construct (ends a prose/list run)."""
return line.strip() == "" or _is_passthrough(line)
def _copy_fence(lines: list[str], i: int, out: list[str]) -> int:
"""Copy a fenced code block verbatim through its closing fence."""
out.append(lines[i])
i += 1
while i < len(lines):
out.append(lines[i])
closed = FENCE_CLOSE.match(lines[i])
i += 1
if closed:
break
return i
def _join_list(lines: list[str], i: int, out: list[str]) -> int:
"""Reflow a list block: one line per item, joining lazy continuations."""
while (
i < len(lines) and not _is_block_break(lines[i]) and LIST_ITEM.match(lines[i])
):
item = [lines[i]]
i += 1
while (
i < len(lines)
and lines[i].strip() != ""
and not LIST_ITEM.match(lines[i])
and not _is_block_break(lines[i])
):
item.append(lines[i].strip())
i += 1
out.append(
" ".join(p.rstrip() if k == 0 else p.strip() for k, p in enumerate(item))
)
return i
def _join_paragraph(lines: list[str], i: int, out: list[str]) -> int:
"""Join a plain paragraph's lines into one."""
para: list[str] = []
while (
i < len(lines)
and not _is_block_break(lines[i])
and not LIST_ITEM.match(lines[i])
):
para.append(lines[i].strip())
i += 1
out.append(" ".join(para))
return i
def _skip_front_matter(lines: list[str], out: list[str]) -> int:
"""Copy a leading `--- ... ---` front-matter block verbatim; return next i."""
if not (lines and lines[0].strip() == "---"):
return 0
out.append(lines[0])
i = 1
while i < len(lines) and lines[i].strip() != "---":
out.append(lines[i])
i += 1
if i < len(lines):
out.append(lines[i])
i += 1
return i
def reflow(text: str) -> str:
lines = text.split("\n")
out: list[str] = []
i = _skip_front_matter(lines, out)
while i < len(lines):
line = lines[i]
if line.strip() == "":
out.append(line)
i += 1
elif FENCE.match(line):
i = _copy_fence(lines, i, out)
elif _is_passthrough(line):
out.append(line)
i += 1
elif LIST_ITEM.match(line):
i = _join_list(lines, i, out)
else:
i = _join_paragraph(lines, i, out)
return "\n".join(out)
def in_scope(p: Path) -> bool:
if any(part in SKIP_DIRS for part in p.parts):
return False
rel = p.as_posix()
if any(rel.startswith(pre) for pre in EXCLUDE_PREFIXES):
return False
return not any(g in rel for g in EXCLUDE_GLOBS)
def main() -> None:
args = sys.argv[1:]
apply = "--apply" in args
check = "--check" in args
changed: list[Path] = []
skipped: list[Path] = []
for p in sorted(ROOT.rglob("*.md")):
if not in_scope(p):
continue
orig = p.read_text()
new = reflow(orig)
if new == orig:
continue
if orig.split() != new.split(): # token invariant — never touch
skipped.append(p)
continue
changed.append(p)
if apply:
p.write_text(new)
if check:
for p in skipped:
print(f"warning: skipped (token-invariant): {p}")
if changed:
print(f"{len(changed)} file(s) have hard-wrapped prose. Fix with:")
print(" make reflow-docs")
for p in changed:
print(f" {p}")
sys.exit(1)
print("OK: no hard-wrapped markdown prose in scope.")
return
verb = "reflowed" if apply else "would reflow"
print(f"{verb}: {len(changed)} skipped (token-invariant): {len(skipped)}")
for p in changed:
print(f" {p}")
for p in skipped:
print(f" !! skipped {p}")
if __name__ == "__main__":
main()