@sylad/cadence 1.39.0 → 1.41.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 +3 -3
- package/agents/spec-writer.md +1 -0
- package/agents/ux-reviewer.md +2 -0
- package/dist/cli.js +49 -62
- package/dist/config-set.js +2 -0
- package/dist/config.js +8 -8
- package/dist/deliver-lots.js +37 -0
- package/dist/deliver.js +15 -39
- package/dist/docs-ok-file.js +25 -0
- package/dist/lot-lines.js +51 -0
- package/dist/memory-index.js +113 -0
- package/dist/orchestrate/command.js +8 -8
- package/dist/orchestrate/continue.js +4 -2
- package/dist/orchestrate/cycle.js +23 -29
- package/dist/orchestrate/deja-traitee.js +76 -0
- package/dist/orchestrate/integrate.js +7 -2
- package/dist/orchestrate/lot-budget.js +2 -2
- package/dist/orchestrate/maquette.js +3 -0
- package/dist/orchestrate/spec.js +51 -6
- package/dist/orchestrate/table.js +29 -11
- package/dist/plan-extras.js +118 -0
- package/dist/plan-read.js +144 -0
- package/dist/plan-states.js +119 -0
- package/dist/plan.js +91 -157
- package/dist/raf-close-checks.js +25 -0
- package/dist/raf-status.js +28 -0
- package/dist/session.js +8 -0
- package/dist/state.js +23 -0
- package/dist/verify.js +20 -3
- package/dist/yaml-keys.js +2 -0
- package/docs/concepts-livraison-dark.svg +32 -0
- package/docs/concepts-livraison-light.svg +32 -0
- package/docs/concepts-lot-dark.svg +52 -0
- package/docs/concepts-lot-light.svg +52 -0
- package/docs/concepts-vague-dark.svg +39 -0
- package/docs/concepts-vague-light.svg +39 -0
- package/docs/concepts-worktrees-dark.svg +45 -0
- package/docs/concepts-worktrees-light.svg +45 -0
- package/docs/concepts.md +159 -0
- package/docs/deliver.md +24 -2
- package/docs/fonctionnement.md +11 -5
- package/docs/lead.md +10 -6
- package/docs/orchestrate.md +22 -5
- package/docs/overview.md +1 -1
- package/docs/perf.md +1 -0
- package/docs/qualite.md +13 -3
- package/docs/raf.md +42 -5
- package/docs/session.md +32 -0
- package/docs/spec.md +5 -2
- package/docs/whats-new.md +6 -0
- package/package.json +10 -1
- package/skills/lead/SKILL.md +6 -0
- package/skills/session-close/SKILL.md +6 -0
- package/templates/orchestrate/spec.md +2 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { RafError } from './plan.js';
|
|
5
|
+
import { claudeConfigDir, projectLogDir } from './orchestrate/launch.js';
|
|
6
|
+
/** Taille (octets) au-delà de laquelle l'index MEMORY.md est signalé quand `memory.indexMaxBytes` n'est pas posé (L442). */
|
|
7
|
+
const MEMORY_INDEX_MAX_BYTES = 12_000;
|
|
8
|
+
/** L'index de la mémoire de Claude Code du dossier `dir` (celui où la session est lancée, jamais la racine git). */
|
|
9
|
+
function memoryIndexPath(env, dir) {
|
|
10
|
+
return join(projectLogDir(claudeConfigDir(env), dir), 'memory', 'MEMORY.md');
|
|
11
|
+
}
|
|
12
|
+
// « - », puis au plus « ** », « [ » et « ⚠ », puis un jour « jj-mm » : le nom du fichier lié ne compte pas.
|
|
13
|
+
const DATED = /^- (?:\*\*)?\[?(?:⚠️?\s*)?(?:0[1-9]|[12]\d|3[01])-(?:0[1-9]|1[0-2])(?!\d)/;
|
|
14
|
+
/** Les entrées datées d'un index : leur nombre et leur poids en octets (fin de ligne comprise). */
|
|
15
|
+
function datedEntries(text) {
|
|
16
|
+
let count = 0;
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
const lines = text.replace(/^\uFEFF/, '').split('\n');
|
|
19
|
+
for (const [i, line] of lines.entries()) {
|
|
20
|
+
const bare = line.replace(/\r$/, '');
|
|
21
|
+
if (!DATED.test(bare))
|
|
22
|
+
continue;
|
|
23
|
+
count++;
|
|
24
|
+
// la fin de ligne ne compte que si elle existe : la dernière ligne peut ne pas en avoir
|
|
25
|
+
bytes += Buffer.byteLength(line) + (i < lines.length - 1 ? 1 : 0);
|
|
26
|
+
}
|
|
27
|
+
return { count, bytes };
|
|
28
|
+
}
|
|
29
|
+
/** Mesure l'index : absent ou au plus égal au seuil = `ok` ; une lecture impossible se dit au lieu de lever. Ne modifie rien. */
|
|
30
|
+
function checkMemoryIndex(env, dir, max) {
|
|
31
|
+
let buf;
|
|
32
|
+
try {
|
|
33
|
+
buf = readFileSync(memoryIndexPath(env, dir));
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const code = e.code;
|
|
37
|
+
return code === 'ENOENT' ? { kind: 'ok' } : { kind: 'unreadable', cause: e.message.split('\n')[0] };
|
|
38
|
+
}
|
|
39
|
+
if (buf.length <= max)
|
|
40
|
+
return { kind: 'ok' };
|
|
41
|
+
return { kind: 'over', size: buf.length, max, ...datedEntries(buf.toString('utf8')) };
|
|
42
|
+
}
|
|
43
|
+
const NO_DATED = "aucune entrée datée à regrouper dans l'index MEMORY.md";
|
|
44
|
+
/** Les lignes de la section « Mémoire » de `session close`, précédée d'une ligne vide (aucune quand l'index est sain). */
|
|
45
|
+
function closeLines(c) {
|
|
46
|
+
if (c.kind === 'ok')
|
|
47
|
+
return [];
|
|
48
|
+
if (c.kind === 'unreadable')
|
|
49
|
+
return [`\nMémoire : l'index MEMORY.md est illisible (${c.cause}), sa taille n'est pas contrôlée`];
|
|
50
|
+
return [
|
|
51
|
+
`\nMémoire : l'index MEMORY.md pèse ${c.size} octets (seuil ${c.max})`,
|
|
52
|
+
c.count === 0
|
|
53
|
+
? ` ${NO_DATED}`
|
|
54
|
+
: ` ${c.count} entrée(s) datée(s) (${c.bytes} octets) à regrouper sous une ligne vers un index secondaire — le skill propose, l'humain décide`,
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
function tourLines(c) {
|
|
58
|
+
if (c.kind === 'ok')
|
|
59
|
+
return [];
|
|
60
|
+
if (c.kind === 'unreadable')
|
|
61
|
+
return [`mémoire · MEMORY.md illisible (${c.cause})`];
|
|
62
|
+
const tail = c.count === 0 ? 'aucune entrée datée à regrouper' : `${c.count} entrée(s) datée(s) à regrouper (cadence session close détaille)`;
|
|
63
|
+
return [`mémoire · MEMORY.md ${c.size} octets > seuil ${c.max} · ${tail}`];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Clé `memory.indexMaxBytes` de cadence.yaml : seuil, en octets, de l'avertissement ; `undefined` quand elle est absente.
|
|
67
|
+
* Une valeur qui n'est pas un entier ≥ 1 est refusée.
|
|
68
|
+
*/
|
|
69
|
+
export function readMemoryIndexMax(file) {
|
|
70
|
+
if (!existsSync(file))
|
|
71
|
+
return undefined;
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = parse(readFileSync(file, 'utf8'));
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
throw new RafError(`${file} illisible : ${e.message.split('\n')[0]}`);
|
|
78
|
+
}
|
|
79
|
+
const m = raw?.memory;
|
|
80
|
+
if (m == null)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (typeof m !== 'object' || Array.isArray(m))
|
|
83
|
+
throw new RafError(`${file} : memory doit être un objet { indexMaxBytes }`);
|
|
84
|
+
for (const k of Object.keys(m))
|
|
85
|
+
if (k !== 'indexMaxBytes')
|
|
86
|
+
throw new RafError(`${file} : memory.${k} inconnu (attendu : indexMaxBytes)`);
|
|
87
|
+
const v = m.indexMaxBytes;
|
|
88
|
+
if (v === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1)
|
|
91
|
+
throw new RafError(`${file} : memory.indexMaxBytes doit être un entier ≥ 1 (octets), pas ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
|
|
92
|
+
return v;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Pour `session close` : le contexte de la mesure, relu à l'appel (une clé invalide lève alors avant tout rapport). Le seuil vient
|
|
96
|
+
* du `cadence.yaml` que la commande lit.
|
|
97
|
+
*/
|
|
98
|
+
export function closeMemoryLines(env, dir, configFile) {
|
|
99
|
+
return () => closeLines(checkMemoryIndex(env, dir, readMemoryIndexMax(configFile) ?? MEMORY_INDEX_MAX_BYTES));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Pour `lead tour` : la ligne « mémoire » du dossier parent, avec le seuil de son cadence.yaml. Une clé invalide n'arrête pas le tour : une ligne d'erreur
|
|
103
|
+
* remplace la ligne « mémoire » ; un cadence.yaml illisible a déjà sa ligne d'erreur en tête de tour et n'en ajoute pas ici.
|
|
104
|
+
*/
|
|
105
|
+
export function tourMemoryLines(env, parent) {
|
|
106
|
+
try {
|
|
107
|
+
return tourLines(checkMemoryIndex(env, parent, readMemoryIndexMax(join(parent, 'cadence.yaml')) ?? MEMORY_INDEX_MAX_BYTES));
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
const message = e.message;
|
|
111
|
+
return / : memory/.test(message) ? [`cadence.yaml · ✗ erreur : ${message.replace(/^.*cadence\.yaml : /, '')}`] : [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -24,7 +24,7 @@ import { acceptationFile, acceptText, redAcceptations } from './accept.js';
|
|
|
24
24
|
import { noTestGate, noTestText } from './no-test.js';
|
|
25
25
|
import { ficheReviewText } from './fiche.js';
|
|
26
26
|
import { maquetteText } from './maquette.js';
|
|
27
|
-
import { costlyHypotheses,
|
|
27
|
+
import { budgetStage, costlyHypotheses, lightLot, specText, stageScreen } from './spec.js';
|
|
28
28
|
import { fichesDuDossier } from '../spec.js';
|
|
29
29
|
import { candidates, fillTargets, handedBackStreak, parsePriority, parseUntil, drawSettingsReport, readPriority, stopReason } from './continue.js';
|
|
30
30
|
import { precheckNothingToFind, Budget, ESCALATION_RESERVE, DROPPED, STOP_REQUESTED, MAX_PASSES, REVIEW_RESERVE, budgetStep, heldForEscalation, lotSpent, MAX_PASSES_CONVERGING, briefName, countInterrupted, planNoteHandBack, leadReviewedHandBack, HYPOTHESES_HANDBACK, handBackDroppedQuestions, needsPrecheck, workDir } from './cycle.js';
|
|
@@ -320,7 +320,7 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
320
320
|
}
|
|
321
321
|
const small = lot.estimate <= 0.5 || lot.quickwin;
|
|
322
322
|
const goal = args.goal ?? lot.goal;
|
|
323
|
-
const screen =
|
|
323
|
+
const screen = stageScreen(t.repo, env.config.spec.dir, lot, env.config.maquette);
|
|
324
324
|
const state = newLot({ project: t.project, repo: t.repo, lot: t.lot, title: lot.title, ...(goal ? { goal } : {}), visible: lot.visible, small, ...(screen ? { screen } : {}), ...(lot.spec && !screen ? { specLot: true } : {}), model: t.model, readOnlyPlan: plan.readonly });
|
|
325
325
|
if (neighbours.repos.length)
|
|
326
326
|
state.repos = neighbours.repos;
|
|
@@ -329,7 +329,7 @@ export async function preflight(args, targets, io, deps, launch, opts = {}) {
|
|
|
329
329
|
state.express = true;
|
|
330
330
|
state.light = lot.estimate <= env.config.review.threshold;
|
|
331
331
|
const escalating = !!env.config.escalation;
|
|
332
|
-
state.budget = lotBudget(lot.estimate,
|
|
332
|
+
state.budget = lotBudget(lot.estimate, budgetStage(lot, screen, env.config.maquette, t.repo, env.config.spec.dir), escalating);
|
|
333
333
|
if (escalating)
|
|
334
334
|
state.escalationReserve = ESCALATION_RESERVE;
|
|
335
335
|
state.dependsOn = lot.after.filter((d) => earlier.includes(d));
|
|
@@ -442,7 +442,7 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
|
|
|
442
442
|
io.out(`${lotKey(l.project, l.lot)} — ${l.title}`);
|
|
443
443
|
if (l.goal)
|
|
444
444
|
io.out(` objectif donné : ${l.goal} (à la place du titre dans les briefs)`);
|
|
445
|
-
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, l.screen
|
|
445
|
+
io.out(` budget du lot : ${l.budget} tokens comptés (base : ${lotBudgetBasis(lot.estimate, budgetStage(lot, l.screen, env.config.maquette, l.repo, env.config.spec.dir), l.escalationReserve ? env.config.escalation?.model : undefined)})`);
|
|
446
446
|
const mine = lots.filter((o) => o.repo === l.repo);
|
|
447
447
|
const together = env.config.worktrees && (mine.length > 1 || multiSlot) && !mine.some((o) => o.repos?.length) && !!repoBranch(l.repo);
|
|
448
448
|
io.out(together ? ` file ${basename(l.repo)} · en parallèle des autres lots du dépôt, dans son worktree (cadence/${id}/${lotSlug(l.project, l.lot)}, créé à la prise du créneau)` : ` file ${basename(l.repo)} · ${slot < 2 ? `créneau ${slot + 1}` : 'en attente d\'un créneau'}`);
|
|
@@ -459,7 +459,7 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
|
|
|
459
459
|
}
|
|
460
460
|
const steps = [{ kind: 'implement', model: l.model }];
|
|
461
461
|
const specStage = !!(l.screen || l.specLot);
|
|
462
|
-
if (specStage && env.config.accept)
|
|
462
|
+
if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir))
|
|
463
463
|
steps.unshift({ kind: 'accept', model: 'sonnet' });
|
|
464
464
|
if (l.screen && env.config.maquette)
|
|
465
465
|
steps.unshift({ kind: 'maquette', model: env.config.review.full });
|
|
@@ -513,12 +513,12 @@ function dryRun(lots, io, deps, { budget, id, multiSlot }) {
|
|
|
513
513
|
const vars = { chemin: l.repo, lot: l.lot, titre: headline(lot, l.goal), objectif: objective(lot, l.goal), commits: '', reponse: '', constats: '', ux: '', choix: '', checks: '', news: l.visible ? newsText(l.lot) : '', captures: '', diff: 'git diff <sha de départ>..HEAD' };
|
|
514
514
|
vars.decisions = decisionsText(humanDecisions(lot, lot.parent ? env.loadPlan().lots().find((x) => x.id === lot.parent) : undefined));
|
|
515
515
|
if (specStage)
|
|
516
|
-
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, back: !!l.specLot });
|
|
516
|
+
vars.spec = specText({ dir: env.config.spec.dir, render: env.config.spec.render, screen: l.screen, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir), back: !!l.specLot });
|
|
517
517
|
if (l.screen && env.config.maquette)
|
|
518
|
-
vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews });
|
|
518
|
+
vars.maquette = maquetteText({ fiche: `${env.config.spec.dir}/<écran>/fiche.yaml`, passes: 0, max: env.config.maquetteReviews, light: lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir) });
|
|
519
519
|
if (l.specLot && env.config.fiche)
|
|
520
520
|
vars.fiche = ficheReviewText({ fiche: `${env.config.spec.dir}/<sujet>/fiche.yaml`, passes: 0 });
|
|
521
|
-
if (specStage && env.config.accept) {
|
|
521
|
+
if (specStage && env.config.accept && !lightLot(l, lot, env.config.maquette, l.repo, env.config.spec.dir)) {
|
|
522
522
|
const folder = `${env.config.spec.dir}/${l.screen ? '<écran>' : '<sujet>'}`;
|
|
523
523
|
vars.accept = acceptText({ fiche: `${folder}/fiche.yaml`, maquette: `${folder}/maquette.html`, screen: !!l.screen, back: false, acceptation: acceptationFile(`${folder}/fiche.yaml`, l.lot), command: env.config.accept ?? '', test: env.config.test });
|
|
524
524
|
}
|
|
@@ -9,7 +9,7 @@ import { RafError } from '../plan.js';
|
|
|
9
9
|
import { lotBudget, projectEnv } from './command.js';
|
|
10
10
|
import { readyFree } from '../ready-free.js';
|
|
11
11
|
import { lotFinished, lotKey } from './state.js';
|
|
12
|
-
import {
|
|
12
|
+
import { budgetStage, stageScreen } from './spec.js';
|
|
13
13
|
import { STOP_REQUESTED } from './cycle.js';
|
|
14
14
|
/** `--until 18:00` : l'heure d'aujourd'hui (date de `now`) à partir de laquelle la vague ne tire plus de lot. */
|
|
15
15
|
export function parseUntil(text, now) {
|
|
@@ -90,11 +90,13 @@ export function candidates(launch, o) {
|
|
|
90
90
|
}
|
|
91
91
|
let specDir = 'docs/specs';
|
|
92
92
|
let escalating = false;
|
|
93
|
+
let maquette = true;
|
|
93
94
|
let setting;
|
|
94
95
|
try {
|
|
95
96
|
const config = projectEnv(dir).config;
|
|
96
97
|
specDir = config.spec.dir;
|
|
97
98
|
escalating = !!config.escalation;
|
|
99
|
+
maquette = config.maquette;
|
|
98
100
|
setting = config.continue;
|
|
99
101
|
}
|
|
100
102
|
catch {
|
|
@@ -108,7 +110,7 @@ export function candidates(launch, o) {
|
|
|
108
110
|
o.skipped?.push(`${project}:${lot.id} écarté du tirage : ${why}`);
|
|
109
111
|
continue;
|
|
110
112
|
}
|
|
111
|
-
if (lotBudget(lot.estimate,
|
|
113
|
+
if (lotBudget(lot.estimate, budgetStage(lot, stageScreen(dir, specDir, lot, maquette), maquette, dir, specDir), escalating) > o.remaining)
|
|
112
114
|
continue;
|
|
113
115
|
out.push({ project, dir, lot });
|
|
114
116
|
}
|
|
@@ -36,7 +36,7 @@ import { lotIds, referenceIdsText } from '../ref/coverage.js';
|
|
|
36
36
|
import { avalFicheText, ficheContracts, ficheRules, hasScreenPart } from './aval.js';
|
|
37
37
|
import { ficheReviewText, reviewedSheetText } from './fiche.js';
|
|
38
38
|
import { bulletLines, maquetteText, reviewedText } from './maquette.js';
|
|
39
|
-
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
39
|
+
import { answeredText, checkSpec, costlyHypotheses, ficheHasBack, ficheReady, findFiche, hypothesisLines, answerAltersRule, hypothesisNote, lightLot, LEAD_SOURCE, ruleIdsSourcedByLead, sourceRules, specText } from './spec.js';
|
|
40
40
|
import { handBackWithoutFinding } from './no-finding.js';
|
|
41
41
|
import { decisionsText, humanDecisions } from './decisions.js';
|
|
42
42
|
import { LOCAL_PRECHECK_UNREADABLE, PRECHECK_UNREADABLE, expressSummary, lotKey, lotRepoPaths, neighbourDirs } from './state.js';
|
|
@@ -439,24 +439,19 @@ export function briefName(l, kind, integration = false) {
|
|
|
439
439
|
return 'review-recheck'; // la revue d'intégration (R17) garde le gabarit qui porte {{integration}}
|
|
440
440
|
return kind;
|
|
441
441
|
}
|
|
442
|
-
/**
|
|
443
|
-
function
|
|
442
|
+
/** Fichier livré avec le paquet à côté des gabarits, joint à un brief (la session ne lit pas le dossier du paquet) ; undefined s'il manque. */
|
|
443
|
+
function packageFile(name) {
|
|
444
444
|
try {
|
|
445
|
-
return readFileSync(join(TEMPLATES_DIR, '..',
|
|
445
|
+
return readFileSync(join(TEMPLATES_DIR, '..', name), 'utf8');
|
|
446
446
|
}
|
|
447
447
|
catch {
|
|
448
448
|
return undefined;
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
|
+
/** Gabarit de fiche livré avec le paquet, joint au brief de la spec. */
|
|
452
|
+
const ficheTemplate = () => packageFile('fiche.yaml');
|
|
451
453
|
/** Gabarit de la partie back de la fiche (L187), joint au brief de la spec d'un lot `spec: true`. */
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
return readFileSync(join(TEMPLATES_DIR, '..', 'fiche-back.yaml'), 'utf8');
|
|
455
|
-
}
|
|
456
|
-
catch {
|
|
457
|
-
return undefined;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
454
|
+
const ficheBackTemplate = () => packageFile('fiche-back.yaml');
|
|
460
455
|
/** Le lot passe par l'étape spec : il déclare `screen:` (écran) ou `spec: true` (comportement sans écran, L188). */
|
|
461
456
|
const hasSpecStage = (l) => !!(l.screen || l.specLot);
|
|
462
457
|
/**
|
|
@@ -465,14 +460,7 @@ const hasSpecStage = (l) => !!(l.screen || l.specLot);
|
|
|
465
460
|
*/
|
|
466
461
|
const partsOf = (c, fiche) => ({ screen: !!c.lot.screen, back: !!c.lot.specLot || (!!c.lot.screen && !!fiche && ficheHasBack(wd(c), fiche)) });
|
|
467
462
|
/** Gabarit du lanceur de tests livré avec le paquet, joint au brief de l'étape accept. */
|
|
468
|
-
|
|
469
|
-
try {
|
|
470
|
-
return readFileSync(join(TEMPLATES_DIR, '..', 'acceptation-runner.mjs'), 'utf8');
|
|
471
|
-
}
|
|
472
|
-
catch {
|
|
473
|
-
return undefined;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
463
|
+
const acceptRunner = () => packageFile('acceptation-runner.mjs');
|
|
476
464
|
/** Fiche du lot : celle de l'étape spec, ou la fiche retrouvée dans le dépôt (reprise). */
|
|
477
465
|
function ficheOf(c) {
|
|
478
466
|
const l = c.lot;
|
|
@@ -543,7 +531,7 @@ function briefFor(c, kind, local = false) {
|
|
|
543
531
|
vars.spec = specText({
|
|
544
532
|
dir: c.config.spec.dir,
|
|
545
533
|
render: c.config.spec.render,
|
|
546
|
-
screen: l.screen,
|
|
534
|
+
screen: l.screen, light: lightLot(l, lot, c.config.maquette, wd(c), c.config.spec.dir),
|
|
547
535
|
back,
|
|
548
536
|
problems: l.specProblems,
|
|
549
537
|
review: !reviewed.length ? undefined : back ? reviewedSheetText(reviewed, !!l.screen, session) : reviewedText(reviewed, session),
|
|
@@ -562,7 +550,7 @@ function briefFor(c, kind, local = false) {
|
|
|
562
550
|
vars.reponse = '';
|
|
563
551
|
}
|
|
564
552
|
if (kind === 'maquette') {
|
|
565
|
-
vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats });
|
|
553
|
+
vars.maquette = maquetteText({ fiche: ficheOf(c) ?? '', passes: l.maquette?.passes ?? 0, max: c.config.maquetteReviews, previous: l.maquetteConstats, light: lightLot(l, lot, c.config.maquette, wd(c), c.config.spec.dir) });
|
|
566
554
|
vars.reponse = '';
|
|
567
555
|
}
|
|
568
556
|
if (kind === 'accept') {
|
|
@@ -1300,18 +1288,21 @@ function summarize(rep, head, repoHeads) {
|
|
|
1300
1288
|
const majeurs = Math.max(rep.majeurs, count('majeur'));
|
|
1301
1289
|
return { conforme: bloquants === 0 && majeurs === 0, bloquants, majeurs, mineurs: Math.max(rep.mineurs, count('mineur')), verdict: rep.verdict.trim(), sousTaches: rep.sousTaches, nonVerifie: rep.nonVerifie, head, ...(repoHeads ? { repoHeads } : {}) };
|
|
1302
1290
|
}
|
|
1303
|
-
function propose(c, text) {
|
|
1291
|
+
function propose(c, text, head) {
|
|
1304
1292
|
if (!c.lot.proposals.includes(text))
|
|
1305
1293
|
c.lot.proposals.push(text);
|
|
1294
|
+
if (head)
|
|
1295
|
+
(c.lot.proposalHeads ??= {})[text] ??= head;
|
|
1306
1296
|
}
|
|
1307
1297
|
const minorLine = (source, k) => `[mineur ${source}] ${k.fichier ? `${k.fichier}${k.ligne ? `:${k.ligne}` : ''} — ` : ''}${k.texte}`;
|
|
1308
1298
|
const withoutLine = (line) => line.replace(/^(\[mineur \w+\] .*?):\d+ — /, '$1 — ');
|
|
1309
|
-
function addProposals(c, rep, source, withMinors = true) {
|
|
1299
|
+
function addProposals(c, rep, source, head, withMinors = true) {
|
|
1310
1300
|
if (withMinors)
|
|
1311
1301
|
for (const k of rep.constats.filter((k) => k.gravite === 'mineur'))
|
|
1312
1302
|
propose(c, minorLine(source, k));
|
|
1313
|
-
|
|
1314
|
-
|
|
1303
|
+
// La première revue qui émet la proposition en garde la date : une revue suivante qui la redit ne la rajeunit pas.
|
|
1304
|
+
for (const t of rep.sousTaches.map((t) => `[sous-tâche ${source}] ${t}`))
|
|
1305
|
+
propose(c, t, head);
|
|
1315
1306
|
}
|
|
1316
1307
|
function minorConstats(rep) {
|
|
1317
1308
|
return rep.constats.filter((k) => k.gravite === 'mineur').map((k) => ({ source: 'code', gravite: k.gravite, fichier: k.fichier, ligne: k.ligne, texte: k.texte }));
|
|
@@ -1452,6 +1443,9 @@ function afterMaquette(c, fiche) {
|
|
|
1452
1443
|
// Des constats de la revue de fiche attendent la revue de la maquette (lot mixte) : les deux partent ensemble à la seconde passe `spec`.
|
|
1453
1444
|
if (l.ficheConstats?.length)
|
|
1454
1445
|
return 'spec';
|
|
1446
|
+
// Une maquette légère n'a pas de règles de comportement à verrouiller : pas d'accept, même si orchestrate.accept est déclaré.
|
|
1447
|
+
if (lightLot(l, c.loadPlan().lot(l.lot), c.config.maquette, wd(c), c.config.spec.dir))
|
|
1448
|
+
return 'implement';
|
|
1455
1449
|
if (!c.config.accept) {
|
|
1456
1450
|
if (!l.warnings.includes(NO_ACCEPT))
|
|
1457
1451
|
l.warnings.push(NO_ACCEPT);
|
|
@@ -1782,7 +1776,7 @@ async function reviewSpecStep(c, r) {
|
|
|
1782
1776
|
const summary = summarize(rep, done.after.head ?? '');
|
|
1783
1777
|
const passes = (l[r.kind]?.passes ?? 0) + 1;
|
|
1784
1778
|
// Fiche : les mineurs sont des notes du lot (design § 4). Maquette : inchangé (propositions du tableau final).
|
|
1785
|
-
addProposals(c, rep, r.kind, r.kind !== 'fiche');
|
|
1779
|
+
addProposals(c, rep, r.kind, summary.head, r.kind !== 'fiche');
|
|
1786
1780
|
// Un lot rendu sur le refus d'écrire ses notes (R26) rejoue cette revue à la reprise : le compte des passes n'est posé qu'une fois les notes écrites.
|
|
1787
1781
|
if (r.kind === 'fiche' && (await noteMinors(c, rep, `la ${r.label}`)))
|
|
1788
1782
|
return;
|
|
@@ -2515,7 +2509,7 @@ async function reviewStep(c, kind) {
|
|
|
2515
2509
|
const summary = summarize(rep, done.after.head ?? '', done.others.length ? Object.fromEntries(done.others.map((o) => [o.repo.rel, o.after.head ?? ''])) : undefined);
|
|
2516
2510
|
if (kind === 'ux') {
|
|
2517
2511
|
l.ux = summary;
|
|
2518
|
-
addProposals(c, rep, 'ux');
|
|
2512
|
+
addProposals(c, rep, 'ux', summary.head);
|
|
2519
2513
|
l.uxVerdict = summary.verdict;
|
|
2520
2514
|
l.constats = blockingConstats(rep, 'ux');
|
|
2521
2515
|
save(c);
|
|
@@ -2569,7 +2563,7 @@ async function reviewStep(c, kind) {
|
|
|
2569
2563
|
l.proposals = left;
|
|
2570
2564
|
l.minorLines = [];
|
|
2571
2565
|
}
|
|
2572
|
-
addProposals(c, rep, 'code', !minorPass);
|
|
2566
|
+
addProposals(c, rep, 'code', summary.head, !minorPass);
|
|
2573
2567
|
if (summary.conforme)
|
|
2574
2568
|
(l.reviewCounts ??= []).push(0); // une revue conforme compte pour 0 dans la comparaison de la convergence (L205)
|
|
2575
2569
|
if (minorPass) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { changedFiles, isAncestor, resolveCommit } from '../git.js';
|
|
2
|
+
/** Début d'une proposition de sous-tâche d'une revue (`addProposals`) : les seules marquées, un mineur étant déjà une ligne de fichier précise. */
|
|
3
|
+
const SUBTASK = '[sous-tâche ';
|
|
4
|
+
/** Les fichiers que le texte d'une proposition cite (`src/a.ts`, `src/a.ts:42`, `` `docs/z.md` ``), dans l'ordre, sans doublon ; les adresses web et les nombres (`1.5`) n'en sont pas. */
|
|
5
|
+
export function citedFiles(text) {
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const raw of text.split(/[\s`'"“”«»(),;*[\]<>]+/)) {
|
|
8
|
+
const tok = raw.replace(/(:\d+(-\d+)?)+$/, '').replace(/[.:!?]+$/, '').replace(/^(\.\/)+/, '');
|
|
9
|
+
if (!tok || tok.includes('://') || !/^[\p{L}\p{N}_@.\-/]+\.[A-Za-z][A-Za-z0-9]+$/u.test(tok) || out.includes(tok))
|
|
10
|
+
continue;
|
|
11
|
+
out.push(tok);
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* La lecture de git : parmi `commits` (sha du lot, du plus ancien au plus récent), ceux qui ne sont pas dans l'histoire de la revue
|
|
17
|
+
* `head`, puis le premier qui modifie `file` (chemin entier ou fin de chemin). Un `head` que le dépôt ne connaît pas, ou une
|
|
18
|
+
* lecture impossible, ne marque rien : mieux vaut une proposition sans marque qu'une marque fausse.
|
|
19
|
+
*/
|
|
20
|
+
export function touchedByLotCommits(commits) {
|
|
21
|
+
// Une lecture par lot et par commit pour tout le rendu : le tableau pose la question pour chaque couple (proposition, fichier cité).
|
|
22
|
+
const lotShas = new Map();
|
|
23
|
+
const files = new Map();
|
|
24
|
+
const filesOf = (repo, c) => {
|
|
25
|
+
const key = `${repo}\0${c}`;
|
|
26
|
+
if (!files.has(key)) {
|
|
27
|
+
try {
|
|
28
|
+
files.set(key, changedFiles(repo, c));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
files.set(key, null);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return files.get(key);
|
|
35
|
+
};
|
|
36
|
+
return (l, head, file) => {
|
|
37
|
+
if (!resolveCommit(l.repo, head))
|
|
38
|
+
return null;
|
|
39
|
+
if (!lotShas.has(l))
|
|
40
|
+
lotShas.set(l, commits(l));
|
|
41
|
+
for (const c of lotShas.get(l)) {
|
|
42
|
+
if (isAncestor(l.repo, c, head))
|
|
43
|
+
continue;
|
|
44
|
+
const changed = filesOf(l.repo, c);
|
|
45
|
+
if (!changed)
|
|
46
|
+
return null;
|
|
47
|
+
if (changed.some((f) => f === file || f.endsWith(`/${file}`)))
|
|
48
|
+
return c.slice(0, 7);
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* La ligne de proposition rendue au lead (L422) : une sous-tâche proposée dont un fichier cité a été modifié par un commit du lot
|
|
55
|
+
* postérieur à la revue qui l'a émise (`l.proposalHeads`) porte « — peut-être déjà traitée (<sha>) » ; les autres lignes sont inchangées.
|
|
56
|
+
* 28-10 : L230 ouvert sur des propositions déjà corrigées par la passe de correction, 5 passes perdues.
|
|
57
|
+
*/
|
|
58
|
+
export function proposalLine(l, proposal, touched) {
|
|
59
|
+
const head = l.proposalHeads?.[proposal];
|
|
60
|
+
if (!proposal.startsWith(SUBTASK) || !head)
|
|
61
|
+
return proposal;
|
|
62
|
+
for (const file of citedFiles(proposal)) {
|
|
63
|
+
let sha = null;
|
|
64
|
+
try {
|
|
65
|
+
sha = touched(l, head, file);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (sha)
|
|
71
|
+
return `${proposal} — peut-être déjà traitée (${sha})`;
|
|
72
|
+
}
|
|
73
|
+
return proposal;
|
|
74
|
+
}
|
|
75
|
+
/** La lecture à employer : celle que l'appelant donne (tests), sinon git sur les commits du lot (`lotCommits` de l'appelant, sinon `fromGit` : ceux des étapes). */
|
|
76
|
+
export const touchedReader = (deps, fromGit) => deps.touchedSince ?? touchedByLotCommits(deps.lotCommits ?? fromGit);
|
|
@@ -6,6 +6,7 @@ import { sharedStateDir } from '../state.js';
|
|
|
6
6
|
import { releaseLock, takeLock } from './lock.js';
|
|
7
7
|
import { mergeUnreleased, moveToUnreleased, settleLotEntries, unreleasedSections } from './changelog-merge.js';
|
|
8
8
|
import { mutexFor } from './mutex.js';
|
|
9
|
+
import { readPlanConfig } from '../config.js';
|
|
9
10
|
import { Plan } from '../plan.js';
|
|
10
11
|
import { parseDocument } from 'yaml';
|
|
11
12
|
import { git, gitFailure, tryGit } from './worktree.js';
|
|
@@ -91,11 +92,15 @@ function resolvePlan(work) {
|
|
|
91
92
|
return false;
|
|
92
93
|
const file = files[0];
|
|
93
94
|
try {
|
|
95
|
+
// Les champs d'extension déclarés (L464) sont des propriétés des lots : sans les réglages, `bare` ne verrait pas un champ changé.
|
|
96
|
+
// Seuls les champs d'extension sont passés : le `plan.onWrite` du projet ne doit pas se lancer pendant un rebase.
|
|
97
|
+
const extras = readPlanConfig(join(work, 'cadence.yaml'), work)?.settings.extras;
|
|
98
|
+
const settings = extras ? { extras } : {};
|
|
94
99
|
const stage = (n) => {
|
|
95
100
|
const text = run(work, 'show', `:${n}:${file}`);
|
|
96
101
|
writeFileSync(join(work, file), text);
|
|
97
102
|
const head = parseDocument(text).toJSON();
|
|
98
|
-
return { lots: Plan.load(join(work, file)).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
|
|
103
|
+
return { lots: Plan.load(join(work, file), settings).lots(), head: JSON.stringify({ ...(head ?? {}), lots: undefined }) };
|
|
99
104
|
};
|
|
100
105
|
const [b, o, t] = [stage(1), stage(2), stage(3)];
|
|
101
106
|
const [base, ours, theirs] = [b.lots, o.lots, t.lots];
|
|
@@ -109,7 +114,7 @@ function resolvePlan(work) {
|
|
|
109
114
|
if (added.some((t) => review(ours.find((o) => o.id === t.id)) !== review(base.find((b) => b.id === t.id))))
|
|
110
115
|
return false;
|
|
111
116
|
writeFileSync(join(work, file), run(work, 'show', `:2:${file}`));
|
|
112
|
-
const plan = Plan.load(join(work, file));
|
|
117
|
+
const plan = Plan.load(join(work, file), settings);
|
|
113
118
|
for (const t of added)
|
|
114
119
|
plan.recordReview(t.id, t.review.verdict, t.review.date, t.review.commit ?? null, t.review.repos, t.review.reporte);
|
|
115
120
|
plan.save();
|
|
@@ -38,7 +38,7 @@ function ordinaryBasis(estimate, stage) {
|
|
|
38
38
|
const basis = estimate <= 0.5 ? "plancher d'un lot jusqu'à 0,5 j" : estimate <= 1 ? "p90 mesuré du total d'un lot de 0,5 à 1 j" : Math.round(estimate * LOT_BUDGET_PER_DAY) <= LOT_BUDGET_ONE_DAY ? `plancher de ${LOT_BUDGET_ONE_DAY} au-delà d'un jour, l'estimate × ${LOT_BUDGET_PER_DAY} ne le dépasse pas encore` : `${LOT_BUDGET_PER_DAY} par jour, p90 mesuré du total au-delà d'un jour`;
|
|
39
39
|
if (!stage)
|
|
40
40
|
return basis;
|
|
41
|
-
return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`;
|
|
41
|
+
return `${ordinary} dérivés de l'estimate (${basis}), plus ${SPEC_STAGE_ALLOCATION} pour ${stage === 'light' ? 'les étapes spec et maquette' : `les étapes spec, ${stage === 'screen' ? 'maquette' : 'fiche'} et accept`}`;
|
|
42
42
|
}
|
|
43
43
|
/** Budget d'un lot, dérivé de son estimate et de sa catégorie : empêche quelques lots d'avaler le budget de la vague au détriment des autres. Un lot à `screen:` vaut le total mesuré d'un lot à écran (L194) ; un lot à `spec: true` y ajoute l'allocation de son étape (L191) ; `escalation` (orchestrate.escalation déclarée) y ajoute la part de l'escalade (L349). */
|
|
44
44
|
export const lotBudget = (estimate, stage, escalation = false) => lotBaseBudget(estimate, stage) + (escalation ? ESCALATION_RESERVE : 0);
|
|
@@ -46,5 +46,5 @@ const lotBaseBudget = (estimate, stage) => {
|
|
|
46
46
|
const ordinary = estimate <= 0.5 ? LOT_BUDGET_FLOOR : Math.max(LOT_BUDGET_ONE_DAY, Math.round(estimate * LOT_BUDGET_PER_DAY));
|
|
47
47
|
if (stage === 'screen')
|
|
48
48
|
return Math.max(SCREEN_LOT_BUDGET, ordinary + SPEC_STAGE_ALLOCATION);
|
|
49
|
-
return ordinary + (stage === 'spec' ? SPEC_STAGE_ALLOCATION : 0);
|
|
49
|
+
return ordinary + (stage === 'spec' || stage === 'light' ? SPEC_STAGE_ALLOCATION : 0);
|
|
50
50
|
};
|
|
@@ -15,6 +15,9 @@ export function maquetteText(o) {
|
|
|
15
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
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.`,
|
|
17
17
|
];
|
|
18
|
+
if (o.light) {
|
|
19
|
+
lines.push(`Light mockup (L459): the sheet covers only the states the lot touches. Sweep those states only, at ${LARGEURS.join(' and ')} px, and compare the target of each one to the capture of the current screen \`${folder}/actuel-<état>-<largeur>.png\` (a free file, not checked by the program; when one is missing the spec writer said so under "nonVerifie": judge the target against the code and the neighbouring pages). Report what makes the target worse than the current screen or inconsistent with the rest of the application, not the defects of the screen the lot does not touch.`);
|
|
20
|
+
}
|
|
18
21
|
if (later && o.previous?.length)
|
|
19
22
|
lines.push('', 'Points of the previous review that the spec writer had to fix:', ...bulletLines(o.previous));
|
|
20
23
|
return lines.join('\n');
|
package/dist/orchestrate/spec.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { isMap, isNode, isScalar, isSeq, parse, parseDocument, stringify } from 'yaml';
|
|
5
5
|
import { acceptationFile } from './accept.js';
|
|
6
6
|
import { checkFiche, fichesDuDossier, LARGEURS } from '../spec.js';
|
|
7
|
+
import { EXPRESS_MAX_ESTIMATE } from './express.js';
|
|
7
8
|
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
8
9
|
const list = (v) => (Array.isArray(v) ? v.filter(isObj) : []);
|
|
9
10
|
/** Chemin (relatif au dépôt) de la fiche du lot sous `<dir>/<écran ou sujet>/fiche.yaml` (`ecran.lot` ou `sujet.lot`), ou null : sert à ne pas rejouer une spec déjà écrite. */
|
|
@@ -24,6 +25,34 @@ export function specScreen(repo, dir, lot) {
|
|
|
24
25
|
const entered = fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && (doc.ecran.lot === lot.id || existsSync(join(repo, dir, name, acceptation))));
|
|
25
26
|
return entered ? lot.screen : undefined;
|
|
26
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Maquette légère (L459) : un lot visible, sans `spec: true`, d'estimate au-dessus de la retouche (voie express), dont le `screen:` du plan n'est pas `page`
|
|
30
|
+
* (`component`, `journey` ou absent) passe par spec → maquette avant le code, sans accept. Se déduit du plan à chaque appel, sans champ d'état :
|
|
31
|
+
* `orchestrate.maquette: false` la retire (la maquette légère est la revue de maquette, sans elle rien n'est à valider).
|
|
32
|
+
*/
|
|
33
|
+
export function lightMaquette(lot, maquette) {
|
|
34
|
+
return maquette && !!lot.visible && !lot.spec && typeof lot.estimate === 'number' && lot.estimate > EXPRESS_MAX_ESTIMATE && lot.screen !== 'page';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Le lot a une acceptation scellée (`acceptation-<lot>.json` à côté d'une fiche à bloc `ecran` de `dir`) : il est entré dans la chaîne complète
|
|
38
|
+
* avant L459 (spec → maquette → accept) et la garde, même s'il répond aujourd'hui à `lightMaquette` ; une maquette légère n'écrit jamais d'acceptation.
|
|
39
|
+
*/
|
|
40
|
+
export function sealedAcceptation(repo, dir, lot) {
|
|
41
|
+
const acceptation = acceptationFile('fiche.yaml', lot);
|
|
42
|
+
return fichesDuDossier(repo, dir).some(({ name, doc }) => isObj(doc.ecran) && existsSync(join(repo, dir, name, acceptation)));
|
|
43
|
+
}
|
|
44
|
+
/** Le lot est une maquette légère : son état a une étape spec (`screen` posé au lancement), son lot du plan répond à `lightMaquette` et il n'a pas d'acceptation scellée (`sealedAcceptation`). Relu du plan et du dépôt à chaque appel, aucun champ d'état. */
|
|
45
|
+
export const lightLot = (state, lot, maquette, repo, dir) => !!state.screen && lightMaquette(lot, maquette) && !sealedAcceptation(repo, dir, lot.id);
|
|
46
|
+
/** `screen` de l'état d'un lot à étape spec : celui de `specScreen`, ou, pour une maquette légère, celui du plan (`component` quand le plan n'en déclare pas : l'état ne sert alors qu'à dire « ce lot a une étape spec »). */
|
|
47
|
+
export function stageScreen(repo, dir, lot, maquette) {
|
|
48
|
+
return specScreen(repo, dir, lot) ?? (lightMaquette(lot, maquette) ? (lot.screen ?? 'component') : undefined);
|
|
49
|
+
}
|
|
50
|
+
/** Catégorie de budget d'un lot (`lotBudget`) : `screen` pour un écran page, `light` pour une maquette légère, `spec` pour un lot `spec: true` sans écran. */
|
|
51
|
+
export function budgetStage(lot, stateScreen, maquette, repo, dir) {
|
|
52
|
+
if (stateScreen)
|
|
53
|
+
return lightLot({ screen: stateScreen }, lot, maquette, repo, dir) ? 'light' : 'screen';
|
|
54
|
+
return lot.spec ? 'spec' : undefined;
|
|
55
|
+
}
|
|
27
56
|
/** Texte sans accents ni majuscules, pour les mots-clés. */
|
|
28
57
|
const plain = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
29
58
|
/**
|
|
@@ -267,20 +296,36 @@ export function hypothesisLines(doc) {
|
|
|
267
296
|
}
|
|
268
297
|
/** Texte de la note du lot pour une hypothèse (une note par règle). */
|
|
269
298
|
export const hypothesisNote = (sujet, line) => `Hypothèses (fiche ${sujet}) — ${line}`;
|
|
299
|
+
/** Consigne de rendu de la maquette : la commande que le projet déclare (`orchestrate.spec.render`), sinon Playwright. `use` : comment la reprendre. */
|
|
300
|
+
const renderLine = (render, use) => render
|
|
301
|
+
? `The project declares how to render mockups (\`orchestrate.spec.render\`): \`${render}\` (from the repository root). ${use}; fall back on Playwright only if it cannot run, and say so under "nonVerifie".`
|
|
302
|
+
: 'No render command is declared (`orchestrate.spec.render`): render with Playwright (open `maquette.html` as a local file, resize to each width, open each `#etat-<état>`).';
|
|
303
|
+
/** Consigne de la maquette d'un lot `screen: page` (ou déjà entré dans la chaîne complète). */
|
|
304
|
+
const pageLines = (o) => [
|
|
305
|
+
`The lot declares \`screen: ${o.screen}\`. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`,
|
|
306
|
+
`Render each state of the mockup at ${LARGEURS.map((w) => `${w}`).join(' and ')} px width: one capture per state and per width, named \`maquette-<état>-<largeur>.png\` (e.g. \`maquette-vide-1440.png\`).`,
|
|
307
|
+
renderLine(o.render, 'Use it'),
|
|
308
|
+
];
|
|
309
|
+
/** Consigne de la maquette légère (L459) : l'écran actuel et la version visée des seuls états touchés. */
|
|
310
|
+
const lightLines = (o) => [
|
|
311
|
+
`This lot is a visible lot that is not a whole page: write a light mockup, before any code. The specs of this project live in \`${o.dir}/<écran>/\` (one folder per screen, \`<écran>\` = the kebab-case \`ecran.id\` of the sheet).`,
|
|
312
|
+
'Cover only the states the lot touches (not the whole screen). For each touched state, capture the CURRENT screen of the application at each width, then the target version: the sheet and the mockup are limited to the states, fields and rules the lot changes.',
|
|
313
|
+
`Name the captures of the current screen \`actuel-<état>-<largeur>.png\` and those of the target \`maquette-<état>-<largeur>.png\`, one per touched state and per width (${LARGEURS.join(' and ')} px; e.g. \`actuel-vide-1440.png\`, \`maquette-vide-1440.png\`), in the folder of the sheet. The program checks the captures of the target, not the \`actuel-*\` ones.`,
|
|
314
|
+
'If the current screen cannot be captured (the application does not start, the page is protected), say so under "nonVerifie" and base the mockup on the code: never invent a capture of the current screen.',
|
|
315
|
+
'Each touched state is a section `id="etat-<état>"` of `maquette.html`, and each touched field carries a `data-testid`.',
|
|
316
|
+
renderLine(o.render, 'Use it for the target'),
|
|
317
|
+
];
|
|
270
318
|
/** Consigne propre à l'étape spec dans le brief : dossier, largeurs, commande de rendu, et les écarts de la passe précédente. */
|
|
271
319
|
export function specText(o) {
|
|
272
320
|
const lines = [];
|
|
273
|
-
if (o.screen)
|
|
274
|
-
lines.push(
|
|
275
|
-
? `The project declares how to render mockups (\`orchestrate.spec.render\`): \`${o.render}\` (from the repository root). Use it; fall back on Playwright only if it cannot run, and say so under "nonVerifie".`
|
|
276
|
-
: 'No render command is declared (`orchestrate.spec.render`): render with Playwright (open `maquette.html` as a local file, resize to each width, open each `#etat-<état>`).');
|
|
277
|
-
}
|
|
321
|
+
if (o.screen)
|
|
322
|
+
lines.push(...(o.light ? lightLines(o) : pageLines(o)));
|
|
278
323
|
if (o.back) {
|
|
279
324
|
lines.push(o.screen
|
|
280
325
|
? `The sheet has both parts, \`sujet\`, \`cas\`, \`contrats\`, \`donnees\` next to \`ecran\`, in the same folder (\`sujet.id\` = \`ecran.id\` = the folder name, \`sujet.lot\` = \`ecran.lot\`); the API calls of the screen are listed once in \`contrats\`, and the actions point to them with \`contrat: Ax\`.`
|
|
281
326
|
: `The lot declares \`spec: true\` (it changes a behaviour, with no screen). The specs of this project live in \`${o.dir}/<sujet>/\` (one folder per subject, \`<sujet>\` = the kebab-case \`sujet.id\` of the sheet); the sheet has no screen part.`);
|
|
282
327
|
}
|
|
283
|
-
if (o.screen && !o.back) {
|
|
328
|
+
if (o.screen && !o.back && !o.light) {
|
|
284
329
|
lines.push(`If the lot also changes a behaviour (an API call, a stored datum, a contract), add the back part to the same sheet: \`sujet\`, \`cas\`, \`contrats\`, \`donnees\` next to \`ecran\` (\`sujet.id\` = \`ecran.id\` = the folder name, \`sujet.lot\` = \`ecran.lot\`); the program then also has the sheet reviewed (rules sourced, contradictions, errors and limits, one test per rule), before the mockup. A lot that only draws a screen keeps a sheet without \`sujet\`.`);
|
|
285
330
|
}
|
|
286
331
|
if (o.template)
|