#!/usr/bin/env python3 """OKF v0.1 conformance linter for the docs vault (serverAppConfigs-docs). The vault is an Open Knowledge Format bundle (omp-docs/references/okf-spec.md) plus the maintenance discipline in okf-maintenance.md, plus this repo's own index law (AGENTS.md -> *Documentation Policy*): an index is a locator, one row, one clause, no numbers. Enforced here so a violation cannot be merged: FRONTMATTER every non-reserved page has a parseable frontmatter block with a non-empty, non-generic `type` (the spec's only hard rule) DESCRIPTION every concept has a description that is not the title, is not a truncation artifact (ends in an ellipsis), carries no digits and fits the one-clause budget (<= 120 chars) TIMESTAMP every concept has an ISO `timestamp` INDEX-FM an index.md carries no frontmatter (it is a listing, not a concept) 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 carries no digits LOG-DATE log.md date headings are `## YYYY-MM-DD`, newest first LENGTH no page exceeds 500 lines (under-split); body under 15 lines is flagged as an over-split stub Exit status is 1 when anything fails, so it gates a test. """ import datetime import pathlib import re import sys RESERVED = {"index.md", "log.md"} GENERIC_TYPES = {"file", "document", "page", "note"} DESC_MAX = 120 BODY_MIN = 15 LINE_MAX = 500 def split_frontmatter(text): """Return (frontmatter_raw_or_None, body).""" if not text.startswith("---"): return None, text end = text.find("\n---", 3) if end == -1: return None, text return text[3:end].strip("\n"), text[end + 4:] def field(raw, key): if raw is None: return None m = re.search(rf"^{key}:\s*(.*)$", raw, re.M) return m.group(1).strip() if m else None def is_iso_date(value): v = value.strip().strip("\"'").split("+")[0].split("T")[0] try: datetime.date.fromisoformat(v) return True except ValueError: return False def vault_pages(vault): return [p for p in sorted(vault.rglob("*.md")) if ".AGENTTRASH" not in str(p) and ".obsidian" not in str(p)] def concept_pages(vault): return [p for p in vault_pages(vault) if p.name not in RESERVED] def check_concepts(vault, fail): for page in concept_pages(vault): rel = str(page.relative_to(vault)) raw, body = split_frontmatter(page.read_text(errors="replace")) if raw is None: fail("FRONTMATTER", rel, "no frontmatter block") continue ty = field(raw, "type") if not ty: fail("FRONTMATTER", rel, "missing type") elif ty.lower() in GENERIC_TYPES: fail("FRONTMATTER", rel, f"generic type {ty!r}") desc = (field(raw, "description") or "").strip().strip("\"'") title = (field(raw, "title") or "").strip().strip("\"'") if not desc or desc in ("-", "—"): fail("DESCRIPTION", rel, "missing description") elif desc == title: fail("DESCRIPTION", rel, "description copies the title") elif desc.endswith("…"): fail("DESCRIPTION", rel, "description is a truncation artifact") elif len(desc) > DESC_MAX: fail("DESCRIPTION", rel, f"description is {len(desc)} chars (budget {DESC_MAX})") elif re.search(r"\d", desc): fail("DESCRIPTION", rel, "description carries numbers (index rows must not)") ts = field(raw, "timestamp") if not ts: fail("TIMESTAMP", rel, "missing timestamp") elif not is_iso_date(ts): fail("TIMESTAMP", rel, f"unparseable timestamp {ts!r}") lines = page.read_text(errors="replace").splitlines() if len(lines) > LINE_MAX: fail("LENGTH", rel, f"{len(lines)} lines (max {LINE_MAX})") elif len([l for l in body.splitlines() if l.strip()]) < BODY_MIN: fail("LENGTH", rel, "stub: fewer than %d body lines" % BODY_MIN) def check_indexes(vault, fail): root_index = vault / "index.md" all_stems = {p.stem for p in concept_pages(vault)} for index in sorted(vault.rglob("index.md")): if ".AGENTTRASH" in str(index): continue rel = str(index.relative_to(vault)) text = index.read_text(errors="replace") if text.startswith("---"): fail("INDEX-FM", rel, "index carries frontmatter") siblings = {p.stem for p in index.parent.glob("*.md") if p.name not in RESERVED} for stem in sorted(siblings): if f"[[{stem}]]" not in text: fail("INDEX-COVER", rel, f"does not link sibling {stem}") if index == root_index: for stem in sorted(all_stems - {p.stem for p in index.parent.glob("*.md")}): if f"[[{stem}]]" not in text: fail("INDEX-COVER", rel, f"does not link {stem}") for line in text.splitlines(): if line.startswith("|") and not re.match(r"^\|[\s-|]+$", line) and "Page" not in line: if re.search(r"\d", line): fail("INDEX-NUM", rel, f"row carries numbers: {line[:70]}") def check_log(vault, fail): for log in sorted(vault.rglob("log.md")): if ".AGENTTRASH" in str(log): continue rel = str(log.relative_to(vault)) dates = [] for line in log.read_text(errors="replace").splitlines(): m = re.match(r"^##\s+(.+)$", line) if not m: continue head = m.group(1).strip() dm = re.match(r"^(\d{4}-\d{2}-\d{2})$", head) if not dm: fail("LOG-DATE", rel, f"heading {head!r} is not a bare YYYY-MM-DD") continue if not is_iso_date(dm.group(1)): fail("LOG-DATE", rel, f"heading {head!r} is not a real date") dates.append(dm.group(1)) if dates != sorted(dates, reverse=True): bad = next(i for i in range(1, len(dates)) if dates[i] > dates[i - 1]) fail("LOG-DATE", rel, f"{dates[bad]} after {dates[bad - 1]}: not newest first") def main(argv): vault = pathlib.Path(argv[1] if len(argv) > 1 else "serverAppConfigs-docs") if not vault.is_dir(): print(f"not a directory: {vault}") return 2 failures = [] def fail(rule, page, msg): failures.append(f"{rule:12} {page}: {msg}") check_concepts(vault, fail) check_indexes(vault, fail) check_log(vault, fail) print(f"{len(concept_pages(vault))} concept pages, {len(failures)} OKF violations") for line in failures: print(" " + line) return 1 if failures else 0 if __name__ == "__main__": sys.exit(main(sys.argv))