@sylad/cadence 1.40.0 → 1.41.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.
@@ -36,7 +36,7 @@ import { lotIds, referenceIdsText } from '../ref/coverage.js';
36
36
  import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
37
37
  import { ficheReviewText, reviewedSheetText } from './fiche.js';
38
38
  import { bulletLines, maquetteText, reviewedText } from './maquette.js';
39
- import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
39
+ import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, lightLot, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
40
40
  import { handBackWithoutFinding } from './no-finding.js';
41
41
  import { decisionsText, humanDecisions } from './decisions.js';
42
42
  import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, expressSummary, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
@@ -439,24 +439,19 @@ export function briefName(l, kind, integration = false) {
439
439
  return 'review-recheck'; // la revue d'intégration (R17) garde le gabarit qui porte {{integration}}
440
440
  return kind;
441
441
  }
442
- /** Gabarit de fiche livré avec le paquet, joint au brief de la spec (la session ne lit pas le dossier du paquet). */
443
- function ficheTemplate() {
442
+ /** Fichier livré avec le paquet à côté des gabarits, joint à un brief (la session ne lit pas le dossier du paquet) ; undefined s'il manque. */
443
+ function packageFile(name) {
444
444
  try {
445
- return readFileSync(join(TEMPLATES_DIR, '..', 'fiche.yaml'), 'utf8');
445
+ return readFileSync(join(TEMPLATES_DIR, '..', name), 'utf8');
446
446
  }
447
447
  catch {
448
448
  return undefined;
449
449
  }
450
450
  }
451
+ /** Gabarit de fiche livré avec le paquet, joint au brief de la spec. */
452
+ const ficheTemplate = () => packageFile('fiche.yaml');
451
453
  /** Gabarit de la partie back de la fiche (L187), joint au brief de la spec d'un lot `spec: true`. */
452
- function ficheBackTemplate() {
453
- try {
454
- return readFileSync(join(TEMPLATES_DIR, '..', 'fiche-back.yaml'), 'utf8');
455
- }
456
- catch {
457
- return undefined;
458
- }
459
- }
454
+ const ficheBackTemplate = () => packageFile('fiche-back.yaml');
460
455
  /** Le lot passe par l'étape spec : il déclare `screen:` (écran) ou `spec: true` (comportement sans écran, L188). */
461
456
  const hasSpecStage = (l) => !!(l.screen || l.specLot);
462
457
  /**
@@ -465,14 +460,7 @@ const hasSpecStage = (l) => !!(l.screen || l.specLot);
465
460
  */
466
461
  const partsOf = (c, fiche) => ({ screen: !!c.lot.screen, back: !!c.lot.specLot || (!!c.lot.screen && !!fiche && ficheHasBack(wd(c), fiche)) });
467
462
  /** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
468
- function acceptRunner() {
469
- try {
470
- return readFileSync(join(TEMPLATES_DIR, '..', 'acceptation-runner.mjs'), 'utf8');
471
- }
472
- catch {
473
- return undefined;
474
- }
475
- }
463
+ const acceptRunner = () => packageFile('acceptation-runner.mjs');
476
464
  /** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
477
465
  function ficheOf(c) {
478
466
  const l = c.lot;
@@ -543,7 +531,7 @@ function briefFor(c, kind, local = false) {
543
531
  vars.spec = specText({
544
532
  dir: c.config.spec.dir,
545
533
  render: c.config.spec.render,
546
- screen: l.screen,
534
+ screen: l.screen, light: lightLot(l, lot, c.config.maquette, wd(c), c.config.spec.dir),
547
535
  back,
548
536
  problems: l.specProblems,
549
537
  review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen, session) : reviewedText(reviewed, session),
@@ -562,7 +550,7 @@ function briefFor(c, kind, local = false) {
562
550
  vars.reponse = '';
563
551
  }
564
552
  if (kind === 'maquette') {
565
- vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats });
553
+ vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats, light: lightLot(l, lot, c.config.maquette, wd(c), c.config.spec.dir) });
566
554
  vars.reponse = '';
567
555
  }
568
556
  if (kind === 'accept') {
@@ -1455,6 +1443,9 @@ function afterMaquette(c, fiche) {
1455
1443
  // Des constats de la revue de fiche attendent la revue de la maquette (lot mixte) : les deux partent ensemble à la seconde passe `spec`.
1456
1444
  if (l.ficheConstats?.length)
1457
1445
  return 'spec';
1446
+ // Une maquette légère n'a pas de règles de comportement à verrouiller : pas d'accept, même si orchestrate.accept est déclaré.
1447
+ if (lightLot(l, c.loadPlan().lot(l.lot), c.config.maquette, wd(c), c.config.spec.dir))
1448
+ return 'implement';
1458
1449
  if (!c.config.accept) {
1459
1450
  if (!l.warnings.includes(NO_ACCEPT))
1460
1451
  l.warnings.push(NO_ACCEPT);
@@ -6,6 +6,7 @@ import { sharedStateDir } from '../state.js';
6
6
  import { releaseLock, takeLock } from './lock.js';
7
7
  import { mergeUnreleased, moveToUnreleased, settleLotEntries, unreleasedSections } from './changelog-merge.js';
8
8
  import { mutexFor } from './mutex.js';
9
+ import { readPlanConfig } from '../config.js';
9
10
  import { Plan } from '../plan.js';
10
11
  import { parseDocument } from 'yaml';
11
12
  import { git, gitFailure, tryGit } from './worktree.js';
@@ -91,11 +92,15 @@ function resolvePlan(work) {
91
92
  return false;
92
93
  const file = files[0];
93
94
  try {
95
+ // Les champs d'extension déclarés (L464) sont des propriétés des lots : sans les réglages, `bare` ne verrait pas un champ changé.
96
+ // Seuls les champs d'extension sont passés : le `plan.onWrite` du projet ne doit pas se lancer pendant un rebase.
97
+ const extras = readPlanConfig(join(work, 'cadence.yaml'), work)?.settings.extras;
98
+ const settings = extras ? { extras } : {};
94
99
  const stage = (n) => {
95
100
  const text = run(work, 'show', `:${n}:${file}`);
96
101
  writeFileSync(join(work, file), text);
97
102
  const head = parseDocument(text).toJSON();
98
- return { lots: Plan.load(join(work, file)).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
103
+ return { lots: Plan.load(join(work, file), settings).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
99
104
  };
100
105
  const [b, o, t] = [stage(1), stage(2), stage(3)];
101
106
  const [base, ours, theirs] = [b.lots, o.lots, t.lots];
@@ -109,7 +114,7 @@ function resolvePlan(work) {
109
114
  if (added.some((t) => review(ours.find((o) => o.id === t.id)) !== review(base.find((b) => b.id === t.id))))
110
115
  return false;
111
116
  writeFileSync(join(work, file), run(work, 'show', `:2:${file}`));
112
- const plan = Plan.load(join(work, file));
117
+ const plan = Plan.load(join(work, file), settings);
113
118
  for (const t of added)
114
119
  plan.recordReview(t.id, t.review.verdict, t.review.date, t.review.commit ?? null, t.review.repos, t.review.reporte);
115
120
  plan.save();
@@ -38,7 +38,7 @@ function ordinaryBasis(estimate, stage) {
38
38
  const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
39
39
  if (!stage)
40
40
  return basis;
41
- return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
41
+ return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour ${stage === 'light' ? 'les étapes spec et maquette' : `les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`}`;
42
42
  }
43
43
  /** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
44
44
  export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
@@ -46,5 +46,5 @@ const lotBaseBudget = (estimate, stage) => {
46
46
  const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
47
47
  if (stage === 'screen')
48
48
  return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
49
- return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
49
+ return ordinary + (stage === 'spec' || stage === 'light' ? SPEC_STAGE_ALLOCATION : 0);
50
50
  };
@@ -15,6 +15,9 @@ export function maquetteText(o) {
15
15
  ? `This is review ${rank} of ${o.max}${rank >= o.max ? ', the last review' : ''}: the spec writer fixed the points of the previous one (below). Check them first, then run the whole sweep again and report ALL the blocking and major findings of it in this report. Mark \`inchange: true\` on every blocking or major finding about a point the spec writer did not change and the previous review had not raised.`
16
16
  : `This is the first review of this mockup (${o.max} review${o.max > 1 ? 's' : ''} at most): there is no later review to keep a finding for. Report ALL the blocking and major findings in this first report; do not keep one back for a later review.`,
17
17
  ];
18
+ if (o.light) {
19
+ lines.push(`Light mockup (L459): the sheet covers only the states the lot touches. Sweep those states only, at ${LARGEURS.join(' and ')} px, and compare the target of each one to the capture of the current screen \`${folder}/actuel-<état>-<largeur>.png\` (a free file, not checked by the program; when one is missing the spec writer said so under "nonVerifie": judge the target against the code and the neighbouring pages). Report what makes the target worse than the current screen or inconsistent with the rest of the application, not the defects of the screen the lot does not touch.`);
20
+ }
18
21
  if (later && o.previous?.length)
19
22
  lines.push('', 'Points of the previous review that the spec writer had to fix:', ...bulletLines(o.previous));
20
23
  return lines.join('\n');
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { isMap, isNode, isScalar, isSeq, parse, parseDocument, stringify } from 'yaml';
5
5
  import { acceptationFile } from './accept.js';
6
6
  import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
7
+ import { EXPRESS_MAX_ESTIMATE } from './express.js';
7
8
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
8
9
  const list = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
9
10
  /** 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. */
@@ -24,6 +25,34 @@ export function specScreen(repo, dir, lot) {
24
25
  const entered = fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && (doc.ecran.lot === lot.id || existsSync(join(repo, dir, name, acceptation))));
25
26
  return entered ? lot.screen : undefined;
26
27
  }
