gestion_table_tournante_libre/scripts/mutation/rapport.js
Mathieu Benoit aaa2c1a3a7 [IMP] mutation: count long-series kills apart, test the audit end to end
A mutant the node series missed but the long series killed was still
counted as a survivor, so the committed audit overstated what escapes
the tests. It now has its own column: 15 of the 137 survivors move
there, leaving 122; the committed files are regenerated from their own
results, without a rerun. The audit itself now runs in the long series
on a small fixture tree: long-series replay, exit code 0 with
survivors, exit 2 and nothing written when the baseline fails. The
committed audit files are read back and must match what rediger writes.
Checked: 3868 node and 105 long tests from the index alone.

--- FR ---

[IMP] mutation : tués par la série longue à part, audit éprouvé entier

Un mutant que la série node manquait et que la série longue tuait
comptait encore comme survivant : l'audit commis surestimait ce qui
échappe aux épreuves. Il a sa colonne : 15 des 137 survivants y passent,
il en reste 122 ; les fichiers commis sont réengendrés de leurs propres
résultats, sans rejeu. L'audit tourne entier dans la série longue sur un
petit arbre : rejeu long, sortie 0 avec survivants, sortie 2 et rien
d'écrit quand la ligne de base tombe. Les fichiers commis sont relus et
doivent égaler ce que rediger écrit.
Vérifié : 3868 node et 105 longues depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-10 18:51:43 -04:00

