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/parser.py
ADDED
|
@@ -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")
|
levain_recipe/profile.py
ADDED
|
@@ -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
|