|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Validate this OKF (Open Knowledge Format) bundle. |
| 3 | +
|
| 4 | +Run locally or in CI: python3 scripts/validate_okf.py |
| 5 | +Exits non-zero if any invariant is violated. See CLAUDE.md for the card schema. |
| 6 | +
|
| 7 | +Invariants enforced |
| 8 | +-------------------- |
| 9 | +Structure |
| 10 | + - exactly one bundle-root index.md, and it is the only file declaring `okf_version` |
| 11 | + - every site folder has an index.md |
| 12 | +Concept cards (`<site>/YYYY-MM-*.md`) |
| 13 | + - non-empty `type`; a `resource:` that is an http(s) URL |
| 14 | + - `title`, `description`, non-empty `tags`, `published` |
| 15 | + - the `> ` lead line matches `description` |
| 16 | +Synthesis cards (`<site>/_synthesis-<slug>.md`) |
| 17 | + - `type: synthesis` |
| 18 | +Links |
| 19 | + - every internal .md link resolves (placeholders containing `<` are ignored) |
| 20 | +Card <-> index 1:1 correspondence |
| 21 | + - every concept card in a site is linked exactly once from that site's index.md |
| 22 | + - every card link in a site index.md resolves to an existing card |
| 23 | + - the bundle-root index.md links every site index.md |
| 24 | +Card <-> synthesis correspondence |
| 25 | + - each card's cluster (tags[0]) has a matching `_synthesis-<cluster>.md` |
| 26 | + - each card is linked from that synthesis card's body |
| 27 | +""" |
| 28 | +import re, sys |
| 29 | +from pathlib import Path |
| 30 | + |
| 31 | +ROOT = Path(__file__).resolve().parent.parent |
| 32 | +SITES = [d.name for d in sorted(ROOT.iterdir()) |
| 33 | + if d.is_dir() and (d / "index.md").exists() and d.name not in (".git", ".github", "scripts", ".claude")] |
| 34 | + |
| 35 | +errors = [] |
| 36 | +def err(msg): errors.append(msg) |
| 37 | + |
| 38 | +CARD_RE = re.compile(r"^\d{4}(-\d{2})?-.+\.md$") # YYYY- or YYYY-MM- prefixed card |
| 39 | +LINK_RE = re.compile(r"\]\(([^)]+)\)") |
| 40 | + |
| 41 | +def fm_and_body(text): |
| 42 | + m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.S) |
| 43 | + return (m.group(1), m.group(2)) if m else (None, text) |
| 44 | + |
| 45 | +def fm_get(fm, key): |
| 46 | + m = re.search(rf"^{key}:\s*(.*)$", fm, re.M) |
| 47 | + return m.group(1).strip() if m else None |
| 48 | + |
| 49 | +def yaml_scalar(v): |
| 50 | + """Decode a YAML double-quoted scalar to its plain string value.""" |
| 51 | + v = v.strip() |
| 52 | + if len(v) >= 2 and v[0] == '"' and v[-1] == '"': |
| 53 | + return v[1:-1].replace('\\"', '"').replace('\\\\', '\\') |
| 54 | + return v |
| 55 | + |
| 56 | +def is_card(p): return p.name != "index.md" and not p.name.startswith("_synthesis-") and CARD_RE.match(p.name) |
| 57 | +def is_synth(p): return p.name.startswith("_synthesis-") |
| 58 | + |
| 59 | +def card_links_in(index_path): |
| 60 | + """card filenames linked from an index/synthesis file (relative links to YYYY-*.md).""" |
| 61 | + out = [] |
| 62 | + for tgt in LINK_RE.findall(index_path.read_text()): |
| 63 | + t = tgt.split("#")[0].lstrip("/") |
| 64 | + name = t.split("/")[-1] |
| 65 | + if CARD_RE.match(name): |
| 66 | + out.append(name) |
| 67 | + return out |
| 68 | + |
| 69 | +# ---- structure: okf_version ---- |
| 70 | +root_index = ROOT / "index.md" |
| 71 | +if not root_index.exists(): |
| 72 | + err("missing bundle-root index.md") |
| 73 | +okf_decls = [] |
| 74 | +for p in ROOT.rglob("*.md"): |
| 75 | + if any(part in (".git",) for part in p.parts): continue |
| 76 | + fm, _ = fm_and_body(p.read_text()) |
| 77 | + if fm and re.search(r"^okf_version:", fm, re.M): |
| 78 | + okf_decls.append(p) |
| 79 | +if okf_decls != [root_index]: |
| 80 | + err(f"okf_version must be declared only in root index.md; found in {[str(p.relative_to(ROOT)) for p in okf_decls]}") |
| 81 | + |
| 82 | +# ---- per-site validation ---- |
| 83 | +all_cards = [] |
| 84 | +for site in SITES: |
| 85 | + sdir = ROOT / site |
| 86 | + site_index = sdir / "index.md" |
| 87 | + cards = sorted(p for p in sdir.glob("*.md") if is_card(p)) |
| 88 | + all_cards += cards |
| 89 | + |
| 90 | + # concept card frontmatter |
| 91 | + for c in cards: |
| 92 | + fm, body = fm_and_body(c.read_text()) |
| 93 | + rel = c.relative_to(ROOT) |
| 94 | + if fm is None: err(f"{rel}: no YAML frontmatter"); continue |
| 95 | + t = fm_get(fm, "type") |
| 96 | + if not t: err(f"{rel}: missing/empty `type`") |
| 97 | + res = fm_get(fm, "resource") |
| 98 | + if not res: err(f"{rel}: missing `resource`") |
| 99 | + elif not res.startswith("http"): err(f"{rel}: `resource` is not a URL: {res}") |
| 100 | + for k in ("title", "description", "published"): |
| 101 | + if not fm_get(fm, k): err(f"{rel}: missing `{k}`") |
| 102 | + tags = fm_get(fm, "tags") |
| 103 | + if not tags or tags.strip() in ("[]", ""): err(f"{rel}: missing/empty `tags`") |
| 104 | + desc = fm_get(fm, "description") |
| 105 | + lead = re.search(r"^\s*>\s*(.+?)\s*$", body, re.M) |
| 106 | + if desc and lead: |
| 107 | + if yaml_scalar(desc) != lead.group(1).strip(): |
| 108 | + err(f"{rel}: `> ` lead line does not match `description`") |
| 109 | + elif desc and not lead: |
| 110 | + err(f"{rel}: missing `> ` lead line") |
| 111 | + |
| 112 | + # synthesis cards |
| 113 | + for s in sdir.glob("_synthesis-*.md"): |
| 114 | + fm, _ = fm_and_body(s.read_text()) |
| 115 | + if fm_get(fm, "type") != "synthesis": |
| 116 | + err(f"{s.relative_to(ROOT)}: synthesis card must have `type: synthesis`") |
| 117 | + |
| 118 | + # 1:1 correspondence card <-> site index |
| 119 | + linked = card_links_in(site_index) |
| 120 | + dupes = {n for n in linked if linked.count(n) > 1} |
| 121 | + for n in sorted(dupes): err(f"{site}/index.md: card linked more than once: {n}") |
| 122 | + linkset, cardset = set(linked), {c.name for c in cards} |
| 123 | + for missing in sorted(cardset - linkset): err(f"{site}/index.md: card not listed in index: {missing}") |
| 124 | + for dangling in sorted(linkset - cardset): err(f"{site}/index.md: index links non-existent card: {dangling}") |
| 125 | + |
| 126 | + # card <-> synthesis correspondence |
| 127 | + for c in cards: |
| 128 | + fm, _ = fm_and_body(c.read_text()) |
| 129 | + tags = fm_get(fm, "tags") or "" |
| 130 | + m = re.search(r'"([^"]+)"', tags) |
| 131 | + cluster = m.group(1) if m else None |
| 132 | + if not cluster: continue |
| 133 | + synth = sdir / f"_synthesis-{cluster}.md" |
| 134 | + if not synth.exists(): |
| 135 | + err(f"{c.relative_to(ROOT)}: no synthesis card for cluster '{cluster}' ({synth.name})") |
| 136 | + elif c.name not in card_links_in(synth): |
| 137 | + err(f"{synth.relative_to(ROOT)}: does not link its member card {c.name}") |
| 138 | + |
| 139 | +# ---- root index links every site ---- |
| 140 | +root_links = {t.split('#')[0].lstrip('/') for t in LINK_RE.findall(root_index.read_text())} |
| 141 | +for site in SITES: |
| 142 | + if f"{site}/index.md" not in root_links: |
| 143 | + err(f"index.md (root): does not link site index {site}/index.md") |
| 144 | + |
| 145 | +# ---- all internal links resolve ---- |
| 146 | +for f in ROOT.rglob("*.md"): |
| 147 | + if ".git" in f.parts: continue |
| 148 | + for tgt in LINK_RE.findall(f.read_text()): |
| 149 | + if tgt.startswith(("http", "#", "mailto:")) or "<" in tgt: continue |
| 150 | + path = tgt.split("#")[0] |
| 151 | + if not path.endswith(".md"): continue |
| 152 | + dest = (ROOT / path.lstrip("/")) if path.startswith("/") else (f.parent / path) |
| 153 | + if not dest.exists(): |
| 154 | + err(f"{f.relative_to(ROOT)}: broken link -> {tgt}") |
| 155 | + |
| 156 | +print(f"OKF bundle: {len(SITES)} sites, {len(all_cards)} concept cards.") |
| 157 | +if errors: |
| 158 | + print(f"FAILED with {len(errors)} error(s):") |
| 159 | + for e in errors: print(" -", e) |
| 160 | + sys.exit(1) |
| 161 | +print("OK — all invariants satisfied.") |
0 commit comments