#!/usr/bin/env node /** * validate.mjs — LA PORTE. Ce script est la règle n°1 du projet écrite en code. * * Le brief §2 l'exige mot pour mot : « Le build échoue si un média non validé est * référencé par une épreuve. Écris ce contrôle comme un script de build, pas comme * une consigne. » Ce fichier est ce contrôle. * * Il vérifie deux couches : * 1. la forme, contre fruits.schema.json (Ajv) ; * 2. les règles que le schéma ne peut pas exprimer — filières, licences, sécurité. * * Sortie non nulle = build refusé. Aucune de ces règles ne se contourne par un flag. */ import { readFile, readdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join, basename } from 'node:path'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); /** Surchargeable pour les tests de la porte (tools/data/test-porte.mjs). */ const DATA = process.env.KI_FWI_DATA ? join(ROOT, process.env.KI_FWI_DATA) : join(ROOT, 'src', 'lib', 'data', 'fruits'); /** Usages qui servent à identifier une espèce. Ce sont eux qui déclenchent la porte. */ const USAGES_IDENTIFIANTS = new Set(['epreuve', 'indice']); const erreurs = []; const alertes = []; const err = (fichier, msg) => erreurs.push(`${fichier} — ${msg}`); const warn = (fichier, msg) => alertes.push(`${fichier} — ${msg}`); async function main() { const schema = JSON.parse(await readFile(join(ROOT, 'fruits.schema.json'), 'utf8')); const ajv = new Ajv2020({ allErrors: true, strict: false }); addFormats(ajv); const valider = ajv.compile(schema); const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort(); if (!fichiers.length) { console.error('✘ Aucune entrée dans src/lib/data/fruits/.'); process.exit(1); } const vus = new Map(); const entrees = []; let nbMedias = 0; let nbMediasValides = 0; let nbNC = 0; for (const f of fichiers) { const brut = await readFile(join(DATA, f), 'utf8'); let e; try { e = JSON.parse(brut); } catch (ex) { err(f, `JSON illisible : ${ex.message}`); continue; } // ── Couche 1 : la forme if (!valider(e)) { for (const d of valider.errors ?? []) err(f, `schéma : ${d.instancePath || '/'} ${d.message}`); continue; } // ── Couche 2 : les règles métier if (e.id !== basename(f, '.json')) err(f, `id « ${e.id} » ≠ nom de fichier`); if (vus.has(e.id)) err(f, `id « ${e.id} » déjà utilisé par ${vus.get(e.id)}`); vus.set(e.id, f); const idsMedias = new Set(); for (const m of e.medias) { nbMedias++; if (m.valide) nbMediasValides++; if (m.usage_non_commercial) nbNC++; if (idsMedias.has(m.id)) err(f, `média « ${m.id} » en double`); idsMedias.add(m.id); const identifiant = m.usages.some((u) => USAGES_IDENTIFIANTS.has(u)); // LA PORTE — brief §2 et §10. if (identifiant) { if (m.filiere !== 'A') err(f, `média « ${m.id} » sert à identifier mais relève de la filière ${m.filiere} : une image générée n'enseigne pas le réel`); if (m.type_media !== 'photo') err(f, `média « ${m.id} » sert à identifier mais n'est pas une photo (${m.type_media})`); if (!m.valide) err(f, `média « ${m.id} » sert à identifier mais n'est pas validé — aucun humain n'a confirmé qu'il montre bien ${e.taxon.scientifique}`); if (!m.vue) err(f, `média « ${m.id} » sert à identifier mais sa vue n'est pas renseignée`); } // Cohérence des filières if (m.filiere === 'B' && m.type_media !== 'image_generee') err(f, `média « ${m.id} » : filière B impose type_media = image_generee`); if (m.filiere === 'B' && m.usages.some((u) => u !== 'decor')) err(f, `média « ${m.id} » : un média de filière B ne peut servir qu'au décor`); if (m.filiere === 'A' && m.type_media === 'image_generee') err(f, `média « ${m.id} » : filière A n'accepte aucune image générée`); // Licences — brief §3 if (m.attribution_requise && !m.auteur) err(f, `média « ${m.id} » : licence ${m.licence} à attribution, auteur manquant`); if (m.composite && (m.partage_identique || m.usage_non_commercial)) err(f, `média « ${m.id} » : compositing interdit sur un média SA ou NC (conflit de licence, ADR 0001)`); if (m.valide && !m.valide_par) err(f, `média « ${m.id} » : validé sans nom de validateur — une validation anonyme n'en est pas une`); // Signalement, pas blocage : l'ADR 0001 accepte le NC contre un engagement non commercial. if (m.usage_non_commercial && m.source === 'wikimedia_commons') warn(f, `média « ${m.id} » : NC annoncé sur une source Commons — vérifier, Commons n'héberge pas de NC`); } // Sécurité enfants — brief §6 : « Pa touché » n'est jamais une épreuve, jamais scoré. if (e.categorie === 'plante_dangereuse') { if (!e.danger) err(f, 'plante dangereuse sans bloc danger'); for (const m of e.medias) if (m.usages.includes('epreuve')) err(f, `média « ${m.id} » : une plante dangereuse ne peut pas porter d'épreuve — le module Pa touché n'est ni chronométré ni scoré`); } // Conditions de publication d'une entrée if (e.valide) { const photos = e.medias.filter((m) => m.valide && m.filiere === 'A' && m.type_media === 'photo'); if (photos.length < 2) err(f, `entrée déclarée valide avec ${photos.length} photo(s) validée(s) — le brief §8 en exige au moins deux`); if (e.orthographe.statut !== 'relu') err(f, "entrée déclarée valide alors que l'orthographe kréyòl n'est pas relue (ADR 0005)"); } else { warn(f, 'entrée non encore validée pour publication'); } if (!e.audio.gcf) warn(f, 'aucun enregistrement kréyòl — le jeu devra se replier en silence'); if (e.orthographe.statut === 'a_relire') warn(f, 'orthographe kréyòl à relire'); entrees.push(e); } // ── Rapport console.log(`\nEntrées : ${entrees.length}`); console.log(`Médias : ${nbMedias} (${nbMediasValides} validés, ${nbNC} sous clause NC)`); console.log(`Publiables : ${entrees.filter((e) => e.valide).length}`); if (alertes.length) { console.log(`\n⚠ ${alertes.length} alerte(s) — n'empêchent pas le build :`); for (const a of alertes) console.log(` · ${a}`); } if (erreurs.length) { console.error(`\n✘ ${erreurs.length} erreur(s) — build refusé :`); for (const e of erreurs) console.error(` · ${e}`); process.exit(1); } console.log('\n✔ Dataset conforme. Aucune épreuve ne s’appuie sur un média non validé.'); } main().catch((e) => { console.error('✘', e.stack ?? e.message); process.exit(1); });