eslint-plugin-effector 0.4.2 → 0.5.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.
Files changed (34) hide show
  1. package/README.md +20 -3
  2. package/config/react.js +5 -0
  3. package/config/recommended.js +1 -0
  4. package/config/scope.js +5 -0
  5. package/index.js +5 -0
  6. package/package.json +4 -1
  7. package/rules/enforce-effect-naming-convention/enforce-effect-naming-convention.js +16 -17
  8. package/rules/enforce-gate-naming-convention/enforce-gate-naming-convention.js +114 -0
  9. package/rules/enforce-gate-naming-convention/enforce-gate-naming-convention.md +11 -0
  10. package/rules/enforce-store-naming-convention/enforce-store-naming-convention.js +25 -25
  11. package/rules/enforce-store-naming-convention/enforce-store-naming-convention.md +11 -3
  12. package/rules/no-ambiguity-target/no-ambiguity-target.js +6 -4
  13. package/rules/no-duplicate-on/no-duplicate-on.js +128 -0
  14. package/rules/no-duplicate-on/no-duplicate-on.md +16 -0
  15. package/rules/no-getState/no-getState.js +9 -36
  16. package/rules/no-unnecessary-combination/no-unnecessary-combination.js +6 -4
  17. package/rules/no-unnecessary-combination/no-unnecessary-combination.md +15 -4
  18. package/rules/no-unnecessary-duplication/no-unnecessary-duplication.js +6 -5
  19. package/rules/no-useless-methods/no-useless-methods.js +6 -4
  20. package/rules/no-watch/no-watch.js +6 -12
  21. package/rules/prefer-sample-over-forward-with-mapping/prefer-sample-over-forward-with-mapping.js +6 -4
  22. package/rules/strict-effect-handlers/strict-effect-handlers.js +74 -0
  23. package/rules/strict-effect-handlers/strict-effect-handlers.md +38 -0
  24. package/rules/tsconfig.json +1 -0
  25. package/utils/extract-imported-from.js +9 -0
  26. package/utils/get-corrected-store-name.js +12 -14
  27. package/utils/get-nested-object-name.js +18 -0
  28. package/utils/get-store-name-convention.js +3 -3
  29. package/utils/is.js +30 -0
  30. package/utils/naming.js +47 -0
  31. package/utils/node-type-is.js +55 -0
  32. package/utils/validate-store-name-convention.js +7 -7
  33. package/utils/extract-imported-from-effector.js +0 -8
  34. package/utils/is-store-name-valid.js +0 -22
package/README.md CHANGED
@@ -25,7 +25,7 @@ Add `effector` to the plugins section of your `.eslintrc` configuration file. Yo
25
25
  ```json
26
26
  {
27
27
  "plugins": ["effector"],
28
- "extends": ["plugin:effector/recommended"]
28
+ "extends": ["plugin:effector/recommended", "plugin:effector/scope"]
29
29
  }
30
30
  ```
31
31
 
@@ -39,7 +39,21 @@ To configure individual rules:
39
39
  }
