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,299 @@
1
+ """Stage 1: Parse PlusCal source with tree-sitter-tlaplus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from collections.abc import Iterator
7
+ from typing import Any
8
+
9
+ import tree_sitter
10
+ import tree_sitter_tlaplus as tstla
11
+
12
+ from .models import ValidationError
13
+
14
+ # Module-level singleton -- built once, reused across calls.
15
+ # tree-sitter-tlaplus returns an int pointer; suppress the
16
+ # deprecation warning until it ships a PyCapsule API.
17
+ with warnings.catch_warnings():
18
+ warnings.simplefilter("ignore", DeprecationWarning)
19
+ LANGUAGE = tree_sitter.Language(tstla.language())
20
+
21
+
22
+ def _make_parser() -> tree_sitter.Parser:
23
+ return tree_sitter.Parser(LANGUAGE)
24
+
25
+
26
+ def parse(
27
+ source: str,
28
+ ) -> tuple[tree_sitter.Node, str] | list[ValidationError]:
29
+ """Parse PlusCal source and return the algorithm AST node.
30
+
31
+ Parameters
32
+ ----------
33
+ source
34
+ Full TLA+ module text containing a PlusCal algorithm.
35
+
36
+ Returns
37
+ -------
38
+ tuple[Node, str] | list[ValidationError]
39
+ On success: (pcal_algorithm node, module_name).
40
+ On failure: list of parse errors.
41
+ """
42
+ parser = _make_parser()
43
+ tree = parser.parse(bytes(source, "utf-8"))
44
+ root = tree.root_node
45
+
46
+ errors = _collect_errors(root)
47
+ if errors:
48
+ return errors
49
+
50
+ module_name = _extract_module_name(root)
51
+ if module_name is None:
52
+ return [
53
+ ValidationError(
54
+ stage="parse",
55
+ message="No TLA+ module found.",
56
+ line=1,
57
+ )
58
+ ]
59
+
60
+ algo = _find_algorithm(root)
61
+ if algo is None:
62
+ return [
63
+ ValidationError(
64
+ stage="parse",
65
+ message="No PlusCal algorithm block found.",
66
+ line=1,
67
+ )
68
+ ]
69
+
70
+ return (algo, module_name)
71
+
72
+
73
+ def _collect_errors(
74
+ root: tree_sitter.Node,
75
+ ) -> list[ValidationError]:
76
+ """Walk the tree and collect ERROR / MISSING nodes."""
77
+ errors: list[ValidationError] = []
78
+
79
+ def _walk(node: tree_sitter.Node) -> None:
80
+ if node.type == "ERROR" or node.is_missing:
81
+ line = node.start_point[0] + 1
82
+ text = (node.text or b"").decode("utf-8", errors="replace")
83
+ snippet = text[:60] if text else ""
84
+ msg = (
85
+ f"Syntax error near: {snippet!r}"
86
+ if snippet
87
+ else "Unexpected syntax"
88
+ )
89
+ errors.append(
90
+ ValidationError(stage="parse", message=msg, line=line)
91
+ )
92
+ for child in node.children:
93
+ _walk(child)
94
+
95
+ _walk(root)
96
+ return errors
97
+
98
+
99
+ def _extract_module_name(
100
+ root: tree_sitter.Node,
101
+ ) -> str | None:
102
+ """Extract the TLA+ module name from the header."""
103
+ for node in root.children:
104
+ if node.type == "module":
105
+ for child in node.children:
106
+ if child.type == "identifier":
107
+ return (child.text or b"").decode("utf-8")
108
+ return None
109
+
110
+
111
+ def _find_algorithm(
112
+ root: tree_sitter.Node,
113
+ ) -> tree_sitter.Node | None:
114
+ """Find the pcal_algorithm node inside a block_comment."""
115
+ for node in root.children:
116
+ if node.type == "module":
117
+ for child in node.children:
118
+ if child.type == "block_comment":
119
+ for gc in child.children:
120
+ if gc.type == "pcal_algorithm":
121
+ return gc
122
+ return None
123
+
124
+
125
+ # ── AST helpers used by later stages ──
126
+
127
+
128
+ def iter_children(
129
+ node: tree_sitter.Node,
130
+ *types: str,
131
+ ) -> list[tree_sitter.Node]:
132
+ """Return direct children matching any of the given types."""
133
+ return [c for c in node.children if c.type in types]
134
+
135
+
136
+ def find_child(
137
+ node: tree_sitter.Node,
138
+ node_type: str,
139
+ ) -> tree_sitter.Node | None:
140
+ """Return the first direct child of the given type."""
141
+ for c in node.children:
142
+ if c.type == node_type:
143
+ return c
144
+ return None
145
+
146
+
147
+ def node_text(node: tree_sitter.Node) -> str:
148
+ """Decode a node's text as UTF-8."""
149
+ return (node.text or b"").decode("utf-8")
150
+
151
+
152
+ def walk_all(
153
+ node: tree_sitter.Node,
154
+ ) -> Iterator[tree_sitter.Node]:
155
+ """Recursively yield all descendant nodes (pre-order)."""
156
+ yield node
157
+ for c in node.children:
158
+ yield from walk_all(c)
159
+
160
+
161
+ def extract_variables(
162
+ algo: tree_sitter.Node,
163
+ ) -> list[dict[str, Any]]:
164
+ r"""Extract variable declarations from pcal_var_decls.
165
+
166
+ Returns a list of dicts: ``{name, type, default, line,
167
+ comments}``. ``comments`` is the list of comment texts that
168
+ appeared between the previous declaration (or the
169
+ ``variables`` keyword) and this one — used downstream to
170
+ associate ``\* @reducer ...`` annotations with their variable.
171
+ """
172
+ var_decls = find_child(algo, "pcal_var_decls")
173
+ if var_decls is None:
174
+ return []
175
+
176
+ results: list[dict[str, Any]] = []
177
+ pending: list[str] = []
178
+ for child in var_decls.children:
179
+ if child.type == "comment":
180
+ pending.append(node_text(child))
181
+ continue
182
+ if child.type != "pcal_var_decl":
183
+ continue
184
+ name_node = find_child(child, "identifier")
185
+ if name_node is None:
186
+ pending = []
187
+ continue
188
+ name = node_text(name_node)
189
+ val_type, default = _infer_var_type(child)
190
+ results.append(
191
+ {
192
+ "name": name,
193
+ "type": val_type,
194
+ "default": default,
195
+ "line": child.start_point[0] + 1,
196
+ "comments": pending,
197
+ }
198
+ )
199
+ pending = []
200
+ return results
201
+
202
+
203
+ def _infer_var_type(
204
+ decl: tree_sitter.Node,
205
+ ) -> tuple[str, Any]:
206
+ """Infer the type and default value from a pcal_var_decl."""
207
+ for child in decl.children:
208
+ if child.type == "nat_number":
209
+ return ("int", int(node_text(child)))
210
+ if child.type == "boolean":
211
+ val = node_text(child).strip()
212
+ return ("bool", val == "TRUE")
213
+ if child.type == "string":
214
+ raw = node_text(child)
215
+ return ("string", raw.strip('"'))
216
+ if child.type == "tuple_literal":
217
+ # Only empty tuples ``<<>>`` are supported as
218
+ # defaults; non-empty ones would need typed elements
219
+ # which the DSL doesn't model today.
220
+ if _is_empty_tuple(child):
221
+ return ("list", [])
222
+ return ("unknown", None)
223
+
224
+
225
+ def _is_empty_tuple(node: tree_sitter.Node) -> bool:
226
+ """True iff a ``tuple_literal`` has no value children.
227
+
228
+ ``<<>>`` parses to a ``tuple_literal`` whose only children
229
+ are the ``langle_bracket`` / ``rangle_bracket`` delimiters;
230
+ anything else (``<<1, 2>>``) adds value children in between.
231
+ """
232
+ for child in node.children:
233
+ if child.type not in ("langle_bracket", "rangle_bracket"):
234
+ return False
235
+ return True
236
+
237
+
238
+ def extract_defines(
239
+ algo: tree_sitter.Node,
240
+ ) -> list[str]:
241
+ """Extract operator names from the define block."""
242
+ defs = find_child(algo, "pcal_definitions")
243
+ if defs is None:
244
+ return []
245
+ names: list[str] = []
246
+ for op in iter_children(defs, "operator_definition"):
247
+ id_node = find_child(op, "identifier")
248
+ if id_node is not None:
249
+ names.append(node_text(id_node))
250
+ return names
251
+
252
+
253
+ def extract_constants(
254
+ algo: tree_sitter.Node,
255
+ ) -> dict[str, Any]:
256
+ """Extract simple constant definitions from the define block.
257
+
258
+ Returns a dict mapping names to their resolved values
259
+ for definitions like ``MaxIterations == 10``. Complex
260
+ expressions (operators, set membership) are skipped.
261
+ """
262
+ defs = find_child(algo, "pcal_definitions")
263
+ if defs is None:
264
+ return {}
265
+
266
+ constants: dict[str, Any] = {}
267
+ for op in iter_children(defs, "operator_definition"):
268
+ id_node = find_child(op, "identifier")
269
+ if id_node is None:
270
+ continue
271
+ name = node_text(id_node)
272
+
273
+ # Find the value node: the child after def_eq
274
+ # that is a simple literal.
275
+ children = list(op.children)
276
+ after_eq = False
277
+ for child in children:
278
+ if child.type == "def_eq":
279
+ after_eq = True
280
+ continue
281
+ if not after_eq:
282
+ continue
283
+ # Only extract simple literals.
284
+ if child.type == "nat_number":
285
+ constants[name] = int(node_text(child))
286
+ elif child.type == "boolean":
287
+ constants[name] = node_text(child) == "TRUE"
288
+ elif child.type == "string":
289
+ constants[name] = node_text(child).strip('"')
290
+ break
291
+
292
+ return constants
293
+
294
+
295
+ def extract_body(
296
+ algo: tree_sitter.Node,
297
+ ) -> tree_sitter.Node | None:
298
+ """Return the pcal_algorithm_body node."""
299
+ return find_child(algo, "pcal_algorithm_body")
@@ -0,0 +1,389 @@
1
+ """Stage 2: Profile check -- reject disallowed PlusCal constructs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any
8
+
9
+ import tree_sitter
10
+
11
+ from .known_recipes import CALLABLE_RECIPES
12
+ from .models import NodeAnnotation, ValidationError
13
+ from .parser import (
14
+ LANGUAGE,
15
+ extract_body,
16
+ node_text,
17
+ )
18
+
19
+ # ── Rejected constructs (tree-sitter query) ──
20
+
21
+ # Each capture name maps to an error message. The query
22
+ # matches all disallowed AST node types in a single pass.
23
+ _REJECTED_MESSAGES: dict[str, str] = {
24
+ "process": "Concurrent processes are not allowed.",
25
+ "procedure": "Procedures are not allowed.",
26
+ "macro": "Macros are not allowed.",
27
+ "return_": "'return' is not allowed (no procedures).",
28
+ "macro_call": "Macro calls are not allowed.",
29
+ "await_": ("'await'/'when' synchronization is not allowed."),
30
+ "set_comp": (
31
+ r"Set comprehensions {x \in S : P(x)} "
32
+ "are not allowed."
33
+ ),
34
+ "func_lit": (
35
+ r"Function definitions [x \in S |-> e] "
36
+ "are not allowed."
37
+ ),
38
+ "choose": "'CHOOSE' is not allowed.",
39
+ "quantifier": (r"Quantifiers (\A, \E) are not allowed."),
40
+ "either": "'either/or' nondeterminism is not allowed.",
41
+ }
42
+
43
+ _REJECTED_QUERY = tree_sitter.Query(
44
+ LANGUAGE,
45
+ """
46
+ (pcal_process) @process
47
+ (pcal_procedure) @procedure
48
+ (pcal_macro) @macro
49
+ (pcal_return) @return_
50
+ (pcal_macro_call) @macro_call
51
+ (pcal_await) @await_
52
+ (set_filter) @set_comp
53
+ (function_literal) @func_lit
54
+ (choose) @choose
55
+ (bounded_quantification) @quantifier
56
+ (unbounded_quantification) @quantifier
57
+ (pcal_either) @either
58
+ """,
59
+ )
60
+
61
+ # ── EXTENDS check (tree-sitter query) ──
62
+
63
+ ALLOWED_EXTENDS: frozenset[str] = frozenset({"Naturals"})
64
+
65
+ _EXTENDS_QUERY = tree_sitter.Query(
66
+ LANGUAGE,
67
+ "(extends (identifier_ref) @ext_module)",
68
+ )
69
+
70
+ # ── Annotation regex patterns ──
71
+
72
+ _RE_NODE = re.compile(r"\\[*]\s*@node\s+({.*})\s*$")
73
+ _RE_PROMPT = re.compile(r'\\[*]\s*@prompt\s+"((?:[^"\\]|\\.)*)"\s*$')
74
+ _RE_TOOLS = re.compile(r"\\[*]\s*@allowed_tools\s+(\[.*\])\s*$")
75
+ _RE_MCP = re.compile(r"\\[*]\s*@mcp_servers\s+(\[.*\])\s*$")
76
+ _RE_PERM = re.compile(r'\\[*]\s*@permission_mode\s+"(\w+)"\s*$')
77
+
78
+ # Valid function reference: bare name (built-in),
79
+ # module:function, or dotted.module:function (user code).
80
+ _RE_FUNC_REF = re.compile(
81
+ r"^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*(?::[a-zA-Z_]\w*)?$"
82
+ )
83
+
84
+ # @send annotation: fan-out over a list state field.
85
+ _RE_SEND = re.compile(r'\\[*]\s*@send\s+"(\w+)"\s*$')
86
+
87
+
88
+ def check_profile(
89
+ algo: tree_sitter.Node,
90
+ module_root: tree_sitter.Node,
91
+ ) -> list[ValidationError] | tuple[list[NodeAnnotation], dict[str, str]]:
92
+ """Validate the spec against the restricted PlusCal profile.
93
+
94
+ Parameters
95
+ ----------
96
+ algo
97
+ The pcal_algorithm AST node.
98
+ module_root
99
+ The top-level source_file node (for EXTENDS check).
100
+
101
+ Returns
102
+ -------
103
+ list[ValidationError] | tuple[list[NodeAnnotation], dict[str, str]]
104
+ Errors if violations found, otherwise a tuple of
105
+ (annotations, send_fields) where send_fields maps
106
+ label → state field name for Send fan-out.
107
+ """
108
+ errors: list[ValidationError] = []
109
+
110
+ errors.extend(_check_extends(module_root))
111
+ errors.extend(_check_rejected(algo))
112
+
113
+ if errors:
114
+ return errors
115
+
116
+ # Extract and validate annotations.
117
+ body = extract_body(algo)
118
+ if body is None:
119
+ return [
120
+ ValidationError(
121
+ stage="profile",
122
+ message="No algorithm body (begin..end) found.",
123
+ line=algo.start_point[0] + 1,
124
+ )
125
+ ]
126
+
127
+ return _extract_annotations(body)
128
+
129
+
130
+ def _check_extends(
131
+ root: tree_sitter.Node,
132
+ ) -> list[ValidationError]:
133
+ """Verify only allowed modules are extended."""
134
+ errors: list[ValidationError] = []
135
+ cursor = tree_sitter.QueryCursor(_EXTENDS_QUERY)
136
+ for _, captures in cursor.matches(root):
137
+ for node in captures.get("ext_module", []):
138
+ name = node_text(node)
139
+ if name not in ALLOWED_EXTENDS:
140
+ errors.append(
141
+ ValidationError(
142
+ stage="profile",
143
+ message=(
144
+ f"EXTENDS {name} is not allowed."
145
+ f" Only {ALLOWED_EXTENDS}"
146
+ " permitted."
147
+ ),
148
+ line=node.start_point[0] + 1,
149
+ )
150
+ )
151
+ return errors
152
+
153
+
154
+ def _check_rejected(
155
+ algo: tree_sitter.Node,
156
+ ) -> list[ValidationError]:
157
+ """Run the rejected-construct query in one pass."""
158
+ errors: list[ValidationError] = []
159
+ cursor = tree_sitter.QueryCursor(_REJECTED_QUERY)
160
+ for _, captures in cursor.matches(algo):
161
+ for capture_name, nodes in captures.items():
162
+ msg = _REJECTED_MESSAGES.get(capture_name, "")
163
+ for node in nodes:
164
+ errors.append(
165
+ ValidationError(
166
+ stage="profile",
167
+ message=msg,
168
+ line=node.start_point[0] + 1,
169
+ )
170
+ )
171
+ return errors
172
+
173
+
174
+ def _extract_annotations(
175
+ body: tree_sitter.Node,
176
+ ) -> list[ValidationError] | tuple[list[NodeAnnotation], dict[str, str]]:
177
+ """Extract @node and @send annotations from comments.
178
+
179
+ Returns (annotations, send_fields) on success, or
180
+ a list of errors.
181
+ """
182
+ annotations: list[NodeAnnotation] = []
183
+ send_fields: dict[str, str] = {}
184
+ errors: list[ValidationError] = []
185
+
186
+ children = list(body.children)
187
+ pending_comments: list[tuple[int, str]] = []
188
+
189
+ i = 0
190
+ while i < len(children):
191
+ child = children[i]
192
+
193
+ if child.type == "comment":
194
+ line = child.start_point[0] + 1
195
+ text = node_text(child)
196
+ pending_comments.append((line, text))
197
+ i += 1
198
+ continue
199
+
200
+ if child.type == "identifier":
201
+ if i + 1 < len(children) and node_text(children[i + 1]) == ":":
202
+ label = node_text(child)
203
+ line = child.start_point[0] + 1
204
+
205
+ if label == "Done":
206
+ pending_comments.clear()
207
+ i += 2
208
+ continue
209
+
210
+ result = _parse_annotation_block(label, line, pending_comments)
211
+ pending_comments.clear()
212
+
213
+ if isinstance(result, ValidationError):
214
+ errors.append(result)
215
+ else:
216
+ ann, send = result
217
+ annotations.append(ann)
218
+ if send:
219
+ send_fields[label] = send
220
+
221
+ i += 2
222
+ continue
223
+
224
+ if child.type not in ("begin", ";"):
225
+ pending_comments.clear()
226
+ i += 1
227
+
228
+ if errors:
229
+ return errors
230
+ return annotations, send_fields
231
+
232
+
233
+ def _parse_annotation_block( # noqa: PLR0911, PLR0915
234
+ label: str,
235
+ line: int,
236
+ comments: list[tuple[int, str]],
237
+ ) -> ValidationError | tuple[NodeAnnotation, str | None]:
238
+ """Parse @-comments into a NodeAnnotation + optional send field."""
239
+ node_json: dict[str, Any] | None = None
240
+ prompt: str = ""
241
+ tools: list[str] = []
242
+ mcp: list[str] = []
243
+ perm: str | None = None
244
+ send: str | None = None
245
+ node_line: int = line
246
+
247
+ for cline, text in comments:
248
+ m = _RE_NODE.match(text)
249
+ if m:
250
+ try:
251
+ node_json = json.loads(m.group(1))
252
+ except json.JSONDecodeError as e:
253
+ return ValidationError(
254
+ stage="profile",
255
+ message=(f"Invalid @node JSON at label '{label}': {e}"),
256
+ line=cline,
257
+ )
258
+ node_line = cline
259
+ continue
260
+
261
+ m = _RE_PROMPT.match(text)
262
+ if m:
263
+ prompt = m.group(1).replace("\\n", "\n")
264
+ continue
265
+
266
+ m = _RE_TOOLS.match(text)
267
+ if m:
268
+ try:
269
+ tools = json.loads(m.group(1))
270
+ except json.JSONDecodeError as e:
271
+ return ValidationError(
272
+ stage="profile",
273
+ message=(
274
+ f"Invalid @allowed_tools JSON at label '{label}': {e}"
275
+ ),
276
+ line=cline,
277
+ )
278
+ continue
279
+
280
+ m = _RE_MCP.match(text)
281
+ if m:
282
+ try:
283
+ mcp = json.loads(m.group(1))
284
+ except json.JSONDecodeError as e:
285
+ return ValidationError(
286
+ stage="profile",
287
+ message=(
288
+ f"Invalid @mcp_servers JSON at label '{label}': {e}"
289
+ ),
290
+ line=cline,
291
+ )
292
+ if not isinstance(mcp, list) or not all(
293
+ isinstance(s, str) for s in mcp
294
+ ):
295
+ return ValidationError(
296
+ stage="profile",
297
+ message=(
298
+ f"@mcp_servers must be a JSON list of"
299
+ f" strings at '{label}'"
300
+ ),
301
+ line=cline,
302
+ )
303
+ continue
304
+
305
+ m = _RE_PERM.match(text)
306
+ if m:
307
+ perm = m.group(1)
308
+ continue
309
+
310
+ m = _RE_SEND.match(text)
311
+ if m:
312
+ send = m.group(1)
313
+ continue
314
+
315
+ if re.search(r"\\[*]\s*@\w+", text):
316
+ return ValidationError(
317
+ stage="profile",
318
+ message=(
319
+ f"Unrecognized annotation in comment "
320
+ f"near label '{label}': {text!r}"
321
+ ),
322
+ line=cline,
323
+ )
324
+
325
+ if node_json is None:
326
+ return ValidationError(
327
+ stage="profile",
328
+ message=(f"Label '{label}' is missing a @node annotation."),
329
+ line=line,
330
+ )
331
+
332
+ func = node_json.pop("function", None)
333
+ if not func:
334
+ return ValidationError(
335
+ stage="profile",
336
+ message=(f"@node at label '{label}' is missing a 'function' key."),
337
+ line=node_line,
338
+ )
339
+
340
+ if not _RE_FUNC_REF.match(func):
341
+ return ValidationError(
342
+ stage="profile",
343
+ message=(
344
+ f"Invalid function reference '{func}' "
345
+ f"at label '{label}'. Use 'name' for "
346
+ "built-ins or 'module:function' for "
347
+ "user code."
348
+ ),
349
+ line=node_line,
350
+ )
351
+
352
+ # Validate "recipe" built-in: requires "name" arg that
353
+ # references a known recipe.
354
+ if func == "recipe":
355
+ name = node_json.get("name") or node_json.get("args", {}).get("name")
356
+ if not name:
357
+ return ValidationError(
358
+ stage="profile",
359
+ message=(
360
+ f"@node at label '{label}' uses function "
361
+ f"'recipe' but is missing a 'name' key."
362
+ ),
363
+ line=node_line,
364
+ )
365
+
366
+ if name not in CALLABLE_RECIPES:
367
+ return ValidationError(
368
+ stage="profile",
369
+ message=(
370
+ f"@node at label '{label}' references "
371
+ f"unknown recipe '{name}'. "
372
+ f"Known: {', '.join(sorted(CALLABLE_RECIPES))}."
373
+ ),
374
+ line=node_line,
375
+ )
376
+
377
+ args: dict[str, Any] = node_json.pop("args", {})
378
+ args.update(node_json)
379
+ if prompt:
380
+ args["prompt"] = prompt
381
+ if tools:
382
+ args["allowed_tools"] = tools
383
+ if mcp:
384
+ args["mcp_servers"] = mcp
385
+ if perm:
386
+ args["permission_mode"] = perm
387
+
388
+ annotation = NodeAnnotation(label=label, function=func, args=args)
389
+ return annotation, send
levain_recipe/py.typed ADDED
File without changes