diff --git a/.changeset/README.md b/.changeset/README.md
index 17b7ed3..7ed3085 100644
--- a/.changeset/README.md
+++ b/.changeset/README.md
@@ -13,7 +13,7 @@ Do not create a major changeset before 1.0. Backward compatibility is not a desi
## Synchronized package versions
-`@typeonce/effect-machine` and `@typeonce/effect-machine-devtools` belong to the same Changesets fixed group. Keep their package versions equal and use `workspace:^` for the devtools dependency on core. A release affecting either package publishes both at the same version, so users can select compatible packages by matching their versions.
+`@typeonce/effect-machine`, `@typeonce/effect-machine-devtools`, and `@typeonce/oxlint-plugin-effect-machine` belong to the same Changesets fixed group. Keep their package versions equal and use `workspace:^` for the devtools dependency on core. A release affecting any package publishes all three at the same version, so users can select compatible packages by matching their versions.
## Writing changelog entries
diff --git a/.changeset/calm-machines-lint.md b/.changeset/calm-machines-lint.md
new file mode 100644
index 0000000..dcbd711
--- /dev/null
+++ b/.changeset/calm-machines-lint.md
@@ -0,0 +1,9 @@
+---
+"@typeonce/effect-machine": minor
+"@typeonce/effect-machine-devtools": minor
+"@typeonce/oxlint-plugin-effect-machine": minor
+---
+
+Add `@typeonce/oxlint-plugin-effect-machine` with recommended rules for redundant default resolvers, asynchronous planning callbacks, and one-use intermediate `Machine.make(...)` definitions.
+
+All three Effect Machine packages now release at the same version.
diff --git a/.changeset/config.json b/.changeset/config.json
index bb45105..9130fa7 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -5,7 +5,8 @@
"fixed": [
[
"@typeonce/effect-machine",
- "@typeonce/effect-machine-devtools"
+ "@typeonce/effect-machine-devtools",
+ "@typeonce/oxlint-plugin-effect-machine"
]
],
"linked": [],
diff --git a/AGENTS.md b/AGENTS.md
index fac5cfd..cd14f8b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,7 @@ When compatibility, convenience, concision, and semantic clarity conflict, prefe
## Experimental versioning
- The library is experimental and pre-1.0. Public additions and breaking API changes use a minor changeset; compatible fixes and implementation improvements use a patch changeset. Do not create major changesets before 1.0.
-- `@typeonce/effect-machine` and `@typeonce/effect-machine-devtools` release from one Changesets fixed group and must always have the same version. Keep their package versions, workspace dependency, and release configuration synchronized so users can install matching versions for compatibility.
+- `@typeonce/effect-machine`, `@typeonce/effect-machine-devtools`, and `@typeonce/oxlint-plugin-effect-machine` release from one Changesets fixed group and must always have the same version. Keep their package versions, workspace dependency, and release configuration synchronized so users can install matching versions for compatibility.
- Backward compatibility is not currently a design goal. Change or remove an existing API whenever a clearer, safer, smaller long-term design replaces it.
- Do not add deprecated aliases, compatibility wrappers, or parallel APIs solely to preserve an inferior existing design unless the user explicitly requests them.
- Explain the resulting API and direct migration in changesets. Do not use changelog entries to credit an external library or narrate implementation history.
diff --git a/README.md b/README.md
index 9e5de86..8c702a4 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,8 @@ This repository is the pnpm workspace for Effect Machine and its development too
- [`@typeonce/effect-machine`](./packages/effect-machine/README.md) contains the machine runtime, testing modules, and documentation.
- [`@typeonce/effect-machine-devtools`](./packages/devtools/README.md) contains the publishable local machine visualizer and CLI.
+- [`@typeonce/oxlint-plugin-effect-machine`](./packages/oxlint-plugin/README.md) checks Effect Machine models for common structural mistakes.
-Both packages use the same version. Install matching versions of core and devtools so their inspection protocol and machine model stay compatible.
+All three packages use the same version. Install matching versions so the runtime, devtools, and lint rules stay aligned.
See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for repository development and validation commands.
diff --git a/package.json b/package.json
index b0e4e70..dc35a92 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
"test:consumer": "node scripts/test-consumer.mjs",
"pack:check": "node scripts/pack-check.mjs",
"devtools:pack-check": "node scripts/devtools-pack-check.mjs",
- "check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm devtools:pack-check",
+ "oxlint-plugin:pack-check": "node scripts/oxlint-plugin-pack-check.mjs",
+ "check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm devtools:pack-check && pnpm oxlint-plugin:pack-check",
"changeset": "changeset",
"version-packages": "changeset version && dprint fmt",
"release": "pnpm build && changeset publish"
diff --git a/packages/effect-machine/docs/machine-review.md b/packages/effect-machine/docs/machine-review.md
new file mode 100644
index 0000000..1b677ea
--- /dev/null
+++ b/packages/effect-machine/docs/machine-review.md
@@ -0,0 +1,199 @@
+# Effect Machine implementation review
+
+Use this review after a machine and its UI adapter have been implemented. Look
+for modeling mistakes, redundant API usage, and logic that escaped the machine.
+Do not force a change when the current ownership is intentional.
+
+Read the [Effect Machine agent guide](./agent-guide.md) for statechart modeling
+and [Effect Atom and React patterns](./effect-atom-react.md) for integration
+patterns.
+
+## Review the responsibility boundaries
+
+Use this split when deciding where code belongs:
+
+| Layer | Owns |
+| --- | --- |
+| Machine | State, transitions, workflow decisions, effect lifetimes, and service execution |
+| Atoms | Reactive selection, projections, and keyed machine lookup |
+| Component | Rendering and translating user interaction into domain events |
+| `RegistryProvider` | An optional Atom registry ownership boundary |
+
+If a component coordinates a workflow, an atom performs business work, or a
+provider has no ownership purpose, inspect that code more closely.
+
+## Remove identity resolvers
+
+A target that supports default construction does not need a resolver whose only
+job is returning `target.from()`.
+
+```ts
+// Redundant
+const handlers = {
+ Start: (to) =>
+ to.full.Running().resolve(({ target }) => target.from())
+}
+
+// Preferred
+const handlers = {
+ Start: (to) => to.full.Running()
+}
+```
+
+This applies to schema-less states and schemas whose constructor fields are all
+optional or defaulted. The type checker rejects the shorter form when the
+target needs data.
+
+Keep `.resolve(...)` when it uses handler context, constructs state data,
+updates a retained owner, chooses a branch, declines a transition, or enqueues
+commands. For resolver-free reentry, use `.reenter()`:
+
+```ts
+const handlers = {
+ Refresh: (to) => to.local.Ready().reenter()
+}
+```
+
+Review check: search for `.resolve(...)` callbacks that only return an empty
+`target.from()` and remove the callback.
+
+## Let `Atom.family` own keyed identity
+
+Treat `useMemo` around an atom family lookup as a warning sign. `Atom.family`
+already returns the same retained object for the same key, including when
+separate components perform the lookup.
+
+```tsx
+// Redundant and local to one component
+const scope = useMemo(() => processFamily(processId), [processId])
+
+// The family owns identity
+const scope = processFamily(processId)
+```
+
+If the component constructs the atoms or machine scope directly, move that
+construction into a module-level family:
+
+```ts
+export const processFamily = Atom.family((processId: string) => {
+ const machine = machineAtoms.make(processMachine, { processId })
+
+ return {
+ stateAtom: AtomMachine.select(machine, "process"),
+ sendAtom: machine.send
+ }
+})
+```
+
+Use a stable domain key. A new key means a different machine instance. Send an
+event when a value should update the current workflow instead.
+
+`useMemo` may still be useful for unrelated expensive calculations. It should
+not establish atom or machine identity. For one instance owned only by a React
+subtree, use a lazy `useState(makeScope)` initializer as described in the React
+guide.
+
+Review check: search for `useMemo` around atom creation, family lookup, or
+`machineAtoms.make`. Replace component-local identity with `Atom.family`, or
+with an intentional component-owned scope.
+
+## Justify each `RegistryProvider`
+
+Effect Atom hooks use a shared default registry when no provider is present.
+Do not add `RegistryProvider` automatically.
+
+Keep a provider when the subtree intentionally needs its own registry. Common
+reasons include:
+
+- the same atom or machine descriptors must represent independent instances in
+ separate subtrees;
+- the subtree owns registry disposal;
+- server rendering or hydration requires a request-local registry;
+- the registry needs initial values, custom scheduling, or custom idle
+ settings.
+
+Without one of those requirements, the default registry is enough:
+
+```tsx
+// A shared application instance can use the default registry.
+export function App() {
+ return
+}
+```
+
+Remember that adding or nesting a provider changes identity. The same atom
+descriptor stores separate state in each registry, so an unnecessary provider
+can split a machine that consumers expected to share.
+
+Review check: for every `RegistryProvider`, state which registry boundary it
+creates and why. Remove it when there is no deliberate boundary.
+
+## Keep workflow logic in the machine
+
+The machine owns business flow. Components render selected state and send
+domain events. Atoms adapt machine state for reactive consumers. Neither layer
+should coordinate the workflow.
+
+The following component owns too much:
+
+```tsx
+const submit = async () => {
+ send(OrderEvents.SubmitStarted())
+ const order = await api.submitOrder(form)
+ analytics.track("order submitted", { orderId: order.id })
+ send(OrderEvents.SubmitSucceeded({ order }))
+}
+```
+
+Model `Submit` as the component-facing event. Let a machine state own the work
+and its lifetime:
+
+```ts
+machine.handle({
+ Editing: {
+ on: {
+ Submit: (to) =>
+ to.full.Submitting().resolve(({ event, target }) =>
+ target.from({ order: event.order })
+ )
+ }
+ },
+ Submitting: {
+ invoke: (from) =>
+ from
+ .effect("submit-order", ({ state }) => submitOrder(state.order))
+ .onDone((to) =>
+ to.full.Complete().resolve(({ output, target }) =>
+ target.from({ order: output })
+ )
+ )
+ .onFailure((to) =>
+ to.full.Failed().resolve(({ error, target }) =>
+ target.from({ message: String(error) })
+ )
+ )
+ }
+})
+```
+
+`submitOrder` can use Effect services for the API request and analytics. The
+service implementation may live in its own module, but the machine decides
+when it runs, which state owns it, what cancels it, and how success or failure
+changes the workflow.
+
+Apply the same test to browser APIs, storage, timers, analytics, navigation,
+and other effects. If the result or lifetime affects machine behavior, execute
+it through the machine. UI-only work such as focusing an element or measuring
+layout can remain in the component when it does not participate in the domain
+flow.
+
+Review check: search components, hooks, and atom modules for service calls,
+`Effect.run*`, promises, browser APIs, analytics, timers, and chains of `send`
+calls. Move workflow coordination into states, transitions, and invoked work.
+
+## Report findings
+
+For each issue, cite the file and line, name the violated boundary, and show the
+smallest correction. Distinguish a confirmed problem from a provider or
+ownership choice that needs clarification. Do not rewrite correct code merely
+to match an example in this guide.
diff --git a/packages/oxlint-plugin/LICENSE b/packages/oxlint-plugin/LICENSE
new file mode 100644
index 0000000..afebcef
--- /dev/null
+++ b/packages/oxlint-plugin/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Sandro Maglione
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/oxlint-plugin/NOTICE b/packages/oxlint-plugin/NOTICE
new file mode 100644
index 0000000..1fd3d2c
--- /dev/null
+++ b/packages/oxlint-plugin/NOTICE
@@ -0,0 +1,2 @@
+Effect Machine Oxlint Plugin is part of the Effect Machine project and is
+distributed under the MIT License.
diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md
new file mode 100644
index 0000000..cec6edb
--- /dev/null
+++ b/packages/oxlint-plugin/README.md
@@ -0,0 +1,95 @@
+# Effect Machine Oxlint plugin
+
+`@typeonce/oxlint-plugin-effect-machine` checks Effect Machine definitions for
+redundant resolvers, asynchronous planning, 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:
+
+```sh
+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`:
+
+```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:
+
+```json
+{
+ "jsPlugins": ["@typeonce/oxlint-plugin-effect-machine"],
+ "rules": {
+ "effect-machine/no-async-planning-callback": "error",
+ "effect-machine/no-redundant-resolve": "error",
+ "effect-machine/prefer-inline-handle": "error"
+ }
+}
+```
+
+The rules are syntax-based. They recognize `Machine.make(...)`, direct chained
+`.handle(...)` calls, and `.handle(...)` calls on definitions declared in the
+same module. They do not resolve a machine definition imported from another
+module.
+
+## Rules
+
+### `effect-machine/no-redundant-resolve`
+
+Removes a resolver whose only result is empty default construction:
+
+```ts
+// Before
+const handlers = {
+ Start: (to) => to.full.Running().resolve(({ target }) => target.from())
+}
+
+// After `oxlint --fix`
+const handlers = {
+ Start: (to) => to.full.Running()
+}
+```
+
+The fixer does not run when the resolver has options, comments, construction
+input, or any other work.
+
+### `effect-machine/no-async-planning-callback`
+
+Rejects asynchronous transition, lifecycle, initial, choice, entry, exit, and
+invocation-planning callbacks. A machine plans synchronously. Put asynchronous
+work in state-owned `invoke` sources instead.
+
+### `effect-machine/prefer-inline-handle`
+
+Reports a private top-level `Machine.make(...)` definition when its only use is
+one `.handle(...)` call:
+
+```ts
+// Before
+const definition = Machine.make({/* ... */})
+export const machine = definition.handle({/* ... */})
+
+// After
+export const machine = Machine.make({/* ... */}).handle({/* ... */})
+```
+
+Definitions that are exported or reused remain valid.
diff --git a/packages/oxlint-plugin/package.json b/packages/oxlint-plugin/package.json
new file mode 100644
index 0000000..5bd095a
--- /dev/null
+++ b/packages/oxlint-plugin/package.json
@@ -0,0 +1,74 @@
+{
+ "name": "@typeonce/oxlint-plugin-effect-machine",
+ "version": "0.25.0",
+ "description": "Oxlint rules for Effect Machine models",
+ "author": "Sandro Maglione",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/typeonce-dev/effect-machine.git",
+ "directory": "packages/oxlint-plugin"
+ },
+ "bugs": {
+ "url": "https://github.com/typeonce-dev/effect-machine/issues"
+ },
+ "homepage": "https://github.com/typeonce-dev/effect-machine/tree/main/packages/oxlint-plugin#readme",
+ "license": "MIT",
+ "type": "module",
+ "sideEffects": false,
+ "files": [
+ "src/**/*.ts",
+ "dist/**/*.js",
+ "dist/**/*.js.map",
+ "dist/**/*.d.ts",
+ "dist/**/*.d.ts.map",
+ "README.md",
+ "LICENSE",
+ "NOTICE"
+ ],
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ },
+ "./recommended": {
+ "types": "./src/recommended.ts",
+ "import": "./src/recommended.ts"
+ },
+ "./package.json": "./package.json",
+ "./internal/*": null
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true,
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./recommended": {
+ "types": "./dist/recommended.d.ts",
+ "import": "./dist/recommended.js"
+ },
+ "./package.json": "./package.json",
+ "./internal/*": null
+ }
+ },
+ "scripts": {
+ "build": "tsc -b tsconfig.build.json",
+ "check": "tsc -b tsconfig.json"
+ },
+ "peerDependencies": {
+ "oxlint": ">=1.80.0 <2"
+ },
+ "dependencies": {
+ "@oxlint/plugins": "1.80.0"
+ },
+ "devDependencies": {
+ "oxlint": "1.80.0",
+ "typescript": "6.0.3",
+ "vitest": "4.1.10"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+}
diff --git a/packages/oxlint-plugin/src/index.ts b/packages/oxlint-plugin/src/index.ts
new file mode 100644
index 0000000..7b7007c
--- /dev/null
+++ b/packages/oxlint-plugin/src/index.ts
@@ -0,0 +1,19 @@
+import type { Plugin } from "@oxlint/plugins"
+import { noAsyncPlanningCallback } from "./internal/rules/noAsyncPlanningCallback.js"
+import { noRedundantResolve } from "./internal/rules/noRedundantResolve.js"
+import { preferInlineHandle } from "./internal/rules/preferInlineHandle.js"
+
+const plugin = {
+ meta: {
+ name: "effect-machine"
+ },
+ rules: {
+ "no-async-planning-callback": noAsyncPlanningCallback,
+ "no-redundant-resolve": noRedundantResolve,
+ "prefer-inline-handle": preferInlineHandle
+ }
+} satisfies Plugin
+
+export { recommended } from "./recommended.js"
+
+export default plugin
diff --git a/packages/oxlint-plugin/src/internal/imports.ts b/packages/oxlint-plugin/src/internal/imports.ts
new file mode 100644
index 0000000..bdd94dd
--- /dev/null
+++ b/packages/oxlint-plugin/src/internal/imports.ts
@@ -0,0 +1,105 @@
+import type { ESTree } from "@oxlint/plugins"
+
+const effectMachineModule = "@typeonce/effect-machine"
+
+export interface MachineBindings {
+ readonly definitions: Set
+ readonly machine: Set
+ readonly namespaces: Set
+}
+
+export const makeMachineBindings = (): MachineBindings => ({
+ definitions: new Set(),
+ machine: new Set(),
+ namespaces: new Set()
+})
+
+const moduleExportName = (node: ESTree.ModuleExportName): string => node.type === "Identifier" ? node.name : node.value
+
+export const recordMachineImport = (
+ bindings: MachineBindings,
+ node: ESTree.ImportDeclaration
+): void => {
+ if (node.source.value !== effectMachineModule) return
+
+ for (const specifier of node.specifiers) {
+ if (specifier.type === "ImportNamespaceSpecifier") {
+ bindings.namespaces.add(specifier.local.name)
+ } else if (
+ specifier.type === "ImportSpecifier" &&
+ moduleExportName(specifier.imported) === "Machine"
+ ) {
+ bindings.machine.add(specifier.local.name)
+ }
+ }
+}
+
+export const hasMachineImport = (bindings: MachineBindings): boolean =>
+ bindings.machine.size > 0 || bindings.namespaces.size > 0
+
+export const staticMemberName = (node: ESTree.Node): string | undefined =>
+ node.type === "MemberExpression" &&
+ !node.computed &&
+ node.property.type === "Identifier"
+ ? node.property.name
+ : undefined
+
+const isNamespaceMachine = (
+ node: ESTree.Node,
+ bindings: MachineBindings
+): boolean =>
+ node.type === "MemberExpression" &&
+ !node.computed &&
+ node.object.type === "Identifier" &&
+ bindings.namespaces.has(node.object.name) &&
+ node.property.type === "Identifier" &&
+ node.property.name === "Machine"
+
+export const isMachineMakeCall = (
+ node: ESTree.CallExpression,
+ bindings: MachineBindings
+): boolean => {
+ if (
+ node.callee.type !== "MemberExpression" ||
+ staticMemberName(node.callee) !== "make"
+ ) return false
+
+ const receiver = node.callee.object
+ return receiver.type === "Identifier"
+ ? bindings.machine.has(receiver.name)
+ : isNamespaceMachine(receiver, bindings)
+}
+
+export const recordMachineDefinition = (
+ bindings: MachineBindings,
+ node: ESTree.VariableDeclarator
+): void => {
+ if (
+ node.id.type === "Identifier" &&
+ node.init?.type === "CallExpression" &&
+ isMachineMakeCall(node.init, bindings)
+ ) bindings.definitions.add(node.id.name)
+}
+
+export const isMachineHandleCall = (
+ node: ESTree.CallExpression,
+ bindings: MachineBindings
+): boolean => {
+ if (
+ node.callee.type !== "MemberExpression" ||
+ staticMemberName(node.callee) !== "handle"
+ ) return false
+
+ const receiver = node.callee.object
+ return receiver.type === "CallExpression"
+ ? isMachineMakeCall(receiver, bindings)
+ : receiver.type === "Identifier" && bindings.definitions.has(receiver.name)
+}
+
+export const isMemberCall = (
+ node: ESTree.Node,
+ member: string
+): node is ESTree.CallExpression =>
+ node.type === "CallExpression" &&
+ node.callee.type === "MemberExpression" &&
+ staticMemberName(node.callee) === member
diff --git a/packages/oxlint-plugin/src/internal/planning.ts b/packages/oxlint-plugin/src/internal/planning.ts
new file mode 100644
index 0000000..09de350
--- /dev/null
+++ b/packages/oxlint-plugin/src/internal/planning.ts
@@ -0,0 +1,124 @@
+import type { ESTree } from "@oxlint/plugins"
+import { isMachineHandleCall, isMachineMakeCall, type MachineBindings, staticMemberName } from "./imports.js"
+
+export type PlanningFunction = ESTree.ArrowFunctionExpression | ESTree.Function
+
+const directPlanningMethods = new Set([
+ "onDone",
+ "onElement",
+ "onFailure",
+ "onSnapshot",
+ "resolve"
+])
+
+const statePlanningProperties = new Set([
+ "always",
+ "choice",
+ "entry",
+ "exit",
+ "invoke"
+])
+
+const propertyName = (node: ESTree.Node): string | undefined => {
+ if (node.type !== "Property" || node.computed) return undefined
+ return node.key.type === "Identifier" || node.key.type === "Literal"
+ ? String(node.key.type === "Identifier" ? node.key.name : node.key.value)
+ : undefined
+}
+
+const isCallArgument = (
+ node: ESTree.ObjectExpression,
+ predicate: (call: ESTree.CallExpression) => boolean
+): boolean =>
+ node.parent.type === "CallExpression" &&
+ node.parent.arguments.includes(node) &&
+ predicate(node.parent)
+
+const isMachineMakeConfig = (
+ node: ESTree.ObjectExpression,
+ bindings: MachineBindings
+): boolean => isCallArgument(node, (call) => isMachineMakeCall(call, bindings))
+
+const isMachineHandleConfig = (
+ node: ESTree.ObjectExpression,
+ bindings: MachineBindings
+): boolean => isCallArgument(node, (call) => isMachineHandleCall(call, bindings))
+
+const isStateConfig = (
+ node: ESTree.ObjectExpression,
+ bindings: MachineBindings
+): boolean => {
+ if (node.parent.type !== "Property" || node.parent.parent.type !== "ObjectExpression") {
+ return false
+ }
+
+ const stateCollection = node.parent.parent
+ if (isMachineHandleConfig(stateCollection, bindings)) return true
+ if (
+ stateCollection.parent.type !== "Property" ||
+ propertyName(stateCollection.parent) !== "states" ||
+ stateCollection.parent.parent.type !== "ObjectExpression"
+ ) return false
+ return isStateConfig(stateCollection.parent.parent, bindings)
+}
+
+const isEventHandlerProperty = (
+ node: ESTree.Node,
+ bindings: MachineBindings
+): boolean => {
+ if (node.type !== "Property" || node.parent.type !== "ObjectExpression") return false
+ const onProperty = node.parent.parent
+ return onProperty.type === "Property" &&
+ propertyName(onProperty) === "on" &&
+ onProperty.parent.type === "ObjectExpression" &&
+ isStateConfig(onProperty.parent, bindings)
+}
+
+const isPropertyPlanningCallback = (
+ node: PlanningFunction,
+ bindings: MachineBindings
+): boolean => {
+ const property = node.parent
+ if (
+ property.type !== "Property" ||
+ property.value !== node ||
+ property.parent.type !== "ObjectExpression"
+ ) return false
+
+ const name = propertyName(property)
+ return name === "initial"
+ ? isMachineMakeConfig(property.parent, bindings)
+ : (name !== undefined && statePlanningProperties.has(name) && isStateConfig(property.parent, bindings)) ||
+ isEventHandlerProperty(property, bindings)
+}
+
+const enclosingFunction = (node: ESTree.Node): PlanningFunction | undefined => {
+ let current: ESTree.Node | null = node.parent
+ while (current !== null && current.type !== "Program") {
+ if (
+ current.type === "ArrowFunctionExpression" ||
+ current.type === "FunctionExpression"
+ ) return current
+ current = current.parent
+ }
+ return undefined
+}
+
+export const isPlanningCallback = (
+ node: PlanningFunction,
+ bindings: MachineBindings
+): boolean => {
+ if (isPropertyPlanningCallback(node, bindings)) return true
+
+ const parent = node.parent
+ if (parent.type === "CallExpression" && parent.arguments.includes(node)) {
+ const method = parent.callee.type === "MemberExpression"
+ ? staticMemberName(parent.callee)
+ : undefined
+ if (method !== undefined && directPlanningMethods.has(method)) {
+ const owner = enclosingFunction(parent)
+ return owner !== undefined && isPlanningCallback(owner, bindings)
+ }
+ }
+ return false
+}
diff --git a/packages/oxlint-plugin/src/internal/rules/noAsyncPlanningCallback.ts b/packages/oxlint-plugin/src/internal/rules/noAsyncPlanningCallback.ts
new file mode 100644
index 0000000..1b6c061
--- /dev/null
+++ b/packages/oxlint-plugin/src/internal/rules/noAsyncPlanningCallback.ts
@@ -0,0 +1,35 @@
+import type { Rule } from "@oxlint/plugins"
+import { hasMachineImport, makeMachineBindings, recordMachineDefinition, recordMachineImport } from "../imports.js"
+import { isPlanningCallback, type PlanningFunction } from "../planning.js"
+
+export const noAsyncPlanningCallback: Rule = {
+ meta: {
+ type: "problem",
+ docs: {
+ description: "Keep Effect Machine planning callbacks synchronous.",
+ recommended: true
+ },
+ schema: [],
+ messages: {
+ asyncPlanning: "Planning callbacks must be synchronous. Move asynchronous work into state-owned invocation."
+ }
+ },
+ create(context) {
+ const bindings = makeMachineBindings()
+ const inspect = (node: PlanningFunction): void => {
+ if (
+ hasMachineImport(bindings) &&
+ node.async &&
+ isPlanningCallback(node, bindings)
+ ) {
+ context.report({ node, messageId: "asyncPlanning" })
+ }
+ }
+ return {
+ ImportDeclaration: (node) => recordMachineImport(bindings, node),
+ VariableDeclarator: (node) => recordMachineDefinition(bindings, node),
+ ArrowFunctionExpression: inspect,
+ FunctionExpression: inspect
+ }
+ }
+}
diff --git a/packages/oxlint-plugin/src/internal/rules/noRedundantResolve.ts b/packages/oxlint-plugin/src/internal/rules/noRedundantResolve.ts
new file mode 100644
index 0000000..2cbb589
--- /dev/null
+++ b/packages/oxlint-plugin/src/internal/rules/noRedundantResolve.ts
@@ -0,0 +1,105 @@
+import type { ESTree, Rule } from "@oxlint/plugins"
+import {
+ hasMachineImport,
+ makeMachineBindings,
+ recordMachineDefinition,
+ recordMachineImport,
+ staticMemberName
+} from "../imports.js"
+import { isPlanningCallback } from "../planning.js"
+
+const returnedExpression = (
+ node: ESTree.ArrowFunctionExpression | ESTree.Function
+): ESTree.Expression | null | undefined => {
+ if (node.body === null) return undefined
+ if (node.body.type !== "BlockStatement") return node.body
+ if (node.body.body.length !== 1 || node.body.body[0]?.type !== "ReturnStatement") {
+ return undefined
+ }
+ return node.body.body[0].argument
+}
+
+const targetBinding = (
+ node: ESTree.ArrowFunctionExpression | ESTree.Function
+): string | undefined => {
+ if (node.params.length !== 1) return undefined
+ const parameter = node.params[0]
+ if (parameter?.type !== "ObjectPattern" || parameter.properties.length !== 1) {
+ return undefined
+ }
+ const property = parameter.properties[0]
+ if (
+ property?.type !== "Property" ||
+ property.computed ||
+ property.key.type !== "Identifier" ||
+ property.key.name !== "target" ||
+ property.value.type !== "Identifier"
+ ) return undefined
+ return property.value.name
+}
+
+const isDefaultTargetConstruction = (
+ node: ESTree.Expression | null | undefined,
+ binding: string
+): boolean =>
+ node?.type === "CallExpression" &&
+ node.arguments.length === 0 &&
+ node.callee.type === "MemberExpression" &&
+ staticMemberName(node.callee) === "from" &&
+ node.callee.object.type === "Identifier" &&
+ node.callee.object.name === binding
+
+export const noRedundantResolve: Rule = {
+ meta: {
+ type: "suggestion",
+ docs: {
+ description: "Remove resolvers that only apply default target construction.",
+ recommended: true
+ },
+ fixable: "code",
+ schema: [],
+ messages: {
+ redundantResolver: "Remove this resolver. The selected target already applies default construction."
+ }
+ },
+ create(context) {
+ const bindings = makeMachineBindings()
+ return {
+ ImportDeclaration: (node) => recordMachineImport(bindings, node),
+ CallExpression(node) {
+ if (
+ !hasMachineImport(bindings) ||
+ node.arguments.length !== 1 ||
+ node.callee.type !== "MemberExpression" ||
+ staticMemberName(node.callee) !== "resolve"
+ ) return
+
+ const callback = node.arguments[0]
+ if (
+ callback?.type !== "ArrowFunctionExpression" &&
+ callback?.type !== "FunctionExpression"
+ ) return
+ if (callback.async || callback.generator) return
+ if (!isPlanningCallback(callback, bindings)) return
+
+ const binding = targetBinding(callback)
+ if (
+ binding === undefined ||
+ !isDefaultTargetConstruction(returnedExpression(callback), binding)
+ ) return
+
+ const receiver = node.callee.object
+ if (context.sourceCode.getCommentsInside(node).length === 0) {
+ context.report({
+ node,
+ messageId: "redundantResolver",
+ fix: (fixer) => fixer.replaceText(node, context.sourceCode.getText(receiver))
+ })
+ } else {
+ context.report({ node, messageId: "redundantResolver" })
+ }
+ },
+ VariableDeclarator: (node) => recordMachineDefinition(bindings, node)
+ }
+ }
+}
diff --git a/packages/oxlint-plugin/src/internal/rules/preferInlineHandle.ts b/packages/oxlint-plugin/src/internal/rules/preferInlineHandle.ts
new file mode 100644
index 0000000..bd9b5a5
--- /dev/null
+++ b/packages/oxlint-plugin/src/internal/rules/preferInlineHandle.ts
@@ -0,0 +1,76 @@
+import type { Context, ESTree, Rule, Variable } from "@oxlint/plugins"
+import {
+ hasMachineImport,
+ isMachineMakeCall,
+ isMemberCall,
+ makeMachineBindings,
+ recordMachineDefinition,
+ recordMachineImport
+} from "../imports.js"
+
+interface Candidate {
+ readonly declaration: ESTree.VariableDeclarator
+ readonly name: ESTree.BindingIdentifier
+}
+
+const isTopLevelPrivateDeclaration = (node: ESTree.VariableDeclarator): boolean =>
+ node.parent.type === "VariableDeclaration" && node.parent.parent.type === "Program"
+
+const declaredVariable = (
+ context: Context,
+ candidate: Candidate
+): Variable | undefined =>
+ context.sourceCode.getDeclaredVariables(candidate.declaration)
+ .find((variable) => variable.name === candidate.name.name)
+
+const isOnlyHandleReference = (variable: Variable): boolean => {
+ const references = variable.references.filter((reference) => reference.isRead())
+ if (references.length !== 1) return false
+
+ const identifier = references[0]?.identifier
+ if (identifier?.parent.type !== "MemberExpression") return false
+ const member = identifier.parent
+ if (member.object !== identifier || !isMemberCall(member.parent, "handle")) return false
+ return member.parent.callee === member
+}
+
+export const preferInlineHandle: Rule = {
+ meta: {
+ type: "suggestion",
+ docs: {
+ description: "Chain handle directly from one-use Machine.make definitions.",
+ recommended: true
+ },
+ schema: [],
+ messages: {
+ inlineHandle:
+ "Chain .handle(...) directly from Machine.make(...). This one-use definition adds no reusable model."
+ }
+ },
+ create(context) {
+ const bindings = makeMachineBindings()
+ const candidates: Array = []
+ return {
+ ImportDeclaration: (node) => recordMachineImport(bindings, node),
+ VariableDeclarator(node) {
+ recordMachineDefinition(bindings, node)
+ if (
+ !hasMachineImport(bindings) ||
+ node.id.type !== "Identifier" ||
+ node.init?.type !== "CallExpression" ||
+ !isTopLevelPrivateDeclaration(node) ||
+ !isMachineMakeCall(node.init, bindings)
+ ) return
+ candidates.push({ declaration: node, name: node.id })
+ },
+ "Program:exit"() {
+ for (const candidate of candidates) {
+ const variable = declaredVariable(context, candidate)
+ if (variable !== undefined && isOnlyHandleReference(variable)) {
+ context.report({ node: candidate.name, messageId: "inlineHandle" })
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/oxlint-plugin/src/recommended.ts b/packages/oxlint-plugin/src/recommended.ts
new file mode 100644
index 0000000..8605c65
--- /dev/null
+++ b/packages/oxlint-plugin/src/recommended.ts
@@ -0,0 +1,6 @@
+/** Rules recommended for every Effect Machine model. */
+export const recommended = {
+ "effect-machine/no-async-planning-callback": "error",
+ "effect-machine/no-redundant-resolve": "error",
+ "effect-machine/prefer-inline-handle": "error"
+} as const
diff --git a/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts b/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts
new file mode 100644
index 0000000..34789aa
--- /dev/null
+++ b/packages/oxlint-plugin/test/noAsyncPlanningCallback.test.ts
@@ -0,0 +1,67 @@
+import { RuleTester } from "oxlint/plugins-dev"
+import { describe, it } from "vitest"
+import plugin from "../src/index.js"
+
+RuleTester.describe = describe
+RuleTester.it = it
+
+const tester = new RuleTester({
+ languageOptions: { parserOptions: { lang: "ts" } }
+})
+
+const rule = plugin.rules["no-async-planning-callback"]
+
+tester.run("no-async-planning-callback", rule, {
+ valid: [
+ `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => from.effect("load", async () => undefined) } })`,
+ `import { Machine } from "@typeonce/effect-machine"
+const other = { resolve: (_callback: unknown) => undefined }
+other.resolve(async () => undefined)`,
+ `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready(), metadata: { initial: async () => undefined } })`,
+ `import { Machine } from "@typeonce/effect-machine"
+const other = { handle: (_config: unknown) => undefined }
+other.handle({ Ready: { entry: async () => undefined } })`,
+ `const machine = { initial: async () => undefined }`
+ ],
+ invalid: [
+ {
+ code: `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: async (to) => to.Ready() })`,
+ errors: [{ messageId: "asyncPlanning" }]
+ },
+ {
+ code: `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { on: { Start: async (to) => to.full.Running() } } })`,
+ errors: [{ messageId: "asyncPlanning" }]
+ },
+ {
+ code: `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready().resolve(async ({ target }) => target.from()) })`,
+ errors: [{ messageId: "asyncPlanning" }]
+ },
+ {
+ code: `import * as EM from "@typeonce/effect-machine"
+EM.Machine.make({ initial: (to) => to.Loading() }).handle({ Loading: { invoke: (from) => from.effect("load", () => Promise.resolve()).onDone(async (to) => to.full.Done()) } })`,
+ errors: [{ messageId: "asyncPlanning" }]
+ },
+ {
+ code: `import { Machine } from "@typeonce/effect-machine"
+const definition = Machine.make({ initial: (to) => to.Parent() })
+definition.handle({ Parent: { states: { Child: { entry: async () => undefined } } } })`,
+ errors: [{ messageId: "asyncPlanning" }]
+ },
+ {
+ code: `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: {
+ entry: async () => undefined,
+ exit: async () => undefined,
+ always: async (to) => to.none,
+ choice: async (to) => to.none,
+ invoke: async (from) => from.timer("tick", 1)
+} })`,
+ errors: Array.from({ length: 5 }, () => ({ messageId: "asyncPlanning" }))
+ }
+ ]
+})
diff --git a/packages/oxlint-plugin/test/noRedundantResolve.test.ts b/packages/oxlint-plugin/test/noRedundantResolve.test.ts
new file mode 100644
index 0000000..765d54d
--- /dev/null
+++ b/packages/oxlint-plugin/test/noRedundantResolve.test.ts
@@ -0,0 +1,59 @@
+import { RuleTester } from "oxlint/plugins-dev"
+import { describe, it } from "vitest"
+import plugin from "../src/index.js"
+
+RuleTester.describe = describe
+RuleTester.it = it
+
+const tester = new RuleTester({
+ languageOptions: { parserOptions: { lang: "ts" } }
+})
+
+const rule = plugin.rules["no-redundant-resolve"]
+
+tester.run("no-redundant-resolve", rule, {
+ valid: [
+ `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from({ id: "ready" })) })`,
+ `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from(), { reenter: true }) })`,
+ `import { Machine } from "@typeonce/effect-machine"
+const other = { resolve: (_callback: unknown) => undefined }
+other.resolve(({ target }) => target.from())`,
+ `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready() }).handle({ Ready: { invoke: (from) => from.effect("work", () => other.resolve(({ target }) => target.from())) } })`,
+ `const other = { resolve: (_callback: unknown) => undefined }
+other.resolve(({ target }) => target.from())`
+ ],
+ invalid: [
+ {
+ code: `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from()) })`,
+ output: `import { Machine } from "@typeonce/effect-machine"
+Machine.make({ initial: (to) => to.Ready() })`,
+ errors: [{ messageId: "redundantResolver" }]
+ },
+ {
+ code: `import { Machine } from "@typeonce/effect-machine"
+const definition = Machine.make({ initial: (to) => to.Ready() })
+definition.handle({ Ready: { on: { Reset: (to) => to.full.Ready().resolve(({ target }) => target.from()) } } })`,
+ output: `import { Machine } from "@typeonce/effect-machine"
+const definition = Machine.make({ initial: (to) => to.Ready() })
+definition.handle({ Ready: { on: { Reset: (to) => to.full.Ready() } } })`,
+ errors: [{ messageId: "redundantResolver" }]
+ },
+ {
+ code: `import { Machine as StateMachine } from "@typeonce/effect-machine"
+StateMachine.make({ initial: (to) => to.Ready().resolve(({ target: next }) => { return next.from() }) })`,
+ output: `import { Machine as StateMachine } from "@typeonce/effect-machine"
+StateMachine.make({ initial: (to) => to.Ready() })`,
+ errors: [{ messageId: "redundantResolver" }]
+ },
+ {
+ code: `import * as EM from "@typeonce/effect-machine"
+EM.Machine.make({ initial: (to) => to.Ready().resolve(({ target }) => /* preserve */ target.from()) })`,
+ output: null,
+ errors: [{ messageId: "redundantResolver" }]
+ }
+ ]
+})
diff --git a/packages/oxlint-plugin/test/preferInlineHandle.test.ts b/packages/oxlint-plugin/test/preferInlineHandle.test.ts
new file mode 100644
index 0000000..1210219
--- /dev/null
+++ b/packages/oxlint-plugin/test/preferInlineHandle.test.ts
@@ -0,0 +1,49 @@
+import { RuleTester } from "oxlint/plugins-dev"
+import { describe, it } from "vitest"
+import plugin from "../src/index.js"
+
+RuleTester.describe = describe
+RuleTester.it = it
+
+const tester = new RuleTester({
+ languageOptions: { parserOptions: { lang: "ts" } }
+})
+
+const rule = plugin.rules["prefer-inline-handle"]
+
+tester.run("prefer-inline-handle", rule, {
+ valid: [
+ `import { Machine } from "@typeonce/effect-machine"
+export const definition = Machine.make({})
+export const machine = definition.handle({})`,
+ `import { Machine } from "@typeonce/effect-machine"
+const definition = Machine.make({})
+export const first = definition.handle({})
+export const second = definition.handle({})`,
+ `import { Machine } from "@typeonce/effect-machine"
+export const machine = Machine.make({}).handle({})`,
+ `import { Machine } from "other-package"
+const definition = Machine.make({})
+export const machine = definition.handle({})`
+ ],
+ invalid: [
+ {
+ code: `import { Machine } from "@typeonce/effect-machine"
+const definition = Machine.make({})
+export const machine = definition.handle({})`,
+ errors: [{ messageId: "inlineHandle", line: 2, column: 6 }]
+ },
+ {
+ code: `import { Machine as StateMachine } from "@typeonce/effect-machine"
+const model = StateMachine.make({})
+model.handle({})`,
+ errors: [{ messageId: "inlineHandle", line: 2, column: 6 }]
+ },
+ {
+ code: `import * as EM from "@typeonce/effect-machine"
+const model = EM.Machine.make({})
+export default model.handle({})`,
+ errors: [{ messageId: "inlineHandle", line: 2, column: 6 }]
+ }
+ ]
+})
diff --git a/packages/oxlint-plugin/tsconfig.build.json b/packages/oxlint-plugin/tsconfig.build.json
new file mode 100644
index 0000000..0178f60
--- /dev/null
+++ b/packages/oxlint-plugin/tsconfig.build.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/oxlint-plugin/tsconfig.json b/packages/oxlint-plugin/tsconfig.json
new file mode 100644
index 0000000..552a74f
--- /dev/null
+++ b/packages/oxlint-plugin/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "../../tsconfig.base.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 41165f4..91a2ebb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -107,6 +107,22 @@ importers:
specifier: 4.1.10
version: 4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))
+ packages/oxlint-plugin:
+ dependencies:
+ '@oxlint/plugins':
+ specifier: 1.80.0
+ version: 1.80.0
+ devDependencies:
+ oxlint:
+ specifier: 1.80.0
+ version: 1.80.0
+ typescript:
+ specifier: 6.0.3
+ version: 6.0.3
+ vitest:
+ specifier: 4.1.10
+ version: 4.1.10(@types/node@25.7.0)(vite@8.1.5(@types/node@25.7.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.9.0))
+
packages:
'@babel/runtime@7.29.7':
@@ -513,6 +529,132 @@ packages:
'@oxc-project/types@0.139.0':
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
+ '@oxlint/binding-android-arm-eabi@1.80.0':
+ resolution: {integrity: sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
+
+ '@oxlint/binding-android-arm64@1.80.0':
+ resolution: {integrity: sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@oxlint/binding-darwin-arm64@1.80.0':
+ resolution: {integrity: sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@oxlint/binding-darwin-x64@1.80.0':
+ resolution: {integrity: sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@oxlint/binding-freebsd-x64@1.80.0':
+ resolution: {integrity: sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@oxlint/binding-linux-arm-gnueabihf@1.80.0':
+ resolution: {integrity: sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxlint/binding-linux-arm-musleabihf@1.80.0':
+ resolution: {integrity: sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@oxlint/binding-linux-arm64-gnu@1.80.0':
+ resolution: {integrity: sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxlint/binding-linux-arm64-musl@1.80.0':
+ resolution: {integrity: sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxlint/binding-linux-ppc64-gnu@1.80.0':
+ resolution: {integrity: sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxlint/binding-linux-riscv64-gnu@1.80.0':
+ resolution: {integrity: sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxlint/binding-linux-riscv64-musl@1.80.0':
+ resolution: {integrity: sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxlint/binding-linux-s390x-gnu@1.80.0':
+ resolution: {integrity: sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxlint/binding-linux-x64-gnu@1.80.0':
+ resolution: {integrity: sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@oxlint/binding-linux-x64-musl@1.80.0':
+ resolution: {integrity: sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@oxlint/binding-openharmony-arm64@1.80.0':
+ resolution: {integrity: sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@oxlint/binding-win32-arm64-msvc@1.80.0':
+ resolution: {integrity: sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@oxlint/binding-win32-ia32-msvc@1.80.0':
+ resolution: {integrity: sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@oxlint/binding-win32-x64-msvc@1.80.0':
+ resolution: {integrity: sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@oxlint/plugins@1.80.0':
+ resolution: {integrity: sha512-QRgH1XqQEYNHa4f1vvPQ5fAdNdncHGIUG1ZWLlGIZHky3qwCEeAKYitZNbZMtaXtAQAAFFTOwqUfzESvimqZNA==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
'@pagefind/darwin-arm64@1.5.2':
resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==}
cpu: [arm64]
@@ -1111,6 +1253,19 @@ packages:
outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
+ oxlint@1.80.0:
+ resolution: {integrity: sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ oxlint-tsgolint: '>=7.0.2001'
+ vite-plus: '*'
+ peerDependenciesMeta:
+ oxlint-tsgolint:
+ optional: true
+ vite-plus:
+ optional: true
+
p-filter@2.1.0:
resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
engines: {node: '>=8'}
@@ -1837,6 +1992,65 @@ snapshots:
'@oxc-project/types@0.139.0': {}
+ '@oxlint/binding-android-arm-eabi@1.80.0':
+ optional: true
+
+ '@oxlint/binding-android-arm64@1.80.0':
+ optional: true
+
+ '@oxlint/binding-darwin-arm64@1.80.0':
+ optional: true
+
+ '@oxlint/binding-darwin-x64@1.80.0':
+ optional: true
+
+ '@oxlint/binding-freebsd-x64@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-arm-gnueabihf@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-arm-musleabihf@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-arm64-gnu@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-arm64-musl@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-ppc64-gnu@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-riscv64-gnu@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-riscv64-musl@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-s390x-gnu@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-x64-gnu@1.80.0':
+ optional: true
+
+ '@oxlint/binding-linux-x64-musl@1.80.0':
+ optional: true
+
+ '@oxlint/binding-openharmony-arm64@1.80.0':
+ optional: true
+
+ '@oxlint/binding-win32-arm64-msvc@1.80.0':
+ optional: true
+
+ '@oxlint/binding-win32-ia32-msvc@1.80.0':
+ optional: true
+
+ '@oxlint/binding-win32-x64-msvc@1.80.0':
+ optional: true
+
+ '@oxlint/plugins@1.80.0': {}
+
'@pagefind/darwin-arm64@1.5.2':
optional: true
@@ -2367,6 +2581,28 @@ snapshots:
outdent@0.5.0: {}
+ oxlint@1.80.0:
+ optionalDependencies:
+ '@oxlint/binding-android-arm-eabi': 1.80.0
+ '@oxlint/binding-android-arm64': 1.80.0
+ '@oxlint/binding-darwin-arm64': 1.80.0
+ '@oxlint/binding-darwin-x64': 1.80.0
+ '@oxlint/binding-freebsd-x64': 1.80.0
+ '@oxlint/binding-linux-arm-gnueabihf': 1.80.0
+ '@oxlint/binding-linux-arm-musleabihf': 1.80.0
+ '@oxlint/binding-linux-arm64-gnu': 1.80.0
+ '@oxlint/binding-linux-arm64-musl': 1.80.0
+ '@oxlint/binding-linux-ppc64-gnu': 1.80.0
+ '@oxlint/binding-linux-riscv64-gnu': 1.80.0
+ '@oxlint/binding-linux-riscv64-musl': 1.80.0
+ '@oxlint/binding-linux-s390x-gnu': 1.80.0
+ '@oxlint/binding-linux-x64-gnu': 1.80.0
+ '@oxlint/binding-linux-x64-musl': 1.80.0
+ '@oxlint/binding-openharmony-arm64': 1.80.0
+ '@oxlint/binding-win32-arm64-msvc': 1.80.0
+ '@oxlint/binding-win32-ia32-msvc': 1.80.0
+ '@oxlint/binding-win32-x64-msvc': 1.80.0
+
p-filter@2.1.0:
dependencies:
p-map: 2.1.0
diff --git a/scripts/check-changeset.mjs b/scripts/check-changeset.mjs
index c1a84ff..2982113 100644
--- a/scripts/check-changeset.mjs
+++ b/scripts/check-changeset.mjs
@@ -37,9 +37,11 @@ const releaseFiles = changedFiles.filter(
path.startsWith("src/") ||
path.startsWith("packages/effect-machine/src/") ||
path.startsWith("packages/devtools/src/") ||
+ path.startsWith("packages/oxlint-plugin/src/") ||
path === "package.json" ||
path === "packages/effect-machine/package.json" ||
- path === "packages/devtools/package.json"
+ path === "packages/devtools/package.json" ||
+ path === "packages/oxlint-plugin/package.json"
)
if (releaseFiles.length === 0) {
diff --git a/scripts/oxlint-plugin-pack-check.mjs b/scripts/oxlint-plugin-pack-check.mjs
new file mode 100644
index 0000000..f5f8ed6
--- /dev/null
+++ b/scripts/oxlint-plugin-pack-check.mjs
@@ -0,0 +1,107 @@
+import { spawnSync } from "node:child_process"
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join, resolve } from "node:path"
+
+const repositoryRoot = resolve(import.meta.dirname, "..")
+const packageRoot = join(repositoryRoot, "packages", "oxlint-plugin")
+const destination = await mkdtemp(join(tmpdir(), "effect-machine-oxlint-pack-"))
+const consumer = join(destination, "consumer")
+const oxlint = join(consumer, "node_modules", ".bin", "oxlint")
+
+const run = (command, args, options = {}) => {
+ const result = spawnSync(command, args, {
+ encoding: "utf8",
+ ...options
+ })
+ if (result.status !== 0) {
+ throw new Error(
+ [`${command} ${args.join(" ")} failed`, result.stdout?.trim(), result.stderr?.trim()]
+ .filter(Boolean)
+ .join("\n")
+ )
+ }
+ return result
+}
+
+try {
+ const packed = run("pnpm", ["pack", "--pack-destination", destination], {
+ cwd: packageRoot
+ })
+ const archive = packed.stdout.trim().split("\n").at(-1)
+ if (archive === undefined) throw new Error("pnpm pack did not return an archive path")
+
+ const listing = run("tar", ["-tzf", archive]).stdout.trim().split("\n")
+ const required = [
+ "package/dist/index.js",
+ "package/dist/index.d.ts",
+ "package/dist/recommended.js",
+ "package/dist/recommended.d.ts",
+ "package/package.json",
+ "package/README.md",
+ "package/LICENSE",
+ "package/NOTICE"
+ ]
+ for (const file of required) {
+ if (!listing.includes(file)) throw new Error(`tarball is missing ${file}`)
+ }
+ const forbidden = listing.filter((file) => /^package\/(?:test|tsconfig)/.test(file))
+ if (forbidden.length > 0) {
+ throw new Error(`tarball contains repository-only files:\n${forbidden.join("\n")}`)
+ }
+
+ await mkdir(consumer)
+ await writeFile(
+ join(consumer, "package.json"),
+ JSON.stringify({
+ private: true,
+ type: "module",
+ devDependencies: {
+ "@typeonce/oxlint-plugin-effect-machine": `file:${archive}`,
+ oxlint: "1.80.0"
+ }
+ }, null, 2)
+ )
+ run("pnpm", ["install", "--prefer-offline", "--ignore-scripts"], { cwd: consumer })
+ await writeFile(
+ join(consumer, ".oxlintrc.json"),
+ JSON.stringify({
+ jsPlugins: ["@typeonce/oxlint-plugin-effect-machine"],
+ rules: { "effect-machine/no-redundant-resolve": "error" }
+ }, null, 2)
+ )
+
+ const fixture = join(consumer, "machine.ts")
+ await writeFile(
+ fixture,
+ `import { Machine } from "@typeonce/effect-machine"\nMachine.make({ initial: (to) => to.Ready().resolve(({ target }) => target.from()) })\n`
+ )
+
+ run(process.execPath, [
+ "--input-type=module",
+ "--eval",
+ `const plugin = (await import("@typeonce/oxlint-plugin-effect-machine")).default;
+ const { recommended } = await import("@typeonce/oxlint-plugin-effect-machine/recommended");
+ if (plugin.meta?.name !== "effect-machine" || Object.keys(plugin.rules).length !== 3) process.exit(1);
+ if (Object.keys(recommended).length !== 3) process.exit(1);`
+ ], { cwd: consumer })
+
+ const lint = spawnSync(oxlint, ["-c", ".oxlintrc.json", "machine.ts"], {
+ cwd: consumer,
+ encoding: "utf8"
+ })
+ const lintOutput = `${lint.stdout ?? ""}\n${lint.stderr ?? ""}`
+ if (lint.status === 0 || !lintOutput.includes("effect-machine(no-redundant-resolve)")) {
+ throw new Error(`packed plugin did not report no-redundant-resolve\n${lintOutput.trim()}`)
+ }
+
+ run(oxlint, ["-c", ".oxlintrc.json", "--fix", "machine.ts"], { cwd: consumer })
+ const fixed = await readFile(fixture, "utf8")
+ if (!fixed.includes("initial: (to) => to.Ready()") || fixed.includes(".resolve(")) {
+ throw new Error(`packed plugin did not apply its fixer\n${fixed}`)
+ }
+
+ console.log("packed Oxlint plugin import, configuration, diagnostic, and fixer passed")
+} finally {
+ await rm(destination, { recursive: true, force: true })
+}
diff --git a/scripts/release-contract.test.mjs b/scripts/release-contract.test.mjs
index 1d0ae7e..9decc69 100644
--- a/scripts/release-contract.test.mjs
+++ b/scripts/release-contract.test.mjs
@@ -7,21 +7,24 @@ const repositoryRoot = resolve(import.meta.dirname, "..")
const readJson = async (path) => JSON.parse(await readFile(resolve(repositoryRoot, path), "utf8"))
-test("core and devtools release with the same version", async () => {
- const [changesets, core, devtools] = await Promise.all([
+test("all Effect Machine packages release with the same version", async () => {
+ const [changesets, core, devtools, oxlintPlugin] = await Promise.all([
readJson(".changeset/config.json"),
readJson("packages/effect-machine/package.json"),
- readJson("packages/devtools/package.json")
+ readJson("packages/devtools/package.json"),
+ readJson("packages/oxlint-plugin/package.json")
])
assert.equal(devtools.version, core.version)
+ assert.equal(oxlintPlugin.version, core.version)
assert.equal(devtools.dependencies[core.name], "workspace:^")
assert.ok(
changesets.fixed.some((group) =>
- group.length === 2 &&
+ group.length === 3 &&
group.includes(core.name) &&
- group.includes(devtools.name)
+ group.includes(devtools.name) &&
+ group.includes(oxlintPlugin.name)
),
- "core and devtools must remain in the same Changesets fixed group"
+ "all Effect Machine packages must remain in the same Changesets fixed group"
)
})