40
40
  ```
41
41
 
42
- ## Supported Rules
42
+ ### Available presets
43
+
44
+ #### plugin:effector/recommended
45
+
46
+ This preset is recommended for most projects.
47
+
48
+ #### plugin:effector/scope
49
+
50
+ This preset is recommended for projects that use [Fork API](https://effector.dev/docs/api/effector/scope). You can read more about Fork API in [an article](https://dev.to/effector/the-best-part-of-effector-4c27).
51
+
52
+ #### plugin:effector/react
53
+
54
+ This preset is recommended for projects that use [React](https://reactjs.org) with Effector.
55
+
56
+ ### Supported rules
43
57
 
44
58
  - [effector/enforce-store-naming-convention](rules/enforce-store-naming-convention/enforce-store-naming-convention.md)
45
59
  - [effector/enforce-effect-naming-convention](rules/enforce-effect-naming-convention/enforce-effect-naming-convention.md)
@@ -50,6 +64,9 @@ To configure individual rules:
50
64
  - [effector/prefer-sample-over-forward-with-mapping](rules/prefer-sample-over-forward-with-mapping/prefer-sample-over-forward-with-mapping.md)
51
65
  - [effector/no-watch](rules/no-watch/no-watch.md)
52
66
  - [effector/no-unnecessary-combination](rules/no-unnecessary-combination/no-unnecessary-combination.md)
67
+ - [effector/no-duplicate-on](rules/no-duplicate-on/no-duplicate-on.md)
68
+ - [effector/strict-effect-handlers](rules/strict-effect-handlers/strict-effect-handlers.md)
69
+ - [effector/enforce-gate-naming-convention](rules/enforce-gate-naming-convention/enforce-gate-naming-convention.md)
53
70
 
54
71
  ## Maintenance
55
72
 
@@ -57,7 +74,7 @@ To configure individual rules:
57
74
 
58
75
  1. Bump `version` in [package.json](package.json)
59
76
  2. Fill [CHANGELOG.md](CHANGELOG.md)
60
- 3. Commit changes by `git commin -m "Release X.X.X"`
77
+ 3. Commit changes by `git commit -m "Release X.X.X"`
61
78
  4. Create git tag for release by `git tag -a vX.X.X -m "vX.X.X"`
62
79
  5. Push changes to remote by `git push --follow-tags`
63
80
  6. Release package to registry by `yarn clean-publish`
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ rules: {
3
+ "effector/enforce-gate-naming-convention": "error",
4
+ },
5
+ };
@@ -9,5 +9,6 @@ module.exports = {
9
9
  "effector/no-ambiguity-target": "warn",
10
10
  "effector/no-watch": "warn",
11
11
  "effector/no-unnecessary-combination": "warn",
12
+ "effector/no-duplicate-on": "error",
12
13
  },
13
14
  };
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ rules: {
3
+ "effector/strict-effect-handlers": "error",
4
+ },
5
+ };
package/index.js CHANGED
@@ -9,8 +9,13 @@ module.exports = {
9
9
  "no-ambiguity-target": require("./rules/no-ambiguity-target/no-ambiguity-target"),
10
10
  "no-watch": require("./rules/no-watch/no-watch"),
11
11
  "no-unnecessary-combination": require("./rules/no-unnecessary-combination/no-unnecessary-combination"),
12
+ "no-duplicate-on": require("./rules/no-duplicate-on/no-duplicate-on"),
13
+ "strict-effect-handlers": require("./rules/strict-effect-handlers/strict-effect-handlers"),
14
+ "enforce-gate-naming-convention": require("./rules/enforce-gate-naming-convention/enforce-gate-naming-convention"),
12
15
  },
13
16
  configs: {
14
17
  recommended: require("./config/recommended"),
18
+ scope: require("./config/scope"),
19
+ react: require("./config/react"),
15
20
  },
16
21
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-effector",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Enforcing best practices for Effector",
5
5
  "keywords": [
6
6
  "eslint",
@@ -24,5 +24,8 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "prettier": "^2.3.2"
27
+ },
28
+ "nano-staged": {
29
+ "*.{js,ts,md}": "prettier --write"
27
30
  }
28
31
  }
@@ -1,7 +1,7 @@
1
- const {
2
- extractImportedFromEffector,
3
- } = require("../../utils/extract-imported-from-effector");
1
+ const { extractImportedFrom } = require("../../utils/extract-imported-from");
4
2
  const { createLinkToRule } = require("../../utils/create-link-to-rule");
3
+ const { nodeTypeIs } = require("../../utils/node-type-is");
4
+ const { namingOf } = require("../../utils/naming");
5
5
 
6
6
  module.exports = {
7
7
  meta: {
@@ -27,13 +27,10 @@ module.exports = {
27
27
  if (parserServices.hasFullTypeInformation) {
28
28
  return {
29
29
  VariableDeclarator(node) {
30
- const checker = parserServices.program.getTypeChecker();
31
- const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node);
32
- const type = checker.getTypeAtLocation(originalNode.initializer);
33
-
34
- const isEffectorEffect =
35
- type?.symbol?.escapedName === "Effect" &&
36
- type?.symbol?.parent?.escapedName?.includes("effector");
30
+ const isEffectorEffect = nodeTypeIs.effect({
31
+ node,
32
+ context,
33
+ });
37
34
 
38
35
  if (!isEffectorEffect) {
39
36
  return;
@@ -41,11 +38,9 @@ module.exports = {
41
38
 
42
39
  const effectName = node.id.name;
43
40
 
44
- if (effectName?.endsWith("Fx")) {
45
- return;
41
+ if (namingOf.effect.isInvalid({ name: effectName })) {
42
+ reportEffectNameConventionViolation({ context, node, effectName });
46
43
  }
47
-
48
- reportEffectNameConventionViolation({ context, node, effectName });
49
44
  },
50
45
  };
51
46
  }
@@ -54,7 +49,11 @@ module.exports = {
54
49
  const importedFromEffector = new Map();
55
50
  return {
56
51
  ImportDeclaration(node) {
57
- extractImportedFromEffector(importedFromEffector, node);
52
+ extractImportedFrom({
53
+ importMap: importedFromEffector,
54
+ node,
55
+ packageName: "effector",
56
+ });
58
57
  },
59
58
  CallExpression(node) {
60
59
  // Effect creation with method
@@ -77,7 +76,7 @@ module.exports = {
77
76
  }
78
77
 
79
78
  const effectName = node.parent.id.name;
80
- if (effectName.endsWith("Fx")) {
79
+ if (namingOf.effect.isValid({ name: effectName })) {
81
80
  continue;
82
81
  }
83
82
 
@@ -101,7 +100,7 @@ module.exports = {
101
100
  }
102
101
 
103
102
  const effectName = node.parent.id.name;
104
- if (effectName.endsWith("Fx")) {
103
+ if (namingOf.effect.isValid({ name: effectName })) {
105
104
  return;
106
105
  }
107
106
 
@@ -0,0 +1,114 @@
1
+ const { extractImportedFrom } = require("../../utils/extract-imported-from");
2
+ const { nodeTypeIs } = require("../../utils/node-type-is");
3
+ const { createLinkToRule } = require("../../utils/create-link-to-rule");
4
+ const { namingOf } = require("../../utils/naming");
5
+
6
+ module.exports = {
7
+ meta: {
8
+ type: "problem",
9
+ docs: {
10
+ description: "Enforce first capital letter for gate naming",
11
+ category: "Naming",
12
+ recommended: true,
13
+ url: createLinkToRule("enforce-gate-naming-convention"),
14
+ },
15
+ messages: {
16
+ invalidName:
17
+ 'Gate "{{ gateName }}" should be named with first capital letter, rename it to "{{ correctedGateName }}"',
18
+ renameGate: 'Rename "{{ gateName }}" to "{{ correctedGateName }}"',
19
+ },
20
+ schema: [],
21
+ hasSuggestions: true,
22
+ },
23
+ create(context) {
24
+ const parserServices = context.parserServices;
25
+ // TypeScript-way
26
+ if (parserServices.hasFullTypeInformation) {
27
+ return {
28
+ VariableDeclarator(node) {
29
+ const isEffectorGate = nodeTypeIs.gate({
30
+ node,
31
+ context,
32
+ });
33
+
34
+ if (!isEffectorGate) {
35
+ return;
36
+ }
37
+
38
+ const gateName = node.id.name;
39
+
40
+ if (namingOf.gate.isInvalid({ name: gateName })) {
41
+ reportGateNameConventionViolation({ context, node, gateName });
42
+ }
43
+ },
44
+ };
45
+ }
46
+
47
+ // JavaScript-way
48
+ const importedFromEffectorReact = new Map();
49
+ return {
50
+ ImportDeclaration(node) {
51
+ extractImportedFrom({
52
+ importMap: importedFromEffectorReact,
53
+ node,
54
+ packageName: "effector-react",
55
+ });
56
+ },
57
+ CallExpression(node) {
58
+ // Effect creation with method
59
+ const GATE_CREATION_METHODS = ["createGate"];
60
+ for (const method of GATE_CREATION_METHODS) {
61
+ const localMethod = importedFromEffectorReact.get(method);
62
+ if (!localMethod) {
63
+ continue;
64
+ }
65
+
66
+ const isEffectorGateCreation = node.callee.name === localMethod;
67
+ if (!isEffectorGateCreation) {
68
+ continue;
69
+ }
70
+
71
+ const resultSavedInVariable =
72
+ node.parent.type === "VariableDeclarator";
73
+ if (!resultSavedInVariable) {
74
+ continue;
75
+ }
76
+
77
+ const gateName = node.parent.id.name;
78
+ if (namingOf.gate.isValid({ name: gateName })) {
79
+ continue;
80
+ }
81
+
82
+ reportGateNameConventionViolation({
83
+ context,
84
+ node: node.parent,
85
+ gateName,
86
+ });
87
+ }
88
+ },
89
+ };
90
+ },
91
+ };
92
+
93
+ function reportGateNameConventionViolation({ context, node, gateName }) {
94
+ const [firstChar, ...restChars] = gateName.split("");
95
+ const correctedGateName = [firstChar.toUpperCase(), ...restChars].join("");
96
+
97
+ context.report({
98
+ node,
99
+ messageId: "invalidName",
100
+ data: {
101
+ gateName,
102
+ correctedGateName,
103
+ },
104
+ suggest: [
105
+ {
106
+ messageId: "renameGate",
107
+ data: { gateName, correctedGateName },
108
+ fix(fixer) {
109
+ return fixer.replaceTextRange(node.id.range, correctedGateName);
110
+ },
111
+ },
112
+ ],
113
+ });
114
+ }
@@ -0,0 +1,11 @@
1
+ # effector/enforce-gate-naming-convention
2
+
3
+ Enforcing naming conventions helps keep the codebase consistent, and reduces overhead when thinking about how to name a variable with gate. Every gate is a react-component, so it should be named as regular react-compoent.
4
+
5
+ ```ts
6
+ // 👍 nice name
7
+ const MyFavoritePageGate = createGate();
8
+
9
+ // 👎 bad name
10
+ const otherFavoritePageGate = createGate();
11
+ ```
@@ -1,7 +1,5 @@
1
- const {
2
- extractImportedFromEffector,
3
- } = require("../../utils/extract-imported-from-effector");
4
- const { isStoreNameValid } = require("../../utils/is-store-name-valid");
1
+ const { extractImportedFrom } = require("../../utils/extract-imported-from");
2
+ const { namingOf } = require("../../utils/naming");
5
3
  const {
6
4
  validateStoreNameConvention,
7
5
  } = require("../../utils/validate-store-name-convention");
@@ -12,6 +10,7 @@ const {
12
10
  getCorrectedStoreName,
13
11
  } = require("../../utils/get-corrected-store-name");
14
12
  const { createLinkToRule } = require("../../utils/create-link-to-rule");
13
+ const { nodeTypeIs } = require("../../utils/node-type-is");
15
14
 
16
15
  module.exports = {
17
16
  meta: {
@@ -40,13 +39,10 @@ module.exports = {
40
39
  if (parserServices.hasFullTypeInformation) {
41
40
  return {
42
41
  VariableDeclarator(node) {
43
- const checker = parserServices.program.getTypeChecker();
44
- const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node);
45
- const type = checker.getTypeAtLocation(originalNode.initializer);
46
-
47
- const isEffectorStore =
48
- type?.symbol?.escapedName === "Store" &&
49
- type?.symbol?.parent?.escapedName?.includes("effector");
42
+ const isEffectorStore = nodeTypeIs.store({
43
+ node,
44
+ context,
45
+ });
50
46
 
51
47
  if (!isEffectorStore) {
52
48
  return;
@@ -54,15 +50,13 @@ module.exports = {
54
50
 
55
51
  const storeName = node.id.name;
56
52
 
57
- if (isStoreNameValid(storeName, context)) {
58
- return;
53
+ if (namingOf.store.isInvalid({ name: storeName, context })) {
54
+ reportStoreNameConventionViolation({
55
+ context,
56
+ node,
57
+ storeName,
58
+ });
59
59
  }
60
-
61
- reportStoreNameConventionViolation({
62
- context,
63
- node,
64
- storeName,
65
- });
66
60
  },
67
61
  };
68
62
  }
@@ -71,7 +65,11 @@ module.exports = {
71
65
  const importedFromEffector = new Map();
72
66
  return {
73
67
  ImportDeclaration(node) {
74
- extractImportedFromEffector(importedFromEffector, node);
68
+ extractImportedFrom({
69
+ importMap: importedFromEffector,
70
+ node,
71
+ packageName: "effector",
72
+ });
75
73
  },
76
74
  CallExpression(node) {
77
75
  // Store creation with method
@@ -95,7 +93,7 @@ module.exports = {
95
93
 
96
94
  const storeName = node.parent.id.name;
97
95
 
98
- if (isStoreNameValid(storeName, context)) {
96
+ if (namingOf.store.isValid({ name: storeName, context })) {
99
97
  continue;
100
98
  }
101
99
 
@@ -111,7 +109,9 @@ module.exports = {
111
109
  if (node.callee?.property?.name === "map") {
112
110
  const storeNameCreatedFromMap = node.callee?.object?.name;
113
111
 
114
- if (!isStoreNameValid(storeNameCreatedFromMap, context)) {
112
+ if (
113
+ namingOf.store.isInvalid({ name: storeNameCreatedFromMap, context })
114
+ ) {
115
115
  return;
116
116
  }
117
117
 
@@ -123,7 +123,7 @@ module.exports = {
123
123
 
124
124
  const storeName = node.parent.id.name;
125
125
 
126
- if (isStoreNameValid(storeName, context)) {
126
+ if (namingOf.store.isValid({ name: storeName, context })) {
127
127
  return;
128
128
  }
129
129
 
@@ -148,7 +148,7 @@ module.exports = {
148
148
 
149
149
  const storeName = node.parent.id.name;
150
150
 
151
- if (isStoreNameValid(storeName, context)) {
151
+ if (namingOf.store.isValid({ name: storeName, context })) {
152
152
  return;
153
153
  }
154
154
 
@@ -179,7 +179,7 @@ function reportStoreNameConventionViolation({ context, node, storeName }) {
179
179
  suggest: [
180
180
  {
181
181
  messageId: "renameStore",
182
- data: { storeName },
182
+ data: { storeName, correctedStoreName },
183
183
  fix(fixer) {
184
184
  return fixer.replaceTextRange(node.id.range, correctedStoreName);
185
185
  },
@@ -3,7 +3,9 @@
3
3
  Enforcing naming conventions helps keep the codebase consistent, and reduces overhead when thinking about how to name a variable with store. Depending on the configuration your stores should be distinguished by a prefix or a postfix $. Enforces prefix convention by default.
4
4
 
5
5
  ## Prefix convention
6
+
6
7
  When configured as:
8
+
7
9
  ```js
