@sylad/cadence 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +286 -22
- package/agents/acceptance-writer.md +21 -0
- package/agents/code-reviewer.md +8 -1
- package/agents/spec-writer.md +23 -0
- package/agents/ux-reviewer.md +20 -1
- package/bin/cadence.js +11 -1
- package/dist/audit.js +14 -2
- package/dist/cli.js +233 -18
- package/dist/config.js +46 -5
- package/dist/corrections.js +228 -0
- package/dist/lead.js +72 -19
- package/dist/news.js +54 -7
- package/dist/orchestrate/accept.js +389 -0
- package/dist/orchestrate/aval.js +65 -0
- package/dist/orchestrate/briefs.js +7 -1
- package/dist/orchestrate/command.js +70 -20
- package/dist/orchestrate/continue.js +1 -1
- package/dist/orchestrate/cycle.js +662 -24
- package/dist/orchestrate/fiche.js +22 -0
- package/dist/orchestrate/launch.js +17 -10
- package/dist/orchestrate/maquette.js +27 -0
- package/dist/orchestrate/sandbox.js +38 -0
- package/dist/orchestrate/schemas.js +17 -1
- package/dist/orchestrate/spec.js +197 -0
- package/dist/orchestrate/state.js +2 -2
- package/dist/orchestrate/table.js +2 -0
- package/dist/plan.js +79 -1
- package/dist/spec.js +457 -0
- package/package.json +4 -3
- package/skills/lead/SKILL.md +33 -8
- package/templates/acceptation-runner.mjs +61 -0
- package/templates/fiche-back.yaml +75 -0
- package/templates/fiche.yaml +41 -0
- package/templates/orchestrate/accept-back.md +16 -0
- package/templates/orchestrate/accept.md +16 -0
- package/templates/orchestrate/fiche.md +14 -0
- package/templates/orchestrate/fix-minors.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +1 -0
- package/templates/orchestrate/maquette.md +12 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
- package/templates/orchestrate/spec-back.md +25 -0
- package/templates/orchestrate/spec.md +28 -0
- package/templates/orchestrate/ux.md +1 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, posix } from 'node:path';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6
|
+
function git(repo, ...args) {
|
|
7
|
+
return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
8
|
+
}
|
|
9
|
+
const gitOk = (repo, ...args) => {
|
|
10
|
+
try {
|
|
11
|
+
execFileSync('git', args, { cwd: repo, stdio: 'ignore' });
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** Un fichier d'acceptation : `acceptation-<lot>.json` (ou l'ancien `acceptation.json`, lu tel quel). */
|
|
19
|
+
export const isAcceptationFile = (path) => /(^|\/)acceptation(-[^/]+)?\.json$/.test(path);
|
|
20
|
+
/** Chemin attendu de l'acceptation d'un lot, à côté de la fiche de son écran : un fichier par lot (l'id de lot, `/` et autres réduits à `-`). */
|
|
21
|
+
export const acceptationFile = (fiche, lot) => posix.join(posix.dirname(fiche), `acceptation-${lot.replace(/[^A-Za-z0-9._-]+/g, '-')}.json`);
|
|
22
|
+
/** Les `acceptation.json` suivis sous `dir` à une révision (HEAD par défaut). */
|
|
23
|
+
export function listAcceptations(repo, dir, rev = 'HEAD') {
|
|
24
|
+
try {
|
|
25
|
+
return git(repo, 'ls-tree', '-r', '--name-only', '-z', rev, '--', dir).split('\0').filter(isAcceptationFile);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Forme du fichier lu, ou la raison pour laquelle il n'en a pas. */
|
|
32
|
+
export function parseAcceptation(text) {
|
|
33
|
+
let doc;
|
|
34
|
+
try {
|
|
35
|
+
doc = JSON.parse(text);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return `JSON illisible (${e.message})`;
|
|
39
|
+
}
|
|
40
|
+
if (!isObj(doc))
|
|
41
|
+
return 'objet attendu';
|
|
42
|
+
const { lot, fiche, fichiers, vert } = doc;
|
|
43
|
+
if (typeof lot !== 'string' || !lot)
|
|
44
|
+
return 'clé « lot » (id du lot) attendue';
|
|
45
|
+
if (typeof fiche !== 'string' || !fiche)
|
|
46
|
+
return 'clé « fiche » (chemin de la fiche) attendue';
|
|
47
|
+
if (!Array.isArray(fichiers) || fichiers.length === 0 || fichiers.some((f) => typeof f !== 'string' || !f))
|
|
48
|
+
return 'clé « fichiers » : liste non vide de chemins attendue';
|
|
49
|
+
if (typeof vert !== 'boolean')
|
|
50
|
+
return 'clé « vert » (booléen) attendue';
|
|
51
|
+
const empreintes = isObj(doc.empreintes) ? Object.fromEntries(Object.entries(doc.empreintes).filter(([, v]) => typeof v === 'string')) : undefined;
|
|
52
|
+
const resceaux = Array.isArray(doc.resceaux) ? doc.resceaux.filter((r) => isObj(r) && ['test', 'raison', 'date', 'avant', 'apres'].every((k) => typeof r[k] === 'string')) : undefined;
|
|
53
|
+
return { lot, fiche, fichiers: fichiers, vert, ...(empreintes ? { empreintes } : {}), ...(resceaux?.length ? { resceaux } : {}) };
|
|
54
|
+
}
|
|
55
|
+
/** JSON à clés triées, sans la clé `vert` : la forme que le verrou compare. */
|
|
56
|
+
const canon = (a) => JSON.stringify(Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'vert').sort(([x], [y]) => x.localeCompare(y))));
|
|
57
|
+
const show = (repo, rev, path) => {
|
|
58
|
+
try {
|
|
59
|
+
const v = JSON.parse(git(repo, 'show', `${rev}:${path}`));
|
|
60
|
+
return isObj(v) ? v : null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
/** Le commit rouge : le plus récent qui a changé le fichier autrement que par sa clé `vert` (l'ajout, ou le scellé du programme). Null hors historique. */
|
|
67
|
+
export function redSha(repo, path) {
|
|
68
|
+
let shas;
|
|
69
|
+
try {
|
|
70
|
+
shas = git(repo, 'log', '--format=%H', '--', path).split('\n').filter(Boolean);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
for (const sha of shas) {
|
|
76
|
+
const cur = show(repo, sha, path);
|
|
77
|
+
if (!cur)
|
|
78
|
+
continue; // suppression
|
|
79
|
+
const prev = show(repo, `${sha}^`, path);
|
|
80
|
+
if (!prev || canon(cur) !== canon(prev))
|
|
81
|
+
return sha;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Le commit rouge a bougé depuis `anchor` par un rescellé du lead (`cadence spec reseal`, L200) : le nouveau sha, sinon null.
|
|
87
|
+
* Seul un commit qui a allongé le journal `resceaux` ET qu'aucune session de la vague n'a fait (`bySession`) est adopté ; toute autre réécriture reste un écart pour le verrou.
|
|
88
|
+
*/
|
|
89
|
+
export function resealedRouge(repo, path, anchor, bySession = () => false) {
|
|
90
|
+
const now = redSha(repo, path);
|
|
91
|
+
if (!now || now === anchor)
|
|
92
|
+
return null;
|
|
93
|
+
if (bySession(now))
|
|
94
|
+
return null; // un commit de session n'est jamais un rescellé du lead : il reste un écart pour le verrou
|
|
95
|
+
const journal = (rev) => {
|
|
96
|
+
const r = show(repo, rev, path)?.resceaux;
|
|
97
|
+
return Array.isArray(r) ? r.length : 0;
|
|
98
|
+
};
|
|
99
|
+
return journal(now) > journal(anchor) ? now : null;
|
|
100
|
+
}
|
|
101
|
+
/** Chemin (relatif au dépôt) de l'`acceptation.json` du lot sous `dir`, ou null. */
|
|
102
|
+
export function findAcceptation(repo, dir, lot) {
|
|
103
|
+
for (const path of listAcceptations(repo, dir).sort()) {
|
|
104
|
+
try {
|
|
105
|
+
const a = parseAcceptation(readFileSync(join(repo, path), 'utf8'));
|
|
106
|
+
if (typeof a !== 'string' && a.lot === lot)
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// illisible : pas celui du lot
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/** Scelle le fichier (le programme, jamais une session) : l'empreinte git de chaque fichier de tests listé ; le commit est à faire. */
|
|
116
|
+
export function sealAcceptation(repo, path) {
|
|
117
|
+
const file = join(repo, path);
|
|
118
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
119
|
+
const fichiers = doc.fichiers.map((f) => posix.normalize(f));
|
|
120
|
+
doc.empreintes = Object.fromEntries(fichiers.map((f) => [f, git(repo, 'hash-object', '--', f).trim()]));
|
|
121
|
+
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Rescelle UN test (L200, `cadence spec reseal`, le lead — jamais une session) : le test d'un lot en cours contredit sa fiche et le verrou total
|
|
125
|
+
* ne laisse aucun chemin de correction. La fiche reste inchangée depuis le commit rouge, les autres fichiers listés aussi : seule l'empreinte
|
|
126
|
+
* du test nommé est refaite, la raison et les deux empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
|
|
127
|
+
*/
|
|
128
|
+
export function resealAcceptation(repo, path, test, reason, today) {
|
|
129
|
+
const file = join(repo, path);
|
|
130
|
+
const a = parseAcceptation(readFileSync(file, 'utf8'));
|
|
131
|
+
if (typeof a === 'string')
|
|
132
|
+
throw new Error(`${path} : ${a}`);
|
|
133
|
+
if (!reason.trim())
|
|
134
|
+
throw new Error('--reason est obligatoire : la raison du rescellé va dans acceptation.json et en note du lot');
|
|
135
|
+
const norm = posix.normalize(test);
|
|
136
|
+
const fichiers = a.fichiers.map((f) => posix.normalize(f));
|
|
137
|
+
if (!fichiers.includes(norm))
|
|
138
|
+
throw new Error(`${test} n'est pas listé dans ${path} (fichiers : ${fichiers.join(', ')})`);
|
|
139
|
+
const avant = a.empreintes?.[norm];
|
|
140
|
+
if (!a.empreintes || !avant)
|
|
141
|
+
throw new Error(`${path} n'est pas scellée (pas d'empreinte pour ${norm}) : l'étape accept n'est pas finie, rien à resceller`);
|
|
142
|
+
const red = redSha(repo, path);
|
|
143
|
+
if (!red)
|
|
144
|
+
throw new Error(`${path} : pas de commit rouge lisible`);
|
|
145
|
+
if (!existsSync(join(repo, norm)))
|
|
146
|
+
throw new Error(`${norm} : fichier absent (un test supprimé ne se rescelle pas)`);
|
|
147
|
+
if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche))
|
|
148
|
+
throw new Error(`la fiche ${a.fiche} a changé depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que fiche inchangée (une fiche qui évolue passe par le lot et sa revue)`);
|
|
149
|
+
const touches = fichiers.filter((f) => f !== norm && (!gitOk(repo, 'diff', '--quiet', red, '--', f) || !existsSync(join(repo, f)) || a.empreintes?.[f] !== git(repo, 'hash-object', '--', f).trim()));
|
|
150
|
+
if (touches.length)
|
|
151
|
+
throw new Error(`autre(s) test(s) listé(s) modifié(s) depuis le commit rouge : ${touches.join(', ')} (le rescellé ne couvre que ${norm}, le verrou garde le reste)`);
|
|
152
|
+
const apres = git(repo, 'hash-object', '--', norm).trim();
|
|
153
|
+
if (apres === avant)
|
|
154
|
+
throw new Error(`${norm} est inchangé depuis le scellé : rien à resceller`);
|
|
155
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
156
|
+
doc.empreintes[norm] = apres;
|
|
157
|
+
const journal = Array.isArray(doc.resceaux) ? doc.resceaux : [];
|
|
158
|
+
doc.resceaux = [...journal, { test: norm, raison: reason.trim(), date: today, avant, apres }];
|
|
159
|
+
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
160
|
+
return { avant, apres, lot: a.lot };
|
|
161
|
+
}
|
|
162
|
+
/** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
|
|
163
|
+
export function setVert(repo, path) {
|
|
164
|
+
const file = join(repo, path);
|
|
165
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
166
|
+
doc.vert = true;
|
|
167
|
+
writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
168
|
+
}
|
|
169
|
+
/** Écarts de l'`acceptation.json` écrit par la session accept (vide = accepté). `base` : la tête d'avant la première session accept ; un fichier de tests qui y existe déjà ne peut pas être listé. */
|
|
170
|
+
export function checkAcceptation(repo, path, lot, fiche, base) {
|
|
171
|
+
const file = join(repo, path);
|
|
172
|
+
if (!existsSync(file))
|
|
173
|
+
return [`${path} : acceptation.json absent (le lister dans ${path}, avec les fichiers de tests, dans le commit rouge)`];
|
|
174
|
+
const a = parseAcceptation(readFileSync(file, 'utf8'));
|
|
175
|
+
if (typeof a === 'string')
|
|
176
|
+
return [`${path} : ${a}`];
|
|
177
|
+
const problems = [];
|
|
178
|
+
if (a.lot !== lot)
|
|
179
|
+
problems.push(`${path} : lot « ${a.lot} » au lieu de ${lot}`);
|
|
180
|
+
if (a.fiche !== fiche)
|
|
181
|
+
problems.push(`${path} : fiche « ${a.fiche} » au lieu de ${fiche}`);
|
|
182
|
+
if (a.vert)
|
|
183
|
+
problems.push(`${path} : vert: true à l'écriture (les tests sont rouges : vert: false ; seul le cycle pose vert: true)`);
|
|
184
|
+
const sha = redSha(repo, path);
|
|
185
|
+
if (!sha)
|
|
186
|
+
problems.push(`${path} : non commité`);
|
|
187
|
+
// Reprise d'une acceptation déjà présente à la base (non scellée) : ses fichiers viennent d'une session accept précédente, pas d'un test ordinaire.
|
|
188
|
+
const fresh = base && !gitOk(repo, 'cat-file', '-e', `${base}:${path}`) ? base : null;
|
|
189
|
+
let texts = '';
|
|
190
|
+
for (const f of a.fichiers) {
|
|
191
|
+
const norm = posix.normalize(f);
|
|
192
|
+
if (isAbsolute(norm) || norm.startsWith('../') || norm === '..') {
|
|
193
|
+
problems.push(`${f} : fichier hors du dépôt`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!existsSync(join(repo, norm))) {
|
|
197
|
+
problems.push(`${f} : fichier de test listé mais absent`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (fresh && gitOk(repo, 'cat-file', '-e', `${base}:${norm}`)) {
|
|
201
|
+
problems.push(`${f} : ce fichier de tests existait avant la session accept (donnez à vos tests des fichiers neufs : ses tests ordinaires sortiraient de config.test et de la CI tant que le lot n'est pas vert)`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (sha && !gitOk(repo, 'cat-file', '-e', `${sha}:${norm}`))
|
|
205
|
+
problems.push(`${f} : absent du commit rouge de ${path} (fichiers de tests et acceptation.json vont dans le même commit)`);
|
|
206
|
+
texts += `${readFileSync(join(repo, norm), 'utf8')}\n`;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const doc = parse(readFileSync(join(repo, fiche), 'utf8'));
|
|
210
|
+
for (const r of doc.regles ?? [])
|
|
211
|
+
for (const t of Array.isArray(r.tests) ? r.tests : [])
|
|
212
|
+
if (typeof t === 'string' && !texts.includes(t) && !texts.includes(t.replace(/'/g, "\\'")))
|
|
213
|
+
problems.push(`${r.id} : test « ${t} » (nom prévu dans la fiche) absent des fichiers listés`);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
problems.push(`${fiche} : fiche illisible`);
|
|
217
|
+
}
|
|
218
|
+
return problems;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* La tête d'avant le premier commit de travail du lot (L184) : le parent du plus ancien des commits `work` (ordre indifférent, ceux que repère `plan.refs`).
|
|
222
|
+
* Aucun commit, un commit racine ou illisible : `undefined`.
|
|
223
|
+
*/
|
|
224
|
+
export function lotStartHead(repo, work) {
|
|
225
|
+
let oldest;
|
|
226
|
+
for (const sha of work) {
|
|
227
|
+
if (!gitOk(repo, 'cat-file', '-e', `${sha}^{commit}`))
|
|
228
|
+
continue;
|
|
229
|
+
const depth = Number(git(repo, 'rev-list', '--count', sha).trim());
|
|
230
|
+
if (!oldest || depth < oldest.depth)
|
|
231
|
+
oldest = { sha, depth };
|
|
232
|
+
}
|
|
233
|
+
if (!oldest || !gitOk(repo, 'rev-parse', '--verify', '-q', `${oldest.sha}^`))
|
|
234
|
+
return undefined;
|
|
235
|
+
return git(repo, 'rev-parse', `${oldest.sha}^`).trim();
|
|
236
|
+
}
|
|
237
|
+
/** La fiche a changé depuis `since` (commits et arbre de travail), ou dans l'un des commits `cites` (ceux qui citent le lot). Illisible pour git : non (le verrou reste entier). */
|
|
238
|
+
function ficheChanged(repo, since, fiche, cites = []) {
|
|
239
|
+
try {
|
|
240
|
+
if (git(repo, 'diff', '--name-only', since, '--', fiche).trim() !== '')
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
return cites.some((sha) => gitOk(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche) && git(repo, 'diff-tree', '--root', '--no-commit-id', '--name-only', '-r', sha, '--', fiche).trim() !== '');
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Le verrou (spec etape-spec § 5), valable pour toute session du dépôt : les tests d'acceptation de TOUS les lots sont inchangés depuis leur
|
|
250
|
+
* commit rouge, au sens de git (arbre de travail compris), et chaque `acceptation.json` n'a bougé que par sa clé `vert` — posée par le
|
|
251
|
+
* cycle, donc jamais modifiée pendant une session (comparée à `headBefore`). Vide = rien à signaler.
|
|
252
|
+
* Un fichier neuf est un écart, sauf `skip` : le fichier du lot que l'étape accept est en train d'écrire, pas encore scellé (les autres lots restent
|
|
253
|
+
* verrouillés, y compris contre un fichier d'un autre lot créé au passage). `rouge` : le commit rouge rangé dans l'état du cycle pour un fichier ; si
|
|
254
|
+
* le dernier commit qui l'a réécrit n'est plus celui-là, une session précédente (interrompue, donc jamais comparée) l'a réécrit.
|
|
255
|
+
* Exception (L183) : une acceptation à `vert: true` (lot livré) laisse modifier ses tests listés si la fiche de l'écran a changé depuis `lotBase` (la
|
|
256
|
+
* tête d'avant le premier travail du lot, par défaut `headBefore`), arbre de travail compris, ou dans un commit de `lotCommits` (ceux qui citent le lot,
|
|
257
|
+
* posés avant la vague) : la fiche fait évoluer les règles. L'acceptation du lot en cours garde le verrou entier de l'exception L183.
|
|
258
|
+
* Une fois livrée, une acceptation n'est plus comparée à son commit rouge (une évolution admise l'a dépassé et rien ne le rescelle) mais à `lotBase` :
|
|
259
|
+
* un test changé avant ce point l'a été par un lot qui avait la fiche pour lui, et ses empreintes scellées ne valent plus.
|
|
260
|
+
* Une seule exemption en plus (L184) : un fichier listé dont le contenu est identique à celui qu'il avait à `lotStart` (la tête d'avant le premier commit de
|
|
261
|
+
* travail du lot, `lotStartHead`) n'est pas un écart : le bilan est nul sur le lot, qui a retiré son propre ajout (une reprise dans une nouvelle vague part d'un état déjà modifié). Un fichier absent à `lotStart` ou supprimé depuis n'a pas de contenu : jamais exempté ; la fiche changée depuis `lotStart` (évolution admise d'un autre lot) ôte l'exemption. Le filtre vaut pour toutes les acceptations, `vert: false` comprises.
|
|
262
|
+
*/
|
|
263
|
+
export function lockViolations(repo, dir, headBefore, opts = {}) {
|
|
264
|
+
const out = [];
|
|
265
|
+
const before = new Set(listAcceptations(repo, dir, headBefore));
|
|
266
|
+
const now = new Set(listAcceptations(repo, dir));
|
|
267
|
+
for (const p of before)
|
|
268
|
+
if (!now.has(p))
|
|
269
|
+
out.push(`${p} : acceptation.json supprimé (verrou des tests d'acceptation)`);
|
|
270
|
+
for (const p of [...now].sort()) {
|
|
271
|
+
if (p === opts.skip)
|
|
272
|
+
continue;
|
|
273
|
+
const isNew = !before.has(p);
|
|
274
|
+
if (isNew)
|
|
275
|
+
out.push(`${p} : acceptation.json créé hors de l'étape accept`);
|
|
276
|
+
const red = redSha(repo, p);
|
|
277
|
+
const cur = show(repo, 'HEAD', p);
|
|
278
|
+
const base = red ? show(repo, red, p) : null;
|
|
279
|
+
if (!red || !cur || !base) {
|
|
280
|
+
out.push(`${p} : acceptation.json sans commit rouge lisible`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const anchor = opts.rouge?.[p];
|
|
284
|
+
if (anchor && red !== anchor)
|
|
285
|
+
out.push(`${p} : acceptation.json réécrit depuis son commit rouge ${anchor.slice(0, 7)} (réécriture commitée par une session interrompue ; seule la clé vert, posée par le cycle, peut changer)`);
|
|
286
|
+
const was = isNew ? undefined : show(repo, headBefore, p);
|
|
287
|
+
// Le commit rouge est le dernier changement du fichier : il ne bouge que si une session l'a réécrit, ce que la comparaison à la tête d'avant la session attrape.
|
|
288
|
+
if (was && canon(cur) !== canon(was))
|
|
289
|
+
out.push(`${p} : acceptation.json réécrit pendant la session (seule la clé vert, posée par le cycle, peut changer)`);
|
|
290
|
+
if (was && was.vert !== cur.vert)
|
|
291
|
+
out.push(`${p} : clé vert changée pendant la session (elle est posée par le cycle)`);
|
|
292
|
+
const fichiers = Array.isArray(base.fichiers) ? base.fichiers.filter((f) => typeof f === 'string') : [];
|
|
293
|
+
if (!fichiers.length)
|
|
294
|
+
continue;
|
|
295
|
+
let changed = [];
|
|
296
|
+
try {
|
|
297
|
+
changed = git(repo, 'diff', '--name-only', '-z', red, '--', ...fichiers).split('\0').filter(Boolean);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
changed = fichiers;
|
|
301
|
+
}
|
|
302
|
+
const sinceRed = changed; // livrée : ces fichiers ont pu évoluer avec la fiche, leur empreinte scellée ne vaut plus
|
|
303
|
+
let depuis = red;
|
|
304
|
+
if (cur.vert === true && typeof base.fiche === 'string' && base.lot !== opts.lot) {
|
|
305
|
+
// L'acceptation du lot en cours (vert posé dès l'implement) garde le verrou entier : l'exception ne vaut que pour un lot suivant.
|
|
306
|
+
const ref = opts.lotBase ?? headBefore;
|
|
307
|
+
if (ficheChanged(repo, ref, base.fiche, opts.lotCommits))
|
|
308
|
+
continue;
|
|
309
|
+
// La référence ne quitte le commit rouge que si la fiche a évolué entre le rouge et `ref` : sinon tout test changé depuis le rouge reste un écart.
|
|
310
|
+
if (gitOk(repo, 'diff', '--name-only', red, ref, '--', base.fiche) && git(repo, 'diff', '--name-only', red, ref, '--', base.fiche).trim() !== '') {
|
|
311
|
+
depuis = ref;
|
|
312
|
+
try {
|
|
313
|
+
changed = git(repo, 'diff', '--name-only', '-z', ref, '--', ...fichiers).split('\0').filter(Boolean);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
changed = fichiers;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const lot = typeof base.lot === 'string' ? base.lot : '?';
|
|
321
|
+
const emp = isObj(base.empreintes) ? base.empreintes : {};
|
|
322
|
+
// Bilan nul sur le lot (L184) : contenu identique à celui d'avant le premier commit de travail du lot (un fichier absent à cette tête n'est jamais exempté).
|
|
323
|
+
// Une fiche changée depuis cette tête (évolution admise d'un autre lot) ôte l'exemption : revenir à l'ancien contenu annulerait cette évolution, pas l'ajout du lot.
|
|
324
|
+
const ficheMoved = typeof base.fiche === 'string' && opts.lotStart !== undefined && (!gitOk(repo, 'diff', '--quiet', opts.lotStart, '--', base.fiche));
|
|
325
|
+
const unchangedByLot = (norm) => {
|
|
326
|
+
if (!opts.lotStart || ficheMoved)
|
|
327
|
+
return false;
|
|
328
|
+
const was = gitOk(repo, 'cat-file', '-e', `${opts.lotStart}:${norm}`) ? git(repo, 'rev-parse', `${opts.lotStart}:${norm}`).trim() : null;
|
|
329
|
+
const now = existsSync(join(repo, norm)) ? git(repo, 'hash-object', '--', norm).trim() : null;
|
|
330
|
+
return was !== null && was === now; // absent n'a pas de contenu : un fichier supprimé, ou créé par le lot (commit rouge), n'est jamais un bilan nul
|
|
331
|
+
};
|
|
332
|
+
changed = changed.filter((f) => !unchangedByLot(f));
|
|
333
|
+
for (const f of fichiers) {
|
|
334
|
+
const norm = posix.normalize(f); // sealAcceptation range les empreintes sous le chemin normalisé, et git diff le rend ainsi
|
|
335
|
+
const want = emp[norm];
|
|
336
|
+
if (typeof want !== 'string' || changed.includes(norm) || (depuis !== red && sinceRed.includes(norm)) || unchangedByLot(norm))
|
|
337
|
+
continue; // déjà signalé ci-dessus, ou bilan nul sur le lot
|
|
338
|
+
const have = existsSync(join(repo, norm)) ? git(repo, 'hash-object', '--', norm).trim() : null;
|
|
339
|
+
if (have !== want)
|
|
340
|
+
out.push(`${f} : empreinte différente de celle du scellé de ${p} (test d'acceptation de ${lot})`);
|
|
341
|
+
}
|
|
342
|
+
for (const f of changed)
|
|
343
|
+
out.push(`${f} : test d'acceptation de ${lot} : empreinte modifiée depuis ${depuis === red ? 'le commit rouge' : 'la tête d\'avant le lot'} ${depuis.slice(0, 7)} (verrou ${p})`);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
/** Une liste (même d'un seul élément) lance chaque entrée sous sa racine ; une chaîne garde le dépôt pour racine. Le brief et le cycle décident par cette seule fonction. */
|
|
348
|
+
export function isAcceptList(accept) {
|
|
349
|
+
return Array.isArray(accept);
|
|
350
|
+
}
|
|
351
|
+
/** Commandes de `orchestrate.accept` : une chaîne ou une liste (L189), toujours rendue en liste. */
|
|
352
|
+
export function acceptCommands(accept) {
|
|
353
|
+
return accept === undefined ? [] : isAcceptList(accept) ? accept : [accept];
|
|
354
|
+
}
|
|
355
|
+
/** Racine d'une commande : le dossier de son `cd <dossier> &&` initial (relatif au dépôt, normalisé), sinon le dépôt (''). Un `cd` qui sort du dépôt n'est pas une racine. */
|
|
356
|
+
export function acceptRoot(command) {
|
|
357
|
+
const m = /^\s*cd\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*&&/.exec(command);
|
|
358
|
+
const dir = posix.normalize(m?.[1] ?? m?.[2] ?? m?.[3] ?? '.');
|
|
359
|
+
return dir === '.' || dir === '..' || dir.startsWith('../') || posix.isAbsolute(dir) ? '' : dir.replace(/\/$/, '');
|
|
360
|
+
}
|
|
361
|
+
/** Le fichier est sous la racine (le dépôt, '', contient tout). */
|
|
362
|
+
export const underRoot = (file, root) => {
|
|
363
|
+
const f = posix.normalize(file);
|
|
364
|
+
return root === '' || f.startsWith(`${root}/`);
|
|
365
|
+
};
|
|
366
|
+
/** Lancements d'une liste de commandes (`single` : la chaîne seule de `orchestrate.accept`, dont la racine reste le dépôt, `cd` ou non, comme avant la liste) pour les fichiers de l'acceptation : une commande sans fichier sous sa racine n'est pas lancée ; un fichier sous aucune racine est un orphelin. */
|
|
367
|
+
export function planAcceptRuns(commands, fichiers, single = false) {
|
|
368
|
+
const all = commands.map((command) => ({ command, root: single ? '' : acceptRoot(command) }));
|
|
369
|
+
return { runs: all.filter((r) => fichiers.some((f) => underRoot(f, r.root))), orphans: fichiers.filter((f) => !all.some((r) => underRoot(f, r.root))) };
|
|
370
|
+
}
|
|
371
|
+
/** Consigne propre à l'étape accept dans le brief : fiche, maquette, commande dédiée, gabarit du lanceur, et les écarts de la passe précédente. */
|
|
372
|
+
export function acceptText(o) {
|
|
373
|
+
const commands = acceptCommands(o.command);
|
|
374
|
+
const screen = o.screen !== false;
|
|
375
|
+
const lines = [
|
|
376
|
+
!screen
|
|
377
|
+
? `Sheet: \`${o.fiche}\` (cases, rules, contracts, data). You get nothing else about the behaviour: the implementation does not exist yet.`
|
|
378
|
+
: `Sheet: \`${o.fiche}\` (rules, fields, actions). Mockup: \`${o.maquette}\` (every field carries \`data-testid="<field id>"\`). You get nothing else about the screen: the implementation does not exist yet.${o.back ? ' The sheet also has a back part (`cas`, `contrats`, `donnees`): write the cases of its rules too, calling the contracts of `contrats[]` as close as possible and simulating the external sources from `entree`.' : ''}`,
|
|
379
|
+
isAcceptList(o.command)
|
|
380
|
+
? `The project declares several dedicated commands (\`orchestrate.accept\`, one per test runner), each of which runs ONLY the acceptance tests of this lot that sit under its root (the folder of its leading \`cd <folder> &&\`, else the repository): ${commands.map((x) => `\`${x}\` (root \`${acceptRoot(x) || '.'}\`)`).join(', ')}. Each is run from the repository root and gets \`CADENCE_ACCEPTATION\` (the absolute path of the lot's \`acceptation.json\`, also substituted for \`{acceptation}\`) and \`CADENCE_ACCEPTATION_ROOT\` (its root); it takes only the files listed under its root, a command with no listed file under its root is not run, and a listed file under no root is refused. Put each test file where its runner looks, and list every file in \`fichiers\`.${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`
|
|
381
|
+
: `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${commands[0]}\` (from the repository root; the program sets \`CADENCE_ACCEPTATION\` to the absolute path of the lot's \`acceptation.json\`, and replaces \`{acceptation}\` with it).${o.test ? ` The project's ordinary test command is \`${o.test}\`: it must stay green with your red tests committed.` : ''}`,
|
|
382
|
+
`Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same ${screen ? 'screen' : 'subject'} has its own, which you never touch, nor its tests — give your test files names of your own): \`{ "lot": "<lot>", "fiche": "${o.fiche}", "fichiers": ["<test file>", …], "vert": false }\` (paths relative to the repository). The ordinary test config of the project (shared with its CI) must exclude those files while \`vert\` is false; the dedicated config includes only them. Both read the file with this runner template (copy it into the project, e.g. \`tests/acceptation-runner.mjs\`):`,
|
|
383
|
+
];
|
|
384
|
+
if (o.runner)
|
|
385
|
+
lines.push('```js', o.runner.trimEnd(), '```');
|
|
386
|
+
if (o.problems?.length)
|
|
387
|
+
lines.push('', 'The program checked your previous pass and it did not pass; fix exactly these points (one more pass only, then the lot goes back to the lead):', ...o.problems.map((p) => `- ${p}`));
|
|
388
|
+
return lines.join('\n');
|
|
389
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { LARGEURS } from '../spec.js';
|
|
3
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
4
|
+
/** Une ligne par règle de la fiche : id, [état ou cas, statut], énoncé, message exact, tests prévus. Vide quand le document n'est pas une fiche. */
|
|
5
|
+
export function ficheRules(doc) {
|
|
6
|
+
if (!isObj(doc) || !Array.isArray(doc.regles))
|
|
7
|
+
return [];
|
|
8
|
+
return doc.regles.filter(isObj).map((r) => {
|
|
9
|
+
const message = typeof r.message === 'string' ? ` Message: « ${r.message} ».` : '';
|
|
10
|
+
const tests = Array.isArray(r.tests) ? ` Tests: ${r.tests.map((t) => `« ${String(t)} »`).join(', ')}.` : '';
|
|
11
|
+
const where = typeof r.etat === 'string' ? r.etat : Array.isArray(r.cas) ? r.cas.join(', ') : '';
|
|
12
|
+
return `- ${String(r.id)} [${where ? `${where}, ` : ''}${String(r.statut)}] ${String(r.enonce ?? '').trim()}${message}${tests}`;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Une ligne par contrat de la fiche back : id, appel, entrée, sortie, erreurs (code et message exact). Vide sans contrats. */
|
|
16
|
+
export function ficheContracts(doc) {
|
|
17
|
+
if (!isObj(doc) || !Array.isArray(doc.contrats))
|
|
18
|
+
return [];
|
|
19
|
+
return doc.contrats.filter(isObj).map((c) => {
|
|
20
|
+
const erreurs = Array.isArray(c.erreurs) && c.erreurs.length ? ` Errors: ${c.erreurs.filter(isObj).map((e) => `${String(e.code)} « ${String(e.message)} »`).join(', ')}.` : '';
|
|
21
|
+
return `- ${String(c.id)} ${String(c.appel)} — input: ${String(c.entree ?? '').trim()}; output: ${String(c.sortie ?? '').trim()}.${erreurs}`;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Le document a-t-il une partie écran (`ecran`) ? Une fiche back seule n'en a pas : ni maquette, ni captures. */
|
|
25
|
+
export const hasScreenPart = (doc) => isObj(doc) && isObj(doc.ecran);
|
|
26
|
+
/** Consigne de la fiche pour une étape en aval : `{{fiche}}` des gabarits implement, fix, review et ux. */
|
|
27
|
+
export function avalFicheText(kind, o) {
|
|
28
|
+
const folder = posix.dirname(o.fiche);
|
|
29
|
+
const maquette = `${folder}/maquette.html`;
|
|
30
|
+
const screen = o.screen !== false;
|
|
31
|
+
const contracts = o.contracts?.length ? `\nIts contracts (the calls the code must hold, with the exact codes and messages of their errors) are:\n${o.contracts.join('\n')}` : '';
|
|
32
|
+
const head = screen
|
|
33
|
+
? `This lot has a screen sheet: \`${o.fiche}\` (mockup \`${maquette}\`). It is the reference of the screen; its rules are:\n${o.rules.join('\n')}${contracts}`
|
|
34
|
+
: `This lot has a spec sheet: \`${o.fiche}\` (no screen: use cases, rules, contracts and data). It is the reference of the behaviour; its rules are:\n${o.rules.join('\n')}${contracts}`;
|
|
35
|
+
if (kind === 'implement' || kind === 'fix') {
|
|
36
|
+
return [
|
|
37
|
+
head,
|
|
38
|
+
kind === 'implement' ? `Read the sheet${screen ? ' and the mockup' : ''} before the first test.` : 'The sheet stays the reference of the fix: a finding that contradicts it is contested under "choix" with the rule.',
|
|
39
|
+
o.acceptation
|
|
40
|
+
? `The acceptance tests are already written and committed red, from the sheet (files listed in \`${o.acceptation}\`): make them green without modifying them — the program compares them to the red commit, and a modified file hands the lot back. A change one of them needs is a question or a finding, never a silent edit.`
|
|
41
|
+
: '',
|
|
42
|
+
'The tests you write yourself cite the rule they cover in their name (`R2 — …`); do not invent a rule: a case the sheet does not cover is noted in your report (under "nonVerifie" or "choix"), not coded.',
|
|
43
|
+
kind === 'implement' && screen ? 'The News entry ends with a `## Hypothèses` section: one line per rule whose status is `hypothese` (`- R3 — what the screen does, and what changes if it is wrong`, taken from its `si_faux`); leave it out when the sheet has no hypothesis.' : '',
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join('\n');
|
|
47
|
+
}
|
|
48
|
+
if (kind === 'review') {
|
|
49
|
+
return [
|
|
50
|
+
head,
|
|
51
|
+
'Check rule → test → green, for each rule of the sheet: its test exists (named after the rule, `R2 — …`), it is green (run it), and the behaviour it states is in the diff. The reviewer does not rebuild the cases: it holds the code against the sheet.',
|
|
52
|
+
o.acceptation
|
|
53
|
+
? `The acceptance tests are listed in \`${o.acceptation}\`${o.rouge ? ` and were committed red at ${o.rouge.slice(0, 7)}` : ''}: each file must be unchanged since the red commit (\`git diff ${o.rouge ? o.rouge.slice(0, 7) : '<red commit>'} -- <file>\` is empty).`
|
|
54
|
+
: 'No acceptance tests were written for this lot: the tests of the rules are the ones the implementation wrote, named after the rule (`R2 — …`).',
|
|
55
|
+
o.contracts?.length ? 'Check each contract too: the code returns the output of the sheet and the exact codes and messages of its errors, and every datum the sheet announces without modification (`donnees`, `modification: aucune`) is intact. A contract that departs from the sheet is a major finding.' : '',
|
|
56
|
+
'A rule without a test, an acceptance test modified since the red commit, a rule coded that is not in the sheet, and a test that is green without exercising its rule are major findings (name the rule and the file).',
|
|
57
|
+
]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
return [
|
|
62
|
+
`This lot has a screen sheet (\`${o.fiche}\`) and a mockup (\`${maquette}\`) that was already reviewed: this pass is a conformity pass, not the full audit. Compare the running app to the mockup, state by state (the states of the sheet), at ${LARGEURS.join(' and ')} px, against the captures \`${folder}/maquette-<état>-<largeur>.png\`.`,
|
|
63
|
+
'Report the differences of rendering and ergonomics the tests do not cover (layout, spacing, hierarchy, wording on screen, contrast, focus, targets, reflow), each tied to a named rule or a measurement and to the mockup capture it differs from. Do not redo the audit of the whole screen. The rules of the sheet are covered by the tests: do not recheck them. This verdict is the input of `raf ux`, which the lead decides on the captures.',
|
|
64
|
+
].join('\n');
|
|
65
|
+
}
|
|
@@ -41,6 +41,12 @@ export function capturesText(captureDir) {
|
|
|
41
41
|
}
|
|
42
42
|
const FILES = {
|
|
43
43
|
precheck: 'precheck.md',
|
|
44
|
+
spec: 'spec.md',
|
|
45
|
+
'spec-back': 'spec-back.md',
|
|
46
|
+
'accept-back': 'accept-back.md',
|
|
47
|
+
fiche: 'fiche.md',
|
|
48
|
+
accept: 'accept.md',
|
|
49
|
+
maquette: 'maquette.md',
|
|
44
50
|
implement: 'implement.md',
|
|
45
51
|
fix: 'fix.md',
|
|
46
52
|
'fix-minors': 'fix-minors.md',
|
|
@@ -69,7 +75,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
|
|
|
69
75
|
export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
|
|
70
76
|
const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
|
|
71
77
|
const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
|
|
72
|
-
const v = { ...vars, repos: vars.repos ?? '', docs: vars.docs ?? '' }[name];
|
|
78
|
+
const v = { ...vars, repos: vars.repos ?? '', docs: vars.docs ?? '', spec: vars.spec ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', fiche: vars.fiche ?? '' }[name];
|
|
73
79
|
if (v === undefined)
|
|
74
80
|
throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
|
|
75
81
|
return v;
|