Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/calm-machines-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@typeonce/oxlint-plugin-effect-machine": minor
---

Add recommended rules that reject duplicate invocation identities, browser API access during planning, and nondeterministic time or randomness during planning.

Strengthen `no-async-planning-callback` to detect direct Promise, fetch, timer, and scheduling operations, and extend `no-redundant-resolve` fixes to resolver-only reentry and empty targetless resolvers. Diagnostics now explain how to move work into state-owned invocations, pass external facts through input or events, or model sequential work with separate states.
19 changes: 19 additions & 0 deletions packages/effect-machine/docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,25 @@ machine.
Do not start a promise inside a transition callback. A transition has no
lifetime in which to own that work. A state does.

Give every invocation declared by one state a unique lifecycle ID. Give every
logic or child process that can be active at the same time a unique runtime
address as well:

```ts
Loading: {
invoke: (from) => [
from.effect("load-document", loadDocument),
from.timer("load-timeout", "10 seconds")
]
}
```

Invocation outcomes are routed by state path and lifecycle ID, and overlapping
children cannot own the same runtime address. If work is sequential, represent
the sequence with separate states and transition from the first outcome. Do
not depend on one child completing quickly enough for another declaration to
reuse its identity.

### Choose state-owned or process-owned children

Use `from.child(...)` when the child belongs to one state and must stop when
Expand Down
8 changes: 8 additions & 0 deletions packages/effect-machine/docs/machine-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ 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.

Run the recommended rules from
[`@typeonce/oxlint-plugin-effect-machine`](../../oxlint-plugin/README.md) before
the manual review. They catch provable duplicate invocation identities,
asynchronous work, browser access, nondeterminism, and redundant resolvers in
direct same-module machine definitions. Continue with this review for
cross-module ownership and architectural questions that syntax alone cannot
answer.

## Review the responsibility boundaries

Use this split when deciding where code belongs:
Expand Down
160 changes: 151 additions & 9 deletions packages/oxlint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Effect Machine Oxlint plugin

`@typeonce/oxlint-plugin-effect-machine` checks Effect Machine definitions for
redundant resolvers, asynchronous planning, and one-use intermediate machine
definitions.
invalid invocation identities, impure planning, redundant resolvers, 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
Expand Down Expand Up @@ -40,16 +40,24 @@ 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-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. 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.
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 declaration
callbacks. A nested invocation source is state-owned work and is not treated
as planning.

## Rules

