[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
This commit is contained in:
Mathieu Benoit 2026-10-09 15:29:08 -04:00
parent 3e4fd03a4c
commit ec3bca4717
6 changed files with 1264 additions and 1 deletions

View file

@ -68,6 +68,10 @@ test_tout: ## Joue les trois séries
couverture: ## Mesure la couverture de la série node, module par module ; seuils durs du § 14.13
$(AVEC_NODE) npm run couverture
.PHONY: mutation
mutation: ## Audit par mutation du moteur, avant une livraison (§ 14.13)
$(AVEC_NODE) npm run mutation
.PHONY: banc
banc: ## Relève les constantes du banc de mesure (§ 19.10), avant une livraison
$(AVEC_NODE) npm run banc

View file

@ -19,7 +19,8 @@
"test:navigateur": "vitest run --project navigateur",
"test:tout": "vitest run",
"couverture": "vitest run --project node --coverage --reporter=default",
"banc": "node scripts/banc/lancer.js"
"banc": "node scripts/banc/lancer.js",
"mutation": "node scripts/mutation/audit.js"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",

358
scripts/mutation/audit.js Normal file
View file

@ -0,0 +1,358 @@
// © 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();
}

View file

@ -0,0 +1,278 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Gardes de l'audit par mutation (§ 14.13, § 14.4, § 14.14). L'audit est sa
// propre commande — make mutation, npm run mutation, scripts/mutation/audit.js —,
// hors des séries node et node-long : son coût est d'un autre ordre que leurs
// budgets. Son périmètre est le moteur seul. Ces gardes lisent audit.js, le
// Makefile et package.json comme du texte : aucun module n'importe audit.js,
// celui-ci compris, donc aucune épreuve des séries.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseAst } from 'vite';
import { describe, test } from '../../test/lanceur.js';
import { construireGraphe, grapheDuProjet } from './graphe.js';
const RACINE = fileURLToPath(new URL('../../', import.meta.url));
const AUDIT = 'scripts/mutation/audit.js';
// Le périmètre que déclare le source d'audit.js : les chaînes de
// « export const PERIMETRE = [...] » ou « Object.freeze([...]) ». null quand
// il ne le déclare pas ; lève quand la déclaration n'est pas une liste de
// chaînes littérales, qu'aucune garde ne saurait lire.
function perimetreDeclare(source) {
for (const noeud of parseAst(source).body) {
if (noeud.type !== 'ExportNamedDeclaration' || noeud.declaration?.type !== 'VariableDeclaration') continue;
for (const { id, init } of noeud.declaration.declarations) {
if (id.type !== 'Identifier' || id.name !== 'PERIMETRE') continue;
const gele =
init?.type === 'CallExpression' &&
init.callee.type === 'MemberExpression' &&
init.callee.object.name === 'Object' &&
init.callee.property.name === 'freeze';
const liste = gele ? init.arguments[0] : init;
if (liste?.type !== 'ArrayExpression') throw new TypeError('PERIMETRE : liste littérale attendue');
return liste.elements.map((element) => {
if (element?.type !== 'Literal' || typeof element.value !== 'string') {
throw new TypeError('PERIMETRE : chaînes littérales attendues');
}
return element.value;
});
}
}
return null;
}
// Les fonctions appelées par leur nom dans le corps de la fonction déclarée
// « nom », fonctions imbriquées comprises, dans l'ordre du texte ; null quand
// le source ne déclare pas cette fonction au niveau du module.
function appelsDans(source, nom) {
const declaration = parseAst(source).body
.map((noeud) => (noeud.type === 'ExportNamedDeclaration' ? noeud.declaration : noeud))
.find((noeud) => noeud?.type === 'FunctionDeclaration' && noeud.id?.name === nom);
if (declaration === undefined) return null;
const appels = [];
const parcourir = (noeud) => {
if (Array.isArray(noeud)) {
noeud.forEach(parcourir);
return;
}
if (typeof noeud !== 'object' || noeud === null) return;
if (noeud.type === 'CallExpression' && noeud.callee.type === 'Identifier') appels.push([noeud.start, noeud.callee.name]);
for (const cle of Object.keys(noeud).sort()) {
if (cle !== 'type' && cle !== 'start' && cle !== 'end') parcourir(noeud[cle]);
}
};
parcourir(declaration.body);
return appels.sort((a, b) => a[0] - b[0]).map(([, appele]) => appele);
}
// Les écarts de l'ordre des appels d'auditer : l'en-tête validé avant la
// ligne de base, l'empreinte prise avant elle et reprise après le dernier
// mutant, avant la première écriture. Une liste vide quand l'ordre tient.
function ecartsDeLOrdre(appels) {
const premier = (nom) => appels.indexOf(nom);
const dernier = (nom) => appels.lastIndexOf(nom);
const base = premier('ligneDeBase');
const ecarts = [];
if (premier('verifierEntete') === -1 || premier('verifierEntete') > base) ecarts.push('en-tête validé après la ligne de base');
if (premier('empreinteDe') === -1 || premier('empreinteDe') > base) ecarts.push('empreinte prise après la ligne de base');
if (premier('empreinteDe') === dernier('empreinteDe') || dernier('empreinteDe') < dernier('executerMutant') || dernier('empreinteDe') > premier('writeFileSync')) {
ecarts.push('empreinte non reprise entre le dernier mutant et l’écriture');
}
return ecarts;
}
// La cible nommée d'un Makefile : sa ligne de documentation (« ## … »), ses
// commandes (les lignes de recette qui la suivent, tabulation retirée) et sa
// déclaration .PHONY ; null quand le Makefile ne la porte pas.
function cibleDuMakefile(texte, nom) {
const lignes = texte.split('\n');
const rang = lignes.findIndex((ligne) => ligne.startsWith(`${nom}:`));
if (rang === -1) return null;
const documentation = /##\s*(.*)$/.exec(lignes[rang])?.[1] ?? null;
const commandes = [];
for (let i = rang + 1; i < lignes.length && lignes[i].startsWith('\t'); i += 1) commandes.push(lignes[i].slice(1));
const factice = lignes.some((ligne) => /^\.PHONY:/.test(ligne) && ligne.slice('.PHONY:'.length).trim().split(/\s+/).includes(nom));
return { documentation, commandes, factice };
}
// Les recettes de toutes les cibles d'un Makefile, { cible, commande }, dans
// l'ordre du texte.
function recettes(texte) {
const sortie = [];
let cible = null;
for (const ligne of texte.split('\n')) {
const tete = /^([A-Za-z0-9_.-]+):/.exec(ligne);
if (tete !== null) cible = tete[1];
else if (ligne.startsWith('\t') && cible !== null) sortie.push({ cible, commande: ligne.slice(1) });
}
return sortie;
}
// Écarts entre la couverture et l'audit : la mesure de la couverture garde
// une seule commande, npm run couverture lancé par make couverture, et la
// passe de couverture de l'audit, interne à audit.js, n'en devient pas une
// seconde. Relevés dans l'ordre : chaque script de package.json autre que
// couverture qui mesure (--coverage, --experimental-test-coverage), le script
// mutation qui ne lance pas audit.js seul ; chaque recette du Makefile hors de
// la cible couverture qui lance la mesure ou une passe de couverture.
function ecartsDeLaCouverture({ scripts, makefile }) {
const ecarts = [];
const MESURE = /--(?:experimental-test-)?coverage\b/;
for (const nom of Object.keys(scripts).sort()) {
if (nom !== 'couverture' && MESURE.test(scripts[nom])) ecarts.push(`npm run ${nom} : mesure la couverture`);
}
if (scripts.mutation !== 'node scripts/mutation/audit.js') ecarts.push('npm run mutation : ne lance pas audit.js seul');
for (const { cible, commande } of recettes(makefile)) {
if (cible !== 'couverture' && (MESURE.test(commande) || /npm run couverture\b/.test(commande))) {
ecarts.push(`make ${cible} : lance une mesure de couverture`);
}
}
return ecarts;
}
// Les modules du graphe qui importent la cible, triés. audit.js est un point
// d'entrée en ligne de commande : qu'aucun module ne l'importe garantit
// qu'aucune épreuve, de quelque série qu'elle soit, ne l'atteint par un
// module intermédiaire.
const importateursDe = (cible, graphe) =>
[...graphe.keys()].filter((fichier) => graphe.get(fichier).includes(cible)).sort();
describe('audit par mutation : sa commande (§ 14.13)', () => {
const { scripts = {} } = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8'));
const makefile = readFileSync(join(RACINE, 'Makefile'), 'utf8');
test('npm run mutation lance scripts/mutation/audit.js, et aucun autre script ne touche à l’audit', () => {
assert.equal(scripts.mutation, 'node scripts/mutation/audit.js');
const autres = Object.keys(scripts)
.sort()
.filter((nom) => nom !== 'mutation' && /mutation|audit\.js/.test(scripts[nom]));
assert.deepEqual(autres, []);
});
test('make mutation lance npm run mutation, cible factice documentée', () => {
assert.deepEqual(cibleDuMakefile(makefile, 'mutation'), {
documentation: 'Audit par mutation du moteur, avant une livraison (§ 14.13)',
commandes: ['$(AVEC_NODE) npm run mutation'],
factice: true,
});
});
test('la lecture du Makefile relève une cible absente, une recette autre, une cible non factice', () => {
assert.equal(cibleDuMakefile('.PHONY: banc\nbanc:\n\t$(AVEC_NODE) npm run banc\n', 'mutation'), null);
assert.deepEqual(cibleDuMakefile('.PHONY: test\nmutation: ## Audit\n\tnode scripts/mutation/audit.js\n\nx:\n\techo\n', 'mutation'), {
documentation: 'Audit',
commandes: ['node scripts/mutation/audit.js'],
factice: false,
});
});
});
describe('audit par mutation : son périmètre, le moteur seul', () => {
test('audit.js déclare exactement [src/moteur]', () => {
assert.deepEqual(perimetreDeclare(readFileSync(join(RACINE, AUDIT), 'utf8')), ['src/moteur']);
});
test('la lecture du périmètre rend une liste élargie telle quelle, null sans déclaration, et refuse un calcul', () => {
assert.deepEqual(perimetreDeclare("export const PERIMETRE = Object.freeze(['src/moteur', 'src/application']);\n"), [
'src/moteur',
'src/application',
]);
assert.deepEqual(perimetreDeclare("export const PERIMETRE = ['src/verger'];\n"), ['src/verger']);
assert.equal(perimetreDeclare("const PERIMETRE = ['src/moteur'];\nexport const AUTRE = 1;\n"), null);
assert.throws(() => perimetreDeclare("const d = 'src';\nexport const PERIMETRE = [`${d}/moteur`];\n"), /chaînes littérales/);
assert.throws(() => perimetreDeclare('export const PERIMETRE = lister();\n'), /liste littérale/);
});
});
describe('audit par mutation : ce qu’il mesure est ce qu’il écrit', () => {
test('auditer valide l’en-tête avant la ligne de base, et reprend l’empreinte des sources avant d’écrire', () => {
const appels = appelsDans(readFileSync(join(RACINE, AUDIT), 'utf8'), 'auditer');
assert.ok(appels.includes('ligneDeBase') && appels.includes('writeFileSync'), appels.join(', '));
assert.deepEqual(ecartsDeLOrdre(appels), []);
});
test('la lecture des appels suit le texte, fonctions imbriquées comprises ; la garde relève chaque écart', () => {
const source = 'export async function auditer() { a(); await b(c()); const f = () => d(); }\nfunction autre() { e(); }\n';
assert.deepEqual(appelsDans(source, 'auditer'), ['a', 'b', 'c', 'd']);
assert.equal(appelsDans(source, 'absente'), null);
assert.deepEqual(ecartsDeLOrdre(['ligneDeBase', 'executerMutant', 'empreinteDe', 'verifierEntete', 'writeFileSync']), [
'en-tête validé après la ligne de base',
'empreinte prise après la ligne de base',
'empreinte non reprise entre le dernier mutant et l’écriture',
]);
assert.deepEqual(
ecartsDeLOrdre(['empreinteDe', 'verifierEntete', 'ligneDeBase', 'executerMutant', 'writeFileSync', 'empreinteDe']),
['empreinte non reprise entre le dernier mutant et l’écriture'],
);
assert.deepEqual(
ecartsDeLOrdre(['empreinteDe', 'verifierEntete', 'ligneDeBase', 'executerMutant', 'empreinteDe', 'writeFileSync']),
[],
);
});
});
describe('audit par mutation : hors des séries (§ 14.4, § 14.14)', () => {
test('aucun module de src/, scripts/, test/ ni electron/ n’importe audit.js : aucune épreuve des séries node et node-long ne l’atteint', () => {
const graphe = grapheDuProjet(RACINE, ['src', 'scripts', 'test', 'electron']);
assert.ok(graphe.has(AUDIT), 'audit.js est dans le graphe');
assert.ok(graphe.get('scripts/mutation/audit.test.js').includes('scripts/mutation/graphe.js'), 'le graphe lit les imports');
assert.ok(graphe.size > 200, `${graphe.size} modules lus`);
assert.deepEqual(importateursDe(AUDIT, graphe), []);
});
test('la garde relève un module qui l’importe, statiquement, par une ré-exportation ou par import()', () => {
const textes = new Map([
['scripts/mutation/audit.js', "import { rediger } from './rapport.js';\n"],
['scripts/mutation/rapport.js', ''],
['scripts/mutation/rapport.test.js', "import { rediger } from './rapport.js';\n"],
['scripts/mutation/aide.js', "export { PERIMETRE } from './audit.js';\n"],
['src/verger/pommes.test.js', "import { PERIMETRE } from '../../scripts/mutation/aide.js';\n"],
['src/verger/poires.long.test.js', "const audit = await import('../../scripts/mutation/audit.js');\n"],
['src/verger/vue.navigateur.test.js', "import '../../scripts/mutation/audit.js';\n"],
]);
const graphe = construireGraphe([...textes.keys()], (chemin) => textes.get(chemin));
assert.deepEqual(importateursDe(AUDIT, graphe), [
'scripts/mutation/aide.js',
'src/verger/poires.long.test.js',
'src/verger/vue.navigateur.test.js',
]);
});
});
describe('audit par mutation : la couverture garde sa commande (§ 14.13)', () => {
test('la mesure reste npm run couverture, lancée par make couverture seule ; l’audit n’en est pas une seconde', () => {
const { scripts = {} } = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8'));
const makefile = readFileSync(join(RACINE, 'Makefile'), 'utf8');
assert.match(scripts.couverture, /--coverage\b/);
assert.deepEqual(cibleDuMakefile(makefile, 'couverture')?.commandes, ['$(AVEC_NODE) npm run couverture']);
assert.deepEqual(ecartsDeLaCouverture({ scripts, makefile }), []);
});
test('la garde relève une passe de couverture sortie d’audit.js, et un audit qui lance autre chose', () => {
const scripts = {
couverture: 'vitest run --project node --coverage',
mutation: 'node --experimental-test-coverage scripts/mutation/audit.js',
test: 'vitest run --project node --coverage',
};
const makefile = [
'.PHONY: couverture mutation',
'couverture: ## Mesure',
'\t$(AVEC_NODE) npm run couverture',
'mutation: ## Audit',
'\t$(AVEC_NODE) npm run couverture',
'\tnode --test --experimental-test-coverage src/moteur',
'',
].join('\n');
assert.deepEqual(ecartsDeLaCouverture({ scripts, makefile }), [
'npm run mutation : mesure la couverture',
'npm run test : mesure la couverture',
'npm run mutation : ne lance pas audit.js seul',
'make mutation : lance une mesure de couverture',
'make mutation : lance une mesure de couverture',
]);
});
});