8
10
  module.exports = {
9
11
  rules: {
@@ -11,7 +13,9 @@ module.exports = {
11
13
  },
12
14
  };
13
15
  ```
16
+
14
17
  Prefix convention will be enforced:
18
+
15
19
  ```ts
16
20
  // 👍 nice name
17
21
  const $name = createStore(null);
@@ -19,9 +23,11 @@ const $name = createStore(null);
19
23
  // 👎 bad name
20
24
  const name = createStore(null);
21
25
  ```
26
+
22
27
  ## Postfix convention
23
28
 
24
29
  When configured as:
30
+
25
31
  ```js
26
32
  module.exports = {
27
33
  rules: {
@@ -29,12 +35,14 @@ module.exports = {
29
35
  },
30
36
  settings: {
31
37
  effector: {
32
- storeNameConvention: "postfix"
33
- }
34
- }
38
+ storeNameConvention: "postfix",
39
+ },
40
+ },
35
41
  };
36
42
  ```
43
+
37
44
  Postfix convention will be enforced:
45
+
38
46
  ```ts
39
47
  // 👍 nice name
40
48
  const name$ = createStore(null);
@@ -1,6 +1,4 @@
1
- const {
2
- extractImportedFromEffector,
3
- } = require("../../utils/extract-imported-from-effector");
1
+ const { extractImportedFrom } = require("../../utils/extract-imported-from");
4
2
  const { traverseParentByType } = require("../../utils/traverse-parent-by-type");
