@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/report.js ADDED
@@ -0,0 +1,339 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * report.js
5
+ *
6
+ * Human-readable duplicate report (Markdown).
7
+ *
8
+ * The report clusters EVERY element signature found in the scanned code —
9
+ * both the canonical component folders and the app code — using the same
10
+ * decision matrix the ESLint rule uses (signature.js `decide`). Two
11
+ * signatures from DIFFERENT files that match (error or warning tier) are
12
+ * joined into one cluster (union-find). A cluster is a set of components /
13
+ * elements that duplicate one another.
14
+ *
15
+ * This is what makes keystone-only duplicates visible: two hand-rolled
16
+ * components that live only in apps/_keystone (and are NOT in the catalog)
17
+ * still form a cluster, because the clustering is pairwise over all scanned
18
+ * signatures, not "app code vs catalog".
19
+ *
20
+ * Clusters are named after the canonical component when one of their members
21
+ * is a catalog entry; otherwise they are named after the most common tag.
22
+ *
23
+ * Tiers (decision matrix, `signature.js`):
24
+ * exact (error tier): a11y/actions overlap, or styles >= 90% (any tag),
25
+ * or styles >= 70% for the same tag
26
+ * similar (warning tier): styles 50-90% (different tag) or 40-70% (same tag)
27
+ *
28
+ * No file links — counts only:
29
+ * component: Button, duplicates: 20 (12 exact, 8 similar)
30
+ * component: Input, duplicates: 2 (2 exact)
31
+ */
32
+
33
+ const { stylesSimilarity, specificA11yToken, MIN_SHARED_STYLE_KEYS } = require("./signature");
34
+
35
+ /**
36
+ * Generic layout/box CSS keys that appear on hundreds of unrelated elements and
37
+ * therefore cannot, on their own, identify a component. Two elements whose
38
+ * shared keys are ALL generic (e.g. `display:flex; align-items:center; gap:8px`)
39
+ * are NOT a duplicate — they are just both flex containers. Requiring at least
40
+ * one SHARED distinctive key (border-radius, box-shadow, color, background,
41
+ * border, ...) breaks the transitive "generic flexbox" hub that would
42
+ * otherwise chain the whole graph together and hide the real duplicates.
43
+ * Typography (font-size/weight, line-height) and spacing (margin/padding) are
44
+ * treated as generic too: they appear on hundreds of unrelated labels and
45
+ * paragraphs and would otherwise form their own hubs.
46
+ *
47
+ * The set is deliberately conservative: a key missing from it is treated as
48
+ * distinctive, which only ADDS edges (never drops a real duplicate). It can
49
+ * never cause a false negative, only a (rare) false positive.
50
+ */
51
+ const GENERIC_STYLE_KEYS = new Set([
52
+ "display",
53
+ "flex",
54
+ "flex-direction",
55
+ "flex-wrap",
56
+ "flex-grow",
57
+ "flex-shrink",
58
+ "flex-basis",
59
+ "align-items",
60
+ "align-content",
61
+ "justify-content",
62
+ "justify-items",
63
+ "gap",
64
+ "row-gap",
65
+ "column-gap",
66
+ "position",
67
+ "top",
68
+ "right",
69
+ "bottom",
70
+ "left",
71
+ "z-index",
72
+ "overflow",
73
+ "overflow-x",
74
+ "overflow-y",
75
+ "cursor",
76
+ "transition",
77
+ "transition-property",
78
+ "user-select",
79
+ "box-sizing",
80
+ "width",
81
+ "height",
82
+ "min-width",
83
+ "max-width",
84
+ "min-height",
85
+ "max-height",
86
+ // Typography + spacing: present on hundreds of unrelated labels/paragraphs/
87
+ // headings. Without these, "font-weight + margin-bottom" alone chains every
88
+ // form label in the repo into one hub (seen on Jabberwock: 65-member label
89
+ // cluster of identical `block font-medium mb-1` labels).
90
+ "font-size",
91
+ "font-weight",
92
+ "font-family",
93
+ "line-height",
94
+ "letter-spacing",
95
+ "text-align",
96
+ "margin",
97
+ "margin-top",
98
+ "margin-right",
99
+ "margin-bottom",
100
+ "margin-left",
101
+ "padding",
102
+ "padding-top",
103
+ "padding-right",
104
+ "padding-bottom",
105
+ "padding-left",
106
+ ]);
107
+
108
+ /**
109
+ * Do two style objects share at least one DISTINCTIVE (non-generic) key?
110
+ * This is the gate that stops generic flexbox layouts from forming a hub.
111
+ *
112
+ * @param {object} a style object
113
+ * @param {object} b style object
114
+ * @returns {boolean} true when the intersection contains a non-generic key
115
+ */
116
+ function sharesDistinctiveKey(a, b) {
117
+ const keys = Object.keys(a || {});
118
+ const other = new Set(Object.keys(b || {}));
119
+ return keys.some((k) => !GENERIC_STYLE_KEYS.has(k) && other.has(k));
120
+ }
121
+
122
+ /**
123
+ * Cluster all signatures (canonical + app) pairwise. Two signatures from
124
+ * different files that match at either tier are unioned.
125
+ *
126
+ * @param {object[]} allSigs signatures with { tag, styles, actions, a11y, path, name? }
127
+ * @returns {Map<number, { exact: number, similar: number, name: string, members: object[] }>}
128
+ * root index -> cluster (only clusters with >1 member are returned)
129
+ */
130
+ function clusterSignatures(allSigs) {
131
+ const n = allSigs.length;
132
+ const parent = new Array(n);
133
+ for (let i = 0; i < n; i += 1) parent[i] = i;
134
+ const find = (x) => {
135
+ while (parent[x] !== x) {
136
+ parent[x] = parent[parent[x]];
137
+ x = parent[x];
138
+ }
139
+ return x;
140
+ };
141
+ const union = (a, b) => {
142
+ const ra = find(a);
143
+ const rb = find(b);
144
+ if (ra !== rb) parent[rb] = ra;
145
+ };
146
+
147
+ // A cluster edge = two signatures that describe the SAME component.
148
+ //
149
+ // We deliberately do NOT use the full `decide` matrix here. The matrix's
150
+ // "action overlap → error" edge (onClick/onChange) is far too generic to be
151
+ // transitive: hundreds of unrelated clickable elements would chain into one
152
+ // giant union-find "hub" that swallows the real duplicates. Clustering is
153
+ // built on the signals that actually mean "same visual/behavioral component":
154
+ //
155
+ // 1. a11y overlap (role:dialog, type:range, ...) — but ONLY across files.
156
+ // Within one file, shared a11y tokens are normal (a form with three
157
+ // type:text inputs is not a duplicate).
158
+ // 2. style similarity with a real shared footprint (>= MIN_SHARED_STYLE_KEYS
159
+ // shared CSS keys): same tag >= 0.7, different tag >= 0.9. This is
160
+ // order-insensitive by construction (Jaccard over property keys) and is
161
+ // the signal for "same component, styles written in a different order".
162
+ //
163
+ // Both are "exact" (error-tier) edges; the warning tier stays a pairwise
164
+ // report hint, not a transitive glue.
165
+ const styleKeys = allSigs.map((s) => Object.keys(s.styles || {}));
166
+
167
+ for (let i = 0; i < n; i += 1) {
168
+ const si = allSigs[i];
169
+ for (let j = i + 1; j < n; j += 1) {
170
+ const sj = allSigs[j];
171
+ const sameFile = si.path === sj.path;
172
+ // (1) a11y overlap — cross-file only, and only on MEANINGFUL tokens.
173
+ // Boolean-presence tokens (value "true", e.g. `aria-label:true`,
174
+ // `aria-expanded:true`) are NOT a duplicate signal: hundreds of
175
+ // unrelated labeled/expandable elements all carry them, which would
176
+ // chain the whole graph into one giant hub. A real a11y duplicate
177
+ // shares a role or a typed input (role:dialog, type:range, ...).
178
+ if (!sameFile) {
179
+ // Only tokens specific enough to identify a component (role:dialog,
180
+ // aria-label:..., ...). Ubiquitous ones (type:button, role:group,
181
+ // every text input's type:text) are transitive glue: a single
182
+ // catalog button would chain every button in the repo into one hub.
183
+ const ai = new Set((si.a11y || []).filter(specificA11yToken));
184
+ if ((sj.a11y || []).some((t) => specificA11yToken(t) && ai.has(t))) {
185
+ union(i, j);
186
+ continue;
187
+ }
188
+ }
189
+ // (2) style similarity with a real shared footprint.
190
+ const ki = styleKeys[i];
191
+ const kj = new Set(styleKeys[j]);
192
+ const shared = ki.length ? ki.filter((k) => kj.has(k)).length : 0;
193
+ if (shared < MIN_SHARED_STYLE_KEYS) continue;
194
+ // Both signatures must share a distinctive (non-generic) key, otherwise
195
+ // they are just two generic flex/box layouts and must not be clustered.
196
+ if (!sharesDistinctiveKey(si.styles, sj.styles)) continue;
197
+ const sim = stylesSimilarity(si.styles, sj.styles);
198
+ const sameTag = si.tag === sj.tag;
199
+ if ((sameTag && sim >= 0.7) || (!sameTag && sim >= 0.9)) union(i, j);
200
+ }
201
+ }
202
+
203
+ // Group by root.
204
+ const groups = new Map();
205
+ for (let i = 0; i < n; i += 1) {
206
+ const r = find(i);
207
+ if (!groups.has(r)) groups.set(r, []);
208
+ groups.get(r).push(i);
209
+ }
210
+
211
+ // Build clusters with >1 member. Every member reached the cluster through an
212
+ // "exact" edge (a11y overlap or high style similarity), so every member is an
213
+ // exact duplicate. The warning tier is a pairwise report hint, not a cluster
214
+ // edge, so it never appears in cluster counts.
215
+ const clusters = new Map();
216
+ for (const [root, idxs] of groups) {
217
+ if (idxs.length < 2) continue;
218
+ const exact = idxs.length;
219
+ const similar = 0;
220
+ // Name: canonical component if any member has one, else most common tag.
221
+ let name = null;
222
+ for (const idx of idxs) {
223
+ if (allSigs[idx].name) {
224
+ name = allSigs[idx].name;
225
+ break;
226
+ }
227
+ }
228
+ if (!name) {
229
+ const tagCount = new Map();
230
+ for (const idx of idxs) {
231
+ const t = allSigs[idx].tag || "?";
232
+ tagCount.set(t, (tagCount.get(t) || 0) + 1);
233
+ }
234
+ name = [...tagCount.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0];
235
+ }
236
+ clusters.set(root, { exact, similar, name, members: idxs.map((i) => allSigs[i]) });
237
+ }
238
+ return clusters;
239
+ }
240
+
241
+ /**
242
+ * Count duplicates per component name from a cluster map.
243
+ *
244
+ * @param {Map<number, object>} clusters from clusterSignatures
245
+ * @returns {Map<string, { exact: number, similar: number }>} name -> tier counts
246
+ */
247
+ function clusterCounts(clusters) {
248
+ const counts = new Map();
249
+ for (const c of clusters.values()) {
250
+ const total = c.members.length;
251
+ if (total < 2) continue;
252
+ if (!counts.has(c.name)) counts.set(c.name, { exact: 0, similar: 0 });
253
+ const cur = counts.get(c.name);
254
+ cur.exact += c.exact;
255
+ cur.similar += c.similar;
256
+ }
257
+ return counts;
258
+ }
259
+
260
+ /**
261
+ * (Legacy) Match every app-code signature against the catalog only.
262
+ * Kept for callers that want the catalog-vs-app view.
263
+ *
264
+ * @param {object[]} appSigs
265
+ * @param {object[]} catalog
266
+ * @returns {Map<string, { exact: number, similar: number }>}
267
+ */
268
+ function duplicateCounts(appSigs, catalog) {
269
+ const { matchSignature } = require("./signature");
270
+ const counts = new Map();
271
+ for (const sig of appSigs) {
272
+ const hit = matchSignature(sig, { components: catalog });
273
+ if (!hit) continue;
274
+ if (!counts.has(hit.name)) counts.set(hit.name, { exact: 0, similar: 0 });
275
+ counts.get(hit.name)[hit.level === "error" ? "exact" : "similar"] += 1;
276
+ }
277
+ return counts;
278
+ }
279
+
280
+ /**
281
+ * Render the Markdown report.
282
+ *
283
+ * @param {Map<string, { exact: number, similar: number }>} counts per-component duplicate counts
284
+ * @param {object} [meta] { files: files scanned, catalog: catalog size }
285
+ * @returns {string} markdown text
286
+ */
287
+ function renderDuplicateReport(counts, meta = {}) {
288
+ const rows = [...counts.entries()]
289
+ .filter(([, c]) => c.exact + c.similar > 0)
290
+ .map(([name, c]) => ({ name, ...c, total: c.exact + c.similar }))
291
+ .sort((x, y) => y.total - x.total || x.name.localeCompare(y.name));
292
+
293
+ const totalExact = rows.reduce((s, r) => s + r.exact, 0);
294
+ const totalSimilar = rows.reduce((s, r) => s + r.similar, 0);
295
+
296
+ const lines = [];
297
+ lines.push("# Component duplicates report");
298
+ lines.push("");
299
+ lines.push(`Generated: ${new Date().toISOString().slice(0, 10)}`);
300
+ lines.push("");
301
+ lines.push(
302
+ `Scanned ${meta.files != null ? meta.files : "?"} file(s)` +
303
+ (meta.catalog != null ? ` against the catalog (${meta.catalog} canonical signature(s)).` : "."),
304
+ );
305
+ lines.push("");
306
+ lines.push("Duplicates are clustered pairwise over ALL scanned signatures (canonical + app), so pairs that exist only inside the app code (e.g. two keystone components) are reported too.");
307
+ lines.push("");
308
+ lines.push("Tiers (decision matrix, `signature.js`):");
309
+ lines.push("");
310
+ lines.push("- **exact** (error tier) — a11y/actions overlap, or styles >= 90% similar (any tag), or styles >= 70% for the same tag. These fail the gate.");
311
+ lines.push("- **similar** (warning tier) — styles 50-90% similar (different tag) or 40-70% (same tag). Report-only hint.");
312
+ lines.push("");
313
+ if (rows.length === 0) {
314
+ lines.push("No duplicates found.");
315
+ lines.push("");
316
+ return lines.join("\n");
317
+ }
318
+
319
+ lines.push(`**Total: ${rows.length} component(s) with duplicates — ${totalExact} exact, ${totalSimilar} similar.**`);
320
+ lines.push("");
321
+ lines.push("| Component | Duplicates | Exact | Similar |");
322
+ lines.push("| --------- | ---------: | ----: | ------: |");
323
+ for (const r of rows) {
324
+ lines.push(`| ${r.name} | ${r.total} | ${r.exact} | ${r.similar} |`);
325
+ }
326
+ lines.push("");
327
+ lines.push("Per component:");
328
+ lines.push("");
329
+ for (const r of rows) {
330
+ const parts = [];
331
+ if (r.exact > 0) parts.push(`${r.exact} exact`);
332
+ if (r.similar > 0) parts.push(`${r.similar} similar`);
333
+ lines.push(`- component: ${r.name}, duplicates: ${r.total} (${parts.join(", ")})`);
334
+ }
335
+ lines.push("");
336
+ return lines.join("\n");
337
+ }
338
+
339
+ module.exports = { clusterSignatures, clusterCounts, duplicateCounts, renderDuplicateReport };