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
118 lines
4.5 KiB
JavaScript
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();
|
|
}
|