79 lines
2.9 KiB
Python
79 lines
2.9 KiB
Python
|
|
#!/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))
|