Amendement de l'ADR 0005 : la relecture ne repose plus sur un relecteur unique. La porte ne s'abaisse pas — l'ouverture porte sur qui peut proposer, jamais sur qui peut valider. La communauté existait déjà - Lingua Libre compte 456 enregistrements en kréyòl gwadloupéyen, versés sur Commons sous licence libre. - Trois de nos treize noms y figuraient déjà — gouyav, kénèt, zikak — en CC BY-SA 4.0 et AVEC EXACTEMENT NOTRE GRAPHIE. Trois entrées sortent du doute par corroboration externe, sans que le projet décide quoi que ce soit. La question n'était pas qui trancherait, mais d'aller regarder. - En regard : 0 lexème Wikidata en gcf, 0 étiquette gcf sur les 13 items. Modèle de données - orthographe.statut : a_relire | en_discussion | relu. Un doute signalé sans question posée est refusé par le validateur — on ne décourage pas le lecteur, on l'appelle. 4 entrées en discussion : ake, medisinye, lorye-jonn, pom-dlo. - noms.variantes_gcf : les usages proposés sont conservés avec leur source et leur île, jamais écrasés. Une graphie identique au nom principal est refusée comme variante : c'est une corroboration, pas un désaccord. - audio : source, licence, url_description, locuteur crédité, valide. check:audio interroge Lingua Libre et trouve seul les prises existantes. Porte : 14/14 (5 cas ajoutés sur la langue et la prononciation). Pis-aller assumé : les 4 questions sont rédigées en français. Les écrire en kréyòl reviendrait à produire du kréyòl non relu au moment même où l'on reconnaît ne pas savoir le faire. Leur traduction est la première contribution demandée. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
7.9 KiB
JavaScript
184 lines
7.9 KiB
JavaScript
#!/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 s’appuie sur un média non validé.');
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error('✘', e.stack ?? e.message);
|
||
process.exit(1);
|
||
});
|