spck-conformance 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spck_conformance/__init__.py +2 -0
- spck_conformance/_bundle/conformance/checks/engine.py +157 -0
- spck_conformance/_bundle/conformance/checks/merchant.py +348 -0
- spck_conformance/_bundle/conformance/checks/merchant_checks.py +764 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-11/checkout-lifecycle.json +918 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-11/discount-consent-identity.json +627 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-11/discovery-negotiation.json +627 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-11/errors-validation-security.json +410 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-11/fulfillment.json +557 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-11/order.json +356 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-11/payment.json +626 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-23/checkout-lifecycle.json +75 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-23/discount-consent-identity.json +519 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-23/discovery-negotiation.json +50 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-23/errors-validation-security.json +322 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-23/fulfillment.json +47 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-23/order.json +280 -0
- spck_conformance/_bundle/conformance/requirements/2026-01-23/payment.json +518 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/cart.json +47 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/catalog.json +54 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/checkout-lifecycle.json +75 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/discounts-consent.json +52 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/discovery.json +90 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/error-envelope.json +53 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/fulfillment.json +47 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/identity-linking.json +854 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/negotiation-errors.json +62 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/order.json +49 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/payment.json +51 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/signals-attribution-eligibility.json +35 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/signatures.json +547 -0
- spck_conformance/_bundle/conformance/requirements/2026-04-08/totals.json +23 -0
- spck_conformance/_bundle/conformance/selfcheck/verdict_gate.py +160 -0
- spck_conformance/cli.py +26 -0
- spck_conformance-0.1.0.dist-info/METADATA +96 -0
- spck_conformance-0.1.0.dist-info/RECORD +39 -0
- spck_conformance-0.1.0.dist-info/WHEEL +5 -0
- spck_conformance-0.1.0.dist-info/entry_points.txt +2 -0
- spck_conformance-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
engine.py — Phase 1 register-driven conformance check engine.
|
|
4
|
+
|
|
5
|
+
A check cites the register requirement(s) it verifies, evaluates a real server
|
|
6
|
+
response, and declares the mutations that MUST break it. Before any check counts
|
|
7
|
+
toward a verdict, the engine self-validates it: the check must pass on the clean
|
|
8
|
+
response AND fail on every declared mutant (kill-rate). A check that isn't
|
|
9
|
+
mutation-safe is reported kill_safe=False, which the verdict gate downgrades to
|
|
10
|
+
inconclusive (so it can never produce a green). Results feed verdict_gate for an
|
|
11
|
+
honest, coverage-gated report.
|
|
12
|
+
|
|
13
|
+
Mutations here operate on the CAPTURED response (deterministic golden+mutation),
|
|
14
|
+
which works for stateful checks too — we mutate the final response under test.
|
|
15
|
+
The live mutation_proxy remains for integration/timing/replay cases.
|
|
16
|
+
"""
|
|
17
|
+
import json, sys, ssl, urllib.request, urllib.error, pathlib, glob
|
|
18
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "selfcheck"))
|
|
19
|
+
from verdict_gate import CheckResult, aggregate, CLEAN, DEVIATION, INCONCLUSIVE # noqa: E402
|
|
20
|
+
|
|
21
|
+
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
22
|
+
REQ_DIR = ROOT / "conformance" / "requirements"
|
|
23
|
+
|
|
24
|
+
# ---- TLS: use certifi's CA bundle if available (many Pythons ship no bundle, so a
|
|
25
|
+
# plain urlopen against https fails with CERTIFICATE_VERIFY_FAILED). --insecure skips
|
|
26
|
+
# verification entirely (testing only). -------------------------------------------
|
|
27
|
+
def _make_ssl_context(insecure=False):
|
|
28
|
+
if insecure:
|
|
29
|
+
ctx = ssl.create_default_context()
|
|
30
|
+
ctx.check_hostname = False
|
|
31
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
32
|
+
return ctx
|
|
33
|
+
try:
|
|
34
|
+
import certifi
|
|
35
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
36
|
+
except Exception:
|
|
37
|
+
return ssl.create_default_context()
|
|
38
|
+
|
|
39
|
+
INSECURE = False
|
|
40
|
+
_SSL_CTX = _make_ssl_context()
|
|
41
|
+
|
|
42
|
+
def set_insecure(v):
|
|
43
|
+
"""Toggle TLS verification (used by the --insecure CLI flag)."""
|
|
44
|
+
global INSECURE, _SSL_CTX
|
|
45
|
+
INSECURE = bool(v)
|
|
46
|
+
_SSL_CTX = _make_ssl_context(INSECURE)
|
|
47
|
+
|
|
48
|
+
class TLSError(Exception):
|
|
49
|
+
"""Raised on a TLS certificate verification failure, with a friendly hint."""
|
|
50
|
+
|
|
51
|
+
# ---- response capture -------------------------------------------------------
|
|
52
|
+
class Resp:
|
|
53
|
+
def __init__(self, status, headers, body):
|
|
54
|
+
self.status, self.headers, self.body = status, dict(headers), body
|
|
55
|
+
try: self.json = json.loads(body)
|
|
56
|
+
except Exception: self.json = None
|
|
57
|
+
def clone(self):
|
|
58
|
+
return Resp(self.status, dict(self.headers), self.body)
|
|
59
|
+
|
|
60
|
+
def fetch(base, path, method="GET", body=None, headers=None):
|
|
61
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
62
|
+
h = {"Content-Type": "application/json", **(headers or {})}
|
|
63
|
+
req = urllib.request.Request(base.rstrip("/") + path, data=data, method=method, headers=h)
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(req, context=_SSL_CTX) as r:
|
|
66
|
+
return Resp(r.status, r.getheaders(), r.read())
|
|
67
|
+
except urllib.error.HTTPError as e:
|
|
68
|
+
return Resp(e.code, e.headers.items(), e.read())
|
|
69
|
+
except Exception as e:
|
|
70
|
+
return Resp(0, {}, str(e).encode())
|
|
71
|
+
|
|
72
|
+
# ---- mutations on a captured response (defect injection) --------------------
|
|
73
|
+
def _reparse(r):
|
|
74
|
+
r.body = json.dumps(r.json).encode() if r.json is not None else r.body
|
|
75
|
+
return r
|
|
76
|
+
def mutate(resp, mut):
|
|
77
|
+
r = resp.clone()
|
|
78
|
+
name, _, arg = mut.partition(":")
|
|
79
|
+
if name == "status": r.status = int(arg); return r
|
|
80
|
+
if name == "empty": r.body = b""; r.json = None; return r
|
|
81
|
+
if name == "corrupt-json": r.body = (r.body or b"{}")[:-1]; r.json = None; return r
|
|
82
|
+
if name == "drop" and r.json is not None:
|
|
83
|
+
cur = r.json; parts = arg.split(".")
|
|
84
|
+
for p in parts[:-1]:
|
|
85
|
+
if isinstance(cur, list) and p.isdigit() and int(p) < len(cur): cur = cur[int(p)]
|
|
86
|
+
elif isinstance(cur, dict): cur = cur.get(p)
|
|
87
|
+
else: cur = None
|
|
88
|
+
if cur is None: break
|
|
89
|
+
last = parts[-1]
|
|
90
|
+
if isinstance(cur, dict): cur.pop(last, None)
|
|
91
|
+
elif isinstance(cur, list) and last.isdigit() and int(last) < len(cur): cur.pop(int(last))
|
|
92
|
+
return _reparse(r)
|
|
93
|
+
if name == "set" and r.json is not None:
|
|
94
|
+
path, _, v = arg.partition("="); val = json.loads(v)
|
|
95
|
+
cur = r.json; parts = path.split(".")
|
|
96
|
+
for p in parts[:-1]:
|
|
97
|
+
if isinstance(cur, list) and p.isdigit() and int(p) < len(cur): cur = cur[int(p)]
|
|
98
|
+
elif isinstance(cur, dict): cur = cur.get(p)
|
|
99
|
+
else: cur = None
|
|
100
|
+
if cur is None: break
|
|
101
|
+
last = parts[-1]
|
|
102
|
+
if isinstance(cur, dict): cur[last] = val
|
|
103
|
+
elif isinstance(cur, list) and last.isdigit() and int(last) < len(cur): cur[int(last)] = val
|
|
104
|
+
return _reparse(r)
|
|
105
|
+
if name == "hset": # set/replace a response header (case-insensitive): hset:Content-Type=text/plain
|
|
106
|
+
k, _, v = arg.partition("=")
|
|
107
|
+
for hk in [h for h in r.headers if h.lower() == k.lower()]: r.headers.pop(hk)
|
|
108
|
+
r.headers[k] = v; return r
|
|
109
|
+
if name == "hdrop": # remove a response header (case-insensitive)
|
|
110
|
+
for hk in [h for h in r.headers if h.lower() == arg.lower()]: r.headers.pop(hk)
|
|
111
|
+
return r
|
|
112
|
+
return r # no-op (mutation not applicable)
|
|
113
|
+
|
|
114
|
+
# ---- check spec -------------------------------------------------------------
|
|
115
|
+
class Check:
|
|
116
|
+
def __init__(self, cid, req_ids, keyword, fetch_fn, predicate, mutations):
|
|
117
|
+
self.id, self.req_ids, self.keyword = cid, req_ids, keyword
|
|
118
|
+
self.fetch_fn, self.predicate, self.mutations = fetch_fn, predicate, mutations
|
|
119
|
+
|
|
120
|
+
def run_check(chk, base):
|
|
121
|
+
"""Returns (results: list[CheckResult], detail: dict). kill_safe is computed by
|
|
122
|
+
mutation: clean must pass, every declared mutant must deviate."""
|
|
123
|
+
try:
|
|
124
|
+
golden = chk.fetch_fn(base)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
kill_safe = False
|
|
127
|
+
res = [CheckResult(rid, chk.keyword, INCONCLUSIVE, kill_safe) for rid in chk.req_ids]
|
|
128
|
+
return res, {"clean": "error:" + str(e), "kills": "", "kill_safe": kill_safe}
|
|
129
|
+
clean = chk.predicate(golden)
|
|
130
|
+
kills, survivors = 0, []
|
|
131
|
+
for m in chk.mutations:
|
|
132
|
+
if chk.predicate(mutate(golden, m)) == DEVIATION: kills += 1
|
|
133
|
+
else: survivors.append(m)
|
|
134
|
+
kill_safe = (clean == CLEAN and not survivors)
|
|
135
|
+
status = clean if kill_safe else (clean if clean == DEVIATION else INCONCLUSIVE)
|
|
136
|
+
res = [CheckResult(rid, chk.keyword, status, kill_safe) for rid in chk.req_ids]
|
|
137
|
+
return res, {"clean": clean, "kills": f"{kills}/{len(chk.mutations)}",
|
|
138
|
+
"kill_safe": kill_safe, "survivors": survivors}
|
|
139
|
+
|
|
140
|
+
# ---- inscope MUSTs from the register (coverage denominator) -----------------
|
|
141
|
+
def inscope_musts(version, transports=("rest", "any")):
|
|
142
|
+
ids = set()
|
|
143
|
+
for f in glob.glob(str(REQ_DIR / version / "*.json")):
|
|
144
|
+
for r in json.load(open(f)).get("rows", []):
|
|
145
|
+
if r["keyword"] in ("MUST", "MUST NOT") and r["testability"] == "testable" \
|
|
146
|
+
and any(t in transports for t in r.get("transport", [])):
|
|
147
|
+
ids.add(r["id"])
|
|
148
|
+
return ids
|
|
149
|
+
|
|
150
|
+
def run_report(checks, base, version, scope_stamp, disclaimer, transports=("rest", "any")):
|
|
151
|
+
all_results, details = [], []
|
|
152
|
+
for chk in checks:
|
|
153
|
+
res, det = run_check(chk, base)
|
|
154
|
+
all_results += res
|
|
155
|
+
details.append((chk, det))
|
|
156
|
+
rep = aggregate(all_results, inscope_musts(version, transports), scope_stamp, disclaimer)
|
|
157
|
+
return rep, details
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
merchant.py — Phase A: the MERCHANT-AGNOSTIC conformance runner.
|
|
4
|
+
|
|
5
|
+
Point it at ANY UCP server. It discovers the server's spec version + declared
|
|
6
|
+
capabilities from /.well-known/ucp, then runs only the checks that apply:
|
|
7
|
+
* discovery/structural checks run on every server (no seeded data);
|
|
8
|
+
* extension checks (fulfillment, discount, catalog, cart) run ONLY if the server
|
|
9
|
+
declares that capability — otherwise `not-applicable` (never a deviation);
|
|
10
|
+
* data-dependent lifecycle checks run against an auto-discovered product (catalog
|
|
11
|
+
search) or a product id from an optional merchant config; otherwise `not-tested`.
|
|
12
|
+
|
|
13
|
+
The verdict denominator is the set of APPLICABLE testable MUSTs (extensions a server
|
|
14
|
+
doesn't implement are excluded), so a lean-but-correct merchant scores honestly.
|
|
15
|
+
|
|
16
|
+
merchant.py --server https://api.example.com [--config merchant.json] [--json]
|
|
17
|
+
"""
|
|
18
|
+
import sys, json, argparse, pathlib, urllib.request, urllib.error, glob
|
|
19
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
20
|
+
sys.path.insert(0, str(HERE))
|
|
21
|
+
from engine import fetch, Resp # noqa: E402
|
|
22
|
+
sys.path.insert(0, str(HERE.parents[0] / "selfcheck"))
|
|
23
|
+
from verdict_gate import aggregate # noqa: E402
|
|
24
|
+
import merchant_checks # noqa: E402
|
|
25
|
+
REQ_DIR = HERE.parents[0] / "requirements"
|
|
26
|
+
|
|
27
|
+
# register area -> the capability a server must declare for that area to be in-scope
|
|
28
|
+
AREA_CAPABILITY = {
|
|
29
|
+
"fulfillment": "dev.ucp.shopping.fulfillment",
|
|
30
|
+
"discount-consent-identity": "dev.ucp.shopping.discount",
|
|
31
|
+
"discounts-consent": "dev.ucp.shopping.discount",
|
|
32
|
+
"catalog": "dev.ucp.shopping.catalog.search",
|
|
33
|
+
"cart": "dev.ucp.shopping.cart",
|
|
34
|
+
"signals-attribution-eligibility": None, # informational; treat as core
|
|
35
|
+
"order": "dev.ucp.shopping.order",
|
|
36
|
+
}
|
|
37
|
+
CORE_CAP = "dev.ucp.shopping.checkout"
|
|
38
|
+
|
|
39
|
+
class MerchantCtx:
|
|
40
|
+
def __init__(self, base, profile, config):
|
|
41
|
+
self.base = base.rstrip("/")
|
|
42
|
+
self.profile = profile
|
|
43
|
+
self.config = config or {}
|
|
44
|
+
ucp = profile.get("ucp", profile) # profile may or may not nest under "ucp"
|
|
45
|
+
self.version = ucp.get("version")
|
|
46
|
+
caps = ucp.get("capabilities")
|
|
47
|
+
# Spec requires capabilities to be a keyed object of reverse-domain names.
|
|
48
|
+
# Never crash on a non-conformant shape (a real sample ships a list): treat it
|
|
49
|
+
# as no declared capabilities (extension checks -> not-applicable) and flag it,
|
|
50
|
+
# so the profile-structure check can report the deviation instead of exploding.
|
|
51
|
+
if isinstance(caps, dict):
|
|
52
|
+
self.capabilities = set(caps.keys())
|
|
53
|
+
self.caps_malformed = False
|
|
54
|
+
else:
|
|
55
|
+
self.capabilities = set()
|
|
56
|
+
self.caps_malformed = caps is not None
|
|
57
|
+
svc = (ucp.get("services") or {}).get("dev.ucp.shopping") or []
|
|
58
|
+
rest = next((s for s in svc if isinstance(s, dict) and s.get("transport") == "rest"), None)
|
|
59
|
+
# A server MAY offer only MCP/embedded transports and still be fully conformant.
|
|
60
|
+
# This runner is REST-scoped, so absence of a REST transport makes the REST
|
|
61
|
+
# lifecycle out-of-scope (not-applicable), never a deviation.
|
|
62
|
+
self.has_rest = rest is not None
|
|
63
|
+
self.transports = [s.get("transport") for s in svc if isinstance(s, dict)]
|
|
64
|
+
self.shopping_endpoint = (rest or {}).get("endpoint", self.base)
|
|
65
|
+
self.product_id = self.config.get("product_id")
|
|
66
|
+
|
|
67
|
+
def discover(base):
|
|
68
|
+
import engine
|
|
69
|
+
try:
|
|
70
|
+
with urllib.request.urlopen(base.rstrip("/") + "/.well-known/ucp", timeout=10,
|
|
71
|
+
context=engine._SSL_CTX) as r:
|
|
72
|
+
return json.loads(r.read()), r.headers.get("Content-Type", "")
|
|
73
|
+
except Exception as e:
|
|
74
|
+
if "CERTIFICATE_VERIFY_FAILED" in str(e):
|
|
75
|
+
raise SystemExit(
|
|
76
|
+
f"discovery failed for {base}: TLS certificate verification failed.\n"
|
|
77
|
+
f" Your Python has no CA bundle. Fix with one of:\n"
|
|
78
|
+
f" • pip install certifi (spck-conformance uses it automatically)\n"
|
|
79
|
+
f" • macOS python.org build: run 'Install Certificates.command'\n"
|
|
80
|
+
f" • re-run with --insecure (skips TLS verification; testing only)")
|
|
81
|
+
raise SystemExit(f"discovery failed for {base}: {e}")
|
|
82
|
+
|
|
83
|
+
def auto_discover_product(ctx):
|
|
84
|
+
"""If the server supports catalog.search, find a real product id to drive lifecycle."""
|
|
85
|
+
if ctx.product_id:
|
|
86
|
+
return ctx.product_id
|
|
87
|
+
if not ctx.has_rest: # REST catalog search only; never probe a non-REST store
|
|
88
|
+
return None
|
|
89
|
+
if "dev.ucp.shopping.catalog.search" not in ctx.capabilities:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
r = fetch(ctx.shopping_endpoint, "/catalog/search", "POST", {"query": "*"},
|
|
93
|
+
{"UCP-Agent": 'profile="https://spck.dev/agent"'})
|
|
94
|
+
prods = (r.json or {}).get("products") or []
|
|
95
|
+
if prods:
|
|
96
|
+
v = (prods[0].get("variants") or [{}])[0]
|
|
97
|
+
return v.get("id") or prods[0].get("id")
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def applicable_areas(ctx):
|
|
103
|
+
"""Which register areas are in scope for THIS server, given its declared capabilities."""
|
|
104
|
+
out = {}
|
|
105
|
+
for area, cap in AREA_CAPABILITY.items():
|
|
106
|
+
out[area] = (cap is None) or (cap in ctx.capabilities) or (cap == CORE_CAP)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
def report(base, config=None):
|
|
110
|
+
profile, ctype = discover(base)
|
|
111
|
+
ctx = MerchantCtx(base, profile, config)
|
|
112
|
+
ctx.product_id = auto_discover_product(ctx)
|
|
113
|
+
areas = applicable_areas(ctx)
|
|
114
|
+
return ctx, ctype, areas
|
|
115
|
+
|
|
116
|
+
def applicable_musts(ctx, areas):
|
|
117
|
+
"""Testable MUSTs from the merchant's spec-version register, restricted to areas
|
|
118
|
+
the server implements (unsupported extensions are excluded from the denominator)."""
|
|
119
|
+
ids = set()
|
|
120
|
+
vdir = REQ_DIR / (ctx.version or "")
|
|
121
|
+
if not vdir.is_dir():
|
|
122
|
+
return ids
|
|
123
|
+
for f in glob.glob(str(vdir / "*.json")):
|
|
124
|
+
area = json.load(open(f)).get("_area", "?")
|
|
125
|
+
if areas.get(area, True) is False:
|
|
126
|
+
continue
|
|
127
|
+
for r in json.load(open(f)).get("rows", []):
|
|
128
|
+
if r["keyword"] in ("MUST", "MUST NOT") and r["testability"] == "testable" \
|
|
129
|
+
and any(t in ("rest", "any") for t in r.get("transport", [])):
|
|
130
|
+
ids.add(r["id"])
|
|
131
|
+
return ids
|
|
132
|
+
|
|
133
|
+
SCOPE = {"tool": "spck.dev merchant conformance (dev)",
|
|
134
|
+
"methodology": "discovery-driven, capability-adaptive, kill-rate-gated"}
|
|
135
|
+
DISCLAIMER = ("Unofficial. Not affiliated with or endorsed by the UCP project. A pass "
|
|
136
|
+
"reflects only the checks run against this server; not certified compliance.")
|
|
137
|
+
|
|
138
|
+
def run_conformance(ctx, areas):
|
|
139
|
+
results, detail = merchant_checks.run_merchant_checks(ctx)
|
|
140
|
+
stamp = {**SCOPE, "spec_version": ctx.version, "server": ctx.base}
|
|
141
|
+
rep = aggregate(results, applicable_musts(ctx, areas), stamp, DISCLAIMER)
|
|
142
|
+
return rep, detail
|
|
143
|
+
|
|
144
|
+
# Synthetic (non-register) check ids get a plain-language description so their reports
|
|
145
|
+
# are still actionable (e.g. the holistic profile-schema validation).
|
|
146
|
+
SYNTHETIC_REQS = {
|
|
147
|
+
"DISC-000": {"keyword": "MUST", "source": "ucp:source/schemas/ucp.json",
|
|
148
|
+
"requirement": "The /.well-known/ucp profile document MUST validate against "
|
|
149
|
+
"the official ucp.json profile schema (structure of version, "
|
|
150
|
+
"services array, and reverse-domain-keyed capabilities object)."},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def scaffold_config(ctx):
|
|
154
|
+
"""Build a starter --config for THIS server: detected product + FILL_ME placeholders
|
|
155
|
+
for the data-dependent inputs each declared capability needs. Friendly onboarding."""
|
|
156
|
+
caps = ctx.capabilities
|
|
157
|
+
def pay(desc):
|
|
158
|
+
return {"payment": {"instruments": [{"id": "instr_1", "handler_id": "FILL_ME: handler id",
|
|
159
|
+
"type": "card", "display": {"brand": "Visa", "last_digits": "1234"},
|
|
160
|
+
"credential": {"type": "token", "token": desc},
|
|
161
|
+
"billing_address": {"street_address": "1 Main St", "address_locality": "Town",
|
|
162
|
+
"address_region": "CA", "address_country": "US", "postal_code": "12345"}}]},
|
|
163
|
+
"risk_signals": {}}
|
|
164
|
+
cfg = {"product_id": ctx.product_id or "FILL_ME: an in-stock product id",
|
|
165
|
+
"currency": "USD"}
|
|
166
|
+
if "dev.ucp.shopping.checkout" in caps:
|
|
167
|
+
cfg["out_of_stock_id"] = "FILL_ME: a product id known to be out of stock"
|
|
168
|
+
if "dev.ucp.shopping.order" in caps:
|
|
169
|
+
cfg["fulfillment_option_id"] = "FILL_ME: a valid fulfillment option id"
|
|
170
|
+
cfg["complete_payment"] = pay("FILL_ME: a payment token that SUCCEEDS")
|
|
171
|
+
cfg["fail_payment"] = pay("FILL_ME: a payment token that FAILS (expect 402)")
|
|
172
|
+
if "dev.ucp.shopping.discount" in caps:
|
|
173
|
+
cfg["discount"] = {"valid_code": "FILL_ME: a real discount code",
|
|
174
|
+
"second_valid_code": "FILL_ME: another real code",
|
|
175
|
+
"invalid_code": "NOT_A_REAL_CODE"}
|
|
176
|
+
if "dev.ucp.shopping.catalog.lookup" in caps:
|
|
177
|
+
cfg["catalog"] = {"variant_id": ctx.product_id or "FILL_ME: a variant id"}
|
|
178
|
+
return cfg
|
|
179
|
+
|
|
180
|
+
def req_meta(version):
|
|
181
|
+
"""req_id -> {requirement, source} from the register, so every result cites its
|
|
182
|
+
normative clause (the trust story: each check traces to a verbatim spec quote)."""
|
|
183
|
+
meta = dict(SYNTHETIC_REQS)
|
|
184
|
+
vdir = REQ_DIR / (version or "")
|
|
185
|
+
if vdir.is_dir():
|
|
186
|
+
for f in glob.glob(str(vdir / "*.json")):
|
|
187
|
+
for r in json.load(open(f)).get("rows", []):
|
|
188
|
+
meta[r["id"]] = {"requirement": r.get("requirement", ""),
|
|
189
|
+
"source": r.get("source", ""), "keyword": r.get("keyword", "")}
|
|
190
|
+
return meta
|
|
191
|
+
|
|
192
|
+
def _citations(req_ids, meta):
|
|
193
|
+
return [{"id": rid, **meta.get(rid, {"requirement": "", "source": ""})} for rid in req_ids]
|
|
194
|
+
|
|
195
|
+
def _xml_escape(s):
|
|
196
|
+
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
197
|
+
.replace('"', """))
|
|
198
|
+
|
|
199
|
+
def junit_xml(ctx, detail, meta):
|
|
200
|
+
"""Render results as JUnit XML so any CI can consume the report as a test run.
|
|
201
|
+
deviation -> <failure>; not-applicable/not-tested -> <skipped>; clean -> pass."""
|
|
202
|
+
cases, n_fail, n_skip = [], 0, 0
|
|
203
|
+
for c, d in detail:
|
|
204
|
+
st = str(d["status"])
|
|
205
|
+
cites = "; ".join(f"{x['id']}: {x['requirement']} [{x['source']}]"
|
|
206
|
+
for x in _citations(c.req_ids, meta))
|
|
207
|
+
name = f"{c.id} ({', '.join(c.req_ids)})"
|
|
208
|
+
body = ""
|
|
209
|
+
if st == "deviation":
|
|
210
|
+
n_fail += 1
|
|
211
|
+
obs = d.get("observed") or {}
|
|
212
|
+
evidence = (f"expected: {cites}\nobserved: HTTP {obs.get('status')} "
|
|
213
|
+
f"body: {obs.get('body')}")
|
|
214
|
+
body = (f'\n <failure message="MUST violated: {_xml_escape(c.req_ids)}">'
|
|
215
|
+
f'{_xml_escape(evidence)}</failure>\n ')
|
|
216
|
+
elif st.startswith(("not-applicable", "not-tested")):
|
|
217
|
+
n_skip += 1
|
|
218
|
+
body = f'\n <skipped message="{_xml_escape(st)}"/>\n '
|
|
219
|
+
cases.append(f' <testcase classname="ucp.{_xml_escape(ctx.version)}" '
|
|
220
|
+
f'name="{_xml_escape(name)}">{body}</testcase>')
|
|
221
|
+
suite = (f' <testsuite name="ucp-merchant-conformance" tests="{len(detail)}" '
|
|
222
|
+
f'failures="{n_fail}" skipped="{n_skip}" '
|
|
223
|
+
f'hostname="{_xml_escape(ctx.base)}">\n' + "\n".join(cases) + "\n </testsuite>")
|
|
224
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n' + suite + "\n</testsuites>\n"
|
|
225
|
+
|
|
226
|
+
def main():
|
|
227
|
+
ap = argparse.ArgumentParser(description="Merchant-agnostic UCP conformance (unofficial).")
|
|
228
|
+
ap.add_argument("--server", required=True)
|
|
229
|
+
ap.add_argument("--config", help="optional merchant config JSON (product_id, out_of_stock_id, fail_token, discount_codes, ...)")
|
|
230
|
+
ap.add_argument("--json", action="store_true", help="emit the report as JSON")
|
|
231
|
+
ap.add_argument("--junit", metavar="FILE", help="write a JUnit XML report to FILE (for CI)")
|
|
232
|
+
ap.add_argument("--init", nargs="?", const="merchant.json", metavar="FILE",
|
|
233
|
+
help="probe the server and scaffold a starter --config to FILE, then exit")
|
|
234
|
+
ap.add_argument("--insecure", action="store_true",
|
|
235
|
+
help="skip TLS certificate verification (testing only)")
|
|
236
|
+
args = ap.parse_args()
|
|
237
|
+
if args.insecure:
|
|
238
|
+
import engine; engine.set_insecure(True)
|
|
239
|
+
config = json.loads(pathlib.Path(args.config).read_text()) if args.config else {}
|
|
240
|
+
ctx, ctype, areas = report(args.server, config)
|
|
241
|
+
if args.init:
|
|
242
|
+
cfg = scaffold_config(ctx)
|
|
243
|
+
pathlib.Path(args.init).write_text(json.dumps(cfg, indent=2) + "\n")
|
|
244
|
+
fills = sum(1 for v in json.dumps(cfg).split('"') if v.startswith("FILL_ME"))
|
|
245
|
+
print(f"Wrote {args.init} for {ctx.base} (spec {ctx.version}).")
|
|
246
|
+
print(f" detected capabilities: {', '.join(sorted(ctx.capabilities)) or '(none)'}")
|
|
247
|
+
print(f" product for lifecycle: {ctx.product_id or '(none auto-discovered)'}")
|
|
248
|
+
print(f"\nNext: replace the {fills} FILL_ME placeholder(s) with real values, then run:")
|
|
249
|
+
print(f" spck-conformance --server {ctx.base} --config {args.init}")
|
|
250
|
+
return 0
|
|
251
|
+
meta = req_meta(ctx.version)
|
|
252
|
+
supported = sorted(c for c in ctx.capabilities)
|
|
253
|
+
out = {
|
|
254
|
+
"server": ctx.base, "spec_version": ctx.version,
|
|
255
|
+
"content_type_json": "application/json" in ctype.lower(),
|
|
256
|
+
"capabilities": supported,
|
|
257
|
+
"shopping_endpoint": ctx.shopping_endpoint,
|
|
258
|
+
"product_for_lifecycle": ctx.product_id,
|
|
259
|
+
"applicable_areas": {a: v for a, v in areas.items()},
|
|
260
|
+
}
|
|
261
|
+
rep, detail = run_conformance(ctx, areas)
|
|
262
|
+
cc = rep.counts
|
|
263
|
+
out["verdict"] = {"aggregate": rep.aggregate, "coverage": rep.coverage,
|
|
264
|
+
"applicable_musts": cc["inscope_musts"], "musts_passed": cc["musts_clean_pass"],
|
|
265
|
+
"deviations": cc["deviations"]}
|
|
266
|
+
out["checks"] = [{"id": c.id, "req_ids": c.req_ids, "capability": c.capability,
|
|
267
|
+
"status": d["status"], "kill_safe": d["kill_safe"],
|
|
268
|
+
"requirements": _citations(c.req_ids, meta),
|
|
269
|
+
# actionable evidence: what the server actually returned
|
|
270
|
+
"observed": d.get("observed")} for c, d in detail]
|
|
271
|
+
out["disclaimer"] = DISCLAIMER
|
|
272
|
+
# exit code for CI: 2 if any MUST deviation, else 0 (partial coverage is not a failure)
|
|
273
|
+
rc = 2 if cc["deviations"] else 0
|
|
274
|
+
if args.junit:
|
|
275
|
+
pathlib.Path(args.junit).write_text(junit_xml(ctx, detail, meta))
|
|
276
|
+
if args.json:
|
|
277
|
+
print(json.dumps(out, indent=2)); return rc
|
|
278
|
+
# ---- grouped, scannable report ------------------------------------------
|
|
279
|
+
passed = [(c, d) for c, d in detail if d["status"] == "clean-pass"]
|
|
280
|
+
devs = [(c, d) for c, d in detail if d["status"] == "deviation"]
|
|
281
|
+
nottest = [(c, d) for c, d in detail if str(d["status"]).startswith("not-tested")]
|
|
282
|
+
napp = [(c, d) for c, d in detail if str(d["status"]).startswith("not-applicable")]
|
|
283
|
+
other = [(c, d) for c, d in detail if (c, d) not in passed + devs + nottest + napp]
|
|
284
|
+
|
|
285
|
+
print(f"═══ UCP conformance report (unofficial) — {ctx.base} ═══")
|
|
286
|
+
print(f" spec {ctx.version} · endpoint {ctx.shopping_endpoint}")
|
|
287
|
+
print(f" capabilities: {', '.join(supported) or '(none declared)'}")
|
|
288
|
+
print(f" product for lifecycle: {ctx.product_id or '(none)'}\n")
|
|
289
|
+
print(f" VERDICT: {rep.aggregate.upper()} — "
|
|
290
|
+
f"{cc['musts_clean_pass']}/{cc['inscope_musts']} applicable MUSTs "
|
|
291
|
+
f"({round(100*rep.coverage)}%), {cc['deviations']} deviation(s)")
|
|
292
|
+
print(f" [{len(passed)} passed · {len(devs)} deviations · {len(nottest)} not-tested "
|
|
293
|
+
f"· {len(napp)} not-applicable]")
|
|
294
|
+
|
|
295
|
+
if devs:
|
|
296
|
+
print(f"\n ✗ DEVIATIONS ({len(devs)}) — a MUST was violated:")
|
|
297
|
+
for c, d in devs:
|
|
298
|
+
print(f" {c.id}")
|
|
299
|
+
for x in _citations(c.req_ids, meta):
|
|
300
|
+
print(f" expected {x['id']}: {x['requirement']}")
|
|
301
|
+
print(f" spec {x['source']}")
|
|
302
|
+
obs = d.get("observed") or {}
|
|
303
|
+
print(f" observed HTTP {obs.get('status')} body: {obs.get('body')}")
|
|
304
|
+
if passed:
|
|
305
|
+
print(f"\n ✓ PASSED ({len(passed)}) — satisfied & kill-safe:")
|
|
306
|
+
for c, _ in passed:
|
|
307
|
+
print(f" {c.id}")
|
|
308
|
+
if nottest:
|
|
309
|
+
print(f"\n ⊘ NOT TESTED ({len(nottest)}) — need config/data (not a failure):")
|
|
310
|
+
for c, d in nottest:
|
|
311
|
+
reason = str(d["status"]).replace("not-tested", "").strip("() ")
|
|
312
|
+
print(f" {c.id:30} {reason or 'needs data'}")
|
|
313
|
+
if napp:
|
|
314
|
+
print(f"\n — NOT APPLICABLE ({len(napp)}) — capability/transport not declared:")
|
|
315
|
+
print(f" {', '.join(c.id for c, _ in napp)}")
|
|
316
|
+
if other:
|
|
317
|
+
print(f"\n ? INCONCLUSIVE ({len(other)}):")
|
|
318
|
+
for c, d in other:
|
|
319
|
+
print(f" {c.id:30} {d['status']}")
|
|
320
|
+
if args.junit:
|
|
321
|
+
print(f"\n JUnit report written to {args.junit}")
|
|
322
|
+
|
|
323
|
+
# ---- friendly next steps -------------------------------------------------
|
|
324
|
+
n_pass = sum(1 for _, d in detail if d["status"] == "clean-pass")
|
|
325
|
+
n_dev = cc["deviations"]
|
|
326
|
+
needed = set()
|
|
327
|
+
for c, d in detail:
|
|
328
|
+
st = str(d["status"])
|
|
329
|
+
if st.startswith("not-tested (needs config"):
|
|
330
|
+
needed.update(st.split("needs config:")[1].rstrip(")").strip().split(","))
|
|
331
|
+
print("\n Next steps:")
|
|
332
|
+
if n_dev:
|
|
333
|
+
print(f" • Fix {n_dev} MUST deviation(s) above — each shows expected vs the observed response.")
|
|
334
|
+
if needed:
|
|
335
|
+
keys = ", ".join(sorted(k.strip() for k in needed if k.strip()))
|
|
336
|
+
hint = "--init to scaffold one" if not args.config else f"add: {keys}"
|
|
337
|
+
print(f" • Unlock more checks by supplying config ({hint}).")
|
|
338
|
+
if ctx.product_id is None and "dev.ucp.shopping.checkout" in ctx.capabilities:
|
|
339
|
+
print(f" • Set config.product_id to a real in-stock product to run the lifecycle checks.")
|
|
340
|
+
if not n_dev and not needed:
|
|
341
|
+
print(f" • Looking good — {n_pass} checks clean-pass, 0 deviations for the checks run.")
|
|
342
|
+
if not args.config:
|
|
343
|
+
print(f" • Tip: `spck-conformance --server {ctx.base} --init` scaffolds a config for this server.")
|
|
344
|
+
print(f"\n {DISCLAIMER}")
|
|
345
|
+
return rc
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
sys.exit(main())
|