@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.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +27 -54
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/precheck-reader.md +1 -1
  7. package/agents/qa-reviewer.md +2 -2
  8. package/agents/reference-analyst.md +25 -0
  9. package/agents/reference-verifier.md +19 -0
  10. package/agents/spec-writer.md +1 -1
  11. package/agents/ux-reviewer.md +1 -1
  12. package/bin/cadence.js +9 -1
  13. package/dist/audit.js +16 -4
  14. package/dist/check.js +24 -2
  15. package/dist/cli.js +258 -30
  16. package/dist/config.js +101 -5
  17. package/dist/corrections.js +24 -2
  18. package/dist/deliver.js +125 -37
  19. package/dist/docsync.js +19 -3
  20. package/dist/git.js +16 -3
  21. package/dist/lead.js +45 -12
  22. package/dist/migrate.js +166 -0
  23. package/dist/orchestrate/briefs.js +46 -6
  24. package/dist/orchestrate/changelog-merge.js +94 -0
  25. package/dist/orchestrate/command.js +358 -41
  26. package/dist/orchestrate/cycle.js +922 -285
  27. package/dist/orchestrate/guard.js +2 -2
  28. package/dist/orchestrate/integrate.js +129 -0
  29. package/dist/orchestrate/launch.js +48 -6
  30. package/dist/orchestrate/mutex.js +30 -0
  31. package/dist/orchestrate/no-test.js +41 -0
  32. package/dist/orchestrate/pool.js +74 -36
  33. package/dist/orchestrate/registry.js +146 -14
  34. package/dist/orchestrate/result.js +1 -1
  35. package/dist/orchestrate/snapshot.js +54 -1
  36. package/dist/orchestrate/spec.js +89 -5
  37. package/dist/orchestrate/state.js +56 -15
  38. package/dist/orchestrate/table.js +74 -3
  39. package/dist/orchestrate/timeouts.js +40 -0
  40. package/dist/orchestrate/worktree.js +368 -0
  41. package/dist/perf.js +188 -25
  42. package/dist/plan.js +112 -7
  43. package/dist/proc.js +6 -0
  44. package/dist/ready-free.js +109 -0
  45. package/dist/ref/add.js +129 -0
  46. package/dist/ref/analyse.js +220 -0
  47. package/dist/ref/check.js +338 -0
  48. package/dist/ref/command.js +92 -0
  49. package/dist/ref/corpus-render.js +83 -0
  50. package/dist/ref/corpus.js +201 -0
  51. package/dist/ref/diff.js +262 -0
  52. package/dist/ref/evaluate.js +241 -0
  53. package/dist/ref/figures.js +252 -0
  54. package/dist/ref/measure.js +330 -0
  55. package/dist/ref/pdf.js +116 -0
  56. package/dist/ref/png.js +125 -0
  57. package/dist/ref/regard.js +102 -0
  58. package/dist/ref/session.js +34 -0
  59. package/dist/ref/store.js +40 -0
  60. package/dist/ref/tools.js +51 -0
  61. package/dist/resources.js +51 -0
  62. package/dist/session.js +5 -2
  63. package/dist/state.js +75 -7
  64. package/dist/testgate.js +15 -0
  65. package/docs/cadence-hud.md +12 -0
  66. package/docs/cycle-vague-dark.svg +1 -1
  67. package/docs/cycle-vague-light.svg +1 -1
  68. package/docs/deliver.md +76 -5
  69. package/docs/fonctionnement-wave-dark.svg +1 -1
  70. package/docs/fonctionnement-wave-light.svg +1 -1
  71. package/docs/fonctionnement.md +6 -6
  72. package/docs/lead.md +40 -13
  73. package/docs/news.md +30 -1
  74. package/docs/orchestrate.md +275 -33
  75. package/docs/overview.md +65 -1
  76. package/docs/perf.md +14 -8
  77. package/docs/raf.md +90 -11
  78. package/docs/ref.md +415 -0
  79. package/docs/releasing.md +3 -3
  80. package/docs/session.md +2 -2
  81. package/docs/skills.md +21 -2
  82. package/docs/spec.md +1 -1
  83. package/docs/stabilite.md +4 -4
  84. package/docs/whats-new.md +30 -1
  85. package/package.json +2 -1
  86. package/skills/deliver/SKILL.md +6 -1
  87. package/skills/lead/SKILL.md +28 -10
  88. package/skills/session-close/SKILL.md +1 -1
  89. package/skills/session-start/SKILL.md +2 -2
  90. package/templates/inventaire.json +96 -0
  91. package/templates/orchestrate/accept-back.md +1 -0
  92. package/templates/orchestrate/accept.md +1 -0
  93. package/templates/orchestrate/fiche.md +1 -0
  94. package/templates/orchestrate/fix-docs.md +18 -0
  95. package/templates/orchestrate/fix-minors.md +2 -1
  96. package/templates/orchestrate/fix.md +5 -2
  97. package/templates/orchestrate/implement.md +7 -4
  98. package/templates/orchestrate/maquette.md +2 -1
  99. package/templates/orchestrate/precheck.md +2 -1
  100. package/templates/orchestrate/review-recheck.md +2 -0
  101. package/templates/orchestrate/review-small.md +5 -1
  102. package/templates/orchestrate/review.md +2 -0
  103. package/templates/orchestrate/spec-back.md +2 -1
  104. package/templates/orchestrate/spec.md +4 -3
  105. package/templates/orchestrate/ux.md +3 -1
