277 lines
8.5 KiB
Python
Executable File
277 lines
8.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Invariant checks across every lesson directory.
|
|
|
|
Usage:
|
|
python scripts/audit_lessons.py [--phase N] [--json] [--strict]
|
|
|
|
Exit codes:
|
|
0 — clean
|
|
1 — issues found
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
PHASES_DIR = ROOT / "phases"
|
|
|
|
LESSON_DIR_RE = re.compile(r"^[0-9]{2}-[a-z0-9][a-z0-9-]*[a-z0-9]$")
|
|
PHASE_DIR_RE = re.compile(r"^[0-9]{2}-[a-z0-9][a-z0-9-]*[a-z0-9]$")
|
|
MD_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s#]+)(?:#[^)]*)?\)")
|
|
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
|
|
|
|
CANONICAL_QUIZ_KEYS = {"stage", "question", "options", "correct", "explanation"}
|
|
LEGACY_QUIZ_KEYS = {"q", "choices", "answer"}
|
|
CODE_IGNORED_NAMES = {"README.md", "AGENTS.md", ".gitkeep", ".DS_Store"}
|
|
MIN_DOC_BYTES = 200
|
|
MAX_OPTIONS = 6
|
|
MIN_OPTIONS = 2
|
|
|
|
|
|
@dataclass
|
|
class Issue:
|
|
rule: str
|
|
lesson: str
|
|
file: str
|
|
message: str
|
|
|
|
def to_dict(self) -> dict[str, str]:
|
|
return {
|
|
"rule": self.rule,
|
|
"lesson": self.lesson,
|
|
"file": self.file,
|
|
"message": self.message,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Audit:
|
|
lessons_checked: int = 0
|
|
issues: list[Issue] = field(default_factory=list)
|
|
|
|
def add(self, rule: str, lesson: Path, file: Path | None, message: str) -> None:
|
|
rel_lesson = lesson.relative_to(ROOT).as_posix()
|
|
rel_file = file.relative_to(ROOT).as_posix() if file else rel_lesson
|
|
self.issues.append(Issue(rule, rel_lesson, rel_file, message))
|
|
|
|
|
|
def iter_lesson_dirs(phase_filter: int | None) -> Iterable[Path]:
|
|
if not PHASES_DIR.is_dir():
|
|
return
|
|
for phase in sorted(PHASES_DIR.iterdir()):
|
|
if not phase.is_dir():
|
|
continue
|
|
if not PHASE_DIR_RE.match(phase.name):
|
|
continue
|
|
if phase_filter is not None:
|
|
try:
|
|
phase_num = int(phase.name.split("-", 1)[0])
|
|
except ValueError:
|
|
continue
|
|
if phase_num != phase_filter:
|
|
continue
|
|
for lesson in sorted(phase.iterdir()):
|
|
if lesson.is_dir():
|
|
yield lesson
|
|
|
|
|
|
def check_lesson_dir_pattern(audit: Audit, lesson: Path) -> bool:
|
|
if not LESSON_DIR_RE.match(lesson.name):
|
|
audit.add(
|
|
"L001",
|
|
lesson,
|
|
None,
|
|
f"lesson dir name does not match NN-slug pattern: {lesson.name!r}",
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def check_docs_en_md(audit: Audit, lesson: Path) -> str | None:
|
|
doc = lesson / "docs" / "en.md"
|
|
if not doc.is_file():
|
|
audit.add("L002", lesson, doc, "missing docs/en.md")
|
|
return None
|
|
try:
|
|
text = doc.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
audit.add("L002", lesson, doc, "docs/en.md is not valid UTF-8")
|
|
return None
|
|
if len(text.encode("utf-8")) < MIN_DOC_BYTES:
|
|
audit.add(
|
|
"L003",
|
|
lesson,
|
|
doc,
|
|
f"docs/en.md shorter than {MIN_DOC_BYTES} bytes (got {len(text)})",
|
|
)
|
|
if not H1_RE.search(text):
|
|
audit.add("L004", lesson, doc, "docs/en.md missing top-level H1")
|
|
return text
|
|
|
|
|
|
def check_code_main(audit: Audit, lesson: Path) -> None:
|
|
code_dir = lesson / "code"
|
|
if not code_dir.is_dir():
|
|
return
|
|
for path in code_dir.rglob("*"):
|
|
if path.is_file() and path.name not in CODE_IGNORED_NAMES:
|
|
return
|
|
audit.add("L005", lesson, code_dir, "code/ is empty (no source or config files)")
|
|
|
|
|
|
def check_quiz(audit: Audit, lesson: Path) -> None:
|
|
quiz = lesson / "quiz.json"
|
|
if not quiz.is_file():
|
|
return
|
|
try:
|
|
raw = quiz.read_text(encoding="utf-8")
|
|
data = json.loads(raw)
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
audit.add("L006", lesson, quiz, f"quiz.json not valid JSON: {exc}")
|
|
return
|
|
if isinstance(data, list):
|
|
questions = data
|
|
elif isinstance(data, dict):
|
|
questions = data.get("questions")
|
|
else:
|
|
questions = None
|
|
if not isinstance(questions, list) or not questions:
|
|
audit.add(
|
|
"L006",
|
|
lesson,
|
|
quiz,
|
|
"quiz.json must be a non-empty array or a dict with non-empty questions[]",
|
|
)
|
|
return
|
|
for idx, q in enumerate(questions):
|
|
if not isinstance(q, dict):
|
|
audit.add("L006", lesson, quiz, f"question[{idx}] is not an object")
|
|
continue
|
|
legacy = LEGACY_QUIZ_KEYS & q.keys()
|
|
if legacy:
|
|
audit.add(
|
|
"L007",
|
|
lesson,
|
|
quiz,
|
|
f"question[{idx}] uses legacy schema keys {sorted(legacy)} "
|
|
f"(canonical: {sorted(CANONICAL_QUIZ_KEYS)})",
|
|
)
|
|
continue
|
|
missing = CANONICAL_QUIZ_KEYS - q.keys()
|
|
if missing:
|
|
audit.add(
|
|
"L006",
|
|
lesson,
|
|
quiz,
|
|
f"question[{idx}] missing keys {sorted(missing)}",
|
|
)
|
|
continue
|
|
options = q.get("options")
|
|
if not isinstance(options, list) or not (MIN_OPTIONS <= len(options) <= MAX_OPTIONS):
|
|
audit.add(
|
|
"L008",
|
|
lesson,
|
|
quiz,
|
|
f"question[{idx}] options length must be {MIN_OPTIONS}..{MAX_OPTIONS} "
|
|
f"(got {len(options) if isinstance(options, list) else type(options).__name__})",
|
|
)
|
|
continue
|
|
correct = q.get("correct")
|
|
if not isinstance(correct, int) or not (0 <= correct < len(options)):
|
|
audit.add(
|
|
"L009",
|
|
lesson,
|
|
quiz,
|
|
f"question[{idx}] correct={correct!r} not a valid index in options[0..{len(options) - 1}]",
|
|
)
|
|
|
|
|
|
def check_internal_links(audit: Audit, lesson: Path, text: str) -> None:
|
|
doc = lesson / "docs" / "en.md"
|
|
seen: set[str] = set()
|
|
for match in MD_LINK_RE.finditer(text):
|
|
href = match.group(1).strip()
|
|
if href in seen:
|
|
continue
|
|
seen.add(href)
|
|
if href.startswith(("http://", "https://", "mailto:", "data:")):
|
|
continue
|
|
if href.startswith("/"):
|
|
target = ROOT / href.lstrip("/")
|
|
else:
|
|
target = (doc.parent / href).resolve()
|
|
if not target.exists():
|
|
audit.add("L010", lesson, doc, f"internal link does not resolve: {href!r}")
|
|
|
|
|
|
def audit_lesson(audit: Audit, lesson: Path) -> None:
|
|
audit.lessons_checked += 1
|
|
if not check_lesson_dir_pattern(audit, lesson):
|
|
return
|
|
text = check_docs_en_md(audit, lesson)
|
|
check_code_main(audit, lesson)
|
|
check_quiz(audit, lesson)
|
|
if text is not None:
|
|
check_internal_links(audit, lesson, text)
|
|
|
|
|
|
def render_report(audit: Audit) -> str:
|
|
by_rule: dict[str, int] = {}
|
|
for issue in audit.issues:
|
|
by_rule[issue.rule] = by_rule.get(issue.rule, 0) + 1
|
|
lines = [
|
|
f"audit_lessons.py — {audit.lessons_checked} lesson(s) checked, "
|
|
f"{len(audit.issues)} issue(s)",
|
|
]
|
|
if audit.issues:
|
|
lines.append("")
|
|
for issue in audit.issues:
|
|
lines.append(f" [{issue.rule}] {issue.file}: {issue.message}")
|
|
lines.append("")
|
|
lines.append("Summary by rule:")
|
|
for rule in sorted(by_rule):
|
|
lines.append(f" {rule}: {by_rule[rule]}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--phase", type=int, default=None, help="restrict to a single phase number")
|
|
parser.add_argument("--json", action="store_true", help="emit JSON report on stdout")
|
|
parser.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help="treat warnings as errors (currently equivalent to default; reserved)",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
audit = Audit()
|
|
for lesson in iter_lesson_dirs(args.phase):
|
|
audit_lesson(audit, lesson)
|
|
|
|
if args.json:
|
|
json.dump(
|
|
{
|
|
"lessons_checked": audit.lessons_checked,
|
|
"issues": [issue.to_dict() for issue in audit.issues],
|
|
},
|
|
sys.stdout,
|
|
indent=2,
|
|
)
|
|
sys.stdout.write("\n")
|
|
else:
|
|
sys.stdout.write(render_report(audit) + "\n")
|
|
|
|
return 1 if audit.issues else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|