@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/dist/spec.js CHANGED
@@ -10,107 +10,237 @@ const KEBAB = /^[a-z][a-z0-9-]*$/;
10
10
  const APPEL = /^(GET|POST|PUT|PATCH|DELETE) \/\S*$/;
11
11
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
12
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
+ }
13
26
  /**
14
- * Les écarts d'une fiche d'écran aux invariants de la spec de l'étape spec (une phrase par écart ; vide = valide) :
15
- * ids de champs uniques, règles R1… sans trou, statut et justification, états connus, tests « Rn — … »,
16
- * citations d'ids existants (actions, règles, masque).
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 — … ».
17
32
  */
18
33
  export function checkFiche(doc) {
19
34
  if (!isObj(doc))
20
- return ["ce n'est pas une fiche : un document YAML à clés (ecran, etats, champs, regles, actions) est attendu"];
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)"];
21
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)');
22
41
  const ecran = isObj(doc.ecran) ? doc.ecran : {};
23
- if (!isObj(doc.ecran))
24
- p.push('ecran : bloc manquant');
25
- for (const k of ['id', 'titre', 'route', 'lot', 'maquette'])
26
- if (!texte(ecran[k]))
27
- p.push(`ecran.${k} : texte attendu`);
28
- if (texte(ecran.route) && !ecran.route.startsWith('/'))
29
- p.push(`ecran.route : « ${ecran.route} » doit commencer par /`);
30
- if (!Array.isArray(ecran.largeurs) || LARGEURS.some((w) => !ecran.largeurs.includes(w)))
31
- p.push(`ecran.largeurs : [${LARGEURS.join(', ')}] attendu`);
32
- const etats = Array.isArray(doc.etats) ? doc.etats.filter(texte) : [];
33
- if (!Array.isArray(doc.etats))
34
- p.push('etats : liste attendue');
35
- else {
36
- for (const [i, e] of doc.etats.entries())
37
- if (!texte(e))
38
- p.push(`état n° ${i + 1} : texte attendu`);
39
- const manquants = ETATS_REQUIS.filter((e) => !etats.includes(e));
40
- if (manquants.length)
41
- p.push(`états : ${manquants.map((e) => `« ${e} »`).join(', ')} manquant(s) (vide, chargement et erreur sont requis)`);
42
- for (const e of new Set(etats.filter((x, i) => etats.indexOf(x) !== i)))
43
- p.push(`états : « ${e} » en double`);
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`);
44
52
  }
45
- const champs = Array.isArray(doc.champs) ? doc.champs : [];
46
- if (champs.length === 0)
47
- p.push('champs : liste non vide attendue');
48
- const ids = new Set();
49
- for (const [i, c] of champs.entries()) {
50
- if (!isObj(c)) {
51
- p.push(`champ n° ${i + 1} : objet attendu`);
52
- continue;
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');
53
73
  }
54
- const nom = texte(c.id) ? `« ${c.id} »` : `n° ${i + 1}`;
55
- if (!texte(c.id))
56
- p.push(`champ ${nom} : id manquant`);
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');
57
93
  else {
58
- if (!KEBAB.test(c.id))
59
- p.push(`champ ${nom} : l'id doit être en kebab-case (c'est aussi le data-testid)`);
60
- if (ids.has(c.id))
61
- p.push(`champ ${nom} en double`);
62
- ids.add(c.id);
63
- }
64
- for (const k of ['libelle', 'type', 'source'])
65
- if (!texte(c[k]))
66
- p.push(`champ ${nom} : ${k} manquant`);
67
- if (typeof c.obligatoire !== 'boolean')
68
- p.push(`champ ${nom} : obligatoire doit valoir true ou false`);
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
+ }
69
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).
70
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
+ }
71
177
  if (regles.length === 0)
72
178
  p.push('regles : liste non vide attendue');
73
- const ruleIds = new Set();
74
179
  for (const [i, r] of regles.entries()) {
75
180
  const attendu = `R${i + 1}`;
76
181
  if (!isObj(r)) {
77
182
  p.push(`règle n° ${i + 1} : objet attendu`);
78
- ruleIds.add(attendu);
79
183
  continue;
80
184
  }
81
185
  const id = texte(r.id) ? r.id : attendu;
82
186
  if (r.id !== attendu)
83
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'}`);
84
- ruleIds.add(id);
85
- if (!texte(r.etat) || !etats.includes(r.etat))
86
- p.push(`${id} : état « ${String(r.etat ?? '')} » inconnu (états : ${etats.join(', ')})`);
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
+ }
87
207
  if (!texte(r.enonce))
