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.
@@ -0,0 +1,554 @@
1
+ """Validation pipeline: parse -> profile -> graph -> translate -> TLC.
2
+
3
+ Also provides ``validate_package()`` for full recipe package
4
+ validation (PlusCal + Python source + pyproject.toml).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import re
12
+ import shutil
13
+ from typing import cast
14
+
15
+ from ._java import resolve_jar
16
+ from .graph import check_graph
17
+ from .integrations_check import check_mcp_names
18
+ from .models import (
19
+ CALL_AGENT_BUILTIN,
20
+ INTERRUPT_BUILTIN,
21
+ INTERRUPTIBLE_CLASSES,
22
+ INTERRUPTIBLE_KEY,
23
+ NodeAnnotation,
24
+ RoutingTable,
25
+ StateField,
26
+ ValidatedSpec,
27
+ ValidationError,
28
+ )
29
+ from .parser import (
30
+ extract_constants,
31
+ extract_defines,
32
+ extract_variables,
33
+ parse,
34
+ )
35
+ from .profile import check_profile
36
+ from .reducers import REDUCERS
37
+ from .source_check import check_python_sources
38
+ from .tlc import model_check
39
+ from .translate import translate
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
44
+
45
+ _RE_REDUCER = re.compile(r"\\[*]\s*@reducer\s+(\w+)\s*$")
46
+ # Strip the PlusCal comment prefix ``\*`` (plus surrounding
47
+ # whitespace) from a single-line comment so the recovered body
48
+ # is just the prose the recipe author wrote.
49
+ _RE_COMMENT_PREFIX = re.compile(r"^\\[*]\s?")
50
+ # Any comment line whose body starts with ``@word`` is a
51
+ # structured annotation (``@reducer``, ``@desc``, future tags) —
52
+ # excluded from the free-form description so the help text never
53
+ # accidentally includes machine-readable metadata.
54
+ _RE_ANNOTATION = re.compile(r"^\\[*]\s*@\w+")
55
+
56
+
57
+ def validate(
58
+ source: str,
59
+ *,
60
+ attached_mcp_names: list[str] | None = None,
61
+ ) -> ValidatedSpec | list[ValidationError]:
62
+ """Full validation pipeline (stages 1-5 + MCP-name check).
63
+
64
+ Runs parse, profile check, graph check, MCP-name resolution,
65
+ PlusCal translation, and TLC model checking. Short-circuits
66
+ on the first stage that fails.
67
+
68
+ ``attached_mcp_names`` is forwarded to the MCP-name check —
69
+ see :func:`validate_fast` for its semantics.
70
+ """
71
+ result = validate_fast(source, attached_mcp_names=attached_mcp_names)
72
+ if isinstance(result, list):
73
+ return result
74
+
75
+ translated = translate(source, result.module_name)
76
+ if isinstance(translated, list):
77
+ return translated
78
+
79
+ invariants = result.invariants
80
+ tlc_errors = model_check(translated, result.module_name, invariants)
81
+ if tlc_errors is not None:
82
+ return tlc_errors
83
+
84
+ return result
85
+
86
+
87
+ def validate_fast( # noqa: PLR0911
88
+ source: str,
89
+ *,
90
+ attached_mcp_names: list[str] | None = None,
91
+ ) -> ValidatedSpec | list[ValidationError]:
92
+ """Fast validation (stages 1-3 + MCP-name check, no Java).
93
+
94
+ ``attached_mcp_names``:
95
+ - ``None`` (default): the MCP-name check reads the target
96
+ list from ``LEVAIN_TARGET_INTEGRATIONS_B64``. Empty/
97
+ missing env var skips the check (clean checkout,
98
+ manual CLI run with no target context).
99
+ - explicit list: enforce against the given names plus
100
+ the platform server names. Pass ``[]`` to enforce
101
+ platform-only.
102
+ """
103
+ # Stage 1: Parse.
104
+ parse_result = parse(source)
105
+ if isinstance(parse_result, list):
106
+ return parse_result
107
+
108
+ algo, module_name = parse_result
109
+
110
+ # Stage 2: Profile check.
111
+ if algo.parent is None or algo.parent.parent is None:
112
+ return [
113
+ ValidationError(
114
+ stage="profile",
115
+ message=("Unexpected AST structure: missing parent nodes."),
116
+ line=algo.start_point[0] + 1,
117
+ )
118
+ ]
119
+ profile_result = check_profile(algo, algo.parent.parent)
120
+ if isinstance(profile_result, list):
121
+ return profile_result
122
+
123
+ annotations, send_fields = profile_result
124
+
125
+ # Extract variables and defines.
126
+ raw_vars = extract_variables(algo)
127
+ variables, var_errors = _build_state_fields(raw_vars)
128
+ if var_errors:
129
+ return var_errors
130
+
131
+ constants = extract_constants(algo)
132
+ invariants = [d for d in extract_defines(algo) if d not in constants]
133
+
134
+ # Stage 3: Graph check. Tell the reachability check that
135
+ # ``interrupt`` nodes count as termination — a chat-shape
136
+ # recipe that loops Respond → WaitForMessage (interrupt) →
137
+ # Respond would otherwise fail "cannot reach Done."
138
+ interrupt_labels = frozenset(
139
+ node.label
140
+ for node in annotations
141
+ if node.function == INTERRUPT_BUILTIN
142
+ )
143
+ graph_result = check_graph(
144
+ algo, raw_vars, send_fields, terminal_labels=interrupt_labels
145
+ )
146
+ if graph_result and isinstance(graph_result[0], ValidationError):
147
+ return graph_result
148
+ routing = cast(list[RoutingTable], graph_result)
149
+
150
+ # Stage 4: MCP-name resolution. Verifies every @mcp_servers
151
+ # entry maps to a known platform server or a target-attached
152
+ # integration; see ``integrations_check`` for the skip rules.
153
+ mcp_errors = check_mcp_names(
154
+ annotations, attached_mcp_names=attached_mcp_names
155
+ )
156
+ if mcp_errors:
157
+ return mcp_errors
158
+
159
+ # Stage 5: agent-to-agent call args. ``call_agent`` nodes need a
160
+ # target slug and an output mapping, or they're a silent no-op.
161
+ a2a_errors = _check_call_agent_nodes(annotations)
162
+ if a2a_errors:
163
+ return a2a_errors
164
+
165
+ # Stage 6: mid-run steering policy. A typo'd ``interruptible``
166
+ # value would otherwise silently fall back to the default and the
167
+ # node would behave unexpectedly under steering.
168
+ steer_errors = _check_interruptible_nodes(annotations)
169
+ if steer_errors:
170
+ return steer_errors
171
+
172
+ return ValidatedSpec(
173
+ source=source,
174
+ module_name=module_name,
175
+ variables=variables,
176
+ nodes=annotations,
177
+ routing=routing,
178
+ invariants=invariants,
179
+ constants=constants,
180
+ )
181
+
182
+
183
+ def _check_call_agent_nodes(
184
+ annotations: list[NodeAnnotation],
185
+ ) -> list[ValidationError]:
186
+ """Validate ``call_agent`` node args (target + I/O).
187
+
188
+ Two shapes are accepted (compiled by the ``call_agent`` dispatch +
189
+ wait nodes):
190
+
191
+ * **Static**: ``agent`` (literal slug), optional ``input`` /
192
+ required ``output`` — flat ``{str: str}`` field maps. The
193
+ compiler reads ``input`` as ``{B_field: A_state_field}`` and
194
+ ``output`` as ``{A_state_field: B_output_field}``.
195
+ * **Dynamic**: ``agent_state`` (state field holding the target
196
+ slug), optional ``input_message_state`` (state field holding the
197
+ overlay dict), required ``output_state`` (state field for the
198
+ child's output dict).
199
+
200
+ A missing target or no output sink makes the node a silent
201
+ no-op, so both are hard errors.
202
+ """
203
+ errors: list[ValidationError] = []
204
+ for node in annotations:
205
+ if node.function != CALL_AGENT_BUILTIN:
206
+ continue
207
+ label = node.label
208
+ errors.extend(_check_one_call_agent(node, label))
209
+ return errors
210
+
211
+
212
+ def _check_one_call_agent(
213
+ node: NodeAnnotation, label: str
214
+ ) -> list[ValidationError]:
215
+ errors: list[ValidationError] = []
216
+
217
+ def _nonempty_str(key: str) -> bool:
218
+ val = node.args.get(key)
219
+ return isinstance(val, str) and bool(val)
220
+
221
+ # Target: a literal ``agent`` slug or a dynamic ``agent_state``
222
+ # field name.
223
+ if not _nonempty_str("agent") and not _nonempty_str("agent_state"):
224
+ errors.append(
225
+ ValidationError(
226
+ stage="profile",
227
+ message=(
228
+ f"@node '{label}' uses 'call_agent' but is missing a "
229
+ "target: set 'agent' (a slug) or 'agent_state' (a "
230
+ "state field name)."
231
+ ),
232
+ )
233
+ )
234
+
235
+ # Static input map (optional) must be a string→string map.
236
+ if node.args.get("input") is not None and not _is_str_map(
237
+ node.args.get("input")
238
+ ):
239
+ errors.append(
240
+ ValidationError(
241
+ stage="profile",
242
+ message=(
243
+ f"@node '{label}': 'call_agent' 'input' must be an "
244
+ "object mapping field names to field names "
245
+ "(string → string)."
246
+ ),
247
+ )
248
+ )
249
+
250
+ # Output sink: a non-empty static map or a dynamic state field.
251
+ out_map = node.args.get("output")
252
+ has_out_state = _nonempty_str("output_state")
253
+ if out_map is not None and not _is_str_map(out_map):
254
+ errors.append(
255
+ ValidationError(
256
+ stage="profile",
257
+ message=(
258
+ f"@node '{label}': 'call_agent' 'output' must be an "
259
+ "object mapping field names to field names "
260
+ "(string → string)."
261
+ ),
262
+ )
263
+ )
264
+ elif not has_out_state and not (_is_str_map(out_map) and out_map):
265
+ errors.append(
266
+ ValidationError(
267
+ stage="profile",
268
+ message=(
269
+ f"@node '{label}': 'call_agent' has no output sink — "
270
+ "map at least one field via 'output' or set "
271
+ "'output_state', or the call is a no-op."
272
+ ),
273
+ )
274
+ )
275
+ return errors
276
+
277
+
278
+ def _check_interruptible_nodes(
279
+ annotations: list[NodeAnnotation],
280
+ ) -> list[ValidationError]:
281
+ """Validate the optional ``interruptible`` value on each node.
282
+
283
+ Absent is fine (the compiler defaults it); a present value must be
284
+ one of the known steering classes so a typo can't silently degrade
285
+ to the default. ``interrupt`` (Wait) nodes are paused waiting for
286
+ user input and can't be cut mid-flight — the compiler never wires
287
+ steer tracking for them, so setting ``interruptible`` there has no
288
+ effect and is rejected here.
289
+ """
290
+ errors: list[ValidationError] = []
291
+ for node in annotations:
292
+ if INTERRUPTIBLE_KEY not in node.args:
293
+ continue
294
+ if node.function == INTERRUPT_BUILTIN:
295
+ errors.append(
296
+ ValidationError(
297
+ stage="profile",
298
+ message=(
299
+ f"@node '{node.label}': {INTERRUPTIBLE_KEY!r} is not "
300
+ "valid on 'interrupt' (Wait) nodes — they pause for "
301
+ "user input and are not subject to mid-run steering."
302
+ ),
303
+ )
304
+ )
305
+ continue
306
+ val = node.args[INTERRUPTIBLE_KEY]
307
+ if val not in INTERRUPTIBLE_CLASSES:
308
+ errors.append(
309
+ ValidationError(
310
+ stage="profile",
311
+ message=(
312
+ f"@node '{node.label}': {INTERRUPTIBLE_KEY!r} must "
313
+ f"be one of {INTERRUPTIBLE_CLASSES}, got {val!r}."
314
+ ),
315
+ )
316
+ )
317
+ return errors
318
+
319
+
320
+ def _is_str_map(val: object) -> bool:
321
+ return isinstance(val, dict) and all(
322
+ isinstance(k, str) and isinstance(v, str) for k, v in val.items()
323
+ )
324
+
325
+
326
+ def validate_package(
327
+ recipe_dir: str,
328
+ *,
329
+ attached_mcp_names: list[str] | None = None,
330
+ ) -> ValidatedSpec | list[ValidationError]:
331
+ """Validate a full recipe package (PlusCal + Python source).
332
+
333
+ Reads ``graph.tla`` from *recipe_dir*, runs the PlusCal
334
+ validation pipeline, then checks Python source references.
335
+
336
+ ``attached_mcp_names`` is forwarded to the MCP-name check —
337
+ see :func:`validate_fast`.
338
+ """
339
+ graph_path = os.path.join(recipe_dir, "graph.tla")
340
+ if not os.path.isfile(graph_path):
341
+ return [
342
+ ValidationError(
343
+ stage="source",
344
+ message="graph.tla not found in recipe directory.",
345
+ )
346
+ ]
347
+
348
+ with open(graph_path, encoding="utf-8") as f:
349
+ source = f.read()
350
+
351
+ # Stages 1-5 when Java + tla2tools.jar are both present, else
352
+ # 1-3 (fast). The fast fallback is a local-dev convenience —
353
+ # in any container meant to run full validation, set
354
+ # ``LEVAIN_REQUIRE_TLC=1`` so a missing toolchain fails loudly
355
+ # instead of silently shipping a spec that skipped TLC.
356
+ if shutil.which("java") and resolve_jar() is not None:
357
+ result = validate(source, attached_mcp_names=attached_mcp_names)
358
+ elif _require_tlc():
359
+ return [
360
+ ValidationError(
361
+ stage="tlc",
362
+ message=(
363
+ "TLC model-checking is required (LEVAIN_REQUIRE_TLC set)"
364
+ " but the toolchain is missing: "
365
+ f"{_missing_tlc_reason()}."
366
+ ),
367
+ )
368
+ ]
369
+ else:
370
+ log.info(
371
+ "validate_package: TLC toolchain unavailable (%s), skipping TLC",
372
+ _missing_tlc_reason(),
373
+ )
374
+ result = validate_fast(source, attached_mcp_names=attached_mcp_names)
375
+ if isinstance(result, list):
376
+ return result
377
+
378
+ # Stage 6: Python source checks.
379
+ src_errors = check_python_sources(result, recipe_dir)
380
+ if src_errors:
381
+ return src_errors
382
+
383
+ return result
384
+
385
+
386
+ def _require_tlc() -> bool:
387
+ """Whether deployed services must run full TLC validation.
388
+
389
+ Set in every image that's meant to do full validation
390
+ (harness worker, platform sandbox, recipe service) so a
391
+ regression that strips Java/tla2tools surfaces as a hard
392
+ validation error rather than a silently degraded one.
393
+ """
394
+ return os.environ.get("LEVAIN_REQUIRE_TLC", "").strip().lower() in _TRUTHY
395
+
396
+
397
+ def _missing_tlc_reason() -> str:
398
+ """Explain which half of the TLC toolchain is unavailable."""
399
+ if shutil.which("java") is None:
400
+ return "java not found on PATH"
401
+ jar = os.environ.get("TLA2TOOLS_JAR")
402
+ if jar:
403
+ return f"tla2tools.jar not found at TLA2TOOLS_JAR={jar!r}"
404
+ return "tla2tools.jar not found (TLA2TOOLS_JAR not set)"
405
+
406
+
407
+ def _build_state_fields(
408
+ raw_vars: list[dict[str, object]],
409
+ ) -> tuple[list[StateField], list[ValidationError]]:
410
+ """Build StateField objects and surface variable-level errors.
411
+
412
+ Checks performed:
413
+ - ``_``-prefixed names collide with platform metadata keys
414
+ (``_error``, ``_cost_usd``) written to node results.
415
+ - Unknown literal types (the parser returned ``"unknown"``
416
+ because the default value wasn't a recognised literal).
417
+ - ``@reducer`` annotation points at a blessed reducer in
418
+ :mod:`levain_recipe.reducers`.
419
+ - ``list``-typed fields must declare a reducer, since the
420
+ default "last write wins" behaviour silently drops
421
+ accumulated items; scalar fields must not (there's no
422
+ reducer in the registry that would compose correctly).
423
+ """
424
+ errors: list[ValidationError] = []
425
+ fields: list[StateField] = []
426
+
427
+ for v in raw_vars:
428
+ name = str(v["name"])
429
+ vtype = str(v["type"])
430
+ raw_line = v.get("line")
431
+ line = raw_line if isinstance(raw_line, int) else None
432
+ comments = cast(list[str], v.get("comments") or [])
433
+
434
+ if name.startswith("_"):
435
+ errors.append(
436
+ ValidationError(
437
+ stage="profile",
438
+ message=(
439
+ f"Variable '{name}' is reserved"
440
+ " (names prefixed with '_' are reserved"
441
+ " for platform metadata)."
442
+ ),
443
+ line=line,
444
+ )
445
+ )
446
+ continue
447
+ if vtype == "unknown":
448
+ errors.append(
449
+ ValidationError(
450
+ stage="profile",
451
+ message=(f"Variable '{name}' has unsupported type."),
452
+ line=line,
453
+ )
454
+ )
455
+ continue
456
+
457
+ try:
458
+ reducer = _extract_reducer(comments)
459
+ except ValueError as exc:
460
+ errors.append(
461
+ ValidationError(
462
+ stage="profile",
463
+ message=f"Variable '{name}': {exc}",
464
+ line=line,
465
+ )
466
+ )
467
+ continue
468
+ if reducer is not None and reducer not in REDUCERS:
469
+ errors.append(
470
+ ValidationError(
471
+ stage="profile",
472
+ message=(
473
+ f"Unknown @reducer '{reducer}' on variable"
474
+ f" '{name}'. Known:"
475
+ f" {', '.join(sorted(REDUCERS))}."
476
+ ),
477
+ line=line,
478
+ )
479
+ )
480
+ continue
481
+ if vtype == "list" and reducer is None:
482
+ errors.append(
483
+ ValidationError(
484
+ stage="profile",
485
+ message=(
486
+ f"List variable '{name}' must declare a"
487
+ " @reducer — the default last-write-wins"
488
+ " behaviour drops accumulated items."
489
+ ),
490
+ line=line,
491
+ )
492
+ )
493
+ continue
494
+ if vtype != "list" and reducer is not None:
495
+ errors.append(
496
+ ValidationError(
497
+ stage="profile",
498
+ message=(
499
+ f"@reducer is only valid on list variables"
500
+ f" (got '{vtype}' on '{name}')."
501
+ ),
502
+ line=line,
503
+ )
504
+ )
505
+ continue
506
+
507
+ fields.append(
508
+ StateField(
509
+ name=name,
510
+ type=vtype,
511
+ default=v["default"],
512
+ reducer=reducer,
513
+ description=_extract_description(comments),
514
+ )
515
+ )
516
+
517
+ return fields, errors
518
+
519
+
520
+ def _extract_description(comments: list[str]) -> str | None:
521
+ r"""Return the free-form description from the comment list.
522
+
523
+ Joins every comment line that is *not* an ``@``-prefixed
524
+ annotation, stripping the leading ``\*`` prefix and
525
+ collapsing the result into one paragraph. Returns ``None``
526
+ when the variable has no descriptive comments.
527
+ """
528
+ parts: list[str] = []
529
+ for text in comments:
530
+ if _RE_ANNOTATION.match(text):
531
+ continue
532
+ body = _RE_COMMENT_PREFIX.sub("", text).strip()
533
+ if body:
534
+ parts.append(body)
535
+ if not parts:
536
+ return None
537
+ return " ".join(parts)
538
+
539
+
540
+ def _extract_reducer(comments: list[str]) -> str | None:
541
+ """Return the ``@reducer <name>`` from the comment list, or None.
542
+
543
+ Raises ``ValueError`` if more than one ``@reducer`` annotation
544
+ is present — the caller wraps it in a ``ValidationError`` so
545
+ duplicates surface instead of being silently dropped.
546
+ """
547
+ matches = [
548
+ m.group(1) for text in comments if (m := _RE_REDUCER.match(text))
549
+ ]
550
+ if len(matches) > 1:
551
+ raise ValueError(
552
+ f"multiple @reducer annotations {matches}; declare at most one."
553
+ )
554
+ return matches[0] if matches else None