@sylad/cadence 1.1.0 → 1.2.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 +114 -21
- package/agents/acceptance-writer.md +3 -1
- package/agents/code-reviewer.md +6 -0
- package/agents/spec-writer.md +3 -1
- package/bin/cadence.js +4 -1
- package/dist/cli.js +145 -15
- package/dist/config.js +16 -4
- package/dist/lead.js +72 -19
- package/dist/orchestrate/accept.js +90 -4
- package/dist/orchestrate/aval.js +25 -6
- package/dist/orchestrate/briefs.js +3 -0
- package/dist/orchestrate/command.js +59 -26
- package/dist/orchestrate/continue.js +1 -1
- package/dist/orchestrate/cycle.js +260 -53
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +7 -7
- package/dist/orchestrate/spec.js +71 -14
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/plan.js +58 -1
- package/dist/spec.js +304 -77
- package/package.json +4 -3
- package/skills/lead/SKILL.md +33 -8
- package/templates/acceptation-runner.mjs +13 -5
- package/templates/fiche-back.yaml +75 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/fiche.md +14 -0
- package/templates/orchestrate/spec-back.md +25 -0
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { basename, dirname, join, posix, relative, resolve } from 'node:path';
|
|
3
4
|
import { parseArgs } from 'node:util';
|
|
4
|
-
import { readDocsConfig, readHookConfig, readNewsConfig, readPlanConfig, readSessionConfig } from './config.js';
|
|
5
|
+
import { readDocsConfig, readHookConfig, readNewsConfig, readOrchestrateConfig, readPlanConfig, readSessionConfig } from './config.js';
|
|
5
6
|
import { repoSections, repoShas, resolveLotRepos } from './repos.js';
|
|
6
7
|
import { audit, exemptPlanOnly, hypothesesOf, ownFiles, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
|
|
7
8
|
import { short } from './check.js';
|
|
@@ -13,15 +14,16 @@ import { gitRoot, readCommits, matchesHead, resolveCommit, stage, stagedFiles, s
|
|
|
13
14
|
import { installHook, pendingCommitMessage, REFUSED } from './hook.js';
|
|
14
15
|
import { citedRefs, linkCommits } from './link.js';
|
|
15
16
|
import { buildNews, loadEntries, newEntry, newsData, newsIssues, publicTitleTooLong, stampEntries } from './news.js';
|
|
16
|
-
import { Plan, RafError, STATUSES } from './plan.js';
|
|
17
|
+
import { Plan, RafError, SCREENS, STATUSES } from './plan.js';
|
|
17
18
|
import { dueLine, isRecurring, recurringByDue } from './recurring.js';
|
|
18
19
|
import { schedule } from './schedule.js';
|
|
19
20
|
import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
|
|
20
|
-
import { findProjects, leadTour, tourLine } from './lead.js';
|
|
21
|
+
import { findProjects, leadTour, readLeadUx, tourLine } from './lead.js';
|
|
21
22
|
import { liveLines, orchestrate, realOrchestrateDeps } from './orchestrate/command.js';
|
|
22
23
|
import { activeLock, REPO_LOCK } from './orchestrate/lock.js';
|
|
23
24
|
import { cadenceHome, openLotsIn } from './orchestrate/registry.js';
|
|
24
25
|
import { classifyAll, measure, ollamaClassifier, readCache, readRuns, renderReport, sampleKeys, serializeCache } from './corrections.js';
|
|
26
|
+
import { findAcceptation, resealAcceptation } from './orchestrate/accept.js';
|
|
25
27
|
import { checkFiche, deriveQa, readFiche, summary } from './spec.js';
|
|
26
28
|
import { readHudContext } from './hud-context.js';
|
|
27
29
|
import { sessionClose, sessionStart } from './session.js';
|
|
@@ -29,8 +31,10 @@ import { clearNext, readNext, sharedStateDir, stateDir, writeNext } from './stat
|
|
|
29
31
|
const HELP = `raf — plan « reste à faire » versionné dans le dépôt, relié aux commits
|
|
30
32
|
|
|
31
33
|
raf init [--project nom] [--prefix L] [--no-hook]
|
|
32
|
-
raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
|
|
34
|
+
raf add "titre" [--estimate j] [--quickwin] [--visible [--screen page|component|journey]] [--spec] [--public "titre public"] [--every jours] [--after L2,L4] [--parent L3]
|
|
33
35
|
raf public <id> "titre public" | --clear titre du lot dans le langage du public (pages Nouveautés et Plan)
|
|
36
|
+
raf screen <id> page|component|journey|none pose, remplace ou retire screen: d'un lot visible existant (l'étape spec d'orchestrate)
|
|
37
|
+
raf spec <id> on|off pose ou retire spec: d'un lot existant (le lot change un comportement : étape spec back d'orchestrate)
|
|
34
38
|
raf start <id> raf done <id> [--force] raf drop <id> [--reason texte]
|
|
35
39
|
raf note <id> "texte"
|
|
36
40
|
raf did <id> ["texte"] un lot récurrent (--every) a été refait : le « dû depuis N j » repart de aujourd'hui
|
|
@@ -47,7 +51,7 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
47
51
|
raf gantt [-o docs/plan/gantt.html]
|
|
48
52
|
raf hook install (pre-commit et post-commit ; cadence.yaml hook.autostart: warn|refuse|start)
|
|
49
53
|
cadence orchestrate <projet>:<lot>[@modèle]… [--budget 2M] [--dry-run] [--wave nom] [--continue [--until HH:MM] [--priority a,b]] une session claude neuve par étape ; --status [vague] [--watch [--interval s]] ; --resume [vague] [--budget 1M] [--answer projet:lot "réponse"] [--drop projet:lot] ; --drop projet:lot [--wave vague] ; --stop-after-current [--wave vague]
|
|
50
|
-
cadence lead tour [dossier] [--idle 3] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
|
|
54
|
+
cadence lead tour [dossier] [--idle 3] [--depth n] [--json] le tableau du lead, sans modèle : une ligne par sous-dossier qui a un plan, ou la ligne du dossier seul s'il est lui-même un projet (en cours, dérive, notes de clôture, prochain lot prêt, dépôt)
|
|
51
55
|
cadence corrections [dossier] [--classify [--model m]] [--cache fichier] [--sample n] [--until date] [--json] mesure des passes de correction des journaux dossier/.cadence/runs : lots à ≥1 et ≥2 corrections, par projet, visibles ou non, passes par cause (classées par Ollama avec --classify, hors quota)
|
|
52
56
|
cadence verify [--retry s] [--sha rév] rejoue deliver.verify hors livraison : 0 vert, 1 effet rouge, 2 rien à vérifier
|
|
53
57
|
raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
|
|
@@ -110,6 +114,7 @@ function dispatch(argv, io) {
|
|
|
110
114
|
quickwin: { type: 'boolean' },
|
|
111
115
|
visible: { type: 'boolean' },
|
|
112
116
|
screen: { type: 'string' },
|
|
117
|
+
spec: { type: 'boolean' },
|
|
113
118
|
public: { type: 'string' },
|
|
114
119
|
every: { type: 'string' },
|
|
115
120
|
dir: { type: 'string' },
|
|
@@ -146,7 +151,7 @@ function dispatch(argv, io) {
|
|
|
146
151
|
io.out(HELP);
|
|
147
152
|
return 0;
|
|
148
153
|
}
|
|
149
|
-
if (command !== 'session' && (values.all || values.depth !== undefined))
|
|
154
|
+
if (command !== 'session' && !(command === 'lead' && !values.all) && (values.all || values.depth !== undefined))
|
|
150
155
|
throw new RafError(`${values.all ? '--all' : '--depth'} ne va qu'avec session start ou session close`);
|
|
151
156
|
const today = io.env.RAF_TODAY ?? toDay(io.now());
|
|
152
157
|
if (!isDay(today))
|
|
@@ -155,14 +160,17 @@ function dispatch(argv, io) {
|
|
|
155
160
|
// Le tour parcourt les sous-dossiers (ou, sans sous-projet, prend le dossier lui-même s'il est un projet) : il ne lit pas le plan du dossier courant.
|
|
156
161
|
if (command === 'lead') {
|
|
157
162
|
if (rest[0] !== 'tour' || rest.length > 2)
|
|
158
|
-
throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--json]');
|
|
163
|
+
throw new RafError('usage : cadence lead tour [dossier] [--idle 3] [--depth n] [--json]');
|
|
159
164
|
const idle = values.idle === undefined ? undefined : Number(values.idle);
|
|
160
165
|
if (idle !== undefined && !(Number.isInteger(idle) && idle >= 0))
|
|
161
166
|
throw new RafError(`--idle invalide : ${values.idle}`);
|
|
167
|
+
const depth = values.depth === undefined ? undefined : Number(values.depth);
|
|
168
|
+
if (depth !== undefined && !(Number.isInteger(depth) && depth >= 1))
|
|
169
|
+
throw new RafError(`--depth invalide : ${values.depth} (un entier ≥ 1)`);
|
|
162
170
|
const parent = resolve(io.cwd, rest[1] ?? '.');
|
|
163
171
|
if (!existsSync(parent))
|
|
164
172
|
throw new RafError(`lead tour : dossier introuvable : ${parent}`);
|
|
165
|
-
const rows = leadTour(parent, today, idle);
|
|
173
|
+
const rows = leadTour(parent, today, idle, depth);
|
|
166
174
|
if (values.json)
|
|
167
175
|
io.out(JSON.stringify(rows, null, 2));
|
|
168
176
|
else if (rows.length === 0)
|
|
@@ -174,13 +182,25 @@ function dispatch(argv, io) {
|
|
|
174
182
|
const [slots] = liveLines();
|
|
175
183
|
if (slots)
|
|
176
184
|
io.out(slots);
|
|
185
|
+
// le réglage lead.ux du cadence.yaml du dossier (L197) : le lead lit ici qui pose le verdict UX, au lieu d'ouvrir le fichier
|
|
186
|
+
try {
|
|
187
|
+
if (readLeadUx(parent) === 'lead')
|
|
188
|
+
io.out('verdict UX : lead (le lead pose raf ux d\'après le rapport du ux-reviewer et les captures ; l\'humain n\'est consulté qu\'en cas de doute)');
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
// un cadence.yaml illisible a déjà sa ligne d'erreur en tête de tour
|
|
192
|
+
if (/lead\.ux/.test(e.message))
|
|
193
|
+
io.out(`cadence.yaml · ✗ erreur : ${e.message.replace(/^.*cadence\.yaml : /, '')}`);
|
|
194
|
+
}
|
|
177
195
|
}
|
|
178
196
|
return 0;
|
|
179
197
|
}
|
|
180
198
|
if (command === 'corrections')
|
|
181
199
|
return corrections(rest, values, io);
|
|
182
|
-
|
|
200
|
+
// `spec` est aussi un verbe de raf (raf spec <lot> on|off, L186) : seuls check et qa vont à la fiche.
|
|
201
|
+
if (command === 'spec' && (rest[0] === 'check' || rest[0] === 'qa'))
|
|
183
202
|
return spec(rest, io);
|
|
203
|
+
const reseal = command === 'spec' && rest[0] === 'reseal';
|
|
184
204
|
// --all balaie les sous-dossiers : il ne lit ni le plan ni le cadence.yaml du dossier courant.
|
|
185
205
|
if (command === 'session' && (values.all || values.depth !== undefined))
|
|
186
206
|
return sessionAll(rest, values, io);
|
|
@@ -226,6 +246,8 @@ function dispatch(argv, io) {
|
|
|
226
246
|
if (values.parent) {
|
|
227
247
|
if (values.screen !== undefined)
|
|
228
248
|
throw new RafError('--screen se pose sur un lot, pas sur une sous-tâche');
|
|
249
|
+
if (values.spec)
|
|
250
|
+
throw new RafError('--spec se pose sur un lot, pas sur une sous-tâche');
|
|
229
251
|
if (values.public !== undefined)
|
|
230
252
|
throw new RafError('--public se pose sur un lot, pas sur une sous-tâche');
|
|
231
253
|
if (values.every !== undefined)
|
|
@@ -242,7 +264,7 @@ function dispatch(argv, io) {
|
|
|
242
264
|
throw new RafError(`périodicité invalide : ${values.every} (un nombre entier de jours)`);
|
|
243
265
|
if (values.public !== undefined)
|
|
244
266
|
refuseLongTitle(values.public);
|
|
245
|
-
id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, public: values.public, every, after });
|
|
267
|
+
id = plan.add(rest.join(' '), today, { estimate, quickwin: values.quickwin, visible: values.visible, screen: values.screen, spec: values.spec, public: values.public, every, after });
|
|
246
268
|
}
|
|
247
269
|
plan.save();
|
|
248
270
|
io.out(id);
|
|
@@ -303,6 +325,30 @@ function dispatch(argv, io) {
|
|
|
303
325
|
plan.save();
|
|
304
326
|
return 0;
|
|
305
327
|
}
|
|
328
|
+
case 'screen': {
|
|
329
|
+
need(2, 'screen <id> page|component|journey|none');
|
|
330
|
+
if (rest.length > 2)
|
|
331
|
+
throw new RafError('usage : raf screen <id> page|component|journey|none');
|
|
332
|
+
const plan = loadPlan();
|
|
333
|
+
if (rest[1] !== 'none' && !SCREENS.includes(rest[1]))
|
|
334
|
+
throw new RafError(`screen inconnu : ${rest[1]} (${SCREENS.join(', ')}, none)`);
|
|
335
|
+
plan.setScreen(rest[0], rest[1] === 'none' ? null : rest[1]);
|
|
336
|
+
plan.save();
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
case 'spec': {
|
|
340
|
+
if (reseal)
|
|
341
|
+
return specReseal(rest.slice(1), values.reason, { root, loadPlan, configPath, today }, io);
|
|
342
|
+
if (rest.length !== 2 || (rest[1] !== 'on' && rest[1] !== 'off'))
|
|
343
|
+
throw new RafError('usage : raf spec <id> on|off (ou cadence spec check|qa <fiche.yaml>)');
|
|
344
|
+
const plan = loadPlan();
|
|
345
|
+
if (!plan.setSpec(rest[0], rest[1] === 'on')) {
|
|
346
|
+
io.out(`${rest[0]} a déjà screen: — l'écran implique la spec, rien à changer`);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
plan.save();
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
306
352
|
case 'ux':
|
|
307
353
|
case 'review':
|
|
308
354
|
return gate(command, rest, loadPlan, root, today, io);
|
|
@@ -338,6 +384,8 @@ function dispatch(argv, io) {
|
|
|
338
384
|
io.out(`public : ${lot.public}`);
|
|
339
385
|
if (lot.screen)
|
|
340
386
|
io.out(`screen : ${lot.screen}`);
|
|
387
|
+
if (lot.spec)
|
|
388
|
+
io.out('spec : true');
|
|
341
389
|
if (notes.length) {
|
|
342
390
|
io.out('notes :');
|
|
343
391
|
// Les lignes suivantes d'une note multiligne s'alignent sous son texte, pas en colonne 0.
|
|
@@ -447,10 +495,8 @@ function dispatch(argv, io) {
|
|
|
447
495
|
case 'deliver': {
|
|
448
496
|
if (!gitRoot(io.cwd))
|
|
449
497
|
throw new RafError('deliver : à lancer dans un dépôt git');
|
|
450
|
-
const orchestrating =
|
|
451
|
-
|
|
452
|
-
const stillOpen = orchestrating ? openLotsIn(cadenceHome(), orchestrating.pid, root) : null;
|
|
453
|
-
if (orchestrating && (stillOpen === null || stillOpen.length > 0))
|
|
498
|
+
const orchestrating = waveHolding(root);
|
|
499
|
+
if (orchestrating)
|
|
454
500
|
throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
|
|
455
501
|
if (!existsSync(configPath))
|
|
456
502
|
throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
|
|
@@ -753,6 +799,90 @@ function morningEffects(configPath, root) {
|
|
|
753
799
|
return [`✗ ${e.message}`];
|
|
754
800
|
}
|
|
755
801
|
}
|
|
802
|
+
/** La vague qui tient le verrou du dépôt tant qu'un de ses lots y travaille encore (état illisible : on la tient pour active) ; null si le dépôt est libre. Partagé par `deliver` et `spec reseal`. */
|
|
803
|
+
function waveHolding(root) {
|
|
804
|
+
const orchestrating = activeLock(join(sharedStateDir(root), REPO_LOCK));
|
|
805
|
+
if (!orchestrating)
|
|
806
|
+
return null;
|
|
807
|
+
const stillOpen = openLotsIn(cadenceHome(), orchestrating.pid, root);
|
|
808
|
+
return stillOpen === null || stillOpen.length > 0 ? orchestrating : null;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* `cadence spec reseal <lot> <test> --reason texte` (L200) : le chemin du lead quand un test d'acceptation scellé du lot EN COURS contredit la fiche.
|
|
812
|
+
* Le test est corrigé dans l'arbre de travail (fiche inchangée) ; la commande refait l'empreinte de ce seul test, journalise la raison dans
|
|
813
|
+
* acceptation.json, commite test + acceptation (le nouveau commit rouge), et note la raison au lot. Le verrou garde tout le reste.
|
|
814
|
+
*/
|
|
815
|
+
const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
816
|
+
function specReseal(args, reason, ctx, io) {
|
|
817
|
+
if (args.length !== 2 || !reason?.trim())
|
|
818
|
+
throw new RafError('usage : cadence spec reseal <lot> <test> --reason "pourquoi le test contredit la fiche"');
|
|
819
|
+
if (io.env.CADENCE_ORCHESTRATED)
|
|
820
|
+
throw new RafError(`spec reseal refusé pendant une vague orchestrée (${io.env.CADENCE_ORCHESTRATED}) : c'est le travail du lead, hors session`);
|
|
821
|
+
// Comme `deliver` : la vague tient le verrou du dépôt tant qu'un de ses lots y travaille (état illisible : on refuse) ; un commit du lead en pleine passe ferait signaler au lot suivant un écart qui n'est pas le sien.
|
|
822
|
+
const orchestrating = waveHolding(ctx.root);
|
|
823
|
+
if (orchestrating)
|
|
824
|
+
throw new RafError(`spec reseal : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — rescellé une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
|
|
825
|
+
const [lotId, test] = args;
|
|
826
|
+
const plan = ctx.loadPlan();
|
|
827
|
+
const lot = plan.lot(lotId);
|
|
828
|
+
if (lot.status !== 'doing')
|
|
829
|
+
throw new RafError(`${lotId} n'est pas en cours (${lot.status}) : le rescellé ne vaut que pour le lot en cours ; un lot livré évolue avec sa fiche (README, « Exception — a delivered lot »)`);
|
|
830
|
+
const repo = gitRoot(ctx.root) ?? ctx.root;
|
|
831
|
+
const dir = readOrchestrateConfig(ctx.configPath).spec.dir;
|
|
832
|
+
const json = findAcceptation(repo, dir, lotId);
|
|
833
|
+
if (!json)
|
|
834
|
+
throw new RafError(`${lotId} : aucune acceptation sous ${dir} (le lot n'a pas passé l'étape accept)`);
|
|
835
|
+
const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
|
|
836
|
+
let r;
|
|
837
|
+
try {
|
|
838
|
+
r = resealAcceptation(repo, json, test, reason, ctx.today);
|
|
839
|
+
}
|
|
840
|
+
catch (e) {
|
|
841
|
+
throw new RafError(e.message);
|
|
842
|
+
}
|
|
843
|
+
const norm = posix.normalize(test);
|
|
844
|
+
try {
|
|
845
|
+
git(repo, ['add', '--', json, norm]);
|
|
846
|
+
git(repo, ['commit', '-q', '-m', `test(${lotId}): acceptation rescellée, ${norm} : ${reason.trim()}`, '--', json, norm]);
|
|
847
|
+
}
|
|
848
|
+
catch (e) {
|
|
849
|
+
// Atomique : un commit refusé (hook pre-commit, ...) ne laisse ni empreinte refaite ni index à moitié prêt ; le test retrouve le texte du lead.
|
|
850
|
+
writeFileSync(join(repo, json), before.json);
|
|
851
|
+
writeFileSync(join(repo, norm), before.test);
|
|
852
|
+
try {
|
|
853
|
+
git(repo, ['reset', '-q', '--', json, norm]);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
// rien d'indexé : rien à défaire
|
|
857
|
+
}
|
|
858
|
+
const out = e;
|
|
859
|
+
throw new RafError(`spec reseal : le commit du rescellé a échoué, ${json} et ${norm} sont revenus tels qu'avant la commande (rien n'est rescellé) — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
|
|
860
|
+
}
|
|
861
|
+
const sha = git(repo, ['rev-parse', '--short', 'HEAD']).trim();
|
|
862
|
+
// Un plan tenu par un autre outil ne se modifie pas d'ici : la raison est déjà dans acceptation.json et dans le commit, la note reste à la main du lead.
|
|
863
|
+
if (plan.readonly) {
|
|
864
|
+
io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha} ; raison dans ${json} et dans le commit — note à ajouter au lot avec l'outil du projet (plan en lecture seule)`);
|
|
865
|
+
return 0;
|
|
866
|
+
}
|
|
867
|
+
plan.note(lotId, `test d'acceptation rescellé (${norm}, fiche inchangée) : ${reason.trim()}`, ctx.today);
|
|
868
|
+
plan.save();
|
|
869
|
+
// La note du lot rejoint le dépôt dans la foulée (un dépôt sale arrêterait la vague suivante) ; un plan hors du dépôt ou non suivi reste à la main du lead.
|
|
870
|
+
const planRel = relative(repo, plan.path);
|
|
871
|
+
const tracked = !planRel.startsWith('..') && git(repo, ['ls-files', '--', planRel]).trim() !== '';
|
|
872
|
+
if (tracked) {
|
|
873
|
+
try {
|
|
874
|
+
git(repo, ['add', '--', planRel]);
|
|
875
|
+
git(repo, ['commit', '-q', '-m', `plan: ${lotId} test d'acceptation rescellé (${norm})`, '--', planRel]);
|
|
876
|
+
}
|
|
877
|
+
catch (e) {
|
|
878
|
+
// Le rescellé est fait (commit ${sha}) : on ne le défait pas, on dit où en est la note, au lieu d'une pile d'appels.
|
|
879
|
+
const out = e;
|
|
880
|
+
throw new RafError(`spec reseal : le rescellé a eu lieu (commit ${sha}) mais le commit de la note du lot a échoué — la note est écrite dans ${planRel}, à commiter à la main (un plan sale arrêterait la vague suivante) — ${(out.stderr || out.stdout || out.message).trim().split('\n').slice(-5).join(' | ')}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
io.out(`${lotId} : ${norm} rescellé (${r.avant.slice(0, 7)} → ${r.apres.slice(0, 7)}), commit ${sha} ; raison dans ${json} et en note du lot${tracked ? '' : ' (plan à commiter)'}`);
|
|
884
|
+
return 0;
|
|
885
|
+
}
|
|
756
886
|
/** `cadence spec check|qa <fiche>` (L170) : code 0 valide, 1 écarts, 2 usage ou fichier illisible. `qa` imprime la section expectations.md de l'écran. */
|
|
757
887
|
function spec(rest, io) {
|
|
758
888
|
const sub = rest[0];
|
package/dist/config.js
CHANGED
|
@@ -220,13 +220,13 @@ export function readSessionConfig(file) {
|
|
|
220
220
|
return config;
|
|
221
221
|
}
|
|
222
222
|
const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
223
|
-
export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'accept', 'implement', 'fix', 'review', 'ux'];
|
|
223
|
+
export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
|
|
224
224
|
const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
225
225
|
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
226
|
-
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
226
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
227
227
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
228
228
|
export function readOrchestrateConfig(file) {
|
|
229
|
-
const config = { precheck: true, maquette: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
229
|
+
const config = { precheck: true, maquette: true, fiche: true, precheckLocal: false, prodAccess: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
|
|
230
230
|
if (!existsSync(file))
|
|
231
231
|
return config;
|
|
232
232
|
let raw;
|
|
@@ -253,7 +253,14 @@ export function readOrchestrateConfig(file) {
|
|
|
253
253
|
for (const k of Object.keys(o))
|
|
254
254
|
if (!ORCH_KEYS.includes(k))
|
|
255
255
|
throw bad(`${k} inconnu (attendu : ${ORCH_KEYS.join(', ')})`);
|
|
256
|
+
if (o.accept != null && Array.isArray(o.accept)) {
|
|
257
|
+
if (!o.accept.length || o.accept.some((x) => typeof x !== 'string' || !x.trim()))
|
|
258
|
+
throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
|
|
259
|
+
config.accept = o.accept.map((x) => x.trim());
|
|
260
|
+
}
|
|
256
261
|
for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
|
|
262
|
+
if (k === 'accept' && Array.isArray(o.accept))
|
|
263
|
+
continue;
|
|
257
264
|
if (o[k] == null)
|
|
258
265
|
continue;
|
|
259
266
|
if (typeof o[k] !== 'string' || !o[k].trim())
|
|
@@ -265,6 +272,11 @@ export function readOrchestrateConfig(file) {
|
|
|
265
272
|
throw bad('maquette : true ou false attendu');
|
|
266
273
|
config.maquette = o.maquette;
|
|
267
274
|
}
|
|
275
|
+
if (o.fiche != null) {
|
|
276
|
+
if (typeof o.fiche !== 'boolean')
|
|
277
|
+
throw bad('fiche : true ou false attendu');
|
|
278
|
+
config.fiche = o.fiche;
|
|
279
|
+
}
|
|
268
280
|
if (o.precheck != null) {
|
|
269
281
|
if (typeof o.precheck !== 'boolean' && o.precheck !== 'local')
|
|
270
282
|
throw bad('precheck : true, false ou local attendu');
|
|
@@ -318,7 +330,7 @@ export function readOrchestrateConfig(file) {
|
|
|
318
330
|
}
|
|
319
331
|
if (o.effort != null) {
|
|
320
332
|
if (!isObject(o.effort))
|
|
321
|
-
throw bad('effort doit être un objet { precheck, spec, maquette, accept, implement, fix, review, ux }');
|
|
333
|
+
throw bad('effort doit être un objet { precheck, spec, maquette, fiche, accept, implement, fix, review, ux }');
|
|
322
334
|
for (const [k, v] of Object.entries(o.effort)) {
|
|
323
335
|
if (!EFFORT_STEPS.includes(k))
|
|
324
336
|
throw bad(`effort.${k} inconnu (attendu : ${EFFORT_STEPS.join(', ')})`);
|
package/dist/lead.js
CHANGED
|
@@ -1,13 +1,58 @@
|
|
|
1
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { basename, join } from 'node:path';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
3
3
|
import { audit, nextUp, planCommits } from './audit.js';
|
|
4
4
|
import { planLoader, readPlanConfig } from './config.js';
|
|
5
5
|
import { diffDays } from './dates.js';
|
|
6
6
|
import { gitRoot, repoStatus } from './git.js';
|
|
7
7
|
import { linkCommits } from './link.js';
|
|
8
8
|
import { lastActivity, lockStatus } from './session.js';
|
|
9
|
+
import { parse } from 'yaml';
|
|
10
|
+
import { RafError } from './plan.js';
|
|
9
11
|
import { priorityRank, readPriority } from './orchestrate/continue.js';
|
|
10
12
|
import { readNext, sharedStateDir, stateDir } from './state.js';
|
|
13
|
+
/** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
|
|
14
|
+
export function readLeadUx(parent) {
|
|
15
|
+
const file = join(parent, 'cadence.yaml');
|
|
16
|
+
if (!existsSync(file))
|
|
17
|
+
return 'human';
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
24
|
+
}
|
|
25
|
+
const l = raw?.lead;
|
|
26
|
+
if (l == null)
|
|
27
|
+
return 'human';
|
|
28
|
+
const ux = l.ux;
|
|
29
|
+
if (typeof l !== 'object' || Array.isArray(l))
|
|
30
|
+
throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
|
|
31
|
+
if (ux == null)
|
|
32
|
+
return 'human';
|
|
33
|
+
if (ux !== 'lead' && ux !== 'human')
|
|
34
|
+
throw new RafError(`${file} : lead.ux doit être « lead » ou « human »`);
|
|
35
|
+
return ux;
|
|
36
|
+
}
|
|
37
|
+
/** Clé `depth:` du cadence.yaml du dossier parent (L201, issue #8), à côté de `priority:` : combien de niveaux `lead tour` parcourt (1 : sous-dossiers directs, le défaut). */
|
|
38
|
+
export function readDepth(parent) {
|
|
39
|
+
const file = join(parent, 'cadence.yaml');
|
|
40
|
+
if (!existsSync(file))
|
|
41
|
+
return 1;
|
|
42
|
+
let raw;
|
|
43
|
+
try {
|
|
44
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
48
|
+
}
|
|
49
|
+
const d = raw?.depth;
|
|
50
|
+
if (d == null)
|
|
51
|
+
return 1;
|
|
52
|
+
if (typeof d !== 'number' || !Number.isInteger(d) || d < 1)
|
|
53
|
+
throw new RafError(`${file} : depth doit être un entier ≥ 1`);
|
|
54
|
+
return d;
|
|
55
|
+
}
|
|
11
56
|
export const NOTES_MAX = 120;
|
|
12
57
|
export const TITLE_MAX = 60;
|
|
13
58
|
/** Au-delà de ce nombre de jours sans activité, un lot en cours est dit silencieux. */
|
|
@@ -56,8 +101,8 @@ export function findProjects(parent, depth = 1) {
|
|
|
56
101
|
return found.sort();
|
|
57
102
|
}
|
|
58
103
|
/** Les faits de `cadence session start` pour un projet, sous forme de données ; lecture seule. */
|
|
59
|
-
export function tourRow(dir, today, idle = TOUR_IDLE) {
|
|
60
|
-
const row = empty(
|
|
104
|
+
export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
|
|
105
|
+
const row = empty(name);
|
|
61
106
|
try {
|
|
62
107
|
const root = gitRoot(dir) ?? dir;
|
|
63
108
|
const plan = planLoader(root)();
|
|
@@ -96,24 +141,32 @@ export function tourRow(dir, today, idle = TOUR_IDLE) {
|
|
|
96
141
|
}
|
|
97
142
|
/**
|
|
98
143
|
* Les projets rangés par la clé `priority:` du cadence.yaml du dossier (L149) ; les autres après, par ordre alphabétique.
|
|
99
|
-
* Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
|
|
144
|
+
* Une clé `depth:` invalide se signale de même. Une priorité illisible ou invalide ne vide pas le tableau : l'ordre alphabétique reste, et une première ligne `cadence.yaml` en erreur le dit.
|
|
100
145
|
* Un dossier qui n'a aucun sous-projet mais est lui-même un projet donne la ligne de ce projet seul (la bande l'interroge depuis le dossier de la session).
|
|
101
146
|
*/
|
|
102
|
-
export function leadTour(parent, today, idle = TOUR_IDLE) {
|
|
103
|
-
const
|
|
147
|
+
export function leadTour(parent, today, idle = TOUR_IDLE, depth) {
|
|
148
|
+
const problems = [];
|
|
149
|
+
const attempt = (read, fallback) => {
|
|
150
|
+
try {
|
|
151
|
+
return read();
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
const error = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
155
|
+
if (!problems.some((p) => p.error === error))
|
|
156
|
+
problems.push({ ...empty('cadence.yaml'), error }); // un yaml illisible l'est pour les deux clés : une seule ligne
|
|
157
|
+
return fallback;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
// --depth l'emporte sur la clé `depth:` ; une clé invalide se signale et laisse un seul niveau
|
|
161
|
+
const levels = depth ?? attempt(() => readDepth(parent), 1);
|
|
162
|
+
const dirs = findProjects(parent, levels);
|
|
104
163
|
if (dirs.length === 0 && isProject(parent))
|
|
105
|
-
return [tourRow(parent, today, idle)];
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
catch (e) {
|
|
113
|
-
problem = { ...empty('cadence.yaml'), error: (e instanceof Error ? e.message : String(e)).split('\n')[0] };
|
|
114
|
-
}
|
|
115
|
-
const sorted = rows.map((r, i) => ({ r, i })).sort((a, b) => priorityRank(a.r.project, priority) - priorityRank(b.r.project, priority) || a.i - b.i).map((x) => x.r);
|
|
116
|
-
return problem ? [problem, ...sorted] : sorted;
|
|
164
|
+
return [...problems, tourRow(parent, today, idle)];
|
|
165
|
+
// un projet imbriqué est nommé par son chemin depuis le dossier : unique, et adressable par `orchestrate <projet>:<lot>` lancé d'ici ; la priorité et l'ordre alphabétique lisent le nom du dossier, comme orchestrate --continue
|
|
166
|
+
const rows = dirs.map((dir) => ({ dir, key: basename(dir), row: tourRow(dir, today, idle, relative(parent, dir).split(sep).join('/')) }));
|
|
167
|
+
const priority = attempt(() => readPriority(parent), []);
|
|
168
|
+
const sorted = rows.sort((a, b) => priorityRank(a.key, priority) - priorityRank(b.key, priority) || a.key.localeCompare(b.key) || a.row.project.localeCompare(b.row.project)).map((x) => x.row);
|
|
169
|
+
return [...problems, ...sorted];
|
|
117
170
|
}
|
|
118
171
|
const DRIFT_SHOWN = 2;
|
|
119
172
|
/** Une ligne par projet : projet · en cours · dérive · notes · prochain lot prêt · dépôt. */
|
|
@@ -49,7 +49,8 @@ export function parseAcceptation(text) {
|
|
|
49
49
|
if (typeof vert !== 'boolean')
|
|
50
50
|
return 'clé « vert » (booléen) attendue';
|
|
51
51
|
const empreintes = isObj(doc.empreintes) ? Object.fromEntries(Object.entries(doc.empreintes).filter(([, v]) => typeof v === 'string')) : undefined;
|
|
52
|
-
|
|
52
|
+
const resceaux = Array.isArray(doc.resceaux) ? doc.resceaux.filter((r) => isObj(r) && ['test', 'raison', 'date', 'avant', 'apres'].every((k) => typeof r[k] === 'string')) : undefined;
|
|
53
|
+
return { lot, fiche, fichiers: fichiers, vert, ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
|
|
53
54
|
}
|
|
54
55
|
/** JSON à clés triées, sans la clé `vert` : la forme que le verrou compare. */
|
|
55
56
|
const canon = (a) => JSON.stringify(Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'vert').sort(([x], [y]) => x.localeCompare(y))));
|
|
@@ -81,6 +82,22 @@ export function redSha(repo, path) {
|
|
|
81
82
|
}
|
|
82
83
|
return null;
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Le commit rouge a bougé depuis `anchor` par un rescellé du lead (`cadence spec reseal`, L200) : le nouveau sha, sinon null.
|
|
87
|
+
* Seul un commit qui a allongé le journal `resceaux` ET qu'aucune session de la vague n'a fait (`bySession`) est adopté ; toute autre réécriture reste un écart pour le verrou.
|
|
88
|
+
*/
|
|
89
|
+
export function resealedRouge(repo, path, anchor, bySession = () => false) {
|
|
90
|
+
const now = redSha(repo, path);
|
|
91
|
+
if (!now || now === anchor)
|
|
92
|
+
return null;
|
|
93
|
+
if (bySession(now))
|
|
94
|
+
return null; // un commit de session n'est jamais un rescellé du lead : il reste un écart pour le verrou
|
|
95
|
+
const journal = (rev) => {
|
|
96
|
+
const r = show(repo, rev, path)?.resceaux;
|
|
97
|
+
return Array.isArray(r) ? r.length : 0;
|
|
98
|
+
};
|
|
99
|
+
return journal(now) > journal(anchor) ? now : null;
|
|
100
|
+
}
|
|
84
101
|
/** Chemin (relatif au dépôt) de l'`acceptation.json` du lot sous `dir`, ou null. */
|
|
85
102
|
export function findAcceptation(repo, dir, lot) {
|
|
86
103
|
for (const path of listAcceptations(repo, dir).sort()) {
|
|
@@ -103,6 +120,45 @@ export function sealAcceptation(repo, path) {
|
|
|
103
120
|
doc.empreintes = Object.fromEntries(fichiers.map((f) => [f, git(repo, 'hash-object', '--', f).trim()]));
|
|
104
121
|
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
105
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Rescelle UN test (L200, `cadence spec reseal`, le lead — jamais une session) : le test d'un lot en cours contredit sa fiche et le verrou total
|
|
125
|
+
* ne laisse aucun chemin de correction. La fiche reste inchangée depuis le commit rouge, les autres fichiers listés aussi : seule l'empreinte
|
|
126
|
+
* du test nommé est refaite, la raison et les deux empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
|
|
127
|
+
*/
|
|
128
|
+
export function resealAcceptation(repo, path, test, reason, today) {
|
|
129
|
+
const file = join(repo, path);
|
|
130
|
+
const a = parseAcceptation(readFileSync(file, 'utf8'));
|
|
131
|
+
if (typeof a === 'string')
|
|
132
|
+
throw new Error(`${path} : ${a}`);
|
|
133
|
+
if (!reason.trim())
|
|
134
|
+
throw new Error('--reason est obligatoire : la raison du rescellé va dans acceptation.json et en note du lot');
|
|
135
|
+
const norm = posix.normalize(test);
|
|
136
|
+
const fichiers = a.fichiers.map((f) => posix.normalize(f));
|
|
137
|
+
if (!fichiers.includes(norm))
|
|
138
|
+
throw new Error(`${test} n'est pas listé dans ${path} (fichiers : ${fichiers.join(', ')})`);
|
|
139
|
+
const avant = a.empreintes?.[norm];
|
|
140
|
+
if (!a.empreintes || !avant)
|
|
141
|
+
throw new Error(`${path} n'est pas scellée (pas d'empreinte pour ${norm}) : l'étape accept n'est pas finie, rien à resceller`);
|
|
142
|
+
const red = redSha(repo, path);
|
|
143
|
+
if (!red)
|
|
144
|
+
throw new Error(`${path} : pas de commit rouge lisible`);
|
|
145
|
+
if (!existsSync(join(repo, norm)))
|
|
146
|
+
throw new Error(`${norm} : fichier absent (un test supprimé ne se rescelle pas)`);
|
|
147
|
+
if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche))
|
|
148
|
+
throw new Error(`la fiche ${a.fiche} a changé depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que fiche inchangée (une fiche qui évolue passe par le lot et sa revue)`);
|
|
149
|
+
const touches = fichiers.filter((f) => f !== norm && (!gitOk(repo, 'diff', '--quiet', red, '--', f) || !existsSync(join(repo, f)) || a.empreintes?.[f] !== git(repo, 'hash-object', '--', f).trim()));
|
|
150
|
+
if (touches.length)
|
|
151
|
+
throw new Error(`autre(s) test(s) listé(s) modifié(s) depuis le commit rouge : ${touches.join(', ')} (le rescellé ne couvre que ${norm}, le verrou garde le reste)`);
|
|
152
|
+
const apres = git(repo, 'hash-object', '--', norm).trim();
|
|
153
|
+
if (apres === avant)
|
|
154
|
+
throw new Error(`${norm} est inchangé depuis le scellé : rien à resceller`);
|
|
155
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
156
|
+
doc.empreintes[norm] = apres;
|
|
157
|
+
const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
|
|
158
|
+
doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres }];
|
|
159
|
+
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
160
|
+
return { avant, apres, lot: a.lot };
|
|
161
|
+
}
|
|
106
162
|
/** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
|
|
107
163
|
export function setVert(repo, path) {
|
|
108
164
|
const file = join(repo, path);
|
|
@@ -288,12 +344,42 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
|
|
|
288
344
|
}
|
|
289
345
|
return out;
|
|
290
346
|
}
|
|
347
|
+
/** Une liste (même d'un seul élément) lance chaque entrée sous sa racine ; une chaîne garde le dépôt pour racine. Le brief et le cycle décident par cette seule fonction. */
|
|
348
|
+
export function isAcceptList(accept) {
|
|
349
|
+
return Array.isArray(accept);
|
|
350
|
+
}
|
|
351
|
+
/** Commandes de `orchestrate.accept` : une chaîne ou une liste (L189), toujours rendue en liste. */
|
|
352
|
+
export function acceptCommands(accept) {
|
|
353
|
+
return accept === undefined ? [] : isAcceptList(accept) ? accept : [accept];
|
|
354
|
+
}
|
|
355
|
+
/** Racine d'une commande : le dossier de son `cd <dossier> &&` initial (relatif au dépôt, normalisé), sinon le dépôt (''). Un `cd` qui sort du dépôt n'est pas une racine. */
|
|
356
|
+
export function acceptRoot(command) {
|
|
357
|
+
const m = /^\s*cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*&&/.exec(command);
|
|
358
|
+
const dir = posix.normalize(m?.[1] ?? m?.[2] ?? m?.[3] ?? '.');
|
|
359
|
+
return dir === '.' || dir === '..' || dir.startsWith('../') || posix.isAbsolute(dir) ? '' : dir.replace(/\/$/, '');
|
|
360
|
+
}
|
|
361
|
+
/** Le fichier est sous la racine (le dépôt, '', contient tout). */
|
|
362
|
+
export const underRoot = (file, root) => {
|
|
363
|
+
const f = posix.normalize(file);
|
|
364
|
+
return root === '' || f.startsWith(`${root}/`);
|
|
365
|
+
};
|
|
366
|
+
/** Lancements d'une liste de commandes (`single` : la chaîne seule de `orchestrate.accept`, dont la racine reste le dépôt, `cd` ou non, comme avant la liste) pour les fichiers de l'acceptation : une commande sans fichier sous sa racine n'est pas lancée ; un fichier sous aucune racine est un orphelin. */
|
|
367
|
+
export function planAcceptRuns(commands, fichiers, single = false) {
|
|
368
|
+
const all = commands.map((command) => ({ command, root: single ? '' : acceptRoot(command) }));
|
|
369
|
+
return { runs: all.filter((r) => fichiers.some((f) => underRoot(f, r.root))), orphans: fichiers.filter((f) => !all.some((r) => underRoot(f, r.root))) };
|
|
370
|
+
}
|
|
291
371
|
/** 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. */
|
|
292
372
|
export function acceptText(o) {
|
|
373
|
+
const commands = acceptCommands(o.command);
|
|
374
|
+
const screen = o.screen !== false;
|
|
293
375
|
const lines = [
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
376
|
+
!screen
|
|
377
|
+
? `Sheet: \`${o.fiche}\` (cases, rules, contracts, data). You get nothing else about the behaviour: the implementation does not exist yet.`
|
|
378
|
+
: `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`.' : ''}`,
|
|
379
|
+
isAcceptList(o.command)
|
|
380
|
+
? `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 ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`
|
|
381
|
+
: `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 ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
|
|
382
|
+
`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). 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\`):`,
|
|
297
383
|
];
|
|
298
384
|
if (o.runner)
|
|
299
385
|
lines.push('```js', o.runner.trimEnd(), '```');
|