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.
- package/dist/ds-diff.js +268 -0
- package/dist/ds-inventory.js +247 -0
- package/dist/evals.js +71 -4
- package/dist/inventory-tool.js +108 -0
- package/dist/server.js +98 -0
- package/package.json +10 -4
- package/skills/EVALS.md +49 -1
- package/skills/_shared/execution-contract.md +35 -0
- package/skills/_shared/issue-schema.md +16 -1
- package/skills/_shared/severity-model.md +43 -0
- package/skills/analyze-figma/evals/cases.json +11 -9
- package/skills/analyze-url/evals/cases.json +27 -8
- package/skills/better-accessibility/SKILL.md +4 -0
- package/skills/better-accessibility/evals/cases.json +18 -5
- package/skills/better-components/SKILL.md +13 -2
- package/skills/better-components/evals/cases.json +20 -5
- package/skills/better-consistency/SKILL.md +12 -1
- package/skills/better-consistency/evals/cases.json +19 -5
- package/skills/better-forms/SKILL.md +12 -1
- package/skills/better-forms/evals/cases.json +17 -4
- package/skills/better-interface/SKILL.md +12 -1
- package/skills/better-interface/evals/cases.json +19 -5
- package/skills/better-layout/SKILL.md +12 -1
- package/skills/better-layout/evals/cases.json +14 -4
- package/skills/better-navigation/SKILL.md +12 -1
- package/skills/better-performance/SKILL.md +12 -1
- package/skills/better-responsive/SKILL.md +12 -1
- package/skills/better-responsive/evals/cases.json +20 -5
- package/skills/better-states/SKILL.md +12 -1
- package/skills/better-states/evals/cases.json +19 -5
- package/skills/better-writing/SKILL.md +12 -1
- package/skills/better-writing/evals/cases.json +18 -4
- package/skills/review-ai-product/SKILL.md +12 -1
- package/skills/review-data-security/SKILL.md +12 -1
- package/skills/review-feature-framing/SKILL.md +171 -0
- package/skills/review-feature-framing/evals/cases.json +130 -0
- package/skills/review-feature-impact/SKILL.md +186 -0
- package/skills/review-feature-impact/evals/cases.json +132 -0
- package/skills/review-figma-hygiene/SKILL.md +13 -2
- package/skills/review-figma-hygiene/evals/cases.json +9 -7
package/dist/ds-diff.js
ADDED
|
@@ -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
|
-
|
|
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
|