@sylad/cadence 1.12.1 → 1.14.1
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/README.md +3 -0
- package/agents/reference-planner.md +26 -0
- package/bin/cadence.js +8 -1
- package/dist/cli.js +197 -22
- package/dist/config-set.js +252 -0
- package/dist/config.js +49 -9
- package/dist/corrections.js +2 -0
- package/dist/git.js +42 -1
- package/dist/lead.js +5 -1
- package/dist/orchestrate/accept.js +63 -1
- package/dist/orchestrate/baseline.js +39 -3
- package/dist/orchestrate/briefs.js +1 -1
- package/dist/orchestrate/changelog-merge.js +40 -1
- package/dist/orchestrate/command.js +120 -19
- package/dist/orchestrate/continue.js +5 -2
- package/dist/orchestrate/cycle.js +318 -74
- package/dist/orchestrate/decisions.js +33 -0
- package/dist/orchestrate/guard.js +16 -0
- package/dist/orchestrate/integrate.js +26 -3
- package/dist/orchestrate/launch.js +4 -1
- package/dist/orchestrate/state.js +1 -1
- package/dist/orchestrate/workers.js +71 -0
- package/dist/orchestrate/worktree.js +15 -4
- package/dist/perf.js +12 -2
- package/dist/plan-commit.js +88 -0
- package/dist/plan.js +41 -3
- package/dist/ref/analyse.js +34 -4
- package/dist/ref/command.js +2 -1
- package/dist/ref/coverage.js +136 -0
- package/dist/ref/diff.js +99 -15
- package/dist/release.js +249 -0
- package/dist/session.js +7 -1
- package/dist/stale-notes.js +22 -0
- package/dist/verify-gate.js +138 -0
- package/docs/deliver.md +37 -0
- package/docs/fonctionnement.md +3 -1
- package/docs/lead.md +18 -1
- package/docs/orchestrate.md +71 -13
- package/docs/overview.md +5 -2
- package/docs/perf.md +4 -1
- package/docs/raf.md +49 -1
- package/docs/ref.md +61 -3
- package/docs/releasing.md +43 -2
- package/docs/session.md +8 -0
- package/docs/skills.md +1 -0
- package/docs/spec.md +2 -0
- package/docs/stabilite.md +2 -0
- package/docs/whats-new.md +9 -0
- package/package.json +3 -3
- package/skills/lead/SKILL.md +6 -4
- package/skills/orchestrate/SKILL.md +1 -1
- package/skills/session-close/SKILL.md +2 -0
- package/skills/session-start/SKILL.md +2 -1
- package/templates/orchestrate/fix-docs.md +1 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +2 -0
- package/templates/orchestrate/implement.md +4 -0
|
@@ -2,34 +2,39 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { parse } from 'yaml';
|
|
3
3
|
import { toolBinOf, withoutLaunchVars } from './snapshot.js';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
5
|
+
import { delimiter, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
6
6
|
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { coveredTasks, lotCommits, lotWork } from '../audit.js';
|
|
9
|
-
import { readDocsConfig } from '../config.js';
|
|
9
|
+
import { readDocsConfig, readPlanConfig } from '../config.js';
|
|
10
|
+
import { PLAN_ACTIONS, leadAnswerMessage, planCommitMessage } from '../plan-commit.js';
|
|
10
11
|
import { articleRepoDir, articlesBrief, docSyncBrief, docSyncGaps, filesOf, gapMessage } from '../docsync.js';
|
|
11
12
|
import { testGateMissing } from '../testgate.js';
|
|
12
|
-
import { changedFiles, gitCommonDir, readCommits, resolveCommit } from '../git.js';
|
|
13
|
+
import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restorePaths } from '../git.js';
|
|
14
|
+
import { blockingLots, gitCommitsIn } from './table.js';
|
|
13
15
|
import { citedRefs } from '../link.js';
|
|
14
16
|
import { repoShas, repoWork } from '../repos.js';
|
|
15
|
-
import { isOpen } from '../plan.js';
|
|
17
|
+
import { isOpen, RafError } from '../plan.js';
|
|
16
18
|
import { isPlanOnly } from '../audit.js';
|
|
17
|
-
import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
|
|
19
|
+
import { compareBaseline, failedTests, hasUnnamedFailure, jestTestFiles } from './baseline.js';
|
|
20
|
+
import { replayFile, replayRanFile, shellArg, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
|
|
18
21
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
19
22
|
import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
|
|
20
23
|
import { SlotAbandoned } from './registry.js';
|
|
21
24
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
22
|
-
import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
|
|
25
|
+
import { cleanPlaywrightOutput, pushed, remoteAdvances, snapshot } from './guard.js';
|
|
23
26
|
import { sumTokens } from './result.js';
|
|
24
27
|
import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
|
|
25
28
|
import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
|
|
26
29
|
import { acceptationFile, acceptCommands, isAcceptList, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, planAcceptRuns, redSha, resealedRouge, sealAcceptation, setVert } from './accept.js';
|
|
30
|
+
import { lotIds, referenceIdsText } from '../ref/coverage.js';
|
|
27
31
|
import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
|
|
28
32
|
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
29
33
|
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
30
34
|
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
35
|
+
import { decisionsText, humanDecisions } from './decisions.js';
|
|
31
36
|
import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
32
|
-
import { advance, INTEGRATE_WAIT_MS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
|
|
37
|
+
import { advance, baseMoved, INTEGRATE_WAIT_MS, MAX_REPLAYS, mergeBase, patchId, rebaseOnto, repoBranch, takeRepoWrite } from './integrate.js';
|
|
33
38
|
import { mutexFor } from './mutex.js';
|
|
34
39
|
import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
|
|
35
40
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
@@ -109,22 +114,68 @@ const stop = (c, status, outcome, noResult = false) => {
|
|
|
109
114
|
function trackedPaths(s) {
|
|
110
115
|
return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
|
|
111
116
|
}
|
|
117
|
+
/** Le gabarit `plan.commit` du projet (L343), undefined sans. */
|
|
118
|
+
const planTemplate = (c) => readPlanConfig(join(c.lot.repo, 'cadence.yaml'), c.lot.repo)?.commit;
|
|
119
|
+
/** Le message d'un commit de plan de la vague : le gabarit `plan.commit` du projet (L343), à défaut le message d'avant. */
|
|
120
|
+
function planMessage(c, action) {
|
|
121
|
+
return planCommitMessage(planTemplate(c), { lot: c.lot.lot, action, wave: c.wave.id });
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Ce que le lead a modifié dans le plan sans commiter, pris AVANT que cadence n'écrive : les fichiers suivis modifiés que `commitPlan` accepte (le plan, ce qui est
|
|
125
|
+
* tenu avec lui, les acceptation.json), avec leur contenu. Un commit de plan refusé (hook) les remet tels quels au lieu de les ramener à HEAD (L343).
|
|
126
|
+
*/
|
|
127
|
+
function planBefore(c, repo = c.lot.repo) {
|
|
128
|
+
const ownFile = planOwnFile(c, repo);
|
|
129
|
+
const kept = new Map();
|
|
130
|
+
for (const f of trackedDirty(repo).filter(ownFile)) {
|
|
131
|
+
try {
|
|
132
|
+
kept.set(f, readFileSync(join(repo, f), 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
kept.set(f, null);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return kept;
|
|
139
|
+
}
|
|
112
140
|
/**
|
|
113
141
|
* Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé.
|
|
114
142
|
* Dans le dépôt du projet (`root` par défaut) : jamais dans un worktree de lot (R21). Les commits de TRAVAIL du cycle (scellé et `vert: true` de l'acceptation)
|
|
115
143
|
* se font, eux, dans `wd(c)`, sur la branche du lot.
|
|
144
|
+
* `before` : ce que le lead avait modifié sans commiter avant l'écriture (`planBefore`) ; un commit refusé le remet, et ramène à HEAD ce que cadence seule a écrit.
|
|
116
145
|
*/
|
|
117
|
-
async function commitPlan(c, message, root = c.lot.repo) {
|
|
146
|
+
async function commitPlan(c, message, root = c.lot.repo, before = new Map()) {
|
|
118
147
|
const repo = root;
|
|
119
148
|
const ownFile = planOwnFile(c, repo);
|
|
120
|
-
|
|
149
|
+
// Les fichiers de plan.onWrite (L346) entrent même non suivis : le premier plan publié n'est pas encore au dépôt.
|
|
150
|
+
const produced = c.loadPlan().produced;
|
|
151
|
+
const dirty = [...new Set([...trackedPaths(await snapshot(repo, { remote: false })), ...(produced.length ? dirtyPaths(repo, produced) : [])])];
|
|
121
152
|
if (dirty.length === 0)
|
|
122
153
|
return null;
|
|
123
154
|
const foreign = dirty.filter((f) => !ownFile(f));
|
|
124
155
|
if (foreign.length)
|
|
125
156
|
return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
|
|
126
157
|
git(repo, 'add', '--', ...dirty);
|
|
127
|
-
|
|
158
|
+
try {
|
|
159
|
+
git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
// Commit refusé (hook commit-msg…, L343) : le plan n'a pas à rester indexé ni modifié par cadence, le lot suivant y verrait un dépôt sale et serait rendu.
|
|
163
|
+
// Ce que le lead avait modifié sans commiter (raf add, raf note pendant la session) est remis, pas effacé.
|
|
164
|
+
git(repo, 'restore', '--staged', '--', ...dirty);
|
|
165
|
+
const own = dirty.filter((f) => !before.has(f));
|
|
166
|
+
// restorePaths : un fichier de plan.onWrite (L346) pas encore au dépôt est retiré, `git restore --source=HEAD` échouerait sur lui (pathspec) et laisserait le plan modifié.
|
|
167
|
+
if (own.length)
|
|
168
|
+
restorePaths(repo, own);
|
|
169
|
+
for (const [f, text] of before) {
|
|
170
|
+
if (!dirty.includes(f))
|
|
171
|
+
continue;
|
|
172
|
+
if (text === null)
|
|
173
|
+
rmSync(join(repo, f), { force: true });
|
|
174
|
+
else
|
|
175
|
+
writeFileSync(join(repo, f), text);
|
|
176
|
+
}
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
128
179
|
return null;
|
|
129
180
|
}
|
|
130
181
|
/**
|
|
@@ -136,8 +187,8 @@ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lo
|
|
|
136
187
|
* Échecs des tests au sha de départ du lot (L213) : la commande est rejouée dans un worktree jetable de ce sha, hors du dépôt,
|
|
137
188
|
* avec des liens vers les dossiers ignorés de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de build exclus, une commande
|
|
138
189
|
* de tests qui y écrit toucherait le build du lot) pour que les dépendances s'y trouvent. Les `node_modules` sont recréés entrée par entrée
|
|
139
|
-
* pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`).
|
|
140
|
-
* l'appelant garde alors les tests pour rouges.
|
|
190
|
+
* 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.
|
|
191
|
+
* Null quand le worktree ne peut être monté : l'appelant garde alors les tests pour rouges.
|
|
141
192
|
*/
|
|
142
193
|
async function baselineFailures(c, command) {
|
|
143
194
|
const l = c.lot;
|
|
@@ -156,7 +207,13 @@ async function baselineFailures(c, command) {
|
|
|
156
207
|
if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
|
|
157
208
|
return null;
|
|
158
209
|
const r = await sh(c, command, 30 * 60_000, {}, dir);
|
|
159
|
-
|
|
210
|
+
// 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
|
|
211
|
+
// 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.
|
|
212
|
+
const found = r.code === 0 ? [] : failedTests(r.output);
|
|
213
|
+
const named = c.config.testReplay && found.length && !hasUnnamedFailure(r.output) ? found : [];
|
|
214
|
+
const { red: failures, unstable } = named.length ? await replayAlone(c, c.config.testReplay, named, r.output, dir) : { red: found, unstable: [] };
|
|
215
|
+
if (unstable.length)
|
|
216
|
+
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(' ; ')}`);
|
|
160
217
|
l.baseline = { sha, command, failures, verified: true };
|
|
161
218
|
save(c);
|
|
162
219
|
return failures;
|
|
@@ -195,9 +252,17 @@ async function againstBaseline(c, command, output) {
|
|
|
195
252
|
}
|
|
196
253
|
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
197
254
|
/** Commande du projet, asynchrone : la boucle d'événements reste libre (délai de l'autre créneau, signaux). Son groupe est tué au délai et au signal. */
|
|
255
|
+
/**
|
|
256
|
+
* PATH des commandes que le programme lance lui-même (porte, ligne de base, démarrage, acceptation, verdict) : le même que celui des sessions,
|
|
257
|
+
* le lien `node-bin/<version>` du .nvmrc du lot en tête (L351). Sans .nvmrc, rien : le PATH hérité reste tel quel.
|
|
258
|
+
*/
|
|
259
|
+
function nodePath(c) {
|
|
260
|
+
const link = c.lot.node?.link;
|
|
261
|
+
return link ? { PATH: [toolBinOf(c.wave.store.dir), link, process.env.PATH ?? ''].filter(Boolean).join(delimiter) } : {};
|
|
262
|
+
}
|
|
198
263
|
function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
|
|
199
264
|
return new Promise((resolve) => {
|
|
200
|
-
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
265
|
+
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...nodePath(c), ...(c.wave.testWorkers ? { [TEST_WORKERS_ENV]: String(c.wave.testWorkers) } : {}), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
201
266
|
const pid = child.pid;
|
|
202
267
|
if (pid === undefined) {
|
|
203
268
|
child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
|
|
@@ -252,7 +317,7 @@ async function startLot(c) {
|
|
|
252
317
|
plan.setStatus(c.lot.lot, 'doing', c.wave.today);
|
|
253
318
|
plan.save();
|
|
254
319
|
}
|
|
255
|
-
const dirty = await commitPlan(c,
|
|
320
|
+
const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.started));
|
|
256
321
|
if (dirty)
|
|
257
322
|
return dirty;
|
|
258
323
|
}
|
|
@@ -306,7 +371,11 @@ function checksText(c) {
|
|
|
306
371
|
const k = c.lot.checks;
|
|
307
372
|
if (!k?.runs.length || k.head !== git(wd(c), 'rev-parse', 'HEAD'))
|
|
308
373
|
return declared;
|
|
309
|
-
const runs = k.runs.map((r) =>
|
|
374
|
+
const runs = k.runs.map((r) => {
|
|
375
|
+
const alone = r.instables?.length ? `${r.instables.length} test(s) red under load in the whole suite, green when replayed alone, not defects: ${r.instables.join(' ; ')}` : '';
|
|
376
|
+
const state = r.code === 0 ? 'green' : r.dejaRouges ? `RED, exit code ${r.code}, but every failing test (${r.dejaRouges}) was already red at the start sha${c.lot.baseline?.sha ? ` ${c.lot.baseline.sha.slice(0, 7)}` : ''}: not a defect of this lot, do not report them as findings` : `RED, exit code ${r.code}`;
|
|
377
|
+
return `- \`${r.command}\` (${r.label}): ${state}${alone ? `${r.code === 0 ? ';' : '. Also'} ${alone}` : ''}`;
|
|
378
|
+
});
|
|
310
379
|
const ran = [`The program already ran these checks at HEAD ${k.head.slice(0, 7)}, just before this review:`, ...runs, 'Take these results as given: do not rebuild, do not rerun the whole suite, and do not wait on them. Run only a check they do not cover (a targeted test of a case you doubt), and list under "nonVerifie" what neither they nor you verified.'].join('\n');
|
|
311
380
|
return declared ? `${ran}\n${declared}` : ran;
|
|
312
381
|
}
|
|
@@ -412,12 +481,14 @@ function avalOf(c, kind) {
|
|
|
412
481
|
// la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
|
|
413
482
|
return kind === 'review-small' && l.small && l.visible && screen ? `${text}\n${avalFicheText('ux', o)}` : text;
|
|
414
483
|
}
|
|
484
|
+
/** Consigne de la part de machine de la session (L350) ; vide quand la vague n'en a pas calculé. */
|
|
485
|
+
const workersText = (c) => (c.wave.testWorkers ? testWorkersText(c.wave.testWorkers) : '');
|
|
415
486
|
function briefFor(c, kind, local = false) {
|
|
416
487
|
const plan = c.loadPlan();
|
|
417
488
|
const l = c.lot;
|
|
418
489
|
const lot = plan.lot(l.lot);
|
|
419
490
|
const tmpDir = c.wave.store.tmpDir(l.project, l.lot);
|
|
420
|
-
const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
|
|
491
|
+
const vars = { chemin: wd(c), lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', spec: '', ux: uxText(c), choix: choixText(c.lot.choix ?? []), checks: kind === 'review' || kind === 'review-small' ? checksText(c) : '', docs: kind === 'review' || kind === 'review-small' ? docsText(c) : '', news: '', captures: '', tmp: tmpText(tmpDir), workers: kind === 'implement' || kind === 'fix' ? workersText(c) : '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []), integration: c.integration ? integrationText(c.integration.base) : '' };
|
|
421
492
|
if (l.visible) {
|
|
422
493
|
const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
|
|
423
494
|
vars.news = newsText(l.lot, pwDir);
|
|
@@ -426,6 +497,11 @@ function briefFor(c, kind, local = false) {
|
|
|
426
497
|
if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
|
|
427
498
|
vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
|
|
428
499
|
vars.fiche = hasSpecStage(l) ? avalOf(c, kind) : '';
|
|
500
|
+
// la vérification des ids d'un document de référence sur l'écran fini : la passe ux, ou la revue courte d'un petit lot visible qui n'a que celle-là (comme la conformité, avalOf)
|
|
501
|
+
if (kind === 'ux' || (kind === 'review-small' && l.small && l.visible))
|
|
502
|
+
vars.ux = [vars.ux, referenceIdsText('ux', lotIds({ ...lot, goal: l.goal ?? lot.goal }))].filter(Boolean).join('\n');
|
|
503
|
+
if (kind === 'implement' || kind === 'fix')
|
|
504
|
+
vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? plan.lots().find((x) => x.id === lot.parent) : undefined));
|
|
429
505
|
if (kind === 'precheck' && local)
|
|
430
506
|
vars.lecture = localReading({ ...lot, goal: l.goal ?? lot.goal });
|
|
431
507
|
if (kind === 'spec') {
|
|
@@ -443,6 +519,7 @@ function briefFor(c, kind, local = false) {
|
|
|
443
519
|
template: l.screen ? ficheTemplate() : undefined,
|
|
444
520
|
templateBack: back || l.screen ? ficheBackTemplate() : undefined,
|
|
445
521
|
});
|
|
522
|
+
vars.spec = [vars.spec, referenceIdsText('spec', lotIds({ ...lot, goal: l.goal ?? lot.goal }))].filter(Boolean).join('\n\n');
|
|
446
523
|
const mine = lotCommitLines(c);
|
|
447
524
|
if (mine)
|
|
448
525
|
vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
|
|
@@ -494,7 +571,27 @@ function briefFor(c, kind, local = false) {
|
|
|
494
571
|
/** Tokens comptés par les sessions d'un lot (un lot repris compte ses étapes d'avant). */
|
|
495
572
|
export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?? 0), 0);
|
|
496
573
|
/** Le lot a dépensé son propre budget (L78) : il ne compte plus que sur le lead, la vague garde le sien pour les autres. */
|
|
497
|
-
const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
|
|
574
|
+
const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget - heldForEscalation(l);
|
|
575
|
+
/**
|
|
576
|
+
* Coût réservé à l'escalade de modèle (L349) : une correction puis une revue neuve complète, jouées au modèle d'escalade. `lotBudget` l'ajoute au budget dérivé de
|
|
577
|
+
* l'estimate quand `orchestrate.escalation` est déclarée, comme REVIEW_RESERVE paie la dernière passe : sans elle, l'escalade (L337) ne jouait que sur les lots
|
|
578
|
+
* qui avaient peu consommé (cadence L341 494 k / 600 k et L342 572 k / 600 k, rendus « escalade fable non jouée (budget) »). ESTIMATION, aucune mesure : aucune
|
|
579
|
+
* escalade n'a été jouée depuis L337 (le seul journal qui en porte une, cadence L342, l'a laissée non jouée). Elle est déduite des journaux `.cadence/runs` du
|
|
580
|
+
* 2026-10-26 (823 passes fix : p90 67 k ; 1 009 revues complètes : p90 109 k), soit 176 k, arrondis à 175 k, au nombre de tokens comptés du modèle des passes
|
|
581
|
+
* (le comptage ne distingue pas le tarif du modèle). À remesurer sur les premières escalades jouées (`cadence perf`, « Escalade de modèle »).
|
|
582
|
+
*/
|
|
583
|
+
export const ESCALATION_RESERVE = 175_000;
|
|
584
|
+
/** Part du budget que les passes ordinaires doivent laisser à l'escalade : retenue tant que l'escalade n'est pas engagée (ou écartée), libérée ensuite. */
|
|
585
|
+
export const heldForEscalation = (l) => (l.escalation ? 0 : (l.escalationReserve ?? 0));
|
|
586
|
+
/**
|
|
587
|
+
* Compteur du lot face au budget de ses passes ordinaires, pour les messages de refus (L349) : la part retenue pour l'escalade en est déduite et nommée
|
|
588
|
+
* (« 6000 / 160999 tokens comptés, 175000 retenus pour l'escalade sur 335999 »), sinon un refus dans cette part contredirait ses propres chiffres ; sans
|
|
589
|
+
* part retenue, le texte d'avant (« 6000 / 335999 tokens comptés »).
|
|
590
|
+
*/
|
|
591
|
+
const budgetCount = (l) => {
|
|
592
|
+
const held = heldForEscalation(l);
|
|
593
|
+
return `${lotSpent(l)} / ${(l.budget ?? 0) - held} tokens comptés${held ? `, ${held} retenus pour l'escalade sur ${l.budget}` : ''}`;
|
|
594
|
+
};
|
|
498
595
|
/**
|
|
499
596
|
* Coût réservé à une passe de correction et à la revue courte qui la suit (L145, étendu L218). Réserve fixe de 155 k tokens comptés :
|
|
500
597
|
* p90 d'une passe fix (65 k) plus p90 d'une revue courte Opus (88 k), arrondi. Mesure faite le 2026-10-15 sur les journaux
|
|
@@ -520,8 +617,8 @@ export function budgetStep(l) {
|
|
|
520
617
|
const step = named ?? l.next;
|
|
521
618
|
return step && step !== 'integrate' && WRITE_STEPS.includes(step) ? step : null;
|
|
522
619
|
}
|
|
523
|
-
/** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
|
|
524
|
-
const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
|
|
620
|
+
/** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. La part de l'escalade (L349) n'est pas à dépenser, sauf pour décider l'escalade elle-même (`escalating`). */
|
|
621
|
+
const fixAffordable = (l, escalating = false) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget - (escalating ? 0 : heldForEscalation(l));
|
|
525
622
|
/** Cause d'une suspension demandée par `--stop-after-current` (L79). */
|
|
526
623
|
export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
|
|
527
624
|
/** Le lot a-t-il été écarté de la vague par `--drop` (L79) ? */
|
|
@@ -642,6 +739,7 @@ async function session(c, kind, local = false) {
|
|
|
642
739
|
prodAccess: c.config.prodAccess,
|
|
643
740
|
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), tmpDir, ...neighbourDirs(l), ...(kind === 'fix' && l.docsFix ? (docsConfig(c).articles ?? []).map((r) => articleRepoDir(l.repo, r)) : [])],
|
|
644
741
|
nodeBin: l.node?.link,
|
|
742
|
+
testWorkers: w.testWorkers,
|
|
645
743
|
toolBin: toolBinOf(w.store.dir),
|
|
646
744
|
tmpDir,
|
|
647
745
|
mcpConfig,
|
|
@@ -826,10 +924,13 @@ async function session(c, kind, local = false) {
|
|
|
826
924
|
const others = neighbours.map((repo, i) => ({ repo, before: beforeOthers[i], after: afterOthers[i] }));
|
|
827
925
|
step.headAfter = after.head ?? undefined;
|
|
828
926
|
// Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
|
|
829
|
-
const watched = [{ where: '', path: wd(c), before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
|
|
927
|
+
const watched = [{ where: '', path: wd(c), repo: c.lot.repo, before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, repo: o.repo.path, before: o.before, after: o.after }))];
|
|
830
928
|
// Les incidents d'abord, dans TOUS les dépôts surveillés (projet et voisins) : un push ou un hook de garde supprimé où que ce soit arrête la vague, avant tout traitement d'un HEAD déplacé ou d'un lot rendu (L214).
|
|
831
|
-
for (const { where, before: b, after: a } of watched) {
|
|
832
|
-
|
|
929
|
+
for (const { where, repo, before: b, after: a } of watched) {
|
|
930
|
+
const delivered = pushed(b, a) ? leadDelivery(c, repo, b, a) : null;
|
|
931
|
+
if (delivered)
|
|
932
|
+
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} : push du lead (livraison ${delivered.length ? `de ${delivered.join(', ')}` : 'du plan'}) toléré${where}`);
|
|
933
|
+
else if (pushed(b, a)) {
|
|
833
934
|
step.status = 'failed';
|
|
834
935
|
step.cause = `push détecté${where}`;
|
|
835
936
|
w.incident = `push détecté${where} pendant ${lotKey(l.project, l.lot)} (${kind})`;
|
|
@@ -949,6 +1050,40 @@ function headStranded(path, b, a) {
|
|
|
949
1050
|
}
|
|
950
1051
|
return null;
|
|
951
1052
|
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Le push du lead pour livrer un lot prêt de la vague (L352) : `git push origin <sha>:main`, le chemin « livrable jusqu'à <sha> » du skill lead.
|
|
1055
|
+
* Toléré quand chaque branche distante qui a bougé avance en avant (ou est créée) vers un sha ancêtre du HEAD du dépôt et que ce sha ne contient aucun
|
|
1056
|
+
* commit d'un lot de la vague qui n'est pas prêt (la garde de `deliver --sha`, `blockingLots`) : ne restent que des commits de lots prêts ou du plan.
|
|
1057
|
+
* Renvoie les lots prêts livrés (vide : du plan seulement) ; null pour tout autre push, qui reste un incident — dont une branche supprimée, un push
|
|
1058
|
+
* forcé, un sha hors de l'histoire de HEAD ou des commits d'un lot en cours ou illisibles.
|
|
1059
|
+
*/
|
|
1060
|
+
function leadDelivery(c, repo, before, after) {
|
|
1061
|
+
const moved = remoteAdvances(before, after);
|
|
1062
|
+
if (!moved)
|
|
1063
|
+
return null;
|
|
1064
|
+
const lots = c.wave.store.lots().filter((x) => lotKey(x.project, x.lot) !== lotKey(c.lot.project, c.lot.lot));
|
|
1065
|
+
const here = [c.lot, ...lots].filter((x) => x.repo === repo || (x.repos ?? []).some((r) => r.path === repo));
|
|
1066
|
+
const delivered = new Set();
|
|
1067
|
+
for (const { from, to } of moved) {
|
|
1068
|
+
if (!isAncestor(repo, to, 'HEAD') || (from !== null && !isAncestor(repo, from, to)))
|
|
1069
|
+
return null;
|
|
1070
|
+
const { blocking, unreadable } = blockingLots(here, repo, to);
|
|
1071
|
+
if (blocking.length > 0 || unreadable.length > 0)
|
|
1072
|
+
return null;
|
|
1073
|
+
for (const x of here) {
|
|
1074
|
+
if (x.status !== 'ready')
|
|
1075
|
+
continue;
|
|
1076
|
+
try {
|
|
1077
|
+
if (gitCommitsIn(x, repo).some((k) => isAncestor(repo, k, to) && (from === null || !isAncestor(repo, k, from))))
|
|
1078
|
+
delivered.add(lotKey(x.project, x.lot));
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return [...delivered];
|
|
1086
|
+
}
|
|
952
1087
|
/** Incident du LOT (L133) : la revue a laissé des traces (captures, commit), ni push ni garde supprimée — les lots des autres dépôts continuent. */
|
|
953
1088
|
function handBack(c, kind, report, step, where, path, b, a, left) {
|
|
954
1089
|
const l = c.lot;
|
|
@@ -1035,7 +1170,7 @@ function overBudget(c, reserve = false) {
|
|
|
1035
1170
|
// Une réponse du lead encore en attente n'a pas été consommée : elle ne doit pas se perdre sans qu'on le dise.
|
|
1036
1171
|
const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
|
|
1037
1172
|
const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
|
|
1038
|
-
const why = reserve ? `budget du lot : ${
|
|
1173
|
+
const why = reserve ? `budget du lot : ${budgetCount(l)} (${basis}), il ne reste pas de quoi payer la correction et la revue courte qui la suivrait (${REVIEW_RESERVE} réservés)` : `budget du lot atteint (${budgetCount(l)}, ${basis})`;
|
|
1039
1174
|
// Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
|
|
1040
1175
|
const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
|
|
1041
1176
|
// Passe d'écriture qui vient d'être jouée, nommée d'après le compteur : l.pass - 1 passes de correction l'ont précédée (minorPass reste vrai après la passe des mineurs). Sans passe jouée, un lot repris déjà revu compte ses tests rouges depuis la revue de sa branche ; jamais revu (L315), c'est l'implémentation qui les a laissés.
|
|
@@ -1397,7 +1532,7 @@ function sourceAnsweredRules(c) {
|
|
|
1397
1532
|
if (!ids.length)
|
|
1398
1533
|
return false;
|
|
1399
1534
|
git(wd(c), 'add', '--', fiche);
|
|
1400
|
-
git(wd(c), 'commit', '-q', '-m',
|
|
1535
|
+
git(wd(c), 'commit', '-q', '-m', leadAnswerMessage(planTemplate(c), l.lot, ids, c.wave.id), '--', fiche);
|
|
1401
1536
|
return false;
|
|
1402
1537
|
}
|
|
1403
1538
|
catch (e) {
|
|
@@ -1539,10 +1674,11 @@ async function noteHypotheses(c, lines, sujet) {
|
|
|
1539
1674
|
const fresh = texts.filter((t) => !have.has(t));
|
|
1540
1675
|
if (!fresh.length)
|
|
1541
1676
|
return null;
|
|
1677
|
+
const before = planBefore(c);
|
|
1542
1678
|
for (const t of fresh)
|
|
1543
1679
|
plan.note(l.lot, t, c.wave.today);
|
|
1544
1680
|
plan.save();
|
|
1545
|
-
return commitPlan(c,
|
|
1681
|
+
return commitPlan(c, planMessage(c, PLAN_ACTIONS.hypotheses(sujet)), c.lot.repo, before);
|
|
1546
1682
|
});
|
|
1547
1683
|
if (dirty) {
|
|
1548
1684
|
handBackPlan(c, `${HYPOTHESES_HANDBACK}${dirty}`);
|
|
@@ -1570,10 +1706,11 @@ async function noteMinors(c, rep, label) {
|
|
|
1570
1706
|
const fresh = [...new Set(texts)].filter((t) => !have.has(t));
|
|
1571
1707
|
if (!fresh.length)
|
|
1572
1708
|
return null;
|
|
1709
|
+
const before = planBefore(c);
|
|
1573
1710
|
for (const t of fresh)
|
|
1574
1711
|
plan.note(c.lot.lot, t, c.wave.today);
|
|
1575
1712
|
plan.save();
|
|
1576
|
-
return commitPlan(c,
|
|
1713
|
+
return commitPlan(c, planMessage(c, PLAN_ACTIONS.minors(label)), c.lot.repo, before);
|
|
1577
1714
|
});
|
|
1578
1715
|
if (dirty) {
|
|
1579
1716
|
handBackPlan(c, `${MINORS_HANDBACK}${label} : ${dirty}`);
|
|
@@ -1841,21 +1978,83 @@ async function programChecks(c, red, runs) {
|
|
|
1841
1978
|
if (r.code === 0)
|
|
1842
1979
|
continue;
|
|
1843
1980
|
const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
|
|
1844
|
-
|
|
1845
|
-
run.dejaRouges =
|
|
1846
|
-
const note = `${
|
|
1981
|
+
const onlyExisting = (n) => {
|
|
1982
|
+
run.dejaRouges = n;
|
|
1983
|
+
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`;
|
|
1847
1984
|
if (!l.warnings.includes(note))
|
|
1848
1985
|
l.warnings.push(note);
|
|
1986
|
+
};
|
|
1987
|
+
if (vs.kind === 'existing') {
|
|
1988
|
+
onlyExisting(vs.n);
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
// 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.
|
|
1992
|
+
// Sans `orchestrate.testReplay` rien n'est rejoué : le rouge est compté tel quel et rien n'est tenu pour instable (L354).
|
|
1993
|
+
const replay = label === 'tests' ? c.config.testReplay : undefined;
|
|
1994
|
+
const named = !replay ? [] : vs.kind === 'new' ? vs.nouveaux : !hasUnnamedFailure(r.output) ? failedTests(r.output) : [];
|
|
1995
|
+
const { red: still, unstable } = named.length ? await replayAlone(c, replay, named, r.output) : { red: [], unstable: [] };
|
|
1996
|
+
if (unstable.length) {
|
|
1997
|
+
noteUnstable(c, unstable);
|
|
1998
|
+
run.instables = unstable;
|
|
1999
|
+
}
|
|
2000
|
+
if (unstable.length && !still.length) {
|
|
2001
|
+
if (vs.kind === 'new' && vs.existants)
|
|
2002
|
+
run.dejaRouges = vs.existants;
|
|
2003
|
+
else
|
|
2004
|
+
run.code = 0;
|
|
1849
2005
|
continue;
|
|
1850
2006
|
}
|
|
2007
|
+
const alone = unstable.length ? `\n(${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls : instables, à ne pas corriger)` : '';
|
|
1851
2008
|
if (vs.kind === 'new') {
|
|
1852
|
-
|
|
2009
|
+
const left = vs.nouveaux.filter((t) => !unstable.includes(t));
|
|
2010
|
+
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}` });
|
|
1853
2011
|
return;
|
|
1854
2012
|
}
|
|
1855
|
-
|
|
2013
|
+
// 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.
|
|
2014
|
+
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');
|
|
2015
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${shown}${alone}` });
|
|
1856
2016
|
return;
|
|
1857
2017
|
}
|
|
1858
2018
|
}
|
|
2019
|
+
/** Sortie d'un lanceur qui n'a trouvé aucun test à rejouer : la ligne de fin du lanceur (vitest « No test files found, exiting with code », jest « No tests found, exiting with code », pytest « no tests ran in »), en début de ligne — pas ces mots cités dans le titre ou la sortie d'un test. */
|
|
2020
|
+
const NO_TEST_RAN = /^\W*(?:no test files? found, exiting with code|no tests? found, exiting with code|no tests ran in)\b/im;
|
|
2021
|
+
/** Rejeux d'un test rouge de la porte, seul (L354) : rouge aux deux, il est compté. */
|
|
2022
|
+
const REPLAYS = 2;
|
|
2023
|
+
/**
|
|
2024
|
+
* 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
|
|
2025
|
+
* qui passaient seuls : quatre corrections vides (4 à 11 min chacune). Un test vert à un rejeu est `unstable` ; rouge aux `REPLAYS` rejeux, ou sans fichier désignable (rien à rejouer seul), il reste dans `red`.
|
|
2026
|
+
*/
|
|
2027
|
+
async function replayAlone(c, template, names, output, cwd = wd(c)) {
|
|
2028
|
+
const jest = jestTestFiles(output);
|
|
2029
|
+
const fileOf = (n) => replayFile(n) ?? jest.get(n) ?? null;
|
|
2030
|
+
let red = names;
|
|
2031
|
+
const unstable = [];
|
|
2032
|
+
for (let i = 0; i < REPLAYS; i++) {
|
|
2033
|
+
const replayable = red.filter((n) => fileOf(n));
|
|
2034
|
+
const files = [...new Set(replayable.map((n) => fileOf(n)))];
|
|
2035
|
+
if (!files.length)
|
|
2036
|
+
break;
|
|
2037
|
+
const r = await sh(c, template.replaceAll('{files}', () => files.map(shellArg).join(' ')), undefined, undefined, cwd);
|
|
2038
|
+
// Seul un rejeu qui sort en 0 en ayant exécuté le FICHIER du test vaut vert. Un rejeu en échec ne conclut rien pour un test qu'il ne nomme pas : sur une porte composée (`a && b`) les fichiers n'atteignent que le dernier lanceur, un premier lanceur qui rougit coupe avant lui. Un rejeu sans test exécuté (filtre sans fichier, `passWithNoTests`) sort en 0 sans rien prouver, et un lanceur qui ignore sans rien dire un fichier qu'il ne trouve pas (vitest d'un autre espace de travail) sort en 0 sans l'avoir joué : le fichier doit figurer, chemin entier, sur une ligne du lanceur de la sortie du rejeu, hors écho de la commande par npm/pnpm/yarn (`replayRanFile`).
|
|
2039
|
+
const ran = r.code === 0 && !NO_TEST_RAN.test(r.output);
|
|
2040
|
+
const passed = ran ? replayable.filter((n) => replayRanFile(r.output, fileOf(n))) : [];
|
|
2041
|
+
unstable.push(...passed);
|
|
2042
|
+
red = red.filter((n) => !passed.includes(n));
|
|
2043
|
+
}
|
|
2044
|
+
return { red, unstable };
|
|
2045
|
+
}
|
|
2046
|
+
/** 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. */
|
|
2047
|
+
function noteUnstable(c, tests) {
|
|
2048
|
+
const l = c.lot;
|
|
2049
|
+
const fresh = tests.filter((t) => !(l.instables ?? []).includes(t));
|
|
2050
|
+
if (!fresh.length)
|
|
2051
|
+
return;
|
|
2052
|
+
l.instables = [...(l.instables ?? []), ...fresh];
|
|
2053
|
+
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(' ; ')}`;
|
|
2054
|
+
l.warnings.push(text);
|
|
2055
|
+
c.wave.log(`${lotKey(l.project, l.lot)} · ${text}`);
|
|
2056
|
+
save(c);
|
|
2057
|
+
}
|
|
1859
2058
|
/**
|
|
1860
2059
|
* 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
|
|
1861
2060
|
* 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.
|
|
@@ -2053,8 +2252,13 @@ async function work(c, kind) {
|
|
|
2053
2252
|
}
|
|
2054
2253
|
if (red.length === 0)
|
|
2055
2254
|
await programChecks(c, red, runs);
|
|
2056
|
-
// La session annonçait des tests rouges et le programme ne les voit
|
|
2057
|
-
|
|
2255
|
+
// La session annonçait des tests rouges et le programme ne les voit ni déjà rouges au départ (L213) ni instables sous charge (L354, verts rejoués seuls) : l'annonce vaut.
|
|
2256
|
+
// L361 : la porte du programme est verte (code 0) et la session n'annonce que des échecs de base (echecsDeBase non vide, nouveauxEchecs présent et vide) : rien de rouge pour le programme, rien de nouveau pour la session → revue, sans correction ni escalade.
|
|
2257
|
+
const gateGreen = runs.some((r) => r.label === 'tests' && r.code === 0);
|
|
2258
|
+
const baseOnly = declared.length > 0 && !!newFailures && !newFailures.length;
|
|
2259
|
+
if (deferred && red.length === 0 && gateGreen && baseOnly)
|
|
2260
|
+
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`);
|
|
2261
|
+
else if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && (r.dejaRouges || r.instables?.length)))
|
|
2058
2262
|
red.push(claimed);
|
|
2059
2263
|
if (red.length) {
|
|
2060
2264
|
toFix(c, red, 'tests rouges');
|
|
@@ -2074,7 +2278,7 @@ async function work(c, kind) {
|
|
|
2074
2278
|
if (l.pass >= MAX_PASSES - 1)
|
|
2075
2279
|
l.warnings.push(`porte « code sans test » non jouée : ${l.pass} passe(s) de correction déjà faite(s), la correction prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${missing.join(', ')})`);
|
|
2076
2280
|
else if (!fixAffordable(l))
|
|
2077
|
-
l.warnings.push(`porte « code sans test » non jouée : budget du lot (${
|
|
2281
|
+
l.warnings.push(`porte « code sans test » non jouée : budget du lot (${budgetCount(l)}), il ne reste pas de quoi payer la correction et la revue qui la suit (${missing.join(', ')})`);
|
|
2078
2282
|
else {
|
|
2079
2283
|
toFix(c, [{ source: 'code', gravite: 'majeur', fichier: missing[0], texte: `code modifié sans test (orchestrate.testGate, vérifié par le programme) : ${missing.join(', ')} — aucun fichier de test touché par les commits du lot. Écris les tests de chaque règle modifiée, nommés d'après la règle (test d'abord), puis commite avec le lot cité.` }], 'code sans test');
|
|
2080
2284
|
return;
|
|
@@ -2090,7 +2294,7 @@ async function work(c, kind) {
|
|
|
2090
2294
|
if (l.pass >= MAX_PASSES - 1)
|
|
2091
2295
|
l.warnings.push(`bibliothécaire non appelé : ${l.pass} passe(s) de correction déjà faite(s), sa passe prendrait la dernière et la revue arriverait sans passe pour ses majeurs (${late})`);
|
|
2092
2296
|
else if (!fixAffordable(l))
|
|
2093
|
-
l.warnings.push(`bibliothécaire non appelé : budget du lot (${
|
|
2297
|
+
l.warnings.push(`bibliothécaire non appelé : budget du lot (${budgetCount(l)}), il ne reste pas de quoi payer sa passe et la revue qui la suit (${late})`);
|
|
2094
2298
|
else {
|
|
2095
2299
|
l.docsFix = true;
|
|
2096
2300
|
toFix(c, gaps.map((g) => ({ source: 'code', gravite: 'majeur', fichier: g.touched[0], texte: gapMessage(l.lot, g) })), 'documentation en retard');
|
|
@@ -2145,7 +2349,7 @@ function toFix(c, constats, what) {
|
|
|
2145
2349
|
stop(c, 'handed-back', text);
|
|
2146
2350
|
return;
|
|
2147
2351
|
}
|
|
2148
|
-
if (!fixAffordable(l)) {
|
|
2352
|
+
if (!fixAffordable(l, true)) {
|
|
2149
2353
|
l.escalation = { model: esc.model, stage: 'done', issue: 'non jouée' };
|
|
2150
2354
|
c.wave.log(`${lotKey(l.project, l.lot)} · escalade ${esc.model} non jouée : budget du lot (${lotSpent(l)} / ${l.budget} tokens comptés)`);
|
|
2151
2355
|
stop(c, 'handed-back', `${text} ; escalade ${esc.model} non jouée (budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés, il ne reste pas de quoi payer la correction et la revue)`);
|
|
@@ -2383,13 +2587,21 @@ async function writeVerdict(c, verdict, commit, reporte) {
|
|
|
2383
2587
|
// Déjà écrit et commité au même sha (reprise de l'intégration, R23) : une seule fois.
|
|
2384
2588
|
if (c.tree && commit !== null && plan.lot(l.lot).review?.commit === commit && !covered.length && !trackedDirty(l.repo).includes(relative(l.repo, plan.path)))
|
|
2385
2589
|
return null;
|
|
2590
|
+
const before = planBefore(c);
|
|
2386
2591
|
plan.recordReview(l.lot, verdict, c.wave.today, commit, neighbours.length ? shas : undefined, reporte);
|
|
2387
2592
|
for (const k of covered)
|
|
2388
2593
|
plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
|
|
2389
2594
|
if (covered.length)
|
|
2390
2595
|
l.warnings.push(`sous-tâches closes (couvertes par des commits qui les citent, revue conforme) : ${covered.map((k) => `${l.lot}/${k.task} (${k.sha.slice(0, 7)})`).join(', ')}`);
|
|
2391
|
-
|
|
2392
|
-
|
|
2596
|
+
try {
|
|
2597
|
+
plan.save();
|
|
2598
|
+
}
|
|
2599
|
+
catch (e) {
|
|
2600
|
+
if (e instanceof RafError)
|
|
2601
|
+
return e.message;
|
|
2602
|
+
throw e;
|
|
2603
|
+
}
|
|
2604
|
+
const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.reviewRecorded), l.repo, before);
|
|
2393
2605
|
if (dirty)
|
|
2394
2606
|
return dirty;
|
|
2395
2607
|
}
|
|
@@ -2400,11 +2612,12 @@ async function writeVerdict(c, verdict, commit, reporte) {
|
|
|
2400
2612
|
if (plan.readonly && c.config.verdict) {
|
|
2401
2613
|
// La trace d'un verdict reporté va dans le texte, le seul endroit où l'outil du projet la lit (R23).
|
|
2402
2614
|
const text = reporte ? `${verdict} ; verdict reporté : ${reporte.de.slice(0, 7)} → ${reporte.vers.slice(0, 7)} (patch-id identique)` : verdict;
|
|
2615
|
+
const before = planBefore(c);
|
|
2403
2616
|
const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(text)), undefined, undefined, l.repo);
|
|
2404
2617
|
if (r.code !== 0)
|
|
2405
2618
|
l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
|
|
2406
2619
|
else {
|
|
2407
|
-
const dirty = await commitPlan(c,
|
|
2620
|
+
const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.reviewNoted), l.repo, before);
|
|
2408
2621
|
if (dirty)
|
|
2409
2622
|
return dirty;
|
|
2410
2623
|
}
|
|
@@ -2434,8 +2647,20 @@ function leadMoved(repo, base) {
|
|
|
2434
2647
|
const dirty = trackedDirty(repo);
|
|
2435
2648
|
return dirty.length ? `arbre du dépôt modifié (${dirty.join(', ')})` : null;
|
|
2436
2649
|
}
|
|
2650
|
+
/** Un refus d'écrire le plan par Plan.save() (plan.onWrite en échec, L346) est la cause d'un lot rendu, pas une erreur du lot. */
|
|
2651
|
+
async function refusal(write) {
|
|
2652
|
+
try {
|
|
2653
|
+
return await write();
|
|
2654
|
+
}
|
|
2655
|
+
catch (e) {
|
|
2656
|
+
if (e instanceof RafError)
|
|
2657
|
+
return e.message;
|
|
2658
|
+
throw e;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2437
2661
|
/** Une écriture de plan du programme (R21, R26) : sous la file et le verrou du dépôt, le dépôt encore sur la branche de départ, aucun fichier suivi modifié hors plan. Rend la cause du refus, null si l'écriture a eu lieu. Hors worktree, rien ne change. */
|
|
2438
|
-
async function planWrite(c,
|
|
2662
|
+
async function planWrite(c, doWrite) {
|
|
2663
|
+
const write = () => refusal(doWrite);
|
|
2439
2664
|
if (!c.tree)
|
|
2440
2665
|
return write();
|
|
2441
2666
|
const l = c.lot;
|
|
@@ -2511,43 +2736,62 @@ async function integrateLot(c) {
|
|
|
2511
2736
|
start = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
|
|
2512
2737
|
// Une branche déjà contenue dans celle du dépôt (arrêt entre l'avance et l'écriture du verdict) : ni rebase ni avance, seul le plan reste à écrire (R23).
|
|
2513
2738
|
if (!integrated(l.repo, t.branch, t.base)) {
|
|
2739
|
+
// Le verdict de départ et le commit relu servent à chaque tour : un rebase rejoué repart d'eux, pas des traces ni du sommet du tour précédent.
|
|
2740
|
+
const opening = verdict;
|
|
2514
2741
|
const reviewed = l.code?.head ?? git(t.dir, 'rev-parse', 'HEAD');
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
const tip = git(t.dir, 'rev-parse', 'HEAD');
|
|
2522
|
-
if (tip !== reviewed) {
|
|
2523
|
-
// Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
|
|
2524
|
-
const { red, runs } = await integratedChecks(c, key);
|
|
2525
|
-
l.checks = runs.length ? { head: tip, runs } : undefined;
|
|
2526
|
-
save(c);
|
|
2527
|
-
if (red.length)
|
|
2528
|
-
return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
|
|
2529
|
-
const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
|
|
2530
|
-
const after = patchId(t.dir, t.base, 'HEAD');
|
|
2531
|
-
if (before !== '' && before === after) {
|
|
2532
|
-
const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
|
|
2533
|
-
reporte = { de: reviewed, vers, patchId: before };
|
|
2742
|
+
for (let replays = 0;; replays++) {
|
|
2743
|
+
verdict = opening;
|
|
2744
|
+
reporte = undefined;
|
|
2745
|
+
const rebased = rebaseOnto(t.dir, t.base);
|
|
2746
|
+
if (rebased.kind === 'conflict') {
|
|
2747
|
+
return refuse(`intégration impossible : conflit en rebasant ${t.branch} sur ${t.base}${rebased.files.length ? ` (fichiers : ${rebased.files.join(', ')})` : ` (${rebased.detail})`} ; ${kept}`);
|
|
2534
2748
|
}
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
l.
|
|
2749
|
+
if (rebased.kind === 'rebased' && rebased.changelog > 0)
|
|
2750
|
+
c.wave.log(`${key} CHANGELOG.md : conflit d'ajout pur résolu par union sous [Unreleased] (${rebased.changelog} commit(s))`);
|
|
2751
|
+
if (rebased.kind === 'rebased' && rebased.moved > 0)
|
|
2752
|
+
c.wave.log(`${key} CHANGELOG.md : ${rebased.moved} entrée(s) du lot replacée(s) sous [Unreleased], une publication a eu lieu pendant la branche`);
|
|
2753
|
+
const tip = git(t.dir, 'rev-parse', 'HEAD');
|
|
2754
|
+
if (tip !== reviewed) {
|
|
2755
|
+
// Le rebase a changé les commits : tests et build rejoués au sommet rebasé, avant que la branche du dépôt ne bouge (R7).
|
|
2756
|
+
const { red, runs } = await integratedChecks(c, key);
|
|
2757
|
+
l.checks = runs.length ? { head: tip, runs } : undefined;
|
|
2758
|
+
save(c);
|
|
2759
|
+
if (red.length)
|
|
2760
|
+
return refuse(`intégration refusée : tests rouges au sommet intégré (${red.map(redExcerpt).join(' ; ')}) ; branche du dépôt inchangée, ${kept}`);
|
|
2761
|
+
const before = patchId(t.dir, mergeBase(t.dir, reviewed, t.base), reviewed);
|
|
2762
|
+
const after = patchId(t.dir, t.base, 'HEAD');
|
|
2763
|
+
if (before !== '' && before === after) {
|
|
2764
|
+
const vers = lotWork(histPlan(c), t.dir, l.lot)[0]?.sha ?? tip;
|
|
2765
|
+
reporte = { de: reviewed, vers, patchId: before };
|
|
2766
|
+
}
|
|
2767
|
+
else {
|
|
2768
|
+
// Le diff du lot n'est plus celui qui a été relu (contexte modifié, conflit résolu) : une revue courte du diff rebasé avant l'avance (R17).
|
|
2769
|
+
const short = await shortReview(c);
|
|
2770
|
+
if (short === null)
|
|
2771
|
+
return;
|
|
2772
|
+
if (short.refused)
|
|
2773
|
+
return refuse(`intégration refusée : revue courte du diff rebasé : ${short.refused} ; branche du dépôt inchangée, ${kept}`);
|
|
2774
|
+
verdict = `${verdict}${SHORT_REVIEW_TRACE}${short.verdict}`;
|
|
2775
|
+
l.verdict = verdict;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
// Les contrôles rejoués et la revue courte ont pu durer : le lead a pu changer de branche ou salir son arbre depuis le contrôle du départ (R24, R8).
|
|
2779
|
+
const late = leadMoved(l.repo, t.base);
|
|
2780
|
+
if (late)
|
|
2781
|
+
return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
|
|
2782
|
+
try {
|
|
2783
|
+
advance(l.repo, t.branch);
|
|
2784
|
+
break;
|
|
2785
|
+
}
|
|
2786
|
+
catch (e) {
|
|
2787
|
+
// L364 : un commit est tombé sur la branche du dépôt pendant le rebase, les contrôles ou la revue courte (plan du lead, autre lot) : l'avance n'est plus une avance rapide.
|
|
2788
|
+
if (!baseMoved(l.repo, t.branch, t.base))
|
|
2789
|
+
throw e;
|
|
2790
|
+
if (replays >= MAX_REPLAYS)
|
|
2791
|
+
return refuse(`intégration refusée : ${t.base} a encore avancé après ${MAX_REPLAYS} rebases rejoués ; branche du dépôt inchangée par le lot, ${kept}`);
|
|
2792
|
+
c.wave.log(`${key} ${t.base} a avancé pendant l'intégration, rebase rejoué (${replays + 1}/${MAX_REPLAYS})`);
|
|
2544
2793
|
}
|
|
2545
2794
|
}
|
|
2546
|
-
// Les contrôles rejoués et la revue courte ont pu durer : le lead a pu changer de branche ou salir son arbre depuis le contrôle du départ (R24, R8).
|
|
2547
|
-
const late = leadMoved(l.repo, t.base);
|
|
2548
|
-
if (late)
|
|
2549
|
-
return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
|
|
2550
|
-
advance(l.repo, t.branch);
|
|
2551
2795
|
}
|
|
2552
2796
|
const end = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
|
|
2553
2797
|
if (end !== start)
|