personal memory agent
1#!/usr/bin/env python3
2# SPDX-License-Identifier: AGPL-3.0-only
3# Copyright (c) 2026 sol pbc
4
5"""Check native-sol parity coverage against current HTTP authorities."""
6
7from __future__ import annotations
8
9import json
10import subprocess
11import sys
12from collections import Counter
13from pathlib import Path
14from typing import Any
15
16try:
17 from scripts.build_native_sol_inventory import (
18 FINAL_HTTP_TOTAL,
19 FINAL_STUB_COUNTS,
20 FINAL_TOP_LEVEL_CHAT_TOTAL,
21 FINAL_TOP_LEVEL_IMPORT_TOTAL,
22 FINAL_TOP_LEVEL_LINK_TOTAL,
23 FINAL_TOP_LEVEL_NOTIFY_TOTAL,
24 REPO_ROOT,
25 discover,
26 )
27except ModuleNotFoundError: # pragma: no cover - direct script execution path.
28 from build_native_sol_inventory import ( # type: ignore[no-redef]
29 FINAL_HTTP_TOTAL,
30 FINAL_STUB_COUNTS,
31 FINAL_TOP_LEVEL_CHAT_TOTAL,
32 FINAL_TOP_LEVEL_IMPORT_TOTAL,
33 FINAL_TOP_LEVEL_LINK_TOTAL,
34 FINAL_TOP_LEVEL_NOTIFY_TOTAL,
35 REPO_ROOT,
36 discover,
37 )
38
39SCHEMA = "native-sol-applicability-v1"
40APPLICABILITY = REPO_ROOT / "core/fixtures/native-sol/applicability.json"
41PARITY_DIR = REPO_ROOT / "core/fixtures/native-sol/parity"
42RUST_MANIFEST = REPO_ROOT / "core/Cargo.toml"
43
44
45def main() -> int:
46 errors = check_coverage()
47 if errors:
48 print("native sol coverage failed:", file=sys.stderr)
49 for error in errors:
50 print(f"- {error}", file=sys.stderr)
51 return 1
52 print("native sol coverage ok")
53 return 0
54
55
56def check_coverage(root: Path = REPO_ROOT) -> list[str]:
57 entries = discover(root)
58 required = {
59 entry.operation_id
60 for entry in entries
61 if entry.surface == "sol-call" and entry.entry_type == "http"
62 }
63 required_stubs = {
64 entry.operation_id
65 for entry in entries
66 if entry.surface == "sol-call" and entry.entry_type in {"moved-stub", "local"}
67 }
68 required_top_level_chat = {
69 entry.operation_id
70 for entry in entries
71 if entry.surface == "sol-chat" and entry.entry_type == "top-level-chat"
72 }
73 required_top_level_import = {
74 entry.operation_id
75 for entry in entries
76 if entry.surface == "sol-import" and entry.entry_type == "top-level-import"
77 }
78 required_top_level_notify = {
79 entry.operation_id
80 for entry in entries
81 if entry.surface == "sol-notify" and entry.entry_type == "top-level-notify"
82 }
83 required_top_level_link = {
84 entry.operation_id
85 for entry in entries
86 if entry.surface == "sol-link" and entry.entry_type == "top-level-link"
87 }
88 required_dispatch = (
89 required
90 | required_stubs
91 | required_top_level_chat
92 | required_top_level_import
93 | required_top_level_link
94 | required_top_level_notify
95 )
96 vectors = load_vectors(PARITY_DIR)
97 resolved = resolve_vectors(PARITY_DIR, vectors)
98 applicability, applicability_errors = load_applicability(APPLICABILITY)
99
100 errors: list[str] = []
101 errors.extend(applicability_errors)
102 if len(required) != FINAL_HTTP_TOTAL:
103 errors.append(
104 f"current HTTP authority count {len(required)} != {FINAL_HTTP_TOTAL}"
105 )
106 stub_counts = Counter(
107 entry.entry_type
108 for entry in entries
109 if entry.surface == "sol-call" and entry.entry_type in FINAL_STUB_COUNTS
110 )
111 for entry_type, expected in sorted(FINAL_STUB_COUNTS.items()):
112 if stub_counts[entry_type] != expected:
113 errors.append(
114 f"current {entry_type} authority count {stub_counts[entry_type]} "
115 f"!= {expected}"
116 )
117 if len(required_top_level_chat) != FINAL_TOP_LEVEL_CHAT_TOTAL:
118 errors.append(
119 f"current top-level chat authority count {len(required_top_level_chat)} "
120 f"!= {FINAL_TOP_LEVEL_CHAT_TOTAL}"
121 )
122 if not applicability_errors:
123 entries = applicability["entries"]
124 keys = set(entries)
125 http_count = applicability.get("http_count")
126 if http_count != FINAL_HTTP_TOTAL:
127 errors.append(
128 f"applicability http_count {http_count!r} != {FINAL_HTTP_TOTAL}"
129 )
130 if http_count != len(entries):
131 errors.append(
132 f"applicability http_count {http_count!r} != entries {len(entries)}"
133 )
134 if http_count != len(required):
135 errors.append(
136 f"applicability http_count {http_count!r} != current HTTP "
137 f"authority count {len(required)}"
138 )
139 errors.extend(compare_sets("applicability keys", required, keys))
140 top_level_entries = applicability.get("top_level_entries", {})
141 if not isinstance(top_level_entries, dict):
142 errors.append("applicability top_level_entries must be an object")
143 top_level_entries = {}
144 import_keys = set(top_level_entries)
145 errors.extend(
146 compare_sets(
147 "top-level import applicability keys",
148 required_top_level_import,
149 import_keys,
150 )
151 )
152
153 if len(required_top_level_import) != FINAL_TOP_LEVEL_IMPORT_TOTAL:
154 errors.append(
155 f"current top-level import authority count {len(required_top_level_import)} "
156 f"!= {FINAL_TOP_LEVEL_IMPORT_TOTAL}"
157 )
158 if len(required_top_level_notify) != FINAL_TOP_LEVEL_NOTIFY_TOTAL:
159 errors.append(
160 f"current top-level notify authority count {len(required_top_level_notify)} "
161 f"!= {FINAL_TOP_LEVEL_NOTIFY_TOTAL}"
162 )
163 if len(required_top_level_link) != FINAL_TOP_LEVEL_LINK_TOTAL:
164 errors.append(
165 f"current top-level link authority count {len(required_top_level_link)} "
166 f"!= {FINAL_TOP_LEVEL_LINK_TOTAL}"
167 )
168 if not required_dispatch:
169 errors.append("native dispatch authority set is empty")
170 resolved_operations = {
171 str(item["operation_id"])
172 for item in resolved.values()
173 if item.get("operation_id") is not None
174 }
175 missing_dispatch = sorted(required_dispatch - resolved_operations)
176 if missing_dispatch:
177 errors.append(
178 "production aggregate dispatch missing required operations "
179 f"{missing_dispatch!r}"
180 )
181
182 buckets = collect_buckets(vectors, resolved, required, {"http"}, errors)
183 for bucket_name in ("request_binding", "success", "failure"):
184 errors.extend(compare_sets(bucket_name, required, buckets[bucket_name]))
185 import_buckets = collect_buckets(
186 vectors,
187 resolved,
188 required_top_level_import,
189 {"top-level-import"},
190 errors,
191 )
192 for bucket_name in ("request_binding", "success", "failure"):
193 errors.extend(
194 compare_sets(
195 f"top-level import {bucket_name}",
196 required_top_level_import,
197 import_buckets[bucket_name],
198 )
199 )
200 notify_buckets = collect_buckets(
201 vectors,
202 resolved,
203 required_top_level_notify,
204 {"top-level-notify"},
205 errors,
206 )
207 for bucket_name in ("notification_binding", "success", "failure"):
208 errors.extend(
209 compare_sets(
210 f"top-level notify {bucket_name}",
211 required_top_level_notify,
212 notify_buckets[bucket_name],
213 )
214 )
215 link_buckets = collect_buckets(
216 vectors,
217 resolved,
218 required_top_level_link,
219 {"top-level-link"},
220 errors,
221 )
222 for bucket_name in ("success", "failure"):
223 errors.extend(
224 compare_sets(
225 f"top-level link {bucket_name}",
226 required_top_level_link,
227 link_buckets[bucket_name],
228 )
229 )
230
231 if not applicability_errors:
232 errors.extend(
233 check_applicability_requirements(
234 applicability["entries"], vectors, resolved, buckets
235 )
236 )
237 errors.extend(
238 check_top_level_import_cases(
239 applicability.get("top_level_entries", {}),
240 vectors,
241 resolved,
242 )
243 )
244 return errors
245
246
247def load_applicability(path: Path) -> tuple[dict[str, Any], list[str]]:
248 try:
249 payload = json.loads(path.read_text(encoding="utf-8"))
250 except FileNotFoundError:
251 return {}, [f"{path.relative_to(REPO_ROOT)} is missing"]
252 except json.JSONDecodeError as error:
253 return {}, [f"{path.relative_to(REPO_ROOT)} is malformed JSON: {error}"]
254 errors: list[str] = []
255 if payload.get("schema") != SCHEMA:
256 errors.append(f"applicability schema must be {SCHEMA!r}")
257 entries = payload.get("entries")
258 if not isinstance(entries, dict):
259 errors.append("applicability entries must be an object")
260 else:
261 for operation_id, entry in entries.items():
262 if not isinstance(operation_id, str) or not operation_id:
263 errors.append("applicability operation ids must be non-empty strings")
264 if not isinstance(entry, dict):
265 errors.append(f"{operation_id}: applicability entry must be an object")
266 continue
267 for key in (
268 "path",
269 "group",
270 "pagination",
271 "mutation",
272 "upload",
273 "env_default",
274 "confirmation",
275 "consent",
276 "dry_run",
277 "multi_request",
278 ):
279 if key not in entry:
280 errors.append(f"{operation_id}: missing applicability field {key}")
281 top_level_entries = payload.get("top_level_entries", {})
282 if top_level_entries is not None and not isinstance(top_level_entries, dict):
283 errors.append("applicability top_level_entries must be an object")
284 return payload, errors
285
286
287def load_vectors(directory: Path) -> dict[str, dict[str, Any]]:
288 vectors: dict[str, dict[str, Any]] = {}
289 for path in sorted(directory.glob("*.jsonl")):
290 for line_number, line in enumerate(
291 path.read_text(encoding="utf-8").splitlines(), 1
292 ):
293 if not line.strip():
294 continue
295 vector = json.loads(line)
296 vector_id = str(vector.get("id") or "")
297 if not vector_id:
298 raise ValueError(
299 f"{path.relative_to(REPO_ROOT)}:{line_number}: missing id"
300 )
301 if vector_id in vectors:
302 raise ValueError(
303 f"{path.relative_to(REPO_ROOT)}:{line_number}: duplicate id {vector_id}"
304 )
305 vector["_fixture"] = str(path.relative_to(REPO_ROOT))
306 vectors[vector_id] = vector
307 return vectors
308
309
310def resolve_vectors(
311 directory: Path, vectors: dict[str, dict[str, Any]]
312) -> dict[str, dict[str, Any]]:
313 paths = sorted(directory.glob("*.jsonl"))
314 completed = subprocess.run(
315 [
316 "cargo",
317 "run",
318 "--quiet",
319 "--manifest-path",
320 str(RUST_MANIFEST),
321 "-p",
322 "solstone-core-sol-client-cli",
323 "--bin",
324 "resolve_parity_leaves",
325 "--",
326 *(str(path) for path in paths),
327 ],
328 check=False,
329 cwd=REPO_ROOT,
330 text=True,
331 stdout=subprocess.PIPE,
332 stderr=subprocess.PIPE,
333 )
334 if completed.returncode != 0:
335 raise RuntimeError(
336 "production parity leaf resolver failed:\n" + completed.stderr
337 )
338 resolved: dict[str, dict[str, Any]] = {}
339 for line in completed.stdout.splitlines():
340 if not line.strip():
341 continue
342 item = json.loads(line)
343 vector_id = str(item["id"])
344 if vector_id not in vectors:
345 raise RuntimeError(f"resolver returned unknown vector id {vector_id}")
346 resolved[vector_id] = item
347 missing = sorted(set(vectors) - set(resolved))
348 if missing:
349 raise RuntimeError(f"resolver omitted vectors {missing!r}")
350 return resolved
351
352
353def collect_buckets(
354 vectors: dict[str, dict[str, Any]],
355 resolved: dict[str, dict[str, Any]],
356 required: set[str],
357 entry_types: set[str],
358 errors: list[str],
359) -> dict[str, set[str]]:
360 buckets: dict[str, set[str]] = {
361 "request_binding": set(),
362 "notification_binding": set(),
363 "success": set(),
364 "failure": set(),
365 }
366 for vector_id, vector in vectors.items():
367 operation_id = resolved[vector_id].get("operation_id")
368 entry_type = resolved[vector_id].get("entry_type")
369 if vector.get("surface", "sol-call") == "sol-call" and operation_id is None:
370 errors.append(
371 f"{vector_id}: argv did not resolve through production dispatch"
372 )
373 continue
374 if entry_type not in entry_types or operation_id not in required:
375 continue
376 expected = vector.get("expected") or {}
377 requests = expected.get("requests") if isinstance(expected, dict) else None
378 notifications = (
379 expected.get("notifications") if isinstance(expected, dict) else None
380 )
381 if pins_request_shape(requests):
382 buckets["request_binding"].add(operation_id)
383 if pins_notification_shape(notifications):
384 buckets["notification_binding"].add(operation_id)
385 if is_failure_vector(vector, expected):
386 buckets["failure"].add(operation_id)
387 elif is_success_vector(
388 expected, vector.get("transport", {}).get("requests", [])
389 ):
390 buckets["success"].add(operation_id)
391 return buckets
392
393
394def pins_notification_shape(notifications: Any) -> bool:
395 return isinstance(notifications, list) and any(
396 isinstance(item, str) and item.endswith("\n") for item in notifications
397 )
398
399
400def pins_request_shape(requests: Any) -> bool:
401 if not isinstance(requests, list):
402 return False
403 return any(
404 isinstance(request, dict)
405 and isinstance(request.get("method"), str)
406 and isinstance(request.get("path"), str)
407 for request in requests
408 )
409
410
411def is_failure_vector(vector: dict[str, Any], expected: Any) -> bool:
412 if isinstance(expected, dict) and expected.get("exit") != 0:
413 return True
414 for request in vector.get("transport", {}).get("requests", []):
415 if not isinstance(request, dict):
416 continue
417 if "fault" in request:
418 return True
419 status = (
420 request.get("response", {}).get("status")
421 if isinstance(request.get("response"), dict)
422 else None
423 )
424 if status is not None and not (200 <= int(status) <= 299):
425 return True
426 return False
427
428
429def is_success_vector(expected: Any, transport_requests: Any) -> bool:
430 if not isinstance(expected, dict) or expected.get("exit") != 0:
431 return False
432 if not isinstance(transport_requests, list):
433 return False
434 for request in transport_requests:
435 if not isinstance(request, dict) or "fault" in request:
436 return False
437 response = request.get("response")
438 if not isinstance(response, dict):
439 return False
440 status = int(response.get("status", 200))
441 if not (200 <= status <= 299):
442 return False
443 return True
444
445
446def compare_sets(label: str, required: set[str], actual: set[str]) -> list[str]:
447 errors: list[str] = []
448 missing = sorted(required - actual)
449 extra = sorted(actual - required)
450 if missing:
451 errors.append(f"{label} missing {missing!r}")
452 if extra:
453 errors.append(f"{label} extra {extra!r}")
454 return errors
455
456
457def check_top_level_import_cases(
458 entries: Any,
459 vectors: dict[str, dict[str, Any]],
460 resolved: dict[str, dict[str, Any]],
461) -> list[str]:
462 if not isinstance(entries, dict):
463 return []
464 errors: list[str] = []
465 for operation_id, entry in sorted(entries.items()):
466 if not isinstance(entry, dict):
467 errors.append(
468 f"{operation_id}: top-level applicability entry must be an object"
469 )
470 continue
471 case_ids = entry.get("case_ids", {})
472 if not isinstance(case_ids, dict):
473 errors.append(f"{operation_id}: top-level case_ids must be an object")
474 continue
475 for case_name, ids in sorted(case_ids.items()):
476 if not isinstance(ids, list) or not ids:
477 errors.append(
478 f"{operation_id}: top-level case {case_name} must be non-empty"
479 )
480 continue
481 for vector_id in ids:
482 if vector_id not in vectors:
483 errors.append(
484 f"{operation_id}: top-level case {case_name} unknown vector {vector_id!r}"
485 )
486 continue
487 mapped = resolved[vector_id].get("operation_id")
488 if mapped != operation_id:
489 errors.append(
490 f"{operation_id}: top-level case {case_name} vector "
491 f"{vector_id!r} maps to {mapped!r}"
492 )
493 return errors
494
495
496def check_applicability_requirements(
497 entries: dict[str, dict[str, Any]],
498 vectors: dict[str, dict[str, Any]],
499 resolved: dict[str, dict[str, Any]],
500 buckets: dict[str, set[str]],
501) -> list[str]:
502 errors: list[str] = []
503 for operation_id, entry in sorted(entries.items()):
504 for dimension in (
505 "pagination",
506 "upload",
507 "env_default",
508 "dry_run",
509 "multi_request",
510 ):
511 payload = entry.get(dimension)
512 if not isinstance(payload, dict) or not payload.get("enabled"):
513 continue
514 errors.extend(
515 check_named_cases(operation_id, dimension, payload, vectors, resolved)
516 )
517 if bool(entry.get("upload", {}).get("enabled")):
518 upload = entry["upload"]
519 operation_vectors = mapped_vectors(vectors, resolved, operation_id)
520 if not any(
521 "UPLOAD" in expected_request_methods(vector)
522 for vector in operation_vectors
523 ):
524 errors.append(f"{operation_id}: upload=true but no UPLOAD vector")
525 errors.extend(
526 check_required_case_names(
527 operation_id,
528 "upload",
529 upload,
530 ["missing_file", "unreadable_file", "rejection_before_mutation"],
531 )
532 )
533 if upload.get("file_count") == "multi" and not any(
534 has_later_upload_failure(vector) for vector in operation_vectors
535 ):
536 errors.append(
537 f"{operation_id}: upload multi has no later-file-failure vector"
538 )
539 if bool(entry.get("multi_request", {}).get("enabled")):
540 multi_request = entry["multi_request"]
541 operation_vectors = mapped_vectors(vectors, resolved, operation_id)
542 if not any(
543 expected_request_count(vector) > 1 for vector in operation_vectors
544 ):
545 errors.append(
546 f"{operation_id}: multi_request=true but no multi-request vector"
547 )
548 if multi_request_can_exceed_one(
549 multi_request.get("request_count")
550 ) and not any(
551 has_later_boundary_failure(vector) for vector in operation_vectors
552 ):
553 errors.append(
554 f"{operation_id}: multi_request has no later-boundary-failure vector"
555 )
556 if bool(entry.get("env_default", {}).get("enabled")):
557 env_default = entry["env_default"]
558 mode = env_default.get("mode", "required")
559 if mode in {"valid_absent", "valid-absent"}:
560 required_cases = ["explicit", "valid_absent"]
561 elif mode == "required":
562 required_cases = ["explicit", "absent"]
563 elif mode in {"required_with_invalid", "required-with-invalid"}:
564 required_cases = ["explicit", "absent", "invalid"]
565 else:
566 errors.append(
567 f"{operation_id}: env_default.mode {mode!r} is unsupported"
568 )
569 required_cases = []
570 errors.extend(
571 check_required_case_names(
572 operation_id, "env_default", env_default, required_cases
573 )
574 )
575 if bool(entry.get("dry_run", {}).get("enabled")):
576 if operation_id not in buckets["success"]:
577 errors.append(f"{operation_id}: dry_run=true but no success coverage")
578 if operation_id not in buckets["failure"]:
579 errors.append(f"{operation_id}: dry_run=true but no failure coverage")
580 return errors
581
582
583def check_required_case_names(
584 operation_id: str,
585 dimension: str,
586 payload: dict[str, Any],
587 required_cases: list[str],
588) -> list[str]:
589 case_ids = payload.get("case_ids", {})
590 if not isinstance(case_ids, dict):
591 return []
592 errors = []
593 for case_name in required_cases:
594 ids = case_ids.get(case_name)
595 if not isinstance(ids, list) or not ids:
596 errors.append(
597 f"{operation_id}: {dimension} missing required case {case_name}"
598 )
599 return errors
600
601
602def check_named_cases(
603 operation_id: str,
604 dimension: str,
605 payload: dict[str, Any],
606 vectors: dict[str, dict[str, Any]],
607 resolved: dict[str, dict[str, Any]],
608) -> list[str]:
609 errors: list[str] = []
610 case_ids = payload.get("case_ids", {})
611 if not isinstance(case_ids, dict):
612 errors.append(f"{operation_id}: {dimension}.case_ids must be an object")
613 return errors
614 for case_name, ids in sorted(case_ids.items()):
615 if not isinstance(ids, list) or not ids:
616 errors.append(
617 f"{operation_id}: {dimension}.case_ids.{case_name} must be non-empty"
618 )
619 continue
620 for vector_id in ids:
621 if vector_id not in vectors:
622 errors.append(
623 f"{operation_id}: {dimension}.case_ids.{case_name} unknown "
624 f"vector {vector_id!r}"
625 )
626 continue
627 mapped = resolved[vector_id].get("operation_id")
628 if mapped != operation_id:
629 errors.append(
630 f"{operation_id}: {dimension}.case_ids.{case_name} vector "
631 f"{vector_id!r} maps to {mapped!r}"
632 )
633 return errors
634
635
636def mapped_vectors(
637 vectors: dict[str, dict[str, Any]],
638 resolved: dict[str, dict[str, Any]],
639 operation_id: str,
640) -> list[dict[str, Any]]:
641 return [
642 vector
643 for vector_id, vector in vectors.items()
644 if resolved[vector_id].get("operation_id") == operation_id
645 ]
646
647
648def expected_request_methods(vector: dict[str, Any]) -> set[str]:
649 requests = vector.get("expected", {}).get("requests", [])
650 methods: set[str] = set()
651 for request in requests if isinstance(requests, list) else []:
652 if isinstance(request, dict) and isinstance(request.get("method"), str):
653 methods.add(request["method"])
654 return methods
655
656
657def expected_request_count(vector: dict[str, Any]) -> int:
658 requests = vector.get("expected", {}).get("requests", [])
659 return len(requests) if isinstance(requests, list) else 0
660
661
662def multi_request_can_exceed_one(value: Any) -> bool:
663 if isinstance(value, int):
664 return value > 1
665 if not isinstance(value, str):
666 return False
667 if value == "single":
668 return False
669 if ".." not in value:
670 try:
671 return int(value) > 1
672 except ValueError:
673 return True
674 _minimum, maximum = value.split("..", 1)
675 if maximum == "n":
676 return True
677 try:
678 return int(maximum) > 1
679 except ValueError:
680 return True
681
682
683def has_later_boundary_failure(vector: dict[str, Any]) -> bool:
684 requests = transport_requests(vector)
685 for index, request in enumerate(requests):
686 if index == 0 or not request_failed(request):
687 continue
688 if all(request_succeeded(previous) for previous in requests[:index]):
689 return True
690 return False
691
692
693def has_later_upload_failure(vector: dict[str, Any]) -> bool:
694 requests = transport_requests(vector)
695 upload_indices = [
696 index
697 for index, request in enumerate(requests)
698 if isinstance(request, dict) and request.get("method") == "UPLOAD"
699 ]
700 for upload_position, request_index in enumerate(upload_indices):
701 if upload_position == 0 or not request_failed(requests[request_index]):
702 continue
703 prior_uploads = (requests[index] for index in upload_indices[:upload_position])
704 if all(request_succeeded(request) for request in prior_uploads):
705 return True
706 return False
707
708
709def transport_requests(vector: dict[str, Any]) -> list[dict[str, Any]]:
710 requests = vector.get("transport", {}).get("requests", [])
711 return [request for request in requests if isinstance(request, dict)]
712
713
714def request_failed(request: dict[str, Any]) -> bool:
715 if "fault" in request:
716 return True
717 status = response_status(request)
718 return status is not None and not (200 <= status <= 299)
719
720
721def request_succeeded(request: dict[str, Any]) -> bool:
722 if "fault" in request:
723 return False
724 status = response_status(request)
725 return status is not None and 200 <= status <= 299
726
727
728def response_status(request: dict[str, Any]) -> int | None:
729 response = request.get("response")
730 if not isinstance(response, dict):
731 return None
732 return int(response.get("status", 200))
733
734
735if __name__ == "__main__":
736 raise SystemExit(main())