88
208
  p.push(`${id} : enonce manquant`);
89
- if (!('message' in r) || !(r.message === null || typeof r.message === 'string'))
90
- p.push(`${id} : message attendu (le texte exact à l'écran, ou null)`);
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
+ }
91
219
  if (r.statut !== 'sourcee' && r.statut !== 'hypothese')
92
220
  p.push(`${id} : statut « ${String(r.statut ?? '')} » inconnu (sourcee ou hypothese)`);
93
221
  else if (r.statut === 'sourcee' && !texte(r.source))
94
222
  p.push(`${id} : une règle sourcée dit d'où elle vient (source)`);
95
223
  else if (r.statut === 'hypothese' && !texte(r.si_faux))
96
224
  p.push(`${id} : une hypothèse dit ce qui changerait si elle était fausse (si_faux)`);
97
- if ('champs' in r && r.champs !== undefined && r.champs !== null && !Array.isArray(r.champs))
98
- p.push(`${id} : champs doit être une liste d'ids de champs`);
99
- const cites = Array.isArray(r.champs) ? r.champs : [];
100
- for (const c of cites)
101
- if (!ids.has(String(c)))
102
- p.push(`${id} : champ « ${String(c)} » absent de la fiche`);
103
- if ('masque' in r && r.masque !== undefined && r.masque !== null) {
104
- if (!Array.isArray(r.masque))
105
- p.push(`${id} : masque doit être une liste d'ids de champs`);
106
- else {
107
- if (r.etat !== 'vide')
108
- p.push(`${id} : masque n'a de sens que pour une règle d'état « vide » (état « ${String(r.etat)} »)`);
109
- for (const m of r.masque) {
110
- if (!ids.has(String(m)))
111
- p.push(`${id} : masque cite le champ « ${String(m)} » absent de la fiche`);
112
- else if (cites.includes(m))
113
- p.push(`${id} : masque cite « ${String(m)} » que la règle fait apparaître dans champs`);
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
+ }
114
244
  }
115
245
  }
116
246
  }
@@ -121,9 +251,13 @@ export function checkFiche(doc) {
121
251
  if (typeof t !== 'string' || !t.startsWith(`${id} — `))
122
252
  p.push(`${id} : le test « ${String(t)} » doit être nommé « ${id} — … »`);
123
253
  }
124
- const actions = Array.isArray(doc.actions) ? doc.actions : [];
125
- if (!Array.isArray(doc.actions))
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))
126
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;
127
261
  for (const [i, a] of actions.entries()) {
128
262
  const attendu = `A${i + 1}`;
129
263
  if (!isObj(a)) {
@@ -131,24 +265,115 @@ export function checkFiche(doc) {
131
265
  continue;
132
266
  }
133
267
  const id = texte(a.id) ? a.id : attendu;
134
- if (a.id !== attendu)
268
+ if (!commun && a.id !== attendu)
135
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`);
136
272
  if (a.declencheur !== null && a.declencheur !== undefined && !(texte(a.declencheur) && ids.has(a.declencheur)))
137
273
  p.push(`${id} : déclencheur « ${String(a.declencheur)} » absent de la fiche (un id de champ, ou null pour le chargement)`);
138
- if (!texte(a.appel) || !APPEL.test(a.appel))
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))
139
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
+ }
140
287
  if ('regles' in a && a.regles !== undefined && a.regles !== null && !Array.isArray(a.regles))
141
288
  p.push(`${id} : regles doit être une liste d'ids de règles`);
142
289
  for (const r of Array.isArray(a.regles) ? a.regles : [])
143
290
  if (!ruleIds.has(String(r)))
144
291
  p.push(`${id} : règle « ${String(r)} » absente de la fiche`);
145
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
+ }
146
364
  return p;
147
365
  }
148
366
  /** Résumé d'une fiche valide, pour la sortie de `cadence spec check`. */