Expand All @@ -72,11 +80,145 @@ const handlers = {
The fixer does not run when the resolver has options, comments, construction
input, or any other work.

Resolver-only reentry uses `.reenter()`:

```ts
// Before
to.local.Ready().resolve(({ target }) => target.from(), { reenter: true })

// After `oxlint --fix`
to.local.Ready().reenter()
```

An empty `to.none.resolve(() => {})` is similarly reduced to `to.none`.

### `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.
Rejects `async` planning callbacks and direct Promise, fetch, timer, or
scheduling operations during planning. A machine plans synchronously and has
no lifetime in which to own work started by a transition.

```ts
// Incorrect: the transition starts unowned work.
const incorrect = {
Submit: (to) => {
fetch("/orders", { method: "POST" })
return to.full.Complete()
}
}

// Correct: the state owns and cancels the work.
const correct = {
Submitting: {
invoke: (from) =>
from.effect("submit-order", () => submitOrder())
.onDone((to) => to.full.Complete())
.onFailure((to) => to.full.Failed())
}
}
```

The rule only reports known unshadowed globals. Calls inside the source passed
to `from.effect`, `from.stream`, or another invocation builder remain valid.

### `effect-machine/no-conflicting-invocation-identity`

Requires every invocation declared by one state to have a unique lifecycle ID
and every concurrently owned logic or child process to have a unique runtime
address.

```ts
// Incorrect: both outcomes use the "load" lifecycle ID.
const incorrect = {
invoke: (from) => [
from.effect("load", loadAccount),
from.timer("load", "10 seconds")
]
}

// Correct
const correct = {
invoke: (from) => [
from.effect("load-account", loadAccount),
from.timer("load-timeout", "10 seconds")
]
}
```

Duplicate lifecycle IDs make outcome routing ambiguous. Duplicate active
addresses fail at runtime. If two operations must run sequentially, put them
in separate states and transition from the first invocation's outcome instead
of relying on completion timing.

The rule compares only identities it can prove equal, such as literals, the
same binding, static `Machine.childAddress(...)` values, and local
`Machine.child(...)` descriptors. It checks one state's invocation declaration
at a time and does not guess whether separate states can be active together.

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

Rejects direct access to stateful browser APIs such as `document`,
`localStorage`, `navigator`, `location`, workers, and browser event APIs during
planning.

```ts
// Incorrect: transition planning reads ambient storage.
const incorrect = {
Restore: (to) =>
localStorage.getItem("draft") === null
? to.full.Empty()
: to.full.Editing()
}

// Correct: state-owned work reads storage and reports an outcome.
const correct = {
Restoring: {
invoke: (from) =>
from.effect("restore-draft", () => restoreDraft())
.onDone((to) => to.full.Editing())
.onFailure((to) => to.full.Empty())
}
}
```

If browser work affects the workflow, move it into a state-owned invocation.
If it only focuses, measures, or renders UI, keep it in the UI adapter. Pure
data utilities such as `URL`, `URLSearchParams`, `TextEncoder`, and
`structuredClone` are not reported.

### `effect-machine/no-nondeterministic-planning`

Rejects direct reads of ambient time or randomness during planning, including
`Date.now()`, zero-argument `new Date()`, `Math.random()`, crypto randomness,
performance clocks, `Temporal.Now`, and process clocks.

```ts
// Incorrect: the same event and snapshot can select different results.
const incorrect = {
Check: (to) =>
Date.now() >= deadline
? to.full.Expired()
: to.none
}

// Correct: receive the external fact as part of the event protocol.
const correct = {
Check: (to) =>
to.branches({
expired: { target: to.full.Expired() },
current: { target: to.full.Current() }
}).resolve(({ event, select }) =>
event.now >= event.deadline
? select.expired.from()
: select.current.from()
)
}
```

Pass the value through machine input or an event when it is already known. If
the machine must obtain it, produce it in a state-owned invocation and
transition from the invocation outcome. Deterministic operations such as
`new Date(event.timestamp)` and `Date.parse(state.createdAt)` remain valid.

### `effect-machine/prefer-inline-handle`

Expand Down
6 changes: 6 additions & 0 deletions packages/oxlint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { Plugin } from "@oxlint/plugins"
import { noAsyncPlanningCallback } from "./internal/rules/noAsyncPlanningCallback.js"
import { noBrowserApiInPlanning } from "./internal/rules/noBrowserApiInPlanning.js"
import { noConflictingInvocationIdentity } from "./internal/rules/noConflictingInvocationIdentity.js"
import { noNondeterministicPlanning } from "./internal/rules/noNondeterministicPlanning.js"
import { noRedundantResolve } from "./internal/rules/noRedundantResolve.js"
import { preferInlineHandle } from "./internal/rules/preferInlineHandle.js"

Expand All @@ -9,6 +12,9 @@ const plugin = {
},
rules: {
"no-async-planning-callback": noAsyncPlanningCallback,
"no-browser-api-in-planning": noBrowserApiInPlanning,
"no-conflicting-invocation-identity": noConflictingInvocationIdentity,
"no-nondeterministic-planning": noNondeterministicPlanning,
"no-redundant-resolve": noRedundantResolve,
"prefer-inline-handle": preferInlineHandle
}
Expand Down
134 changes: 134 additions & 0 deletions packages/oxlint-plugin/src/internal/ambient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { Context, ESTree } from "@oxlint/plugins"
import { canonicalGlobalPath } from "./ast.js"

const scheduledFunctions = new Set([
"fetch",
"queueMicrotask",
"requestAnimationFrame",
"requestIdleCallback",
"setImmediate",
"setInterval",
"setTimeout"
])

const promiseMethods = new Set([
"all",
"allSettled",
"any",
"race",
"reject",
"resolve",
"try"
])

export const asyncOperation = (
context: Context,
node: ESTree.CallExpression | ESTree.NewExpression
): string | undefined => {
const path = canonicalGlobalPath(context, node.callee)
if (path === undefined) return undefined
if (node.type === "NewExpression") {
return path.length === 1 && path[0] === "Promise" ? "new Promise(...)" : undefined
}
if (path.length === 1 && scheduledFunctions.has(path[0]!)) return `${path[0]}(...)`
if (path.length === 2 && path[0] === "Promise" && promiseMethods.has(path[1]!)) {
return `Promise.${path[1]}(...)`
}
return path.length === 2 && path[0] === "process" && path[1] === "nextTick"
? "process.nextTick(...)"
: undefined
}

export const nondeterministicOperation = (
context: Context,
node: ESTree.CallExpression | ESTree.NewExpression
): string | undefined => {
const path = canonicalGlobalPath(context, node.callee)
if (path === undefined) return undefined
if (node.type === "NewExpression") {
return path.length === 1 && path[0] === "Date" && node.arguments.length === 0
? "new Date()"
: undefined
}
if (path.length === 1 && path[0] === "Date") return "Date()"
if (path.length === 2 && path[0] === "Date" && path[1] === "now") return "Date.now()"
if (path.length === 2 && path[0] === "Math" && path[1] === "random") return "Math.random()"
if (
path.length === 2 &&
path[0] === "crypto" &&
(path[1] === "getRandomValues" || path[1] === "randomUUID")
) return `crypto.${path[1]}(...)`
if (path.length === 2 && path[0] === "performance" && path[1] === "now") {
return "performance.now()"
}
if (path.length >= 3 && path[0] === "Temporal" && path[1] === "Now") {
return `${path.join(".")}()`
}
if (
path[0] === "process" &&
(path.join(".") === "process.hrtime" ||
path.join(".") === "process.hrtime.bigint" ||
path.join(".") === "process.uptime")
) return `${path.join(".")}()`
return undefined
}

export const nondeterministicProperty = (
context: Context,
node: ESTree.MemberExpression
): string | undefined => {
const path = canonicalGlobalPath(context, node)
return path?.length === 2 && path[0] === "performance" && path[1] === "timeOrigin"
? "performance.timeOrigin"
: undefined
}

const browserRoots = new Set([
"caches",
"cookieStore",
"document",
"history",
"indexedDB",
"localStorage",
"location",
"navigator",
"screen",
"self",
"sessionStorage",
"visualViewport",
"window"
])

const browserFunctions = new Set([
"addEventListener",
"alert",
"confirm",
"dispatchEvent",
"getComputedStyle",
"matchMedia",
"open",
"postMessage",
"prompt",
"removeEventListener"
])

const browserConstructors = new Set([
"BroadcastChannel",
"EventSource",
"SharedWorker",
"WebSocket",
"Worker",
"XMLHttpRequest"
])

export const browserApi = (
context: Context,
node: ESTree.Expression
): string | undefined => {
const path = canonicalGlobalPath(context, node)
if (path === undefined || path.length === 0) return undefined
if (browserRoots.has(path[0]!) || browserFunctions.has(path[0]!) || browserConstructors.has(path[0]!)) {
return path.join(".")
}
return undefined
}
Loading