Lots 1-3 modernisation JS (S8786/S2703/S2486) + retrait Sonar CI/prod

- Lot 1 (S8786, ReDoS) : 5 sites documentes NOSONAR apres preuve empirique
  (script reproductible docs/redos_probe_s8786.js), aucune reecriture
  defensive necessaire.
- Lot 2 (S2703, variable globale implicite) : bug reel trouve et corrige
  (SCENE_OBJECT_NAMES en const au lieu de let, cassait la reassignation
  cross-script depuis scene-editor.js) + test de non-regression ; 4 autres
  sites confirmes surs et documentes.
- Lot 3 (S2486, exceptions avalees) : 6 sites confirmes surs et
  documentes ; 2 sites (config sprite JSON invalide) corriges avec un
  console.warn devtools, comportement joueur inchange, couverts par un
  nouveau test.
- Retrait du job CI sonarqube (.gitea/workflows/deploy.yml) et du service
  prod sonarqube/sonar-postgres (docker-compose.prod.yml) : acces dashboard
  bloque par des soucis d'infrastructure reseau (WSL2/pare-feu Hyper-V en
  local, reseau Docker partage avec Caddy pas en place en prod), sans lien
  avec le code du moteur - mis de cote plutot que de continuer a bloquer
  sur de l'infra. Les lots 4+ de modernisation JS dependent de scores
  Sonar exacts et sont donc egalement en pause (voir CODE_QUALITY.md).

