Skip to content

Instantly share code, notes, and snippets.

@bonus414
Last active June 14, 2026 12:36
Show Gist options
  • Select an option

  • Save bonus414/9690fb35db0fe404e3a37ccc780b44b9 to your computer and use it in GitHub Desktop.

Select an option

Save bonus414/9690fb35db0fe404e3a37ccc780b44b9 to your computer and use it in GitHub Desktop.
AI Writing Linter — eslint for AI writing: a deterministic linter for the patterns that make text read as AI-generated, with a composable base ruleset + personal-voice layer

AI Writing Linter — eslint for AI writing

Telling a model "sound human, don't be cringe" doesn't reliably work — the tells leak right back in on the next draft. So instead of asking, this lints: a deterministic checker for the patterns that scream "an AI wrote this."

Two composable layers:

  • check_text.py — the base. Catches the universal AI tells: puffery ("boasts a", "rich tapestry"), importance-emphasis ("plays a vital role"), ritual conclusions, hedging, AI-vocabulary clusters ("delve", "underscore"), title-case headings, em-dash overuse, and more. Works on any text.
  • check_voice.py — a personal voice layer on top. It imports the base ruleset, promotes severity where you're stricter, and adds your own patterns: throat-clearing openers, label-colon fragments, an em-dash budget, thesis- opening detection. The patterns it ships with are one writer's preferences — treat them as a worked example to edit, not gospel.

The base+override split is the point: keep the shared rules in one place and layer your own voice on top, instead of forking a giant list.

Usage

# base linter
python check_text.py draft.md
echo "This boasts a rich tapestry of features." | python check_text.py -

# personal voice layer (needs check_text.py next to it)
python check_voice.py draft.md
python check_voice.py draft.md --json
python check_voice.py draft.md --severity warning   # warnings + errors only

Exit code is non-zero when any error-severity pattern is found, so you can run it in a pre-commit hook or CI — continuous integration for writing. It runs before a draft ships, and fixing the violations is mandatory, not optional.

No dependencies — standard library only. Python 3.9+.

Make it yours

check_voice.py is a worked example, not a prescription. Copy it, keep the import check_text base, and swap in the patterns your voice cares about. The base catches the robot; your layer keeps you sounding like you.

