From 4515ae2272856aedb73e374104cafcdbe5823641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 24 Aug 2026 23:24:21 +0200 Subject: [PATCH] =?UTF-8?q?docs(auteur):=20aligner=20le=20guide=20sur=200.?= =?UTF-8?q?1.84,=20et=20rendre=20la=20d=C3=A9rive=20d=C3=A9tectable=20(0.1?= =?UTF-8?q?.85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Le comportement, le CHANGELOG et le CLAUDE.md avaient suivi la correction ; cette page non, et rien ne pouvait le dire. C'est un drift que j'ai moi-même produit en corrigeant les fixtures : j'ai mis à jour trois surfaces sur quatre. Corriger la phrase ne protège de rien, elle repérimera au prochain contrôle ajouté. Ce commit corrige donc les deux : la section, et l'absence de lien mécanique entre ce que le validateur détecte et ce que la documentation en dit. Le garde-fou : toute clé d'anomalie qu'un validator peut produire doit être citée dans les deux pages auteur, et aucune clé disparue ne peut y rester. Les clés sont lues par AST plutôt que listées à la main — une clé ajoutée doit faire échouer le test, pas attendre qu'un lecteur la remarque. Il ne sait pas lire une phrase, et ne prétend pas juger si le texte autour d'une clé est juste. 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, chacune avec son sens. Elle a une valeur propre — c'est ce qu'un auteur lit dans la sortie du validateur, et rien ne le lui traduisait. Trois clés sont exemptées nommément, avec leur raison : elles dépendent du réseau ou d'un incident de fichier, pas du contrat qu'un auteur écrit. Un test vérifie qu'aucune exemption ne désigne un contrôle disparu. Éprouvé par trois mutations : un contrôle neuf non documenté est nommé, une clé citée mais disparue est nommée, et une lecture des validators cassée fait rougir la suite au lieu de la rendre vide. Une note de méthode, parce qu'elle m'a coûté dix minutes : la clé de mutation faisait exactement la même longueur que l'originale, si bien que le .pyc gardait la même taille et que Python le tenait pour à jour après restauration. Le test échouait sur un code source pourtant correct. Purger __pycache__ après une mutation de même longueur, ou vérifier le comportement plutôt que le fichier. Vérifié : 866 tests dont 5 neufs, 18 e2e, ruff, mypy strict, et test_documentation_synchrone toujours vert. Closes #195 Co-Authored-By: Claude Opus 5 --- CHANGELOG.fr.md | 30 ++++ CHANGELOG.md | 28 ++++ docs/catalog-author.fr.md | 66 ++++++-- docs/catalog-author.md | 61 ++++++-- pyproject.toml | 2 +- tests/test_doc_auteur_couvre_les_controles.py | 144 ++++++++++++++++++ uv.lock | 2 +- 7 files changed, 311 insertions(+), 22 deletions(-) create mode 100644 tests/test_doc_auteur_couvre_les_controles.py 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'" },