@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.1
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.
- package/.agents/plugins/marketplace.json +3 -3
- package/.claude-plugin/marketplace.json +3 -3
- package/package.json +1 -1
- package/plugin.json +2 -2
- package/skills/yotta-dev-mcp/SKILL.md +24 -9
- package/skills/yotta-dev-mcp/references/adapters.md +69 -0
- package/skills/yotta-dev-mcp/references/architecture-contract.md +256 -0
- package/skills/yotta-dev-mcp/references/tools.md +182 -3
- package/skills/yotta-dev-mcp/scripts/dev_adapters.py +609 -0
- package/skills/yotta-dev-mcp/scripts/dev_architecture.py +379 -0
- package/skills/yotta-dev-mcp/scripts/dev_common.py +155 -0
- package/skills/yotta-dev-mcp/scripts/dev_contract.py +830 -0
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +328 -210
- package/skills/yotta-dev-mcp/scripts/dev_impact.py +556 -0
- package/skills/yotta-dev-mcp/scripts/dev_mcp_doctor.py +768 -0
- package/skills/yotta-dev-mcp/scripts/dev_model.py +447 -0
- package/skills/yotta-dev-mcp/scripts/dev_selftest.py +544 -0
- package/skills/yotta-dev-mcp/scripts/dev_verify.py +450 -0
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +246 -7
- package/skills/yotta-dev-mcp/server.json +3 -3
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Architecture contract for yotta-dev-mcp (`.yotta/architecture.json`).
|
|
4
|
+
|
|
5
|
+
The contract is plain JSON, versioned, and validated with deterministic
|
|
6
|
+
findings: every problem carries a code, a severity, a JSON pointer and short
|
|
7
|
+
evidence. Validation never writes to disk and never raises on bad input.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
CONTRACT_PATH = ".yotta/architecture.json"
|
|
15
|
+
VERIFICATION_PATH = ".yotta/verification.json"
|
|
16
|
+
CONTRACT_VERSION = 1
|
|
17
|
+
VERIFICATION_VERSION = 1
|
|
18
|
+
|
|
19
|
+
SEVERITIES = ("critical", "high", "medium", "low", "info")
|
|
20
|
+
BLOCKING_SEVERITIES = ("critical", "high")
|
|
21
|
+
RISK_LEVELS = ("critical", "high", "medium", "low")
|
|
22
|
+
RULE_TYPES = ("forbid-dependency", "allow-dependency")
|
|
23
|
+
RULE_DEFAULT_SEVERITY = "medium"
|
|
24
|
+
BOUNDARY_VISIBILITY = ("public", "internal", "private")
|
|
25
|
+
INVARIANT_CHECKS = ("static", "command", "manual")
|
|
26
|
+
|
|
27
|
+
TOP_LEVEL_KEYS = frozenset({
|
|
28
|
+
"version", "project", "layers", "rules", "boundaries",
|
|
29
|
+
"data_ownership", "invariants", "risk_weights",
|
|
30
|
+
})
|
|
31
|
+
VERIFICATION_LEVELS = ("L2", "L3", "L4")
|
|
32
|
+
VERIFICATION_KINDS = (
|
|
33
|
+
"python-unittest", "pytest", "python-compile", "npm-test", "npm-lint",
|
|
34
|
+
)
|
|
35
|
+
VERIFICATION_TOP_LEVEL_KEYS = frozenset({"version", "checks", "manual"})
|
|
36
|
+
VERIFICATION_CHECK_KEYS = frozenset({
|
|
37
|
+
"id", "level", "kind", "cwd", "timeout", "required", "claim",
|
|
38
|
+
})
|
|
39
|
+
VERIFICATION_MANUAL_KEYS = frozenset({"id", "claim"})
|
|
40
|
+
|
|
41
|
+
ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
|
42
|
+
WINDOWS_ABS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
|
43
|
+
|
|
44
|
+
_GLOB_CACHE = {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_id(value):
|
|
48
|
+
return isinstance(value, str) and bool(ID_RE.match(value))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _glob_regex(pattern):
|
|
52
|
+
cached = _GLOB_CACHE.get(pattern)
|
|
53
|
+
if cached is not None:
|
|
54
|
+
return cached
|
|
55
|
+
text = pattern
|
|
56
|
+
if text.endswith("/"):
|
|
57
|
+
text += "**"
|
|
58
|
+
parts = []
|
|
59
|
+
index = 0
|
|
60
|
+
while index < len(text):
|
|
61
|
+
if text.startswith("**/", index):
|
|
62
|
+
parts.append("(?:.*/)?")
|
|
63
|
+
index += 3
|
|
64
|
+
continue
|
|
65
|
+
if text.startswith("**", index):
|
|
66
|
+
parts.append(".*")
|
|
67
|
+
index += 2
|
|
68
|
+
continue
|
|
69
|
+
char = text[index]
|
|
70
|
+
if char == "*":
|
|
71
|
+
parts.append("[^/]*")
|
|
72
|
+
elif char == "?":
|
|
73
|
+
parts.append("[^/]")
|
|
74
|
+
else:
|
|
75
|
+
parts.append(re.escape(char))
|
|
76
|
+
index += 1
|
|
77
|
+
compiled = re.compile("^" + "".join(parts) + "$")
|
|
78
|
+
_GLOB_CACHE[pattern] = compiled
|
|
79
|
+
return compiled
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def match_path(path, pattern):
|
|
83
|
+
"""Return True when a repository-relative POSIX path matches a glob."""
|
|
84
|
+
if not isinstance(pattern, str) or not pattern.strip():
|
|
85
|
+
return False
|
|
86
|
+
rel = str(path).replace("\\", "/")
|
|
87
|
+
if rel.startswith("./"):
|
|
88
|
+
rel = rel[2:]
|
|
89
|
+
return bool(_glob_regex(pattern.strip()).match(rel))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def match_layers(path, contract):
|
|
93
|
+
"""Return the ids of every layer whose globs match the path, in order."""
|
|
94
|
+
if not isinstance(contract, dict):
|
|
95
|
+
return []
|
|
96
|
+
matched = []
|
|
97
|
+
for layer in contract.get("layers") or []:
|
|
98
|
+
for pattern in layer.get("paths") or []:
|
|
99
|
+
if match_path(path, pattern):
|
|
100
|
+
matched.append(layer["id"])
|
|
101
|
+
break
|
|
102
|
+
return matched
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def match_any(path, patterns):
|
|
106
|
+
"""Return True when the path matches at least one glob in the list."""
|
|
107
|
+
for pattern in patterns or []:
|
|
108
|
+
if match_path(path, pattern):
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _path_problem(value):
|
|
114
|
+
if not isinstance(value, str) or not value.strip():
|
|
115
|
+
return "path must be a non-empty string"
|
|
116
|
+
text = value.strip()
|
|
117
|
+
if text.startswith("/") or text.startswith("~"):
|
|
118
|
+
return "path must be relative to the repository root"
|
|
119
|
+
if WINDOWS_ABS_RE.match(text) or text.startswith("\\\\"):
|
|
120
|
+
return "path must not be an absolute Windows path"
|
|
121
|
+
if any(part == ".." for part in text.replace("\\", "/").split("/")):
|
|
122
|
+
return "path must not escape the repository root"
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _normalize_layer(layer):
|
|
127
|
+
paths = [item for item in layer.get("paths") or [] if isinstance(item, str)]
|
|
128
|
+
return {
|
|
129
|
+
"id": layer.get("id"),
|
|
130
|
+
"title": layer.get("title") if isinstance(layer.get("title"), str) else None,
|
|
131
|
+
"paths": paths,
|
|
132
|
+
"risk": layer.get("risk") if layer.get("risk") in RISK_LEVELS else None,
|
|
133
|
+
"description": layer.get("description") if isinstance(layer.get("description"), str) else None,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalize_rule(rule):
|
|
138
|
+
return {
|
|
139
|
+
"id": rule.get("id"),
|
|
140
|
+
"type": rule.get("type"),
|
|
141
|
+
"from": rule.get("from"),
|
|
142
|
+
"to": rule.get("to"),
|
|
143
|
+
"severity": rule.get("severity") or RULE_DEFAULT_SEVERITY,
|
|
144
|
+
"claim": rule.get("claim") if isinstance(rule.get("claim"), str) else None,
|
|
145
|
+
"description": rule.get("description") if isinstance(rule.get("description"), str) else None,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _normalize_boundary(boundary):
|
|
150
|
+
return {
|
|
151
|
+
"id": boundary.get("id"),
|
|
152
|
+
"layer": boundary.get("layer"),
|
|
153
|
+
"paths": [item for item in boundary.get("paths") or [] if isinstance(item, str)],
|
|
154
|
+
"visibility": boundary.get("visibility") or "internal",
|
|
155
|
+
"description": boundary.get("description") if isinstance(boundary.get("description"), str) else None,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _normalize_store(store):
|
|
160
|
+
return {
|
|
161
|
+
"store": store.get("store"),
|
|
162
|
+
"owner": store.get("owner"),
|
|
163
|
+
"paths": [item for item in store.get("paths") or [] if isinstance(item, str)],
|
|
164
|
+
"kind": store.get("kind") if isinstance(store.get("kind"), str) else None,
|
|
165
|
+
"notes": store.get("notes") if isinstance(store.get("notes"), str) else None,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _normalize_invariant(invariant):
|
|
170
|
+
return {
|
|
171
|
+
"id": invariant.get("id"),
|
|
172
|
+
"claim": invariant.get("claim"),
|
|
173
|
+
"severity": invariant.get("severity") or RULE_DEFAULT_SEVERITY,
|
|
174
|
+
"check": invariant.get("check") or "manual",
|
|
175
|
+
"paths": [item for item in invariant.get("paths") or [] if isinstance(item, str)],
|
|
176
|
+
"description": invariant.get("description") if isinstance(invariant.get("description"), str) else None,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _normalize(data):
|
|
181
|
+
layers = [item for item in data.get("layers") or []
|
|
182
|
+
if isinstance(item, dict) and _is_id(item.get("id"))]
|
|
183
|
+
rules = [item for item in data.get("rules") or []
|
|
184
|
+
if isinstance(item, dict) and _is_id(item.get("id"))]
|
|
185
|
+
boundaries = [item for item in data.get("boundaries") or []
|
|
186
|
+
if isinstance(item, dict) and _is_id(item.get("id"))]
|
|
187
|
+
stores = [item for item in data.get("data_ownership") or []
|
|
188
|
+
if isinstance(item, dict) and _is_id(item.get("store"))]
|
|
189
|
+
invariants = [item for item in data.get("invariants") or []
|
|
190
|
+
if isinstance(item, dict) and _is_id(item.get("id"))]
|
|
191
|
+
weights = data.get("risk_weights")
|
|
192
|
+
return {
|
|
193
|
+
"version": data.get("version"),
|
|
194
|
+
"project": data.get("project") if isinstance(data.get("project"), str) else None,
|
|
195
|
+
"layers": [_normalize_layer(item) for item in layers],
|
|
196
|
+
"rules": [_normalize_rule(item) for item in rules],
|
|
197
|
+
"boundaries": [_normalize_boundary(item) for item in boundaries],
|
|
198
|
+
"data_ownership": [_normalize_store(item) for item in stores],
|
|
199
|
+
"invariants": [_normalize_invariant(item) for item in invariants],
|
|
200
|
+
"risk_weights": dict(weights) if isinstance(weights, dict) else {},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _result(present, path, contract, findings, version=None):
|
|
205
|
+
ordered = sorted(findings, key=lambda item: (item["pointer"], item["code"], item["message"]))
|
|
206
|
+
blocking = any(item["severity"] in BLOCKING_SEVERITIES for item in ordered)
|
|
207
|
+
return {
|
|
208
|
+
"present": bool(present),
|
|
209
|
+
"path": path,
|
|
210
|
+
"ok": not blocking,
|
|
211
|
+
"status": "FAIL" if blocking else "PASS",
|
|
212
|
+
"version": version if version is not None
|
|
213
|
+
else (contract or {}).get("version"),
|
|
214
|
+
"contract": contract,
|
|
215
|
+
"layers": [layer["id"] for layer in (contract or {}).get("layers") or []],
|
|
216
|
+
"rules": [rule["id"] for rule in (contract or {}).get("rules") or []],
|
|
217
|
+
"findings": ordered,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def validate_contract(data, source=CONTRACT_PATH):
|
|
222
|
+
"""Validate a decoded contract and return findings plus a normalized copy."""
|
|
223
|
+
source = str(source).replace("\\", "/")
|
|
224
|
+
findings = []
|
|
225
|
+
|
|
226
|
+
def add(code, severity, message, pointer="", evidence=""):
|
|
227
|
+
findings.append({
|
|
228
|
+
"code": code,
|
|
229
|
+
"severity": severity,
|
|
230
|
+
"message": message,
|
|
231
|
+
"pointer": pointer,
|
|
232
|
+
"path": source,
|
|
233
|
+
"evidence": evidence,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
def check_paths(values, pointer, object_code, required=False):
|
|
237
|
+
if values is None:
|
|
238
|
+
if required:
|
|
239
|
+
add(object_code, "high",
|
|
240
|
+
"paths must be a list of glob strings", pointer, "None")
|
|
241
|
+
return
|
|
242
|
+
if not isinstance(values, list):
|
|
243
|
+
add(object_code, "high",
|
|
244
|
+
"paths must be a list of glob strings", pointer, repr(values))
|
|
245
|
+
return
|
|
246
|
+
for index, value in enumerate(values):
|
|
247
|
+
problem = _path_problem(value)
|
|
248
|
+
if problem:
|
|
249
|
+
add("contract-invalid-path", "high", problem,
|
|
250
|
+
"%s/%d" % (pointer, index), repr(value))
|
|
251
|
+
|
|
252
|
+
if not isinstance(data, dict):
|
|
253
|
+
add("contract-not-object", "critical",
|
|
254
|
+
"contract root must be a JSON object", "", type(data).__name__)
|
|
255
|
+
return _result(True, source, None, findings)
|
|
256
|
+
|
|
257
|
+
version = data.get("version")
|
|
258
|
+
if isinstance(version, bool) or not isinstance(version, int) or version != CONTRACT_VERSION:
|
|
259
|
+
add("contract-unsupported-version", "critical",
|
|
260
|
+
"contract version must be %d" % CONTRACT_VERSION,
|
|
261
|
+
"/version", repr(version))
|
|
262
|
+
|
|
263
|
+
for key in sorted(set(data) - TOP_LEVEL_KEYS):
|
|
264
|
+
add("contract-unknown-key", "low",
|
|
265
|
+
"unknown top-level key is ignored", "/" + key, key)
|
|
266
|
+
|
|
267
|
+
layer_ids = []
|
|
268
|
+
layers = data.get("layers")
|
|
269
|
+
if not isinstance(layers, list):
|
|
270
|
+
add("contract-invalid-layers", "critical",
|
|
271
|
+
"layers must be a list", "/layers", type(layers).__name__)
|
|
272
|
+
layers = []
|
|
273
|
+
elif not layers:
|
|
274
|
+
add("contract-empty-layers", "medium",
|
|
275
|
+
"layers is empty; every module stays UNKNOWN", "/layers", "")
|
|
276
|
+
for index, layer in enumerate(layers):
|
|
277
|
+
pointer = "/layers/%d" % index
|
|
278
|
+
if not isinstance(layer, dict):
|
|
279
|
+
add("contract-invalid-layer", "high",
|
|
280
|
+
"layer must be an object", pointer, type(layer).__name__)
|
|
281
|
+
continue
|
|
282
|
+
layer_id = layer.get("id")
|
|
283
|
+
if not _is_id(layer_id):
|
|
284
|
+
add("contract-invalid-layer", "high",
|
|
285
|
+
"layer id must match [a-z0-9][a-z0-9._-]*",
|
|
286
|
+
pointer + "/id", repr(layer_id))
|
|
287
|
+
elif layer_id in layer_ids:
|
|
288
|
+
add("contract-duplicate-layer", "high",
|
|
289
|
+
"duplicate layer id", pointer + "/id", layer_id)
|
|
290
|
+
else:
|
|
291
|
+
layer_ids.append(layer_id)
|
|
292
|
+
if layer.get("paths") is None:
|
|
293
|
+
add("contract-layer-without-paths", "medium",
|
|
294
|
+
"layer declares no paths", pointer + "/paths", str(layer_id))
|
|
295
|
+
else:
|
|
296
|
+
check_paths(layer.get("paths"), pointer + "/paths", "contract-invalid-layer")
|
|
297
|
+
risk = layer.get("risk")
|
|
298
|
+
if risk is not None and risk not in RISK_LEVELS:
|
|
299
|
+
add("contract-invalid-risk", "medium",
|
|
300
|
+
"risk must be one of %s" % ", ".join(RISK_LEVELS),
|
|
301
|
+
pointer + "/risk", repr(risk))
|
|
302
|
+
|
|
303
|
+
rules = data.get("rules", [])
|
|
304
|
+
if not isinstance(rules, list):
|
|
305
|
+
add("contract-invalid-rules", "high",
|
|
306
|
+
"rules must be a list", "/rules", type(rules).__name__)
|
|
307
|
+
rules = []
|
|
308
|
+
rule_ids = []
|
|
309
|
+
for index, rule in enumerate(rules):
|
|
310
|
+
pointer = "/rules/%d" % index
|
|
311
|
+
if not isinstance(rule, dict):
|
|
312
|
+
add("contract-invalid-rule", "high",
|
|
313
|
+
"rule must be an object", pointer, type(rule).__name__)
|
|
314
|
+
continue
|
|
315
|
+
rule_id = rule.get("id")
|
|
316
|
+
if not _is_id(rule_id):
|
|
317
|
+
add("contract-invalid-rule", "high",
|
|
318
|
+
"rule id must match [a-z0-9][a-z0-9._-]*", pointer + "/id", repr(rule_id))
|
|
319
|
+
elif rule_id in rule_ids:
|
|
320
|
+
add("contract-duplicate-rule", "high",
|
|
321
|
+
"duplicate rule id", pointer + "/id", rule_id)
|
|
322
|
+
else:
|
|
323
|
+
rule_ids.append(rule_id)
|
|
324
|
+
severity = rule.get("severity")
|
|
325
|
+
if severity is not None and severity not in SEVERITIES:
|
|
326
|
+
add("contract-invalid-severity", "high",
|
|
327
|
+
"severity must be one of %s" % ", ".join(SEVERITIES),
|
|
328
|
+
pointer + "/severity", repr(severity))
|
|
329
|
+
rule_type = rule.get("type")
|
|
330
|
+
if rule_type not in RULE_TYPES:
|
|
331
|
+
add("contract-invalid-rule", "high",
|
|
332
|
+
"rule type must be one of %s" % ", ".join(RULE_TYPES),
|
|
333
|
+
pointer + "/type", repr(rule_type))
|
|
334
|
+
continue
|
|
335
|
+
source_layer = rule.get("from")
|
|
336
|
+
if source_layer not in layer_ids:
|
|
337
|
+
add("contract-unknown-layer-ref", "high",
|
|
338
|
+
"rule references an undefined layer",
|
|
339
|
+
pointer + "/from", repr(source_layer))
|
|
340
|
+
target = rule.get("to")
|
|
341
|
+
if rule_type == "forbid-dependency":
|
|
342
|
+
if not isinstance(target, str):
|
|
343
|
+
add("contract-invalid-rule", "high",
|
|
344
|
+
"forbid-dependency needs one target layer in 'to'",
|
|
345
|
+
pointer + "/to", repr(target))
|
|
346
|
+
elif target not in layer_ids:
|
|
347
|
+
add("contract-unknown-layer-ref", "high",
|
|
348
|
+
"rule references an undefined layer", pointer + "/to", repr(target))
|
|
349
|
+
else:
|
|
350
|
+
if not isinstance(target, list) or not target:
|
|
351
|
+
add("contract-invalid-rule", "high",
|
|
352
|
+
"allow-dependency needs a list of allowed layers in 'to'",
|
|
353
|
+
pointer + "/to", repr(target))
|
|
354
|
+
else:
|
|
355
|
+
for position, item in enumerate(target):
|
|
356
|
+
if item not in layer_ids:
|
|
357
|
+
add("contract-unknown-layer-ref", "high",
|
|
358
|
+
"rule references an undefined layer",
|
|
359
|
+
"%s/to/%d" % (pointer, position), repr(item))
|
|
360
|
+
|
|
361
|
+
boundaries = data.get("boundaries", [])
|
|
362
|
+
if not isinstance(boundaries, list):
|
|
363
|
+
add("contract-invalid-boundaries", "high",
|
|
364
|
+
"boundaries must be a list", "/boundaries", type(boundaries).__name__)
|
|
365
|
+
boundaries = []
|
|
366
|
+
boundary_ids = []
|
|
367
|
+
for index, boundary in enumerate(boundaries):
|
|
368
|
+
pointer = "/boundaries/%d" % index
|
|
369
|
+
if not isinstance(boundary, dict):
|
|
370
|
+
add("contract-invalid-boundary", "high",
|
|
371
|
+
"boundary must be an object", pointer, type(boundary).__name__)
|
|
372
|
+
continue
|
|
373
|
+
boundary_id = boundary.get("id")
|
|
374
|
+
if not _is_id(boundary_id):
|
|
375
|
+
add("contract-invalid-boundary", "high",
|
|
376
|
+
"boundary id must match [a-z0-9][a-z0-9._-]*",
|
|
377
|
+
pointer + "/id", repr(boundary_id))
|
|
378
|
+
elif boundary_id in boundary_ids:
|
|
379
|
+
add("contract-duplicate-boundary", "high",
|
|
380
|
+
"duplicate boundary id", pointer + "/id", boundary_id)
|
|
381
|
+
else:
|
|
382
|
+
boundary_ids.append(boundary_id)
|
|
383
|
+
if boundary.get("layer") not in layer_ids:
|
|
384
|
+
add("contract-unknown-layer-ref", "high",
|
|
385
|
+
"boundary references an undefined layer",
|
|
386
|
+
pointer + "/layer", repr(boundary.get("layer")))
|
|
387
|
+
check_paths(boundary.get("paths"), pointer + "/paths", "contract-invalid-boundary")
|
|
388
|
+
visibility = boundary.get("visibility")
|
|
389
|
+
if visibility is not None and visibility not in BOUNDARY_VISIBILITY:
|
|
390
|
+
add("contract-invalid-boundary", "medium",
|
|
391
|
+
"visibility must be one of %s" % ", ".join(BOUNDARY_VISIBILITY),
|
|
392
|
+
pointer + "/visibility", repr(visibility))
|
|
393
|
+
|
|
394
|
+
stores = data.get("data_ownership", [])
|
|
395
|
+
if not isinstance(stores, list):
|
|
396
|
+
add("contract-invalid-data-ownership", "high",
|
|
397
|
+
"data_ownership must be a list", "/data_ownership", type(stores).__name__)
|
|
398
|
+
stores = []
|
|
399
|
+
store_ids = []
|
|
400
|
+
for index, store in enumerate(stores):
|
|
401
|
+
pointer = "/data_ownership/%d" % index
|
|
402
|
+
if not isinstance(store, dict):
|
|
403
|
+
add("contract-invalid-store", "high",
|
|
404
|
+
"data_ownership entry must be an object", pointer, type(store).__name__)
|
|
405
|
+
continue
|
|
406
|
+
store_id = store.get("store")
|
|
407
|
+
if not _is_id(store_id):
|
|
408
|
+
add("contract-invalid-store", "high",
|
|
409
|
+
"store id must match [a-z0-9][a-z0-9._-]*", pointer + "/store", repr(store_id))
|
|
410
|
+
elif store_id in store_ids:
|
|
411
|
+
add("contract-duplicate-store", "high",
|
|
412
|
+
"duplicate store id", pointer + "/store", store_id)
|
|
413
|
+
else:
|
|
414
|
+
store_ids.append(store_id)
|
|
415
|
+
if store.get("owner") not in layer_ids:
|
|
416
|
+
add("contract-unknown-layer-ref", "high",
|
|
417
|
+
"store references an undefined owner layer",
|
|
418
|
+
pointer + "/owner", repr(store.get("owner")))
|
|
419
|
+
check_paths(store.get("paths"), pointer + "/paths", "contract-invalid-store")
|
|
420
|
+
|
|
421
|
+
invariants = data.get("invariants", [])
|
|
422
|
+
if not isinstance(invariants, list):
|
|
423
|
+
add("contract-invalid-invariants", "high",
|
|
424
|
+
"invariants must be a list", "/invariants", type(invariants).__name__)
|
|
425
|
+
invariants = []
|
|
426
|
+
invariant_ids = []
|
|
427
|
+
for index, invariant in enumerate(invariants):
|
|
428
|
+
pointer = "/invariants/%d" % index
|
|
429
|
+
if not isinstance(invariant, dict):
|
|
430
|
+
add("contract-invalid-invariant", "high",
|
|
431
|
+
"invariant must be an object", pointer, type(invariant).__name__)
|
|
432
|
+
continue
|
|
433
|
+
invariant_id = invariant.get("id")
|
|
434
|
+
if not _is_id(invariant_id):
|
|
435
|
+
add("contract-invalid-invariant", "high",
|
|
436
|
+
"invariant id must match [a-z0-9][a-z0-9._-]*",
|
|
437
|
+
pointer + "/id", repr(invariant_id))
|
|
438
|
+
elif invariant_id in invariant_ids:
|
|
439
|
+
add("contract-duplicate-invariant", "high",
|
|
440
|
+
"duplicate invariant id", pointer + "/id", invariant_id)
|
|
441
|
+
else:
|
|
442
|
+
invariant_ids.append(invariant_id)
|
|
443
|
+
claim = invariant.get("claim")
|
|
444
|
+
if not isinstance(claim, str) or not claim.strip():
|
|
445
|
+
add("contract-invalid-invariant", "high",
|
|
446
|
+
"invariant needs a non-empty claim", pointer + "/claim", repr(claim))
|
|
447
|
+
severity = invariant.get("severity")
|
|
448
|
+
if severity is not None and severity not in SEVERITIES:
|
|
449
|
+
add("contract-invalid-severity", "high",
|
|
450
|
+
"severity must be one of %s" % ", ".join(SEVERITIES),
|
|
451
|
+
pointer + "/severity", repr(severity))
|
|
452
|
+
check = invariant.get("check")
|
|
453
|
+
if check is not None and check not in INVARIANT_CHECKS:
|
|
454
|
+
add("contract-invalid-invariant", "medium",
|
|
455
|
+
"check must be one of %s" % ", ".join(INVARIANT_CHECKS),
|
|
456
|
+
pointer + "/check", repr(check))
|
|
457
|
+
check_paths(invariant.get("paths"), pointer + "/paths", "contract-invalid-invariant")
|
|
458
|
+
|
|
459
|
+
weights = data.get("risk_weights")
|
|
460
|
+
if weights is not None and not isinstance(weights, dict):
|
|
461
|
+
add("contract-invalid-risk-weight", "medium",
|
|
462
|
+
"risk_weights must be an object", "/risk_weights", type(weights).__name__)
|
|
463
|
+
elif isinstance(weights, dict):
|
|
464
|
+
for key in sorted(weights):
|
|
465
|
+
if key not in layer_ids:
|
|
466
|
+
add("contract-unknown-layer-ref", "high",
|
|
467
|
+
"risk_weights references an undefined layer",
|
|
468
|
+
"/risk_weights/" + key, repr(key))
|
|
469
|
+
value = weights[key]
|
|
470
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 5:
|
|
471
|
+
add("contract-invalid-risk-weight", "medium",
|
|
472
|
+
"risk weight must be a number between 0 and 5",
|
|
473
|
+
"/risk_weights/" + key, repr(value))
|
|
474
|
+
|
|
475
|
+
return _result(True, source, _normalize(data), findings)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def load_contract(root, contract_file=None):
|
|
479
|
+
"""Load and validate the contract; never raises on malformed input."""
|
|
480
|
+
rel = str(contract_file or CONTRACT_PATH).replace("\\", "/")
|
|
481
|
+
target = Path(root) / rel
|
|
482
|
+
if not target.is_file():
|
|
483
|
+
return _result(False, rel, None, [], version=None)
|
|
484
|
+
try:
|
|
485
|
+
text = target.read_text(encoding="utf-8")
|
|
486
|
+
except OSError as exc:
|
|
487
|
+
finding = {
|
|
488
|
+
"code": "contract-unreadable", "severity": "critical",
|
|
489
|
+
"message": "contract could not be read", "pointer": "",
|
|
490
|
+
"path": rel, "evidence": str(exc),
|
|
491
|
+
}
|
|
492
|
+
return _result(True, rel, None, [finding])
|
|
493
|
+
try:
|
|
494
|
+
data = json.loads(text)
|
|
495
|
+
except json.JSONDecodeError as exc:
|
|
496
|
+
finding = {
|
|
497
|
+
"code": "contract-invalid-json", "severity": "critical",
|
|
498
|
+
"message": "invalid JSON: %s" % exc.msg, "pointer": "",
|
|
499
|
+
"path": rel,
|
|
500
|
+
"evidence": "line %d, column %d" % (exc.lineno, exc.colno),
|
|
501
|
+
}
|
|
502
|
+
return _result(True, rel, None, [finding])
|
|
503
|
+
return validate_contract(data, source=rel)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _verification_result(present, path, policy, findings, version=None):
|
|
507
|
+
ordered = sorted(findings, key=lambda item: (item["pointer"], item["code"], item["message"]))
|
|
508
|
+
blocking = any(item["severity"] in BLOCKING_SEVERITIES for item in ordered)
|
|
509
|
+
return {
|
|
510
|
+
"present": bool(present),
|
|
511
|
+
"path": path,
|
|
512
|
+
"ok": not blocking,
|
|
513
|
+
"status": "FAIL" if blocking else "PASS",
|
|
514
|
+
"version": version if version is not None
|
|
515
|
+
else (policy or {}).get("version"),
|
|
516
|
+
"policy": policy,
|
|
517
|
+
"checks": list((policy or {}).get("checks") or []),
|
|
518
|
+
"manual": list((policy or {}).get("manual") or []),
|
|
519
|
+
"findings": ordered,
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def validate_verification_policy(data, source=VERIFICATION_PATH):
|
|
524
|
+
"""Validate `.yotta/verification.json` v1 and return a normalized policy."""
|
|
525
|
+
source = str(source).replace("\\", "/")
|
|
526
|
+
findings = []
|
|
527
|
+
|
|
528
|
+
def add(code, severity, message, pointer, evidence):
|
|
529
|
+
findings.append({
|
|
530
|
+
"code": code,
|
|
531
|
+
"severity": severity,
|
|
532
|
+
"message": message,
|
|
533
|
+
"pointer": pointer,
|
|
534
|
+
"path": source,
|
|
535
|
+
"evidence": evidence,
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
if not isinstance(data, dict):
|
|
539
|
+
add("verification-invalid-root", "critical",
|
|
540
|
+
"verification policy must be a JSON object", "", repr(type(data).__name__))
|
|
541
|
+
return _verification_result(True, source, None, findings)
|
|
542
|
+
|
|
543
|
+
version = data.get("version")
|
|
544
|
+
if version != VERIFICATION_VERSION:
|
|
545
|
+
add("verification-unsupported-version", "critical",
|
|
546
|
+
"unsupported verification policy version", "/version", repr(version))
|
|
547
|
+
for key in sorted(data):
|
|
548
|
+
if key not in VERIFICATION_TOP_LEVEL_KEYS:
|
|
549
|
+
add("verification-unknown-key", "low",
|
|
550
|
+
"unknown verification policy key", "/" + key, repr(key))
|
|
551
|
+
|
|
552
|
+
raw_checks = data.get("checks", [])
|
|
553
|
+
if not isinstance(raw_checks, list):
|
|
554
|
+
add("verification-invalid-checks", "critical",
|
|
555
|
+
"checks must be an array", "/checks", repr(type(raw_checks).__name__))
|
|
556
|
+
raw_checks = []
|
|
557
|
+
checks = []
|
|
558
|
+
seen_check_ids = set()
|
|
559
|
+
for index, item in enumerate(raw_checks):
|
|
560
|
+
pointer = "/checks/%d" % index
|
|
561
|
+
if not isinstance(item, dict):
|
|
562
|
+
add("verification-invalid-check", "high",
|
|
563
|
+
"check must be an object", pointer, repr(type(item).__name__))
|
|
564
|
+
continue
|
|
565
|
+
for key in sorted(item):
|
|
566
|
+
if key not in VERIFICATION_CHECK_KEYS:
|
|
567
|
+
add("verification-unknown-check-key", "low",
|
|
568
|
+
"unknown check key", pointer + "/" + key, repr(key))
|
|
569
|
+
check_id = item.get("id")
|
|
570
|
+
level = item.get("level")
|
|
571
|
+
kind = item.get("kind")
|
|
572
|
+
cwd = item.get("cwd", ".")
|
|
573
|
+
timeout = item.get("timeout", 120)
|
|
574
|
+
required = item.get("required", True)
|
|
575
|
+
claim = item.get("claim")
|
|
576
|
+
valid = True
|
|
577
|
+
if not _is_id(check_id):
|
|
578
|
+
add("verification-invalid-id", "high",
|
|
579
|
+
"check id must be a lower-case slug", pointer + "/id", repr(check_id))
|
|
580
|
+
valid = False
|
|
581
|
+
elif check_id in seen_check_ids:
|
|
582
|
+
add("verification-duplicate-check", "high",
|
|
583
|
+
"check id must be unique", pointer + "/id", repr(check_id))
|
|
584
|
+
valid = False
|
|
585
|
+
else:
|
|
586
|
+
seen_check_ids.add(check_id)
|
|
587
|
+
if level not in VERIFICATION_LEVELS:
|
|
588
|
+
add("verification-invalid-level", "high",
|
|
589
|
+
"check level must be L2, L3 or L4", pointer + "/level", repr(level))
|
|
590
|
+
valid = False
|
|
591
|
+
if kind not in VERIFICATION_KINDS:
|
|
592
|
+
add("verification-invalid-kind", "high",
|
|
593
|
+
"check kind is not in the whitelist", pointer + "/kind", repr(kind))
|
|
594
|
+
valid = False
|
|
595
|
+
cwd_problem = _path_problem(cwd)
|
|
596
|
+
if cwd_problem:
|
|
597
|
+
add("verification-invalid-cwd", "high",
|
|
598
|
+
"check cwd must stay inside the repository", pointer + "/cwd",
|
|
599
|
+
cwd_problem)
|
|
600
|
+
valid = False
|
|
601
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 600:
|
|
602
|
+
add("verification-invalid-timeout", "high",
|
|
603
|
+
"timeout must be an integer between 1 and 600",
|
|
604
|
+
pointer + "/timeout", repr(timeout))
|
|
605
|
+
valid = False
|
|
606
|
+
if not isinstance(required, bool):
|
|
607
|
+
add("verification-invalid-required", "high",
|
|
608
|
+
"required must be a boolean", pointer + "/required", repr(required))
|
|
609
|
+
valid = False
|
|
610
|
+
if claim is not None and (not isinstance(claim, str) or not claim.strip()):
|
|
611
|
+
add("verification-invalid-claim", "medium",
|
|
612
|
+
"claim must be a non-empty string when present",
|
|
613
|
+
pointer + "/claim", repr(claim))
|
|
614
|
+
if not valid:
|
|
615
|
+
continue
|
|
616
|
+
checks.append({
|
|
617
|
+
"id": check_id,
|
|
618
|
+
"level": level,
|
|
619
|
+
"kind": kind,
|
|
620
|
+
"cwd": str(cwd).replace("\\", "/"),
|
|
621
|
+
"timeout": timeout,
|
|
622
|
+
"required": required,
|
|
623
|
+
"claim": claim.strip() if isinstance(claim, str) else None,
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
raw_manual = data.get("manual", [])
|
|
627
|
+
if not isinstance(raw_manual, list):
|
|
628
|
+
add("verification-invalid-manual-list", "critical",
|
|
629
|
+
"manual must be an array", "/manual", repr(type(raw_manual).__name__))
|
|
630
|
+
raw_manual = []
|
|
631
|
+
manual = []
|
|
632
|
+
seen_manual_ids = set()
|
|
633
|
+
for index, item in enumerate(raw_manual):
|
|
634
|
+
pointer = "/manual/%d" % index
|
|
635
|
+
if not isinstance(item, dict):
|
|
636
|
+
add("verification-invalid-manual", "high",
|
|
637
|
+
"manual claim must be an object", pointer, repr(type(item).__name__))
|
|
638
|
+
continue
|
|
639
|
+
for key in sorted(item):
|
|
640
|
+
if key not in VERIFICATION_MANUAL_KEYS:
|
|
641
|
+
add("verification-unknown-manual-key", "low",
|
|
642
|
+
"unknown manual claim key", pointer + "/" + key, repr(key))
|
|
643
|
+
manual_id = item.get("id")
|
|
644
|
+
claim = item.get("claim")
|
|
645
|
+
valid = True
|
|
646
|
+
if not _is_id(manual_id):
|
|
647
|
+
add("verification-invalid-manual", "high",
|
|
648
|
+
"manual claim id must be a lower-case slug",
|
|
649
|
+
pointer + "/id", repr(manual_id))
|
|
650
|
+
valid = False
|
|
651
|
+
elif manual_id in seen_manual_ids:
|
|
652
|
+
add("verification-duplicate-manual", "high",
|
|
653
|
+
"manual claim id must be unique", pointer + "/id", repr(manual_id))
|
|
654
|
+
valid = False
|
|
655
|
+
else:
|
|
656
|
+
seen_manual_ids.add(manual_id)
|
|
657
|
+
if not isinstance(claim, str) or not claim.strip():
|
|
658
|
+
add("verification-invalid-manual", "high",
|
|
659
|
+
"manual claim must be a non-empty string",
|
|
660
|
+
pointer + "/claim", repr(claim))
|
|
661
|
+
valid = False
|
|
662
|
+
if valid:
|
|
663
|
+
manual.append({"id": manual_id, "claim": claim.strip()})
|
|
664
|
+
|
|
665
|
+
policy = {
|
|
666
|
+
"version": VERIFICATION_VERSION,
|
|
667
|
+
"checks": checks,
|
|
668
|
+
"manual": manual,
|
|
669
|
+
}
|
|
670
|
+
return _verification_result(True, source, policy, findings)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def load_verification_policy(root, policy_file=None):
|
|
674
|
+
"""Load and validate the optional verification policy; never raises on bad input."""
|
|
675
|
+
rel = str(policy_file or VERIFICATION_PATH).replace("\\", "/")
|
|
676
|
+
target = Path(root) / rel
|
|
677
|
+
if not target.is_file():
|
|
678
|
+
return _verification_result(False, rel, None, [], version=None)
|
|
679
|
+
try:
|
|
680
|
+
text = target.read_text(encoding="utf-8")
|
|
681
|
+
except OSError as exc:
|
|
682
|
+
finding = {
|
|
683
|
+
"code": "verification-unreadable", "severity": "critical",
|
|
684
|
+
"message": "verification policy could not be read", "pointer": "",
|
|
685
|
+
"path": rel, "evidence": str(exc),
|
|
686
|
+
}
|
|
687
|
+
return _verification_result(True, rel, None, [finding])
|
|
688
|
+
try:
|
|
689
|
+
data = json.loads(text)
|
|
690
|
+
except json.JSONDecodeError as exc:
|
|
691
|
+
finding = {
|
|
692
|
+
"code": "verification-invalid-json", "severity": "critical",
|
|
693
|
+
"message": "invalid JSON: %s" % exc.msg, "pointer": "",
|
|
694
|
+
"path": rel,
|
|
695
|
+
"evidence": "line %d, column %d" % (exc.lineno, exc.colno),
|
|
696
|
+
}
|
|
697
|
+
return _verification_result(True, rel, None, [finding])
|
|
698
|
+
return validate_verification_policy(data, source=rel)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def contract_schema():
|
|
702
|
+
"""Return a JSON-Schema description of `.yotta/architecture.json` v1."""
|
|
703
|
+
layer = {
|
|
704
|
+
"type": "object",
|
|
705
|
+
"required": ["id"],
|
|
706
|
+
"additionalProperties": True,
|
|
707
|
+
"properties": {
|
|
708
|
+
"id": {"type": "string", "pattern": ID_RE.pattern},
|
|
709
|
+
"title": {"type": "string"},
|
|
710
|
+
"description": {"type": "string"},
|
|
711
|
+
"paths": {"type": "array", "items": {"type": "string"}},
|
|
712
|
+
"risk": {"enum": list(RISK_LEVELS)},
|
|
713
|
+
},
|
|
714
|
+
}
|
|
715
|
+
rule = {
|
|
716
|
+
"type": "object",
|
|
717
|
+
"required": ["id", "type", "from"],
|
|
718
|
+
"additionalProperties": True,
|
|
719
|
+
"properties": {
|
|
720
|
+
"id": {"type": "string", "pattern": ID_RE.pattern},
|
|
721
|
+
"type": {"enum": list(RULE_TYPES)},
|
|
722
|
+
"from": {"type": "string"},
|
|
723
|
+
"to": {
|
|
724
|
+
"description": "target layer for forbid-dependency, layer list for allow-dependency",
|
|
725
|
+
"oneOf": [
|
|
726
|
+
{"type": "string"},
|
|
727
|
+
{"type": "array", "items": {"type": "string"}},
|
|
728
|
+
],
|
|
729
|
+
},
|
|
730
|
+
"severity": {"enum": list(SEVERITIES)},
|
|
731
|
+
"claim": {"type": "string"},
|
|
732
|
+
"description": {"type": "string"},
|
|
733
|
+
},
|
|
734
|
+
}
|
|
735
|
+
boundary = {
|
|
736
|
+
"type": "object",
|
|
737
|
+
"required": ["id", "layer"],
|
|
738
|
+
"additionalProperties": True,
|
|
739
|
+
"properties": {
|
|
740
|
+
"id": {"type": "string", "pattern": ID_RE.pattern},
|
|
741
|
+
"layer": {"type": "string"},
|
|
742
|
+
"paths": {"type": "array", "items": {"type": "string"}},
|
|
743
|
+
"visibility": {"enum": list(BOUNDARY_VISIBILITY)},
|
|
744
|
+
"description": {"type": "string"},
|
|
745
|
+
},
|
|
746
|
+
}
|
|
747
|
+
store = {
|
|
748
|
+
"type": "object",
|
|
749
|
+
"required": ["store", "owner"],
|
|
750
|
+
"additionalProperties": True,
|
|
751
|
+
"properties": {
|
|
752
|
+
"store": {"type": "string", "pattern": ID_RE.pattern},
|
|
753
|
+
"owner": {"type": "string"},
|
|
754
|
+
"paths": {"type": "array", "items": {"type": "string"}},
|
|
755
|
+
"kind": {"type": "string"},
|
|
756
|
+
"notes": {"type": "string"},
|
|
757
|
+
},
|
|
758
|
+
}
|
|
759
|
+
invariant = {
|
|
760
|
+
"type": "object",
|
|
761
|
+
"required": ["id", "claim"],
|
|
762
|
+
"additionalProperties": True,
|
|
763
|
+
"properties": {
|
|
764
|
+
"id": {"type": "string", "pattern": ID_RE.pattern},
|
|
765
|
+
"claim": {"type": "string"},
|
|
766
|
+
"severity": {"enum": list(SEVERITIES)},
|
|
767
|
+
"check": {"enum": list(INVARIANT_CHECKS)},
|
|
768
|
+
"paths": {"type": "array", "items": {"type": "string"}},
|
|
769
|
+
"description": {"type": "string"},
|
|
770
|
+
},
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
774
|
+
"title": "yotta architecture contract (.yotta/architecture.json)",
|
|
775
|
+
"type": "object",
|
|
776
|
+
"required": ["version", "layers"],
|
|
777
|
+
"additionalProperties": False,
|
|
778
|
+
"properties": {
|
|
779
|
+
"version": {"const": CONTRACT_VERSION},
|
|
780
|
+
"project": {"type": "string"},
|
|
781
|
+
"layers": {"type": "array", "items": layer},
|
|
782
|
+
"rules": {"type": "array", "items": rule},
|
|
783
|
+
"boundaries": {"type": "array", "items": boundary},
|
|
784
|
+
"data_ownership": {"type": "array", "items": store},
|
|
785
|
+
"invariants": {"type": "array", "items": invariant},
|
|
786
|
+
"risk_weights": {
|
|
787
|
+
"type": "object",
|
|
788
|
+
"additionalProperties": {"type": "number", "minimum": 0, "maximum": 5},
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def verification_schema():
|
|
795
|
+
"""Return a JSON-Schema description of `.yotta/verification.json` v1."""
|
|
796
|
+
check = {
|
|
797
|
+
"type": "object",
|
|
798
|
+
"required": ["id", "level", "kind"],
|
|
799
|
+
"additionalProperties": False,
|
|
800
|
+
"properties": {
|
|
801
|
+
"id": {"type": "string", "pattern": ID_RE.pattern},
|
|
802
|
+
"level": {"enum": list(VERIFICATION_LEVELS)},
|
|
803
|
+
"kind": {"enum": list(VERIFICATION_KINDS)},
|
|
804
|
+
"cwd": {"type": "string"},
|
|
805
|
+
"timeout": {"type": "integer", "minimum": 1, "maximum": 600},
|
|
806
|
+
"required": {"type": "boolean"},
|
|
807
|
+
"claim": {"type": "string"},
|
|
808
|
+
},
|
|
809
|
+
}
|
|
810
|
+
manual = {
|
|
811
|
+
"type": "object",
|
|
812
|
+
"required": ["id", "claim"],
|
|
813
|
+
"additionalProperties": False,
|
|
814
|
+
"properties": {
|
|
815
|
+
"id": {"type": "string", "pattern": ID_RE.pattern},
|
|
816
|
+
"claim": {"type": "string"},
|
|
817
|
+
},
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
821
|
+
"title": "yotta verification policy (.yotta/verification.json)",
|
|
822
|
+
"type": "object",
|
|
823
|
+
"required": ["version"],
|
|
824
|
+
"additionalProperties": False,
|
|
825
|
+
"properties": {
|
|
826
|
+
"version": {"const": VERIFICATION_VERSION},
|
|
827
|
+
"checks": {"type": "array", "items": check},
|
|
828
|
+
"manual": {"type": "array", "items": manual},
|
|
829
|
+
},
|
|
830
|
+
}
|