Skills › BMAD Method › bmad-review › word_metrics.py
word_metrics.py
105 lines · skills/bmad-review/scripts/word_metrics.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""Exact word counts for a document, as JSON.
7Emits the document's total word count and a per-heading-section breakdown so
8an editorial review can ground word-impact estimates and reduction
9percentages in real numbers instead of guessing. Sections are delimited by
10markdown headings (# through ######); heading markers inside fenced code
11blocks are ignored (fences pair CommonMark-style: a fence closes only on a
12run of the same character at least as long, so ```` fences may embed ```
13examples). A word is any whitespace-separated token, plus one word per
14Chinese or Japanese character since those scripts do not space-delimit words.
15Korean spaces its words, so Hangul counts per token. For non-markdown
16input the result is a single section holding the full text.
17"""
19import argparse
20import json
21import re
22import sys
23from pathlib import Path
25HEADING = re.compile(r"^(#{1,6})\s+(\S.*)$")
26FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
27CJK = re.compile(r"[-ヿ㐀-䶿一-鿿豈-ヲ-゚]")
30def word_count(text: str) -> int:
31 cjk = len(CJK.findall(text))
32 return cjk + len(CJK.sub(" ", text).split())
35def section_metrics(text: str) -> list[dict]:
36 sections = []
37 current = {"heading": "(preamble)", "level": 0, "body": []}
38 open_fence = None # (char, length) while inside a fenced block
39 for line in text.splitlines():
40 fence = FENCE.match(line)
41 if fence:
42 marker = fence.group(1)
43 if open_fence is None:
44 open_fence = (marker[0], len(marker))
45 elif marker[0] == open_fence[0] and len(marker) >= open_fence[1] and line.strip() == marker:
46 open_fence = None
47 current["body"].append(line)
48 continue
49 match = None if open_fence else HEADING.match(line)
50 if match:
51 sections.append(current)
52 current = {
53 "heading": match.group(2).strip(),
54 "level": len(match.group(1)),
55 "body": [],
56 }
57 else:
58 current["body"].append(line)
59 sections.append(current)
61 out = []
62 for section in sections:
63 words = word_count("\n".join(section["body"]))
64 if section["heading"] == "(preamble)" and words == 0:
65 continue
66 out.append({"heading": section["heading"], "level": section["level"], "words": words})
67 return out
70def metrics(path: Path) -> dict:
71 text = path.read_text(encoding="utf-8", errors="replace")
72 return {
73 "file": str(path),
74 "total_words": word_count(text),
75 "sections": section_metrics(text),
76 }
79def main() -> int:
80 if hasattr(sys.stdout, "reconfigure"):
81 sys.stdout.reconfigure(encoding="utf-8") # JSON is UTF-8 regardless of locale code page
82 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
83 parser.add_argument("path", help="document to measure")
84 parser.add_argument("-o", "--output", help="write JSON here (default: stdout)")
85 args = parser.parse_args()
87 path = Path(args.path)
88 if not path.is_file():
89 print(f"error: not a readable file: {path}", file=sys.stderr)
90 return 2
92 result = json.dumps(metrics(path), indent=2, ensure_ascii=False)
93 if args.output:
94 Path(args.output).write_text(result + "\n", encoding="utf-8")
95 else:
96 print(result)
97 return 0
100if __name__ == "__main__":
101 if sys.platform == "win32":
102 # Piped output on Windows defaults to a legacy code page, not UTF-8.
103 sys.stdout.reconfigure(encoding="utf-8")
104 sys.stderr.reconfigure(encoding="utf-8")
105 sys.exit(main())