28
+ /**
29
+ * Maquette légère (L459) : un lot visible, sans `spec: true`, d'estimate au-dessus de la retouche (voie express), dont le `screen:` du plan n'est pas `page`
30
+ * (`component`, `journey` ou absent) passe par spec → maquette avant le code, sans accept. Se déduit du plan à chaque appel, sans champ d'état :
31
+ * `orchestrate.maquette: false` la retire (la maquette légère est la revue de maquette, sans elle rien n'est à valider).
32
+ */
33
+ export function lightMaquette(lot, maquette) {
34
+ return maquette && !!lot.visible && !lot.spec && typeof lot.estimate === 'number' && lot.estimate > EXPRESS_MAX_ESTIMATE && lot.screen !== 'page';
35
+ }
36
+ /**
37
+ * Le lot a une acceptation scellée (`acceptation-<lot>.json` à côté d'une fiche à bloc `ecran` de `dir`) : il est entré dans la chaîne complète
38
+ * avant L459 (spec → maquette → accept) et la garde, même s'il répond aujourd'hui à `lightMaquette` ; une maquette légère n'écrit jamais d'acceptation.
39
+ */
40
+ export function sealedAcceptation(repo, dir, lot) {
41
+ const acceptation = acceptationFile('fiche.yaml', lot);
42
+ return fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && existsSync(join(repo, dir, name, acceptation)));
43
+ }
44
+ /** Le lot est une maquette légère : son état a une étape spec (`screen` posé au lancement), son lot du plan répond à `lightMaquette` et il n'a pas d'acceptation scellée (`sealedAcceptation`). Relu du plan et du dépôt à chaque appel, aucun champ d'état. */
45
+ export const lightLot = (state, lot, maquette, repo, dir) => !!state.screen && lightMaquette(lot, maquette) && !sealedAcceptation(repo, dir, lot.id);
46
+ /** `screen` de l'état d'un lot à étape spec : celui de `specScreen`, ou, pour une maquette légère, celui du plan (`component` quand le plan n'en déclare pas : l'état ne sert alors qu'à dire « ce lot a une étape spec »). */
47
+ export function stageScreen(repo, dir, lot, maquette) {
48
+ return specScreen(repo, dir, lot) ?? (lightMaquette(lot, maquette) ? (lot.screen ?? 'component') : undefined);
49
+ }
50
+ /** Catégorie de budget d'un lot (`lotBudget`) : `screen` pour un écran page, `light` pour une maquette légère, `spec` pour un lot `spec: true` sans écran. */
51
+ export function budgetStage(lot, stateScreen, maquette, repo, dir) {
52
+ if (stateScreen)
53
+ return lightLot({ screen: stateScreen }, lot, maquette, repo, dir) ? 'light' : 'screen';
54
+ return lot.spec ? 'spec' : undefined;
55
+ }
27
56
  /** Texte sans accents ni majuscules, pour les mots-clés. */
