@cratis/eslint-plugin-components 2.4.1 → 2.5.1
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/README.md +56 -1
- package/index.js +7 -1
- package/lib/noHooksInViewModel.js +122 -0
- package/lib/onbeforeexecuteMustReturn.js +107 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,8 +7,10 @@ Cratis base config, [`@cratis/eslint-config`](https://www.npmjs.com/package/@cra
|
|
|
7
7
|
|---|---|
|
|
8
8
|
| `no-root-barrel-import` | Disallows importing from the `@cratis/components` root barrel. Use a subpath export (`@cratis/components/CommandDialog`, `@cratis/components/DataPage`, `@cratis/components/Toolbar`, …) — the root pulls the whole optional-peer-heavy surface and hides intent. |
|
|
9
9
|
| `no-primereact-dialog` | Disallows importing `Dialog` from `primereact/dialog`. Use `CommandDialog` from `@cratis/components/CommandDialog`, or `Dialog` from `@cratis/components/Dialogs` — the wrappers add Arc command binding, overlay/focus fixes, and theming. |
|
|
10
|
+
| `onbeforeexecute-must-return` | Requires an `onBeforeExecute` callback to return the command values. `onBeforeExecute` is a transformer — a body that can complete without returning executes the command with `undefined` (silent data loss). |
|
|
11
|
+
| `no-hooks-in-view-model` | Disallows React hooks (including generated Arc proxies' `.use()`) inside a view model class. View models must be plain, hook-free classes that receive injected abstractions. |
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
The two import rules cover `import` and re-`export … from` forms.
|
|
12
14
|
|
|
13
15
|
## Install
|
|
14
16
|
|
|
@@ -41,3 +43,56 @@ export default [
|
|
|
41
43
|
source: 'primereact/dialog', // module to forbid
|
|
42
44
|
}],
|
|
43
45
|
```
|
|
46
|
+
|
|
47
|
+
`onbeforeexecute-must-return` and `no-hooks-in-view-model` take no options.
|
|
48
|
+
|
|
49
|
+
## Rules
|
|
50
|
+
|
|
51
|
+
### `onbeforeexecute-must-return`
|
|
52
|
+
|
|
53
|
+
`onBeforeExecute` (on `CommandDialog`, `StepperCommandDialog`, `CommandScope`, …) is a
|
|
54
|
+
**transformer**: it receives the current command values and must **return** them (mutated or
|
|
55
|
+
not). A callback that returns nothing runs the command with `undefined` — every field is
|
|
56
|
+
wiped at submit time. TypeScript catches this at fully-typed call sites; this rule is the
|
|
57
|
+
backstop for JavaScript and loosely-typed consumers.
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// ✅ returns the values
|
|
61
|
+
<CommandDialog onBeforeExecute={values => { values.id = Guid.create(); return values; }} />
|
|
62
|
+
<CommandDialog onBeforeExecute={values => values} />
|
|
63
|
+
|
|
64
|
+
// ❌ side-effect only — command executes with undefined
|
|
65
|
+
<CommandDialog onBeforeExecute={values => { values.id = Guid.create(); }} />
|
|
66
|
+
// ❌ bare return
|
|
67
|
+
<CommandDialog onBeforeExecute={values => { return; }} />
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
It flags JSX attribute (`onBeforeExecute={…}`), object property (`{ onBeforeExecute: … }`),
|
|
71
|
+
and variable (`const onBeforeExecute = …`) forms. It is a lint backstop, not a full
|
|
72
|
+
control-flow analysis: a callback that returns a value on some branches but can still fall
|
|
73
|
+
through is not flagged.
|
|
74
|
+
|
|
75
|
+
### `no-hooks-in-view-model`
|
|
76
|
+
|
|
77
|
+
A view model is plain TypeScript — it must never call React hooks. `react-hooks/rules-of-hooks`
|
|
78
|
+
does not reliably flag hooks called from **class methods**, so this rule fills the gap for
|
|
79
|
+
Cratis MVVM view models. Inject an abstraction (`IIdentityProvider`, `IMessenger`, a query
|
|
80
|
+
service) instead of calling a hook.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// ❌ generated Arc proxy hook inside a view model
|
|
84
|
+
class AuthorsViewModel {
|
|
85
|
+
load() { const [authors] = AllAuthors.use(); } // flagged
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ✅ inject the abstraction; call hooks only in the component
|
|
89
|
+
@injectable
|
|
90
|
+
class AuthorsViewModel {
|
|
91
|
+
constructor(private readonly identity: IIdentityProvider) {}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A class is treated as a view model when it is registered via `withViewModel(...)`, decorated
|
|
96
|
+
with `@injectable`, or named `*ViewModel`. Both bare hooks (`useState`, `useIdentity`, …) and
|
|
97
|
+
proxy member hooks (`.use()`, `.useSuspense()`, `.useChangeStream()`) are flagged. Hooks
|
|
98
|
+
inside a nested non–view-model class are not.
|
package/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { noPrimereactDialog } from './lib/noPrimereactDialog.js';
|
|
3
3
|
import { noRootBarrelImport } from './lib/noRootBarrelImport.js';
|
|
4
|
+
import { onbeforeexecuteMustReturn } from './lib/onbeforeexecuteMustReturn.js';
|
|
5
|
+
import { noHooksInViewModel } from './lib/noHooksInViewModel.js';
|
|
4
6
|
|
|
5
7
|
const { version } = createRequire(import.meta.url)('./package.json');
|
|
6
8
|
|
|
@@ -13,6 +15,8 @@ const plugin = {
|
|
|
13
15
|
rules: {
|
|
14
16
|
'no-primereact-dialog': noPrimereactDialog,
|
|
15
17
|
'no-root-barrel-import': noRootBarrelImport,
|
|
18
|
+
'onbeforeexecute-must-return': onbeforeexecuteMustReturn,
|
|
19
|
+
'no-hooks-in-view-model': noHooksInViewModel,
|
|
16
20
|
},
|
|
17
21
|
configs: {},
|
|
18
22
|
};
|
|
@@ -31,6 +35,8 @@ Object.assign(plugin.configs, {
|
|
|
31
35
|
rules: {
|
|
32
36
|
'@cratis/components/no-primereact-dialog': 'error',
|
|
33
37
|
'@cratis/components/no-root-barrel-import': 'error',
|
|
38
|
+
'@cratis/components/onbeforeexecute-must-return': 'error',
|
|
39
|
+
'@cratis/components/no-hooks-in-view-model': 'error',
|
|
34
40
|
},
|
|
35
41
|
},
|
|
36
42
|
],
|
|
@@ -38,4 +44,4 @@ Object.assign(plugin.configs, {
|
|
|
38
44
|
|
|
39
45
|
export default plugin;
|
|
40
46
|
export const { configs, rules, meta } = plugin;
|
|
41
|
-
export { noPrimereactDialog, noRootBarrelImport };
|
|
47
|
+
export { noPrimereactDialog, noRootBarrelImport, onbeforeexecuteMustReturn, noHooksInViewModel };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const HOOK_IDENTIFIER = /^use[A-Z0-9]/;
|
|
2
|
+
// Matches a `.use(...)` / `.useSuspense(...)` / `.useChangeStream(...)` member call — the
|
|
3
|
+
// shape of a generated Arc query/command proxy hook — as well as bare `useX()` hooks.
|
|
4
|
+
const HOOK_MEMBER = /^use([A-Z0-9]|$)/;
|
|
5
|
+
|
|
6
|
+
const decoratorName = (decorator) => {
|
|
7
|
+
const expression = decorator.expression;
|
|
8
|
+
if (!expression) return undefined;
|
|
9
|
+
if (expression.type === 'Identifier') return expression.name;
|
|
10
|
+
if (expression.type === 'CallExpression' && expression.callee?.type === 'Identifier') {
|
|
11
|
+
return expression.callee.name;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const hasInjectableDecorator = (classNode) => {
|
|
17
|
+
const decorators = classNode.decorators ?? [];
|
|
18
|
+
return decorators.some((decorator) => decoratorName(decorator) === 'injectable');
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const isHookCall = (node) => {
|
|
22
|
+
const callee = node.callee;
|
|
23
|
+
if (!callee) return false;
|
|
24
|
+
if (callee.type === 'Identifier') return HOOK_IDENTIFIER.test(callee.name);
|
|
25
|
+
if (callee.type === 'MemberExpression' && callee.property?.type === 'Identifier' && !callee.computed) {
|
|
26
|
+
return HOOK_MEMBER.test(callee.property.name);
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Walk a class body for hook calls, without descending into a nested class (a different
|
|
32
|
+
// subject) — but still descend through the view model's own methods and getters.
|
|
33
|
+
const forEachHookCall = (node, visit) => {
|
|
34
|
+
if (!node || typeof node.type !== 'string') return;
|
|
35
|
+
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') return;
|
|
36
|
+
if (node.type === 'CallExpression' && isHookCall(node)) {
|
|
37
|
+
visit(node);
|
|
38
|
+
}
|
|
39
|
+
for (const key of Object.keys(node)) {
|
|
40
|
+
if (key === 'parent') continue;
|
|
41
|
+
const child = node[key];
|
|
42
|
+
if (Array.isArray(child)) {
|
|
43
|
+
for (const item of child) forEachHookCall(item, visit);
|
|
44
|
+
} else {
|
|
45
|
+
forEachHookCall(child, visit);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Forbid React hooks (including generated Arc proxies' `.use()`) inside a class that is a
|
|
51
|
+
// view model — one registered via `withViewModel`, decorated with `@injectable`, or named
|
|
52
|
+
// `*ViewModel`. `react-hooks/rules-of-hooks` does not reliably flag hooks called from class
|
|
53
|
+
// methods; a view model must be plain, hook-free TypeScript and receive injected
|
|
54
|
+
// abstractions instead.
|
|
55
|
+
export const noHooksInViewModel = {
|
|
56
|
+
meta: {
|
|
57
|
+
type: 'problem',
|
|
58
|
+
docs: {
|
|
59
|
+
description: 'Disallow React hooks (including Arc proxy .use()) inside a view model class.',
|
|
60
|
+
recommended: true,
|
|
61
|
+
url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md',
|
|
62
|
+
},
|
|
63
|
+
schema: [],
|
|
64
|
+
messages: {
|
|
65
|
+
noHook: "Do not call the hook '{{name}}' inside a view model. View models must be plain, hook-free classes — inject the abstraction (IIdentityProvider, IMessenger, a query service) instead.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
const registeredViewModels = new Set();
|
|
70
|
+
const classNodes = [];
|
|
71
|
+
|
|
72
|
+
const nameOfCallee = (callee) => {
|
|
73
|
+
if (!callee) return undefined;
|
|
74
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
75
|
+
if (callee.type === 'MemberExpression' && callee.property?.type === 'Identifier') {
|
|
76
|
+
return callee.property.name;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const hookName = (node) => {
|
|
82
|
+
const callee = node.callee;
|
|
83
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
84
|
+
if (callee.type === 'MemberExpression' && callee.property?.type === 'Identifier') {
|
|
85
|
+
return `.${callee.property.name}`;
|
|
86
|
+
}
|
|
87
|
+
return 'hook';
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const collectClass = (node) => {
|
|
91
|
+
classNodes.push(node);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
CallExpression(node) {
|
|
96
|
+
if (nameOfCallee(node.callee) !== 'withViewModel') return;
|
|
97
|
+
const first = node.arguments[0];
|
|
98
|
+
if (first?.type === 'Identifier') {
|
|
99
|
+
registeredViewModels.add(first.name);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
ClassDeclaration: collectClass,
|
|
103
|
+
ClassExpression: collectClass,
|
|
104
|
+
'Program:exit'() {
|
|
105
|
+
for (const classNode of classNodes) {
|
|
106
|
+
const name = classNode.id?.name;
|
|
107
|
+
const isViewModel =
|
|
108
|
+
(name && /ViewModel$/.test(name)) ||
|
|
109
|
+
hasInjectableDecorator(classNode) ||
|
|
110
|
+
(name && registeredViewModels.has(name));
|
|
111
|
+
if (!isViewModel) continue;
|
|
112
|
+
|
|
113
|
+
forEachHookCall(classNode.body, (call) => {
|
|
114
|
+
context.report({ node: call, messageId: 'noHook', data: { name: hookName(call) } });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export default noHooksInViewModel;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Collect every `return` statement that belongs directly to `functionBody`, without
|
|
2
|
+
// descending into nested functions (their returns are not this function's returns) or into
|
|
3
|
+
// a return's own argument (which may itself contain a nested arrow).
|
|
4
|
+
const collectOwnReturns = (node, returns) => {
|
|
5
|
+
if (!node || typeof node.type !== 'string') return;
|
|
6
|
+
if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (node.type === 'ReturnStatement') {
|
|
10
|
+
returns.push(node);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
for (const key of Object.keys(node)) {
|
|
14
|
+
if (key === 'parent') continue;
|
|
15
|
+
const child = node[key];
|
|
16
|
+
if (Array.isArray(child)) {
|
|
17
|
+
for (const item of child) collectOwnReturns(item, returns);
|
|
18
|
+
} else {
|
|
19
|
+
collectOwnReturns(child, returns);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const keyNameOf = (key) => {
|
|
25
|
+
if (!key) return undefined;
|
|
26
|
+
if (key.type === 'Identifier') return key.name;
|
|
27
|
+
if (key.type === 'Literal') return String(key.value);
|
|
28
|
+
return undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// True when `node` is the function assigned to an `onBeforeExecute` binding — a JSX
|
|
32
|
+
// attribute (`onBeforeExecute={...}`), an object property (`{ onBeforeExecute: ... }`), or a
|
|
33
|
+
// variable (`const onBeforeExecute = ...`).
|
|
34
|
+
const isOnBeforeExecuteCallback = (node) => {
|
|
35
|
+
const parent = node.parent;
|
|
36
|
+
if (!parent) return false;
|
|
37
|
+
|
|
38
|
+
if (parent.type === 'JSXExpressionContainer' &&
|
|
39
|
+
parent.parent?.type === 'JSXAttribute' &&
|
|
40
|
+
parent.parent.name?.name === 'onBeforeExecute') {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ((parent.type === 'Property' || parent.type === 'PropertyDefinition') &&
|
|
45
|
+
parent.value === node &&
|
|
46
|
+
keyNameOf(parent.key) === 'onBeforeExecute') {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (parent.type === 'VariableDeclarator' &&
|
|
51
|
+
parent.init === node &&
|
|
52
|
+
parent.id?.type === 'Identifier' &&
|
|
53
|
+
parent.id.name === 'onBeforeExecute') {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Require an `onBeforeExecute` callback to return the command values on every path. It is a
|
|
61
|
+
// transformer, not a side-effect hook: a body that can complete without returning executes
|
|
62
|
+
// the command with `undefined` (silent data loss). A lint backstop for consumers whose call
|
|
63
|
+
// sites are not fully typed — it flags a block-bodied callback that returns nothing, or a
|
|
64
|
+
// bare `return;`. It does not do full path analysis, so a callback that returns a value on
|
|
65
|
+
// some branches but can still fall through is not flagged.
|
|
66
|
+
export const onbeforeexecuteMustReturn = {
|
|
67
|
+
meta: {
|
|
68
|
+
type: 'problem',
|
|
69
|
+
docs: {
|
|
70
|
+
description: 'Require an onBeforeExecute callback to return the command values; returning nothing executes the command with undefined.',
|
|
71
|
+
recommended: true,
|
|
72
|
+
url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md',
|
|
73
|
+
},
|
|
74
|
+
schema: [],
|
|
75
|
+
messages: {
|
|
76
|
+
missingReturn: 'onBeforeExecute is a transformer and must return the command values. This callback can complete without returning, which executes the command with undefined values. Return the values (mutated or not).',
|
|
77
|
+
emptyReturn: 'onBeforeExecute must return the command values, not `return;`. Return the values (mutated or not).',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
create(context) {
|
|
81
|
+
const check = (node) => {
|
|
82
|
+
if (!isOnBeforeExecuteCallback(node)) return;
|
|
83
|
+
// An expression-bodied arrow (`values => values`) always returns a value.
|
|
84
|
+
if (node.type === 'ArrowFunctionExpression' && node.body.type !== 'BlockStatement') return;
|
|
85
|
+
|
|
86
|
+
const returns = [];
|
|
87
|
+
collectOwnReturns(node.body, returns);
|
|
88
|
+
|
|
89
|
+
const emptyReturn = returns.find((statement) => !statement.argument);
|
|
90
|
+
if (emptyReturn) {
|
|
91
|
+
context.report({ node: emptyReturn, messageId: 'emptyReturn' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (returns.length === 0) {
|
|
96
|
+
context.report({ node, messageId: 'missingReturn' });
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
ArrowFunctionExpression: check,
|
|
102
|
+
FunctionExpression: check,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export default onbeforeexecuteMustReturn;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cratis/eslint-plugin-components",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Cratis Components ESLint rules: import from subpaths
|
|
3
|
+
"version": "2.5.1",
|
|
4
|
+
"description": "Cratis Components ESLint rules: import from subpaths not the root barrel, use the Cratis dialog wrappers instead of primereact/dialog, require onBeforeExecute callbacks to return their values, and keep React hooks out of view models. Compose on top of @cratis/eslint-config.",
|
|
5
5
|
"author": "Cratis",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|