diff --git a/scripts/kb/check_okf.py b/scripts/kb/check_okf.py new file mode 100755 index 0000000..335cbb8 --- /dev/null +++ b/scripts/kb/check_okf.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Walidator konformancji OKF v0.1 dla bazy wiedzy homelab-codex. + +Zaadaptowany z ~/narty-2027/saalbach-kb/check_okf.py. Wersja tamta sprawdzała +wyłącznie obecność frontmattera i niepuste `type` (OKF §9). Tutaj dochodzą +reguły tego repo: + + 1. Frontmatter YAML obecny i parsowalny. + 2. `okf` przypięte do wersji PINNED_OKF ("0.1"). + 3. `type` z zamkniętej listy TYPES. + 4. `visibility` ustawione i z listy {public, private}. + 5. `status` z listy {active, deprecated, planned}. + 6. `updated` w formacie YYYY-MM-DD. + 7. `as_of` (YYYY-MM-DD) wymagane wtedy i tylko wtedy, gdy `type: audit`. + 8. `superseded_by` niepuste wtedy i tylko wtedy, gdy `status: deprecated`. + 9. Każdy wpis `links` wskazuje na istniejący plik (ścieżka względna wobec + katalogu dokumentu). + 10. Wpisy `contradicts` wyglądające jak ścieżka .md też muszą istnieć; + pozostałe wpisy to wolny tekst. + 11. `stub` — o ile obecne — musi być boolem. + +Zakres domyślny: kb/ oraz docs/sessions/. Reszta repo (CLAUDE.md, README.md, +.claude/skills/, docs/backlog.md itd.) leży poza bazą wiedzy i nie podlega +walidacji. + +Tylko biblioteka standardowa: minimalny parser YAML wystarczający dla +frontmatterów w tym repo (klucze skalarne, listy inline, listy blokowe). +Uruchomienie: python3 scripts/kb/check_okf.py [katalog-repo] +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +PINNED_OKF = "0.1" + +TYPES = { + "node", + "service", + "subsystem", + "decision", + "incident", + "runbook", + "phase", + "audit", + "session-log", +} + +VISIBILITIES = {"public", "private"} +STATUSES = {"active", "deprecated", "planned"} + +SCOPE = ("kb", "docs/sessions") + +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def split_frontmatter(text: str) -> tuple[str | None, str]: + """Zwraca (blok frontmattera lub None, reszta dokumentu).""" + if not text.startswith("---\n"): + return None, text + end = text.find("\n---", 3) + if end == -1: + raise ValueError("blok frontmattera otwarty '---', ale nigdy nie zamknięty") + return text[4:end], text[end + 4 :] + + +def parse_yaml(block: str) -> dict[str, object]: + """Minimalny parser mapy YAML najwyższego poziomu.""" + out: dict[str, object] = {} + key: str | None = None + for lineno, raw in enumerate(block.splitlines(), start=2): + line = raw.rstrip() + if not line.strip() or line.lstrip().startswith("#"): + continue + if line.startswith((" ", "\t")): + item = line.strip() + if not item.startswith("- "): + raise ValueError(f"linia {lineno}: nieoczekiwane wcięcie: {line!r}") + if key is None: + raise ValueError(f"linia {lineno}: element listy bez klucza") + if not isinstance(out.get(key), list): + out[key] = [] + out[key].append(scalar(item[2:])) # type: ignore[union-attr] + continue + if ":" not in line: + raise ValueError(f"linia {lineno}: brak ':' w {line!r}") + key, _, value = line.partition(":") + key = key.strip() + if not key: + raise ValueError(f"linia {lineno}: pusty klucz w {line!r}") + out[key] = scalar(value.strip()) + return out + + +def scalar(value: str) -> object: + value = value.strip() + if not value: + return "" + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + return [scalar(v) for v in inner.split(",")] if inner else [] + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + if value in ("true", "false"): + return value == "true" + return value + + +def as_list(value: object) -> list[str]: + if value in ("", None): + return [] + if isinstance(value, list): + return [str(v) for v in value if str(v).strip()] + return [str(value)] + + +def scope_files(root: Path) -> list[Path]: + files: list[Path] = [] + for rel in SCOPE: + base = root / rel + if base.is_dir(): + files.extend(p for p in base.rglob("*.md") if ".git" not in p.parts) + return sorted(set(files)) + + +def check_file(path: Path, root: Path) -> list[str]: + rel = path.relative_to(root).as_posix() + errors: list[str] = [] + text = path.read_text(encoding="utf-8") + + try: + block, _ = split_frontmatter(text) + except ValueError as exc: + return [f"{rel}: {exc}"] + + if block is None: + return [f"{rel}: brak bloku frontmattera YAML (§9.1)"] + + try: + fm = parse_yaml(block) + except ValueError as exc: + return [f"{rel}: niepoprawny YAML we frontmatterze — {exc}"] + + okf = str(fm.get("okf", "")).strip() + if okf != PINNED_OKF: + errors.append(f"{rel}: `okf` musi być przypięte do {PINNED_OKF!r}, jest {okf!r}") + + doc_type = str(fm.get("type", "")).strip() + if not doc_type: + errors.append(f"{rel}: brak niepustego pola `type` (§9.2)") + elif doc_type not in TYPES: + errors.append( + f"{rel}: `type` = {doc_type!r} spoza listy ({', '.join(sorted(TYPES))})" + ) + + visibility = str(fm.get("visibility", "")).strip() + if not visibility: + errors.append(f"{rel}: brak pola `visibility`") + elif visibility not in VISIBILITIES: + errors.append(f"{rel}: `visibility` = {visibility!r} spoza {sorted(VISIBILITIES)}") + + status = str(fm.get("status", "")).strip() + if not status: + errors.append(f"{rel}: brak pola `status`") + elif status not in STATUSES: + errors.append(f"{rel}: `status` = {status!r} spoza {sorted(STATUSES)}") + + updated = str(fm.get("updated", "")).strip() + if not DATE_RE.match(updated): + errors.append(f"{rel}: `updated` musi być YYYY-MM-DD, jest {updated!r}") + + as_of = str(fm.get("as_of", "")).strip() + if doc_type == "audit": + if not DATE_RE.match(as_of): + errors.append( + f"{rel}: `type: audit` wymaga `as_of` w formacie YYYY-MM-DD, jest {as_of!r}" + ) + elif as_of: + errors.append(f"{rel}: `as_of` dozwolone tylko dla `type: audit`") + + superseded = str(fm.get("superseded_by", "")).strip() + if status == "deprecated" and not superseded: + errors.append(f"{rel}: `status: deprecated` wymaga niepustego `superseded_by`") + if status != "deprecated" and superseded: + errors.append(f"{rel}: `superseded_by` dozwolone tylko dla `status: deprecated`") + + if "stub" in fm and not isinstance(fm["stub"], bool): + errors.append(f"{rel}: `stub` musi być boolem, jest {fm['stub']!r}") + + for link in as_list(fm.get("links")): + target = (path.parent / link).resolve() + if not target.exists(): + errors.append(f"{rel}: `links` wskazuje na nieistniejący plik: {link}") + + for item in as_list(fm.get("contradicts")): + if item.endswith(".md") and "/" in item: + target = (path.parent / item).resolve() + if not target.exists(): + errors.append( + f"{rel}: `contradicts` wskazuje na nieistniejący plik: {item}" + ) + + return errors + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parents[2]) + root = root.resolve() + files = scope_files(root) + + print(f"Repo: {root}") + print(f"Zakres: {', '.join(SCOPE)}") + print(f"Sprawdzono plików .md: {len(files)}") + + by_type: dict[str, int] = {} + errors: list[str] = [] + for path in files: + errors.extend(check_file(path, root)) + try: + block, _ = split_frontmatter(path.read_text(encoding="utf-8")) + if block: + t = str(parse_yaml(block).get("type", "?")) + by_type[t] = by_type.get(t, 0) + 1 + except ValueError: + pass + + if by_type: + print(" per type: " + ", ".join(f"{t}={n}" for t, n in sorted(by_type.items()))) + print() + + if not files: + print("BRAK plików w zakresie — nie ma czego walidować.") + return 1 + + if errors: + print(f"NIEZGODNE z OKF v{PINNED_OKF} — {len(errors)} problem(ów):") + for err in errors: + print(f" ✗ {err}") + return 1 + + print(f"ZGODNE z OKF v{PINNED_OKF}: frontmatter parsowalny, okf przypięte,") + print("type/visibility/status z list, daty poprawne, links rozwiązywalne.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())