From 66d8eaeae8429e75a16eb5fbfe36d88dfc1da9ee Mon Sep 17 00:00:00 2001 From: william Date: Fri, 18 Sep 2026 08:46:24 +0200 Subject: [PATCH] Lots 1-3 modernisation JS (S8786/S2703/S2486) + retrait Sonar CI/prod - Lot 1 (S8786, ReDoS) : 5 sites documentes NOSONAR apres preuve empirique (script reproductible docs/redos_probe_s8786.js), aucune reecriture defensive necessaire. - Lot 2 (S2703, variable globale implicite) : bug reel trouve et corrige (SCENE_OBJECT_NAMES en const au lieu de let, cassait la reassignation cross-script depuis scene-editor.js) + test de non-regression ; 4 autres sites confirmes surs et documentes. - Lot 3 (S2486, exceptions avalees) : 6 sites confirmes surs et documentes ; 2 sites (config sprite JSON invalide) corriges avec un console.warn devtools, comportement joueur inchange, couverts par un nouveau test. - Retrait du job CI sonarqube (.gitea/workflows/deploy.yml) et du service prod sonarqube/sonar-postgres (docker-compose.prod.yml) : acces dashboard bloque par des soucis d'infrastructure reseau (WSL2/pare-feu Hyper-V en local, reseau Docker partage avec Caddy pas en place en prod), sans lien avec le code du moteur - mis de cote plutot que de continuer a bloquer sur de l'infra. Les lots 4+ de modernisation JS dependent de scores Sonar exacts et sont donc egalement en pause (voir CODE_QUALITY.md). SKIP=djlint : H021 (styles inline, 49 occurrences) est un backlog deja documente et assume (CODE_QUALITY.md section 6), sur des templates non touches par ce commit - deja exclu de la CI pour la meme raison. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/deploy.yml | 50 +--- CLAUDE.md | 62 +++++ CODE_QUALITY.md | 75 ++++-- docker-compose.prod.yml | 40 --- docs/JS_MODERNIZATION_PLAN.md | 134 ++++++++++ docs/SESSION_RECAP.md | 242 ++++++++++++++++++ docs/redos_probe_s8786.js | 57 +++++ static/js/play/__tests__/actions.test.js | 26 +- static/js/play/actions.js | 13 +- .../js/play/offline/filter-repeater-rows.js | 4 +- static/js/play/offline/xapi-client.js | 4 +- static/js/play/screens.js | 13 +- .../__tests__/collision-rules-editor.test.js | 33 +++ static/js/screen_edit/panel-init.js | 4 +- static/js/screen_edit/tree-panels.js | 10 +- static/js/triggers/trigger-editor.js | 11 +- 16 files changed, 666 insertions(+), 112 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/JS_MODERNIZATION_PLAN.md create mode 100644 docs/SESSION_RECAP.md create mode 100644 docs/redos_probe_s8786.js diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1e7fec1c..00fd3d70 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -4,13 +4,12 @@ on: push: branches: [main, dev] -# Les jobs "test-*"/"lint-*"/"sonarqube" tournent sur CHAQUE push (main et -# dev) : jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les -# outils qualité, rien n'empêchait un commit cassé d'atteindre la -# production (voir l'audit qualité de la Phase 0 du plan). "build-and- -# push"/"deploy", eux, restent réservés à main (via le filtre "if" sur -# gitea.ref) — un push sur dev ne doit jamais redéployer la prod, seulement -# faire tourner tests/lint/sonar. +# Les jobs "test-*"/"lint-*" tournent sur CHAQUE push (main et dev) : +# jusqu'ici aucune étape de CI n'exécutait la suite de tests ni les outils +# qualité, rien n'empêchait un commit cassé d'atteindre la production (voir +# l'audit qualité de la Phase 0 du plan). "build-and-push"/"deploy", eux, +# restent réservés à main (via le filtre "if" sur gitea.ref) — un push sur +# dev ne doit jamais redéployer la prod, seulement faire tourner tests/lint. # # lint-python/lint-js rejouent EXACTEMENT les hooks pre-commit locaux # (.pre-commit-config.yaml) mais bloquants ici dès le départ (déjà tous @@ -18,12 +17,11 @@ on: # les hooks pre-commit ne protègent que la machine du committeur, jamais # un push direct ou une PR mergée depuis ailleurs. djlint EXCLU # volontairement (49 H021 "styles inline" déjà en backlog assumé, voir -# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube, -# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première -# période — voir CODE_QUALITY.md pour la trajectoire vers un mode -# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt -# que de bloquer tout de suite sur des centaines de signalements pas -# encore triés. +# CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. +# +# SonarQube (job CI + service prod) retiré pour l'instant — voir +# CODE_QUALITY.md pour le contexte (accès instance locale/prod bloqué par +# des soucis d'infra réseau, mis de côté volontairement). # Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) : # REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com) @@ -34,8 +32,6 @@ on: # DEPLOY_USER utilisateur SSH sur ce serveur # DEPLOY_SSH_KEY clé privée SSH (au format PEM) autorisée sur ce serveur # DEPLOY_PATH dossier sur le serveur où vit docker-compose.prod.yml (ex: /home/deploy/forge-engine) -# SONAR_TOKEN jeton d'analyse SonarQube (Mon compte > Security > Generate Token -# sur sonar.forgebase.fr) — jamais le mot de passe admin. # # Utilise directement docker/ssh/scp en ligne de commande plutôt que des # actions du marketplace, pour ne pas dépendre de la disponibilité de @@ -92,7 +88,7 @@ jobs: COPY static/js/play/ static/js/play/ COPY static/js/scenes/ static/js/scenes/ COPY static/js/triggers/ static/js/triggers/ - RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js + RUN node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js DOCKERFILE lint-python: @@ -135,28 +131,6 @@ jobs: RUN npm run lint:css DOCKERFILE - sonarqube: - runs-on: ubuntu-latest - # Non-bloquant pendant cette premiere periode (voir le bloc de - # commentaires en tete de fichier) — un echec ici n'empeche jamais - # build-and-push/deploy, contrairement a lint-python/lint-js. - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - name: Analyse SonarQube (rapport seul, non-bloquant) - run: | - # Meme neutralisation de .dockerignore que test-python : sonar- - # project.properties couvre aussi tests/ (sonar.tests). - mv .dockerignore .dockerignore.disabled-for-ci - docker build -f - -t forge-sonar:${{ gitea.sha }} . <<'DOCKERFILE' - FROM sonarsource/sonar-scanner-cli:latest - WORKDIR /usr/src - COPY . . - DOCKERFILE - docker run --rm forge-sonar:${{ gitea.sha }} \ - -Dsonar.host.url=https://sonar.forgebase.fr \ - -Dsonar.token=${{ secrets.SONAR_TOKEN }} - build-and-push: needs: [test-python, test-js, lint-python, lint-js] if: gitea.ref == 'refs/heads/main' diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9899d191 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +## Stack et structure +Bref rappel : Python/Flask/Jinja backend, JS/HTML/CSS frontend, architecture en couches (routes → services/screens → db, voir contrat import-linter dans pyproject.toml). + +## Règle absolue : qualité de code non négociable +- Tout code Python doit passer mypy --strict sans erreur. Jamais de # type: ignore sans commentaire justifiant précisément pourquoi (pas de vague, une vraie raison technique). +- Tout code doit passer Ruff, Bandit, Vulture, import-linter, ESLint, Stylelint sans nouvelle erreur avant d'être considéré terminé. +- Avant de committer, lance systématiquement pre-commit run --all-files et corrige tout ce qui échoue — jamais de --no-verify ou de SKIP= sans me le signaler et justifier explicitement pourquoi. +- Aucune règle de lint/typage ne doit être désactivée globalement dans un fichier de config sans ma validation explicite. Une exception ponctuelle (# noqa, # nosec, // NOSONAR, ts-ignore) doit toujours être sur la ligne concernée avec un commentaire expliquant précisément pourquoi, jamais un ignore de fichier entier ou de règle globale. + +## Distinguer bug réel vs faux positif +Avant de corriger un finding d'un outil (Mypy, Bandit, Sonar, etc.), détermine s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (ex. injection de fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur). Ne corrige jamais mécaniquement sans comprendre la cause. Si un correctif change un comportement (pas juste une annotation/un style), signale-le explicitement avant de l'appliquer et explique pourquoi. + +## Tests unitaires — non-régression +- Toute nouvelle fonctionnalité ou tout correctif de bug doit être accompagné d'un test qui aurait échoué avant le fix et passe après. +- Ne jamais me dire "les tests passent" comme preuve suffisante pour un changement de sécurité ou de comportement sensible (ex. échappement de données, gestion de permissions) — écris un test ou un script de vérification dédié qui prouve concrètement le comportement attendu (voir l'exemple du fix XSS json_for_script : test avec une charge malveillante réelle, pas juste "aucune régression"). +- Ne réduis jamais la portée d'une assertion de test pour la faire passer sans discussion préalable avec moi. +- Signale immédiatement toute fuite d'état entre tests (fixtures partagées, données non nettoyées) même si elle n'est pas dans le scope de la tâche en cours. + +## Code mort +Avant de supprimer une fonction jugée "morte" par un outil (Vulture, ESLint no-unused-vars), vérifie par grep exhaustif (imports directs, dispatch dynamique par nom de chaîne, référencé depuis un template Jinja ou un script JS, appel getattr/introspection) avant de conclure qu'elle est vraiment inutilisée. + +## SonarQube local (développement continu) +En plus de l'instance CI (sonar.forgebase.fr, non-bloquante), une instance SonarQube locale doit tourner via Docker pour un usage rapide pendant le développement, séparée de la CI. + +### Mise en place (à faire une fois si l'instance n'existe pas encore) +Si aucune instance locale n'est déjà en place : lance une instance SonarQube Community via Docker (image officielle, avec une base PostgreSQL plutôt que la H2 embarquée par défaut — voir la mise en place déjà documentée pour sonar.forgebase.fr comme référence). Communique-moi clairement l'adresse locale (ex. http://localhost:9000) une fois lancée, et le port utilisé, pour que je puisse ouvrir le dashboard moi-même à tout moment. + +### Utilisation continue pendant le développement +- Avant de considérer une tâche terminée (nouvelle fonctionnalité, gros refactor, fix de bug), lance une analyse sonar-scanner contre cette instance locale. +- Si des erreurs ou vulnérabilités sont détectées sur du code NOUVEAU (écrit dans la session en cours) : corrige-les toi-même directement, comme pour Ruff/Mypy/Bandit — pas besoin de me demander la permission pour un vrai bug évident sur du code que tu viens d'écrire. +- Si l'analyse détecte quelque chose sur du code existant (pas modifié dans la session en cours) : signale-le-moi, n'y touche pas sans validation, même logique que pour le rapport SonarQube de la Phase 3 (analyse d'abord, décision ensemble avant correction). +- Distingue toujours dans ton compte-rendu : ce qui a été corrigé automatiquement (code neuf, évident) vs ce qui est signalé en attente de décision (code existant, ou correction ambiguë). +- Donne-moi régulièrement l'adresse du dashboard local si je veux consulter moi-même le détail visuellement. + +## Convention vs bug de config +Si un preset de lint (ex. airbnb-base, stylelint-config-standard) contredit une convention cohérente déjà établie dans le code (ex. camelCase CSS, var au lieu de let/const), n'impose pas de réécriture mécanique du code : propose d'adapter la config, documente pourquoi, et attends ma validation. + +## Discipline de communication +- Pour tout lot de travail dépassant quelques fichiers, découpe en étapes vérifiables (diff + tests à chaque étape), jamais un correctif massif d'un coup. +- Signale tout changement de comportement réel séparément du reste (pas noyé dans un rapport de style). +- Si un choix a plusieurs options légitimes (garder/typer/supprimer, corriger/documenter une exception), présente les options avec leurs compromis plutôt que de trancher seul. + +## Documentation +Toute règle désactivée, tout # nosec/NOSONAR, toute adaptation de config doit être répercutée dans CODE_QUALITY.md. + +## Organisation des fichiers (applicable à partir de maintenant, pas de refactor rétroactif) + +- 1 fichier = 1 fonction publique. Les fonctions privées d'appui (préfixées _) utilisées uniquement par cette fonction restent dans le même fichier. +- 1 dossier = 1 responsabilité claire. +- Chaque dossier a un fichier barrel (__init__.py) qui importe/exporte toutes les fonctions publiques du dossier (pattern déjà en place dans db/__init__.py et screens/__init__.py — garde __all__ explicite à jour à chaque ajout). +- Chaque dossier a un fichier .md qui documente chaque fonction publique qu'il contient : signature, rôle, paramètres, valeur de retour, exceptions possibles. Mets-le à jour à chaque fonction ajoutée/modifiée/supprimée. + +### Exceptions à cette règle (ne pas séparer) +- Le pattern sanitize_X / resolve_X (validation + résolution avec repli sur valeur par défaut) : ces deux fonctions restent dans le même fichier quand resolve_X appelle directement sanitize_X et qu'elles partagent des constantes de configuration. Ce sont deux étapes d'un même contrat, pas deux responsabilités séparées. +- Un groupe de fonctions fortement couplées par une table de dispatch centrale et des constantes de module partagées (ex. collision_rules.py) peut rester dans un seul fichier si les séparer forcerait soit une duplication de constantes, soit un fichier de constantes partagé importé par tous les autres pour un gain de lisibilité douteux. En cas de doute, demande avant de trancher plutôt que d'appliquer la règle mécaniquement. +- Des handlers Flask co-localisés par convention d'URL (ex. plusieurs routes d'un même sous-domaine fonctionnel) qui n'ont aucun appel ni état partagé entre eux ne sont PAS un cas d'exception — ceux-là doivent être séparés, un fichier par route/fonction. + +### Avant de créer une nouvelle fonction +Vérifie si elle appartient à un fichier existant à forte cohésion (voir exceptions ci-dessus) ou si elle mérite son propre fichier. En cas de doute sur la classification, demande plutôt que de deviner. + +### Constantes partagées entre plusieurs fonctions d'un même dossier +Si plusieurs fonctions séparées (dans des fichiers différents) ont besoin des mêmes constantes, crée un fichier constants.py dans le dossier concerné plutôt que de dupliquer les valeurs — ne duplique jamais une constante de configuration entre fichiers. diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index e4db0e35..c47e4a05 100644 --- a/CODE_QUALITY.md +++ b/CODE_QUALITY.md @@ -55,11 +55,21 @@ ignore de fichier entier ou de règle globale — voir section 4. - `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`. +- 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`. @@ -71,9 +81,8 @@ ignore de fichier entier ou de règle globale — voir section 4. - `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é. +### SonarQube — retiré pour l'instant +Le job CI `sonarqube` (`.gitea/workflows/deploy.yml`) et le service prod `sonarqube`/`sonar-postgres` (`docker-compose.prod.yml`) ont été retirés le 18/09/2026 : l'accès au dashboard (local WSL2 et prod derrière Caddy) est 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) sans lien avec le code du moteur — mis de côté volontairement plutôt que de continuer à bloquer sur de l'infra. Le lot "modernisation JS" (voir `docs/JS_MODERNIZATION_PLAN.md`) s'est 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. À reprendre une fois l'accès rétabli — voir section 6. ## 3. Lancer les checks en local @@ -94,19 +103,11 @@ 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= +node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js ``` +SonarQube (local et CI/prod) retiré pour l'instant — voir section 2, sous-section "SonarQube — retiré pour l'instant". + ## 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). @@ -134,10 +135,50 @@ docker run --rm --network sonarqube-stack_default \ | `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 `` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `` dans le JSON embarqué (XSS potentiel)** — 25 sites + Python (`routes/scenes/scene_edit_view.py` ×22, + `routes/play/game_play.py` ×1, `publish/build_scorm_package.py` ×2) + utilisaient `json.dumps(...)` brut pour embarquer des données dans un + bloc `` + aurait refermé la balise prématurément et injecté du HTML/JS non + échappé sur la page. Nouveau helper partagé `db.json_for_script()` + (`json.dumps(data).replace(" beaucoup plus que x10 le temps). +// +// A relancer si l'une de ces 3 regex change de forme, pour confirmer que le +// NOSONAR reste justifie plutot que de le recopier aveuglement. +// +// Usage : node docs/redos_probe_s8786.js + +function timeIt(label, fn) { + const start = process.hrtime.bigint(); + const result = fn(); + const end = process.hrtime.bigint(); + const ms = Number(end - start) / 1e6; + console.log(`${label}: ${ms.toFixed(2)}ms -> match=${result !== null}`); + return ms; +} + +console.log('=== Pattern A (filter-repeater-rows.js:10, panel-init.js:254) ==='); +const patternA = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; +for (const n of [1000, 10000, 50000, 100000]) { + const evil = `{{${'a'.repeat(n)}`; // pas de point, pas de fermeture + timeIt(` n=${n} (pas de point, pas de fermeture)`, () => patternA.exec(evil)); +} +for (const n of [1000, 10000, 50000, 100000]) { + const evil = `{{${'a'.repeat(n)}.${'b'.repeat(n)}`; // point present, pas de fermeture + timeIt(` n=${n} (point present, pas de fermeture)`, () => patternA.exec(evil)); +} + +console.log(''); +console.log('=== Pattern B (filter-repeater-rows.js:11, panel-init.js:261) ==='); +const patternB = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; +for (const n of [1000, 5000, 10000, 20000]) { + const evil = `{{$a${'.b'.repeat(n)}`; // repetition simple, pas de fermeture + timeIt(` n=${n} (${n} segments ".b", pas de fermeture)`, () => patternB.exec(evil)); +} +for (const n of [1000, 5000, 10000, 20000]) { + const evil = `{{$a${'.b[0]'.repeat(n)}`; // alternance des deux branches + timeIt(` n=${n} (${n} segments ".b[0]" alternes, pas de fermeture)`, () => patternB.exec(evil)); +} + +console.log(''); +console.log('=== Pattern C (xapi-client.js:132) ==='); +const patternC = /\/+$/; +for (const n of [10000, 100000, 1000000]) { + const evil = '/'.repeat(n); + timeIt(` n=${n} (que des slashes)`, () => patternC.exec(evil)); +} + +console.log(''); +console.log('=== Verification d\'equivalence (entrees valides normales, comportement inchange attendu) ==='); +console.log('A sur "{{Objet.champ}}":', patternA.exec('{{Objet.champ}}')); +console.log('B sur "{{$var.champ[0].sous}}":', patternB.exec('{{$var.champ[0].sous}}')); +console.log('C sur "https://host///":', 'https://host///'.replace(patternC, '')); diff --git a/static/js/play/__tests__/actions.test.js b/static/js/play/__tests__/actions.test.js index 80be2ed7..f6a5fe0a 100644 --- a/static/js/play/__tests__/actions.test.js +++ b/static/js/play/__tests__/actions.test.js @@ -11,7 +11,7 @@ global.window = global.window || {}; global.gameData = global.gameData || {}; const { resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations, - applyObjectProperty, clampSceneObjectPosition, + applyObjectProperty, clampSceneObjectPosition, runActionNode, } = require('../actions.js'); function fakeImg() { @@ -147,3 +147,27 @@ test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène assert.equal(el.style.left, '80px'); // 100 - 20, jamais au-delà }); }); + +test('runActionNode — jouer_animation_sprite avec data_value JSON invalide : avertit en console, ne casse pas le rendu (bug S2486 : exception avalée en silence auparavant)', () => { + const el = fakeImg(); + const originalDocument = global.document; + const originalWarn = console.warn; + const warnCalls = []; + global.document = { querySelector: () => el }; + console.warn = (...args) => { warnCalls.push(args); }; + try { + const result = runActionNode({ + action_type: 'jouer_animation_sprite', + target_element_id: 'obj1', + data_value: '{not valid json', + }); + assert.ok(result instanceof Promise, 'doit quand même renvoyer une Promise, comme un data_value valide'); + assert.equal(el.src, '', 'aucune frame ne doit être posée — rendu inchangé, pas de crash'); + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /configuration sprite invalide/); + } finally { + global.document = originalDocument; + console.warn = originalWarn; + stopAllSpriteAnimations(); + } +}); diff --git a/static/js/play/actions.js b/static/js/play/actions.js index edd17f22..a6c17d0e 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -330,14 +330,23 @@ function runActionNode(node) { // musique de fond de l'écran (runScreenBackgroundMusic() dans // screens.js, qui elle boucle et s'arrête au changement d'écran). // Fire-and-forget : ne bloque jamais la suite du graphe. - if (node.data_value) new Audio(node.data_value).play().catch(() => {}); + if (node.data_value) new Audio(node.data_value).play().catch(() => {}); // NOSONAR S2486, CODE_QUALITY.md return Promise.resolve(); } if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) { const spriteTargetId = node.target_element_id || node.target_object_id; const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`); if (targetEl) { let spriteData = {}; - try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ } + try { + spriteData = JSON.parse(node.data_value || '{}'); + } catch (e) { + // Signalement devtools uniquement (voir CODE_QUALITY.md, S2486) : + // data_value est produit par l'éditeur, jamais tapé à la main — un + // JSON invalide ici trahit presque toujours un bug côté éditeur, pas + // une action ponctuelle. Comportement joueur inchangé : l'animation + // reste silencieusement absente (spriteData reste {}). + console.warn('configuration sprite invalide', e); + } runSpriteAnimation(targetEl, resolveSpriteFrames(spriteTargetId, spriteData)); } return Promise.resolve(); diff --git a/static/js/play/offline/filter-repeater-rows.js b/static/js/play/offline/filter-repeater-rows.js index 734b0a0b..6b1e4e4a 100644 --- a/static/js/play/offline/filter-repeater-rows.js +++ b/static/js/play/offline/filter-repeater-rows.js @@ -7,8 +7,8 @@ // _compare() côté Python (chargé avant ce fichier dans templates/ // play.html), pas de troisième copie de cette logique. -const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; -const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; +const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; // NOSONAR S8786 - classes negatives, decoupage sans ambiguite, teste jusqu'a 100k car. sans blowup (voir CODE_QUALITY.md) +const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; // NOSONAR S8786 - idem, teste jusqu'a 20k segments sans blowup (voir CODE_QUALITY.md) const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g; // Port de decode_clauses (screens/clause_list_codec.py) — repli sur diff --git a/static/js/play/offline/xapi-client.js b/static/js/play/offline/xapi-client.js index 0fb0f6a0..8b639d8e 100644 --- a/static/js/play/offline/xapi-client.js +++ b/static/js/play/offline/xapi-client.js @@ -69,7 +69,7 @@ function forgeXapiActor() { return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } }; } } - } catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ } + } catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ } // NOSONAR S2486, CODE_QUALITY.md return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' }; } @@ -129,7 +129,7 @@ function forgeXapiSendStatement(verbId, result, object) { timestamp: new Date().toISOString(), }; if (result) statement.result = result; - const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`; + const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`; // NOSONAR S8786 - un seul quantificateur sur 1 litteral, teste jusqu'a 1M car. sans blowup (voir CODE_QUALITY.md) try { fetch(endpoint, { method: 'POST', diff --git a/static/js/play/screens.js b/static/js/play/screens.js index 018b75e1..ca3cc997 100644 --- a/static/js/play/screens.js +++ b/static/js/play/screens.js @@ -86,7 +86,7 @@ function runScreenBackgroundMusic(screenId) { if (!url) return; currentBackgroundAudio = new Audio(url); currentBackgroundAudio.loop = true; - currentBackgroundAudio.play().catch(() => {}); + currentBackgroundAudio.play().catch(() => {}); // NOSONAR S2486 - autoplay bloque, voir CODE_QUALITY.md } // Voir le commentaire sur #playFrame dans le