designwithai-mcp 0.1.0 → 0.4.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 (40) hide show
  1. package/dist/ds-diff.js +268 -0
  2. package/dist/ds-inventory.js +247 -0
  3. package/dist/evals.js +71 -4
  4. package/dist/inventory-tool.js +108 -0
  5. package/dist/server.js +98 -0
  6. package/package.json +10 -4
  7. package/skills/EVALS.md +49 -1
  8. package/skills/_shared/execution-contract.md +35 -0
  9. package/skills/_shared/issue-schema.md +16 -1
  10. package/skills/_shared/severity-model.md +43 -0
  11. package/skills/analyze-figma/evals/cases.json +11 -9
  12. package/skills/analyze-url/evals/cases.json +27 -8
  13. package/skills/better-accessibility/SKILL.md +4 -0
  14. package/skills/better-accessibility/evals/cases.json +18 -5
  15. package/skills/better-components/SKILL.md +13 -2
  16. package/skills/better-components/evals/cases.json +20 -5
  17. package/skills/better-consistency/SKILL.md +12 -1
  18. package/skills/better-consistency/evals/cases.json +19 -5
  19. package/skills/better-forms/SKILL.md +12 -1
  20. package/skills/better-forms/evals/cases.json +17 -4
  21. package/skills/better-interface/SKILL.md +12 -1
  22. package/skills/better-interface/evals/cases.json +19 -5
  23. package/skills/better-layout/SKILL.md +12 -1
  24. package/skills/better-layout/evals/cases.json +14 -4
  25. package/skills/better-navigation/SKILL.md +12 -1
  26. package/skills/better-performance/SKILL.md +12 -1
  27. package/skills/better-responsive/SKILL.md +12 -1
  28. package/skills/better-responsive/evals/cases.json +20 -5
  29. package/skills/better-states/SKILL.md +12 -1
  30. package/skills/better-states/evals/cases.json +19 -5
  31. package/skills/better-writing/SKILL.md +12 -1
  32. package/skills/better-writing/evals/cases.json +18 -4
  33. package/skills/review-ai-product/SKILL.md +12 -1
  34. package/skills/review-data-security/SKILL.md +12 -1
  35. package/skills/review-feature-framing/SKILL.md +171 -0
  36. package/skills/review-feature-framing/evals/cases.json +130 -0
  37. package/skills/review-feature-impact/SKILL.md +186 -0
  38. package/skills/review-feature-impact/evals/cases.json +132 -0
  39. package/skills/review-figma-hygiene/SKILL.md +13 -2
  40. package/skills/review-figma-hygiene/evals/cases.json +9 -7
