187 lines
7.3 KiB
Python
187 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Lint committed docs against .agents/shared/writing-style.md.
|
|
|
|
Checks:
|
|
1. Banned vocabulary (significance puffers, analytical verbs, poetic nouns,
|
|
promotional adjectives, opening crutches).
|
|
2. Broken relative markdown links.
|
|
3. Plan status consistency (status vs location vs index).
|
|
|
|
Prose-voice rules are not machine-checkable; this covers the parts that are.
|
|
Run from the repo root: python3 .agents/skills/docs-lint/lint.py [paths...]
|
|
Exit 1 if any violation is found.
|
|
"""
|
|
import os, re, sys, glob
|
|
|
|
BANNED = [
|
|
"pivotal", "crucial", "vital", "groundbreaking", "transformative", "testament",
|
|
"paramount", "invaluable", "delve", "leverage", "utilize", "facilitate", "foster",
|
|
"showcase", "underscore", "streamline", "harness", "tapestry", "realm", "paradigm",
|
|
"nexus", "cornerstone", "robust", "seamless", "innovative", "cutting-edge",
|
|
"meticulous", "holistic", "comprehensive", "in today's world",
|
|
"it's worth noting", "it is important to note",
|
|
]
|
|
BAN_RE = re.compile(r'(?<![\w-])(' + "|".join(re.escape(w) for w in BANNED) + r')(?![\w-])', re.I)
|
|
LINK = re.compile(r'\]\(([^)]+)\)')
|
|
|
|
def iter_md(paths):
|
|
for p in paths:
|
|
if os.path.isfile(p) and p.endswith(".md"):
|
|
yield p
|
|
for root, dirs, files in os.walk(p):
|
|
dirs[:] = [d for d in dirs if d not in (".git", "node_modules")]
|
|
for f in files:
|
|
if f.endswith(".md"):
|
|
yield os.path.join(root, f)
|
|
|
|
def check_plans():
|
|
"""Check plan status consistency: active plans with 'Done' status, files
|
|
missing from index, dangling index entries, done files with wrong status."""
|
|
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
plans_dir = os.path.join(REPO, "plans")
|
|
done_dir = os.path.join(REPO, "plans", "done")
|
|
index_path = os.path.join(plans_dir, "index.md")
|
|
|
|
if not os.path.exists(index_path):
|
|
return 0
|
|
|
|
violations = 0
|
|
STATUS_RE = re.compile(r'^\*\*Status:\*\*\s*(.+)', re.I)
|
|
|
|
# Parse index.md for active and done entries
|
|
active_files = set()
|
|
done_files = set()
|
|
current_section = None
|
|
with open(index_path) as f:
|
|
for line in f:
|
|
if line.startswith("## Active"):
|
|
current_section = "active"
|
|
continue
|
|
if line.startswith("## Done"):
|
|
current_section = "done"
|
|
continue
|
|
if current_section == "active":
|
|
m = re.search(r'\]\(([^)]+)\)', line)
|
|
if m:
|
|
active_files.add(m.group(1))
|
|
elif current_section == "done":
|
|
m = re.search(r'\]\(([^)]+)\)', line)
|
|
if m:
|
|
done_files.add(m.group(1))
|
|
|
|
# Active plans on disk (not in done/, not index.md)
|
|
disk_active = set()
|
|
for f in glob.glob(os.path.join(plans_dir, "*.md")):
|
|
name = os.path.basename(f)
|
|
if name == "index.md":
|
|
continue
|
|
disk_active.add(name)
|
|
|
|
# Done plans on disk
|
|
disk_done = set()
|
|
if os.path.isdir(done_dir):
|
|
for f in glob.glob(os.path.join(done_dir, "*.md")):
|
|
disk_done.add("done/" + os.path.basename(f))
|
|
|
|
# Check 1: active plans on disk whose internal status is Done/Implemented/Complete
|
|
for name in disk_active:
|
|
fpath = os.path.join(plans_dir, name)
|
|
with open(fpath) as f:
|
|
for line_num, line in enumerate(f, 1):
|
|
if line_num > 5:
|
|
break
|
|
m = STATUS_RE.match(line)
|
|
if m:
|
|
status = m.group(1).strip().lower()
|
|
done_keywords = ["done", "implemented", "complete", "completed"]
|
|
if any(status.startswith(kw) for kw in done_keywords):
|
|
print(f"{fpath}:{line_num}: status '{m.group(1).strip()}' — file is in plans/ but appears done; move to done/")
|
|
violations += 1
|
|
break
|
|
|
|
# Check 2: active plans on disk not in index
|
|
for name in sorted(disk_active):
|
|
if name not in active_files:
|
|
fpath = os.path.join(plans_dir, name)
|
|
print(f"{fpath}:1: not listed in plans/index.md Active table")
|
|
violations += 1
|
|
|
|
# Check 3: done plans on disk not in index
|
|
for name in sorted(disk_done):
|
|
if name not in done_files:
|
|
fpath = os.path.join(REPO, "plans", name)
|
|
print(f"{fpath}:1: not listed in plans/index.md Done table")
|
|
violations += 1
|
|
|
|
# Check 4: index entries with no file on disk
|
|
for name in sorted(active_files):
|
|
if name not in disk_active:
|
|
print(f"plans/index.md: active entry '{name}' — file not found on disk")
|
|
violations += 1
|
|
|
|
for name in sorted(done_files):
|
|
if name not in disk_done:
|
|
print(f"plans/index.md: done entry '{name}' — file not found on disk")
|
|
violations += 1
|
|
|
|
# Check 5: files in done/ whose internal status doesn't say Done
|
|
for name in disk_done:
|
|
fpath = os.path.join(REPO, "plans", name)
|
|
with open(fpath) as f:
|
|
found_status = False
|
|
for line_num, line in enumerate(f, 1):
|
|
if line_num > 5:
|
|
break
|
|
m = STATUS_RE.match(line)
|
|
if m:
|
|
found_status = True
|
|
status = m.group(1).strip().lower()
|
|
if not status.startswith("done"):
|
|
print(f"{fpath}:{line_num}: status '{m.group(1).strip()}' — file is in done/ but status is not 'Done'")
|
|
violations += 1
|
|
break
|
|
if not found_status:
|
|
print(f"{fpath}:1: file is in done/ but has no Status header")
|
|
violations += 1
|
|
|
|
return violations
|
|
|
|
def main(argv):
|
|
paths = argv or ["knowledge", ".agents", "operations", "investigations", "plans"]
|
|
violations = 0
|
|
|
|
if "plans" in paths or any(p.startswith("plans") for p in paths):
|
|
violations += check_plans()
|
|
|
|
# The style guide and this skill enumerate the banned words by definition.
|
|
ban_exempt = ("shared/writing-style.md", "skills/docs-lint/")
|
|
for f in sorted(set(iter_md(paths))):
|
|
check_banned = not any(x in f for x in ban_exempt)
|
|
fence = False
|
|
with open(f) as fh:
|
|
for ln, line in enumerate(fh, 1):
|
|
if line.lstrip().startswith("```"):
|
|
fence = not fence; continue
|
|
if fence:
|
|
continue
|
|
if check_banned:
|
|
for m in BAN_RE.finditer(line):
|
|
print(f"{f}:{ln}: banned word '{m.group(1)}'")
|
|
violations += 1
|
|
for m in LINK.finditer(line):
|
|
link = m.group(1)
|
|
if re.match(r'^(https?:|mailto:|#|/)', link):
|
|
continue
|
|
path = re.split(r'[#?]', link)[0]
|
|
if not path:
|
|
continue
|
|
tgt = os.path.normpath(os.path.join(os.path.dirname(f), path))
|
|
if not os.path.exists(tgt):
|
|
print(f"{f}:{ln}: broken link -> {link}")
|
|
violations += 1
|
|
print(f"\n{violations} violation(s)")
|
|
return 1 if violations else 0
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|