@sylad/cadence 1.34.1 → 1.36.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/NOTICE +28 -0
- package/README.md +1 -1
- package/agents/qa-reviewer.md +5 -4
- package/agents/spec-writer.md +2 -0
- package/agents/ux-reviewer.md +28 -0
- package/dist/cli.js +13 -29
- package/dist/config.js +8 -21
- package/dist/corrections-sans-code.js +12 -0
- package/dist/deliver-ci.js +105 -0
- package/dist/deliver.js +61 -79
- package/dist/gate-config.js +47 -0
- package/dist/orchestrate/accept.js +5 -2
- package/dist/orchestrate/ajout.js +186 -0
- package/dist/orchestrate/baseline.js +57 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +118 -140
- package/dist/orchestrate/cycle.js +69 -55
- package/dist/orchestrate/depot.js +33 -0
- package/dist/orchestrate/gate-notes.js +27 -0
- package/dist/orchestrate/gate-parts.js +135 -0
- package/dist/orchestrate/gate-place.js +66 -0
- package/dist/orchestrate/lot-budget.js +50 -0
- package/dist/orchestrate/pool.js +1 -1
- package/dist/orchestrate/retrait.js +37 -0
- package/dist/orchestrate/snapshot.js +13 -0
- package/dist/orchestrate/state.js +48 -10
- package/dist/orchestrate/tiers.js +9 -0
- package/dist/orchestrate/workers.js +31 -4
- package/dist/plan-commit.js +4 -2
- package/dist/plan-uncommitted.js +26 -0
- package/dist/release.js +9 -7
- package/dist/remote-gate.js +278 -0
- package/dist/state.js +4 -0
- package/dist/verify-gate-remote.js +55 -0
- package/dist/verify-gate.js +37 -28
- package/docs/corrections.md +4 -0
- package/docs/deliver.md +22 -2
- package/docs/lead.md +3 -1
- package/docs/news.md +2 -4
- package/docs/orchestrate.md +22 -0
- package/docs/overview.md +1 -0
- package/docs/raf.md +3 -3
- package/docs/releasing.md +17 -8
- package/docs/remote-gate.md +89 -0
- package/docs/skills.md +5 -2
- package/docs/whats-new.md +6 -0
- package/package.json +3 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/spec.md +3 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { basename, join } from 'node:path';
|
|
2
|
+
import { sharedStateDir } from '../state.js';
|
|
3
|
+
import { addHint } from './ajout.js';
|
|
4
|
+
import { canInstallPrePush, gitProcessRunning, indexLockRefusal, snapshot } from './guard.js';
|
|
5
|
+
import { activeLock, REPO_LOCK } from './lock.js';
|
|
6
|
+
/** Les fichiers suivis modifiés d'un dépôt. */
|
|
7
|
+
export async function trackedDirty(repo) {
|
|
8
|
+
return (await snapshot(repo, { remote: false })).tracked.map((l) => l.slice(3));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Les refus propres à un dépôt avant qu'une vague y joue (arbre sale, `index.lock`, hook de push, orchestration en cours), les mêmes pour un lot lancé, repris ou ajouté.
|
|
12
|
+
* L421 : le dépôt que tient la vague qui reçoit l'ajout (`addTo`) est le sien : son arbre porte le travail de ses lots et son hook de push est le sien. `resume` : un dépôt tenu n'est pas refusé.
|
|
13
|
+
*/
|
|
14
|
+
export async function repoRefusals(repo, t, run, opts) {
|
|
15
|
+
const busy = activeLock(join(sharedStateDir(repo), REPO_LOCK));
|
|
16
|
+
const ours = !!opts.addTo && busy?.wave === opts.addTo.wave;
|
|
17
|
+
const name = basename(repo);
|
|
18
|
+
const out = [];
|
|
19
|
+
if (!ours) {
|
|
20
|
+
const dirty = await trackedDirty(repo);
|
|
21
|
+
if (dirty.length)
|
|
22
|
+
out.push(`${name} : arbre sale, ${dirty.length} fichier(s) suivi(s) modifié(s) : ${dirty.join(', ')}`);
|
|
23
|
+
const locked = indexLockRefusal(repo, run.io.now(), run.deps.gitRunning ?? gitProcessRunning);
|
|
24
|
+
if (locked)
|
|
25
|
+
out.push(`${name} : ${locked}`);
|
|
26
|
+
const hook = canInstallPrePush(repo);
|
|
27
|
+
if (hook)
|
|
28
|
+
out.push(`${name} : ${hook}`);
|
|
29
|
+
}
|
|
30
|
+
if (busy && !opts.resume && !ours)
|
|
31
|
+
out.push(`${name} : une orchestration y est déjà en cours (${busy.wave}, pid ${busy.pid})${addHint(t, busy.wave, run.launch)}`);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ESCALATION_RESERVE } from './cycle.js';
|
|
2
|
+
/**
|
|
3
|
+
* Budget d'un lot ordinaire (L78, L128, recalibré L194). Mesuré sur le TOTAL de chaque lot dans `.cadence/runs` (toutes les vagues
|
|
4
|
+
* où il est passé, 250 lots, 11-10), rangé par estimate : jusqu'à 0,5 j p90 374 k (152 lots), de 0,5 à 1 j p90 732 k (88 lots, max
|
|
5
|
+
* 1,09 M), au-delà d'un jour p90 1,14 M pour les 10 lots de 1,25 à 2 j (570 k par jour d'estimate), max 1,7 M (L188). Le plafond
|
|
6
|
+
* de L78 (400 k par jour) rendait au lead, au deuxième tour, un lot de 1 j (L188 : 483 k puis 495 k).
|
|
7
|
+
* Plancher relevé de 450 k à 540 k (L218) avec la réserve de la passe fix (155 k) : 14 k + 282 k + 80 k + 155 k = 531 k arrondis, la marge avant refus du fix reste 385 k.
|
|
8
|
+
* Relevé à 600 k (L247), mesuré sur 16–22-10 : 145 lots à 540 k, p90 451 k, 3 au-dessus (tous maritime-atlas : 12 lots, p90 556 k, max 609 k) ; la marge
|
|
9
|
+
* avant refus du fix passe à 445 k. Les lots à 750 k (60 lots, p90 571 k, 0 dépassement) et à 1,3 M (20 lots, p90 904 k, 1 dépassement) tiennent : inchangés.
|
|
10
|
+
* Remesuré sur 17–23-10 (L280, `docs/perf/recalage-2026-10-24.md`) : 0 lot sur 309 au-dessus de son budget, p90 452 k à 600 k, 563 k à 750 k, 875 k à 1,3 M : tous inchangés, avec au moins 10 % de marge.
|
|
11
|
+
*/
|
|
12
|
+
const LOT_BUDGET_FLOOR = 600_000;
|
|
13
|
+
/** Lot de plus de 0,5 j et d'au plus 1 j : p90 mesuré du total, 732 k arrondis à 750 k. */
|
|
14
|
+
const LOT_BUDGET_ONE_DAY = 750_000;
|
|
15
|
+
/** Tokens par jour d'estimate au-delà d'un jour : p90 1,14 M pour 2 j. */
|
|
16
|
+
const LOT_BUDGET_PER_DAY = 570_000;
|
|
17
|
+
/**
|
|
18
|
+
* Total d'un lot à `screen:` (L194), étapes spec, maquette et accept comprises : p90 1,28 M sur 5 lots (L113 1,12 M, L25 1,28 M, L50 877 k,
|
|
19
|
+
* L85 758 k, L43 238 k), arrondi à 1,3 M. Remplace l'allocation de L191 (+350 k au budget d'estimate), qui ne payait ni l'UX de
|
|
20
|
+
* conformité ni les passes de correction.
|
|
21
|
+
*/
|
|
22
|
+
const SCREEN_LOT_BUDGET = 1_300_000;
|
|
23
|
+
/**
|
|
24
|
+
* Allocation d'un lot à fiche `spec: true` (L188/L191), en plus du budget dérivé de l'estimate : sessions spec, fiche et accept hors de
|
|
25
|
+
* l'estimate d'écriture. Relevée de 350 k à 450 k (L218) : la chaîne avant implement du lot spec warhammer L120 a coûté 388 k (350 k : pire cas
|
|
26
|
+
* mesuré des étapes d'un lot à écran, ol L113 : 349 k) ; cadence L217 n'en a pris que 176 k.
|
|
27
|
+
*/
|
|
28
|
+
export const SPEC_STAGE_ALLOCATION = 450_000;
|
|
29
|
+
/** Base mesurée d'un budget, pour `--dry-run` : l'estimate (et l'étape spec) d'où il sort. */
|
|
30
|
+
export function lotBudgetBasis(estimate, stage, escalationModel) {
|
|
31
|
+
const base = ordinaryBasis(estimate, stage);
|
|
32
|
+
return escalationModel ? `${base}, plus ${ESCALATION_RESERVE} réservés à l'escalade ${escalationModel}` : base;
|
|
33
|
+
}
|
|
34
|
+
function ordinaryBasis(estimate, stage) {
|
|
35
|
+
if (stage === 'screen' && lotBudget(estimate, stage) === SCREEN_LOT_BUDGET)
|
|
36
|
+
return "p90 mesuré du total d'un lot à écran, étapes spec, maquette et accept comprises";
|
|
37
|
+
const ordinary = lotBudget(estimate);
|
|
38
|
+
const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
|
|
39
|
+
if (!stage)
|
|
40
|
+
return basis;
|
|
41
|
+
return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
|
|
42
|
+
}
|
|
43
|
+
/** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
|
|
44
|
+
export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
|
|
45
|
+
const lotBaseBudget = (estimate, stage) => {
|
|
46
|
+
const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
|
|
47
|
+
if (stage === 'screen')
|
|
48
|
+
return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
|
|
49
|
+
return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
|
|
50
|
+
};
|
package/dist/orchestrate/pool.js
CHANGED
|
@@ -93,7 +93,7 @@ export async function runPool(ctxs, slots = SLOTS, known = [], onSettled = () =>
|
|
|
93
93
|
};
|
|
94
94
|
for (;;) {
|
|
95
95
|
admitted.length = 0;
|
|
96
|
-
for (const c of answers?.take(canRun) ?? []) {
|
|
96
|
+
for (const c of (await answers?.take(canRun)) ?? []) {
|
|
97
97
|
live.add(c);
|
|
98
98
|
pending.push([c]);
|
|
99
99
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { gitRoot } from '../git.js';
|
|
4
|
+
import { keptTrees, removeTree } from './worktree.js';
|
|
5
|
+
/**
|
|
6
|
+
* `--remove-worktree projet:lot` (L279, R20) : seule commande qui retire un worktree conservé. Refus (code 2, rien retiré) pour le worktree d'une vague vivante (même avec `--abandon-commits`), et tant qu'un commit du lot n'est ni
|
|
7
|
+
* intégré à la branche du dépôt ni confirmé abandonné par `--abandon-commits` ; un worktree entièrement intégré se retire sans option. `--wave` désigne la vague
|
|
8
|
+
* quand plusieurs ont laissé un worktree pour ce lot.
|
|
9
|
+
*/
|
|
10
|
+
export function removeWorktree(args, io) {
|
|
11
|
+
const t = args.removeWorktree;
|
|
12
|
+
const key = `${t.project ?? basename(io.cwd)}:${t.lot}`;
|
|
13
|
+
const projectDir = t.project ? resolve(io.cwd, t.project) : io.cwd;
|
|
14
|
+
const repo = existsSync(projectDir) ? gitRoot(projectDir) : null;
|
|
15
|
+
const kept = repo ? keptTrees(repo, { includeActive: true }).filter((k) => k.lot === key && (args.wave === undefined || k.wave === args.wave)) : [];
|
|
16
|
+
if (!repo || kept.length === 0) {
|
|
17
|
+
io.err(`orchestrate : ${key} : aucun worktree conservé`);
|
|
18
|
+
return 2;
|
|
19
|
+
}
|
|
20
|
+
if (kept.length > 1) {
|
|
21
|
+
io.err(`orchestrate : ${key} : plusieurs worktrees conservés (vagues ${kept.map((k) => k.wave).join(', ')}) : précisez --wave`);
|
|
22
|
+
return 2;
|
|
23
|
+
}
|
|
24
|
+
const k = kept[0];
|
|
25
|
+
if (k.active) {
|
|
26
|
+
// une vague vivante joue encore ce lot (ses sessions travaillent dans ce dossier) : refus sans rien retirer, même avec --abandon-commits
|
|
27
|
+
io.err(`orchestrate : ${key} : la vague ${k.wave} est vivante et joue ce lot : worktree non retirable (arrêter la vague d'abord)`);
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
const gone = removeTree({ repo, dir: k.dir ?? '', branch: k.branch, base: k.base }, { abandon: args.abandonCommits });
|
|
31
|
+
if (!gone.ok) {
|
|
32
|
+
io.err(gone.dirty ? `orchestrate : ${key} : fichiers suivis modifiés dans le worktree (${gone.dirty.join(', ')}), --abandon-commits pour les abandonner` : gone.untracked ? `orchestrate : ${key} : fichiers non suivis dans le worktree (${gone.untracked.join(', ')}), --abandon-commits pour les abandonner` : `orchestrate : ${key} : ${gone.unintegrated} commit(s) non intégré(s), --abandon-commits pour les abandonner`);
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
io.out(`orchestrate : ${key} : worktree retiré (${gone.abandoned} commit(s) abandonné(s))`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
@@ -130,6 +130,19 @@ export function waveReadsAnswerQueue(waveDir) {
|
|
|
130
130
|
return false;
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* La vague lit-elle la file d'ajouts `adds.log` (L421, `--add`) ? Une vague lancée avant L421 ne la lirait jamais : le lot y dormirait. Sans instantané : on suppose que oui.
|
|
135
|
+
*/
|
|
136
|
+
export function waveReadsAdds(waveDir) {
|
|
137
|
+
if (!snapshotExists(waveDir))
|
|
138
|
+
return true;
|
|
139
|
+
try {
|
|
140
|
+
return readFileSync(join(toolDirOf(waveDir), 'dist', 'orchestrate', 'state.js'), 'utf8').includes('adds.log');
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
133
146
|
/**
|
|
134
147
|
* L'instantané de la vague connaît-il `--budget projet:lot=+N` (L223) ? Un instantané plus ancien relit l'argument avec son `parseBudget` et le refuse
|
|
135
148
|
* comme budget invalide. Sans instantané : rien à relire, on suppose que oui.
|
|
@@ -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';
|
|
@@ -168,11 +168,11 @@ export class RunStore {
|
|
|
168
168
|
writeFileSync(f, kept.map((l) => `${l}\n`).join(''));
|
|
169
169
|
}
|
|
170
170
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
171
|
+
* Lit une file en ajout seul de la vague (`answers.log`, `adds.log`) : une ligne JSON par demande (`{id, …}`) ou par demande prise (`{taken: id}`) ; une demande prise n'y figure plus.
|
|
172
|
+
* Une ligne vide ou illisible est ignorée, et `parse` écarte une ligne dont la forme n'est pas celle de la file. Pas de `.json` : `lots()` le prendrait pour un lot.
|
|
173
173
|
*/
|
|
174
|
-
|
|
175
|
-
const f = join(this.dir,
|
|
174
|
+
queue(file, parse) {
|
|
175
|
+
const f = join(this.dir, file);
|
|
176
176
|
if (!existsSync(f))
|
|
177
177
|
return [];
|
|
178
178
|
const entries = [];
|
|
@@ -182,8 +182,11 @@ export class RunStore {
|
|
|
182
182
|
const o = JSON.parse(line);
|
|
183
183
|
if (typeof o.taken === 'number')
|
|
184
184
|
taken.add(o.taken);
|
|
185
|
-
else if (typeof o.id === 'number'
|
|
186
|
-
|
|
185
|
+
else if (typeof o.id === 'number') {
|
|
186
|
+
const rest = parse(o);
|
|
187
|
+
if (rest)
|
|
188
|
+
entries.push({ id: o.id, ...rest });
|
|
189
|
+
}
|
|
187
190
|
}
|
|
188
191
|
catch {
|
|
189
192
|
// ligne vide ou illisible : ignorée
|
|
@@ -191,11 +194,22 @@ export class RunStore {
|
|
|
191
194
|
}
|
|
192
195
|
return entries.filter((e) => !taken.has(e.id));
|
|
193
196
|
}
|
|
197
|
+
/** Numéro suivant d'une file : un de plus que le plus grand déjà écrit. */
|
|
198
|
+
nextId(file) {
|
|
199
|
+
const f = join(this.dir, file);
|
|
200
|
+
const last = existsSync(f) ? [...readFileSync(f, 'utf8').matchAll(/^\{"id":(\d+),/gm)].map((m) => Number(m[1])) : [];
|
|
201
|
+
return Math.max(0, ...last) + 1;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* File des réponses du lead à une vague vivante (L407) : `answers.log`, une ligne JSON par réponse déposée (`{id, lot, text}`).
|
|
205
|
+
* La vague la relit entre deux étapes.
|
|
206
|
+
*/
|
|
207
|
+
answerQueue() {
|
|
208
|
+
return this.queue('answers.log', (o) => (typeof o.lot === 'string' && typeof o.text === 'string' ? { lot: o.lot, text: o.text } : null));
|
|
209
|
+
}
|
|
194
210
|
/** Dépose une réponse dans la file ; rend son numéro. */
|
|
195
211
|
requestAnswer(lot, text) {
|
|
196
|
-
const
|
|
197
|
-
const last = existsSync(f) ? [...readFileSync(f, 'utf8').matchAll(/^\{"id":(\d+),/gm)].map((m) => Number(m[1])) : [];
|
|
198
|
-
const id = Math.max(0, ...last) + 1;
|
|
212
|
+
const id = this.nextId('answers.log');
|
|
199
213
|
appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ id, lot, text })}\n`);
|
|
200
214
|
return id;
|
|
201
215
|
}
|
|
@@ -203,6 +217,30 @@ export class RunStore {
|
|
|
203
217
|
markAnswerTaken(id) {
|
|
204
218
|
appendFileSync(join(this.dir, 'answers.log'), `${JSON.stringify({ taken: id })}\n`);
|
|
205
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* File des lots à ajouter à une vague vivante (L421, `--add`) : `adds.log`, une ligne JSON par demande (`{id, lot, project?, model?}`), le projet étant écrit comme le lead l'a donné
|
|
222
|
+
* (nom de dossier depuis le dossier de lancement). La vague la relit entre deux étapes, comme celle des réponses.
|
|
223
|
+
*/
|
|
224
|
+
addQueue() {
|
|
225
|
+
return this.queue('adds.log', (o) => (typeof o.lot === 'string' ? { lot: o.lot, ...(typeof o.project === 'string' ? { project: o.project } : {}), ...(typeof o.model === 'string' ? { model: o.model } : {}) } : null));
|
|
226
|
+
}
|
|
227
|
+
/** Dépose un lot à ajouter ; rend son numéro. */
|
|
228
|
+
requestAdd(t) {
|
|
229
|
+
const id = this.nextId('adds.log');
|
|
230
|
+
this.appendAddLine({ id, lot: t.lot, ...(t.project ? { project: t.project } : {}), ...(t.model ? { model: t.model } : {}) });
|
|
231
|
+
return id;
|
|
232
|
+
}
|
|
233
|
+
/** La demande `id` est prise (lot ajouté ou écarté) : elle ne sera plus relue. */
|
|
234
|
+
markAddTaken(id) {
|
|
235
|
+
this.appendAddLine({ taken: id });
|
|
236
|
+
}
|
|
237
|
+
/** Ajoute une ligne à `adds.log`, après un saut de ligne si une écriture interrompue a laissé la dernière sans le sien (sinon les deux lignes se collent et sont perdues). */
|
|
238
|
+
appendAddLine(o) {
|
|
239
|
+
const f = join(this.dir, 'adds.log');
|
|
240
|
+
const before = existsSync(f) ? readFileSync(f, 'utf8') : '';
|
|
241
|
+
const cut = before !== '' && !before.endsWith('\n');
|
|
242
|
+
appendFileSync(f, `${cut ? '\n' : ''}${JSON.stringify(o)}\n`);
|
|
243
|
+
}
|
|
206
244
|
journal(line) {
|
|
207
245
|
appendFileSync(join(this.dir, 'journal.log'), `${new Date().toISOString()} ${line}\n`);
|
|
208
246
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { git, gitFailure, tryGit } from './worktree.js';
|
|
4
|
+
import { lotKey, lotRepoPaths } from './state.js';
|
|
4
5
|
/** Voie prévue pour un travail qui doit toucher un dépôt tiers (L456) : une branche à part, jamais la branche principale. */
|
|
5
6
|
export const wipBranch = (project, lot) => `wip/${project}-${lot}`;
|
|
6
7
|
const real = (p) => {
|
|
@@ -119,3 +120,11 @@ export function tierMoved(repo, before, after, lots) {
|
|
|
119
120
|
}
|
|
120
121
|
return null;
|
|
121
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Les dépôts tiers de la session qui le sont encore à sa fin (L421) : un lot ajouté (`--add`) pendant la session a pu prendre l'un d'eux, dont la branche principale bouge alors
|
|
125
|
+
* légitimement, comme celle des autres lots de la vague (R14).
|
|
126
|
+
*/
|
|
127
|
+
export function stillThird(before, wave, mine) {
|
|
128
|
+
const ofOthers = new Set(wave.filter((x) => lotKey(x.project, x.lot) !== mine).flatMap(lotRepoPaths));
|
|
129
|
+
return [...before].filter(([repo]) => !ofOthers.has(repo));
|
|
130
|
+
}
|
|
@@ -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
|
}
|
package/dist/plan-commit.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|