@sylad/cadence 1.13.0 → 1.15.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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +3 -0
  4. package/agents/reference-planner.md +26 -0
  5. package/dist/audit.js +5 -2
  6. package/dist/cli.js +195 -21
  7. package/dist/config-set.js +6 -3
  8. package/dist/config.js +47 -9
  9. package/dist/corrections.js +1 -0
  10. package/dist/git.js +11 -1
  11. package/dist/lead.js +5 -1
  12. package/dist/orchestrate/accept.js +63 -1
  13. package/dist/orchestrate/baseline.js +39 -3
  14. package/dist/orchestrate/changelog-merge.js +98 -5
  15. package/dist/orchestrate/command.js +118 -12
  16. package/dist/orchestrate/cycle.js +255 -63
  17. package/dist/orchestrate/decisions.js +2 -0
  18. package/dist/orchestrate/integrate.js +97 -5
  19. package/dist/orchestrate/launch.js +2 -0
  20. package/dist/orchestrate/state.js +1 -1
  21. package/dist/orchestrate/workers.js +51 -0
  22. package/dist/orchestrate/worktree.js +15 -4
  23. package/dist/perf.js +9 -1
  24. package/dist/plan-commit.js +90 -0
  25. package/dist/plan.js +41 -3
  26. package/dist/ref/analyse.js +34 -4
  27. package/dist/ref/command.js +2 -1
  28. package/dist/ref/coverage.js +136 -0
  29. package/dist/ref/diff.js +99 -15
  30. package/dist/release.js +5 -1
  31. package/dist/session.js +7 -1
  32. package/dist/stale-notes.js +22 -0
  33. package/dist/verify-gate.js +138 -0
  34. package/docs/deliver.md +37 -0
  35. package/docs/fonctionnement.md +2 -1
  36. package/docs/lead.md +6 -4
  37. package/docs/orchestrate.md +67 -13
  38. package/docs/overview.md +3 -2
  39. package/docs/perf.md +3 -1
  40. package/docs/raf.md +50 -2
  41. package/docs/ref.md +61 -3
  42. package/docs/releasing.md +5 -3
  43. package/docs/session.md +8 -0
  44. package/docs/skills.md +1 -0
  45. package/docs/spec.md +2 -0
  46. package/docs/stabilite.md +2 -0
  47. package/docs/whats-new.md +9 -0
  48. package/package.json +3 -3
  49. package/skills/lead/SKILL.md +8 -6
  50. package/skills/orchestrate/SKILL.md +1 -1
  51. package/skills/session-close/SKILL.md +2 -0
  52. package/skills/session-start/SKILL.md +2 -1
package/dist/config.js CHANGED
@@ -1,11 +1,14 @@
1
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { isDay } from './dates.js';
5
5
  import { DEFAULT_TEST_PATTERNS } from './testgate.js';
6
6
  import { gitRoot } from './git.js';
7
+ import { planCommitProblem } from './plan-commit.js';
8
+ import { isComposedGate } from './orchestrate/workers.js';
7
9
  import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
8
- const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add'];
10
+ const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates', 'add', 'onWrite', 'commit'];
11
+ const ON_WRITE_KEYS = ['command', 'files'];
9
12
  const FORMAT_KEYS = ['lots', 'fields', 'statuses', 'estimates'];
10
13
  const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
11
14
  const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
@@ -42,6 +45,25 @@ function readQa(qa, file, workRoot) {
42
45
  throw new RafError(`${file} : qa.expectations « ${v.trim()} » est hors du dépôt`);
43
46
  return rel;
44
47
  }
