#!/usr/bin/env python3 """Validate newOS4 course content. Exit 0 = publishable, 1 = errors printed.""" import json, os, glob, sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) COURSE = os.path.join(ROOT, 'course') ATLAS = os.path.join(COURSE, 'atlas.json') errors, warnings = [], [] def load(p): try: with open(p, encoding='utf-8') as f: return json.load(f) except Exception as e: errors.append(f'{os.path.relpath(p, ROOT)}: invalid JSON: {e}') return None def main(): manifest = load(os.path.join(COURSE, 'course.json')) if not manifest: return finish() ids, lesson_ids = set(), set() for ch in manifest.get('chapters', []): cid = ch.get('id') if not cid: errors.append('course.json: chapter missing id'); continue if cid in ids: errors.append(f'course.json: duplicate chapter id {cid}') ids.add(cid) path = os.path.join(COURSE, 'chapters', cid + '.json') if not os.path.exists(path): errors.append(f'missing chapter file {cid}.json'); continue chd = load(path) if not chd: continue man_ids = [x.get('id') for x in ch.get('lessons', [])] file_ids = [x.get('id') for x in chd.get('lessons', [])] if man_ids != file_ids: errors.append(f'{cid}: manifest lessons {man_ids} != chapter file lessons {file_ids}') for l in chd.get('lessons', []): lid = l.get('id') if not lid: errors.append(f'{cid}: lesson missing id'); continue if lid in lesson_ids: errors.append(f'duplicate lesson id {lid}') lesson_ids.add(lid) for k in ('title', 'objective'): if not l.get(k): errors.append(f'{lid}: missing {k}') mins = l.get('minutes', 0) if not isinstance(mins, int) or isinstance(mins, bool) or mins < 1: errors.append(f'{lid}: minutes must be >=1') blocks = l.get('blocks') or [] if len(blocks) < 3: errors.append(f'{lid}: needs >=3 blocks') for b in blocks: if b.get('type') not in ('text', 'code', 'cmd', 'tip', 'warn', 'table'): errors.append(f'{lid}: bad block type {b.get("type")!r}') ex = l.get('exercise') if ex: if not ex.get('instruction') or not ex.get('accept'): errors.append(f'{lid}: exercise needs instruction+accept') chk = ex.get('check', {}) if chk.get('type') not in ('command', 'file', 'output', 'manual'): errors.append(f'{lid}: exercise check.type invalid') if chk.get('type') == 'file' and not chk.get('path'): errors.append(f'{lid}: file check requires path') if chk.get('type') == 'output' and not chk.get('contains'): errors.append(f'{lid}: output check requires contains') if not ex.get('hint'): warnings.append(f'{lid}: exercise without hint') if l.get('generated') and not l.get('sources'): errors.append(f'{lid}: generated lesson needs sources') if os.path.exists(ATLAS): atlas = load(ATLAS) or {} known = set(atlas.get('commands', {})) for f in glob.glob(os.path.join(COURSE, 'chapters', '*.json')): chd = load(f) or {} for l in chd.get('lessons', []): for c in l.get('commands', []): if c not in known: errors.append(f'{l.get("id")}: unknown command {c}') for name, key in (('feed.json', 'items'), ('faq.json', 'items')): p = os.path.join(COURSE, name) if not os.path.exists(p): continue d = load(p) or {} for it in d.get(key, []): if isinstance(it, dict) and it.get('date') is None: warnings.append(f'{name}: item without date') return finish() def finish(): for w in warnings: print(f'WARN {w}') for e in errors: print(f'ERROR {e}') print(f'course-validate: {len(errors)} error(s), {len(warnings)} warning(s)') return 1 if errors else 0 if __name__ == '__main__': sys.exit(main())