@sylad/cadence 1.34.1 → 1.35.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.
@@ -18,8 +18,11 @@ import { citedRefs } from '../link.js';
18
18
  import { repoShas, repoWork } from '../repos.js';
19
19
  import { isOpen, RafError } from '../plan.js';
20
20
  import { isPlanOnly } from '../audit.js';
21
- import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
22
- import { replayRed, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
21
+ import { compareBaseline, failedTests } from './baseline.js';
22
+ import { noteTight, noteUnstable } from './gate-notes.js';
23
+ import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
24
+ import { baselineCandidates, gateLabel, gateReplayable, gateUnnamed, redAloneGate, redCandidates, replayGate, runGate } from './gate-parts.js';
25
+ import { withPlace } from './gate-place.js';
23
26
  import { APP_TIMEOUT_S, startApp } from './app.js';
24
27
  import { capturesText, gateText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tiersText, tmpText } from './briefs.js';
25
28
  import { SlotAbandoned } from './registry.js';
@@ -196,33 +199,49 @@ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lo
196
199
  * pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Avec `orchestrate.testReplay`, un rouge nommé y est rejoué seul (L354) avant d'être retenu.
197
200
  * Null quand le worktree ne peut être monté : l'appelant garde alors les tests pour rouges.
198
201
  */
199
- async function baselineFailures(c, command) {
202
+ async function baselineFailures(c, test) {
200
203
  const l = c.lot;
201
204
  const sha = startSha(c);
205
+ const command = gateLabel(test); // clé de cache ; pour des parties (L512) le libellé « dir : run ; … », stable d'une lecture à l'autre
202
206
  if (!sha)
203
207
  return null;
204
208
  if (l.baseline?.verified && l.baseline.sha === sha && l.baseline.command === command)
205
209
  return l.baseline.failures;
206
- const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
207
- const links = [];
208
- try {
209
- git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
210
- links.push(...linkIgnored(l.repo, dir));
211
- // Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
212
- // ne dit rien des tests : pas de ligne de base, l'appelant garde les tests pour rouges.
213
- if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
214
- return null;
215
- const r = await sh(c, command, 30 * 60_000, {}, dir);
210
+ return atStartSha(c, sha, async (place) => {
211
+ const r = await runGate(place.run, test);
216
212
  // Ligne de base comprise (L354) : un rouge du sha de départ est rejoué seul dans le même worktree avant d'être retenu. Sous charge, un test
217
- // qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel.
213
+ // qui passe seul au départ rougirait ici et excuserait une vraie régression du lot (« déjà rouge au départ »). Un échec sans nom reste tel quel ; en parties (L512), partie par partie.
218
214
  const found = r.code === 0 ? [] : failedTests(r.output);
219
- const named = c.config.testReplay && found.length && !hasUnnamedFailure(r.output) ? found : [];
220
- const { red: failures, unstable } = named.length ? await replayAlone(c, c.config.testReplay, named, r.output, dir) : { red: found, unstable: [] };
215
+ const named = gateReplayable(test, c.config.testReplay) && found.length ? baselineCandidates(r) : [];
216
+ const { red: failures, unstable } = named.length ? await replayGate(place.run, c.config.testReplay, named, r) : { red: found, unstable: [] };
221
217
  if (unstable.length)
222
218
  c.wave.log(`${lotKey(l.project, l.lot)} · ligne de base ${sha.slice(0, 7)} : ${unstable.length} test(s) rouge(s) sous charge, vert(s) rejoué(s) seul(s), non retenu(s) comme déjà rouge(s) : ${unstable.join(' ; ')}`);
223
219
  l.baseline = { sha, command, failures, verified: true };
224
220
  save(c);
225
221
  return failures;
222
+ });
223
+ }
224
+ /** Parmi les tests rouges `names` du lot, ceux rouges SEULS aussi au sha de départ (L510) : la suite entière du départ a pu les voir verts (ordre, charge), ils n'en sont pas moins déjà rouges avant le lot. Vide sans `testReplay`, sans sha de départ, build du départ rouge ou worktree non monté. */
225
+ async function redAtStart(c, names, ran) {
226
+ const sha = startSha(c);
227
+ return !sha || !gateReplayable(c.config.test, c.config.testReplay) || !names.length ? [] : (await atStartSha(c, sha, (place) => redAloneGate(place.run, c.config.testReplay, names, ran))) ?? [];
228
+ }
229
+ /** Joue `fn` dans un worktree jetable du sha de départ (ligne de base L213, rouges au départ L510), liens vers les dossiers ignorés posés puis retirés avec lui. Null si le worktree ne se monte pas, si le build du départ est rouge ou si `fn` lève. */
230
+ async function atStartSha(c, sha, fn) {
231
+ const l = c.lot;
232
+ const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
233
+ const links = [];
234
+ try {
235
+ git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
236
+ links.push(...linkIgnored(l.repo, dir));
237
+ // Sur l'hôte `gate.host` quand il y en a un (L403) ; le worktree local reste le lieu du rejeu si la connexion tombe.
238
+ return await withPlace({ config: c.config, repo: l.repo, sha, dir, needClean: false, label: `porte du lot ${l.lot}`, detail: `(ligne de base ${sha.slice(0, 7)})`, local: (cmd) => sh(c, cmd, 30 * 60_000, {}, dir), say: (line) => c.wave.log(`${lotKey(l.project, l.lot)} · ${line}`) }, async (place) => {
239
+ // Le build d'abord, comme dans la porte (L333) : les tests lisent ce qu'il produit (`dist`), le départ sans lui rougirait tout. Un build rouge au départ
240
+ // ne dit rien des tests : rien à rendre, l'appelant garde les tests pour rouges.
241
+ if (c.config.build && (await place.run(c.config.build)).code !== 0)
242
+ return null;
243
+ return fn(place);
244
+ });
226
245
  }
227
246
  catch {
228
247
  return null;
@@ -246,9 +265,10 @@ async function baselineFailures(c, command) {
246
265
  }
247
266
  }
248
267
  /** Des tests rouges comparés à la ligne de base (L213) : tous déjà rouges au départ, ou au moins un nouveau, ou rien de comparable. */
249
- async function againstBaseline(c, command, output) {
250
- const now = failedTests(output);
251
- if (!now.length || hasUnnamedFailure(output))
268
+ async function againstBaseline(c, command, ran) {
269
+ const now = failedTests(ran.output);
270
+ // Un échec sans test nommé (en chaîne, ou dans une partie rouge, L512) : rien n'est comparable ni excusable, les tests restent rouges.
271
+ if (!now.length || gateUnnamed(ran))
252
272
  return { kind: 'unknown' };
253
273
  const base = await baselineFailures(c, command);
254
274
  if (!base?.length)
@@ -1829,7 +1849,7 @@ async function acceptProblems(c, fiche, json, headBefore) {
1829
1849
  l.warnings.push(NO_TEST_ACCEPT);
1830
1850
  }
1831
1851
  else {
1832
- const ordinary = await sh(c, c.config.test);
1852
+ const ordinary = await runGate((cmd) => sh(c, cmd), c.config.test); // porte en parties (L512) : chaque partie dans son dossier, sortie fusionnée
1833
1853
  if (ordinary.code !== 0)
1834
1854
  out.push(`orchestrate.test échoue (code ${ordinary.code}) avec les tests rouges commités : les fichiers listés dans ${json} ne sont pas exclus de la config du lanceur (partagée avec la CI) tant que vert est false\n${tail(ordinary.output, 15)}`);
1835
1855
  }
@@ -1980,7 +2000,8 @@ async function acceptGreen(c) {
1980
2000
  */
1981
2001
  async function programChecks(c, red, runs) {
1982
2002
  try {
1983
- await runChecks(c, red, runs);
2003
+ // Sur l'hôte `gate.host` (L403) quand il y en a un : le HEAD du worktree du lot, s'il est propre ; sinon, ou sans hôte, en local.
2004
+ await withPlace({ config: c.config, repo: c.lot.repo, sha: tryGit(wd(c), ['rev-parse', 'HEAD']), dir: wd(c), needClean: true, label: `porte du lot ${c.lot.lot}`, local: (cmd) => sh(c, cmd), say: (line) => c.wave.log(`${lotKey(c.lot.project, c.lot.lot)} · ${line}`) }, (place) => runChecks(c, red, runs, place));
1984
2005
  }
1985
2006
  finally {
1986
2007
  // L395 : la porte se lit dans l'état du lot tant qu'une commande (ou le rejeu qui la suit) tourne, jamais après.
@@ -1988,19 +2009,19 @@ async function programChecks(c, red, runs) {
1988
2009
  save(c);
1989
2010
  }
1990
2011
  }
1991
- async function runChecks(c, red, runs) {
2012
+ async function runChecks(c, red, runs, place) {
1992
2013
  const l = c.lot;
1993
2014
  for (const [label, command] of [['build', c.config.build], ['tests', c.config.test]]) {
1994
2015
  if (!command)
1995
2016
  continue;
1996
2017
  l.gate = { label, started: new Date().toISOString() };
1997
2018
  save(c);
1998
- const r = await sh(c, command);
1999
- const run = { label, command, code: r.code };
2019
+ const r = await runGate(place.run, command); // porte en parties (L512) : toutes jouées, chacune dans son dossier ; le CheckRun porte le libellé « dir : run ; … »
2020
+ const run = { label, command: gateLabel(command), code: r.code, ...place.stamp() };
2000
2021
  runs.push(run);
2001
2022
  if (r.code === 0)
2002
2023
  continue;
2003
- const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
2024
+ const vs = label === 'tests' ? await againstBaseline(c, command, r) : { kind: 'unknown' };
2004
2025
  const onlyExisting = (n) => {
2005
2026
  run.dejaRouges = n;
2006
2027
  const note = `${n} test(s) déjà rouge(s) au sha de départ ${(l.baseline?.sha ?? '').slice(0, 7)}, pas un défaut du lot : listés à part, la revue a lieu`;
@@ -2013,11 +2034,11 @@ async function runChecks(c, red, runs) {
2013
2034
  }
2014
2035
  // Un rouge absent de la ligne de base est rejoué SEUL avant d'être compté (L354) : sous charge, des tests qui passent seuls rougissent ensemble.
2015
2036
  // Sans `orchestrate.testReplay` rien n'est rejoué : le rouge est compté tel quel et rien n'est tenu pour instable (L354).
2016
- const replay = label === 'tests' ? c.config.testReplay : undefined;
2017
- const named = !replay ? [] : vs.kind === 'new' ? vs.nouveaux : !hasUnnamedFailure(r.output) ? failedTests(r.output) : [];
2018
- const { red: still, unstable } = named.length ? await replayAlone(c, replay, named, r.output) : { red: [], unstable: [] };
2037
+ const named = label !== 'tests' || !gateReplayable(c.config.test, c.config.testReplay) ? [] : vs.kind === 'new' ? vs.nouveaux : redCandidates(r);
2038
+ const { red: still, unstable, tight } = named.length ? await replayGate(place.run, c.config.testReplay, named, r) : { red: [], unstable: [], tight: [] };
2019
2039
  if (unstable.length) {
2020
- noteUnstable(c, unstable);
2040
+ noteUnstable(c, unstable.filter((t) => !tight.some((k) => k.test === t)));
2041
+ noteTight(c, tight);
2021
2042
  run.instables = unstable;
2022
2043
  }
2023
2044
  if (unstable.length && !still.length) {
@@ -2027,37 +2048,30 @@ async function runChecks(c, red, runs) {
2027
2048
  run.code = 0;
2028
2049
  continue;
2029
2050
  }
2051
+ // L510 : un rouge qui reste rouge seul dans le lot mais que la suite entière du départ avait vu vert (ordre, charge) peut être rouge seul au départ aussi : déjà rouge, ni correction ni escalade.
2052
+ const atStart = still.length ? await redAtStart(c, still, r) : [];
2053
+ if (atStart.length) {
2054
+ const note = `${atStart.length} test(s) déjà rouge(s) au sha de départ ${startSha(c)?.slice(0, 7)} (rouge(s) seuls aussi, absent(s) de la ligne de base), pas un défaut du lot : ${atStart.join(' ; ')}`;
2055
+ if (!l.warnings.includes(note))
2056
+ l.warnings.push(note);
2057
+ if (atStart.length === still.length) {
2058
+ run.dejaRouges = atStart.length + (vs.kind === 'new' ? vs.existants : 0);
2059
+ continue;
2060
+ }
2061
+ }
2062
+ const fresh = still.filter((t) => !atStart.includes(t));
2030
2063
  const alone = unstable.length ? `\n(${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls : instables, à ne pas corriger)` : '';
2031
2064
  if (vs.kind === 'new') {
2032
- const left = vs.nouveaux.filter((t) => !unstable.includes(t));
2033
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
2065
+ const left = vs.nouveaux.filter((t) => !unstable.includes(t) && !atStart.includes(t));
2066
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${run.command} en échec (code ${r.code}) : ${left.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${left.map((t) => `- ${t}`).join('\n')}${vs.existants ? `\n(${vs.existants} autre(s) test(s) déjà rouge(s) au sha de départ : à ne pas corriger)` : ''}${alone}` });
2034
2067
  return;
2035
2068
  }
2036
2069
  // Des instables sont écartés : la sortie de la suite entière les montre encore en FAIL, le constat ne nomme que ceux qui restent rouges.
2037
- const shown = unstable.length ? `${still.length} test(s) rouge(s) après rejeu seul :\n${still.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
2038
- red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${shown}${alone}` });
2070
+ const shown = unstable.length || atStart.length ? `${fresh.length} test(s) rouge(s) après rejeu seul :\n${fresh.map((t) => `- ${t}`).join('\n')}` : r.output.trim().split('\n').slice(-40).join('\n');
2071
+ red.push({ source: 'tests', gravite: 'bloquant', texte: `${run.command} en échec (code ${r.code}) :\n${shown}${alone}` });
2039
2072
  return;
2040
2073
  }
2041
2074
  }
2042
- /**
2043
- * Rejoue SEULS (la commande `orchestrate.testReplay` où `{files}` devient les fichiers de ces tests, dans `cwd` : le worktree du lot, ou celui de la ligne de base) les tests rouges d'une suite entière (L354). Le 26-10, vitest à 31 workers par suite sur une machine à 4 sessions rougissait des tests
2044
- * qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`. Le rejeu lui-même est `replayRed` (workers.ts), partagé avec `verify --gate` (L387).
2045
- */
2046
- function replayAlone(c, template, names, output, cwd = wd(c)) {
2047
- return replayRed((command) => sh(c, command, undefined, undefined, cwd), template, names, output);
2048
- }
2049
- /** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
2050
- function noteUnstable(c, tests) {
2051
- const l = c.lot;
2052
- const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
2053
- if (!fresh.length)
2054
- return;
2055
- l.instables = [...(l.instables ?? []), ...fresh];
2056
- const text = `${fresh.length} test(s) instable(s) sous charge (rouge(s) dans la suite, vert(s) rejoué(s) seul(s)), pas de passe de correction : ${fresh.join(' ; ')}`;
2057
- l.warnings.push(text);
2058
- c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
2059
- save(c);
2060
- }
2061
2075
  /**
2062
2076
  * Contrôles du sommet intégré (L328) : un rouge est relancé une fois avant de rendre le lot. Le 25-10, L326 a été rendu pour un rouge de charge (L325 tournait en même
2063
2077
  * temps) alors que le même sommet rejoué seul était vert. Rouge deux fois = rendu, avec le résultat de la seconde passe ; vert à la relance = une alerte sur le lot et une ligne du journal.
@@ -2269,7 +2283,7 @@ async function work(c, kind) {
2269
2283
  const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
2270
2284
  const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
2271
2285
  if (deferred && red.length === 0 && gateGreen && baseOnly)
2272
- l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${c.config.test}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
2286
+ l.warnings.push(`tests annoncés rouges par la session, mais la porte du programme verte (${gateLabel(c.config.test ?? '')}, code 0) et aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction`);
2273
2287
  else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
2274
2288
  red.push(claimed);
2275
2289
  if (red.length) {
@@ -0,0 +1,27 @@
1
+ // Ce que la porte de tests dit d'un rouge qu'elle n'envoie pas en correction : instable sous charge (L354), délai trop juste (L510).
2
+ import { lotKey } from './state.js';
3
+ /** Trace des tests instables sous charge (L354) : le lot (`instables`, journal et `cadence perf`), un avertissement du lot (tableau de fin de vague) et une ligne du journal de la vague. */
4
+ export function noteUnstable(c, tests) {
5
+ const l = c.lot;
6
+ const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
7
+ if (!fresh.length)
8
+ return;
9
+ l.instables = [...(l.instables ?? []), ...fresh];
10
+ const text = `${fresh.length} test(s) instable(s) sous charge (rouge(s) dans la suite, vert(s) rejoué(s) seul(s)), pas de passe de correction : ${fresh.join(' ; ')}`;
11
+ l.warnings.push(text);
12
+ c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
13
+ c.wave.store.writeLot(l);
14
+ }
15
+ const seconds = (ms) => `${Math.round(ms / 100) / 10} s`.replace(/\.0 s$/, ' s');
16
+ /** Trace des tests au « délai trop juste » (L510) : rouges dans la suite pour avoir expiré, verts seuls mais près de leur délai. Le lot (`delaisJustes`) et un avertissement qui nomme chaque test, sa durée seul et son délai ; jamais de passe de correction. */
17
+ export function noteTight(c, tests) {
18
+ const l = c.lot;
19
+ const fresh = tests.filter((t) => !(l.delaisJustes ?? []).some((k) => k.test === t.test));
20
+ if (!fresh.length)
21
+ return;
22
+ l.delaisJustes = [...(l.delaisJustes ?? []), ...fresh];
23
+ const text = `délai trop juste, pas un bug (pas de passe de correction) : ${fresh.map((t) => `${t.test} (${seconds(t.ms)} seul pour ${seconds(t.limit)} de délai)`).join(' ; ')} — relever le délai du test`;
24
+ l.warnings.push(text);
25
+ c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
26
+ c.wave.store.writeLot(l);
27
+ }
@@ -0,0 +1,135 @@
1
+ // Porte de tests déclarée en parties (L512) : `orchestrate.test: [{ dir, run, replay? }]`. Le 31-10, la porte composée de warhammer (`backend && frontend`) refusait `testReplay`,
2
+ // donc ni le rejeu seul (L354) ni le délai trop juste et le rouge au départ (L510) : quatre corrections et une escalade pour des faux rouges (L61). Décision de Sylvain du 01-11 :
3
+ // cadence lance chaque partie séparément, dans son dossier, et attribue chaque rouge à la partie qui l'a imprimé ; une partie sans `replay` garde ses rouges comptés ; la forme chaîne reste telle quelle.
4
+ // Ce module ne change que le lieu du lancement et l'attribution des noms : les règles de verdict (deux rejeux, fichier vu dans la sortie, délai trop juste) restent celles de workers.ts.
5
+ import { isAbsolute, posix } from 'node:path';
6
+ import { failedTests, hasUnnamedFailure } from './baseline.js';
7
+ import { isComposedGate, redAlone, replayRed, shellArg } from './workers.js';
8
+ export const isParts = (t) => Array.isArray(t);
9
+ const PART_KEYS = ['dir', 'run', 'replay'];
10
+ /** Le dossier d'une partie, normalisé (`./front/` → `front`) ; null s'il est vide, absolu ou contient « .. ». */
11
+ function partDir(v) {
12
+ if (typeof v !== 'string' || !v.trim() || isAbsolute(v.trim()) || v.split(/[\\/]/).includes('..'))
13
+ return null;
14
+ const n = posix.normalize(v.trim().replaceAll('\\', '/')).replace(/\/+$/, '');
15
+ return n || null;
16
+ }
17
+ /** Lecture de `orchestrate.test` (contrat A1) : la chaîne telle quelle, ou la liste des parties validées ; `bad` fabrique l'erreur de cadence.yaml. */
18
+ export function readGateTest(raw, bad) {
19
+ if (typeof raw === 'string') {
20
+ if (!raw.trim())
21
+ throw bad('test : commande non vide attendue');
22
+ return raw.trim();
23
+ }
24
+ if (!Array.isArray(raw))
25
+ throw bad('test : commande non vide attendue (chaîne ou liste de parties { dir, run, replay? })');
26
+ if (!raw.length)
27
+ throw bad('test : au moins une partie attendue (dir, run, replay facultatif)');
28
+ const seen = new Set();
29
+ return raw.map((item) => {
30
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
31
+ throw bad('test : chaque partie doit être un objet { dir, run, replay? }');
32
+ const o = item;
33
+ for (const k of Object.keys(o))
34
+ if (!PART_KEYS.includes(k))
35
+ throw bad(`test : clé « ${k} » inconnue dans une partie (attendu : dir, run, replay)`);
36
+ const dir = partDir(o.dir);
37
+ if (dir === null || seen.has(dir))
38
+ throw bad(`test : dir invalide « ${typeof o.dir === 'string' ? o.dir : JSON.stringify(o.dir)} » (chemin relatif, sans « .. », unique)`);
39
+ seen.add(dir);
40
+ if (typeof o.run !== 'string' || !o.run.trim())
41
+ throw bad(`test : la commande run de la partie « ${dir} » ne peut pas être vide (chaîne non vide attendue)`);
42
+ const part = { dir, run: o.run.trim() };
43
+ if (o.replay != null) {
44
+ if (typeof o.replay !== 'string' || !o.replay.trim() || !o.replay.includes('{files}'))
45
+ throw bad(`test : le replay de la partie « ${dir} » doit être une commande non vide contenant {files} (remplacé par les fichiers des tests rouges)`);
46
+ part.replay = o.replay.trim();
47
+ }
48
+ return part;
49
+ });
50
+ }
51
+ /** `orchestrate.testReplay` face à `orchestrate.test` (L354, L512) : `{files}` exigé ; refusé avec une liste de parties (le rejeu se déclare par partie) et sur une porte composée en chaîne. */
52
+ export function checkTestReplay(test, replay, bad) {
53
+ if (!replay)
54
+ return;
55
+ if (!replay.includes('{files}'))
56
+ throw bad('testReplay : la commande doit contenir {files} (remplacé par les fichiers des tests rouges)');
57
+ if (isParts(test))
58
+ throw bad('testReplay : refusé quand orchestrate.test est une liste de parties : déclarer replay dans chaque partie');
59
+ if (test && isComposedGate(test))
60
+ throw bad('testReplay : refusé quand orchestrate.test est une porte composée (&&, ||, sous-shell) : le rejeu seul ne vaut que pour un lanceur unique');
61
+ }
62
+ /** Le libellé d'une porte (R9 b) : la chaîne telle quelle ; les parties « dir : run » jointes par « ; ». Sert aux messages, aux briefs, au CheckRun et à la clé de cache de la ligne de base. */
63
+ export const gateLabel = (t) => (isParts(t) ? t.map((p) => `${p.dir} : ${p.run}`).join(' ; ') : t);
64
+ /** La porte a de quoi rejouer un rouge seul : `testReplay` pour une chaîne, au moins une partie avec `replay` pour une liste. */
65
+ export const gateReplayable = (t, replay) => (isParts(t) ? t.some((p) => !!p.replay) : !!replay);
66
+ /** La commande d'une partie jouée depuis la racine du lieu (worktree local ou de l'hôte) : un dossier introuvable fait sortir le shell en échec en le nommant (« can't cd to dir »), sans test nommé. */
67
+ export const partCommand = (p, command = p.run) => `cd ${shellArg(p.dir)} && ${command}`;
68
+ /** Joue la porte : la chaîne en un bloc ; les parties toutes, chacune dans son dossier (R8 : une partie rouge ne coupe pas les suivantes), code de la première non nulle, sorties fusionnées sous un en-tête « [dir] ». */
69
+ export async function runGate(run, test) {
70
+ if (!isParts(test))
71
+ return run(test);
72
+ const parts = [];
73
+ for (const part of test) {
74
+ const r = await run(partCommand(part));
75
+ parts.push({ part, code: r.code, output: r.output });
76
+ }
77
+ return { code: parts.find((p) => p.code !== 0)?.code ?? 0, output: parts.map((p) => `[${p.part.dir}]\n${p.output}`).join('\n'), parts };
78
+ }
79
+ /**
80
+ * La porte a échoué pour autre chose qu'un test nommé : forme chaîne, `hasUnnamedFailure` (L354) ; parties, une partie rouge qui ne nomme aucun test (erreur de compilation, « can't cd to », lanceur
81
+ * planté) ou dont la sortie porte un échec sans nom (seuil de couverture, fichier qui ne se charge pas). Rien n'est alors rejoué ni excusé : la porte reste rouge, quoi que le rejeu des autres parties dise (revue du 01-11, bloquant : jamais de faux vert).
82
+ */
83
+ export function gateUnnamed(ran) {
84
+ if (!ran.parts)
85
+ return hasUnnamedFailure(ran.output);
86
+ return ran.parts.some((p) => p.code !== 0 && (hasUnnamedFailure(p.output) || failedTests(p.output).length === 0));
87
+ }
88
+ /** Les rouges nommés de la porte du LOT, candidats au rejeu seul : vide dès que la porte a un échec sans test nommé (`gateUnnamed`), en chaîne comme en parties — rien n'excuse alors le lot. */
89
+ export function redCandidates(ran) {
90
+ return gateUnnamed(ran) ? [] : failedTests(ran.output);
91
+ }
92
+ /**
93
+ * Les rouges nommés de la LIGNE DE BASE, candidats au rejeu seul : là, rejouer retire des excuses (un rouge de charge du départ ne doit pas excuser une vraie régression du lot), donc chaque partie est jugée pour elle-même :
94
+ * une partie rouge sans test nommé au départ ne retient que ses propres rouges (aucun), les noms des autres parties sont rejoués par leur partie (`perPart`). Forme chaîne : comme avant (tout ou rien).
95
+ */
96
+ export function baselineCandidates(ran) {
97
+ return ran.parts ? failedTests(ran.output) : redCandidates(ran);
98
+ }
99
+ /** Par partie rouge, les noms de `names` qu'elle a imprimés et le verdict de `judge` sur eux seuls ; null (rien de prouvable) pour une partie sans `replay` ou à échec sans nom. */
100
+ async function perPart(parts, names, judge) {
101
+ const out = [];
102
+ for (const p of parts) {
103
+ if (p.code === 0)
104
+ continue;
105
+ const printed = new Set(failedTests(p.output));
106
+ const own = names.filter((n) => printed.has(n));
107
+ if (own.length)
108
+ out.push({ own, v: p.part.replay && !hasUnnamedFailure(p.output) ? await judge(p, own) : null });
109
+ }
110
+ return out;
111
+ }
112
+ /** Un nom est excusé seulement si CHAQUE partie qui l'a imprimé rouge l'excuse (règle du lead du 01-11 : jamais de faux vert) ; un nom qu'aucune partie rouge n'a imprimé ne l'est pas. */
113
+ const everyPart = (judged, names, ok) => [...new Set(names)].filter((n) => {
114
+ const mine = judged.filter((j) => j.own.includes(n));
115
+ return mine.length > 0 && mine.every((j) => j.v !== null && ok(j.v, n));
116
+ });
117
+ /**
118
+ * Rejeu seul des rouges `names` (L354, L510) : forme chaîne, `replayRed` tel quel ; parties, chaque partie rouge rejoue dans son dossier, par son `replay`, les seuls noms qu'elle a imprimés (R4),
119
+ * et un nom imprimé par plusieurs parties n'est instable que si toutes le voient vert (R5) ; le délai trop juste d'un instable est celui lu dans l'une d'elles.
120
+ */
121
+ export async function replayGate(run, replay, names, ran) {
122
+ if (!ran.parts)
123
+ return replayRed(run, replay, names, ran.output);
124
+ const judged = await perPart(ran.parts, names, (p, own) => replayRed((c) => run(partCommand(p.part, c)), p.part.replay, own, p.output));
125
+ const unstable = everyPart(judged, names, (v, n) => v.unstable.includes(n));
126
+ const tight = unstable.flatMap((n) => judged.map((j) => j.v?.tight.find((k) => k.test === n)).filter((k) => !!k).slice(0, 1));
127
+ return { red: [...new Set(names)].filter((n) => !unstable.includes(n)), unstable, tight };
128
+ }
129
+ /** Rouges seuls au sha de départ (L510, R6) : forme chaîne, `redAlone` tel quel ; parties, un nom n'est « déjà rouge » que si chaque partie qui l'a imprimé rouge dans le lot le nomme encore en échec à ses rejeux au départ. */
130
+ export async function redAloneGate(run, replay, names, ran) {
131
+ if (!ran.parts)
132
+ return redAlone(run, replay, names, ran.output);
133
+ const judged = await perPart(ran.parts, names, (p, own) => redAlone((c) => run(partCommand(p.part, c)), p.part.replay, own, p.output));
134
+ return everyPart(judged, names, (v, n) => v.includes(n));
135
+ }
@@ -0,0 +1,66 @@
1
+ // Où une commande de la porte d'un lot tourne (L403) : sur l'hôte `orchestrate.gate.host` quand il répond et que l'hôte verrait ce qui est testé, sinon en local.
2
+ // Les décisions de verdict (rouge, ligne de base, rejeu seul) restent celles du cycle : ce module ne change que le lieu et ne rend jamais un 255 comme un résultat.
3
+ import { GATE_TIMEOUT_MS, isSha, openRemoteGate } from '../remote-gate.js';
4
+ import { configuredCap } from './command.js';
5
+ import { testWorkersFrom } from './workers.js';
6
+ import { trackedDirty, untrackedFiles } from './worktree.js';
7
+ const localPlace = (local) => ({ run: local, stamp: () => ({}), close: () => { } });
8
+ /**
9
+ * Ouvre le lieu de la porte. Sans `gate.host`, ou quand l'hôte ne peut pas porter la porte (arbre non validé R7, hôte injoignable, push ou cache impossible R4/R5), la porte est locale et la ligne dit pourquoi.
10
+ * Sur l'hôte, un code 255 de ssh (connexion perdue) n'est jamais un résultat : la commande est rejouée en local, la ligne le dit, et le lieu reste local pour les commandes suivantes (R6).
11
+ */
12
+ async function openPlace(o) {
13
+ const host = o.config.gate?.host;
14
+ // Aucune commande de porte déclarée : rien à jouer, ni sonde ni worktree sur l'hôte.
15
+ if (!host || !o.sha || !(o.config.build || o.config.test))
16
+ return localPlace(o.local);
17
+ if (o.needClean) {
18
+ const dirty = [...trackedDirty(o.dir), ...untrackedFiles(o.dir)];
19
+ if (dirty.length) {
20
+ o.say(`${o.label} : worktree non validé, jouée en local (l'hôte ne verrait pas les modifications)`);
21
+ return localPlace(o.local);
22
+ }
23
+ }
24
+ if (!isSha(o.sha)) {
25
+ o.say(`${o.label} : sha invalide pour la porte déportée (hexadécimal attendu), jouée en local`);
26
+ return localPlace(o.local);
27
+ }
28
+ const env = o.env ?? process.env;
29
+ const opened = await openRemoteGate({ host, install: o.config.gate?.install, repo: o.repo, sha: o.sha, depsFrom: o.repo, worktreeDeps: o.config.worktreeDeps, env, timeoutMs: GATE_TIMEOUT_MS, workers: (cores) => testWorkersFrom(env, cores, configuredCap(env)) });
30
+ if (!opened.ok) {
31
+ o.say(opened.stage === 'host' ? `${o.label} : porte déportée indisponible sur ${host} (${opened.cause}) — jouée en local` : `${o.label} : cache de dépendances impossible sur ${host} (${opened.cause}) — jouée en local`);
32
+ return localPlace(o.local);
33
+ }
34
+ const gate = opened.gate;
35
+ let lost = false;
36
+ const where = `hôte ${host}, sha ${o.sha.slice(0, 7)}, ${gate.cache}`;
37
+ o.say(`${o.label} : porte jouée sur l'hôte ${host} (sha ${o.sha.slice(0, 7)}, ${gate.cache})`);
38
+ return {
39
+ run: async (command) => {
40
+ if (lost)
41
+ return o.local(command);
42
+ const r = await gate.run(command, GATE_TIMEOUT_MS);
43
+ if (!r.lost)
44
+ return r;
45
+ lost = true;
46
+ o.say(`${o.label} : connexion perdue avec ${host} pendant la porte — rejouée en local${o.detail ? ` ${o.detail}` : ''}`);
47
+ return o.local(command);
48
+ },
49
+ stamp: () => ({ lieu: lost ? `local (connexion perdue avec ${host})` : where }),
50
+ close: () => {
51
+ const orphan = gate.close();
52
+ if (orphan)
53
+ o.say(`${o.label} : nettoyage de l'hôte ${host} en échec (${orphan}) — le worktree orphelin y sera retiré au prochain appel`);
54
+ },
55
+ };
56
+ }
57
+ /** Joue `fn` dans le lieu de la porte et retire le worktree de l'hôte quoi qu'il arrive (R10). */
58
+ export async function withPlace(o, fn) {
59
+ const place = await openPlace(o);
60
+ try {
61
+ return await fn(place);
62
+ }
63
+ finally {
64
+ place.close();
65
+ }
66
+ }
@@ -9,7 +9,7 @@ import { pidAlive } from '../state.js';
9
9
  export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
10
10
  export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
11
11
  export function newLot(o) {
12
- return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, outcome: null };
12
+ return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, delaisJustes: undefined, outcome: null };
13
13
  }
14
14
  /** Un lot fini ne travaille plus dans son dépôt : prêt, rendu ou échoué. */
15
15
  export const lotFinished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
@@ -1,5 +1,5 @@
1
1
  // Part de la machine de chaque session (L350) : le 26-10, 32 cœurs et 4 sessions, vitest à 31 workers par suite, et des tests qui passaient seuls rougissaient ensemble.
2
- import { jestTestFiles, stripAnsi } from './baseline.js';
2
+ import { failedTests, jestTestFiles, stripAnsi, testDurationMs, timeoutLimits } from './baseline.js';
3
3
  /** Variable posée dans l'environnement des sessions et de la porte : le nombre de workers que leurs lanceurs de tests ont le droit d'ouvrir. */
4
4
  export const TEST_WORKERS_ENV = 'CADENCE_TEST_WORKERS';
5
5
  /** Cœurs ÷ plafond de sessions, arrondi vers le bas, jamais moins de 1 (cœurs inconnus ou plafond absurde compris). */
@@ -73,15 +73,35 @@ export function replayRanFile(output, file) {
73
73
  const NO_TEST_RAN = /^\W*(?:no test files? found, exiting with code|no tests? found, exiting with code|no tests ran in)\b/im;
74
74
  /** Rejeux d'un test rouge de la porte, seul (L354) : rouge aux deux, il est compté. */
75
75
  const REPLAYS = 2;
76
+ /**
77
+ * Parmi `names`, les tests rouges SEULS (L510) : `run` rejoue la commande `template` avec leurs fichiers, au plus `REPLAYS` fois ; un test reste s'il est nommé en échec par chaque rejeu, qui sort en échec. C'est une preuve positive : un rejeu qui échoue pour une autre raison
78
+ * (build cassé, fichier introuvable) ne nomme pas le test et ne l'excuse pas. Un test sans fichier désignable n'est pas rejoué, donc n'est jamais retenu. Sert à savoir si un rouge du lot était déjà rouge au sha de départ.
79
+ */
80
+ export async function redAlone(run, template, names, output) {
81
+ const jest = jestTestFiles(output);
82
+ const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
83
+ let still = [...new Set(names)].filter((n) => fileOf(n));
84
+ for (let i = 0; i < REPLAYS && still.length; i++) {
85
+ const files = [...new Set(still.map((n) => fileOf(n)))];
86
+ const r = await run(template.replaceAll('{files}', () => files.map(shellArg).join(' ')));
87
+ const failing = r.code === 0 ? new Set() : new Set(failedTests(r.output));
88
+ still = still.filter((n) => failing.has(n));
89
+ }
90
+ return still;
91
+ }
92
+ /** Un test vert seul mais à au moins cette part de son délai est un « délai trop juste » (L510) : 25 s pour 30 s le 31-10 (warhammer L61). */
93
+ const TIGHT_RATIO = 0.8;
76
94
  /**
77
95
  * Rejoue SEULS les tests `names` rouges dans `output` (L354, L387) : `run` exécute la commande `template` où `{files}` devient les fichiers de ces tests, dans le dossier de l'appelant (worktree du lot, de la ligne de base ou de `verify --gate`).
78
- * Un test vert à un rejeu est `unstable` ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
96
+ * Un test vert à un rejeu est `unstable` (et `tight` quand sa durée seul est près de son délai, L510) ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
79
97
  */
80
98
  export async function replayRed(run, template, names, output) {
81
99
  const jest = jestTestFiles(output);
82
100
  const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
83
- let red = names;
101
+ let red = [...new Set(names)];
84
102
  const unstable = [];
103
+ const tight = [];
104
+ const limits = timeoutLimits(output);
85
105
  for (let i = 0; i < REPLAYS; i++) {
86
106
  const replayable = red.filter((n) => fileOf(n));
87
107
  const files = [...new Set(replayable.map((n) => fileOf(n)))];
@@ -92,7 +112,14 @@ export async function replayRed(run, template, names, output) {
92
112
  const ran = r.code === 0 && !NO_TEST_RAN.test(r.output);
93
113
  const passed = ran ? replayable.filter((n) => replayRanFile(r.output, fileOf(n))) : [];
94
114
  unstable.push(...passed);
115
+ // L510 : vert seul mais expiré dans la suite, et la durée du rejeu est près du délai : le test est trop juste, pas bugué. Sans délai lu dans la suite ou sans durée lisible au rejeu, rien n'est dit.
116
+ for (const n of passed) {
117
+ const limit = limits.get(n);
118
+ const ms = limit ? testDurationMs(r.output, n, fileOf(n)) : null;
119
+ if (limit && ms !== null && ms >= TIGHT_RATIO * limit)
120
+ tight.push({ test: n, ms, limit });
121
+ }
95
122
  red = red.filter((n) => !passed.includes(n));
96
123
  }
97
- return { red, unstable };
124
+ return { red, unstable, tight };
98
125
  }
@@ -21,6 +21,8 @@ export const PLAN_ACTIONS = {
21
21
  /** `raf archive` (L396) : le nombre de lots déplacés dans l'archive. */
22
22
  archived: (n) => `${n} lot(s) archivé(s)`,
23
23
  reviewRecorded: 'revue de code enregistrée',
24
+ /** `raf ux <lot>` ne commite pas : l'action de la commande de commit qu'il propose (L509). */
25
+ uxRecorded: 'verdict UX enregistré',
24
26
  reviewNoted: 'revue de code notée',
25
27
  /** `cadence deliver` pendant une vague (L388) : le lot de l'article ouvert, ou ses titres ajoutés en note. */
26
28
  articleOpened: 'article à rafraîchir ouvert',
@@ -51,12 +53,12 @@ export function planCommitProblem(template) {
51
53
  }
52
54
  /**
53
55
  * Le message d'un commit de plan : le gabarit du projet (`plan.commit`), à défaut celui d'avant L343.
54
- * Hors vague, un groupe entre parenthèses qui porte `{vague}` disparaît avec elle, au lieu de laisser « (orchestrate ) ».
56
+ * Hors vague, un groupe entre parenthèses ou crochets qui porte `{vague}` disparaît avec elle, au lieu de laisser « (orchestrate ) » ou « [] ».
55
57
  */
56
58
  export function planCommitMessage(template, v) {
57
59
  let t = template ?? (v.wave === undefined ? PLAN_COMMIT_PLAIN : PLAN_COMMIT_WAVE);
58
60
  if (v.wave === undefined)
59
- t = t.replace(/\s*\([^()]*\{vague\}[^()]*\)/g, '').replaceAll('{vague}', '');
61
+ t = t.replace(/\s*(?:\([^()]*\{vague\}[^()]*\)|\[[^\[\]]*\{vague\}[^\[\]]*\])/g, '').replaceAll('{vague}', '');
60
62
  return t.replaceAll('{lot}', () => v.lot).replaceAll('{vague}', () => v.wave ?? '').replaceAll('{action}', () => v.action);
61
63
  }
62
64
  /** Le hook commit-msg que git lancerait dans `root` (core.hooksPath compris, husky aussi) ; null s'il n'y en a pas d'exécutable. */
@@ -0,0 +1,26 @@
1
+ import { relative } from 'node:path';
2
+ import { dirtyPaths, gitRoot } from './git.js';
3
+ import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
4
+ /**
5
+ * `raf done`, `raf ux <lot>` et `raf review <lot>` (L509) écrivent le plan et ne le commitent jamais (voulu : raf n'a pas de réglage qui commite, docs/raf.md) : la commande de commit du plan et des fichiers
6
+ * que `plan.onWrite` produit, au gabarit `plan.commit` du projet (L371). Null hors d'un dépôt git ou quand rien n'est à commiter.
7
+ */
8
+ function uncommittedPlanCommand(plan, root, lot, template, action = PLAN_ACTIONS.done) {
9
+ const repo = gitRoot(root);
10
+ if (!repo)
11
+ return null;
12
+ // Seuls les chemins que git voit modifiés : `git add` d'un chemin absent ou ignoré est fatal.
13
+ const paths = dirtyPaths(repo, [relative(repo, plan.path), ...plan.files, ...plan.produced, ...plan.archiveFiles().map((f) => relative(repo, f))]);
14
+ if (paths.length === 0)
15
+ return null;
16
+ const quote = (t) => (/^[\w./-]+$/.test(t) ? t : `'${t.replaceAll("'", "'\\''")}'`);
17
+ const message = planCommitMessage(template, { lot, action });
18
+ const list = paths.map(quote).join(' ');
19
+ return `plan non commité : raf ne commite jamais (voulu, ce n'est pas un réglage), à commiter :\n git add -- ${list} && git commit -m ${quote(message)} -- ${list}`;
20
+ }
21
+ /** Dit sur `out` la commande de commit du plan, quand il y a quelque chose à commiter (rien sinon). */
22
+ export function printUncommittedPlan(out, ...args) {
23
+ const command = uncommittedPlanCommand(...args);
24
+ if (command)
25
+ out(command);
26
+ }
package/dist/release.js CHANGED
@@ -200,20 +200,22 @@ function whatsNewIssues(text, version) {
200
200
  * types que Claude Code génère dans plugins/cadence-hud et que seul un clone ne porte pas). `git clone` du dépôt courant ne prend que ce qui est commité,
201
201
  * `--no-hardlinks` pour ne rien partager avec lui ; le clone est retiré, vert ou rouge.
202
202
  */
203
- function cloneStep(scripts) {
203
+ function cloneStep(scripts, untag) {
204
204
  const run = scripts.map((n) => (n === 'test' ? 'npm test' : `npm run ${n}`)).join(' && ');
205
- return `d=$(mktemp -d) && git clone -q --no-hardlinks . "$d/c" && (cd "$d/c" && npm ci && ${run}); r=$?; rm -rf "$d"; test $r -eq 0 (la suite dans un git clone neuf, comme publish.yml ; rouge = pas de tag)`;
205
+ return `d=$(mktemp -d) && git clone -q --no-hardlinks . "$d/c" && (cd "$d/c" && npm ci && ${run}); r=$?; rm -rf "$d"; test $r -eq 0 || ${untag} (la suite dans un git clone neuf, qui reçoit le tag provisoire, comme publish.yml ; rouge = tag retiré)`;
206
206
  }
207
207
  /** Les étapes que le lead joue, dans l'ordre : la commande n'en fait aucune. */
208
208
  function steps(p, files, branch) {
209
209
  const tag = `v${p.version}`;
210
+ // Rouge à la porte, au clone ou à l'éval : le tag provisoire est retiré, la release n'a pas eu lieu.
211
+ const untag = `{ git tag -d ${tag}; false; }`;
210
212
  const out = [
211
213
  `git add ${files.join(' ')} && git commit -m "release: ${p.version}"`,
212
- 'cadence verify --gate --sha HEAD (la suite et le build rejoués sur le commit de version, où le CHANGELOG n\'a plus de [Unreleased] plein ; rouge = pas d\'éval ni de tag)',
213
- ...(p.cloneScripts.length ? [cloneStep(p.cloneScripts)] : []),
214
- ...(p.hasEvalScript ? ['npm run eval:plugin -- --trust-plugin (facturé ; un cas sous le seuil arrête la release)'] : []),
215
- `git tag ${tag}`,
216
- `git push origin ${branch} ${tag} (le tag lance le workflow de publication)`,
214
+ `git tag ${tag} (tag local provisoire sur le commit de version : les tests qui lisent le dernier tag, comme test/changelog.test.ts, voient la version publiée ; retiré si une des trois étapes suivantes est rouge, jamais poussé avant)`,
215
+ `cadence verify --gate --sha HEAD || ${untag} (la suite et le build rejoués sur le commit de version, où le CHANGELOG n'a plus de [Unreleased] plein ; rouge = tag retiré, pas d'éval)`,
216
+ ...(p.cloneScripts.length ? [cloneStep(p.cloneScripts, untag)] : []),
217
+ ...(p.hasEvalScript ? [`npm run eval:plugin -- --trust-plugin || ${untag} (facturé ; un cas sous le seuil arrête la release et retire le tag ; si la lecture du rapport le juge faux, reposer le tag de l’étape 2 avant le push)`] : []),
218
+ `git push origin ${branch} ${tag} (le tag provisoire devient le tag de la release ; il lance le workflow de publication)`,
217
219
  'gh run watch (jusqu\'à la fin du run de publication)',
218
220
  ...(p.isPrivate || p.packageName === '' ? [] : [`npm view ${p.packageName} version (doit répondre ${p.version})`]),
219
221
  ...(p.pluginName ? [