@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,105 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * errorTypes/duplicate-component.js
5
+ *
6
+ * Error type 1 — DUPLICATE DECLARATION (messageId: duplicateComponent).
7
+ *
8
+ * The component registry (reports/component-registry.json) maps every
9
+ * canonical component name to the list of directories where that name is
10
+ * allowed to be declared. Declaring a registry name outside its canonical
11
+ * directories — const Button = ..., function Form() {...}, class Card,
12
+ * export default function Badge — is a duplicate.
13
+ *
14
+ * Only EXPORTED component bindings count: a private styled sub-part
15
+ * (const Title = styled.h3 inside Card) that happens to share a name with a
16
+ * canonical component is an internal detail, not a duplicate — private
17
+ * bindings cannot be imported elsewhere.
18
+ */
19
+
20
+ const { inCanonicalDir } = require("../config");
21
+
22
+ /** Is this identifier a component binding (not a type)? */
23
+ function isComponentBinding(node) {
24
+ if (!node || node.type !== "Identifier") return false;
25
+ if (!/^[A-Z]/.test(node.name)) return false;
26
+ // Skip type-only declarations: export type Foo / interface Foo are
27
+ // not components.
28
+ let parent = node.parent;
29
+ while (parent) {
30
+ if (parent.type === "TSInterfaceDeclaration") return false;
31
+ if (parent.type === "TSTypeAliasDeclaration") return false;
32
+ if (parent.type === "TSModuleDeclaration") return false;
33
+ if (parent.type === "ExportNamedDeclaration" || parent.type === "ExportDefaultDeclaration") break;
34
+ parent = parent.parent;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ /**
40
+ * Is this VariableDeclarator part of an exported declaration?
41
+ *
42
+ * Only exported components are "the component" — a private styled sub-part
43
+ * (const Title = styled.h3 inside Card, const Overlay inside HamburgerMenu)
44
+ * that happens to share a name with a canonical component is an internal
45
+ * detail, not a duplicate. Private bindings cannot be imported elsewhere,
46
+ * so they are never the thing the user is trying to prevent.
47
+ */
48
+ function isExportedDeclarator(node) {
49
+ let p = node.parent;
50
+ while (p && p.type === "VariableDeclaration") p = p.parent;
51
+ return p != null && p.type === "ExportNamedDeclaration";
52
+ }
53
+
54
+ /**
55
+ * Create the error type-1 visitors.
56
+ *
57
+ * @param {object} env { context, names, relPath }
58
+ * @returns {object} visitors: VariableDeclarator, ExportNamedDeclaration,
59
+ * ExportDefaultDeclaration
60
+ */
61
+ function createHandler(env) {
62
+ const { context, names, relPath } = env;
63
+
64
+ /** Report a duplicate declaration of a registry name. */
65
+ function checkName(name, node) {
66
+ const dirs = names[name];
67
+ if (!dirs || dirs.length === 0) return;
68
+ if (inCanonicalDir(relPath, dirs)) return;
69
+ context.report({
70
+ node,
71
+ messageId: "duplicateComponent",
72
+ data: { name, dirs: dirs.join(" or ") },
73
+ });
74
+ }
75
+
76
+ return {
77
+ VariableDeclarator(node) {
78
+ if (isComponentBinding(node.id) && isExportedDeclarator(node)) checkName(node.id.name, node.id);
79
+ },
80
+ ExportNamedDeclaration(node) {
81
+ const decl = node.declaration;
82
+ if (!decl) return;
83
+ // VariableDeclaration is handled by the VariableDeclarator
84
+ // visitor (avoids double-reporting).
85
+ if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration") {
86
+ if (isComponentBinding(decl.id)) checkName(decl.id.name, decl.id);
87
+ }
88
+ },
89
+ ExportDefaultDeclaration(node) {
90
+ const decl = node.declaration;
91
+ if (!decl) return;
92
+ // export default function Foo / class Foo
93
+ if ((decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration") && decl.id) {
94
+ if (isComponentBinding(decl.id)) checkName(decl.id.name, decl.id);
95
+ } else if (decl.type === "Identifier") {
96
+ // export default SomeBinding — the binding must be declared
97
+ // in this file; its name is the folder name only, so we
98
+ // cannot attribute a registry name to it reliably. Skip.
99
+ return;
100
+ }
101
+ },
102
+ };
103
+ }
104
+
105
+ module.exports = { createHandler, isComponentBinding, isExportedDeclarator };
@@ -0,0 +1,212 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * errorTypes/layout-primitive.js
5
+ *
6
+ * Error type 3 — HAND-ROLLED COMPONENTS BY BEHAVIOR (messageId: layoutPrimitive)
7
+ * and Error type 3b — CATALOG MATCH (messageId: catalogDuplicate,
8
+ * similarComponent).
9
+ *
10
+ * A plain div is building material and is NOT flagged on its own. But an
11
+ * element that BEHAVES like a canonical component — a div with
12
+ * flex/grid/col/row/justify/gap classes (a hand-rolled container), a span
13
+ * with onClick (a hand-rolled button), role=dialog / aria-modal (a
14
+ * hand-rolled modal), role=menu (a hand-rolled dropdown), input type=range
15
+ * (a hand-rolled slider), role=tooltip (a hand-rolled tooltip), a box with
16
+ * rounded corners + shadow (a hand-rolled Card / Paper), a spinner, tabs,
17
+ * accordion, progress, alert, avatar, badge — is a duplicate of a canonical
18
+ * component and is reported EVERYWHERE, including inside the component
19
+ * folders. That is what makes the rule total: a container hand-rolled in
20
+ * packages/components is still a duplicate of Container.
21
+ *
22
+ * The taxonomy mirrors the component categories of shadcn / MUI / antd.
23
+ * Values are resolved dynamically: className from a local variable,
24
+ * ternaries, cn()/clsx() calls, template literals, and identifiers imported
25
+ * from a sibling file (the imported file is read from disk and the exported
26
+ * constant's string literals are collected).
27
+ *
28
+ * Error type 3b matches the element's Signature against the component catalog
29
+ * (reports/component-catalog.json) using the decision matrix
30
+ * (signature.js). An error-tier verdict supersedes the other error types; a
31
+ * warning-tier verdict is a report-only hint (never fails the gate).
32
+ */
33
+
34
+ const sig = require("../signature");
35
+ const {
36
+ SPINNER_CLASS_RE,
37
+ MODAL_CLASS_RE,
38
+ CARD_CLASS_RE,
39
+ CARD_SECOND_RE,
40
+ CONTAINER_CLASS_RE,
41
+ ROLE_WHAT,
42
+ } = require("../config");
43
+ const { attrOf, attrValueOf, classNameCandidates, stylePropsOf, buildSignature } = require("../dom");
44
+ const { forBehavior } = require("../suggestions");
45
+
46
+ /**
47
+ * Library style props that signal "this wrapper applies its OWN styling to a
48
+ * library primitive" — the static equivalent of a style={} object. MUI `sx`,
49
+ * antd `style`/`css`, styled-components `className` overrides, etc. A bare
50
+ * <MuiButton> has none of these (plain import); <MuiPaper sx={{...}}> does
51
+ * (a hand-rolled card). buildSignature() does not read these, so includeDynamic
52
+ * needs an explicit check to tell the two apart.
53
+ */
54
+ const LIBRARY_STYLE_PROPS = new Set(["sx", "css", "styleObject", "stl"]);
55
+ function hasLibraryStyleProp(opening) {
56
+ return (opening.attributes || []).some(
57
+ (a) => a.type === "JSXAttribute" && a.name && LIBRARY_STYLE_PROPS.has(a.name.name)
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Error type 3: an element that behaves like a canonical component.
63
+ * Returns a human "what" string, or null if the element is an ordinary
64
+ * building block.
65
+ */
66
+ function behaviorOf(tag, opening, scope, fileDir) {
67
+ const role = attrValueOf(opening, "role");
68
+ if (typeof role === "string" && ROLE_WHAT[role]) return ROLE_WHAT[role];
69
+ if (attrValueOf(opening, "aria-modal") === "true") return "modal / dialog";
70
+ const hasPopup = attrValueOf(opening, "aria-haspopup");
71
+ if (typeof hasPopup === "string" && hasPopup !== "false") return "dropdown / menu trigger";
72
+ if (attrValueOf(opening, "contentEditable") != null) return "contentEditable element (a hand-rolled control)";
73
+ if (attrValueOf(opening, "onClick") != null) return "clickable element (a hand-rolled button)";
74
+ if (attrOf(opening, "aria-expanded")) return "accordion / collapsible";
75
+
76
+ const cls = classNameCandidates(opening, scope, fileDir).join(" ");
77
+ // Merged styles: tailwind className (incl. arbitrary values like z-[1000])
78
+ // + inline style={}. The modal check below runs on this merged map, so a
79
+ // "fixed inset-0 z-[1000]" Tailwind overlay is a modal, not only an inline
80
+ // style={{position:"fixed",zIndex:9999}} one.
81
+ const style = {};
82
+ for (const c of classNameCandidates(opening, scope, fileDir)) {
83
+ Object.assign(style, sig.twToStyles(c));
84
+ }
85
+ for (const [prop, values] of Object.entries(stylePropsOf(opening, scope, fileDir))) {
86
+ const key = sig.camelToKebab(prop);
87
+ style[key] = (style[key] || []).concat(values);
88
+ }
89
+ const styleHas = (prop) => Object.prototype.hasOwnProperty.call(style, prop);
90
+ const styleAny = (prop, re) => (style[prop] || []).some((v) => re.test(String(v)));
91
+
92
+ if (SPINNER_CLASS_RE.test(cls) || styleAny("animation", /spin|rotate/i)) return "spinner / skeleton";
93
+ if (MODAL_CLASS_RE.test(cls)) return "modal / dialog";
94
+ if (
95
+ styleHas("position") &&
96
+ (style.position || []).some((v) => /fixed/.test(String(v))) &&
97
+ (style["z-index"] || []).some((v) => Number(String(v).replace(/[^0-9-]/g, "")) >= 1000)
98
+ ) {
99
+ return "modal / dialog";
100
+ }
101
+ if (CARD_CLASS_RE.test(cls) && (CARD_SECOND_RE.test(cls) || cls.split(/\s+/).filter(Boolean).length >= 3)) {
102
+ return "card / paper";
103
+ }
104
+ // Kebab-case keys: the merged map normalizes inline camelCase to kebab.
105
+ if (styleHas("border-radius") && (styleHas("box-shadow") || styleHas("border"))) return "card / paper";
106
+ // NOTE: there is deliberately NO "layout container" branch. A div with
107
+ // display:flex/grid is the normal way to build a layout — it is not a
108
+ // duplicate of any canonical component, and the canonical Container is a
109
+ // CSS-GRID component, so suggesting it for a flexbox div is actively
110
+ // wrong. Only strong behavior signals (button/modal/card/spinner/...) are
111
+ // reported here.
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Create the error type-3 handler.
117
+ *
118
+ * @param {object} env { context, behaviorTags, catalogComponents, relPath, fileDir }
119
+ * @returns {object} { matchCatalog, handleElement }
120
+ */
121
+ function createHandler(env) {
122
+ const { context, behaviorTags, catalogComponents, relPath, fileDir, registry, inComponentsFolder, componentsFolder } = env;
123
+
124
+ /**
125
+ * Error type 3b: match the element signature against the component catalog
126
+ * (decision matrix). Returns {name, path, level, reason, sim} or null.
127
+ * Skips the entry whose own file is the current file.
128
+ *
129
+ * @param {string} tag the (possibly resolved) element tag
130
+ * @param {object} opening the JSXOpeningElement node
131
+ * @param {object} scope the eslint-scope scope
132
+ * @param {object|null} dynEntry optional rendered-DOM signature (includeDynamic):
133
+ * when present, the candidate is re-tagged to the rendered DOM tag and
134
+ * enriched with the styles/actions/a11y that the library applies at
135
+ * runtime (MUI emotion/sx, antd cssinjs) — invisible to static analysis.
136
+ */
137
+ function matchCatalog(tag, opening, scope, dynEntry) {
138
+ if (!catalogComponents.length) return null;
139
+ const candidate = buildSignature(tag, opening, scope, fileDir);
140
+ if (dynEntry) {
141
+ if (dynEntry.tag) candidate.tag = dynEntry.tag;
142
+ // A plain library use (<MuiButton> with no style/onClick/a11y of its
143
+ // own) is an IMPORT, not a duplicate: the rendered DOM is the
144
+ // library's, so we must not merge its rendered styles into the
145
+ // candidate — that would let a bare <MuiButton> "look like" a
146
+ // hand-rolled Card/Container and produce a false similarComponent.
147
+ // Only a WRAPPER that carries its own static signal (its own styles
148
+ // or an onClick / a11y marker) is a candidate for a duplicate, and
149
+ // for it we merge the rendered styles the library applies.
150
+ const hasOwnSignal =
151
+ Object.keys(candidate.styles).length > 0 ||
152
+ candidate.actions.length > 0 ||
153
+ candidate.a11y.length > 0 ||
154
+ hasLibraryStyleProp(opening);
155
+ if (hasOwnSignal && dynEntry.styles && Object.keys(dynEntry.styles).length && Object.keys(candidate.styles).length < sig.MIN_SHARED_STYLE_KEYS) {
156
+ candidate.styles = sig.mergeStyles(candidate.styles, dynEntry.styles);
157
+ }
158
+ // NOTE: actions/a11y are deliberately NOT merged from the dynamic
159
+ // entry. Behavior (onClick, type, role) is a per-USE static signal:
160
+ // a plain <MuiButton> without onClick must stay a plain import, not
161
+ // become a "duplicate" just because MUI renders <button type=button>.
162
+ // The dynamic catalog contributes only what static analysis cannot
163
+ // see: the real DOM tag + runtime styles.
164
+ }
165
+ // Exclude the current file's OWN catalog entries from the pool BEFORE
166
+ // best-selection. includeDynamic adds the file's own rendered wrappers
167
+ // (e.g. <MuiButton> in _keystone.tsx) to the catalog; a candidate that
168
+ // merges its own rendered styles would match its own entry at sim 1.0,
169
+ // and a post-hoc "own file" drop would then discard the real match
170
+ // against the canonical component. Filtering first lets the canonical
171
+ // entry win the best-selection.
172
+ // When the CURRENT file is a canonical component (inside the component
173
+ // folders), also drop the dynamic app-wrapper entries: a canonical
174
+ // component is the source of truth and must not be reported as a
175
+ // "duplicate" of a hand-rolled app wrapper (e.g. forms Button must not
176
+ // match an app's <MuiButton> plain import).
177
+ const pool = catalogComponents.filter((c) => c.path !== relPath && !(inComponentsFolder && c.__dyn));
178
+ const res = sig.matchSignature(candidate, { components: pool });
179
+ return res;
180
+ }
181
+
182
+ /**
183
+ * Handle a non-raw element (div, span, ul, a, ...).
184
+ * Returns true when a finding was reported.
185
+ *
186
+ * @param {string} tag
187
+ * @param {object} opening the JSXOpeningElement node
188
+ * @param {object} scope the eslint-scope scope of the element
189
+ * @returns {boolean}
190
+ */
191
+ function handleElement(tag, opening, scope) {
192
+ if (!behaviorTags.has(tag)) return false;
193
+ // Error type 3: behavior — reported EVERYWHERE, including inside the
194
+ // component folders. A container hand-rolled in packages/components is
195
+ // still a duplicate of Container: that is what makes the rule total.
196
+ const what = behaviorOf(tag, opening, scope, fileDir);
197
+ if (what) {
198
+ const sug = forBehavior(what, registry, catalogComponents, componentsFolder);
199
+ context.report({
200
+ node: opening,
201
+ messageId: "layoutPrimitive",
202
+ data: { tag, what, component: sug.component, path: sug.path },
203
+ });
204
+ return true;
205
+ }
206
+ return false;
207
+ }
208
+
209
+ return { matchCatalog, handleElement };
210
+ }
211
+
212
+ module.exports = { createHandler, behaviorOf };
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * errorTypes/raw-html.js
5
+ *
6
+ * Error type 2 — RAW HTML (messageId: rawHtml).
7
+ *
8
+ * Raw interactive / form elements (button, label, select, textarea, input,
9
+ * form, table, dialog) are only legal inside the canonical packages
10
+ * (@md/components / @md/sections) — that is where the canonical components
11
+ * are built from them. Outside those packages every raw element is a
12
+ * report: use the canonical component instead.
13
+ *
14
+ * Error type 3 also applies to raw elements: a raw input type=range / select /
15
+ * dialog is a hand-rolled slider / select / modal, and that behavior check
16
+ * runs EVERYWHERE — including inside the component folders. The rawHtml
17
+ * report (error type 2) stays silent there, because the raw element is the
18
+ * building material the canonical component is made of.
19
+ */
20
+
21
+ const { ROLE_WHAT, RAW_BEHAVIOR_TAGS } = require("../config");
22
+ const { attrValueOf } = require("../dom");
23
+ const { forBehavior, forRaw } = require("../suggestions");
24
+
25
+ /**
26
+ * Behavior of a raw element (input type=range, select, dialog,
27
+ * button with onClick, role=...). Returns a "what" string or null.
28
+ */
29
+ function behaviorForRaw(tag, opening) {
30
+ const role = attrValueOf(opening, "role");
31
+ if (typeof role === "string" && ROLE_WHAT[role]) return ROLE_WHAT[role];
32
+ if (tag === "input") {
33
+ const type = attrValueOf(opening, "type");
34
+ if (type === "range") return "slider";
35
+ if (type === "checkbox") return "checkbox (a hand-rolled control)";
36
+ if (type === "radio") return "radio (a hand-rolled control)";
37
+ }
38
+ if (tag === "dialog") return "modal / dialog";
39
+ if (tag === "select") return "select (a hand-rolled control)";
40
+ if (tag === "button" && attrValueOf(opening, "onClick") != null) return "clickable element (a hand-rolled button)";
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Create the error type-2 handler.
46
+ *
47
+ * @param {object} env { context, rawElements, inComponentsFolder }
48
+ * @returns {function} handleRaw(tag, opening) -> boolean (was a finding reported?)
49
+ */
50
+ function createHandler(env) {
51
+ const { context, rawElements, inComponentsFolder, registry, catalogComponents, componentsFolder } = env;
52
+
53
+ /**
54
+ * Handle a raw element (button, input, select, ...).
55
+ * Returns true when a finding was reported.
56
+ *
57
+ * @param {string} tag
58
+ * @param {object} opening the JSXOpeningElement node
59
+ * @returns {boolean}
60
+ */
61
+ function handleRaw(tag, opening) {
62
+ if (!rawElements.has(tag)) return false;
63
+ let reported = false;
64
+ // Error type 2: raw HTML is only legal inside the component folders
65
+ // (it is the building material the canonical components are made of).
66
+ if (!inComponentsFolder) {
67
+ const sug = forRaw(tag, attrValueOf(opening, "type"), registry, catalogComponents, componentsFolder);
68
+ context.report({
69
+ node: opening,
70
+ messageId: "rawHtml",
71
+ data: { tag, component: sug.component, path: sug.path },
72
+ });
73
+ reported = true;
74
+ }
75
+ // Error type 3 also applies to raw elements: a raw input type=range /
76
+ // select / dialog is a hand-rolled slider / select / modal. Runs
77
+ // EVERYWHERE — a hand-rolled slider in packages/components is still a
78
+ // duplicate of Slider.
79
+ if (RAW_BEHAVIOR_TAGS.has(tag)) {
80
+ const what = behaviorForRaw(tag, opening);
81
+ if (what) {
82
+ const sug = forBehavior(what, registry, catalogComponents, componentsFolder);
83
+ context.report({
84
+ node: opening,
85
+ messageId: "layoutPrimitive",
86
+ data: { tag, what, component: sug.component, path: sug.path },
87
+ });
88
+ reported = true;
89
+ }
90
+ }
91
+ return reported;
92
+ }
93
+
94
+ return { handleRaw };
95
+ }
96
+
97
+ module.exports = { createHandler, behaviorForRaw };
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * errorTypes/styled-in-app.js
5
+ *
6
+ * Error type 4 — STYLED IN APP (messageId: styledInApp).
7
+ *
8
+ * styled.div / styled.button / ... created outside the component folders is
9
+ * UI built in the app — reported, whatever the tag. Inside the component
10
+ * folders styled.* is the building material the canonical components are
11
+ * made of and is legal.
12
+ */
13
+
14
+ /**
15
+ * Create the error type-4 visitors.
16
+ *
17
+ * @param {object} env { context, inComponentsFolder }
18
+ * @returns {object} visitors: VariableDeclarator
19
+ */
20
+ const sig = require("../signature");
21
+
22
+ function createHandler(env) {
23
+ const { context, inComponentsFolder, catalogComponents, componentsFolder } = env;
24
+
25
+ /** Fallback path: the first canonical folder, or the generic phrase. */
26
+ const defaultPath = componentsFolder && componentsFolder.length ? componentsFolder[0] : "the canonical component packages";
27
+
28
+ /**
29
+ * Collect the CSS text of a styled template (no-substitution literal, or a
30
+ * template expression with interpolations — only the literal parts).
31
+ */
32
+ function cssTextOf(tpl) {
33
+ if (!tpl) return "";
34
+ if (tpl.type === "NoSubstitutionTemplate") return tpl.value.cooked || tpl.value.raw || "";
35
+ if (tpl.type === "TemplateLiteral") {
36
+ return (tpl.quasis || []).map((q) => q.value.cooked || q.value.raw || "").join("");
37
+ }
38
+ return "";
39
+ }
40
+
41
+ /**
42
+ * Error type 4: styled.<htmlTag> created outside the component folders is
43
+ * UI built in the app — reported, whatever the tag. The suggestion is the
44
+ * catalog component whose signature matches the styled template, if any.
45
+ */
46
+ function checkStyledInApp(node) {
47
+ if (inComponentsFolder) return;
48
+ const init = node.init;
49
+ if (!init || init.type !== "TaggedTemplateExpression") return;
50
+ const tagExpr = init.tag;
51
+ // styled.div`...` (MemberExpression) and styled(MuiButton)`...`
52
+ // (CallExpression with an identifier argument — a wrapper built on a
53
+ // LIBRARY component, e.g. MUI/antd). Both are app-built UI outside the
54
+ // component folders.
55
+ let tag = null;
56
+ if (tagExpr.type === "MemberExpression" && tagExpr.property && tagExpr.property.name) {
57
+ tag = tagExpr.property.name;
58
+ } else if (tagExpr.type === "CallExpression" && tagExpr.arguments.length) {
59
+ const arg0 = tagExpr.arguments[0];
60
+ if (arg0.type === "Identifier") tag = arg0.name;
61
+ else if (arg0.type === "Literal" && typeof arg0.value === "string") tag = arg0.value;
62
+ }
63
+ if (!tag) return;
64
+
65
+ let component = "CanonicalComponent";
66
+ let path = defaultPath;
67
+ const cssText = cssTextOf(init.template);
68
+ const styles = sig.cssTextToStyles(cssText);
69
+ if (Object.keys(styles).length && catalogComponents.length) {
70
+ const hit = sig.matchSignature(
71
+ { tag, styles, actions: [], a11y: [], data: [] },
72
+ { components: catalogComponents },
73
+ );
74
+ if (hit) {
75
+ component = hit.name;
76
+ path = hit.path;
77
+ }
78
+ }
79
+ context.report({
80
+ node: node.id,
81
+ messageId: "styledInApp",
82
+ data: { tag, component, path },
83
+ });
84
+ }
85
+
86
+ return {
87
+ VariableDeclarator(node) {
88
+ checkStyledInApp(node);
89
+ },
90
+ };
91
+ }
92
+
93
+ module.exports = { createHandler };