#!/usr/bin/env python3 """OKF v0.1 conformance linter for a docs bundle (default serverAppConfigs-docs). Checks the hard rules of the Open Knowledge Format spec plus the maintenance guidance, so "conforms to OKF" is a test and not a claim: FRONTMATTER every non-reserved .md has parseable YAML frontmatter with a non-empty `type` from the spec's value set TYPE the value is one of reference / howto / tutorial / explanation DESC `description` is present, non-blank, not the title, <= 120 chars, and does not restate a fleet quantity TIMESTAMP `timestamp` present, ISO 8601, not in the future LENGTH lines between 1 and 500 inclusive INDEX-FM no index.md may carry frontmatter (index.md is reserved) LOG-DATE log.md date headings are `## YYYY-MM-DD` or `## YYYY-MM-DD — Title`, one entry per date, newest first INDEX-COVER every index.md links each of its sibling pages; the root index links every page in the bundle INDEX-NUM an index table row restates a fleet quantity — a digit run of three or more, a digits-plus-unit figure, a port, or a multiplier. Names (Qwen3.6, RDNA4, RTX 3090, PICO-8, P4) are not quantities and are allowed CITATIONS informational: pages with factual claims and no Citations section are counted, never failed LENGTH lines between 1 and 500 inclusive. A page may opt out of the ceiling with `okf-exempt: length` in its frontmatter: AGENTS.md makes one page per endpoint permanent, and an endpoint page with a full Eras table cannot split without creating a second owner of the same shape (tests/docs_completeness.bats enforces that) Exit 0 when there are no violations. """ import datetime import re import sys from pathlib import Path TYPES = {"reference", "howto", "tutorial", "explanation"} RESERVED = {"index.md", "log.md", "AGENTS.md"} # A quantity, as opposed to a name: a comma-grouped figure, a run of 3+ digits, # a unit-bearing figure, a port (":7770"), or a multiplier ("2x", "3 *"). QUANTITY = re.compile( r"\d{1,3}(?:,\d{3})+" # 262,144 r"|\d{3,}" # 200000, 7770, 3090 r"|\d+(?:\.\d+)?\s*(?:GiB|MiB|GB|MB|TB|tok/s|t/s|x\b|%)" r"|[:×]\s*\d" r"|\d+\s*[x×]\s*\d" ) # Product / model / standard names that legitimately contain digits. NAMES = re.compile( r"Qwen[\d.]*[-\w]*|Gemma\s?\d[\w.]*|MiMo[-\w.\d.]*|Ornith[-\w.\d]*" r"|Bonsai[-\w.\d]*|Ling[-\w.\d.]*|DeepSeek[-\w.\d]*|GLM[-\w.\d]*" r"|PICO-\d+|RDNA\s?\d+|R\d{4}|RTX\s?\d+|XDNA\d|gfx\d+|CUDA\s?[\d.]+" r"|\d+\s?GbE|Wi-?Fi\s?\d|v?\d+\.\d+(?:\.\d+)?|b\d+|P\d\b|A\d+\b" r"|frank-\w*2026-\d\d-\d\d|2026-\d\d-\d\d", re.I, ) DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}") FM_KEY = re.compile(r"^([A-Za-z][\w-]*):\s*(.*)$") def split_frontmatter(text): if not text.startswith("---"): return None, text end = text.find("\n---", 3) if end < 0: return None, text body_start = text.find("\n", end + 1) return text[3:end], text[body_start + 1 :] if body_start >= 0 else "" def fields(raw): out = {} if not raw: return out key = None for line in raw.splitlines(): if not line.strip(): continue m = FM_KEY.match(line) if m: key, val = m.group(1), m.group(2).strip().strip("\"'") out[key] = val elif line.startswith(" ") and key: out[key] = (out[key] + " " + line.strip()).strip() return out def strip_names(value): return NAMES.sub(" ", value) def check_file(path: Path, root: Path, length_exempt=frozenset()): v = [] rel = str(path.relative_to(root)) text = path.read_text(errors="replace") lines = text.splitlines() name = path.name raw, body = split_frontmatter(text) fm = fields(raw) if name not in RESERVED: if raw is None: v.append(("FRONTMATTER", rel, "no parseable frontmatter")) else: t = fm.get("type", "") if not t: v.append(("TYPE", rel, "missing type")) elif t not in TYPES: v.append(("TYPE", rel, f"type '{t}' not in {sorted(TYPES)}")) d = fm.get("description", "") if not d: v.append(("DESC", rel, "missing description")) else: if len(d) > 120: v.append(("DESC", rel, f"description is {len(d)} chars (max 120)")) if d.strip("# ").lower() == fm.get("title", "\0").strip("# ").lower(): v.append(("DESC", rel, "description equals title")) if QUANTITY.search(strip_names(d)): v.append(("DESC", rel, "description restates a quantity")) ts = fm.get("timestamp", "") if not ts: v.append(("TIMESTAMP", rel, "missing timestamp")) else: try: stamp = datetime.date.fromisoformat(ts[:10]) if stamp > datetime.date.today(): v.append(("TIMESTAMP", rel, f"timestamp {ts} is in the future")) except ValueError: v.append(("TIMESTAMP", rel, f"unparseable timestamp {ts!r}")) if name == "index.md" and raw is not None: v.append(("INDEX-FM", rel, "index.md must not carry frontmatter")) if name == "log.md": dates = [] for line in lines: m = re.match(r"^##\s+(\S.*?)\s*$", line) if not m: continue head = m.group(1) if not DATE_RE.match(head): continue dates.append(head[:10]) # OKF requires the heading to USE the date format; a title after an # em-dash is allowed and costs zero lines, so `## 2026-08-17 — Why the # pair was rebuilt` is conformant and `## 2026-08-17Rebuilt` is not. if head != head[:10] and not re.match(r"^\d{4}-\d{2}-\d{2}\s+—\s+\S", head): v.append(("LOG-DATE", rel, f"date heading must be the date alone or `date — Title`: '## {head}'")) if len(dates) != len(set(dates)): v.append(("LOG-DATE", rel, "duplicate date heading")) if dates != sorted(dates, reverse=True): v.append(("LOG-DATE", rel, "date headings not newest first")) if raw is not None and name not in RESERVED: n = len(lines) body_lines = len([ln for ln in body.splitlines() if ln.strip()]) opted_out = "length" in fm.get("okf-exempt", "").lower() if n > 500: if rel in length_exempt: v.append(("LENGTH-OK", rel, f"{n} lines — over the hint, pinned by the " "one-page-per-endpoint law")) elif opted_out: v.append(("LENGTH-OK", rel, f"{n} lines — over the hint, opted out by the page")) else: v.append(("LENGTH", rel, f"{n} lines (max 500)")) elif body_lines < 15: v.append(("LENGTH", rel, "stub: fewer than 15 body lines")) # A second `type:` key inside the body means the page carries two copies of # itself with a frontmatter block in the middle — the shape a naive # frontmatter rewrite produces. It doubles the length, splits the wikilinks # and makes the page's facts ambiguous, so it is a hard failure. if raw is not None and name not in RESERVED: dupes = [i + len((raw or "").splitlines()) + 2 for i, ln in enumerate(body.splitlines()) if re.match(r"^(type|title|description):", ln)] if dupes: v.append(("DOUBLED", rel, f"frontmatter keys inside the body at line(s) " f"{', '.join(str(d) for d in dupes[:5])} — the page " "contains a duplicated copy of itself")) return v def index_rows(text): rows = [] for line in text.splitlines(): if line.startswith("|") and "[[" in line: rows.append(line) return rows def check_links(root: Path): v = [] pages = [ p for p in sorted(root.rglob("*.md")) if ".AGENTTRASH" not in str(p) and ".obsidian" not in str(p) ] for index in [p for p in pages if p.name == "index.md"]: text = index.read_text(errors="replace") links = set(re.findall(r"\[\[([^\]|#]+)", text)) if index.parent == root: targets = [p for p in pages if p.name not in RESERVED] else: targets = [p for p in index.parent.glob("*.md") if p.name != "index.md"] for target in targets: if target.stem not in links and target.name not in links: v.append(("INDEX-COVER", str(index.relative_to(root)), f"does not link sibling {target.name}")) for index in [p for p in pages if p.name == "index.md"]: for row in index_rows(index.read_text(errors="replace")): if QUANTITY.search(strip_names(row)): v.append(("INDEX-NUM", str(index.relative_to(root)), "row restates a quantity: " + row[:80])) return v def live_endpoints(root: Path): """Pages pinned by AGENTS.md's one-page-per-endpoint law. Derived, never hardcoded — the same derivation as tests/docs_completeness.bats: an endpoint is live when scripts/ holds both its env and its unit. Those pages carry the Eras table for their whole history, so the OKF 500-line split hint cannot apply to them without creating a second owner of the same shape. """ scripts = root.parent / "scripts" out = set() if scripts.is_dir(): for envf in scripts.glob("frank-*.env"): if (scripts / f"{envf.stem}.service").exists(): out.add(f"LLM/{envf.stem}.md") return out def retired_era_pages(root: Path): """Era pages of a retired endpoint: campaign ledgers, one owner each. AGENTS.md lets a campaign ledger that exists nowhere else live on the page that measured it, and a retired endpoint keeps exactly one page per era. Splitting such a ledger to satisfy the 500-line hint would fragment one campaign across two owners, so the tag opts the page out. """ out = set() for path in root.rglob("*.md"): s = str(path) if ".AGENTTRASH" in s or ".obsidian" in s: continue raw, _ = split_frontmatter(path.read_text(errors="replace")) if raw is not None and "retired-endpoint" in fields(raw).get("tags", ""): out.add(str(path.relative_to(root))) return out def main(argv): root = Path(argv[1] if len(argv) > 1 else "serverAppConfigs-docs") exempt = frozenset(live_endpoints(root)) | retired_era_pages(root) violations = [] notes = [] citation_gaps = 0 pages = 0 for path in sorted(root.rglob("*.md")): s = str(path) if ".AGENTTRASH" in s or ".obsidian" in s: continue pages += 1 for code, rel, detail in check_file(path, root, exempt): (notes if code == "LENGTH-OK" else violations).append((code, rel, detail)) text = path.read_text(errors="replace") if len(text.splitlines()) > 40 and not re.search( r"^##\s+(Citations|Sources)\b", text, re.M): citation_gaps += 1 violations += check_links(root) for code, rel, detail in violations: print(f" {code:12s} {rel}: {detail}") for code, rel, detail in notes: print(f" {code:12s} {rel}: {detail}") print(f"{pages} concept pages, {len(violations)} OKF violations" f" ({citation_gaps} pages without a Citations section — soft;" f" {len(notes)} length exemptions honoured)") return 1 if violations else 0 if __name__ == "__main__": sys.exit(main(sys.argv))