diff --git a/scripts/mutation/mutants.js b/scripts/mutation/mutants.js new file mode 100644 index 0000000..bb77cc5 --- /dev/null +++ b/scripts/mutation/mutants.js @@ -0,0 +1,314 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Énumération des mutants d'un module, pour l'audit par mutation du moteur +// (§ 14.13). Le source se lit par parseAst de Vite, dont les décalages start +// et end comptent des unités UTF-16 : ceux de String.prototype.slice, ce que +// mutants.test.js épingle sur un commentaire accentué. Un mutant remplace un +// seul intervalle [debut, fin) du source ; appliquer refait ce remplacement. +// +// Rien de ce qui n'est pas du code ne se mute : ni chaîne, ni commentaire (ils +// sont absents de l'arbre, et la recherche d'un opérateur les saute), ni +// gabarit — expressions comprises : elles composent un message —, ni import, +// statique ou dynamique. Un module de définitions de types et les épreuves +// n'ont aucun mutant. +import { createHash } from 'node:crypto'; +import { basename } from 'node:path'; +import { parseAst } from 'vite'; + +/** + * @typedef {Object} Mutant + * @property {string} cle fichier:operateur:empreinte:rang — stable quand + * le reste du source bouge, voir cleDe + * @property {string} fichier le chemin reçu, tel quel + * @property {number} ligne 1 pour la première ligne, du jeton muté + * @property {number} colonne 1 pour le premier caractère, en unités UTF-16 + * @property {string} operateur un nom d'OPERATEURS + * @property {string} original source.slice(debut, fin) + * @property {string} remplacement ce qui prend sa place + * @property {number} debut décalage en unités UTF-16, inclus + * @property {number} fin décalage en unités UTF-16, exclu + */ + +// Les opérateurs, dans l'ordre où deux mutants d'un même site se rangent. +// arithmetique + ↔ -, * ↔ /, % → * +// frontiere < ↔ <=, > ↔ >= +// negation === ↔ !==, == ↔ !=, < → >=, <= → >, > → <=, >= → < +// logique && ↔ ||, ?? → && — le nœud entier, entre parenthèses +// retrait-non !x → x +// mise-a-jour ++ ↔ --, += ↔ -= +// nombre-plus-un n → n + 1 ; nombre-zero n → 0 ; booleen true ↔ false +// si-vrai, si-faux le test d'un if → true, false +// ternaire-vrai, ternaire-faux le test d'un ternaire → true, false +// min-max Math.min ↔ Math.max +// corps-si le corps d'un if → {} +export const OPERATEURS = Object.freeze([ + 'arithmetique', + 'frontiere', + 'negation', + 'logique', + 'retrait-non', + 'mise-a-jour', + 'nombre-plus-un', + 'nombre-zero', + 'booleen', + 'si-vrai', + 'si-faux', + 'ternaire-vrai', + 'ternaire-faux', + 'min-max', + 'corps-si', +]); + +const ARITHMETIQUE = { '+': '-', '-': '+', '*': '/', '/': '*', '%': '*' }; +const FRONTIERE = { '<': '<=', '<=': '<', '>': '>=', '>=': '>' }; +const NEGATION = { '===': '!==', '!==': '===', '==': '!=', '!=': '==', '<': '>=', '<=': '>', '>': '<=', '>=': '<' }; +const LOGIQUE = { '&&': '||', '||': '&&', '??': '&&' }; +const MISE_A_JOUR = { '++': '--', '--': '++', '+=': '-=', '-=': '+=' }; +const MIN_MAX = { min: 'max', max: 'min' }; + +// Sous-arbres qui ne portent aucun site : les import, les ré-exportations et +// les gabarits. +const SANS_SITE = new Set([ + 'ImportDeclaration', + 'ImportExpression', + 'ExportAllDeclaration', + 'TemplateLiteral', + 'TaggedTemplateExpression', +]); + +// Vrai pour un module qui se mute : ni le module des seules définitions de +// types, ni une épreuve (.test.js, dont .long.test.js et .navigateur.test.js). +function seMute(fichier) { + const nom = basename(fichier); + return nom !== 'types.js' && !nom.endsWith('.test.js'); +} + +const estChaine = (noeud) => + (noeud.type === 'Literal' && typeof noeud.value === 'string') || noeud.type === 'TemplateLiteral'; + +// Le décalage du jeton `jeton` entre debut et fin, là où seuls se trouvent des +// blancs, des parenthèses, des commentaires et ce jeton : l'espace entre deux +// opérandes. Lève sur tout autre caractère, ou sans jeton. +function jetonEntre(source, debut, fin, jeton) { + let i = debut; + while (i < fin) { + if (source.startsWith('/*', i)) { + i = source.indexOf('*/', i + 2) + 2; + } else if (source.startsWith('//', i)) { + const saut = source.indexOf('\n', i); + i = saut === -1 ? fin : saut + 1; + } else if (/[\s()]/u.test(source[i])) { + i += 1; + } else if (source.startsWith(jeton, i)) { + return i; + } else { + break; + } + } + throw new Error(`opérateur ${jeton} introuvable entre ${debut} et ${fin}`); +} + +// Les sites d'un nœud : { operateur, debut, fin, remplacement, site, texte }. +// site : décalage du jeton muté, qui donne ligne et colonne ; texte : le texte +// du nœud qui entre dans la clé. Parent : le nœud qui contient celui-ci, ou null. +function sitesDe(source, noeud, parent) { + const texte = (n) => source.slice(n.start, n.end); + const sites = []; + const jeton = (operateur, debut, ancien, nouveau, noeudCle = noeud) => { + sites.push({ operateur, debut, fin: debut + ancien.length, remplacement: nouveau, site: debut, texte: texte(noeudCle) }); + }; + switch (noeud.type) { + case 'BinaryExpression': { + const position = () => jetonEntre(source, noeud.left.end, noeud.right.start, noeud.operator); + const concatene = noeud.operator === '+' && (estChaine(noeud.left) || estChaine(noeud.right)); + if (noeud.operator in ARITHMETIQUE && !concatene) { + jeton('arithmetique', position(), noeud.operator, ARITHMETIQUE[noeud.operator]); + } + if (noeud.operator in FRONTIERE) jeton('frontiere', position(), noeud.operator, FRONTIERE[noeud.operator]); + if (noeud.operator in NEGATION) jeton('negation', position(), noeud.operator, NEGATION[noeud.operator]); + break; + } + case 'LogicalExpression': { + // && et || n'ont pas la même priorité, et ?? ne se mêle à aucun des deux + // sans parenthèses : le nœud entier se réécrit, chaque opérande et le + // tout entre parenthèses. Le texte de part et d'autre de l'opérateur + // garde ses commentaires ; une parenthèse ferme après un saut de ligne + // qui termine un commentaire //. + const position = jetonEntre(source, noeud.left.end, noeud.right.start, noeud.operator); + const gauche = source.slice(noeud.start, position); + const droite = source.slice(position + noeud.operator.length, noeud.end); + sites.push({ + operateur: 'logique', + debut: noeud.start, + fin: noeud.end, + remplacement: `((${gauche})${LOGIQUE[noeud.operator]}(${droite}))`, + site: position, + texte: texte(noeud), + }); + break; + } + case 'UnaryExpression': + if (noeud.operator === '!' && source[noeud.start] === '!') { + sites.push({ + operateur: 'retrait-non', + debut: noeud.start, + fin: noeud.start + 1, + remplacement: '', + site: noeud.start, + texte: texte(noeud), + }); + } + break; + case 'UpdateExpression': { + const debut = noeud.prefix ? noeud.start : noeud.end - noeud.operator.length; + if (source.startsWith(noeud.operator, debut)) { + jeton('mise-a-jour', debut, noeud.operator, MISE_A_JOUR[noeud.operator]); + } + break; + } + case 'AssignmentExpression': + if (noeud.operator in MISE_A_JOUR && !(noeud.operator === '+=' && estChaine(noeud.right))) { + const debut = jetonEntre(source, noeud.left.end, noeud.right.start, noeud.operator); + jeton('mise-a-jour', debut, noeud.operator, MISE_A_JOUR[noeud.operator]); + } + break; + case 'Literal': { + // Une clé de propriété écrite n'est pas une valeur. Le texte d'un + // littéral seul ne distingue rien : la clé prend celui de son parent. + const cle = parent && (parent.type === 'Property' || parent.type === 'MethodDefinition' || parent.type === 'PropertyDefinition') && parent.key === noeud && !parent.computed; + const contexte = parent ?? noeud; + if (typeof noeud.value === 'number' && !cle) { + const suivant = noeud.value + 1; + if (suivant !== noeud.value) jeton('nombre-plus-un', noeud.start, noeud.raw, String(suivant), contexte); + if (noeud.value !== 0) jeton('nombre-zero', noeud.start, noeud.raw, '0', contexte); + } else if (typeof noeud.value === 'boolean') { + jeton('booleen', noeud.start, noeud.raw, String(!noeud.value), contexte); + } + break; + } + case 'IfStatement': + case 'ConditionalExpression': { + const [vrai, faux] = noeud.type === 'IfStatement' ? ['si-vrai', 'si-faux'] : ['ternaire-vrai', 'ternaire-faux']; + const test = texte(noeud.test); + if (test !== 'true') jeton(vrai, noeud.test.start, test, 'true', noeud.test); + if (test !== 'false') jeton(faux, noeud.test.start, test, 'false', noeud.test); + if (noeud.type === 'IfStatement') { + const corps = noeud.consequent; + const vide = corps.type === 'BlockStatement' && corps.body.length === 0; + if (!vide) jeton('corps-si', corps.start, texte(corps), '{}', corps); + } + break; + } + case 'CallExpression': { + const appele = noeud.callee; + if ( + appele.type === 'MemberExpression' && + !appele.computed && + appele.object.type === 'Identifier' && + appele.object.name === 'Math' && + appele.property.type === 'Identifier' && + appele.property.name in MIN_MAX + ) { + jeton('min-max', appele.property.start, appele.property.name, MIN_MAX[appele.property.name]); + } + break; + } + default: + break; + } + return sites; +} + +// Parcourt l'arbre en profondeur et appelle visiter(noeud, parent) sur chaque +// nœud hors des sous-arbres SANS_SITE. L'ordre de la visite ne décide de rien : +// mutants trie les sites par leur place dans le source. +function parcourir(noeud, parent, visiter) { + if (SANS_SITE.has(noeud.type)) return; + if (noeud.type === 'ExportNamedDeclaration' && noeud.source) return; + visiter(noeud, parent); + for (const valeur of Object.values(noeud)) { + const enfants = Array.isArray(valeur) ? valeur : [valeur]; + for (const enfant of enfants) { + if (enfant !== null && typeof enfant === 'object' && typeof enfant.type === 'string') { + parcourir(enfant, noeud, visiter); + } + } + } +} + +// Décalages du début de chaque ligne, pour ligne et colonne. +function debutsDeLignes(source) { + const debuts = [0]; + for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1)) debuts.push(i + 1); + return debuts; +} + +// La ligne (1…) du décalage, par dichotomie sur les débuts de lignes. +function ligneDe(debuts, decalage) { + let bas = 0; + let haut = debuts.length - 1; + while (bas < haut) { + const milieu = (bas + haut + 1) >> 1; + if (debuts[milieu] <= decalage) bas = milieu; + else haut = milieu - 1; + } + return bas + 1; +} + +// L'empreinte du texte d'un nœud, blancs ramenés à une espace : seize +// chiffres hexadécimaux de son SHA-256. Une ligne ajoutée ailleurs ne la +// change pas ; une réindentation non plus. +const empreinte = (texte) => createHash('sha256').update(texte.replace(/\s+/gu, ' ')).digest('hex').slice(0, 16); + +/** + * Les mutants du source, dans l'ordre du source : par décalage du jeton muté, + * puis dans l'ordre d'OPERATEURS. La clé est fichier:operateur:empreinte:rang, + * l'empreinte celle du texte du nœud muté (du parent pour un littéral), le + * rang celui du mutant parmi ceux de même fichier, opérateur et empreinte. + * Lève sur un source que parseAst refuse. + * @param {string} source + * @param {string} fichier chemin relatif à la racine du projet + * @returns {Mutant[]} + */ +export function mutants(source, fichier) { + if (!seMute(fichier)) return []; + const sites = []; + parcourir(parseAst(source), null, (noeud, parent) => sites.push(...sitesDe(source, noeud, parent))); + sites.sort((a, b) => a.site - b.site || OPERATEURS.indexOf(a.operateur) - OPERATEURS.indexOf(b.operateur)); + const debuts = debutsDeLignes(source); + const rangs = new Map(); + return sites.map(({ operateur, debut, fin, remplacement, site, texte }) => { + const prefixe = `${fichier}:${operateur}:${empreinte(texte)}`; + const rang = rangs.get(prefixe) ?? 0; + rangs.set(prefixe, rang + 1); + const ligne = ligneDe(debuts, site); + return { + cle: `${prefixe}:${rang}`, + fichier, + ligne, + colonne: site - debuts[ligne - 1] + 1, + operateur, + original: source.slice(debut, fin), + remplacement, + debut, + fin, + }; + }); +} + +/** + * Le source où l'intervalle du mutant porte son remplacement. Lève quand le + * source ne porte pas l'original à cette place : le mutant vient d'une autre + * version du fichier. + * @param {string} source + * @param {Mutant} mutant + * @returns {string} + */ +export function appliquer(source, mutant) { + const { debut, fin, original, remplacement, cle } = mutant; + if (source.slice(debut, fin) !== original) { + throw new Error(`le source ne porte pas l'original du mutant ${cle} en [${debut}, ${fin})`); + } + return source.slice(0, debut) + remplacement + source.slice(fin); +} diff --git a/scripts/mutation/mutants.test.js b/scripts/mutation/mutants.test.js new file mode 100644 index 0000000..3c6550e --- /dev/null +++ b/scripts/mutation/mutants.test.js @@ -0,0 +1,344 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de l'énumération des mutants (§ 14.13) : chaque opérateur sur un +// extrait inventé, les décalages en unités UTF-16, ce qui n'est jamais muté, +// la clé stable, et le plancher du moteur réel. +import assert from 'node:assert/strict'; +import { readdirSync, 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 { appliquer, mutants, OPERATEURS } from './mutants.js'; + +const RACINE = fileURLToPath(new URL('../..', import.meta.url)); +const EXTRAIT = 'scripts/mutation/extrait.js'; + +// Les sources mutées d'un extrait, pour un opérateur, dans l'ordre du source. +const mutes = (source, operateur) => + mutants(source, EXTRAIT) + .filter((m) => m.operateur === operateur) + .map((m) => appliquer(source, m)); + +// Vrai quand (ligne, colonne) ne décroît jamais d'un mutant au suivant. +const ordonne = (liste) => + liste.every((m, i) => i === 0 || liste[i - 1].ligne < m.ligne || (liste[i - 1].ligne === m.ligne && liste[i - 1].colonne <= m.colonne)); + +describe('OPERATEURS', () => { + test('la liste est figée et nomme chaque opérateur une fois', () => { + assert.deepEqual(OPERATEURS, [ + 'arithmetique', + 'frontiere', + 'negation', + 'logique', + 'retrait-non', + 'mise-a-jour', + 'nombre-plus-un', + 'nombre-zero', + 'booleen', + 'si-vrai', + 'si-faux', + 'ternaire-vrai', + 'ternaire-faux', + 'min-max', + 'corps-si', + ]); + assert.ok(Object.isFrozen(OPERATEURS)); + }); +}); + +describe('chaque opérateur sur un extrait minimal', () => { + test('arithmetique : + et - s’échangent, * et / aussi, % devient *', () => { + const source = 'y = a + b;\ny = a - b;\ny = a * b;\ny = a / b;\ny = a % b;\n'; + assert.deepEqual(mutes(source, 'arithmetique'), [ + 'y = a - b;\ny = a - b;\ny = a * b;\ny = a / b;\ny = a % b;\n', + 'y = a + b;\ny = a + b;\ny = a * b;\ny = a / b;\ny = a % b;\n', + 'y = a + b;\ny = a - b;\ny = a / b;\ny = a / b;\ny = a % b;\n', + 'y = a + b;\ny = a - b;\ny = a * b;\ny = a * b;\ny = a % b;\n', + 'y = a + b;\ny = a - b;\ny = a * b;\ny = a / b;\ny = a * b;\n', + ]); + }); + + test('arithmetique : une concaténation avec une chaîne n’est pas une addition', () => { + assert.deepEqual(mutes("s = 'a' + b;\ns = b + 'a';\n", 'arithmetique'), []); + }); + + test('frontiere : < et <= s’échangent, > et >= aussi', () => { + assert.deepEqual(mutes('p = a < b; p = a <= b; p = a > b; p = a >= b;', 'frontiere'), [ + 'p = a <= b; p = a <= b; p = a > b; p = a >= b;', + 'p = a < b; p = a < b; p = a > b; p = a >= b;', + 'p = a < b; p = a <= b; p = a >= b; p = a >= b;', + 'p = a < b; p = a <= b; p = a > b; p = a > b;', + ]); + }); + + test('negation : l’égalité et chaque comparaison prennent leur contraire', () => { + const source = 'p = a === b; p = a !== b; p = a == b; p = a != b; p = a < b; p = a <= b; p = a > b; p = a >= b;'; + const remplacements = mutants(source, EXTRAIT) + .filter((m) => m.operateur === 'negation') + .map((m) => `${m.original}→${m.remplacement}`); + assert.deepEqual(remplacements, ['===→!==', '!==→===', '==→!=', '!=→==', '<→>=', '<=→>', '>→<=', '>=→<']); + }); + + test('logique : && et || s’échangent, ?? devient &&, entre parenthèses', () => { + assert.deepEqual(mutes('p = a && b; p = a || b; p = a ?? b;', 'logique'), [ + 'p = ((a )||( b)); p = a || b; p = a ?? b;', + 'p = a && b; p = ((a )&&( b)); p = a ?? b;', + 'p = a && b; p = a || b; p = ((a )&&( b));', + ]); + }); + + test('logique : la parenthèse garde l’arbre quand la priorité change', () => { + // a && b && c se lit (a && b) && c : sans parenthèse, le nœud de gauche muté + // en || se relirait a || (b && c). + const source = 'p = a && b && c;'; + const interieur = mutants(source, EXTRAIT).find((m) => m.operateur === 'logique' && m.original === 'a && b'); + assert.equal(appliquer(source, interieur), 'p = ((a )||( b)) && c;'); + // Un ?? sous un ?? ne se mêle pas à && sans parenthèse : la source mutée se lit. + for (const m of mutants('p = a ?? b ?? c;', EXTRAIT)) { + parseAst(appliquer('p = a ?? b ?? c;', m)); + } + }); + + test('retrait-non : le ! part, son argument reste', () => { + assert.deepEqual(mutes('p = !a; q = !(b && c);', 'retrait-non'), ['p = a; q = !(b && c);', 'p = !a; q = (b && c);']); + }); + + test('mise-a-jour : ++ et -- s’échangent, += et -= aussi', () => { + assert.deepEqual(mutes('i++; --j; k += c; k -= c;', 'mise-a-jour'), [ + 'i--; --j; k += c; k -= c;', + 'i++; ++j; k += c; k -= c;', + 'i++; --j; k -= c; k -= c;', + 'i++; --j; k += c; k += c;', + ]); + }); + + test('nombre-plus-un et nombre-zero : n devient n + 1, puis 0 s’il ne l’est pas déjà', () => { + const source = 'n = 3; n = 0.5; n = 0; n = 0x10;'; + assert.deepEqual(mutes(source, 'nombre-plus-un'), [ + 'n = 4; n = 0.5; n = 0; n = 0x10;', + 'n = 3; n = 1.5; n = 0; n = 0x10;', + 'n = 3; n = 0.5; n = 1; n = 0x10;', + 'n = 3; n = 0.5; n = 0; n = 17;', + ]); + assert.deepEqual(mutes(source, 'nombre-zero'), [ + 'n = 0; n = 0.5; n = 0; n = 0x10;', + 'n = 3; n = 0; n = 0; n = 0x10;', + 'n = 3; n = 0.5; n = 0; n = 0;', + ]); + }); + + test('nombre : une clé de propriété n’est pas une valeur', () => { + assert.deepEqual(mutants('o = { 1: a };', EXTRAIT), []); + }); + + test('booleen : true et false s’échangent', () => { + assert.deepEqual(mutes('v = true; w = false;', 'booleen'), ['v = false; w = false;', 'v = true; w = true;']); + }); + + test('si-vrai et si-faux : le test d’un if devient constant, sauf s’il l’est déjà', () => { + const source = 'if (a > b) f();\nif (true) g();\n'; + assert.deepEqual(mutes(source, 'si-vrai'), ['if (true) f();\nif (true) g();\n']); + assert.deepEqual(mutes(source, 'si-faux'), ['if (false) f();\nif (true) g();\n', 'if (a > b) f();\nif (false) g();\n']); + }); + + test('ternaire-vrai et ternaire-faux : le test du ternaire devient constant', () => { + assert.deepEqual(mutes('v = a || b ? c : d;', 'ternaire-vrai'), ['v = true ? c : d;']); + assert.deepEqual(mutes('v = a || b ? c : d;', 'ternaire-faux'), ['v = false ? c : d;']); + }); + + test('min-max : Math.min et Math.max s’échangent, rien d’autre ne s’appelle min', () => { + assert.deepEqual(mutes('v = Math.min(a, b); w = Math.max(a, b); x = o.min(a);', 'min-max'), [ + 'v = Math.max(a, b); w = Math.max(a, b); x = o.min(a);', + 'v = Math.min(a, b); w = Math.min(a, b); x = o.min(a);', + ]); + }); + + test('corps-si : le corps d’un if se vide, l’autre branche reste', () => { + const source = 'if (a) { f(); } else { g(); }\nif (b) h();\nif (c) {}\n'; + assert.deepEqual(mutes(source, 'corps-si'), [ + 'if (a) {} else { g(); }\nif (b) h();\nif (c) {}\n', + 'if (a) { f(); } else { g(); }\nif (b) {}\nif (c) {}\n', + ]); + }); +}); + +describe('décalages', () => { + test('un commentaire accentué avant le site : appliquer coupe au bon endroit, en unités UTF-16', () => { + // é, « » et une clé de sol hors du plan multilingue de base, qui compte + // deux unités UTF-16 et quatre octets. + const source = '// Côté « été » \u{1D11E}\nexport const x = a < b;\n'; + const [site] = mutants(source, EXTRAIT).filter((m) => m.operateur === 'frontiere'); + const indice = source.indexOf('<'); + assert.equal(indice, 38); + assert.equal(site.debut, indice); + assert.equal(site.fin, indice + 1); + assert.equal(site.ligne, 2); + assert.equal(site.colonne, 20); + assert.equal(appliquer(source, site), '// Côté « été » \u{1D11E}\nexport const x = a <= b;\n'); + }); + + test('un commentaire entre les opérandes qui porte l’opérateur n’est pas le site', () => { + const source = 'y = a /* é - < */ - b;'; + const sites = mutants(source, EXTRAIT).filter((m) => m.operateur === 'arithmetique'); + assert.equal(sites.length, 1); + assert.equal(sites[0].debut, source.lastIndexOf('-')); + assert.equal(appliquer(source, sites[0]), 'y = a /* é - < */ + b;'); + }); + + test('un commentaire de ligne entre les opérandes qui porte l’opérateur n’est pas le site', () => { + const source = 'y = a // note - <\n - b;'; + const sites = mutants(source, EXTRAIT).filter((m) => m.operateur === 'arithmetique'); + assert.equal(sites.length, 1); + assert.equal(sites[0].debut, source.lastIndexOf('-')); + assert.equal(appliquer(source, sites[0]), 'y = a // note - <\n + b;'); + parseAst(appliquer(source, sites[0])); + }); + + test('appliquer refuse un mutant que le source ne porte plus à sa place', () => { + const source = 'y = a + b;'; + const [site] = mutants(source, EXTRAIT); + assert.throws(() => appliquer('y = a - b;', site), /ne porte pas/); + }); +}); + +describe('ce qui n’est jamais muté', () => { + test('ni chaîne, ni commentaire, ni gabarit, ni import', () => { + const source = [ + "import { a } from './a.js';", + "export { b } from './b.js';", + "const s = 'a < b + 1 && true';", + '// c < d + 1 && true', + '/* e > 2 */', + 'const t = `${a + 1} < 2 ${true}`;', + 'const u = String.raw`${a - 1}`;', + "const v = import('./c.js');", + '', + ].join('\n'); + assert.deepEqual(mutants(source, EXTRAIT), []); + }); + + test('un import dynamique dont l’argument porte des sites reste sans mutant', () => { + // Hors de import(…), le ternaire, i + 1 et le 1 en donneraient. + assert.deepEqual(mutants("const m = import(a ? './x.js' : './y.js');\n", EXTRAIT), []); + assert.deepEqual(mutants("const n = import('./p' + (i + 1) + '.js');\n", EXTRAIT), []); + assert.ok(mutants("const m = a ? './x.js' : './y.js';\n", EXTRAIT).length > 0); + assert.ok(mutants("const n = './p' + (i + 1) + '.js';\n", EXTRAIT).length > 0); + }); + + test('un source sans site rend []', () => { + assert.deepEqual(mutants('', EXTRAIT), []); + assert.deepEqual(mutants("export const NOM = 'x';\n", EXTRAIT), []); + }); + + test('types.js et les épreuves sont exclus, quel que soit leur contenu', () => { + assert.ok(mutants('p = a < b;', EXTRAIT).length > 0); + assert.deepEqual(mutants('p = a < b;', 'src/moteur/types.js'), []); + assert.deepEqual(mutants('p = a < b;', 'src/moteur/plafond.test.js'), []); + assert.deepEqual(mutants('p = a < b;', 'src/moteur/plafond.long.test.js'), []); + }); +}); + +describe('la clé', () => { + const SOURCE = 'function f(a, b) {\n if (a < b) return a + 2;\n return b + 1;\n}\nconst g = (a) => a + 1;\nconst h = (a) => a + 1;\n'; + + test('chaque mutant a sa clé, et deux sites identiques se distinguent par leur rang', () => { + const cles = mutants(SOURCE, EXTRAIT).map((m) => m.cle); + assert.equal(new Set(cles).size, cles.length); + const jumeaux = mutants(SOURCE, EXTRAIT).filter((m) => m.operateur === 'arithmetique' && m.ligne >= 5); + assert.equal(jumeaux.length, 2); + assert.match(jumeaux[0].cle, /:0$/); + assert.match(jumeaux[1].cle, /:1$/); + }); + + test('la clé porte le fichier et l’opérateur', () => { + assert.equal(mutants(SOURCE, EXTRAIT).length, 17); + for (const m of mutants(SOURCE, EXTRAIT)) { + assert.ok(m.cle.startsWith(`${EXTRAIT}:${m.operateur}:`), m.cle); + } + const autre = mutants(SOURCE, 'scripts/mutation/autre.js').map((m) => m.cle); + assert.equal(autre.filter((cle) => mutants(SOURCE, EXTRAIT).some((m) => m.cle === cle)).length, 0); + }); + + test('une ligne sans rapport ajoutée au-dessus ne change aucune clé', () => { + const avant = mutants(SOURCE, EXTRAIT); + // Le 1 et le 2 ajoutés précèdent ceux de SOURCE : une clé prise sur le + // seul littéral verrait leur rang glisser. + const apres = mutants(`let k = 1;\nconst z = y * 2; // é\n${SOURCE}`, EXTRAIT).filter((m) => m.ligne > 2); + assert.deepEqual( + apres.map(({ cle, operateur, original, remplacement, ligne }) => ({ cle, operateur, original, remplacement, ligne })), + avant.map(({ cle, operateur, original, remplacement, ligne }) => ({ cle, operateur, original, remplacement, ligne: ligne + 2 })), + ); + }); + + test('deux énumérations du même source sont identiques', () => { + assert.ok(mutants(SOURCE, EXTRAIT).length > 0); + assert.deepEqual(mutants(SOURCE, EXTRAIT), mutants(SOURCE, EXTRAIT)); + }); +}); + +describe('l’ordre du source', () => { + test('les mutants se rangent par place du jeton muté, quel que soit le parcours de l’arbre', () => { + // Le if se visite avant le < de son test, et le test avant le corps. + const source = 'if (a < b) y = a + 2;\n'; + const liste = mutants(source, EXTRAIT); + assert.ok(ordonne(liste)); + assert.deepEqual( + liste.map((m) => `${m.colonne}:${m.operateur}`), + ['5:si-vrai', '5:si-faux', '7:frontiere', '7:negation', '12:corps-si', '18:arithmetique', '20:nombre-plus-un', '20:nombre-zero'], + ); + }); + + test('deux mutants d’un même site se rangent dans l’ordre d’OPERATEURS, non dans celui du parcours', () => { + // Le if se visite avant son test : sans départage, si-vrai passerait devant. + const operateurs = (source, colonne) => + mutants(source, EXTRAIT) + .filter((m) => m.colonne === colonne) + .map((m) => m.operateur); + assert.deepEqual(operateurs('p = a < b;', 7), ['frontiere', 'negation']); + assert.deepEqual(operateurs('if (0) f();', 5), ['nombre-plus-un', 'si-vrai', 'si-faux']); + assert.deepEqual(operateurs('if (!a) f();', 5), ['retrait-non', 'si-vrai', 'si-faux']); + }); +}); + +describe('le moteur réel', () => { + // Les modules du moteur, hors épreuves, dans l'ordre des noms. + const dossier = join(RACINE, 'src/moteur'); + const modules = readdirSync(dossier) + .filter((nom) => nom.endsWith('.js')) + .sort() + .map((nom) => ({ fichier: `src/moteur/${nom}`, source: readFileSync(join(dossier, nom), 'utf8') })); + + test('il rend au moins 1 000 mutants, de clés distinctes, chacun à sa place dans le source', () => { + const tous = modules.flatMap(({ fichier, source }) => + mutants(source, fichier).map((m) => { + assert.equal(source.slice(m.debut, m.fin), m.original, m.cle); + assert.notEqual(m.remplacement, m.original, m.cle); + return m; + }), + ); + assert.ok(tous.length >= 1000, `${tous.length} mutants`); + for (const { fichier, source } of modules) assert.ok(ordonne(mutants(source, fichier)), fichier); + assert.equal(new Set(tous.map((m) => m.cle)).size, tous.length); + assert.ok(tous.every((m) => !m.fichier.endsWith('types.js') && !m.fichier.endsWith('.test.js'))); + }); + + // Relire chaque source mutée reparse le module entier par mutant, des + // milliers de fois : se relisent le premier mutant de chaque opérateur dans + // chaque module, et tous ceux de logique, les seuls qui réécrivent un nœud + // entier. + test('une source mutée du moteur se lit encore, pour chaque opérateur de chaque module', () => { + const operateurs = new Set(); + for (const { fichier, source } of modules) { + const vus = new Set(); + for (const m of mutants(source, fichier)) { + if (vus.has(m.operateur) && m.operateur !== 'logique') continue; + vus.add(m.operateur); + operateurs.add(m.operateur); + assert.doesNotThrow(() => parseAst(appliquer(source, m)), m.cle); + } + } + assert.deepEqual([...operateurs].sort(), [...OPERATEURS].sort()); + }); +});