@typeonce/oxlint-plugin-effect-machine checks Effect Machine definitions for
invalid invocation identities, impure planning, redundant resolvers,
conditional none fallbacks that keep an event accepted, and one-use
intermediate machine definitions.
The plugin uses Oxlint's JavaScript plugin interface. Custom JavaScript plugins are currently alpha in Oxlint, so keep Oxlint and this package on versions that have been tested together.
Install the plugin and the matching Effect Machine release:
pnpm add -D oxlint @typeonce/oxlint-plugin-effect-machine
pnpm add @typeonce/effect-machineAll Effect Machine packages use the same version.
Load the plugin and its recommended rules from oxlint.config.ts:
import { recommended } from "@typeonce/oxlint-plugin-effect-machine/recommended"
import { defineConfig } from "oxlint"
export default defineConfig({
jsPlugins: ["@typeonce/oxlint-plugin-effect-machine"],
rules: recommended
})JSON configurations can list the same rules directly:
{
"jsPlugins": ["@typeonce/oxlint-plugin-effect-machine"],
"rules": {
"effect-machine/no-async-planning-callback": "error",
"effect-machine/no-browser-api-in-planning": "error",
"effect-machine/no-conditional-none-branch": "error",
"effect-machine/no-conflicting-invocation-identity": "error",
"effect-machine/no-nondeterministic-planning": "error",
"effect-machine/no-redundant-resolve": "error",
"effect-machine/prefer-inline-handle": "error"
}
}The rules are syntax-based and deliberately conservative. They recognize
Machine.make(...), direct chained .handle(...) calls, and .handle(...)
calls on definitions declared in the same module. They do not resolve an
imported machine definition or guess the result of an arbitrary function call.
Planning checks cover initial, transition, resolution, lifecycle, choice, initialization, output, history fallback, and invocation input callbacks. Registered source programs are state-owned work and are not treated as planning.
An empty targetless resolver has no work to do:
// Before
Ignore: { none: true, resolve: () => {} }
// After oxlint --fix
Ignore: { none: true }The fixer preserves callbacks with comments or meaningful work. Ordinary
transitions with default construction use { target: "Ready" }.
A { none: true } branch accepts its event without changing state, so
Machine.can and AtomMachine.can report true when a resolver selects it.
The rule reports a resolver that selects a none branch conditionally without
declinable: true, because a control enabled by can would stay enabled:
// In make:
branches: {
submit: {
review: { target: "Reviewing" },
incomplete: { none: true }
}
}
// Incorrect: Submitted is still accepted while the form is incomplete.
Submitted: {
branches: "submit",
resolve: ({ state, select }) =>
state.complete ? select.review() : select.incomplete()
}
// Correct: the incomplete form declines Submitted.
Submitted: {
branches: "submit",
declinable: true,
resolve: ({ state, select, decline }) =>
state.complete ? select.review() : decline()
}Remove the unused none branch from the group after the change, or use
guard on an ordinary transition. The rule recognizes select.<key>(),
destructured selectors, ternaries, and if returns against a group declared in
the same module's make call. An unconditional none selection and a resolver
with declinable: true remain valid.
Rejects asynchronous work in transition construction, guards, resolvers,
initialize input mappers, lifecycle handlers, root and initial data constructors, and invocation input mappers. Register lazy
Effects and Streams in make, then let a state invoke them:
// In make: effects: { submitOrder }
Submitting: {
invoke: {
src: "submitOrder",
input: ({ state }) => state.order,
onDone: { target: "Complete", data: ({ output }) => ({ order: output }) },
onFailure: { target: "Failed", data: ({ error }) => ({ message: String(error) }) }
}
}Source programs may perform asynchronous work. Input mappers only select inputs synchronously. The rule reports known unshadowed globals, not arbitrary function calls whose behavior it cannot determine.
Invocations on one state require unique lifecycle IDs. Effects, Streams, and
timers default their ID to src; an explicit id overrides it. Logic and child
processes also require unique addresses while active.
// Incorrect: both declarations explicitly use the same ID.
invoke: ;
;[
{ src: "loadAccount", id: "load", ...accountOutcomes },
{ src: "loadTimeout", id: "load", ...timeoutOutcomes }
]
// Correct: distinct registered source names supply distinct default IDs.
invoke: ;
;[
{ src: "loadAccount", ...accountOutcomes },
{ src: "loadTimeout", ...timeoutOutcomes }
]The rule resolves static literals, local bindings, Machine.childAddress, and
child descriptors registered in the same module's make call. It checks one
state's invocation array at a time. Runtime validation also checks concurrent
ownership; use separate states when two lifecycles must run sequentially.
Rejects ambient browser access such as localStorage, document, navigator,
and event APIs from planning callbacks. Obtain external facts in registered
Effects and pass their results through invocation outcomes. Keep purely visual
work such as focusing or measuring elements in the UI adapter.
Pure data utilities such as URL, URLSearchParams, TextEncoder, and
structuredClone remain valid.
Rejects ambient time and randomness in planning callbacks, including
Date.now(), zero-argument new Date(), Math.random(), and crypto randomness.
Receive facts through events or machine input, or obtain them in invoked work:
// In make:
branches: {
expiry: {
expired: { target: "Expired" }
}
}
// In handle:
Check: {
branches: "expiry",
declinable: true,
resolve: ({ event, select, decline }) => event.now >= event.deadline
? select.expired.from()
: decline()
}Deterministic operations such as new Date(event.timestamp) and
Date.parse(state.createdAt) remain valid.
Reports a private top-level Machine.make(...) definition when its only use is
one .handle(...) call:
// Before
const definition = Machine.make({/* ... */})
export const machine = definition.handle({ states: {/* ... */} })
// After
export const machine = Machine.make({/* ... */}).handle({ states: {/* ... */} })Definitions that are exported or reused remain valid.