@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 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
- Both cover `import` and re-`export … from` forms.
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.1",
4
- "description": "Cratis Components ESLint rules: import from subpaths, not the root barrel, and use the Cratis dialog wrappers instead of primereact/dialog. Compose on top of @cratis/eslint-config.",
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",