[ADD] mutation: engine mutant generator on Vite's parser
The engine's tests must be shown to fail when its code is wrong (spec 14.13). The generator parses each module of src/moteur with Vite's parseAst and yields one mutant per site and operator: arithmetic, boundary, negation, logic, update, numbers, booleans, branches, ternaries, min/max. Strings, comments, templates and imports are never mutated, sites follow source order, and a mutant key survives an unrelated line added above it. 2576 mutants on src/moteur, enumerated in tens of milliseconds. Checked: tests red first; 3201 node tests green from the index alone. --- FR --- [ADD] mutation : générateur de mutants du moteur, analyseur de Vite Il faut montrer que les épreuves du moteur échouent quand son code est faux (spec 14.13). Le générateur lit chaque module de src/moteur par parseAst de Vite et rend un mutant par site et par opérateur : arithmétique, frontière, négation, logique, mise à jour, nombres, booléens, branches, ternaires, min/max. Chaînes, commentaires, gabarits et imports ne sont jamais mutés, les sites suivent l'ordre du source, et une clé de mutant survit à une ligne sans rapport ajoutée au-dessus. 2576 mutants sur src/moteur, énumérés en quelques dizaines de millisecondes. Vérifié : rouge d'abord ; 3201 node verts depuis l'index seul. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
aabb05e038
commit
52bc5f0bc8
2 changed files with 658 additions and 0 deletions
314
scripts/mutation/mutants.js
Normal file
314
scripts/mutation/mutants.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
344
scripts/mutation/mutants.test.js
Normal file
344
scripts/mutation/mutants.test.js
Normal file
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue