@sylad/cadence 0.28.0 → 1.1.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.
@@ -0,0 +1,303 @@
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
+ return { lot, fiche, fichiers: fichiers, vert, ...(empreintes ? { empreintes } : {}) };
53
+ }
54
+ /** JSON à clés triées, sans la clé `vert` : la forme que le verrou compare. */
55
+ const canon = (a) => JSON.stringify(Object.fromEntries(Object.entries(a).filter(([k]) => k !== 'vert').sort(([x], [y]) => x.localeCompare(y))));
56
+ const show = (repo, rev, path) => {
57
+ try {
58
+ const v = JSON.parse(git(repo, 'show', `${rev}:${path}`));
59
+ return isObj(v) ? v : null;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ };
65
+ /** 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. */
66
+ export function redSha(repo, path) {
67
+ let shas;
68
+ try {
69
+ shas = git(repo, 'log', '--format=%H', '--', path).split('\n').filter(Boolean);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ for (const sha of shas) {
75
+ const cur = show(repo, sha, path);
76
+ if (!cur)
77
+ continue; // suppression
78
+ const prev = show(repo, `${sha}^`, path);
79
+ if (!prev || canon(cur) !== canon(prev))
80
+ return sha;
81
+ }
82
+ return null;
83
+ }
84
+ /** Chemin (relatif au dépôt) de l'`acceptation.json` du lot sous `dir`, ou null. */
85
+ export function findAcceptation(repo, dir, lot) {
86
+ for (const path of listAcceptations(repo, dir).sort()) {
87
+ try {
88
+ const a = parseAcceptation(readFileSync(join(repo, path), 'utf8'));
89
+ if (typeof a !== 'string' && a.lot === lot)
90
+ return path;
91
+ }
92
+ catch {
93
+ // illisible : pas celui du lot
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+ /** Scelle le fichier (le programme, jamais une session) : l'empreinte git de chaque fichier de tests listé ; le commit est à faire. */
99
+ export function sealAcceptation(repo, path) {
100
+ const file = join(repo, path);
101
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
102
+ const fichiers = doc.fichiers.map((f) => posix.normalize(f));
103
+ doc.empreintes = Object.fromEntries(fichiers.map((f) => [f, git(repo, 'hash-object', '--', f).trim()]));
104
+ writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
105
+ }
106
+ /** Pose `vert: true` dans le fichier (le cycle, jamais une session) ; le commit est à faire. */
107
+ export function setVert(repo, path) {
108
+ const file = join(repo, path);
109
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
110
+ doc.vert = true;
111
+ writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
112
+ }
113
+ /** É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é. */
114
+ export function checkAcceptation(repo, path, lot, fiche, base) {
115
+ const file = join(repo, path);
116
+ if (!existsSync(file))
117
+ return [`${path} : acceptation.json absent (le lister dans ${path}, avec les fichiers de tests, dans le commit rouge)`];
118
+ const a = parseAcceptation(readFileSync(file, 'utf8'));
119
+ if (typeof a === 'string')
120
+ return [`${path} : ${a}`];
121
+ const problems = [];
122
+ if (a.lot !== lot)
123
+ problems.push(`${path} : lot « ${a.lot} » au lieu de ${lot}`);
124
+ if (a.fiche !== fiche)
125
+ problems.push(`${path} : fiche « ${a.fiche} » au lieu de ${fiche}`);
126
+ if (a.vert)
127
+ problems.push(`${path} : vert: true à l'écriture (les tests sont rouges : vert: false ; seul le cycle pose vert: true)`);
128
+ const sha = redSha(repo, path);
129
+ if (!sha)
130
+ problems.push(`${path} : non commité`);
131
+ // 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.
132
+ const fresh = base && !gitOk(repo, 'cat-file', '-e', `${base}:${path}`) ? base : null;
133
+ let texts = '';
134
+ for (const f of a.fichiers) {
135
+ const norm = posix.normalize(f);
136
+ if (isAbsolute(norm) || norm.startsWith('../') || norm === '..') {
137
+ problems.push(`${f} : fichier hors du dépôt`);
138
+ continue;
139
+ }
140
+ if (!existsSync(join(repo, norm))) {
141
+ problems.push(`${f} : fichier de test listé mais absent`);
142
+ continue;
143
+ }
144
+ if (fresh && gitOk(repo, 'cat-file', '-e', `${base}:${norm}`)) {
145
+ 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)`);
146
+ continue;
147
+ }
148
+ if (sha && !gitOk(repo, 'cat-file', '-e', `${sha}:${norm}`))
149
+ problems.push(`${f} : absent du commit rouge de ${path} (fichiers de tests et acceptation.json vont dans le même commit)`);
150
+ texts += `${readFileSync(join(repo, norm), 'utf8')}\n`;
151
+ }
152
+ try {
153
+ const doc = parse(readFileSync(join(repo, fiche), 'utf8'));
154
+ for (const r of doc.regles ?? [])
155
+ for (const t of Array.isArray(r.tests) ? r.tests : [])
156
+ if (typeof t === 'string' && !texts.includes(t) && !texts.includes(t.replace(/'/g, "\\'")))
157
+ problems.push(`${r.id} : test « ${t} » (nom prévu dans la fiche) absent des fichiers listés`);
158
+ }
159
+ catch {
160
+ problems.push(`${fiche} : fiche illisible`);
161
+ }
162
+ return problems;
163
+ }
164
+ /**
165
+ * 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`).
166
+ * Aucun commit, un commit racine ou illisible : `undefined`.
167
+ */
168
+ export function lotStartHead(repo, work) {
169
+ let oldest;
170
+ for (const sha of work) {
171
+ if (!gitOk(repo, 'cat-file', '-e', `${sha}^{commit}`))
172
+ continue;
173
+ const depth = Number(git(repo, 'rev-list', '--count', sha).trim());
174
+ if (!oldest || depth < oldest.depth)
175
+ oldest = { sha, depth };
176
+ }
177
+ if (!oldest || !gitOk(repo, 'rev-parse', '--verify', '-q', `${oldest.sha}^`))
178
+ return undefined;
179
+ return git(repo, 'rev-parse', `${oldest.sha}^`).trim();
180
+ }
181
+ /** 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). */
182
+ function ficheChanged(repo, since, fiche, cites = []) {
183
+ try {
184
+ if (git(repo, 'diff', '--name-only', since, '--', fiche).trim() !== '')
185
+ return true;
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ 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() !== '');
191
+ }
192
+ /**
193
+ * 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
194
+ * 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
195
+ * cycle, donc jamais modifiée pendant une session (comparée à `headBefore`). Vide = rien à signaler.
196
+ * 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
197
+ * 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
198
+ * 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.
199
+ * 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
200
+ * 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,
201
+ * 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.
202
+ * 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` :
203
+ * 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.
204
+ * 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
205
+ * 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.
206
+ */
207
+ export function lockViolations(repo, dir, headBefore, opts = {}) {
208
+ const out = [];
209
+ const before = new Set(listAcceptations(repo, dir, headBefore));
210
+ const now = new Set(listAcceptations(repo, dir));
211
+ for (const p of before)
212
+ if (!now.has(p))
213
+ out.push(`${p} : acceptation.json supprimé (verrou des tests d'acceptation)`);
214
+ for (const p of [...now].sort()) {
215
+ if (p === opts.skip)
216
+ continue;
217
+ const isNew = !before.has(p);
218
+ if (isNew)
219
+ out.push(`${p} : acceptation.json créé hors de l'étape accept`);
220
+ const red = redSha(repo, p);
221
+ const cur = show(repo, 'HEAD', p);
222
+ const base = red ? show(repo, red, p) : null;
223
+ if (!red || !cur || !base) {
224
+ out.push(`${p} : acceptation.json sans commit rouge lisible`);
225
+ continue;
226
+ }
227
+ const anchor = opts.rouge?.[p];
228
+ if (anchor && red !== anchor)
229
+ 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)`);
230
+ const was = isNew ? undefined : show(repo, headBefore, p);
231
+ // 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.
232
+ if (was && canon(cur) !== canon(was))
233
+ out.push(`${p} : acceptation.json réécrit pendant la session (seule la clé vert, posée par le cycle, peut changer)`);
234
+ if (was && was.vert !== cur.vert)
235
+ out.push(`${p} : clé vert changée pendant la session (elle est posée par le cycle)`);
236
+ const fichiers = Array.isArray(base.fichiers) ? base.fichiers.filter((f) => typeof f === 'string') : [];
237
+ if (!fichiers.length)
238
+ continue;
239
+ let changed = [];
240
+ try {
241
+ changed = git(repo, 'diff', '--name-only', '-z', red, '--', ...fichiers).split('\0').filter(Boolean);
242
+ }
243
+ catch {
244
+ changed = fichiers;
245
+ }
246
+ const sinceRed = changed; // livrée : ces fichiers ont pu évoluer avec la fiche, leur empreinte scellée ne vaut plus
247
+ let depuis = red;
248
+ if (cur.vert === true && typeof base.fiche === 'string' && base.lot !== opts.lot) {
249
+ // 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.
250
+ const ref = opts.lotBase ?? headBefore;
251
+ if (ficheChanged(repo, ref, base.fiche, opts.lotCommits))
252
+ continue;
253
+ // 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.
254
+ if (gitOk(repo, 'diff', '--name-only', red, ref, '--', base.fiche) && git(repo, 'diff', '--name-only', red, ref, '--', base.fiche).trim() !== '') {
255
+ depuis = ref;
256
+ try {
257
+ changed = git(repo, 'diff', '--name-only', '-z', ref, '--', ...fichiers).split('\0').filter(Boolean);
258
+ }
259
+ catch {
260
+ changed = fichiers;
261
+ }
262
+ }
263
+ }
264
+ const lot = typeof base.lot === 'string' ? base.lot : '?';
265
+ const emp = isObj(base.empreintes) ? base.empreintes : {};
266
+ // 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é).
267
+ // 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.
268
+ const ficheMoved = typeof base.fiche === 'string' && opts.lotStart !== undefined && (!gitOk(repo, 'diff', '--quiet', opts.lotStart, '--', base.fiche));
269
+ const unchangedByLot = (norm) => {
270
+ if (!opts.lotStart || ficheMoved)
271
+ return false;
272
+ const was = gitOk(repo, 'cat-file', '-e', `${opts.lotStart}:${norm}`) ? git(repo, 'rev-parse', `${opts.lotStart}:${norm}`).trim() : null;
273
+ const now = existsSync(join(repo, norm)) ? git(repo, 'hash-object', '--', norm).trim() : null;
274
+ 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
275
+ };
276
+ changed = changed.filter((f) => !unchangedByLot(f));
277
+ for (const f of fichiers) {
278
+ const norm = posix.normalize(f); // sealAcceptation range les empreintes sous le chemin normalisé, et git diff le rend ainsi
279
+ const want = emp[norm];
280
+ if (typeof want !== 'string' || changed.includes(norm) || (depuis !== red && sinceRed.includes(norm)) || unchangedByLot(norm))
281
+ continue; // déjà signalé ci-dessus, ou bilan nul sur le lot
282
+ const have = existsSync(join(repo, norm)) ? git(repo, 'hash-object', '--', norm).trim() : null;
283
+ if (have !== want)
284
+ out.push(`${f} : empreinte différente de celle du scellé de ${p} (test d'acceptation de ${lot})`);
285
+ }
286
+ for (const f of changed)
287
+ 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})`);
288
+ }
289
+ return out;
290
+ }
291
+ /** 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. */
292
+ export function acceptText(o) {
293
+ const lines = [
294
+ `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.`,
295
+ `The project declares the dedicated command that runs ONLY the acceptance tests of this lot (\`orchestrate.accept\`): \`${o.command}\` (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.` : ''}`,
296
+ `Write \`${o.acceptation}\` (one file per lot, next to the sheet: another lot on the same screen 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\`):`,
297
+ ];
298
+ if (o.runner)
299
+ lines.push('```js', o.runner.trimEnd(), '```');
300
+ if (o.problems?.length)
301
+ 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}`));
302
+ return lines.join('\n');
303
+ }
@@ -0,0 +1,46 @@
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, 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
+ return `- ${String(r.id)} [${String(r.etat)}, ${String(r.statut)}] ${String(r.enonce ?? '').trim()}${message}${tests}`;
12
+ });
13
+ }
14
+ /** Consigne de la fiche pour une étape en aval : `{{fiche}}` des gabarits implement, fix, review et ux. */
15
+ export function avalFicheText(kind, o) {
16
+ const folder = posix.dirname(o.fiche);
17
+ const maquette = `${folder}/maquette.html`;
18
+ const head = `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')}`;
19
+ if (kind === 'implement' || kind === 'fix') {
20
+ return [
21
+ head,
22
+ kind === 'implement' ? 'Read the sheet 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.',
23
+ o.acceptation
24
+ ? `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.`
25
+ : '',
26
+ '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.',
27
+ kind === 'implement' ? '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.' : '',
28
+ ]
29
+ .filter(Boolean)
30
+ .join('\n');
31
+ }
32
+ if (kind === 'review') {
33
+ return [
34
+ head,
35
+ '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.',
36
+ o.acceptation
37
+ ? `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).`
38
+ : 'No acceptance tests were written for this lot: the tests of the rules are the ones the implementation wrote, named after the rule (`R2 — …`).',
39
+ '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).',
40
+ ].join('\n');
41
+ }
42
+ return [
43
+ `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\`.`,
44
+ '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.',
45
+ ].join('\n');
46
+ }
@@ -41,6 +41,9 @@ export function capturesText(captureDir) {
41
41
  }
42
42
  const FILES = {
43
43
  precheck: 'precheck.md',
44
+ spec: 'spec.md',
45
+ accept: 'accept.md',
46
+ maquette: 'maquette.md',
44
47
  implement: 'implement.md',
45
48
  fix: 'fix.md',
46
49
  'fix-minors': 'fix-minors.md',
@@ -69,7 +72,7 @@ export function loadTemplates(dir = TEMPLATES_DIR) {
69
72
  export function renderBrief(kind, vars, source = TEMPLATES_DIR) {
70
73
  const text = typeof source === 'string' ? loadTemplates(source)[kind] : source[kind];
71
74
  const out = text.replace(/\{\{(\w+)\}\}/g, (_m, name) => {
72
- const v = { ...vars, repos: vars.repos ?? '', docs: vars.docs ?? '' }[name];
75
+ const v = { ...vars, repos: vars.repos ?? '', docs: vars.docs ?? '', spec: vars.spec ?? '', accept: vars.accept ?? '', maquette: vars.maquette ?? '', fiche: vars.fiche ?? '' }[name];
73
76
  if (v === undefined)
74
77
  throw new RafError(`gabarit ${FILES[kind]} : valeur manquante pour {{${name}}}`);
75
78
  return v;
@@ -12,6 +12,9 @@ import { AGENTS_DIR } from '../skills.js';
12
12
  import { pidAlive, sharedStateDir } from '../state.js';
13
13
  import { acquireSlot, cadenceHome, freeSlots, liveSlots, liveWaves, registerWave, unregisterWave, updateWaveRepos } from './registry.js';
14
14
  import { loadTemplates, newsText, objective, renderBrief } from './briefs.js';
15
+ import { acceptationFile, acceptText } from './accept.js';
16
+ import { maquetteText } from './maquette.js';
17
+ import { specText } from './spec.js';
15
18
  import { candidates, parsePriority, parseUntil, readPriority, stopReason } from './continue.js';
16
19
  import { Budget, DROPPED, MAX_PASSES, countInterrupted, handBackDroppedQuestions, needsPrecheck } from './cycle.js';
17
20
  import { canInstallPrePush, installPrePush, removePrePush, snapshot } from './guard.js';
@@ -240,7 +243,7 @@ async function preflight(args, targets, io, deps, launch, opts = {}) {
240
243
  continue;
241
244
  }
242
245
  const small = lot.estimate <= 0.5 || lot.quickwin;
243
- const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, model: t.model, readOnlyPlan: plan.readonly });
246
+ const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, visible: lot.visible, small, ...(lot.screen ? { screen: lot.screen } : {}), model: t.model, readOnlyPlan: plan.readonly });
244
247
  if (neighbours.repos.length)
245
248
  state.repos = neighbours.repos;
246
249
  state.light = lot.estimate <= env.config.review.threshold;
@@ -291,6 +294,12 @@ function dryRun(lots, io, deps, budget, id) {
291
294
  io.out(` node : ${l.node.version} (.nvmrc ${l.node.wanted}${skipped})`);
292
295
  }
293
296
  const steps = [{ kind: 'implement', model: l.model }];
297
+ if (l.screen && env.config.accept)
298
+ steps.unshift({ kind: 'accept', model: 'sonnet' });
299
+ if (l.screen && env.config.maquette)
300
+ steps.unshift({ kind: 'maquette', model: env.config.review.full });
301
+ if (l.screen)
302
+ steps.unshift({ kind: 'spec', model: 'sonnet' });
294
303
  if (needsPrecheck(env.config, env.loadPlan(), l.repo, l.lot, l.repos))
295
304
  steps.unshift({ kind: 'precheck', model: env.config.precheckLocal ? 'local' : 'sonnet' });
296
305
  const { light, full } = env.config.review;
@@ -307,13 +316,21 @@ function dryRun(lots, io, deps, budget, id) {
307
316
  const pwDir = join(resolve(RunStore.runsDir(io.cwd)), id, lotSlug(l.project, l.lot), 'playwright');
308
317
  // brief écrit par --dry-run = base d'une délégation à la main : aucun dossier de vague (il n'existe pas)
309
318
  const vars = { chemin: l.repo, lot: l.lot, titre: lot.title, objectif: objective(lot), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '' };
319
+ if (l.screen)
320
+ vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen });
321
+ if (l.screen && env.config.maquette)
322
+ vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0 });
323
+ if (l.screen && env.config.accept) {
324
+ const folder = `${env.config.spec.dir}/<écran>`;
325
+ vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept, test: env.config.test });
326
+ }
310
327
  for (const s of steps) {
311
328
  const file = join(tmp, `${lotSlug(l.project, l.lot)}--${s.kind}.md`);
312
329
  const brief = renderBrief(s.kind, vars, deps.templatesDir);
313
330
  writeFileSync(file, brief);
314
331
  const playwright = !!mcpServersFor(s.kind, l.visible, '', l.small).playwright;
315
332
  const local = s.model === 'local';
316
- const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'ux' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && s.kind === 'implement' ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
333
+ const args = (local ? buildLocalArgs : buildArgs)({ kind: s.kind, sessionId: '<uuid>', brief: '<brief>', model: s.model === 'local' ? 'sonnet' : s.model, local, effort: env.config.effort[s.kind === 'review-small' ? 'review' : s.kind], schema: schemaFor(s.kind), agent: s.kind === 'implement' ? undefined : s.kind === 'spec' ? 'spec-writer' : s.kind === 'accept' ? 'acceptance-writer' : s.kind === 'ux' || s.kind === 'maquette' ? 'ux-reviewer' : s.kind === 'precheck' ? 'precheck-reader' : 'code-reviewer', cwd: l.repo, wave: id, permissionMode: env.config.permissionMode, addDirs: [...env.config.addDirs, ...(playwright && (s.kind === 'implement' || s.kind === 'spec') ? [pwDir] : []), ...neighbourDirs(l)], timeoutMs: 0, mcpConfig: '<mcp>', playwright }, agents).map((a) => (a.startsWith('{') ? '<json>' : a));
317
334
  io.out(` ${s.kind} : ${local ? 'claude-local' : 'claude'} ${args.join(' ')}`);
318
335
  io.out(` brief : ${file}`);
319
336
  const servers = Object.keys(mcpServersFor(s.kind, l.visible, '', l.small));