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
466 lines
22 KiB
JavaScript
466 lines
22 KiB
JavaScript
// © 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;
|
||
}
|