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_recipe/graph.py ADDED
@@ -0,0 +1,813 @@
1
+ """Stage 3: Graph topology check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict, deque
6
+ from typing import Any
7
+
8
+ import tree_sitter
9
+
10
+ from .models import (
11
+ Condition,
12
+ Route,
13
+ RoutingTable,
14
+ ValidationError,
15
+ )
16
+ from .parser import (
17
+ extract_body,
18
+ find_child,
19
+ iter_children,
20
+ node_text,
21
+ walk_all,
22
+ )
23
+
24
+ # ── Operator mapping ──
25
+
26
+ _OP_MAP: dict[str, str] = {
27
+ "eq": "eq",
28
+ "neq": "neq",
29
+ "geq": "gte",
30
+ "leq": "lte",
31
+ "lt": "lt",
32
+ "gt": "gt",
33
+ }
34
+
35
+ _NEGATE_OP: dict[str, str] = {
36
+ "eq": "neq",
37
+ "neq": "eq",
38
+ "gte": "lt",
39
+ "lt": "gte",
40
+ "lte": "gt",
41
+ "gt": "lte",
42
+ }
43
+
44
+
45
+ def check_graph(
46
+ algo: tree_sitter.Node,
47
+ variables: list[dict[str, object]],
48
+ send_fields: dict[str, str] | None = None,
49
+ *,
50
+ terminal_labels: set[str] | frozenset[str] = frozenset(),
51
+ ) -> list[RoutingTable] | list[ValidationError]:
52
+ """Extract and validate the graph topology.
53
+
54
+ Parameters
55
+ ----------
56
+ algo
57
+ The pcal_algorithm AST node.
58
+ variables
59
+ Variable declarations (from parser.extract_variables).
60
+ send_fields
61
+ Map of label → state field name for Send fan-out.
62
+ Extracted from ``@send`` annotations by the profile
63
+ checker.
64
+ terminal_labels
65
+ Labels that count as termination for reachability —
66
+ currently the set of labels whose ``@node`` function is
67
+ ``interrupt``. An interrupt-paused turn IS a clean turn
68
+ end; the next dispatch resumes the graph. Without this,
69
+ a chat-shape recipe with ``Respond -> Wait -> Respond``
70
+ loop would fail "cannot reach Done."
71
+
72
+ Returns
73
+ -------
74
+ list[RoutingTable] | list[ValidationError]
75
+ The routing tables on success, or errors.
76
+ """
77
+ body = extract_body(algo)
78
+ if body is None:
79
+ return [
80
+ ValidationError(
81
+ stage="graph",
82
+ message="No algorithm body found.",
83
+ line=algo.start_point[0] + 1,
84
+ )
85
+ ]
86
+
87
+ labels, label_stmts = _extract_labels(body)
88
+ if not labels:
89
+ return [
90
+ ValidationError(
91
+ stage="graph",
92
+ message="No labels found in algorithm body.",
93
+ line=body.start_point[0] + 1,
94
+ )
95
+ ]
96
+
97
+ routing = _extract_routing(labels, label_stmts, send_fields or {})
98
+
99
+ # Flatten routing tables for structural checks.
100
+ edges = _edges_from_routing(routing)
101
+
102
+ errors: list[ValidationError] = []
103
+ all_labels = set(labels) | {"Done"}
104
+ for src, tgt in edges:
105
+ if tgt not in all_labels:
106
+ errors.append(
107
+ ValidationError(
108
+ stage="graph",
109
+ message=(
110
+ f"goto '{tgt}' at label '{src}' "
111
+ f"targets an undefined label."
112
+ ),
113
+ )
114
+ )
115
+ errors.extend(_check_reachability(labels, edges))
116
+ errors.extend(
117
+ _check_termination(labels, edges, terminal_labels=terminal_labels)
118
+ )
119
+ errors.extend(_check_condition_completeness(label_stmts))
120
+ errors.extend(_check_parseable_conditions(label_stmts))
121
+ errors.extend(_check_assignments(label_stmts, variables))
122
+ errors.extend(_check_with_types(label_stmts, variables))
123
+
124
+ if errors:
125
+ return errors
126
+ return routing
127
+
128
+
129
+ def _edges_from_routing(
130
+ routing: list[RoutingTable],
131
+ ) -> list[tuple[str, str]]:
132
+ """Flatten routing tables to (source, target) pairs."""
133
+ edges: list[tuple[str, str]] = []
134
+ for table in routing:
135
+ for route in table.routes:
136
+ edges.append((table.source, route.target))
137
+ return edges
138
+
139
+
140
+ # ── Label extraction ──
141
+
142
+
143
+ def _extract_labels(
144
+ body: tree_sitter.Node,
145
+ ) -> tuple[list[str], dict[str, list[tree_sitter.Node]]]:
146
+ """Extract label names and their associated statements.
147
+
148
+ Returns (ordered_labels, {label: [stmt_nodes]}).
149
+ """
150
+ labels: list[str] = []
151
+ label_stmts: dict[str, list[tree_sitter.Node]] = {}
152
+
153
+ children = list(body.children)
154
+ current_label: str | None = None
155
+ current_stmts: list[tree_sitter.Node] = []
156
+
157
+ i = 0
158
+ while i < len(children):
159
+ child = children[i]
160
+
161
+ if child.type == "identifier":
162
+ if i + 1 < len(children) and node_text(children[i + 1]) == ":":
163
+ if current_label is not None:
164
+ label_stmts[current_label] = current_stmts
165
+ current_label = node_text(child)
166
+ labels.append(current_label)
167
+ current_stmts = []
168
+ i += 2
169
+ continue
170
+
171
+ if current_label is not None and child.type not in (
172
+ "begin",
173
+ "comment",
174
+ ";",
175
+ ):
176
+ current_stmts.append(child)
177
+
178
+ i += 1
179
+
180
+ if current_label is not None:
181
+ label_stmts[current_label] = current_stmts
182
+
183
+ return labels, label_stmts
184
+
185
+
186
+ # ── Routing extraction ──
187
+
188
+
189
+ def _extract_routing(
190
+ labels: list[str],
191
+ label_stmts: dict[str, list[tree_sitter.Node]],
192
+ send_fields: dict[str, str],
193
+ ) -> list[RoutingTable]:
194
+ """Build routing tables from gotos and if/else."""
195
+ tables: list[RoutingTable] = []
196
+
197
+ for idx, label in enumerate(labels):
198
+ stmts = label_stmts.get(label, [])
199
+ raw_routes = _routes_from_stmts(stmts)
200
+ send_field = send_fields.get(label)
201
+
202
+ has_while = any(s.type == "pcal_while" for s in stmts)
203
+ if has_while:
204
+ # While loop: add self-edge (loop-back) and
205
+ # fallthrough (exit) to next label.
206
+ raw_routes.append((label, []))
207
+ if idx + 1 < len(labels):
208
+ raw_routes.append((labels[idx + 1], []))
209
+ elif not raw_routes:
210
+ # Implicit fallthrough: no routes at all.
211
+ if idx + 1 < len(labels):
212
+ raw_routes.append((labels[idx + 1], []))
213
+
214
+ routes = [
215
+ Route(
216
+ target=target,
217
+ conditions=conds,
218
+ send_field=send_field,
219
+ )
220
+ for target, conds in raw_routes
221
+ ]
222
+
223
+ if routes:
224
+ tables.append(RoutingTable(source=label, routes=routes))
225
+
226
+ return tables
227
+
228
+
229
+ # Type alias for intermediate route data.
230
+ _RawRoute = tuple[str, list[Condition]]
231
+
232
+
233
+ def _routes_from_stmts(
234
+ stmts: list[tree_sitter.Node],
235
+ ) -> list[_RawRoute]:
236
+ """Collect (target, conditions) from goto/if nodes."""
237
+ results: list[_RawRoute] = []
238
+
239
+ for stmt in stmts:
240
+ if stmt.type == "pcal_goto":
241
+ target_node = find_child(stmt, "identifier")
242
+ if target_node:
243
+ results.append((node_text(target_node), []))
244
+
245
+ elif stmt.type == "pcal_if":
246
+ results.extend(_routes_from_if(stmt))
247
+
248
+ elif stmt.type == "pcal_while":
249
+ results.extend(_routes_from_while(stmt))
250
+
251
+ elif stmt.type == "pcal_with":
252
+ inner = [
253
+ c
254
+ for c in stmt.children
255
+ if c.type
256
+ not in (
257
+ "with",
258
+ "do",
259
+ "identifier",
260
+ "set_in",
261
+ "finite_set_literal",
262
+ "pcal_end_with",
263
+ ";",
264
+ )
265
+ ]
266
+ results.extend(_routes_from_stmts(inner))
267
+
268
+ return results
269
+
270
+
271
+ _IfBranch = tuple[list[tree_sitter.Node] | None, list[tree_sitter.Node]]
272
+
273
+
274
+ def _if_branches(if_node: tree_sitter.Node) -> list[_IfBranch]:
275
+ """Split a ``pcal_if`` into its branches.
276
+
277
+ tree-sitter-tlaplus emits ``elsif`` / ``else`` as *flat sibling
278
+ tokens* of the ``pcal_if`` node — there is no ``pcal_elsif``
279
+ subnode — so branch boundaries must be re-derived from the token
280
+ stream: each conditioned branch is its keyword, the condition
281
+ parts up to ``then``, and the statements up to the next boundary.
282
+ Returns ``(condition_parts, stmts)`` per branch, with
283
+ ``condition_parts=None`` for the else branch.
284
+
285
+ The old walk assumed nested ``pcal_elsif`` nodes and swallowed
286
+ every elsif (keyword, condition, and body) into the first branch's
287
+ statements — which flattened every elsif chain into "first
288
+ condition ANDed onto all branches" and left whole subgraphs
289
+ unreachable at runtime.
290
+ """
291
+ children = list(if_node.children)
292
+ branches: list[_IfBranch] = []
293
+ i, n = 0, len(children)
294
+ while i < n:
295
+ child = children[i]
296
+ if child.type in ("if", "elsif"):
297
+ i += 1
298
+ cond_parts: list[tree_sitter.Node] = []
299
+ while i < n and children[i].type != "then":
300
+ cond_parts.append(children[i])
301
+ i += 1
302
+ i += 1 # consume 'then'
303
+ stmts: list[tree_sitter.Node] = []
304
+ while i < n and children[i].type not in (
305
+ "elsif",
306
+ "else",
307
+ "pcal_end_if",
308
+ ):
309
+ stmts.append(children[i])
310
+ i += 1
311
+ branches.append((cond_parts, stmts))
312
+ continue
313
+ if child.type == "else":
314
+ i += 1
315
+ stmts = []
316
+ while i < n and children[i].type != "pcal_end_if":
317
+ stmts.append(children[i])
318
+ i += 1
319
+ branches.append((None, stmts))
320
+ continue
321
+ i += 1
322
+ return branches
323
+
324
+
325
+ def _routes_from_if(
326
+ if_node: tree_sitter.Node,
327
+ ) -> list[_RawRoute]:
328
+ r"""Extract structured routes from an if/elsif/else chain.
329
+
330
+ Branch *k*'s routes carry the negation of every earlier branch
331
+ condition ANDed with its own (Route conditions are ANDed); the
332
+ else branch carries all the negations. A disjunctive condition
333
+ (``a = TRUE \/ b = TRUE``) expands into one route per disjunct —
334
+ its negation is the conjunction of the negated disjuncts
335
+ (De Morgan), which fits the ANDed-conditions model directly.
336
+
337
+ A conditioned branch whose condition cannot be parsed contributes
338
+ no routes — never an unconditional catch-all —
339
+ ``_check_parseable_conditions`` rejects the spec loudly instead.
340
+ """
341
+ results: list[_RawRoute] = []
342
+ neg_stack: list[Condition] = []
343
+ for cond_parts, stmts in _if_branches(if_node):
344
+ inner_routes = _routes_from_stmts(stmts)
345
+ if cond_parts is None:
346
+ for target, inner_conds in inner_routes:
347
+ results.append((target, [*neg_stack, *inner_conds]))
348
+ continue
349
+ disjuncts = _parse_condition_list(cond_parts)
350
+ if disjuncts is None:
351
+ continue
352
+ for target, inner_conds in inner_routes:
353
+ for disjunct in disjuncts:
354
+ results.append((target, [*neg_stack, disjunct, *inner_conds]))
355
+ neg_stack.extend(_negate(d) for d in disjuncts)
356
+ return results
357
+
358
+
359
+ def _routes_from_while(
360
+ while_node: tree_sitter.Node,
361
+ ) -> list[_RawRoute]:
362
+ """Extract routes from a while loop body."""
363
+ body_stmts: list[tree_sitter.Node] = []
364
+ in_body = False
365
+ for child in while_node.children:
366
+ if child.type == "do":
367
+ in_body = True
368
+ elif in_body and child.type != "pcal_end_while":
369
+ body_stmts.append(child)
370
+ return _routes_from_stmts(body_stmts)
371
+
372
+
373
+ # ── Condition parsing ──
374
+
375
+
376
+ def _parse_condition_list(
377
+ parts: list[tree_sitter.Node],
378
+ ) -> list[Condition] | None:
379
+ r"""Parse a branch condition into its disjuncts.
380
+
381
+ A simple comparison yields one Condition; a top-level ``\/``
382
+ chain yields one per disjunct (each must itself be a simple
383
+ comparison). Anything else — conjunctions, bare booleans,
384
+ prefix negation — is unsupported and yields None so the caller
385
+ fails loudly instead of guessing.
386
+ """
387
+ for part in parts:
388
+ if part.type == "bound_infix_op":
389
+ return _parse_disjuncts(part)
390
+ return None
391
+
392
+
393
+ def _parse_disjuncts(
394
+ node: tree_sitter.Node,
395
+ ) -> list[Condition] | None:
396
+ children = list(node.children)
397
+ if len(children) == 3 and children[1].type == "lor":
398
+ lhs, _op, rhs = children
399
+ if lhs.type != "bound_infix_op" or rhs.type != "bound_infix_op":
400
+ return None
401
+ left = _parse_disjuncts(lhs)
402
+ right = _parse_disjuncts(rhs)
403
+ if left is None or right is None:
404
+ return None
405
+ return [*left, *right]
406
+ cond = _parse_infix(node)
407
+ return [cond] if cond is not None else None
408
+
409
+
410
+ def _parse_infix(
411
+ node: tree_sitter.Node,
412
+ ) -> Condition | None:
413
+ """Parse ``field OP value`` from a bound_infix_op."""
414
+ children = list(node.children)
415
+ if len(children) != 3:
416
+ return None
417
+
418
+ lhs, op_node, rhs = children
419
+
420
+ # Determine field name (LHS).
421
+ field_name: str | None = None
422
+ if lhs.type == "identifier_ref":
423
+ field_name = node_text(lhs)
424
+ if field_name is None:
425
+ return None
426
+
427
+ # Determine operator.
428
+ op = _OP_MAP.get(op_node.type)
429
+ if op is None:
430
+ return None
431
+
432
+ # Determine value (RHS).
433
+ value = _parse_value(rhs)
434
+ if value is None:
435
+ # RHS might be an identifier_ref (e.g., MaxIterations).
436
+ if rhs.type == "identifier_ref":
437
+ value = node_text(rhs)
438
+ else:
439
+ return None
440
+
441
+ return Condition(field=field_name, op=op, value=value)
442
+
443
+
444
+ def _parse_value(node: tree_sitter.Node) -> Any:
445
+ """Extract a literal value from an AST node."""
446
+ if node.type == "boolean":
447
+ return node_text(node).strip() == "TRUE"
448
+ if node.type == "nat_number":
449
+ return int(node_text(node))
450
+ if node.type == "string":
451
+ return node_text(node).strip('"')
452
+ return None
453
+
454
+
455
+ def _negate(cond: Condition) -> Condition:
456
+ """Return the logical negation of a condition."""
457
+ neg_op = _NEGATE_OP.get(cond.op)
458
+ if neg_op is None:
459
+ raise ValueError(f"Cannot negate operator {cond.op!r}")
460
+ return Condition(field=cond.field, op=neg_op, value=cond.value)
461
+
462
+
463
+ # ── Reachability ──
464
+
465
+
466
+ def _check_reachability(
467
+ labels: list[str],
468
+ edges: list[tuple[str, str]],
469
+ ) -> list[ValidationError]:
470
+ """BFS from first label: all labels must be reachable."""
471
+ if not labels:
472
+ return []
473
+
474
+ adj: dict[str, set[str]] = defaultdict(set)
475
+ for source, target in edges:
476
+ adj[source].add(target)
477
+
478
+ reachable: set[str] = set()
479
+ queue: deque[str] = deque([labels[0]])
480
+ while queue:
481
+ node = queue.popleft()
482
+ if node in reachable:
483
+ continue
484
+ reachable.add(node)
485
+ for target in adj[node]:
486
+ if target not in reachable:
487
+ queue.append(target)
488
+
489
+ unreachable = set(labels) - reachable
490
+
491
+ errors: list[ValidationError] = []
492
+ for label in sorted(unreachable):
493
+ errors.append(
494
+ ValidationError(
495
+ stage="graph",
496
+ message=(
497
+ f"Label '{label}' is not reachable from '{labels[0]}'."
498
+ ),
499
+ )
500
+ )
501
+ return errors
502
+
503
+
504
+ def _check_termination(
505
+ labels: list[str],
506
+ edges: list[tuple[str, str]],
507
+ *,
508
+ terminal_labels: set[str] | frozenset[str] = frozenset(),
509
+ ) -> list[ValidationError]:
510
+ """Reverse BFS from Done (or any terminal label): all labels reach a sink.
511
+
512
+ ``terminal_labels`` lists labels that count as termination
513
+ for reachability purposes — used for ``interrupt`` nodes,
514
+ which pause the graph indefinitely waiting for the next
515
+ user message. An interrupt-paused turn IS a clean turn
516
+ end; the next turn re-enters the graph from the interrupt.
517
+ """
518
+ reverse_adj: dict[str, set[str]] = defaultdict(set)
519
+ for source, target in edges:
520
+ reverse_adj[target].add(source)
521
+
522
+ can_reach_done: set[str] = set()
523
+ seeds: list[str] = ["Done", *terminal_labels]
524
+ queue: deque[str] = deque(seeds)
525
+ while queue:
526
+ node = queue.popleft()
527
+ if node in can_reach_done:
528
+ continue
529
+ can_reach_done.add(node)
530
+ for source in reverse_adj[node]:
531
+ if source not in can_reach_done:
532
+ queue.append(source)
533
+
534
+ stuck = set(labels) - can_reach_done - {"Done"}
535
+
536
+ errors: list[ValidationError] = []
537
+ targets = {t for _, t in edges}
538
+ has_done_path = "Done" in targets or "Done" in set(labels)
539
+ # At least one sink (Done or an interrupt/terminal label) must
540
+ # exist. Per-node reachability is enforced by the ``stuck``
541
+ # set above — this outer check only verifies that a sink
542
+ # exists at all so the graph has somewhere to terminate.
543
+ has_terminal_path = bool(terminal_labels)
544
+ if not has_done_path and not has_terminal_path:
545
+ errors.append(
546
+ ValidationError(
547
+ stage="graph",
548
+ message=(
549
+ "No transition targets 'Done'. "
550
+ "The algorithm cannot terminate."
551
+ ),
552
+ )
553
+ )
554
+
555
+ for label in sorted(stuck):
556
+ errors.append(
557
+ ValidationError(
558
+ stage="graph",
559
+ message=(f"Label '{label}' cannot reach 'Done'."),
560
+ )
561
+ )
562
+ return errors
563
+
564
+
565
+ # ── Condition completeness ──
566
+
567
+
568
+ def _check_condition_completeness(
569
+ label_stmts: dict[str, list[tree_sitter.Node]],
570
+ ) -> list[ValidationError]:
571
+ """Every if must have an else branch."""
572
+ errors: list[ValidationError] = []
573
+
574
+ for label, stmts in label_stmts.items():
575
+ for stmt in stmts:
576
+ for node in walk_all(stmt):
577
+ if node.type not in ("pcal_if", "pcal_elsif"):
578
+ continue
579
+ has_else = any(
580
+ c.type in ("else", "pcal_elsif") for c in node.children
581
+ )
582
+ if not has_else:
583
+ construct = "elsif" if node.type == "pcal_elsif" else "if"
584
+ errors.append(
585
+ ValidationError(
586
+ stage="graph",
587
+ message=(
588
+ f"'{construct}' at label "
589
+ f"'{label}' has no 'else' "
590
+ f"branch. All conditionals "
591
+ f"must have a default path."
592
+ ),
593
+ line=(node.start_point[0] + 1),
594
+ )
595
+ )
596
+
597
+ return errors
598
+
599
+
600
+ def _check_parseable_conditions(
601
+ label_stmts: dict[str, list[tree_sitter.Node]],
602
+ ) -> list[ValidationError]:
603
+ """Reject if/elsif conditions the router cannot parse.
604
+
605
+ Bare boolean identifiers (``if flag then``), prefix
606
+ negation (``if ~flag then``), and compound conjunctions
607
+ are not supported. Authors must use explicit comparisons
608
+ like ``flag = TRUE`` or ``flag = FALSE``.
609
+ """
610
+ errors: list[ValidationError] = []
611
+
612
+ for label, stmts in label_stmts.items():
613
+ for stmt in stmts:
614
+ for node in walk_all(stmt):
615
+ if node.type != "pcal_if":
616
+ continue
617
+ # Every branch of the chain — the old walk only ever
618
+ # saw the first ``if`` condition, so unparseable elsif
619
+ # conditions slipped through while their branches were
620
+ # silently dropped from routing.
621
+ for cond_parts, _branch_stmts in _if_branches(node):
622
+ if cond_parts is None:
623
+ continue
624
+ meaningful = [
625
+ p for p in cond_parts if p.type not in ("(", ")")
626
+ ]
627
+ if (
628
+ meaningful
629
+ and _parse_condition_list(cond_parts) is None
630
+ ):
631
+ snippet = " ".join(node_text(p) for p in meaningful)
632
+ errors.append(
633
+ ValidationError(
634
+ stage="graph",
635
+ message=(
636
+ f"Condition at label '{label}' "
637
+ f"cannot be parsed: {snippet!r}. "
638
+ f"Use explicit comparisons "
639
+ f"(e.g. x = TRUE), optionally "
640
+ f"joined with \\/."
641
+ ),
642
+ line=node.start_point[0] + 1,
643
+ )
644
+ )
645
+
646
+ return errors
647
+
648
+
649
+ # ── State consistency ──
650
+
651
+
652
+ def _check_assignments(
653
+ label_stmts: dict[str, list[tree_sitter.Node]],
654
+ variables: list[dict[str, object]],
655
+ ) -> list[ValidationError]:
656
+ """Every assignment must target a declared variable."""
657
+ var_names = {str(v["name"]) for v in variables}
658
+ errors: list[ValidationError] = []
659
+
660
+ for label, stmts in label_stmts.items():
661
+ for stmt in stmts:
662
+ for node in walk_all(stmt):
663
+ if node.type != "pcal_assign":
664
+ continue
665
+ lhs = find_child(node, "pcal_lhs")
666
+ if lhs is None:
667
+ continue
668
+ ref = find_child(lhs, "identifier_ref")
669
+ if ref is None:
670
+ continue
671
+ name = node_text(ref)
672
+ if name not in var_names:
673
+ errors.append(
674
+ ValidationError(
675
+ stage="graph",
676
+ message=(
677
+ f"Assignment to '{name}' "
678
+ f"at label '{label}' — "
679
+ "not a declared variable."
680
+ ),
681
+ line=node.start_point[0] + 1,
682
+ )
683
+ )
684
+
685
+ return errors
686
+
687
+
688
+ # ── With-block type validation ──
689
+
690
+ _TYPE_TO_NODE_TYPES: dict[str, set[str]] = {
691
+ "bool": {"boolean"},
692
+ "int": {"nat_number"},
693
+ "string": {"string"},
694
+ }
695
+
696
+ _BOOL_FULL = {"TRUE", "FALSE"}
697
+
698
+
699
+ def _check_with_types(
700
+ label_stmts: dict[str, list[tree_sitter.Node]],
701
+ variables: list[dict[str, object]],
702
+ ) -> list[ValidationError]:
703
+ """Validate with-block value sets against variable types."""
704
+ var_types = {str(v["name"]): str(v["type"]) for v in variables}
705
+ errors: list[ValidationError] = []
706
+
707
+ for label, stmts in label_stmts.items():
708
+ for stmt in stmts:
709
+ for node in walk_all(stmt):
710
+ if node.type != "pcal_with":
711
+ continue
712
+ errors.extend(_check_one_with(node, label, var_types))
713
+
714
+ return errors
715
+
716
+
717
+ def _check_one_with(
718
+ with_node: tree_sitter.Node,
719
+ label: str,
720
+ var_types: dict[str, str],
721
+ ) -> list[ValidationError]:
722
+ """Check a single with block."""
723
+ errors: list[ValidationError] = []
724
+ line = with_node.start_point[0] + 1
725
+
726
+ value_set = find_child(with_node, "finite_set_literal")
727
+ if value_set is None:
728
+ return errors
729
+
730
+ with_var = find_child(with_node, "identifier")
731
+ if with_var is None:
732
+ return errors
733
+ with_var_name = node_text(with_var)
734
+
735
+ assigned_var = _find_with_target(with_node, with_var_name)
736
+ if assigned_var is None:
737
+ return errors
738
+
739
+ var_type = var_types.get(assigned_var)
740
+ if var_type is None:
741
+ return errors
742
+
743
+ expected_node_types = _TYPE_TO_NODE_TYPES.get(var_type, set())
744
+ if not expected_node_types:
745
+ return errors
746
+
747
+ values = iter_children(value_set, "boolean", "nat_number", "string")
748
+
749
+ pre_count = len(errors)
750
+ for val_node in values:
751
+ if val_node.type not in expected_node_types:
752
+ errors.append(
753
+ ValidationError(
754
+ stage="graph",
755
+ message=(
756
+ f"'with' at label '{label}' has "
757
+ f"value {node_text(val_node)!r} "
758
+ f"but '{assigned_var}' is "
759
+ f"declared as {var_type}."
760
+ ),
761
+ line=line,
762
+ )
763
+ )
764
+
765
+ if var_type == "bool" and values and len(errors) == pre_count:
766
+ present = {node_text(v) for v in values if v.type == "boolean"}
767
+ missing = _BOOL_FULL - present
768
+ if missing:
769
+ errors.append(
770
+ ValidationError(
771
+ stage="graph",
772
+ message=(
773
+ f"'with' at label '{label}' "
774
+ f"assigns to bool "
775
+ f"'{assigned_var}' but only "
776
+ f"covers {present}. Missing: "
777
+ f"{sorted(missing)}."
778
+ ),
779
+ line=line,
780
+ )
781
+ )
782
+
783
+ return errors
784
+
785
+
786
+ def _find_with_target(
787
+ with_node: tree_sitter.Node,
788
+ with_var_name: str,
789
+ ) -> str | None:
790
+ """Find the state variable assigned from the with-var."""
791
+ for node in walk_all(with_node):
792
+ if node.type != "pcal_assign":
793
+ continue
794
+ lhs = find_child(node, "pcal_lhs")
795
+ if lhs is None:
796
+ continue
797
+ lhs_positions = {
798
+ (d.start_point, d.end_point)
799
+ for d in walk_all(lhs)
800
+ if d.type == "identifier_ref"
801
+ }
802
+ rhs_refs = [
803
+ d
804
+ for d in walk_all(node)
805
+ if d.type == "identifier_ref"
806
+ and (d.start_point, d.end_point) not in lhs_positions
807
+ ]
808
+ if not any(node_text(r) == with_var_name for r in rhs_refs):
809
+ continue
810
+ lhs_ref = find_child(lhs, "identifier_ref")
811
+ if lhs_ref is not None:
812
+ return node_text(lhs_ref)
813
+ return None