@typeonce/oxlint-plugin-effect-machine 0.26.2 → 0.27.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/README.md +151 -9
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/ambient.d.ts +6 -0
- package/dist/internal/ambient.d.ts.map +1 -0
- package/dist/internal/ambient.js +118 -0
- package/dist/internal/ambient.js.map +1 -0
- package/dist/internal/ast.d.ts +7 -0
- package/dist/internal/ast.d.ts.map +1 -0
- package/dist/internal/ast.js +62 -0
- package/dist/internal/ast.js.map +1 -0
- package/dist/internal/imports.d.ts +1 -0
- package/dist/internal/imports.d.ts.map +1 -1
- package/dist/internal/imports.js +19 -9
- package/dist/internal/imports.js.map +1 -1
- package/dist/internal/planning.d.ts +3 -0
- package/dist/internal/planning.d.ts.map +1 -1
- package/dist/internal/planning.js +54 -7
- package/dist/internal/planning.js.map +1 -1
- package/dist/internal/rules/noAsyncPlanningCallback.d.ts.map +1 -1
- package/dist/internal/rules/noAsyncPlanningCallback.js +18 -3
- package/dist/internal/rules/noAsyncPlanningCallback.js.map +1 -1
- package/dist/internal/rules/noBrowserApiInPlanning.d.ts +3 -0
- package/dist/internal/rules/noBrowserApiInPlanning.d.ts.map +1 -0
- package/dist/internal/rules/noBrowserApiInPlanning.js +54 -0
- package/dist/internal/rules/noBrowserApiInPlanning.js.map +1 -0
- package/dist/internal/rules/noConflictingInvocationIdentity.d.ts +3 -0
- package/dist/internal/rules/noConflictingInvocationIdentity.d.ts.map +1 -0
- package/dist/internal/rules/noConflictingInvocationIdentity.js +289 -0
- package/dist/internal/rules/noConflictingInvocationIdentity.js.map +1 -0
- package/dist/internal/rules/noNondeterministicPlanning.d.ts +3 -0
- package/dist/internal/rules/noNondeterministicPlanning.d.ts.map +1 -0
- package/dist/internal/rules/noNondeterministicPlanning.js +33 -0
- package/dist/internal/rules/noNondeterministicPlanning.js.map +1 -0
- package/dist/internal/rules/noRedundantResolve.d.ts.map +1 -1
- package/dist/internal/rules/noRedundantResolve.js +41 -15
- package/dist/internal/rules/noRedundantResolve.js.map +1 -1
- package/dist/recommended.d.ts +3 -0
- package/dist/recommended.d.ts.map +1 -1
- package/dist/recommended.js +3 -0
- package/dist/recommended.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +6 -0
- package/src/internal/ambient.ts +134 -0
- package/src/internal/ast.ts +83 -0
- package/src/internal/imports.ts +27 -8
- package/src/internal/planning.ts +69 -7
- package/src/internal/rules/noAsyncPlanningCallback.ts +21 -4
- package/src/internal/rules/noBrowserApiInPlanning.ts +57 -0
- package/src/internal/rules/noConflictingInvocationIdentity.ts +343 -0
- package/src/internal/rules/noNondeterministicPlanning.ts +39 -0
- package/src/internal/rules/noRedundantResolve.ts +48 -16
- package/src/recommended.ts +3 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import type { Context, ESTree, Rule, Variable } from "@oxlint/plugins"
|
|
2
|
+
import { resolvedVariable, unwrapExpression } from "../ast.js"
|
|
3
|
+
import {
|
|
4
|
+
hasMachineImport,
|
|
5
|
+
isMachineMemberCall,
|
|
6
|
+
type MachineBindings,
|
|
7
|
+
makeMachineBindings,
|
|
8
|
+
recordMachineDefinition,
|
|
9
|
+
recordMachineImport,
|
|
10
|
+
staticMemberName
|
|
11
|
+
} from "../imports.js"
|
|
12
|
+
import { isInvokePlanningCallback, type PlanningFunction } from "../planning.js"
|
|
13
|
+
|
|
14
|
+
interface Identity {
|
|
15
|
+
readonly key: string
|
|
16
|
+
readonly label: string
|
|
17
|
+
readonly node: ESTree.Expression
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface InvocationIdentity {
|
|
21
|
+
readonly address?: Identity
|
|
22
|
+
readonly lifecycle: Identity
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const completionMethods = new Set(["onDone", "onElement", "onFailure", "onSnapshot"])
|
|
26
|
+
const sourceMethods = new Set(["effect", "logic", "stream", "timer"])
|
|
27
|
+
|
|
28
|
+
const variableKey = (variable: Variable): string | undefined => {
|
|
29
|
+
const identifier = variable.identifiers[0]
|
|
30
|
+
return identifier === undefined ? undefined : `binding:${identifier.range[0]}:${identifier.range[1]}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isStableVariable = (variable: Variable): boolean =>
|
|
34
|
+
variable.defs.some((definition) =>
|
|
35
|
+
definition.type === "ImportBinding" ||
|
|
36
|
+
(definition.type === "Variable" &&
|
|
37
|
+
definition.node.type === "VariableDeclarator" &&
|
|
38
|
+
definition.node.parent.type === "VariableDeclaration" &&
|
|
39
|
+
definition.node.parent.kind === "const")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const constInitializer = (
|
|
43
|
+
variable: Variable
|
|
44
|
+
): ESTree.Expression | undefined => {
|
|
45
|
+
const definition = variable.defs.find((candidate) => candidate.type === "Variable")
|
|
46
|
+
const declaration = definition?.node
|
|
47
|
+
if (
|
|
48
|
+
declaration?.type !== "VariableDeclarator" ||
|
|
49
|
+
declaration.parent.type !== "VariableDeclaration" ||
|
|
50
|
+
declaration.parent.kind !== "const" ||
|
|
51
|
+
declaration.init === null
|
|
52
|
+
) return undefined
|
|
53
|
+
return declaration.init
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const staticIdentity = (
|
|
57
|
+
context: Context,
|
|
58
|
+
node: ESTree.Expression,
|
|
59
|
+
bindings: MachineBindings,
|
|
60
|
+
seen: Set<string> = new Set()
|
|
61
|
+
): Identity | undefined => {
|
|
62
|
+
const expression = unwrapExpression(node)
|
|
63
|
+
if (
|
|
64
|
+
expression.type === "Literal" &&
|
|
65
|
+
(typeof expression.value === "string" || typeof expression.value === "number")
|
|
66
|
+
) {
|
|
67
|
+
return {
|
|
68
|
+
key: `value:${String(expression.value)}`,
|
|
69
|
+
label: JSON.stringify(expression.value),
|
|
70
|
+
node: expression
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) {
|
|
74
|
+
const value = expression.quasis[0]?.value.cooked
|
|
75
|
+
return value === null || value === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: { key: `value:${value}`, label: JSON.stringify(value), node: expression }
|
|
78
|
+
}
|
|
79
|
+
if (expression.type === "Identifier") {
|
|
80
|
+
const variable = resolvedVariable(context, expression)
|
|
81
|
+
if (variable === undefined || !isStableVariable(variable)) return undefined
|
|
82
|
+
const key = variableKey(variable)
|
|
83
|
+
if (key === undefined || seen.has(key)) return undefined
|
|
84
|
+
const initializer = constInitializer(variable)
|
|
85
|
+
if (initializer !== undefined) {
|
|
86
|
+
const nextSeen = new Set(seen)
|
|
87
|
+
nextSeen.add(key)
|
|
88
|
+
const initialized = staticIdentity(context, initializer, bindings, nextSeen)
|
|
89
|
+
if (initialized !== undefined) return { ...initialized, node: expression }
|
|
90
|
+
}
|
|
91
|
+
return { key, label: expression.name, node: expression }
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
isMachineMemberCall(expression, "childAddress", bindings)
|
|
95
|
+
) {
|
|
96
|
+
const argument = expression.arguments[0]
|
|
97
|
+
if (expression.arguments.length === 1 && argument !== undefined && argument.type !== "SpreadElement") {
|
|
98
|
+
return staticIdentity(context, argument, bindings, seen)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const descriptorIdentity = (
|
|
105
|
+
context: Context,
|
|
106
|
+
node: ESTree.Expression,
|
|
107
|
+
bindings: MachineBindings,
|
|
108
|
+
seen: Set<string> = new Set()
|
|
109
|
+
): Identity | undefined => {
|
|
110
|
+
const expression = unwrapExpression(node)
|
|
111
|
+
if (expression.type === "Identifier") {
|
|
112
|
+
const variable = resolvedVariable(context, expression)
|
|
113
|
+
if (variable === undefined || !isStableVariable(variable)) return undefined
|
|
114
|
+
const key = variableKey(variable)
|
|
115
|
+
if (key === undefined || seen.has(key)) return undefined
|
|
116
|
+
const initializer = constInitializer(variable)
|
|
117
|
+
if (initializer !== undefined) {
|
|
118
|
+
const nextSeen = new Set(seen)
|
|
119
|
+
nextSeen.add(key)
|
|
120
|
+
const initialized = descriptorIdentity(context, initializer, bindings, nextSeen)
|
|
121
|
+
if (initialized !== undefined) return { ...initialized, node: expression }
|
|
122
|
+
}
|
|
123
|
+
return { key: `descriptor:${key}`, label: expression.name, node: expression }
|
|
124
|
+
}
|
|
125
|
+
if (
|
|
126
|
+
isMachineMemberCall(expression, "child", bindings)
|
|
127
|
+
) {
|
|
128
|
+
const argument = expression.arguments[0]
|
|
129
|
+
if (argument !== undefined && argument.type !== "SpreadElement") {
|
|
130
|
+
return staticIdentity(context, argument, bindings)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const objectProperty = (
|
|
137
|
+
object: ESTree.ObjectExpression,
|
|
138
|
+
name: string
|
|
139
|
+
): ESTree.Expression | undefined => {
|
|
140
|
+
for (const property of object.properties) {
|
|
141
|
+
if (property.type !== "Property" || property.kind !== "init") continue
|
|
142
|
+
const propertyName = property.computed
|
|
143
|
+
? property.key.type === "Literal" && typeof property.key.value === "string"
|
|
144
|
+
? property.key.value
|
|
145
|
+
: undefined
|
|
146
|
+
: property.key.type === "Identifier" || property.key.type === "Literal"
|
|
147
|
+
? String(property.key.type === "Identifier" ? property.key.name : property.key.value)
|
|
148
|
+
: undefined
|
|
149
|
+
if (propertyName === name) return property.value
|
|
150
|
+
}
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const resolvedObject = (
|
|
155
|
+
context: Context,
|
|
156
|
+
node: ESTree.Expression,
|
|
157
|
+
seen: Set<string> = new Set()
|
|
158
|
+
): ESTree.ObjectExpression | undefined => {
|
|
159
|
+
const expression = unwrapExpression(node)
|
|
160
|
+
if (expression.type === "ObjectExpression") return expression
|
|
161
|
+
if (expression.type !== "Identifier") return undefined
|
|
162
|
+
const variable = resolvedVariable(context, expression)
|
|
163
|
+
if (variable === undefined) return undefined
|
|
164
|
+
const key = variableKey(variable)
|
|
165
|
+
if (key === undefined || seen.has(key)) return undefined
|
|
166
|
+
const initializer = constInitializer(variable)
|
|
167
|
+
if (initializer === undefined) return undefined
|
|
168
|
+
const nextSeen = new Set(seen)
|
|
169
|
+
nextSeen.add(key)
|
|
170
|
+
return resolvedObject(context, initializer, nextSeen)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sourceCall = (
|
|
174
|
+
node: ESTree.Expression
|
|
175
|
+
): ESTree.CallExpression | undefined => {
|
|
176
|
+
let expression = unwrapExpression(node)
|
|
177
|
+
while (
|
|
178
|
+
expression.type === "CallExpression" &&
|
|
179
|
+
expression.callee.type === "MemberExpression" &&
|
|
180
|
+
completionMethods.has(staticMemberName(expression.callee) ?? "")
|
|
181
|
+
) expression = unwrapExpression(expression.callee.object)
|
|
182
|
+
return expression.type === "CallExpression" ? expression : undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const invocationIdentity = (
|
|
186
|
+
context: Context,
|
|
187
|
+
node: ESTree.Expression,
|
|
188
|
+
from: string,
|
|
189
|
+
bindings: MachineBindings
|
|
190
|
+
): InvocationIdentity | undefined => {
|
|
191
|
+
const call = sourceCall(node)
|
|
192
|
+
if (
|
|
193
|
+
call?.callee.type !== "MemberExpression" ||
|
|
194
|
+
call.callee.object.type !== "Identifier" ||
|
|
195
|
+
call.callee.object.name !== from
|
|
196
|
+
) return undefined
|
|
197
|
+
const method = staticMemberName(call.callee)
|
|
198
|
+
if (method === "child") {
|
|
199
|
+
const child = call.arguments[0]
|
|
200
|
+
if (child === undefined || child.type === "SpreadElement") return undefined
|
|
201
|
+
const identity = descriptorIdentity(context, child, bindings)
|
|
202
|
+
return identity === undefined ? undefined : { lifecycle: identity, address: identity }
|
|
203
|
+
}
|
|
204
|
+
if (method === undefined || !sourceMethods.has(method)) return undefined
|
|
205
|
+
const id = call.arguments[0]
|
|
206
|
+
if (id === undefined || id.type === "SpreadElement") return undefined
|
|
207
|
+
const lifecycle = staticIdentity(context, id, bindings)
|
|
208
|
+
if (lifecycle === undefined) return undefined
|
|
209
|
+
if (method !== "logic") return { lifecycle }
|
|
210
|
+
const options = call.arguments[1]
|
|
211
|
+
if (options === undefined || options.type === "SpreadElement") return { lifecycle }
|
|
212
|
+
const object = resolvedObject(context, options)
|
|
213
|
+
const addressNode = object === undefined ? undefined : objectProperty(object, "address")
|
|
214
|
+
const address = addressNode === undefined
|
|
215
|
+
? undefined
|
|
216
|
+
: staticIdentity(context, addressNode, bindings)
|
|
217
|
+
return address === undefined ? { lifecycle } : { lifecycle, address }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const returnedExpression = (
|
|
221
|
+
node: PlanningFunction
|
|
222
|
+
): ESTree.Expression | undefined => {
|
|
223
|
+
if (node.body === null) return undefined
|
|
224
|
+
if (node.body.type !== "BlockStatement") return node.body
|
|
225
|
+
const returns = node.body.body.filter((statement) => statement.type === "ReturnStatement")
|
|
226
|
+
if (returns.length !== 1) return undefined
|
|
227
|
+
return returns[0]!.argument ?? undefined
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const returnedEntries = (
|
|
231
|
+
context: Context,
|
|
232
|
+
node: PlanningFunction
|
|
233
|
+
): ReadonlyArray<ESTree.Expression> => {
|
|
234
|
+
const returned = returnedExpression(node)
|
|
235
|
+
if (returned === undefined) return []
|
|
236
|
+
const array = resolvedObjectOrArray(context, returned)
|
|
237
|
+
if (array?.type !== "ArrayExpression") return [returned]
|
|
238
|
+
return array.elements.filter((entry): entry is ESTree.Expression => entry !== null && entry.type !== "SpreadElement")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const resolvedObjectOrArray = (
|
|
242
|
+
context: Context,
|
|
243
|
+
node: ESTree.Expression,
|
|
244
|
+
seen: Set<string> = new Set()
|
|
245
|
+
): ESTree.ObjectExpression | ESTree.ArrayExpression | undefined => {
|
|
246
|
+
const expression = unwrapExpression(node)
|
|
247
|
+
if (expression.type === "ObjectExpression" || expression.type === "ArrayExpression") return expression
|
|
248
|
+
if (expression.type !== "Identifier") return undefined
|
|
249
|
+
const variable = resolvedVariable(context, expression)
|
|
250
|
+
if (variable === undefined) return undefined
|
|
251
|
+
const key = variableKey(variable)
|
|
252
|
+
if (key === undefined || seen.has(key)) return undefined
|
|
253
|
+
const initializer = constInitializer(variable)
|
|
254
|
+
if (initializer === undefined) return undefined
|
|
255
|
+
const nextSeen = new Set(seen)
|
|
256
|
+
nextSeen.add(key)
|
|
257
|
+
return resolvedObjectOrArray(context, initializer, nextSeen)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const resolvedEntry = (
|
|
261
|
+
context: Context,
|
|
262
|
+
node: ESTree.Expression
|
|
263
|
+
): ESTree.Expression => {
|
|
264
|
+
const expression = unwrapExpression(node)
|
|
265
|
+
if (expression.type !== "Identifier") return expression
|
|
266
|
+
const variable = resolvedVariable(context, expression)
|
|
267
|
+
const initializer = variable === undefined ? undefined : constInitializer(variable)
|
|
268
|
+
return initializer === undefined ? expression : unwrapExpression(initializer)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const noConflictingInvocationIdentity: Rule = {
|
|
272
|
+
meta: {
|
|
273
|
+
type: "problem",
|
|
274
|
+
docs: {
|
|
275
|
+
description: "Require unique invocation lifecycle IDs and runtime addresses within a state.",
|
|
276
|
+
recommended: true
|
|
277
|
+
},
|
|
278
|
+
schema: [],
|
|
279
|
+
messages: {
|
|
280
|
+
conflictingAddress:
|
|
281
|
+
"Invocation runtime address {{identity}} is reused in this state. Concurrent children cannot own the same address. Give each from.logic(...) invocation a distinct Machine.childAddress(...), or put sequential work in separate states.",
|
|
282
|
+
conflictingBoth:
|
|
283
|
+
"Invocation identity {{identity}} is reused as both lifecycle ID and runtime address in this state. Outcomes become ambiguous and overlapping starts fail. Give each invocation a unique ID/address, or put sequential work in separate states.",
|
|
284
|
+
conflictingLifecycle:
|
|
285
|
+
"Invocation lifecycle ID {{identity}} is reused in this state. Outcomes are routed by state path and ID, so duplicate IDs are ambiguous and overlapping starts fail. Give each invocation a unique ID, or put sequential work in separate states."
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
create(context) {
|
|
289
|
+
const bindings = makeMachineBindings()
|
|
290
|
+
const inspect = (node: PlanningFunction): void => {
|
|
291
|
+
if (!hasMachineImport(bindings) || !isInvokePlanningCallback(node, bindings)) return
|
|
292
|
+
const parameter = node.params[0]
|
|
293
|
+
if (parameter?.type !== "Identifier") return
|
|
294
|
+
const lifecycle = new Map<string, number>()
|
|
295
|
+
const addresses = new Map<string, number>()
|
|
296
|
+
const entries = returnedEntries(context, node)
|
|
297
|
+
entries.forEach((entry, index) => {
|
|
298
|
+
const identity = invocationIdentity(context, resolvedEntry(context, entry), parameter.name, bindings)
|
|
299
|
+
if (identity === undefined) return
|
|
300
|
+
const lifecycleConflict = lifecycle.get(identity.lifecycle.key)
|
|
301
|
+
const addressConflict = identity.address === undefined
|
|
302
|
+
? undefined
|
|
303
|
+
: addresses.get(identity.address.key)
|
|
304
|
+
if (
|
|
305
|
+
lifecycleConflict !== undefined &&
|
|
306
|
+
addressConflict !== undefined &&
|
|
307
|
+
lifecycleConflict === addressConflict
|
|
308
|
+
) {
|
|
309
|
+
context.report({
|
|
310
|
+
node: identity.lifecycle.node,
|
|
311
|
+
messageId: "conflictingBoth",
|
|
312
|
+
data: { identity: identity.lifecycle.label }
|
|
313
|
+
})
|
|
314
|
+
} else {
|
|
315
|
+
if (lifecycleConflict !== undefined) {
|
|
316
|
+
context.report({
|
|
317
|
+
node: identity.lifecycle.node,
|
|
318
|
+
messageId: "conflictingLifecycle",
|
|
319
|
+
data: { identity: identity.lifecycle.label }
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
if (addressConflict !== undefined && identity.address !== undefined) {
|
|
323
|
+
context.report({
|
|
324
|
+
node: identity.address.node,
|
|
325
|
+
messageId: "conflictingAddress",
|
|
326
|
+
data: { identity: identity.address.label }
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (!lifecycle.has(identity.lifecycle.key)) lifecycle.set(identity.lifecycle.key, index)
|
|
331
|
+
if (identity.address !== undefined && !addresses.has(identity.address.key)) {
|
|
332
|
+
addresses.set(identity.address.key, index)
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
ImportDeclaration: (node) => recordMachineImport(bindings, node),
|
|
338
|
+
VariableDeclarator: (node) => recordMachineDefinition(bindings, node),
|
|
339
|
+
ArrowFunctionExpression: inspect,
|
|
340
|
+
FunctionExpression: inspect
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ESTree, Rule } from "@oxlint/plugins"
|
|
2
|
+
import { nondeterministicOperation, nondeterministicProperty } from "../ambient.js"
|
|
3
|
+
import { hasMachineImport, makeMachineBindings, recordMachineDefinition, recordMachineImport } from "../imports.js"
|
|
4
|
+
import { enclosingPlanningCallback } from "../planning.js"
|
|
5
|
+
|
|
6
|
+
export const noNondeterministicPlanning: Rule = {
|
|
7
|
+
meta: {
|
|
8
|
+
type: "problem",
|
|
9
|
+
docs: {
|
|
10
|
+
description: "Keep ambient time and randomness out of Effect Machine planning.",
|
|
11
|
+
recommended: true
|
|
12
|
+
},
|
|
13
|
+
schema: [],
|
|
14
|
+
messages: {
|
|
15
|
+
nondeterministic:
|
|
16
|
+
"{{operation}} produces a different result without a machine event or state change. Pass the value through machine input or an event, or produce it in a state-owned invocation and transition from its outcome."
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
const bindings = makeMachineBindings()
|
|
21
|
+
const report = (
|
|
22
|
+
node: ESTree.CallExpression | ESTree.MemberExpression | ESTree.NewExpression,
|
|
23
|
+
operation: string | undefined
|
|
24
|
+
): void => {
|
|
25
|
+
if (
|
|
26
|
+
operation !== undefined &&
|
|
27
|
+
hasMachineImport(bindings) &&
|
|
28
|
+
enclosingPlanningCallback(node, bindings) !== undefined
|
|
29
|
+
) context.report({ node, messageId: "nondeterministic", data: { operation } })
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
ImportDeclaration: (node) => recordMachineImport(bindings, node),
|
|
33
|
+
CallExpression: (node) => report(node, nondeterministicOperation(context, node)),
|
|
34
|
+
MemberExpression: (node) => report(node, nondeterministicProperty(context, node)),
|
|
35
|
+
NewExpression: (node) => report(node, nondeterministicOperation(context, node)),
|
|
36
|
+
VariableDeclarator: (node) => recordMachineDefinition(bindings, node)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -49,6 +49,24 @@ const isDefaultTargetConstruction = (
|
|
|
49
49
|
node.callee.object.type === "Identifier" &&
|
|
50
50
|
node.callee.object.name === binding
|
|
51
51
|
|
|
52
|
+
const isEmptyResolver = (
|
|
53
|
+
node: ESTree.ArrowFunctionExpression | ESTree.Function
|
|
54
|
+
): boolean => node.body?.type === "BlockStatement" && node.body.body.length === 0
|
|
55
|
+
|
|
56
|
+
const isTargetlessReceiver = (node: ESTree.Expression): boolean =>
|
|
57
|
+
node.type === "MemberExpression" && staticMemberName(node) === "none"
|
|
58
|
+
|
|
59
|
+
const isReenterOnlyOptions = (node: ESTree.Expression | undefined): boolean => {
|
|
60
|
+
if (node?.type !== "ObjectExpression" || node.properties.length !== 1) return false
|
|
61
|
+
const property = node.properties[0]
|
|
62
|
+
return property?.type === "Property" &&
|
|
63
|
+
!property.computed &&
|
|
64
|
+
property.key.type === "Identifier" &&
|
|
65
|
+
property.key.name === "reenter" &&
|
|
66
|
+
property.value.type === "Literal" &&
|
|
67
|
+
property.value.value === true
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
export const noRedundantResolve: Rule = {
|
|
53
71
|
meta: {
|
|
54
72
|
type: "suggestion",
|
|
@@ -59,7 +77,12 @@ export const noRedundantResolve: Rule = {
|
|
|
59
77
|
fixable: "code",
|
|
60
78
|
schema: [],
|
|
61
79
|
messages: {
|
|
62
|
-
redundantResolver:
|
|
80
|
+
redundantResolver:
|
|
81
|
+
"Remove this resolver. The selected target already applies default construction, so use the target selector directly.",
|
|
82
|
+
redundantReenterResolver:
|
|
83
|
+
"Replace this resolver with .reenter(). It applies the same default construction while explicitly reentering the selected state.",
|
|
84
|
+
redundantTargetlessResolver:
|
|
85
|
+
"Remove this empty resolver. A targetless transition performs the same work as to.none; use to.none directly."
|
|
63
86
|
}
|
|
64
87
|
},
|
|
65
88
|
create(context) {
|
|
@@ -69,7 +92,7 @@ export const noRedundantResolve: Rule = {
|
|
|
69
92
|
CallExpression(node) {
|
|
70
93
|
if (
|
|
71
94
|
!hasMachineImport(bindings) ||
|
|
72
|
-
node.arguments.length !== 1 ||
|
|
95
|
+
(node.arguments.length !== 1 && node.arguments.length !== 2) ||
|
|
73
96
|
node.callee.type !== "MemberExpression" ||
|
|
74
97
|
staticMemberName(node.callee) !== "resolve"
|
|
75
98
|
) return
|
|
@@ -82,22 +105,31 @@ export const noRedundantResolve: Rule = {
|
|
|
82
105
|
if (callback.async || callback.generator) return
|
|
83
106
|
if (!isPlanningCallback(callback, bindings)) return
|
|
84
107
|
|
|
108
|
+
const receiver = node.callee.object
|
|
85
109
|
const binding = targetBinding(callback)
|
|
86
|
-
|
|
87
|
-
binding
|
|
88
|
-
|
|
89
|
-
) return
|
|
110
|
+
const defaultConstruction = binding !== undefined &&
|
|
111
|
+
isDefaultTargetConstruction(returnedExpression(callback), binding)
|
|
112
|
+
const targetless = isTargetlessReceiver(receiver) && isEmptyResolver(callback)
|
|
113
|
+
if (!defaultConstruction && !targetless) return
|
|
90
114
|
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
const options = node.arguments[1]
|
|
116
|
+
if (options?.type === "SpreadElement") return
|
|
117
|
+
const reenter = options === undefined ? false : isReenterOnlyOptions(options)
|
|
118
|
+
if (options !== undefined && !reenter) return
|
|
119
|
+
|
|
120
|
+
const messageId = reenter
|
|
121
|
+
? "redundantReenterResolver"
|
|
122
|
+
: targetless
|
|
123
|
+
? "redundantTargetlessResolver"
|
|
124
|
+
: "redundantResolver"
|
|
125
|
+
const replacement = `${context.sourceCode.getText(receiver)}${reenter ? ".reenter()" : ""}`
|
|
126
|
+
context.report({
|
|
127
|
+
node,
|
|
128
|
+
messageId,
|
|
129
|
+
...(context.sourceCode.getCommentsInside(node).length === 0
|
|
130
|
+
? { fix: (fixer) => fixer.replaceText(node, replacement) }
|
|
131
|
+
: undefined)
|
|
132
|
+
})
|
|
101
133
|
},
|
|
102
134
|
VariableDeclarator: (node) => recordMachineDefinition(bindings, node)
|
|
103
135
|
}
|
package/src/recommended.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Rules recommended for every Effect Machine model. */
|
|
2
2
|
export const recommended = {
|
|
3
3
|
"effect-machine/no-async-planning-callback": "error",
|
|
4
|
+
"effect-machine/no-browser-api-in-planning": "error",
|
|
5
|
+
"effect-machine/no-conflicting-invocation-identity": "error",
|
|
6
|
+
"effect-machine/no-nondeterministic-planning": "error",
|
|
4
7
|
"effect-machine/no-redundant-resolve": "error",
|
|
5
8
|
"effect-machine/prefer-inline-handle": "error"
|
|
6
9
|
} as const
|