#!/usr/bin/env python3
"""The repo's own checks. Exit 1 on any problem. Run before every push: python3 scripts/check.py
- every YAML file parses; every module.yaml has id, name, maturity and stories
- no mode (mode, modes, modes_allowed, default_mode) is a YAML boolean: an unquoted off/on/yes/no parses as
one, and erp-compose scaffold (erp-platform 0.9.1) then refuses or misreads the step
- erp/phase/*.md: at most 60 lines each; erp/build.md: at most 140 (AGENTS.md rule 2)
- every dodil://erp/phase/<x> reference, in any file, has erp/phase/<x>.md
- no client names: this repo is public (AGENTS.md rule 5)
- every brick repo in any module's reference.repos has a brick_entries entry in modules/shared.yaml
- index.json is up to date (scripts/build_index.py --check)
The module specs must also pass the blog's checker: run node <ui-blogs>/scripts/check-modules.mjs
from this repo's root (it reads ./modules).
"""
import pathlib
import re
import subprocess
import sys
import yaml
ROOT = pathlib.Path(__file__).resolve().parent.parent
# Known client names and private paths. Matched case-insensitively; spaces optional.
CLIENT = re.compile(r"gung\s?ho|vogue|ihdiash|scandinavia|laura\s?funk|clients/", re.I)
SELF = {"scripts/check.py"}
PHASE_MAX, MAP_MAX = 60, 140
problems = []
MODE_KEYS = {"mode", "modes", "modes_allowed", "default_mode"}
def mode_booleans(node, path=""):
"""(where, value) for every boolean under a mode key, anywhere in a YAML document."""
if isinstance(node, dict):
for k, v in node.items():
here = f"{path}.{k}" if path else str(k)
if k in MODE_KEYS:
for i, x in enumerate(v if isinstance(v, list) else [v]):
if isinstance(x, bool):
yield (f"{here}[{i}]" if isinstance(v, list) else here), x
else:
yield from mode_booleans(v, here)
elif isinstance(node, list):
for i, x in enumerate(node):
name = (x.get("key") or x.get("id")) if isinstance(x, dict) else None
yield from mode_booleans(x, f"{path}[{name or i}]")
def text_files():
for p in sorted(ROOT.rglob("*")):
rel = p.relative_to(ROOT).as_posix()
if p.is_file() and not rel.startswith(".git/") and "__pycache__" not in rel and p.suffix in {
".md", ".yaml", ".yml", ".json", ".py", ".txt", ""}:
yield rel, p
for f in sorted(ROOT.rglob("*.yaml")):
rel = f.relative_to(ROOT).as_posix()
try:
doc = yaml.safe_load(f.read_text(encoding="utf-8"))
except yaml.YAMLError as e:
problems.append(f"{rel}: does not parse: {str(e).splitlines()[0]}")
continue
for where, v in mode_booleans(doc):
problems.append(f"{rel}: {where} is the boolean {v!r}, not a mode: YAML 1.1 reads an unquoted "
"off/on/yes/no as one; quote it (\"off\")")
if f.name == "module.yaml":
missing = [k for k in ("id", "name", "maturity", "stories") if not (doc or {}).get(k)]
if missing:
problems.append(f"{rel}: missing {', '.join(missing)}")
elif doc["id"] != f.parent.name:
problems.append(f"{rel}: id {doc['id']!r} does not match its folder")
cards = sorted((ROOT / "erp" / "phase").glob("*.md"))
if not cards:
problems.append("erp/phase/: no phase cards")
for c in cards:
n = len(c.read_text(encoding="utf-8").splitlines())
if n > PHASE_MAX:
problems.append(f"erp/phase/{c.name}: {n} lines (max {PHASE_MAX})")
build = ROOT / "erp" / "build.md"
if not build.exists():
problems.append("erp/build.md: missing")
elif (n := len(build.read_text(encoding="utf-8").splitlines())) > MAP_MAX:
problems.append(f"erp/build.md: {n} lines (max {MAP_MAX})")
shared = yaml.safe_load((ROOT / "modules" / "shared.yaml").read_text(encoding="utf-8")) or {}
entries = shared.get("brick_entries") or {}
for f in sorted((ROOT / "modules").glob("*/module.yaml")):
for r in ((yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("reference") or {}).get("repos") or []:
if r.get("role") == "brick" and r.get("name") not in entries:
problems.append(f"{f.relative_to(ROOT).as_posix()}: brick repo {r.get('name')!r} has no entry in "
"modules/shared.yaml brick_entries (erp-compose init cannot compose it)")
for repo, e in entries.items():
if not isinstance(e, dict) or not e.get("name") or not re.fullmatch(r"[A-Za-z_][\w.]*:[A-Za-z_]\w*", str(e.get("brick", ""))):
problems.append(f"modules/shared.yaml brick_entries.{repo}: needs name and brick: \"package.module:OBJECT\"")
names = {c.stem for c in cards}
for rel, p in text_files():
t = p.read_text(encoding="utf-8", errors="replace")
for ref in sorted(set(re.findall(r"dodil://erp/phase/([a-z0-9_-]+)", t))):
if ref not in names:
problems.append(f"{rel}: dodil://erp/phase/{ref} has no erp/phase/{ref}.md")
if rel not in SELF:
for i, line in enumerate(t.splitlines(), 1):
if CLIENT.search(line):
problems.append(f"{rel}:{i}: client name: {CLIENT.search(line).group(0)!r}")
r = subprocess.run([sys.executable, str(ROOT / "scripts" / "build_index.py"), "--check"], capture_output=True, text=True)
if r.returncode:
problems.append(r.stderr.strip() or "index.json check failed")
if problems:
print("\n".join(problems))
print(f"check: {len(problems)} problem(s)")
sys.exit(1)
print(f"check: ok ({len(cards)} phase cards, {len(list((ROOT / 'modules').glob('*/module.yaml')))} module specs)")