@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.
@@ -5,9 +5,9 @@ import { parse } from 'yaml';
5
5
  import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
6
6
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
7
7
  const list = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
8
- /** Chemin (relatif au dépôt) de la fiche du lot sous `<dir>/<écran>/fiche.yaml`, ou null : sert à ne pas rejouer une spec déjà écrite. */
8
+ /** Chemin (relatif au dépôt) de la fiche du lot sous `<dir>/<écran ou sujet>/fiche.yaml` (`ecran.lot` ou `sujet.lot`), ou null : sert à ne pas rejouer une spec déjà écrite. */
9
9
  export function findFiche(repo, dir, lot) {
10
- const hit = fichesDuDossier(repo, dir).find(({ doc }) => isObj(doc.ecran) && doc.ecran.lot === lot);
10
+ const hit = fichesDuDossier(repo, dir).find(({ doc }) => (isObj(doc.ecran) && doc.ecran.lot === lot) || (isObj(doc.sujet) && doc.sujet.lot === lot));
11
11
  return hit ? `${dir}/${hit.name}/fiche.yaml` : null;
12
12
  }
13
13
  /** Texte sans accents ni majuscules, pour les mots-clés. */
@@ -40,9 +40,19 @@ export function costlyHypotheses(doc) {
40
40
  }
41
41
  return out;
42
42
  }
