gestion_table_tournante_libre/scripts/mutation/mutants.js
Mathieu Benoit 52bc5f0bc8 [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
2026-10-09 04:52:30 -04:00

314 lines
13 KiB
JavaScript

// © 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);
}