dodil/erp-method

scripts/check.py

#!/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)")