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
278 lines
14 KiB
JavaScript
278 lines
14 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Gardes de l'audit par mutation (§ 14.13, § 14.4, § 14.14). L'audit est sa
|
||
// propre commande — make mutation, npm run mutation, scripts/mutation/audit.js —,
|
||
// hors des séries node et node-long : son coût est d'un autre ordre que leurs
|
||
// budgets. Son périmètre est le moteur seul. Ces gardes lisent audit.js, le
|
||
// Makefile et package.json comme du texte : aucun module n'importe audit.js,
|
||
// celui-ci compris, donc aucune épreuve des séries.
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { parseAst } from 'vite';
|
||
import { describe, test } from '../../test/lanceur.js';
|
||
import { construireGraphe, grapheDuProjet } from './graphe.js';
|
||
|
||
const RACINE = fileURLToPath(new URL('../../', import.meta.url));
|
||
const AUDIT = 'scripts/mutation/audit.js';
|
||
|
||
// Le périmètre que déclare le source d'audit.js : les chaînes de
|
||
// « export const PERIMETRE = [...] » ou « Object.freeze([...]) ». null quand
|
||
// il ne le déclare pas ; lève quand la déclaration n'est pas une liste de
|
||
// chaînes littérales, qu'aucune garde ne saurait lire.
|
||
function perimetreDeclare(source) {
|
||
for (const noeud of parseAst(source).body) {
|
||
if (noeud.type !== 'ExportNamedDeclaration' || noeud.declaration?.type !== 'VariableDeclaration') continue;
|
||
for (const { id, init } of noeud.declaration.declarations) {
|
||
if (id.type !== 'Identifier' || id.name !== 'PERIMETRE') continue;
|
||
const gele =
|
||
init?.type === 'CallExpression' &&
|
||
init.callee.type === 'MemberExpression' &&
|
||
init.callee.object.name === 'Object' &&
|
||
init.callee.property.name === 'freeze';
|
||
const liste = gele ? init.arguments[0] : init;
|
||
if (liste?.type !== 'ArrayExpression') throw new TypeError('PERIMETRE : liste littérale attendue');
|
||
return liste.elements.map((element) => {
|
||
if (element?.type !== 'Literal' || typeof element.value !== 'string') {
|
||
throw new TypeError('PERIMETRE : chaînes littérales attendues');
|
||
}
|
||
return element.value;
|
||
});
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Les fonctions appelées par leur nom dans le corps de la fonction déclarée
|
||
// « nom », fonctions imbriquées comprises, dans l'ordre du texte ; null quand
|
||
// le source ne déclare pas cette fonction au niveau du module.
|
||
function appelsDans(source, nom) {
|
||
const declaration = parseAst(source).body
|
||
.map((noeud) => (noeud.type === 'ExportNamedDeclaration' ? noeud.declaration : noeud))
|
||
.find((noeud) => noeud?.type === 'FunctionDeclaration' && noeud.id?.name === nom);
|
||
if (declaration === undefined) return null;
|
||
const appels = [];
|
||
const parcourir = (noeud) => {
|
||
if (Array.isArray(noeud)) {
|
||
noeud.forEach(parcourir);
|
||
return;
|
||
}
|
||
if (typeof noeud !== 'object' || noeud === null) return;
|
||
if (noeud.type === 'CallExpression' && noeud.callee.type === 'Identifier') appels.push([noeud.start, noeud.callee.name]);
|
||
for (const cle of Object.keys(noeud).sort()) {
|
||
if (cle !== 'type' && cle !== 'start' && cle !== 'end') parcourir(noeud[cle]);
|
||
}
|
||
};
|
||
parcourir(declaration.body);
|
||
return appels.sort((a, b) => a[0] - b[0]).map(([, appele]) => appele);
|
||
}
|
||
|
||
// Les écarts de l'ordre des appels d'auditer : l'en-tête validé avant la
|
||
// ligne de base, l'empreinte prise avant elle et reprise après le dernier
|
||
// mutant, avant la première écriture. Une liste vide quand l'ordre tient.
|
||
function ecartsDeLOrdre(appels) {
|
||
const premier = (nom) => appels.indexOf(nom);
|
||
const dernier = (nom) => appels.lastIndexOf(nom);
|
||
const base = premier('ligneDeBase');
|
||
const ecarts = [];
|
||
if (premier('verifierEntete') === -1 || premier('verifierEntete') > base) ecarts.push('en-tête validé après la ligne de base');
|
||
if (premier('empreinteDe') === -1 || premier('empreinteDe') > base) ecarts.push('empreinte prise après la ligne de base');
|
||
if (premier('empreinteDe') === dernier('empreinteDe') || dernier('empreinteDe') < dernier('executerMutant') || dernier('empreinteDe') > premier('writeFileSync')) {
|
||
ecarts.push('empreinte non reprise entre le dernier mutant et l’écriture');
|
||
}
|
||
return ecarts;
|
||
}
|
||
|
||
// La cible nommée d'un Makefile : sa ligne de documentation (« ## … »), ses
|
||
// commandes (les lignes de recette qui la suivent, tabulation retirée) et sa
|
||
// déclaration .PHONY ; null quand le Makefile ne la porte pas.
|
||
function cibleDuMakefile(texte, nom) {
|
||
const lignes = texte.split('\n');
|
||
const rang = lignes.findIndex((ligne) => ligne.startsWith(`${nom}:`));
|
||
if (rang === -1) return null;
|
||
const documentation = /##\s*(.*)$/.exec(lignes[rang])?.[1] ?? null;
|
||
const commandes = [];
|
||
for (let i = rang + 1; i < lignes.length && lignes[i].startsWith('\t'); i += 1) commandes.push(lignes[i].slice(1));
|
||
const factice = lignes.some((ligne) => /^\.PHONY:/.test(ligne) && ligne.slice('.PHONY:'.length).trim().split(/\s+/).includes(nom));
|
||
return { documentation, commandes, factice };
|
||
}
|
||
|
||
// Les recettes de toutes les cibles d'un Makefile, { cible, commande }, dans
|
||
// l'ordre du texte.
|
||
function recettes(texte) {
|
||
const sortie = [];
|
||
let cible = null;
|
||
for (const ligne of texte.split('\n')) {
|
||
const tete = /^([A-Za-z0-9_.-]+):/.exec(ligne);
|
||
if (tete !== null) cible = tete[1];
|
||
else if (ligne.startsWith('\t') && cible !== null) sortie.push({ cible, commande: ligne.slice(1) });
|
||
}
|
||
return sortie;
|
||
}
|
||
|
||
// Écarts entre la couverture et l'audit : la mesure de la couverture garde
|
||
// une seule commande, npm run couverture lancé par make couverture, et la
|
||
// passe de couverture de l'audit, interne à audit.js, n'en devient pas une
|
||
// seconde. Relevés dans l'ordre : chaque script de package.json autre que
|
||
// couverture qui mesure (--coverage, --experimental-test-coverage), le script
|
||
// mutation qui ne lance pas audit.js seul ; chaque recette du Makefile hors de
|
||
// la cible couverture qui lance la mesure ou une passe de couverture.
|
||
function ecartsDeLaCouverture({ scripts, makefile }) {
|
||
const ecarts = [];
|
||
const MESURE = /--(?:experimental-test-)?coverage\b/;
|
||
for (const nom of Object.keys(scripts).sort()) {
|
||
if (nom !== 'couverture' && MESURE.test(scripts[nom])) ecarts.push(`npm run ${nom} : mesure la couverture`);
|
||
}
|
||
if (scripts.mutation !== 'node scripts/mutation/audit.js') ecarts.push('npm run mutation : ne lance pas audit.js seul');
|
||
for (const { cible, commande } of recettes(makefile)) {
|
||
if (cible !== 'couverture' && (MESURE.test(commande) || /npm run couverture\b/.test(commande))) {
|
||
ecarts.push(`make ${cible} : lance une mesure de couverture`);
|
||
}
|
||
}
|
||
return ecarts;
|
||
}
|
||
|
||
// Les modules du graphe qui importent la cible, triés. audit.js est un point
|
||
// d'entrée en ligne de commande : qu'aucun module ne l'importe garantit
|
||
// qu'aucune épreuve, de quelque série qu'elle soit, ne l'atteint par un
|
||
// module intermédiaire.
|
||
const importateursDe = (cible, graphe) =>
|
||
[...graphe.keys()].filter((fichier) => graphe.get(fichier).includes(cible)).sort();
|
||
|
||
describe('audit par mutation : sa commande (§ 14.13)', () => {
|
||
const { scripts = {} } = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8'));
|
||
const makefile = readFileSync(join(RACINE, 'Makefile'), 'utf8');
|
||
|
||
test('npm run mutation lance scripts/mutation/audit.js, et aucun autre script ne touche à l’audit', () => {
|
||
assert.equal(scripts.mutation, 'node scripts/mutation/audit.js');
|
||
const autres = Object.keys(scripts)
|
||
.sort()
|
||
.filter((nom) => nom !== 'mutation' && /mutation|audit\.js/.test(scripts[nom]));
|
||
assert.deepEqual(autres, []);
|
||
});
|
||
|
||
test('make mutation lance npm run mutation, cible factice documentée', () => {
|
||
assert.deepEqual(cibleDuMakefile(makefile, 'mutation'), {
|
||
documentation: 'Audit par mutation du moteur, avant une livraison (§ 14.13)',
|
||
commandes: ['$(AVEC_NODE) npm run mutation'],
|
||
factice: true,
|
||
});
|
||
});
|
||
|
||
test('la lecture du Makefile relève une cible absente, une recette autre, une cible non factice', () => {
|
||
assert.equal(cibleDuMakefile('.PHONY: banc\nbanc:\n\t$(AVEC_NODE) npm run banc\n', 'mutation'), null);
|
||
assert.deepEqual(cibleDuMakefile('.PHONY: test\nmutation: ## Audit\n\tnode scripts/mutation/audit.js\n\nx:\n\techo\n', 'mutation'), {
|
||
documentation: 'Audit',
|
||
commandes: ['node scripts/mutation/audit.js'],
|
||
factice: false,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('audit par mutation : son périmètre, le moteur seul', () => {
|
||
test('audit.js déclare exactement [src/moteur]', () => {
|
||
assert.deepEqual(perimetreDeclare(readFileSync(join(RACINE, AUDIT), 'utf8')), ['src/moteur']);
|
||
});
|
||
|
||
test('la lecture du périmètre rend une liste élargie telle quelle, null sans déclaration, et refuse un calcul', () => {
|
||
assert.deepEqual(perimetreDeclare("export const PERIMETRE = Object.freeze(['src/moteur', 'src/application']);\n"), [
|
||
'src/moteur',
|
||
'src/application',
|
||
]);
|
||
assert.deepEqual(perimetreDeclare("export const PERIMETRE = ['src/verger'];\n"), ['src/verger']);
|
||
assert.equal(perimetreDeclare("const PERIMETRE = ['src/moteur'];\nexport const AUTRE = 1;\n"), null);
|
||
assert.throws(() => perimetreDeclare("const d = 'src';\nexport const PERIMETRE = [`${d}/moteur`];\n"), /chaînes littérales/);
|
||
assert.throws(() => perimetreDeclare('export const PERIMETRE = lister();\n'), /liste littérale/);
|
||
});
|
||
});
|
||
|
||
describe('audit par mutation : ce qu’il mesure est ce qu’il écrit', () => {
|
||
test('auditer valide l’en-tête avant la ligne de base, et reprend l’empreinte des sources avant d’écrire', () => {
|
||
const appels = appelsDans(readFileSync(join(RACINE, AUDIT), 'utf8'), 'auditer');
|
||
assert.ok(appels.includes('ligneDeBase') && appels.includes('writeFileSync'), appels.join(', '));
|
||
assert.deepEqual(ecartsDeLOrdre(appels), []);
|
||
});
|
||
|
||
test('la lecture des appels suit le texte, fonctions imbriquées comprises ; la garde relève chaque écart', () => {
|
||
const source = 'export async function auditer() { a(); await b(c()); const f = () => d(); }\nfunction autre() { e(); }\n';
|
||
assert.deepEqual(appelsDans(source, 'auditer'), ['a', 'b', 'c', 'd']);
|
||
assert.equal(appelsDans(source, 'absente'), null);
|
||
assert.deepEqual(ecartsDeLOrdre(['ligneDeBase', 'executerMutant', 'empreinteDe', 'verifierEntete', 'writeFileSync']), [
|
||
'en-tête validé après la ligne de base',
|
||
'empreinte prise après la ligne de base',
|
||
'empreinte non reprise entre le dernier mutant et l’écriture',
|
||
]);
|
||
assert.deepEqual(
|
||
ecartsDeLOrdre(['empreinteDe', 'verifierEntete', 'ligneDeBase', 'executerMutant', 'writeFileSync', 'empreinteDe']),
|
||
['empreinte non reprise entre le dernier mutant et l’écriture'],
|
||
);
|
||
assert.deepEqual(
|
||
ecartsDeLOrdre(['empreinteDe', 'verifierEntete', 'ligneDeBase', 'executerMutant', 'empreinteDe', 'writeFileSync']),
|
||
[],
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('audit par mutation : hors des séries (§ 14.4, § 14.14)', () => {
|
||
test('aucun module de src/, scripts/, test/ ni electron/ n’importe audit.js : aucune épreuve des séries node et node-long ne l’atteint', () => {
|
||
const graphe = grapheDuProjet(RACINE, ['src', 'scripts', 'test', 'electron']);
|
||
assert.ok(graphe.has(AUDIT), 'audit.js est dans le graphe');
|
||
assert.ok(graphe.get('scripts/mutation/audit.test.js').includes('scripts/mutation/graphe.js'), 'le graphe lit les imports');
|
||
assert.ok(graphe.size > 200, `${graphe.size} modules lus`);
|
||
assert.deepEqual(importateursDe(AUDIT, graphe), []);
|
||
});
|
||
|
||
test('la garde relève un module qui l’importe, statiquement, par une ré-exportation ou par import()', () => {
|
||
const textes = new Map([
|
||
['scripts/mutation/audit.js', "import { rediger } from './rapport.js';\n"],
|
||
['scripts/mutation/rapport.js', ''],
|
||
['scripts/mutation/rapport.test.js', "import { rediger } from './rapport.js';\n"],
|
||
['scripts/mutation/aide.js', "export { PERIMETRE } from './audit.js';\n"],
|
||
['src/verger/pommes.test.js', "import { PERIMETRE } from '../../scripts/mutation/aide.js';\n"],
|
||
['src/verger/poires.long.test.js', "const audit = await import('../../scripts/mutation/audit.js');\n"],
|
||
['src/verger/vue.navigateur.test.js', "import '../../scripts/mutation/audit.js';\n"],
|
||
]);
|
||
const graphe = construireGraphe([...textes.keys()], (chemin) => textes.get(chemin));
|
||
assert.deepEqual(importateursDe(AUDIT, graphe), [
|
||
'scripts/mutation/aide.js',
|
||
'src/verger/poires.long.test.js',
|
||
'src/verger/vue.navigateur.test.js',
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('audit par mutation : la couverture garde sa commande (§ 14.13)', () => {
|
||
test('la mesure reste npm run couverture, lancée par make couverture seule ; l’audit n’en est pas une seconde', () => {
|
||
const { scripts = {} } = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8'));
|
||
const makefile = readFileSync(join(RACINE, 'Makefile'), 'utf8');
|
||
assert.match(scripts.couverture, /--coverage\b/);
|
||
assert.deepEqual(cibleDuMakefile(makefile, 'couverture')?.commandes, ['$(AVEC_NODE) npm run couverture']);
|
||
assert.deepEqual(ecartsDeLaCouverture({ scripts, makefile }), []);
|
||
});
|
||
|
||
test('la garde relève une passe de couverture sortie d’audit.js, et un audit qui lance autre chose', () => {
|
||
const scripts = {
|
||
couverture: 'vitest run --project node --coverage',
|
||
mutation: 'node --experimental-test-coverage scripts/mutation/audit.js',
|
||
test: 'vitest run --project node --coverage',
|
||
};
|
||
const makefile = [
|
||
'.PHONY: couverture mutation',
|
||
'couverture: ## Mesure',
|
||
'\t$(AVEC_NODE) npm run couverture',
|
||
'mutation: ## Audit',
|
||
'\t$(AVEC_NODE) npm run couverture',
|
||
'\tnode --test --experimental-test-coverage src/moteur',
|
||
'',
|
||
].join('\n');
|
||
assert.deepEqual(ecartsDeLaCouverture({ scripts, makefile }), [
|
||
'npm run mutation : mesure la couverture',
|
||
'npm run test : mesure la couverture',
|
||
'npm run mutation : ne lance pas audit.js seul',
|
||
'make mutation : lance une mesure de couverture',
|
||
'make mutation : lance une mesure de couverture',
|
||
]);
|
||
});
|
||
});
|