gestion_table_tournante_libre/scripts/mutation/audit.test.js

279 lines
14 KiB
JavaScript
Raw Normal View History

// © 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',
]);
});
});