Writing System
Orwell's six writing rules (1946) as an always-on prose standard for docs, PR text, commit messages, reports, and copy, plus a /rewrite command that applies them to existing text. Adds measurable STE-lite limits and a stdlib linter (ccgm-prose-lint). Governs prose only, never code or technical terms.
Tags
README
writing-system
Orwell's six writing rules (1946) as an always-on prose standard, plus a /rewrite command that applies them to existing text.
What It Does
Most setups ban AI-sounding words one at a time ("no delve", "no em dashes") and still ship every README and PR description in the same AI voice. The missing piece is a writing system, not a longer blacklist. This module installs one:
-
rules/writing-system.mdloads in every session and governs prose: docs, READMEs, PR descriptions, commit messages, issue comments, session reports, chat responses, marketing copy. The six rules:- Never use a metaphor, simile or other figure of speech which you are used to seeing in print.
- Never use a long word where a short one will do.
- If it is possible to cut a word out, always cut it out.
- Never use the passive where you can use the active.
- Never use a foreign phrase, a scientific word or a jargon word if you can think of an everyday English equivalent.
- Break any of these rules sooner than say anything outright barbarous.
-
The rule adds two workflow subsections: commit/PR prose (plain words, no achievement language, one-read test) and session reports (plain sentences, no emoji checkmarks, no "Successfully").
-
commands/rewrite.mdadds/rewrite [file] [mode:landing] [--apply]: lists every violation (stale phrase, long word with its short replacement, cuttable word, passive construction, jargon), then rewrites, keeping every fact, number, and name unchanged.mode:landingadds the swap test for marketing copy: if a competitor could paste the line unchanged onto their page, rewrite or delete it. -
STE-lite limits in the rule add the measurable half of ASD-STE100: sentence length by kind (20 words for instructions, 25 for description), paragraphs of 6 sentences or fewer, noun clusters of 3 words or fewer, one action per step, and a name beside each first-time reference.
bin/ccgm-prose-lint(librarylib/prose_lint.py, Python standard library only) measures them:ccgm-prose-lint [FILE|-] [--json] [--strict]. The linter checks sentence length and paragraph length (errors) and two-action steps (warnings). Noun clusters and unnamed references stay in the rule as guidance: an audit of real messages found the checks too noisy (16% and 35% precision), so they were removed.--strictexits 1 on any error. Tools in~/.claude/binare not onPATH; call"$HOME/.claude/bin/ccgm-prose-lint". -
/rewrite --sterewrites to full Simplified Technical English.
The rules never touch code, identifiers, API names, or technical terms whose plain-word swap would change the meaning.
Relationship to editorial-critique
/editorial-critique is the deep review: 8 parallel lenses over long-form writing. /rewrite is the cheap pass: one read, one violations list, one rewrite. The rule file is the standard both work from.
Manual Installation
# Global (all projects)
mkdir -p ~/.claude/rules ~/.claude/commands
cp rules/writing-system.md ~/.claude/rules/writing-system.md
cp commands/rewrite.md ~/.claude/commands/rewrite.md
mkdir -p ~/.claude/bin ~/.claude/lib
cp lib/prose_lint.py ~/.claude/lib/prose_lint.py
cp bin/ccgm-prose-lint ~/.claude/bin/ccgm-prose-lint && chmod +x ~/.claude/bin/ccgm-prose-lint
Files
| File | Description |
|---|---|
rules/writing-system.md |
The six rules, scope carve-outs, commit/PR and report subsections |
commands/rewrite.md |
/rewrite: violations list, then rewrite; mode:landing for copy; --ste for full STE |
lib/prose_lint.py |
lint(text) and stats(text): the STE-lite measurements |
bin/ccgm-prose-lint |
CLI over prose_lint: text, --json, --strict |
tests/test_prose_lint.py |
pytest suite for the linter and CLI |
Will install
| Path | Action | Target | Type |
|---|---|---|---|
rules/writing-system.md | → | rules/writing-system.md | rule |
commands/rewrite.md | → | commands/rewrite.md | command |
lib/prose_lint.py | → | lib/prose_lint.py | lib |
bin/ccgm-prose-lint | → | bin/ccgm-prose-lint | script |
Dependencies
No dependencies.
Required by
No other module depends on this one.
Included in presets
Install this module
Agent prompt
Recommended for agent users -- hands the whole install off to your assistant.
Fetch https://ccgm.dev/modules/writing-system.md and install this module into my Claude Code setup.
Native plugin marketplace
One command via the native plugin marketplace -- additive, does not merge settings.json.
claude plugin install writing-system@ccgm
The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.
Files
rule (1)
rules/writing-system.md
# Writing System
Prose follows the six rules. Fix the system, not one word at a time.
These rules govern prose: docs, READMEs, PR descriptions, commit messages, issue comments, session reports, chat responses, marketing copy. They never touch code, identifiers, API names, error strings someone will grep for, or a technical term whose plain-word swap would change the meaning ("idempotent" stays "idempotent").
## The Six Rules (Orwell, 1946)
1. Never use a metaphor, simile or other figure of speech which you are used to seeing in print.
2. Never use a long word where a short one will do.
3. If it is possible to cut a word out, always cut it out.
4. Never use the passive where you can use the active.
5. Never use a foreign phrase, a scientific word or a jargon word if you can think of an everyday English equivalent.
6. Break any of these rules sooner than say anything outright barbarous.
Check deliverable prose (a README, a PR body, a doc page, a report) against these rules before delivering it.
## What the Rules Do to a Sentence
Before: "Comprehensive error handling has been implemented across all API endpoints to ensure robust and reliable performance."
After: "We added error handling to every API endpoint."
Comprehensive, robust, reliable, and ensure are gone. Passive turned active. 16 words down to 8. Same facts.
## Commit Messages and PR Descriptions
State what changed and why in plain words. No achievement language: no "comprehensive", no "robust", no "seamless". A reviewer should know what the change does in one read. Value-first structure (what the change enables, then the evidence) comes from the git-workflow rules and the `pr-description` skill; these rules govern the words inside that structure.
## Session Reports and Summaries
Report progress in plain sentences: what changed, what failed, what comes next. No emoji checkmarks, no "Successfully", no "Perfect", no wall of bullets. Start with three lines; add detail only when it changes the reader's next action.
This governs free-form summaries. When a command or skill mandates an exact output format (a JSON envelope, `/sds`'s final report, a statusline, a structured findings artifact), that format wins.
## STE-lite Limits
The measurable half of ASD-STE100. `ccgm-prose-lint` checks the sentence and paragraph limits (noun clusters and unnamed references are judged by reading): before delivering a document, run `test -x "$HOME/.claude/bin/ccgm-prose-lint"` as its own command; if it exits non-zero, note that the linter is not installed and go on; otherwise run `"$HOME/.claude/bin/ccgm-prose-lint" --strict FILE` as its own command.
- Instruction sentences: 20 words or fewer. Descriptive sentences: 25 or fewer.
- Paragraphs: 6 sentences or fewer, one topic, topic sentence first. Each list item counts as its own paragraph.
- Noun clusters: 3 words or fewer. Break longer ones with a preposition.
- Steps: imperative, one action per sentence, condition before the action.
- One term per concept within a document.
- Name a reference the first time it appears in text for the operator: "PR #1138 (autoheal signature quality)", not "#1138".
- Same scope as the six rules: prose only. Code, identifiers, error strings, and technical terms are exempt.
## Why There Is No Word Blacklist
Banning words one at a time ("no delve", "no em dashes") treats symptoms. The six rules are the system; a banned-word list is what you fall back on without one. For a deep, line-by-line review of long-form writing, run `/editorial-critique`; its detectors (AI-tell vocabulary, filler phrases) apply these rules at review time. For a quick single pass over existing text, run `/rewrite`.
## Red Flags
Stop and rewrite if you catch yourself:
- Writing "comprehensive", "robust", "seamless", or "cutting-edge" in prose
- Opening a report with an emoji checkmark or the word "Successfully"
- Writing a sentence whose subject performs no action ("error handling has been implemented")
- Padding a summary with bullets that restate the diff
- Swapping a precise technical term for a folksy one and losing the meaning; rule 6 exists for this
command (1)
commands/rewrite.md
---
description: Rewrite prose under the six writing rules - list every violation first, then rewrite, keeping every fact, number, and name unchanged
allowed-tools: Read, Edit, Glob, Grep, AskUserQuestion
argument-hint: "[file-path or pasted text] [mode:landing] [--ste] [--apply]"
---
# /rewrite - Apply the Six Writing Rules
Single-pass rewrite of existing prose under the six writing rules in `~/.claude/rules/writing-system.md`. Violations first, then the rewrite. Facts, numbers, and names survive untouched.
For a deep multi-lens review (argument, structure, data, impact), use `/editorial-critique` instead. This command is the cheap pass: one read, one list, one rewrite.
## Step 1: Resolve the Target
- If `$ARGUMENTS` contains a file path, Read it. Critique the prose only: skip frontmatter, fenced code blocks, inline code, and command output.
- If `$ARGUMENTS` contains pasted text, use it directly.
- If neither, ask what to rewrite.
Parse flags from `$ARGUMENTS` before treating the rest as the target: `mode:landing`, `--ste`, and `--apply`.
## Step 2: List Every Violation
Go through the text and list each violation, grouped by rule, quoting the exact original text:
1. **Stale figures of speech** (rule 1): each print-worn metaphor or simile, with a fresh or literal replacement.
2. **Long words** (rule 2): each long word with its short replacement ("utilize" -> "use", "approximately" -> "about").
3. **Cuttable words** (rule 3): each word or phrase that can go without losing meaning ("in order to" -> "to", "it's worth noting that" -> cut).
4. **Passive constructions** (rule 4): each passive with its active rewrite, where the actor is known.
5. **Jargon** (rule 5): each jargon or foreign phrase with an everyday equivalent, only where precision survives the swap. A technical term with no accurate plain substitute stays.
6. **STE-lite limits** (always on): each sentence over 20 words (instruction) or 25 words (description), each paragraph over 6 sentences, each noun cluster over 3 words, each step that holds two actions, each reference ("#1138") with no name beside it. For a file, run `test -x "$HOME/.claude/bin/ccgm-prose-lint"` as its own command; if it exits non-zero, print a one-line "not installed" note and skip. Otherwise run `"$HOME/.claude/bin/ccgm-prose-lint" --json FILE` as its own command for the sentence, paragraph, and two-action findings. It does not check noun clusters or unnamed references; judge those by reading.
If a category has no violations, skip it. Do not pad the list.
## Step 3: Rewrite
Produce the full rewrite applying every fix from Step 2.
Hard constraints:
- Keep every fact, number, and name unchanged.
- Keep code blocks, identifiers, commands, links, and frontmatter byte-for-byte.
- Keep the document's structure (headings, lists, tables) unless a violation lives in the structure itself.
- Rule 6 wins conflicts: if a fix makes the sentence worse, leave the original and say so.
Output the rewrite in a fenced code block so it pastes clean (see `copy-paste-output.md`).
## Step 3b: --ste
`--ste` rewrites to full Simplified Technical English, not only the STE-lite limits:
- One meaning per word, and one word per meaning, across the whole text.
- Active voice and simple tenses (past, present, future). No perfect or progressive forms where a simple tense works.
- Instructions are imperative, one action per sentence, with the condition first.
- Instruction sentences: 20 words or fewer. Descriptive sentences: 25 or fewer.
- Use a noun or verb, not a "-ing" form acting as a noun, for the thing being done.
- Keep technical terms, code, identifiers, and error strings exactly as written.
Rule 6 still wins: if the STE form changes a fact, keep the original and say so.
## Step 4: mode:landing
For marketing or landing-page copy, add two checks on top of Steps 2-3:
- **One concrete claim per line.** Flag any line that makes two claims or none.
- **The swap test.** For every line, ask: could a competitor paste this unchanged onto their page? If yes, the line says nothing about this product. Rewrite it around a concrete, specific claim, or delete it.
List swap-test failures in Step 2's output as their own group.
## Step 5: Apply
- If the target was a file and `--apply` was passed, apply the rewrite with Edit.
- If the target was a file without `--apply`, show the violations and rewrite, then ask whether to apply.
- If the target was pasted text, the fenced rewrite is the deliverable; there is nothing to apply.
lib (1)
lib/prose_lint.py
"""prose_lint: measure the STE-lite limits from the writing-system rule.
Public API:
lint(text) -> list[Finding] errors and warnings
stats(text) -> dict counts the metrics tools read
Errors: sentence length (by kind), paragraph length, and diagram width.
Warnings: two-action steps.
Noun clusters and unnamed references are guidance in the rule, not checked here:
an audit of real messages put their precision at 16% and 35%.
Before counting, the text loses frontmatter, fenced code, inline code, tables,
headings, URLs, and file:line refs. Each list item is its own paragraph.
"""
import math
import re
from dataclasses import dataclass
# Every threshold lives here.
LIMITS = {
"instruction_words": 20,
"description_words": 25,
"paragraph_sentences": 6,
"diagram_columns": 100,
}
RULES = ("sentence-length", "paragraph-length", "two-actions", "diagram-width")
@dataclass
class Finding:
line: int
level: str # "error" | "warning"
rule: str
message: str
text: str
def as_dict(self):
return {
"line": self.line,
"level": self.level,
"rule": self.rule,
"message": self.message,
"text": self.text,
}
# --- word lists --------------------------------------------------------------
# Base-form verbs that are rarely nouns: a sentence starting with one is an instruction.
_VERBS = set("""
add run use check read write create delete remove open close keep install update edit fix
verify confirm ensure show print return call pass send start stop restart copy move rename
replace select choose click press type enter avoid do don't let take give find search look
review merge push pull apply revert wait ask tell follow define declare import export deploy
launch kill skip save load begin finish split join pick treat try change increase decrease
restrict explain describe include exclude prefer compare inspect resolve retry convert
extract parse validate provide supply assign configure enable disable grant revoke approve
reject accept handle extend reduce compute measure calculate generate render ignore
make put get turn bring cite quote paste upload download sign pin unpin cancel clear
dispatch delegate append prepend insert trigger fetch track wrap
""".split())
# Words that are also common nouns: an instruction only when a determiner,
# pronoun, preposition, or particle follows ("Store the result", not "Store layer gains").
_AMBIGUOUS_VERBS = set("""
store log record test build link list name state report commit branch tag limit document
stage count match query mark switch drop cut break end set stack file note plan sort
rewrite checkout rebase patch scope target group price score label point focus
""".split())
_VERB_FOLLOWERS = set("""
the a an this that these those it them each every all any your my our its their both
to with on in into from up out off at by for of as over under back if when
""".split())
_ABBREVIATIONS = {
"e.g", "i.e", "vs", "cf", "approx", "mr", "mrs", "ms", "dr", "st", "no", "fig", "ca", "al",
"etc", "inc", "ltd", "jr", "sr",
}
# --- cleaning ----------------------------------------------------------------
_FENCE = re.compile(r"^\s*(```+|~~~+)")
_LIST_ITEM = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?")
_HEADING = re.compile(r"^\s{0,3}#{1,6}(?:\s|$)")
_RULE_LINE = re.compile(r"^\s*(?:[-*_=]\s*){3,}$")
_URL = re.compile(r"(?:https?://|www\.)\S+")
_FILE_REF = re.compile(r"[\w./~-]*\w\.\w+:\d+(?::\d+)?")
_INLINE_CODE = re.compile(r"`[^`]*`")
# Inline code becomes this marker: it keeps a sentence boundary visible to the
# splitter but is not a word.
_CODE = "\ue000"
_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
_HTML_TAG = re.compile(r"</?[A-Za-z][^>]*>|<!--.*?-->")
def _clean_inline(s):
s = _INLINE_CODE.sub(_CODE, s)
s = _IMAGE.sub("", s)
s = _LINK.sub(r"\1", s)
s = _URL.sub("", s)
s = _FILE_REF.sub("", s)
s = _HTML_TAG.sub("", s)
s = s.replace("*", "").replace("__", "")
s = re.sub(r"\s+([.,;:!?])", r"\1", s)
return re.sub(r"\s+", " ", s).strip()
def _body_start(lines):
"""Index of the first line after frontmatter, or 0 when there is none."""
if lines and lines[0].strip() == "---":
for j in range(1, len(lines)):
if lines[j].strip() == "---":
return j + 1
return 0
def _blocks(text):
"""Yield (first_line_number, cleaned_text) for each paragraph or list item."""
lines = text.splitlines()
i = _body_start(lines)
fence = None
cur = None # [start_line, [parts]]
out = []
def flush():
nonlocal cur
if cur:
cleaned = _clean_inline(" ".join(cur[1]))
if re.search(r"\w", cleaned):
out.append((cur[0], cleaned))
cur = None
for n in range(i, len(lines)):
raw = lines[n]
m = _FENCE.match(raw)
if fence:
if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence):
fence = None
continue
if m:
flush()
fence = m.group(1)
continue
stripped = raw.strip()
if not stripped:
flush()
continue
if stripped.startswith("|") or _HEADING.match(raw) or _RULE_LINE.match(raw):
flush()
continue
stripped = re.sub(r"^(?:>\s?)+", "", stripped).strip()
item = _LIST_ITEM.match(stripped)
if item:
flush()
cur = [n + 1, [stripped[item.end():]]]
elif cur:
cur[1].append(stripped)
else:
cur = [n + 1, [stripped]]
flush()
return out
# --- diagrams ----------------------------------------------------------------
# A fenced block holding any of these is a diagram; other fenced code is exempt.
_BOX_CHARS = frozenset("┌┐└┘─│├┤┬┴┼═║╔╗╚╝╠╣╦╩╬▶▼◀▲►◄▾▴▸◂")
def _diagram_findings(text):
"""Error for each line over the column limit in a fenced block with box-drawing characters."""
lines = text.splitlines()
start = _body_start(lines)
limit = LIMITS["diagram_columns"]
findings = []
fence, body = None, []
def close():
if any(_BOX_CHARS.intersection(line) for _, line in body):
for n, line in body:
if len(line) > limit: # characters, not bytes
findings.append(Finding(
n, "error", "diagram-width",
f"{len(line)} columns in a diagram; limit is {limit}", line[:80],
))
for n in range(start, len(lines)):
raw = lines[n]
m = _FENCE.match(raw)
if fence:
if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence):
close()
fence, body = None, []
else:
body.append((n + 1, raw.rstrip("\r")))
elif m:
fence, body = m.group(1), []
if fence: # unterminated fence: the block runs to the end of the file
close()
return findings
# --- sentences ---------------------------------------------------------------
_SPLIT = re.compile(r"""([.!?]+["')\]]*)\s+(?=["'(\[]?[A-Za-z0-9\ue000])""")
def _split_sentences(block):
sentences, start = [], 0
for m in _SPLIT.finditer(block):
before = block[start:m.start()].split()
last = before[-1].lower().strip("\"'([") if before else ""
if m.group(1) == "." and (last in _ABBREVIATIONS or re.fullmatch(r"[a-z]", last)):
continue
if block[m.end():].lstrip("\"'([")[:1].islower():
# A lowercase start splits only after a plain full stop, "!" or "?"
# that follows a word of 3+ letters that is not an abbreviation.
ends_word = re.search(r"[A-Za-z]{3,}$", before[-1].strip("\"'([")) if before else None
if m.group(1).rstrip("\"')]") not in (".", "!", "?") or not ends_word or last in _ABBREVIATIONS:
continue
sentences.append(block[start:m.end()].strip())
start = m.end()
tail = block[start:].strip()
if tail:
sentences.append(tail)
return [s for s in sentences if re.search(r"\w", s)]
_ID = re.compile(r"#\d+|[A-Z]{1,3}-?\d+")
def _is_id_list(sentence):
"""True when more than half the words are IDs (E3, XP-3, #12): not prose."""
words = _words(sentence)
ids = sum(1 for w in words if _ID.fullmatch(_strip_punct(w)))
return ids * 2 > len(words)
def _words(sentence):
return [w for w in sentence.split() if re.search(r"\w", w)]
def _strip_punct(w):
return w.strip("\"'()[]{}.,;:!?—–-")
def _is_instruction(sentence):
words = _words(sentence)
if not words:
return False
first = _strip_punct(words[0]).lower()
if first in _VERBS:
return True
if first in _AMBIGUOUS_VERBS:
if len(words) == 1:
return True
return _strip_punct(words[1]).lower() in _VERB_FOLLOWERS
return False
# --- analysis ----------------------------------------------------------------
def _analyze(text):
"""Return (paragraphs, findings). Paragraph = (line, [sentences])."""
findings = []
paragraphs = []
for line, block in _blocks(text):
sentences = [s for s in _split_sentences(block) if not _is_id_list(s)]
if not sentences:
continue
paragraphs.append((line, sentences))
if len(sentences) > LIMITS["paragraph_sentences"]:
findings.append(Finding(
line, "error", "paragraph-length",
f"{len(sentences)} sentences; limit is {LIMITS['paragraph_sentences']}",
block[:80],
))
for s in sentences:
instr = _is_instruction(s)
n = len(_words(s))
limit = LIMITS["instruction_words" if instr else "description_words"]
if n > limit:
kind = "instruction" if instr else "description"
findings.append(Finding(
line, "error", "sentence-length",
f"{n} words in {'an' if instr else 'a'} {kind}; limit is {limit}", s,
))
if instr:
findings.extend(_two_actions(line, s))
findings.extend(_diagram_findings(text))
findings.sort(key=lambda f: f.line)
return paragraphs, findings
_VERB_ALTERNATION = "|".join(sorted(re.escape(v) for v in _VERBS if "'" not in v))
_TWO_ACTIONS = re.compile(
rf"\b(?:and|then)\s+(?:then\s+)?(?:{_VERB_ALTERNATION})\b|,\s*then\b|;", re.I
)
def _two_actions(line, sentence):
if _TWO_ACTIONS.search(sentence):
return [Finding(line, "warning", "two-actions",
"step holds more than one action; split it", sentence)]
return []
# --- public API --------------------------------------------------------------
def lint(text):
"""Return the findings for `text`, in document order."""
return _analyze(text)[1]
def _counts(paragraphs):
return [len(_words(s)) for _, group in paragraphs for s in group]
def sentence_word_counts(text):
"""Word count of each counted sentence, in document order (same set as stats())."""
return _counts(_analyze(text)[0])
def stats(text):
paragraphs, findings = _analyze(text)
sentences = [s for _, group in paragraphs for s in group]
counts = _counts(paragraphs)
instr = sum(1 for s in sentences if _is_instruction(s))
by_rule = dict.fromkeys(RULES, 0)
for f in findings:
by_rule[f.rule] += 1
over = by_rule["sentence-length"]
ranked = sorted(counts)
return {
"words": sum(counts),
"sentences": len(sentences),
"paragraphs": len(paragraphs),
"instruction_sentences": instr,
"descriptive_sentences": len(sentences) - instr,
"max_sentence_words": max(counts, default=0),
"mean_sentence_words": round(sum(counts) / len(counts), 1) if counts else 0,
"max_paragraph_sentences": max((len(g) for _, g in paragraphs), default=0),
"errors": sum(1 for f in findings if f.level == "error"),
"warnings": sum(1 for f in findings if f.level == "warning"),
"p90_sentence_words": ranked[math.ceil(0.9 * len(ranked)) - 1] if ranked else 0,
"sentences_over_limit": over,
"pct_over_limit": round(over / len(counts) * 100, 1) if counts else 0,
"by_rule": by_rule,
}
script (1)
bin/ccgm-prose-lint
#!/usr/bin/env python3
"""ccgm-prose-lint: check prose against the STE-lite limits in writing-system.
ccgm-prose-lint [FILE|-] [--json] [--strict]
Reads FILE, or stdin when FILE is "-" or absent. Prints one finding per line:
FILE:LINE: level: rule: message
--json prints {"findings": [...], "stats": {...}} instead.
--strict exits 1 when any error-level finding exists. Warnings never fail.
Exit codes: 0 ok, 1 strict failure, 2 unreadable input.
"""
import argparse
import json
import sys
from pathlib import Path
_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE.parent / "lib"))
import prose_lint # noqa: E402
def main(argv=None):
ap = argparse.ArgumentParser(description="STE-lite prose linter")
ap.add_argument("file", nargs="?", default="-")
ap.add_argument("--json", action="store_true", help="emit findings and stats as JSON")
ap.add_argument("--strict", action="store_true", help="exit 1 on any error-level finding")
args = ap.parse_args(argv)
try:
text = sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
print(f"ccgm-prose-lint: cannot read {args.file}: {e}", file=sys.stderr)
return 2
findings = prose_lint.lint(text)
if args.json:
print(json.dumps({
"findings": [f.as_dict() for f in findings],
"stats": prose_lint.stats(text),
}, indent=2))
else:
name = "<stdin>" if args.file == "-" else args.file
for f in findings:
print(f"{name}:{f.line}: {f.level}: {f.rule}: {f.message}")
if args.strict and any(f.level == "error" for f in findings):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())