From aaa2c1a3a76922bbea8bbdc2e72aaa6b8cdbe644 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 10 Oct 2026 18:51:43 -0400 Subject: [PATCH] [IMP] mutation: count long-series kills apart, test the audit end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutant the node series missed but the long series killed was still counted as a survivor, so the committed audit overstated what escapes the tests. It now has its own column: 15 of the 137 survivors move there, leaving 122; the committed files are regenerated from their own results, without a rerun. The audit itself now runs in the long series on a small fixture tree: long-series replay, exit code 0 with survivors, exit 2 and nothing written when the baseline fails. The committed audit files are read back and must match what rediger writes. Checked: 3868 node and 105 long tests from the index alone. --- FR --- [IMP] mutation : tués par la série longue à part, audit éprouvé entier Un mutant que la série node manquait et que la série longue tuait comptait encore comme survivant : l'audit commis surestimait ce qui échappe aux épreuves. Il a sa colonne : 15 des 137 survivants y passent, il en reste 122 ; les fichiers commis sont réengendrés de leurs propres résultats, sans rejeu. L'audit tourne entier dans la série longue sur un petit arbre : rejeu long, sortie 0 avec survivants, sortie 2 et rien d'écrit quand la ligne de base tombe. Les fichiers commis sont relus et doivent égaler ce que rediger écrit. Vérifié : 3868 node et 105 longues depuis l'index seul. Assisted-by: Claude Opus 5.5 --- audit/mutation-moteur.json | 371 ++++++++++-------- audit/mutation-moteur.md | 102 ++--- scripts/mutation/audit.js | 41 +- scripts/mutation/rapport.js | 319 ++++++++++----- scripts/mutation/rapport.test.js | 210 ++++++++-- .../fixtures/mutation/arbre/bornes.epreuve.js | 21 + test/fixtures/mutation/arbre/bornes.js | 20 + test/fixtures/mutation/arbre/bornes.longue.js | 17 + test/mutation.long.test.js | 157 +++++++- 9 files changed, 886 insertions(+), 372 deletions(-) create mode 100644 test/fixtures/mutation/arbre/bornes.epreuve.js create mode 100644 test/fixtures/mutation/arbre/bornes.js create mode 100644 test/fixtures/mutation/arbre/bornes.longue.js diff --git a/audit/mutation-moteur.json b/audit/mutation-moteur.json index 5438d15..ad91004 100644 --- a/audit/mutation-moteur.json +++ b/audit/mutation-moteur.json @@ -11,7 +11,8 @@ "bilan": { "total": 2576, "tues": 2171, - "survivants": 137, + "tuesSerieLongue": 15, + "survivants": 122, "equivalents": 61, "delais": 207, "erreurs": 0 @@ -21,6 +22,7 @@ "fichier": "src/moteur/classement.js", "total": 45, "tues": 38, + "tuesSerieLongue": 0, "survivants": 0, "equivalents": 5, "delais": 2, @@ -30,6 +32,7 @@ "fichier": "src/moteur/configuration.js", "total": 407, "tues": 365, + "tuesSerieLongue": 0, "survivants": 11, "equivalents": 0, "delais": 31, @@ -39,6 +42,7 @@ "fichier": "src/moteur/conflits.js", "total": 24, "tues": 24, + "tuesSerieLongue": 0, "survivants": 0, "equivalents": 0, "delais": 0, @@ -48,6 +52,7 @@ "fichier": "src/moteur/diagnostic.js", "total": 383, "tues": 312, + "tuesSerieLongue": 0, "survivants": 45, "equivalents": 0, "delais": 26, @@ -57,6 +62,7 @@ "fichier": "src/moteur/indicateurs.js", "total": 325, "tues": 281, + "tuesSerieLongue": 0, "survivants": 0, "equivalents": 18, "delais": 26, @@ -66,6 +72,7 @@ "fichier": "src/moteur/manque.js", "total": 162, "tues": 148, + "tuesSerieLongue": 0, "survivants": 0, "equivalents": 9, "delais": 5, @@ -75,7 +82,8 @@ "fichier": "src/moteur/plafond.js", "total": 397, "tues": 305, - "survivants": 2, + "tuesSerieLongue": 2, + "survivants": 0, "equivalents": 29, "delais": 61, "erreurs": 0 @@ -84,7 +92,8 @@ "fichier": "src/moteur/recherche.js", "total": 648, "tues": 530, - "survivants": 69, + "tuesSerieLongue": 13, + "survivants": 56, "equivalents": 0, "delais": 49, "erreurs": 0 @@ -93,6 +102,7 @@ "fichier": "src/moteur/verification.js", "total": 185, "tues": 168, + "tuesSerieLongue": 0, "survivants": 10, "equivalents": 0, "delais": 7, @@ -104,6 +114,7 @@ "operateur": "arithmetique", "total": 287, "tues": 270, + "tuesSerieLongue": 0, "survivants": 8, "equivalents": 7, "delais": 2, @@ -113,7 +124,8 @@ "operateur": "frontiere", "total": 165, "tues": 113, - "survivants": 31, + "tuesSerieLongue": 2, + "survivants": 29, "equivalents": 20, "delais": 1, "erreurs": 0 @@ -122,6 +134,7 @@ "operateur": "negation", "total": 343, "tues": 314, + "tuesSerieLongue": 0, "survivants": 5, "equivalents": 0, "delais": 24, @@ -131,6 +144,7 @@ "operateur": "logique", "total": 77, "tues": 71, + "tuesSerieLongue": 0, "survivants": 3, "equivalents": 0, "delais": 3, @@ -140,6 +154,7 @@ "operateur": "retrait-non", "total": 36, "tues": 36, + "tuesSerieLongue": 0, "survivants": 0, "equivalents": 0, "delais": 0, @@ -149,7 +164,8 @@ "operateur": "mise-a-jour", "total": 158, "tues": 84, - "survivants": 2, + "tuesSerieLongue": 2, + "survivants": 0, "equivalents": 0, "delais": 72, "erreurs": 0 @@ -158,7 +174,8 @@ "operateur": "nombre-plus-un", "total": 540, "tues": 469, - "survivants": 47, + "tuesSerieLongue": 9, + "survivants": 38, "equivalents": 20, "delais": 4, "erreurs": 0 @@ -167,6 +184,7 @@ "operateur": "nombre-zero", "total": 291, "tues": 184, + "tuesSerieLongue": 0, "survivants": 6, "equivalents": 5, "delais": 96, @@ -176,6 +194,7 @@ "operateur": "booleen", "total": 22, "tues": 22, + "tuesSerieLongue": 0, "survivants": 0, "equivalents": 0, "delais": 0, @@ -185,7 +204,8 @@ "operateur": "si-vrai", "total": 186, "tues": 170, - "survivants": 9, + "tuesSerieLongue": 2, + "survivants": 7, "equivalents": 6, "delais": 1, "erreurs": 0 @@ -194,6 +214,7 @@ "operateur": "si-faux", "total": 186, "tues": 174, + "tuesSerieLongue": 0, "survivants": 9, "equivalents": 1, "delais": 2, @@ -203,6 +224,7 @@ "operateur": "ternaire-vrai", "total": 39, "tues": 35, + "tuesSerieLongue": 0, "survivants": 4, "equivalents": 0, "delais": 0, @@ -212,6 +234,7 @@ "operateur": "ternaire-faux", "total": 39, "tues": 37, + "tuesSerieLongue": 0, "survivants": 2, "equivalents": 0, "delais": 0, @@ -221,6 +244,7 @@ "operateur": "min-max", "total": 21, "tues": 19, + "tuesSerieLongue": 0, "survivants": 1, "equivalents": 1, "delais": 0, @@ -230,6 +254,7 @@ "operateur": "corps-si", "total": 186, "tues": 173, + "tuesSerieLongue": 0, "survivants": 10, "equivalents": 1, "delais": 2, @@ -853,28 +878,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/plafond.js:si-vrai:b5e28aed44dacc4e:0", - "fichier": "src/moteur/plafond.js", - "ligne": 268, - "colonne": 9, - "operateur": "si-vrai", - "original": "plafond === undefined", - "remplacement": "true", - "couverte": true, - "longue": "tue" - }, - { - "cle": "src/moteur/plafond.js:frontiere:91da8a7654bc6855:0", - "fichier": "src/moteur/plafond.js", - "ligne": 285, - "colonne": 16, - "operateur": "frontiere", - "original": ">", - "remplacement": ">=", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:nombre-plus-un:1bad6b8cf97131fc:0", "fichier": "src/moteur/recherche.js", @@ -1007,17 +1010,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:d850f94bdaabcc65:0", - "fichier": "src/moteur/recherche.js", - "ligne": 215, - "colonne": 18, - "operateur": "nombre-plus-un", - "original": "0", - "remplacement": "1", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:frontiere:6f32f41253f60a4b:0", "fichier": "src/moteur/recherche.js", @@ -1029,17 +1021,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:32ea99e99f98f65a:0", - "fichier": "src/moteur/recherche.js", - "ligne": 223, - "colonne": 23, - "operateur": "nombre-plus-un", - "original": "1", - "remplacement": "2", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:frontiere:6f32f41253f60a4b:1", "fichier": "src/moteur/recherche.js", @@ -1051,28 +1032,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:a216dbba9047bdaf:1", - "fichier": "src/moteur/recherche.js", - "ligne": 241, - "colonne": 31, - "operateur": "nombre-plus-un", - "original": "1", - "remplacement": "2", - "couverte": true, - "longue": "tue" - }, - { - "cle": "src/moteur/recherche.js:frontiere:8d0d1504ba708607:1", - "fichier": "src/moteur/recherche.js", - "ligne": 242, - "colonne": 23, - "operateur": "frontiere", - "original": "<", - "remplacement": "<=", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:frontiere:7d1bafe580984c35:0", "fichier": "src/moteur/recherche.js", @@ -1095,28 +1054,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:547b58a30bc0a997:2", - "fichier": "src/moteur/recherche.js", - "ligne": 253, - "colonne": 16, - "operateur": "nombre-plus-un", - "original": "0", - "remplacement": "1", - "couverte": true, - "longue": "tue" - }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:a216dbba9047bdaf:2", - "fichier": "src/moteur/recherche.js", - "ligne": 253, - "colonne": 31, - "operateur": "nombre-plus-un", - "original": "1", - "remplacement": "2", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:nombre-plus-un:8136471d1947f817:0", "fichier": "src/moteur/recherche.js", @@ -1139,17 +1076,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:mise-a-jour:74e1f61770d5c394:0", - "fichier": "src/moteur/recherche.js", - "ligne": 299, - "colonne": 20, - "operateur": "mise-a-jour", - "original": "+=", - "remplacement": "-=", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:frontiere:f004b1ffa3dbf9fa:0", "fichier": "src/moteur/recherche.js", @@ -1161,17 +1087,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:mise-a-jour:2a6f58021d2d2599:0", - "fichier": "src/moteur/recherche.js", - "ligne": 302, - "colonne": 31, - "operateur": "mise-a-jour", - "original": "+=", - "remplacement": "-=", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:frontiere:efe1e5c7656bf0ba:0", "fichier": "src/moteur/recherche.js", @@ -1227,17 +1142,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:si-vrai:c9e104a555a44bd6:0", - "fichier": "src/moteur/recherche.js", - "ligne": 353, - "colonne": 9, - "operateur": "si-vrai", - "original": "q !== FANTOME", - "remplacement": "true", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:ternaire-vrai:24193ec16d54cc7c:0", "fichier": "src/moteur/recherche.js", @@ -1392,39 +1296,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:bc30efa0e866ef2a:0", - "fichier": "src/moteur/recherche.js", - "ligne": 551, - "colonne": 50, - "operateur": "nombre-plus-un", - "original": "0", - "remplacement": "1", - "couverte": true, - "longue": "tue" - }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:bc30efa0e866ef2a:1", - "fichier": "src/moteur/recherche.js", - "ligne": 551, - "colonne": 62, - "operateur": "nombre-plus-un", - "original": "0", - "remplacement": "1", - "couverte": true, - "longue": "tue" - }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:9e9819057aefb747:0", - "fichier": "src/moteur/recherche.js", - "ligne": 551, - "colonne": 69, - "operateur": "nombre-plus-un", - "original": "0", - "remplacement": "1", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:nombre-plus-un:6bdb440888819c6a:0", "fichier": "src/moteur/recherche.js", @@ -1469,17 +1340,6 @@ "couverte": true, "longue": "survivant" }, - { - "cle": "src/moteur/recherche.js:nombre-plus-un:b3235e2c1dea830b:0", - "fichier": "src/moteur/recherche.js", - "ligne": 772, - "colonne": 34, - "operateur": "nombre-plus-un", - "original": "0", - "remplacement": "1", - "couverte": true, - "longue": "tue" - }, { "cle": "src/moteur/recherche.js:frontiere:6f32f41253f60a4b:4", "fichier": "src/moteur/recherche.js", @@ -1745,6 +1605,173 @@ "longue": "survivant" } ], + "tuesSerieLongue": [ + { + "cle": "src/moteur/plafond.js:si-vrai:b5e28aed44dacc4e:0", + "fichier": "src/moteur/plafond.js", + "ligne": 268, + "colonne": 9, + "operateur": "si-vrai", + "original": "plafond === undefined", + "remplacement": "true", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/plafond.js:frontiere:91da8a7654bc6855:0", + "fichier": "src/moteur/plafond.js", + "ligne": 285, + "colonne": 16, + "operateur": "frontiere", + "original": ">", + "remplacement": ">=", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:d850f94bdaabcc65:0", + "fichier": "src/moteur/recherche.js", + "ligne": 215, + "colonne": 18, + "operateur": "nombre-plus-un", + "original": "0", + "remplacement": "1", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:32ea99e99f98f65a:0", + "fichier": "src/moteur/recherche.js", + "ligne": 223, + "colonne": 23, + "operateur": "nombre-plus-un", + "original": "1", + "remplacement": "2", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:a216dbba9047bdaf:1", + "fichier": "src/moteur/recherche.js", + "ligne": 241, + "colonne": 31, + "operateur": "nombre-plus-un", + "original": "1", + "remplacement": "2", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:frontiere:8d0d1504ba708607:1", + "fichier": "src/moteur/recherche.js", + "ligne": 242, + "colonne": 23, + "operateur": "frontiere", + "original": "<", + "remplacement": "<=", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:547b58a30bc0a997:2", + "fichier": "src/moteur/recherche.js", + "ligne": 253, + "colonne": 16, + "operateur": "nombre-plus-un", + "original": "0", + "remplacement": "1", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:a216dbba9047bdaf:2", + "fichier": "src/moteur/recherche.js", + "ligne": 253, + "colonne": 31, + "operateur": "nombre-plus-un", + "original": "1", + "remplacement": "2", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:mise-a-jour:74e1f61770d5c394:0", + "fichier": "src/moteur/recherche.js", + "ligne": 299, + "colonne": 20, + "operateur": "mise-a-jour", + "original": "+=", + "remplacement": "-=", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:mise-a-jour:2a6f58021d2d2599:0", + "fichier": "src/moteur/recherche.js", + "ligne": 302, + "colonne": 31, + "operateur": "mise-a-jour", + "original": "+=", + "remplacement": "-=", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:si-vrai:c9e104a555a44bd6:0", + "fichier": "src/moteur/recherche.js", + "ligne": 353, + "colonne": 9, + "operateur": "si-vrai", + "original": "q !== FANTOME", + "remplacement": "true", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:bc30efa0e866ef2a:0", + "fichier": "src/moteur/recherche.js", + "ligne": 551, + "colonne": 50, + "operateur": "nombre-plus-un", + "original": "0", + "remplacement": "1", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:bc30efa0e866ef2a:1", + "fichier": "src/moteur/recherche.js", + "ligne": 551, + "colonne": 62, + "operateur": "nombre-plus-un", + "original": "0", + "remplacement": "1", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:9e9819057aefb747:0", + "fichier": "src/moteur/recherche.js", + "ligne": 551, + "colonne": 69, + "operateur": "nombre-plus-un", + "original": "0", + "remplacement": "1", + "couverte": true, + "longue": "tue" + }, + { + "cle": "src/moteur/recherche.js:nombre-plus-un:b3235e2c1dea830b:0", + "fichier": "src/moteur/recherche.js", + "ligne": 772, + "colonne": 34, + "operateur": "nombre-plus-un", + "original": "0", + "remplacement": "1", + "couverte": true, + "longue": "tue" + } + ], "equivalents": [ { "cle": "src/moteur/classement.js:frontiere:d2e0232a8a610afd:0", diff --git a/audit/mutation-moteur.md b/audit/mutation-moteur.md index 5da9d65..eabc4f0 100644 --- a/audit/mutation-moteur.md +++ b/audit/mutation-moteur.md @@ -10,49 +10,50 @@ | Node | v26.10.0 | Chaque mutant se joue contre les épreuves de la série node qui atteignent son module, sous `node --test`. +Un survivant se rejoue contre les épreuves longues qui atteignent son module avant d’être compté : tué par elles, il se compte à part. Un survivant sur une ligne couverte désigne une épreuve qui exécute sans affirmer. ## Bilan -| mutants | tués | survivants | équivalents | délais | erreurs | -|---:|---:|---:|---:|---:|---:| -| 2576 | 2171 | 137 | 61 | 207 | 0 | +| mutants | tués | tués par la série longue | survivants | équivalents | délais | erreurs | +|---:|---:|---:|---:|---:|---:|---:| +| 2576 | 2171 | 15 | 122 | 61 | 207 | 0 | ## Par fichier -| fichier | total | tués | survivants | équivalents | délais | erreurs | -|---|---:|---:|---:|---:|---:|---:| -| `src/moteur/classement.js` | 45 | 38 | 0 | 5 | 2 | 0 | -| `src/moteur/configuration.js` | 407 | 365 | 11 | 0 | 31 | 0 | -| `src/moteur/conflits.js` | 24 | 24 | 0 | 0 | 0 | 0 | -| `src/moteur/diagnostic.js` | 383 | 312 | 45 | 0 | 26 | 0 | -| `src/moteur/indicateurs.js` | 325 | 281 | 0 | 18 | 26 | 0 | -| `src/moteur/manque.js` | 162 | 148 | 0 | 9 | 5 | 0 | -| `src/moteur/plafond.js` | 397 | 305 | 2 | 29 | 61 | 0 | -| `src/moteur/recherche.js` | 648 | 530 | 69 | 0 | 49 | 0 | -| `src/moteur/verification.js` | 185 | 168 | 10 | 0 | 7 | 0 | +| fichier | total | tués | tués par la série longue | survivants | équivalents | délais | erreurs | +|---|---:|---:|---:|---:|---:|---:|---:| +| `src/moteur/classement.js` | 45 | 38 | 0 | 0 | 5 | 2 | 0 | +| `src/moteur/configuration.js` | 407 | 365 | 0 | 11 | 0 | 31 | 0 | +| `src/moteur/conflits.js` | 24 | 24 | 0 | 0 | 0 | 0 | 0 | +| `src/moteur/diagnostic.js` | 383 | 312 | 0 | 45 | 0 | 26 | 0 | +| `src/moteur/indicateurs.js` | 325 | 281 | 0 | 0 | 18 | 26 | 0 | +| `src/moteur/manque.js` | 162 | 148 | 0 | 0 | 9 | 5 | 0 | +| `src/moteur/plafond.js` | 397 | 305 | 2 | 0 | 29 | 61 | 0 | +| `src/moteur/recherche.js` | 648 | 530 | 13 | 56 | 0 | 49 | 0 | +| `src/moteur/verification.js` | 185 | 168 | 0 | 10 | 0 | 7 | 0 | ## Par opérateur -| opérateur | total | tués | survivants | équivalents | délais | erreurs | -|---|---:|---:|---:|---:|---:|---:| -| arithmetique | 287 | 270 | 8 | 7 | 2 | 0 | -| frontiere | 165 | 113 | 31 | 20 | 1 | 0 | -| negation | 343 | 314 | 5 | 0 | 24 | 0 | -| logique | 77 | 71 | 3 | 0 | 3 | 0 | -| retrait-non | 36 | 36 | 0 | 0 | 0 | 0 | -| mise-a-jour | 158 | 84 | 2 | 0 | 72 | 0 | -| nombre-plus-un | 540 | 469 | 47 | 20 | 4 | 0 | -| nombre-zero | 291 | 184 | 6 | 5 | 96 | 0 | -| booleen | 22 | 22 | 0 | 0 | 0 | 0 | -| si-vrai | 186 | 170 | 9 | 6 | 1 | 0 | -| si-faux | 186 | 174 | 9 | 1 | 2 | 0 | -| ternaire-vrai | 39 | 35 | 4 | 0 | 0 | 0 | -| ternaire-faux | 39 | 37 | 2 | 0 | 0 | 0 | -| min-max | 21 | 19 | 1 | 1 | 0 | 0 | -| corps-si | 186 | 173 | 10 | 1 | 2 | 0 | +| opérateur | total | tués | tués par la série longue | survivants | équivalents | délais | erreurs | +|---|---:|---:|---:|---:|---:|---:|---:| +| arithmetique | 287 | 270 | 0 | 8 | 7 | 2 | 0 | +| frontiere | 165 | 113 | 2 | 29 | 20 | 1 | 0 | +| negation | 343 | 314 | 0 | 5 | 0 | 24 | 0 | +| logique | 77 | 71 | 0 | 3 | 0 | 3 | 0 | +| retrait-non | 36 | 36 | 0 | 0 | 0 | 0 | 0 | +| mise-a-jour | 158 | 84 | 2 | 0 | 0 | 72 | 0 | +| nombre-plus-un | 540 | 469 | 9 | 38 | 20 | 4 | 0 | +| nombre-zero | 291 | 184 | 0 | 6 | 5 | 96 | 0 | +| booleen | 22 | 22 | 0 | 0 | 0 | 0 | 0 | +| si-vrai | 186 | 170 | 2 | 7 | 6 | 1 | 0 | +| si-faux | 186 | 174 | 0 | 9 | 1 | 2 | 0 | +| ternaire-vrai | 39 | 35 | 0 | 4 | 0 | 0 | 0 | +| ternaire-faux | 39 | 37 | 0 | 2 | 0 | 0 | 0 | +| min-max | 21 | 19 | 0 | 1 | 1 | 0 | 0 | +| corps-si | 186 | 173 | 0 | 10 | 1 | 2 | 0 | -## Survivants (137), lignes couvertes d’abord +## Survivants (122), lignes couvertes d’abord | lieu | opérateur | avant | après | ligne couverte | série longue | |---|---|---|---|---|---| @@ -112,8 +113,6 @@ Un survivant sur une ligne couverte désigne une épreuve qui exécute sans affi | `src/moteur/diagnostic.js:369:9` | si-faux | `valeur > max` | `false` | oui | survit | | `src/moteur/diagnostic.js:369:16` | frontiere | `>` | `>=` | oui | survit | | `src/moteur/diagnostic.js:369:23` | corps-si | `max = valeur;` | `{}` | oui | survit | -| `src/moteur/plafond.js:268:9` | si-vrai | `plafond === undefined` | `true` | oui | tué | -| `src/moteur/plafond.js:285:16` | frontiere | `>` | `>=` | oui | tué | | `src/moteur/recherche.js:143:18` | nombre-plus-un | `1` | `2` | oui | survit | | `src/moteur/recherche.js:160:20` | nombre-plus-un | `0xffff` | `65536` | oui | survit | | `src/moteur/recherche.js:160:20` | nombre-zero | `0xffff` | `0` | oui | survit | @@ -126,27 +125,18 @@ Un survivant sur une ligne couverte désigne une épreuve qui exécute sans affi | `src/moteur/recherche.js:204:21` | frontiere | `<` | `<=` | oui | survit | | `src/moteur/recherche.js:204:75` | nombre-plus-un | `1` | `2` | oui | survit | | `src/moteur/recherche.js:214:21` | frontiere | `<` | `<=` | oui | survit | -| `src/moteur/recherche.js:215:18` | nombre-plus-un | `0` | `1` | oui | tué | | `src/moteur/recherche.js:217:23` | frontiere | `<` | `<=` | oui | survit | -| `src/moteur/recherche.js:223:23` | nombre-plus-un | `1` | `2` | oui | tué | | `src/moteur/recherche.js:241:21` | frontiere | `<` | `<=` | oui | survit | -| `src/moteur/recherche.js:241:31` | nombre-plus-un | `1` | `2` | oui | tué | -| `src/moteur/recherche.js:242:23` | frontiere | `<` | `<=` | oui | tué | | `src/moteur/recherche.js:247:21` | frontiere | `<` | `<=` | oui | survit | | `src/moteur/recherche.js:252:19` | nombre-plus-un | `0` | `1` | oui | survit | -| `src/moteur/recherche.js:253:16` | nombre-plus-un | `0` | `1` | oui | tué | -| `src/moteur/recherche.js:253:31` | nombre-plus-un | `1` | `2` | oui | tué | | `src/moteur/recherche.js:269:37` | nombre-plus-un | `1` | `2` | oui | survit | | `src/moteur/recherche.js:285:13` | frontiere | `>` | `>=` | oui | survit | -| `src/moteur/recherche.js:299:20` | mise-a-jour | `+=` | `-=` | oui | tué | | `src/moteur/recherche.js:301:23` | frontiere | `>` | `>=` | oui | survit | -| `src/moteur/recherche.js:302:31` | mise-a-jour | `+=` | `-=` | oui | tué | | `src/moteur/recherche.js:317:15` | frontiere | `<` | `<=` | oui | survit | | `src/moteur/recherche.js:317:15` | negation | `<` | `>=` | oui | survit | | `src/moteur/recherche.js:325:13` | frontiere | `>` | `>=` | oui | survit | | `src/moteur/recherche.js:351:9` | si-faux | `s === exclu` | `false` | oui | survit | | `src/moteur/recherche.js:351:22` | corps-si | `continue;` | `{}` | oui | survit | -| `src/moteur/recherche.js:353:9` | si-vrai | `q !== FANTOME` | `true` | oui | tué | | `src/moteur/recherche.js:417:13` | ternaire-vrai | `R <= UINT16_MAX` | `true` | oui | survit | | `src/moteur/recherche.js:417:13` | ternaire-faux | `R <= UINT16_MAX` | `false` | oui | survit | | `src/moteur/recherche.js:417:15` | frontiere | `<=` | `<` | oui | survit | @@ -161,14 +151,10 @@ Un survivant sur une ligne couverte désigne une épreuve qui exécute sans affi | `src/moteur/recherche.js:536:16` | nombre-plus-un | `0` | `1` | oui | survit | | `src/moteur/recherche.js:542:18` | nombre-plus-un | `0` | `1` | oui | survit | | `src/moteur/recherche.js:547:9` | si-vrai | `mouvement !== null` | `true` | oui | survit | -| `src/moteur/recherche.js:551:50` | nombre-plus-un | `0` | `1` | oui | tué | -| `src/moteur/recherche.js:551:62` | nombre-plus-un | `0` | `1` | oui | tué | -| `src/moteur/recherche.js:551:69` | nombre-plus-un | `0` | `1` | oui | tué | | `src/moteur/recherche.js:560:34` | nombre-plus-un | `1` | `2` | oui | survit | | `src/moteur/recherche.js:664:23` | nombre-plus-un | `0` | `1` | oui | survit | | `src/moteur/recherche.js:670:16` | nombre-plus-un | `0` | `1` | oui | survit | | `src/moteur/recherche.js:721:16` | nombre-plus-un | `0` | `1` | oui | survit | -| `src/moteur/recherche.js:772:34` | nombre-plus-un | `0` | `1` | oui | tué | | `src/moteur/recherche.js:779:21` | frontiere | `<` | `<=` | oui | survit | | `src/moteur/recherche.js:782:11` | si-vrai | `t !== RESERVE` | `true` | oui | survit | | `src/moteur/recherche.js:813:7` | si-faux | `m < 2` | `false` | oui | survit | @@ -194,6 +180,28 @@ Un survivant sur une ligne couverte désigne une épreuve qui exécute sans affi | `src/moteur/verification.js:169:50` | nombre-plus-un | `1` | `2` | oui | survit | | `src/moteur/verification.js:174:44` | frontiere | `<` | `<=` | oui | survit | +## Tués par la série longue (15) + +Survivants de la série node que les épreuves longues tuent : la série node exécute leur ligne sans l’affirmer. + +| lieu | opérateur | avant | après | ligne couverte | +|---|---|---|---|---| +| `src/moteur/plafond.js:268:9` | si-vrai | `plafond === undefined` | `true` | oui | +| `src/moteur/plafond.js:285:16` | frontiere | `>` | `>=` | oui | +| `src/moteur/recherche.js:215:18` | nombre-plus-un | `0` | `1` | oui | +| `src/moteur/recherche.js:223:23` | nombre-plus-un | `1` | `2` | oui | +| `src/moteur/recherche.js:241:31` | nombre-plus-un | `1` | `2` | oui | +| `src/moteur/recherche.js:242:23` | frontiere | `<` | `<=` | oui | +| `src/moteur/recherche.js:253:16` | nombre-plus-un | `0` | `1` | oui | +| `src/moteur/recherche.js:253:31` | nombre-plus-un | `1` | `2` | oui | +| `src/moteur/recherche.js:299:20` | mise-a-jour | `+=` | `-=` | oui | +| `src/moteur/recherche.js:302:31` | mise-a-jour | `+=` | `-=` | oui | +| `src/moteur/recherche.js:353:9` | si-vrai | `q !== FANTOME` | `true` | oui | +| `src/moteur/recherche.js:551:50` | nombre-plus-un | `0` | `1` | oui | +| `src/moteur/recherche.js:551:62` | nombre-plus-un | `0` | `1` | oui | +| `src/moteur/recherche.js:551:69` | nombre-plus-un | `0` | `1` | oui | +| `src/moteur/recherche.js:772:34` | nombre-plus-un | `0` | `1` | oui | + ## Mutants équivalents (61) Déclarés dans `audit/mutants-equivalents.json`, avec leur raison. diff --git a/scripts/mutation/audit.js b/scripts/mutation/audit.js index 661d440..9d7e7b1 100644 --- a/scripts/mutation/audit.js +++ b/scripts/mutation/audit.js @@ -25,8 +25,9 @@ // 7. l'empreinte des sources se reprend : différente de celle de l'étape 0, // les mutants ont été lus ou joués sur d'autres sources que celles que // l'en-tête désigne, et rien ne s'écrit ; -// 8. audit/mutation-moteur.md et .json, par rediger ; les survivants que -// audit/mutants-equivalents.json déclare équivalents y sont rangés à part. +// 8. audit/mutation-moteur.md et .json, par rediger ; les survivants que la +// série longue tue, et ceux que audit/mutants-equivalents.json déclare +// équivalents, y sont rangés à part. // // Sortie 0 quand le rapport est écrit, survivants compris : l'audit n'est pas // bloquant. Sortie 2 quand l'audit ne mesure pas — en-tête refusé, ligne de @@ -41,7 +42,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { executerMutant, executerSerie } from './executer.js'; import { grapheDuProjet, testsConcernes } from './graphe.js'; import { mutants } from './mutants.js'; -import { rediger, verifierEntete } from './rapport.js'; +import { equivalentsDe, rediger, verifierEntete } from './rapport.js'; // Les dossiers mutés, et dont les épreuves jugent les mutants : le moteur // seul (§ 14.13). audit.test.js le lit dans ce source. @@ -93,28 +94,17 @@ function empreinteDe(racine, fichiers) { return hachage.digest('hex'); } -// Les équivalences déclarées : [{cle, raison}], deux chaînes non vides, clés -// uniques. Fichier absent : aucune. Lève AuditImpossible sur une autre forme. +// Les équivalences déclarées, lues par equivalentsDe de rapport.js : chaque +// clé vers sa raison. Fichier absent : aucune. Lève AuditImpossible, qui +// nomme le fichier, sur un texte qu'equivalentsDe refuse. function lireEquivalents(racine) { const chemin = join(racine, EQUIVALENTS); if (!existsSync(chemin)) return new Map(); - let liste; try { - liste = JSON.parse(readFileSync(chemin, 'utf8')); + return equivalentsDe(readFileSync(chemin, 'utf8')); } catch (erreur) { - throw new AuditImpossible(`${EQUIVALENTS} illisible : ${erreur.message}`); + throw new AuditImpossible(`${EQUIVALENTS} : ${erreur.message}`); } - const forme = `${EQUIVALENTS} : liste de { cle, raison }, chaînes non vides, clés uniques`; - if (!Array.isArray(liste)) throw new AuditImpossible(forme); - const equivalents = new Map(); - for (const entree of liste) { - const { cle, raison } = entree ?? {}; - if (typeof cle !== 'string' || cle === '' || typeof raison !== 'string' || raison === '' || equivalents.has(cle)) { - throw new AuditImpossible(forme); - } - equivalents.set(cle, raison); - } - return equivalents; } // Les lignes exécutées d'un rapport LCOV, par fichier : DA:, @@ -299,16 +289,20 @@ export async function auditer({ racine = RACINE, journal = (texte) => process.st for (const cle of [...equivalents.keys()].sort(comparer)) { if (!cles.has(cle)) journal(`équivalence sans mutant (source changé ?) : ${cle}`); } + // Une déclaration d'équivalence ne vaut que pour un survivant que la série + // longue ne tue pas : un mutant qu'une épreuve tue change quelque chose. const resultats = taches.map(({ mutant }, rang) => { const issue = issues[rang]; + const longue = longues[rang]; const declare = equivalents.get(mutant.cle) ?? null; - if (declare !== null && issue !== 'survivant') journal(`déclaré équivalent, pourtant ${issue} : ${mutant.cle}`); + const survit = issue === 'survivant' && longue !== 'tue'; + if (declare !== null && !survit) journal(`déclaré équivalent, pourtant ${longue === 'tue' ? 'tué par la série longue' : issue} : ${mutant.cle}`); return { mutant, issue, couverte: couvertes.get(mutant.fichier)?.has(mutant.ligne) ?? false, - longue: longues[rang], - equivalent: issue === 'survivant' ? declare : null, + longue, + equivalent: survit ? declare : null, }; }); @@ -342,7 +336,8 @@ async function principal() { const { bilan, dureeMs } = await auditer({ arreter: () => arret }); process.stderr.write( `audit écrit : ${SORTIES.markdown}, ${SORTIES.json} — ${bilan.total} mutants, ${bilan.tues} tués, ` + - `${bilan.survivants} survivants, ${bilan.equivalents} équivalents, ${bilan.delais} délais, ` + + `${bilan.tuesSerieLongue} tués par la série longue, ${bilan.survivants} survivants, ` + + `${bilan.equivalents} équivalents, ${bilan.delais} délais, ` + `${bilan.erreurs} erreurs, en ${(dureeMs / 60_000).toFixed(1)} min\n`, ); process.exitCode = 0; diff --git a/scripts/mutation/rapport.js b/scripts/mutation/rapport.js index f4b1dff..150b07b 100644 --- a/scripts/mutation/rapport.js +++ b/scripts/mutation/rapport.js @@ -2,9 +2,12 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Le résultat écrit de l'audit par mutation (§ 14.13) : rediger compose le -// Markdown et le JSON que audit.js écrit sous audit/, versionnés. Fonction -// pure et déterministe : ni lecture de fichier ni horloge, et la sortie ne -// dépend pas de l'ordre des résultats reçus. +// Markdown et le JSON que audit.js écrit sous audit/, versionnés ; relire +// recompose le Markdown du seul JSON écrit, par la même composition ; +// equivalentsDe lit audit/mutants-equivalents.json. Fonctions pures et +// déterministes : elles reçoivent des textes, ne lisent aucun fichier ni +// horloge, et la sortie de rediger ne dépend pas de l'ordre des résultats +// reçus. // // Un rapport ne nomme pas sa machine (§ 19.10) : son en-tête est une liste // fermée de champs, chacun d'une forme stricte — la version, le périmètre en @@ -15,7 +18,10 @@ // // Les survivants sur une ligne couverte viennent en tête : une épreuve qui // exécute la ligne sans que la mutation la fasse échouer exécute sans -// affirmer, ce que l'audit est là pour désigner. Un survivant que +// affirmer, ce que l'audit est là pour désigner. Un survivant de la série +// node se rejoue contre les épreuves longues qui atteignent son module avant +// d'être compté : tué par elles, il n'est pas un survivant, et se compte et +// se liste à part, dans sa colonne. Un survivant que // audit/mutants-equivalents.json déclare équivalent se compte et se liste à // part, avec sa raison. import { deriver } from '../version.js'; @@ -146,8 +152,8 @@ function verifierResultat(resultat, rang) { if (longue !== null && !(issue === 'survivant' && ISSUES.includes(longue))) { throw new TypeError(`${ici} : série longue, une issue pour un survivant seul, null sinon`); } - if (equivalent !== null && !(issue === 'survivant' && typeof equivalent === 'string' && equivalent !== '')) { - throw new TypeError(`${ici} : équivalent, une raison non vide pour un survivant seul, null sinon`); + if (equivalent !== null && !(issue === 'survivant' && longue !== 'tue' && typeof equivalent === 'string' && equivalent !== '')) { + throw new TypeError(`${ici} : équivalent, une raison non vide pour un survivant seul, que la série longue ne tue pas ; null sinon`); } sansCompte(equivalent, `${ici}, équivalent`); return { cle, fichier, ligne, colonne, operateur, original, remplacement, issue, couverte, longue, equivalent }; @@ -169,7 +175,10 @@ function comparerLieux(a, b) { ); } -// La colonne de comptes où tombe un résultat. +// La colonne de comptes où tombe un résultat. Un survivant de la série node +// que la série longue tue n'est pas un survivant (§ 14.13) ; un survivant +// qu'elle laisse vivre, ou qu'elle ne rejoue pas, en reste un, ou un +// équivalent déclaré. const colonneDe = (r) => r.issue === 'tue' ? 'tues' @@ -177,11 +186,15 @@ const colonneDe = (r) => ? 'delais' : r.issue === 'erreur' ? 'erreurs' - : r.equivalent === null - ? 'survivants' - : 'equivalents'; + : r.longue === 'tue' + ? 'tuesSerieLongue' + : r.equivalent === null + ? 'survivants' + : 'equivalents'; -const compteVide = () => ({ total: 0, tues: 0, survivants: 0, equivalents: 0, delais: 0, erreurs: 0 }); +// Les colonnes de comptes, dans l'ordre du JSON et des tableaux. +const COLONNES = ['total', 'tues', 'tuesSerieLongue', 'survivants', 'equivalents', 'delais', 'erreurs']; +const compteVide = () => Object.fromEntries(COLONNES.map((colonne) => [colonne, 0])); function compter(liste) { const compte = compteVide(); @@ -192,8 +205,8 @@ function compter(liste) { return compte; } -const COLONNES = ['total', 'tues', 'survivants', 'equivalents', 'delais', 'erreurs']; -const ENTETES_COLONNES = '| total | tués | survivants | équivalents | délais | erreurs |'; +const ENTETES_COLONNES = '| total | tués | tués par la série longue | survivants | équivalents | délais | erreurs |'; +const ALIGNEMENT_COMPTES = COLONNES.map(() => '---:').join('|'); const ligneComptes = (tete, compte) => `| ${tete === null ? '' : `${tete} | `}${COLONNES.map((c) => compte[c]).join(' | ')} |`; // Le texte d'un nœud dans une cellule : blancs ramenés à une espace, au plus @@ -225,6 +238,88 @@ const mutantEcrit = (r) => ({ remplacement: r.remplacement, }); +// Les champs d'une entrée de la liste des survivants, et de celle des tués +// par la série longue. +const vivantEcrit = (r) => ({ ...mutantEcrit(r), couverte: r.couverte, longue: r.longue }); + +// Une ligne de liste du Markdown : lieu, opérateur, avant, après, puis les +// cellules propres à la liste. +const ligneDeListe = (r, ...suite) => + `| ${lieu(r)} | ${r.operateur} | ${cellule(r.original)} | ${cellule(r.remplacement)} | ${suite.join(' | ')} |`; + +// Une section du Markdown : son titre, puis « Aucun. » ou le tableau. +const section = (titre, introduction, liste, entete, ligne) => [ + `## ${titre}`, + '', + ...introduction, + ...(liste.length === 0 + ? ['Aucun.'] + : [`| lieu | opérateur | avant | après | ${entete.join(' | ')} |`, `|---|---|---|---|${entete.map(() => '---').join('|')}|`, ...liste.map(ligne)]), + '', +]; + +// Le Markdown d'un audit, composé de ses données, celles que le JSON porte : +// rediger et relire en tirent le même texte. +function composer(donnees) { + const { entete: tete, bilan, parFichier, parOperateur, survivants, tuesSerieLongue, equivalents, delaisEtErreurs } = donnees; + const longueLue = (r) => (r.longue === null ? '—' : ISSUE_LUE[r.longue]); + return [ + '# Audit par mutation du moteur', + '', + 'Écrit par `make mutation` (`scripts/mutation/audit.js`, § 14.13) ; ne se modifie pas à la main.', + '', + '| | |', + '|---|---|', + `| version | ${tete.version} |`, + `| périmètre | ${tete.perimetre.map((d) => `\`${d}\``).join(', ')} |`, + `| sources et épreuves du périmètre (SHA-256) | \`${tete.empreinte}\` |`, + `| Node | ${tete.node} |`, + '', + 'Chaque mutant se joue contre les épreuves de la série node qui atteignent son module, sous `node --test`.', + 'Un survivant se rejoue contre les épreuves longues qui atteignent son module avant d’être compté : tué par elles, il se compte à part.', + 'Un survivant sur une ligne couverte désigne une épreuve qui exécute sans affirmer.', + '', + '## Bilan', + '', + ENTETES_COLONNES.replace('| total', '| mutants'), + `|${ALIGNEMENT_COMPTES}|`, + ligneComptes(null, bilan), + '', + '## Par fichier', + '', + `| fichier ${ENTETES_COLONNES}`, + `|---|${ALIGNEMENT_COMPTES}|`, + ...parFichier.map((c) => ligneComptes(`\`${c.fichier}\``, c)), + '', + '## Par opérateur', + '', + `| opérateur ${ENTETES_COLONNES}`, + `|---|${ALIGNEMENT_COMPTES}|`, + ...parOperateur.map((c) => ligneComptes(c.operateur, c)), + '', + ...section(`Survivants (${survivants.length}), lignes couvertes d’abord`, [], survivants, ['ligne couverte', 'série longue'], (r) => + ligneDeListe(r, r.couverte ? 'oui' : 'non', longueLue(r)), + ), + ...section( + `Tués par la série longue (${tuesSerieLongue.length})`, + ['Survivants de la série node que les épreuves longues tuent : la série node exécute leur ligne sans l’affirmer.', ''], + tuesSerieLongue, + ['ligne couverte'], + (r) => ligneDeListe(r, r.couverte ? 'oui' : 'non'), + ), + ...section( + `Mutants équivalents (${equivalents.length})`, + ['Déclarés dans `audit/mutants-equivalents.json`, avec leur raison.', ''], + equivalents, + ['raison'], + (r) => ligneDeListe(r, r.raison.replace(/\s+/g, ' ').replaceAll('|', '\\|')), + ), + ...section(`Délais et erreurs (${delaisEtErreurs.length})`, [], delaisEtErreurs, ['issue'], (r) => ligneDeListe(r, ISSUE_LUE[r.issue])), + ].join('\n'); +} + +const enJson = (donnees) => `${JSON.stringify(donnees, null, 2)}\n`; + /** * Le rapport d'un audit, en Markdown et en JSON, déterministe. * Lève sur des résultats vides — un audit qui n'a rien mesuré n'a rien à @@ -252,100 +347,120 @@ export function rediger({ entete, resultats } = {}) { operateur, ...compter(lus.filter((r) => r.operateur === operateur)), })); - const bilan = compter(lus); - const survivants = lus.filter((r) => colonneDe(r) === 'survivants'); - const equivalents = lus.filter((r) => colonneDe(r) === 'equivalents'); - const delaisEtErreurs = lus.filter((r) => r.issue === 'delai' || r.issue === 'erreur'); + const dans = (colonne) => lus.filter((r) => colonneDe(r) === colonne); const donnees = { format: 1, entete: tete, - bilan, + bilan: compter(lus), parFichier, parOperateur, - survivants: survivants.map((r) => ({ ...mutantEcrit(r), couverte: r.couverte, longue: r.longue })), - equivalents: equivalents.map((r) => ({ ...mutantEcrit(r), raison: r.equivalent })), - delaisEtErreurs: delaisEtErreurs.map((r) => ({ ...mutantEcrit(r), issue: r.issue })), + survivants: dans('survivants').map(vivantEcrit), + tuesSerieLongue: dans('tuesSerieLongue').map(vivantEcrit), + equivalents: dans('equivalents').map((r) => ({ ...mutantEcrit(r), raison: r.equivalent })), + delaisEtErreurs: lus.filter((r) => r.issue === 'delai' || r.issue === 'erreur').map((r) => ({ ...mutantEcrit(r), issue: r.issue })), }; - - const md = [ - '# Audit par mutation du moteur', - '', - 'Écrit par `make mutation` (`scripts/mutation/audit.js`, § 14.13) ; ne se modifie pas à la main.', - '', - '| | |', - '|---|---|', - `| version | ${tete.version} |`, - `| périmètre | ${tete.perimetre.map((d) => `\`${d}\``).join(', ')} |`, - `| sources et épreuves du périmètre (SHA-256) | \`${tete.empreinte}\` |`, - `| Node | ${tete.node} |`, - '', - 'Chaque mutant se joue contre les épreuves de la série node qui atteignent son module, sous `node --test`.', - 'Un survivant sur une ligne couverte désigne une épreuve qui exécute sans affirmer.', - '', - '## Bilan', - '', - ENTETES_COLONNES.replace('| total', '| mutants'), - '|---:|---:|---:|---:|---:|---:|', - ligneComptes(null, bilan), - '', - '## Par fichier', - '', - `| fichier ${ENTETES_COLONNES}`, - '|---|---:|---:|---:|---:|---:|---:|', - ...parFichier.map((c) => ligneComptes(`\`${c.fichier}\``, c)), - '', - '## Par opérateur', - '', - `| opérateur ${ENTETES_COLONNES}`, - '|---|---:|---:|---:|---:|---:|---:|', - ...parOperateur.map((c) => ligneComptes(c.operateur, c)), - '', - `## Survivants (${survivants.length}), lignes couvertes d’abord`, - '', - ...(survivants.length === 0 - ? ['Aucun.'] - : [ - '| lieu | opérateur | avant | après | ligne couverte | série longue |', - '|---|---|---|---|---|---|', - ...survivants.map( - (r) => - `| ${lieu(r)} | ${r.operateur} | ${cellule(r.original)} | ${cellule(r.remplacement)} | ${ - r.couverte ? 'oui' : 'non' - } | ${r.longue === null ? '—' : ISSUE_LUE[r.longue]} |`, - ), - ]), - '', - `## Mutants équivalents (${equivalents.length})`, - '', - 'Déclarés dans `audit/mutants-equivalents.json`, avec leur raison.', - '', - ...(equivalents.length === 0 - ? ['Aucun.'] - : [ - '| lieu | opérateur | avant | après | raison |', - '|---|---|---|---|---|', - ...equivalents.map( - (r) => - `| ${lieu(r)} | ${r.operateur} | ${cellule(r.original)} | ${cellule(r.remplacement)} | ${r.equivalent - .replace(/\s+/g, ' ') - .replaceAll('|', '\\|')} |`, - ), - ]), - '', - `## Délais et erreurs (${delaisEtErreurs.length})`, - '', - ...(delaisEtErreurs.length === 0 - ? ['Aucun.'] - : [ - '| lieu | opérateur | avant | après | issue |', - '|---|---|---|---|---|', - ...delaisEtErreurs.map( - (r) => `| ${lieu(r)} | ${r.operateur} | ${cellule(r.original)} | ${cellule(r.remplacement)} | ${ISSUE_LUE[r.issue]} |`, - ), - ]), - '', - ]; - - return { markdown: md.join('\n'), json: `${JSON.stringify(donnees, null, 2)}\n` }; + return { markdown: composer(donnees), json: enJson(donnees) }; +} + +// Les clés du JSON écrit, dans son ordre. +const CLES_DONNEES = ['format', 'entete', 'bilan', 'parFichier', 'parOperateur', 'survivants', 'tuesSerieLongue', 'equivalents', 'delaisEtErreurs']; + +// Lève quand les comptes d'une ligne ne sont pas ceux de COLONNES, entiers +// positifs ou nuls, dont le total est la somme des autres. +function verifierComptes(compte, ici) { + const ecarts = + compte === null || + typeof compte !== 'object' || + COLONNES.some((c) => !Number.isInteger(compte[c]) || compte[c] < 0) || + COLONNES.slice(1).reduce((somme, c) => somme + compte[c], 0) !== compte.total; + if (ecarts) throw new TypeError(`${ici} : comptes ${COLONNES.join(', ')} attendus, le total égal à la somme des autres`); +} + +// Lève quand les lignes ne somment pas, colonne par colonne, au bilan. +function verifierSommes(lignes, bilan, ici) { + if (!Array.isArray(lignes)) throw new TypeError(`${ici} : liste attendue`); + lignes.forEach((ligne, rang) => verifierComptes(ligne, `${ici}, ligne ${rang + 1}`)); + for (const colonne of COLONNES) { + if (lignes.reduce((somme, ligne) => somme + ligne[colonne], 0) !== bilan[colonne]) { + throw new TypeError(`${ici} : la colonne ${colonne} ne somme pas au bilan`); + } + } +} + +/** + * Relit le JSON qu'a écrit rediger et en recompose le Markdown, par la même + * composition. Lève une TypeError sur un texte que rediger n'écrit pas : une + * autre mise en forme ou d'autres clés, un en-tête que verifierEntete + * refuse, des comptes qui contredisent les listes, ou un survivant que la + * série longue tue, laissé parmi les survivants. + * @param {string} json le texte de audit/mutation-moteur.json + * @returns {{donnees: object, markdown: string}} + */ +export function relire(json) { + let donnees; + try { + donnees = JSON.parse(json); + } catch (erreur) { + throw new TypeError(`JSON illisible : ${erreur.message}`); + } + if (typeof donnees !== 'object' || donnees === null || enJson(donnees) !== json || Object.keys(donnees).join() !== CLES_DONNEES.join()) { + throw new TypeError(`autre forme que celle que rediger écrit : ${CLES_DONNEES.join(', ')}, indentées de deux espaces`); + } + if (donnees.format !== 1) throw new TypeError(`format ${String(donnees.format)} : 1 attendu`); + const tete = verifierEntete(donnees.entete); + if (enJson(tete) !== enJson(donnees.entete)) throw new TypeError('en-tête : autres champs ou autre ordre que ceux de verifierEntete'); + const { bilan, survivants, tuesSerieLongue, equivalents, delaisEtErreurs } = donnees; + verifierComptes(bilan, 'bilan'); + verifierSommes(donnees.parFichier, bilan, 'par fichier'); + verifierSommes(donnees.parOperateur, bilan, 'par opérateur'); + const listes = [ + ['survivants', survivants, bilan.survivants], + ['tuesSerieLongue', tuesSerieLongue, bilan.tuesSerieLongue], + ['equivalents', equivalents, bilan.equivalents], + ['delaisEtErreurs', delaisEtErreurs, bilan.delais + bilan.erreurs], + ]; + for (const [nom, liste, compte] of listes) { + if (!Array.isArray(liste) || liste.length !== compte) throw new TypeError(`bilan : ${compte} attendus dans ${nom}`); + } + const lieuLu = (r) => `${r.fichier}:${r.ligne}:${r.colonne}`; + const tueParLaLongue = survivants.find((r) => r.longue === 'tue'); + if (tueParLaLongue !== undefined) { + throw new TypeError(`un survivant que la série longue tue, laissé parmi les survivants : ${lieuLu(tueParLaLongue)}`); + } + const autre = tuesSerieLongue.find((r) => r.longue !== 'tue'); + if (autre !== undefined) throw new TypeError(`tuesSerieLongue : un mutant que la série longue ne tue pas, ${lieuLu(autre)}`); + return { donnees, markdown: composer(donnees) }; +} + +const FORME_EQUIVALENTS = 'liste de { cle, raison }'; + +/** + * Les équivalences déclarées dans audit/mutants-equivalents.json : chaque + * clé vers sa raison, dans l'ordre du fichier. Lève une TypeError sur un + * texte illisible, une autre forme qu'une liste de { cle, raison } — deux + * chaînes non vides —, une clé en double, et une raison qui porte un chemin + * de compte, que rediger refuserait après l'audit entier. + * @param {string} texte + * @returns {Map} + */ +export function equivalentsDe(texte) { + let liste; + try { + liste = JSON.parse(texte); + } catch (erreur) { + throw new TypeError(`illisible : ${erreur.message}`); + } + if (!Array.isArray(liste)) throw new TypeError(`${FORME_EQUIVALENTS} attendue`); + const equivalents = new Map(); + liste.forEach((entree, rang) => { + if (typeof entree !== 'object' || entree === null || Array.isArray(entree)) throw new TypeError(`${FORME_EQUIVALENTS} attendue, rang ${rang + 1}`); + const { cle, raison } = entree; + if (typeof cle !== 'string' || cle === '') throw new TypeError(`${FORME_EQUIVALENTS}, clé vide ou absente au rang ${rang + 1}`); + if (typeof raison !== 'string' || raison === '') throw new TypeError(`${FORME_EQUIVALENTS}, raison vide pour ${cle}`); + if (equivalents.has(cle)) throw new TypeError(`${FORME_EQUIVALENTS}, clé en double : ${cle}`); + sansCompte(raison, `la raison de ${cle}`); + equivalents.set(cle, raison); + }); + return equivalents; } diff --git a/scripts/mutation/rapport.test.js b/scripts/mutation/rapport.test.js index 53c1c12..4c76ca2 100644 --- a/scripts/mutation/rapport.test.js +++ b/scripts/mutation/rapport.test.js @@ -3,11 +3,18 @@ // 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. +// n'existent pas : rediger ne lit aucun fichier. La dernière partie lit, elle, +// les trois fichiers committés d'audit/, comme du texte, sans importer +// audit.js. import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { VERSION } from '../../src/version.genere.js'; import { describe, test } from '../../test/lanceur.js'; -import { rediger } from './rapport.js'; +import { equivalentsDe, rediger, relire } from './rapport.js'; + +const RACINE = fileURLToPath(new URL('../../', import.meta.url)); const ENTETE = Object.freeze({ version: VERSION.affichee, @@ -44,8 +51,9 @@ const resultat = (m, issue, { couverte = true, longue = null, equivalent = null // 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) +// longue, donc compté à part des survivants ; 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' }), @@ -66,35 +74,37 @@ 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('le bilan compte chaque issue ; un survivant que la série longue tue, et un équivalent, à part', () => { + assert.deepEqual(lu.bilan, { total: 9, tues: 2, tuesSerieLongue: 1, survivants: 3, 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 }, + { fichier: 'src/verger/poires.js', total: 4, tues: 1, tuesSerieLongue: 1, survivants: 1, equivalents: 0, delais: 0, erreurs: 1 }, + { fichier: 'src/verger/pommes.js', total: 5, tues: 1, tuesSerieLongue: 0, survivants: 2, equivalents: 1, delais: 1, erreurs: 0 }, ]); }); test('par opérateur, dans l’ordre d’OPERATEURS', () => { + const vide = { tues: 0, tuesSerieLongue: 0, survivants: 0, equivalents: 0, delais: 0, erreurs: 0 }; 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 }, + { operateur: 'arithmetique', total: 2, ...vide, tues: 1, tuesSerieLongue: 1 }, + { operateur: 'frontiere', total: 3, ...vide, tues: 1, survivants: 2 }, + { operateur: 'negation', total: 1, ...vide, survivants: 1 }, + { operateur: 'logique', total: 1, ...vide, erreurs: 1 }, + { operateur: 'nombre-zero', total: 1, ...vide, delais: 1 }, + { operateur: 'si-vrai', total: 1, ...vide, equivalents: 1 }, ]); }); 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'); + assert.ok(lignes.includes('| mutants | tués | tués par la série longue | survivants | équivalents | délais | erreurs |'), 'en-tête du bilan'); + assert.ok(lignes.includes('| 9 | 2 | 1 | 3 | 1 | 1 | 1 |'), 'ligne du bilan'); + assert.ok(lignes.includes('| `src/verger/poires.js` | 4 | 1 | 1 | 1 | 0 | 0 | 1 |'), 'ligne de poires.js'); + assert.ok(lignes.includes('| `src/verger/pommes.js` | 5 | 1 | 0 | 2 | 1 | 1 | 0 |'), 'ligne de pommes.js'); + assert.ok(lignes.includes('| frontiere | 3 | 1 | 0 | 2 | 0 | 0 | 0 |'), 'ligne de frontiere'); }); test('l’en-tête se recopie tel quel quand il ne nomme pas la machine', () => { @@ -111,23 +121,30 @@ describe('rapport de mutation : l’ordre', () => { 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(lieux(lu.survivants), ['src/verger/poires.js:12:2', '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('un survivant que la série longue tue se liste à part, entre les survivants et les équivalents', () => { + assert.deepEqual( + lu.tuesSerieLongue.map((r) => [`${r.fichier}:${r.ligne}:${r.colonne}`, r.couverte, r.longue]), + [['src/verger/poires.js:12:5', true, 'tue']], + ); + const section = (titre) => markdown.indexOf(`\n## ${titre}`); + const ici = markdown.indexOf('`src/verger/poires.js:12:5`'); + assert.ok(section('Survivants (3)') > 0, 'trois survivants annoncés'); + assert.ok(section('Tués par la série longue (1)') > section('Survivants (3)'), 'la section suit les survivants'); + assert.ok(ici > section('Tués par la série longue (1)') && ici < section('Mutants équivalents (1)'), 'le mutant est dans sa section'); + assert.equal(markdown.indexOf('`src/verger/poires.js:12:5`', ici + 1), -1, 'et nulle part ailleurs'); + }); + 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 @@ -242,6 +259,11 @@ describe('rapport de mutation : ce qu’il refuse', () => { ); } refuse({ ...premier, longue: 'tue' }, /série longue/); + assert.throws( + () => rediger({ entete: ENTETE, resultats: [{ ...RESULTATS[1], equivalent: 'déclaré à tort' }] }), + /équivalent/, + 'un survivant que la série longue tue n’est pas équivalent', + ); refuse({ ...premier, couverte: 'oui' }, /couverte/); refuse({ ...premier, mutant: { ...premier.mutant, cle: second.mutant.cle } }, /clé/); }); @@ -269,3 +291,141 @@ describe('rapport de mutation : les cellules de code', () => { assert.ok(markdown.includes('`` a && `x` ``'), markdown); }); }); + +describe('rapport de mutation : la relecture du JSON écrit', () => { + const ecrit = rediger({ entete: ENTETE, resultats: RESULTATS }); + // Le JSON écrit, modifié par changer sur sa forme lue, réécrit comme + // rediger l'écrit. + const modifie = (changer) => { + const lu = JSON.parse(ecrit.json); + changer(lu); + return `${JSON.stringify(lu, null, 2)}\n`; + }; + + test('relire rend le Markdown que rediger a écrit, octet pour octet', () => { + const relu = relire(ecrit.json); + assert.equal(relu.markdown, ecrit.markdown); + assert.deepEqual(relu.donnees, JSON.parse(ecrit.json)); + }); + + test('relire refuse un JSON réindenté, un en-tête qui nomme la machine, des comptes contraires aux listes', () => { + assert.throws(() => relire(JSON.stringify(JSON.parse(ecrit.json))), /forme/); + assert.throws(() => relire('{'), /illisible/); + assert.throws(() => relire(modifie((lu) => Object.assign(lu.entete, { hote: 'atelier-ombrelle' }))), /ne nomme pas sa machine/); + assert.throws(() => relire(modifie((lu) => Object.assign(lu, { format: 2 }))), /format/); + assert.throws(() => relire(modifie((lu) => (lu.bilan.survivants += 1))), /bilan/); + assert.throws(() => relire(modifie((lu) => (lu.parFichier[0].tues += 1))), /par fichier/); + assert.throws(() => relire(modifie((lu) => (lu.parOperateur[0].tues += 1))), /par opérateur/); + }); + + test('relire refuse un survivant que la série longue tue, laissé parmi les survivants', () => { + // L'ancien compte : le mutant tué par la série longue rangé avec les + // survivants, bilan, fichier et opérateur ajustés pour que seuls les + // listes et la règle du compte le trahissent. + const ancien = modifie((lu) => { + lu.survivants.splice(1, 0, ...lu.tuesSerieLongue.splice(0)); + for (const compte of [lu.bilan, lu.parFichier[0], lu.parOperateur[0]]) { + compte.survivants += 1; + compte.tuesSerieLongue -= 1; + } + }); + assert.throws(() => relire(ancien), /série longue/); + }); +}); + +describe('rapport de mutation : les équivalents déclarés', () => { + const texte = (liste) => `${JSON.stringify(liste, null, 2)}\n`; + const CLE = 'src/verger/pommes.js:si-vrai:00000000000000aa:0'; + + test('equivalentsDe rend chaque clé avec sa raison, dans l’ordre du fichier', () => { + const lus = equivalentsDe(texte([{ cle: CLE, raison: 'toujours vrai ici' }, { cle: `${CLE}1`, raison: 'idem' }])); + assert.deepEqual([...lus], [ + [CLE, 'toujours vrai ici'], + [`${CLE}1`, 'idem'], + ]); + assert.deepEqual([...equivalentsDe('[]\n')], []); + }); + + test('equivalentsDe refuse un texte illisible, une autre forme, une raison vide, une clé en double, un chemin de compte', () => { + assert.throws(() => equivalentsDe('['), /illisible/); + assert.throws(() => equivalentsDe('{}'), /liste de \{ cle, raison \}/); + assert.throws(() => equivalentsDe(texte([{ cle: CLE }])), new RegExp(`raison.*${CLE}`)); + assert.throws(() => equivalentsDe(texte([{ cle: CLE, raison: '' }])), new RegExp(`raison.*${CLE}`)); + assert.throws(() => equivalentsDe(texte([{ cle: '', raison: 'x' }])), /clé/); + assert.throws(() => equivalentsDe(texte([null])), /liste de \{ cle, raison \}/); + assert.throws( + () => equivalentsDe(texte([{ cle: CLE, raison: 'a' }, { cle: CLE, raison: 'b' }])), + new RegExp(`en double.*${CLE}`), + ); + assert.throws(() => equivalentsDe(texte([{ cle: CLE, raison: 'lu dans /home/exemple/notes' }])), /ne nomme pas sa machine/); + }); +}); + +// Les fautes des trois fichiers d'audit/, lus comme du texte : un JSON que +// relire accepte, au périmètre du moteur seul ; le Markdown que sa +// composition rejoue ; les équivalents déclarés, exactement ceux que le JSON +// range à part, chacun avec la même raison. Une liste vide quand tout +// s'accorde. +function fautesAudit({ markdown, json, equivalents }) { + let relu; + try { + relu = relire(json); + } catch (erreur) { + return [`mutation-moteur.json : ${erreur.message}`]; + } + const fautes = []; + const { perimetre } = relu.donnees.entete; + if (perimetre.length !== 1 || perimetre[0] !== 'src/moteur') fautes.push(`périmètre ${perimetre.join(', ')} : src/moteur seul attendu`); + if (markdown !== relu.markdown) fautes.push('mutation-moteur.md : autre que le Markdown que rediger rejoue'); + let declares; + try { + declares = equivalentsDe(equivalents); + } catch (erreur) { + return [...fautes, `mutants-equivalents.json : ${erreur.message}`]; + } + const ranges = new Map(relu.donnees.equivalents.map(({ cle, raison }) => [cle, raison])); + for (const [cle, raison] of declares) { + if (!ranges.has(cle)) fautes.push(`mutants-equivalents.json : clé inconnue du JSON, ${cle}`); + else if (ranges.get(cle) !== raison) fautes.push(`mutants-equivalents.json : raison autre que celle du JSON, ${cle}`); + } + for (const cle of ranges.keys()) { + if (!declares.has(cle)) fautes.push(`mutation-moteur.json : équivalent non déclaré, ${cle}`); + } + return fautes; +} + +describe('rapport de mutation : audit/ committé (§ 14.13)', () => { + const lire = (fichier) => readFileSync(join(RACINE, 'audit', fichier), 'utf8'); + const COMMITTE = Object.freeze({ + markdown: lire('mutation-moteur.md'), + json: lire('mutation-moteur.json'), + equivalents: lire('mutants-equivalents.json'), + }); + + test('audit/ committé : l’en-tête passe verifierEntete, le Markdown est celui que rediger rejoue, chaque équivalent a une clé unique et une raison non vide, connue du JSON', () => { + assert.deepEqual(fautesAudit(COMMITTE), []); + assert.deepEqual(relire(COMMITTE.json).donnees.entete.perimetre, ['src/moteur']); + assert.ok(equivalentsDe(COMMITTE.equivalents).size > 0, 'des équivalents déclarés'); + }); + + test('la garde relève, en mémoire, une raison vidée, une clé inconnue, un Markdown retouché, un périmètre élargi', () => { + const liste = JSON.parse(COMMITTE.equivalents); + const premiere = liste[0].cle; + const videe = `${JSON.stringify([{ ...liste[0], raison: '' }, ...liste.slice(1)], null, 2)}\n`; + assert.deepEqual(fautesAudit({ ...COMMITTE, equivalents: videe }), [ + `mutants-equivalents.json : liste de { cle, raison }, raison vide pour ${premiere}`, + ]); + const inconnue = 'src/moteur/inventee.js:frontiere:0123456789abcdef:0'; + const ajoutee = `${JSON.stringify([...liste, { cle: inconnue, raison: 'une clé que nul audit n’a rendue' }], null, 2)}\n`; + assert.deepEqual(fautesAudit({ ...COMMITTE, equivalents: ajoutee }), [`mutants-equivalents.json : clé inconnue du JSON, ${inconnue}`]); + assert.deepEqual(fautesAudit({ ...COMMITTE, markdown: COMMITTE.markdown.replace('## Bilan', '## Bilan retouché') }), [ + 'mutation-moteur.md : autre que le Markdown que rediger rejoue', + ]); + const elargi = COMMITTE.json.replace('"src/moteur"\n', '"src/moteur",\n "src/application"\n'); + assert.notEqual(elargi, COMMITTE.json); + assert.deepEqual(fautesAudit({ ...COMMITTE, json: elargi }), [ + 'périmètre src/moteur, src/application : src/moteur seul attendu', + 'mutation-moteur.md : autre que le Markdown que rediger rejoue', + ]); + }); +}); diff --git a/test/fixtures/mutation/arbre/bornes.epreuve.js b/test/fixtures/mutation/arbre/bornes.epreuve.js new file mode 100644 index 0000000..de9db33 --- /dev/null +++ b/test/fixtures/mutation/arbre/bornes.epreuve.js @@ -0,0 +1,21 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'épreuve node inventée de bornes.js, posée en src/moteur/bornes.test.js du +// petit arbre de test/mutation.long.test.js : ses imports sont écrits pour +// cette place. Son nom ici ne finit pas par .test.js : aucune série de Vitest +// ne la ramasse. Elle exécute triple sans en affirmer la valeur. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { plusGrand, triple } from './bornes.js'; + +describe('bornes', () => { + test('plusGrand', () => { + assert.equal(plusGrand(2, 5), 5); + assert.equal(plusGrand(5, 2), 5); + }); + + test('triple rend un nombre', () => { + assert.equal(typeof triple(2), 'number'); + }); +}); diff --git a/test/fixtures/mutation/arbre/bornes.js b/test/fixtures/mutation/arbre/bornes.js new file mode 100644 index 0000000..c6781fb --- /dev/null +++ b/test/fixtures/mutation/arbre/bornes.js @@ -0,0 +1,20 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Module inventé du petit arbre que test/mutation.long.test.js audite par +// scripts/mutation/audit.js, posé en src/moteur/bornes.js d'un dossier +// temporaire. Chaque fonction porte des sites dont l'issue est connue sous +// bornes.epreuve.js (la série node) et bornes.longue.js (la série longue). + +// Le plus grand de deux nombres. « a > b » → « a >= b » rend le même nombre +// pour tout couple : un survivant que ni l'une ni l'autre série ne tue. +export function plusGrand(a, b) { + return a > b ? a : b; +} + +// Le triple d'un nombre. La série node n'en lit que le type, la série longue +// la valeur : « n * 3 » → « n / 3 », « n * 4 », « n * 0 » survivent à la +// première et meurent sous la seconde. +export function triple(n) { + return n * 3; +} diff --git a/test/fixtures/mutation/arbre/bornes.longue.js b/test/fixtures/mutation/arbre/bornes.longue.js new file mode 100644 index 0000000..655ec55 --- /dev/null +++ b/test/fixtures/mutation/arbre/bornes.longue.js @@ -0,0 +1,17 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'épreuve longue inventée de bornes.js, posée en +// src/moteur/bornes.long.test.js du petit arbre de +// test/mutation.long.test.js : ses imports sont écrits pour cette place. Son +// nom ici ne finit pas par .test.js : aucune série de Vitest ne la ramasse. +// Elle affirme la valeur de triple, que l'épreuve node tait. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { triple } from './bornes.js'; + +describe('bornes, série longue', () => { + test('triple rend le triple', () => { + assert.equal(triple(2), 6); + }); +}); diff --git a/test/mutation.long.test.js b/test/mutation.long.test.js index a997bef..96789f5 100644 --- a/test/mutation.long.test.js +++ b/test/mutation.long.test.js @@ -7,13 +7,34 @@ // lance de vrais processus de Node, d'où sa place hors de la série // surveillée (§ 14.14). Le cas du délai vérifie qu'aucun processus de // l'épreuve ne reste après le délai, groupe entier compris. +// +// Puis l'audit entier, en ligne de commande : node scripts/mutation/audit.js +// dans un processus fils, sur un petit arbre planté dans un dossier +// temporaire — les scripts de l'audit recopiés, le module inventé de +// test/fixtures/mutation/arbre/ en src/moteur, une épreuve node qui laisse +// survivre des mutants, une épreuve longue qui en tue. Aucune épreuve +// n'importe audit.js (audit.test.js) : le processus fils l'exécute tel que +// make mutation le lance, sa racine étant celle de l'arbre. import assert from 'node:assert/strict'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { executerMutant } from '../scripts/mutation/executer.js'; import { mutants } from '../scripts/mutation/mutants.js'; +import { relire } from '../scripts/mutation/rapport.js'; import { describe, test } from './lanceur.js'; const RACINE = fileURLToPath(new URL('..', import.meta.url)); @@ -110,3 +131,133 @@ describe('executerMutant', () => { await assert.rejects(executerMutant({ mutant, tests: [EPREUVE], delaiMs: 0 }), RangeError); }); }); + +// Le petit arbre de l'audit : [chemin dans l'arbre, source sous la racine du +// projet]. Les scripts de l'audit sont recopiés, et non liés : un module lié +// se charge sous son chemin réel, et audit.js prendrait le projet pour racine. +const ARBRE = Object.freeze([ + ['scripts/version.js', 'scripts/version.js'], + ...['audit', 'crochets', 'executer', 'graphe', 'mutants', 'rapport'].map((nom) => [ + `scripts/mutation/${nom}.js`, + `scripts/mutation/${nom}.js`, + ]), + ['test/lanceur.js', 'test/lanceur.js'], + ['version.json', 'version.json'], + ['src/moteur/bornes.js', 'test/fixtures/mutation/arbre/bornes.js'], + ['src/moteur/bornes.test.js', 'test/fixtures/mutation/arbre/bornes.epreuve.js'], + ['src/moteur/bornes.long.test.js', 'test/fixtures/mutation/arbre/bornes.longue.js'], +]); + +// Le délai du processus fils : l'audit du petit arbre dure quelques +// secondes ; au-delà, l'épreuve échoue plutôt que d'attendre le délai de la +// série. +const DELAI_AUDIT_MS = 90_000; + +// Plante l'arbre dans un dossier temporaire neuf, node_modules lié à celui du +// projet (vite, dont mutants.js et executer.js lisent parseAst), et y lance +// l'audit ; rend le dossier, le code de sortie et la sortie d'erreur. +// preparer(dossier) modifie l'arbre avant l'audit. Le dossier s'efface par +// effacer(dossier). +function auditerArbre(preparer = () => {}) { + const dossier = mkdtempSync(join(tmpdir(), 'gtt-audit-')); + for (const [cible, source] of ARBRE) { + mkdirSync(dirname(join(dossier, cible)), { recursive: true }); + copyFileSync(join(RACINE, source), join(dossier, cible)); + } + writeFileSync(join(dossier, 'package.json'), '{ "type": "module" }\n'); + symlinkSync(join(RACINE, 'node_modules'), join(dossier, 'node_modules'), 'dir'); + preparer(dossier); + const fils = spawnSync(process.execPath, ['scripts/mutation/audit.js'], { + cwd: dossier, + encoding: 'utf8', + timeout: DELAI_AUDIT_MS, + killSignal: 'SIGKILL', + }); + return { dossier, code: fils.status, sortie: `${fils.stderr ?? ''}${fils.error ? `\n${fils.error.message}` : ''}` }; +} + +const effacer = (dossier) => rmSync(dossier, { recursive: true, force: true }); + +// Les mutants d'une liste du JSON, lus « ligne opérateur avant→après ». +const lus = (liste) => liste.map((m) => `${m.ligne} ${m.operateur} ${m.original}→${m.remplacement}`); + +describe('audit par mutation, en ligne de commande (§ 14.13)', () => { + test('un survivant que la série longue tue se compte à part ; l’audit sort en 0 avec un survivant, et écrit ses deux fichiers', () => { + const { dossier, code, sortie } = auditerArbre(); + try { + assert.equal(code, 0, sortie); + assert.match(sortie, /audit écrit : audit\/mutation-moteur\.md, audit\/mutation-moteur\.json/u); + const json = readFileSync(join(dossier, 'audit/mutation-moteur.json'), 'utf8'); + const markdown = readFileSync(join(dossier, 'audit/mutation-moteur.md'), 'utf8'); + const lu = JSON.parse(json); + assert.deepEqual(lu.entete.perimetre, ['src/moteur']); + assert.deepEqual(lu.bilan, { total: 7, tues: 3, tuesSerieLongue: 3, survivants: 1, equivalents: 0, delais: 0, erreurs: 0 }); + // n * 3 → n / 3 : l'épreuve node le laisse vivre, l'épreuve longue le tue. + assert.deepEqual(lus(lu.tuesSerieLongue), ['19 arithmetique *→/', '19 nombre-plus-un 3→4', '19 nombre-zero 3→0']); + assert.deepEqual( + lu.tuesSerieLongue.map(({ couverte, longue }) => [couverte, longue]), + [ + [true, 'tue'], + [true, 'tue'], + [true, 'tue'], + ], + ); + // a > b → a >= b : rejoué contre l'épreuve longue, qui atteint le + // module, il survit encore ; c'est le seul survivant compté. + assert.deepEqual(lus(lu.survivants), ['12 frontiere >→>=']); + assert.equal(lu.survivants[0].longue, 'survivant'); + assert.equal(relire(json).markdown, markdown); + } finally { + effacer(dossier); + } + }); + + test('une ligne de base rouge : sortie 2 (SORTIE_NE_MESURE_PAS), et aucun dossier audit/', () => { + const { dossier, code, sortie } = auditerArbre((arbre) => + appendFileSync( + join(arbre, 'src/moteur/bornes.test.js'), + "\ntest('une épreuve rouge avant tout mutant', () => {\n assert.equal(plusGrand(1, 2), 1);\n});\n", + ), + ); + try { + assert.equal(code, 2, sortie); + assert.match(sortie, /l’audit ne mesure pas : la série node du périmètre, non mutée, échoue/u); + assert.equal(existsSync(join(dossier, 'audit')), false, 'aucun dossier audit/'); + } finally { + effacer(dossier); + } + }); + + test('un mutant déclaré équivalent que la série longue tue reste compté tué par elle, et l’audit le signale', () => { + const source = readFileSync(join(RACINE, 'test/fixtures/mutation/arbre/bornes.js'), 'utf8'); + const [divise] = mutants(source, 'src/moteur/bornes.js').filter((m) => m.operateur === 'arithmetique'); + const { dossier, code, sortie } = auditerArbre((arbre) => { + mkdirSync(join(arbre, 'audit')); + writeFileSync(join(arbre, 'audit/mutants-equivalents.json'), `${JSON.stringify([{ cle: divise.cle, raison: 'déclaré à tort' }])}\n`); + }); + try { + assert.equal(code, 0, sortie); + assert.ok(sortie.includes(`déclaré équivalent, pourtant tué par la série longue : ${divise.cle}`), sortie); + const lu = JSON.parse(readFileSync(join(dossier, 'audit/mutation-moteur.json'), 'utf8')); + assert.equal(lu.bilan.equivalents, 0); + assert.ok(lu.tuesSerieLongue.some((m) => m.cle === divise.cle)); + } finally { + effacer(dossier); + } + }); + + test('des équivalents déclarés d’une autre forme : sortie 2, avant toute mesure, rien d’écrit', () => { + const { dossier, code, sortie } = auditerArbre((arbre) => { + mkdirSync(join(arbre, 'audit')); + writeFileSync(join(arbre, 'audit/mutants-equivalents.json'), '[{ "cle": "src/moteur/bornes.js:frontiere:0:0", "raison": "" }]\n'); + }); + try { + assert.equal(code, 2, sortie); + assert.match(sortie, /audit\/mutants-equivalents\.json : liste de \{ cle, raison \}, raison vide pour src\/moteur\/bornes\.js:frontiere:0:0/u); + assert.doesNotMatch(sortie, /ligne de base/u); + assert.deepEqual(readdirSync(join(dossier, 'audit')), ['mutants-equivalents.json']); + } finally { + effacer(dossier); + } + }); +});