Files

184 lines
7.9 KiB
JavaScript
Raw Permalink Normal View History

#!/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');
}
// 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');
}
if (e.orthographe.statut === 'a_relire') warn(f, 'orthographe kréyòl à relire');
if (e.orthographe.statut === 'en_discussion') warn(f, 'orthographe kréyòl en discussion — question posée aux locuteurs');
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 sappuie sur un média non validé.');
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});