@sylad/cadence 1.34.1 → 1.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/NOTICE +28 -0
- package/README.md +1 -1
- package/agents/qa-reviewer.md +5 -4
- package/agents/spec-writer.md +2 -0
- package/agents/ux-reviewer.md +28 -0
- package/dist/cli.js +13 -29
- package/dist/config.js +8 -21
- package/dist/corrections-sans-code.js +12 -0
- package/dist/deliver-ci.js +105 -0
- package/dist/deliver.js +61 -79
- package/dist/gate-config.js +47 -0
- package/dist/orchestrate/accept.js +5 -2
- package/dist/orchestrate/ajout.js +186 -0
- package/dist/orchestrate/baseline.js +57 -0
- package/dist/orchestrate/briefs.js +4 -1
- package/dist/orchestrate/command.js +118 -140
- package/dist/orchestrate/cycle.js +69 -55
- package/dist/orchestrate/depot.js +33 -0
- package/dist/orchestrate/gate-notes.js +27 -0
- package/dist/orchestrate/gate-parts.js +135 -0
- package/dist/orchestrate/gate-place.js +66 -0
- package/dist/orchestrate/lot-budget.js +50 -0
- package/dist/orchestrate/pool.js +1 -1
- package/dist/orchestrate/retrait.js +37 -0
- package/dist/orchestrate/snapshot.js +13 -0
- package/dist/orchestrate/state.js +48 -10
- package/dist/orchestrate/tiers.js +9 -0
- package/dist/orchestrate/workers.js +31 -4
- package/dist/plan-commit.js +4 -2
- package/dist/plan-uncommitted.js +26 -0
- package/dist/release.js +9 -7
- package/dist/remote-gate.js +278 -0
- package/dist/state.js +4 -0
- package/dist/verify-gate-remote.js +55 -0
- package/dist/verify-gate.js +37 -28
- package/docs/corrections.md +4 -0
- package/docs/deliver.md +22 -2
- package/docs/lead.md +3 -1
- package/docs/news.md +2 -4
- package/docs/orchestrate.md +22 -0
- package/docs/overview.md +1 -0
- package/docs/raf.md +3 -3
- package/docs/releasing.md +17 -8
- package/docs/remote-gate.md +89 -0
- package/docs/skills.md +5 -2
- package/docs/whats-new.md +6 -0
- package/package.json +3 -1
- package/skills/lead/SKILL.md +1 -1
- package/templates/orchestrate/spec.md +3 -1
package/dist/deliver.js
CHANGED
|
@@ -6,16 +6,14 @@ import { parse } from 'yaml';
|
|
|
6
6
|
import { onTermination, readProcs, SIGNAL_GRACE_MS, SIGNAL_PENDING_MS, TreeTracker } from './proc.js';
|
|
7
7
|
import { deliveryLots, isPlanOnly } from './audit.js';
|
|
8
8
|
import { commitPaths, dirtyPaths, headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
|
|
9
|
+
import { planOnlySince, pollGithubCi, stamped, stepJournal } from './deliver-ci.js';
|
|
9
10
|
import { PLAN_ACTIONS, planCommitMessage } from './plan-commit.js';
|
|
10
11
|
import { citedRefs } from './link.js';
|
|
11
12
|
import { describeArticle, openArticleLots } from './articles.js';
|
|
12
13
|
import { RafError } from './plan.js';
|
|
13
14
|
import { appendDelivery, appendFailedDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
14
|
-
const POLL_CI = 15_000;
|
|
15
|
-
const CI_APPEAR = 300_000;
|
|
16
15
|
/** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
|
|
17
16
|
const POLL_VERIFY = 10_000;
|
|
18
|
-
const CI_OK = new Set(['success', 'skipped', 'neutral']);
|
|
19
17
|
const GH_TIMEOUT = 60_000;
|
|
20
18
|
export const TIMED_OUT = 124;
|
|
21
19
|
/** Sortie gardée d'une commande : de quoi retrouver ses dernières lignes. */
|
|
@@ -236,6 +234,32 @@ export function describeCheck(c, sha) {
|
|
|
236
234
|
return `GET ${substitute(c.url, sha)} → ${c.status ?? 200}${c.contains === undefined ? '' : `, contient « ${substitute(c.contains, sha)} »`}`;
|
|
237
235
|
}
|
|
238
236
|
const guardMessage = (sha, lots) => `${sha.slice(0, 7)} contient des commits de lots ni terminés ni prêts : ${lots.map((l) => `${l.id} (${l.state})`).join(', ')} ; livrer un sha antérieur, ou relire ces lots (raf review) avant`;
|
|
237
|
+
/** La simulation de `--dry-run` : ce que la livraison jouerait, sans rien exécuter. */
|
|
238
|
+
function describeSimulation(ctx, sha, env, branch, script) {
|
|
239
|
+
const { config, out } = ctx;
|
|
240
|
+
out(`Livraison de ${env.CADENCE_SHORT} (${branch}) — simulation, rien n'est exécuté`);
|
|
241
|
+
if (script !== null) {
|
|
242
|
+
out(` Script du projet : ${script}`);
|
|
243
|
+
out(` (CI, déploiement et contrôles métier sont les siens ; délai ${config.deployTimeout} s)`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const skipped = config.ci === 'none' ? null : planOnlySince(ctx, sha);
|
|
247
|
+
if (skipped)
|
|
248
|
+
out(` CI : non attendue (aucun fichier hors plan depuis ${skipped.slice(0, 7)})`);
|
|
249
|
+
else
|
|
250
|
+
out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
|
|
251
|
+
out(' Déploiement :');
|
|
252
|
+
if (config.deploy.length === 0)
|
|
253
|
+
out(' (aucune commande)');
|
|
254
|
+
config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
|
|
255
|
+
}
|
|
256
|
+
if (ctx.articles?.length)
|
|
257
|
+
out(` Articles rafraîchis après une livraison verte (lot « Article <projet> à rafraîchir ») : ${ctx.articles.map(describeArticle).join(', ')}`);
|
|
258
|
+
if (config.verify.length)
|
|
259
|
+
out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
|
|
260
|
+
config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
|
|
261
|
+
out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
262
|
+
}
|
|
239
263
|
/** Livre HEAD. 0 livré, 1 étape en échec, 2 refus avant d'agir. */
|
|
240
264
|
export async function deliver(ctx, deps) {
|
|
241
265
|
const { config, out, err } = ctx;
|
|
@@ -293,24 +317,7 @@ export async function deliver(ctx, deps) {
|
|
|
293
317
|
const branch = atHead ? (repo.branch ?? 'HEAD détachée') : 'sha choisi, antérieur ou étranger à la tête';
|
|
294
318
|
const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: atHead ? (repo.branch ?? '') : '' };
|
|
295
319
|
if (ctx.dryRun) {
|
|
296
|
-
|
|
297
|
-
if (script !== null) {
|
|
298
|
-
out(` Script du projet : ${script}`);
|
|
299
|
-
out(` (CI, déploiement et contrôles métier sont les siens ; délai ${config.deployTimeout} s)`);
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
|
|
303
|
-
out(' Déploiement :');
|
|
304
|
-
if (config.deploy.length === 0)
|
|
305
|
-
out(' (aucune commande)');
|
|
306
|
-
config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
|
|
307
|
-
}
|
|
308
|
-
if (ctx.articles?.length)
|
|
309
|
-
out(` Articles rafraîchis après une livraison verte (lot « Article <projet> à rafraîchir ») : ${ctx.articles.map(describeArticle).join(', ')}`);
|
|
310
|
-
if (config.verify.length)
|
|
311
|
-
out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
|
|
312
|
-
config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
|
|
313
|
-
out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
320
|
+
describeSimulation(ctx, sha, env, branch, script);
|
|
314
321
|
if (undelivered.length)
|
|
315
322
|
return refuse(guardMessage(sha, undelivered));
|
|
316
323
|
return 0;
|
|
@@ -328,6 +335,7 @@ export async function deliver(ctx, deps) {
|
|
|
328
335
|
}
|
|
329
336
|
if (!taken)
|
|
330
337
|
return refuse('une autre livraison vient de démarrer');
|
|
338
|
+
const startedAt = deps.now();
|
|
331
339
|
try {
|
|
332
340
|
const fail = (msg, cls, tail = []) => {
|
|
333
341
|
err(`deliver : ${msg}`);
|
|
@@ -341,9 +349,10 @@ export async function deliver(ctx, deps) {
|
|
|
341
349
|
return 1;
|
|
342
350
|
};
|
|
343
351
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
|
|
352
|
+
const step = stepJournal(ctx, deps, sha, () => deliveredIds(ctx, lastDelivery(ctx.state), sha));
|
|
344
353
|
let declared = null;
|
|
345
354
|
if (script !== null) {
|
|
346
|
-
out(`→ script du projet : ${script}`);
|
|
355
|
+
out(stamped(deps, `→ script du projet : ${script}`));
|
|
347
356
|
// Le script qui déploie un autre commit que CADENCE_SHA y écrit son sha : cadence ne le devine pas.
|
|
348
357
|
const file = join(ctx.state, `deployed-${process.pid}`);
|
|
349
358
|
try {
|
|
@@ -368,18 +377,28 @@ export async function deliver(ctx, deps) {
|
|
|
368
377
|
}
|
|
369
378
|
}
|
|
370
379
|
else {
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
380
|
+
const skipped = config.ci === 'none' ? null : planOnlySince(ctx, sha);
|
|
381
|
+
if (skipped)
|
|
382
|
+
out(stamped(deps, `→ CI : non attendue (aucun fichier hors plan depuis ${skipped.slice(0, 7)})`));
|
|
383
|
+
else {
|
|
384
|
+
const ciStart = deps.now();
|
|
385
|
+
const ci = await waitCi(ctx, deps, sha, env);
|
|
386
|
+
if (config.ci !== 'none')
|
|
387
|
+
step('ci', ciStart, ci === null);
|
|
388
|
+
// Seule une commande de CI a une sortie : pour `ci: github`, lastOutput serait celle d'une commande antérieure.
|
|
389
|
+
if (ci)
|
|
390
|
+
return fail(ci, 'ci', typeof config.ci === 'object' ? deps.lastOutput?.() : []);
|
|
391
|
+
}
|
|
375
392
|
for (const [i, cmd] of config.deploy.entries()) {
|
|
376
|
-
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
393
|
+
out(stamped(deps, `→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`));
|
|
394
|
+
const deployStart = deps.now();
|
|
377
395
|
const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
396
|
+
step(`deploiement ${i + 1}/${config.deploy.length}`, deployStart, code === 0);
|
|
378
397
|
if (code !== 0)
|
|
379
398
|
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`, 'deploiement', deps.lastOutput?.());
|
|
380
399
|
}
|
|
381
400
|
}
|
|
382
|
-
const failed = await verifyAll(ctx, deps, sha, env);
|
|
401
|
+
const failed = await verifyAll(ctx, deps, sha, env, step);
|
|
383
402
|
if (failed)
|
|
384
403
|
return fail(failed.message, 'verification', failed.tail);
|
|
385
404
|
// Ce qui est journalisé : le sha que le script déclare (CADENCE_DEPLOYED_FILE) l'emporte. Sans déclaration, hors vague, un script qui a commité
|
|
@@ -406,12 +425,13 @@ export async function deliver(ctx, deps) {
|
|
|
406
425
|
const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
|
|
407
426
|
const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
|
|
408
427
|
appendDelivery(ctx.state, ctx.today, final);
|
|
428
|
+
const took = `en ${Math.round((deps.now() - startedAt) / 1000)} s`;
|
|
409
429
|
if (how === 'declared')
|
|
410
|
-
out(`✓ livré : ${final.slice(0, 7)} (sha déclaré par le script)`);
|
|
430
|
+
out(stamped(deps, `✓ livré : ${final.slice(0, 7)} (sha déclaré par le script) ${took}`));
|
|
411
431
|
else if (how === 'moved')
|
|
412
|
-
out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
|
|
432
|
+
out(stamped(deps, `✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT}) ${took}`));
|
|
413
433
|
else
|
|
414
|
-
out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
|
|
434
|
+
out(stamped(deps, `✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT} ${took}`));
|
|
415
435
|
if (delivered)
|
|
416
436
|
out(delivered);
|
|
417
437
|
refreshArticles(ctx, ids, final);
|
|
@@ -427,51 +447,11 @@ async function waitCi(ctx, deps, sha, env) {
|
|
|
427
447
|
if (ci === 'none')
|
|
428
448
|
return null;
|
|
429
449
|
if (typeof ci === 'object') {
|
|
430
|
-
ctx.out(`→ CI : ${ci.command}`);
|
|
450
|
+
ctx.out(stamped(deps, `→ CI : ${ci.command}`));
|
|
431
451
|
const code = await deps.exec(ci.command, env, ciTimeout * 1000);
|
|
432
452
|
return code === 0 ? null : `CI en échec (${codeText(code)}) : ${ci.command}`;
|
|
433
453
|
}
|
|
434
|
-
ctx
|
|
435
|
-
const start = deps.now();
|
|
436
|
-
let last = '';
|
|
437
|
-
let failingSince = null;
|
|
438
|
-
for (;;) {
|
|
439
|
-
let runs = [];
|
|
440
|
-
let error = null;
|
|
441
|
-
try {
|
|
442
|
-
runs = deps.gh(sha);
|
|
443
|
-
failingSince = null;
|
|
444
|
-
}
|
|
445
|
-
catch (e) {
|
|
446
|
-
// Erreur passagère (réseau, quota d'API) : on réessaie ; persistante, on abandonne avec sa cause.
|
|
447
|
-
error = e.message;
|
|
448
|
-
failingSince ??= deps.now();
|
|
449
|
-
}
|
|
450
|
-
const elapsed = deps.now() - start;
|
|
451
|
-
if (error !== null) {
|
|
452
|
-
if (deps.now() - failingSince >= CI_APPEAR || elapsed >= ciTimeout * 1000)
|
|
453
|
-
return `gh run list en échec : ${error}`;
|
|
454
|
-
ctx.err(` gh run list en échec, nouvel essai : ${error}`);
|
|
455
|
-
}
|
|
456
|
-
else if (runs.length === 0) {
|
|
457
|
-
if (elapsed >= CI_APPEAR)
|
|
458
|
-
return `aucun run CI pour ce sha après ${CI_APPEAR / 60_000} min (le commit poussé est-il celui que la CI construit ?)`;
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
const pending = runs.filter((r) => r.status !== 'completed');
|
|
462
|
-
const summary = `${runs.length} run(s), ${runs.length - pending.length} terminé(s)`;
|
|
463
|
-
if (summary !== last)
|
|
464
|
-
ctx.out(` ${summary}`);
|
|
465
|
-
last = summary;
|
|
466
|
-
if (pending.length === 0) {
|
|
467
|
-
const bad = runs.filter((r) => !CI_OK.has(r.conclusion));
|
|
468
|
-
return bad.length ? `CI en échec : ${bad.map((r) => `${r.name} (${r.conclusion})`).join(', ')}` : null;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
if (elapsed >= ciTimeout * 1000)
|
|
472
|
-
return `CI non terminée après ${ciTimeout} s`;
|
|
473
|
-
await deps.sleep(POLL_CI);
|
|
474
|
-
}
|
|
454
|
+
return pollGithubCi(ctx, deps, sha);
|
|
475
455
|
}
|
|
476
456
|
/** Un essai d'une vérification : cause de l'échec, ou null. Partagé par deliver et `cadence verify`. */
|
|
477
457
|
async function tryCheck(c, deps, sha, env, budgetMs) {
|
|
@@ -510,12 +490,14 @@ export async function retryCheck(c, deps, sha, env, until, attemptMs) {
|
|
|
510
490
|
}
|
|
511
491
|
}
|
|
512
492
|
/** Chaque vérification est réessayée jusqu'au délai commun ; message d'échec ou null. */
|
|
513
|
-
async function verifyAll(ctx, deps, sha, env) {
|
|
493
|
+
async function verifyAll(ctx, deps, sha, env, step) {
|
|
514
494
|
const deadline = deps.now() + ctx.config.verifyTimeout * 1000;
|
|
515
495
|
for (const [i, c] of ctx.config.verify.entries()) {
|
|
516
496
|
const label = describeCheck(c, sha);
|
|
517
|
-
ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
|
|
497
|
+
ctx.out(stamped(deps, `→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`));
|
|
498
|
+
const checkStart = deps.now();
|
|
518
499
|
const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
|
|
500
|
+
step(`verification ${i + 1}/${ctx.config.verify.length}`, checkStart, reason === null);
|
|
519
501
|
if (reason !== null)
|
|
520
502
|
return { message: `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`, tail: c.command !== undefined ? (deps.lastOutput?.() ?? []) : [] };
|
|
521
503
|
}
|
|
@@ -557,8 +539,8 @@ function deliveredLots(ctx, ids, prev, sha) {
|
|
|
557
539
|
}
|
|
558
540
|
/**
|
|
559
541
|
* Livraison verte (L144) : les lots livrés dont le titre est public changent ce que les articles externes racontent, on ouvre
|
|
560
|
-
* donc le lot qui les rafraîchit.
|
|
561
|
-
* le gabarit `plan.commit
|
|
542
|
+
* donc le lot qui les rafraîchit. Le plan est commité (L388 pendant une vague, L420 aussi hors vague : le livreur lancé à la main le laissait modifié) avec les fichiers que produit `plan.onWrite`, avec
|
|
543
|
+
* le gabarit `plan.commit` (sans la vague hors vague), pour ne pas laisser le plan modifié dans l'arbre — sauf si le plan ou ces fichiers l'étaient
|
|
562
544
|
* déjà avant l'écriture (le lead y a des modifications que ce commit emporterait). Une erreur ici ne défait pas une livraison déjà faite.
|
|
563
545
|
*/
|
|
564
546
|
function refreshArticles(ctx, ids, sha) {
|
|
@@ -586,10 +568,10 @@ function refreshArticles(ctx, ids, sha) {
|
|
|
586
568
|
plan.save();
|
|
587
569
|
const touched = [...r.opened, ...r.noted];
|
|
588
570
|
let state = 'plan modifié, à commiter';
|
|
589
|
-
if (touched.length &&
|
|
571
|
+
if (touched.length && wasDirty) {
|
|
590
572
|
state = 'plan déjà modifié avant la livraison, non commité : à commiter avec vos modifications';
|
|
591
573
|
}
|
|
592
|
-
else if (touched.length
|
|
574
|
+
else if (touched.length) {
|
|
593
575
|
const message = planCommitMessage(ctx.planCommit, { lot: touched.join(', '), action: r.opened.length ? PLAN_ACTIONS.articleOpened : PLAN_ACTIONS.articleNoted, wave: ctx.waveId });
|
|
594
576
|
try {
|
|
595
577
|
state = `plan commité ${commitPaths(ctx.root, [planFile, ...dirtyPaths(ctx.root, plan.produced)], message)}`;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
/** Un chemin de test relatif au dépôt, sans « .. » (`gate.readsPlan`). */
|
|
3
|
+
const relativeTest = (x) => typeof x === 'string' && !!x.trim() && !isAbsolute(x) && !x.split(/[\\/]/).includes('..');
|
|
4
|
+
/** `gate.readsPlan` : true, une liste de chemins de tests, false ou absent (rien d'actif). */
|
|
5
|
+
function readsPlanOf(r, bad, others) {
|
|
6
|
+
if (r === true)
|
|
7
|
+
return true;
|
|
8
|
+
if (Array.isArray(r) && r.length && r.every(relativeTest))
|
|
9
|
+
return r.map((x) => x.trim());
|
|
10
|
+
// Sans `host` ni `install`, `gate: {}` ne déclare rien : refusé comme avant.
|
|
11
|
+
if (r === false || (r == null && others))
|
|
12
|
+
return undefined;
|
|
13
|
+
throw bad('gate.readsPlan : true, false ou liste de chemins de tests relatifs au dépôt, sans « .. »');
|
|
14
|
+
}
|
|
15
|
+
/** `gate.host` : une destination ssh, sans espace ; une qui commencerait par « - » serait lue par ssh comme une option (-oProxyCommand=…). */
|
|
16
|
+
function hostOf(v, bad) {
|
|
17
|
+
const h = typeof v === 'string' ? v.trim() : '';
|
|
18
|
+
if (!h || /\s/.test(h) || h.startsWith('-'))
|
|
19
|
+
throw bad('gate.host : destination ssh attendue (une chaîne sans espace, ne commençant pas par « - »)');
|
|
20
|
+
return h;
|
|
21
|
+
}
|
|
22
|
+
/** `gate.install` : une commande non vide. */
|
|
23
|
+
function installOf(v, bad) {
|
|
24
|
+
if (typeof v !== 'string' || !v.trim())
|
|
25
|
+
throw bad('gate.install : commande non vide attendue');
|
|
26
|
+
return v.trim();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Clé `orchestrate.gate` de cadence.yaml : `readsPlan` (L450), `host` et `install` (L403). Rend undefined quand elle ne déclare rien d'actif (`readsPlan: false` seul) ;
|
|
30
|
+
* `host` ou `install` seuls posent la clé sans `readsPlan` : déclarer l'hôte ne vaut pas déclarer que des tests lisent le plan (R11).
|
|
31
|
+
* `bad` fabrique l'erreur de config préfixée (`cadence.yaml : orchestrate.<what>`).
|
|
32
|
+
*/
|
|
33
|
+
export function readGate(raw, bad) {
|
|
34
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
35
|
+
throw bad('gate : objet attendu (gate: { readsPlan: true })');
|
|
36
|
+
const o = raw;
|
|
37
|
+
for (const k of Object.keys(o))
|
|
38
|
+
if (!['readsPlan', 'host', 'install'].includes(k))
|
|
39
|
+
throw bad(`gate.${k} inconnu (attendu : readsPlan, host, install)`);
|
|
40
|
+
const readsPlan = readsPlanOf(o.readsPlan, bad, o.host != null || o.install != null);
|
|
41
|
+
const gate = {
|
|
42
|
+
...(readsPlan ? { readsPlan } : {}),
|
|
43
|
+
...(o.host != null ? { host: hostOf(o.host, bad) } : {}),
|
|
44
|
+
...(o.install != null ? { install: installOf(o.install, bad) } : {}),
|
|
45
|
+
};
|
|
46
|
+
return Object.keys(gate).length ? gate : undefined;
|
|
47
|
+
}
|
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { isAbsolute, join, posix } from 'node:path';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
5
|
import { citedRefs } from '../link.js';
|
|
6
|
+
import { isParts } from './gate-parts.js';
|
|
6
7
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
7
8
|
function git(repo, ...args) {
|
|
8
9
|
return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -461,6 +462,8 @@ export function planAcceptRuns(commands, fichiers, single = false) {
|
|
|
461
462
|
return { runs: all.filter((r) => fichiers.some((f) => underRoot(f, r.root))), orphans: fichiers.filter((f) => !all.some((r) => underRoot(f, r.root))) };
|
|
462
463
|
}
|
|
463
464
|
/** Consigne propre à l'étape accept dans le brief : fiche, maquette, commande dédiée, gabarit du lanceur, et les écarts de la passe précédente. */
|
|
465
|
+
/** La commande ordinaire du projet dans le prompt de la session accept : une chaîne telle quelle, une porte en parties (L512) partie par partie avec son dossier. */
|
|
466
|
+
const ordinaryText = (t) => (!t ? '' : isParts(t) ? ` The project's ordinary test commands, one per folder, are ${t.map((p) => `\`${p.run}\` (in \`${p.dir}/\`)`).join(', ')}: they must stay green with your red tests committed.` : ` The project's ordinary test command is \`${t}\`: it must stay green with your red tests committed.`);
|
|
464
467
|
export function acceptText(o) {
|
|
465
468
|
const commands = acceptCommands(o.command);
|
|
466
469
|
const screen = o.screen !== false;
|
|
@@ -469,8 +472,8 @@ export function acceptText(o) {
|
|
|
469
472
|
? `Sheet: \`${o.fiche}\` (cases, rules, contracts, data). You get nothing else about the behaviour: the implementation does not exist yet.`
|
|
470
473
|
: `Sheet: \`${o.fiche}\` (rules, fields, actions). Mockup: \`${o.maquette}\` (every field carries \`data-testid="<field id>"\`). You get nothing else about the screen: the implementation does not exist yet.${o.back ? ' The sheet also has a back part (`cas`, `contrats`, `donnees`): write the cases of its rules too, calling the contracts of `contrats[]` as close as possible and simulating the external sources from `entree`.' : ''}`,
|
|
471
474
|
isAcceptList(o.command)
|
|
472
|
-
? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${o.test
|
|
473
|
-
: `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test
|
|
475
|
+
? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${ordinaryText(o.test)}`
|
|
476
|
+
: `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${ordinaryText(o.test)}`,
|
|
474
477
|
`If \`${o.acceptation}\` already exists with \`vert: true\` and \`empreintes\`, the lot's oracle is green and sealed: never rewrite it, nor its tests, \`resceaux\` or \`empreintes\`, and never set \`vert\` (a corrected test is resealed by the lead, \`cadence spec reseal\`). Write nothing, commit nothing, and hand the lot back with that finding in \`questions\`. A \`vert: true\` file WITHOUT \`empreintes\` (or whose \`empreintes\` lack one of the listed test files) was never sealed by the program (only the cycle sets \`vert\`, after the seal): it proves nothing, so write the oracle as described below, with \`vert: false\`.`,
|
|
475
478
|
`Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same ${screen ? 'screen' : 'subject'} has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). If the tests need a build or a browser (Playwright, Chromium, \`dist\`), add \`"e2e": true\` to it: those files then stay out of the ordinary runner and the unit CI even once green (they would turn it red without \`dist\` or Chromium) and belong to the project's e2e suite, run after the build (\`acceptationE2e()\` of the runner template lists them). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
|
|
476
479
|
];
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
2
|
+
import { lotFinished, lotKey, lotRepoPaths } from './state.js';
|
|
3
|
+
import { cadenceHome, liveWaves } from './registry.js';
|
|
4
|
+
import { REPO_LOCK, takeLock } from './lock.js';
|
|
5
|
+
import { installPrePush } from './guard.js';
|
|
6
|
+
import { sharedStateDir } from '../state.js';
|
|
7
|
+
import { waveReadsAdds } from './snapshot.js';
|
|
8
|
+
const ALONE = ['lots', 'resume', 'status', 'drop', 'stopAfterCurrent', 'budget', 'lotBudgets', 'dryRun', 'continue', 'answers', 'integrate', 'removeWorktree', 'waits', 'goal', 'maxSessions'];
|
|
9
|
+
/** `--add` ne se combine avec rien d'autre que `--wave` (L421) : vrai quand une autre action est donnée. */
|
|
10
|
+
export const addNotAlone = (args) => ALONE.some((k) => (Array.isArray(args[k]) ? args[k].length > 0 : !!args[k] || args[k] === 0));
|
|
11
|
+
/**
|
|
12
|
+
* L'identifiant d'un projet dans la vague, le même quelle que soit la façon de le taper : le chemin donné résolu depuis le dossier de lancement (`./b`, `b/../b` → `b`),
|
|
13
|
+
* et le nom du dossier de lancement quand le projet est ce dossier (`.`, ou rien). Sans cela une vague lancée dans un dépôt avec `L1` rangeait son lot sous `a:L1` et
|
|
14
|
+
* `--add .:L2` cherchait ses lots et ses doublons sous `.` : dépendance non reconnue, doublon admis.
|
|
15
|
+
*/
|
|
16
|
+
export function projectName(cwd, project) {
|
|
17
|
+
if (!project)
|
|
18
|
+
return basename(cwd);
|
|
19
|
+
return relative(cwd, resolve(cwd, project)) || basename(cwd);
|
|
20
|
+
}
|
|
21
|
+
/** La clé d'un lot tel que la vague le range : le projet résolu (`projectName`) et le lot. */
|
|
22
|
+
export const addKey = (launch, t) => lotKey(projectName(launch, t.project), t.lot);
|
|
23
|
+
/** La commande qui ajoute le lot à la vague vivante qui tient le dépôt, quand cette vague a été lancée depuis ce dossier (`--add` ne voit que celles-là) ; sinon rien. */
|
|
24
|
+
export function addHint(t, wave, launch) {
|
|
25
|
+
if (!liveWaves(cadenceHome()).some((w) => w.wave === wave && w.cwd === launch))
|
|
26
|
+
return '';
|
|
27
|
+
return ` ; pour y ajouter ce lot : cadence orchestrate --add ${relative(launch, t.projectDir) || '.'}:${t.lot} --wave ${wave}`;
|
|
28
|
+
}
|
|
29
|
+
/** Refus d'un ajout (code 2) ou rien : les lots déjà dans la vague ou en file ne sont pas rejoués, le budget restant doit tenir celui de chaque lot ajouté. */
|
|
30
|
+
function budgetRefusals(lots, wave) {
|
|
31
|
+
let remaining = wave ? wave.budget - wave.consumed : Infinity;
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const l of lots) {
|
|
34
|
+
if (l.budget > remaining)
|
|
35
|
+
out.push(`${lotKey(l.project, l.lot)} : le budget restant de la vague (${Math.max(0, Math.round(remaining))} tokens) ne tient pas le budget du lot (${l.budget})`);
|
|
36
|
+
remaining -= l.budget;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `--add` sur une vague VIVANTE (L421) : chaque lot passe le contrôle préalable d'un lot donné au lancement (dépôt tenu par CETTE vague admis, dépendances comptées dans la vague), puis est déposé
|
|
42
|
+
* dans la file d'ajouts que la vague prend entre deux étapes. Tout est refusé avant d'agir (code 2) dès qu'un lot l'est, comme `--answer`.
|
|
43
|
+
*/
|
|
44
|
+
export async function addToLive(args, run, store, checks) {
|
|
45
|
+
const { io, launch } = run;
|
|
46
|
+
const refusals = [];
|
|
47
|
+
const targets = checks.resolveTargets({ lots: args.add }, io, refusals);
|
|
48
|
+
if (!waveReadsAdds(store.dir))
|
|
49
|
+
refusals.push(`la vague ${store.id} ne lit pas la file d'ajouts (lancée avec une version de cadence d'avant L421) : attendre sa fin, puis lancer le lot dans une vague`);
|
|
50
|
+
const queued = store.addQueue().map((e) => ({ project: projectName(launch, e.project), lot: e.lot, queued: true }));
|
|
51
|
+
const pre = refusals.length ? { refusals: [], lots: [] } : await checks.preflight(targets, run, { wave: store.id, lots: [...store.lots(), ...queued] });
|
|
52
|
+
refusals.push(...pre.refusals, ...budgetRefusals(pre.lots, store.readWave()));
|
|
53
|
+
if (refusals.length) {
|
|
54
|
+
for (const r of refusals)
|
|
55
|
+
io.err(`orchestrate : ${r}`);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
for (const a of args.add) {
|
|
59
|
+
const key = addKey(launch, a);
|
|
60
|
+
const id = store.requestAdd({ project: a.project, lot: a.lot, model: a.model });
|
|
61
|
+
// La vague a pu finir entre le contrôle et le dépôt : sa file ne sera plus lue, la demande est retirée.
|
|
62
|
+
if (!liveWaves(cadenceHome()).some((w) => w.wave === store.id)) {
|
|
63
|
+
store.markAddTaken(id);
|
|
64
|
+
io.err(`orchestrate : ${key} : la vague ${store.id} vient de finir, lot non ajouté : le lancer dans une nouvelle vague`);
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
store.journal(`demande : ajouter ${key} à la vague`);
|
|
68
|
+
io.out(`${key} : ajout déposé — la vague ${store.id} le prend entre deux étapes et le joue dès qu'un créneau est libre`);
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Les lots déposés par `--add` (L421), pris par la vague entre deux étapes comme les réponses du lead. Un lot dont le dépôt est occupé par un lot qui joue attend dans `waiting`
|
|
74
|
+
* (son contexte n'est pas reconstruit à chaque tour, mais ce qui l'écarterait est relu au moment de le prendre) ; un lot qui ne tient plus à cet instant est écarté et dit au journal.
|
|
75
|
+
*/
|
|
76
|
+
export class AddFeed {
|
|
77
|
+
e;
|
|
78
|
+
waiting = new Map();
|
|
79
|
+
constructor(e) {
|
|
80
|
+
this.e = e;
|
|
81
|
+
}
|
|
82
|
+
discard(entry, why) {
|
|
83
|
+
this.e.wctx.log(`${addKey(this.e.launch, entry)} : ajout écarté (${why})`);
|
|
84
|
+
this.e.store.markAddTaken(entry.id);
|
|
85
|
+
this.waiting.delete(entry.id);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/** Le contexte du lot demandé, contrôlé comme au lancement ; null quand il est écarté (dit au journal). */
|
|
89
|
+
async prepare(entry) {
|
|
90
|
+
const e = this.e;
|
|
91
|
+
const key = addKey(e.launch, entry);
|
|
92
|
+
const stop = e.stopCause();
|
|
93
|
+
if (stop)
|
|
94
|
+
return this.discard(entry, stop);
|
|
95
|
+
const refusals = [];
|
|
96
|
+
const targets = e.checks.resolveTargets({ lots: [{ project: entry.project, lot: entry.lot, model: entry.model }] }, e.io, refusals);
|
|
97
|
+
const known = [...e.ctxs, ...this.waiting.values()].map((c) => ({ project: c.lot.project, lot: c.lot.lot }));
|
|
98
|
+
const built = refusals.length ? { refusals } : await e.build(targets[0], known);
|
|
99
|
+
if ('refusals' in built)
|
|
100
|
+
return this.discard(entry, built.refusals.map((r) => (r.startsWith(`${key} : `) ? r.slice(key.length + 3) : r)).join(' ; '));
|
|
101
|
+
if (built.ctx.lot.budget > e.budgetLeft())
|
|
102
|
+
return this.discard(entry, `le budget restant de la vague ne tient pas le budget du lot (${built.ctx.lot.budget})`);
|
|
103
|
+
return built.ctx;
|
|
104
|
+
}
|
|
105
|
+
/** Un ajout resté en attente est recontrôlé quand la vague le prend : arrêt demandé, budget épuisé ou lot fini entre-temps l'écartent comme à la lecture de la file. */
|
|
106
|
+
recheck(entry, c) {
|
|
107
|
+
const e = this.e;
|
|
108
|
+
const why = e.stopCause() ?? (c.lot.budget > e.budgetLeft() ? `le budget restant de la vague ne tient pas le budget du lot (${c.lot.budget})` : e.planState(c));
|
|
109
|
+
return why ? this.discard(entry, why) : c;
|
|
110
|
+
}
|
|
111
|
+
/** Prend le verrou et la garde de push d'un dépôt que la vague ne tient pas (ou plus) ; rend la cause du refus. */
|
|
112
|
+
hold(repo, fresh) {
|
|
113
|
+
const e = this.e;
|
|
114
|
+
const file = join(sharedStateDir(repo), REPO_LOCK);
|
|
115
|
+
if (e.held.includes(file))
|
|
116
|
+
return null;
|
|
117
|
+
const l = takeLock(file, { pid: process.pid, wave: e.wave.id, started: new Date().toISOString() });
|
|
118
|
+
if (!l.ok && l.held.pid !== process.pid)
|
|
119
|
+
return `${basename(repo)} : une orchestration y est déjà en cours (${l.held.wave}, pid ${l.held.pid})`;
|
|
120
|
+
e.held.push(file);
|
|
121
|
+
fresh.push(repo);
|
|
122
|
+
if (l.ok && l.stale)
|
|
123
|
+
e.wctx.log(`orchestrate : verrou de dépôt périmé retiré (${basename(repo)}, pid ${l.stale.pid} mort)`);
|
|
124
|
+
const hook = installPrePush(repo, e.wave.id);
|
|
125
|
+
if (!hook.ok)
|
|
126
|
+
return `${basename(repo)} : ${hook.reason}`;
|
|
127
|
+
e.freed.delete(repo);
|
|
128
|
+
if (!e.repos.includes(repo))
|
|
129
|
+
e.repos.push(repo);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
/** Les verrous de tous les dépôts du lot ; en cas de refus, ceux pris pour lui sont rendus. */
|
|
133
|
+
holdAll(c) {
|
|
134
|
+
const fresh = [];
|
|
135
|
+
const refused = lotRepoPaths(c.lot).map((r) => this.hold(r, fresh)).find((x) => x) ?? null;
|
|
136
|
+
if (refused)
|
|
137
|
+
for (const r of fresh)
|
|
138
|
+
this.e.freeRepo(r);
|
|
139
|
+
return refused;
|
|
140
|
+
}
|
|
141
|
+
admit(entry, c) {
|
|
142
|
+
const e = this.e;
|
|
143
|
+
e.ctxs.push(c);
|
|
144
|
+
e.all.push(c.lot);
|
|
145
|
+
if (e.lots !== e.all)
|
|
146
|
+
e.lots.push(c.lot);
|
|
147
|
+
e.wave.lots.push(lotKey(c.lot.project, c.lot.lot));
|
|
148
|
+
e.store.writeLot(c.lot);
|
|
149
|
+
e.saveWave();
|
|
150
|
+
e.store.markAddTaken(entry.id);
|
|
151
|
+
this.waiting.delete(entry.id);
|
|
152
|
+
e.wctx.log(`${lotKey(c.lot.project, c.lot.lot)} : ajouté à la vague ${e.wave.id}`);
|
|
153
|
+
}
|
|
154
|
+
/** Les lots ajoutés à ce tour, prêts à être joués ; `canRun` dit si le dépôt du lot est libre (jamais deux sessions dans un même dépôt hors worktree). */
|
|
155
|
+
async take(canRun) {
|
|
156
|
+
const added = [];
|
|
157
|
+
for (const entry of this.e.store.addQueue()) {
|
|
158
|
+
const kept = this.waiting.get(entry.id);
|
|
159
|
+
const c = kept ? this.recheck(entry, kept) : await this.prepare(entry);
|
|
160
|
+
if (!c)
|
|
161
|
+
continue;
|
|
162
|
+
if (!canRun(c)) {
|
|
163
|
+
this.waiting.set(entry.id, c);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const refused = this.holdAll(c);
|
|
167
|
+
if (refused)
|
|
168
|
+
this.discard(entry, refused);
|
|
169
|
+
else {
|
|
170
|
+
this.admit(entry, c);
|
|
171
|
+
added.push(c);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (added.length)
|
|
175
|
+
this.e.announce();
|
|
176
|
+
return added;
|
|
177
|
+
}
|
|
178
|
+
/** Les demandes encore en file quand plus rien ne joue ne seront plus lues : écartées et dites. */
|
|
179
|
+
drain(why) {
|
|
180
|
+
this.waiting.clear();
|
|
181
|
+
for (const entry of this.e.store.addQueue())
|
|
182
|
+
this.discard(entry, why);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Un lot est-il encore à jouer dans ce dépôt (utilisé pour décider d'un worktree) ? */
|
|
186
|
+
export const openInRepo = (ctxs, c) => ctxs.filter((o) => !lotFinished(o.lot) && lotRepoPaths(o.lot).some((r) => lotRepoPaths(c.lot).includes(r)));
|
|
@@ -61,6 +61,63 @@ export function unloadedSuites(output) {
|
|
|
61
61
|
}
|
|
62
62
|
return [...found].sort();
|
|
63
63
|
}
|
|
64
|
+
/** Le délai d'un test expiré : vitest « Test timed out in 30000ms », jest « Exceeded timeout of 5000 ms for a test ». */
|
|
65
|
+
const SUMMARY = /^\s*(?:Test Files?|Tests?)\s+\d/;
|
|
66
|
+
const TIMEOUT = /(?:timed out in|Exceeded timeout of)\s+(\d+)\s*ms/i;
|
|
67
|
+
/**
|
|
68
|
+
* Le délai (ms) de chaque test qui a expiré dans la sortie d'un lanceur (L510) : le message d'expiration est lu sous le dernier test nommé rouge qui le précède, avec le nom que donne `failedTests`.
|
|
69
|
+
* Un message sans test nommé avant lui ne désigne rien et n'est pas retenu.
|
|
70
|
+
*/
|
|
71
|
+
export function timeoutLimits(output) {
|
|
72
|
+
const limits = new Map();
|
|
73
|
+
let current = null;
|
|
74
|
+
for (const raw of stripAnsi(output).split('\n')) {
|
|
75
|
+
if (FILE_LOAD_FAIL.test(raw))
|
|
76
|
+
continue;
|
|
77
|
+
current = namedFailure(raw, current);
|
|
78
|
+
const t = TIMEOUT.exec(raw);
|
|
79
|
+
if (t && current)
|
|
80
|
+
limits.set(current, Number(t[1]));
|
|
81
|
+
}
|
|
82
|
+
return limits;
|
|
83
|
+
}
|
|
84
|
+
/** Le test rouge qu'une ligne nomme, null quand elle ouvre un résumé de fin ou un nom vide, `current` quand elle n'en nomme aucun. */
|
|
85
|
+
function namedFailure(raw, current) {
|
|
86
|
+
if (SUMMARY.test(raw))
|
|
87
|
+
return null;
|
|
88
|
+
for (const re of MARKERS) {
|
|
89
|
+
const m = re.exec(raw);
|
|
90
|
+
if (!m)
|
|
91
|
+
continue;
|
|
92
|
+
const name = clean(m[1]);
|
|
93
|
+
return name && !/^(?:Test Files?|Tests?)\b/.test(name) ? name : null;
|
|
94
|
+
}
|
|
95
|
+
return current;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* La durée (ms) que la sortie d'un rejeu donne au test `name` du fichier `file` : la ligne du test (son dernier segment « suite > test » ou « Suite › test ») qui finit par une durée, sinon la ligne ✓ du fichier (« ✓ a.test.ts (3 tests) 24s »,
|
|
99
|
+
* une borne haute pour le test). Null quand aucune durée ne se lit.
|
|
100
|
+
*/
|
|
101
|
+
export function testDurationMs(output, name, file) {
|
|
102
|
+
const leaf = name.split(/ > | › /).pop().trim();
|
|
103
|
+
const lines = stripAnsi(output).split('\n');
|
|
104
|
+
const byTest = leaf ? lines.find((l) => lineDurationMs(l) !== null && endsWithName(l, leaf)) : undefined;
|
|
105
|
+
if (byTest)
|
|
106
|
+
return lineDurationMs(byTest);
|
|
107
|
+
const byFile = lines.find((l) => /^\s*[✓√]/.test(l) && l.includes(file) && lineDurationMs(l) !== null);
|
|
108
|
+
return byFile ? lineDurationMs(byFile) : null;
|
|
109
|
+
}
|
|
110
|
+
const DURATION_END = /\s*\(?(\d+(?:\.\d+)?)\s*(ms|s)\)?\s*$/;
|
|
111
|
+
/** La durée (ms) qui finit une ligne de lanceur, null sans durée. */
|
|
112
|
+
function lineDurationMs(l) {
|
|
113
|
+
const m = DURATION_END.exec(l);
|
|
114
|
+
return m ? Math.round(Number(m[1]) * (m[2] === 's' ? 1000 : 1)) : null;
|
|
115
|
+
}
|
|
116
|
+
/** La ligne finit par `leaf` (puis sa durée), au début d'un mot : un nom qui n'est que le sous-texte d'un voisin (« plan » / « plan-data ») ne la désigne pas. */
|
|
117
|
+
function endsWithName(l, leaf) {
|
|
118
|
+
const bare = l.replace(DURATION_END, '').trimEnd();
|
|
119
|
+
return bare.endsWith(leaf) && (bare.length === leaf.length || /[\s>›]/.test(bare[bare.length - leaf.length - 1]));
|
|
120
|
+
}
|
|
64
121
|
/** Sortie sans les séquences de couleur du lanceur. */
|
|
65
122
|
export const stripAnsi = (s) => s.replace(ANSI, '');
|
|
66
123
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isParts } from './gate-parts.js';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { RafError } from '../plan.js';
|
|
@@ -133,7 +134,9 @@ export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
|
133
134
|
* alors que la porte joue `npx tsc --noEmit` (test et scripts compris) : L330, L353. Vide sans commande déclarée.
|
|
134
135
|
*/
|
|
135
136
|
export function gateText(gate) {
|
|
136
|
-
|
|
137
|
+
// Porte en parties (L512) : une ligne par partie, avec son dossier, jamais la liste sérialisée.
|
|
138
|
+
const tests = !gate.test ? [] : isParts(gate.test) ? gate.test.map((p) => `- tests (run in \`${p.dir}/\`): \`${p.run}\``) : [`- tests: \`${gate.test}\``];
|
|
139
|
+
const lines = [...tests, gate.build ? `- build: \`${gate.build}\`` : ''].filter(Boolean);
|
|
137
140
|
if (!lines.length)
|
|
138
141
|
return '';
|
|
139
142
|
return `The program's gate, played after every writing session, is exactly:\n${lines.join('\n')}\nRun these commands as written before reporting, not a narrower one (\`npm run build\` or \`npm test\` may cover less than the gate). A red there sends the lot back to a correction pass even when your own run was green.`;
|