@sylad/cadence 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -54
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/precheck-reader.md +1 -1
- package/agents/qa-reviewer.md +2 -2
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +16 -4
- package/dist/check.js +24 -2
- package/dist/cli.js +258 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +24 -2
- package/dist/deliver.js +125 -37
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +45 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +46 -6
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +358 -41
- package/dist/orchestrate/cycle.js +922 -285
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +129 -0
- package/dist/orchestrate/launch.js +48 -6
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +146 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +54 -1
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +56 -15
- package/dist/orchestrate/table.js +74 -3
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +188 -25
- package/dist/plan.js +112 -7
- package/dist/proc.js +6 -0
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +129 -0
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +92 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +252 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +116 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +75 -7
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +76 -5
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +6 -6
- package/docs/lead.md +40 -13
- package/docs/news.md +30 -1
- package/docs/orchestrate.md +275 -33
- package/docs/overview.md +65 -1
- package/docs/perf.md +14 -8
- package/docs/raf.md +90 -11
- package/docs/ref.md +415 -0
- package/docs/releasing.md +3 -3
- package/docs/session.md +2 -2
- package/docs/skills.md +21 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +30 -1
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +28 -10
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +7 -4
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { RafError } from '../plan.js';
|
|
3
|
+
/** Les onze types d'élément d'un inventaire (design §2), dans l'ordre du résumé. */
|
|
4
|
+
export const TYPES = ['ecran', 'composant', 'etat', 'texte', 'jeton', 'table', 'gabarit', 'schema', 'lien', 'statut', 'silence'];
|
|
5
|
+
const CONFIANCES = ['mesure', 'lu', 'incertain'];
|
|
6
|
+
const CATEGORIES = ['couleur', 'taille_police', 'espacement', 'rayon', 'opacite'];
|
|
7
|
+
const DIRECTIONS = ['de_vers', 'bidirectionnel'];
|
|
8
|
+
const CONSTATS = ['confirme', 'precise', 'contredit'];
|
|
9
|
+
/** Le vocabulaire fermé des rôles d'un `texte` et d'un `composant` : le rôle décide de l'identité et de la justesse d'un élément, donc il est écrit ici, une ligne par rôle (repris dans templates/inventaire.json et docs/ref.md). Fermée mais large : celle de la conception (titre, bouton, message_erreur…), celle du corpus et celle d'un écran réel. */
|
|
10
|
+
export const ROLES = {
|
|
11
|
+
titre: "titre d'un document, d'une page ou d'une section",
|
|
12
|
+
'titre-panneau': "titre d'un panneau ou d'une fenêtre d'interface",
|
|
13
|
+
paragraphe: "phrase ou bloc de texte courant",
|
|
14
|
+
legende: "texte qui désigne une figure, un tableau ou une échelle de couleurs (« Figure 1 — … »)",
|
|
15
|
+
'etiquette-figure': "mot ou valeur écrit dans une figure (flèche, axe, courbe, seuil)",
|
|
16
|
+
exigence: "énoncé d'une exigence dans une liste d'exigences",
|
|
17
|
+
ligne: "ligne d'un relevé ou d'un formulaire, sans autre fonction",
|
|
18
|
+
message_erreur: "message qui signale une erreur ou un refus à l'utilisateur",
|
|
19
|
+
unite: "unité accolée à une valeur (kt, hPa, m)",
|
|
20
|
+
info_bulle: "texte court qui apparaît au survol ou au focus d'un élément",
|
|
21
|
+
bouton: "contrôle sur lequel on agit (composant), ou son libellé (texte)",
|
|
22
|
+
champ: "zone où l'on saisit une valeur (composant), ou son libellé (texte)",
|
|
23
|
+
case_a_cocher: "contrôle à deux états, coché ou non, indépendant des autres",
|
|
24
|
+
selecteur: "contrôle qui fait choisir une valeur dans une liste fermée (liste déroulante, boutons radio)",
|
|
25
|
+
onglet: "entrée d'une barre qui bascule entre des vues de même niveau",
|
|
26
|
+
menu: "liste d'actions ou de destinations qui s'ouvre depuis un contrôle",
|
|
27
|
+
liste: "suite d'éléments de même nature affichés les uns sous les autres",
|
|
28
|
+
panneau: "zone d'interface qui regroupe des contrôles (composant)",
|
|
29
|
+
carte: "zone d'affichage cartographique ou graphique (composant)",
|
|
30
|
+
icone: "pictogramme sans texte qui représente une action ou un objet",
|
|
31
|
+
badge: "pastille ou marque qui qualifie un élément (statut, compteur)",
|
|
32
|
+
};
|
|
33
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
34
|
+
const isInt = (v, min) => typeof v === 'number' && Number.isInteger(v) && v >= min;
|
|
35
|
+
const isText = (v) => typeof v === 'string' && v.trim() !== '';
|
|
36
|
+
const isList = (v) => Array.isArray(v) && v.length > 0;
|
|
37
|
+
const isPct = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100;
|
|
38
|
+
const zoneOk = (z) => isObj(z) && isInt(z.x, 0) && isInt(z.y, 0) && isInt(z.l, 1) && isInt(z.h, 1);
|
|
39
|
+
/** Lit un fichier JSON ; absent, dossier, vide ou illisible (BOM compris) est une erreur d'usage (code 2), pas un écart. */
|
|
40
|
+
export function readInventaire(path) {
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
throw new RafError(`ref : fichier introuvable : ${path}`);
|
|
43
|
+
let text;
|
|
44
|
+
try {
|
|
45
|
+
text = readFileSync(path, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new RafError(`ref : ${path} n'est pas un fichier lisible`);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(text);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw new RafError(`ref : ${path} n'est pas du JSON lisible : ${e.message.split('\n')[0]}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Valide un inventaire ou une lecture (schéma version 1) : une ligne par écart, « chemin : raison ». Forme seulement, jamais la justesse des mesures. */
|
|
58
|
+
export function checkInventaire(root) {
|
|
59
|
+
if (!isObj(root))
|
|
60
|
+
return ['racine : un objet est attendu'];
|
|
61
|
+
const problems = [];
|
|
62
|
+
const bad = (path, reason) => problems.push(`${path} : ${reason}`);
|
|
63
|
+
if (root.version !== 1)
|
|
64
|
+
bad('version', '1 attendu');
|
|
65
|
+
if (root.genre !== 'inventaire' && root.genre !== 'lecture')
|
|
66
|
+
bad('genre', '« inventaire » ou « lecture » attendu');
|
|
67
|
+
const document = isObj(root.document) ? root.document : {};
|
|
68
|
+
const slug = document.slug;
|
|
69
|
+
if (typeof slug !== 'string' || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug))
|
|
70
|
+
bad('document.slug', 'kebab-case attendu');
|
|
71
|
+
if (typeof document.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(document.sha256))
|
|
72
|
+
bad('document.sha256', '64 caractères hexadécimaux minuscules attendus');
|
|
73
|
+
if (!isList(root.elements)) {
|
|
74
|
+
bad('elements', 'un tableau non vide est attendu');
|
|
75
|
+
return problems;
|
|
76
|
+
}
|
|
77
|
+
const elements = root.elements;
|
|
78
|
+
const typeOf = new Map();
|
|
79
|
+
const firstWith = new Map();
|
|
80
|
+
elements.forEach((e, i) => {
|
|
81
|
+
if (!isObj(e) || typeof e.id !== 'string')
|
|
82
|
+
return;
|
|
83
|
+
if (!firstWith.has(e.id)) {
|
|
84
|
+
firstWith.set(e.id, i);
|
|
85
|
+
if (typeof e.type === 'string')
|
|
86
|
+
typeOf.set(e.id, e.type);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
elements.forEach((e, i) => {
|
|
90
|
+
const at = `elements[${i}]`;
|
|
91
|
+
if (!isObj(e))
|
|
92
|
+
return bad(`${at}.type`, "l'un des onze types attendu");
|
|
93
|
+
const type = typeof e.type === 'string' && TYPES.includes(e.type) ? e.type : undefined;
|
|
94
|
+
if (type === undefined)
|
|
95
|
+
bad(`${at}.type`, "l'un des onze types attendu");
|
|
96
|
+
checkId(e, i, type, typeof slug === 'string' ? slug : undefined, firstWith, bad);
|
|
97
|
+
const champ = (path) => bad(path, `champ du type ${type ?? 'inconnu'} manquant ou invalide`);
|
|
98
|
+
checkSource(e, at, type, bad, champ);
|
|
99
|
+
checkConfiance(e, at, root.genre === 'lecture', bad, champ);
|
|
100
|
+
if (type !== undefined)
|
|
101
|
+
checkFields(e, at, type, (field) => bad(`${at}.${field}`, `champ du type ${type} manquant ou invalide`), bad);
|
|
102
|
+
});
|
|
103
|
+
elements.forEach((e, i) => {
|
|
104
|
+
if (isObj(e))
|
|
105
|
+
checkRefs(e, `elements[${i}]`, typeOf, firstWith, bad);
|
|
106
|
+
});
|
|
107
|
+
return problems;
|
|
108
|
+
}
|
|
109
|
+
function checkId(e, i, type, slug, firstWith, bad) {
|
|
110
|
+
const at = `elements[${i}].id`;
|
|
111
|
+
const id = e.id;
|
|
112
|
+
// le slug attendu est celui de document tel quel : invalide, il donne un écart de plus, pas un masquage
|
|
113
|
+
const ok = typeof id === 'string' && (slug !== undefined && type !== undefined ? id.startsWith(`${slug}#${type}-`) && /^[1-9]\d*$/.test(id.slice(`${slug}#${type}-`.length)) : /^[^#]+#[a-z]+-[1-9]\d*$/.test(id));
|
|
114
|
+
if (!ok)
|
|
115
|
+
bad(at, '<slug>#<type>-<n> attendu');
|
|
116
|
+
const first = typeof id === 'string' ? firstWith.get(id) : undefined;
|
|
117
|
+
if (first !== undefined && first !== i)
|
|
118
|
+
bad(at, `déjà porté par elements[${first}]`);
|
|
119
|
+
}
|
|
120
|
+
function checkSource(e, at, type, bad, champ) {
|
|
121
|
+
const source = isObj(e.source) ? e.source : {};
|
|
122
|
+
if (!isInt(source.page, 1))
|
|
123
|
+
champ(`${at}.source.page`);
|
|
124
|
+
// seul un silence peut n'avoir pas de zone ; s'il en porte une, elle est validée
|
|
125
|
+
if (!(type === 'silence' && source.zone === undefined) && !zoneOk(source.zone))
|
|
126
|
+
bad(`${at}.source.zone`, 'x, y, l, h entiers attendus (l et h ≥ 1)');
|
|
127
|
+
if (e.citations !== undefined) {
|
|
128
|
+
if (!Array.isArray(e.citations))
|
|
129
|
+
return champ(`${at}.citations`);
|
|
130
|
+
e.citations.forEach((c, j) => {
|
|
131
|
+
const o = isObj(c) ? c : {};
|
|
132
|
+
if (!isInt(o.page, 1))
|
|
133
|
+
champ(`${at}.citations[${j}].page`);
|
|
134
|
+
if (!isInt(o.ligne, 1))
|
|
135
|
+
champ(`${at}.citations[${j}].ligne`);
|
|
136
|
+
if (!isText(o.extrait))
|
|
137
|
+
champ(`${at}.citations[${j}].extrait`);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function checkConfiance(e, at, lecture, bad, champ) {
|
|
142
|
+
const confiance = e.confiance;
|
|
143
|
+
if (typeof confiance !== 'string' || !CONFIANCES.includes(confiance))
|
|
144
|
+
bad(`${at}.confiance`, 'mesure, lu ou incertain attendu');
|
|
145
|
+
if (e.ocr !== undefined && typeof e.ocr !== 'boolean')
|
|
146
|
+
champ(`${at}.ocr`);
|
|
147
|
+
if (confiance === 'incertain') {
|
|
148
|
+
if (!isText(e.cause))
|
|
149
|
+
bad(`${at}.cause`, 'obligatoire pour un élément incertain');
|
|
150
|
+
}
|
|
151
|
+
else if (e.cause !== undefined && CONFIANCES.includes(confiance)) {
|
|
152
|
+
champ(`${at}.cause`);
|
|
153
|
+
}
|
|
154
|
+
if (lecture && e.ocr === true && confiance === 'lu')
|
|
155
|
+
bad(`${at}.confiance`, "un texte ocr n'est jamais lu dans une lecture");
|
|
156
|
+
}
|
|
157
|
+
/** Les champs propres au type : `fail(chemin)` écrit l'écart « champ du type … manquant ou invalide » ; `bad` sert aux raisons propres. */
|
|
158
|
+
function checkFields(e, at, type, fail, bad) {
|
|
159
|
+
const need = (field, ok) => {
|
|
160
|
+
if (!ok)
|
|
161
|
+
fail(field);
|
|
162
|
+
};
|
|
163
|
+
const sub = (v) => (isObj(v) ? v : {});
|
|
164
|
+
switch (type) {
|
|
165
|
+
case 'ecran': {
|
|
166
|
+
need('largeur_reference', isInt(e.largeur_reference, 1));
|
|
167
|
+
if (!isList(e.zones))
|
|
168
|
+
return fail('zones');
|
|
169
|
+
e.zones.forEach((z, j) => {
|
|
170
|
+
const o = sub(z);
|
|
171
|
+
const p = `zones[${j}]`;
|
|
172
|
+
need(`${p}.nom`, isText(o.nom));
|
|
173
|
+
for (const k of ['x', 'y'])
|
|
174
|
+
need(`${p}.${k}`, isInt(o[k], 0));
|
|
175
|
+
for (const k of ['l', 'h'])
|
|
176
|
+
need(`${p}.${k}`, isInt(o[k], 1));
|
|
177
|
+
if (!isObj(o.pct))
|
|
178
|
+
return fail(`${p}.pct`);
|
|
179
|
+
for (const k of ['x', 'y', 'l', 'h'])
|
|
180
|
+
need(`${p}.pct.${k}`, isPct(o.pct[k]));
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
case 'composant': {
|
|
185
|
+
need('role', isText(e.role));
|
|
186
|
+
checkRole(e, at, bad);
|
|
187
|
+
const position = sub(e.position);
|
|
188
|
+
const taille = sub(e.taille);
|
|
189
|
+
for (const k of ['x', 'y'])
|
|
190
|
+
need(`position.${k}`, isInt(position[k], 0));
|
|
191
|
+
for (const k of ['l', 'h'])
|
|
192
|
+
need(`taille.${k}`, isInt(taille[k], 1));
|
|
193
|
+
need('contenu', typeof e.contenu === 'string');
|
|
194
|
+
need('ecran', typeof e.ecran === 'string');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
case 'etat':
|
|
198
|
+
need('nom', isText(e.nom));
|
|
199
|
+
need('cible', typeof e.cible === 'string');
|
|
200
|
+
return;
|
|
201
|
+
case 'texte':
|
|
202
|
+
// un libellé recopié tel quel n'a ni blanc en tête ni blanc en fin (R33)
|
|
203
|
+
need('libelle', isText(e.libelle) && e.libelle === e.libelle.trim());
|
|
204
|
+
need('role', isText(e.role));
|
|
205
|
+
checkRole(e, at, bad);
|
|
206
|
+
return;
|
|
207
|
+
case 'jeton': {
|
|
208
|
+
need('categorie', typeof e.categorie === 'string' && CATEGORIES.includes(e.categorie));
|
|
209
|
+
need('valeur', isText(e.valeur) || (typeof e.valeur === 'number' && Number.isFinite(e.valeur)));
|
|
210
|
+
const point = e.point;
|
|
211
|
+
if (e.confiance === 'mesure' || point !== undefined) {
|
|
212
|
+
const carre = isObj(point) && isInt(point.x, 0) && isInt(point.y, 0);
|
|
213
|
+
need('point', carre && (point.l === undefined && point.h === undefined ? true : zoneOk(point)));
|
|
214
|
+
}
|
|
215
|
+
if (e.contraste !== undefined) {
|
|
216
|
+
const c = sub(e.contraste);
|
|
217
|
+
need('contraste.texte', isText(c.texte));
|
|
218
|
+
need('contraste.fond', isText(c.fond));
|
|
219
|
+
need('contraste.ratio', typeof c.ratio === 'number' && c.ratio >= 1 && c.ratio <= 21);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
case 'table': {
|
|
224
|
+
need('colonnes', isList(e.colonnes) && e.colonnes.every((c) => typeof c === 'string'));
|
|
225
|
+
if (!isList(e.lignes))
|
|
226
|
+
return fail('lignes');
|
|
227
|
+
const largeur = Array.isArray(e.colonnes) ? e.colonnes.length : -1;
|
|
228
|
+
e.lignes.forEach((l, j) => need(`lignes[${j}]`, Array.isArray(l) && l.length === largeur && l.every((c) => typeof c === 'string')));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
case 'gabarit': {
|
|
232
|
+
need('nom', isText(e.nom));
|
|
233
|
+
if (!isList(e.champs))
|
|
234
|
+
return fail('champs');
|
|
235
|
+
e.champs.forEach((c, j) => {
|
|
236
|
+
const o = sub(c);
|
|
237
|
+
const p = `champs[${j}]`;
|
|
238
|
+
need(`${p}.nom`, isText(o.nom));
|
|
239
|
+
need(`${p}.obligatoire`, typeof o.obligatoire === 'boolean');
|
|
240
|
+
need(`${p}.format`, isText(o.format));
|
|
241
|
+
const num = (v) => v === undefined || (typeof v === 'number' && Number.isFinite(v));
|
|
242
|
+
need(`${p}.min`, num(o.min) && !(typeof o.min === 'number' && typeof o.max === 'number' && o.min > o.max));
|
|
243
|
+
need(`${p}.max`, num(o.max));
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case 'schema': {
|
|
248
|
+
const ids = new Set();
|
|
249
|
+
if (!isList(e.noeuds))
|
|
250
|
+
fail('noeuds');
|
|
251
|
+
else
|
|
252
|
+
e.noeuds.forEach((n, j) => {
|
|
253
|
+
const o = sub(n);
|
|
254
|
+
need(`noeuds[${j}].id`, isText(o.id) && !ids.has(o.id));
|
|
255
|
+
if (isText(o.id))
|
|
256
|
+
ids.add(o.id);
|
|
257
|
+
need(`noeuds[${j}].libelle`, isText(o.libelle));
|
|
258
|
+
});
|
|
259
|
+
if (!Array.isArray(e.flux))
|
|
260
|
+
return fail('flux');
|
|
261
|
+
e.flux.forEach((f, j) => {
|
|
262
|
+
const o = sub(f);
|
|
263
|
+
need(`flux[${j}].de`, typeof o.de === 'string');
|
|
264
|
+
need(`flux[${j}].vers`, typeof o.vers === 'string');
|
|
265
|
+
need(`flux[${j}].libelle`, isText(o.libelle));
|
|
266
|
+
need(`flux[${j}].direction`, typeof o.direction === 'string' && DIRECTIONS.includes(o.direction));
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case 'lien': {
|
|
271
|
+
need('figure', isText(e.figure));
|
|
272
|
+
if (!isObj(e.passage))
|
|
273
|
+
fail('passage');
|
|
274
|
+
else {
|
|
275
|
+
need('passage.page', isInt(e.passage.page, 1));
|
|
276
|
+
need('passage.ligne', isInt(e.passage.ligne, 1));
|
|
277
|
+
need('passage.extrait', isText(e.passage.extrait));
|
|
278
|
+
}
|
|
279
|
+
if (typeof e.constat !== 'string' || !CONSTATS.includes(e.constat))
|
|
280
|
+
return fail('constat');
|
|
281
|
+
if (e.constat === 'precise')
|
|
282
|
+
for (const k of ['lecture_ambigue', 'interpretation', 'raison'])
|
|
283
|
+
need(k, isText(e[k]));
|
|
284
|
+
if (e.constat === 'contredit') {
|
|
285
|
+
need('enonces', Array.isArray(e.enonces) && e.enonces.length === 2 && e.enonces.every(isText));
|
|
286
|
+
if (e.interpretation !== undefined)
|
|
287
|
+
bad(`${at}.interpretation`, 'un lien contredit ne tranche pas');
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
case 'statut':
|
|
292
|
+
need('libelle', isText(e.libelle));
|
|
293
|
+
need('couvre', isList(e.couvre) && e.couvre.every((c) => typeof c === 'string'));
|
|
294
|
+
return;
|
|
295
|
+
case 'silence':
|
|
296
|
+
// rédigée en question : elle se termine par « ? » (R35)
|
|
297
|
+
need('question', isText(e.question) && e.question.trimEnd().endsWith('?'));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** Un rôle non vide hors de la liste est un écart de schéma, jamais une erreur de lecture mesurée plus tard. */
|
|
302
|
+
function checkRole(e, at, bad) {
|
|
303
|
+
if (isText(e.role) && !Object.hasOwn(ROLES, e.role))
|
|
304
|
+
bad(`${at}.role`, `« ${e.role} » n'est pas un rôle de la liste : ${Object.keys(ROLES).join(', ')}`);
|
|
305
|
+
}
|
|
306
|
+
/** Les références d'un élément, résolues sur la liste complète des ids (R27) et sur les noeuds du schéma (R23). */
|
|
307
|
+
function checkRefs(e, at, typeOf, ids, bad) {
|
|
308
|
+
const expect = (path, id, types) => {
|
|
309
|
+
if (typeof id === 'string' && !types.includes(typeOf.get(id) ?? ''))
|
|
310
|
+
bad(path, `${id} ne désigne aucun élément de type ${types.join(' ou ')}`);
|
|
311
|
+
};
|
|
312
|
+
if (e.type === 'composant')
|
|
313
|
+
expect(`${at}.ecran`, e.ecran, ['ecran']);
|
|
314
|
+
if (e.type === 'etat')
|
|
315
|
+
expect(`${at}.cible`, e.cible, ['composant', 'ecran']);
|
|
316
|
+
if (e.type === 'statut' && Array.isArray(e.couvre)) {
|
|
317
|
+
e.couvre.forEach((id, j) => {
|
|
318
|
+
if (typeof id === 'string' && !ids.has(id))
|
|
319
|
+
bad(`${at}.couvre[${j}]`, `${id} ne désigne aucun élément de type ${TYPES.slice(0, -1).join(', ')} ou ${TYPES[TYPES.length - 1]}`);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (e.type === 'schema' && Array.isArray(e.noeuds) && Array.isArray(e.flux)) {
|
|
323
|
+
const noeuds = new Set(e.noeuds.map((n) => (isObj(n) ? n.id : undefined)));
|
|
324
|
+
e.flux.forEach((f, j) => {
|
|
325
|
+
if (!isObj(f))
|
|
326
|
+
return;
|
|
327
|
+
for (const k of ['de', 'vers'])
|
|
328
|
+
if (typeof f[k] === 'string' && !noeuds.has(f[k]))
|
|
329
|
+
bad(`${at}.flux[${j}].${k}`, `${f[k]} ne désigne aucun élément de type noeud`);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** « inventaire valide : N éléments (ecran: n, …) », les types présents dans l'ordre des onze ; le genre dit « inventaire » ou « lecture ». */
|
|
334
|
+
export function summaryInventaire(root) {
|
|
335
|
+
const elements = root.elements;
|
|
336
|
+
const parts = TYPES.map((t) => [t, elements.filter((e) => e.type === t).length]).filter(([, n]) => n > 0).map(([t, n]) => `${t}: ${n}`);
|
|
337
|
+
return `${root.genre} valide : ${elements.length} éléments (${parts.join(', ')})`;
|
|
338
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { RafError } from '../plan.js';
|
|
6
|
+
import { AGENTS_DIR } from '../skills.js';
|
|
7
|
+
import { realClaude, readAgents } from '../orchestrate/launch.js';
|
|
8
|
+
import { add } from './add.js';
|
|
9
|
+
import { analyse } from './analyse.js';
|
|
10
|
+
import { checkInventaire, readInventaire, summaryInventaire } from './check.js';
|
|
11
|
+
import { buildCorpus } from './corpus.js';
|
|
12
|
+
import { diffLectures } from './diff.js';
|
|
13
|
+
import { chargerPlaywright, lancer, rendre } from './corpus-render.js';
|
|
14
|
+
import { evaluer, formatEvaluation } from './evaluate.js';
|
|
15
|
+
import { measure } from './measure.js';
|
|
16
|
+
import { referencesDir } from './store.js';
|
|
17
|
+
const USAGE_ADD = 'usage : cadence ref add <document.pdf> [--sections 3-5,9] [--slug nom]';
|
|
18
|
+
const USAGE_ANALYSE = 'usage : cadence ref analyse <document.pdf> [--sections 3-5,9] [--slug nom] [--model sonnet|opus|haiku] [--model-b sonnet|opus|haiku] [--seuil 30] [--double]';
|
|
19
|
+
const USAGE_CHECK = 'usage : cadence ref check <inventaire.json | lecture.json>';
|
|
20
|
+
const USAGE_DIFF = 'usage : cadence ref diff <lecture-a.json> <lecture-b.json> [--out inventaire.json] [--seuil 30]';
|
|
21
|
+
const USAGE_EVAL = 'usage : cadence ref eval <attendu.json | dossier> <lecture.json | dossier> [--json]';
|
|
22
|
+
const USAGE_CORPUS = 'usage : cadence ref corpus <dossier> [--sans-rendu]';
|
|
23
|
+
const USAGE_MEASURE = 'usage : cadence ref measure <slug> <page> <page | x,y | x,y,l,h> [--quoi couleurs|contraste|boites|texte] [--largeur 1440] [--json]';
|
|
24
|
+
/** `cadence ref <sous-verbe> …` : les documents de référence (issue #14) ; `check` valide un inventaire ou une lecture. */
|
|
25
|
+
export function refCommand(rest, values, root, io) {
|
|
26
|
+
const [verb, ...args] = rest;
|
|
27
|
+
const configPath = resolve(io.cwd, values.config ?? join(root, 'cadence.yaml'));
|
|
28
|
+
const refsDir = () => referencesDir(root, configPath, io.env);
|
|
29
|
+
if (verb === 'add' && args.length === 1) {
|
|
30
|
+
add({ document: args[0], slug: values.slug, sections: values.sections, env: io.env, cwd: io.cwd, refsDir, now: io.now(), out: io.out });
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
if (verb === 'analyse' && args.length === 1) {
|
|
34
|
+
const claude = realClaude(io.env.CADENCE_CLAUDE_BIN || 'claude', io.env);
|
|
35
|
+
return analyse({ document: args[0], slug: values.slug, sections: values.sections, model: values.model, modelB: values['model-b'], double: values.double === true, seuil: values.seuil, env: io.env, cwd: io.cwd, refsDir, configPath, claudeHome: io.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), now: io.now(), out: io.out, err: io.err, claude, agents: readAgents(AGENTS_DIR) });
|
|
36
|
+
}
|
|
37
|
+
if (verb === 'measure' && args.length === 3) {
|
|
38
|
+
measure({ slug: args[0], page: args[1], zone: args[2], quoi: values.quoi, largeur: values.largeur, env: io.env, refsDir, json: values.json === true, out: io.out });
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
if (verb === 'check' && args.length === 1) {
|
|
42
|
+
// pas d'outil système ni de references.dir : seul le fichier donné est lu (R30)
|
|
43
|
+
const inventaire = readInventaire(resolve(io.cwd, args[0]));
|
|
44
|
+
const problems = checkInventaire(inventaire);
|
|
45
|
+
for (const m of problems)
|
|
46
|
+
io.out(`✗ ${m}`);
|
|
47
|
+
if (problems.length > 0) {
|
|
48
|
+
io.out(`${problems.length} écart(s)`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
io.out(summaryInventaire(inventaire));
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (verb === 'diff' && args.length === 2) {
|
|
55
|
+
return diffLectures({ a: resolve(io.cwd, args[0]), b: resolve(io.cwd, args[1]), seuil: values.seuil, out: values.out === undefined ? undefined : resolve(io.cwd, values.out), log: io.out });
|
|
56
|
+
}
|
|
57
|
+
if (verb === 'eval' && args.length === 2) {
|
|
58
|
+
const ev = evaluer(resolve(io.cwd, args[0]), resolve(io.cwd, args[1]));
|
|
59
|
+
if (values.json === true)
|
|
60
|
+
io.out(JSON.stringify(ev, null, 2));
|
|
61
|
+
else
|
|
62
|
+
for (const l of formatEvaluation(ev))
|
|
63
|
+
io.out(l);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
if (verb === 'corpus' && args.length === 1)
|
|
67
|
+
return corpus(resolve(io.cwd, args[0]), values['sans-rendu'] === true, io);
|
|
68
|
+
const others = (u) => u.replace('usage : ', '');
|
|
69
|
+
throw new RafError(verb === 'add' ? USAGE_ADD : verb === 'analyse' ? USAGE_ANALYSE : verb === 'measure' ? USAGE_MEASURE : verb === 'check' ? USAGE_CHECK : verb === 'diff' ? USAGE_DIFF : verb === 'eval' ? USAGE_EVAL : verb === 'corpus' ? USAGE_CORPUS : `${USAGE_ADD}\n ${others(USAGE_ANALYSE)}\n ${others(USAGE_MEASURE)}\n ${others(USAGE_CHECK)}\n ${others(USAGE_DIFF)}\n ${others(USAGE_EVAL)}\n ${others(USAGE_CORPUS)}`);
|
|
70
|
+
}
|
|
71
|
+
/** `ref corpus <dossier>` : un sous-dossier par piège (document.html, attendu.json, puis document.pdf et page-<n>.png sauf --sans-rendu). */
|
|
72
|
+
async function corpus(dossier, sansRendu, io) {
|
|
73
|
+
if (existsSync(dossier))
|
|
74
|
+
throw new RafError(`ref corpus : ${dossier} existe déjà (un corpus n'est jamais mêlé à un autre : choisissez un dossier neuf)`);
|
|
75
|
+
const navigateur = sansRendu ? null : await lancer(chargerPlaywright(io.env, io.cwd));
|
|
76
|
+
const cas = buildCorpus();
|
|
77
|
+
for (const c of cas) {
|
|
78
|
+
mkdirSync(join(dossier, c.nom), { recursive: true });
|
|
79
|
+
writeFileSync(join(dossier, c.nom, 'document.html'), c.html);
|
|
80
|
+
writeFileSync(join(dossier, c.nom, 'attendu.json'), `${JSON.stringify(c.inventaire, null, 2)}\n`);
|
|
81
|
+
}
|
|
82
|
+
if (navigateur) {
|
|
83
|
+
await rendre(navigateur, dossier, cas);
|
|
84
|
+
// `ref add` empreint le PDF, qui change à chaque rendu : l'attendu porte celle du PDF écrit
|
|
85
|
+
for (const c of cas) {
|
|
86
|
+
c.inventaire.document.sha256 = createHash('sha256').update(readFileSync(join(dossier, c.nom, 'document.pdf'))).digest('hex');
|
|
87
|
+
writeFileSync(join(dossier, c.nom, 'attendu.json'), `${JSON.stringify(c.inventaire, null, 2)}\n`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
io.out(`corpus : ${cas.length} cas, ${cas.reduce((n, c) => n + c.pages, 0)} pages, ${cas.reduce((n, c) => n + c.inventaire.elements.length, 0)} éléments attendus dans ${dossier}${sansRendu ? ' (sans rendu : ni PDF ni PNG)' : ''}`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { RafError } from '../plan.js';
|
|
5
|
+
/** 816×1056 px CSS = 8,5×11 in ; à 200 dpi, 1700×2200 px : le repère des zones de l'inventaire. */
|
|
6
|
+
const ECHELLE = 200 / 96;
|
|
7
|
+
const BRUIT = `<svg style="position:absolute" width="0" height="0"><filter id="bruit" x="0" y="0" width="100%" height="100%">
|
|
8
|
+
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" result="t"/>
|
|
9
|
+
<feColorMatrix in="t" type="matrix" values="0 0 0 0 .25 0 0 0 0 .25 0 0 0 0 .25 1.2 0 0 0 -.38" result="grain"/>
|
|
10
|
+
<feMerge><feMergeNode in="SourceGraphic"/><feMergeNode in="grain"/></feMerge></filter></svg>`;
|
|
11
|
+
/** Playwright, d'où `CADENCE_PLAYWRIGHT` (dossier du paquet) le désigne, sinon depuis le dossier courant : le dépôt n'en dépend pas. */
|
|
12
|
+
export function chargerPlaywright(env, cwd) {
|
|
13
|
+
const aide = 'installez-le (npm i -D playwright, puis npx playwright install chromium) ou pointez CADENCE_PLAYWRIGHT vers le dossier du paquet playwright, ou ajoutez --sans-rendu';
|
|
14
|
+
const cible = env.CADENCE_PLAYWRIGHT;
|
|
15
|
+
try {
|
|
16
|
+
return createRequire(join(cwd, 'noop.js'))(cible ?? 'playwright');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new RafError(cible ? `ref corpus : CADENCE_PLAYWRIGHT (${cible}) ne désigne pas un paquet playwright lisible : ${aide}` : `ref corpus : Playwright est introuvable : ${aide}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Lance Chromium : un navigateur absent ou d'une autre version est un refus, avant que le moindre fichier soit écrit. */
|
|
23
|
+
export async function lancer(playwright) {
|
|
24
|
+
try {
|
|
25
|
+
return await playwright.chromium.launch();
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
throw new RafError(`ref corpus : Chromium ne se lance pas (${e.message.split('\n')[0]}) : installez-le avec npx playwright install chromium, ou ajoutez --sans-rendu`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Rend chaque cas dans `<dossier>/<nom>/` (le HTML et l'attendu y sont déjà), puis ferme le navigateur. */
|
|
32
|
+
export async function rendre(navigateur, dossier, cas) {
|
|
33
|
+
try {
|
|
34
|
+
for (const c of cas)
|
|
35
|
+
await rendreCas(navigateur, join(dossier, c.nom), c);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await navigateur.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Écrit `document.pdf` et `page-<n>.png` (1700×2200) d'un cas : tables en image rastérisées, scan rastérisé puis bruité. */
|
|
42
|
+
async function rendreCas(navigateur, dossier, cas) {
|
|
43
|
+
const contexte = await navigateur.newContext({ viewport: { width: 816, height: 1056 }, deviceScaleFactor: ECHELLE });
|
|
44
|
+
try {
|
|
45
|
+
const page = await contexte.newPage();
|
|
46
|
+
await page.setContent(cas.html);
|
|
47
|
+
for (const el of await page.locator('.raster').all()) {
|
|
48
|
+
const src = `data:image/png;base64,${(await el.screenshot()).toString('base64')}`;
|
|
49
|
+
await el.evaluate((n, s) => {
|
|
50
|
+
const r = n.getBoundingClientRect();
|
|
51
|
+
n.outerHTML = `<img src="${s}" style="display:block;width:${r.width}px;height:${r.height}px">`;
|
|
52
|
+
}, src);
|
|
53
|
+
}
|
|
54
|
+
if (cas.scan) {
|
|
55
|
+
await page.evaluate((bruit) => {
|
|
56
|
+
document.body.insertAdjacentHTML('afterbegin', bruit);
|
|
57
|
+
document.querySelectorAll('.page').forEach((p) => {
|
|
58
|
+
p.innerHTML = `<div style="position:relative;width:816px;height:1056px;transform:rotate(0.6deg)">${p.innerHTML}</div>`;
|
|
59
|
+
p.style.filter = 'url(#bruit) grayscale(1) blur(0.6px) contrast(1.15)';
|
|
60
|
+
});
|
|
61
|
+
}, BRUIT);
|
|
62
|
+
}
|
|
63
|
+
const sections = page.locator('.page');
|
|
64
|
+
const prises = [];
|
|
65
|
+
for (let i = 0; i < cas.pages; i++)
|
|
66
|
+
prises.push(await sections.nth(i).screenshot());
|
|
67
|
+
if (cas.scan) {
|
|
68
|
+
// le PDF d'un scan ne porte que l'image de chaque page, jamais la couche texte
|
|
69
|
+
await page.evaluate((images) => {
|
|
70
|
+
document.querySelectorAll('.page').forEach((p, i) => {
|
|
71
|
+
p.style.cssText = '';
|
|
72
|
+
p.innerHTML = `<img src="${images[i]}" style="display:block;width:816px;height:1056px">`;
|
|
73
|
+
});
|
|
74
|
+
}, prises.map((b) => `data:image/png;base64,${b.toString('base64')}`));
|
|
75
|
+
}
|
|
76
|
+
mkdirSync(dossier, { recursive: true });
|
|
77
|
+
prises.forEach((b, i) => writeFileSync(join(dossier, `page-${i + 1}.png`), b));
|
|
78
|
+
writeFileSync(join(dossier, 'document.pdf'), await page.pdf({ width: '8.5in', height: '11in', printBackground: true, margin: { top: '0', right: '0', bottom: '0', left: '0' } }));
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await contexte.close();
|
|
82
|
+
}
|
|
83
|
+
}
|