351
scripts/mutation/rapport.js Normal file
View file

@ -0,0 +1,351 @@
// © 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. Fonction
// pure et déterministe : ni lecture de fichier ni horloge, et la sortie 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 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' && typeof equivalent === 'string' && equivalent !== '')) {
throw new TypeError(`${ici} : équivalent, une raison non vide pour un survivant seul, 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.
const colonneDe = (r) =>
r.issue === 'tue'
? 'tues'
: r.issue === 'delai'
? 'delais'
: r.issue === 'erreur'
? 'erreurs'
: r.equivalent === null
? 'survivants'
: 'equivalents';
const compteVide = () => ({ total: 0, tues: 0, survivants: 0, equivalents: 0, delais: 0, erreurs: 0 });
function compter(liste) {
const compte = compteVide();
for (const r of liste) {
compte.total += 1;
compte[colonneDe(r)] += 1;
}
return compte;
}
const COLONNES = ['total', 'tues', 'survivants', 'equivalents', 'delais', 'erreurs'];
const ENTETES_COLONNES = '| total | tués | survivants | équivalents | délais | erreurs |';
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,
});
/**
* 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 bilan = compter(lus);
const survivants = lus.filter((r) => colonneDe(r) === 'survivants');
const equivalents = lus.filter((r) => colonneDe(r) === 'equivalents');
const delaisEtErreurs = lus.filter((r) => r.issue === 'delai' || r.issue === 'erreur');
const donnees = {
format: 1,
entete: tete,
bilan,
parFichier,
parOperateur,
survivants: survivants.map((r) => ({ ...mutantEcrit(r), couverte: r.couverte, longue: r.longue })),
equivalents: equivalents.map((r) => ({ ...mutantEcrit(r), raison: r.equivalent })),
delaisEtErreurs: delaisEtErreurs.map((r) => ({ ...mutantEcrit(r), issue: r.issue })),
};
const md = [
'# 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 sur une ligne couverte désigne une épreuve qui exécute sans affirmer.',
'',
'## Bilan',
'',
ENTETES_COLONNES.replace('| total', '| mutants'),
'|---:|---:|---:|---:|---:|---:|',
ligneComptes(null, bilan),
'',
'## Par fichier',
'',
`| fichier ${ENTETES_COLONNES}`,
'|---|---:|---:|---:|---:|---:|---:|',
...parFichier.map((c) => ligneComptes(`\`${c.fichier}\``, c)),
'',
'## Par opérateur',
'',
`| opérateur ${ENTETES_COLONNES}`,
'|---|---:|---:|---:|---:|---:|---:|',
...parOperateur.map((c) => ligneComptes(c.operateur, c)),
'',
`## Survivants (${survivants.length}), lignes couvertes d’abord`,
'',
...(survivants.length === 0
? ['Aucun.']
: [
'| lieu | opérateur | avant | après | ligne couverte | série longue |',
'|---|---|---|---|---|---|',
...survivants.map(
(r) =>
`| ${lieu(r)} | ${r.operateur} | ${cellule(r.original)} | ${cellule(r.remplacement)} | ${
r.couverte ? 'oui' : 'non'
} | ${r.longue === null ? '—' : ISSUE_LUE[r.longue]} |`,
),
]),
'',
`## Mutants équivalents (${equivalents.length})`,
'',
'Déclarés dans `audit/mutants-equivalents.json`, avec leur raison.',
'',
...(equivalents.length === 0
? ['Aucun.']
: [
'| lieu | opérateur | avant | après | raison |',
'|---|---|---|---|---|',
...equivalents.map(
(r) =>
`| ${lieu(r)} | ${r.operateur} | ${cellule(r.original)} | ${cellule(r.remplacement)} | ${r.equivalent
.replace(/\s+/g, ' ')
.replaceAll('|', '\\|')} |`,
),
]),
'',
`## Délais et erreurs (${delaisEtErreurs.length})`,
'',
...(delaisEtErreurs.length === 0
? ['Aucun.']
: [
'| lieu | opérateur | avant | après | issue |',
'|---|---|---|---|---|',
...delaisEtErreurs.map(
(r) => `| ${lieu(r)} | ${r.operateur} | ${cellule(r.original)} | ${cellule(r.remplacement)} | ${ISSUE_LUE[r.issue]} |`,
),
]),
'',
];
return { markdown: md.join('\n'), json: `${JSON.stringify(donnees, null, 2)}\n` };
}

