gestion_table_tournante_libre/scripts/mutation/graphe.js
Mathieu Benoit 8bdc959183 [ADD] mutation: run one mutant through node --test with a loader hook
A mutant counts only when the tests that import its module actually run
against the mutated source. A loader hook (module.registerHooks) serves the
mutated text in place of the module; the import graph picks the tests that
reach it, and node --test runs them in a child with a deadline. The verdict
is killed, survived, timed out or error; an empty request, a missing test
or a non-positive deadline is refused before anything starts.
Checked: red first; 3418 node and 76 long tests green from the index,
including a real run on a small invented module.

--- FR ---

[ADD] mutation : jouer un mutant sous node --test, crochet de chargement

Un mutant ne compte que si les épreuves qui importent son module jouent
vraiment contre le source muté. Un crochet de chargement
(module.registerHooks) sert le texte muté à la place du module ; le graphe
des imports choisit les épreuves qui l'atteignent, et node --test les joue
dans un enfant sous délai. Le verdict est tué, survivant, délai ou erreur ;
une demande vide, une épreuve absente ou un délai non positif sont refusés
avant tout lancement.
Vérifié : rouge d'abord ; 3418 node et 76 longues vertes depuis l'index,
dont un vrai lancement sur un petit module inventé.

Assisted-by: Claude Opus 5.5
2026-10-09 13:04:14 -04:00

118 lines
4.5 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le graphe des imports, pour l'audit par mutation (§ 14.13) : un mutant ne
// se joue que contre les épreuves dont les imports atteignent le module muté.
// Un chemin est relatif à la racine du projet, séparé par « / » sur tout
// système. Seuls comptent les chargements relatifs écrits en clair — import,
// export … from, import() d'une chaîne : un spécificateur nu ou natif mène
// hors du projet, et un chemin calculé ne désigne aucun module avant
// l'exécution. Une épreuve qui n'atteint un module que par un chemin calculé
// ne se joue donc pas contre ses mutants.
import { readdirSync, readFileSync } from 'node:fs';
import { join, posix, relative, sep } from 'node:path';
import { parseAst } from 'vite';
// « ./ » ou « ../ » en tête, ou « . » et « .. » seuls.
const RELATIF = /^\.{1,2}(?:\/|$)/;
// Une épreuve, quel que soit son niveau : .test.js, .long.test.js,
// .navigateur.test.js.
const estEpreuve = (fichier) => fichier.endsWith('.test.js');
// Les spécificateurs écrits en clair d'un nœud, s'il en charge un.
function specificateurDe(noeud) {
switch (noeud.type) {
case 'ImportDeclaration':
case 'ExportAllDeclaration':
case 'ExportNamedDeclaration':
case 'ImportExpression': {
const source = noeud.source;
return source && source.type === 'Literal' && typeof source.value === 'string' ? source.value : null;
}
default:
return null;
}
}
// Visite chaque nœud de l'arbre, en profondeur.
function parcourir(noeud, visiter) {
visiter(noeud);
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, visiter);
}
}
}
/**
* Les modules que le source charge par un chemin relatif, résolus depuis la
* racine, sans le suffixe de requête (« ?raw »), triés et sans doublon.
* Lève sur un source que parseAst refuse.
* @param {string} source
* @param {string} fichier chemin du module, relatif à la racine
* @returns {string[]}
*/
export function importsDe(source, fichier) {
const dossier = posix.dirname(fichier);
const vus = new Set();
parcourir(parseAst(source), (noeud) => {
const specificateur = specificateurDe(noeud);
if (specificateur !== null && RELATIF.test(specificateur)) {
vus.add(posix.normalize(posix.join(dossier, specificateur.replace(/\?.*$/su, ''))));
}
});
return [...vus].sort();
}
/**
* Le graphe des fichiers donnés : chacun, dans l'ordre trié, vers ses imports.
* Un import hors de la liste reste une feuille.
* @param {string[]} fichiers chemins relatifs à la racine
* @param {(fichier: string) => string} lire le texte d'un fichier
* @returns {Map<string, string[]>}
*/
export function construireGraphe(fichiers, lire) {
return new Map([...fichiers].sort().map((fichier) => [fichier, importsDe(lire(fichier), fichier)]));
}
/**
* Le graphe des modules .js des dossiers donnés, lus sous la racine.
* @param {string} racine chemin absolu de la racine du projet
* @param {string[]} dossiers relatifs à la racine
* @returns {Map<string, string[]>}
*/
export function grapheDuProjet(racine, dossiers) {
const fichiers = dossiers.flatMap((dossier) =>
readdirSync(join(racine, dossier), { recursive: true, withFileTypes: true })
.filter((entree) => entree.isFile() && entree.name.endsWith('.js'))
.map((entree) => relative(racine, join(entree.parentPath, entree.name)).split(sep).join('/')),
);
return construireGraphe(fichiers, (fichier) => readFileSync(join(racine, fichier), 'utf8'));
}
/**
* Les épreuves du graphe (*.test.js) dont les imports, de proche en proche,
* atteignent le module muté ; triées. Un cycle se parcourt une fois.
* @param {string} fichierMute chemin relatif à la racine
* @param {Map<string, string[]>} graphe
* @returns {string[]}
*/
export function testsConcernes(fichierMute, graphe) {
const atteint = (depart) => {
const vus = new Set([depart]);
const pile = [depart];
while (pile.length > 0) {
for (const suivant of graphe.get(pile.pop()) ?? []) {
if (suivant === fichierMute) return true;
if (!vus.has(suivant)) {
vus.add(suivant);
pile.push(suivant);
}
}
}
return false;
};
return [...graphe.keys()].filter((fichier) => estEpreuve(fichier) && atteint(fichier)).sort();
}