@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -0
- package/agents/reference-planner.md +26 -0
- package/dist/audit.js +5 -2
- package/dist/cli.js +195 -21
- package/dist/config-set.js +6 -3
- package/dist/config.js +47 -9
- package/dist/corrections.js +1 -0
- package/dist/git.js +11 -1
- package/dist/lead.js +5 -1
- package/dist/orchestrate/accept.js +63 -1
- package/dist/orchestrate/baseline.js +39 -3
- package/dist/orchestrate/changelog-merge.js +98 -5
- package/dist/orchestrate/command.js +118 -12
- package/dist/orchestrate/cycle.js +255 -63
- package/dist/orchestrate/decisions.js +2 -0
- package/dist/orchestrate/integrate.js +97 -5
- package/dist/orchestrate/launch.js +2 -0
- package/dist/orchestrate/state.js +1 -1
- package/dist/orchestrate/workers.js +51 -0
- package/dist/orchestrate/worktree.js +15 -4
- package/dist/perf.js +9 -1
- package/dist/plan-commit.js +90 -0
- package/dist/plan.js +41 -3
- package/dist/ref/analyse.js +34 -4
- package/dist/ref/command.js +2 -1
- package/dist/ref/coverage.js +136 -0
- package/dist/ref/diff.js +99 -15
- package/dist/release.js +5 -1
- package/dist/session.js +7 -1
- package/dist/stale-notes.js +22 -0
- package/dist/verify-gate.js +138 -0
- package/docs/deliver.md +37 -0
- package/docs/fonctionnement.md +2 -1
- package/docs/lead.md +6 -4
- package/docs/orchestrate.md +67 -13
- package/docs/overview.md +3 -2
- package/docs/perf.md +3 -1
- package/docs/raf.md +50 -2
- package/docs/ref.md +61 -3
- package/docs/releasing.md +5 -3
- package/docs/session.md +8 -0
- package/docs/skills.md +1 -0
- package/docs/spec.md +2 -0
- package/docs/stabilite.md +2 -0
- package/docs/whats-new.md +9 -0
- package/package.json +3 -3
- package/skills/lead/SKILL.md +8 -6
- package/skills/orchestrate/SKILL.md +1 -1
- package/skills/session-close/SKILL.md +2 -0
- 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
|
-
|
|
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 (!
|
|
422
|
-
throw bad(`review.${k} :
|
|
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) {
|
package/dist/corrections.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
+
}
|