2026-07-26 23:47:27 -04:00
|
|
|
|
#!/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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-27 10:40:23 -04:00
|
|
|
|
// Langue — ADR 0005. On ne tranche pas l'orthographe, mais on ne bâcle pas la question.
|
|
|
|
|
|
if (e.orthographe.statut === 'en_discussion' && !e.orthographe.question_ouverte)
|
|
|
|
|
|
err(f, "orthographe « en discussion » sans question posée — on ne signale pas un doute sans dire lequel");
|
|
|
|
|
|
if (e.orthographe.statut === 'relu' && !e.orthographe.relu_par)
|
|
|
|
|
|
err(f, 'orthographe déclarée relue sans nom de relecteur');
|
|
|
|
|
|
for (const v of e.noms.variantes_gcf ?? [])
|
|
|
|
|
|
if (v.graphie === e.noms.gcf)
|
|
|
|
|
|
err(f, `variante « ${v.graphie} » identique au nom principal — c'est une corroboration, pas une variante`);
|
|
|
|
|
|
|
|
|
|
|
|
// Prononciation — brief §6 : voix humaine, jamais de synthèse.
|
|
|
|
|
|
if (e.audio.gcf) {
|
|
|
|
|
|
if (!e.audio.locuteur) err(f, 'enregistrement sans locuteur crédité — il conserve ses droits voisins');
|
|
|
|
|
|
if (!e.audio.licence) err(f, 'enregistrement sans licence déclarée');
|
|
|
|
|
|
if (e.audio.source && e.audio.source !== 'porteur' && !e.audio.url_description)
|
|
|
|
|
|
err(f, `enregistrement de source « ${e.audio.source} » sans page d'origine`);
|
|
|
|
|
|
if (!e.audio.valide) warn(f, 'enregistrement présent mais non validé — le jeu se repliera en silence');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
warn(f, 'aucun enregistrement kréyòl — le jeu devra se replier en silence');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 23:47:27 -04:00
|
|
|
|
if (e.orthographe.statut === 'a_relire') warn(f, 'orthographe kréyòl à relire');
|
2026-07-27 10:40:23 -04:00
|
|
|
|
if (e.orthographe.statut === 'en_discussion') warn(f, 'orthographe kréyòl en discussion — question posée aux locuteurs');
|
2026-07-26 23:47:27 -04:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|