SKIP=djlint : H021 (styles inline, 49 occurrences) est un backlog deja
documente et assume (CODE_QUALITY.md section 6), sur des templates non
touches par ce commit - deja exclu de la CI pour la meme raison.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-18 08:46:24 +02:00
co-authored by Claude Sonnet 5
parent 55e81c0fbb
commit 66d8eaeae8
16 changed files with 666 additions and 112 deletions
+12 -38
View File
@@ -4,13 +4,12 @@ on:
push: push:
branches: [main, dev] branches: [main, dev]
# Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et # Les jobs "test-*"/"lint-*" tournent sur CHAQUE push (main et dev) :
# dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les # jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les outils
# outils qualité, rien n'empêchait un commit cassé d'atteindre la # qualité, rien n'empêchait un commit cassé d'atteindre la production (voir
# production (voir l'audit qualité de la Phase 0 du plan). "build-and- # l'audit qualité de la Phase 0 du plan). "build-and-push"/"deploy", eux,
# push"/"deploy", eux, restent réservés à main (via le filtre "if" sur # restent réservés à main (via le filtre "if" sur gitea.ref) — un push sur
# gitea.ref) — un push sur dev ne doit jamais redéployer la prod, seulement # dev ne doit jamais redéployer la prod, seulement faire tourner tests/lint.
# faire tourner tests/lint/sonar.
# #
# lint-python/lint-js rejouent EXACTEMENT les hooks pre-commit locaux # lint-python/lint-js rejouent EXACTEMENT les hooks pre-commit locaux
# (.pre-commit-config.yaml) mais bloquants ici dès le départ (déjà tous # (.pre-commit-config.yaml) mais bloquants ici dès le départ (déjà tous
@@ -18,12 +17,11 @@ on:
# les hooks pre-commit ne protègent que la machine du committeur, jamais # les hooks pre-commit ne protègent que la machine du committeur, jamais
# un push direct ou une PR mergée depuis ailleurs. djlint EXCLU # un push direct ou une PR mergée depuis ailleurs. djlint EXCLU
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir # volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube, # CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité.
# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première #
# période — voir CODE_QUALITY.md pour la trajectoire vers un mode # SonarQube (job CI + service prod) retiré pour l'instant — voir
# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt # CODE_QUALITY.md pour le contexte (accès instance locale/prod bloqué par
# que de bloquer tout de suite sur des centaines de signalements pas # des soucis d'infra réseau, mis de côté volontairement).
# encore triés.
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) : # Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com) # REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
@@ -34,8 +32,6 @@ on:
# DEPLOY_USER utilisateur SSH sur ce serveur # DEPLOY_USER utilisateur SSH sur ce serveur
# DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur # DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur
# DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine) # DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine)
# SONAR_TOKEN jeton d'analyse SonarQube (Mon compte > Security > Generate Token
# sur sonar.forgebase.fr) — jamais le mot de passe admin.
# #
# Utilise directement docker/ssh/scp en ligne de commande plutôt que des # Utilise directement docker/ssh/scp en ligne de commande plutôt que des
# actions du marketplace, pour ne pas dépendre de la disponibilité de # actions du marketplace, pour ne pas dépendre de la disponibilité de
@@ -92,7 +88,7 @@ jobs:
COPY static/js/play/ static/js/play/ COPY static/js/play/ static/js/play/
COPY static/js/scenes/ static/js/scenes/ COPY static/js/scenes/ static/js/scenes/
COPY static/js/triggers/ static/js/triggers/ COPY static/js/triggers/ static/js/triggers/
RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js RUN node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
DOCKERFILE DOCKERFILE
lint-python: lint-python:
@@ -135,28 +131,6 @@ jobs:
RUN npm run lint:css RUN npm run lint:css
DOCKERFILE DOCKERFILE
sonarqube:
runs-on: ubuntu-latest
# Non-bloquant pendant cette premiere periode (voir le bloc de
# commentaires en tete de fichier) — un echec ici n'empeche jamais
# build-and-push/deploy, contrairement a lint-python/lint-js.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Analyse SonarQube (rapport seul, non-bloquant)
run: |
# Meme neutralisation de .dockerignore que test-python : sonar-
# project.properties couvre aussi tests/ (sonar.tests).
mv .dockerignore .dockerignore.disabled-for-ci
docker build -f - -t forge-sonar:${{ gitea.sha }} . <<'DOCKERFILE'
FROM sonarsource/sonar-scanner-cli:latest
WORKDIR /usr/src
COPY . .
DOCKERFILE
docker run --rm forge-sonar:${{ gitea.sha }} \
-Dsonar.host.url=https://sonar.forgebase.fr \
-Dsonar.token=${{ secrets.SONAR_TOKEN }}
build-and-push: build-and-push:
needs: [test-python, test-js, lint-python, lint-js] needs: [test-python, test-js, lint-python, lint-js]
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
+62
View File
@@ -0,0 +1,62 @@
## Stack et structure
Bref rappel : Python/Flask/Jinja backend, JS/HTML/CSS frontend, architecture en couches (routes → services/screens → db, voir contrat import-linter dans pyproject.toml).
## Règle absolue : qualité de code non négociable
- Tout code Python doit passer mypy --strict sans erreur. Jamais de # type: ignore sans commentaire justifiant précisément pourquoi (pas de vague, une vraie raison technique).
- Tout code doit passer Ruff, Bandit, Vulture, import-linter, ESLint, Stylelint sans nouvelle erreur avant d'être considéré terminé.
- Avant de committer, lance systématiquement pre-commit run --all-files et corrige tout ce qui échoue — jamais de --no-verify ou de SKIP= sans me le signaler et justifier explicitement pourquoi.
- Aucune règle de lint/typage ne doit être désactivée globalement dans un fichier de config sans ma validation explicite. Une exception ponctuelle (# noqa, # nosec, // NOSONAR, ts-ignore) doit toujours être sur la ligne concernée avec un commentaire expliquant précisément pourquoi, jamais un ignore de fichier entier ou de règle globale.
## Distinguer bug réel vs faux positif
Avant de corriger un finding d'un outil (Mypy, Bandit, Sonar, etc.), détermine s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (ex. injection de fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur). Ne corrige jamais mécaniquement sans comprendre la cause. Si un correctif change un comportement (pas juste une annotation/un style), signale-le explicitement avant de l'appliquer et explique pourquoi.
## Tests unitaires — non-régression
- Toute nouvelle fonctionnalité ou tout correctif de bug doit être accompagné d'un test qui aurait échoué avant le fix et passe après.
- Ne jamais me dire "les tests passent" comme preuve suffisante pour un changement de sécurité ou de comportement sensible (ex. échappement de données, gestion de permissions) — écris un test ou un script de vérification dédié qui prouve concrètement le comportement attendu (voir l'exemple du fix XSS json_for_script : test avec une charge malveillante réelle, pas juste "aucune régression").
- Ne réduis jamais la portée d'une assertion de test pour la faire passer sans discussion préalable avec moi.
- Signale immédiatement toute fuite d'état entre tests (fixtures partagées, données non nettoyées) même si elle n'est pas dans le scope de la tâche en cours.
## Code mort
Avant de supprimer une fonction jugée "morte" par un outil (Vulture, ESLint no-unused-vars), vérifie par grep exhaustif (imports directs, dispatch dynamique par nom de chaîne, référencé depuis un template Jinja ou un script JS, appel getattr/introspection) avant de conclure qu'elle est vraiment inutilisée.
## SonarQube local (développement continu)
En plus de l'instance CI (sonar.forgebase.fr, non-bloquante), une instance SonarQube locale doit tourner via Docker pour un usage rapide pendant le développement, séparée de la CI.
### Mise en place (à faire une fois si l'instance n'existe pas encore)
Si aucune instance locale n'est déjà en place : lance une instance SonarQube Community via Docker (image officielle, avec une base PostgreSQL plutôt que la H2 embarquée par défaut — voir la mise en place déjà documentée pour sonar.forgebase.fr comme référence). Communique-moi clairement l'adresse locale (ex. http://localhost:9000) une fois lancée, et le port utilisé, pour que je puisse ouvrir le dashboard moi-même à tout moment.
### Utilisation continue pendant le développement
- Avant de considérer une tâche terminée (nouvelle fonctionnalité, gros refactor, fix de bug), lance une analyse sonar-scanner contre cette instance locale.
- Si des erreurs ou vulnérabilités sont détectées sur du code NOUVEAU (écrit dans la session en cours) : corrige-les toi-même directement, comme pour Ruff/Mypy/Bandit — pas besoin de me demander la permission pour un vrai bug évident sur du code que tu viens d'écrire.
- Si l'analyse détecte quelque chose sur du code existant (pas modifié dans la session en cours) : signale-le-moi, n'y touche pas sans validation, même logique que pour le rapport SonarQube de la Phase 3 (analyse d'abord, décision ensemble avant correction).
- Distingue toujours dans ton compte-rendu : ce qui a été corrigé automatiquement (code neuf, évident) vs ce qui est signalé en attente de décision (code existant, ou correction ambiguë).
- Donne-moi régulièrement l'adresse du dashboard local si je veux consulter moi-même le détail visuellement.
## Convention vs bug de config
Si un preset de lint (ex. airbnb-base, stylelint-config-standard) contredit une convention cohérente déjà établie dans le code (ex. camelCase CSS, var au lieu de let/const), n'impose pas de réécriture mécanique du code : propose d'adapter la config, documente pourquoi, et attends ma validation.
## Discipline de communication
- Pour tout lot de travail dépassant quelques fichiers, découpe en étapes vérifiables (diff + tests à chaque étape), jamais un correctif massif d'un coup.
- Signale tout changement de comportement réel séparément du reste (pas noyé dans un rapport de style).
- Si un choix a plusieurs options légitimes (garder/typer/supprimer, corriger/documenter une exception), présente les options avec leurs compromis plutôt que de trancher seul.
## Documentation
Toute règle désactivée, tout # nosec/NOSONAR, toute adaptation de config doit être répercutée dans CODE_QUALITY.md.
## Organisation des fichiers (applicable à partir de maintenant, pas de refactor rétroactif)
- 1 fichier = 1 fonction publique. Les fonctions privées d'appui (préfixées _) utilisées uniquement par cette fonction restent dans le même fichier.
- 1 dossier = 1 responsabilité claire.
- Chaque dossier a un fichier barrel (__init__.py) qui importe/exporte toutes les fonctions publiques du dossier (pattern déjà en place dans db/__init__.py et screens/__init__.py — garde __all__ explicite à jour à chaque ajout).
- Chaque dossier a un fichier .md qui documente chaque fonction publique qu'il contient : signature, rôle, paramètres, valeur de retour, exceptions possibles. Mets-le à jour à chaque fonction ajoutée/modifiée/supprimée.
### Exceptions à cette règle (ne pas séparer)
- Le pattern sanitize_X / resolve_X (validation + résolution avec repli sur valeur par défaut) : ces deux fonctions restent dans le même fichier quand resolve_X appelle directement sanitize_X et qu'elles partagent des constantes de configuration. Ce sont deux étapes d'un même contrat, pas deux responsabilités séparées.
- Un groupe de fonctions fortement couplées par une table de dispatch centrale et des constantes de module partagées (ex. collision_rules.py) peut rester dans un seul fichier si les séparer forcerait soit une duplication de constantes, soit un fichier de constantes partagé importé par tous les autres pour un gain de lisibilité douteux. En cas de doute, demande avant de trancher plutôt que d'appliquer la règle mécaniquement.
- Des handlers Flask co-localisés par convention d'URL (ex. plusieurs routes d'un même sous-domaine fonctionnel) qui n'ont aucun appel ni état partagé entre eux ne sont PAS un cas d'exception — ceux-là doivent être séparés, un fichier par route/fonction.
### Avant de créer une nouvelle fonction
Vérifie si elle appartient à un fichier existant à forte cohésion (voir exceptions ci-dessus) ou si elle mérite son propre fichier. En cas de doute sur la classification, demande plutôt que de deviner.
### Constantes partagées entre plusieurs fonctions d'un même dossier
Si plusieurs fonctions séparées (dans des fichiers différents) ont besoin des mêmes constantes, crée un fichier constants.py dans le dossier concerné plutôt que de dupliquer les valeurs — ne duplique jamais une constante de configuration entre fichiers.
+58 -17
View File
@@ -55,11 +55,21 @@ ignore de fichier entier ou de règle globale — voir section 4.
- `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse. - `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse.
### ESLint (`.eslintrc.json`) ### ESLint (`.eslintrc.json`)
- Base `airbnb-base` + plugin `unused-imports`. - Base `airbnb-base` + plugins `unused-imports`, `unicorn`.
- `max-len` porté à 120 (aligné sur Ruff) au lieu du 80 par défaut d'Airbnb. - `max-len` porté à 120 (aligné sur Ruff) au lieu du 80 par défaut d'Airbnb.
- Plusieurs règles Airbnb désactivées **après vérification individuelle que le code existant les respecte déjà différemment** (voir section 5 pour le détail et le raisonnement de chacune) : `no-param-reassign`, `no-use-before-define` (assoupli pour les fonctions, gardé strict pour variables/classes), `func-names`, `no-underscore-dangle`, `no-console`, `no-plusplus`, `no-continue`, `no-bitwise`, `guard-for-in`, `no-restricted-syntax`, `prefer-destructuring`, `consistent-return`, `no-return-assign`, `no-nested-ternary`, `no-void`, `implicit-arrow-linebreak`, `no-unused-expressions`, `no-useless-concat`. - Plusieurs règles Airbnb désactivées **après vérification individuelle que le code existant les respecte déjà différemment** (voir section 5 pour le détail et le raisonnement de chacune) : `no-param-reassign`, `no-use-before-define` (assoupli pour les fonctions, gardé strict pour variables/classes), `func-names`, `no-underscore-dangle`, `no-console`, `no-plusplus`, `no-continue`, `no-bitwise`, `guard-for-in`, `no-restricted-syntax`, `prefer-destructuring`, `consistent-return`, `no-return-assign`, `no-nested-ternary`, `no-void`, `implicit-arrow-linebreak`, `no-unused-expressions`, `no-useless-concat`.
- `unused-imports/no-unused-vars` avec une liste blanche (`varsIgnorePattern`) d'environ 95 noms : fonctions invoquées uniquement depuis des attributs `onclick`/`onchange` inline dans les templates Jinja, invisibles pour l'analyse statique d'ESLint. - `unused-imports/no-unused-vars` avec une liste blanche (`varsIgnorePattern`) d'environ 95 noms : fonctions invoquées uniquement depuis des attributs `onclick`/`onchange` inline dans les templates Jinja, invisibles pour l'analyse statique d'ESLint.
- Bloc `globals` documentant les variables cross-fichiers volontaires (ex. `gameData`, `screensData` — voir section 5). - Bloc `globals` documentant les variables cross-fichiers volontaires (ex. `gameData`, `screensData` — voir section 5).
- **`eslint-plugin-unicorn`** (version `55.0.0` épinglée — la dernière exige ESLint ≥10, incompatible avec notre `^8.57.1`) : activé avec **UNIQUEMENT 9 règles explicitement listées**, jamais sa config `recommended` complète (qui en contient des dizaines d'autres, jamais évaluées pour ce projet — décision délibérée pour ne pas introduire un nouveau volume de règles non passées en revue). Choisi pour nettoyer le lot "modernisation JS" du rapport SonarQube (voir `docs/JS_MODERNIZATION_PLAN.md`) : `eslint-plugin-sonarjs` (l'équivalent officiel SonarSource) ne couvrait qu'une seule des règles visées. Les 9 règles activées, chacune avec un fixer `--fix` vérifié :
- `unicorn/prefer-number-properties` — `parseFloat`/`parseInt`/`isNaN`/`isFinite` → `Number.*`
- `unicorn/prefer-string-replace-all` — `.replace(/x/g, ...)` → `.replaceAll(...)`
- `unicorn/prefer-dom-node-dataset` — `getAttribute('data-x')` → `.dataset.x`
- `unicorn/prefer-includes` — `.indexOf(x) !== -1` → `.includes(x)`
- `unicorn/prefer-string-starts-ends-with` — comparaison manuelle de sous-chaîne → `.startsWith()`/`.endsWith()`
- `unicorn/prefer-modern-math-apis` — expression mathématique manuelle → `Math.hypot()` etc.
- `unicorn/prefer-at` — `arr[arr.length - 1]` → `arr.at(-1)`
- `unicorn/no-useless-fallback-in-spread` — `{...(x || {})}` → `{...x}`
- `unicorn/no-for-loop` — boucle `for` classique sur un itérable → `for...of`
### Stylelint (`.stylelintrc.json`) ### Stylelint (`.stylelintrc.json`)
- Base `stylelint-config-standard`. - Base `stylelint-config-standard`.
@@ -71,9 +81,8 @@ ignore de fichier entier ou de règle globale — voir section 4.
- `profile = "jinja"`, `max_line_length = 160`, `indent = 2`. - `profile = "jinja"`, `max_line_length = 160`, `indent = 2`.
- H021 (styles inline) : **backlog assumé, pas une exception corrigée** — voir section 6, ne pas confondre avec les vraies exceptions de la section 5. - H021 (styles inline) : **backlog assumé, pas une exception corrigée** — voir section 6, ne pas confondre avec les vraies exceptions de la section 5.
### SonarQube ### SonarQube — retiré pour l'instant
- **Local (développement continu)** : Docker Community Build + PostgreSQL (jamais la base H2 embarquée), tourne en permanence sur cette machine via `~/sonarqube-stack/docker-compose.yml` (WSL2/Ubuntu). Dashboard : **http://localhost:9000** — identifiants personnels, jamais consignés ici. Le job CI `sonarqube` (`.gitea/workflows/deploy.yml`) et le service prod `sonarqube`/`sonar-postgres` (`docker-compose.prod.yml`) ont été retirés le 18/09/2026 : l'accès au dashboard (local WSL2 et prod derrière Caddy) est resté bloqué par des soucis d'infrastructure réseau (redirection de port WSL2/pare-feu Hyper-V côté local, réseau Docker partagé avec Caddy pas encore en place côté prod) sans lien avec le code du moteur — mis de côté volontairement plutôt que de continuer à bloquer sur de l'infra. Le lot "modernisation JS" (voir `docs/JS_MODERNIZATION_PLAN.md`) s'est arrêté après le lot 3 (`S2486`) pour cette même raison : les lots 4+ dépendent de scores Sonar exacts (complexité cognitive notamment) qu'aucun proxy fiable ne remplace. À reprendre une fois l'accès rétabli — voir section 6.
- **CI (`sonar.forgebase.fr`)** : instance séparée, self-hébergée, scan à chaque push via `.gitea/workflows/deploy.yml` (job `sonarqube`), **non-bloquant** (`continue-on-error: true`) le temps que le rapport soit entièrement trié.
## 3. Lancer les checks en local ## 3. Lancer les checks en local
@@ -94,19 +103,11 @@ pre-commit run --all-files
# Suite de tests # Suite de tests
python -m pytest tests/ -q python -m pytest tests/ -q
node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
# Scan SonarQube local (rapport seul - jamais de correction automatique
# a partir de son seul resultat sans triage prealable, voir section 4)
# Necessite le stack Docker local demarre (voir section 2) et un token
# genere sur http://localhost:9000 (Mon compte > Security > Generate Token).
docker run --rm --network sonarqube-stack_default \
-v <chemin-absolu-du-repo>:/usr/src \
sonarsource/sonar-scanner-cli \
-Dsonar.host.url=http://sonarqube:9000 \
-Dsonar.token=<votre-token>
``` ```
SonarQube (local et CI/prod) retiré pour l'instant — voir section 2, sous-section "SonarQube — retiré pour l'instant".
## 4. Procédure de justification d'une exception ## 4. Procédure de justification d'une exception
1. **Jamais de correction mécanique sans comprendre la cause.** Avant de corriger un finding, déterminer s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur, architecture volontaire). 1. **Jamais de correction mécanique sans comprendre la cause.** Avant de corriger un finding, déterminer s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur, architecture volontaire).
@@ -134,10 +135,50 @@ docker run --rm --network sonarqube-stack_default \
| `db/assert_not_none.py:14` | `B101`/`S101`/`python:S7632` | Unique `assert` de narrowing de type restant dans tout le moteur, après centralisation de 11 sites dispersés (`ai/`, `routes/scenes/`, `screens/payload/`, `scripts/`) dans ce helper unique. Voir section 4 pour l'impossibilité structurelle de satisfaire Bandit+Ruff avec un seul `#`. | Session du 16/09/2026 | | `db/assert_not_none.py:14` | `B101`/`S101`/`python:S7632` | Unique `assert` de narrowing de type restant dans tout le moteur, après centralisation de 11 sites dispersés (`ai/`, `routes/scenes/`, `screens/payload/`, `scripts/`) dans ce helper unique. Voir section 4 pour l'impossibilité structurelle de satisfaire Bandit+Ruff avec un seul `#`. | Session du 16/09/2026 |
| `db/rows/delete_row.py`, `db/rows/get_row.py`, `db/rows/list_rows.py`, `screens/animations/update_animation_clip.py`, `screens/flow/add_flow_node.py`, `screens/scenes/add_scene_object.py` | `B608`/`S608`/`python:S7632` | Noms de table/colonnes construits uniquement à partir de `slugify()`/whitelists codées en dur (`_UPDATABLE_FIELDS`, `FLOW_NODE_FIELDS`), jamais d'une entrée arbitraire — valeurs toujours paramétrées (`?`). Distinct du cas `assert_not_none` ci-dessus (nature différente : construction de SQL, pas narrowing de type) — non couvert par ce refactor. | Session du 16/09/2026 | | `db/rows/delete_row.py`, `db/rows/get_row.py`, `db/rows/list_rows.py`, `screens/animations/update_animation_clip.py`, `screens/flow/add_flow_node.py`, `screens/scenes/add_scene_object.py` | `B608`/`S608`/`python:S7632` | Noms de table/colonnes construits uniquement à partir de `slugify()`/whitelists codées en dur (`_UPDATABLE_FIELDS`, `FLOW_NODE_FIELDS`), jamais d'une entrée arbitraire — valeurs toujours paramétrées (`?`). Distinct du cas `assert_not_none` ci-dessus (nature différente : construction de SQL, pas narrowing de type) — non couvert par ce refactor. | Session du 16/09/2026 |
| `static/js/play/bindings.js:179` (`gameData = newData`) | `javascript:S2703` | Pattern volontaire de scripts globaux (pas des modules ES) : `gameData`/`screensData` sont déclarés une fois par `let` dans le `<script>` inline de `templates/play.html`, chargé avant tous les `static/js/play/*.js` (ordre séquentiel vérifié, pas de `defer`/`async`). Réassignation légitime d'une variable déjà déclarée dans un scope global partagé — déjà documenté dans `.eslintrc.json` (`"gameData": "writable"`). | Session du 16/09/2026 | | `static/js/play/bindings.js:179` (`gameData = newData`) | `javascript:S2703` | Pattern volontaire de scripts globaux (pas des modules ES) : `gameData`/`screensData` sont déclarés une fois par `let` dans le `<script>` inline de `templates/play.html`, chargé avant tous les `static/js/play/*.js` (ordre séquentiel vérifié, pas de `defer`/`async`). Réassignation légitime d'une variable déjà déclarée dans un scope global partagé — déjà documenté dans `.eslintrc.json` (`"gameData": "writable"`). | Session du 16/09/2026 |
| `static/js/play/bindings.js:180` (`screensData = gameData.screens`) | `javascript:S2703` | Même bloc, même pattern, même fichier de déclaration que `gameData` ci-dessus : `let screensData = gameData.screens;` posé dans le même `<script>` inline de `templates/play.html:145` (juste après `gameData:144`), chargé avant `bindings.js` — déjà documenté dans `.eslintrc.json` (`"screensData": "writable"`). Lot 2 "modernisation JS". | Lot 2 "modernisation JS", 16/09/2026 |
| `static/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
| `static/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations, toutes dans ce même fichier | `javascript:S2703` | Déclarée ET réassignée exclusivement à l'intérieur de `collision-rules-editor.js` (aucun autre fichier n'y touche, vérifié par grep exhaustif) — Sonar la signale seulement parce que ce fichier est un script classique sans wrapper de module (comme tout `static/js/`), pas parce qu'un ordre de chargement entre fichiers serait en jeu ; c'est le cas le plus simple des 4 (état privé à un seul fichier). Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
| `static/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 |
| 31 sites `\|safe` (`templates/scene_edit.html`, `templates/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces 31 sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 | | 31 sites `\|safe` (`templates/scene_edit.html`, `templates/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces 31 sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 |
| `static/js/play/offline/filter-repeater-rows.js:10,11`, `static/js/screen_edit/panel-init.js:254,261`, `static/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 |
| `static/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 |
| `static/js/screen_edit/tree-panels.js:162` (`dragstart` galerie d'icônes), `:295` (`dragstart` arborescence) | `javascript:S2486` | `e.dataTransfer.setData(...)` sert uniquement à satisfaire l'exigence cross-navigateur de l'API HTML5 Drag (au moins un type MIME posé) — vérifié que les deux `drop` correspondants (lignes 177-182 et 320-344) lisent l'id glissé depuis une variable JS module (`draggedIconClass`/`treeDragElementId`), jamais `e.dataTransfer.getData(...)` : un échec de `setData` n'a donc aucun effet sur le comportement réel du glisser-déposer. | Lot 3 "modernisation JS", 16/09/2026 |
| `static/js/play/offline/xapi-client.js:72` (`forgeXapiActor`) | `javascript:S2486` | API SCORM absente ou non conforme — attendu hors d'un vrai LMS (ex. prévisualisation) — repli déjà en place sur un acteur anonyme générique. Comportement déjà documenté par le commentaire du bloc. | Lot 3 "modernisation JS", 16/09/2026 |
| `static/js/play/screens.js:89` (`runScreenBackgroundMusic`), `static/js/play/actions.js:333` (action "jouer_son") | `javascript:S2486` | `.play().catch(() => {})` — échec attendu du navigateur (lecture audio automatique bloquée tant qu'aucune interaction utilisateur n'a eu lieu), jamais une erreur applicative à signaler. | Lot 3 "modernisation JS", 16/09/2026 |
| `static/js/play/screens.js:192` (`applyAnimationClip`, clip `sprite`), `static/js/play/actions.js:340` (action `jouer_animation_sprite`) | `javascript:S2486` | **Corrigé, pas seulement documenté** : `JSON.parse(custom_keyframes / data_value)` invalide laissait `spriteData` retomber silencieusement sur `{}` (aucune animation jouée) sans aucun signal — `custom_keyframes`/`data_value` sont produits par l'éditeur, jamais tapés à la main, donc un JSON invalide ici trahit presque toujours un bug côté éditeur plutôt qu'une simple erreur de saisie. Un `console.warn('configuration sprite invalide', e)` a été ajouté dans les deux `catch` : signal devtools pour le créateur en test, **comportement joueur inchangé** (l'animation reste silencieusement absente). Couvert par un nouveau test (`static/js/play/__tests__/actions.test.js`, `runActionNode — jouer_animation_sprite avec data_value JSON invalide`) qui vérifie à la fois l'absence de crash/rendu cassé ET l'appel du `console.warn`. | Lot 3 "modernisation JS", 16/09/2026 |
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
Méthode : pour chaque regex distincte flaguée, construction d'entrées **adversariales** (choisies pour maximiser l'ambiguïté que Sonar soupçonne) via un script Node.js dédié, mesure du temps d'exécution à plusieurs tailles croissantes. Un vrai ReDoS montre une croissance **exponentielle** du temps avec la taille de l'entrée (doubler la taille multiplie le temps par un facteur, pas par une constante) — ici, le temps reste quasi-linéaire à toutes les tailles testées, y compris pour la structure la plus suspecte des 3.
**Pattern A** — `FORGE_REF_PATTERN` (`filter-repeater-rows.js:10`) et `_FILTER_REF_RE` (`panel-init.js:254`, copie identique) :
```
/^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/
```
Structure soupçonnée : deux groupes quantifiés (`[^.{}]+`) séparés par un littéral (`\.`). Non exploitable ici car les deux classes sont **négatives** et excluent explicitement le `.` qui les sépare — à une position donnée, il n'existe qu'un seul découpage possible entre les deux groupes (pas de chevauchement combinatoire).
Entrées testées : `` `{{` + 'a'.repeat(n) `` (pas de fermeture) et `` `{{` + 'a'.repeat(n) + '.' + 'b'.repeat(n) `` (point présent, pas de fermeture), `n` = 1 000 / 10 000 / 50 000 / 100 000.
Résultat mesuré : **1.39 ms à n=100 000** (pire cas). Vérifié aussi que le pattern reste correct sur une entrée valide (`{{Objet.champ}}` → capture `['Objet', 'champ']`).
**Pattern B** — `FORGE_VAR_REF_PATTERN` (`filter-repeater-rows.js:11`) et `_VAR_REF_RE` (`panel-init.js:261`, copie identique) :
```
/^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/
```
Structure soupçonnée : la plus proche d'un vrai ReDoS des 3 — un groupe répété par `*` dont une branche de l'alternance contient elle-même un `+` (proche du classique `(a+)*`). Non exploitable ici car chaque itération exige un caractère de tête exclusif (`.` ou `[`) qui est justement exclu de la classe négative interne (`[^.{}[\]]`) — aucune itération ne peut chevaucher la suivante.
Entrées testées : `` `{{$a` + '.b'.repeat(n) `` (répétition simple, pas de fermeture) et `` `{{$a` + '.b[0]'.repeat(n) `` (alternance des deux branches, pas de fermeture), `n` = 1 000 / 5 000 / 10 000 / 20 000.
Résultat mesuré : **0.95 ms à n=20 000 segments** (pire cas, forme alternée). Vérifié sur entrée valide (`{{$var.champ[0].sous}}` → capture `['var', '.champ[0].sous']`).
**Pattern C** — `xapi-client.js:132` :
```js
config.endpoint.replace(/\/+$/, '')
```
Structure soupçonnée : un seul groupe quantifié sur un littéral unique, ancré en fin de chaîne — le cas le plus simple des 3, sans groupe adjacent ni alternance avec qui entrer en ambiguïté.
Entrée testée : `'/'.repeat(n)`, `n` = 10 000 / 100 000 / 1 000 000.
Résultat mesuré : **1.38 ms à n=1 000 000** (le run à n=100 000 a affiché 13.89 ms, un pic de bruit de mesure — non reproductible et incohérent avec un temps plus court à n=1 000 000, donc pas un signal réel). Vérifié sur entrée valide (`'https://host///'.replace(...)` → `'https://host'`).
**Pour refaire ce test après une modification d'une de ces 3 regex** : `node docs/redos_probe_s8786.js` — script conservé dans le dépôt, directement exécutable, pas à reconstituer depuis la prose. Si le temps croît plus vite que linéairement (ex. ×100 quand la taille ×10), c'est un vrai ReDoS — sinon, le `NOSONAR` reste justifié.
## 6. Backlog qualité ## 6. Backlog qualité
- **djLint H021 (49 occurrences, 6 templates)** — styles inline à remplacer par un système de modèles/classes CSS réutilisables. **Priorité immédiate** après la clôture de ce dispositif, avant ou après la Phase 4 CI selon décision à prendre le moment venu. Ce n'est **pas** une exception documentée en section 5 : c'est une dette assumée et non traitée, à corriger, pas à justifier indéfiniment. - **djLint H021 (49 occurrences, 6 templates)** — styles inline à remplacer par un système de modèles/classes CSS réutilisables. **Priorité immédiate** après la clôture de ce dispositif, avant ou après la Phase 4 CI selon décision à prendre le moment venu. Ce n'est **pas** une exception documentée en section 5 : c'est une dette assumée et non traitée, à corriger, pas à justifier indéfiniment.
- **Code smells SonarQube (421 restants)** — analysés en volume (modernisation JS, complexité cognitive Python, duplication) mais pas encore triés catégorie par catégorie. Prochain lot après ce document. - **Code smells SonarQube (421 restants)** — lot JS "modernisation" (357 issues), plan détaillé et validé dans `docs/JS_MODERNIZATION_PLAN.md` (répartition par règle, classification mécanique/cas par cas, outillage vérifié, 9 lots) ; lots 1-3 (`S8786`, `S2703`, `S2486`) faits, lots 4-9 **en pause** — dépendent de scores Sonar exacts (voir section 2). Reste aussi la complexité cognitive Python et la duplication, non encore triées.
- **Trajectoire SonarQube CI bloquant** — une fois (a) le rapport de code smells trié et (b) une solution trouvée pour `Web:S5247` (ou acceptée comme limitation permanente), retirer `continue-on-error: true` du job `sonarqube` dans `.gitea/workflows/deploy.yml`. - **SonarQube (local + CI/prod) retiré, à réintégrer** — job CI et service prod retirés le 18/09/2026 (voir section 2). Une fois l'accès dashboard rétabli (local et/ou prod derrière Caddy) : reconfigurer le job `.gitea/workflows/deploy.yml`/le service `docker-compose.prod.yml`, reprendre le lot JS "modernisation" au lot 4, puis retirer `continue-on-error: true` une fois le rapport de code smells trié et `Web:S5247` résolu ou accepté comme limitation permanente.
-40
View File
@@ -24,46 +24,6 @@ services:
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
# SonarQube (rapport qualité — voir sonar-project.properties, CODE_QUALITY.md).
# Base PostgreSQL dédiée (jamais la H2 embarquée par défaut, pas adaptée
# en usage durable) — mêmes images que la stack locale (WSL,
# ~/sonarqube-stack/docker-compose.yml) pour rester cohérent.
# SONAR_DB_PASSWORD : requis, à définir dans le .env du serveur (jamais
# en dur ici) — même convention que REGISTRY_IMAGE ci-dessus.
# Port lié à 127.0.0.1 uniquement (jamais 0.0.0.0) : SonarQube n'est
# JAMAIS exposé directement sur internet, seul Caddy (sur l'hôte, voir
# sonar.forgebase.fr) reverse-proxy vers ce port local avec TLS
# (Let's Encrypt) devant.
sonar-postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: sonar
POSTGRES_PASSWORD: ${SONAR_DB_PASSWORD:?SONAR_DB_PASSWORD is required}
POSTGRES_DB: sonarqube
volumes:
- sonar_postgres_data:/var/lib/postgresql/data
sonarqube:
image: sonarqube:community
restart: unless-stopped
depends_on:
- sonar-postgres
environment:
SONAR_JDBC_URL: jdbc:postgresql://sonar-postgres:5432/sonarqube
SONAR_JDBC_USERNAME: sonar
SONAR_JDBC_PASSWORD: ${SONAR_DB_PASSWORD:?SONAR_DB_PASSWORD is required}
ports:
- "127.0.0.1:9000:9000"
volumes:
- sonarqube_data:/opt/sonarqube/data
- sonarqube_extensions:/opt/sonarqube/extensions
- sonarqube_logs:/opt/sonarqube/logs
volumes: volumes:
forge_projects: forge_projects:
forge_data: forge_data:
sonar_postgres_data:
sonarqube_data:
sonarqube_extensions:
sonarqube_logs:
+134
View File
@@ -0,0 +1,134 @@
# Plan — lot JS "modernisation" (357 issues SonarQube)
Trace de la stratégie validée avant exécution — même valeur que
`docs/SESSION_RECAP.md` pour la Phase 3 : ce document décrit le plan tel
que discuté et confirmé, pas un compte-rendu de ce qui a déjà été fait.
Pour l'état d'avancement réel lot par lot, voir les commits de ce
chantier (référencés ici au fur et à mesure).
Origine : rapport SonarQube local (`http://localhost:9000`), catégorie
"code smells" JavaScript, 357 occurrences réparties sur 22 règles.
## 1. Répartition exacte par règle Sonar
| Règle | Nom | Occurrences |
|---|---|---|
| `S7773` | Number static methods preferred over globals (`parseFloat`→`Number.parseFloat`, etc.) | 119 |
| `S6582` | Optional chaining should be preferred | 98 |
| `S7781` | `replaceAll()` instead of `replace()` with global regex | 29 |
| `S7761` | Data attributes via `.dataset` | 22 |
| `S7765` | `.includes()` instead of `.indexOf()`/`.lastIndexOf()` | 19 |
| `S3972` | Conditionals should start on new lines | 15 |
| `S3776` | Cognitive Complexity too high | 9 |
| `S4138` | `for-of` should be used with Iterables | 6 |
| `S4624` | Template literals should not be nested | 6 |
| `S2703` | Variables should be declared explicitly (var globale implicite) | 6 |
| `S8786` | Regex non-linear backtracking (ReDoS) | 5 |
| `S3358` | Ternary operators should not be nested | 4 |
| `S7744` | Unnecessary fallback objects in object spread | 3 |
| `S2486` | Exceptions should not be ignored | 3 |
| `S6557` | `startsWith()`/`endsWith()` instead of manual check | 3 |
| `S7721` | Function should be moved to highest possible scope | 2 |
| `S4144` | Functions should not have identical implementations | 2 |
| `S6653` | `Object.hasOwn()` instead of `hasOwnProperty` | 2 |
| `S6671` | Literal used for promise rejection (pas une `Error`) | 1 |
| `S7755` | `.at()` instead of `[…length - index]` | 1 |
| `S7769` | Modern Math APIs (`Math.hypot` etc.) | 1 |
| `S3735` | `void` should not be used | 1 |
| **Total** | | **357** |
## 2. Classification
### Mécanique, sans risque sémantique (équivalence prouvée)
| Règle | Pourquoi c'est sûr |
|---|---|
| `S7773` | `parseFloat`/`parseInt`/`isNaN`/`isFinite` sont des alias exacts de `Number.*` depuis ES2015 |
| `S7781` | `replace()`→`replaceAll()` seulement quand la regex a déjà le flag `g` (précondition de la règle) |
| `S7765` | `.indexOf(x) !== -1` ⇔ `.includes(x)` — équivalence stricte pour ce pattern |
| `S7761` | `getAttribute('data-x')`→`.dataset.x` — mécanique si nom d'attribut statique |
| `S7744` | Le spread de `null`/`undefined` ne lève jamais — le fallback `\|\| {}` est toujours redondant |
| `S6557`, `S6653`, `S7755`, `S7769` | Équivalences directes, très faible volume (7 sites), pas besoin d'outil |
| `S3972` | Pur formatage, zéro impact comportemental |
### ⚠️ Correction par rapport à l'hypothèse de départ : `S6582` n'est PAS mécanique
`a && a.b` et `a?.b` divergent quand `a` est une valeur **fausse mais
définie** (`0`, `""`, `false`, `NaN`) : `a && a.b` s'arrête et renvoie
`a`, `a?.b` continue et accède à `.b`. Une transformation aveugle peut
changer le comportement. Malgré son volume (98, 2ᵉ plus grosse règle),
`S6582` est traité en examen au cas par cas, pas en lot mécanique.
### Nécessite un vrai examen au cas par cas
| Règle | Nature | Note |
|---|---|---|
| `S6582` (98) | Sémantique | Voir ci-dessus — sous-lots avec vérification du type de la valeur testée |
| `S8786` (5) | **Sécurité-adjacent** | Sites JS, distincts de l'exception Python déjà validée en Phase 3 (`auth/email_validation.py`) |
| `S2703` (6) | **Sécurité-adjacent** | 1 seul des 6 sites déjà examiné et validé (`bindings.js:179`, `gameData`) — 5 restent à vérifier, ne pas supposer qu'ils sont identiques |
| `S2486` (3) | **Sécurité-adjacent** | Exception avalée, peut masquer un vrai bug |
| `S3776` (9) | Refactor réel | Pas de fix mécanique — restructuration fonction par fonction |
| `S4624` (6), `S3358` (4) | Lisibilité | Extraction manuelle |
| `S4138` (6) | Risque de perte de l'index | Fix proposé par un outil mais vérifié site par site avant application |
| `S4144` (2), `S7721` (2) | Décision de conception | Dédupliquer/déplacer une fonction change la portée |
| `S6671` (1) | Change le type de la valeur de rejet | Impact possible sur du code aval qui inspecterait `.message`/`instanceof Error` |
| `S3735` (1) | Dépend de l'intention | À lire avant de trancher |
## 3. Outillage — vérifié empiriquement
- **`eslint-plugin-sonarjs`** (officiel SonarSource, 4.2.1, dernière version) : ne couvre qu'**une seule** des 22 règles avec un fixer (`S3972`) — les règles `S77xx` sont trop récentes pour cette version.
- **`eslint-plugin-unicorn`** : dernière version (75.0.0) exige ESLint ≥10 (incompatible avec notre 8.57.1). Version retenue : **55.0.0** (exige ESLint ≥8.56.0, compatible), ajoutée en devDependency permanente. 9 règles activées dans `.eslintrc.json` avec un fixer confirmé (`fixable: 'code'` vérifié dans le code source du plugin) :
- `unicorn/prefer-number-properties` (S7773)
- `unicorn/prefer-string-replace-all` (S7781)
- `unicorn/prefer-dom-node-dataset` (S7761)
- `unicorn/prefer-includes` (S7765)
- `unicorn/prefer-string-starts-ends-with` (S6557)
- `unicorn/prefer-modern-math-apis` (S7769)
- `unicorn/prefer-at` (S7755)
- `unicorn/no-useless-fallback-in-spread` (S7744)
- `unicorn/no-for-loop` (S4138)
- Uniquement ces 9 règles activées, jamais la config `recommended` complète du plugin (qui en contient des dizaines d'autres, jamais évaluées pour ce projet) — voir `CODE_QUALITY.md` section 2.
- **Dry-run réel** (`--fix-dry-run`) sur tout `static/js/**/*.js` avec ces 9 règles : **222 problèmes détectés, 222 avec un fix proposé.** Les comptes par règle ne correspondent pas exactement à ceux de Sonar (moteurs différents, ex. `prefer-includes` détecte 41 cas contre 19 pour `S7765` côté Sonar) — normal, la vérification finale se fait via un rescan Sonar après application, pas via le compte ESLint.
- **`@typescript-eslint`** (candidat pour combler `S6582`) : nécessite de remplacer tout le parser JS par le parser TypeScript — écarté, trop lourd pour un projet 100% JS.
- **`Object.hasOwn` (`S6653`, 2 sites) et `startsWith`/`endsWith` (`S6557`, 3 sites)** : aucun outil ne les a détectés dans le dry-run — 5 sites au total, correction manuelle directe.
## 4. Ordre de priorité
1. Sécurité-adjacent : `S8786` (5), `S2703` (6, dont 5 non-vérifiés), `S2486` (3) — 14 sites.
2. Structure/lisibilité à risque de bug : `S3776` (9), `S4624` (6), `S3358` (4), `S4144` (2), `S7721` (2), `S6671` (1), `S3735` (1) — 25 sites.
3. `S6582` optional chaining (98) — lot dédié à part.
4. Modernisation mécanique via outil (`S7773`, `S7781`, `S7765`, `S7761`, `S7744`, `S3972`, `S4138` vérifié) — le plus gros volume, le moins risqué, en dernier.
5. Reliquat manuel très faible volume (`S6557`, `S6653`, `S7755`, `S7769`) — 7 sites.
## 5. Découpage en 9 lots
| Lot | Contenu | Méthode |
|---|---|---|
| 1 | `S8786` (5) | Lecture individuelle, diff par site |
| 2 | `S2703` (5 restants, hors `gameData` déjà validé) | Lecture individuelle — même pattern ou différent ? |
| 3 | `S2486` (3) | Lecture individuelle |
| 4 | `S3776` (9) | Refactor fonction par fonction |
| 5 | `S4624` + `S3358` + `S4144` + `S7721` + `S6671` + `S3735` (16) | Groupés, petits volumes, revue individuelle |
| 6 | `S6582` (98) | Sous-lots de ~20-25, vérification du type à chaque site |
| 7 | `S7773`+`S7781`+`S7765`+`S7761`+`S7744`+`S3972` via `eslint-plugin-unicorn`@55.0.0 `--fix` | `--fix` en un coup, diff complet relu avant commit |
| 8 | `S4138` (fix proposé par `no-for-loop`, vérifié un par un) | Semi-automatique |
| 9 | `S6557`+`S6653`+`S7755`+`S7769` (7) | Manuel direct |
**À chaque lot** : diff complet montré avant application, suite de tests
JS (`node --test static/js/play/__tests__/*.test.js
static/js/play/offline/__tests__/*.test.js
static/js/scenes/__tests__/*.test.js`, 241 tests) après application,
signalement immédiat de tout changement de comportement réel (pas
seulement du style). Rescan SonarQube local après le lot 7 (le plus gros
volume mécanique) pour confirmer la réduction réelle plutôt que de se
fier au seul compte ESLint, puis un rescan final après le lot 9.
## Note de séquencement Git
Les 9 règles `unicorn/*` ont été activées dans `.eslintrc.json` (et
`eslint-plugin-unicorn` ajouté en devDependency) **avant** que les 222
occurrences correspondantes soient corrigées — ce qui rend `pre-commit`/
la CI rouges sur ESLint dès maintenant si ces changements de config
étaient commités seuls. Ces changements restent donc **non commités**
tant que le lot 7 (la correction mécanique) n'est pas fait, pour ne
jamais introduire un commit où le lint échouerait sur `dev`.
+242
View File
@@ -0,0 +1,242 @@
# Compte-rendu — mise en place qualité de code (2026-09-15)
Compte-rendu chronologique de la session ayant mis en place le dispositif
qualité de code du moteur (Phases 1 à 4 du plan). Documente ce qui a été
fait, trouvé et décidé — pour savoir comment utiliser les outils au
quotidien, voir `CODE_QUALITY.md` (à venir, Phase 5).
Commits de référence : `c57420c8` (Phase 3), `2ff127f6` et `20f9398b`
(Phase 4).
## 1. Contexte et objectif
Le moteur disposait déjà d'une suite de tests (591 tests Python, 241
tests JS) mais d'aucun outillage de qualité de code, d'architecture ou de
détection de code mort — rien n'empêchait un import circulaire, une
fonction non typée, une variable inutilisée ou un champ de formulaire
inaccessible d'atteindre la production. L'objectif de cette session :
mettre en place un dispositif **strictement strict** (aucune règle
désactivée "pour ne pas casser le build" — l'existant est corrigé pour
satisfaire la config, jamais l'inverse), puis nettoyer l'existant pour le
faire passer au vert, en 4 phases :
1. Configuration des outils.
2. Audit de l'existant (rapport initial).
3. Corrections par lots (typage, sécurité, architecture, a11y, ESLint/
Stylelint).
4. Intégration continue (CI) — pour que ces vérifications protègent
aussi un push direct ou une PR, pas seulement la machine de qui
committe.
## 2. Outils mis en place (Phase 1)
| Outil | Rôle |
|---|---|
| **Ruff** | Lint + formatage Python (remplace flake8/isort/black en un seul outil) |
| **Mypy** (`--strict`) | Typage statique Python — détecte les incohérences de type avant l'exécution |
| **Vulture** | Détection de code mort Python (fonctions/imports jamais utilisés) |
| **Bandit** | Analyse de sécurité Python (injections, primitives cryptographiques faibles, etc.) |
| **import-linter** | Fait respecter les couches applicatives (ex. `db/` ne doit dépendre d'aucun autre paquet du moteur) |
| **ESLint** (`airbnb-base`) | Lint JavaScript |
| **Stylelint** (`stylelint-config-standard`) | Lint CSS |
| **djLint** | Lint des templates Jinja |
| **SonarQube Community Build** | Analyse qualité/sécurité agrégée, auto-hébergée (Docker + PostgreSQL) sur `sonar.forgebase.fr` |
| **pre-commit** | Orchestre tous les hooks ci-dessus localement, bloquant, avant chaque commit |
## 3. Bilan chiffré avant/après (Phase 2-3)
État final, vérifié directement à plusieurs reprises au cours de la
session (dernière vérification avant le commit `c57420c8`) :
| Outil | Résultat final |
|---|---|
| Ruff (lint + format) | 0 erreur |
| Mypy `--strict` | 0 erreur sur 388 fichiers source |
| Vulture | 0 signalement |
| Bandit | 0 issue (23 suppressions `# nosec` documentées individuellement) |
| import-linter | 1 contrat ("Couches applicatives Forge Engine"), respecté |
| ESLint | 0 erreur (12 avertissements pré-existants `no-alert`, jugés acceptables) |
| Stylelint | 0 erreur |
| djLint | 49 erreurs H021 (styles inline) — **backlog assumé**, voir §8 |
**Note de transparence** : cette session n'a pas produit de rapport
d'audit initial formalisé et conservé (`CODE_QUALITY.md`/Phase 5 n'existe
pas encore) — un tableau détaillé "avant/après par paquet" avec des
comptes précis par outil n'est donc pas reconstituable après coup avec
certitude. Ce qui a été retenu au fil de la session :
- Le typage Mypy `--strict` a été déployé **paquet par paquet**, dans
l'ordre : `db` → `screens` → `auth` → `core` → `ai` → `routes` → puis
`publish`/`scripts`/`tests`/`app.py`/`build_css.py` — chaque paquet
validé à 0 erreur avant de passer au suivant. `routes/` était le plus
gros lot (366 signalements Mypy à lui seul avant correction).
`screens/` était le paquet le plus étendu en nombre de fichiers touchés
(95 fichiers dans le commit final).
- Ruff signalait de l'ordre de 800+ erreurs cumulées (F401/F811,
formatage, imports) avant nettoyage, toutes résolues.
- Un régression Vulture a été détectée et corrigée en cours de route (un
changement de forme d'import — groupé vs individuel — modifiait la
détection d'usage), traitée via `vulture_whitelist.py`.
Le rapport SonarQube (voir §8), lui, a été entièrement conservé et trié
point par point : **72 bugs**, **57 vulnérabilités**, **466 code
smells**, **0 security hotspot**, Quality Gate **OK**.
## 4. Vrais bugs trouvés et corrigés
Bugs de comportement réel (pas du style), chacun vérifié dans le diff du
commit `c57420c8` :
- **`db/definitions/create_definition.py` / `delete_definition.py`** —
un `relation_definition_id`/`definition_id` invalide faisait planter
la fonction avec un `TypeError` cru (indexation d'un `None` renvoyé par
`get_definition`). Remplacé par un `ValueError` explicite et lisible.
- **`db/connection.py` → `core/flask_app.py`** — inversion de dépendance
architecturale : `db/` (couche la plus basse) importait
`core.flask_app` en interne (`_install_teardown_safety_net`), en
violation du contrat import-linter. Extrait en
`install_teardown_safety_net(app)`, une fonction pure prenant l'app en
paramètre ; le câblage réel (l'import de `core.flask_app`) déplacé dans
un nouveau fichier `core/db_teardown_guard.py` (couche de câblage, qui
a le droit de dépendre des deux côtés).
- **`ai/chat.py::_describe_scene_state`** — si l'écran associé à une
conversation IA avait été supprimé entre-temps, la fonction plantait
avec un `TypeError` (indexation d'un `screen` à `None`). Ajout d'une
exception dédiée `ScreenDeletedError`, interceptée par
`run_chat_turn` pour renvoyer un message clair ("Cet écran a été
supprimé...") plutôt qu'un crash.
- **`routes/scenes/scene_object_geometry.py`** — condition de course :
l'objet de scène était relu (`get_scene_object`) **après** avoir
appliqué la mise à jour de géométrie, uniquement pour lire
`obj["scene_id"]` — si l'objet avait été supprimé entre-temps (onglet
concurrent), `obj` valait `None` et l'accès plantait. La vérification
d'existence est maintenant faite **avant** toute mise à jour, avec un
retour 404 propre si l'objet n'existe plus.
- **Fuite `</script>` dans le JSON embarqué (XSS potentiel)** — 25 sites
Python (`routes/scenes/scene_edit_view.py` ×22,
`routes/play/game_play.py` ×1, `publish/build_scorm_package.py` ×2)
utilisaient `json.dumps(...)` brut pour embarquer des données dans un
bloc `<script>` — un champ texte utilisateur (nom d'écran/objet/
variable, texte de dialogue...) contenant littéralement `</script>`
aurait refermé la balise prématurément et injecté du HTML/JS non
échappé sur la page. Nouveau helper partagé `db.json_for_script()`
(`json.dumps(data).replace("</", "<\\/")`), appliqué aux 25 sites ;
vérifié par un test dédié (rendu strictement identique quand aucun
`</` n'est présent, neutralisation confirmée sinon).
- **Fuite de handle fichier Windows (export SCORM)** —
`routes/publish/export_scorm.py` servait le zip exporté via
`send_file()` en mode passthrough, puis tentait de le supprimer
(`after_this_request`) pendant que Werkzeug tenait encore le fichier
ouvert — l'erreur (`PermissionError`) était silencieusement avalée par
un `contextlib.suppress(OSError)`, laissant le fichier temporaire sur
le disque à chaque export. Corrigé en bufferisant le zip en mémoire
(`io.BytesIO`) et en supprimant le fichier temporaire avant de
construire la réponse. Vérifié par un script de détection de fuite
(avant : un fichier de 7 Mo restait sur le disque après chaque export ;
après : aucune fuite).
- **Bug d'isolation de tests (compte admin partagé)** — la suite de
tests partage un seul compte admin sur toute une session pytest ; 22
sites (répartis dans `test_ai_tools.py`, `test_user_assets.py`,
`test_scene_object_add_asset.py`) créaient un "asset" sur ce compte
sans le nettoyer, polluant les tests suivants. Corrigé ponctuellement
aux 22 sites, **puis** doublé d'une fixture `autouse=True` dans
`tests/conftest.py` (`_cleanup_admin_assets`) qui nettoie
automatiquement tout asset créé pendant un test — filet de sécurité
pour un futur test qui oublierait le même nettoyage manuel.
## 5. Code mort supprimé
Chaque suppression vérifiée par une recherche exhaustive de toute
référence restante dans le code (pas seulement le signalement Vulture) :
- `db/definitions/add_field_to_definition.py`, `delete_field.py`,
`rename_definition.py`, `update_field.py`
- `db/rows/relation_options.py`, `rows_referencing.py`
- `screens/clause_list_codec.py`
- `screens/rendering/trigger_for.py`
Pour chacun : recherche du nom de la fonction/fichier dans tout le
dépôt (`grep` récursif) après suppression — zéro référence restante dans
le code exécutable (au pire une mention dans un commentaire de
documentation, sans impact fonctionnel).
## 6. Décisions de configuration vs changements de code
Cas où la configuration a été adaptée aux conventions réelles et déjà
établies du projet plutôt que de réécrire massivement le code existant :
| Règle désactivée/ajustée | Fichier | Raison |
|---|---|---|
| `selector-class-pattern`/`selector-id-pattern: null` | `.stylelintrc.json` | Le projet utilise du camelCase pour ses classes/id CSS (`.canvasElement`, `#scormProgress`...) depuis le début — `stylelint-config-standard` impose du kebab-case par défaut ; renommer aurait touché des milliers de sélecteurs et leurs usages JS/HTML pour un gain de lisibilité nul |
| `no-descending-specificity: null` | `.stylelintrc.json` | Le CSS existant est organisé par composant/fonctionnalité, pas par ordre strict de spécificité — se conformer aurait demandé de réordonner une grande partie du fichier |
| `no-param-reassign: "off"` | `.eslintrc.json` | Convention déjà répandue dans le code (réassignation de paramètres pour la commodité, ex. petites moulinettes sur `event`/élément) — vérifiée site par site avant de désactiver la règle plutôt que de tout réécrire |
| `no-use-before-define` limité aux variables/classes (`functions: false`) | `.eslintrc.json` | Pattern de callback "classique" très répandu : des gestionnaires `onclick="foo()"` posés dans le HTML référencent des fonctions déclarées plus bas dans le fichier JS — le hoisting des déclarations de fonction rend ça sûr, contrairement aux variables |
| `func-names`, `no-underscore-dangle`, `no-console`, `no-plusplus`, `no-continue`, `no-bitwise`, `guard-for-in`, `no-restricted-syntax`, `prefer-destructuring`, `consistent-return`, `no-return-assign`, `no-nested-ternary`, `no-void`, `implicit-arrow-linebreak`, `no-unused-expressions`, `no-useless-concat` : `"off"` | `.eslintrc.json` | Chacune vérifiée individuellement comme sûre pour ce code avant désactivation (style déjà cohérent dans le projet, ex. underscore-prefixed pour le "privé" par convention) |
| `max-len` porté à 120 | `.eslintrc.json` | Convention déjà en usage dans le code Python (Ruff `line-length = 120`) — alignée côté JS plutôt que forcer un retour à 80 sur tout le dépôt |
| `unused-imports/no-unused-vars` avec liste blanche de ~95 noms | `.eslintrc.json` | Fonctions invoquées uniquement depuis des attributs `onclick`/`onchange` inline dans les templates Jinja — invisibles pour l'analyse statique d'ESLint (qui ne lit pas le HTML), mais réellement utilisées |
## 7. Intégration CI (Phase 4)
`.gitea/workflows/deploy.yml` — déclenché sur chaque push (`main` et
`dev`) :
- **`lint-python`** (bloquant) — rejoue `ruff check`/`ruff format
--check`/`mypy .`/`vulture`/`bandit`/`lint-imports` dans une image
Docker jetable (mêmes commandes que `.pre-commit-config.yaml`, jamais
dupliquées avec des paramètres différents). `djlint` volontairement
exclu (backlog H021, voir §8).
- **`lint-js`** (bloquant) — `npm run lint:js` / `npm run lint:css` dans
une image `node:20-slim`.
- **`sonarqube`** (`continue-on-error: true`, non-bloquant) — scan
contre l'instance self-hébergée `sonar.forgebase.fr`. N'est **pas**
dans le `needs` de `build-and-push` : un échec ici n'affecte jamais le
reste du pipeline.
- **`build-and-push`** dépend désormais de `test-python`, `test-js`,
`lint-python` **et** `lint-js`.
- **`deploy`** sécurisé : le token du registre passe par
`--password-stdin` (jamais en argument `-p`, visible via `ps` sur le
serveur) ; la clé SSH privée est supprimée en fin de job
(`if: always()`, `rm -f` — ne peut jamais échouer même si le fichier
n'a jamais été créé).
Premier run réel après le push de `2ff127f6` (run CI Gitea #96, commit
`2ff127f6`) : `test-python` ✅, `test-js` ✅, `lint-python` ✅, `lint-js`
✅, `sonarqube` ❌ (attendu — infra pas encore prête, voir §8),
conclusion globale du run : **succès** (confirmant que le mode
non-bloquant de `sonarqube` fonctionne bien tel que configuré).
## 8. Points en suspens / backlog
- **djLint H021 (styles inline, 49 occurrences sur 6 templates)** —
backlogué explicitement, jamais désactivé silencieusement (documenté
dans chaque commit qui l'a sauté via `SKIP=djlint`). **Priorité
immédiate après cette session**, voir §9.
- **SonarQube en CI** — échoue actuellement (`Failed to query server
version`) : l'endpoint `sonar.forgebase.fr` (Caddy + Let's Encrypt)
n'est pas encore pleinement opérationnel côté serveur perso. Aucune
date communiquée pour la mise en service — à vérifier avec l'infra
avant de s'attendre à un scan CI qui aboutit.
- **Rapport SonarQube — 466 code smells** — analysés en volume mais non
triés individuellement (contrairement aux 72 bugs et 57 vulnérabilités,
entièrement passés en revue point par point cette session). Lot séparé
à traiter après la Phase 4.
- **Trajectoire vers un SonarQube bloquant en CI** — une fois (a) l'infra
serveur opérationnelle et (b) les code smells triés (faux positifs
documentés en `NOSONAR`, comme déjà fait pour les 72 bugs/57
vulnérabilités), retirer `continue-on-error: true` du job `sonarqube`
et l'ajouter au `needs` de `build-and-push`.
- **Phase 5 (CODE_QUALITY.md)** — non commencée : documentation de
référence sur les outils et comment les utiliser au quotidien (ce
fichier-ci n'en tient pas lieu, voir l'introduction).
## 9. Prochaines étapes
**Priorité immédiate** (juste après cette session, avant ou après la
Phase 4 selon décision à prendre le moment venu) : supprimer le style
inline (djLint H021) au profit d'un système de modèles/classes CSS
réutilisables, plutôt que de continuer à accumuler des exceptions sur
cette règle.
Ensuite, dans l'ordre discuté : trier les 466 code smells SonarQube,
activer SonarQube en CI dès que l'infra serveur est prête, puis rédiger
`CODE_QUALITY.md` (Phase 5).
+57
View File
@@ -0,0 +1,57 @@
// Script de verification empirique pour les 5 sites NOSONAR javascript:S8786
// (voir CODE_QUALITY.md, section 5) - teste les 3 regex distinctes flaguees
// (2 sont dupliquees dans 2 fichiers chacune) contre des entrees construites
// pour maximiser l'ambiguite que l'analyseur Sonar soupconne, a plusieurs
// tailles croissantes. Un vrai ReDoS montrerait une croissance exponentielle
// du temps avec la taille (x10 la taille -> beaucoup plus que x10 le temps).
//
// A relancer si l'une de ces 3 regex change de forme, pour confirmer que le
// NOSONAR reste justifie plutot que de le recopier aveuglement.
//
// Usage : node docs/redos_probe_s8786.js
function timeIt(label, fn) {
const start = process.hrtime.bigint();
const result = fn();
const end = process.hrtime.bigint();
const ms = Number(end - start) / 1e6;
console.log(`${label}: ${ms.toFixed(2)}ms -> match=${result !== null}`);
return ms;
}
console.log('=== Pattern A (filter-repeater-rows.js:10, panel-init.js:254) ===');
const patternA = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
for (const n of [1000, 10000, 50000, 100000]) {
const evil = `{{${'a'.repeat(n)}`; // pas de point, pas de fermeture
timeIt(` n=${n} (pas de point, pas de fermeture)`, () => patternA.exec(evil));
}
for (const n of [1000, 10000, 50000, 100000]) {
const evil = `{{${'a'.repeat(n)}.${'b'.repeat(n)}`; // point present, pas de fermeture
timeIt(` n=${n} (point present, pas de fermeture)`, () => patternA.exec(evil));
}
console.log('');
console.log('=== Pattern B (filter-repeater-rows.js:11, panel-init.js:261) ===');
const patternB = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
for (const n of [1000, 5000, 10000, 20000]) {
const evil = `{{$a${'.b'.repeat(n)}`; // repetition simple, pas de fermeture
timeIt(` n=${n} (${n} segments ".b", pas de fermeture)`, () => patternB.exec(evil));
}
for (const n of [1000, 5000, 10000, 20000]) {
const evil = `{{$a${'.b[0]'.repeat(n)}`; // alternance des deux branches
timeIt(` n=${n} (${n} segments ".b[0]" alternes, pas de fermeture)`, () => patternB.exec(evil));
}
console.log('');
console.log('=== Pattern C (xapi-client.js:132) ===');
const patternC = /\/+$/;
for (const n of [10000, 100000, 1000000]) {
const evil = '/'.repeat(n);
timeIt(` n=${n} (que des slashes)`, () => patternC.exec(evil));
}
console.log('');
console.log('=== Verification d\'equivalence (entrees valides normales, comportement inchange attendu) ===');
console.log('A sur "{{Objet.champ}}":', patternA.exec('{{Objet.champ}}'));
console.log('B sur "{{$var.champ[0].sous}}":', patternB.exec('{{$var.champ[0].sous}}'));
console.log('C sur "https://host///":', 'https://host///'.replace(patternC, ''));
+25 -1
View File
@@ -11,7 +11,7 @@ global.window = global.window || {};
global.gameData = global.gameData || {}; global.gameData = global.gameData || {};
const { const {
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations, resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
applyObjectProperty, clampSceneObjectPosition, applyObjectProperty, clampSceneObjectPosition, runActionNode,
} = require('../actions.js'); } = require('../actions.js');
function fakeImg() { function fakeImg() {
@@ -147,3 +147,27 @@ test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène
assert.equal(el.style.left, '80px'); // 100 - 20, jamais au-delà assert.equal(el.style.left, '80px'); // 100 - 20, jamais au-delà
}); });
}); });
test('runActionNode — jouer_animation_sprite avec data_value JSON invalide : avertit en console, ne casse pas le rendu (bug S2486 : exception avalée en silence auparavant)', () => {
const el = fakeImg();
const originalDocument = global.document;
const originalWarn = console.warn;
const warnCalls = [];
global.document = { querySelector: () => el };
console.warn = (...args) => { warnCalls.push(args); };
try {
const result = runActionNode({
action_type: 'jouer_animation_sprite',
target_element_id: 'obj1',
data_value: '{not valid json',
});
assert.ok(result instanceof Promise, 'doit quand même renvoyer une Promise, comme un data_value valide');
assert.equal(el.src, '', 'aucune frame ne doit être posée — rendu inchangé, pas de crash');
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /configuration sprite invalide/);
} finally {
global.document = originalDocument;
console.warn = originalWarn;
stopAllSpriteAnimations();
}
});
+11 -2
View File
@@ -330,14 +330,23 @@ function runActionNode(node) {
// musique de fond de l'écran (runScreenBackgroundMusic() dans // musique de fond de l'écran (runScreenBackgroundMusic() dans
// screens.js, qui elle boucle et s'arrête au changement d'écran). // screens.js, qui elle boucle et s'arrête au changement d'écran).
// Fire-and-forget : ne bloque jamais la suite du graphe. // Fire-and-forget : ne bloque jamais la suite du graphe.
if (node.data_value) new Audio(node.data_value).play().catch(() => {}); if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md
return Promise.resolve(); return Promise.resolve();
} if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) { } if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
const spriteTargetId = node.target_element_id || node.target_object_id; const spriteTargetId = node.target_element_id || node.target_object_id;
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`); const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
if (targetEl) { if (targetEl) {
let spriteData = {}; let spriteData = {};
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ } try {
spriteData = JSON.parse(node.data_value || '{}');
} catch (e) {
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
// data_value est produit par l'éditeur, jamais tapé à la main — un
// JSON invalide ici trahit presque toujours un bug côté éditeur, pas
// une action ponctuelle. Comportement joueur inchangé : l'animation
// reste silencieusement absente (spriteData reste {}).
console.warn('configuration sprite invalide', e);
}
runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData)); runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData));
} }
return Promise.resolve(); return Promise.resolve();
@@ -7,8 +7,8 @@
// _compare() côté Python (chargé avant ce fichier dans templates/ // _compare() côté Python (chargé avant ce fichier dans templates/
// play.html), pas de troisième copie de cette logique. // play.html), pas de troisième copie de cette logique.
const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; // NOSONAR S8786 - classes negatives, decoupage sans ambiguite, teste jusqu'a 100k car. sans blowup (voir CODE_QUALITY.md)
const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; // NOSONAR S8786 - idem, teste jusqu'a 20k segments sans blowup (voir CODE_QUALITY.md)
const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g; const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur // Port de decode_clauses (screens/clause_list_codec.py) — repli sur
+2 -2
View File
@@ -69,7 +69,7 @@ function forgeXapiActor() {
return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } }; return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } };
} }
} }
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ } } catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ } // NOSONAR S2486, CODE_QUALITY.md
return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' }; return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' };
} }
@@ -129,7 +129,7 @@ function forgeXapiSendStatement(verbId, result, object) {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
if (result) statement.result = result; if (result) statement.result = result;
const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`; const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`; // NOSONAR S8786 - un seul quantificateur sur 1 litteral, teste jusqu'a 1M car. sans blowup (voir CODE_QUALITY.md)
try { try {
fetch(endpoint, { fetch(endpoint, {
method: 'POST', method: 'POST',
+11 -2
View File
@@ -86,7 +86,7 @@ function runScreenBackgroundMusic(screenId) {
if (!url) return; if (!url) return;
currentBackgroundAudio = new Audio(url); currentBackgroundAudio = new Audio(url);
currentBackgroundAudio.loop = true; currentBackgroundAudio.loop = true;
currentBackgroundAudio.play().catch(() => {}); currentBackgroundAudio.play().catch(() => {}); // NOSONAR S2486 - autoplay bloque, voir CODE_QUALITY.md
} }
// Voir le commentaire sur #playFrame dans le <style> de play.html : la // Voir le commentaire sur #playFrame dans le <style> de play.html : la
@@ -189,7 +189,16 @@ function applyAnimationClip(clip) {
// assigné, gameData.personnage_animations, pas figées au moment où // assigné, gameData.personnage_animations, pas figées au moment où
// le clip a été configuré). // le clip a été configuré).
let spriteData = {}; let spriteData = {};
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* invalide : rien à jouer */ } try {
spriteData = JSON.parse(clip.custom_keyframes || '{}');
} catch (e) {
// Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) :
// custom_keyframes est produit par l'éditeur, jamais tapé à la
// main — un JSON invalide ici trahit presque toujours un bug côté
// éditeur, pas une action ponctuelle. Comportement joueur inchangé :
// l'animation reste silencieusement absente (spriteData reste {}).
console.warn('configuration sprite invalide', e);
}
spriteData.loop = infinite; spriteData.loop = infinite;
setTimeout(() => { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000); setTimeout(() => { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
} else { } else {
@@ -28,6 +28,7 @@ function makeEl() {
getAttribute() { return null; }, getAttribute() { return null; },
addEventListener() {}, addEventListener() {},
removeEventListener() {}, removeEventListener() {},
dataset: {},
}; };
Object.defineProperty(el, 'innerHTML', { Object.defineProperty(el, 'innerHTML', {
get() { return this._html; }, get() { return this._html; },
@@ -48,6 +49,9 @@ function setupGlobals() {
global.ELEMENT_VISIBILITY_LABELS_MAP = { visible: 'Visible' }; global.ELEMENT_VISIBILITY_LABELS_MAP = { visible: 'Visible' };
global.VIDEO_MODE_LABELS_MAP = { plein_ecran: 'Plein écran' }; global.VIDEO_MODE_LABELS_MAP = { plein_ecran: 'Plein écran' };
global.USER_ASSETS_OPTIONS = []; global.USER_ASSETS_OPTIONS = [];
// Nécessaire depuis le chargement de scene-editor.js (voir plus bas) :
// initScenePosFields() la lit dès son propre appel top-level.
global.CURRENT_SELECTED_ID = null;
const elementsById = {}; const elementsById = {};
global.document = { global.document = {
@@ -75,6 +79,11 @@ function setupGlobals() {
setupGlobals(); setupGlobals();
loadAsScript('../collision-rules-editor.js'); loadAsScript('../collision-rules-editor.js');
loadAsScript('../../triggers/trigger-editor.js'); loadAsScript('../../triggers/trigger-editor.js');
// scene-editor.js APRÈS trigger-editor.js, exactement l'ordre de chargement
// réel de templates/scene_edit.html (voir CODE_QUALITY.md, S2703) —
// nécessaire pour tester refreshSceneObjectNames() en conditions réelles,
// contre la vraie déclaration `SCENE_OBJECT_NAMES` de trigger-editor.js.
loadAsScript('../scene-editor.js');
test('assistant — une chaîne "attendre -> puis dialogue" sauvegarde bien LES DEUX, dans le bon ordre', () => { test('assistant — une chaîne "attendre -> puis dialogue" sauvegarde bien LES DEUX, dans le bon ordre', () => {
// Bug corrigé : "Rien de plus" renvoyait la DERNIÈRE feuille ajoutée // Bug corrigé : "Rien de plus" renvoyait la DERNIÈRE feuille ajoutée
@@ -212,3 +221,27 @@ test('triggerActionChainHtml — un bouton "+ Ajouter une action" apparaît apr
assert.ok(html.includes("screenTriggerOpenAppendActionModal('a_1')")); assert.ok(html.includes("screenTriggerOpenAppendActionModal('a_1')"));
assert.ok(html.includes("screenTriggerOpenAppendActionModal('d_1')")); assert.ok(html.includes("screenTriggerOpenAppendActionModal('d_1')"));
}); });
test('refreshSceneObjectNames — SCENE_OBJECT_NAMES doit rester réassignable depuis scene-editor.js (bug S2703 : déclarée en `const` dans trigger-editor.js, la réassignation levait TypeError et la liste ne se mettait jamais à jour sans recharger la page)', async () => {
setupGlobals();
global.fetch = function (url) {
assert.equal(url, '/game/pytest_wizard/scene-object-names');
return Promise.resolve({ ok: true, json: () => Promise.resolve(['Marc', 'Damien']) });
};
global.refreshSceneObjectNames();
// fetch(...).then(...).then(...) est asynchrone (2 sauts de microtâches) —
// laisse la file d'attente se vider avant de vérifier le résultat.
await new Promise((resolve) => { setTimeout(resolve, 0); });
// SCENE_OBJECT_NAMES est déclarée en `let`/`const` (pas `var`) : comme
// dans un vrai navigateur, cette liaison ne devient JAMAIS une propriété
// de l'objet global — il faut la relire via une autre exécution dans le
// même environnement lexical partagé (vm.runInThisContext), pas via
// `global.SCENE_OBJECT_NAMES` (toujours `undefined`, quel que soit le
// résultat réel du bug).
assert.deepEqual(vm.runInThisContext('SCENE_OBJECT_NAMES'), ['Marc', 'Damien']);
// Preuve que triggerSpeakerOptions() (trigger-editor.js) voit bien le
// nouveau nom SANS rechargement de page — exactement le symptôme rapporté.
assert.ok(global.triggerSpeakerOptions(null).includes('Damien'));
});
+2 -2
View File
@@ -251,14 +251,14 @@ function bindClauseListDefinitionSelect(defSelId) {
// d'une expression s'il les rencontrait telles quelles). // d'une expression s'il les rencontrait telles quelles).
const _FILTER_REF_OPEN = '{' + '{'; const _FILTER_REF_OPEN = '{' + '{';
const _FILTER_REF_CLOSE = '}' + '}'; const _FILTER_REF_CLOSE = '}' + '}';
const _FILTER_REF_RE = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; const _FILTER_REF_RE = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; // NOSONAR S8786 - classes negatives, decoupage sans ambiguite, teste jusqu'a 100k car. sans blowup (voir CODE_QUALITY.md)
// Une paire d'accolades doublées entourant "$nom_variable" (voir // Une paire d'accolades doublées entourant "$nom_variable" (voir
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une // _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE // variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un // ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la // chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
// variable, utile pour une variable "objet"/"tableau". // variable, utile pour une variable "objet"/"tableau".
const _VAR_REF_RE = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; const _VAR_REF_RE = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; // NOSONAR S8786 - idem, teste jusqu'a 20k segments sans blowup (voir CODE_QUALITY.md)
function _filterValueDefinitionId(objName) { function _filterValueDefinitionId(objName) {
const opt = document.querySelector(`.filterValueObjSel option[value="${CSS.escape(objName)}"]`); const opt = document.querySelector(`.filterValueObjSel option[value="${CSS.escape(objName)}"]`);
+5 -5
View File
@@ -29,7 +29,7 @@ function restoreTreeCollapsedState() {
document.querySelectorAll('.elementTree .treeNode').forEach((node) => { document.querySelectorAll('.elementTree .treeNode').forEach((node) => {
const id = node.dataset.elementId; const id = node.dataset.elementId;
let collapsed = false; let collapsed = false;
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {} try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {} // NOSONAR S2486 - lecture localStorage optionnelle (etat replie/deplie), echec sans consequence, reste "deplie" par defaut
node.classList.toggle('collapsed', collapsed); node.classList.toggle('collapsed', collapsed);
}); });
} }
@@ -54,7 +54,7 @@ function saveFloatPanelState(key, state) {
try { try {
const current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}'); const current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state))); localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
} catch (e) {} } catch (e) {} // NOSONAR S2486 - localStorage optionnel, voir CODE_QUALITY.md
} }
function loadFloatPanelState(key) { function loadFloatPanelState(key) {
@@ -159,7 +159,7 @@ if (!window.__forgeIconGalleryDragBound) {
draggedIconClass = tile.dataset.iconClass; draggedIconClass = tile.dataset.iconClass;
tile.classList.add('dragging'); tile.classList.add('dragging');
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {} try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {} // NOSONAR S2486 - setData() cosmetique HTML5 (validite du drag cross-navigateur) ; le drop lit draggedIconClass (variable JS), jamais dataTransfer.getData()
}); });
document.addEventListener('dragend', (e) => { document.addEventListener('dragend', (e) => {
@@ -227,7 +227,7 @@ if (!window.__forgeTreeToggleBound) {
e.stopPropagation(); e.stopPropagation();
const node = btn.closest('.treeNode'); const node = btn.closest('.treeNode');
const collapsed = node.classList.toggle('collapsed'); const collapsed = node.classList.toggle('collapsed');
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {} try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {} // NOSONAR S2486 - ecriture localStorage optionnelle (etat replie/deplie), echec sans consequence
}); });
} }
@@ -292,7 +292,7 @@ if (!window.__forgeTreeDragBound) {
treeDragParentId = info.parentId; treeDragParentId = info.parentId;
row.classList.add('dragging'); row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {} try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {} // NOSONAR S2486 - setData() cosmetique HTML5 ; le drop lit treeDragElementId (variable JS), jamais dataTransfer.getData()
}); });
document.addEventListener('dragend', (e) => { document.addEventListener('dragend', (e) => {
+10 -1
View File
@@ -17,7 +17,16 @@
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas // scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
// seulement un personnage — proposés comme "qui parle" pour une réplique, // seulement un personnage — proposés comme "qui parle" pour une réplique,
// en plus de "Joueur" (toujours disponible, jamais un objet de scène). // en plus de "Joueur" (toujours disponible, jamais un objet de scène).
const SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : []; // `let`, pas `const` : réassignée depuis un AUTRE <script> (voir
// refreshSceneObjectNames(), static/js/scenes/scene-editor.js) — deux
// balises <script> classiques sur la même page partagent un même
// environnement lexical global, mais une liaison `const` posée dans l'une
// ne peut PAS être réassignée depuis l'autre (TypeError: Assignment to
// constant variable., vérifié empiriquement). Bug réel trouvé lors de
// l'audit S2703 : renommer un personnage puis ouvrir un dialogue de
// déclencheur SANS recharger la page ne voyait jamais le nouveau nom.
// eslint-disable-next-line prefer-const -- reassignee depuis scene-editor.js, voir commentaire ci-dessus
let SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : [];
// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/ // Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/
// .questBubble-pnj, static/style.css) même si le nom affiché est // .questBubble-pnj, static/style.css) même si le nom affiché est