-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvalidator.py
More file actions
345 lines (297 loc) · 12.9 KB
/
Copy pathvalidator.py
File metadata and controls
345 lines (297 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
"""WosoolDSLValidator: the compiler frontend. A deterministic, side-effect-free static-analysis
pass that admits a safe program or rejects an unsafe one with structured diagnostics.
Pipeline (wosool-dsl/03-VALIDATION-AND-TYPES.md): V1 schema → V2 reference integrity →
V3 acyclicity (Kahn) → V4 whitelist → V5 argument typing → V6 reachability/terminality/forward-
refs. Each pass appends diagnostics; ERROR-severity blocks admission. V1 is fatal (no model to
analyse), so it returns early; the rest run independently so one program can surface every fault.
"""
from __future__ import annotations
from typing import Any
from pydantic import ValidationError
from nilscript.kernel.context import ValidationContext
from nilscript.kernel.diagnostics import DiagnosticCollector, ValidationResult
from nilscript.kernel.models import (
ActionNode,
AwaitApprovalNode,
ConditionNode,
ForeachNode,
NotifyNode,
ParallelNode,
QueryNode,
WosoolProgram,
)
from nilscript.kernel.references import (
Reference,
iter_full_references,
iter_references,
parse_reference,
references_in_text,
)
def validate(raw: dict[str, Any], ctx: ValidationContext) -> ValidationResult:
diags = DiagnosticCollector()
# V1 — schema gate (Pydantic). Fatal: without a parsed model the later passes have nothing.
try:
program = WosoolProgram.model_validate(raw)
except ValidationError as exc:
first = exc.errors()[0]
loc = ".".join(str(p) for p in first.get("loc", ()))
diags.error(
"V1_SCHEMA", f"{first.get('msg', 'invalid')} (at {loc or 'root'})", location=loc
)
return ValidationResult.of(diags.items)
node_ids = set(program.nodes)
_check_references(program, node_ids, diags)
order, acyclic = _check_acyclicity(program, node_ids, diags)
_check_whitelist(program, ctx, diags)
_check_arguments(program, ctx, diags)
_check_output_references(program, ctx, diags)
_check_reachability(program, node_ids, order, acyclic, diags)
return ValidationResult.of(diags.items)
# --- V2 reference integrity -----------------------------------------------------------------
def _successor_targets(node: Any) -> list[str]:
"""The control-flow successor node ids a node points at (terminals/None excluded)."""
targets: list[str] = []
for attr in ("next", "on_true", "on_false", "on_approved", "on_rejected", "on_timeout", "body"):
value = getattr(node, attr, None)
if isinstance(value, str):
targets.append(value)
if isinstance(node, ParallelNode):
targets.extend(node.branches)
error = getattr(node, "on_error", None)
if error is not None and error.to is not None:
targets.append(error.to)
return targets
def _check_references(
program: WosoolProgram, node_ids: set[str], diags: DiagnosticCollector
) -> None:
if program.entry not in node_ids:
diags.error("V2_DANGLING_REF", f"entry {program.entry!r} is not a defined node")
for node in program.pipeline:
for target in _successor_targets(node):
if target not in node_ids:
diags.error(
"V2_DANGLING_REF",
f"node {node.id!r} targets undefined node {target!r}",
node=node.id,
)
# --- V3 acyclicity (Kahn's topological sort) ------------------------------------------------
def _check_acyclicity(
program: WosoolProgram, node_ids: set[str], diags: DiagnosticCollector
) -> tuple[list[str], bool]:
edges = {
node.id: [t for t in _successor_targets(node) if t in node_ids] for node in program.pipeline
}
in_degree = {nid: 0 for nid in node_ids}
for sources in edges.values():
for target in sources:
in_degree[target] += 1
queue = [nid for nid in node_ids if in_degree[nid] == 0]
order: list[str] = []
while queue:
nid = queue.pop()
order.append(nid)
for target in edges[nid]:
in_degree[target] -= 1
if in_degree[target] == 0:
queue.append(target)
if len(order) < len(node_ids):
cyclic = sorted(node_ids - set(order))
diags.error("V3_CYCLE", f"graph is not acyclic; nodes in a cycle: {cyclic}")
return order, False
return order, True
# --- V4 whitelist ---------------------------------------------------------------------------
def _check_whitelist(
program: WosoolProgram, ctx: ValidationContext, diags: DiagnosticCollector
) -> None:
ws = program.workspace
for node in program.pipeline:
if isinstance(node, ActionNode):
spec = ctx.skill(node.skill)
if spec is None or node.verb not in spec.required_verbs:
diags.error(
"V4_UNKNOWN_SKILL",
f"skill/verb {node.skill!r}/{node.verb!r} is not in the registered catalog",
node=node.id,
)
continue
if not ctx.verb_allowed(ws, node.verb):
diags.error(
"V4_SCOPE_DENIED",
f"verb {node.verb!r} is not granted to workspace {ws!r}",
node=node.id,
)
if spec.deprecated:
diags.warning(
"V4_DEPRECATED_VERB",
f"verb {node.verb!r} (skill {node.skill!r}) is deprecated; admits with a "
"one-MINOR overlap — migrate to its replacement",
node=node.id,
)
elif isinstance(node, QueryNode):
if not ctx.is_read_verb(node.verb):
diags.error(
"V4_UNKNOWN_VERB",
f"query verb {node.verb!r} is not a registered read verb",
node=node.id,
)
elif not ctx.verb_allowed(ws, node.verb):
diags.error(
"V4_SCOPE_DENIED",
f"verb {node.verb!r} is not granted to workspace {ws!r}",
node=node.id,
)
# --- V5 argument typing ---------------------------------------------------------------------
def _check_arguments(
program: WosoolProgram, ctx: ValidationContext, diags: DiagnosticCollector
) -> None:
for node in program.pipeline:
if not isinstance(node, ActionNode):
continue # query args carry no hint_schema; reads are screened by the System
spec = ctx.skill(node.skill)
if spec is None:
continue # already a V4 error
schema = spec.hint_schema
properties = schema.get("properties", {})
required = schema.get("required", [])
allow_extra = schema.get("additionalProperties", True) is not False
for field in required:
if field not in node.args:
diags.error(
"V5_ARG_MISSING",
f"node {node.id!r} is missing required arg {field!r}",
node=node.id,
)
for key, value in node.args.items():
if key not in properties:
if not allow_extra:
diags.error(
"V5_ARG_TYPE",
f"node {node.id!r} has unknown arg {key!r}",
node=node.id,
)
continue
if parse_reference(value) is not None:
continue # a data reference — its value is unknown until run time
expected = properties[key].get("type")
if expected == "string" and not isinstance(value, str):
got = type(value).__name__
diags.error(
"V5_ARG_TYPE",
f"node {node.id!r} arg {key!r} must be a string hint, got {got}",
node=node.id,
)
# --- V5 (0.2) typed QUERY-output references --------------------------------------------------
def _ref_str(ref: Reference) -> str:
tail = "".join(f"[{s}]" if isinstance(s, int) else f".{s}" for s in ref.segments)
return f"$.{ref.source}{tail}"
def _walk_output(
schema: dict[str, Any],
segments: tuple[Any, ...],
node: Any,
ref: Reference,
diags: DiagnosticCollector,
) -> None:
"""Walk a reference path through a query verb's typed response schema (the shape of `output`).
A field segment requires an object carrying that property (else V5_OUTPUT_FIELD_UNKNOWN); an
index segment requires an array (else V5_PATH_SHAPE_MISMATCH); a field read from an array (or
an index into an object) is the array/object confusion the typed contract exists to reject.
"""
cursor: dict[str, Any] = schema
for segment in segments:
kind = cursor.get("type")
if isinstance(segment, int):
if kind != "array":
diags.error(
"V5_PATH_SHAPE_MISMATCH",
f"node {node.id!r}: index [{segment}] into a non-array ({kind}) in {_ref_str(ref)}",
node=node.id,
)
return
cursor = cursor.get("items", {})
else:
if kind != "object":
diags.error(
"V5_PATH_SHAPE_MISMATCH",
f"node {node.id!r}: field {segment!r} read from a non-object ({kind}) in {_ref_str(ref)}",
node=node.id,
)
return
properties = cursor.get("properties", {})
if segment not in properties:
diags.error(
"V5_OUTPUT_FIELD_UNKNOWN",
f"node {node.id!r}: field {segment!r} is not in the query response in {_ref_str(ref)}",
node=node.id,
)
return
cursor = properties[segment]
def _check_output_references(
program: WosoolProgram, ctx: ValidationContext, diags: DiagnosticCollector
) -> None:
nodes = program.nodes
for node in program.pipeline:
if not isinstance(node, ActionNode | QueryNode):
continue
for ref in iter_full_references(node.args):
source = nodes.get(ref.source)
if not isinstance(source, QueryNode):
continue # only QUERY outputs are typed; action outputs stay opaque
response = ctx.response_schema_for(source.verb)
if response is None:
continue # untyped read — nothing to check against
if not ref.segments or ref.segments[0] != "output":
continue # not an output reference
_walk_output(response, ref.segments[1:], node, ref, diags)
# --- V6 reachability, terminality, forward references ---------------------------------------
def _node_reference_sources(node: Any) -> list[str]:
"""Data-reference source steps a node consumes (for forward-reference detection)."""
if isinstance(node, ActionNode | QueryNode):
return iter_references(node.args)
if isinstance(node, ConditionNode):
return references_in_text(node.expression)
if isinstance(node, ForeachNode):
ref = parse_reference(node.items)
return [ref.source] if ref is not None else []
if isinstance(node, AwaitApprovalNode):
ref = parse_reference(node.proposal)
return [ref.source] if ref is not None else []
if isinstance(node, NotifyNode):
return iter_references(node.message.model_dump())
return []
def _check_reachability(
program: WosoolProgram,
node_ids: set[str],
order: list[str],
acyclic: bool,
diags: DiagnosticCollector,
) -> None:
nodes = program.nodes
# Reachability: BFS from entry over successor edges.
reachable: set[str] = set()
if program.entry in node_ids:
frontier = [program.entry]
while frontier:
nid = frontier.pop()
if nid in reachable:
continue
reachable.add(nid)
frontier.extend(t for t in _successor_targets(nodes[nid]) if t in node_ids)
for nid in sorted(node_ids - reachable):
diags.error("V6_UNREACHABLE", f"node {nid!r} is not reachable from entry", node=nid)
# Terminality: at least one reachable node ends the walk.
if reachable and not any(not _successor_targets(nodes[nid]) for nid in reachable):
diags.error("V6_NO_TERMINAL", "program has no terminal node (every path continues)")
# Forward references: a $.step_k must precede the consuming node in topological order.
if not acyclic:
return # the cycle (V3) makes order meaningless; skip to avoid false positives
index = {nid: i for i, nid in enumerate(order)}
for node in program.pipeline:
for source in _node_reference_sources(node):
if source in ("item", "input") or source not in index:
continue
if index[source] >= index[node.id]:
diags.error(
"V6_FORWARD_REF",
f"node {node.id!r} references {source!r} which does not precede it",
node=node.id,
)