@sylad/cadence 1.39.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -3
- package/agents/spec-writer.md +1 -0
- package/agents/ux-reviewer.md +2 -0
- package/dist/cli.js +49 -62
- package/dist/config-set.js +2 -0
- package/dist/config.js +8 -8
- package/dist/deliver-lots.js +37 -0
- package/dist/deliver.js +15 -39
- package/dist/docs-ok-file.js +25 -0
- package/dist/lot-lines.js +51 -0
- package/dist/memory-index.js +113 -0
- package/dist/orchestrate/command.js +8 -8
- package/dist/orchestrate/continue.js +4 -2
- package/dist/orchestrate/cycle.js +23 -29
- package/dist/orchestrate/deja-traitee.js +76 -0
- package/dist/orchestrate/integrate.js +7 -2
- package/dist/orchestrate/lot-budget.js +2 -2
- package/dist/orchestrate/maquette.js +3 -0
- package/dist/orchestrate/spec.js +51 -6
- package/dist/orchestrate/table.js +29 -11
- package/dist/plan-extras.js +118 -0
- package/dist/plan-read.js +144 -0
- package/dist/plan-states.js +119 -0
- package/dist/plan.js +91 -157
- package/dist/raf-close-checks.js +25 -0
- package/dist/raf-status.js +28 -0
- package/dist/session.js +8 -0
- package/dist/state.js +23 -0
- package/dist/verify.js +20 -3
- package/dist/yaml-keys.js +2 -0
- package/docs/concepts-livraison-dark.svg +32 -0
- package/docs/concepts-livraison-light.svg +32 -0
- package/docs/concepts-lot-dark.svg +52 -0
- package/docs/concepts-lot-light.svg +52 -0
- package/docs/concepts-vague-dark.svg +39 -0
- package/docs/concepts-vague-light.svg +39 -0
- package/docs/concepts-worktrees-dark.svg +45 -0
- package/docs/concepts-worktrees-light.svg +45 -0
- package/docs/concepts.md +159 -0
- package/docs/deliver.md +24 -2
- package/docs/fonctionnement.md +11 -5
- package/docs/lead.md +10 -6
- package/docs/orchestrate.md +22 -5
- package/docs/overview.md +1 -1
- package/docs/perf.md +1 -0
- package/docs/qualite.md +13 -3
- package/docs/raf.md +42 -5
- package/docs/session.md +32 -0
- package/docs/spec.md +5 -2
- package/docs/whats-new.md +6 -0
- package/package.json +10 -1
- package/skills/lead/SKILL.md +6 -0
- package/skills/session-close/SKILL.md +6 -0
- package/templates/orchestrate/spec.md +2 -2
|
@@ -5,6 +5,7 @@ export { estimateLines, launchPrices } from './cout.js';
|
|
|
5
5
|
import { gitRoot, isAncestor } from '../git.js';
|
|
6
6
|
import { repoWork } from '../repos.js';
|
|
7
7
|
import { quotaReset } from './result.js';
|
|
8
|
+
import { proposalLine, touchedReader } from './deja-traitee.js';
|
|
8
9
|
const k = (n) => `${Math.round(n / 1000)} k`;
|
|
9
10
|
const m = (n) => `${(n / 1_000_000).toFixed(1).replace('.', ',')} M`;
|
|
10
11
|
export const LABEL = {
|
|
@@ -83,6 +84,15 @@ function gitCommitsOf(l) {
|
|
|
83
84
|
return [];
|
|
84
85
|
}
|
|
85
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Les commits que les étapes du lot ont notés (entrées `<sha> <sujet>`, du plus ancien au plus récent), à défaut ceux que git lui attribue :
|
|
89
|
+
* les sha de la revue (`proposalHeads`) et ceux-là sont du même état de la branche, alors que git, lu depuis le HEAD du dépôt, ne voit pas
|
|
90
|
+
* les commits d'un lot non intégré et ne retrouve, après l'intégration par rebase, que des commits réécrits.
|
|
91
|
+
*/
|
|
92
|
+
function reviewedCommitsOf(l) {
|
|
93
|
+
const noted = [...new Set(l.steps.flatMap((s) => s.commits ?? []).filter((c) => !c.startsWith('[')).map((c) => c.split(' ')[0]))];
|
|
94
|
+
return noted.length ? noted : gitCommitsOf(l);
|
|
95
|
+
}
|
|
86
96
|
const sameCommit = (a, b) => a.length >= 4 && b.length >= 4 && (a.startsWith(b) || b.startsWith(a));
|
|
87
97
|
/**
|
|
88
98
|
* Sha des commits du lot dans son dépôt : ceux que git attribue au lot (une session en échec, au rapport illisible ou coupée
|
|
@@ -206,7 +216,7 @@ function signalLines(l) {
|
|
|
206
216
|
return out;
|
|
207
217
|
}
|
|
208
218
|
/** Le détail d'un lot (L441) : ce que le tableau compact de `--status` n'imprime pas et que `--status --detail <lot>` donne. */
|
|
209
|
-
function detailLines(l) {
|
|
219
|
+
function detailLines(l, touched) {
|
|
210
220
|
const out = [];
|
|
211
221
|
if (l.goal)
|
|
212
222
|
out.push(`${l.project}:${l.lot} — objectif donné : ${l.goal}`);
|
|
@@ -228,7 +238,7 @@ function detailLines(l) {
|
|
|
228
238
|
for (const h of l.hypotheses ?? [])
|
|
229
239
|
out.push(`${l.project}:${l.lot} — hypothèse de la fiche : ${h}`);
|
|
230
240
|
for (const p of l.proposals)
|
|
231
|
-
out.push(`${l.project}:${l.lot} — proposé : ${p}`);
|
|
241
|
+
out.push(`${l.project}:${l.lot} — proposé : ${proposalLine(l, p, touched)}`);
|
|
232
242
|
if (l.uxVerdict && l.visible)
|
|
233
243
|
out.push(`${l.project}:${l.lot} — verdict UX pour « raf ux » : ${l.uxVerdict}`);
|
|
234
244
|
return out;
|
|
@@ -237,7 +247,7 @@ function detailLines(l) {
|
|
|
237
247
|
* `--status --detail <lot>` (L441) : les lignes de détail d'un lot de la vague, lues dans l'état de la vague (le fichier de chaque lot).
|
|
238
248
|
* `sel` : `projet:lot` ou le seul `lot` s'il est unique dans la vague. Lot inconnu ou ambigu : lève.
|
|
239
249
|
*/
|
|
240
|
-
export function renderDetail(lots, sel) {
|
|
250
|
+
export function renderDetail(lots, sel, deps = {}) {
|
|
241
251
|
const hit = lots.filter((l) => `${l.project}:${l.lot}` === sel);
|
|
242
252
|
const found = hit.length ? hit : sel.includes(':') ? [] : lots.filter((l) => l.lot === sel);
|
|
243
253
|
if (found.length === 0)
|
|
@@ -245,9 +255,23 @@ export function renderDetail(lots, sel) {
|
|
|
245
255
|
if (found.length > 1)
|
|
246
256
|
throw new Error(`lot ambigu : ${sel} (${found.map((l) => `${l.project}:${l.lot}`).join(', ')}) : donner projet:lot`);
|
|
247
257
|
const l = found[0];
|
|
248
|
-
const lines = detailLines(l);
|
|
258
|
+
const lines = detailLines(l, touchedReader(deps, reviewedCommitsOf));
|
|
249
259
|
return lines.length ? lines : [`${l.project}:${l.lot} — aucun détail (ni objectif, capture, choix, proposition, ni verdict UX)`];
|
|
250
260
|
}
|
|
261
|
+
/** Les lignes de signal et de détail de chaque lot, ajoutées à `out` ; rend les lots qui ont un détail. Compact : les lignes de détail ne sont pas imprimées, seul leur nombre compte, et la marque L422 n'en change pas le nombre : aucune lecture git. */
|
|
262
|
+
function perLotLines(lots, out, deps) {
|
|
263
|
+
const touched = deps.compact ? () => null : touchedReader(deps, reviewedCommitsOf);
|
|
264
|
+
const withDetail = [];
|
|
265
|
+
for (const l of lots) {
|
|
266
|
+
out.push(...signalLines(l));
|
|
267
|
+
const lines = detailLines(l, touched);
|
|
268
|
+
if (lines.length > 0)
|
|
269
|
+
withDetail.push(`${l.project}:${l.lot}`);
|
|
270
|
+
if (!deps.compact)
|
|
271
|
+
out.push(...lines);
|
|
272
|
+
}
|
|
273
|
+
return withDetail;
|
|
274
|
+
}
|
|
251
275
|
/** Le tableau de fin de vague (ou de `--status`), une ligne par lot puis le total et les questions. `compact` (L441, `--status`) : sans le détail des lots, une seule ligne renvoie à `--status --detail`. */
|
|
252
276
|
export function renderTable(wave, lots, deps = {}) {
|
|
253
277
|
const cost = costView(lots, deps.prices);
|
|
@@ -270,13 +294,7 @@ export function renderTable(wave, lots, deps = {}) {
|
|
|
270
294
|
for (const d of deliverable(lots, deps.isAncestor ?? isAncestor, deps.lotCommits ?? gitCommitsOf)) {
|
|
271
295
|
out.push(`${d.ready.project}:${d.ready.lot} — livrable jusqu'à ${d.sha} (dernier commit de ${d.ready.lot}, sous ${d.under.lot} ${d.under.status === 'handed-back' || d.under.status === 'failed' ? 'rendu' : LABEL[d.under.status]}) : git push origin ${d.sha}:main && cadence deliver --sha ${d.sha}`);
|
|
272
296
|
}
|
|
273
|
-
|
|
274
|
-
out.push(...signalLines(l));
|
|
275
|
-
if (deps.compact)
|
|
276
|
-
continue;
|
|
277
|
-
out.push(...detailLines(l));
|
|
278
|
-
}
|
|
279
|
-
const withDetail = lots.filter((l) => detailLines(l).length > 0).map((l) => `${l.project}:${l.lot}`);
|
|
297
|
+
const withDetail = perLotLines(lots, out, deps);
|
|
280
298
|
if (deps.compact && withDetail.length)
|
|
281
299
|
out.push(`détail (objectif, captures, base de tests, choix, propositions, verdict UX) : cadence orchestrate --status --detail <projet:lot> — ${withDetail.join(', ')}`);
|
|
282
300
|
return out;
|
|
@@ -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
|
+
}
|