// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Le manifeste des captures et ses contrôles du niveau node (§ 19.3, § 19.5, // § 19.7). Pur : aucune lecture de fichier, aucune horloge. L'appelant lit le // manifeste, la documentation et le dossier des images, puis passe leurs // contenus ; chaque contrôle rend la liste de ses fautes, vide quand tout // tient, et refuse de passer sur un balayage vide. // // Un nom de capture (« fichier ») n'a pas d'extension : l'image est // doc/captures/.png. L'ordre déclaré des captures et des reportées // est celui de leurs noms, comparés unité de code par unité de code : le // préfixe NN, puis « a », « b », range chaque entrée à sa place dans le guide. // // Une faute est { chemin, faute } : le chemin du fichier en cause, relatif à // la racine du projet (l'image d'une capture, le manifeste, un document), et // la nature de la faute en toutes lettres. import { createHash } from 'node:crypto'; import { VERSION } from '../../src/version.genere.js'; /** @typedef {{chemin: string, faute: string}} Faute */ export const DOSSIER_CAPTURES = 'doc/captures'; export const CHEMIN_MANIFESTE = `${DOSSIER_CAPTURES}/manifeste.json`; /** Chemin de l'image d'une capture, relatif à la racine du projet. */ export function cheminImage(fichier) { return `${DOSSIER_CAPTURES}/${fichier}.png`; } // Les treize étapes du parcours de l'opérateur (§ 2.1), dans leur ordre : la // seule source de l'ordre des captures (§ 19.3). export const ETAPES = Object.freeze(['1', '1b', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']); const capture = (fichier, etape, configuration, source = 'navigateur') => Object.freeze({ fichier, etape, configuration, source }); // La table du § 19.3 en données, avec 02b, 07a et 07b. « configuration » nomme // ce que la capture exige : le répertoire neuf, le fichier d'import // d'exemples/, l'identifiant de démonstration du catalogue, ou l'événement que // le parcours construit puis retient. export const CAPTURES = Object.freeze([ capture('01-premier-ecran', '1', 'repertoire-neuf'), capture('01b-passer-en-ecriture', '1b', 'repertoire-neuf'), capture('02-import-apercu', '2', 'participants_cas_limites.csv'), capture('02b-import-cp1252', '2', 'participants_cas_limites_cp1252.csv'), capture('03-import-rapport', '2', 'participants_cas_limites.csv'), capture('04-grille-participants', '3', 'evenement-construit'), capture('05-tables-etat', '4', 'evenement-construit'), capture('06-defaut-consequence', '4', 'evenement-construit'), capture('07-plan-tables', '4', 'evenement-construit'), capture('07a-cibles-sieges', '4', 'evenement-construit'), capture('07a-cibles-tables', '4', 'evenement-construit'), capture('07b-nombre-de-tours', '5', 'evenement-construit'), capture('08-animateurs-designes', '6', 'evenement-construit'), capture('09-refus-sur-place', '7', 'evenement-construit'), capture('10-diagnostic', '8', 'grande'), capture('11-propositions', '9', 'evenement-construit'), capture('19-ajustement', '11', 'evenement-retenu'), capture('20-mode-lecture', '11', 'evenement-retenu'), capture('22-plan-brouillon', '12', 'evenement-retenu'), capture('23-plan-retenu', '12', 'evenement-retenu'), capture('24-badges-controle', '12', 'evenement-retenu', 'pdf'), capture('25-badges-planche', '12', 'evenement-retenu', 'pdf'), ]); const reportee = (fichier, etape) => Object.freeze({ fichier, etape, raison: 'iteration5' }); // Les captures des écrans que l'itération 5 livre (Q8 a) : déclarées à leur // étape, sans image ; le contrôle d'ordre les compte comme présentes. export const REPORTEES = Object.freeze([ reportee('12-qualite-profil', '10'), reportee('13-qualite-collisions', '10'), reportee('14-qualite-paires', '10'), reportee('15-pas-de-matrice', '10'), reportee('16-minimum-atteint', '10'), reportee('17-comparaison-manque', '10'), reportee('18-comparaison-escalier', '10'), reportee('21-historique', '11'), ]); // Captures déclarées dans CAPTURES et pas encore photographiées : la // publication et le contrôle d'ordre les admettent absentes du manifeste. export const A_VENIR = Object.freeze(['22-plan-brouillon', '23-plan-retenu', '24-badges-controle', '25-badges-planche']); // --- Forme canonique et empreintes --------------------------------------------- const comparer = (a, b) => (a < b ? -1 : a > b ? 1 : 0); function estObjet(valeur) { return typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur); } // JSON d'une valeur, les clés de chaque objet triées par unités de code, sans // espace. Lève TypeError sur ce que JSON ne porte pas fidèlement : undefined, // fonction, symbole, grand entier, nombre non fini. function json(valeur) { if (valeur === null || typeof valeur === 'string' || typeof valeur === 'boolean') return JSON.stringify(valeur); if (typeof valeur === 'number') { if (!Number.isFinite(valeur)) throw new TypeError(`nombre non fini dans le manifeste : ${valeur}`); return JSON.stringify(valeur); } if (Array.isArray(valeur)) return `[${valeur.map(json).join(',')}]`; if (estObjet(valeur)) { const cles = Object.keys(valeur).sort(comparer); return `{${cles.map((cle) => `${JSON.stringify(cle)}:${json(valeur[cle])}`).join(',')}}`; } throw new TypeError(`valeur que JSON ne porte pas dans le manifeste : ${typeof valeur}`); } const sha256 = (donnees) => createHash('sha256').update(donnees).digest('hex'); /** * Le manifeste en JSON canonique : clés triées à toute profondeur, chaque * élément d'un tableau de premier niveau sur sa ligne (une capture, une * reportée), le reste sur la ligne de sa clé, une fin de ligne finale. Deux * manifestes égaux rendent les mêmes octets, quel que soit l'ordre de leurs * clés ; l'ordre des tableaux est conservé. * @param {object} manifeste * @returns {string} */ export function canoniser(manifeste) { if (!estObjet(manifeste)) throw new TypeError('le manifeste est un objet'); const champs = Object.keys(manifeste).sort(comparer).map((cle) => { const valeur = manifeste[cle]; const tete = `${JSON.stringify(cle)}:`; if (Array.isArray(valeur) && valeur.length > 0) return `${tete}[\n${valeur.map(json).join(',\n')}\n]`; return tete + json(valeur); }); return `{${champs.join(',')}}\n`; } /** * Empreinte d'étape (§ 19.5, amendé) : SHA-256 hexadécimal du JSON canonique * de l'entrée, sans image, empreinte, versionReelle ni versionNavigateur, à * plat ou sous pilote. La version réelle et celle du navigateur changent * d'une livraison et d'un poste à l'autre sans que l'état montré change : * les garder réécrirait chaque image à chaque incrément. * @param {object} entree une entrée de manifeste.captures * @returns {string} */ export function empreinteEtape(entree) { if (!estObjet(entree)) throw new TypeError('une entrée de manifeste est un objet'); const { image: _image, empreinte: _empreinte, versionReelle: _version, versionNavigateur: _navigateur, ...reste } = entree; if (estObjet(reste.pilote)) { const { versionNavigateur: _versionPilote, ...pilote } = reste.pilote; reste.pilote = pilote; } return sha256(json(reste)); } // --- Appariement documentation, manifeste, disque -------------------------------- const MOTIF_REFERENCE = /doc\/captures\/[A-Za-z0-9._-]+\.png/g; /** * Les images de doc/captures qu'un document référence, sous la forme * doc/captures/.png quel que soit le préfixe relatif qui les précède, * chacune une fois, dans l'ordre de leur première apparition. * @param {string} texte * @returns {string[]} */ export function imagesReferencees(texte) { if (typeof texte !== 'string') throw new TypeError('le texte d’un document est une chaîne'); return [...new Set(texte.match(MOTIF_REFERENCE) ?? [])]; } function exigerTableau(valeur, nom) { if (!Array.isArray(valeur)) throw new TypeError(`${nom} est un tableau`); } function capturesDe(manifeste) { if (!estObjet(manifeste)) throw new TypeError('le manifeste est un objet'); const captures = manifeste.captures ?? []; exigerTableau(captures, 'manifeste.captures'); return captures; } const enListe = (parties) => (parties.length === 1 ? parties[0] : `${parties.slice(0, -1).join(', ')} et ${parties.at(-1)}`); /** * L'appariement du § 19.7 : toute image référencée par la documentation a * son entrée au manifeste et son fichier, toute entrée et tout fichier sont * référencés. Une image absente d'un côté au moins est une orpheline : une * faute par image, qui dit où elle est et d'où elle manque. Un manifeste sans * capture, une documentation vide, un document sans image, un dossier sans * image sont chacun une faute. * @param {{manifeste: object, references: Array<{document: string, images: string[]}>, images: string[]}} entrees * references : par document, les chemins doc/captures/.png qu'il * référence ; images : les chemins des PNG présents sur le disque * @returns {Faute[]} les balayages vides, puis les images par chemin */ export function verifierAppariement({ manifeste, references, images }) { const captures = capturesDe(manifeste); exigerTableau(references, 'references'); exigerTableau(images, 'images'); const fautes = []; if (captures.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucune capture au manifeste' }); if (references.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucun document ne référence d’image' }); if (images.length === 0) fautes.push({ chemin: DOSSIER_CAPTURES, faute: 'aucune image sur le disque' }); const documentsDe = new Map(); for (const { document, images: referencees } of references) { exigerTableau(referencees, `les images de ${document}`); if (referencees.length === 0) fautes.push({ chemin: document, faute: 'aucune image référencée' }); for (const image of referencees) { const documents = documentsDe.get(image) ?? []; if (!documents.includes(document)) documents.push(document); documentsDe.set(image, documents); } } const auManifeste = new Set(); const doubles = new Set(); for (const entree of captures) { const chemin = cheminImage(entree.fichier); if (auManifeste.has(chemin)) doubles.add(chemin); auManifeste.add(chemin); } const surDisque = new Set(images); const tous = [...new Set([...documentsDe.keys(), ...auManifeste, ...surDisque])].sort(comparer); for (const chemin of tous) { if (doubles.has(chemin)) fautes.push({ chemin, faute: 'deux fois au manifeste' }); const presences = []; const absences = []; if (documentsDe.has(chemin)) presences.push(`référencée par ${documentsDe.get(chemin).join(', ')}`); else absences.push('de la documentation'); if (auManifeste.has(chemin)) presences.push('au manifeste'); else absences.push('du manifeste'); if (surDisque.has(chemin)) presences.push('sur le disque'); else absences.push('du disque'); if (absences.length > 0) fautes.push({ chemin, faute: `orpheline : ${presences.join(', ')} ; absente ${enListe(absences)}` }); } return fautes; } // --- Ordre contre les étapes ------------------------------------------------------ /** * Le contrôle d'ordre du § 19.3 : les captures et les reportées du * manifeste, plus chaque capture d'aVenir absente du manifeste à son étape * déclarée dans CAPTURES, rangées par nom. Faute : une étape hors d'ETAPES ; * un nom déclaré deux fois ; une entrée dont l'étape précède celle de * l'entrée connue qui la devance ; une étape d'ETAPES sans aucune entrée ; un * manifeste sans aucune entrée. * @param {object} manifeste * @param {{aVenir?: readonly string[]}} [options] A_VENIR par défaut * @returns {Faute[]} les fautes d'entrée dans l'ordre des noms, puis les étapes manquantes */ export function verifierOrdre(manifeste, { aVenir = A_VENIR } = {}) { const captures = capturesDe(manifeste); const reportees = manifeste.reportees ?? []; exigerTableau(reportees, 'manifeste.reportees'); const presentes = new Set(captures.map((c) => c.fichier)); const admises = CAPTURES.filter((c) => aVenir.includes(c.fichier) && !presentes.has(c.fichier)); const entrees = [...captures, ...reportees, ...admises] .map(({ fichier, etape }) => ({ fichier, etape })) .sort((a, b) => comparer(a.fichier, b.fichier)); const fautes = []; if (entrees.length === 0) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: 'aucune capture ni reportée' }); const vues = new Set(); const couvertes = new Set(); let precedente = null; for (const entree of entrees) { const chemin = cheminImage(entree.fichier); if (vues.has(entree.fichier)) fautes.push({ chemin, faute: 'déclarée deux fois' }); vues.add(entree.fichier); const rang = ETAPES.indexOf(entree.etape); if (rang < 0) { fautes.push({ chemin, faute: `étape inconnue « ${entree.etape} »` }); continue; } couvertes.add(entree.etape); if (precedente !== null && rang < precedente.rang) { fautes.push({ chemin, faute: `étape ${entree.etape} après ${precedente.fichier}, de l’étape ${precedente.etape} : l’ordre contredit les étapes`, }); } precedente = { ...entree, rang }; } for (const etape of ETAPES) { if (!couvertes.has(etape)) fautes.push({ chemin: CHEMIN_MANIFESTE, faute: `étape ${etape} : aucune capture ni reportée` }); } return fautes; } // --- Version ---------------------------------------------------------------------- /** * Écart entre la version réelle inscrite au manifeste et celle de la * construction (§ 19.7) : null quand chaque capture porte la version * attendue ; sinon { manifeste, construction }, où manifeste nomme les * versions distinctes des captures, dans leur ordre, séparées par « , » * (« absente » pour une capture qui n'en porte pas), et vaut null pour un * manifeste sans capture, qui n'établit aucune version. * @param {object} manifeste * @param {string} [version] VERSION.affichee par défaut * @returns {null | {manifeste: string|null, construction: string}} */ export function ecartDeVersion(manifeste, version = VERSION.affichee) { if (typeof version !== 'string') throw new TypeError('la version de la construction est une chaîne'); const captures = capturesDe(manifeste); if (captures.length === 0) return { manifeste: null, construction: version }; const versions = [...new Set(captures.map((c) => (typeof c.versionReelle === 'string' ? c.versionReelle : 'absente')))]; if (versions.length === 1 && versions[0] === version) return null; return { manifeste: versions.join(', '), construction: version }; } // --- Image -------------------------------------------------------------------------- const SIGNATURE_PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; const LIMITE_PNG = 2 ** 31 - 1; /** * Largeur et hauteur d'un PNG, lues dans son morceau IHDR, qui suit la * signature (octets 16 à 23, gros-boutiste). Lève sur une signature fausse, * un en-tête tronqué, un premier morceau qui n'est pas un IHDR de 13 octets, * une dimension nulle ou au-delà de 2³¹ − 1. * @param {Uint8Array} octets * @returns {{largeur: number, hauteur: number}} */ export function lireEntetePng(octets) { if (!(octets instanceof Uint8Array)) throw new TypeError('les octets d’une image sont un Uint8Array'); if (octets.length < SIGNATURE_PNG.length || SIGNATURE_PNG.some((octet, rang) => octets[rang] !== octet)) { throw new Error('signature PNG absente'); } if (octets.length < 24) throw new Error(`en-tête PNG tronqué : ${octets.length} octets`); const vue = new DataView(octets.buffer, octets.byteOffset, octets.byteLength); const type = String.fromCharCode(...octets.subarray(12, 16)); if (vue.getUint32(8) !== 13 || type !== 'IHDR') throw new Error('premier morceau PNG autre qu’un IHDR de 13 octets'); const largeur = vue.getUint32(16); const hauteur = vue.getUint32(20); if (largeur === 0 || largeur > LIMITE_PNG) throw new Error(`largeur PNG hors domaine : ${largeur}`); if (hauteur === 0 || hauteur > LIMITE_PNG) throw new Error(`hauteur PNG hors domaine : ${hauteur}`); return { largeur, hauteur }; } /** * Les fautes du fichier image d'une entrée : son SHA-256 diffère de * image.sha256 (une image remplacée, par une version antérieure ou par une * autre) ; il ne se lit pas comme un PNG ; ses dimensions diffèrent du cadre * multiplié par le facteur d'échelle (arrondi à l'entier), ou de celles * qu'inscrit image. Le contrôle porte sur les octets du fichier, jamais sur * des pixels (§ 19.4). * @param {object} entree une entrée de manifeste.captures * @param {Uint8Array} octets le contenu du fichier * @returns {Faute[]} */ export function fautesDImage(entree, octets) { if (!estObjet(entree)) throw new TypeError('une entrée de manifeste est un objet'); const chemin = cheminImage(entree.fichier); const image = estObjet(entree.image) ? entree.image : {}; const fautes = []; if (sha256(octets) !== image.sha256) fautes.push({ chemin, faute: 'image remplacée : son empreinte diffère de celle du manifeste' }); let lue; try { lue = lireEntetePng(octets); } catch (erreur) { fautes.push({ chemin, faute: `illisible : ${erreur.message}` }); return fautes; } const facteur = entree.facteurEchelle; const largeur = Math.round(entree.cadre?.largeur * facteur); const hauteur = Math.round(entree.cadre?.hauteur * facteur); if (lue.largeur !== largeur || lue.hauteur !== hauteur) { fautes.push({ chemin, faute: `dimensions ${lue.largeur} × ${lue.hauteur}, attendues ${largeur} × ${hauteur} (cadre × facteur)` }); } if (lue.largeur !== image.largeur || lue.hauteur !== image.hauteur) { fautes.push({ chemin, faute: `dimensions ${lue.largeur} × ${lue.hauteur}, le manifeste inscrit ${image.largeur} × ${image.hauteur}` }); } return fautes; } // --- Chemins hors de la racine neutre -------------------------------------------------- // Un chemin absolu commence en début de texte ou après l'un de ces // caractères. Le deux-points en est (« poste:/home/… ») : le « // » d'une // adresse web reste écarté, car une barre suivie d'une barre n'ouvre pas un // chemin POSIX et un chemin réseau ne suit pas un deux-points. Un chiffre, // une lettre, un point ou une barre avant « / » font une fraction ou un // chemin relatif. const AVANT_CHEMIN = new Set([...' \t\n\r\f\v([{<"\'«“‘=,;:>', '\u{a0}', '\u{202f}']); // Le schéma d'une adresse de fichier, comparé sans casse. const SCHEMA_FICHIER = 'file://'; // Un chemin s'arrête à un blanc ou à l'un de ces caractères… const FIN_CHEMIN = new Set([...'"\'<>|«»“”‘', '\u{a0}', '\u{202f}']); // … et perd la ponctuation qui le suit dans la phrase. const PONCTUATION_FINALE = new Set([...'.,;:!?)]}»’”']); const estBlanc = (caractere) => /\s/u.test(caractere); const estLettre = (caractere) => /^[A-Za-z]$/.test(caractere); // La forme d'un chemin absolu qui commence à la position rang : 'windows' // (lecteur, chemin réseau sous l'une ou l'autre barre), 'posix' (racine, // dossier personnel abrégé), 'fichier' (adresse file://, quoi qui la // précède), ou null. function formeEn(texte, rang) { if (texte.slice(rang, rang + SCHEMA_FICHIER.length).toLowerCase() === SCHEMA_FICHIER) return 'fichier'; const avant = rang === 0 ? null : texte[rang - 1]; const borne = avant === null || AVANT_CHEMIN.has(avant) || estBlanc(avant); const c = texte[rang]; const suivant = texte[rang + 1]; if (estLettre(c) && suivant === ':' && (texte[rang + 2] === '\\' || texte[rang + 2] === '/')) { return borne ? 'windows' : null; } if (!borne) return null; const troisieme = texte[rang + 2]; const ouvreReseau = (barre) => c === barre && suivant === barre && troisieme !== undefined && troisieme !== barre && !estBlanc(troisieme); if (ouvreReseau('\\') || (avant !== ':' && ouvreReseau('/'))) return 'windows'; if (c === '/' && suivant !== undefined && suivant !== '/' && !estBlanc(suivant)) return 'posix'; if (c === '~' && /^~[A-Za-z0-9._-]*\//.test(texte.slice(rang, rang + 64))) return 'posix'; return null; } function finDuJeton(texte, debut) { let fin = debut; while (fin < texte.length && !estBlanc(texte[fin]) && !FIN_CHEMIN.has(texte[fin])) fin += 1; while (fin > debut && PONCTUATION_FINALE.has(texte[fin - 1])) fin -= 1; return fin; } // Le chemin que porte une adresse file:// et sa forme : « file:///C:/… » // donne un lecteur, « file:///… » un chemin POSIX, « file://hôte/… » un // chemin réseau. Les séquences %XX sont décodées ; une séquence invalide // laisse le chemin tel qu'écrit. function cheminDeFichier(adresse) { const reste = adresse.slice(SCHEMA_FICHIER.length - 2); let chemin = reste.startsWith('///') ? reste.slice(2) : reste; try { chemin = decodeURIComponent(chemin); } catch { // le chemin reste encodé, et se compare tel quel } if (/^\/[A-Za-z]:[\\/]/.test(chemin)) return { forme: 'windows', chemin: chemin.slice(1) }; if (chemin.startsWith('//')) return { forme: 'windows', chemin }; return { forme: 'posix', chemin }; } const normaliserWindows = (chemin) => chemin.replaceAll('/', '\\').toLowerCase(); const estSeparateur = (caractere) => caractere === '/' || caractere === '\\'; function formeDeRacine(racine) { if (typeof racine !== 'string' || racine.length === 0) throw new TypeError('la racine neutre est un chemin absolu non vide'); if (/^[A-Za-z]:[\\/]/.test(racine) || racine.startsWith('\\\\')) return 'windows'; if (racine.startsWith('/')) return 'posix'; throw new TypeError(`la racine neutre n’est pas un chemin absolu : « ${racine} »`); } // La racine sans ses séparateurs finaux, sauf celui d'une racine de lecteur // ou de système (« C:\ », « / »). function sansSeparateurFinal(racine) { let fin = racine.length; while (fin > 1 && estSeparateur(racine[fin - 1]) && !/^[A-Za-z]:$/.test(racine.slice(0, fin - 1))) fin -= 1; return racine.slice(0, fin); } /** * Les chemins absolus d'un texte qui ne sont ni la racine neutre ni sous * elle (§ 19.4) : chemins de lecteur et réseau de Windows, chemins POSIX, * dossier personnel abrégé « ~/ », adresses file://. Une adresse se juge * sur le chemin décodé qu'elle porte et se relève telle qu'écrite. Sous * Windows, la comparaison ignore la casse et le sens des barres. La racine * se reconnaît entière même quand elle porte une espace ; un autre chemin * s'arrête au premier blanc, ce qui suffit à relever la part qui nomme un * compte. Chaque chemin relevé une fois, dans l'ordre du texte. * @param {string} texte * @param {string} racine la racine neutre, absolue * @returns {string[]} */ export function cheminsHorsRacine(texte, racine) { if (typeof texte !== 'string') throw new TypeError('le texte relevé est une chaîne'); const formeRacine = formeDeRacine(racine); const base = sansSeparateurFinal(racine); const commeRacine = formeRacine === 'windows' ? normaliserWindows : (chemin) => chemin; const baseComparee = commeRacine(base); const sousLaRacine = (chemin) => { if (commeRacine(chemin.slice(0, base.length)) !== baseComparee) return false; const reste = chemin.slice(base.length); return reste === '' || estSeparateur(reste[0]) || estSeparateur(base.at(-1)); }; const releves = []; let rang = 0; while (rang < texte.length) { const forme = formeEn(texte, rang); if (forme === null) { rang += 1; continue; } let fin; let dessous; if (forme === 'fichier') { fin = finDuJeton(texte, rang); const porte = cheminDeFichier(texte.slice(rang, fin)); dessous = porte.forme === formeRacine && sousLaRacine(porte.chemin); } else { // La racine se reconnaît avant la coupe au blanc, qu'elle peut porter. const aLaRacine = forme === formeRacine && commeRacine(texte.slice(rang, rang + base.length)) === baseComparee; fin = finDuJeton(texte, aLaRacine ? rang + base.length : rang); dessous = aLaRacine && sousLaRacine(texte.slice(rang, fin)); } if (!dessous) { const chemin = texte.slice(rang, fin); if (!releves.includes(chemin)) releves.push(chemin); } rang = Math.max(fin, rang + 1); } return releves; } // --- Un relevé ne nomme pas sa machine (contrainte 27) --------------------------------- const CLES_MANIFESTE = ['captures', 'format', 'pilote', 'reportees']; const CLES_PILOTE = ['cadre', 'facteurEchelle', 'mode', 'navigateur', 'versionNavigateur']; const CLES_CADRE = ['hauteur', 'largeur']; const CLES_CAPTURE = [ 'cadre', 'empreinte', 'etape', 'etat', 'facteurEchelle', 'fichier', 'graine', 'image', 'instant', 'mode', 'pdf', 'source', 'styles', 'texte', 'theme', 'versionReelle', ]; const CLES_IMAGE = ['hauteur', 'largeur', 'sha256']; const CLES_REPORTEE = ['etape', 'fichier', 'raison']; function clesHorsContrat(objet, permises, prefixe) { if (!estObjet(objet)) return []; return Object.keys(objet) .filter((cle) => !permises.includes(cle)) .sort(comparer) .map((cle) => `clé hors du contrat : « ${prefixe}${cle} »`); } function chainesDe(valeur) { if (typeof valeur === 'string') return [valeur]; if (Array.isArray(valeur)) return valeur.flatMap(chainesDe); if (estObjet(valeur)) return Object.keys(valeur).sort(comparer).flatMap((cle) => [cle, ...chainesDe(valeur[cle])]); return []; } function cheminsDe(valeur, racine) { const releves = chainesDe(valeur).flatMap((chaine) => cheminsHorsRacine(chaine, racine)); return [...new Set(releves)].map((chemin) => `chemin hors de la racine neutre : « ${chemin} »`); } /** * La garde de la contrainte 27 : un manifeste ne nomme ni son hôte, ni un * compte, ni un utilisateur, ni une date de la machine. Les clés du * manifeste, du pilote, de chaque capture, de son cadre et de son image, et * de chaque reportée forment des listes fermées, celles du contrat : un * champ « hote » ou « date » est une clé hors du contrat. Toute chaîne, clés * comprises, est passée à cheminsHorsRacine. * @param {object} manifeste * @param {string} racine la racine neutre des captures * @returns {Faute[]} le manifeste et son pilote, puis chaque capture et chaque reportée, dans leur ordre */ export function fautesDeMachine(manifeste, racine) { const captures = capturesDe(manifeste); const reportees = manifeste.reportees ?? []; exigerTableau(reportees, 'manifeste.reportees'); const fautes = []; const ajouter = (chemin, natures) => { for (const faute of natures) fautes.push({ chemin, faute }); }; const { captures: _captures, reportees: _reportees, ...tete } = manifeste; ajouter(CHEMIN_MANIFESTE, [ ...clesHorsContrat(manifeste, CLES_MANIFESTE, ''), ...clesHorsContrat(manifeste.pilote, CLES_PILOTE, 'pilote.'), ...clesHorsContrat(manifeste.pilote?.cadre, CLES_CADRE, 'pilote.cadre.'), ...cheminsDe(tete, racine), ]); for (const entree of captures) { ajouter(cheminImage(entree?.fichier), [ ...clesHorsContrat(entree, CLES_CAPTURE, ''), ...clesHorsContrat(entree?.cadre, CLES_CADRE, 'cadre.'), ...clesHorsContrat(entree?.image, CLES_IMAGE, 'image.'), ...cheminsDe(entree, racine), ]); } for (const entree of reportees) { ajouter(cheminImage(entree?.fichier), [...clesHorsContrat(entree, CLES_REPORTEE, ''), ...cheminsDe(entree, racine)]); } return fautes; }