48
+ /** Clé `plan.onWrite` (L346) : la commande qui republie le plan après chaque écriture, lancée dans `cwd` (la racine du dépôt), et les fichiers qu'elle produit (relatifs au dépôt, jamais hors de lui). */
49
+ function readOnWrite(v, cwd, bad) {
50
+ if (!isObject(v))
51
+ throw bad('onWrite doit être un objet { command, files }');
52
+ for (const k of Object.keys(v))
53
+ if (!ON_WRITE_KEYS.includes(k))
54
+ throw bad(`onWrite.${k} inconnu (attendu : ${ON_WRITE_KEYS.join(', ')})`);
55
+ if (typeof v.command !== 'string' || v.command.trim() === '')
56
+ throw bad('onWrite.command doit être une commande');
57
+ const files = list(v.files).map((f) => {
58
+ const rel = posix.normalize(f.trim().replace(/\\/g, '/'));
59
+ if (rel === '.' || rel === '..' || rel.startsWith('../') || isAbsolute(rel))
60
+ throw bad(`onWrite.files « ${f} » est hors du dépôt`);
61
+ if (existsSync(resolve(cwd, rel)) && statSync(resolve(cwd, rel)).isDirectory())
62
+ throw bad(`onWrite.files « ${f} » est un dossier (déclarer les fichiers produits, pas leur dossier)`);
63
+ return rel;
64
+ });
65
+ return { command: v.command, files, cwd };
66
+ }
45
67
  /**
46
68
  * Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
47
69
  * `root` : la racine du dépôt de travail (celle du CLI), d'où se résout un chemin absolu de `qa.expectations`.
@@ -84,13 +106,20 @@ export function readPlanConfig(file, root) {
84
106
  settings.ignore = list(p.ignore);
85
107
  if (p.files != null)
86
108
  settings.files = list(p.files);
109
+ if (p.onWrite != null)
110
+ settings.onWrite = readOnWrite(p.onWrite, root ?? dirname(resolve(file)), bad);
87
111
  if (FORMAT_KEYS.some((k) => p[k] != null))
88
112
  settings.format = parseFormat(p, bad);
89
113
  if (p.path != null && String(p.path).trim() === '')
90
114
  throw bad('path est vide');
91
115
  if (p.add != null && (typeof p.add !== 'string' || !p.add.includes('{title}')))
92
116
  throw bad('add doit être une commande qui contient {title}');
93
- return { ...(p.path != null ? { path: String(p.path) } : {}), ...(p.add != null ? { add: String(p.add) } : {}), settings };
117
+ if (p.commit != null) {
118
+ const problem = typeof p.commit === 'string' ? planCommitProblem(p.commit) : 'doit être un gabarit de message';
119
+ if (problem)
120
+ throw bad(`commit ${problem}`);
121
+ }
122
+ return { ...(p.path != null ? { path: String(p.path) } : {}), ...(p.add != null ? { add: String(p.add) } : {}), ...(p.commit != null ? { commit: String(p.commit) } : {}), settings };
94
123
  }
95
124
  export const PLAN_DEFAULT = '.cadence/raf.yaml';
96
125
  export const PLAN_LEGACY = 'docs/plan/raf.yaml';
@@ -283,9 +312,8 @@ export function readSessionConfig(file) {
283
312
  }
284
313
  const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
285
314
  export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
286
- const MODELS = ['haiku', 'sonnet', 'opus'];
287
315
  const REVIEW_KEYS = ['threshold', 'light', 'full'];
288
- const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
316
+ const ORCH_KEYS = ['start', 'verdict', 'test', 'testReplay', 'build', 'worktreeDeps', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'escalation', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
289
317
  /** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
290
318
  export function readOrchestrateConfig(file) {
291
319
  const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: false, worktrees: false, spec: { dir: 'docs/specs' }, review: { threshold: 0.25, light: 'sonnet', full: 'opus' }, effort: { precheck: 'low', spec: 'medium', maquette: 'high', fiche: 'medium', accept: 'medium', implement: 'medium', fix: 'medium', review: 'high', ux: 'high' }, permissionMode: 'auto', addDirs: [], timeouts: { work: 45 * 60_000, review: 25 * 60_000 } };
@@ -320,7 +348,7 @@ export function readOrchestrateConfig(file) {
320
348
  throw bad('accept : commande non vide attendue (chaîne ou liste de commandes non vides)');
321
349
  config.accept = o.accept.map((x) => x.trim());
322
350
  }
323
- for (const k of ['start', 'verdict', 'test', 'build', 'accept']) {
351
+ for (const k of ['start', 'verdict', 'test', 'testReplay', 'build', 'accept']) {
324
352
  if (k === 'accept' && Array.isArray(o.accept))
325
353
  continue;
326
354
  if (o[k] == null)
@@ -329,6 +357,16 @@ export function readOrchestrateConfig(file) {
329
357
  throw bad(`${k} : commande non vide attendue`);
330
358
  config[k] = o[k].trim();
331
359
  }
360
+ if (config.testReplay && !config.testReplay.includes('{files}'))
361
+ throw bad('testReplay : la commande doit contenir {files} (remplacé par les fichiers des tests rouges)');
362
+ if (config.testReplay && config.test && isComposedGate(config.test))
363
+ throw bad('testReplay : refusé quand orchestrate.test est une porte composée (&&, ||, sous-shell) : le rejeu seul ne vaut que pour un lanceur unique');
364
+ if (o.worktreeDeps != null) {
365
+ const deps = o.worktreeDeps;
366
+ if (!Array.isArray(deps) || !deps.length || deps.some((x) => typeof x !== 'string' || !x.trim() || isAbsolute(x) || x.split(/[\\/]/).includes('..')))
367
+ throw bad('worktreeDeps : liste de chemins relatifs au dépôt, sans « .. » (node_modules, .venv…)');
368
+ config.worktreeDeps = deps.map((x) => x.trim().replace(/\/$/, ''));
369
+ }
332
370
  if (o.maquette != null) {
333
371
  if (typeof o.maquette !== 'boolean')
334
372
  throw bad('maquette : true ou false attendu');
@@ -418,9 +456,9 @@ export function readOrchestrateConfig(file) {
418
456
  const m = o.review[k];
419
457
  if (m == null)
420
458
  continue;
421
- if (!MODELS.includes(m))
422
- throw bad(`review.${k} : ${MODELS.join(', ')} attendu`);
423
- config.review[k] = m;
459
+ if (typeof m !== 'string' || !m.trim())
460
+ throw bad(`review.${k} : nom de modèle non vide attendu (haiku, sonnet, opus ou un nom passé tel quel à claude --model)`);
461
+ config.review[k] = m.trim();
424
462
  }
425
463
  }
426
464
  if (o.escalation != null) {
@@ -121,6 +121,7 @@ export function readJournals(runsDir, until, since) {
121
121
  ...(typeof rec.escalationReserve === 'number' ? { escalationReserve: rec.escalationReserve } : {}),
122
122
  ...(precheck ? { precheck } : {}),
123
123
  ...(escalation ? { escalation } : {}),
124
+ ...(Array.isArray(rec.instables) && rec.instables.some((t) => typeof t === 'string') ? { instables: rec.instables.filter((t) => typeof t === 'string') } : {}),
124
125
  ...(Array.isArray(rec.warnings) && rec.warnings.some((w) => typeof w === 'string' && w.startsWith(LOCAL_PRECHECK_UNREADABLE)) ? { localUnreadable: true } : {}),
125
126
  steps: steps.map((s) => {
126
127
  const usage = usageOf(s.tokens);
package/dist/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { rmSync } from 'node:fs';
2
3
  import { basename, dirname, resolve } from 'node:path';
3
4
  function git(cwd, args) {
4
5
  // Le journal complet d'un gros dépôt dépasse le Mo par défaut : sans marge, il serait lu comme vide.
@@ -110,7 +111,7 @@ export function fileAt(cwd, rev, file) {
110
111
  return null;
111
112
  }
112
113
  }
113
- function tryGit(cwd, args) {
114
+ export function tryGit(cwd, args) {
114
115
  try {
115
116
  return git(cwd, args).trim();
116
117
  }
@@ -230,6 +231,15 @@ export function commitPaths(root, paths, message) {
230
231
  }
231
232
  return tryGit(root, ['rev-parse', '--short', 'HEAD']) ?? '';
232
233
  }
234
+ /** Remet `paths` comme à HEAD : un fichier suivi retrouve sa version commitée, un fichier que HEAD ne connaît pas est supprimé. */
235
+ export function restorePaths(root, paths) {
236
+ for (const f of paths) {
237
+ if (tryGit(root, ['cat-file', '-e', `HEAD:${f}`]) !== null)
238
+ git(root, ['checkout', '-q', 'HEAD', '--', f]);
239
+ else
240
+ rmSync(resolve(root, f), { force: true });
241
+ }
242
+ }
233
243
  /** Un fichier SUIVI est-il modifié (indexé ou non) ? Les fichiers non suivis ne comptent pas : un commit à chemins explicites ne les embarque pas. `except` : chemins qu'on ne compte pas. */
