diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 73c7217..5f320f8 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,36 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +## [0.1.85] - 2026-08-24 + +### Corrigé + +- **Le guide auteur ne décrit plus un piège que la 0.1.84 a supprimé** (issue + #195). `docs/catalog-author.md` annonçait encore qu'une fixture non déclarée + passait en silence — « nothing says so » — douze versions après l'ajout du + contrôle qui la signale. La page dit désormais ce qui se passe réellement, + dans les deux sens et avec le code de sortie. + +### Ajouté + +- **Un test tient le guide auteur en phase avec les validators.** Toute clé + d'anomalie qu'un validator peut produire doit être citée dans + `docs/catalog-author.*`, et aucune clé disparue ne peut y rester. Ajouter un + contrôle sans le documenter fait échouer la suite, et le message nomme la clé + manquante. + + Corriger une phrase périmée ne protège de rien : elle repérimera. Ce qui + manquait est un lien **mécanique** entre ce que le validateur détecte et ce que + la documentation en dit. Le garde-fou ne sait pas lire une phrase, mais il + force à ouvrir la page au moment où le comportement change — et c'est ce + moment-là qui manquait. + + Les pages ont gagné la table que cela exigeait : les 25 clés sur lesquelles un + auteur peut agir, avec le sens de chacune. Trois clés sont exemptées + nommément : elles dépendent du réseau ou d'un incident de fichier, pas du + contrat qu'un auteur écrit. + + ## [0.1.84] - 2026-08-24 Cette version clôt l'audit des angles morts. Son contenu a été développé diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c01feb..d0e52b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.85] - 2026-08-24 + +### Fixed + +- **The author guide no longer describes a trap that 0.1.84 removed** (issue + #195). `docs/catalog-author.md` still announced that an undeclared fixture + passed silently — « nothing says so » — twelve versions after the check that + reports it was added. The page now states what actually happens, in both + directions and with the exit code. + +### Added + +- **A test keeps the author guide in step with the validators.** Every anomaly + key a validator can emit must be cited in `docs/catalog-author.*`, and no key + may be cited that no longer exists. Adding a check without documenting it + fails the suite, and the message names the missing key. + + Correcting a stale sentence protects nothing — it goes stale again. What was + missing is anything mechanical tying *what the validator detects* to *what the + documentation says about it*. The guard cannot read a sentence, but it forces + the page open at the moment the behaviour changes, which is the moment that + was missed. + + The pages gained the table this required: all 25 keys an author can act on, + with what each one means. Three keys are exempt by name — they depend on the + network or on a file incident, not on the contract an author writes. + + ## [0.1.84] - 2026-08-24 This release closes the blind-spot audit. Its entries were developed as diff --git a/docs/catalog-author.fr.md b/docs/catalog-author.fr.md index d7735fa..8099f09 100644 --- a/docs/catalog-author.fr.md +++ b/docs/catalog-author.fr.md @@ -89,6 +89,40 @@ faute, il a fait un autre choix). `--check-urls` ajoute le seul contrôle réseau : chaque `doc_url` doit répondre. +### Chaque clé, et ce qu'elle veut dire + +Le validateur nomme chaque anomalie par une clé stable. Un test tient cette table +en phase avec le code : ajouter un contrôle sans le documenter ici fait échouer +la suite. + +| Clé | Ce que ça veut dire | +| --- | --- | +| `struct_missing_file` | un fichier requis est absent | +| `struct_missing_dir` | `challenge/tests/` est absent | +| `struct_vm_targets_empty` | un lab `vm` ne déclare aucune `runtime.targets` | +| `struct_default_unknown` | `runtime.default` nomme une target que `targets[]` ne définit pas | +| `struct_shell_workdir_empty` | un lab `shell` ne déclare pas de `runtime.workdir` | +| `struct_session_unknown` | `runtime.session` n'est ni `target` ni `local` | +| `metadata_field_empty` | un champ requis est vide | +| `metadata_list_empty` | `skills` ou `distros` est une liste vide | +| `metadata_doc_url_scheme` | `doc_url` n'est pas en http(s) | +| `metadata_lab_type_invalid` | `lab_type` sort de l'énuméré | +| `metadata_exam_score_invalid` | `exam_passing_score` est hors bornes | +| `content_broken_links` | un lien relatif ne pointe sur rien | +| `content_missing_english` | un document n'est traduit que d'un côté | +| `content_scoring_points_mismatch` | les tâches totalisent un autre nombre de points que celui annoncé | +| `content_scoring_count_mismatch` | l'en-tête annonce un autre nombre de tâches notées | +| `content_scoring_tasks_vs_tests` | tâches notées et tests ne se correspondent pas | +| `content_target_host_unknown` | l'hôte d'une target est absent de `infra.hosts[]` | +| `content_role_host_unknown` | une entrée de `roles` nomme un hôte inconnu | +| `content_solution_plaintext` | un fichier de `solution/` est lisible en clair | +| `content_fixture_missing` | une fixture est déclarée mais absente de `fixtures/` | +| `content_fixture_undeclared` | un fichier est dans `fixtures/` sans être déclaré | +| `content_fixture_escapes` | un chemin de fixture est absolu ou contient `..` | +| `content_doc_url_no_scheme` | `doc_url` ne porte aucun schéma d'URL | +| `content_doc_url_scheme` | `doc_url` emploie un schéma autre que http(s) | +| `schema_version_too_new` | le fichier déclare un `schema_version` que ce dsoxlab ne sait pas lire | + **Ce qu'il ne peut pas vérifier :** qu'un lab listé dans `meta.yml` existe sur le disque. Le validator parcourt ce que la découverte a déjà chargé. D'où l'ordre ci-dessus. @@ -114,20 +148,30 @@ labs et **nommer** les blocs ; le rattachement compare le chemin relatif depuis répertoire de lab. La préparation est déclarative (`lab.yaml`) ou Ansible (`setup.yaml`). -**5. Une fixture non déclarée n'est pas copiée, et rien ne le signale.** Le -runtime shell itère sur `runtime.fixtures`, **pas** sur le répertoire -`fixtures/`. Un lab qui livre des fichiers sans les déclarer ouvre sur un -répertoire de travail vide, et l'apprenant n'a rien à faire. Le contrôle se fait -à la main : +**5. `fixtures/` et `runtime.fixtures` doivent dire la même chose.** Le runtime +shell itère sur `runtime.fixtures`, **pas** sur le répertoire `fixtures/` — les +deux peuvent donc diverger, et les deux sens échouaient en silence. Depuis la +0.1.84, plus aucun : -```bash -dsoxlab run -ls /challenge/work # doit lister exactement ce que fixtures déclare -``` +| Situation | Ce qui se passe | +| --- | --- | +| déclarée, absente du disque | `run` **échoue** (code 2) et nomme toutes les fautives d'un coup | +| présente sur le disque, non déclarée | `validate-structure` la signale (`content_fixture_undeclared`) | +| chemin qui sort du workdir | signalé, et refusé à l'exécution (`content_fixture_escapes`) | + +La validation précède **toute** copie : c'est donc tout ou rien, parce qu'un +répertoire de travail à moitié rempli a l'air de marcher, et que l'apprenant +cherche alors l'erreur dans son propre travail. Ce défaut a rendu **7 labs +injouables le 2026-07-28**, tous marqués faits — il se cachait d'autant mieux +que les outils de vérification des corrigés copient, eux, le répertoire entier : +la solution passait au vert pendant que le parcours apprenant était cassé. + +Les fichiers cachés (`.gitkeep`) sont exemptés : ils servent à versionner un +répertoire vide, et les signaler serait un faux positif que chaque auteur +apprendrait à ignorer. Le chemin déclaré est préservé : `modules/stockage/main.tf` atterrit sous -`/modules/stockage/main.tf`, répertoires intermédiaires compris. Un -chemin absolu, ou contenant `..`, est refusé avec un avertissement. +`/modules/stockage/main.tf`, répertoires intermédiaires compris. **6. Une clé hors contrat est signalée, pas refusée.** Depuis la 0.1.54, `validate-structure` nomme toute clé que le moteur ne lira jamais, avec la plus diff --git a/docs/catalog-author.md b/docs/catalog-author.md index f5770a7..89d1bb3 100644 --- a/docs/catalog-author.md +++ b/docs/catalog-author.md @@ -88,6 +88,39 @@ fault, it made another choice). `--check-urls` adds the only network control: each `doc_url` must answer. +### Every key, and what it means + +The validator names each anomaly by a stable key. A test keeps this table in +step with the code: adding a check without documenting it here fails the suite. + +| Key | What it means | +| --- | --- | +| `struct_missing_file` | a required file is absent | +| `struct_missing_dir` | `challenge/tests/` is absent | +| `struct_vm_targets_empty` | a `vm` lab declares no `runtime.targets` | +| `struct_default_unknown` | `runtime.default` names a target `targets[]` does not define | +| `struct_shell_workdir_empty` | a `shell` lab declares no `runtime.workdir` | +| `struct_session_unknown` | `runtime.session` is neither `target` nor `local` | +| `metadata_field_empty` | a required field is empty | +| `metadata_list_empty` | `skills` or `distros` is an empty list | +| `metadata_doc_url_scheme` | `doc_url` is not http(s) | +| `metadata_lab_type_invalid` | `lab_type` is outside the enumeration | +| `metadata_exam_score_invalid` | `exam_passing_score` is out of range | +| `content_broken_links` | a relative link points at nothing | +| `content_missing_english` | a document is translated on one side only | +| `content_scoring_points_mismatch` | the tasks total a different number of points than announced | +| `content_scoring_count_mismatch` | the header announces a different number of graded tasks | +| `content_scoring_tasks_vs_tests` | graded tasks and tests do not line up | +| `content_target_host_unknown` | a target's host is absent from `infra.hosts[]` | +| `content_role_host_unknown` | a `roles` entry names an unknown host | +| `content_solution_plaintext` | a file under `solution/` is readable in the clear | +| `content_fixture_missing` | a fixture is declared but absent from `fixtures/` | +| `content_fixture_undeclared` | a file sits in `fixtures/` without being declared | +| `content_fixture_escapes` | a fixture path is absolute or contains `..` | +| `content_doc_url_no_scheme` | `doc_url` carries no URL scheme | +| `content_doc_url_scheme` | `doc_url` uses a scheme other than http(s) | +| `schema_version_too_new` | the file declares a `schema_version` this dsoxlab cannot read | + **What it cannot check:** that a lab listed in `meta.yml` exists on disk. The validator walks what discovery already loaded. Hence the order above. @@ -112,19 +145,29 @@ blocks; the match compares the path relative to `labs/` against lab directory. Preparation is declarative (`lab.yaml`) or Ansible (`setup.yaml`). -**5. An undeclared fixture is not copied, and nothing says so.** The shell +**5. `fixtures/` and `runtime.fixtures` must say the same thing.** The shell runtime iterates over `runtime.fixtures`, **not** over the `fixtures/` -directory. A lab that ships files without listing them opens on an empty work -directory, and the learner has nothing to work with. Check it by hand: +directory — so the two can disagree, and both directions used to fail silently. +Since 0.1.84 neither does: -```bash -dsoxlab run -ls /challenge/work # must list exactly what fixtures declares -``` +| Situation | What happens | +| --- | --- | +| declared, missing from disk | `run` **fails** (exit 2) and names every offender at once | +| present on disk, undeclared | `validate-structure` reports it (`content_fixture_undeclared`) | +| path escaping the workdir | reported, and refused at run time (`content_fixture_escapes`) | + +Validation happens **before** any copy, so it is all or nothing: a half-filled +work directory looks like it works, and the learner then hunts for a mistake +that is not theirs. This defect made **7 labs unplayable on 2026-07-28**, all +marked done — it hid all the better because the tooling that checks the answer +keys copies the whole directory, so the solution went green while the learner's +path was broken. + +Hidden files (`.gitkeep`) are exempt: they version an empty directory, and +flagging them would be a false positive every author learns to ignore. The declared path is preserved: `modules/storage/main.tf` lands under -`/modules/storage/main.tf`, intermediate directories included. An -absolute path, or one containing `..`, is refused with a warning. +`/modules/storage/main.tf`, intermediate directories included. **6. A key outside the contract is reported, not refused.** Since 0.1.54 `validate-structure` names any key the engine will never read, along with the diff --git a/pyproject.toml b/pyproject.toml index b713e7b..2246ecc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.1.84" +version = "0.1.85" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_doc_auteur_couvre_les_controles.py b/tests/test_doc_auteur_couvre_les_controles.py new file mode 100644 index 0000000..e3126ad --- /dev/null +++ b/tests/test_doc_auteur_couvre_les_controles.py @@ -0,0 +1,144 @@ +"""La documentation auteur couvre tous les contrôles de `validate-structure`. + +Corriger une phrase périmée ne protège de rien : elle repérimera. Ce module +attaque la cause — **rien ne reliait mécaniquement ce que le validateur détecte +à ce que la documentation en dit**. + +L'incident qui l'a motivé : `docs/catalog-author.md` a annoncé pendant douze +versions qu'une fixture non déclarée passait en silence (« nothing says so »), +alors que 0.1.84 avait ajouté `content_fixture_undeclared` pour la signaler. Le +comportement, le CHANGELOG et le `CLAUDE.md` avaient suivi ; cette page non, et +rien ne pouvait le dire. + +Le principe retenu est le seul qui tienne sans jugement humain : **toute clé +d'anomalie qu'un validator peut produire est citée dans la page auteur.** Ajouter +un contrôle sans le documenter devient rouge, et le message nomme la clé +manquante. + +Ce que ce test ne prétend pas faire : vérifier que la *phrase* qui entoure la clé +est juste. Aucun test ne sait lire « nothing says so ». Mais il force à ouvrir la +page au moment où le comportement change, et c'est ce moment-là qui manquait. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +VALIDATORS = RACINE / "src" / "dsoxlab" / "validators" +DOCS = (RACINE / "docs" / "catalog-author.md", + RACINE / "docs" / "catalog-author.fr.md") + +#: Clés qu'un auteur de catalogue ne peut ni provoquer ni corriger : elles +#: relèvent de l'outil ou du réseau, pas du contrat qu'il écrit. Nommées une par +#: une avec leur raison — une exemption par motif finirait par couvrir des cas +#: qu'on n'a pas examinés. +_HORS_PAGE_AUTEUR = { + "content_doc_url_unreachable": + "dépend du réseau au moment du contrôle, pas du catalogue ; " + "seul `--check-urls` la produit", + "content_doc_url_status": + "même raison : c'est la réponse HTTP du site, pas une faute d'auteur", + "content_solution_unreadable": + "un corrigé illisible sur le disque est un incident de fichier, pas " + "une règle du contrat", +} + + +def _cles_des_validators() -> dict[str, str]: + """Chaque clé d'anomalie qu'un validator peut produire, avec son module. + + Lues dans l'arbre syntaxique plutôt que listées à la main : une clé ajoutée + doit faire échouer ce test, pas attendre qu'un lecteur la remarque. + """ + trouvees: dict[str, str] = {} + for chemin in sorted(VALIDATORS.glob("*.py")): + arbre = ast.parse(chemin.read_text(encoding="utf-8")) + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Call): + continue + nom = getattr(noeud.func, "id", "") + if not nom.endswith("Issue"): + continue + for kw in noeud.keywords: + if kw.arg == "key" and isinstance(kw.value, ast.Constant): + trouvees[str(kw.value.value)] = chemin.name + # `MetadataIssue("champ", "cle", {...})` : la clé est en 2e position. + if nom == "MetadataIssue" and len(noeud.args) >= 2: + second = noeud.args[1] + if isinstance(second, ast.Constant): + trouvees[str(second.value)] = chemin.name + return trouvees + + +def test_la_lecture_des_validators_est_representative() -> None: + """Sans ce contrôle, une lecture cassée rendrait le suivant toujours vert. + + C'est le défaut que tout ce module corrige, et il vaut d'abord pour lui. + """ + assert len(_cles_des_validators()) >= 25 + + +def test_chaque_controle_est_documente_en_anglais() -> None: + absentes = _absentes(DOCS[0]) + + assert absentes == [], ( + "ces contrôles existent dans les validators mais ne sont cités nulle " + f"part dans docs/catalog-author.md : {absentes}\n" + "Un auteur ne peut pas corriger ce qu'aucune page ne lui explique. " + "Documente-les, ou inscris-les dans _HORS_PAGE_AUTEUR avec leur raison." + ) + + +def test_chaque_controle_est_documente_en_francais() -> None: + absentes = _absentes(DOCS[1]) + + assert absentes == [], ( + "ces contrôles ne sont cités nulle part dans " + f"docs/catalog-author.fr.md : {absentes}" + ) + + +def _absentes(page: Path) -> list[str]: + texte = page.read_text(encoding="utf-8") + return sorted( + cle for cle in _cles_des_validators() + if cle not in _HORS_PAGE_AUTEUR and cle not in texte + ) + + +def test_chaque_exemption_designe_un_controle_existant() -> None: + """Une exemption dont le contrôle a disparu couvre le vide, et le cache. + + Elle survivrait au renommage de la clé qu'elle dispensait, et dispenserait + silencieusement la suivante portant le même nom. + """ + connues = _cles_des_validators() + + orphelines = sorted(c for c in _HORS_PAGE_AUTEUR if c not in connues) + + assert orphelines == [], f"exemptions sans contrôle correspondant : {orphelines}" + + +def test_la_page_ne_cite_aucun_controle_disparu() -> None: + """L'autre sens du drift, celui qui a produit cette issue. + + Une page qui décrit un contrôle retiré enseigne une règle qui n'existe plus + — exactement ce qu'a fait « an undeclared fixture … nothing says so » pendant + douze versions. + """ + connues = set(_cles_des_validators()) + fantomes: list[str] = [] + + for page in DOCS: + texte = page.read_text(encoding="utf-8") + for mot in set(texte.split()): + nu = mot.strip("`(),.:;*") + if nu.startswith(("content_", "struct_", "metadata_")) and nu not in connues: + fantomes.append(f"{page.name}: {nu}") + + assert fantomes == [], ( + f"ces clés sont citées dans la documentation mais n'existent plus : " + f"{sorted(fantomes)}" + ) diff --git a/uv.lock b/uv.lock index ab93570..ccc449d 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.1.84" +version = "0.1.85" source = { editable = "." } dependencies = [ { name = "ansible-core", version = "2.19.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },