Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ certifié : un résumé honnête, généré à partir de ce que le moteur de jeu
implémente RÉELLEMENT aujourd'hui (voir static/js/play/triggers.js,
dialogue-box-controller.js, screens/scenes/render_scene_object.py), à
compléter/faire valider par le créateur du jeu avant publication."""
from datetime import date
_DECLARATION_TEMPLATE = """Déclaration d'accessibilité — {game_name}
@@ -60,7 +61,7 @@ complète du jeu "{game_name}".
"""
def build_accessibility_declaration(game_name):
def build_accessibility_declaration(game_name: str) -> str:
"""Texte brut (jamais de mise en forme HTML/Markdown à interpréter :
prévu pour être lu/imprimé/collé tel quel dans un document interne,
voir routes/publish/accessibility_declaration.py)."""
+37 -30
View File
@@ -3,12 +3,14 @@ routes/publish/export_scorm.py) — paquet STATIQUE : index.html porte tout
le runtime jouable directement en mémoire (window.FORGE_OFFLINE, voir
static/js/play/offline/) puisqu'un SCORM tourne SEUL dans le LMS du
client, sans serveur Forge Engine disponible."""
import base64
import json
import os
import shutil
import tempfile
import zipfile
from collections.abc import Iterable
from typing import Any
from flask import render_template
@@ -24,12 +26,19 @@ _BASE_DIR = os.path.dirname(_PUBLISH_DIR)
# templates/play.html) — jamais les assets propres à l'éditeur seul
# (icônes de l'interface de l'éditeur mises à part, déjà dans icons/).
_STATIC_ITEMS = [
"style.css", "csrf_fetch.js", "icons", "branding", "vendor/fonts", "vendor/animate.min.css",
"js/play", "characters", "backgrounds",
"style.css",
"csrf_fetch.js",
"icons",
"branding",
"vendor/fonts",
"vendor/animate.min.css",
"js/play",
"characters",
"backgrounds",
]
def _copy_characters(static_dir, used_forge_characters):
def _copy_characters(static_dir: str, used_forge_characters: Iterable[str]) -> None:
"""`static/characters/` contient 2 rangements bien distincts (voir le
plan "Bibliothèque de sprites animaux CraftPix") : le rangement plat
Kenney (`<slug>/*.png`, CC0, ~6 Mo, toujours copié comme avant) et
@@ -68,7 +77,7 @@ def _copy_characters(static_dir, used_forge_characters):
# (déjà copié ci-dessus) n'a pas ce préfixe.
if not character_slug.startswith("animal-"):
continue
animal, _, nn = character_slug[len("animal-"):].rpartition("-")
animal, _, nn = character_slug[len("animal-") :].rpartition("-")
if animal:
used_variants.add((animal, nn))
for animal, nn in used_variants:
@@ -77,7 +86,7 @@ def _copy_characters(static_dir, used_forge_characters):
shutil.copytree(src, os.path.join(animals_dst_root, animal, nn))
def _copy_static_assets(staging_dir, slug):
def _copy_static_assets(staging_dir: str, slug: str) -> None:
static_dir = os.path.join(staging_dir, "static")
used_forge_characters = screens.list_used_forge_characters(slug)
for item in _STATIC_ITEMS:
@@ -103,7 +112,7 @@ def _copy_static_assets(staging_dir, slug):
shutil.copy2(src, dst)
def _copy_uploads(staging_dir, slug):
def _copy_uploads(staging_dir: str, slug: str) -> None:
"""Fichiers envoyés par le créateur (widget Image/Vidéo, "Envoyer un
fichier" — voir routes/uploads/upload_file.py) : servis EN LIGNE par
une route Flask (/game/<slug>/uploads/<fichier>, jamais static/), donc
@@ -114,7 +123,7 @@ def _copy_uploads(staging_dir, slug):
shutil.copytree(src, os.path.join(staging_dir, "uploads"))
def _relativize_absolute_urls(html, slug):
def _relativize_absolute_urls(html: str, slug: str) -> str:
"""screens/rendering (render_icone.py) et screens/labels (sprite_
library.py/animal_sprite_library.py) construisent certaines URLs en
DUR avec un préfixe absolu "/static/..." ou "/game/<slug>/uploads/..."
@@ -141,21 +150,21 @@ def _relativize_absolute_urls(html, slug):
"static/icons/x.svg" y redeviendrait "static/static/icons/x.svg")."""
return (
html.replace(f"/game/{slug}/uploads/", "uploads/")
.replace("/static/icons/", "icons/")
.replace("/static/", "static/")
.replace("/static/icons/", "icons/")
.replace("/static/", "static/")
)
def _collect_icon_slugs(payload):
def _collect_icon_slugs(payload: dict[str, Any]) -> set[str]:
"""Tout _icon_slug (widget "icone") réellement posé dans ce jeu —
élément normal ET élément niché dans un élément de jeu réutilisable
(element_type_templates, voir full_game_payload.py) — pour ne pas
embarquer ("data:" URI, voir _build_icon_data_uris) le catalogue
ENTIER (~1 Mo pour ~260 icônes) alors qu'un jeu n'en utilise
typiquement que quelques-unes."""
slugs = set()
slugs: set[str] = set()
def scan(elements):
def scan(elements: Iterable[dict[str, Any]] | None) -> None:
for el in elements or []:
if el.get("widget") == "icone":
slugs.add((el.get("attributes") or {}).get("_icon_slug") or "star")
@@ -169,7 +178,7 @@ def _collect_icon_slugs(payload):
return slugs
def _build_icon_data_uris(slugs):
def _build_icon_data_uris(slugs: Iterable[str]) -> dict[str, str]:
"""Encode chaque icône RÉELLEMENT utilisée (voir _collect_icon_slugs)
en data: URI. Nécessaire en plus de _relativize_absolute_urls : Firefox
traite CHAQUE ressource file:// comme une origine opaque distincte, et
@@ -181,7 +190,7 @@ def _build_icon_data_uris(slugs):
data: URI n'est PAS une requête réseau séparée : elle contourne
entièrement ce problème (comme pour toute autre resource CSS
référencée par chemin sous file://)."""
uris = {}
uris: dict[str, str] = {}
icons_dir = os.path.join(_BASE_DIR, "static", "icons")
for icon_slug in slugs:
path = os.path.join(icons_dir, f"{icon_slug}.svg")
@@ -193,7 +202,7 @@ def _build_icon_data_uris(slugs):
return uris
def _inline_icon_urls(html, icon_data_uris):
def _inline_icon_urls(html: str, icon_data_uris: dict[str, str]) -> str:
"""Remplace chaque "icons/<slug>.svg" (forme relative déjà posée par
_relativize_absolute_urls/render_icone.py) par sa data: URI — s'applique
aussi bien au rendu initial (déjà bake dans le payload JSON embarqué
@@ -205,11 +214,11 @@ def _inline_icon_urls(html, icon_data_uris):
return html
def _list_package_files(staging_dir):
def _list_package_files(staging_dir: str) -> list[str]:
"""Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir
(hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS
pour qu'un validateur strict (ADL SCORM Cloud) accepte le paquet."""
rels = []
rels: list[str] = []
for root, _dirs, files in os.walk(staging_dir):
for name in files:
full = os.path.join(root, name)
@@ -219,7 +228,7 @@ def _list_package_files(staging_dir):
return rels
def _zip_dir(staging_dir, zip_path):
def _zip_dir(staging_dir: str, zip_path: str) -> None:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _dirs, files in os.walk(staging_dir):
for name in files:
@@ -228,7 +237,7 @@ def _zip_dir(staging_dir, zip_path):
zf.write(full, rel)
def build_scorm_zip(slug):
def build_scorm_zip(slug: str) -> tuple[str, str]:
"""Construit le zip Web/SCORM et renvoie (chemin, nom du jeu) — à
l'appelant de supprimer le zip une fois la réponse HTTP envoyée (voir
routes/publish/export_scorm.py). Doit être appelé DANS une requête
@@ -273,8 +282,7 @@ def build_scorm_zip(slug):
# en SCORM 1.2 (un seul champ pour coder complétion ET réussite).
scorm_version = db.get_scorm_version(slug)
scorm_api_wrapper_url = (
"static/js/play/offline/scorm2004-api.js" if scorm_version == "2004"
else "static/js/play/offline/scorm-api.js"
"static/js/play/offline/scorm2004-api.js" if scorm_version == "2004" else "static/js/play/offline/scorm-api.js"
)
# Score max possible (voir db/dialogue_lines.py::sum_question_rewards) :
@@ -302,15 +310,14 @@ def build_scorm_zip(slug):
_copy_uploads(staging_dir, slug)
html = render_template(
"play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload),
offline_mode=True, asset_url=lambda filename: "static/" + filename,
"play.html",
game=game,
screens_data=payload["screens"],
payload_json=db.json_for_script(payload),
offline_mode=True,
asset_url=lambda filename: "static/" + filename,
scorm_api_wrapper_url=scorm_api_wrapper_url,
# "</" -> "<\\/" : endpoint/login sont saisis librement par le
# créateur du jeu (voir routes/publish/xapi_settings.py) — sans
# cet échappement, une valeur contenant "</script>" refermerait
# prématurément la balise <script> de templates/play.html qui
# embarque ce JSON (json.dumps n'échappe jamais "<"/">"/"&").
xapi_config_json=json.dumps(xapi_config).replace("</", "<\\/"),
xapi_config_json=db.json_for_script(xapi_config),
success_threshold_percent=success_threshold_percent,
max_quiz_score=max_quiz_score,
)
+13 -5
View File
@@ -6,8 +6,10 @@ du créateur du jeu (voir db/games/get_scorm_version.py) : seule la 2004
sépare cmi.completion_status/cmi.success_status, utile quand un
"échoué" doit rester "completed" (l'apprenant est allé au bout) plutôt
que retomber en "incomplete" comme l'impose le champ unique de la 1.2."""
import os
from xml.sax.saxutils import escape
from collections.abc import Iterable
from xml.sax.saxutils import escape # nosec B406 - genere de la sortie XML, ne parse jamais d'XML externe
_MANIFEST_TEMPLATE_1_2 = """<?xml version="1.0" standalone="no" ?>
<manifest identifier="FORGE_{identifier}" version="1"
@@ -74,17 +76,23 @@ _MANIFEST_TEMPLATE_2004 = """<?xml version="1.0" standalone="no" ?>
_MANIFEST_TEMPLATES = {"1.2": _MANIFEST_TEMPLATE_1_2, "2004": _MANIFEST_TEMPLATE_2004}
def build_manifest_xml(slug, game_name, package_files, version="1.2"):
def build_manifest_xml(
slug: str,
game_name: str,
package_files: Iterable[str],
version: str = "1.2",
) -> str:
"""`package_files` : chemins relatifs (depuis la racine du paquet, tel
qu'écrits dans le zip) de TOUS les fichiers autres que index.html —
un LMS strict (SCORM Cloud) rejette un manifeste qui omet un fichier
réellement présent dans le paquet. `version` : '1.2' (défaut) ou
'2004' — voir db/games/get_scorm_version.py."""
file_entries = "\n".join(
' <file href="{}"/>'.format(escape(rel.replace(os.sep, "/")))
for rel in package_files
' <file href="{}"/>'.format(escape(rel.replace(os.sep, "/"))) for rel in package_files
)
template = _MANIFEST_TEMPLATES.get(version, _MANIFEST_TEMPLATE_1_2)
return template.format(
identifier=escape(slug), title=escape(game_name), file_entries=file_entries,
identifier=escape(slug),
title=escape(game_name),
file_entries=file_entries,
)