gestion_table_tournante_libre/scripts/mutation/audit.js

354 lines
16 KiB
JavaScript
Raw Normal View History

// © 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 la
// série longue tue, et ceux 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 { equivalentsDe, 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, lues par equivalentsDe de rapport.js : chaque
// clé vers sa raison. Fichier absent : aucune. Lève AuditImpossible, qui
// nomme le fichier, sur un texte qu'equivalentsDe refuse.
function lireEquivalents(racine) {
const chemin = join(racine, EQUIVALENTS);
if (!existsSync(chemin)) return new Map();
try {
return equivalentsDe(readFileSync(chemin, 'utf8'));
} catch (erreur) {
throw new AuditImpossible(`${EQUIVALENTS} : ${erreur.message}`);
}
}
// 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}`);
}
// Une déclaration d'équivalence ne vaut que pour un survivant que la série
// longue ne tue pas : un mutant qu'une épreuve tue change quelque chose.
const resultats = taches.map(({ mutant }, rang) => {
const issue = issues[rang];
const longue = longues[rang];
const declare = equivalents.get(mutant.cle) ?? null;
const survit = issue === 'survivant' && longue !== 'tue';
if (declare !== null && !survit) journal(`déclaré équivalent, pourtant ${longue === 'tue' ? 'tué par la série longue' : issue} : ${mutant.cle}`);
return {
mutant,
issue,
couverte: couvertes.get(mutant.fichier)?.has(mutant.ligne) ?? false,
longue,
equivalent: survit ? 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.tuesSerieLongue} tués par la série longue, ${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();
}