#!/usr/bin/env python3
"""
Lint text for AI writing patterns from the anti-ai-writing guide.
Usage:
python check_text.py <file>
echo "text" | python check_text.py -
python check_text.py <file> --json
python check_text.py <file> --severity warning # only warnings + errors
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import defaultdict
from dataclasses import asdict, dataclass
from typing import Iterable
@dataclass
class Issue:
category: str
severity: str
line: int
matched: str
rule: str
SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2}
CATEGORIES: dict[str, dict] = {
"puffery": {
"severity": "error",
"patterns": [
(r"\b(nestled|breathtaking|stunning|captivating|must-visit|groundbreaking|renowned|exemplifies|profound)\b", "promotional adjective"),
(r"\bboasts?\s+a\b", "'boasts a'"),
(r"\bcommitment\s+to\b(?!\s+(memory|the\s+\w+\s+of))", "'commitment to' (vague)"),
(r"\bnatural\s+beauty\b", "'natural beauty'"),
(r"\bin\s+the\s+heart\s+of\b", "'in the heart of'"),
(r"\bgateway\s+to\b(?!\s+(the\s+)?(API|app|service|database|cluster))", "'gateway to' (figurative)"),
(r"\bdiverse\s+(array|tapestry)\b", "'diverse array/tapestry'"),
(r"\brich\s+(history|cultural\s+heritage|tapestry|legacy)\b", "'rich [history/heritage]'"),
(r"\bfascinating\s+glimpse\b", "'fascinating glimpse'"),
(r"\b(showcases?|showcasing|showcased)\b", "showcase verb"),
(r"\b(scenic\s+landscapes|historical\s+landmarks)\b", "tourism puffery"),
(r"\b(watershed\s+moment|key\s+turning\s+point|deeply\s+rooted|profound\s+heritage|steadfast\s+dedication|indelible\s+mark)\b", "puffery cliche"),
(r"\b(continues?\s+to\s+captivate)\b", "'continues to captivate'"),
(r"\bdependable\s+value-driven\s+experiences\b", "marketing jargon"),
],
},
"importance_emphasis": {
"severity": "error",
"patterns": [
(r"\b(stands|serves)\s+as\s+a\s+(testament|reminder|symbol|hub|celebration)\b", "'stands/serves as a testament'"),
(r"\b(plays?|played)\s+a\s+(vital|significant|crucial|pivotal)\s+role\b", "'plays a [vital] role'"),
(r"\b(pivotal|crucial)\s+moment\b", "'pivotal moment'"),
(r"\b(underscores?|highlights?)\s+(its|their)\s+(importance|significance)\b", "'underscores its importance'"),
(r"\breflects\s+broader\b", "'reflects broader'"),
(r"\bsymbolizing\s+(its|their)\s+(ongoing|enduring|lasting)\b", "'symbolizing its ongoing'"),
(r"\bsetting\s+the\s+stage\s+for\b", "'setting the stage for'"),
(r"\b(marks|marking|shaping)\s+(the|a)\s+\w+\s+(in|of|for)\b", "'marking the X of'"),
(r"\b(represents?|marks?)\s+a\s+shift\b", "'represents/marks a shift'"),
(r"\bevolving\s+landscape\b", "'evolving landscape'"),
(r"\bfocal\s+point\b", "'focal point'"),
(r"\bcontributing\s+to\s+the\b", "'contributing to the'"),
(r"\b(enduring|lasting)\s+(impact|legacy|influence)\b", "'enduring impact/legacy'"),
(r"\bpreserving\s+this\s+\w+\s+is\s+vital\b", "'preserving this X is vital'"),
(r"\bcrucial\s+for\s+the\s+survival\s+of\b", "'crucial for the survival of'"),
],
},
"superficial_participial": {
"severity": "error",
"patterns": [
(r"\b(highlighting|emphasizing|reflecting|underscoring|fostering|cultivating|demonstrating|confirming|illustrating|encompassing|ensuring)\s+(its|their|the)\s+(significance|importance|role|relevance|influence|ongoing|enduring|lasting|continued|rich)\b", "trailing -ing clause"),
(r"\bcreating\s+a\s+(space|sense|community)\s+where\b", "'creating a space where'"),
(r"\bembodying\s+the\s+spirit\s+of\b", "'embodying the spirit of'"),
(r"\bfostering\s+a\s+sense\s+of\b", "'fostering a sense of'"),
],
},
"notability_media": {
"severity": "error",
"patterns": [
(r"\bindependent\s+coverage\b", "'independent coverage'"),
(r"\b(local|regional|national)\s+media\s+outlets\b", "vague media outlets"),
(r"\b(featured|cited|profiled|documented)\s+in\s+(archived|multiple|various|numerous)\b", "vague media coverage claim"),
(r"\bmaintains\s+an?\s+(active|strong)\s+(social\s+media|digital)\s+presence\b", "'maintains an active social media presence'"),
(r"\bsignificant,\s+substantial,\s+secondary\s+coverage\b", "stacked vague intensifiers"),
(r"\brepeated\s+national\s+media\s+coverage\b", "'repeated national media coverage'"),
(r"\bongoing\s+public\s+presence\s+in\s+respected\s+media\b", "'ongoing public presence'"),
(r"\bwritten\s+by\s+a\s+leading\s+expert\b", "'written by a leading expert'"),
],
},
"vague_attribution": {
"severity": "warning",
"patterns": [
(r"\bindustry\s+reports?\s+(suggest|show|indicate|cite)\b", "'industry reports suggest'"),
(r"\bobservers\s+have\s+cited\b", "'observers have cited'"),
(r"\b(experts|some\s+critics)\s+argue\b", "'experts/critics argue'"),
(r"\bresearchers\s+and\s+conservationists\b", "'researchers and conservationists'"),
(r"\b(several|various|multiple)\s+(sources|publications)\s+(have|suggest|indicate)\b", "'several sources suggest'"),
(r"\bdescribed\s+in\s+scholarship\b", "'described in scholarship' (no scholar named)"),
(r"\bmodern\s+researchers\s+treat\b", "'modern researchers treat'"),
],
},
"challenges_future": {
"severity": "error",
"patterns": [
(r"\bdespite\s+(its|these|the)\s+(success|achievements|challenges|popularity)[\s\S]{1,200}(continues?\s+to\s+thrive|positions?\s+(it|them|the)|future\s+prospects|ongoing\s+initiatives|future\s+(growth|breakthroughs))", "challenges-and-future formula"),
(r"^\s*#+\s+(challenges\s+and\s+future|future\s+outlook|future\s+directions)\s*$", "ritual 'Challenges and future' section"),
],
},
"negative_parallelism": {
"severity": "warning",
"patterns": [
(r"\bnot\s+(just|only)\b[\s\S]{1,80}\bbut\s+(also|rather)\b", "'not only...but also'"),
(r"\b(it'?s|it\s+is)\s+not\s+(about|just)\b[\s\S]{1,60}\b(it'?s|it\s+is)\b", "'it's not about...it's'"),
(r"\bis\s+not\s+\w+\s+but\s+\w+\b", "'is not X but Y'"),
(r"\bno\s+\w+,\s+no\s+\w+,\s+just\b", "'no X, no Y, just'"),
],
},
"ritual_conclusions": {
"severity": "error",
"patterns": [
(r"^\s*(in\s+summary|in\s+conclusion|overall),", "ritual conclusion opener"),
(r"^\s*#+\s+(conclusion|summary|future\s+outlook)\s*$", "standalone Conclusion/Summary section"),
],
},
"collaborative_meta": {
"severity": "error",
"patterns": [
(r"\bI\s+hope\s+this\s+helps\b", "'I hope this helps'"),
(r"^\s*(certainly|of\s+course)!", "chatbot pleasantry"),
(r"\b(would\s+you\s+like|let\s+me\s+know|I'?d\s+be\s+happy\s+to)\b", "interactive prompt"),
(r"\bhere\s+is\s+a\s+(more\s+)?detailed\s+(breakdown|version)\b", "'here is a detailed breakdown'"),
(r"\byou'?re\s+absolutely\s+right\b", "'you're absolutely right'"),
(r"^\s*subject:\s+", "Subject: header"),
(r"\bas\s+an?\s+(AI|large)\s+(language\s+)?(model|assistant)\b", "AI self-reference"),
(r"\bI\s+am\s+open\s+to\s+(any|further)\s+(suggestions|feedback|guidance|input)\b", "open-to-feedback closer"),
(r"\bif\s+(there\s+are|you\s+have)\s+specific\s+(areas|sections|concerns)\b", "'if you have specific concerns'"),
(r"\bI\s+would\s+(greatly\s+)?appreciate\s+(your\s+)?(guidance|feedback|input)\b", "appreciation closer"),
(r"\bhappy\s+to\s+address\s+any\s+further\s+concerns\b", "'happy to address concerns'"),
(r"\bit'?s\s+(important|worth)\s+to\s+(note|remember|consider)\b", "'it's important to note'"),
(r"\bworth\s+noting\s+that\b", "'worth noting that'"),
],
},
"knowledge_cutoff": {
"severity": "error",
"patterns": [
(r"\bas\s+of\s+my\s+(last|latest)\s+(update|training|knowledge)\b", "knowledge cutoff disclaimer"),
(r"\bup\s+to\s+my\s+last\s+training\s+update\b", "training update disclaimer"),
(r"\bbased\s+on\s+available\s+information\b", "'based on available information'"),
(r"\bin\s+the\s+provided\s+sources\b", "'in the provided sources'"),
(r"\bwhile\s+specific\s+details\s+are\s+(limited|scarce)\b", "'specific details are limited'"),
(r"\bnot\s+widely\s+(available|documented|disclosed)\b", "'not widely available/documented'"),
(r"\bkeeps?\s+(much\s+of\s+)?(his|her|their)\s+personal\s+(life|details)\s+private\b", "speculative privacy claim"),
(r"\blikely\s+due\s+to\s+limited\s+(mainstream\s+)?exposure\b", "'likely due to limited exposure'"),
(r"\bI'?ve?\s+inferred\b", "'I've inferred'"),
(r"\bmy\s+analysis\s+is\s+based\s+on\s+available\b", "'my analysis is based on available'"),
(r"\bmay\s+vary\s+depending\b", "'may vary depending'"),
],
},
"placeholder": {
"severity": "error",
"patterns": [
(r"\bINSERT[_\s]?(SOURCE[_\s]?URL|PUBLISHER|TEXT|CONTENT|DATE)\b", "INSERT placeholder"),
(r"\bPASTE[_\s]?URL[_\s]?HERE\b", "PASTE placeholder"),
(r"\bSOURCE[_\s]?PUBLISHER\b", "SOURCE_PUBLISHER placeholder"),
(r"\b20\d\d-xx-xx\b", "fill-in date"),
(r"\b20XX\b", "20XX placeholder"),
(r"\[insert\s+[^\]]+\]", "[insert ...] placeholder"),
],
},
"ai_vocabulary": {
# Single uses are info; clustering escalates to warning (handled separately).
"severity": "info",
"patterns": [
(r"\b(delve|delving|delved)\b", "delve"),
(r"\b(crucial)\b", "crucial"),
(r"\b(pivotal)\b", "pivotal"),
(r"\b(tapestry)\b", "tapestry"),
(r"\b(underscore[sd]?|underscoring)\b", "underscore"),
(r"\b(intricate|intricacies)\b", "intricate"),
(r"\b(meticulous(?:ly)?)\b", "meticulous"),
(r"\b(garner(?:ed|ing)?)\b", "garner"),
(r"\b(interplay)\b", "interplay"),
(r"\b(testament)\b", "testament"),
(r"\b(bolstered)\b", "bolstered"),
(r"\b(enduring)\b", "enduring"),
(r"\b(enhance[sd]?|enhancing)\b", "enhance"),
(r"\b(foster(?:ing|ed)?)\b", "foster"),
(r"\blandscape\b(?!\s+(painting|architect|garden|design|mode|orientation))", "landscape (abstract)"),
(r"\b(vibrant)\b", "vibrant"),
(r"\baligns?\s+with\b", "align with"),
(r"\bvaluable\s+insights?\b", "valuable insights"),
(r"\bresonates?\s+with\b", "resonate with"),
(r"^\s*Additionally,", "sentence-initial 'Additionally'"),
],
},
"copulative_avoidance": {
"severity": "warning",
"patterns": [
(r"\b(serves?|stands?|marks?)\s+as\s+(a|an|the)\s+(major|key|critical|important|primary|leading|central|defining)\b", "copulative + intensifier (use is/are/has)"),
(r"\brepresents?\s+a\s+(major|significant|critical|paradigm)\b", "'represents a [major]'"),
(r"\b(features?|offers?)\s+(a|an)\s+(state-of-the-art|world-class|cutting-edge|innovative)\b", "promotional copulative"),
],
},
"inline_header_bullets": {
"severity": "warning",
"patterns": [
(r"^\s*[-•*]\s+\*\*[^*\n]+\*\*:\s+\S", "inline-header bullet (- **X**: text)"),
(r"^\s*\d+\.\s+\*\*[^*\n]+\*\*:\s+\S", "inline-header numbered list"),
],
},
"false_range": {
"severity": "warning",
"patterns": [
(r"\b(ranges?|spans?|spanning|ranging)\s+from\s+\w+(\s+\w+){0,3}\s+to\s+\w+", "figurative 'from X to Y' range"),
],
},
"mechanical_triad": {
"severity": "info",
"patterns": [
(r"\b\w+,\s+\w+,\s+and\s+\w+\s+(alike|together|combined)\b", "rule-of-three closer"),
],
},
}
TITLE_CASE_SKIP = {
"a", "an", "the", "and", "or", "but", "of", "in", "on", "at", "to", "for",
"with", "by", "from", "as", "is", "are", "vs", "via",
}
def find_line(text: str, offset: int) -> int:
return text.count("\n", 0, offset) + 1
def check_title_case_heading(line: str, line_num: int) -> list[Issue]:
m = re.match(r"^\s*(#+)\s+(.+?)\s*$", line)
if not m:
return []
heading = m.group(2)
words = re.findall(r"\b[A-Za-z][A-Za-z'-]*\b", heading)
if len(words) < 3:
return []
capitalized = [w for w in words if w[0].isupper() and w.lower() not in TITLE_CASE_SKIP]
if len(capitalized) >= 3:
return [Issue(
category="formatting_tells",
severity="warning",
line=line_num,
matched=heading[:120],
rule="Title Case heading (use sentence case)",
)]
return []
def check_excessive_bold(text: str) -> list[Issue]:
issues: list[Issue] = []
for line_num, line in enumerate(text.split("\n"), 1):
bolds = re.findall(r"\*\*[^*\n]+\*\*", line)
if len(bolds) >= 3:
issues.append(Issue(
category="formatting_tells",
severity="warning",
line=line_num,
matched=f"{len(bolds)} bolded spans on one line",
rule="excessive boldface clustering",
))
return issues
def check_em_dash_overuse(text: str) -> list[Issue]:
issues: list[Issue] = []
line_cursor = 1
for paragraph in text.split("\n\n"):
em_count = paragraph.count("—")
if em_count >= 3:
issues.append(Issue(
category="formatting_tells",
severity="info",
line=line_cursor,
matched=f"{em_count} em dashes in one paragraph",
rule="em dash overuse",
))
line_cursor += paragraph.count("\n") + 2
return issues
def detect_vocab_clusters(singles: list[Issue]) -> list[Issue]:
"""Escalate AI-vocab to warning when 3+ flagged terms appear within 5 lines."""
if len(singles) < 3:
return []
by_line: dict[int, list[Issue]] = defaultdict(list)
for issue in singles:
by_line[issue.line].append(issue)
line_nums = sorted(by_line)
cluster_issues: list[Issue] = []
seen_lines: set[int] = set()
for ln in line_nums:
if ln in seen_lines:
continue
nearby = [l for l in line_nums if ln <= l <= ln + 5]
nearby_count = sum(len(by_line[l]) for l in nearby)
if nearby_count >= 3:
terms = sorted({iss.matched for l in nearby for iss in by_line[l]})
cluster_issues.append(Issue(
category="ai_vocabulary",
severity="warning",
line=ln,
matched=", ".join(terms),
rule=f"AI-vocabulary cluster ({nearby_count} flagged terms within ~5 lines)",
))
seen_lines.update(nearby)
return cluster_issues
def check_text(text: str) -> list[Issue]:
issues: list[Issue] = []
for category, info in CATEGORIES.items():
for pattern, rule in info["patterns"]:
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
issues.append(Issue(
category=category,
severity=info["severity"],
line=find_line(text, match.start()),
matched=match.group(0).strip()[:140],
rule=rule,
))
for line_num, line in enumerate(text.split("\n"), 1):
issues.extend(check_title_case_heading(line, line_num))
issues.extend(check_excessive_bold(text))
issues.extend(check_em_dash_overuse(text))
vocab_singles = [i for i in issues if i.category == "ai_vocabulary" and i.severity == "info"]
issues.extend(detect_vocab_clusters(vocab_singles))
return issues
def filter_severity(issues: Iterable[Issue], min_severity: str) -> list[Issue]:
threshold = SEVERITY_ORDER[min_severity]
return [i for i in issues if SEVERITY_ORDER[i.severity] <= threshold]
def main() -> int:
parser = argparse.ArgumentParser(description="Lint text for AI writing patterns")
parser.add_argument("file", help="File path, or '-' for stdin")
parser.add_argument("--json", action="store_true", help="Output JSON")
parser.add_argument("--severity", choices=["error", "warning", "info"], default="info",
help="Minimum severity to report (default: info)")
args = parser.parse_args()
if args.file == "-":
text = sys.stdin.read()
else:
with open(args.file, "r", encoding="utf-8") as f:
text = f.read()
issues = check_text(text)
issues = filter_severity(issues, args.severity)
issues.sort(key=lambda i: (i.line, SEVERITY_ORDER[i.severity], i.category))
if args.json:
print(json.dumps([asdict(i) for i in issues], indent=2))
return 1 if any(i.severity == "error" for i in issues) else 0
if not issues:
print("No AI writing patterns detected.")
return 0
counts: dict[str, int] = defaultdict(int)
for i in issues:
counts[i.severity] += 1
summary = ", ".join(f"{counts[s]} {s}" for s in ("error", "warning", "info") if counts[s])
print(f"Found {len(issues)} pattern(s): {summary}\n")
for issue in issues:
print(f"Line {issue.line} [{issue.severity}] {issue.category}: {issue.rule}")
print(f" -> \"{issue.matched}\"")
print()
return 1 if any(i.severity == "error" for i in issues) else 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Lint text for personal-voice violations.
Layers your own style patterns on top of anti-ai-writing's check_text.py:
- Imports the base AI-writing rule set (puffery, importance emphasis, etc.)
- Promotes severity for rules you're stricter about (vocab clusters)
- Adds your own patterns (throat-clearing, inline label-colons, em-dash budget)
This file is a worked example. The patterns below are one writer's preferences —
copy it, keep the `import check_text` base, and swap in the rules your voice cares
about.
Usage:
python check_voice.py <file>
echo "text" | python check_voice.py -
python check_voice.py <file> --json
python check_voice.py <file> --severity warning
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import defaultdict
from dataclasses import asdict
from pathlib import Path
# check_voice layers on top of check_text. In this gist both files sit side by
# side, so look for check_text.py in the same directory as this file.
THIS_DIR = Path(__file__).resolve().parent
if not (THIS_DIR / "check_text.py").exists():
sys.stderr.write(
"error: check_text.py not found next to check_voice.py\n"
"The base linter (check_text.py) must live in the same directory.\n"
)
sys.exit(2)
sys.path.insert(0, str(THIS_DIR))
import check_text # noqa: E402
from check_text import Issue, SEVERITY_ORDER, filter_severity # noqa: E402
# Personal patterns (additional rules, not duplicated from check_text)
VOICE_CATEGORIES: dict[str, dict] = {
"voice_throat_clearing": {
"severity": "warning",
"patterns": [
(r"\bhere'?s\s+the\s+thing\b", "'here's the thing' (AI throat-clearing)"),
(r"\bhere'?s\s+what\s+(?!I\b|you\b|we\b|happened\b|works\b|matters\b)", "'here's what X' opener"),
(r"^here'?s\s+(the|this|my|one|another)\s+\w+\s+(that|who|which|isn'?t|nobody|no\s+one)\b", "'Here's the X that…' (reveal-preamble throat-clearing)"),
(r"\bI\s+mean,\s+look\b", "'I mean, look' (filler)"),
(r"\bthe\s+thing\s+is,?\s+", "'the thing is' (AI throat-clearing)"),
],
},
"voice_label_colon_sentence": {
# "Label: description" patterns. Requires 20+ chars after colon to skip headers.
"severity": "warning",
"patterns": [
(r"^[A-Z][^.!?\n:#]{1,80}:\s+[A-Za-z][^\n]{20,}", "label-colon fragment ('Thing: description')"),
],
},
"voice_sentence_initial_transitions": {
"severity": "warning",
"patterns": [
(r"^\s*(Furthermore|Moreover|Additionally|However),", "sentence-initial transition (prefer no transition, or 'and'/'but')"),
],
},
"voice_listicle_opener": {
"severity": "warning",
"patterns": [
(r"^\s*here\s+are\s+\d+\s+(ways|reasons|things|tips)\b", "'Here are N ways' (listicle opener)"),
(r"^\s*\d+\s+(ways|reasons|things|tips)\s+to\s+", "'N ways to X' (listicle opener)"),
],
},
# NOTE: an "assertion_by_negation" rule (', not a/the Y') was prototyped and
# pulled. It can't distinguish a deliberate two-beat kicker ("It's not hard.
# It's just not automatic.") or a literal factual correction ("a comments
# feed, not a posts feed") from AI's balancing "not X but Y". The actual
# failure mode it was meant to catch — meta + contrast stacked — is already
# caught by voice_announce_the_point. Don't re-add without a tone signal a
# regex can't provide.
"voice_announce_the_point": {
# Meta sentences whose job is to announce/frame a point rather than make it.
# Base catches only the literal "worth noting that"; this cuts the whole family.
"severity": "warning",
"patterns": [
(r"\bworth\s+(noting|naming|mentioning|saying|pointing\s+out|highlighting|calling\s+out)\b", "announce-the-point ('worth naming/mentioning' — make the point, don't announce it)"),
(r"\bI'?d\s+want\s+you\s+to\s+(read|see|take|know|understand|hear)\b", "reader-instruction ('I'd want you to read this as' — trust the reader)"),
(r"\bthe\s+(part|thing|point|takeaway)\b[\w\s,'-]{0,45}\bI'?d\s+want\s+you\s+to\b", "reader-instruction framing ('the part I'd want you to...')"),
(r"\bwhat\s+I'?d\s+say\s+here\s+is\b", "announce-the-point ('what I'd say here is' — just say it)"),
(r"^\s*there'?s\s+(one|a|another)\s+\w+\s+worth\s+\w+", "announce-the-point opener ('There's one X worth naming')"),
],
},
}
# Severity overrides on shared rules — tighter than baseline
SEVERITY_OVERRIDES = {
"ai_vocabulary": "warning", # promote from info
"mechanical_triad": "warning", # promote from info
}
def check_em_dash_budget(text: str) -> list[Issue]:
"""House rule: max 2 em dashes per entire piece (anti-ai checks per paragraph)."""
count = text.count("—")
if count > 2:
return [Issue(
category="voice_em_dash_budget",
severity="warning",
line=1,
matched=f"{count} em dashes total",
rule=f"em dash budget exceeded (max 2 per piece, found {count})",
)]
return []
def check_opening(text: str) -> list[Issue]:
"""Flag thesis-statement openings. Prefer opening with a scene/moment, not 'Here's why X matters'."""
# Find first non-blank, non-heading line
for line_num, line in enumerate(text.split("\n"), 1):
stripped = line.strip()
if not stripped or stripped.startswith("#") or stripped.startswith("---"):
continue
thesis_patterns = [
(r"^here'?s\s+(why|how|what)\s+(\w+\s+){1,5}(matters|works|is|means|happened|broke|changed)", "'Here's why/how X matters' opener"),
(r"^this\s+(is|was)\s+(a|the|about)\s+\w+\s+(about|on|of)\s+", "'This is about X' thesis opener"),
(r"^in\s+this\s+(post|article|piece),", "'In this post' meta opener"),
]
for pattern, rule in thesis_patterns:
if re.search(pattern, stripped, re.IGNORECASE):
return [Issue(
category="voice_opening",
severity="warning",
line=line_num,
matched=stripped[:120],
rule=rule,
)]
return [] # checked first content line, done
return []
def check_voice(text: str) -> list[Issue]:
issues: list[Issue] = []
# 1. Run base anti-ai-writing checks
base_issues = check_text.check_text(text)
for issue in base_issues:
if issue.category in SEVERITY_OVERRIDES:
issue.severity = SEVERITY_OVERRIDES[issue.category]
issues.extend(base_issues)
# 2. Personal regex patterns
for category, info in VOICE_CATEGORIES.items():
for pattern, rule in info["patterns"]:
for match in re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE):
issues.append(Issue(
category=category,
severity=info["severity"],
line=text.count("\n", 0, match.start()) + 1,
matched=match.group(0).strip()[:140],
rule=rule,
))
# 3. Personal structural checks
issues.extend(check_em_dash_budget(text))
issues.extend(check_opening(text))
return issues
def main() -> int:
parser = argparse.ArgumentParser(description="Lint text for personal-voice violations")
parser.add_argument("file", help="File path, or '-' for stdin")
parser.add_argument("--json", action="store_true", help="Output JSON")
parser.add_argument("--severity", choices=["error", "warning", "info"], default="info",
help="Minimum severity to report (default: info)")
args = parser.parse_args()
if args.file == "-":
text = sys.stdin.read()
else:
with open(args.file, "r", encoding="utf-8") as f:
text = f.read()
issues = check_voice(text)
issues = filter_severity(issues, args.severity)
issues.sort(key=lambda i: (i.line, SEVERITY_ORDER[i.severity], i.category))
if args.json:
print(json.dumps([asdict(i) for i in issues], indent=2))
return 1 if any(i.severity == "error" for i in issues) else 0
if not issues:
print("No personal-voice violations detected.")
return 0
counts: dict[str, int] = defaultdict(int)
for i in issues:
counts[i.severity] += 1
summary = ", ".join(f"{counts[s]} {s}" for s in ("error", "warning", "info") if counts[s])
print(f"Found {len(issues)} pattern(s): {summary}\n")
for issue in issues:
print(f"Line {issue.line} [{issue.severity}] {issue.category}: {issue.rule}")
print(f" -> \"{issue.matched}\"")
print()
return 1 if any(i.severity == "error" for i in issues) else 0
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment