feat(session-0): cadre juridique, porte de validation et dataset vérifié
Session 0 du plan — aucun code applicatif. Cadre - 5 ADR : licences, Phaser 4 hors budget initial, frontière Svelte/Phaser, filières A/B des assets, kréyòl langue principale (norme gwadloupéyenne). - LICENSE (AGPL-3.0-or-later, texte canonique gnu.org), LICENSE-CONTENT (CC BY-SA 4.0). Le jeu est une œuvre collective : aucun compositing sur un média SA ou NC, contrainte vérifiée par script. - Audit des 7 sources avec endpoints réellement appelés. TRAMIL vérifié en direct : « all rights reserved » et page de mentions légales en 404. iNaturalist mesuré à 93 % de CC BY-NC sur un échantillon de 305 photos. Outillage (tools/data/, hors bundle, zéro appel réseau au runtime) - verify:taxa, ingest:commons, build:dataset, validate:data, test:porte, build:credits, check:audio. - validate.mjs est la porte exigée par le brief §2 : le build échoue si une épreuve s'appuie sur un média non validé, non photographique ou de filière B, sur une attribution sans auteur, sur un compositing SA/NC, ou sur une entrée publiée sans relecture kréyòl. test:porte prouve les 9 refus + 1 témoin. Données - 13 entrées (8 fruits + 5 plantes du module Pa touché) vérifiées contre Wikidata ET GBIF : 13/13 ACCEPTED, 0 divergence avec la graine. - 130 candidats Commons sous licence libre, 39 pré-inscrits, 0 validé — aucun agent ne peut valider un média. Écarts signalés, non corrigés en silence - Le champ danger de la graine a deux formes (note vs resume) : normalisé. - 3 identifiants divergent du nom kréyòl au-delà des accents : non tranchés. - comestible:false sur akée et pomme-cajou : sous-estimation prudente assumée. - Le mancenillier n'a aucune photographie moderne dans ses 3 premiers candidats — l'entrée la plus critique est la moins bien servie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user