149
367
  export function summary(doc) {
150
368
  const n = (k) => (Array.isArray(doc[k]) ? doc[k].length : 0);
151
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`;
152
377
  return `fiche valide : ${n('champs')} champs, ${n('regles')} règles (${hyp} hypothèses), ${n('actions')} actions`;
153
378
  }
154
379
  /** Lit une fiche YAML ; un fichier absent ou illisible est une erreur d'usage (code 2), pas un écart de la fiche. */
@@ -175,6 +400,8 @@ export function deriveQa(doc) {
175
400
  const problems = checkFiche(doc);
176
401
  if (problems.length > 0)
177
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");
178
405
  const f = doc;
179
406
  const vides = f.regles.filter((r) => r.etat === 'vide' && r.statut === 'sourcee');
180
407
  const propres = new Set(f.regles.filter((r) => r.etat === 'vide' || r.etat === 'erreur').flatMap((r) => r.champs ?? []));
@@ -186,9 +413,9 @@ export function deriveQa(doc) {
186
413
  const messages = f.regles.filter((r) => r.etat === 'erreur' && typeof r.message === 'string' && r.message !== '').map((r) => `"${r.message}"`);
187
414
  if (messages.length)
188
415
  out.push(`- never: ${messages.join(', ')}`);
189
- for (const a of f.actions.filter((x) => (x.declencheur === null || x.declencheur === undefined) && x.appel.startsWith('GET '))) {
416
+ for (const a of f.actions.filter((x) => (x.declencheur === null || x.declencheur === undefined) && appelDe(x, f.contrats).startsWith('GET '))) {
190
417
  const peut = vides.filter((r) => (a.regles ?? []).includes(r.id)).map((r) => condition(r.enonce));
191
- out.push(`- api: ${a.appel.slice(4)}${peut.length ? ` — may be empty when ${peut.join(' ; ')}` : ''}`);
418
+ out.push(`- api: ${appelDe(a, f.contrats).slice(4)}${peut.length ? ` — may be empty when ${peut.join(' ; ')}` : ''}`);
192
419
  }
193
420
  return `${out.join('\n')}\n`;
194
421
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylad/cadence",
3
- "version": "1.1.0",
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": "npm run build",
31
+ "prepare": "tsc -p tsconfig.build.json",
31
32
  "prepublishOnly": "npm run typecheck && npm test"
32
33
  },
33
34
  "dependencies": {
@@ -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`, 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
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, a UX reviewer's captures are looked at by the human). While a question or a UX reservation is pending on a
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
 
@@ -4,12 +4,12 @@
4
4
  // vitest.config.ts test: { exclude: [...configDefaults.exclude, ...acceptationIgnored()] }
5
5
  // jest.config.js testPathIgnorePatterns: ['/node_modules/', ...acceptationIgnored(process.cwd(), 'docs/specs', 'regex')]
6
6
  // vitest.acceptation.config.ts (commande dédiée, orchestrate.accept)
7
- // test: { include: acceptationOnly() }
7
+ // test: { include: acceptationOnly() } (une LISTE de lanceurs : chacun ne voit que les fichiers sous sa racine, cf. acceptationOnly)
8
8
  //
9
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
10
  // le cycle pose `vert: true` quand la commande dédiée passe, et les fichiers rejoignent alors le lanceur ordinaire.
11
11
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
- import { join } from 'node:path';
12
+ import { join, posix } from 'node:path';
13
13
 
14
14
  const read = (file) => {
15
15
  try {
@@ -44,10 +44,18 @@ export function acceptationIgnored(root = process.cwd(), dir = 'docs/specs', for
44
44
  return out;
45
45
  }
46
46
 
47
- /** 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. */
48
- export function acceptationOnly(file = process.env.CADENCE_ACCEPTATION) {
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 ?? '.') {
49
54
  if (!file) throw new Error('CADENCE_ACCEPTATION : chemin de l\'acceptation.json du lot attendu (posé par la commande orchestrate.accept)');
50
55
  const doc = read(file);
51
56
  if (!doc) throw new Error(`${file} : acceptation.json illisible`);
52
- return doc.fichiers.filter((f) => typeof f === 'string');
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));
53
61
  }