28
57
  const plain = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
29
58
  /**
@@ -267,20 +296,36 @@ export function hypothesisLines(doc) {
267
296
  }
268
297
  /** Texte de la note du lot pour une hypothèse (une note par règle). */
269
298
  export const hypothesisNote = (sujet, line) => `Hypothèses (fiche ${sujet}) — ${line}`;
299
+ /** Consigne de rendu de la maquette : la commande que le projet déclare (`orchestrate.spec.render`), sinon Playwright. `use` : comment la reprendre. */
300
+ const renderLine = (render, use) => render
301
+ ? `The project declares how to render mockups (\`orchestrate.spec.render\`): \`${render}\` (from the repository root). ${use}; fall back on Playwright only if it cannot run, and say so under "nonVerifie".`
302
+ : '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>`).';
303
+ /** Consigne de la maquette d'un lot `screen: page` (ou déjà entré dans la chaîne complète). */
304
+ const pageLines = (o) => [
305
+ `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).`,
306
+ `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\`).`,
307
+ renderLine(o.render, 'Use it'),
308
+ ];
309
+ /** Consigne de la maquette légère (L459) : l'écran actuel et la version visée des seuls états touchés. */
310
+ const lightLines = (o) => [
311
+ `This lot is a visible lot that is not a whole page: write a light mockup, before any code. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`,
312
+ 'Cover only the states the lot touches (not the whole screen). For each touched state, capture the CURRENT screen of the application at each width, then the target version: the sheet and the mockup are limited to the states, fields and rules the lot changes.',
313
+ `Name the captures of the current screen \`actuel-<état>-<largeur>.png\` and those of the target \`maquette-<état>-<largeur>.png\`, one per touched state and per width (${LARGEURS.join(' and ')} px; e.g. \`actuel-vide-1440.png\`, \`maquette-vide-1440.png\`), in the folder of the sheet. The program checks the captures of the target, not the \`actuel-*\` ones.`,
314
+ 'If the current screen cannot be captured (the application does not start, the page is protected), say so under "nonVerifie" and base the mockup on the code: never invent a capture of the current screen.',
315
+ 'Each touched state is a section `id="etat-<état>"` of `maquette.html`, and each touched field carries a `data-testid`.',
316
+ renderLine(o.render, 'Use it for the target'),
317
+ ];
270
318
  /** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
271
319
  export function specText(o) {
272
320
  const lines = [];
273
- if (o.screen) {
274
- 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
275
- ? `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".`
276
- : '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>`).');
277
- }
321
+ if (o.screen)
322
+ lines.push(...(o.light ? lightLines(o) : pageLines(o)));
278
323
  if (o.back) {
279
324
  lines.push(o.screen
280
325
  ? `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\`.`
281
326
  : `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.`);
282
327
  }
283
- if (o.screen && !o.back) {
328
+ if (o.screen && !o.back && !o.light) {
284
329
  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\`.`);
285
330
  }
286
331
  if (o.template)
@@ -0,0 +1,118 @@
1
+ import { FIELDS, RafError } from './plan.js';
2
+ const EXTRA_KEYS = ['enum', 'default'];
3
+ const EXTRA_NAME = /^[a-z][a-z0-9_]*$/;
4
+ /** Les clés que raf lit ou écrit sur un lot : un champ d'extension ne peut pas en prendre le nom. */
5
+ const LOT_KEYS = ['tasks', 'uses', 'local', 'ux', 'review', 'docs_ok', 'extras', 'problems', 'state', 'state_before'];
6
+ const nonEmptyText = (v) => typeof v === 'string' && v.trim() !== '';
7
+ /** Une déclaration `plan.extras.<nom>` lue, ou l'erreur (message sans préfixe) qui la refuse. */
8
+ function readDecl(name, decl) {
9
+ if (decl == null)
10
+ return {};
11
+ if (typeof decl !== 'object' || Array.isArray(decl))
12
+ return `extras.${name} doit être un objet { enum, default }`;
13
+ const d = decl;
14
+ const unknown = Object.keys(d).find((k) => !EXTRA_KEYS.includes(k));
15
+ if (unknown)
16
+ return `extras.${name}.${unknown} inconnu (attendu : ${EXTRA_KEYS.join(', ')})`;
17
+ const out = {};
18
+ if (d.enum != null) {
19
+ if (!Array.isArray(d.enum) || d.enum.length === 0 || !d.enum.every(nonEmptyText))
20
+ return `extras.${name}.enum doit être une liste non vide de textes`;
21
+ if (new Set(d.enum).size !== d.enum.length)
22
+ return `extras.${name}.enum : une valeur est donnée deux fois`;
23
+ out.enum = d.enum;
24
+ }
25
+ if (d.default != null) {
26
+ if (!nonEmptyText(d.default))
27
+ return `extras.${name}.default doit être un texte non vide`;
28
+ if (out.enum && !out.enum.includes(d.default))
29
+ return `extras.${name}.default « ${d.default} » hors de enum (${out.enum.join(', ')})`;
30
+ out.default = d.default;
31
+ }
32
+ return out;
33
+ }
34
+ /**
35
+ * Clé `plan.extras` de cadence.yaml (L464) lue en `{ extras }` (`{}` quand la clé manque). Refuse, en nommant la clé : une forme fausse, un nom invalide ou pris
36
+ * par un champ de raf, un plan tenu par un autre outil (`foreign` : lecture seule, raf n'y écrit aucun champ).
37
+ */
38
+ export function readExtras(v, foreign, bad) {
39
+ if (v == null)
40
+ return {};
41
+ if (foreign)
42
+ throw bad("extras ne s'applique pas à un plan tenu par un autre outil (lecture seule)");
43
+ if (typeof v !== 'object' || Array.isArray(v))
44
+ throw bad('extras doit être un objet { champ: { enum, default } }');
45
+ const extras = {};
46
+ for (const [name, raw] of Object.entries(v)) {
47
+ if (!EXTRA_NAME.test(name))
48
+ throw bad(`extras.${name} : nom invalide (minuscules, chiffres et _, commençant par une lettre)`);
49
+ if (FIELDS.includes(name) || LOT_KEYS.includes(name))
50
+ throw bad(`extras.${name} : c'est un champ de raf, pas un champ d'extension`);
51
+ const decl = readDecl(name, raw);
52
+ if (typeof decl === 'string')
53
+ throw bad(decl);
54
+ extras[name] = decl;
55
+ }
56
+ return { extras };
57
+ }
58
+ /** Ce qui cloche dans la valeur d'un champ d'extension, ou null : champ non déclaré, pas un texte non vide, hors de `enum`. */
59
+ function valueProblem(decls, name, value) {
60
+ const decl = decls[name];
61
+ if (!decl) {
62
+ const names = Object.keys(decls);
63
+ return names.length === 0 ? `${name} : aucun champ d'extension déclaré (cadence.yaml : plan.extras)` : `${name} : champ d'extension non déclaré (${names.join(', ')})`;
64
+ }
65
+ if (!nonEmptyText(value))
66
+ return `${name} : texte non vide attendu`;
67
+ if (decl.enum && !decl.enum.includes(value.trim()))
68
+ return `${name} « ${value.trim()} » hors de enum (${decl.enum.join(', ')})`;
69
+ return null;
70
+ }
71
+ /** Les champs à poser à la création d'un lot (`raf add --set`), validés et nettoyés ; une erreur refuse tout. Sans valeur donnée : rien. */
72
+ export function checkedExtras(decls, given) {
73
+ const out = {};
74
+ for (const [name, value] of Object.entries(given ?? {})) {
75
+ const problem = valueProblem(decls ?? {}, name, value);
76
+ if (problem)
77
+ throw new RafError(problem);
78
+ out[name] = value.trim();
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * Le lot lu avec ses champs d'extension : la valeur écrite, sinon le `default`. Une valeur fausse est remontée par check (`bad-field`) et n'est pas gardée.
84
+ * Les clés non déclarées ne sont pas lues : elles restent sur le disque, intactes.
85
+ */
86
+ export function withExtras(lot, raw, decls) {
87
+ const extras = {};
88
+ for (const [name, decl] of Object.entries(decls ?? {})) {
89
+ const written = raw[name];
90
+ if (written == null) {
91
+ if (decl.default !== undefined)
92
+ extras[name] = decl.default;
93
+ continue;
94
+ }
95
+ const problem = valueProblem(decls ?? {}, name, written);
96
+ if (problem)
97
+ lot.problems.push(problem);
98
+ else
99
+ extras[name] = written.trim();
100
+ }
101
+ return Object.keys(extras).length > 0 ? { ...lot, extras } : lot;
102
+ }
103
+ /** Les `--set champ=valeur` de `raf add` : champ → valeur, coupée au premier « = ». Une forme sans champ ou sans valeur, un champ donné deux fois : refusés. Rend undefined sans `--set`. */
104
+ export function parseSet(sets) {
105
+ if (sets === undefined)
106
+ return undefined;
107
+ const out = {};
108
+ for (const item of sets) {
109
+ const [name, ...rest] = item.split('=');
110
+ const value = rest.join('=').trim();
111
+ if (name.trim() === '' || value === '')
112
+ throw new RafError(`--set attend champ=valeur (reçu « ${item} »)`);
113
+ if (Object.hasOwn(out, name.trim()))
114
+ throw new RafError(`--set ${name.trim()} est donné deux fois`);
115
+ out[name.trim()] = value;
116
+ }
117
+ return out;
118
+ }
@@ -0,0 +1,144 @@
1
+ import { isDay } from './dates.js';
2
+ import { RESOURCE_NAME, RESOURCES_BAD_LIST, SCREENS, STATUSES } from './plan.js';
3
+ const GATE_INVALID = 'gate invalide : count: entier ≥ 1 et un critère spec: true ou screen: page attendus';
4
+ /** Lit `gate: { count: N, spec: true }` ou `{ count: N, screen: page }` ; toute autre forme est un problème du lot et une condition jamais remplie. */
5
+ function readGate(raw, problems) {
6
+ if (raw == null)
7
+ return undefined;
8
+ const invalid = () => {
9
+ problems.push(GATE_INVALID);
10
+ return { invalid: true };
11
+ };
12
+ if (typeof raw !== 'object' || Array.isArray(raw))
13
+ return invalid();
14
+ const g = raw;
15
+ if (!Number.isInteger(g.count) || g.count < 1)
16
+ return invalid();
17
+ const criteria = [...(g.spec === true ? ['spec'] : []), ...(g.screen === 'page' ? ['screen'] : [])];
18
+ const others = Object.keys(g).filter((k) => k !== 'count' && k !== 'spec' && k !== 'screen');
19
+ if (criteria.length !== 1 || others.length || ('spec' in g && g.spec !== true) || ('screen' in g && g.screen !== 'page'))
20
+ return invalid();
21
+ return { count: g.count, criterion: criteria[0] };
22
+ }
23
+ /** `state:` et `state_before:` écrits à la main avec un non-texte : signalés, la clé étant écartée. */
24
+ function checkStateNames(raw, problems) {
25
+ for (const k of ['state', 'state_before'])
26
+ if (raw[k] != null && typeof raw[k] !== 'string')
27
+ problems.push(`${k} : un nom d'état attendu (reçu « ${String(raw[k])} »)`);
28
+ }
29
+ export function normalizeLot(raw, problems = []) {
30
+ const status = STATUSES.includes(raw.status) ? raw.status : 'todo';
31
+ const asDay = (field) => {
32
+ const v = raw[field];
33
+ if (v == null)
34
+ return undefined;
35
+ if (isDay(v))
36
+ return v;
37
+ problems.push(`${field} « ${String(v)} » n'est pas une date AAAA-MM-JJ`);
38
+ return undefined;
39
+ };
40
+ const after = raw.after == null ? [] : Array.isArray(raw.after) ? raw.after.map(String) : [String(raw.after)];
41
+ const last = asDay('last');
42
+ let uses;
43
+ if (raw.uses != null) {
44
+ if (Array.isArray(raw.uses) && raw.uses.every((n) => typeof n === 'string' && RESOURCE_NAME.test(n)))
45
+ uses = [...new Set(raw.uses)];
46
+ else
47
+ problems.push(`uses ${RESOURCES_BAD_LIST}`);
48
+ }
49
+ let screen;
50
+ if (raw.screen != null) {
51
+ if (SCREENS.includes(raw.screen))
52
+ screen = raw.screen;
53
+ else
54
+ problems.push(`screen « ${String(raw.screen)} » inconnu (${SCREENS.join(', ')})`);
55
+ if (screen && raw.visible !== true)
56
+ problems.push(`screen « ${screen} » sans visible : un écran est un changement visible`);
57
+ }
58
+ let spec;
59
+ if (raw.spec != null) {
60
+ if (raw.spec === true)
61
+ spec = true;
62
+ else if (raw.spec !== false)
63
+ problems.push(`spec : true ou false attendu (reçu « ${String(raw.spec)} »)`);
64
+ }
65
+ checkStateNames(raw, problems);
66
+ const gate = readGate(raw.gate, problems);
67
+ const okRaw = raw.docs_ok;
68
+ const docsOk = okRaw && typeof okRaw === 'object' && typeof okRaw.reason === 'string' && okRaw.reason.trim() !== '' ? { date: String(okRaw.date ?? ''), reason: okRaw.reason.trim() } : undefined;
69
+ return {
70
+ id: String(raw.id),
71
+ title: String(raw.title ?? ''),
72
+ status,
73
+ ...(typeof raw.state === 'string' && raw.state.trim() !== '' ? { state: raw.state.trim() } : {}),
74
+ ...(typeof raw.state_before === 'string' && raw.state_before.trim() !== '' ? { stateBefore: raw.state_before.trim() } : {}),
75
+ estimate: typeof raw.estimate === 'number' && raw.estimate > 0 ? raw.estimate : 1,
76
+ quickwin: raw.quickwin === true,
77
+ visible: raw.visible === true,
78
+ ...(screen ? { screen } : {}),
79
+ ...(spec ? { spec } : {}), ...(raw.local === true ? { local: true } : {}),
80
+ ...(gate ? { gate } : {}),
81
+ ...(typeof raw.public === 'string' && raw.public.trim() !== '' ? { public: raw.public.trim() } : {}),
82
+ ...(typeof raw.goal === 'string' && raw.goal.trim() !== '' ? { goal: raw.goal.trim() } : {}),
83
+ ...(Number.isInteger(raw.every) && raw.every > 0 ? { every: raw.every } : {}),
84
+ ...(last ? { last } : {}),
85
+ after,
86
+ ...(uses?.length ? { uses } : {}),
87
+ created: asDay('created'),
88
+ started: asDay('started'),
89
+ finished: asDay('finished'),
90
+ notes: Array.isArray(raw.notes)
91
+ ? raw.notes.map((n) => ({ date: String(n.date ?? ''), text: String(n.text ?? '') }))
92
+ : [],
93
+ tasks: Array.isArray(raw.tasks)
94
+ ? raw.tasks.map((t) => ({
95
+ id: String(t.id),
96
+ title: String(t.title ?? ''),
97
+ status: STATUSES.includes(t.status) ? t.status : 'todo',
98
+ }))
99
+ : [],
100
+ ...(repoList(raw.repos).length ? { repos: repoList(raw.repos) } : {}),
101
+ ...(asVerdict(raw.ux) ? { ux: asVerdict(raw.ux) } : {}),
102
+ ...(asVerdict(raw.review) ? { review: asVerdict(raw.review) } : {}),
103
+ ...(docsOk ? { docsOk } : {}),
104
+ problems,
105
+ };
106
+ }
107
+ /**
108
+ * Dépôts voisins déclarés : une chaîne (le chemin) ou un objet `{ path, cite }`, seuls ou en liste (une valeur seule vaut une
109
+ * liste d'un dépôt). Une entrée sans chemin textuel est écartée ; un `cite` vide ou non textuel vaut pas de `cite`.
110
+ */
111
+ function repoList(raw) {
112
+ const items = Array.isArray(raw) ? raw : raw == null ? [] : [raw];
113
+ const out = [];
114
+ for (const item of items) {
115
+ const path = typeof item === 'string' ? item : item && typeof item === 'object' && typeof item.path === 'string' ? item.path : '';
116
+ if (path.trim() === '')
117
+ continue;
118
+ const cite = item && typeof item === 'object' ? item.cite : undefined;
119
+ out.push({ path: path.trim(), ...(typeof cite === 'string' && cite.trim() !== '' ? { cite: cite.trim() } : {}) });
120
+ }
121
+ return out;
122
+ }
123
+ function asVerdict(raw) {
124
+ if (!raw || typeof raw !== 'object')
125
+ return undefined;
126
+ const v = raw;
127
+ // Écrit à la main sans verdict (clé absente, vide ou blanche) : rien n'a été dit de la revue, la porte
128
+ // reste fermée — comme `raf ux` et `raf review` refusent d'enregistrer un verdict vide.
129
+ if (String(v.verdict ?? '').trim() === '')
130
+ return undefined;
131
+ const verdict = { date: String(v.date ?? ''), verdict: String(v.verdict) };
132
+ // Champ présent mais vide : relu « jusqu'à rien », comme un verdict noté sans commit.
133
+ if ('commit' in v)
134
+ verdict.commit = v.commit == null || String(v.commit).trim() === '' ? null : String(v.commit).trim();
135
+ if (v.repos && typeof v.repos === 'object' && !Array.isArray(v.repos)) {
136
+ const repos = Object.entries(v.repos).map(([k, sha]) => [k, sha == null || String(sha).trim() === '' ? null : String(sha).trim()]);
137
+ if (repos.length)
138
+ verdict.repos = Object.fromEntries(repos);
139
+ }
140
+ const r = v.reporte;
141
+ if (r && typeof r === 'object' && !Array.isArray(r) && r.de != null && r.vers != null)
142
+ verdict.reporte = { de: String(r.de), vers: String(r.vers), patchId: String(r.patchId ?? '') };
143
+ return verdict;
144
+ }
@@ -0,0 +1,119 @@
1
+ import { isObject, list } from './yaml-keys.js';
2
+ import { STATUSES } from './plan.js';
3
+ function parseBases(raw, bad) {
4
+ if (!isObject(raw))
5
+ throw bad('states doit être un objet { état: statut de base }');
6
+ const states = {};
7
+ for (const [name, base] of Object.entries(raw)) {
8
+ if (!STATUSES.includes(base))
9
+ throw bad(`states.${name} : statut de base « ${String(base)} » inconnu (attendu : ${STATUSES.join(', ')})`);
10
+ states[name] = base;
11
+ }
12
+ const bases = Object.values(states);
13
+ if (!['todo', 'doing', 'done'].every((b) => bases.includes(b)))
14
+ throw bad('states doit déclarer au moins un état todo, un doing et un done');
15
+ return states;
16
+ }
17
+ function parseTransitions(raw, states, bad) {
18
+ if (!isObject(raw))
19
+ throw bad('transitions doit être un objet { état: [états permis] }');
20
+ const transitions = {};
21
+ for (const [from, tos] of Object.entries(raw)) {
22
+ if (!Object.hasOwn(states, from))
23
+ throw bad(`transitions : « ${from} » n'est pas un état déclaré`);
24
+ for (const to of list(tos))
25
+ if (!Object.hasOwn(states, to))
26
+ throw bad(`transitions.${from} : « ${to} » n'est pas un état déclaré`);
27
+ transitions[from] = list(tos);
28
+ }
29
+ return transitions;
30
+ }
31
+ function requireNames(p, bad) {
32
+ for (const k of ['doneState', 'blockedState'])
33
+ if (p[k] != null && typeof p[k] !== 'string')
34
+ throw bad(`${k} : un nom d'état attendu`);
35
+ }
36
+ /**
37
+ * Lit `plan.states`, `plan.transitions`, `plan.doneState` et `plan.blockedState` de cadence.yaml : `{ states }` ou `{}` quand rien n'est déclaré.
38
+ * `foreign` : le plan est tenu par un autre outil (lots, fields, statuses, estimates), dont les états sont les siens.
39
+ */
40
+ export function parseStates(p, bad, foreign) {
41
+ if (p.states == null) {
42
+ for (const k of ['transitions', 'doneState', 'blockedState'])
43
+ if (p[k] != null)
44
+ throw bad(`${k} demande plan.states`);
45
+ return {};
46
+ }
47
+ if (foreign)
48
+ throw bad('states ne va pas avec un plan tenu par un autre outil (lots, fields, statuses, estimates) : ses états sont les siens');
49
+ const states = parseBases(p.states, bad);
50
+ const model = { states };
51
+ if (p.transitions != null)
52
+ model.transitions = parseTransitions(p.transitions, states, bad);
53
+ requireNames(p, bad);
54
+ if (p.doneState != null) {
55
+ if (states[String(p.doneState)] !== 'done')
56
+ throw bad(`doneState : « ${String(p.doneState)} » n'est pas un état done`);
57
+ model.done = String(p.doneState);
58
+ }
59
+ if (p.blockedState != null) {
60
+ const base = states[String(p.blockedState)];
61
+ if (base !== 'todo' && base !== 'doing')
62
+ throw bad(`blockedState : « ${String(p.blockedState)} » doit être un état todo ou doing`);
63
+ model.blocked = String(p.blockedState);
64
+ }
65
+ return { states: model };
66
+ }
67
+ /** L'état que prend un lot qui entre dans ce statut de base : `doneState` pour done, sinon le premier état déclaré de ce statut. */
68
+ export function defaultState(model, base) {
69
+ if (base === 'done' && model.done)
70
+ return model.done;
71
+ return Object.keys(model.states).find((s) => model.states[s] === base);
72
+ }
73
+ /** L'état d'un lot : celui qu'il porte, sinon l'état par défaut de son statut (lot écrit avant la déclaration). */
74
+ export function currentState(model, lot) {
75
+ return lot.state ?? defaultState(model, lot.status) ?? lot.status;
76
+ }
77
+ /** Pourquoi `from → to` est refusé par `plan.transitions` ; `null` quand c'est permis ou que rien n'est déclaré. */
78
+ export function transitionProblem(model, from, to) {
79
+ if (!model.transitions)
80
+ return null;
81
+ const allowed = model.transitions[from];
82
+ if (allowed?.includes(to))
83
+ return null;
84
+ return `${from} → ${to} n'est pas permis (${allowed?.length ? `depuis ${from} : ${allowed.join(', ')}` : 'état terminal'})`;
85
+ }
86
+ /** Les écarts d'un état écrit à la main : état non déclaré, ou statut de base qui contredit `status:`. */
87
+ export function stateProblems(model, lot) {
88
+ if (lot.state === undefined)
89
+ return [];
90
+ const base = Object.hasOwn(model.states, lot.state) ? model.states[lot.state] : undefined;
91
+ if (base === undefined)
92
+ return [`état « ${lot.state} » non déclaré (cadence.yaml : plan.states)`];
93
+ return base === lot.status ? [] : [`état « ${lot.state} » (${base}) contredit status: ${lot.status}`];
94
+ }
95
+ /**
96
+ * Applique à `node` le changement de statut de base de `raf status` : todo → doing démarre le lot, doing → todo le remet en attente
97
+ * (`started:` retiré) — refusé, comme `raf todo`, dès qu'un commit cite le lot : le remettre en attente perdrait leur lien. Rend le refus
98
+ * (`move` nomme le lot, l'état visé et son statut de base), `undefined` quand le changement est fait. Autre changement : rien n'est touché,
99
+ * le refus renvoie à sa commande.
100
+ */
101
+ export function rebase(node, lot, move, today, commits) {
102
+ const { ref, to, base } = move;
103
+ if (lot.status === 'todo' && base === 'doing') {
104
+ node.set('status', 'doing');
105
+ if (!lot.started)
106
+ node.set('started', today);
107
+ return undefined;
108
+ }
109
+ if (lot.status === 'doing' && base === 'todo') {
110
+ const n = commits();
111
+ if (n > 0)
112
+ return `${ref} a ${n} commit(s) qui le citent : le remettre en attente perdrait leur lien — un état doing (raf status ${ref} <état>), ou raf drop ${ref}`;
113
+ node.set('status', 'todo');
114
+ node.delete('started');
115
+ return undefined;
116
+ }
117
+ const command = base === 'done' ? 'done' : base === 'dropped' ? 'drop' : 'todo';
118
+ return `${ref} : ${to} a pour statut de base ${base}, ${lot.status} aujourd'hui — passer par raf ${command} ${ref}`;
119
+ }