uilint 0.2.1 → 0.2.3
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/dist/index.js +2155 -253
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/skills/ui-consistency-enforcer/SKILL.md +445 -0
- package/skills/ui-consistency-enforcer/references/REGISTRY-ENTRY.md +163 -0
- package/skills/ui-consistency-enforcer/references/RULE-TEMPLATE.ts +253 -0
- package/skills/ui-consistency-enforcer/references/TEST-TEMPLATE.ts +496 -0
|
@@ -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
|
+
}
|