Walidator konformancji OKF v0.1 (§9)
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>
This commit is contained in:
parent
7ef5c34e52
commit
d7abf0a68e
151
check_okf.py
Normal file
151
check_okf.py
Normal file
|
|
@ -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())
|
||||
Loading…
Reference in a new issue