#!/usr/bin/env python3 """OKF v0.1 conformance + broken-link validator (Python standard library only). Conformance (SPEC.md section 9): every non-reserved .md has a parseable frontmatter block with a non-empty `type`. Broken cross-links are reported as warnings, never errors (consumers MUST tolerate them). """ from __future__ import annotations import argparse import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from okf_common import (FrontmatterError, concept_id, extract_links, iter_concepts, parse_frontmatter) def _resolve_link(bundle, path, target): """Return a Path for an internal .md link, or None for external/anchor/non-md.""" t = target.split("#", 1)[0].strip() if not t or "://" in t or t.startswith("mailto:") or not t.endswith(".md"): return None if t.startswith("/"): return bundle / t.lstrip("/") return path.parent / t def validate_bundle(bundle_dir): bundle = Path(bundle_dir) errors, warnings = [], [] concept_files = list(iter_concepts(bundle)) existing = {p.resolve() for p in bundle.rglob("*.md")} for path in concept_files: cid = concept_id(bundle, path) text = path.read_text(encoding="utf-8") try: meta, body = parse_frontmatter(text) except FrontmatterError as exc: errors.append({"concept": cid, "rule": "frontmatter", "message": str(exc)}) continue if meta is None: errors.append({"concept": cid, "rule": "frontmatter", "message": "missing YAML frontmatter block"}) continue if not str(meta.get("type", "")).strip(): errors.append({"concept": cid, "rule": "type", "message": "missing or empty required 'type' field"}) for target in extract_links(body): resolved = _resolve_link(bundle, path, target) if resolved is not None and resolved.resolve() not in existing: warnings.append({"concept": cid, "rule": "broken_link", "message": "link target not found: %s" % target}) return { "bundle": str(bundle), "concepts": len(concept_files), "conformant": len(errors) == 0, "errors": errors, "warnings": warnings, } def format_report(report): status = "CONFORMANT" if report["conformant"] else "NON-CONFORMANT" lines = [ "OKF v0.1 validation: %s" % report["bundle"], " concepts: %d status: %s" % (report["concepts"], status), " errors: %d warnings: %d" % (len(report["errors"]), len(report["warnings"])), ] for e in report["errors"]: lines.append(" ERROR [%s] %s: %s" % (e["concept"], e["rule"], e["message"])) for w in report["warnings"]: lines.append(" WARN [%s] %s: %s" % (w["concept"], w["rule"], w["message"])) return "\n".join(lines) def main(argv=None): ap = argparse.ArgumentParser(description="Validate an OKF v0.1 bundle.") ap.add_argument("bundle", help="Path to the bundle directory") ap.add_argument("--json", action="store_true", help="Emit a JSON report") args = ap.parse_args(argv) report = validate_bundle(args.bundle) print(json.dumps(report, indent=2) if args.json else format_report(report)) return 0 if report["conformant"] else 1 if __name__ == "__main__": sys.exit(main())