designwithai-mcp 0.12.0 → 0.13.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/dist/cli.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * LE MÊME MOTEUR, EN LIGNE DE COMMANDE.
3
+ *
4
+ * Une page d'état qui n'existe que dans un IDE ne sert pas l'équipe : elle sert
5
+ * la personne qui a l'IDE. La CI, elle, n'a pas de client MCP — elle a un shell.
6
+ * Ces sous-commandes exposent EXACTEMENT les mêmes fonctions de rendu que les
7
+ * outils MCP, sans en dupliquer une ligne. Une définition, un endroit : si la
8
+ * page change, elle change aux deux endroits parce qu'il n'y en a qu'un.
9
+ *
10
+ * Tout va sur la sortie standard, pour que `>` et `|` fassent leur travail.
11
+ */
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { workspaceRoot } from "./paths.js";
15
+ import { lireRegistre } from "./registry-read.js";
16
+ import { rendreStatusPage } from "./status-page.js";
17
+ import { dernierUpdate, enregistrerUpdate, rendreStatusUpdate } from "./status-update.js";
18
+ import { delta, rendreDelta } from "./pr-delta.js";
19
+ import { lireReviewsA } from "./registry-git.js";
20
+ import { constats } from "./pr-delta.js";
21
+ export const SOUS_COMMANDES = ["status-page", "status-update", "pr-delta", "--help", "-h"];
22
+ const AIDE = `designwithai-mcp — le serveur MCP du Handbook, et trois rendus en ligne de commande.
23
+
24
+ designwithai-mcp démarre le serveur MCP (stdio)
25
+
26
+ designwithai-mcp status-page [--out F] [--product N]
27
+ Écrit la page d'état du produit, dérivée du registre.
28
+ Défaut : docs/etat-produit/index.html. --out - écrit sur la sortie standard.
29
+
30
+ designwithai-mcp status-update [--since ISO] [--product N] [--no-save]
31
+ Écrit le point d'avancement en Markdown sur la sortie standard.
32
+ Sans --since : depuis le dernier point enregistré.
33
+
34
+ designwithai-mcp pr-delta --base <ref> [--head <ref>] [--branch N]
35
+ Compare le registre de la base à celui du répertoire courant (ou de --head)
36
+ et rend le commentaire de PR. Suppose que .designwithai/ est VERSIONNÉ.
37
+
38
+ Le registre vit dans .designwithai/ du projet courant (DWAI_WORKSPACE pour l'imposer).`;
39
+ function option(argv, nom) {
40
+ const i = argv.indexOf(`--${nom}`);
41
+ if (i === -1)
42
+ return undefined;
43
+ const v = argv[i + 1];
44
+ return v && !v.startsWith("--") ? v : undefined;
45
+ }
46
+ /**
47
+ * Rend vrai quand une sous-commande a été traitée — le serveur ne doit alors
48
+ * PAS ouvrir stdio : un client MCP qui reçoit du Markdown sur stdout croit
49
+ * lire du protocole et se coupe sans message lisible.
50
+ */
51
+ export async function traiterCli(argv) {
52
+ const cmd = argv[0];
53
+ if (!cmd)
54
+ return false;
55
+ if (cmd === "--help" || cmd === "-h") {
56
+ process.stdout.write(`${AIDE}\n`);
57
+ return true;
58
+ }
59
+ if (cmd === "status-page") {
60
+ const r = lireRegistre();
61
+ const produit = option(argv, "product");
62
+ const html = rendreStatusPage(r, {
63
+ maintenant: new Date().toISOString(),
64
+ ...(produit ? { produit } : {}),
65
+ });
66
+ const out = option(argv, "out") ?? "docs/etat-produit/index.html";
67
+ if (out === "-") {
68
+ process.stdout.write(html);
69
+ return true;
70
+ }
71
+ const cible = path.resolve(workspaceRoot(), out);
72
+ mkdirSync(path.dirname(cible), { recursive: true });
73
+ writeFileSync(cible, html, "utf8");
74
+ process.stderr.write(`Page écrite : ${path.relative(workspaceRoot(), cible)}\n`);
75
+ /* Les fichiers illisibles vont sur stderr ET dans la page. Un registre à
76
+ moitié lu qui rendrait une page sereine serait le pire de cette surface. */
77
+ for (const i of r.illisibles)
78
+ process.stderr.write(`⚠ illisible : ${i.chemin} — ${i.raison}\n`);
79
+ return true;
80
+ }
81
+ if (cmd === "status-update") {
82
+ const r = lireRegistre();
83
+ const maintenant = new Date();
84
+ const depuis = option(argv, "since") ??
85
+ dernierUpdate() ??
86
+ new Date(maintenant.getTime() - 30 * 24 * 3600 * 1000).toISOString();
87
+ const produit = option(argv, "product");
88
+ const md = rendreStatusUpdate(r, {
89
+ depuis,
90
+ maintenant: maintenant.toISOString(),
91
+ ...(produit ? { produit } : {}),
92
+ });
93
+ process.stdout.write(`${md}\n`);
94
+ if (!argv.includes("--no-save")) {
95
+ const chemin = enregistrerUpdate(md, maintenant);
96
+ process.stderr.write(`Point enregistré : ${path.relative(workspaceRoot(), chemin)}\n`);
97
+ }
98
+ return true;
99
+ }
100
+ if (cmd === "pr-delta") {
101
+ const base = option(argv, "base");
102
+ if (!base) {
103
+ process.stderr.write("pr-delta a besoin de --base <ref>.\n");
104
+ process.exitCode = 2;
105
+ return true;
106
+ }
107
+ const cwd = workspaceRoot();
108
+ let avant;
109
+ try {
110
+ avant = lireReviewsA(base, cwd);
111
+ }
112
+ catch (e) {
113
+ process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
114
+ process.exitCode = 2;
115
+ return true;
116
+ }
117
+ const tete = option(argv, "head");
118
+ const apres = tete ? lireReviewsA(tete, cwd).reviews : lireRegistre().reviews;
119
+ if (avant.absent) {
120
+ process.stdout.write(`${rendreDelta({ reglesProuves: [], declaresCorriges: [], nouveaux: [], deplaces: [], persistants: [] }, { base, baseSansRegistre: true })}\n`);
121
+ return true;
122
+ }
123
+ const d = delta(avant.reviews, apres);
124
+ const branche = option(argv, "branch");
125
+ process.stdout.write(`${rendreDelta(d, { ...(branche ? { branche } : {}), base })}\n`);
126
+ if (avant.illisibles.length > 0) {
127
+ process.stderr.write(`⚠ ${avant.illisibles.length} fichier(s) illisible(s) côté base : la comparaison est incomplète.\n`);
128
+ }
129
+ void constats;
130
+ return true;
131
+ }
132
+ process.stderr.write(`Sous-commande inconnue : ${cmd}\n\n${AIDE}\n`);
133
+ process.exitCode = 2;
134
+ return true;
135
+ }
@@ -462,7 +462,7 @@ export const TOLERANCE_TYPO = 1;
462
462
  * relie deux. En dessous de trois, la mesure le DIT au lieu de rendre un
