@sylad/cadence 0.2.0 → 0.5.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 +2 -2
- package/README.md +185 -11
- package/agents/code-reviewer.md +89 -0
- package/bin/cadence.js +3 -2
- package/dist/audit.js +95 -5
- package/dist/check.js +2 -1
- package/dist/cli.js +100 -46
- package/dist/config.js +106 -0
- package/dist/dates.js +8 -0
- package/dist/deliver.js +87 -29
- package/dist/git.js +34 -1
- package/dist/link.js +3 -3
- package/dist/news.js +82 -9
- package/dist/plan.js +203 -19
- package/dist/session.js +36 -6
- package/package.json +3 -2
- package/skills/deliver/SKILL.md +23 -1
- package/skills/lead/SKILL.md +80 -0
- package/skills/session-close/SKILL.md +17 -0
- package/skills/session-start/SKILL.md +25 -1
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
|
-
import {
|
|
5
|
-
import { audit,
|
|
4
|
+
import { readPlanConfig, readSessionConfig } from './config.js';
|
|
5
|
+
import { audit, exemptPlanOnly, isPlanOnly, lotWork, nextUp, planCommits, unreviewedWork } from './audit.js';
|
|
6
|
+
import { short } from './check.js';
|
|
6
7
|
import { isDay, toDay } from './dates.js';
|
|
7
8
|
import { deliver, parseDeliverConfig, realDeps } from './deliver.js';
|
|
8
9
|
import { ganttData, renderGantt } from './gantt.js';
|
|
9
10
|
import { gitRoot, readCommits } from './git.js';
|
|
10
11
|
import { installHook } from './hook.js';
|
|
11
12
|
import { linkCommits } from './link.js';
|
|
12
|
-
import { buildNews, loadEntries, newEntry, newsData, newsIssues } from './news.js';
|
|
13
|
-
import {
|
|
13
|
+
import { buildNews, loadEntries, newEntry, newsData, newsIssues, stampEntries } from './news.js';
|
|
14
|
+
import { Plan, RafError, STATUSES } from './plan.js';
|
|
14
15
|
import { schedule } from './schedule.js';
|
|
15
16
|
import { AGENTS_DIR, installAgents, installSkills, SKILLS_DIR } from './skills.js';
|
|
16
17
|
import { sessionClose, sessionStart } from './session.js';
|
|
@@ -23,16 +24,21 @@ const HELP = `raf — plan « reste à faire » versionné dans le dépôt, reli
|
|
|
23
24
|
raf note <id> "texte"
|
|
24
25
|
raf ux enable revue UX obligatoire avant « done » pour les lots --visible
|
|
25
26
|
raf ux <id> "verdict" enregistre la revue d'ergonomie du lot (agent ux-reviewer)
|
|
27
|
+
raf review enable revue de code obligatoire avant « done » pour les lots qui ont des commits
|
|
28
|
+
raf review <id> "verdict" enregistre la revue de code du lot (agent code-reviewer)
|
|
29
|
+
raf commits <id> les commits du lot que compte la porte de revue de code, du plus ancien au plus récent
|
|
26
30
|
raf now ce qui est en cours, la suite, les derniers terminés
|
|
27
31
|
raf list [--status todo|doing|done|dropped]
|
|
28
32
|
raf check [--since date] [--idle 7] (défaut : date « since » du plan) code 1 s'il y a des écarts
|
|
29
33
|
raf gantt [-o docs/plan/gantt.html]
|
|
30
34
|
raf hook install
|
|
31
|
-
raf news new <lot…> [--title t] | list | check | build [-o dossier] (aussi « cadence news … »)
|
|
35
|
+
raf news new <lot…> [--title t] | list | check | stamp | build [-o dossier] (aussi « cadence news … »)
|
|
32
36
|
|
|
33
37
|
Un commit appartient à un lot quand son message cite l'identifiant : « feat(L3): … », « L3/t1 ».
|
|
34
38
|
Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
|
|
35
39
|
Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
|
|
40
|
+
Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
|
|
41
|
+
autre outil se lit sans migration (correspondance des champs, voir le README) et reste en lecture seule.
|
|
36
42
|
Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
|
|
37
43
|
export function run(argv, io) {
|
|
38
44
|
try {
|
|
@@ -78,6 +84,7 @@ function dispatch(argv, io) {
|
|
|
78
84
|
output: { type: 'string', short: 'o' },
|
|
79
85
|
config: { type: 'string' },
|
|
80
86
|
'dry-run': { type: 'boolean' },
|
|
87
|
+
sha: { type: 'string' },
|
|
81
88
|
help: { type: 'boolean', short: 'h' },
|
|
82
89
|
},
|
|
83
90
|
});
|
|
@@ -90,7 +97,13 @@ function dispatch(argv, io) {
|
|
|
90
97
|
if (!isDay(today))
|
|
91
98
|
throw new RafError(`RAF_TODAY invalide : ${today} (attendu AAAA-MM-JJ)`);
|
|
92
99
|
const root = gitRoot(io.cwd) ?? io.cwd;
|
|
93
|
-
|
|
100
|
+
// cadence.yaml peut dire où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
101
|
+
const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
|
|
102
|
+
// Installer les skills ou le hook ne lit pas le plan : un cadence.yaml fautif ne doit pas l'empêcher.
|
|
103
|
+
const installing = command === 'skills' || (command === 'hook' && rest[0] === 'install');
|
|
104
|
+
const planConfig = installing ? null : readPlanConfig(configPath);
|
|
105
|
+
const planPath = resolve(io.cwd, values.file ?? io.env.RAF_FILE ?? resolve(root, planConfig?.path ?? 'docs/plan/raf.yaml'));
|
|
106
|
+
const loadPlan = () => Plan.load(planPath, planConfig?.settings);
|
|
94
107
|
const newsDir = resolve(io.cwd, values.dir ?? join(root, 'docs/nouveautes'));
|
|
95
108
|
const need = (n, usage) => {
|
|
96
109
|
if (rest.length < n)
|
|
@@ -98,6 +111,8 @@ function dispatch(argv, io) {
|
|
|
98
111
|
};
|
|
99
112
|
switch (command) {
|
|
100
113
|
case 'init': {
|
|
114
|
+
if (planConfig?.settings.format)
|
|
115
|
+
throw new RafError(`plan en lecture seule : ${configPath} décrit un plan tenu par un autre outil`);
|
|
101
116
|
const plan = Plan.create(planPath, values.project ?? basename(root), values.prefix ?? 'L', today);
|
|
102
117
|
io.out(`plan créé : ${plan.path}`);
|
|
103
118
|
if (!values['no-hook'] && gitRoot(io.cwd))
|
|
@@ -106,7 +121,7 @@ function dispatch(argv, io) {
|
|
|
106
121
|
}
|
|
107
122
|
case 'add': {
|
|
108
123
|
need(1, 'add "titre"');
|
|
109
|
-
const plan =
|
|
124
|
+
const plan = loadPlan();
|
|
110
125
|
let id;
|
|
111
126
|
if (values.parent) {
|
|
112
127
|
id = plan.addTask(values.parent, rest.join(' '));
|
|
@@ -126,9 +141,11 @@ function dispatch(argv, io) {
|
|
|
126
141
|
case 'done':
|
|
127
142
|
case 'drop': {
|
|
128
143
|
need(1, `${command} <id>`);
|
|
129
|
-
const plan =
|
|
144
|
+
const plan = loadPlan();
|
|
130
145
|
const status = command === 'start' ? 'doing' : command === 'done' ? 'done' : 'dropped';
|
|
131
|
-
plan
|
|
146
|
+
// Le plan ne lit pas git : lui dire combien de commits du lot sa revue ne couvre pas quand elle est exigée.
|
|
147
|
+
const unreviewed = status === 'done' && plan.reviewSince && !plan.readonly && !values.force ? unreviewedWork(plan, root, rest[0]) : 0;
|
|
148
|
+
plan.setStatus(rest[0], status, today, { force: values.force, unreviewed });
|
|
132
149
|
if (command === 'drop' && values.reason)
|
|
133
150
|
plan.note(rest[0], `abandonné : ${values.reason}`, today);
|
|
134
151
|
plan.save();
|
|
@@ -143,28 +160,28 @@ function dispatch(argv, io) {
|
|
|
143
160
|
}
|
|
144
161
|
case 'note': {
|
|
145
162
|
need(2, 'note <id> "texte"');
|
|
146
|
-
const plan =
|
|
163
|
+
const plan = loadPlan();
|
|
147
164
|
plan.note(rest[0], rest.slice(1).join(' '), today);
|
|
148
165
|
plan.save();
|
|
149
166
|
return 0;
|
|
150
167
|
}
|
|
151
|
-
case 'ux':
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
168
|
+
case 'ux':
|
|
169
|
+
case 'review':
|
|
170
|
+
return gate(command, rest, loadPlan, root, today, io);
|
|
171
|
+
case 'commits': {
|
|
172
|
+
need(1, 'commits <lot>');
|
|
173
|
+
const plan = loadPlan();
|
|
174
|
+
const id = rest[0];
|
|
175
|
+
if (!plan.lots().some((l) => l.id === id)) {
|
|
176
|
+
throw new RafError(id.includes('/') ? `les commits se listent par lot, pas par sous-tâche : ${id}` : `lot inconnu : ${id}`);
|
|
159
177
|
}
|
|
160
|
-
|
|
161
|
-
plan.
|
|
162
|
-
|
|
163
|
-
plan.save();
|
|
178
|
+
// Une lecture : le même ensemble que « raf done » et « raf check », plan en lecture seule compris.
|
|
179
|
+
for (const c of lotWork(plan, root, id).reverse())
|
|
180
|
+
io.out(short(c));
|
|
164
181
|
return 0;
|
|
165
182
|
}
|
|
166
183
|
case 'list': {
|
|
167
|
-
const plan =
|
|
184
|
+
const plan = loadPlan();
|
|
168
185
|
if (values.status && !STATUSES.includes(values.status))
|
|
169
186
|
throw new RafError(`statut inconnu : ${values.status}`);
|
|
170
187
|
for (const l of plan.lots().filter((l) => !values.status || l.status === values.status)) {
|
|
@@ -173,21 +190,21 @@ function dispatch(argv, io) {
|
|
|
173
190
|
return 0;
|
|
174
191
|
}
|
|
175
192
|
case 'now':
|
|
176
|
-
return now(
|
|
193
|
+
return now(loadPlan(), root, newsDir, today, io);
|
|
177
194
|
case 'check': {
|
|
178
195
|
const idle = values.idle === undefined ? 7 : Number(values.idle);
|
|
179
196
|
if (!Number.isInteger(idle) || idle < 0)
|
|
180
197
|
throw new RafError(`--idle invalide : ${values.idle}`);
|
|
181
|
-
const issues = audit(
|
|
198
|
+
const issues = audit(loadPlan(), root, newsDir, today, { since: values.since, idle });
|
|
182
199
|
for (const i of issues)
|
|
183
200
|
io.out(`✗ ${i.message}`);
|
|
184
201
|
io.out(issues.length === 0 ? '✓ plan et historique cohérents' : `${issues.length} écart(s)`);
|
|
185
202
|
return issues.length === 0 ? 0 : 1;
|
|
186
203
|
}
|
|
187
204
|
case 'gantt': {
|
|
188
|
-
const plan =
|
|
205
|
+
const plan = loadPlan();
|
|
189
206
|
const lots = plan.lots();
|
|
190
|
-
const linked = linkCommits(lots,
|
|
207
|
+
const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
|
|
191
208
|
const stamp = io.now();
|
|
192
209
|
const html = renderGantt(ganttData(plan.project, schedule(lots, linked.byLot, today), today, `${toDay(stamp)} ${stamp.toTimeString().slice(0, 5)}`));
|
|
193
210
|
const out = resolve(io.cwd, values.output ?? join(dirname(planPath), 'gantt.html'));
|
|
@@ -203,22 +220,24 @@ function dispatch(argv, io) {
|
|
|
203
220
|
return 0;
|
|
204
221
|
}
|
|
205
222
|
if (rest[0] === 'post-commit')
|
|
206
|
-
return postCommit(planPath, newsDir, root, io);
|
|
223
|
+
return postCommit(existsSync(planPath) ? loadPlan : null, newsDir, root, io);
|
|
207
224
|
throw new RafError('usage : raf hook install|post-commit');
|
|
208
225
|
}
|
|
209
|
-
case 'session':
|
|
226
|
+
case 'session': {
|
|
210
227
|
if (!gitRoot(io.cwd))
|
|
211
228
|
throw new RafError('session : à lancer dans un dépôt git');
|
|
212
|
-
|
|
229
|
+
// « next » n'écrit que les notes : la commande du projet ne se joue qu'à la reprise et à la clôture.
|
|
230
|
+
const facts = rest[0] === 'start' || rest[0] === 'close' ? readSessionConfig(configPath)[rest[0]] : undefined;
|
|
231
|
+
return session(rest, { plan: loadPlan(), root, newsDir, state: stateDir(root), shared: sharedStateDir(root), today, out: io.out, facts }, values);
|
|
232
|
+
}
|
|
213
233
|
case 'deliver': {
|
|
214
234
|
if (!gitRoot(io.cwd))
|
|
215
235
|
throw new RafError('deliver : à lancer dans un dépôt git');
|
|
216
|
-
const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
|
|
217
236
|
if (!existsSync(configPath))
|
|
218
237
|
throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans le README)`);
|
|
219
238
|
const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
|
|
220
|
-
const plan = existsSync(planPath) ?
|
|
221
|
-
const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], out: io.out, err: io.err };
|
|
239
|
+
const plan = existsSync(planPath) ? loadPlan() : null;
|
|
240
|
+
const ctx = { root, state: sharedStateDir(root), plan, config, today, dryRun: !!values['dry-run'], sha: values.sha, args: rest, out: io.out, err: io.err };
|
|
222
241
|
return deliver(ctx, realDeps(root));
|
|
223
242
|
}
|
|
224
243
|
case 'skills': {
|
|
@@ -241,11 +260,38 @@ function dispatch(argv, io) {
|
|
|
241
260
|
return 0;
|
|
242
261
|
}
|
|
243
262
|
case 'news':
|
|
244
|
-
return news(rest,
|
|
263
|
+
return news(rest, loadPlan(), newsDir, today, values, io);
|
|
245
264
|
default:
|
|
246
265
|
throw new RafError(`commande inconnue : ${command} (raf --help)`);
|
|
247
266
|
}
|
|
248
267
|
}
|
|
268
|
+
const GATES = {
|
|
269
|
+
ux: { on: 'revue UX obligatoire pour les lots visibles', already: 'revue UX déjà active' },
|
|
270
|
+
review: { on: 'revue de code obligatoire pour les lots à commits', already: 'revue de code déjà active' },
|
|
271
|
+
};
|
|
272
|
+
/** « raf ux … » et « raf review … » ont la même forme : activer la porte, ou noter le verdict d'un lot. */
|
|
273
|
+
function gate(kind, rest, loadPlan, root, today, io) {
|
|
274
|
+
if (rest.length < 1)
|
|
275
|
+
throw new RafError(`usage : raf ${kind} enable | ${kind} <lot> "verdict"`);
|
|
276
|
+
const plan = loadPlan();
|
|
277
|
+
if (rest[0] === 'enable') {
|
|
278
|
+
const changed = kind === 'ux' ? plan.enableUx(today) : plan.enableReview(today);
|
|
279
|
+
plan.save();
|
|
280
|
+
io.out(changed ? `${GATES[kind].on} à partir du ${today}` : `${GATES[kind].already} depuis le ${kind === 'ux' ? plan.uxSince : plan.reviewSince}`);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
if (rest.length < 2)
|
|
284
|
+
throw new RafError(`usage : raf ${kind} <lot> "verdict"`);
|
|
285
|
+
// Le plan refuse lui-même une sous-tâche, un verdict vide, un lot inconnu.
|
|
286
|
+
const verdict = rest.slice(1).join(' ');
|
|
287
|
+
if (kind === 'ux')
|
|
288
|
+
plan.recordUx(rest[0], verdict, today);
|
|
289
|
+
// Le verdict vaut jusqu'au dernier commit compté du lot ; un plan en lecture seule refuse sans lire git.
|
|
290
|
+
else
|
|
291
|
+
plan.recordReview(rest[0], verdict, today, plan.readonly ? null : (lotWork(plan, root, rest[0])[0]?.sha ?? null));
|
|
292
|
+
plan.save();
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
249
295
|
function describe(l, commits) {
|
|
250
296
|
const bits = [`${l.estimate} j`];
|
|
251
297
|
if (commits)
|
|
@@ -254,9 +300,9 @@ function describe(l, commits) {
|
|
|
254
300
|
bits.push(`sous-tâches ${l.tasks.filter((t) => t.status === 'done').length}/${l.tasks.length}`);
|
|
255
301
|
return `${l.quickwin ? '⚡ ' : ''}${l.id} ${l.title} (${bits.join(', ')})`;
|
|
256
302
|
}
|
|
257
|
-
function now(plan, root, today, io) {
|
|
303
|
+
function now(plan, root, newsDir, today, io) {
|
|
258
304
|
const lots = plan.lots();
|
|
259
|
-
const linked = linkCommits(lots,
|
|
305
|
+
const linked = linkCommits(lots, planCommits(plan, root), plan.refs);
|
|
260
306
|
const count = (id) => linked.byLot.get(id)?.length ?? 0;
|
|
261
307
|
const { doing, ready, blocked } = nextUp(lots);
|
|
262
308
|
io.out(`${plan.project} — ${today}`);
|
|
@@ -283,21 +329,20 @@ function now(plan, root, today, io) {
|
|
|
283
329
|
for (const l of done)
|
|
284
330
|
io.out(` ${l.id} ${l.title} (${l.finished})`);
|
|
285
331
|
}
|
|
286
|
-
const
|
|
287
|
-
const issues = check(lots, { ...recent, byLot: linked.byLot }, today);
|
|
332
|
+
const issues = audit(plan, root, newsDir, today);
|
|
288
333
|
if (issues.length)
|
|
289
334
|
io.out(`\n${issues.length} écart(s) entre le plan et l'historique — raf check`);
|
|
290
335
|
return 0;
|
|
291
336
|
}
|
|
292
|
-
function postCommit(
|
|
293
|
-
if (!
|
|
337
|
+
function postCommit(load, newsDir, root, io) {
|
|
338
|
+
if (!load)
|
|
294
339
|
return 0;
|
|
295
340
|
try {
|
|
296
|
-
const plan =
|
|
341
|
+
const plan = load();
|
|
297
342
|
const head = readCommits(root, { range: '-1' })[0];
|
|
298
343
|
if (!head)
|
|
299
344
|
return 0;
|
|
300
|
-
const refs =
|
|
345
|
+
const refs = plan.refs(`${head.subject}\n${head.body}`);
|
|
301
346
|
if (refs.length === 0) {
|
|
302
347
|
const planOnly = exemptPlanOnly({ byLot: new Map(), orphans: [head], unknown: [] }, plan, root).orphans.length === 0;
|
|
303
348
|
if (!planOnly && !/^Merge\b/.test(head.subject)) {
|
|
@@ -306,12 +351,13 @@ function postCommit(planPath, newsDir, root, io) {
|
|
|
306
351
|
return 0;
|
|
307
352
|
}
|
|
308
353
|
const lots = new Map(plan.lots().map((l) => [l.id, l]));
|
|
354
|
+
const planning = isPlanOnly(head.sha, plan, root);
|
|
309
355
|
for (const r of refs) {
|
|
310
356
|
const lot = lots.get(r.lot);
|
|
311
357
|
if (!lot)
|
|
312
358
|
io.err(`raf: ${r.lot} n'existe pas dans le plan`);
|
|
313
|
-
else if (lot.status === 'todo')
|
|
314
|
-
io.err(`raf: ${r.lot} est encore todo — raf start ${r.lot}`);
|
|
359
|
+
else if (lot.status === 'todo' && !planning)
|
|
360
|
+
io.err(`raf: ${r.lot} est encore todo${plan.readonly ? '' : ` — raf start ${r.lot}`}`);
|
|
315
361
|
else
|
|
316
362
|
io.err(`raf: ${r.lot} (${lot.status}) ${lot.title}`);
|
|
317
363
|
}
|
|
@@ -334,7 +380,15 @@ function news([sub, ...args], plan, dir, today, values, io) {
|
|
|
334
380
|
throw new RafError('usage : cadence news new <lot…> [--title t]');
|
|
335
381
|
for (const id of args)
|
|
336
382
|
plan.lot(id);
|
|
337
|
-
io.out(newEntry(dir, args, values.title ?? plan.lot(args[0]).title, today));
|
|
383
|
+
io.out(newEntry(dir, args, values.title ?? plan.lot(args[0]).title, today, io.now()));
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
case 'stamp': {
|
|
387
|
+
const stamped = stampEntries(dir, io.now());
|
|
388
|
+
for (const s of stamped)
|
|
389
|
+
io.out(`${s.file} created: ${s.created}`);
|
|
390
|
+
if (stamped.length === 0)
|
|
391
|
+
io.out('✓ toutes les entrées ont une heure de création');
|
|
338
392
|
return 0;
|
|
339
393
|
}
|
|
340
394
|
case 'list':
|
|
@@ -362,7 +416,7 @@ function news([sub, ...args], plan, dir, today, values, io) {
|
|
|
362
416
|
return 0;
|
|
363
417
|
}
|
|
364
418
|
default:
|
|
365
|
-
throw new RafError('usage : cadence news new|list|check|build');
|
|
419
|
+
throw new RafError('usage : cadence news new|list|check|stamp|build');
|
|
366
420
|
}
|
|
367
421
|
}
|
|
368
422
|
function session([sub, ...args], ctx, values) {
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import { isDay } from './dates.js';
|
|
4
|
+
import { FIELDS, RafError, STATUSES } from './plan.js';
|
|
5
|
+
const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
|
|
6
|
+
const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
|
|
7
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
9
|
+
/**
|
|
10
|
+
* Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
11
|
+
* Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
|
|
12
|
+
*/
|
|
13
|
+
export function readPlanConfig(file) {
|
|
14
|
+
if (!existsSync(file))
|
|
15
|
+
return null;
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
22
|
+
}
|
|
23
|
+
const p = raw?.plan;
|
|
24
|
+
if (p == null)
|
|
25
|
+
return null;
|
|
26
|
+
const bad = (what) => new RafError(`${file} : plan.${what}`);
|
|
27
|
+
if (typeof p === 'string' && p.trim() !== '')
|
|
28
|
+
return { path: p, settings: {} };
|
|
29
|
+
if (!isObject(p))
|
|
30
|
+
throw new RafError(`${file} : plan doit être un chemin ou un objet`);
|
|
31
|
+
for (const k of Object.keys(p))
|
|
32
|
+
if (!KEYS.includes(k))
|
|
33
|
+
throw bad(`${k} inconnu (attendu : ${KEYS.join(', ')})`);
|
|
34
|
+
const settings = {};
|
|
35
|
+
if (p.project != null)
|
|
36
|
+
settings.project = String(p.project);
|
|
37
|
+
if (p.since != null) {
|
|
38
|
+
if (!isDay(p.since))
|
|
39
|
+
throw bad(`since « ${String(p.since)} » n'est pas une date AAAA-MM-JJ`);
|
|
40
|
+
settings.since = p.since;
|
|
41
|
+
}
|
|
42
|
+
if (p.ignore != null)
|
|
43
|
+
settings.ignore = list(p.ignore);
|
|
44
|
+
if (p.files != null)
|
|
45
|
+
settings.files = list(p.files);
|
|
46
|
+
if (FORMAT_KEYS.some((k) => p[k] != null))
|
|
47
|
+
settings.format = parseFormat(p, bad);
|
|
48
|
+
if (p.path != null && String(p.path).trim() === '')
|
|
49
|
+
throw bad('path est vide');
|
|
50
|
+
return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
|
|
51
|
+
}
|
|
52
|
+
function parseFormat(p, bad) {
|
|
53
|
+
const format = { lots: p.lots == null ? 'lots' : String(p.lots), fields: {}, statuses: {}, estimates: {} };
|
|
54
|
+
for (const key of ['fields', 'statuses', 'estimates'])
|
|
55
|
+
if (p[key] != null && !isObject(p[key]))
|
|
56
|
+
throw bad(`${key} doit être un objet`);
|
|
57
|
+
for (const [field, keys] of Object.entries((p.fields ?? {}))) {
|
|
58
|
+
if (!FIELDS.includes(field))
|
|
59
|
+
throw bad(`fields.${field} inconnu (attendu : ${FIELDS.join(', ')})`);
|
|
60
|
+
format.fields[field] = list(keys);
|
|
61
|
+
}
|
|
62
|
+
for (const [status, theirs] of Object.entries((p.statuses ?? {}))) {
|
|
63
|
+
if (!STATUSES.includes(status))
|
|
64
|
+
throw bad(`statuses.${status} inconnu (attendu : ${STATUSES.join(', ')})`);
|
|
65
|
+
for (const s of list(theirs)) {
|
|
66
|
+
if (format.statuses[s] && format.statuses[s] !== status)
|
|
67
|
+
throw bad(`statuses : « ${s} » correspond à deux statuts`);
|
|
68
|
+
format.statuses[s] = status;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const [label, days] of Object.entries((p.estimates ?? {}))) {
|
|
72
|
+
if (typeof days !== 'number' || !(days > 0))
|
|
73
|
+
throw bad(`estimates.${label} doit être un nombre de jours positif`);
|
|
74
|
+
format.estimates[label] = days;
|
|
75
|
+
}
|
|
76
|
+
return format;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
|
|
80
|
+
* joués par cadence au lieu d'être remplacés.
|
|
81
|
+
*/
|
|
82
|
+
export function readSessionConfig(file) {
|
|
83
|
+
if (!existsSync(file))
|
|
84
|
+
return {};
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
91
|
+
}
|
|
92
|
+
const s = raw?.session;
|
|
93
|
+
if (s == null)
|
|
94
|
+
return {};
|
|
95
|
+
if (!isObject(s))
|
|
96
|
+
throw new RafError(`${file} : session doit être un objet { start, close }`);
|
|
97
|
+
const config = {};
|
|
98
|
+
for (const [k, v] of Object.entries(s)) {
|
|
99
|
+
if (k !== 'start' && k !== 'close')
|
|
100
|
+
throw new RafError(`${file} : session.${k} inconnu (attendu : start, close)`);
|
|
101
|
+
if (typeof v !== 'string' || !v.trim())
|
|
102
|
+
throw new RafError(`${file} : session.${k} : commande non vide attendue`);
|
|
103
|
+
config[k] = v.trim();
|
|
104
|
+
}
|
|
105
|
+
return config;
|
|
106
|
+
}
|
package/dist/dates.js
CHANGED
|
@@ -8,6 +8,14 @@ export function toDay(date) {
|
|
|
8
8
|
const d = String(date.getDate()).padStart(2, '0');
|
|
9
9
|
return `${y}-${m}-${d}`;
|
|
10
10
|
}
|
|
11
|
+
/** Horodatage local à la minute, fuseau explicite : `2026-09-29T10:12+02:00`. */
|
|
12
|
+
export function toStamp(date) {
|
|
13
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
14
|
+
const off = -date.getTimezoneOffset();
|
|
15
|
+
const sign = off < 0 ? '-' : '+';
|
|
16
|
+
const abs = Math.abs(off);
|
|
17
|
+
return `${toDay(date)}T${two(date.getHours())}:${two(date.getMinutes())}${sign}${two(Math.floor(abs / 60))}:${two(abs % 60)}`;
|
|
18
|
+
}
|
|
11
19
|
function parse(day) {
|
|
12
20
|
return new Date(`${day}T00:00:00Z`);
|
|
13
21
|
}
|
package/dist/deliver.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
2
|
import { parse } from 'yaml';
|
|
3
|
-
import { headSha, isAncestor, onRemote, readCommits, repoStatus } from './git.js';
|
|
4
|
-
import {
|
|
3
|
+
import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
5
|
import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
6
6
|
const POLL_CI = 15_000;
|
|
7
7
|
const CI_APPEAR = 300_000;
|
|
@@ -23,6 +23,14 @@ export function parseDeliverConfig(text, file) {
|
|
|
23
23
|
throw new RafError(`${file} : clé deliver absente`);
|
|
24
24
|
const cfg = d;
|
|
25
25
|
const bad = (what) => new RafError(`${file} : deliver.${what}`);
|
|
26
|
+
let script;
|
|
27
|
+
if (cfg.script !== undefined) {
|
|
28
|
+
if (typeof cfg.script !== 'string' || !cfg.script.trim())
|
|
29
|
+
throw bad('script : commande non vide attendue');
|
|
30
|
+
if (cfg.ci !== undefined || cfg.deploy !== undefined)
|
|
31
|
+
throw bad('script remplace ci et deploy : garder l\'un ou les autres');
|
|
32
|
+
script = cfg.script.trim();
|
|
33
|
+
}
|
|
26
34
|
let ci = 'none';
|
|
27
35
|
if (cfg.ci === 'github' || cfg.ci === 'none')
|
|
28
36
|
ci = cfg.ci;
|
|
@@ -33,6 +41,8 @@ export function parseDeliverConfig(text, file) {
|
|
|
33
41
|
}
|
|
34
42
|
else if (cfg.ci !== undefined)
|
|
35
43
|
throw bad('ci : attendu github, none ou { command: "…" }');
|
|
44
|
+
if (cfg.allowDirty !== undefined && typeof cfg.allowDirty !== 'boolean')
|
|
45
|
+
throw bad('allowDirty : true ou false attendu');
|
|
36
46
|
const seconds = (key, dflt) => {
|
|
37
47
|
const v = cfg[key];
|
|
38
48
|
if (v === undefined)
|
|
@@ -45,7 +55,8 @@ export function parseDeliverConfig(text, file) {
|
|
|
45
55
|
if (!Array.isArray(deploy) || !deploy.every((c) => typeof c === 'string' && c.trim()))
|
|
46
56
|
throw bad('deploy : liste de commandes non vides attendue');
|
|
47
57
|
const verify = cfg.verify ?? [];
|
|
48
|
-
|
|
58
|
+
// Avec un script de projet, les contrôles métier sont les siens : ceux de cadence deviennent facultatifs.
|
|
59
|
+
if (!Array.isArray(verify) || (verify.length === 0 && script === undefined)) {
|
|
49
60
|
throw bad('verify : au moins une vérification (une livraison se prouve par son effet)');
|
|
50
61
|
}
|
|
51
62
|
const checks = verify.map((v, i) => {
|
|
@@ -68,7 +79,16 @@ export function parseDeliverConfig(text, file) {
|
|
|
68
79
|
throw bad(`${where}.contains : texte attendu`);
|
|
69
80
|
return { url, ...(status === undefined ? {} : { status: status }), ...(contains === undefined ? {} : { contains }) };
|
|
70
81
|
});
|
|
71
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
ci,
|
|
84
|
+
...(script === undefined ? {} : { script }),
|
|
85
|
+
...(cfg.allowDirty ? { allowDirty: true } : {}),
|
|
86
|
+
ciTimeout: seconds('ciTimeout', 1800),
|
|
87
|
+
deploy: deploy,
|
|
88
|
+
verify: checks,
|
|
89
|
+
verifyTimeout: seconds('verifyTimeout', 300),
|
|
90
|
+
deployTimeout: seconds('deployTimeout', 1800),
|
|
91
|
+
};
|
|
72
92
|
}
|
|
73
93
|
/** Dépendances réelles : sh, gh, fetch, horloge. */
|
|
74
94
|
export function realDeps(root) {
|
|
@@ -119,6 +139,10 @@ export function realDeps(root) {
|
|
|
119
139
|
function substitute(text, sha) {
|
|
120
140
|
return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
|
|
121
141
|
}
|
|
142
|
+
/** Argument rendu tel quel au script par sh, quels que soient ses espaces, guillemets ou jokers. */
|
|
143
|
+
function shellQuote(arg) {
|
|
144
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
145
|
+
}
|
|
122
146
|
function describeCheck(c, sha) {
|
|
123
147
|
if (c.command !== undefined)
|
|
124
148
|
return c.command;
|
|
@@ -131,12 +155,19 @@ export async function deliver(ctx, deps) {
|
|
|
131
155
|
err(`deliver : ${msg}`);
|
|
132
156
|
return 2;
|
|
133
157
|
};
|
|
134
|
-
const
|
|
158
|
+
const head0 = headSha(ctx.root);
|
|
159
|
+
const sha = ctx.sha === undefined ? head0 : resolveCommit(ctx.root, ctx.sha);
|
|
135
160
|
if (!sha)
|
|
136
|
-
return refuse('aucun commit à livrer');
|
|
161
|
+
return refuse(ctx.sha === undefined ? 'aucun commit à livrer' : `--sha ${ctx.sha} : commit introuvable`);
|
|
162
|
+
if (ctx.args.length && config.script === undefined) {
|
|
163
|
+
return refuse('des arguments ne se passent qu\'à un script de projet (deliver.script dans cadence.yaml)');
|
|
164
|
+
}
|
|
165
|
+
const script = config.script === undefined ? null : [config.script, ...ctx.args.map(shellQuote)].join(' ');
|
|
137
166
|
const repo = repoStatus(ctx.root);
|
|
138
|
-
if (repo.dirty)
|
|
167
|
+
if (repo.dirty && !config.allowDirty)
|
|
139
168
|
return refuse(`${repo.dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
|
|
169
|
+
if (repo.dirty)
|
|
170
|
+
err(`deliver : ${repo.dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
|
|
140
171
|
if (!onRemote(ctx.root, sha))
|
|
141
172
|
return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
|
|
142
173
|
const lock = readLock(ctx.state);
|
|
@@ -149,16 +180,25 @@ export async function deliver(ctx, deps) {
|
|
|
149
180
|
if (why)
|
|
150
181
|
return refuse(`ci: github exige gh authentifié — ${why}`);
|
|
151
182
|
}
|
|
152
|
-
|
|
153
|
-
const
|
|
183
|
+
// Un sha choisi n'est pas forcément sur la branche courante : ne pas lui en prêter une.
|
|
184
|
+
const atHead = sha === head0;
|
|
185
|
+
const branch = atHead ? (repo.branch ?? 'HEAD détachée') : 'sha choisi, antérieur ou étranger à la tête';
|
|
186
|
+
const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: atHead ? (repo.branch ?? '') : '' };
|
|
154
187
|
if (ctx.dryRun) {
|
|
155
188
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch}) — simulation, rien n'est exécuté`);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
189
|
+
if (script !== null) {
|
|
190
|
+
out(` Script du projet : ${script}`);
|
|
191
|
+
out(` (CI, déploiement et contrôles métier sont les siens ; délai ${config.deployTimeout} s)`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
|
|
195
|
+
out(' Déploiement :');
|
|
196
|
+
if (config.deploy.length === 0)
|
|
197
|
+
out(' (aucune commande)');
|
|
198
|
+
config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
|
|
199
|
+
}
|
|
200
|
+
if (config.verify.length)
|
|
201
|
+
out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
|
|
162
202
|
config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
|
|
163
203
|
out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
164
204
|
return 0;
|
|
@@ -175,21 +215,37 @@ export async function deliver(ctx, deps) {
|
|
|
175
215
|
return 1;
|
|
176
216
|
};
|
|
177
217
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
for (const [i, cmd] of config.deploy.entries()) {
|
|
182
|
-
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
183
|
-
const code = deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
218
|
+
if (script !== null) {
|
|
219
|
+
out(`→ script du projet : ${script}`);
|
|
220
|
+
const code = deps.exec(script, env, config.deployTimeout * 1000);
|
|
184
221
|
if (code !== 0)
|
|
185
|
-
return fail(`
|
|
222
|
+
return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const ci = await waitCi(ctx, deps, sha, env);
|
|
226
|
+
if (ci)
|
|
227
|
+
return fail(ci);
|
|
228
|
+
for (const [i, cmd] of config.deploy.entries()) {
|
|
229
|
+
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
230
|
+
const code = deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
231
|
+
if (code !== 0)
|
|
232
|
+
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
|
|
233
|
+
}
|
|
186
234
|
}
|
|
187
235
|
const failed = await verifyAll(ctx, deps, sha, env);
|
|
188
236
|
if (failed)
|
|
189
237
|
return fail(failed);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
238
|
+
// Un script de projet peut commiter pendant la livraison (horodatage d'une entrée Nouveautés) :
|
|
239
|
+
// ce qu'il a livré est alors la nouvelle tête, pas le sha de départ.
|
|
240
|
+
const head = script === null || !atHead ? sha : (headSha(ctx.root) ?? sha);
|
|
241
|
+
const moved = head !== sha && isAncestor(ctx.root, sha, head);
|
|
242
|
+
const final = moved ? head : sha;
|
|
243
|
+
const delivered = deliveredLots(ctx, lastDelivery(ctx.state), final);
|
|
244
|
+
appendDelivery(ctx.state, ctx.today, final);
|
|
245
|
+
if (moved)
|
|
246
|
+
out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
|
|
247
|
+
else
|
|
248
|
+
out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
|
|
193
249
|
if (delivered)
|
|
194
250
|
out(delivered);
|
|
195
251
|
return 0;
|
|
@@ -278,9 +334,11 @@ async function verifyAll(ctx, deps, sha, env) {
|
|
|
278
334
|
const reason = await tryCheck(c, deps, sha, env, deadline - deps.now());
|
|
279
335
|
if (reason === null)
|
|
280
336
|
break;
|
|
281
|
-
|
|
337
|
+
const left = deadline - deps.now();
|
|
338
|
+
if (left <= 0)
|
|
282
339
|
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
|
|
283
|
-
|
|
340
|
+
// jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
|
|
341
|
+
await deps.sleep(Math.min(POLL_VERIFY, left));
|
|
284
342
|
}
|
|
285
343
|
}
|
|
286
344
|
return null;
|
|
@@ -295,11 +353,11 @@ function deliveredLots(ctx, prev, sha) {
|
|
|
295
353
|
const known = new Set(ctx.plan.lots().map((l) => l.id));
|
|
296
354
|
const ids = new Set();
|
|
297
355
|
for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
|
|
298
|
-
for (const r of
|
|
356
|
+
for (const r of ctx.plan.refs(`${c.subject}\n${c.body}`))
|
|
299
357
|
if (known.has(r.lot))
|
|
300
358
|
ids.add(r.lot);
|
|
301
359
|
}
|
|
302
360
|
if (ids.size === 0)
|
|
303
361
|
return null;
|
|
304
|
-
return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — raf done si l'effet est celui attendu`;
|
|
362
|
+
return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — ${ctx.plan.readonly ? "à fermer avec l'outil du projet" : 'raf done'} si l'effet est celui attendu`;
|
|
305
363
|
}
|