@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/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
+ };