@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.0

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,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
+ }