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:
co-authored by
Claude Sonnet 5
parent
55e81c0fbb
commit
66d8eaeae8
+12
-38
@@ -4,13 +4,12 @@ on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
|
||||
# Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et
|
||||
# dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les
|
||||
# outils qualité, rien n'empêchait un commit cassé d'atteindre la
|
||||
# production (voir l'audit qualité de la Phase 0 du plan). "build-and-
|
||||
# push"/"deploy", eux, restent réservés à main (via le filtre "if" sur
|
||||
# gitea.ref) — un push sur dev ne doit jamais redéployer la prod, seulement
|
||||
# faire tourner tests/lint/sonar.
|
||||
# Les jobs "test-*"/"lint-*" tournent sur CHAQUE push (main et dev) :
|
||||
# jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les outils
|
||||
# qualité, rien n'empêchait un commit cassé d'atteindre la production (voir
|
||||
# l'audit qualité de la Phase 0 du plan). "build-and-push"/"deploy", eux,
|
||||
# restent réservés à main (via le filtre "if" sur gitea.ref) — un push sur
|
||||
# dev ne doit jamais redéployer la prod, seulement faire tourner tests/lint.
|
||||
#
|
||||
# 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
|
||||
@@ -18,12 +17,11 @@ on:
|
||||
# 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
|
||||
# volontairement (49 H021 "styles inline" déjà en backlog assumé, voir
|
||||
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube,
|
||||
# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première
|
||||
# période — voir CODE_QUALITY.md pour la trajectoire vers un mode
|
||||
# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt
|
||||
# que de bloquer tout de suite sur des centaines de signalements pas
|
||||
# encore triés.
|
||||
# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité.
|
||||
#
|
||||
# SonarQube (job CI + service prod) retiré pour l'instant — voir
|
||||
# CODE_QUALITY.md pour le contexte (accès instance locale/prod bloqué par
|
||||
# des soucis d'infra réseau, mis de côté volontairement).
|
||||
|
||||
# Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) :
|
||||
# REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com)
|
||||
@@ -34,8 +32,6 @@ on:
|
||||
# DEPLOY_USER utilisateur SSH 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)
|
||||
# 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
|
||||
# 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/scenes/ static/js/scenes/
|
||||
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
|
||||
|
||||
lint-python:
|
||||
@@ -135,28 +131,6 @@ jobs:
|
||||
RUN npm run lint:css
|
||||
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:
|
||||
needs: [test-python, test-js, lint-python, lint-js]
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- 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`)
|
||||
- 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`.
|
||||
- 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
|
||||
- **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.
|
||||
- **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é.
|
||||
### SonarQube — retiré pour l'instant
|
||||
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.
|
||||
|
||||
## 3. Lancer les checks en local
|
||||
|
||||
@@ -94,19 +103,11 @@ pre-commit run --all-files
|
||||
|
||||
# Suite de tests
|
||||
python -m pytest tests/ -q
|
||||
node --test static/js/play/__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>
|
||||
node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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/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: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 |
|
||||
| `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é
|
||||
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -24,46 +24,6 @@ services:
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
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:
|
||||
forge_projects:
|
||||
forge_data:
|
||||
sonar_postgres_data:
|
||||
sonarqube_data:
|
||||
sonarqube_extensions:
|
||||
sonarqube_logs:
|
||||
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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, ''));
|
||||
@@ -11,7 +11,7 @@ global.window = global.window || {};
|
||||
global.gameData = global.gameData || {};
|
||||
const {
|
||||
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
||||
applyObjectProperty, clampSceneObjectPosition,
|
||||
applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||
} = require('../actions.js');
|
||||
|
||||
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à
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -330,14 +330,23 @@ function runActionNode(node) {
|
||||
// musique de fond de l'écran (runScreenBackgroundMusic() dans
|
||||
// screens.js, qui elle boucle et s'arrête au changement d'écran).
|
||||
// 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();
|
||||
} 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 targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
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));
|
||||
}
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// _compare() côté Python (chargé avant ce fichier dans templates/
|
||||
// play.html), pas de troisième copie de cette logique.
|
||||
|
||||
const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||
const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\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*\}\}$/; // NOSONAR S8786 - idem, teste jusqu'a 20k segments sans blowup (voir CODE_QUALITY.md)
|
||||
const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
|
||||
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur
|
||||
|
||||
@@ -69,7 +69,7 @@ function forgeXapiActor() {
|
||||
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' };
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ function forgeXapiSendStatement(verbId, result, object) {
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
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 {
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -86,7 +86,7 @@ function runScreenBackgroundMusic(screenId) {
|
||||
if (!url) return;
|
||||
currentBackgroundAudio = new Audio(url);
|
||||
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
|
||||
@@ -189,7 +189,16 @@ function applyAnimationClip(clip) {
|
||||
// assigné, gameData.personnage_animations, pas figées au moment où
|
||||
// le clip a été configuré).
|
||||
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;
|
||||
setTimeout(() => { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeEl() {
|
||||
getAttribute() { return null; },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dataset: {},
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
get() { return this._html; },
|
||||
@@ -48,6 +49,9 @@ function setupGlobals() {
|
||||
global.ELEMENT_VISIBILITY_LABELS_MAP = { visible: 'Visible' };
|
||||
global.VIDEO_MODE_LABELS_MAP = { plein_ecran: 'Plein écran' };
|
||||
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 = {};
|
||||
global.document = {
|
||||
@@ -75,6 +79,11 @@ function setupGlobals() {
|
||||
setupGlobals();
|
||||
loadAsScript('../collision-rules-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', () => {
|
||||
// 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('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'));
|
||||
});
|
||||
|
||||
@@ -251,14 +251,14 @@ function bindClauseListDefinitionSelect(defSelId) {
|
||||
// d'une expression s'il les rencontrait telles quelles).
|
||||
const _FILTER_REF_OPEN = '{' + '{';
|
||||
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
|
||||
// _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
|
||||
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
|
||||
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
|
||||
// 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) {
|
||||
const opt = document.querySelector(`.filterValueObjSel option[value="${CSS.escape(objName)}"]`);
|
||||
|
||||
@@ -29,7 +29,7 @@ function restoreTreeCollapsedState() {
|
||||
document.querySelectorAll('.elementTree .treeNode').forEach((node) => {
|
||||
const id = node.dataset.elementId;
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function saveFloatPanelState(key, state) {
|
||||
try {
|
||||
const current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
|
||||
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) {
|
||||
@@ -159,7 +159,7 @@ if (!window.__forgeIconGalleryDragBound) {
|
||||
draggedIconClass = tile.dataset.iconClass;
|
||||
tile.classList.add('dragging');
|
||||
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) => {
|
||||
@@ -227,7 +227,7 @@ if (!window.__forgeTreeToggleBound) {
|
||||
e.stopPropagation();
|
||||
const node = btn.closest('.treeNode');
|
||||
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;
|
||||
row.classList.add('dragging');
|
||||
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) => {
|
||||
|
||||
@@ -17,7 +17,16 @@
|
||||
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
|
||||
// seulement un personnage — proposés comme "qui parle" pour une réplique,
|
||||
// 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/
|
||||
// .questBubble-pnj, static/style.css) même si le nom affiché est
|
||||
|
||||
Reference in New Issue
Block a user