@codyswann/lisa 1.50.0 → 1.50.2
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/node_modules/@codyswann/eslint-plugin-code-organization/README.md +149 -0
- package/node_modules/@codyswann/eslint-plugin-code-organization/__tests__/enforce-statement-order.test.js +473 -0
- package/node_modules/@codyswann/eslint-plugin-code-organization/index.js +28 -0
- package/node_modules/@codyswann/eslint-plugin-code-organization/package.json +10 -0
- package/node_modules/@codyswann/eslint-plugin-code-organization/rules/enforce-statement-order.js +162 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/README.md +234 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/__tests__/plugin-index.test.js +89 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/__tests__/require-memo-in-view.test.js +201 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/__tests__/single-component-per-file.test.js +294 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/index.js +37 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/package.json +10 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/rules/enforce-component-structure.js +235 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/rules/no-return-in-view.js +96 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/rules/require-memo-in-view.js +183 -0
- package/node_modules/@codyswann/eslint-plugin-component-structure/rules/single-component-per-file.js +243 -0
- package/node_modules/@codyswann/eslint-plugin-ui-standards/README.md +192 -0
- package/node_modules/@codyswann/eslint-plugin-ui-standards/index.js +31 -0
- package/node_modules/@codyswann/eslint-plugin-ui-standards/package.json +10 -0
- package/node_modules/@codyswann/eslint-plugin-ui-standards/rules/no-classname-outside-ui.js +56 -0
- package/node_modules/@codyswann/eslint-plugin-ui-standards/rules/no-direct-rn-imports.js +60 -0
- package/package.json +6 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is managed by Lisa.
|
|
3
|
+
* Do not edit directly — changes will be overwritten on the next `lisa` run.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Unit tests for the single-component-per-file ESLint rule
|
|
8
|
+
*
|
|
9
|
+
* Tests that View and Container files contain exactly one React component.
|
|
10
|
+
* Ensures components/ui/** and components/shared/** directories are excluded from the rule.
|
|
11
|
+
* @module eslint-plugin-component-structure/tests
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { RuleTester } = require("eslint");
|
|
15
|
+
|
|
16
|
+
const rule = require("../rules/single-component-per-file");
|
|
17
|
+
|
|
18
|
+
const FEATURE_EXAMPLE_COMPONENTS_PATH = "/features/example/components";
|
|
19
|
+
const SHARED_COMPONENTS_PATH = "/components/shared";
|
|
20
|
+
const UI_COMPONENTS_PATH = "/components/ui";
|
|
21
|
+
|
|
22
|
+
const ruleTester = new RuleTester({
|
|
23
|
+
languageOptions: {
|
|
24
|
+
ecmaVersion: 2020,
|
|
25
|
+
sourceType: "module",
|
|
26
|
+
parserOptions: {
|
|
27
|
+
ecmaFeatures: {
|
|
28
|
+
jsx: true,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
ruleTester.run("single-component-per-file", rule, {
|
|
35
|
+
valid: [
|
|
36
|
+
// 1. Single component - arrow function
|
|
37
|
+
{
|
|
38
|
+
code: `
|
|
39
|
+
const MyView = () => <div>Hello</div>;
|
|
40
|
+
MyView.displayName = "MyView";
|
|
41
|
+
export default MyView;
|
|
42
|
+
`,
|
|
43
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
44
|
+
},
|
|
45
|
+
// 2. Single component - memo wrapped
|
|
46
|
+
{
|
|
47
|
+
code: `
|
|
48
|
+
import { memo } from "react";
|
|
49
|
+
const MyView = memo(() => <div>Hello</div>);
|
|
50
|
+
MyView.displayName = "MyView";
|
|
51
|
+
export default MyView;
|
|
52
|
+
`,
|
|
53
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
54
|
+
},
|
|
55
|
+
// 3. Single component - React.memo wrapped
|
|
56
|
+
{
|
|
57
|
+
code: `
|
|
58
|
+
import React from "react";
|
|
59
|
+
const MyView = React.memo(() => <div>Hello</div>);
|
|
60
|
+
MyView.displayName = "MyView";
|
|
61
|
+
export default MyView;
|
|
62
|
+
`,
|
|
63
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
64
|
+
},
|
|
65
|
+
// 4. Single component - function declaration
|
|
66
|
+
{
|
|
67
|
+
code: `
|
|
68
|
+
function MyView() {
|
|
69
|
+
return <div>Hello</div>;
|
|
70
|
+
}
|
|
71
|
+
export default MyView;
|
|
72
|
+
`,
|
|
73
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
74
|
+
},
|
|
75
|
+
// 5. Single component - TypeScript React.FC (parser will handle TypeScript syntax)
|
|
76
|
+
{
|
|
77
|
+
code: `
|
|
78
|
+
const MyView: React.FC = () => <div>Hello</div>;
|
|
79
|
+
MyView.displayName = "MyView";
|
|
80
|
+
export default MyView;
|
|
81
|
+
`,
|
|
82
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
83
|
+
languageOptions: {
|
|
84
|
+
parser: require("@typescript-eslint/parser"),
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
// 6. Single component in Container file
|
|
88
|
+
{
|
|
89
|
+
code: `
|
|
90
|
+
const MyContainer = () => {
|
|
91
|
+
return <div>Hello</div>;
|
|
92
|
+
};
|
|
93
|
+
export default MyContainer;
|
|
94
|
+
`,
|
|
95
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyContainer.tsx`,
|
|
96
|
+
},
|
|
97
|
+
// 7. PascalCase function that doesn't return JSX (not a component)
|
|
98
|
+
{
|
|
99
|
+
code: `
|
|
100
|
+
const MyView = () => <div>Hello</div>;
|
|
101
|
+
const HelperFunction = () => {
|
|
102
|
+
return "not JSX";
|
|
103
|
+
};
|
|
104
|
+
export default MyView;
|
|
105
|
+
`,
|
|
106
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
107
|
+
},
|
|
108
|
+
// 8. Non-View/Container file (rule should not apply)
|
|
109
|
+
{
|
|
110
|
+
code: `
|
|
111
|
+
const Component1 = () => <div>1</div>;
|
|
112
|
+
const Component2 = () => <div>2</div>;
|
|
113
|
+
export { Component1, Component2 };
|
|
114
|
+
`,
|
|
115
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/utils.tsx`,
|
|
116
|
+
},
|
|
117
|
+
// 9. Excluded directory - UI components
|
|
118
|
+
{
|
|
119
|
+
code: `
|
|
120
|
+
const Component1 = () => <div>1</div>;
|
|
121
|
+
const Component2 = () => <div>2</div>;
|
|
122
|
+
export { Component1, Component2 };
|
|
123
|
+
`,
|
|
124
|
+
filename: `${UI_COMPONENTS_PATH}/MyView.tsx`,
|
|
125
|
+
},
|
|
126
|
+
// 10. Excluded directory - Shared components
|
|
127
|
+
{
|
|
128
|
+
code: `
|
|
129
|
+
const Component1 = () => <div>1</div>;
|
|
130
|
+
const Component2 = () => <div>2</div>;
|
|
131
|
+
export { Component1, Component2 };
|
|
132
|
+
`,
|
|
133
|
+
filename: `${SHARED_COMPONENTS_PATH}/MyView.tsx`,
|
|
134
|
+
},
|
|
135
|
+
// 11. Single component - conditional expression
|
|
136
|
+
{
|
|
137
|
+
code: `
|
|
138
|
+
const MyView = ({ show }) => show ? <div>Visible</div> : <div>Hidden</div>;
|
|
139
|
+
export default MyView;
|
|
140
|
+
`,
|
|
141
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
142
|
+
},
|
|
143
|
+
// 12. Single component - logical expression
|
|
144
|
+
{
|
|
145
|
+
code: `
|
|
146
|
+
const MyView = ({ show }) => show && <div>Content</div>;
|
|
147
|
+
export default MyView;
|
|
148
|
+
`,
|
|
149
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
invalid: [
|
|
153
|
+
// 1. Two arrow function components
|
|
154
|
+
{
|
|
155
|
+
code: `
|
|
156
|
+
const Component1 = () => <div>First</div>;
|
|
157
|
+
const Component2 = () => <div>Second</div>;
|
|
158
|
+
export default Component1;
|
|
159
|
+
`,
|
|
160
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
161
|
+
errors: [
|
|
162
|
+
{
|
|
163
|
+
messageId: "multipleComponents",
|
|
164
|
+
data: {
|
|
165
|
+
componentName: "Component2",
|
|
166
|
+
firstComponentName: "Component1",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
// 2. Two memo-wrapped components
|
|
172
|
+
{
|
|
173
|
+
code: `
|
|
174
|
+
import { memo } from "react";
|
|
175
|
+
const Component1 = memo(() => <div>First</div>);
|
|
176
|
+
const Component2 = memo(() => <div>Second</div>);
|
|
177
|
+
export default Component1;
|
|
178
|
+
`,
|
|
179
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
180
|
+
errors: [
|
|
181
|
+
{
|
|
182
|
+
messageId: "multipleComponents",
|
|
183
|
+
data: {
|
|
184
|
+
componentName: "Component2",
|
|
185
|
+
firstComponentName: "Component1",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
// 3. Main component and helper component (realistic violation)
|
|
191
|
+
{
|
|
192
|
+
code: `
|
|
193
|
+
import React from "react";
|
|
194
|
+
const MessageItem = ({ item }) => <div>{item}</div>;
|
|
195
|
+
const MessageListView = ({ messages }) => (
|
|
196
|
+
<div>{messages.map(msg => <MessageItem item={msg} />)}</div>
|
|
197
|
+
);
|
|
198
|
+
export default MessageListView;
|
|
199
|
+
`,
|
|
200
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MessageListView.tsx`,
|
|
201
|
+
errors: [
|
|
202
|
+
{
|
|
203
|
+
messageId: "multipleComponents",
|
|
204
|
+
data: {
|
|
205
|
+
componentName: "MessageListView",
|
|
206
|
+
firstComponentName: "MessageItem",
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
// 4. Function declaration and arrow function
|
|
212
|
+
{
|
|
213
|
+
code: `
|
|
214
|
+
function Component1() {
|
|
215
|
+
return <div>First</div>;
|
|
216
|
+
}
|
|
217
|
+
const Component2 = () => <div>Second</div>;
|
|
218
|
+
export default Component1;
|
|
219
|
+
`,
|
|
220
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
221
|
+
errors: [
|
|
222
|
+
{
|
|
223
|
+
messageId: "multipleComponents",
|
|
224
|
+
data: {
|
|
225
|
+
componentName: "Component2",
|
|
226
|
+
firstComponentName: "Component1",
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
// 5. Three components (multiple violations)
|
|
232
|
+
{
|
|
233
|
+
code: `
|
|
234
|
+
const Component1 = () => <div>First</div>;
|
|
235
|
+
const Component2 = () => <div>Second</div>;
|
|
236
|
+
const Component3 = () => <div>Third</div>;
|
|
237
|
+
export default Component1;
|
|
238
|
+
`,
|
|
239
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
240
|
+
errors: [
|
|
241
|
+
{
|
|
242
|
+
messageId: "multipleComponents",
|
|
243
|
+
data: {
|
|
244
|
+
componentName: "Component2",
|
|
245
|
+
firstComponentName: "Component1",
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
messageId: "multipleComponents",
|
|
250
|
+
data: {
|
|
251
|
+
componentName: "Component3",
|
|
252
|
+
firstComponentName: "Component1",
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
// 6. Container file with multiple components
|
|
258
|
+
{
|
|
259
|
+
code: `
|
|
260
|
+
const Helper = () => <div>Helper</div>;
|
|
261
|
+
const MyContainer = () => <div><Helper /></div>;
|
|
262
|
+
export default MyContainer;
|
|
263
|
+
`,
|
|
264
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyContainer.tsx`,
|
|
265
|
+
errors: [
|
|
266
|
+
{
|
|
267
|
+
messageId: "multipleComponents",
|
|
268
|
+
data: {
|
|
269
|
+
componentName: "MyContainer",
|
|
270
|
+
firstComponentName: "Helper",
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
// 7. Multiple components with conditional expressions
|
|
276
|
+
{
|
|
277
|
+
code: `
|
|
278
|
+
const Component1 = ({ show }) => show ? <div>First</div> : null;
|
|
279
|
+
const Component2 = ({ show }) => show && <div>Second</div>;
|
|
280
|
+
export default Component1;
|
|
281
|
+
`,
|
|
282
|
+
filename: `${FEATURE_EXAMPLE_COMPONENTS_PATH}/MyView.tsx`,
|
|
283
|
+
errors: [
|
|
284
|
+
{
|
|
285
|
+
messageId: "multipleComponents",
|
|
286
|
+
data: {
|
|
287
|
+
componentName: "Component2",
|
|
288
|
+
firstComponentName: "Component1",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is managed by Lisa.
|
|
3
|
+
* Do not edit directly — changes will be overwritten on the next `lisa` run.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ESLint plugin for component structure standards
|
|
8
|
+
*
|
|
9
|
+
* This plugin enforces component structure and patterns for React components
|
|
10
|
+
* in the frontend application. Supports ESLint 9 flat config format.
|
|
11
|
+
*
|
|
12
|
+
* Rules:
|
|
13
|
+
* - enforce-component-structure: Ensures components follow the Container/View pattern
|
|
14
|
+
* - no-return-in-view: Disallows return statements in View components
|
|
15
|
+
* - require-memo-in-view: Enforces React.memo usage in View components
|
|
16
|
+
* - single-component-per-file: Ensures only one React component per file
|
|
17
|
+
* @module eslint-plugin-component-structure
|
|
18
|
+
*/
|
|
19
|
+
const enforceComponentStructure = require("./rules/enforce-component-structure");
|
|
20
|
+
const noReturnInView = require("./rules/no-return-in-view");
|
|
21
|
+
const requireMemoInView = require("./rules/require-memo-in-view");
|
|
22
|
+
const singleComponentPerFile = require("./rules/single-component-per-file");
|
|
23
|
+
|
|
24
|
+
const plugin = {
|
|
25
|
+
meta: {
|
|
26
|
+
name: "eslint-plugin-component-structure",
|
|
27
|
+
version: "1.0.0",
|
|
28
|
+
},
|
|
29
|
+
rules: {
|
|
30
|
+
"enforce-component-structure": enforceComponentStructure,
|
|
31
|
+
"no-return-in-view": noReturnInView,
|
|
32
|
+
"require-memo-in-view": requireMemoInView,
|
|
33
|
+
"single-component-per-file": singleComponentPerFile,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
module.exports = plugin;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codyswann/eslint-plugin-component-structure",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ESLint plugin for component structure standards",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"publishConfig": { "access": "public" },
|
|
7
|
+
"peerDependencies": {
|
|
8
|
+
"eslint": ">=9.0.0"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is managed by Lisa.
|
|
3
|
+
* Do not edit directly — changes will be overwritten on the next `lisa` run.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
meta: {
|
|
11
|
+
type: "problem",
|
|
12
|
+
docs: {
|
|
13
|
+
description:
|
|
14
|
+
"Enforce component structure in features/**/components directories",
|
|
15
|
+
category: "Best Practices",
|
|
16
|
+
recommended: true,
|
|
17
|
+
},
|
|
18
|
+
fixable: null,
|
|
19
|
+
schema: [],
|
|
20
|
+
messages: {
|
|
21
|
+
missingContainer:
|
|
22
|
+
'Component directory "{{componentName}}" is missing {{componentName}}Container.tsx file',
|
|
23
|
+
missingView:
|
|
24
|
+
'Component directory "{{componentName}}" is missing {{componentName}}View.tsx file',
|
|
25
|
+
missingIndex:
|
|
26
|
+
'Component directory "{{componentName}}" is missing index.tsx file',
|
|
27
|
+
incorrectIndexExport:
|
|
28
|
+
"index.tsx should export {{componentName}}Container or {{componentName}}View as default",
|
|
29
|
+
componentNotInDirectory:
|
|
30
|
+
"Component files must be inside a directory named after the component",
|
|
31
|
+
incorrectFileNaming: "{{fileName}} should be named {{expectedName}}",
|
|
32
|
+
invalidFileInComponentDirectory:
|
|
33
|
+
"Only index.ts(x), {{componentName}}Container.tsx, and {{componentName}}View.tsx are allowed in component directories. Found: {{fileName}}",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
create(context) {
|
|
38
|
+
const filename = context.getFilename();
|
|
39
|
+
const normalizedPath = filename.replace(/\\/g, "/");
|
|
40
|
+
|
|
41
|
+
// Get the path after components/ or screens/
|
|
42
|
+
const componentsMatch = normalizedPath.match(
|
|
43
|
+
/\/(components|screens)\/(.+)$/
|
|
44
|
+
);
|
|
45
|
+
if (!componentsMatch) return {};
|
|
46
|
+
const afterComponents = componentsMatch[2];
|
|
47
|
+
|
|
48
|
+
const pathParts = afterComponents.split("/");
|
|
49
|
+
|
|
50
|
+
// If file is directly in components/ directory (not in a subdirectory)
|
|
51
|
+
if (pathParts.length === 1) {
|
|
52
|
+
const fileName = pathParts[0];
|
|
53
|
+
if (fileName.endsWith(".tsx") || fileName.endsWith(".jsx")) {
|
|
54
|
+
context.report({
|
|
55
|
+
node: context.getSourceCode().ast,
|
|
56
|
+
messageId: "componentNotInDirectory",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Get component name and file name from the END of the path
|
|
63
|
+
// This handles both ComponentName/file.tsx and custom/ui/ComponentName/file.tsx
|
|
64
|
+
const fileName = pathParts[pathParts.length - 1];
|
|
65
|
+
const componentName = pathParts[pathParts.length - 2];
|
|
66
|
+
|
|
67
|
+
// Skip validation for files in __tests__ directories
|
|
68
|
+
if (componentName === "__tests__") {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Only check .ts/.tsx/.jsx files
|
|
73
|
+
if (
|
|
74
|
+
!fileName ||
|
|
75
|
+
(!fileName.endsWith(".ts") &&
|
|
76
|
+
!fileName.endsWith(".tsx") &&
|
|
77
|
+
!fileName.endsWith(".jsx"))
|
|
78
|
+
) {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Get the directory path
|
|
83
|
+
const dirPath = path.dirname(filename);
|
|
84
|
+
|
|
85
|
+
// Check if file is one of the allowed types
|
|
86
|
+
const isIndex =
|
|
87
|
+
fileName === "index.ts" ||
|
|
88
|
+
fileName === "index.tsx" ||
|
|
89
|
+
fileName === "index.jsx";
|
|
90
|
+
|
|
91
|
+
// Allow *Container.*.tsx and *Container.*.jsx patterns (e.g., MyComponentContainer.native.tsx)
|
|
92
|
+
const containerPattern = new RegExp(
|
|
93
|
+
`^${componentName}Container\\.[^.]+\\.(tsx|jsx)$`
|
|
94
|
+
);
|
|
95
|
+
const isContainer =
|
|
96
|
+
fileName === `${componentName}Container.tsx` ||
|
|
97
|
+
fileName === `${componentName}Container.jsx` ||
|
|
98
|
+
containerPattern.test(fileName);
|
|
99
|
+
|
|
100
|
+
// Allow *View.*.tsx and *View.*.jsx patterns (e.g., MyComponentView.web.tsx)
|
|
101
|
+
const viewPattern = new RegExp(
|
|
102
|
+
`^${componentName}View\\.[^.]+\\.(tsx|jsx)$`
|
|
103
|
+
);
|
|
104
|
+
const isView =
|
|
105
|
+
fileName === `${componentName}View.tsx` ||
|
|
106
|
+
fileName === `${componentName}View.jsx` ||
|
|
107
|
+
viewPattern.test(fileName);
|
|
108
|
+
|
|
109
|
+
// Report error if file is not one of the allowed types
|
|
110
|
+
if (!isIndex && !isContainer && !isView) {
|
|
111
|
+
context.report({
|
|
112
|
+
node: context.getSourceCode().ast,
|
|
113
|
+
messageId: "invalidFileInComponentDirectory",
|
|
114
|
+
data: { fileName, componentName },
|
|
115
|
+
});
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check file naming
|
|
120
|
+
if (
|
|
121
|
+
fileName === "index.ts" ||
|
|
122
|
+
fileName === "index.tsx" ||
|
|
123
|
+
fileName === "index.jsx"
|
|
124
|
+
) {
|
|
125
|
+
// Check if index.tsx exports the Container
|
|
126
|
+
return {
|
|
127
|
+
Program(node) {
|
|
128
|
+
const sourceCode = context.getSourceCode();
|
|
129
|
+
const text = sourceCode.getText();
|
|
130
|
+
|
|
131
|
+
// Check for export patterns (allow Container or View)
|
|
132
|
+
const defaultExportPattern = new RegExp(
|
|
133
|
+
`export\\s*{\\s*default\\s*}\\s*from\\s*['"\`]\\.\\/${componentName}(Container|View)['"\`]|` +
|
|
134
|
+
`export\\s*\\*\\s*from\\s*['"\`]\\.\\/${componentName}(Container|View)['"\`]|` +
|
|
135
|
+
`export\\s*{\\s*${componentName}(Container|View)\\s*as\\s*default\\s*}|` +
|
|
136
|
+
`export\\s*default\\s*${componentName}(Container|View)`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (!defaultExportPattern.test(text)) {
|
|
140
|
+
context.report({
|
|
141
|
+
node,
|
|
142
|
+
messageId: "incorrectIndexExport",
|
|
143
|
+
data: { componentName },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
} else if (
|
|
149
|
+
fileName.endsWith("Container.tsx") ||
|
|
150
|
+
fileName.endsWith("Container.jsx")
|
|
151
|
+
) {
|
|
152
|
+
const expectedName = `${componentName}Container.tsx`;
|
|
153
|
+
if (
|
|
154
|
+
fileName !== expectedName &&
|
|
155
|
+
fileName !== `${componentName}Container.jsx`
|
|
156
|
+
) {
|
|
157
|
+
context.report({
|
|
158
|
+
node: context.getSourceCode().ast,
|
|
159
|
+
messageId: "incorrectFileNaming",
|
|
160
|
+
data: { fileName, expectedName },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
} else if (fileName.endsWith("View.tsx") || fileName.endsWith("View.jsx")) {
|
|
164
|
+
const expectedName = `${componentName}View.tsx`;
|
|
165
|
+
if (
|
|
166
|
+
fileName !== expectedName &&
|
|
167
|
+
fileName !== `${componentName}View.jsx`
|
|
168
|
+
) {
|
|
169
|
+
context.report({
|
|
170
|
+
node: context.getSourceCode().ast,
|
|
171
|
+
messageId: "incorrectFileNaming",
|
|
172
|
+
data: { fileName, expectedName },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check for required files in the directory (only once per directory)
|
|
178
|
+
// We'll do this check only for the first file we encounter
|
|
179
|
+
const cache = new Map();
|
|
180
|
+
const checkRequiredFiles = () => {
|
|
181
|
+
try {
|
|
182
|
+
const files = cache.has(dirPath)
|
|
183
|
+
? cache.get(dirPath)
|
|
184
|
+
: (() => {
|
|
185
|
+
const dirFiles = fs.readdirSync(dirPath);
|
|
186
|
+
cache.set(dirPath, dirFiles);
|
|
187
|
+
return dirFiles;
|
|
188
|
+
})();
|
|
189
|
+
const hasContainer = files.some(
|
|
190
|
+
f =>
|
|
191
|
+
f === `${componentName}Container.tsx` ||
|
|
192
|
+
f === `${componentName}Container.jsx`
|
|
193
|
+
);
|
|
194
|
+
const hasView = files.some(
|
|
195
|
+
f =>
|
|
196
|
+
f === `${componentName}View.tsx` || f === `${componentName}View.jsx`
|
|
197
|
+
);
|
|
198
|
+
const hasIndex = files.some(
|
|
199
|
+
f => f === "index.tsx" || f === "index.jsx"
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
if (!hasContainer) {
|
|
203
|
+
context.report({
|
|
204
|
+
node: context.getSourceCode().ast,
|
|
205
|
+
messageId: "missingContainer",
|
|
206
|
+
data: { componentName },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (!hasView) {
|
|
210
|
+
context.report({
|
|
211
|
+
node: context.getSourceCode().ast,
|
|
212
|
+
messageId: "missingView",
|
|
213
|
+
data: { componentName },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (!hasIndex) {
|
|
217
|
+
context.report({
|
|
218
|
+
node: context.getSourceCode().ast,
|
|
219
|
+
messageId: "missingIndex",
|
|
220
|
+
data: { componentName },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
} catch (_err) {
|
|
224
|
+
// Directory might not exist or be accessible
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Only check once per file
|
|
229
|
+
if (fileName === "index.tsx" || fileName === "index.jsx") {
|
|
230
|
+
checkRequiredFiles();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {};
|
|
234
|
+
},
|
|
235
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is managed by Lisa.
|
|
3
|
+
* Do not edit directly — changes will be overwritten on the next `lisa` run.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem",
|
|
9
|
+
docs: {
|
|
10
|
+
description:
|
|
11
|
+
"Disallow return statements in View components - use arrow function shorthand",
|
|
12
|
+
category: "Best Practices",
|
|
13
|
+
recommended: true,
|
|
14
|
+
},
|
|
15
|
+
fixable: null,
|
|
16
|
+
schema: [],
|
|
17
|
+
messages: {
|
|
18
|
+
noReturnInView:
|
|
19
|
+
"View components should use arrow function shorthand: () => (...) instead of () => { return (...) }. Hoist any definitions outside of the arrow function body or into the corresponding Container.",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
create(context) {
|
|
24
|
+
const filename = context.getFilename();
|
|
25
|
+
const normalizedPath = filename.replace(/\\/g, "/");
|
|
26
|
+
|
|
27
|
+
// Only check View.tsx and View.jsx files
|
|
28
|
+
if (!filename.endsWith("View.tsx") && !filename.endsWith("View.jsx")) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check if file is in features/**/components, features/**/screens, or components directory
|
|
33
|
+
const isFeatureComponent =
|
|
34
|
+
normalizedPath.includes("features/") &&
|
|
35
|
+
normalizedPath.includes("/components/");
|
|
36
|
+
const isFeatureScreen =
|
|
37
|
+
normalizedPath.includes("features/") &&
|
|
38
|
+
normalizedPath.includes("/screens/");
|
|
39
|
+
const isComponentsDir =
|
|
40
|
+
normalizedPath.includes("/components/") &&
|
|
41
|
+
!normalizedPath.includes("/components/ui/") &&
|
|
42
|
+
!normalizedPath.includes("/components/custom/ui/");
|
|
43
|
+
|
|
44
|
+
if (!isFeatureComponent && !isFeatureScreen && !isComponentsDir) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
ArrowFunctionExpression(node) {
|
|
50
|
+
// Check if this is a component (starts with capital letter or is exported)
|
|
51
|
+
const parent = node.parent;
|
|
52
|
+
|
|
53
|
+
// Helper to check if variable has a View component name
|
|
54
|
+
const isViewComponent = name =>
|
|
55
|
+
/^[A-Z]/.test(name) && name.includes("View");
|
|
56
|
+
|
|
57
|
+
// Determine if this is a component
|
|
58
|
+
const isComponent = (() => {
|
|
59
|
+
// Check if it's a default export
|
|
60
|
+
if (parent.type === "ExportDefaultDeclaration") {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if it's a variable declaration with PascalCase name
|
|
65
|
+
if (
|
|
66
|
+
parent.type === "VariableDeclarator" &&
|
|
67
|
+
parent.id.type === "Identifier"
|
|
68
|
+
) {
|
|
69
|
+
return isViewComponent(parent.id.name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if it's part of an export statement
|
|
73
|
+
if (
|
|
74
|
+
parent.type === "VariableDeclarator" &&
|
|
75
|
+
parent.parent.type === "VariableDeclaration" &&
|
|
76
|
+
parent.parent.parent.type === "ExportNamedDeclaration"
|
|
77
|
+
) {
|
|
78
|
+
return isViewComponent(parent.id.name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return false;
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
if (!isComponent) return;
|
|
85
|
+
|
|
86
|
+
// Check if the arrow function has a block body (any BlockStatement is forbidden)
|
|
87
|
+
if (node.body.type === "BlockStatement") {
|
|
88
|
+
context.report({
|
|
89
|
+
node,
|
|
90
|
+
messageId: "noReturnInView",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|