Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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é
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 55 additions & 11 deletions docs/catalog-author.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <id>
ls <lab>/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
`<workdir>/modules/stockage/main.tf`, répertoires intermédiaires compris. Un
chemin absolu, ou contenant `..`, est refusé avec un avertissement.
`<workdir>/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
Expand Down
61 changes: 52 additions & 9 deletions docs/catalog-author.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 <id>
ls <lab>/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
`<workdir>/modules/storage/main.tf`, intermediate directories included. An
absolute path, or one containing `..`, is refused with a warning.
`<workdir>/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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
144 changes: 144 additions & 0 deletions tests/test_doc_auteur_couvre_les_controles.py
Original file line number Diff line number Diff line change
@@ -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)}"
)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading