Skip to content

Linting State Machines

pystator.lint performs static analysis on a loaded StateMachine and reports potential configuration problems — without processing any events. Run it in CI, during development, or as a post-load validation step to catch mistakes early.

Quick start

from pystator import StateMachine
from pystator.lint import lint, LintSeverity

machine = StateMachine.from_yaml("order_fsm.yaml")
warnings = lint(machine)

if warnings:
    for w in warnings:
        print(f"[{w.severity.value.upper()}] {w.code}: {w.message}")
else:
    print("No lint issues found")

Lint checks

unreachable_state · WARNING

A state exists in the config but cannot be reached from the initial state through any sequence of transitions.

Why it matters: Unreachable states are dead code. They bloat the config, confuse readers, and may indicate a missing transition.

Example output:

[WARNING] unreachable_state: State 'SUSPENDED' is not reachable from the initial state


dead_end · WARNING

A non-terminal state has no outbound transitions — entities that enter it are stuck forever.

Why it matters: Every non-terminal state must have at least one way out. A dead-end state is almost always a bug in the YAML.

Example output:

[WARNING] dead_end: Non-terminal state 'REVIEW' has no outbound transitions


dead_transition · WARNING

A transition can never fire because another transition from the same source with the same trigger always takes precedence (e.g., an unconditional guard comes before a guarded one).

Why it matters: The order of transitions matters. A transition that can never be selected wastes config and may hide logic errors.


nondeterministic · WARNING

Multiple transitions from the same source state share the same trigger with no guards to differentiate them, making the outcome unpredictable.

Why it matters: The FSM engine picks the first matching transition. If two transitions are identical, behaviour depends on definition order — that is almost always unintentional.

Example output:

[WARNING] nondeterministic: State 'pending' has multiple unguarded transitions for trigger 'submit'


missing_guard · WARNING

A transition references a guard by name, but that guard is not registered in the provided registry.

Why it matters: At runtime, unregistered guards default to True (pass-through) in the API server, but may raise in library mode. Catching this at load time is safer.


missing_action · WARNING

A transition references an action by name, but that action is not registered in the provided registry.

Why it matters: Unregistered actions are silently skipped at runtime by default. Lint surfaces this before it causes silent data loss.


Severity levels

Level Enum value Meaning
ERROR LintSeverity.ERROR Definite bug — fix before deploying
WARNING LintSeverity.WARNING Likely problem — investigate
INFO LintSeverity.INFO Informational suggestion

All built-in checks currently emit WARNING. The severity levels exist so custom checks can use ERROR for blocking issues.


Filtering by severity

from pystator.lint import lint, LintSeverity

warnings = lint(machine)

errors = [w for w in warnings if w.severity == LintSeverity.ERROR]
warnings_only = [w for w in warnings if w.severity == LintSeverity.WARNING]

if errors:
    raise SystemExit(f"{len(errors)} lint errors — fix before deploying")

LintWarning fields

@dataclass(frozen=True, slots=True)
class LintWarning:
    code: str              # Machine-readable: "unreachable_state", "dead_end", etc.
    severity: LintSeverity # ERROR, WARNING, or INFO
    message: str           # Human-readable description
    context: dict          # Extra data (e.g. {"state": "SUSPENDED"}, {"trigger": "submit"})

Access structured data from context for programmatic handling:

for w in lint(machine):
    if w.code == "unreachable_state":
        print(f"Unreachable: {w.context['state']}")
    elif w.code == "missing_guard":
        print(f"Missing guard: {w.context.get('guard')} on {w.context.get('transition')}")

CI integration

pytest

# tests/test_machine_lint.py
import pytest
from pystator import StateMachine
from pystator.lint import lint, LintSeverity

def test_no_lint_errors():
    machine = StateMachine.from_yaml("machines/order_fsm.yaml")
    findings = lint(machine)
    errors = [w for w in findings if w.severity == LintSeverity.ERROR]
    assert not errors, "\n".join(f"{w.code}: {w.message}" for w in errors)

def test_no_lint_warnings():
    machine = StateMachine.from_yaml("machines/order_fsm.yaml")
    findings = lint(machine)
    assert not findings, "\n".join(f"[{w.severity}] {w.code}: {w.message}" for w in findings)

Pre-commit hook

# scripts/lint_machines.py
import sys
from pathlib import Path
from pystator import StateMachine
from pystator.lint import lint, LintSeverity

exit_code = 0
for path in Path("machines").glob("*.yaml"):
    machine = StateMachine.from_yaml(str(path))
    warnings = lint(machine)
    for w in warnings:
        prefix = "ERROR" if w.severity == LintSeverity.ERROR else "WARN"
        print(f"{path}: [{prefix}] {w.code}: {w.message}")
        if w.severity == LintSeverity.ERROR:
            exit_code = 1

sys.exit(exit_code)
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: pystator-lint
        name: pystator lint
        entry: python scripts/lint_machines.py
        language: python
        pass_filenames: false

Writing custom checks

lint() runs all built-in checks and returns their combined results. You can run your own checks alongside it:

from pystator.lint import LintWarning, LintSeverity

def check_state_name_convention(machine) -> list[LintWarning]:
    """All state names must be UPPER_SNAKE_CASE."""
    return [
        LintWarning(
            code="naming_convention",
            severity=LintSeverity.WARNING,
            message=f"State '{name}' should be UPPER_SNAKE_CASE",
            context={"state": name},
        )
        for name in machine.state_names
        if name != name.upper()
    ]

warnings = lint(machine) + check_state_name_convention(machine)

See also