gestion_table_tournante_libre/scripts/mutation/mutants.js

315 lines
13 KiB
JavaScript
Raw Normal View History

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