315 lines
13 KiB
JavaScript
315 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);
|
||
|
|
}
|