Ajoute CODE_QUALITY.md et corrige S7632 a la racine (helper assert_not_none)
- Nouveau db/assert_not_none.py : centralise l'unique suppression
Bandit/Ruff (# nosec B101 / # noqa: S101) de narrowing de type dans
tout le moteur. Remplace 11 sites disperses (ai/chat.py, ai/tools.py,
routes/scenes/scene_object_{add,collision,geometry,personnage_data,
quiz_config}.py, screens/payload/full_game_payload.py,
scripts/build_demo_dialogues.py) qui repetaient chacun le meme
commentaire empile - Sonar (python:S7632) ne parse pas deux
commentaires # sur une ligne, meme si Ruff et Bandit les acceptent
chacun tres bien (contrainte structurelle documentee dans
CODE_QUALITY.md : chaque outil exige son propre mot-cle immediatement
apres un #, aucun format a un seul # ne peut satisfaire les deux a la
fois). Les 6 sites # nosec B608 (SQL dynamique) restent inchanges,
nature differente, hors perimetre de ce refactor.
Verifie : mypy --strict propre (389 fichiers), ruff/bandit/import-linter
clean, suite complete verte (591 tests), scan SonarQube local relance
confirmant S7632 a 7 (1 seul site restant dans le helper lui-meme + les
6 B608), 0 bug (une regression S8371 trouvee et corrigee en route).
- 4 sites |safe repositionnes sur leur ligne exacte (scene_edit.html,
register_2fa.html, onboarding_new.html, play.html) - le marqueur
NOSONAR etait sur la ligne precedente par erreur (meme piege que celui
documente pour S8371 ci-dessus). Confirme par scan que meme corrige,
l'analyseur Web de Sonar ne supporte aucune syntaxe de suppression
inline testee pour la regle Web:S5247 - documente comme limitation
technique connue dans CODE_QUALITY.md plutot que force.
- CODE_QUALITY.md (nouveau) : reference complete du dispositif qualite -
vue d'ensemble par outil, configuration de chacun, commandes de lancement
local, procedure de justification d'une exception (avec le format exact
attendu par Bandit/Ruff/Sonar, verifie empiriquement), table des 10
exceptions documentees, backlog (djLint H021, code smells Sonar).
djLint (H021, styles inline) volontairement saute pour ce commit - meme
backlog assume que les commits precedents, aucun rapport avec ce changement.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
20f9398bd4
commit
55e81c0fbb
+143
@@ -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 <chemin-absolu-du-repo>:/usr/src \
|
||||||
|
sonarsource/sonar-scanner-cli \
|
||||||
|
-Dsonar.host.url=http://sonarqube:9000 \
|
||||||
|
-Dsonar.token=<votre-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 <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 |
|
||||||
|
| `screens/data_actions/compute_operation.py` (×2), `static/js/play/offline/compute-operation.js` (×2), `static/js/scenes/collision-rules-editor.js` (×2), `static/js/triggers/trigger-editor.js` | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI) — jamais un usage cryptographique. | Phase 3 |
|
||||||
|
| `static/js/play/offline/xapi-client.js` (18 sites) + `static/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/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/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/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 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## 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`.
|
||||||
+1
-3
@@ -327,9 +327,7 @@ def run_chat_turn(slug: str, screen_id: int, conversation_id: int, user_id: int,
|
|||||||
)
|
)
|
||||||
messages.append({"role": "user", "content": tool_results})
|
messages.append({"role": "user", "content": tool_results})
|
||||||
|
|
||||||
assert response is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
response = db.assert_not_none(response, "_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois")
|
||||||
"_MAX_TOOL_ITERATIONS > 0, la boucle for s'execute donc au moins une fois"
|
|
||||||
)
|
|
||||||
text = next((b.text for b in response.content if b.type == "text"), "")
|
text = next((b.text for b in response.content if b.type == "text"), "")
|
||||||
if text:
|
if text:
|
||||||
return text
|
return text
|
||||||
|
|||||||
+1
-3
@@ -558,9 +558,7 @@ def _dispatch_add_scene_object(
|
|||||||
image_url=image_url,
|
image_url=image_url,
|
||||||
)
|
)
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "object_id vient d'etre cree par screens.add_scene_object juste au-dessus")
|
||||||
"object_id vient d'etre cree par screens.add_scene_object juste au-dessus"
|
|
||||||
)
|
|
||||||
# Position par défaut (100,100, voir ensure_scene_schema.py) déjà
|
# Position par défaut (100,100, voir ensure_scene_schema.py) déjà
|
||||||
# posée par screens.add_scene_object — reste passée par le même
|
# posée par screens.add_scene_object — reste passée par le même
|
||||||
# garde-fou pour rester correcte même si ce défaut changeait un jour.
|
# garde-fou pour rester correcte même si ce défaut changeait un jour.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ exécute un vrai CREATE TABLE, et remplir le formulaire généré exécute un
|
|||||||
vrai INSERT dans cette table.
|
vrai INSERT dans cette table.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .assert_not_none import assert_not_none
|
||||||
from .connection import connect
|
from .connection import connect
|
||||||
from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
|
from .constants import FIELD_TYPES, GLOBAL_VARIABLE_TYPES, PROJECTS_DIR, SCORE_STATUS_CHOICES, SCORE_STATUS_LABELS
|
||||||
from .custom_events.create_custom_event import create_custom_event
|
from .custom_events.create_custom_event import create_custom_event
|
||||||
@@ -90,6 +91,7 @@ __all__ = [
|
|||||||
"slugify",
|
"slugify",
|
||||||
"table_name_for",
|
"table_name_for",
|
||||||
"json_for_script",
|
"json_for_script",
|
||||||
|
"assert_not_none",
|
||||||
"game_dir",
|
"game_dir",
|
||||||
"db_path",
|
"db_path",
|
||||||
"connect",
|
"connect",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_not_none(value: _T | None, message: str) -> _T:
|
||||||
|
"""Narrowing de type explicite : lève un AssertionError clair si `value`
|
||||||
|
est None, renvoie `value` (type non-Optional) sinon. Centralise ici
|
||||||
|
l'unique suppression Bandit/Ruff de ce type dans tout le moteur - un
|
||||||
|
`assert` direct à chaque site d'appel dupliquait le même commentaire
|
||||||
|
de suppression à chaque fois (voir CODE_QUALITY.md, Sonar python:S7632 :
|
||||||
|
Sonar ne reconnaît pas deux commentaires # empilés sur une ligne, même
|
||||||
|
si Ruff et Bandit les acceptent chacun très bien)."""
|
||||||
|
assert value is not None, message # nosec B101 # noqa: S101 - narrowing de type, seule assert du genre ici
|
||||||
|
return value
|
||||||
@@ -2,6 +2,7 @@ from flask import abort, g, jsonify, redirect, request, url_for
|
|||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
from core.sprite_gate import forbid_admin_only_background, forbid_admin_only_character
|
from core.sprite_gate import forbid_admin_only_background, forbid_admin_only_character
|
||||||
@@ -94,11 +95,11 @@ def scene_object_add(slug: str, screen_id: int) -> Response:
|
|||||||
# _clamp_to_camera pour l'IA, ici pour un dépôt manuel.
|
# _clamp_to_camera pour l'IA, ici pour un dépôt manuel.
|
||||||
obj_for_clamp = screens.get_scene_object(slug, new_id)
|
obj_for_clamp = screens.get_scene_object(slug, new_id)
|
||||||
screen = screens.get_screen(slug, screen_id)
|
screen = screens.get_screen(slug, screen_id)
|
||||||
assert obj_for_clamp is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj_for_clamp = db.assert_not_none(
|
||||||
"new_id vient d'etre cree par screens.add_scene_object juste au-dessus"
|
obj_for_clamp, "new_id vient d'etre cree par screens.add_scene_object juste au-dessus"
|
||||||
)
|
)
|
||||||
assert screen is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
screen = db.assert_not_none(
|
||||||
"screen_id valide : add_scene_object aurait leve IntegrityError sinon (FK scene_id)"
|
screen, "screen_id valide : add_scene_object aurait leve IntegrityError sinon (FK scene_id)"
|
||||||
)
|
)
|
||||||
max_x = max(0, screen["scene_width"] - obj_for_clamp["width"])
|
max_x = max(0, screen["scene_width"] - obj_for_clamp["width"])
|
||||||
max_y = max(0, screen["scene_height"] - obj_for_clamp["height"])
|
max_y = max(0, screen["scene_height"] - obj_for_clamp["height"])
|
||||||
@@ -110,9 +111,7 @@ def scene_object_add(slug: str, screen_id: int) -> Response:
|
|||||||
)
|
)
|
||||||
if request.headers.get("X-Requested-With") == "fetch":
|
if request.headers.get("X-Requested-With") == "fetch":
|
||||||
obj = screens.get_scene_object(slug, new_id)
|
obj = screens.get_scene_object(slug, new_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "new_id vient d'etre cree par screens.add_scene_object plus haut")
|
||||||
"new_id vient d'etre cree par screens.add_scene_object plus haut"
|
|
||||||
)
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"id": new_id,
|
"id": new_id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
|
||||||
@@ -33,7 +34,5 @@ def scene_object_collision(slug: str, object_id: int) -> Response | tuple[Respon
|
|||||||
|
|
||||||
screens.set_scene_object_collision(slug, object_id, settings)
|
screens.set_scene_object_collision(slug, object_id, settings)
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)")
|
||||||
"object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)"
|
|
||||||
)
|
|
||||||
return jsonify({"ok": True, "collision": screens.resolve_collision_settings(obj)})
|
return jsonify({"ok": True, "collision": screens.resolve_collision_settings(obj)})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
|
||||||
@@ -34,8 +35,8 @@ def scene_object_geometry(slug: str, object_id: int) -> Response | tuple[Respons
|
|||||||
# voir iaChatRefreshAfterTurn() dans static/js/ia/ia-chat.js), rendant
|
# voir iaChatRefreshAfterTurn() dans static/js/ia/ia-chat.js), rendant
|
||||||
# injoignable à la souris tout ce qui dépassait la scène nominale.
|
# injoignable à la souris tout ce qui dépassait la scène nominale.
|
||||||
screen = screens.get_screen(slug, obj["scene_id"])
|
screen = screens.get_screen(slug, obj["scene_id"])
|
||||||
assert screen is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
screen = db.assert_not_none(
|
||||||
"obj existe (verifie plus haut) et _scene_objects.scene_id a ON DELETE CASCADE vers _screens"
|
screen, "obj existe (verifie plus haut) et _scene_objects.scene_id a ON DELETE CASCADE vers _screens"
|
||||||
)
|
)
|
||||||
objects = screens.list_scene_objects(slug, obj["scene_id"])
|
objects = screens.list_scene_objects(slug, obj["scene_id"])
|
||||||
world_width, world_height = screens.resolve_scene_world_size(screen, objects)
|
world_width, world_height = screens.resolve_scene_world_size(screen, objects)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
from core.sprite_gate import forbid_admin_only_character
|
from core.sprite_gate import forbid_admin_only_character
|
||||||
@@ -22,7 +23,5 @@ def scene_object_personnage_data(slug: str, object_id: int) -> Response | tuple[
|
|||||||
forbid_admin_only_character(body["forge_character"])
|
forbid_admin_only_character(body["forge_character"])
|
||||||
screens.set_scene_object_personnage_data(slug, object_id, body)
|
screens.set_scene_object_personnage_data(slug, object_id, body)
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)")
|
||||||
"object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)"
|
|
||||||
)
|
|
||||||
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)})
|
return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
import screens
|
import screens
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
|
|
||||||
@@ -20,7 +21,5 @@ def scene_object_quiz_config(slug: str, object_id: int) -> Response | tuple[Resp
|
|||||||
# coup, plutôt que de deviner quelle balise ajouter/retirer côté
|
# coup, plutôt que de deviner quelle balise ajouter/retirer côté
|
||||||
# client.
|
# client.
|
||||||
obj = screens.get_scene_object(slug, object_id)
|
obj = screens.get_scene_object(slug, object_id)
|
||||||
assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
obj = db.assert_not_none(obj, "object_id valide : set_scene_object_quiz_config a renvoye une config non-None")
|
||||||
"object_id valide : set_scene_object_quiz_config a renvoye une config non-None"
|
|
||||||
)
|
|
||||||
return jsonify({"config": config, "rendered_html": screens.render_scene_object(obj)})
|
return jsonify({"config": config, "rendered_html": screens.render_scene_object(obj)})
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ def full_game_payload(slug: str, player_id: str = db.PLAYER_SHARED) -> dict[str,
|
|||||||
fields_meta = {}
|
fields_meta = {}
|
||||||
for d in definitions:
|
for d in definitions:
|
||||||
full = db.get_definition(slug, d["id"])
|
full = db.get_definition(slug, d["id"])
|
||||||
assert full is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction
|
full = db.assert_not_none(
|
||||||
"d['id'] vient de list_definitions(slug), donc get_definition ne peut pas renvoyer None ici"
|
full, "d['id'] vient de list_definitions(slug), donc get_definition ne peut pas renvoyer None ici"
|
||||||
)
|
)
|
||||||
rows = db.list_rows(slug, full, player_id)
|
rows = db.list_rows(slug, full, player_id)
|
||||||
# Un champ "relation" vit dans une colonne SQL "<champ>_id", jamais
|
# Un champ "relation" vit dans une colonne SQL "<champ>_id", jamais
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
from werkzeug.test import TestResponse
|
from werkzeug.test import TestResponse
|
||||||
|
|
||||||
|
import db
|
||||||
import routes # noqa: F401 - enregistre toutes les routes
|
import routes # noqa: F401 - enregistre toutes les routes
|
||||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
|
||||||
from core.flask_app import app
|
from core.flask_app import app
|
||||||
@@ -59,8 +60,9 @@ from core.flask_app import app
|
|||||||
|
|
||||||
def _redirect_id(resp: TestResponse, pattern: str) -> int:
|
def _redirect_id(resp: TestResponse, pattern: str) -> int:
|
||||||
match = re.search(pattern, resp.headers["Location"]) # NOSONAR S8371 - Location attendue, KeyError = echec clair
|
match = re.search(pattern, resp.headers["Location"]) # NOSONAR S8371 - Location attendue, KeyError = echec clair
|
||||||
assert match is not None, ( # nosec B101 # noqa: S101 - script d'usage unique, jamais execute en production
|
match = db.assert_not_none(
|
||||||
f"motif {pattern!r} absent de Location: {resp.headers['Location']!r}" # NOSONAR S8371 - idem
|
match,
|
||||||
|
f"motif {pattern!r} absent de Location: {resp.headers['Location']!r}", # NOSONAR S8371 - idem
|
||||||
)
|
)
|
||||||
return int(match.group(1))
|
return int(match.group(1))
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
<div class="notification is-danger">{{ error }}</div>
|
<div class="notification is-danger">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# NOSONAR S5247 - qr_svg est produit par la librairie qrcode a partir d'une URI TOTP generee serveur, aucun texte libre utilisateur embarque #}
|
<div class="totpQrWrap">{{ qr_svg|safe }}</div> {# //NOSONAR S5247 - qr_svg est produit par la librairie qrcode a partir d'une URI TOTP generee serveur, aucun texte libre utilisateur embarque #}
|
||||||
<div class="totpQrWrap">{{ qr_svg|safe }}</div>
|
|
||||||
<p class="hint">Impossible de scanner ? Saisis cette clé manuellement dans l'application : <code>{{ secret }}</code></p>
|
<p class="hint">Impossible de scanner ? Saisis cette clé manuellement dans l'application : <code>{{ secret }}</code></p>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('register_2fa') }}" class="mt-4">
|
<form method="post" action="{{ url_for('register_2fa') }}" class="mt-4">
|
||||||
|
|||||||
@@ -22,8 +22,7 @@
|
|||||||
<span class="onboardingCardLabel">{{ info.label }}</span>
|
<span class="onboardingCardLabel">{{ info.label }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="onboardingCardFace onboardingCardBack">
|
<span class="onboardingCardFace onboardingCardBack">
|
||||||
{# NOSONAR S5247 - description est du HTML fixe, code en dur dans db.ONBOARDING_TYPES (aucune saisie utilisateur) #}
|
<span class="onboardingCardDesc">{{ info.description|safe }}</span> {# //NOSONAR S5247 - description est du HTML fixe, code en dur dans db.ONBOARDING_TYPES (aucune saisie utilisateur) #}
|
||||||
<span class="onboardingCardDesc">{{ info.description|safe }}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@
|
|||||||
// routes/play/game_play.py) que la partie publique par joueur
|
// routes/play/game_play.py) que la partie publique par joueur
|
||||||
// (/jouer/<slug>, routes/public_play/), deux familles de routes
|
// (/jouer/<slug>, routes/public_play/), deux familles de routes
|
||||||
// distinctes (voir état par joueur, Phase 1).
|
// distinctes (voir état par joueur, Phase 1).
|
||||||
let gameData = {{ payload_json|safe }};
|
let gameData = {{ payload_json|safe }}; // NOSONAR S5247 - json_for_script() echappe deja la balise fermante de script (voir db/json_for_script.py)
|
||||||
let screensData = gameData.screens;
|
let screensData = gameData.screens;
|
||||||
{% if offline_mode %}
|
{% if offline_mode %}
|
||||||
// Export Web/SCORM (voir publish/build_scorm_package.py) : paquet
|
// Export Web/SCORM (voir publish/build_scorm_package.py) : paquet
|
||||||
|
|||||||
@@ -307,8 +307,7 @@
|
|||||||
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }} {{ 'isQuizFullscreenPreview' if o.quiz_fullscreen else '' }}" id="el-{{ o.id }}"
|
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }} {{ 'isQuizFullscreenPreview' if o.quiz_fullscreen else '' }}" id="el-{{ o.id }}"
|
||||||
style="position:absolute; left:{{ o.x }}px; top:{{ o.y }}px; width:{{ o.width }}px; height:{{ o.height }}px;"
|
style="position:absolute; left:{{ o.x }}px; top:{{ o.y }}px; width:{{ o.width }}px; height:{{ o.height }}px;"
|
||||||
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
||||||
{# NOSONAR S5247 - rendered_html vient de render_scene_object() (Python), qui echappe deja tout texte utilisateur via html.escape() #}
|
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div> {# //NOSONAR S5247 - rendered_html vient de render_scene_object() (Python), qui echappe deja tout texte utilisateur via html.escape() #}
|
||||||
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
|
||||||
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, {{ o.id }})"></div>
|
||||||
{% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}
|
{% if o.id == selected_id and o.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}
|
||||||
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
<!-- Boîte de collision (voir screens/rendering/collision_settings.py) :
|
||||||
|
|||||||
Reference in New Issue
Block a user