@cratis/eslint-plugin-components 3.6.1 → 4.1.0
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/LICENSE +21 -0
- package/README.md +136 -22
- package/index.js +4 -2
- package/lib/kernelBoundary.js +139 -0
- package/lib/noReactInKernel.js +116 -0
- package/lib/noRootBarrelImport.js +255 -24
- package/lib/onbeforeexecuteMustReturn.js +36 -16
- package/lib/rootNamespaceMap.js +79 -0
- package/package.json +6 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Cratis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -3,20 +3,37 @@
|
|
|
3
3
|
ESLint rules for projects that consume Cratis Components. Compose these on top of the
|
|
4
4
|
Cratis base config, [`@cratis/eslint-config`](https://www.npmjs.com/package/@cratis/eslint-config).
|
|
5
5
|
|
|
6
|
-
| Rule
|
|
7
|
-
|
|
8
|
-
| `no-root-barrel-import`
|
|
9
|
-
| `no-primereact-dialog`
|
|
10
|
-
| `onbeforeexecute-must-return` | Requires an `onBeforeExecute` callback to return the command values.
|
|
11
|
-
| `no-hooks-in-view-model`
|
|
12
|
-
| `no-raw-command-form-marker`
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
| Rule | What it does |
|
|
7
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
8
|
+
| `no-root-barrel-import` | Disallows importing a removed Components 3 component namespace from the Components 4 setup-only root. Use the exact component subpath (`@cratis/components/CommandDialog`, `@cratis/components/DataPage`, `@cratis/components/Toolbar`, …). Package-wide provider/configuration symbols remain allowed at the root; an unambiguous namespace violation is autofixed. |
|
|
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. The runtime guards a missing return by keeping the current object and warning, but a replacement value is discarded and the callback violates the transformer contract. |
|
|
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. |
|
|
13
|
+
| `no-react-in-kernel` | Repository architecture rule that rejects React, React DOM, React Aria Components, and browser DOM globals in the explicit Components kernel inventory. It is intentionally not part of the consumer `recommended` config. |
|
|
14
|
+
|
|
15
|
+
Both import rules cover static `import` and re-`export … from` forms. The root-barrel rule also reports TypeScript `import = require(...)`, dynamic `import(...)`, and CommonJS `require(...)` of the package root; ambiguous forms are never autofixed.
|
|
16
|
+
|
|
17
|
+
> **Publication status:** The install examples target the owner-authorized 4.0.0 npm release. When
|
|
18
|
+
> reading this README from repository source before that release, verify availability with
|
|
19
|
+
> `npm view @cratis/eslint-plugin-components@4.0.0 version`; source contributors use the workspace
|
|
20
|
+
> commands in this checkout instead.
|
|
15
21
|
|
|
16
22
|
## Install
|
|
17
23
|
|
|
24
|
+
Use the independently released Components 4 tooling train bounded to `>=4 <5`. Never use
|
|
25
|
+
`latest`; plugin patches can release independently from Components Core:
|
|
26
|
+
|
|
18
27
|
```sh
|
|
19
|
-
|
|
28
|
+
TOOLING_RANGE='^4.0.0' # Shell-safe equivalent of >=4 <5.
|
|
29
|
+
|
|
30
|
+
# Choose the application package manager:
|
|
31
|
+
npm install --save-dev "@cratis/eslint-plugin-components@$TOOLING_RANGE" \
|
|
32
|
+
@cratis/eslint-config eslint
|
|
33
|
+
pnpm add --save-dev "@cratis/eslint-plugin-components@$TOOLING_RANGE" \
|
|
34
|
+
@cratis/eslint-config eslint
|
|
35
|
+
yarn add --dev "@cratis/eslint-plugin-components@$TOOLING_RANGE" \
|
|
36
|
+
@cratis/eslint-config eslint
|
|
20
37
|
```
|
|
21
38
|
|
|
22
39
|
## Use
|
|
@@ -38,7 +55,7 @@ export default [
|
|
|
38
55
|
```js
|
|
39
56
|
'@cratis/components/no-root-barrel-import': ['error', {
|
|
40
57
|
packageName: '@cratis/components', // barrel to forbid
|
|
41
|
-
allow: [], // exact specifiers to permit
|
|
58
|
+
allow: [], // exact specifiers to permit wholesale
|
|
42
59
|
}],
|
|
43
60
|
'@cratis/components/no-primereact-dialog': ['error', {
|
|
44
61
|
source: 'primereact/dialog', // module to forbid
|
|
@@ -47,24 +64,112 @@ export default [
|
|
|
47
64
|
|
|
48
65
|
`onbeforeexecute-must-return`, `no-hooks-in-view-model` and `no-raw-command-form-marker` take no options.
|
|
49
66
|
|
|
67
|
+
`no-react-in-kernel` is a repository-owned architecture guard and is not enabled by the consumer preset. Components configures it with the canonical inventory in `lib/kernelBoundary.js`; an explicit `kernelPaths` array may be supplied when testing or applying the rule to another owned tree.
|
|
68
|
+
|
|
69
|
+
## Repository kernel boundary
|
|
70
|
+
|
|
71
|
+
`no-react-in-kernel` applies only when the linted filename is one of the explicitly declared `kernelPaths`. It reports static imports and re-exports, TypeScript import assignments, dynamic imports, CommonJS `require(...)`, package subpaths, and browser DOM global references. Near-miss package names and files outside the inventory are left alone. The repository decision and exact included/excluded modules are documented in `Documentation/decisions/0003-kernel-boundary.md`.
|
|
72
|
+
|
|
73
|
+
## Considered and rejected: a hardcoded-owned-label rule
|
|
74
|
+
|
|
75
|
+
During the issue #174 localization pass, a static rule flagging a hardcoded English literal that
|
|
76
|
+
bypasses `CratisComponentsProvider` messages (e.g. a `title`/`aria-label` string sitting next to an
|
|
77
|
+
already-localized prop) was considered and rejected. Every rule above is a syntactic, structural
|
|
78
|
+
pattern an AST visitor resolves unambiguously (an import specifier, a known display-name
|
|
79
|
+
constant, a callback's control-flow shape). Detecting an _owned-label bypass_ instead requires
|
|
80
|
+
understanding which of a component's many string literals are user-facing chrome versus CSS class
|
|
81
|
+
names, `data-*` values, decorative glyphs, or genuinely correct per-instance defaults — a semantic
|
|
82
|
+
judgment call, not a syntactic one. A rule broad enough to catch a real bypass (any string literal
|
|
83
|
+
passed to `title`/`aria-label`/`placeholder`/a `Tooltip`'s `content`) flags the large fraction of
|
|
84
|
+
those call sites that are already correct, and an allowlist narrow enough to avoid that noise stops
|
|
85
|
+
catching new bypasses the moment a new component or prop name is introduced — the allowlist would
|
|
86
|
+
need updating in lockstep with every future component, which defeats the purpose of a ratchet.
|
|
87
|
+
Render-time sentinel-provider specs (see
|
|
88
|
+
`Source/Common/for_CratisComponentsProvider/when_every_owned_label_is_overridden.tsx`) are the
|
|
89
|
+
reliable gate instead: they render each audited surface under a provider whose messages are all
|
|
90
|
+
unmistakable sentinel strings and assert the rendered label is the sentinel, never the English
|
|
91
|
+
default — a real behavioral check that a static rule cannot approximate without the same false-positive
|
|
92
|
+
cost. Revisit a lint rule if a narrower, high-confidence pattern emerges (for example, once every
|
|
93
|
+
owned-label prop follows one consistent naming and JSDoc convention).
|
|
94
|
+
|
|
50
95
|
## Rules
|
|
51
96
|
|
|
97
|
+
### `no-root-barrel-import`
|
|
98
|
+
|
|
99
|
+
Components 4 removes every component-family namespace from the package root. The rule keeps a fixed map from each Components 3 namespace to its Components 4 subpath, plus the exact setup allowlist that remains at the root:
|
|
100
|
+
|
|
101
|
+
- `CratisComponentsProvider`, `useCratisComponentsConfig`, `cratisDefaults`, and `mergeCratisComponentsConfig`;
|
|
102
|
+
- `CratisComponentsConfig`, `CratisComponentsProviderProps`, and `CratisComponentsMessages`;
|
|
103
|
+
- `CratisPaginatorMessages`, `CratisDatePickerMessages`, `CratisDropdownMessages`, `CratisDialogMessages`, `CratisStepperMessages`, `CratisNotificationsMessages`, `CratisDataTableMessages`, and `CratisColumnFilterMessages`.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// ✅ approved setup symbols stay at the root
|
|
107
|
+
import { CratisComponentsProvider } from '@cratis/components';
|
|
108
|
+
|
|
109
|
+
// ❌ a component namespace imported from the root barrel
|
|
110
|
+
import { Canvas } from '@cratis/components';
|
|
111
|
+
|
|
112
|
+
// ✅ the same namespace, imported from its subpath
|
|
113
|
+
import * as Canvas from '@cratis/components/Canvas';
|
|
114
|
+
|
|
115
|
+
// ❌ whole-package forms are ambiguous and reported without an autofix
|
|
116
|
+
import Components = require('@cratis/components');
|
|
117
|
+
const lazyComponents = await import('@cratis/components');
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A single-namespace violation is autofixed to the namespace form shown above, preserving an alias (`Canvas as C`) and `import type` (`import type { Canvas } from '@cratis/components'` becomes `import type * as Canvas from '@cratis/components/Canvas'`, and a per-specifier `type` modifier is honored the same way). The historical `CommandStepper` namespace is mapped to `@cratis/components/CommandDialog`, because that root namespace exposed the complete CommandDialog module rather than only the narrower standalone stepper entry. A mixed import naming both a setup symbol and a namespace is split — the setup symbol stays imported from the root, the namespace moves:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// Before
|
|
124
|
+
import { CratisComponentsProvider, Canvas } from '@cratis/components';
|
|
125
|
+
|
|
126
|
+
// After (autofixed)
|
|
127
|
+
import { CratisComponentsProvider } from '@cratis/components';
|
|
128
|
+
import * as Canvas from '@cratis/components/Canvas';
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The rule never guesses. Each of these is flagged with guidance but **not** autofixed:
|
|
132
|
+
|
|
133
|
+
- A namespace import of the whole package (`import * as Everything from '@cratis/components'`)
|
|
134
|
+
— which subpath each later member access belongs to cannot be inferred from the import alone.
|
|
135
|
+
- A default import (`import Everything from '@cratis/components'`) — the package has no
|
|
136
|
+
default export.
|
|
137
|
+
- A side-effect-only import (`import '@cratis/components'`) — there is no binding to infer a
|
|
138
|
+
subpath from.
|
|
139
|
+
- A named import of a symbol that is neither an approved setup symbol nor a known namespace
|
|
140
|
+
(for example a member that only exists _inside_ a namespace, not at the root) — the whole
|
|
141
|
+
statement is left unfixed rather than partially migrated.
|
|
142
|
+
- The Components 3.6 `Compatibility` namespace and direct pass-through exports — reported
|
|
143
|
+
as known removals with typed-parts guidance and never autofixed because Components 4 has
|
|
144
|
+
no compatibility subpath.
|
|
145
|
+
- Any `export … from '@cratis/components'` re-export form — flagged with the same subpath
|
|
146
|
+
guidance, but re-exports are never autofixed.
|
|
147
|
+
|
|
148
|
+
A companion, standalone codemod applies the equivalent rewrite across a whole project in one
|
|
149
|
+
pass, and additionally autofixes a named `export { Canvas } from '@cratis/components'`
|
|
150
|
+
re-export the same way it autofixes the matching import — this rule flags every re-export
|
|
151
|
+
form with guidance but never autofixes any of them; see
|
|
152
|
+
[`@cratis/components.migrator`](https://www.npmjs.com/package/@cratis/components.migrator).
|
|
153
|
+
|
|
52
154
|
### `onbeforeexecute-must-return`
|
|
53
155
|
|
|
54
156
|
`onBeforeExecute` (on `CommandDialog`, `StepperCommandDialog`, `CommandScope`, …) is a
|
|
55
157
|
**transformer**: it receives the current command values and must **return** them (mutated or
|
|
56
|
-
not). A callback that returns nothing
|
|
57
|
-
|
|
58
|
-
|
|
158
|
+
not). A callback that returns nothing does not run the command with `undefined` — a runtime
|
|
159
|
+
guard (`applyBeforeExecute`) falls back to the current object and logs a `console.warn`.
|
|
160
|
+
In-place mutations may therefore remain, but a replacement object is discarded and the call
|
|
161
|
+
still violates the transformer contract. TypeScript catches this at fully typed call sites;
|
|
162
|
+
this rule is the static backstop for JavaScript and loosely typed consumers, so behavior never
|
|
163
|
+
relies on the fallback.
|
|
59
164
|
|
|
60
165
|
```tsx
|
|
61
166
|
// ✅ returns the values
|
|
62
167
|
<CommandDialog onBeforeExecute={values => { values.id = Guid.create(); return values; }} />
|
|
63
168
|
<CommandDialog onBeforeExecute={values => values} />
|
|
64
169
|
|
|
65
|
-
// ❌
|
|
66
|
-
<CommandDialog onBeforeExecute={values => { values
|
|
67
|
-
// ❌ bare return
|
|
170
|
+
// ❌ replacement is discarded — the runtime falls back to `values` and warns
|
|
171
|
+
<CommandDialog onBeforeExecute={values => { const updated = { ...values, id: Guid.create() }; }} />
|
|
172
|
+
// ❌ bare return — same guarded fallback
|
|
68
173
|
<CommandDialog onBeforeExecute={values => { return; }} />
|
|
69
174
|
```
|
|
70
175
|
|
|
@@ -83,7 +188,9 @@ service) instead of calling a hook.
|
|
|
83
188
|
```ts
|
|
84
189
|
// ❌ generated Arc proxy hook inside a view model
|
|
85
190
|
class AuthorsViewModel {
|
|
86
|
-
load() {
|
|
191
|
+
load() {
|
|
192
|
+
const [authors] = AllAuthors.use();
|
|
193
|
+
} // flagged
|
|
87
194
|
}
|
|
88
195
|
|
|
89
196
|
// ✅ inject the abstraction; call hooks only in the component
|
|
@@ -103,7 +210,7 @@ inside a nested non–view-model class are not.
|
|
|
103
210
|
`CommandForm`, `CommandDialog` and `CommandStepper` decide which children are fields by
|
|
104
211
|
inspecting the child's component type. Historically that test was a single string
|
|
105
212
|
comparison against `displayName` — and `displayName` is React's public, writable
|
|
106
|
-
|
|
213
|
+
_diagnostic_ name, a routine target for build tooling. Storybook's
|
|
107
214
|
`reactDocgen: 'react-docgen-typescript'` setting rewrites it by default.
|
|
108
215
|
|
|
109
216
|
When it is rewritten, the child stops being recognized as a field: it renders with no
|
|
@@ -118,13 +225,20 @@ properties, and that shared shape is what carries the contract across the two pa
|
|
|
118
225
|
```ts
|
|
119
226
|
// ❌ a build transform that rewrites displayName silently unbinds this field
|
|
120
227
|
MyField.displayName = 'CommandFormField';
|
|
121
|
-
if (component.displayName === 'CommandFormField') {
|
|
228
|
+
if (component.displayName === 'CommandFormField') {
|
|
229
|
+
wrap(component);
|
|
230
|
+
}
|
|
122
231
|
|
|
123
232
|
// ✅ marker first, legacy displayName still set and still honoured
|
|
124
|
-
import {
|
|
233
|
+
import {
|
|
234
|
+
markAsCommandFormField,
|
|
235
|
+
isCommandFormField,
|
|
236
|
+
} from '@cratis/components/CommandForm';
|
|
125
237
|
|
|
126
238
|
markAsCommandFormField(MyField);
|
|
127
|
-
if (isCommandFormField(component)) {
|
|
239
|
+
if (isCommandFormField(component)) {
|
|
240
|
+
wrap(component);
|
|
241
|
+
}
|
|
128
242
|
```
|
|
129
243
|
|
|
130
244
|
Flagged in both directions: assignment (`C.displayName = '…'`, including computed and
|
package/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { noRootBarrelImport } from './lib/noRootBarrelImport.js';
|
|
|
4
4
|
import { onbeforeexecuteMustReturn } from './lib/onbeforeexecuteMustReturn.js';
|
|
5
5
|
import { noHooksInViewModel } from './lib/noHooksInViewModel.js';
|
|
6
6
|
import { noRawCommandFormMarker } from './lib/noRawCommandFormMarker.js';
|
|
7
|
+
import { noReactInKernel } from './lib/noReactInKernel.js';
|
|
7
8
|
|
|
8
9
|
const { version } = createRequire(import.meta.url)('./package.json');
|
|
9
10
|
|
|
@@ -19,6 +20,7 @@ const plugin = {
|
|
|
19
20
|
'onbeforeexecute-must-return': onbeforeexecuteMustReturn,
|
|
20
21
|
'no-hooks-in-view-model': noHooksInViewModel,
|
|
21
22
|
'no-raw-command-form-marker': noRawCommandFormMarker,
|
|
23
|
+
'no-react-in-kernel': noReactInKernel,
|
|
22
24
|
},
|
|
23
25
|
configs: {},
|
|
24
26
|
};
|
|
@@ -32,7 +34,7 @@ Object.assign(plugin.configs, {
|
|
|
32
34
|
recommended: [
|
|
33
35
|
{
|
|
34
36
|
name: '@cratis/components/recommended',
|
|
35
|
-
files: ['**/*.ts
|
|
37
|
+
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'],
|
|
36
38
|
plugins: { '@cratis/components': plugin },
|
|
37
39
|
rules: {
|
|
38
40
|
'@cratis/components/no-primereact-dialog': 'error',
|
|
@@ -47,4 +49,4 @@ Object.assign(plugin.configs, {
|
|
|
47
49
|
|
|
48
50
|
export default plugin;
|
|
49
51
|
export const { configs, rules, meta } = plugin;
|
|
50
|
-
export { noPrimereactDialog, noRootBarrelImport, onbeforeexecuteMustReturn, noHooksInViewModel, noRawCommandFormMarker };
|
|
52
|
+
export { noPrimereactDialog, noRootBarrelImport, onbeforeexecuteMustReturn, noHooksInViewModel, noRawCommandFormMarker, noReactInKernel };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Repository-owned source modules whose contracts are deliberately independent of React and the browser DOM.
|
|
8
|
+
*
|
|
9
|
+
* This is the canonical kernel inventory. The root ESLint config, the no-react-in-kernel rule, and the
|
|
10
|
+
* emitted package-graph gate all consume this exact list.
|
|
11
|
+
*/
|
|
12
|
+
export const kernelSourcePaths = Object.freeze([
|
|
13
|
+
'Source/PivotViewer/constants.ts',
|
|
14
|
+
'Source/PivotViewer/engine/layout.ts',
|
|
15
|
+
'Source/PivotViewer/engine/requestCorrelator.ts',
|
|
16
|
+
'Source/PivotViewer/engine/store.ts',
|
|
17
|
+
'Source/PivotViewer/engine/types.ts',
|
|
18
|
+
'Source/Canvas/canvasGesture.ts',
|
|
19
|
+
'Source/Canvas/canvasTransformActivity.ts',
|
|
20
|
+
'Source/Canvas/panMomentum.ts',
|
|
21
|
+
'Source/Canvas/pinchGesture.ts',
|
|
22
|
+
'Source/Canvas/shapes/Region/regionContainment.ts',
|
|
23
|
+
'Source/Filter/types.ts',
|
|
24
|
+
'Source/Filter/utils.ts',
|
|
25
|
+
'Source/DataTables/DataTableFilterMatcherRegistry.ts',
|
|
26
|
+
'Source/DataTables/DataTableFilterMeta.ts',
|
|
27
|
+
'Source/DataTables/paginatorRange.ts',
|
|
28
|
+
'Source/DataTables/selectionKeys.ts',
|
|
29
|
+
'Source/CommandDialog/applyBeforeExecute.ts',
|
|
30
|
+
'Source/CommandForm/commandFormMarkers.ts',
|
|
31
|
+
'Source/CommandForm/fields/chipValues.ts',
|
|
32
|
+
'Source/CommandForm/fields/fieldValueFromEvent.ts',
|
|
33
|
+
'Source/SchemaEditor/schemaHelpers.ts',
|
|
34
|
+
'Source/Chat/isTopicUnnamed.ts',
|
|
35
|
+
'Source/Chat/shouldRequestTopicName.ts',
|
|
36
|
+
'Source/Chat/topicsByActivity.ts',
|
|
37
|
+
'Source/Chat/Kit/findOwnReaction.ts',
|
|
38
|
+
'Source/Chat/Kit/reactionsExcludingUser.ts',
|
|
39
|
+
'Source/Chat/Kit/Mentions/MentionCandidate.ts',
|
|
40
|
+
'Source/Chat/Kit/Mentions/MentionQuery.ts',
|
|
41
|
+
'Source/Chat/Kit/Mentions/activeMentionQuery.ts',
|
|
42
|
+
'Source/Chat/Kit/Mentions/applyMention.ts',
|
|
43
|
+
'Source/Chat/Kit/Mentions/extractMentions.ts',
|
|
44
|
+
'Source/Chat/Kit/Mentions/findMentionRanges.ts',
|
|
45
|
+
'Source/Chat/Kit/Mentions/matchCandidates.ts',
|
|
46
|
+
'Source/Chat/Kit/Mentions/mentionSegments.ts',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** React package roots forbidden from kernel source and emitted closures. */
|
|
50
|
+
const reactKernelPackages = Object.freeze([
|
|
51
|
+
'react',
|
|
52
|
+
'react-dom',
|
|
53
|
+
'react-aria-components',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Browser DOM globals forbidden from kernel source and emitted runtime closures. */
|
|
57
|
+
export const browserDomGlobals = Object.freeze([
|
|
58
|
+
'CSSStyleDeclaration',
|
|
59
|
+
'ClipboardEvent',
|
|
60
|
+
'CustomEvent',
|
|
61
|
+
'DOMRect',
|
|
62
|
+
'Document',
|
|
63
|
+
'DragEvent',
|
|
64
|
+
'Element',
|
|
65
|
+
'Event',
|
|
66
|
+
'EventTarget',
|
|
67
|
+
'FocusEvent',
|
|
68
|
+
'HTMLButtonElement',
|
|
69
|
+
'HTMLCanvasElement',
|
|
70
|
+
'HTMLDivElement',
|
|
71
|
+
'HTMLElement',
|
|
72
|
+
'HTMLFormElement',
|
|
73
|
+
'HTMLInputElement',
|
|
74
|
+
'HTMLTextAreaElement',
|
|
75
|
+
'InputEvent',
|
|
76
|
+
'IntersectionObserver',
|
|
77
|
+
'KeyboardEvent',
|
|
78
|
+
'MessageEvent',
|
|
79
|
+
'MouseEvent',
|
|
80
|
+
'MutationObserver',
|
|
81
|
+
'Node',
|
|
82
|
+
'PointerEvent',
|
|
83
|
+
'ResizeObserver',
|
|
84
|
+
'TouchEvent',
|
|
85
|
+
'WheelEvent',
|
|
86
|
+
'Window',
|
|
87
|
+
'Worker',
|
|
88
|
+
'cancelAnimationFrame',
|
|
89
|
+
'document',
|
|
90
|
+
'getComputedStyle',
|
|
91
|
+
'history',
|
|
92
|
+
'localStorage',
|
|
93
|
+
'location',
|
|
94
|
+
'matchMedia',
|
|
95
|
+
'navigator',
|
|
96
|
+
'performance',
|
|
97
|
+
'requestAnimationFrame',
|
|
98
|
+
'sessionStorage',
|
|
99
|
+
'self',
|
|
100
|
+
'window',
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
/** True when a package specifier is React, React DOM, React Aria Components, or one of their subpaths. */
|
|
104
|
+
export const isKernelReactSpecifier = (specifier) =>
|
|
105
|
+
reactKernelPackages.some(
|
|
106
|
+
(packageName) =>
|
|
107
|
+
specifier === packageName || specifier.startsWith(`${packageName}/`),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const normalized = (value) => value.split(path.sep).join('/').replace(/^\.\//u, '');
|
|
111
|
+
|
|
112
|
+
/** True when an ESLint filename identifies one of the declared repository kernel modules. */
|
|
113
|
+
export function isKernelSourcePath(filename, paths = kernelSourcePaths, cwd = process.cwd()) {
|
|
114
|
+
if (!filename || filename === '<input>' || filename === '<text>') return false;
|
|
115
|
+
|
|
116
|
+
const normalizedFilename = normalized(filename);
|
|
117
|
+
const normalizedCwd = normalized(cwd).replace(/\/$/u, '');
|
|
118
|
+
const relative = normalizedFilename.startsWith(`${normalizedCwd}/`)
|
|
119
|
+
? normalizedFilename.slice(normalizedCwd.length + 1)
|
|
120
|
+
: normalizedFilename;
|
|
121
|
+
|
|
122
|
+
return paths.some((candidate) => {
|
|
123
|
+
const normalizedCandidate = normalized(candidate);
|
|
124
|
+
return (
|
|
125
|
+
relative === normalizedCandidate ||
|
|
126
|
+
normalizedFilename.endsWith(`/${normalizedCandidate}`)
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Maps a declared TypeScript module under `Source/` to its emitted `dist/esm` path. */
|
|
132
|
+
export function kernelEmittedPath(sourcePath, extension) {
|
|
133
|
+
if (extension !== '.js' && extension !== '.d.ts') {
|
|
134
|
+
throw new Error(`Unsupported kernel emitted extension '${extension}'.`);
|
|
135
|
+
}
|
|
136
|
+
return normalized(sourcePath)
|
|
137
|
+
.replace(/^Source\//u, '')
|
|
138
|
+
.replace(/\.(?:tsx?|mts|cts)$/u, extension);
|
|
139
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
browserDomGlobals,
|
|
6
|
+
isKernelReactSpecifier,
|
|
7
|
+
isKernelSourcePath,
|
|
8
|
+
kernelSourcePaths,
|
|
9
|
+
} from './kernelBoundary.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Keeps the repository's explicitly declared computation kernel independent of React and browser DOM APIs.
|
|
13
|
+
* The filename check is intentional: this is an owned architectural boundary, not a consumer-wide ban.
|
|
14
|
+
*/
|
|
15
|
+
export const noReactInKernel = {
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'problem',
|
|
18
|
+
docs: {
|
|
19
|
+
description:
|
|
20
|
+
'Disallow React package edges and browser DOM globals in explicitly declared Components kernel modules.',
|
|
21
|
+
recommended: false,
|
|
22
|
+
url: 'https://github.com/Cratis/Components/blob/main/Documentation/decisions/0003-kernel-boundary.md',
|
|
23
|
+
},
|
|
24
|
+
schema: [
|
|
25
|
+
{
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
kernelPaths: {
|
|
29
|
+
type: 'array',
|
|
30
|
+
items: { type: 'string' },
|
|
31
|
+
uniqueItems: true,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
messages: {
|
|
38
|
+
forbiddenDependency:
|
|
39
|
+
"Kernel module '{{file}}' must not depend on '{{specifier}}'. Keep React, React DOM, and React Aria Components outside the repository-owned kernel.",
|
|
40
|
+
forbiddenBrowserGlobal:
|
|
41
|
+
"Kernel module '{{file}}' must not use browser DOM global '{{name}}'. Pass data through a React/DOM adapter outside the kernel instead.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
create(context) {
|
|
45
|
+
const kernelPaths = context.options[0]?.kernelPaths ?? kernelSourcePaths;
|
|
46
|
+
const filename = context.filename;
|
|
47
|
+
if (!isKernelSourcePath(filename, kernelPaths, context.cwd ?? process.cwd())) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const file = kernelPaths.find((candidate) =>
|
|
52
|
+
isKernelSourcePath(filename, [candidate], context.cwd ?? process.cwd()),
|
|
53
|
+
);
|
|
54
|
+
const reportedGlobals = new Set();
|
|
55
|
+
|
|
56
|
+
const reportSpecifier = (node) => {
|
|
57
|
+
if (!node || typeof node.value !== 'string') return;
|
|
58
|
+
if (!isKernelReactSpecifier(node.value)) return;
|
|
59
|
+
context.report({
|
|
60
|
+
node,
|
|
61
|
+
messageId: 'forbiddenDependency',
|
|
62
|
+
data: { file, specifier: node.value },
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
ImportDeclaration(node) {
|
|
68
|
+
reportSpecifier(node.source);
|
|
69
|
+
},
|
|
70
|
+
ExportNamedDeclaration(node) {
|
|
71
|
+
reportSpecifier(node.source);
|
|
72
|
+
},
|
|
73
|
+
ExportAllDeclaration(node) {
|
|
74
|
+
reportSpecifier(node.source);
|
|
75
|
+
},
|
|
76
|
+
TSImportEqualsDeclaration(node) {
|
|
77
|
+
const reference = node.moduleReference;
|
|
78
|
+
if (reference?.type === 'TSExternalModuleReference') {
|
|
79
|
+
reportSpecifier(reference.expression);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
ImportExpression(node) {
|
|
83
|
+
reportSpecifier(node.source);
|
|
84
|
+
},
|
|
85
|
+
CallExpression(node) {
|
|
86
|
+
if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const [argument] = node.arguments;
|
|
90
|
+
if (!argument || argument.type === 'SpreadElement') return;
|
|
91
|
+
reportSpecifier(argument);
|
|
92
|
+
},
|
|
93
|
+
'Program:exit'() {
|
|
94
|
+
const forbidden = new Set(browserDomGlobals);
|
|
95
|
+
const scopes = context.sourceCode.scopeManager?.scopes ?? [];
|
|
96
|
+
for (const scope of scopes) {
|
|
97
|
+
for (const reference of scope.references) {
|
|
98
|
+
const identifier = reference.identifier;
|
|
99
|
+
if (!forbidden.has(identifier.name)) continue;
|
|
100
|
+
if (reference.resolved?.defs?.length > 0) continue;
|
|
101
|
+
const key = `${identifier.range?.[0] ?? identifier.loc.start.line}:${identifier.name}`;
|
|
102
|
+
if (reportedGlobals.has(key)) continue;
|
|
103
|
+
reportedGlobals.add(key);
|
|
104
|
+
context.report({
|
|
105
|
+
node: identifier,
|
|
106
|
+
messageId: 'forbiddenBrowserGlobal',
|
|
107
|
+
data: { file, name: identifier.name },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export default noReactInKernel;
|
|
@@ -1,48 +1,279 @@
|
|
|
1
|
+
import {
|
|
2
|
+
namespaceSubpaths,
|
|
3
|
+
approvedRootSymbols,
|
|
4
|
+
removedRootSymbols,
|
|
5
|
+
} from './rootNamespaceMap.js';
|
|
6
|
+
|
|
1
7
|
const DEFAULTS = { packageName: '@cratis/components', allow: [] };
|
|
2
8
|
|
|
3
|
-
// Disallow importing from the Cratis Components root barrel. The
|
|
4
|
-
// purpose-built subpath exports (`@cratis/components/CommandDialog`,
|
|
5
|
-
// `@cratis/components/DataPage`, `@cratis/components/Toolbar`, …);
|
|
6
|
-
//
|
|
7
|
-
//
|
|
9
|
+
// Disallow importing a component namespace from the Cratis Components root barrel. The
|
|
10
|
+
// package exposes purpose-built subpath exports (`@cratis/components/CommandDialog`,
|
|
11
|
+
// `@cratis/components/DataPage`, `@cratis/components/Toolbar`, …); Components 4 removes
|
|
12
|
+
// component namespaces from the setup-only root. The small,
|
|
13
|
+
// approved setup surface (`CratisComponentsProvider` and friends) remains importable from
|
|
14
|
+
// the root. Covers static imports/re-exports, TypeScript import assignments, dynamic imports,
|
|
15
|
+
// and CommonJS require calls. Only named static imports are autofixed, and only when every
|
|
16
|
+
// specifier in the statement is unambiguous.
|
|
8
17
|
export const noRootBarrelImport = {
|
|
9
18
|
meta: {
|
|
10
19
|
type: 'suggestion',
|
|
20
|
+
fixable: 'code',
|
|
11
21
|
docs: {
|
|
12
|
-
description:
|
|
22
|
+
description:
|
|
23
|
+
'Disallow importing a component namespace from the @cratis/components root barrel; use a subpath export. Approved setup symbols (e.g. CratisComponentsProvider) remain allowed at the root.',
|
|
13
24
|
recommended: true,
|
|
14
25
|
url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md',
|
|
15
26
|
},
|
|
16
|
-
schema: [
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
schema: [
|
|
28
|
+
{
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
packageName: { type: 'string' },
|
|
32
|
+
allow: { type: 'array', items: { type: 'string' } },
|
|
33
|
+
},
|
|
34
|
+
additionalProperties: false,
|
|
21
35
|
},
|
|
22
|
-
|
|
23
|
-
}],
|
|
36
|
+
],
|
|
24
37
|
messages: {
|
|
25
|
-
|
|
38
|
+
useNamespaceSubpath:
|
|
39
|
+
"Import '{{name}}' from '{{packageName}}/{{subpath}}' as a namespace instead of the package root barrel: import * as {{name}} from '{{packageName}}/{{subpath}}'.",
|
|
40
|
+
useNamespaceSubpathExport:
|
|
41
|
+
"Re-export '{{name}}' from '{{packageName}}/{{subpath}}' instead of the package root barrel: export * as {{name}} from '{{packageName}}/{{subpath}}'. This is not autofixed — verify downstream default-export expectations.",
|
|
42
|
+
removedCompatibilitySymbol:
|
|
43
|
+
"'{{name}}' was a Components 3 PrimeReact compatibility export and has no Components 4 subpath. Migrate renderer slots to typed Cratis parts and remove this import manually.",
|
|
44
|
+
removedCompatibilitySymbolExport:
|
|
45
|
+
"'{{name}}' was a Components 3 PrimeReact compatibility export and has no Components 4 subpath. Migrate renderer slots to typed Cratis parts and remove this re-export manually.",
|
|
46
|
+
unknownSymbol:
|
|
47
|
+
"'{{name}}' is not a recognized '{{packageName}}' root export (neither an approved setup symbol nor a known namespace). Import it from its specific subpath instead of the package root.",
|
|
48
|
+
unknownSymbolExport:
|
|
49
|
+
"'{{name}}' is not a recognized '{{packageName}}' root export (neither an approved setup symbol nor a known namespace). Re-export it from its specific subpath instead of the package root.",
|
|
50
|
+
ambiguousImport:
|
|
51
|
+
"This import of '{{packageName}}' cannot be safely auto-migrated off the root barrel — move each symbol to its subpath import manually. See ESLint/README.md.",
|
|
52
|
+
ambiguousExport:
|
|
53
|
+
"This wildcard re-export of '{{packageName}}' cannot be safely auto-migrated off the root barrel — replace it with explicit subpath re-exports.",
|
|
26
54
|
},
|
|
27
55
|
},
|
|
28
56
|
create(context) {
|
|
29
57
|
const options = { ...DEFAULTS, ...(context.options[0] ?? {}) };
|
|
58
|
+
const packageName = options.packageName;
|
|
30
59
|
const allow = new Set(options.allow);
|
|
31
60
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
};
|
|
61
|
+
const isRootSpecifier = (source) =>
|
|
62
|
+
!!source &&
|
|
63
|
+
typeof source.value === 'string' &&
|
|
64
|
+
source.value === packageName &&
|
|
65
|
+
!allow.has(source.value);
|
|
39
66
|
|
|
40
67
|
return {
|
|
41
|
-
ImportDeclaration
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
ImportDeclaration(node) {
|
|
69
|
+
if (!isRootSpecifier(node.source)) return;
|
|
70
|
+
checkImport(context, node, packageName);
|
|
71
|
+
},
|
|
72
|
+
TSImportEqualsDeclaration(node) {
|
|
73
|
+
const reference = node.moduleReference;
|
|
74
|
+
if (
|
|
75
|
+
reference?.type !== 'TSExternalModuleReference' ||
|
|
76
|
+
!isRootSpecifier(reference.expression)
|
|
77
|
+
)
|
|
78
|
+
return;
|
|
79
|
+
context.report({
|
|
80
|
+
node: reference.expression,
|
|
81
|
+
messageId: 'ambiguousImport',
|
|
82
|
+
data: { packageName },
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
ImportExpression(node) {
|
|
86
|
+
if (!isRootSpecifier(node.source)) return;
|
|
87
|
+
context.report({
|
|
88
|
+
node: node.source,
|
|
89
|
+
messageId: 'ambiguousImport',
|
|
90
|
+
data: { packageName },
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
CallExpression(node) {
|
|
94
|
+
if (node.callee.type !== 'Identifier' || node.callee.name !== 'require')
|
|
95
|
+
return;
|
|
96
|
+
const [argument] = node.arguments;
|
|
97
|
+
if (!argument || argument.type === 'SpreadElement') return;
|
|
98
|
+
if (!isRootSpecifier(argument)) return;
|
|
99
|
+
context.report({
|
|
100
|
+
node: argument,
|
|
101
|
+
messageId: 'ambiguousImport',
|
|
102
|
+
data: { packageName },
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
ExportNamedDeclaration(node) {
|
|
106
|
+
if (!isRootSpecifier(node.source)) return;
|
|
107
|
+
checkNamedExport(context, node, packageName);
|
|
108
|
+
},
|
|
109
|
+
ExportAllDeclaration(node) {
|
|
110
|
+
if (!isRootSpecifier(node.source)) return;
|
|
111
|
+
context.report({
|
|
112
|
+
node: node.source,
|
|
113
|
+
messageId: 'ambiguousExport',
|
|
114
|
+
data: { packageName },
|
|
115
|
+
});
|
|
116
|
+
},
|
|
44
117
|
};
|
|
45
118
|
},
|
|
46
119
|
};
|
|
47
120
|
|
|
121
|
+
function checkImport(context, node, packageName) {
|
|
122
|
+
// No specifiers at all: `import '@cratis/components';` — nothing to infer a subpath
|
|
123
|
+
// from.
|
|
124
|
+
if (node.specifiers.length === 0) {
|
|
125
|
+
context.report({
|
|
126
|
+
node: node.source,
|
|
127
|
+
messageId: 'ambiguousImport',
|
|
128
|
+
data: { packageName },
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// `import Default from '...'` and/or `import * as Namespace from '...'`.
|
|
134
|
+
const hasDefault = node.specifiers.some((s) => s.type === 'ImportDefaultSpecifier');
|
|
135
|
+
const hasNamespace = node.specifiers.some(
|
|
136
|
+
(s) => s.type === 'ImportNamespaceSpecifier',
|
|
137
|
+
);
|
|
138
|
+
if (hasDefault || hasNamespace) {
|
|
139
|
+
context.report({
|
|
140
|
+
node: node.source,
|
|
141
|
+
messageId: 'ambiguousImport',
|
|
142
|
+
data: { packageName },
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const named = node.specifiers.filter((s) => s.type === 'ImportSpecifier');
|
|
148
|
+
const kept = [];
|
|
149
|
+
const namespaced = [];
|
|
150
|
+
let hasUnknown = false;
|
|
151
|
+
|
|
152
|
+
for (const specifier of named) {
|
|
153
|
+
const importedName = specifier.imported.name ?? specifier.imported.value;
|
|
154
|
+
|
|
155
|
+
if (approvedRootSymbols.has(importedName)) {
|
|
156
|
+
kept.push(specifier);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (Object.hasOwn(namespaceSubpaths, importedName)) {
|
|
160
|
+
namespaced.push({ specifier, subpath: namespaceSubpaths[importedName] });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (removedRootSymbols.has(importedName)) {
|
|
164
|
+
hasUnknown = true;
|
|
165
|
+
context.report({
|
|
166
|
+
node: specifier,
|
|
167
|
+
messageId: 'removedCompatibilitySymbol',
|
|
168
|
+
data: { name: importedName },
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
hasUnknown = true;
|
|
174
|
+
context.report({
|
|
175
|
+
node: specifier,
|
|
176
|
+
messageId: 'unknownSymbol',
|
|
177
|
+
data: { name: importedName, packageName },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (namespaced.length === 0) return; // nothing to move (all approved, or all unknown-and-already-reported).
|
|
182
|
+
|
|
183
|
+
// Never guess: an unrecognized specifier means no autofix for the whole statement,
|
|
184
|
+
// even for the specifiers this rule does recognize.
|
|
185
|
+
const fix = hasUnknown
|
|
186
|
+
? undefined
|
|
187
|
+
: (fixer) => [
|
|
188
|
+
fixer.replaceText(
|
|
189
|
+
node,
|
|
190
|
+
buildReplacementText({
|
|
191
|
+
packageName,
|
|
192
|
+
declTypeOnly: node.importKind === 'type',
|
|
193
|
+
kept,
|
|
194
|
+
namespaced,
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
for (const { specifier, subpath } of namespaced) {
|
|
200
|
+
const importedName = specifier.imported.name ?? specifier.imported.value;
|
|
201
|
+
context.report({
|
|
202
|
+
node: specifier,
|
|
203
|
+
messageId: 'useNamespaceSubpath',
|
|
204
|
+
data: { name: importedName, packageName, subpath },
|
|
205
|
+
fix,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function checkNamedExport(context, node, packageName) {
|
|
211
|
+
if (!node.specifiers || node.specifiers.length === 0) {
|
|
212
|
+
// `export {} from '@cratis/components';` — nothing to report.
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const specifier of node.specifiers) {
|
|
217
|
+
const localNode = specifier.local ?? specifier;
|
|
218
|
+
const exportedName = localNode.name ?? localNode.value;
|
|
219
|
+
|
|
220
|
+
if (approvedRootSymbols.has(exportedName)) continue;
|
|
221
|
+
if (Object.hasOwn(namespaceSubpaths, exportedName)) {
|
|
222
|
+
context.report({
|
|
223
|
+
node: specifier,
|
|
224
|
+
messageId: 'useNamespaceSubpathExport',
|
|
225
|
+
data: {
|
|
226
|
+
name: exportedName,
|
|
227
|
+
packageName,
|
|
228
|
+
subpath: namespaceSubpaths[exportedName],
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (removedRootSymbols.has(exportedName)) {
|
|
234
|
+
context.report({
|
|
235
|
+
node: specifier,
|
|
236
|
+
messageId: 'removedCompatibilitySymbolExport',
|
|
237
|
+
data: { name: exportedName },
|
|
238
|
+
});
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
context.report({
|
|
243
|
+
node: specifier,
|
|
244
|
+
messageId: 'unknownSymbolExport',
|
|
245
|
+
data: { name: exportedName, packageName },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function specifierText(specifier, prefix = '') {
|
|
251
|
+
const importedName = specifier.imported.name ?? specifier.imported.value;
|
|
252
|
+
const localName = specifier.local.name;
|
|
253
|
+
const typeOnly = specifier.importKind === 'type';
|
|
254
|
+
const alias = localName === importedName ? '' : ` as ${localName}`;
|
|
255
|
+
return `${prefix}${typeOnly ? 'type ' : ''}${importedName}${alias}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildReplacementText({ packageName, declTypeOnly, kept, namespaced }) {
|
|
259
|
+
const lines = [];
|
|
260
|
+
|
|
261
|
+
if (kept.length > 0) {
|
|
262
|
+
const specifiers = kept.map((specifier) => specifierText(specifier)).join(', ');
|
|
263
|
+
lines.push(
|
|
264
|
+
`import ${declTypeOnly ? 'type ' : ''}{ ${specifiers} } from '${packageName}';`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const { specifier, subpath } of namespaced) {
|
|
269
|
+
const localName = specifier.local.name;
|
|
270
|
+
const typeOnly = declTypeOnly || specifier.importKind === 'type';
|
|
271
|
+
lines.push(
|
|
272
|
+
`import ${typeOnly ? 'type ' : ''}* as ${localName} from '${packageName}/${subpath}';`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return lines.join('\n');
|
|
277
|
+
}
|
|
278
|
+
|
|
48
279
|
export default noRootBarrelImport;
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// a return's own argument (which may itself contain a nested arrow).
|
|
4
4
|
const collectOwnReturns = (node, returns) => {
|
|
5
5
|
if (!node || typeof node.type !== 'string') return;
|
|
6
|
-
if (
|
|
6
|
+
if (
|
|
7
|
+
node.type === 'FunctionDeclaration' ||
|
|
8
|
+
node.type === 'FunctionExpression' ||
|
|
9
|
+
node.type === 'ArrowFunctionExpression'
|
|
10
|
+
) {
|
|
7
11
|
return;
|
|
8
12
|
}
|
|
9
13
|
if (node.type === 'ReturnStatement') {
|
|
@@ -35,22 +39,28 @@ const isOnBeforeExecuteCallback = (node) => {
|
|
|
35
39
|
const parent = node.parent;
|
|
36
40
|
if (!parent) return false;
|
|
37
41
|
|
|
38
|
-
if (
|
|
42
|
+
if (
|
|
43
|
+
parent.type === 'JSXExpressionContainer' &&
|
|
39
44
|
parent.parent?.type === 'JSXAttribute' &&
|
|
40
|
-
parent.parent.name?.name === 'onBeforeExecute'
|
|
45
|
+
parent.parent.name?.name === 'onBeforeExecute'
|
|
46
|
+
) {
|
|
41
47
|
return true;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
if (
|
|
50
|
+
if (
|
|
51
|
+
(parent.type === 'Property' || parent.type === 'PropertyDefinition') &&
|
|
45
52
|
parent.value === node &&
|
|
46
|
-
keyNameOf(parent.key) === 'onBeforeExecute'
|
|
53
|
+
keyNameOf(parent.key) === 'onBeforeExecute'
|
|
54
|
+
) {
|
|
47
55
|
return true;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
if (
|
|
58
|
+
if (
|
|
59
|
+
parent.type === 'VariableDeclarator' &&
|
|
51
60
|
parent.init === node &&
|
|
52
61
|
parent.id?.type === 'Identifier' &&
|
|
53
|
-
parent.id.name === 'onBeforeExecute'
|
|
62
|
+
parent.id.name === 'onBeforeExecute'
|
|
63
|
+
) {
|
|
54
64
|
return true;
|
|
55
65
|
}
|
|
56
66
|
|
|
@@ -58,30 +68,40 @@ const isOnBeforeExecuteCallback = (node) => {
|
|
|
58
68
|
};
|
|
59
69
|
|
|
60
70
|
// Require an `onBeforeExecute` callback to return the command values on every path. It is a
|
|
61
|
-
// transformer, not a side-effect hook
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
71
|
+
// transformer, not a side-effect hook. A runtime guard (`applyBeforeExecute`) prevents a
|
|
72
|
+
// missing return from executing with `undefined`: it falls back to the current object and logs
|
|
73
|
+
// a warning. In-place mutations may remain because the callback received that same object, but
|
|
74
|
+
// a replacement object is discarded and the callback still violates its contract. This lint
|
|
75
|
+
// backstop keeps JavaScript and loosely typed call sites from relying on that fallback. It flags
|
|
76
|
+
// a block-bodied callback that returns nothing, or a bare `return;`. It does not do full path
|
|
77
|
+
// analysis, so a callback that returns a value on some branches but can still fall through is
|
|
78
|
+
// not flagged.
|
|
66
79
|
export const onbeforeexecuteMustReturn = {
|
|
67
80
|
meta: {
|
|
68
81
|
type: 'problem',
|
|
69
82
|
docs: {
|
|
70
|
-
description:
|
|
83
|
+
description:
|
|
84
|
+
'Require an onBeforeExecute callback to return the command values; on a missing return the runtime falls back to the current object and warns, so replacement values are discarded.',
|
|
71
85
|
recommended: true,
|
|
72
86
|
url: 'https://github.com/Cratis/Components/blob/main/ESLint/README.md',
|
|
73
87
|
},
|
|
74
88
|
schema: [],
|
|
75
89
|
messages: {
|
|
76
|
-
missingReturn:
|
|
77
|
-
|
|
90
|
+
missingReturn:
|
|
91
|
+
'onBeforeExecute is a transformer and must return the command values. This callback can complete without returning, so the runtime falls back to the current object and logs a warning; any replacement value is discarded. Return the values (mutated or not).',
|
|
92
|
+
emptyReturn:
|
|
93
|
+
'onBeforeExecute must return the command values, not `return;`. The runtime falls back to the current object and logs a warning; any replacement value is discarded. Return the values (mutated or not).',
|
|
78
94
|
},
|
|
79
95
|
},
|
|
80
96
|
create(context) {
|
|
81
97
|
const check = (node) => {
|
|
82
98
|
if (!isOnBeforeExecuteCallback(node)) return;
|
|
83
99
|
// An expression-bodied arrow (`values => values`) always returns a value.
|
|
84
|
-
if (
|
|
100
|
+
if (
|
|
101
|
+
node.type === 'ArrowFunctionExpression' &&
|
|
102
|
+
node.body.type !== 'BlockStatement'
|
|
103
|
+
)
|
|
104
|
+
return;
|
|
85
105
|
|
|
86
106
|
const returns = [];
|
|
87
107
|
collectOwnReturns(node.body, returns);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Single source of truth for `no-root-barrel-import`'s namespace-aware behavior: every
|
|
6
|
+
* component namespace removed from the Components 3 root, and the small setup surface
|
|
7
|
+
* that remains supported at the Components 4 root.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `Source/index.ts` and the `exports` map in `Source/package.json`. When a
|
|
10
|
+
* namespace subpath is added, renamed, or removed there, update `namespaceSubpaths` here
|
|
11
|
+
* (and the matching copy in `Migrator/lib/namespaceMap.js`) in the same change. Neither
|
|
12
|
+
* file imports the other: this plugin and the codemods package ship and publish
|
|
13
|
+
* independently, so the map is intentionally duplicated rather than shared across a
|
|
14
|
+
* workspace boundary.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every retained namespace removed from the Components 3 root, mapped to the Components 4
|
|
19
|
+
* subpath it belongs to: importing `Canvas` from the root should instead be
|
|
20
|
+
* `import * as Canvas from '@cratis/components/Canvas'`. `Types` is the one case where the
|
|
21
|
+
* namespace name and its subpath differ in casing (`./types`, lowercase).
|
|
22
|
+
*/
|
|
23
|
+
export const namespaceSubpaths = {
|
|
24
|
+
Canvas: 'Canvas',
|
|
25
|
+
Chat: 'Chat',
|
|
26
|
+
CommandDialog: 'CommandDialog',
|
|
27
|
+
// The former root CommandStepper namespace aliased the entire CommandDialog module.
|
|
28
|
+
// Preserve that module identity; the narrower ./CommandStepper subpath does not.
|
|
29
|
+
CommandStepper: 'CommandDialog',
|
|
30
|
+
CommandForm: 'CommandForm',
|
|
31
|
+
Common: 'Common',
|
|
32
|
+
DataPage: 'DataPage',
|
|
33
|
+
DataTables: 'DataTables',
|
|
34
|
+
Dialogs: 'Dialogs',
|
|
35
|
+
Display: 'Display',
|
|
36
|
+
Dropdown: 'Dropdown',
|
|
37
|
+
Filter: 'Filter',
|
|
38
|
+
Notifications: 'Notifications',
|
|
39
|
+
ObjectContentEditor: 'ObjectContentEditor',
|
|
40
|
+
ObjectNavigationalBar: 'ObjectNavigationalBar',
|
|
41
|
+
PivotViewer: 'PivotViewer',
|
|
42
|
+
SchemaEditor: 'SchemaEditor',
|
|
43
|
+
TimeMachine: 'TimeMachine',
|
|
44
|
+
Toolbar: 'Toolbar',
|
|
45
|
+
Types: 'types',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Components 3 renderer-compatibility exports removed without a Components 4 subpath. */
|
|
49
|
+
export const removedRootSymbols = new Set([
|
|
50
|
+
'Compatibility',
|
|
51
|
+
'assertPrimeReact11PassThroughCompatibility',
|
|
52
|
+
'components3PrimeReact11PassThroughContract',
|
|
53
|
+
'primeReact11PassThroughSentinelAttribute',
|
|
54
|
+
'primeReact11PassThroughSentinelPreset',
|
|
55
|
+
'PrimeReact11PassThroughComponent',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The primary setup surface `Source/index.ts` re-exports directly from the root so a
|
|
60
|
+
* consumer that only wants provider setup does not need to know about the `Common`
|
|
61
|
+
* subpath. Importing one of these from the package root is allowed.
|
|
62
|
+
*/
|
|
63
|
+
export const approvedRootSymbols = new Set([
|
|
64
|
+
'CratisComponentsProvider',
|
|
65
|
+
'useCratisComponentsConfig',
|
|
66
|
+
'cratisDefaults',
|
|
67
|
+
'mergeCratisComponentsConfig',
|
|
68
|
+
'CratisComponentsConfig',
|
|
69
|
+
'CratisComponentsProviderProps',
|
|
70
|
+
'CratisComponentsMessages',
|
|
71
|
+
'CratisPaginatorMessages',
|
|
72
|
+
'CratisDatePickerMessages',
|
|
73
|
+
'CratisDropdownMessages',
|
|
74
|
+
'CratisDialogMessages',
|
|
75
|
+
'CratisStepperMessages',
|
|
76
|
+
'CratisNotificationsMessages',
|
|
77
|
+
'CratisDataTableMessages',
|
|
78
|
+
'CratisColumnFilterMessages',
|
|
79
|
+
]);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cratis/eslint-plugin-components",
|
|
3
|
-
"version": "
|
|
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.",
|
|
3
|
+
"version": "4.1.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, keep React hooks out of view models, protect the repository-owned React-free kernel, 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",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"index.js",
|
|
24
24
|
"lib",
|
|
25
|
-
"README.md"
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
26
27
|
],
|
|
27
28
|
"exports": {
|
|
28
29
|
"./package.json": "./package.json",
|
|
@@ -30,9 +31,10 @@
|
|
|
30
31
|
},
|
|
31
32
|
"scripts": {
|
|
32
33
|
"test": "yarn g:test",
|
|
34
|
+
"verify-package": "node ./test/verify-packed-package.mjs",
|
|
33
35
|
"ci": "yarn g:test"
|
|
34
36
|
},
|
|
35
37
|
"peerDependencies": {
|
|
36
38
|
"eslint": ">=9"
|
|
37
39
|
}
|
|
38
|
-
}
|
|
40
|
+
}
|