@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/resolve.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* resolve.js
|
|
5
|
+
*
|
|
6
|
+
* String-literal resolution machinery shared by the rule (ESTree AST) and the
|
|
7
|
+
* catalog scanner (TypeScript AST).
|
|
8
|
+
*
|
|
9
|
+
* - ESTree (rule): stringLiteralsOf, resolveExportedFromFile,
|
|
10
|
+
* resolveStringCandidates — resolve className/style expressions to
|
|
11
|
+
* candidate string literals, following local constants, cn()/clsx()
|
|
12
|
+
* calls, ternaries, and imported helpers (read from disk).
|
|
13
|
+
* - TS AST (scanner): tsStringLiteralsOf — the same job over a TypeScript
|
|
14
|
+
* AST node (used by the catalog generator).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require("node:fs");
|
|
18
|
+
const path = require("node:path");
|
|
19
|
+
const ts = require("typescript");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collect string literals from an ESTree expression (shallow, capped).
|
|
23
|
+
*/
|
|
24
|
+
function stringLiteralsOf(expr, out, depth) {
|
|
25
|
+
if (!expr || depth > 4 || out.length >= 24) return;
|
|
26
|
+
switch (expr.type) {
|
|
27
|
+
case "Literal":
|
|
28
|
+
if (typeof expr.value === "string") out.push(expr.value);
|
|
29
|
+
break;
|
|
30
|
+
case "TemplateLiteral":
|
|
31
|
+
for (const q of expr.quasis) {
|
|
32
|
+
if (q.value && q.value.cooked) out.push(q.value.cooked);
|
|
33
|
+
}
|
|
34
|
+
break;
|
|
35
|
+
case "ObjectExpression":
|
|
36
|
+
for (const p of expr.properties) {
|
|
37
|
+
if (p.type === "Property") stringLiteralsOf(p.value, out, depth + 1);
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
case "ArrayExpression":
|
|
41
|
+
for (const el of expr.elements) {
|
|
42
|
+
if (el) stringLiteralsOf(el, out, depth + 1);
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
case "CallExpression":
|
|
46
|
+
for (const arg of expr.arguments) stringLiteralsOf(arg, out, depth + 1);
|
|
47
|
+
break;
|
|
48
|
+
case "ConditionalExpression":
|
|
49
|
+
stringLiteralsOf(expr.consequent, out, depth + 1);
|
|
50
|
+
stringLiteralsOf(expr.alternate, out, depth + 1);
|
|
51
|
+
break;
|
|
52
|
+
case "LogicalExpression":
|
|
53
|
+
stringLiteralsOf(expr.left, out, depth + 1);
|
|
54
|
+
stringLiteralsOf(expr.right, out, depth + 1);
|
|
55
|
+
break;
|
|
56
|
+
case "BinaryExpression":
|
|
57
|
+
if (expr.operator === "+") {
|
|
58
|
+
stringLiteralsOf(expr.left, out, depth + 1);
|
|
59
|
+
stringLiteralsOf(expr.right, out, depth + 1);
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Collect string literals from a TypeScript AST node (shallow, capped). */
|
|
68
|
+
function tsStringLiteralsOf(node, out, depth, root) {
|
|
69
|
+
if (!node || depth > 4 || out.length >= 24) return;
|
|
70
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
71
|
+
out.push(node.text);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (ts.isTemplateExpression(node)) {
|
|
75
|
+
for (const s of node.head && node.head.text ? [node.head.text] : []) out.push(s);
|
|
76
|
+
for (const span of node.templateSpans) {
|
|
77
|
+
if (span.literal && span.literal.text) out.push(span.literal.text);
|
|
78
|
+
tsStringLiteralsOf(span.expression, out, depth + 1, root);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
83
|
+
for (const p of node.properties) tsStringLiteralsOf(p.initializer, out, depth + 1, root);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
87
|
+
for (const el of node.elements) tsStringLiteralsOf(el, out, depth + 1, root);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (ts.isCallExpression(node)) {
|
|
91
|
+
for (const arg of node.arguments) tsStringLiteralsOf(arg, out, depth + 1, root);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (ts.isConditionalExpression(node)) {
|
|
95
|
+
tsStringLiteralsOf(node.whenTrue, out, depth + 1, root);
|
|
96
|
+
tsStringLiteralsOf(node.whenFalse, out, depth + 1, root);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.Plus) {
|
|
100
|
+
tsStringLiteralsOf(node.left, out, depth + 1, root);
|
|
101
|
+
tsStringLiteralsOf(node.right, out, depth + 1, root);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (ts.isBlock(node)) {
|
|
105
|
+
ts.forEachChild(node, (n) => tsStringLiteralsOf(n, out, depth + 1, root));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (ts.isReturnStatement(node) && node.expression) {
|
|
109
|
+
tsStringLiteralsOf(node.expression, out, depth + 1, root);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (ts.isIdentifier(node) && root) {
|
|
113
|
+
// A bare identifier in the initializer (e.g. const cls = baseCls) —
|
|
114
|
+
// resolve it within the same file.
|
|
115
|
+
const findDecl = (n) => {
|
|
116
|
+
if (out.length >= 24) return;
|
|
117
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === node.text && n.initializer) {
|
|
118
|
+
tsStringLiteralsOf(n.initializer, out, depth + 1, root);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
ts.forEachChild(n, findDecl);
|
|
122
|
+
};
|
|
123
|
+
findDecl(root);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Cache of imported-value lookups: absPath -> { name: result }.
|
|
128
|
+
const importedCache = new Map();
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Read a sibling file from disk and resolve one of its exported constants to
|
|
132
|
+
* string literals. Handles: const X = "...", template literals, cn()/clsx()
|
|
133
|
+
* calls, object literals, ternaries, and re-exports (export { Y as X }).
|
|
134
|
+
* Cached per file. Returns null when the file cannot be read or the export is
|
|
135
|
+
* not a static string.
|
|
136
|
+
*/
|
|
137
|
+
function resolveExportedFromFile(fileAbs, exportName) {
|
|
138
|
+
let byFile = importedCache.get(fileAbs);
|
|
139
|
+
if (!byFile) {
|
|
140
|
+
byFile = {};
|
|
141
|
+
importedCache.set(fileAbs, byFile);
|
|
142
|
+
}
|
|
143
|
+
if (Object.prototype.hasOwnProperty.call(byFile, exportName)) return byFile[exportName];
|
|
144
|
+
let result = null;
|
|
145
|
+
try {
|
|
146
|
+
const source = fs.readFileSync(fileAbs, "utf8");
|
|
147
|
+
const sf = ts.createSourceFile(fileAbs, source, ts.ScriptTarget.ES2020, true, fileAbs.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
148
|
+
const visit = (node) => {
|
|
149
|
+
if (result) return;
|
|
150
|
+
// export function NAME(...) { ... }
|
|
151
|
+
if (ts.isFunctionDeclaration(node) && node.name && node.name.text === exportName && ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) {
|
|
152
|
+
const out = [];
|
|
153
|
+
if (node.body) tsStringLiteralsOf(node.body, out, 0, sf);
|
|
154
|
+
result = out.length > 0 ? out : null;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// export const NAME = ... / export let / export var
|
|
158
|
+
if (ts.isVariableStatement(node) && ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) {
|
|
159
|
+
for (const d of node.declarationList.declarations) {
|
|
160
|
+
if (ts.isIdentifier(d.name) && d.name.text === exportName && d.initializer) {
|
|
161
|
+
const out = [];
|
|
162
|
+
tsStringLiteralsOf(d.initializer, out, 0, sf);
|
|
163
|
+
// Arrow/function initializer: also collect the body
|
|
164
|
+
// (const rowCls = (w) => w ? "a" : "b").
|
|
165
|
+
if (out.length === 0 && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))) {
|
|
166
|
+
if (d.initializer.body) tsStringLiteralsOf(d.initializer.body, out, 0, sf);
|
|
167
|
+
}
|
|
168
|
+
result = out.length > 0 ? out : null;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// export { Y as NAME } / export { Y as NAME } from "./other"
|
|
174
|
+
if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
175
|
+
for (const el of node.exportClause.elements) {
|
|
176
|
+
const exported = el.name && el.name.text;
|
|
177
|
+
if (exported === exportName) {
|
|
178
|
+
if (node.moduleSpecifier) {
|
|
179
|
+
// Re-export from another file — resolve there.
|
|
180
|
+
const localName = el.propertyName ? el.propertyName.text : exportName;
|
|
181
|
+
const target = path.resolve(path.dirname(fileAbs), node.moduleSpecifier.text);
|
|
182
|
+
const exts = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
|
|
183
|
+
for (const ext of exts) {
|
|
184
|
+
const candidate = target + ext;
|
|
185
|
+
try {
|
|
186
|
+
if (fs.statSync(candidate).isFile()) {
|
|
187
|
+
result = resolveExportedFromFile(candidate, localName);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// try next extension
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// Local re-export: find the local declaration.
|
|
196
|
+
const localName = el.propertyName ? el.propertyName.text : exportName;
|
|
197
|
+
const findLocal = (n) => {
|
|
198
|
+
if (result) return;
|
|
199
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === localName && n.initializer) {
|
|
200
|
+
const out = [];
|
|
201
|
+
tsStringLiteralsOf(n.initializer, out, 0, sf);
|
|
202
|
+
result = out.length > 0 ? out : null;
|
|
203
|
+
}
|
|
204
|
+
ts.forEachChild(n, findLocal);
|
|
205
|
+
};
|
|
206
|
+
findLocal(sf);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
ts.forEachChild(node, visit);
|
|
213
|
+
};
|
|
214
|
+
visit(sf);
|
|
215
|
+
} catch {
|
|
216
|
+
result = null;
|
|
217
|
+
}
|
|
218
|
+
byFile[exportName] = result;
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Resolve an expression to candidate string values (dynamic values: local
|
|
224
|
+
* variables, ternaries, cn()/clsx() calls, template literals, imported
|
|
225
|
+
* constants). Returns null when the expression is not a statically known
|
|
226
|
+
* string.
|
|
227
|
+
*/
|
|
228
|
+
function resolveStringCandidates(expr, scope, depth, fileDir) {
|
|
229
|
+
if (!expr || depth > 3) return null;
|
|
230
|
+
switch (expr.type) {
|
|
231
|
+
case "Literal":
|
|
232
|
+
// Strings and numbers: inline style values are often numeric
|
|
233
|
+
// (padding: 16, borderRadius: 8). The catalog scanner
|
|
234
|
+
// (scanner/extract.js) records numeric literals the same way, so
|
|
235
|
+
// the runtime candidate must too — otherwise a numeric-styled
|
|
236
|
+
// element loses those style keys and falls below the shared-key
|
|
237
|
+
// threshold.
|
|
238
|
+
return typeof expr.value === "string" || typeof expr.value === "number" ? [String(expr.value)] : null;
|
|
239
|
+
case "TemplateLiteral": {
|
|
240
|
+
if (expr.expressions.length === 0) {
|
|
241
|
+
const cooked = expr.quasis.map((q) => q.value.cooked).join("");
|
|
242
|
+
return [cooked];
|
|
243
|
+
}
|
|
244
|
+
const out = [];
|
|
245
|
+
for (const q of expr.quasis) {
|
|
246
|
+
if (q.value.cooked) out.push(q.value.cooked);
|
|
247
|
+
}
|
|
248
|
+
for (const e of expr.expressions) {
|
|
249
|
+
const c = resolveStringCandidates(e, scope, depth + 1, fileDir);
|
|
250
|
+
if (c) out.push(...c);
|
|
251
|
+
}
|
|
252
|
+
return out.length ? out : null;
|
|
253
|
+
}
|
|
254
|
+
case "ConditionalExpression": {
|
|
255
|
+
const a = resolveStringCandidates(expr.consequent, scope, depth + 1, fileDir);
|
|
256
|
+
const b = resolveStringCandidates(expr.alternate, scope, depth + 1, fileDir);
|
|
257
|
+
const out = [];
|
|
258
|
+
if (a) out.push(...a);
|
|
259
|
+
if (b) out.push(...b);
|
|
260
|
+
return out.length ? out : null;
|
|
261
|
+
}
|
|
262
|
+
case "LogicalExpression": {
|
|
263
|
+
const a = resolveStringCandidates(expr.left, scope, depth + 1, fileDir);
|
|
264
|
+
const b = resolveStringCandidates(expr.right, scope, depth + 1, fileDir);
|
|
265
|
+
const out = [];
|
|
266
|
+
if (a) out.push(...a);
|
|
267
|
+
if (b) out.push(...b);
|
|
268
|
+
return out.length ? out : null;
|
|
269
|
+
}
|
|
270
|
+
case "BinaryExpression": {
|
|
271
|
+
if (expr.operator !== "+") return null;
|
|
272
|
+
const a = resolveStringCandidates(expr.left, scope, depth + 1, fileDir);
|
|
273
|
+
const b = resolveStringCandidates(expr.right, scope, depth + 1, fileDir);
|
|
274
|
+
if (a && b) return [...a, ...b];
|
|
275
|
+
return a || b;
|
|
276
|
+
}
|
|
277
|
+
case "CallExpression": {
|
|
278
|
+
const callee = expr.callee;
|
|
279
|
+
const isJoiner =
|
|
280
|
+
(callee.type === "Identifier" && /^(cn|clsx|cx|classNames|classnames|joinClasses|mergeClasses)$/.test(callee.name)) ||
|
|
281
|
+
(callee.type === "MemberExpression" && callee.object && callee.object.name === "classNames");
|
|
282
|
+
if (isJoiner) {
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const arg of expr.arguments) {
|
|
285
|
+
const c = resolveStringCandidates(arg, scope, depth + 1, fileDir);
|
|
286
|
+
if (c) out.push(...c);
|
|
287
|
+
}
|
|
288
|
+
return out.length ? out : null;
|
|
289
|
+
}
|
|
290
|
+
// Call of an imported function (e.g. rowCls(wide)) — resolve the
|
|
291
|
+
// exported function body in the sibling file.
|
|
292
|
+
if (callee.type === "Identifier" && fileDir) {
|
|
293
|
+
let imp = null;
|
|
294
|
+
for (let s = scope; s; s = s.upper) {
|
|
295
|
+
imp = (s.variables || []).find((v) => v.name === callee.name && v.defs && v.defs.some((d) => d.type === "ImportBinding"));
|
|
296
|
+
if (imp) break;
|
|
297
|
+
}
|
|
298
|
+
if (imp) {
|
|
299
|
+
const binding = (imp.defs.find((d) => d.type === "ImportBinding") || imp.defs[0] || {}).node;
|
|
300
|
+
const source = binding.parent && binding.parent.source && binding.parent.source.value;
|
|
301
|
+
if (source && source.startsWith(".")) {
|
|
302
|
+
const target = path.resolve(fileDir, source);
|
|
303
|
+
const exts = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
|
|
304
|
+
for (const ext of exts) {
|
|
305
|
+
const candidate = target + ext;
|
|
306
|
+
try {
|
|
307
|
+
if (fs.statSync(candidate).isFile()) {
|
|
308
|
+
const c = resolveExportedFromFile(candidate, imp.name);
|
|
309
|
+
if (c) return c;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// try next extension
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
case "Identifier": {
|
|
322
|
+
// Local declaration in an enclosing scope (const cls = ...).
|
|
323
|
+
for (let s = scope; s; s = s.upper) {
|
|
324
|
+
const v = s.variables.find((x) => x.name === expr.name);
|
|
325
|
+
if (!v) continue;
|
|
326
|
+
for (const def of v.defs || []) {
|
|
327
|
+
const node = def && def.node;
|
|
328
|
+
if (node && node.type === "VariableDeclarator" && node.init) {
|
|
329
|
+
const c = resolveStringCandidates(node.init, scope, depth + 1, fileDir);
|
|
330
|
+
if (c) return c;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Imported from a sibling file — read the file, find the exported
|
|
335
|
+
// constant, collect its string literals. The import binding lives
|
|
336
|
+
// in the module scope, so walk up.
|
|
337
|
+
let imp = null;
|
|
338
|
+
for (let s = scope; s; s = s.upper) {
|
|
339
|
+
imp = (s.variables || []).find((v) => v.name === expr.name && v.defs && v.defs.some((d) => d.type === "ImportBinding"));
|
|
340
|
+
if (imp) break;
|
|
341
|
+
}
|
|
342
|
+
if (imp && fileDir) {
|
|
343
|
+
const binding = (imp.defs.find((d) => d.type === "ImportBinding") || imp.defs[0] || {}).node;
|
|
344
|
+
const source = binding.parent && binding.parent.source && binding.parent.source.value;
|
|
345
|
+
if (source && source.startsWith(".")) {
|
|
346
|
+
const target = path.resolve(fileDir, source);
|
|
347
|
+
const exts = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
|
|
348
|
+
for (const ext of exts) {
|
|
349
|
+
const candidate = target + ext;
|
|
350
|
+
try {
|
|
351
|
+
if (fs.statSync(candidate).isFile()) {
|
|
352
|
+
const c = resolveExportedFromFile(candidate, imp.name);
|
|
353
|
+
if (c) return c;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
// try next extension
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
default:
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = {
|
|
370
|
+
stringLiteralsOf,
|
|
371
|
+
tsStringLiteralsOf,
|
|
372
|
+
resolveExportedFromFile,
|
|
373
|
+
resolveStringCandidates,
|
|
374
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scanner/attribute.js
|
|
5
|
+
*
|
|
6
|
+
* Component attribution: map a source file to the canonical component it
|
|
7
|
+
* belongs to.
|
|
8
|
+
*
|
|
9
|
+
* The component is the nearest PascalCase directory at or above the file's
|
|
10
|
+
* directory (e.g. packages/components/default/forms/Button/index.tsx ->
|
|
11
|
+
* Button). When the component registry is available, the registry's public
|
|
12
|
+
* name for that directory wins (the registry is the source of truth for
|
|
13
|
+
* public names).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const path = require("node:path");
|
|
18
|
+
|
|
19
|
+
/** Build the dir -> public component name map from the registry (if present). */
|
|
20
|
+
function buildDirNameMap(repoRoot, registryPath) {
|
|
21
|
+
const map = new Map();
|
|
22
|
+
const regPath = registryPath || path.join(repoRoot, "reports", "component-registry.json");
|
|
23
|
+
if (!fs.existsSync(regPath)) return map;
|
|
24
|
+
try {
|
|
25
|
+
const reg = JSON.parse(fs.readFileSync(regPath, "utf8"));
|
|
26
|
+
for (const [name, dirs] of Object.entries((reg && reg.names) || {})) {
|
|
27
|
+
for (const d of dirs) {
|
|
28
|
+
if (!map.has(d)) map.set(d, name);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// registry unreadable — fall back to directory basenames
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Attribute a file to a component name.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} fileAbs absolute path to the source file
|
|
41
|
+
* @param {string} repoRoot absolute repo root
|
|
42
|
+
* @param {Map<string,string>} dirNameMap registry dir -> public name
|
|
43
|
+
* @returns {string} component name
|
|
44
|
+
*/
|
|
45
|
+
function componentNameFor(fileAbs, repoRoot, dirNameMap) {
|
|
46
|
+
let dir = path.dirname(fileAbs);
|
|
47
|
+
// Fallback: the file's own basename (e.g. useCampaignRun.tsx -> useCampaignRun).
|
|
48
|
+
// The old fallback was the directory basename, which produced garbage names
|
|
49
|
+
// like "hooks" or "components" for files sitting in generic folders.
|
|
50
|
+
let name = path.basename(fileAbs).replace(/\.[^.]+$/, "");
|
|
51
|
+
// index.tsx / styles.tsx inside a PascalCase dir belong to that dir's component
|
|
52
|
+
// (e.g. Pagination/index.tsx -> Pagination, Button/styles.ts -> Button).
|
|
53
|
+
const isFileScoped = /^(index|styles|types|constants|utils|hooks|test|stories)$/i.test(name);
|
|
54
|
+
// The file's OWN dir is the most likely component dir (Pagination/index.tsx).
|
|
55
|
+
let up = dir;
|
|
56
|
+
if (/^[A-Z][A-Za-z0-9]*$/.test(path.basename(dir)) && isFileScoped) {
|
|
57
|
+
name = path.basename(dir);
|
|
58
|
+
} else {
|
|
59
|
+
for (let i = 0; i < 4; i += 1) {
|
|
60
|
+
const parent = path.dirname(up);
|
|
61
|
+
if (parent === up) break;
|
|
62
|
+
const bn = path.basename(parent);
|
|
63
|
+
if (/^[A-Z][A-Za-z0-9]*$/.test(bn)) {
|
|
64
|
+
up = parent;
|
|
65
|
+
if (isFileScoped) name = bn; // index/styles/etc. -> dir component name
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
up = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Prefer the registry's public name for this directory.
|
|
72
|
+
const relDir = path.relative(repoRoot, dir).split(path.sep).join("/");
|
|
73
|
+
const relUp = path.relative(repoRoot, up).split(path.sep).join("/");
|
|
74
|
+
const regName = dirNameMap.get(relDir) || dirNameMap.get(relUp);
|
|
75
|
+
if (regName) name = regName;
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { buildDirNameMap, componentNameFor };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { computeGenericTokens, distinctiveTokens } = require("./matching");
|
|
4
|
+
const { resolveThresholds } = require("../thresholds");
|
|
5
|
+
|
|
6
|
+
const JACCARD_MIN = 0.3;
|
|
7
|
+
const FAMILY_MATCH_MIN = resolveThresholds({}).familyMatchMin;
|
|
8
|
+
const MIN_FAMILY_TOKENS = 3;
|
|
9
|
+
|
|
10
|
+
const componentTokenSet = (c, generic) => {
|
|
11
|
+
const s = new Set();
|
|
12
|
+
for (const e of c.elementProps || []) for (const t of e.tokens || []) s.add(t);
|
|
13
|
+
return distinctiveTokens([...s], generic);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const jaccard = (a, b) => {
|
|
17
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
18
|
+
let inter = 0;
|
|
19
|
+
for (const t of a) if (b.has(t)) inter += 1;
|
|
20
|
+
const union = a.size + b.size - inter;
|
|
21
|
+
return union === 0 ? 0 : inter / union;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function clusterComponents(canonComponents) {
|
|
25
|
+
const generic = computeGenericTokens(canonComponents);
|
|
26
|
+
const sets = canonComponents.map((c) => componentTokenSet(c, generic));
|
|
27
|
+
const clusters = [];
|
|
28
|
+
for (let i = 0; i < canonComponents.length; i += 1) {
|
|
29
|
+
const s = sets[i];
|
|
30
|
+
if (s.size < MIN_FAMILY_TOKENS) continue;
|
|
31
|
+
let best = null;
|
|
32
|
+
for (const cl of clusters) {
|
|
33
|
+
const j = jaccard(s, cl.tokenSet);
|
|
34
|
+
if (j >= JACCARD_MIN && (!best || j > best.j)) best = { cl, j };
|
|
35
|
+
}
|
|
36
|
+
if (best) best.cl.members.push(i);
|
|
37
|
+
else clusters.push({ members: [i], tokenSet: new Set(s) });
|
|
38
|
+
}
|
|
39
|
+
const families = [];
|
|
40
|
+
for (const cl of clusters) {
|
|
41
|
+
if (cl.members.length < 2) continue;
|
|
42
|
+
const count = new Map();
|
|
43
|
+
for (const i of cl.members) for (const t of sets[i]) count.set(t, (count.get(t) || 0) + 1);
|
|
44
|
+
const half = cl.members.length / 2;
|
|
45
|
+
const tokenSet = new Set();
|
|
46
|
+
for (const [t, n] of count) if (n >= half) tokenSet.add(t);
|
|
47
|
+
if (tokenSet.size < MIN_FAMILY_TOKENS) continue;
|
|
48
|
+
families.push({ members: cl.members, tokenSet, sets });
|
|
49
|
+
}
|
|
50
|
+
return { families, sets, generic };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function nearestMember(family, appTokens) {
|
|
54
|
+
let best = null;
|
|
55
|
+
for (const i of family.members) {
|
|
56
|
+
const j = jaccard(appTokens, family.sets[i]);
|
|
57
|
+
if (!best || j > best.j) best = { idx: i, j };
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function matchFamilies(appComponents, canonComponents, cluster, opts) {
|
|
63
|
+
const { families, sets, generic } = cluster;
|
|
64
|
+
const th = resolveThresholds(opts);
|
|
65
|
+
const matches = [];
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const app of appComponents) {
|
|
68
|
+
const appTokens = componentTokenSet(app, generic);
|
|
69
|
+
if (appTokens.size < MIN_FAMILY_TOKENS) continue;
|
|
70
|
+
let best = null;
|
|
71
|
+
for (const family of families) {
|
|
72
|
+
const j = jaccard(appTokens, family.tokenSet);
|
|
73
|
+
if (j < th.familyMatchMin) continue;
|
|
74
|
+
const near = nearestMember(family, appTokens);
|
|
75
|
+
if (!near || near.j < th.familyMatchMin) continue;
|
|
76
|
+
const member = canonComponents[near.idx];
|
|
77
|
+
if ((app.usesCanonical || []).includes(member.name)) continue;
|
|
78
|
+
if ((app.elementProps || []).length > 3 * (member.elementProps || []).length) continue;
|
|
79
|
+
if (!best || near.j > best.j) best = { canon: member, j: near.j, familyJ: j };
|
|
80
|
+
}
|
|
81
|
+
if (best) {
|
|
82
|
+
const key = `${app.path}:${app.line}->${best.canon.path}:${best.canon.name}`;
|
|
83
|
+
if (seen.has(key)) continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
matches.push({
|
|
86
|
+
app,
|
|
87
|
+
canon: best.canon,
|
|
88
|
+
tier: "family",
|
|
89
|
+
reason: `token profile matches the ${best.canon.name} family (${(best.j * 100).toFixed(0)}% overlap)`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return matches;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { clusterComponents, matchFamilies, componentTokenSet, jaccard, JACCARD_MIN, FAMILY_MATCH_MIN, MIN_FAMILY_TOKENS };
|