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
17 changes: 17 additions & 0 deletions .changeset/machine-test-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@typeonce/effect-machine": minor
"@typeonce/effect-machine-devtools": minor
---

Add `MachineTest.check`, `MachineTest.analyze`, and the `effect-machine check` command, so a machine can be verified with one call and no browser.

`check` reports states with no path from the initial state, public events with no handler, nondeterministic planning, planner law violations, invariant failures, and declared branches that were never taken. It derives input and events from the machine's schemas, and each finding from a run includes the events that reproduce it. A report is `"exhaustive"` only when nothing limited exploration; otherwise `limits` says why it is `"bounded"`.

```ts
const program = Effect.gen(function*() {
const report = yield* MachineTest.check(Checkout)
return report.ok // false when any finding is an error
})
```

From a shell, `effect-machine check src/checkout.ts --json` prints one JSON object and exits with status 1 on any error.
40 changes: 38 additions & 2 deletions LLMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,7 @@ address as well:
```ts
// Register timers: { loadTimeout: "10 seconds" } in make.
Loading: {
invoke: ;
;[
invoke: [
{
src: "loadDocument",
input: ({ state }) => state.documentId,
Expand Down Expand Up @@ -503,6 +502,43 @@ owner input. Every branch the choice can reach, including through nested
choices, must stay below the owner; otherwise the definition does not compile
and `handle` throws while naming the choice, branch, and escaping target.

### Verify a machine after changing it

Run this after writing or changing a machine, before writing a specific test:

```ts
import { MachineTest } from "@typeonce/effect-machine/testing"
import { Effect } from "effect"