@@ -0,0 +1,268 @@
1
+ /**
2
+ * LE DIFF D'ALIGNEMENT DS — comparateur déterministe, définition unique.
3
+ *
4
+ * Troisième module de ce dépôt à suivre le pattern de `finding-identity.ts`, et
5
+ * pour la même raison : deux surfaces ont besoin du MÊME calcul, et l'écrire
6
+ * deux fois garantit qu'elles diront un jour deux choses différentes des mêmes
7
+ * faits.
8
+ *
9
+ * - le serveur MCP compare depuis l'éditeur, au moment où il lit le code ;
10
+ * - le site compare depuis le cockpit, dès que les deux inventaires sont en
11
+ * base — SANS repasser par l'éditeur.
12
+ *
13
+ * CONTRAINTE À TENIR : ce fichier ne dépend de rien. Ni `node:`, ni `Deno`, ni
14
+ * un autre module du dépôt. Il part dans un bundle navigateur.
15
+ *
16
+ * Ce qu'il fait, et rien d'autre : des ENSEMBLES. Présent des deux côtés,
17
+ * absent ici, absent là, divergent. Le rapprochement flou des noms
18
+ * (Button ↔ Btn ↔ bouton-primaire) n'est pas de son ressort : il arrive en
19
+ * entrée, sous forme de paires déjà établies et déjà prouvées.
20
+ */
21
+ /** Normalisation POUR LA COMPARAISON seulement. Les noms d'origine sont gardés. */
22
+ export function normaliser(nom) {
23
+ return nom
24
+ .toLowerCase()
25
+ .normalize("NFD")
26
+ .replace(/[̀-ͯ]/g, "")
27
+ .replace(/[^a-z0-9]+/g, "");
28
+ }
29
+ /** Deux valeurs disent-elles la même chose ? Les couleurs comprises. */
30
+ export function memeValeur(a, b) {
31
+ if (a === undefined || b === undefined)
32
+ return true;
33
+ const c = (v) => v.trim().toLowerCase().replace(/\s+/g, "");
34
+ const x = c(a);
35
+ const y = c(b);
36
+ if (x === y)
37
+ return true;
38
+ const hex = (v) => {
39
+ const m = v.match(/^#([0-9a-f]{3,8})$/);
40
+ if (!m)
41
+ return null;
42
+ const h = m[1];
43
+ return h.length === 3 ? h.split("").map((d) => d + d).join("") : h;
44
+ };
45
+ const hx = hex(x);
46
+ const hy = hex(y);
47
+ if (hx && hy)
48
+ return hx.slice(0, 6) === hy.slice(0, 6);
49
+ /* 16px contre 1rem n'est PAS rapproché : une unité différente est un vrai
50
+ écart, et le deviner reviendrait à supposer une taille de base. */
51
+ return false;
52
+ }
53
+ const VIDE = {
54
+ aligne: 0,
55
+ "absent-figma": 0,
56
+ "absent-code": 0,
57
+ divergent: 0,
58
+ "non-jugeable": 0,
59
+ };
60
+ /**
61
+ * Compare deux inventaires. Déterministe, sans réseau, sans modèle.
62
+ *
63
+ * LA RÈGLE D'ABSENCE, appliquée ici comme partout : un écart « absent » n'est
64
+ * un constat que si l'inventaire d'en face est COMPLET. Sur un inventaire
65
+ * partiel, ce qu'on ne trouve pas est peut-être seulement ce qu'on n'a pas su
66
+ * lire — l'écart devient `non-jugeable`, et l'écran le dit au lieu de le
67
+ * compter comme une faute.
68
+ */
69
+ export function comparer(figma, code, rapprochements = []) {
70
+ const parNom = (inv) => {
71
+ const m = new Map();
72
+ for (const it of inv.items)
73
+ m.set(`${it.kind} ${normaliser(it.name)}`, it);
74
+ return m;
75
+ };
76
+ const F = parNom(figma);
77
+ const C = parNom(code);
78
+ /* Les paires établies ailleurs, chacune avec sa preuve. */
79
+ const alias = new Map();
80
+ for (const r of rapprochements)
81
+ alias.set(normaliser(r.code), r);
82
+ const ecarts = [];
83
+ const vus = new Set();
84
+ for (const [cle, item] of F) {
85
+ const kind = cle.split(" ")[0];
86
+ let jumeau = C.get(cle);
87
+ let rapproche;
88
+ if (!jumeau) {
89
+ for (const [cCle, cItem] of C) {
90
+ if (!cCle.startsWith(`${kind} `))
91
+ continue;
92
+ const r = alias.get(normaliser(cItem.name));
93
+ if (r && normaliser(r.figma) === normaliser(item.name)) {
94
+ jumeau = cItem;
95
+ rapproche = r.evidence;
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ if (jumeau) {
101
+ vus.add(`${jumeau.kind} ${normaliser(jumeau.name)}`);
102
+ const aligne = memeValeur(item.value, jumeau.value);
103
+ ecarts.push({
104
+ kind: aligne ? "aligne" : "divergent",
105
+ itemKind: item.kind,
106
+ name: item.name,
107
+ figma: item,
108
+ code: jumeau,
109
+ evidence: aligne
110
+ ? `« ${item.name} » présent des deux côtés${item.value ? `, valeur ${item.value}` : ""}.`
111
+ : `« ${item.name} » vaut ${item.value} côté maquette et ${jumeau.value} côté code.`,
112
+ rapproche,
113
+ });
114
+ continue;
115
+ }
116
+ ecarts.push({
117
+ kind: code.partial ? "non-jugeable" : "absent-code",
118
+ itemKind: item.kind,
119
+ name: item.name,
120
+ figma: item,
121
+ evidence: code.partial
122
+ ? `« ${item.name} » n'apparaît pas dans l'inventaire du code, mais cet inventaire est PARTIEL : son absence ne prouve rien.`
123
+ : `« ${item.name} » figure dans la maquette${item.location ? ` (${item.location})` : ""} et dans aucun des ${code.items.length} éléments lus côté code.`,
124
+ });
125
+ }
126
+ for (const [cle, item] of C) {
127
+ if (vus.has(cle))
128
+ continue;
129
+ ecarts.push({
130
+ kind: figma.partial ? "non-jugeable" : "absent-figma",
131
+ itemKind: item.kind,
132
+ name: item.name,
133
+ code: item,
134
+ evidence: figma.partial
135
+ ? `« ${item.name} » n'apparaît pas dans l'inventaire de la maquette, mais cette lecture est PARTIELLE : son absence ne prouve rien.`
136
+ : `« ${item.name} » existe en code${item.location ? ` (${item.location})` : ""} et dans aucun des ${figma.items.length} éléments lus côté maquette.`,
137
+ });
138
+ }
139
+ const compte = { ...VIDE };
140
+ for (const e of ecarts)
141
+ compte[e.kind]++;
142
+ const rang = {
143
+ divergent: 0,
144
+ "absent-code": 1,
145
+ "absent-figma": 2,
146
+ "non-jugeable": 3,
147
+ aligne: 4,
148
+ };
149
+ ecarts.sort((a, b) => rang[a.kind] - rang[b.kind] || a.name.localeCompare(b.name));
150
+ return {
151
+ ecarts,
152
+ compte,
153
+ partial: figma.partial || code.partial,
154
+ couverture: { figma: figma.covered, code: code.covered },
155
+ nonLu: { figma: figma.unread, code: code.unread },
156
+ };
157
+ }
158
+ /* ═══════════════════════════════════════════════════════════════════════════
159
+ LE RAPPROCHEMENT FLOU — quand les deux côtés parlent de la même chose sans
160
+ l'appeler pareil.
161
+
162
+ POURQUOI IL VIT DANS CE FICHIER ET PAS À CÔTÉ. Il était dans `ds-match.ts`,
163
+ et il y importait `normaliser` d'ici. Ça compile sous Node, ça casse au
164
+ bundle : `moduleResolution: NodeNext` exige l'extension `.js`, et le
165
+ bundler du site, lui, ne sait pas la faire pointer vers un `.ts`.
166
+
167
+ La règle du dépôt existait déjà, écrite en tête de `severity.ts` : un module
168
+ qui part dans le bundle navigateur NE DÉPEND DE RIEN. `ds-diff` et
169
+ `finding-identity` la tenaient ; le rapprochement a été le premier à
170
+ l'enfreindre, et le build l'a dit tout de suite.
171
+
172
+ Le remède aurait pu être une copie de `normaliser` de l'autre côté. C'eût
173
+ été le pire : deux normalisations qui dérivent, et un rapprochement qui
174
+ cesse silencieusement de trouver ce que la comparaison trouve. Rapprocher et
175
+ comparer sont deux moitiés d'une même opération et partagent déjà cette
176
+ fonction — elles vivent donc au même endroit.
177
+ ═══════════════════════════════════════════════════════════════════════════ */
178
+ /**
179
+ * Le nom découpé en segments comparables.
180
+ *
181
+ * Un nom de design system est composé, pas rédigé : `brand/primary`,
182
+ * `--color-brand-primary`, `ColorBrandPrimary` disent la même chose avec trois
183
+ * conventions. On coupe donc sur les séparateurs ET sur les bosses de casse,
184
+ * puis on met à plat. Les suites de chiffres restent des segments : `space-4`
185
+ * et `space-8` ne doivent pas se rapprocher.
186
+ */
187
+ export function segments(nom) {
188
+ return nom
189
+ .normalize("NFD")
190
+ .replace(/[̀-ͯ]/g, "")
191
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
192
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
193
+ .split(/[^A-Za-z0-9]+/)
194
+ .flatMap((m) => m.split(/(?<=[a-zA-Z])(?=[0-9])|(?<=[0-9])(?=[a-zA-Z])/))
195
+ .map((m) => m.toLowerCase())
196
+ .filter((m) => m.length > 0);
197
+ }
198
+ /** Au moins deux segments communs. Un seul mot partagé ne prouve rien. */
199
+ export const MIN_PARTAGES = 2;
200
+ /**
201
+ * Le seuil de Dice.
202
+ *
203
+ * 0,5 veut dire : la moitié des segments des deux noms réunis sont communs.
204
+ * `brand/primary` contre `--color-brand-primary` fait 0,8 ; `text/body large`
205
+ * contre `--font-body-large` fait 0,67 ; `brand/primary` contre
206
+ * `--color-brand-secondary` tombe sous le plancher des deux segments avant même
207
+ * d'être mesuré. Le seuil est haut à dessein : ce module a le droit de rater un
208
+ * rapprochement, il n'a pas le droit d'en inventer un.
209
+ */
210
+ export const SEUIL = 0.5;
211
+ /** Dice sur les ensembles de segments : 2·|A∩B| / (|A|+|B|). */
212
+ export function score(a, b) {
213
+ const A = new Set(segments(a));
214
+ const B = new Set(segments(b));
215
+ if (A.size === 0 || B.size === 0)
216
+ return { dice: 0, partages: [] };
217
+ const partages = [...A].filter((s) => B.has(s));
218
+ return { dice: (2 * partages.length) / (A.size + B.size), partages };
219
+ }
220
+ /**
221
+ * Les paires établies entre deux inventaires.
222
+ *
223
+ * Le résultat est stable : même entrée, même sortie, dans le même ordre. Les
224
+ * éléments dont les noms se normalisent à l'identique sont laissés au
225
+ * comparateur exact — les rapprocher ici les compterait deux fois.
226
+ */
227
+ export function rapprocher(figma, code) {
228
+ const exacts = new Set(code.map((c) => `${c.kind} ${normaliser(c.name)}`));
229
+ const pris = new Set();
230
+ const paires = [];
231
+ for (const f of figma) {
232
+ const cle = `${f.kind} ${normaliser(f.name)}`;
233
+ if (exacts.has(cle))
234
+ continue; // le comparateur exact s'en charge
235
+ let meilleur = null;
236
+ let exaequo = false;
237
+ for (const c of code) {
238
+ if (c.kind !== f.kind)
239
+ continue; // garde-fou 1
240
+ if (pris.has(`${c.kind} ${normaliser(c.name)}`))
241
+ continue;
242
+ if (normaliser(c.name) === normaliser(f.name))
243
+ continue;
244
+ const { dice, partages } = score(f.name, c.name);
245
+ if (partages.length < MIN_PARTAGES || dice < SEUIL)
246
+ continue; // garde-fous 2
247
+ if (!meilleur || dice > meilleur.dice) {
248
+ meilleur = { item: c, dice, partages };
249
+ exaequo = false;
250
+ }
251
+ else if (dice === meilleur.dice) {
252
+ exaequo = true; // garde-fou 3
253
+ }
254
+ }
255
+ /* L'ambiguïté ne se tranche pas. Deux candidats au même score veut dire
256
+ qu'on ne sait pas — et « je ne sais pas » ne se déguise pas en paire. */
257
+ if (!meilleur || exaequo)
258
+ continue;
259
+ pris.add(`${meilleur.item.kind} ${normaliser(meilleur.item.name)}`);
260
+ paires.push({
261
+ figma: f.name,
262
+ code: meilleur.item.name,
263
+ evidence: `« ${f.name} » et « ${meilleur.item.name} » partagent ` +
264
+ `${meilleur.partages.map((p) => `« ${p} »`).join(", ")}.`,
265
+ });
266
+ }
267
+ return paires;
268
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * L'INVENTAIRE DU CODE — déterministe, et honnête sur ce qu'il ignore.
3
+ *
4
+ * Pas de repli sur un modèle. Cet extracteur sait lire une LISTE ÉCRITE de
5
+ * formats, et rien d'autre. Ce qu'il rencontre sans savoir le lire n'est pas
6
+ * deviné : il alimente `unread`, lève `partial`, et se dit à l'écran.
7
+ *
8
+ * Ce choix a une conséquence directe, et c'est tout l'intérêt : sur un
9
+ * inventaire partiel, le comparateur refuse de conclure à une absence (voir
10
+ * `ds-diff.ts`). Un extracteur qui prétendrait tout lire produirait des
11
+ * « composants manquants » qui ne sont que des fichiers non compris. Mieux vaut
12
+ * une couverture étroite et dite qu'une couverture large et supposée.
13
+ */
14
+ import { readFileSync, readdirSync, statSync } from "node:fs";
15
+ import path from "node:path";
16
+ /**
17
+ * CE QUE JE SAIS LIRE. Cette liste part telle quelle dans l'inventaire, et
18
+ * s'affiche à la personne. L'allonger demande d'écrire l'extracteur qui va
19
+ * avec — jamais l'inverse.
20
+ */
21
+ export const SAIT_LIRE = [
22
+ "tokens au format DTCG (*.tokens.json, tokens.json)",
23
+ "variables CSS personnalisées (--nom: valeur)",
24
+ "thème Tailwind (tailwind.config.*)",
25
+ "composants exportés (*.tsx, *.jsx)",
26
+ "stories Storybook (*.stories.*)",
27
+ ];
28
+ const IGNORE = new Set([
29
+ "node_modules", ".git", ".next", "dist", "build", "out", "coverage",
30
+ ".turbo", ".vercel", "vendor", "__snapshots__",
31
+ ]);
32
+ /** Plafonds de parcours : un dépôt réel ne se lit pas entièrement à chaud. */
33
+ const MAX_FICHIERS = 4000;
34
+ const MAX_OCTETS = 400_000;
35
+ const pascal = (s) => /^[A-Z][A-Za-z0-9]*$/.test(s);
36
+ /** Les tokens DTCG : un arbre dont les feuilles portent `$value`. */
37
+ function lireDtcg(etat, contenu, fichier) {
38
+ let arbre;
39
+ try {
40
+ arbre = JSON.parse(contenu);
41
+ }
42
+ catch {
43
+ etat.unread.add(`${fichier} — JSON illisible`);
44
+ return;
45
+ }
46
+ const descendre = (noeud, chemin) => {
47
+ if (!noeud || typeof noeud !== "object")
48
+ return;
49
+ const o = noeud;
50
+ if ("$value" in o) {
51
+ etat.items.push({
52
+ kind: "token",
53
+ name: chemin.join("."),
54
+ type: typeof o.$type === "string" ? o.$type : undefined,
55
+ value: typeof o.$value === "string" ? o.$value : JSON.stringify(o.$value),
56
+ location: fichier,
57
+ });
58
+ return;
59
+ }
60
+ for (const [cle, valeur] of Object.entries(o)) {
61
+ if (cle.startsWith("$"))
62
+ continue;
63
+ descendre(valeur, [...chemin, cle]);
64
+ }
65
+ };
66
+ descendre(arbre, []);
67
+ }
68
+ /** Les variables CSS personnalisées, quelle que soit leur règle d'accueil. */
69
+ function lireCss(etat, contenu, fichier) {
70
+ const re = /--([a-zA-Z0-9_-]+)\s*:\s*([^;}]+)[;}]/g;
71
+ let m;
72
+ const vus = new Set();
73
+ while ((m = re.exec(contenu))) {
74
+ const nom = m[1];
75
+ if (vus.has(nom))
76
+ continue;
77
+ vus.add(nom);
78
+ etat.items.push({
79
+ kind: "token",
80
+ name: nom,
81
+ type: "css-variable",
82
+ value: m[2].trim(),
83
+ location: fichier,
84
+ });
85
+ }
86
+ }
87
+ /**
88
+ * Le thème Tailwind.
89
+ *
90
+ * Une config est du JavaScript : l'évaluer exécuterait du code du dépôt
91
+ * audité, ce qui est hors de question. On lit donc les clés déclarées dans
92
+ * `theme` / `extend` par lecture de surface — déterministe, et volontairement
93
+ * limité aux formes simples. Une config qui compose, importe ou calcule ses
94
+ * valeurs part dans `unread` : dire qu'on n'a pas compris coûte moins cher
95
+ * qu'un inventaire faux.
96
+ */
97
+ function lireTailwind(etat, contenu, fichier) {
98
+ const bloc = contenu.match(/theme\s*:\s*\{([\s\S]*)\}/);
99
+ if (!bloc) {
100
+ etat.unread.add(`${fichier} — aucun bloc theme reconnaissable`);
101
+ return;
102
+ }
103
+ const groupes = ["colors", "spacing", "borderRadius", "fontSize", "fontFamily", "boxShadow"];
104
+ let trouve = 0;
105
+ for (const groupe of groupes) {
106
+ const g = bloc[1].match(new RegExp(`${groupe}\\s*:\\s*\\{([^{}]*)\\}`));
107
+ if (!g)
108
+ continue;
109
+ const re = /["']?([a-zA-Z0-9_-]+)["']?\s*:\s*["']([^"']+)["']/g;
110
+ let m;
111
+ while ((m = re.exec(g[1]))) {
112
+ trouve++;
113
+ etat.items.push({
114
+ kind: "token",
115
+ name: `${groupe}.${m[1]}`,
116
+ type: groupe,
117
+ value: m[2],
118
+ location: fichier,
119
+ });
120
+ }
121
+ }
122
+ if (trouve === 0)
123
+ etat.unread.add(`${fichier} — thème présent mais aucune valeur littérale lisible`);
124
+ if (/\.\.\.|require\(|import\s/.test(bloc[1])) {
125
+ etat.unread.add(`${fichier} — le thème compose ou importe : lecture de surface incomplète`);
126
+ }
127
+ }
128
+ /** Les composants exportés. Un nom en PascalCase, exporté, fait foi. */
129
+ function lireComposants(etat, contenu, fichier) {
130
+ const vus = new Set();
131
+ const motifs = [
132
+ /export\s+(?:default\s+)?function\s+([A-Za-z0-9_]+)/g,
133
+ /export\s+const\s+([A-Za-z0-9_]+)\s*[:=]/g,
134
+ ];
135
+ for (const re of motifs) {
136
+ let m;
137
+ while ((m = re.exec(contenu))) {
138
+ const nom = m[1];
139
+ if (!pascal(nom) || vus.has(nom))
140
+ continue;
141
+ vus.add(nom);
142
+ etat.items.push({ kind: "component", name: nom, location: fichier });
143
+ }
144
+ }
145
+ }
146
+ /** Les stories : ce qu'elles documentent, par leur titre ou leur nom de fichier. */
147
+ function lireStories(etat, contenu, fichier) {
148
+ const titre = contenu.match(/title\s*:\s*["'`]([^"'`]+)["'`]/);
149
+ const nom = titre
150
+ ? titre[1].split("/").pop().trim()
151
+ : path.basename(fichier).replace(/\.stories\.[a-z]+$/i, "");
152
+ etat.items.push({ kind: "story", name: nom, location: fichier });
153
+ }
154
+ function parcourir(etat, racine, dossier) {
155
+ if (etat.plafond)
156
+ return;
157
+ let entrees;
158
+ try {
159
+ entrees = readdirSync(dossier);
160
+ }
161
+ catch {
162
+ return;
163
+ }
164
+ for (const entree of entrees) {
165
+ if (etat.plafond)
166
+ return;
167
+ if (entree.startsWith(".") && entree !== ".storybook")
168
+ continue;
169
+ const complet = path.join(dossier, entree);
170
+ let s;
171
+ try {
172
+ s = statSync(complet);
173
+ }
174
+ catch {
175
+ continue;
176
+ }
177
+ if (s.isDirectory()) {
178
+ if (IGNORE.has(entree))
179
+ continue;
180
+ parcourir(etat, racine, complet);
181
+ continue;
182
+ }
183
+ if (++etat.fichiers > MAX_FICHIERS) {
184
+ etat.plafond = true;
185
+ etat.unread.add(`parcours arrêté après ${MAX_FICHIERS} fichiers`);
186
+ return;
187
+ }
188
+ const relatif = path.relative(racine, complet);
189
+ if (s.size > MAX_OCTETS) {
190
+ etat.unread.add(`${relatif} — fichier trop volumineux pour être lu`);
191
+ continue;
192
+ }
193
+ let contenu;
194
+ try {
195
+ contenu = readFileSync(complet, "utf8");
196
+ }
197
+ catch {
198
+ etat.unread.add(`${relatif} — illisible`);
199
+ continue;
200
+ }
201
+ if (/\.stories\.[jt]sx?$/i.test(entree))
202
+ lireStories(etat, contenu, relatif);
203
+ else if (/\.tokens\.json$/i.test(entree) || entree === "tokens.json")
204
+ lireDtcg(etat, contenu, relatif);
205
+ else if (/^tailwind\.config\.[cm]?[jt]s$/i.test(entree))
206
+ lireTailwind(etat, contenu, relatif);
207
+ else if (/\.css$/i.test(entree))
208
+ lireCss(etat, contenu, relatif);
209
+ else if (/\.[jt]sx$/i.test(entree))
210
+ lireComposants(etat, contenu, relatif);
211
+ else if (/\.(scss|sass|less|styl)$/i.test(entree)) {
212
+ etat.unread.add(`${relatif} — préprocesseur non lu`);
213
+ }
214
+ else if (/\.(vue|svelte)$/i.test(entree)) {
215
+ etat.unread.add(`${relatif} — composant ${path.extname(entree).slice(1)} non lu`);
216
+ }
217
+ }
218
+ }
219
+ /**
220
+ * Inventorie un dépôt.
221
+ *
222
+ * `partial` est vrai dès qu'une seule chose n'a pas été comprise. C'est
223
+ * volontairement sévère : le comparateur s'en sert pour refuser de conclure à
224
+ * une absence, et il vaut mieux refuser une conclusion de trop qu'en produire
225
+ * une fausse.
226
+ */
227
+ export function inventorierCode(racine) {
228
+ const etat = { items: [], unread: new Set(), fichiers: 0, plafond: false };
229
+ parcourir(etat, racine, racine);
230
+ /* Storybook n'est pas une troisième source : c'est un ATTRIBUT porté par les
231
+ composants du code. Documenté, ou pas. */
232
+ const stories = new Set(etat.items.filter((i) => i.kind === "story").map((i) => i.name.toLowerCase()));
233
+ for (const it of etat.items) {
234
+ if (it.kind !== "component")
235
+ continue;
236
+ it.attrs = { ...(it.attrs ?? {}), documented: stories.has(it.name.toLowerCase()) ? "true" : "false" };
237
+ }
238
+ const unread = [...etat.unread];
239
+ return {
240
+ source: "code",
241
+ items: etat.items.filter((i) => i.kind !== "story"),
242
+ partial: unread.length > 0,
243
+ covered: [...SAIT_LIRE],
244
+ unread,
245
+ at: undefined,
246
+ };
247
+ }
package/dist/evals.js CHANGED
@@ -42,8 +42,44 @@ export function getCase(caseId) {
42
42
  return null;
43
43
  }
44
44
  // ─── Scoring ─────────────────────────────────────────────────────────────────
45
+ /**
46
+ * Les noms de champs, en deux langues — et pourquoi il faut les accepter.
47
+ *
48
+ * `issue-schema.md` décrit le schéma en FRANÇAIS : Sévérité, Localisation,
49
+ * Preuve, Correction. Un juge francophone qui le lit émet donc naturellement
50
+ * `severite` et `localisation`. Le banc, lui, ne lisait que les noms anglais.
51
+ *
52
+ * Ce que ça produisait, mesuré : un juge a rendu trois relais parfaits en
53
+ * sévérité `note` — et le banc, ne trouvant pas `severity`, les a comptés comme
54
+ * TROIS FAUX POSITIFS significatifs. La règle « sévérité inconnue = comptée
55
+ * comme significative » se retournait contre un juge irréprochable, et le
56
+ * corpus qu'il servait perdait aussi sa détection, faute de pouvoir lire ses
57
+ * résumés.
58
+ *
59
+ * Un instrument qui mesure autre chose que ce qu'il prétend est exactement ce
60
+ * que ce produit reproche aux autres. On normalise donc à la lecture, sans rien
61
+ * exiger du juge : les deux langues disent la même chose.
62
+ */
63
+ const ALIAS = {
64
+ severite: "severity",
65
+ gravite: "severity",
66
+ localisation: "location",
67
+ categorie: "category",
68
+ resume: "summary",
69
+ constat: "summary",
70
+ };
71
+ function normaliser(f) {
72
+ const brut = f;
73
+ const out = { ...brut };
74
+ for (const [fr, en] of Object.entries(ALIAS)) {
75
+ if (out[en] === undefined && brut[fr] !== undefined)
76
+ out[en] = brut[fr];
77
+ }
78
+ return out;
79
+ }
45
80
  function findingText(f) {
46
- return [f.location, f.category, f.summary, f.correction].filter(Boolean).join(" ");
81
+ const n = normaliser(f);
82
+ return [n.location, n.category, n.summary, n.correction].filter(Boolean).join(" ");
47
83
  }
48
84
  /**
49
85
  * Le matcher n'habite plus ici.
@@ -56,7 +92,38 @@ function findingText(f) {
56
92
  * chiffres pour la même réalité.
57
93
  */
58
94
  function phraseMatched(phrase, findings) {
59
- return findings.some((f) => matches(phrase, findingText(f)));
95
+ return findings.some((f) => !estRelais(f) && matches(phrase, findingText(f)));
96
+ }
97
+ /**
98
+ * LE RELAIS — ni violation, ni détection.
99
+ *
100
+ * Chaque SKILL.md impose une section « À passer à un autre skill : constat
101
+ * pressenti -> skill concerné, sans le développer ». Un juge qui refuse un
102
+ * défaut hors de son périmètre et le RELAIE fait exactement ce que son contrat
103
+ * exige.
104
+ *
105
+ * Le banc comptait ce relais comme une violation de périmètre, puisque la
106
+ * phrase relayée est justement celle que la golden interdit. Autrement dit : il
107
+ * punissait le bon comportement. Trente cas sur quarante échecs de la première
108
+ * passe indépendante venaient de là — et le cas le plus net,
109
+ * `a11y-out-of-scope-hierarchy`, était un juge irréprochable : il a refusé,
110
+ * relayé, et n'a rien développé.
111
+ *
112
+ * La définition reste ÉTROITE, à dessein. Deux conditions cumulatives :
113
+ *
114
+ * 1. sévérité `note` — un relais n'est pas une revendication ;
115
+ * 2. le texte NOMME explicitement un autre skill de la maison.
116
+ *
117
+ * Un constat de sévérité majeure ou au-delà sur le domaine d'un autre juge
118
+ * reste une violation : c'est du vrai débordement, et cette correction ne
119
+ * l'amnistie pas. `scripts/test-scoring.ts` fige les deux cas.
120
+ */
121
+ const NOM_DE_SKILL = /\b(better|review|analyze)-[a-z]+(?:-[a-z]+)*\b/;
122
+ export function estRelais(f) {
123
+ const severite = String(normaliser(f).severity ?? "").toLowerCase().trim();
124
+ if (severite !== "note")
125
+ return false;
126
+ return NOM_DE_SKILL.test(findingText(f));
60
127
  }
61
128
  /**
62
129
  * Un finding inventé ne compte comme FAUX POSITIF que s'il est SIGNIFICATIF :
@@ -66,7 +133,7 @@ function phraseMatched(phrase, findings) {
66
133
  * inconnue : traitée comme significative — le doute ne protège pas le skill.
67
134
  */
68
135
  function isSignificant(f) {
69
- const s = f.severity;
136
+ const s = String(normaliser(f).severity ?? "").toLowerCase().trim();
70
137
  return s !== "mineur" && s !== "note";
71
138
  }
72
139
  export function scoreCase(c, cand, now) {
@@ -82,7 +149,7 @@ export function scoreCase(c, cand, now) {
82
149
  // le banc prouve. Aucun verdict attendu → la condition ne s'applique pas.
83
150
  const verdict_ok = !g.expected_verdict ? true : cand.verdict === g.expected_verdict;
84
151
  const scope_ok = forbidden_hits.length === 0;
85
- const extras = cand.findings.filter((f) => !g.expected_findings.some((p) => matches(p, findingText(f))));
152
+ const extras = cand.findings.filter((f) => !estRelais(f) && !g.expected_findings.some((p) => matches(p, findingText(f))));
86
153
  const extra_findings = extras.length;
87
154
  const extra_significant = extras.filter(isSignificant).length;
88
155
  // Passe si : refus correct, périmètre respecté, verdict conforme quand il est