check_okf.py — tylko stdlib, minimalny parser YAML wystarczający dla frontmatterów w tym bundle'u. Sprawdza trzy warunki z §9: - każdy nie-zarezerwowany .md ma parsowalny blok frontmattera YAML, - każdy frontmatter ma niepuste pole `type`, - pliki zarezerwowane bez frontmattera, poza root index.md, w którym dozwolone jest wyłącznie okf_version (§11). Wynik na frontmatterach zweryfikowany krzyżowo z PyYAML; walidator przetestowany na 5 wstrzykniętych naruszeniach (każde wykryte). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
152 lines
5.3 KiB
Python
152 lines
5.3 KiB
Python
#!/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())
|