uilint 0.2.1 → 0.2.4

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,253 @@
1
+ /**
2
+ * Rule: {rule-name}
3
+ *
4
+ * {Detailed description of what this rule enforces and why.
5
+ * Include examples of patterns this catches and the preferred alternatives.}
6
+ *
7
+ * Examples:
8
+ * - Bad: <button>Click</button>
9
+ * - Good: <Button>Click</Button> (from @/components/ui/button)
10
+ */
11
+
12
+ import { createRule } from "../utils/create-rule.js";
13
+ import type { TSESTree } from "@typescript-eslint/utils";
14
+
15
+ // Define all possible error message IDs as a union type
16
+ type MessageIds = "preferComponent" | "missingImport";
17
+
18
+ // Define the options schema as a tuple type
19
+ // The ? makes the entire options object optional
20
+ type Options = [
21
+ {
22
+ /** The preferred component to use */
23
+ preferred?: string;
24
+ /** Import source for the preferred component */
25
+ importSource?: string;
26
+ /** HTML elements to check (e.g., ["button", "input"]) */
27
+ elements?: string[];
28
+ /** Files/directories to ignore (glob patterns) */
29
+ ignore?: string[];
30
+ }?
31
+ ];
32
+
33
+ // Track component state during file traversal
34
+ interface ComponentUsage {
35
+ node: TSESTree.JSXOpeningElement;
36
+ elementName: string;
37
+ hasPreferredImport: boolean;
38
+ }
39
+
40
+ export default createRule<Options, MessageIds>({
41
+ name: "{rule-name}",
42
+ meta: {
43
+ // "problem" = likely bug, "suggestion" = code improvement, "layout" = formatting
44
+ type: "suggestion",
45
+ docs: {
46
+ description: "{Short description for docs}",
47
+ },
48
+ // Define all error messages with placeholders using {{name}} syntax
49
+ messages: {
50
+ preferComponent:
51
+ "Use <{{preferred}}> from '{{source}}' instead of native <{{element}}>.",
52
+ missingImport:
53
+ "Import {{component}} from '{{source}}' to use the design system component.",
54
+ },
55
+ // JSON Schema for validating options
56
+ schema: [
57
+ {
58
+ type: "object",
59
+ properties: {
60
+ preferred: {
61
+ type: "string",
62
+ description: "The preferred component name to use",
63
+ },
64
+ importSource: {
65
+ type: "string",
66
+ description: "The import path for the preferred component",
67
+ },
68
+ elements: {
69
+ type: "array",
70
+ items: { type: "string" },
71
+ description: "HTML elements to check",
72
+ },
73
+ ignore: {
74
+ type: "array",
75
+ items: { type: "string" },
76
+ description: "Glob patterns for files to ignore",
77
+ },
78
+ },
79
+ additionalProperties: false,
80
+ },
81
+ ],
82
+ },
83
+ // Default options - these are merged with user options
84
+ defaultOptions: [
85
+ {
86
+ preferred: "Button",
87
+ importSource: "@/components/ui/button",
88
+ elements: ["button"],
89
+ ignore: [],
90
+ },
91
+ ],
92
+ // The create function receives context and returns AST visitor methods
93
+ create(context) {
94
+ // Get options with defaults
95
+ const options = context.options[0] || {};
96
+ const preferred = options.preferred ?? "Button";
97
+ const importSource = options.importSource ?? "@/components/ui/button";
98
+ const elements = new Set(options.elements ?? ["button"]);
99
+ const ignorePatterns = options.ignore ?? [];
100
+
101
+ // Track state during file traversal
102
+ const imports = new Map<string, string>(); // localName -> source
103
+ const usages: ComponentUsage[] = [];
104
+
105
+ // Check if file should be ignored
106
+ const filename = context.filename || context.getFilename?.() || "";
107
+ if (ignorePatterns.some((pattern) => filename.includes(pattern))) {
108
+ return {}; // Skip this file entirely
109
+ }
110
+
111
+ return {
112
+ // ============================================
113
+ // IMPORT TRACKING
114
+ // ============================================
115
+ ImportDeclaration(node) {
116
+ const source = node.source.value as string;
117
+
118
+ for (const spec of node.specifiers) {
119
+ switch (spec.type) {
120
+ case "ImportSpecifier":
121
+ // import { Button } from "..."
122
+ // import { Button as Btn } from "..."
123
+ imports.set(spec.local.name, source);
124
+ break;
125
+ case "ImportDefaultSpecifier":
126
+ // import Button from "..."
127
+ imports.set(spec.local.name, source);
128
+ break;
129
+ case "ImportNamespaceSpecifier":
130
+ // import * as UI from "..."
131
+ imports.set(spec.local.name, source);
132
+ break;
133
+ }
134
+ }
135
+ },
136
+
137
+ // ============================================
138
+ // JSX ELEMENT ANALYSIS
139
+ // ============================================
140
+ JSXOpeningElement(node) {
141
+ // Handle simple elements: <button>
142
+ if (node.name.type === "JSXIdentifier") {
143
+ const name = node.name.name;
144
+
145
+ // Only check lowercase (HTML) elements in our target list
146
+ if (elements.has(name)) {
147
+ // Check if the preferred component is imported
148
+ const hasPreferredImport =
149
+ imports.has(preferred) &&
150
+ imports.get(preferred) === importSource;
151
+
152
+ usages.push({
153
+ node,
154
+ elementName: name,
155
+ hasPreferredImport,
156
+ });
157
+ }
158
+ }
159
+
160
+ // Handle member expressions: <UI.Button>
161
+ if (node.name.type === "JSXMemberExpression") {
162
+ // Get the root object (e.g., "UI" from UI.Button)
163
+ let current = node.name.object;
164
+ while (current.type === "JSXMemberExpression") {
165
+ current = current.object;
166
+ }
167
+
168
+ if (current.type === "JSXIdentifier") {
169
+ // You can check namespace imports here if needed
170
+ }
171
+ }
172
+ },
173
+
174
+ // ============================================
175
+ // FINAL ANALYSIS (after entire file parsed)
176
+ // ============================================
177
+ "Program:exit"() {
178
+ for (const usage of usages) {
179
+ // Report the violation
180
+ context.report({
181
+ node: usage.node,
182
+ messageId: "preferComponent",
183
+ data: {
184
+ preferred,
185
+ source: importSource,
186
+ element: usage.elementName,
187
+ },
188
+ });
189
+ }
190
+ },
191
+ };
192
+ },
193
+ });
194
+
195
+ // ============================================
196
+ // UTILITY FUNCTIONS (if needed)
197
+ // ============================================
198
+
199
+ /**
200
+ * Check if a name follows PascalCase (component naming convention)
201
+ */
202
+ function isPascalCase(name: string): boolean {
203
+ return /^[A-Z][a-zA-Z0-9]*$/.test(name);
204
+ }
205
+
206
+ /**
207
+ * Check if a name is a React hook (starts with "use")
208
+ */
209
+ function isHookName(name: string): boolean {
210
+ return /^use[A-Z]/.test(name);
211
+ }
212
+
213
+ /**
214
+ * Get the name from various function declaration patterns
215
+ */
216
+ function getFunctionName(
217
+ node:
218
+ | TSESTree.FunctionDeclaration
219
+ | TSESTree.FunctionExpression
220
+ | TSESTree.ArrowFunctionExpression
221
+ ): string | null {
222
+ // Function declaration: function Foo() {}
223
+ if (node.type === "FunctionDeclaration" && node.id) {
224
+ return node.id.name;
225
+ }
226
+
227
+ // Variable declarator: const Foo = () => {}
228
+ const parent = node.parent;
229
+ if (
230
+ parent?.type === "VariableDeclarator" &&
231
+ parent.id.type === "Identifier"
232
+ ) {
233
+ return parent.id.name;
234
+ }
235
+
236
+ // forwardRef/memo wrapper: const Foo = forwardRef(() => {})
237
+ if (parent?.type === "CallExpression") {
238
+ const callParent = parent.parent;
239
+ if (
240
+ callParent?.type === "VariableDeclarator" &&
241
+ callParent.id.type === "Identifier"
242
+ ) {
243
+ return callParent.id.name;
244
+ }
245
+ }
246
+
247
+ // Named function expression: const x = function Foo() {}
248
+ if (node.type === "FunctionExpression" && node.id) {
249
+ return node.id.name;
250
+ }
251
+
252
+ return null;
253
+ }