chore: import upstream snapshot with attribution
This commit is contained in:
Executable
+276
@@ -0,0 +1,276 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user