@sylad/cadence 1.13.0 → 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/dist/cli.js +174 -20
- package/dist/config-set.js +6 -3
- package/dist/config.js +47 -9
- package/dist/corrections.js +1 -0
- package/dist/git.js +11 -1
- package/dist/lead.js +5 -1
- package/dist/orchestrate/accept.js +63 -1
- package/dist/orchestrate/baseline.js +39 -3
- package/dist/orchestrate/changelog-merge.js +40 -1
- package/dist/orchestrate/command.js +97 -10
- package/dist/orchestrate/cycle.js +242 -63
- package/dist/orchestrate/decisions.js +2 -0
- package/dist/orchestrate/integrate.js +26 -3
- package/dist/orchestrate/launch.js +2 -0
- package/dist/orchestrate/state.js +1 -1
- package/dist/orchestrate/workers.js +51 -0
- package/dist/orchestrate/worktree.js +15 -4
- package/dist/perf.js +9 -1
- 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 +5 -1
- 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 +2 -1
- package/docs/lead.md +5 -4
- package/docs/orchestrate.md +57 -11
- package/docs/overview.md +3 -2
- package/docs/perf.md +3 -1
- package/docs/raf.md +48 -1
- package/docs/ref.md +61 -3
- package/docs/releasing.md +5 -3
- 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 +6 -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
|
@@ -2,21 +2,22 @@ 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, isAncestor, readCommits, resolveCommit } from '../git.js';
|
|
13
|
+
import { changedFiles, dirtyPaths, gitCommonDir, isAncestor, readCommits, resolveCommit, restorePaths } from '../git.js';
|
|
13
14
|
import { blockingLots, gitCommitsIn } from './table.js';
|
|
14
15
|
import { citedRefs } from '../link.js';
|
|
15
16
|
import { repoShas, repoWork } from '../repos.js';
|
|
16
|
-
import { isOpen } from '../plan.js';
|
|
17
|
+
import { isOpen, RafError } from '../plan.js';
|
|
17
18
|
import { isPlanOnly } from '../audit.js';
|
|
18
|
-
import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
|
|
19
|
-
import { TEST_WORKERS_ENV, testWorkersText } from './workers.js';
|
|
19
|
+
import { compareBaseline, failedTests, hasUnnamedFailure, jestTestFiles } from './baseline.js';
|
|
20
|
+
import { replayFile, replayRanFile, shellArg, TEST_WORKERS_ENV, testWorkersText } from './workers.js';
|
|
20
21
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
21
22
|
import { capturesText, headline, integrationText, localReading, newsText, objective, renderBrief, reposText, shotsText, TEMPLATES_DIR, tmpText } from './briefs.js';
|
|
22
23
|
import { SlotAbandoned } from './registry.js';
|
|
@@ -26,13 +27,14 @@ import { sumTokens } from './result.js';
|
|
|
26
27
|
import { cutReport, EXTENSION_MS, MAX_EXTENSIONS, workTimeoutMs } from './timeouts.js';
|
|
27
28
|
import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
|
|
28
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';
|
|
29
31
|
import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
|
|
30
32
|
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
31
33
|
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
32
34
|
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
33
35
|
import { decisionsText, humanDecisions } from './decisions.js';
|
|
34
36
|
import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
35
|
-
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';
|
|
36
38
|
import { mutexFor } from './mutex.js';
|
|
37
39
|
import { branchExists, git, gitFailure, integrated, linkIgnored, lotBranch, planFileSet, planSeenFrom, prepareTree, removeTree, trackedDirty } from './worktree.js';
|
|
38
40
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
@@ -112,22 +114,68 @@ const stop = (c, status, outcome, noResult = false) => {
|
|
|
112
114
|
function trackedPaths(s) {
|
|
113
115
|
return s.tracked.map((l) => l.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''));
|
|
114
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
|
+
}
|
|
115
140
|
/**
|
|
116
141
|
* Commit d'entretien du plan, chemins explicites. Rend un message d'arbre sale quand autre chose que le plan a bougé.
|
|
117
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)
|
|
118
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.
|
|
119
145
|
*/
|
|
120
|
-
async function commitPlan(c, message, root = c.lot.repo) {
|
|
146
|
+
async function commitPlan(c, message, root = c.lot.repo, before = new Map()) {
|
|
121
147
|
const repo = root;
|
|
122
148
|
const ownFile = planOwnFile(c, repo);
|
|
123
|
-
|
|
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) : [])])];
|
|
124
152
|
if (dirty.length === 0)
|
|
125
153
|
return null;
|
|
126
154
|
const foreign = dirty.filter((f) => !ownFile(f));
|
|
127
155
|
if (foreign.length)
|
|
128
156
|
return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
|
|
129
157
|
git(repo, 'add', '--', ...dirty);
|
|
130
|
-
|
|
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
|
+
}
|
|
131
179
|
return null;
|
|
132
180
|
}
|
|
133
181
|
/**
|
|
@@ -139,8 +187,8 @@ const startSha = (c) => lotStartHead(wd(c), lotWork(histPlan(c), wd(c), c.lot.lo
|
|
|
139
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,
|
|
140
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
|
|
141
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
|
|
142
|
-
* pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`).
|
|
143
|
-
* 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.
|
|
144
192
|
*/
|
|
145
193
|
async function baselineFailures(c, command) {
|
|
146
194
|
const l = c.lot;
|
|
@@ -159,7 +207,13 @@ async function baselineFailures(c, command) {
|
|
|
159
207
|
if (c.config.build && (await sh(c, c.config.build, 30 * 60_000, {}, dir)).code !== 0)
|
|
160
208
|
return null;
|
|
161
209
|
const r = await sh(c, command, 30 * 60_000, {}, dir);
|
|
162
|
-
|
|
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(' ; ')}`);
|
|
163
217
|
l.baseline = { sha, command, failures, verified: true };
|
|
164
218
|
save(c);
|
|
165
219
|
return failures;
|
|
@@ -198,9 +252,17 @@ async function againstBaseline(c, command, output) {
|
|
|
198
252
|
}
|
|
199
253
|
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
200
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
|
+
}
|
|
201
263
|
function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = wd(c)) {
|
|
202
264
|
return new Promise((resolve) => {
|
|
203
|
-
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...(c.wave.testWorkers ? { [TEST_WORKERS_ENV]: String(c.wave.testWorkers) } : {}), ...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 });
|
|
204
266
|
const pid = child.pid;
|
|
205
267
|
if (pid === undefined) {
|
|
206
268
|
child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
|
|
@@ -255,7 +317,7 @@ async function startLot(c) {
|
|
|
255
317
|
plan.setStatus(c.lot.lot, 'doing', c.wave.today);
|
|
256
318
|
plan.save();
|
|
257
319
|
}
|
|
258
|
-
const dirty = await commitPlan(c,
|
|
320
|
+
const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.started));
|
|
259
321
|
if (dirty)
|
|
260
322
|
return dirty;
|
|
261
323
|
}
|
|
@@ -309,7 +371,11 @@ function checksText(c) {
|
|
|
309
371
|
const k = c.lot.checks;
|
|
310
372
|
if (!k?.runs.length || k.head !== git(wd(c), 'rev-parse', 'HEAD'))
|
|
311
373
|
return declared;
|
|
312
|
-
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
|
+
});
|
|
313
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');
|
|
314
380
|
return declared ? `${ran}\n${declared}` : ran;
|
|
315
381
|
}
|
|
@@ -431,6 +497,9 @@ function briefFor(c, kind, local = false) {
|
|
|
431
497
|
if (mcpServersFor(kind, l.visible, '', l.small, !!l.screen).playwright)
|
|
432
498
|
vars.shots = shotsText(playwrightDir(c.wave.store.lotDir(l.project, l.lot)));
|
|
433
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');
|
|
434
503
|
if (kind === 'implement' || kind === 'fix')
|
|
435
504
|
vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? plan.lots().find((x) => x.id === lot.parent) : undefined));
|
|
436
505
|
if (kind === 'precheck' && local)
|
|
@@ -450,6 +519,7 @@ function briefFor(c, kind, local = false) {
|
|
|
450
519
|
template: l.screen ? ficheTemplate() : undefined,
|
|
451
520
|
templateBack: back || l.screen ? ficheBackTemplate() : undefined,
|
|
452
521
|
});
|
|
522
|
+
vars.spec = [vars.spec, referenceIdsText('spec', lotIds({ ...lot, goal: l.goal ?? lot.goal }))].filter(Boolean).join('\n\n');
|
|
453
523
|
const mine = lotCommitLines(c);
|
|
454
524
|
if (mine)
|
|
455
525
|
vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
|
|
@@ -1462,7 +1532,7 @@ function sourceAnsweredRules(c) {
|
|
|
1462
1532
|
if (!ids.length)
|
|
1463
1533
|
return false;
|
|
1464
1534
|
git(wd(c), 'add', '--', fiche);
|
|
1465
|
-
git(wd(c), 'commit', '-q', '-m',
|
|
1535
|
+
git(wd(c), 'commit', '-q', '-m', leadAnswerMessage(planTemplate(c), l.lot, ids, c.wave.id), '--', fiche);
|
|
1466
1536
|
return false;
|
|
1467
1537
|
}
|
|
1468
1538
|
catch (e) {
|
|
@@ -1604,10 +1674,11 @@ async function noteHypotheses(c, lines, sujet) {
|
|
|
1604
1674
|
const fresh = texts.filter((t) => !have.has(t));
|
|
1605
1675
|
if (!fresh.length)
|
|
1606
1676
|
return null;
|
|
1677
|
+
const before = planBefore(c);
|
|
1607
1678
|
for (const t of fresh)
|
|
1608
1679
|
plan.note(l.lot, t, c.wave.today);
|
|
1609
1680
|
plan.save();
|
|
1610
|
-
return commitPlan(c,
|
|
1681
|
+
return commitPlan(c, planMessage(c, PLAN_ACTIONS.hypotheses(sujet)), c.lot.repo, before);
|
|
1611
1682
|
});
|
|
1612
1683
|
if (dirty) {
|
|
1613
1684
|
handBackPlan(c, `${HYPOTHESES_HANDBACK}${dirty}`);
|
|
@@ -1635,10 +1706,11 @@ async function noteMinors(c, rep, label) {
|
|
|
1635
1706
|
const fresh = [...new Set(texts)].filter((t) => !have.has(t));
|
|
1636
1707
|
if (!fresh.length)
|
|
1637
1708
|
return null;
|
|
1709
|
+
const before = planBefore(c);
|
|
1638
1710
|
for (const t of fresh)
|
|
1639
1711
|
plan.note(c.lot.lot, t, c.wave.today);
|
|
1640
1712
|
plan.save();
|
|
1641
|
-
return commitPlan(c,
|
|
1713
|
+
return commitPlan(c, planMessage(c, PLAN_ACTIONS.minors(label)), c.lot.repo, before);
|
|
1642
1714
|
});
|
|
1643
1715
|
if (dirty) {
|
|
1644
1716
|
handBackPlan(c, `${MINORS_HANDBACK}${label} : ${dirty}`);
|
|
@@ -1906,21 +1978,83 @@ async function programChecks(c, red, runs) {
|
|
|
1906
1978
|
if (r.code === 0)
|
|
1907
1979
|
continue;
|
|
1908
1980
|
const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
|
|
1909
|
-
|
|
1910
|
-
run.dejaRouges =
|
|
1911
|
-
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`;
|
|
1912
1984
|
if (!l.warnings.includes(note))
|
|
1913
1985
|
l.warnings.push(note);
|
|
1986
|
+
};
|
|
1987
|
+
if (vs.kind === 'existing') {
|
|
1988
|
+
onlyExisting(vs.n);
|
|
1914
1989
|
continue;
|
|
1915
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;
|
|
2005
|
+
continue;
|
|
2006
|
+
}
|
|
2007
|
+
const alone = unstable.length ? `\n(${unstable.length} test(s) rouge(s) sous charge, verts rejoués seuls : instables, à ne pas corriger)` : '';
|
|
1916
2008
|
if (vs.kind === 'new') {
|
|
1917
|
-
|
|
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}` });
|
|
1918
2011
|
return;
|
|
1919
2012
|
}
|
|
1920
|
-
|
|
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}` });
|
|
1921
2016
|
return;
|
|
1922
2017
|
}
|
|
1923
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
|
+
}
|
|
1924
2058
|
/**
|
|
1925
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
|
|
1926
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.
|
|
@@ -2118,8 +2252,13 @@ async function work(c, kind) {
|
|
|
2118
2252
|
}
|
|
2119
2253
|
if (red.length === 0)
|
|
2120
2254
|
await programChecks(c, red, runs);
|
|
2121
|
-
// La session annonçait des tests rouges et le programme ne les voit
|
|
2122
|
-
|
|
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)))
|
|
2123
2262
|
red.push(claimed);
|
|
2124
2263
|
if (red.length) {
|
|
2125
2264
|
toFix(c, red, 'tests rouges');
|
|
@@ -2448,13 +2587,21 @@ async function writeVerdict(c, verdict, commit, reporte) {
|
|
|
2448
2587
|
// Déjà écrit et commité au même sha (reprise de l'intégration, R23) : une seule fois.
|
|
2449
2588
|
if (c.tree && commit !== null && plan.lot(l.lot).review?.commit === commit && !covered.length && !trackedDirty(l.repo).includes(relative(l.repo, plan.path)))
|
|
2450
2589
|
return null;
|
|
2590
|
+
const before = planBefore(c);
|
|
2451
2591
|
plan.recordReview(l.lot, verdict, c.wave.today, commit, neighbours.length ? shas : undefined, reporte);
|
|
2452
2592
|
for (const k of covered)
|
|
2453
2593
|
plan.setStatus(`${l.lot}/${k.task}`, 'done', c.wave.today);
|
|
2454
2594
|
if (covered.length)
|
|
2455
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(', ')}`);
|
|
2456
|
-
|
|
2457
|
-
|
|
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);
|
|
2458
2605
|
if (dirty)
|
|
2459
2606
|
return dirty;
|
|
2460
2607
|
}
|
|
@@ -2465,11 +2612,12 @@ async function writeVerdict(c, verdict, commit, reporte) {
|
|
|
2465
2612
|
if (plan.readonly && c.config.verdict) {
|
|
2466
2613
|
// La trace d'un verdict reporté va dans le texte, le seul endroit où l'outil du projet la lit (R23).
|
|
2467
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);
|
|
2468
2616
|
const r = await sh(c, c.config.verdict.replaceAll('{lot}', l.lot).replaceAll('{verdict}', shEscape(text)), undefined, undefined, l.repo);
|
|
2469
2617
|
if (r.code !== 0)
|
|
2470
2618
|
l.warnings.push(`orchestrate.verdict en échec (code ${r.code}) : le lead reporte le verdict`);
|
|
2471
2619
|
else {
|
|
2472
|
-
const dirty = await commitPlan(c,
|
|
2620
|
+
const dirty = await commitPlan(c, planMessage(c, PLAN_ACTIONS.reviewNoted), l.repo, before);
|
|
2473
2621
|
if (dirty)
|
|
2474
2622
|
return dirty;
|
|
2475
2623
|
}
|
|
@@ -2499,8 +2647,20 @@ function leadMoved(repo, base) {
|
|
|
2499
2647
|
const dirty = trackedDirty(repo);
|
|
2500
2648
|
return dirty.length ? `arbre du dépôt modifié (${dirty.join(', ')})` : null;
|
|
2501
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
|
+
}
|
|
2502
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. */
|
|
2503
|
-
async function planWrite(c,
|
|
2662
|
+
async function planWrite(c, doWrite) {
|
|
2663
|
+
const write = () => refusal(doWrite);
|
|
2504
2664
|
if (!c.tree)
|
|
2505
2665
|
return write();
|
|
2506
2666
|
const l = c.lot;
|
|
@@ -2576,43 +2736,62 @@ async function integrateLot(c) {
|
|
|
2576
2736
|
start = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
|
|
2577
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).
|
|
2578
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;
|
|
2579
2741
|
const reviewed = l.code?.head ?? git(t.dir, 'rev-parse', 'HEAD');
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
if (
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
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}`);
|
|
2748
|
+
}
|
|
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
|
+
}
|
|
2599
2777
|
}
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
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})`);
|
|
2609
2793
|
}
|
|
2610
2794
|
}
|
|
2611
|
-
// 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).
|
|
2612
|
-
const late = leadMoved(l.repo, t.base);
|
|
2613
|
-
if (late)
|
|
2614
|
-
return refuse(`intégration refusée : ${late} ; branche du dépôt inchangée, ${kept}`);
|
|
2615
|
-
advance(l.repo, t.branch);
|
|
2616
2795
|
}
|
|
2617
2796
|
const end = git(l.repo, 'rev-parse', `refs/heads/${t.base}`);
|
|
2618
2797
|
if (end !== start)
|
|
@@ -9,6 +9,8 @@ const DECIDED = /(décision|décid|tranch|valid|arbitr|demand|veut|voulu|exig|re
|
|
|
9
9
|
* un abandon (cli.ts `drop --reason`). Elles citent souvent l'utilisateur ou le lead sans porter aucune décision de l'humain.
|
|
10
10
|
*/
|
|
11
11
|
const PROGRAM_NOTE = /^(\[[^\]\n]{1,40}\] )?(Hypothèses \(fiche |Revue de [^\n]{1,60}? — mineur : |titre remplacé : |remis en attente(\s*:|$)|abandonné : )/;
|
|
12
|
+
/** Une note que le programme écrit (`PROGRAM_NOTE`) ou posée sur une sous-tâche (`[L5/T2] …`) : ce n'est pas le lead qui l'a écrite sur le lot. */
|
|
13
|
+
export const isProgramNote = (text) => PROGRAM_NOTE.test(text) || /^\[[^\]\n]{1,40}\] /.test(text);
|
|
12
14
|
const isDecision = (text) => !PROGRAM_NOTE.test(text) && WHO.test(text) && DECIDED.test(text);
|
|
13
15
|
/**
|
|
14
16
|
* Les décisions de l'humain écrites dans les notes d'un lot et de son parent (le parent d'abord), dans l'ordre du plan. Le texte est
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { gitCommonDir } from '../git.js';
|
|
5
5
|
import { sharedStateDir } from '../state.js';
|
|
6
6
|
import { activeLock, releaseLock, takeLock } from './lock.js';
|
|
7
|
-
import { mergeUnreleased } from './changelog-merge.js';
|
|
7
|
+
import { mergeUnreleased, moveToUnreleased } from './changelog-merge.js';
|
|
8
8
|
import { mutexFor } from './mutex.js';
|
|
9
9
|
import { git, gitFailure, tryGit } from './worktree.js';
|
|
10
10
|
/** Verrou d'intégration d'un dépôt (L279, R10) : dans l'état commun aux worktrees, au format du verrou d'orchestration, sous un autre nom. */
|
|
@@ -12,6 +12,8 @@ export const INTEGRATION_LOCK = 'orchestrate-integration.lock';
|
|
|
12
12
|
/** Attente du verrou d'intégration (R25) : une intégration dure un rebase et la relance des tests. */
|
|
13
13
|
export const INTEGRATE_WAIT_MS = 10 * 60_000;
|
|
14
14
|
export const INTEGRATE_POLL_MS = 1_000;
|
|
15
|
+
/** Rebases rejoués quand la branche du dépôt avance pendant l'intégration (L364) : au-delà, le lot est rendu. */
|
|
16
|
+
export const MAX_REPLAYS = 2;
|
|
15
17
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
18
|
/**
|
|
17
19
|
* Prend la main sur le dépôt pour une intégration ou une écriture de plan du programme (R10, R21, R26) : d'abord la file en mémoire du dépôt (les lots d'une
|
|
@@ -77,6 +79,24 @@ function resolveChangelog(work, start) {
|
|
|
77
79
|
return false;
|
|
78
80
|
}
|
|
79
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Après un rebase réussi : les entrées que le lot a ajoutées sous `[Unreleased]` et qu'une publication de la branche a laissées sous la version publiée
|
|
84
|
+
* (git les y range sans conflit quand le bloc a été renommé) sont remises sous le `[Unreleased]` de la branche, dans un commit à part (L362). Rend leur nombre.
|
|
85
|
+
*/
|
|
86
|
+
function moveOwnEntries(work, base, before, start) {
|
|
87
|
+
if (start === undefined)
|
|
88
|
+
return 0;
|
|
89
|
+
const [tip, main] = [tryGit(work, 'show', `${before}:${CHANGELOG}`), tryGit(work, 'show', `refs/heads/${base}:${CHANGELOG}`)];
|
|
90
|
+
if (tip === null || main === null || !existsSync(join(work, CHANGELOG)))
|
|
91
|
+
return 0;
|
|
92
|
+
const fixed = moveToUnreleased(readFileSync(join(work, CHANGELOG), 'utf8'), tip, start, main);
|
|
93
|
+
if (!fixed)
|
|
94
|
+
return 0;
|
|
95
|
+
writeFileSync(join(work, CHANGELOG), fixed.text);
|
|
96
|
+
run(work, 'add', '--', CHANGELOG);
|
|
97
|
+
run(work, 'commit', '--no-verify', '-q', '-m', 'docs: CHANGELOG — les entrées du lot replacées sous [Unreleased] (une publication a eu lieu pendant la branche)');
|
|
98
|
+
return fixed.moved;
|
|
99
|
+
}
|
|
80
100
|
/**
|
|
81
101
|
* Rebase le worktree `work` sur la branche `base` (R6). Une branche qui descend déjà du sommet de `base` n'a rien à rebaser (fast-forward pur : aucun sha ne
|
|
82
102
|
* change). Un conflit d'ajout pur dans le CHANGELOG est résolu par union à chaque commit qui le porte (L314, `changelog` les compte). Tout autre conflit — ou
|
|
@@ -111,7 +131,8 @@ export function rebaseOnto(work, base) {
|
|
|
111
131
|
}
|
|
112
132
|
}
|
|
113
133
|
}
|
|
114
|
-
|
|
134
|
+
const moved = moveOwnEntries(work, base, before, start);
|
|
135
|
+
return { kind: 'rebased', before, after: git(work, 'rev-parse', 'HEAD'), changelog, moved };
|
|
115
136
|
}
|
|
116
137
|
/**
|
|
117
138
|
* Patch-id stable (`git patch-id --stable`) du diff cumulé de `from` à `to` (R9) : le même diff reste le même quand le contexte se déplace, il change quand
|
|
@@ -127,3 +148,5 @@ export function patchId(cwd, from, to) {
|
|
|
127
148
|
export function advance(repo, branch) {
|
|
128
149
|
git(repo, 'merge', '--ff-only', '--quiet', `refs/heads/${branch}`);
|
|
129
150
|
}
|
|
151
|
+
/** Vrai quand la branche du dépôt n'est plus un ancêtre de celle du lot : elle a avancé depuis le rebase, l'avance rapide est impossible (L364). */
|
|
152
|
+
export const baseMoved = (repo, branch, base) => tryGit(repo, 'merge-base', '--is-ancestor', `refs/heads/${base}`, `refs/heads/${branch}`) === null;
|
|
@@ -9,6 +9,8 @@ import { SlotAbandoned } from './registry.js';
|
|
|
9
9
|
import { TEST_WORKERS_ENV } from './workers.js';
|
|
10
10
|
import { killMarked, SESSION_MARK_VAR, TreeTracker } from '../proc.js';
|
|
11
11
|
import { isQuotaMessage, lacksStructuredOutput, parseSession, salvageUsage, sumTokens, tokensOf } from './result.js';
|
|
12
|
+
/** Les alias que `claude --model` comprend sans connaître le compte : tout autre nom est passé tel quel (L345) et le dry-run en avertit. */
|
|
13
|
+
export const MODEL_ALIASES = ['haiku', 'sonnet', 'opus'];
|
|
12
14
|
/**
|
|
13
15
|
* Serveurs MCP d'une étape : Playwright pour `ux` et `maquette` (revue des captures et du HTML local, L172) et, sur un lot `visible`, pour `spec` quand il a un écran `screen:` (rendu 1440/390 de la maquette, L171), `implement` et `fix` (fix-minors compris) ;
|
|
14
16
|
* et `review-small` d'un petit lot visible (sa seule revue d'ergonomie) ; rien pour `review`, ni pour un lot sans écran.
|
|
@@ -8,7 +8,7 @@ import { gitCommonDir, gitRoot } from '../git.js';
|
|
|
8
8
|
export const LOCAL_PRECHECK_UNREADABLE = 'contrôle préalable : rapport du modèle local illisible';
|
|
9
9
|
export const PRECHECK_UNREADABLE = 'contrôle préalable illisible, implémentation lancée';
|
|
10
10
|
export function newLot(o) {
|
|
11
|
-
return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], outcome: null };
|
|
11
|
+
return { ...o, status: 'queued', pass: 0, next: null, steps: [], constats: [], verdict: null, uxVerdict: null, questions: [], choix: [], answers: [], proposals: [], warnings: [], instables: undefined, outcome: null };
|
|
12
12
|
}
|
|
13
13
|
/** Un lot fini ne travaille plus dans son dépôt : prêt, rendu ou échoué. */
|
|
14
14
|
export const lotFinished = (l) => l.status === 'ready' || l.status === 'handed-back' || l.status === 'failed';
|