# 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 ```bash # 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 ` — le mot `nosec` doit être immédiatement précédé d'un `#` sur la ligne. - Ruff : `# noqa: ` — 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 - raison`, sur la ligne exacte. - SonarQube (JS) : `// NOSONAR - 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 `` (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 `