levain-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- levain_cli-0.1.0.dist-info/METADATA +77 -0
- levain_cli-0.1.0.dist-info/RECORD +26 -0
- levain_cli-0.1.0.dist-info/WHEEL +4 -0
- levain_cli-0.1.0.dist-info/entry_points.txt +2 -0
- levain_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- levain_recipe/__init__.py +20 -0
- levain_recipe/__main__.py +6 -0
- levain_recipe/_format.py +45 -0
- levain_recipe/_java.py +95 -0
- levain_recipe/auth.py +322 -0
- levain_recipe/cli.py +344 -0
- levain_recipe/codegen.py +114 -0
- levain_recipe/graph.py +813 -0
- levain_recipe/integrations_check.py +218 -0
- levain_recipe/known_recipes.py +21 -0
- levain_recipe/mcp.py +85 -0
- levain_recipe/models.py +146 -0
- levain_recipe/parser.py +299 -0
- levain_recipe/profile.py +389 -0
- levain_recipe/py.typed +0 -0
- levain_recipe/reducers.py +66 -0
- levain_recipe/source_check.py +279 -0
- levain_recipe/tlc.py +236 -0
- levain_recipe/translate.py +142 -0
- levain_recipe/user_input.py +334 -0
- levain_recipe/validate.py +554 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Validate ``@mcp_servers`` names resolve to mapped tools.
|
|
2
|
+
|
|
3
|
+
Run after the profile + graph stages produce the annotation
|
|
4
|
+
list. For each node's ``@mcp_servers`` declaration, every name
|
|
5
|
+
must resolve to either:
|
|
6
|
+
|
|
7
|
+
- A platform-provided MCP server (today: ``knead-review``), or
|
|
8
|
+
- A customer-attached integration mapped to the target agent —
|
|
9
|
+
surfaced via ``LEVAIN_TARGET_INTEGRATIONS_B64`` and decoded by
|
|
10
|
+
:mod:`levain_sdk.integrations`. Only MCP-callable kinds
|
|
11
|
+
count (see ``_MCP_CALLABLE_KINDS``).
|
|
12
|
+
|
|
13
|
+
The check is **deterministic over its inputs**: it consumes the
|
|
14
|
+
annotation list (from the spec source) and the target-integration
|
|
15
|
+
list (from an explicit parameter or the env var). When neither
|
|
16
|
+
the parameter nor the env var supplies any context, the check is
|
|
17
|
+
skipped — a clean checkout / manual CLI invocation has no source
|
|
18
|
+
of truth to validate against, and rejecting valid recipes for
|
|
19
|
+
lack of context would be worse than letting runtime resolution
|
|
20
|
+
catch the error.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .models import NodeAnnotation, ValidationError
|
|
29
|
+
|
|
30
|
+
# Structural stand-in for ``levain_sdk.integrations.TargetIntegration``
|
|
31
|
+
# (a TypedDict, so ``.get()`` is all we need). Typed locally to keep
|
|
32
|
+
# the agent runtime out of this package's dependencies.
|
|
33
|
+
TargetIntegration = Mapping[str, Any]
|
|
34
|
+
|
|
35
|
+
# Platform-provided MCP servers, mirroring
|
|
36
|
+
# ``levain_sdk.mcp_registry._PLATFORM_SERVERS``. Vendored for the
|
|
37
|
+
# same reason as ``known_recipes.CALLABLE_RECIPES``: this package
|
|
38
|
+
# validates recipes without installing the runtime. Includes the
|
|
39
|
+
# platform-agent-only servers (``levain-build``, ``levain-issues``),
|
|
40
|
+
# which the runtime registers by side-effect import — so the live
|
|
41
|
+
# set differs by process, and this copy is the union. A drift test
|
|
42
|
+
# in Levain's CI keeps the two equal; a stale copy only affects
|
|
43
|
+
# local lint, since the platform re-validates every push.
|
|
44
|
+
PLATFORM_MCP_SERVERS = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"google_ads",
|
|
47
|
+
"knead-review",
|
|
48
|
+
"levain-agents",
|
|
49
|
+
"levain-app-data",
|
|
50
|
+
"levain-artifacts",
|
|
51
|
+
"levain-build",
|
|
52
|
+
"levain-image",
|
|
53
|
+
"levain-issues",
|
|
54
|
+
"levain-memory",
|
|
55
|
+
"levain-progress",
|
|
56
|
+
"levain-sessions",
|
|
57
|
+
"levain-wiki",
|
|
58
|
+
"meta_ads",
|
|
59
|
+
"shopify",
|
|
60
|
+
"slack",
|
|
61
|
+
"stripe",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def platform_server_names() -> set[str]:
|
|
67
|
+
"""Names an ``@mcp_servers`` entry can bind to platform-side."""
|
|
68
|
+
return set(PLATFORM_MCP_SERVERS)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_env_targets() -> list[TargetIntegration]:
|
|
72
|
+
"""Attached integrations from the sandbox env, when available.
|
|
73
|
+
|
|
74
|
+
Only resolvable inside a sandbox, where the runtime is installed
|
|
75
|
+
and the harness has injected the env var. Everywhere else this
|
|
76
|
+
is empty and the caller skips the check.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
from levain_sdk.integrations import load_target
|
|
80
|
+
except ImportError:
|
|
81
|
+
return []
|
|
82
|
+
return list(load_target())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Integration kinds the runtime resolver can wire as @mcp_servers:
|
|
86
|
+
# OAuth-protected remote MCP servers, and GitHub App integrations
|
|
87
|
+
# (resolved to the SDK's bundled GitHub tools server). Mirror of
|
|
88
|
+
# the dispatch in ``levain_sdk.mcp_registry.resolve_mcp_servers``.
|
|
89
|
+
_MCP_CALLABLE_KINDS = frozenset({"mcp_url", "github_app"})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_mcp_names(
|
|
93
|
+
annotations: list[NodeAnnotation],
|
|
94
|
+
*,
|
|
95
|
+
attached_mcp_names: list[str] | None = None,
|
|
96
|
+
) -> list[ValidationError]:
|
|
97
|
+
"""Return one ``ValidationError`` per unresolved ``@mcp_servers`` name.
|
|
98
|
+
|
|
99
|
+
``attached_mcp_names``:
|
|
100
|
+
- ``None`` (default): read the target list from the env
|
|
101
|
+
var. When the env is unset / empty, the check is
|
|
102
|
+
skipped (allowing platform names) so a clean-checkout
|
|
103
|
+
run on a recipe declaring ``["knead-review"]`` still
|
|
104
|
+
passes.
|
|
105
|
+
- explicit list (incl. ``[]``): enforce against the given
|
|
106
|
+
list union with platform server names. Pass ``[]`` to enforce
|
|
107
|
+
"platform-only" behaviour in tests.
|
|
108
|
+
"""
|
|
109
|
+
targets: list[TargetIntegration]
|
|
110
|
+
if attached_mcp_names is None:
|
|
111
|
+
targets = _load_env_targets()
|
|
112
|
+
if not targets:
|
|
113
|
+
# No source of truth; skip the check rather than
|
|
114
|
+
# rejecting recipes that legitimately reference tools
|
|
115
|
+
# we just can't see from here.
|
|
116
|
+
return []
|
|
117
|
+
explicit_names: list[str] = []
|
|
118
|
+
else:
|
|
119
|
+
targets = []
|
|
120
|
+
explicit_names = list(attached_mcp_names)
|
|
121
|
+
|
|
122
|
+
platform = platform_server_names()
|
|
123
|
+
|
|
124
|
+
errors: list[ValidationError] = []
|
|
125
|
+
for ann in annotations:
|
|
126
|
+
servers = ann.args.get("mcp_servers")
|
|
127
|
+
if not isinstance(servers, list):
|
|
128
|
+
continue
|
|
129
|
+
for s in servers:
|
|
130
|
+
if not isinstance(s, str):
|
|
131
|
+
continue
|
|
132
|
+
if _resolves(s, platform, targets, explicit_names):
|
|
133
|
+
continue
|
|
134
|
+
errors.append(
|
|
135
|
+
ValidationError(
|
|
136
|
+
stage="mcp_servers",
|
|
137
|
+
message=_unresolved_message(
|
|
138
|
+
s, platform, targets, explicit_names
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return errors
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def unresolved_mcp_names(
|
|
146
|
+
names: list[str],
|
|
147
|
+
*,
|
|
148
|
+
attached_mcp_names: list[str],
|
|
149
|
+
) -> list[str]:
|
|
150
|
+
"""Subset of ``names`` that resolve to nothing.
|
|
151
|
+
|
|
152
|
+
The per-name resolution primitive behind :func:`check_mcp_names`,
|
|
153
|
+
exposed for callers that carry their own integration context (the
|
|
154
|
+
API's graph annotation) — sharing ``_resolves`` keeps every
|
|
155
|
+
consumer on one definition of "this name binds at run time".
|
|
156
|
+
"""
|
|
157
|
+
platform = platform_server_names()
|
|
158
|
+
explicit = list(attached_mcp_names)
|
|
159
|
+
return [n for n in names if not _resolves(n, platform, [], explicit)]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolves(
|
|
163
|
+
name: str,
|
|
164
|
+
platform: set[str],
|
|
165
|
+
targets: list[TargetIntegration],
|
|
166
|
+
explicit_names: list[str],
|
|
167
|
+
) -> bool:
|
|
168
|
+
"""Mirror the runtime resolver's matching rules."""
|
|
169
|
+
needle = name.strip().casefold()
|
|
170
|
+
if any(p.casefold() == needle for p in platform):
|
|
171
|
+
return True
|
|
172
|
+
if any(
|
|
173
|
+
_matches(name, t)
|
|
174
|
+
for t in targets
|
|
175
|
+
if t.get("kind") in _MCP_CALLABLE_KINDS
|
|
176
|
+
):
|
|
177
|
+
return True
|
|
178
|
+
return any(e.strip().casefold() == needle for e in explicit_names)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _matches(name: str, target: TargetIntegration) -> bool:
|
|
182
|
+
"""Mirror of ``platform_sdk.integrations.matches``.
|
|
183
|
+
|
|
184
|
+
Duplicated (not imported) because ``platform_sdk`` isn't a
|
|
185
|
+
dependency of the open ``levain-cli`` package — see
|
|
186
|
+
``known_recipes.CALLABLE_RECIPES`` for the same pattern.
|
|
187
|
+
"""
|
|
188
|
+
if not name:
|
|
189
|
+
return False
|
|
190
|
+
needle = name.strip().casefold()
|
|
191
|
+
display = target.get("display_name", "").strip().casefold()
|
|
192
|
+
if display and display == needle:
|
|
193
|
+
return True
|
|
194
|
+
provider = target.get("provider", "").strip().casefold()
|
|
195
|
+
return bool(provider and provider == needle)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _unresolved_message(
|
|
199
|
+
name: str,
|
|
200
|
+
platform: set[str],
|
|
201
|
+
targets: list[TargetIntegration],
|
|
202
|
+
explicit_names: list[str],
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Build a helpful error listing what *is* available."""
|
|
205
|
+
available_target_names = [
|
|
206
|
+
t["display_name"]
|
|
207
|
+
for t in targets
|
|
208
|
+
if t.get("display_name") and t.get("kind") in _MCP_CALLABLE_KINDS
|
|
209
|
+
]
|
|
210
|
+
available = sorted({*platform, *available_target_names, *explicit_names})
|
|
211
|
+
if available:
|
|
212
|
+
listing = ", ".join(available)
|
|
213
|
+
else:
|
|
214
|
+
listing = "(none)"
|
|
215
|
+
return (
|
|
216
|
+
f"@mcp_servers name {name!r} does not resolve to a "
|
|
217
|
+
f"mapped integration. Available: {listing}."
|
|
218
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Recipes a ``call_agent`` node may target.
|
|
2
|
+
|
|
3
|
+
Mirrors the platform's trusted-recipe set
|
|
4
|
+
(``platform_sdk.recipe.names.RECIPE_NAMES``); a drift-guard test
|
|
5
|
+
in Levain's CI keeps the two equal. The server re-validates
|
|
6
|
+
every push with its own copy, so a stale local set only affects
|
|
7
|
+
local lint output.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
CALLABLE_RECIPES = frozenset(
|
|
11
|
+
{
|
|
12
|
+
"build",
|
|
13
|
+
"commit",
|
|
14
|
+
"crawler",
|
|
15
|
+
"issue_maintainer",
|
|
16
|
+
"observer",
|
|
17
|
+
"plan",
|
|
18
|
+
"review",
|
|
19
|
+
"wiki_maintainer",
|
|
20
|
+
}
|
|
21
|
+
)
|
levain_recipe/mcp.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Stdio MCP server exposing the recipe toolchain.
|
|
2
|
+
|
|
3
|
+
For MCP clients that cannot run shell commands (e.g. Claude
|
|
4
|
+
Desktop): the tools mirror the CLI commands and operate on the
|
|
5
|
+
local filesystem. Requires the ``[mcp]`` extra.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
from ._format import format_errors, format_success
|
|
16
|
+
from ._java import ensure_jar
|
|
17
|
+
from .codegen import generate_state_module
|
|
18
|
+
from .validate import validate, validate_fast, validate_package
|
|
19
|
+
|
|
20
|
+
server = FastMCP("levain-recipe")
|
|
21
|
+
|
|
22
|
+
_NO_JAVA_NOTE = (
|
|
23
|
+
"Note: Java not found — stages 4-5 (TLC model checking) "
|
|
24
|
+
"skipped; they still run server-side on push.\n\n"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@server.tool()
|
|
29
|
+
def validate_recipe(path: str) -> str:
|
|
30
|
+
"""Validate a PlusCal recipe spec (graph.tla) file."""
|
|
31
|
+
file = Path(path)
|
|
32
|
+
if not file.is_file():
|
|
33
|
+
return f"Error: file not found: {path}"
|
|
34
|
+
source = file.read_text(encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
note = ""
|
|
37
|
+
if shutil.which("java"):
|
|
38
|
+
ensure_jar()
|
|
39
|
+
result = validate(source)
|
|
40
|
+
else:
|
|
41
|
+
note = _NO_JAVA_NOTE
|
|
42
|
+
result = validate_fast(source)
|
|
43
|
+
|
|
44
|
+
if isinstance(result, list):
|
|
45
|
+
return note + format_errors(result)
|
|
46
|
+
return note + format_success(result)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@server.tool()
|
|
50
|
+
def validate_recipe_package(recipe_dir: str) -> str:
|
|
51
|
+
"""Validate a full recipe package (graph.tla + Python source)."""
|
|
52
|
+
if not Path(recipe_dir).is_dir():
|
|
53
|
+
return f"Error: not a directory: {recipe_dir}"
|
|
54
|
+
|
|
55
|
+
note = ""
|
|
56
|
+
if shutil.which("java"):
|
|
57
|
+
ensure_jar()
|
|
58
|
+
else:
|
|
59
|
+
note = _NO_JAVA_NOTE
|
|
60
|
+
result = validate_package(recipe_dir)
|
|
61
|
+
if isinstance(result, list):
|
|
62
|
+
return note + format_errors(result)
|
|
63
|
+
return note + format_success(result)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@server.tool()
|
|
67
|
+
def generate_state(recipe_dir: str) -> str:
|
|
68
|
+
"""Regenerate the recipe's ``_state.py`` GraphState TypedDict."""
|
|
69
|
+
root = Path(recipe_dir)
|
|
70
|
+
if not root.is_dir():
|
|
71
|
+
return f"Error: not a directory: {recipe_dir}"
|
|
72
|
+
|
|
73
|
+
if shutil.which("java"):
|
|
74
|
+
ensure_jar()
|
|
75
|
+
result = validate_package(recipe_dir)
|
|
76
|
+
if isinstance(result, list):
|
|
77
|
+
return format_errors(result)
|
|
78
|
+
|
|
79
|
+
src_dir = root / "src" / result.module_name
|
|
80
|
+
if not src_dir.is_dir():
|
|
81
|
+
return f"Error: expected src dir at {src_dir}"
|
|
82
|
+
|
|
83
|
+
state_path = src_dir / "_state.py"
|
|
84
|
+
state_path.write_text(generate_state_module(result), encoding="utf-8")
|
|
85
|
+
return f"Wrote {state_path}"
|
levain_recipe/models.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Data models for validated PlusCal specs.
|
|
2
|
+
|
|
3
|
+
Canonical definitions shared by the spec validator, compiler,
|
|
4
|
+
and sandbox modules. Other modules should import from here
|
|
5
|
+
rather than defining their own copies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# Canonical mapping from spec type names to Python types.
|
|
12
|
+
# The single source of truth consumed by both the compiler
|
|
13
|
+
# (runtime TypedDict) and the codegen (generated ``_state.py``).
|
|
14
|
+
SPEC_TYPES: dict[str, type] = {
|
|
15
|
+
"bool": bool,
|
|
16
|
+
"string": str,
|
|
17
|
+
"int": int,
|
|
18
|
+
"list": list,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Built-in node function name for the langgraph interrupt
|
|
22
|
+
# primitive. Recognised by the validator (terminal label),
|
|
23
|
+
# compiler (graph-native node), and user-input projector
|
|
24
|
+
# (``interrupting`` flag).
|
|
25
|
+
INTERRUPT_BUILTIN = "interrupt"
|
|
26
|
+
|
|
27
|
+
# Built-in node function name for an agent-to-agent call. Like
|
|
28
|
+
# ``interrupt`` it runs in the langgraph runtime (harness side, not
|
|
29
|
+
# the sandbox) — it needs DB + dispatch access to run another agent.
|
|
30
|
+
# Args: ``agent`` (target slug), ``input`` (B-field → A-state-field
|
|
31
|
+
# mapping), ``output`` (A-state-field → B-output-field mapping).
|
|
32
|
+
CALL_AGENT_BUILTIN = "call_agent"
|
|
33
|
+
|
|
34
|
+
# Mid-run steering policy for a node, set via the optional
|
|
35
|
+
# ``interruptible`` key on a ``@node`` annotation. Controls when a
|
|
36
|
+
# user message that arrives while this node is the live node is folded
|
|
37
|
+
# into the graph:
|
|
38
|
+
# * ``enqueue`` — let the node finish, fold in at the next boundary.
|
|
39
|
+
# * ``interrupt`` — cut the node mid-flight and re-route now.
|
|
40
|
+
# * ``none`` — never cut here (atomic/critical section, e.g. a git
|
|
41
|
+
# commit/push); the steer is deferred until the graph leaves it.
|
|
42
|
+
# The default is deliberately ``enqueue``: never silently un-steerable,
|
|
43
|
+
# never cuts a critical section.
|
|
44
|
+
INTERRUPTIBLE_KEY = "interruptible"
|
|
45
|
+
INTERRUPTIBLE_CLASSES = ("enqueue", "interrupt", "none")
|
|
46
|
+
DEFAULT_INTERRUPTIBLE = "enqueue"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class StateField:
|
|
51
|
+
"""A variable declared in the PlusCal variables block.
|
|
52
|
+
|
|
53
|
+
``reducer`` names a blessed reducer from
|
|
54
|
+
:mod:`levain_recipe.reducers`. List-typed fields
|
|
55
|
+
must declare one; scalar fields may not. The default "last
|
|
56
|
+
write wins" behaviour is rarely correct for a list, so we
|
|
57
|
+
require an explicit choice.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
type: str # key into SPEC_TYPES
|
|
62
|
+
default: Any = ""
|
|
63
|
+
reducer: str | None = None
|
|
64
|
+
# Free-form description collected from non-annotation comment
|
|
65
|
+
# lines above the variable declaration in graph.tla. Surfaced
|
|
66
|
+
# in the generated ``_state.py`` (as ``Annotated[T, "..."]``)
|
|
67
|
+
# and on the API's state-schema endpoint so the FE can render
|
|
68
|
+
# a per-field helper in the State tab.
|
|
69
|
+
description: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class NodeAnnotation:
|
|
74
|
+
"""Metadata for a labeled node.
|
|
75
|
+
|
|
76
|
+
Every node is a function call. Built-in functions
|
|
77
|
+
(``claude_agent``, ``shell``) are provided by the
|
|
78
|
+
platform; user functions use ``module:function`` syntax
|
|
79
|
+
and are resolved by the sandbox via importlib.
|
|
80
|
+
|
|
81
|
+
The ``@prompt``, ``@allowed_tools``, and
|
|
82
|
+
``@permission_mode`` comment annotations are sugar —
|
|
83
|
+
the profile checker merges them into ``args``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
label: str
|
|
87
|
+
function: str # "claude_agent", "shell", "my_mod:score"
|
|
88
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class Condition:
|
|
93
|
+
"""A single boolean predicate on a state field."""
|
|
94
|
+
|
|
95
|
+
field: str
|
|
96
|
+
op: str # "eq", "neq", "gt", "gte", "lt", "lte"
|
|
97
|
+
value: Any # True, False, 0, "pass", etc.
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Route:
|
|
102
|
+
"""One arm of a routing decision.
|
|
103
|
+
|
|
104
|
+
All conditions are ANDed. If ``send_field`` is set,
|
|
105
|
+
the harness fans out: one Send per item in that list
|
|
106
|
+
field, each dispatched to ``target``.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
target: str
|
|
110
|
+
conditions: list[Condition] = field(default_factory=list)
|
|
111
|
+
send_field: str | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class RoutingTable:
|
|
116
|
+
"""Ordered routing rules for a single source node.
|
|
117
|
+
|
|
118
|
+
Routes are evaluated in order; first match wins.
|
|
119
|
+
The last route typically has no conditions (default).
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
source: str
|
|
123
|
+
routes: list[Route]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class ValidatedSpec:
|
|
128
|
+
"""Result of successful validation."""
|
|
129
|
+
|
|
130
|
+
source: str
|
|
131
|
+
module_name: str
|
|
132
|
+
variables: list[StateField]
|
|
133
|
+
nodes: list[NodeAnnotation]
|
|
134
|
+
routing: list[RoutingTable]
|
|
135
|
+
invariants: list[str] = field(default_factory=list)
|
|
136
|
+
constants: dict[str, Any] = field(default_factory=dict)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class ValidationError:
|
|
141
|
+
"""A validation failure with context."""
|
|
142
|
+
|
|
143
|
+
stage: str # "parse", "profile", "graph", "translate", "tlc"
|
|
144
|
+
message: str
|
|
145
|
+
line: int | None = None
|
|
146
|
+
counterexample: list[dict[str, Any]] | None = None
|