@sylad/cadence 1.7.0 → 1.9.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 +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +26 -8
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/qa-reviewer.md +2 -2
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +12 -1
- package/dist/audit.js +14 -4
- package/dist/check.js +6 -0
- package/dist/cli.js +295 -29
- package/dist/config.js +147 -5
- package/dist/corrections.js +78 -22
- package/dist/deliver.js +85 -21
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +37 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +28 -6
- package/dist/orchestrate/command.js +303 -34
- package/dist/orchestrate/cycle.js +720 -273
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +85 -0
- package/dist/orchestrate/launch.js +37 -5
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +123 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +25 -0
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +42 -6
- package/dist/orchestrate/table.js +61 -2
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +620 -0
- package/dist/plan.js +92 -6
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +116 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +78 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +179 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +104 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +82 -0
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/corrections.md +1 -1
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +58 -3
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +5 -5
- package/docs/lead.md +37 -12
- package/docs/news.md +24 -1
- package/docs/orchestrate.md +230 -19
- package/docs/perf.md +89 -0
- package/docs/raf.md +89 -11
- package/docs/ref.md +316 -0
- package/docs/session.md +2 -2
- package/docs/skills.md +9 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +4 -0
- package/package.json +3 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +31 -6
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +6 -3
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, realpathSync } 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
|
+
import { DEFAULT_TEST_PATTERNS } from './testgate.js';
|
|
5
6
|
import { gitRoot } from './git.js';
|
|
6
7
|
import { FIELDS, Plan, RafError, STATUSES } from './plan.js';
|
|
7
8
|
const KEYS = ['path', 'project', 'since', 'ignore', 'files', 'lots', 'fields', 'statuses', 'estimates'];
|
|
@@ -44,7 +45,7 @@ function readQa(qa, file, workRoot) {
|
|
|
44
45
|
/**
|
|
45
46
|
* Clé `plan:` de cadence.yaml : où est le plan et, s'il est tenu par un autre outil, comment le lire.
|
|
46
47
|
* `root` : la racine du dépôt de travail (celle du CLI), d'où se résout un chemin absolu de `qa.expectations`.
|
|
47
|
-
* Null quand le fichier ou la clé manque — le plan est alors docs/plan/raf.yaml au format de raf.
|
|
48
|
+
* Null quand le fichier ou la clé manque — le plan est alors .cadence/raf.yaml (ou docs/plan/raf.yaml hérité) au format de raf.
|
|
48
49
|
*/
|
|
49
50
|
export function readPlanConfig(file, root) {
|
|
50
51
|
if (!existsSync(file))
|
|
@@ -89,6 +90,19 @@ export function readPlanConfig(file, root) {
|
|
|
89
90
|
throw bad('path est vide');
|
|
90
91
|
return { ...(p.path != null ? { path: String(p.path) } : {}), settings };
|
|
91
92
|
}
|
|
93
|
+
export const PLAN_DEFAULT = '.cadence/raf.yaml';
|
|
94
|
+
export const PLAN_LEGACY = 'docs/plan/raf.yaml';
|
|
95
|
+
/**
|
|
96
|
+
* Le plan par défaut d'un projet (L246) : .cadence/raf.yaml, versionné et jamais publié avec docs/ ; un docs/plan/raf.yaml hérité
|
|
97
|
+
* reste le plan tant qu'il n'est pas déplacé et que .cadence/raf.yaml n'existe pas. `legacy` : le plan hérité existe (lu, ou ignoré
|
|
98
|
+
* quand les deux existent) — c'est à l'appelant d'en parler.
|
|
99
|
+
*/
|
|
100
|
+
export function defaultPlan(root) {
|
|
101
|
+
const legacy = existsSync(join(root, PLAN_LEGACY));
|
|
102
|
+
if (existsSync(join(root, PLAN_DEFAULT)))
|
|
103
|
+
return { path: join(root, PLAN_DEFAULT), legacy: legacy ? 'ignored' : null };
|
|
104
|
+
return { path: join(root, legacy ? PLAN_LEGACY : PLAN_DEFAULT), legacy: legacy ? 'read' : null };
|
|
105
|
+
}
|
|
92
106
|
/**
|
|
93
107
|
* Le chargeur du plan du projet dont `root` est la racine : le `cadence.yaml` est lu tout de suite (une erreur sort ici), le plan à
|
|
94
108
|
* chaque appel. Une seule lecture de « où est le plan et comment le lire » pour l'orchestrateur, `lead tour` et le tirage de `--continue`.
|
|
@@ -96,8 +110,8 @@ export function readPlanConfig(file, root) {
|
|
|
96
110
|
export function planLoader(root) {
|
|
97
111
|
const configPath = join(root, 'cadence.yaml');
|
|
98
112
|
const planConfig = readPlanConfig(configPath, root);
|
|
99
|
-
|
|
100
|
-
return () => Plan.load(
|
|
113
|
+
// Le défaut est résolu à chaque appel : un plan déplacé (git mv) pendant une vague est suivi.
|
|
114
|
+
return () => Plan.load(planConfig?.path ? resolve(root, planConfig.path) : defaultPlan(root).path, { ...planConfig?.settings, config: configPath });
|
|
101
115
|
}
|
|
102
116
|
function parseFormat(p, bad) {
|
|
103
117
|
const format = { lots: p.lots == null ? 'lots' : String(p.lots), fields: {}, statuses: {}, estimates: {} };
|
|
@@ -178,6 +192,52 @@ export function readHookConfig(file) {
|
|
|
178
192
|
throw new RafError(`${file} : hook.autostart : ${AUTOSTART.join(', ')} attendu`);
|
|
179
193
|
return { autostart: a };
|
|
180
194
|
}
|
|
195
|
+
const PRICED_MODELS = ['sonnet', 'opus', 'haiku'];
|
|
196
|
+
const PRICE_KEYS = ['input', 'output', 'cacheRead'];
|
|
197
|
+
/** Clé `perf.prices` de cadence.yaml : la grille de prix du coût par lot livré ; null sans grille (clé absente ou vide). */
|
|
198
|
+
export function readPerfConfig(file) {
|
|
199
|
+
if (!existsSync(file))
|
|
200
|
+
return null;
|
|
201
|
+
let raw;
|
|
202
|
+
try {
|
|
203
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
207
|
+
}
|
|
208
|
+
const perf = raw?.perf;
|
|
209
|
+
if (perf == null)
|
|
210
|
+
return null;
|
|
211
|
+
if (!isObject(perf))
|
|
212
|
+
throw new RafError(`${file} : perf doit être un objet { prices }`);
|
|
213
|
+
for (const k of Object.keys(perf))
|
|
214
|
+
if (k !== 'prices')
|
|
215
|
+
throw new RafError(`${file} : perf.${k} inconnu (attendu : prices)`);
|
|
216
|
+
const prices = perf.prices;
|
|
217
|
+
if (prices == null)
|
|
218
|
+
return null;
|
|
219
|
+
if (!isObject(prices))
|
|
220
|
+
throw new RafError(`${file} : perf.prices doit être un objet { ${PRICED_MODELS.join(', ')} }`);
|
|
221
|
+
const grid = {};
|
|
222
|
+
for (const [model, price] of Object.entries(prices)) {
|
|
223
|
+
if (!PRICED_MODELS.includes(model))
|
|
224
|
+
throw new RafError(`${file} : perf.prices.${model} inconnu (attendu : ${PRICED_MODELS.join(', ')})`);
|
|
225
|
+
if (!isObject(price))
|
|
226
|
+
throw new RafError(`${file} : perf.prices.${model} doit être un objet { ${PRICE_KEYS.join(', ')} }`);
|
|
227
|
+
for (const k of Object.keys(price))
|
|
228
|
+
if (!PRICE_KEYS.includes(k))
|
|
229
|
+
throw new RafError(`${file} : perf.prices.${model}.${k} inconnu (attendu : ${PRICE_KEYS.join(', ')})`);
|
|
230
|
+
const entry = {};
|
|
231
|
+
for (const k of PRICE_KEYS) {
|
|
232
|
+
const v = price[k];
|
|
233
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
|
|
234
|
+
throw new RafError(`${file} : perf.prices.${model}.${k} : nombre ≥ 0 attendu (€ par million de tokens)`);
|
|
235
|
+
entry[k] = v;
|
|
236
|
+
}
|
|
237
|
+
grid[model] = entry;
|
|
238
|
+
}
|
|
239
|
+
return Object.keys(grid).length === 0 ? null : grid;
|
|
240
|
+
}
|
|
181
241
|
/**
|
|
182
242
|
* Clé `session:` de cadence.yaml : les faits propres au projet (son script de reprise, de clôture),
|
|
183
243
|
* joués par cadence au lieu d'être remplacés.
|
|
@@ -223,10 +283,10 @@ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
|
223
283
|
export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
|
|
224
284
|
const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
225
285
|
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
226
|
-
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
286
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'worktrees', 'testGate', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
227
287
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
228
288
|
export function readOrchestrateConfig(file) {
|
|
229
|
-
const config = { precheck: true, maquette: true, maquetteReviews: 3, fiche: true, precheckLocal: false, prodAccess: 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 } };
|
|
289
|
+
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 } };
|
|
230
290
|
if (!existsSync(file))
|
|
231
291
|
return config;
|
|
232
292
|
let raw;
|
|
@@ -293,6 +353,34 @@ export function readOrchestrateConfig(file) {
|
|
|
293
353
|
throw bad('prodAccess : true ou false attendu');
|
|
294
354
|
config.prodAccess = o.prodAccess;
|
|
295
355
|
}
|
|
356
|
+
if (o.worktrees != null) {
|
|
357
|
+
if (typeof o.worktrees !== 'boolean')
|
|
358
|
+
throw bad('worktrees doit être true ou false');
|
|
359
|
+
config.worktrees = o.worktrees;
|
|
360
|
+
}
|
|
361
|
+
if (o.testGate != null && o.testGate !== false) {
|
|
362
|
+
const g = o.testGate;
|
|
363
|
+
if (!isObject(g))
|
|
364
|
+
throw bad('testGate doit être false ou un objet { code, tests, except, threshold }');
|
|
365
|
+
for (const k of Object.keys(g))
|
|
366
|
+
if (!['code', 'tests', 'except', 'threshold'].includes(k))
|
|
367
|
+
throw bad(`testGate.${k} inconnu (attendu : code, tests, except, threshold)`);
|
|
368
|
+
const patterns = (key, fallback) => {
|
|
369
|
+
if (g[key] == null) {
|
|
370
|
+
if (fallback)
|
|
371
|
+
return fallback;
|
|
372
|
+
throw bad(`testGate.${key} : au moins un motif de chemin attendu`);
|
|
373
|
+
}
|
|
374
|
+
const v = list(g[key]);
|
|
375
|
+
if (v.some((x) => !x.trim()) || (key !== 'except' && !v.length))
|
|
376
|
+
throw bad(`testGate.${key} : motifs de chemin non vides attendus`);
|
|
377
|
+
return v.map((x) => x.trim());
|
|
378
|
+
};
|
|
379
|
+
const threshold = g.threshold ?? 1;
|
|
380
|
+
if (typeof threshold !== 'number' || !Number.isInteger(threshold) || threshold < 1)
|
|
381
|
+
throw bad('testGate.threshold : entier supérieur ou égal à 1 attendu');
|
|
382
|
+
config.testGate = { code: patterns('code', null), tests: patterns('tests', DEFAULT_TEST_PATTERNS), except: patterns('except', []), threshold };
|
|
383
|
+
}
|
|
296
384
|
if (o.spec != null) {
|
|
297
385
|
if (!isObject(o.spec))
|
|
298
386
|
throw bad('spec doit être un objet { dir, render }');
|
|
@@ -407,6 +495,9 @@ export function readDocsConfig(file) {
|
|
|
407
495
|
catch (e) {
|
|
408
496
|
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
409
497
|
}
|
|
498
|
+
if (raw?.describedBy != null) {
|
|
499
|
+
throw new RafError(`${file} : describedBy n'existe plus (L308) : les documents hors dépôt se déclarent dans docs.articles, { repo, file } relatif au projet`);
|
|
500
|
+
}
|
|
410
501
|
const d = raw?.docs;
|
|
411
502
|
if (d == null)
|
|
412
503
|
return none;
|
|
@@ -460,3 +551,54 @@ export function readDocsConfig(file) {
|
|
|
460
551
|
}
|
|
461
552
|
return config;
|
|
462
553
|
}
|
|
554
|
+
/** Clé `references:` de cadence.yaml. */
|
|
555
|
+
export function readReferencesConfig(file) {
|
|
556
|
+
if (!existsSync(file))
|
|
557
|
+
return {};
|
|
558
|
+
let raw;
|
|
559
|
+
try {
|
|
560
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
564
|
+
}
|
|
565
|
+
const r = raw?.references;
|
|
566
|
+
if (r == null)
|
|
567
|
+
return {};
|
|
568
|
+
if (!isObject(r))
|
|
569
|
+
throw new RafError(`${file} : references doit être un objet { dir }`);
|
|
570
|
+
for (const k of Object.keys(r))
|
|
571
|
+
if (k !== 'dir')
|
|
572
|
+
throw new RafError(`${file} : references.${k} inconnu (attendu : dir)`);
|
|
573
|
+
if (r.dir == null)
|
|
574
|
+
return {};
|
|
575
|
+
if (typeof r.dir !== 'string' || r.dir.trim() === '')
|
|
576
|
+
throw new RafError(`${file} : references.dir : chemin attendu`);
|
|
577
|
+
return { dir: r.dir.trim() };
|
|
578
|
+
}
|
|
579
|
+
/** Clé `migrate:` de cadence.yaml (L284) : `migrate.keep`. */
|
|
580
|
+
export function readMigrateConfig(file) {
|
|
581
|
+
const none = { keep: [] };
|
|
582
|
+
if (!existsSync(file))
|
|
583
|
+
return none;
|
|
584
|
+
let raw;
|
|
585
|
+
try {
|
|
586
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
590
|
+
}
|
|
591
|
+
const m = raw?.migrate;
|
|
592
|
+
if (m == null)
|
|
593
|
+
return none;
|
|
594
|
+
if (!isObject(m))
|
|
595
|
+
throw new RafError(`${file} : migrate doit être un objet { keep }`);
|
|
596
|
+
for (const k of Object.keys(m))
|
|
597
|
+
if (k !== 'keep')
|
|
598
|
+
throw new RafError(`${file} : migrate.${k} inconnu (attendu : keep)`);
|
|
599
|
+
if (m.keep == null)
|
|
600
|
+
return none;
|
|
601
|
+
if (!Array.isArray(m.keep) || m.keep.some((x) => typeof x !== 'string' || !x.trim()))
|
|
602
|
+
throw new RafError(`${file} : migrate.keep doit être une liste de chemins ou de motifs non vides`);
|
|
603
|
+
return { keep: m.keep.map((x) => x.trim()) };
|
|
604
|
+
}
|
package/dist/corrections.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { basename, join } from 'node:path';
|
|
3
|
+
import { PRECHECK_UNREADABLE } from './orchestrate/state.js';
|
|
3
4
|
import { RafError } from './plan.js';
|
|
4
5
|
/** Les causes d'une passe de correction (L165), dans l'ordre où le rapport les liste. */
|
|
5
6
|
export const CAUSES = ['spec-floue', 'regle-inventee', 'derive-architecture', 'donnees-sql', 'bug-code', 'test-manquant', 'faux-constat'];
|
|
@@ -14,7 +15,7 @@ const LABELS = {
|
|
|
14
15
|
'sans-constat': 'sans revue (tests rouges)',
|
|
15
16
|
'non-classe': 'non classé',
|
|
16
17
|
};
|
|
17
|
-
const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
|
|
18
|
+
export const isReview = (kind) => kind.startsWith('review') || kind === 'ux';
|
|
18
19
|
function readJson(file) {
|
|
19
20
|
try {
|
|
20
21
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
@@ -37,15 +38,38 @@ function findingsOf(report) {
|
|
|
37
38
|
const list = Array.isArray(body?.constats) ? body.constats : [];
|
|
38
39
|
return list.map((c) => (typeof c === 'string' ? c : c?.texte)).filter((t) => typeof t === 'string' && t.length > 0);
|
|
39
40
|
}
|
|
41
|
+
const USAGE_KEYS = ['input', 'cacheWrite', 'cacheRead', 'output'];
|
|
42
|
+
/** Les quatre compteurs d'une étape quand ce sont tous des nombres. */
|
|
43
|
+
function usageOf(tokens) {
|
|
44
|
+
if (!tokens || !USAGE_KEYS.every((k) => typeof tokens[k] === 'number'))
|
|
45
|
+
return undefined;
|
|
46
|
+
return { input: tokens.input, cacheWrite: tokens.cacheWrite, cacheRead: tokens.cacheRead, output: tokens.output };
|
|
47
|
+
}
|
|
40
48
|
/**
|
|
41
|
-
* Lit les journaux de vague `<runs>/<vague>/<projet>--<lot>.json
|
|
42
|
-
*
|
|
49
|
+
* Lit TOUS les journaux de vague `<runs>/<vague>/<projet>--<lot>.json` lisibles (seuls un journal sans `steps` lisible ou, sous `until` ou `since`, une vague sans date de création lisible sont écartés) : c'est la lecture que partagent `readRuns` et `cadence perf` (L225), le filtre « session implement aboutie » n'est pas ici mais dans leurs prédicats (`implemented`). Chaque pas `fix` abouti est une passe, rattachée aux constats de la dernière revue qui la précède.
|
|
50
|
+
* `until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là ; une vague ou une étape sans date lisible est écartée. `since` : date ISO, ne garde que les vagues créées strictement après.
|
|
43
51
|
*/
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Verdict du contrôle préalable d'un journal : `precheckVerdict` (L288) ; pour un journal d'avant, relu de ce qu'il garde : le lot
|
|
54
|
+
* rendu « livrable déjà présent » est un « oui », celui qui porte le constat du précheck (`precheck`) un « partiel », tout autre
|
|
55
|
+
* lot dont l'étape precheck a abouti un « non ». Sans étape precheck aboutie, aucun verdict ; de même pour un journal qui garde l'avertissement
|
|
56
|
+
* d'un rapport illisible (L293) : sans verdict lisible, le lot n'est pas un « non ».
|
|
57
|
+
*/
|
|
58
|
+
function precheckVerdictOf(rec, steps) {
|
|
59
|
+
if (rec.precheckVerdict === 'oui' || rec.precheckVerdict === 'partiel' || rec.precheckVerdict === 'non')
|
|
60
|
+
return rec.precheckVerdict;
|
|
61
|
+
if (!steps.some((s) => s.kind === 'precheck' && s.status === 'ok'))
|
|
62
|
+
return undefined;
|
|
63
|
+
if (Array.isArray(rec.warnings) && rec.warnings.some((w) => typeof w === 'string' && w.startsWith(PRECHECK_UNREADABLE)))
|
|
64
|
+
return undefined;
|
|
65
|
+
if (typeof rec.outcome === 'string' && rec.outcome.startsWith('livrable déjà présent'))
|
|
66
|
+
return 'oui';
|
|
67
|
+
return typeof rec.precheck === 'string' && rec.precheck !== '' ? 'partiel' : 'non';
|
|
68
|
+
}
|
|
69
|
+
export function readJournals(runsDir, until, since) {
|
|
70
|
+
const journals = [];
|
|
47
71
|
if (!existsSync(runsDir))
|
|
48
|
-
return
|
|
72
|
+
return journals;
|
|
49
73
|
const limit = until === undefined ? Infinity : Date.parse(until);
|
|
50
74
|
const from = since === undefined ? undefined : Date.parse(since);
|
|
51
75
|
// Sous `until`, une étape compte si elle est terminée avant la date ; sans date de fin lisible, elle est écartée.
|
|
@@ -54,15 +78,16 @@ export function readRuns(runsDir, until, since) {
|
|
|
54
78
|
const waveDir = join(runsDir, wave);
|
|
55
79
|
if (!statSync(waveDir).isDirectory())
|
|
56
80
|
continue;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
const waveJson = readJson(join(waveDir, 'wave.json'));
|
|
82
|
+
// Les vagues nommées à la main (ol-gains-1) n'ont pas d'horodatage dans leur nom : on lit `created` dans wave.json.
|
|
83
|
+
const created = Date.parse(waveJson?.created);
|
|
84
|
+
if (until !== undefined && !(created <= limit))
|
|
85
|
+
continue;
|
|
86
|
+
// `since` est strict : une vague créée pile à la borne reste dans l'avant de `until` (inclusif), les deux mesures ne se recouvrent jamais ; sans date de création lisible, la vague est dans aucune.
|
|
87
|
+
if (from !== undefined && !(created > from))
|
|
88
|
+
continue;
|
|
89
|
+
const waveReadable = waveJson !== null && typeof waveJson === 'object';
|
|
90
|
+
const waveCreated = Number.isNaN(created) ? undefined : waveJson.created;
|
|
66
91
|
for (const file of readdirSync(waveDir).sort()) {
|
|
67
92
|
if (!file.endsWith('.json') || file === 'wave.json')
|
|
68
93
|
continue;
|
|
@@ -71,20 +96,51 @@ export function readRuns(runsDir, until, since) {
|
|
|
71
96
|
continue;
|
|
72
97
|
const steps = rec.steps.filter((s) => s && typeof s === 'object');
|
|
73
98
|
const done = (s) => s.status === 'ok' && inTime(s);
|
|
74
|
-
if (!steps.some((s) => s.kind === 'implement' && done(s)))
|
|
75
|
-
continue;
|
|
76
99
|
const name = basename(file, '.json');
|
|
77
100
|
const fixSteps = steps.filter((s) => s.kind === 'fix' && done(s));
|
|
78
|
-
|
|
79
|
-
for (const fix of fixSteps) {
|
|
101
|
+
const passes = fixSteps.map((fix) => {
|
|
80
102
|
const review = [...steps].reverse().find((s) => s.n < fix.n && isReview(s.kind) && s.report);
|
|
81
103
|
// Une passe fix entre la revue et celle-ci a déjà traité ces constats : cette passe-ci (tests rouges) n'en a pas.
|
|
82
104
|
const handled = review ? fixSteps.some((f) => f.n > review.n && f.n < fix.n) : false;
|
|
83
105
|
const findings = review?.report && !handled ? findingsOf(readJson(join(waveDir, name, review.report))) : [];
|
|
84
|
-
|
|
85
|
-
}
|
|
106
|
+
return { key: `${wave}/${name}/${fix.n}`, project: rec.project, lot: rec.lot, visible: !!rec.visible, findings, commits: fix.commits ?? [], ...(fix.ended ? { fixEnded: fix.ended } : {}), ...(findings.length > 0 && review ? { reviewKind: review.kind } : {}) };
|
|
107
|
+
});
|
|
108
|
+
const precheck = precheckVerdictOf(rec, steps);
|
|
109
|
+
journals.push({
|
|
110
|
+
wave,
|
|
111
|
+
...(waveCreated ? { waveCreated } : {}),
|
|
112
|
+
waveReadable,
|
|
113
|
+
project: rec.project,
|
|
114
|
+
lot: rec.lot,
|
|
115
|
+
visible: !!rec.visible,
|
|
116
|
+
small: !!rec.small,
|
|
117
|
+
status: String(rec.status),
|
|
118
|
+
...(typeof rec.budget === 'number' ? { budget: rec.budget } : {}),
|
|
119
|
+
...(precheck ? { precheck } : {}),
|
|
120
|
+
steps: steps.map((s) => {
|
|
121
|
+
const usage = usageOf(s.tokens);
|
|
122
|
+
return { n: s.n, kind: s.kind, status: s.status, ...(typeof s.model === 'string' ? { model: s.model } : {}), ...(s.started ? { started: s.started } : {}), ...(s.ended ? { ended: s.ended } : {}), ...(typeof s.tokens?.counted === 'number' ? { counted: s.tokens.counted } : {}), ...(usage ? { usage } : {}) };
|
|
123
|
+
}),
|
|
124
|
+
implemented: steps.some((s) => s.kind === 'implement' && done(s)),
|
|
125
|
+
passes,
|
|
126
|
+
});
|
|
86
127
|
}
|
|
87
128
|
}
|
|
129
|
+
return journals;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Les lots et passes de correction des journaux de `<runs>`. Un lot compte (dénominateur) dès qu'une session d'écriture a abouti (`until` : date ISO, ne lit que les vagues créées jusque-là et, dans chacune, les étapes terminées (`ended`) jusque-là, pour une mesure que ni les vagues suivantes ni une vague encore en cours ne changent ; `since` : date ISO, ne garde que les lots des vagues créées strictement après, avec toutes leurs passes : une vague rend tous ses briefs depuis la copie de l'outil figée à sa création, c'est la date de la vague, non celle du début d'un lot, qui dit quel brief il a reçu, sauf pour une vague reprise sans instantané ou, avec `--budget projet:lot=+N`, depuis un instantané d'avant L223 : elle tourne alors avec le dist/ et les gabarits courants ; `--until` plus `--since` ne font le total sans borne que si aucune vague créée avant la borne n'est encore reprenable).
|
|
133
|
+
*/
|
|
134
|
+
export function readRuns(runsDir, until, since) {
|
|
135
|
+
const lots = [];
|
|
136
|
+
const passes = [];
|
|
137
|
+
for (const j of readJournals(runsDir, until, since)) {
|
|
138
|
+
if (!j.implemented)
|
|
139
|
+
continue;
|
|
140
|
+
lots.push({ project: j.project, lot: j.lot, visible: j.visible, fixes: j.passes.length });
|
|
141
|
+
for (const { fixEnded: _ended, reviewKind: _kind, ...pass } of j.passes)
|
|
142
|
+
passes.push(pass);
|
|
143
|
+
}
|
|
88
144
|
return { lots, passes };
|
|
89
145
|
}
|
|
90
146
|
const tally = () => ({ lots: 0, atLeast1: 0, atLeast2: 0, passes: 0 });
|
package/dist/deliver.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { constants } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
3
5
|
import { parse } from 'yaml';
|
|
4
6
|
import { onTermination, readProcs, SIGNAL_GRACE_MS, TreeTracker } from './proc.js';
|
|
5
7
|
import { isPlanOnly } from './audit.js';
|
|
@@ -7,7 +9,7 @@ import { headSha, isAncestor, onRemote, readCommits, repoStatus, resolveCommit }
|
|
|
7
9
|
import { citedRefs } from './link.js';
|
|
8
10
|
import { describeArticle, openArticleLots } from './articles.js';
|
|
9
11
|
import { RafError } from './plan.js';
|
|
10
|
-
import { appendDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
12
|
+
import { appendDelivery, appendFailedDelivery, lastDelivery, lockAlive, lockFault, lockPath, readLock, releaseLock, removeStaleLock, writeLock } from './state.js';
|
|
11
13
|
const POLL_CI = 15_000;
|
|
12
14
|
const CI_APPEAR = 300_000;
|
|
13
15
|
/** Intervalle de réessai d'une vérification : celui de deliver ET de `cadence verify --retry`. */
|
|
@@ -15,6 +17,10 @@ export const POLL_VERIFY = 10_000;
|
|
|
15
17
|
const CI_OK = new Set(['success', 'skipped', 'neutral']);
|
|
16
18
|
const GH_TIMEOUT = 60_000;
|
|
17
19
|
export const TIMED_OUT = 124;
|
|
20
|
+
/** Sortie gardée d'une commande : de quoi retrouver ses dernières lignes. */
|
|
21
|
+
const OUTPUT_KEPT = 8_192;
|
|
22
|
+
/** Les lignes de la sortie ; une ligne réécrite par \r (barre de progression) ne garde que sa dernière image non vide. */
|
|
23
|
+
const outputTail = (text) => text.split('\n').flatMap((l) => l.split('\r').filter((f) => f.trim() !== '').slice(-1));
|
|
18
24
|
const codeText = (code) => (code === TIMED_OUT ? 'délai dépassé' : `code ${code}`);
|
|
19
25
|
export function parseDeliverConfig(text, file) {
|
|
20
26
|
let raw;
|
|
@@ -111,15 +117,25 @@ export function realDeps(root, read = readProcs) {
|
|
|
111
117
|
throw new Error(String(err.stderr || err.message).trim().split('\n').slice(0, 3).join(' '));
|
|
112
118
|
}
|
|
113
119
|
};
|
|
120
|
+
let output = '';
|
|
114
121
|
return {
|
|
122
|
+
lastOutput: () => outputTail(output),
|
|
115
123
|
exec: (cmd, env, timeoutMs) => new Promise((resolve) => {
|
|
124
|
+
output = '';
|
|
116
125
|
const child = spawn('sh', ['-c', cmd], {
|
|
117
126
|
cwd: root,
|
|
118
127
|
env: { ...process.env, ...env },
|
|
119
|
-
|
|
128
|
+
// La sortie passe par cadence (recopiée à l'identique) pour que la raison d'un échec en garde la fin.
|
|
129
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
120
130
|
// PAS de groupe détaché : la commande reste dans le groupe de premier plan et la session de cadence.
|
|
121
131
|
// Ctrl-C et le raccrochage l'atteignent avec cadence, et /dev/tty reste là pour ssh, sudo, pinentry.
|
|
122
132
|
});
|
|
133
|
+
const relay = (from, to) => from.on('data', (chunk) => {
|
|
134
|
+
to.write(chunk);
|
|
135
|
+
output = (output + chunk.toString('utf8')).slice(-OUTPUT_KEPT);
|
|
136
|
+
});
|
|
137
|
+
relay(child.stdout, process.stdout);
|
|
138
|
+
relay(child.stderr, process.stderr);
|
|
123
139
|
const pid = child.pid;
|
|
124
140
|
if (pid === undefined) {
|
|
125
141
|
child.once('error', () => resolve(127));
|
|
@@ -179,7 +195,7 @@ function substitute(text, sha) {
|
|
|
179
195
|
return text.replaceAll('${SHA}', sha).replaceAll('${SHORT}', sha.slice(0, 7));
|
|
180
196
|
}
|
|
181
197
|
/** Argument rendu tel quel au script par sh, quels que soient ses espaces, guillemets ou jokers. */
|
|
182
|
-
function shellQuote(arg) {
|
|
198
|
+
export function shellQuote(arg) {
|
|
183
199
|
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
184
200
|
}
|
|
185
201
|
export function describeCheck(c, sha) {
|
|
@@ -202,11 +218,13 @@ export async function deliver(ctx, deps) {
|
|
|
202
218
|
return refuse('des arguments ne se passent qu\'à un script de projet (deliver.script dans cadence.yaml)');
|
|
203
219
|
}
|
|
204
220
|
const script = config.script === undefined ? null : [config.script, ...ctx.args.map(shellQuote)].join(' ');
|
|
221
|
+
// --sha livre un commit poussé et ne lit pas l'arbre : un fichier modifié (le plan après raf done) n'y change rien.
|
|
205
222
|
const repo = repoStatus(ctx.root);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
223
|
+
const dirty = ctx.sha === undefined ? repo.dirty : 0;
|
|
224
|
+
if (dirty && !config.allowDirty)
|
|
225
|
+
return refuse(`${dirty} fichier(s) suivi(s) modifié(s) : commiter et pousser d'abord`);
|
|
226
|
+
if (dirty)
|
|
227
|
+
err(`deliver : ${dirty} fichier(s) suivi(s) modifié(s) : non livré(s), seul ${sha.slice(0, 7)} l'est (allowDirty)`);
|
|
210
228
|
if (!onRemote(ctx.root, sha))
|
|
211
229
|
return refuse(`${sha.slice(0, 7)} non poussé : la CI n'a rien construit (git push)`);
|
|
212
230
|
const lock = readLock(ctx.state);
|
|
@@ -258,40 +276,86 @@ export async function deliver(ctx, deps) {
|
|
|
258
276
|
if (!taken)
|
|
259
277
|
return refuse('une autre livraison vient de démarrer');
|
|
260
278
|
try {
|
|
261
|
-
const fail = (msg) => {
|
|
279
|
+
const fail = (msg, cls, tail = []) => {
|
|
262
280
|
err(`deliver : ${msg}`);
|
|
281
|
+
// Une livraison rouge s'inscrit au journal des rouges (perf) ; son écriture ne change ni le code ni le message d'échec.
|
|
282
|
+
try {
|
|
283
|
+
appendFailedDelivery(ctx.state, ctx.today, sha, msg, cls, tail);
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
err(`deliver : journal des livraisons rouges non écrit : ${e.message}`);
|
|
287
|
+
}
|
|
263
288
|
return 1;
|
|
264
289
|
};
|
|
265
290
|
out(`Livraison de ${env.CADENCE_SHORT} (${branch})`);
|
|
291
|
+
let declared = null;
|
|
266
292
|
if (script !== null) {
|
|
267
293
|
out(`→ script du projet : ${script}`);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
294
|
+
// Le script qui déploie un autre commit que CADENCE_SHA y écrit son sha : cadence ne le devine pas.
|
|
295
|
+
const file = join(ctx.state, `deployed-${process.pid}`);
|
|
296
|
+
try {
|
|
297
|
+
writeFileSync(file, '');
|
|
298
|
+
const code = await deps.exec(script, { ...env, CADENCE_DEPLOYED_FILE: file }, config.deployTimeout * 1000);
|
|
299
|
+
// Code 2 : le script a refusé avant d'agir (même convention que deliver) ; tout autre échec est un déploiement qui a cassé.
|
|
300
|
+
if (code !== 0)
|
|
301
|
+
return fail(`script de livraison en échec (${codeText(code)}) : ${script}`, code === 2 ? 'refus' : 'deploiement', deps.lastOutput?.());
|
|
302
|
+
const text = readFileSync(file, 'utf8').trim();
|
|
303
|
+
if (text !== '') {
|
|
304
|
+
// Un sha seulement : HEAD ou une branche seraient résolus après le script, au commit d'un autre.
|
|
305
|
+
declared = /^[0-9a-f]{7,40}$/i.test(text) ? resolveCommit(ctx.root, text) : null;
|
|
306
|
+
if (declared === null)
|
|
307
|
+
return fail(`CADENCE_DEPLOYED_FILE : « ${text.split('\n')[0].slice(0, 80)} » n'est pas un sha de commit du dépôt (le script doit y écrire le sha hexadécimal qu'il a déployé, ni HEAD ni une branche, ou rien)`, 'deploiement');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
return fail(`CADENCE_DEPLOYED_FILE illisible : ${e.message}`, 'deploiement');
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
rmSync(file, { force: true });
|
|
315
|
+
}
|
|
271
316
|
}
|
|
272
317
|
else {
|
|
273
318
|
const ci = await waitCi(ctx, deps, sha, env);
|
|
319
|
+
// Seule une commande de CI a une sortie : pour `ci: github`, lastOutput serait celle d'une commande antérieure.
|
|
274
320
|
if (ci)
|
|
275
|
-
return fail(ci);
|
|
321
|
+
return fail(ci, 'ci', typeof config.ci === 'object' ? deps.lastOutput?.() : []);
|
|
276
322
|
for (const [i, cmd] of config.deploy.entries()) {
|
|
277
323
|
out(`→ déploiement ${i + 1}/${config.deploy.length} : ${cmd}`);
|
|
278
324
|
const code = await deps.exec(cmd, env, config.deployTimeout * 1000);
|
|
279
325
|
if (code !== 0)
|
|
280
|
-
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}
|
|
326
|
+
return fail(`déploiement en échec (${codeText(code)}) : ${cmd}`, 'deploiement', deps.lastOutput?.());
|
|
281
327
|
}
|
|
282
328
|
}
|
|
283
329
|
const failed = await verifyAll(ctx, deps, sha, env);
|
|
284
330
|
if (failed)
|
|
285
|
-
return fail(failed);
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
return fail(failed.message, 'verification', failed.tail);
|
|
332
|
+
// Ce qui est journalisé : le sha que le script déclare (CADENCE_DEPLOYED_FILE) l'emporte. Sans déclaration, hors vague, un script qui a commité
|
|
333
|
+
// pendant la livraison (horodatage d'une entrée Nouveautés) a livré la nouvelle tête ; pendant une vague on ne devine plus qui a commité
|
|
334
|
+
// (le script, un lot en cours, un commit de plan) : le journal garde le sha de départ et l'avertissement le dit.
|
|
335
|
+
let final = sha;
|
|
336
|
+
let how = 'sha';
|
|
337
|
+
if (declared !== null && declared !== sha) {
|
|
338
|
+
final = declared;
|
|
339
|
+
how = 'declared';
|
|
340
|
+
}
|
|
341
|
+
else if (declared === null && script !== null) {
|
|
342
|
+
const head = headSha(ctx.root);
|
|
343
|
+
if (ctx.duringWave) {
|
|
344
|
+
if (head !== null && head !== head0) {
|
|
345
|
+
err(`deliver : la tête a avancé pendant la livraison ; sans sha déclaré par le script (CADENCE_DEPLOYED_FILE), le journal garde ${env.CADENCE_SHORT}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
else if (atHead && head !== null && head !== sha && isAncestor(ctx.root, sha, head)) {
|
|
349
|
+
final = head;
|
|
350
|
+
how = 'moved';
|
|
351
|
+
}
|
|
352
|
+
}
|
|
291
353
|
const ids = deliveredIds(ctx, lastDelivery(ctx.state), final);
|
|
292
354
|
const delivered = deliveredLots(ctx, ids, lastDelivery(ctx.state), final);
|
|
293
355
|
appendDelivery(ctx.state, ctx.today, final);
|
|
294
|
-
if (
|
|
356
|
+
if (how === 'declared')
|
|
357
|
+
out(`✓ livré : ${final.slice(0, 7)} (sha déclaré par le script)`);
|
|
358
|
+
else if (how === 'moved')
|
|
295
359
|
out(`✓ livré : ${final.slice(0, 7)} (tête déplacée par le script depuis ${env.CADENCE_SHORT})`);
|
|
296
360
|
else
|
|
297
361
|
out(`✓ livré${config.verify.length ? ' et vérifié' : ''} : ${env.CADENCE_SHORT}`);
|
|
@@ -400,7 +464,7 @@ async function verifyAll(ctx, deps, sha, env) {
|
|
|
400
464
|
ctx.out(`→ vérification ${i + 1}/${ctx.config.verify.length} : ${label}`);
|
|
401
465
|
const reason = await retryCheck(c, deps, sha, env, deadline, () => deadline - deps.now());
|
|
402
466
|
if (reason !== null)
|
|
403
|
-
return `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}
|
|
467
|
+
return { message: `vérification en échec après ${ctx.config.verifyTimeout} s : ${label} — ${reason}`, tail: c.command !== undefined ? (deps.lastOutput?.() ?? []) : [] };
|
|
404
468
|
}
|
|
405
469
|
return null;
|
|
406
470
|
}
|
package/dist/docsync.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
1
2
|
import { changedFiles } from './git.js';
|
|
2
3
|
/** Motif de chemin : `*` reste dans un dossier, `**` le traverse, `?` un caractère, un `/` final prend tout le dossier ; sinon chemin exact. */
|
|
3
4
|
export function docSyncMatcher(pattern) {
|
|
@@ -15,9 +16,9 @@ export function docSyncSet(patterns) {
|
|
|
15
16
|
const no = patterns.filter((p) => p.startsWith('!')).map((p) => docSyncMatcher(p.slice(1)));
|
|
16
17
|
return (file) => yes.some((m) => m(file)) && !no.some((m) => m(file));
|
|
17
18
|
}
|
|
18
|
-
/** Fichiers modifiés par un ensemble de commits, sans doublon. */
|
|
19
|
-
export function filesOf(root, commits) {
|
|
20
|
-
return new Set(commits.flatMap((c) => changedFiles(root, c.sha)));
|
|
19
|
+
/** Fichiers modifiés par un ensemble de commits, sans doublon ; `withoutDeleted` écarte les fichiers que les commits suppriment. */
|
|
20
|
+
export function filesOf(root, commits, opts = {}) {
|
|
21
|
+
return new Set(commits.flatMap((c) => changedFiles(root, c.sha, opts)));
|
|
21
22
|
}
|
|
22
23
|
/** Les règles que ces fichiers enfreignent : ils touchent `paths` sans qu'aucun `docs` ne soit parmi eux. */
|
|
23
24
|
export function docSyncGaps(rules, files) {
|
|
@@ -55,3 +56,18 @@ export function docSyncBrief(rules, gaps) {
|
|
|
55
56
|
...gaps.map((g) => `- ${listFiles(g.touched)} changed, but none of ${g.rule.docs.join(', ')} was touched`),
|
|
56
57
|
].join('\n');
|
|
57
58
|
}
|
|
59
|
+
/** Dossier du dépôt qui porte un document hors dépôt : `repo` relatif à la racine du projet, ou absolu (résolu tel quel). */
|
|
60
|
+
export const articleRepoDir = (root, rule) => resolve(root, rule.repo);
|
|
61
|
+
/**
|
|
62
|
+
* Consigne du bibliothécaire (L306, L308) sur les documents hors dépôt : seulement ceux que la configuration déclare (`docs.articles` du cadence.yaml du projet).
|
|
63
|
+
* Vide sans entrée. Le dépôt du document est ouvert à la session en lecture (--add-dir) ; le bibliothécaire ne l'écrit pas : il propose un lot pour le plan de ce dépôt.
|
|
64
|
+
*/
|
|
65
|
+
export function articlesBrief(root, rules) {
|
|
66
|
+
if (!rules.length)
|
|
67
|
+
return '';
|
|
68
|
+
return [
|
|
69
|
+
'Documents outside this repository, declared by the configuration (docs.articles of cadence.yaml): the project is also described by',
|
|
70
|
+
...rules.map((r) => `- \`${r.file}\` of the repository \`${r.repo}\` (${articleRepoDir(root, r)}, open to this session for reading)`),
|
|
71
|
+
'If the change makes one of them false or leaves it out, do not edit it and do not touch the plan of that repository: write a proposed lot (a title, then the file, the passage and the sentence to change) and put it in the "resume" of your report, starting with « lot proposé ». Read it to be exact; read it only.',
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
package/dist/git.js
CHANGED
|
@@ -12,6 +12,16 @@ export function gitRoot(cwd) {
|
|
|
12
12
|
return null;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/** Vrai quand `path` (relatif à `root`) est suivi par git : `git mv` échoue (code 128) sur un fichier non suivi. */
|
|
16
|
+
export function isTracked(root, path) {
|
|
17
|
+
try {
|
|
18
|
+
git(root, ['ls-files', '--error-unmatch', '--', path]);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
const FIELD = '\x1f';
|
|
16
26
|
const RECORD = '\x1e';
|
|
17
27
|
/** Commits reachable from HEAD, newest first. Empty when the repo has no commit yet. */
|
|
@@ -83,10 +93,13 @@ export function syncIndexWithHead(cwd, file) {
|
|
|
83
93
|
if (same(['HEAD']) && !same(['--cached']))
|
|
84
94
|
stage(cwd, file);
|
|
85
95
|
}
|
|
86
|
-
/**
|
|
87
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Fichiers modifiés par un commit, relatifs à la racine du dépôt (noms accentués ou à espaces compris). `withoutDeleted` écarte ceux que
|
|
98
|
+
* le commit supprime (`--diff-filter=d`) : un fichier supprimé n'est plus du code à tester.
|
|
99
|
+
*/
|
|
100
|
+
export function changedFiles(cwd, sha, opts = {}) {
|
|
88
101
|
// -z : noms séparés par NUL, jamais échappés ; quotepath=off en plus pour les sorties qui citeraient quand même.
|
|
89
|
-
return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', sha]).split('\0').filter(Boolean);
|
|
102
|
+
return git(cwd, ['-c', 'core.quotepath=off', 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', ...(opts.withoutDeleted ? ['--diff-filter=d'] : []), sha]).split('\0').filter(Boolean);
|
|
90
103
|
}
|
|
91
104
|
/** Contenu d'un fichier à un commit (`<sha>^` pour l'état d'avant) ; null s'il n'existe pas à ce commit. */
|
|
92
105
|
export function fileAt(cwd, rev, file) {
|