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
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
|