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
+1 -1
View File
@@ -6,7 +6,7 @@ from ..slugify import slugify
from .project_slug import build_slug
def create_game(name, owner_folder=None, project_slug_override=None):
def create_game(name: str, owner_folder: str | None = None, project_slug_override: str | None = None) -> str:
"""Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js
reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
+1 -1
View File
@@ -6,7 +6,7 @@ from ..game_dir import game_dir
from .project_slug import split_slug
def delete_game(slug):
def delete_game(slug: str) -> None:
shutil.rmtree(game_dir(slug))
# Structure de dossiers par utilisateur (voir project_slug.py) :
# nettoie aussi le dossier propriétaire s'il ne contient plus aucun
+5 -2
View File
@@ -1,13 +1,16 @@
from typing import Any
from ..connection import connect
from .game_type_catalog import get_onboarding_type
def game_meta(slug):
def game_meta(slug: str) -> dict[str, Any]:
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close()
return {
"slug": slug, "name": row["value"] if row else slug,
"slug": slug,
"name": row["value"] if row else slug,
# Onboarding guidé (voir game_type_catalog.py) : utilisé par
# templates/base.html pour cacher le lien "Tableau de bord" à un
# compte "restreint" (quiz/embranchement/rpg).
+11 -9
View File
@@ -13,11 +13,14 @@ _meta['onboarding_type'] (une ligne _meta par projet, même convention que
game_type) retient CE choix — jamais lu par le
rendu jouable, seulement par le routage (routes/games/game_dashboard.py,
routes/screens/screens_new.py)."""
from typing import Any
import db
DEFAULT_ONBOARDING_TYPE = "rpg"
ONBOARDING_TYPES = {
ONBOARDING_TYPES: dict[str, dict[str, Any]] = {
"rpg": {
"label": "Créer un jeu 2D ludique",
"tagline": "Ton monde, ton héros, ton scénario.",
@@ -57,7 +60,7 @@ ONBOARDING_TYPES = {
}
def get_onboarding_type_raw(slug):
def get_onboarding_type_raw(slug: str) -> str | None:
"""None si _meta['onboarding_type'] est absent — distingue un projet
JAMAIS passé par l'onboarding guidé (créé avant son existence, ou par
l'admin via "+ Nouveau jeu") d'un projet explicitement "custom"."""
@@ -67,11 +70,11 @@ def get_onboarding_type_raw(slug):
return row["value"] if row else None
def get_onboarding_type(slug):
def get_onboarding_type(slug: str) -> str:
return get_onboarding_type_raw(slug) or DEFAULT_ONBOARDING_TYPE
def set_onboarding_type(slug, onboarding_type):
def set_onboarding_type(slug: str, onboarding_type: str) -> None:
conn = db.connect(slug)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('onboarding_type', ?)",
@@ -81,15 +84,14 @@ def set_onboarding_type(slug, onboarding_type):
conn.close()
def is_restricted(onboarding_type_or_slug):
def is_restricted(onboarding_type_or_slug: str) -> bool:
"""Accepte directement une clé de ONBOARDING_TYPES, ou un slug de
projet (résout alors son onboarding_type d'abord) — pratique aussi
bien pour core/auth_guard.py (a le slug) que pour un test unitaire (a
déjà la clé)."""
onboarding_type = (
onboarding_type_or_slug if onboarding_type_or_slug in ONBOARDING_TYPES
onboarding_type_or_slug
if onboarding_type_or_slug in ONBOARDING_TYPES
else get_onboarding_type(onboarding_type_or_slug)
)
return ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"]
return bool(ONBOARDING_TYPES.get(onboarding_type, ONBOARDING_TYPES[DEFAULT_ONBOARDING_TYPE])["restricted"])
+1 -1
View File
@@ -3,7 +3,7 @@ from ..connection import connect
DEFAULT_GAME_TYPE = "jeu_2d"
def get_game_type(slug):
def get_game_type(slug: str) -> str:
"""Type de jeu choisi à la création (voir create_game.py) : "document"
(éditeur générique actuel — quiz/formulaires/contenus, écrans =
éléments HTML positionnés en %) ou "jeu_2d" (éditeur de scène dédié —
+2 -2
View File
@@ -4,7 +4,7 @@ DEFAULT_SCORM_VERSION = "1.2"
VALID_SCORM_VERSIONS = ("1.2", "2004")
def get_scorm_version(slug):
def get_scorm_version(slug: str) -> str:
"""Version SCORM exportée pour ce jeu (voir publish/build_scorm_package.py,
publish/scorm_manifest.py) — '1.2' par défaut (compatibilité la plus
large) ou '2004' (3rd/4th edition, sépare completion_status/
@@ -16,4 +16,4 @@ def get_scorm_version(slug):
conn.close()
if row is None or row["value"] not in VALID_SCORM_VERSIONS:
return DEFAULT_SCORM_VERSION
return row["value"]
return str(row["value"])
+2 -4
View File
@@ -3,16 +3,14 @@ from ..connection import connect
DEFAULT_SUCCESS_THRESHOLD_PERCENT = 70
def get_success_threshold(slug):
def get_success_threshold(slug: str) -> int:
"""Seuil de réussite (% de bonnes réponses au quiz) de CE jeu — stocké
dans _meta (clé 'success_threshold_percent'), même convention que
get_xapi_settings.py. Détermine le statut SCORM/xAPI de fin de partie
(reussi/echoue, voir static/js/play/dialogue-box-controller.js::
forgeSyncAllQuestsCompletionToScorm) : 70% par défaut si jamais réglé."""
conn = connect(slug)
row = conn.execute(
"SELECT value FROM _meta WHERE key = 'success_threshold_percent'"
).fetchone()
row = conn.execute("SELECT value FROM _meta WHERE key = 'success_threshold_percent'").fetchone()
conn.close()
if row is None or row["value"] in (None, ""):
return DEFAULT_SUCCESS_THRESHOLD_PERCENT
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def get_xapi_settings(slug):
def get_xapi_settings(slug: str) -> dict[str, str]:
"""Réglages xAPI de CE jeu (voir set_xapi_settings.py — stockés dans
_meta, même convention que 'name'/'onboarding_type', voir
db/games/game_type_catalog.py) : URL du LRS (Learning Record Store)
+5 -4
View File
@@ -1,12 +1,13 @@
import os
from typing import Any
from ..constants import PROJECTS_DIR
from ..connection import connect
from ..constants import PROJECTS_DIR
from ..db_path import db_path
from .project_slug import build_slug
def list_games(owner_folder):
def list_games(owner_folder: str) -> list[dict[str, Any]]:
"""Scanne UNIQUEMENT projects/<owner_folder>/ (voir db/games/
project_slug.py — owner_folder est l'id du compte, voir
db/games/create_game.py) : jamais les autres comptes — voir
@@ -17,7 +18,7 @@ def list_games(owner_folder):
fonction ne lit plus jamais l'arborescence project/ en entier (avant
ce correctif, elle listait TOUS les comptes sans distinction — faille
corrigée, voir routes/games/index.py)."""
games = []
games: list[dict[str, Any]] = []
entry_path = os.path.join(PROJECTS_DIR, owner_folder)
if not os.path.isdir(entry_path):
return games
@@ -28,7 +29,7 @@ def list_games(owner_folder):
return games
def _append_game(games, slug):
def _append_game(games: list[dict[str, Any]], slug: str) -> None:
conn = connect(slug)
row = conn.execute("SELECT value FROM _meta WHERE key = 'name'").fetchone()
conn.close()
+1 -1
View File
@@ -5,7 +5,7 @@ from ..game_dir import game_dir
from .project_slug import build_slug, split_slug
def move_game(old_slug, new_owner_folder):
def move_game(old_slug: str, new_owner_folder: str) -> str:
"""Renomme le dossier PROPRIÉTAIRE d'un compte — owner_folder est
l'id du compte (voir db/games/project_slug.py, routes/games/
games_new.py) : ne change donc plus jamais après coup en usage normal,
+2 -2
View File
@@ -17,11 +17,11 @@ move_game, list_games) savent que le slug encode ce chemin composé — si
la convention change un jour, ce fichier est le seul à modifier."""
def build_slug(owner_folder, project_part):
def build_slug(owner_folder: str, project_part: str) -> str:
return f"{owner_folder}_{project_part}"
def split_slug(slug):
def split_slug(slug: str) -> tuple[str, str | None]:
"""(owner_folder, project_part) si `slug` est bien composé, sinon
(slug, None) — un slug "plat" (créé avant cette convention, pas encore
migré par scripts/migrate_flat_project_slugs.py) reste lisible tel
+1 -1
View File
@@ -2,7 +2,7 @@ from ..connection import connect
from .get_scorm_version import VALID_SCORM_VERSIONS
def set_scorm_version(slug, version):
def set_scorm_version(slug: str, version: str) -> None:
"""Enregistre la version SCORM exportée — voir get_scorm_version.py."""
if version not in VALID_SCORM_VERSIONS:
raise ValueError("version SCORM invalide : {!r}".format(version))
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def set_success_threshold(slug, percent):
def set_success_threshold(slug: str, percent: int) -> int:
"""Enregistre le seuil de réussite (0-100) — voir get_success_threshold.py."""
percent = max(0, min(100, int(percent)))
conn = connect(slug)
+4 -10
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def set_xapi_settings(slug, endpoint, login, password=None):
def set_xapi_settings(slug: str, endpoint: str, login: str, password: str | None = None) -> None:
"""Enregistre les réglages xAPI de CE jeu (voir get_xapi_settings.py).
`password=None` (champ laissé vide côté formulaire, voir
routes/publish/xapi_settings.py) laisse le mot de passe déjà
@@ -10,15 +10,9 @@ def set_xapi_settings(slug, endpoint, login, password=None):
que la route GET puisse ne jamais renvoyer sa valeur au navigateur
(juste un booléen "déjà configuré")."""
conn = connect(slug)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",)
)
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",)
)
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_endpoint', ?)", (endpoint or "",))
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_login', ?)", (login or "",))
if password is not None:
conn.execute(
"INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,)
)
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('xapi_lrs_password', ?)", (password,))
conn.commit()
conn.close()
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def update_game_name(slug, new_name):
def update_game_name(slug: str, new_name: str) -> None:
conn = connect(slug)
conn.execute("UPDATE _meta SET value = ? WHERE key = 'name'", (new_name,))
conn.commit()