@md-code/react-component-uniqueness 0.1.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/signature.js ADDED
@@ -0,0 +1,663 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * signature.js
5
+ *
6
+ * Component-signature machinery shared by the rule and the catalog scanner.
7
+ *
8
+ * A Signature is the normalized, order-insensitive description of one JSX
9
+ * element:
10
+ *
11
+ * {
12
+ * tag: "div",
13
+ * styles: { "display": ["flex"], "gap": ["1rem"], ... }, // CSS prop -> candidate values
14
+ * actions: ["click", ...], // event handlers (onClick -> click)
15
+ * a11y: ["role:dialog", "aria-modal:true", ...],
16
+ * data: ["data-testid:...", ...],
17
+ * }
18
+ *
19
+ * All style sources — tailwind className, inline style={...}, and
20
+ * styled-components CSS — are converted into the same CSS-property space,
21
+ * so "flex gap-4" (tailwind), { display: "flex", gap: "1rem" } (inline), and
22
+ * "display: flex; gap: 1rem;" (styled) all describe the same element.
23
+ */
24
+
25
+ /** Tailwind utility class -> [cssProperty, cssValue] (value null = property presence). */
26
+ const TW = {
27
+ flex: ["display", "flex"],
28
+ "flex-1": ["flex", "1"],
29
+ "flex-auto": ["flex", "auto"],
30
+ "flex-none": ["flex", "none"],
31
+ block: ["display", "block"],
32
+ "inline-block": ["display", "inline-block"],
33
+ hidden: ["display", "none"],
34
+ grid: ["display", "grid"],
35
+ "inline-grid": ["display", "inline-grid"],
36
+ "flex-col": ["flex-direction", "column"],
37
+ "flex-row": ["flex-direction", "row"],
38
+ "flex-wrap": ["flex-wrap", "wrap"],
39
+ "flex-nowrap": ["flex-wrap", "nowrap"],
40
+ "flex-col-reverse": ["flex-direction", "column-reverse"],
41
+ "flex-row-reverse": ["flex-direction", "row-reverse"],
42
+ "justify-center": ["justify-content", "center"],
43
+ "justify-between": ["justify-content", "space-between"],
44
+ "justify-around": ["justify-content", "space-around"],
45
+ "justify-start": ["justify-content", "flex-start"],
46
+ "justify-end": ["justify-content", "flex-end"],
47
+ "items-center": ["align-items", "center"],
48
+ "items-start": ["align-items", "flex-start"],
49
+ "items-end": ["align-items", "flex-end"],
50
+ "items-baseline": ["align-items", "baseline"],
51
+ "items-stretch": ["align-items", "stretch"],
52
+ "self-center": ["align-self", "center"],
53
+ "self-start": ["align-self", "flex-start"],
54
+ "self-end": ["align-self", "flex-end"],
55
+ "self-stretch": ["align-self", "stretch"],
56
+ "content-center": ["align-content", "center"],
57
+ "content-between": ["align-content", "space-between"],
58
+ "content-start": ["align-content", "flex-start"],
59
+ "content-end": ["align-content", "flex-end"],
60
+ gap: ["gap", null],
61
+ "gap-0": ["gap", "0"],
62
+ "gap-1": ["gap", "0.25rem"],
63
+ "gap-2": ["gap", "0.5rem"],
64
+ "gap-3": ["gap", "0.75rem"],
65
+ "gap-4": ["gap", "1rem"],
66
+ "gap-5": ["gap", "1.25rem"],
67
+ "gap-6": ["gap", "1.5rem"],
68
+ "gap-8": ["gap", "2rem"],
69
+ "gap-10": ["gap", "2.5rem"],
70
+ "gap-12": ["gap", "3rem"],
71
+ "gap-16": ["gap", "4rem"],
72
+ "gap-20": ["gap", "5rem"],
73
+ "gap-24": ["gap", "6rem"],
74
+ "gap-x-4": ["column-gap", "1rem"],
75
+ "gap-y-4": ["row-gap", "1rem"],
76
+ "gap-x-8": ["column-gap", "2rem"],
77
+ "gap-y-8": ["row-gap", "2rem"],
78
+ rounded: ["border-radius", "0.25rem"],
79
+ "rounded-sm": ["border-radius", "0.125rem"],
80
+ "rounded-md": ["border-radius", "0.375rem"],
81
+ "rounded-lg": ["border-radius", "0.5rem"],
82
+ "rounded-xl": ["border-radius", "0.75rem"],
83
+ "rounded-2xl": ["border-radius", "1rem"],
84
+ "rounded-3xl": ["border-radius", "1.5rem"],
85
+ "rounded-full": ["border-radius", "9999px"],
86
+ "rounded-none": ["border-radius", "0"],
87
+ "rounded-t-full": ["border-radius", "9999px"],
88
+ "rounded-l-full": ["border-radius", "9999px"],
89
+ "rounded-r-full": ["border-radius", "9999px"],
90
+ "rounded-tl": ["border-top-left-radius", "0.25rem"],
91
+ "rounded-tr": ["border-top-right-radius", "0.25rem"],
92
+ "rounded-bl": ["border-bottom-left-radius", "0.25rem"],
93
+ "rounded-br": ["border-bottom-right-radius", "0.25rem"],
94
+ shadow: ["box-shadow", "1"],
95
+ "shadow-sm": ["box-shadow", "1"],
96
+ "shadow-md": ["box-shadow", "1"],
97
+ "shadow-lg": ["box-shadow", "1"],
98
+ "shadow-xl": ["box-shadow", "1"],
99
+ "shadow-2xl": ["box-shadow", "1"],
100
+ "shadow-inner": ["box-shadow", "1"],
101
+ "shadow-none": ["box-shadow", "none"],
102
+ border: ["border", "1"],
103
+ "border-0": ["border-width", "0"],
104
+ "border-2": ["border-width", "2px"],
105
+ "border-4": ["border-width", "4px"],
106
+ "border-t": ["border-top", "1"],
107
+ "border-b": ["border-bottom", "1"],
108
+ "border-l": ["border-left", "1"],
109
+ "border-r": ["border-right", "1"],
110
+ "border-dashed": ["border-style", "dashed"],
111
+ "border-dotted": ["border-style", "dotted"],
112
+ "border-solid": ["border-style", "solid"],
113
+ "border-none": ["border", "none"],
114
+ "border-collapse": ["border-collapse", "collapse"],
115
+ "border-separate": ["border-separate", "separate"],
116
+ "overflow-hidden": ["overflow", "hidden"],
117
+ "overflow-auto": ["overflow", "auto"],
118
+ "overflow-scroll": ["overflow", "scroll"],
119
+ "overflow-visible": ["overflow", "visible"],
120
+ "overflow-x-auto": ["overflow-x", "auto"],
121
+ "overflow-y-auto": ["overflow-y", "auto"],
122
+ truncate: ["text-overflow", "ellipsis"],
123
+ "line-clamp-2": ["display", "-webkit-box"],
124
+ "w-full": ["width", "100%"],
125
+ "w-auto": ["width", "auto"],
126
+ "w-screen": ["width", "100vw"],
127
+ "min-w-0": ["min-width", "0"],
128
+ "min-w-full": ["min-width", "100%"],
129
+ "max-w-full": ["max-width", "100%"],
130
+ "max-w-screen": ["max-width", "100vw"],
131
+ "max-w-prose": ["max-width", "65ch"],
132
+ "max-w-xs": ["max-width", "20rem"],
133
+ "max-w-sm": ["max-width", "24rem"],
134
+ "max-w-md": ["max-width", "28rem"],
135
+ "max-w-lg": ["max-width", "32rem"],
136
+ "max-w-xl": ["max-width", "36rem"],
137
+ "max-w-2xl": ["max-width", "42rem"],
138
+ "max-w-3xl": ["max-width", "48rem"],
139
+ "max-w-4xl": ["max-width", "56rem"],
140
+ "max-w-5xl": ["max-width", "64rem"],
141
+ "max-w-6xl": ["max-width", "72rem"],
142
+ "max-w-7xl": ["max-width", "80rem"],
143
+ "h-full": ["height", "100%"],
144
+ "h-auto": ["height", "auto"],
145
+ "h-screen": ["height", "100vh"],
146
+ "min-h-0": ["min-height", "0"],
147
+ "min-h-full": ["min-height", "100%"],
148
+ "min-h-screen": ["min-height", "100vh"],
149
+ "p-0": ["padding", "0"],
150
+ "p-1": ["padding", "0.25rem"],
151
+ "p-2": ["padding", "0.5rem"],
152
+ "p-3": ["padding", "0.75rem"],
153
+ "p-4": ["padding", "1rem"],
154
+ "p-5": ["padding", "1.25rem"],
155
+ "p-6": ["padding", "1.5rem"],
156
+ "p-8": ["padding", "2rem"],
157
+ "p-10": ["padding", "2.5rem"],
158
+ "p-12": ["padding", "3rem"],
159
+ "px-0": ["padding-left", "0"],
160
+ "px-1": ["padding-left", "0.25rem"],
161
+ "px-2": ["padding-left", "0.5rem"],
162
+ "px-3": ["padding-left", "0.75rem"],
163
+ "px-4": ["padding-left", "1rem"],
164
+ "px-5": ["padding-left", "1.25rem"],
165
+ "px-6": ["padding-left", "1.5rem"],
166
+ "px-8": ["padding-left", "2rem"],
167
+ "py-0": ["padding-top", "0"],
168
+ "py-1": ["padding-top", "0.25rem"],
169
+ "py-2": ["padding-top", "0.5rem"],
170
+ "py-3": ["padding-top", "0.75rem"],
171
+ "py-4": ["padding-top", "1rem"],
172
+ "py-5": ["padding-top", "1.25rem"],
173
+ "py-6": ["padding-top", "1.5rem"],
174
+ "py-8": ["padding-top", "2rem"],
175
+ "pt-4": ["padding-top", "1rem"],
176
+ "pb-4": ["padding-bottom", "1rem"],
177
+ "pl-4": ["padding-left", "1rem"],
178
+ "pr-4": ["padding-right", "1rem"],
179
+ "pt-8": ["padding-top", "2rem"],
180
+ "pb-8": ["padding-bottom", "2rem"],
181
+ "pl-8": ["padding-left", "2rem"],
182
+ "pr-8": ["padding-right", "2rem"],
183
+ "m-0": ["margin", "0"],
184
+ "m-1": ["margin", "0.25rem"],
185
+ "m-2": ["margin", "0.5rem"],
186
+ "m-3": ["margin", "0.75rem"],
187
+ "m-4": ["margin", "1rem"],
188
+ "m-6": ["margin", "1.5rem"],
189
+ "m-8": ["margin", "2rem"],
190
+ "mx-auto": ["margin-left", "auto"],
191
+ "my-4": ["margin-top", "1rem"],
192
+ "mt-0": ["margin-top", "0"],
193
+ "mt-1": ["margin-top", "0.25rem"],
194
+ "mt-2": ["margin-top", "0.5rem"],
195
+ "mt-3": ["margin-top", "0.75rem"],
196
+ "mt-4": ["margin-top", "1rem"],
197
+ "mt-6": ["margin-top", "1.5rem"],
198
+ "mt-8": ["margin-top", "2rem"],
199
+ "mb-0": ["margin-bottom", "0"],
200
+ "mb-1": ["margin-bottom", "0.25rem"],
201
+ "mb-2": ["margin-bottom", "0.5rem"],
202
+ "mb-3": ["margin-bottom", "0.75rem"],
203
+ "mb-4": ["margin-bottom", "1rem"],
204
+ "mb-6": ["margin-bottom", "1.5rem"],
205
+ "mb-8": ["margin-bottom", "2rem"],
206
+ "ml-0": ["margin-left", "0"],
207
+ "ml-4": ["margin-left", "1rem"],
208
+ "mr-4": ["margin-right", "1rem"],
209
+ "ml-auto": ["margin-left", "auto"],
210
+ "mr-auto": ["margin-right", "auto"],
211
+ "mt-auto": ["margin-top", "auto"],
212
+ "mb-auto": ["margin-bottom", "auto"],
213
+ static: ["position", "static"],
214
+ fixed: ["position", "fixed"],
215
+ absolute: ["position", "absolute"],
216
+ relative: ["position", "relative"],
217
+ sticky: ["position", "sticky"],
218
+ "inset-0": ["inset", "0"],
219
+ "top-0": ["top", "0"],
220
+ "top-4": ["top", "1rem"],
221
+ "top-1/2": ["top", "50%"],
222
+ "bottom-0": ["bottom", "0"],
223
+ "left-0": ["left", "0"],
224
+ "right-0": ["right", "0"],
225
+ "z-0": ["z-index", "0"],
226
+ "z-10": ["z-index", "10"],
227
+ "z-20": ["z-index", "20"],
228
+ "z-30": ["z-index", "30"],
229
+ "z-40": ["z-index", "40"],
230
+ "z-50": ["z-index", "50"],
231
+ "z-auto": ["z-index", "auto"],
232
+ "text-center": ["text-align", "center"],
233
+ "text-left": ["text-align", "left"],
234
+ "text-right": ["text-align", "right"],
235
+ "text-justify": ["text-align", "justify"],
236
+ "text-2xl": ["font-size", "1.5rem"],
237
+ "text-xl": ["font-size", "1.25rem"],
238
+ "text-lg": ["font-size", "1.125rem"],
239
+ "text-base": ["font-size", "1rem"],
240
+ "text-sm": ["font-size", "0.875rem"],
241
+ "text-xs": ["font-size", "0.75rem"],
242
+ "text-2xs": ["font-size", "0.625rem"],
243
+ "font-bold": ["font-weight", "700"],
244
+ "font-semibold": ["font-weight", "600"],
245
+ "font-medium": ["font-weight", "500"],
246
+ "font-normal": ["font-weight", "400"],
247
+ "font-light": ["font-weight", "300"],
248
+ "font-thin": ["font-weight", "100"],
249
+ "font-extrabold": ["font-weight", "800"],
250
+ "font-black": ["font-weight", "900"],
251
+ "font-extralight": ["font-weight", "200"],
252
+ italic: ["font-style", "italic"],
253
+ "not-italic": ["font-style", "normal"],
254
+ underline: ["text-decoration", "underline"],
255
+ "line-through": ["text-decoration", "line-through"],
256
+ "no-underline": ["text-decoration", "none"],
257
+ uppercase: ["text-transform", "uppercase"],
258
+ lowercase: ["text-transform", "lowercase"],
259
+ capitalize: ["text-transform", "capitalize"],
260
+ "normal-case": ["text-transform", "none"],
261
+ "tracking-tight": ["letter-spacing", "-0.025em"],
262
+ "tracking-wide": ["letter-spacing", "0.025em"],
263
+ "tracking-widest": ["letter-spacing", "0.1em"],
264
+ "leading-none": ["line-height", "1"],
265
+ "leading-tight": ["line-height", "1.25"],
266
+ "leading-snug": ["line-height", "1.375"],
267
+ "leading-normal": ["line-height", "1.5"],
268
+ "leading-relaxed": ["line-height", "1.625"],
269
+ "leading-loose": ["line-height", "2"],
270
+ "whitespace-nowrap": ["white-space", "nowrap"],
271
+ "whitespace-normal": ["white-space", "normal"],
272
+ "whitespace-pre": ["white-space", "pre"],
273
+ "whitespace-pre-line": ["white-space", "pre-line"],
274
+ "whitespace-pre-wrap": ["white-space", "pre-wrap"],
275
+ "break-words": ["word-break", "break-word"],
276
+ "break-all": ["word-break", "break-all"],
277
+ "list-none": ["list-style", "none"],
278
+ "list-disc": ["list-style", "disc"],
279
+ "list-decimal": ["list-style", "decimal"],
280
+ "appearance-none": ["appearance", "none"],
281
+ "cursor-pointer": ["cursor", "pointer"],
282
+ "cursor-default": ["cursor", "default"],
283
+ "cursor-wait": ["cursor", "wait"],
284
+ "cursor-not-allowed": ["cursor", "not-allowed"],
285
+ "cursor-text": ["cursor", "text"],
286
+ "pointer-events-none": ["pointer-events", "none"],
287
+ "pointer-events-auto": ["pointer-events", "auto"],
288
+ "select-none": ["user-select", "none"],
289
+ "select-text": ["user-select", "text"],
290
+ "resize-none": ["resize", "none"],
291
+ "resize-y": ["resize", "vertical"],
292
+ "resize-x": ["resize", "horizontal"],
293
+ resize: ["resize", "both"],
294
+ "object-cover": ["object-fit", "cover"],
295
+ "object-contain": ["object-fit", "contain"],
296
+ "object-fill": ["object-fit", "fill"],
297
+ "object-none": ["object-fit", "none"],
298
+ "object-scale-down": ["object-fit", "scale-down"],
299
+ transition: ["transition", "1"],
300
+ "transition-all": ["transition-property", "all"],
301
+ "transition-colors": ["transition-property", "color"],
302
+ "transition-opacity": ["transition-property", "opacity"],
303
+ "transition-transform": ["transition-property", "transform"],
304
+ "transition-none": ["transition", "none"],
305
+ "duration-150": ["transition-duration", "150ms"],
306
+ "duration-200": ["transition-duration", "200ms"],
307
+ "duration-300": ["transition-duration", "300ms"],
308
+ "duration-500": ["transition-duration", "500ms"],
309
+ "duration-700": ["transition-duration", "700ms"],
310
+ "ease-in": ["transition-timing-function", "cubic-bezier(0.4, 0, 1, 1)"],
311
+ "ease-out": ["transition-timing-function", "cubic-bezier(0, 0, 0.2, 1)"],
312
+ "ease-in-out": ["transition-timing-function", "cubic-bezier(0.4, 0, 0.2, 1)"],
313
+ "ease-linear": ["transition-timing-function", "linear"],
314
+ "animate-spin": ["animation", "spin"],
315
+ "animate-ping": ["animation", "ping"],
316
+ "animate-pulse": ["animation", "pulse"],
317
+ "animate-bounce": ["animation", "bounce"],
318
+ "animate-none": ["animation", "none"],
319
+ transform: ["transform", "1"],
320
+ "scale-50": ["transform", "scale(0.5)"],
321
+ "scale-75": ["transform", "scale(0.75)"],
322
+ "scale-90": ["transform", "scale(0.9)"],
323
+ "scale-95": ["transform", "scale(0.95)"],
324
+ "scale-100": ["transform", "scale(1)"],
325
+ "scale-105": ["transform", "scale(1.05)"],
326
+ "scale-110": ["transform", "scale(1.1)"],
327
+ "scale-125": ["transform", "scale(1.25)"],
328
+ "scale-150": ["transform", "scale(1.5)"],
329
+ "rotate-0": ["transform", "rotate(0deg)"],
330
+ "rotate-45": ["transform", "rotate(45deg)"],
331
+ "rotate-90": ["transform", "rotate(90deg)"],
332
+ "rotate-180": ["transform", "rotate(180deg)"],
333
+ "backface-hidden": ["backface-visibility", "hidden"],
334
+ "backface-visible": ["backface-visibility", "visible"],
335
+ "will-change-transform": ["will-change", "transform"],
336
+ "will-change-scroll": ["will-change", "scroll"],
337
+ "will-change-auto": ["will-change", "auto"],
338
+ "sr-only": ["position", "absolute"],
339
+ "not-sr-only": ["position", "static"],
340
+ "bg-cover": ["background-size", "cover"],
341
+ "bg-center": ["background-position", "center"],
342
+ "bg-no-repeat": ["background-repeat", "no-repeat"],
343
+ "bg-repeat": ["background-repeat", "repeat"],
344
+ "bg-fixed": ["background-attachment", "fixed"],
345
+ "bg-local": ["background-attachment", "local"],
346
+ "bg-scroll": ["background-attachment", "scroll"],
347
+ };
348
+
349
+ /** Responsive/state prefixes to strip (sm:, md:, lg:, xl:, hover:, focus:, ...). */
350
+ const TW_PREFIX_RE = /^(sm|md|lg|xl|2xl|hover|focus|active|disabled|group-hover|peer-hover|motion-safe|motion-reduce|dark|light|first|last|odd|even|checked|indeterminate|selected|placeholder|file|before|after|in|out|open|read-only|read-write|required|valid|invalid|target|visited|link|any|not|has|is|empty|only|aria|focus-within|focus-visible|has-focus|group-focus|peer-focus|group-active|peer-active|group-disabled|peer-disabled|group-checked|peer-checked|group-selected|peer-selected|group-focus-within|peer-focus-within|group-focus-visible|peer-focus-visible|group-open|peer-open|group-read-only|peer-read-only|group-read-write|peer-read-write|group-required|peer-required|group-valid|peer-valid|group-invalid|peer-invalid|group-target|peer-target|group-visited|peer-visited|group-link|peer-link):/;
351
+
352
+ /**
353
+ * Arbitrary-value utilities: the bracketed value is taken VERBATIM (Tailwind
354
+ * does the same — `z-[1000]` is `z-index: 1000`), the prefix maps to a CSS
355
+ * property. `text-[13px]` is a font-size only when the value is a length
356
+ * (`text-[red]` is a color and is skipped); `bg-[...]` is a background-color
357
+ * only for color-like values (`bg-[url(...)]` is skipped).
358
+ */
359
+ const TW_ARBITRARY_PREFIX = {
360
+ z: "z-index",
361
+ top: "top",
362
+ left: "left",
363
+ right: "right",
364
+ bottom: "bottom",
365
+ inset: "inset",
366
+ w: "width",
367
+ h: "height",
368
+ "min-w": "min-width",
369
+ "max-w": "max-width",
370
+ "min-h": "min-height",
371
+ "max-h": "max-height",
372
+ p: "padding",
373
+ px: "padding-inline",
374
+ py: "padding-block",
375
+ pt: "padding-top",
376
+ pr: "padding-right",
377
+ pb: "padding-bottom",
378
+ pl: "padding-left",
379
+ m: "margin",
380
+ mx: "margin-inline",
381
+ my: "margin-block",
382
+ mt: "margin-top",
383
+ mr: "margin-right",
384
+ mb: "margin-bottom",
385
+ ml: "margin-left",
386
+ rounded: "border-radius",
387
+ border: "border-width",
388
+ text: "font-size", // only for length values — see twArbitrary
389
+ bg: "background-color", // only for color values — see twArbitrary
390
+ };
391
+ const TW_LENGTH_RE = /^[0-9.]+(px|rem|em|vh|vw|vmin|vmax|ch|ex|%|pt|pc|in|cm|mm)$/;
392
+ const TW_COLOR_RE = /^(#|rgb|hsl|hwb|lab|lch|oklab|oklch|color\(|[a-z]+$)/;
393
+
394
+ /**
395
+ * Parse an arbitrary-value utility (`z-[1000]`, `w-[420px]`, `inset-[0px]`,
396
+ * `p-[12px]`, `text-[13px]`, `rounded-[8px]`, `bg-[#000]`, ...).
397
+ * Returns [prop, value] or null when the prefix (or value kind) is unknown.
398
+ */
399
+ function twArbitrary(token) {
400
+ const m = token.match(/^([a-z]+(?:-[a-z0-9]+)*)-\[(.+)\]$/);
401
+ if (!m) return null;
402
+ const prop = TW_ARBITRARY_PREFIX[m[1]];
403
+ if (!prop) return null;
404
+ const value = m[2];
405
+ if (m[1] === "text" && !TW_LENGTH_RE.test(value)) return null; // text-[red] = color, not size
406
+ if (m[1] === "bg" && !TW_COLOR_RE.test(value)) return null; // bg-[url(...)] = image
407
+ return [prop, value];
408
+ }
409
+
410
+ /**
411
+ * Convert a tailwind class string into a styles map
412
+ * (cssProperty -> array of candidate values). Unknown utilities are
413
+ * skipped — only recognized utilities contribute to the signature.
414
+ */
415
+ function twToStyles(cls) {
416
+ const styles = {};
417
+ if (typeof cls !== "string") return styles;
418
+ for (const rawToken of cls.split(/\s+/)) {
419
+ if (!rawToken) continue;
420
+ let token = rawToken;
421
+ // Strip responsive/state prefixes (repeat for stacked ones like lg:hover:).
422
+ let guard = 0;
423
+ while (TW_PREFIX_RE.test(token) && guard < 4) {
424
+ token = token.replace(TW_PREFIX_RE, "");
425
+ guard += 1;
426
+ }
427
+ // Negative values: -ml-4 -> margin-left: -1rem.
428
+ let negative = false;
429
+ if (token.startsWith("-")) {
430
+ negative = true;
431
+ token = token.slice(1);
432
+ }
433
+ // Arbitrary values first (`z-[1000]`, `w-[420px]`, ...), then the fixed map.
434
+ const arb = twArbitrary(token);
435
+ const hit = arb || TW[token];
436
+ if (!hit) continue;
437
+ const [prop, value] = hit;
438
+ const v = value == null ? (negative ? "0" : "1") : negative ? `-${value}` : value;
439
+ if (!Object.prototype.hasOwnProperty.call(styles, prop)) styles[prop] = [];
440
+ if (!styles[prop].includes(v)) styles[prop].push(v);
441
+ }
442
+ return styles;
443
+ }
444
+
445
+ /** camelCase (JS style object) -> kebab-case (CSS). */
446
+ function camelToKebab(name) {
447
+ return String(name).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
448
+ }
449
+
450
+ /**
451
+ * Parse a CSS declaration block (styled-components template body or a plain
452
+ * "a: b; c: d" string) into a styles map. Interpolations ({...}) are
453
+ * skipped — only static declarations contribute.
454
+ */
455
+ function cssTextToStyles(text) {
456
+ const styles = {};
457
+ if (typeof text !== "string") return styles;
458
+ // Remove comments.
459
+ const clean = text.replace(/\/\*[\s\S]*?\*\//g, "");
460
+ for (const decl of clean.split(";")) {
461
+ const idx = decl.indexOf(":");
462
+ if (idx < 0) continue;
463
+ const prop = decl.slice(0, idx).trim();
464
+ const value = decl.slice(idx + 1).trim();
465
+ if (!prop || !value) continue;
466
+ if (prop.startsWith("@") || prop.startsWith("$") || value.includes("{")) continue;
467
+ if (!/^[a-zA-Z-]+$/.test(prop)) continue;
468
+ const p = prop.toLowerCase();
469
+ if (!Object.prototype.hasOwnProperty.call(styles, p)) styles[p] = [];
470
+ if (!styles[p].includes(value)) styles[p].push(value);
471
+ }
472
+ return styles;
473
+ }
474
+
475
+ /**
476
+ * Merge styles maps. A value is "compatible" when one is a presence marker
477
+ * ("1") — in that case the other value wins.
478
+ */
479
+ function mergeStyles(a, b) {
480
+ const out = {};
481
+ for (const [prop, values] of Object.entries(a)) {
482
+ out[prop] = [...values];
483
+ }
484
+ for (const [prop, values] of Object.entries(b)) {
485
+ if (!Object.prototype.hasOwnProperty.call(out, prop)) {
486
+ out[prop] = [...values];
487
+ continue;
488
+ }
489
+ for (const v of values) {
490
+ if (v === "1" && out[prop].length > 0) continue; // presence marker loses
491
+ if (v === "1" && out[prop].every((x) => x === "1")) continue;
492
+ if (!out[prop].includes(v)) out[prop].push(v);
493
+ }
494
+ }
495
+ return out;
496
+ }
497
+
498
+ /** Jaccard similarity of two styles maps (property-level). */
499
+ function stylesSimilarity(a, b) {
500
+ const A = new Set(Object.keys(a));
501
+ const B = new Set(Object.keys(b));
502
+ if (A.size === 0 && B.size === 0) return 0;
503
+ let inter = 0;
504
+ for (const p of A) if (B.has(p)) inter += 1;
505
+ const union = A.size + B.size - inter;
506
+ return union === 0 ? 0 : inter / union;
507
+ }
508
+
509
+ /**
510
+ * Decision matrix for a candidate element signature vs a catalog entry.
511
+ *
512
+ * Returns null (no match), or { level: "error" | "warning", reason }.
513
+ *
514
+ * error: a11y/actions overlap (role:dialog, input[type=range], onClick on a role)
515
+ * OR styles >= 0.9 (any tag)
516
+ * OR same tag AND styles >= 0.7
517
+ * warning: different tag AND styles 0.5..0.9
518
+ * OR same tag AND styles 0.4..0.7
519
+ */
520
+ /**
521
+ * Minimum number of SHARED style keys required for a styles-only match to
522
+ * count. A generic 2-key layout (e.g. `display:flex; justify-content:center`)
523
+ * appears on hundreds of unrelated divs; without this floor it becomes a
524
+ * union-find "hub" that absorbs the whole graph and hides real duplicates.
525
+ * A distinctive 5-key signature still matches. a11y/action matches are never
526
+ * gated by this floor (they are already specific signals).
527
+ */
528
+ const MIN_SHARED_STYLE_KEYS = 3;
529
+
530
+ /**
531
+ * Is an a11y token a real duplicate signal? Boolean-presence tokens (value
532
+ * "true", e.g. `aria-label:true`, `aria-expanded:true`) are NOT: they mark
533
+ * "this element HAS an aria-label / is expanded", which is ubiquitous and
534
+ * would match any labeled app element against any labeled catalog component.
535
+ * Meaningful tokens carry a concrete value (role:dialog, type:range, ...).
536
+ *
537
+ * @param {string} token a11y token, e.g. "role:dialog" or "aria-label:true"
538
+ * @returns {boolean} true when the token is a real duplicate signal
539
+ */
540
+ function meaningfulA11yToken(token) {
541
+ return !!token && !token.endsWith(":true");
542
+ }
543
+
544
+ /**
545
+ * Tokens that are meaningful (they carry a value) but still too common to
546
+ * identify a component on their own: every button declares type:button, every
547
+ * text input type:text, every fieldset role:group. In the ESLint rule these
548
+ * are fine because the candidate is ONE specific element; in report clustering
549
+ * they are transitive glue — a single type:button catalog entry chains every
550
+ * button in the repo into one giant cluster.
551
+ */
552
+ const UBQUITOUS_A11Y_TOKENS = new Set([
553
+ "type:button",
554
+ "type:text",
555
+ "type:submit",
556
+ "type:reset",
557
+ "type:email",
558
+ "type:password",
559
+ "type:search",
560
+ "type:tel",
561
+ "type:url",
562
+ "type:number",
563
+ "type:date",
564
+ "type:time",
565
+ "role:group",
566
+ "role:presentation",
567
+ "role:none",
568
+ "role:list",
569
+ "role:listitem",
570
+ "role:status",
571
+ "role:alert",
572
+ "role:img",
573
+ ]);
574
+
575
+ /**
576
+ * An a11y token specific enough to identify a component by itself
577
+ * (role:dialog, aria-label:Rows per page, ...).
578
+ */
579
+ function specificA11yToken(token) {
580
+ return meaningfulA11yToken(token) && !UBQUITOUS_A11Y_TOKENS.has(token);
581
+ }
582
+
583
+ function decide(candidate, entry) {
584
+ const entryA11y = new Set(entry.a11y);
585
+ const a11yOverlap = candidate.a11y.filter((a) => meaningfulA11yToken(a) && entryA11y.has(a));
586
+ const actionOverlap = candidate.actions.filter((a) => entry.actions.includes(a));
587
+ const sameTag = candidate.tag === entry.tag;
588
+ const sim = stylesSimilarity(candidate.styles, entry.styles);
589
+ // Shared style-key footprint.
590
+ const A = Object.keys(candidate.styles);
591
+ const B = new Set(Object.keys(entry.styles));
592
+ const shared = A.filter((p) => B.has(p)).length;
593
+
594
+ // a11y overlap is a specific, standalone duplicate signal (role:dialog,
595
+ // type:range, ...). Boolean-presence tokens are filtered by meaningfulA11yToken.
596
+ if (a11yOverlap.length > 0) return { level: "error", reason: a11yOverlap[0] };
597
+
598
+ // Action overlap (click/change/...) is NOT a standalone duplicate signal:
599
+ // every button has onClick, every input has onChange, so a bare action match
600
+ // would make every button "duplicate" every other button. It only proves a
601
+ // duplicate when at least one side carries a MEANINGFUL a11y marker
602
+ // (type:button, role:dialog, ...) — that marker identifies the element as a
603
+ // re-implementation of a specific canonical component rather than just
604
+ // "another clickable thing". A plain <Button onClick> with no such marker
605
+ // against a catalog entry that has none either stays silent.
606
+ // Action overlap (click/change/...) is a WEAK standalone signal: every
607
+ // button has onClick, every input has onChange, so a bare action match would
608
+ // make every clickable element "duplicate" every other clickable element.
609
+ // It is promoted to a duplicate ONLY when the ENTRY is a full BUTTON
610
+ // definition: it carries the `type:button` marker AND a real style footprint
611
+ // (>= MIN_SHARED_STYLE_KEYS). That combination is unambiguous — "the
612
+ // canonical button" — so a bare element matching it on action is a
613
+ // re-implementation of THAT component, not just "another clickable thing".
614
+ //
615
+ // A bare <PageNumber onClick> / <ClearButton onClick> against an entry that
616
+ // is only a labeled overlay (role:dialog), a tab (role:tab + 2 generic
617
+ // styles), or a range (type:range) is NOT convicted here: it is "another
618
+ // clickable thing", not a re-implementation of that component. The candidate
619
+ // is still caught by the a11y-overlap branch (above) or the styles branch
620
+ // (below) whenever it genuinely shares those signals. The candidate's OWN
621
+ // meaningful a11y is deliberately NOT used here — it does not prove the
622
+ // element matches THIS entry and is the source of the ubiquitous-action FPs.
623
+ const entryIsButton = (entry.a11y || []).includes("type:button");
624
+ const entryStyleCount = Object.keys(entry.styles || {}).length;
625
+ if (actionOverlap.length > 0 && entryIsButton && entryStyleCount >= MIN_SHARED_STYLE_KEYS) {
626
+ return { level: "error", reason: actionOverlap[0] };
627
+ }
628
+
629
+ // Styles-only match (no a11y/action signal): require a real shared footprint.
630
+ if (shared < MIN_SHARED_STYLE_KEYS) return null;
631
+ if (sim >= 0.9) return { level: "error", reason: `styles ${sim.toFixed(2)}` };
632
+ if (sameTag && sim >= 0.7) return { level: "error", reason: `styles ${sim.toFixed(2)}` };
633
+ if (!sameTag && sim >= 0.5) return { level: "warning", reason: `styles ${sim.toFixed(2)}` };
634
+ if (sameTag && sim >= 0.4) return { level: "warning", reason: `styles ${sim.toFixed(2)}` };
635
+ return null;
636
+ }
637
+
638
+ /**
639
+ * Match a candidate signature against the catalog. Returns the best
640
+ * { name, path, level, reason, sim } or null.
641
+ */
642
+ function matchSignature(candidate, catalog) {
643
+ const entries = (catalog && catalog.components) || [];
644
+ let best = null;
645
+ for (const entry of entries) {
646
+ const d = decide(candidate, entry);
647
+ if (!d) continue;
648
+ const score = d.level === "error" ? 2 : 1;
649
+ const sim = stylesSimilarity(candidate.styles, entry.styles);
650
+ // `score` MUST be stored on `best`: the comparison below reads
651
+ // `best.score`, and without it `best.score` is `undefined`, so
652
+ // `score > best.score` is always false and the first match wins
653
+ // regardless of level — silently defeating the "error beats warning"
654
+ // selection (a warning-tier match found earlier would mask a later
655
+ // error-tier duplicate).
656
+ if (!best || score > best.score || (score === best.score && sim > best.sim)) {
657
+ best = { name: entry.name, path: entry.path, level: d.level, reason: d.reason, sim, score };
658
+ }
659
+ }
660
+ return best;
661
+ }
662
+
663
+ module.exports = { TW, twToStyles, camelToKebab, cssTextToStyles, mergeStyles, stylesSimilarity, decide, matchSignature, meaningfulA11yToken, specificA11yToken, MIN_SHARED_STYLE_KEYS };