@@ -0,0 +1,201 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Pixels CSS (96 dpi) → pixels de la page rendue à 200 dpi : le repère des zones d'un inventaire. */
3
+ const K = 200 / 96;
4
+ const zone = (b) => ({ x: Math.round(b.x * K), y: Math.round(b.y * K), l: Math.round(b.l * K), h: Math.round(b.h * K) });
5
+ const CSS = `@page{size:8.5in 11in;margin:0}html,body{margin:0}
6
+ .page{position:relative;width:816px;height:1056px;overflow:hidden;background:#fff;font-family:Arial,Helvetica,sans-serif;color:#111;page-break-after:always}
7
+ .page:last-child{page-break-after:auto}.b{position:absolute;box-sizing:border-box}
8
+ h1{margin:0;font-size:28px}p{margin:0;font-size:16px}table{border-collapse:collapse;font-size:15px}td,th{border:1px solid #333;padding:6px 10px;text-align:left}th{background:#e6e6e6}`;
9
+ const bloc = (b, contenu, style = '') => `<div class="b" style="left:${b.x}px;top:${b.y}px;width:${b.l}px;height:${b.h}px;${style}">${contenu}</div>`;
10
+ const assemble = (titre, pages) => `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>${titre}</title><style>${CSS}</style></head><body>\n${pages.map((p) => `<section class="page">\n${p}\n</section>`).join('\n')}\n</body></html>\n`;
11
+ /** Construit l'inventaire d'un cas : ids `corpus-<nom>#<type>-<n>` numérotés par type, zones en repère 200 dpi. */
12
+ class Inventaire {
13
+ nom;
14
+ n = new Map();
15
+ elements = [];
16
+ constructor(nom) {
17
+ this.nom = nom;
18
+ }
19
+ add(type, page, boite, champs, piege = false) {
20
+ const k = (this.n.get(type) ?? 0) + 1;
21
+ this.n.set(type, k);
22
+ const id = `corpus-${this.nom}#${type}-${k}`;
23
+ this.elements.push({ type, id, confiance: 'lu', source: boite ? { page, zone: zone(boite) } : { page }, ...champs, ...(piege ? { piege: this.nom } : {}) });
24
+ return id;
25
+ }
26
+ texte(page, boite, libelle, role, extra = {}, piege = false) {
27
+ return this.add('texte', page, boite, { libelle, role, ...extra }, piege);
28
+ }
29
+ /** Écran à la largeur de référence 1440 : la zone « page » couvre toute la page. */
30
+ ecran(page) {
31
+ return this.add('ecran', page, { x: 0, y: 0, l: 816, h: 1056 }, { largeur_reference: 1440, zones: [{ nom: 'page', x: 0, y: 0, l: 1440, h: 1863, pct: { x: 0, y: 0, l: 100, h: 100 } }] });
32
+ }
33
+ }
34
+ function fini(nom, titre, pages, inv, scan = false) {
35
+ const html = assemble(titre, pages);
36
+ const inventaire = {
37
+ version: 1,
38
+ genre: 'inventaire',
39
+ document: { slug: `corpus-${nom}`, sha256: createHash('sha256').update(html).digest('hex') },
40
+ pages: pages.length,
41
+ elements: inv.elements,
42
+ };
43
+ return { nom, html, pages: pages.length, scan, inventaire };
44
+ }
45
+ const TITRE = { x: 60, y: 50, l: 696, h: 40 };
46
+ const PARA = { x: 60, y: 110, l: 696, h: 24 };
47
+ const FIGURE = { x: 60, y: 170, l: 696, h: 300 };
48
+ const LEGENDE = { x: 60, y: 482, l: 696, h: 24 };
49
+ /** Texte ambigu que seule la figure lève : « à côté de la flèche » (tête ou queue ?) ; la figure met l'étiquette à la queue. */
50
+ function texteAmbigu() {
51
+ const nom = 'texte-ambigu-leve-par-figure';
52
+ const inv = new Inventaire(nom);
53
+ const legende = 'Figure 1 — Jet arrow with its speed label';
54
+ const phrase = 'Wind speed is written beside the arrow, in knots (Figure 1).';
55
+ const svg = `<svg width="696" height="300" viewBox="0 0 696 300" xmlns="http://www.w3.org/2000/svg" font-family="Arial,Helvetica,sans-serif">
56
+ <defs><marker id="t" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#111"/></marker></defs>
57
+ <line x1="90" y1="160" x2="600" y2="160" stroke="#111" stroke-width="6" marker-end="url(#t)"/>
58
+ <text x="90" y="125" font-size="26" font-weight="bold">120 KT</text><text x="90" y="205" font-size="16">tail</text><text x="560" y="205" font-size="16">head</text></svg>`;
59
+ inv.texte(1, TITRE, 'Jet arrow annotation', 'titre');
60
+ inv.texte(1, PARA, phrase, 'paragraphe');
61
+ inv.texte(1, { x: 150, y: 270, l: 90, h: 34 }, '120 KT', 'etiquette-figure');
62
+ inv.texte(1, { x: 148, y: 358, l: 26, h: 24 }, 'tail', 'etiquette-figure');
63
+ inv.texte(1, { x: 618, y: 358, l: 40, h: 24 }, 'head', 'etiquette-figure');
64
+ inv.texte(1, LEGENDE, legende, 'legende');
65
+ inv.add('lien', 1, FIGURE, {
66
+ figure: legende,
67
+ passage: { page: 1, ligne: 2, extrait: 'Wind speed is written beside the arrow' },
68
+ constat: 'precise',
69
+ lecture_ambigue: 'À côté de la flèche : à la tête ou à la queue ?',
70
+ interpretation: "L'étiquette de vitesse se place au-dessus de la queue de la flèche",
71
+ raison: 'Dans la Figure 1, « 120 KT » est écrit au-dessus de la queue (tail), pas de la tête (head)',
72
+ }, true);
73
+ return fini(nom, 'Jet arrow annotation', [bloc(TITRE, '<h1>Jet arrow annotation</h1>') + bloc(PARA, `<p>${phrase}</p>`) + bloc(FIGURE, svg) + bloc(LEGENDE, `<p>${legende}</p>`)], inv);
74
+ }
75
+ /** Figure qui contredit le texte : le texte donne 80 kt, la figure trace le seuil à 100 kt. */
76
+ function figureContredit() {
77
+ const nom = 'figure-contredit-texte';
78
+ const inv = new Inventaire(nom);
79
+ const legende = 'Figure 1 — Reporting threshold';
80
+ const phrase = 'Turbulence is reported above 80 kt of jet speed (Figure 1).';
81
+ const svg = `<svg width="696" height="300" viewBox="0 0 696 300" xmlns="http://www.w3.org/2000/svg" font-family="Arial,Helvetica,sans-serif">
82
+ <rect x="90" y="190" width="420" height="60" fill="#9bb7d4"/><rect x="90" y="110" width="420" height="4" fill="#c0392b"/>
83
+ <text x="520" y="120" font-size="22" font-weight="bold">100 kt</text><text x="90" y="280" font-size="18">Jet speed</text></svg>`;
84
+ inv.texte(1, TITRE, 'Turbulence reporting', 'titre');
85
+ inv.texte(1, PARA, phrase, 'paragraphe');
86
+ inv.texte(1, { x: 578, y: 268, l: 68, h: 30 }, '100 kt', 'etiquette-figure');
87
+ inv.texte(1, { x: 148, y: 432, l: 84, h: 26 }, 'Jet speed', 'etiquette-figure');
88
+ inv.texte(1, LEGENDE, legende, 'legende');
89
+ inv.add('lien', 1, FIGURE, {
90
+ figure: legende,
91
+ passage: { page: 1, ligne: 2, extrait: 'Turbulence is reported above 80 kt' },
92
+ constat: 'contredit',
93
+ enonces: ['Texte : la turbulence est signalée au-dessus de 80 kt', 'Figure 1 : le seuil est tracé à 100 kt'],
94
+ }, true);
95
+ return fini(nom, 'Turbulence reporting', [bloc(TITRE, '<h1>Turbulence reporting</h1>') + bloc(PARA, `<p>${phrase}</p>`) + bloc(FIGURE, svg) + bloc(LEGENDE, `<p>${legende}</p>`)], inv);
96
+ }
97
+ /** Table en image : dessinée en HTML puis rastérisée au rendu, absente de la couche texte du PDF. */
98
+ function tableEnImage() {
99
+ const nom = 'table-en-image';
100
+ const inv = new Inventaire(nom);
101
+ const colonnes = ['Code', 'Meaning', 'Oktas'];
102
+ const lignes = [['0', 'Clear', '0'], ['1', 'Few', '1-2'], ['3', 'Scattered', '3-4'], ['5', 'Broken', '5-7'], ['8', 'Overcast', '8']];
103
+ const legende = 'Table 1 — Cloud amount';
104
+ const phrase = 'The codes are listed in Table 1.';
105
+ const zoneTable = { x: 60, y: 190, l: 400, h: 230 };
106
+ const html = `<table class="raster"><tr>${colonnes.map((c) => `<th>${c}</th>`).join('')}</tr>${lignes.map((l) => `<tr>${l.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</table>`;
107
+ inv.texte(1, TITRE, 'Cloud amount codes', 'titre');
108
+ inv.texte(1, PARA, phrase, 'paragraphe');
109
+ inv.texte(1, { x: 60, y: 156, l: 300, h: 24 }, legende, 'legende');
110
+ inv.add('table', 1, zoneTable, { colonnes, lignes }, true);
111
+ return fini(nom, 'Cloud amount codes', [bloc(TITRE, '<h1>Cloud amount codes</h1>') + bloc(PARA, `<p>${phrase}</p>`) + bloc({ x: 60, y: 156, l: 300, h: 24 }, `<p>${legende}</p>`) + bloc(zoneTable, html)], inv);
112
+ }
113
+ /** Gabarit de type BUFR (section 1) : champs ordonnés, obligatoires ou facultatifs, format et bornes. */
114
+ function gabaritBufr() {
115
+ const nom = 'gabarit-type-bufr';
116
+ const inv = new Inventaire(nom);
117
+ const champs = [
118
+ { octets: '1-3', nom: 'Length of section', format: 'uint24', statut: 'Mandatory', min: 0, max: 16777215 },
119
+ { octets: '4', nom: 'Master table', format: 'uint8', statut: 'Mandatory', min: 0, max: 255 },
120
+ { octets: '5-6', nom: 'Originating centre', format: 'uint16', statut: 'Mandatory', min: 0, max: 65535 },
121
+ { octets: '7', nom: 'Update sequence', format: 'uint8', statut: 'Optional', min: 0, max: 255 },
122
+ { octets: '8', nom: 'Optional section flag', format: 'bit', statut: 'Optional', min: 0, max: 1 },
123
+ ];
124
+ const zoneTable = { x: 60, y: 130, l: 696, h: 250 };
125
+ const rangs = champs.map((c) => `<tr><td>${c.octets}</td><td>${c.nom}</td><td>${c.format}</td><td>${c.statut}</td><td>${c.min}-${c.max}</td></tr>`).join('');
126
+ const html = `<table><tr><th>Octet</th><th>Field</th><th>Format</th><th>Status</th><th>Range</th></tr>${rangs}</table>`;
127
+ inv.texte(1, TITRE, 'BUFR section 1', 'titre');
128
+ inv.add('gabarit', 1, zoneTable, { nom: 'BUFR section 1', champs: champs.map((c) => ({ nom: c.nom, obligatoire: c.statut === 'Mandatory', format: c.format, min: c.min, max: c.max })) }, true);
129
+ return fini(nom, 'BUFR section 1', [bloc(TITRE, '<h1>BUFR section 1</h1>') + bloc(zoneTable, html)], inv);
130
+ }
131
+ /** Contrôle translucide sur fond chargé : un bouton blanc à 25 % d'opacité sur une carte à bandes, à côté d'un bouton opaque. */
132
+ function controleTranslucide() {
133
+ const nom = 'controle-translucide';
134
+ const inv = new Inventaire(nom);
135
+ const fond = 'background:linear-gradient(135deg,#0b3d91 0%,#2e8b57 50%,#f2c14e 100%),repeating-linear-gradient(45deg,#ffffff 0 10px,#000000 10px 20px);background-blend-mode:multiply;';
136
+ const layers = { x: 300, y: 400, l: 160, h: 44 };
137
+ const zoom = { x: 500, y: 400, l: 120, h: 44 };
138
+ const ecran = inv.ecran(1);
139
+ inv.add('composant', 1, layers, { role: 'bouton', position: { x: Math.round(layers.x * K), y: Math.round(layers.y * K) }, taille: { l: Math.round(layers.l * K), h: Math.round(layers.h * K) }, contenu: 'Layers', ecran });
140
+ inv.add('composant', 1, zoom, { role: 'bouton', position: { x: Math.round(zoom.x * K), y: Math.round(zoom.y * K) }, taille: { l: Math.round(zoom.l * K), h: Math.round(zoom.h * K) }, contenu: 'Zoom', ecran });
141
+ inv.texte(1, { x: 340, y: 410, l: 90, h: 24 }, 'Layers', 'bouton');
142
+ inv.texte(1, { x: 535, y: 410, l: 60, h: 24 }, 'Zoom', 'bouton');
143
+ inv.add('jeton', 1, layers, { categorie: 'opacite', valeur: '0.25', point: { x: Math.round((layers.x + 10) * K), y: Math.round((layers.y + 10) * K) } }, true);
144
+ inv.add('jeton', 1, zoom, { categorie: 'couleur', valeur: '#1e3a8a', point: { x: Math.round((zoom.x + 10) * K), y: Math.round((zoom.y + 10) * K) } });
145
+ const bouton = (b, texte, style) => bloc(b, `<span style="font-size:18px;color:#fff;line-height:44px">${texte}</span>`, `text-align:center;border-radius:6px;${style}`);
146
+ const page = bloc({ x: 0, y: 0, l: 816, h: 1056 }, '', fond) + bouton(layers, 'Layers', 'background:rgba(255, 255, 255, 0.25);') + bouton(zoom, 'Zoom', 'background:#1e3a8a;');
147
+ return fini(nom, 'Map controls', [page], inv);
148
+ }
149
+ /** Page « Proposal » parmi des pages exigées : les exigences (page 1) et une maquette de panneau marquée Proposal (page 2). */
150
+ function pageProposal() {
151
+ const nom = 'page-proposal';
152
+ const inv = new Inventaire(nom);
153
+ const exigences = ['REQ-1 The map shall show the layer panel.', 'REQ-2 The panel shall list every active layer.'];
154
+ const panneau = { x: 60, y: 160, l: 300, h: 420 };
155
+ const badge = { x: 600, y: 50, l: 150, h: 36 };
156
+ const legendePanneau = 'Figure 1 — Layer panel';
157
+ const LEGENDE_PANNEAU = { x: 60, y: 592, l: 300, h: 24 };
158
+ inv.texte(1, TITRE, 'Requirements', 'titre');
159
+ exigences.forEach((t, i) => inv.texte(1, { x: 60, y: 110 + i * 40, l: 696, h: 24 }, t, 'exigence'));
160
+ const ecran = inv.ecran(2);
161
+ inv.texte(2, { x: 60, y: 50, l: 400, h: 40 }, 'Layer panel mock-up', 'titre');
162
+ const comp = inv.add('composant', 2, panneau, { role: 'panneau', position: { x: Math.round(panneau.x * K), y: Math.round(panneau.y * K) }, taille: { l: Math.round(panneau.l * K), h: Math.round(panneau.h * K) }, contenu: 'Layers', ecran });
163
+ inv.texte(2, { x: 80, y: 176, l: 100, h: 26 }, 'Layers', 'titre-panneau');
164
+ // deux lignes du panneau : « Wind » dessinée sélectionnée (fond bleu, bordure), « Waves » dessinée grisée (désactivée)
165
+ const wind = { x: 80, y: 230, l: 240, h: 36 };
166
+ const waves = { x: 80, y: 280, l: 240, h: 36 };
167
+ const ligne = (b, contenu) => inv.add('composant', 2, b, { role: 'case_a_cocher', position: { x: Math.round(b.x * K), y: Math.round(b.y * K) }, taille: { l: Math.round(b.l * K), h: Math.round(b.h * K) }, contenu, ecran });
168
+ const compWind = ligne(wind, 'Wind');
169
+ const compWaves = ligne(waves, 'Waves');
170
+ inv.add('etat', 2, wind, { nom: 'selectionne', cible: compWind });
171
+ inv.add('etat', 2, waves, { nom: 'desactive', cible: compWaves });
172
+ // convention du corpus : tout libellé visible d'un composant est attendu aussi comme `texte`, avec le rôle du composant
173
+ inv.texte(2, { x: 90, y: 234, l: 70, h: 28 }, 'Wind', 'case_a_cocher');
174
+ inv.texte(2, { x: 90, y: 284, l: 70, h: 28 }, 'Waves', 'case_a_cocher');
175
+ inv.texte(2, LEGENDE_PANNEAU, legendePanneau, 'legende');
176
+ inv.texte(2, { x: 625, y: 54, l: 100, h: 28 }, 'Proposal', 'badge');
177
+ inv.add('lien', 2, panneau, { figure: legendePanneau, passage: { page: 1, ligne: 3, extrait: 'REQ-2 The panel shall list every active layer.' }, constat: 'confirme' });
178
+ inv.add('statut', 2, badge, { libelle: 'Proposal', couvre: [comp] }, true);
179
+ const page1 = bloc(TITRE, '<h1>Requirements</h1>') + exigences.map((t, i) => bloc({ x: 60, y: 110 + i * 40, l: 696, h: 24 }, `<p>${t}</p>`)).join('');
180
+ const page2 = bloc({ x: 60, y: 50, l: 400, h: 40 }, '<h1>Layer panel mock-up</h1>') +
181
+ bloc(badge, '<span style="font-size:18px;font-weight:bold;color:#fff;line-height:36px">Proposal</span>', 'background:#e67e22;border-radius:18px;text-align:center') +
182
+ bloc(panneau, '<p style="font-size:20px;font-weight:bold;margin:16px 20px">Layers</p>', 'background:#f4f4f4;border:2px solid #333') +
183
+ bloc(wind, '<p style="font-size:16px;line-height:32px;padding-left:12px;color:#0b2a66">Wind</p>', 'background:#cfe3ff;border:2px solid #1e5fd0') +
184
+ bloc(waves, '<p style="font-size:16px;line-height:32px;padding-left:12px;color:#999">Waves</p>', 'background:#e6e6e6;border:2px solid #cfcfcf') +
185
+ bloc(LEGENDE_PANNEAU, `<p>${legendePanneau}</p>`);
186
+ return fini(nom, 'Layer panel', [page1, page2], inv);
187
+ }
188
+ /** Version scannée : une fiche de station dessinée puis rastérisée, bruitée et légèrement penchée au rendu ; texte attendu issu d'un OCR. */
189
+ function scanBruite() {
190
+ const nom = 'scan-bruite';
191
+ const inv = new Inventaire(nom);
192
+ const lignes = ['Station: LFPG', 'Visibility: 9999 m', 'Wind: 240/12 KT'];
193
+ inv.texte(1, TITRE, 'Station report', 'titre', { ocr: true }, true);
194
+ lignes.forEach((t, i) => inv.texte(1, { x: 60, y: 130 + i * 50, l: 400, h: 30 }, t, 'ligne', { ocr: true }, true));
195
+ const page = bloc(TITRE, '<h1>Station report</h1>') + lignes.map((t, i) => bloc({ x: 60, y: 130 + i * 50, l: 400, h: 30 }, `<p style="font-size:22px">${t}</p>`)).join('');
196
+ return fini(nom, 'Station report', [page], inv, true);
197
+ }
198
+ /** Le corpus synthétique à vérité connue : un cas par piège de la conception, dans l'ordre (design « Évaluation »). */
199
+ export function buildCorpus() {
200
+ return [texteAmbigu(), figureContredit(), tableEnImage(), gabaritBufr(), controleTranslucide(), pageProposal(), scanBruite()];
201
+ }
@@ -0,0 +1,262 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { RafError } from '../plan.js';
3
+ import { TYPES, checkInventaire, readInventaire } from './check.js';
4
+ import { affirmation, identite } from './evaluate.js';
5
+ /** Au-delà de cette part d'éléments non appariés, les deux lectures ne décrivent pas le même document : pas de réconciliation (design §3, hypothèse H3). */
6
+ export const SEUIL_NON_APPARIES = 0.3;
7
+ /** Recouvrement minimal (intersection sur union) de deux zones du même type et de la même page pour que deux lectures parlent du même élément lu autrement. */
8
+ const RECOUVREMENT_MIN = 0.5;
9
+ const zoneOf = (e) => e.source?.zone;
10
+ /** Intersection sur union de deux zones ; 0 quand l'une manque. */
11
+ function recouvrement(a, b) {
12
+ const p = zoneOf(a);
13
+ const q = zoneOf(b);
14
+ if (!p || !q)
15
+ return 0;
16
+ const l = Math.min(p.x + p.l, q.x + q.l) - Math.max(p.x, q.x);
17
+ const h = Math.min(p.y + p.h, q.y + q.h) - Math.max(p.y, q.y);
18
+ if (l <= 0 || h <= 0)
19
+ return 0;
20
+ const inter = l * h;
21
+ return inter / (p.l * p.h + q.l * q.h - inter);
22
+ }
23
+ const SKIP = new Set(['id', 'type', 'source', 'confiance', 'cause', 'ocr', 'citations', 'ecran', 'cible', 'couvre', 'usage']);
24
+ const show = (v) => {
25
+ const s = v === undefined ? 'absent' : typeof v === 'string' ? v : JSON.stringify(v);
26
+ return s.length > 60 ? `${s.slice(0, 59)}…` : s;
27
+ };
28
+ /** Les champs qui diffèrent, « champ : A « … » ≠ B « … » » : les ids et les renvois sont numérotés par chaque lecture, ils ne comptent pas. */
29
+ function detailDivergence(a, b) {
30
+ const out = [];
31
+ for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
32
+ if (SKIP.has(k) || JSON.stringify(a[k]) === JSON.stringify(b[k]))
33
+ continue;
34
+ out.push(`${k} : A « ${show(a[k])} » ≠ B « ${show(b[k])} »`);
35
+ }
36
+ return out.length > 0 ? out.join(' ; ') : 'affirmations différentes';
37
+ }
38
+ /**
39
+ * Apparie deux lectures du même document, par type, page, identité (ce qui désigne l'élément, comme `ref eval`) et recouvrement de zone :
40
+ * concordant (même identité, même affirmation), divergent (même identité et affirmation différente, ou même zone et identité différente), seul.
41
+ * Les silences sont réunis, une même question une seule fois. `seuil` : part de non appariés au-delà de laquelle `refuse` est vrai.
42
+ */
43
+ export function reconcilier(a, b, seuil = SEUIL_NON_APPARIES) {
44
+ const parIdA = new Map(a.elements.map((e) => [e.id, e]));
45
+ const parIdB = new Map(b.elements.map((e) => [e.id, e]));
46
+ const cle = (e, par) => `${e.source?.page}\u0000${identite(e, par)}`;
47
+ const items = new Map();
48
+ const pairedB = new Set();
49
+ for (const type of TYPES.filter((t) => t !== 'silence')) {
50
+ const as = a.elements.filter((e) => e.type === type);
51
+ const bs = b.elements.filter((e) => e.type === type);
52
+ const libresB = () => bs.filter((e) => !pairedB.has(e));
53
+ const lier = (ea, eb, statut) => {
54
+ pairedB.add(eb);
55
+ items.set(ea, { statut, element: ea, a: ea, b: eb, ...(statut === 'divergent' ? { detail: detailDivergence(ea, eb) } : {}) });
56
+ };
57
+ const meilleur = (ea, candidats) => candidats.reduce((best, c) => (best === undefined || recouvrement(ea, c) > recouvrement(ea, best) ? c : best), undefined);
58
+ // 1. même identité et même affirmation ; 2. même identité, affirmation différente ; 3. identité différente mais même zone
59
+ for (const ea of as) {
60
+ const eb = meilleur(ea, libresB().filter((c) => cle(c, parIdB) === cle(ea, parIdA) && affirmation(c, parIdB) === affirmation(ea, parIdA)));
61
+ if (eb)
62
+ lier(ea, eb, 'concordant');
63
+ }
64
+ for (const ea of as.filter((e) => !items.has(e))) {
65
+ const eb = meilleur(ea, libresB().filter((c) => cle(c, parIdB) === cle(ea, parIdA)));
66
+ if (eb)
67
+ lier(ea, eb, 'divergent');
68
+ }
69
+ for (const ea of as.filter((e) => !items.has(e))) {
70
+ const eb = meilleur(ea, libresB().filter((c) => c.source?.page === ea.source?.page && recouvrement(ea, c) >= RECOUVREMENT_MIN));
71
+ if (eb)
72
+ lier(ea, eb, 'divergent');
73
+ }
74
+ for (const ea of as.filter((e) => !items.has(e)))
75
+ items.set(ea, { statut: 'seul', element: ea, a: ea, lecture: 'a' });
76
+ }
77
+ // l'ordre de l'inventaire : la lecture A dans son ordre, puis ce que B seule porte dans son ordre
78
+ const ordered = [];
79
+ for (const e of a.elements)
80
+ if (e.type !== 'silence')
81
+ ordered.push(items.get(e));
82
+ for (const e of b.elements)
83
+ if (e.type !== 'silence' && !pairedB.has(e))
84
+ ordered.push({ statut: 'seul', element: e, b: e, lecture: 'b' });
85
+ const vues = new Map();
86
+ for (const [e, lecture] of [...a.elements.map((e) => [e, 'a']), ...b.elements.map((e) => [e, 'b'])]) {
87
+ if (e.type !== 'silence')
88
+ continue;
89
+ const k = identite(e, new Map());
90
+ const deja = vues.get(k);
91
+ if (deja) {
92
+ (deja.alias ??= []).push(`${lecture}:${e.id}`); // un statut peut couvrir ce silence-ci : son renvoi suit le silence réuni
93
+ continue;
94
+ }
95
+ const item = { statut: 'silence', element: e, ...(lecture === 'a' ? { a: e } : { b: e }) };
96
+ vues.set(k, item);
97
+ ordered.push(item);
98
+ }
99
+ const compte = (s) => ordered.filter((i) => i.statut === s).length;
100
+ const concordants = compte('concordant');
101
+ const divergents = compte('divergent');
102
+ const seuls = compte('seul');
103
+ const total = concordants + divergents + seuls;
104
+ const ratio = total === 0 ? null : seuls / total;
105
+ return { items: ordered, concordants, divergents, seuls, silences: compte('silence'), nonApparies: seuls, ratio, refuse: ratio !== null && ratio > seuil };
106
+ }
107
+ /** Les renvois d'un élément à d'autres éléments de sa lecture : (champ, ids). `ecran` et `cible` sont obligatoires, `couvre` se réduit. */
108
+ const renvois = (e) => (e.type === 'composant' ? [e.ecran] : e.type === 'etat' ? [e.cible] : e.type === 'statut' ? e.couvre : []);
109
+ const lectureLabel = (l) => l.toUpperCase();
110
+ /** La confiance d'un élément concordant : un doute d'une lecture est un doute ; sinon `mesure` si l'une des deux a mesuré, `lu` sinon. */
111
+ function confianceConcordant(ea, eb) {
112
+ if (ea.confiance === 'incertain' || eb.confiance === 'incertain') {
113
+ const causes = [ea, eb].filter((e) => e.confiance === 'incertain').map((e) => e.cause);
114
+ return { confiance: 'incertain', cause: [...new Set(causes)].join(' ; ') };
115
+ }
116
+ return { confiance: ea.confiance === 'mesure' || eb.confiance === 'mesure' ? 'mesure' : 'lu' };
117
+ }
118
+ /**
119
+ * L'inventaire réconcilié (genre « inventaire »), ids renumérotés `<slug>#<type>-<n>` et renvois suivis. Un divergent est `incertain` avec les deux
120
+ * versions ; un seul écarté par le troisième regard disparaît (avec ce qui ne tient plus sans lui), un seul confirmé garde sa confiance (un doute
121
+ * reste un doute), un seul sans décision reste `incertain` : `regard` dit si le troisième regard a eu lieu (sa cause le dit).
122
+ * Un élément apparié tient tant que l'une de ses deux versions renvoie à des éléments gardés (la lecture A d'abord) : quand l'autre version renvoyait à un
123
+ * élément écarté, il est gardé, rattaché d'après la version qui tient, et `incertain` avec la cause du rattachement ; il disparaît quand aucune ne tient.
124
+ */
125
+ export function assembler(a, b, rec, regard = false) {
126
+ const entrees = rec.items.map((item) => ({ item, supprimee: item.statut === 'seul' && item.decision?.verdict === 'ecarte' }));
127
+ const proprietaire = new Map();
128
+ for (const en of entrees) {
129
+ if (en.item.a)
130
+ proprietaire.set(`a:${en.item.a.id}`, en);
131
+ if (en.item.b)
132
+ proprietaire.set(`b:${en.item.b.id}`, en);
133
+ for (const k of en.item.alias ?? [])
134
+ proprietaire.set(k, en);
135
+ }
136
+ const versionsDe = (en) => ['a', 'b'].filter((v) => en.item[v] !== undefined);
137
+ const cible = (v, id) => proprietaire.get(`${v}:${id}`);
138
+ const vivant = (v, id) => {
139
+ const t = cible(v, id);
140
+ return t !== undefined && !t.supprimee;
141
+ };
142
+ /** Une version tient quand ses renvois tiennent : tous pour un composant ou un état, au moins un pour un statut. */
143
+ const tient = (en, v) => {
144
+ const element = en.item[v];
145
+ const ids = renvois(element);
146
+ const vivants = ids.filter((id) => vivant(v, id)).length;
147
+ return ids.length === 0 || (element.type === 'statut' ? vivants > 0 : vivants === ids.length);
148
+ };
149
+ // ce qui renvoie à un élément écarté ne tient plus : un point fixe, un écarté pouvant en entraîner d'autres. Un élément apparié tient dès que l'une de
150
+ // ses deux versions tient (la lecture A d'abord) : la lecture B peut le rattacher à un écran conservé quand A le rattache à un écran écarté.
151
+ for (let change = true; change;) {
152
+ change = false;
153
+ for (const en of entrees) {
154
+ if (en.supprimee)
155
+ continue;
156
+ const v = versionsDe(en).find((x) => tient(en, x));
157
+ if (v === undefined) {
158
+ en.supprimee = true;
159
+ change = true;
160
+ }
161
+ else
162
+ en.version = v;
163
+ }
164
+ }
165
+ const compteur = new Map();
166
+ const nouvelId = new Map();
167
+ for (const en of entrees) {
168
+ if (en.supprimee)
169
+ continue;
170
+ const type = en.item.element.type;
171
+ const n = (compteur.get(type) ?? 0) + 1;
172
+ compteur.set(type, n);
173
+ nouvelId.set(en, `${a.document.slug}#${type}-${n}`);
174
+ }
175
+ const elements = entrees.filter((en) => !en.supprimee).map((en) => {
176
+ const { item } = en;
177
+ const v = en.version;
178
+ const e = { ...item.element, id: nouvelId.get(en) };
179
+ const id = (ancien) => nouvelId.get(cible(v, ancien));
180
+ // les renvois sont ceux de la version qui tient (pour un élément apparié, parfois celle de la lecture B)
181
+ const source = item[v];
182
+ if (e.type === 'composant')
183
+ e.ecran = id(source.ecran);
184
+ if (e.type === 'etat')
185
+ e.cible = id(source.cible);
186
+ if (e.type === 'statut')
187
+ e.couvre = source.couvre.filter((x) => vivant(v, x)).map(id);
188
+ const doute = (cause) => {
189
+ e.confiance = 'incertain';
190
+ e.cause = cause;
191
+ };
192
+ if (item.statut === 'concordant') {
193
+ const { confiance, cause } = confianceConcordant(item.a, item.b);
194
+ e.confiance = confiance;
195
+ if (cause === undefined)
196
+ delete e.cause;
197
+ else
198
+ e.cause = cause;
199
+ }
200
+ else if (item.statut === 'divergent') {
201
+ doute(`lectures divergentes — ${item.detail}`);
202
+ }
203
+ else if (item.statut === 'seul' && item.decision?.verdict !== 'confirme') {
204
+ const base = `lu dans la lecture ${lectureLabel(item.lecture)} seulement, troisième regard ${regard ? 'sans réponse' : 'non fait'}`;
205
+ doute(item.element.confiance === 'incertain' ? `${base} ; ${item.element.cause}` : base);
206
+ }
207
+ // les deux lectures ne le rattachent plus au même élément : l'une renvoyait à un élément écarté par le troisième regard, l'élément est gardé
208
+ // par l'autre (il est lu, ou mesuré, par les deux) mais son rattachement n'est plus qu'une lecture : un doute, avec sa cause
209
+ const autre = v === 'a' ? 'b' : 'a';
210
+ if (item.a && item.b && !tient(en, autre)) {
211
+ const note = `rattachement : la lecture ${lectureLabel(autre)} le rattachait à un élément écarté par le troisième regard, rattaché d'après la lecture ${lectureLabel(v)}`;
212
+ doute(e.confiance === 'incertain' ? `${e.cause} ; ${note}` : note);
213
+ }
214
+ return e;
215
+ });
216
+ return { version: 1, genre: 'inventaire', document: { slug: a.document.slug, sha256: a.document.sha256 }, elements };
217
+ }
218
+ function lireLecture(path) {
219
+ const doc = readInventaire(path);
220
+ const problems = checkInventaire(doc);
221
+ if (problems.length > 0)
222
+ throw new RafError(`ref diff : ${path} n'est pas valide (${problems.length} écart(s), voir cadence ref check) : ${problems[0]}`);
223
+ if (doc.genre !== 'lecture')
224
+ throw new RafError(`ref diff : ${path} doit être une lecture, pas un « ${doc.genre} »`);
225
+ return doc;
226
+ }
227
+ /** `--seuil` : une part en pour cent (0 à 100) rendue en fraction ; absent, le seuil de la conception. */
228
+ export function parseSeuil(text, commande) {
229
+ if (text === undefined)
230
+ return SEUIL_NON_APPARIES;
231
+ if (!/^\d+(\.\d+)?$/.test(text) || Number(text) > 100)
232
+ throw new RafError(`${commande} : --seuil invalide : « ${text} » (une part en pour cent, de 0 à 100)`);
233
+ return Number(text) / 100;
234
+ }
235
+ /** `ref diff <a> <b> [--out inventaire.json] [--seuil 30]` : 0 réconcilié, 1 trop d'éléments non appariés (rien d'écrit), 2 fichier ou appel refusé. */
236
+ export function diffLectures(o) {
237
+ const seuil = parseSeuil(o.seuil, 'ref diff');
238
+ const a = lireLecture(o.a);
239
+ const b = lireLecture(o.b);
240
+ if (a.document.slug !== b.document.slug)
241
+ throw new RafError(`ref diff : les deux lectures ne sont pas du même document : slug ${a.document.slug} et ${b.document.slug}`);
242
+ if (a.document.sha256 !== b.document.sha256)
243
+ throw new RafError(`ref diff : l'empreinte des deux lectures diffère pour ${a.document.slug} : pas la même version du document (jamais deux versions mêlées)`);
244
+ const rec = reconcilier(a, b, seuil);
245
+ const sa = rec.items.filter((i) => i.statut === 'seul' && i.lecture === 'a').length;
246
+ const pct = (x) => `${Number((x * 100).toFixed(1))} %`;
247
+ o.log(`concordants : ${rec.concordants} · divergents : ${rec.divergents} · seuls : ${rec.seuls} (A : ${sa}, B : ${rec.seuls - sa}) · silences : ${rec.silences}`);
248
+ o.log(`non appariés : ${rec.ratio === null ? 'n/a' : pct(rec.ratio)} (seuil ${pct(seuil)})`);
249
+ for (const i of rec.items) {
250
+ if (i.statut === 'divergent')
251
+ o.log(`divergent : ${i.element.type} p.${i.element.source.page} : ${i.detail}`);
252
+ if (i.statut === 'seul')
253
+ o.log(`seul (${lectureLabel(i.lecture)}) : ${i.element.type} p.${i.element.source.page} : ${identite(i.element, new Map())}`);
254
+ }
255
+ if (rec.refuse) {
256
+ o.log(`plus de ${pct(seuil)} d'éléments non appariés (${rec.nonApparies} sur ${rec.concordants + rec.divergents + rec.seuls}) : les deux lectures ne décrivent pas le même document — question au lead, aucune réconciliation`);
257
+ return 1;
258
+ }
259
+ if (o.out !== undefined)
260
+ writeFileSync(o.out, `${JSON.stringify(assembler(a, b, rec), null, 2)}\n`);
261
+ return 0;
262
+ }