diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md new file mode 100644 index 00000000..e4db0e35 --- /dev/null +++ b/CODE_QUALITY.md @@ -0,0 +1,143 @@ +# 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`, `screens`, `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 screens 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` → `screens` → `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` + plugin `unused-imports`. +- `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). + +### 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 +- **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é. + +## 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 screens 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/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 :/usr/src \ + sonarsource/sonar-scanner-cli \ + -Dsonar.host.url=http://sonarqube:9000 \ + -Dsonar.token= +``` + +## 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). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `