From d057c8ee7780ce380b2006a5b5eac2e9d9ce2f55 Mon Sep 17 00:00:00 2001 From: Denis Defolie Date: Sun, 11 Oct 2026 16:47:28 +0200 Subject: [PATCH] Add JSON schemas, data validator, and first CE1 content Introduces a generic JSON-Schema-subset validator in GDScript (scripts/data/schema_validator.gd) used both by `make validate-data` and, later, by ExerciseEngine at load time, plus semantic_checks.gd for cross-field constraints a schema can't express (answer index bounds, at least one faulty token in sentence correction, word count matching between a dictation's target_text and its annotations). Schemas cover rule, exercise (dispatched per type: multiple_choice, sentence_correction), dictation, curriculum, and save (save.schema.json is not yet wired into validate-data; saves live in user://, handled at the Profiles/Saves step). First draft content for the CE1 "accord_adjectif" rule (one rule, two multiple-choice exercises, one sentence correction, one dictation, one curriculum entry) exercises the full pipeline end to end and seeds docs/review/content-review.md, regenerated by the new tools/generate_content_review.py (`make content-review`). Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 1 + Makefile | 10 +- data/curriculum/.gitkeep | 0 .../ce1_accords_groupe_nominal.json | 8 + data/dictations/.gitkeep | 0 .../ce1_accord_adjectif_dictee_01.json | 18 +++ data/exercises/ce1/.gitkeep | 0 .../ce1_accord_adjectif_correction_01.json | 25 +++ .../ce1/ce1_accord_adjectif_qcm_01.json | 17 ++ .../ce1/ce1_accord_adjectif_qcm_02.json | 17 ++ data/rules/.gitkeep | 0 data/rules/regle_accord_adjectif.json | 9 ++ data/schemas/curriculum.schema.json | 24 +++ data/schemas/dictation.schema.json | 47 ++++++ .../exercise_multiple_choice.schema.json | 43 ++++++ .../exercise_sentence_correction.schema.json | 48 ++++++ data/schemas/rule.schema.json | 22 +++ data/schemas/save.schema.json | 32 ++++ docs/review/content-review.md | 41 +++++ docs/specs/decisions.md | 10 ++ scripts/data/data_loader.gd | 76 +++++++++ scripts/data/data_loader.gd.uid | 1 + scripts/data/schema_validator.gd | 122 +++++++++++++++ scripts/data/schema_validator.gd.uid | 1 + scripts/data/semantic_checks.gd | 49 ++++++ scripts/data/semantic_checks.gd.uid | 1 + scripts/data/validate_data_cli.gd | 84 ++++++---- tests/data/test_schema_validator.gd | 69 +++++++++ tests/data/test_semantic_checks.gd | 45 ++++++ tools/generate_content_review.py | 145 ++++++++++++++++++ tools/setup.sh | 8 +- 31 files changed, 938 insertions(+), 35 deletions(-) delete mode 100644 data/curriculum/.gitkeep create mode 100644 data/curriculum/ce1_accords_groupe_nominal.json delete mode 100644 data/dictations/.gitkeep create mode 100644 data/dictations/ce1_accord_adjectif_dictee_01.json delete mode 100644 data/exercises/ce1/.gitkeep create mode 100644 data/exercises/ce1/ce1_accord_adjectif_correction_01.json create mode 100644 data/exercises/ce1/ce1_accord_adjectif_qcm_01.json create mode 100644 data/exercises/ce1/ce1_accord_adjectif_qcm_02.json delete mode 100644 data/rules/.gitkeep create mode 100644 data/rules/regle_accord_adjectif.json create mode 100644 data/schemas/curriculum.schema.json create mode 100644 data/schemas/dictation.schema.json create mode 100644 data/schemas/exercise_multiple_choice.schema.json create mode 100644 data/schemas/exercise_sentence_correction.schema.json create mode 100644 data/schemas/rule.schema.json create mode 100644 data/schemas/save.schema.json create mode 100644 docs/review/content-review.md create mode 100644 scripts/data/data_loader.gd create mode 100644 scripts/data/data_loader.gd.uid create mode 100644 scripts/data/schema_validator.gd create mode 100644 scripts/data/schema_validator.gd.uid create mode 100644 scripts/data/semantic_checks.gd create mode 100644 scripts/data/semantic_checks.gd.uid create mode 100644 tests/data/test_schema_validator.gd create mode 100644 tests/data/test_semantic_checks.gd create mode 100644 tools/generate_content_review.py diff --git a/CLAUDE.md b/CLAUDE.md index aba031a..fb7b053 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ Le `Makefile` est le seul point d'entrée pour les opérations système. Tenir c - `make validate-data` : valide tous les JSON de `data/` contre leurs schémas - `make test` : tests en mode headless - `make check` : lint + validate-data + test (à passer avant chaque commit) +- `make content-review` : régénère `docs/review/content-review.md` à partir de `data/` - `make export-linux` / `make export-windows` : exports dans `build/` ## Conventions de code diff --git a/Makefile b/Makefile index fd1fa9d..b5d14cb 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,11 @@ GODOT_BIN := .godot-bin/godot VENV := .venv GDLINT := $(VENV)/bin/gdlint GDFORMAT := $(VENV)/bin/gdformat +PYRIGHT := $(VENV)/bin/pyright +PYTHON := $(VENV)/bin/python3 GD_SOURCES := autoload scripts tests -.PHONY: setup run lint format validate-data test check export-linux export-windows +.PHONY: setup run lint format validate-data test check content-review export-linux export-windows setup: tools/setup.sh @@ -14,6 +16,7 @@ run: lint: $(GDLINT) $(GD_SOURCES) + $(PYRIGHT) tools/ format: $(GDFORMAT) $(GD_SOURCES) @@ -22,10 +25,13 @@ validate-data: $(GODOT_BIN) --headless --path . -s res://scripts/data/validate_data_cli.gd test: - $(GODOT_BIN) --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gexit + $(GODOT_BIN) --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit check: lint validate-data test +content-review: + $(PYTHON) tools/generate_content_review.py + export-linux: @echo "Export Linux : pas encore configure (voir Ordre de construction, etape 6)." @exit 1 diff --git a/data/curriculum/.gitkeep b/data/curriculum/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/data/curriculum/ce1_accords_groupe_nominal.json b/data/curriculum/ce1_accords_groupe_nominal.json new file mode 100644 index 0000000..4c0ed7b --- /dev/null +++ b/data/curriculum/ce1_accords_groupe_nominal.json @@ -0,0 +1,8 @@ +{ + "level": "CE1", + "region_id": "foret_des_accords", + "chapter_id": "accords_groupe_nominal", + "title": "Les accords dans le groupe nominal", + "rule_ids": ["regle_accord_adjectif"], + "review_status": "draft" +} diff --git a/data/dictations/.gitkeep b/data/dictations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/data/dictations/ce1_accord_adjectif_dictee_01.json b/data/dictations/ce1_accord_adjectif_dictee_01.json new file mode 100644 index 0000000..5d095f3 --- /dev/null +++ b/data/dictations/ce1_accord_adjectif_dictee_01.json @@ -0,0 +1,18 @@ +{ + "id": "ce1_accord_adjectif_dictee_01", + "level": "CE1", + "domain": "grammaire", + "rule_ids": ["regle_accord_adjectif"], + "difficulty": 1, + "target_text": "Les petits chats dorment.", + "words": [ + { "text": "Les", "expects_capital": true }, + { "text": "petits", "rule_id": "regle_accord_adjectif" }, + { "text": "chats" }, + { "text": "dorment." } + ], + "audio_id": "ce1_accord_adjectif_dictee_01", + "audio_provisional": true, + "accepted_variants": [], + "review_status": "draft" +} diff --git a/data/exercises/ce1/.gitkeep b/data/exercises/ce1/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/data/exercises/ce1/ce1_accord_adjectif_correction_01.json b/data/exercises/ce1/ce1_accord_adjectif_correction_01.json new file mode 100644 index 0000000..dbe918c --- /dev/null +++ b/data/exercises/ce1/ce1_accord_adjectif_correction_01.json @@ -0,0 +1,25 @@ +{ + "id": "ce1_accord_adjectif_correction_01", + "level": "CE1", + "domain": "grammaire", + "skill": "accord_groupe_nominal", + "rule_id": "regle_accord_adjectif", + "difficulty": 2, + "type": "sentence_correction", + "instruction": "Touche le mot qui contient une erreur, puis corrige-le.", + "tokens": [ + { "text": "Les", "correct": true }, + { + "text": "petit", + "correct": false, + "expected": "petits", + "rule_id": "regle_accord_adjectif" + }, + { "text": "chiens", "correct": true }, + { "text": "courent.", "correct": true } + ], + "hint": "Le determinant « les » indique un pluriel.", + "explanation": "L'adjectif « petit » s'accorde avec le nom « chiens », qui est au pluriel : il faut ecrire « petits ».", + "review_tags": ["pluriel", "accord_adjectif"], + "review_status": "draft" +} diff --git a/data/exercises/ce1/ce1_accord_adjectif_qcm_01.json b/data/exercises/ce1/ce1_accord_adjectif_qcm_01.json new file mode 100644 index 0000000..c026142 --- /dev/null +++ b/data/exercises/ce1/ce1_accord_adjectif_qcm_01.json @@ -0,0 +1,17 @@ +{ + "id": "ce1_accord_adjectif_qcm_01", + "level": "CE1", + "domain": "grammaire", + "skill": "accord_groupe_nominal", + "rule_id": "regle_accord_adjectif", + "difficulty": 1, + "type": "multiple_choice", + "instruction": "Choisis le bon groupe de mots.", + "question": "Les ... chiens jouent.", + "answers": ["petit", "petits", "petite"], + "correct_answer": 1, + "hint": "Observe le determinant « les » et le nom « chiens ».", + "explanation": "Le groupe nominal est au pluriel. L'adjectif prend ici la marque du pluriel.", + "review_tags": ["pluriel", "accord_adjectif"], + "review_status": "draft" +} diff --git a/data/exercises/ce1/ce1_accord_adjectif_qcm_02.json b/data/exercises/ce1/ce1_accord_adjectif_qcm_02.json new file mode 100644 index 0000000..5c7ed14 --- /dev/null +++ b/data/exercises/ce1/ce1_accord_adjectif_qcm_02.json @@ -0,0 +1,17 @@ +{ + "id": "ce1_accord_adjectif_qcm_02", + "level": "CE1", + "domain": "grammaire", + "skill": "accord_groupe_nominal", + "rule_id": "regle_accord_adjectif", + "difficulty": 1, + "type": "multiple_choice", + "instruction": "Choisis le bon groupe de mots.", + "question": "La ... maison est jolie.", + "answers": ["petit", "petite", "petites"], + "correct_answer": 1, + "hint": "Observe le determinant « la » et le nom « maison ».", + "explanation": "Le nom « maison » est feminin singulier. L'adjectif prend donc la marque du feminin.", + "review_tags": ["feminin", "accord_adjectif"], + "review_status": "draft" +} diff --git a/data/rules/.gitkeep b/data/rules/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/data/rules/regle_accord_adjectif.json b/data/rules/regle_accord_adjectif.json new file mode 100644 index 0000000..470b142 --- /dev/null +++ b/data/rules/regle_accord_adjectif.json @@ -0,0 +1,9 @@ +{ + "rule_id": "regle_accord_adjectif", + "level": "CE1", + "domain": "grammaire", + "skill": "accord_groupe_nominal", + "statement": "Quand un adjectif accompagne un nom, il s'accorde avec ce nom en genre et en nombre.", + "example": "Un petit chien. Une petite chienne. Des petits chiens. Des petites chiennes.", + "review_status": "draft" +} diff --git a/data/schemas/curriculum.schema.json b/data/schemas/curriculum.schema.json new file mode 100644 index 0000000..46565c8 --- /dev/null +++ b/data/schemas/curriculum.schema.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "required": [ + "level", + "region_id", + "chapter_id", + "title", + "rule_ids", + "review_status" + ], + "additionalProperties": false, + "properties": { + "level": { "type": "string", "enum": ["CE1", "CE2"] }, + "region_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "chapter_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "title": { "type": "string", "minLength": 1 }, + "rule_ids": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" } + }, + "review_status": { "type": "string", "enum": ["draft", "validated"] } + } +} diff --git a/data/schemas/dictation.schema.json b/data/schemas/dictation.schema.json new file mode 100644 index 0000000..d32120d --- /dev/null +++ b/data/schemas/dictation.schema.json @@ -0,0 +1,47 @@ +{ + "type": "object", + "required": [ + "id", + "level", + "domain", + "rule_ids", + "difficulty", + "target_text", + "words", + "audio_id", + "audio_provisional", + "accepted_variants", + "review_status" + ], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "level": { "type": "string", "enum": ["CE1", "CE2"] }, + "domain": { "type": "string", "enum": ["grammaire", "orthographe"] }, + "rule_ids": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" } + }, + "difficulty": { "type": "integer", "minimum": 1, "maximum": 3 }, + "target_text": { "type": "string", "minLength": 1 }, + "words": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["text"], + "additionalProperties": false, + "properties": { + "text": { "type": "string", "minLength": 1 }, + "rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "expects_capital": { "type": "boolean" } + } + } + }, + "audio_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "audio_provisional": { "type": "boolean" }, + "accepted_variants": { "type": "array", "items": { "type": "string" } }, + "review_status": { "type": "string", "enum": ["draft", "validated"] } + } +} diff --git a/data/schemas/exercise_multiple_choice.schema.json b/data/schemas/exercise_multiple_choice.schema.json new file mode 100644 index 0000000..7f86b10 --- /dev/null +++ b/data/schemas/exercise_multiple_choice.schema.json @@ -0,0 +1,43 @@ +{ + "type": "object", + "required": [ + "id", + "level", + "domain", + "skill", + "rule_id", + "difficulty", + "type", + "instruction", + "question", + "answers", + "correct_answer", + "hint", + "explanation", + "review_tags", + "review_status" + ], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "level": { "type": "string", "enum": ["CE1", "CE2"] }, + "domain": { "type": "string", "enum": ["grammaire", "orthographe"] }, + "skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "difficulty": { "type": "integer", "minimum": 1, "maximum": 3 }, + "type": { "type": "string", "const": "multiple_choice" }, + "instruction": { "type": "string", "minLength": 1 }, + "question": { "type": "string", "minLength": 1 }, + "answers": { + "type": "array", + "minItems": 2, + "maxItems": 6, + "items": { "type": "string", "minLength": 1 } + }, + "correct_answer": { "type": "integer", "minimum": 0 }, + "hint": { "type": "string", "minLength": 1 }, + "explanation": { "type": "string", "minLength": 1 }, + "review_tags": { "type": "array", "items": { "type": "string" } }, + "review_status": { "type": "string", "enum": ["draft", "validated"] } + } +} diff --git a/data/schemas/exercise_sentence_correction.schema.json b/data/schemas/exercise_sentence_correction.schema.json new file mode 100644 index 0000000..1d532f0 --- /dev/null +++ b/data/schemas/exercise_sentence_correction.schema.json @@ -0,0 +1,48 @@ +{ + "type": "object", + "required": [ + "id", + "level", + "domain", + "skill", + "rule_id", + "difficulty", + "type", + "instruction", + "tokens", + "hint", + "explanation", + "review_tags", + "review_status" + ], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "level": { "type": "string", "enum": ["CE1", "CE2"] }, + "domain": { "type": "string", "enum": ["grammaire", "orthographe"] }, + "skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "difficulty": { "type": "integer", "minimum": 1, "maximum": 3 }, + "type": { "type": "string", "const": "sentence_correction" }, + "instruction": { "type": "string", "minLength": 1 }, + "tokens": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": ["text", "correct"], + "additionalProperties": false, + "properties": { + "text": { "type": "string", "minLength": 1 }, + "correct": { "type": "boolean" }, + "expected": { "type": "string", "minLength": 1 }, + "rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" } + } + } + }, + "hint": { "type": "string", "minLength": 1 }, + "explanation": { "type": "string", "minLength": 1 }, + "review_tags": { "type": "array", "items": { "type": "string" } }, + "review_status": { "type": "string", "enum": ["draft", "validated"] } + } +} diff --git a/data/schemas/rule.schema.json b/data/schemas/rule.schema.json new file mode 100644 index 0000000..81d49ca --- /dev/null +++ b/data/schemas/rule.schema.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "required": [ + "rule_id", + "level", + "domain", + "skill", + "statement", + "example", + "review_status" + ], + "additionalProperties": false, + "properties": { + "rule_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "level": { "type": "string", "enum": ["CE1", "CE2"] }, + "domain": { "type": "string", "enum": ["grammaire", "orthographe"] }, + "skill": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "statement": { "type": "string", "minLength": 1 }, + "example": { "type": "string", "minLength": 1 }, + "review_status": { "type": "string", "enum": ["draft", "validated"] } + } +} diff --git a/data/schemas/save.schema.json b/data/schemas/save.schema.json new file mode 100644 index 0000000..edeef61 --- /dev/null +++ b/data/schemas/save.schema.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "required": [ + "schema_version", + "profile_id", + "display_name", + "school_level", + "session_count", + "unlocked_regions", + "skills", + "rewards" + ], + "additionalProperties": false, + "properties": { + "schema_version": { "type": "integer", "minimum": 1 }, + "profile_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "display_name": { "type": "string", "minLength": 1 }, + "school_level": { "type": "string", "enum": ["CE1", "CE2"] }, + "session_count": { "type": "integer", "minimum": 0 }, + "unlocked_regions": { "type": "array", "items": { "type": "string" } }, + "skills": { "type": "object" }, + "rewards": { + "type": "object", + "required": ["stars", "items"], + "additionalProperties": false, + "properties": { + "stars": { "type": "integer", "minimum": 0 }, + "items": { "type": "array", "items": { "type": "string" } } + } + } + } +} diff --git a/docs/review/content-review.md b/docs/review/content-review.md new file mode 100644 index 0000000..bd5a6ba --- /dev/null +++ b/docs/review/content-review.md @@ -0,0 +1,41 @@ +# Revue du contenu pedagogique + +Genere automatiquement depuis `data/` par `tools/generate_content_review.py`. Ne pas editer ce fichier a la main. + +## regle_accord_adjectif (CE1, grammaire) + +**Enonce** : Quand un adjectif accompagne un nom, il s'accorde avec ce nom en genre et en nombre. + +**Exemple** : Un petit chien. Une petite chienne. Des petits chiens. Des petites chiennes. + +**Statut** : draft + +### Choix multiples + +- **ce1_accord_adjectif_qcm_01** (difficulte 1) : Les ... chiens jouent. + - Reponses : petit, petits, petite + - Reponse attendue : petits + - Indice : Observe le determinant « les » et le nom « chiens ». + - Explication : Le groupe nominal est au pluriel. L'adjectif prend ici la marque du pluriel. + - Statut : draft +- **ce1_accord_adjectif_qcm_02** (difficulte 1) : La ... maison est jolie. + - Reponses : petit, petite, petites + - Reponse attendue : petite + - Indice : Observe le determinant « la » et le nom « maison ». + - Explication : Le nom « maison » est feminin singulier. L'adjectif prend donc la marque du feminin. + - Statut : draft + +### Corrections de phrase + +- **ce1_accord_adjectif_correction_01** (difficulte 2) : Les [petit -> petits] chiens courent. + - Indice : Le determinant « les » indique un pluriel. + - Explication : L'adjectif « petit » s'accorde avec le nom « chiens », qui est au pluriel : il faut ecrire « petits ». + - Statut : draft + +### Dictees + +- **ce1_accord_adjectif_dictee_01** : Les petits chats dorment. + - Variantes acceptees : aucune + - Audio : provisoire + - Statut : draft + diff --git a/docs/specs/decisions.md b/docs/specs/decisions.md index f34293a..f38919d 100644 --- a/docs/specs/decisions.md +++ b/docs/specs/decisions.md @@ -88,3 +88,13 @@ Ajouts à l'arborescence proposée : - Licence et hébergement : tranchés ci-dessus (point 0). - Validation du contenu CE1/CE2 par un enseignant avant diffusion : à organiser, pas de date fixée. - Spécification minimale de performance pour « appareil familial » : non définie, à préciser avant les exports. + +## 11. Étape « Données » : précisions prises en cours d'implémentation + +- Le validateur générique (`scripts/data/schema_validator.gd`) implémente un sous-ensemble de JSON Schema (`type`, `required`, `properties`, `additionalProperties`, `items`, `enum`, `const`, bornes numériques, longueurs, `pattern`). Suffisant pour tous les schémas actuels ; pas de `patternProperties` ni de `if`/`then`. +- Le dispatch du schéma d'exercice se fait sur le champ `type` (`multiple_choice` → `exercise_multiple_choice.schema.json`, `sentence_correction` → `exercise_sentence_correction.schema.json`), dans `DataLoader.exercise_schema_name_for_type`. Un type absent de cette fonction est rejeté explicitement par `make validate-data`, conformément à l'invariant « liste fermée des types d'exercices ». +- Les contraintes croisées qu'un schéma ne peut pas exprimer (index `correct_answer` dans les bornes de `answers`, au moins un token fautif en correction de phrase avec une correction `expected`, cohérence entre `target_text` et `words` en dictée) vivent dans `scripts/data/semantic_checks.gd`, testées indépendamment du schéma. +- Schéma de dictée : `words` (annotation mot par mot : `rule_id` et `expects_capital` facultatifs) est **obligatoire**, pas facultatif — sans lui, l'erreur reste non classée (cf. « Points durs » du prompt de lancement). Le nombre de mots dans `words` doit correspondre à `target_text` découpé sur les espaces. +- Le schéma `save` reste provisoire pour le champ `skills` (`{"type": "object"}`, sans validation des clés dynamiques) : le validateur actuel ne sait pas exprimer un schéma pour des clés arbitraires. À affiner à l'étape « Profils et sauvegardes ». +- GUT est pinné en v9.6.0, pas la dernière version (9.7.1 référence le singleton `AccessibilityServer`, absent de Godot 4.6.3 en mode headless). +- `tools/setup.sh` lance `godot --headless --path . --import` en fin d'installation : nécessaire pour que les scripts `class_name` globaux (`SchemaValidator`, `DataLoader`, `SemanticChecks`) soient résolus avant la première exécution de `make test` / `make validate-data`. diff --git a/scripts/data/data_loader.gd b/scripts/data/data_loader.gd new file mode 100644 index 0000000..614bfd4 --- /dev/null +++ b/scripts/data/data_loader.gd @@ -0,0 +1,76 @@ +class_name DataLoader +extends RefCounted +## Charge et valide les fichiers JSON de data/ contre leurs schemas (data/schemas/). +## Meme code utilise par `make validate-data` (headless) et par ExerciseEngine a +## l'execution : une seule source de verite pour la validation (voir +## docs/specs/decisions.md, point 2). + +const SCHEMA_DIR := "res://data/schemas" + +static var _schema_cache: Dictionary = {} + + +## Mappe un type d'exercice (champ "type" des donnees) vers le nom de son schema. +## Chaine vide si le type n'est pas reconnu : la liste des types est fermee ici, +## pas devinee ailleurs. +static func exercise_schema_name_for_type(exercise_type: String) -> String: + match exercise_type: + "multiple_choice": + return "exercise_multiple_choice" + "sentence_correction": + return "exercise_sentence_correction" + _: + return "" + + +static func load_schema(schema_name: String) -> Dictionary: + if _schema_cache.has(schema_name): + return _schema_cache[schema_name] + var path := "%s/%s.schema.json" % [SCHEMA_DIR, schema_name] + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_error("Schema introuvable : %s" % path) + return {} + var parsed: Variant = JSON.parse_string(file.get_as_text()) + var schema: Dictionary = parsed if typeof(parsed) == TYPE_DICTIONARY else {} + _schema_cache[schema_name] = schema + return schema + + +## Lit un fichier JSON et le valide contre le schema nomme. Retourne la liste des +## erreurs (vide si valide) ; ne retourne jamais les donnees analysees, pour que +## l'appelant ne puisse pas accidentellement utiliser une donnee non validee. +static func validate_file(path: String, schema_name: String) -> Array[String]: + var parsed: Variant = read_json(path) + if parsed == null: + return ["%s : lecture impossible ou JSON invalide" % path] + var schema := load_schema(schema_name) + return SchemaValidator.validate(parsed, schema, path) + + +static func read_json(path: String) -> Variant: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + return null + var text := file.get_as_text() + if text.strip_edges().is_empty(): + return null + return JSON.parse_string(text) + + +static func find_json_files(root_path: String) -> Array[String]: + var result: Array[String] = [] + var dir := DirAccess.open(root_path) + if dir == null: + return result + dir.list_dir_begin() + var entry := dir.get_next() + while entry != "": + if entry != "." and entry != "..": + var full_path := root_path.path_join(entry) + if dir.current_is_dir(): + result.append_array(find_json_files(full_path)) + elif entry.ends_with(".json"): + result.append(full_path) + entry = dir.get_next() + return result diff --git a/scripts/data/data_loader.gd.uid b/scripts/data/data_loader.gd.uid new file mode 100644 index 0000000..10080cb --- /dev/null +++ b/scripts/data/data_loader.gd.uid @@ -0,0 +1 @@ +uid://l56rn1o7hqm0 diff --git a/scripts/data/schema_validator.gd b/scripts/data/schema_validator.gd new file mode 100644 index 0000000..806364a --- /dev/null +++ b/scripts/data/schema_validator.gd @@ -0,0 +1,122 @@ +class_name SchemaValidator +extends RefCounted +## Validateur de donnees contre un sous-ensemble de JSON Schema : type, required, +## properties, additionalProperties, items, enum, const, minimum, maximum, +## minItems, maxItems, minLength, maxLength, pattern. +## Les schemas ne decrivent jamais de code a executer : seulement une structure +## de donnees a verifier (voir l'invariant d'architecture dans CLAUDE.md). + + +static func validate(value: Variant, schema: Dictionary, path: String = "$") -> Array[String]: + var errors: Array[String] = [] + _validate_node(value, schema, path, errors) + return errors + + +static func _validate_node( + value: Variant, schema: Dictionary, path: String, errors: Array[String] +) -> void: + if schema.has("const"): + if value != schema["const"]: + errors.append("%s : doit valoir exactement %s" % [path, schema["const"]]) + return + + if schema.has("enum"): + var allowed: Array = schema["enum"] + if not allowed.has(value): + errors.append("%s : valeur '%s' absente de l'enum %s" % [path, value, allowed]) + return + + if schema.has("type"): + if not _check_type(value, schema["type"]): + errors.append( + "%s : type attendu '%s', trouve '%s'" % [path, schema["type"], typeof(value)] + ) + return + + match typeof(value): + TYPE_DICTIONARY: + _validate_object(value, schema, path, errors) + TYPE_ARRAY: + _validate_array(value, schema, path, errors) + TYPE_STRING: + _validate_string(value, schema, path, errors) + TYPE_INT, TYPE_FLOAT: + _validate_number(value, schema, path, errors) + + +static func _check_type(value: Variant, expected: String) -> bool: + match expected: + "object": + return typeof(value) == TYPE_DICTIONARY + "array": + return typeof(value) == TYPE_ARRAY + "string": + return typeof(value) == TYPE_STRING + "boolean": + return typeof(value) == TYPE_BOOL + "integer": + if typeof(value) == TYPE_INT: + return true + if typeof(value) == TYPE_FLOAT: + var number: float = value + return number == floor(number) + return false + "number": + return typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT + _: + return true + + +static func _validate_object( + value: Dictionary, schema: Dictionary, path: String, errors: Array[String] +) -> void: + if schema.has("required"): + for key: String in schema["required"]: + if not value.has(key): + errors.append("%s : propriete requise manquante '%s'" % [path, key]) + + var properties: Dictionary = schema.get("properties", {}) + var additional_allowed: Variant = schema.get("additionalProperties", true) + for key: String in value.keys(): + if properties.has(key): + _validate_node(value[key], properties[key], "%s.%s" % [path, key], errors) + elif additional_allowed == false: + errors.append("%s : propriete inattendue '%s'" % [path, key]) + + +static func _validate_array( + value: Array, schema: Dictionary, path: String, errors: Array[String] +) -> void: + if schema.has("minItems") and value.size() < int(schema["minItems"]): + errors.append("%s : au moins %d element(s) attendu(s)" % [path, schema["minItems"]]) + if schema.has("maxItems") and value.size() > int(schema["maxItems"]): + errors.append("%s : au plus %d element(s) attendu(s)" % [path, schema["maxItems"]]) + if schema.has("items"): + var item_schema: Dictionary = schema["items"] + for i in value.size(): + _validate_node(value[i], item_schema, "%s[%d]" % [path, i], errors) + + +static func _validate_string( + value: String, schema: Dictionary, path: String, errors: Array[String] +) -> void: + if schema.has("minLength") and value.length() < int(schema["minLength"]): + errors.append("%s : longueur minimale %d non atteinte" % [path, schema["minLength"]]) + if schema.has("maxLength") and value.length() > int(schema["maxLength"]): + errors.append("%s : longueur maximale %d depassee" % [path, schema["maxLength"]]) + if schema.has("pattern"): + var regex := RegEx.new() + regex.compile(schema["pattern"]) + if regex.search(value) == null: + errors.append("%s : ne correspond pas au motif '%s'" % [path, schema["pattern"]]) + + +static func _validate_number( + value: Variant, schema: Dictionary, path: String, errors: Array[String] +) -> void: + var number: float = value + if schema.has("minimum") and number < float(schema["minimum"]): + errors.append("%s : doit etre >= %s" % [path, schema["minimum"]]) + if schema.has("maximum") and number > float(schema["maximum"]): + errors.append("%s : doit etre <= %s" % [path, schema["maximum"]]) diff --git a/scripts/data/schema_validator.gd.uid b/scripts/data/schema_validator.gd.uid new file mode 100644 index 0000000..0d89e25 --- /dev/null +++ b/scripts/data/schema_validator.gd.uid @@ -0,0 +1 @@ +uid://dl6pewdq2dyus diff --git a/scripts/data/semantic_checks.gd b/scripts/data/semantic_checks.gd new file mode 100644 index 0000000..277ff50 --- /dev/null +++ b/scripts/data/semantic_checks.gd @@ -0,0 +1,49 @@ +class_name SemanticChecks +extends RefCounted +## Contraintes croisees entre champs qu'un schema JSON (voir SchemaValidator) ne +## peut pas exprimer : index valide, coherence entre deux tableaux, etc. +## Appele uniquement apres une validation de schema reussie : ces fonctions +## supposent une structure deja conforme. + + +static func check_multiple_choice(data: Dictionary, path: String) -> Array[String]: + var errors: Array[String] = [] + var answers: Array = data.get("answers", []) + var correct_answer: int = data.get("correct_answer", -1) + if correct_answer < 0 or correct_answer >= answers.size(): + errors.append( + "%s : correct_answer (%d) hors des bornes de answers (%d element(s))" + % [path, correct_answer, answers.size()] + ) + return errors + + +static func check_sentence_correction(data: Dictionary, path: String) -> Array[String]: + var errors: Array[String] = [] + var tokens: Array = data.get("tokens", []) + var has_error_token := false + for i in tokens.size(): + var token: Dictionary = tokens[i] + if token.get("correct", true) == false: + has_error_token = true + if not token.has("expected"): + errors.append( + "%s.tokens[%d] : token fautif sans correction attendue ('expected')" + % [path, i] + ) + if not has_error_token: + errors.append("%s : aucun token fautif, rien a corriger" % path) + return errors + + +static func check_dictation(data: Dictionary, path: String) -> Array[String]: + var errors: Array[String] = [] + var target_text: String = data.get("target_text", "") + var words: Array = data.get("words", []) + var expected_word_count := target_text.split(" ", false).size() + if words.size() != expected_word_count: + errors.append( + "%s : %d mot(s) annote(s) pour %d mot(s) dans target_text" + % [path, words.size(), expected_word_count] + ) + return errors diff --git a/scripts/data/semantic_checks.gd.uid b/scripts/data/semantic_checks.gd.uid new file mode 100644 index 0000000..50146a6 --- /dev/null +++ b/scripts/data/semantic_checks.gd.uid @@ -0,0 +1 @@ +uid://c2ekxtn3qh5ku diff --git a/scripts/data/validate_data_cli.gd b/scripts/data/validate_data_cli.gd index cabbad9..5966a8d 100644 --- a/scripts/data/validate_data_cli.gd +++ b/scripts/data/validate_data_cli.gd @@ -1,46 +1,68 @@ extends SceneTree ## Outil headless invoque par `make validate-data`. -## A ce stade, verifie seulement que les fichiers data/**/*.json sont du JSON bien forme. -## La validation contre les schemas (rule, exercise, dictation, curriculum, save) sera -## ajoutee a l'etape "Donnees" (voir Ordre de construction, docs/specs/decisions.md point 2). +## Valide chaque fichier JSON de data/ contre son schema (data/schemas/), puis +## applique les contraintes croisees que le schema ne peut pas exprimer. func _init() -> void: - var json_paths: Array[String] = _find_json_files("res://data") var failures: Array[String] = [] - for path in json_paths: - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - failures.append("%s : lecture impossible" % path) - continue - var text := file.get_as_text() - if text.strip_edges().is_empty(): - continue - if JSON.parse_string(text) == null: - failures.append("%s : JSON invalide" % path) + var checked := 0 + + checked += _validate_simple_dir("res://data/rules", "rule", failures) + checked += _validate_simple_dir("res://data/curriculum", "curriculum", failures) + checked += _validate_dictations("res://data/dictations", failures) + checked += _validate_exercises("res://data/exercises", failures) if failures.is_empty(): - print("validate-data : %d fichier(s) JSON valide(s)" % json_paths.size()) + print("validate-data : %d fichier(s) JSON valide(s)" % checked) quit(0) else: for failure in failures: printerr(failure) + printerr("validate-data : %d erreur(s)" % failures.size()) quit(1) -func _find_json_files(root_path: String) -> Array[String]: - var result: Array[String] = [] - var dir := DirAccess.open(root_path) - if dir == null: - return result - dir.list_dir_begin() - var entry := dir.get_next() - while entry != "": - if entry != "." and entry != "..": - var full_path := root_path.path_join(entry) - if dir.current_is_dir(): - result.append_array(_find_json_files(full_path)) - elif entry.ends_with(".json"): - result.append(full_path) - entry = dir.get_next() - return result +func _validate_simple_dir(dir_path: String, schema_name: String, failures: Array[String]) -> int: + var count := 0 + for path in DataLoader.find_json_files(dir_path): + count += 1 + failures.append_array(DataLoader.validate_file(path, schema_name)) + return count + + +func _validate_dictations(dir_path: String, failures: Array[String]) -> int: + var count := 0 + for path in DataLoader.find_json_files(dir_path): + count += 1 + var schema_errors := DataLoader.validate_file(path, "dictation") + failures.append_array(schema_errors) + if schema_errors.is_empty(): + var data: Dictionary = DataLoader.read_json(path) + failures.append_array(SemanticChecks.check_dictation(data, path)) + return count + + +func _validate_exercises(dir_path: String, failures: Array[String]) -> int: + var count := 0 + for path in DataLoader.find_json_files(dir_path): + count += 1 + var data: Variant = DataLoader.read_json(path) + if data == null or typeof(data) != TYPE_DICTIONARY or not data.has("type"): + failures.append("%s : champ 'type' manquant ou JSON invalide" % path) + continue + var exercise_type: String = data["type"] + var schema_name := DataLoader.exercise_schema_name_for_type(exercise_type) + if schema_name.is_empty(): + failures.append("%s : type d'exercice inconnu '%s'" % [path, exercise_type]) + continue + var schema_errors := DataLoader.validate_file(path, schema_name) + failures.append_array(schema_errors) + if not schema_errors.is_empty(): + continue + match exercise_type: + "multiple_choice": + failures.append_array(SemanticChecks.check_multiple_choice(data, path)) + "sentence_correction": + failures.append_array(SemanticChecks.check_sentence_correction(data, path)) + return count diff --git a/tests/data/test_schema_validator.gd b/tests/data/test_schema_validator.gd new file mode 100644 index 0000000..0ce1b12 --- /dev/null +++ b/tests/data/test_schema_validator.gd @@ -0,0 +1,69 @@ +extends GutTest +## Tests du validateur de schema generique (scripts/data/schema_validator.gd), +## independants de tout contenu reel de data/. + +var schema: Dictionary = { + "type": "object", + "required": ["id", "level"], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "level": { "type": "string", "enum": ["CE1", "CE2"] }, + "difficulty": { "type": "integer", "minimum": 1, "maximum": 3 }, + "tags": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + }, +} + + +func test_valid_object_has_no_errors() -> void: + var data := { "id": "ex_01", "level": "CE1", "difficulty": 2, "tags": ["pluriel"] } + assert_eq(SchemaValidator.validate(data, schema), []) + + +func test_missing_required_property_is_reported() -> void: + var data := { "level": "CE1" } + var errors := SchemaValidator.validate(data, schema) + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "id") + + +func test_wrong_type_is_reported() -> void: + var data := { "id": "ex_01", "level": "CE1", "difficulty": "deux" } + var errors := SchemaValidator.validate(data, schema) + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "difficulty") + + +func test_enum_violation_is_reported() -> void: + var data := { "id": "ex_01", "level": "CM1" } + var errors := SchemaValidator.validate(data, schema) + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "level") + + +func test_pattern_violation_is_reported() -> void: + var data := { "id": "Ex-01", "level": "CE1" } + var errors := SchemaValidator.validate(data, schema) + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "id") + + +func test_unexpected_property_is_reported_when_additional_forbidden() -> void: + var data := { "id": "ex_01", "level": "CE1", "extra": true } + var errors := SchemaValidator.validate(data, schema) + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "extra") + + +func test_min_items_violation_is_reported() -> void: + var data := { "id": "ex_01", "level": "CE1", "tags": [] } + var errors := SchemaValidator.validate(data, schema) + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "tags") + + +func test_numeric_bounds_are_checked() -> void: + var data := { "id": "ex_01", "level": "CE1", "difficulty": 9 } + var errors := SchemaValidator.validate(data, schema) + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "difficulty") diff --git a/tests/data/test_semantic_checks.gd b/tests/data/test_semantic_checks.gd new file mode 100644 index 0000000..54ce285 --- /dev/null +++ b/tests/data/test_semantic_checks.gd @@ -0,0 +1,45 @@ +extends GutTest +## Tests des contraintes croisees (scripts/data/semantic_checks.gd). + + +func test_multiple_choice_rejects_out_of_bounds_correct_answer() -> void: + var data := { "answers": ["a", "b"], "correct_answer": 5 } + var errors := SemanticChecks.check_multiple_choice(data, "test") + assert_eq(errors.size(), 1) + + +func test_multiple_choice_accepts_valid_index() -> void: + var data := { "answers": ["a", "b"], "correct_answer": 1 } + assert_eq(SemanticChecks.check_multiple_choice(data, "test"), []) + + +func test_sentence_correction_requires_at_least_one_error_token() -> void: + var data := { "tokens": [{ "text": "Les", "correct": true }] } + var errors := SemanticChecks.check_sentence_correction(data, "test") + assert_eq(errors.size(), 1) + + +func test_sentence_correction_requires_expected_on_error_tokens() -> void: + var data := { "tokens": [{ "text": "petit", "correct": false }] } + var errors := SemanticChecks.check_sentence_correction(data, "test") + assert_eq(errors.size(), 1) + assert_string_contains(errors[0], "expected") + + +func test_dictation_word_count_must_match_target_text() -> void: + var data := { + "target_text": "Les petits chats dorment.", + "words": [{ "text": "Les" }, { "text": "petits" }], + } + var errors := SemanticChecks.check_dictation(data, "test") + assert_eq(errors.size(), 1) + + +func test_dictation_accepts_matching_word_count() -> void: + var data := { + "target_text": "Les petits chats dorment.", + "words": [ + { "text": "Les" }, { "text": "petits" }, { "text": "chats" }, { "text": "dorment." } + ], + } + assert_eq(SemanticChecks.check_dictation(data, "test"), []) diff --git a/tools/generate_content_review.py b/tools/generate_content_review.py new file mode 100644 index 0000000..d501931 --- /dev/null +++ b/tools/generate_content_review.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Genere docs/review/content-review.md a partir des donnees de data/. + +Le JSON se relit mal ; ce document rassemble, pour chaque regle, son enonce +puis chaque exercice qui l'illustre, avec sa reponse attendue, son indice et +son explication. C'est ce document que l'adulte valide (voir CLAUDE.md, +section Contenu pedagogique). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = REPO_ROOT / "data" +OUTPUT_PATH = REPO_ROOT / "docs" / "review" / "content-review.md" + + +def load_json_files(directory: Path) -> list[dict[str, Any]]: + if not directory.exists(): + return [] + return [ + json.loads(path.read_text(encoding="utf-8")) for path in sorted(directory.rglob("*.json")) + ] + + +def format_multiple_choice(exercise: dict[str, Any]) -> str: + answers: list[str] = exercise["answers"] + correct = answers[exercise["correct_answer"]] + return "\n".join( + [ + f"- **{exercise['id']}** (difficulte {exercise['difficulty']}) : {exercise['question']}", + f" - Reponses : {', '.join(answers)}", + f" - Reponse attendue : {correct}", + f" - Indice : {exercise['hint']}", + f" - Explication : {exercise['explanation']}", + f" - Statut : {exercise['review_status']}", + ] + ) + + +def format_sentence_correction(exercise: dict[str, Any]) -> str: + rendered: list[str] = [] + for token in exercise["tokens"]: + if token.get("correct", True): + rendered.append(token["text"]) + else: + rendered.append(f"[{token['text']} -> {token['expected']}]") + sentence = " ".join(rendered) + return "\n".join( + [ + f"- **{exercise['id']}** (difficulte {exercise['difficulty']}) : {sentence}", + f" - Indice : {exercise['hint']}", + f" - Explication : {exercise['explanation']}", + f" - Statut : {exercise['review_status']}", + ] + ) + + +def format_dictation(dictation: dict[str, Any]) -> str: + variants: list[str] = dictation["accepted_variants"] + audio_state = "provisoire" if dictation["audio_provisional"] else "finalise" + return "\n".join( + [ + f"- **{dictation['id']}** : {dictation['target_text']}", + f" - Variantes acceptees : {', '.join(variants) if variants else 'aucune'}", + f" - Audio : {audio_state}", + f" - Statut : {dictation['review_status']}", + ] + ) + + +def render( + rules: list[dict[str, Any]], + exercises: list[dict[str, Any]], + dictations: list[dict[str, Any]], +) -> str: + multiple_choice = [e for e in exercises if e["type"] == "multiple_choice"] + sentence_correction = [e for e in exercises if e["type"] == "sentence_correction"] + + lines: list[str] = [ + "# Revue du contenu pedagogique", + "", + "Genere automatiquement depuis `data/` par `tools/generate_content_review.py`. " + "Ne pas editer ce fichier a la main.", + "", + ] + + for rule in sorted(rules, key=lambda r: str(r["rule_id"])): + rule_id = rule["rule_id"] + lines += [ + f"## {rule_id} ({rule['level']}, {rule['domain']})", + "", + f"**Enonce** : {rule['statement']}", + "", + f"**Exemple** : {rule['example']}", + "", + f"**Statut** : {rule['review_status']}", + "", + ] + + rule_mc = [e for e in multiple_choice if e["rule_id"] == rule_id] + if rule_mc: + lines.append("### Choix multiples") + lines.append("") + for exercise in sorted(rule_mc, key=lambda e: str(e["id"])): + lines.append(format_multiple_choice(exercise)) + lines.append("") + + rule_sc = [e for e in sentence_correction if e["rule_id"] == rule_id] + if rule_sc: + lines.append("### Corrections de phrase") + lines.append("") + for exercise in sorted(rule_sc, key=lambda e: str(e["id"])): + lines.append(format_sentence_correction(exercise)) + lines.append("") + + rule_dictations = [d for d in dictations if rule_id in d["rule_ids"]] + if rule_dictations: + lines.append("### Dictees") + lines.append("") + for dictation in sorted(rule_dictations, key=lambda d: str(d["id"])): + lines.append(format_dictation(dictation)) + lines.append("") + + return "\n".join(lines) + "\n" + + +def main() -> None: + rules = load_json_files(DATA_DIR / "rules") + exercises = load_json_files(DATA_DIR / "exercises") + dictations = load_json_files(DATA_DIR / "dictations") + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUTPUT_PATH.write_text(render(rules, exercises, dictations), encoding="utf-8") + print( + f"content-review.md genere : {len(rules)} regle(s), " + f"{len(exercises)} exercice(s), {len(dictations)} dictee(s)." + ) + + +if __name__ == "__main__": + main() diff --git a/tools/setup.sh b/tools/setup.sh index f87f37c..73d9798 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -11,6 +11,7 @@ GODOT_BIN_DIR=".godot-bin" GODOT_BIN="${GODOT_BIN_DIR}/godot" GUT_VERSION="v9.6.0" # v9.6.1+ reference AccessibilityServer, absent de Godot 4.6.3 GDTOOLKIT_VERSION="4.5.0" +PYRIGHT_VERSION="1.1.407" mkdir -p "${GODOT_BIN_DIR}" @@ -46,6 +47,9 @@ if [ ! -d ".venv" ]; then python3 -m venv .venv fi ./.venv/bin/pip install --quiet --upgrade pip -./.venv/bin/pip install --quiet "gdtoolkit==${GDTOOLKIT_VERSION}" +./.venv/bin/pip install --quiet "gdtoolkit==${GDTOOLKIT_VERSION}" "pyright==${PYRIGHT_VERSION}" -echo "Setup termine : Godot ${GODOT_VERSION}, GUT ${GUT_VERSION}, gdtoolkit ${GDTOOLKIT_VERSION}." +echo "Import du projet (enregistre les class_name globaux : SchemaValidator, DataLoader...)." +"${GODOT_BIN}" --headless --path . --import >/dev/null + +echo "Setup termine : Godot ${GODOT_VERSION}, GUT ${GUT_VERSION}, gdtoolkit ${GDTOOLKIT_VERSION}, pyright ${PYRIGHT_VERSION}."