@sylad/cadence 0.28.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +244 -18
- package/agents/acceptance-writer.md +19 -0
- package/agents/code-reviewer.md +2 -1
- package/agents/spec-writer.md +21 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +8 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +91 -6
- package/dist/config.js +34 -5
- package/dist/corrections.js +228 -0
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +303 -0
- package/dist/orchestrate/aval.js +46 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +19 -2
- package/dist/orchestrate/cycle.js +529 -27
- package/dist/orchestrate/launch.js +12 -5
- package/dist/orchestrate/maquette.js +27 -0
- package/dist/orchestrate/sandbox.js +38 -0
- package/dist/orchestrate/schemas.js +17 -1
- package/dist/orchestrate/spec.js +140 -0
- package/dist/plan.js +22 -1
- package/dist/spec.js +230 -0
- package/package.json +2 -1
- package/templates/acceptation-runner.mjs +53 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +1 -0
- package/templates/orchestrate/maquette.md +12 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { parse } from 'yaml';
|
|
2
3
|
import { toolBinOf, withoutLaunchVars } from './snapshot.js';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { dirname, join, relative } from 'node:path';
|
|
5
|
-
import { writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
7
|
import { coveredTasks, lotCommits, lotWork } from '../audit.js';
|
|
7
8
|
import { readDocsConfig } from '../config.js';
|
|
8
9
|
import { docSyncBrief, docSyncGaps, filesOf } from '../docsync.js';
|
|
9
|
-
import { readCommits, resolveCommit } from '../git.js';
|
|
10
|
+
import { changedFiles, readCommits, resolveCommit } from '../git.js';
|
|
10
11
|
import { citedRefs } from '../link.js';
|
|
11
12
|
import { repoShas, repoWork } from '../repos.js';
|
|
12
13
|
import { isOpen } from '../plan.js';
|
|
13
14
|
import { isPlanOnly } from '../audit.js';
|
|
14
15
|
import { APP_TIMEOUT_S, startApp } from './app.js';
|
|
15
|
-
import { capturesText, newsText, objective, renderBrief, reposText } from './briefs.js';
|
|
16
|
+
import { capturesText, newsText, objective, renderBrief, reposText, TEMPLATES_DIR } from './briefs.js';
|
|
16
17
|
import { journalTokens, peakContext, mcpServersFor, playwrightDir, runSession, trackGroup, writeMcpConfig } from './launch.js';
|
|
17
18
|
import { cleanPlaywrightOutput, pushed, snapshot } from './guard.js';
|
|
18
|
-
import { checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, WORK_SCHEMA } from './schemas.js';
|
|
19
|
+
import { ACCEPT_SCHEMA, checkShape, PRECHECK_SCHEMA, REVIEW_SCHEMA, schemaFor, SPEC_SCHEMA, WORK_SCHEMA } from './schemas.js';
|
|
20
|
+
import { acceptationFile, acceptText, checkAcceptation, findAcceptation, isAcceptationFile, lockViolations, lotStartHead, parseAcceptation, redSha, sealAcceptation, setVert } from './accept.js';
|
|
21
|
+
import { avalFicheText, ficheRules } from './aval.js';
|
|
22
|
+
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
23
|
+
import { answeredText, checkSpec, costlyHypotheses, ficheReady, findFiche, specText } from './spec.js';
|
|
19
24
|
import { lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
20
25
|
/** Passes de correction au plus, puis la main est rendue au lead. */
|
|
21
26
|
export const MAX_PASSES = 2;
|
|
@@ -78,10 +83,13 @@ async function commitPlan(c, message) {
|
|
|
78
83
|
const repo = c.lot.repo;
|
|
79
84
|
const plan = c.loadPlan();
|
|
80
85
|
const own = new Set([relative(repo, plan.path), ...plan.maintained]);
|
|
86
|
+
// Les acceptation.json (L173) rejoignent les fichiers que le cycle a le droit de commiter : il y pose le scellé et `vert: true`.
|
|
87
|
+
const specDir = `${c.config.spec.dir.replace(/\/+$/, '')}/`;
|
|
88
|
+
const ownFile = (f) => own.has(f) || (f.startsWith(specDir) && isAcceptationFile(f));
|
|
81
89
|
const dirty = trackedPaths(await snapshot(repo, { remote: false }));
|
|
82
90
|
if (dirty.length === 0)
|
|
83
91
|
return null;
|
|
84
|
-
const foreign = dirty.filter((f) => !
|
|
92
|
+
const foreign = dirty.filter((f) => !ownFile(f));
|
|
85
93
|
if (foreign.length)
|
|
86
94
|
return `arbre sale (fichiers hors plan) : ${foreign.join(', ')}`;
|
|
87
95
|
git(repo, 'add', '--', ...dirty);
|
|
@@ -90,9 +98,9 @@ async function commitPlan(c, message) {
|
|
|
90
98
|
}
|
|
91
99
|
const shEscape = (s) => s.replace(/\s+/g, ' ').replace(/[\\"$`]/g, '\\$&');
|
|
92
100
|
/** 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. */
|
|
93
|
-
function sh(c, command, timeoutMs = 30 * 60_000) {
|
|
101
|
+
function sh(c, command, timeoutMs = 30 * 60_000, env = {}) {
|
|
94
102
|
return new Promise((resolve) => {
|
|
95
|
-
const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, env: withoutLaunchVars(process.env), stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
103
|
+
const child = spawn('sh', ['-c', command], { cwd: c.lot.repo, env: { ...withoutLaunchVars(process.env), ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
96
104
|
const pid = child.pid;
|
|
97
105
|
if (pid === undefined) {
|
|
98
106
|
child.once('error', (e) => resolve({ code: 127, output: String(e.message) }));
|
|
@@ -216,16 +224,86 @@ function briefName(l, kind) {
|
|
|
216
224
|
return 'review-recheck';
|
|
217
225
|
return kind;
|
|
218
226
|
}
|
|
227
|
+
/** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
|
|
228
|
+
function ficheTemplate() {
|
|
229
|
+
try {
|
|
230
|
+
return readFileSync(join(TEMPLATES_DIR, '..', 'fiche.yaml'), 'utf8');
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
|
|
237
|
+
function acceptRunner() {
|
|
238
|
+
try {
|
|
239
|
+
return readFileSync(join(TEMPLATES_DIR, '..', 'acceptation-runner.mjs'), 'utf8');
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
|
|
246
|
+
function ficheOf(c) {
|
|
247
|
+
const l = c.lot;
|
|
248
|
+
return l.spec && 'fiche' in l.spec ? l.spec.fiche : findFiche(l.repo, c.config.spec.dir, l.lot);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Consigne de la fiche pour les étapes en aval d'un lot qui en a une (L174) : implement, fix, revue de code, UX de conformité.
|
|
252
|
+
* Vide quand le lot n'a pas de fiche (sous le seuil, ou pas encore écrite) ou qu'elle est illisible.
|
|
253
|
+
*/
|
|
254
|
+
function avalOf(c, kind) {
|
|
255
|
+
const l = c.lot;
|
|
256
|
+
const step = kind === 'implement' || kind === 'fix' || kind === 'ux' ? kind : kind === 'review' || kind === 'review-small' ? 'review' : null;
|
|
257
|
+
const fiche = step ? ficheOf(c) : null;
|
|
258
|
+
if (!step || !fiche)
|
|
259
|
+
return '';
|
|
260
|
+
let rules;
|
|
261
|
+
try {
|
|
262
|
+
rules = ficheRules(parse(readFileSync(join(l.repo, fiche), 'utf8')));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return '';
|
|
266
|
+
}
|
|
267
|
+
if (!rules.length)
|
|
268
|
+
return '';
|
|
269
|
+
const acceptation = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot) ?? undefined;
|
|
270
|
+
const o = { fiche, rules, acceptation, rouge: l.accept?.rouge };
|
|
271
|
+
const text = avalFicheText(step, o);
|
|
272
|
+
// la revue courte d'un petit lot visible est sa seule passe : elle porte aussi la conformité à la maquette
|
|
273
|
+
return kind === 'review-small' && l.small && l.visible ? `${text}\n${avalFicheText('ux', o)}` : text;
|
|
274
|
+
}
|
|
219
275
|
function briefFor(c, kind) {
|
|
220
276
|
const plan = c.loadPlan();
|
|
221
277
|
const l = c.lot;
|
|
222
278
|
const lot = plan.lot(l.lot);
|
|
223
|
-
const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', 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: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
|
|
279
|
+
const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), 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: '', repos: reposText(kind === 'implement' || kind === 'fix' ? 'write' : 'read', l.lot, l.repos ?? []) };
|
|
224
280
|
if (l.visible) {
|
|
225
281
|
const pwDir = playwrightDir(c.wave.store.lotDir(l.project, l.lot));
|
|
226
282
|
vars.news = newsText(l.lot, pwDir);
|
|
227
283
|
vars.captures = capturesText(pwDir);
|
|
228
284
|
}
|
|
285
|
+
vars.fiche = l.screen ? avalOf(c, kind) : '';
|
|
286
|
+
if (kind === 'spec') {
|
|
287
|
+
vars.spec = specText({ dir: c.config.spec.dir, render: c.config.spec.render, screen: l.screen, problems: l.specProblems, review: l.maquetteConstats?.length ? reviewedText(l.maquetteConstats) : undefined, template: ficheTemplate() });
|
|
288
|
+
const mine = lotCommitLines(c);
|
|
289
|
+
if (mine)
|
|
290
|
+
vars.commits = `Commits of the lot already present (do not redo them; fix or complete the spec from them):\n${mine}`;
|
|
291
|
+
if (l.pendingAnswer)
|
|
292
|
+
vars.reponse = l.specAsked?.length ? answeredText(l.specAsked, l.pendingAnswer) : `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
293
|
+
}
|
|
294
|
+
if (kind === 'maquette') {
|
|
295
|
+
vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, previous: l.maquetteConstats });
|
|
296
|
+
vars.reponse = '';
|
|
297
|
+
}
|
|
298
|
+
if (kind === 'accept') {
|
|
299
|
+
const fiche = ficheOf(c) ?? '';
|
|
300
|
+
const folder = posix.dirname(fiche);
|
|
301
|
+
vars.accept = acceptText({ fiche, maquette: `${folder}/maquette.html`, acceptation: acceptationFile(fiche, l.lot), command: c.config.accept ?? '', test: c.config.test, runner: acceptRunner(), problems: l.acceptProblems });
|
|
302
|
+
vars.objectif = ''; // l'oracle est écrit depuis la fiche et la maquette seules, pas depuis le texte du lot
|
|
303
|
+
vars.commits = '';
|
|
304
|
+
if (l.pendingAnswer)
|
|
305
|
+
vars.reponse = `Answer from the human to your earlier question: ${l.pendingAnswer}`;
|
|
306
|
+
}
|
|
229
307
|
if (kind === 'implement' || kind === 'fix') {
|
|
230
308
|
const interrupted = [...l.steps].reverse().find((s) => s.kind === kind && s.status === 'interrupted');
|
|
231
309
|
if (kind === 'fix') {
|
|
@@ -303,11 +381,11 @@ async function session(c, kind, local = false) {
|
|
|
303
381
|
const halt = halted(w, l);
|
|
304
382
|
if (halt)
|
|
305
383
|
return suspend(c, halt);
|
|
306
|
-
const write = kind === 'implement' || kind === 'fix';
|
|
384
|
+
const write = kind === 'implement' || kind === 'fix' || kind === 'spec' || kind === 'accept';
|
|
307
385
|
// Le budget du lot borne l'écriture, jamais la revue (L145) : une revue est jouée dès que le code est à relire (sauf tests rouges après une écriture, cf. REVIEW_RESERVE), la passe fix réserve son coût.
|
|
308
386
|
if (write && (lotOver(l) || (kind === 'fix' && !fixAffordable(l))))
|
|
309
387
|
return overBudget(c, kind === 'fix' && !lotOver(l));
|
|
310
|
-
const model = write ? l.model : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
|
|
388
|
+
const model = kind === 'spec' || kind === 'accept' ? 'sonnet' : write ? l.model : local ? 'local' : kind === 'precheck' ? 'sonnet' : reviewModel(c, kind); // le contrôle préalable ne fait que lire : pas d'Opus
|
|
311
389
|
const effort = c.config.effort[kind === 'review-small' ? 'review' : kind];
|
|
312
390
|
const before = await snapshot(l.repo);
|
|
313
391
|
const neighbours = l.repos ?? [];
|
|
@@ -316,7 +394,7 @@ async function session(c, kind, local = false) {
|
|
|
316
394
|
const sessionId = randomUUID();
|
|
317
395
|
const step = { n, kind, model, ...(effort !== 'default' ? { effort } : {}), status: 'running', sessionId, started: new Date().toISOString(), headBefore: before.head ?? undefined };
|
|
318
396
|
l.steps.push(step);
|
|
319
|
-
const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing' };
|
|
397
|
+
const label = { implement: 'implementing', fix: 'fixing', review: 'reviewing', ux: 'reviewing', 'review-small': 'reviewing', precheck: 'implementing', spec: 'implementing', accept: 'implementing', maquette: 'reviewing' };
|
|
320
398
|
transition(c, label[kind]);
|
|
321
399
|
w.log(`${lotKey(l.project, l.lot)} · session ${n} ${kind} (${model})`);
|
|
322
400
|
let brief;
|
|
@@ -341,10 +419,11 @@ async function session(c, kind, local = false) {
|
|
|
341
419
|
effort,
|
|
342
420
|
local,
|
|
343
421
|
schema: schemaFor(kind),
|
|
344
|
-
agent: kind === 'ux' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
|
|
422
|
+
agent: kind === 'spec' ? 'spec-writer' : kind === 'accept' ? 'acceptance-writer' : kind === 'ux' || kind === 'maquette' ? 'ux-reviewer' : kind === 'precheck' ? 'precheck-reader' : write ? undefined : 'code-reviewer',
|
|
345
423
|
cwd: l.repo,
|
|
346
424
|
wave: w.id,
|
|
347
425
|
permissionMode: c.config.permissionMode,
|
|
426
|
+
prodAccess: c.config.prodAccess,
|
|
348
427
|
addDirs: [...c.config.addDirs, ...(playwright && write ? [playwrightDir(dirname(mcpConfig))] : []), ...neighbourDirs(l)],
|
|
349
428
|
nodeBin: l.node?.link,
|
|
350
429
|
toolBin: toolBinOf(w.store.dir),
|
|
@@ -447,20 +526,91 @@ async function session(c, kind, local = false) {
|
|
|
447
526
|
w.saveWave();
|
|
448
527
|
return stop(c, 'failed', `incident : ${w.incident}`);
|
|
449
528
|
}
|
|
529
|
+
// É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é.
|
|
530
|
+
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))) {
|
|
531
|
+
const fresh = a.untracked.filter((f) => !b.untracked.includes(f)).map(unquotePath);
|
|
532
|
+
const stray = join(dir, 'stray');
|
|
533
|
+
const to = where ? join(stray, relative(l.repo, path).replace(/[\\/]/g, '_')) : stray;
|
|
534
|
+
const moved = moveStray(path, fresh, to);
|
|
535
|
+
if (moved.length) {
|
|
536
|
+
const text = `${kind} : ${moved.length} fichier(s) non suivi(s) laissé(s)${where} par une étape en lecture seule, déplacé(s) dans ${to} : ${moved.join(', ')}`;
|
|
537
|
+
c.lot.warnings.push(text);
|
|
538
|
+
w.log(`${lotKey(l.project, l.lot)} · ${text}`);
|
|
539
|
+
}
|
|
540
|
+
if (moved.length === fresh.length)
|
|
541
|
+
continue;
|
|
542
|
+
// Déplacement partiel : l'incident ne nomme que ce qui reste dans le dépôt.
|
|
543
|
+
return handBack(c, kind, res.structured, step, where, path, b, a, fresh.filter((f) => !moved.includes(f)));
|
|
544
|
+
}
|
|
450
545
|
if (!write && (b.head !== a.head || b.tracked.join() !== a.tracked.join() || b.untracked.join() !== a.untracked.join())) {
|
|
451
|
-
|
|
452
|
-
step.status = 'failed';
|
|
453
|
-
step.cause = `le dépôt${where} a changé pendant une revue`;
|
|
454
|
-
const traces = [...trackedPaths(a).filter((f) => !trackedPaths(b).includes(f)), ...a.untracked.filter((f) => !b.untracked.includes(f))];
|
|
455
|
-
(w.dirtyRepos ??= new Map()).set(path, lotKey(l.project, l.lot));
|
|
456
|
-
const what = traces.length ? ` (${traces.join(', ')})` : b.head !== a.head ? ' (commit)' : '';
|
|
457
|
-
return stop(c, 'handed-back', `incident : ${kind} de ${lotKey(l.project, l.lot)} a modifié le dépôt${where}${what}${lostVerdict(c, kind, res.structured)}`);
|
|
546
|
+
return handBack(c, kind, res.structured, step, where, path, b, a);
|
|
458
547
|
}
|
|
459
548
|
}
|
|
460
549
|
step.status = 'ok';
|
|
461
550
|
save(c);
|
|
462
551
|
return { step, report: res.structured, before, after, others };
|
|
463
552
|
}
|
|
553
|
+
/** 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. */
|
|
554
|
+
function handBack(c, kind, report, step, where, path, b, a, left) {
|
|
555
|
+
const l = c.lot;
|
|
556
|
+
const w = c.wave;
|
|
557
|
+
step.status = 'failed';
|
|
558
|
+
step.cause = `le dépôt${where} a changé pendant une revue`;
|
|
559
|
+
const traces = left ?? [...trackedPaths(a).filter((f) => !trackedPaths(b).includes(f)), ...a.untracked.filter((f) => !b.untracked.includes(f))];
|
|
560
|
+
(w.dirtyRepos ??= new Map()).set(path, lotKey(l.project, l.lot));
|
|
561
|
+
const what = traces.length ? ` (${traces.join(', ')})` : b.head !== a.head ? ' (commit)' : '';
|
|
562
|
+
return stop(c, 'handed-back', `incident : ${kind} de ${lotKey(l.project, l.lot)} a modifié le dépôt${where}${what}${lostVerdict(c, kind, report)}`);
|
|
563
|
+
}
|
|
564
|
+
/** Décode un chemin de `git status --porcelain` : entre guillemets, avec les échappements du C-quoting (`\303\251`, `\"`, `\\`, `\t`…). */
|
|
565
|
+
function unquotePath(f) {
|
|
566
|
+
if (!/^".*"$/s.test(f))
|
|
567
|
+
return f;
|
|
568
|
+
const bytes = [];
|
|
569
|
+
const esc = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };
|
|
570
|
+
const body = f.slice(1, -1);
|
|
571
|
+
for (let i = 0; i < body.length; i++) {
|
|
572
|
+
const ch = body[i];
|
|
573
|
+
if (ch !== '\\') {
|
|
574
|
+
bytes.push(...Buffer.from(ch));
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const oct = /^[0-7]{3}/.exec(body.slice(i + 1));
|
|
578
|
+
if (oct) {
|
|
579
|
+
bytes.push(parseInt(oct[0], 8));
|
|
580
|
+
i += 3;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
const n = body[++i];
|
|
584
|
+
bytes.push(...(n in esc ? [esc[n]] : Buffer.from(n)));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return Buffer.from(bytes).toString('utf8');
|
|
588
|
+
}
|
|
589
|
+
/** Déplace des fichiers non suivis (chemins relatifs au dépôt, décodés) sous `to`, arborescence gardée ; renvoie ceux qui l'ont été (s'arrête au premier échec). */
|
|
590
|
+
function moveStray(repo, files, to) {
|
|
591
|
+
const moved = [];
|
|
592
|
+
for (const rel of files) {
|
|
593
|
+
try {
|
|
594
|
+
const from = join(repo, rel);
|
|
595
|
+
let dest = join(to, rel);
|
|
596
|
+
for (let i = 1; existsSync(dest); i++)
|
|
597
|
+
dest = join(to, `${rel}.${i}`);
|
|
598
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
599
|
+
try {
|
|
600
|
+
renameSync(from, dest);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
copyFileSync(from, dest); // autre volume
|
|
604
|
+
rmSync(from);
|
|
605
|
+
}
|
|
606
|
+
moved.push(rel);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return moved;
|
|
613
|
+
}
|
|
464
614
|
/** Verdict d'une revue interrompue par un incident du lot : rapporté au lead plutôt que perdu (L133). Rien n'est enregistré dans le plan. */
|
|
465
615
|
function lostVerdict(c, kind, report) {
|
|
466
616
|
if (kind === 'precheck')
|
|
@@ -570,7 +720,7 @@ async function precheck(c) {
|
|
|
570
720
|
}
|
|
571
721
|
catch (e) {
|
|
572
722
|
l.warnings.push(`contrôle préalable illisible, implémentation lancée : ${e.message}`);
|
|
573
|
-
l.next =
|
|
723
|
+
l.next = firstWrite(c);
|
|
574
724
|
save(c);
|
|
575
725
|
return;
|
|
576
726
|
}
|
|
@@ -593,9 +743,348 @@ async function precheck(c) {
|
|
|
593
743
|
l.precheck = `A pre-check found part of the deliverable already in the repository (do not redo it): ${rep.resume.trim()}${proofs}`;
|
|
594
744
|
l.warnings.push(`contrôle préalable : livrable en partie présent — ${rep.resume.trim()}${proofs}`);
|
|
595
745
|
}
|
|
746
|
+
l.next = firstWrite(c);
|
|
747
|
+
save(c);
|
|
748
|
+
}
|
|
749
|
+
/** Première session d'écriture d'un lot : la spec de son écran quand il déclare `screen:` et qu'aucune fiche n'est écrite, sinon l'implémentation. */
|
|
750
|
+
function firstWrite(c) {
|
|
751
|
+
const l = c.lot;
|
|
752
|
+
if (!l.screen || l.spec)
|
|
753
|
+
return 'implement';
|
|
754
|
+
const fiche = findFiche(l.repo, c.config.spec.dir, l.lot);
|
|
755
|
+
return fiche && ficheReady(l.repo, fiche, l.lot) ? afterSpec(c, fiche) : 'spec';
|
|
756
|
+
}
|
|
757
|
+
const NO_TEST_ACCEPT = 'étape accept : orchestrate.test absent, rien ne prouve que les tests rouges sont exclus du lanceur de tests et de la CI (vérifier à la main la config du lanceur avant de livrer)';
|
|
758
|
+
const NO_ACCEPT = 'étape accept ignorée : orchestrate.accept absent (commande dédiée aux tests d\'acceptation), le lot passe de spec à implement sans tests rouges';
|
|
759
|
+
/**
|
|
760
|
+
* Étape qui suit une spec écrite : la revue UX de la maquette (S4) tant qu'elle n'est pas conforme (`orchestrate.maquette`), sauf si une
|
|
761
|
+
* acceptation conforme est déjà commitée (elle n'a pu être écrite qu'après la revue) ; puis les tests d'acceptation rouges (S5).
|
|
762
|
+
*/
|
|
763
|
+
function afterSpec(c, fiche) {
|
|
764
|
+
const l = c.lot;
|
|
765
|
+
if (c.config.maquette && !l.maquette?.conforme && !acceptationProof(c, fiche) && !codeAfterFiche(c, fiche))
|
|
766
|
+
return 'maquette';
|
|
767
|
+
return afterMaquette(c, fiche);
|
|
768
|
+
}
|
|
769
|
+
/** Étape qui suit une maquette relue : les tests d'acceptation rouges (S5) quand le projet déclare sa commande dédiée et qu'aucune acceptation conforme n'est déjà commitée, sinon l'implémentation. */
|
|
770
|
+
function afterMaquette(c, fiche) {
|
|
771
|
+
const l = c.lot;
|
|
772
|
+
if (!c.config.accept) {
|
|
773
|
+
if (!l.warnings.includes(NO_ACCEPT))
|
|
774
|
+
l.warnings.push(NO_ACCEPT);
|
|
775
|
+
return 'implement';
|
|
776
|
+
}
|
|
777
|
+
const json = lotAcceptation(c, fiche);
|
|
778
|
+
if (acceptationProof(c, fiche)) {
|
|
779
|
+
l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
|
|
780
|
+
return 'implement';
|
|
781
|
+
}
|
|
782
|
+
return 'accept';
|
|
783
|
+
}
|
|
784
|
+
/** Le lot a déjà du code commité après sa fiche : l'implémentation n'a pu partir qu'une fois la maquette relue, la revue n'est pas rejouée (même sans `orchestrate.accept`). */
|
|
785
|
+
function codeAfterFiche(c, fiche) {
|
|
786
|
+
const l = c.lot;
|
|
787
|
+
let sha;
|
|
788
|
+
try {
|
|
789
|
+
// Le point de départ est le commit qui a créé la fiche : un commit plus récent du dossier (capture corrigée) ne doit pas masquer le code commité avant lui.
|
|
790
|
+
sha = git(l.repo, 'log', '--diff-filter=A', '--format=%H', '--', fiche).split('\n').filter(Boolean).pop() ?? git(l.repo, 'log', '-1', '--format=%H', '--', posix.dirname(fiche));
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
if (!sha)
|
|
796
|
+
return false;
|
|
797
|
+
const dir = `${posix.dirname(fiche)}/`;
|
|
798
|
+
const after = new Set(readCommits(l.repo, { range: `${sha}..HEAD` }).map((k) => k.sha));
|
|
799
|
+
// Un commit de la session spec elle-même (fiche, maquette, captures) ou de la session accept (tests listés) n'est pas du code.
|
|
800
|
+
const tests = new Set();
|
|
801
|
+
const json = lotAcceptation(c, fiche);
|
|
802
|
+
if (listed(c, json)) {
|
|
803
|
+
const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
|
|
804
|
+
if (typeof doc !== 'string')
|
|
805
|
+
for (const f of doc.fichiers)
|
|
806
|
+
tests.add(posix.normalize(f));
|
|
807
|
+
}
|
|
808
|
+
return lotWork(c.loadPlan(), l.repo, l.lot).some((k) => after.has(k.sha) && changedFiles(l.repo, k.sha).some((f) => !f.startsWith(dir) && !tests.has(f)));
|
|
809
|
+
}
|
|
810
|
+
/** L'acceptation du lot : celle que git suit déjà pour lui (même un ancien `acceptation.json`), sinon `acceptation-<lot>.json` à côté de la fiche. */
|
|
811
|
+
const lotAcceptation = (c, fiche) => findAcceptation(c.lot.repo, c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
|
|
812
|
+
const listed = (c, json) => existsSync(join(c.lot.repo, json));
|
|
813
|
+
/**
|
|
814
|
+
* Une acceptation conforme et SCELLÉE par le programme (empreintes posées), rouge ou verte (`vert: true`, lot relancé dans une nouvelle vague) :
|
|
815
|
+
* elle n'a pu être écrite qu'après la revue de la maquette et les tests rouges, qui ne sont donc pas rejoués. Sans `orchestrate.accept`, aucune preuve.
|
|
816
|
+
*/
|
|
817
|
+
function acceptationProof(c, fiche) {
|
|
818
|
+
const l = c.lot;
|
|
819
|
+
const json = lotAcceptation(c, fiche);
|
|
820
|
+
if (!c.config.accept || !listed(c, json))
|
|
821
|
+
return false;
|
|
822
|
+
const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
|
|
823
|
+
if (typeof doc === 'string' || !doc.empreintes)
|
|
824
|
+
return false;
|
|
825
|
+
// Le scellé n'est posé que par le programme : chaque fichier listé doit porter l'empreinte git de son contenu actuel (une clé `empreintes` vide ou copiée d'un autre lot ne prouve rien).
|
|
826
|
+
const emp = doc.empreintes;
|
|
827
|
+
const sealed = doc.fichiers.every((f) => {
|
|
828
|
+
const norm = posix.normalize(f);
|
|
829
|
+
return existsSync(join(l.repo, norm)) && emp[norm] === git(l.repo, 'hash-object', '--', norm).trim();
|
|
830
|
+
});
|
|
831
|
+
if (!sealed)
|
|
832
|
+
return false;
|
|
833
|
+
const vertMsg = checkAcceptation(l.repo, json, l.lot, fiche).filter((p) => !(doc.vert && p.includes('vert: true à l\'écriture')));
|
|
834
|
+
return vertMsg.length === 0;
|
|
835
|
+
}
|
|
836
|
+
/** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
|
|
837
|
+
function dirtAfter(done) {
|
|
838
|
+
return [
|
|
839
|
+
...trackedPaths(done.after),
|
|
840
|
+
...done.after.untracked.filter((f) => !done.before.untracked.includes(f)),
|
|
841
|
+
...done.others.flatMap((o) => [...trackedPaths(o.after), ...o.after.untracked.filter((f) => !o.before.untracked.includes(f))].map((f) => `${o.repo.rel}: ${f}`)),
|
|
842
|
+
];
|
|
843
|
+
}
|
|
844
|
+
/** Suite commune des étapes spec et accept : commits de la session (avertis s'ils ne citent pas le lot), choix reportés, vraies questions (les non-questions deviennent un avertissement). */
|
|
845
|
+
function takeReport(c, done, rep) {
|
|
846
|
+
const l = c.lot;
|
|
847
|
+
const commits = readCommits(l.repo, { range: done.step.headBefore ? `${done.step.headBefore}..HEAD` : undefined });
|
|
848
|
+
done.step.commits = commits.map(short).reverse();
|
|
849
|
+
for (const k of commits)
|
|
850
|
+
if (!citedRefs(k, c.loadPlan().refs).some((r) => r.lot === l.lot))
|
|
851
|
+
l.warnings.push(`commit qui ne cite pas ${l.lot} : ${short(k)}`);
|
|
852
|
+
for (const x of rep.choix ?? [])
|
|
853
|
+
if (!(l.choix ??= []).includes(x))
|
|
854
|
+
l.choix.push(x);
|
|
855
|
+
save(c);
|
|
856
|
+
for (const q of rep.questions)
|
|
857
|
+
if (q.trim() && isNonQuestion(q))
|
|
858
|
+
l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
|
|
859
|
+
return rep.questions.filter((q) => !isNonQuestion(q));
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Étape spec (L171, spec etape-spec S3) : la session écrit maquette, captures et fiche dans `<spec.dir>/<écran>/`, le programme
|
|
863
|
+
* en relève les écarts (une seconde passe, puis le lot est rendu), une hypothèse coûteuse à défaire devient une question au lead,
|
|
864
|
+
* et une sortie sous le seuil (le lot ne change aucun écran) rend le lot au chemin actuel.
|
|
865
|
+
*/
|
|
866
|
+
async function specStep(c) {
|
|
867
|
+
const l = c.lot;
|
|
868
|
+
const done = await session(c, 'spec');
|
|
869
|
+
if (!done)
|
|
870
|
+
return;
|
|
871
|
+
let rep;
|
|
872
|
+
try {
|
|
873
|
+
rep = checkShape(done.report, SPEC_SCHEMA);
|
|
874
|
+
}
|
|
875
|
+
catch (e) {
|
|
876
|
+
badReport(c, done.step, e);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const questions = takeReport(c, done, rep);
|
|
880
|
+
if (questions.length) {
|
|
881
|
+
l.questions = questions;
|
|
882
|
+
l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
|
|
883
|
+
l.next = 'spec';
|
|
884
|
+
transition(c, 'question', questions[0]);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const dirt = dirtAfter(done);
|
|
888
|
+
if (dirt.length) {
|
|
889
|
+
stop(c, 'handed-back', `dépôt sale après spec : ${dirt.join(', ')}`);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const reason = rep.sousSeuil.trim();
|
|
893
|
+
if (reason) {
|
|
894
|
+
l.spec = { sousSeuil: reason };
|
|
895
|
+
l.specProblems = undefined;
|
|
896
|
+
l.warnings.push(`étape spec : sous le seuil, aucune maquette — ${reason}`);
|
|
897
|
+
l.next = 'implement';
|
|
898
|
+
save(c);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
// Le chemin du rapport est ramené relatif au dépôt (absolu, `./…` ou `a/../b` donneraient de faux écarts) ; hors du dépôt, c'est un écart.
|
|
902
|
+
const reported = rep.fiche.trim();
|
|
903
|
+
const asked = reported ? relative(l.repo, resolve(l.repo, reported)) : '';
|
|
904
|
+
const outside = asked === '..' || asked.startsWith('../') || isAbsolute(asked);
|
|
905
|
+
const fiche = outside ? '' : asked || findFiche(l.repo, c.config.spec.dir, l.lot) || '';
|
|
906
|
+
const inDir = fiche === '' || fiche.startsWith(`${c.config.spec.dir.replace(/\/+$/, '')}/`);
|
|
907
|
+
const problems = !outside && fiche && !inDir ? [`${fiche} : fiche hors de ${c.config.spec.dir} (la spec vit dans ${c.config.spec.dir}/<écran>/fiche.yaml, où l'étape la retrouve)`] : outside ? [`${reported} : fiche hors du dépôt (le chemin de « fiche » est relatif au dépôt : ${c.config.spec.dir}/<écran>/fiche.yaml)`] : fiche ? checkSpec(l.repo, fiche, l.lot) : [`aucune fiche écrite : ${c.config.spec.dir}/<écran>/fiche.yaml est attendue (ou une raison dans « sousSeuil » si le lot ne change aucun écran)`];
|
|
908
|
+
if (problems.length) {
|
|
909
|
+
if (l.specProblems) {
|
|
910
|
+
stop(c, 'handed-back', `spec non conforme après 2 passes : ${problems.join(' ; ')}`);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
l.specProblems = problems;
|
|
914
|
+
l.next = 'spec';
|
|
915
|
+
save(c);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
l.specProblems = undefined; // les contrôles passent : les écarts de la passe précédente sont corrigés
|
|
919
|
+
const doc = parse(readFileSync(join(l.repo, fiche), 'utf8'));
|
|
920
|
+
// Un choix coûteux à défaire n'est pas une hypothèse : la question remonte au lead, la session reprend avec sa réponse.
|
|
921
|
+
const costly = costlyHypotheses(doc);
|
|
922
|
+
if (costly.length) {
|
|
923
|
+
l.questions = costly.map((x) => `${x.id} est posée comme hypothèse mais touche un choix coûteux à défaire (${x.categorie}) : « ${x.siFaux} » — la confirmer, ou la corriger, avant que la fiche serve de référence.`);
|
|
924
|
+
l.specAsked = [...l.questions];
|
|
925
|
+
l.next = 'spec';
|
|
926
|
+
transition(c, 'question', l.questions[0]);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
l.specAsked = undefined;
|
|
930
|
+
l.spec = { fiche, hypotheses: (doc.regles ?? []).filter((r) => r.statut === 'hypothese').length };
|
|
931
|
+
l.specProblems = undefined;
|
|
932
|
+
l.next = afterSpec(c, fiche);
|
|
933
|
+
save(c);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Étape maquette (L172, spec etape-spec S4) : l'agent ux-reviewer, en lecture seule, relit la maquette et sa fiche. Un bloquant ou un majeur renvoie
|
|
937
|
+
* à la spec (une seule seconde passe, avec les constats), puis la maquette est relue ; ce qui reste après la seconde revue est rendu au lead.
|
|
938
|
+
* Les mineurs vont en notes du lot. Ce verdict n'est pas `raf ux` : la conformité après code garde ce rôle.
|
|
939
|
+
*/
|
|
940
|
+
async function maquetteStep(c) {
|
|
941
|
+
const l = c.lot;
|
|
942
|
+
const fiche = ficheOf(c);
|
|
943
|
+
if (!fiche) {
|
|
944
|
+
l.warnings.push('revue de la maquette ignorée : aucune fiche trouvée pour ce lot');
|
|
945
|
+
l.next = 'implement';
|
|
946
|
+
save(c);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
const done = await session(c, 'maquette');
|
|
950
|
+
if (!done)
|
|
951
|
+
return;
|
|
952
|
+
let rep;
|
|
953
|
+
try {
|
|
954
|
+
rep = checkShape(done.report, REVIEW_SCHEMA);
|
|
955
|
+
}
|
|
956
|
+
catch (e) {
|
|
957
|
+
badReport(c, done.step, e);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const summary = summarize(rep, done.after.head ?? '');
|
|
961
|
+
const passes = (l.maquette?.passes ?? 0) + 1;
|
|
962
|
+
l.maquette = { passes, conforme: summary.conforme, verdict: summary.verdict };
|
|
963
|
+
addProposals(c, rep, 'maquette');
|
|
964
|
+
const constats = blockingConstats(rep, 'ux');
|
|
965
|
+
if (summary.conforme) {
|
|
966
|
+
l.maquetteConstats = undefined;
|
|
967
|
+
l.next = afterMaquette(c, fiche);
|
|
968
|
+
save(c);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (passes >= 2) {
|
|
972
|
+
stop(c, 'handed-back', `maquette non conforme après 2 revues : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
l.maquetteConstats = constats.length ? constats : [{ source: 'ux', gravite: 'majeur', texte: summary.verdict }];
|
|
976
|
+
l.next = 'spec';
|
|
977
|
+
save(c);
|
|
978
|
+
}
|
|
979
|
+
/** Commande dédiée aux tests d'acceptation du lot : `{acceptation}` y est remplacé, `CADENCE_ACCEPTATION` posé, chemin absolu dans les deux cas. */
|
|
980
|
+
function runAccept(c, json) {
|
|
981
|
+
const abs = join(c.lot.repo, json);
|
|
982
|
+
return sh(c, (c.config.accept ?? '').replaceAll('{acceptation}', abs), 30 * 60_000, { CADENCE_ACCEPTATION: abs });
|
|
983
|
+
}
|
|
984
|
+
const tail = (output, n = 40) => output.trim().split('\n').slice(-n).join('\n');
|
|
985
|
+
/** Écarts des tests d'acceptation écrits par la session (vide = acceptés) : autres lots intacts, acceptation.json conforme, tests rouges, config.test toujours verte. */
|
|
986
|
+
async function acceptProblems(c, fiche, json, headBefore) {
|
|
987
|
+
const l = c.lot;
|
|
988
|
+
const out = headBefore ? lockViolations(l.repo, c.config.spec.dir, headBefore, { skip: json }) : [];
|
|
989
|
+
out.push(...checkAcceptation(l.repo, json, l.lot, fiche, l.steps.find((s) => s.kind === 'accept')?.headBefore));
|
|
990
|
+
if (out.length)
|
|
991
|
+
return out;
|
|
992
|
+
const red = await runAccept(c, json);
|
|
993
|
+
if (red.code === 0)
|
|
994
|
+
out.push(`la commande dédiée (orchestrate.accept) passe alors que le code n'existe pas : des tests verts avant le code ne prouvent rien — chaque test doit échouer parce que la fonctionnalité manque\n${tail(red.output, 15)}`);
|
|
995
|
+
if (!c.config.test) {
|
|
996
|
+
if (!l.warnings.includes(NO_TEST_ACCEPT))
|
|
997
|
+
l.warnings.push(NO_TEST_ACCEPT);
|
|
998
|
+
}
|
|
999
|
+
else {
|
|
1000
|
+
const ordinary = await sh(c, c.config.test);
|
|
1001
|
+
if (ordinary.code !== 0)
|
|
1002
|
+
out.push(`orchestrate.test échoue (code ${ordinary.code}) avec les tests rouges commités : les fichiers listés dans ${json} ne sont pas exclus de la config du lanceur (partagée avec la CI) tant que vert est false\n${tail(ordinary.output, 15)}`);
|
|
1003
|
+
}
|
|
1004
|
+
return out;
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Étape accept (L173, spec etape-spec S5) : une session distincte de l'implémenteur écrit, depuis la fiche et la maquette seules, les tests
|
|
1008
|
+
* d'acceptation rouges. Le programme vérifie (acceptation.json conforme, autres lots intacts, commande dédiée rouge, config.test verte),
|
|
1009
|
+
* accorde une seconde passe, puis SCELLE le fichier (empreintes) dans un commit du cycle : c'est le commit rouge du verrou.
|
|
1010
|
+
*/
|
|
1011
|
+
async function acceptStep(c) {
|
|
1012
|
+
const l = c.lot;
|
|
1013
|
+
const fiche = ficheOf(c);
|
|
1014
|
+
if (!fiche || !c.config.accept) {
|
|
1015
|
+
l.next = 'implement';
|
|
1016
|
+
save(c);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
const json = lotAcceptation(c, fiche);
|
|
1020
|
+
const done = await session(c, 'accept');
|
|
1021
|
+
if (!done)
|
|
1022
|
+
return;
|
|
1023
|
+
let rep;
|
|
1024
|
+
try {
|
|
1025
|
+
rep = checkShape(done.report, ACCEPT_SCHEMA);
|
|
1026
|
+
}
|
|
1027
|
+
catch (e) {
|
|
1028
|
+
badReport(c, done.step, e);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const questions = takeReport(c, done, rep);
|
|
1032
|
+
if (questions.length) {
|
|
1033
|
+
l.questions = questions;
|
|
1034
|
+
l.next = 'accept';
|
|
1035
|
+
transition(c, 'question', questions[0]);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const dirt = dirtAfter(done);
|
|
1039
|
+
if (dirt.length) {
|
|
1040
|
+
stop(c, 'handed-back', `dépôt sale après accept : ${dirt.join(', ')}`);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const problems = await acceptProblems(c, fiche, json, done.step.headBefore);
|
|
1044
|
+
if (problems.length) {
|
|
1045
|
+
if (l.acceptProblems) {
|
|
1046
|
+
stop(c, 'handed-back', `accept non conforme après 2 passes : ${problems.join(' ; ')}`);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
l.acceptProblems = problems;
|
|
1050
|
+
l.next = 'accept';
|
|
1051
|
+
save(c);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
l.acceptProblems = undefined;
|
|
1055
|
+
sealAcceptation(l.repo, json);
|
|
1056
|
+
const dirty = await commitPlan(c, `test(${l.lot}): acceptation scellée, empreintes des tests rouges (orchestrate ${c.wave.id})`);
|
|
1057
|
+
if (dirty) {
|
|
1058
|
+
stop(c, 'handed-back', `scellé de ${json} : ${dirty}`);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
l.accept = { acceptation: json, rouge: git(l.repo, 'rev-parse', 'HEAD') };
|
|
596
1062
|
l.next = 'implement';
|
|
597
1063
|
save(c);
|
|
598
1064
|
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Retour au vert (S5) : à la fin de chaque implement/fix, quand la commande dédiée passe, le CYCLE (jamais une session) pose `vert: true`
|
|
1067
|
+
* dans l'acceptation.json du lot et le commite ; les fichiers rejoignent alors config.test et la CI. Rend un constat quand elle échoue.
|
|
1068
|
+
*/
|
|
1069
|
+
async function acceptGreen(c) {
|
|
1070
|
+
const l = c.lot;
|
|
1071
|
+
if (!c.config.accept)
|
|
1072
|
+
return null;
|
|
1073
|
+
const json = l.accept?.acceptation ?? findAcceptation(l.repo, c.config.spec.dir, l.lot);
|
|
1074
|
+
if (!json || !existsSync(join(l.repo, json)))
|
|
1075
|
+
return null;
|
|
1076
|
+
const a = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
|
|
1077
|
+
if (typeof a === 'string' || a.vert)
|
|
1078
|
+
return null;
|
|
1079
|
+
const r = await runAccept(c, json);
|
|
1080
|
+
if (r.code !== 0)
|
|
1081
|
+
return { source: 'tests', gravite: 'bloquant', texte: `tests d'acceptation encore rouges (${c.config.accept}, code ${r.code}) : à rendre verts sans modifier les fichiers listés dans ${json} :\n${tail(r.output)}` };
|
|
1082
|
+
setVert(l.repo, json);
|
|
1083
|
+
const dirty = await commitPlan(c, `chore(${l.lot}): acceptation verte, vert: true posé par le cycle (orchestrate ${c.wave.id})`);
|
|
1084
|
+
if (dirty)
|
|
1085
|
+
return { source: 'tests', gravite: 'bloquant', texte: `vert: true de ${json} : ${dirty}` };
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
599
1088
|
/** Étape d'écriture (implémentation, correction) : contrôles, puis étape suivante décidée. */
|
|
600
1089
|
async function work(c, kind) {
|
|
601
1090
|
const l = c.lot;
|
|
@@ -661,15 +1150,17 @@ async function work(c, kind) {
|
|
|
661
1150
|
transition(c, 'question', questions[0]);
|
|
662
1151
|
return;
|
|
663
1152
|
}
|
|
664
|
-
const dirt =
|
|
665
|
-
...trackedPaths(done.after),
|
|
666
|
-
...done.after.untracked.filter((f) => !done.before.untracked.includes(f)),
|
|
667
|
-
...done.others.flatMap((o) => [...trackedPaths(o.after), ...o.after.untracked.filter((f) => !o.before.untracked.includes(f))].map((f) => `${o.repo.rel}: ${f}`)),
|
|
668
|
-
];
|
|
1153
|
+
const dirt = dirtAfter(done);
|
|
669
1154
|
if (dirt.length) {
|
|
670
1155
|
stop(c, 'handed-back', `dépôt sale après ${kind} : ${dirt.join(', ')}`);
|
|
671
1156
|
return;
|
|
672
1157
|
}
|
|
1158
|
+
// Verrou des tests d'acceptation (L173) : ceux de TOUS les lots du dépôt, pas seulement du lot courant ; une modification est une question au lead.
|
|
1159
|
+
const locked = done.step.headBefore ? lockViolations(l.repo, c.config.spec.dir, done.step.headBefore, { rouge: l.accept?.rouge ? { [l.accept.acceptation]: l.accept.rouge } : {}, lot: l.lot, lotBase: l.steps.find((s) => s.headBefore)?.headBefore, lotStart: lotStartHead(l.repo, lotWork(c.loadPlan(), l.repo, l.lot).map((k) => k.sha)), lotCommits: lotCommits(c.loadPlan(), l.repo, l.lot).map((k) => k.sha) }) : [];
|
|
1160
|
+
if (locked.length) {
|
|
1161
|
+
stop(c, 'handed-back', `verrou des tests d'acceptation : ${locked.join(' ; ')}`);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
673
1164
|
// Reprise : une session précédente de l'étape (correction ordinaire ou passe des mineurs) a pu commiter avant d'être coupée ; la revue d'origine ne vaut alors plus.
|
|
674
1165
|
// Référence : le HEAD de départ de la première session coupée de la série qui précède celle-ci (pas la revue, qui peut ne pas exister encore).
|
|
675
1166
|
let resumedFrom;
|
|
@@ -708,6 +1199,11 @@ async function work(c, kind) {
|
|
|
708
1199
|
if (!rep.build.vert)
|
|
709
1200
|
red.push({ source: 'tests', gravite: 'bloquant', texte: `build annoncé rouge par la session : ${rep.build.commande} — ${rep.build.resultat}` });
|
|
710
1201
|
const runs = [];
|
|
1202
|
+
if (red.length === 0) {
|
|
1203
|
+
const green = await acceptGreen(c);
|
|
1204
|
+
if (green)
|
|
1205
|
+
red.push(green);
|
|
1206
|
+
}
|
|
711
1207
|
if (red.length === 0) {
|
|
712
1208
|
for (const [label, command] of [['tests', c.config.test], ['build', c.config.build]]) {
|
|
713
1209
|
if (!command)
|
|
@@ -1011,13 +1507,19 @@ export async function runLot(c) {
|
|
|
1011
1507
|
return;
|
|
1012
1508
|
}
|
|
1013
1509
|
l.startedSha = (await snapshot(l.repo, { remote: false })).head ?? undefined;
|
|
1014
|
-
l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos) ? 'precheck' :
|
|
1510
|
+
l.next = needsPrecheck(c.config, c.loadPlan(), l.repo, l.lot, l.repos) ? 'precheck' : firstWrite(c);
|
|
1015
1511
|
save(c);
|
|
1016
1512
|
}
|
|
1017
1513
|
while (l.next && !TERMINAL.has(l.status)) {
|
|
1018
1514
|
const kind = l.next;
|
|
1019
1515
|
if (kind === 'precheck')
|
|
1020
1516
|
await precheck(c);
|
|
1517
|
+
else if (kind === 'spec')
|
|
1518
|
+
await specStep(c);
|
|
1519
|
+
else if (kind === 'maquette')
|
|
1520
|
+
await maquetteStep(c);
|
|
1521
|
+
else if (kind === 'accept')
|
|
1522
|
+
await acceptStep(c);
|
|
1021
1523
|
else if (kind === 'implement' || kind === 'fix')
|
|
1022
1524
|
await work(c, kind);
|
|
1023
1525
|
else
|