Skip to content

Commit e8a5bfa

Browse files
tbfangelclaude
andcommitted
Add CI validator for OKF bundle invariants
scripts/validate_okf.py enforces card frontmatter, link integrity, and a strict 1:1 correspondence between concept cards and their site-index and synthesis-card listings. Runs in CI on every push/PR (.github/workflows/validate-okf.yml) and is documented in CLAUDE.md; /sync now runs it before committing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d9256a4 commit e8a5bfa

5 files changed

Lines changed: 207 additions & 1 deletion

File tree

‎.claude/skills/sync/SKILL.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ For each cluster that gained cards:
9696
In the root `index.md`, update each affected site's card count in the Sections list. In `README.md`,
9797
update the post count / date range for each source that gained posts.
9898

99-
## Step 10 — Commit and open PR
99+
## Step 10 — Validate, commit, and open PR
100+
101+
Run `python3 scripts/validate_okf.py` and fix any errors before committing (this is what CI enforces —
102+
new cards must be listed in the site `index.md` and their `_synthesis-<cluster>.md` `## Related`).
100103

101104
Stage only the files you created/modified (never `git add -A`). Commit `Knowledge base sync: YYYY-MM-DD`.
102105
Then `gh pr create --title "Knowledge base sync: YYYY-MM-DD" --body "…"`. The PR body must include:

‎.github/workflows/validate-okf.yml‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Validate OKF bundle
2+
3+
on:
4+
push:
5+
branches: ["**"]
6+
pull_request:
7+
8+
jobs:
9+
validate:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-python@v5
14+
with:
15+
python-version: "3.x"
16+
- name: Validate OKF invariants
17+
run: python3 scripts/validate_okf.py

‎CLAUDE.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,29 @@ unit), and keep scratch state in temp files that you delete when done.
7878
|---|---|
7979
| `web-resource-crawler` | User says "crawl the blogs/articles at &lt;site&gt; and summarize them" |
8080

81+
## Validation (run after any change)
82+
83+
`scripts/validate_okf.py` enforces the bundle invariants; CI runs it on every push/PR
84+
(`.github/workflows/validate-okf.yml`). Run it locally before committing:
85+
86+
```bash
87+
python3 scripts/validate_okf.py
88+
```
89+
90+
It checks, and fails on violation of:
91+
- **Structure** — one bundle-root `index.md`, sole declarer of `okf_version`; every site has an `index.md`.
92+
- **Card frontmatter** — non-empty `type`; `resource` is an http(s) URL; `title`/`description`/`tags`/
93+
`published` present; the `> ` lead line matches `description`.
94+
- **Synthesis cards** — `type: synthesis`.
95+
- **Card ↔ index 1:1 correspondence** — every card is linked exactly once from its site `index.md`;
96+
every card link in an index resolves; the root `index.md` links every site index.
97+
- **Card ↔ synthesis correspondence** — each card's cluster (`tags[0]`) has a `_synthesis-<cluster>.md`
98+
that links the card.
99+
- **Links** — every internal `.md` link resolves.
100+
101+
When you add a card (e.g. via `/sync`), you must also add it to the site `index.md` listing and to its
102+
`_synthesis-<cluster>.md` `## Related`, or validation fails.
103+
81104
## Conventions
82105

83106
- All content files are Markdown with YAML frontmatter.

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Automation for maintaining the bundle (all OKF-aware):
2626

2727
See [`CLAUDE.md`](CLAUDE.md) for the card schema.
2828

29+
**Integrity:** `scripts/validate_okf.py` checks the bundle invariants — card frontmatter, resolvable links, and a strict 1:1 correspondence between concept cards and their index/synthesis listings. CI runs it on every push and PR ([`.github/workflows/validate-okf.yml`](.github/workflows/validate-okf.yml)); run it locally with `python3 scripts/validate_okf.py`.
30+
2931
---
3032

3133
## Acknowledgments

‎scripts/validate_okf.py‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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

Comments
 (0)