gestion_table_tournante_libre/scripts/mutation/audit.js
Mathieu Benoit ec3bca4717 [ADD] mutation: engine audit command and its versioned written report
Spec 14.13 asks for an audit that shows which engine mutants the tests
kill, written where a reviewer can read it. npm run mutation and make
mutation run the baseline, a coverage pass that stops early when nothing is
measured, then every mutant; survivors are replayed against the long
series. The report holds version, scope, source fingerprint and Node
version only, no duration, host or date, and refuses any other field.
Declared equivalent mutants live in a separate list of key and reason.
Checked: red first; 3612 node tests green from the index alone.

--- FR ---

[ADD] mutation : commande d'audit du moteur, rapport écrit et versionné

Le § 14.13 demande un audit qui montre quels mutants du moteur les épreuves
tuent, écrit là où un relecteur le lit. npm run mutation et make mutation
jouent la ligne de base, une passe de couverture qui s'arrête tôt si rien
n'est mesuré, puis chaque mutant ; les survivants se rejouent contre la
série longue. Le rapport ne porte que version, périmètre, empreinte du
source et version de Node, ni durée, ni hôte, ni date, et refuse tout autre
champ. Les mutants déclarés équivalents vivent dans une liste à part.
Vérifié : rouge d'abord ; 3612 node verts depuis l'index seul.

Assisted-by: Claude Opus 5.5
2026-10-09 15:29:08 -04:00