View file

@ -0,0 +1,271 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le rapport de l'audit par mutation (§ 14.13) : rediger rend le Markdown et
// le JSON d'un audit, sur des résultats inventés. Les modules nommés ici
// n'existent pas : rediger ne lit aucun fichier.
import assert from 'node:assert/strict';
import { VERSION } from '../../src/version.genere.js';
import { describe, test } from '../../test/lanceur.js';
import { rediger } from './rapport.js';
const ENTETE = Object.freeze({
version: VERSION.affichee,
perimetre: ['src/verger'],
empreinte: 'a'.repeat(64),
node: 'v26.1.0',
});
// Un mutant inventé, aux champs du contrat de mutants.js.
function mutant(fichier, ligne, operateur, original, remplacement, colonne = 5) {
return {
cle: `${fichier}:${operateur}:${ligne}${colonne}:0`,
fichier,
ligne,
colonne,
operateur,
original,
remplacement,
debut: ligne * 100 + colonne,
fin: ligne * 100 + colonne + original.length,
};
}
const resultat = (m, issue, { couverte = true, longue = null, equivalent = null } = {}) => ({
mutant: m,
issue,
couverte,
longue,
equivalent,
});
// Neuf résultats sur deux modules, dans le désordre :
// pommes.js arithmetique tué ; frontiere survivant couvert (l. 30) ;
// negation survivant non couvert (l. 4) ; nombre-zero délai ;
// si-vrai survivant équivalent
// poires.js arithmetique survivant couvert (l. 12), tué par la série
// longue ; frontiere tué ; logique erreur ; frontiere survivant
// couvert (l. 12, colonne 2)
const RESULTATS = Object.freeze([
resultat(mutant('src/verger/pommes.js', 3, 'arithmetique', '+', '-'), 'tue'),
resultat(mutant('src/verger/poires.js', 12, 'arithmetique', '*', '/'), 'survivant', { longue: 'tue' }),
resultat(mutant('src/verger/pommes.js', 30, 'frontiere', '<', '<='), 'survivant', { longue: 'survivant' }),
resultat(mutant('src/verger/pommes.js', 4, 'negation', '===', '!=='), 'survivant', { couverte: false }),
resultat(mutant('src/verger/poires.js', 7, 'frontiere', '>', '>='), 'tue'),
resultat(mutant('src/verger/pommes.js', 9, 'nombre-zero', '1', '0'), 'delai'),
resultat(mutant('src/verger/poires.js', 20, 'logique', 'a && b', '((a )||( b))'), 'erreur'),
resultat(mutant('src/verger/pommes.js', 15, 'si-vrai', 'n > 0', 'true'), 'survivant', {
equivalent: 'le test est toujours vrai à cet endroit',
}),
resultat(mutant('src/verger/poires.js', 12, 'frontiere', '<=', '<', 2), 'survivant'),
]);
const lieux = (liste) => liste.map(({ fichier, ligne, colonne }) => `${fichier}:${ligne}:${colonne}`);
describe('rapport de mutation : les comptes', () => {
const { json } = rediger({ entete: ENTETE, resultats: RESULTATS });
const lu = JSON.parse(json);
test('le bilan compte chaque issue, un survivant équivalent à part', () => {
assert.deepEqual(lu.bilan, { total: 9, tues: 2, survivants: 4, equivalents: 1, delais: 1, erreurs: 1 });
});
test('par fichier, dans l’ordre des chemins', () => {
assert.deepEqual(lu.parFichier, [
{ fichier: 'src/verger/poires.js', total: 4, tues: 1, survivants: 2, equivalents: 0, delais: 0, erreurs: 1 },
{ fichier: 'src/verger/pommes.js', total: 5, tues: 1, survivants: 2, equivalents: 1, delais: 1, erreurs: 0 },
]);
});
test('par opérateur, dans l’ordre d’OPERATEURS', () => {
assert.deepEqual(lu.parOperateur, [
{ operateur: 'arithmetique', total: 2, tues: 1, survivants: 1, equivalents: 0, delais: 0, erreurs: 0 },
{ operateur: 'frontiere', total: 3, tues: 1, survivants: 2, equivalents: 0, delais: 0, erreurs: 0 },
{ operateur: 'negation', total: 1, tues: 0, survivants: 1, equivalents: 0, delais: 0, erreurs: 0 },
{ operateur: 'logique', total: 1, tues: 0, survivants: 0, equivalents: 0, delais: 0, erreurs: 1 },
{ operateur: 'nombre-zero', total: 1, tues: 0, survivants: 0, equivalents: 0, delais: 1, erreurs: 0 },
{ operateur: 'si-vrai', total: 1, tues: 0, survivants: 0, equivalents: 1, delais: 0, erreurs: 0 },
]);
});
test('le Markdown porte les mêmes comptes, en lignes de tableau', () => {
const { markdown } = rediger({ entete: ENTETE, resultats: RESULTATS });
const lignes = markdown.split('\n');
assert.ok(lignes.includes('| 9 | 2 | 4 | 1 | 1 | 1 |'), 'ligne du bilan');
assert.ok(lignes.includes('| `src/verger/poires.js` | 4 | 1 | 2 | 0 | 0 | 1 |'), 'ligne de poires.js');
assert.ok(lignes.includes('| `src/verger/pommes.js` | 5 | 1 | 2 | 1 | 1 | 0 |'), 'ligne de pommes.js');
assert.ok(lignes.includes('| frontiere | 3 | 1 | 2 | 0 | 0 | 0 |'), 'ligne de frontiere');
});
test('l’en-tête se recopie tel quel quand il ne nomme pas la machine', () => {
assert.deepEqual(lu.entete, { ...ENTETE });
const { markdown } = rediger({ entete: ENTETE, resultats: RESULTATS });
for (const valeur of [ENTETE.version, ENTETE.empreinte, ENTETE.node, 'src/verger']) {
assert.ok(markdown.includes(valeur), valeur);
}
});
});
describe('rapport de mutation : l’ordre', () => {
const { json, markdown } = rediger({ entete: ENTETE, resultats: RESULTATS });
const lu = JSON.parse(json);
test('les survivants couverts d’abord, puis par fichier, ligne et colonne', () => {
assert.deepEqual(lieux(lu.survivants), [
'src/verger/poires.js:12:2',
'src/verger/poires.js:12:5',
'src/verger/pommes.js:30:5',
'src/verger/pommes.js:4:5',
]);
assert.deepEqual(
lu.survivants.map(({ couverte, longue }) => [couverte, longue]),
[
[true, null],
[true, 'tue'],
[true, 'survivant'],
[false, null],
],
);
});
test('dans un même fichier, les lignes en ordre numérique : 9 avant 12', () => {
// L'opérateur de la ligne 12 précède celui de la ligne 9 dans OPERATEURS,
// et « 12 » précède « 9 » en ordre lexical : seul l'ordre numérique des
// lignes met la ligne 9 en tête.
const paire = [
resultat(mutant('src/verger/pommes.js', 12, 'arithmetique', '+', '-'), 'survivant'),
resultat(mutant('src/verger/pommes.js', 9, 'frontiere', '<', '<='), 'survivant'),
];
for (const resultats of [paire, [...paire].reverse()]) {
const { survivants } = JSON.parse(rediger({ entete: ENTETE, resultats }).json);
assert.deepEqual(lieux(survivants), ['src/verger/pommes.js:9:5', 'src/verger/pommes.js:12:5']);
}
});
test('le Markdown range les survivants dans le même ordre, l’équivalent dans sa section', () => {
const rangs = lieux(lu.survivants).map((lieu) => markdown.indexOf(`\`${lieu}\``));
assert.ok(rangs.every((rang) => rang > 0), 'chaque survivant est nommé');
assert.deepEqual([...rangs].sort((a, b) => a - b), rangs);
const section = markdown.indexOf('## Mutants équivalents');
assert.ok(section > rangs.at(-1), 'la section des équivalents suit les survivants');
assert.ok(markdown.indexOf('`src/verger/pommes.js:15:5`') > section);
assert.ok(markdown.includes('le test est toujours vrai à cet endroit'));
});
test('équivalents, délais et erreurs sont listés à part, hors des survivants', () => {
assert.deepEqual(lieux(lu.equivalents), ['src/verger/pommes.js:15:5']);
assert.equal(lu.equivalents[0].raison, 'le test est toujours vrai à cet endroit');
assert.deepEqual(
lu.delaisEtErreurs.map((r) => `${r.fichier}:${r.ligne} ${r.issue}`),
['src/verger/poires.js:20 erreur', 'src/verger/pommes.js:9 delai'],
);
});
test('l’ordre des résultats reçus ne change pas un octet de la sortie', () => {
const renverse = rediger({ entete: ENTETE, resultats: [...RESULTATS].reverse() });
assert.equal(renverse.markdown, markdown);
assert.equal(renverse.json, json);
});
});
describe('rapport de mutation : ce qu’il refuse', () => {
test('des résultats vides ou absents', () => {
assert.throws(() => rediger({ entete: ENTETE, resultats: [] }), /aucun résultat/);
assert.throws(() => rediger({ entete: ENTETE }), /aucun résultat/);
});
test('aucune date ni heure dans le texte', () => {
const { markdown, json } = rediger({ entete: ENTETE, resultats: RESULTATS });
for (const texte of [markdown, json]) {
assert.doesNotMatch(texte, /\d{4}-\d{2}-\d{2}/);
assert.doesNotMatch(texte, /\d{1,2}:\d{2}:\d{2}/);
assert.doesNotMatch(texte, /\b(?:date|instant|horodatage)\b/i);
}
});
test('un en-tête qui nomme la machine : un nom d’hôte, un chemin de compte', () => {
const refuse = (entete) =>
assert.throws(() => rediger({ entete, resultats: RESULTATS }), /ne nomme pas sa machine/, JSON.stringify(entete));
refuse({ ...ENTETE, hote: 'atelier-ombrelle' });
refuse({ ...ENTETE, poste: 'atelier-ombrelle.local' });
refuse({ ...ENTETE, node: 'atelier-ombrelle' });
refuse({ ...ENTETE, node: '/home/exemple/.local/node/bin/node' });
refuse({ ...ENTETE, perimetre: ['/home/exemple/verger/src/verger'] });
refuse({ ...ENTETE, perimetre: ['C:\\Users\\exemple\\verger'] });
refuse({ ...ENTETE, date: 'mardi' });
refuse({ ...ENTETE, node: 'v26.0.0-/home/exemple' });
});
test('une version de Node avec un suffixe de préversion, sans blanc ni barre oblique', () => {
for (const node of ['v26.0.0-rc.1', 'v27.0.0-pre']) {
assert.equal(JSON.parse(rediger({ entete: { ...ENTETE, node }, resultats: RESULTATS }).json).entete.node, node);
}
for (const node of ['v26.0.0-rc 1', 'v26.0.0-a/b', 'v26.0.0-', 'v26.0.0+rc']) {
assert.throws(() => rediger({ entete: { ...ENTETE, node }, resultats: RESULTATS }), /en-tête : node/, node);
}
});
test('une version que la dérivation de scripts/version.js refuse', () => {
const [annee, , , rang] = VERSION.affichee.split('.');
for (const version of [[annee, '13', '45', rang].join('.'), `${VERSION.affichee}2`]) {
assert.throws(() => rediger({ entete: { ...ENTETE, version }, resultats: RESULTATS }), /en-tête : version/, version);
}
});
test('un en-tête incomplet ou d’une autre forme', () => {
const refuse = (entete) => assert.throws(() => rediger({ entete, resultats: RESULTATS }), TypeError, JSON.stringify(entete));
const { empreinte, ...sansEmpreinte } = ENTETE;
assert.equal(empreinte.length, 64);
refuse(sansEmpreinte);
refuse({ ...ENTETE, empreinte: 'b'.repeat(63) });
refuse({ ...ENTETE, version: 'v1' });
refuse({ ...ENTETE, perimetre: [] });
refuse({ ...ENTETE, perimetre: ['src/../verger'] });
refuse(null);
});
test('un résultat d’une autre forme : issue, opérateur, chemin, équivalence, rejeu, clé', () => {
const [premier, second] = RESULTATS;
const refuse = (remplace, motif) =>
assert.throws(() => rediger({ entete: ENTETE, resultats: [remplace, second] }), motif, JSON.stringify(remplace));
refuse({ ...premier, issue: 'blesse' }, /issue/);
refuse({ ...premier, mutant: { ...premier.mutant, operateur: 'inversion' } }, /opérateur/);
refuse({ ...premier, mutant: { ...premier.mutant, fichier: '/home/exemple/verger/pommes.js' } }, /ne nomme pas sa machine/);
refuse({ ...premier, mutant: { ...premier.mutant, fichier: '../verger/pommes.js' } }, /chemin/);
refuse({ ...premier, mutant: { ...premier.mutant, ligne: 0 } }, /ligne/);
refuse({ ...premier, equivalent: 'tué pourtant' }, /équivalent/);
for (const equivalent of ['vu dans /home/exemple/notes.txt', 'vu dans C:\\Users\\exemple\\notes.txt']) {
assert.throws(
() => rediger({ entete: ENTETE, resultats: [{ ...RESULTATS[2], equivalent }, second] }),
/ne nomme pas sa machine : chemin de compte dans résultat 0, équivalent/,
equivalent,
);
}
refuse({ ...premier, longue: 'tue' }, /série longue/);
refuse({ ...premier, couverte: 'oui' }, /couverte/);
refuse({ ...premier, mutant: { ...premier.mutant, cle: second.mutant.cle } }, /clé/);
});
});
describe('rapport de mutation : les cellules de code', () => {
test('une barre verticale s’échappe, un nœud sur plusieurs lignes tient sur une', () => {
const resultats = [
resultat(mutant('src/verger/pommes.js', 2, 'logique', 'a ||\n b', '((a )&&(\n b))'), 'survivant'),
resultat(mutant('src/verger/pommes.js', 3, 'corps-si', `{ ${'x += 1; '.repeat(20)}}`, '{}'), 'survivant'),
];
const { markdown, json } = rediger({ entete: ENTETE, resultats });
const ligne = markdown.split('\n').find((l) => l.includes('`src/verger/pommes.js:2:5`'));
assert.ok(ligne.includes('`a \\|\\| b`'), ligne);
assert.ok(ligne.includes('`((a )&&( b))`'), ligne);
const longue = markdown.split('\n').find((l) => l.includes('`src/verger/pommes.js:3:5`'));
assert.ok(longue.includes('…`'), longue);
assert.ok(longue.length < 200, longue);
assert.equal(JSON.parse(json).survivants[0].original, 'a ||\n b');
});
test('un accent grave dans le code garde la cellule entière', () => {
const resultats = [resultat(mutant('src/verger/pommes.js', 2, 'logique', 'a && `x`', '((a )||( `x`))'), 'survivant')];
const { markdown } = rediger({ entete: ENTETE, resultats });
assert.ok(markdown.includes('`` a && `x` ``'), markdown);
});
});