5
3
  const { createLinkToRule } = require("../../utils/create-link-to-rule");
6
4
 
@@ -24,7 +22,11 @@ module.exports = {
24
22
 
25
23
  return {
26
24
  ImportDeclaration(node) {
27
- extractImportedFromEffector(importedFromEffector, node);
25
+ extractImportedFrom({
26
+ importMap: importedFromEffector,
27
+ node,
28
+ packageName: "effector",
29
+ });
28
30
  },
29
31
  CallExpression(node) {
30
32
  const POSSIBLE_USELESS_METHODS = ["sample", "guard"];
@@ -0,0 +1,128 @@
1
+ const { createLinkToRule } = require("../../utils/create-link-to-rule");
2
+ const { getNestedObjectName } = require("../../utils/get-nested-object-name");
3
+ const { is } = require("../../utils/is");
4
+
5
+ module.exports = {
6
+ meta: {
7
+ type: "problem",
8
+ docs: {
9
+ description: "Forbids duplicate `.on` calls on store",
10
+ category: "Quality",
11
+ recommended: true,
12
+ url: createLinkToRule("no-duplicate-on"),
13
+ },
14
+ messages: {
15
+ duplicateOn:
16
+ "Method `.on` is called on store `{{ storeName }}` more than once for {{ unitName }}.",
17
+ },
18
+ schema: [],
19
+ },
20
+ create(context) {
21
+ const usedOns = new Map();
22
+
23
+ function isEventUsedInStoreOn(scope, storeName, unitName) {
24
+ const usedOnsOnScope = usedOns.get(scope);
25
+
26
+ if (!usedOnsOnScope) {
27
+ return false;
28
+ }
29
+
30
+ const usedUnits = usedOnsOnScope.get(storeName);
31
+
32
+ if (!usedUnits) {
33
+ return false;
34
+ }
35
+
36
+ return usedUnits.has(unitName);
37
+ }
38
+
39
+ function markUnitAsUsedInStoreOn(scope, storeName, unitNames) {
40
+ let usedOnsOnScope = usedOns.get(scope);
41
+
42
+ if (!usedOnsOnScope) {
43
+ usedOnsOnScope = new Map();
44
+ usedOns.set(scope, usedOnsOnScope);
45
+ }
46
+
47
+ let usedUnits = usedOnsOnScope.get(storeName);
48
+
49
+ if (!usedUnits) {
50
+ usedUnits = new Set();
51
+ usedOnsOnScope.set(storeName, usedUnits);
52
+ }
53
+
54
+ usedUnits.add(...unitNames);
55
+ }
56
+
57
+ return {
58
+ 'CallExpression[callee.property.name="on"]'(node) {
59
+ const storeObject = getNestedCallee(node) ?? getAssignedVariable(node);
60
+ const storeName = getStoreName(storeObject);
61
+
62
+ if (!is.store({ context, node: storeObject })) {
63
+ return;
64
+ }
65
+
66
+ const triggerObjects = normalizePossibleArrayNode(node.arguments[0]);
67
+ const unitNames = triggerObjects.map(getNestedObjectName);
68
+
69
+ const scope = context.getScope();
70
+
71
+ for (const unitName of unitNames) {
72
+ const unitAlreadyUsed = isEventUsedInStoreOn(
73
+ scope,
74
+ storeName,
75
+ unitName
76
+ );
77
+
78
+ if (unitAlreadyUsed) {
79
+ context.report({
80
+ node,
81
+ messageId: "duplicateOn",
82
+ data: {
83
+ storeName,
84
+ unitName,
85
+ },
86
+ });
87
+
88
+ return;
89
+ }
90
+ }
91
+
92
+ markUnitAsUsedInStoreOn(scope, storeName, unitNames);
93
+ },
94
+ };
95
+ },
96
+ };
97
+
98
+ function normalizePossibleArrayNode(node) {
99
+ if (node.type === "ArrayExpression") {
100
+ return node.elements;
101
+ }
102
+
103
+ return [node];
104
+ }
105
+
106
+ function getNestedCallee(node) {
107
+ const { callee } = node;
108
+
109
+ if (callee.object?.type === "CallExpression") {
110
+ return getNestedCallee(callee.object);
111
+ }
112
+
113
+ return callee.object;
114
+ }
115
+
116
+ function getAssignedVariable(node) {
117
+ const { parent } = node;
118
+
119
+ if (parent.type === "VariableDeclarator") {
120
+ return parent;
121
+ }
122
+
123
+ return getAssignedVariable(parent);
124
+ }
125
+
126
+ function getStoreName(node) {
127
+ return node.name ?? node.id.name;
128
+ }
@@ -0,0 +1,16 @@
1
+ # effector/no-duplicate-on
2
+
3
+ Disallows duplcates `on`-handlers on particular store.
4
+
5
+ ```ts
6
+ const increment = createEvent();
7
+
8
+ // 👍 all explicitly
9
+ const $goodCounter = createStore(0).on(increment, (counter) => counter + 1);
10
+
11
+ // 👎 so, which handler should we choose?
12
+ // it's better to remove one of them
13
+ const $badCounter = createStore(0)
14
+ .on(increment, (counter) => counter + 1)
15
+ .on(increment, (counter) => counter + 2);
16
+ ```
@@ -1,8 +1,8 @@
1
1
  const {
2
2
  traverseNestedObjectNode,
3
3
  } = require("../../utils/traverse-nested-object-node");
4
- const { isStoreNameValid } = require("../../utils/is-store-name-valid");
5
4
  const { createLinkToRule } = require("../../utils/create-link-to-rule");
5
+ const { is } = require("../../utils/is");
6
6
 
7
7
  module.exports = {
8
8
  meta: {
@@ -20,47 +20,20 @@ module.exports = {
20
20
  schema: [],
21
21
  },
22
22
  create(context) {
23
- const { parserServices } = context;
24
-
25
23
  return {
26
- CallExpression(node) {
27
- const methodName = node.callee?.property?.name;
28
- if (methodName !== "getState") {
29
- return;
30
- }
24
+ 'CallExpression[callee.property.name="getState"]'(node) {
25
+ const storeNode = traverseNestedObjectNode(node.callee?.object);
31
26
 
32
- const object = traverseNestedObjectNode(node.callee?.object);
33
- const objectName = object?.name;
27
+ const isEffectorStore = is.store({
28
+ context,
29
+ node: storeNode,
30
+ });
34
31
 
35
- if (!objectName) {
32
+ if (!isEffectorStore) {
36
33
  return;
37
34
  }
38
35
 
39
- // TypeScript-way
40
- if (parserServices.hasFullTypeInformation) {
41
- const checker = parserServices.program.getTypeChecker();
42
- const originalNode = parserServices.esTreeNodeToTSNodeMap.get(object);
43
- const type = checker.getTypeAtLocation(originalNode);
44
-
45
- const isEffectorStore =
46
- type?.symbol?.escapedName === "Store" &&
47
- type?.symbol?.parent?.escapedName?.includes("effector");
48
-
49
- if (!isEffectorStore) {
50
- return;
51
- }
52
-
53
- reportGetStateCall({ context, node, storeName: objectName });
54
- }
55
- // JavaScript-way
56
- else {
57
- const isEffectorStore = isStoreNameValid(objectName, context);
58
- if (!isEffectorStore) {
59
- return;
60
- }
61
-
62
- reportGetStateCall({ context, node, storeName: objectName });
63
- }
36
+ reportGetStateCall({ context, node, storeName: storeNode.name });
64
37
  },
65
38
  };
66
39
  },
@@ -1,6 +1,4 @@
1
- const {
2
- extractImportedFromEffector,
3
- } = require("../../utils/extract-imported-from-effector");
1
+ const { extractImportedFrom } = require("../../utils/extract-imported-from");
4
2
  const { createLinkToRule } = require("../../utils/create-link-to-rule");
5
3
 
6
4
  module.exports = {
@@ -24,7 +22,11 @@ module.exports = {
24
22
 
25
23
  return {
26
24
  ImportDeclaration(node) {
27
- extractImportedFromEffector(importedFromEffector, node);
25
+ extractImportedFrom({
26
+ importMap: importedFromEffector,
27
+ node,
28
+ packageName: "effector",
29
+ });
28
30
  },
29
31
  CallExpression(node) {
30
32
  const METHODS_WITH_POSSIBLE_UNNECESSARY_COMBINATION = [
@@ -4,11 +4,22 @@ Call of `combine`/`merge` in `clock`/`source` is unnecessary. It can be omitted
4
4
 
5
5
  ```ts
6
6
  // 👎 can be simplified
7
- const badEventOne = guard({ clock: combine($store1, $store2), filter: $filter });
8
- const badEventOne = guard({ clock: combine($store1, $store2, (store1, store2) => ({x: store1, y: store2})), filter: $filter });
7
+ const badEventOne = guard({
8
+ clock: combine($store1, $store2),
9
+ filter: $filter,
10
+ });
11
+ const badEventOne = guard({
12
+ clock: combine($store1, $store2, (store1, store2) => ({
13
+ x: store1,
14
+ y: store2,
15
+ })),
16
+ filter: $filter,
17
+ });
9
18
 
10
19
  // 👍 better
11
20
  const goodEventOne = guard({ clock: [$store1, $store2], filter: $filter });
12
- const goodEventTwo = guard({ clock: ({x: $store1, x: $store2}), filter: $filter });
21
+ const goodEventTwo = guard({
22
+ clock: { x: $store1, x: $store2 },
23
+ filter: $filter,
24
+ });
13
25
  ```
14
-
@@ -1,6 +1,4 @@
1
- const {
2
- extractImportedFromEffector,
3
- } = require("../../utils/extract-imported-from-effector");
1
+ const { extractImportedFrom } = require("../../utils/extract-imported-from");
4
2
  const { areNodesSameInText } = require("../../utils/are-nodes-same-in-text");
5
3
  const { createLinkToRule } = require("../../utils/create-link-to-rule");
6
4
 
@@ -24,11 +22,14 @@ module.exports = {
24
22
  },
25
23
  create(context) {
26
24
  const importedFromEffector = new Map();
27
- const sourceCode = context.getSourceCode();
28
25
 
29
26
  return {
30
27
  ImportDeclaration(node) {
31
- extractImportedFromEffector(importedFromEffector, node);
28
+ extractImportedFrom({
29
+ importMap: importedFromEffector,
30
+ node,
31
+ packageName: "effector",
32
+ });
32
33
  },
33
34
  CallExpression(node) {
34
35
  const METHODS_WITH_POSSIBLE_DUPLCATION = ["sample", "guard"];
@@ -1,6 +1,4 @@
1
- const {
2
- extractImportedFromEffector,
3
- } = require("../../utils/extract-imported-from-effector");
1
+ const { extractImportedFrom } = require("../../utils/extract-imported-from");
4
2
  const { traverseParentByType } = require("../../utils/traverse-parent-by-type");
5
3
  const { createLinkToRule } = require("../../utils/create-link-to-rule");
6
4
 
@@ -24,7 +22,11 @@ module.exports = {
24
22
 
25
23
  return {
26
24
  ImportDeclaration(node) {
27
- extractImportedFromEffector(importedFromEffector, node);
25
+ extractImportedFrom({
26
+ importMap: importedFromEffector,
27
+ node,
28
+ packageName: "effector",
29
+ });
28
30
  },
29
31
  CallExpression(node) {
30
32
  const POSSIBLE_USELESS_METHODS = ["sample", "guard"];
@@ -2,6 +2,7 @@ const {
2
2
  traverseNestedObjectNode,
3
3
  } = require("../../utils/traverse-nested-object-node");
4
4
  const { createLinkToRule } = require("../../utils/create-link-to-rule");
5
+ const { nodeTypeIs } = require("../../utils/node-type-is");
5
6
 
6
7
  module.exports = {
7
8
  meta: {
@@ -24,22 +25,15 @@ module.exports = {
24
25
  // JavaScript-way https://github.com/effector/eslint-plugin/issues/48#issuecomment-931107829
25
26
  return {};
26
27
  }
27
- const checker = parserServices.program.getTypeChecker();
28
28
 
29
29
  return {
30
- CallExpression(node) {
31
- const methodName = node.callee?.property?.name;
32
- if (methodName !== "watch") {
33
- return;
34
- }
35
-
30
+ 'CallExpression[callee.property.name="watch"]'(node) {
36
31
  const object = traverseNestedObjectNode(node.callee?.object);
37
- const originalNode = parserServices.esTreeNodeToTSNodeMap.get(object);
38
- const type = checker.getTypeAtLocation(originalNode);
39
32
 
40
- const isEffectorUnit =
41
- ["Effect", "Event", "Store"].includes(type?.symbol?.escapedName) &&
42
- type?.symbol?.parent?.escapedName?.includes("effector");
33
+ const isEffectorUnit = nodeTypeIs.unit({
34
+ node: object,
35
+ context,
36
+ });
43
37
 
44
38
  if (!isEffectorUnit) {
45
39
  return;
@@ -1,6 +1,4 @@
1
- const {
2
- extractImportedFromEffector,
3
- } = require("../../utils/extract-imported-from-effector");
1
+ const { extractImportedFrom } = require("../../utils/extract-imported-from");
4
2
  const {
5
3
  traverseNestedObjectNode,
6
4
  } = require("../../utils/traverse-nested-object-node");
@@ -28,7 +26,11 @@ module.exports = {
28
26
 
29
27
  return {
30
28
  ImportDeclaration(node) {
31
- extractImportedFromEffector(importedFromEffector, node);
29
+ extractImportedFrom({
30
+ importMap: importedFromEffector,
31
+ node,
32
+ packageName: "effector",
33
+ });
32
34
  },
33
35
  CallExpression(node) {
34
36
  const localMethod = importedFromEffector.get("forward");
@@ -0,0 +1,74 @@
1
+ const { createLinkToRule } = require("../../utils/create-link-to-rule");
2
+ const { is } = require("../../utils/is");
3
+
4
+ module.exports = {
5
+ meta: {
6
+ type: "problem",
7
+ docs: {
8
+ description:
9
+ "Forbids mix of async functions and effects calls in effect handlers.",
10
+ category: "Quality",
11
+ recommended: true,
12
+ url: createLinkToRule("strict-effect-handlers"),
13
+ },
14
+ messages: {
15
+ mixedCallsInHandler:
16
+ "Handler of effect `{{ effectName }}` can lead to scope loosing in Fork API.",
17
+ mixedCallsInFunction:
18
+ "Function `{{ functionName }}` can lead to scope loosing in Fork API.",
19
+ },
20
+ schema: [],
21
+ },
22
+ create(context) {
23
+ function onEffectHandler(node) {
24
+ if (!node.body?.body) {
25
+ return;
26
+ }
27
+
28
+ const calledNodes = node.body.body
29
+ .filter((bodyNode) => bodyNode.expression?.type === "AwaitExpression")
30
+ .map((awaitNode) => ({
31
+ node: awaitNode.expression.argument.callee,
32
+ context,
33
+ }));
34
+
35
+ const hasEffects = calledNodes.some(is.effect);
36
+ const hasRegularAsyncFunctions = calledNodes.some(is.not.effect);
37
+
38
+ const hasError = hasEffects && hasRegularAsyncFunctions;
39
+
40
+ if (!hasError) {
41
+ return;
42
+ }
43
+
44
+ const isEffectHandler = is.effect({
45
+ node: node.parent?.parent,
46
+ context,
47
+ });
48
+
49
+ if (isEffectHandler) {
50
+ const effectName = node.parent?.parent?.id?.name ?? "Unknown";
51
+
52
+ context.report({
53
+ node: node.parent,
54
+ messageId: "mixedCallsInHandler",
55
+ data: { effectName },
56
+ });
57
+ } else {
58
+ const functionName = node.id?.name ?? "Unknown";
59
+
60
+ context.report({
61
+ node,
62
+ messageId: "mixedCallsInFunction",
63
+ data: { functionName },
64
+ });
65
+ }
66
+ }
67
+
68
+ return {
69
+ ArrowFunctionExpression: onEffectHandler,
70
+ FunctionExpression: onEffectHandler,
71
+ FunctionDeclaration: onEffectHandler,
72
+ };
73
+ },
74
+ };
@@ -0,0 +1,38 @@
1
+ # effector/strict-effect-handlers
2
+
3
+ [Related documentation](https://effector.dev/docs/api/effector/scope#imperative-effects-calls-with-scope)
4
+
5
+ When effect calls another effects then it should call only effects, not common async functions and effect calls should have await:
6
+
7
+ ```ts
8
+ // 👍 effect without inner effects:
9
+ const delayFx = createEffect(async () => {
10
+ await new Promise((rs) => setTimeout(rs, 80));
11
+ });
12
+ ```
13
+
14
+ ```ts
15
+ const authUserFx = createEffect();
16
+ const sendMessageFx = createEffect();
17
+
18
+ // 👍 effect with inner effects
19
+ const sendWithAuthFx = createEffect(async () => {
20
+ await authUserFx();
21
+ await delayFx();
22
+ await sendMessageFx();
23
+ });
24
+ ```
25
+
26
+ ```ts
27
+ // 👎 effect with inner effects and common async functions
28
+
29
+ const sendWithAuthFx = createEffect(async () => {
30
+ await authUserFx();
31
+ //WRONG! wrap that in effect
32
+ await new Promise((rs) => setTimeout(rs, 80));
33
+ //context lost
34
+ await sendMessageFx();
35
+ });
36
+ ```
37
+
38
+ So, any effect might either call another effects or perform some async computations but not both.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "isolatedModules": true,
4
+ "esModuleInterop": true,
4
5
  "module": "commonjs",
5
6
  "lib": ["es2017", "es2019"],
6
7
  "baseUrl": "./"
@@ -0,0 +1,9 @@
1
+ function extractImportedFrom({ importMap, node, packageName }) {
2
+ if (node.source.value === packageName) {
3
+ for (const s of node.specifiers) {
4
+ importMap.set(s.imported.name, s.local.name);
5
+ }
6
+ }
7
+ }
8
+
9
+ module.exports = { extractImportedFrom };
@@ -1,23 +1,21 @@
1
1
  const { getStoreNameConvention } = require("./get-store-name-convention");
2
2
 
3
3
  function getCorrectedStoreName(storeName, context) {
4
- const storeNameConvention = getStoreNameConvention(context);
4
+ const storeNameConvention = getStoreNameConvention(context);
5
5
 
6
- // handle edge case
7
- if (storeName.startsWith("$") && storeName.endsWith("$")) {
8
-
9
- if (storeNameConvention === "prefix") {
10
- return `$${storeName.slice(0, -1)}`;
11
- } else {
12
- return `${storeName.slice(1)}$`;
13
- }
6
+ // handle edge case
7
+ if (storeName.startsWith("$") && storeName.endsWith("$")) {
8
+ if (storeNameConvention === "prefix") {
9
+ return `$${storeName.slice(0, -1)}`;
10
+ } else {
11
+ return `${storeName.slice(1)}$`;
14
12
  }
13
+ }
15
14
 
16
- const correctedStoreName = storeNameConvention === "prefix"
17
- ? `$${storeName}`
18
- : `${storeName}$`;
15
+ const correctedStoreName =
16
+ storeNameConvention === "prefix" ? `$${storeName}` : `${storeName}$`;
19
17
 
20
- return correctedStoreName;
18
+ return correctedStoreName;
21
19
  }
22
20
 
23
- module.exports = { getCorrectedStoreName };
21
+ module.exports = { getCorrectedStoreName };
@@ -0,0 +1,18 @@
1
+ function getNestedObjectName(node) {
2
+ let root = node;
3
+ let name = "";
4
+
5
+ while (root.type === "MemberExpression") {
6
+ name = `${root.property.name}.${name}`;
7
+ root = root.object;
8
+ }
9
+
10
+ if (root.type === "Identifier") {
11
+ name = `${root.name}.${name}`;
12
+ }
13
+
14
+ // Remove last dot
15
+ return name.slice(0, -1);
16
+ }
17
+
18
+ module.exports = { getNestedObjectName };
@@ -1,6 +1,6 @@
1
1
  function getStoreNameConvention(context) {
2
- // prefix convention is default
3
- return context.settings.effector?.storeNameConvention || "prefix";
2
+ // prefix convention is default
3
+ return context.settings.effector?.storeNameConvention || "prefix";
4
4
  }
5
5
 
6
- module.exports = { getStoreNameConvention };
6
+ module.exports = { getStoreNameConvention };
package/utils/is.js ADDED
@@ -0,0 +1,30 @@
1
+ const { nodeTypeIs } = require("./node-type-is");
2
+ const { namingOf } = require("./naming");
3
+
4
+ function isSomething({ isValidNaming, isTypeCorrect }) {
5
+ return ({ node, context }) => {
6
+ if (context.parserServices.hasFullTypeInformation) {
7
+ return isTypeCorrect({ node, context });
8
+ }
9
+
10
+ return isValidNaming({ name: node?.name ?? node?.id?.name, context });
11
+ };
12
+ }
13
+
14
+ const isStore = isSomething({
15
+ isTypeCorrect: nodeTypeIs.store,
16
+ isValidNaming: namingOf.store.isValid,
17
+ });
18
+
19
+ const isEffect = isSomething({
20
+ isTypeCorrect: nodeTypeIs.effect,
21
+ isValidNaming: namingOf.effect.isValid,
22
+ });
23
+
24
+ const is = {
25
+ store: (opts) => isStore(opts),
26
+ effect: (opts) => isEffect(opts),
27
+ not: { store: (opts) => !isStore(opts), effect: (opts) => !isEffect(opts) },
28
+ };
29
+
30
+ module.exports = { is };
@@ -0,0 +1,47 @@
1
+ const { getStoreNameConvention } = require("./get-store-name-convention");
2
+
3
+ function isEffectNameValid({ name }) {
4
+ return Boolean(name?.endsWith("Fx"));
5
+ }
6
+
7
+ function isGateNameValid({ name }) {
8
+ const [firstChar] = name.split("");
9
+
10
+ return Boolean(firstChar?.toUpperCase() === firstChar);
11
+ }
12
+
13
+ function isStoreNameValid({ name, context }) {
14
+ const storeNameConvention = getStoreNameConvention(context);
15
+
16
+ // validate edge case
17
+ if (name?.startsWith("$") && name?.endsWith("$")) {
18
+ return false;
19
+ }
20
+
21
+ if (storeNameConvention === "prefix" && name?.startsWith("$")) {
22
+ return true;
23
+ }
24
+
25
+ if (storeNameConvention === "postfix" && name?.endsWith("$")) {
26
+ return true;
27
+ }
28
+
29
+ return false;
30
+ }
31
+
32
+ const namingOf = {
33
+ effect: {
34
+ isValid: (opts) => isEffectNameValid(opts),
35
+ isInvalid: (opts) => !isEffectNameValid(opts),
36
+ },
37
+ store: {
38
+ isValid: (opts) => isStoreNameValid(opts),
39
+ isInvalid: (opts) => !isStoreNameValid(opts),
40
+ },
41
+ gate: {
42
+ isValid: (opts) => isGateNameValid(opts),
43
+ isInvalid: (opts) => !isGateNameValid(opts),
44
+ },
45
+ };
46
+
47
+ module.exports = { namingOf };
@@ -0,0 +1,55 @@
1
+ function hasType({ node, possibleTypes, context, from }) {
2
+ const checker = context.parserServices.program.getTypeChecker();
3
+ const originalNode = context.parserServices.esTreeNodeToTSNodeMap.get(node);
4
+ const type = checker.getTypeAtLocation(
5
+ originalNode?.initializer ?? originalNode
6
+ );
7
+
8
+ const symbol = type?.symbol ?? type?.aliasSymbol;
9
+
10
+ return (
11
+ possibleTypes.includes(symbol?.escapedName) &&
12
+ Boolean(symbol?.parent?.escapedName?.includes(from))
13
+ );
14
+ }
15
+
16
+ const nodeTypeIs = {
17
+ effect: (opts) =>
18
+ hasType({ ...opts, possibleTypes: ["Effect"], from: "effector" }),
19
+ store: (opts) =>
20
+ hasType({ ...opts, possibleTypes: ["Store"], from: "effector" }),
21
+ event: (opts) =>
22
+ hasType({ ...opts, possibleTypes: ["Event"], from: "effector" }),
23
+ unit: (opts) =>
24
+ hasType({
25
+ ...opts,
26
+ possibleTypes: ["Effect", "Store", "Event"],
27
+ from: "effector",
28
+ }),
29
+ gate: (opts) =>
30
+ hasType({ ...opts, possibleTypes: ["Gate"], from: "effector-react" }),
31
+ not: {
32
+ effect: (opts) =>
33
+ !hasType({
34
+ ...opts,
35
+ possibleTypes: ["Effect"],
36
+ from: "effector",
37
+ }),
38
+ store: (opts) =>
39
+ !hasType({ ...opts, possibleTypes: ["Store"], from: "effector" }),
40
+ event: (opts) =>
41
+ !hasType({ ...opts, possibleTypes: ["Event"], from: "effector" }),
42
+ unit: (opts) =>
43
+ !hasType({
44
+ ...opts,
45
+ possibleTypes: ["Effect", "Store", "Event"],
46
+ from: "effector",
47
+ }),
48
+ gate: (opts) =>
49
+ !hasType({ ...opts, possibleTypes: ["Gate"], from: "effector-react" }),
50
+ },
51
+ };
52
+
53
+ module.exports = {
54
+ nodeTypeIs,
55
+ };
@@ -1,13 +1,13 @@
1
1
  const { getStoreNameConvention } = require("./get-store-name-convention");
2
2
 
3
3
  function validateStoreNameConvention(context) {
4
- const storeNameConvention = getStoreNameConvention(context);
4
+ const storeNameConvention = getStoreNameConvention(context);
5
5
 
6
- if (storeNameConvention !== "prefix" && storeNameConvention !== "postfix") {
7
- throw new Error(
8
- "Invalid Configuration of effector-plugin-eslint/enforce-store-naming-convention. The value should be equal to prefix or postfix."
9
- );
10
- }
6
+ if (storeNameConvention !== "prefix" && storeNameConvention !== "postfix") {
7
+ throw new Error(
8
+ "Invalid Configuration of effector-plugin-eslint/enforce-store-naming-convention. The value should be equal to prefix or postfix."
9
+ );
10
+ }
11
11
  }
12
12
 
13
- module.exports = { validateStoreNameConvention };
13
+ module.exports = { validateStoreNameConvention };
@@ -1,8 +0,0 @@
1
- function extractImportedFromEffector(importedFromEffector, node) {
2
- if (node.source.value === "effector") {
3
- for (const s of node.specifiers) {
4
- importedFromEffector.set(s.imported.name, s.local.name);
5
- }
6
- }
7
- }
8
- module.exports = { extractImportedFromEffector };
@@ -1,22 +0,0 @@
1
- const { getStoreNameConvention } = require("./get-store-name-convention");
2
-
3
- function isStoreNameValid(storeName, context) {
4
- const storeNameConvention = getStoreNameConvention(context);
5
-
6
- // validate edge case
7
- if (storeName?.startsWith("$") && storeName?.endsWith("$")) {
8
- return false
9
- }
10
-
11
- if (storeNameConvention === "prefix" && storeName?.startsWith("$")) {
12
- return true;
13
- }
14
-
15
- if (storeNameConvention === "postfix" && storeName?.endsWith("$")) {
16
- return true;
17
- }
18
-
19
- return false;
20
- }
21
-
22
- module.exports = { isStoreNameValid };