463
463
  * chiffre qui ne veut rien dire.
464
464
  */
465
- export function echelleTypo(items) {
465
+ export function echelleTypo(items, systeme) {
466
466
  const nonMesures = [];
467
467
  const vues = new Set();
468
468
  for (const it of items) {
@@ -476,11 +476,33 @@ export function echelleTypo(items) {
476
476
  vues.add(px);
477
477
  }
478
478
  const tailles = [...vues].sort((a, b) => a - b);
479
- if (tailles.length < 3) {
479
+ /* L'ÉCHELLE ÉNUMÉRÉE PASSE AVANT TOUT LE RESTE, Y COMPRIS AVANT LE SEUIL DE
480
+ TROIS TAILLES. Ce seuil existe parce qu'on ne DÉDUIT rien de deux valeurs ;
481
+ ici on ne déduit pas, on compare à une liste écrite. Une équipe qui déclare
482
+ son échelle a droit à sa mesure même sur un fichier qui n'en emploie
483
+ qu'une. */
484
+ const declarees = (systeme?.taillesTypo ?? []).filter((n) => Number.isFinite(n) && n > 0);
485
+ if (declarees.length > 0) {
486
+ const attendues = [...new Set(declarees)].sort((a, b) => a - b);
487
+ /* Conformité = APPARTENANCE. Pas de tolérance : une liste ne se discute
488
+ pas, et « à un pixel près d'un pas déclaré » n'est pas le pas déclaré. */
489
+ const dansLaListe = new Set(attendues);
490
+ const orphelines = tailles.filter((t) => !dansLaListe.has(t));
491
+ return {
492
+ valeur: { ratio: null, ecartMoyen: null },
493
+ detail: { tailles, attendues, orphelines, essais: [], origine: "énumérée" },
494
+ completude: { lus: tailles.length, nonMesures },
495
+ };
496
+ }
497
+ /* UN RATIO DÉCLARÉ N'EST PAS UN RATIO À DEVINER. Tant que ce paramètre
498
+ n'existait pas, `ratioTypo` se déclarait et ne servait à rien : la mesure
499
+ cherchait le ratio le moins faux et ignorait celui de l'équipe. */
500
+ const ratioDeclare = systeme?.ratioTypo && systeme.ratioTypo > 1 && systeme.ratioTypo <= 3 ? systeme.ratioTypo : null;
501
+ if (tailles.length < 3 && !ratioDeclare) {
480
502
  nonMesures.push(`${tailles.length} taille(s) distincte(s) : en dessous de trois, n'importe quel ratio les relie — aucune échelle n'est déductible.`);
481
503
  return {
482
504
  valeur: { ratio: null, ecartMoyen: null },
483
- detail: { tailles, attendues: [], orphelines: [], essais: [] },
505
+ detail: { tailles, attendues: [], orphelines: [], essais: [], origine: "déduite" },
484
506
  completude: { lus: tailles.length, nonMesures },
485
507
  };
486
508
  }
@@ -493,14 +515,16 @@ export function echelleTypo(items) {
493
515
  const ecart = tailles.reduce((somme, t) => somme + Math.min(...pas.map((p) => Math.abs(p - t))), 0) / tailles.length;
494
516
  return { ratio, ecart: Math.round(ecart * 1000) / 1000 };
495
517
  }).sort((a, b) => a.ecart - b.ecart);
496
- const gagnant = essais[0];
518
+ const gagnant = ratioDeclare
519
+ ? (essais.find((e) => e.ratio === ratioDeclare) ?? { ratio: ratioDeclare, ecart: 0 })
520
+ : essais[0];
497
521
  const attendues = [];
498
522
  for (let v = base; v <= max * gagnant.ratio; v *= gagnant.ratio)
499
523
  attendues.push(Math.round(v * 10) / 10);
500
524
  const orphelines = tailles.filter((t) => Math.min(...attendues.map((p) => Math.abs(p - t))) > TOLERANCE_TYPO);
501
525
  return {
502
526
  valeur: { ratio: gagnant.ratio, ecartMoyen: gagnant.ecart },
503
- detail: { tailles, attendues, orphelines, essais },
527
+ detail: { tailles, attendues, orphelines, essais, origine: ratioDeclare ? "ratio déclaré" : "déduite" },
504
528
  completude: { lus: tailles.length, nonMesures },
505
529
  };
506
530
  }
@@ -38,7 +38,7 @@ export function craftReport(brut, opts = {}) {
38
38
  const grille = conformiteGrille(items, reference.base);
39
39
  const pas = distinctes(items, estEspacement);
40
40
  const coherence = echelleCoherente(pas.detail, reference.base);
41
- const typo = echelleTypo(items);
41
+ const typo = echelleTypo(items, opts.systeme);
42
42
  const tailles = distinctes(items, estTailleTypo);
43
43
  const doublons = quasiDoublonsCouleur(items);
44
44
  const couleurs = new Set(items.filter(estCouleur).map((i) => (i.value ?? "").trim().toLowerCase()));
@@ -71,6 +71,8 @@ export function craftReport(brut, opts = {}) {
71
71
  ratio: typo.valeur.ratio,
72
72
  ecartMoyen: typo.valeur.ecartMoyen,
73
73
  tailles: typo.detail.tailles,
74
+ attendues: typo.detail.attendues,
75
+ origine: typo.detail.origine,
74
76
  orphelines: typo.detail.orphelines,
75
77
  nonMesures: [...typo.completude.nonMesures, ...tailles.completude.nonMesures],
76
78
  },
@@ -193,13 +195,30 @@ export function rendreCraftReport(r, opts = {}) {
193
195
  l.push("");
194
196
  /* ── Typographie ── */
195
197
  l.push("LES TAILLES DE TEXTE");
196
- if (r.typo.ratio === null) {
198
+ /* TROIS PHRASES, PARCE QU'IL Y A TROIS SITUATIONS — et les confondre revient
199
+ à opposer une supposition à une décision. « déduite » doit se lire comme
200
+ déduite : c'est le seul cas où l'échelle vient de nous et pas de l'équipe. */
201
+ if (r.typo.origine === "énumérée") {
202
+ l.push(` ${r.typo.tailles.length} taille(s) employée(s) : ${r.typo.tailles.join(", ")}.` + cite("TYPO-02"));
203
+ l.push(` Mesuré contre l'échelle ÉNUMÉRÉE que l'équipe a déclarée — ${r.typo.attendues.join(", ")}. ` +
204
+ "Aucun ratio n'est cherché : la conformité, ici, c'est d'être dans la liste." + cite("TYPO-01"));
205
+ if (r.typo.orphelines.length > 0) {
206
+ l.push(` ${r.typo.orphelines.length} taille(s) hors de l'échelle déclarée : ${r.typo.orphelines.join(", ")}.`);
207
+ }
208
+ else {
209
+ l.push(" Toutes tombent sur un pas déclaré.");
210
+ }
211
+ }
212
+ else if (r.typo.ratio === null) {
197
213
  l.push(` Pas assez de tailles distinctes pour déduire une échelle (${r.typo.tailles.length}).` + cite("TYPO-01"));
198
214
  }
199
215
  else {
200
216
  l.push(` ${r.typo.tailles.length} taille(s) : ${r.typo.tailles.join(", ")}.` + cite("TYPO-02"));
201
- l.push(` L'échelle qui les explique le mieux est un ratio de ${r.typo.ratio} ` +
202
- `(écart moyen ${r.typo.ecartMoyen} pixel).` + cite("TYPO-01"));
217
+ l.push(r.typo.origine === "ratio déclaré"
218
+ ? ` Mesuré contre le ratio de ${r.typo.ratio} que l'équipe a déclaré ` +
219
+ `(écart moyen ${r.typo.ecartMoyen} pixel).` + cite("TYPO-01")
220
+ : ` Aucune échelle déclarée : celle qui les explique le mieux est un ratio de ${r.typo.ratio} ` +
221
+ `(écart moyen ${r.typo.ecartMoyen} pixel). C'est une déduction, pas une décision.` + cite("TYPO-01"));
203
222
  if (r.typo.orphelines.length > 0) {
204
223
  l.push(` ${r.typo.orphelines.length} taille(s) qu'aucun pas n'explique : ${r.typo.orphelines.join(", ")}.`);
205
224
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * LES INSTANTANÉS DE MESURE — sans historique, pas de tendance.
3
+ *
4
+ * `craft_report` calculait et oubliait. Une conformité de 97 % ne dit rien toute
5
+ * seule : c'est « 94 puis 97 » qui raconte quelque chose, et « 97 puis 91 » qui
6
+ * réveille quelqu'un. On garde donc chaque instantané, horodaté, dans le projet.
7
+ *
8
+ * Ce qu'on garde est VOLONTAIREMENT MAIGRE : les comptes, pas les listes. Un
9
+ * instantané n'a pas à rejouer le rapport — il sert à dire ce qui a bougé, et
10
+ * la liste exhaustive se recalcule en une seconde depuis le code courant.
11
+ */
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
13
+ import path from "node:path";
14
+ import { workspaceDir } from "./paths.js";
15
+ export function craftDir() {
16
+ return process.env.DWAI_CRAFT_PATH ?? workspaceDir("craft");
17
+ }
18
+ export function horodatage(now) {
19
+ return now.toISOString().replace(/[:.]/g, "-").replace("T", "_").replace(/Z$/, "").slice(0, 16);
20
+ }
21
+ export function depuisRapport(r, now) {
22
+ return {
23
+ at: now.toISOString(),
24
+ reference: { niveau: r.reference.niveau, base: r.reference.base, contre: r.reference.contre },
25
+ espace: {
26
+ total: r.espace.total,
27
+ conformes: r.espace.conformes,
28
+ pct: Math.round(r.espace.pct * 1000) / 1000,
29
+ divergents: r.espace.divergents.length,
30
+ pas: r.espace.pas,
31
+ },
32
+ typo: { tailles: r.typo.tailles.length, ratio: r.typo.ratio, orphelines: r.typo.orphelines.length },
33
+ couleurs: { distinctes: r.couleurs.distinctes, doublons: r.couleurs.doublons },
34
+ rayons: r.rayons.distincts,
35
+ grille: {
36
+ bascules: r.grille.bascules.length,
37
+ horsPlages: r.grille.horsPlages.length,
38
+ plafonds: r.grille.plafonds,
39
+ },
40
+ lecture: { rencontrees: r.lecture.rencontrees, lues: r.lecture.lues },
41
+ };
42
+ }
43
+ export function enregistrerInstantane(i, now) {
44
+ const dir = craftDir();
45
+ mkdirSync(dir, { recursive: true });
46
+ const chemin = path.join(dir, `${horodatage(now)}.json`);
47
+ writeFileSync(chemin, `${JSON.stringify(i, null, 2)}\n`);
48
+ return chemin;
49
+ }
50
+ /** Tous les instantanés, du plus ancien au plus récent. Un abîmé disparaît. */
51
+ export function lireInstantanes() {
52
+ const dir = craftDir();
53
+ if (!existsSync(dir))
54
+ return [];
55
+ const out = [];
56
+ for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
57
+ try {
58
+ const o = JSON.parse(readFileSync(path.join(dir, f), "utf8"));
59
+ if (o && typeof o.at === "string" && o.espace)
60
+ out.push(o);
61
+ }
62
+ catch {
63
+ /* Un instantané illisible n'est pas une mesure. Le fil s'en charge : c'est
64
+ lui qui rend l'événement « illisible », pas ce lecteur. */
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ /** Le dernier et l'avant-dernier — de quoi dire ce qui a bougé, et rien de plus. */
70
+ export function tendance() {
71
+ const tous = lireInstantanes();
72
+ return {
73
+ dernier: tous[tous.length - 1] ?? null,
74
+ precedent: tous[tous.length - 2] ?? null,
75
+ };
76
+ }
@@ -38,9 +38,27 @@ export const SAIT_LIRE_STORIES = [
38
38
  "le composant documenté (dernier segment du `title`, ou nom du fichier)",
39
39
  "les états documentés (un export nommé par story)",
40
40
  ];
41
+ /**
42
+ * CE QU'ON NE LIT PAS — et la fois où cette liste a menti.
43
+ *
44
+ * En faisant tourner la mesure sur NOTRE propre dépôt pour remplir le registre,
45
+ * elle est revenue à 100 % de conformité d'espacement. Le réflexe est écrit
46
+ * quelque part depuis le premier jour : à 100 %, c'est la mesure qui est
47
+ * fausse, pas nous qui sommes parfaits. Elle lisait le dossier `backups` — des
48
+ * copies de builds de production — et y trouvait la feuille CSS compilée de
49
+ * Tailwind. Les variables internes de Tailwind et son échelle nommée y tombent
50
+ * évidemment sur la grille : ils SONT la grille. On mesurait le générateur au
51
+ * lieu de mesurer l'usage, et on aurait archivé ce chiffre-là.
52
+ *
53
+ * `out`, `dist`, `build` et `.next` étaient déjà là ; il manquait les
54
+ * conventions de sauvegarde et le dossier de travail de ce produit. Aucune de
55
+ * ces entrées n'est un nom propre : ce sont des conventions, et un dépôt qui
56
+ * y range de la source a de plus gros soucis que sa grille.
57
+ */
41
58
  const IGNORE = new Set([
42
59
  "node_modules", ".git", ".next", "dist", "build", "out", "coverage",
43
60
  ".turbo", ".vercel", "vendor", "__snapshots__",
61
+ "backups", "backup", ".backups", "_next", ".designwithai", ".cache", "tmp",
44
62
  ]);
45
63
  /** Plafonds de parcours : un dépôt réel ne se lit pas entièrement à chaud. */
46
64
  const MAX_FICHIERS = 4000;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * LE DELTA DE BRANCHE — ce que cette branche change au registre.
3
+ *
4
+ * Deux registres, celui de la base et celui de la branche, et la même
5
+ * `diffFindings` que partout ailleurs — avec ses DÉPLACÉS. C'est le point :
6
+ * un sélecteur renommé ne devient pas « réglé » dans un commentaire de PR
7
+ * plus qu'ailleurs, et la catégorie qui dit « je ne sais pas » existe ici
8
+ * aussi.
9
+ *
10
+ * AUCUN SERVICE À NOUS DANS LA BOUCLE. Les fichiers du registre sont dans git ;
11
+ * le calcul est déterministe et local. Le commentaire est posté par l'Action
12
+ * avec le jeton standard de GitHub — pas de secret designwithai, pas d'appel
13
+ * sortant, rien à héberger.
14
+ *
15
+ * Aucune dépendance de valeur hors des modules purs.
16
+ */
17
+ import { diffFindings } from "./finding-identity.js";
18
+ import { estProuve } from "./registry-events.js";
19
+ /** Tous les constats d'un jeu de revues, à plat. */
20
+ export function constats(reviews) {
21
+ return reviews.flatMap((r) => r.findings);
22
+ }
23
+ /**
24
+ * Ce que la branche change.
25
+ *
26
+ * « Réglé » se mesure sur DEUX conditions réunies : le constat a disparu de la
27
+ * branche, ET il portait une vérification. Un constat simplement absent est
28
+ * « résolu » au sens du diff — mais rien ne prouve qu'il a été corrigé, et
29
+ * c'est pourquoi il ne s'affiche pas comme réglé.
30
+ */
31
+ export function delta(base, branche) {
32
+ const avant = constats(base);
33
+ const apres = constats(branche);
34
+ const d = diffFindings(avant, apres);
35
+ /* Un constat disparu de la branche : est-ce réglé ? On le dit seulement si la
36
+ branche porte sa vérification. Sinon il est simplement absent, et c'est un
37
+ autre mot. */
38
+ const versionApres = (f) => apres.find((x) => (x.title ?? x.summary) === (f.title ?? f.summary)) ?? f;
39
+ /* CE QUE LA BRANCHE CHANGE, PAS CE QU'ELLE CONTIENT. Un constat déjà prouvé
40
+ dans la base n'est pas réglé PAR cette branche : le recompter ferait
41
+ grossir le chiffre à chaque PR sans que personne ne corrige rien. */
42
+ const dejaProuve = new Set(avant.filter(estProuve).map((f) => f.title ?? f.summary ?? ""));
43
+ const reglesProuves = apres.filter((f) => estProuve(f) && !dejaProuve.has(f.title ?? f.summary ?? ""));
44
+ const declaresCorriges = apres.filter((f) => !estProuve(f) && (f.status === "fixed" || f.status === "partial"));
45
+ /* UN CONSTAT N'APPARAÎT QUE DANS UNE CASE. Un défaut trouvé ET corrigé dans
46
+ la même branche arrive « nouveau » au sens du diff, mais il est déjà
47
+ annoncé comme corrigé plus haut. Le laisser dans les deux gonflerait le
48
+ compte de deux façons opposées dans le même commentaire. */
49
+ const dejaDit = new Set([...reglesProuves, ...declaresCorriges]);
50
+ return {
51
+ reglesProuves,
52
+ declaresCorriges,
53
+ nouveaux: d.added.filter((f) => !dejaDit.has(f)),
54
+ deplaces: (d.deplaces ?? []).map((x) => ({ avant: x.avant, apres: versionApres(x.apres) })),
55
+ persistants: d.persisting.filter((f) => !dejaDit.has(f) && f.status !== "fixed"),
56
+ };
57
+ }
58
+ const titre = (f) => f.title || f.summary || "un constat";
59
+ /** Le commentaire de PR, en Markdown. Une ligne de comptes, le détail dépliable. */
60
+ export function rendreDelta(d, opts = {}) {
61
+ /* LA BASE SANS REGISTRE N'EST PAS UNE BASE VIDE. Rendre « 12 nouveaux » quand
62
+ la base n'a simplement jamais versionné son registre transforme une absence
63
+ d'information en accusation. On le dit, et on ne compte rien. */
64
+ if (opts.baseSansRegistre) {
65
+ return [
66
+ `**Pas de comparaison possible** — ${opts.base ? `\`${opts.base}\`` : "la base"} ne contient aucun fichier de registre.`,
67
+ "",
68
+ "Ce n'est pas « aucun constat » : c'est « rien à comparer ». Le delta suppose que " +
69
+ "`.designwithai/` est versionné des deux côtés. S'il est dans le `.gitignore`, " +
70
+ "cette comparaison ne peut pas exister — et un delta rendu quand même dirait le contraire de la vérité.",
71
+ ].join("\n");
72
+ }
73
+ const bouts = [
74
+ `${d.reglesProuves.length} réglé${d.reglesProuves.length > 1 ? "s" : ""} (vérifié${d.reglesProuves.length > 1 ? "s" : ""})`,
75
+ `${d.nouveaux.length} nouveau${d.nouveaux.length > 1 ? "x" : ""}`,
76
+ `${d.deplaces.length} déplacé${d.deplaces.length > 1 ? "s" : ""}`,
77
+ `${d.persistants.length} persistant${d.persistants.length > 1 ? "s" : ""}`,
78
+ ];
79
+ const l = [];
80
+ l.push(`**${opts.branche ? `${opts.branche} : ` : "Cette branche : "}${bouts.join(" · ")}**`);
81
+ if (d.declaresCorriges.length > 0) {
82
+ const n = d.declaresCorriges.length;
83
+ l.push("");
84
+ l.push(`${n} autre${n > 1 ? "s sont déclarés" : " est déclaré"} corrigé${n > 1 ? "s" : ""} ` +
85
+ `**sans vérification** — non compté${n > 1 ? "s" : ""} dans les réglés.`);
86
+ }
87
+ const bloc = (nom, lignes) => {
88
+ if (lignes.length === 0)
89
+ return;
90
+ l.push("");
91
+ l.push(`<details><summary>${nom} (${lignes.length})</summary>`);
92
+ l.push("");
93
+ for (const x of lignes)
94
+ l.push(`- ${x}`);
95
+ l.push("");
96
+ l.push("</details>");
97
+ };
98
+ bloc("Réglés, avec preuve", d.reglesProuves.map((f) => `${titre(f)} — vérifié contre ${f.verified_by}`));
99
+ bloc("Déclarés corrigés, sans vérification", d.declaresCorriges.map(titre));
100
+ bloc("Nouveaux", d.nouveaux.map((f) => `${titre(f)}${f.severity ? ` (${f.severity})` : ""}`));
101
+ bloc("Déplacés — ni réglés, ni nouveaux", d.deplaces.map((x) => `${titre(x.apres)} — « ${x.avant.ancre?.selector ?? "?"} » → « ${x.apres.ancre?.selector ?? "?"} ». ` +
102
+ "Même défaut renommé, ou deux éléments jumeaux : rien ici ne permet de trancher."));
103
+ bloc("Persistants", d.persistants.map((f) => `${titre(f)}${f.severity ? ` (${f.severity})` : ""}`));
104
+ l.push("");
105
+ l.push("<sub>Dérivé du registre versionné, en comparant la base et la branche. Aucun service tiers n'intervient.</sub>");
106
+ return l.join("\n");
107
+ }