@sylad/cadence 1.3.0 → 1.5.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 +72 -34
- package/bin/cadence.js +1 -1
- package/dist/audit.js +21 -1
- package/dist/cli.js +31 -13
- package/dist/corrections.js +8 -4
- package/dist/lead.js +6 -3
- package/dist/orchestrate/accept.js +22 -7
- package/dist/orchestrate/baseline.js +66 -0
- package/dist/orchestrate/command.js +94 -15
- package/dist/orchestrate/cycle.js +326 -56
- package/dist/orchestrate/guard.js +13 -3
- package/dist/orchestrate/schemas.js +2 -0
- package/dist/orchestrate/snapshot.js +14 -0
- package/dist/orchestrate/table.js +11 -0
- package/dist/plan.js +46 -1
- package/dist/session.js +3 -2
- package/package.json +1 -1
- package/skills/lead/SKILL.md +3 -4
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +10 -0
- package/templates/orchestrate/implement.md +10 -0
- package/templates/orchestrate/spec-back.md +1 -0
- package/templates/orchestrate/spec.md +1 -0
|
@@ -2,8 +2,9 @@ import { execFileSync, 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';
|
|
6
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
7
8
|
import { coveredTasks, lotCommits, lotWork } from '../audit.js';
|
|
8
9
|
import { readDocsConfig } from '../config.js';
|
|
9
10
|
import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
|
|
@@ -12,6 +13,7 @@ import { citedRefs } from '../link.js';
|
|
|
12
13
|
import { repoShas, repoWork } from '../repos.js';
|
|
13
14
|
import { isOpen } from '../plan.js';
|
|
14
15
|
import { isPlanOnly } from '../audit.js';
|
|
16
|
+
import { compareBaseline, failedTests, hasUnnamedFailure } from './baseline.js';
|
|
15
17
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
16
18
|
import { capturesText, newsText, objective, renderBrief, reposText, TEMPLATES_DIR } from './briefs.js';
|
|
17
19
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
@@ -62,6 +64,12 @@ const short = (c) => `${c.sha.slice(0, 7)} ${c.subject}`;
|
|
|
62
64
|
function git(repo, ...args) {
|
|
63
65
|
return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
64
66
|
}
|
|
67
|
+
/** Le message d'une commande git qui a échoué : sa sortie d'erreur (sur une ligne), à défaut le message de l'exception. */
|
|
68
|
+
function gitFailure(e) {
|
|
69
|
+
const err = e.stderr;
|
|
70
|
+
const text = typeof err === 'string' && err.trim() ? err : e instanceof Error ? e.message : String(e);
|
|
71
|
+
return text.trim().replace(/\s*\n\s*/g, ' ');
|
|
72
|
+
}
|
|
65
73
|
function save(c) {
|
|
66
74
|
c.wave.store.writeLot(c.lot);
|
|
67
75
|
}
|
|
@@ -99,11 +107,136 @@ async function commitPlan(c, message) {
|
|
|
99
107
|
git(repo, 'commit', '-q', '-m', message, '--', ...dirty);
|
|
100
108
|
return null;
|
|
101
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Sha d'où le lot est parti : le parent de son plus ancien commit de travail (un lot relancé, ou déjà en partie commité, a ses propres
|
|
112
|
+
* commits avant la vague), à défaut le HEAD d'avant la première session de la vague.
|
|
113
|
+
*/
|
|
114
|
+
const startSha = (c) => lotStartHead(c.lot.repo, lotWork(c.loadPlan(), c.lot.repo, c.lot.lot).map((k) => k.sha)) ?? c.lot.steps.find((s) => s.headBefore)?.headBefore;
|
|
115
|
+
/** Dossiers ignorés de sortie de build : jamais liés dans le worktree jetable, une commande de tests qui y écrit toucherait le build du lot. */
|
|
116
|
+
const OUTPUT_DIRS = new Set(['dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.output', '.angular', '.turbo', '.svelte-kit', '.parcel-cache']);
|
|
117
|
+
/**
|
|
118
|
+
* Un `node_modules` dans le worktree de base (L213) : un vrai dossier dont chaque entrée est un lien vers celle du dépôt vivant, SAUF les liens
|
|
119
|
+
* de workspace (npm, pnpm, yarn : `node_modules/@x/shared -> ../../packages/shared`) qui pointent vers du code du dépôt hors de tout
|
|
120
|
+
* `node_modules`. Lier le dossier d'un bloc les ferait résoudre dans le dépôt vivant, donc exécuter le code du lot au lieu de celui du sha de
|
|
121
|
+
* départ ; ils sont refaits vers la copie du worktree (absente au sha de départ : pas de lien). Les dossiers de portée `@x` sont parcourus,
|
|
122
|
+
* pas les paquets. Ajoute chaque lien créé à `links`.
|
|
123
|
+
*/
|
|
124
|
+
function mirrorNodeModules(repo, dir, name, links) {
|
|
125
|
+
const repoReal = realpathSync(repo);
|
|
126
|
+
const workspaceCopy = (live) => {
|
|
127
|
+
let real;
|
|
128
|
+
try {
|
|
129
|
+
real = realpathSync(live);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null; // lien mort dans le dépôt vivant : rien à refaire
|
|
133
|
+
}
|
|
134
|
+
const rel = relative(repoReal, real);
|
|
135
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('node_modules'))
|
|
136
|
+
return null;
|
|
137
|
+
return join(dir, rel);
|
|
138
|
+
};
|
|
139
|
+
const link = (target, at) => {
|
|
140
|
+
symlinkSync(target, at);
|
|
141
|
+
links.push(at);
|
|
142
|
+
};
|
|
143
|
+
const mirror = (liveDir, to, scope) => {
|
|
144
|
+
mkdirSync(to);
|
|
145
|
+
for (const e of readdirSync(liveDir, { withFileTypes: true })) {
|
|
146
|
+
const live = join(liveDir, e.name);
|
|
147
|
+
const at = join(to, e.name);
|
|
148
|
+
if (e.isSymbolicLink()) {
|
|
149
|
+
const copy = workspaceCopy(live);
|
|
150
|
+
if (copy) {
|
|
151
|
+
if (existsSync(copy))
|
|
152
|
+
link(copy, at);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else if (!scope && e.isDirectory() && e.name.startsWith('@')) {
|
|
157
|
+
mirror(live, at, true);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
link(live, at);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
mirror(join(repo, name), join(dir, name), false);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* É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,
|
|
167
|
+
* avec des liens vers les dossiers ignorés de tout niveau (node_modules, .venv… ; `dist` et les autres sorties de build exclus, une commande
|
|
168
|
+
* 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
|
|
169
|
+
* pour que les paquets de workspace résolvent dans le worktree (`mirrorNodeModules`). Null quand le worktree ne peut être monté :
|
|
170
|
+
* l'appelant garde alors les tests pour rouges.
|
|
171
|
+
*/
|
|
172
|
+
async function baselineFailures(c, command) {
|
|
173
|
+
const l = c.lot;
|
|
174
|
+
const sha = startSha(c);
|
|
175
|
+
if (!sha)
|
|
176
|
+
return null;
|
|
177
|
+
if (l.baseline?.verified && l.baseline.sha === sha && l.baseline.command === command)
|
|
178
|
+
return l.baseline.failures;
|
|
179
|
+
const dir = mkdtempSync(join(tmpdir(), 'cadence-baseline-'));
|
|
180
|
+
const links = [];
|
|
181
|
+
try {
|
|
182
|
+
git(l.repo, 'worktree', 'add', '--detach', '-f', dir, sha);
|
|
183
|
+
// Tous niveaux : en monorepo les dépendances vivent dans packages/*/node_modules. `--directory` ne descend pas dans un dossier ignoré, donc
|
|
184
|
+
// chaque entrée est le dossier ou fichier ignoré le plus haut ; son parent doit exister au sha de départ (sinon rien où le relier).
|
|
185
|
+
const ignored = git(l.repo, 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory').split('\n').map((f) => f.replace(/\/$/, '')).filter((f) => f && !f.startsWith('.cadence') && !f.split('/').some((p) => OUTPUT_DIRS.has(p)));
|
|
186
|
+
for (const name of ignored) {
|
|
187
|
+
const to = join(dir, name);
|
|
188
|
+
if (existsSync(to) || !existsSync(dirname(to)))
|
|
189
|
+
continue;
|
|
190
|
+
if (basename(name) === 'node_modules' && lstatSync(join(l.repo, name)).isDirectory()) {
|
|
191
|
+
mirrorNodeModules(l.repo, dir, name, links);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
symlinkSync(join(l.repo, name), to);
|
|
195
|
+
links.push(to);
|
|
196
|
+
}
|
|
197
|
+
const r = await sh(c, command, 30 * 60_000, {}, dir);
|
|
198
|
+
const failures = r.code === 0 ? [] : failedTests(r.output);
|
|
199
|
+
l.baseline = { sha, command, failures, verified: true };
|
|
200
|
+
save(c);
|
|
201
|
+
return failures;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
for (const k of links)
|
|
208
|
+
if (lstatSync(k, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
209
|
+
unlinkSync(k);
|
|
210
|
+
try {
|
|
211
|
+
git(l.repo, 'worktree', 'remove', '--force', dir);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
rmSync(dir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
git(l.repo, 'worktree', 'prune');
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// rien à élaguer
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Des tests rouges comparés à la ligne de base (L213) : tous déjà rouges au départ, ou au moins un nouveau, ou rien de comparable. */
|
|
225
|
+
async function againstBaseline(c, command, output) {
|
|
226
|
+
const now = failedTests(output);
|
|
227
|
+
if (!now.length || hasUnnamedFailure(output))
|
|
228
|
+
return { kind: 'unknown' };
|
|
229
|
+
const base = await baselineFailures(c, command);
|
|
230
|
+
if (!base?.length)
|
|
231
|
+
return { kind: 'unknown' };
|
|
232
|
+
const { nouveaux, existants } = compareBaseline(now, base);
|
|
233
|
+
return nouveaux.length ? { kind: 'new', nouveaux, existants: existants.length } : { kind: 'existing', n: existants.length };
|
|
234
|
+
}
|
|
102
235
|
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
103
236
|
/** 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. */
|
|
104
|
-
function sh(c, command, timeoutMs = 30 * 60_000, env = {}) {
|
|
237
|
+
function sh(c, command, timeoutMs = 30 * 60_000, env = {}, cwd = c.lot.repo) {
|
|
105
238
|
return new Promise((resolve) => {
|
|
106
|
-
const child = spawn('sh', ['-c', command], { cwd
|
|
239
|
+
const child = spawn('sh', ['-c', command], { cwd, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
107
240
|
const pid = child.pid;
|
|
108
241
|
if (pid === undefined) {
|
|
109
242
|
child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
|
|
@@ -205,11 +338,15 @@ function choixText(choix) {
|
|
|
205
338
|
}
|
|
206
339
|
/** Tests et build déjà lancés par le programme au HEAD relu : le relecteur ne les refait pas (L75). Vide si HEAD a bougé depuis ou si rien n'a tourné. */
|
|
207
340
|
function checksText(c) {
|
|
341
|
+
const b = c.lot.baseline;
|
|
342
|
+
// Sans orchestrate.test aucun rejeu : la base annoncée par la session (non vérifiée) est la seule trace des échecs de départ ; elle s'ajoute aux runs du programme (le build), elle ne les remplace pas.
|
|
343
|
+
const declared = b && !b.verified && b.failures.length ? `The author reports ${b.failures.length} test(s) already red at the start sha${b.sha ? ` ${b.sha.slice(0, 7)}` : ''}, before this lot (not verified by the program):\n${b.failures.map((t) => `- ${t}`).join('\n')}\nThey are not defects of this lot, do not report them as findings; a failing test outside this list is still a finding.` : '';
|
|
208
344
|
const k = c.lot.checks;
|
|
209
345
|
if (!k?.runs.length || k.head !== git(c.lot.repo, 'rev-parse', 'HEAD'))
|
|
210
|
-
return
|
|
211
|
-
const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${r.code === 0 ? 'green' : `RED, exit code ${r.code}`}`);
|
|
212
|
-
|
|
346
|
+
return declared;
|
|
347
|
+
const runs = k.runs.map((r) => `- \`${r.command}\` (${r.label}): ${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}`}`);
|
|
348
|
+
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');
|
|
349
|
+
return declared ? `${ran}\n${declared}` : ran;
|
|
213
350
|
}
|
|
214
351
|
/** Consigne docs.sync du brief de revue (L143) : calculée sur les commits du lot au moment de la revue, pas écrite d'avance. */
|
|
215
352
|
function docsText(c) {
|
|
@@ -370,18 +507,31 @@ export const lotSpent = (l) => l.steps.reduce((n, s) => n + (s.tokens?.counted ?
|
|
|
370
507
|
/** 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. */
|
|
371
508
|
const lotOver = (l) => l.budget !== undefined && lotSpent(l) >= l.budget;
|
|
372
509
|
/**
|
|
373
|
-
* Coût réservé à la revue qui suit
|
|
374
|
-
*
|
|
375
|
-
* `
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* couvre 170 revues sur 187 (90,9 %) mais 99 revues courtes sur 114 (86,8 %). Une passe fix ne part que s'il reste de quoi payer cette revue.
|
|
510
|
+
* 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 :
|
|
511
|
+
* 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
|
|
512
|
+
* `.cadence/runs` (523 fichiers de lot) : 497 passes fix Sonnet, médiane 45 k, p90 65 k, max 124 k ; 272 revues courtes Opus,
|
|
513
|
+
* médiane 45 k, p90 88 k ; la somme des p90 majore le p90 de la somme, elle n'a pas été recalculée par lot. La réserve de L145 (65 k, revue
|
|
514
|
+
* seule) laissait un lot rendu avant la correction à 740 k / 800 k (warhammer L120) ou 406 k / 450 k (maritime B26.1). Une passe fix ne part
|
|
515
|
+
* que s'il reste de quoi payer la correction ET sa revue ; sinon le lot est rendu avec « correction non jouée (budget) ».
|
|
380
516
|
* Seule exception : des tests rouges après une écriture (work() appelle toFix() sans revue) — la passe fix suivante peut
|
|
381
517
|
* alors être refusée et le lot revient au lead sans revue, avec ses constats.
|
|
382
518
|
*/
|
|
383
|
-
export const REVIEW_RESERVE =
|
|
384
|
-
/**
|
|
519
|
+
export const REVIEW_RESERVE = 155_000;
|
|
520
|
+
/** Étapes d'écriture : les seules que le budget du lot borne (`session`), donc les seules qu'il puisse laisser non jouées. */
|
|
521
|
+
const WRITE_STEPS = ['implement', 'fix', 'spec', 'accept'];
|
|
522
|
+
/**
|
|
523
|
+
* Étape d'écriture que le budget a laissée non jouée (L223), pour un lot rendu au lead sur budget ; null sinon. `stop` vide `next` : l'étape se lit
|
|
524
|
+
* dans l'outcome que `overBudget` écrit (« étape « fix » non jouée »), repli sur `next` s'il est resté. Reconnu par ce texte (aucun marqueur dans
|
|
525
|
+
* l'état) : « correction non jouée (budget) » ou « budget du lot atteint ».
|
|
526
|
+
*/
|
|
527
|
+
export function budgetStep(l) {
|
|
528
|
+
if (l.status !== 'handed-back' || !/correction non jouée \(budget\)|budget du lot atteint/.test(l.outcome ?? ''))
|
|
529
|
+
return null;
|
|
530
|
+
const named = /étape « ([\w-]+) » non jouée/.exec(l.outcome ?? '')?.[1];
|
|
531
|
+
const step = named ?? l.next;
|
|
532
|
+
return step && WRITE_STEPS.includes(step) ? step : null;
|
|
533
|
+
}
|
|
534
|
+
/** Le budget du lot paie-t-il la passe de correction et la revue courte qui la suit ? Sans budget de lot : toujours. */
|
|
385
535
|
const fixAffordable = (l) => l.budget === undefined || lotSpent(l) + REVIEW_RESERVE < l.budget;
|
|
386
536
|
/** Cause d'une suspension demandée par `--stop-after-current` (L79). */
|
|
387
537
|
export const STOP_REQUESTED = 'arrêt demandé (--stop-after-current)';
|
|
@@ -397,6 +547,8 @@ function halted(w, lot) {
|
|
|
397
547
|
if (lot && w.dirtyRepos) {
|
|
398
548
|
for (const r of lotRepoPaths(lot)) {
|
|
399
549
|
const by = w.dirtyRepos.get(r);
|
|
550
|
+
if (by && w.movedRepos?.has(r))
|
|
551
|
+
return `dépôt ${r} laissé en HEAD déplacé par ${by} : à remettre sur sa branche par le lead avant de reprendre`;
|
|
400
552
|
if (by)
|
|
401
553
|
return `dépôt ${r} sali par la revue de ${by} : à nettoyer par le lead avant de reprendre`;
|
|
402
554
|
}
|
|
@@ -554,7 +706,8 @@ async function session(c, kind, local = false) {
|
|
|
554
706
|
step.headAfter = after.head ?? undefined;
|
|
555
707
|
// Le dépôt du projet d'abord, puis chaque voisin : mêmes contrôles, l'incident nomme le dépôt voisin.
|
|
556
708
|
const watched = [{ where: '', path: l.repo, before, after }, ...others.map((o) => ({ where: ` dans ${o.repo.rel}`, path: o.repo.path, before: o.before, after: o.after }))];
|
|
557
|
-
|
|
709
|
+
// 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).
|
|
710
|
+
for (const { where, before: b, after: a } of watched) {
|
|
558
711
|
if (pushed(b, a)) {
|
|
559
712
|
step.status = 'failed';
|
|
560
713
|
step.cause = `push détecté${where}`;
|
|
@@ -569,6 +722,43 @@ async function session(c, kind, local = false) {
|
|
|
569
722
|
w.saveWave();
|
|
570
723
|
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
571
724
|
}
|
|
725
|
+
}
|
|
726
|
+
for (const { where, path, before: b, after: a } of watched) {
|
|
727
|
+
// Session d'écriture finie sur une autre branche ou en HEAD détaché (L214) : pas un push, mais les contrôles et le lot suivant partiraient d'un HEAD qui n'est plus la branche du lot.
|
|
728
|
+
if (write && b.branch !== a.branch) {
|
|
729
|
+
const was = `${a.branch ? `sur la branche ${a.branch}` : `en HEAD détaché (${(a.head ?? '').slice(0, 7)})`} au lieu de ${b.branch ?? 'HEAD détaché'}`;
|
|
730
|
+
const why = b.branch ? headStranded(path, b, a) : 'le dépôt n\'était pas sur une branche au départ';
|
|
731
|
+
// La remise peut échouer (index.lock, fichier en conflit) : le lot est rendu, l'orchestrateur ne s'arrête pas (L214).
|
|
732
|
+
let failed = null;
|
|
733
|
+
if (b.branch && why === null) {
|
|
734
|
+
try {
|
|
735
|
+
git(path, 'checkout', '-q', b.branch);
|
|
736
|
+
}
|
|
737
|
+
catch (e) {
|
|
738
|
+
failed = `HEAD déplacé — remise de la branche ${b.branch} impossible : ${gitFailure(e)}`;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (b.branch && why === null && failed === null) {
|
|
742
|
+
Object.assign(a, await snapshot(path));
|
|
743
|
+
if (path === l.repo)
|
|
744
|
+
step.headAfter = a.head ?? undefined;
|
|
745
|
+
const text = `${kind} : la session a fini${where} ${was} (arbre propre, rien hors de la branche) — branche ${b.branch} remise`;
|
|
746
|
+
l.warnings.push(text);
|
|
747
|
+
w.log(`${lotKey(l.project, l.lot)} · ${text}`);
|
|
748
|
+
}
|
|
749
|
+
else if (!b.branch) {
|
|
750
|
+
const text = `${kind} : la session a fini${where} ${was} — ${why}`;
|
|
751
|
+
l.warnings.push(text);
|
|
752
|
+
w.log(`${lotKey(l.project, l.lot)} · ${text}`);
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
step.status = 'failed';
|
|
756
|
+
step.cause = failed ? `${failed}${where}` : `HEAD déplacé${where}`;
|
|
757
|
+
(w.dirtyRepos ??= new Map()).set(path, lotKey(l.project, l.lot));
|
|
758
|
+
(w.movedRepos ??= new Set()).add(path);
|
|
759
|
+
return stop(c, 'handed-back', `incident : HEAD déplacé${where} pendant ${lotKey(l.project, l.lot)} (${kind}) : la session a fini ${was} et ${failed ?? why}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
572
762
|
// Étape en lecture seule qui n'a laissé que des fichiers NON SUIVIS (captures d'un script en chemin relatif, L162) : déplacés dans le dossier du lot, avertissement, le verdict est gardé.
|
|
573
763
|
if (!write && b.head === a.head && b.tracked.join() === a.tracked.join() && b.untracked.join() !== a.untracked.join() && b.untracked.every((f) => a.untracked.includes(f))) {
|
|
574
764
|
const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
|
|
@@ -593,6 +783,28 @@ async function session(c, kind, local = false) {
|
|
|
593
783
|
save(c);
|
|
594
784
|
return { step, report: res.structured, before, after, others };
|
|
595
785
|
}
|
|
786
|
+
/** Pourquoi la branche de départ ne peut pas être remise sans risque après un HEAD déplacé (L214), null si elle le peut : arbre propre et rien hors de la branche. */
|
|
787
|
+
function headStranded(path, b, a) {
|
|
788
|
+
if (a.tracked.length || a.untracked.join() !== b.untracked.join())
|
|
789
|
+
return 'l\'arbre n\'est pas propre';
|
|
790
|
+
// Le HEAD final, et chaque position prise par HEAD pendant la session (reflog) : un commit fait en chemin puis quitté n'est plus que dans le reflog.
|
|
791
|
+
let seen = [];
|
|
792
|
+
try {
|
|
793
|
+
seen = git(path, 'reflog', '--format=%H', `-n${Math.max(0, a.reflog - b.reflog)}`, 'HEAD').split('\n').filter(Boolean);
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
// reflog illisible : le HEAD final seul juge
|
|
797
|
+
}
|
|
798
|
+
for (const sha of [a.head ?? 'HEAD', ...seen]) {
|
|
799
|
+
try {
|
|
800
|
+
git(path, 'merge-base', '--is-ancestor', sha, `refs/heads/${b.branch}`);
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
return `des commits ne sont pas dans la branche ${b.branch}`;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
596
808
|
/** 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. */
|
|
597
809
|
function handBack(c, kind, report, step, where, path, b, a, left) {
|
|
598
810
|
const l = c.lot;
|
|
@@ -679,13 +891,14 @@ function overBudget(c, reserve = false) {
|
|
|
679
891
|
// 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.
|
|
680
892
|
const answer = l.pendingAnswer ? ` ; la réponse du lead (« ${l.pendingAnswer} ») n'a pas été jouée` : '';
|
|
681
893
|
const basis = hasSpecStage(l) ? 'estimate et étape spec' : "dérivé de l'estimate";
|
|
682
|
-
const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${basis}), il ne reste pas de quoi payer la revue qui
|
|
894
|
+
const why = reserve ? `budget du lot : ${lotSpent(l)} / ${l.budget} tokens comptés (${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 (${lotSpent(l)} / ${l.budget} tokens comptés, ${basis})`;
|
|
683
895
|
// Tests rouges après une écriture : la passe fix refusée aurait suivi, le lot revient sans revue (L145) — la cause le dit.
|
|
684
896
|
const red = l.next === 'fix' && l.constats.some((k) => k.source === 'tests');
|
|
685
897
|
// 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).
|
|
686
898
|
const played = l.pass - 1;
|
|
687
899
|
const after = l.minorPass && played === (l.minorPassAt ?? 0) ? 'la passe des mineurs' : played > 0 ? `la passe fix ${played}` : "l'implémentation";
|
|
688
|
-
const
|
|
900
|
+
const notPlayed = l.next === 'fix' ? 'correction non jouée (budget) : ' : '';
|
|
901
|
+
const prefix = notPlayed + (red ? `tests rouges après ${after}, correction suivante non abordable : ` : '');
|
|
689
902
|
const unreviewed = red ? ' ; le lot revient sans revue, avec ses constats' : '';
|
|
690
903
|
return stop(c, 'handed-back', `${prefix}${why} : étape « ${l.next ?? '?'} » non jouée${answer}${unreviewed}, à décider par le lead`);
|
|
691
904
|
}
|
|
@@ -1277,6 +1490,12 @@ async function work(c, kind) {
|
|
|
1277
1490
|
badReport(c, done.step, e);
|
|
1278
1491
|
return;
|
|
1279
1492
|
}
|
|
1493
|
+
// L'écriture a changé le code (L221) : le verdict et les constats de la revue UX d'avant ne valent plus, l'UX est rejouée après la prochaine revue de code conforme.
|
|
1494
|
+
if (!l.small) {
|
|
1495
|
+
l.ux = undefined;
|
|
1496
|
+
l.uxVerdict = null;
|
|
1497
|
+
l.constats = l.constats.filter((k) => k.source !== 'ux');
|
|
1498
|
+
}
|
|
1280
1499
|
const plan = c.loadPlan();
|
|
1281
1500
|
const range = done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined;
|
|
1282
1501
|
const commits = readCommits(l.repo, { range });
|
|
@@ -1353,7 +1572,12 @@ async function work(c, kind) {
|
|
|
1353
1572
|
propose(c, minorLine('code', k));
|
|
1354
1573
|
l.constats = [];
|
|
1355
1574
|
l.warnings.push('passe des mineurs sans commit : le lot conclut sur la revue conforme d\'origine, mineurs rendus en propositions');
|
|
1356
|
-
if (l.code)
|
|
1575
|
+
if (l.code && uxAfterReview(c)) {
|
|
1576
|
+
// L221 : l'UX finale suit la dernière revue de code conforme, y compris quand la passe des mineurs n'a rien commité.
|
|
1577
|
+
l.next = 'ux';
|
|
1578
|
+
save(c);
|
|
1579
|
+
}
|
|
1580
|
+
else if (l.code)
|
|
1357
1581
|
await conclude(c, l.code, ' sans commit');
|
|
1358
1582
|
else
|
|
1359
1583
|
stop(c, 'handed-back', 'passe des mineurs sans commit, mais aucune revue conforme gardée');
|
|
@@ -1371,8 +1595,28 @@ async function work(c, kind) {
|
|
|
1371
1595
|
else if (made === 0)
|
|
1372
1596
|
l.warnings.push(kind === 'fix' ? 'fix sans nouveau commit : revue lancée sur les commits du lot' : `${kind} sans nouveau commit : revue lancée sur les commits déjà faits du lot`);
|
|
1373
1597
|
const red = [];
|
|
1374
|
-
|
|
1375
|
-
|
|
1598
|
+
// Tests annoncés rouges (L213) : avec orchestrate.test, le programme rejoue et compare à la ligne de base, la session n'est crue que si les tests y sont rouges autrement que par des échecs de départ ; sans, la liste des échecs de base de la session, non vérifiée.
|
|
1599
|
+
const claimed = { source: 'tests', gravite: 'bloquant', texte: `tests annoncés rouges par la session : ${rep.tests.commande} — ${rep.tests.resultat}` };
|
|
1600
|
+
let deferred = false;
|
|
1601
|
+
const declared = (rep.echecsDeBase ?? []).map((t) => t.trim()).filter(Boolean);
|
|
1602
|
+
const newFailures = rep.nouveauxEchecs?.map((t) => t.trim()).filter(Boolean);
|
|
1603
|
+
if (!c.config.test && declared.length) {
|
|
1604
|
+
l.baseline = { sha: startSha(c) ?? '', failures: declared, verified: false };
|
|
1605
|
+
l.warnings.push(`échecs déjà présents au sha de départ selon la session (${declared.length}) : non vérifié par le programme (orchestrate.test absent)`);
|
|
1606
|
+
}
|
|
1607
|
+
if (!rep.tests.vert) {
|
|
1608
|
+
if (c.config.test)
|
|
1609
|
+
deferred = true;
|
|
1610
|
+
// Sans orchestrate.test (L215) : champ structuré, pas de lecture de la prose. vert:false + echecsDeBase + nouveauxEchecs présent et vide → pas de correction (non vérifié, signalé à la revue) ; nouveauxEchecs non vide → correction.
|
|
1611
|
+
else if (declared.length && newFailures && !newFailures.length)
|
|
1612
|
+
l.warnings.push(`tests annoncés rouges par la session, aucun nouvel échec déclaré (nouveauxEchecs vide) : les échecs sont ceux de la base qu'elle déclare (${declared.length}) ; pas de correction, non vérifié par le programme (orchestrate.test absent)`);
|
|
1613
|
+
else if (newFailures?.length)
|
|
1614
|
+
red.push({ ...claimed, texte: `${claimed.texte} — nouveaux échecs déclarés par la session : ${newFailures.join(' ; ')}` });
|
|
1615
|
+
else
|
|
1616
|
+
red.push(claimed);
|
|
1617
|
+
}
|
|
1618
|
+
if (rep.tests.vert && !c.config.test && newFailures?.length)
|
|
1619
|
+
l.warnings.push(`rapport incohérent : tests annoncés verts mais nouveaux échecs déclarés (${newFailures.join(' ; ')}) ; pas de correction, non vérifié par le programme (orchestrate.test absent)`);
|
|
1376
1620
|
if (!rep.build.vert)
|
|
1377
1621
|
red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
|
|
1378
1622
|
const runs = [];
|
|
@@ -1386,13 +1630,29 @@ async function work(c, kind) {
|
|
|
1386
1630
|
if (!command)
|
|
1387
1631
|
continue;
|
|
1388
1632
|
const r = await sh(c, command);
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1633
|
+
const run = { label, command, code: r.code };
|
|
1634
|
+
runs.push(run);
|
|
1635
|
+
if (r.code === 0)
|
|
1636
|
+
continue;
|
|
1637
|
+
const vs = label === 'tests' ? await againstBaseline(c, command, r.output) : { kind: 'unknown' };
|
|
1638
|
+
if (vs.kind === 'existing') {
|
|
1639
|
+
run.dejaRouges = vs.n;
|
|
1640
|
+
const note = `${vs.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`;
|
|
1641
|
+
if (!l.warnings.includes(note))
|
|
1642
|
+
l.warnings.push(note);
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
if (vs.kind === 'new') {
|
|
1646
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) : ${vs.nouveaux.length} test(s) rouge(s) qui ne l'étaient pas au sha de départ :\n${vs.nouveaux.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)` : ''}` });
|
|
1392
1647
|
break;
|
|
1393
1648
|
}
|
|
1649
|
+
red.push({ source: 'tests', gravite: 'bloquant', texte: `${command} en échec (code ${r.code}) :\n${r.output.trim().split('\n').slice(-40).join('\n')}` });
|
|
1650
|
+
break;
|
|
1394
1651
|
}
|
|
1395
1652
|
}
|
|
1653
|
+
// La session annonçait des tests rouges et le programme ne les voit pas rouges au départ : l'annonce vaut.
|
|
1654
|
+
if (deferred && red.length === 0 && !runs.some((r) => r.label === 'tests' && r.dejaRouges))
|
|
1655
|
+
red.push(claimed);
|
|
1396
1656
|
if (red.length) {
|
|
1397
1657
|
toFix(c, red, 'tests rouges');
|
|
1398
1658
|
return;
|
|
@@ -1402,25 +1662,24 @@ async function work(c, kind) {
|
|
|
1402
1662
|
l.next = firstReview(c);
|
|
1403
1663
|
save(c);
|
|
1404
1664
|
}
|
|
1665
|
+
/** Mention « sans commit » du verdict quand la passe des mineurs n'a rien commité (l'UX finale conclut alors à la place de la passe). */
|
|
1666
|
+
const noCommitNote = (l) => (l.warnings.some((w) => w.startsWith('passe des mineurs sans commit')) ? ' sans commit' : '');
|
|
1405
1667
|
/** Revue de code qui clôt le cycle : complète, ou courte une fois la passe des mineurs jouée. */
|
|
1406
1668
|
const codeReview = (l) => (l.minorPass ? 'review-small' : 'review');
|
|
1407
1669
|
/**
|
|
1408
|
-
* Première étape après un travail d'écriture
|
|
1409
|
-
*
|
|
1670
|
+
* Première étape après un travail d'écriture (L221) : la revue de code, jamais l'UX. La revue UX d'un lot visible est jouée une seule fois,
|
|
1671
|
+
* après la dernière revue de code conforme (`uxAfterReview`), sur le code qui sera livré.
|
|
1410
1672
|
*/
|
|
1411
1673
|
function firstReview(c) {
|
|
1412
1674
|
const l = c.lot;
|
|
1413
1675
|
if (l.small)
|
|
1414
1676
|
return l.visible || l.minorPass ? 'review-small' : 'review'; // passe unique code + ergonomie ; sans écran, une revue de code
|
|
1415
|
-
if (l.visible)
|
|
1416
|
-
|
|
1417
|
-
l.uxNote = 'UX à faire par le lead (aucune application déclarée : cadence.yaml orchestrate.ux)';
|
|
1418
|
-
return codeReview(l);
|
|
1419
|
-
}
|
|
1420
|
-
return 'ux';
|
|
1421
|
-
}
|
|
1677
|
+
if (l.visible && !c.config.ux)
|
|
1678
|
+
l.uxNote = 'UX à faire par le lead (aucune application déclarée : cadence.yaml orchestrate.ux)';
|
|
1422
1679
|
return codeReview(l);
|
|
1423
1680
|
}
|
|
1681
|
+
/** La revue de code conforme d'un lot visible non petit est-elle suivie de la revue UX ? Sans `orchestrate.ux`, le lot conclut (note « UX à faire par le lead »). */
|
|
1682
|
+
const uxAfterReview = (c) => !c.lot.small && c.lot.visible && !!c.config.ux;
|
|
1424
1683
|
/** Une passe de correction de plus, ou la main rendue au lead quand les deux sont faites. */
|
|
1425
1684
|
function toFix(c, constats, what) {
|
|
1426
1685
|
const l = c.lot;
|
|
@@ -1442,12 +1701,20 @@ const seesApp = (l, kind) => kind === 'ux' || (kind === 'review-small' && l.smal
|
|
|
1442
1701
|
const appNote = (state, ux) => state.kind === 'busy' ? `UX non vérifiée : port occupé (${ux.url} répond déjà, l'application n'a pas été lancée)` : state.kind === 'unverified' ? `UX non vérifiée : ${state.cause}` : '';
|
|
1443
1702
|
/**
|
|
1444
1703
|
* Avec `command` ET `url`, le programme lance l'application avant l'étape et la tue après (succès, erreur ou signal) :
|
|
1445
|
-
* port occupé ou application muette, la revue UX n'a pas lieu (note dans le lot
|
|
1446
|
-
* unique d'un petit lot, elle, a lieu sur le code seul.
|
|
1704
|
+
* port occupé ou application muette, la revue UX n'a pas lieu (étape `skipped`, note dans le lot) et le lot conclut
|
|
1705
|
+
* sur la dernière revue de code conforme (R6) ; la passe unique d'un petit lot, elle, a lieu sur le code seul.
|
|
1447
1706
|
*/
|
|
1448
1707
|
async function review(c, kind) {
|
|
1449
1708
|
const l = c.lot;
|
|
1450
1709
|
const ux = c.config.ux;
|
|
1710
|
+
// Un état repris, écrit avant L221, peut porter next = ux : l'UX ne part que sur une revue de code conforme du HEAD courant, sinon la revue de code passe d'abord (R9).
|
|
1711
|
+
if (kind === 'ux' && !(l.code?.conforme && l.code.head === git(l.repo, 'rev-parse', 'HEAD'))) {
|
|
1712
|
+
l.next = codeReview(l);
|
|
1713
|
+
if (l.status === 'suspended')
|
|
1714
|
+
l.status = 'reviewing'; // reprise d'un lot suspendu : la boucle de runLot enchaîne sur la revue de code au lieu de rendre la main
|
|
1715
|
+
save(c);
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1451
1718
|
if (!ux?.command || !ux.url || !seesApp(l, kind))
|
|
1452
1719
|
return reviewStep(c, kind);
|
|
1453
1720
|
const halt = halted(c.wave, l);
|
|
@@ -1455,6 +1722,7 @@ async function review(c, kind) {
|
|
|
1455
1722
|
suspend(c, halt); // la session n'aurait pas lieu : l'application ne se lance pas pour rien
|
|
1456
1723
|
return;
|
|
1457
1724
|
}
|
|
1725
|
+
let skipped = false;
|
|
1458
1726
|
c.wave.log(`${lotKey(l.project, l.lot)} · lancement de l'application (${ux.url})`);
|
|
1459
1727
|
const app = await startApp({ command: ux.command, url: ux.url, cwd: l.repo, nodeBin: l.node?.link, log: join(c.wave.store.lotDir(l.project, l.lot), 'ux-app.log'), timeoutMs: (ux.timeout ?? APP_TIMEOUT_S) * 1000 });
|
|
1460
1728
|
try {
|
|
@@ -1463,12 +1731,7 @@ async function review(c, kind) {
|
|
|
1463
1731
|
l.uxNote = appNote(app.state, ux);
|
|
1464
1732
|
c.wave.log(`${lotKey(l.project, l.lot)} · ${l.uxNote.split('\n')[0]}`);
|
|
1465
1733
|
if (kind === 'ux') {
|
|
1466
|
-
//
|
|
1467
|
-
l.ux = undefined;
|
|
1468
|
-
l.uxVerdict = null;
|
|
1469
|
-
l.constats = l.constats.filter((k) => k.source !== 'ux');
|
|
1470
|
-
l.next = codeReview(l);
|
|
1471
|
-
save(c);
|
|
1734
|
+
skipped = true; // aucune session : la revue de code conforme suffit, le lot conclut avec la note
|
|
1472
1735
|
return;
|
|
1473
1736
|
}
|
|
1474
1737
|
}
|
|
@@ -1479,6 +1742,8 @@ async function review(c, kind) {
|
|
|
1479
1742
|
finally {
|
|
1480
1743
|
c.app = undefined;
|
|
1481
1744
|
await app.stop();
|
|
1745
|
+
if (skipped && l.code)
|
|
1746
|
+
await conclude(c, l.code, noCommitNote(l));
|
|
1482
1747
|
}
|
|
1483
1748
|
}
|
|
1484
1749
|
async function reviewStep(c, kind) {
|
|
@@ -1500,8 +1765,12 @@ async function reviewStep(c, kind) {
|
|
|
1500
1765
|
addProposals(c, rep, 'ux');
|
|
1501
1766
|
l.uxVerdict = summary.verdict;
|
|
1502
1767
|
l.constats = blockingConstats(rep, 'ux');
|
|
1503
|
-
l.next = codeReview(l); // la revue de code clôt toujours le cycle
|
|
1504
1768
|
save(c);
|
|
1769
|
+
// L'UX est la dernière étape (L221) : conforme, le lot conclut avec la revue de code ; sinon une correction (jamais une revue de code d'office), la revue de code et l'UX rejouée suivent.
|
|
1770
|
+
if (summary.conforme)
|
|
1771
|
+
await conclude(c, l.code, noCommitNote(l));
|
|
1772
|
+
else
|
|
1773
|
+
toFix(c, l.constats, 'UX non conforme');
|
|
1505
1774
|
return;
|
|
1506
1775
|
}
|
|
1507
1776
|
l.code = summary;
|
|
@@ -1511,18 +1780,18 @@ async function reviewStep(c, kind) {
|
|
|
1511
1780
|
l.ux = summary;
|
|
1512
1781
|
l.uxVerdict = summary.verdict;
|
|
1513
1782
|
}
|
|
1514
|
-
const uxOk = l.small || !l.ux || l.ux.conforme;
|
|
1515
1783
|
const minors = minorConstats(rep);
|
|
1516
1784
|
// Revue conforme avec mineurs : une seule passe de correction des mineurs, avant de conclure (rien sous le tapis).
|
|
1517
|
-
// Budget épuisé : la passe n'aurait aucune session pour la jouer,
|
|
1518
|
-
|
|
1785
|
+
// Budget épuisé : la passe n'aurait aucune session pour la jouer, les mineurs sont rendus en propositions ; la revue conforme conclut le lot,
|
|
1786
|
+
// sauf un lot visible avec orchestrate.ux, qui passe à next = 'ux' puis reste suspendu tant que le budget de la vague l'est.
|
|
1787
|
+
const wanted = summary.conforme && !l.minorPass && !l.light && minors.length > 0; // lot léger : les mineurs restent des notes (L108)
|
|
1519
1788
|
const noBudget = wanted && (w.budget.exhausted || !fixAffordable(l)) && !w.incident && !w.quota.hit;
|
|
1520
1789
|
const minorPass = wanted && !noBudget;
|
|
1521
1790
|
if (noBudget)
|
|
1522
|
-
l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la revue qui suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
|
|
1791
|
+
l.warnings.push(`${w.budget.exhausted ? 'budget atteint' : 'budget du lot trop juste pour payer la correction et la revue courte qui la suivrait'} : la passe des mineurs n'a pas eu lieu, mineurs rendus en propositions`);
|
|
1523
1792
|
// Les mineurs confiés à la passe ont pu être proposés par une revue non conforme antérieure : si la revue qui la suit est
|
|
1524
1793
|
// conforme, ils sont traités et ne restent pas en propositions (ceux que cette revue signale encore sont ajoutés juste après).
|
|
1525
|
-
if (l.minorPass && !minorPass && summary.conforme &&
|
|
1794
|
+
if (l.minorPass && !minorPass && summary.conforme && l.minorLines?.length) {
|
|
1526
1795
|
// Chaque mineur confié retire UNE proposition : la même ligne d'abord, sinon une de même fichier et même texte (le correctif a pu décaler la ligne).
|
|
1527
1796
|
// Un autre mineur de même fichier et même texte, à une autre ligne, reste donc proposé. Limite connue : un texte reformulé n'est pas reconnu et reste proposé.
|
|
1528
1797
|
const left = [...l.proposals];
|
|
@@ -1539,7 +1808,7 @@ async function reviewStep(c, kind) {
|
|
|
1539
1808
|
l.minorLines = [];
|
|
1540
1809
|
}
|
|
1541
1810
|
addProposals(c, rep, 'code', !minorPass);
|
|
1542
|
-
if (summary.conforme
|
|
1811
|
+
if (summary.conforme)
|
|
1543
1812
|
(l.reviewCounts ??= []).push(0); // une revue conforme compte pour 0 dans la comparaison de la convergence (L205)
|
|
1544
1813
|
if (minorPass) {
|
|
1545
1814
|
l.minorPass = true;
|
|
@@ -1551,18 +1820,19 @@ async function reviewStep(c, kind) {
|
|
|
1551
1820
|
save(c);
|
|
1552
1821
|
return;
|
|
1553
1822
|
}
|
|
1554
|
-
if (summary.conforme
|
|
1555
|
-
|
|
1823
|
+
if (summary.conforme) {
|
|
1824
|
+
if (uxAfterReview(c)) {
|
|
1825
|
+
l.next = 'ux';
|
|
1826
|
+
save(c);
|
|
1827
|
+
}
|
|
1828
|
+
else
|
|
1829
|
+
await conclude(c, summary);
|
|
1556
1830
|
return;
|
|
1557
1831
|
}
|
|
1558
|
-
const constats =
|
|
1832
|
+
const constats = blockingConstats(rep, 'code');
|
|
1559
1833
|
(l.reviewCounts ??= []).push(constats.length);
|
|
1560
1834
|
toFix(c, constats, 'revue non conforme');
|
|
1561
1835
|
}
|
|
1562
|
-
/** Constats UX de la dernière revue UX, gardés dans l'état par la revue elle-même (l.constats avant la revue de code). */
|
|
1563
|
-
function uxConstatsKept(c) {
|
|
1564
|
-
return c.lot.constats.filter((k) => k.source === 'ux');
|
|
1565
|
-
}
|
|
1566
1836
|
/** Revue conforme : le verdict est enregistré avec le sha relu, puis le commit du plan. `raf done` reste au lead. */
|
|
1567
1837
|
async function conclude(c, code, minorNote = '') {
|
|
1568
1838
|
const l = c.lot;
|
|
@@ -250,10 +250,13 @@ export function guardPresent(repo) {
|
|
|
250
250
|
return existsSync(file) && readFileSync(file, 'utf8').includes(MARK);
|
|
251
251
|
}
|
|
252
252
|
export async function snapshot(repo, opts = {}) {
|
|
253
|
-
const [status, head, upstream, remote] = await Promise.all([
|
|
253
|
+
const [status, head, upstream, upstreamRef, branch, reflog, remote] = await Promise.all([
|
|
254
254
|
out(repo, ['status', '--porcelain', '--untracked-files=all']),
|
|
255
255
|
out(repo, ['rev-parse', '--verify', '-q', 'HEAD']),
|
|
256
256
|
out(repo, ['rev-parse', '-q', '--verify', '@{u}']),
|
|
257
|
+
out(repo, ['rev-parse', '-q', '--symbolic-full-name', '@{u}']),
|
|
258
|
+
out(repo, ['symbolic-ref', '-q', '--short', 'HEAD']),
|
|
259
|
+
out(repo, ['reflog', '--format=%H', 'HEAD']),
|
|
257
260
|
opts.remote === false ? Promise.resolve(null) : out(repo, ['ls-remote', '--heads']),
|
|
258
261
|
]);
|
|
259
262
|
const lines = (status ?? '').split('\n').filter(Boolean);
|
|
@@ -264,13 +267,20 @@ export async function snapshot(repo, opts = {}) {
|
|
|
264
267
|
tracked: lines.filter((l) => !l.startsWith('??') && keep(l)),
|
|
265
268
|
untracked: lines.filter((l) => l.startsWith('??') && keep(l)).map((l) => l.slice(3)),
|
|
266
269
|
upstream,
|
|
270
|
+
upstreamRef: upstreamRef?.trim() || null,
|
|
271
|
+
branch: branch?.trim() || null,
|
|
272
|
+
reflog: (reflog ?? '').split('\n').filter(Boolean).length,
|
|
267
273
|
guard: guardPresent(repo),
|
|
268
274
|
remote,
|
|
269
275
|
};
|
|
270
276
|
}
|
|
271
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Écart de la référence amont ou du dépôt distant depuis `before` : un push est parti.
|
|
279
|
+
* La référence amont n'est comparée que si c'est la même branche amont des deux côtés : un `git checkout <sha>` (HEAD
|
|
280
|
+
* détaché, plus d'amont) ou un changement de branche déplace le HEAD, pas origin (L214) ; le dépôt distant reste juge.
|
|
281
|
+
*/
|
|
272
282
|
export function pushed(before, after) {
|
|
273
|
-
if (before.upstream !== after.upstream)
|
|
283
|
+
if (before.upstreamRef !== null && before.upstreamRef === after.upstreamRef && before.upstream !== after.upstream)
|
|
274
284
|
return true;
|
|
275
285
|
return before.remote !== null && after.remote !== null && before.remote !== after.remote;
|
|
276
286
|
}
|