diff --git a/Makefile b/Makefile index 847f71c..feb6835 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/package.json b/package.json index f2ef5b7..523d7e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/mutation/audit.js b/scripts/mutation/audit.js new file mode 100644 index 0000000..661d440 --- /dev/null +++ b/scripts/mutation/audit.js @@ -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:, +// 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(); +} diff --git a/scripts/mutation/audit.test.js b/scripts/mutation/audit.test.js new file mode 100644 index 0000000..2b57e6f --- /dev/null +++ b/scripts/mutation/audit.test.js @@ -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', + ]); + }); +}); diff --git a/scripts/mutation/rapport.js b/scripts/mutation/rapport.js new file mode 100644 index 0000000..f4b1dff --- /dev/null +++ b/scripts/mutation/rapport.js @@ -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` }; +} diff --git a/scripts/mutation/rapport.test.js b/scripts/mutation/rapport.test.js new file mode 100644 index 0000000..53c1c12 --- /dev/null +++ b/scripts/mutation/rapport.test.js @@ -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); + }); +});