- 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 <noreply@anthropic.com>
27 KiB
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/S106désactivées pourtests/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 cartests/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 destrict, 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 unmin_confidenceabaissé 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 importeroutes/core, jamais l'inverse.
ESLint (.eslintrc.json)
- Base
airbnb-base+ pluginsunused-imports,unicorn. max-lenporté à 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-varsavec une liste blanche (varsIgnorePattern) d'environ 95 noms : fonctions invoquées uniquement depuis des attributsonclick/onchangeinline dans les templates Jinja, invisibles pour l'analyse statique d'ESLint.- Bloc
globalsdocumentant les variables cross-fichiers volontaires (ex.gameData,screensData— voir section 5). eslint-plugin-unicorn(version55.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 configrecommendedcomplè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 (voirdocs/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--fixvé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.xunicorn/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— boucleforclassique sur un itérable →for...of
Stylelint (.stylelintrc.json)
- Base
stylelint-config-standard. selector-class-pattern/selector-id-patterndé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-specificitydé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 — 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
# 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/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
- 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).
- 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).
- 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").
- Format attendu par outil (vérifié empiriquement cette session, chaque outil a ses propres exigences de position du mot-clé) :
- Bandit :
# nosec <CODE>— le motnosecdoit être immédiatement précédé d'un#sur la ligne. - Ruff :
# noqa: <CODE>— le motnoqadoit ê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).
- Bandit :
- 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.
- 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 |
static/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/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/js/scenes/scene-editor.js:214 (CURRENT_SELECTED_ID = null), static/js/screen_edit/tree-panels.js:392 (CURRENT_SELECTED_ID = selectedId || null) |
javascript:S2703 |
Déclarée en var (pas let/const) dans templates/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/js/scenes/collision-rules-editor.js:78 (let _collisionWizard = null;) et ses réassignations, toutes dans ce même fichier |
javascript:S2703 |
Déclarée ET réassignée exclusivement à l'intérieur de collision-rules-editor.js (aucun autre fichier n'y touche, vérifié par grep exhaustif) — Sonar la signale seulement parce que ce fichier est un script classique sans wrapper de module (comme tout static/js/), pas parce qu'un ordre de chargement entre fichiers serait en jeu ; c'est le cas le plus simple des 4 (état privé à un seul fichier). Déjà documenté dans .eslintrc.json ("_collisionWizard": "writable"). |
Lot 2 "modernisation JS", 16/09/2026 |
static/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/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/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/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 |
static/js/play/offline/filter-repeater-rows.js:10,11, static/js/screen_edit/panel-init.js:254,261, static/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/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/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/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/js/play/screens.js:89 (runScreenBackgroundMusic), static/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/js/play/screens.js:192 (applyAnimationClip, clip sprite), static/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/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 |
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 (421 restants) — lot JS "modernisation" (357 issues), 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-9 en pause — dépendent de scores Sonar exacts (voir section 2). Reste aussi la complexité cognitive Python et la duplication, non encore triées. - SonarQube (local + CI/prod) retiré, à réintégrer — job CI et service prod retirés le 18/09/2026 (voir section 2). Une fois l'accès dashboard rétabli (local et/ou prod derrière Caddy) : reconfigurer le job
.gitea/workflows/deploy.yml/le servicedocker-compose.prod.yml, reprendre le lot JS "modernisation" au lot 4, puis retirercontinue-on-error: trueune fois le rapport de code smells trié etWeb:S5247résolu ou accepté comme limitation permanente.