Skip to content

Commit 32c772d

Browse files
@NgDaddyrainerhahnekamp
@NgDaddy
andauthoredAug 15, 2024··
feat(eslint-plugin): add preferProtectedState rule (#4488)
Closes #4474 Co-authored-by: Rainer Hahnekamp <rainer.hahnekamp@gmail.com>
1 parent 8c499cf commit 32c772d

File tree

9 files changed

+216
-5
lines changed

9 files changed

+216
-5
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { ESLintUtils, TSESLint } from '@typescript-eslint/utils';
2+
import * as path from 'path';
3+
import rule, {
4+
preferProtectedState,
5+
preferProtectedStateSuggest,
6+
} from '../../../src/rules/signals/prefer-protected-state';
7+
import { ruleTester, fromFixture } from '../../utils';
8+
9+
type MessageIds = ESLintUtils.InferMessageIdsTypeFromRule<typeof rule>;
10+
type Options = readonly ESLintUtils.InferOptionsTypeFromRule<typeof rule>[];
11+
type RunTests = TSESLint.RunTests<MessageIds, Options>;
12+
13+
const valid: () => RunTests['valid'] = () => [
14+
`const mySignalStore = signalStore();`,
15+
`const mySignalStore = signalStore({ protectedState: true });`,
16+
`const mySignalStore = signalStore({ providedIn: 'root' });`,
17+
`const mySignalStore = signalStore({ providedIn: 'root', protectedState: true });`,
18+
];
19+
20+
const invalid: () => RunTests['invalid'] = () => [
21+
fromFixture(
22+
`
23+
const mySignalStore = signalStore({ providedIn: 'root', protectedState: false, });
24+
~~~~~~~~~~~~~~~~~~~~~ [${preferProtectedState} suggest]`,
25+
{
26+
suggestions: [
27+
{
28+
messageId: preferProtectedStateSuggest,
29+
output: `
30+
const mySignalStore = signalStore({ providedIn: 'root', });`,
31+
},
32+
],
33+
}
34+
),
35+
fromFixture(
36+
`
37+
const mySignalStore = signalStore({ providedIn: 'root', protectedState: false , });
38+
~~~~~~~~~~~~~~~~~~~~~ [${preferProtectedState} suggest]`,
39+
{
40+
suggestions: [
41+
{
42+
messageId: preferProtectedStateSuggest,
43+
output: `
44+
const mySignalStore = signalStore({ providedIn: 'root', });`,
45+
},
46+
],
47+
}
48+
),
49+
fromFixture(
50+
`
51+
const mySignalStore = signalStore({ protectedState: false, });
52+
~~~~~~~~~~~~~~~~~~~~~ [${preferProtectedState} suggest]`,
53+
{
54+
suggestions: [
55+
{
56+
messageId: preferProtectedStateSuggest,
57+
output: `
58+
const mySignalStore = signalStore();`,
59+
},
60+
],
61+
}
62+
),
63+
fromFixture(
64+
`
65+
const mySignalStore = signalStore({ protectedState: false, providedIn: 'root' });
66+
~~~~~~~~~~~~~~~~~~~~~ [${preferProtectedState} suggest]`,
67+
{
68+
suggestions: [
69+
{
70+
messageId: preferProtectedStateSuggest,
71+
output: `
72+
const mySignalStore = signalStore({ providedIn: 'root' });`,
73+
},
74+
],
75+
}
76+
),
77+
];
78+
79+
ruleTester().run(path.parse(__filename).name, rule, {
80+
valid: valid(),
81+
invalid: invalid(),
82+
});

‎modules/eslint-plugin/src/configs/all.json

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@ngrx/prefer-concat-latest-from": "error",
1616
"@ngrx/signal-state-no-arrays-at-root-level": "error",
1717
"@ngrx/signal-store-feature-should-use-generic-type": "error",
18+
"@ngrx/prefer-protected-state": "error",
1819
"@ngrx/with-state-no-arrays-at-root-level": "error",
1920
"@ngrx/avoid-combining-selectors": "error",
2021
"@ngrx/avoid-dispatching-multiple-actions-sequentially": "error",

‎modules/eslint-plugin/src/configs/all.ts

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default (
4343
'@ngrx/prefer-concat-latest-from': 'error',
4444
'@ngrx/signal-state-no-arrays-at-root-level': 'error',
4545
'@ngrx/signal-store-feature-should-use-generic-type': 'error',
46+
'@ngrx/prefer-protected-state': 'error',
4647
'@ngrx/with-state-no-arrays-at-root-level': 'error',
4748
'@ngrx/avoid-combining-selectors': 'error',
4849
'@ngrx/avoid-dispatching-multiple-actions-sequentially': 'error',

‎modules/eslint-plugin/src/configs/signals.json

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"rules": {
55
"@ngrx/signal-state-no-arrays-at-root-level": "error",
66
"@ngrx/signal-store-feature-should-use-generic-type": "error",
7+
"@ngrx/prefer-protected-state": "error",
78
"@ngrx/with-state-no-arrays-at-root-level": "error"
89
},
910
"parserOptions": {

‎modules/eslint-plugin/src/configs/signals.ts

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export default (
3232
rules: {
3333
'@ngrx/signal-state-no-arrays-at-root-level': 'error',
3434
'@ngrx/signal-store-feature-should-use-generic-type': 'error',
35+
'@ngrx/prefer-protected-state': 'error',
3536
'@ngrx/with-state-no-arrays-at-root-level': 'error',
3637
},
3738
},

‎modules/eslint-plugin/src/rules/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import preferConcatLatestFrom from './operators/prefer-concat-latest-from';
3535
import signalStateNoArraysAtRootLevel from './signals/signal-state-no-arrays-at-root-level';
3636
import signalStoreFeatureShouldUseGenericType from './signals/signal-store-feature-should-use-generic-type';
3737
import withStateNoArraysAtRootLevel from './signals/with-state-no-arrays-at-root-level';
38+
import preferProtectedState from './signals/prefer-protected-state';
3839

3940
export const rules = {
4041
// component-store
@@ -79,5 +80,6 @@ export const rules = {
7980
'signal-state-no-arrays-at-root-level': signalStateNoArraysAtRootLevel,
8081
'signal-store-feature-should-use-generic-type':
8182
signalStoreFeatureShouldUseGenericType,
83+
'prefer-protected-state': preferProtectedState,
8284
'with-state-no-arrays-at-root-level': withStateNoArraysAtRootLevel,
8385
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { type TSESTree } from '@typescript-eslint/utils';
2+
import * as path from 'path';
3+
import { createRule } from '../../rule-creator';
4+
5+
export const preferProtectedState = 'preferProtectedState';
6+
export const preferProtectedStateSuggest = 'preferProtectedStateSuggest';
7+
8+
type MessageIds =
9+
| typeof preferProtectedState
10+
| typeof preferProtectedStateSuggest;
11+
type Options = readonly [];
12+
13+
export default createRule<Options, MessageIds>({
14+
name: path.parse(__filename).name,
15+
meta: {
16+
type: 'suggestion',
17+
hasSuggestions: true,
18+
ngrxModule: 'signals',
19+
docs: {
20+
description: `A Signal Store prefers protected state`,
21+
},
22+
schema: [],
23+
messages: {
24+
[preferProtectedState]:
25+
'{ protectedState: false } should be removed to prevent external state mutations.',
26+
[preferProtectedStateSuggest]: 'Remove `{protectedState: false}`.',
27+
},
28+
},
29+
defaultOptions: [],
30+
create: (context) => {
31+
return {
32+
[`CallExpression[callee.name=signalStore][arguments.length>0] > ObjectExpression[properties.length>0] > Property[key.name=protectedState][value.value=false]`](
33+
node: TSESTree.Property
34+
) {
35+
context.report({
36+
node,
37+
messageId: preferProtectedState,
38+
suggest: [
39+
{
40+
messageId: preferProtectedStateSuggest,
41+
fix: (fixer) => {
42+
const getRangeToBeRemoved = (): Parameters<
43+
typeof fixer.removeRange
44+
>[0] => {
45+
const parentObject = node.parent as TSESTree.ObjectExpression;
46+
const parentObjectHasOnlyOneProperty =
47+
parentObject.properties.length === 1;
48+
49+
if (parentObjectHasOnlyOneProperty) {
50+
/**
51+
* Remove the entire object if it contains only one property - the relevant one
52+
*/
53+
return parentObject.range;
54+
}
55+
56+
const tokenAfter = context.sourceCode.getTokenAfter(node);
57+
const tokenAfterIsComma = tokenAfter?.value?.trim() === ',';
58+
/**
59+
* Remove the specific property if there is more than one property in the parent
60+
*/
61+
return [
62+
node.range[0],
63+
/**
64+
* remove trailing comma as well
65+
*/
66+
tokenAfterIsComma ? tokenAfter.range[1] : node.range[1],
67+
];
68+
};
69+
70+
return fixer.removeRange(getRangeToBeRemoved());
71+
},
72+
},
73+
],
74+
});
75+
},
76+
};
77+
},
78+
});

‎projects/ngrx.io/content/guide/eslint-plugin/index.md

+6-5
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,12 @@ module.exports = tseslint.config({
123123

124124
### signals
125125

126-
| Name | Description | Category | Fixable | Has suggestions | Configurable | Requires type information |
127-
| ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------- | ------- | --------------- | ------------ | ------------------------- |
128-
| [@ngrx/signal-state-no-arrays-at-root-level](/guide/eslint-plugin/rules/signal-state-no-arrays-at-root-level) | signalState should accept a record or dictionary as an input argument. | problem | No | No | No | No |
129-
| [@ngrx/signal-store-feature-should-use-generic-type](/guide/eslint-plugin/rules/signal-store-feature-should-use-generic-type) | A custom Signal Store feature that accepts an input should define a generic type. | problem | Yes | No | No | No |
130-
| [@ngrx/with-state-no-arrays-at-root-level](/guide/eslint-plugin/rules/with-state-no-arrays-at-root-level) | withState should accept a record or dictionary as an input argument. | problem | No | No | No | Yes |
126+
| Name | Description | Category | Fixable | Has suggestions | Configurable | Requires type information |
127+
|------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------| ---------- |---------| --------------- | ------------ |--------------------------|
128+
| [@ngrx/signal-state-no-arrays-at-root-level](/guide/eslint-plugin/rules/signal-state-no-arrays-at-root-level) | signalState should accept a record or dictionary as an input argument. | problem | No | No | No | No |
129+
| [@ngrx/signal-store-feature-should-use-generic-type](/guide/eslint-plugin/rules/signal-store-feature-should-use-generic-type) | A custom Signal Store feature that accepts an input should define a generic type. | problem | Yes | No | No | No |
130+
| [@ngrx/prefer-protected-state](/guide/eslint-plugin/rules/prefer-protected-state) | A Signal Store prefers protected state. | suggestion | No | Yes | No | No |
131+
| [@ngrx/with-state-no-arrays-at-root-level](/guide/eslint-plugin/rules/with-state-no-arrays-at-root-level) | withState should accept a record or dictionary as an input argument. | problem | No | No | No | Yes |
131132

132133
### store
133134

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# prefer-protected-state
2+
3+
A Signal Store prefers protected state.
4+
5+
- **Type**: suggestion
6+
- **Fixable**: No
7+
- **Suggestion**: Yes
8+
- **Requires type checking**: No
9+
- **Configurable**: No
10+
11+
<!-- Everything above this generated, do not edit -->
12+
<!-- MANUAL-DOC:START -->
13+
14+
## Rule Details
15+
16+
This rule ensures that state changes are only managed by the Signal Store to prevent unintended modifications and provide clear ownership of where changes occur.
17+
18+
Examples of **incorrect** code for this rule:
19+
20+
```ts
21+
// SUGGESTION ❗
22+
const Store = signalStore(
23+
{ protectedState: false },
24+
~~~~~~~~~~~~~~~~~~~~~ [warning]
25+
withState({}),
26+
);
27+
```
28+
29+
Examples of **correct** code for this rule:
30+
31+
```ts
32+
// GOOD ✅
33+
const Store = signalStore(
34+
withState({}),
35+
);
36+
```
37+
38+
```ts
39+
// GOOD ✅
40+
const Store = signalStore(
41+
{ protectedState: true },
42+
withState({}),
43+
);
44+
```

0 commit comments

Comments
 (0)
Please sign in to comment.