Canon/proposal file-kind marker, checked on PR (design first) #4

Merged
weksa merged 5 commits from weksa-lore/canon-proposal-marker into main 2026-11-09 11:55:55 +00:00
4 changed files with 341 additions and 0 deletions

17
.github/workflows/canon-marker.yml vendored Normal file
View file

@ -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 }}"

1
.gitignore vendored
View file

@ -32,3 +32,4 @@ Thumbs.db
/quartz-site/node_modules/
/quartz-site/public/
/quartz-site/*.log
__pycache__/

View file

@ -0,0 +1,117 @@
#!/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]))

View file

@ -0,0 +1,206 @@
"""Tests for scripts/check_canon_marker.py: each builds a throwaway git repo
with a main branch, adds pages on a branch, and runs the check against main."""
import os
import subprocess
import tempfile
import unittest
SCRIPT = os.path.join(os.path.dirname(__file__), "..", "check_canon_marker.py")
RULING = "aetheria-lore:ruling:megiddo-origin"
def git(cwd, *args):
subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
cwd=cwd, check=True, capture_output=True)
class Repo:
def __init__(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
git(self.dir, "init", "-b", "main")
os.makedirs(os.path.join(self.dir, "Aetheria"))
self.write("Aetheria/Legacy.md", "old page, no marker\n" + "line of lore\n" * 20)
git(self.dir, "add", "-A")
git(self.dir, "commit", "-m", "base")
git(self.dir, "checkout", "-b", "pr")
def write(self, path, text):
full = os.path.join(self.dir, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(text)
def commit(self):
git(self.dir, "add", "-A")
git(self.dir, "commit", "-m", "pr")
def check(self):
p = subprocess.run(["python3", SCRIPT, "main"], cwd=self.dir,
capture_output=True, text=True)
return p.returncode, p.stdout + p.stderr
def canon(rulings, kind="canon"):
return f"---\nkind: {kind}\nrulings:\n{rulings}---\nbody\n"
class CanonMarker(unittest.TestCase):
def run_pages(self, pages, after=None):
r = Repo()
for path, text in pages.items():
r.write(path, text)
if after:
after(r)
r.commit()
return r.check()
def test_a_canon_page_with_a_ruling_id_passes(self):
code, out = self.run_pages({"Aetheria/A.md": canon(f" - {RULING}\n")})
self.assertEqual(code, 0, out)
def test_a_canon_page_without_a_ruling_fails(self):
code, out = self.run_pages({"Aetheria/A.md": "---\nkind: canon\n---\nx\n"})
self.assertEqual(code, 1, out)
def test_a_non_ascii_path_is_checked_not_skipped(self):
code, out = self.run_pages({"Aetheria/Framgång.md": "---\nkind: canon\n---\nx\n"})
self.assertEqual(code, 1, out)
self.assertIn("Framgång.md", out)
def test_a_renamed_page_is_checked(self):
def rename(r):
git(r.dir, "mv", "Aetheria/Legacy.md", "Aetheria/Moved.md")
r.write("Aetheria/Moved.md", "---\nkind: canon\n---\n" + "old page, no marker\n" + "line of lore\n" * 20)
code, out = self.run_pages({}, after=rename)
self.assertEqual(code, 1, out)
self.assertIn("Moved.md", out)
def test_quoted_values_parse(self):
text = f'---\nkind: "canon"\nrulings:\n - "{RULING}"\n---\nx\n'
code, out = self.run_pages({"Aetheria/A.md": text})
self.assertEqual(code, 0, out)
def test_a_trailing_comment_on_a_ruling_is_not_part_of_the_id(self):
code, out = self.run_pages({"Aetheria/A.md": canon(f" - {RULING} # megiddo\n")})
self.assertEqual(code, 0, out)
def test_a_flow_list_of_rulings_parses(self):
text = f"---\nkind: canon\nrulings: [{RULING}, weksa:ruling:other]\n---\nx\n"
code, out = self.run_pages({"Aetheria/A.md": text})
self.assertEqual(code, 0, out)
def test_cross_campaign_ruling_ids_count(self):
code, out = self.run_pages({"Aetheria/A.md": canon(" - weksa-body:ruling:faction-play-1\n")})
self.assertEqual(code, 0, out)
def test_a_malformed_ruling_id_fails(self):
code, out = self.run_pages({"Aetheria/A.md": canon(" - megiddo-origin\n")})
self.assertEqual(code, 1, out)
def test_a_proposal_may_not_carry_rulings(self):
code, out = self.run_pages({"Aetheria/A.md": canon(f" - {RULING}\n", kind="proposal")})
self.assertEqual(code, 1, out)
def test_a_proposal_without_rulings_passes(self):
code, out = self.run_pages({"Aetheria/A.md": "---\nkind: proposal\n---\nx\n"})
self.assertEqual(code, 0, out)
def test_an_unknown_kind_fails(self):
code, out = self.run_pages({"Aetheria/A.md": "---\nkind: draft\n---\nx\n"})
self.assertEqual(code, 1, out)
def test_a_new_untagged_page_fails(self):
code, out = self.run_pages({"Aetheria/A.md": "no frontmatter\n"})
self.assertEqual(code, 1, out)
def test_a_touched_legacy_page_passes_with_a_notice(self):
code, out = self.run_pages({"Aetheria/Legacy.md": "old page, edited\n" + "line of lore\n" * 20})
self.assertEqual(code, 0, out)
self.assertIn("notice", out)
def test_invalid_yaml_fails_instead_of_crashing(self):
code, out = self.run_pages({"Aetheria/A.md": "---\nkind: [canon\n---\nx\n"})
self.assertEqual(code, 1, out)
self.assertNotIn("Traceback", out)
def test_a_non_mapping_frontmatter_fails(self):
code, out = self.run_pages({"Aetheria/A.md": "---\n- canon\n---\nx\n"})
self.assertEqual(code, 1, out)
self.assertNotIn("Traceback", out)
def test_a_python_object_tag_is_refused_not_executed(self):
text = "---\nkind: !!python/object/apply:os.getcwd []\n---\nx\n"
code, out = self.run_pages({"Aetheria/A.md": text})
self.assertEqual(code, 1, out)
self.assertNotIn("Traceback", out)
def test_a_repeated_key_fails(self):
text = f"---\nkind: proposal\nkind: canon\nrulings:\n - {RULING}\n---\nx\n"
code, out = self.run_pages({"Aetheria/A.md": text})
self.assertEqual(code, 1, out)
self.assertIn("duplicate", out)
def test_a_bom_does_not_hide_the_marker(self):
code, out = self.run_pages({"Aetheria/Legacy.md": "\ufeff---\nkind: canon\n---\nx\n"})
self.assertEqual(code, 1, out)
def test_a_ruling_id_with_a_trailing_newline_is_malformed(self):
text = f'---\nkind: canon\nrulings:\n - "{RULING}\\n"\n---\nx\n'
code, out = self.run_pages({"Aetheria/A.md": text})
self.assertEqual(code, 1, out)
def test_empty_frontmatter_is_a_kindless_page_not_unterminated(self):
code, out = self.run_pages({"Aetheria/A.md": "---\n---\nx\n"})
self.assertEqual(code, 1, out)
self.assertNotIn("unterminated", out)
self.assertIn("needs `kind", out)
def test_a_lookalike_closing_marker_does_not_end_the_frontmatter(self):
text = "---\nkind: proposal\n----\nstill frontmatter?\n---\nx\n"
code, out = self.run_pages({"Aetheria/A.md": text})
self.assertEqual(code, 1, out)
def test_a_renamed_legacy_page_passes_with_a_notice(self):
def rename(r):
git(r.dir, "mv", "Aetheria/Legacy.md", "Aetheria/Moved.md")
code, out = self.run_pages({}, after=rename)
self.assertEqual(code, 0, out)
self.assertIn("notice", out)
def test_a_page_main_changed_after_the_branch_point_is_not_charged_to_the_pr(self):
# Old.md is a bad canon page that main already holds; the PR merges it in
# and does not touch it, then main edits it again. Two-dot diffing
# would see the PR's copy as modified and fail the PR for main's page.
r = Repo()
r.write("Aetheria/Mine.md", "---\nkind: proposal\n---\nx\n")
r.commit()
git(r.dir, "checkout", "main")
r.write("Aetheria/Old.md", "---\nkind: canon\n---\nv1\n")
git(r.dir, "add", "-A")
git(r.dir, "commit", "-m", "main adds Old")
git(r.dir, "checkout", "pr")
git(r.dir, "merge", "--no-edit", "main")
git(r.dir, "checkout", "main")
r.write("Aetheria/Old.md", "---\nkind: canon\n---\nv2\n")
git(r.dir, "add", "-A")
git(r.dir, "commit", "-m", "main edits Old")
git(r.dir, "checkout", "pr")
code, out = r.check()
self.assertEqual(code, 0, out)
def test_an_unknown_base_ref_fails_loudly_without_a_traceback(self):
r = Repo()
p = subprocess.run(["python3", SCRIPT, "no-such-ref"], cwd=r.dir,
capture_output=True, text=True)
self.assertEqual(p.returncode, 1)
self.assertNotIn("Traceback", p.stdout + p.stderr)
self.assertIn("no-such-ref", p.stdout + p.stderr)
def test_pages_outside_aetheria_are_not_checked(self):
code, out = self.run_pages({"README2.md": "---\nkind: canon\n---\nx\n"})
self.assertEqual(code, 0, out)
if __name__ == "__main__":
unittest.main()