466 lines
22 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.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le résultat écrit de l'audit par mutation (§ 14.13) : rediger compose le
// Markdown et le JSON que audit.js écrit sous audit/, versionnés ; relire
// recompose le Markdown du seul JSON écrit, par la même composition ;
// equivalentsDe lit audit/mutants-equivalents.json. Fonctions pures et
// déterministes : elles reçoivent des textes, ne lisent aucun fichier ni
// horloge, et la sortie de rediger ne dépend pas de l'ordre des résultats
// reçus.
//
// Un rapport ne nomme pas sa machine (§ 19.10) : son en-tête est une liste
// fermée de champs, chacun d'une forme stricte — la version, le périmètre en
// chemins relatifs, l'empreinte des sources, la version de Node —, et rien
// n'y porte un nom d'hôte, un chemin de compte ni une date. Les durées ne
// s'y écrivent pas : elles dépendent de la machine. Un en-tête ou un
// résultat d'une autre forme lève, rien n'est écrit.
//
// Les survivants sur une ligne couverte viennent en tête : une épreuve qui
// exécute la ligne sans que la mutation la fasse échouer exécute sans
// affirmer, ce que l'audit est là pour désigner. Un survivant de la série
// node se rejoue contre les épreuves longues qui atteignent son module avant
// d'être compté : tué par elles, il n'est pas un survivant, et se compte et
// se liste à part, dans sa colonne. Un survivant que
// audit/mutants-equivalents.json déclare équivalent se compte et se liste à
// part, avec sa raison.
import { deriver } from '../version.js';
import { OPERATEURS } from './mutants.js';
/**
* @typedef {'tue'|'survivant'|'delai'|'erreur'} Issue
* @typedef {Object} Entete
* @property {string} version forme affichée de version.json, que deriver de scripts/version.js admet
* @property {string[]} perimetre dossiers mutés, relatifs à la racine
* @property {string} empreinte SHA-256 hexadécimal des sources du périmètre et de leurs épreuves
* @property {string} node process.version, préversion comprise
* @typedef {Object} Resultat
* @property {import('./mutants.js').Mutant} mutant
* @property {Issue} issue contre la série du périmètre, sous node --test
* @property {boolean} couverte la ligne du mutant est exécutée par cette série
* @property {Issue|null} longue issue du rejeu d'un survivant contre les
* .long.test.js qui atteignent son module ;
* null quand il n'est pas rejoué
* @property {string|null} equivalent la raison, pour un survivant déclaré équivalent
*/
const ISSUES = ['tue', 'survivant', 'delai', 'erreur'];
const CHAMPS_ENTETE = ['version', 'perimetre', 'empreinte', 'node'];
// Les champs d'en-tête qui désignent l'hôte, son utilisateur ou un instant :
// leur présence dit pourquoi l'en-tête est refusé. Un nom se lit en
// minuscules, sans tiret ni tiret bas.
const CHAMP_IDENTIFIANT = /host|hote|machine|computer|poste|user|utilisateur|compte|login|date|instant|horodatage|timestamp/;
// Un chemin de compte : le dossier personnel sous Linux (/home/x), sous macOS
// (/Users/x), celui de root, le profil sous Windows (C:\Users\x).
const CHEMIN_DE_COMPTE = /\/(?:home|Users)\/[^/\s]+|\/root(?:\/|$)|[A-Za-z]:[\\/](?:Users|Documents and Settings)[\\/]/i;
const MACHINE = 'un rapport ne nomme pas sa machine';
// La version de Node admet un suffixe de préversion (-rc.1, -pre) fait de
// lettres, de chiffres et de points : ni blanc ni barre oblique, donc ni hôte
// ni chemin.
const FORMES_ENTETE = {
empreinte: [/^[0-9a-f]{64}$/, '64 chiffres hexadécimaux'],
node: [/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/, 'vX.Y.Z, suivi ou non de -préversion'],
};
// Un chemin relatif, en barres obliques : ni racine, ni lecteur, ni
// segment « .. », ni blanc.
const estRelatif = (chemin) =>
typeof chemin === 'string' &&
/^[^/\\\s:][^\\\s:]*$/.test(chemin) &&
!chemin.split('/').some((segment) => segment === '..' || segment === '' || segment === '.');
// Lève quand le texte porte un chemin de compte.
function sansCompte(texte, champ) {
if (typeof texte === 'string' && CHEMIN_DE_COMPTE.test(texte)) {
throw new TypeError(`${MACHINE} : chemin de compte dans ${champ}`);
}
}
/**
* L'en-tête validé, recopié dans l'ordre de CHAMPS_ENTETE. Lève une
* TypeError sur un en-tête qui nomme la machine ou n'a pas la forme de
* Entete ; la version se juge par deriver de scripts/version.js, seule
* autorité de sa forme (§ 13.2). audit.js l'appelle avant de mesurer, rediger
* à nouveau avant d'écrire.
* @param {Entete} entete
* @returns {Entete}
*/
export function verifierEntete(entete) {
if (typeof entete !== 'object' || entete === null || Array.isArray(entete)) {
throw new TypeError('en-tête attendu : { version, perimetre, empreinte, node }');
}
const cles = Object.keys(entete).sort();
for (const cle of cles) {
if (CHAMP_IDENTIFIANT.test(cle.toLowerCase().replace(/[-_]/g, ''))) {
throw new TypeError(`${MACHINE} : champ ${cle} refusé`);
}
}
for (const cle of cles) {
if (!CHAMPS_ENTETE.includes(cle)) {
throw new TypeError(`${MACHINE} : champ ${cle} hors de l'en-tête (${CHAMPS_ENTETE.join(', ')})`);
}
}
const { perimetre } = entete;
if (!Array.isArray(perimetre) || perimetre.length === 0) {
throw new TypeError('en-tête : perimetre, liste non vide de chemins relatifs attendue');
}
for (const dossier of perimetre) {
sansCompte(dossier, 'perimetre');
if (!estRelatif(dossier)) throw new TypeError(`en-tête : perimetre, chemin relatif attendu, reçu ${String(dossier)}`);
}
sansCompte(entete.version, 'version');
try {
deriver(entete.version);
} catch (erreur) {
throw new TypeError(`en-tête : version, ${erreur.message} ; ${MACHINE}, aucun autre texte n'y entre`);
}
for (const [champ, [forme, attendu]] of Object.entries(FORMES_ENTETE)) {
const valeur = entete[champ];
sansCompte(valeur, champ);
if (typeof valeur !== 'string' || !forme.test(valeur)) {
throw new TypeError(`en-tête : ${champ}, ${attendu} attendu ; ${MACHINE}, aucun autre texte n'y entre`);
}
}
return { version: entete.version, perimetre: [...perimetre], empreinte: entete.empreinte, node: entete.node };
}
const estEntierPositif = (n) => Number.isInteger(n) && n > 0;
// Le résultat validé, réduit à ce que le rapport écrit.
function verifierResultat(resultat, rang) {
const ici = `résultat ${rang}`;
if (typeof resultat !== 'object' || resultat === null) throw new TypeError(`${ici} : objet attendu`);
const { mutant, issue, couverte, longue, equivalent } = resultat;
if (typeof mutant !== 'object' || mutant === null) throw new TypeError(`${ici} : mutant attendu`);
const { cle, fichier, ligne, colonne, operateur, original, remplacement } = mutant;
if (typeof cle !== 'string' || cle === '') throw new TypeError(`${ici} : clé non vide attendue`);
sansCompte(fichier, `${ici}, fichier`);
sansCompte(cle, `${ici}, clé`);
if (!estRelatif(fichier)) throw new TypeError(`${ici} : chemin relatif attendu, reçu ${String(fichier)}`);
if (!estEntierPositif(ligne)) throw new TypeError(`${ici} : ligne, entier ≥ 1 attendu`);
if (!estEntierPositif(colonne)) throw new TypeError(`${ici} : colonne, entier ≥ 1 attendu`);
if (!OPERATEURS.includes(operateur)) throw new TypeError(`${ici} : opérateur inconnu ${String(operateur)}`);
if (typeof original !== 'string' || typeof remplacement !== 'string') {
throw new TypeError(`${ici} : original et remplacement, deux chaînes attendues`);
}
if (!ISSUES.includes(issue)) throw new TypeError(`${ici} : issue inconnue ${String(issue)}`);
if (typeof couverte !== 'boolean') throw new TypeError(`${ici} : couverte, booléen attendu`);
if (longue !== null && !(issue === 'survivant' && ISSUES.includes(longue))) {
throw new TypeError(`${ici} : série longue, une issue pour un survivant seul, null sinon`);
}
if (equivalent !== null && !(issue === 'survivant' && longue !== 'tue' && typeof equivalent === 'string' && equivalent !== '')) {
throw new TypeError(`${ici} : équivalent, une raison non vide pour un survivant seul, que la série longue ne tue pas ; null sinon`);
}
sansCompte(equivalent, `${ici}, équivalent`);
return { cle, fichier, ligne, colonne, operateur, original, remplacement, issue, couverte, longue, equivalent };
}
// Comparaison par unités de code : l'ordre ne dépend d'aucune locale.
const comparer = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// Survivants couverts d'abord, puis fichier, ligne, colonne, rang de
// l'opérateur, clé : un ordre total, la clé étant unique.
function comparerLieux(a, b) {
return (
Number(b.couverte) - Number(a.couverte) ||
comparer(a.fichier, b.fichier) ||
a.ligne - b.ligne ||
a.colonne - b.colonne ||
OPERATEURS.indexOf(a.operateur) - OPERATEURS.indexOf(b.operateur) ||
comparer(a.cle, b.cle)
);
}
// La colonne de comptes où tombe un résultat. Un survivant de la série node
// que la série longue tue n'est pas un survivant (§ 14.13) ; un survivant
// qu'elle laisse vivre, ou qu'elle ne rejoue pas, en reste un, ou un
// équivalent déclaré.
const colonneDe = (r) =>
r.issue === 'tue'
? 'tues'
: r.issue === 'delai'
? 'delais'
: r.issue === 'erreur'
? 'erreurs'
: r.longue === 'tue'
? 'tuesSerieLongue'
: r.equivalent === null
? 'survivants'
: 'equivalents';
// Les colonnes de comptes, dans l'ordre du JSON et des tableaux.
const COLONNES = ['total', 'tues', 'tuesSerieLongue', 'survivants', 'equivalents', 'delais', 'erreurs'];
const compteVide = () => Object.fromEntries(COLONNES.map((colonne) => [colonne, 0]));
function compter(liste) {
const compte = compteVide();
for (const r of liste) {
compte.total += 1;
compte[colonneDe(r)] += 1;
}
return compte;
}
const ENTETES_COLONNES = '| total | tués | tués par la série longue | survivants | équivalents | délais | erreurs |';
const ALIGNEMENT_COMPTES = COLONNES.map(() => '---:').join('|');
const ligneComptes = (tete, compte) => `| ${tete === null ? '' : `${tete} | `}${COLONNES.map((c) => compte[c]).join(' | ')} |`;
// Le texte d'un nœud dans une cellule : blancs ramenés à une espace, au plus
// LONGUEUR_CELLULE points de code, « | » échappé pour le tableau, entre des
// accents graves plus nombreux que la plus longue suite qu'il contient.
const LONGUEUR_CELLULE = 60;
function cellule(code) {
const plat = code.replace(/\s+/g, ' ').trim();
if (plat === '') return '(vide)';
const points = [...plat];
const court = points.length > LONGUEUR_CELLULE ? `${points.slice(0, LONGUEUR_CELLULE - 1).join('')}…` : plat;
const echappe = court.replaceAll('|', '\\|');
const suite = Math.max(0, ...[...echappe.matchAll(/`+/g)].map(([s]) => s.length));
const garde = '`'.repeat(suite + 1);
return suite === 0 ? `${garde}${echappe}${garde}` : `${garde} ${echappe} ${garde}`;
}
const lieu = (r) => `\`${r.fichier}:${r.ligne}:${r.colonne}\``;
const ISSUE_LUE = { tue: 'tué', survivant: 'survit', delai: 'délai', erreur: 'erreur' };
// Chaque élément d'une liste du JSON, dans l'ordre de ses champs.
const mutantEcrit = (r) => ({
cle: r.cle,
fichier: r.fichier,
ligne: r.ligne,
colonne: r.colonne,
operateur: r.operateur,
original: r.original,
remplacement: r.remplacement,
});
// Les champs d'une entrée de la liste des survivants, et de celle des tués
// par la série longue.
const vivantEcrit = (r) => ({ ...mutantEcrit(r), couverte: r.couverte, longue: r.longue });
// Une ligne de liste du Markdown : lieu, opérateur, avant, après, puis les
// cellules propres à la liste.
const ligneDeListe = (r, ...suite) =>
`| ${lieu(r)} | ${r.operateur} | ${cellule(r.original)} | ${cellule(r.remplacement)} | ${suite.join(' | ')} |`;
// Une section du Markdown : son titre, puis « Aucun. » ou le tableau.
const section = (titre, introduction, liste, entete, ligne) => [
`## ${titre}`,
'',
...introduction,
...(liste.length === 0
? ['Aucun.']
: [`| lieu | opérateur | avant | après | ${entete.join(' | ')} |`, `|---|---|---|---|${entete.map(() => '---').join('|')}|`, ...liste.map(ligne)]),
'',
];
// Le Markdown d'un audit, composé de ses données, celles que le JSON porte :
// rediger et relire en tirent le même texte.
function composer(donnees) {
const { entete: tete, bilan, parFichier, parOperateur, survivants, tuesSerieLongue, equivalents, delaisEtErreurs } = donnees;
const longueLue = (r) => (r.longue === null ? '—' : ISSUE_LUE[r.longue]);
return [
'# Audit par mutation du moteur',
'',
'Écrit par `make mutation` (`scripts/mutation/audit.js`, § 14.13) ; ne se modifie pas à la main.',
'',
'| | |',
'|---|---|',
`| version | ${tete.version} |`,
`| périmètre | ${tete.perimetre.map((d) => `\`${d}\``).join(', ')} |`,
`| sources et épreuves du périmètre (SHA-256) | \`${tete.empreinte}\` |`,
`| Node | ${tete.node} |`,
'',
'Chaque mutant se joue contre les épreuves de la série node qui atteignent son module, sous `node --test`.',
'Un survivant se rejoue contre les épreuves longues qui atteignent son module avant d’être compté : tué par elles, il se compte à part.',
'Un survivant sur une ligne couverte désigne une épreuve qui exécute sans affirmer.',
'',
'## Bilan',
'',
ENTETES_COLONNES.replace('| total', '| mutants'),
`|${ALIGNEMENT_COMPTES}|`,
ligneComptes(null, bilan),
'',
'## Par fichier',
'',
`| fichier ${ENTETES_COLONNES}`,
`|---|${ALIGNEMENT_COMPTES}|`,
...parFichier.map((c) => ligneComptes(`\`${c.fichier}\``, c)),
'',
'## Par opérateur',
'',
`| opérateur ${ENTETES_COLONNES}`,
`|---|${ALIGNEMENT_COMPTES}|`,
...parOperateur.map((c) => ligneComptes(c.operateur, c)),
'',
...section(`Survivants (${survivants.length}), lignes couvertes d’abord`, [], survivants, ['ligne couverte', 'série longue'], (r) =>
ligneDeListe(r, r.couverte ? 'oui' : 'non', longueLue(r)),
),
...section(
`Tués par la série longue (${tuesSerieLongue.length})`,
['Survivants de la série node que les épreuves longues tuent : la série node exécute leur ligne sans l’affirmer.', ''],
tuesSerieLongue,
['ligne couverte'],
(r) => ligneDeListe(r, r.couverte ? 'oui' : 'non'),
),
...section(
`Mutants équivalents (${equivalents.length})`,
['Déclarés dans `audit/mutants-equivalents.json`, avec leur raison.', ''],
equivalents,
['raison'],
(r) => ligneDeListe(r, r.raison.replace(/\s+/g, ' ').replaceAll('|', '\\|')),
),
...section(`Délais et erreurs (${delaisEtErreurs.length})`, [], delaisEtErreurs, ['issue'], (r) => ligneDeListe(r, ISSUE_LUE[r.issue])),
].join('\n');
}
const enJson = (donnees) => `${JSON.stringify(donnees, null, 2)}\n`;
/**
* Le rapport d'un audit, en Markdown et en JSON, déterministe.
* Lève sur des résultats vides — un audit qui n'a rien mesuré n'a rien à
* écrire (§ 14.2) —, sur un en-tête qui nomme la machine ou n'a pas la forme
* de Entete, et sur un résultat d'une autre forme que Resultat ou dont la
* clé revient.
* @param {{entete: Entete, resultats: Resultat[]}} demande
* @returns {{markdown: string, json: string}}
*/
export function rediger({ entete, resultats } = {}) {
if (!Array.isArray(resultats) || resultats.length === 0) {
throw new RangeError('aucun résultat : un audit qui ne mesure rien ne s’écrit pas');
}
const tete = verifierEntete(entete);
const lus = resultats.map(verifierResultat).sort(comparerLieux);
const cles = new Set();
for (const r of lus) {
if (cles.has(r.cle)) throw new TypeError(`clé en double : ${r.cle}`);
cles.add(r.cle);
}
const fichiers = [...new Set(lus.map((r) => r.fichier))].sort(comparer);
const parFichier = fichiers.map((fichier) => ({ fichier, ...compter(lus.filter((r) => r.fichier === fichier)) }));
const parOperateur = OPERATEURS.filter((op) => lus.some((r) => r.operateur === op)).map((operateur) => ({
operateur,
...compter(lus.filter((r) => r.operateur === operateur)),
}));
const dans = (colonne) => lus.filter((r) => colonneDe(r) === colonne);
const donnees = {
format: 1,
entete: tete,
bilan: compter(lus),
parFichier,
parOperateur,
survivants: dans('survivants').map(vivantEcrit),
tuesSerieLongue: dans('tuesSerieLongue').map(vivantEcrit),
equivalents: dans('equivalents').map((r) => ({ ...mutantEcrit(r), raison: r.equivalent })),
delaisEtErreurs: lus.filter((r) => r.issue === 'delai' || r.issue === 'erreur').map((r) => ({ ...mutantEcrit(r), issue: r.issue })),
};
return { markdown: composer(donnees), json: enJson(donnees) };
}
// Les clés du JSON écrit, dans son ordre.
const CLES_DONNEES = ['format', 'entete', 'bilan', 'parFichier', 'parOperateur', 'survivants', 'tuesSerieLongue', 'equivalents', 'delaisEtErreurs'];
// Lève quand les comptes d'une ligne ne sont pas ceux de COLONNES, entiers
// positifs ou nuls, dont le total est la somme des autres.
function verifierComptes(compte, ici) {
const ecarts =
compte === null ||
typeof compte !== 'object' ||
COLONNES.some((c) => !Number.isInteger(compte[c]) || compte[c] < 0) ||
COLONNES.slice(1).reduce((somme, c) => somme + compte[c], 0) !== compte.total;
if (ecarts) throw new TypeError(`${ici} : comptes ${COLONNES.join(', ')} attendus, le total égal à la somme des autres`);
}
// Lève quand les lignes ne somment pas, colonne par colonne, au bilan.
function verifierSommes(lignes, bilan, ici) {
if (!Array.isArray(lignes)) throw new TypeError(`${ici} : liste attendue`);
lignes.forEach((ligne, rang) => verifierComptes(ligne, `${ici}, ligne ${rang + 1}`));
for (const colonne of COLONNES) {
if (lignes.reduce((somme, ligne) => somme + ligne[colonne], 0) !== bilan[colonne]) {
throw new TypeError(`${ici} : la colonne ${colonne} ne somme pas au bilan`);
}
}
}
/**
* Relit le JSON qu'a écrit rediger et en recompose le Markdown, par la même
* composition. Lève une TypeError sur un texte que rediger n'écrit pas : une
* autre mise en forme ou d'autres clés, un en-tête que verifierEntete
* refuse, des comptes qui contredisent les listes, ou un survivant que la
* série longue tue, laissé parmi les survivants.
* @param {string} json le texte de audit/mutation-moteur.json
* @returns {{donnees: object, markdown: string}}
*/
export function relire(json) {
let donnees;
try {
donnees = JSON.parse(json);
} catch (erreur) {
throw new TypeError(`JSON illisible : ${erreur.message}`);
}
if (typeof donnees !== 'object' || donnees === null || enJson(donnees) !== json || Object.keys(donnees).join() !== CLES_DONNEES.join()) {
throw new TypeError(`autre forme que celle que rediger écrit : ${CLES_DONNEES.join(', ')}, indentées de deux espaces`);
}
if (donnees.format !== 1) throw new TypeError(`format ${String(donnees.format)} : 1 attendu`);
const tete = verifierEntete(donnees.entete);
if (enJson(tete) !== enJson(donnees.entete)) throw new TypeError('en-tête : autres champs ou autre ordre que ceux de verifierEntete');
const { bilan, survivants, tuesSerieLongue, equivalents, delaisEtErreurs } = donnees;
verifierComptes(bilan, 'bilan');
verifierSommes(donnees.parFichier, bilan, 'par fichier');
verifierSommes(donnees.parOperateur, bilan, 'par opérateur');
const listes = [
['survivants', survivants, bilan.survivants],
['tuesSerieLongue', tuesSerieLongue, bilan.tuesSerieLongue],
['equivalents', equivalents, bilan.equivalents],
['delaisEtErreurs', delaisEtErreurs, bilan.delais + bilan.erreurs],
];
for (const [nom, liste, compte] of listes) {
if (!Array.isArray(liste) || liste.length !== compte) throw new TypeError(`bilan : ${compte} attendus dans ${nom}`);
}
const lieuLu = (r) => `${r.fichier}:${r.ligne}:${r.colonne}`;
const tueParLaLongue = survivants.find((r) => r.longue === 'tue');
if (tueParLaLongue !== undefined) {
throw new TypeError(`un survivant que la série longue tue, laissé parmi les survivants : ${lieuLu(tueParLaLongue)}`);
}
const autre = tuesSerieLongue.find((r) => r.longue !== 'tue');
if (autre !== undefined) throw new TypeError(`tuesSerieLongue : un mutant que la série longue ne tue pas, ${lieuLu(autre)}`);
return { donnees, markdown: composer(donnees) };
}
const FORME_EQUIVALENTS = 'liste de { cle, raison }';
/**
* Les équivalences déclarées dans audit/mutants-equivalents.json : chaque
* clé vers sa raison, dans l'ordre du fichier. Lève une TypeError sur un
* texte illisible, une autre forme qu'une liste de { cle, raison } — deux
* chaînes non vides —, une clé en double, et une raison qui porte un chemin
* de compte, que rediger refuserait après l'audit entier.
* @param {string} texte
* @returns {Map<string, string>}
*/
export function equivalentsDe(texte) {
let liste;
try {
liste = JSON.parse(texte);
} catch (erreur) {
throw new TypeError(`illisible : ${erreur.message}`);
}
if (!Array.isArray(liste)) throw new TypeError(`${FORME_EQUIVALENTS} attendue`);
const equivalents = new Map();
liste.forEach((entree, rang) => {
if (typeof entree !== 'object' || entree === null || Array.isArray(entree)) throw new TypeError(`${FORME_EQUIVALENTS} attendue, rang ${rang + 1}`);
const { cle, raison } = entree;
if (typeof cle !== 'string' || cle === '') throw new TypeError(`${FORME_EQUIVALENTS}, clé vide ou absente au rang ${rang + 1}`);
if (typeof raison !== 'string' || raison === '') throw new TypeError(`${FORME_EQUIVALENTS}, raison vide pour ${cle}`);
if (equivalents.has(cle)) throw new TypeError(`${FORME_EQUIVALENTS}, clé en double : ${cle}`);
sansCompte(raison, `la raison de ${cle}`);
equivalents.set(cle, raison);
});
return equivalents;
}