Files
ki-fwi/tools/data/validate.mjs
T
OKIandClaude Opus 5 945a524b44 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>
2026-07-26 23:47:27 -04:00

164 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 sappuie sur un média non validé.');
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});