@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/index.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @md-code/react-component-uniqueness
|
|
5
|
+
*
|
|
6
|
+
* One component = one canonical location. Four error types:
|
|
7
|
+
*
|
|
8
|
+
* 1. DUPLICATE DECLARATION (messageId: duplicateComponent)
|
|
9
|
+
* The component registry maps every canonical component name to the list
|
|
10
|
+
* of directories where that name is allowed to be declared. Declaring a
|
|
11
|
+
* registry name outside its canonical directories is a duplicate.
|
|
12
|
+
* (errorTypes/duplicate-component.js)
|
|
13
|
+
*
|
|
14
|
+
* 2. RAW HTML (messageId: rawHtml)
|
|
15
|
+
* Raw interactive / form elements are only legal inside the canonical
|
|
16
|
+
* packages — that is where the canonical components are built from them.
|
|
17
|
+
* (errorTypes/raw-html.js)
|
|
18
|
+
*
|
|
19
|
+
* 3. HAND-ROLLED COMPONENTS BY BEHAVIOR (messageId: layoutPrimitive) +
|
|
20
|
+
* CATALOG MATCH (messageId: catalogDuplicate / similarComponent)
|
|
21
|
+
* An element that behaves like a canonical component (container, card,
|
|
22
|
+
* modal, dropdown, tabs, slider, tooltip, ...) is a duplicate and is
|
|
23
|
+
* reported everywhere. The element's Signature is also matched against
|
|
24
|
+
* the component catalog (decision matrix).
|
|
25
|
+
* (errorTypes/layout-primitive.js)
|
|
26
|
+
*
|
|
27
|
+
* 4. STYLED IN APP (messageId: styledInApp)
|
|
28
|
+
* styled.<htmlTag> created outside the canonical packages is UI built in
|
|
29
|
+
* the app. (errorTypes/styled-in-app.js)
|
|
30
|
+
*
|
|
31
|
+
* The rule is TOTAL. Legacy findings go to the debt ledger (shrink-only).
|
|
32
|
+
*
|
|
33
|
+
* Options:
|
|
34
|
+
* - registry: object — { names: { Name: [dir, ...] }, known: {...} }.
|
|
35
|
+
* Inlined registry (takes precedence over registryPath).
|
|
36
|
+
* - registryPath: string — repo-relative (or absolute) path to the
|
|
37
|
+
* registry JSON. Default: "reports/component-registry.json".
|
|
38
|
+
* - catalog: object — { components: [...] }. Inlined catalog.
|
|
39
|
+
* - catalogPath: string — path to the catalog JSON.
|
|
40
|
+
* Default: "reports/component-catalog.json".
|
|
41
|
+
* - includeDynamic: boolean — when true, also match against the dynamic
|
|
42
|
+
* catalog (signatures captured from a real rendered DOM, covering styles
|
|
43
|
+
* invisible statically: MUI emotion/sx, antd cssinjs). Default: false.
|
|
44
|
+
* - dynamicCatalog: object — inlined dynamic catalog.
|
|
45
|
+
* - dynamicCatalogPath: string — path to the dynamic catalog JSON.
|
|
46
|
+
* Default: "reports/component-catalog-dynamic.json".
|
|
47
|
+
* - rawElements: string[] — tags flagged by the raw-html error type.
|
|
48
|
+
* - behaviorTags: string[] — tags eligible for behavior analysis.
|
|
49
|
+
* - componentsFolder: string[] — the canonical home of every reusable
|
|
50
|
+
* component. Raw HTML / styled.* are legal only inside these folders
|
|
51
|
+
* (error types 2 and 4 stay silent there); the catalog is generated from
|
|
52
|
+
* them and the duplicate report clusters them. Trailing slashes are
|
|
53
|
+
* optional. NO DEFAULT: the canonical layout is the consumer's knowledge
|
|
54
|
+
* (pass it in the ESLint rule options). Empty list = every file is
|
|
55
|
+
* treated as outside the component folders.
|
|
56
|
+
* - include: (string|RegExp)[] — files to lint (minimatch globs or
|
|
57
|
+
* regexes). Empty (default) = every file not excluded.
|
|
58
|
+
* - exclude: (string|RegExp)[] — files to skip (minimatch globs or
|
|
59
|
+
* regexes). Default: node_modules / dist / build / .git.
|
|
60
|
+
* - debt: object — debt ledger ("relpath::ruleId" -> count). Findings
|
|
61
|
+
* recorded in the ledger are not reported (shrink-only).
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
const path = require("node:path");
|
|
65
|
+
|
|
66
|
+
const { normalizeOptions, inComponentsFolder: inComponentsFolderOf, isIgnored } = require("./config");
|
|
67
|
+
const { applyDebt, findRepoRoot } = require("./debt");
|
|
68
|
+
const { loadRegistry, loadCatalog, loadDynamicCatalog } = require("./loaders");
|
|
69
|
+
const duplicateComponentErrorType = require("./errorTypes/duplicate-component");
|
|
70
|
+
const rawHtmlErrorType = require("./errorTypes/raw-html");
|
|
71
|
+
const layoutPrimitiveErrorType = require("./errorTypes/layout-primitive");
|
|
72
|
+
const styledInAppErrorType = require("./errorTypes/styled-in-app");
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
meta: {
|
|
76
|
+
type: "problem",
|
|
77
|
+
docs: {
|
|
78
|
+
description:
|
|
79
|
+
"component names are unique and components are not hand-rolled: a canonical component may only be declared in its registry directory, raw interactive HTML is only legal inside the canonical packages, and an element that behaves like a canonical component (container, card, modal, dropdown, tabs, slider, tooltip, ...) is a duplicate everywhere",
|
|
80
|
+
},
|
|
81
|
+
messages: {
|
|
82
|
+
duplicateComponent:
|
|
83
|
+
"Duplicate declaration of canonical component '{{name}}'. Canonical location: {{dirs}}. Use the canonical component instead of re-declaring it.",
|
|
84
|
+
rawHtml: "Raw <{{tag}}> outside the canonical packages → replace with `{{component}}` from {{path}}.",
|
|
85
|
+
layoutPrimitive: "Hand-rolled {{what}} built from <{{tag}}> → replace with `{{component}}` from {{path}}.",
|
|
86
|
+
styledInApp: "styled.{{tag}} created outside the canonical packages → move it as `{{component}}` into {{path}}.",
|
|
87
|
+
catalogDuplicate: "Element <{{tag}}> duplicates {{what}} ({{path}}): {{reason}}. Use the canonical component instead.",
|
|
88
|
+
similarComponent: "Element <{{tag}}> is similar to {{what}} ({{path}}): {{reason}}. Consider using the canonical component.",
|
|
89
|
+
},
|
|
90
|
+
schema: [
|
|
91
|
+
{
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
registry: { type: "object" },
|
|
95
|
+
registryPath: { type: "string" },
|
|
96
|
+
catalog: { type: "object" },
|
|
97
|
+
catalogPath: { type: "string" },
|
|
98
|
+
includeDynamic: { type: "boolean" },
|
|
99
|
+
dynamicCatalog: { type: "object" },
|
|
100
|
+
dynamicCatalogPath: { type: "string" },
|
|
101
|
+
rawElements: { type: "array", items: { type: "string" } },
|
|
102
|
+
behaviorTags: { type: "array", items: { type: "string" } },
|
|
103
|
+
componentsFolder: {
|
|
104
|
+
type: "array",
|
|
105
|
+
items: {
|
|
106
|
+
anyOf: [
|
|
107
|
+
{ type: "string" },
|
|
108
|
+
{
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
path: { type: "string" },
|
|
112
|
+
rank: { type: "number" },
|
|
113
|
+
},
|
|
114
|
+
required: ["path"],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
include: { type: "array", items: { type: "string" } },
|
|
120
|
+
exclude: { type: "array", items: { type: "string" } },
|
|
121
|
+
debt: { type: "object" },
|
|
122
|
+
thresholds: { type: "object" },
|
|
123
|
+
},
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
create(rawContext) {
|
|
129
|
+
// ESLint 9: rule options live on the context, not as a second create arg.
|
|
130
|
+
const rawOptions = rawContext.options ?? [];
|
|
131
|
+
const opts = (Array.isArray(rawOptions) ? rawOptions[0] : rawOptions) || {};
|
|
132
|
+
const config = normalizeOptions(opts);
|
|
133
|
+
const context = applyDebt(rawContext, config.debt, "md-code/react-component-uniqueness");
|
|
134
|
+
|
|
135
|
+
const registry = loadRegistry(config);
|
|
136
|
+
const names = (registry && registry.names) || {};
|
|
137
|
+
const catalog = loadCatalog(config);
|
|
138
|
+
let catalogComponents = (catalog && catalog.components) || [];
|
|
139
|
+
|
|
140
|
+
// includeDynamic: merge the rendered-DOM catalog (MUI sx / antd
|
|
141
|
+
// cssinjs styles, invisible statically) into the static catalog, and
|
|
142
|
+
// build the tagMap: a component tag (e.g. "MuiBtn") -> the rendered
|
|
143
|
+
// DOM tag ("button"), so a wrapper built on a library component is
|
|
144
|
+
// matched against the catalog with its REAL tag + runtime styles.
|
|
145
|
+
let dynTagMap = null;
|
|
146
|
+
if (config.includeDynamic) {
|
|
147
|
+
const dyn = loadDynamicCatalog(config);
|
|
148
|
+
const dynComponents = (dyn && dyn.components) || [];
|
|
149
|
+
if (dynComponents.length) {
|
|
150
|
+
// Entry shape: { name, path, comp (JSX tag / import alias used in the
|
|
151
|
+
// source, e.g. "Bn"), tag (rendered DOM tag, e.g. "button"), styles,
|
|
152
|
+
// actions, a11y, data }.
|
|
153
|
+
// Dedupe by PATH: a file already in the static catalog keeps its
|
|
154
|
+
// static entry (the static catalog is the source of truth for
|
|
155
|
+
// canonical files); dynamic entries only ADD files the static pass
|
|
156
|
+
// could not fully see (library wrappers in app folders).
|
|
157
|
+
const seen = new Set(catalogComponents.map((c) => c.path));
|
|
158
|
+
// Mark the added app-wrapper entries so matchCatalog can drop
|
|
159
|
+
// them when linting a canonical file (a canonical component is
|
|
160
|
+
// the source of truth and must not be a "duplicate" of an app
|
|
161
|
+
// wrapper).
|
|
162
|
+
const dynAdded = dynComponents.filter((c) => !seen.has(c.path)).map((c) => ({ ...c, __dyn: true }));
|
|
163
|
+
catalogComponents = [...catalogComponents, ...dynAdded];
|
|
164
|
+
// JSX tag -> rendered-DOM entry. Several wrappers may share one
|
|
165
|
+
// library tag (MuiButton x3); they render the same DOM + styles, so
|
|
166
|
+
// a last-wins collision is harmless (actions are NOT merged — see
|
|
167
|
+
// layout-primitive.js).
|
|
168
|
+
dynTagMap = new Map(dynComponents.map((c) => [c.comp || c.origTag, c]));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const filePath = (rawContext.getFilename ? rawContext.getFilename() : rawContext.filename) || "";
|
|
173
|
+
const abs = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
174
|
+
const root = findRepoRoot(path.dirname(abs)) || config.root;
|
|
175
|
+
const relPath = root ? path.relative(root, abs).split(path.sep).join("/") : "";
|
|
176
|
+
const fileDir = path.dirname(abs);
|
|
177
|
+
|
|
178
|
+
// include/exclude file filters (minimatch globs or regexes).
|
|
179
|
+
if (relPath && isIgnored(relPath, config.include, config.exclude)) return {};
|
|
180
|
+
|
|
181
|
+
// Inside the component folders = inside the canonical home of
|
|
182
|
+
// components. Raw HTML / styled.* are legal there (error types 2 and 4
|
|
183
|
+
// stay silent); behavior and catalog checks run everywhere.
|
|
184
|
+
const inComponentsFolder = inComponentsFolderOf(relPath, config.componentsFolder);
|
|
185
|
+
|
|
186
|
+
const env = {
|
|
187
|
+
context,
|
|
188
|
+
names,
|
|
189
|
+
relPath,
|
|
190
|
+
fileDir,
|
|
191
|
+
inComponentsFolder,
|
|
192
|
+
rawElements: config.rawElements,
|
|
193
|
+
behaviorTags: config.behaviorTags,
|
|
194
|
+
catalogComponents,
|
|
195
|
+
registry,
|
|
196
|
+
componentsFolder: config.componentsFolder,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const dupHandler = duplicateComponentErrorType.createHandler(env);
|
|
200
|
+
const rawHandler = rawHtmlErrorType.createHandler(env);
|
|
201
|
+
const layoutHandler = layoutPrimitiveErrorType.createHandler(env);
|
|
202
|
+
const styledHandler = styledInAppErrorType.createHandler(env);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
VariableDeclarator(node) {
|
|
206
|
+
dupHandler.VariableDeclarator(node);
|
|
207
|
+
styledHandler.VariableDeclarator(node);
|
|
208
|
+
},
|
|
209
|
+
ExportNamedDeclaration(node) {
|
|
210
|
+
dupHandler.ExportNamedDeclaration(node);
|
|
211
|
+
},
|
|
212
|
+
ExportDefaultDeclaration(node) {
|
|
213
|
+
dupHandler.ExportDefaultDeclaration(node);
|
|
214
|
+
},
|
|
215
|
+
JSXOpeningElement(node) {
|
|
216
|
+
if (node.name.type !== "JSXIdentifier") return;
|
|
217
|
+
const sourceTag = node.name.name;
|
|
218
|
+
// includeDynamic: a CAPITALIZED tag that the playground
|
|
219
|
+
// rendered (e.g. a MUI/antd wrapper) is resolved to its real
|
|
220
|
+
// DOM tag, and the signature is enriched with the runtime
|
|
221
|
+
// styles the library applies (emotion/sx, cssinjs). Raw HTML
|
|
222
|
+
// tags (div, button, ...) are already DOM tags — they are never
|
|
223
|
+
// resolved, so their rawHtml / behavior analysis still runs.
|
|
224
|
+
const isComponentTag = /^[A-Z]/.test(sourceTag);
|
|
225
|
+
const dynEntry = dynTagMap && isComponentTag && dynTagMap.has(sourceTag) ? dynTagMap.get(sourceTag) : null;
|
|
226
|
+
const tag = dynEntry && dynEntry.tag ? dynEntry.tag : sourceTag;
|
|
227
|
+
// rawHtml and the behavior (layout-primitive) checks are
|
|
228
|
+
// SOURCE-level: a capitalized component resolved to a DOM tag
|
|
229
|
+
// via includeDynamic is not a raw element in the source, so it
|
|
230
|
+
// must not be reported as "Raw <button>" or a hand-rolled
|
|
231
|
+
// primitive. Static mode is unaffected (dynTagMap empty →
|
|
232
|
+
// isRawSource always true).
|
|
233
|
+
const isRawSource = !dynEntry;
|
|
234
|
+
// A JSX tag that is a REGISTRY CANONICAL NAME (e.g. <Button>,
|
|
235
|
+
// <Select>, <Input>) is a REFERENCE to the canonical component,
|
|
236
|
+
// not a hand-rolled re-implementation. Its declaration-site
|
|
237
|
+
// duplicate (a second `export const Button = ...`) is caught by
|
|
238
|
+
// the separate duplicateComponent error type. So a catalog
|
|
239
|
+
// match against such a tag is a false positive — the element is
|
|
240
|
+
// legitimately USING the canonical component. Skip the
|
|
241
|
+
// error-tier catalog verdict for registry names. Fixture tags
|
|
242
|
+
// (MuiButton, AntButton, Bn, ...) are NOT registry names, so
|
|
243
|
+
// they are still matched and flagged.
|
|
244
|
+
const isCanonicalRef = isComponentTag && Object.prototype.hasOwnProperty.call(names, sourceTag);
|
|
245
|
+
// Error type 3b: catalog match (decision matrix) — applies to
|
|
246
|
+
// every element, reported everywhere, including inside the
|
|
247
|
+
// canonical packages.
|
|
248
|
+
const scope = rawContext.sourceCode.getScope(node);
|
|
249
|
+
const hit = isCanonicalRef ? null : layoutHandler.matchCatalog(tag, node, scope, dynEntry);
|
|
250
|
+
if (hit && hit.level === "error") {
|
|
251
|
+
context.report({
|
|
252
|
+
node,
|
|
253
|
+
messageId: "catalogDuplicate",
|
|
254
|
+
data: { tag, what: hit.name, path: hit.path, reason: hit.reason },
|
|
255
|
+
});
|
|
256
|
+
return; // an error-tier catalog verdict supersedes the other error types
|
|
257
|
+
}
|
|
258
|
+
const similarHit = hit; // warning tier — reported below, non-authoritative
|
|
259
|
+
// NOTE: the old single-signal "behavior" check (layoutHandler.
|
|
260
|
+
// handleElement: div+onClick = "button", flex = "container",
|
|
261
|
+
// fixed+z-1000 = "modal") is DISABLED. It guessed a role from
|
|
262
|
+
// ONE sign without comparing anything, and produced noise
|
|
263
|
+
// (every flex-div = "container"). The agreed design is the
|
|
264
|
+
// 4-category comparison (tag, a11y, actions, styles) against
|
|
265
|
+
// the catalog — matchCatalog above — which catches real
|
|
266
|
+
// hand-rolled components when they actually look like a
|
|
267
|
+
// canonical one.
|
|
268
|
+
const reported = isRawSource && rawHandler.handleRaw(tag, node);
|
|
269
|
+
// Warning tier: report-only hint, never fails the gate
|
|
270
|
+
// (gen-lint-debt.mjs excludes similarComponent findings).
|
|
271
|
+
// Reported everywhere — a similar-looking element inside the
|
|
272
|
+
// component folders is still a hint worth surfacing.
|
|
273
|
+
if (!reported && similarHit) {
|
|
274
|
+
context.report({
|
|
275
|
+
node,
|
|
276
|
+
messageId: "similarComponent",
|
|
277
|
+
data: { tag, what: similarHit.name, path: similarHit.path, reason: similarHit.reason },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
};
|
package/loaders.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* loaders.js
|
|
5
|
+
*
|
|
6
|
+
* Loads the two data files the rule depends on:
|
|
7
|
+
*
|
|
8
|
+
* - the component registry (reports/component-registry.json)
|
|
9
|
+
* - the component catalog (reports/component-catalog.json)
|
|
10
|
+
*
|
|
11
|
+
* Both are cached per process. Errors are reported once per process with a
|
|
12
|
+
* clear, actionable message (English) and the error type degrades to strict /
|
|
13
|
+
* no-op rather than crashing the lint run.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const path = require("node:path");
|
|
18
|
+
|
|
19
|
+
const { generateCatalog } = require("./scanner/generate");
|
|
20
|
+
const { writeCatalog } = require("./scanner/output");
|
|
21
|
+
|
|
22
|
+
const cache = new Map(); // absolutePath -> { loaded: boolean, value: any, warned: boolean }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read and parse a JSON data file, with caching and a one-time, actionable
|
|
26
|
+
* warning when the file is missing or invalid.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} absPath absolute path to the JSON file
|
|
29
|
+
* @param {string} label human label used in the warning (e.g. "component registry")
|
|
30
|
+
* @returns {object|null} the parsed object, or null when unavailable
|
|
31
|
+
*/
|
|
32
|
+
function loadJson(absPath, label) {
|
|
33
|
+
if (cache.has(absPath)) return cache.get(absPath).value;
|
|
34
|
+
|
|
35
|
+
let entry = { loaded: false, value: null, warned: false };
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(absPath, "utf8");
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
entry.value = parsed && typeof parsed === "object" ? parsed : null;
|
|
40
|
+
entry.loaded = true;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
entry.value = null;
|
|
43
|
+
entry.loaded = false;
|
|
44
|
+
const reason = err && err.code === "ENOENT" ? "file not found" : `could not be read/parsed (${err && err.message ? err.message : err})`;
|
|
45
|
+
// One warning per process, per file — not per linted file.
|
|
46
|
+
if (!entry.warned) {
|
|
47
|
+
entry.warned = true;
|
|
48
|
+
const rel = path.isAbsolute(absPath) ? absPath : path.resolve(absPath);
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.warn(
|
|
51
|
+
`[react-component-uniqueness] ${label} ${reason} at ${rel}. ` +
|
|
52
|
+
`The ${label} error type is disabled for this run. ` +
|
|
53
|
+
`Generate it first (see the package README) or pass it via the rule options.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cache.set(absPath, entry);
|
|
59
|
+
return entry.value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load the component registry.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} config runtime config (from config.normalizeOptions)
|
|
66
|
+
* @returns {object|null} { names: { Name: [dir, ...] }, known: {...} } or null
|
|
67
|
+
*/
|
|
68
|
+
function loadRegistry(config) {
|
|
69
|
+
if (config.registry) return config.registry;
|
|
70
|
+
return loadJson(config.registryPath, "component registry");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Load the component catalog. When the file is missing, generate it on the
|
|
75
|
+
* fly from the configured component folders (the catalog is gitignored and
|
|
76
|
+
* never committed — CI gets it the same way, on first lint).
|
|
77
|
+
*
|
|
78
|
+
* @param {object} config runtime config (from config.normalizeOptions)
|
|
79
|
+
* @returns {object|null} { components: [{ name, path, tag, styles, actions, a11y, data }] } or null
|
|
80
|
+
*/
|
|
81
|
+
function loadCatalog(config) {
|
|
82
|
+
if (config.catalog) return config.catalog;
|
|
83
|
+
|
|
84
|
+
const roots = (config.componentsFolder || []).map((d) => d.replace(/\/+$/, ""));
|
|
85
|
+
|
|
86
|
+
if (fs.existsSync(config.catalogPath)) {
|
|
87
|
+
return loadJson(config.catalogPath, "component catalog");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (roots.length === 0) {
|
|
91
|
+
return loadJson(config.catalogPath, "component catalog");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const { components } = generateCatalog(roots, config.root || process.cwd(), config.include, config.exclude);
|
|
96
|
+
const out = { generatedAt: new Date().toISOString(), components };
|
|
97
|
+
fs.mkdirSync(path.dirname(config.catalogPath), { recursive: true });
|
|
98
|
+
fs.writeFileSync(config.catalogPath, JSON.stringify(out, null, 2) + "\n");
|
|
99
|
+
cache.set(config.catalogPath, { loaded: true, value: out, warned: false });
|
|
100
|
+
// eslint-disable-next-line no-console
|
|
101
|
+
console.log(`[react-component-uniqueness] generated component catalog (${components.length} signature(s)) at ${config.catalogPath}`);
|
|
102
|
+
return out;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// eslint-disable-next-line no-console
|
|
105
|
+
console.warn(`[react-component-uniqueness] could not generate the component catalog: ${err && err.message ? err.message : err}`);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Load the DYNAMIC component catalog (signatures captured from a rendered
|
|
112
|
+
* DOM via the playground). Returns null when unavailable — the rule degrades
|
|
113
|
+
* to the static catalog only.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} config runtime config (from config.normalizeOptions)
|
|
116
|
+
* @returns {object|null} { components: [...] } or null
|
|
117
|
+
*/
|
|
118
|
+
function loadDynamicCatalog(config) {
|
|
119
|
+
if (config.dynamicCatalog) return config.dynamicCatalog;
|
|
120
|
+
return loadJson(config.dynamicCatalogPath, "dynamic component catalog");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { loadJson, loadRegistry, loadCatalog, loadDynamicCatalog };
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@md-code/react-component-uniqueness",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ESLint rule that enforces one canonical location per React component: duplicate declarations, raw HTML outside the canonical packages, hand-rolled components by behavior, and styled.* created in the app.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"md-code-react-component-uniqueness": "./bin/react-component-uniqueness.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"plugin.js",
|
|
13
|
+
"config.js",
|
|
14
|
+
"signature.js",
|
|
15
|
+
"debt.js",
|
|
16
|
+
"thresholds.js",
|
|
17
|
+
"loaders.js",
|
|
18
|
+
"resolve.js",
|
|
19
|
+
"dom.js",
|
|
20
|
+
"report.js",
|
|
21
|
+
"errorTypes/",
|
|
22
|
+
"scanner/",
|
|
23
|
+
"scanner/filters/",
|
|
24
|
+
"bin/",
|
|
25
|
+
"snapshots/",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./index.js",
|
|
30
|
+
"./plugin": "./plugin.js",
|
|
31
|
+
"./config": "./config.js",
|
|
32
|
+
"./signature": "./signature.js",
|
|
33
|
+
"./debt": "./debt.js",
|
|
34
|
+
"./thresholds": "./thresholds.js",
|
|
35
|
+
"./report": "./report.js",
|
|
36
|
+
"./filters": "./scanner/filters/index.js",
|
|
37
|
+
"./snapshots": "./snapshots/run.mjs",
|
|
38
|
+
"./snapshots/*": "./snapshots/*"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"minimatch": "^9.0.4"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"gen:catalog": "node scanner/main.js"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"eslint": ">=8.40.0",
|
|
48
|
+
"typescript": ">=4.7.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"puppeteer-core": { "optional": true }
|
|
52
|
+
},
|
|
53
|
+
"optionalDependencies": {
|
|
54
|
+
"puppeteer-core": "^24.0.0"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* plugin.js
|
|
5
|
+
*
|
|
6
|
+
* ESLint flat-config plugin export. Usage:
|
|
7
|
+
*
|
|
8
|
+
* const plugin = require("@md-code/react-component-uniqueness/plugin");
|
|
9
|
+
*
|
|
10
|
+
* export default [
|
|
11
|
+
* {
|
|
12
|
+
* plugins: { "md-code": plugin },
|
|
13
|
+
* rules: { "md-code/react-component-uniqueness": "error" },
|
|
14
|
+
* },
|
|
15
|
+
* ];
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
rules: {
|
|
20
|
+
"react-component-uniqueness": require("./index"),
|
|
21
|
+
},
|
|
22
|
+
};
|