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.
Files changed (39) hide show
  1. spck_conformance/__init__.py +2 -0
  2. spck_conformance/_bundle/conformance/checks/engine.py +157 -0
  3. spck_conformance/_bundle/conformance/checks/merchant.py +348 -0
  4. spck_conformance/_bundle/conformance/checks/merchant_checks.py +764 -0
  5. spck_conformance/_bundle/conformance/requirements/2026-01-11/checkout-lifecycle.json +918 -0
  6. spck_conformance/_bundle/conformance/requirements/2026-01-11/discount-consent-identity.json +627 -0
  7. spck_conformance/_bundle/conformance/requirements/2026-01-11/discovery-negotiation.json +627 -0
  8. spck_conformance/_bundle/conformance/requirements/2026-01-11/errors-validation-security.json +410 -0
  9. spck_conformance/_bundle/conformance/requirements/2026-01-11/fulfillment.json +557 -0
  10. spck_conformance/_bundle/conformance/requirements/2026-01-11/order.json +356 -0
  11. spck_conformance/_bundle/conformance/requirements/2026-01-11/payment.json +626 -0
  12. spck_conformance/_bundle/conformance/requirements/2026-01-23/checkout-lifecycle.json +75 -0
  13. spck_conformance/_bundle/conformance/requirements/2026-01-23/discount-consent-identity.json +519 -0
  14. spck_conformance/_bundle/conformance/requirements/2026-01-23/discovery-negotiation.json +50 -0
  15. spck_conformance/_bundle/conformance/requirements/2026-01-23/errors-validation-security.json +322 -0
  16. spck_conformance/_bundle/conformance/requirements/2026-01-23/fulfillment.json +47 -0
  17. spck_conformance/_bundle/conformance/requirements/2026-01-23/order.json +280 -0
  18. spck_conformance/_bundle/conformance/requirements/2026-01-23/payment.json +518 -0
  19. spck_conformance/_bundle/conformance/requirements/2026-04-08/cart.json +47 -0
  20. spck_conformance/_bundle/conformance/requirements/2026-04-08/catalog.json +54 -0
  21. spck_conformance/_bundle/conformance/requirements/2026-04-08/checkout-lifecycle.json +75 -0
  22. spck_conformance/_bundle/conformance/requirements/2026-04-08/discounts-consent.json +52 -0
  23. spck_conformance/_bundle/conformance/requirements/2026-04-08/discovery.json +90 -0
  24. spck_conformance/_bundle/conformance/requirements/2026-04-08/error-envelope.json +53 -0
  25. spck_conformance/_bundle/conformance/requirements/2026-04-08/fulfillment.json +47 -0
  26. spck_conformance/_bundle/conformance/requirements/2026-04-08/identity-linking.json +854 -0
  27. spck_conformance/_bundle/conformance/requirements/2026-04-08/negotiation-errors.json +62 -0
  28. spck_conformance/_bundle/conformance/requirements/2026-04-08/order.json +49 -0
  29. spck_conformance/_bundle/conformance/requirements/2026-04-08/payment.json +51 -0
  30. spck_conformance/_bundle/conformance/requirements/2026-04-08/signals-attribution-eligibility.json +35 -0
  31. spck_conformance/_bundle/conformance/requirements/2026-04-08/signatures.json +547 -0
  32. spck_conformance/_bundle/conformance/requirements/2026-04-08/totals.json +23 -0
  33. spck_conformance/_bundle/conformance/selfcheck/verdict_gate.py +160 -0
  34. spck_conformance/cli.py +26 -0
  35. spck_conformance-0.1.0.dist-info/METADATA +96 -0
  36. spck_conformance-0.1.0.dist-info/RECORD +39 -0
  37. spck_conformance-0.1.0.dist-info/WHEEL +5 -0
  38. spck_conformance-0.1.0.dist-info/entry_points.txt +2 -0
  39. spck_conformance-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2 @@
1
+ """spck-conformance — unofficial capability-adaptive UCP conformance runner."""
2
+ __version__ = "0.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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
197
+ .replace('"', "&quot;"))
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())