@codyswann/lisa 4.33.4 → 4.33.6

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.
Files changed (88) hide show
  1. package/dist/configs/eslint/expo.d.ts.map +1 -1
  2. package/dist/configs/eslint/expo.js +4 -7
  3. package/dist/configs/eslint/expo.js.map +1 -1
  4. package/dist/configs/eslint/view-gate.d.ts +42 -0
  5. package/dist/configs/eslint/view-gate.d.ts.map +1 -0
  6. package/dist/configs/eslint/view-gate.js +110 -0
  7. package/dist/configs/eslint/view-gate.js.map +1 -0
  8. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  9. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  10. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  11. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  12. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  13. package/dist/core/upstream-evidence-manifest.js +20 -9
  14. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  15. package/eslint-plugin-component-structure/README.md +85 -2
  16. package/eslint-plugin-component-structure/__tests__/no-hooks-in-view.test.js +229 -0
  17. package/eslint-plugin-component-structure/__tests__/no-return-in-view.test.js +185 -0
  18. package/eslint-plugin-component-structure/__tests__/plugin-index.test.js +8 -1
  19. package/eslint-plugin-component-structure/index.js +4 -1
  20. package/eslint-plugin-component-structure/lib/view-file-scope.js +62 -0
  21. package/eslint-plugin-component-structure/rules/no-hooks-in-view.js +101 -0
  22. package/eslint-plugin-component-structure/rules/no-return-in-view.js +133 -62
  23. package/expo/copy-overwrite/eslint.expo.ts +88 -1
  24. package/package.json +4 -4
  25. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-agy/plugin.json +1 -1
  28. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  31. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo/.codex-plugin/skills/container-view-pattern/SKILL.md +80 -24
  38. package/plugins/lisa-expo/.codex-plugin/skills/container-view-pattern/scripts/validate_component.py +166 -43
  39. package/plugins/lisa-expo/skills/container-view-pattern/SKILL.md +80 -24
  40. package/plugins/lisa-expo/skills/container-view-pattern/scripts/validate_component.py +166 -43
  41. package/plugins/lisa-expo-agy/plugin.json +1 -1
  42. package/plugins/lisa-expo-agy/skills/container-view-pattern/SKILL.md +80 -24
  43. package/plugins/lisa-expo-agy/skills/container-view-pattern/scripts/validate_component.py +166 -43
  44. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-copilot/skills/container-view-pattern/SKILL.md +80 -24
  46. package/plugins/lisa-expo-copilot/skills/container-view-pattern/scripts/validate_component.py +166 -43
  47. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-expo-cursor/skills/container-view-pattern/SKILL.md +80 -24
  49. package/plugins/lisa-expo-cursor/skills/container-view-pattern/scripts/validate_component.py +166 -43
  50. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  68. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails-agy/plugin.json +1 -1
  73. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  78. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  83. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/src/expo/skills/container-view-pattern/SKILL.md +80 -24
  86. package/plugins/src/expo/skills/container-view-pattern/scripts/validate_component.py +166 -43
  87. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +414 -6
  88. package/typescript/create-only/.github/workflows/review-evidence.yml +78 -3
@@ -40,15 +40,36 @@ In `.eslintrc.json`:
40
40
 
41
41
  ### no-return-in-view
42
42
 
43
- Prevents early returns and conditional logic in View components to maintain pure presentation components.
43
+ Requires a View component to be an arrow function with an **expression body**.
44
44
 
45
45
  #### Rule Details
46
46
 
47
- View components should only contain JSX presentation logic. Any conditional rendering or early returns should be handled in the Container component or passed as props.
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(4);
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: Disallows return statements in View components
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 };