const verifyCounter = Effect.gen(function*() {
const report = yield* MachineTest.check(CounterMachine)
if (!report.ok) yield* Effect.die(MachineTest.formatCheckReport(report))
})
```

From a shell, `effect-machine check src/counter.ts --json` from
`@typeonce/effect-machine-devtools` does the same for every machine a file exports.

To fix a finding, keep the failing evidence first. Replay its events to reproduce
it, make the smallest behavior change, then run the check again. Do not silence a
finding by weakening a type or a test.

| Law | Meaning | Fix |
| ---------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------- |
| `topology.noPathFromInitial` | No declared transition can enter the state. | Add the missing transition, or remove the state. |
| `events.noHandler` | No state handles a public event. | Handle it, or remove it from the protocol. |
| `planning.deterministic` | The same snapshot and event planned two different results. | Move the clock, randomness, or external read into an event or work. |
| `planning.failed` | A planning callback threw or a value failed its schema. | Replay the events, then fix the callback or the value. |
| `planning.verification` | A planner law failed. This usually means an Effect Machine bug. | Report it with the replay. |
| `invariant.violated` | An invariant you supplied failed. | Replay the events, then fix the behavior or the invariant. |
| `transition.notCovered` | Exploration never took a declared branch. This is a warning. | Cover it with a scenario, or remove it if it cannot happen. |
| `schema.underivable` | Input or events could not be sampled from their schemas. | Pass `input` or `events` explicitly. |

A report is `"exhaustive"` only when `limits` is empty. Otherwise a pass means
nothing was found in what was explored. Keep sampled checks, bounded exploration,
and proof distinct when reporting a result. Invocations do not run during planning,
so test them on a started machine with `MachineTest.probe`.

### Test paths and invariants

Test the statechart as a graph. Send domain events, inspect reached states, and
Expand Down
2 changes: 1 addition & 1 deletion ai-docs/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AI docs

`LLMS.md` is generated from `ai-docs/src`. Each build copies it into the core package as `AGENTS.md` and `CLAUDE.md`, next to a copy of `ai-docs`, so an installed package carries documentation matching its version.
`LLMS.md` is generated from `ai-docs/src`. Packing the core package copies it in as `AGENTS.md` and `CLAUDE.md`, next to a copy of `ai-docs`, so an installed package carries documentation matching its version. The copies are removed after packing.

## Add content

Expand Down
40 changes: 38 additions & 2 deletions ai-docs/src/10_modeling/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,7 @@ address as well:
```ts
// Register timers: { loadTimeout: "10 seconds" } in make.
Loading: {
invoke: ;
;[
invoke: [
{
src: "loadDocument",
input: ({ state }) => state.documentId,
Expand Down Expand Up @@ -495,6 +494,43 @@ owner input. Every branch the choice can reach, including through nested
choices, must stay below the owner; otherwise the definition does not compile
and `handle` throws while naming the choice, branch, and escaping target.

### Verify a machine after changing it

Run this after writing or changing a machine, before writing a specific test:

```ts
import { MachineTest } from "@typeonce/effect-machine/testing"
import { Effect } from "effect"

const verifyCounter = Effect.gen(function*() {
const report = yield* MachineTest.check(CounterMachine)
if (!report.ok) yield* Effect.die(MachineTest.formatCheckReport(report))
})
```

From a shell, `effect-machine check src/counter.ts --json` from
`@typeonce/effect-machine-devtools` does the same for every machine a file exports.

To fix a finding, keep the failing evidence first. Replay its events to reproduce
it, make the smallest behavior change, then run the check again. Do not silence a
finding by weakening a type or a test.

| Law | Meaning | Fix |
| ---------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------- |
| `topology.noPathFromInitial` | No declared transition can enter the state. | Add the missing transition, or remove the state. |
| `events.noHandler` | No state handles a public event. | Handle it, or remove it from the protocol. |
| `planning.deterministic` | The same snapshot and event planned two different results. | Move the clock, randomness, or external read into an event or work. |
| `planning.failed` | A planning callback threw or a value failed its schema. | Replay the events, then fix the callback or the value. |
| `planning.verification` | A planner law failed. This usually means an Effect Machine bug. | Report it with the replay. |
| `invariant.violated` | An invariant you supplied failed. | Replay the events, then fix the behavior or the invariant. |
| `transition.notCovered` | Exploration never took a declared branch. This is a warning. | Cover it with a scenario, or remove it if it cannot happen. |
| `schema.underivable` | Input or events could not be sampled from their schemas. | Pass `input` or `events` explicitly. |

A report is `"exhaustive"` only when `limits` is empty. Otherwise a pass means
nothing was found in what was explored. Keep sampled checks, bounded exploration,
and proof distinct when reporting a result. Invocations do not run during planning,
so test them on a started machine with `MachineTest.probe`.

### Test paths and invariants

Test the statechart as a graph. Send domain events, inspect reached states, and
Expand Down
1 change: 1 addition & 0 deletions dprint.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"**/dist",
"**/build",
"**/docs",
"ai-docs/src/**/*.md",
"**/coverage",
"packages/**/CHANGELOG.md",
"!scratchpad/**/*",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"type": "module",
"packageManager": "pnpm@10.17.1",
"scripts": {
"build": "pnpm --recursive --filter \"./packages/**\" run build && node scripts/ai-docs/copy.mjs",
"build": "pnpm --recursive --filter \"./packages/**\" run build",
"test": "node --expose-gc ./node_modules/vitest/vitest.mjs run",
"doctest": "vitest run --config vitest.docs.ts",
"doctest:types": "node scripts/doc-examples/check.mjs",
Expand Down
11 changes: 11 additions & 0 deletions packages/devtools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ The command writes `index.html`, `machines.json`, `manifest.json`, and content-h

Static generation evaluates candidate project modules once in the same isolated worker used by the live visualizer. It fails when no machines are found or any candidate cannot produce a valid document. The command replaces only an empty directory or a directory previously generated by Effect Machine, so an unrelated non-empty destination is never removed.

## Check machines

Analyze, explore, and verify the project's machines without a browser:

```sh
pnpm exec effect-machine check
pnpm exec effect-machine check src/checkout.ts --json
```

The optional path is a file or glob relative to `--root`. The command runs `MachineTest.check` on every exported machine in the same isolated worker as the visualizer, and exits with status 1 when a report has an error, a module fails to load, or nothing matches. `--json` prints one object: `{ schemaVersion, ok, machines }`. The command cannot supply Effect services, so a machine whose planning callbacks require them reports a planning failure; call `MachineTest.check` from a test with those services instead.

## Live results

The browser reports one of these statuses for every candidate:
Expand Down
35 changes: 35 additions & 0 deletions packages/devtools/src/DevToolsProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,41 @@ export const MachineResult = Schema.Union([Ready, Partial, Failed])
*/
export type MachineResult = Schema.Schema.Type<typeof MachineResult>

/**
* The outcome of checking one exported machine. `report` is the JSON form of a
* `MachineTest.CheckReport`.
*
* @category schemas
* @since 0.47.0
*/
export const Checked = Schema.Struct({
_tag: Schema.tag("Checked"),
key: Schema.String,
source: MachineDocument.Source,
report: Schema.Json
})

/**
* @category models
* @since 0.47.0
*/
export type Checked = Schema.Schema.Type<typeof Checked>

/**
* One result of checking a project: a report, or a module or machine that could
* not be loaded.
*
* @category schemas
* @since 0.47.0
*/
export const CheckResult = Schema.Union([Checked, Failed])

/**
* @category models
* @since 0.47.0
*/
export type CheckResult = Schema.Schema.Type<typeof CheckResult>

/**
* A complete live-registry update sent to browser clients.
*
Expand Down
16 changes: 16 additions & 0 deletions packages/devtools/src/ProjectInspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export class ProjectInspector extends Context.Service<ProjectInspector, {
readonly inspect: (
options: InspectOptions
) => Effect.Effect<ReadonlyArray<DevToolsProtocol.MachineResult>, DiscoveryError | EvaluationError>
readonly check: (
options: InspectOptions
) => Effect.Effect<ReadonlyArray<DevToolsProtocol.CheckResult>, DiscoveryError | EvaluationError>
}>()("@typeonce/effect-machine-devtools/ProjectInspector") {}

/**
Expand Down Expand Up @@ -130,6 +133,19 @@ export const inspect = (options: InspectOptions): Effect.Effect<
ProjectInspector
> => Effect.flatMap(ProjectInspector, (inspector) => inspector.inspect(options))

/**
* Runs `MachineTest.check` on every machine exported by the project, in the
* isolated evaluator.
*
* @category combinators
* @since 0.47.0
*/
export const check = (options: InspectOptions): Effect.Effect<
ReadonlyArray<DevToolsProtocol.CheckResult>,
DiscoveryError | EvaluationError,
ProjectInspector
> => Effect.flatMap(ProjectInspector, (inspector) => inspector.check(options))

/**
* Node-backed inspector layer. Each inspection evaluates candidates inside a
* fresh worker so a broken project module cannot corrupt the long-lived host.
Expand Down
27 changes: 26 additions & 1 deletion packages/devtools/src/bin.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
#!/usr/bin/env node
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices"
import * as Argument from "effect/cli/Argument"
import * as Command from "effect/cli/Command"
import * as Flag from "effect/cli/Flag"
import * as Console from "effect/Console"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import PackageJson from "../package.json" with { type: "json" }
import * as DevServer from "./DevServer.js"
import * as CheckCommand from "./internal/checkCommand.js"
import * as StaticSite from "./internal/staticSite.js"
import * as MachineRegistry from "./MachineRegistry.js"
import * as ProjectInspector from "./ProjectInspector.js"
Expand Down Expand Up @@ -78,7 +81,29 @@ const build = Command.make("build", { outputDirectory }).pipe(
}))
)

const cli = dev.pipe(Command.withSubcommands([build]))
const target = Argument.String("path").pipe(
Argument.withDescription("Machine source file or glob relative to the project root"),
Argument.optional
)

const json = Flag.Boolean("json").pipe(
Flag.withDescription("Print one JSON object instead of text"),
Flag.withDefault(false)
)

const check = Command.make("check", { target, json }).pipe(
Command.withDescription("Analyze, explore, and verify the project's machines, and exit non-zero on any error"),
Command.withHandler(Effect.fnUntraced(function*({ json, target }) {
const { include, root } = yield* base
const results = yield* ProjectInspector.check({ root, include: Option.getOrElse(target, () => include) }).pipe(
Effect.provide(ProjectInspector.layer)
)
yield* Console.log(json ? CheckCommand.renderJson(results) : CheckCommand.renderText(results))
if (!CheckCommand.isOk(results)) yield* Effect.sync(() => (process.exitCode = 1))
}))
)

const cli = dev.pipe(Command.withSubcommands([build, check]))

Command.run(cli, { version: PackageJson.version }).pipe(
Effect.provide(NodeServices.layer),
Expand Down
38 changes: 38 additions & 0 deletions packages/devtools/src/internal/checkCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type * as DevToolsProtocol from "../DevToolsProtocol.js"

const reportOf = (result: DevToolsProtocol.Checked): {
readonly ok: boolean
readonly text: string
} => result.report as never

const failureText = (result: DevToolsProtocol.Failed): string =>
[
`FAIL ${result.key}`,
...result.diagnostics.map((diagnostic) => ` error ${diagnostic.code}: ${diagnostic.message}`)
].join("\n")

/** A project with nothing to check fails too, so a wrong path is not reported as a pass. */
export const isOk = (results: ReadonlyArray<DevToolsProtocol.CheckResult>): boolean =>
results.length > 0 && results.every((result) => result._tag === "Checked" && reportOf(result).ok)

export const renderJson = (results: ReadonlyArray<DevToolsProtocol.CheckResult>): string =>
JSON.stringify(
{
schemaVersion: 1,
ok: isOk(results),
machines: results.map((result) => {
if (result._tag !== "Checked") {
return { key: result.key, source: result.source, ok: false, diagnostics: result.diagnostics }
}
const { text: _text, ...report } = result.report as Record<string, unknown>
return { key: result.key, source: result.source, ...report }
})
},
null,
2
)

export const renderText = (results: ReadonlyArray<DevToolsProtocol.CheckResult>): string =>
results.length === 0
? "No Effect Machine definitions found"
: results.map((result) => result._tag === "Checked" ? reportOf(result).text : failureText(result)).join("\n")
Loading
Loading