@sylad/cadence 0.6.0 → 0.8.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 +143 -11
- package/bin/cadence.js +6 -2
- package/dist/audit.js +36 -14
- package/dist/cli.js +76 -9
- package/dist/deliver.js +90 -32
- package/dist/git.js +12 -2
- package/dist/link.js +15 -1
- package/dist/news.js +58 -13
- package/dist/plan.js +17 -3
- package/dist/proc.js +161 -0
- package/dist/session.js +1 -0
- package/dist/state.js +10 -7
- package/dist/verify.js +114 -0
- package/package.json +13 -3
- package/skills/deliver/SKILL.md +3 -1
- package/skills/session-close/SKILL.md +5 -0
- package/skills/session-start/SKILL.md +3 -0
package/dist/deliver.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { execFileSync,
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:os';
|
|
2
3
|
import { parse } from 'yaml';
|
|
4
|
+
import { onTermination, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
|
|
5
|
+
import { isPlanOnly } from './audit.js';
|
|
3
6
|
import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
|
|
7
|
+
import { citedRefs } from './link.js';
|
|
4
8
|
import { RafError } from './plan.js';
|
|
5
9
|
import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
6
10
|
const POLL_CI = 15_000;
|
|
7
11
|
const CI_APPEAR = 300_000;
|
|
8
|
-
|
|
12
|
+
/** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
|
|
13
|
+
export const POLL_VERIFY = 10_000;
|
|
9
14
|
const CI_OK = new Set(['success', 'skipped', 'neutral']);
|
|
10
15
|
const GH_TIMEOUT = 60_000;
|
|
11
16
|
export const TIMED_OUT = 124;
|
|
@@ -106,18 +111,50 @@ export function realDeps(root) {
|
|
|
106
111
|
}
|
|
107
112
|
};
|
|
108
113
|
return {
|
|
109
|
-
exec: (cmd, env, timeoutMs) => {
|
|
110
|
-
const
|
|
114
|
+
exec: (cmd, env, timeoutMs) => new Promise((resolve) => {
|
|
115
|
+
const child = spawn('sh', ['-c', cmd], {
|
|
111
116
|
cwd: root,
|
|
112
117
|
env: { ...process.env, ...env },
|
|
113
118
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
// PAS de groupe détaché : la commande reste dans le groupe de premier plan et la session de cadence.
|
|
120
|
+
// Ctrl-C et le raccrochage l'atteignent avec cadence, et /dev/tty reste là pour ssh, sudo, pinentry.
|
|
116
121
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
const pid = child.pid;
|
|
123
|
+
if (pid === undefined) {
|
|
124
|
+
child.once('error', () => resolve(127));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// L'arbre de la commande est suivi pendant toute son exécution (relevé périodique, pid + heure de
|
|
128
|
+
// démarrage) : un enfant dont le parent meurt reste connu. Au délai, ou si cadence est tué (Ctrl-C,
|
|
129
|
+
// SIGTERM, raccrochage), TOUS les processus suivis encore vivants meurent avant que cadence ne rende la
|
|
130
|
+
// main ou ne meure — sh fait un fork par commande, et un descendant survivant livrerait encore pendant
|
|
131
|
+
// qu'une seconde livraison prend le verrou libéré ou périmé. Ctrl-C et raccrochage : l'arbre a déjà
|
|
132
|
+
// reçu le signal du terminal, un court délai de grâce laisse finir ses trap (et git son index.lock)
|
|
133
|
+
// avant le kill ; SIGTERM (à cadence seul) et le délai : kill immédiat.
|
|
134
|
+
const tree = new TreeTracker(pid);
|
|
135
|
+
// Terminaison en cours : exec ne rend pas la main avant qu'elle ne soit finie — sinon deliver libère
|
|
136
|
+
// le verrou pendant la grâce, alors que des descendants tournent encore.
|
|
137
|
+
let ending = null;
|
|
138
|
+
const forget = onTermination((sig) => (ending = sig === 'SIGTERM' ? Promise.resolve(tree.kill()) : tree.end(SIGNAL_GRACE_MS)));
|
|
139
|
+
let timedOut = false;
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
timedOut = true;
|
|
142
|
+
tree.kill();
|
|
143
|
+
}, Math.max(1_000, timeoutMs));
|
|
144
|
+
child.once('exit', (code, signal) => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
const result = timedOut ? TIMED_OUT : signal ? 128 + (constants.signals[signal] ?? 0) : (code ?? 1);
|
|
147
|
+
const settle = () => {
|
|
148
|
+
tree.stop();
|
|
149
|
+
forget();
|
|
150
|
+
resolve(result);
|
|
151
|
+
};
|
|
152
|
+
if (ending)
|
|
153
|
+
void ending.then(settle);
|
|
154
|
+
else
|
|
155
|
+
settle();
|
|
156
|
+
});
|
|
157
|
+
}),
|
|
121
158
|
gh: (sha) => JSON.parse(gh(['run', 'list', '--commit', sha, '--json', 'name,status,conclusion'])),
|
|
122
159
|
ghReady: () => {
|
|
123
160
|
try {
|
|
@@ -128,8 +165,8 @@ export function realDeps(root) {
|
|
|
128
165
|
return e.message;
|
|
129
166
|
}
|
|
130
167
|
},
|
|
131
|
-
fetch: async (url) => {
|
|
132
|
-
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(20_000) });
|
|
168
|
+
fetch: async (url, timeoutMs = 20_000) => {
|
|
169
|
+
const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(Math.max(1_000, Math.min(20_000, timeoutMs))) });
|
|
133
170
|
return { status: res.status, text: await res.text() };
|
|
134
171
|
},
|
|
135
172
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
@@ -143,7 +180,7 @@ function substitute(text, sha) {
|
|
|
143
180
|
function shellQuote(arg) {
|
|
144
181
|
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
145
182
|
}
|
|
146
|
-
function describeCheck(c, sha) {
|
|
183
|
+
export function describeCheck(c, sha) {
|
|
147
184
|
if (c.command !== undefined)
|
|
148
185
|
return c.command;
|
|
149
186
|
return `GET ${substitute(c.url, sha)} → ${c.status ?? 200}${c.contains === undefined ? '' : `, contient « ${substitute(c.contains, sha)} »`}`;
|
|
@@ -217,7 +254,7 @@ export async function deliver(ctx, deps) {
|
|
|
217
254
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
|
|
218
255
|
if (script !== null) {
|
|
219
256
|
out(`→ script du projet : ${script}`);
|
|
220
|
-
const code = deps.exec(script, env, config.deployTimeout * 1000);
|
|
257
|
+
const code = await deps.exec(script, env, config.deployTimeout * 1000);
|
|
221
258
|
if (code !== 0)
|
|
222
259
|
return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
|
|
223
260
|
}
|
|
@@ -227,7 +264,7 @@ export async function deliver(ctx, deps) {
|
|
|
227
264
|
return fail(ci);
|
|
228
265
|
for (const [i, cmd] of config.deploy.entries()) {
|
|
229
266
|
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
230
|
-
const code = deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
267
|
+
const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
231
268
|
if (code !== 0)
|
|
232
269
|
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
|
|
233
270
|
}
|
|
@@ -261,7 +298,7 @@ async function waitCi(ctx, deps, sha, env) {
|
|
|
261
298
|
return null;
|
|
262
299
|
if (typeof ci === 'object') {
|
|
263
300
|
ctx.out(`→ CI : ${ci.command}`);
|
|
264
|
-
const code = deps.exec(ci.command, env, ciTimeout * 1000);
|
|
301
|
+
const code = await deps.exec(ci.command, env, ciTimeout * 1000);
|
|
265
302
|
return code === 0 ? null : `CI en échec (${codeText(code)}) : ${ci.command}`;
|
|
266
303
|
}
|
|
267
304
|
ctx.out(`→ CI : attente des runs GitHub de ${sha.slice(0, 7)}`);
|
|
@@ -306,13 +343,14 @@ async function waitCi(ctx, deps, sha, env) {
|
|
|
306
343
|
await deps.sleep(POLL_CI);
|
|
307
344
|
}
|
|
308
345
|
}
|
|
309
|
-
|
|
346
|
+
/** Un essai d'une vérification : cause de l'échec, ou null. Partagé par deliver et `cadence verify`. */
|
|
347
|
+
export async function tryCheck(c, deps, sha, env, budgetMs) {
|
|
310
348
|
if (c.command !== undefined) {
|
|
311
|
-
const code = deps.exec(c.command, env, budgetMs);
|
|
349
|
+
const code = await deps.exec(c.command, env, budgetMs);
|
|
312
350
|
return code === 0 ? null : codeText(code);
|
|
313
351
|
}
|
|
314
352
|
try {
|
|
315
|
-
const res = await deps.fetch(substitute(c.url, sha));
|
|
353
|
+
const res = await deps.fetch(substitute(c.url, sha), budgetMs);
|
|
316
354
|
const want = c.status ?? 200;
|
|
317
355
|
if (res.status !== want)
|
|
318
356
|
return `statut ${res.status} (attendu ${want})`;
|
|
@@ -324,36 +362,56 @@ async function tryCheck(c, deps, sha, env, budgetMs) {
|
|
|
324
362
|
return `erreur réseau : ${e.message}`;
|
|
325
363
|
}
|
|
326
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* UNE boucle de réessai, pour deliver et pour `cadence verify` : essaie, puis réessaie toutes les POLL_VERIFY
|
|
367
|
+
* tant que `until` n'est pas atteint. `attemptMs()` donne le délai de chaque essai. Rend la dernière cause
|
|
368
|
+
* d'échec, ou null dès que l'effet est celui attendu.
|
|
369
|
+
*/
|
|
370
|
+
export async function retryCheck(c, deps, sha, env, until, attemptMs) {
|
|
371
|
+
for (;;) {
|
|
372
|
+
const reason = await tryCheck(c, deps, sha, env, attemptMs());
|
|
373
|
+
if (reason === null)
|
|
374
|
+
return null;
|
|
375
|
+
const left = until - deps.now();
|
|
376
|
+
if (left <= 0)
|
|
377
|
+
return reason;
|
|
378
|
+
// jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
|
|
379
|
+
await deps.sleep(Math.min(POLL_VERIFY, left));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
327
382
|
/** Chaque vérification est réessayée jusqu'au délai commun ; message d'échec ou null. */
|
|
328
383
|
async function verifyAll(ctx, deps, sha, env) {
|
|
329
384
|
const deadline = deps.now() + ctx.config.verifyTimeout * 1000;
|
|
330
385
|
for (const [i, c] of ctx.config.verify.entries()) {
|
|
331
386
|
const label = describeCheck(c, sha);
|
|
332
387
|
ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
break;
|
|
337
|
-
const left = deadline - deps.now();
|
|
338
|
-
if (left <= 0)
|
|
339
|
-
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
|
|
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));
|
|
342
|
-
}
|
|
388
|
+
const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
|
|
389
|
+
if (reason !== null)
|
|
390
|
+
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
|
|
343
391
|
}
|
|
344
392
|
return null;
|
|
345
393
|
}
|
|
346
|
-
/**
|
|
394
|
+
/**
|
|
395
|
+
* Ligne des lots cités depuis la livraison précédente, ou null (première livraison, rien de cité).
|
|
396
|
+
*
|
|
397
|
+
* Plan en lecture seule : seuls les lots EN COURS sont annoncés. Ses identifiants sont de forme libre, et
|
|
398
|
+
* un message cite volontiers un numéro qui en a la forme (réserve « R1 » d'une revue) ou un lot clos
|
|
399
|
+
* nommé pour le contexte — les annoncer « livrés » ferait fermer à tort. L'état est celui du départ de
|
|
400
|
+
* la livraison : le plan a été lu avant que le script du projet ne ferme lui-même les lots qu'il livre.
|
|
401
|
+
*/
|
|
347
402
|
function deliveredLots(ctx, prev, sha) {
|
|
348
403
|
if (!ctx.plan || !prev || prev === sha)
|
|
349
404
|
return null;
|
|
350
405
|
if (!isAncestor(ctx.root, prev, sha)) {
|
|
351
406
|
return `livraison précédente (${prev.slice(0, 7)}) hors de l'historique de ${sha.slice(0, 7)} (réécrit ?) : lots livrés non calculés`;
|
|
352
407
|
}
|
|
353
|
-
const
|
|
408
|
+
const lots = ctx.plan.lots();
|
|
409
|
+
const known = new Set((ctx.plan.readonly ? lots.filter((l) => l.status === 'doing') : lots).map((l) => l.id));
|
|
354
410
|
const ids = new Set();
|
|
355
411
|
for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
|
|
356
|
-
|
|
412
|
+
if (isPlanOnly(c.sha, ctx.plan, ctx.root))
|
|
413
|
+
continue; // entretien du plan : ne livre rien, même s'il cite des lots
|
|
414
|
+
for (const r of citedRefs(c, ctx.plan.refs))
|
|
357
415
|
if (known.has(r.lot))
|
|
358
416
|
ids.add(r.lot);
|
|
359
417
|
}
|
package/dist/git.js
CHANGED
|
@@ -45,9 +45,19 @@ export function hooksDir(cwd) {
|
|
|
45
45
|
const dir = resolve(cwd, git(cwd, ['rev-parse', '--git-path', 'hooks']).trim());
|
|
46
46
|
return basename(dir) === '_' && basename(dirname(dir)) === '.husky' ? dirname(dir) : dir;
|
|
47
47
|
}
|
|
48
|
-
/** Fichiers modifiés par un commit, relatifs à la racine du dépôt. */
|
|
48
|
+
/** Fichiers modifiés par un commit, relatifs à la racine du dépôt (noms accentués ou à espaces compris). */
|
|
49
49
|
export function changedFiles(cwd, sha) {
|
|
50
|
-
|
|
50
|
+
// -z : noms séparés par NUL, jamais échappés ; quotepath=off en plus pour les sorties qui citeraient quand même.
|
|
51
|
+
return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', sha]).split('\0').filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
/** Contenu d'un fichier à un commit (`<sha>^` pour l'état d'avant) ; null s'il n'existe pas à ce commit. */
|
|
54
|
+
export function fileAt(cwd, rev, file) {
|
|
55
|
+
try {
|
|
56
|
+
return git(cwd, ['show', `${rev}:${file}`]);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
51
61
|
}
|
|
52
62
|
function tryGit(cwd, args) {
|
|
53
63
|
try {
|
package/dist/link.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
export function isMerge(c) {
|
|
2
2
|
return /^Merge\b/.test(c.subject);
|
|
3
3
|
}
|
|
4
|
+
/** Portée d'un sujet « type(L24,L25)! : … » ; null sans parenthèses. */
|
|
5
|
+
function scopeOf(subject) {
|
|
6
|
+
return /^[\w-]+\(([^)]*)\)!?\s*:/.exec(subject)?.[1] ?? null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Références qui décident à quels lots appartient un commit : celles de la portée « type(L24) » quand elle
|
|
10
|
+
* en cite ; sinon, celles du message entier. Les mentions en passage (« page équipe (L27) »), les plages
|
|
11
|
+
* (« L28–L31 ») et le corps du message ne comptent donc pas dès que la portée désigne les lots.
|
|
12
|
+
*/
|
|
13
|
+
export function citedRefs(c, refsOf) {
|
|
14
|
+
const scope = scopeOf(c.subject);
|
|
15
|
+
const scoped = scope === null ? [] : refsOf(scope);
|
|
16
|
+
return scoped.length > 0 ? scoped : refsOf(`${c.subject}\n${c.body}`);
|
|
17
|
+
}
|
|
4
18
|
/** `refs` lit les références d'un message : `plan.refs`. */
|
|
5
19
|
export function linkCommits(lots, commits, refsOf) {
|
|
6
20
|
const ids = new Set(lots.map((l) => l.id));
|
|
@@ -9,7 +23,7 @@ export function linkCommits(lots, commits, refsOf) {
|
|
|
9
23
|
const orphans = [];
|
|
10
24
|
const unknown = [];
|
|
11
25
|
for (const c of commits) {
|
|
12
|
-
const refs =
|
|
26
|
+
const refs = citedRefs(c, refsOf);
|
|
13
27
|
if (refs.length === 0) {
|
|
14
28
|
if (!isMerge(c))
|
|
15
29
|
orphans.push(c);
|
package/dist/news.js
CHANGED
|
@@ -16,8 +16,52 @@ function validStamp(s) {
|
|
|
16
16
|
return (!Number.isNaN(Date.parse(s.replace(' ', 'T'))) && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d && h < 24 && mi < 60 && sec < 60);
|
|
17
17
|
}
|
|
18
18
|
const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
19
|
+
const CAPTURE_KEYS = ['file', 'alt'];
|
|
20
|
+
const CAPTURE_SHAPE = '{ file: chemin, alt: "texte alternatif" }';
|
|
21
|
+
/**
|
|
22
|
+
* Captures de l'en-tête : un chemin, ou `{ file, alt }` quand l'entrée dit ce que l'image montre.
|
|
23
|
+
* Les chemins restent une liste de chaînes (ce que lit déjà le JSON publié), les textes les suivent un à un.
|
|
24
|
+
*/
|
|
25
|
+
function readCaptures(raw, problems) {
|
|
26
|
+
const captures = [];
|
|
27
|
+
const alts = [];
|
|
28
|
+
for (const [i, item] of (raw == null ? [] : Array.isArray(raw) ? raw : [raw]).entries()) {
|
|
29
|
+
let file;
|
|
30
|
+
let alt = '';
|
|
31
|
+
if (typeof item === 'string' || typeof item === 'number') {
|
|
32
|
+
file = String(item);
|
|
33
|
+
}
|
|
34
|
+
else if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
35
|
+
const map = item;
|
|
36
|
+
if (map.file == null || String(map.file).trim() === '') {
|
|
37
|
+
problems.push(`capture n° ${i + 1} : fichier absent — ${CAPTURE_SHAPE}`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
file = String(map.file);
|
|
41
|
+
// Une ligne : le texte part tel quel dans un attribut alt.
|
|
42
|
+
alt = map.alt == null ? '' : String(map.alt).replace(/\s+/g, ' ').trim();
|
|
43
|
+
for (const k of Object.keys(map)) {
|
|
44
|
+
if (!CAPTURE_KEYS.includes(k))
|
|
45
|
+
problems.push(`capture ${file} : clé inconnue « ${k} » (attendu : ${CAPTURE_KEYS.join(', ')})`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
problems.push(`capture n° ${i + 1} illisible : un chemin ou ${CAPTURE_SHAPE}`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Copiées telles quelles sous le dossier de build : un chemin qui sort du dossier écrirait ailleurs.
|
|
53
|
+
if (isAbsolute(file) || file.split(/[\\/]/).includes('..'))
|
|
54
|
+
problems.push(`capture hors du dossier des Nouveautés : ${file}`);
|
|
55
|
+
// Noms sages : utilisables tels quels dans une URL, et seulement des images.
|
|
56
|
+
else if (!CAPTURE.test(file))
|
|
57
|
+
problems.push(`capture ${file} : image .png, .jpg, .webp ou .gif, nom en lettres, chiffres, « . _ - / »`);
|
|
58
|
+
captures.push(file);
|
|
59
|
+
alts.push(alt);
|
|
60
|
+
}
|
|
61
|
+
return { captures, alts };
|
|
62
|
+
}
|
|
19
63
|
export function parseEntry(file, text) {
|
|
20
|
-
const entry = { file, slug: basename(file).replace(/\.md$/, ''), title: '', date: '', lots: [], captures: [], body: '', problems: [] };
|
|
64
|
+
const entry = { file, slug: basename(file).replace(/\.md$/, ''), title: '', date: '', lots: [], captures: [], alts: [], body: '', problems: [] };
|
|
21
65
|
const m = FRONT.exec(text.replace(/^\uFEFF/, '')); // BOM des éditeurs Windows
|
|
22
66
|
if (!m) {
|
|
23
67
|
entry.problems.push('en-tête YAML absent (--- … ---)');
|
|
@@ -48,18 +92,10 @@ export function parseEntry(file, text) {
|
|
|
48
92
|
entry.created = created.replace(' ', 'T');
|
|
49
93
|
}
|
|
50
94
|
entry.lots = list(head.lots);
|
|
51
|
-
entry.captures = list(head.captures);
|
|
52
95
|
if (head.nocapture != null && String(head.nocapture).trim())
|
|
53
96
|
entry.nocapture = String(head.nocapture).trim();
|
|
54
97
|
entry.body = m[2].trim();
|
|
55
|
-
|
|
56
|
-
// Copiées telles quelles sous le dossier de build : un chemin qui sort du dossier écrirait ailleurs.
|
|
57
|
-
if (isAbsolute(c) || c.split(/[\\/]/).includes('..'))
|
|
58
|
-
entry.problems.push(`capture hors du dossier des Nouveautés : ${c}`);
|
|
59
|
-
// Noms sages : utilisables tels quels dans une URL, et seulement des images.
|
|
60
|
-
else if (!CAPTURE.test(c))
|
|
61
|
-
entry.problems.push(`capture ${c} : image .png, .jpg, .webp ou .gif, nom en lettres, chiffres, « . _ - / »`);
|
|
62
|
-
}
|
|
98
|
+
Object.assign(entry, readCaptures(head.captures, entry.problems));
|
|
63
99
|
if (!entry.title)
|
|
64
100
|
entry.problems.push('title vide');
|
|
65
101
|
if (!isDay(entry.date))
|
|
@@ -136,7 +172,7 @@ export function newEntry(dir, lots, rawTitle, today, now) {
|
|
|
136
172
|
if (existsSync(path))
|
|
137
173
|
throw new RafError(`${path} existe déjà`);
|
|
138
174
|
mkdirSync(dir, { recursive: true });
|
|
139
|
-
writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
|
|
175
|
+
writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# une capture peut dire ce qu'elle montre : captures: [${CAPTURE_SHAPE.replace('chemin', 'captures/x.png')}]\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
|
|
140
176
|
return path;
|
|
141
177
|
}
|
|
142
178
|
/**
|
|
@@ -210,7 +246,16 @@ export function newsData(project, entries, generated) {
|
|
|
210
246
|
return {
|
|
211
247
|
project,
|
|
212
248
|
generated,
|
|
213
|
-
entries: entries.map((e) => ({
|
|
249
|
+
entries: entries.map((e) => ({
|
|
250
|
+
slug: e.slug,
|
|
251
|
+
title: e.title,
|
|
252
|
+
date: e.date,
|
|
253
|
+
lots: e.lots,
|
|
254
|
+
captures: e.captures,
|
|
255
|
+
// Clé ajoutée seulement quand elle dit quelque chose : une entrée sans texte garde sa forme d'avant.
|
|
256
|
+
...(e.alts.some(Boolean) ? { alts: e.alts } : {}),
|
|
257
|
+
html: renderMarkdown(e.body),
|
|
258
|
+
})),
|
|
214
259
|
};
|
|
215
260
|
}
|
|
216
261
|
/** Écrit nouveautes.json, index.html et copie les captures sous `out`. */
|
|
@@ -237,7 +282,7 @@ function renderNewsPage(data) {
|
|
|
237
282
|
<h2>${escapeText(e.title)}</h2>
|
|
238
283
|
<p class="meta"><time datetime="${e.date}">${e.date}</time> · ${e.lots.map(escapeText).join(', ')}</p>
|
|
239
284
|
${e.html}
|
|
240
|
-
${e.captures.map((c) => `<a href="${escapeText(c)}"><img src="${escapeText(c)}" alt="Capture : ${
|
|
285
|
+
${e.captures.map((c, i) => `<a href="${escapeText(c)}"><img src="${escapeText(c)}" alt="${escapeText(e.alts?.[i] || `Capture : ${e.title}`)}" loading="lazy"></a>`).join('\n ')}
|
|
241
286
|
</article>`)
|
|
242
287
|
.join('\n');
|
|
243
288
|
return `<!doctype html>
|
package/dist/plan.js
CHANGED
|
@@ -12,9 +12,15 @@ export function isOpen(status) {
|
|
|
12
12
|
function escapeRe(s) {
|
|
13
13
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Ce qui ne peut pas suivre un identifiant cité : un point suivi d'un caractère de mot — « L1.4 » n'est
|
|
17
|
+
* pas le lot L1, « NC2.4 » n'est pas NC2 — alors que le point qui finit une phrase (« voir L1. ») passe.
|
|
18
|
+
* Même garde pour le format de raf et pour les identifiants d'un plan en lecture seule.
|
|
19
|
+
*/
|
|
20
|
+
const DOTTED = '\\.\\w';
|
|
15
21
|
/** Matches `L3` or `L3/t1` as whole words. Group 1 = lot id, group 2 = task id. */
|
|
16
22
|
export function refPattern(prefix) {
|
|
17
|
-
return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(
|
|
23
|
+
return new RegExp(`(?<![\\w/])(${escapeRe(prefix)}\\d+)(?:/(t\\d+))?(?!\\w|${DOTTED})`, 'g');
|
|
18
24
|
}
|
|
19
25
|
export function extractRefs(text, prefix) {
|
|
20
26
|
return [...text.matchAll(refPattern(prefix))].map((m) => ({ lot: m[1], task: m[2] }));
|
|
@@ -116,6 +122,10 @@ export class Plan {
|
|
|
116
122
|
}
|
|
117
123
|
return { patterns, invalid };
|
|
118
124
|
}
|
|
125
|
+
/** Fichier de configuration lu pour ce plan, null quand on n'en connaît pas (cadence.yaml à la racine alors). */
|
|
126
|
+
get configFile() {
|
|
127
|
+
return this.settings.config ?? null;
|
|
128
|
+
}
|
|
119
129
|
/** Fichiers tenus avec le plan, relatifs à la racine du dépôt. */
|
|
120
130
|
get files() {
|
|
121
131
|
return this.settings.files ?? [];
|
|
@@ -149,7 +159,7 @@ export class Plan {
|
|
|
149
159
|
ids: new Set(ids),
|
|
150
160
|
tasks: new Set(lots.flatMap((l) => l.tasks.map((t) => `${l.id}/${t.id}`))),
|
|
151
161
|
refs: ids.length
|
|
152
|
-
? new RegExp(`(?<![\\w/.-])(${ids.map(escapeRe).join('|')})(?:/([\\w-]+(?:\\.[\\w-]+)*))?(?![\\w-]
|
|
162
|
+
? new RegExp(`(?<![\\w/.-])(${ids.map(escapeRe).join('|')})(?:/([\\w-]+(?:\\.[\\w-]+)*))?(?![\\w-]|${DOTTED})`, 'g')
|
|
153
163
|
: null,
|
|
154
164
|
};
|
|
155
165
|
}
|
|
@@ -431,7 +441,11 @@ function asVerdict(raw) {
|
|
|
431
441
|
if (!raw || typeof raw !== 'object')
|
|
432
442
|
return undefined;
|
|
433
443
|
const v = raw;
|
|
434
|
-
|
|
444
|
+
// Écrit à la main sans verdict (clé absente, vide ou blanche) : rien n'a été dit de la revue, la porte
|
|
445
|
+
// reste fermée — comme `raf ux` et `raf review` refusent d'enregistrer un verdict vide.
|
|
446
|
+
if (String(v.verdict ?? '').trim() === '')
|
|
447
|
+
return undefined;
|
|
448
|
+
const verdict = { date: String(v.date ?? ''), verdict: String(v.verdict) };
|
|
435
449
|
// Champ présent mais vide : relu « jusqu'à rien », comme un verdict noté sans commit.
|
|
436
450
|
if ('commit' in v)
|
|
437
451
|
verdict.commit = v.commit == null || String(v.commit).trim() === '' ? null : String(v.commit).trim();
|
package/dist/proc.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
/**
|
|
4
|
+
* Signaux qui terminent cadence pendant qu'une commande tourne : chaque commande en cours inscrit son
|
|
5
|
+
* nettoyage, cadence l'exécute avant de sortir — tué par le même signal, comme sans nettoyage.
|
|
6
|
+
*/
|
|
7
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
8
|
+
const cleanups = new Set();
|
|
9
|
+
let dying = false;
|
|
10
|
+
/**
|
|
11
|
+
* Délai de grâce après Ctrl-C ou raccrochage : l'arbre de la commande a déjà reçu le signal du terminal,
|
|
12
|
+
* ses `trap` et le nettoyage de git (index.lock) ont ce temps pour finir avant le kill.
|
|
13
|
+
*/
|
|
14
|
+
export const SIGNAL_GRACE_MS = 2_000;
|
|
15
|
+
async function onSignal(sig) {
|
|
16
|
+
if (dying)
|
|
17
|
+
return; // un second signal pendant le nettoyage n'interrompt rien
|
|
18
|
+
dying = true;
|
|
19
|
+
await Promise.all([...cleanups].map((f) => f(sig)));
|
|
20
|
+
cleanups.clear();
|
|
21
|
+
for (const s of SIGNALS)
|
|
22
|
+
process.removeListener(s, onSignal);
|
|
23
|
+
process.kill(process.pid, sig);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Inscrit un nettoyage à faire si cadence reçoit Ctrl-C, SIGTERM ou le raccrochage ; rend son retrait.
|
|
27
|
+
* Le nettoyage peut être asynchrone : cadence l'attend avant de mourir du signal.
|
|
28
|
+
*/
|
|
29
|
+
export function onTermination(cleanup) {
|
|
30
|
+
if (cleanups.size === 0)
|
|
31
|
+
for (const s of SIGNALS)
|
|
32
|
+
process.on(s, onSignal);
|
|
33
|
+
cleanups.add(cleanup);
|
|
34
|
+
return () => {
|
|
35
|
+
if (dying || !cleanups.delete(cleanup))
|
|
36
|
+
return;
|
|
37
|
+
if (cleanups.size === 0)
|
|
38
|
+
for (const s of SIGNALS)
|
|
39
|
+
process.removeListener(s, onSignal);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function signal(pid, sig) {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, sig);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false; // déjà mort
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Tous les processus visibles : /proc sous Linux (starttime, champ 22 de stat), `ps` ailleurs (lstart). */
|
|
52
|
+
function readProcs() {
|
|
53
|
+
const procs = new Map();
|
|
54
|
+
let names = null;
|
|
55
|
+
try {
|
|
56
|
+
names = readdirSync('/proc').filter((n) => /^\d+$/.test(n));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// pas de /proc (macOS) : ps
|
|
60
|
+
}
|
|
61
|
+
if (names) {
|
|
62
|
+
for (const n of names) {
|
|
63
|
+
try {
|
|
64
|
+
const stat = readFileSync(`/proc/${n}/stat`, 'utf8');
|
|
65
|
+
// « pid (comm) état ppid … starttime … » — comm peut contenir espaces et parenthèses
|
|
66
|
+
const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
67
|
+
procs.set(Number(n), { ppid: Number(f[1]), start: f[19], zombie: f[0] === 'Z' });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// sorti entre-temps
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
for (const line of execFileSync('ps', ['-A', '-o', 'pid=,ppid=,stat=,lstart='], { encoding: 'utf8' }).trim().split('\n')) {
|
|
76
|
+
const [pid, ppid, stat, ...lstart] = line.trim().split(/\s+/);
|
|
77
|
+
procs.set(Number(pid), { ppid: Number(ppid), start: lstart.join(' '), zombie: stat.startsWith('Z') });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return procs;
|
|
81
|
+
}
|
|
82
|
+
/** Intervalle du relevé des descendants d'une commande de deliver pendant qu'elle tourne. */
|
|
83
|
+
export const TRACK_INTERVAL_MS = 200;
|
|
84
|
+
/**
|
|
85
|
+
* Suivi continu de l'arbre d'une commande : toutes les TRACK_INTERVAL_MS, ses descendants sont relevés et
|
|
86
|
+
* chacun est retenu avec son heure de démarrage. Un processus vu une fois reste suivi même si son parent
|
|
87
|
+
* meurt (rattaché à init, il n'est plus sous la racine) — ses propres enfants aussi. Un pid suivi n'est
|
|
88
|
+
* signalé que si son heure de démarrage est toujours la même : jamais un pid réutilisé par un autre.
|
|
89
|
+
* Échappe seulement ce qui quitte l'arbre entre deux relevés (fork puis mort du parent en moins d'un
|
|
90
|
+
* intervalle) et ce que cadence n'a pas le droit de tuer (sudo).
|
|
91
|
+
*/
|
|
92
|
+
export class TreeTracker {
|
|
93
|
+
known = new Map();
|
|
94
|
+
timer;
|
|
95
|
+
constructor(root) {
|
|
96
|
+
const info = readProcs().get(root);
|
|
97
|
+
if (info)
|
|
98
|
+
this.known.set(root, info.start);
|
|
99
|
+
this.timer = setInterval(() => this.scan(), TRACK_INTERVAL_MS);
|
|
100
|
+
this.timer.unref();
|
|
101
|
+
}
|
|
102
|
+
/** Arrête le relevé périodique (la commande est finie, ou son arbre est tué). */
|
|
103
|
+
stop() {
|
|
104
|
+
clearInterval(this.timer);
|
|
105
|
+
}
|
|
106
|
+
/** Relève les nouveaux descendants des processus suivis encore vivants ; rend le relevé. */
|
|
107
|
+
scan(procs = readProcs()) {
|
|
108
|
+
for (let grew = true; grew;) {
|
|
109
|
+
grew = false;
|
|
110
|
+
for (const [pid, info] of procs) {
|
|
111
|
+
if (this.known.has(pid) || !this.isTracked(info.ppid, procs))
|
|
112
|
+
continue;
|
|
113
|
+
this.known.set(pid, info.start);
|
|
114
|
+
grew = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return procs;
|
|
118
|
+
}
|
|
119
|
+
/** Processus suivis encore vivants (même heure de démarrage, pas zombies). */
|
|
120
|
+
alive(procs = this.scan()) {
|
|
121
|
+
return [...this.known.keys()].filter((pid) => this.isTracked(pid, procs) && !procs.get(pid).zombie);
|
|
122
|
+
}
|
|
123
|
+
isTracked(pid, procs) {
|
|
124
|
+
const start = this.known.get(pid);
|
|
125
|
+
return start !== undefined && procs.get(pid)?.start === start;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Tue tous les processus suivis encore vivants et TOUS leurs descendants actuels, sans en laisser filer un :
|
|
129
|
+
* chacun est d'abord arrêté (SIGSTOP) — plus aucun fork, et plus aucun enfant rattaché à init par la mort
|
|
130
|
+
* de son parent —, l'arbre est relu jusqu'à ce qu'il ne s'y ajoute plus rien, puis tout est tué (SIGKILL).
|
|
131
|
+
*/
|
|
132
|
+
kill() {
|
|
133
|
+
this.stop();
|
|
134
|
+
const seen = new Set([process.pid]);
|
|
135
|
+
const stopped = [];
|
|
136
|
+
for (let fresh = this.alive(); fresh.length > 0;) {
|
|
137
|
+
for (const pid of fresh) {
|
|
138
|
+
seen.add(pid); // essayé une fois : mort entre-temps ou hors d'atteinte (sudo), il ne revient pas
|
|
139
|
+
if (signal(pid, 'SIGSTOP'))
|
|
140
|
+
stopped.push(pid);
|
|
141
|
+
}
|
|
142
|
+
const procs = this.scan();
|
|
143
|
+
fresh = this.alive(procs).filter((pid) => !seen.has(pid));
|
|
144
|
+
}
|
|
145
|
+
for (const pid of stopped)
|
|
146
|
+
signal(pid, 'SIGKILL');
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Laisse à l'ensemble suivi jusqu'à `graceMs` pour finir de lui-même (il a déjà reçu le signal du terminal),
|
|
150
|
+
* puis tue ce qui reste. Rend la main dès que plus aucun processus suivi n'est vivant.
|
|
151
|
+
*/
|
|
152
|
+
async end(graceMs) {
|
|
153
|
+
this.stop();
|
|
154
|
+
const deadline = Date.now() + graceMs;
|
|
155
|
+
while (this.alive().length > 0) {
|
|
156
|
+
if (Date.now() >= deadline)
|
|
157
|
+
return this.kill();
|
|
158
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -107,6 +107,7 @@ export function sessionStart(ctx, opts) {
|
|
|
107
107
|
section(out, `Fait depuis ${opts.since}`, done);
|
|
108
108
|
section(out, 'Écarts (raf check)', audit(plan, ctx.root, ctx.newsDir, today).map((i) => `✗ ${i.message}`));
|
|
109
109
|
section(out, 'Dépôt', [repoLine(ctx.root).line]);
|
|
110
|
+
section(out, 'Effets en production (cadence verify)', ctx.effects ?? []);
|
|
110
111
|
section(out, 'Faits propres au projet', projectFacts(ctx, opts.since));
|
|
111
112
|
const proposals = [
|
|
112
113
|
...doing.map((l) => ({ l, why: 'en cours' })),
|
package/dist/state.js
CHANGED
|
@@ -21,14 +21,17 @@ export function readNext(dir) {
|
|
|
21
21
|
const lines = rest.map((l) => l.replace(/^- /, '')).filter(Boolean);
|
|
22
22
|
return { date: head.replace(/^# /, '').trim(), lines };
|
|
23
23
|
}
|
|
24
|
-
/** Remplace les notes pour la prochaine session
|
|
24
|
+
/** Remplace les notes pour la prochaine session. Ne les efface jamais : c'est `clearNext`, demandé exprès. */
|
|
25
25
|
export function writeNext(dir, date, lines) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
if (lines.length === 0)
|
|
27
|
+
throw new Error('writeNext : aucune ligne');
|
|
28
|
+
writeFileSync(join(dir, 'next.md'), `# ${date}\n${lines.map((l) => `- ${l.replace(/\n/g, ' ')}`).join('\n')}\n`);
|
|
29
|
+
}
|
|
30
|
+
/** Efface les notes pour la prochaine session ; rend celles qui s'y trouvaient, null s'il n'y en avait pas. */
|
|
31
|
+
export function clearNext(dir) {
|
|
32
|
+
const previous = readNext(dir);
|
|
33
|
+
rmSync(join(dir, 'next.md'), { force: true });
|
|
34
|
+
return previous;
|
|
32
35
|
}
|
|
33
36
|
export function lockPath(dir) {
|
|
34
37
|
return join(dir, 'deliver.lock');
|