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:
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:
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:
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¶
- FSM Config Reference
- Visualization — generate diagrams from state machine definitions
- API Reference — lint