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,66 @@
|
|
|
1
|
+
r"""Blessed reducers for recipe state fields.
|
|
2
|
+
|
|
3
|
+
LangGraph applies a reducer when a node returns an update for a
|
|
4
|
+
state field. The default merges by overwrite ("last write wins"),
|
|
5
|
+
which is wrong for accumulating lists — messages, logs, any
|
|
6
|
+
append-only channel. Recipes opt into a reducer by name via
|
|
7
|
+
``\* @reducer <name>`` above a variable declaration; validation
|
|
8
|
+
rejects anything not in this registry.
|
|
9
|
+
|
|
10
|
+
Keeping the set small and named (rather than accepting arbitrary
|
|
11
|
+
dotted paths) keeps the DSL's surface area bounded: a customer
|
|
12
|
+
recipe can't reach into the platform by declaring
|
|
13
|
+
``@reducer some.module:func``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ReducerSpec:
|
|
23
|
+
"""Codegen info for a blessed reducer.
|
|
24
|
+
|
|
25
|
+
Deliberately metadata only — names and import lines, no
|
|
26
|
+
callables. Binding a name to the function LangGraph invokes is
|
|
27
|
+
the runtime's job (``levain_sdk.reducers.RUNTIME_REDUCERS``),
|
|
28
|
+
and keeping it there is what lets this package validate a
|
|
29
|
+
recipe without installing the agent runtime.
|
|
30
|
+
|
|
31
|
+
``element_type_name`` is the type each list item takes in the
|
|
32
|
+
generated ``_state.py``; ``import_line`` is the single import
|
|
33
|
+
codegen writes when this reducer is used.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
element_type_name: str
|
|
38
|
+
import_line: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
REDUCERS: dict[str, ReducerSpec] = {
|
|
42
|
+
"add_messages": ReducerSpec(
|
|
43
|
+
name="add_messages",
|
|
44
|
+
element_type_name="Message",
|
|
45
|
+
import_line="from levain_sdk.messages import Message, add_messages",
|
|
46
|
+
),
|
|
47
|
+
"add_str": ReducerSpec(
|
|
48
|
+
name="add_str",
|
|
49
|
+
element_type_name="str",
|
|
50
|
+
import_line="from levain_sdk.reducers import add_str",
|
|
51
|
+
),
|
|
52
|
+
"extend_list": ReducerSpec(
|
|
53
|
+
name="extend_list",
|
|
54
|
+
# ``dict[str, Any]`` rather than bare ``dict``: the latter
|
|
55
|
+
# trips mypy's ``type-arg`` rule on the generated
|
|
56
|
+
# ``_state.py``.
|
|
57
|
+
element_type_name="dict[str, Any]",
|
|
58
|
+
import_line="from levain_sdk.reducers import extend_list",
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if not all(k == v.name for k, v in REDUCERS.items()):
|
|
63
|
+
raise ValueError("REDUCERS dict key must equal ReducerSpec.name")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["REDUCERS", "ReducerSpec"]
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Python source validation for recipe packages.
|
|
2
|
+
|
|
3
|
+
Checks that @node function references in graph.tla resolve
|
|
4
|
+
to actual Python functions in the recipe's src/ directory.
|
|
5
|
+
Uses ast (no imports) for safe static analysis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tomllib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .models import NodeAnnotation, ValidatedSpec, ValidationError
|
|
17
|
+
|
|
18
|
+
_SDK_PACKAGE = "levain-sdk"
|
|
19
|
+
_NORMALIZE_RE = re.compile(r"[-_.]+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_python_sources(
|
|
23
|
+
spec: ValidatedSpec,
|
|
24
|
+
recipe_dir: str,
|
|
25
|
+
) -> list[ValidationError]:
|
|
26
|
+
"""Validate Python source references in a recipe package.
|
|
27
|
+
|
|
28
|
+
Checks:
|
|
29
|
+
1. module:function refs resolve to files + functions
|
|
30
|
+
2. Functions are async def with 2 parameters
|
|
31
|
+
3. pyproject.toml exists and is valid
|
|
32
|
+
4. pyproject.toml declares SDK dependency
|
|
33
|
+
5. No orphan public ``async def`` functions in node-hosting
|
|
34
|
+
files — every top-level async function in a file referenced
|
|
35
|
+
by a ``@node`` annotation must itself be bound to a
|
|
36
|
+
``@node``, or be marked private with a leading underscore.
|
|
37
|
+
Catches recipes that add Python functions for new behaviour
|
|
38
|
+
but forget to wire them into ``graph.tla``.
|
|
39
|
+
"""
|
|
40
|
+
errors: list[ValidationError] = []
|
|
41
|
+
src_dir = os.path.join(recipe_dir, "src")
|
|
42
|
+
|
|
43
|
+
errors.extend(_check_pyproject(recipe_dir))
|
|
44
|
+
errors.extend(_check_function_refs(spec.nodes, src_dir))
|
|
45
|
+
errors.extend(_check_orphan_node_functions(spec.nodes, src_dir))
|
|
46
|
+
|
|
47
|
+
return errors
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_pyproject(recipe_dir: str) -> list[ValidationError]:
|
|
51
|
+
"""Check pyproject.toml exists and declares SDK dependency."""
|
|
52
|
+
path = os.path.join(recipe_dir, "pyproject.toml")
|
|
53
|
+
if not os.path.isfile(path):
|
|
54
|
+
return [
|
|
55
|
+
ValidationError(
|
|
56
|
+
stage="source",
|
|
57
|
+
message="pyproject.toml not found in recipe directory.",
|
|
58
|
+
)
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
with open(path, "rb") as f:
|
|
63
|
+
data = tomllib.load(f)
|
|
64
|
+
except tomllib.TOMLDecodeError as exc:
|
|
65
|
+
return [
|
|
66
|
+
ValidationError(
|
|
67
|
+
stage="source",
|
|
68
|
+
message=f"pyproject.toml is not valid TOML: {exc}",
|
|
69
|
+
)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
deps = data.get("project", {}).get("dependencies", [])
|
|
73
|
+
has_sdk = any(
|
|
74
|
+
_NORMALIZE_RE.sub("-", re.split(r"[~><!=;\[ ]", d)[0].strip()).lower()
|
|
75
|
+
== _SDK_PACKAGE
|
|
76
|
+
for d in deps
|
|
77
|
+
)
|
|
78
|
+
if not has_sdk:
|
|
79
|
+
return [
|
|
80
|
+
ValidationError(
|
|
81
|
+
stage="source",
|
|
82
|
+
message=(
|
|
83
|
+
f"pyproject.toml does not declare "
|
|
84
|
+
f"'{_SDK_PACKAGE}' as a dependency."
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_function_refs(
|
|
93
|
+
nodes: list[NodeAnnotation],
|
|
94
|
+
src_dir: str,
|
|
95
|
+
) -> list[ValidationError]:
|
|
96
|
+
"""Check that module:function refs resolve to Python code."""
|
|
97
|
+
errors: list[ValidationError] = []
|
|
98
|
+
|
|
99
|
+
for node in nodes:
|
|
100
|
+
func = node.function
|
|
101
|
+
|
|
102
|
+
# Bare names (no colon) are dispatcher-handled node types
|
|
103
|
+
# (e.g. claude_agent, shell, recipe) — not Python refs.
|
|
104
|
+
if ":" not in func:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
module_path, func_name = func.rsplit(":", 1)
|
|
108
|
+
|
|
109
|
+
# Resolve module path to file.
|
|
110
|
+
parts = module_path.split(".")
|
|
111
|
+
py_file = Path(src_dir, *parts).with_suffix(".py")
|
|
112
|
+
|
|
113
|
+
# Guard against path traversal escaping src/.
|
|
114
|
+
try:
|
|
115
|
+
py_file.resolve().relative_to(Path(src_dir).resolve())
|
|
116
|
+
except ValueError:
|
|
117
|
+
errors.append(
|
|
118
|
+
ValidationError(
|
|
119
|
+
stage="source",
|
|
120
|
+
message=(
|
|
121
|
+
f"Module path '{module_path}' "
|
|
122
|
+
f"escapes the src/ directory."
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if not py_file.is_file():
|
|
129
|
+
errors.append(
|
|
130
|
+
ValidationError(
|
|
131
|
+
stage="source",
|
|
132
|
+
message=(
|
|
133
|
+
f"Module '{module_path}' not found "
|
|
134
|
+
f"at {py_file.relative_to(Path(src_dir).parent)}."
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Parse and check function exists.
|
|
141
|
+
try:
|
|
142
|
+
source = py_file.read_text(encoding="utf-8")
|
|
143
|
+
tree = ast.parse(source, filename=str(py_file))
|
|
144
|
+
except (SyntaxError, OSError) as exc:
|
|
145
|
+
errors.append(
|
|
146
|
+
ValidationError(
|
|
147
|
+
stage="source",
|
|
148
|
+
message=(f"Syntax error in {py_file.name}: {exc}"),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
func_node = _find_async_def(tree, func_name)
|
|
154
|
+
if func_node is None:
|
|
155
|
+
errors.append(
|
|
156
|
+
ValidationError(
|
|
157
|
+
stage="source",
|
|
158
|
+
message=(
|
|
159
|
+
f"Function '{func_name}' not found as "
|
|
160
|
+
f"a top-level async def in "
|
|
161
|
+
f"{py_file.name}."
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Check parameter count (state, config).
|
|
168
|
+
n_params = (
|
|
169
|
+
len(func_node.args.posonlyargs)
|
|
170
|
+
+ len(func_node.args.args)
|
|
171
|
+
+ len(func_node.args.kwonlyargs)
|
|
172
|
+
)
|
|
173
|
+
if n_params != 2:
|
|
174
|
+
errors.append(
|
|
175
|
+
ValidationError(
|
|
176
|
+
stage="source",
|
|
177
|
+
message=(
|
|
178
|
+
f"Function '{func_name}' in "
|
|
179
|
+
f"{py_file.name} has {n_params} "
|
|
180
|
+
f"parameter(s), expected 2 "
|
|
181
|
+
f"(state, config)."
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return errors
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _find_async_def(
|
|
190
|
+
tree: ast.Module,
|
|
191
|
+
name: str,
|
|
192
|
+
) -> ast.AsyncFunctionDef | None:
|
|
193
|
+
"""Find a top-level async def by name."""
|
|
194
|
+
for node in ast.iter_child_nodes(tree):
|
|
195
|
+
if isinstance(node, ast.AsyncFunctionDef) and node.name == name:
|
|
196
|
+
return node
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _check_orphan_node_functions(
|
|
201
|
+
nodes: list[NodeAnnotation],
|
|
202
|
+
src_dir: str,
|
|
203
|
+
) -> list[ValidationError]:
|
|
204
|
+
"""Flag public ``async def`` functions not bound to a ``@node``.
|
|
205
|
+
|
|
206
|
+
Recipes that add behaviour (new "nodes" in the agent sense)
|
|
207
|
+
must wire it through ``graph.tla`` — adding the function to
|
|
208
|
+
``nodes.py`` alone leaves the recipe topology unchanged and
|
|
209
|
+
the new function as dead code.
|
|
210
|
+
|
|
211
|
+
The check is per-file: for each ``.py`` file referenced by
|
|
212
|
+
any ``@node`` annotation, collect the bound function names,
|
|
213
|
+
then flag any other top-level ``async def`` that doesn't
|
|
214
|
+
start with ``_`` (private convention). Files not hosting
|
|
215
|
+
any node binding are ignored — utility modules can have
|
|
216
|
+
arbitrary public async helpers.
|
|
217
|
+
|
|
218
|
+
The error message stays neutral: it reports the
|
|
219
|
+
inconsistency and lets the recipe author / build agent
|
|
220
|
+
decide whether the right fix is to wire the function in or
|
|
221
|
+
to remove it as dead code. We surface the gap; we don't
|
|
222
|
+
push a direction.
|
|
223
|
+
"""
|
|
224
|
+
errors: list[ValidationError] = []
|
|
225
|
+
|
|
226
|
+
# file path -> set of bound function names from @node refs
|
|
227
|
+
bindings_per_file: dict[Path, set[str]] = {}
|
|
228
|
+
for node in nodes:
|
|
229
|
+
func = node.function
|
|
230
|
+
if ":" not in func:
|
|
231
|
+
continue
|
|
232
|
+
module_path, func_name = func.rsplit(":", 1)
|
|
233
|
+
py_file = Path(src_dir, *module_path.split(".")).with_suffix(".py")
|
|
234
|
+
try:
|
|
235
|
+
py_file.resolve().relative_to(Path(src_dir).resolve())
|
|
236
|
+
except ValueError:
|
|
237
|
+
continue
|
|
238
|
+
if not py_file.is_file():
|
|
239
|
+
continue
|
|
240
|
+
bindings_per_file.setdefault(py_file, set()).add(func_name)
|
|
241
|
+
|
|
242
|
+
for py_file, bound_names in bindings_per_file.items():
|
|
243
|
+
try:
|
|
244
|
+
tree = ast.parse(py_file.read_text(encoding="utf-8"))
|
|
245
|
+
except (SyntaxError, OSError):
|
|
246
|
+
# ``_check_function_refs`` already surfaced this; don't
|
|
247
|
+
# double-report.
|
|
248
|
+
continue
|
|
249
|
+
async_defs = {
|
|
250
|
+
n.name
|
|
251
|
+
for n in ast.iter_child_nodes(tree)
|
|
252
|
+
if isinstance(n, ast.AsyncFunctionDef)
|
|
253
|
+
}
|
|
254
|
+
# If any bound name is missing from the file,
|
|
255
|
+
# _check_function_refs already reported it — skip
|
|
256
|
+
# the orphan scan to avoid double-reporting every
|
|
257
|
+
# other function in the file as unbound.
|
|
258
|
+
if not bound_names.issubset(async_defs):
|
|
259
|
+
continue
|
|
260
|
+
for child in ast.iter_child_nodes(tree):
|
|
261
|
+
if not isinstance(child, ast.AsyncFunctionDef):
|
|
262
|
+
continue
|
|
263
|
+
if child.name.startswith("_"):
|
|
264
|
+
continue
|
|
265
|
+
if child.name in bound_names:
|
|
266
|
+
continue
|
|
267
|
+
errors.append(
|
|
268
|
+
ValidationError(
|
|
269
|
+
stage="source",
|
|
270
|
+
message=(
|
|
271
|
+
f"Async function '{child.name}' in "
|
|
272
|
+
f"{py_file.name} isn't bound to a @node "
|
|
273
|
+
f"annotation in graph.tla. Either wire it "
|
|
274
|
+
f"into the graph or remove it as dead code."
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return errors
|
levain_recipe/tlc.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Stage 5: TLC model checking via tla2tools.jar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._java import RE_MODULE_NAME, require_java, resolve_jar
|
|
12
|
+
from .models import ValidationError
|
|
13
|
+
|
|
14
|
+
_TLC_TIMEOUT_S = 60
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def model_check(
|
|
18
|
+
translated_source: str,
|
|
19
|
+
module_name: str,
|
|
20
|
+
invariants: list[str],
|
|
21
|
+
) -> list[ValidationError] | None:
|
|
22
|
+
"""Run TLC on the translated TLA+ spec.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
translated_source
|
|
27
|
+
TLA+ source after PlusCal translation.
|
|
28
|
+
module_name
|
|
29
|
+
Module name for temp file naming.
|
|
30
|
+
invariants
|
|
31
|
+
Invariant names to check.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
None | list[ValidationError]
|
|
36
|
+
None on success, errors on failure.
|
|
37
|
+
"""
|
|
38
|
+
if not RE_MODULE_NAME.match(module_name):
|
|
39
|
+
return [
|
|
40
|
+
ValidationError(
|
|
41
|
+
stage="tlc",
|
|
42
|
+
message="Invalid module name.",
|
|
43
|
+
)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
jar = resolve_jar()
|
|
47
|
+
if jar is None:
|
|
48
|
+
env_jar = os.environ.get("TLA2TOOLS_JAR")
|
|
49
|
+
if env_jar:
|
|
50
|
+
msg = f"TLA2TOOLS_JAR={env_jar!r} does not exist."
|
|
51
|
+
else:
|
|
52
|
+
msg = "tla2tools.jar not found. Set TLA2TOOLS_JAR env var."
|
|
53
|
+
return [ValidationError(stage="tlc", message=msg)]
|
|
54
|
+
|
|
55
|
+
java_or_err = require_java("tlc")
|
|
56
|
+
if isinstance(java_or_err, list):
|
|
57
|
+
return java_or_err
|
|
58
|
+
java = java_or_err
|
|
59
|
+
|
|
60
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
61
|
+
spec_path = os.path.join(tmpdir, f"{module_name}.tla")
|
|
62
|
+
cfg_path = os.path.join(tmpdir, f"{module_name}.cfg")
|
|
63
|
+
|
|
64
|
+
with open(spec_path, "w", encoding="utf-8") as f:
|
|
65
|
+
f.write(translated_source)
|
|
66
|
+
|
|
67
|
+
cfg = _generate_cfg(invariants)
|
|
68
|
+
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
69
|
+
f.write(cfg)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
[
|
|
74
|
+
java,
|
|
75
|
+
"-Xmx512m",
|
|
76
|
+
"-jar",
|
|
77
|
+
jar,
|
|
78
|
+
"-config",
|
|
79
|
+
cfg_path,
|
|
80
|
+
"-deadlock",
|
|
81
|
+
spec_path,
|
|
82
|
+
],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=_TLC_TIMEOUT_S,
|
|
86
|
+
cwd=tmpdir,
|
|
87
|
+
check=False,
|
|
88
|
+
)
|
|
89
|
+
except subprocess.TimeoutExpired:
|
|
90
|
+
return [
|
|
91
|
+
ValidationError(
|
|
92
|
+
stage="tlc",
|
|
93
|
+
message=(
|
|
94
|
+
"TLC model check timed out after "
|
|
95
|
+
f"{_TLC_TIMEOUT_S}s. The state "
|
|
96
|
+
"space may be too large."
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
return _parse_tlc_output(
|
|
102
|
+
result.stdout + result.stderr,
|
|
103
|
+
result.returncode,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _generate_cfg(invariants: list[str]) -> str:
|
|
108
|
+
"""Generate a TLC .cfg file.
|
|
109
|
+
|
|
110
|
+
Safety only — emits ``INVARIANT`` lines for each ``define``
|
|
111
|
+
block operator. Liveness (``PROPERTY Termination``) is
|
|
112
|
+
intentionally NOT checked here: under PlusCal's restricted
|
|
113
|
+
profile, TLA+ can't cleanly express "the agent eventually
|
|
114
|
+
converges", and a mechanical bound (``retry_count``) would
|
|
115
|
+
limit agents arbitrarily. Termination is instead enforced
|
|
116
|
+
at runtime by the orchestrator's budget/timeout guardrails.
|
|
117
|
+
"""
|
|
118
|
+
lines = ["SPECIFICATION Spec", ""]
|
|
119
|
+
|
|
120
|
+
if invariants:
|
|
121
|
+
for inv in invariants:
|
|
122
|
+
lines.append(f"INVARIANT {inv}")
|
|
123
|
+
lines.append("")
|
|
124
|
+
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── TLC output parsing ──
|
|
129
|
+
|
|
130
|
+
_RE_ERROR = re.compile(r"Error:\s*(.*)")
|
|
131
|
+
_RE_INVARIANT = re.compile(r"Invariant\s+(\w+)\s+is violated")
|
|
132
|
+
_RE_STATE = re.compile(r"State\s+(\d+):\s*<(\w+)\s+line\s+(\d+)")
|
|
133
|
+
_RE_VAR_VAL = re.compile(r"/\\\s*(\w+)\s*=\s*(.*)")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_tlc_output(
|
|
137
|
+
output: str,
|
|
138
|
+
returncode: int,
|
|
139
|
+
) -> list[ValidationError] | None:
|
|
140
|
+
"""Parse TLC stdout/stderr for pass or failure."""
|
|
141
|
+
if returncode == 0 and "Model checking completed" in output:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
if returncode == 0 and "Model checking completed" not in output:
|
|
145
|
+
return [
|
|
146
|
+
ValidationError(
|
|
147
|
+
stage="tlc",
|
|
148
|
+
message=(
|
|
149
|
+
"TLC exited successfully but did not report completion."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
errors: list[ValidationError] = []
|
|
155
|
+
|
|
156
|
+
# Check for invariant violations.
|
|
157
|
+
trace = _extract_trace(output) if _RE_INVARIANT.search(output) else None
|
|
158
|
+
for inv_match in _RE_INVARIANT.finditer(output):
|
|
159
|
+
errors.append(
|
|
160
|
+
ValidationError(
|
|
161
|
+
stage="tlc",
|
|
162
|
+
message=(f"Invariant '{inv_match.group(1)}' is violated."),
|
|
163
|
+
counterexample=trace or None,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
if errors:
|
|
167
|
+
return errors
|
|
168
|
+
|
|
169
|
+
# Generic errors.
|
|
170
|
+
for m in _RE_ERROR.finditer(output):
|
|
171
|
+
errors.append(
|
|
172
|
+
ValidationError(
|
|
173
|
+
stage="tlc",
|
|
174
|
+
message=m.group(1).strip(),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if not errors and returncode != 0:
|
|
179
|
+
errors.append(
|
|
180
|
+
ValidationError(
|
|
181
|
+
stage="tlc",
|
|
182
|
+
message=(
|
|
183
|
+
f"TLC exited with code {returncode}: " + output[:200]
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return errors or None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _extract_trace(
|
|
192
|
+
output: str,
|
|
193
|
+
) -> list[dict[str, Any]]:
|
|
194
|
+
"""Extract the counterexample trace from TLC output."""
|
|
195
|
+
states: list[dict[str, Any]] = []
|
|
196
|
+
current_state: dict[str, Any] = {}
|
|
197
|
+
|
|
198
|
+
for line in output.splitlines():
|
|
199
|
+
state_m = _RE_STATE.match(line.strip())
|
|
200
|
+
if state_m:
|
|
201
|
+
if current_state:
|
|
202
|
+
states.append(current_state)
|
|
203
|
+
current_state = {
|
|
204
|
+
"state_num": int(state_m.group(1)),
|
|
205
|
+
"action": state_m.group(2),
|
|
206
|
+
"line": int(state_m.group(3)),
|
|
207
|
+
"vars": {},
|
|
208
|
+
}
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
var_m = _RE_VAR_VAL.match(line.strip())
|
|
212
|
+
if var_m and current_state:
|
|
213
|
+
name = var_m.group(1)
|
|
214
|
+
value = _parse_tlc_value(var_m.group(2).strip())
|
|
215
|
+
current_state["vars"][name] = value
|
|
216
|
+
|
|
217
|
+
if current_state:
|
|
218
|
+
states.append(current_state)
|
|
219
|
+
|
|
220
|
+
return states
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_tlc_value(raw: str) -> Any:
|
|
224
|
+
"""Best-effort parse of a TLC value."""
|
|
225
|
+
if raw == "TRUE":
|
|
226
|
+
return True
|
|
227
|
+
if raw == "FALSE":
|
|
228
|
+
return False
|
|
229
|
+
try:
|
|
230
|
+
return int(raw)
|
|
231
|
+
except ValueError:
|
|
232
|
+
pass
|
|
233
|
+
# Strip TLA+ string quotes.
|
|
234
|
+
if raw.startswith('"') and raw.endswith('"'):
|
|
235
|
+
return raw[1:-1]
|
|
236
|
+
return raw
|