Files
Forge-Engine/CODE_QUALITY.md
T
williamandClaude Sonnet 5 b4e4e80b7c
Build and deploy / test-python (push) Successful in 10m10s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Successful in 4m44s
Build and deploy / lint-js (push) Failing after 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m8s
Panneau gauche à onglets : "Pages" (gestion complète) et "Mise en page" (bibliothèque)
Remplace le carrousel une-page-à-la-fois par un panneau dédié plein
hauteur : liste verticale scrollable de toutes les pages, réordonnage
par glisser OU boutons haut/bas (accessibilité clavier), renommer
(crayon, édition en ligne), supprimer (protégé contre la suppression
de la dernière page), ajouter. La bibliothèque d'éléments passe dans
un second onglet "Mise en page", contenu inchangé. "Mise en page"
actif par défaut au chargement.

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

34 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 (×7 : _render_association_player ×2, _render_memory_player ×1, _mots_place_word ×3, _mots_attempt_placement ×1 — les 3 derniers ajoutés le 20/09/2026 pour le mini-jeu Mots mêlés) 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, mélange des cartes du mini-jeu Memory, direction/position de placement + lettre de remplissage de la grille du mini-jeu Mots mêlés) — 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
31 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) — 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 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 et le 21/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
templates/document/document_edit.html (.docPageRow, rangées de l'onglet "Pages" du panneau gauche) + static/document/js/document-editor.js (forgeDocRenderPageManagerList) Web:S6819, Web:MouseEventWithoutKeyboardEquivalentCheck (Sonar) div role="button" tabindex="0" volontaire : chaque rangée contient de vrais <button> d'action (monter/descendre/renommer/supprimer, voir .docPageRowActions), qu'un <button> englobant ne pourrait pas contenir validement (imbrication de <button> invalide, le parseur HTML referme le bouton englobant trop tôt — même défaut structurel déjà rencontré et corrigé de la même façon ailleurs dans ce fichier). L'équivalent clavier (Entrée/Espace déclenche forgeDocSwitchPage, même effet que le clic) est posé côté JS (row.addEventListener('keydown', ...)), donc le finding clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les addEventListener attachés dynamiquement. Vérifié par un test jsdom dédié (rôle button, équivalent clavier fonctionnel). Session du 21/09/2026 ; renommé (panneau à onglets) le 23/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.