@sylad/cadence 1.5.1 → 1.7.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 +45 -1732
- package/agents/acceptance-writer.md +2 -0
- package/agents/code-reviewer.md +1 -1
- package/agents/ux-reviewer.md +2 -0
- package/dist/audit.js +4 -3
- package/dist/cli.js +4 -4
- package/dist/config.js +7 -2
- package/dist/orchestrate/accept.js +29 -15
- package/dist/orchestrate/briefs.js +1 -1
- package/dist/orchestrate/command.js +4 -1
- package/dist/orchestrate/cycle.js +91 -19
- package/dist/orchestrate/fiche.js +3 -3
- package/dist/orchestrate/maquette.js +12 -11
- package/dist/orchestrate/schemas.js +1 -1
- package/docs/cadence-hud.md +50 -0
- package/docs/corrections.md +24 -0
- package/docs/cycle-vague-dark.svg +42 -0
- package/docs/cycle-vague-light.svg +42 -0
- package/docs/deliver.md +204 -0
- package/docs/fonctionnement-lead-dark.svg +101 -0
- package/docs/fonctionnement-lead-light.svg +101 -0
- package/docs/fonctionnement-lot-dark.svg +38 -0
- package/docs/fonctionnement-lot-light.svg +38 -0
- package/docs/fonctionnement-service-dark.svg +23 -0
- package/docs/fonctionnement-service-light.svg +23 -0
- package/docs/fonctionnement-wave-dark.svg +66 -0
- package/docs/fonctionnement-wave-light.svg +66 -0
- package/docs/fonctionnement.md +106 -0
- package/docs/lead-workflow-dark.svg +200 -0
- package/docs/lead-workflow-light.svg +200 -0
- package/docs/lead.md +147 -0
- package/docs/news.md +245 -0
- package/docs/orchestrate.md +639 -0
- package/docs/overview.md +27 -0
- package/docs/raf.md +192 -0
- package/docs/releasing.md +59 -0
- package/docs/session.md +107 -0
- package/docs/skills.md +76 -0
- package/docs/spec.md +60 -0
- package/docs/stabilite.md +35 -0
- package/docs/whats-new.md +81 -0
- package/package.json +28 -1
- package/templates/acceptation-runner.mjs +1 -1
- package/templates/fiche-back.yaml +1 -1
- package/templates/fiche.yaml +1 -1
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fix.md +1 -0
- package/templates/orchestrate/implement.md +1 -0
- package/templates/orchestrate/maquette.md +2 -0
- package/templates/orchestrate/review-small.md +1 -0
- package/templates/orchestrate/review.md +1 -0
|
@@ -12,11 +12,13 @@ Follow the brief you are given. The method:
|
|
|
12
12
|
2. **One test per planned name.** Each rule has `tests[]` names (`R3 — …`): write exactly those cases, with exactly those names, selectors = `data-testid` = field ids, API calls simulated from `actions[]`.
|
|
13
13
|
3. **Wire the runner.** Copy the runner template of the brief, make the project's ordinary test config (shared by the project's test command and its CI) exclude the files of the the lot's acceptation file (`acceptation-<lot>.json`) whose `vert` is false, and always those of an acceptation with `"e2e": true` (see below), and create the dedicated config that includes only the files of the lot. If the project's CI or test command do not use that config (hard-coded paths), do not commit red tests: stop and put the question in the report.
|
|
14
14
|
If the tests drive a browser and need the build (Playwright…), set `"e2e": true` in the acceptation file (`{ "lot", "fiche", "fichiers", "vert": false, "e2e": true }`): `acceptationIgnored()` then excludes them even once green, and `acceptationE2e()` lists them for the project's e2e suite, run after the build (`npm run build && npx playwright test`, in the CI after the build step). Wire that suite; the ordinary runner and the unit CI must never run them.
|
|
15
|
+
3b. **Cite, assert nothing beyond.** Above each assertion, a comment cites the rule and the sentence of its statement that it verifies (`// R4 — « <sentence of the rule> »`); you assert nothing beyond that sentence (a rule that forbids X does not license asserting that Y never happens). Before the commit, go through every assertion: one with no sentence of the sheet behind it is removed or listed under "choix" of your report.
|
|
15
16
|
4. **Red for the right reason.** Run the dedicated command (by hand, outside the cycle, with `CADENCE_ACCEPTATION=<path of the acceptation file>`; for a command of a list, also `CADENCE_ACCEPTATION_ROOT=<root of the command>`, the folder of its leading `cd <folder> &&`, else the repository `.`): every test must fail because the feature is missing. A test that passes before the code proves nothing; a test that fails on a typo proves nothing either. Run the project's ordinary tests: they must stay green.
|
|
16
17
|
5. **One commit**, test files + the lot's acceptation file (`acceptation-<lot>.json`) (`lot`, `fiche`, `fichiers`, `vert: false`) + runner config, explicit paths, a message that cites the lot, a clean tree.
|
|
17
18
|
|
|
18
19
|
For a back sheet (`sujet`, `cas`, `contrats[]`, no mockup), the cases call the contracts of `contrats[]` as close as possible — the controller or the service for an HTTP call, the method of the job for a `job` — and simulate the external sources from `entree`; there are no `data-testid`.
|
|
19
20
|
|
|
20
21
|
- You edit only tests, the runner config and the lot's acceptation file (`acceptation-<lot>.json`): never application code, the sheet, the mockup or the plan. Do not push, deliver, run `raf start|done|note|review|ux`, or launch subagents.
|
|
22
|
+
- An acceptation file that already has `vert: true` and one fingerprint in `empreintes` for each listed test file is a green, sealed oracle: never rewrite it, its tests, `resceaux` or `empreintes`, and never touch `vert`. Write nothing, commit nothing, and hand the lot back with that finding in `questions` (a test to correct is resealed by the lead, `cadence spec reseal`). A `vert: true` file WITHOUT `empreintes` (or whose `empreintes` lack one of the listed test files) was never sealed by the program (only the cycle sets `vert`, after the seal): it proves nothing, write the oracle as the brief says, with `vert: false`.
|
|
21
23
|
- Never write `vert: true` or the fingerprints: the program seals the file and the cycle turns it green.
|
|
22
24
|
- Give the report in the structured output the brief asks for.
|
package/agents/code-reviewer.md
CHANGED
|
@@ -58,7 +58,7 @@ fact. If the path or the id is missing, or the lot has no commit to review, say
|
|
|
58
58
|
to a log or to the repository;
|
|
59
59
|
- a written convention of the project not followed: quote the line of CLAUDE.md.
|
|
60
60
|
- documentation that does not follow the change: the lot changes a behaviour, a command, an option or a default and the README (or the project's usage documentation) still describes the old one or says nothing: name the file and the stale or missing passage.
|
|
61
|
-
- **a lot with a screen sheet** (the brief names `fiche.yaml`): check rule → test → green for each `Rn` of the sheet. The test exists (named after the rule, `R2 — …`), it is green (run it, or take the program's result), and the behaviour the rule states is in the diff. When an acceptance file (`acceptation-<lot>.json`) is named, each listed test file is unchanged since the red commit (`git diff <red commit> -- <file>` is empty). 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. You hold the code against the sheet: you do not rebuild the cases, and a rule's wording is not yours to rewrite.
|
|
61
|
+
- **a lot with a screen sheet** (the brief names `fiche.yaml`): check rule → test → green for each `Rn` of the sheet. The test exists (named after the rule, `R2 — …`), it is green (run it, or take the program's result), and the behaviour the rule states is in the diff. When an acceptance file (`acceptation-<lot>.json`) is named, each listed test file is unchanged since the red commit (`git diff <red commit> -- <file>` is empty). 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. Also *major* ("sealed test stronger than the sheet"): a sealed assertion that cites no sentence of its rule, or that asserts more than the sentence it cites — re-read each assertion against that sentence; only the lead corrects it (`cadence spec reseal`), never the session. You hold the code against the sheet: you do not rebuild the cases, and a rule's wording is not yours to rewrite.
|
|
62
62
|
- **Sheet mode** — when the brief says "sheet mode" (the orchestrator, before any code exists, for a lot with a back sheet `docs/specs/<sujet>/fiche.yaml`), the subject is not a diff but the sheet itself: `sujet`, `cas`, `regles`, `contrats`, `donnees`. There are no commits of the lot to read yet: skip steps 3–5 and read the sheet, then the code and the tests of the modules of `sujet.perimetre` and every source the sheet cites. Check, each finding with its measurement (the file:line you opened, the command you ran):
|
|
63
63
|
1. **Sourced or hypothesis.** Each `sourcee` rule cites a source that exists: open the file:line or the note and read what it says (a source that does not say so is a *major* finding). Each `hypothese` has a `si_faux` and hides none of the four expensive-to-undo categories (data model or storage; public or shared API contract or published route; destructive or irreversible action; contradiction with a sourced rule): such a hypothesis is a *major* finding, because it is a question for the lead.
|
|
64
64
|
2. **No contradiction.** With a rule already coded (search the modules of the perimeter, open the existing tests that assert the opposite) and with an existing API contract (field, error code, route).
|
package/agents/ux-reviewer.md
CHANGED
|
@@ -24,6 +24,8 @@ On top of the usability and accessibility checks, check the **consistency mockup
|
|
|
24
24
|
- every state of `etats` has its section `#etat-<état>`; the `masque` of an empty-state rule really hides the fields it names;
|
|
25
25
|
- the units, formats and wording the sheet declares are those shown.
|
|
26
26
|
|
|
27
|
+
**Sweep everything in the first review**, and render it as a checklist in the text of your report (one line per item, ticked or not): every state of the sheet at 1440 px and at 390 px; every rule of the sheet; contrast (WCAG 2.2 AA, measured); the real envelope of the app (usable width, side bar, header — measured in the neighbouring pages, not assumed); gutters; empty or stretched panels; holes in the layout. What you cannot verify (a state you cannot capture, an ambiguous rule) goes in `nonVerifie`, not ticked. Report ALL the blocking and major findings in the first report: the mockup is reviewed up to `orchestrate.maquetteReviews` times (3 by default), but a later review that finds a major on a point nobody changed counts as a review defect in the wave journal. From the second review on, check the points of the previous one first, run the sweep again, and set `inchange: true` on each blocking or major finding about a point the spec writer did not change and the previous review had not raised (leave the field out otherwise).
|
|
28
|
+
|
|
27
29
|
Check the **sample data** too: recompute the weekday of every date shown (a command such as `date -d`), do not trust the one written. A wrong sample datum (weekday, date, figure that does not add up) is **minor**, never major: it changes no field, rule or state of the sheet, so it does not send the mockup back to the spec writer.
|
|
28
30
|
|
|
29
31
|
A mismatch is a finding like any other (rule: the sheet is the reference of the later steps; a mockup that contradicts it misleads the acceptance tests). Rank blocking / major / minor as below: a blocking or major finding sends the mockup back to the spec writer, a minor one becomes a note of the lot. Leave the proposed sub-tasks empty — a mockup has no observable bug. This verdict is **not** `raf ux`: the conformity pass after the code keeps that role.
|
package/dist/audit.js
CHANGED
|
@@ -57,6 +57,7 @@ export function isPlanOnly(sha, plan, root) {
|
|
|
57
57
|
const VERSION_JSON = /(^|\/)(package\.json|package-lock\.json|\.claude-plugin\/(plugin|marketplace)\.json)$/;
|
|
58
58
|
const CHANGELOG = /(^|\/)CHANGELOG\.md$/;
|
|
59
59
|
const README = /^README\.md$/;
|
|
60
|
+
const WHATS_NEW = /^docs\/whats-new\.md$/;
|
|
60
61
|
/** Le README sans sa section « What's new », rangée par version et donc réécrite à la release. */
|
|
61
62
|
function withoutWhatsNew(text) {
|
|
62
63
|
return (text ?? '').replace(/^## What's new\s*$[\s\S]*?(?=^## |(?![\s\S]))/m, '');
|
|
@@ -81,17 +82,17 @@ function withoutVersion(text) {
|
|
|
81
82
|
}
|
|
82
83
|
/**
|
|
83
84
|
* Commit de version (L72) : TOUS ses fichiers sont des fichiers de version — package.json, package-lock.json,
|
|
84
|
-
* .claude-plugin/plugin.json et marketplace.json, où seule la `version` change, et CHANGELOG.md, plus la section « What's new » du README (seule). Il clôt le lot
|
|
85
|
+
* .claude-plugin/plugin.json et marketplace.json, où seule la `version` change, et CHANGELOG.md, plus la section « What's new » du README (seule) et docs/whats-new.md, sa forme longue (L232). Il clôt le lot
|
|
85
86
|
* après sa revue : l'exiger relu rouvrirait la porte à chaque release. Les fichiers décident, jamais le sujet.
|
|
86
87
|
*/
|
|
87
88
|
export function isReleaseOnly(sha, root) {
|
|
88
89
|
const files = changedFiles(root, sha);
|
|
89
90
|
// Les noms d'abord : un fichier hors version refuse le commit sans qu'aucun contenu ne soit lu.
|
|
90
|
-
if (!files.length || !files.every((f) => CHANGELOG.test(f) || README.test(f) || VERSION_JSON.test(f)))
|
|
91
|
+
if (!files.length || !files.every((f) => CHANGELOG.test(f) || WHATS_NEW.test(f) || README.test(f) || VERSION_JSON.test(f)))
|
|
91
92
|
return false;
|
|
92
93
|
return (files.length > 0 &&
|
|
93
94
|
files.every((f) => {
|
|
94
|
-
if (CHANGELOG.test(f))
|
|
95
|
+
if (CHANGELOG.test(f) || WHATS_NEW.test(f))
|
|
95
96
|
return true;
|
|
96
97
|
if (README.test(f))
|
|
97
98
|
return withoutWhatsNew(fileAt(root, `${sha}^`, f)) === withoutWhatsNew(fileAt(root, sha, f));
|
package/dist/cli.js
CHANGED
|
@@ -66,7 +66,7 @@ publié par exemple, ainsi que le fichier d'attendus QA : docs/qa/expectations.m
|
|
|
66
66
|
Un lot --visible attend une entrée Nouveautés (docs/nouveautes/, --dir) avec capture ; raf check le vérifie.
|
|
67
67
|
Un texte qui commence par « - » se passe après « -- » : raf note L1 -- "-5 %".
|
|
68
68
|
Le plan est docs/plan/raf.yaml, ou celui que nomme « plan: » dans cadence.yaml ; un plan tenu par un
|
|
69
|
-
autre outil se lit sans migration (correspondance des champs, voir
|
|
69
|
+
autre outil se lit sans migration (correspondance des champs, voir docs/raf.md du paquet @sylad/cadence) et reste en lecture seule.
|
|
70
70
|
Options communes : --file chemin (ou RAF_FILE), RAF_TODAY=AAAA-MM-JJ pour figer la date.`;
|
|
71
71
|
/** Ajoute `line` au .gitignore de `root` s'il n'y est pas déjà (avec ou sans `/` initial ou final). Rend vrai si écrit. */
|
|
72
72
|
export function ensureGitignore(root, line) {
|
|
@@ -512,7 +512,7 @@ function dispatch(argv, io) {
|
|
|
512
512
|
if (orchestrating)
|
|
513
513
|
throw new RafError(`deliver : orchestration en cours (vague ${orchestrating.wave}, pid ${orchestrating.pid}) — livrer une fois les lots de ce dépôt finis (le verrou tombe dès que tous sont prêts ou rendus)`);
|
|
514
514
|
if (!existsSync(configPath))
|
|
515
|
-
throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans
|
|
515
|
+
throw new RafError(`pas de configuration de livraison : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
|
|
516
516
|
const config = parseDeliverConfig(readFileSync(configPath, 'utf8'), configPath);
|
|
517
517
|
const plan = existsSync(planPath) ? loadPlan() : null;
|
|
518
518
|
const articles = readDocsConfig(configPath).articles;
|
|
@@ -525,7 +525,7 @@ function dispatch(argv, io) {
|
|
|
525
525
|
if (!gitRoot(io.cwd))
|
|
526
526
|
throw new RafError('verify : à lancer dans un dépôt git');
|
|
527
527
|
if (!existsSync(configPath))
|
|
528
|
-
throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans
|
|
528
|
+
throw new RafError(`pas de configuration : ${configPath} (voir « cadence.yaml » dans docs/deliver.md du paquet @sylad/cadence)`);
|
|
529
529
|
const retry = values.retry === undefined ? 0 : Number(values.retry);
|
|
530
530
|
if (!Number.isFinite(retry) || retry < 0)
|
|
531
531
|
throw new RafError(`--retry invalide : ${values.retry} (secondes, 0 ou plus)`);
|
|
@@ -866,7 +866,7 @@ function specReseal(args, reason, porteur, ctx, io) {
|
|
|
866
866
|
const before = { json: readFileSync(join(repo, json), 'utf8'), test: existsSync(join(repo, test)) ? readFileSync(join(repo, test), 'utf8') : '' };
|
|
867
867
|
let r;
|
|
868
868
|
try {
|
|
869
|
-
r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined, plan.refs);
|
|
869
|
+
r = resealAcceptation(repo, json, test, reason, ctx.today, livre ? porteur : undefined, plan.refs, plan.lots().filter((l) => l.status === 'done').map((l) => l.id));
|
|
870
870
|
}
|
|
871
871
|
catch (e) {
|
|
872
872
|
throw new RafError(e.message);
|
package/dist/config.js
CHANGED
|
@@ -223,10 +223,10 @@ const EFFORTS = ['default', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
|
223
223
|
export const EFFORT_STEPS = ['precheck', 'spec', 'maquette', 'fiche', 'accept', 'implement', 'fix', 'review', 'ux'];
|
|
224
224
|
const MODELS = ['haiku', 'sonnet', 'opus'];
|
|
225
225
|
const REVIEW_KEYS = ['threshold', 'light', 'full'];
|
|
226
|
-
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
226
|
+
const ORCH_KEYS = ['start', 'verdict', 'test', 'build', 'accept', 'maquette', 'maquetteReviews', 'fiche', 'ux', 'precheck', 'prodAccess', 'spec', 'review', 'effort', 'permissionMode', 'addDirs', 'timeouts'];
|
|
227
227
|
/** Clé `orchestrate:` de cadence.yaml. Absente : les défauts (auto, 45 min d'implémentation, 25 min de revue). */
|
|
228
228
|
export function readOrchestrateConfig(file) {
|
|
229
|
-
const config = { precheck: true, maquette: true, 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 } };
|
|
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 } };
|
|
230
230
|
if (!existsSync(file))
|
|
231
231
|
return config;
|
|
232
232
|
let raw;
|
|
@@ -272,6 +272,11 @@ export function readOrchestrateConfig(file) {
|
|
|
272
272
|
throw bad('maquette : true ou false attendu');
|
|
273
273
|
config.maquette = o.maquette;
|
|
274
274
|
}
|
|
275
|
+
if (o.maquetteReviews != null) {
|
|
276
|
+
if (typeof o.maquetteReviews !== 'number' || !Number.isInteger(o.maquetteReviews) || o.maquetteReviews < 1)
|
|
277
|
+
throw bad('maquetteReviews : entier supérieur ou égal à 1 attendu');
|
|
278
|
+
config.maquetteReviews = o.maquetteReviews;
|
|
279
|
+
}
|
|
275
280
|
if (o.fiche != null) {
|
|
276
281
|
if (typeof o.fiche !== 'boolean')
|
|
277
282
|
throw bad('fiche : true ou false attendu');
|
|
@@ -126,9 +126,9 @@ export function sealAcceptation(repo, path) {
|
|
|
126
126
|
* ne laisse aucun chemin de correction. Seule l'empreinte du test nommé est refaite (les autres fichiers listés restent inchangés), la raison et les deux
|
|
127
127
|
* empreintes vont dans `resceaux`. Le commit (test + acceptation) est à faire : il devient le commit rouge.
|
|
128
128
|
* La fiche est celle du commit rouge, ou (L217) une fiche amendée par des commits qui citent tous le lot (`refsOf` : `plan.refs`, lu comme `citedRefs` ;
|
|
129
|
-
* lot livré : le lot
|
|
129
|
+
* lot livré : le lot, son `porteur` ou, L229, l'un des `livres`, les lots done du plan — la fiche vivante fait foi) et commitée : l'entrée porte alors `ficheAmendee`, le sha court du dernier commit qui l'a touchée.
|
|
130
130
|
*/
|
|
131
|
-
export function resealAcceptation(repo, path, test, reason, today, porteur, refsOf = () => []) {
|
|
131
|
+
export function resealAcceptation(repo, path, test, reason, today, porteur, refsOf = () => [], livres = []) {
|
|
132
132
|
const file = join(repo, path);
|
|
133
133
|
const a = parseAcceptation(readFileSync(file, 'utf8'));
|
|
134
134
|
if (typeof a === 'string')
|
|
@@ -156,9 +156,13 @@ export function resealAcceptation(repo, path, test, reason, today, porteur, refs
|
|
|
156
156
|
if (!gitOk(repo, 'diff', '--quiet', red, '--', a.fiche)) {
|
|
157
157
|
if (!existsSync(join(repo, a.fiche)))
|
|
158
158
|
throw new Error(`la fiche ${a.fiche} a été supprimée ou renommée depuis le commit rouge ${red.slice(0, 7)} : le rescellé ne vaut que pour une fiche présente`);
|
|
159
|
-
const citant = new Set([a.lot, ...(porteur ? [porteur] : [])]);
|
|
159
|
+
const citant = new Set([a.lot, ...(porteur ? [porteur, ...livres] : [])]);
|
|
160
160
|
const commits = git(repo, 'log', '--format=%H%x1f%s%x1f%b%x1e', `${red}..HEAD`, '--', a.fiche).split('\x1e').map((r) => r.replace(/^\n/, '')).filter(Boolean).map((r) => { const [sha = '', subject = '', body = ''] = r.split('\x1f'); return { sha, day: '', subject, body: body.trim() }; });
|
|
161
161
|
const fautif = commits.find((c) => !citedRefs(c, refsOf).some((r) => citant.has(r.lot)));
|
|
162
|
+
if (fautif && porteur) {
|
|
163
|
+
const cites = [...new Set(citedRefs(fautif, refsOf).map((r) => r.lot))];
|
|
164
|
+
throw new Error(`la fiche ${a.fiche} a été modifiée depuis le commit rouge ${red.slice(0, 7)} par un commit qui ne cite ni ${a.lot}, ni le porteur ${porteur}, ni un lot livré : ${fautif.sha.slice(0, 7)} (cite : ${cites.length ? cites.join(', ') : 'aucun lot'}) — faire citer le porteur ou un lot livré par ce commit (le réécrire avant qu'il soit poussé) ou, sinon, rescelle à la main avec le lead`);
|
|
165
|
+
}
|
|
162
166
|
if (fautif)
|
|
163
167
|
throw new Error(`la fiche ${a.fiche} a été modifiée depuis le commit rouge ${red.slice(0, 7)} par un commit qui ne cite pas ${a.lot} : ${fautif.sha.slice(0, 7)}`);
|
|
164
168
|
ficheAmendee = commits[0]?.sha.slice(0, 7);
|
|
@@ -251,16 +255,22 @@ export function lotStartHead(repo, work) {
|
|
|
251
255
|
return undefined;
|
|
252
256
|
return git(repo, 'rev-parse', `${oldest.sha}^`).trim();
|
|
253
257
|
}
|
|
254
|
-
/**
|
|
255
|
-
|
|
258
|
+
/**
|
|
259
|
+
* La fiche a été amendée par l'un des commits `cites` (ceux qui citent le lot en cours) postérieur au commit rouge `red` (descendant strict) : seule voie d'ouverture de
|
|
260
|
+
* l'exception L183 (L231). Un diff d'arbre de travail, un commit d'un autre lot ou un commit antérieur ou égal au rouge n'ouvre rien. Illisible pour git : non (le verrou reste entier).
|
|
261
|
+
*/
|
|
262
|
+
function ficheAmended(repo, red, fiche, cites = []) {
|
|
263
|
+
return cites.some((sha) => sha !== red && gitOk(repo, 'merge-base', '--is-ancestor', red, 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() !== '');
|
|
264
|
+
}
|
|
265
|
+
/** Le premier commit fautif (L231) : le plus ancien commit après `since` qui a touché le chemin `file` (modification, suppression ou renommage, sans suivre les renommages), « <sha7> <sujet> » ; vide si aucun commit ne l'a touché (écart de l'arbre de travail). */
|
|
266
|
+
function firstCommitTouching(repo, since, file) {
|
|
256
267
|
try {
|
|
257
|
-
|
|
258
|
-
|
|
268
|
+
const first = git(repo, 'log', '--reverse', '--format=%H %s', `${since}..HEAD`, '--', file).split('\n').find(Boolean);
|
|
269
|
+
return first ? `${first.slice(0, 7)}${first.slice(40)}` : '';
|
|
259
270
|
}
|
|
260
271
|
catch {
|
|
261
|
-
return
|
|
272
|
+
return '';
|
|
262
273
|
}
|
|
263
|
-
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() !== '');
|
|
264
274
|
}
|
|
265
275
|
/**
|
|
266
276
|
* 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
|
|
@@ -269,9 +279,10 @@ function ficheChanged(repo, since, fiche, cites = []) {
|
|
|
269
279
|
* 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
|
|
270
280
|
* 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
|
|
271
281
|
* 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.
|
|
272
|
-
* Exception (L183) : une acceptation à `vert: true` (lot livré) laisse modifier ses tests listés si
|
|
273
|
-
*
|
|
274
|
-
*
|
|
282
|
+
* Exception (L183, resserrée par L231) : une acceptation à `vert: true` (lot livré) laisse modifier ses tests listés si sa fiche a été amendée par un commit de `lotCommits`
|
|
283
|
+
* (ceux qui citent le lot en cours) postérieur à son commit rouge (descendant strict) : le lot en cours fait évoluer les règles. Une fois ouverte, l'exception couvre toute
|
|
284
|
+
* l'acceptation (même commit que la fiche, commit ultérieur, session suivante). Un amendement par un autre lot, ou non commité (arbre de travail), n'ouvre rien. L'acceptation
|
|
285
|
+
* du lot en cours garde le verrou entier. Le message d'un test modifié nomme le premier commit fautif (`firstCommitTouching`), absent quand aucun commit ne l'a touché.
|
|
275
286
|
* 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` :
|
|
276
287
|
* 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.
|
|
277
288
|
* 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
|
|
@@ -321,7 +332,7 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
|
|
|
321
332
|
if (cur.vert === true && typeof base.fiche === 'string' && base.lot !== opts.lot) {
|
|
322
333
|
// 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.
|
|
323
334
|
const ref = opts.lotBase ?? headBefore;
|
|
324
|
-
if (
|
|
335
|
+
if (ficheAmended(repo, red, base.fiche, opts.lotCommits))
|
|
325
336
|
continue;
|
|
326
337
|
// 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.
|
|
327
338
|
if (gitOk(repo, 'diff', '--name-only', red, ref, '--', base.fiche) && git(repo, 'diff', '--name-only', red, ref, '--', base.fiche).trim() !== '') {
|
|
@@ -356,8 +367,10 @@ export function lockViolations(repo, dir, headBefore, opts = {}) {
|
|
|
356
367
|
if (have !== want)
|
|
357
368
|
out.push(`${f} : empreinte différente de celle du scellé de ${p} (test d'acceptation de ${lot})`);
|
|
358
369
|
}
|
|
359
|
-
for (const f of changed)
|
|
360
|
-
|
|
370
|
+
for (const f of changed) {
|
|
371
|
+
const culprit = firstCommitTouching(repo, depuis, f);
|
|
372
|
+
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})${culprit ? ` ; premier commit fautif : ${culprit}` : ''}`);
|
|
373
|
+
}
|
|
361
374
|
}
|
|
362
375
|
return out;
|
|
363
376
|
}
|
|
@@ -396,6 +409,7 @@ export function acceptText(o) {
|
|
|
396
409
|
isAcceptList(o.command)
|
|
397
410
|
? `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.` : ''}`
|
|
398
411
|
: `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.` : ''}`,
|
|
412
|
+
`If \`${o.acceptation}\` already exists with \`vert: true\` and \`empreintes\`, the lot's oracle is green and sealed: never rewrite it, nor its tests, \`resceaux\` or \`empreintes\`, and never set \`vert\` (a corrected test is resealed by the lead, \`cadence spec reseal\`). Write nothing, commit nothing, and hand the lot back with that finding in \`questions\`. A \`vert: true\` file WITHOUT \`empreintes\` (or whose \`empreintes\` lack one of the listed test files) was never sealed by the program (only the cycle sets \`vert\`, after the seal): it proves nothing, so write the oracle as described below, with \`vert: false\`.`,
|
|
399
413
|
`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). If the tests need a build or a browser (Playwright, Chromium, \`dist\`), add \`"e2e": true\` to it: those files then stay out of the ordinary runner and the unit CI even once green (they would turn it red without \`dist\` or Chromium) and belong to the project's e2e suite, run after the build (\`acceptationE2e()\` of the runner template lists them). 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\`):`,
|
|
400
414
|
];
|
|
401
415
|
if (o.runner)
|
|
@@ -24,7 +24,7 @@ export function reposText(role, lot, repos) {
|
|
|
24
24
|
`\`raf commits ${lot}\` lists them all; read the diff of the neighbouring ones with \`git -C <path> log\` / \`git -C <path> show\` (${repos.map((r) => `git -C ${r.path} log`).join(' ; ')}), and review them like the rest. You are read-only there too.`,
|
|
25
25
|
].join('\n');
|
|
26
26
|
}
|
|
27
|
-
/** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir
|
|
27
|
+
/** Consigne d'implémentation d'un lot `visible` : son entrée Nouveautés (convention de `cadence news`, voir docs/news.md du paquet @sylad/cadence). */
|
|
28
28
|
export function newsText(lot, captureDir) {
|
|
29
29
|
return [
|
|
30
30
|
`This lot is visible: write its News entry. Run \`cadence news new ${lot}\` (it creates \`docs/nouveautes/<date>-<slug>.md\`), then write the text in terms of what changes for the user, factual (what the user sees or can now do, no implementation detail, no promise).`,
|
|
@@ -392,7 +392,7 @@ function dryRun(lots, io, deps, budget, id) {
|
|
|
392
392
|
if (specStage)
|
|
393
393
|
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
|
|
394
394
|
if (l.screen && env.config.maquette)
|
|
395
|
-
vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0 });
|
|
395
|
+
vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews });
|
|
396
396
|
if (l.specLot && env.config.fiche)
|
|
397
397
|
vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
|
|
398
398
|
if (specStage && env.config.accept) {
|
|
@@ -486,6 +486,9 @@ export async function orchestrate(argv, io, deps) {
|
|
|
486
486
|
throw new RafError('--status ne se combine pas avec --drop / --stop-after-current');
|
|
487
487
|
if (args.resume !== undefined && args.stopAfterCurrent)
|
|
488
488
|
throw new RafError('--resume ne se combine pas avec --stop-after-current (il reprend la vague, il ne l\'arrête pas)');
|
|
489
|
+
// L228 : la reprise n'a pas de simulation — elle relève le budget, réécrit wave.json et relance les sessions. Refusée ici, avant le réexec de l'instantané (une vague ancienne ne connaît pas ce refus).
|
|
490
|
+
if (args.resume !== undefined && args.dryRun)
|
|
491
|
+
throw new RafError('--resume ne se combine pas avec --dry-run (la reprise écrit l\'état de la vague et relance les sessions, elle ne se simule pas)');
|
|
489
492
|
if (args.lotBudgets.length && args.resume === undefined)
|
|
490
493
|
throw new RafError("--budget projet:lot=+N ne vaut qu'avec --resume");
|
|
491
494
|
if (args.since !== undefined && !args.waits)
|
|
@@ -450,13 +450,14 @@ function briefFor(c, kind) {
|
|
|
450
450
|
const reviewed = [...(l.ficheConstats ?? []), ...(l.maquetteConstats ?? [])];
|
|
451
451
|
const written = ficheOf(c);
|
|
452
452
|
const back = partsOf(l, written).back;
|
|
453
|
+
const session = { run: (l.specRuns ?? 0) + 1, max: specBudget(c) };
|
|
453
454
|
vars.spec = specText({
|
|
454
455
|
dir: c.config.spec.dir,
|
|
455
456
|
render: c.config.spec.render,
|
|
456
457
|
screen: l.screen,
|
|
457
458
|
back,
|
|
458
459
|
problems: l.specProblems,
|
|
459
|
-
review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen) : reviewedText(reviewed),
|
|
460
|
+
review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen, session) : reviewedText(reviewed, session),
|
|
460
461
|
template: l.screen ? ficheTemplate() : undefined,
|
|
461
462
|
templateBack: back || l.screen ? ficheBackTemplate() : undefined,
|
|
462
463
|
});
|
|
@@ -471,7 +472,7 @@ function briefFor(c, kind) {
|
|
|
471
472
|
vars.reponse = '';
|
|
472
473
|
}
|
|
473
474
|
if (kind === 'maquette') {
|
|
474
|
-
vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, previous: l.maquetteConstats });
|
|
475
|
+
vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats });
|
|
475
476
|
vars.reponse = '';
|
|
476
477
|
}
|
|
477
478
|
if (kind === 'accept') {
|
|
@@ -1078,9 +1079,11 @@ function codeAfterFiche(c, fiche) {
|
|
|
1078
1079
|
/** L'acceptation du lot : celle que git suit déjà pour lui (même un ancien `acceptation.json`), sinon `acceptation-<lot>.json` à côté de la fiche. */
|
|
1079
1080
|
const lotAcceptation = (c, fiche) => findAcceptation(c.lot.repo, c.config.spec.dir, c.lot.lot) ?? acceptationFile(fiche, c.lot.lot);
|
|
1080
1081
|
const listed = (c, json) => existsSync(join(c.lot.repo, json));
|
|
1082
|
+
/** Une acceptation porte-t-elle une empreinte par fichier listé ? Le contenu n'est pas comparé (L233) : `empreintes: {}` ou celles d'un autre fichier ne scellent rien. */
|
|
1083
|
+
const hasSeals = (doc) => !!doc.empreintes && doc.fichiers.every((f) => !!doc.empreintes[posix.normalize(f)]);
|
|
1081
1084
|
/**
|
|
1082
|
-
* Une acceptation conforme et SCELLÉE par le programme (empreintes posées), rouge ou verte (`vert: true`, lot relancé dans une nouvelle vague
|
|
1083
|
-
* elle n'a pu être écrite qu'après la revue de la maquette et les tests rouges, qui ne sont donc pas rejoués. Sans `orchestrate.accept`, aucune preuve.
|
|
1085
|
+
* Une acceptation conforme et SCELLÉE par le programme (empreintes posées), rouge ou verte (`vert: true`, lot relancé dans une nouvelle vague ; une verte et scellée vaut
|
|
1086
|
+
* preuve même si son scellé ne tient plus, L233) : elle n'a pu être écrite qu'après la revue de la maquette et les tests rouges, qui ne sont donc pas rejoués. Sans `orchestrate.accept`, aucune preuve.
|
|
1084
1087
|
*/
|
|
1085
1088
|
function acceptationProof(c, fiche) {
|
|
1086
1089
|
const l = c.lot;
|
|
@@ -1088,18 +1091,24 @@ function acceptationProof(c, fiche) {
|
|
|
1088
1091
|
if (!c.config.accept || !listed(c, json))
|
|
1089
1092
|
return false;
|
|
1090
1093
|
const doc = parseAcceptation(readFileSync(join(l.repo, json), 'utf8'));
|
|
1091
|
-
if (typeof doc === 'string'
|
|
1094
|
+
if (typeof doc === 'string')
|
|
1092
1095
|
return false;
|
|
1096
|
+
// Un lot déjà vert (L233) n'a plus d'oracle à écrire : le cycle seul a pu poser `vert: true`, après le scellé et les tests rouges. Même une preuve qui ne tient plus
|
|
1097
|
+
// (test rescellé ou corrigé depuis, fiche amendée) ne renvoie jamais à accept, qui réécrirait un oracle vert et exigerait des tests rouges : le lot part à implement ou à la revue.
|
|
1098
|
+
// Une verte porte une empreinte par fichier listé, sinon elle n'a jamais été scellée (`empreintes: {}` ou celles d'un autre fichier ne prouvent rien).
|
|
1099
|
+
if (!hasSeals(doc))
|
|
1100
|
+
return false;
|
|
1101
|
+
if (doc.vert)
|
|
1102
|
+
return true;
|
|
1093
1103
|
// Le scellé n'est posé que par le programme : chaque fichier listé doit porter l'empreinte git de son contenu actuel (une clé `empreintes` vide ou copiée d'un autre lot ne prouve rien).
|
|
1094
|
-
const emp = doc.empreintes;
|
|
1104
|
+
const emp = doc.empreintes ?? {};
|
|
1095
1105
|
const sealed = doc.fichiers.every((f) => {
|
|
1096
1106
|
const norm = posix.normalize(f);
|
|
1097
1107
|
return existsSync(join(l.repo, norm)) && emp[norm] === git(l.repo, 'hash-object', '--', norm).trim();
|
|
1098
1108
|
});
|
|
1099
1109
|
if (!sealed)
|
|
1100
1110
|
return false;
|
|
1101
|
-
|
|
1102
|
-
return vertMsg.length === 0;
|
|
1111
|
+
return checkAcceptation(l.repo, json, l.lot, fiche).length === 0;
|
|
1103
1112
|
}
|
|
1104
1113
|
/** Chemins salis par une session : suivis modifiés, nouveaux fichiers non suivis, et ceux des dépôts voisins. */
|
|
1105
1114
|
function dirtAfter(done) {
|
|
@@ -1126,6 +1135,8 @@ function takeReport(c, done, rep) {
|
|
|
1126
1135
|
l.warnings.push(`question ignorée (non-question) : « ${q.trim()} »`);
|
|
1127
1136
|
return rep.questions.filter((q) => !isNonQuestion(q));
|
|
1128
1137
|
}
|
|
1138
|
+
/** Plafond de sessions `spec` du lot (L230) : les revues de maquette d'un lot à écran, sinon deux. Fiche et maquette partagent `l.specRuns`. */
|
|
1139
|
+
const specBudget = (c) => (c.lot.screen && c.config.maquette ? c.config.maquetteReviews : 2);
|
|
1129
1140
|
/**
|
|
1130
1141
|
* Étape spec (L171, spec etape-spec S3) : la session écrit maquette, captures et fiche dans `<spec.dir>/<écran>/`, le programme
|
|
1131
1142
|
* en relève les écarts (une seconde passe, puis le lot est rendu), une hypothèse coûteuse à défaire devient une question au lead,
|
|
@@ -1148,7 +1159,7 @@ async function specStep(c) {
|
|
|
1148
1159
|
const questions = takeReport(c, done, rep);
|
|
1149
1160
|
if (questions.length) {
|
|
1150
1161
|
l.questions = questions;
|
|
1151
|
-
l.specRuns = undefined; // la réponse ouvre un nouveau budget de
|
|
1162
|
+
l.specRuns = undefined; // la réponse ouvre un nouveau budget de sessions
|
|
1152
1163
|
l.specAsked = undefined; // la prochaine réponse porte sur la question de la session, pas sur d'anciennes hypothèses coûteuses
|
|
1153
1164
|
l.next = 'spec';
|
|
1154
1165
|
transition(c, 'question', questions[0]);
|
|
@@ -1177,8 +1188,8 @@ async function specStep(c) {
|
|
|
1177
1188
|
const inDir = fiche === '' || fiche.startsWith(`${c.config.spec.dir.replace(/\/+$/, '')}/`);
|
|
1178
1189
|
const problems = !outside && fiche && !inDir ? [`${fiche} : fiche hors de ${c.config.spec.dir} (la spec vit dans ${c.config.spec.dir}/${what}/fiche.yaml, où l'étape la retrouve)`] : outside ? [`${reported} : fiche hors du dépôt (le chemin de « fiche » est relatif au dépôt : ${c.config.spec.dir}/${what}/fiche.yaml)`] : fiche ? checkSpec(l.repo, fiche, l.lot, partsOf(l, fiche)) : [`aucune fiche écrite : ${c.config.spec.dir}/${what}/fiche.yaml est attendue (ou une raison dans « sousSeuil » si le lot ne change ${l.screen ? 'aucun écran' : 'aucun comportement'})`];
|
|
1179
1190
|
if (problems.length) {
|
|
1180
|
-
if ((l.specRuns ?? 0) >=
|
|
1181
|
-
stop(c, 'handed-back', `spec non conforme après
|
|
1191
|
+
if ((l.specRuns ?? 0) >= specBudget(c)) {
|
|
1192
|
+
stop(c, 'handed-back', `spec non conforme après ${specBudget(c)} passes : ${problems.join(' ; ')}`);
|
|
1182
1193
|
return;
|
|
1183
1194
|
}
|
|
1184
1195
|
l.specProblems = problems;
|
|
@@ -1193,7 +1204,7 @@ async function specStep(c) {
|
|
|
1193
1204
|
if (costly.length) {
|
|
1194
1205
|
l.questions = costly.map((x) => `${x.id} est posée comme hypothèse mais touche un choix coûteux à défaire (${x.categorie}) : « ${x.siFaux} » — la confirmer, ou la corriger, avant que la fiche serve de référence.`);
|
|
1195
1206
|
l.specAsked = [...l.questions];
|
|
1196
|
-
l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de
|
|
1207
|
+
l.specRuns = undefined; // la réponse du lead ouvre un nouveau budget de sessions, comme celle d'une question de la session
|
|
1197
1208
|
l.next = 'spec';
|
|
1198
1209
|
transition(c, 'question', l.questions[0]);
|
|
1199
1210
|
return;
|
|
@@ -1294,8 +1305,8 @@ async function noteMinors(c, rep, label) {
|
|
|
1294
1305
|
const FICHE_REVIEW = { kind: 'fiche', label: 'fiche', source: 'code', after: afterFiche, retry: afterFiche };
|
|
1295
1306
|
const MAQUETTE_REVIEW = { kind: 'maquette', label: 'maquette', source: 'ux', after: afterMaquette, retry: () => 'spec' };
|
|
1296
1307
|
/**
|
|
1297
|
-
* Revue de la spec (fiche ou maquette) : une session de lecture seule. Un bloquant ou un majeur renvoie à la spec (
|
|
1298
|
-
*
|
|
1308
|
+
* Revue de la spec (fiche ou maquette) : une session de lecture seule. Un bloquant ou un majeur renvoie à la spec (avec les constats ; pour un lot mixte, ceux des deux revues ensemble), puis la spec est relue : deux revues pour la fiche,
|
|
1309
|
+
* `orchestrate.maquetteReviews` pour la maquette, dans le plafond de sessions spec du lot (`specBudget`) ; ce qui reste à la dernière revue est rendu au lead.
|
|
1299
1310
|
* Les mineurs de la fiche vont en notes du lot (ceux de la maquette restent des propositions). Ce verdict n'est pas `raf review` ni `raf ux` : la revue après code garde ce rôle.
|
|
1300
1311
|
*/
|
|
1301
1312
|
async function reviewSpecStep(c, r) {
|
|
@@ -1326,6 +1337,12 @@ async function reviewSpecStep(c, r) {
|
|
|
1326
1337
|
if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
|
|
1327
1338
|
return;
|
|
1328
1339
|
const constats = blockingConstats(rep, r.source);
|
|
1340
|
+
if (r.kind === 'maquette' && passes >= 2) {
|
|
1341
|
+
// Lu sur rep.constats : blockingConstats ne garde pas `inchange`. Seul le booléen vrai est une marque (checkShape ne descend pas dans constats[]).
|
|
1342
|
+
for (const k of rep.constats)
|
|
1343
|
+
if ((k.gravite === 'bloquant' || k.gravite === 'majeur') && k.inchange === true)
|
|
1344
|
+
c.wave.log(`${lotKey(l.project, l.lot)} · défaut de revue : maquette, revue ${passes}, majeur sur un point inchangé absent de la revue ${passes - 1} : ${k.texte}`);
|
|
1345
|
+
}
|
|
1329
1346
|
const key = r.kind === 'fiche' ? 'ficheConstats' : 'maquetteConstats';
|
|
1330
1347
|
if (summary.conforme) {
|
|
1331
1348
|
l[key] = undefined;
|
|
@@ -1333,13 +1350,14 @@ async function reviewSpecStep(c, r) {
|
|
|
1333
1350
|
save(c);
|
|
1334
1351
|
return;
|
|
1335
1352
|
}
|
|
1336
|
-
|
|
1337
|
-
|
|
1353
|
+
const reviews = r.kind === 'maquette' ? c.config.maquetteReviews : 2;
|
|
1354
|
+
if (passes >= reviews) {
|
|
1355
|
+
stop(c, 'handed-back', `${r.label} non conforme après ${reviews} revue${reviews > 1 ? 's' : ''} : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
|
|
1338
1356
|
return;
|
|
1339
1357
|
}
|
|
1340
|
-
// Le budget de
|
|
1341
|
-
if ((l.specRuns ?? 0) >=
|
|
1342
|
-
stop(c, 'handed-back', `${r.label} non conforme et la spec a déjà joué
|
|
1358
|
+
// Le budget de sessions spec est épuisé (écarts du programme compris) : un renvoi à la spec serait une session de trop.
|
|
1359
|
+
if ((l.specRuns ?? 0) >= specBudget(c)) {
|
|
1360
|
+
stop(c, 'handed-back', `${r.label} non conforme et la spec a déjà joué ${specBudget(c)} sessions : ${summary.verdict}\n${bulletLines(constats).join('\n')}`);
|
|
1343
1361
|
return;
|
|
1344
1362
|
}
|
|
1345
1363
|
l[key] = constats.length ? constats : [{ source: r.source, gravite: 'majeur', texte: summary.verdict }];
|
|
@@ -1389,6 +1407,34 @@ async function acceptProblems(c, fiche, json, headBefore) {
|
|
|
1389
1407
|
}
|
|
1390
1408
|
return out;
|
|
1391
1409
|
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Écart de l'acceptation verte et scellée `json` par rapport à ce qu'elle était à `first` (tête d'avant la première session accept du lot), ou null.
|
|
1412
|
+
* Admis : le même contenu, ou un rescellé du lead (`cadence spec reseal`, L200) : `vert` et `empreintes` toujours posés, journal `resceaux` allongé, aucun commit
|
|
1413
|
+
* du fichier dans une session de la vague, fichier propre. Tout le reste (vert: false, journal effacé, réécriture commitée par la session) est rendu au lead (L233).
|
|
1414
|
+
*/
|
|
1415
|
+
function greenDrift(c, json, first, before, was) {
|
|
1416
|
+
const l = c.lot;
|
|
1417
|
+
if (!listed(c, json))
|
|
1418
|
+
return 'acceptation.json supprimé depuis la première session accept (verrou des tests d\'acceptation)';
|
|
1419
|
+
const text = readFileSync(join(l.repo, json), 'utf8');
|
|
1420
|
+
// `before` vient de git() qui rogne les blancs de fin, le fichier écrit par le programme finit par un saut de ligne : les deux se comparent rognés.
|
|
1421
|
+
if (text.trim() === before.trim())
|
|
1422
|
+
return null;
|
|
1423
|
+
const now = parseAcceptation(text);
|
|
1424
|
+
const dirty = git(l.repo, 'status', '--porcelain', '--', json).trim() !== '';
|
|
1425
|
+
let bySession = false;
|
|
1426
|
+
try {
|
|
1427
|
+
const mine = waveCommits(c, l.repo);
|
|
1428
|
+
bySession = git(l.repo, 'log', '--format=%H', `${first}..HEAD`, '--', json).split('\n').some((sha) => sha && mine(sha));
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
bySession = true;
|
|
1432
|
+
}
|
|
1433
|
+
const journal = (a) => a.resceaux?.length ?? 0;
|
|
1434
|
+
if (typeof now !== 'string' && now.vert && now.empreintes && journal(now) > journal(was) && !bySession && !dirty)
|
|
1435
|
+
return null;
|
|
1436
|
+
return 'acceptation verte réécrite depuis la première session accept (vert, empreintes ou resceaux changés ; seul un rescellé du lead, `cadence spec reseal`, est admis) : remettre le fichier comme avant la session, par exemple `git checkout <commit vert> -- <fichier>` puis `--resume`';
|
|
1437
|
+
}
|
|
1392
1438
|
/**
|
|
1393
1439
|
* Étape accept (L173, spec etape-spec S5) : une session distincte de l'implémenteur écrit, depuis la fiche et la maquette seules, les tests
|
|
1394
1440
|
* d'acceptation rouges. Le programme vérifie (acceptation.json conforme, autres lots intacts, commande dédiée rouge, config.test verte),
|
|
@@ -1403,6 +1449,32 @@ async function acceptStep(c) {
|
|
|
1403
1449
|
return;
|
|
1404
1450
|
}
|
|
1405
1451
|
const json = lotAcceptation(c, fiche);
|
|
1452
|
+
// Un lot déjà vert et scellé (L233) n'a plus d'oracle à écrire, même quand `next` vaut encore accept (question posée pendant accept, puis réponse du lead) : jamais de session accept.
|
|
1453
|
+
// Le fichier est lu tel qu'il était avant la première session accept du lot : la seconde passe voit le fichier écrit par la session, qui ne doit pas être vert (checkAcceptation).
|
|
1454
|
+
const first = l.steps.find((s) => s.kind === 'accept')?.headBefore;
|
|
1455
|
+
let text = null;
|
|
1456
|
+
try {
|
|
1457
|
+
text = first ? git(l.repo, 'show', `${first}:${json}`) : listed(c, json) ? readFileSync(join(l.repo, json), 'utf8') : null;
|
|
1458
|
+
}
|
|
1459
|
+
catch {
|
|
1460
|
+
text = null;
|
|
1461
|
+
}
|
|
1462
|
+
const doc = text === null ? null : parseAcceptation(text);
|
|
1463
|
+
if (doc && typeof doc !== 'string' && doc.vert && hasSeals(doc)) {
|
|
1464
|
+
// Le fichier actuel doit être celui de `first` : une session accept qui a réécrit puis commité l'oracle avant de poser sa question (le verrou ne s'applique pas à l'étape accept, `skip`)
|
|
1465
|
+
// ne se fait pas adopter comme nouveau commit rouge. Seul un rescellé du lead (journal `resceaux` allongé, commit hors de toute session, fichier propre) est une différence admise.
|
|
1466
|
+
const drift = first ? greenDrift(c, json, first, text, doc) : null;
|
|
1467
|
+
if (drift) {
|
|
1468
|
+
stop(c, 'handed-back', `verrou des tests d'acceptation : ${json} : ${drift}`);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
l.accept = { acceptation: json, rouge: redSha(l.repo, json) ?? '' };
|
|
1472
|
+
l.next = 'implement';
|
|
1473
|
+
// Un lot en question n'a plus rien à demander : la réponse du lead (« ne réécris rien ») est prise en compte par ce choix, elle ne repart pas dans le brief d'implement.
|
|
1474
|
+
l.pendingAnswer = null;
|
|
1475
|
+
transition(c, 'implementing', 'acceptation déjà verte : accept ignoré');
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1406
1478
|
const done = await session(c, 'accept');
|
|
1407
1479
|
if (!done)
|
|
1408
1480
|
return;
|
|
@@ -13,10 +13,10 @@ export function ficheReviewText(o) {
|
|
|
13
13
|
lines.push('', 'Points of the first review that the spec writer had to fix:', ...bulletLines(o.previous));
|
|
14
14
|
return lines.join('\n');
|
|
15
15
|
}
|
|
16
|
-
/** Consigne de la
|
|
17
|
-
export function reviewedSheetText(constats, mockup) {
|
|
16
|
+
/** Consigne de la passe `spec` qui suit une revue : les constats bloquants et majeurs des revues de la fiche (et de la maquette d'un lot mixte), ensemble, la session `run` sur `max`. */
|
|
17
|
+
export function reviewedSheetText(constats, mockup, o) {
|
|
18
18
|
return [
|
|
19
|
-
`The review of your ${mockup ? 'sheet and mockup' : 'sheet'} found the blocking and major points below. Fix exactly these in the sheet${mockup ? ', the mockup and the captures' : ''} (
|
|
19
|
+
`The review of your ${mockup ? 'sheet and mockup' : 'sheet'} found the blocking and major points below. Fix exactly these in the sheet${mockup ? ', the mockup and the captures' : ''} (spec session ${o.run} of ${o.max}: it is reviewed again, and what remains once the sessions are used goes back to the lead). Keep the sheet consistent: every rule sourced or a hypothesis with its \`si_faux\`, every rule attached to a case and tested, every contract with its errors:`,
|
|
20
20
|
...bulletLines(constats),
|
|
21
21
|
].join('\n');
|
|
22
22
|
}
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { posix } from 'node:path';
|
|
2
2
|
import { LARGEURS } from '../spec.js';
|
|
3
|
-
// Revue UX de la maquette (L172, spec etape-spec S4) : ce que le programme dit à l'agent ux-reviewer en mode « maquette », et à l'agent spec à la
|
|
3
|
+
// Revue UX de la maquette (L172, spec etape-spec S4) : ce que le programme dit à l'agent ux-reviewer en mode « maquette », et à l'agent spec à la passe suivante.
|
|
4
4
|
/** Une ligne par constat : gravité, endroit, texte. */
|
|
5
5
|
export const bulletLines = (constats) => constats.map((k) => `- [${k.gravite}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`);
|
|
6
|
-
/** Consigne de la revue de maquette dans le brief : la fiche, la maquette, les captures attendues, la
|
|
6
|
+
/** Consigne de la revue de maquette dans le brief : la fiche, la maquette, les captures attendues, le rang de la revue sur le plafond `max` (L230). */
|
|
7
7
|
export function maquetteText(o) {
|
|
8
8
|
const folder = posix.dirname(o.fiche);
|
|
9
|
-
const
|
|
9
|
+
const rank = o.passes + 1;
|
|
10
|
+
const later = rank >= 2;
|
|
10
11
|
const lines = [
|
|
11
12
|
`Mockup mode: review the mockup \`${folder}/maquette.html\` against its sheet \`${o.fiche}\`. It is a local file, not an application: open it directly (file URL), do not start any server.`,
|
|
12
13
|
`The spec writer committed the captures \`${folder}/maquette-<état>-<largeur>.png\` (one per state of the sheet and per width: ${LARGEURS.join(' and ')} px): look at every one, and re-capture a state yourself when a capture looks stale. Each state is the section \`#etat-<état>\` of the mockup.`,
|
|
13
|
-
|
|
14
|
-
?
|
|
15
|
-
:
|
|
14
|
+
later
|
|
15
|
+
? `This is review ${rank} of ${o.max}${rank >= o.max ? ', the last review' : ''}: the spec writer fixed the points of the previous one (below). Check them first, then run the whole sweep again and report ALL the blocking and major findings of it in this report. Mark \`inchange: true\` on every blocking or major finding about a point the spec writer did not change and the previous review had not raised.`
|
|
16
|
+
: `This is the first review of this mockup (${o.max} review${o.max > 1 ? 's' : ''} at most): there is no later review to keep a finding for. Report ALL the blocking and major findings in this first report; do not keep one back for a later review.`,
|
|
16
17
|
];
|
|
17
|
-
if (
|
|
18
|
-
lines.push('', 'Points of the
|
|
18
|
+
if (later && o.previous?.length)
|
|
19
|
+
lines.push('', 'Points of the previous review that the spec writer had to fix:', ...bulletLines(o.previous));
|
|
19
20
|
return lines.join('\n');
|
|
20
21
|
}
|
|
21
|
-
/** Consigne de la
|
|
22
|
-
export function reviewedText(constats) {
|
|
22
|
+
/** Consigne de la passe `spec` qui suit une revue : les constats bloquants et majeurs de la revue UX de la maquette, la session `run` sur `max`. */
|
|
23
|
+
export function reviewedText(constats, o) {
|
|
23
24
|
return [
|
|
24
|
-
|
|
25
|
+
`The UX review of your mockup found the blocking and major points below. Fix exactly these in the mockup, the captures and the sheet (spec session ${o.run} of ${o.max}: the mockup is reviewed again, and what remains after the last review goes back to the lead). Keep the sheet consistent with the mockup — every field, every rule message visible in the state its \`etat\` names, every \`data-testid\`:`,
|
|
25
26
|
...bulletLines(constats),
|
|
26
27
|
].join('\n');
|
|
27
28
|
}
|
|
@@ -40,7 +40,7 @@ export const REVIEW_SCHEMA = {
|
|
|
40
40
|
bloquants: num,
|
|
41
41
|
majeurs: num,
|
|
42
42
|
mineurs: num,
|
|
43
|
-
constats: { type: 'array', items: { type: 'object', properties: { gravite: { type: 'string', enum: ['bloquant', 'majeur', 'mineur'] }, fichier: str, ligne: num, texte: str }, required: ['gravite', 'texte'] } },
|
|
43
|
+
constats: { type: 'array', items: { type: 'object', properties: { gravite: { type: 'string', enum: ['bloquant', 'majeur', 'mineur'] }, fichier: str, ligne: num, texte: str, inchange: { type: 'boolean', description: 'optional, review 2 or later of a mockup: true when the finding is on a point the spec writer did not change and the previous review had not raised' } }, required: ['gravite', 'texte'] } },
|
|
44
44
|
sousTaches: strings,
|
|
45
45
|
nonVerifie: strings,
|
|
46
46
|
verdict: str,
|