@sylad/cadence 1.42.1 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/orchestrate/command-args.js +138 -0
- package/dist/orchestrate/command.js +21 -1618
- package/dist/orchestrate/cycle.js +3 -7
- package/dist/orchestrate/draw.js +62 -0
- package/dist/orchestrate/dry-run.js +223 -0
- package/dist/orchestrate/execute.js +421 -0
- package/dist/orchestrate/integrate.js +58 -4
- package/dist/orchestrate/pool.js +1 -1
- package/dist/orchestrate/preflight.js +190 -0
- package/dist/orchestrate/resume.js +384 -0
- package/dist/orchestrate/snapshot.js +6 -4
- package/dist/orchestrate/status.js +264 -0
- package/docs/orchestrate.md +14 -2
- package/docs/qualite.md +5 -0
- package/docs/whats-new.md +3 -0
- package/package.json +3 -1
- package/scripts/qualite.d.mts +15 -0
- package/scripts/qualite.mjs +352 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Garde-fous de qualité du code (L462) : mesure (ESLint + typescript-eslint + sonarjs, knip, jscpd) et cliquet.
|
|
2
|
+
// Le cliquet compare la mesure à qualite-baseline.json : l'existant passe, rien ne peut empirer, ce qui baisse se fige.
|
|
3
|
+
// Lancé par `npm run qualite` (porte orchestrate.build et CI) ; la recette est dans docs/qualite.md.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, globSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
export const BASELINE = 'qualite-baseline.json';
|
|
11
|
+
|
|
12
|
+
/** Où lire le chiffre d'une règle ESLint dans son message (« Maximum allowed is 400 » donne le seuil, pas la mesure). */
|
|
13
|
+
const MESURE = {
|
|
14
|
+
'max-lines': /too many lines \((\d+)\)/,
|
|
15
|
+
'max-lines-per-function': /too many lines \((\d+)\)/,
|
|
16
|
+
'max-params': /too many parameters \((\d+)\)/,
|
|
17
|
+
'max-depth': /nested too deeply \((\d+)\)/,
|
|
18
|
+
complexity: /complexity of (\d+)/,
|
|
19
|
+
'sonarjs/cognitive-complexity': /from (\d+) to the/,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// une règle sans chiffre dans son message (erreur d'analyse, directive inutile) pèse 1 par occurrence
|
|
23
|
+
const chiffre = (regle, message) => {
|
|
24
|
+
const m = MESURE[regle]?.exec(message);
|
|
25
|
+
return m ? Number(m[1]) : 1;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const ajouter = (mesure, cle, n) => { mesure[cle] = (mesure[cle] ?? 0) + n; };
|
|
29
|
+
const chemin = (racine, fichier) => relative(racine, fichier).split('\\').join('/');
|
|
30
|
+
|
|
31
|
+
/** Sortie JSON d'ESLint → par fichier et par règle : le nombre de dépassements et la somme des chiffres mesurés (`|total`). */
|
|
32
|
+
export function lireEslint(json, racine) {
|
|
33
|
+
const mesure = {};
|
|
34
|
+
for (const f of json) {
|
|
35
|
+
for (const m of f.messages) {
|
|
36
|
+
// ruleId nul : erreur d'analyse, ou directive eslint-disable inutile ; comptée, jamais ignorée
|
|
37
|
+
const regle = m.ruleId ?? 'analyse';
|
|
38
|
+
const base = `eslint|${chemin(racine, f.filePath)}|${regle}`;
|
|
39
|
+
ajouter(mesure, base, 1);
|
|
40
|
+
// la somme, pas le pire : une fonction qui grossit sans dépasser la pire du fichier fait monter le total
|
|
41
|
+
ajouter(mesure, `${base}|total`, chiffre(regle, m.message));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return mesure;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const TYPES_KNIP = ['dependencies', 'devDependencies', 'optionalPeerDependencies', 'unlisted', 'binaries', 'unresolved', 'exports', 'types', 'duplicates', 'catalog'];
|
|
48
|
+
|
|
49
|
+
/** Sortie JSON de knip → par fichier et par sorte de code mort : le nombre d'éléments. */
|
|
50
|
+
export function lireKnip(json, racine) {
|
|
51
|
+
const mesure = {};
|
|
52
|
+
for (const f of json.files ?? []) ajouter(mesure, `knip|${chemin(racine, f)}|files`, 1);
|
|
53
|
+
for (const i of json.issues ?? []) {
|
|
54
|
+
for (const t of TYPES_KNIP) if (i[t]?.length) ajouter(mesure, `knip|${i.file}|${t}`, i[t].length);
|
|
55
|
+
// enumMembers est un objet { enum: [membres] }, pas une liste
|
|
56
|
+
const membres = Object.values(i.enumMembers ?? {}).reduce((n, l) => n + l.length, 0);
|
|
57
|
+
if (membres) ajouter(mesure, `knip|${i.file}|enumMembers`, membres);
|
|
58
|
+
}
|
|
59
|
+
return mesure;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Rapport JSON de jscpd → par fichier, le nombre de blocs dupliqués qui le touchent et leur total de lignes (`|lignes`). */
|
|
63
|
+
export function lireJscpd(rapport) {
|
|
64
|
+
const mesure = {};
|
|
65
|
+
for (const d of rapport.duplicates ?? []) {
|
|
66
|
+
for (const nom of [d.firstFile.name, d.secondFile.name]) {
|
|
67
|
+
ajouter(mesure, `jscpd|${nom}`, 1);
|
|
68
|
+
ajouter(mesure, `jscpd|${nom}|lignes`, d.lines ?? 0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return mesure;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Les directives qui éteignent un contrôle au lieu de corriger le code : un fichier qui en gagne une empire, comme pour une règle.
|
|
75
|
+
// (ESLint les ignore déjà : eslint.config.mjs pose noInlineConfig ; on les compte aussi, pour que la tentative se voie.)
|
|
76
|
+
// Chaque motif exige le commentaire qui porte la directive : le même texte dans une chaîne, une regex ou une phrase n'éteint rien.
|
|
77
|
+
const DIRECTIVES = {
|
|
78
|
+
'eslint-disable': /\/[/*][ \t]*eslint-disable/g,
|
|
79
|
+
'jscpd-ignore': /\/[/*][ \t]*jscpd:ignore-(?:start|end)/g,
|
|
80
|
+
'knip-tag': /(?:\/\*\*|^[ \t]*\*)[^\n]*?@(?:public|internal|beta|alpha|alias|lintignore|knipignore)\b/gm,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Par fichier et par sorte de directive (`directive|fichier|nom`), le nombre d'occurrences — dans les seuls fichiers que
|
|
85
|
+
* l'outil concerné analyse (`perimetres[nom]` : chemins relatifs à `racine`). Une directive dans un fichier qu'ESLint ne
|
|
86
|
+
* lint pas, ou que .jscpd.json ignore, n'éteint rien : elle n'est pas comptée.
|
|
87
|
+
*/
|
|
88
|
+
export function lireDirectives(racine, perimetres) {
|
|
89
|
+
const mesure = {};
|
|
90
|
+
for (const [nom, re] of Object.entries(DIRECTIVES)) {
|
|
91
|
+
for (const f of new Set(perimetres[nom] ?? [])) {
|
|
92
|
+
const n = readFileSync(join(racine, f), 'utf8').match(re)?.length ?? 0;
|
|
93
|
+
if (n) ajouter(mesure, `directive|${f}|${nom}`, n);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return mesure;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Ce qui empire (une clé neuve ou plus haute que la base), ce qui baisse (plus bas ou disparu). */
|
|
100
|
+
export function comparer(base, courant) {
|
|
101
|
+
const pires = [];
|
|
102
|
+
const mieux = [];
|
|
103
|
+
for (const cle of new Set([...Object.keys(base), ...Object.keys(courant)])) {
|
|
104
|
+
const avant = base[cle] ?? 0;
|
|
105
|
+
const apres = courant[cle] ?? 0;
|
|
106
|
+
if (apres > avant) pires.push({ cle, avant, apres });
|
|
107
|
+
else if (apres < avant) mieux.push({ cle, avant, apres });
|
|
108
|
+
}
|
|
109
|
+
return { pires, mieux };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const cheminDeCle = (cle) => cle.split('|')[1];
|
|
113
|
+
// les clés `eslint|fichier|règle` et `jscpd|fichier` comptent des occurrences ; les autres portent le chiffre à additionner
|
|
114
|
+
const estCompteur = (cle) => { const p = cle.split('|'); return (p[0] === 'eslint' && p.length === 3) || (p[0] === 'jscpd' && p.length === 2); };
|
|
115
|
+
|
|
116
|
+
/** Le total de chaque mesure (outil, règle) sur tous les fichiers : ce que la ligne de base pèse en tout. */
|
|
117
|
+
function totaux(mesure) {
|
|
118
|
+
const t = {};
|
|
119
|
+
for (const [cle, n] of Object.entries(mesure)) {
|
|
120
|
+
if (estCompteur(cle)) continue;
|
|
121
|
+
const p = cle.split('|');
|
|
122
|
+
ajouter(t, [p[0], ...p.slice(2)].join('|'), n);
|
|
123
|
+
}
|
|
124
|
+
return t;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Le passage de `avant` à `apres` quand des fichiers ont été déplacés ou découpés : une clé d'un fichier qui existait
|
|
129
|
+
* (`existe(chemin)`) ne peut pas monter, un chemin neuf part libre, mais le total de chaque mesure sur tous les fichiers
|
|
130
|
+
* ne peut pas monter. Rend ce qui empire. Découper un fichier de 3000 lignes en trois de 1000 passe ; en déplacer
|
|
131
|
+
* le défaut dans un fichier qui existait, ou en ajouter, non.
|
|
132
|
+
*/
|
|
133
|
+
export function comparerMigration(avant, apres, existe) {
|
|
134
|
+
const pires = comparer(avant, Object.fromEntries(Object.entries(apres).filter(([cle]) => existe(cheminDeCle(cle))))).pires;
|
|
135
|
+
const ta = totaux(avant);
|
|
136
|
+
const tb = totaux(apres);
|
|
137
|
+
for (const cle of Object.keys(tb)) if (tb[cle] > (ta[cle] ?? 0)) pires.push({ cle: `${cle} (total, tous fichiers)`, avant: ta[cle] ?? 0, apres: tb[cle] });
|
|
138
|
+
return pires;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const trier = (mesure) => Object.fromEntries(Object.entries(mesure).sort(([a], [b]) => (a < b ? -1 : 1)));
|
|
142
|
+
|
|
143
|
+
export const lireBase = (racine) => JSON.parse(readFileSync(join(racine, BASELINE), 'utf8'));
|
|
144
|
+
/** Le texte de la ligne de base : trié par clé, JSON indenté de 2 espaces, terminé par un saut de ligne. */
|
|
145
|
+
export const serialiser = (mesure) => `${JSON.stringify(trier(mesure), null, 2)}\n`;
|
|
146
|
+
const ecrireBase = (racine, mesure) => writeFileSync(join(racine, BASELINE), serialiser(mesure));
|
|
147
|
+
|
|
148
|
+
function lancer(racine, bin, args, accepte) {
|
|
149
|
+
// un outil absent ferait sortir node avec 1 et rien sur stdout, que le JSON.parse d'après lirait comme « Unexpected end of JSON input »
|
|
150
|
+
if (!existsSync(join(racine, 'node_modules', bin))) throw new Error(`${bin.split('/')[0]} est absent de node_modules (${racine}) : lancer « npm install » puis relancer.`);
|
|
151
|
+
const r = spawnSync(process.execPath, [join(racine, 'node_modules', bin), ...args], { cwd: racine, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
152
|
+
// un outil qui plante ne doit jamais passer pour « zéro défaut »
|
|
153
|
+
if (r.error || !accepte.includes(r.status)) throw new Error(`${bin} a échoué (code ${r.status}) : ${r.error?.message ?? r.stderr.slice(0, 500)}`);
|
|
154
|
+
return r.stdout;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const lireJson = (racine, f) => JSON.parse(readFileSync(join(racine, f), 'utf8'));
|
|
158
|
+
|
|
159
|
+
/** Les fichiers que chaque outil a analysés : ESLint et jscpd les listent dans leur sortie, knip se lit dans ses globs `project` moins `ignore`. */
|
|
160
|
+
function perimetres(racine, eslint, jscpd) {
|
|
161
|
+
const knip = lireJson(racine, 'knip.json');
|
|
162
|
+
const sources = Object.values(jscpd.statistics?.formats ?? {}).flatMap((f) => Object.keys(f.sources ?? {}));
|
|
163
|
+
return {
|
|
164
|
+
'eslint-disable': eslint.map((f) => chemin(racine, f.filePath)),
|
|
165
|
+
'jscpd-ignore': sources.map((f) => chemin(racine, f)),
|
|
166
|
+
'knip-tag': globSync(knip.project ?? [], { cwd: racine, exclude: [...(knip.ignore ?? []), 'node_modules/**'] }).map((f) => f.split('\\').join('/')),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Joue les trois outils dans `racine` (qui porte eslint.config.mjs, knip.json et .jscpd.json) et rend la mesure à plat. */
|
|
171
|
+
export function mesurer(racine) {
|
|
172
|
+
const eslint = JSON.parse(lancer(racine, 'eslint/bin/eslint.js', ['-f', 'json', '.'], [0, 1]));
|
|
173
|
+
const knip = JSON.parse(lancer(racine, 'knip/bin/knip.js', ['--reporter', 'json'], [0, 1]));
|
|
174
|
+
const sortie = mkdtempSync(join(tmpdir(), 'qualite-jscpd-'));
|
|
175
|
+
try {
|
|
176
|
+
lancer(racine, 'jscpd/bin/jscpd', ['-c', '.jscpd.json', '--output', sortie], [0]);
|
|
177
|
+
const jscpd = lireJson(sortie, 'jscpd-report.json');
|
|
178
|
+
return trier({ ...lireEslint(eslint, racine), ...lireKnip(knip, racine), ...lireJscpd(jscpd), ...lireDirectives(racine, perimetres(racine, eslint, jscpd)) });
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(sortie, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Le point de départ du lot : au merge-base de HEAD et de `ref`, la ligne de base (null si elle n'y existait pas encore), `existe(chemin)` (vrai pour un fichier qui y était) et `config(chemin)` (son contenu, null s'il n'y était pas). */
|
|
185
|
+
export function lireRef(racine, ref) {
|
|
186
|
+
const git = (...args) => spawnSync('git', args, { cwd: racine, encoding: 'utf8' });
|
|
187
|
+
const mb = git('merge-base', 'HEAD', ref);
|
|
188
|
+
if (mb.status !== 0) throw new Error(`référence « ${ref} » introuvable (git merge-base HEAD ${ref}) : ${mb.stderr.trim() || 'pas de dépôt git ?'}`);
|
|
189
|
+
const sha = mb.stdout.trim();
|
|
190
|
+
const show = git('show', `${sha}:${BASELINE}`);
|
|
191
|
+
const existe = (chemin) => git('cat-file', '-e', `${sha}:${chemin}`).status === 0;
|
|
192
|
+
const config = (chemin) => { const r = git('show', `${sha}:${chemin}`); return r.status === 0 ? r.stdout : null; };
|
|
193
|
+
const fichiers = () => git('ls-tree', '-r', '-z', '--name-only', sha).stdout.split('\0').filter(Boolean);
|
|
194
|
+
return { nom: `${ref} (${sha.slice(0, 7)})`, base: show.status === 0 ? JSON.parse(show.stdout) : null, existe, config, fichiers };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Les seuils et les exclusions des trois outils : les relever ferait baisser la mesure sans corriger le code. Tout ce que les
|
|
198
|
+
// outils lisent est comparé, pas seulement le fichier que ce dépôt utilise : ESLint 9 charge eslint.config.js AVANT .mjs, knip
|
|
199
|
+
// lit huit noms et package.json#knip, jscpd lit package.json#jscpd, et knip comme jscpd respectent .gitignore.
|
|
200
|
+
const CONFIGS = [
|
|
201
|
+
...['js', 'mjs', 'cjs', 'ts', 'mts', 'cts'].map((e) => `eslint.config.${e}`),
|
|
202
|
+
'knip.json', 'knip.jsonc', '.knip.json', '.knip.jsonc', 'knip.ts', 'knip.js', 'knip.config.ts', 'knip.config.js',
|
|
203
|
+
'.jscpd.json',
|
|
204
|
+
'.gitignore',
|
|
205
|
+
// knip lit la config de vitest (ses entrées, son plugin) et tsconfig.json (ses chemins, son périmètre) ; vitest se rabat sur vite.config.*
|
|
206
|
+
...['ts', 'mts', 'cts', 'js', 'mjs', 'cjs'].flatMap((e) => [`vitest.config.${e}`, `vitest.workspace.${e}`, `vite.config.${e}`]),
|
|
207
|
+
'tsconfig.json',
|
|
208
|
+
];
|
|
209
|
+
// package.json : knip y lit ses points d'entrée (scripts, main, bin, exports) ; jscpd et knip leur config
|
|
210
|
+
const CLES_PACKAGE = ['knip', 'jscpd', 'scripts', 'main', 'bin', 'exports'];
|
|
211
|
+
|
|
212
|
+
// la valeur d'une clé de package.json, sérialisée pour la comparer ; null si le fichier manque, est illisible ou n'a pas la clé
|
|
213
|
+
const section = (texte, cle) => {
|
|
214
|
+
if (texte === null) return null;
|
|
215
|
+
try { const v = JSON.parse(texte)[cle]; return v === undefined ? null : JSON.stringify(v); } catch { return null; }
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const lireSiPresent = (racine, f) => (existsSync(join(racine, f)) ? readFileSync(join(racine, f), 'utf8') : null);
|
|
219
|
+
const etat = (depart, actuel) => (depart === null ? 'nouveau' : actuel === null ? 'supprimé' : 'modifié');
|
|
220
|
+
|
|
221
|
+
// les libellés « fichier (nouveau|supprimé|modifié) » des couples (nom, départ, actuel) qui diffèrent
|
|
222
|
+
const differences = (couples) => couples.filter(([, depart, actuel]) => depart !== actuel).map(([nom, depart, actuel]) => `${nom} (${etat(depart, actuel)})`);
|
|
223
|
+
|
|
224
|
+
// les .gitignore imbriqués (src/.gitignore…), ceux du départ comme ceux d'aujourd'hui ; node_modules et .git ne sont pas parcourus
|
|
225
|
+
const gitignoresImbriques = (racine, ref) => {
|
|
226
|
+
const ici = [];
|
|
227
|
+
const parcourir = (rel) => {
|
|
228
|
+
for (const e of readdirSync(join(racine, rel), { withFileTypes: true })) {
|
|
229
|
+
const chemin = rel ? `${rel}/${e.name}` : e.name;
|
|
230
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== '.git') parcourir(chemin); } else if (e.name === '.gitignore') ici.push(chemin);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
parcourir('');
|
|
234
|
+
// un .gitignore posé dans un dossier ignoré par une règle d'AILLEURS (généré, non suivi) ne change rien à ce que knip et jscpd
|
|
235
|
+
// mesurent ; celui qui s'ignore lui-même (`*`) est une vraie règle neuve et reste compté. check-ignore -v cite aussi le chemin
|
|
236
|
+
// qu'un motif de négation (`!.gitignore`) remet dans le dépôt : lui n'est PAS ignoré, il reste compté
|
|
237
|
+
const ignores = ici.length ? spawnSync('git', ['check-ignore', '-v', '-z', '--stdin'], { cwd: racine, input: ici.join('\0'), encoding: 'utf8' }) : null;
|
|
238
|
+
const exclus = new Set();
|
|
239
|
+
if (ignores?.status === 0) {
|
|
240
|
+
const champs = ignores.stdout.split('\0'); // source, ligne, motif, chemin
|
|
241
|
+
for (let i = 0; i + 3 < champs.length; i += 4) if (champs[i] !== champs[i + 3] && !champs[i + 2].startsWith('!')) exclus.add(champs[i + 3]);
|
|
242
|
+
}
|
|
243
|
+
const depart = (ref.fichiers?.() ?? []).filter((f) => basename(f) === '.gitignore');
|
|
244
|
+
return [...new Set([...ici.filter((f) => !exclus.has(f)), ...depart])].filter((f) => f !== '.gitignore').sort();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const fichiersChanges = (racine, ref) => differences([...CONFIGS, ...gitignoresImbriques(racine, ref)].map((f) => [f, ref.config(f), lireSiPresent(racine, f)]));
|
|
248
|
+
|
|
249
|
+
function clesPackageChangees(racine, ref) {
|
|
250
|
+
const depart = ref.config('package.json');
|
|
251
|
+
const actuel = lireSiPresent(racine, 'package.json');
|
|
252
|
+
return differences(CLES_PACKAGE.map((cle) => [`package.json#${cle}`, section(depart, cle), section(actuel, cle)]));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Refus (code 1) si une config que les outils lisent diffère de celle du point de départ du lot (apparue, disparue ou modifiée), null sinon. */
|
|
256
|
+
function configChangee(racine, ref) {
|
|
257
|
+
// sans base au départ, rien n'est encore jugé par ces configs : le lot qui adopte les outils les introduit librement
|
|
258
|
+
if (!ref?.config || !ref.base) return null;
|
|
259
|
+
const changees = [...fichiersChanges(racine, ref), ...clesPackageChangees(racine, ref)];
|
|
260
|
+
if (!changees.length) return null;
|
|
261
|
+
return { code: 1, texte: `${changees.join(', ')} diffère(nt) de ${ref.nom} : un seuil ou une exclusion ne se change pas dans un lot qui est jugé par eux (corriger le code ; changer une config est une décision du plan, livrée seule).` };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Les motifs actifs de .git/info/exclude (hors commentaires et lignes vides) ; vide hors dépôt git ou sans ce fichier. */
|
|
265
|
+
function exclusLocaux(racine) {
|
|
266
|
+
const r = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: racine, encoding: 'utf8' });
|
|
267
|
+
const chemin = r.status === 0 ? resolve(racine, r.stdout.trim()) : null;
|
|
268
|
+
if (!chemin || !existsSync(chemin)) return [];
|
|
269
|
+
return readFileSync(chemin, 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const ligne = ({ cle, avant, apres }) => ` ${cle} : ${avant} → ${apres}`;
|
|
273
|
+
|
|
274
|
+
const existeAuDepart = (ref) => ref.existe ?? (() => true);
|
|
275
|
+
|
|
276
|
+
/** La base du lot contre celle du départ : refus (code 1) si elle monte, null sinon. */
|
|
277
|
+
function baseRemonte(base, ref) {
|
|
278
|
+
const remonte = ref?.base ? comparerMigration(ref.base, base, existeAuDepart(ref)) : [];
|
|
279
|
+
if (!remonte.length) return null;
|
|
280
|
+
return { code: 1, texte: `${BASELINE} remonte par rapport à ${ref.nom} (elle ne peut que baisser : corriger le code, pas la base) :\n${remonte.map(ligne).join('\n')}` };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** `--migrer` : réécrit la base pour une mesure où des fichiers ont été déplacés ou découpés. */
|
|
284
|
+
function migrer(racine, base, mesure, ref) {
|
|
285
|
+
if (!ref) return { code: 2, texte: '--migrer exige --contre <ref> : les fichiers « qui existaient » sont ceux du point de départ du lot.' };
|
|
286
|
+
const empire = comparerMigration(base, mesure, existeAuDepart(ref));
|
|
287
|
+
if (empire.length) return { code: 1, texte: `Qualité : la migration empire ${empire.length} mesure(s) (ni un fichier qui existait ni le total ne peuvent monter) :\n${empire.map(ligne).join('\n')}` };
|
|
288
|
+
ecrireBase(racine, mesure);
|
|
289
|
+
return { code: 0, texte: `${BASELINE} migrée : ${Object.keys(mesure).length} mesures, aucun total en hausse.` };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Refus (code 1) qui nomme ce qui empire ; sans base au point de départ, il propose la voie d'amorçage plutôt que de corriger le code des autres. */
|
|
293
|
+
function empire(pires, ref) {
|
|
294
|
+
// la base locale date d'alors d'avant les commits de main : --init --contre la re-mesure (docs/qualite.md)
|
|
295
|
+
const voie = ref && !ref.base ? `\n${ref.nom} n'a pas encore de ${BASELINE} : si ces hausses viennent de ses commits (merge ou rebase), « node scripts/qualite.mjs --init --contre ${ref.nom.split(' ')[0]} » re-mesure la base au sommet courant.` : '';
|
|
296
|
+
return { code: 1, texte: `Qualité : ${pires.length} mesure(s) empirent (rien ne peut empirer, un fichier touché ne peut que baisser) :\n${pires.map(ligne).join('\n')}${voie}` };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Compare la mesure à la base. mode : 'verifier' (défaut), 'init' (crée la base), 'ecrire' (la baisse) ou 'migrer'
|
|
301
|
+
* (réécrit la base après un déplacement ou un découpage de fichiers, voir comparerMigration ; exige `ref`). Rend { code, texte }.
|
|
302
|
+
* `ref` (voir lireRef) : la base du point de départ du lot ; la base du lot ne peut pas la dépasser, sinon une session
|
|
303
|
+
* « réparerait » la base au lieu du code et la porte comme la CI resteraient vertes.
|
|
304
|
+
*/
|
|
305
|
+
export function appliquer(racine, mode, mesure = mesurer(racine), ref = null) {
|
|
306
|
+
const resultat = decider(racine, mode, mesure, ref);
|
|
307
|
+
const exclus = exclusLocaux(racine);
|
|
308
|
+
return exclus.length ? { ...resultat, texte: `${resultat.texte}\nAvertissement : .git/info/exclude n'est pas vide (${exclus.slice(0, 3).join(', ')}${exclus.length > 3 ? `, … ${exclus.length - 3} autre(s)` : ''}) ; il n'est pas versionné et ESLint, knip et jscpd ne le comparent pas au départ : ces chemins échappent peut-être à la mesure (docs/qualite.md).` } : resultat;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function decider(racine, mode, mesure, ref) {
|
|
312
|
+
const existe = existsSync(join(racine, BASELINE));
|
|
313
|
+
const config = configChangee(racine, ref);
|
|
314
|
+
if (config) return config;
|
|
315
|
+
if (mode === 'init') {
|
|
316
|
+
if (ref?.base) return { code: 2, texte: `${BASELINE} existe déjà dans ${ref.nom} : --init ne la recrée pas (elle ne peut que baisser, utiliser --ecrire).` };
|
|
317
|
+
// sans base au départ (main n'en a pas encore), il n'y a rien à ne pas dépasser : on l'amorce, y compris pour re-mesurer après un rebase
|
|
318
|
+
if (existe && !ref) return { code: 2, texte: `${BASELINE} existe déjà : --init ne la recrée pas (elle ne peut que baisser, utiliser --ecrire).` };
|
|
319
|
+
ecrireBase(racine, mesure);
|
|
320
|
+
return { code: 0, texte: `${BASELINE} créée : ${Object.keys(mesure).length} mesures figées.` };
|
|
321
|
+
}
|
|
322
|
+
if (!existe) return { code: 2, texte: `${BASELINE} absente : lancer « node scripts/qualite.mjs --init » une fois, puis la versionner.` };
|
|
323
|
+
const base = lireBase(racine);
|
|
324
|
+
const refus = baseRemonte(base, ref);
|
|
325
|
+
if (refus) return refus;
|
|
326
|
+
if (mode === 'migrer') return migrer(racine, base, mesure, ref);
|
|
327
|
+
const { pires, mieux } = comparer(base, mesure);
|
|
328
|
+
if (pires.length) return empire(pires, ref);
|
|
329
|
+
if (mode === 'ecrire') {
|
|
330
|
+
ecrireBase(racine, mesure);
|
|
331
|
+
return { code: 0, texte: `${BASELINE} baissée : ${mieux.length} mesure(s) en moins.` };
|
|
332
|
+
}
|
|
333
|
+
const reste = mieux.length ? `\n${mieux.length} mesure(s) ont baissé : « npm run qualite:cliquet » fige le gain.` : '';
|
|
334
|
+
return { code: 0, texte: `Qualité : rien n'empire (${Object.keys(mesure).length} mesures sous la ligne de base).${reste}` };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
338
|
+
const args = process.argv.slice(2);
|
|
339
|
+
const i = args.indexOf('--contre');
|
|
340
|
+
const ref = i >= 0 ? args.splice(i, 2)[1] : null;
|
|
341
|
+
const arg = args[0];
|
|
342
|
+
const mode = { '--init': 'init', '--ecrire': 'ecrire', '--migrer': 'migrer' }[arg ?? ''];
|
|
343
|
+
if ((arg && !mode) || args.length > 1 || (i >= 0 && !ref)) { console.error('usage : node scripts/qualite.mjs [--init | --ecrire | --migrer] [--contre <ref>]'); process.exit(2); }
|
|
344
|
+
try {
|
|
345
|
+
const { code, texte } = appliquer(process.cwd(), mode ?? 'verifier', undefined, ref ? lireRef(process.cwd(), ref) : null);
|
|
346
|
+
(code ? console.error : console.log)(texte);
|
|
347
|
+
process.exit(code);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
console.error(e.message);
|
|
350
|
+
process.exit(2);
|
|
351
|
+
}
|
|
352
|
+
}
|