Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+88 -76
View File
@@ -12,24 +12,26 @@
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
// branchement FORGE_OFFLINE nécessaire ici.
var activeCollisionRuleInterval = null;
var activeCollisionRuleInteractListener = null;
let activeCollisionRuleInterval = null;
let activeCollisionRuleInteractListener = null;
// État "actif" par (objectId, ruleIndex) — pour ne déclencher une action
// qu'à l'ENTRÉE dans la collision/le périmètre, jamais à chaque tick tant
// que le joueur y reste (le comportement attendu d'un déclencheur, pas
// d'un effet répété 20 fois/seconde).
var forgeCollisionRuleActiveState = {};
let forgeCollisionRuleActiveState = {};
function forgeCollisionRuleStateKey(objectId, ruleIndex) {
return objectId + ':' + ruleIndex;
return `${objectId}:${ruleIndex}`;
}
function forgeElementBoxRect(el, collision) {
var left = parseFloat(el.style.left) || 0;
var top = parseFloat(el.style.top) || 0;
var width = parseFloat(el.style.width) || 0;
var height = parseFloat(el.style.height) || 0;
return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision);
const left = parseFloat(el.style.left) || 0;
const top = parseFloat(el.style.top) || 0;
const width = parseFloat(el.style.width) || 0;
const height = parseFloat(el.style.height) || 0;
return forgeCollisionRectFromBox({
left, top, width, height,
}, collision);
}
// Une marge (px) appliquée SEULEMENT à la détection du déclencheur
@@ -49,13 +51,16 @@ function forgeElementBoxRect(el, collision) {
// restant petit face à la taille d'un sprite (souvent 64-128px) pour ne
// jamais déclencher "à la collision" alors que les objets sont encore
// visiblement séparés.
var FORGE_COLLISION_RULE_TOUCH_MARGIN = 24;
const FORGE_COLLISION_RULE_TOUCH_MARGIN = 24;
function forgeExpandRect(rect, margin) {
return {
left: rect.left - margin, top: rect.top - margin,
right: rect.right + margin, bottom: rect.bottom + margin,
width: rect.width + margin * 2, height: rect.height + margin * 2,
left: rect.left - margin,
top: rect.top - margin,
right: rect.right + margin,
bottom: rect.bottom + margin,
width: rect.width + margin * 2,
height: rect.height + margin * 2,
shape: rect.shape,
};
}
@@ -77,7 +82,7 @@ function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
// déjà position:relative — .sceneWorld/.sceneUI, voir static/style.css),
// positionnée en JS aux mêmes coordonnées que l'objet.
function forgeCollisionInteractBubbleEl(objectId, parentEl) {
var bubble = parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]');
let bubble = parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
if (!bubble) {
bubble = document.createElement('div');
bubble.className = 'collisionInteractBubble';
@@ -97,18 +102,18 @@ function forgeCollisionInteractBubbleEl(objectId, parentEl) {
// "c'est trop haut"). La boîte de collision, que l'auteur ajuste déjà
// pour épouser la silhouette réelle, donne une ancre bien plus fidèle.
function forgeShowCollisionInteractBubble(objectEl, objectId, objRect, key) {
var parentEl = objectEl.parentElement;
const parentEl = objectEl.parentElement;
if (!parentEl) return;
var bubble = forgeCollisionInteractBubbleEl(objectId, parentEl);
bubble.textContent = 'Appuie sur ' + key;
const bubble = forgeCollisionInteractBubbleEl(objectId, parentEl);
bubble.textContent = `Appuie sur ${key}`;
bubble.style.display = 'block';
bubble.style.left = (objRect.left + objRect.width / 2) + 'px'; // centrée horizontalement (voir transform:translateX(-50%), static/style.css)
bubble.style.top = objRect.top + 'px'; // AU-DESSUS de la boîte de collision (voir transform:translateY(-100%), static/style.css)
bubble.style.left = `${objRect.left + objRect.width / 2}px`; // centrée horizontalement (voir transform:translateX(-50%), static/style.css)
bubble.style.top = `${objRect.top}px`; // AU-DESSUS de la boîte de collision (voir transform:translateY(-100%), static/style.css)
}
function forgeHideCollisionInteractBubble(objectId, objectEl) {
var parentEl = objectEl && objectEl.parentElement;
var bubble = parentEl && parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]');
const parentEl = objectEl && objectEl.parentElement;
const bubble = parentEl && parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
if (bubble) bubble.style.display = 'none';
}
@@ -118,11 +123,11 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
// collision dure" d'un déclencheur "interagir", peut être posée sur
// N'IMPORTE QUEL objet de l'écran (voir action.object_id, pas
// nécessairement l'objet qui porte le déclencheur).
var FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
var forgeIndicationTimeouts = {};
const FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
const forgeIndicationTimeouts = {};
function forgeIndicationBubbleEl(objectId, parentEl) {
var bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="' + objectId + '"]');
let bubble = parentEl.querySelector(`.collisionIndicationBubble[data-for-object-id="${objectId}"]`);
if (!bubble) {
bubble = document.createElement('div');
bubble.className = 'collisionIndicationBubble';
@@ -133,17 +138,17 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
}
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
var targetEl = document.querySelector('[data-object-id="' + objectId + '"]');
var parentEl = targetEl && targetEl.parentElement;
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
const parentEl = targetEl && targetEl.parentElement;
if (!targetEl || !parentEl || !texte) return;
var objRect = forgeElementBoxRect(targetEl);
var bubble = forgeIndicationBubbleEl(objectId, parentEl);
const objRect = forgeElementBoxRect(targetEl);
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
bubble.textContent = texte;
bubble.style.display = 'block';
bubble.style.left = (objRect.left + objRect.width / 2) + 'px';
bubble.style.top = objRect.top + 'px';
bubble.style.left = `${objRect.left + objRect.width / 2}px`;
bubble.style.top = `${objRect.top}px`;
if (forgeIndicationTimeouts[objectId]) clearTimeout(forgeIndicationTimeouts[objectId]);
forgeIndicationTimeouts[objectId] = setTimeout(function () {
forgeIndicationTimeouts[objectId] = setTimeout(() => {
bubble.style.display = 'none';
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
}
@@ -168,8 +173,8 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
// s'exécuter immédiatement — un dialogue attend des clics du joueur
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
// controller.js une fois le dialogue épuisé.
var lines = (action.dialogue && action.dialogue.lines) || [];
var dialogueId = action.dialogue && action.dialogue.id;
const lines = (action.dialogue && action.dialogue.lines) || [];
const dialogueId = action.dialogue && action.dialogue.id;
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
} else if (action.type === 'variable') {
// Modifie une variable globale — même vocabulaire/calcul que l'action
@@ -187,30 +192,30 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
// ici (voir maquette validée : le losange ne teste qu'une variable).
var isTrue = evaluateConditionClause({
const isTrue = evaluateConditionClause({
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
});
var branch = isTrue ? action.si_vrai : action.si_faux;
const branch = isTrue ? action.si_vrai : action.si_faux;
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
} else if (action.type === 'surbrillance' || action.type === 'visibilite') {
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas
// forcément celui qui porte le déclencheur) — réutilise TEL QUEL
// applyObjectProperty (static/js/play/actions.js, équivalent scène 2D
// d'applyElementProperty), plutôt que de dupliquer cette logique.
var targetObjEl = document.querySelector('[data-object-id="' + action.object_id + '"]');
const targetObjEl = document.querySelector(`[data-object-id="${action.object_id}"]`);
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
} else if (action.type === 'son') {
// Effet sonore ponctuel, non bouclé — même comportement que l'action
// "Jouer un son" du Flow (static/js/play/actions.js), instantané :
// `then` s'exécute tout de suite, sans attendre la fin du son.
if (action.asset_url) new Audio(action.asset_url).play().catch(function () {});
if (action.asset_url) new Audio(action.asset_url).play().catch(() => {});
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
} else if (action.type === 'video') {
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
// "Passer") — voir static/js/play/video-overlay.js, même esprit
// qu'un dialogue ci-dessus.
forgeShowVideoOverlay(action.asset_url, action.mode, function () {
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
});
} else if (action.type === 'indication') {
@@ -221,37 +226,37 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
// vocabulaire que l'action "attendre" du Flow (static/js/play/
// actions.js), utile ex. pour laisser un temps de lecture après une
// narration à l'affichage de l'écran.
var seconds = parseFloat(action.data_value);
var ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
setTimeout(function () {
const seconds = parseFloat(action.data_value);
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
setTimeout(() => {
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
}, ms);
}
}
function forgeCollisionRuleTick(screenData) {
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]');
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
if (!player || !playerEl) return;
var playerRect = forgeElementBoxRect(playerEl, player.collision);
const playerRect = forgeElementBoxRect(playerEl, player.collision);
(screenData.elements || []).forEach(function (obj) {
var rules = obj.collision_rules;
(screenData.elements || []).forEach((obj) => {
const rules = obj.collision_rules;
if (!rules || !rules.length) return;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
if (!targetEl) return;
var objRect = forgeElementBoxRect(targetEl, obj.collision);
const objRect = forgeElementBoxRect(targetEl, obj.collision);
var anyInteractActive = false;
rules.forEach(function (rule, ruleIndex) {
let anyInteractActive = false;
rules.forEach((rule, ruleIndex) => {
// "clic"/"survol" sont liés à part (voir forgeBindClicSurvolRules,
// écouteurs DOM directs) — jamais évalués ici, sans quoi ils
// s'exécuteraient EN PLUS, à la proximité du joueur, en plus de
// leur propre déclencheur.
if (rule.trigger !== 'collision') return;
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
var wasActive = !!forgeCollisionRuleActiveState[stateKey];
const stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
const isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
const wasActive = !!forgeCollisionRuleActiveState[stateKey];
if (rule.action.type === 'interagir') {
if (isActive) {
@@ -274,16 +279,16 @@ function forgeCollisionRuleTick(screenData) {
// qu'elle reste enfoncée.
function forgeStartCollisionRuleInteractListener(screenData) {
activeCollisionRuleInteractListener = function (ev) {
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
if (!player || !player.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
(screenData.elements || []).forEach(function (obj) {
var rules = obj.collision_rules;
(screenData.elements || []).forEach((obj) => {
const rules = obj.collision_rules;
if (!rules || !rules.length) return;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
if (!targetEl) return;
rules.forEach(function (rule, ruleIndex) {
rules.forEach((rule, ruleIndex) => {
if (rule.action.type !== 'interagir') return;
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
const stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
if (!forgeCollisionRuleActiveState[stateKey]) return;
forgeRunCollisionRuleAction(rule.action.sub_action, targetEl, obj);
});
@@ -298,26 +303,26 @@ function forgeStartCollisionRuleInteractListener(screenData) {
// posé une fois par objet suffit (voir screens/rendering/
// collision_rules.py). Re-attachés à chaque affichage d'écran, comme le
// reste de ce fichier.
var activeClicSurvolListeners = [];
let activeClicSurvolListeners = [];
function forgeUnbindClicSurvolRules() {
activeClicSurvolListeners.forEach(function (l) { l.el.removeEventListener(l.event, l.handler); });
activeClicSurvolListeners.forEach((l) => { l.el.removeEventListener(l.event, l.handler); });
activeClicSurvolListeners = [];
}
function forgeBindClicSurvolRules(screenData) {
forgeUnbindClicSurvolRules();
(screenData.elements || []).forEach(function (obj) {
var rules = obj.collision_rules;
(screenData.elements || []).forEach((obj) => {
const rules = obj.collision_rules;
if (!rules || !rules.length) return;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
if (!targetEl) return;
rules.forEach(function (rule) {
rules.forEach((rule) => {
if (rule.trigger !== 'clic' && rule.trigger !== 'survol') return;
var eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
var handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
const eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
const handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
targetEl.addEventListener(eventName, handler);
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler: handler });
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler });
});
});
}
@@ -343,22 +348,29 @@ function forgeStopCollisionRuleControllers() {
function forgeStartCollisionRuleControllers(screenData) {
forgeStopCollisionRuleControllers();
if (!screenData || screenData.kind !== 'jeu_2d') return;
var elements = screenData.elements || [];
const elements = screenData.elements || [];
forgeBindClicSurvolRules(screenData);
var hasPlayer = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
var hasRules = elements.some(function (o) { return o.collision_rules && o.collision_rules.length; });
const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
const hasRules = elements.some((o) => o.collision_rules && o.collision_rules.length);
if (!hasPlayer || !hasRules) return;
activeCollisionRuleInterval = setInterval(function () { forgeCollisionRuleTick(screenData); }, 50);
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
forgeStartCollisionRuleInteractListener(screenData);
}
// static/js/play/__tests__/ (node:test) — même garde que personnage-controller.js.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
forgeCollisionRuleStateKey, forgeElementBoxRect, forgeExpandRect,
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules, forgeShowIndicationBubble,
forgeCollisionRuleTriggerActive,
forgeRunCollisionRuleAction,
forgeCollisionRuleTick,
forgeStartCollisionRuleControllers,
forgeStopCollisionRuleControllers,
forgeCollisionRuleStateKey,
forgeElementBoxRect,
forgeExpandRect,
forgeBindClicSurvolRules,
forgeUnbindClicSurvolRules,
forgeShowIndicationBubble,
};
const conditionsMod = require('./conditions.js');
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;