Measured thresholds must be adopted by a developer, never written by the bench into the code. The record holds the bench configuration and its measures; its reader refuses an empty record, a measure without its constant, and any field or value that looks like a host, an account or a date. A guard in the constants test makes every registry value that claims a bench origin agree with the record. Checked: red first, eight mutations caught; 3392 node tests green from the index alone. --- FR --- [ADD] banc : format du relevé versionné, accord avec les constantes Un seuil mesuré s'adopte par un geste du développeur ; le banc ne l'écrit jamais dans le code. Le relevé porte la configuration du banc et ses mesures ; sa lecture refuse un relevé vide, une mesure sans sa constante, et tout champ ou valeur qui ressemble à un hôte, un compte ou une date. Une garde de l'épreuve des constantes accorde au relevé chaque valeur du registre qui se dit tirée du banc. Vérifié : rouge d'abord, huit mutations attrapées ; 3392 node verts depuis l'index seul. Assisted-by: Claude Opus 5.5
342 lines
17 KiB
JavaScript
342 lines
17 KiB
JavaScript
// © 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 =
|
||
/(?<!\d)(?:\d{4}([-./])(?:0[1-9]|1[0-2])\1(?:0[1-9]|[12]\d|3[01])|(?:0?[1-9]|[12]\d|3[01])([/.])(?:0?[1-9]|[12]\d|3[01])\2\d{4})(?!\d)/;
|
||
|
||
const MESSAGE_IDENTIFIANT = 'un relevé ne nomme pas sa machine : ni hôte, ni utilisateur, ni date';
|
||
|
||
const estObjet = (valeur) => 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<import('../../src/geometrie/constantes.js').Constante>} [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<import('../../src/geometrie/constantes.js').Constante>} 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<import('../../src/geometrie/constantes.js').Constante>} 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');
|
||
}
|