AetheriaLore/scripts/check_canon_marker.py
Weksa ddf79e4373
Some checks failed
Canon marker / check (pull_request) Has been cancelled
Canon marker check: refuse repeated keys, read BOM files, fullmatch ids, loud bad base ref; untrack pyc (Soul)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 11:31:57 +00:00

117 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Canon/proposal marker check. Usage: check_canon_marker.py <base-ref>
Checks .md files under Aetheria/ added, modified or renamed against <base-ref>.
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"<campaign>:ruling:<label>\n (found: {bad or 'none'})")
elif kind == "proposal":
if rulings:
problem = "kind: proposal may not carry `rulings:`"
else:
problem = f"kind must be canon or proposal (found: {kind})"
if problem:
failed = True
print(f"CANON CHECK FAILED: {path}\n {problem}\n"
" A ruling must exist before its text enters a canon page. "
"Put the text in a `kind: proposal` page until it does.")
return 1 if failed else 0
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
sys.exit(main(sys.argv[1]))