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
+67 -62
View File
@@ -7,7 +7,7 @@
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
// pilotable par une action "Modifier un élément".
var ELEMENT_PROPERTY_CSS = {
const ELEMENT_PROPERTY_CSS = {
bg_color: { css: 'backgroundColor', suffix: '' },
border_color: { css: 'borderColor', suffix: '' },
width: { css: 'width', suffix: '%' },
@@ -97,7 +97,7 @@ function applyElementProperty(targetEl, property, value) {
// toujours au même endroit.
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
const delta = parseFloat(value);
if (isNaN(delta)) return;
if (Number.isNaN(delta)) return;
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
return;
@@ -113,7 +113,7 @@ function applyElementProperty(targetEl, property, value) {
// 1re valeur si l'écran est rechargé.
if (typeof value === 'string' && value.indexOf('||') !== -1) {
const parts = value.split('||');
const key = 'toggle' + property;
const key = `toggle${property}`;
const atSecond = targetEl.dataset[key] === '1';
const next = atSecond ? parts[0] : parts[1];
targetEl.style[spec.css] = next + spec.suffix;
@@ -137,7 +137,7 @@ function applyElementProperty(targetEl, property, value) {
// pour un objet de scène), la valeur n'est pas bornée plutôt que de
// risquer un blocage à 0.
function clampSceneObjectPosition(targetEl, css, rawValue) {
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
const screenData = screensData.find((s) => s.id === window.currentScreenId);
if (!screenData || screenData.scene_width == null) return rawValue;
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
@@ -156,15 +156,15 @@ function applyObjectProperty(targetEl, property, value) {
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
const css = property === 'pos_x_relatif' ? 'left' : 'top';
const delta = parseFloat(value);
if (isNaN(delta)) return;
if (Number.isNaN(delta)) return;
const current = parseFloat(targetEl.style[css]) || 0;
targetEl.style[css] = clampSceneObjectPosition(targetEl, css, current + delta) + 'px';
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, current + delta)}px`;
return;
}
if (property === 'pos_x' || property === 'pos_y') {
if (value === undefined || value === null) return;
const css = property === 'pos_x' ? 'left' : 'top';
targetEl.style[css] = clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0) + 'px';
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0)}px`;
return;
}
applyElementProperty(targetEl, property, value);
@@ -175,14 +175,14 @@ function runActionNode(node) {
const idx = screenIndexById(window.currentScreenId);
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
return Promise.resolve();
} else if (node.action_type === 'ecran_precedent') {
} if (node.action_type === 'ecran_precedent') {
const idx = screenIndexById(window.currentScreenId);
if (idx > 0) showScreen(screensData[idx - 1].id);
return Promise.resolve();
} else if (node.action_type === 'aller_a' && node.target_screen_id) {
} if (node.action_type === 'aller_a' && node.target_screen_id) {
showScreen(node.target_screen_id);
return Promise.resolve();
} else if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
} if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
// détail avec ses placeholders "champ" résolus contre cette ligne
@@ -205,27 +205,27 @@ function runActionNode(node) {
bindHoverTexts();
bindHoverTriggers();
return Promise.resolve();
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
} if (node.action_type === 'modifier_element' && node.target_element_id) {
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve();
} else if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
} if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
// "modifier_element" ci-dessus, mais en pixels (voir
// applyObjectProperty() plus bas) plutôt qu'en %. data-element-id
// est posé sur CHAQUE objet de scène en plus de data-object-id (voir
// render_scene_object.py), donc le même sélecteur suffit.
const targetEl = document.querySelector('[data-element-id="' + (node.target_element_id || node.target_object_id) + '"]');
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
return Promise.resolve();
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
} if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
// Notification pure (voir screens/custom_events/) : ne fait que
// signaler l'événement, jamais choisir un élément — c'est à
// l'écouteur (déclencheur → condition → action) de décider quoi
// faire, avec ses propres réglages habituels.
window.dispatchGameEvent(node.target_custom_event_id);
return Promise.resolve();
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
} if (node.action_type === 'activer_onglet' && node.target_element_id) {
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
// y compris "aucun parent" = les autres éléments de premier niveau de
@@ -234,31 +234,30 @@ function runActionNode(node) {
// (imbriqués compris, voir list_elements côté serveur), donc ça
// fonctionne aussi bien pour des onglets imbriqués dans un même
// conteneur que pour des "vues" posées directement sur l'écran.
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
const screenData = screensData.find((s) => s.id === window.currentScreenId);
const allElements = screenData ? screenData.elements : [];
const target = allElements.find(function(e){ return e.id === node.target_element_id; });
const target = allElements.find((e) => e.id === node.target_element_id);
if (target) {
const parentKey = target.parent_id || null;
allElements
.filter(function(e){ return (e.parent_id || null) === parentKey; })
.forEach(function(e) {
const el = document.querySelector('[data-element-id="' + e.id + '"]');
.filter((e) => (e.parent_id || null) === parentKey)
.forEach((e) => {
const el = document.querySelector(`[data-element-id="${e.id}"]`);
if (!el) return;
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; }
else { el.style.display = 'none'; }
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
});
}
return Promise.resolve();
} else if (node.action_type === 'attendre') {
} if (node.action_type === 'attendre') {
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
// pendant N secondes (node.data_value) avant de continuer sur le(s)
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
// quelques secondes après l'affichage de l'écran, sans action du
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
const seconds = parseFloat(node.data_value);
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
return new Promise(function(resolve) { setTimeout(resolve, ms); });
} else if (node.action_type === 'modifier_donnee') {
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
return new Promise((resolve) => { setTimeout(resolve, ms); });
} if (node.action_type === 'modifier_donnee') {
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) :
// aucun serveur disponible dans le paquet exporté — la même mutation
// s'applique directement sur gameData, en mémoire, dans le navigateur.
@@ -272,21 +271,22 @@ function runActionNode(node) {
// LAST_INSERTED_ROW_ID (voir flow_node_run_data.py) — sans effet
// sinon, donc toujours transmis sans condition ici.
return fetch(runDataUrl, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId })
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
})
.then(r => r.json())
.then((r) => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'modifier_variable') {
} if (node.action_type === 'modifier_variable') {
if (window.FORGE_OFFLINE) {
forgeApplyVariableActionOffline(gameData, node);
return refreshRuntimeData();
}
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
return fetch(runVarUrl, {method: 'POST'})
.then(r => r.json())
return fetch(runVarUrl, { method: 'POST' })
.then((r) => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'modifier_score') {
} if (node.action_type === 'modifier_score') {
// Score/progression (voir db/scoring/, screens/data_actions/
// apply_score_action.py) : même patron que "Modifier une variable
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
@@ -296,19 +296,19 @@ function runActionNode(node) {
return refreshRuntimeData();
}
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
return fetch(runScoreUrl, {method: 'POST'})
.then(r => r.json())
return fetch(runScoreUrl, { method: 'POST' })
.then((r) => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'definir_statut_partie') {
} if (node.action_type === 'definir_statut_partie') {
if (window.FORGE_OFFLINE) {
forgeApplyStatusActionOffline(gameData, node);
return refreshRuntimeData();
}
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
return fetch(runStatusUrl, {method: 'POST'})
.then(r => r.json())
return fetch(runStatusUrl, { method: 'POST' })
.then((r) => r.json())
.then(() => refreshRuntimeData());
} else if (node.action_type === 'ajouter_ligne') {
} if (node.action_type === 'ajouter_ligne') {
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire
// hors ligne) ; son id devient window.lastInsertedRowId pour que les
// nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID,
@@ -320,21 +320,21 @@ function runActionNode(node) {
}
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
return fetch(runAddRowUrl, { method: 'POST' })
.then(r => r.json())
.then(function(json) {
.then((r) => r.json())
.then((json) => {
window.lastInsertedRowId = json.row_id;
return refreshRuntimeData();
});
} else if (node.action_type === 'jouer_son') {
} if (node.action_type === 'jouer_son') {
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la
// 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(function() {});
if (node.data_value) new Audio(node.data_value).play().catch(() => {});
return Promise.resolve();
} else if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
} 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 + '"]');
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 */ }
@@ -359,7 +359,7 @@ function runActionNode(node) {
// ci-dessus (événementiel) ET un clip de la Timeline d'animation de kind
// "sprite" (déclaratif, voir applyAnimationClip() dans screens.js) — les
// deux partagent ce même moteur, aucune logique dupliquée.
var activeSpriteAnimations = new Map();
const activeSpriteAnimations = new Map();
// Phase 8 — résout le NOM d'une animation (ex. "walk") en liste de frames à
// partir du personnage ACTUELLEMENT assigné à cet élément
@@ -373,7 +373,7 @@ var activeSpriteAnimations = new Map();
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
// les nœuds/clips déjà créés avant ce changement.
function resolveSpriteFrames(elementId, spriteData) {
var byAnimation = (gameData.personnage_animations || {})[elementId];
const byAnimation = (gameData.personnage_animations || {})[elementId];
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
}
@@ -404,11 +404,11 @@ function resolveSpriteFrames(elementId, spriteData) {
// aussitôt posée si on ne la protégeait pas explicitement — un setTimeout
// dédié la maintient "en cours" un court instant avant de la libérer.
function runSpriteAnimation(targetEl, spriteData) {
var frames = spriteData.frames || [];
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
var loop = !!spriteData.loop;
var signature = frames.join('|') + '@' + fps + (loop ? '+loop' : '');
var active = activeSpriteAnimations.get(targetEl);
const frames = spriteData.frames || [];
const fps = spriteData.fps > 0 ? spriteData.fps : 8;
const loop = !!spriteData.loop;
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
const active = activeSpriteAnimations.get(targetEl);
if (active && active.signature === signature) return;
if (active && !active.loop) return;
if (active) {
@@ -416,16 +416,16 @@ function runSpriteAnimation(targetEl, spriteData) {
activeSpriteAnimations.delete(targetEl);
}
if (!frames.length) return;
var frameIndex = 0;
let frameIndex = 0;
targetEl.src = frames[0];
if (frames.length === 1) {
if (loop) return;
var holdMs = Math.max(1000 / fps, 150);
var timeoutId = setTimeout(function() { activeSpriteAnimations.delete(targetEl); }, holdMs);
activeSpriteAnimations.set(targetEl, { intervalId: timeoutId, signature: signature, loop: loop });
const holdMs = Math.max(1000 / fps, 150);
const timeoutId = setTimeout(() => { activeSpriteAnimations.delete(targetEl); }, holdMs);
activeSpriteAnimations.set(targetEl, { intervalId: timeoutId, signature, loop });
return;
}
var intervalId = setInterval(function() {
const intervalId = setInterval(() => {
frameIndex++;
if (frameIndex >= frames.length) {
if (!loop) { clearInterval(intervalId); activeSpriteAnimations.delete(targetEl); return; }
@@ -433,7 +433,7 @@ function runSpriteAnimation(targetEl, spriteData) {
}
targetEl.src = frames[frameIndex];
}, 1000 / fps);
activeSpriteAnimations.set(targetEl, { intervalId: intervalId, signature: signature, loop: loop });
activeSpriteAnimations.set(targetEl, { intervalId, signature, loop });
}
// Appelée depuis showScreen() (screens.js) à chaque changement d'écran —
@@ -441,14 +441,19 @@ function runSpriteAnimation(targetEl, spriteData) {
// directement depuis screens.js, même patron que runScreenTimerTriggers()
// dans triggers.js (l'appelant ne connaît jamais la structure interne).
function stopAllSpriteAnimations() {
activeSpriteAnimations.forEach(function(active) { clearInterval(active.intervalId); });
activeSpriteAnimations.forEach((active) => { clearInterval(active.intervalId); });
activeSpriteAnimations.clear();
}
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
applyObjectProperty, clampSceneObjectPosition, runActionNode,
resolveSpriteFrames,
runSpriteAnimation,
stopAllSpriteAnimations,
activeSpriteAnimations,
applyObjectProperty,
clampSceneObjectPosition,
runActionNode,
};
}