From eca7d5459a57f145ac347554b539afdcc86005e1 Mon Sep 17 00:00:00 2001 From: Asterios Raptis Date: Sat, 1 Aug 2026 10:06:13 +0200 Subject: [PATCH] chore(gates): auf das mitgelieferte Abdeckungs-Gate umstellen (engine#103) Die vendorierte Kopie verglich die ZAHL geminteter Sets gegen die Grundlinie und betrachtete die Gesamtzahl nie. Ein neues, ungemintetes Set hob den Nenner, liess den Zaehler unberuehrt und kam gruen durch. Aufgefallen in alc-psychology: "2 of 3 set(s) fully minted, baseline 2 / OK". learn-content-engine 0.17.0 liefert die Regel als check-stable-id-coverage mit, samt der fehlenden roten Bahn INCOMPLETE, die jedes nicht vollstaendig gemintete Set beim Pfad nennt. Repo-lokal bleibt nur noch die Zahl in schema/stable-id-coverage.txt, weil sie eine Eigenschaft dieses Repos ist. Die Kopie wird im SELBEN Schritt geloescht, nicht spaeter. Stuenden beide nebeneinander, ruft irgendwann jemand die alte auf, und die meldet bestanden, wo die neue fehlschlaegt. Zwei Pruefungen mit verschiedenen Antworten sind schlechter als eine falsche, weil man ihnen ansieht, dass eine stimmt, aber nicht welche. Der Selbsttest der Kopie faellt mit ihr weg: die Bahnen des mitgelieferten Befehls deckt die Engine-Suite ab, und genau das ist der Sinn des Mitliefern. Python und PyYAML entfallen im stable-ids-Workflow, weil dort nichts mehr sie braucht. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/stable-ids.yml | 14 +-- Makefile | 5 +- schema/engine-version.txt | 2 +- scripts/check_stable_id_coverage.py | 170 ---------------------------- 4 files changed, 5 insertions(+), 186 deletions(-) delete mode 100644 scripts/check_stable_id_coverage.py diff --git a/.github/workflows/stable-ids.yml b/.github/workflows/stable-ids.yml index 0efe2eb..08fa317 100644 --- a/.github/workflows/stable-ids.yml +++ b/.github/workflows/stable-ids.yml @@ -15,10 +15,6 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - uses: actions/setup-node@v4 with: node-version: "20" @@ -26,14 +22,8 @@ jobs: - name: Install the pinned engine (the gate ships with it) run: npm install --no-save --no-package-lock --no-audit --no-fund "learn-content-engine@$(cat schema/engine-version.txt)" - - name: Install PyYAML - run: pip install "pyyaml>=6,<7" - - name: Stability gate against the PR base (shipped command) run: npx --no-install learn-content-engine check-stable-ids --base origin/${{ github.base_ref }} - - name: Coverage ratchet self-test (every path must fire) - run: python scripts/check_stable_id_coverage.py --self-test - - - name: Coverage ratchet - run: python scripts/check_stable_id_coverage.py + - name: Coverage gate (shipped command) + run: npx --no-install learn-content-engine check-stable-id-coverage diff --git a/Makefile b/Makefile index 568d41c..c5c751c 100644 --- a/Makefile +++ b/Makefile @@ -92,7 +92,6 @@ audit: $(VENV)/.ready clean: rm -rf $(VENV) -stable-ids: $(ENGINE_STAMP) ## Stabilitaets-Gate (mitgeliefert) + Abdeckungs-Ratchet (repo-lokal) +stable-ids: $(ENGINE_STAMP) ## Stabilitaets- und Abdeckungs-Gate (beide mitgeliefert) npx --no-install learn-content-engine check-stable-ids --base origin/main - python3 scripts/check_stable_id_coverage.py --self-test - python3 scripts/check_stable_id_coverage.py + npx --no-install learn-content-engine check-stable-id-coverage diff --git a/schema/engine-version.txt b/schema/engine-version.txt index 2a0970c..c5523bd 100644 --- a/schema/engine-version.txt +++ b/schema/engine-version.txt @@ -1 +1 @@ -0.16.1 +0.17.0 diff --git a/scripts/check_stable_id_coverage.py b/scripts/check_stable_id_coverage.py deleted file mode 100644 index 1ee246d..0000000 --- a/scripts/check_stable_id_coverage.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -"""Coverage ratchet for stable_id (engine#90). - -The STABILITY half (does a published id still point at its element?) is not -here: it ships with the pinned engine as ``learn-content-engine -check-stable-ids`` and is called from ``make stable-ids``. Ten vendored -copies would drift; one shipped command does not. - -What stays repo-local is the COVERAGE number, because it is a property of -this repository: how many of its sets are FULLY minted (every exercise and -card in every lesson carries a stable_id; half a set is half a promise). -The committed baseline (``schema/stable-id-coverage.txt``) may only be -crossed deliberately: computed < baseline is a regression, computed > -baseline demands a conscious raise. Both are red. - -The run prints the checked quantities, and a repo whose manifest lists no -sets fails rather than reporting full coverage over nothing. - - python3 scripts/check_stable_id_coverage.py - python3 scripts/check_stable_id_coverage.py --self-test -""" -from __future__ import annotations - -import json -import sys -import tempfile -from pathlib import Path - -import yaml - -REPO_ROOT = Path(__file__).resolve().parents[1] -BASELINE = REPO_ROOT / "schema" / "stable-id-coverage.txt" - - -def set_fully_minted(set_dir: Path) -> bool: - manifest_path = set_dir / "manifest.yaml" - if not manifest_path.is_file(): - return False - set_manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} - lesson_names = (set_manifest.get("metadata") or {}).get("lessons") or [] - if not lesson_names: - return False - for name in lesson_names: - lesson_path = set_dir / "lessons" / name - if not lesson_path.is_file(): - return False - lesson = json.loads(lesson_path.read_text(encoding="utf-8")) - for card in lesson.get("cards") or []: - if not card.get("stable_id"): - return False - for step in lesson.get("steps") or []: - exercise = step.get("exercise") - if exercise and not exercise.get("stable_id"): - return False - return True - - -def coverage(repo_root: Path) -> tuple[int, int]: - manifest = yaml.safe_load((repo_root / "manifest.yaml").read_text(encoding="utf-8")) or {} - root_sets = manifest.get("sets") or [] - covered = sum( - 1 for s in root_sets if s.get("path") and set_fully_minted(repo_root / s["path"]) - ) - return covered, len(root_sets) - - -def gate(repo_root: Path, baseline_path: Path) -> int: - covered, total = coverage(repo_root) - baseline = int(baseline_path.read_text(encoding="utf-8").strip()) if baseline_path.is_file() else 0 - print(f"stable-id coverage: {covered} of {total} set(s) fully minted, baseline {baseline}") - if total == 0: - print("FAIL: the root manifest lists no sets; a run over nothing is never fully covered") - return 1 - if covered < baseline: - print(f"FAIL: coverage {covered} below baseline {baseline} (regression)") - return 1 - if covered > baseline: - print(f"FAIL: coverage {covered} above baseline {baseline}; raise it deliberately in {baseline_path.name}") - return 1 - print("OK: coverage equals the baseline") - return 0 - - -def self_test() -> int: - """Prove the three red paths fire; a ratchet nobody saw trip is decoration.""" - failures = [] - lesson = { - "id": "l1", - "title": "L1", - "cards": [{"id": "c1", "front": "f", "back": "b", "stable_id": "card-selftest01"}], - "steps": [ - { - "id": "s1", - "type": "exercise", - "exercise": { - "id": "e1", - "type": "free_text", - "prompt": "p", - "accept": ["a"], - "stable_id": "ex-selftest001", - }, - } - ], - } - - def build(tmp: Path, minted: bool, with_sets: bool = True) -> None: - (tmp / "schema").mkdir(parents=True, exist_ok=True) - if not with_sets: - (tmp / "manifest.yaml").write_text("name: Leer\nsets: []\n", encoding="utf-8") - return - lessons = tmp / "sets/de/demo/lessons" - lessons.mkdir(parents=True, exist_ok=True) - payload = json.loads(json.dumps(lesson)) - if not minted: - del payload["cards"][0]["stable_id"] - del payload["steps"][0]["exercise"]["stable_id"] - (lessons / "01-demo.json").write_text(json.dumps(payload), encoding="utf-8") - (tmp / "sets/de/demo/manifest.yaml").write_text( - "name: Demo\nsets:\n - id: demo\nmetadata:\n lessons:\n - 01-demo.json\n", - encoding="utf-8", - ) - (tmp / "manifest.yaml").write_text( - "name: Demo\nsets:\n - id: demo\n path: sets/de/demo\n", encoding="utf-8" - ) - - scenarios = [ - ("regression (minted 0, baseline 1)", False, True, "1", "below baseline"), - ("undeclared raise (minted 1, baseline 0)", True, True, "0", "above baseline"), - ("no sets at all", True, False, "0", "lists no sets"), - ] - for name, minted, with_sets, baseline_value, marker in scenarios: - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - build(tmp_path, minted, with_sets) - baseline_path = tmp_path / "schema" / "stable-id-coverage.txt" - baseline_path.write_text(f"{baseline_value}\n", encoding="utf-8") - import io - import contextlib - - buffer = io.StringIO() - with contextlib.redirect_stdout(buffer): - code = gate(tmp_path, baseline_path) - if code == 0 or marker not in buffer.getvalue(): - failures.append(f"{name}: did not fire (exit={code})\n{buffer.getvalue()}") - else: - print(f"self-test OK: {name}") - - # Positive control: a matching baseline must PASS, or the gate is just - # always-red and proves nothing. - with tempfile.TemporaryDirectory() as tmp: - tmp_path = Path(tmp) - build(tmp_path, True, True) - baseline_path = tmp_path / "schema" / "stable-id-coverage.txt" - baseline_path.write_text("1\n", encoding="utf-8") - if gate(tmp_path, baseline_path) != 0: - failures.append("positive control: a matching baseline was not accepted") - else: - print("self-test OK: positive control (baseline matches)") - - if failures: - print("SELF-TEST FAIL:") - for failure in failures: - print(failure) - return 1 - print("Self-test passed: every ratchet path fires, and a matching baseline passes.") - return 0 - - -if __name__ == "__main__": - sys.exit(self_test() if "--self-test" in sys.argv else gate(REPO_ROOT, BASELINE))