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
-44
View File
@@ -1,44 +0,0 @@
from ..connection import connect
from ..constants import FIELD_TYPES
from ..quote_ident import quote_ident
from ..slugify import slugify
from .get_definition import get_definition
def add_field_to_definition(slug, definition_id, field):
"""CRUD — Update d'une définition : ajoute un champ à un objet déjà
créé. Exécute un vrai ALTER TABLE ... ADD COLUMN sur la table SQL
existante (les lignes déjà enregistrées reçoivent NULL pour ce
nouveau champ, comportement standard d'un ALTER TABLE)."""
definition = get_definition(slug, definition_id)
conn = connect(slug)
fname = slugify(field["name"]).replace("-", "_")
ftype = field["type"]
required = 1 if field.get("required") else 0
relation_definition_id = None
position = (max((f["position"] for f in definition["fields"]), default=-1)) + 1
if ftype == "relation":
related = get_definition(slug, int(field["relation_definition_id"]))
col = f"{fname}_id"
# ALTER TABLE ADD COLUMN de SQLite n'accepte pas de contrainte
# REFERENCES portant sur une colonne ajoutée après coup avec la
# même simplicité qu'à la création : on ajoute la colonne simple —
# c'est la table _fields qui reste la source de vérité utilisée par
# le moteur pour savoir que cette colonne est une relation.
conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(col)} INTEGER")
relation_definition_id = related["id"]
else:
sql_type = FIELD_TYPES[ftype]["sql"]
conn.execute(f"ALTER TABLE {definition['table_name']} ADD COLUMN {quote_ident(fname)} {sql_type}")
min_value = field.get("min_value") if ftype in ("nombre_entier", "nombre_decimal") else None
max_value = field.get("max_value") if ftype in ("nombre_entier", "nombre_decimal") else None
conn.execute(
"""INSERT INTO _fields
(definition_id, name, type, relation_definition_id, required, position, min_value, max_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(definition_id, field["name"], ftype, relation_definition_id, required, position, min_value, max_value),
)
conn.commit()
conn.close()
+11 -6
View File
@@ -1,13 +1,15 @@
from typing import Any
from ..connection import connect
from ..constants import FIELD_TYPES
from ..quote_ident import quote_ident
from ..slugify import slugify
from ..table_name_for import table_name_for
from .get_definition import get_definition
from .ensure_field_bounds_schema import ensure_field_bounds_schema
from .get_definition import get_definition
def create_definition(slug, name, fields, per_player=True):
def create_definition(slug: str, name: str, fields: list[dict[str, Any]], per_player: bool = True) -> int:
"""Feature 2 : crée une définition d'objet (comme une table de BDD) et
exécute le vrai CREATE TABLE correspondant, avec les bons types de
colonne, y compris les colonnes de clé étrangère pour les relations
@@ -30,7 +32,7 @@ def create_definition(slug, name, fields, per_player=True):
"INSERT INTO _definitions (name, table_name, per_player) VALUES (?, ?, ?)",
(name, tname, 1 if per_player else 0),
)
definition_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
definition_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
columns_sql = ["id INTEGER PRIMARY KEY AUTOINCREMENT"]
for pos, f in enumerate(fields):
@@ -41,10 +43,13 @@ def create_definition(slug, name, fields, per_player=True):
if ftype == "relation":
related = get_definition(slug, int(f["relation_definition_id"]))
if related is None:
raise ValueError(
f"relation_definition_id {f['relation_definition_id']!r} ne correspond "
"a aucune definition existante"
)
col = f"{fname}_id"
columns_sql.append(
f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)"
)
columns_sql.append(f"{quote_ident(col)} INTEGER REFERENCES {related['table_name']}(id)")
relation_definition_id = related["id"]
else:
sql_type = FIELD_TYPES[ftype]["sql"]
+3 -1
View File
@@ -1,7 +1,9 @@
from typing import Any
from ..connection import connect
def definitions_referencing(slug, definition_id):
def definitions_referencing(slug: str, definition_id: int) -> list[dict[str, Any]]:
"""Autres définitions de CE jeu qui ont un champ de type relation
pointant vers cette définition — utilisé pour bloquer une suppression
qui casserait ces relations."""
+3 -1
View File
@@ -2,8 +2,10 @@ from ..connection import connect
from .get_definition import get_definition
def delete_definition(slug, definition_id):
def delete_definition(slug: str, definition_id: int) -> None:
definition = get_definition(slug, definition_id)
if definition is None:
raise ValueError(f"definition_id {definition_id!r} ne correspond a aucune definition existante")
conn = connect(slug)
conn.execute(f"DROP TABLE IF EXISTS {definition['table_name']}")
conn.execute("DELETE FROM _fields WHERE definition_id = ?", (definition_id,))
-29
View File
@@ -1,29 +0,0 @@
import sqlite3
from ..connection import connect
from ..quote_ident import quote_ident
from ..slugify import slugify
from .get_definition import get_definition
def delete_field(slug, definition_id, field_id):
"""CRUD — Update d'une définition : retire un champ. Exécute un vrai
ALTER TABLE ... DROP COLUMN (SQLite ≥ 3.35). Sur une version de SQLite
trop ancienne pour DROP COLUMN, le champ est retiré de la définition
(le moteur ne le proposera plus dans les formulaires) mais la colonne
SQL peut subsister sans casser quoi que ce soit d'autre."""
definition = get_definition(slug, definition_id)
field = next((f for f in definition["fields"] if f["id"] == field_id), None)
if not field:
return
col = slugify(field["name"]).replace("-", "_")
if field["type"] == "relation":
col += "_id"
conn = connect(slug)
try:
conn.execute(f"ALTER TABLE {definition['table_name']} DROP COLUMN {quote_ident(col)}")
except sqlite3.OperationalError:
pass
conn.execute("DELETE FROM _fields WHERE id = ?", (field_id,))
conn.commit()
conn.close()
+1 -1
View File
@@ -1,7 +1,7 @@
from ..connection import connect
def ensure_field_bounds_schema(slug):
def ensure_field_bounds_schema(slug: str) -> None:
"""Migration légère (voir screens/screens_repo/ensure_schema.py pour le
même principe) : ajoute les colonnes min_value/max_value à _fields pour
les jeux créés avant le bornage automatique (2.2), et per_player à
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect
from .ensure_field_bounds_schema import ensure_field_bounds_schema
def get_definition(slug, definition_id):
def get_definition(slug: str, definition_id: int) -> dict[str, Any] | None:
ensure_field_bounds_schema(slug)
conn = connect(slug)
d = conn.execute("SELECT * FROM _definitions WHERE id = ?", (definition_id,)).fetchone()
+3 -1
View File
@@ -1,8 +1,10 @@
from typing import Any
from ..connection import connect
from .ensure_field_bounds_schema import ensure_field_bounds_schema
def list_definitions(slug):
def list_definitions(slug: str) -> list[dict[str, Any]]:
ensure_field_bounds_schema(slug)
conn = connect(slug)
rows = conn.execute("SELECT * FROM _definitions ORDER BY id").fetchall()
-8
View File
@@ -1,8 +0,0 @@
from ..connection import connect
def rename_definition(slug, definition_id, new_name):
conn = connect(slug)
conn.execute("UPDATE _definitions SET name = ? WHERE id = ?", (new_name, definition_id))
conn.commit()
conn.close()
-49
View File
@@ -1,49 +0,0 @@
import sqlite3
from ..connection import connect
from ..quote_ident import quote_ident
from ..slugify import slugify
from .get_definition import get_definition
def update_field(slug, definition_id, field_id, new_name, required, relation_definition_id=None, min_value=None, max_value=None):
"""CRUD — Update d'une définition : modifie un champ déjà créé (nom,
obligatoire, objet lié si c'est une relation, et bornes mini/maxi si
c'est un champ numérique — voir 2.2, bornage automatique). Si le nom
change, la vraie colonne SQL est renommée (ALTER TABLE ... RENAME
COLUMN, SQLite ≥ 3.25) pour que la colonne réelle continue de
correspondre exactement au nom du champ tel qu'affiché — pas de dérive
entre la définition et la table. Le TYPE d'un champ existant ne se
change pas ici (une vraie conversion de type SQLite demanderait de
reconstruire la table et de convertir les données déjà enregistrées,
hors scope de cette version)."""
definition = get_definition(slug, definition_id)
field = next((f for f in definition["fields"] if f["id"] == field_id), None)
if not field:
return
old_col = slugify(field["name"]).replace("-", "_")
new_col = slugify(new_name).replace("-", "_")
if field["type"] == "relation":
old_col += "_id"
new_col += "_id"
conn = connect(slug)
if old_col != new_col:
try:
conn.execute(
f"ALTER TABLE {definition['table_name']} RENAME COLUMN {quote_ident(old_col)} TO {quote_ident(new_col)}"
)
except sqlite3.OperationalError:
pass # SQLite trop ancien pour RENAME COLUMN : la colonne SQL garde son ancien nom
rel_id = int(relation_definition_id) if (field["type"] == "relation" and relation_definition_id) else field["relation_definition_id"]
is_numeric = field["type"] in ("nombre_entier", "nombre_decimal")
min_v = (min_value if min_value not in (None, "") else None) if is_numeric else None
max_v = (max_value if max_value not in (None, "") else None) if is_numeric else None
conn.execute(
"UPDATE _fields SET name = ?, required = ?, relation_definition_id = ?, min_value = ?, max_value = ? WHERE id = ?",
(new_name, 1 if required else 0, rel_id, min_v, max_v, field_id),
)
conn.commit()
conn.close()