43
+ /** La fiche écrite porte une partie back (un bloc `sujet`) : c'est ce qui fait d'un lot à `screen:` un lot mixte (L188), jamais un champ `spec: true` à côté de `screen:`. */
44
+ export function ficheHasBack(repo, fiche) {
45
+ try {
46
+ const doc = parse(readFileSync(join(repo, fiche), 'utf8'));
47
+ return isObj(doc) && 'sujet' in doc;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
43
53
  /** Une fiche déjà écrite ne dispense de la spec que si elle passe les contrôles du programme et ne porte aucune hypothèse coûteuse. */
44
- export function ficheReady(repo, fiche, lot) {
45
- if (checkSpec(repo, fiche, lot).length)
54
+ export function ficheReady(repo, fiche, lot, parts = { screen: true, back: false }) {
55
+ if (checkSpec(repo, fiche, lot, parts).length)
46
56
  return false;
47
57
  try {
48
58
  return costlyHypotheses(parse(readFileSync(join(repo, fiche), 'utf8'))).length === 0;
@@ -74,10 +84,10 @@ function tracked(repo, dir) {
74
84
  * elle est celle du lot et de son dossier, la maquette existe avec une section `#etat-<état>` par état et un
75
85
  * `data-testid` par champ, et chaque état a sa capture à chaque largeur — le tout commité.
76
86
  */
77
- export function checkSpec(repo, fiche, lot) {
87
+ export function checkSpec(repo, fiche, lot, parts = { screen: true, back: false }) {
78
88
  const file = join(repo, fiche);
79
89
  if (!existsSync(file))
80
- return [`${fiche} : fiche absente (écrire docs/specs/<écran>/fiche.yaml et la commiter)`];
90
+ return [`${fiche} : fiche absente (écrire ${fiche.replace(/[^/]+\/fiche\.yaml$/, parts.screen ? '<écran>' : '<sujet>')}/fiche.yaml et la commiter)`];
81
91
  let doc;
82
92
  try {
83
93
  doc = parse(readFileSync(file, 'utf8'));
@@ -86,9 +96,30 @@ export function checkSpec(repo, fiche, lot) {
86
96
  return [`${fiche} : YAML illisible (${e.message.split('\n')[0]})`];
87
97
  }
88
98
  const problems = checkFiche(doc).map((p) => `fiche : ${p}`);
89
- if (!isObj(doc) || !isObj(doc.ecran))
99
+ if (!isObj(doc))
90
100
  return problems;
91
101
  const folder = fiche.replace(/\/fiche\.yaml$/, '');
102
+ // un lot back (`spec: true`, avec ou sans écran) écrit la partie sujet : sans elle, ni lot ni dossier ne seraient contrôlés
103
+ if (parts.back) {
104
+ if (!isObj(doc.sujet)) {
105
+ if (!('sujet' in doc) && 'ecran' in doc)
106
+ problems.push('fiche : sujet : bloc manquant (un lot qui change un comportement écrit la partie sujet, même si la fiche a aussi un écran)');
107
+ }
108
+ else {
109
+ const sujet = doc.sujet;
110
+ if (typeof sujet.lot === 'string' && sujet.lot !== lot)
111
+ problems.push(`sujet.lot : « ${sujet.lot} » au lieu de ${lot} (la fiche est celle du lot qui implémente le comportement)`);
112
+ if (typeof sujet.id === 'string' && !folder.endsWith(`/${sujet.id}`))
113
+ problems.push(`sujet.id : « ${sujet.id} » ne correspond pas au dossier ${folder}`);
114
+ if (!parts.screen && !tracked(repo, folder).has(fiche))
115
+ problems.push(`${fiche} : fiche non commité`);
116
+ }
117
+ }
118
+ if (!parts.screen)
119
+ return problems;
120
+ // un lot à écran écrit la partie écran : sans elle, ni lot, ni dossier, ni maquette, ni captures ne seraient contrôlés
121
+ if (!isObj(doc.ecran))
122
+ return [...problems.filter((p) => p !== 'fiche : ecran : bloc manquant'), 'fiche : ecran : bloc manquant (un lot à écran écrit la partie écran, même si la fiche a aussi un sujet)'];
92
123
  const ecran = doc.ecran;
93
124
  if (typeof ecran.lot === 'string' && ecran.lot !== lot)
94
125
  problems.push(`ecran.lot : « ${ecran.lot} » au lieu de ${lot} (la fiche est celle du lot qui implémente l'écran)`);
@@ -121,17 +152,43 @@ export function checkSpec(repo, fiche, lot) {
121
152
  need(`${folder}/maquette-${e}-${w}.png`, 'capture');
122
153
  return problems;
123
154
  }
155
+ /** Première phrase d'un texte, ramenée à une longueur lisible dans une note. */
156
+ const sentence = (t, max = 160) => {
157
+ const flat = t.replace(/\s+/g, ' ').trim();
158
+ const end = flat.search(/[.!?](\s|$)/);
159
+ const first = end > 0 ? flat.slice(0, end) : flat;
160
+ return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
161
+ };
162
+ /** Une ligne par règle `hypothese` de la fiche (`R3 : ce que la règle dit ; si faux : ce qui changerait`), pour la note du lot et le tableau de fin de vague (spec etape-spec-back § 7). */
163
+ export function hypothesisLines(doc) {
164
+ if (!isObj(doc))
165
+ return [];
166
+ return list(doc.regles)
167
+ .filter((r) => r.statut === 'hypothese' && typeof r.id === 'string')
168
+ .map((r) => `${String(r.id)} : ${sentence(String(r.enonce ?? ''))} ; si faux : ${sentence(String(r.si_faux ?? ''))}`);
169
+ }
170
+ /** Texte de la note du lot pour une hypothèse (une note par règle). */
171
+ export const hypothesisNote = (sujet, line) => `Hypothèses (fiche ${sujet}) — ${line}`;
124
172
  /** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
125
173
  export function specText(o) {
126
- const lines = [
127
- `The lot declares \`screen: ${o.screen ?? 'page'}\`. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`,
128
- `Render each state of the mockup at ${LARGEURS.map((w) => `${w}`).join(' and ')} px width: one capture per state and per width, named \`maquette-<état>-<largeur>.png\` (e.g. \`maquette-vide-1440.png\`).`,
129
- o.render
174
+ const lines = [];
175
+ if (o.screen) {
176
+ lines.push(`The lot declares \`screen: ${o.screen}\`. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`, `Render each state of the mockup at ${LARGEURS.map((w) => `${w}`).join(' and ')} px width: one capture per state and per width, named \`maquette-<état>-<largeur>.png\` (e.g. \`maquette-vide-1440.png\`).`, o.render
130
177
  ? `The project declares how to render mockups (\`orchestrate.spec.render\`): \`${o.render}\` (from the repository root). Use it; fall back on Playwright only if it cannot run, and say so under "nonVerifie".`
131
- : 'No render command is declared (`orchestrate.spec.render`): render with Playwright (open `maquette.html` as a local file, resize to each width, open each `#etat-<état>`).',
132
- ];
178
+ : 'No render command is declared (`orchestrate.spec.render`): render with Playwright (open `maquette.html` as a local file, resize to each width, open each `#etat-<état>`).');
179
+ }
180
+ if (o.back) {
181
+ lines.push(o.screen
182
+ ? `The sheet has both parts, \`sujet\`, \`cas\`, \`contrats\`, \`donnees\` next to \`ecran\`, in the same folder (\`sujet.id\` = \`ecran.id\` = the folder name, \`sujet.lot\` = \`ecran.lot\`); the API calls of the screen are listed once in \`contrats\`, and the actions point to them with \`contrat: Ax\`.`
183
+ : `The lot declares \`spec: true\` (it changes a behaviour, with no screen). The specs of this project live in \`${o.dir}/<sujet>/\` (one folder per subject, \`<sujet>\` = the kebab-case \`sujet.id\` of the sheet); the sheet has no screen part.`);
184
+ }
185
+ if (o.screen && !o.back) {
186
+ lines.push(`If the lot also changes a behaviour (an API call, a stored datum, a contract), add the back part to the same sheet: \`sujet\`, \`cas\`, \`contrats\`, \`donnees\` next to \`ecran\` (\`sujet.id\` = \`ecran.id\` = the folder name, \`sujet.lot\` = \`ecran.lot\`); the program then also has the sheet reviewed (rules sourced, contradictions, errors and limits, one test per rule), before the mockup. A lot that only draws a screen keeps a sheet without \`sujet\`.`);
187
+ }
133
188
  if (o.template)
134
- lines.push('', 'Template of the sheet (`templates/fiche.yaml` of cadence; every key and invariant is checked by `cadence spec check`):', '```yaml', o.template.trimEnd(), '```');
189
+ lines.push('', 'Template of the screen part of the sheet (`templates/fiche.yaml` of cadence; every key and invariant is checked by `cadence spec check`):', '```yaml', o.template.trimEnd(), '```');
190
+ if (o.templateBack)
191
+ lines.push('', 'Template of the back part of the sheet (`templates/fiche-back.yaml` of cadence; every key and invariant is checked by `cadence spec check`):', '```yaml', o.templateBack.trimEnd(), '```');
135
192
  if (o.problems?.length)
136
193
  lines.push('', 'The program checked your previous spec and it did not pass; fix exactly these points (one more pass only, then the lot goes back to the lead):', ...o.problems.map((p) => `- ${p}`));
137
194
  if (o.review)
@@ -11,8 +11,8 @@ export const lotRepoPaths = (l) => [l.repo, ...(l.repos ?? []).map((r) => r.path
11
11
  /** Les dépôts voisins du lot, donnés en `--add-dir` à ses sessions. */
12
12
  export const neighbourDirs = (l) => (l.repos ?? []).map((r) => r.path);
13
13
  export const lotKey = (project, lot) => `${project}:${lot}`;
14
- /** Nom de dossier d'un lot dans la vague : le « / » d'un id de sous-tâche (`Q4/accueil-4-ux12`) devient « __ » ; un id sans « / » est inchangé. */
15
- export const lotSlug = (project, lot) => `${project}--${lot.split('/').join('__')}`;
14
+ /** Nom de dossier d'un lot dans la vague : le « / » d'un id de sous-tâche (`Q4/accueil-4-ux12`) ou d'un projet imbriqué (`groupe/gamma`) devient « __ » ; sans « / » le nom est inchangé. */
15
+ export const lotSlug = (project, lot) => `${project.split('/').join('__')}--${lot.split('/').join('__')}`;
16
16
  const lotFile = (project, lot) => `${lotSlug(project, lot)}.json`;
17
17
  function atomicWrite(file, data) {
18
18
  const tmp = `${file}.${process.pid}.tmp`;
@@ -123,6 +123,8 @@ export function renderTable(wave, lots, deps = {}) {
123
123
  out.push(`${l.project}:${l.lot} — ⚠ ${w}`);
124
124
  for (const x of l.choix ?? [])
125
125
  out.push(`${l.project}:${l.lot} — choix fait : ${x}`);
126
+ for (const h of l.hypotheses ?? [])
127
+ out.push(`${l.project}:${l.lot} — hypothèse de la fiche : ${h}`);
126
128
  for (const p of l.proposals)
127
129
  out.push(`${l.project}:${l.lot} — proposé : ${p}`);
128
130
  if (l.uxVerdict && l.visible)
package/dist/plan.js CHANGED
@@ -5,7 +5,7 @@ import { isDay, toDay } from './dates.js';
5
5
  export const STATUSES = ['todo', 'doing', 'done', 'dropped'];
6
6
  export class RafError extends Error {
7
7
  }
8
- export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent'];
8
+ export const FIELDS = ['id', 'title', 'status', 'estimate', 'quickwin', 'visible', 'screen', 'spec', 'public', 'every', 'last', 'after', 'created', 'started', 'finished', 'notes', 'repos', 'parent'];
9
9
  /** Nature de l'écran d'un lot visible : nouvelle page, nouveau composant, parcours modifié. */
10
10
  export const SCREENS = ['page', 'component', 'journey'];
11
11
  export function isOpen(status) {
@@ -319,6 +319,8 @@ export class Plan {
319
319
  entry.visible = true;
320
320
  if (opts.screen)
321
321
  entry.screen = opts.screen;
322
+ if (opts.spec)
323
+ entry.spec = true;
322
324
  if (opts.every !== undefined)
323
325
  entry.every = opts.every;
324
326
  if (opts.after?.length)
@@ -414,6 +416,51 @@ export class Plan {
414
416
  const at = node.items.findIndex((p) => String(p.key?.value ?? p.key) === 'title');
415
417
  node.items.splice(at + 1, 0, this.doc.createPair('public', value));
416
418
  }
419
+ /** Pose, remplace (nature) ou retire (null) le `screen:` d'un lot existant ; exige `visible`, comme `add`. */
420
+ setScreen(ref, screen) {
421
+ this.writable();
422
+ if (ref.includes('/'))
423
+ throw new RafError(`l'écran se pose sur un lot, pas une sous-tâche : ${ref}`);
424
+ const node = this.lotNode(ref);
425
+ if (screen === null) {
426
+ node.delete('screen');
427
+ return;
428
+ }
429
+ if (!SCREENS.includes(screen))
430
+ throw new RafError(`screen inconnu : ${String(screen)} (${SCREENS.join(', ')}, none)`);
431
+ if (this.lot(ref).visible !== true)
432
+ throw new RafError(`${ref} n'est pas visible : un écran est un changement visible (raf add --visible)`);
433
+ if (node.has('screen')) {
434
+ node.set('screen', screen);
435
+ return;
436
+ }
437
+ const keys = node.items.map((p) => String(p.key?.value ?? p.key));
438
+ node.items.splice(keys.indexOf('visible') + 1, 0, this.doc.createPair('screen', screen));
439
+ }
440
+ /** Pose (true) ou retire (false) le `spec:` d'un lot existant ; `screen:` l'implique, donc on ne retire pas la spec d'un lot à écran. Renvoie false quand `screen:` rendait la pose sans objet. */
441
+ setSpec(ref, on) {
442
+ this.writable();
443
+ if (ref.includes('/'))
444
+ throw new RafError(`la spec se pose sur un lot, pas une sous-tâche : ${ref}`);
445
+ const node = this.lotNode(ref);
446
+ if (this.lot(ref).screen) {
447
+ if (!on)
448
+ throw new RafError(`${ref} a screen: — retirer d'abord screen: (raf screen ${ref} none), l'écran implique la spec`);
449
+ return false;
450
+ }
451
+ if (!on) {
452
+ node.delete('spec');
453
+ return true;
454
+ }
455
+ if (node.has('spec')) {
456
+ node.set('spec', true);
457
+ return true;
458
+ }
459
+ const keys = node.items.map((p) => String(p.key?.value ?? p.key));
460
+ const at = Math.max(...['screen', 'visible', 'quickwin', 'estimate'].map((k) => keys.indexOf(k)));
461
+ node.items.splice(at + 1, 0, this.doc.createPair('spec', true));
462
+ return true;
463
+ }
417
464
  /** Un lot récurrent a été refait : `last` passe à aujourd'hui, avec une note si `text` est donné. */
418
465
  did(ref, today, text) {
419
466
  this.writable();
@@ -483,6 +530,8 @@ function foreignLots(raw, format) {
483
530
  quickwin: pick('quickwin'),
484
531
  visible: pick('visible'),
485
532
  screen: pick('screen'),
533
+ // Le `spec:` d'un plan tenu par un autre outil n'est à nous que s'il est mappé, et seul `true` compte : ailleurs c'est souvent un chemin de document (L198).
534
+ spec: format.fields.spec && pick('spec') === true ? true : undefined,
486
535
  public: pick('public'),
487
536
  every: pick('every'),
488
537
  last: day('last'),
@@ -539,6 +588,13 @@ function normalizeLot(raw, problems = []) {
539
588
  if (screen && raw.visible !== true)
540
589
  problems.push(`screen « ${screen} » sans visible : un écran est un changement visible`);
541
590
  }
591
+ let spec;
592
+ if (raw.spec != null) {
593
+ if (raw.spec === true)
594
+ spec = true;
595
+ else if (raw.spec !== false)
596
+ problems.push(`spec : true ou false attendu (reçu « ${String(raw.spec)} »)`);
597
+ }
542
598
  return {
543
599
  id: String(raw.id),
544
600
  title: String(raw.title ?? ''),
@@ -547,6 +603,7 @@ function normalizeLot(raw, problems = []) {
547
603
  quickwin: raw.quickwin === true,
548
604
  visible: raw.visible === true,
549
605
  ...(screen ? { screen } : {}),
606
+ ...(spec ? { spec } : {}),
550
607
  ...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
551
608
  ...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
552
609
  ...(last ? { last } : {}),