diff --git a/.github/workflows/canon-marker.yml b/.github/workflows/canon-marker.yml new file mode 100644 index 0000000..39600d4 --- /dev/null +++ b/.github/workflows/canon-marker.yml @@ -0,0 +1,17 @@ +name: Canon marker + +on: + pull_request: + +jobs: + check: + # The forge's runner carries the label `docker` (image node:22-bookworm); + # `ubuntu-latest` matches no runner there and the job is cancelled. + runs-on: docker + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: apt-get update -qq && apt-get install -y -qq python3-yaml + - run: python3 -m unittest scripts/tests/test_check_canon_marker.py + - run: python3 scripts/check_canon_marker.py "origin/${{ github.base_ref }}" diff --git a/.gitignore b/.gitignore index fea6634..046c00e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ Thumbs.db /quartz-site/node_modules/ /quartz-site/public/ /quartz-site/*.log +__pycache__/ diff --git a/scripts/check_canon_marker.py b/scripts/check_canon_marker.py new file mode 100644 index 0000000..2ad615f --- /dev/null +++ b/scripts/check_canon_marker.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Canon/proposal marker check. Usage: check_canon_marker.py + +Checks .md files under Aetheria/ added, modified or renamed against . +Proves the form and presence of ruling ids, not that the ruling exists. +""" +import re, subprocess, sys + +import yaml + +ID = re.compile(r"^[a-z0-9][a-z0-9-]*:ruling:[a-z0-9][a-z0-9._-]*$") + + +def git(*a): + return subprocess.run(["git", *a], check=True, capture_output=True, text=True).stdout + + +class UniqueKeyLoader(yaml.SafeLoader): + """safe_load that refuses a repeated key: one page, one reading of `kind`.""" + + def construct_mapping(self, node, deep=False): + seen = set() + for key_node, _ in node.value: + key = self.construct_object(key_node, deep=True) + if key in seen: + raise yaml.constructor.ConstructorError( + None, None, f"duplicate key {key!r}", key_node.start_mark) + seen.add(key) + return super().construct_mapping(node, deep) + + +FRONT = re.compile(r"---\n(?:(.*?)\n)?---[ \t]*(?:\n|\Z)", re.S) + + +def frontmatter(text): + """Return (kind, rulings or None, error). Frontmatter is real YAML.""" + if not text.startswith("---\n"): + return None, None, None + m = FRONT.match(text) + if not m: + return None, None, "unterminated frontmatter" + try: + data = yaml.load(m.group(1) or "", Loader=UniqueKeyLoader) + except yaml.YAMLError as e: + return None, None, f"frontmatter is not valid YAML ({type(e).__name__}: {e})" + if data is None: + data = {} + if not isinstance(data, dict): + return None, None, "frontmatter must be a mapping" + kind, rulings = data.get("kind"), data.get("rulings") + if kind is not None and not isinstance(kind, str): + return None, None, "kind must be a string" + if rulings is not None and not (isinstance(rulings, list) and all(isinstance(r, str) for r in rulings)): + return None, None, "rulings must be a list of strings" + return kind, rulings, None + + +def changed(base): + """(status letter, path) for each page added, modified or renamed (the new path).""" + out = git("diff", "-z", "--name-status", "--diff-filter=AMR", f"{base}...HEAD", "--", "Aetheria") + fields = out.split("\0") + rows, i = [], 0 + while i < len(fields) and fields[i]: + st = fields[i] + if st[0] == "R": + rows.append(("R", fields[i + 2])) + i += 3 + else: + rows.append((st[0], fields[i + 1])) + i += 2 + return rows + + +def main(base): + try: + rows = changed(base) + except subprocess.CalledProcessError as e: + print(f"CANON CHECK FAILED: cannot diff against {base!r}: {e.stderr.strip()}") + return 1 + failed = False + for st, path in rows: + if not path.endswith(".md"): + continue + try: + with open(path, encoding="utf-8-sig") as f: + kind, rulings, err = frontmatter(f.read()) + except (OSError, UnicodeDecodeError) as e: + kind, rulings, err = None, None, f"cannot read page ({type(e).__name__})" + problem = None + if err: + problem = err + elif kind is None: + if st == "A": + problem = "new page needs `kind: canon` or `kind: proposal`" + else: + print(f"notice: {path} has no kind (legacy); tag it when you next touch it") + elif kind == "canon": + bad = [r for r in (rulings or []) if not ID.fullmatch(r)] + if not rulings or bad: + problem = ("kind: canon requires `rulings:` with at least one id of the form " + f":ruling: