Canon/proposal file-kind marker, checked on PR (design first) #4
4 changed files with 341 additions and 0 deletions
17
.github/workflows/canon-marker.yml
vendored
Normal file
17
.github/workflows/canon-marker.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -32,3 +32,4 @@ Thumbs.db
|
|||
/quartz-site/node_modules/
|
||||
/quartz-site/public/
|
||||
/quartz-site/*.log
|
||||
__pycache__/
|
||||
|
|
|
|||
117
scripts/check_canon_marker.py
Normal file
117
scripts/check_canon_marker.py
Normal 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]))
|
||||
206
scripts/tests/test_check_canon_marker.py
Normal file
206
scripts/tests/test_check_canon_marker.py
Normal 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()
|
||||
Loading…
Reference in a new issue