234
244
  export function hasTrackedChanges(cwd, except = []) {
235
245
  return git(cwd, ['status', '--porcelain', '--untracked-files=no', '--', '.', ...except.map((f) => `:(exclude)${f}`)]).trim() !== '';
package/dist/lead.js CHANGED
@@ -12,6 +12,7 @@ import { priorityRank, readPriority } from './orchestrate/continue.js';
12
12
  import { readNext, sharedStateDir, stateDir } from './state.js';
13
13
  import { freeLine, readyFree } from './ready-free.js';
14
14
  import { cadenceHome } from './orchestrate/registry.js';
15
+ import { staleNotes, staleText } from './stale-notes.js';
15
16
  import { noTestGate, noTestText } from './orchestrate/no-test.js';
16
17
  import { KEPT_TITLE, keptLine, keptTrees } from './orchestrate/worktree.js';
17
18
  /** Clé `lead.ux` du cadence.yaml du dossier parent (L197) : qui pose le verdict UX (`raf ux`) d'un lot visible. `human` (défaut) : l'humain ; `lead` : le lead, l'humain n'étant consulté qu'en cas de doute. */
@@ -123,6 +124,9 @@ export function tourRow(dir, today, idle = TOUR_IDLE, name = basename(dir)) {
123
124
  });
