@codyswann/lisa 4.33.3 → 4.33.5
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/configs/eslint/expo.d.ts.map +1 -1
- package/dist/configs/eslint/expo.js +4 -7
- package/dist/configs/eslint/expo.js.map +1 -1
- package/dist/configs/eslint/view-gate.d.ts +42 -0
- package/dist/configs/eslint/view-gate.d.ts.map +1 -0
- package/dist/configs/eslint/view-gate.js +110 -0
- package/dist/configs/eslint/view-gate.js.map +1 -0
- package/dist/configs/repo-scan.d.ts +25 -0
- package/dist/configs/repo-scan.d.ts.map +1 -0
- package/dist/configs/repo-scan.js +83 -0
- package/dist/configs/repo-scan.js.map +1 -0
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +20 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/eslint-plugin-component-structure/README.md +85 -2
- package/eslint-plugin-component-structure/__tests__/no-hooks-in-view.test.js +229 -0
- package/eslint-plugin-component-structure/__tests__/no-return-in-view.test.js +185 -0
- package/eslint-plugin-component-structure/__tests__/plugin-index.test.js +8 -1
- package/eslint-plugin-component-structure/index.js +4 -1
- package/eslint-plugin-component-structure/lib/view-file-scope.js +62 -0
- package/eslint-plugin-component-structure/rules/no-hooks-in-view.js +101 -0
- package/eslint-plugin-component-structure/rules/no-return-in-view.js +133 -62
- package/expo/copy-overwrite/eslint.expo.ts +88 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/skills/container-view-pattern/SKILL.md +80 -24
- package/plugins/lisa-expo/.codex-plugin/skills/container-view-pattern/scripts/validate_component.py +166 -43
- package/plugins/lisa-expo/skills/container-view-pattern/SKILL.md +80 -24
- package/plugins/lisa-expo/skills/container-view-pattern/scripts/validate_component.py +166 -43
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-agy/skills/container-view-pattern/SKILL.md +80 -24
- package/plugins/lisa-expo-agy/skills/container-view-pattern/scripts/validate_component.py +166 -43
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/skills/container-view-pattern/SKILL.md +80 -24
- package/plugins/lisa-expo-copilot/skills/container-view-pattern/scripts/validate_component.py +166 -43
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/skills/container-view-pattern/SKILL.md +80 -24
- package/plugins/lisa-expo-cursor/skills/container-view-pattern/scripts/validate_component.py +166 -43
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/expo/skills/container-view-pattern/SKILL.md +80 -24
- package/plugins/src/expo/skills/container-view-pattern/scripts/validate_component.py +166 -43
|
@@ -40,15 +40,36 @@ In `.eslintrc.json`:
|
|
|
40
40
|
|
|
41
41
|
### no-return-in-view
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Requires a View component to be an arrow function with an **expression body**.
|
|
44
44
|
|
|
45
45
|
#### Rule Details
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
Stated as a required shape rather than as "no return statements", because a
|
|
48
|
+
`FunctionDeclaration` cannot have an expression body — so requiring one bans
|
|
49
|
+
declaration form by construction, with no prose scanning and no false-positive
|
|
50
|
+
surface.
|
|
51
|
+
|
|
52
|
+
That distinction is the bug this rule shipped with. It registered a single
|
|
53
|
+
`ArrowFunctionExpression` visitor, and nothing anywhere required a View to be an
|
|
54
|
+
arrow function, so:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const HomeScreenView = ({ label }) => { return <Box />; }; // reported
|
|
58
|
+
function HomeScreenView({ label }) { return <Box />; } // never visited
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Writing `function` instead of `const` was a complete, silent opt-out, with
|
|
62
|
+
`require-memo-in-view` still passing because it checks the export.
|
|
63
|
+
|
|
64
|
+
What the rule guarantees is **no statement list**. It is not a purity gate:
|
|
65
|
+
`{Date.now()}` and `{Math.random() > 0.5 ? … : …}` are legal expression bodies.
|
|
66
|
+
Hooks are the other half — see `no-hooks-in-view`.
|
|
48
67
|
|
|
49
68
|
**Where does this rule apply?**
|
|
50
69
|
|
|
51
70
|
- Files matching `*View.tsx`, `*View.jsx`
|
|
71
|
+
- In `features/**/components/`, `features/**/screens/`, and `components/`
|
|
72
|
+
- **Excludes**: `components/ui/`, `components/custom/ui/`
|
|
52
73
|
|
|
53
74
|
#### Configuration
|
|
54
75
|
|
|
@@ -64,6 +85,66 @@ In `.eslintrc.json`:
|
|
|
64
85
|
|
|
65
86
|
---
|
|
66
87
|
|
|
88
|
+
### no-hooks-in-view
|
|
89
|
+
|
|
90
|
+
Disallows any hook call anywhere in a View component file.
|
|
91
|
+
|
|
92
|
+
#### Rule Details
|
|
93
|
+
|
|
94
|
+
The matcher is a **shape** — `use` followed by an uppercase letter, called — and
|
|
95
|
+
never a list of names. The call that motivated this rule was a project-local
|
|
96
|
+
custom hook, which any enumerated list would have missed.
|
|
97
|
+
|
|
98
|
+
That is also why there is no exemption list: a custom data hook and a render
|
|
99
|
+
helper are indistinguishable by name, so `useMemo` and `useCallback` are caught
|
|
100
|
+
too. Both the bare call (`useFlag()`) and the namespaced one (`React.useMemo()`)
|
|
101
|
+
are matched.
|
|
102
|
+
|
|
103
|
+
Scope is the file, not the component body — a hook called from a local render
|
|
104
|
+
helper still runs during that View's render.
|
|
105
|
+
|
|
106
|
+
❌ **Incorrect**:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// HomeScreenView.tsx — already in the shape no-return-in-view demands
|
|
110
|
+
const HomeScreenView = ({ label }) => (
|
|
111
|
+
<Box>{useCreateNoteQuickActionEnabled() ? <Text>{label}</Text> : null}</Box>
|
|
112
|
+
);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
✅ **Correct** — the hook moves to the Container and its result arrives as a prop:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
const HomeScreenView = ({ label, isQuickActionEnabled }) => (
|
|
119
|
+
<Box>{isQuickActionEnabled ? <Text>{label}</Text> : null}</Box>
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Where does this rule apply?**
|
|
124
|
+
|
|
125
|
+
- Files matching `*View.tsx`, `*View.jsx`
|
|
126
|
+
- In `features/**/components/`, `features/**/screens/`, and `components/`
|
|
127
|
+
- **Excludes**: `components/ui/`, `components/custom/ui/`
|
|
128
|
+
|
|
129
|
+
Pair it with `no-restricted-imports` on the same files to catch the import line
|
|
130
|
+
before a call site exists. The two are independent axes: a hook re-exported
|
|
131
|
+
through a barrel escapes the import patterns, and an import with no call yet
|
|
132
|
+
escapes this rule.
|
|
133
|
+
|
|
134
|
+
#### Configuration
|
|
135
|
+
|
|
136
|
+
In `.eslintrc.json`:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"rules": {
|
|
141
|
+
"component-structure/no-hooks-in-view": "error"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
67
148
|
### require-memo-in-view
|
|
68
149
|
|
|
69
150
|
Enforces the use of `React.memo()` for View components to optimize rendering performance.
|
|
@@ -197,6 +278,7 @@ export default [
|
|
|
197
278
|
},
|
|
198
279
|
rules: {
|
|
199
280
|
'component-structure/enforce-component-structure': 'error',
|
|
281
|
+
'component-structure/no-hooks-in-view': 'error',
|
|
200
282
|
'component-structure/no-return-in-view': 'error',
|
|
201
283
|
'component-structure/require-memo-in-view': 'error',
|
|
202
284
|
'component-structure/single-component-per-file': 'error',
|
|
@@ -212,6 +294,7 @@ export default [
|
|
|
212
294
|
"plugins": ["component-structure"],
|
|
213
295
|
"rules": {
|
|
214
296
|
"component-structure/enforce-component-structure": "error",
|
|
297
|
+
"component-structure/no-hooks-in-view": "error",
|
|
215
298
|
"component-structure/no-return-in-view": "error",
|
|
216
299
|
"component-structure/require-memo-in-view": "error",
|
|
217
300
|
"component-structure/single-component-per-file": "error"
|
|
@@ -0,0 +1,229 @@
|
|
|
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 no-hooks-in-view ESLint rule.
|
|
8
|
+
*
|
|
9
|
+
* The View half of the Container/View pattern has documented "No hooks" since
|
|
10
|
+
* the skill was written, and nothing enforced it. The call that made that
|
|
11
|
+
* expensive was a project-local custom hook, so the matcher here is a SHAPE —
|
|
12
|
+
* `use` followed by an uppercase letter, called — and never a list of names.
|
|
13
|
+
* A name list would have missed the one that mattered.
|
|
14
|
+
*
|
|
15
|
+
* Catching `useMemo` and `useCallback` too is the accepted cost of that shape:
|
|
16
|
+
* a custom data hook and a render helper are indistinguishable by name, and the
|
|
17
|
+
* skill already forbids `useMemo` in a View, so exempting it would contradict
|
|
18
|
+
* the document. Exemptions are the escape hatch this gate is not allowed.
|
|
19
|
+
* @module eslint-plugin-component-structure/tests/no-hooks-in-view
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { RuleTester } = require("eslint");
|
|
23
|
+
|
|
24
|
+
const rule = require("../rules/no-hooks-in-view");
|
|
25
|
+
|
|
26
|
+
const FEATURE_COMPONENTS = "features/example/components";
|
|
27
|
+
const VIEW_FILE = `${FEATURE_COMPONENTS}/MyView.tsx`;
|
|
28
|
+
|
|
29
|
+
describe("the report names the escape route", () => {
|
|
30
|
+
/**
|
|
31
|
+
* A ban with no way out is what makes a developer — or an agent — reach for a
|
|
32
|
+
* disable comment, and this rule accepts none. The message is therefore part
|
|
33
|
+
* of the rule's contract, not decoration, and the wording that survived a
|
|
34
|
+
* design argument is exactly the wording that goes stale silently: nothing
|
|
35
|
+
* else in the suite fails if it is replaced with "Not allowed."
|
|
36
|
+
*/
|
|
37
|
+
const message = rule.meta.messages.noHooksInView;
|
|
38
|
+
|
|
39
|
+
it("names the Container as the destination", () => {
|
|
40
|
+
expect(message).toContain("into the corresponding Container");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("says a Container may hold a callback that returns JSX", () => {
|
|
44
|
+
// Without this, the advice produces a second violation for the renderItem
|
|
45
|
+
// shape — the collision that nearly cost this rule its generic matcher.
|
|
46
|
+
expect(message).toContain("returns JSX");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("offers row extraction as an alternative, not a requirement", () => {
|
|
50
|
+
expect(message).toContain("renderItem");
|
|
51
|
+
expect(message).toContain("equally valid alternative");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("names the hook it is reporting", () => {
|
|
55
|
+
expect(message).toContain("{{hook}}");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const ruleTester = new RuleTester({
|
|
60
|
+
languageOptions: {
|
|
61
|
+
ecmaVersion: 2020,
|
|
62
|
+
sourceType: "module",
|
|
63
|
+
parserOptions: {
|
|
64
|
+
ecmaFeatures: {
|
|
65
|
+
jsx: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
ruleTester.run("no-hooks-in-view", rule, {
|
|
72
|
+
valid: [
|
|
73
|
+
{
|
|
74
|
+
code: `
|
|
75
|
+
const MyView = ({ label }) => <Text>{label}</Text>;
|
|
76
|
+
|
|
77
|
+
export default memo(MyView);
|
|
78
|
+
`,
|
|
79
|
+
filename: VIEW_FILE,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
// `useful` is not a hook: the shape requires an uppercase letter after
|
|
83
|
+
// "use". Without that boundary the rule would report ordinary helpers.
|
|
84
|
+
code: `
|
|
85
|
+
const MyView = ({ items }) => <Box>{useful(items)}</Box>;
|
|
86
|
+
|
|
87
|
+
export default memo(MyView);
|
|
88
|
+
`,
|
|
89
|
+
filename: VIEW_FILE,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
// An import of a hook is not a call. The import line is covered by
|
|
93
|
+
// no-restricted-imports in the shipped config, on purpose: two
|
|
94
|
+
// independent axes, not one duplicated.
|
|
95
|
+
code: `
|
|
96
|
+
import { useMemo } from "react";
|
|
97
|
+
|
|
98
|
+
const MyView = ({ label }) => <Text>{label}</Text>;
|
|
99
|
+
|
|
100
|
+
export default memo(MyView);
|
|
101
|
+
`,
|
|
102
|
+
filename: VIEW_FILE,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
// Containers own hooks.
|
|
106
|
+
code: `
|
|
107
|
+
const MyContainer = () => {
|
|
108
|
+
const value = useMemo(() => 1, []);
|
|
109
|
+
return <MyView value={value} />;
|
|
110
|
+
};
|
|
111
|
+
`,
|
|
112
|
+
filename: `${FEATURE_COMPONENTS}/MyContainer.tsx`,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
// Vendored UI primitives are outside the Container/View pattern.
|
|
116
|
+
code: `
|
|
117
|
+
const ButtonView = () => <Pressable>{useThemeColor()}</Pressable>;
|
|
118
|
+
`,
|
|
119
|
+
filename: "components/ui/Button/ButtonView.tsx",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
code: `
|
|
123
|
+
const InputView = () => <Field>{useThemeColor()}</Field>;
|
|
124
|
+
`,
|
|
125
|
+
filename: "components/custom/ui/Input/InputView.tsx",
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
invalid: [
|
|
129
|
+
{
|
|
130
|
+
// THE INCIDENT SHAPE. An expression-bodied arrow — legal under
|
|
131
|
+
// no-return-in-view in every form — with a hook called inline in JSX.
|
|
132
|
+
// This is the case the expression-body requirement alone cannot catch.
|
|
133
|
+
code: `
|
|
134
|
+
const MyView = ({ label }) => (
|
|
135
|
+
<Box>{useFlag() ? <Text>{label}</Text> : null}</Box>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
export default memo(MyView);
|
|
139
|
+
`,
|
|
140
|
+
filename: VIEW_FILE,
|
|
141
|
+
errors: [{ messageId: "noHooksInView", data: { hook: "useFlag" } }],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
// A project-local custom hook. Any rule built on an enumerated name list
|
|
145
|
+
// misses this, which is what made the generic shape non-negotiable.
|
|
146
|
+
code: `
|
|
147
|
+
const MyView = ({ label }) => (
|
|
148
|
+
<Box>
|
|
149
|
+
{useCreateNoteQuickActionEnabled() ? <Text>{label}</Text> : null}
|
|
150
|
+
</Box>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
export default memo(MyView);
|
|
154
|
+
`,
|
|
155
|
+
filename: VIEW_FILE,
|
|
156
|
+
errors: [
|
|
157
|
+
{
|
|
158
|
+
messageId: "noHooksInView",
|
|
159
|
+
data: { hook: "useCreateNoteQuickActionEnabled" },
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
// React's own memoisation helpers are hooks and are forbidden here too.
|
|
165
|
+
code: `
|
|
166
|
+
const MyView = ({ items }) => (
|
|
167
|
+
<Box>{useMemo(() => items.length, [items])}</Box>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
export default memo(MyView);
|
|
171
|
+
`,
|
|
172
|
+
filename: VIEW_FILE,
|
|
173
|
+
errors: [{ messageId: "noHooksInView", data: { hook: "useMemo" } }],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
// Namespaced call form.
|
|
177
|
+
code: `
|
|
178
|
+
const MyView = ({ items }) => (
|
|
179
|
+
<Box>{React.useMemo(() => items.length, [items])}</Box>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
export default memo(MyView);
|
|
183
|
+
`,
|
|
184
|
+
filename: VIEW_FILE,
|
|
185
|
+
errors: [{ messageId: "noHooksInView", data: { hook: "useMemo" } }],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
// A hook inside a block-bodied View: both gates fire on their own axis,
|
|
189
|
+
// and this one reports independently of no-return-in-view.
|
|
190
|
+
code: `
|
|
191
|
+
const MyView = ({ label }) => {
|
|
192
|
+
const flag = useFeatureFlag();
|
|
193
|
+
return <Text>{flag ? label : null}</Text>;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export default memo(MyView);
|
|
197
|
+
`,
|
|
198
|
+
filename: VIEW_FILE,
|
|
199
|
+
errors: [
|
|
200
|
+
{ messageId: "noHooksInView", data: { hook: "useFeatureFlag" } },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
// A hook inside a local render helper is still a hook in a View file.
|
|
205
|
+
code: `
|
|
206
|
+
function renderBody(props) {
|
|
207
|
+
return <Text>{useTheme().color}</Text>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const MyView = props => <Box>{renderBody(props)}</Box>;
|
|
211
|
+
|
|
212
|
+
export default memo(MyView);
|
|
213
|
+
`,
|
|
214
|
+
filename: VIEW_FILE,
|
|
215
|
+
errors: [{ messageId: "noHooksInView", data: { hook: "useTheme" } }],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
code: `
|
|
219
|
+
const CardView = () => <Box>{useTranslation().t("k")}</Box>;
|
|
220
|
+
|
|
221
|
+
export default memo(CardView);
|
|
222
|
+
`,
|
|
223
|
+
filename: "components/Card/CardView.jsx",
|
|
224
|
+
errors: [
|
|
225
|
+
{ messageId: "noHooksInView", data: { hook: "useTranslation" } },
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
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 no-return-in-view ESLint rule.
|
|
8
|
+
*
|
|
9
|
+
* The rule shipped for its whole life with a single `ArrowFunctionExpression`
|
|
10
|
+
* visitor, and nothing anywhere required a View to be an arrow function. So
|
|
11
|
+
* `const XView = (...) => { ... }` was blocked while the byte-equivalent
|
|
12
|
+
* `function XView(...) { ... }` was never visited at all — a silent, complete,
|
|
13
|
+
* permanent opt-out of the only statement gate on Views, with
|
|
14
|
+
* `require-memo-in-view` still passing because it checks the export.
|
|
15
|
+
*
|
|
16
|
+
* Every `declaration form` case below fails against that rule. They are the
|
|
17
|
+
* point of this file: a suite without them is the same green-but-inert control
|
|
18
|
+
* the rule itself was.
|
|
19
|
+
* @module eslint-plugin-component-structure/tests/no-return-in-view
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { RuleTester } = require("eslint");
|
|
23
|
+
|
|
24
|
+
const rule = require("../rules/no-return-in-view");
|
|
25
|
+
|
|
26
|
+
const FEATURE_COMPONENTS = "features/example/components";
|
|
27
|
+
const VIEW_FILE = `${FEATURE_COMPONENTS}/MyView.tsx`;
|
|
28
|
+
|
|
29
|
+
const ruleTester = new RuleTester({
|
|
30
|
+
languageOptions: {
|
|
31
|
+
ecmaVersion: 2020,
|
|
32
|
+
sourceType: "module",
|
|
33
|
+
parserOptions: {
|
|
34
|
+
ecmaFeatures: {
|
|
35
|
+
jsx: true,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
ruleTester.run("no-return-in-view", rule, {
|
|
42
|
+
valid: [
|
|
43
|
+
{
|
|
44
|
+
// The shape the rule exists to require.
|
|
45
|
+
code: `
|
|
46
|
+
const MyView = ({ label }) => <Text>{label}</Text>;
|
|
47
|
+
|
|
48
|
+
MyView.displayName = "MyView";
|
|
49
|
+
|
|
50
|
+
export default memo(MyView);
|
|
51
|
+
`,
|
|
52
|
+
filename: VIEW_FILE,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
// Parenthesised expression body is still an expression body.
|
|
56
|
+
code: `
|
|
57
|
+
const MyView = ({ label }) => (
|
|
58
|
+
<Box>
|
|
59
|
+
<Text>{label}</Text>
|
|
60
|
+
</Box>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export default memo(MyView);
|
|
64
|
+
`,
|
|
65
|
+
filename: VIEW_FILE,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
// A local render helper is NOT the View component. This is exactly the
|
|
69
|
+
// case a substring ban on the word "function" gets wrong, and the reason
|
|
70
|
+
// the rule is stated as "the View must be an expression-bodied arrow"
|
|
71
|
+
// rather than "no function declarations in View files".
|
|
72
|
+
code: `
|
|
73
|
+
function renderBody(props) {
|
|
74
|
+
return <Text>{props.label}</Text>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MyView = ({ label }) => <Box>{renderBody({ label })}</Box>;
|
|
78
|
+
|
|
79
|
+
export default memo(MyView);
|
|
80
|
+
`,
|
|
81
|
+
filename: VIEW_FILE,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
// Not a View file.
|
|
85
|
+
code: `
|
|
86
|
+
function MyContainer() {
|
|
87
|
+
return <MyView />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default MyContainer;
|
|
91
|
+
`,
|
|
92
|
+
filename: `${FEATURE_COMPONENTS}/MyContainer.tsx`,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
// Outside the directories the Container/View pattern applies to.
|
|
96
|
+
code: `
|
|
97
|
+
function MyView() {
|
|
98
|
+
return <Box />;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default MyView;
|
|
102
|
+
`,
|
|
103
|
+
filename: "lib/MyView.tsx",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
invalid: [
|
|
107
|
+
{
|
|
108
|
+
// The case the rule already caught: block-bodied arrow.
|
|
109
|
+
code: `
|
|
110
|
+
const MyView = ({ label }) => {
|
|
111
|
+
return <Text>{label}</Text>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export default memo(MyView);
|
|
115
|
+
`,
|
|
116
|
+
filename: VIEW_FILE,
|
|
117
|
+
errors: [{ messageId: "noReturnInView" }],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
// DECLARATION FORM. Fails against the pre-fix rule, which never visits
|
|
121
|
+
// a FunctionDeclaration and reports nothing at all.
|
|
122
|
+
code: `
|
|
123
|
+
function MyView({ label }) {
|
|
124
|
+
return <Text>{label}</Text>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
MyView.displayName = "MyView";
|
|
128
|
+
|
|
129
|
+
export default memo(MyView);
|
|
130
|
+
`,
|
|
131
|
+
filename: VIEW_FILE,
|
|
132
|
+
errors: [{ messageId: "mustBeArrowFunction" }],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
// Declaration form, exported inline.
|
|
136
|
+
code: `
|
|
137
|
+
export default function MyView({ label }) {
|
|
138
|
+
return <Text>{label}</Text>;
|
|
139
|
+
}
|
|
140
|
+
`,
|
|
141
|
+
filename: VIEW_FILE,
|
|
142
|
+
errors: [{ messageId: "mustBeArrowFunction" }],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
// Function-expression form assigned to a View-named binding.
|
|
146
|
+
code: `
|
|
147
|
+
const MyView = function ({ label }) {
|
|
148
|
+
return <Text>{label}</Text>;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export default memo(MyView);
|
|
152
|
+
`,
|
|
153
|
+
filename: VIEW_FILE,
|
|
154
|
+
errors: [{ messageId: "mustBeArrowFunction" }],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
// The component the file exports, named without the word "View".
|
|
158
|
+
// Reached through the default export's memo() argument, so the rule does
|
|
159
|
+
// not depend on the identifier spelling.
|
|
160
|
+
code: `
|
|
161
|
+
function HomeScreen({ label }) {
|
|
162
|
+
return <Text>{label}</Text>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
HomeScreen.displayName = "HomeScreen";
|
|
166
|
+
|
|
167
|
+
export default memo(HomeScreen);
|
|
168
|
+
`,
|
|
169
|
+
filename: `features/home/screens/HomeScreenView.tsx`,
|
|
170
|
+
errors: [{ messageId: "mustBeArrowFunction" }],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
// Declaration form in a plain components/ directory.
|
|
174
|
+
code: `
|
|
175
|
+
function CardView(props) {
|
|
176
|
+
return <Box>{props.label}</Box>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export default memo(CardView);
|
|
180
|
+
`,
|
|
181
|
+
filename: "components/Card/CardView.jsx",
|
|
182
|
+
errors: [{ messageId: "mustBeArrowFunction" }],
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
});
|
|
@@ -55,6 +55,13 @@ describe("eslint-plugin-component-structure", () => {
|
|
|
55
55
|
expect(plugin.rules["no-return-in-view"]).toHaveProperty("meta");
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it("should export no-hooks-in-view rule", () => {
|
|
59
|
+
expect(plugin.rules).toHaveProperty("no-hooks-in-view");
|
|
60
|
+
expect(typeof plugin.rules["no-hooks-in-view"]).toBe("object");
|
|
61
|
+
expect(plugin.rules["no-hooks-in-view"]).toHaveProperty("create");
|
|
62
|
+
expect(plugin.rules["no-hooks-in-view"]).toHaveProperty("meta");
|
|
63
|
+
});
|
|
64
|
+
|
|
58
65
|
it("should export require-memo-in-view rule", () => {
|
|
59
66
|
expect(plugin.rules).toHaveProperty("require-memo-in-view");
|
|
60
67
|
expect(typeof plugin.rules["require-memo-in-view"]).toBe("object");
|
|
@@ -73,7 +80,7 @@ describe("eslint-plugin-component-structure", () => {
|
|
|
73
80
|
|
|
74
81
|
it("should have correct number of rules", () => {
|
|
75
82
|
const ruleCount = Object.keys(plugin.rules).length;
|
|
76
|
-
expect(ruleCount).toBe(
|
|
83
|
+
expect(ruleCount).toBe(5);
|
|
77
84
|
});
|
|
78
85
|
});
|
|
79
86
|
|
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Rules:
|
|
13
13
|
* - enforce-component-structure: Ensures components follow the Container/View pattern
|
|
14
|
-
* - no-return-in-view:
|
|
14
|
+
* - no-return-in-view: Requires View components to be expression-bodied arrows
|
|
15
|
+
* - no-hooks-in-view: Disallows hook calls in View components
|
|
15
16
|
* - require-memo-in-view: Enforces React.memo usage in View components
|
|
16
17
|
* - single-component-per-file: Ensures only one React component per file
|
|
17
18
|
* @module eslint-plugin-component-structure
|
|
18
19
|
*/
|
|
19
20
|
const enforceComponentStructure = require("./rules/enforce-component-structure");
|
|
21
|
+
const noHooksInView = require("./rules/no-hooks-in-view");
|
|
20
22
|
const noReturnInView = require("./rules/no-return-in-view");
|
|
21
23
|
const requireMemoInView = require("./rules/require-memo-in-view");
|
|
22
24
|
const singleComponentPerFile = require("./rules/single-component-per-file");
|
|
@@ -28,6 +30,7 @@ const plugin = {
|
|
|
28
30
|
},
|
|
29
31
|
rules: {
|
|
30
32
|
"enforce-component-structure": enforceComponentStructure,
|
|
33
|
+
"no-hooks-in-view": noHooksInView,
|
|
31
34
|
"no-return-in-view": noReturnInView,
|
|
32
35
|
"require-memo-in-view": requireMemoInView,
|
|
33
36
|
"single-component-per-file": singleComponentPerFile,
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
* Where the View half of the Container/View pattern is enforced.
|
|
8
|
+
*
|
|
9
|
+
* Two rules ask this question — `no-return-in-view` (no statements) and
|
|
10
|
+
* `no-hooks-in-view` (no hooks) — and they must agree. A second copy of the
|
|
11
|
+
* predicate is how a gate silently stops covering half the files it names, so
|
|
12
|
+
* the answer lives here once and both rules require it.
|
|
13
|
+
*
|
|
14
|
+
* This is scope, not exemption. `components/ui/**` and `components/custom/ui/**`
|
|
15
|
+
* hold vendored primitives that the pattern's own applicability table marks as
|
|
16
|
+
* outside it, and that set is inherited verbatim from `require-memo-in-view` and
|
|
17
|
+
* from the shipped config's `ignores` rather than invented here. There is no
|
|
18
|
+
* per-file, per-site, or opt-in-flag way to leave it: a violating View is fixed
|
|
19
|
+
* by migrating the View.
|
|
20
|
+
* @module eslint-plugin-component-structure/lib/view-file-scope
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A `components` directory anywhere in the path, as a whole path segment.
|
|
25
|
+
*
|
|
26
|
+
* Segment-anchored so `my-components/` is not mistaken for it, and
|
|
27
|
+
* `(^|/)`-anchored so a RuleTester's repo-relative filename and a real absolute
|
|
28
|
+
* one resolve identically. A bare `includes("/components/")` gets the relative
|
|
29
|
+
* form wrong, which is invisible in production and silently disables every test
|
|
30
|
+
* case written against a relative path.
|
|
31
|
+
*/
|
|
32
|
+
const COMPONENTS_SEGMENT = /(^|\/)components\//u;
|
|
33
|
+
|
|
34
|
+
/** The vendored-primitive directories the Container/View pattern excludes. */
|
|
35
|
+
const VENDORED_UI_SEGMENT = /(^|\/)components\/(custom\/)?ui\//u;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a rule should run against this file.
|
|
39
|
+
* @param {string} filename - The file ESLint is linting, in any path style.
|
|
40
|
+
* @returns {boolean} True when the file is a View inside the pattern's scope.
|
|
41
|
+
*/
|
|
42
|
+
function isEnforcedViewFile(filename) {
|
|
43
|
+
if (!filename.endsWith("View.tsx") && !filename.endsWith("View.jsx")) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const normalizedPath = filename.replace(/\\/gu, "/");
|
|
48
|
+
|
|
49
|
+
const isFeatureComponent =
|
|
50
|
+
normalizedPath.includes("features/") &&
|
|
51
|
+
COMPONENTS_SEGMENT.test(normalizedPath);
|
|
52
|
+
const isFeatureScreen =
|
|
53
|
+
normalizedPath.includes("features/") &&
|
|
54
|
+
normalizedPath.includes("/screens/");
|
|
55
|
+
const isComponentsDir =
|
|
56
|
+
COMPONENTS_SEGMENT.test(normalizedPath) &&
|
|
57
|
+
!VENDORED_UI_SEGMENT.test(normalizedPath);
|
|
58
|
+
|
|
59
|
+
return isFeatureComponent || isFeatureScreen || isComponentsDir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { isEnforcedViewFile };
|