@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
@@ -43,9 +43,16 @@ The Container/View pattern is **required** in these directories:
43
43
  Run the skill's generator script for any component type:
44
44
 
45
45
  ```bash
46
- python3 .claude/skills/container-view-pattern/scripts/create_component.py <type> <name> [feature]
46
+ python3 "${CLAUDE_PLUGIN_ROOT:-node_modules/@codyswann/lisa/plugins/lisa-expo}/skills/container-view-pattern/scripts/create_component.py" <type> <name> [feature]
47
47
  ```
48
48
 
49
+ The `:-` default is load-bearing. This skill ships inside the Lisa plugin, not in
50
+ the consumer's `.claude/skills/`, and `CLAUDE_PLUGIN_ROOT` is not exported into
51
+ an agent's Bash environment — so a bare `.claude/skills/…` path names a file that
52
+ does not exist in a consumer, and a bare `"${CLAUDE_PLUGIN_ROOT}/…"` expands to
53
+ an absolute `/skills/…` and exits 1. The default resolves from the consumer
54
+ repository root, which is where the script is actually invoked.
55
+
49
56
  **Component Types:**
50
57
 
51
58
  | Type | Command | Creates in |
@@ -71,7 +78,7 @@ ComponentName/
71
78
 
72
79
  Container components handle all business logic:
73
80
 
74
- 1. **Single View render**: Container must ONLY render its corresponding View component - no other UI elements or components
81
+ 1. **Single View render**: A Container's **returned tree** is only its corresponding View component — no other UI elements beside it. This constrains what the Container renders, not what it holds: a Container may define a callback that returns JSX (a `renderItem` passed down as a prop is the common case), and that is where such a callback belongs once a View may no longer call hooks.
75
82
  2. **State management**: Use `useState`, `useReducer`
76
83
  3. **Data fetching**: Use GraphQL hooks, API calls
77
84
  4. **Memoization**: Wrap all computed values in `useMemo`
@@ -146,15 +153,33 @@ export default ComponentNameContainer;
146
153
 
147
154
  ## View Component Requirements
148
155
 
