[IMP] mutation: count long-series kills apart, test the audit end to end

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
This commit is contained in:
Mathieu Benoit 2026-10-10 18:51:43 -04:00
parent 171de56c4e
commit aaa2c1a3a7
9 changed files with 886 additions and 372 deletions

View file

@ -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",

View file

@ -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.

View file

@ -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:<ligne>,<compte>
@ -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;

View file

@ -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<string, string>}
*/
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;
}

View file

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

View file

@ -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');
});
});

20
test/fixtures/mutation/arbre/bornes.js vendored Normal file
View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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);
}
});
});