@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 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` and `no-hooks-in-view-model` take no options.
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.0",
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.",
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",