Skip to content

Latest commit

 

History

History
217 lines (170 loc) · 6.84 KB

README.md

File metadata and controls

217 lines (170 loc) · 6.84 KB

Effect Machine Oxlint plugin

@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

Install the plugin and the matching Effect Machine release:

pnpm add -D oxlint @typeonce/oxlint-plugin-effect-machine
pnpm add @typeonce/effect-machine

All Effect Machine packages use the same version.

Configure

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.

Rules

effect-machine/no-redundant-resolve

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" }.

effect-machine/no-conditional-none-branch

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.

effect-machine/no-async-planning-callback

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.

effect-machine/no-conflicting-invocation-identity

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.

effect-machine/no-browser-api-in-planning

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.

effect-machine/no-nondeterministic-planning

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.

effect-machine/prefer-inline-handle

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.