Skip to content

Commit 8e533e5

Browse files
committed
feat: no-ambiguity-target
1 parent 33e0b6c commit 8e533e5

File tree

6 files changed

+270
-1
lines changed

6 files changed

+270
-1
lines changed

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import enforceGateNamingConvention from "./rules/enforce-gate-naming-convention/
77
import enforceStoreNamingConvention from "./rules/enforce-store-naming-convention/enforce-store-naming-convention"
88
import keepOptionsOrder from "./rules/keep-options-order/keep-options-order"
99
import mandatoryScopeBinding from "./rules/mandatory-scope-binding/mandatory-scope-binding"
10+
import noAmbiguityTarget from "./rules/no-ambiguity-target/no-ambiguity-target"
1011
import noForward from "./rules/no-forward/no-forward"
1112
import noGetState from "./rules/no-getState/no-getState"
1213
import noGuard from "./rules/no-guard/no-guard"
@@ -26,6 +27,7 @@ const base = {
2627
"enforce-store-naming-convention": enforceStoreNamingConvention,
2728
"keep-options-order": keepOptionsOrder,
2829
"mandatory-scope-binding": mandatoryScopeBinding,
30+
"no-ambiguity-target": noAmbiguityTarget,
2931
"no-forward": noForward,
3032
"no-getState": noGetState,
3133
"no-guard": noGuard,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
description: Forbid ambiguous target in sample and guard
3+
---
4+
5+
# effector/no-ambiguity-target
6+
7+
Call of `guard`/`sample` with `target` and variable assignment creates ambiguity. One of them should be removed.
8+
9+
```ts
10+
// 👎 should be rewritten
11+
const result = guard({ clock: trigger, filter: Boolean, target })
12+
13+
// 👍 makes sense
14+
guard({ clock: trigger, filter: Boolean, target })
15+
const result = target
16+
```
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester"
2+
import { parser } from "typescript-eslint"
3+
4+
import { ts } from "@/shared/tag"
5+
6+
import rule from "./no-ambiguity-target"
7+
8+
const ruleTester = new RuleTester({
9+
languageOptions: {
10+
parser,
11+
parserOptions: {
12+
projectService: { allowDefaultProject: ["*.ts"], defaultProject: "tsconfig.fixture.json" },
13+
},
14+
},
15+
})
16+
17+
ruleTester.run("no-ambiguity-target", rule, {
18+
valid: [
19+
{
20+
name: "sample target",
21+
code: ts`
22+
import { sample, createEvent } from "effector"
23+
24+
const trigger = createEvent()
25+
const target = createEvent()
26+
27+
sample({ clock: trigger, fn: Boolean, target })
28+
sample({ source: trigger, fn: Boolean, target })
29+
`,
30+
},
31+
{
32+
name: "guard target",
33+
code: ts`
34+
import { sample, guard, createEvent } from "effector"
35+
36+
const trigger = createEvent()
37+
const target = createEvent()
38+
39+
guard({ clock: trigger, filter: Boolean, target })
40+
guard({ source: trigger, filter: Boolean, target })
41+
`,
42+
},
43+
{
44+
name: "sample assignment",
45+
code: ts`
46+
import { sample, createEvent } from "effector"
47+
48+
const trigger = createEvent()
49+
const target = createEvent()
50+
51+
const a = sample({ clock: trigger, fn: Boolean })
52+
const b = sample({ source: trigger, fn: Boolean })
53+
const c = sample(trigger, trigger, () => null)
54+
`,
55+
},
56+
{
57+
name: "guard assignment",
58+
code: ts`
59+
import { sample, guard, createEvent } from "effector"
60+
61+
const trigger = createEvent()
62+
const target = createEvent()
63+
64+
const a = guard({ clock: trigger, filter: Boolean })
65+
const b = guard({ source: trigger, filter: Boolean })
66+
const c = guard(trigger, { filter: Boolean })
67+
`,
68+
},
69+
{
70+
name: "sample object member",
71+
code: ts`
72+
import { sample, createEvent } from "effector"
73+
74+
const source = createEvent()
75+
76+
const $$ = { a: sample({ source }) }
77+
`,
78+
},
79+
{
80+
name: "sample returned from factory",
81+
code: ts`
82+
import { sample, createEvent, Store } from "effector"
83+
84+
const source = createEvent()
85+
86+
const createSourced = (clock: Store<unknown>) => sample(clock, source)
87+
const createSourcedObject = (clock: Store<unknown>) => sample({ clock, source })
88+
89+
const truthful = (clock: Store<unknown>) => {
90+
return sample({ clock, filter: Boolean })
91+
}
92+
`,
93+
},
94+
{
95+
// https://github.com/igorkamyshev/eslint-plugin-effector/issues/133
96+
name: "function in object",
97+
code: ts`
98+
import { createStore, createEvent, sample } from "effector"
99+
100+
const obj = {
101+
fn: () => {
102+
const $store = createStore(0)
103+
const event = createEvent()
104+
105+
sample({ source: event, target: $store })
106+
},
107+
}
108+
`,
109+
},
110+
],
111+
invalid: [
112+
{
113+
name: "guard object",
114+
code: ts`
115+
import { guard, createEvent } from "effector"
116+
117+
const trigger = createEvent()
118+
const target = createEvent()
119+
120+
const result = {
121+
something: guard({ clock: trigger, filter: Boolean, target }),
122+
}
123+
`,
124+
errors: [{ messageId: "ambiguous", line: 7, column: 14, data: { method: "guard" } }],
125+
},
126+
{
127+
name: "sample",
128+
code: ts`
129+
import { sample, createEvent } from "effector"
130+
131+
const trigger = createEvent()
132+
const target = createEvent()
133+
134+
const result = sample({ clock: trigger, fn: Boolean, target })
135+
`,
136+
errors: [{ messageId: "ambiguous", line: 6, column: 16, data: { method: "sample" } }],
137+
},
138+
{
139+
name: "factory",
140+
code: ts`
141+
import { sample, createEvent, Store } from "effector"
142+
143+
const target = createEvent<unknown>()
144+
145+
const truthful = (clock: Store<unknown>) => sample({ clock, filter: Boolean, target })
146+
`,
147+
errors: [{ messageId: "ambiguous", line: 5, column: 45, data: { method: "sample" } }],
148+
},
149+
{
150+
name: "nested",
151+
code: ts`
152+
import { sample, createEvent, Store } from "effector"
153+
154+
const clock = createEvent<unknown>()
155+
const target = createEvent<unknown>()
156+
157+
sample({ clock: sample({ clock, filter: Boolean, target }), fn: () => true, target })
158+
`,
159+
errors: [{ messageId: "ambiguous", line: 6, column: 17, data: { method: "sample" } }],
160+
},
161+
],
162+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { type TSESTree as Node, AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils"
2+
import esquery from "esquery"
3+
import type { Node as ESNode } from "estree"
4+
5+
import { createRule } from "@/shared/create"
6+
7+
export default createRule({
8+
name: "no-ambiguity-target",
9+
meta: {
10+
type: "problem",
11+
docs: {
12+
description: "Forbid ambiguous target in `sample` and `guard`.",
13+
},
14+
messages: {
15+
ambiguous:
16+
"Method `{{ method }}` both specifies `target` option and assigns the result to a variable. Consider removing one of them.",
17+
},
18+
schema: [],
19+
},
20+
defaultOptions: [],
21+
create: (context) => {
22+
const imports = new Set<string>()
23+
24+
const source = context.sourceCode
25+
const visitorKeys = source.visitorKeys
26+
27+
const PACKAGE_NAME = /^effector(?:\u002Fcompat)?$/
28+
29+
const importSelector = `ImportDeclaration[source.value=${PACKAGE_NAME}]`
30+
const methodSelector = `ImportSpecifier[imported.name=/(sample|guard)/]`
31+
32+
const usageStack: boolean[] = []
33+
34+
const query = {
35+
target: esquery.parse("!Property.properties > Identifier.key[name=target]"),
36+
}
37+
38+
type MethodCall = Node.CallExpression & { callee: Node.Identifier }
39+
40+
// TODO: Implement rule logic
41+
return {
42+
"ReturnStatement": () => usageStack.push(true),
43+
"ReturnStatement:exit": () => usageStack.pop(),
44+
45+
"VariableDeclarator": () => usageStack.push(true),
46+
"VariableDeclarator:exit": () => usageStack.pop(),
47+
48+
"ObjectExpression": () => usageStack.push(true),
49+
"ObjectExpression:exit": () => usageStack.pop(),
50+
51+
"BlockStatement": () => usageStack.push(false),
52+
"BlockStatement:exit": () => usageStack.pop(),
53+
54+
[`${importSelector} > ${methodSelector}`]: (node: Node.ImportSpecifier) => imports.add(node.local.name),
55+
56+
[`CallExpression[callee.type="Identifier"]`]: (node: MethodCall) => {
57+
const isTracked = imports.has(node.callee.name)
58+
if (!isTracked) return
59+
60+
const isUsed = usageStack.at(-1) ?? false
61+
if (!isUsed) return
62+
63+
const [config] = node.arguments
64+
65+
if (config?.type !== NodeType.ObjectExpression) /* can't have a target */ return
66+
67+
const [target] = esquery
68+
.match(config as ESNode, query.target, { visitorKeys })
69+
.map((node) => node as Node.Property)
70+
.filter((prop) => prop.parent === config)
71+
72+
if (!target) return
73+
74+
context.report({ node, messageId: "ambiguous", data: { method: node.callee.name } })
75+
},
76+
}
77+
},
78+
})

src/rules/no-useless-methods/no-useless-methods.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,5 +164,16 @@ ruleTester.run("no-useless-methods", rule, {
164164
`,
165165
errors: [{ messageId: "uselessMethod", line: 6, data: { method: "sample" } }],
166166
},
167+
{
168+
name: "sample in for loop",
169+
code: ts`
170+
import { sample, createEvent } from "effector"
171+
172+
const source = createEvent()
173+
174+
for (const i = 0; i < 10; i++) sample({ source })
175+
`,
176+
errors: [{ messageId: "uselessMethod", line: 5, column: 32, data: { method: "sample" } }],
177+
},
167178
],
168179
})

src/ruleset.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const recommended = {
44
"effector/enforce-effect-naming-convention": "error",
55
"effector/enforce-store-naming-convention": "error",
66
"effector/keep-options-order": "warn",
7-
// "effector/no-ambiguity-target": "warn",
7+
"effector/no-ambiguity-target": "warn",
88
// "effector/no-duplicate-on": "error",
99
"effector/no-forward": "error",
1010
"effector/no-getState": "error",

0 commit comments

Comments
 (0)