#!/usr/bin/env python3 """Structural verifier for docs/tutorials (NK-WP-0009-T06). Fails a tutorial that is prose-only: missing required sections, missing or invalid exercise status, no per-step owner tags, retired endpoints, secret markers, or references to repo paths that do not exist. """ from __future__ import annotations import re import sys from pathlib import Path REQUIRED_SECTIONS = [ "Outcome", "Prerequisites", "Architecture context", "Steps", "Verification", "Rollback", "Threat checks", "Ownership notes", ] STATUS_RE = re.compile( r"^Exercise status: (unexercised|exercised \d{4}-\d{2}-\d{2} by \S+)\s*$", re.M) OWNER_TAG_RE = re.compile(r"\*\*\[owner: [^\]]+\]\*\*") SECRET_RE = re.compile( r"(hvs\.[A-Za-z0-9]{8,}|s\.[A-Za-z0-9]{24}|-----BEGIN [A-Z ]*PRIVATE KEY|otpauth://)") PATH_RE = re.compile(r"`((?:docs|tools|canon|workplans)/[\w./-]+)`") RETIRED_MENTION_OK = "retired" def check(path: Path, root: Path) -> list[str]: text = path.read_text() errs: list[str] = [] if not STATUS_RE.search(text): errs.append("missing or invalid 'Exercise status:' header") headings = set(re.findall(r"^## (.+?)\s*$", text, re.M)) for s in REQUIRED_SECTIONS: if s not in headings: errs.append(f"missing section: {s}") steps = re.search(r"^## Steps\s*$(.*?)(?=^## |\Z)", text, re.M | re.S) if steps: items = re.findall(r"^\d+\. .*$", steps.group(1), re.M) if not items: errs.append("Steps has no numbered steps") for i in items: if not OWNER_TAG_RE.search(i): errs.append(f"step lacks owner tag: {i[:50]}") ver = re.search(r"^## Verification\s*$(.*?)(?=^## |\Z)", text, re.M | re.S) if ver and "Done when" not in ver.group(1): errs.append("Verification lacks a 'Done when' outcome") for line in text.splitlines(): if "bao.coulomb.social" in line and RETIRED_MENTION_OK not in line: errs.append("references bao.coulomb.social without marking it retired") if SECRET_RE.search(text): errs.append("contains secret-looking marker") for ref in PATH_RE.findall(text): if "<" in ref or "*" in ref: continue if not (root / ref).exists(): errs.append(f"references missing path: {ref}") return errs def main(argv: list[str]) -> int: root = Path(__file__).resolve().parents[2] tdir = Path(argv[1]) if len(argv) > 1 else root / "docs" / "tutorials" files = sorted(p for p in tdir.glob("*.md") if p.name not in ("README.md", "TEMPLATE.md")) if not files: print("no tutorials found", file=sys.stderr) return 1 failed = 0 for f in files: errs = check(f, root) print(f"{'FAIL' if errs else 'ok '} {f.name}") for e in errs: print(f" - {e}") failed += bool(errs) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main(sys.argv))