@cratis/eslint-plugin-arc 0.0.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 +42 -0
- package/index.js +43 -0
- package/lib/noHooksInViewModel.js +70 -0
- package/lib/skipGeneratedProxies.js +38 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @cratis/eslint-plugin-arc
|
|
2
|
+
|
|
3
|
+
ESLint rules for projects that consume Cratis Arc. Compose these on top of the Cratis
|
|
4
|
+
base config, [`@cratis/eslint-config`](https://www.npmjs.com/package/@cratis/eslint-config).
|
|
5
|
+
|
|
6
|
+
| Rule / processor | What it does |
|
|
7
|
+
|---|---|
|
|
8
|
+
| `skip-generated-proxies` (processor) | Skips Arc-generated proxy files wholesale. They carry a `// @generated by Cratis` header (and a `**DO NOT EDIT**` banner), cannot be edited, and are regenerated by the build — so any finding on them is un-actionable. Keyed on the header because proxies sit intermixed with hand-written `.ts`. |
|
|
9
|
+
| `no-hooks-in-view-model` | Disallows React hook calls inside MVVM view models (classes named `*ViewModel`). A view model must be a plain, React-free class; inject Cratis abstractions instead of calling hooks. |
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
yarn add -D @cratis/eslint-plugin-arc @cratis/eslint-config eslint
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// eslint.config.mjs
|
|
21
|
+
import cratis from '@cratis/eslint-config';
|
|
22
|
+
import arc from '@cratis/eslint-plugin-arc';
|
|
23
|
+
|
|
24
|
+
export default [
|
|
25
|
+
...cratis.configs.consumer,
|
|
26
|
+
...arc.configs.recommended,
|
|
27
|
+
// …your project rules
|
|
28
|
+
];
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### `no-hooks-in-view-model` options
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
'@cratis/arc/no-hooks-in-view-model': ['error', {
|
|
35
|
+
classSuffix: 'ViewModel', // class-name suffix that marks a view model
|
|
36
|
+
hookPattern: '^use[A-Z]', // bare-identifier calls treated as hooks
|
|
37
|
+
additionalHooks: ['injectQuery'], // extra call names to forbid
|
|
38
|
+
}]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Only bare-identifier calls (`useState(...)`) are flagged — member calls like
|
|
42
|
+
`this.useDefaults()` are not, so view-model methods that merely start with `use` are safe.
|
package/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { noHooksInViewModel } from './lib/noHooksInViewModel.js';
|
|
3
|
+
import { isGeneratedProxy, skipGeneratedProxies } from './lib/skipGeneratedProxies.js';
|
|
4
|
+
|
|
5
|
+
const { version } = createRequire(import.meta.url)('./package.json');
|
|
6
|
+
|
|
7
|
+
// A single flat-config plugin object — meta + rules + processors + self-referencing
|
|
8
|
+
// configs — per the ESLint flat-config plugin convention. The default export IS the
|
|
9
|
+
// plugin, so consumers get `arc.meta`, `arc.rules`, `arc.processors`, and `arc.configs`
|
|
10
|
+
// directly. Composes on top of @cratis/eslint-config.
|
|
11
|
+
const plugin = {
|
|
12
|
+
meta: { name: '@cratis/eslint-plugin-arc', version },
|
|
13
|
+
rules: { 'no-hooks-in-view-model': noHooksInViewModel },
|
|
14
|
+
processors: { 'skip-generated-proxies': skipGeneratedProxies },
|
|
15
|
+
configs: {},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// configs reference the plugin itself, so they are assigned after it exists.
|
|
19
|
+
//
|
|
20
|
+
// import cratis from '@cratis/eslint-config';
|
|
21
|
+
// import arc from '@cratis/eslint-plugin-arc';
|
|
22
|
+
// export default [...cratis.configs.consumer, ...arc.configs.recommended];
|
|
23
|
+
Object.assign(plugin.configs, {
|
|
24
|
+
recommended: [
|
|
25
|
+
{
|
|
26
|
+
name: '@cratis/arc/skip-generated-proxies',
|
|
27
|
+
files: ['**/*.ts'],
|
|
28
|
+
processor: plugin.processors['skip-generated-proxies'],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: '@cratis/arc/recommended',
|
|
32
|
+
files: ['**/*.ts', '**/*.tsx'],
|
|
33
|
+
plugins: { '@cratis/arc': plugin },
|
|
34
|
+
rules: {
|
|
35
|
+
'@cratis/arc/no-hooks-in-view-model': 'error',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export default plugin;
|
|
42
|
+
export const { configs, rules, processors, meta } = plugin;
|
|
43
|
+
export { noHooksInViewModel, skipGeneratedProxies, isGeneratedProxy };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const DEFAULT_HOOK_PATTERN = /^use[A-Z]/;
|
|
2
|
+
|
|
3
|
+
// Flags React hook calls made inside an MVVM view model. In Cratis MVVM a view model is
|
|
4
|
+
// a plain, React-free class — constructed and unit-tested without React — and everything
|
|
5
|
+
// it needs from the React world (identity, navigation, query/command hooks) is injected
|
|
6
|
+
// as a Cratis abstraction, never obtained by calling a hook. A hook called from a class
|
|
7
|
+
// named `*ViewModel` is therefore always a mistake, and one that is otherwise invisible
|
|
8
|
+
// until the component misbehaves at runtime.
|
|
9
|
+
//
|
|
10
|
+
// Only bare-identifier calls (`useState(...)`) are flagged, never member calls
|
|
11
|
+
// (`this.useDefaults()`, `service.useCache()`), so a view-model method that merely starts
|
|
12
|
+
// with `use` is not a false positive. Scope is the nearest enclosing class, so a hook in
|
|
13
|
+
// a non-view-model class nested inside a view model is left alone.
|
|
14
|
+
export const noHooksInViewModel = {
|
|
15
|
+
meta: {
|
|
16
|
+
type: 'problem',
|
|
17
|
+
docs: {
|
|
18
|
+
description: 'Disallow React hook calls inside MVVM view models (classes named *ViewModel).',
|
|
19
|
+
recommended: true,
|
|
20
|
+
url: 'https://github.com/Cratis/Arc/blob/main/Source/JavaScript/Arc.ESLint/README.md',
|
|
21
|
+
},
|
|
22
|
+
schema: [{
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
classSuffix: { type: 'string' },
|
|
26
|
+
hookPattern: { type: 'string' },
|
|
27
|
+
additionalHooks: { type: 'array', items: { type: 'string' } },
|
|
28
|
+
},
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
}],
|
|
31
|
+
messages: {
|
|
32
|
+
noHook: "Do not call the React hook '{{hook}}' inside view model '{{viewModel}}'. A view model must be a plain, React-free class — inject the Cratis abstraction instead of calling a hook.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
create(context) {
|
|
36
|
+
const options = context.options[0] ?? {};
|
|
37
|
+
const classSuffix = options.classSuffix ?? 'ViewModel';
|
|
38
|
+
const hookPattern = options.hookPattern ? new RegExp(options.hookPattern) : DEFAULT_HOOK_PATTERN;
|
|
39
|
+
const additionalHooks = new Set(options.additionalHooks ?? []);
|
|
40
|
+
|
|
41
|
+
// Stack of enclosing classes; each entry is the view-model name, or null when the
|
|
42
|
+
// class is not a view model. The top of the stack is the nearest enclosing class.
|
|
43
|
+
const enclosingClasses = [];
|
|
44
|
+
|
|
45
|
+
const enterClass = node => {
|
|
46
|
+
const name = node.id && node.id.name;
|
|
47
|
+
enclosingClasses.push(name && name.endsWith(classSuffix) ? name : null);
|
|
48
|
+
};
|
|
49
|
+
const exitClass = () => enclosingClasses.pop();
|
|
50
|
+
const nearestViewModel = () => (enclosingClasses.length ? enclosingClasses[enclosingClasses.length - 1] : null);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
ClassDeclaration: enterClass,
|
|
54
|
+
'ClassDeclaration:exit': exitClass,
|
|
55
|
+
ClassExpression: enterClass,
|
|
56
|
+
'ClassExpression:exit': exitClass,
|
|
57
|
+
CallExpression(node) {
|
|
58
|
+
const viewModel = nearestViewModel();
|
|
59
|
+
if (!viewModel) return;
|
|
60
|
+
if (node.callee.type !== 'Identifier') return;
|
|
61
|
+
const hook = node.callee.name;
|
|
62
|
+
if (hookPattern.test(hook) || additionalHooks.has(hook)) {
|
|
63
|
+
context.report({ node, messageId: 'noHook', data: { hook, viewModel } });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export default noHooksInViewModel;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// An ESLint flat-config processor that skips Cratis-generated proxy files wholesale.
|
|
2
|
+
//
|
|
3
|
+
// Arc's ProxyGenerator emits a `// @generated by Cratis …` line as line 1 (see
|
|
4
|
+
// GeneratedFileMetadata.cs) followed by a `**DO NOT EDIT** - This file is an
|
|
5
|
+
// automatically generated file.` banner (the Handlebars templates). These files cannot
|
|
6
|
+
// be edited — they are regenerated by `dotnet build -c Release` — so every lint finding
|
|
7
|
+
// on them is un-actionable, and an autofix would rewrite a file the next build reverts.
|
|
8
|
+
//
|
|
9
|
+
// Generated `.ts` proxies are co-located with hand-written `.ts` (view models, helpers)
|
|
10
|
+
// with no path or naming distinction, so the header is the only reliable signal — hence
|
|
11
|
+
// a content-sniffing processor rather than an `ignores` glob. `preprocess` returns `[]`
|
|
12
|
+
// (no lintable blocks) for a generated file and the file unchanged for everything else.
|
|
13
|
+
// The pass-through preserves the original filename, so import resolution is unaffected,
|
|
14
|
+
// and `supportsAutofix` keeps `--fix` working on the hand-written files that flow through.
|
|
15
|
+
|
|
16
|
+
const GENERATED_HEADER = '// @generated by Cratis';
|
|
17
|
+
const DO_NOT_EDIT_BANNER = '**DO NOT EDIT** - This file is an automatically generated file.';
|
|
18
|
+
|
|
19
|
+
export const isGeneratedProxy = text => {
|
|
20
|
+
if (typeof text !== 'string') return false;
|
|
21
|
+
if (text.startsWith(GENERATED_HEADER)) return true;
|
|
22
|
+
// Fallback for any generator variant that omits the @generated line: the DO NOT EDIT
|
|
23
|
+
// banner the templates emit within the first handful of lines.
|
|
24
|
+
return text.split('\n', 4).join('\n').includes(DO_NOT_EDIT_BANNER);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const skipGeneratedProxies = {
|
|
28
|
+
meta: { name: '@cratis/arc/skip-generated-proxies' },
|
|
29
|
+
preprocess(text) {
|
|
30
|
+
return isGeneratedProxy(text) ? [] : [text];
|
|
31
|
+
},
|
|
32
|
+
postprocess(messages) {
|
|
33
|
+
return messages.flat();
|
|
34
|
+
},
|
|
35
|
+
supportsAutofix: true,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default skipGeneratedProxies;
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cratis/eslint-plugin-arc",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Cratis Arc ESLint rules: skip generated proxies and keep MVVM view models React-free. Compose on top of @cratis/eslint-config.",
|
|
5
|
+
"author": "Cratis",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Cratis/Arc.git"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"eslint",
|
|
17
|
+
"eslintplugin",
|
|
18
|
+
"eslint-plugin",
|
|
19
|
+
"cratis",
|
|
20
|
+
"arc",
|
|
21
|
+
"mvvm"
|
|
22
|
+
],
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"lib",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
"./package.json": "./package.json",
|
|
30
|
+
".": "./index.js"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "yarn g:test",
|
|
34
|
+
"ci": "yarn g:test"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"eslint": ">=9"
|
|
38
|
+
}
|
|
39
|
+
}
|