@sylad/cadence 1.12.1 → 1.14.1
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/bin/cadence.js +8 -1
- package/dist/cli.js +197 -22
- package/dist/config-set.js +252 -0
- package/dist/config.js +49 -9
- package/dist/corrections.js +2 -0
- package/dist/git.js +42 -1
- package/dist/lead.js +5 -1
- package/dist/orchestrate/accept.js +63 -1
- package/dist/orchestrate/baseline.js +39 -3
- package/dist/orchestrate/briefs.js +1 -1
- package/dist/orchestrate/changelog-merge.js +40 -1
- package/dist/orchestrate/command.js +120 -19
- package/dist/orchestrate/continue.js +5 -2
- package/dist/orchestrate/cycle.js +318 -74
- package/dist/orchestrate/decisions.js +33 -0
- package/dist/orchestrate/guard.js +16 -0
- package/dist/orchestrate/integrate.js +26 -3
- package/dist/orchestrate/launch.js +4 -1
- package/dist/orchestrate/state.js +1 -1
- package/dist/orchestrate/workers.js +71 -0
- package/dist/orchestrate/worktree.js +15 -4
- package/dist/perf.js +12 -2
- package/dist/plan-commit.js +88 -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 +249 -0
- 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 +3 -1
- package/docs/lead.md +18 -1
- package/docs/orchestrate.md +71 -13
- package/docs/overview.md +5 -2
- package/docs/perf.md +4 -1
- package/docs/raf.md +49 -1
- package/docs/ref.md +61 -3
- package/docs/releasing.md +43 -2
- 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 +6 -4
- package/skills/orchestrate/SKILL.md +1 -1
- package/skills/session-close/SKILL.md +2 -0
- package/skills/session-start/SKILL.md +2 -1
- package/templates/orchestrate/fix-docs.md +1 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +2 -0
- package/templates/orchestrate/implement.md +4 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { RafError } from '../plan.js';
|
|
3
|
+
import { isProgramNote } from '../orchestrate/decisions.js';
|
|
4
|
+
import { fichesDuDossier } from '../spec.js';
|
|
5
|
+
import { checkInventaire, readInventaire, TYPES } from './check.js';
|
|
6
|
+
/** Un id d'élément d'inventaire (`<slug>#<type>-<n>`, R9 de `ref check`) tel qu'un lot le cite dans son titre, son objectif ou ses notes. */
|
|
7
|
+
const ID_SOURCE = `[a-z0-9]+(?:-[a-z0-9]+)*#(?:${TYPES.join('|')})-[1-9]\\d*`;
|
|
8
|
+
const ID_PATTERN = new RegExp(ID_SOURCE, 'g');
|
|
9
|
+
/** Une ligne de `perimetre.hors_perimetre` d'une fiche qui justifie un élément non pris : `<id> : <raison>`. */
|
|
10
|
+
const JUSTIFICATION = new RegExp(`^\\s*(${ID_SOURCE})\\s*:\\s*(\\S.*)$`);
|
|
11
|
+
/** Les ids d'éléments d'inventaire cités dans un texte, sans doublon, dans l'ordre. */
|
|
12
|
+
export function idsInText(text) {
|
|
13
|
+
return [...new Set(text.match(ID_PATTERN) ?? [])];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Les ids qu'un lot cite : son titre, son objectif (`goal:`) et les notes que le lead a écrites sur le lot. Les notes que le programme pose (titre remplacé,
|
|
17
|
+
* mineur de revue, hypothèses…) et celles des sous-tâches ne comptent pas : elles citent ce que le lot ne couvre plus ou ne couvre pas, et c'est le lot qui est fait ou non.
|
|
18
|
+
*/
|
|
19
|
+
export function lotIds(lot) {
|
|
20
|
+
return idsInText([lot.title, lot.goal ?? '', ...lot.notes.map((n) => n.text).filter((t) => !isProgramNote(t))].join('\n'));
|
|
21
|
+
}
|
|
22
|
+
/** Les éléments que la fiche d'un lot justifie comme non pris (`<id> : <raison>` dans `sujet.perimetre.hors_perimetre`), par id. */
|
|
23
|
+
export function nonPris(repo, specDir, lotId) {
|
|
24
|
+
const out = new Map();
|
|
25
|
+
const doc = fichesDuDossier(repo, specDir).find(({ doc }) => doc.sujet?.lot === lotId)?.doc;
|
|
26
|
+
const sujet = doc?.sujet;
|
|
27
|
+
const lignes = sujet?.perimetre?.hors_perimetre;
|
|
28
|
+
for (const l of Array.isArray(lignes) ? lignes : []) {
|
|
29
|
+
const m = typeof l === 'string' ? JUSTIFICATION.exec(l) : null;
|
|
30
|
+
if (m)
|
|
31
|
+
out.set(m[1], m[2].trim());
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** Consigne des briefs d'un lot qui cite des éléments d'un document de référence : le spec-writer les prend ou justifie, le ux-reviewer les vérifie sur l'écran fini. */
|
|
36
|
+
export function referenceIdsText(role, ids) {
|
|
37
|
+
if (!ids.length)
|
|
38
|
+
return '';
|
|
39
|
+
const list = ids.map((id) => `- \`${id}\``).join('\n');
|
|
40
|
+
if (role === 'spec') {
|
|
41
|
+
return [
|
|
42
|
+
'The lot covers these elements of a reference document (ids of its `inventaire.json`, `<slug>#<type>-<n>`):',
|
|
43
|
+
list,
|
|
44
|
+
'Take each one into the sheet: the field, rule or state that shows it quotes its id in its `source` (e.g. `source: "exemple#texte-3"`). An element you do not take is justified, one line `<id> : <reason>` — in the report under "choix", and in `perimetre.hors_perimetre` when the sheet has a back part. An id you leave out without a reason is a gap the review reads as a finding. `cadence ref coverage` reads the `hors_perimetre` lines: an element so justified is not covered by your lot, it stays a gap until the lead writes its exclusion or another lot takes it.',
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
return [
|
|
48
|
+
'The lot covers these elements of a reference document (ids of its `inventaire.json`):',
|
|
49
|
+
list,
|
|
50
|
+
'On the finished screen, verify each one: it is visible (or reachable) in the state where the sheet puts it, its text, role and place are those of the element, and it is in the captures you take. An element missing or different on the running screen is a finding naming its id; an element the sheet justified as not taken is not one.',
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
/** Ce qui désigne un élément à l'œil : son libellé, sa question, son nom… (rien pour un jeton ou une table sans nom). */
|
|
54
|
+
function label(e) {
|
|
55
|
+
const text = [e.libelle, e.question, e.nom, e.contenu, e.figure, e.valeur].find((v) => typeof v === 'string' || typeof v === 'number');
|
|
56
|
+
return text === undefined ? '' : ` « ${String(text).slice(0, 60)} »`;
|
|
57
|
+
}
|
|
58
|
+
function where(e) {
|
|
59
|
+
const source = e.source;
|
|
60
|
+
const zone = source.zone ? ` zone ${source.zone.x},${source.zone.y},${source.zone.l},${source.zone.h}` : '';
|
|
61
|
+
return `p.${source.page}${zone}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Calcule la couverture d'un inventaire valide par les lots du plan (fait, abandonné compté pour rien) et par ses exclusions écrites. Un lot fait dont la fiche
|
|
65
|
+
* justifie un élément comme non pris (`justifies`) ne le couvre pas : l'élément reste un écart tant que le lead ne l'a pas exclu par écrit ou fait par un autre lot.
|
|
66
|
+
*/
|
|
67
|
+
export function computeCoverage(inventaire, lots, justifies = () => new Map()) {
|
|
68
|
+
const elements = inventaire.elements;
|
|
69
|
+
const known = new Set(elements.map((e) => e.id));
|
|
70
|
+
const byId = new Map();
|
|
71
|
+
for (const lot of lots)
|
|
72
|
+
for (const id of lotIds(lot))
|
|
73
|
+
if (known.has(id))
|
|
74
|
+
byId.set(id, [...(byId.get(id) ?? []), { id: lot.id, status: lot.status }]);
|
|
75
|
+
const problems = [];
|
|
76
|
+
const excluded = new Set();
|
|
77
|
+
const raw = inventaire.exclusions;
|
|
78
|
+
if (raw !== undefined && !Array.isArray(raw))
|
|
79
|
+
problems.push('exclusions : un tableau [{ id, raison }] est attendu');
|
|
80
|
+
else
|
|
81
|
+
raw?.forEach((x, i) => {
|
|
82
|
+
const o = typeof x === 'object' && x !== null ? x : {};
|
|
83
|
+
if (typeof o.id !== 'string' || !known.has(o.id))
|
|
84
|
+
problems.push(`exclusions[${i}].id : ${String(o.id)} ne désigne aucun élément de l'inventaire`);
|
|
85
|
+
else if (typeof o.raison !== 'string' || o.raison.trim() === '')
|
|
86
|
+
problems.push(`exclusions[${i}].raison : la raison de l'exclusion de ${o.id} est obligatoire`);
|
|
87
|
+
else
|
|
88
|
+
excluded.add(o.id);
|
|
89
|
+
});
|
|
90
|
+
const gaps = [];
|
|
91
|
+
let done = 0;
|
|
92
|
+
for (const e of elements) {
|
|
93
|
+
const id = e.id;
|
|
94
|
+
const mine = (byId.get(id) ?? []).filter((l) => l.status !== 'dropped');
|
|
95
|
+
const faits = mine.filter((l) => l.status === 'done');
|
|
96
|
+
const pris = faits.filter((l) => !justifies(l.id).has(id));
|
|
97
|
+
if (pris.length > 0)
|
|
98
|
+
done++;
|
|
99
|
+
else if (!excluded.has(id)) {
|
|
100
|
+
const reason = faits.length
|
|
101
|
+
? `justifié non pris par la fiche de ${faits.map((l) => `${l.id} : ${justifies(l.id).get(id)}`).join(' ; ')}, sans exclusion écrite`
|
|
102
|
+
: mine.length ? `lot non fait (${mine.map((l) => `${l.id} ${l.status}`).join(', ')})` : 'aucun lot, aucune exclusion écrite';
|
|
103
|
+
gaps.push({ id, text: `${id} (${e.type} ${where(e)}${label(e)}) : ${reason}` });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { gaps, problems, total: elements.length, done, excluded: elements.length - done - gaps.length };
|
|
107
|
+
}
|
|
108
|
+
const USAGE = 'usage : cadence ref coverage <inventaire.json>';
|
|
109
|
+
/** `cadence ref coverage <inventaire.json>` : l'inventaire de `--double` ou la lecture de `lectures/a.json` (le défaut de `ref analyse`) ; 0 si tout élément a un lot fait ou une exclusion écrite, 1 sinon, 2 pour un appel mal formé ou un fichier qui n'est pas un inventaire ou une lecture valide. Sans modèle, sans outil système. */
|
|
110
|
+
export function coverageCommand(args, loadPlan, io, fiches) {
|
|
111
|
+
if (args.length !== 1)
|
|
112
|
+
throw new RafError(USAGE);
|
|
113
|
+
const path = resolve(io.cwd, args[0]);
|
|
114
|
+
const inventaire = readInventaire(path);
|
|
115
|
+
const invalid = checkInventaire(inventaire);
|
|
116
|
+
if (invalid.length > 0)
|
|
117
|
+
throw new RafError(`ref coverage : ${path} n'est ni un inventaire ni une lecture valide (${invalid.length} écart(s), voir cadence ref check) : ${invalid[0]}`);
|
|
118
|
+
const cache = new Map();
|
|
119
|
+
const justifies = (lotId) => {
|
|
120
|
+
if (!cache.has(lotId))
|
|
121
|
+
cache.set(lotId, nonPris(fiches.repo, fiches.specDir, lotId));
|
|
122
|
+
return cache.get(lotId);
|
|
123
|
+
};
|
|
124
|
+
const c = computeCoverage(inventaire, loadPlan().lots(), justifies);
|
|
125
|
+
for (const p of c.problems)
|
|
126
|
+
io.out(`✗ ${p}`);
|
|
127
|
+
for (const g of c.gaps)
|
|
128
|
+
io.out(`✗ ${g.text}`);
|
|
129
|
+
const left = c.gaps.length + c.problems.length;
|
|
130
|
+
if (left > 0) {
|
|
131
|
+
io.out(`${c.gaps.length} élément(s) sans lot fait ni exclusion écrite${c.problems.length ? `, ${c.problems.length} exclusion(s) refusée(s)` : ''} sur ${c.total}`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
io.out(`couverture complète : ${c.total} éléments (${c.done} par un lot fait, ${c.excluded} exclus avec leur raison)`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
package/dist/ref/diff.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { RafError } from '../plan.js';
|
|
3
3
|
import { TYPES, checkInventaire, readInventaire } from './check.js';
|
|
4
4
|
import { affirmation, identite } from './evaluate.js';
|
|
@@ -116,13 +116,16 @@ function confianceConcordant(ea, eb) {
|
|
|
116
116
|
return { confiance: ea.confiance === 'mesure' || eb.confiance === 'mesure' ? 'mesure' : 'lu' };
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
|
-
* L'inventaire réconcilié (genre « inventaire »), ids
|
|
119
|
+
* L'inventaire réconcilié (genre « inventaire »), ids `<slug>#<type>-<n>` (renumérotés, sauf pour un élément de `precedent`) et renvois suivis. Un divergent est `incertain` avec les deux
|
|
120
120
|
* versions ; un seul écarté par le troisième regard disparaît (avec ce qui ne tient plus sans lui), un seul confirmé garde sa confiance (un doute
|
|
121
121
|
* reste un doute), un seul sans décision reste `incertain` : `regard` dit si le troisième regard a eu lieu (sa cause le dit).
|
|
122
122
|
* Un élément apparié tient tant que l'une de ses deux versions renvoie à des éléments gardés (la lecture A d'abord) : quand l'autre version renvoyait à un
|
|
123
123
|
* élément écarté, il est gardé, rattaché d'après la version qui tient, et `incertain` avec la cause du rattachement ; il disparaît quand aucune ne tient.
|
|
124
|
+
* `precedent` : l'inventaire que ce passage remplace, du même document. Un élément relu (même type, même page, même identité, zone qui recouvre le plus)
|
|
125
|
+
* y garde son id ; un élément neuf prend un numéro au-dessus du plus grand de son type, jamais celui d'un élément disparu : un lot qui cite un id
|
|
126
|
+
* continue de viser le même élément, et les `exclusions` écrites passent dans le nouvel inventaire.
|
|
124
127
|
*/
|
|
125
|
-
export function assembler(a, b, rec, regard = false) {
|
|
128
|
+
export function assembler(a, b, rec, regard = false, precedent) {
|
|
126
129
|
const entrees = rec.items.map((item) => ({ item, supprimee: item.statut === 'seul' && item.decision?.verdict === 'ecarte' }));
|
|
127
130
|
const proprietaire = new Map();
|
|
128
131
|
for (const en of entrees) {
|
|
@@ -162,16 +165,7 @@ export function assembler(a, b, rec, regard = false) {
|
|
|
162
165
|
en.version = v;
|
|
163
166
|
}
|
|
164
167
|
}
|
|
165
|
-
const
|
|
166
|
-
const nouvelId = new Map();
|
|
167
|
-
for (const en of entrees) {
|
|
168
|
-
if (en.supprimee)
|
|
169
|
-
continue;
|
|
170
|
-
const type = en.item.element.type;
|
|
171
|
-
const n = (compteur.get(type) ?? 0) + 1;
|
|
172
|
-
compteur.set(type, n);
|
|
173
|
-
nouvelId.set(en, `${a.document.slug}#${type}-${n}`);
|
|
174
|
-
}
|
|
168
|
+
const nouvelId = idsStables(entrees, a, b, precedent);
|
|
175
169
|
const elements = entrees.filter((en) => !en.supprimee).map((en) => {
|
|
176
170
|
const { item } = en;
|
|
177
171
|
const v = en.version;
|
|
@@ -213,7 +207,43 @@ export function assembler(a, b, rec, regard = false) {
|
|
|
213
207
|
}
|
|
214
208
|
return e;
|
|
215
209
|
});
|
|
216
|
-
|
|
210
|
+
const exclusions = memeDocument(precedent, a) ? precedent.exclusions : undefined;
|
|
211
|
+
return { version: 1, genre: 'inventaire', document: { slug: a.document.slug, sha256: a.document.sha256 }, elements, ...(exclusions !== undefined ? { exclusions } : {}) };
|
|
212
|
+
}
|
|
213
|
+
const memeDocument = (precedent, a) => (precedent?.genre === 'inventaire' || precedent?.genre === 'lecture') && precedent.document?.slug === a.document.slug && Array.isArray(precedent.elements);
|
|
214
|
+
/** Les ids du nouvel inventaire : ceux de `precedent` pour les éléments relus, des numéros neufs pour les autres (ordre de l'inventaire, numérotation par type). */
|
|
215
|
+
function idsStables(entrees, a, b, precedent) {
|
|
216
|
+
const slug = a.document.slug;
|
|
217
|
+
const anciens = memeDocument(precedent, a) ? precedent.elements : [];
|
|
218
|
+
const parAncien = new Map(anciens.map((e) => [e.id, e]));
|
|
219
|
+
const parA = new Map(a.elements.map((e) => [e.id, e]));
|
|
220
|
+
const parB = new Map(b.elements.map((e) => [e.id, e]));
|
|
221
|
+
const reprise = new Set();
|
|
222
|
+
const nouvelId = new Map();
|
|
223
|
+
for (const en of entrees.filter((x) => !x.supprimee)) {
|
|
224
|
+
const e = en.item.element;
|
|
225
|
+
const par = en.item.a ? parA : parB;
|
|
226
|
+
const libres = anciens.filter((p) => !reprise.has(p) && p.type === e.type && p.source?.page === e.source?.page && identite(p, parAncien) === identite(e, par));
|
|
227
|
+
const ancien = libres.reduce((best, c) => (best === undefined || recouvrement(e, c) > recouvrement(e, best) ? c : best), undefined);
|
|
228
|
+
if (ancien) {
|
|
229
|
+
reprise.add(ancien);
|
|
230
|
+
nouvelId.set(en, ancien.id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const compteur = new Map();
|
|
234
|
+
for (const e of anciens) {
|
|
235
|
+
const n = Number(String(e.id).slice(String(e.id).lastIndexOf('-') + 1));
|
|
236
|
+
compteur.set(e.type, Math.max(compteur.get(e.type) ?? 0, Number.isInteger(n) ? n : 0));
|
|
237
|
+
}
|
|
238
|
+
for (const en of entrees) {
|
|
239
|
+
if (en.supprimee || nouvelId.has(en))
|
|
240
|
+
continue;
|
|
241
|
+
const type = en.item.element.type;
|
|
242
|
+
const n = (compteur.get(type) ?? 0) + 1;
|
|
243
|
+
compteur.set(type, n);
|
|
244
|
+
nouvelId.set(en, `${slug}#${type}-${n}`);
|
|
245
|
+
}
|
|
246
|
+
return nouvelId;
|
|
217
247
|
}
|
|
218
248
|
function lireLecture(path) {
|
|
219
249
|
const doc = readInventaire(path);
|
|
@@ -232,6 +262,60 @@ export function parseSeuil(text, commande) {
|
|
|
232
262
|
throw new RafError(`${commande} : --seuil invalide : « ${text} » (une part en pour cent, de 0 à 100)`);
|
|
233
263
|
return Number(text) / 100;
|
|
234
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Ce qu'un passage de `ref analyse` remplace et dont il reprend les ids : l'inventaire du dossier du document, sinon sa lecture `lectures/a.json`
|
|
267
|
+
* (une lecture unique ou la lecture A d'une double), du moment qu'elle est valide ; sinon rien.
|
|
268
|
+
*/
|
|
269
|
+
export function precedentDe(inventaire, lecture) {
|
|
270
|
+
const inv = inventairePrecedent(inventaire);
|
|
271
|
+
if (inv)
|
|
272
|
+
return inv;
|
|
273
|
+
if (!existsSync(lecture))
|
|
274
|
+
return undefined;
|
|
275
|
+
try {
|
|
276
|
+
const doc = readInventaire(lecture);
|
|
277
|
+
return doc.genre === 'lecture' && checkInventaire(doc).length === 0 ? doc : undefined;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Une lecture seule (`ref analyse` sans `--double`) numérote ses éléments par son propre ordre : ceux qui relisent un élément de `precedent` (même règle
|
|
285
|
+
* que `assembler`) reprennent son id, les renvois suivent, et les `exclusions` écrites passent dans la lecture. Sans `precedent` du même document, rien ne change.
|
|
286
|
+
*/
|
|
287
|
+
export function reprendreIds(lecture, precedent) {
|
|
288
|
+
if (!memeDocument(precedent, lecture))
|
|
289
|
+
return;
|
|
290
|
+
const elements = lecture.elements;
|
|
291
|
+
const entrees = elements.map((e) => ({ item: { statut: 'concordant', element: e, a: e }, supprimee: false }));
|
|
292
|
+
const nouvelId = idsStables(entrees, lecture, lecture, precedent);
|
|
293
|
+
const ancienVersNeuf = new Map(entrees.map((en) => [en.item.element.id, nouvelId.get(en)]));
|
|
294
|
+
for (const e of elements) {
|
|
295
|
+
if (e.type === 'composant')
|
|
296
|
+
e.ecran = ancienVersNeuf.get(e.ecran) ?? e.ecran;
|
|
297
|
+
if (e.type === 'etat')
|
|
298
|
+
e.cible = ancienVersNeuf.get(e.cible) ?? e.cible;
|
|
299
|
+
if (e.type === 'statut')
|
|
300
|
+
e.couvre = e.couvre.map((id) => ancienVersNeuf.get(id) ?? id);
|
|
301
|
+
}
|
|
302
|
+
for (const e of elements)
|
|
303
|
+
e.id = ancienVersNeuf.get(e.id);
|
|
304
|
+
if (precedent.exclusions !== undefined)
|
|
305
|
+
lecture.exclusions = precedent.exclusions;
|
|
306
|
+
}
|
|
307
|
+
/** L'inventaire que `--out` va remplacer, s'il en est un de valide ; sinon rien (un fichier illisible ou d'une autre forme n'est pas un inventaire précédent). */
|
|
308
|
+
export function inventairePrecedent(path) {
|
|
309
|
+
if (!existsSync(path))
|
|
310
|
+
return undefined;
|
|
311
|
+
try {
|
|
312
|
+
const doc = readInventaire(path);
|
|
313
|
+
return doc.genre === 'inventaire' && checkInventaire(doc).length === 0 ? doc : undefined;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
235
319
|
/** `ref diff <a> <b> [--out inventaire.json] [--seuil 30]` : 0 réconcilié, 1 trop d'éléments non appariés (rien d'écrit), 2 fichier ou appel refusé. */
|
|
236
320
|
export function diffLectures(o) {
|
|
237
321
|
const seuil = parseSeuil(o.seuil, 'ref diff');
|
|
@@ -257,6 +341,6 @@ export function diffLectures(o) {
|
|
|
257
341
|
return 1;
|
|
258
342
|
}
|
|
259
343
|
if (o.out !== undefined)
|
|
260
|
-
writeFileSync(o.out, `${JSON.stringify(assembler(a, b, rec), null, 2)}\n`);
|
|
344
|
+
writeFileSync(o.out, `${JSON.stringify(assembler(a, b, rec, false, inventairePrecedent(o.out)), null, 2)}\n`);
|
|
261
345
|
return 0;
|
|
262
346
|
}
|
package/dist/release.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { currentBranch, hasTrackedChanges, tagExists } from './git.js';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
|
+
export const BUMPS = ['patch', 'minor', 'major'];
|
|
6
|
+
const PACKAGE = 'package.json';
|
|
7
|
+
const LOCK = 'package-lock.json';
|
|
8
|
+
const PLUGIN = '.claude-plugin/plugin.json';
|
|
9
|
+
const MARKETPLACE = '.claude-plugin/marketplace.json';
|
|
10
|
+
const CHANGELOG = 'CHANGELOG.md';
|
|
11
|
+
const WHATS_NEW = 'docs/whats-new.md';
|
|
12
|
+
/** Longueur maximale (en caractères) de la ligne d'une version dans la liste courte de docs/whats-new.md. */
|
|
13
|
+
export const SHORT_LINE_MAX = 240;
|
|
14
|
+
export function nextVersion(current, bump) {
|
|
15
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(current);
|
|
16
|
+
if (!m)
|
|
17
|
+
throw new RafError(`release : la version de ${PACKAGE} (${current}) n'est pas de la forme x.y.z, elle ne se calcule pas`);
|
|
18
|
+
const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
19
|
+
if (bump === 'major')
|
|
20
|
+
return `${major + 1}.0.0`;
|
|
21
|
+
if (bump === 'minor')
|
|
22
|
+
return `${major}.${minor + 1}.0`;
|
|
23
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
24
|
+
}
|
|
25
|
+
function readJson(root, file) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(join(root, file), 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
throw new RafError(`release : ${file} illisible (${e.message})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Remplace la première capture d'un motif `(préfixe)(ancienne)(suffixe)` ; null si le motif est absent. */
|
|
34
|
+
function swap(text, pattern, version) {
|
|
35
|
+
return pattern.test(text) ? text.replace(pattern, `$1${version}$3`) : null;
|
|
36
|
+
}
|
|
37
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
/** Écart entre les dépendances de package.json et celles de la racine du lock : ce qu'un `npm install` ferait bouger. */
|
|
39
|
+
function lockDrift(pkg, lock) {
|
|
40
|
+
const root = lock.packages?.[''] ?? {};
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
|
|
43
|
+
const want = pkg[field] ?? {};
|
|
44
|
+
const have = root[field] ?? {};
|
|
45
|
+
for (const name of new Set([...Object.keys(want), ...Object.keys(have)])) {
|
|
46
|
+
if (want[name] !== have[name])
|
|
47
|
+
out.push(`${field}.${name} : ${PACKAGE} ${want[name] ?? '(absent)'}, ${LOCK} ${have[name] ?? '(absent)'}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** Les sections d'un CHANGELOG « Keep a Changelog » : le corps de [Unreleased] et le texte à partir de la section suivante. */
|
|
53
|
+
function splitUnreleased(text) {
|
|
54
|
+
const head = /^## \[Unreleased\][^\n]*\n/m.exec(text);
|
|
55
|
+
if (!head)
|
|
56
|
+
throw new RafError(`release : ${CHANGELOG} n'a pas de section « ## [Unreleased] » à renommer`);
|
|
57
|
+
const start = head.index + head[0].length;
|
|
58
|
+
const next = /^## /m.exec(text.slice(start));
|
|
59
|
+
const end = next ? start + next.index : text.length;
|
|
60
|
+
return { before: text.slice(0, head.index), body: text.slice(start, end), after: text.slice(end) };
|
|
61
|
+
}
|
|
62
|
+
/** Une section est vide quand elle n'a que des titres de sous-section (« ### Added ») et des lignes blanches. */
|
|
63
|
+
function hasEntries(body) {
|
|
64
|
+
return body.split('\n').some((l) => l.trim() !== '' && !/^#{3,}\s/.test(l));
|
|
65
|
+
}
|
|
66
|
+
/** Un échec de git (hors dépôt, dépôt sans commit) est un refus, pas une trace de pile. */
|
|
67
|
+
const inGit = (what, f) => {
|
|
68
|
+
try {
|
|
69
|
+
return f();
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
throw new RafError(`release : ${what} impossible (${(e.message.split('\n').find((l) => /^fatal|^error/i.test(l)) ?? e.message).trim()}) — la commande se lance dans un dépôt git qui a au moins un commit`);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/** Calcule tous les fichiers de la release SANS rien écrire : un refus laisse l'arbre intact. */
|
|
76
|
+
function plan(root, bump, today) {
|
|
77
|
+
if (!existsSync(join(root, PACKAGE)))
|
|
78
|
+
throw new RafError(`release : ${PACKAGE} introuvable dans ${root}`);
|
|
79
|
+
const pkg = readJson(root, PACKAGE);
|
|
80
|
+
const current = String(pkg.version ?? '');
|
|
81
|
+
const version = nextVersion(current, bump);
|
|
82
|
+
if (inGit('la lecture de l\'état git', () => hasTrackedChanges(root, [WHATS_NEW])))
|
|
83
|
+
throw new RafError(`release : l'arbre de travail a des fichiers suivis modifiés (hors ${WHATS_NEW}, que le commit de version embarque) — le commit de version ne tient que les fichiers de version ; les commiter ou les remiser d'abord`);
|
|
84
|
+
if (inGit('la lecture des tags', () => tagExists(root, `v${version}`)))
|
|
85
|
+
throw new RafError(`release : la version ${version} est déjà taguée (v${version}) — rien à préparer`);
|
|
86
|
+
const writes = new Map();
|
|
87
|
+
const files = [];
|
|
88
|
+
const put = (file, text) => {
|
|
89
|
+
writes.set(file, text);
|
|
90
|
+
files.push(file);
|
|
91
|
+
};
|
|
92
|
+
const edit = (file, edits) => {
|
|
93
|
+
let text = readFileSync(join(root, file), 'utf8');
|
|
94
|
+
for (const [spec, label] of edits) {
|
|
95
|
+
const next = swap(text, typeof spec === 'function' ? spec(text) : spec, version);
|
|
96
|
+
if (next === null)
|
|
97
|
+
throw new RafError(`release : ${file} n'a pas ${label} à mettre à jour`);
|
|
98
|
+
text = next;
|
|
99
|
+
}
|
|
100
|
+
return text;
|
|
101
|
+
};
|
|
102
|
+
// Le champ de tête du fichier, à l'indentation du fichier (deux espaces, quatre, tabulation) : celle de sa première clé.
|
|
103
|
+
const topVersion = (text) => new RegExp(`^(${escape(/^\{[ \t]*\r?\n([ \t]+)"/.exec(text)?.[1] ?? ' ')}"version":\\s*")([^"]*)(")`, 'm');
|
|
104
|
+
put(PACKAGE, edit(PACKAGE, [[topVersion, 'de champ "version"']]));
|
|
105
|
+
if (existsSync(join(root, LOCK))) {
|
|
106
|
+
const lock = readJson(root, LOCK);
|
|
107
|
+
if (lock.version !== current || (lock.packages?.['']?.version ?? current) !== current) {
|
|
108
|
+
throw new RafError(`release : ${LOCK} (version ${lock.version}) n'est pas à la version de ${PACKAGE} (${current}) — un \`npm install\` le recale, à commiter à part`);
|
|
109
|
+
}
|
|
110
|
+
const drift = lockDrift(pkg, lock);
|
|
111
|
+
if (drift.length > 0)
|
|
112
|
+
throw new RafError(`release : ${LOCK} n'est pas à jour des dépendances de ${PACKAGE}, un \`npm install\` les ferait bouger — à commiter à part avant la release :\n ${drift.join('\n ')}`);
|
|
113
|
+
const edits = [[topVersion, 'de champ "version"']];
|
|
114
|
+
if (lock.packages?.[''])
|
|
115
|
+
edits.push([/("packages":\s*\{\s*"":\s*\{[^{}]*?"version":\s*")([^"]*)(")/, 'de version pour la racine de packages']);
|
|
116
|
+
put(LOCK, edit(LOCK, edits));
|
|
117
|
+
}
|
|
118
|
+
let pluginName = null;
|
|
119
|
+
let marketplaceName = null;
|
|
120
|
+
if (existsSync(join(root, PLUGIN))) {
|
|
121
|
+
pluginName = String(readJson(root, PLUGIN).name ?? '');
|
|
122
|
+
put(PLUGIN, edit(PLUGIN, [[topVersion, 'de champ "version"']]));
|
|
123
|
+
}
|
|
124
|
+
if (existsSync(join(root, MARKETPLACE))) {
|
|
125
|
+
const market = readJson(root, MARKETPLACE);
|
|
126
|
+
marketplaceName = typeof market.name === 'string' ? market.name : null;
|
|
127
|
+
// Le plugin du dépôt : celui qui porte le nom de plugin.json, sinon celui qui était à la version du paquet ; les autres plugins du marketplace gardent la leur.
|
|
128
|
+
const entries = Array.isArray(market.plugins) ? market.plugins : [];
|
|
129
|
+
const own = entries.find((p) => pluginName !== null && p?.name === pluginName) ?? entries.find((p) => p?.version === current);
|
|
130
|
+
if (!own)
|
|
131
|
+
throw new RafError(`release : ${MARKETPLACE} n'a pas de plugin ${pluginName ? `« ${pluginName} »` : `à la version ${current}`} à mettre à jour`);
|
|
132
|
+
put(MARKETPLACE, edit(MARKETPLACE, [[new RegExp(`("name":\\s*"${escape(String(own.name))}"(?:[^{}]|\\{[^{}]*\\})*?"version":\\s*")([^"]*)(")`), `de version pour le plugin « ${own.name} »`]]));
|
|
133
|
+
}
|
|
134
|
+
if (!existsSync(join(root, CHANGELOG)))
|
|
135
|
+
throw new RafError(`release : ${CHANGELOG} n'existe pas — la release renomme sa section [Unreleased], il faut donc un ${CHANGELOG} qui en porte une`);
|
|
136
|
+
const changelog = readFileSync(join(root, CHANGELOG), 'utf8');
|
|
137
|
+
const { before, body, after } = splitUnreleased(changelog);
|
|
138
|
+
if (!hasEntries(body))
|
|
139
|
+
throw new RafError(`release : la section [Unreleased] est vide dans ${CHANGELOG} — rien à publier ; y écrire ce qui change pour l'utilisateur (lots cités)`);
|
|
140
|
+
// Une section [Unreleased] vide reste au-dessus : sans elle, la fusion de CHANGELOG du mode worktree d'orchestrate ne trouve plus où poser les entrées des lots suivants.
|
|
141
|
+
put(CHANGELOG, `${before}## [Unreleased]\n\n## [${version}] - ${today}\n${body}${after}`);
|
|
142
|
+
// Chaque fichier écrit doit se relire avec la version attendue : un motif qui aurait touché un autre champ est un refus, pas une release fausse.
|
|
143
|
+
for (const [file, text] of writes) {
|
|
144
|
+
if (!file.endsWith('.json'))
|
|
145
|
+
continue;
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(text);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new RafError(`release : ${file} ne se relirait plus après la mise à jour de la version`);
|
|
152
|
+
}
|
|
153
|
+
const seen = file === MARKETPLACE ? parsed.plugins.some((p) => p.version === version) : parsed.version === version;
|
|
154
|
+
if (!seen)
|
|
155
|
+
throw new RafError(`release : ${file} ne porte pas ${version} après la mise à jour`);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
version,
|
|
159
|
+
current,
|
|
160
|
+
writes,
|
|
161
|
+
files,
|
|
162
|
+
pluginName,
|
|
163
|
+
marketplaceName,
|
|
164
|
+
packageName: String(pkg.name ?? ''),
|
|
165
|
+
isPrivate: pkg.private === true,
|
|
166
|
+
hasEvalScript: typeof pkg.scripts?.['eval:plugin'] === 'string',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* docs/whats-new.md porte la version sous deux formes, écrites à la main ou par une session (jamais par la commande) :
|
|
171
|
+
* une ligne de la liste « In two lines » (≤ 240 caractères, en tête) et un paragraphe long (en tête de « Version by version »).
|
|
172
|
+
* Rend les écarts, une ligne chacun ; vide quand les deux sont là, à leur place.
|
|
173
|
+
*/
|
|
174
|
+
export function whatsNewIssues(text, version) {
|
|
175
|
+
const lines = text.split('\n');
|
|
176
|
+
const issues = [];
|
|
177
|
+
// « Version by version » coupe le fichier : la liste courte est avant, les paragraphes longs sont après (sauf l'introduction, premier paragraphe de la section).
|
|
178
|
+
const heading = lines.findIndex((l) => /^##\s+Version by version\s*$/.test(l));
|
|
179
|
+
const split = heading < 0 ? lines.length : heading;
|
|
180
|
+
const intro = heading < 0 ? -1 : lines.findIndex((l, i) => i > heading && l.trim() !== '');
|
|
181
|
+
const check = (form, absent, misplaced, pattern, from, to, where, max) => {
|
|
182
|
+
const hits = lines.map((l, i) => ({ l, i, v: i >= from && i < to ? pattern.exec(l)?.[1] : undefined })).filter((x) => x.v !== undefined && x.i !== intro);
|
|
183
|
+
const at = hits.findIndex((h) => h.v === version);
|
|
184
|
+
if (at < 0) {
|
|
185
|
+
issues.push(`${WHATS_NEW} : ${form} ${absent} — l'écrire ${where}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (at > 0)
|
|
189
|
+
issues.push(`${WHATS_NEW} : ${form} ${misplaced} (après la version ${hits[at - 1].v} ; la plus récente va en premier) — la déplacer ${where}`);
|
|
190
|
+
if (max !== undefined && hits[at].l.length > max)
|
|
191
|
+
issues.push(`${WHATS_NEW} : ${form} trop longue (${hits[at].l.length} caractères, ${max} au plus)`);
|
|
192
|
+
};
|
|
193
|
+
check('ligne de « In two lines »', 'manquante', 'mal placée', /^- \*\*(\d+\.\d+\.\d+)\*\*:/, 0, split, 'en tête de la liste', SHORT_LINE_MAX);
|
|
194
|
+
check('paragraphe long', 'manquant', 'mal placé', /^\*\*(\d+\.\d+\.\d+)\*\*:/, split, lines.length, 'juste après l’introduction de « Version by version »');
|
|
195
|
+
return issues;
|
|
196
|
+
}
|
|
197
|
+
/** Les étapes que le lead joue, dans l'ordre : la commande n'en fait aucune. */
|
|
198
|
+
function steps(p, files, branch) {
|
|
199
|
+
const tag = `v${p.version}`;
|
|
200
|
+
const out = [
|
|
201
|
+
`git add ${files.join(' ')} && git commit -m "release: ${p.version}"`,
|
|
202
|
+
...(p.hasEvalScript ? ['npm run eval:plugin -- --trust-plugin (facturé ; un cas sous le seuil arrête la release)'] : []),
|
|
203
|
+
`git tag ${tag}`,
|
|
204
|
+
`git push origin ${branch} ${tag} (le tag lance le workflow de publication)`,
|
|
205
|
+
'gh run watch (jusqu\'à la fin du run de publication)',
|
|
206
|
+
...(p.isPrivate || p.packageName === '' ? [] : [`npm view ${p.packageName} version (doit répondre ${p.version})`]),
|
|
207
|
+
...(p.pluginName ? [
|
|
208
|
+
...(p.marketplaceName ? [`claude plugin marketplace update ${p.marketplaceName} (sans elle, le plugin installé reste à l'ancienne version)`] : []),
|
|
209
|
+
`claude plugin update ${p.pluginName}${p.marketplaceName ? `@${p.marketplaceName}` : ''} (le plugin se lit depuis le dépôt poussé)`,
|
|
210
|
+
'/reload-plugins (dans la session Claude Code ouverte, pour charger la nouvelle version)',
|
|
211
|
+
] : []),
|
|
212
|
+
];
|
|
213
|
+
return out.map((s, i) => ` ${i + 1}. ${s}`);
|
|
214
|
+
}
|
|
215
|
+
/** `cadence release patch|minor|major [--dry-run]` : prépare le commit de version, sans rien publier. Rend le code de sortie. */
|
|
216
|
+
export function release(rest, dryRun, root, today, out) {
|
|
217
|
+
const bump = rest[0];
|
|
218
|
+
if (rest.length !== 1 || !bump || !BUMPS.includes(bump))
|
|
219
|
+
throw new RafError('usage : cadence release patch|minor|major [--dry-run]');
|
|
220
|
+
const p = plan(root, bump, today);
|
|
221
|
+
const whatsNewPath = join(root, WHATS_NEW);
|
|
222
|
+
const hasWhatsNew = existsSync(whatsNewPath);
|
|
223
|
+
const files = hasWhatsNew ? [...p.files, WHATS_NEW] : p.files;
|
|
224
|
+
const branch = inGit('la lecture de la branche', () => currentBranch(root)) ?? 'main';
|
|
225
|
+
out(`${dryRun ? 'release (--dry-run, rien n\'est écrit)' : 'release'} : ${p.current} → ${p.version}`);
|
|
226
|
+
for (const f of p.files)
|
|
227
|
+
out(` ${dryRun ? 'écrirait' : 'écrit'} : ${f}${f === 'CHANGELOG.md' ? ` ([Unreleased] → [${p.version}] - ${today}, une section [Unreleased] vide au-dessus)` : ' (version)'}`);
|
|
228
|
+
if (!dryRun)
|
|
229
|
+
for (const [file, text] of p.writes)
|
|
230
|
+
writeFileSync(join(root, file), text);
|
|
231
|
+
let ready = true;
|
|
232
|
+
if (hasWhatsNew) {
|
|
233
|
+
const issues = whatsNewIssues(readFileSync(whatsNewPath, 'utf8'), p.version);
|
|
234
|
+
if (issues.length === 0)
|
|
235
|
+
out(` ${WHATS_NEW} : les deux formes de ${p.version} sont là`);
|
|
236
|
+
else {
|
|
237
|
+
ready = false;
|
|
238
|
+
for (const i of issues)
|
|
239
|
+
out(` ${i}`);
|
|
240
|
+
out(dryRun
|
|
241
|
+
? ' le commit de version ne serait pas prêt : écrire ou déplacer ces formes (à la main ou par une session) avant de lancer la commande'
|
|
242
|
+
: ' le commit de version n\'est pas prêt : écrire ces formes (à la main ou par une session), puis suivre les étapes ci-dessous sans relancer la commande (elle changerait encore la version)');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
out('étapes du lead, dans l\'ordre (la commande n\'en joue aucune) :');
|
|
246
|
+
for (const s of steps(p, files, branch))
|
|
247
|
+
out(s);
|
|
248
|
+
return ready ? 0 : 1;
|
|
249
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -5,6 +5,7 @@ import { audit, exemptPlanOnly, gateWaiting, nextUp, planCommits } from './audit
|
|
|
5
5
|
import { diffDays, maxDay } from './dates.js';
|
|
6
6
|
import { repoStatus } from './git.js';
|
|
7
7
|
import { linkCommits } from './link.js';
|
|
8
|
+
import { staleNoteLines, staleNotes, staleText } from './stale-notes.js';
|
|
8
9
|
import { lockAlive, readLock, readNext, readSessionMark, writeSessionMark } from './state.js';
|
|
9
10
|
const FACTS_TIMEOUT = 120_000;
|
|
10
11
|
/**
|
|
@@ -107,7 +108,7 @@ export function sessionStart(ctx, opts) {
|
|
|
107
108
|
out(`${plan.project} — reprise du ${today}`);
|
|
108
109
|
const next = readNext(ctx.state);
|
|
109
110
|
if (next)
|
|
110
|
-
section(out, `Notes de la dernière clôture (${next.date})`, next.lines.map((l) => `- ${l}`));
|
|
111
|
+
section(out, `Notes de la dernière clôture (${next.date})`, staleNoteLines(next.lines, lots).map((l) => `- ${l}`));
|
|
111
112
|
const lock = lockStatus(ctx.shared);
|
|
112
113
|
if (lock)
|
|
113
114
|
out(`\n${lock.line}`);
|
|
@@ -152,6 +153,11 @@ export function sessionClose(ctx, opts) {
|
|
|
152
153
|
commits.push(`${recent.orphans.length} commit(s) sans lot :`, ...recent.orphans.map((c) => ` ${short(c)}`));
|
|
153
154
|
}
|
|
154
155
|
section(out, `Commits de la période (${window.label})`, commits.length ? commits : ['(aucun)']);
|
|
156
|
+
const next = readNext(ctx.state);
|
|
157
|
+
if (next) {
|
|
158
|
+
const stale = staleNotes(next.lines, plan.lots()).filter((n) => n.stale.length);
|
|
159
|
+
section(out, `Notes de la dernière clôture à réécrire (${next.date})`, stale.map((n) => `${n.line} ${staleText(n.stale)} — cadence session next les remplace`));
|
|
160
|
+
}
|
|
155
161
|
const quiet = plan.lots().filter((l) => l.status === 'doing' && !isRecurring(l) && !recent.byLot.has(l.id));
|
|
156
162
|
section(out, 'Lots en cours', quiet.map((l) => `${l.id} ${l.title} — aucun commit sur la période : ${plan.readonly ? "le fermer ou l'annoter avec l'outil du projet" : 'raf done ou raf note'}`));
|
|
157
163
|
const issues = audit(plan, ctx.root, ctx.newsDir, today);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
2
|
+
/** « 2026-10-24 » → « 24-10 » : la note est récente, l'année ne se lit pas. */
|
|
3
|
+
const dayMonth = (d) => `${d.slice(8, 10)}-${d.slice(5, 7)}`;
|
|
4
|
+
/**
|
|
5
|
+
* Pour chaque ligne de note, les lots `done` ou `dropped` qu'elle cite, dans l'ordre de la ligne. Un identifiant se
|
|
6
|
+
* lit entier : `L2` n'est pas cité par `L217`, ni `B28` par `B28.8`, mais un point final (`reprendre L2.`) ne le prolonge pas.
|
|
7
|
+
*/
|
|
8
|
+
export function staleNotes(lines, lots) {
|
|
9
|
+
const finished = lots.filter((l) => l.status === 'done' || l.status === 'dropped');
|
|
10
|
+
return lines.map((line) => {
|
|
11
|
+
const hits = finished
|
|
12
|
+
.map((l) => ({ l, at: line.search(new RegExp(`(?<![\\w./-])${escape(l.id)}(?![\\w/-]|\\.\\w)`)) }))
|
|
13
|
+
.filter((h) => h.at >= 0)
|
|
14
|
+
.sort((a, b) => a.at - b.at);
|
|
15
|
+
return { line, stale: hits.map(({ l }) => `${l.id} ${l.status}${l.finished ? ` le ${dayMonth(l.finished)}` : ''}`) };
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export const staleText = (stale) => `⚠ note périmée : ${stale.join(', ')}`;
|
|
19
|
+
/** Les notes telles quelles, celles qui citent un lot fini suivies de « ⚠ note périmée : … ». */
|
|
20
|
+
export function staleNoteLines(lines, lots) {
|
|
21
|
+
return staleNotes(lines, lots).map((n) => (n.stale.length ? `${n.line} ${staleText(n.stale)}` : n.line));
|
|
22
|
+
}
|