Files
Forge-Engine/CODE_QUALITY.md
T
williamandClaude Sonnet 5 ce750ec697 Implemente le mini-jeu Association (glisser-deposer par paires)
document_engine/labels/association_config.py (nouveau) : modele de
donnees, meme convention resolve_X/sanitize_X que quiz_config.py -
DEFAULT_ASSOCIATION_CONFIG, sanitize_association_config (chaque paire
doit avoir ses deux cotes non vides, sinon supprimee silencieusement ;
liste tronquee a MAX_PAIRS=8).

Cote serveur, routes/document/document_element_update.py revalide
desormais aussi l'association avant persistance (meme raisonnement que
pour le quiz). Le rendu (_render_association) affiche un resume reel
(nombre de paires) et, des qu'au moins une paire existe, un plateau de
glisser-deposer REELEMENT interactif en Mode Apercu
(_render_association_player) : les deux colonnes (termes/
correspondances) sont melangees independamment (random.shuffle,
melange d'affichage documente dans CODE_QUALITY.md) puis embarquees en
JSON dans un attribut data-assoc-config.

Cote editeur, le panneau Proprietes d'une association ("relier
visuellement deux champs qui vont ensemble") est une liste de paires
repetable, chaque ligne reliant visuellement un champ "Element" et un
champ "Correspondance" par un glyphe ↔. Le plateau jouable en Apercu
(static/document/js/document-editor.js) supporte deux facons de jouer,
toutes deux reelles : glisser-deposer HTML5 natif, ou cliquer une
carte puis son emplacement (repli pour les appareils sans support
fiable du drag) - bonne association verrouillee en vert, mauvaise
signalee puis reinitialisee, ecran de resultat une fois toutes les
paires associees.

Bug reel trouve ET corrige via simulation DOM complete (glisser-
deposer + clic simules, pas juste un chargement de page) : le
feedback visuel reutilisait la classe CSS du quiz via une
reaffectation de className qui supprimait au passage la classe
d'identite docAssocFeedback, rendant l'element introuvable des le
premier essai de match (aurait plante en usage reel des la premiere
tentative). Corrige en gardant toujours les deux classes ensemble.

Tests : 8 tests purs (tests/document/test_association_config.py, sans
Flask) + 1 test de route verifiant la sanitization a l'ecriture.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 50 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur paire invalide, rendu du plateau,
et simulation DOM du gameplay reel (glisser-deposer correct/incorrect,
clic-selection, progression, ecran de resultat) - script de
diagnostic non conserve dans le depot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:01:21 +02:00

33 KiB
Raw Blame History

Qualité de code — Forge Engine

Référence de fonctionnement du dispositif qualité mis en place (Phases 1 à 4). Pour le compte-rendu chronologique de ce qui a été fait/trouvé/ décidé pendant la mise en place, voir docs/SESSION_RECAP.md — ce fichier-ci documente uniquement l'état actuel et comment l'utiliser au quotidien.

Règle de base, non négociable : aucune règle de lint/typage n'est désactivée globalement dans un fichier de config sans validation explicite. Une exception ponctuelle est toujours une ligne de code (# noqa, # nosec, // NOSONAR) avec une raison précise, jamais un ignore de fichier entier ou de règle globale — voir section 4.

1. Vue d'ensemble

Outil Vérifie Portée Obligatoire / Avertissement
Ruff (lint) Erreurs Python, imports inutilisés, style *.py Obligatoire (bloquant CI + pre-commit)
Ruff (format) Formatage Python *.py Obligatoire
Mypy --strict Typage statique Python *.py Obligatoire
Bandit Sécurité Python (injections, primitives faibles) ai, auth, core, db, filters, publish, routes, game_engine, document_engine, scripts, app.py, build_css.py Obligatoire
Vulture Code mort Python mêmes dossiers que Bandit Obligatoire
import-linter Contrat de couches applicatives tout le code Python Obligatoire
ESLint (airbnb-base) Lint JavaScript static/js/**/*.js Obligatoire
Stylelint (stylelint-config-standard) Lint CSS styles/**/*.css Obligatoire
djLint Lint des templates Jinja templates/**/*.html Avertissement — H021 (styles inline) en backlog assumé, voir section 6
SonarQube (local + CI) Bugs/vulnérabilités/code smells/hotspots agrégés tout le dépôt Avertissement — non-bloquant en CI pour l'instant, voir section 3

2. Configuration de chaque outil

Ruff (pyproject.toml, [tool.ruff])

  • line-length = 120, target-version = "py313".
  • Règles activées : F (pyflakes), E/W (pycodestyle), B (bugbear), C4 (comprehensions), SIM (simplification), S (bandit-équivalent Ruff), I (imports).
  • per-file-ignores : S101/S106 désactivées pour tests/ uniquement (assert et mots de passe en dur sont la norme dans les tests, jamais en dehors).
  • Exclusions : projects/, user_assets/, data/, Bug/, regles/ (données runtime, jamais du code).

Mypy (pyproject.toml, [tool.mypy])

  • strict = true, python_version = "3.13".
  • explicit_package_bases = true + mypy_path = "." : nécessaire car tests/ n'a pas de __init__.py — sans ça mypy refuse de démarrer ("Source file found twice under different module names"). Ce n'est pas un assouplissement de strict, juste la résolution des chemins de module.
  • Mêmes exclusions que Ruff.

Bandit (pyproject.toml, [tool.bandit])

  • exclude_dirs : projects, user_assets, data, Bug, regles, tests (les tests contiennent des mots de passe/tokens de test en dur, jamais un vrai risque).
  • Portée explicite (-r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py) plutôt que tout le dépôt.

Vulture (pyproject.toml, [tool.vulture])

  • min_confidence = 80, paths = mêmes dossiers que Bandit + vulture_whitelist.py.
  • exclude = ["*/tests/*"].
  • Faux positifs structurels (routes Flask enregistrées par décorateur, hooks appelés par convention de nom) : whitelist dédiée dans vulture_whitelist.py, jamais un min_confidence abaissé globalement.

import-linter (pyproject.toml, [tool.importlinter])

  • Un seul contrat de type layers, du haut vers le bas : routes → ai | publish | core → game_engine | document_engine → auth | filters → db.
  • Une couche ne peut importer qu'une couche strictement en dessous d'elle, jamais au-dessus, jamais une couche sœur du même niveau.
  • 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 + 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.
  • selector-class-pattern/selector-id-pattern désactivées : le projet utilise du camelCase pour ses classes/id CSS depuis le début (.canvasElement, #scormProgress...) — imposer le kebab-case du preset aurait demandé de renommer des milliers de sélecteurs et leurs usages JS/HTML pour un gain nul.
  • no-descending-specificity désactivée : le CSS existant est organisé par composant/fonctionnalité, pas par ordre strict de spécificité.
  • ignoreFiles : static/vendor/**, static/style.css (bundle généré, contient du Bulma vendored).

djLint (pyproject.toml, [tool.djlint])

  • 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

  • CI (sonar.forgebase.fr) : job sonarqube (.gitea/workflows/deploy.yml), scan à chaque push, non-bloquant (continue-on-error: true) le temps que le rapport soit entièrement trié. Retiré le 18/09/2026 (voir historique ci-dessous), remis en place le 18/09/2026 une fois l'instance prod confirmée opérationnelle.
  • Service prod (docker-compose.prod.yml) : reste retiré côté dépôt pour l'instant — l'instance prod opérationnelle n'est pas (encore) pilotée par ce fichier de ce côté-ci ; à réintégrer explicitement si besoin.
  • Historique du retrait temporaire (16-18/09/2026) : l'accès au dashboard (local WSL2 et prod derrière Caddy) était 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 à ce moment) sans lien avec le code du moteur. Le lot "modernisation JS" (voir docs/JS_MODERNIZATION_PLAN.md) s'était 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 parfaitement (le SonarLint de l'IDE a servi de solution de contournement fonctionnelle en attendant, voir lots 4 en cours directement sur les fichiers ouverts dans l'éditeur).

3. Lancer les checks en local

# Un outil a la fois (memes commandes que .pre-commit-config.yaml)
ruff check .
ruff format --check .
mypy .
vulture
bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py
lint-imports
djlint templates
npx eslint "static/js/**/*.js"
npx stylelint "styles/**/*.css"

# Tout d'un coup (ce que fait un commit)
pre-commit run --all-files

# Suite de tests
python -m pytest tests/ -q
node --test static/game/js/play/__tests__/*.test.js static/game/js/play/offline/__tests__/*.test.js static/game/js/scenes/__tests__/*.test.js

SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloquante), instance locale toujours bloquée (voir historique dans cette même sous-section).

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).
  2. Une exception ponctuelle, jamais globale. Le commentaire de suppression va sur la ligne exacte signalée par l'outil — pas la ligne au-dessus, pas la ligne en dessous (piège vécu à plusieurs reprises cette session : un commentaire mal placé ne supprime rien du tout, silencieusement).
  3. La raison est précise, jamais vague. "Faux positif" seul ne suffit pas — expliquer pourquoi (ex. "table_name vient de slugify()+prefixe obj_, jamais d'une entrée brute").
  4. Format attendu par outil (vérifié empiriquement cette session, chaque outil a ses propres exigences de position du mot-clé) :
    • Bandit : # nosec <CODE> — le mot nosec doit être immédiatement précédé d'un # sur la ligne.
    • Ruff : # noqa: <CODE> — le mot noqa doit être immédiatement précédé d'un # sur la ligne. Bandit et Ruff peuvent coexister sur une même ligne physique en utilisant deux # distincts (# nosec B101 # noqa: S101 - raison) — c'est structurel, pas un choix de style : chaque outil cherche son propre mot-clé juste après un #, et aucun format à un seul # ne peut satisfaire les deux à la fois (testé empiriquement).
    • SonarQube (Python) : # NOSONAR <règle> - raison, sur la ligne exacte.
    • SonarQube (JS) : // NOSONAR <règle> - raison, sur la ligne exacte.
    • SonarQube (templates Jinja/HTML, règle Web:*) : aucune syntaxe trouvée qui fonctionne malgré plusieurs tentatives (commentaire Jinja {# #}, commentaire JS natif dans un <script>, bonne position de ligne) — voir la limitation documentée en section 5 (Web:S5247).
  5. Toute exception validée est répercutée ici (section 5), avec la date/le contexte. Une exception non documentée ici n'est pas considérée comme validée.
  6. Qui valide : aucune exception n'est appliquée sans validation explicite de l'utilisateur — présenter le choix avec ses compromis, jamais trancher seul quand plusieurs options légitimes existent.

5. Exceptions et faux positifs documentés

Site(s) Outil / règle Raison Contexte
core/flask_app.py:22 python:S4502 (Sonar) CSRF géré par core/csrf_guard.py — garde maison globale (@app.before_request), testée dans test_csrf.py, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. Phase 3
game_engine/data_actions/compute_operation.py (×2), static/game/js/play/offline/compute-operation.js (×2), static/game/js/scenes/collision-rules-editor.js (×2), static/game/js/triggers/trigger-editor.js, document_engine/rendering/render_document_element.py (×2, _render_association_player — ajouté le 20/09/2026) B311/S311/python:S2245/javascript:S2245 Tirage aléatoire de jeu (dé, id local d'UI, mélange des deux colonnes du mini-jeu Association) — jamais un usage cryptographique. Phase 3 ; complété le 20/09/2026
static/game/js/play/offline/xapi-client.js (18 sites) + static/game/js/play/offline/__tests__/xapi-client.test.js (2 sites) javascript:S5332 Identifiants du vocabulaire xAPI standard ADL (http://adlnet.gov/expapi/...), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le http:// fait partie du texte fixé par la spec. Le vrai endpoint réseau (config.endpoint) est toujours saisi par le créateur, jamais un littéral de ce fichier. Phase 3
publish/scorm_manifest.py B406 (Bandit) Seul fichier du dépôt qui touche du XML — uniquement en génération (xml.sax.saxutils.escape), jamais en parsing d'XML externe. Phase 3
scripts/game/build_demo_dialogues.py:61-63 python:S8371 (Sonar) Accès direct resp.headers["Location"] volontaire : script d'usage unique jamais exécuté en production, un KeyError cru est un échec au moins aussi clair qu'un .get() renvoyant None. Phase 3
static/game/js/scenes/collision-rules-editor.js (leafAction.then = nextLeaf) javascript:S7739 then est un champ métier ("action suivante de la chaîne"), jamais une promesse — vérifié qu'aucun site d'appel ne le passe à await/Promise.resolve(). Risque latent documenté plutôt que renommage (le nom est ancré dans le schéma JSON persisté en base et côté Python). Phase 3
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/game/scenes/, game_engine/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, game_engine/animations/update_animation_clip.py, game_engine/flow/add_flow_node.py, game_engine/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/game/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/game/play.html, chargé avant tous les static/game/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/game/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/game/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/game/js/scenes/scene-editor.js:214 (CURRENT_SELECTED_ID = null), static/game/js/screen_edit/tree-panels.js:392 (CURRENT_SELECTED_ID = selectedId || null) javascript:S2703 Déclarée en var (pas let/const) dans templates/game/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/game/js/scenes/collision-rules-editor.js:78 (let _collisionWizard = null;) et ses réassignations dans ce fichier, et static/game/js/triggers/trigger-editor.js:787,937 (triggerOpenAppendActionModal/screenTriggerOpenAppendActionModal, _collisionWizard = { bodyEl: body }; sans mot-clé) javascript:S2703 Même pattern de partage inter-scripts que SCENE_OBJECT_NAMES : trigger-editor.js réutilise TELLES QUELLES les étapes de l'assistant de collision-rules-editor.js (renderCollisionWizardChainStep/collisionWizardBuildLeafAction, qui ne lisent que .bodyEl — voir commentaire ligne 784-786 de trigger-editor.js) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. trigger-editor.js est chargé AVANT collision-rules-editor.js (templates/game/scene_edit.html:887-888), mais sans risque de TDZ : les deux réassignations de trigger-editor.js sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que collision-rules-editor.js (et son let _collisionWizard = null;) n'ait fini de se charger. Déjà documenté dans .eslintrc.json ("_collisionWizard": "writable"). Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026
static/game/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/game/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/game/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
32 sites |safe (templates/game/scene_edit.html, templates/game/play.html, templates/auth/register_2fa.html, templates/onboarding/onboarding_new.html, templates/game_dashboard_simple.html, templates/document/document_edit.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) ; (3) rendered_document (document_edit.html, ajouté le 20/09/2026) — même sous-groupe (1) : produit par document_engine.render_document, qui échappe (html.escape()) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py). 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 32 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 ; complété le 20/09/2026
static/game/js/play/offline/filter-repeater-rows.js:10,11, static/game/js/screen_edit/panel-init.js:254,261, static/game/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/game/js/play/dialogue-box-controller.js (forgeShowQuizBox, ligne du void widget.offsetWidth;) javascript:S3735 Force une lecture de mise en page (reflow) AVANT de reposer la classe is-active, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, void marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : void widget.offsetWidth; (S3735, "retirer void") → widget.offsetWidth; seule (S905, "expression sans effet — accepté par ESLint ici, no-unused-expressions est désactivé dans ce projet, mais pas par Sonar") → const _ = widget.offsetWidth; (S1481, "variable jamais lue — accepté par ESLint via varsIgnorePattern: ^_$, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : void restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. Lot 4 "modernisation JS", 18/09/2026
static/game/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/game/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/game/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/game/js/play/screens.js:89 (runScreenBackgroundMusic), static/game/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/game/js/play/screens.js:192 (applyAnimationClip, clip sprite), static/game/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/game/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
static/game/js/play/offline/filter-repeater-rows.js:24 (forgeDecodeClauses) javascript:S2486 Corrigé, même patron que ci-dessus : _filtres_json est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. console.warn('_filtres_json invalide, filtre ignoré', e) ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (filter-repeater-rows.test.js, forgeDecodeClauses — _filtres_json invalide). Lot 4 "modernisation JS", 18/09/2026
static/game/js/play/offline/filter-repeater-rows.js:59, :70 (forgeResolveVariablePath, JSON.parse + navigation .champ/[index]) javascript:S2486 Documenté, pas corrigé — nature différente du cas ci-dessus : ici rawValue est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un console.warn ici bruiterait la console à chaque usage légitime. Lot 4 "modernisation JS", 18/09/2026
static/document/js/document-editor.js (FORGE_DOC_STYLE_PRESETS, FORGE_DOC_SHAPE_KINDS, FORGE_DOC_SNAP_SIZE — 3 sites) eslint:no-var, eslint:vars-on-top Constantes de premier niveau déclarées en var plutôt que const : un <script src> de page est rejoué TEL QUEL par pjax.js (runScriptsIn) à chaque navigation interne — une redéclaration let/const au premier niveau lèverait SyntaxError: already declared à la 2e exécution (voir l'en-tête de static/pjax.js, et le commentaire d'en-tête de ce fichier). var est le seul mot-clé sûr à ce niveau ; tout le reste du fichier (état mutable, y compris à l'intérieur des fonctions) est bien en let/const, porté par window.forgeDocState plutôt que par des variables de module (même convention que static/game/js/scenes/scene-editor.js et les autres scripts de page existants, qui n'ont eux aucune constante de ce genre à déclarer). Session du 20/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 :

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 — lot JS "modernisation" (357 issues d'origine), 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+ repris le 18/09/2026 via SonarLint (IDE) fichier par fichier — pas de liste exhaustive centralisée pendant que le dashboard serveur restait inaccessible, donc pas de rescan global de confirmation avant que la CI sonarqube (voir ci-dessous) tourne à nouveau. Reste aussi la complexité cognitive Python et la duplication, non encore triées.
  • CI sonarqube restaurée (18/09/2026) — job remis en place dans .gitea/workflows/deploy.yml (non-bloquant) une fois l'instance prod confirmée opérationnelle. Le service docker-compose.prod.yml reste volontairement absent de ce dépôt (prod pilotée autrement de ce côté). Une fois le premier scan CI passé : comparer son rapport à ce qui a déjà été corrigé fichier par fichier via SonarLint, pour confirmer qu'aucun doublon/oubli, avant de retirer continue-on-error: true.