@sylad/cadence 1.0.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 +286 -22
- package/agents/acceptance-writer.md +21 -0
- package/agents/code-reviewer.md +8 -1
- package/agents/spec-writer.md +23 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +11 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +233 -18
- package/dist/config.js +46 -5
- package/dist/corrections.js +228 -0
- package/dist/lead.js +72 -19
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +389 -0
- package/dist/orchestrate/aval.js +65 -0
- package/dist/orchestrate/briefs.js +7 -1
- package/dist/orchestrate/command.js +70 -20
- package/dist/orchestrate/continue.js +1 -1
- package/dist/orchestrate/cycle.js +662 -24
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +17 -10
- 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 +197 -0
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/plan.js +79 -1
- package/dist/spec.js +457 -0
- package/package.json +4 -3
- package/skills/lead/SKILL.md +33 -8
- package/templates/acceptation-runner.mjs +61 -0
- package/templates/fiche-back.yaml +75 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fiche.md +14 -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-back.md +25 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
package/dist/spec.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { parse } from 'yaml';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
|
+
/** Les largeurs de rendu d'une fiche (L170, spec etape-spec S2). */
|
|
6
|
+
export const LARGEURS = [1440, 390];
|
|
7
|
+
/** Les états qu'une fiche porte au moins. */
|
|
8
|
+
export const ETATS_REQUIS = ['vide', 'chargement', 'erreur'];
|
|
9
|
+
const KEBAB = /^[a-z][a-z0-9-]*$/;
|
|
10
|
+
const APPEL = /^(GET|POST|PUT|PATCH|DELETE) \/\S*$/;
|
|
11
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
12
|
+
const texte = (v) => typeof v === 'string' && v.trim() !== '';
|
|
13
|
+
const APPEL_BACK = /^((GET|POST|PUT|PATCH|DELETE) \/\S*|job \S+|commande \S+( \S+)*)$/;
|
|
14
|
+
export const CODE_ERREUR = /^(HTTP \d{3}|log (debug|log|warn|warning|error|info|critical|fatal|verbose))$/;
|
|
15
|
+
const MODIFICATIONS = ['aucune', 'ajout', 'changement'];
|
|
16
|
+
const liste = (v) => (Array.isArray(v) ? v : []);
|
|
17
|
+
/** Les ids d'une liste d'objets, dans l'ordre. */
|
|
18
|
+
const idsDe = (v) => liste(v).filter(isObj).map((x) => x.id).filter(texte);
|
|
19
|
+
/** Un appel d'action : le sien, ou celui du contrat auquel elle renvoie (`contrat: Ax`, fiche mixte). */
|
|
20
|
+
export function appelDe(action, contrats) {
|
|
21
|
+
if (texte(action.appel))
|
|
22
|
+
return action.appel;
|
|
23
|
+
const c = liste(contrats).filter(isObj).find((x) => x.id === action.contrat);
|
|
24
|
+
return c && texte(c.appel) ? c.appel : '';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Les écarts d'une fiche aux invariants de la spec de l'étape spec (une phrase par écart ; vide = valide). Une fiche a une partie
|
|
28
|
+
* écran (`ecran`, `etats`, `champs`, `actions`), une partie back (`sujet`, `cas`, `contrats`, `donnees`), ou les deux (lot mixte) ;
|
|
29
|
+
* `regles` est commun. Écran : ids de champs uniques, états connus, citations d'ids existants (actions, règles, masque).
|
|
30
|
+
* Back : cas C1… et contrats A1… sans trou, règles rattachées à un cas, alternatives qui citent des règles, erreurs de contrat,
|
|
31
|
+
* décision du lead sur une donnée existante modifiée. Les deux : règles R1… sans trou, statut et justification, tests « Rn — … ».
|
|
32
|
+
*/
|
|
33
|
+
export function checkFiche(doc) {
|
|
34
|
+
if (!isObj(doc))
|
|
35
|
+
return ["ce n'est pas une fiche : un document YAML à clés est attendu (ecran, etats, champs, regles, actions pour un écran ; sujet, cas, regles, contrats, donnees pour un lot sans écran)"];
|
|
36
|
+
const p = [];
|
|
37
|
+
const back = 'sujet' in doc;
|
|
38
|
+
const ecranPart = !back || 'ecran' in doc;
|
|
39
|
+
if (!back && !('ecran' in doc))
|
|
40
|
+
p.push('ecran ou sujet : bloc manquant (ecran pour un lot à écran, sujet pour un lot sans écran, les deux pour un lot mixte)');
|
|
41
|
+
const ecran = isObj(doc.ecran) ? doc.ecran : {};
|
|
42
|
+
if (ecranPart) {
|
|
43
|
+
if (!isObj(doc.ecran))
|
|
44
|
+
p.push('ecran : bloc manquant');
|
|
45
|
+
for (const k of ['id', 'titre', 'route', 'lot', 'maquette'])
|
|
46
|
+
if (!texte(ecran[k]))
|
|
47
|
+
p.push(`ecran.${k} : texte attendu`);
|
|
48
|
+
if (texte(ecran.route) && !ecran.route.startsWith('/'))
|
|
49
|
+
p.push(`ecran.route : « ${ecran.route} » doit commencer par /`);
|
|
50
|
+
if (!Array.isArray(ecran.largeurs) || LARGEURS.some((w) => !ecran.largeurs.includes(w)))
|
|
51
|
+
p.push(`ecran.largeurs : [${LARGEURS.join(', ')}] attendu`);
|
|
52
|
+
}
|
|
53
|
+
const sujet = isObj(doc.sujet) ? doc.sujet : {};
|
|
54
|
+
if (back) {
|
|
55
|
+
if (!isObj(doc.sujet))
|
|
56
|
+
p.push('sujet : bloc manquant');
|
|
57
|
+
for (const k of ['id', 'titre', 'lot'])
|
|
58
|
+
if (!texte(sujet[k]))
|
|
59
|
+
p.push(`sujet.${k} : texte attendu`);
|
|
60
|
+
if (texte(sujet.id) && !KEBAB.test(sujet.id))
|
|
61
|
+
p.push(`sujet.id : « ${sujet.id} » doit être en kebab-case (c'est le nom du dossier docs/specs/<sujet>/)`);
|
|
62
|
+
const per = sujet.perimetre;
|
|
63
|
+
if (!isObj(per))
|
|
64
|
+
p.push('sujet.perimetre : bloc attendu (modules, endpoints, jobs)');
|
|
65
|
+
else {
|
|
66
|
+
for (const k of ['modules', 'endpoints', 'jobs'])
|
|
67
|
+
if (!Array.isArray(per[k]) || per[k].some((x) => !texte(x)))
|
|
68
|
+
p.push(`sujet.perimetre.${k} : liste de textes attendue`);
|
|
69
|
+
if (['modules', 'endpoints', 'jobs'].every((k) => liste(per[k]).length === 0))
|
|
70
|
+
p.push('sujet.perimetre : au moins un module, un endpoint ou un job attendu');
|
|
71
|
+
if ('hors_perimetre' in per && per.hors_perimetre !== null && (!Array.isArray(per.hors_perimetre) || per.hors_perimetre.some((x) => !texte(x))))
|
|
72
|
+
p.push('sujet.perimetre.hors_perimetre : liste de textes attendue');
|
|
73
|
+
}
|
|
74
|
+
if (ecranPart && isObj(doc.ecran) && isObj(doc.sujet)) {
|
|
75
|
+
if (texte(sujet.id) && texte(ecran.id) && sujet.id !== ecran.id)
|
|
76
|
+
p.push(`sujet.id « ${sujet.id} » et ecran.id « ${ecran.id} » diffèrent (dans une fiche mixte, l'un et l'autre sont le nom du dossier)`);
|
|
77
|
+
if (texte(sujet.lot) && texte(ecran.lot) && sujet.lot !== ecran.lot)
|
|
78
|
+
p.push(`sujet.lot « ${sujet.lot} » et ecran.lot « ${ecran.lot} » diffèrent (c'est le lot d'implémentation)`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Sans partie écran, ces clés ne veulent rien dire : les ignorer en silence laisserait passer, par exemple, des actions A1… en double des contrats.
|
|
82
|
+
if (!ecranPart)
|
|
83
|
+
for (const k of ['actions', 'etats', 'champs'])
|
|
84
|
+
if (doc[k] !== undefined && doc[k] !== null)
|
|
85
|
+
p.push(`${k} : n'a de sens que dans une fiche qui a une partie écran (ecran)`);
|
|
86
|
+
let etats = [];
|
|
87
|
+
let champs = [];
|
|
88
|
+
const ids = new Set();
|
|
89
|
+
if (ecranPart) {
|
|
90
|
+
etats = Array.isArray(doc.etats) ? doc.etats.filter(texte) : [];
|
|
91
|
+
if (!Array.isArray(doc.etats))
|
|
92
|
+
p.push('etats : liste attendue');
|
|
93
|
+
else {
|
|
94
|
+
for (const [i, e] of doc.etats.entries())
|
|
95
|
+
if (!texte(e))
|
|
96
|
+
p.push(`état n° ${i + 1} : texte attendu`);
|
|
97
|
+
const manquants = ETATS_REQUIS.filter((e) => !etats.includes(e));
|
|
98
|
+
if (manquants.length)
|
|
99
|
+
p.push(`états : ${manquants.map((e) => `« ${e} »`).join(', ')} manquant(s) (vide, chargement et erreur sont requis)`);
|
|
100
|
+
for (const e of new Set(etats.filter((x, i) => etats.indexOf(x) !== i)))
|
|
101
|
+
p.push(`états : « ${e} » en double`);
|
|
102
|
+
}
|
|
103
|
+
champs = Array.isArray(doc.champs) ? doc.champs : [];
|
|
104
|
+
if (champs.length === 0)
|
|
105
|
+
p.push('champs : liste non vide attendue');
|
|
106
|
+
for (const [i, c] of champs.entries()) {
|
|
107
|
+
if (!isObj(c)) {
|
|
108
|
+
p.push(`champ n° ${i + 1} : objet attendu`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const nom = texte(c.id) ? `« ${c.id} »` : `n° ${i + 1}`;
|
|
112
|
+
if (!texte(c.id))
|
|
113
|
+
p.push(`champ ${nom} : id manquant`);
|
|
114
|
+
else {
|
|
115
|
+
if (!KEBAB.test(c.id))
|
|
116
|
+
p.push(`champ ${nom} : l'id doit être en kebab-case (c'est aussi le data-testid)`);
|
|
117
|
+
if (ids.has(c.id))
|
|
118
|
+
p.push(`champ ${nom} en double`);
|
|
119
|
+
ids.add(c.id);
|
|
120
|
+
}
|
|
121
|
+
for (const k of ['libelle', 'type', 'source'])
|
|
122
|
+
if (!texte(c[k]))
|
|
123
|
+
p.push(`champ ${nom} : ${k} manquant`);
|
|
124
|
+
if (typeof c.obligatoire !== 'boolean')
|
|
125
|
+
p.push(`champ ${nom} : obligatoire doit valoir true ou false`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Les ids des cas, des règles et des contrats, relevés avant les contrôles croisés (une alternative cite des règles, une règle des cas).
|
|
129
|
+
const regles = Array.isArray(doc.regles) ? doc.regles : [];
|
|
130
|
+
const ruleIds = new Set(regles.map((r, i) => (isObj(r) && texte(r.id) ? r.id : `R${i + 1}`)));
|
|
131
|
+
const casIds = new Set(liste(doc.cas).map((c, i) => (isObj(c) && texte(c.id) ? c.id : `C${i + 1}`)));
|
|
132
|
+
const contratIds = new Set(idsDe(doc.contrats));
|
|
133
|
+
if (back) {
|
|
134
|
+
const cas = Array.isArray(doc.cas) ? doc.cas : [];
|
|
135
|
+
if (cas.length === 0)
|
|
136
|
+
p.push('cas : liste non vide attendue');
|
|
137
|
+
for (const [i, c] of cas.entries()) {
|
|
138
|
+
const attendu = `C${i + 1}`;
|
|
139
|
+
if (!isObj(c)) {
|
|
140
|
+
p.push(`cas n° ${i + 1} : objet attendu`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const id = texte(c.id) ? c.id : attendu;
|
|
144
|
+
if (c.id !== attendu)
|
|
145
|
+
p.push(`cas n° ${i + 1} : l'id doit être ${attendu} (C1, C2… dans l'ordre, sans trou), pas ${texte(c.id) ? c.id : 'absent'}`);
|
|
146
|
+
for (const k of ['titre', 'acteur', 'declencheur', 'resultat'])
|
|
147
|
+
if (!texte(c[k]))
|
|
148
|
+
p.push(`${id} : ${k} manquant`);
|
|
149
|
+
if (!Array.isArray(c.preconditions) || c.preconditions.some((x) => !texte(x)))
|
|
150
|
+
p.push(`${id} : preconditions doit être une liste de textes`);
|
|
151
|
+
if (!Array.isArray(c.nominal) || c.nominal.length === 0 || c.nominal.some((x) => !texte(x)))
|
|
152
|
+
p.push(`${id} : nominal doit être une liste non vide d'étapes (textes)`);
|
|
153
|
+
if (!Array.isArray(c.alternatives))
|
|
154
|
+
p.push(`${id} : alternatives doit être une liste ([] avec sans_alternative si le cas n'en a aucune)`);
|
|
155
|
+
else if (c.alternatives.length === 0 && !texte(c.sans_alternative))
|
|
156
|
+
p.push(`${id} : au moins une alternative ou un cas d'erreur, ou « alternatives: [] » avec sa raison dans sans_alternative`);
|
|
157
|
+
for (const [j, a] of liste(c.alternatives).entries()) {
|
|
158
|
+
const nom = `${id} : alternative n° ${j + 1}`;
|
|
159
|
+
if (!isObj(a)) {
|
|
160
|
+
p.push(`${nom} : objet attendu`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const k of ['si', 'alors'])
|
|
164
|
+
if (!texte(a[k]))
|
|
165
|
+
p.push(`${nom} : ${k} manquant`);
|
|
166
|
+
const rs = liste(a.regles);
|
|
167
|
+
if (!Array.isArray(a.regles) || rs.length === 0)
|
|
168
|
+
p.push(`${nom} : au moins une règle citée (regles)`);
|
|
169
|
+
for (const r of rs)
|
|
170
|
+
if (!ruleIds.has(String(r)))
|
|
171
|
+
p.push(`${nom} : règle « ${String(r)} » absente de la fiche`);
|
|
172
|
+
}
|
|
173
|
+
if (!regles.some((r) => isObj(r) && liste(r.cas).includes(id)))
|
|
174
|
+
p.push(`${id} : aucune règle ne le cite (chaque cas est cité par au moins une règle)`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (regles.length === 0)
|
|
178
|
+
p.push('regles : liste non vide attendue');
|
|
179
|
+
for (const [i, r] of regles.entries()) {
|
|
180
|
+
const attendu = `R${i + 1}`;
|
|
181
|
+
if (!isObj(r)) {
|
|
182
|
+
p.push(`règle n° ${i + 1} : objet attendu`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const id = texte(r.id) ? r.id : attendu;
|
|
186
|
+
if (r.id !== attendu)
|
|
187
|
+
p.push(`règle n° ${i + 1} : l'id doit être ${attendu} (R1, R2… dans l'ordre, sans trou), pas ${texte(r.id) ? r.id : 'absent'}`);
|
|
188
|
+
// Une règle d'écran est rattachée à un état, une règle back à des cas ; dans une fiche mixte, à l'un des deux au moins.
|
|
189
|
+
const aUnEtat = 'etat' in r && r.etat !== undefined && r.etat !== null;
|
|
190
|
+
const aDesCas = 'cas' in r && r.cas !== undefined && r.cas !== null;
|
|
191
|
+
if (ecranPart && (aUnEtat || !back)) {
|
|
192
|
+
if (!texte(r.etat) || !etats.includes(r.etat))
|
|
193
|
+
p.push(`${id} : état « ${String(r.etat ?? '')} » inconnu (états : ${etats.join(', ')})`);
|
|
194
|
+
}
|
|
195
|
+
else if (aUnEtat)
|
|
196
|
+
p.push(`${id} : etat n'a de sens que dans une fiche qui a une partie écran (ecran)`);
|
|
197
|
+
if (back && !aUnEtat && (!Array.isArray(r.cas) || r.cas.length === 0))
|
|
198
|
+
p.push(`${id} : une règle est rattachée à au moins un cas (cas: [C1, …])${ecranPart ? ' ou à un état (etat)' : ''}`);
|
|
199
|
+
if (aDesCas) {
|
|
200
|
+
if (!Array.isArray(r.cas))
|
|
201
|
+
p.push(`${id} : cas doit être une liste d'ids de cas`);
|
|
202
|
+
else
|
|
203
|
+
for (const c of r.cas)
|
|
204
|
+
if (!casIds.has(String(c)) || !back)
|
|
205
|
+
p.push(`${id} : cas « ${String(c)} » absent de la fiche`);
|
|
206
|
+
}
|
|
207
|
+
if (!texte(r.enonce))
|
|
208
|
+
p.push(`${id} : enonce manquant`);
|
|
209
|
+
const rattachee = ecranPart && (!back || aUnEtat);
|
|
210
|
+
if (rattachee) {
|
|
211
|
+
if (!('message' in r) || !(r.message === null || typeof r.message === 'string'))
|
|
212
|
+
p.push(`${id} : message attendu (le texte exact à l'écran, ou null)`);
|
|
213
|
+
}
|
|
214
|
+
else if (!aUnEtat) {
|
|
215
|
+
for (const k of ['message', 'champs', 'masque'])
|
|
216
|
+
if (r[k] !== undefined && r[k] !== null)
|
|
217
|
+
p.push(`${id} : ${k} n'a de sens que pour une règle rattachée à un état`);
|
|
218
|
+
}
|
|
219
|
+
if (r.statut !== 'sourcee' && r.statut !== 'hypothese')
|
|
220
|
+
p.push(`${id} : statut « ${String(r.statut ?? '')} » inconnu (sourcee ou hypothese)`);
|
|
221
|
+
else if (r.statut === 'sourcee' && !texte(r.source))
|
|
222
|
+
p.push(`${id} : une règle sourcée dit d'où elle vient (source)`);
|
|
223
|
+
else if (r.statut === 'hypothese' && !texte(r.si_faux))
|
|
224
|
+
p.push(`${id} : une hypothèse dit ce qui changerait si elle était fausse (si_faux)`);
|
|
225
|
+
if (rattachee) {
|
|
226
|
+
if ('champs' in r && r.champs !== undefined && r.champs !== null && !Array.isArray(r.champs))
|
|
227
|
+
p.push(`${id} : champs doit être une liste d'ids de champs`);
|
|
228
|
+
const cites = Array.isArray(r.champs) ? r.champs : [];
|
|
229
|
+
for (const c of cites)
|
|
230
|
+
if (!ids.has(String(c)))
|
|
231
|
+
p.push(`${id} : champ « ${String(c)} » absent de la fiche`);
|
|
232
|
+
if ('masque' in r && r.masque !== undefined && r.masque !== null) {
|
|
233
|
+
if (!Array.isArray(r.masque))
|
|
234
|
+
p.push(`${id} : masque doit être une liste d'ids de champs`);
|
|
235
|
+
else {
|
|
236
|
+
if (r.etat !== 'vide')
|
|
237
|
+
p.push(`${id} : masque n'a de sens que pour une règle d'état « vide » (état « ${String(r.etat)} »)`);
|
|
238
|
+
for (const m of r.masque) {
|
|
239
|
+
if (!ids.has(String(m)))
|
|
240
|
+
p.push(`${id} : masque cite le champ « ${String(m)} » absent de la fiche`);
|
|
241
|
+
else if (cites.includes(m))
|
|
242
|
+
p.push(`${id} : masque cite « ${String(m)} » que la règle fait apparaître dans champs`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const tests = Array.isArray(r.tests) ? r.tests : [];
|
|
248
|
+
if (tests.length === 0)
|
|
249
|
+
p.push(`${id} : au moins un test prévu, nommé « ${id} — … »`);
|
|
250
|
+
for (const t of tests)
|
|
251
|
+
if (typeof t !== 'string' || !t.startsWith(`${id} — `))
|
|
252
|
+
p.push(`${id} : le test « ${String(t)} » doit être nommé « ${id} — … »`);
|
|
253
|
+
}
|
|
254
|
+
// Sans partie écran, les actions sont déjà refusées d'un bloc : ne pas y ajouter un écart par action.
|
|
255
|
+
const actions = ecranPart && Array.isArray(doc.actions) ? doc.actions : [];
|
|
256
|
+
if (ecranPart && !Array.isArray(doc.actions))
|
|
257
|
+
p.push('actions : liste attendue');
|
|
258
|
+
const contrats = Array.isArray(doc.contrats) ? doc.contrats : [];
|
|
259
|
+
// Les ids A sont communs aux actions de l'écran et aux contrats : sans trou sur l'ensemble (une fiche d'écran seule : dans l'ordre des actions).
|
|
260
|
+
const commun = back && ecranPart;
|
|
261
|
+
for (const [i, a] of actions.entries()) {
|
|
262
|
+
const attendu = `A${i + 1}`;
|
|
263
|
+
if (!isObj(a)) {
|
|
264
|
+
p.push(`action n° ${i + 1} : objet attendu`);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const id = texte(a.id) ? a.id : attendu;
|
|
268
|
+
if (!commun && a.id !== attendu)
|
|
269
|
+
p.push(`action n° ${i + 1} : l'id doit être ${attendu} (A1, A2… dans l'ordre), pas ${texte(a.id) ? a.id : 'absent'}`);
|
|
270
|
+
if (commun && !texte(a.id))
|
|
271
|
+
p.push(`action n° ${i + 1} : id manquant`);
|
|
272
|
+
if (a.declencheur !== null && a.declencheur !== undefined && !(texte(a.declencheur) && ids.has(a.declencheur)))
|
|
273
|
+
p.push(`${id} : déclencheur « ${String(a.declencheur)} » absent de la fiche (un id de champ, ou null pour le chargement)`);
|
|
274
|
+
if (commun && texte(a.contrat)) {
|
|
275
|
+
if (!contratIds.has(a.contrat))
|
|
276
|
+
p.push(`${id} : contrat « ${a.contrat} » absent de la fiche (un id de contrats)`);
|
|
277
|
+
if ('appel' in a && a.appel !== undefined && a.appel !== null)
|
|
278
|
+
p.push(`${id} : une action qui renvoie à un contrat ne répète pas son appel (contrat ${a.contrat})`);
|
|
279
|
+
}
|
|
280
|
+
else if (!texte(a.appel) || !APPEL.test(a.appel))
|
|
281
|
+
p.push(`${id} : appel « ${String(a.appel ?? '')} » invalide (VERBE /chemin)`);
|
|
282
|
+
else if (commun) {
|
|
283
|
+
const meme = contrats.filter(isObj).find((c) => c.appel === a.appel && texte(c.id));
|
|
284
|
+
if (meme)
|
|
285
|
+
p.push(`${id} : appel « ${a.appel} » déjà celui du contrat ${String(meme.id)} (renvoyer au contrat avec contrat: ${String(meme.id)}, sans répéter l'appel)`);
|
|
286
|
+
}
|
|
287
|
+
if ('regles' in a && a.regles !== undefined && a.regles !== null && !Array.isArray(a.regles))
|
|
288
|
+
p.push(`${id} : regles doit être une liste d'ids de règles`);
|
|
289
|
+
for (const r of Array.isArray(a.regles) ? a.regles : [])
|
|
290
|
+
if (!ruleIds.has(String(r)))
|
|
291
|
+
p.push(`${id} : règle « ${String(r)} » absente de la fiche`);
|
|
292
|
+
}
|
|
293
|
+
if (back) {
|
|
294
|
+
if (!Array.isArray(doc.contrats) || contrats.length === 0)
|
|
295
|
+
p.push('contrats : liste non vide attendue');
|
|
296
|
+
for (const [i, a] of contrats.entries()) {
|
|
297
|
+
const attendu = `A${i + 1}`;
|
|
298
|
+
if (!isObj(a)) {
|
|
299
|
+
p.push(`contrat n° ${i + 1} : objet attendu`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const id = texte(a.id) ? a.id : attendu;
|
|
303
|
+
if (!commun && a.id !== attendu)
|
|
304
|
+
p.push(`contrat n° ${i + 1} : l'id doit être ${attendu} (A1, A2… dans l'ordre, sans trou), pas ${texte(a.id) ? a.id : 'absent'}`);
|
|
305
|
+
if (commun && !(texte(a.id) && /^A\d+$/.test(a.id)))
|
|
306
|
+
p.push(`contrat n° ${i + 1} : l'id doit être de la forme A<n>`);
|
|
307
|
+
if (!texte(a.appel) || !APPEL_BACK.test(a.appel))
|
|
308
|
+
p.push(`${id} : appel « ${String(a.appel ?? '')} » invalide (VERBE /chemin, job <nom> ou commande <nom>)`);
|
|
309
|
+
for (const k of ['entree', 'sortie'])
|
|
310
|
+
if (!texte(a[k]))
|
|
311
|
+
p.push(`${id} : ${k} manquant`);
|
|
312
|
+
if (!Array.isArray(a.erreurs))
|
|
313
|
+
p.push(`${id} : erreurs doit être une liste ([] avec sans_erreur si le contrat n'en a aucune)`);
|
|
314
|
+
else if (a.erreurs.length === 0 && !texte(a.sans_erreur))
|
|
315
|
+
p.push(`${id} : au moins une erreur, ou « erreurs: [] » avec sa raison dans sans_erreur`);
|
|
316
|
+
for (const [j, e] of liste(a.erreurs).entries()) {
|
|
317
|
+
if (!isObj(e)) {
|
|
318
|
+
p.push(`${id} : erreur n° ${j + 1} : objet { code, message } attendu`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!texte(e.code) || !CODE_ERREUR.test(e.code))
|
|
322
|
+
p.push(`${id} : erreur n° ${j + 1} : code « ${String(e.code ?? '')} » invalide (HTTP <n> ou log debug|log|warn|warning|error|info|critical|fatal|verbose)`);
|
|
323
|
+
if (!texte(e.message))
|
|
324
|
+
p.push(`${id} : erreur n° ${j + 1} : message manquant (le texte exact)`);
|
|
325
|
+
}
|
|
326
|
+
if ('regles' in a && a.regles !== undefined && a.regles !== null && !Array.isArray(a.regles))
|
|
327
|
+
p.push(`${id} : regles doit être une liste d'ids de règles`);
|
|
328
|
+
for (const r of liste(a.regles))
|
|
329
|
+
if (!ruleIds.has(String(r)))
|
|
330
|
+
p.push(`${id} : règle « ${String(r)} » absente de la fiche`);
|
|
331
|
+
}
|
|
332
|
+
if (commun) {
|
|
333
|
+
// ids A communs : uniques, et A1…An sans trou sur l'ensemble des actions et des contrats
|
|
334
|
+
const tous = [...idsDe(doc.actions), ...idsDe(doc.contrats)];
|
|
335
|
+
for (const d of new Set(tous.filter((x, i) => tous.indexOf(x) !== i)))
|
|
336
|
+
p.push(`ids A : « ${d} » en double (les actions de l'écran et les contrats partagent A1…An)`);
|
|
337
|
+
const manque = tous.length > 0 ? Array.from({ length: tous.length }, (_, i) => `A${i + 1}`).filter((x) => !tous.includes(x)) : [];
|
|
338
|
+
if (manque.length)
|
|
339
|
+
p.push(`ids A : A1…A${tous.length} sans trou attendu sur les actions et les contrats, ${manque.join(', ')} manquant(s)`);
|
|
340
|
+
}
|
|
341
|
+
const donnees = Array.isArray(doc.donnees) ? doc.donnees : [];
|
|
342
|
+
if (!Array.isArray(doc.donnees))
|
|
343
|
+
p.push('donnees : liste attendue ([] si le lot ne touche aucune donnée)');
|
|
344
|
+
for (const [i, d] of donnees.entries()) {
|
|
345
|
+
const attendu = `D${i + 1}`;
|
|
346
|
+
if (!isObj(d)) {
|
|
347
|
+
p.push(`donnée n° ${i + 1} : objet attendu`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const id = texte(d.id) ? d.id : attendu;
|
|
351
|
+
if (d.id !== attendu)
|
|
352
|
+
p.push(`donnée n° ${i + 1} : l'id doit être ${attendu} (D1, D2… dans l'ordre), pas ${texte(d.id) ? d.id : 'absent'}`);
|
|
353
|
+
for (const k of ['objet', 'note'])
|
|
354
|
+
if (!texte(d[k]))
|
|
355
|
+
p.push(`${id} : ${k} manquant`);
|
|
356
|
+
if (typeof d.existant !== 'boolean')
|
|
357
|
+
p.push(`${id} : existant doit valoir true ou false`);
|
|
358
|
+
if (!MODIFICATIONS.includes(String(d.modification)))
|
|
359
|
+
p.push(`${id} : modification « ${String(d.modification ?? '')} » inconnue (${MODIFICATIONS.join(', ')})`);
|
|
360
|
+
else if (d.existant === true && d.modification !== 'aucune' && !texte(d.decision))
|
|
361
|
+
p.push(`${id} : une donnée existante modifiée porte la decision du lead (sa réponse à la question ciblée)`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return p;
|
|
365
|
+
}
|
|
366
|
+
/** Résumé d'une fiche valide, pour la sortie de `cadence spec check`. */
|
|
367
|
+
export function summary(doc) {
|
|
368
|
+
const n = (k) => (Array.isArray(doc[k]) ? doc[k].length : 0);
|
|
369
|
+
const hyp = (Array.isArray(doc.regles) ? doc.regles : []).filter((r) => r.statut === 'hypothese').length;
|
|
370
|
+
const ecran = isObj(doc.ecran);
|
|
371
|
+
const back = isObj(doc.sujet);
|
|
372
|
+
const partieBack = `${n('cas')} cas, ${n('contrats')} contrats, ${n('donnees')} données`;
|
|
373
|
+
if (ecran && back)
|
|
374
|
+
return `fiche valide : ${n('champs')} champs, ${n('regles')} règles (${hyp} hypothèses), ${n('actions')} actions ; ${partieBack}`;
|
|
375
|
+
if (back)
|
|
376
|
+
return `fiche valide : ${n('cas')} cas, ${n('regles')} règles (${hyp} hypothèses), ${n('contrats')} contrats, ${n('donnees')} données`;
|
|
377
|
+
return `fiche valide : ${n('champs')} champs, ${n('regles')} règles (${hyp} hypothèses), ${n('actions')} actions`;
|
|
378
|
+
}
|
|
379
|
+
/** Lit une fiche YAML ; un fichier absent ou illisible est une erreur d'usage (code 2), pas un écart de la fiche. */
|
|
380
|
+
export function readFiche(path) {
|
|
381
|
+
if (!existsSync(path))
|
|
382
|
+
throw new RafError(`spec : fiche introuvable : ${path}`);
|
|
383
|
+
try {
|
|
384
|
+
return parse(readFileSync(path, 'utf8'));
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
throw new RafError(`spec : ${path} n'est pas du YAML lisible : ${e.message.split('\n')[0]}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/** Une phrase sans point final, pour la suite d'un « unless » ou d'un « may be empty when ». */
|
|
391
|
+
const condition = (enonce) => enonce.trim().replace(/[.\s]+$/, '');
|
|
392
|
+
/**
|
|
393
|
+
* La section `docs/qa/expectations.md` de l'écran, dérivée de la fiche (spec etape-spec § 5). Une fiche invalide est refusée.
|
|
394
|
+
* - `shows:` un champ obligatoire par ligne, sauf les champs propres à un état (cités par une règle d'état vide ou erreur) ;
|
|
395
|
+
* la ligne d'un champ du `masque` d'une règle sourcée d'état vide se termine par `— unless <énoncé>`.
|
|
396
|
+
* - `never:` les messages des règles d'état erreur (jamais celui d'une règle vide : c'est un affichage prévu).
|
|
397
|
+
* - `api:` les actions de chargement (sans déclencheur) en GET ; `— may be empty when <énoncé>` si une règle sourcée d'état vide la porte.
|
|
398
|
+
*/
|
|
399
|
+
export function deriveQa(doc) {
|
|
400
|
+
const problems = checkFiche(doc);
|
|
401
|
+
if (problems.length > 0)
|
|
402
|
+
throw new RafError(`fiche invalide :\n${problems.map((m) => `✗ ${m}`).join('\n')}`);
|
|
403
|
+
if (!isObj(doc.ecran))
|
|
404
|
+
throw new RafError("spec qa : la fiche n'a pas de partie écran (ecran) : rien à dériver pour docs/qa/expectations.md");
|
|
405
|
+
const f = doc;
|
|
406
|
+
const vides = f.regles.filter((r) => r.etat === 'vide' && r.statut === 'sourcee');
|
|
407
|
+
const propres = new Set(f.regles.filter((r) => r.etat === 'vide' || r.etat === 'erreur').flatMap((r) => r.champs ?? []));
|
|
408
|
+
const out = [`## ${f.ecran.route}`];
|
|
409
|
+
for (const c of f.champs.filter((x) => x.obligatoire === true && !propres.has(x.id))) {
|
|
410
|
+
const unless = vides.filter((r) => (r.masque ?? []).includes(c.id)).map((r) => condition(r.enonce));
|
|
411
|
+
out.push(`- shows: ${c.libelle}${unless.length ? ` — unless ${unless.join(' ; ')}` : ''}`);
|
|
412
|
+
}
|
|
413
|
+
const messages = f.regles.filter((r) => r.etat === 'erreur' && typeof r.message === 'string' && r.message !== '').map((r) => `"${r.message}"`);
|
|
414
|
+
if (messages.length)
|
|
415
|
+
out.push(`- never: ${messages.join(', ')}`);
|
|
416
|
+
for (const a of f.actions.filter((x) => (x.declencheur === null || x.declencheur === undefined) && appelDe(x, f.contrats).startsWith('GET '))) {
|
|
417
|
+
const peut = vides.filter((r) => (a.regles ?? []).includes(r.id)).map((r) => condition(r.enonce));
|
|
418
|
+
out.push(`- api: ${appelDe(a, f.contrats).slice(4)}${peut.length ? ` — may be empty when ${peut.join(' ; ')}` : ''}`);
|
|
419
|
+
}
|
|
420
|
+
return `${out.join('\n')}\n`;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Les règles `hypothese` des fiches du dossier des specs (`<dir>/<écran>/fiche.yaml`), par lot (`ecran.lot`) — L174.
|
|
424
|
+
* Un lot sans hypothèse n'y figure pas ; un dossier absent ou une fiche illisible ne donnent rien (le contrôle des fiches est `cadence spec check`).
|
|
425
|
+
*/
|
|
426
|
+
export function ficheHypotheses(root, dir) {
|
|
427
|
+
const out = new Map();
|
|
428
|
+
for (const { doc } of fichesDuDossier(root, dir)) {
|
|
429
|
+
if (!isObj(doc.ecran) || !texte(doc.ecran.lot))
|
|
430
|
+
continue;
|
|
431
|
+
const ids = (Array.isArray(doc.regles) ? doc.regles : []).filter(isObj).filter((r) => r.statut === 'hypothese' && texte(r.id)).map((r) => String(r.id));
|
|
432
|
+
if (ids.length)
|
|
433
|
+
out.set(doc.ecran.lot, [...(out.get(doc.ecran.lot) ?? []), ...ids]);
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
/** Les fiches lisibles de `<dir>/<écran>/fiche.yaml` (écrans triés), avec le nom du dossier : une fiche absente ou illisible est sautée. */
|
|
438
|
+
export function fichesDuDossier(root, dir) {
|
|
439
|
+
const base = join(root, dir);
|
|
440
|
+
const out = [];
|
|
441
|
+
if (!existsSync(base))
|
|
442
|
+
return out;
|
|
443
|
+
for (const name of readdirSync(base).sort()) {
|
|
444
|
+
const file = join(base, name, 'fiche.yaml');
|
|
445
|
+
if (!existsSync(file))
|
|
446
|
+
continue;
|
|
447
|
+
try {
|
|
448
|
+
const doc = parse(readFileSync(file, 'utf8'));
|
|
449
|
+
if (isObj(doc))
|
|
450
|
+
out.push({ name, doc });
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// fiche illisible : le contrôle des fiches est `cadence spec check`
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylad/cadence",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A small, repo-native working method: a versioned plan linked to your commits, a changelog with screenshots, session rituals and deliveries proven by their effect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sylvain Ladoire",
|
|
@@ -23,11 +23,12 @@
|
|
|
23
23
|
"node": ">=20"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"build": "tsc -p tsconfig.build.json --noEmit",
|
|
27
|
+
"service": "node scripts/mise-en-service.mjs",
|
|
27
28
|
"test": "vitest run",
|
|
28
29
|
"typecheck": "tsc --noEmit",
|
|
29
30
|
"eval:plugin": "claude plugin eval . --max-cost-usd 5 --no-publish",
|
|
30
|
-
"prepare": "
|
|
31
|
+
"prepare": "tsc -p tsconfig.build.json",
|
|
31
32
|
"prepublishOnly": "npm run typecheck && npm test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
package/skills/lead/SKILL.md
CHANGED
|
@@ -53,7 +53,7 @@ like (the human's words) and keeps the rest verbatim.
|
|
|
53
53
|
|
|
54
54
|
A lot that adds or changes a screen is `visible`: after the implementation, have the
|
|
55
55
|
`ux-reviewer` agent review it (give it the URL or the way to run the app) and bring its verdict and
|
|
56
|
-
proposed sub-tasks back to the human.
|
|
56
|
+
proposed sub-tasks back to the human — or, where the folder's `cadence.yaml` says `lead.ux: lead`, form the UX verdict yourself (see « The UX verdict » in section 2c).
|
|
57
57
|
|
|
58
58
|
### 2b. A wave through `cadence orchestrate`
|
|
59
59
|
|
|
@@ -92,15 +92,15 @@ and the project declares how to see its app (`orchestrate.ux` in `cadence.yaml`)
|
|
|
92
92
|
(Opus, the `code-reviewer` agent; Sonnet, with no pass on the minor findings, for a lot whose estimate is
|
|
93
93
|
≤ 0.25 d — `orchestrate.review` in `cadence.yaml`; a blocker or major finding still brings a correction and an
|
|
94
94
|
Opus review), a correction in a new session if the review is not compliant (two
|
|
95
|
-
passes at most) — and records `raf review` itself when the code review is compliant. The state is in
|
|
95
|
+
passes at most) — and records `raf review` itself when the code review is compliant. A lot with `spec: true` (no screen) goes `spec → fiche → accept` before the code: the `code-reviewer` reviews its back-end sheet, and the hypotheses of the final sheet come back in the table as « hypothèse de la fiche » lines, which the lead relays to the human. The state is in
|
|
96
96
|
`.cadence/runs/<wave>/`, not in this conversation. `@haiku` only when the human writes it, for a
|
|
97
97
|
mechanical lot. Exit code: 0 all ready · 1 some lots handed back · 2 refused before acting · 3 wave
|
|
98
98
|
suspended (budget or usage limit; `--resume --budget …` continues).
|
|
99
99
|
|
|
100
100
|
What the orchestrator does **not** do, and stays with the lead: choose the lots (with the human), bring the
|
|
101
101
|
questions back (`--resume --answer <project>:<lot> "…"`), look at the UX reviewer's captures and record
|
|
102
|
-
`raf ux` (the orchestrator reports its verdict, it never records it), re-verify (section 3, point 2),
|
|
103
|
-
`raf done`, push and deliver. Minor findings and proposed sub-tasks come back in the table: adding them to
|
|
102
|
+
`raf ux` (the orchestrator reports its verdict, it never records it; who decides it is the `lead.ux` setting, section 2c), re-verify (section 3, point 2),
|
|
103
|
+
`raf done`, push and deliver. **A locked acceptance test that contradicts the sheet** (the lot is handed back with `verrou des tests d'acceptation`, or the session's question says its own test is wrong, e.g. a regex that rejects a sentence the sheet allows) has a path, and it is the lead's, never a session's: correct the test in the working tree, **the sheet unchanged**, then `cadence spec reseal <lot> <test> --reason "why it contradicts the sheet"` (outside any wave): the program redoes the fingerprint of that one test, writes the reason in `acceptation.json` (`resceaux`) and as a note of the lot, commits both, and the lock keeps everything else. It refuses a lot that is not in progress, a changed sheet and any other listed test touched, and during a wave of that repository. It is atomic (a refused commit restores `acceptation.json` and the test), with a plan kept read-only by another tool it leaves the plan untouched and says a note is to be added by hand, with an untracked plan or one outside the repository it writes the note but does not commit it (`plan à commiter`: do not add it again), and if the commit of the note is refused the reseal stays done and the error says to commit the plan by hand. Do it **between two sessions**, never inside one: a resealed commit that belongs to a session of the wave is not adopted (still a lock violation), and sessions are forbidden the command. Then continue the lot: `--resume --answer` when it waits on a `question`; a new wave `cadence orchestrate <project>:<lot>` when it was handed back (`--resume` skips finished lots). Minor findings and proposed sub-tasks come back in the table: adding them to
|
|
104
104
|
the plan is the lead's decision. A minor finding becomes a lot of its own only if it describes an observable bug (a wrong output, a crash, a measured regression); otherwise it stays a note of the originating lot, so that a review never feeds the next one. If an orchestrated wave is running in a repository, do not commit there
|
|
105
105
|
and do not deliver it (`cadence deliver` refuses).
|
|
106
106
|
|
|
@@ -109,7 +109,7 @@ and do not deliver it (`cadence deliver` refuses).
|
|
|
109
109
|
Once the human has given the **first choice and the order** (the lots, and the priority: `--priority` or the
|
|
110
110
|
`priority:` list of the parent folder's `cadence.yaml`), the lead does not come back to ask « next? ». For each lot a
|
|
111
111
|
wave hands back **ready**, it chains, in this order, one project at a time: re-verify it yourself (section 3, point 2),
|
|
112
|
-
`raf done` (a read-only plan: the project's own command; a `visible` lot first waits for the human's UX verdict, `raf ux
|
|
112
|
+
`raf done` (a read-only plan: the project's own command; a `visible` lot first waits for the human's UX verdict, `raf ux` (the lead's own where the UX-verdict setting below hands it over), and is neither closed nor delivered without it — the chain goes on with the other lots), push, deliver (section 4; when the final table gives « livrable jusqu'à <sha> » for a repository — a ready lot under a handed-back one — push that sha alone with `git push origin <sha>:main`, then `cadence deliver --sha <sha>`, never HEAD, which holds the unreviewed code of the lot above), the `qa-reviewer` check that
|
|
113
113
|
section asks for; then it starts the next wave, in the declared priority — `cadence orchestrate --continue --priority …` draws the next ready lot
|
|
114
114
|
of the plan itself (section 2b), and the limits of section « Limits that always apply » still hold. A lot that was not
|
|
115
115
|
handed back ready (refused, review not compliant after the correction passes, red delivery, blocking QA finding) is
|
|
@@ -130,11 +130,29 @@ that was about to start comes first among them. Under it, chain. The lead says i
|
|
|
130
130
|
it is on each time it chains or stops; it never waits for the figure to move (never a polling loop).
|
|
131
131
|
|
|
132
132
|
**What stays with the human**: the first choice and the order; the questions a session raised (the lead brings them
|
|
133
|
-
back at once and does not answer in the human's place); the UX reservations (`raf ux` is recorded on the human's
|
|
134
|
-
verdict
|
|
133
|
+
back at once and does not answer in the human's place); the UX reservations (by default `raf ux` is recorded on the human's
|
|
134
|
+
verdict and a UX reviewer's captures are looked at by the human; with `lead.ux: lead` only a doubt comes back to them, see below). While a question or a UX reservation is pending on a
|
|
135
135
|
lot, the chain goes on with the other lots and does not wait for it. A delivery that needs the human's confirmation
|
|
136
136
|
(their standing instructions, section 4) is a stop of the chain, not a bypass.
|
|
137
137
|
|
|
138
|
+
**The UX verdict.** By default (`lead.ux: human`, or no `lead:` key) the human gives the UX verdict of every `visible` lot.
|
|
139
|
+
Where the `cadence.yaml` of the parent folder says
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
lead:
|
|
143
|
+
ux: lead # lead | human — default human
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
the human has said they are not a UX judge and want to be asked only when in doubt (`cadence lead tour` prints
|
|
147
|
+
`verdict UX : lead …` as its last line when the setting is on, and `cadence.yaml · ✗ erreur : lead.ux …` for a value
|
|
148
|
+
other than `lead` or `human`). The lead then forms the verdict itself: it reads the `ux-reviewer`'s report, **looks at the
|
|
149
|
+
captures itself** (it opens the images, it does not trust the report's words), and records `raf ux <lot> "…"` with the
|
|
150
|
+
reviewer's last line brought up to date. It does so only when the reviewer has no reservation left and the captures show
|
|
151
|
+
what the lot promised. At the **slightest doubt** — a reservation of the reviewer it cannot settle, a capture it cannot read,
|
|
152
|
+
a layout or wording choice that is a matter of taste, a state the captures do not show — it records nothing, brings the
|
|
153
|
+
captures and the question to the human, and the chain goes on with the other lots as for any UX reservation. The verdict
|
|
154
|
+
is always recorded by the lead's own `raf ux`, never by a subagent.
|
|
155
|
+
|
|
138
156
|
## 3. Check
|
|
139
157
|
|
|
140
158
|
0. After an orchestrated wave the review is already done, by a fresh session: read the table, then go to
|
|
@@ -148,11 +166,18 @@ lot, the chain goes on with the other lots and does not wait for it. A delivery
|
|
|
148
166
|
what nobody could verify.
|
|
149
167
|
3. Fix or re-delegate what the review found; then record the verdict, `raf review <lot> "…"` with
|
|
150
168
|
the code reviewer's last line brought up to date (and `raf ux <lot> "…"` for a visible lot, with
|
|
151
|
-
the UX reviewer's), then `raf done <lot>`. Where the gate is on (`raf review enable`, the
|
|
169
|
+
the UX reviewer's; with `lead.ux: lead` the lead has formed that verdict, section 2c), then `raf done <lot>`. Where the gate is on (`raf review enable`, the
|
|
152
170
|
human's decision for each repository), `raf done` refuses a lot that has commits and no
|
|
153
171
|
verdict, or a verdict older than its latest commit: a fix made after the review means a new
|
|
154
172
|
review of the lot, not an edited verdict. A read-only plan has no gate in `raf` (a `uxSince` or `reviewSince` written in it is
|
|
155
173
|
ignored): the verdict goes into the project's own tool.
|
|
174
|
+
On the cadence repository itself, the `raf` and `cadence` of the `PATH` are not the working repository's `dist/`: they
|
|
175
|
+
are a separate, detached copy (`~/.local/share/cadence/service`, built there), so nothing a session or a wave does in the working
|
|
176
|
+
repository (`npm run build`, `npm install`, `npm ci`, uncommitted `src/` files) changes the code in service. Once the verdict is recorded
|
|
177
|
+
and the lot accepted (`raf done`), the lead updates that copy with `npm run service -- <sha>`, where `<sha>` is the accepted code, not HEAD
|
|
178
|
+
when a handed-back lot is stacked above it (the wave's table says « livrable jusqu'à <sha> »). The script refuses a sha that is not an
|
|
179
|
+
ancestor of HEAD, or whose commits cite a lot that is not done; it builds the new copy aside and switches only when the build succeeded.
|
|
180
|
+
A lot found non-compliant is never put in service.
|
|
156
181
|
|
|
157
182
|
## 4. Delivery
|
|
158
183
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Gabarit de l'étape accept d'orchestrate (L173) : à copier dans le projet (ex. tests/acceptation-runner.mjs) et à importer
|
|
2
|
+
// depuis la config du lanceur de tests, PARTAGÉE par `orchestrate.test` et la CI. Voir « Étape accept » dans le README de cadence.
|
|
3
|
+
//
|
|
4
|
+
// vitest.config.ts test: { exclude: [...configDefaults.exclude, ...acceptationIgnored()] }
|
|
5
|
+
// jest.config.js testPathIgnorePatterns: ['/node_modules/', ...acceptationIgnored(process.cwd(), 'docs/specs', 'regex')]
|
|
6
|
+
// vitest.acceptation.config.ts (commande dédiée, orchestrate.accept)
|
|
7
|
+
// test: { include: acceptationOnly() } (une LISTE de lanceurs : chacun ne voit que les fichiers sous sa racine, cf. acceptationOnly)
|
|
8
|
+
//
|
|
9
|
+
// Un acceptation.json à `vert: false` (tests rouges du lot, pas encore implémenté) exclut ses fichiers du lanceur du projet et de la CI ;
|
|
10
|
+
// le cycle pose `vert: true` quand la commande dédiée passe, et les fichiers rejoignent alors le lanceur ordinaire.
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join, posix } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const read = (file) => {
|
|
15
|
+
try {
|
|
16
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
17
|
+
return doc && typeof doc === 'object' && Array.isArray(doc.fichiers) ? doc : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
24
|
+
|
|
25
|
+
/** Fichiers des acceptation-<lot>.json (ou acceptation.json) à `vert: false` sous `<root>/<dir>/*` (un fichier par lot : plusieurs par écran), relatifs à `root` ; `format: 'regex'` pour testPathIgnorePatterns. */
|
|
26
|
+
export function acceptationIgnored(root = process.cwd(), dir = 'docs/specs', format = 'glob') {
|
|
27
|
+
const base = join(root, dir);
|
|
28
|
+
if (!existsSync(base)) return [];
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const name of readdirSync(base).sort()) {
|
|
31
|
+
const folder = join(base, name);
|
|
32
|
+
let files = [];
|
|
33
|
+
try {
|
|
34
|
+
files = readdirSync(folder).filter((f) => /^acceptation(-.+)?\.json$/.test(f)).sort();
|
|
35
|
+
} catch {
|
|
36
|
+
continue; // pas un dossier d'écran
|
|
37
|
+
}
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
const doc = read(join(folder, file));
|
|
40
|
+
if (!doc || doc.vert === true) continue;
|
|
41
|
+
for (const f of doc.fichiers) if (typeof f === 'string') out.push(format === 'regex' ? `${escape(f)}$` : f);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fichiers de l'acceptation.json donné (par défaut celui de `CADENCE_ACCEPTATION`, posé par le cycle) : l'`include` de la config dédiée.
|
|
49
|
+
* Avec une liste de lanceurs (`orchestrate.accept` en liste), chaque lanceur ne prend que les fichiers sous SA racine, le dossier de son
|
|
50
|
+
* `cd <dossier> &&` initial (`CADENCE_ACCEPTATION_ROOT`, posé par le cycle ; '.' ou absent = le dépôt) : ils sont rendus relatifs à cette racine,
|
|
51
|
+
* là où le lanceur est démarré.
|
|
52
|
+
*/
|
|
53
|
+
export function acceptationOnly(file = process.env.CADENCE_ACCEPTATION, root = process.env.CADENCE_ACCEPTATION_ROOT ?? '.') {
|
|
54
|
+
if (!file) throw new Error('CADENCE_ACCEPTATION : chemin de l\'acceptation.json du lot attendu (posé par la commande orchestrate.accept)');
|
|
55
|
+
const doc = read(file);
|
|
56
|
+
if (!doc) throw new Error(`${file} : acceptation.json illisible`);
|
|
57
|
+
const files = doc.fichiers.filter((f) => typeof f === 'string');
|
|
58
|
+
const base = posix.normalize(root.replace(/\\/g, '/')).replace(/\/$/, '');
|
|
59
|
+
if (base === '.' || base === '') return files;
|
|
60
|
+
return files.filter((f) => posix.normalize(f).startsWith(`${base}/`)).map((f) => posix.normalize(f).slice(base.length + 1));
|
|
61
|
+
}
|