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
3 changes: 3 additions & 0 deletions .changeset/repository-oxlint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---

---
93 changes: 93 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"jsPlugins": ["@typeonce/oxlint-plugin-effect-machine"],
"categories": {
"correctness": "error"
},
// Type-aware rules run through oxlint-tsgolint, with each file's nearest tsconfig.
"options": {
"typeAware": true
},
"rules": {
"typescript/no-unnecessary-type-assertion": "error",
"typescript/switch-exhaustiveness-check": ["error", { "considerDefaultExhaustiveForUnions": true }],
"typescript/no-floating-promises": "error",
"typescript/no-misused-promises": "error",
"eslint/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true }
],
// Effect.gen bodies capture the instance as `self`.
"typescript/no-this-alias": ["error", { "allowedNames": ["self"] }],
// Unions such as `unknown | StoppedError` and `string | "dynamic"` document the intended values.
"typescript/no-redundant-type-constituents": "off",
// Runtime hot paths preallocate arrays, as Effect does.
"unicorn/no-new-array": "off"
},
"overrides": [
{
// Public and internal types keep phantom type parameters for variance and inference. `noUnusedLocals` checks the rest.
"files": ["packages/*/src/**"],
"rules": {
"eslint/no-unused-vars": "off"
}
},
{
// Type tests, type-performance fixtures, and scripts belong to no tsconfig, so type information would use the wrong compiler options.
"files": ["packages/*/typetest/**", "packages/*/perf/**", "scripts/**"],
"rules": {
"typescript/await-thenable": "off",
"typescript/no-array-delete": "off",
"typescript/no-base-to-string": "off",
"typescript/no-duplicate-type-constituents": "off",
"typescript/no-floating-promises": "off",
"typescript/no-for-in-array": "off",
"typescript/no-implied-eval": "off",
"typescript/no-meaningless-void-operator": "off",
"typescript/no-misused-promises": "off",
"typescript/no-misused-spread": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unsafe-unary-minus": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/require-array-sort-compare": "off",
"typescript/restrict-template-expressions": "off",
"typescript/switch-exhaustiveness-check": "off",
"typescript/unbound-method": "off"
}
},
{
// Type tests bind values only to check their types, and type-performance fixtures stay byte-stable for comparisons.
"files": ["packages/*/typetest/**", "packages/*/perf/types/**"],
"rules": {
"eslint/no-unused-vars": "off"
}
},
{
// Dogfood the Effect Machine rules on the example machines and machine fixtures.
"files": [
"packages/devtools/src/internal/browser/example-machine.ts",
"packages/devtools/src/internal/browser/*-example.ts",
"packages/effect-machine/test/examples/**",
"packages/effect-machine/test/fixtures/**"
],
"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"
}
}
],
"ignorePatterns": [
"**/dist/**",
"**/node_modules/**",
".data/**",
".agents/**",
"references/**",
// Intentionally unparsable fixture for devtools error reporting.
"packages/devtools/test/fixtures/broken-machine.mjs"
]
}
22 changes: 22 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,28 @@ provide the in-process compiler API that tstyche uses. TypeScript 7 still
compiles the public types through the package projects and the runtime tests,
but type assertions are evaluated only with TypeScript 6.

## Formatting and linting

`pnpm format` formats the repository with dprint, as Effect does. `pnpm lint`
builds the local `@typeonce/oxlint-plugin-effect-machine` package and runs
Oxlint with the configuration in `.oxlintrc.json`. Both run as part of
`pnpm check`.

Type-aware rules run through `oxlint-tsgolint`, which reads each file's nearest
tsconfig. The root `tsconfig.json` references `tsconfig.tests.json` so tests get
the repository's compiler options. Type tests, type-performance fixtures, and
scripts belong to no tsconfig and skip type-aware rules.

The repository's own Effect Machine rules check the devtools example machines
and the machine fixtures under `packages/effect-machine/test`. Fix a finding
rather than suppressing it. When code must keep the reported pattern, for
example a cast that TypeScript 6 requires but the TypeScript 7 checker used by
`oxlint-tsgolint` does not, suppress only that line and give the reason:

```ts
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- TypeScript 6 infers `readonly string[]` without it.
```

## API reference data

`pnpm docs:api` generates an Effect-compatible TypeDoc dataset under
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"typecheck:ts7": "pnpm --dir tooling/typescript-7 run typecheck",
"perf:types": "pnpm build && node scripts/type-performance.mjs",
"perf:runtime": "pnpm build && node --expose-gc scripts/runtime-performance.mjs",
"lint": "pnpm --filter @typeonce/oxlint-plugin-effect-machine build && oxlint",
"format": "dprint fmt",
"format:check": "dprint check",
"docs:api": "node scripts/api-reference/generate.mjs",
Expand All @@ -29,17 +30,20 @@
"react:pack-check": "node scripts/react-pack-check.mjs",
"devtools:pack-check": "node scripts/devtools-pack-check.mjs",
"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 typecheck:ts7 && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm react:pack-check && pnpm devtools:pack-check && pnpm oxlint-plugin:pack-check",
"check": "pnpm format:check && pnpm lint && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm typecheck:ts7 && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check && pnpm react:pack-check && pnpm devtools:pack-check && pnpm oxlint-plugin:pack-check",
"changeset": "changeset",
"version-packages": "changeset version && dprint fmt",
"release": "pnpm build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "2.31.0",
"@effect/vitest": "4.1.1",
"@typeonce/oxlint-plugin-effect-machine": "workspace:*",
"@types/node": "26.6.6",
"dprint": "0.64.3",
"effect": "4.1.1",
"oxlint": "1.90.0",
"oxlint-tsgolint": "7.0.2005",
"pagefind": "1.5.2",
"tinybench": "2.9.0",
"tstyche": "7.2.5",
Expand Down
39 changes: 19 additions & 20 deletions packages/devtools/src/internal/browser/chart-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ const nodeMetric = (node: ChartNode, selfLoops: number): NodeMetric => {
}
}

// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- TypeScript 6 does not type the CommonJS default export as constructable.
const ELK = ELKBundle as unknown as new(args?: ELKConstructorArguments) => ElkApi
const elk = new ELK()

