@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/dist/deliver.js CHANGED
@@ -1,11 +1,16 @@
1
- import { execFileSync, spawnSync } from 'node:child_process';
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
- const POLL_VERIFY = 10_000;
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 r = spawnSync('sh', ['-c', cmd], {
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
- timeout: Math.max(1_000, timeoutMs),
115
- killSignal: 'SIGKILL',
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
- if (r.error?.code === 'ETIMEDOUT' || r.signal)
118
- return TIMED_OUT;
119
- return r.status ?? 1;
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
- async function tryCheck(c, deps, sha, env, budgetMs) {
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
- for (;;) {
334
- const reason = await tryCheck(c, deps, sha, env, deadline - deps.now());
335
- if (reason === null)
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
- /** Ligne des lots cités depuis la livraison précédente, ou null (première livraison, rien de cité). */
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 known = new Set(ctx.plan.lots().map((l) => l.id));
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
- for (const r of ctx.plan.refs(`${c.subject}\n${c.body}`))
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
- return git(cwd, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha]).split('\n').filter(Boolean);
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 = refsOf(`${c.subject}\n${c.body}`);
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
- for (const c of entry.captures) {
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) => ({ slug: e.slug, title: e.title, date: e.date, lots: e.lots, captures: e.captures, html: renderMarkdown(e.body) })),
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 : ${escapeText(e.title)}" loading="lazy"></a>`).join('\n ')}
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+))?(?![\\w])`, 'g');
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-]|\\.\\w)`, 'g')
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
- const verdict = { date: String(v.date ?? ''), verdict: String(v.verdict ?? '') };
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 ; sans ligne, les efface. */
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
- const file = join(dir, 'next.md');
27
- if (lines.length === 0) {
28
- rmSync(file, { force: true });
29
- return;
30
- }
31
- writeFileSync(file, `# ${date}\n${lines.map((l) => `- ${l.replace(/\n/g, ' ')}`).join('\n')}\n`);
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');