@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.
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+ const { resolveThresholds, DEFAULT_THRESHOLDS } = require("../thresholds");
5
+
6
+ const GENERIC_FREQ = 0.25;
7
+ const MIN_SHARED = DEFAULT_THRESHOLDS.minShared;
8
+ const MIN_RATIO_SAME_TAG = DEFAULT_THRESHOLDS.minRatioSameTag;
9
+ const MIN_RATIO_ANY_TAG = DEFAULT_THRESHOLDS.minRatioAnyTag;
10
+ const EXACT_RATIO = DEFAULT_THRESHOLDS.exactRatio;
11
+ const FUZZY_NAME_MIN_LEN = 5;
12
+ const FUZZY_BASENAME_MIN_LEN = 4;
13
+ const JSX_BLOCK_MIN_SEQ = 3;
14
+ const JSX_BLOCK_MIN_RATIO = 0.5;
15
+ const JSX_BLOCK_MAX_SUBTREES = 25;
16
+
17
+ const kebabToPascal = (s) =>
18
+ s
19
+ .split(/[-_ ]+/)
20
+ .filter(Boolean)
21
+ .map((w) => w[0].toUpperCase() + w.slice(1))
22
+ .join("");
23
+
24
+ const basenameToPascal = (file) => kebabToPascal(path.basename(file, path.extname(file)));
25
+
26
+ const pascalWords = (s) => String(s || "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
27
+ const kebabWords = (s) => String(s || "").split(/[^A-Za-z0-9]+/).filter(Boolean);
28
+ const singular = (w) => (w.length > 3 && w.endsWith("s") && !w.endsWith("ss") ? w.slice(0, -1) : w);
29
+
30
+ function fuzzyNameHit(appName, canonNames) {
31
+ const words = pascalWords(appName);
32
+ if (words.length === 0) return null;
33
+ let best = null;
34
+ for (const cn of canonNames) {
35
+ if (cn.length < FUZZY_NAME_MIN_LEN) continue;
36
+ const lc = cn.toLowerCase();
37
+ for (const w of words) {
38
+ const lw = w.toLowerCase();
39
+ if (lw === lc || (lw.length >= FUZZY_NAME_MIN_LEN && (lw.startsWith(lc) || lw.endsWith(lc)))) {
40
+ if (!best || cn.length > best.name.length) best = { name: cn };
41
+ break;
42
+ }
43
+ }
44
+ }
45
+ return best;
46
+ }
47
+
48
+ function fuzzyBasenameHit(appPath, canonNames) {
49
+ const base = path.basename(appPath, path.extname(appPath));
50
+ const words = kebabWords(base).map((w) => w.toLowerCase());
51
+ if (words.length === 0) return null;
52
+ const fullKebab = words.join("-");
53
+ let best = null;
54
+ for (const cn of canonNames) {
55
+ if (cn.length < FUZZY_BASENAME_MIN_LEN) continue;
56
+ const cnWords = pascalWords(cn).map((w) => w.toLowerCase());
57
+ const cnKebab = cnWords.join("-");
58
+ if (fullKebab === cnKebab || fullKebab === singular(cnKebab) || fullKebab === cnKebab.replace(/s$/, "")) {
59
+ if (!best || cn.length > best.name.length) best = { name: cn };
60
+ continue;
61
+ }
62
+ const lc = cn.toLowerCase();
63
+ const lcs = singular(lc);
64
+ for (const w of words) {
65
+ if (w === lc || w === lcs || singular(w) === lc) {
66
+ if (!best || cn.length > best.name.length) best = { name: cn };
67
+ break;
68
+ }
69
+ }
70
+ if (best && best.name === cn) continue;
71
+ for (const w of words) {
72
+ if (w.length < 4) continue;
73
+ const ws = singular(w);
74
+ if (cnWords.some((cw) => cw === w || cw === ws || singular(cw) === ws)) {
75
+ if (!best || cn.length > best.name.length) best = { name: cn };
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ return best;
81
+ }
82
+
83
+ function computeGenericTokens(canonComponents) {
84
+ const elements = [];
85
+ for (const c of canonComponents) {
86
+ for (const e of c.elementProps || []) {
87
+ if ((e.tokens || []).length > 0) elements.push(e.tokens);
88
+ }
89
+ }
90
+ const total = elements.length || 1;
91
+ const freq = new Map();
92
+ for (const toks of elements) {
93
+ for (const t of new Set(toks)) freq.set(t, (freq.get(t) || 0) + 1);
94
+ }
95
+ const generic = new Set();
96
+ for (const [t, n] of freq) if (n / total >= GENERIC_FREQ) generic.add(t);
97
+ return generic;
98
+ }
99
+
100
+ const distinctiveTokens = (tokens, generic) => {
101
+ const out = new Set();
102
+ for (const t of tokens || []) if (!generic.has(t)) out.add(t);
103
+ return out;
104
+ };
105
+
106
+ function buildCanonicalIndex(canonComponents) {
107
+ const byName = new Map();
108
+ const byBasename = new Map();
109
+ for (const c of canonComponents) {
110
+ const name = c.name;
111
+ const key = name.toLowerCase();
112
+ if (!byName.has(key)) byName.set(key, []);
113
+ byName.get(key).push(c);
114
+ const base = basenameToPascal(c.path).toLowerCase();
115
+ if (!byBasename.has(base)) byBasename.set(base, []);
116
+ byBasename.get(base).push(c);
117
+ }
118
+ return { byName, byBasename };
119
+ }
120
+
121
+ function canonElementIndex(canonComponents, generic) {
122
+ const out = [];
123
+ for (const c of canonComponents) {
124
+ for (const e of c.elementProps || []) {
125
+ const tag = (e.tag || "").toLowerCase();
126
+ if (!tag) continue;
127
+ const toks = distinctiveTokens(e.tokens, generic);
128
+ if (toks.size === 0) continue;
129
+ out.push({ canon: c, tag, toks });
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ function findStructuralBest(app, canonEls, generic, th, skipSelf) {
136
+ let best = null;
137
+ for (const el of app.elementProps || []) {
138
+ const appTag = (el.tag || "").toLowerCase();
139
+ if (!appTag) continue;
140
+ const appToks = distinctiveTokens(el.tokens, generic);
141
+ if (appToks.size < th.minShared) continue;
142
+ for (const cel of canonEls) {
143
+ if (skipSelf && cel.canon.path === app.path) continue;
144
+ const sameTag = cel.tag === appTag;
145
+ const threshold = sameTag ? th.minRatioSameTag : th.minRatioAnyTag;
146
+ let shared = 0;
147
+ for (const t of cel.toks) if (appToks.has(t)) shared += 1;
148
+ if (shared < th.minShared) continue;
149
+ const ratio = shared / cel.toks.size;
150
+ if (ratio < threshold) continue;
151
+ const score = shared + (sameTag ? 1 : 0);
152
+ if (!best || score > best.score || (score === best.score && ratio > best.ratio)) {
153
+ best = { canon: cel.canon, ratio, shared, tag: appTag, sameTag, canonSize: cel.toks.size };
154
+ }
155
+ }
156
+ }
157
+
158
+ return best;
159
+ }
160
+
161
+ function isSubsequence(needle, hay) {
162
+ let i = 0;
163
+ for (const t of hay) {
164
+ if (i < needle.length && needle[i] === t) i += 1;
165
+ }
166
+ return i === needle.length;
167
+ }
168
+
169
+ function matchJsxBlocks(appComponents, canonComponents, generic, opts) {
170
+ const th = resolveThresholds(opts);
171
+ const o = opts || {};
172
+ const index = new Map();
173
+ for (const c of canonComponents) {
174
+ const seq = (c.subtrees && c.subtrees[0] && c.subtrees[0].seq) || (c.elementProps || []).map((e) => e.tag).filter(Boolean);
175
+ if (seq.length < JSX_BLOCK_MIN_SEQ) continue;
176
+ const toks = new Set();
177
+ for (const e of c.elementProps || []) for (const t of e.tokens || []) toks.add(t);
178
+ const dtoks = distinctiveTokens([...toks], generic);
179
+ if (dtoks.size === 0) continue;
180
+ const key = seq[0];
181
+ if (!index.has(key)) index.set(key, []);
182
+ index.get(key).push({ canon: c, seq, toks: dtoks });
183
+ }
184
+ const matches = [];
185
+ const seen = new Set();
186
+ for (const app of appComponents) {
187
+ const subtrees = (app.subtrees || []).slice(0, JSX_BLOCK_MAX_SUBTREES);
188
+ for (const st of subtrees) {
189
+ const candidates = index.get(st.seq[0]);
190
+ if (!candidates) continue;
191
+ for (const cand of candidates) {
192
+ if (cand.canon.name === app.name) continue;
193
+ if (o.skipSelf && cand.canon.path === app.path) continue;
194
+ if ((app.usesCanonical || []).includes(cand.canon.name)) continue;
195
+ if (cand.seq.length > st.seq.length) continue;
196
+ if (cand.seq.length / st.seq.length < JSX_BLOCK_MIN_RATIO) continue;
197
+ if (!isSubsequence(cand.seq, st.seq)) continue;
198
+ let shared = 0;
199
+ for (const t of st.tokens) if (cand.toks.has(t)) shared += 1;
200
+ if (shared < th.minShared) continue;
201
+ const key = `${app.path}:${app.line}->${cand.canon.path}:${cand.canon.name}`;
202
+ if (seen.has(key)) continue;
203
+ seen.add(key);
204
+ matches.push({
205
+ app,
206
+ canon: cand.canon,
207
+ tier: "jsx-block",
208
+ subtree: st,
209
+ reason: `JSX block <${st.tag}> (${st.seq.length} elements) contains the full structure of ${cand.canon.name} (${cand.seq.length} elements, ${shared} shared token(s))`,
210
+ });
211
+ break;
212
+ }
213
+ }
214
+ }
215
+ return matches;
216
+ }
217
+
218
+ function matchComponents(appComponents, canonComponents, opts) {
219
+ const o = opts || {};
220
+ const th = resolveThresholds(o);
221
+ const index = buildCanonicalIndex(canonComponents);
222
+ const generic = computeGenericTokens(canonComponents);
223
+ const canonEls = canonElementIndex(canonComponents, generic);
224
+ const canonNames = [...new Set(canonComponents.map((c) => c.name))];
225
+ const matches = [];
226
+ const seen = new Set();
227
+
228
+ const push = (app, canon, tier, reason) => {
229
+ const key = `${app.path}:${app.line}->${canon.path}:${canon.name}`;
230
+ if (seen.has(key)) return;
231
+ seen.add(key);
232
+ matches.push({ app, canon, tier, reason });
233
+ };
234
+
235
+ for (const app of appComponents) {
236
+ if (o.canonicalFiles && o.canonicalFiles.has(app.path)) continue;
237
+ if (o.isHtmlOnly && o.isHtmlOnly(app)) continue;
238
+
239
+ const nameHits = [...(index.byName.get(app.name.toLowerCase()) || []), ...(o.skipBasename ? [] : index.byBasename.get(basenameToPascal(app.path).toLowerCase()) || [])].filter((c) => !(o.skipSelf && c.path === app.path));
240
+ if (nameHits.length > 0) {
241
+ const canon = nameHits[0];
242
+ push(app, canon, "name", `name "${app.name}" matches canonical ${canon.name} (${canon.path})`);
243
+ continue;
244
+ }
245
+
246
+ const structBest = findStructuralBest(app, canonEls, generic, th, o.skipSelf);
247
+
248
+ const fuzzy = fuzzyNameHit(app.name, canonNames) || fuzzyBasenameHit(app.path, canonNames);
249
+ if (fuzzy && structBest) {
250
+ const canon = index.byName.get(fuzzy.name.toLowerCase());
251
+ if (canon && canon.length > 0 && structBest.canon.path === canon[0].path) {
252
+ push(app, canon[0], "name-fuzzy", `"${app.name}" (${path.basename(app.path)}) references canonical "${fuzzy.name}"`);
253
+ continue;
254
+ }
255
+ }
256
+
257
+ if (o.skipWrappers && (app.usesCanonical || []).length > 0) continue;
258
+
259
+ if (structBest) {
260
+ const tier = structBest.ratio >= th.exactRatio ? "structural-exact" : "structural-similar";
261
+ const tagNote = structBest.sameTag ? `common element <${structBest.tag}>` : `element <${structBest.tag}> (no canonical tag match)`;
262
+ push(app, structBest.canon, tier, `${structBest.shared}/${structBest.canonSize} distinctive class tokens shared (${(structBest.ratio * 100).toFixed(0)}%), ${tagNote}`);
263
+ }
264
+ }
265
+
266
+ return matches;
267
+ }
268
+
269
+ module.exports = { matchComponents, matchJsxBlocks, buildCanonicalIndex, computeGenericTokens, distinctiveTokens, kebabToPascal, basenameToPascal, fuzzyNameHit, fuzzyBasenameHit, MIN_SHARED, MIN_RATIO_SAME_TAG, MIN_RATIO_ANY_TAG, EXACT_RATIO, GENERIC_FREQ };
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * scanner/output.js
5
+ *
6
+ * Catalog output: dedupe, sort, and write reports/component-catalog.json.
7
+ */
8
+
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+
12
+ /**
13
+ * Deduplicate and sort the collected signatures.
14
+ *
15
+ * @param {Array<object>} components raw entries { name, path, tag, styles, actions, a11y, data }
16
+ * @returns {Array<object>} deduped + sorted
17
+ */
18
+ function dedupeAndSort(components) {
19
+ const seen = new Set();
20
+ const out = [];
21
+ for (const c of components) {
22
+ const key = JSON.stringify({ n: c.name, ...c });
23
+ if (seen.has(key)) continue;
24
+ seen.add(key);
25
+ out.push(c);
26
+ }
27
+ out.sort((a, b) => (a.name + a.tag + a.path).localeCompare(b.name + b.tag + b.path));
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Write the catalog JSON file (pretty, trailing newline).
33
+ *
34
+ * @param {string} outPath absolute output path
35
+ * @param {Array<object>} components deduped + sorted entries
36
+ */
37
+ function writeCatalog(outPath, components) {
38
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
39
+ fs.writeFileSync(outPath, JSON.stringify({ generatedAt: new Date().toISOString(), components }, null, 2) + "\n", "utf8");
40
+ }
41
+
42
+ module.exports = { dedupeAndSort, writeCatalog };
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * scanner/walk.js
5
+ *
6
+ * Directory walking for the catalog scanner: yields every .ts/.tsx/.jsx
7
+ * file under a root, skipping node_modules, dist, and dotfiles.
8
+ */
9
+
10
+ const fs = require("node:fs");
11
+ const path = require("node:path");
12
+
13
+ /**
14
+ * Walk a directory tree, yielding absolute paths of .ts/.tsx/.jsx files.
15
+ *
16
+ * @param {string} dir absolute directory
17
+ * @returns {Generator<string>}
18
+ */
19
+ function* walk(dir) {
20
+ let entries;
21
+ try {
22
+ entries = fs.readdirSync(dir, { withFileTypes: true });
23
+ } catch {
24
+ return; // unreadable directory — skip
25
+ }
26
+ for (const entry of entries) {
27
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) continue;
28
+ const full = path.join(dir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ yield* walk(full);
31
+ } else if (/\.(tsx?|jsx)$/.test(entry.name)) {
32
+ yield full;
33
+ }
34
+ }
35
+ }
36
+
37
+ module.exports = { walk };