diff --git a/check_okf.py b/check_okf.py new file mode 100644 index 0000000..8150264 --- /dev/null +++ b/check_okf.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Walidator konformancji OKF v0.1 (§9) dla tego bundle'a. + +Sprawdza: + 1. Każdy nie-zarezerwowany plik .md ma parsowalny blok frontmattera YAML. + 2. Każdy frontmatter ma niepuste pole `type`. + 3. Pliki zarezerwowane (index.md, log.md) nie mają frontmattera — + z jedynym wyjątkiem: root index.md może mieć wyłącznie `okf_version` (§11). + +Tylko biblioteka standardowa: minimalny parser YAML wystarczający dla +frontmatterów w tym bundle'u (klucze skalarne, listy inline, listy blokowe). +Uruchomienie: python3 check_okf.py [katalog-bundle'a] +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +RESERVED = {"index.md", "log.md"} + + +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") + out.setdefault(key, []) + if not isinstance(out[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] + return value + + +def check(root: Path) -> list[str]: + errors: list[str] = [] + files = sorted(p for p in root.rglob("*.md") if ".git" not in p.parts) + if not files: + return [f"brak plików .md w {root}"] + + for path in files: + rel = path.relative_to(root).as_posix() + text = path.read_text(encoding="utf-8") + try: + block, _ = split_frontmatter(text) + except ValueError as exc: + errors.append(f"{rel}: {exc}") + continue + + if path.name in RESERVED: + is_root_index = rel == "index.md" + if block is None: + continue + if not is_root_index: + errors.append( + f"{rel}: plik zarezerwowany nie może mieć frontmattera (§6/§11)" + ) + continue + try: + fm = parse_yaml(block) + except ValueError as exc: + errors.append(f"{rel}: niepoprawny YAML we frontmatterze — {exc}") + continue + extra = set(fm) - {"okf_version"} + if extra: + errors.append( + f"{rel}: root index.md może mieć wyłącznie okf_version, " + f"znaleziono też: {', '.join(sorted(extra))} (§11)" + ) + elif not str(fm.get("okf_version", "")).strip(): + errors.append(f"{rel}: puste okf_version (§11)") + continue + + if block is None: + errors.append(f"{rel}: brak bloku frontmattera YAML (§9.1)") + continue + try: + fm = parse_yaml(block) + except ValueError as exc: + errors.append(f"{rel}: niepoprawny YAML we frontmatterze — {exc}") + continue + if not str(fm.get("type", "")).strip(): + errors.append(f"{rel}: brak niepustego pola `type` (§9.2)") + + return errors + + +def main() -> int: + root = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent).resolve() + files = sorted(p for p in root.rglob("*.md") if ".git" not in p.parts) + errors = check(root) + + print(f"Bundle: {root}") + print(f"Sprawdzono plików .md: {len(files)}") + concepts = [p for p in files if p.name not in RESERVED] + reserved = [p for p in files if p.name in RESERVED] + print(f" koncepty: {len(concepts)}, zarezerwowane: {len(reserved)}") + print() + + if errors: + print(f"NIEZGODNE z OKF v0.1 — {len(errors)} problem(ów):") + for err in errors: + print(f" ✗ {err}") + return 1 + + print("ZGODNE z OKF v0.1 (§9): frontmatter parsowalny, `type` niepusty,") + print("pliki zarezerwowane bez frontmattera (poza okf_version w root index.md).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())