@sylad/cadence 0.2.0 → 0.6.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 +2 -2
- package/README.md +273 -12
- package/agents/code-reviewer.md +89 -0
- package/agents/qa-reviewer.md +122 -0
- package/bin/cadence.js +3 -2
- package/dist/audit.js +95 -5
- package/dist/check.js +2 -1
- package/dist/cli.js +100 -46
- package/dist/config.js +106 -0
- package/dist/dates.js +8 -0
- package/dist/deliver.js +87 -29
- package/dist/git.js +34 -1
- package/dist/link.js +3 -3
- package/dist/news.js +82 -9
- package/dist/plan.js +203 -19
- package/dist/session.js +36 -6
- package/package.json +3 -2
- package/skills/deliver/SKILL.md +32 -1
- package/skills/lead/SKILL.md +94 -0
- package/skills/session-close/SKILL.md +17 -0
- package/skills/session-start/SKILL.md +25 -1
package/dist/deliver.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
2
|
import { parse } from 'yaml';
|
|
3
|
-
import { headSha, isAncestor, onRemote, readCommits, repoStatus } from './git.js';
|
|
4
|
-
import {
|
|
3
|
+
import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit } from './git.js';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
5
|
import { appendDelivery, lastDelivery, lockAlive, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
6
6
|
const POLL_CI = 15_000;
|
|
7
7
|
const CI_APPEAR = 300_000;
|
|
@@ -23,6 +23,14 @@ export function parseDeliverConfig(text, file) {
|
|
|
23
23
|
throw new RafError(`${file} : clé deliver absente`);
|
|
24
24
|
const cfg = d;
|
|
25
25
|
const bad = (what) => new RafError(`${file} : deliver.${what}`);
|
|
26
|
+
let script;
|
|
27
|
+
if (cfg.script !== undefined) {
|
|
28
|
+
if (typeof cfg.script !== 'string' || !cfg.script.trim())
|
|
29
|
+
throw bad('script : commande non vide attendue');
|
|
30
|
+
if (cfg.ci !== undefined || cfg.deploy !== undefined)
|
|
31
|
+
throw bad('script remplace ci et deploy : garder l\'un ou les autres');
|
|
32
|
+
script = cfg.script.trim();
|
|
33
|
+
}
|
|
26
34
|
let ci = 'none';
|
|
27
35
|
if (cfg.ci === 'github' || cfg.ci === 'none')
|
|
28
36
|
ci = cfg.ci;
|
|
@@ -33,6 +41,8 @@ export function parseDeliverConfig(text, file) {
|
|
|
33
41
|
}
|
|
34
42
|
else if (cfg.ci !== undefined)
|
|
35
43
|
throw bad('ci : attendu github, none ou { command: "…" }');
|
|
44
|
+
if (cfg.allowDirty !== undefined && typeof cfg.allowDirty !== 'boolean')
|
|
45
|
+
throw bad('allowDirty : true ou false attendu');
|
|
36
46
|
const seconds = (key, dflt) => {
|
|
37
47
|
const v = cfg[key];
|
|
38
48
|
if (v === undefined)
|
|
@@ -45,7 +55,8 @@ export function parseDeliverConfig(text, file) {
|
|
|
45
55
|
if (!Array.isArray(deploy) || !deploy.every((c) => typeof c === 'string' && c.trim()))
|
|
46
56
|
throw bad('deploy : liste de commandes non vides attendue');
|
|
47
57
|
const verify = cfg.verify ?? [];
|
|
48
|
-
|
|
58
|
+
// Avec un script de projet, les contrôles métier sont les siens : ceux de cadence deviennent facultatifs.
|
|
59
|
+
if (!Array.isArray(verify) || (verify.length === 0 && script === undefined)) {
|
|
49
60
|
throw bad('verify : au moins une vérification (une livraison se prouve par son effet)');
|
|
50
61
|
}
|
|
51
62
|
const checks = verify.map((v, i) => {
|
|
@@ -68,7 +79,16 @@ export function parseDeliverConfig(text, file) {
|
|
|
68
79
|
throw bad(`${where}.contains : texte attendu`);
|
|
69
80
|
return { url, ...(status === undefined ? {} : { status: status }), ...(contains === undefined ? {} : { contains }) };
|
|
70
81
|
});
|
|
71
|
-
return {
|
|
82
|
+
return {
|
|
83
|
+
ci,
|
|
84
|
+
...(script === undefined ? {} : { script }),
|
|
85
|
+
...(cfg.allowDirty ? { allowDirty: true } : {}),
|
|
86
|
+
ciTimeout: seconds('ciTimeout', 1800),
|
|
87
|
+
deploy: deploy,
|
|
88
|
+
verify: checks,
|
|
89
|
+
verifyTimeout: seconds('verifyTimeout', 300),
|
|
90
|
+
deployTimeout: seconds('deployTimeout', 1800),
|
|
91
|
+
};
|
|
72
92
|
}
|
|
73
93
|
/** Dépendances réelles : sh, gh, fetch, horloge. */
|
|
74
94
|
export function realDeps(root) {
|
|
@@ -119,6 +139,10 @@ export function realDeps(root) {
|
|
|
119
139
|
function substitute(text, sha) {
|
|
120
140
|
return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
|
|
121
141
|
}
|
|
142
|
+
/** Argument rendu tel quel au script par sh, quels que soient ses espaces, guillemets ou jokers. */
|
|
143
|
+
function shellQuote(arg) {
|
|
144
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
145
|
+
}
|
|
122
146
|
function describeCheck(c, sha) {
|
|
123
147
|
if (c.command !== undefined)
|
|
124
148
|
return c.command;
|
|
@@ -131,12 +155,19 @@ export async function deliver(ctx, deps) {
|
|
|
131
155
|
err(`deliver : ${msg}`);
|
|
132
156
|
return 2;
|
|
133
157
|
};
|
|
134
|
-
const
|
|
158
|
+
const head0 = headSha(ctx.root);
|
|
159
|
+
const sha = ctx.sha === undefined ? head0 : resolveCommit(ctx.root, ctx.sha);
|
|
135
160
|
if (!sha)
|
|
136
|
-
return refuse('aucun commit à livrer');
|
|
161
|
+
return refuse(ctx.sha === undefined ? 'aucun commit à livrer' : `--sha ${ctx.sha} : commit introuvable`);
|
|
162
|
+
if (ctx.args.length && config.script === undefined) {
|
|
163
|
+
return refuse('des arguments ne se passent qu\'à un script de projet (deliver.script dans cadence.yaml)');
|
|
164
|
+
}
|
|
165
|
+
const script = config.script === undefined ? null : [config.script, ...ctx.args.map(shellQuote)].join(' ');
|
|
137
166
|
const repo = repoStatus(ctx.root);
|
|
138
|
-
if (repo.dirty)
|
|
167
|
+
if (repo.dirty && !config.allowDirty)
|
|
139
168
|
return refuse(`${repo.dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
|
|
169
|
+
if (repo.dirty)
|
|
170
|
+
err(`deliver : ${repo.dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
|
|
140
171
|
if (!onRemote(ctx.root, sha))
|
|
141
172
|
return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
|
|
142
173
|
const lock = readLock(ctx.state);
|
|
@@ -149,16 +180,25 @@ export async function deliver(ctx, deps) {
|
|
|
149
180
|
if (why)
|
|
150
181
|
return refuse(`ci: github exige gh authentifié — ${why}`);
|
|
151
182
|
}
|
|
152
|
-
|
|
153
|
-
const
|
|
183
|
+
// Un sha choisi n'est pas forcément sur la branche courante : ne pas lui en prêter une.
|
|
184
|
+
const atHead = sha === head0;
|
|
185
|
+
const branch = atHead ? (repo.branch ?? 'HEAD détachée') : 'sha choisi, antérieur ou étranger à la tête';
|
|
186
|
+
const env = { CADENCE_SHA: sha, CADENCE_SHORT: sha.slice(0, 7), CADENCE_BRANCH: atHead ? (repo.branch ?? '') : '' };
|
|
154
187
|
if (ctx.dryRun) {
|
|
155
188
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch}) — simulation, rien n'est exécuté`);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
189
|
+
if (script !== null) {
|
|
190
|
+
out(` Script du projet : ${script}`);
|
|
191
|
+
out(` (CI, déploiement et contrôles métier sont les siens ; délai ${config.deployTimeout} s)`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
out(` CI : ${typeof config.ci === 'string' ? config.ci : config.ci.command}${config.ci === 'none' ? '' : ` (délai ${config.ciTimeout} s)`}`);
|
|
195
|
+
out(' Déploiement :');
|
|
196
|
+
if (config.deploy.length === 0)
|
|
197
|
+
out(' (aucune commande)');
|
|
198
|
+
config.deploy.forEach((c, i) => out(` ${i + 1}. ${c}`));
|
|
199
|
+
}
|
|
200
|
+
if (config.verify.length)
|
|
201
|
+
out(` Vérifications (réessayées pendant ${config.verifyTimeout} s) :`);
|
|
162
202
|
config.verify.forEach((c, i) => out(` ${i + 1}. ${describeCheck(c, sha)}`));
|
|
163
203
|
out(` Variables : ${Object.entries(env).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
|
164
204
|
return 0;
|
|
@@ -175,21 +215,37 @@ export async function deliver(ctx, deps) {
|
|
|
175
215
|
return 1;
|
|
176
216
|
};
|
|
177
217
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
for (const [i, cmd] of config.deploy.entries()) {
|
|
182
|
-
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
183
|
-
const code = deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
218
|
+
if (script !== null) {
|
|
219
|
+
out(`→ script du projet : ${script}`);
|
|
220
|
+
const code = deps.exec(script, env, config.deployTimeout * 1000);
|
|
184
221
|
if (code !== 0)
|
|
185
|
-
return fail(`
|
|
222
|
+
return fail(`script de livraison en échec (${codeText(code)}) : ${script}`);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const ci = await waitCi(ctx, deps, sha, env);
|
|
226
|
+
if (ci)
|
|
227
|
+
return fail(ci);
|
|
228
|
+
for (const [i, cmd] of config.deploy.entries()) {
|
|
229
|
+
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
230
|
+
const code = deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
231
|
+
if (code !== 0)
|
|
232
|
+
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`);
|
|
233
|
+
}
|
|
186
234
|
}
|
|
187
235
|
const failed = await verifyAll(ctx, deps, sha, env);
|
|
188
236
|
if (failed)
|
|
189
237
|
return fail(failed);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
238
|
+
// Un script de projet peut commiter pendant la livraison (horodatage d'une entrée Nouveautés) :
|
|
239
|
+
// ce qu'il a livré est alors la nouvelle tête, pas le sha de départ.
|
|
240
|
+
const head = script === null || !atHead ? sha : (headSha(ctx.root) ?? sha);
|
|
241
|
+
const moved = head !== sha && isAncestor(ctx.root, sha, head);
|
|
242
|
+
const final = moved ? head : sha;
|
|
243
|
+
const delivered = deliveredLots(ctx, lastDelivery(ctx.state), final);
|
|
244
|
+
appendDelivery(ctx.state, ctx.today, final);
|
|
245
|
+
if (moved)
|
|
246
|
+
out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
|
|
247
|
+
else
|
|
248
|
+
out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
|
|
193
249
|
if (delivered)
|
|
194
250
|
out(delivered);
|
|
195
251
|
return 0;
|
|
@@ -278,9 +334,11 @@ async function verifyAll(ctx, deps, sha, env) {
|
|
|
278
334
|
const reason = await tryCheck(c, deps, sha, env, deadline - deps.now());
|
|
279
335
|
if (reason === null)
|
|
280
336
|
break;
|
|
281
|
-
|
|
337
|
+
const left = deadline - deps.now();
|
|
338
|
+
if (left <= 0)
|
|
282
339
|
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`;
|
|
283
|
-
|
|
340
|
+
// jamais au-delà du délai : une commande tuée « à l'échéance » peut rendre la main un rien avant elle
|
|
341
|
+
await deps.sleep(Math.min(POLL_VERIFY, left));
|
|
284
342
|
}
|
|
285
343
|
}
|
|
286
344
|
return null;
|
|
@@ -295,11 +353,11 @@ function deliveredLots(ctx, prev, sha) {
|
|
|
295
353
|
const known = new Set(ctx.plan.lots().map((l) => l.id));
|
|
296
354
|
const ids = new Set();
|
|
297
355
|
for (const c of readCommits(ctx.root, { range: `${prev}..${sha}` })) {
|
|
298
|
-
for (const r of
|
|
356
|
+
for (const r of ctx.plan.refs(`${c.subject}\n${c.body}`))
|
|
299
357
|
if (known.has(r.lot))
|
|
300
358
|
ids.add(r.lot);
|
|
301
359
|
}
|
|
302
360
|
if (ids.size === 0)
|
|
303
361
|
return null;
|
|
304
|
-
return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — raf done si l'effet est celui attendu`;
|
|
362
|
+
return `livré : ${[...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} — ${ctx.plan.readonly ? "à fermer avec l'outil du projet" : 'raf done'} si l'effet est celui attendu`;
|
|
305
363
|
}
|
package/dist/git.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { basename, dirname, resolve } from 'node:path';
|
|
3
3
|
function git(cwd, args) {
|
|
4
|
-
|
|
4
|
+
// Le journal complet d'un gros dépôt dépasse le Mo par défaut : sans marge, il serait lu comme vide.
|
|
5
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 1024 });
|
|
5
6
|
}
|
|
6
7
|
export function gitRoot(cwd) {
|
|
7
8
|
try {
|
|
@@ -60,6 +61,10 @@ function tryGit(cwd, args) {
|
|
|
60
61
|
export function headSha(cwd) {
|
|
61
62
|
return tryGit(cwd, ['rev-parse', '--verify', '-q', 'HEAD']);
|
|
62
63
|
}
|
|
64
|
+
/** Sha complet du commit que désigne `rev` (sha abrégé, branche, tag), null s'il n'existe pas. */
|
|
65
|
+
export function resolveCommit(cwd, rev) {
|
|
66
|
+
return tryGit(cwd, ['rev-parse', '--verify', '-q', `${rev}^{commit}`]);
|
|
67
|
+
}
|
|
63
68
|
export function repoStatus(cwd) {
|
|
64
69
|
const lines = git(cwd, ['status', '--porcelain']).split('\n').filter(Boolean);
|
|
65
70
|
const untracked = lines.filter((l) => l.startsWith('??')).length;
|
|
@@ -89,3 +94,31 @@ export function gitCommonDir(cwd) {
|
|
|
89
94
|
export function isAncestor(cwd, a, b) {
|
|
90
95
|
return tryGit(cwd, ['merge-base', '--is-ancestor', a, b]) !== null;
|
|
91
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Instant (ms), date d'auteur (conservée par rebase, amend, cherry-pick), du commit qui a ajouté chaque fichier
|
|
99
|
+
* de `dir` SOUS CE NOM (--no-renames : un renommage compte comme un ajout, l'historique antérieur n'est pas suivi),
|
|
100
|
+
* clé = chemin relatif à `dir`.
|
|
101
|
+
* Vide hors d'un dépôt git ; un fichier jamais commité en est absent.
|
|
102
|
+
*/
|
|
103
|
+
export function addedTimes(dir) {
|
|
104
|
+
const times = new Map();
|
|
105
|
+
let out;
|
|
106
|
+
try {
|
|
107
|
+
// quotepath=off + -z : noms accentués tels quels, sans guillemets ni échappement octal.
|
|
108
|
+
out = git(dir, ['-c', 'core.quotepath=off', 'log', '-z', '--diff-filter=A', '--no-renames', '--relative', '--name-only', `--format=${RECORD}%aI`, '--', '.']);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return times;
|
|
112
|
+
}
|
|
113
|
+
// Enregistrement = RECORD date \0 \n nom \0 nom \0 … ; le plus ancien ajout l'emporte.
|
|
114
|
+
for (const record of out.split(RECORD).filter(Boolean)) {
|
|
115
|
+
const [stamp, ...files] = record.split('\0');
|
|
116
|
+
const t = Date.parse(stamp);
|
|
117
|
+
for (const [i, f] of files.entries()) {
|
|
118
|
+
const name = i === 0 ? f.replace(/^\n/, '') : f;
|
|
119
|
+
if (name)
|
|
120
|
+
times.set(name, Math.min(t, times.get(name) ?? Infinity));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return times;
|
|
124
|
+
}
|
package/dist/link.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { extractRefs } from './plan.js';
|
|
2
1
|
export function isMerge(c) {
|
|
3
2
|
return /^Merge\b/.test(c.subject);
|
|
4
3
|
}
|
|
5
|
-
|
|
4
|
+
/** `refs` lit les références d'un message : `plan.refs`. */
|
|
5
|
+
export function linkCommits(lots, commits, refsOf) {
|
|
6
6
|
const ids = new Set(lots.map((l) => l.id));
|
|
7
7
|
const tasks = new Set(lots.flatMap((l) => l.tasks.map((t) => `${l.id}/${t.id}`)));
|
|
8
8
|
const byLot = new Map();
|
|
9
9
|
const orphans = [];
|
|
10
10
|
const unknown = [];
|
|
11
11
|
for (const c of commits) {
|
|
12
|
-
const refs =
|
|
12
|
+
const refs = refsOf(`${c.subject}\n${c.body}`);
|
|
13
13
|
if (refs.length === 0) {
|
|
14
14
|
if (!isMerge(c))
|
|
15
15
|
orphans.push(c);
|
package/dist/news.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
3
|
import { parse, stringify } from 'yaml';
|
|
4
|
-
import { isDay } from './dates.js';
|
|
4
|
+
import { isDay, toStamp } from './dates.js';
|
|
5
|
+
import { addedTimes } from './git.js';
|
|
5
6
|
import { RafError } from './plan.js';
|
|
7
|
+
const CREATED_EMPTY = 'created vide — cadence news stamp';
|
|
8
|
+
const CREATED = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
9
|
+
const OFFSET = /(?:Z|[+-]\d{2}:\d{2})$/;
|
|
6
10
|
const CAPTURE = /^[\w./-]+\.(?:png|jpe?g|webp|gif)$/i;
|
|
7
11
|
const FRONT = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
12
|
+
/** Bien formée ET réelle : Date.parse accepte le 30 février (et rend NaN pour 25:70). */
|
|
13
|
+
function validStamp(s) {
|
|
14
|
+
const [y, mo, d, h, mi, sec = 0] = s.match(/\d+/g).slice(0, 6).map(Number);
|
|
15
|
+
const day = new Date(Date.UTC(y, mo - 1, d));
|
|
16
|
+
return (!Number.isNaN(Date.parse(s.replace(' ', 'T'))) && day.getUTCMonth() === mo - 1 && day.getUTCDate() === d && h < 24 && mi < 60 && sec < 60);
|
|
17
|
+
}
|
|
8
18
|
const list = (v) => (v == null ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);
|
|
9
19
|
export function parseEntry(file, text) {
|
|
10
20
|
const entry = { file, slug: basename(file).replace(/\.md$/, ''), title: '', date: '', lots: [], captures: [], body: '', problems: [] };
|
|
@@ -23,6 +33,20 @@ export function parseEntry(file, text) {
|
|
|
23
33
|
}
|
|
24
34
|
entry.title = String(head.title ?? '').trim();
|
|
25
35
|
entry.date = String(head.date ?? '');
|
|
36
|
+
if ('created' in head) {
|
|
37
|
+
const created = String(head.created ?? '').trim();
|
|
38
|
+
if (!created)
|
|
39
|
+
entry.problems.push(CREATED_EMPTY);
|
|
40
|
+
else if (!CREATED.test(created))
|
|
41
|
+
entry.problems.push(`created « ${created} » n'est pas une heure AAAA-MM-JJTHH:MM±hh:mm`);
|
|
42
|
+
// Sans fuseau, l'heure serait lue dans celui de la machine : l'ordre changerait entre le poste et la CI.
|
|
43
|
+
else if (!OFFSET.test(created))
|
|
44
|
+
entry.problems.push(`created « ${created} » sans fuseau : ajouter ±hh:mm ou Z`);
|
|
45
|
+
else if (!validStamp(created))
|
|
46
|
+
entry.problems.push(`created « ${created} » n'est pas une heure valide`);
|
|
47
|
+
else
|
|
48
|
+
entry.created = created.replace(' ', 'T');
|
|
49
|
+
}
|
|
26
50
|
entry.lots = list(head.lots);
|
|
27
51
|
entry.captures = list(head.captures);
|
|
28
52
|
if (head.nocapture != null && String(head.nocapture).trim())
|
|
@@ -42,14 +66,29 @@ export function parseEntry(file, text) {
|
|
|
42
66
|
entry.problems.push(`date « ${entry.date} » n'est pas une date AAAA-MM-JJ`);
|
|
43
67
|
return entry;
|
|
44
68
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Entrées du dossier, plus récentes d'abord : par date, puis à date égale par instant de création
|
|
71
|
+
* (`created` de l'en-tête, sinon date d'auteur du commit qui a ajouté le fichier sous ce nom — un renommage
|
|
72
|
+
* compte comme un ajout — ; pas encore commité = le plus récent), puis par nom de fichier décroissant.
|
|
73
|
+
* `added` (historique git) n'est appelé que si une entrée n'a pas de `created`.
|
|
74
|
+
*/
|
|
75
|
+
export function loadEntries(dir, added = () => addedTimes(dir)) {
|
|
47
76
|
if (!existsSync(dir))
|
|
48
77
|
return [];
|
|
49
|
-
|
|
78
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
50
79
|
.filter((f) => f.isFile() && f.name.endsWith('.md') && f.name.toLowerCase() !== 'readme.md')
|
|
51
|
-
.map((f) => parseEntry(f.name, readFileSync(join(dir, f.name), 'utf8')))
|
|
52
|
-
|
|
80
|
+
.map((f) => parseEntry(f.name, readFileSync(join(dir, f.name), 'utf8')));
|
|
81
|
+
const history = entries.some((e) => !e.created) ? added() : new Map();
|
|
82
|
+
const created = (e) => (e.created ? Date.parse(e.created) : history.get(e.file) ?? Infinity);
|
|
83
|
+
return entries.sort((a, b) => {
|
|
84
|
+
if (a.date !== b.date)
|
|
85
|
+
return b.date.localeCompare(a.date);
|
|
86
|
+
const ta = created(a);
|
|
87
|
+
const tb = created(b);
|
|
88
|
+
if (ta !== tb)
|
|
89
|
+
return ta < tb ? 1 : -1;
|
|
90
|
+
return b.file.localeCompare(a.file);
|
|
91
|
+
});
|
|
53
92
|
}
|
|
54
93
|
export function newsIssues(lots, entries, dir) {
|
|
55
94
|
const issues = [];
|
|
@@ -66,6 +105,9 @@ export function newsIssues(lots, entries, dir) {
|
|
|
66
105
|
if (!existsSync(path) || !statSync(path).isFile())
|
|
67
106
|
issues.push({ kind: 'missing-capture', message: `${e.file} : capture absente ${c}` });
|
|
68
107
|
}
|
|
108
|
+
if (e.problems.length === 0 && !e.created) {
|
|
109
|
+
issues.push({ kind: 'no-time', message: `${e.file} sans heure de création (created: AAAA-MM-JJTHH:MM±hh:mm) — cadence news stamp` });
|
|
110
|
+
}
|
|
69
111
|
if (e.problems.length === 0 && e.captures.length === 0 && !e.nocapture) {
|
|
70
112
|
issues.push({ kind: 'no-capture', message: `${e.file} sans capture (ou « nocapture: raison »)` });
|
|
71
113
|
}
|
|
@@ -87,16 +129,47 @@ export function slugify(title) {
|
|
|
87
129
|
.slice(0, 60)
|
|
88
130
|
.replace(/-+$/, '') || 'entree');
|
|
89
131
|
}
|
|
90
|
-
/** Crée le squelette d'une entrée ; refuse d'écraser un fichier existant. */
|
|
91
|
-
export function newEntry(dir, lots, rawTitle, today) {
|
|
132
|
+
/** Crée le squelette d'une entrée, horodatée de `now` ; refuse d'écraser un fichier existant. */
|
|
133
|
+
export function newEntry(dir, lots, rawTitle, today, now) {
|
|
92
134
|
const title = rawTitle.replace(/\s+/g, ' ').trim(); // un saut de ligne casserait l'en-tête
|
|
93
135
|
const path = join(dir, `${today}-${slugify(title)}.md`);
|
|
94
136
|
if (existsSync(path))
|
|
95
137
|
throw new RafError(`${path} existe déjà`);
|
|
96
138
|
mkdirSync(dir, { recursive: true });
|
|
97
|
-
writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
|
|
139
|
+
writeFileSync(path, `---\ntitle: ${stringify(title).trimEnd()}\ndate: ${today}\ncreated: ${toStamp(now)}\nlots: [${lots.join(', ')}]\ncaptures: []\n# nocapture: raison, quand une capture n'a pas de sens\n---\nCe qui change pour l'utilisateur.\n`);
|
|
98
140
|
return path;
|
|
99
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Migration : écrit `created:` dans les entrées lisibles qui n'en ont pas (ou l'ont vide), d'après la date
|
|
144
|
+
* d'auteur du commit qui a ajouté le fichier sous ce nom — un renommage n'est pas suivi — (`now` s'il n'est pas
|
|
145
|
+
* encore commité). Le reste du fichier est laissé tel quel.
|
|
146
|
+
*/
|
|
147
|
+
export function stampEntries(dir, now) {
|
|
148
|
+
let history;
|
|
149
|
+
const added = () => (history ??= addedTimes(dir)); // un seul git log, et seulement s'il sert
|
|
150
|
+
const done = [];
|
|
151
|
+
for (const e of loadEntries(dir, added)) {
|
|
152
|
+
// Seule une clé created vide est réparée ici ; une heure fausse reste à corriger à la main.
|
|
153
|
+
if (e.created || !e.problems.every((p) => p === CREATED_EMPTY))
|
|
154
|
+
continue;
|
|
155
|
+
const created = toStamp(new Date(added().get(e.file) ?? now.getTime()));
|
|
156
|
+
const path = join(dir, e.file);
|
|
157
|
+
const text = readFileSync(path, 'utf8');
|
|
158
|
+
const lines = text.split('\n');
|
|
159
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trimEnd() === '---');
|
|
160
|
+
const key = (name) => lines.findIndex((l, i) => i > 0 && i < end && new RegExp(`^${name}\\s*:`).test(l));
|
|
161
|
+
const existing = key('created');
|
|
162
|
+
const dateLine = key('date');
|
|
163
|
+
const eol = lines[dateLine >= 0 ? dateLine : 0].endsWith('\r') ? '\r' : '';
|
|
164
|
+
if (existing >= 0)
|
|
165
|
+
lines[existing] = `created: ${created}${eol}`; // clé vide : remplacée, jamais dupliquée
|
|
166
|
+
else
|
|
167
|
+
lines.splice(dateLine >= 0 ? dateLine + 1 : end, 0, `created: ${created}${eol}`);
|
|
168
|
+
writeFileSync(path, lines.join('\n'));
|
|
169
|
+
done.push({ file: e.file, created });
|
|
170
|
+
}
|
|
171
|
+
return done;
|
|
172
|
+
}
|
|
100
173
|
function escapeText(s) {
|
|
101
174
|
return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
102
175
|
}
|