124
125
  row.drift = audit(plan, root, join(root, 'docs/nouveautes'), today).map((i) => `${i.warning ? '⚠' : '✗'} ${i.message}`);
125
126
  row.notes = readNext(stateDir(root))?.lines ?? [];
127
+ const stale = [...new Set(staleNotes(row.notes, lots).flatMap((n) => n.stale))];
128
+ if (stale.length)
129
+ row.staleNotes = stale;
126
130
  const counted = lots.filter((l) => l.status !== 'dropped' && l.every === undefined);
127
131
  row.progress = {
128
132
  done: counted.filter((l) => l.status === 'done').length,
@@ -213,7 +217,7 @@ export function tourLine(r) {
213
217
  const drift = r.drift.length
214
218
  ? `${r.drift.length} (${r.drift.slice(0, DRIFT_SHOWN).join(' ; ')}${r.drift.length > DRIFT_SHOWN ? ' ; …' : ''})`
215
219
  : 'aucune';
216
- const notes = r.notes.length ? truncate(r.notes.join(' / '), NOTES_MAX) : 'aucune';
220
+ const notes = r.notes.length ? `${truncate(r.notes.join(' / '), NOTES_MAX)}${r.staleNotes ? ` ${staleText(r.staleNotes)}` : ''}` : 'aucune';
217
221
  const next = r.next ? `${r.next.id} ${truncate(r.next.title, TITLE_MAX)}` : 'aucun';
218
222
  return [r.project, `en cours ${doing}`, `dérive ${drift}`, `notes : ${notes}`, `prochain ${next}`, ...(r.waiting?.length ? [`attend ${r.waiting.join(', ')}`] : []), `dépôt ${r.repo.length ? r.repo.join(', ') : 'propre'}`].join(' · ');
219
223
  }
@@ -1,4 +1,4 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { isAbsolute, join, posix } from 'node:path';
4
4
  import { parse } from 'yaml';
@@ -180,6 +180,68 @@ export function resealAcceptation(repo, path, test, reason, today, porteur, refs
180
180
  writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
181
181
  return { avant, apres, lot: a.lot, ...(ficheAmendee ? { ficheAmendee } : {}) };
182
182
  }
183
+ /** Les acceptations de `dir` à `vert: false` au HEAD : leur chemin et le lot qu'elles portent. Un fichier illisible n'en est pas une. */
184
+ export function redAcceptations(repo, dir) {
185
+ const out = [];
186
+ for (const path of listAcceptations(repo, dir).sort()) {
187
+ try {
188
+ const a = parseAcceptation(readFileSync(join(repo, path), 'utf8'));
189
+ if (typeof a !== 'string' && !a.vert)
190
+ out.push({ path, lot: a.lot });
191
+ }
192
+ catch {
193
+ // illisible ou absent de l'arbre de travail : le verrou en parle, pas ce signalement
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+ /**
199
+ * La commande dédiée (`orchestrate.accept`) lancée à la main, sous les mêmes règles que le cycle (`runAccept`) : chaque commande dont la racine contient un fichier listé,
200
+ * `{acceptation}` substitué, `CADENCE_ACCEPTATION` et `CADENCE_ACCEPTATION_ROOT` posés. Un fichier listé sous la racine d'aucune commande est rendu (`orphans`) sans rien lancer.
201
+ */
202
+ export function runAcceptation(repo, json, accept) {
203
+ const abs = join(repo, json);
204
+ const doc = parseAcceptation(readFileSync(abs, 'utf8'));
205
+ const plan = planAcceptRuns(acceptCommands(accept), typeof doc === 'string' ? [] : doc.fichiers, !isAcceptList(accept));
206
+ if (plan.orphans.length)
207
+ return { runs: [], orphans: plan.orphans };
208
+ const runs = plan.runs.map((r) => {
209
+ const done = spawnSync('sh', ['-c', r.command.replaceAll('{acceptation}', abs)], { cwd: repo, encoding: 'utf8', timeout: 30 * 60_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, CADENCE_ACCEPTATION: abs, CADENCE_ACCEPTATION_ROOT: r.root || '.' } });
210
+ return { command: r.command, code: done.status ?? 1, output: `${done.stdout ?? ''}${done.stderr ?? ''}${done.error ? done.error.message : ''}` };
211
+ });
212
+ return { runs, orphans: [] };
213
+ }
214
+ /**
215
+ * `cadence spec green` (L366) : la commande d'acceptation vient de passer au vert sur les tests tels qu'ils sont. Ceux qu'un lot mené à la main a mis au point
216
+ * après le commit rouge ont une empreinte scellée périmée : le verrou les signalerait encore à chaque lot suivant. Chaque empreinte différente est refaite et
217
+ * journalisée dans `resceaux` (comme `spec reseal`, sans fiche amendée) ; le commit qui pose `vert: true` devient ainsi le commit rouge. Rend les tests rescellés.
218
+ * L'appelant s'est assuré que les tests listés sont commités (l'empreinte rescellée est celle de l'arbre de travail) ; un test absent est refusé.
219
+ */
220
+ export function resealForGreen(repo, path, today) {
221
+ const file = join(repo, path);
222
+ const a = parseAcceptation(readFileSync(file, 'utf8'));
223
+ if (typeof a === 'string')
224
+ throw new Error(`${path} : ${a}`);
225
+ const fichiers = a.fichiers.map((f) => posix.normalize(f));
226
+ const absents = fichiers.filter((f) => !existsSync(join(repo, f)));
227
+ if (absents.length)
228
+ throw new Error(`test(s) listé(s) absent(s) de ${path} : ${absents.join(', ')}`);
229
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
230
+ const empreintes = isObj(doc.empreintes) ? doc.empreintes : {};
231
+ const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
232
+ const refaits = fichiers.filter((f) => empreintes[f] !== git(repo, 'hash-object', '--', f).trim());
233
+ for (const f of refaits) {
234
+ const apres = git(repo, 'hash-object', '--', f).trim();
235
+ journal.push({ test: f, raison: 'cadence spec green : tests mis au point après le commit rouge, acceptation verte sur ces tests', date: today, avant: empreintes[f] ?? '', apres });
236
+ empreintes[f] = apres;
237
+ }
238
+ if (refaits.length) {
239
+ doc.empreintes = empreintes;
240
+ doc.resceaux = journal;
241
+ writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
242
+ }
243
+ return refaits;
244
+ }
183
245
  /** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
184
246
  export function setVert(repo, path) {
185
247
  const file = join(repo, path);
@@ -49,6 +49,42 @@ export function failedTests(output) {
49
49
  }
50
50
  return [...found].sort();
51
51
  }
52
+ /** Fichiers de test qui ne se chargent pas (vitest « FAIL a.test.ts [ a.test.ts ] »), triés, sans doublon : les échecs que `failedTests` laisse de côté faute de test à nommer. */
53
+ export function unloadedSuites(output) {
54
+ const found = new Set();
55
+ for (const raw of output.replace(ANSI, '').split('\n')) {
56
+ if (!FILE_LOAD_FAIL.test(raw))
57
+ continue;
58
+ const name = raw.replace(/^\s*FAIL\s+/, '').replace(/\s+\[[^\]]*\]\s*$/, '').trim();
59
+ if (name)
60
+ found.add(name);
61
+ }
62
+ return [...found].sort();
63
+ }
64
+ /** Sortie sans les séquences de couleur du lanceur. */
65
+ export const stripAnsi = (s) => s.replace(ANSI, '');
66
+ /**
67
+ * jest : le fichier de chaque « ● Suite › test » et de chaque ligne « FAIL projet fichier » (nom rouge à part entière avec displayName), celui de la dernière ligne `FAIL fichier` qui le précède dans la même sortie (L354). Un test jest ne porte pas son fichier dans son nom ;
68
+ * sans cette lecture on ne saurait pas quel fichier rejouer pour lui, ni si le rejeu l'a couvert.
69
+ */
70
+ export function jestTestFiles(output) {
71
+ const files = new Map();
72
+ let file = null;
73
+ for (const raw of stripAnsi(output).split('\n')) {
74
+ const f = /^\s*(PASS|FAIL)\b(.*)$/.exec(raw);
75
+ if (f) {
76
+ // « FAIL src/a.test.ts », « FAIL src/a.test.ts (6.21 s) » (jest ajoute la durée au-delà de 5 s : un fichier lent sous charge) ou « FAIL projet src/a.test.ts » (displayName) : le fichier est le dernier mot. Toute autre ligne PASS/FAIL ne désigne aucun fichier : ne pas garder le précédent.
77
+ const last = f[1] === 'FAIL' ? f[2].replace(/\s*\([^)]*\)\s*$/, '').trim().split(/\s+/).pop() ?? '' : '';
78
+ file = /^[^\s|]+\.\w+$/.test(last) ? last : null;
79
+ if (file)
80
+ files.set(clean(f[2]), file); // « FAIL projet fichier » est lui-même un nom rouge (failedTests) : son fichier est le sien
81
+ }
82
+ const t = /^\s*●\s+(.+)$/.exec(raw);
83
+ if (t && file)
84
+ files.set(clean(t[1]), file);
85
+ }
86
+ return files;
87
+ }
52
88
  /** Échecs d'après le lot qui n'étaient pas rouges au départ (`nouveaux`) et ceux qui l'étaient déjà (`existants`). */
53
89
  export function compareBaseline(now, base) {
54
90
  const was = new Set(base);
@@ -56,11 +92,11 @@ export function compareBaseline(now, base) {
56
92
  }
57
93
  /**
58
94
  * Un échec que la sortie ne rattache à aucun test : erreur non gérée (vitest « Unhandled Errors », promesse rejetée), fichier de spec qui ne se charge plus (jest « Test suite failed to run », vitest « FAIL a.test.ts [ a.test.ts ] » : le FAIL de fichier existait peut-être au départ), seuil de
59
- * couverture non atteint. Comparer les seuls noms lus le masquerait derrière d'anciens échecs : l'appelant garde les tests pour rouges.
95
+ * couverture non atteint. `loadFailsNamed` : l'appelant nomme déjà les fichiers qui ne se chargent pas (`unloadedSuites`), ils ne comptent plus comme échec sans nom. Comparer les seuls noms lus le masquerait derrière d'anciens échecs : l'appelant garde les tests pour rouges.
60
96
  */
61
- export function hasUnnamedFailure(output) {
97
+ export function hasUnnamedFailure(output, loadFailsNamed = false) {
62
98
  const text = output.replace(ANSI, '');
63
- if (text.split('\n').some((l) => FILE_LOAD_FAIL.test(l)))
99
+ if (!loadFailsNamed && text.split('\n').some((l) => FILE_LOAD_FAIL.test(l)))
64
100
  return true;
65
101
  return /Unhandled (?:Errors?|Rejections?)|Test suite failed to run|caught \d+ unhandled|coverage threshold|Coverage for \w+ \([\d.]+%\) does not meet/i.test(text);
66
102
  }
@@ -1,5 +1,6 @@
1
1
  /** Ordre des sections de Keep a Changelog ; un titre inconnu vient après, dans l'ordre où il apparaît. */
2
2
  const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
3
+ const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
3
4
  /** Découpe le fichier autour de `## [Unreleased]` : ce qui précède (titre compris), le bloc, ce qui suit à partir de la version suivante. */
4
5
  function split(text) {
5
6
  const lines = text.split('\n');
@@ -46,6 +47,8 @@ function onlyAdds(base, side) {
46
47
  i++;
47
48
  return i === base.length;
48
49
  }
50
+ /** `text` porte l'entrée `e` entière, sur ses propres lignes : « - Fix typo » n'est pas dans « - Fix typo in README ». */
51
+ const hasEntry = (text, e) => `\n${text}\n`.includes(`\n${e}\n`);
49
52
  /** Un côté qui n'a pas bougé laisse l'autre gagner ; deux côtés qui ont bougé de la même façon aussi ; sinon, pas de réponse. */
50
53
  function pick(base, ours, theirs) {
51
54
  if (ours === theirs)
@@ -77,18 +80,108 @@ export function mergeUnreleased(base, ours, theirs, start) {
77
80
  const st = start === undefined ? null : split(start);
78
81
  const startEntries = new Set([...(st ? (sections(st.block)?.values() ?? []) : [])].flat());
79
82
  const own = new Set(start === undefined || !st ? [] : [...bs.values()].flat().filter((e) => !startEntries.has(e)));
83
+ // Une entrée de base que ours range sous une version publiée (la branche a été publiée pendant la vie du lot) n'a pas été retirée : elle a changé de place.
84
+ // Seule une entrée de base compte, entière : une entrée nouvelle de theirs qui est un bout d'une entrée publiée n'est pas la même, elle reste au lot.
85
+ // Les entrées du lot (`own`) ne comptent pas dans l'ordre de ours : l'union d'un commit précédent les y a recasées en fin de section.
86
+ const baseEntries = new Set([...bs.values()].flat());
87
+ const released = (e) => baseEntries.has(e) && o.tail !== b.tail && hasEntry(o.tail, e) && !hasEntry(b.tail, e);
80
88
  for (const [title, entries] of bs) {
81
89
  const kept = entries.filter((e) => !own.has(e));
82
- if (!onlyAdds(kept, (os.get(title) ?? []).filter((e) => !own.has(e))) || !onlyAdds(entries, os.get(title) ?? []) || !onlyAdds(kept, ts.get(title) ?? [])) {
90
+ if (!onlyAdds(kept.filter((e) => !released(e)), (os.get(title) ?? []).filter((e) => !own.has(e))) || !onlyAdds(kept, ts.get(title) ?? [])) {
83
91
  return null;
84
92
  }
85
93
  }
86
94
  const titles = [...new Set([...os.keys(), ...ts.keys()])];
87
- const rank = (s) => (SECTION_ORDER.includes(s) ? SECTION_ORDER.indexOf(s) : SECTION_ORDER.length);
88
95
  titles.sort((x, y) => rank(x) - rank(y));
89
- const body = titles.map((title) => {
90
- const entries = [...new Set([...(os.get(title) ?? []).filter((e) => !own.has(e)), ...(ts.get(title) ?? [])])];
91
- return `### ${title}\n\n${entries.join('\n')}\n`;
96
+ const body = titles.flatMap((title) => {
97
+ const entries = [...new Set([...(os.get(title) ?? []).filter((e) => !own.has(e)), ...(ts.get(title) ?? []).filter((e) => !released(e))])];
98
+ return entries.length ? [`### ${title}\n\n${entries.join('\n')}\n`] : [];
92
99
  });
93
100
  return `${head}\n\n${body.join('\n')}\n${tail}`;
94
101
  }
102
+ /** Entrées du bloc `[Unreleased]` de `text`, par section ; null sans bloc ou avec du texte hors entrée. */
103
+ export function unreleasedSections(text) {
104
+ const s = split(text);
105
+ return s ? sections(s.block) : null;
106
+ }
107
+ /**
108
+ * Replace sous `[Unreleased]` les entrées du lot qu'une publication de la branche a laissées sous une version (L362). `lotTip` : le CHANGELOG du sommet du lot
109
+ * avant le rebase, `start` celui de son point de départ — une entrée de `[Unreleased]` du premier absente du second est au lot — et `main` celui de la
110
+ * branche du dépôt : une entrée déjà dans sa partie publiée n'a pas été glissée là par le rebase, elle reste. `rebased` : le CHANGELOG après le rebase.
111
+ * Rend le texte corrigé et le nombre d'entrées déplacées, ou null quand il n'y a rien à déplacer (ou que le fichier n'a pas la forme attendue).
112
+ */
113
+ export function moveToUnreleased(rebased, lotTip, start, main) {
114
+ const [r, mainSplit] = [split(rebased), split(main)];
115
+ const [tip, was] = [unreleasedSections(lotTip), unreleasedSections(start)];
116
+ const [now, published] = [sections(r?.block ?? []), mainSplit ? mainSplit.tail : ''];
117
+ if (!r || !tip || !was || !now)
118
+ return null;
119
+ const known = new Set([...was.values()].flat());
120
+ let tail = r.tail;
121
+ let moved = 0;
122
+ const added = new Map();
123
+ for (const [title, entries] of tip) {
124
+ for (const e of entries) {
125
+ if (known.has(e) || hasEntry(published, e) || (now.get(title) ?? []).includes(e) || !hasEntry(tail, e))
126
+ continue;
127
+ tail = tail.replace(`\n${e}\n`, '\n');
128
+ added.set(title, [...(added.get(title) ?? []), e]);
129
+ moved++;
130
+ }
131
+ }
132
+ if (moved === 0)
133
+ return null;
134
+ tail = tail.replace(/^### .+\n\n(\n|$)/gm, '');
135
+ for (const [title, entries] of added)
136
+ now.set(title, [...(now.get(title) ?? []), ...entries]);
137
+ const titles = [...now.keys()].sort((x, y) => rank(x) - rank(y));
138
+ const body = titles.map((title) => `### ${title}\n\n${(now.get(title) ?? []).join('\n')}\n`);
139
+ return { text: `${r.head}\n\n${body.join('\n')}\n${tail}`, moved };
140
+ }
141
+ /**
142
+ * Une entrée finale par lot (L369) : après le rebase, le `[Unreleased]` ne porte du lot que ses entrées du sommet de sa branche. Les `intermediates` — les
143
+ * entrées que ses commits successifs ont écrites puis réécrites, absentes du sommet comme de ses versions publiées — en sont retirées (sous `[Unreleased]` comme sous une version), à moins
144
+ * que `main` ne les porte aussi : elles sont alors à elle. Une entrée du sommet absente du texte (ni sous `[Unreleased]`, ni sous une version, ni déjà publiée
145
+ * sur `main`) y est remise sous sa section. `lotTip`, `start`, `main` : comme pour `moveToUnreleased`. Rend le texte corrigé, ou null quand il n'y a rien à
146
+ * changer (ou que le fichier n'a pas la forme attendue).
147
+ */
148
+ export function settleLotEntries(rebased, lotTip, start, main, intermediates) {
149
+ const [r, mainSplit] = [split(rebased), split(main)];
150
+ const [tip, was] = [unreleasedSections(lotTip), unreleasedSections(start)];
151
+ const now = sections(r?.block ?? []);
152
+ if (!r || !mainSplit || !tip || !was || !now)
153
+ return null;
154
+ const mainOwn = new Set([...(sections(mainSplit.block)?.values() ?? [])].flat());
155
+ const known = new Set([...was.values()].flat());
156
+ const final = new Set([...tip.values()].flat());
157
+ // Une entrée que le sommet du lot porte sous une version (le lot l'a publiée lui-même) est finale, pas intermédiaire.
158
+ const tipTail = split(lotTip)?.tail ?? '';
159
+ const stale = intermediates.filter((e) => !final.has(e) && !hasEntry(tipTail, e) && !mainOwn.has(e) && !hasEntry(mainSplit.tail, e));
160
+ let changed = false;
161
+ for (const [title, entries] of now) {
162
+ const kept = entries.filter((e) => !stale.includes(e));
163
+ if (kept.length !== entries.length)
164
+ changed = true;
165
+ now.set(title, kept);
166
+ }
167
+ let tail = r.tail;
168
+ for (const e of stale) {
169
+ if (hasEntry(tail, e)) {
170
+ tail = tail.replace(`\n${e}\n`, '\n');
171
+ changed = true;
172
+ }
173
+ }
174
+ const present = (e) => [...now.values()].some((v) => v.includes(e)) || hasEntry(tail, e) || hasEntry(mainSplit.tail, e);
175
+ for (const [title, entries] of tip) {
176
+ for (const e of entries.filter((x) => !known.has(x) && !present(x))) {
177
+ now.set(title, [...(now.get(title) ?? []), e]);
178
+ changed = true;
179
+ }
180
+ }
181
+ if (!changed)
182
+ return null;
183
+ tail = tail.replace(/^### .+\n\n(\n|$)/gm, '');
184
+ const titles = [...now.keys()].filter((t) => (now.get(t) ?? []).length > 0).sort((x, y) => rank(x) - rank(y));
185
+ const body = titles.map((title) => `### ${title}\n\n${(now.get(title) ?? []).join('\n')}\n`);
186
+ return `${r.head}\n\n${body.join('\n')}\n${tail}`;
187
+ }