149
- View components are pure presentation:
150
-
151
- 1. **Arrow function shorthand**: Use `() => (...)` not `() => { return (...); }`
152
- 2. **No return statements**: The component body must be a single JSX expression
156
+ View components carry **no statements and no hooks**. That is the guarantee, and
157
+ it is deliberately narrower than "pure": `{Date.now()}` and
158
+ `{Math.random() > 0.5 ? … : …}` sit happily inside an expression body and no
159
+ rule below rejects them. Claiming purity here would repeat the defect this
160
+ section was rewritten to fix — see [Validation](#validation).
161
+
162
+ 1. **Expression-bodied arrow function**: The View component must be
163
+ `const XView = (props) => (...)`. Not `() => { return (...); }`, and not
164
+ `function XView(props) { ... }` — a function declaration cannot have an
165
+ expression body, so declaration form always carries a statement list. This is
166
+ the requirement; the other two shapes are the two ways of failing it.
167
+ 2. **No statements**: The component body is a single JSX expression.
153
168
  3. **memo wrapper**: Export with `memo()` for performance optimization
154
169
  4. **displayName**: Set `ComponentName.displayName = "ComponentName"`
155
170
  5. **Readonly props**: All props should be marked as `readonly`
156
- 6. **No hooks**: View should not contain `useState`, `useEffect`, `useMemo`, etc.
157
- 7. **No logic**: All conditional rendering should use ternary expressions in JSX
171
+ 6. **No hooks anywhere in the file**: No call of the shape `use` + an uppercase
172
+ letter — `useState`, `useEffect`, `useMemo`, `useCallback`, and every
173
+ project-local custom hook alike. The ban is on the shape, not on a list of
174
+ names: the call that motivated this gate was a project-local hook that no
175
+ name list would have contained. `useMemo` and `useCallback` are caught for
176
+ the same reason — a data hook and a render helper are indistinguishable by
177
+ name, and this document already forbade `useMemo` in a View. There is no
178
+ exemption list, no per-site waiver, and no opt-out flag; a View that calls a
179
+ hook is fixed by moving the hook into the Container.
180
+ 7. **No logic**: All conditional rendering should use ternary expressions in JSX.
181
+ Unlike 1, 2 and 6, this one is **not lint-enforced** — see the second table
182
+ under [Validation](#validation).
158
183
 
159
184
  ### View Template
160
185
 
@@ -207,23 +232,47 @@ export { default } from "./ComponentNameContainer";
207
232
 
208
233
  ### ESLint Rules
209
234
 
210
- The following ESLint rules enforce the pattern:
235
+ Each row names the numbered requirement it enforces. Every requirement above
236
+ appears in exactly one of the two tables, so a requirement with no rule is
237
+ visible as such rather than implied to be covered.
238
+
239
+ For most of this skill's life the table below listed four rules under the
240
+ heading "the following ESLint rules enforce the pattern" — and none of them
241
+ enforced requirement 6 or 7. That false assurance is why nobody checked, and why
242
+ a declaration-form View with a hook in it passed lint for years.
243
+
244
+ | Rule | Enforces | Description |
245
+ | ------------------------------------------------- | --------- | ----------------------------------------------------------------- |
246
+ | `component-structure/enforce-component-structure` | structure | Validates directory structure and file naming |
247
+ | `component-structure/no-return-in-view` | 1, 2 | View must be an arrow function with an expression body |
248
+ | `component-structure/no-hooks-in-view` | 6 | No `use[A-Z]…()` call anywhere in a View file, by shape not name |
249
+ | `no-restricted-imports` (View files) | 6 | No React hook named-import, and no `**/hooks/**` import |
250
+ | `component-structure/require-memo-in-view` | 3, 4 | Ensures View uses memo and displayName |
251
+ | `component-structure/single-component-per-file` | structure | One component per file |
211
252
 
212
- | Rule | Description |
213
- | ------------------------------------------------- | --------------------------------------------- |
214
- | `component-structure/enforce-component-structure` | Validates directory structure and file naming |
215
- | `component-structure/no-return-in-view` | Ensures View uses arrow shorthand |
216
- | `component-structure/require-memo-in-view` | Ensures View uses memo and displayName |
217
- | `component-structure/single-component-per-file` | One component per file |
253
+ `no-hooks-in-view` and `no-restricted-imports` are two independent axes, not one
254
+ duplicated: a hook re-exported through a barrel escapes the import patterns, and
255
+ an import with no call site yet escapes the call matcher.
256
+
257
+ ### What lint does NOT enforce
258
+
259
+ | Requirement | Why not |
260
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------- |
261
+ | 5 — readonly props | A convention on the props interface; carried by review and the templates above. |
262
+ | 7 — no logic beyond ternaries | "Logic" has no AST shape. `{Date.now()}` and `{Math.random() > 0.5 ? … : …}` are legal expression bodies. |
218
263
 
219
264
  ### Manual Validation
220
265
 
221
266
  Run the validation script to check a component:
222
267
 
223
268
  ```bash
224
- python3 .claude/skills/container-view-pattern/scripts/validate_component.py <path-to-component-directory>
269
+ python3 "${CLAUDE_PLUGIN_ROOT:-node_modules/@codyswann/lisa/plugins/lisa-expo}/skills/container-view-pattern/scripts/validate_component.py" <path-to-component-directory>
225
270
  ```
226
271
 
272
+ The script is a fast pre-check, not the gate: it reads one component directory
273
+ with regexes, while ESLint reads the AST of every file. When they disagree,
274
+ ESLint is right.
275
+
227
276
  Run ESLint to check all components:
228
277
 
229
278
  ```bash
@@ -238,7 +287,7 @@ bun run lint
238
287
 
239
288
  | Issue | Resolution |
240
289
  | ------------------------------------ | ----------------------------------------------------------- |
241
- | Rendering UI elements besides View | Container must ONLY return the corresponding View component |
290
+ | Returning UI elements besides View | Container's returned tree is ONLY the corresponding View |
242
291
  | Rendering multiple components | Move all UI to View; Container returns only View |
243
292
  | Missing `useMemo` for objects/arrays | Wrap computed values in `useMemo` |
244
293
  | Missing `useCallback` for functions | Wrap handlers in `useCallback` |
@@ -247,13 +296,14 @@ bun run lint
247
296
 
248
297
  ### View Violations
249
298
 
250
- | Issue | Resolution |
251
- | ----------------------------- | ------------------------------------------------- |
252
- | Using block body `{ return }` | Convert to arrow shorthand `() => (...)` |
253
- | Missing `memo` wrapper | Add `export default memo(ComponentView)` |
254
- | Missing `displayName` | Add `ComponentView.displayName = "ComponentView"` |
255
- | Contains hooks | Move hooks to Container |
256
- | Contains state | Move state to Container |
299
+ | Issue | Resolution |
300
+ | ------------------------------------ | ------------------------------------------------- |
301
+ | Using block body `{ return }` | Convert to arrow shorthand `() => (...)` |
302
+ | Declared as `function XView() {…}` | Convert to `const XView = (props) => (...)` |
303
+ | Missing `memo` wrapper | Add `export default memo(ComponentView)` |
304
+ | Missing `displayName` | Add `ComponentView.displayName = "ComponentView"` |
305
+ | Contains hooks (including custom) | Move hooks to Container |
306
+ | Contains state | Move state to Container |
257
307
 
258
308
  ## Extracting Helper Functions
259
309
 
@@ -275,6 +325,12 @@ const ComponentView = ({ isLoading, isDark }: Props) => (
275
325
  );
276
326
  ```
277
327
 
328
+ A local render helper may be a function declaration. Requirement 1 is about the
329
+ **View component**, not about the word `function` appearing in the file — which
330
+ is why it is stated as "expression-bodied arrow" rather than as a ban on
331
+ declarations. The helper is still inside a View file, so requirement 6 applies to
332
+ it: it may not call a hook either.
333
+
278
334
  ## Event Handler Naming Convention
279
335
 
280
336
  - **Container**: Use `handle*` prefix (e.g., `handleSubmit`, `handleClick`)
@@ -10,11 +10,29 @@ This script validates that a component directory follows the Container/View patt
10
10
  - Has index.tsx with correct export
11
11
  - View uses memo() wrapper
12
12
  - View has displayName
13
- - View uses arrow function shorthand
13
+ - View is an arrow function with an expression body (declaration form is a
14
+ violation, not an alternative spelling)
15
+ - View calls no hooks — matched by SHAPE (`use` + uppercase), never by a list of
16
+ names, because the call that motivated this check was a project-local custom
17
+ hook that no list would have contained
18
+
19
+ One check reports as an ADVISORY rather than an error: "Container renders JSX
20
+ besides the View". It is a whole-file text scan that cannot tell a returned JSX
21
+ tree from JSX inside a callback, so a Container holding a legitimate `renderItem`
22
+ trips it — and moving a `renderItem` out of a View is one of the sanctioned fixes
23
+ for the View hook ban. It prints and does not fail.
24
+
25
+ This is a fast pre-check, not the gate. ESLint reads the AST of every file;
26
+ this reads one directory with regexes. When they disagree, ESLint is right.
14
27
 
15
28
  Usage:
16
29
  python3 validate_component.py <path-to-component-directory>
17
30
 
31
+ The script ships inside the Lisa plugin, not in a consumer's `.claude/skills/`,
32
+ so invoke it through the plugin root:
33
+
34
+ python3 "${CLAUDE_PLUGIN_ROOT:-node_modules/@codyswann/lisa/plugins/lisa-expo}/skills/container-view-pattern/scripts/validate_component.py" <path>
35
+
18
36
  Example:
19
37
  python3 validate_component.py features/player-kanban/components/AddColumnButton
20
38
  """
@@ -25,21 +43,118 @@ import sys
25
43
  from pathlib import Path
26
44
 
27
45
 
28
- def validate_component(component_path: str) -> tuple[bool, list[str]]:
46
+ HOOK_CALL = re.compile(r"\b(use[A-Z]\w*)\s*\(")
47
+ """Every hook call, by SHAPE.
48
+
49
+ `use` followed by an uppercase letter is React's own definition of a hook name,
50
+ so this matches `useState`, `useMemo`, and every project-local custom hook
51
+ alike. It is a shape and never a list: a list is what let
52
+ `useCreateNoteQuickActionEnabled()` through.
53
+
54
+ It also matches hook calls inside comments and strings. That is the accepted
55
+ cost of a regex pre-check; ESLint's `component-structure/no-hooks-in-view` reads
56
+ the AST and is the gate.
57
+ """
58
+
59
+
60
+ def _arrow_body_is_block(source: str, start: int) -> bool:
61
+ """
62
+ Whether the arrow beginning at `start` has a block body.
63
+
64
+ Walks to the first top-level `=>` instead of matching a parameter list with
65
+ `\\([^)]*\\)`, which fails on any nested paren — a destructured parameter with
66
+ a default, a type annotation carrying a function type, or a parenthesised
67
+ union.
68
+
69
+ Args:
70
+ source: The View file's text.
71
+ start: Index just past the `const <Name>View` binding.
72
+
73
+ Returns:
74
+ True when the arrow's body opens with `{`.
75
+ """
76
+ depth = 0
77
+ index = start
78
+ while index < len(source) - 1:
79
+ char = source[index]
80
+ if char in "([{":
81
+ depth += 1
82
+ elif char in ")]}":
83
+ depth -= 1
84
+ elif char == ";" and depth == 0:
85
+ return False
86
+ elif depth == 0 and source[index : index + 2] == "=>":
87
+ rest = source[index + 2 :].lstrip()
88
+ return rest.startswith("{")
89
+ index += 1
90
+ return False
91
+
92
+
93
+ def view_form_errors(component_name: str, view_content: str) -> list[str]:
94
+ """
95
+ Errors about the FORM of the View component.
96
+
97
+ The requirement is an arrow function with an expression body. A function
98
+ declaration cannot have one, so declaration form is banned by construction
99
+ rather than by scanning for the word "function" — which would fire on JSDoc
100
+ prose, on local render helpers, and on disable-comment justifications, none
101
+ of which are the View component.
102
+
103
+ Args:
104
+ component_name: The component directory's name, without the View suffix.
105
+ view_content: The View file's text.
106
+
107
+ Returns:
108
+ Zero or one error message.
109
+ """
110
+ name = f"{component_name}View"
111
+ expected = f"const {name} = (props) => (...)"
112
+
113
+ if re.search(rf"\bfunction\s+{re.escape(name)}\b", view_content):
114
+ return [
115
+ f"View must be an arrow function with an expression body: {expected}. "
116
+ "A function declaration cannot have one, so it always carries a statement list."
117
+ ]
118
+
119
+ function_expression = re.search(
120
+ rf"\bconst\s+{re.escape(name)}\b[^=]*=\s*function\b", view_content
121
+ )
122
+ if function_expression:
123
+ return [
124
+ f"View must be an arrow function with an expression body: {expected}. "
125
+ "A function expression cannot have one."
126
+ ]
127
+
128
+ binding = re.search(rf"\bconst\s+{re.escape(name)}\b", view_content)
129
+ if binding and _arrow_body_is_block(view_content, binding.end()):
130
+ return [
131
+ "View should use arrow function shorthand: () => (...) instead of () => { return (...) }"
132
+ ]
133
+
134
+ return []
135
+
136
+
137
+ def validate_component(component_path: str) -> tuple[bool, list[str], list[str]]:
29
138
  """
30
139
  Validate a component directory follows Container/View pattern.
31
140
 
141
+ Errors and advisories are separated because one check here cannot decide
142
+ what it reports on — see the Container JSX scan below. A check that cannot
143
+ tell a violation from a legitimate shape must say so rather than fail the
144
+ run; a confident wrong verdict is worse than a flagged uncertainty.
145
+
32
146
  Args:
33
147
  component_path: Path to the component directory
34
148
 
35
149
  Returns:
36
- Tuple of (is_valid, list of error messages)
150
+ Tuple of (is_valid, list of error messages, list of advisory messages)
37
151
  """
38
152
  errors = []
153
+ advisories = []
39
154
  component_dir = Path(component_path)
40
155
 
41
156
  if not component_dir.is_dir():
42
- return False, [f"Path is not a directory: {component_path}"]
157
+ return False, [f"Path is not a directory: {component_path}"], advisories
43
158
 
44
159
  component_name = component_dir.name
45
160
 
@@ -92,39 +207,24 @@ def validate_component(component_path: str) -> tuple[bool, list[str]]:
92
207
  if not re.search(display_name_pattern, view_content):
93
208
  errors.append(f"View should have displayName: {component_name}View.displayName = \"{component_name}View\"")
94
209
 
95
- # Check for block body (return statement) in main component
96
- # This is a simplified check - ESLint does the full validation
97
- block_body_pattern = rf"const\s+{component_name}View\s*=\s*\([^)]*\)\s*=>\s*{{"
98
- if re.search(block_body_pattern, view_content):
99
- errors.append("View should use arrow function shorthand: () => (...) instead of () => { return (...) }")
100
-
101
- # Check for hooks in View (they should be in Container)
102
- hook_patterns = [
103
- r"\buse[A-Z]\w+\s*\(", # General hook pattern
104
- r"\buseState\s*\(",
105
- r"\buseEffect\s*\(",
106
- r"\buseMemo\s*\(",
107
- r"\buseCallback\s*\(",
108
- r"\buseReducer\s*\(",
109
- r"\buseContext\s*\(",
110
- ]
111
-
112
- for hook_pattern in hook_patterns:
113
- # Exclude memo import check
114
- if hook_pattern == r"\buse[A-Z]\w+\s*\(":
115
- # More specific check to avoid false positives
116
- if re.search(r"\buseState\s*\(", view_content):
117
- errors.append("View should not contain useState - move to Container")
118
- break
119
- if re.search(r"\buseEffect\s*\(", view_content):
120
- errors.append("View should not contain useEffect - move to Container")
121
- break
122
- if re.search(r"\buseMemo\s*\(", view_content):
123
- errors.append("View should not contain useMemo - move to Container")
124
- break
125
- if re.search(r"\buseCallback\s*\(", view_content):
126
- errors.append("View should not contain useCallback - move to Container")
127
- break
210
+ # Check the View component's form.
211
+ #
212
+ # The old check was `const {name}View = ([^)]*) => {` — arrow-only, and
213
+ # therefore blind to `function {name}View(...) {`, independently
214
+ # reproducing the exact defect the ESLint rule carried. It also broke on
215
+ # any parameter list containing a nested paren.
216
+ errors.extend(view_form_errors(component_name, view_content))
217
+
218
+ # Check for hooks in View (they should be in Container).
219
+ #
220
+ # One generic shape match, deliberately. The previous version listed
221
+ # `\buse[A-Z]\w+\s*\(` first and then narrowed to four hardcoded names
222
+ # and `break`ed, so the general pattern was UNREACHABLE — dead code that
223
+ # read as coverage. A project-local hook such as
224
+ # `useCreateNoteQuickActionEnabled()` matched the pattern that was never
225
+ # evaluated and none of the four that were.
226
+ for hook in sorted(set(HOOK_CALL.findall(view_content))):
227
+ errors.append(f"View should not contain {hook} - move to Container")
128
228
 
129
229
  # Validate Container file
130
230
  if container_file.exists():
@@ -140,14 +240,31 @@ def validate_component(component_path: str) -> tuple[bool, list[str]]:
140
240
  if not re.search(return_view_pattern, container_content):
141
241
  errors.append(f"Container should return <{component_name}View />")
142
242
 
143
- # Check that Container ONLY renders View (no other JSX elements)
144
- # Find all JSX tags in Container (excluding the View)
243
+ # ADVISORY, not an error — and deliberately so.
244
+ #
245
+ # "Container should ONLY render View" is a naive `<([A-Z]\w*)` scan over
246
+ # the ENTIRE container file. It cannot tell a component's returned JSX
247
+ # tree from JSX inside a callback, so a Container holding a perfectly
248
+ # legitimate `renderItem` — `useCallback(({item}) => <Row … />, […])` —
249
+ # trips it.
250
+ #
251
+ # That matters now that Views may not call hooks: moving a renderItem
252
+ # callback to the Container is one of the two sanctioned fixes, and
253
+ # Containers ARE allowed to hold logic, including a callback that
254
+ # returns JSX. Left as an error, this check would reject the very
255
+ # migration the View rules ask for.
256
+ #
257
+ # So it reports and does not fail. Scoping it to the returned tree needs
258
+ # a parser, which a regex pre-check is not; until then, saying plainly
259
+ # what it cannot distinguish beats a confident wrong verdict.
145
260
  all_jsx_pattern = r"<([A-Z][a-zA-Z0-9]*)"
146
261
  jsx_matches = re.findall(all_jsx_pattern, container_content)
147
262
  other_components = [m for m in jsx_matches if m != f"{component_name}View"]
148
263
  if other_components:
149
- errors.append(
150
- f"Container should ONLY render {component_name}View, but found: {', '.join(set(other_components))}"
264
+ advisories.append(
265
+ f"Container renders JSX besides {component_name}View: {', '.join(sorted(set(other_components)))}. "
266
+ "This is a whole-file text scan and cannot tell a returned JSX tree from JSX inside a callback, "
267
+ "so a legitimate renderItem callback lands here too. Review it; this does not fail validation."
151
268
  )
152
269
 
153
270
  # Check for extra files
@@ -178,7 +295,7 @@ def validate_component(component_path: str) -> tuple[bool, list[str]]:
178
295
  if not is_allowed and item.is_file():
179
296
  errors.append(f"Unexpected file in component directory: {item.name}")
180
297
 
181
- return len(errors) == 0, errors
298
+ return len(errors) == 0, errors, advisories
182
299
 
183
300
 
184
301
  def main():
@@ -193,7 +310,13 @@ def main():
193
310
  print(f"Validating component: {component_path}")
194
311
  print("-" * 50)
195
312
 
196
- is_valid, errors = validate_component(component_path)
313
+ is_valid, errors, advisories = validate_component(component_path)
314
+
315
+ if advisories:
316
+ print("Advisories (reviewed by a human, not a failure):")
317
+ for advisory in advisories:
318
+ print(f" ~ {advisory}")
319
+ print("-" * 50)
197
320
 
198
321
  if is_valid:
199
322
  print("Component follows Container/View pattern")
@@ -43,9 +43,16 @@ The Container/View pattern is **required** in these directories:
43
43
  Run the skill's generator script for any component type:
44
44
 
45
45
  ```bash
46
- python3 .claude/skills/container-view-pattern/scripts/create_component.py <type> <name> [feature]
46
+ python3 "${CLAUDE_PLUGIN_ROOT:-node_modules/@codyswann/lisa/plugins/lisa-expo}/skills/container-view-pattern/scripts/create_component.py" <type> <name> [feature]
47
47
  ```
48
48
 
49
+ The `:-` default is load-bearing. This skill ships inside the Lisa plugin, not in
50
+ the consumer's `.claude/skills/`, and `CLAUDE_PLUGIN_ROOT` is not exported into
51
+ an agent's Bash environment — so a bare `.claude/skills/…` path names a file that
52
+ does not exist in a consumer, and a bare `"${CLAUDE_PLUGIN_ROOT}/…"` expands to
53
+ an absolute `/skills/…` and exits 1. The default resolves from the consumer
54
+ repository root, which is where the script is actually invoked.
55
+
49
56
  **Component Types:**
50
57
 
51
58
  | Type | Command | Creates in |
@@ -71,7 +78,7 @@ ComponentName/
71
78
 
72
79
  Container components handle all business logic:
73
80
 
74
- 1. **Single View render**: Container must ONLY render its corresponding View component - no other UI elements or components
81
+ 1. **Single View render**: A Container's **returned tree** is only its corresponding View component — no other UI elements beside it. This constrains what the Container renders, not what it holds: a Container may define a callback that returns JSX (a `renderItem` passed down as a prop is the common case), and that is where such a callback belongs once a View may no longer call hooks.
75
82
  2. **State management**: Use `useState`, `useReducer`
76
83
  3. **Data fetching**: Use GraphQL hooks, API calls
77
84
  4. **Memoization**: Wrap all computed values in `useMemo`
@@ -146,15 +153,33 @@ export default ComponentNameContainer;
146
153
 
147
154
  ## View Component Requirements
148
155
 
149
- View components are pure presentation:
150
-
151
- 1. **Arrow function shorthand**: Use `() => (...)` not `() => { return (...); }`
152
- 2. **No return statements**: The component body must be a single JSX expression
156
+ View components carry **no statements and no hooks**. That is the guarantee, and
157
+ it is deliberately narrower than "pure": `{Date.now()}` and
158
+ `{Math.random() > 0.5 ? … : …}` sit happily inside an expression body and no
159
+ rule below rejects them. Claiming purity here would repeat the defect this
160
+ section was rewritten to fix — see [Validation](#validation).
161
+
162
+ 1. **Expression-bodied arrow function**: The View component must be
163
+ `const XView = (props) => (...)`. Not `() => { return (...); }`, and not
164
+ `function XView(props) { ... }` — a function declaration cannot have an
165
+ expression body, so declaration form always carries a statement list. This is
166
+ the requirement; the other two shapes are the two ways of failing it.
167
+ 2. **No statements**: The component body is a single JSX expression.
153
168
  3. **memo wrapper**: Export with `memo()` for performance optimization
154
169
  4. **displayName**: Set `ComponentName.displayName = "ComponentName"`
155
170
  5. **Readonly props**: All props should be marked as `readonly`
156
- 6. **No hooks**: View should not contain `useState`, `useEffect`, `useMemo`, etc.
157
- 7. **No logic**: All conditional rendering should use ternary expressions in JSX
171
+ 6. **No hooks anywhere in the file**: No call of the shape `use` + an uppercase
172
+ letter — `useState`, `useEffect`, `useMemo`, `useCallback`, and every
173
+ project-local custom hook alike. The ban is on the shape, not on a list of
174
+ names: the call that motivated this gate was a project-local hook that no
175
+ name list would have contained. `useMemo` and `useCallback` are caught for
176
+ the same reason — a data hook and a render helper are indistinguishable by
177
+ name, and this document already forbade `useMemo` in a View. There is no
178
+ exemption list, no per-site waiver, and no opt-out flag; a View that calls a
179
+ hook is fixed by moving the hook into the Container.
180
+ 7. **No logic**: All conditional rendering should use ternary expressions in JSX.
181
+ Unlike 1, 2 and 6, this one is **not lint-enforced** — see the second table
182
+ under [Validation](#validation).
158
183
 
159
184
  ### View Template
160
185
 
@@ -207,23 +232,47 @@ export { default } from "./ComponentNameContainer";
207
232
 
208
233
  ### ESLint Rules
209
234
 
210
- The following ESLint rules enforce the pattern:
235
+ Each row names the numbered requirement it enforces. Every requirement above
236
+ appears in exactly one of the two tables, so a requirement with no rule is
237
+ visible as such rather than implied to be covered.
238
+
239
+ For most of this skill's life the table below listed four rules under the
240
+ heading "the following ESLint rules enforce the pattern" — and none of them
241
+ enforced requirement 6 or 7. That false assurance is why nobody checked, and why
242
+ a declaration-form View with a hook in it passed lint for years.
243
+
244
+ | Rule | Enforces | Description |
245
+ | ------------------------------------------------- | --------- | ----------------------------------------------------------------- |
246
+ | `component-structure/enforce-component-structure` | structure | Validates directory structure and file naming |
247
+ | `component-structure/no-return-in-view` | 1, 2 | View must be an arrow function with an expression body |
248
+ | `component-structure/no-hooks-in-view` | 6 | No `use[A-Z]…()` call anywhere in a View file, by shape not name |
249
+ | `no-restricted-imports` (View files) | 6 | No React hook named-import, and no `**/hooks/**` import |
250
+ | `component-structure/require-memo-in-view` | 3, 4 | Ensures View uses memo and displayName |
251
+ | `component-structure/single-component-per-file` | structure | One component per file |
211
252
 
212
- | Rule | Description |
213
- | ------------------------------------------------- | --------------------------------------------- |
214
- | `component-structure/enforce-component-structure` | Validates directory structure and file naming |
215
- | `component-structure/no-return-in-view` | Ensures View uses arrow shorthand |
216
- | `component-structure/require-memo-in-view` | Ensures View uses memo and displayName |
217
- | `component-structure/single-component-per-file` | One component per file |
253
+ `no-hooks-in-view` and `no-restricted-imports` are two independent axes, not one
254
+ duplicated: a hook re-exported through a barrel escapes the import patterns, and
255
+ an import with no call site yet escapes the call matcher.
256
+
257
+ ### What lint does NOT enforce
258
+
259
+ | Requirement | Why not |
260
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------- |
261
+ | 5 — readonly props | A convention on the props interface; carried by review and the templates above. |
262
+ | 7 — no logic beyond ternaries | "Logic" has no AST shape. `{Date.now()}` and `{Math.random() > 0.5 ? … : …}` are legal expression bodies. |
218
263
 
219
264
  ### Manual Validation
220
265
 
221
266
  Run the validation script to check a component:
222
267
 
223
268
  ```bash
224
- python3 .claude/skills/container-view-pattern/scripts/validate_component.py <path-to-component-directory>
269
+ python3 "${CLAUDE_PLUGIN_ROOT:-node_modules/@codyswann/lisa/plugins/lisa-expo}/skills/container-view-pattern/scripts/validate_component.py" <path-to-component-directory>
225
270
  ```
226
271
 
272
+ The script is a fast pre-check, not the gate: it reads one component directory
273
+ with regexes, while ESLint reads the AST of every file. When they disagree,
274
+ ESLint is right.
275
+
227
276
  Run ESLint to check all components:
228
277
 
229
278
  ```bash
@@ -238,7 +287,7 @@ bun run lint
238
287
 
239
288
  | Issue | Resolution |
240
289
  | ------------------------------------ | ----------------------------------------------------------- |
241
- | Rendering UI elements besides View | Container must ONLY return the corresponding View component |
290
+ | Returning UI elements besides View | Container's returned tree is ONLY the corresponding View |
242
291
  | Rendering multiple components | Move all UI to View; Container returns only View |
243
292
  | Missing `useMemo` for objects/arrays | Wrap computed values in `useMemo` |
244
293
  | Missing `useCallback` for functions | Wrap handlers in `useCallback` |
@@ -247,13 +296,14 @@ bun run lint
247
296
 
248
297
  ### View Violations
249
298
 
250
- | Issue | Resolution |
251
- | ----------------------------- | ------------------------------------------------- |
252
- | Using block body `{ return }` | Convert to arrow shorthand `() => (...)` |
253
- | Missing `memo` wrapper | Add `export default memo(ComponentView)` |
254
- | Missing `displayName` | Add `ComponentView.displayName = "ComponentView"` |
255
- | Contains hooks | Move hooks to Container |
256
- | Contains state | Move state to Container |
299
+ | Issue | Resolution |
300
+ | ------------------------------------ | ------------------------------------------------- |
301
+ | Using block body `{ return }` | Convert to arrow shorthand `() => (...)` |
302
+ | Declared as `function XView() {…}` | Convert to `const XView = (props) => (...)` |
303
+ | Missing `memo` wrapper | Add `export default memo(ComponentView)` |
304
+ | Missing `displayName` | Add `ComponentView.displayName = "ComponentView"` |
305
+ | Contains hooks (including custom) | Move hooks to Container |
306
+ | Contains state | Move state to Container |
257
307
 
258
308
  ## Extracting Helper Functions
259
309
 
@@ -275,6 +325,12 @@ const ComponentView = ({ isLoading, isDark }: Props) => (
275
325
  );
276
326
  ```
277
327
 
328
+ A local render helper may be a function declaration. Requirement 1 is about the
329
+ **View component**, not about the word `function` appearing in the file — which
330
+ is why it is stated as "expression-bodied arrow" rather than as a ban on
331
+ declarations. The helper is still inside a View file, so requirement 6 applies to
332
+ it: it may not call a hook either.
333
+
278
334
  ## Event Handler Naming Convention
279
335
 
280
336
  - **Container**: Use `handle*` prefix (e.g., `handleSubmit`, `handleClick`)