@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/README.md +125 -0
- package/bin/react-component-uniqueness.js +10 -0
- package/config.js +290 -0
- package/debt.js +105 -0
- package/dom.js +158 -0
- package/errorTypes/duplicate-component.js +105 -0
- package/errorTypes/layout-primitive.js +212 -0
- package/errorTypes/raw-html.js +97 -0
- package/errorTypes/styled-in-app.js +93 -0
- package/index.js +283 -0
- package/loaders.js +123 -0
- package/package.json +59 -0
- package/plugin.js +22 -0
- package/report.js +339 -0
- package/resolve.js +374 -0
- package/scanner/attribute.js +79 -0
- package/scanner/clusters.js +96 -0
- package/scanner/component.js +346 -0
- package/scanner/extract.js +256 -0
- package/scanner/filters/drop-child-element-matches.js +16 -0
- package/scanner/filters/drop-usages.js +14 -0
- package/scanner/filters/index.js +8 -0
- package/scanner/filters/registry.js +30 -0
- package/scanner/generate.js +58 -0
- package/scanner/load-config.js +109 -0
- package/scanner/main.js +439 -0
- package/scanner/matching.js +269 -0
- package/scanner/output.js +42 -0
- package/scanner/walk.js +37 -0
- package/signature.js +663 -0
- package/snapshots/README.md +69 -0
- package/snapshots/build-playground.mjs +132 -0
- package/snapshots/run.mjs +43 -0
- package/snapshots/snapshot.mjs +204 -0
- package/thresholds.js +35 -0
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 };
|