358 lines
16 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)
// L'audit par mutation du moteur (§ 14.13), sa propre commande : make
// mutation, npm run mutation, node scripts/mutation/audit.js. Hors des séries
// node et node-long, sous aucun budget (§ 14.14) ; aucune épreuve ne
// l'importe (audit.test.js).
//
// Déroulé, chaque étape sous le repli node --test du § 14.8 :
// 0. l'en-tête du rapport — version, périmètre, empreinte des sources, Node —
// se compose et se juge par verifierEntete avant toute mesure : un
// en-tête refusé ne jette pas des heures de calcul ;
// 1. ligne de base : la série node du périmètre, non mutée, passe ;
// 2. passe de couverture (--experimental-test-coverage), d'où les lignes
// couvertes — avant les mutants, pour qu'un audit qui ne mesure pas
// s'arrête tôt ;
// 3. les mutants des modules du périmètre ; aucun, refus ;
// 4. un mutant témoin, un throw en tête de module, doit être tué : sinon
// le source muté n'atteint pas les épreuves et chaque mutant
// survivrait ;
// 5. chaque mutant contre les épreuves node du périmètre qui atteignent son
// module, os.availableParallelism() à la fois ;
// 6. les survivants rejoués contre les .long.test.js du périmètre qui
// atteignent leur module, après une ligne de base de ces épreuves ;
// 7. l'empreinte des sources se reprend : différente de celle de l'étape 0,
// les mutants ont été lus ou joués sur d'autres sources que celles que
// l'en-tête désigne, et rien ne s'écrit ;
// 8. audit/mutation-moteur.md et .json, par rediger ; les survivants que
// audit/mutants-equivalents.json déclare équivalents y sont rangés à part.
//
// Sortie 0 quand le rapport est écrit, survivants compris : l'audit n'est pas
// bloquant. Sortie 2 quand l'audit ne mesure pas — en-tête refusé, ligne de
// base rouge, aucun mutant, témoin non tué, fichier d'équivalents illisible,
// sources changées pendant l'audit, arrêt demandé — et rien n'est écrit. Les durées se lisent à l'horloge réelle et
// ne s'impriment que sur la sortie d'erreur, jamais dans le rapport.
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { availableParallelism, tmpdir } from 'node:os';
import { join, relative, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { executerMutant, executerSerie } from './executer.js';
import { grapheDuProjet, testsConcernes } from './graphe.js';
import { mutants } from './mutants.js';
import { rediger, verifierEntete } from './rapport.js';
// Les dossiers mutés, et dont les épreuves jugent les mutants : le moteur
// seul (§ 14.13). audit.test.js le lit dans ce source.
export const PERIMETRE = Object.freeze(['src/moteur']);
// Les arbres dont le graphe des imports se construit : une épreuve du
// périmètre peut atteindre un module muté par un module de src/ ou de test/.
const ARBRES_DU_GRAPHE = Object.freeze(['src', 'test']);
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
const SORTIES = Object.freeze({ markdown: 'audit/mutation-moteur.md', json: 'audit/mutation-moteur.json' });
const EQUIVALENTS = 'audit/mutants-equivalents.json';
// Délai d'une ligne de base ; celui d'un mutant vaut FACTEUR_DELAI fois la
// ligne de base qui le juge, DELAI_MIN_MS au moins : une boucle sans fin
// coûte ce délai, et un mutant lent sous la charge de P processus ne se
// confond pas avec elle.
const DELAI_LIGNE_DE_BASE_MS = 30 * 60_000;
const DELAI_MIN_MS = 10_000;
const FACTEUR_DELAI = 4;
export const SORTIE_NE_MESURE_PAS = 2;
class AuditImpossible extends Error {}
const estEpreuveNode = (f) => f.endsWith('.test.js') && !f.endsWith('.long.test.js') && !f.endsWith('.navigateur.test.js');
const estEpreuveLongue = (f) => f.endsWith('.long.test.js');
const estModule = (f) => !f.endsWith('.test.js');
const comparer = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// Les fichiers .js du périmètre, relatifs à la racine, en barres obliques,
// triés.
function fichiersDuPerimetre(racine) {
return PERIMETRE.flatMap((dossier) =>
readdirSync(join(racine, dossier), { recursive: true, withFileTypes: true })
.filter((entree) => entree.isFile() && entree.name.endsWith('.js'))
.map((entree) => relative(racine, join(entree.parentPath, entree.name)).split(sep).join('/')),
).sort(comparer);
}
// SHA-256 des fichiers donnés, chemin et octets de chacun, dans l'ordre reçu.
function empreinteDe(racine, fichiers) {
const hachage = createHash('sha256');
for (const fichier of fichiers) {
hachage.update(`${fichier}\0`);
hachage.update(readFileSync(join(racine, fichier)));
hachage.update('\0');
}
return hachage.digest('hex');
}
// Les équivalences déclarées : [{cle, raison}], deux chaînes non vides, clés
// uniques. Fichier absent : aucune. Lève AuditImpossible sur une autre forme.
function lireEquivalents(racine) {
const chemin = join(racine, EQUIVALENTS);
if (!existsSync(chemin)) return new Map();
let liste;
try {
liste = JSON.parse(readFileSync(chemin, 'utf8'));
} catch (erreur) {
throw new AuditImpossible(`${EQUIVALENTS} illisible : ${erreur.message}`);
}
const forme = `${EQUIVALENTS} : liste de { cle, raison }, chaînes non vides, clés uniques`;
if (!Array.isArray(liste)) throw new AuditImpossible(forme);
const equivalents = new Map();
for (const entree of liste) {
const { cle, raison } = entree ?? {};
if (typeof cle !== 'string' || cle === '' || typeof raison !== 'string' || raison === '' || equivalents.has(cle)) {
throw new AuditImpossible(forme);
}
equivalents.set(cle, raison);
}
return equivalents;
}
// Les lignes exécutées d'un rapport LCOV, par fichier : DA:<ligne>,<compte>
// avec un compte non nul.
function lignesCouvertes(lcov) {
const couvertes = new Map();
let fichier = null;
for (const ligne of lcov.split('\n')) {
if (ligne.startsWith('SF:')) {
fichier = ligne.slice(3).trim().split(sep).join('/');
if (!couvertes.has(fichier)) couvertes.set(fichier, new Set());
} else if (ligne.startsWith('DA:') && fichier !== null) {
const [numero, compte] = ligne.slice(3).split(',');
if (Number(compte) > 0) couvertes.get(fichier).add(Number(numero));
} else if (ligne.trim() === 'end_of_record') {
fichier = null;
}
}
return couvertes;
}
// Les dernières lignes d'une sortie, pour dire pourquoi une ligne de base
// échoue.
const fin = (sortie) => sortie.trimEnd().split('\n').slice(-15).join('\n');
// La ligne de base d'une liste d'épreuves : elles passent, non mutées.
// Rend la durée ; lève AuditImpossible sinon.
async function ligneDeBase(racine, tests, nom) {
const serie = await executerSerie({ tests, delaiMs: DELAI_LIGNE_DE_BASE_MS, racine });
if (serie.delai || serie.code !== 0) {
throw new AuditImpossible(`${nom}, non mutée, échoue sous node --test (code ${serie.code}) :\n${fin(serie.sortie)}`);
}
return serie.dureeMs;
}
// Les lignes couvertes par la série node du périmètre, sous
// --experimental-test-coverage, par un rapport LCOV écrit dans un dossier
// temporaire, hors du projet, effacé ensuite.
async function passeDeCouverture(racine, tests) {
const dossier = mkdtempSync(join(tmpdir(), 'gtt-couverture-'));
try {
const lcov = join(dossier, 'lcov.info');
const serie = await executerSerie({
tests,
delaiMs: DELAI_LIGNE_DE_BASE_MS,
racine,
argumentsNode: [
'--experimental-test-coverage',
...PERIMETRE.map((d) => `--test-coverage-include=${d}/**`),
'--test-coverage-exclude=**/*.test.js',
'--test-reporter=lcov',
`--test-reporter-destination=${lcov}`,
'--test-reporter-destination=stdout',
],
});
if (serie.delai || serie.code !== 0 || !existsSync(lcov)) {
throw new AuditImpossible(`la passe de couverture échoue (code ${serie.code}) :\n${fin(serie.sortie)}`);
}
return lignesCouvertes(readFileSync(lcov, 'utf8'));
} finally {
rmSync(dossier, { recursive: true, force: true });
}
}
// Fait chaque tâche, au plus `largeur` à la fois ; rend les résultats dans
// l'ordre des tâches, quel que soit l'ordre où elles finissent. Plus aucune
// tâche ne part quand arreter() devient vrai : celles en cours finissent.
async function enParallele(taches, largeur, faire, { arreter, signaler }) {
const resultats = new Array(taches.length);
let suivante = 0;
let finies = 0;
const ouvrier = async () => {
while (suivante < taches.length && !arreter()) {
const rang = suivante;
suivante += 1;
resultats[rang] = await faire(taches[rang]);
finies += 1;
signaler(finies, taches.length);
}
};
await Promise.all(Array.from({ length: Math.min(largeur, taches.length) }, ouvrier));
if (arreter()) throw new AuditImpossible('arrêt demandé avant la fin');
return resultats;
}
// Un signalement d'avancement tous les vingtièmes, et au dernier.
function avancement(nom, journal) {
let dernier = -1;
return (finies, total) => {
const palier = Math.floor((finies * 20) / total);
if (palier !== dernier || finies === total) {
dernier = palier;
journal(`${nom} : ${finies} / ${total}`);
}
};
}
/**
* L'audit entier, le rapport écrit sous racine/audit/. Lève
* AuditImpossible quand il ne mesure pas, sans rien écrire.
* @param {{racine?: string, journal?: (texte: string) => void, arreter?: () => boolean}} [options]
* @returns {Promise<{bilan: object, dureeMs: number}>}
*/
export async function auditer({ racine = RACINE, journal = (texte) => process.stderr.write(`${texte}\n`), arreter = () => false } = {}) {
const debut = performance.now();
const fichiers = fichiersDuPerimetre(racine);
const epreuvesNode = fichiers.filter(estEpreuveNode);
const epreuvesLongues = fichiers.filter(estEpreuveLongue);
if (epreuvesNode.length === 0) throw new AuditImpossible(`aucune épreuve node sous ${PERIMETRE.join(', ')}`);
const equivalents = lireEquivalents(racine);
const empreinte = empreinteDe(racine, fichiers);
let entete;
try {
const { version } = JSON.parse(readFileSync(join(racine, 'version.json'), 'utf8'));
entete = verifierEntete({ version, perimetre: [...PERIMETRE], empreinte, node: process.version });
} catch (erreur) {
throw new AuditImpossible(`en-tête du rapport refusé avant toute mesure : ${erreur.message}`);
}
journal(`ligne de base : ${epreuvesNode.length} épreuves, sous node --test`);
const base = await ligneDeBase(racine, epreuvesNode, 'la série node du périmètre');
journal(`ligne de base : ${(base / 1000).toFixed(1)} s`);
const couvertes = await passeDeCouverture(racine, epreuvesNode);
const graphe = grapheDuProjet(racine, ARBRES_DU_GRAPHE);
const juges = (fichier, estJuge) => testsConcernes(fichier, graphe).filter((t) => fichiers.includes(t) && estJuge(t));
const taches = fichiers.filter(estModule).flatMap((fichier) => {
const tests = juges(fichier, estEpreuveNode);
const longues = juges(fichier, estEpreuveLongue);
return mutants(readFileSync(join(racine, fichier), 'utf8'), fichier).map((mutant) => ({ mutant, tests, longues }));
});
if (taches.length === 0) throw new AuditImpossible(`aucun mutant sous ${PERIMETRE.join(', ')}`);
const delaiMs = Math.max(DELAI_MIN_MS, FACTEUR_DELAI * base);
const premier = taches.find(({ tests }) => tests.length > 0);
if (premier === undefined) throw new AuditImpossible('aucune épreuve node n’atteint un module muté');
const temoin = {
cle: 'temoin',
fichier: premier.mutant.fichier,
ligne: 1,
colonne: 1,
operateur: 'temoin',
original: '',
remplacement: "throw new Error('mutant témoin');\n",
debut: 0,
fin: 0,
};
const issueTemoin = await executerMutant({ mutant: temoin, tests: premier.tests, delaiMs, racine });
if (issueTemoin.issue !== 'tue') {
throw new AuditImpossible(`le mutant témoin de ${temoin.fichier} n’est pas tué (${issueTemoin.issue}) : le source muté n’atteint pas les épreuves`);
}
const largeur = availableParallelism();
journal(`${taches.length} mutants, ${largeur} à la fois, délai ${(delaiMs / 1000).toFixed(0)} s`);
const issues = await enParallele(
taches,
largeur,
async ({ mutant, tests }) => (tests.length === 0 ? 'survivant' : (await executerMutant({ mutant, tests, delaiMs, racine })).issue),
{ arreter, signaler: avancement('mutants', journal) },
);
const aRejouer = taches.map((tache, rang) => ({ ...tache, rang })).filter(({ rang, longues }) => issues[rang] === 'survivant' && longues.length > 0);
const longues = new Array(taches.length).fill(null);
if (aRejouer.length > 0) {
const utiles = epreuvesLongues.filter((t) => aRejouer.some((tache) => tache.longues.includes(t)));
journal(`série longue : ligne de base, ${utiles.length} épreuves`);
const baseLongue = await ligneDeBase(racine, utiles, 'la série longue du périmètre');
const delaiLong = Math.max(DELAI_MIN_MS, FACTEUR_DELAI * baseLongue);
const rejeux = await enParallele(
aRejouer,
largeur,
async ({ mutant, longues: tests }) => (await executerMutant({ mutant, tests, delaiMs: delaiLong, racine })).issue,
{ arreter, signaler: avancement('survivants rejoués', journal) },
);
aRejouer.forEach(({ rang }, i) => {
longues[rang] = rejeux[i];
});
}
const cles = new Set(taches.map(({ mutant }) => mutant.cle));
for (const cle of [...equivalents.keys()].sort(comparer)) {
if (!cles.has(cle)) journal(`équivalence sans mutant (source changé ?) : ${cle}`);
}
const resultats = taches.map(({ mutant }, rang) => {
const issue = issues[rang];
const declare = equivalents.get(mutant.cle) ?? null;
if (declare !== null && issue !== 'survivant') journal(`déclaré équivalent, pourtant ${issue} : ${mutant.cle}`);
return {
mutant,
issue,
couverte: couvertes.get(mutant.fichier)?.has(mutant.ligne) ?? false,
longue: longues[rang],
equivalent: issue === 'survivant' ? declare : null,
};
});
// Un fichier ajouté ou retiré change aussi l'empreinte : le chemin de
// chacun y entre.
if (empreinteDe(racine, fichiersDuPerimetre(racine)) !== empreinte) {
throw new AuditImpossible(`sources changées pendant l’audit sous ${PERIMETRE.join(', ')} : l’empreinte ne désignerait pas les sources mutées`);
}
const { markdown, json } = rediger({ entete, resultats });
mkdirSync(join(racine, 'audit'), { recursive: true });
writeFileSync(join(racine, SORTIES.markdown), markdown);
writeFileSync(join(racine, SORTIES.json), json);
return { bilan: JSON.parse(json).bilan, dureeMs: performance.now() - debut };
}
// En ligne de commande. Un premier SIGINT ou SIGTERM n'envoie plus de
// mutant et laisse finir ceux en cours, chacun borné par son délai, puis
// sort en 2 sans rien écrire : interrompre l'audit d'un coup laisserait
// vivre les groupes de processus détachés qu'executer.js lance. Un second
// signal sort sur-le-champ.
async function principal() {
let arret = false;
const demander = () => {
if (arret) process.exit(SORTIE_NE_MESURE_PAS);
arret = true;
process.stderr.write('arrêt demandé : les mutants en cours finissent, au plus leur délai\n');
};
process.on('SIGINT', demander);
process.on('SIGTERM', demander);
try {
const { bilan, dureeMs } = await auditer({ arreter: () => arret });
process.stderr.write(
`audit écrit : ${SORTIES.markdown}, ${SORTIES.json} — ${bilan.total} mutants, ${bilan.tues} tués, ` +
`${bilan.survivants} survivants, ${bilan.equivalents} équivalents, ${bilan.delais} délais, ` +
`${bilan.erreurs} erreurs, en ${(dureeMs / 60_000).toFixed(1)} min\n`,
);
process.exitCode = 0;
} catch (erreur) {
const raison = erreur instanceof AuditImpossible ? erreur.message : (erreur?.stack ?? String(erreur));
process.stderr.write(`l’audit ne mesure pas : ${raison}\n`);
process.exitCode = SORTIE_NE_MESURE_PAS;
}
}
if (process.argv[1] !== undefined && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url) {
await principal();
}