Spec 14.13 asks for an audit that shows which engine mutants the tests kill, written where a reviewer can read it. npm run mutation and make mutation run the baseline, a coverage pass that stops early when nothing is measured, then every mutant; survivors are replayed against the long series. The report holds version, scope, source fingerprint and Node version only, no duration, host or date, and refuses any other field. Declared equivalent mutants live in a separate list of key and reason. Checked: red first; 3612 node tests green from the index alone. --- FR --- [ADD] mutation : commande d'audit du moteur, rapport écrit et versionné Le § 14.13 demande un audit qui montre quels mutants du moteur les épreuves tuent, écrit là où un relecteur le lit. npm run mutation et make mutation jouent la ligne de base, une passe de couverture qui s'arrête tôt si rien n'est mesuré, puis chaque mutant ; les survivants se rejouent contre la série longue. Le rapport ne porte que version, périmètre, empreinte du source et version de Node, ni durée, ni hôte, ni date, et refuse tout autre champ. Les mutants déclarés équivalents vivent dans une liste à part. Vérifié : rouge d'abord ; 3612 node verts depuis l'index seul. Assisted-by: Claude Opus 5.5
271 lines
13 KiB
JavaScript
271 lines
13 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Le rapport de l'audit par mutation (§ 14.13) : rediger rend le Markdown et
|
||
// le JSON d'un audit, sur des résultats inventés. Les modules nommés ici
|
||
// n'existent pas : rediger ne lit aucun fichier.
|
||
import assert from 'node:assert/strict';
|
||
import { VERSION } from '../../src/version.genere.js';
|
||
import { describe, test } from '../../test/lanceur.js';
|
||
import { rediger } from './rapport.js';
|
||
|
||
const ENTETE = Object.freeze({
|
||
version: VERSION.affichee,
|
||
perimetre: ['src/verger'],
|
||
empreinte: 'a'.repeat(64),
|
||
node: 'v26.1.0',
|
||
});
|
||
|
||
// Un mutant inventé, aux champs du contrat de mutants.js.
|
||
function mutant(fichier, ligne, operateur, original, remplacement, colonne = 5) {
|
||
return {
|
||
cle: `${fichier}:${operateur}:${ligne}${colonne}:0`,
|
||
fichier,
|
||
ligne,
|
||
colonne,
|
||
operateur,
|
||
original,
|
||
remplacement,
|
||
debut: ligne * 100 + colonne,
|
||
fin: ligne * 100 + colonne + original.length,
|
||
};
|
||
}
|
||
|
||
const resultat = (m, issue, { couverte = true, longue = null, equivalent = null } = {}) => ({
|
||
mutant: m,
|
||
issue,
|
||
couverte,
|
||
longue,
|
||
equivalent,
|
||
});
|
||
|
||
// Neuf résultats sur deux modules, dans le désordre :
|
||
// pommes.js arithmetique tué ; frontiere survivant couvert (l. 30) ;
|
||
// negation survivant non couvert (l. 4) ; nombre-zero délai ;
|
||
// si-vrai survivant équivalent
|
||
// poires.js arithmetique survivant couvert (l. 12), tué par la série
|
||
// longue ; frontiere tué ; logique erreur ; frontiere survivant
|
||
// couvert (l. 12, colonne 2)
|
||
const RESULTATS = Object.freeze([
|
||
resultat(mutant('src/verger/pommes.js', 3, 'arithmetique', '+', '-'), 'tue'),
|
||
resultat(mutant('src/verger/poires.js', 12, 'arithmetique', '*', '/'), 'survivant', { longue: 'tue' }),
|
||
resultat(mutant('src/verger/pommes.js', 30, 'frontiere', '<', '<='), 'survivant', { longue: 'survivant' }),
|
||
resultat(mutant('src/verger/pommes.js', 4, 'negation', '===', '!=='), 'survivant', { couverte: false }),
|
||
resultat(mutant('src/verger/poires.js', 7, 'frontiere', '>', '>='), 'tue'),
|
||
resultat(mutant('src/verger/pommes.js', 9, 'nombre-zero', '1', '0'), 'delai'),
|
||
resultat(mutant('src/verger/poires.js', 20, 'logique', 'a && b', '((a )||( b))'), 'erreur'),
|
||
resultat(mutant('src/verger/pommes.js', 15, 'si-vrai', 'n > 0', 'true'), 'survivant', {
|
||
equivalent: 'le test est toujours vrai à cet endroit',
|
||
}),
|
||
resultat(mutant('src/verger/poires.js', 12, 'frontiere', '<=', '<', 2), 'survivant'),
|
||
]);
|
||
|
||
const lieux = (liste) => liste.map(({ fichier, ligne, colonne }) => `${fichier}:${ligne}:${colonne}`);
|
||
|
||
describe('rapport de mutation : les comptes', () => {
|
||
const { json } = rediger({ entete: ENTETE, resultats: RESULTATS });
|
||
const lu = JSON.parse(json);
|
||
|
||
test('le bilan compte chaque issue, un survivant équivalent à part', () => {
|
||
assert.deepEqual(lu.bilan, { total: 9, tues: 2, survivants: 4, equivalents: 1, delais: 1, erreurs: 1 });
|
||
});
|
||
|
||
test('par fichier, dans l’ordre des chemins', () => {
|
||
assert.deepEqual(lu.parFichier, [
|
||
{ fichier: 'src/verger/poires.js', total: 4, tues: 1, survivants: 2, equivalents: 0, delais: 0, erreurs: 1 },
|
||
{ fichier: 'src/verger/pommes.js', total: 5, tues: 1, survivants: 2, equivalents: 1, delais: 1, erreurs: 0 },
|
||
]);
|
||
});
|
||
|
||
test('par opérateur, dans l’ordre d’OPERATEURS', () => {
|
||
assert.deepEqual(lu.parOperateur, [
|
||
{ operateur: 'arithmetique', total: 2, tues: 1, survivants: 1, equivalents: 0, delais: 0, erreurs: 0 },
|
||
{ operateur: 'frontiere', total: 3, tues: 1, survivants: 2, equivalents: 0, delais: 0, erreurs: 0 },
|
||
{ operateur: 'negation', total: 1, tues: 0, survivants: 1, equivalents: 0, delais: 0, erreurs: 0 },
|
||
{ operateur: 'logique', total: 1, tues: 0, survivants: 0, equivalents: 0, delais: 0, erreurs: 1 },
|
||
{ operateur: 'nombre-zero', total: 1, tues: 0, survivants: 0, equivalents: 0, delais: 1, erreurs: 0 },
|
||
{ operateur: 'si-vrai', total: 1, tues: 0, survivants: 0, equivalents: 1, delais: 0, erreurs: 0 },
|
||
]);
|
||
});
|
||
|
||
test('le Markdown porte les mêmes comptes, en lignes de tableau', () => {
|
||
const { markdown } = rediger({ entete: ENTETE, resultats: RESULTATS });
|
||
const lignes = markdown.split('\n');
|
||
assert.ok(lignes.includes('| 9 | 2 | 4 | 1 | 1 | 1 |'), 'ligne du bilan');
|
||
assert.ok(lignes.includes('| `src/verger/poires.js` | 4 | 1 | 2 | 0 | 0 | 1 |'), 'ligne de poires.js');
|
||
assert.ok(lignes.includes('| `src/verger/pommes.js` | 5 | 1 | 2 | 1 | 1 | 0 |'), 'ligne de pommes.js');
|
||
assert.ok(lignes.includes('| frontiere | 3 | 1 | 2 | 0 | 0 | 0 |'), 'ligne de frontiere');
|
||
});
|
||
|
||
test('l’en-tête se recopie tel quel quand il ne nomme pas la machine', () => {
|
||
assert.deepEqual(lu.entete, { ...ENTETE });
|
||
const { markdown } = rediger({ entete: ENTETE, resultats: RESULTATS });
|
||
for (const valeur of [ENTETE.version, ENTETE.empreinte, ENTETE.node, 'src/verger']) {
|
||
assert.ok(markdown.includes(valeur), valeur);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('rapport de mutation : l’ordre', () => {
|
||
const { json, markdown } = rediger({ entete: ENTETE, resultats: RESULTATS });
|
||
const lu = JSON.parse(json);
|
||
|
||
test('les survivants couverts d’abord, puis par fichier, ligne et colonne', () => {
|
||
assert.deepEqual(lieux(lu.survivants), [
|
||
'src/verger/poires.js:12:2',
|
||
'src/verger/poires.js:12:5',
|
||
'src/verger/pommes.js:30:5',
|
||
'src/verger/pommes.js:4:5',
|
||
]);
|
||
assert.deepEqual(
|
||
lu.survivants.map(({ couverte, longue }) => [couverte, longue]),
|
||
[
|
||
[true, null],
|
||
[true, 'tue'],
|
||
[true, 'survivant'],
|
||
[false, null],
|
||
],
|
||
);
|
||
});
|
||
|
||
test('dans un même fichier, les lignes en ordre numérique : 9 avant 12', () => {
|
||
// L'opérateur de la ligne 12 précède celui de la ligne 9 dans OPERATEURS,
|
||
// et « 12 » précède « 9 » en ordre lexical : seul l'ordre numérique des
|
||
// lignes met la ligne 9 en tête.
|
||
const paire = [
|
||
resultat(mutant('src/verger/pommes.js', 12, 'arithmetique', '+', '-'), 'survivant'),
|
||
resultat(mutant('src/verger/pommes.js', 9, 'frontiere', '<', '<='), 'survivant'),
|
||
];
|
||
for (const resultats of [paire, [...paire].reverse()]) {
|
||
const { survivants } = JSON.parse(rediger({ entete: ENTETE, resultats }).json);
|
||
assert.deepEqual(lieux(survivants), ['src/verger/pommes.js:9:5', 'src/verger/pommes.js:12:5']);
|
||
}
|
||
});
|
||
|
||
test('le Markdown range les survivants dans le même ordre, l’équivalent dans sa section', () => {
|
||
const rangs = lieux(lu.survivants).map((lieu) => markdown.indexOf(`\`${lieu}\``));
|
||
assert.ok(rangs.every((rang) => rang > 0), 'chaque survivant est nommé');
|
||
assert.deepEqual([...rangs].sort((a, b) => a - b), rangs);
|
||
const section = markdown.indexOf('## Mutants équivalents');
|
||
assert.ok(section > rangs.at(-1), 'la section des équivalents suit les survivants');
|
||
assert.ok(markdown.indexOf('`src/verger/pommes.js:15:5`') > section);
|
||
assert.ok(markdown.includes('le test est toujours vrai à cet endroit'));
|
||
});
|
||
|
||
test('équivalents, délais et erreurs sont listés à part, hors des survivants', () => {
|
||
assert.deepEqual(lieux(lu.equivalents), ['src/verger/pommes.js:15:5']);
|
||
assert.equal(lu.equivalents[0].raison, 'le test est toujours vrai à cet endroit');
|
||
assert.deepEqual(
|
||
lu.delaisEtErreurs.map((r) => `${r.fichier}:${r.ligne} ${r.issue}`),
|
||
['src/verger/poires.js:20 erreur', 'src/verger/pommes.js:9 delai'],
|
||
);
|
||
});
|
||
|
||
test('l’ordre des résultats reçus ne change pas un octet de la sortie', () => {
|
||
const renverse = rediger({ entete: ENTETE, resultats: [...RESULTATS].reverse() });
|
||
assert.equal(renverse.markdown, markdown);
|
||
assert.equal(renverse.json, json);
|
||
});
|
||
});
|
||
|
||
describe('rapport de mutation : ce qu’il refuse', () => {
|
||
test('des résultats vides ou absents', () => {
|
||
assert.throws(() => rediger({ entete: ENTETE, resultats: [] }), /aucun résultat/);
|
||
assert.throws(() => rediger({ entete: ENTETE }), /aucun résultat/);
|
||
});
|
||
|
||
test('aucune date ni heure dans le texte', () => {
|
||
const { markdown, json } = rediger({ entete: ENTETE, resultats: RESULTATS });
|
||
for (const texte of [markdown, json]) {
|
||
assert.doesNotMatch(texte, /\d{4}-\d{2}-\d{2}/);
|
||
assert.doesNotMatch(texte, /\d{1,2}:\d{2}:\d{2}/);
|
||
assert.doesNotMatch(texte, /\b(?:date|instant|horodatage)\b/i);
|
||
}
|
||
});
|
||
|
||
test('un en-tête qui nomme la machine : un nom d’hôte, un chemin de compte', () => {
|
||
const refuse = (entete) =>
|
||
assert.throws(() => rediger({ entete, resultats: RESULTATS }), /ne nomme pas sa machine/, JSON.stringify(entete));
|
||
refuse({ ...ENTETE, hote: 'atelier-ombrelle' });
|
||
refuse({ ...ENTETE, poste: 'atelier-ombrelle.local' });
|
||
refuse({ ...ENTETE, node: 'atelier-ombrelle' });
|
||
refuse({ ...ENTETE, node: '/home/exemple/.local/node/bin/node' });
|
||
refuse({ ...ENTETE, perimetre: ['/home/exemple/verger/src/verger'] });
|
||
refuse({ ...ENTETE, perimetre: ['C:\\Users\\exemple\\verger'] });
|
||
refuse({ ...ENTETE, date: 'mardi' });
|
||
refuse({ ...ENTETE, node: 'v26.0.0-/home/exemple' });
|
||
});
|
||
|
||
test('une version de Node avec un suffixe de préversion, sans blanc ni barre oblique', () => {
|
||
for (const node of ['v26.0.0-rc.1', 'v27.0.0-pre']) {
|
||
assert.equal(JSON.parse(rediger({ entete: { ...ENTETE, node }, resultats: RESULTATS }).json).entete.node, node);
|
||
}
|
||
for (const node of ['v26.0.0-rc 1', 'v26.0.0-a/b', 'v26.0.0-', 'v26.0.0+rc']) {
|
||
assert.throws(() => rediger({ entete: { ...ENTETE, node }, resultats: RESULTATS }), /en-tête : node/, node);
|
||
}
|
||
});
|
||
|
||
test('une version que la dérivation de scripts/version.js refuse', () => {
|
||
const [annee, , , rang] = VERSION.affichee.split('.');
|
||
for (const version of [[annee, '13', '45', rang].join('.'), `${VERSION.affichee}2`]) {
|
||
assert.throws(() => rediger({ entete: { ...ENTETE, version }, resultats: RESULTATS }), /en-tête : version/, version);
|
||
}
|
||
});
|
||
|
||
test('un en-tête incomplet ou d’une autre forme', () => {
|
||
const refuse = (entete) => assert.throws(() => rediger({ entete, resultats: RESULTATS }), TypeError, JSON.stringify(entete));
|
||
const { empreinte, ...sansEmpreinte } = ENTETE;
|
||
assert.equal(empreinte.length, 64);
|
||
refuse(sansEmpreinte);
|
||
refuse({ ...ENTETE, empreinte: 'b'.repeat(63) });
|
||
refuse({ ...ENTETE, version: 'v1' });
|
||
refuse({ ...ENTETE, perimetre: [] });
|
||
refuse({ ...ENTETE, perimetre: ['src/../verger'] });
|
||
refuse(null);
|
||
});
|
||
|
||
test('un résultat d’une autre forme : issue, opérateur, chemin, équivalence, rejeu, clé', () => {
|
||
const [premier, second] = RESULTATS;
|
||
const refuse = (remplace, motif) =>
|
||
assert.throws(() => rediger({ entete: ENTETE, resultats: [remplace, second] }), motif, JSON.stringify(remplace));
|
||
refuse({ ...premier, issue: 'blesse' }, /issue/);
|
||
refuse({ ...premier, mutant: { ...premier.mutant, operateur: 'inversion' } }, /opérateur/);
|
||
refuse({ ...premier, mutant: { ...premier.mutant, fichier: '/home/exemple/verger/pommes.js' } }, /ne nomme pas sa machine/);
|
||
refuse({ ...premier, mutant: { ...premier.mutant, fichier: '../verger/pommes.js' } }, /chemin/);
|
||
refuse({ ...premier, mutant: { ...premier.mutant, ligne: 0 } }, /ligne/);
|
||
refuse({ ...premier, equivalent: 'tué pourtant' }, /équivalent/);
|
||
for (const equivalent of ['vu dans /home/exemple/notes.txt', 'vu dans C:\\Users\\exemple\\notes.txt']) {
|
||
assert.throws(
|
||
() => rediger({ entete: ENTETE, resultats: [{ ...RESULTATS[2], equivalent }, second] }),
|
||
/ne nomme pas sa machine : chemin de compte dans résultat 0, équivalent/,
|
||
equivalent,
|
||
);
|
||
}
|
||
refuse({ ...premier, longue: 'tue' }, /série longue/);
|
||
refuse({ ...premier, couverte: 'oui' }, /couverte/);
|
||
refuse({ ...premier, mutant: { ...premier.mutant, cle: second.mutant.cle } }, /clé/);
|
||
});
|
||
});
|
||
|
||
describe('rapport de mutation : les cellules de code', () => {
|
||
test('une barre verticale s’échappe, un nœud sur plusieurs lignes tient sur une', () => {
|
||
const resultats = [
|
||
resultat(mutant('src/verger/pommes.js', 2, 'logique', 'a ||\n b', '((a )&&(\n b))'), 'survivant'),
|
||
resultat(mutant('src/verger/pommes.js', 3, 'corps-si', `{ ${'x += 1; '.repeat(20)}}`, '{}'), 'survivant'),
|
||
];
|
||
const { markdown, json } = rediger({ entete: ENTETE, resultats });
|
||
const ligne = markdown.split('\n').find((l) => l.includes('`src/verger/pommes.js:2:5`'));
|
||
assert.ok(ligne.includes('`a \\|\\| b`'), ligne);
|
||
assert.ok(ligne.includes('`((a )&&( b))`'), ligne);
|
||
const longue = markdown.split('\n').find((l) => l.includes('`src/verger/pommes.js:3:5`'));
|
||
assert.ok(longue.includes('…`'), longue);
|
||
assert.ok(longue.length < 200, longue);
|
||
assert.equal(JSON.parse(json).survivants[0].original, 'a ||\n b');
|
||
});
|
||
|
||
test('un accent grave dans le code garde la cellule entière', () => {
|
||
const resultats = [resultat(mutant('src/verger/pommes.js', 2, 'logique', 'a && `x`', '((a )||( `x`))'), 'survivant')];
|
||
const { markdown } = rediger({ entete: ENTETE, resultats });
|
||
assert.ok(markdown.includes('`` a && `x` ``'), markdown);
|
||
});
|
||
});
|