Expand Down Expand Up @@ -464,24 +465,22 @@ const makeGraph = (
return {
id: "chart-root",
children: children(null),
edges: [
...model.edges.map((edge): ElkExtendedEdge => {
const label = labelMetric(edge)
const edgeLayout = policy.edge(edge)
const runtimeTarget = edge.kind === "runtime" ? runtimeByEdgeId.get(edge.id) : undefined
return {
id: edge.id,
sources: [sourcePortId(edge)],
targets: [runtimeTarget === undefined ? targetPortId(edge) : runtimeTargetPortId(runtimeTarget)],
labels: [{ text: edge.accessibleLabel, width: label.width, height: label.height }],
layoutOptions: {
"elk.layered.priority.direction": edgeLayout.direction === "forward" ? "10" : "1",
"elk.layered.priority.shortness": "5",
"elk.layered.priority.straightness": "5"
}
edges: model.edges.map((edge): ElkExtendedEdge => {
const label = labelMetric(edge)
const edgeLayout = policy.edge(edge)
const runtimeTarget = edge.kind === "runtime" ? runtimeByEdgeId.get(edge.id) : undefined
return {
id: edge.id,
sources: [sourcePortId(edge)],
targets: [runtimeTarget === undefined ? targetPortId(edge) : runtimeTargetPortId(runtimeTarget)],
labels: [{ text: edge.accessibleLabel, width: label.width, height: label.height }],
layoutOptions: {
"elk.layered.priority.direction": edgeLayout.direction === "forward" ? "10" : "1",
"elk.layered.priority.shortness": "5",
"elk.layered.priority.straightness": "5"
}
})
],
}
}),
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
Expand Down Expand Up @@ -916,20 +915,20 @@ const normalizeEndpointDirection = (
const validEndpoints = (route: ReadonlyArray<ChartPoint>): boolean =>
isOutwardStep(route[0]!, route[1]!, side) && chartRouteLength(route.slice(0, 2)) >= 9 &&
(!preserveOpposite ||
isOutwardStep(route.at(-1)!, route.at(-2)!, oppositeSide!) && chartRouteLength(route.slice(-2)) >= 9)
isOutwardStep(route.at(-1)!, route.at(-2)!, oppositeSide) && chartRouteLength(route.slice(-2)) >= 9)

const tail = points.slice(1)
const attached = compactPoints([start, ...tail])
if (attached.length >= 2 && validEndpoints(attached)) return attached

if (tail.length === 1 && preserveOpposite) {
tail.unshift(outwardPoint(end, oppositeSide!, 18))
tail.unshift(outwardPoint(end, oppositeSide, 18))
}
const stub = outwardPoint(start, side, 18)
const endpointObstacles = [
...obstacles,
routeNodeRect(node),
...(preserveOpposite ? [routeNodeRect(opposite!)] : [])
...(preserveOpposite ? [routeNodeRect(opposite)] : [])
]
return endpointConnections(stub, tail[0]!, routeNodeRect(node), side)
.map((connection) => compactPoints([start, ...connection, ...tail.slice(1)]))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export const machine = Machine.make({
initial: {
target: "application.workflow.running.editing",
decoded: true,
data: ({}) => new Editing({})
data: () => new Editing({})
},
states: {
editing: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ class StreamValue extends Schema.TaggedClass<StreamValue>("InvokeGalleryStreamVa
const GalleryEvents = Machine.eventsFromSchemas(RunEffect, RunStream, RunTimer, RunProcess, RunChild, Reset)
const GalleryInternalEvents = Machine.internalEventsFromSchemas(StreamValue)
const processLogic = Machine.logic({
initial: "starting" as "starting" | "ready",
initial: "starting",
run: () => Effect.fail("process stopped")
})
const GalleryStates = Machine.state({
Expand Down Expand Up @@ -156,7 +156,7 @@ export const invokeOutcomesMachine = Machine.make({
initial: {
target: "Gallery.Choose",
decoded: true,
data: ({}) => new Choose({})
data: () => new Choose({})
},
on: {
Reset: { target: "Gallery.Choose", decoded: true, data: () => (new Choose({})) }
Expand Down Expand Up @@ -259,6 +259,7 @@ export const invokeOutcomesMachine = Machine.make({
resolve: ({ select, snapshot }) =>
snapshot.state === "ready"
? select.ready({ data: new Completed({ source: "process", result: "ready" }), decoded: true })
// oxlint-disable-next-line effect-machine/no-conditional-none-branch -- `Machine.can` does not query snapshot outcomes; the titled branch documents the wait.
: select.waiting()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export const parallelCompletionMachine = Machine.make({
}
},
Order: {
initial: { payment: ({}) => ({ attempts: 0 }), fulfillment: ({}) => ({ warehouse: "north" }) },
initial: { payment: () => ({ attempts: 0 }), fulfillment: () => ({ warehouse: "north" }) },
on: {
CancelOrder: {
target: "Cancelled",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const parallelOwnerRoutingMachine = Machine.make({
},
states: {
Print: {
initial: { Options: ({}) => ({}), Operation: ({}) => ({}) },
initial: { Options: () => ({}), Operation: () => ({}) },
invoke: { src: "source1" },
on: {
PrintRequested: {
Expand All @@ -118,7 +118,7 @@ export const parallelOwnerRoutingMachine = Machine.make({
Options: {
initial: {
target: "Print.Options.Ready",
data: ({}) => ({})
data: () => ({})
},
states: {
Ready: {}
Expand All @@ -127,7 +127,7 @@ export const parallelOwnerRoutingMachine = Machine.make({
Operation: {
initial: {
target: "Print.Operation.Active",
data: ({}) => ({})
data: () => ({})
},
states: {
Active: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const plannerMachine = Machine.make({
initial: {
target: "Idle",
decoded: true,
data: ({ root: { input: input } }) => new Idle({ owner: input.owner })
data: ({ root: { input } }) => new Idle({ owner: input.owner })
},
root: ({ input }) => ({ input }),
states: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export const transitionSemanticsMachine = Machine.make({
initial: {
target: "Workspace.Review.Checking",
decoded: true,
data: ({}) => new Checking({ checks: ["types", "tests"] })
data: () => new Checking({ checks: ["types", "tests"] })
},
onDone: {
target: "Workspace.Finished",
Expand Down
4 changes: 1 addition & 3 deletions packages/devtools/src/internal/machineDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ const enabled = Machine.enabled as (
snapshot: unknown
) => ReadonlyArray<PropertyKey>

const inputEventSchemas = Machine.inputEventSchemas as (
machine: Machine.Machine.Any
) => ReadonlyArray<Machine.Machine.TaggedSchema>
const inputEventSchemas = Machine.inputEventSchemas

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/src/internal/projectInspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ const evaluate = (
return runWorker(request).pipe(
Effect.flatMap((message) => {
if (message._tag !== "InspectedMachines") {
return Effect.fail(new Error(`Unexpected worker response: ${message._tag}`))
return Effect.fail(new Error(`Unexpected worker response: ${String(message._tag)}`))
}
return Effect.forEach(
message.results,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ describe("chart route endpoints", () => {
edge.kind === "transition" && edge.edge.source === "Failed" && edge.edge.target === "Working"
)
assert.isDefined(retry)
assert.isAtLeast(chartRouteLength(retry!.points.slice(0, 2)), 9)
assert.isAtLeast(chartRouteLength(retry!.points.slice(-2)), 9)
assert.isAtLeast(chartRouteLength(retry.points.slice(0, 2)), 9)
assert.isAtLeast(chartRouteLength(retry.points.slice(-2)), 9)
})

// Rotate the same geometry to exercise every side, then reverse the edge to
Expand Down
2 changes: 1 addition & 1 deletion packages/effect-machine-react/src/internal/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,5 @@ export const createMachineContext = <Args extends MachineContext.Args, A extends
if (machine === undefined) throw new Error("Machine context must be read inside its Provider")
return machine
}
return Object.freeze({ Provider, useMachine }) as MachineContext<Args, A>
return Object.freeze({ Provider, useMachine })
}
7 changes: 4 additions & 3 deletions packages/effect-machine-react/test/MachineAtom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe("useMachineAtom", () => {
await waitFor(() => assert.strictEqual(screen.getByTestId("value").textContent, "0"))
const initialOwnerRenders = ownerRenders
const initialReaderRenders = readerRenders
await act(() => {
await act(async () => {
registry.set(current!.send, new Increment({}))
})
await waitFor(() => assert.strictEqual(screen.getByTestId("value").textContent, "1"))
Expand Down Expand Up @@ -117,14 +117,14 @@ describe("useMachineAtom", () => {
await waitFor(() => assert.strictEqual(screen.getByTestId("reached").textContent, "false"))
assert.strictEqual(reachedThreshold(current!), reachedThreshold(current!))
const initialReaderRenders = readerRenders
await act(() => {
await act(async () => {
registry.set(current!.send, new Increment({}))
})
const active = registry.get(AtomMachine.select(current!, "Active"))
assert.ok(AsyncResult.isSuccess(active))
assert.strictEqual(Option.getOrThrow(active.value).value, 1)
assert.strictEqual(readerRenders, initialReaderRenders)
await act(() => {
await act(async () => {
registry.set(current!.send, new Increment({}))
})
await waitFor(() => assert.strictEqual(screen.getByTestId("reached").textContent, "true"))
Expand Down Expand Up @@ -327,6 +327,7 @@ describe("useMachineAtom", () => {
<Owner />
</RegistryContext.Provider>
)
// oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- TypeScript 6 types the queried element as `HTMLElement`.
const field = await waitFor(() => screen.getByTestId("field") as HTMLInputElement)
// React restores a controlled input to its rendered value when the
// handler leaves state unchanged, so each value must survive its event.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const withInput = Machine.make({
}).handle({
initial: {
target: "Editing",
data: ({}) => ({ draft: "" })
data: () => ({ draft: "" })
},
root: ({ input }) => ({ count: input }),
states: { Editing: {}, Closed: {} }
Expand Down
Loading
Loading