// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Le relevé du banc de mesure (§ 19.10, § 14.1) et son accord avec le // registre des constantes. Le banc écrit un relevé, scripts/banc/releve.json, // jamais le registre : adopter une valeur est un geste du développeur, qui // l'écrit dans src/geometrie/constantes.js, passe le statut à « mesuree » et // fait citer à la configuration de l'entrée le résumé de celle du relevé. // accordRegistre refuse ensuite toute constante du banc mesurée que le relevé // ne soutient pas. Le relevé ne nomme pas sa machine : sa configuration est // une liste fermée de champs, et aucun texte n'y porte un chemin de compte // ni une date. Fonctions pures, sans lecture de fichier ni horloge. import { CONSTANTES } from '../../src/geometrie/constantes.js'; /** * @typedef {{chemin: string, faute: string}} Faute * @typedef {{cadre: {largeur: number, hauteur: number}, facteurEchelle: number, * navigateur: string|null, node: string|null, processeursLogiques: number|null, * ralentissementCpu: number|null, police: string|null}} ConfigurationReleve * @typedef {{nom: string, unite: string, valeurProposee: number|null, critere: string, * courbe?: object[], verdict?: 'tenu'|'nonTenu'}} MesureReleve * @typedef {{format: 1, configuration: ConfigurationReleve, mesures: MesureReleve[], * plafondAPriori?: object[], parallele?: object[]}} Releve */ // Les champs d'un relevé, et ceux de sa configuration : rien d'autre ne s'y // écrit, un champ de plus pouvant nommer la machine. const CHAMPS_RELEVE = ['format', 'configuration', 'mesures', 'plafondAPriori', 'parallele']; const CHAMPS_CONFIGURATION = [ 'cadre', 'facteurEchelle', 'navigateur', 'node', 'processeursLogiques', 'ralentissementCpu', 'police', ]; // Les noms de champ qui désignent l'hôte, son utilisateur ou un instant, // refusés à toute profondeur du relevé. Un nom se lit en minuscules, sans // tiret bas ni tiret : « Hostname », « host_name » et « nom-hote » se // valent. Il est identifiant quand il contient un mot de MOT_IDENTIFIANT // (nomMachine, computerName), commence par un préfixe de // PREFIXE_IDENTIFIANT (dateReleve, userName), ou vaut un nom de // NOM_IDENTIFIANT. Ces derniers ne se lisent qu'entiers, leur début // commençant des mots ordinaires : compteur, postérieur. const MOT_IDENTIFIANT = ['host', 'hote', 'machine', 'computer', 'utilisateur', 'horodatage', 'timestamp']; const PREFIXE_IDENTIFIANT = ['user', 'date', 'login']; const NOM_IDENTIFIANT = new Set(['poste', 'compte', 'instant']); function estChampIdentifiant(cle) { const nom = cle.toLowerCase().replace(/[_-]/g, ''); return ( MOT_IDENTIFIANT.some((mot) => nom.includes(mot)) || PREFIXE_IDENTIFIANT.some((prefixe) => nom.startsWith(prefixe)) || NOM_IDENTIFIANT.has(nom) ); } // Un chemin de compte : le dossier personnel sous Linux (/home/x, et // /var/home/x qui le contient), sous macOS (/Users/x), celui de root, et le // profil sous Windows (C:\Users\x, C:\Documents and Settings\x), quel que // soit le séparateur. /opt/homebrew ne s'y lit pas : « home » y est suivi // d'autre chose qu'un séparateur. const CHEMIN_DE_COMPTE = /\/(?:home|Users)\/[^/\s]+|\/root(?:\/|$)|[A-Za-z]:[\\/](?:Users|Documents and Settings)[\\/][^\\/\s]+/i; // Une date calendaire dans un texte, seule ou au début d'un horodatage : // AAAA-MM-JJ, AAAA.MM.JJ ou AAAA/MM/JJ, d'un même séparateur, le mois de 01 // à 12 et le jour de 01 à 31 ; ou JJ/MM/AAAA et JJ.MM.AAAA, jour et mois // sur un ou deux chiffres, dans un ordre ou dans l'autre (31/12 ou 12/31). // Aucun chiffre ne la précède ni ne la suit : un nombre plus long ne s'y lit pas. const DATE = /(? typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur); const estTexte = (valeur) => typeof valeur === 'string' && valeur.length > 0; const estPositif = (valeur) => Number.isFinite(valeur) && valeur > 0; const estEntierPositif = (valeur) => Number.isInteger(valeur) && valeur > 0; // Les clés d'un objet, triées par unités de code : l'ordre des fautes ne // dépend pas de l'ordre d'écriture du fichier. const clesTriees = (objet) => Object.keys(objet).sort(); // Chaque champ de la configuration, avec ce qu'il admet et la faute qu'il // porte sinon. null vaut « inconnu » pour les champs qui le permettent : un // relevé qui ne connaît pas encore son navigateur reste vérifiable. const REGLES_CONFIGURATION = { cadre: [ (v) => estObjet(v) && clesTriees(v).join(',') === 'hauteur,largeur' && estEntierPositif(v.largeur) && estEntierPositif(v.hauteur), 'largeur et hauteur en px, entiers positifs, et rien d’autre', ], facteurEchelle: [estPositif, 'nombre positif attendu'], navigateur: [(v) => v === null || estTexte(v), 'texte non vide ou null attendu'], node: [(v) => v === null || estTexte(v), 'texte non vide ou null attendu'], processeursLogiques: [(v) => v === null || estEntierPositif(v), 'entier positif ou null attendu'], ralentissementCpu: [(v) => v === null || (Number.isFinite(v) && v >= 1), 'nombre ≥ 1 ou null attendu'], police: [(v) => v === null || estTexte(v), 'texte non vide ou null attendu'], }; // Fautes de la configuration : un champ hors de la liste (les champs // identifiants sont laissés au balayage), puis chaque champ de la liste, // dans son ordre. function fautesConfiguration(configuration) { if (!estObjet(configuration)) return [{ chemin: 'configuration', faute: 'configuration absente : objet attendu' }]; const fautes = []; for (const cle of clesTriees(configuration)) { if (!CHAMPS_CONFIGURATION.includes(cle) && !estChampIdentifiant(cle)) { fautes.push({ chemin: `configuration.${cle}`, faute: `champ hors de la configuration admise : ${CHAMPS_CONFIGURATION.join(', ')}`, }); } } for (const champ of CHAMPS_CONFIGURATION) { const chemin = `configuration.${champ}`; if (!Object.hasOwn(configuration, champ)) { fautes.push({ chemin, faute: 'champ absent' }); continue; } const [admet, faute] = REGLES_CONFIGURATION[champ]; if (!admet(configuration[champ])) fautes.push({ chemin, faute }); } return fautes; } // Fautes d'une mesure, dans l'ordre de ses champs. Une mesure qui nomme une // constante du registre appartient au banc et en porte l'unité ; un autre // nom est une garde du banc (le temps par image), qui porte la sienne. function fautesMesure(mesure, rang, vus, constantes) { const base = `mesures[${rang}]`; if (!estObjet(mesure)) return [{ chemin: base, faute: 'objet attendu' }]; const fautes = []; const faute = (champ, texte) => fautes.push({ chemin: `${base}.${champ}`, faute: texte }); const entree = estTexte(mesure.nom) ? constantes.find((c) => c.nom === mesure.nom) : undefined; if (!estTexte(mesure.nom)) faute('nom', 'nom non vide attendu'); else if (vus.has(mesure.nom)) faute('nom', 'mesure déjà relevée plus haut'); else if (entree !== undefined && entree.proprietaire !== 'banc') { faute('nom', `constante de propriétaire ${entree.proprietaire} : le banc ne la relève pas`); } if (estTexte(mesure.nom)) vus.add(mesure.nom); if (!estTexte(mesure.unite)) faute('unite', 'unité non vide attendue'); else if (entree !== undefined && entree.unite !== mesure.unite) { faute('unite', `unité « ${mesure.unite} », le registre dit « ${entree.unite} »`); } if (!(mesure.valeurProposee === null || Number.isFinite(mesure.valeurProposee))) { faute('valeurProposee', 'nombre fini ou null attendu'); } if (!estTexte(mesure.critere)) faute('critere', 'critère non vide attendu'); if (Object.hasOwn(mesure, 'courbe') && !Array.isArray(mesure.courbe)) faute('courbe', 'liste attendue'); if (Object.hasOwn(mesure, 'verdict') && mesure.verdict !== 'tenu' && mesure.verdict !== 'nonTenu') { faute('verdict', '« tenu » ou « nonTenu » attendu'); } return fautes; } // Balayage du relevé entier, clés triées à chaque niveau : un champ // identifiant, et tout texte qui porte un chemin de compte ou une date. function fautesIdentifiantes(valeur, chemin, fautes) { if (typeof valeur === 'string') { if (CHEMIN_DE_COMPTE.test(valeur)) fautes.push({ chemin, faute: 'chemin de compte : un relevé ne nomme pas sa machine' }); if (DATE.test(valeur)) fautes.push({ chemin, faute: 'date : un relevé ne porte pas l’instant de sa prise' }); } else if (Array.isArray(valeur)) { valeur.forEach((element, rang) => fautesIdentifiantes(element, `${chemin}[${rang}]`, fautes)); } else if (estObjet(valeur)) { for (const cle of clesTriees(valeur)) { const sous = chemin === '' ? cle : `${chemin}.${cle}`; if (estChampIdentifiant(cle)) fautes.push({ chemin: sous, faute: MESSAGE_IDENTIFIANT }); fautesIdentifiantes(valeur[cle], sous, fautes); } } return fautes; } /** * Les fautes d'un relevé, [] quand il est conforme. Dans l'ordre : un champ * hors du format ; le format, 1 ; la configuration ; les mesures, une liste * non vide — un relevé vide ne prouve rien (§ 14.2) —, chacune nommée une * seule fois, et à l'unité du registre quand elle nomme l'une de ses * constantes, qui doit alors appartenir au banc ; plafondAPriori et * parallele, des listes quand ils sont présents ; puis, à toute profondeur, * chaque champ identifiant et chaque texte qui porte un chemin de compte ou * une date. * @param {Releve} releve * @param {ReadonlyArray} [constantes] * @returns {Faute[]} */ export function verifierReleve(releve, constantes = CONSTANTES) { if (!estObjet(releve)) return [{ chemin: '', faute: 'relevé : objet attendu' }]; const fautes = []; for (const cle of clesTriees(releve)) { if (!CHAMPS_RELEVE.includes(cle) && !estChampIdentifiant(cle)) { fautes.push({ chemin: cle, faute: 'champ hors du format du relevé' }); } } if (releve.format !== 1) fautes.push({ chemin: 'format', faute: `format ${String(releve.format)} : 1 attendu` }); fautes.push(...fautesConfiguration(releve.configuration)); if (!Array.isArray(releve.mesures)) { fautes.push({ chemin: 'mesures', faute: 'liste attendue' }); } else if (releve.mesures.length === 0) { fautes.push({ chemin: 'mesures', faute: 'aucune mesure : un relevé vide ne prouve rien' }); } else { const vus = new Set(); releve.mesures.forEach((mesure, rang) => fautes.push(...fautesMesure(mesure, rang, vus, constantes))); } for (const champ of ['plafondAPriori', 'parallele']) { if (Object.hasOwn(releve, champ) && !Array.isArray(releve[champ])) fautes.push({ chemin: champ, faute: 'liste attendue' }); } return fautesIdentifiantes(releve, '', fautes); } // Un nombre écrit à la française : la virgule décimale, les milliers // séparés par une espace, comme le registre les écrit (« 1 366 »). function nombre(valeur) { const [entiere, decimale] = String(valeur).split('.'); const groupee = entiere.replace(/\B(?=(\d{3})+$)/g, ' '); return decimale === undefined ? groupee : `${groupee},${decimale}`; } /** * Le résumé d'une configuration de relevé, celui que la configuration d'une * constante adoptée cite : cadre, facteur d'échelle, navigateur, Node, * processeurs logiques, ralentissement du processeur et police, dans cet * ordre quel que soit celui des champs. Un champ null se dit inconnu. * @param {ConfigurationReleve} configuration * @returns {string} * @throws {TypeError} sur ce qui n'est pas un objet */ export function resumeConfiguration(configuration) { if (!estObjet(configuration)) throw new TypeError('configuration de relevé : objet attendu'); const { cadre, facteurEchelle, navigateur, node, processeursLogiques: p, ralentissementCpu: r, police } = configuration; const processeurs = p === null ? 'processeurs logiques inconnus' : `${nombre(p)} ${p === 1 ? 'processeur logique' : 'processeurs logiques'}`; let ralentissement = `processeur ralenti ×${nombre(r)}`; if (r === null) ralentissement = 'ralentissement inconnu'; else if (r === 1) ralentissement = 'processeur non ralenti'; return [ `cadre de ${nombre(cadre.largeur)} × ${nombre(cadre.hauteur)} px`, `facteur ${nombre(facteurEchelle)}`, navigateur ?? 'navigateur inconnu', `Node ${node ?? 'inconnu'}`, processeurs, ralentissement, `police ${police ?? 'inconnue'}`, ].join(', '); } // La mesure que le relevé porte pour une constante, undefined sinon. const mesureDe = (releve, nom) => (Array.isArray(releve.mesures) ? releve.mesures : []).find((m) => m?.nom === nom); /** * L'accord du registre avec un relevé : pour chaque constante du banc * déclarée mesurée, dans l'ordre du registre, « absente » quand le relevé ne * la porte pas ou n'en propose aucune valeur ; sinon « valeur » quand le * registre porte une autre valeur que la proposée, puis « configuration » * quand la configuration de l'entrée ne cite pas le résumé de celle du * relevé. Une constante défaut de travail, ou d'un autre propriétaire, ne * produit rien : le relevé ne la soutient ni ne la contredit. * @param {ReadonlyArray} constantes * @param {Releve} releve * @returns {Array<{nom: string, faute: 'absente'|'valeur'|'configuration'}>} */ export function accordRegistre(constantes, releve) { const resume = resumeConfiguration(releve.configuration); const fautes = []; for (const { nom, valeur, configuration, proprietaire, statut } of constantes) { if (proprietaire !== 'banc' || statut !== 'mesuree') continue; const mesure = mesureDe(releve, nom); if (mesure === undefined || mesure.valeurProposee === null) { fautes.push({ nom, faute: 'absente' }); continue; } if (mesure.valeurProposee !== valeur) fautes.push({ nom, faute: 'valeur' }); if (!configuration.includes(resume)) fautes.push({ nom, faute: 'configuration' }); } return fautes; } // Les libellés du statut et du propriétaire, tels que le tableau les écrit. const STATUTS = { mesuree: 'mesurée', defautDeTravail: 'défaut de travail' }; const PROPRIETAIRES = { banc: 'banc', node: 'node', manuel: 'manuel', execution: 'exécution' }; const ENTETE = [ 'Constante', 'Ce qu’elle mesure', 'Valeur', 'Statut', 'Propriétaire', 'Protocole', 'Valeur proposée par le banc', 'Configuration du relevé', ]; // Une cellule de tableau Markdown : une barre verticale s'échappe, un saut // de ligne devient une espace. const cellule = (texte) => String(texte).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' '); const ligne = (cellules) => `| ${cellules.map(cellule).join(' | ')} |`; const avecUnite = (valeur, unite) => `${nombre(valeur)} ${unite}`; // La valeur que le relevé propose pour une constante : avec son unité ; // « non relevée » pour une constante du banc qu'il ne mesure pas ou dont il // ne propose rien ; « — » hors du banc, qui ne la relève pas. function valeurProposee(constante, releve) { if (constante.proprietaire !== 'banc') return '—'; const mesure = mesureDe(releve, constante.nom); return mesure === undefined || mesure.valeurProposee === null ? 'non relevée' : avecUnite(mesure.valeurProposee, constante.unite); } /** * Le tableau des constantes mesurées (§ 19.10, § 19.8), contenu de la région * engendrée « constantes » : un en-tête, son séparateur, puis une ligne par * constante du registre, dans son ordre — ce qu'elle mesure, sa valeur * (« relevée à l'exécution » sans valeur), son statut en toutes lettres, son * propriétaire, son protocole, la valeur que le relevé propose, et la * configuration : le résumé de celle du relevé pour une constante du banc, * celle du registre pour les autres. Lignes jointes par un saut de ligne, * sans saut final. * @param {ReadonlyArray} constantes * @param {Releve} releve * @returns {string} * @throws {RangeError} sur un registre vide : un tableau sans ligne ne prouve rien */ export function tableauConstantes(constantes, releve) { if (constantes.length === 0) throw new RangeError('tableau des constantes : registre vide'); const resume = resumeConfiguration(releve.configuration); const lignes = constantes.map((c) => ligne([ `\`${c.nom}\``, c.mesure, c.valeur === null ? 'relevée à l’exécution' : avecUnite(c.valeur, c.unite), STATUTS[c.statut], PROPRIETAIRES[c.proprietaire], c.protocole, valeurProposee(c, releve), c.proprietaire === 'banc' ? resume : c.configuration, ]), ); return [ligne(ENTETE), ligne(ENTETE.map(() => '---')), ...lignes].join('\n'); }