@cratis/eslint-plugin-components 3.4.0 → 3.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 +37 -1
- package/index.js +4 -1
- package/lib/noRawCommandFormMarker.js +89 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Cratis base config, [`@cratis/eslint-config`](https://www.npmjs.com/package/@cra
|
|
|
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
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
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. |
|
|
12
|
+
| `no-raw-command-form-marker` | Disallows identifying a CommandForm field or column by a hand-written `displayName` string, in either direction. Use `markAsCommandFormField`/`markAsCommandFormColumn` and `isCommandFormField`/`isCommandFormColumn` from `@cratis/components/CommandForm` — they go through a marker a build transform cannot rewrite. |
|
|
12
13
|
|
|
13
14
|
The two import rules cover `import` and re-`export … from` forms.
|
|
14
15
|
|
|
@@ -44,7 +45,7 @@ export default [
|
|
|
44
45
|
}],
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
`onbeforeexecute-must-return
|
|
48
|
+
`onbeforeexecute-must-return`, `no-hooks-in-view-model` and `no-raw-command-form-marker` take no options.
|
|
48
49
|
|
|
49
50
|
## Rules
|
|
50
51
|
|
|
@@ -96,3 +97,38 @@ A class is treated as a view model when it is registered via `withViewModel(...)
|
|
|
96
97
|
with `@injectable`, or named `*ViewModel`. Both bare hooks (`useState`, `useIdentity`, …) and
|
|
97
98
|
proxy member hooks (`.use()`, `.useSuspense()`, `.useChangeStream()`) are flagged. Hooks
|
|
98
99
|
inside a nested non–view-model class are not.
|
|
100
|
+
|
|
101
|
+
### `no-raw-command-form-marker`
|
|
102
|
+
|
|
103
|
+
`CommandForm`, `CommandDialog` and `CommandStepper` decide which children are fields by
|
|
104
|
+
inspecting the child's component type. Historically that test was a single string
|
|
105
|
+
comparison against `displayName` — and `displayName` is React's public, writable
|
|
106
|
+
*diagnostic* name, a routine target for build tooling. Storybook's
|
|
107
|
+
`reactDocgen: 'react-docgen-typescript'` setting rewrites it by default.
|
|
108
|
+
|
|
109
|
+
When it is rewritten, the child stops being recognized as a field: it renders with no
|
|
110
|
+
container, so no label, no bound value and no change handler. There is no error and no
|
|
111
|
+
warning, and every gate stays green.
|
|
112
|
+
|
|
113
|
+
The helpers go through an `isCommandFormField` / `isCommandFormColumn` marker that a rename
|
|
114
|
+
does not touch, while still setting and honoring the legacy `displayName` — so they are strictly
|
|
115
|
+
more permissive than the literal, never less. `@cratis/arc.react` marks and reads the same two
|
|
116
|
+
properties, and that shared shape is what carries the contract across the two packages.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// ❌ a build transform that rewrites displayName silently unbinds this field
|
|
120
|
+
MyField.displayName = 'CommandFormField';
|
|
121
|
+
if (component.displayName === 'CommandFormField') { wrap(component); }
|
|
122
|
+
|
|
123
|
+
// ✅ marker first, legacy displayName still set and still honoured
|
|
124
|
+
import { markAsCommandFormField, isCommandFormField } from '@cratis/components/CommandForm';
|
|
125
|
+
|
|
126
|
+
markAsCommandFormField(MyField);
|
|
127
|
+
if (isCommandFormField(component)) { wrap(component); }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Flagged in both directions: assignment (`C.displayName = '…'`, including computed and
|
|
131
|
+
object-literal forms) and comparison (`===`, `!==`, either operand order). Referring to the
|
|
132
|
+
exported `CommandFormFieldDisplayName` / `CommandFormColumnDisplayName` constants is not
|
|
133
|
+
flagged, so the declarations themselves and any deliberate legacy-path code stay clean.
|
|
134
|
+
Prefer `asCommandFormField` from `@cratis/arc.react` where it applies — it marks for you.
|
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { noPrimereactDialog } from './lib/noPrimereactDialog.js';
|
|
|
3
3
|
import { noRootBarrelImport } from './lib/noRootBarrelImport.js';
|
|
4
4
|
import { onbeforeexecuteMustReturn } from './lib/onbeforeexecuteMustReturn.js';
|
|
5
5
|
import { noHooksInViewModel } from './lib/noHooksInViewModel.js';
|
|
6
|
+
import { noRawCommandFormMarker } from './lib/noRawCommandFormMarker.js';
|
|
6
7
|
|
|
7
8
|
const { version } = createRequire(import.meta.url)('./package.json');
|
|
8
9
|
|
|
@@ -17,6 +18,7 @@ const plugin = {
|
|
|
17
18
|
'no-root-barrel-import': noRootBarrelImport,
|
|
18
19
|
'onbeforeexecute-must-return': onbeforeexecuteMustReturn,
|
|
19
20
|
'no-hooks-in-view-model': noHooksInViewModel,
|
|
21
|
+
'no-raw-command-form-marker': noRawCommandFormMarker,
|
|
20
22
|
},
|
|
21
23
|
configs: {},
|
|
22
24
|
};
|
|
@@ -37,6 +39,7 @@ Object.assign(plugin.configs, {
|
|
|
37
39
|
'@cratis/components/no-root-barrel-import': 'error',
|
|
38
40
|
'@cratis/components/onbeforeexecute-must-return': 'error',
|
|
39
41
|
'@cratis/components/no-hooks-in-view-model': 'error',
|
|
42
|
+
'@cratis/components/no-raw-command-form-marker': 'error',
|
|
40
43
|
},
|
|
41
44
|
},
|
|
42
45
|
],
|
|
@@ -44,4 +47,4 @@ Object.assign(plugin.configs, {
|
|
|
44
47
|
|
|
45
48
|
export default plugin;
|
|
46
49
|
export const { configs, rules, meta } = plugin;
|
|
47
|
-
export { noPrimereactDialog, noRootBarrelImport, onbeforeexecuteMustReturn, noHooksInViewModel };
|
|
50
|
+
export { noPrimereactDialog, noRootBarrelImport, onbeforeexecuteMustReturn, noHooksInViewModel, noRawCommandFormMarker };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The identifiers a CommandForm child is recognized by, and the helpers that own each one.
|
|
2
|
+
const MARKERS = {
|
|
3
|
+
CommandFormField: { mark: 'markAsCommandFormField', predicate: 'isCommandFormField' },
|
|
4
|
+
CommandFormColumn: { mark: 'markAsCommandFormColumn', predicate: 'isCommandFormColumn' },
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const EQUALITY = new Set(['===', '!==', '==', '!=']);
|
|
8
|
+
|
|
9
|
+
// True for `x.displayName` and `x['displayName']`.
|
|
10
|
+
const isDisplayNameMember = node =>
|
|
11
|
+
node?.type === 'MemberExpression' &&
|
|
12
|
+
(node.computed
|
|
13
|
+
? node.property?.type === 'Literal' && node.property.value === 'displayName'
|
|
14
|
+
: node.property?.type === 'Identifier' && node.property.name === 'displayName');
|
|
15
|
+
|
|
16
|
+
const markerFor = node =>
|
|
17
|
+
node?.type === 'Literal' && typeof node.value === 'string'
|
|
18
|
+
? MARKERS[node.value]
|
|
19
|
+
: undefined;
|
|
20
|
+
|
|
21
|
+
// Disallow hand-writing the `displayName` strings that identify a CommandForm field or
|
|
22
|
+
// column, in either direction — stamping one onto a component, or comparing against one to
|
|
23
|
+
// decide what a child is.
|
|
24
|
+
//
|
|
25
|
+
// `displayName` is React's public, writable diagnostic name and a routine target for build
|
|
26
|
+
// tooling: Storybook's `reactDocgen: 'react-docgen-typescript'` setting rewrites it by
|
|
27
|
+
// default. A component identified only by that string stops being recognized the moment
|
|
28
|
+
// anything renames it, and it fails silently — the field simply renders with no container,
|
|
29
|
+
// so no label, no bound value and no change handler, with no error and no warning.
|
|
30
|
+
//
|
|
31
|
+
// The helpers in '@cratis/components/CommandForm' set and read an `isCommandFormField` /
|
|
32
|
+
// `isCommandFormColumn` marker that a rename does not touch, and keep the legacy `displayName`
|
|
33
|
+
// alongside it for compatibility, so going through them is both safer and strictly more
|
|
34
|
+
// permissive than the literal. `@cratis/arc.react` marks and reads the same two properties;
|
|
35
|
+
// that shared shape is what carries the contract between the packages.
|
|
36
|
+
export const noRawCommandFormMarker = {
|
|
37
|
+
meta: {
|
|
38
|
+
type: 'problem',
|
|
39
|
+
docs: {
|
|
40
|
+
description: 'Disallow identifying CommandForm fields and columns by a raw displayName string; use the marker helpers.',
|
|
41
|
+
recommended: true,
|
|
42
|
+
url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md',
|
|
43
|
+
},
|
|
44
|
+
schema: [],
|
|
45
|
+
messages: {
|
|
46
|
+
useMarkHelper:
|
|
47
|
+
"Do not stamp displayName = '{{name}}' by hand — a build transform that rewrites displayName silently unbinds this component. Use {{helper}}() from '@cratis/components/CommandForm'.",
|
|
48
|
+
usePredicate:
|
|
49
|
+
"Do not identify a CommandForm child by comparing displayName to '{{name}}' — a component whose displayName was rewritten is missed. Use {{helper}}() from '@cratis/components/CommandForm'.",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
create(context) {
|
|
53
|
+
const report = (node, messageId, helper, name) =>
|
|
54
|
+
context.report({ node, messageId, data: { helper, name } });
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
// C.displayName = 'CommandFormField'
|
|
58
|
+
AssignmentExpression(node) {
|
|
59
|
+
if (node.operator !== '=' || !isDisplayNameMember(node.left)) return;
|
|
60
|
+
const marker = markerFor(node.right);
|
|
61
|
+
if (marker) report(node, 'useMarkHelper', marker.mark, node.right.value);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// { displayName: 'CommandFormField' } — e.g. via Object.assign
|
|
65
|
+
Property(node) {
|
|
66
|
+
const key = node.computed ? undefined : node.key;
|
|
67
|
+
const named =
|
|
68
|
+
(key?.type === 'Identifier' && key.name === 'displayName') ||
|
|
69
|
+
(key?.type === 'Literal' && key.value === 'displayName');
|
|
70
|
+
if (!named) return;
|
|
71
|
+
const marker = markerFor(node.value);
|
|
72
|
+
if (marker) report(node, 'useMarkHelper', marker.mark, node.value.value);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// child.displayName === 'CommandFormField' (either operand order)
|
|
76
|
+
BinaryExpression(node) {
|
|
77
|
+
if (!EQUALITY.has(node.operator)) return;
|
|
78
|
+
const [member, literal] = isDisplayNameMember(node.left)
|
|
79
|
+
? [node.left, node.right]
|
|
80
|
+
: [node.right, node.left];
|
|
81
|
+
if (!isDisplayNameMember(member)) return;
|
|
82
|
+
const marker = markerFor(literal);
|
|
83
|
+
if (marker) report(node, 'usePredicate', marker.predicate, literal.value);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export default noRawCommandFormMarker;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cratis/eslint-plugin-components",
|
|
3
|
-
"version": "3.
|
|
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,
|
|
3
|
+
"version": "3.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, keep React hooks out of view models, and identify CommandForm fields by a tamper-resistant marker rather than a raw displayName string. Compose on top of @cratis/eslint-config.",
|
|
5
5
|
"author": "Cratis",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|